panel: inline AND-IF condition editor for compact-form programs
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run

Replaces the read-only "conditions present but not editable" banner
with a real editor for the cond / cond2 u16 fields on TIMED / EVENT /
YEARLY programs.

Compact-form conditions split into five families per
clsText.GetConditionalText (clsText.cs:2224-2274):

  none     — cond = 0 (no inline condition)
  misc     — family 0x00, low nibble = enuMiscConditional
             (NONE / NEVER / LIGHT / DARK / PHONE_* / AC_POWER_* /
             BATTERY_* / ENERGY_COST_*)
  zone     — family 0x04, low byte = zone, bit 0x0200 = NOT_READY
  unit     — family 0x08, low 9 bits = unit, bit 0x0200 = ON
  time     — family 0x0C, low byte = time-clock #, bit 0x0200 = enabled
  sec      — family >= 0x10, bits 8-11 = area, bits 12-14 = security mode

types.ts gains decodeCondition / encodeCondition + the
MISC_CONDITIONALS / SECURITY_MODE_NAMES enums. Round-trip is exact:
decode(encode(c)) === c for every supported family.

UI: two condition slots per editor (matching the two u16 fields on
the wire). Each slot has a family-picker dropdown that swaps the
sub-fields (zone picker + secure/not-ready, unit picker + on/off,
area picker + security mode, time-clock # + enabled/disabled, misc
condition picker, or "none"). Picking a family seeds sensible defaults
(NEVER for misc, first zone secure, first unit ON, area 1 disarmed,
time clock 1 enabled).

Object pickers reuse the same _bucketWithPreserve helper introduced
for the action editor, so out-of-range zone/unit/area refs in inline
conditions keep their original value with a "preserve" label.

Live smoke test against the real panel: slot #1's actual condition
"AND IF Time clock 4 is disabled" now decodes into the editor as
Family=Time clock / # = 4 / Is = disabled — exactly the on-disk state.

Frontend bundle: 63 KB minified (up from 56 KB with the new editor
section + cond helpers).
This commit is contained in:
Ryan Malloy 2026-05-17 01:43:32 -06:00
parent 6f92671cf2
commit 5870e2f7ee
7 changed files with 479 additions and 31 deletions

View File

@ -14,11 +14,14 @@ import { renderTokens } from "./token-renderer.js";
import { import {
COMMAND_OPTIONS, COMMAND_OPTIONS,
CommandOption, CommandOption,
CondFamily,
DAY_BITS, DAY_BITS,
DecodedCondition,
DecodedEvent, DecodedEvent,
EventCategory, EventCategory,
FIXED_EVENTS, FIXED_EVENTS,
Hass, Hass,
MISC_CONDITIONALS,
MONTH_NAMES, MONTH_NAMES,
NamedObject, NamedObject,
ObjectListResponse, ObjectListResponse,
@ -29,8 +32,11 @@ import {
ProgramFields, ProgramFields,
ProgramListResponse, ProgramListResponse,
ProgramRow, ProgramRow,
SECURITY_MODE_NAMES,
commandOptionFor, commandOptionFor,
decodeCondition,
decodeEventId, decodeEventId,
encodeCondition,
encodeEventId, encodeEventId,
eventIdFromFields, eventIdFromFields,
packEventIdIntoFields, packEventIdIntoFields,
@ -874,13 +880,7 @@ export class OmniPanelPrograms extends LitElement {
<div class="editor-body"> <div class="editor-body">
${this._renderTriggerSection(draft)} ${this._renderTriggerSection(draft)}
${this._renderActionSection(draft)} ${this._renderActionSection(draft)}
${draft.cond || draft.cond2 ? html` ${this._renderConditionsSection(draft)}
<div class="conditions-readonly">
<strong>Inline conditions:</strong>
this program carries up to two inline AND-IF conditions on
the source record. They're preserved on save but editing
condition fields is not yet supported.
</div>` : ""}
</div> </div>
<footer> <footer>
@ -1180,6 +1180,204 @@ export class OmniPanelPrograms extends LitElement {
`; `;
} }
private _renderConditionsSection(draft: ProgramFields): TemplateResult {
return html`
<fieldset>
<legend>Inline AND-IF conditions</legend>
${this._renderConditionSlot(
"First condition", draft.cond ?? 0,
(v) => this._patchDraft({ cond: v }),
)}
${this._renderConditionSlot(
"Second condition", draft.cond2 ?? 0,
(v) => this._patchDraft({ cond2: v }),
)}
</fieldset>
`;
}
private _renderConditionSlot(
label: string, raw: number, onChange: (newCond: number) => void,
): TemplateResult {
const decoded = decodeCondition(raw);
const setFamily = (family: CondFamily) => {
// Seed sensible defaults when switching family so the sub-fields
// immediately encode to a non-degenerate value.
const firstZone = this._objects?.zones?.[0]?.index ?? 1;
const firstUnit = this._objects?.units?.[0]?.index ?? 1;
const firstArea = this._objects?.areas?.[0]?.index ?? 1;
let next: DecodedCondition;
switch (family) {
case "none": next = { family: "none" }; break;
case "misc": next = { family: "misc", misc: 1 }; break; // NEVER
case "zone": next = { family: "zone", index: firstZone, active: false }; break;
case "unit": next = { family: "unit", index: firstUnit, active: true }; break;
case "time": next = { family: "time", index: 1, active: true }; break;
case "sec": next = { family: "sec", index: firstArea, mode: 0 }; break;
}
onChange(encodeCondition(next));
};
return html`
<div class="cond-slot">
<label class="block cond-family-label">
${label}
<select @change=${(e: Event) =>
setFamily((e.target as HTMLSelectElement).value as CondFamily)}>
<option value="none" ?selected=${decoded.family === "none"}>(none)</option>
<option value="zone" ?selected=${decoded.family === "zone"}>Zone state</option>
<option value="unit" ?selected=${decoded.family === "unit"}>Unit state</option>
<option value="sec" ?selected=${decoded.family === "sec"}>Area in security mode</option>
<option value="time" ?selected=${decoded.family === "time"}>Time clock</option>
<option value="misc" ?selected=${decoded.family === "misc"}>Misc (light, AC power, )</option>
</select>
</label>
${this._renderConditionSubfields(decoded, onChange)}
</div>
`;
}
private _renderConditionSubfields(
decoded: DecodedCondition, onChange: (newCond: number) => void,
): TemplateResult {
if (decoded.family === "none") return html``;
if (decoded.family === "zone") {
const zones = this._bucketWithPreserve(
this._objects?.zones ?? null, "zone", decoded.index ?? 0,
);
return html`
<label class="block">
Zone
<select @change=${(e: Event) => {
const idx = parseInt((e.target as HTMLSelectElement).value, 10);
onChange(encodeCondition({ ...decoded, index: idx }));
}}>
${zones.map((z) => html`
<option .value=${String(z.index)}
?selected=${z.index === decoded.index}>
#${z.index} ${z.name}
</option>
`)}
</select>
</label>
<label class="block">
Is
<select @change=${(e: Event) => {
const active = (e.target as HTMLSelectElement).value === "1";
onChange(encodeCondition({ ...decoded, active }));
}}>
<option value="0" ?selected=${!decoded.active}>secure</option>
<option value="1" ?selected=${decoded.active}>not ready</option>
</select>
</label>`;
}
if (decoded.family === "unit") {
const units = this._bucketWithPreserve(
this._objects?.units ?? null, "unit", decoded.index ?? 0,
);
return html`
<label class="block">
Unit
<select @change=${(e: Event) => {
const idx = parseInt((e.target as HTMLSelectElement).value, 10);
onChange(encodeCondition({ ...decoded, index: idx }));
}}>
${units.map((u) => html`
<option .value=${String(u.index)}
?selected=${u.index === decoded.index}>
#${u.index} ${u.name}
</option>
`)}
</select>
</label>
<label class="block">
Is
<select @change=${(e: Event) => {
const active = (e.target as HTMLSelectElement).value === "1";
onChange(encodeCondition({ ...decoded, active }));
}}>
<option value="1" ?selected=${decoded.active}>ON</option>
<option value="0" ?selected=${!decoded.active}>OFF</option>
</select>
</label>`;
}
if (decoded.family === "sec") {
const areas = this._bucketWithPreserve(
this._objects?.areas ?? null, "area", decoded.index ?? 0,
);
return html`
<label class="block">
Area
<select @change=${(e: Event) => {
const idx = parseInt((e.target as HTMLSelectElement).value, 10);
onChange(encodeCondition({ ...decoded, index: idx }));
}}>
${areas.map((a) => html`
<option .value=${String(a.index)}
?selected=${a.index === decoded.index}>
#${a.index} ${a.name}
</option>
`)}
</select>
</label>
<label class="block">
Mode
<select @change=${(e: Event) => {
const mode = parseInt((e.target as HTMLSelectElement).value, 10);
onChange(encodeCondition({ ...decoded, mode }));
}}>
${SECURITY_MODE_NAMES.map((m) => html`
<option .value=${String(m.value)}
?selected=${m.value === decoded.mode}>
${m.label}
</option>
`)}
</select>
</label>`;
}
if (decoded.family === "time") {
return html`
<label class="block">
Time clock # (1..3)
<input
type="number" min="1" max="3"
.value=${String(decoded.index ?? 1)}
@input=${(e: Event) => {
const idx = parseInt((e.target as HTMLInputElement).value, 10);
if (Number.isFinite(idx)) {
onChange(encodeCondition({ ...decoded, index: idx }));
}
}}
/>
</label>
<label class="block">
Is
<select @change=${(e: Event) => {
const active = (e.target as HTMLSelectElement).value === "1";
onChange(encodeCondition({ ...decoded, active }));
}}>
<option value="1" ?selected=${decoded.active}>enabled</option>
<option value="0" ?selected=${!decoded.active}>disabled</option>
</select>
</label>`;
}
// misc
return html`
<label class="block">
Condition
<select @change=${(e: Event) => {
const misc = parseInt((e.target as HTMLSelectElement).value, 10);
onChange(encodeCondition({ family: "misc", misc }));
}}>
${MISC_CONDITIONALS.map((m) => html`
<option .value=${String(m.value)}
?selected=${m.value === decoded.misc}>
${m.label}
</option>
`)}
</select>
</label>`;
}
// -- styles ----------------------------------------------------------- // -- styles -----------------------------------------------------------
static styles = css` static styles = css`
@ -1515,6 +1713,17 @@ export class OmniPanelPrograms extends LitElement {
font-size: 0.82rem; font-size: 0.82rem;
color: var(--secondary-text-color, #666); color: var(--secondary-text-color, #666);
} }
.cond-slot {
padding: 8px 10px;
margin-top: 6px;
background: var(--secondary-background-color, #f5f5f5);
border-radius: 4px;
}
.cond-slot:first-of-type { margin-top: 0; }
.cond-family-label {
font-weight: 600;
color: var(--primary-text-color, #000);
}
`; `;
} }

View File

@ -280,6 +280,135 @@ export const MONTH_NAMES = [
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
]; ];
// --------------------------------------------------------------------------
// Compact-form AND-IF condition encode/decode for the inline-conditions
// editor (TIMED/EVENT/YEARLY cond + cond2 fields).
//
// Mirrors clsText.GetConditionalText (clsText.cs:2224-2274) and the
// Python _emit_traditional_cond in program_renderer.py. Bit layout:
//
// family = (cond >> 8) & 0xFC
// selector bit = (cond & 0x0200) — meaning depends on family
//
// family 0x00 OTHER — cond & 0x0F = enuMiscConditional (NONE=0,
// NEVER=1, LIGHT=2, DARK=3, ...)
// family 0x04 ZONE — low 8 bits = zone index; selector bit
// 0=secure, 1=not ready
// family 0x08 CTRL — low 9 bits = unit index; selector bit
// 0=OFF, 1=ON
// family 0x0C TIME — low 8 bits = time-clock index; selector bit
// 0=disabled, 1=enabled
// family >= 0x10 SEC — (cond >> 8) & 0x0F = area, (cond >> 12) & 0x07 = mode
//
// cond == 0 means "no condition" (NONE).
// --------------------------------------------------------------------------
export type CondFamily =
| "none" // cond = 0 — no inline condition
| "misc" // OTHER family (NEVER, LIGHT, DARK, PHONE_*, AC_POWER_*, …)
| "zone" // ZONE family — zone + secure/not-ready
| "unit" // CTRL family — unit + on/off
| "time" // TIME family — time-clock + enabled/disabled
| "sec"; // SEC family — area + security mode
export interface DecodedCondition {
family: CondFamily;
/** misc-conditional index (0..15) — used when family == "misc". */
misc?: number;
/** Zone / unit / time-clock / area index — used by the named families. */
index?: number;
/** Selector bit: zone "not ready", unit "on", time-clock "enabled". */
active?: boolean;
/** SEC family security mode (0..7). */
mode?: number;
}
// MiscConditional enum (matches omni_pca.programs.MiscConditional).
// Each entry: { value, label }. NONE renders as "always" and NEVER as
// "never" — both common authoring patterns.
export const MISC_CONDITIONALS: ReadonlyArray<{ value: number; label: string }> = [
{ value: 0, label: "always" },
{ value: 1, label: "never" },
{ value: 2, label: "it is light outside" },
{ value: 3, label: "it is dark outside" },
{ value: 4, label: "phone line is dead" },
{ value: 5, label: "phone is ringing" },
{ value: 6, label: "phone is off hook" },
{ value: 7, label: "phone is on hook" },
{ value: 8, label: "AC power is off" },
{ value: 9, label: "AC power is on" },
{ value: 10, label: "battery is low" },
{ value: 11, label: "battery is OK" },
{ value: 12, label: "energy cost is low" },
{ value: 13, label: "energy cost is mid" },
{ value: 14, label: "energy cost is high" },
{ value: 15, label: "energy cost is critical" },
];
// Security modes for the SEC family (matches enuSecurityMode order).
export const SECURITY_MODE_NAMES: ReadonlyArray<{ value: number; label: string }> = [
{ value: 0, label: "Off (disarmed)" },
{ value: 1, label: "Day" },
{ value: 2, label: "Night" },
{ value: 3, label: "Away" },
{ value: 4, label: "Vacation" },
{ value: 5, label: "Day Instant" },
{ value: 6, label: "Night Delayed" },
];
export function decodeCondition(cond: number): DecodedCondition {
if (cond === 0) return { family: "none" };
const family = (cond >> 8) & 0xFC;
const active = (cond & 0x0200) !== 0;
if (family === 0x00) {
return { family: "misc", misc: cond & 0x0F };
}
if (family === 0x04) {
return { family: "zone", index: cond & 0xFF, active };
}
if (family === 0x08) {
return { family: "unit", index: cond & 0x01FF, active };
}
if (family === 0x0C) {
return { family: "time", index: cond & 0xFF, active };
}
// SEC family (family >= 0x10): area in high nibble of upper byte,
// mode in top nibble.
return {
family: "sec",
index: (cond >> 8) & 0x0F,
mode: (cond >> 12) & 0x07,
};
}
export function encodeCondition(c: DecodedCondition): number {
switch (c.family) {
case "none":
return 0;
case "misc":
return (c.misc ?? 0) & 0x0F; // family 0x00, low nibble = misc
case "zone": {
const idx = (c.index ?? 0) & 0xFF;
return 0x0400 | (c.active ? 0x0200 : 0) | idx;
}
case "unit": {
const idx = (c.index ?? 0) & 0x01FF;
return 0x0800 | (c.active ? 0x0200 : 0) | idx;
}
case "time": {
const idx = (c.index ?? 0) & 0xFF;
return 0x0C00 | (c.active ? 0x0200 : 0) | idx;
}
case "sec": {
const area = (c.index ?? 1) & 0x0F;
const mode = (c.mode ?? 0) & 0x07;
return (mode << 12) | (area << 8);
}
}
}
/** 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.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 KiB

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 353 KiB

After

Width:  |  Height:  |  Size: 356 KiB