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.
152 lines
6.1 KiB
Python
152 lines
6.1 KiB
Python
"""Pure helper functions for the omni_pca integration.
|
|
|
|
Anything in this module is deliberately decoupled from Home Assistant and
|
|
the live OmniClient so it can be unit-tested without either dependency.
|
|
The HA-side code (binary_sensor, etc.) imports these and converts the
|
|
returned strings to ``BinarySensorDeviceClass`` enum members.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Final
|
|
|
|
# String values that correspond 1:1 to HA's BinarySensorDeviceClass enum
|
|
# members. We return strings here (instead of importing the enum) so this
|
|
# module stays importable without Home Assistant in the venv.
|
|
DEVICE_CLASS_OPENING: Final = "opening"
|
|
DEVICE_CLASS_DOOR: Final = "door"
|
|
DEVICE_CLASS_WINDOW: Final = "window"
|
|
DEVICE_CLASS_MOTION: Final = "motion"
|
|
DEVICE_CLASS_SMOKE: Final = "smoke"
|
|
DEVICE_CLASS_GAS: Final = "gas"
|
|
DEVICE_CLASS_MOISTURE: Final = "moisture"
|
|
DEVICE_CLASS_TAMPER: Final = "tamper"
|
|
DEVICE_CLASS_SAFETY: Final = "safety"
|
|
DEVICE_CLASS_PROBLEM: Final = "problem"
|
|
DEVICE_CLASS_SOUND: Final = "sound"
|
|
DEVICE_CLASS_HEAT: Final = "heat"
|
|
DEVICE_CLASS_COLD: Final = "cold"
|
|
|
|
|
|
# Maps the Omni ``enuZoneType`` byte (see ``omni_pca.models.ZoneType``) to
|
|
# a HA ``BinarySensorDeviceClass`` string. The mapping is a judgement
|
|
# call — Omni's zone-type taxonomy is finer-grained than HA's binary
|
|
# sensor classes, so we collapse a few buckets:
|
|
#
|
|
# * Perimeter / entry-exit / latching variants → opening
|
|
# (most installs use these for door/window contacts)
|
|
# * Interior / night / away interior → motion (PIRs)
|
|
# * Fire family (FIRE/FIRE_EMERGENCY/FIRE_TAMPER) → smoke
|
|
# * Water / freeze → moisture / cold
|
|
# * Gas → gas
|
|
# * Tamper / latching tamper → tamper
|
|
# * Panic / police / silent duress / aux-emerg → safety
|
|
# * Temperature / humidity / aux → not a binary sensor
|
|
# (callers should skip — see ``is_binary_zone_type``)
|
|
#
|
|
# The default for any unmapped value is "opening", which matches the
|
|
# dominant residential install (perimeter contact).
|
|
_ZONE_TYPE_TO_DEVICE_CLASS: dict[int, str] = {
|
|
# Burglary / contact zones
|
|
0: DEVICE_CLASS_OPENING, # ENTRY_EXIT
|
|
1: DEVICE_CLASS_OPENING, # PERIMETER
|
|
4: DEVICE_CLASS_OPENING, # DOUBLE_ENTRY_DELAY
|
|
5: DEVICE_CLASS_OPENING, # QUAD_ENTRY_DELAY
|
|
6: DEVICE_CLASS_OPENING, # LATCHING_PERIMETER
|
|
67: DEVICE_CLASS_OPENING, # EXIT_TERMINATOR
|
|
# Motion zones
|
|
2: DEVICE_CLASS_MOTION, # NIGHT_INTERIOR
|
|
3: DEVICE_CLASS_MOTION, # AWAY_INTERIOR
|
|
7: DEVICE_CLASS_MOTION, # LATCHING_NIGHT_INTERIOR
|
|
8: DEVICE_CLASS_MOTION, # LATCHING_AWAY_INTERIOR
|
|
# Panic / duress / police family
|
|
16: DEVICE_CLASS_SAFETY, # PANIC
|
|
17: DEVICE_CLASS_SAFETY, # POLICE_EMERGENCY
|
|
18: DEVICE_CLASS_SAFETY, # SILENT_DURESS
|
|
48: DEVICE_CLASS_SAFETY, # AUX_EMERGENCY
|
|
# Tamper
|
|
19: DEVICE_CLASS_TAMPER, # TAMPER
|
|
20: DEVICE_CLASS_TAMPER, # LATCHING_TAMPER
|
|
56: DEVICE_CLASS_TAMPER, # FIRE_TAMPER (treat as tamper, not smoke)
|
|
# Fire family
|
|
32: DEVICE_CLASS_SMOKE, # FIRE
|
|
33: DEVICE_CLASS_SMOKE, # FIRE_EMERGENCY
|
|
# Other safety / environmental
|
|
34: DEVICE_CLASS_GAS, # GAS
|
|
49: DEVICE_CLASS_PROBLEM, # TROUBLE
|
|
54: DEVICE_CLASS_COLD, # FREEZE
|
|
55: DEVICE_CLASS_MOISTURE, # WATER
|
|
# Sound / aux
|
|
64: DEVICE_CLASS_SOUND, # AUXILIARY (loose mapping; use sound)
|
|
65: DEVICE_CLASS_OPENING, # KEYSWITCH
|
|
66: DEVICE_CLASS_OPENING, # SHUNT_LOCK
|
|
}
|
|
|
|
# Zone-type bytes that don't map to a binary sensor at all — they're
|
|
# numeric readings (temperature, humidity, energy) and should be exposed
|
|
# via the sensor platform in Phase B instead. We skip these in
|
|
# binary_sensor setup.
|
|
_ANALOG_ZONE_TYPES: frozenset[int] = frozenset({
|
|
80, # ENERGY_SAVER
|
|
81, # OUTDOOR_TEMP
|
|
82, # TEMPERATURE
|
|
83, # TEMP_ALARM
|
|
84, # HUMIDITY
|
|
})
|
|
|
|
|
|
def device_class_for_zone_type(zone_type: int) -> str:
|
|
"""Return the HA ``BinarySensorDeviceClass`` value for an Omni zone type.
|
|
|
|
Defaults to ``"opening"`` — the most common contact-sensor case — for
|
|
any zone-type byte we don't have an explicit mapping for. Callers
|
|
should check :func:`is_binary_zone_type` first to decide whether the
|
|
zone makes sense as a binary sensor at all.
|
|
"""
|
|
return _ZONE_TYPE_TO_DEVICE_CLASS.get(zone_type, DEVICE_CLASS_OPENING)
|
|
|
|
|
|
def is_binary_zone_type(zone_type: int) -> bool:
|
|
"""True iff this zone type belongs on the binary_sensor platform.
|
|
|
|
Analog/numeric zone types (temperature, humidity, energy savers) are
|
|
sensor-platform candidates, not binary sensors, so we filter them out
|
|
here so the coordinator's discovery doesn't have to know.
|
|
"""
|
|
return zone_type not in _ANALOG_ZONE_TYPES
|
|
|
|
|
|
# Zone types whose live ``is_on`` semantics should be derived from the
|
|
# *latched* alarm bit (alarm tripped) rather than the current condition
|
|
# bit (open/closed). Smoke/fire/gas/water/freeze/panic are latching by
|
|
# nature — a smoke detector that flashed for one second still wants to
|
|
# read "on" until the user clears the alarm.
|
|
_LATCHED_ALARM_ZONE_TYPES: frozenset[int] = frozenset({
|
|
16, 17, 18, # panic family
|
|
19, 20, 56, # tamper family
|
|
32, 33, # fire family
|
|
34, # gas
|
|
48, # aux emergency
|
|
54, 55, # freeze, water
|
|
})
|
|
|
|
|
|
def use_latched_alarm_for_zone(zone_type: int) -> bool:
|
|
"""True if this zone's ``is_on`` should track the latched alarm bit.
|
|
|
|
For door/window/motion zones we use the *current condition* bit (live
|
|
open/closed). For latching alarm zones (smoke, water, panic, …) we
|
|
instead use the latched-tripped bit so a brief sensor blip stays
|
|
visible to the user until the alarm is cleared.
|
|
"""
|
|
return zone_type in _LATCHED_ALARM_ZONE_TYPES
|
|
|
|
|
|
def prettify_name(name: str) -> str:
|
|
"""Convert the panel's ``FRONT_DOOR`` style name into ``Front Door``.
|
|
|
|
Returns an empty string unchanged so callers can use truthiness to
|
|
detect "no name configured on this index".
|
|
"""
|
|
return name.replace("_", " ").strip().title()
|