panel: structured-OP AND record editor (TEMP > 70 etc.)
Replaces the read-only "structured comparison" banner with a real
editor. Structured AND records encode ``Arg1 OP Arg2`` where Arg1 is
a typed reference (Zone / Unit / Thermostat / Area / TimeDate) plus a
per-type field selector, and Arg2 is either another typed reference
or a literal constant.
I1 — TS types + decoders:
Wire layout (programs.py decoders, clsProgram.cs):
cond high byte = and_op (CondOP: 1=EQ, 2=NE, 3=LT,
4=GT, 5=ODD, 6=EVEN, 7=MULT,
8=IN, 9=NOT_IN)
cond low byte = and_arg1_argtype (CondArgType)
cond2 (whole) = and_arg1_ix (object idx; 0 for TimeDate)
cmd = and_arg1_field (per-type field selector)
par = and_arg2_argtype (Constant most common)
pr2 = and_arg2_ix (constant value or 2nd obj idx)
month = and_arg2_field
day,days = and_compconst (BE u16; usually 0)
decodeStructuredAnd / encodeStructuredAnd handle both directions;
round-trip exact.
Per-Arg1Type field menus in FIELDS_BY_TYPE — exact 1:1 with the
Python enuZoneField / enuUnitField / enuThermostatField /
enuTimeDateField enums in omni_pca.programs and the field handling
in StateEvaluator. Areas only expose "Security mode" (single useful
field). TimeDate exposes Year / Month / Day / DoW / Time / Hour /
Minute (skips the rarely-used Date / DST / SunriseSunset fields).
I2 — editor UI:
isEditableStructuredAnd guard: only opens the editor for records
matching the editor's scope (Arg1 in supported types, Arg2=Constant,
compConst=0). Out-of-scope structured records render with a
"read-only" tag — preserved on save, still removable.
Structured rows render with a "structured" tag and an orange-tinted
background to distinguish them from Traditional rows. Layout:
Arg1 type ▸ object picker ▸ Field ▸ Operator ▸ Compare against
Unary operators (ODD / EVEN) hide the Arg2 input. Changing Arg1 type
resets the Arg1 index + field to defaults so the form stays self-
consistent (no stale picker values from a previous type).
Arg2 is locked to Constant in this pass. Editing record-vs-record
comparisons (e.g. "Thermostat 1 temp > Thermostat 2 temp") is a
future cut — current real-world programs use the Constant form
exclusively per my homeowner-panel sample.
_pickBucket gains the missing "thermostat" branch (was missed in
earlier passes; only mattered now that thermostat is an Arg1Type).
Live screenshot 12-structured-and.png shows an injected chain with
both a Traditional AND (CTRL UNIT 1 ON) and a Structured AND
(Thermostat(1).Temperature > 70) — both editable end-to-end.
Frontend bundle: 88 KB minified (up from 82 KB).
Full suite: 653 passed, 1 skipped (no test changes).
This commit is contained in:
parent
9ca4da98e8
commit
486258a034
@ -12,13 +12,17 @@ import { LitElement, html, css, PropertyValues, TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { renderTokens } from "./token-renderer.js";
|
||||
import {
|
||||
ARG_TYPES,
|
||||
COMMAND_OPTIONS,
|
||||
COND_OPS,
|
||||
CommandOption,
|
||||
CondFamily,
|
||||
DAY_BITS,
|
||||
DecodedCondition,
|
||||
DecodedEvent,
|
||||
DecodedStructuredAnd,
|
||||
EventCategory,
|
||||
FIELDS_BY_TYPE,
|
||||
FIXED_EVENTS,
|
||||
Hass,
|
||||
MISC_CONDITIONALS,
|
||||
@ -37,18 +41,23 @@ import {
|
||||
ProgramListResponse,
|
||||
ProgramRow,
|
||||
SECURITY_MODE_NAMES,
|
||||
argTypeKind,
|
||||
commandOptionFor,
|
||||
decodeAndCondition,
|
||||
decodeCondition,
|
||||
decodeEventId,
|
||||
decodeStructuredAnd,
|
||||
emptyAndRecord,
|
||||
emptyOrRecord,
|
||||
emptyThenRecord,
|
||||
encodeAndCondition,
|
||||
encodeCondition,
|
||||
encodeEventId,
|
||||
encodeStructuredAnd,
|
||||
eventIdFromFields,
|
||||
isEditableStructuredAnd,
|
||||
isStructuredAnd,
|
||||
isUnaryOp,
|
||||
packEventIdIntoFields,
|
||||
} from "./types.js";
|
||||
|
||||
@ -570,6 +579,7 @@ export class OmniPanelPrograms extends LitElement {
|
||||
case "unit": return this._objects.units;
|
||||
case "area": return this._objects.areas;
|
||||
case "button": return this._objects.buttons;
|
||||
case "thermostat": return this._objects.thermostats;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@ -1820,19 +1830,7 @@ export class OmniPanelPrograms extends LitElement {
|
||||
): TemplateResult {
|
||||
const isOr = cond.prog_type === PROGRAM_TYPE_OR;
|
||||
if (isStructuredAnd(cond)) {
|
||||
return html`
|
||||
<div class="cond-slot structured-cond">
|
||||
<div>
|
||||
<strong>${isOr ? "OR IF" : "AND IF"}</strong> (structured comparison — read-only)
|
||||
<button type="button" class="mini-btn danger"
|
||||
@click=${() => this._removeChainCondition(idx)}>×</button>
|
||||
</div>
|
||||
<div class="conditions-readonly">
|
||||
This condition uses a structured comparison (TEMP > N etc.).
|
||||
Editing structured-OP records is not yet supported; it's
|
||||
preserved on save.
|
||||
</div>
|
||||
</div>`;
|
||||
return this._renderStructuredChainConditionRow(cond, idx, isOr);
|
||||
}
|
||||
const decoded = decodeAndCondition(cond);
|
||||
return html`
|
||||
@ -1846,6 +1844,156 @@ export class OmniPanelPrograms extends LitElement {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _renderStructuredChainConditionRow(
|
||||
cond: ProgramFields, idx: number, isOr: boolean,
|
||||
): 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.
|
||||
return html`
|
||||
<div class="cond-slot structured-cond">
|
||||
<div class="cond-row-header">
|
||||
<strong>${isOr ? "OR IF" : "AND IF"}</strong>
|
||||
<span class="readonly-tag">read-only</span>
|
||||
<button type="button" class="mini-btn danger"
|
||||
@click=${() => this._removeChainCondition(idx)}>×</button>
|
||||
</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.
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
return html`
|
||||
<div class="cond-slot structured-cond">
|
||||
<div class="cond-row-header">
|
||||
<strong>${isOr ? "OR IF" : "AND IF"}</strong>
|
||||
<span class="structured-tag">structured</span>
|
||||
<button type="button" class="mini-btn danger"
|
||||
@click=${() => this._removeChainCondition(idx)}>×</button>
|
||||
</div>
|
||||
${this._renderStructuredAndForm(s, idx)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Render the editor for one structured-AND condition. Lays out as:
|
||||
*
|
||||
* Arg1 type ▸ object/picker ▸ field ▸ operator ▸ Arg2 constant
|
||||
*
|
||||
* Arg2 is locked to Constant in this pass. For unary operators
|
||||
* (ODD / EVEN) the Arg2 input is 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 showArg2 = !isUnaryOp(s.op);
|
||||
return html`
|
||||
<div class="structured-row">
|
||||
<label class="block">
|
||||
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,
|
||||
});
|
||||
}}>
|
||||
${ARG_TYPES.filter((a) => a.value !== 0).map((a) => html`
|
||||
<option .value=${String(a.value)} ?selected=${a.value === s.arg1Type}>
|
||||
${a.label}
|
||||
</option>`)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
${arg1Kind ? this._renderStructuredArg1Picker(s, arg1Kind, update) : ""}
|
||||
|
||||
${arg1Fields.length > 0 ? html`
|
||||
<label class="block">
|
||||
Field
|
||||
<select @change=${(e: Event) => update({
|
||||
arg1Field: parseInt((e.target as HTMLSelectElement).value, 10),
|
||||
})}>
|
||||
${arg1Fields.map((f) => html`
|
||||
<option .value=${String(f.value)} ?selected=${f.value === s.arg1Field}>
|
||||
${f.label}
|
||||
</option>`)}
|
||||
</select>
|
||||
</label>` : ""}
|
||||
|
||||
<label class="block">
|
||||
Operator
|
||||
<select @change=${(e: Event) => update({
|
||||
op: parseInt((e.target as HTMLSelectElement).value, 10),
|
||||
})}>
|
||||
${COND_OPS.map((o) => html`
|
||||
<option .value=${String(o.value)} ?selected=${o.value === s.op}>
|
||||
${o.label}
|
||||
</option>`)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
${showArg2 ? html`
|
||||
<label class="block">
|
||||
Compare against (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>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _renderStructuredArg1Picker(
|
||||
s: DecodedStructuredAnd,
|
||||
kind: string,
|
||||
update: (p: Partial<DecodedStructuredAnd>) => void,
|
||||
): TemplateResult {
|
||||
const bucket = this._bucketWithPreserve(
|
||||
this._pickBucket(kind), kind, s.arg1Ix,
|
||||
);
|
||||
const label = 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),
|
||||
})}>
|
||||
${bucket.map((o) => html`
|
||||
<option .value=${String(o.index)} ?selected=${o.index === s.arg1Ix}>
|
||||
#${o.index} ${o.name}
|
||||
</option>`)}
|
||||
</select>
|
||||
</label>`;
|
||||
}
|
||||
|
||||
private _renderChainCondFamily(
|
||||
decoded: DecodedCondition, idx: number,
|
||||
): TemplateResult {
|
||||
@ -2458,7 +2606,30 @@ export class OmniPanelPrograms extends LitElement {
|
||||
border-color: var(--error-color, #db4437);
|
||||
}
|
||||
.structured-cond {
|
||||
background: rgba(255, 152, 0, 0.08); /* subtle warning tint */
|
||||
background: rgba(255, 152, 0, 0.08); /* subtle structured tint */
|
||||
}
|
||||
.structured-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
.structured-tag, .readonly-tag {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 6px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.structured-tag {
|
||||
background: rgba(255, 152, 0, 0.18);
|
||||
color: #b35a00;
|
||||
}
|
||||
.readonly-tag {
|
||||
background: var(--secondary-background-color, #eee);
|
||||
color: var(--secondary-text-color, #888);
|
||||
}
|
||||
.chain-meta {
|
||||
margin-top: 8px;
|
||||
|
||||
@ -547,6 +547,177 @@ export function emptyThenRecord(firstUnit: number = 1): ProgramFields {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Structured-OP AND record editing.
|
||||
//
|
||||
// When ``and_op`` (= ``(cond >> 8) & 0xFF``) is non-zero, the record
|
||||
// encodes ``Arg1 OP Arg2`` where Arg1 and Arg2 are typed references
|
||||
// (Zone, Unit, Thermostat, Area, TimeDate, Constant) plus per-type
|
||||
// field selectors. This is fundamentally a different shape from the
|
||||
// Traditional encoding handled by decodeAndCondition above.
|
||||
//
|
||||
// Wire layout (from programs.py decoders + clsProgram.cs):
|
||||
//
|
||||
// cond high byte (>>8) = and_op (CondOP)
|
||||
// cond low byte (& FF) = and_arg1_argtype (CondArgType)
|
||||
// cond2 (whole u16) = and_arg1_ix (object index or 0)
|
||||
// cmd = and_arg1_field (per-type field selector)
|
||||
// par = and_arg2_argtype (CondArgType — usually Constant)
|
||||
// pr2 = and_arg2_ix (constant value OR second object idx)
|
||||
// month = and_arg2_field (per-type field selector for arg2)
|
||||
// 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.
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
|
||||
// CondOP enum (matches omni_pca.programs.CondOP). 0=Traditional is
|
||||
// excluded from the editor — picking it would switch to Traditional
|
||||
// editing semantics.
|
||||
export const COND_OPS: ReadonlyArray<{ value: number; label: string }> = [
|
||||
{ value: 1, label: "==" },
|
||||
{ value: 2, label: "!=" },
|
||||
{ value: 3, label: "<" },
|
||||
{ value: 4, label: ">" },
|
||||
{ value: 5, label: "is odd" },
|
||||
{ value: 6, label: "is even" },
|
||||
{ value: 7, label: "is multiple of" },
|
||||
{ value: 8, label: "in (bitmask)" },
|
||||
{ value: 9, label: "not in (bitmask)" },
|
||||
];
|
||||
|
||||
/** True iff the operator only uses Arg1 (no Arg2). */
|
||||
export function isUnaryOp(op: number): boolean {
|
||||
return op === 5 || op === 6; // ODD, EVEN
|
||||
}
|
||||
|
||||
// CondArgType enum (matches omni_pca.programs.CondArgType). Only the
|
||||
// editor-supported subset; full list is in programs.py.
|
||||
export const ARG_TYPES: ReadonlyArray<{
|
||||
value: number; label: string; kind: string | null;
|
||||
}> = [
|
||||
{ value: 0, label: "Constant", kind: null },
|
||||
{ value: 2, label: "Zone", kind: "zone" },
|
||||
{ value: 3, label: "Unit", kind: "unit" },
|
||||
{ value: 4, label: "Thermostat", kind: "thermostat" },
|
||||
{ value: 6, label: "Area", kind: "area" },
|
||||
{ value: 7, label: "Time / Date", kind: null }, // no object picker
|
||||
];
|
||||
|
||||
export function isEditableArg1Type(argtype: number): boolean {
|
||||
return [2, 3, 4, 6, 7].includes(argtype);
|
||||
}
|
||||
|
||||
export function argTypeKind(argtype: number): string | null {
|
||||
const a = ARG_TYPES.find((x) => x.value === argtype);
|
||||
return a ? a.kind : null;
|
||||
}
|
||||
|
||||
// Per-Arg1Type field menus. Numbers match omni_pca.programs enums
|
||||
// (enuZoneField / enuUnitField / enuThermostatField / enuTimeDateField).
|
||||
export const FIELDS_BY_TYPE: Readonly<Record<number, ReadonlyArray<{
|
||||
value: number; label: string;
|
||||
}>>> = {
|
||||
// Zone (argtype 2) — enuZoneField
|
||||
2: [
|
||||
{ value: 1, label: "Loop reading" },
|
||||
{ value: 2, label: "Current state" },
|
||||
{ value: 3, label: "Arming state" },
|
||||
{ value: 4, label: "Alarm state" },
|
||||
],
|
||||
// Unit (argtype 3) — enuUnitField
|
||||
3: [
|
||||
{ value: 1, label: "Current state" },
|
||||
{ value: 2, label: "Previous state" },
|
||||
{ value: 3, label: "Timer" },
|
||||
{ value: 4, label: "Level" },
|
||||
],
|
||||
// Thermostat (argtype 4) — enuThermostatField
|
||||
4: [
|
||||
{ value: 1, label: "Current temperature" },
|
||||
{ value: 2, label: "Heat setpoint" },
|
||||
{ value: 3, label: "Cool setpoint" },
|
||||
{ value: 4, label: "System mode" },
|
||||
{ value: 5, label: "Fan mode" },
|
||||
{ value: 6, label: "Hold mode" },
|
||||
{ value: 7, label: "Freeze alarm" },
|
||||
{ value: 8, label: "Comm error" },
|
||||
{ value: 9, label: "Humidity" },
|
||||
{ value: 10, label: "Humidify setpoint" },
|
||||
{ value: 11, label: "Dehumidify setpoint" },
|
||||
{ value: 12, label: "Outdoor temperature" },
|
||||
{ value: 13, label: "System status" },
|
||||
],
|
||||
// Area (argtype 6) — single useful field
|
||||
6: [
|
||||
{ value: 1, label: "Security mode" },
|
||||
],
|
||||
// TimeDate (argtype 7) — enuTimeDateField
|
||||
7: [
|
||||
{ value: 2, label: "Year" },
|
||||
{ value: 3, label: "Month" },
|
||||
{ value: 4, label: "Day" },
|
||||
{ value: 5, label: "Day of week (1=Mon..7=Sun)" },
|
||||
{ value: 6, label: "Time (minutes since midnight)" },
|
||||
{ value: 8, label: "Hour" },
|
||||
{ value: 9, label: "Minute" },
|
||||
],
|
||||
};
|
||||
|
||||
export interface DecodedStructuredAnd {
|
||||
op: number; // CondOP value (1..9)
|
||||
arg1Type: number; // CondArgType
|
||||
arg1Ix: number; // 1-based object index (0 for TimeDate)
|
||||
arg1Field: number; // per-type field
|
||||
arg2Type: number; // CondArgType (locked to Constant in editor)
|
||||
arg2Ix: number; // constant value OR second object index
|
||||
arg2Field: number; // per-type field (usually 0 for constants)
|
||||
compConst: number; // extra constant; preserved verbatim
|
||||
}
|
||||
|
||||
export function decodeStructuredAnd(fields: ProgramFields): DecodedStructuredAnd {
|
||||
return {
|
||||
op: ((fields.cond ?? 0) >> 8) & 0xFF,
|
||||
arg1Type: (fields.cond ?? 0) & 0xFF,
|
||||
arg1Ix: fields.cond2 ?? 0,
|
||||
arg1Field: fields.cmd ?? 0,
|
||||
arg2Type: fields.par ?? 0,
|
||||
arg2Ix: fields.pr2 ?? 0,
|
||||
arg2Field: fields.month ?? 0,
|
||||
compConst: ((fields.day ?? 0) << 8) | (fields.days ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function encodeStructuredAnd(s: DecodedStructuredAnd): Partial<ProgramFields> {
|
||||
return {
|
||||
cond: ((s.op & 0xFF) << 8) | (s.arg1Type & 0xFF),
|
||||
cond2: s.arg1Ix & 0xFFFF,
|
||||
cmd: s.arg1Field & 0xFF,
|
||||
par: s.arg2Type & 0xFF,
|
||||
pr2: s.arg2Ix & 0xFFFF,
|
||||
month: s.arg2Field & 0xFF,
|
||||
day: (s.compConst >> 8) & 0xFF,
|
||||
days: s.compConst & 0xFF,
|
||||
};
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function isEditableStructuredAnd(s: DecodedStructuredAnd): boolean {
|
||||
if (!isEditableArg1Type(s.arg1Type)) return false;
|
||||
if (!isUnaryOp(s.op) && s.arg2Type !== 0) return false;
|
||||
if (s.compConst !== 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** HA's hass object — minimal surface we use. */
|
||||
export interface Hass {
|
||||
connection: {
|
||||
|
||||
File diff suppressed because one or more lines are too long
BIN
dev/artifacts/screenshots/2026-05-16/12-structured-and.png
Normal file
BIN
dev/artifacts/screenshots/2026-05-16/12-structured-and.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 218 KiB |
Loading…
x
Reference in New Issue
Block a user