omni-pca/dev/add_real_panel.py
Ryan Malloy 30b482a8cb 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.
2026-05-11 01:30:49 -06:00

151 lines
4.8 KiB
Python

#!/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())