Repository navigation
Architecture Design Document
Austria Smartmeter integration for Home Assistant · domain
asm
| Field | Value |
|---|---|
| Document | Architecture Design Document (ADD) |
| Status | Draft / in review |
| Applies to | Integration 1.2.x |
| Branch | HA2026_09_dev |
| Commit | bd6c124 |
| Release |
1.2.0-beta.1 (pre-release) |
| Target platform | Home Assistant 2026.9+ |
| Read together with | Software Design Document · Workflow Diagrams |
| Supersedes | Design Documentation 1.1.8 (archived) · provider guide 1.1.8 (archived) |
This document describes what the Austria Smartmeter integration is, why it is built the way it is, and which architectural decisions were taken for the 1.2.x line. It covers the system context, the component decomposition, the cross-cutting concerns and the trade-offs.
It does not describe individual functions line by line — that is the job of the Software Design Document.
- Cloud polling integration for Austrian smart meter portals.
- Two providers in production: Wiener Netze and Netz Niederösterreich (EVN).
- Home Assistant integration surfaces: config flow, options flow, sensor entities, devices, diagnostics, brand images.
- Reading the smart meter locally (optical/IR or P1 interface). The integration is cloud only.
- Hardening against portal-side API changes beyond graceful degradation.
- Billing-grade accounting. Values are informational.
- Additional providers (Stromnetz Graz, e-netze.at, energiedaten.at) — see §11.
| Stakeholder | Need |
|---|---|
| Home Assistant user | Set up the integration with portal credentials and get usable energy sensors. |
| Home Assistant user | Feed the Energy Dashboard without unit or statistics corruption. |
| Home Assistant user | Understand failures: a message that says what failed, plus a diagnostics dump. |
| Maintainer | Onboard a new grid operator without touching the core. |
| Maintainer | Survive portal-side API changes with a small, contained change. |
| ID | Requirement |
|---|---|
| FR-1 | The user configures exactly one provider plus username/password through the UI. |
| FR-2 | Each config entry discovers all metering points of the account automatically. |
| FR-3 | Data is polled periodically; the interval is user configurable. |
| FR-4 | Meter readings, feed-in readings and available daily statistics are exposed as sensors. |
| FR-5 | Static meter data (address, IDs, contract state) is exposed as diagnostic entities. |
| FR-6 | A credentials failure surfaces as a re-authentication, not as a silent retry loop. |
| FR-7 | Diagnostics can be downloaded with credentials redacted. |
| ID | Requirement |
|---|---|
| NFR-1 | All blocking I/O runs in the Home Assistant executor; the event loop is never blocked. |
| NFR-2 | Energy values are normalised to Wh and declared to Home Assistant consistently. |
| NFR-3 | A missing optional data source (e.g. daily statistics) must not break the mandatory sensors. |
| NFR-4 | No requirement that is already provided by Home Assistant Core (see §7.2). |
| NFR-5 | Only public, documented Home Assistant APIs are used — no deprecated surfaces. |
Austrian grid operators expose their smart meter data through web portals. There is no unified national API, and each portal has its own authentication scheme, its own endpoint naming and its own data shape. The integration has to act as an adapter layer between n heterogeneous portals and one Home Assistant data model.
| Constraint | Consequence |
|---|---|
| Home Assistant 2026.9 requires Python 3.14.2 | The code is written for a modern interpreter; ConfigEntry generics and entry.runtime_data are used. |
| Home Assistant custom integrations may not ship anything that Core already provides |
requests and python-dateutil were removed from manifest.json requirements. |
Core guarantees requests is installed |
The provider clients may keep using the synchronous requests API. |
| No hardware access | Polling only; there is no push channel. |
| Portals rate-limit and expire sessions | Conservative default poll interval (6 h) and proactive session refresh. |
| Portal data is not real-time | A reading is normally yesterday, so a high poll frequency buys nothing. |
flowchart LR
user["Home Assistant user"] -->|"provider + credentials"| asm["Austria Smartmeter<br/>(custom_components/asm)"]
user -->|"scan interval"| asm
ha["Home Assistant Core<br/>config entries, entity registry,<br/>statistics, diagnostics"] <-->|"config flow,<br/>entities, devices"| asm
asm -->|"HTTPS + REST/JSON"| wn["Wiener Netze<br/>smartmeter-web.wienernetze.at<br/>api.wstw.at"]
asm -->|"HTTPS + REST/JSON"| noe["Netz Niederösterreich<br/>smartmeter.netz-noe.at"]
ha -->|"long term statistics"| rec[("Recorder DB")]
classDef ext fill:#f8fafc,stroke:#334155,color:#0f172a
classDef core fill:#e0e7ff,stroke:#4f46e5,color:#312e81
classDef portal fill:#dcfce7,stroke:#16a34a,color:#14532d
class user ext
class ha,rec core
class wn,noe portal
The integration is a cloud polling integration (iot_class: cloud_polling). It owns
no local device and no webhook. Every state change originates from a timer-driven poll.
The integration is layered, and the dependency direction is strictly downwards:
Home Assistant surface → orchestration → provider adapter → portal.
flowchart TD
subgraph ha["Home Assistant surface"]
cf["config_flow.py<br/>Config + Options flow"]
sen["sensor.py<br/>Sensor platform"]
dia["diagnostics.py<br/>Diagnostics"]
ini["__init__.py<br/>Entry setup / unload"]
end
subgraph core["Orchestration"]
co["coordinator.py<br/>DataUpdateCoordinator"]
end
subgraph api["Provider adapters"]
fac["api/client.py<br/>factory"]
abc["api/base.py<br/>SmartmeterClient (ABC)"]
n1["api/client_wn.py"]
n2["api/client_noe.py"]
end
ini --> cf
ini --> co
co --> fac
fac --> abc
fac --> n1
fac --> n2
co --> sen
sen --> co
n1 -->|"HTTP"| p1(("Wiener Netze portal"))
n2 -->|"HTTP"| p2(("Netz NOE portal"))
classDef surface fill:#dbeafe,stroke:#2563eb,color:#172554
classDef orch fill:#fef3c7,stroke:#d97706,color:#78350f
classDef prov fill:#dcfce7,stroke:#16a34a,color:#14532d
class cf,sen,dia,ini surface
class co orch
class fac,abc,n1,n2,api prov
A complete, tool-generated component map (including entity types and portal boundaries) is in Workflow Diagrams.
| Layer | Module | Responsibility |
|---|---|---|
| Entry point | __init__.py |
Create the coordinator, run the first refresh, publish it on entry.runtime_data, forward platforms, own unload and the options reload listener. |
| Configuration | config_flow.py |
Provider selection, credential validation, entry creation, options flow (scan interval). |
| Orchestration | coordinator.py |
Login lifecycle, contract discovery, statistics fetch, reading fetch, normalisation into one data blob, error→Home Assistant exception mapping. |
| Adapter contract | api/base.py |
The abstract interface every provider must implement. |
| Adapter factory | api/client.py |
Maps a provider id to an implementation. The single place that knows all providers. |
| Adapters |
api/client_wn.py, api/client_noe.py
|
Portal-specific authentication, endpoint names, pagination, payload parsing and normalisation to the internal contract. |
| Presentation | sensor.py |
Turns the coordinator blob into entities, devices and entity metadata (unit, device class, state class). |
| Operations | diagnostics.py |
Redacted support dump. |
| # | Decision | Rationale | Consequences |
|---|---|---|---|
| AD-1 |
One coordinator instance per config entry, published on entry.runtime_data
|
entry.runtime_data is the Home Assistant 2025.7+ pattern; it is bound to the entry lifecycle and avoids a global hass.data[DOMAIN] registry and manual cleanup. |
__init__.py unload is a one-liner; entities reach the coordinator via entry.runtime_data. |
| AD-2 | Provider-specific code is confined to api/client_*.py |
The portals share nothing but the concept. Keeping the divergence out of the core keeps the core testable and small. | A new provider is one new file plus two registrations. |
| AD-3 |
The adapter normalises everything into an internal contract (zaehlpunkte, readings, statistics) |
The coordinator and the sensor platform must not contain provider branches. | The adapter absorbs all API drift; see the contract in the SDD. |
| AD-4 | All energy values are normalised to Wh | The Home Assistant Energy Dashboard is unit sensitive; a wrong unit silently corrupts long-term statistics. | Adapters must convert (kWh×1000). sensor.py declares Wh once. |
| AD-5 | The state class is derived from the semantics of the reading, not from the provider | A cumulative meter reading is total_increasing; a per-day consumption value is total. Declaring a resetting value as total_increasing produces impossible statistics. |
Readings may carry a wertetyp; see AD-6. Default (absent) preserves the historic behaviour. |
| AD-6 | wertetyp marks period-based readings |
Providers differ: Wiener Netze returns cumulative METER_READ values, Netz NÖ only returns per-interval consumption. |
_state_class_for() picks total for DAY/CONSUMPTION/QUARTER_HOUR, else total_increasing. Wiener Netze is unaffected. |
| AD-7 | Blocking HTTP via requests inside async_add_executor_job |
requests is guaranteed present in Core, the portals need cookies and form scraping, and a full aiohttp rewrite buys nothing for a 6-hourly poll. |
The event loop is never blocked (NFR-1); the session object lives in the adapter. |
| AD-8 | Session lifetime is modelled explicitly | The portals expire cookies without an API-visible expiry; the previous code reported "logged in" forever. |
is_login_expired() is time-based; adapters re-login proactively and once more on a mid-update failure. |
| AD-9 | Errors are mapped to Home Assistant config-entry exceptions | Home Assistant understands ConfigEntryAuthFailed (offer reauth) and ConfigEntryNotReady/UpdateFailed (retry). |
Credentials problems never turn into an endless retry loop (FR-6). |
| AD-10 | Brand images ship inside the integration | Since Home Assistant 2026.3 a local brand/ directory takes precedence over the brands repository. |
No PR to home-assistant/core brands is required; assets live with the code. |
| AD-11 | Diagnostics with redaction instead of log-scraping | Portal payloads are large and contain personal data; a redacted dump is the fastest support path. |
async_redact_data removes username and password. |
| AD-12 | Requirements limited to what Core does not ship | Core requirements.txt already pins requests; the previous python-dateutil use was two date shifts. |
Only lxml remains; _shift_years() replaces relativedelta. |
flowchart TD
req["Adapter HTTP call"] --> kind{"What failed?"}
kind -->|"transport / DNS / timeout"| ce["SmartmeterConnectionError"]
kind -->|"non-JSON body (HTML error page)"| ce
kind -->|"credentials rejected"| le["SmartmeterLoginError"]
kind -->|"authenticated but unusable / no data"| qe["SmartmeterQueryError"]
ce --> up["UpdateFailed / cannot_connect"]
le --> auth["ConfigEntryAuthFailed / invalid_auth"]
qe --> up
up --> retry["HA retries at the next interval"]
auth --> reauth["HA starts re-authentication"]
A non-JSON response is deliberately treated as a connection problem, not as an authentication problem: the portal answers with an HTML page when an endpoint does not exist, which says nothing about the credentials.
| Dependency | Status | Reason |
|---|---|---|
requests |
Removed from manifest | Provided and pinned by Home Assistant Core. |
python-dateutil |
Removed from manifest | Usage replaced by stdlib date.replace() arithmetic. |
lxml |
Kept | Not a Core dependency; needed to scrape the Keycloak login form. |
voluptuous, HA helpers |
Provided by Core | Standard integration imports. |
-
LOGGERis exported fromconst.pyso every module logs undercustom_components.asm. - Debug logging carries the why (endpoint chosen, fallback triggered, implicit match), never credentials.
- User-facing failures are warnings with the provider and the exception text.
- Credentials are stored in the config entry (Home Assistant's encrypted store).
- Credentials never appear in logs and are redacted from diagnostics.
- No third-party service is contacted; only the operator portal the user selected.
- Payloads may contain the metering address, which is exposed deliberately as diagnostic entities and included in diagnostics.
| Aspect | Rule |
|---|---|
| Unit | Always Wh in the entity, always converted in the adapter. |
| Device class |
energy for OBIS readings and daily statistics. |
| State class |
total_increasing for cumulative readings, total for period values (AD-5/AD-6). |
| unique_id |
{zaehlpunkt}_{obisCode} for readings, {zaehlpunkt}_diag_{key} for diagnostics, {zaehlpunkt}_stat_{key} for statistics. Stable — changing it would orphan user history.
|
| Entity id / name | Derived from the portal's friendly meter name. has_entity_name deliberately stays False so existing entity ids are not rewritten. |
flowchart LR
subgraph old["1.1.8 (main)"]
o1["hass.data[DOMAIN][entry_id]"]
o2["flow error: Authenticaton typo<br/>NOE login always failed"]
o3["requests + lxml + python-dateutil"]
o4["no brand images"]
o5["no diagnostics"]
o6["all OBIS readings<br/>state_class = total_increasing"]
end
subgraph new["1.2.x (HA2026_09_dev)"]
n1["entry.runtime_data"]
n2["Authentication endpoint,<br/>modern + legacy metering points"]
n3["lxml only"]
n4["brand/ icon + logo, light + dark"]
n5["diagnostics.py with redaction"]
n6["state class derived from<br/>wertetyp (AD-5 / AD-6)"]
end
o1 --> n1
o2 --> n2
o3 --> n3
o4 --> n4
o5 --> n5
o6 --> n6
classDef before fill:#ffe4e6,stroke:#e11d48,color:#881337
classDef after fill:#dcfce7,stroke:#16a34a,color:#14532d
class o1,o2,o3,o4,o5,o6 before
class n1,n2,n3,n4,n5,n6 after
| Area | 1.1.8 | 1.2.x |
|---|---|---|
| Coordinator storage | hass.data[DOMAIN][entry_id] |
entry.runtime_data |
| Flow result typing | data_entry_flow.FlowResult |
ConfigFlowResult |
| Options flow | constructed with a config entry | default constructor + read-only config_entry property |
| Manifest |
dependencies: [], 3 requirements, name typo |
integration_type: hub, lxml only, correct name |
| Netz NÖ login | broken (endpoint typo) | working, with legacy fallback |
| Netz NÖ readings | never parsed (wrong response shape) | parsed, kWh → Wh, wertetyp: DAY
|
| Brand images | none | local brand/ directory |
| Diagnostics | none | redacted dump |
| Minimum Home Assistant | 2025.12 | 2026.9 |
Breaking: the 1.2.x line requires Home Assistant 2026.9 or newer.
hacs.jsonpinshomeassistant: 2026.9.0, so HACS offers the release only to matching systems.
| Attribute | How it is addressed |
|---|---|
| Reliability | Graceful degradation (FR-3/NFR-3): statistics are optional, readings are per-meter and a failure is logged without aborting the cycle. |
| Maintainability | Provider logic isolated (AD-2/AD-3); no provider conditionals in the core; a 100+ assertion harness runs the integration without Home Assistant. |
| Testability | The adapter contract behaves like a pure function from portal payload to internal dict, which is directly unit-testable. |
| Portability | No platform-specific code; lxml has wheels for the platforms Home Assistant supports. |
| Observability | Debug logs that state the decision taken, plus the diagnostics dump. |
| Security | Credentials redacted, no secrets in logs, only the user-selected portal is contacted. |
| Performance | One HTTP round-trip per meter per cycle plus two per entry; negligible for a 6-hour interval. |
| Risk | Impact | Mitigation / status |
|---|---|---|
| Portal API changes without notice | Integration stops returning data | Contained in the adapter; historical_data/zaehlpunkte raise descriptive errors. Residual risk — no portal contract exists.
|
| Netz NÖ endpoint may differ per rollout wave | Login or discovery fails for some users | Modern endpoint first, legacy two-step fallback second. |
| Netz NÖ reading semantics unverified against real hardware | Values could be off by a factor or a day | kWh→Wh conversion is based on the documented portal behaviour; must be confirmed by a user with a Netz NÖ meter. |
lxml install failure on an exotic architecture |
Integration fails to load | Accepted: lxml ships manylinux/musllinux wheels; the alternative (regex scraping) was rejected as more fragile. |
| Wiener Netze HTML scraping of the login form | A frontend redesign breaks login | Pre-existing; lxml XPath on //form/@action is deliberately generic. |
| Synchronous HTTP | A very slow portal blocks an executor thread | Bounded by a 30 s timeout; the executor is a thread pool. |
| Statistic entities depend on a provider endpoint | Missing statistics for Netz NÖ today | By design: consumptions() returns [] and the coordinator treats statistics as optional. |
-
New provider: implement
SmartmeterClient, register it inapi/client.pyand add the constant inconst.py. See Adding a New Provider. - Sub-entry / multi-account: the data model is already keyed per metering point, so a single entry can drive many meters. Additional accounts use additional config entries.
- Requested but not implemented: Stromnetz Graz (planned), e-netze.at (issue #3), energiedaten.at (issue #4). Each needs its own portal research and credentials.
-
Repair flows: a
repairs.pycould offer a guided fix when a portal is permanently unavailable; not implemented.
- Software Design Document — module and contract level.
- Workflow Diagrams — GitDiagram map and sequence diagrams.
- Adding a New Provider — developer guide.
- Archived 1.1.8 design documentation.
- Repository: https://github.com/acdcnow/AustrianSmartMeter-for-Home-Assistant
- Release: https://github.com/acdcnow/AustrianSmartMeter-for-Home-Assistant/releases/tag/1.2.0-beta.1
- Home Assistant developer docs: Config entries, Data update coordinator, Brand images.
🟢 Current — 1.2.x
(branch HA2026_09_dev, HA 2026.9+)
🗄️ Archived — 1.1.8
(branch main)
🔗 Links