Home Assistant Integration¶
The typical integration flow has two distinct phases:
Configuration Phase¶
During config flow, use asset-aware mode to discover entities.
# 1. Login via REST
await api_client.ensure_auth(email, password)
# 2. Select object and modules
objects = await api_client.get_objects()
object_id = objects[0].id
modules_resp = await api_client.get_modules(object_id=object_id)
module_ids = [str(m.devid or m.id) for m in modules_resp if (m.devid or m.id) is not None]
# 3. Enable asset-aware resolution
param_store = ParamStore()
resolver = ParamResolver.from_api(api=api_client, store=param_store, lang="en")
# 4. Prime parameters via REST snapshot
status, payload = await api_client.modules_parameters_prime(module_ids, return_data=True)
if status in (200, 204) and isinstance(payload, dict):
param_store.ingest_prime_payload(payload)
# 5. Build entity descriptors with metadata from assets
# Pick module permissions + menu id (deviceMenu) from one module; you can merge across modules if needed.
first = modules_resp[0]
device_menu = int(first.deviceMenu)
permissions = list(getattr(first, "permissions", []) or [])
symbols = await resolver.merge_assets_with_permissions(permissions=permissions, device_menu=device_menu)
descriptors = []
for symbol, desc in symbols.items():
descriptors.append({
"symbol": symbol,
"label": desc.get("label"),
"unit": desc.get("unit"),
})
Note
No WebSocket connection needed during config flow!
Runtime Phase¶
At runtime, use lightweight mode for best performance.
# 1. Create gateway and lightweight ParamStore
gateway = BragerOneGateway(api=api_client, object_id=object_id, modules=module_ids)
param_store = ParamStore() # runtime-light (storage-only)
# 2. Subscribe to updates
async def handle_updates():
async for event in gateway.bus.subscribe():
if event.value is None:
continue
param_store.upsert(
f"{event.pool}.{event.chan}{event.idx}",
event.value,
devid=event.devid,
)
# Trigger HA entity updates
# Per-module route visibility (multi-module): param_store.flatten_for_devid(devid)
def on_alarm_quantity(event):
if event.changed:
... # REST refresh alarm feed for event.devid
gateway.on_alarm_quantity(on_alarm_quantity)
# 3. Start gateway (connects WS, subscribes, primes)
await gateway.start()
Module connectivity¶
Two layers — do not conflate them:
Module ↔ cloud (SPA
connectedAt) — plant gateway reachability. Observe and wait when offline; the client cannot repair it.Library ↔ cloud (Socket.IO client session) — must be detectable and self-healing (transport reset, reconnect, REST re-prime while down).
EventBus boundary: gateway.bus / EventBus.subscribe() delivers
ParamUpdate only. Module online/offline, cloud session, live-push health, and
alarm quantity are not on that bus (so existing typed bus.subscribe() loops
stay unbroken). Use the dedicated gateway callbacks / poll APIs:
from pybragerone.models.events import CloudSessionConnectivity, ModuleConnectivity
def on_module(event: ModuleConnectivity) -> None:
print(event.devid, "online" if event.online else "offline", event.source)
def on_session(event: CloudSessionConnectivity) -> None:
print("cloud session", "up" if event.up else "down", event.source)
# Outage attrs (additive): event.down_since / down_for_s / reason while down;
# event.last_down_for_s / last_reason after restore.
gateway.on_module_connectivity(on_module)
gateway.on_cloud_session(on_session)
# gateway.on_live_push(...) / live_push_health()
# gateway.on_alarm_quantity(...) # badge count; row lists remain REST
# After start / refresh:
# gateway.module_online(devid) -> True | False | None
# gateway.module_connected_at(devid) -> int | None # REST connectedAt
# gateway.ws_session_up() -> bool # library↔cloud Socket.IO
# gateway.cloud_session_outage() -> dict # down_since / down_for_s / reason / last_*
# gateway.module_outage(devid) -> dict # same keys for module↔cloud
# gateway.live_push_health() -> dict # push_healthy / live_stale_for_s / last_resumed_after_s
# gateway.last_param_update_age_s() -> float | None
# gateway.last_live_param_update_age_s() -> float | None
# gateway.connectivity_episodes() -> list[dict] # recent completed outages (ring buffer)
reason on these outage snapshots is a client observation token
(disconnect / stop, or finer Socket.IO tokens such as handshake_503,
empty_queue, server_stop, eio_close, connect_error,
reconnect_error, supervisor_stale, force_reconnect, hard_reset for
the cloud session; rest / ws / derived for module connectedAt) —
not a diagnosis of boiler or LAN hardware. Engine.IO abort reasons on the
Socket.IO disconnect event (transport error / transport close) classify
as eio_close rather than generic disconnect. Live down_for_s is measured with a
monotonic clock while down_since is wall-clock time.time() for Home
Assistant attributes. A restore logs Cloud session restored after …s /
Module connectivity restored after …s.
Recent completed episodes (all three layers) are retained in a ring buffer via
gateway.connectivity_episodes() — oldest → newest dicts with layer,
started_at / ended_at, down_for_s, reason, optional devid,
and episode_id (suitable for HA diagnostics; no credentials).
Three distinct layers (do not conflate):
Library ↔ cloud —
ws_session_up/CloudSessionConnectivity(+ outage attrs).Module ↔ cloud —
module_online/ModuleConnectivity(+ outage attrs).Live push health —
live_push_health()/LivePushHealth/on_live_push: age since the last liveParamUpdatewhile the Socket.IO session is up (push_healthy/live_stale_for_s). A zombie is session-up withpush_healthy=False. Resume logslive ParamUpdate resumed after …sand setslast_resumed_after_s. Home Assistant parameter entities treat session-down andpush_healthy=Falseas unavailable (fail-closed) so history shows a gap instead of a flat stale line.
The gateway primes from GET /v1/modules (connectedAt != 0 means online —
same truthiness check as the SPA card/modal) and listens for the official Socket.IO
push app:module:connection:status:changed (payload
{devid: {connectedAt, gateway}}, applied by Layout / ObjectsLayout in the web
app). The client’s own Socket.IO session is tracked separately and does not
force modules offline (SPA parity). Session-down publication is deferred by
cloud_session_down_hysteresis_s (default 15s) so brief WS blinks do not
mark Home Assistant entities unavailable or overwrite cloud outage last_*. A background REST poll (default 60s;
connectivity_poll_interval=0 disables it) continues even while WS is down.
A single failed get_modules keeps the previous module state; sustained
unusable results (errors or empty/unrecognised listings) also keep last-known
module online so a library↔cloud outage is not reported as module offline.
Authoritative offline still comes from connectedAt=0 on a usable listing,
derived-missing when at least one sibling row is present, or WS
connection:status. Empty listings never wipe modules on a single tick (or
after a streak). Ordinary WS disconnect does not discard an in-flight
get_modules failure from the diagnostic streak — only stop() invalidates
that HTTP completion. Refreshes are serialized so callbacks that re-enter
refresh cannot rebuild the streak under the lock.
get_modules_fail_offline_after is a deprecated no-op retained for
compatibility. Module validation coerces null connectedAt to 0 (offline); the
gateway applies the same rule for duck-typed nulls. Corrupt rows skipped by
get_modules (or non-numeric duck-typed values) are absent from the listing —
when at least one sibling row is usable, missing subscribed modules are derived
offline; empty gateway blobs are still applied when connectedAt is usable.
While the client’s Socket.IO session is down, the same poll REST-primes
parameters so Home Assistant entities keep receiving ParamUpdate events (WS
deltas only resume after reconnect + resubscribe + prime). An Engine.IO abort
that skips the Socket.IO disconnect callback still marks the session down before
reconnect, so that REST-prime path can run. If the session still reports up but
no live ParamUpdate is published for 180s (zombie transport), the poll REST-primes
anyway; after two consecutive zombie primes it forces a hard Socket.IO restart
(SPA parity: connect → ModulesService.connect + REST parameters), awaiting
namespace join + resubscribe() so module binding completes. After repeated failed
hard restarts it hard-resets the Socket.IO client, then rebuilds RealtimeManager.
Hard reconnect, transport recycle, and manager rebuild each attempt a fresh login
before proceeding when credentials are available; token-only gateway clients (no
creds_provider) keep the existing access token instead of clearing it.
Transport recycle and manager rebuild then apply an exponential recovery cooldown
(REST primes continue). A successful hard reconnect does not arm cooldown; when
hard reconnect aborts because forced re-login failed, cooldown is armed so the
poll loop does not thrash. After the rebuild cap (default 3) the gateway enters
REST-only quarantine (default 6 h) — WS recovery pauses, REST primes continue,
cleared by live traffic or module-online recovery.
modules.connect caches only the body shape (wsid/sid, optional
group_id), never a stale SID — each reconnect posts the current namespace SID.
resubscribe() is serialized and deduped per namespace SID.
A subscribed module returning online while still zombie clears cooldown or
quarantine and triggers recovery immediately (failed auth/resubscribe re-arms
cooldown). Recovery is skipped while every subscribed module is known offline. Numeric
event 22 (SIGMA_NETWORK_EVENT_MODULE_MEMORY_UPDATED) also triggers a
per-module REST prime. BragerOneGateway.last_param_update_age_s() returns that gap for
diagnostics.
Connection labels are not hardcoded: resolve them from the live module
i18n namespace (same keys the SPA uses):
from pybragerone.models.i18n import I18nResolver
labels = await I18nResolver(assets).resolve_module_connection_labels(lang="pl")
# labels["serverConnection"], labels["connection.status"],
# labels["connection.connected"], labels["connection.notConnected"], ...
Stable grouping key for the HA connection child device: module.connection
(i18n namespace path — not a menu-router route).
Important
After WebSocket reconnect: Always re-fetch parameters via REST!
# On reconnect, the gateway performs modules.connect + subscribe + prime again.
# Make sure your ParamStore subscriber is active before starting the gateway.
Entity Naming¶
Route vs parameter visibility¶
Everyday web-UI side-menu routes may be gated separately from individual parameters:
ParamResolver.route_visibility_diagnostics— static SPA route gates (installer denylist,isVisibleOnSideMenu,displayDropdownleftovers).ParamResolver.parameter_visibility_diagnostics— per-parameter status bits (INVISIBLE,DEVICE_AVAILABLE).
Static menu shells (e.g. MAINMENU_STREFY_CZASOWE / path timezones) load
parameter tokens from deviceMenu/static/<path>.ts chunks referenced in
index-*.js. Use LiveAssetsCatalog.discover_static_route_tokens,
LiveAssetsCatalog.fetch_alarm_name_source (Alarms chunk / AlarmName enum), and pass
static_route_symbols into build_panel_groups_from_menu (or call
build_panel_groups with a primed ParamStore so overlays are resolved
automatically). For activity feed display values use
ParamResolver.resolve_raw_display_value(raw, unit_code=...) so numeric unit
transforms match parameter entities.
The Home Assistant integration caches entity descriptors in the config entry;
after upgrading to a release that adds static-route overlays, users need a
ha-bragerone release that bumps BOOTSTRAP_VERSION (the release/2026.9
train uses version 14) and must reconfigure or reload the integration so
bootstrap re-runs. Reloading on an integration build that still stores the
previous bootstrap version leaves stale descriptor caches unchanged. Upgrading
py-bragerone alone does not rewrite existing HA entity registries.
build_panel_groups(..., web_ui_only=False) keeps permission/module-item based
grouping and does not apply SPA displayDropdown gates; pass
web_ui_only=True (and primed flat_values) for everyday web-UI parity.
build_panel_groups and panel_route_diagnostics accept
use_store_flat_values=False to skip primed-store overlays and treat dropdown
state as unprimed (structural route discovery). When flat_values is passed
explicitly it always takes precedence over both the store and that switch.
ParamStore.flatten_for_devid(devid) supplies module-scoped snapshots for
multi-module setups; always forward ParamUpdate.devid on upsert/upsert_async.
# Recommended unique_id format for HA entities
unique_id = f"bragerone_{device_id}_{pool}_{chan}{idx}"
# For binary sensors from status bits
unique_id = f"bragerone_{device_id}_{pool}_{chan}{idx}_bit{bit_index}"
# Examples:
# - bragerone_ABC123_P4_v1
# - bragerone_ABC123_P5_s40_bit3