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.
This commit is contained in:
parent
486258a034
commit
8a0fb1e4fe
@ -1849,9 +1849,9 @@ export class OmniPanelPrograms extends LitElement {
|
||||
): TemplateResult {
|
||||
const s = decodeStructuredAnd(cond);
|
||||
if (!isEditableStructuredAnd(s)) {
|
||||
// Out of editor scope (non-constant Arg2, unsupported Arg1 type,
|
||||
// or non-zero compConst). Surface as preserve-only so the user
|
||||
// can still remove the row but can't damage the encoded data.
|
||||
// Out of editor scope (unsupported Arg1/Arg2 type or non-zero
|
||||
// CompConst). Surface as preserve-only so the user can still
|
||||
// remove the row but can't damage the encoded data.
|
||||
return html`
|
||||
<div class="cond-slot structured-cond">
|
||||
<div class="cond-row-header">
|
||||
@ -1862,8 +1862,8 @@ export class OmniPanelPrograms extends LitElement {
|
||||
</div>
|
||||
<div class="conditions-readonly">
|
||||
Structured comparison with a shape the editor can't drive
|
||||
yet (Arg2 references another object, Arg1 is an unsupported
|
||||
type, or a CompConst value is present). Preserved on save.
|
||||
yet (Arg1 or Arg2 is an unsupported type, or a CompConst
|
||||
value is present). Preserved on save.
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@ -1881,24 +1881,24 @@ export class OmniPanelPrograms extends LitElement {
|
||||
|
||||
/** 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
|
||||
* (ODD / EVEN) the Arg2 input is hidden.
|
||||
* Both Arg1 and Arg2 support Zone / Unit / Thermostat / Area /
|
||||
* TimeDate references; Arg2 also supports plain Constant. For
|
||||
* unary operators (ODD / EVEN) the Arg2 controls are hidden.
|
||||
*/
|
||||
private _renderStructuredAndForm(
|
||||
s: DecodedStructuredAnd, idx: number,
|
||||
): TemplateResult {
|
||||
const update = (patch: Partial<DecodedStructuredAnd>) => {
|
||||
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));
|
||||
};
|
||||
const arg1Fields = FIELDS_BY_TYPE[s.arg1Type] ?? [];
|
||||
const arg1Kind = argTypeKind(s.arg1Type);
|
||||
const arg2Fields = FIELDS_BY_TYPE[s.arg2Type] ?? [];
|
||||
const arg2Kind = argTypeKind(s.arg2Type);
|
||||
const showArg2 = !isUnaryOp(s.op);
|
||||
return html`
|
||||
<div class="structured-row">
|
||||
@ -1906,19 +1906,10 @@ export class OmniPanelPrograms extends LitElement {
|
||||
Arg1 type
|
||||
<select @change=${(e: Event) => {
|
||||
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({
|
||||
arg1Type: newType,
|
||||
arg1Ix: newIx,
|
||||
arg1Field: firstField,
|
||||
arg1Ix: this._defaultIxForKind(argTypeKind(newType)),
|
||||
arg1Field: (FIELDS_BY_TYPE[newType] ?? [{ value: 0 }])[0].value,
|
||||
});
|
||||
}}>
|
||||
${ARG_TYPES.filter((a) => a.value !== 0).map((a) => html`
|
||||
@ -1928,7 +1919,9 @@ export class OmniPanelPrograms extends LitElement {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
${arg1Kind ? this._renderStructuredArg1Picker(s, arg1Kind, update) : ""}
|
||||
${arg1Kind ? this._renderStructuredObjectPicker(
|
||||
arg1Kind, s.arg1Ix, (v) => update({ arg1Ix: v }), "Arg1",
|
||||
) : ""}
|
||||
|
||||
${arg1Fields.length > 0 ? html`
|
||||
<label class="block">
|
||||
@ -1957,7 +1950,35 @@ export class OmniPanelPrograms extends LitElement {
|
||||
|
||||
${showArg2 ? html`
|
||||
<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"
|
||||
.value=${String(s.arg2Ix)}
|
||||
@input=${(e: Event) => {
|
||||
@ -1968,26 +1989,56 @@ export class OmniPanelPrograms extends LitElement {
|
||||
}}
|
||||
/>
|
||||
</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>`;
|
||||
}
|
||||
|
||||
private _renderStructuredArg1Picker(
|
||||
s: DecodedStructuredAnd,
|
||||
/** First discovered object index for a given kind, falling back to 1
|
||||
* 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,
|
||||
update: (p: Partial<DecodedStructuredAnd>) => void,
|
||||
current: number,
|
||||
onChange: (v: number) => void,
|
||||
labelPrefix: string,
|
||||
): TemplateResult {
|
||||
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`
|
||||
<label class="block">
|
||||
${label}
|
||||
<select @change=${(e: Event) => update({
|
||||
arg1Ix: parseInt((e.target as HTMLSelectElement).value, 10),
|
||||
})}>
|
||||
${labelPrefix} ${kindLabel}
|
||||
<select @change=${(e: Event) =>
|
||||
onChange(parseInt((e.target as HTMLSelectElement).value, 10))}>
|
||||
${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}
|
||||
</option>`)}
|
||||
</select>
|
||||
|
||||
@ -569,11 +569,11 @@ export function emptyThenRecord(firstUnit: number = 1): ProgramFields {
|
||||
// day, days = and_compconst (BE u16 — extra constant, rarely used)
|
||||
//
|
||||
// Editor cuts:
|
||||
// * Arg2 locked to Constant in this pass (other-object Arg2 stays
|
||||
// read-only with a banner). Arg2-constant covers
|
||||
// "TEMP > 70", "Zone.CurrentState == 1", "Hour == 22" etc.
|
||||
// * Arg1 restricted to Zone / Unit / Thermostat / Area / TimeDate.
|
||||
// Anything else (Aux / Audio / System / etc.) stays read-only.
|
||||
// * Arg1 and Arg2 both restricted to Constant / Zone / Unit /
|
||||
// Thermostat / Area / TimeDate. Anything else (Aux / Audio /
|
||||
// System / etc.) stays read-only.
|
||||
// * Non-zero CompConst stays read-only (rarely used; preserved on
|
||||
// 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
|
||||
* fully drive. Other shapes (Arg2=non-constant object, exotic Arg1
|
||||
* types, or non-zero compConst) stay read-only — they're preserved
|
||||
* on save but their fields aren't exposed as form controls. */
|
||||
* fully drive. Arg1 must be one of the editable reference types;
|
||||
* Arg2 must be Constant or one of the editable reference types
|
||||
* (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 {
|
||||
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;
|
||||
return true;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
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 |
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())
|
||||
Loading…
x
Reference in New Issue
Block a user