Compare commits

..

No commits in common. "9726ee36bb09cde49790240f1611342b3157ae65" and "486258a034a3bf121f9fabdecfebbf737828801a" have entirely different histories.

10 changed files with 131 additions and 565 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 (unsupported Arg1/Arg2 type or non-zero // Out of editor scope (non-constant Arg2, unsupported Arg1 type,
// CompConst). Surface as preserve-only so the user can still // or non-zero compConst). Surface as preserve-only so the user
// remove the row but can't damage the encoded data. // can still 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 (Arg1 or Arg2 is an unsupported type, or a CompConst yet (Arg2 references another object, Arg1 is an unsupported
value is present). Preserved on save. type, or a CompConst 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 * Arg1 type object/picker field operator Arg2 constant
* Arg2 type (constant | object/picker field)
* *
* Both Arg1 and Arg2 support Zone / Unit / Thermostat / Area / * Arg2 is locked to Constant in this pass. For unary operators
* TimeDate references; Arg2 also supports plain Constant. For * (ODD / EVEN) the Arg2 input is hidden.
* 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,10 +1906,19 @@ 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: this._defaultIxForKind(argTypeKind(newType)), arg1Ix: newIx,
arg1Field: (FIELDS_BY_TYPE[newType] ?? [{ value: 0 }])[0].value, arg1Field: firstField,
}); });
}}> }}>
${ARG_TYPES.filter((a) => a.value !== 0).map((a) => html` ${ARG_TYPES.filter((a) => a.value !== 0).map((a) => html`
@ -1919,9 +1928,7 @@ export class OmniPanelPrograms extends LitElement {
</select> </select>
</label> </label>
${arg1Kind ? this._renderStructuredObjectPicker( ${arg1Kind ? this._renderStructuredArg1Picker(s, arg1Kind, update) : ""}
arg1Kind, s.arg1Ix, (v) => update({ arg1Ix: v }), "Arg1",
) : ""}
${arg1Fields.length > 0 ? html` ${arg1Fields.length > 0 ? html`
<label class="block"> <label class="block">
@ -1950,95 +1957,37 @@ export class OmniPanelPrograms extends LitElement {
${showArg2 ? html` ${showArg2 ? html`
<label class="block"> <label class="block">
Arg2 type Compare against (constant)
<select @change=${(e: Event) => { <input type="number" min="0" max="65535"
const newType = parseInt((e.target as HTMLSelectElement).value, 10); .value=${String(s.arg2Ix)}
const newKind = argTypeKind(newType); @input=${(e: Event) => {
// Constant → arg2Ix is the literal value (preserve); const v = parseInt((e.target as HTMLInputElement).value, 10);
// reference type → arg2Ix is an object index (reset to if (Number.isFinite(v) && v >= 0 && v <= 0xFFFF) {
// first discovered or 0 for TimeDate). update({ arg2Ix: v });
const newIx = newType === 0 }
? s.arg2Ix }}
: this._defaultIxForKind(newKind); />
const newField = newType === 0 </label>` : ""}
? 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"
.value=${String(s.arg2Ix)}
@input=${(e: Event) => {
const v = parseInt((e.target as HTMLInputElement).value, 10);
if (Number.isFinite(v) && v >= 0 && v <= 0xFFFF) {
update({ arg2Ix: v });
}
}}
/>
</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>`;
} }
/** First discovered object index for a given kind, falling back to 1 private _renderStructuredArg1Picker(
* for reference kinds (TimeDate / null returns 0 "no object"). */ s: DecodedStructuredAnd,
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,
current: number, update: (p: Partial<DecodedStructuredAnd>) => void,
onChange: (v: number) => void,
labelPrefix: string,
): TemplateResult { ): TemplateResult {
const bucket = this._bucketWithPreserve( const bucket = this._bucketWithPreserve(
this._pickBucket(kind), kind, current, this._pickBucket(kind), kind, s.arg1Ix,
); );
const kindLabel = kind[0].toUpperCase() + kind.slice(1); const label = kind[0].toUpperCase() + kind.slice(1);
return html` return html`
<label class="block"> <label class="block">
${labelPrefix} ${kindLabel} ${label}
<select @change=${(e: Event) => <select @change=${(e: Event) => update({
onChange(parseInt((e.target as HTMLSelectElement).value, 10))}> arg1Ix: parseInt((e.target as HTMLSelectElement).value, 10),
})}>
${bucket.map((o) => html` ${bucket.map((o) => html`
<option .value=${String(o.index)} ?selected=${o.index === current}> <option .value=${String(o.index)} ?selected=${o.index === s.arg1Ix}>
#${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:
// * Arg1 and Arg2 both restricted to Constant / Zone / Unit / // * Arg2 locked to Constant in this pass (other-object Arg2 stays
// Thermostat / Area / TimeDate. Anything else (Aux / Audio / // read-only with a banner). Arg2-constant covers
// System / etc.) stays read-only. // "TEMP > 70", "Zone.CurrentState == 1", "Hour == 22" etc.
// * Non-zero CompConst stays read-only (rarely used; preserved on // * Arg1 restricted to Zone / Unit / Thermostat / Area / TimeDate.
// save). // Anything else (Aux / Audio / System / etc.) stays read-only.
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
@ -708,15 +708,12 @@ 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. Arg1 must be one of the editable reference types; * fully drive. Other shapes (Arg2=non-constant object, exotic Arg1
* Arg2 must be Constant or one of the editable reference types * types, or non-zero compConst) stay read-only they're preserved
* (unary operators ignore Arg2 entirely). Non-zero compConst stays * on save but their fields aren't exposed as form controls. */
* 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 && !isEditableArg1Type(s.arg2Type)) { if (!isUnaryOp(s.op) && s.arg2Type !== 0) return false;
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,32 +44,6 @@ 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.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

View File

@ -35,14 +35,8 @@ 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,10 +10,8 @@ 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,
@ -24,55 +22,10 @@ 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(
@ -103,50 +56,11 @@ 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(),
) )
def _key_for_pca(path: Path, override: int | None) -> int: async def _serve(host: str, port: int, key: bytes) -> None:
"""Pick the decryption key for a .pca file. panel = MockPanel(controller_key=key, state=_populated_state())
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())
@ -172,23 +86,6 @@ 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(
@ -207,14 +104,7 @@ def main() -> int:
file=sys.stderr) file=sys.stderr)
return 2 return 2
pca_path: Path | None = None asyncio.run(_serve(args.host, args.port, key))
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

@ -1,150 +0,0 @@
#!/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

@ -1,63 +0,0 @@
#!/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))