Compare commits
2 Commits
486258a034
...
9726ee36bb
| Author | SHA1 | Date | |
|---|---|---|---|
| 9726ee36bb | |||
| 8a0fb1e4fe |
@ -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,37 +1950,95 @@ export class OmniPanelPrograms extends LitElement {
|
|||||||
|
|
||||||
${showArg2 ? html`
|
${showArg2 ? html`
|
||||||
<label class="block">
|
<label class="block">
|
||||||
Compare against (constant)
|
Arg2 type
|
||||||
<input type="number" min="0" max="65535"
|
<select @change=${(e: Event) => {
|
||||||
.value=${String(s.arg2Ix)}
|
const newType = parseInt((e.target as HTMLSelectElement).value, 10);
|
||||||
@input=${(e: Event) => {
|
const newKind = argTypeKind(newType);
|
||||||
const v = parseInt((e.target as HTMLInputElement).value, 10);
|
// Constant → arg2Ix is the literal value (preserve);
|
||||||
if (Number.isFinite(v) && v >= 0 && v <= 0xFFFF) {
|
// reference type → arg2Ix is an object index (reset to
|
||||||
update({ arg2Ix: v });
|
// first discovered or 0 for TimeDate).
|
||||||
}
|
const newIx = newType === 0
|
||||||
}}
|
? s.arg2Ix
|
||||||
/>
|
: this._defaultIxForKind(newKind);
|
||||||
</label>` : ""}
|
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"
|
||||||
|
.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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
@ -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
@ -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
|
||||||
|
|||||||
BIN
dev/artifacts/screenshots/2026-05-17/arg2-object-editor.png
Normal file
BIN
dev/artifacts/screenshots/2026-05-17/arg2-object-editor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
BIN
dev/artifacts/screenshots/2026-05-17/real-pca-overview.png
Normal file
BIN
dev/artifacts/screenshots/2026-05-17/real-pca-overview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
150
dev/screenshot_arg2_object.py
Normal file
150
dev/screenshot_arg2_object.py
Normal 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())
|
||||||
63
dev/screenshot_overview.py
Normal file
63
dev/screenshot_overview.py
Normal 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))
|
||||||
Loading…
x
Reference in New Issue
Block a user