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.
311 lines
11 KiB
Python
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),
|
|
}
|