panel: structured-OP AND record editor (TEMP > 70 etc.)
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run

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:
Ryan Malloy 2026-05-17 02:24:59 -06:00
parent 9ca4da98e8
commit 486258a034
4 changed files with 623 additions and 196 deletions

View File

@ -12,13 +12,17 @@ import { LitElement, html, css, PropertyValues, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import { renderTokens } from "./token-renderer.js"; import { renderTokens } from "./token-renderer.js";
import { import {
ARG_TYPES,
COMMAND_OPTIONS, COMMAND_OPTIONS,
COND_OPS,
CommandOption, CommandOption,
CondFamily, CondFamily,
DAY_BITS, DAY_BITS,
DecodedCondition, DecodedCondition,
DecodedEvent, DecodedEvent,
DecodedStructuredAnd,
EventCategory, EventCategory,
FIELDS_BY_TYPE,
FIXED_EVENTS, FIXED_EVENTS,
Hass, Hass,
MISC_CONDITIONALS, MISC_CONDITIONALS,
@ -37,18 +41,23 @@ import {
ProgramListResponse, ProgramListResponse,
ProgramRow, ProgramRow,
SECURITY_MODE_NAMES, SECURITY_MODE_NAMES,
argTypeKind,
commandOptionFor, commandOptionFor,
decodeAndCondition, decodeAndCondition,
decodeCondition, decodeCondition,
decodeEventId, decodeEventId,
decodeStructuredAnd,
emptyAndRecord, emptyAndRecord,
emptyOrRecord, emptyOrRecord,
emptyThenRecord, emptyThenRecord,
encodeAndCondition, encodeAndCondition,
encodeCondition, encodeCondition,
encodeEventId, encodeEventId,
encodeStructuredAnd,
eventIdFromFields, eventIdFromFields,
isEditableStructuredAnd,
isStructuredAnd, isStructuredAnd,
isUnaryOp,
packEventIdIntoFields, packEventIdIntoFields,
} from "./types.js"; } from "./types.js";
@ -566,10 +575,11 @@ export class OmniPanelPrograms extends LitElement {
private _pickBucket(kind: string): NamedObject[] | null { private _pickBucket(kind: string): NamedObject[] | null {
if (!this._objects) return null; if (!this._objects) return null;
switch (kind) { switch (kind) {
case "zone": return this._objects.zones; case "zone": return this._objects.zones;
case "unit": return this._objects.units; case "unit": return this._objects.units;
case "area": return this._objects.areas; case "area": return this._objects.areas;
case "button": return this._objects.buttons; case "button": return this._objects.buttons;
case "thermostat": return this._objects.thermostats;
default: return null; default: return null;
} }
} }
@ -1820,19 +1830,7 @@ export class OmniPanelPrograms extends LitElement {
): TemplateResult { ): TemplateResult {
const isOr = cond.prog_type === PROGRAM_TYPE_OR; const isOr = cond.prog_type === PROGRAM_TYPE_OR;
if (isStructuredAnd(cond)) { if (isStructuredAnd(cond)) {
return html` return this._renderStructuredChainConditionRow(cond, idx, isOr);
<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 &gt; N etc.).
Editing structured-OP records is not yet supported; it's
preserved on save.
</div>
</div>`;
} }
const decoded = decodeAndCondition(cond); const decoded = decodeAndCondition(cond);
return html` return html`
@ -1846,6 +1844,156 @@ export class OmniPanelPrograms extends LitElement {
</div>`; </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( private _renderChainCondFamily(
decoded: DecodedCondition, idx: number, decoded: DecodedCondition, idx: number,
): TemplateResult { ): TemplateResult {
@ -2458,7 +2606,30 @@ export class OmniPanelPrograms extends LitElement {
border-color: var(--error-color, #db4437); border-color: var(--error-color, #db4437);
} }
.structured-cond { .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 { .chain-meta {
margin-top: 8px; margin-top: 8px;

View File

@ -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. */ /** HA's hass object — minimal surface we use. */
export interface Hass { export interface Hass {
connection: { connection: {

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB