diff --git a/.gitignore b/.gitignore index a1ff03a..48e324e 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ panel_key* # Wine artifacts (if used for testing) .wine-pca/ ha-config/ +dist/ diff --git a/dev/artifacts/screenshots/2026-05-10/01-overview.png b/dev/artifacts/screenshots/2026-05-10/01-overview.png new file mode 100644 index 0000000..f6694f9 Binary files /dev/null and b/dev/artifacts/screenshots/2026-05-10/01-overview.png differ diff --git a/dev/artifacts/screenshots/2026-05-10/02-devices.png b/dev/artifacts/screenshots/2026-05-10/02-devices.png new file mode 100644 index 0000000..b1a0256 Binary files /dev/null and b/dev/artifacts/screenshots/2026-05-10/02-devices.png differ diff --git a/dev/artifacts/screenshots/2026-05-10/02-integrations-list.png b/dev/artifacts/screenshots/2026-05-10/02-integrations-list.png new file mode 100644 index 0000000..8c26a9e Binary files /dev/null and b/dev/artifacts/screenshots/2026-05-10/02-integrations-list.png differ diff --git a/dev/artifacts/screenshots/2026-05-10/03-integrations.png b/dev/artifacts/screenshots/2026-05-10/03-integrations.png new file mode 100644 index 0000000..34cbcc8 Binary files /dev/null and b/dev/artifacts/screenshots/2026-05-10/03-integrations.png differ diff --git a/dev/artifacts/screenshots/2026-05-10/03-omni-pca-config.png b/dev/artifacts/screenshots/2026-05-10/03-omni-pca-config.png new file mode 100644 index 0000000..7753b8c Binary files /dev/null and b/dev/artifacts/screenshots/2026-05-10/03-omni-pca-config.png differ diff --git a/dev/artifacts/screenshots/2026-05-10/04-entities.png b/dev/artifacts/screenshots/2026-05-10/04-entities.png new file mode 100644 index 0000000..5911d50 Binary files /dev/null and b/dev/artifacts/screenshots/2026-05-10/04-entities.png differ diff --git a/dev/artifacts/screenshots/2026-05-10/04-panel-device.png b/dev/artifacts/screenshots/2026-05-10/04-panel-device.png new file mode 100644 index 0000000..8327bf7 Binary files /dev/null and b/dev/artifacts/screenshots/2026-05-10/04-panel-device.png differ diff --git a/dev/artifacts/screenshots/2026-05-10/05-developer-states.png b/dev/artifacts/screenshots/2026-05-10/05-developer-states.png new file mode 100644 index 0000000..0daf7b5 Binary files /dev/null and b/dev/artifacts/screenshots/2026-05-10/05-developer-states.png differ diff --git a/dev/artifacts/screenshots/2026-05-10/05-entities-light.png b/dev/artifacts/screenshots/2026-05-10/05-entities-light.png new file mode 100644 index 0000000..c16d141 Binary files /dev/null and b/dev/artifacts/screenshots/2026-05-10/05-entities-light.png differ diff --git a/dev/artifacts/screenshots/2026-05-10/05-entities-omni.png b/dev/artifacts/screenshots/2026-05-10/05-entities-omni.png new file mode 100644 index 0000000..c16d141 Binary files /dev/null and b/dev/artifacts/screenshots/2026-05-10/05-entities-omni.png differ diff --git a/dev/artifacts/screenshots/2026-05-10/06-developer-states.png b/dev/artifacts/screenshots/2026-05-10/06-developer-states.png new file mode 100644 index 0000000..5ae6d80 Binary files /dev/null and b/dev/artifacts/screenshots/2026-05-10/06-developer-states.png differ diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index 47b7e35..2649e63 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -19,14 +19,16 @@ services: mock-panel: image: ghcr.io/astral-sh/uv:python3.14-bookworm-slim - working_dir: /app + working_dir: /tmp/mock volumes: - - ..:/app:ro - command: > - uv run --project /app - python /app/dev/run_mock_panel.py - --host 0.0.0.0 - --port 14369 + - ../src:/tmp/mock/src:ro + - ./run_mock_panel.py:/tmp/mock/run_mock_panel.py:ro + environment: + PYTHONPATH: /tmp/mock/src + command: + - sh + - -c + - "uv pip install --system --quiet cryptography && python /tmp/mock/run_mock_panel.py --host 0.0.0.0 --port 14369" ports: - "14369:14369" diff --git a/dev/screenshot.py b/dev/screenshot.py new file mode 100644 index 0000000..43ba96d --- /dev/null +++ b/dev/screenshot.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +"""End-to-end demo: onboard HA, add the omni_pca integration against the +mock panel, drive playwright through the resulting UI to capture screenshots. + +Run inside the project venv: + uv run --with playwright --with httpx --with websockets \ + python dev/screenshot.py [--outdir DIR] [--ha-url URL] +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from datetime import datetime +from pathlib import Path + +import httpx +from playwright.async_api import async_playwright + +DEFAULT_HA_URL = "http://localhost:8123" +DEFAULT_USERNAME = "demo" +DEFAULT_PASSWORD = "demo-password-1234" +PANEL_HOST = "host.docker.internal" +PANEL_PORT = 14369 +CONTROLLER_KEY = "000102030405060708090a0b0c0d0e0f" + + +async def _complete_onboarding( + client: httpx.AsyncClient, headers: dict[str, str], ha_url: str +) -> None: + """POST every remaining onboarding step in turn so HA stops greeting us.""" + r = await client.get("/api/onboarding") + pending = [s["step"] for s in r.json() if not s.get("done")] + print(f" pending onboarding: {pending}") + + if "core_config" in pending: + try: + r = await client.post("/api/onboarding/core_config", headers=headers) + print(f" core_config -> {r.status_code}") + except Exception as e: + print(f" core_config error: {e}") + if "analytics" in pending: + try: + r = await client.post( + "/api/onboarding/analytics", + headers=headers, + json={}, + ) + print(f" analytics -> {r.status_code}") + except Exception as e: + print(f" analytics error: {e}") + if "integration" in pending: + try: + r = await client.post( + "/api/onboarding/integration", + headers=headers, + json={"client_id": ha_url, "redirect_uri": ha_url}, + ) + print(f" integration -> {r.status_code}") + except Exception as e: + print(f" integration error: {e}") + + +async def _onboard(ha_url: str) -> str: + """Run HA's onboarding REST flow if needed. Returns access token. + + HA uses the authorization_code OAuth flow. On first-run, POSTing to + /api/onboarding/users returns an auth_code that we exchange for tokens. + On subsequent runs, we use a long-lived access token created during + the first run (persisted in ha-config/.storage/auth). + """ + async with httpx.AsyncClient(base_url=ha_url, timeout=30.0) as client: + r = await client.get("/api/onboarding") + steps = r.json() + user_step = next((s for s in steps if s["step"] == "user"), None) + + if user_step and not user_step.get("done"): + # First-run path: create user, get auth_code, exchange. + r = await client.post( + "/api/onboarding/users", + json={ + "client_id": ha_url, + "name": "Demo User", + "username": DEFAULT_USERNAME, + "password": DEFAULT_PASSWORD, + "language": "en", + }, + ) + r.raise_for_status() + auth_code = r.json()["auth_code"] + print(" āœ“ user created") + + r = await client.post( + "/auth/token", + data={ + "client_id": ha_url, + "grant_type": "authorization_code", + "code": auth_code, + }, + ) + r.raise_for_status() + access_token = r.json()["access_token"] + + # Complete the remaining onboarding steps so we land on the + # dashboard rather than the discovery wizard. + headers = {"Authorization": f"Bearer {access_token}"} + await _complete_onboarding(client, headers, ha_url) + return access_token + + # Subsequent-run path: log in via /auth/login_flow. + token_file = ( + Path(__file__).parent / "ha-config" / ".storage" / ".demo_access_token" + ) + if token_file.exists(): + print(" re-using cached demo access token") + return token_file.read_text().strip() + + print(" logging in via /auth/login_flow") + 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() + return r.json()["access_token"] + + +async def _add_integration(ha_url: str, token: str) -> None: + """Add the omni_pca config entry via the REST config-flow endpoints.""" + headers = {"Authorization": f"Bearer {token}"} + async with httpx.AsyncClient(base_url=ha_url, timeout=30.0) as client: + 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": + print(f" integration already configured: {entry['title']}") + return + + 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" config flow opened: {flow_id} (step={flow.get('step_id')})") + + r = await client.post( + f"/api/config/config_entries/flow/{flow_id}", + headers=headers, + json={ + "host": PANEL_HOST, + "port": PANEL_PORT, + "controller_key": CONTROLLER_KEY, + }, + ) + r.raise_for_status() + result = r.json() + if result.get("type") == "create_entry": + print(f" āœ“ entry created: {result.get('title')}") + else: + raise RuntimeError(f"unexpected flow outcome: {result}") + + +async def _take_screenshots(ha_url: str, token: str, outdir: Path) -> list[Path]: + """Drive playwright through a few interesting pages.""" + outdir.mkdir(parents=True, exist_ok=True) + shots: list[Path] = [] + + async with async_playwright() as p: + browser = await p.chromium.launch(headless=True) + context = await browser.new_context( + viewport={"width": 1440, "height": 900}, + device_scale_factor=2, + ) + # Inject auth so we skip the login screen. + await context.add_init_script( + f"""window.localStorage.setItem('hassTokens', JSON.stringify({{ + access_token: '{token}', + token_type: 'Bearer', + expires_in: 1800, + hassUrl: '{ha_url}', + clientId: '{ha_url}', + expires: Date.now() + 1800000, + refresh_token: 'placeholder', + }})); + window.localStorage.setItem('selectedLanguage', '"en"'); + """ + ) + page = await context.new_page() + + async def shot(filename: str, url: str, *, wait_for: str | None = None, + wait_secs: float = 4.0) -> None: + print(f" → {filename} ({url})") + try: + await page.goto(f"{ha_url}{url}", wait_until="networkidle", + timeout=30000) + except Exception as e: + print(f" nav warning: {e}") + if wait_for: + try: + await page.locator(wait_for).first.wait_for(timeout=10000) + except Exception: + pass + await page.wait_for_timeout(int(wait_secs * 1000)) + path = outdir / filename + await page.screenshot(path=str(path), full_page=False) + shots.append(path) + + # Make sure onboarding is fully complete before we screenshot anything. + async with httpx.AsyncClient(base_url=ha_url, timeout=15.0) as client: + await _complete_onboarding( + client, {"Authorization": f"Bearer {token}"}, ha_url + ) + + # Look up the panel device id so we can deep-link to its page. + device_id: str | None = None + async with httpx.AsyncClient(base_url=ha_url, timeout=15.0) as client: + r = await client.post( + "/api/template", + headers={"Authorization": f"Bearer {token}"}, + json={"template": "{{ device_id('sensor.omni_pro_ii_panel_model') }}"}, + ) + if r.status_code == 200: + device_id = (r.text or "").strip().strip('"') + if device_id and device_id != "None": + print(f" panel device_id: {device_id}") + else: + device_id = None + + await shot("01-overview.png", "/lovelace/0", wait_secs=5.0) + await shot("02-integrations-list.png", + "/config/integrations/dashboard", wait_secs=4.0) + await shot("03-omni-pca-config.png", + "/config/integrations/integration/omni_pca", wait_secs=4.0) + if device_id: + await shot("04-panel-device.png", + f"/config/devices/device/{device_id}", wait_secs=4.0) + await shot( + "05-entities-omni.png", + '/config/entities?config_entry=' + 'omni_pca', + wait_secs=4.0, + ) + await shot("06-developer-states.png", + "/developer-tools/state", wait_secs=4.0) + + await browser.close() + return shots + + +async def amain(args: argparse.Namespace) -> int: + print("[1/3] HA onboarding...") + token = await _onboard(args.ha_url) + # Cache token so subsequent runs against an already-onboarded HA can reuse. + token_file = ( + Path(__file__).parent / "ha-config" / ".storage" / ".demo_access_token" + ) + try: + token_file.parent.mkdir(parents=True, exist_ok=True) + token_file.write_text(token) + except Exception: + pass + + print("[2/3] adding omni_pca integration...") + await _add_integration(args.ha_url, token) + # Give HA a moment to discover all entities. + await asyncio.sleep(8) + print("[3/3] capturing screenshots...") + shots = await _take_screenshots(args.ha_url, token, args.outdir) + print(f"\nāœ“ wrote {len(shots)} screenshots to {args.outdir}") + for p in shots: + print(f" {p}") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--ha-url", default=DEFAULT_HA_URL) + parser.add_argument( + "--outdir", + type=Path, + default=Path(__file__).parent + / "artifacts" + / "screenshots" + / datetime.now().strftime("%Y-%m-%d"), + ) + args = parser.parse_args() + return asyncio.run(amain(args)) + + +if __name__ == "__main__": + sys.exit(main())