Ryan Malloy e8ed7d1b89 HA Phase A: rebuild coordinator + binary_sensor on v1.0 client + JOURNEY.md
custom_components/omni_pca/coordinator.py — full rewrite:
- Long-lived OmniClient for entry lifetime
- One-shot discovery: system info + zone/unit/area/thermostat/button names
  via list_*_names + per-index get_object_properties
- Periodic poll (30s default): get_extended_status for zones/units/thermostats,
  get_object_status for areas, skip empty discoveries
- Background _run_event_listener task consuming client.events(), patches
  state in-place and async_set_updated_data on push:
    ZoneStateChanged    -> patch zone_status raw byte
    UnitStateChanged    -> patch unit_status state, preserve brightness
    ArmingChanged       -> patch area_status mode + last_user
    AlarmActivated/Cleared -> trigger refresh
    AcLost/Restored, BatteryLow/Restored -> recorded for sensors
- InvalidEncryptionKeyError/HandshakeError -> ConfigEntryAuthFailed (HA reauth)
- OmniConnectionError/RequestTimeoutError -> UpdateFailed + drop client
- Event task cancelled in async_shutdown

custom_components/omni_pca/binary_sensor.py — full rewrite:
- OmniZoneBinarySensor per discovered zone (device class from zone type:
  smoke/water/freeze use latched-alarm bit; doors/motion use current condition)
- OmniZoneBypassedBinarySensor per zone (DIAGNOSTIC, PROBLEM)
- OmniSystemAcBinarySensor (POWER, prefers AcLost/AcRestored push)
- OmniSystemBatteryBinarySensor (BATTERY)
- OmniSystemTroubleBinarySensor (PROBLEM)

custom_components/omni_pca/helpers.py — pure functions extracted for testing:
- device_class_for_zone_type, is_binary_zone_type, use_latched_alarm_for_zone,
  prettify_name. 61 unit tests in tests/test_ha_helpers.py.

docs/JOURNEY.md — 4383-word raw chronological retrospective of the whole
arc from binary archive to working library. 18 dated sections including
the 2191-byte magic-number header validation moment, the two non-public
protocol quirks, the offline-panel comedy. Source material for future
writeups (intentionally raw, not polished).

264 tests pass (was 203, +61 helper tests). Ruff clean across all dirs.
2026-05-10 14:48:50 -06:00

311 lines
11 KiB
Python

"""Binary sensor platform for the omni_pca integration.
Per-zone entities
-----------------
* :class:`OmniZoneBinarySensor` — one per discovered zone. ``is_on``
derives from :class:`~omni_pca.models.ZoneStatus`. The HA device class
is picked from the zone-type byte by
:func:`~custom_components.omni_pca.helpers.device_class_for_zone_type`.
* :class:`OmniZoneBypassedBinarySensor` — one per discovered zone.
Diagnostic entity (``problem`` device-class) that turns on when the
zone is currently bypassed by the user or auto-bypassed by the panel.
Panel-level entities
--------------------
* :class:`OmniSystemAcBinarySensor` — ``power``-class. ``is_on`` = AC OK.
Tracks both the periodic SystemStatus poll and any pushed
:class:`~omni_pca.events.AcLost` / :class:`~omni_pca.events.AcRestored`
events so HA reacts immediately on a power-blip.
* :class:`OmniSystemBatteryBinarySensor` — ``battery``-class. ``is_on``
when the backup battery reading drops below the panel's threshold
(or a :class:`~omni_pca.events.BatteryLow` event came in since the
last :class:`~omni_pca.events.BatteryRestored`).
* :class:`OmniSystemTroubleBinarySensor` — ``problem``-class. ``is_on``
when SystemStatus reports any troubles.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from omni_pca.events import (
AcLost,
AcRestored,
BatteryLow,
BatteryRestored,
)
from .const import DOMAIN
from .coordinator import OmniDataUpdateCoordinator
from .helpers import (
device_class_for_zone_type,
is_binary_zone_type,
prettify_name,
use_latched_alarm_for_zone,
)
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Create one binary_sensor per discovered zone, plus system-level entities."""
coordinator: OmniDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
entities: list[BinarySensorEntity] = []
for index in sorted(coordinator.data.zones):
props = coordinator.data.zones[index]
if not is_binary_zone_type(props.zone_type):
# Analog zones (temperature, humidity) aren't binary sensors;
# Phase B will surface them on the sensor platform.
continue
entities.append(OmniZoneBinarySensor(coordinator, index))
entities.append(OmniZoneBypassedBinarySensor(coordinator, index))
entities.append(OmniSystemAcBinarySensor(coordinator))
entities.append(OmniSystemBatteryBinarySensor(coordinator))
entities.append(OmniSystemTroubleBinarySensor(coordinator))
async_add_entities(entities)
# --------------------------------------------------------------------------
# Zone entities
# --------------------------------------------------------------------------
class _OmniZoneBaseEntity(
CoordinatorEntity[OmniDataUpdateCoordinator], BinarySensorEntity
):
"""Shared boilerplate for the two per-zone entities."""
_attr_has_entity_name = True
def __init__(
self, coordinator: OmniDataUpdateCoordinator, index: int
) -> None:
super().__init__(coordinator)
self._index = index
self._attr_device_info = coordinator.device_info
@property
def available(self) -> bool:
return (
super().available
and self.coordinator.data is not None
and self._index in self.coordinator.data.zones
)
@property
def _zone_props(self): # type: ignore[no-untyped-def]
return self.coordinator.data.zones.get(self._index)
@property
def _zone_status(self): # type: ignore[no-untyped-def]
return self.coordinator.data.zone_status.get(self._index)
class OmniZoneBinarySensor(_OmniZoneBaseEntity):
"""A single zone exposed as the primary binary_sensor.
Live ``is_on`` derives from the matching :class:`ZoneStatus`:
* For motion / smoke / water / freeze / panic / tamper zones we use
the *latched* tripped bit so a brief pulse stays visible until the
user clears the alarm
(see :func:`~custom_components.omni_pca.helpers.use_latched_alarm_for_zone`).
* For door / window / opening zones we use the *current condition*
bit so HA tracks the door truthfully.
"""
def __init__(
self, coordinator: OmniDataUpdateCoordinator, index: int
) -> None:
super().__init__(coordinator, index)
self._attr_unique_id = f"{coordinator.unique_id}-zone-{index}"
props = coordinator.data.zones[index]
self._attr_name = prettify_name(props.name) or f"Zone {index}"
self._attr_device_class = BinarySensorDeviceClass(
device_class_for_zone_type(props.zone_type)
)
@property
def is_on(self) -> bool | None:
status = self._zone_status
props = self._zone_props
if status is None or props is None:
return None
# Pick the right bit based on zone type — latched-alarm zones
# (smoke, water, panic, …) stay "on" until cleared even after a
# one-shot trip, while contact / motion zones track the live
# current condition bit.
if use_latched_alarm_for_zone(props.zone_type):
return status.is_in_alarm
return status.is_open
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
status = self._zone_status
props = self._zone_props
if status is None or props is None:
return None
return {
"zone_index": self._index,
"zone_type": props.zone_type,
"area": props.area,
"is_open": status.is_open,
"is_bypassed": status.is_bypassed,
"is_in_alarm": status.is_in_alarm,
"is_trouble": status.is_trouble,
"loop_reading": status.loop,
"raw_status": status.raw_status,
}
class OmniZoneBypassedBinarySensor(_OmniZoneBaseEntity):
"""Diagnostic entity that turns on when a zone is bypassed.
Surfacing bypass as its own entity (rather than just an attribute on
the primary sensor) lets automations key on it directly — e.g.
"remind me at 10pm if any zone is still bypassed".
"""
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_device_class = BinarySensorDeviceClass.PROBLEM
def __init__(
self, coordinator: OmniDataUpdateCoordinator, index: int
) -> None:
super().__init__(coordinator, index)
self._attr_unique_id = f"{coordinator.unique_id}-zone-{index}-bypassed"
props = coordinator.data.zones[index]
base = prettify_name(props.name) or f"Zone {index}"
self._attr_name = f"{base} Bypassed"
@property
def is_on(self) -> bool | None:
status = self._zone_status
if status is None:
return None
return status.is_bypassed
# --------------------------------------------------------------------------
# System-level entities
# --------------------------------------------------------------------------
class _OmniSystemBaseEntity(
CoordinatorEntity[OmniDataUpdateCoordinator], BinarySensorEntity
):
"""Shared boilerplate for hub-scoped system binary sensors."""
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(self, coordinator: OmniDataUpdateCoordinator) -> None:
super().__init__(coordinator)
self._attr_device_info = coordinator.device_info
class OmniSystemAcBinarySensor(_OmniSystemBaseEntity):
"""``power`` device class — on when mains AC is present.
Uses the most recent :class:`AcLost` / :class:`AcRestored` push event
as the authoritative signal, falling back to the SystemStatus battery
heuristic when no event has been seen yet (panel never lost AC).
"""
_attr_device_class = BinarySensorDeviceClass.POWER
def __init__(self, coordinator: OmniDataUpdateCoordinator) -> None:
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.unique_id}-system-ac"
self._attr_name = "AC Power"
@property
def is_on(self) -> bool | None:
data = self.coordinator.data
if data is None:
return None
last = data.last_event
if isinstance(last, AcLost):
return False
if isinstance(last, AcRestored):
return True
if data.system_status is not None:
return data.system_status.ac_ok
return None
class OmniSystemBatteryBinarySensor(_OmniSystemBaseEntity):
"""``battery`` device class — on when the backup battery is LOW."""
_attr_device_class = BinarySensorDeviceClass.BATTERY
def __init__(self, coordinator: OmniDataUpdateCoordinator) -> None:
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.unique_id}-system-battery"
self._attr_name = "Backup Battery"
@property
def is_on(self) -> bool | None:
data = self.coordinator.data
if data is None:
return None
last = data.last_event
if isinstance(last, BatteryLow):
return True
if isinstance(last, BatteryRestored):
return False
if data.system_status is not None:
return not data.system_status.battery_ok
return None
@property
def extra_state_attributes(self) -> dict[str, int] | None:
if self.coordinator.data is None or self.coordinator.data.system_status is None:
return None
return {
"battery_reading": self.coordinator.data.system_status.battery_reading,
}
class OmniSystemTroubleBinarySensor(_OmniSystemBaseEntity):
"""``problem`` device class — on when SystemStatus reports any troubles."""
_attr_device_class = BinarySensorDeviceClass.PROBLEM
def __init__(self, coordinator: OmniDataUpdateCoordinator) -> None:
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.unique_id}-system-trouble"
self._attr_name = "System Trouble"
@property
def is_on(self) -> bool | None:
data = self.coordinator.data
if data is None or data.system_status is None:
return None
return bool(data.system_status.troubles)
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
if self.coordinator.data is None or self.coordinator.data.system_status is None:
return None
return {
"troubles": list(self.coordinator.data.system_status.troubles),
}