Skip to content

System Design Document

acdcnow edited this page Sep 21, 2026 · 2 revisions

System Design Document (SDD)

Project: Pontos – Home Assistant integration for Hansgrohe Pontos and SYR water meters Version: 2.9.3 References: Architecture Concept Document, Repository documentation


1. Overview

The integration is a Home Assistant custom component with the domain pontos. It consists of an entry point, a coordinator that polls the device, one platform module per entity domain, a config flow with an options flow and a legacy adoption step, a service layer, and a declarative per-device configuration.

custom_components/pontos/
├── __init__.py            setup, unload, update listener, debug logging
├── binary_sensor.py       alarm, warning, notification, wifi, microleakage
├── button.py              service buttons (clear alarms/warnings/notifications, leak test)
├── config_flow.py         config flow, options flow, legacy adoption
├── const.py               domain, option keys, defaults, helpers
├── coordinator.py         DataUpdateCoordinator (TimestampDataUpdateCoordinator)
├── device.py              device registry info, identifiers, profile limit
├── device_config/         declarative per-model configuration
│   ├── common.py          shared builders (numbers, binary sensors, services, ...)
│   ├── conf_pontos.py     Hansgrohe Pontos Base
│   ├── conf_trio.py       SYR Trio
│   ├── conf_safetech.py   SYR SafeTech+
│   ├── conf_safetech_v4.py SYR SafeTech+ (old firmware)
│   └── conf_neosoft.py    SYR NeoSoft
├── manifest.json          domain, version, documentation and issue tracker URLs
├── migrate.py             config entry migrations (up to version 5)
├── number.py              profile settings (volume, time, flow, return time)
├── profile_select.py      select entity built from the profile names
├── select.py              dropdown selects (mode, schedule, profile)
├── sensor.py              all sensor endpoints incl. data quality handling
├── services.py            service registry, schemas, targeting, dispatch
├── services.yaml          user facing service and field descriptions
├── switch.py              on/off switches (buzzer)
├── time.py                time entities (leak test time, regeneration time)
├── translations/          en (complete), de (complete), fr, nb
├── utils.py               HTTP fetch/merge and command sending
└── brand/                 icon/logo (normal and @2x)

2. Data model

2.1 Config entry

Version 5 (bumped from 4 in 2.9.3).

data (fixed, describes the device):

Key Type Description
device_name str Default device name (the entry title is authoritative)
make str Model, one of the keys of MAKES

options (all changeable at runtime):

Key Type Default Range Effect
ip_address str – valid IP Device address
port int 5333 1-65535 HTTP port
http_timeout int 5 1-60 s Timeout per request
retry_attempts int 2 1-10 Attempts per poll
retry_delay int 2 1-60 s Base delay between attempts (linear backoff)
fetch_interval int 10 5-3600 s Coordinator polling interval
device_name str entry title – Device name, written to the entry title
make str from data model key Model override, options win over data
ignore_invalid_values bool True – Reject implausible values instead of passing them through
stale_tolerance int 60 0-1440 min How long the last valid value may be kept (0 = unlimited)
volume_unit str L L, m³ Display unit for volume sensors (native unit stays L)
enable_diagnostics bool True – Create sensors with an entity category
enable_controls bool True – Create button/select/switch/time/number entities
debug_logging bool False – Set the integration logger to DEBUG while the entry is loaded

Helpers: get_make() (options first), get_option() (options, data, default), get_device_name(), normalise_options().

2.2 Coordinator data

dict[str, Any] – the merged JSON of all endpoints of the model, for example:

{ "getVOL": "Vol[L]268289", "getAVR": "0mL", "getBAR": "3700 mbar", "getALA": "FF", "getWFS": 0 }

Values are used as delivered by the device (including unit suffixes and comma decimals). All normalisation happens in the sensor entity, driven by the device configuration.

2.3 Entity model

Aspect Rule
unique_id slugify(f"{identifier}_{key}") where identifier is the serial number, otherwise the MAC, otherwise the entry id (device_identifier())
translation_key Device config key (for example total_consumption), looked up under entity.<platform>.<key>
has_entity_name Always True, the device name is composed by Home Assistant
device_info {"identifiers": <same identifiers as the device>} – every entity links to the device registered in device.py
Profile entities Only created up to the profile count reported by getPRN
Diagnostic entities Skipped entirely when enable_diagnostics is off
Control entities Skipped entirely when enable_controls is off
Values Cached in the entity, recomputed in _handle_coordinator_update() – no repeated parsing per state write

3. Device configuration model

Every conf_<model>.py module provides:

Attribute Type Purpose
PLATFORMS list[str] Platforms to forward
MODEL, MANUFACTURER str Device registry metadata
BASE_URL str http://{ip}:{port}/<prefix>/
URL_LIST list[str] URLs read in one poll cycle
SENSOR_DETAILS dict[str, dict] Sensors: name, endpoint, unit, device class, state class, category, scale, format dictionary, code dictionary, attributes, min_value
SERVICES dict[str, dict] Services: name and endpoint template with placeholders
BUTTONS dict[str, dict] Buttons (service + availability sensor)
SELECTORS dict[str, dict] Selects (sensor + service + code dictionary)
SWITCHES dict[str, dict] Switches (sensor + on/off service)
TIME_ENTRIES dict[str, dict] Time entities (sensor + service)
NUMBERS dict[str, dict] Numbers (endpoint, profile, service, field, unit, min, max, step)
BINARY_SENSORS dict[str, dict] Binary sensors (on_values or off_values, code dictionary)
PROFILE_COUNT_ENDPOINT str Endpoint that reports the number of profiles (models with profiles)

Shared builders in device_config/common.py generate the repetitive structures: build_profile_services(), build_self_study_services(), build_profile_numbers(), build_self_study_sensors(), build_network_sensors(), build_alarm_binary_sensors(), build_status_binary_sensors(), build_wifi_binary_sensor(), build_microleakage_binary_sensor(), build_profile_count_sensor().

Feature matrix (what each model provides)

Feature Pontos Base Trio SafeTech+ SafeTech+ v4 NeoSoft
Consumption, flow, pressure, temperature yes (no flow) yes yes yes yes
Conductivity / hardness yes – yes yes yes
Profiles (1-8) yes yes yes yes –
Profile availability PAx – yes yes yes –
Leakage warning PWx – yes yes yes –
Self-study SLx – yes yes yes –
Microleakage test (DEX, DSV, DTT, DRP) – – yes – –
Warnings / notifications – yes yes – yes
Network diagnostics – yes yes reduced yes
Regeneration settings – – – – yes

4. Component design

4.1 utils.py

Function Behaviour
build_urls(ip, url_list, port) Expands {ip}/{port} placeholders
fetch_data(...) Fetches all URLs sequentially with ClientTimeout(total=timeout), merges dict payloads, collects errors, retries up to max_attempts with linear backoff, returns the partial result (empty only if nothing answered)
send_command(..., endpoint, payload, port, timeout) Fills the endpoint template from the payload dict and calls it, returns success

Design notes: the payload is passed as a dict and not as **kwargs, otherwise a placeholder named endpoint (used by generic_service) would collide with the parameter.

4.2 coordinator.py

  • PontosDataUpdateCoordinator(TimestampDataUpdateCoordinator[dict[str, Any]])
  • Reads all settings once in __init__ and passes config_entry=entry (no reliance on the current_entry context variable)
  • Holds a shared asyncio.Lock used by the coordinator and by services.py (command_lock)
  • _async_update_data(): fetch, log partial failures, raise UpdateFailed if nothing was received
  • is_stale(tolerance_minutes) for the sensor tolerance window

4.3 config_flow.py

Step Purpose
async_step_user Add a device (IP, port, interval, name, model). If a hass_pontos entry exists, hands over to adopt_legacy
async_step_adopt_legacy Pre-fills the form from the legacy entry, removes the legacy entry (and with it its entity registry entries) and creates the new entry
PontosOptionsFlow.async_step_init Sectioned options form (connection, polling, device, data quality, entities, logging)

Validation: the IP is parsed with ipaddress, the device is contacted (async_connection_works(): at least one endpoint answering and at least one get* key in the payload). The connection test is skipped when the IP is unchanged. Errors: cannot_connect, invalid_ip.

The options flow never writes title; the entry title is updated with async_update_entry(entry, title=...) so the options stay clean.

4.4 services.py

  • SERVICE_SCHEMAS – one voluptuous schema per service, always including the optional entry_id and device_id target fields
  • _placeholders(endpoint) – extracts the placeholders of the endpoint template using string.Formatter
  • _resolve_payload() – maps the call data onto the placeholders; a single-placeholder template additionally accepts the legacy data field
  • _targeted_entries() – resolves entry_id → device_id (via the device registry, DeviceEntry.config_entry_id) → all entries (with a warning when there is more than one)
  • Command lock per device, refresh after a command, ServiceValidationError for unknown targets, missing fields and unsupported models
  • Registration is idempotent and all services are removed when the last entry is unloaded

4.5 Platforms

Platform Notable design points
sensor.py CoordinatorEntity + RestoreSensor, value processing in _process(), rejection handling in _reject(), available = super().available and value is not None, volume sensors can expose a suggested unit
binary_sensor.py on_values (value in list) or off_values (value not in list) semantics, values upper-cased, ERROR values make the entity unavailable, code dictionary exposed as status attribute
number.py Static entity names (generated per profile), writes through the matching service with profile and the configured field
select.py, profile_select.py, switch.py, time.py Entities mirror a sensor and write through a service; the service is called with entry_id so the command reaches the right device
valve.py State derived from the valve_status sensor, reports_position = False, open/close call the services

5. Data quality

5.1 Processing pipeline (PontosSensor._process)

Step Operation
1 Read the raw value of the endpoint (None → entity unavailable)
2 Reject values containing ERROR
3 Apply format_dict (unit suffixes, decimal separator)
4 Translate code_dict values (for example FF → no_alarm)
5 Apply scale and round to two decimals
6 Non-numeric sensors end here (text values are passed through)
7 Convert to a number, otherwise reject
8 Check against min_value (automatically 0 for total / total_increasing)
9 Accept: remember the value and timestamp, return int for whole numbers

5.2 Rejection behaviour

Situation Result
ignore_invalid_values is off The value is passed through (logged once per streak)
Rejection, no valid value known yet Entity is unavailable
Rejection, valid value older than stale_tolerance (≠ 0) Entity is unavailable
Rejection, valid value within the window Last valid value is reported, invalid_value and invalid_since attributes are set

Attributes on every sensor: raw_value, invalid_value, invalid_since, last_valid_at, plus the configured attributes mapping. Rejection streaks are logged once to avoid log spam while a device keeps sending the same invalid value.

5.3 Restart continuity

RestoreSensor is used to seed the last valid value from the previous run, so an implausible first reading after a restart does not create a gap or a reset in the statistics.


6. Runtime flows

6.1 Setup

sequenceDiagram
    participant HA as Home Assistant
    participant EN as async_setup_entry
    participant CO as Coordinator
    participant DV as Device
    participant PL as Platforms
    HA->>EN: entry
    EN->>CO: PontosDataUpdateCoordinator(hass, entry, model)
    EN->>CO: async_config_entry_first_refresh()
    CO->>DV: GET <base>get/all
    DV-->>CO: JSON
    CO-->>EN: data
    EN->>EN: register_device() reads serial, MAC, versions
    EN->>EN: register services
    EN->>PL: await async_forward_entry_setups()
    EN-->>HA: True
Loading

6.2 Command with target resolution

flowchart TD
    A[Service call] --> B{entry_id given?}
    B -->|yes| C[Use that entry]
    B -->|no| D{device_id given?}
    D -->|yes| E[Look up device, use DeviceEntry.config_entry_id]
    D -->|no| F[Use all entries, warn if more than one]
    C --> G[Validate the fields against the schema]
    E --> G
    F --> G
    G --> H[Fill the endpoint template from the fields]
    H --> I[Send the command under the device lock]
    I --> J[Refresh the coordinator]
Loading

6.3 Legacy adoption

sequenceDiagram
    participant U as User
    participant CF as Config flow
    participant CE as Config entries
    U->>CF: Add integration
    CF->>CE: async_entries("hass_pontos")
    CE-->>CF: legacy entry found
    CF-->>U: adopt_legacy form (pre-filled)
    U->>CF: Confirm
    CF->>CE: async_remove(legacy entry)
    Note over CE: removes the legacy entry and its entities
    CF->>CE: create entry in domain pontos
    Note over CE: entity ids are free again and are reused
Loading

7. Services reference

Service Endpoint template Required fields
open_valve / close_valve set/ab/... (model specific) –
clear_alarms / clear_warnings / clear_notifications set/ala/255, set/wrn/255, set/not/255 or clr/ala –
set_profile set/prf/{profile} profile
set_profile_volume set/pv{profile}/{volume} profile, volume
set_profile_time set/pt{profile}/{minutes} profile, minutes
set_profile_flow set/pf{profile}/{flow} profile, flow
set_profile_return_time set/pr{profile}/{hours} profile, hours
set_profile_availability set/pa{profile}/{enabled} profile, enabled
set_profile_warning set/pw{profile}/{enabled} profile, enabled
microleakage_test set/dex –
microleakage_time set/dtt/{time} time
microleakage_schedule set/drp/{schedule} schedule
start_self_study set/slp/{days} days
delete_self_study set/sld/true –
set_regeneration_mode set/rmo/{mode} mode
set_regeneration_interval set/rpd/{days} days
set_regeneration_time set/rtm/{time} time
enable_buzzer / disable_buzzer set/buz/true, set/buz/false –
generic_service set/{endpoint}/{data} endpoint, data

Boolean fields are converted to true/false before the template is filled. Every service is documented with its fields and selectors in services.yaml, and the same field names are used in schema and in the endpoint template – the mismatch that existed before 2.9.3 is therefore impossible now.


8. Localisation

Translation layout:

config.step.user / config.step.adopt_legacy
config.error.cannot_connect / invalid_ip
options.step.init.data.*            (field labels)
options.step.init.sections.*.name   (section titles)
entity.sensor.*, entity.binary_sensor.*, entity.valve.*, entity.button.*,
entity.select.*, entity.switch.*, entity.time.*

English and German are complete, French and Norwegian fall back to English for newer keys. Entity names that depend on generated data (profile numbers) use static names instead of translation keys to avoid hundreds of translation entries.


9. Migrations and compatibility

From To Action
1 2 Add make to data
2 3 Add fetch_interval to data
3 4 Move ip_address and fetch_interval from data to options
4 5 Add device_name to data, add port, fill all new options with defaults

Migration runs in async_migrate_entry before the entry is set up. Entries with a higher version than the integration supports are not touched (logged as an error, setup is aborted by Home Assistant), which prevents a downgrade from corrupting an entry.

Statistics compatibility: the entity identifiers are derived from names, not from the domain, so the adoption of a hass_pontos configuration keeps the entity ids and therefore the recorded history and the long term statistics.


10. Testing and validation

Tool Purpose
testing/serve.py Simulates the endpoints of all models (python serve.py --device safetech)
testing/*.json Payload fixtures per model
testing/endpoint_matrix.py Compares fixture keys with the endpoints referenced in the device configuration (finds unused endpoints)
testing/link_pontos.sh Hard-links the repository into a Home Assistant instance
hassfest workflow Manifest and translation validation
HACS validate workflow Repository validation (brands, manifest, description, topics, issues, license)
Check Version workflow Fails on a lowered version and when the integration changed without a version bump

Manual test checklist before a release:

  1. A fresh installation adds all platform entities without errors.
  2. All entities become available once the device answers.
  3. Interrupting the device marks all entities unavailable, and they recover.
  4. A negative getVOL keeps the previous value and sets invalid_value.
  5. Each service works from the UI with the documented fields, targeting a single device.
  6. Options changes (including model and volume unit) reload the entry correctly.
  7. Upgrading a hass_pontos installation imports the configuration and keeps entity ids.

11. Extension guide

Task Steps
Add a sensor Add an entry to SENSOR_DETAILS of the model (endpoint, unit, device class, state class, scale or format dictionary) and add entity.sensor.<key>.name to en.json (and de.json)
Add a service Add the endpoint template to SERVICES (named placeholders), add the matching schema to SERVICE_SCHEMAS in services.py, document it in services.yaml and add a translation entry
Add a binary sensor Add an entry to BINARY_SENSORS (on_values or off_values) plus a translation key
Add numbers Include build_profile_numbers() in the model or add explicit entries with endpoint, profile, service, field, unit and range
Add a model Create device_config/conf_<model>.py, register it in MAKES in const.py, extend documentation/readme.md and the README device table
Add an option Add the key and default to const.py, add it to a section in PontosOptionsFlow._options_schema(), include it in _current_options(), document it in the README and add the translation

Versioning rules: the integration version lives in manifest.json. Every change under custom_components/ requires a version bump, because the Check Version workflow fails otherwise.


12. Known limitations

  • Only the endpoints with verified semantics are exposed – clock, DNS and internal counters are not mapped to entities.
  • The integration polls; there is no push channel, so the minimum useful interval is bounded by the device's response time.
  • One config entry per device – two entries for the same device would produce duplicate entities.
  • Statistics that were already distorted by an older version are not corrected automatically.
  • The domain rename in 2.9.3 requires one manual step (delete the old folder, re-add the integration), because Home Assistant loads only one integration per repository.