diff --git a/dev/README.md b/dev/README.md index b84bdb5..3346aaf 100644 --- a/dev/README.md +++ b/dev/README.md @@ -44,6 +44,32 @@ make dev-down # stop the stack make dev-reset # wipe HA config and start fresh ``` +## Load real `.pca` data into the mock + +By default the mock serves a small synthetic state (five zones, four +units, …). Point `OMNI_PCA_FIXTURE` at a real `.pca` file to make the +mock indistinguishable on the wire from the source panel: + +```bash +# dev/.env (gitignored) +OMNI_PCA_FIXTURE=/fixtures/path/to/Account.pca +``` + +The host directory `/home/kdm/home-auto/HAI` is mounted at `/fixtures` +inside the mock-panel container (see `docker-compose.yml`); adjust the +mount if your `.pca` lives elsewhere. + +The decryption key is auto-derived from a sibling `PCA01.CFG` if one +exists (this is how PC Access exports usually ship). To override: + +```bash +OMNI_PCA_FIXTURE_KEY=0xC1A280B2 # or --pca-key on the command line +``` + +`MockState.from_pca` populates zones, units, areas, thermostats, +buttons, programs, model byte, and firmware version from the file — +everything the HA integration reads at discovery time. + ## Notes - The HA container mounts `../custom_components/omni_pca/` read-only, so diff --git a/dev/artifacts/screenshots/2026-05-17/real-pca-overview.png b/dev/artifacts/screenshots/2026-05-17/real-pca-overview.png new file mode 100644 index 0000000..fc26825 Binary files /dev/null and b/dev/artifacts/screenshots/2026-05-17/real-pca-overview.png differ diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index fbe6a6f..dd2b681 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -35,8 +35,14 @@ services: volumes: - ../src:/tmp/mock/src:ro - ./run_mock_panel.py:/tmp/mock/run_mock_panel.py:ro + # Mount the captured .pca fixtures read-only so the mock can + # optionally seed its state from a real export. Set + # OMNI_PCA_FIXTURE in dev/.env (or pass on the command line) to + # activate; left unset, the mock uses the hard-coded sample. + - /home/kdm/home-auto/HAI:/fixtures:ro environment: PYTHONPATH: /tmp/mock/src + OMNI_PCA_FIXTURE: ${OMNI_PCA_FIXTURE:-} command: - sh - -c diff --git a/dev/run_mock_panel.py b/dev/run_mock_panel.py index 6deb132..c995ff1 100644 --- a/dev/run_mock_panel.py +++ b/dev/run_mock_panel.py @@ -10,8 +10,10 @@ 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, @@ -22,10 +24,55 @@ from omni_pca.mock_panel import ( 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( @@ -56,11 +103,50 @@ def _populated_state() -> MockState: 3: MockButtonState(name="GOODNIGHT"), }, user_codes={1: 1234, 2: 5678}, + programs=_seed_programs(), ) -async def _serve(host: str, port: int, key: bytes) -> None: - panel = MockPanel(controller_key=key, state=_populated_state()) +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()) @@ -86,6 +172,23 @@ def main() -> int: 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( @@ -104,7 +207,14 @@ def main() -> int: file=sys.stderr) return 2 - asyncio.run(_serve(args.host, args.port, key)) + 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 diff --git a/dev/screenshot_overview.py b/dev/screenshot_overview.py new file mode 100644 index 0000000..054175c --- /dev/null +++ b/dev/screenshot_overview.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Quick screenshot of the Omni Programs side panel landing page.""" + +from __future__ import annotations + +import asyncio +import sys +from datetime import datetime +from pathlib import Path + +import httpx +from playwright.async_api import async_playwright + +HA_URL = "http://localhost:8123" +USERNAME = "demo" +PASSWORD = "demo-password-1234" + + +async def _login_token() -> str: + async with httpx.AsyncClient(base_url=HA_URL, timeout=30) as c: + r = await c.post("/auth/login_flow", json={ + "client_id": HA_URL, "handler": ["homeassistant", None], + "redirect_uri": HA_URL, + }) + flow_id = r.json()["flow_id"] + r = await c.post(f"/auth/login_flow/{flow_id}", json={ + "username": USERNAME, "password": PASSWORD, "client_id": HA_URL, + }) + code = r.json()["result"] + r = await c.post("/auth/token", data={ + "client_id": HA_URL, "grant_type": "authorization_code", "code": code, + }) + return r.json()["access_token"] + + +async def amain(outdir: Path) -> None: + token = await _login_token() + outdir.mkdir(parents=True, exist_ok=True) + async with async_playwright() as p: + browser = await p.chromium.launch() + context = await browser.new_context(viewport={"width": 1400, "height": 900}) + await context.add_init_script(f""" + window.localStorage.setItem('hassTokens', JSON.stringify({{ + access_token: '{token}', token_type: 'Bearer', refresh_token: '', + expires: Date.now() + 3600000, hassUrl: '{HA_URL}', clientId: '{HA_URL}', + }})); + window.localStorage.setItem('selectedTheme', JSON.stringify({{dark: false}})); + """) + page = await context.new_page() + await page.goto(f"{HA_URL}/omni-panel-programs", wait_until="domcontentloaded") + await page.wait_for_timeout(8000) + path = outdir / "real-pca-overview.png" + await page.screenshot(path=str(path), full_page=True) + print(f" wrote {path}") + await browser.close() + + +if __name__ == "__main__": + outdir = Path(sys.argv[1]) if len(sys.argv) > 1 else ( + Path(__file__).parent / "artifacts" / "screenshots" / + datetime.now().strftime("%Y-%m-%d") + ) + asyncio.run(amain(outdir))