Skip to content

Architecture Design Document

acdcnow edited this page Sep 21, 2026 · 2 revisions

Architecture Design Document (ADD)

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)

1. Purpose and scope

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.

1.1 In scope

  • 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.

1.2 Out of scope

  • 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.

2. Stakeholders and requirements

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.

2.1 Functional requirements

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.

2.2 Non-functional requirements

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.

3. Context

3.1 Business context

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.

3.2 Technical context and constraints

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.

4. System context

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
Loading

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.


5. Component decomposition

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
Loading

A complete, tool-generated component map (including entity types and portal boundaries) is in Workflow Diagrams.

5.1 Responsibilities

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.

6. Architectural decisions

# 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.

7. Cross-cutting concerns

7.1 Error handling strategy

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"]
Loading

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.

7.2 Dependency policy

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.

7.3 Logging

  • LOGGER is exported from const.py so every module logs under custom_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.

7.4 Security and privacy

  • 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.

7.5 Units, statistics and naming

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.

8. Deltas versus 1.1.8

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
Loading
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.json pins homeassistant: 2026.9.0, so HACS offers the release only to matching systems.


9. Quality attributes

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.

10. Risks and trade-offs

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.

11. Extension points and roadmap

  • New provider: implement SmartmeterClient, register it in api/client.py and add the constant in const.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.py could offer a guided fix when a portal is permanently unavailable; not implemented.

12. References

Austria Smartmeter

🏠 Documentation home


🟢 Current — 1.2.x (branch HA2026_09_dev, HA 2026.9+)


🗄️ Archived — 1.1.8 (branch main)


🔗 Links

Clone this wiki locally