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.
151 lines
4.8 KiB
Python
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())
|