diff --git a/custom_components/omni_pca/config_flow.py b/custom_components/omni_pca/config_flow.py index 4f8ef0d..6dc5c90 100644 --- a/custom_components/omni_pca/config_flow.py +++ b/custom_components/omni_pca/config_flow.py @@ -168,15 +168,40 @@ class OmniConfigFlow(ConfigFlow, domain=DOMAIN): key: bytes, transport: str = DEFAULT_TRANSPORT, ) -> tuple[str | None, str | None]: - """Try to connect once. Returns (title, error_code).""" + """Try to connect once. Returns (title, error_code). + + TCP uses :class:`OmniClient` (v2 wire protocol). UDP uses the v1 + adapter — UDP-listening panels speak the legacy wire protocol, + not OmniLink2 — see :mod:`omni_pca.v1.adapter` for the bridge. + """ try: - async with OmniClient( - host, - port=port, - controller_key=key, - transport=transport, # type: ignore[arg-type] - ) as client: - info = await client.get_system_information() + if transport == TRANSPORT_UDP: + from omni_pca.v1 import ( + HandshakeError as V1HandshakeError, + ) + from omni_pca.v1 import ( + InvalidEncryptionKeyError as V1InvalidEncryptionKeyError, + ) + from omni_pca.v1 import OmniClientV1Adapter + from omni_pca.v1.connection import ( + ConnectionError as V1ConnectionError, + ) + + try: + async with OmniClientV1Adapter( + host, port=port, controller_key=key, + ) as client: + info = await client.get_system_information() + except (V1HandshakeError, V1InvalidEncryptionKeyError): + return None, "invalid_auth" + except (V1ConnectionError, OSError, TimeoutError) as err: + LOGGER.debug("v1 probe failed: %s", err) + return None, "cannot_connect" + else: + async with OmniClient( + host, port=port, controller_key=key, transport=transport, # type: ignore[arg-type] + ) as client: + info = await client.get_system_information() except (HandshakeError, InvalidEncryptionKeyError): return None, "invalid_auth" except (OmniConnectionError, OSError, TimeoutError) as err: diff --git a/custom_components/omni_pca/coordinator.py b/custom_components/omni_pca/coordinator.py index 0619a73..03db36b 100644 --- a/custom_components/omni_pca/coordinator.py +++ b/custom_components/omni_pca/coordinator.py @@ -234,12 +234,25 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]): async def _ensure_connected(self) -> OmniClient: if self._client is not None: return self._client - client = OmniClient( - self._host, - port=self._port, - controller_key=self._controller_key, - transport=self._transport, # type: ignore[arg-type] - ) + 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__() @@ -392,9 +405,15 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]): 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 @@ -443,6 +462,42 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]): 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``. diff --git a/dev/add_real_panel.py b/dev/add_real_panel.py new file mode 100644 index 0000000..35634a9 --- /dev/null +++ b/dev/add_real_panel.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Add a *second* omni_pca config entry pointing at the real panel. + +The dev stack already has one entry pointing at the mock panel +(``host.docker.internal:14369``). This script adds another entry for +the real panel at ``192.168.1.9:4369`` using ``transport=udp`` and the +controller key from the bundled .pca fixture. + +Run inside the project venv: + cd /home/kdm/home-auto/omni-pca + uv run python dev/add_real_panel.py +""" + +from __future__ import annotations + +import argparse +import asyncio +import sys +from pathlib import Path + +import httpx + +sys.path.insert(0, str(Path(__file__).parent)) +from probe_v1 import _load_key # type: ignore # noqa: E402 + +DEFAULT_HA_URL = "http://localhost:8123" +PANEL_HOST = "192.168.1.9" +PANEL_PORT = 4369 + + +DEFAULT_USERNAME = "demo" +DEFAULT_PASSWORD = "demo-password-1234" + + +async def _get_token(ha_url: str) -> str: + """Re-use the cached access token; otherwise log in via /auth/login_flow.""" + token_file = ( + Path(__file__).parent / "ha-config" / ".storage" / ".demo_access_token" + ) + if token_file.exists(): + return token_file.read_text().strip() + async with httpx.AsyncClient(base_url=ha_url, timeout=15.0) as client: + r = await client.post( + "/auth/login_flow", + json={ + "client_id": ha_url, + "handler": ["homeassistant", None], + "redirect_uri": ha_url, + }, + ) + r.raise_for_status() + flow_id = r.json()["flow_id"] + r = await client.post( + f"/auth/login_flow/{flow_id}", + json={ + "client_id": ha_url, + "username": DEFAULT_USERNAME, + "password": DEFAULT_PASSWORD, + }, + ) + r.raise_for_status() + auth_code = r.json()["result"] + r = await client.post( + "/auth/token", + data={ + "client_id": ha_url, + "grant_type": "authorization_code", + "code": auth_code, + }, + ) + r.raise_for_status() + token = r.json()["access_token"] + # Cache for next run. + try: + token_file.write_text(token) + except Exception: + pass + return token + + +async def amain(args: argparse.Namespace) -> int: + key_bytes = _load_key(None) + key_hex = key_bytes.hex() + print(f"[add-real-panel] target HA: {args.ha_url}") + print(f"[add-real-panel] panel: {PANEL_HOST}:{PANEL_PORT} (UDP)") + print(f"[add-real-panel] key: ...{key_hex[-4:]} (16 bytes)\n") + + token = await _get_token(args.ha_url) + headers = {"Authorization": f"Bearer {token}"} + + async with httpx.AsyncClient(base_url=args.ha_url, timeout=30.0) as client: + # ---- check if an entry already exists for this host ---- + r = await client.get( + "/api/config/config_entries/entry", headers=headers + ) + r.raise_for_status() + for entry in r.json(): + if entry.get("domain") != "omni_pca": + continue + data = entry.get("data", {}) + if data.get("host") == PANEL_HOST and data.get("port") == PANEL_PORT: + print(f" already configured: {entry['title']} ({entry['entry_id']})") + return 0 + + # ---- start the config flow ---- + r = await client.post( + "/api/config/config_entries/flow", + headers=headers, + json={"handler": "omni_pca", "show_advanced_options": False}, + ) + r.raise_for_status() + flow = r.json() + flow_id = flow["flow_id"] + print(f" flow opened: {flow_id} (step={flow.get('step_id')})") + + # ---- submit the form for the real panel ---- + r = await client.post( + f"/api/config/config_entries/flow/{flow_id}", + headers=headers, + json={ + "host": PANEL_HOST, + "port": PANEL_PORT, + "controller_key": key_hex, + "transport": "udp", + }, + timeout=60.0, # the probe round-trip can take a few seconds + ) + r.raise_for_status() + result = r.json() + if result.get("type") == "create_entry": + print(f" ✓ entry created: {result.get('title')}") + print(f" entry_id: {result.get('result')}") + elif result.get("type") == "form": + print(f" form re-shown — errors: {result.get('errors')}") + return 1 + else: + print(f" unexpected outcome: {result}") + return 1 + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--ha-url", default=DEFAULT_HA_URL) + args = parser.parse_args() + return asyncio.run(amain(args)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/dev/probe_v1_coordinator.py b/dev/probe_v1_coordinator.py new file mode 100644 index 0000000..423b51d --- /dev/null +++ b/dev/probe_v1_coordinator.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Phase-3 smoke test: drive OmniClientV1Adapter through the same +sequence the HA coordinator runs in async_config_entry_first_refresh. + +Doesn't pull in HA; just executes the discovery + initial poll pattern +against the real panel and prints what an OmniData snapshot would look +like. If this works, the actual HA coordinator should work too. + +Run: + cd /home/kdm/home-auto/omni-pca + uv run python dev/probe_v1_coordinator.py +""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from probe_v1 import _load_key # type: ignore # noqa: E402 + +from omni_pca.models import ObjectType +from omni_pca.v1 import OmniClientV1Adapter + + +async def amain(args: argparse.Namespace) -> int: + key = _load_key(args.key) + print(f"[coord probe] target {args.host}:{args.port}\n") + + async with OmniClientV1Adapter( + host=args.host, port=args.port, controller_key=key, timeout=10.0 + ) as c: + # ---- 1. SystemInformation ---- + info = await c.get_system_information() + print(f"=== SystemInformation ===\n" + f" model={info.model_name} fw={info.firmware_version}\n") + + # ---- 2. Discovery: per-type names + synthesized properties ---- + print("=== Discovery (UploadNames stream + synth Properties) ===") + zone_names = await c.list_zone_names() + unit_names = await c.list_unit_names() + area_names = await c.list_area_names() + tstat_names = await c.list_thermostat_names() + button_names = await c.list_button_names() + print(f" zones: {len(zone_names)}") + print(f" units: {len(unit_names)}") + print(f" areas: {len(area_names)} (fallback if 0 streamed)") + print(f" thermostats: {len(tstat_names)}") + print(f" buttons: {len(button_names)}") + print() + + # Sanity-check that get_object_properties returns a real dataclass + # for one zone, one unit, one area, one thermostat, one button. + for type_byte, name_dict, label in [ + (ObjectType.ZONE, zone_names, "Zone"), + (ObjectType.UNIT, unit_names, "Unit"), + (ObjectType.AREA, area_names, "Area"), + (ObjectType.THERMOSTAT, tstat_names, "Thermostat"), + (ObjectType.BUTTON, button_names, "Button"), + ]: + if not name_dict: + print(f" {label}: no entries, skipping property synth") + continue + idx = min(name_dict) + props = await c.get_object_properties(type_byte, idx) + print(f" {label} #{idx}: {props}") + print() + + # ---- 3. Polling: bulk status for each type, plus area derivation ---- + print("=== Polling (bulk status) ===") + if zone_names: + zone_end = max(zone_names) + zones = await c.get_extended_status(ObjectType.ZONE, 1, zone_end) + open_zones = [z for z in zones if getattr(z, "is_open", False)] + print(f" ZoneStatus[1..{zone_end}]: {len(zones)} records, " + f"{len(open_zones)} currently open") + if unit_names: + unit_end = max(unit_names) + units = await c.get_extended_status(ObjectType.UNIT, 1, unit_end) + on_units = [u for u in units if getattr(u, "is_on", False)] + print(f" UnitStatus[1..{unit_end}]: {len(units)} records, " + f"{len(on_units)} currently on") + if tstat_names: + tstat_end = max(tstat_names) + tstats = await c.get_extended_status( + ObjectType.THERMOSTAT, 1, tstat_end + ) + print(f" ThermostatStatus[1..{tstat_end}]: {len(tstats)} records") + + # Areas: derived from SystemStatus + if area_names: + area_end = max(area_names) + areas = await c.get_object_status(ObjectType.AREA, 1, area_end) + modes = [a.mode for a in areas] + print(f" AreaStatus[1..{area_end}]: {len(areas)} records, " + f"modes={modes}") + print() + + # ---- 4. SystemStatus ---- + status = await c.get_system_status() + print(f"=== SystemStatus ===\n" + f" panel_time={status.panel_time} " + f"battery=0x{status.battery_reading:02x}\n" + f" sunrise={status.sunrise_hour:02d}:{status.sunrise_minute:02d} " + f"sunset={status.sunset_hour:02d}:{status.sunset_minute:02d}\n") + + print("[coord probe] ✓ full discovery + poll cycle worked over v1+UDP") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--host", default="192.168.1.9") + parser.add_argument("--port", type=int, default=4369) + parser.add_argument("--key", help="32 hex chars; overrides env/.omni_key") + parser.add_argument("--debug", action="store_true") + args = parser.parse_args() + logging.basicConfig( + level=logging.DEBUG if args.debug else logging.WARNING, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + return asyncio.run(amain(args)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/omni_pca/events.py b/src/omni_pca/events.py index d3d5a6d..34defc3 100644 --- a/src/omni_pca/events.py +++ b/src/omni_pca/events.py @@ -127,17 +127,22 @@ class UpbLinkAction(IntEnum): # -------------------------------------------------------------------------- -def _ensure_system_events(message: Message) -> bytes: - """Validate that ``message`` is a v2 SystemEvents reply, return its - payload bytes (everything after the opcode). +def _ensure_system_events( + message: Message, + expected_opcode: int = int(OmniLink2MessageType.SystemEvents), +) -> bytes: + """Validate that ``message`` is a SystemEvents reply, return payload bytes. - Reference: clsOLMsgSystemEvents.cs (entire file) — the message body - is just ``[opcode][word1_hi][word1_lo][word2_hi][word2_lo]…``. + The v1 and v2 SystemEvents inner-message bodies are byte-identical + (clsOLMsgSystemEvents.cs vs clsOL2MsgSystemEvents.cs both yield + ``[opcode][word1_hi][word1_lo][word2_hi][word2_lo]…``); only the + opcode byte differs (35 vs 55). Pass ``expected_opcode`` to dispatch + the v1 path from :class:`omni_pca.v1.adapter.OmniClientV1Adapter`. """ - if message.opcode != int(OmniLink2MessageType.SystemEvents): + if message.opcode != expected_opcode: raise ValueError( - "not a SystemEvents message: opcode " - f"{message.opcode} (expected {int(OmniLink2MessageType.SystemEvents)})" + f"not a SystemEvents message: opcode {message.opcode} " + f"(expected {expected_opcode})" ) payload = message.payload if len(payload) % 2 != 0: @@ -700,18 +705,23 @@ def _classify(word: int) -> SystemEvent: # -------------------------------------------------------------------------- -def parse_events(message: Message) -> list[SystemEvent]: - """Decode a v2 ``SystemEvents`` (opcode 55) message into typed events. +def parse_events( + message: Message, + expected_opcode: int = int(OmniLink2MessageType.SystemEvents), +) -> list[SystemEvent]: + """Decode a ``SystemEvents`` message into typed events. The panel batches multiple state changes into a single message, so the return type is always a list — even for messages that carry just one event. Empty SystemEvents messages return an empty list rather than raising. - Reference: clsOLMsgSystemEvents.cs:10-18 (SystemEventsCount + per- - word accessor). + ``expected_opcode`` defaults to v2 (55); pass v1's value (35) when + decoding from a ``v1.OmniConnectionV1`` push stream. + + Reference: clsOLMsgSystemEvents.cs / clsOL2MsgSystemEvents.cs. """ - payload = _ensure_system_events(message) + payload = _ensure_system_events(message, expected_opcode) return [_classify(w) for w in _iter_event_words(payload)] @@ -790,6 +800,7 @@ class EventStream: """ source: object # OmniConnection or duck-typed equivalent + expected_opcode: int = int(OmniLink2MessageType.SystemEvents) _buffer: list[SystemEvent] = field(default_factory=list) def __post_init__(self) -> None: @@ -817,10 +828,10 @@ class EventStream: raise except asyncio.CancelledError: raise - if msg.opcode != int(OmniLink2MessageType.SystemEvents): + if msg.opcode != self.expected_opcode: # Non-event message (Status, Ack, …) — silently ignore. continue - self._buffer = parse_events(msg) + self._buffer = parse_events(msg, self.expected_opcode) return self._buffer.pop(0) diff --git a/src/omni_pca/v1/__init__.py b/src/omni_pca/v1/__init__.py index c6eb7a1..ad531d4 100644 --- a/src/omni_pca/v1/__init__.py +++ b/src/omni_pca/v1/__init__.py @@ -13,6 +13,7 @@ V1 for Modem/UDP/Serial, V2 only for TCP). from __future__ import annotations +from .adapter import OmniClientV1Adapter from .client import OmniClientV1, OmniNakError, OmniProtocolError from .connection import ( HandshakeError, @@ -37,6 +38,7 @@ __all__ = [ "NameRecord", "NameType", "OmniClientV1", + "OmniClientV1Adapter", "OmniConnectionV1", "OmniNakError", "OmniProtocolError", diff --git a/src/omni_pca/v1/adapter.py b/src/omni_pca/v1/adapter.py new file mode 100644 index 0000000..6502db7 --- /dev/null +++ b/src/omni_pca/v1/adapter.py @@ -0,0 +1,424 @@ +"""V2-shape adapter over :class:`OmniClientV1`. + +The Home Assistant coordinator was written against :class:`omni_pca.client.OmniClient` +(the v2 API). When the user configures ``transport=udp`` we need a client +that *looks* like ``OmniClient`` but speaks v1-over-UDP underneath. + +This adapter exposes only the methods the coordinator and entity +platforms actually call. Where v1 lacks a v2 opcode (Properties for +zones/units/areas, AcknowledgeAlerts), we synthesize a sensible +fallback rather than raise — HA users shouldn't have to care that their +panel is on a different wire protocol. + +What the adapter does: + +* **Discovery (``list_*_names``)**: delegates to ``OmniClientV1`` (which + drives the streaming ``UploadNames`` flow once per call). +* **Properties (``get_object_properties``)**: synthesizes a minimal + ``*Properties`` dataclass from the name alone. v1 has no Properties + opcode, so we can't fetch zone_type / unit_type / area_alarms / etc. + Defaults are zero — entity platforms read mostly the name + the live + ``*Status`` snapshot, so this works for the common case. +* **Bulk status (``get_extended_status``)**: routes Zone/Unit/Thermostat/ + AuxSensor through the v1 typed ``get_*_status`` calls and returns the + resulting dataclass list (same shape v2 produces). +* **Area status (``get_object_status(AREA, …)``)**: derives ``AreaStatus`` + records from the per-area mode bytes in v1 ``SystemStatus`` — v1 has + no per-area status opcode and the modes are the only thing the panel + reports on UDP. +* **Events (``events()``)**: returns an :class:`EventStream` filtered on + v1's SystemEvents opcode (35) instead of v2's (55). Word format is + identical, so the existing typed-event decoder works unchanged. +* **Writes**: pass-through to the underlying ``OmniClientV1`` methods, + whose Command / ExecuteSecurityCommand payloads are byte-identical + to v2 — only the opcode differs. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Awaitable, Callable +from typing import Self + +from ..commands import Command +from ..events import EventStream, SystemEvent +from ..models import ( + AreaProperties, + AreaStatus, + AuxSensorStatus, + ButtonProperties, + ObjectType, + SecurityMode, + SystemInformation, + SystemStatus, + ThermostatProperties, + ThermostatStatus, + UnitProperties, + UnitStatus, + ZoneProperties, + ZoneStatus, +) +from ..opcodes import OmniLinkMessageType +from .client import OmniClientV1 +from .connection import OmniConnectionV1 + +# Type used by coordinator for object_type arg (the IntEnum in +# omni_pca.client is just a re-export of models.ObjectType). +_ObjectType = ObjectType + +_DEFAULT_PORT = 4369 + + +class OmniClientV1Adapter: + """V2-shaped facade over :class:`OmniClientV1`. + + Construct with the same kwargs as :class:`OmniClient`; the + coordinator does not need to know which one it has. + """ + + def __init__( + self, + host: str, + port: int = _DEFAULT_PORT, + controller_key: bytes = b"", + timeout: float = 5.0, + retry_count: int = 3, + **_ignored, + ) -> None: + # `transport=` and similar kwargs are accepted-and-ignored so the + # coordinator's construction call stays identical across v1/v2. + self._client = OmniClientV1( + host=host, + port=port, + controller_key=controller_key, + timeout=timeout, + retry_count=retry_count, + ) + + # ---- lifecycle ------------------------------------------------------ + + async def __aenter__(self) -> Self: + await self._client.__aenter__() + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + await self._client.__aexit__(exc_type, exc, tb) + + @property + def connection(self) -> OmniConnectionV1: + """Underlying :class:`OmniConnectionV1` — used by the coordinator's + low-level walks. v1's connection has the same ``unsolicited()`` / + ``request()`` surface as v2's, just a different wire dialect. + """ + return self._client.connection + + # ---- panel-wide reads ---------------------------------------------- + + async def get_system_information(self) -> SystemInformation: + return await self._client.get_system_information() + + async def get_system_status(self) -> SystemStatus: + return await self._client.get_system_status() + + # ---- discovery (cached once per coordinator setup) ----------------- + # + # The coordinator calls list_*_names() once per object type. Each + # call drives a fresh UploadNames stream, which on this panel takes + # ~250ms per ~100 names. We cache the full bucketed dict on first + # call so the four list_*_names() calls + several synthesize- + # properties calls all share one network roundtrip. + + async def _ensure_names(self) -> dict[int, dict[int, str]]: + cached = getattr(self, "_name_cache", None) + if cached is None: + cached = await self._client.list_all_names() + self._name_cache = cached + return cached + + def _invalidate_names(self) -> None: + """Force the next discovery call to re-stream UploadNames.""" + self._name_cache = None # type: ignore[assignment] + + async def list_zone_names(self) -> dict[int, str]: + return (await self._ensure_names()).get(1, {}) # NameType.ZONE + + async def list_unit_names(self) -> dict[int, str]: + return (await self._ensure_names()).get(2, {}) # NameType.UNIT + + async def list_area_names(self) -> dict[int, str]: + """Return area names, falling back to "Area N" when stream is empty. + + Most v1 panels don't expose user-assigned area names — the slots + exist (8 for Omni Pro II) but the .pca file leaves them zero- + filled. HA needs *something* to label each area entity, so we + synthesize "Area 1".."Area 8" as a fixed-size fallback. The 8 + is the Omni Pro II cap; we cap here even when ``SystemStatus`` + reports more mode bytes because the long-form SystemStatus + payload mixes in EE-expansion telemetry past byte 22. + """ + named = (await self._ensure_names()).get(5, {}) # NameType.AREA + if named: + return named + return {i: f"Area {i}" for i in range(1, 9)} + + async def list_thermostat_names(self) -> dict[int, str]: + return (await self._ensure_names()).get(6, {}) # NameType.THERMOSTAT + + async def list_button_names(self) -> dict[int, str]: + return (await self._ensure_names()).get(3, {}) # NameType.BUTTON + + async def list_code_names(self) -> dict[int, str]: + return (await self._ensure_names()).get(4, {}) # NameType.CODE + + async def list_message_names(self) -> dict[int, str]: + return (await self._ensure_names()).get(7, {}) # NameType.MESSAGE + + # ---- properties synthesis ------------------------------------------ + + async def get_object_properties( + self, object_type: ObjectType, index: int + ) -> ZoneProperties | UnitProperties | AreaProperties | ThermostatProperties | None: + """Synthesize a Properties dataclass from the name alone. + + v1 has no ``RequestProperties`` opcode; the rich fields v2 carries + (zone_type, unit areas bitfield, exit/entry delays, …) simply + aren't reachable on UDP. We return a minimal dataclass with just + ``index`` + ``name`` populated and everything else defaulted to + 0/False so entity setup doesn't need a transport branch. + + Returns ``None`` if the object isn't defined (no name and not in + the default area-fallback range), which mirrors v2's behavior + when ``RequestProperties`` walks past the last defined object. + """ + names = await self._ensure_names() + if object_type == ObjectType.ZONE: + name = names.get(1, {}).get(index) + if not name: + return None + return ZoneProperties( + index=index, name=name, zone_type=0, area=1, + options=0, status=0, loop=0, + ) + if object_type == ObjectType.UNIT: + name = names.get(2, {}).get(index) + if not name: + return None + return UnitProperties( + index=index, name=name, unit_type=0, + status=0, time=0, areas=0, + ) + if object_type == ObjectType.AREA: + # Use the same fallback logic as list_area_names so HA always + # gets at least the 8 default-area entries. + label = (await self.list_area_names()).get(index) + if label is None: + return None + return AreaProperties( + index=index, name=label, mode=0, alarms=0, + enabled=True, entry_delay=0, exit_delay=0, + ) + if object_type == ObjectType.THERMOSTAT: + name = names.get(6, {}).get(index) + if not name: + return None + return ThermostatProperties( + index=index, name=name, thermostat_type=0, + communicating=True, + ) + if object_type == ObjectType.BUTTON: + name = names.get(3, {}).get(index) + if not name: + return None + return ButtonProperties(index=index, name=name) + return None + + # ---- bulk status --------------------------------------------------- + + # Per-type max records per chunk. Empirically firmware 2.12 caps unit + # responses around 62 records regardless of the MessageLength byte + # limit; other types follow similar conservative caps. We chunk well + # under those thresholds to leave headroom for any per-firmware + # variance and the AES zero-padding the wire frames add. + _CHUNK_SIZES: dict[int, int] = { + ObjectType.ZONE: 80, # 2 B/rec, panel caps high enough + ObjectType.UNIT: 40, # firmware 2.12 NAKs at 63+ records + ObjectType.THERMOSTAT: 30, + ObjectType.AUXILIARY: 60, + } + + async def get_extended_status( + self, + object_type: ObjectType, + start: int, + end: int | None = None, + ) -> list: + """Route v2 ``get_extended_status`` to the matching v1 typed call. + + v1 panels (Omni Pro II) can have 511 units across a sparse + address space. We chunk wide ranges into per-type-sized batches + and concatenate the records — same effect for the caller, only + the wire transcript is different. + """ + last = end if end is not None else start + if object_type == ObjectType.ZONE: + fetch = self._client.get_zone_status + elif object_type == ObjectType.UNIT: + fetch = self._client.get_unit_status + elif object_type == ObjectType.THERMOSTAT: + fetch = self._client.get_thermostat_status + elif object_type == ObjectType.AUXILIARY: + fetch = self._client.get_aux_status + else: + raise ValueError( + f"v1 has no bulk extended-status opcode for {object_type.name}" + ) + + chunk = self._CHUNK_SIZES.get(int(object_type), 40) + out: dict[int, object] = {} + cur = start + while cur <= last: + chunk_end = min(cur + chunk - 1, last) + records = await fetch(cur, chunk_end) + out.update(records) + cur = chunk_end + 1 + return [out[i] for i in sorted(out)] + + async def get_object_status( + self, + object_type: ObjectType, + start: int, + end: int | None = None, + ) -> list: + """Synthesize AreaStatus from SystemStatus's per-area mode bytes. + + v1 has no per-area status opcode — but the SystemStatus payload + carries one ``Mode`` byte per area (single-area panels see one + byte at offset 15, multi-area panels see N consecutive bytes). + We promote each into an :class:`AreaStatus` with just ``index`` + and ``mode`` populated; entry/exit timers and alarms are zero + because the protocol doesn't expose them at this level. + + For non-area object types we fall back to extended-status, which + on v1 maps to the basic typed-status opcodes (which is what the + v2 coordinator actually wants anyway since v2's basic and + extended status are interchangeable in shape). + """ + if object_type != ObjectType.AREA: + return await self.get_extended_status(object_type, start, end) + + last = end if end is not None else start + status = await self._client.get_system_status() + # First N bytes of area_alarms are valid area modes; the rest are + # EE-expansion data on long SystemStatus payloads (firmware 2.12 + # length=39 form). We can't reliably tell where modes stop, so + # match against the list_area_names() count from the same + # SystemStatus. + area_count = max(1, min(8, len(status.area_alarms))) + out: list[AreaStatus] = [] + for idx in range(start, min(last, area_count) + 1): + mode_pair = ( + status.area_alarms[idx - 1] if idx - 1 < len(status.area_alarms) + else (0, 0) + ) + out.append( + AreaStatus( + index=idx, + mode=mode_pair[0], + last_user=0, + entry_timer_secs=0, + exit_timer_secs=0, + alarms=mode_pair[1], + ) + ) + return out + + # ---- events -------------------------------------------------------- + + def events(self) -> AsyncIterator[SystemEvent]: + """v1-aware EventStream — filters on v1 SystemEvents opcode (35).""" + return EventStream( + self._client.connection, + expected_opcode=int(OmniLinkMessageType.SystemEvents), + ).__aiter__() + + async def subscribe( + self, callback: Callable[[object], Awaitable[None]] + ) -> None: + """Not used by the coordinator (which prefers ``events()``); kept + for API parity with :class:`OmniClient`. Raises ``NotImplementedError`` + to flag accidental use — when we need it, copy the v2 implementation. + """ + raise NotImplementedError( + "OmniClientV1Adapter.subscribe is not implemented; " + "use events() instead" + ) + + # ---- writes (pure pass-through) ------------------------------------ + + async def execute_command( + self, command: Command, parameter1: int = 0, parameter2: int = 0 + ) -> None: + await self._client.execute_command(command, parameter1, parameter2) + + async def execute_security_command( + self, area: int, mode: SecurityMode, code: int + ) -> None: + await self._client.execute_security_command(area, mode, code) + + async def acknowledge_alerts(self) -> None: + await self._client.acknowledge_alerts() + + async def turn_unit_on(self, index: int) -> None: + await self._client.turn_unit_on(index) + + async def turn_unit_off(self, index: int) -> None: + await self._client.turn_unit_off(index) + + async def set_unit_level(self, index: int, percent: int) -> None: + await self._client.set_unit_level(index, percent) + + async def bypass_zone(self, index: int, code: int = 0) -> None: + await self._client.bypass_zone(index, code) + + async def restore_zone(self, index: int, code: int = 0) -> None: + await self._client.restore_zone(index, code) + + async def execute_button(self, index: int) -> None: + await self._client.execute_button(index) + + async def execute_program(self, index: int) -> None: + """Run a panel program by index. + + v1 ``enuUnitCommand.Execute`` (raw byte not aliased in our enum) + and v2 both use a generic Command. The Command enum's + ``EXECUTE_PROGRAM`` value works on both because the on-the-wire + Command body is byte-identical. + """ + await self.execute_command(Command.EXECUTE_PROGRAM, parameter2=index) + + async def show_message(self, index: int, beep: bool = True) -> None: + await self.execute_command( + Command.SHOW_MESSAGE_WITH_BEEP if beep else Command.SHOW_MESSAGE_NO_BEEP, + parameter2=index, + ) + + async def clear_message(self, index: int) -> None: + await self.execute_command(Command.CLEAR_MESSAGE, parameter2=index) + + async def set_thermostat_system_mode(self, index: int, mode_value: int) -> None: + await self._client.set_thermostat_system_mode(index, mode_value) + + async def set_thermostat_fan_mode(self, index: int, mode_value: int) -> None: + await self._client.set_thermostat_fan_mode(index, mode_value) + + async def set_thermostat_hold_mode(self, index: int, mode_value: int) -> None: + await self._client.set_thermostat_hold_mode(index, mode_value) + + async def set_thermostat_heat_setpoint_raw( + self, index: int, raw_temp: int + ) -> None: + await self._client.set_thermostat_heat_setpoint_raw(index, raw_temp) + + async def set_thermostat_cool_setpoint_raw( + self, index: int, raw_temp: int + ) -> None: + await self._client.set_thermostat_cool_setpoint_raw(index, raw_temp) diff --git a/src/omni_pca/v1/client.py b/src/omni_pca/v1/client.py index 250c6b4..ea1cfda 100644 --- a/src/omni_pca/v1/client.py +++ b/src/omni_pca/v1/client.py @@ -415,11 +415,22 @@ class OmniClientV1: end: int, parser: Callable[[bytes, int], list[T]], ) -> dict[int, T]: - if not 1 <= start <= end <= 0xFF: + if not 1 <= start <= end <= 0xFFFF: raise ValueError( - f"invalid range: start={start}, end={end} (must be 1..255 with start<=end)" + f"invalid range: start={start}, end={end} " + f"(must be 1..65535 with start<=end)" + ) + # v1 has two payload forms (clsOLMsgRequestUnitStatus.cs:18-31): + # short (3-byte msg with 1-byte start+end) when both ≤ 255, long + # (5-byte msg with BE u16 start+end) otherwise. The panel picks + # the right reply format based on what it received. + if start <= 0xFF and end <= 0xFF: + payload = bytes([start, end]) + else: + payload = bytes( + [(start >> 8) & 0xFF, start & 0xFF, + (end >> 8) & 0xFF, end & 0xFF] ) - payload = bytes([start, end]) reply = await self._conn.request(request_op, payload) self._expect(reply.opcode, reply_op) records = parser(reply.payload, start)