Repository navigation
System Design Document
Project: Pontos – Home Assistant integration for Hansgrohe Pontos and SYR water meters Version: 2.9.3 References: Architecture Concept Document, Repository documentation
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)
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().
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.
| 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 |
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 | 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 |
| 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.
PontosDataUpdateCoordinator(TimestampDataUpdateCoordinator[dict[str, Any]])- Reads all settings once in
__init__and passesconfig_entry=entry(no reliance on thecurrent_entrycontext variable) - Holds a shared
asyncio.Lockused by the coordinator and byservices.py(command_lock) -
_async_update_data(): fetch, log partial failures, raiseUpdateFailedif nothing was received -
is_stale(tolerance_minutes)for the sensor tolerance window
| 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.
-
SERVICE_SCHEMAS– one voluptuous schema per service, always including the optionalentry_idanddevice_idtarget fields -
_placeholders(endpoint)– extracts the placeholders of the endpoint template usingstring.Formatter -
_resolve_payload()– maps the call data onto the placeholders; a single-placeholder template additionally accepts the legacydatafield -
_targeted_entries()– resolvesentry_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,
ServiceValidationErrorfor unknown targets, missing fields and unsupported models - Registration is idempotent and all services are removed when the last entry is unloaded
| 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 |
| 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 |
| 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.
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.
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
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]
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
| 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.
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.
| 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.
| 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:
- A fresh installation adds all platform entities without errors.
- All entities become available once the device answers.
- Interrupting the device marks all entities
unavailable, and they recover. - A negative
getVOLkeeps the previous value and setsinvalid_value. - Each service works from the UI with the documented fields, targeting a single device.
- Options changes (including model and volume unit) reload the entry correctly.
- Upgrading a
hass_pontosinstallation imports the configuration and keeps entity ids.
| 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.
- 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.
- Version 2.9.3
- Domain
pontos - Verified with HA 2026.9.3