Home Assistant Integration ========================== The typical integration flow has two distinct phases: Configuration Phase ------------------- During config flow, use asset-aware mode to discover entities. .. code-block:: python # 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. .. code-block:: python # 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 def on_alarm_feed_invalidate(event): ... # REST refresh alarm lists (SPA alarms:change / received) def on_activity_feed_invalidate(event): ... # REST refresh activity lists (quantity badge or task lifecycle) gateway.on_alarm_quantity(on_alarm_quantity) gateway.on_alarm_feed_invalidate(on_alarm_feed_invalidate) gateway.on_activity_feed_invalidate(on_activity_feed_invalidate) # 3. Start gateway (connects WS, subscribes, primes) await gateway.start() Module connectivity ------------------- Two layers — do not conflate them: 1. **Module ↔ cloud** (SPA ``connectedAt``) — plant gateway reachability. Observe and wait when offline; the client cannot repair it. 2. **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, alarm quantity, and alarm/activity **feed invalidate** signals are **not** on that bus (so existing typed ``bus.subscribe()`` loops stay unbroken). Use the dedicated gateway callbacks / poll APIs: .. code-block:: python 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 # gateway.on_alarm_feed_invalidate(...) # SPA alarms:change / received → REST # gateway.on_activity_feed_invalidate(...) # activity quantity / task → 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): 1. **Library ↔ cloud** — ``ws_session_up`` / ``CloudSessionConnectivity`` (+ outage attrs). 2. **Module ↔ cloud** — ``module_online`` / ``ModuleConnectivity`` (+ outage attrs). 3. **Live push health** — ``live_push_health()`` / ``LivePushHealth`` / ``on_live_push``: age since the last **live** ``ParamUpdate`` while the Socket.IO session is up (``push_healthy`` / ``live_stale_for_s``). A zombie is session-up with ``push_healthy=False``. Resume logs ``live ParamUpdate resumed after …s`` and sets ``last_resumed_after_s``. Home Assistant parameter entities treat session-down and ``push_healthy=False`` as 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): .. code-block:: python 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! .. code-block:: python # 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``, ``displayDropdown`` leftovers). - ``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/.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``. .. code-block:: python # 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