Adds CONF_PCA_PATH + CONF_PCA_KEY config-flow fields. When set, the coordinator parses programs from the .pca file at that path instead of streaming them over the wire on every entry refresh. Useful for: * deployments where wire enumeration is slow (1500-slot iteration) * offline snapshots when the panel is unreachable * deterministic test setups against a known fixture The config-flow validates the file is readable and decrypts cleanly, surfacing pca_not_found / pca_decode_failed errors via the strings/ en.json translations. The .pca path is checked first in _discover_programs; if absent the wire path runs as before. So existing deployments are unaffected. Tests cover the success path (live fixture, 330 programs) and the two validation failures (missing file, garbage bytes).
818 lines
31 KiB
Python
818 lines
31 KiB
Python
"""DataUpdateCoordinator that owns the long-lived OmniClient connection.
|
|
|
|
Lifecycle
|
|
---------
|
|
1. ``async_config_entry_first_refresh`` connects, runs a one-time
|
|
*discovery* pass that enumerates every named zone / unit / area /
|
|
thermostat / button / program on the panel, and seeds ``self.data``
|
|
with a populated :class:`OmniData`.
|
|
2. ``_async_update_data`` is then called every :data:`SCAN_INTERVAL` to
|
|
re-poll *live state only* (extended status for zones / units /
|
|
thermostats, basic status for areas).
|
|
3. A background task (:meth:`_run_event_listener`) consumes
|
|
:meth:`OmniClient.events` for the lifetime of the entry; whenever a
|
|
typed :class:`SystemEvent` arrives, the relevant slice of state is
|
|
patched in-place and ``async_set_updated_data`` fires so HA pushes
|
|
updates to subscribed entities without waiting for the next poll.
|
|
|
|
The library's :class:`OmniClient` is the *only* thing that talks to the
|
|
wire. We keep one client per coordinator and close it on shutdown; on a
|
|
recoverable :class:`OmniConnectionError` we drop and recreate it on the
|
|
next refresh, preserving the existing :class:`OmniData` so entities don't
|
|
flicker to "unavailable" between attempts.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
from dataclasses import dataclass, field, replace
|
|
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.exceptions import ConfigEntryAuthFailed
|
|
from homeassistant.helpers.device_registry import DeviceInfo
|
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
|
|
|
from omni_pca.client import ObjectType as ClientObjectType
|
|
from omni_pca.client import OmniClient
|
|
from omni_pca.connection import (
|
|
ConnectionError as OmniConnectionError,
|
|
)
|
|
from omni_pca.connection import (
|
|
HandshakeError,
|
|
InvalidEncryptionKeyError,
|
|
RequestTimeoutError,
|
|
)
|
|
from omni_pca.events import (
|
|
AcLost,
|
|
AcRestored,
|
|
AlarmActivated,
|
|
AlarmCleared,
|
|
ArmingChanged,
|
|
BatteryLow,
|
|
BatteryRestored,
|
|
SystemEvent,
|
|
UnitStateChanged,
|
|
ZoneStateChanged,
|
|
)
|
|
from omni_pca.models import (
|
|
OBJECT_TYPE_TO_PROPERTIES,
|
|
AreaProperties,
|
|
AreaStatus,
|
|
ButtonProperties,
|
|
ObjectType,
|
|
SystemInformation,
|
|
SystemStatus,
|
|
ThermostatProperties,
|
|
ThermostatStatus,
|
|
UnitProperties,
|
|
UnitStatus,
|
|
ZoneProperties,
|
|
ZoneStatus,
|
|
)
|
|
from omni_pca.opcodes import OmniLink2MessageType
|
|
from omni_pca.programs import Program
|
|
|
|
from .const import (
|
|
DOMAIN,
|
|
EVENT_TASK_NAME,
|
|
LOGGER,
|
|
MANUFACTURER,
|
|
MAX_OBJECT_INDEX,
|
|
SCAN_INTERVAL,
|
|
)
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Public data shape exposed to entities
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class OmniData:
|
|
"""Snapshot of everything a coordinator's entities can read.
|
|
|
|
Discovery dictionaries (``zones``, ``units``, ``areas``,
|
|
``thermostats``, ``buttons``, ``programs``) are populated once on
|
|
first refresh and never re-walked — they describe panel topology,
|
|
which only changes when the installer reprograms the controller and
|
|
the user reloads the integration.
|
|
|
|
Live ``*_status`` dictionaries are re-populated on every poll *and*
|
|
patched in-place from the event listener.
|
|
"""
|
|
|
|
system_info: SystemInformation
|
|
zones: dict[int, ZoneProperties] = field(default_factory=dict)
|
|
units: dict[int, UnitProperties] = field(default_factory=dict)
|
|
areas: dict[int, AreaProperties] = field(default_factory=dict)
|
|
thermostats: dict[int, ThermostatProperties] = field(default_factory=dict)
|
|
buttons: dict[int, ButtonProperties] = field(default_factory=dict)
|
|
programs: dict[int, Program] = field(default_factory=dict)
|
|
|
|
zone_status: dict[int, ZoneStatus] = field(default_factory=dict)
|
|
unit_status: dict[int, UnitStatus] = field(default_factory=dict)
|
|
area_status: dict[int, AreaStatus] = field(default_factory=dict)
|
|
thermostat_status: dict[int, ThermostatStatus] = field(default_factory=dict)
|
|
|
|
system_status: SystemStatus | None = None
|
|
last_event: SystemEvent | None = None
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Coordinator
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
|
|
"""Coordinator that owns one :class:`OmniClient` and one panel device."""
|
|
|
|
config_entry: ConfigEntry
|
|
|
|
def __init__(
|
|
self,
|
|
hass: HomeAssistant,
|
|
entry: ConfigEntry,
|
|
*,
|
|
host: str,
|
|
port: int,
|
|
controller_key: bytes,
|
|
transport: str = "tcp",
|
|
pca_path: str | None = None,
|
|
pca_key: int = 0,
|
|
) -> None:
|
|
super().__init__(
|
|
hass,
|
|
LOGGER,
|
|
name=f"{DOMAIN} {host}:{port}",
|
|
update_interval=SCAN_INTERVAL,
|
|
config_entry=entry,
|
|
)
|
|
self._host = host
|
|
self._port = port
|
|
self._controller_key = controller_key
|
|
self._transport = transport
|
|
self._pca_path = pca_path
|
|
self._pca_key = pca_key
|
|
self._client: OmniClient | None = None
|
|
self._discovery_done = False
|
|
self._discovered: OmniData | None = None
|
|
self._event_task: asyncio.Task[None] | None = None
|
|
|
|
# ---- public surface --------------------------------------------------
|
|
|
|
@property
|
|
def unique_id(self) -> str:
|
|
"""Stable identifier for this panel (host:port)."""
|
|
return f"{self._host}:{self._port}"
|
|
|
|
@property
|
|
def client(self) -> OmniClient:
|
|
"""The live OmniClient. Raises if the coordinator hasn't connected yet."""
|
|
if self._client is None:
|
|
raise RuntimeError("OmniClient is not connected")
|
|
return self._client
|
|
|
|
@property
|
|
def device_info(self) -> DeviceInfo:
|
|
"""DeviceInfo for the single hub device this coordinator represents."""
|
|
info = self._discovered.system_info if self._discovered is not None else None
|
|
return DeviceInfo(
|
|
identifiers={(DOMAIN, self.unique_id)},
|
|
name=info.model_name if info is not None else "Omni Panel",
|
|
manufacturer=MANUFACTURER,
|
|
model=info.model_name if info is not None else None,
|
|
sw_version=info.firmware_version if info is not None else None,
|
|
configuration_url=None,
|
|
)
|
|
|
|
async def async_shutdown(self) -> None:
|
|
"""Tear down the event task and the client connection on unload."""
|
|
await self._cancel_event_task()
|
|
await self._drop_client()
|
|
await super().async_shutdown()
|
|
|
|
# ---- DataUpdateCoordinator hook -------------------------------------
|
|
|
|
async def _async_update_data(self) -> OmniData:
|
|
try:
|
|
client = await self._ensure_connected()
|
|
if not self._discovery_done:
|
|
self._discovered = await self._run_discovery(client)
|
|
self._discovery_done = True
|
|
self._start_event_task()
|
|
|
|
assert self._discovered is not None
|
|
base = self._discovered
|
|
zone_status = await self._poll_zone_status(client, base.zones)
|
|
unit_status = await self._poll_unit_status(client, base.units)
|
|
area_status = await self._poll_area_status(client, base.areas)
|
|
thermostat_status = await self._poll_thermostat_status(
|
|
client, base.thermostats
|
|
)
|
|
system_status = await self._safe_system_status(client)
|
|
except (InvalidEncryptionKeyError, HandshakeError) as err:
|
|
# Surface as auth failure so HA triggers the reauth flow.
|
|
await self._drop_client()
|
|
raise ConfigEntryAuthFailed(str(err)) from err
|
|
except (OmniConnectionError, RequestTimeoutError, OSError) as err:
|
|
await self._drop_client()
|
|
raise UpdateFailed(f"panel unreachable: {err}") from err
|
|
|
|
# Preserve any last_event already captured by the event task; the
|
|
# poll path doesn't see push events so it must not overwrite it.
|
|
last_event = self.data.last_event if self.data is not None else None
|
|
|
|
return replace(
|
|
self._discovered,
|
|
zone_status=zone_status,
|
|
unit_status=unit_status,
|
|
area_status=area_status,
|
|
thermostat_status=thermostat_status,
|
|
system_status=system_status,
|
|
last_event=last_event,
|
|
)
|
|
|
|
# ---- connection management ------------------------------------------
|
|
|
|
async def _ensure_connected(self) -> OmniClient:
|
|
if self._client is not None:
|
|
return self._client
|
|
if self._transport == "udp":
|
|
# Panels listening UDP-only speak the v1 wire protocol, not
|
|
# v2. The adapter exposes the OmniClient API surface this
|
|
# coordinator was written against, but underneath it drives
|
|
# an OmniConnectionV1 + the typed v1 status/command opcodes.
|
|
from omni_pca.v1 import OmniClientV1Adapter
|
|
|
|
client: OmniClient = OmniClientV1Adapter( # type: ignore[assignment]
|
|
self._host,
|
|
port=self._port,
|
|
controller_key=self._controller_key,
|
|
)
|
|
else:
|
|
client = OmniClient(
|
|
self._host,
|
|
port=self._port,
|
|
controller_key=self._controller_key,
|
|
transport=self._transport, # type: ignore[arg-type]
|
|
)
|
|
# Drive __aenter__ manually so the client survives across update
|
|
# cycles; we close it explicitly on shutdown / failure.
|
|
await client.__aenter__()
|
|
self._client = client
|
|
return client
|
|
|
|
async def _drop_client(self) -> None:
|
|
if self._client is None:
|
|
return
|
|
client = self._client
|
|
self._client = None
|
|
try:
|
|
await client.__aexit__(None, None, None)
|
|
except Exception: # pragma: no cover - best-effort cleanup
|
|
LOGGER.debug("error during client cleanup", exc_info=True)
|
|
|
|
# ---- discovery -------------------------------------------------------
|
|
|
|
async def _run_discovery(self, client: OmniClient) -> OmniData:
|
|
"""Walk every object type once and stash the static topology."""
|
|
system_info = await client.get_system_information()
|
|
|
|
zones = await self._discover_zones(client)
|
|
units = await self._discover_units(client)
|
|
areas = await self._discover_areas(client)
|
|
thermostats = await self._discover_thermostats(client)
|
|
buttons = await self._discover_buttons(client)
|
|
programs = await self._discover_programs(client)
|
|
|
|
LOGGER.info(
|
|
"omni_pca discovery: %d zones, %d units, %d areas, "
|
|
"%d thermostats, %d buttons, %d programs",
|
|
len(zones),
|
|
len(units),
|
|
len(areas),
|
|
len(thermostats),
|
|
len(buttons),
|
|
len(programs),
|
|
)
|
|
return OmniData(
|
|
system_info=system_info,
|
|
zones=zones,
|
|
units=units,
|
|
areas=areas,
|
|
thermostats=thermostats,
|
|
buttons=buttons,
|
|
programs=programs,
|
|
)
|
|
|
|
async def _discover_zones(
|
|
self, client: OmniClient
|
|
) -> dict[int, ZoneProperties]:
|
|
names = await self._best_effort(client.list_zone_names, default={})
|
|
out: dict[int, ZoneProperties] = {}
|
|
for index in sorted(names):
|
|
try:
|
|
props = await client.get_object_properties(
|
|
ClientObjectType.ZONE, index
|
|
)
|
|
except (OmniConnectionError, RequestTimeoutError):
|
|
raise
|
|
except Exception:
|
|
LOGGER.debug("zone %d properties fetch failed", index, exc_info=True)
|
|
continue
|
|
if isinstance(props, ZoneProperties):
|
|
out[index] = props
|
|
return out
|
|
|
|
async def _discover_units(
|
|
self, client: OmniClient
|
|
) -> dict[int, UnitProperties]:
|
|
names = await self._best_effort(client.list_unit_names, default={})
|
|
out: dict[int, UnitProperties] = {}
|
|
for index in sorted(names):
|
|
try:
|
|
props = await client.get_object_properties(
|
|
ClientObjectType.UNIT, index
|
|
)
|
|
except (OmniConnectionError, RequestTimeoutError):
|
|
raise
|
|
except Exception:
|
|
LOGGER.debug("unit %d properties fetch failed", index, exc_info=True)
|
|
continue
|
|
if isinstance(props, UnitProperties):
|
|
out[index] = props
|
|
return out
|
|
|
|
async def _discover_areas(
|
|
self, client: OmniClient
|
|
) -> dict[int, AreaProperties]:
|
|
names = await self._best_effort(client.list_area_names, default={})
|
|
out: dict[int, AreaProperties] = {}
|
|
for index in sorted(names):
|
|
try:
|
|
props = await client.get_object_properties(
|
|
ClientObjectType.AREA, index
|
|
)
|
|
except (OmniConnectionError, RequestTimeoutError):
|
|
raise
|
|
except Exception:
|
|
LOGGER.debug("area %d properties fetch failed", index, exc_info=True)
|
|
continue
|
|
if isinstance(props, AreaProperties):
|
|
out[index] = props
|
|
return out
|
|
|
|
async def _discover_thermostats(
|
|
self, client: OmniClient
|
|
) -> dict[int, ThermostatProperties]:
|
|
"""Walk thermostat properties via the low-level connection.
|
|
|
|
The high-level :meth:`OmniClient.get_object_properties` only knows
|
|
zone/unit/area parsers in v1.0 of the library; thermostats are in
|
|
:data:`OBJECT_TYPE_TO_PROPERTIES` on the model side, so we drive
|
|
the wire ourselves and parse with the model's class.
|
|
"""
|
|
return await self._walk_properties(
|
|
client, ObjectType.THERMOSTAT, ThermostatProperties
|
|
)
|
|
|
|
async def _discover_buttons(
|
|
self, client: OmniClient
|
|
) -> dict[int, ButtonProperties]:
|
|
return await self._walk_properties(
|
|
client, ObjectType.BUTTON, ButtonProperties
|
|
)
|
|
|
|
async def _discover_programs(
|
|
self, client: OmniClient
|
|
) -> dict[int, Program]:
|
|
"""Enumerate defined panel programs.
|
|
|
|
Two sources, in order of preference:
|
|
|
|
1. ``CONF_PCA_PATH`` is configured → parse the .pca file and
|
|
extract the programs block. Avoids streaming 1500 records on
|
|
every entry refresh and works against an offline snapshot.
|
|
2. Otherwise → enumerate over the wire:
|
|
* v2 (TCP): ``client.iter_programs()`` drives UploadProgram
|
|
with request_reason=1 ("next defined after slot").
|
|
* v1 (UDP): adapter forwards to OmniClientV1.iter_programs(),
|
|
a bare UploadPrograms stream ack-walked to EOD.
|
|
|
|
Both paths yield :class:`omni_pca.programs.Program` and skip
|
|
empty slots. Errors are logged and swallowed — programs are
|
|
non-critical discovery, so a partial list beats blocking setup.
|
|
"""
|
|
if self._pca_path:
|
|
return await self._discover_programs_from_pca()
|
|
out: dict[int, Program] = {}
|
|
try:
|
|
async for prog in client.iter_programs():
|
|
if prog.slot is not None:
|
|
out[prog.slot] = prog
|
|
except (OmniConnectionError, RequestTimeoutError):
|
|
raise
|
|
except Exception:
|
|
LOGGER.debug(
|
|
"program enumeration interrupted (kept %d)", len(out), exc_info=True
|
|
)
|
|
return out
|
|
|
|
async def _discover_programs_from_pca(self) -> dict[int, Program]:
|
|
"""Parse the configured .pca file and pull out its programs block.
|
|
|
|
Runs the disk I/O on the executor since :mod:`pca_file` does
|
|
sync reads. Any failure (missing file, bad key, malformed block)
|
|
is logged and downgraded to an empty dict — the rest of
|
|
discovery still works.
|
|
"""
|
|
from pathlib import Path
|
|
|
|
from omni_pca.pca_file import parse_pca_file
|
|
|
|
try:
|
|
data = await self.hass.async_add_executor_job(
|
|
Path(self._pca_path).read_bytes
|
|
)
|
|
acct = await self.hass.async_add_executor_job(
|
|
lambda: parse_pca_file(data, key=self._pca_key)
|
|
)
|
|
except Exception:
|
|
LOGGER.warning(
|
|
"failed to load programs from %s — falling back to empty list",
|
|
self._pca_path, exc_info=True,
|
|
)
|
|
return {}
|
|
out: dict[int, Program] = {}
|
|
for prog in acct.programs:
|
|
if prog.slot is None or prog.is_empty():
|
|
continue
|
|
out[prog.slot] = prog
|
|
return out
|
|
|
|
async def _walk_properties(
|
|
self,
|
|
client: OmniClient,
|
|
object_type: ObjectType,
|
|
parser: type,
|
|
) -> dict[int, object]:
|
|
"""Walk every defined object of ``object_type`` and parse with ``parser``.
|
|
|
|
Mirrors the strategy used by ``OmniClient._walk_named_objects`` but
|
|
works for any model in :data:`OBJECT_TYPE_TO_PROPERTIES` (the
|
|
client's internal parser table only covers zones/units/areas in
|
|
v1.0). We drive ``RequestProperties`` directly on the connection
|
|
so we don't have to monkey-patch the library.
|
|
|
|
On UDP/v1 panels there is no ``RequestProperties`` opcode at all,
|
|
so we fall back to the v1 adapter's name-stream-based discovery
|
|
(each object's ``Properties`` is synthesized from its name).
|
|
"""
|
|
if parser is None or OBJECT_TYPE_TO_PROPERTIES.get(int(object_type)) is None:
|
|
return {}
|
|
if self._transport == "udp":
|
|
return await self._walk_properties_v1(client, object_type)
|
|
out: dict[int, object] = {}
|
|
cursor = 0
|
|
conn = client.connection
|
|
# Manual request/reply loop with relative_direction=1 (=next).
|
|
for _ in range(MAX_OBJECT_INDEX):
|
|
payload = bytes(
|
|
[
|
|
int(object_type),
|
|
(cursor >> 8) & 0xFF,
|
|
cursor & 0xFF,
|
|
1, # relative_direction = next
|
|
0, 0, 0, # filter1..3
|
|
]
|
|
)
|
|
try:
|
|
reply = await conn.request(
|
|
OmniLink2MessageType.RequestProperties, payload
|
|
)
|
|
except RequestTimeoutError:
|
|
break
|
|
if reply.opcode == int(OmniLink2MessageType.EOD):
|
|
break
|
|
if reply.opcode != int(OmniLink2MessageType.Properties):
|
|
break
|
|
try:
|
|
obj = parser.parse(reply.payload)
|
|
except Exception:
|
|
LOGGER.debug(
|
|
"parse failed for %s past index %d",
|
|
object_type.name,
|
|
cursor,
|
|
exc_info=True,
|
|
)
|
|
break
|
|
# Object name being empty is OK for buttons/programs but the
|
|
# spec says "named only" — we still keep the entry as a
|
|
# candidate; entity setup filters by truthiness.
|
|
index_attr = getattr(obj, "index", None)
|
|
name_attr = getattr(obj, "name", "")
|
|
if index_attr is None:
|
|
break
|
|
if name_attr:
|
|
out[index_attr] = obj
|
|
cursor = index_attr
|
|
if cursor >= MAX_OBJECT_INDEX:
|
|
break
|
|
return out
|
|
|
|
async def _walk_properties_v1(
|
|
self, client: OmniClient, object_type: ObjectType
|
|
) -> dict[int, object]:
|
|
"""V1 fallback for :meth:`_walk_properties`.
|
|
|
|
v1 has no RequestProperties opcode — names come from streaming
|
|
UploadNames and the rest of the Properties fields can't be
|
|
recovered from the wire. We delegate to the adapter's
|
|
``get_object_properties`` (which synthesizes a minimal record
|
|
from the cached name list) and skip anything it returns ``None``
|
|
for.
|
|
"""
|
|
# Pick the right per-type name lister. The adapter caches the
|
|
# UploadNames stream output so these are nearly free after the
|
|
# first call this discovery pass.
|
|
if object_type == ObjectType.THERMOSTAT:
|
|
names = await client.list_thermostat_names() # type: ignore[attr-defined]
|
|
elif object_type == ObjectType.BUTTON:
|
|
names = await client.list_button_names() # type: ignore[attr-defined]
|
|
else:
|
|
# Programs / Messages / etc — nothing to walk.
|
|
return {}
|
|
out: dict[int, object] = {}
|
|
for idx in sorted(names):
|
|
try:
|
|
props = await client.get_object_properties(object_type, idx)
|
|
except Exception:
|
|
LOGGER.debug(
|
|
"v1 properties synth failed for %s #%d",
|
|
object_type.name, idx, exc_info=True,
|
|
)
|
|
continue
|
|
if props is not None:
|
|
out[idx] = props
|
|
return out
|
|
|
|
@staticmethod
|
|
async def _best_effort(coro_fn, *, default):
|
|
"""Call ``coro_fn()`` and swallow non-transport errors, returning ``default``.
|
|
|
|
We let :class:`OmniConnectionError` / :class:`RequestTimeoutError`
|
|
propagate so the coordinator can drop the client and reconnect;
|
|
anything else (a parse failure on a particular reply, NAK on a
|
|
feature the panel doesn't support) is downgraded to a debug log.
|
|
"""
|
|
try:
|
|
return await coro_fn()
|
|
except (OmniConnectionError, RequestTimeoutError):
|
|
raise
|
|
except Exception:
|
|
LOGGER.debug("best-effort %s failed", coro_fn.__name__, exc_info=True)
|
|
return default
|
|
|
|
# ---- live polling ----------------------------------------------------
|
|
|
|
async def _poll_zone_status(
|
|
self, client: OmniClient, zones: dict[int, ZoneProperties]
|
|
) -> dict[int, ZoneStatus]:
|
|
if not zones:
|
|
return {}
|
|
end = max(zones)
|
|
try:
|
|
records = await client.get_extended_status(ObjectType.ZONE, 1, end)
|
|
except (OmniConnectionError, RequestTimeoutError):
|
|
raise
|
|
except Exception:
|
|
LOGGER.debug("zone extended_status poll failed", exc_info=True)
|
|
return self.data.zone_status if self.data is not None else {}
|
|
return {
|
|
r.index: r
|
|
for r in records
|
|
if isinstance(r, ZoneStatus) and r.index in zones
|
|
}
|
|
|
|
async def _poll_unit_status(
|
|
self, client: OmniClient, units: dict[int, UnitProperties]
|
|
) -> dict[int, UnitStatus]:
|
|
if not units:
|
|
return {}
|
|
end = max(units)
|
|
try:
|
|
records = await client.get_extended_status(ObjectType.UNIT, 1, end)
|
|
except (OmniConnectionError, RequestTimeoutError):
|
|
raise
|
|
except Exception:
|
|
LOGGER.debug("unit extended_status poll failed", exc_info=True)
|
|
return self.data.unit_status if self.data is not None else {}
|
|
return {
|
|
r.index: r
|
|
for r in records
|
|
if isinstance(r, UnitStatus) and r.index in units
|
|
}
|
|
|
|
async def _poll_area_status(
|
|
self, client: OmniClient, areas: dict[int, AreaProperties]
|
|
) -> dict[int, AreaStatus]:
|
|
if not areas:
|
|
return {}
|
|
end = max(areas)
|
|
try:
|
|
records = await client.get_object_status(ObjectType.AREA, 1, end)
|
|
except (OmniConnectionError, RequestTimeoutError):
|
|
raise
|
|
except Exception:
|
|
LOGGER.debug("area status poll failed", exc_info=True)
|
|
return self.data.area_status if self.data is not None else {}
|
|
return {
|
|
r.index: r
|
|
for r in records
|
|
if isinstance(r, AreaStatus) and r.index in areas
|
|
}
|
|
|
|
async def _poll_thermostat_status(
|
|
self, client: OmniClient, thermostats: dict[int, ThermostatProperties]
|
|
) -> dict[int, ThermostatStatus]:
|
|
if not thermostats:
|
|
return {}
|
|
end = max(thermostats)
|
|
try:
|
|
records = await client.get_extended_status(
|
|
ObjectType.THERMOSTAT, 1, end
|
|
)
|
|
except (OmniConnectionError, RequestTimeoutError):
|
|
raise
|
|
except Exception:
|
|
LOGGER.debug("thermostat extended_status poll failed", exc_info=True)
|
|
return (
|
|
self.data.thermostat_status if self.data is not None else {}
|
|
)
|
|
return {
|
|
r.index: r
|
|
for r in records
|
|
if isinstance(r, ThermostatStatus) and r.index in thermostats
|
|
}
|
|
|
|
async def _safe_system_status(
|
|
self, client: OmniClient
|
|
) -> SystemStatus | None:
|
|
try:
|
|
return await client.get_system_status()
|
|
except (OmniConnectionError, RequestTimeoutError):
|
|
raise
|
|
except Exception:
|
|
LOGGER.debug("get_system_status failed", exc_info=True)
|
|
return None
|
|
|
|
# ---- event listener --------------------------------------------------
|
|
|
|
def _start_event_task(self) -> None:
|
|
if self._event_task is not None and not self._event_task.done():
|
|
return
|
|
self._event_task = self.config_entry.async_create_background_task(
|
|
self.hass,
|
|
self._run_event_listener(),
|
|
EVENT_TASK_NAME,
|
|
)
|
|
|
|
async def _cancel_event_task(self) -> None:
|
|
if self._event_task is None:
|
|
return
|
|
task = self._event_task
|
|
self._event_task = None
|
|
if not task.done():
|
|
task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
await task
|
|
|
|
async def _run_event_listener(self) -> None:
|
|
"""Background loop: consume typed events and push state to entities.
|
|
|
|
Re-establishes the iterator on each connection cycle. If the
|
|
client gets dropped (transport error during a poll), we exit; the
|
|
next ``_async_update_data`` will reconnect and respawn this task.
|
|
"""
|
|
client = self._client
|
|
if client is None:
|
|
return
|
|
try:
|
|
async for event in client.events():
|
|
self._apply_event(event)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except (OmniConnectionError, RequestTimeoutError, OSError):
|
|
LOGGER.debug("event listener exited on transport error", exc_info=True)
|
|
except Exception: # pragma: no cover - defensive
|
|
LOGGER.exception("event listener crashed")
|
|
|
|
def _apply_event(self, event: SystemEvent) -> None:
|
|
"""Patch ``self.data`` in place for the relevant event subclass."""
|
|
data = self.data
|
|
if data is None:
|
|
return
|
|
new_data = self._patched_for_event(data, event)
|
|
if new_data is not None:
|
|
self.async_set_updated_data(new_data)
|
|
|
|
def _patched_for_event(
|
|
self, data: OmniData, event: SystemEvent
|
|
) -> OmniData | None:
|
|
"""Return a new OmniData reflecting ``event``, or ``None`` to skip.
|
|
|
|
Pure-ish (mutates only the dict members of the returned snapshot).
|
|
Split out so it stays unit-testable without HA.
|
|
"""
|
|
if isinstance(event, ZoneStateChanged):
|
|
existing = data.zone_status.get(event.zone_index)
|
|
if existing is None:
|
|
# We saw a zone the discovery missed — synthesize a record
|
|
# so entities at least see the open/closed flip.
|
|
new_status = ZoneStatus(
|
|
index=event.zone_index,
|
|
raw_status=0x01 if event.is_open else 0x00,
|
|
loop=0,
|
|
)
|
|
else:
|
|
# Toggle low-2-bit current condition; preserve the rest.
|
|
base = existing.raw_status & ~0x03
|
|
new_raw = base | (0x01 if event.is_open else 0x00)
|
|
new_status = ZoneStatus(
|
|
index=existing.index,
|
|
raw_status=new_raw,
|
|
loop=existing.loop,
|
|
)
|
|
patched = dict(data.zone_status)
|
|
patched[event.zone_index] = new_status
|
|
return replace(data, zone_status=patched, last_event=event)
|
|
|
|
if isinstance(event, UnitStateChanged):
|
|
existing = data.unit_status.get(event.unit_index)
|
|
new_state = 1 if event.is_on else 0
|
|
if existing is None:
|
|
new_status = UnitStatus(
|
|
index=event.unit_index,
|
|
state=new_state,
|
|
time_remaining_secs=0,
|
|
)
|
|
else:
|
|
# Preserve a brightness level if we have one — the event
|
|
# only carries on/off.
|
|
if existing.state >= 100 and event.is_on:
|
|
new_status = existing
|
|
else:
|
|
new_status = UnitStatus(
|
|
index=existing.index,
|
|
state=new_state,
|
|
time_remaining_secs=existing.time_remaining_secs,
|
|
)
|
|
patched = dict(data.unit_status)
|
|
patched[event.unit_index] = new_status
|
|
return replace(data, unit_status=patched, last_event=event)
|
|
|
|
if isinstance(event, ArmingChanged):
|
|
existing = data.area_status.get(event.area_index)
|
|
if existing is None:
|
|
if event.area_index == 0:
|
|
# System-wide arming change with no specific area —
|
|
# let the next poll resync.
|
|
return replace(data, last_event=event)
|
|
new_status = AreaStatus(
|
|
index=event.area_index,
|
|
mode=event.new_mode,
|
|
last_user=event.user_index,
|
|
entry_timer_secs=0,
|
|
exit_timer_secs=0,
|
|
alarms=0,
|
|
)
|
|
else:
|
|
new_status = AreaStatus(
|
|
index=existing.index,
|
|
mode=event.new_mode,
|
|
last_user=event.user_index,
|
|
entry_timer_secs=existing.entry_timer_secs,
|
|
exit_timer_secs=existing.exit_timer_secs,
|
|
alarms=existing.alarms,
|
|
)
|
|
patched = dict(data.area_status)
|
|
patched[new_status.index] = new_status
|
|
return replace(data, area_status=patched, last_event=event)
|
|
|
|
if isinstance(event, AlarmActivated | AlarmCleared):
|
|
# Force a poll so AreaStatus.alarms picks up the current bits.
|
|
self.hass.async_create_task(self.async_request_refresh())
|
|
return replace(data, last_event=event)
|
|
|
|
if isinstance(event, AcLost | AcRestored | BatteryLow | BatteryRestored):
|
|
# Just stash the event; the system_* binary sensors derive
|
|
# their state from `last_event` alone.
|
|
return replace(data, last_event=event)
|
|
|
|
# Other event families are interesting but don't move any
|
|
# currently-modeled state — record them for diagnostics so
|
|
# subscribers can still react via the last_event attribute.
|
|
return replace(data, last_event=event)
|
|
|