omni-pca/dev/run_mock_panel.py
Ryan Malloy 9726ee36bb
Some checks failed
Validate / Hassfest (push) Has been cancelled
Validate / HACS validation (push) Has been cancelled
dev: load real .pca fixtures into the mock-panel
Adds an OMNI_PCA_FIXTURE escape hatch so the mock can serve real
panel data instead of the synthetic five-zone state. With this in
place the dev stack is wire-indistinguishable from the source
panel for everything the HA integration touches: 330 programs,
16 zones, 44 units, 2 thermostats etc. from our test fixture.

- run_mock_panel.py: --pca / OMNI_PCA_FIXTURE accepts a path; the
  decryption key is auto-derived from a sibling PCA01.CFG when one
  exists (the common PC Access export layout), with --pca-key /
  OMNI_PCA_FIXTURE_KEY as override. Falls back to KEY_EXPORT for
  vanilla unsigned exports.
- docker-compose.yml: mount /home/kdm/home-auto/HAI as /fixtures
  read-only and surface OMNI_PCA_FIXTURE so dev/.env can drive it.
- dev/README.md: new section documenting fixture loading.
- dev/screenshot_overview.py: quick playwright helper for capturing
  the side-panel landing page with whatever fixture is loaded.
- dev/artifacts/screenshots/2026-05-17/real-pca-overview.png: snapshot
  of the Omni Programs side panel against the real .pca fixture
  (330 programs).
2026-05-17 13:06:19 -06:00

223 lines
7.4 KiB
Python

#!/usr/bin/env python3
"""Launch a long-running MockPanel suitable for the docker-compose dev stack.
Reuses the mock fixture from the test suite so the behaviour matches what
the HA integration tests prove out. Defaults match dev/docker-compose.yml.
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import os
import signal
import sys
from pathlib import Path
from omni_pca.mock_panel import (
MockAreaState,
MockButtonState,
MockPanel,
MockState,
MockThermostatState,
MockUnitState,
MockZoneState,
)
from omni_pca.commands import Command
from omni_pca.pca_file import KEY_EXPORT, parse_pca01_cfg
from omni_pca.programs import Days, Program, ProgramType
DEFAULT_KEY_HEX = "000102030405060708090a0b0c0d0e0f"
def _seed_programs() -> dict[int, bytes]:
"""A handful of programs covering compact-form + clausal-chain shapes.
Slot 200..202 is a chain with a structured-AND condition whose Arg2
is itself a Thermostat reference — exercises the Arg2-as-object
editor controls.
"""
programs: dict[int, Program] = {
12: Program(
slot=12, prog_type=int(ProgramType.TIMED),
cmd=int(Command.UNIT_ON), pr2=1,
hour=6, minute=0, days=int(Days.MONDAY | Days.FRIDAY),
),
42: Program(
slot=42, prog_type=int(ProgramType.TIMED),
cmd=int(Command.UNIT_OFF), pr2=2,
hour=22, minute=30, days=int(Days.SUNDAY),
),
# Chain: WHEN zone 1 not-ready, AND IF Thermostat 1.Temp >
# Thermostat 2.Temp, THEN turn ON unit 3. The AND record is a
# structured-OP comparison with Arg2 as a Thermostat reference.
200: Program(
slot=200, prog_type=int(ProgramType.WHEN),
month=0x04, day=0x01,
),
201: Program(
slot=201, prog_type=int(ProgramType.AND),
cond=(4 << 8) | 4, # op=GT (4), arg1Type=Thermostat (4)
cond2=1, # arg1Ix=1
cmd=1, # arg1Field=current temp
par=4, # arg2Type=Thermostat (4)
pr2=2, # arg2Ix=2
month=1, # arg2Field=current temp
),
202: Program(
slot=202, prog_type=int(ProgramType.THEN),
cmd=int(Command.UNIT_ON), pr2=3,
),
}
return {slot: p.encode_wire_bytes() for slot, p in programs.items()}
def _populated_state() -> MockState:
"""A small but representative set of objects so HA shows real entities."""
return MockState(
zones={
1: MockZoneState(name="FRONT_DOOR"),
2: MockZoneState(name="GARAGE_ENTRY"),
3: MockZoneState(name="BACK_DOOR"),
10: MockZoneState(name="LIVING_MOTION"),
11: MockZoneState(name="HALL_MOTION"),
},
units={
1: MockUnitState(name="LIVING_LAMP"),
2: MockUnitState(name="KITCHEN_OVERHEAD"),
3: MockUnitState(name="FRONT_PORCH"),
4: MockUnitState(name="BEDROOM_FAN"),
},
areas={
1: MockAreaState(name="MAIN"),
2: MockAreaState(name="GUEST"),
},
thermostats={
1: MockThermostatState(name="LIVING_ROOM"),
2: MockThermostatState(name="MASTER_BEDROOM"),
},
buttons={
1: MockButtonState(name="GOOD_MORNING"),
2: MockButtonState(name="MOVIE_MODE"),
3: MockButtonState(name="GOODNIGHT"),
},
user_codes={1: 1234, 2: 5678},
programs=_seed_programs(),
)
def _key_for_pca(path: Path, override: int | None) -> int:
"""Pick the decryption key for a .pca file.
Priority:
1. Explicit override (CLI / env var).
2. Per-installation key from a sibling ``PCA01.CFG`` (most common —
PC Access ships each export with a matching config file).
3. ``KEY_EXPORT`` as a last resort for vanilla exports.
"""
if override is not None:
return override
cfg_path = path.parent / "PCA01.CFG"
if cfg_path.is_file():
cfg = parse_pca01_cfg(cfg_path.read_bytes())
logging.info("derived pca_key from %s: 0x%08X", cfg_path.name, cfg.pca_key)
return cfg.pca_key
logging.info("no sibling PCA01.CFG; falling back to KEY_EXPORT")
return KEY_EXPORT
def _state_from_pca(path: Path, key: int) -> MockState:
"""Seed a MockState from a real .pca file."""
state = MockState.from_pca(str(path), key=key)
logging.info(
"loaded %s: %d zones, %d units, %d areas, %d thermostats, %d programs",
path.name,
len(state.zones), len(state.units), len(state.areas),
len(state.thermostats), len(state.programs),
)
return state
async def _serve(
host: str, port: int, key: bytes, pca: Path | None, pca_key: int | None,
) -> None:
if pca is not None:
state = _state_from_pca(pca, _key_for_pca(pca, pca_key))
else:
state = _populated_state()
panel = MockPanel(controller_key=key, state=state)
async with panel.serve(host=host, port=port) as (bound_host, bound_port):
logging.info("MockPanel listening on %s:%d", bound_host, bound_port)
logging.info("Use this controller key in HA: %s", key.hex())
stop = asyncio.Event()
def _on_signal() -> None:
logging.info("shutdown signal received")
stop.set()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, _on_signal)
await stop.wait()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=14369)
parser.add_argument(
"--controller-key",
default=DEFAULT_KEY_HEX,
help="32 hex chars; default is the docker-compose value",
)
parser.add_argument(
"--pca",
default=os.environ.get("OMNI_PCA_FIXTURE"),
help="Path to a .pca file. When supplied, the mock seeds its "
"state from this file instead of the hard-coded sample. "
"Can also be set via OMNI_PCA_FIXTURE.",
)
parser.add_argument(
"--pca-key",
type=lambda s: int(s, 0),
default=(
int(os.environ["OMNI_PCA_FIXTURE_KEY"], 0)
if os.environ.get("OMNI_PCA_FIXTURE_KEY") else None
),
help="32-bit decryption key for --pca. Default: auto-derive from "
"a sibling PCA01.CFG, or fall back to KEY_EXPORT.",
)
args = parser.parse_args()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
try:
key = bytes.fromhex(args.controller_key)
except ValueError:
print(f"controller-key must be 32 hex chars: {args.controller_key!r}",
file=sys.stderr)
return 2
if len(key) != 16:
print(f"controller-key must decode to exactly 16 bytes (got {len(key)})",
file=sys.stderr)
return 2
pca_path: Path | None = None
if args.pca:
pca_path = Path(args.pca)
if not pca_path.is_file():
print(f"--pca path not found: {pca_path}", file=sys.stderr)
return 2
asyncio.run(_serve(args.host, args.port, key, pca_path, args.pca_key))
return 0
if __name__ == "__main__":
raise SystemExit(main())