panel: inline AND-IF condition editor for compact-form programs
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).
@ -14,11 +14,14 @@ import { renderTokens } from "./token-renderer.js";
|
||||
import {
|
||||
COMMAND_OPTIONS,
|
||||
CommandOption,
|
||||
CondFamily,
|
||||
DAY_BITS,
|
||||
DecodedCondition,
|
||||
DecodedEvent,
|
||||
EventCategory,
|
||||
FIXED_EVENTS,
|
||||
Hass,
|
||||
MISC_CONDITIONALS,
|
||||
MONTH_NAMES,
|
||||
NamedObject,
|
||||
ObjectListResponse,
|
||||
@ -29,8 +32,11 @@ import {
|
||||
ProgramFields,
|
||||
ProgramListResponse,
|
||||
ProgramRow,
|
||||
SECURITY_MODE_NAMES,
|
||||
commandOptionFor,
|
||||
decodeCondition,
|
||||
decodeEventId,
|
||||
encodeCondition,
|
||||
encodeEventId,
|
||||
eventIdFromFields,
|
||||
packEventIdIntoFields,
|
||||
@ -874,13 +880,7 @@ export class OmniPanelPrograms extends LitElement {
|
||||
<div class="editor-body">
|
||||
${this._renderTriggerSection(draft)}
|
||||
${this._renderActionSection(draft)}
|
||||
${draft.cond || draft.cond2 ? html`
|
||||
<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>` : ""}
|
||||
${this._renderConditionsSection(draft)}
|
||||
</div>
|
||||
|
||||
<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 -----------------------------------------------------------
|
||||
|
||||
static styles = css`
|
||||
@ -1515,6 +1713,17 @@ export class OmniPanelPrograms extends LitElement {
|
||||
font-size: 0.82rem;
|
||||
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);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@ -280,6 +280,135 @@ export const MONTH_NAMES = [
|
||||
"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. */
|
||||
export interface Hass {
|
||||
connection: {
|
||||
|
||||
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 294 KiB After Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 324 KiB After Width: | Height: | Size: 325 KiB |
|
Before Width: | Height: | Size: 353 KiB After Width: | Height: | Size: 356 KiB |