omni-pca/dev/probe_v1_client.py
Ryan Malloy 92c8b695b4 v1-over-UDP: parallel OmniClientV1 for panels that listen UDP-only
Some Omni network modules are configured for UDP, in which case PC Access
falls back to the v1 wire protocol (OmniLinkMessage outer = 0x10, inner
StartChar 0x5A, typed Request*Status opcodes) instead of v2's TCP path
(OmniLink2Message + StartChar 0x21 + parameterised RequestProperties).
This adds a parallel implementation rather than overloading the v2 path.

omni_pca/v1/
  connection.py   UDP-only OmniConnectionV1; reuses crypto + handshake,
                  routes post-handshake messages through OmniLinkMessage
                  (0x10) wrapping v1 inner format. Adds iter_streaming
                  for the lock-step UploadNames/Acknowledge/EOD pattern.
  messages.py     Block parsers for the typed v1 status replies (zone,
                  unit, thermostat, aux), v1 SystemStatus, and NameData
                  (handles both one-byte and two-byte NameNumber forms).
  client.py       OmniClientV1: read API (get_system_information,
                  get_*_status), discovery (iter_names + list_*_names),
                  write API (execute_command, execute_security_command,
                  turn_unit_*, set_unit_level, bypass/restore_zone,
                  execute_button, set_thermostat_*). acknowledge_alerts
                  is a no-op (v1 has no equivalent opcode).

Discovery uses bare UploadNames; panel streams every defined name across
all types in a fixed order with per-record Acknowledge. Verified against
firmware 2.12 — pulled 16 zones, 44 units, 16 buttons, 8 codes,
2 thermostats, 8 messages in one stream.

src/omni_pca/message.py
  Fix flipped START_CHAR_V1_* constants. enuOmniLinkMessageFormat says
  Addressable=0x41 and NonAddressable=0x5A; our names had them swapped.
  Wire bytes were unchanged, so existing tests kept passing — but
  encode_v1() with no serial_address now correctly emits 0x5A, which
  is what UDP needs.

tests/
  test_v1_messages.py        22 cases; payloads are real wire captures
                              from a firmware-2.12 panel via probe_v1_recon.
  test_v1_client_commands.py 20 cases; payload-packing for the Command
                              and ExecuteSecurityCommand opcodes,
                              including BE u16 parameter2 and the
                              digit-by-digit security code form.

dev/
  probe_v1.py        Phase-1 smoke: handshake + RequestSystemInformation.
  probe_v1_recon.py  Raw opcode dump for protocol reconnaissance.
  probe_v1_stream.py Streaming UploadNames flow exploration.
  probe_v1_client.py Full read-path smoke test via OmniClientV1.
  probe_v1_write.py  Live no-op execute_command round-trip.

.gitignore: ignore dev/.omni_key (probe scripts read controller key from
this file as one fallback option).

Discovery on firmware 2.12: Request*ExtendedStatus opcodes (63/65/69)
NAK on this firmware — only the basic Request*Status opcodes are
implemented, so OmniClientV1 uses those (3 bytes/unit, 7 bytes/tstat,
4 bytes/aux records). HA still gets enough signal for polling; full
properties discovery uses streaming UploadNames instead.

Test totals: 387 passed, 1 skipped (existing fixture skip).
2026-05-11 01:08:01 -06:00

123 lines
4.6 KiB
Python

#!/usr/bin/env python3
"""Phase-2a smoke test: drive OmniClientV1 against the real panel.
Hits the read-only methods we care about for HA polling. Compares parsed
values against the recon dump so we catch off-by-one byte errors fast.
Run:
cd /home/kdm/home-auto/omni-pca
uv run python dev/probe_v1_client.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.v1 import OmniClientV1, OmniNakError
async def amain(args: argparse.Namespace) -> int:
key = _load_key(args.key)
print(f"[client probe] target {args.host}:{args.port}\n")
async with OmniClientV1(
host=args.host, port=args.port, controller_key=key, timeout=4.0,
) as c:
info = await c.get_system_information()
print(f"system: model={info.model_name} fw={info.firmware_version} "
f"phone={info.local_phone!r}")
print("\n--- discovery (streaming UploadNames) ---")
all_names = await c.list_all_names()
for type_byte in sorted(all_names):
try:
from omni_pca.v1 import NameType
label = NameType(type_byte).name
except ValueError:
label = f"type{type_byte}"
print(f" {label} ({len(all_names[type_byte])} entries)")
for num in sorted(all_names[type_byte]):
print(f" #{num}: {all_names[type_byte][num]!r}")
try:
sysstatus = await c.get_system_status()
print(f"status: time={sysstatus.panel_time} "
f"battery=0x{sysstatus.battery_reading:02x} "
f"sunrise={sysstatus.sunrise_hour:02d}:{sysstatus.sunrise_minute:02d} "
f"sunset={sysstatus.sunset_hour:02d}:{sysstatus.sunset_minute:02d} "
f"area_modes={[m for m, _ in sysstatus.area_alarms]}")
except Exception as exc:
print(f"system status failed: {type(exc).__name__}: {exc}")
print("\n--- zones 1..16 ---")
zones = await c.get_zone_status(1, 16)
for idx in sorted(zones):
z = zones[idx]
flags = []
if z.is_open: flags.append("open")
if z.is_in_alarm: flags.append("alarm")
if z.is_bypassed: flags.append("bypass")
if z.is_trouble: flags.append("trouble")
tag = ",".join(flags) or "secure"
print(f" zone {idx:2d}: status=0x{z.raw_status:02x} loop=0x{z.loop:02x} ({tag})")
print("\n--- units 1..16 ---")
units = await c.get_unit_status(1, 16)
for idx in sorted(units):
u = units[idx]
br = u.brightness
br_s = f"{br}%" if br is not None else "n/a"
print(f" unit {idx:2d}: state=0x{u.state:02x} ({br_s}) "
f"time_remaining={u.time_remaining_secs}s")
print("\n--- thermostats 1..4 ---")
try:
tstats = await c.get_thermostat_status(1, 4)
for idx in sorted(tstats):
t = tstats[idx]
print(f" tstat {idx}: status=0x{t.status:02x} "
f"temp_F={t.temperature_f:.1f} "
f"heat={t.heat_setpoint_f:.0f} cool={t.cool_setpoint_f:.0f} "
f"mode=0x{t.system_mode:02x} fan=0x{t.fan_mode:02x} "
f"hold=0x{t.hold_mode:02x}")
except OmniNakError as exc:
print(f" no thermostats configured: {exc}")
print("\n--- aux 1..8 ---")
try:
auxes = await c.get_aux_status(1, 8)
for idx in sorted(auxes):
a = auxes[idx]
print(f" aux {idx}: output=0x{a.output:02x} value=0x{a.value_raw:02x} "
f"low=0x{a.low_raw:02x} high=0x{a.high_raw:02x}")
except OmniNakError as exc:
print(f" no aux sensors: {exc}")
print("\n[client probe] ✓ disconnected cleanly")
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())