HA integration: wire v1+UDP into the coordinator + config flow
OmniClientV1Adapter (src/omni_pca/v1/adapter.py)
V2-shape facade over OmniClientV1. Exposes the OmniClient surface the
HA coordinator was written against — get_system_information,
list_*_names, get_object_properties (synthesized from streamed names),
get_extended_status (chunked, routed to v1 typed status opcodes),
get_object_status(AREA, ...) (derived from SystemStatus.area_alarms),
events() (EventStream on v1 SystemEvents opcode 35), plus all the
write-method shims.
Chunks unit/zone/thermostat/aux polls per-type because firmware 2.12
NAKs Request*Status with >~62 records in one shot (verified live).
Falls back to "Area 1".."Area 8" when the UploadNames stream returns
zero areas — common on panels where the installer didn't name them.
custom_components/omni_pca/coordinator.py
_ensure_connected picks OmniClientV1Adapter for transport=udp. New
_walk_properties_v1 replaces the v2 RequestProperties walk with a
name-stream + synthesized-Properties pass.
custom_components/omni_pca/config_flow.py
_probe routes to OmniClientV1Adapter for transport=udp instead of
trying to drive v2 OmniClient over UDP (which silently dropped after
handshake, per the earlier diagnosis).
src/omni_pca/events.py
parse_events / _ensure_system_events / EventStream now take an
expected_opcode arg (default v2 SystemEvents=55, v1 callers pass 35).
Word format is byte-identical between v1 and v2, so the typed-event
decoder is unchanged.
src/omni_pca/v1/client.py
_range_status supports the long-form RequestUnitStatus (BE u16
start/end) so panels with unit indices > 255 (sprinklers, flags) work.
Verified end-to-end against firmware 2.12 panel at 192.168.1.9:
config entries:
state=loaded Omni Pro II (host.docker.internal) (mock)
state=loaded Omni Pro II (192.168.1.9) (real, v1+UDP)
real-panel entities created in HA: 96 (30 binary_sensor, 26 light,
15 switch, 13 button, 9 sensor, 3 climate)
cross-check: light.omni_pro_ii_front_porch_2 = on (matches live
probe: unit #2 'FRONT PORCH' state=0x01 brightness=100)
dev/probe_v1_coordinator.py
Coordinator-shaped end-to-end smoke test against the real panel
without HA — drives the full discovery + poll cycle through the
adapter. Useful for regression-checking the v1 wire path.
dev/add_real_panel.py
Programmatically adds the real-panel config entry to the dev HA
stack via the REST config-flow endpoints. Idempotent.
This commit is contained in:
parent
92c8b695b4
commit
30b482a8cb
@ -168,15 +168,40 @@ class OmniConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
key: bytes,
|
key: bytes,
|
||||||
transport: str = DEFAULT_TRANSPORT,
|
transport: str = DEFAULT_TRANSPORT,
|
||||||
) -> tuple[str | None, str | None]:
|
) -> 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:
|
try:
|
||||||
async with OmniClient(
|
if transport == TRANSPORT_UDP:
|
||||||
host,
|
from omni_pca.v1 import (
|
||||||
port=port,
|
HandshakeError as V1HandshakeError,
|
||||||
controller_key=key,
|
)
|
||||||
transport=transport, # type: ignore[arg-type]
|
from omni_pca.v1 import (
|
||||||
) as client:
|
InvalidEncryptionKeyError as V1InvalidEncryptionKeyError,
|
||||||
info = await client.get_system_information()
|
)
|
||||||
|
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):
|
except (HandshakeError, InvalidEncryptionKeyError):
|
||||||
return None, "invalid_auth"
|
return None, "invalid_auth"
|
||||||
except (OmniConnectionError, OSError, TimeoutError) as err:
|
except (OmniConnectionError, OSError, TimeoutError) as err:
|
||||||
|
|||||||
@ -234,12 +234,25 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
|
|||||||
async def _ensure_connected(self) -> OmniClient:
|
async def _ensure_connected(self) -> OmniClient:
|
||||||
if self._client is not None:
|
if self._client is not None:
|
||||||
return self._client
|
return self._client
|
||||||
client = OmniClient(
|
if self._transport == "udp":
|
||||||
self._host,
|
# Panels listening UDP-only speak the v1 wire protocol, not
|
||||||
port=self._port,
|
# v2. The adapter exposes the OmniClient API surface this
|
||||||
controller_key=self._controller_key,
|
# coordinator was written against, but underneath it drives
|
||||||
transport=self._transport, # type: ignore[arg-type]
|
# 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
|
# Drive __aenter__ manually so the client survives across update
|
||||||
# cycles; we close it explicitly on shutdown / failure.
|
# cycles; we close it explicitly on shutdown / failure.
|
||||||
await client.__aenter__()
|
await client.__aenter__()
|
||||||
@ -392,9 +405,15 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
|
|||||||
client's internal parser table only covers zones/units/areas in
|
client's internal parser table only covers zones/units/areas in
|
||||||
v1.0). We drive ``RequestProperties`` directly on the connection
|
v1.0). We drive ``RequestProperties`` directly on the connection
|
||||||
so we don't have to monkey-patch the library.
|
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:
|
if parser is None or OBJECT_TYPE_TO_PROPERTIES.get(int(object_type)) is None:
|
||||||
return {}
|
return {}
|
||||||
|
if self._transport == "udp":
|
||||||
|
return await self._walk_properties_v1(client, object_type)
|
||||||
out: dict[int, object] = {}
|
out: dict[int, object] = {}
|
||||||
cursor = 0
|
cursor = 0
|
||||||
conn = client.connection
|
conn = client.connection
|
||||||
@ -443,6 +462,42 @@ class OmniDataUpdateCoordinator(DataUpdateCoordinator[OmniData]):
|
|||||||
break
|
break
|
||||||
return out
|
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
|
@staticmethod
|
||||||
async def _best_effort(coro_fn, *, default):
|
async def _best_effort(coro_fn, *, default):
|
||||||
"""Call ``coro_fn()`` and swallow non-transport errors, returning ``default``.
|
"""Call ``coro_fn()`` and swallow non-transport errors, returning ``default``.
|
||||||
|
|||||||
150
dev/add_real_panel.py
Normal file
150
dev/add_real_panel.py
Normal file
@ -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())
|
||||||
129
dev/probe_v1_coordinator.py
Normal file
129
dev/probe_v1_coordinator.py
Normal file
@ -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())
|
||||||
@ -127,17 +127,22 @@ class UpbLinkAction(IntEnum):
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _ensure_system_events(message: Message) -> bytes:
|
def _ensure_system_events(
|
||||||
"""Validate that ``message`` is a v2 SystemEvents reply, return its
|
message: Message,
|
||||||
payload bytes (everything after the opcode).
|
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
|
The v1 and v2 SystemEvents inner-message bodies are byte-identical
|
||||||
is just ``[opcode][word1_hi][word1_lo][word2_hi][word2_lo]…``.
|
(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(
|
raise ValueError(
|
||||||
"not a SystemEvents message: opcode "
|
f"not a SystemEvents message: opcode {message.opcode} "
|
||||||
f"{message.opcode} (expected {int(OmniLink2MessageType.SystemEvents)})"
|
f"(expected {expected_opcode})"
|
||||||
)
|
)
|
||||||
payload = message.payload
|
payload = message.payload
|
||||||
if len(payload) % 2 != 0:
|
if len(payload) % 2 != 0:
|
||||||
@ -700,18 +705,23 @@ def _classify(word: int) -> SystemEvent:
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def parse_events(message: Message) -> list[SystemEvent]:
|
def parse_events(
|
||||||
"""Decode a v2 ``SystemEvents`` (opcode 55) message into typed 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 panel batches multiple state changes into a single message, so
|
||||||
the return type is always a list — even for messages that carry just
|
the return type is always a list — even for messages that carry just
|
||||||
one event. Empty SystemEvents messages return an empty list rather
|
one event. Empty SystemEvents messages return an empty list rather
|
||||||
than raising.
|
than raising.
|
||||||
|
|
||||||
Reference: clsOLMsgSystemEvents.cs:10-18 (SystemEventsCount + per-
|
``expected_opcode`` defaults to v2 (55); pass v1's value (35) when
|
||||||
word accessor).
|
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)]
|
return [_classify(w) for w in _iter_event_words(payload)]
|
||||||
|
|
||||||
|
|
||||||
@ -790,6 +800,7 @@ class EventStream:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
source: object # OmniConnection or duck-typed equivalent
|
source: object # OmniConnection or duck-typed equivalent
|
||||||
|
expected_opcode: int = int(OmniLink2MessageType.SystemEvents)
|
||||||
_buffer: list[SystemEvent] = field(default_factory=list)
|
_buffer: list[SystemEvent] = field(default_factory=list)
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
@ -817,10 +828,10 @@ class EventStream:
|
|||||||
raise
|
raise
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
if msg.opcode != int(OmniLink2MessageType.SystemEvents):
|
if msg.opcode != self.expected_opcode:
|
||||||
# Non-event message (Status, Ack, …) — silently ignore.
|
# Non-event message (Status, Ack, …) — silently ignore.
|
||||||
continue
|
continue
|
||||||
self._buffer = parse_events(msg)
|
self._buffer = parse_events(msg, self.expected_opcode)
|
||||||
return self._buffer.pop(0)
|
return self._buffer.pop(0)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ V1 for Modem/UDP/Serial, V2 only for TCP).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .adapter import OmniClientV1Adapter
|
||||||
from .client import OmniClientV1, OmniNakError, OmniProtocolError
|
from .client import OmniClientV1, OmniNakError, OmniProtocolError
|
||||||
from .connection import (
|
from .connection import (
|
||||||
HandshakeError,
|
HandshakeError,
|
||||||
@ -37,6 +38,7 @@ __all__ = [
|
|||||||
"NameRecord",
|
"NameRecord",
|
||||||
"NameType",
|
"NameType",
|
||||||
"OmniClientV1",
|
"OmniClientV1",
|
||||||
|
"OmniClientV1Adapter",
|
||||||
"OmniConnectionV1",
|
"OmniConnectionV1",
|
||||||
"OmniNakError",
|
"OmniNakError",
|
||||||
"OmniProtocolError",
|
"OmniProtocolError",
|
||||||
|
|||||||
424
src/omni_pca/v1/adapter.py
Normal file
424
src/omni_pca/v1/adapter.py
Normal file
@ -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)
|
||||||
@ -415,11 +415,22 @@ class OmniClientV1:
|
|||||||
end: int,
|
end: int,
|
||||||
parser: Callable[[bytes, int], list[T]],
|
parser: Callable[[bytes, int], list[T]],
|
||||||
) -> dict[int, T]:
|
) -> dict[int, T]:
|
||||||
if not 1 <= start <= end <= 0xFF:
|
if not 1 <= start <= end <= 0xFFFF:
|
||||||
raise ValueError(
|
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)
|
reply = await self._conn.request(request_op, payload)
|
||||||
self._expect(reply.opcode, reply_op)
|
self._expect(reply.opcode, reply_op)
|
||||||
records = parser(reply.payload, start)
|
records = parser(reply.payload, start)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user