Repository navigation
Adding a New Provider
Developer guide for integration 1.2.x · branch
HA2026_09_devThis replaces the older guide, which is preserved at Extend the integration to support a new energy provider (archived).
Adding a grid operator means writing one adapter class that translates a portal into the internal contract. The coordinator, the config flow and the sensor platform are provider-agnostic and should not need to be touched — see ADD AD-2/AD-3.
| File | Change |
|---|---|
custom_components/asm/const.py |
Add the provider id and its display name to PROVIDERS. |
custom_components/asm/api/client_yourprovider.py |
New. The adapter. |
custom_components/asm/api/client.py |
Register the adapter in get_client(). |
Optionally add a translation label for the provider name — the provider name shown in the
dropdown comes from PROVIDERS, so no translation file change is needed for the value
itself. If you want the label localised, add a selector translation later; the current
code renders PROVIDERS directly.
# custom_components/asm/const.py
PROVIDER_WIENER_NETZE = "wiener_netze"
PROVIDER_NETZ_NOE = "netz_noe"
PROVIDER_YOURPROVIDER = "yourprovider" # <-- add
PROVIDERS = {
PROVIDER_WIENER_NETZE: "Wiener Netze",
PROVIDER_NETZ_NOE: "Netz Niederösterreich (EVN)",
PROVIDER_YOURPROVIDER: "Your Provider", # <-- add
}The key is what ends up in the config entry, so treat it as permanent — changing it later
invalidates existing entries' unique_id.
Create custom_components/asm/api/client_yourprovider.py.
- No Home Assistant imports. Adapters are plain Python and therefore testable without the framework.
-
Synchronous is fine. Every call is executed by the coordinator in the executor, so
use
requests(guaranteed by Core — do not add it tomanifest.json). -
Raise the right error.
SmartmeterLoginErrorfor credentials problems, everything else about transport/format asSmartmeterConnectionError, "authenticated but unusable" asSmartmeterQueryError(all fromapi/errors.py). -
Normalise to Wh. The sensor platform declares
Whand never converts. - Never log credentials.
"""Your Provider API client."""
from __future__ import annotations
import logging
from datetime import date, datetime, timedelta
from typing import Any
import requests
from .base import SmartmeterClient
from .errors import (
SmartmeterConnectionError,
SmartmeterLoginError,
SmartmeterQueryError,
)
LOGGER = logging.getLogger(__name__)
BASE_URL = "https://portal.example.at/api"
REQUEST_TIMEOUT = 30
SESSION_MAX_AGE = timedelta(minutes=30) # model the session lifetime explicitly
KWH_TO_WH = 1000
UNIT_WH = "Wh"
class YourProviderClient(SmartmeterClient):
"""Client for Your Provider."""
def __init__(self, username, password):
super().__init__(username, password)
self.session = requests.Session()
self._login_time: datetime | None = None
# ------------------------------------------------------------- session
def is_logged_in(self) -> bool:
return self._login_time is not None and not self.is_login_expired()
def is_login_expired(self) -> bool:
if self._login_time is None:
return True
return datetime.now() - self._login_time >= SESSION_MAX_AGE
def _reset(self) -> None:
self.session.cookies.clear()
self._login_time = None
def _ensure_session(self) -> None:
if not self.is_logged_in():
self._reset()
self.login()
# ---------------------------------------------------------------- http
def _request(self, method: str, url: str, **kwargs: Any) -> requests.Response:
try:
return self.session.request(method, url, timeout=REQUEST_TIMEOUT, **kwargs)
except requests.exceptions.RequestException as err:
raise SmartmeterConnectionError(f"Request to {url} failed: {err}") from err
def _get_json(self, url: str, **kwargs: Any) -> Any:
response = self._request("GET", url, **kwargs)
if response.status_code != 200:
raise SmartmeterConnectionError(f"{url} returned HTTP {response.status_code}.")
try:
return response.json()
except ValueError as err:
# Portals answer with HTML when an endpoint does not exist. Report that
# instead of the useless "Expecting value: line 1 column 1 (char 0)".
snippet = " ".join(response.text.split())[:200]
raise SmartmeterConnectionError(
f"{url} returned a non-JSON response "
f"(HTTP {response.status_code}): {snippet!r}"
) from err
# --------------------------------------------------------------- login
def login(self):
response = self._request(
"POST", f"{BASE_URL}/Login", json={"user": self.username, "pwd": self.password}
)
payload = self._safe_json(response)
if response.status_code in (401, 403):
raise SmartmeterLoginError("Login failed. Check username and password.")
if response.status_code != 200:
raise SmartmeterConnectionError(
f"Login endpoint returned HTTP {response.status_code}."
)
if isinstance(payload, dict) and not payload.get("success", True):
raise SmartmeterLoginError("Login failed. Check username and password.")
self._login_time = datetime.now()
return self
# ----------------------------------------------------------- contract
def zaehlpunkte(self) -> list[dict[str, Any]]:
self._ensure_session()
raw = self._get_json(f"{BASE_URL}/MeteringPoints")
meters = [_normalise_metering_point(item) for item in raw or []]
if not meters:
raise SmartmeterQueryError("No metering points found in this account.")
return [{"zaehlpunkte": meters}]
def consumptions(self) -> list[dict[str, Any]]:
"""Return daily statistics, or [] when the portal has none."""
return []
def historical_data(
self, zaehlpunktnummer: str, date_from: date = None, date_until: date = None
) -> list[dict[str, Any]]:
self._ensure_session()
if date_until is None:
date_until = date.today()
if date_from is None:
date_from = date_until - timedelta(days=1)
payload = self._get_json(
f"{BASE_URL}/Readings",
params={"meterId": zaehlpunktnummer, "day": date_from.isoformat()},
)
# --- decide the semantics of the value (see section 4) -----------------
is_cumulative = bool(payload and payload[0].get("isMeterRead"))
values = [float(v) for v in (payload[0].get("values") or [])]
return [
{
"obisCode": "1-1:1.8.0",
"wertetyp": "METER_READ" if is_cumulative else "DAY",
"einheit": UNIT_WH,
"messwerte": [
{
"zeitpunkt": f"{date_from.isoformat()}T00:00:00",
"messwert": round(sum(values) * KWH_TO_WH, 3),
"status": "VALID",
}
],
}
] if values else []This is the part that most often goes wrong: is the number you return a meter reading or a period consumption?
| Portal gives you | wertetyp |
state_class Home Assistant sees |
Typical OBIS |
|---|---|---|---|
| Cumulative register that only grows |
METER_READ or omit |
total_increasing |
1-1:1.8.0, 1-1:2.8.0
|
| Energy consumed within a day / 15 min / hour |
DAY, CONSUMPTION, QUARTER_HOUR
|
total |
1-1:1.9.0, 1-1:2.9.0
|
Declaring a value that resets every day as total_increasing makes Home Assistant compute
huge negative deltas in the long-term statistics. If the portal only offers period values,
that is fine — just mark them correctly. See
ADD AD-5/AD-6.
If a day has no data, return []. The entity then reads unknown, which is honest. A
0 would be indistinguishable from a genuine zero-consumption day.
Keep parsing in small module-level helpers so they can be unit-tested directly:
def _normalise_metering_point(raw: Any) -> dict[str, Any]:
if not isinstance(raw, dict):
raise SmartmeterQueryError(f"Unexpected metering point payload: {raw!r}")
zaehlpunktnummer = raw.get("meteringPointId") or raw.get("meterId")
if not zaehlpunktnummer:
raise SmartmeterQueryError("Portal returned a metering point without an id.")
info = dict(raw)
info["zaehlpunktnummer"] = zaehlpunktnummer
info["zaehlpunktName"] = raw.get("name") or zaehlpunktnummer
info["zaehlpunktAnlagentyp"] = raw.get("smartMeterType") or "CONSUMING"
return infoThe full contract your methods must satisfy is documented in SDD §3. The short version:
-
zaehlpunkte()→[{"zaehlpunkte": [{"zaehlpunktnummer": ..., ...}]}] -
historical_data()→[{"obisCode": ..., "wertetyp": ..., "einheit": "Wh", "messwerte": [{"messwert": ..., "zeitpunkt": ...}]}] -
consumptions()→[{"zaehlpunktnummer": ..., "consumptionYesterday": {"value": Wh, "date": ...}}]
Everything extra that you put into the zaehlpunkte dict is automatically surfaced as
diagnostic entities or as extra state attributes, so add useful portal fields — but never
secrets.
# custom_components/asm/api/client.py
from .client_yourprovider import YourProviderClient
def get_client(provider: str, username, password) -> SmartmeterClient:
if provider == PROVIDER_NETZ_NOE:
return NetzNoeClient(username, password)
if provider == PROVIDER_YOURPROVIDER:
return YourProviderClient(username, password)
return WienerNetzeClient(username, password)Because WienerNetzeClient is the fallback, add an explicit branch rather than relying on
the default.
The adapters do not import Home Assistant, so they can be exercised directly. Drive the session with a fake that routes URL → response and assert on the normalised output:
client = YourProviderClient("user", "pass")
client.session = FakeSession({
("POST", "Login"): FakeResponse(200, {"success": True}),
("GET", "MeteringPoints"): FakeResponse(200, [{"meteringPointId": "AT001", "name": "Home"}]),
("GET", "Readings"): FakeResponse(200, [{"values": [0.1, 0.2]}]),
})
assert client.zaehlpunkte()[0]["zaehlpunkte"][0]["zaehlpunktnummer"] == "AT001"
assert client.historical_data("AT001")[0]["messwerte"][0]["messwert"] == 300.0Assert at minimum:
- successful login marks the client logged in, and the payload is what the portal expects,
- a wrong endpoint (HTML body) raises
SmartmeterConnectionError, not a JSON error, - wrong credentials raise
SmartmeterLoginError, - kWh → Wh conversion,
- the
wertetypyou emit, - an empty response yields
[].
- Restart Home Assistant to reload the code.
- Settings → Devices & Services → Add Integration → Austria Smartmeter.
- Your provider appears in the dropdown. Enter the credentials.
- Enable debug logging to compare the raw portal payload against your normalised output:
logger:
default: info
logs:
custom_components.asm: debug- Download the diagnostics file from the integration page to see the blob the coordinator
produced — it contains
info,readingsandstatsper metering point.
- Provider constant + display name added to
const.py - Adapter class inherits
SmartmeterClientand implements all six methods - Adapter raises
SmartmeterLoginError/SmartmeterConnectionError/SmartmeterQueryErrorappropriately - Values normalised to Wh
-
wertetypset correctly for period values - Empty responses return
[], not a fake0 - No credentials in logs, no Home Assistant imports in the adapter
- Registered in
get_client() - No new
manifest.jsonrequirement unless Core does not provide it - Verified with a fake-session harness
- Provider added to the supported table in
README.mdandinfo.md - Documentation updated: ADD §11
🟢 Current — 1.2.x
(branch HA2026_09_dev, HA 2026.9+)
🗄️ Archived — 1.1.8
(branch main)
🔗 Links