Compare commits

..

2 Commits

Author SHA1 Message Date
9726ee36bb dev: load real .pca fixtures into the mock-panel
Some checks failed
Validate / Hassfest (push) Has been cancelled
Validate / HACS validation (push) Has been cancelled
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
8a0fb1e4fe panel: Arg2-as-object editor for structured-AND records
Lets structured-AND IF rows compare a typed field against another
typed field, not just a constant. Authoring "Thermostat 1.Temp >
Thermostat 2.Temp" now works in-place; previously Arg2 was locked
to Constant in the editor.

- types.ts: relax isEditableStructuredAnd to permit Zone/Unit/
  Thermostat/Area/TimeDate as Arg2 types (the same editable set
  already accepted for Arg1).
- omni-panel-programs.ts: replace the lone constant input with
  Arg2 type/object/field controls that mirror the Arg1 layout;
  switching Arg2 between Constant and a reference type swaps the
  sub-controls and resets defaults sensibly.
- _renderStructuredArg1Picker generalised to _renderStructuredObjectPicker
  driving both sides; _defaultIxForKind extracted as a shared helper.
- Bundle rebuilt.
- dev/screenshot_arg2_object.py: targeted playwright helper that
  opens the chain at slot 200 and screenshots the editor for
  visual verification.
2026-05-17 13:06:07 -06:00
10 changed files with 565 additions and 131 deletions

View File

@ -1849,9 +1849,9 @@ export class OmniPanelPrograms extends LitElement {
): TemplateResult { ): TemplateResult {
const s = decodeStructuredAnd(cond); const s = decodeStructuredAnd(cond);
if (!isEditableStructuredAnd(s)) { if (!isEditableStructuredAnd(s)) {
// Out of editor scope (non-constant Arg2, unsupported Arg1 type, // Out of editor scope (unsupported Arg1/Arg2 type or non-zero
// or non-zero compConst). Surface as preserve-only so the user // CompConst). Surface as preserve-only so the user can still
// can still remove the row but can't damage the encoded data. // remove the row but can't damage the encoded data.
return html` return html`
<div class="cond-slot structured-cond"> <div class="cond-slot structured-cond">
<div class="cond-row-header"> <div class="cond-row-header">
@ -1862,8 +1862,8 @@ export class OmniPanelPrograms extends LitElement {
</div> </div>
<div class="conditions-readonly"> <div class="conditions-readonly">
Structured comparison with a shape the editor can't drive Structured comparison with a shape the editor can't drive
yet (Arg2 references another object, Arg1 is an unsupported yet (Arg1 or Arg2 is an unsupported type, or a CompConst
type, or a CompConst value is present). Preserved on save. value is present). Preserved on save.
</div> </div>
</div>`; </div>`;
} }
@ -1881,24 +1881,24 @@ export class OmniPanelPrograms extends LitElement {
/** Render the editor for one structured-AND condition. Lays out as: /** Render the editor for one structured-AND condition. Lays out as:
* *
* Arg1 type object/picker field operator Arg2 constant * Arg1 type object/picker field operator
* Arg2 type (constant | object/picker field)
* *
* Arg2 is locked to Constant in this pass. For unary operators * Both Arg1 and Arg2 support Zone / Unit / Thermostat / Area /
* (ODD / EVEN) the Arg2 input is hidden. * TimeDate references; Arg2 also supports plain Constant. For
* unary operators (ODD / EVEN) the Arg2 controls are hidden.
*/ */
private _renderStructuredAndForm( private _renderStructuredAndForm(
s: DecodedStructuredAnd, idx: number, s: DecodedStructuredAnd, idx: number,
): TemplateResult { ): TemplateResult {
const update = (patch: Partial<DecodedStructuredAnd>) => { const update = (patch: Partial<DecodedStructuredAnd>) => {
const merged = { ...s, ...patch }; const merged = { ...s, ...patch };
// Force Arg2 = Constant in editor scope so nothing accidentally
// promotes to an object reference.
merged.arg2Type = 0;
merged.arg2Field = 0;
this._patchChainCondition(idx, encodeStructuredAnd(merged)); this._patchChainCondition(idx, encodeStructuredAnd(merged));
}; };
const arg1Fields = FIELDS_BY_TYPE[s.arg1Type] ?? []; const arg1Fields = FIELDS_BY_TYPE[s.arg1Type] ?? [];
const arg1Kind = argTypeKind(s.arg1Type); const arg1Kind = argTypeKind(s.arg1Type);
const arg2Fields = FIELDS_BY_TYPE[s.arg2Type] ?? [];
const arg2Kind = argTypeKind(s.arg2Type);
const showArg2 = !isUnaryOp(s.op); const showArg2 = !isUnaryOp(s.op);
return html` return html`
<div class="structured-row"> <div class="structured-row">
@ -1906,19 +1906,10 @@ export class OmniPanelPrograms extends LitElement {
Arg1 type Arg1 type
<select @change=${(e: Event) => { <select @change=${(e: Event) => {
const newType = parseInt((e.target as HTMLSelectElement).value, 10); const newType = parseInt((e.target as HTMLSelectElement).value, 10);
// Reset arg1Ix + field when type changes — keeps the form
// self-consistent and avoids stale picker values.
const firstField = (FIELDS_BY_TYPE[newType] ?? [{ value: 0 }])[0].value;
const newKind = argTypeKind(newType);
let newIx = 0;
if (newKind === "zone") newIx = this._objects?.zones?.[0]?.index ?? 1;
else if (newKind === "unit") newIx = this._objects?.units?.[0]?.index ?? 1;
else if (newKind === "thermostat") newIx = this._objects?.thermostats?.[0]?.index ?? 1;
else if (newKind === "area") newIx = this._objects?.areas?.[0]?.index ?? 1;
update({ update({
arg1Type: newType, arg1Type: newType,
arg1Ix: newIx, arg1Ix: this._defaultIxForKind(argTypeKind(newType)),
arg1Field: firstField, arg1Field: (FIELDS_BY_TYPE[newType] ?? [{ value: 0 }])[0].value,
}); });
}}> }}>
${ARG_TYPES.filter((a) => a.value !== 0).map((a) => html` ${ARG_TYPES.filter((a) => a.value !== 0).map((a) => html`
@ -1928,7 +1919,9 @@ export class OmniPanelPrograms extends LitElement {
</select> </select>
</label> </label>
${arg1Kind ? this._renderStructuredArg1Picker(s, arg1Kind, update) : ""} ${arg1Kind ? this._renderStructuredObjectPicker(
arg1Kind, s.arg1Ix, (v) => update({ arg1Ix: v }), "Arg1",
) : ""}
${arg1Fields.length > 0 ? html` ${arg1Fields.length > 0 ? html`
<label class="block"> <label class="block">
@ -1957,7 +1950,35 @@ export class OmniPanelPrograms extends LitElement {
${showArg2 ? html` ${showArg2 ? html`
<label class="block"> <label class="block">
Compare against (constant) Arg2 type
<select @change=${(e: Event) => {
const newType = parseInt((e.target as HTMLSelectElement).value, 10);
const newKind = argTypeKind(newType);
// Constant → arg2Ix is the literal value (preserve);
// reference type → arg2Ix is an object index (reset to
// first discovered or 0 for TimeDate).
const newIx = newType === 0
? s.arg2Ix
: this._defaultIxForKind(newKind);
const newField = newType === 0
? 0
: (FIELDS_BY_TYPE[newType] ?? [{ value: 0 }])[0].value;
update({
arg2Type: newType,
arg2Ix: newIx,
arg2Field: newField,
});
}}>
${ARG_TYPES.map((a) => html`
<option .value=${String(a.value)} ?selected=${a.value === s.arg2Type}>
${a.label}
</option>`)}
</select>
</label>
${s.arg2Type === 0 ? html`
<label class="block">
Constant
<input type="number" min="0" max="65535" <input type="number" min="0" max="65535"
.value=${String(s.arg2Ix)} .value=${String(s.arg2Ix)}
@input=${(e: Event) => { @input=${(e: Event) => {
@ -1968,26 +1989,56 @@ export class OmniPanelPrograms extends LitElement {
}} }}
/> />
</label>` : ""} </label>` : ""}
${arg2Kind ? this._renderStructuredObjectPicker(
arg2Kind, s.arg2Ix, (v) => update({ arg2Ix: v }), "Arg2",
) : ""}
${s.arg2Type !== 0 && arg2Fields.length > 0 ? html`
<label class="block">
Arg2 field
<select @change=${(e: Event) => update({
arg2Field: parseInt((e.target as HTMLSelectElement).value, 10),
})}>
${arg2Fields.map((f) => html`
<option .value=${String(f.value)} ?selected=${f.value === s.arg2Field}>
${f.label}
</option>`)}
</select>
</label>` : ""}
` : ""}
</div>`; </div>`;
} }
private _renderStructuredArg1Picker( /** First discovered object index for a given kind, falling back to 1
s: DecodedStructuredAnd, * for reference kinds (TimeDate / null returns 0 "no object"). */
private _defaultIxForKind(kind: string | null): number {
switch (kind) {
case "zone": return this._objects?.zones?.[0]?.index ?? 1;
case "unit": return this._objects?.units?.[0]?.index ?? 1;
case "thermostat": return this._objects?.thermostats?.[0]?.index ?? 1;
case "area": return this._objects?.areas?.[0]?.index ?? 1;
default: return 0;
}
}
private _renderStructuredObjectPicker(
kind: string, kind: string,
update: (p: Partial<DecodedStructuredAnd>) => void, current: number,
onChange: (v: number) => void,
labelPrefix: string,
): TemplateResult { ): TemplateResult {
const bucket = this._bucketWithPreserve( const bucket = this._bucketWithPreserve(
this._pickBucket(kind), kind, s.arg1Ix, this._pickBucket(kind), kind, current,
); );
const label = kind[0].toUpperCase() + kind.slice(1); const kindLabel = kind[0].toUpperCase() + kind.slice(1);
return html` return html`
<label class="block"> <label class="block">
${label} ${labelPrefix} ${kindLabel}
<select @change=${(e: Event) => update({ <select @change=${(e: Event) =>
arg1Ix: parseInt((e.target as HTMLSelectElement).value, 10), onChange(parseInt((e.target as HTMLSelectElement).value, 10))}>
})}>
${bucket.map((o) => html` ${bucket.map((o) => html`
<option .value=${String(o.index)} ?selected=${o.index === s.arg1Ix}> <option .value=${String(o.index)} ?selected=${o.index === current}>
#${o.index} ${o.name} #${o.index} ${o.name}
</option>`)} </option>`)}
</select> </select>

View File

@ -569,11 +569,11 @@ export function emptyThenRecord(firstUnit: number = 1): ProgramFields {
// day, days = and_compconst (BE u16 — extra constant, rarely used) // day, days = and_compconst (BE u16 — extra constant, rarely used)
// //
// Editor cuts: // Editor cuts:
// * Arg2 locked to Constant in this pass (other-object Arg2 stays // * Arg1 and Arg2 both restricted to Constant / Zone / Unit /
// read-only with a banner). Arg2-constant covers // Thermostat / Area / TimeDate. Anything else (Aux / Audio /
// "TEMP > 70", "Zone.CurrentState == 1", "Hour == 22" etc. // System / etc.) stays read-only.
// * Arg1 restricted to Zone / Unit / Thermostat / Area / TimeDate. // * Non-zero CompConst stays read-only (rarely used; preserved on
// Anything else (Aux / Audio / System / etc.) stays read-only. // save).
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
@ -708,12 +708,15 @@ export function encodeStructuredAnd(s: DecodedStructuredAnd): Partial<ProgramFie
} }
/** True iff the structured AND record is in a shape the editor can /** True iff the structured AND record is in a shape the editor can
* fully drive. Other shapes (Arg2=non-constant object, exotic Arg1 * fully drive. Arg1 must be one of the editable reference types;
* types, or non-zero compConst) stay read-only they're preserved * Arg2 must be Constant or one of the editable reference types
* on save but their fields aren't exposed as form controls. */ * (unary operators ignore Arg2 entirely). Non-zero compConst stays
* read-only preserved on save but not exposed as a form control. */
export function isEditableStructuredAnd(s: DecodedStructuredAnd): boolean { export function isEditableStructuredAnd(s: DecodedStructuredAnd): boolean {
if (!isEditableArg1Type(s.arg1Type)) return false; if (!isEditableArg1Type(s.arg1Type)) return false;
if (!isUnaryOp(s.op) && s.arg2Type !== 0) return false; if (!isUnaryOp(s.op) && s.arg2Type !== 0 && !isEditableArg1Type(s.arg2Type)) {
return false;
}
if (s.compConst !== 0) return false; if (s.compConst !== 0) return false;
return true; return true;
} }

File diff suppressed because one or more lines are too long

View File

@ -44,6 +44,32 @@ make dev-down # stop the stack
make dev-reset # wipe HA config and start fresh 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 ## Notes
- The HA container mounts `../custom_components/omni_pca/` read-only, so - The HA container mounts `../custom_components/omni_pca/` read-only, so

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

View File

@ -35,8 +35,14 @@ services:
volumes: volumes:
- ../src:/tmp/mock/src:ro - ../src:/tmp/mock/src:ro
- ./run_mock_panel.py:/tmp/mock/run_mock_panel.py: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: environment:
PYTHONPATH: /tmp/mock/src PYTHONPATH: /tmp/mock/src
OMNI_PCA_FIXTURE: ${OMNI_PCA_FIXTURE:-}
command: command:
- sh - sh
- -c - -c

View File

@ -10,8 +10,10 @@ from __future__ import annotations
import argparse import argparse
import asyncio import asyncio
import logging import logging
import os
import signal import signal
import sys import sys
from pathlib import Path
from omni_pca.mock_panel import ( from omni_pca.mock_panel import (
MockAreaState, MockAreaState,
@ -22,10 +24,55 @@ from omni_pca.mock_panel import (
MockUnitState, MockUnitState,
MockZoneState, 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" 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: def _populated_state() -> MockState:
"""A small but representative set of objects so HA shows real entities.""" """A small but representative set of objects so HA shows real entities."""
return MockState( return MockState(
@ -56,11 +103,50 @@ def _populated_state() -> MockState:
3: MockButtonState(name="GOODNIGHT"), 3: MockButtonState(name="GOODNIGHT"),
}, },
user_codes={1: 1234, 2: 5678}, user_codes={1: 1234, 2: 5678},
programs=_seed_programs(),
) )
async def _serve(host: str, port: int, key: bytes) -> None: def _key_for_pca(path: Path, override: int | None) -> int:
panel = MockPanel(controller_key=key, state=_populated_state()) """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): 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("MockPanel listening on %s:%d", bound_host, bound_port)
logging.info("Use this controller key in HA: %s", key.hex()) logging.info("Use this controller key in HA: %s", key.hex())
@ -86,6 +172,23 @@ def main() -> int:
default=DEFAULT_KEY_HEX, default=DEFAULT_KEY_HEX,
help="32 hex chars; default is the docker-compose value", 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() args = parser.parse_args()
logging.basicConfig( logging.basicConfig(
@ -104,7 +207,14 @@ def main() -> int:
file=sys.stderr) file=sys.stderr)
return 2 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 return 0

View File

@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Focused screenshot of the structured-AND Arg2-as-object editor.
Drives an already-onboarded HA at localhost:8123, opens the side panel,
clicks into the chain at slot 200, hits Edit, and snaps the form.
"""
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
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"]
FIND_PANEL = """
(() => {
function find(root, depth=0) {
if (!root || depth > 15) return null;
if (root.tagName === 'OMNI-PANEL-PROGRAMS') return root;
for (const k of Array.from(root.children || [])) {
const r = find(k, depth+1);
if (r) return r;
}
if (root.shadowRoot) {
const r = find(root.shadowRoot, depth+1);
if (r) return r;
}
return null;
}
return find(document.body);
})()
"""
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()
page.on("console", lambda m: print(f" [browser] {m.type}: {m.text}"))
await page.goto(f"{HA_URL}/omni-panel-programs", wait_until="domcontentloaded")
await page.wait_for_timeout(6000)
# Click the chain row (slot 200).
ok = await page.evaluate(f"""() => {{
const panel = {FIND_PANEL};
if (!panel) return 'no-panel';
const rows = Array.from(panel.shadowRoot.querySelectorAll('.row'));
const target = rows.find(r => r.textContent.includes('200'));
if (!target) return 'no-row-200 ' + rows.map(r => r.textContent.slice(0,40)).join(' | ');
target.click();
return 'clicked';
}}""")
print(f" row-click: {ok}")
await page.wait_for_timeout(800)
# Click Edit.
ok = await page.evaluate(f"""() => {{
const panel = {FIND_PANEL};
if (!panel) return 'no-panel';
const buttons = panel.shadowRoot.querySelectorAll('.detail button');
for (const b of buttons) {{
if (b.textContent.trim() === 'Edit') {{ b.click(); return 'clicked'; }}
}}
return 'no-edit-button';
}}""")
print(f" edit-click: {ok}")
await page.wait_for_timeout(1500)
path = outdir / "arg2-object-editor.png"
await page.screenshot(path=str(path), full_page=True)
print(f" wrote {path}")
await browser.close()
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"--outdir",
type=Path,
default=Path(__file__).parent / "artifacts" / "screenshots" /
datetime.now().strftime("%Y-%m-%d"),
)
args = parser.parse_args()
asyncio.run(amain(args.outdir))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -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))