diff --git a/custom_components/omni_pca/frontend/src/omni-panel-programs.ts b/custom_components/omni_pca/frontend/src/omni-panel-programs.ts index b8cdee9..45c9488 100644 --- a/custom_components/omni_pca/frontend/src/omni-panel-programs.ts +++ b/custom_components/omni_pca/frontend/src/omni-panel-programs.ts @@ -25,8 +25,12 @@ import { MONTH_NAMES, NamedObject, ObjectListResponse, + PROGRAM_TYPE_AT, PROGRAM_TYPE_EVENT, + PROGRAM_TYPE_EVERY, + PROGRAM_TYPE_OR, PROGRAM_TYPE_TIMED, + PROGRAM_TYPE_WHEN, PROGRAM_TYPE_YEARLY, ProgramDetail, ProgramFields, @@ -34,11 +38,17 @@ import { ProgramRow, SECURITY_MODE_NAMES, commandOptionFor, + decodeAndCondition, decodeCondition, decodeEventId, + emptyAndRecord, + emptyOrRecord, + emptyThenRecord, + encodeAndCondition, encodeCondition, encodeEventId, eventIdFromFields, + isStructuredAnd, packEventIdIntoFields, } from "./types.js"; @@ -100,6 +110,18 @@ export class OmniPanelPrograms extends LitElement { // omni_pca/programs/write websocket, Cancel discards. @state() private _editingDraft: ProgramFields | null = null; @state() private _objects: ObjectListResponse | null = null; + // Separate edit state for clausal chains. Set when the user clicks + // "Edit chain"; null otherwise. Head + conditions + actions are kept + // as parallel arrays so add/remove operations on a specific list + // don't churn the others. ``headSlot`` is the chain's anchor; the + // backend writes the new chain into [headSlot, headSlot+len) and + // clears any old slots beyond. + @state() private _chainDraft: { + headSlot: number; + head: ProgramFields; + conditions: ProgramFields[]; + actions: ProgramFields[]; + } | null = null; private _refreshTimer: number | null = null; @@ -324,17 +346,18 @@ export class OmniPanelPrograms extends LitElement { } private async _beginEdit(): Promise { - if (!this._detail || this._detail.kind !== "compact") return; - // The frontend supports editing compact-form TIMED / EVENT / YEARLY - // programs. Other compact types (REMARK) and clausal chains remain - // read-only — the editor pathway returns early without seeding a - // draft so the read-only view stays visible. - if (!EDITABLE_PROG_TYPES.has(this._detail.trigger_type)) return; + if (!this._detail) return; await this._ensureObjectsLoaded(); if (!this._entryId) return; - // The detail response now carries raw fields directly. If they're - // missing (panel returned only tokens) we fall back to sensible - // defaults so the form at least opens — better than a hard error. + if (this._detail.kind === "chain") { + this._beginChainEdit(); + return; + } + // The frontend supports editing compact-form TIMED / EVENT / YEARLY + // programs. Other compact types (REMARK) remain read-only — the + // editor pathway returns early without seeding a draft so the + // read-only view stays visible. + if (!EDITABLE_PROG_TYPES.has(this._detail.trigger_type)) return; const fields = this._detail.fields ?? this._defaultFieldsForType( this._detail.trigger_type, ); @@ -343,6 +366,110 @@ export class OmniPanelPrograms extends LitElement { this._stopRefreshTimer(); } + private _beginChainEdit(): void { + if (!this._detail || !this._detail.chain_members) return; + const members = this._detail.chain_members; + const head = members.find((m) => m.role === "head"); + if (!head) return; + this._chainDraft = { + headSlot: head.slot, + head: { ...head.fields }, + conditions: members + .filter((m) => m.role === "condition") + .map((m) => ({ ...m.fields })), + actions: members + .filter((m) => m.role === "action") + .map((m) => ({ ...m.fields })), + }; + this._stopRefreshTimer(); + } + + private _cancelChainEdit(): void { + this._chainDraft = null; + this._startRefreshTimer(); + } + + private async _saveChainDraft(): Promise { + if (!this._chainDraft || !this._entryId) return; + this._writeFeedback = "saving chain…"; + try { + await this.hass.connection.sendMessagePromise({ + type: "omni_pca/programs/chain/write", + entry_id: this._entryId, + head_slot: this._chainDraft.headSlot, + head: this._chainDraft.head, + conditions: this._chainDraft.conditions, + actions: this._chainDraft.actions, + }); + this._writeFeedback = `saved chain @ slot ${this._chainDraft.headSlot}`; + const headSlot = this._chainDraft.headSlot; + this._chainDraft = null; + this._startRefreshTimer(); + await this._loadList(); + await this._loadDetail(headSlot); + } catch (err) { + const m = err instanceof Error ? err.message : String(err); + this._writeFeedback = `error: ${m}`; + } + setTimeout(() => { this._writeFeedback = null; }, 4000); + } + + // ---- chain draft mutation helpers --------------------------------- + + private _patchChainHead(patch: Partial): void { + if (!this._chainDraft) return; + this._chainDraft = { + ...this._chainDraft, + head: { ...this._chainDraft.head, ...patch }, + }; + } + + private _patchChainCondition(idx: number, patch: Partial): void { + if (!this._chainDraft) return; + const conds = [...this._chainDraft.conditions]; + conds[idx] = { ...conds[idx], ...patch }; + this._chainDraft = { ...this._chainDraft, conditions: conds }; + } + + private _addChainCondition(asOr: boolean = false): void { + if (!this._chainDraft) return; + const fresh = asOr ? emptyOrRecord() : emptyAndRecord(); + this._chainDraft = { + ...this._chainDraft, + conditions: [...this._chainDraft.conditions, fresh], + }; + } + + private _removeChainCondition(idx: number): void { + if (!this._chainDraft) return; + const conds = this._chainDraft.conditions.filter((_, i) => i !== idx); + this._chainDraft = { ...this._chainDraft, conditions: conds }; + } + + private _patchChainAction(idx: number, patch: Partial): void { + if (!this._chainDraft) return; + const actions = [...this._chainDraft.actions]; + actions[idx] = { ...actions[idx], ...patch }; + this._chainDraft = { ...this._chainDraft, actions: actions }; + } + + private _addChainAction(): void { + if (!this._chainDraft) return; + const firstUnit = this._objects?.units?.[0]?.index ?? 1; + this._chainDraft = { + ...this._chainDraft, + actions: [...this._chainDraft.actions, emptyThenRecord(firstUnit)], + }; + } + + private _removeChainAction(idx: number): void { + if (!this._chainDraft) return; + // Guard: a chain must have at least one action. + if (this._chainDraft.actions.length <= 1) return; + const actions = this._chainDraft.actions.filter((_, i) => i !== idx); + this._chainDraft = { ...this._chainDraft, actions: actions }; + } + private _defaultFieldsForType(triggerType: string): ProgramFields | null { const firstUnit = this._objects?.units?.[0]?.index ?? 1; if (triggerType === "TIMED") { @@ -767,6 +894,9 @@ export class OmniPanelPrograms extends LitElement { if (this._editingDraft !== null) { return this._renderEditor(d); } + if (this._chainDraft !== null) { + return this._renderChainEditor(d); + } return html` + `; + } + + private _renderChainHeadSection(head: ProgramFields): TemplateResult { + // Reuse the existing trigger-section renderers from the compact-form + // editor — head records share the same field semantics as their + // compact counterparts (AT = TIMED, WHEN = EVENT, EVERY uses + // cond/cond2 for interval). The renderers patch via _patchDraft, + // which we shim to redirect to _patchChainHead while in chain mode. + if (head.prog_type === PROGRAM_TYPE_WHEN) { + return this._renderEventTriggerChain(head); + } + if (head.prog_type === PROGRAM_TYPE_AT) { + return this._renderTimedTriggerChain(head); + } + if (head.prog_type === PROGRAM_TYPE_EVERY) { + return this._renderEveryTriggerChain(head); + } + return html` +
+ Editing trigger type ${head.prog_type} (chain head) is not supported. +
`; + } + + private _renderTimedTriggerChain(head: ProgramFields): TemplateResult { + // Same fields as TIMED compact: hour/minute/days. + return html` +
+ AT (trigger) +
+ + : + +
+
+ ${DAY_BITS.map((d) => { + const active = ((head.days ?? 0) & d.bit) !== 0; + return html` + `; + })} +
+
+ `; + } + + private _renderEventTriggerChain(head: ProgramFields): TemplateResult { + // WHEN heads use the same event-id packing as EVENT compact-form: + // month/day bytes carry (event_id >> 8) and (event_id & 0xFF). + const eventId = ((head.month ?? 0) << 8) | (head.day ?? 0); + const decoded = decodeEventId(eventId); + const setEvent = (e: DecodedEvent) => { + const id = encodeEventId(e); + this._patchChainHead({ + month: (id >> 8) & 0xFF, + day: id & 0xFF, + }); + }; + return html` +
+ WHEN (trigger event) + + ${this._renderChainEventSubfields(decoded, setEvent)} +
+ `; + } + + private _renderChainEventSubfields( + decoded: DecodedEvent, setEvent: (e: DecodedEvent) => void, + ): TemplateResult { + if (decoded.category === "button") { + const buttons = this._bucketWithPreserve( + this._objects?.buttons ?? null, "button", decoded.button ?? 0, + ); + return html` + `; + } + if (decoded.category === "zone") { + const zones = this._bucketWithPreserve( + this._objects?.zones ?? null, "zone", decoded.zone ?? 0, + ); + return html` + + `; + } + if (decoded.category === "unit") { + const units = this._bucketWithPreserve( + this._objects?.units ?? null, "unit", decoded.unit ?? 0, + ); + return html` + + `; + } + if (decoded.category === "fixed") { + return html` + `; + } + return html`
Unrecognised event ID. Pick a category to redefine.
`; + } + + private _renderEveryTriggerChain(head: ProgramFields): TemplateResult { + // EVERY's interval = ((cond & 0xFF) << 8) | ((cond2 >> 8) & 0xFF). + // Decode for display; the editor exposes a single "seconds" input + // and packs back to cond + cond2 on change. + const interval = + (((head.cond ?? 0) & 0xFF) << 8) | (((head.cond2 ?? 0) >> 8) & 0xFF); + return html` +
+ EVERY (interval, seconds) + +
+ `; + } + + private _renderChainConditionsSection(conds: ProgramFields[]): TemplateResult { + return html` +
+ + Conditions (${conds.length}) + + + + ${conds.length === 0 ? html` +
+ No conditions — chain fires unconditionally when triggered. +
` : ""} + ${conds.map((c, idx) => this._renderChainConditionRow(c, idx))} +
+ `; + } + + private _renderChainConditionRow( + cond: ProgramFields, idx: number, + ): TemplateResult { + const isOr = cond.prog_type === PROGRAM_TYPE_OR; + if (isStructuredAnd(cond)) { + return html` +
+
+ ${isOr ? "OR IF" : "AND IF"} (structured comparison — read-only) + +
+
+ This condition uses a structured comparison (TEMP > N etc.). + Editing structured-OP records is not yet supported; it's + preserved on save. +
+
`; + } + const decoded = decodeAndCondition(cond); + return html` +
+
+ ${isOr ? "OR IF" : "AND IF"} + +
+ ${this._renderChainCondFamily(decoded, idx)} +
`; + } + + private _renderChainCondFamily( + decoded: DecodedCondition, idx: number, + ): TemplateResult { + const setFamily = (family: CondFamily) => { + 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; + 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; + } + const enc = encodeAndCondition(next); + this._patchChainCondition(idx, enc); + }; + const setDecoded = (next: DecodedCondition) => { + this._patchChainCondition(idx, encodeAndCondition(next)); + }; + return html` + + ${this._renderChainCondSubfields(decoded, setDecoded)} + `; + } + + private _renderChainCondSubfields( + decoded: DecodedCondition, setDecoded: (d: DecodedCondition) => void, + ): TemplateResult { + if (decoded.family === "zone") { + const zones = this._bucketWithPreserve( + this._objects?.zones ?? null, "zone", decoded.index ?? 0, + ); + return html` + + `; + } + if (decoded.family === "unit") { + const units = this._bucketWithPreserve( + this._objects?.units ?? null, "unit", decoded.index ?? 0, + ); + return html` + + `; + } + if (decoded.family === "sec") { + const areas = this._bucketWithPreserve( + this._objects?.areas ?? null, "area", decoded.index ?? 0, + ); + return html` + + `; + } + if (decoded.family === "time") { + return html` + + `; + } + // misc + return html` + `; + } + + private _renderChainActionsSection(actions: ProgramFields[]): TemplateResult { + return html` +
+ + Actions (${actions.length}) + + + ${actions.map((a, idx) => this._renderChainActionRow(a, idx, actions.length))} +
+ `; + } + + private _renderChainActionRow( + action: ProgramFields, idx: number, total: number, + ): TemplateResult { + const cmdOpt: CommandOption | undefined = commandOptionFor(action.cmd ?? 0); + const objectBucket = cmdOpt?.ref_kind + ? this._bucketWithPreserve( + this._pickBucket(cmdOpt.ref_kind), + cmdOpt.ref_kind, + action.pr2 ?? 0, + ) + : null; + const showsLevelPercent = action.cmd === 9; + return html` +
+
+ ${idx === 0 ? "THEN" : "AND"} + ${total > 1 ? html` + ` : ""} +
+ + ${cmdOpt?.ref_kind ? html` + ` : ""} + ${showsLevelPercent ? html` + ` : ""} +
+ `; + } + // -- styles ----------------------------------------------------------- static styles = css` @@ -1724,6 +2437,37 @@ export class OmniPanelPrograms extends LitElement { font-weight: 600; color: var(--primary-text-color, #000); } + .cond-row-header { + display: flex; justify-content: space-between; align-items: center; + margin-bottom: 4px; + } + .mini-btn { + border: 1px solid var(--divider-color, #ccc); + background: var(--card-background-color, #fff); + color: inherit; + padding: 2px 8px; + border-radius: 3px; + font-size: 0.78rem; + cursor: pointer; + font-family: inherit; + margin-left: 6px; + } + .mini-btn:hover { background: var(--secondary-background-color, #eee); } + .mini-btn.danger { + color: var(--error-color, #db4437); + border-color: var(--error-color, #db4437); + } + .structured-cond { + background: rgba(255, 152, 0, 0.08); /* subtle warning tint */ + } + .chain-meta { + margin-top: 8px; + padding: 8px 10px; + font-size: 0.82rem; + color: var(--secondary-text-color, #666); + background: var(--secondary-background-color, #f5f5f5); + border-radius: 4px; + } `; } diff --git a/custom_components/omni_pca/frontend/src/types.ts b/custom_components/omni_pca/frontend/src/types.ts index 2a76d76..c5851f6 100644 --- a/custom_components/omni_pca/frontend/src/types.ts +++ b/custom_components/omni_pca/frontend/src/types.ts @@ -50,6 +50,13 @@ export interface ProgramDetail { /** Raw Program field values; included for compact-form programs so * the editor can seed its form from real data rather than defaults. */ fields?: ProgramFields; + /** For chain detail: per-member role + raw fields. Drives the + * chain editor's row-per-slot rendering. */ + chain_members?: Array<{ + slot: number; + role: "head" | "condition" | "action"; + fields: ProgramFields; + }>; } export interface ProgramListRequest { @@ -409,6 +416,137 @@ export function encodeCondition(c: DecodedCondition): number { } } + +// -------------------------------------------------------------------------- +// Clausal chain (multi-record) editor types +// -------------------------------------------------------------------------- + + +/** ProgramType values for the chain head/body/tail records. */ +export const PROGRAM_TYPE_WHEN = 5; +export const PROGRAM_TYPE_AT = 6; +export const PROGRAM_TYPE_EVERY = 7; +export const PROGRAM_TYPE_AND = 8; +export const PROGRAM_TYPE_OR = 9; +export const PROGRAM_TYPE_THEN = 10; + +/** Roles assigned by the backend's chain_members payload. */ +export type ChainMemberRole = "head" | "condition" | "action"; + +export interface ChainMember { + slot: number; + role: ChainMemberRole; + fields: ProgramFields; +} + +/** Decoded view of a Traditional AND/OR record's condition. + * + * AND records use the SAME family encoding as compact-form cond, but + * the bytes land in different ProgramFields slots: + * + * family = fields.cond & 0xFF (disk byte 1) + * instance = (fields.cond2 >> 8) & 0xFF (disk byte 3) + * + * The selector bit (`0x0200`) doesn't apply to AND records the same + * way — instead the family byte's bit 1 (0x02) carries the + * secure/not-ready or off/on selector. For example: + * 0x04 = ZONE secure 0x06 = ZONE not-ready + * 0x08 = CTRL off 0x0A = CTRL on + * 0x0C = TIME disabled 0x0E = TIME enabled + */ +export function decodeAndCondition(fields: ProgramFields): DecodedCondition { + const family = (fields.cond ?? 0) & 0xFF; + const instance = ((fields.cond2 ?? 0) >> 8) & 0xFF; + const familyMajor = family & 0xFC; + const selector = (family & 0x02) !== 0; + if (family === 0 && instance === 0) return { family: "none" }; + if (familyMajor === 0x00) return { family: "misc", misc: family & 0x0F }; + if (familyMajor === 0x04) return { family: "zone", index: instance, active: selector }; + if (familyMajor === 0x08) return { family: "unit", index: instance, active: selector }; + if (familyMajor === 0x0C) return { family: "time", index: instance, active: selector }; + // SEC: high nibble of family = mode, low nibble = area. + return { + family: "sec", + index: family & 0x0F, + mode: (family >> 4) & 0x07, + }; +} + +/** Re-encode a DecodedCondition into the cond/cond2 fields of an + * AND/OR record. Returns a partial ProgramFields with cond + cond2 + * set; the caller should merge with the rest of the record (cmd/par/ + * etc. stay zero for Traditional AND records). + */ +export function encodeAndCondition(c: DecodedCondition): { + cond: number; cond2: number; +} { + switch (c.family) { + case "none": + return { cond: 0, cond2: 0 }; + case "misc": + return { cond: (c.misc ?? 0) & 0x0F, cond2: 0 }; + case "zone": { + const family = 0x04 | (c.active ? 0x02 : 0); + return { cond: family, cond2: ((c.index ?? 0) & 0xFF) << 8 }; + } + case "unit": { + const family = 0x08 | (c.active ? 0x02 : 0); + return { cond: family, cond2: ((c.index ?? 0) & 0xFF) << 8 }; + } + case "time": { + const family = 0x0C | (c.active ? 0x02 : 0); + return { cond: family, cond2: ((c.index ?? 0) & 0xFF) << 8 }; + } + case "sec": { + const area = (c.index ?? 1) & 0x0F; + const mode = (c.mode ?? 0) & 0x07; + const family = (mode << 4) | area; + return { cond: family, cond2: 0 }; + } + } +} + +/** True if the AND/OR record's op byte indicates a Structured-OP + * comparison (TEMP > 70 etc.) rather than the Traditional bit-packed + * condition. Structured records use entirely different field + * semantics; the editor in this pass renders them read-only. + * + * OP byte lives at fields.cond >> 8 (disk byte 2). 0 = Traditional; + * 1..9 = Structured (CondOP enum). + */ +export function isStructuredAnd(fields: ProgramFields): boolean { + return (((fields.cond ?? 0) >> 8) & 0xFF) !== 0; +} + +/** Build a fresh empty AND record (Traditional, NEVER condition). */ +export function emptyAndRecord(): ProgramFields { + return { + prog_type: PROGRAM_TYPE_AND, + cond: 0x01, // family OTHER (0x00) + misc NEVER (0x01) + cond2: 0, cmd: 0, par: 0, pr2: 0, + month: 0, day: 0, days: 0, hour: 0, minute: 0, + }; +} + +/** Build a fresh empty OR record. Same shape as AND with a different + * prog_type — semantically starts a new group in the conditions list. + */ +export function emptyOrRecord(): ProgramFields { + return { ...emptyAndRecord(), prog_type: PROGRAM_TYPE_OR }; +} + +/** Build a fresh empty THEN action record (Turn OFF unit 1). */ +export function emptyThenRecord(firstUnit: number = 1): ProgramFields { + return { + prog_type: PROGRAM_TYPE_THEN, + cmd: 0, // UNIT_OFF + par: 0, + pr2: firstUnit, + cond: 0, cond2: 0, + month: 0, day: 0, days: 0, hour: 0, minute: 0, + }; +} + /** HA's hass object — minimal surface we use. */ export interface Hass { connection: { diff --git a/custom_components/omni_pca/websocket.py b/custom_components/omni_pca/websocket.py index f628801..852d36b 100644 --- a/custom_components/omni_pca/websocket.py +++ b/custom_components/omni_pca/websocket.py @@ -368,6 +368,21 @@ async def _ws_get_program( "tokens": _tokens_to_json(tokens), "references": _extract_references(tokens), "chain_slots": [m.slot for m in members if m.slot is not None], + # Per-member raw fields + role so the editor can render + # an editable form for each line of the clausal chain. + # role is "head" / "condition" / "action". + "chain_members": [ + { + "slot": m.slot, + "role": ( + "head" if m is containing_chain.head + else "action" if m in containing_chain.actions + else "condition" + ), + "fields": _program_to_fields(m), + } + for m in members if m.slot is not None + ], }) return @@ -425,6 +440,159 @@ _PROGRAM_FIELD_SCHEMA = vol.Schema( ) +@websocket_api.websocket_command( + { + vol.Required("type"): "omni_pca/programs/chain/write", + vol.Required("entry_id"): str, + vol.Required("head_slot"): vol.All(int, vol.Range(min=1, max=1500)), + vol.Required("head"): dict, # WHEN / AT / EVERY program dict + vol.Required("conditions"): [dict], + vol.Required("actions"): [dict], + } +) +@websocket_api.async_response +async def _ws_chain_write( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Rewrite a clausal chain into consecutive slots. + + A clausal program spans one head (WHEN/AT/EVERY) + N condition + records (AND/OR) + M action records (THEN), each in its own slot. + Editing means rewriting the whole run. + + Logic: + 1. Find the *existing* chain that owns ``head_slot`` (so we know + which old slots to clear when the chain shrinks). + 2. The new run spans slots [head_slot .. head_slot + new_len - 1]. + If new_len > old_len, the additional slots must currently be + FREE — refuse otherwise so we never trample an adjacent + program. + 3. Write each new record via ``download_program``. The new run's + records are emitted in slot order; THEN actions land last. + 4. Clear any old chain slots beyond the new run's end (shrinking + case) so leftover continuation records don't get mis-associated + with the now-shorter chain. + """ + coordinator = _coordinator_for_entry(hass, msg["entry_id"]) + if coordinator is None: + connection.send_error(msg["id"], "not_found", "panel not configured") + return + try: + client = coordinator.client + except RuntimeError as err: + connection.send_error(msg["id"], "not_connected", str(err)) + return + + from omni_pca.programs import Program # local — avoid cycle + + # Validate every member dict against the per-record schema (used + # individually so each member can have its own defaults). + try: + head_fields = _PROGRAM_FIELD_SCHEMA(msg["head"]) + condition_fields = [_PROGRAM_FIELD_SCHEMA(c) for c in msg["conditions"]] + action_fields = [_PROGRAM_FIELD_SCHEMA(a) for a in msg["actions"]] + except vol.Invalid as err: + connection.send_error(msg["id"], "invalid", f"bad chain member: {err}") + return + + if not action_fields: + connection.send_error( + msg["id"], "invalid", "chain must have at least one THEN action", + ) + return + + head_slot = msg["head_slot"] + new_len = 1 + len(condition_fields) + len(action_fields) + + # Find the existing chain (if any) so we know which old slots are + # currently part of this program. Without an existing chain we still + # allow writing — that's the "create chain at this empty slot" case. + from omni_pca.program_engine import build_chains + + programs = coordinator.data.programs if coordinator.data else {} + existing = next( + (c for c in build_chains(tuple(programs.values())) + if c.head.slot == head_slot), + None, + ) + existing_slots: set[int] = set() + if existing is not None: + for m in (existing.head, *existing.conditions, *existing.actions): + if m.slot is not None: + existing_slots.add(m.slot) + + new_slot_range = range(head_slot, head_slot + new_len) + if new_slot_range.stop > 1501: + connection.send_error( + msg["id"], "invalid", + f"chain of {new_len} records starting at slot {head_slot} " + f"would extend past slot 1500", + ) + return + + # Anti-trample check for any expansion slots that aren't already + # part of this chain. + for s in new_slot_range: + if s in existing_slots: + continue + if s in programs and not programs[s].is_empty(): + connection.send_error( + msg["id"], "invalid", + f"target slot {s} is occupied by another program " + f"(slot {s}); free it first", + ) + return + + # Build the typed records. + head = Program(slot=head_slot, **head_fields) + new_records: list[tuple[int, Program]] = [(head_slot, head)] + for i, cf in enumerate(condition_fields): + slot = head_slot + 1 + i + new_records.append((slot, Program(slot=slot, **cf))) + actions_base = head_slot + 1 + len(condition_fields) + for i, af in enumerate(action_fields): + slot = actions_base + i + new_records.append((slot, Program(slot=slot, **af))) + + # Write them in order. + try: + for slot, prog in new_records: + await client.download_program(slot, prog) + except NotImplementedError as err: + connection.send_error(msg["id"], "not_supported", str(err)) + return + except Exception as err: + connection.send_error(msg["id"], "write_failed", str(err)) + return + + # Clear any old chain slot that's not in the new range (shrinking + # case). Order matters: clears come *after* writes so a transient + # observer never sees a half-rewritten chain. + to_clear = existing_slots - set(new_slot_range) + for slot in sorted(to_clear): + try: + await client.clear_program(slot) + except Exception: + # Don't fail the whole write for a clear-failure; log and continue. + _log.warning("failed to clear shrunk-away slot %s", slot) + + # Update coordinator state. Same shape as single-slot write: drop + # cleared slots, set written slots. + if coordinator.data is not None: + for slot, prog in new_records: + coordinator.data.programs[slot] = prog + for slot in to_clear: + coordinator.data.programs.pop(slot, None) + + connection.send_result(msg["id"], { + "head_slot": head_slot, + "written_slots": list(new_slot_range), + "cleared_slots": sorted(to_clear), + }) + + @websocket_api.websocket_command( { vol.Required("type"): "omni_pca/objects/list", @@ -705,6 +873,7 @@ def async_register_websocket_commands(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, _ws_clear_program) websocket_api.async_register_command(hass, _ws_clone_program) websocket_api.async_register_command(hass, _ws_write_program) + websocket_api.async_register_command(hass, _ws_chain_write) websocket_api.async_register_command(hass, _ws_list_objects) diff --git a/custom_components/omni_pca/www/panel.js b/custom_components/omni_pca/www/panel.js index c93d846..34c1aee 100644 --- a/custom_components/omni_pca/www/panel.js +++ b/custom_components/omni_pca/www/panel.js @@ -1,15 +1,15 @@ // omni_pca side panel — generated by frontend/build.mjs. Edit src/, not this file. -var Oe=Object.defineProperty;var He=Object.getOwnPropertyDescriptor;var u=(n,t,e,r)=>{for(var i=r>1?void 0:r?He(t,e):t,s=n.length-1,o;s>=0;s--)(o=n[s])&&(i=(r?o(t,e,i):o(i))||i);return r&&i&&Oe(t,e,i),i};var j=globalThis,U=j.ShadowRoot&&(j.ShadyCSS===void 0||j.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,W=Symbol(),he=new WeakMap,R=class{constructor(t,e,r){if(this._$cssResult$=!0,r!==W)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o,e=this.t;if(U&&t===void 0){let r=e!==void 0&&e.length===1;r&&(t=he.get(e)),t===void 0&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),r&&he.set(e,t))}return t}toString(){return this.cssText}},me=n=>new R(typeof n=="string"?n:n+"",void 0,W),Z=(n,...t)=>{let e=n.length===1?n[0]:t.reduce((r,i,s)=>r+(o=>{if(o._$cssResult$===!0)return o.cssText;if(typeof o=="number")return o;throw Error("Value passed to 'css' function must be a 'css' function result: "+o+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+n[s+1],n[0]);return new R(e,n,W)},fe=(n,t)=>{if(U)n.adoptedStyleSheets=t.map(e=>e instanceof CSSStyleSheet?e:e.styleSheet);else for(let e of t){let r=document.createElement("style"),i=j.litNonce;i!==void 0&&r.setAttribute("nonce",i),r.textContent=e.cssText,n.appendChild(r)}},G=U?n=>n:n=>n instanceof CSSStyleSheet?(t=>{let e="";for(let r of t.cssRules)e+=r.cssText;return me(e)})(n):n;var{is:je,defineProperty:Ue,getOwnPropertyDescriptor:Be,getOwnPropertyNames:Ye,getOwnPropertySymbols:qe,getPrototypeOf:Ve}=Object,B=globalThis,ge=B.trustedTypes,We=ge?ge.emptyScript:"",Ze=B.reactiveElementPolyfillSupport,M=(n,t)=>n,D={toAttribute(n,t){switch(t){case Boolean:n=n?We:null;break;case Object:case Array:n=n==null?n:JSON.stringify(n)}return n},fromAttribute(n,t){let e=n;switch(t){case Boolean:e=n!==null;break;case Number:e=n===null?null:Number(n);break;case Object:case Array:try{e=JSON.parse(n)}catch{e=null}}return e}},Y=(n,t)=>!je(n,t),_e={attribute:!0,type:String,converter:D,reflect:!1,useDefault:!1,hasChanged:Y};Symbol.metadata??=Symbol("metadata"),B.litPropertyMetadata??=new WeakMap;var b=class extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=_e){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){let r=Symbol(),i=this.getPropertyDescriptor(t,r,e);i!==void 0&&Ue(this.prototype,t,i)}}static getPropertyDescriptor(t,e,r){let{get:i,set:s}=Be(this.prototype,t)??{get(){return this[e]},set(o){this[e]=o}};return{get:i,set(o){let c=i?.call(this);s?.call(this,o),this.requestUpdate(t,c,r)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??_e}static _$Ei(){if(this.hasOwnProperty(M("elementProperties")))return;let t=Ve(this);t.finalize(),t.l!==void 0&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(M("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(M("properties"))){let e=this.properties,r=[...Ye(e),...qe(e)];for(let i of r)this.createProperty(i,e[i])}let t=this[Symbol.metadata];if(t!==null){let e=litPropertyMetadata.get(t);if(e!==void 0)for(let[r,i]of e)this.elementProperties.set(r,i)}this._$Eh=new Map;for(let[e,r]of this.elementProperties){let i=this._$Eu(e,r);i!==void 0&&this._$Eh.set(i,e)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){let e=[];if(Array.isArray(t)){let r=new Set(t.flat(1/0).reverse());for(let i of r)e.unshift(G(i))}else t!==void 0&&e.push(G(t));return e}static _$Eu(t,e){let r=e.attribute;return r===!1?void 0:typeof r=="string"?r:typeof t=="string"?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),this.renderRoot!==void 0&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){let t=new Map,e=this.constructor.elementProperties;for(let r of e.keys())this.hasOwnProperty(r)&&(t.set(r,this[r]),delete this[r]);t.size>0&&(this._$Ep=t)}createRenderRoot(){let t=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return fe(t,this.constructor.elementStyles),t}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,r){this._$AK(t,r)}_$ET(t,e){let r=this.constructor.elementProperties.get(t),i=this.constructor._$Eu(t,r);if(i!==void 0&&r.reflect===!0){let s=(r.converter?.toAttribute!==void 0?r.converter:D).toAttribute(e,r.type);this._$Em=t,s==null?this.removeAttribute(i):this.setAttribute(i,s),this._$Em=null}}_$AK(t,e){let r=this.constructor,i=r._$Eh.get(t);if(i!==void 0&&this._$Em!==i){let s=r.getPropertyOptions(i),o=typeof s.converter=="function"?{fromAttribute:s.converter}:s.converter?.fromAttribute!==void 0?s.converter:D;this._$Em=i;let c=o.fromAttribute(e,s.type);this[i]=c??this._$Ej?.get(i)??c,this._$Em=null}}requestUpdate(t,e,r,i=!1,s){if(t!==void 0){let o=this.constructor;if(i===!1&&(s=this[t]),r??=o.getPropertyOptions(t),!((r.hasChanged??Y)(s,e)||r.useDefault&&r.reflect&&s===this._$Ej?.get(t)&&!this.hasAttribute(o._$Eu(t,r))))return;this.C(t,e,r)}this.isUpdatePending===!1&&(this._$ES=this._$EP())}C(t,e,{useDefault:r,reflect:i,wrapped:s},o){r&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,o??e??this[t]),s!==!0||o!==void 0)||(this._$AL.has(t)||(this.hasUpdated||r||(e=void 0),this._$AL.set(t,e)),i===!0&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(e){Promise.reject(e)}let t=this.scheduleUpdate();return t!=null&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(let[i,s]of this._$Ep)this[i]=s;this._$Ep=void 0}let r=this.constructor.elementProperties;if(r.size>0)for(let[i,s]of r){let{wrapped:o}=s,c=this[i];o!==!0||this._$AL.has(i)||c===void 0||this.C(i,void 0,s,c)}}let t=!1,e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(r=>r.hostUpdate?.()),this.update(e)):this._$EM()}catch(r){throw t=!1,this._$EM(),r}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(e=>e.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(e=>this._$ET(e,this[e])),this._$EM()}updated(t){}firstUpdated(t){}};b.elementStyles=[],b.shadowRootOptions={mode:"open"},b[M("elementProperties")]=new Map,b[M("finalized")]=new Map,Ze?.({ReactiveElement:b}),(B.reactiveElementVersions??=[]).push("2.1.2");var re=globalThis,ve=n=>n,q=re.trustedTypes,be=q?q.createPolicy("lit-html",{createHTML:n=>n}):void 0,Se="$lit$",$=`lit$${Math.random().toFixed(9).slice(2)}$`,we="?"+$,Ge=`<${we}>`,S=document,P=()=>S.createComment(""),z=n=>n===null||typeof n!="object"&&typeof n!="function",ie=Array.isArray,Ke=n=>ie(n)||typeof n?.[Symbol.iterator]=="function",K=`[ -\f\r]`,I=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,ye=/-->/g,$e=/>/g,E=RegExp(`>|${K}(?:([^\\s"'>=/]+)(${K}*=${K}*(?:[^ -\f\r"'\`<>=]|("|')|))|$)`,"g"),xe=/'/g,Ee=/"/g,Te=/^(?:script|style|textarea|title)$/i,ne=n=>(t,...e)=>({_$litType$:n,strings:t,values:e}),a=ne(1),dt=ne(2),pt=ne(3),w=Symbol.for("lit-noChange"),g=Symbol.for("lit-nothing"),ke=new WeakMap,k=S.createTreeWalker(S,129);function Ae(n,t){if(!ie(n)||!n.hasOwnProperty("raw"))throw Error("invalid template strings array");return be!==void 0?be.createHTML(t):t}var Je=(n,t)=>{let e=n.length-1,r=[],i,s=t===2?"":t===3?"":"",o=I;for(let c=0;c"?(o=i??I,d=-1):f[1]===void 0?d=-2:(d=o.lastIndex-f[2].length,h=f[1],o=f[3]===void 0?E:f[3]==='"'?Ee:xe):o===Ee||o===xe?o=E:o===ye||o===$e?o=I:(o=E,i=void 0);let y=o===E&&n[c+1].startsWith("/>")?" ":"";s+=o===I?l+Ge:d>=0?(r.push(h),l.slice(0,d)+Se+l.slice(d)+$+y):l+$+(d===-2?c:y)}return[Ae(n,s+(n[e]||"")+(t===2?"":t===3?"":"")),r]},N=class n{constructor({strings:t,_$litType$:e},r){let i;this.parts=[];let s=0,o=0,c=t.length-1,l=this.parts,[h,f]=Je(t,e);if(this.el=n.createElement(h,r),k.currentNode=this.el.content,e===2||e===3){let d=this.el.content.firstChild;d.replaceWith(...d.childNodes)}for(;(i=k.nextNode())!==null&&l.length0){i.textContent=q?q.emptyScript:"";for(let y=0;y2||r[0]!==""||r[1]!==""?(this._$AH=Array(r.length-1).fill(new String),this.strings=r):this._$AH=g}_$AI(t,e=this,r,i){let s=this.strings,o=!1;if(s===void 0)t=T(this,t,e,0),o=!z(t)||t!==this._$AH&&t!==w,o&&(this._$AH=t);else{let c=t,l,h;for(t=s[0],l=0;l{let r=e?.renderBefore??t,i=r._$litPart$;if(i===void 0){let s=e?.renderBefore??null;r._$litPart$=i=new L(t.insertBefore(P(),s),s,void 0,e??{})}return i._$AI(n),i};var se=globalThis,x=class extends b{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){let t=super.createRenderRoot();return this.renderOptions.renderBefore??=t.firstChild,t}update(t){let e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this._$Do=Ce(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return w}};x._$litElement$=!0,x.finalized=!0,se.litElementHydrateSupport?.({LitElement:x});var Qe=se.litElementPolyfillSupport;Qe?.({LitElement:x});(se.litElementVersions??=[]).push("4.2.2");var Fe=n=>(t,e)=>{e!==void 0?e.addInitializer(()=>{customElements.define(n,t)}):customElements.define(n,t)};var et={attribute:!0,type:String,converter:D,reflect:!1,hasChanged:Y},tt=(n=et,t,e)=>{let{kind:r,metadata:i}=e,s=globalThis.litPropertyMetadata.get(i);if(s===void 0&&globalThis.litPropertyMetadata.set(i,s=new Map),r==="setter"&&((n=Object.create(n)).wrapped=!0),s.set(e.name,n),r==="accessor"){let{name:o}=e;return{set(c){let l=t.get.call(this);t.set.call(this,c),this.requestUpdate(o,l,n,!0,c)},init(c){return c!==void 0&&this.C(o,void 0,n,c),c}}}if(r==="setter"){let{name:o}=e;return function(c){let l=this[o];t.call(this,c),this.requestUpdate(o,l,n,!0,c)}}throw Error("Unsupported decorator location: "+r)};function O(n){return(t,e)=>typeof e=="object"?tt(n,t,e):((r,i,s)=>{let o=i.hasOwnProperty(s);return i.constructor.createProperty(s,r),o?Object.getOwnPropertyDescriptor(i,s):void 0})(n,t,e)}function m(n){return O({...n,state:!0,attribute:!1})}function oe(n,t){return a`${n.map(e=>rt(e,t))}`}function rt(n,t){switch(n.k){case"newline":return a`
`;case"indent":return a`${n.t}`;case"keyword":return a`${n.t}`;case"operator":return a`${n.t}`;case"value":return a`${n.t}`;case"ref":{let e=t&&n.ek&&typeof n.ei=="number"?()=>t(n.ek,n.ei):void 0;return a``}default:return a`${n.t}`}}var ae=[{value:0,label:"Turn OFF unit",ref_kind:"unit"},{value:1,label:"Turn ON unit",ref_kind:"unit"},{value:2,label:"All OFF",ref_kind:null},{value:3,label:"All ON",ref_kind:null},{value:4,label:"Bypass zone",ref_kind:"zone"},{value:5,label:"Restore zone",ref_kind:"zone"},{value:7,label:"Execute button",ref_kind:"button"},{value:9,label:"Set unit level %",ref_kind:"unit"},{value:48,label:"Disarm area",ref_kind:"area"},{value:49,label:"Arm area Day",ref_kind:"area"},{value:50,label:"Arm area Night",ref_kind:"area"},{value:51,label:"Arm area Away",ref_kind:"area"},{value:52,label:"Arm area Vacation",ref_kind:"area"}];function le(n){return ae.find(t=>t.value===n)}var Re=[{bit:2,label:"Mon"},{bit:4,label:"Tue"},{bit:8,label:"Wed"},{bit:16,label:"Thu"},{bit:32,label:"Fri"},{bit:64,label:"Sat"},{bit:128,label:"Sun"}],ce=1,de=2,pe=3;var ue=[{id:768,label:"Phone line dead"},{id:769,label:"Phone ringing"},{id:770,label:"Phone off hook"},{id:771,label:"Phone on hook"},{id:772,label:"AC power lost"},{id:773,label:"AC power restored"}];function C(n){if(ue.some(t=>t.id===n))return{category:"fixed",fixedId:n};if(!(n&65280))return{category:"button",button:n&255};if((n&64512)===1024){let t=n&1023;return{category:"zone",zone:Math.floor(t/4)+1,zoneState:t%4}}if((n&64512)===2048){let t=n&1023;return{category:"unit",unit:Math.floor(t/2)+1,unitOn:(t&1)===1}}return{category:"raw",raw:n}}function Me(n){switch(n.category){case"button":return(n.button??1)&255;case"zone":{let t=(n.zone??1)-1,e=(n.zoneState??0)&3;return 1024|t*4+e&1023}case"unit":{let t=(n.unit??1)-1,e=n.unitOn?1:0;return 2048|t*2+e&1023}case"fixed":return n.fixedId??768;case"raw":default:return n.raw??0}}function F(n){return(n.month??0)<<8|(n.day??0)}function De(n,t){return{...n,month:t>>8&255,day:t&255}}var Ie=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],Pe=[{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"}],ze=[{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"}];function Ne(n){if(n===0)return{family:"none"};let t=n>>8&252,e=(n&512)!==0;return t===0?{family:"misc",misc:n&15}:t===4?{family:"zone",index:n&255,active:e}:t===8?{family:"unit",index:n&511,active:e}:t===12?{family:"time",index:n&255,active:e}:{family:"sec",index:n>>8&15,mode:n>>12&7}}function _(n){switch(n.family){case"none":return 0;case"misc":return(n.misc??0)&15;case"zone":{let t=(n.index??0)&255;return 1024|(n.active?512:0)|t}case"unit":{let t=(n.index??0)&511;return 2048|(n.active?512:0)|t}case"time":{let t=(n.index??0)&255;return 3072|(n.active?512:0)|t}case"sec":{let t=(n.index??1)&15;return((n.mode??0)&7)<<12|t<<8}}}var Le=new Set(["TIMED","EVENT","YEARLY"]),it=["TIMED","EVENT","YEARLY","WHEN","AT","EVERY","REMARK"],nt=5e3,p=class extends x{constructor(){super(...arguments);this.narrow=!1;this._entryId=null;this._rows=[];this._total=0;this._filteredTotal=0;this._loading=!1;this._error=null;this._activeTriggerTypes=new Set;this._referenceFilter=null;this._searchTerm="";this._selectedSlot=null;this._detail=null;this._detailLoading=!1;this._fireFeedback=null;this._writeFeedback=null;this._cloneTargetSlot="";this._showCloneInput=!1;this._confirmingClear=!1;this._editingDraft=null;this._objects=null;this._refreshTimer=null}connectedCallback(){super.connectedCallback(),this._discoverEntry(),this._entryId&&(this._loadList(),this._startRefreshTimer())}disconnectedCallback(){super.disconnectedCallback(),this._stopRefreshTimer()}updated(e){e.has("hass")&&this._entryId===null&&(this._discoverEntry(),this._entryId&&(this._loadList(),this._startRefreshTimer()))}_discoverEntry(){this.hass?.connection&&this._discoverViaList()}async _discoverViaList(){try{let r=(await this.hass.connection.sendMessagePromise({type:"config_entries/get"})).filter(s=>s.domain==="omni_pca");if(r.length===0){this._error="No Omni panel configured. Add one via Settings \u2192 Devices & Services.";return}let i=r.find(s=>s.state==="loaded");this._entryId=(i??r[0]).entry_id,this._error=null,this._loadList(),this._startRefreshTimer()}catch(e){this._error=`Could not discover panels: ${e instanceof Error?e.message:String(e)}`}}async _loadList(){if(this._entryId){this._loading=!0,this._error=null;try{let e={type:"omni_pca/programs/list",entry_id:this._entryId};this._activeTriggerTypes.size>0&&(e.trigger_types=[...this._activeTriggerTypes]),this._referenceFilter&&(e.references_entity=this._referenceFilter),this._searchTerm&&(e.search=this._searchTerm);let r=await this.hass.connection.sendMessagePromise(e);this._rows=r.programs,this._total=r.total,this._filteredTotal=r.filtered_total}catch(e){this._error=e instanceof Error?e.message:String(e)}finally{this._loading=!1}}}async _loadDetail(e){if(this._entryId){this._detailLoading=!0,this._detail=null;try{this._detail=await this.hass.connection.sendMessagePromise({type:"omni_pca/programs/get",entry_id:this._entryId,slot:e})}catch(r){this._error=r instanceof Error?r.message:String(r)}finally{this._detailLoading=!1}}}async _fireProgram(e){if(this._entryId){this._fireFeedback="firing\u2026";try{await this.hass.connection.sendMessagePromise({type:"omni_pca/programs/fire",entry_id:this._entryId,slot:e}),this._fireFeedback=`fired slot ${e}`}catch(r){this._fireFeedback=`error: ${r instanceof Error?r.message:r}`}setTimeout(()=>{this._fireFeedback=null},4e3)}}async _clearProgram(e){if(this._entryId){this._writeFeedback="clearing\u2026";try{await this.hass.connection.sendMessagePromise({type:"omni_pca/programs/clear",entry_id:this._entryId,slot:e}),this._writeFeedback=`cleared slot ${e}`,this._confirmingClear=!1,this._selectedSlot=null,this._detail=null,await this._loadList()}catch(r){let i=r instanceof Error?r.message:String(r);this._writeFeedback=`error: ${i}`}setTimeout(()=>{this._writeFeedback=null},4e3)}}async _cloneProgram(e){if(!this._entryId)return;let r=this._cloneTargetSlot.trim(),i=parseInt(r,10);if(!Number.isFinite(i)||i<1||i>1500){this._writeFeedback="target slot must be 1..1500",setTimeout(()=>{this._writeFeedback=null},4e3);return}if(i===e){this._writeFeedback="target must differ from source",setTimeout(()=>{this._writeFeedback=null},4e3);return}this._writeFeedback="cloning\u2026";try{await this.hass.connection.sendMessagePromise({type:"omni_pca/programs/clone",entry_id:this._entryId,source_slot:e,target_slot:i}),this._writeFeedback=`cloned to slot ${i}`,this._showCloneInput=!1,this._cloneTargetSlot="",this._selectedSlot=i,await this._loadList(),await this._loadDetail(i)}catch(s){let o=s instanceof Error?s.message:String(s);this._writeFeedback=`error: ${o}`}setTimeout(()=>{this._writeFeedback=null},4e3)}_onCloneTargetInput(e){this._cloneTargetSlot=e.target.value}async _ensureObjectsLoaded(){if(!(this._objects!==null||!this._entryId))try{this._objects=await this.hass.connection.sendMessagePromise({type:"omni_pca/objects/list",entry_id:this._entryId})}catch(e){let r=e instanceof Error?e.message:String(e);console.warn("omni_pca: objects/list failed",r)}}async _beginEdit(){if(!this._detail||this._detail.kind!=="compact"||!Le.has(this._detail.trigger_type)||(await this._ensureObjectsLoaded(),!this._entryId))return;let e=this._detail.fields??this._defaultFieldsForType(this._detail.trigger_type);e!==null&&(this._editingDraft={...e},this._stopRefreshTimer())}_defaultFieldsForType(e){let r=this._objects?.units?.[0]?.index??1;if(e==="TIMED")return{prog_type:ce,cmd:1,par:0,pr2:r,hour:6,minute:0,days:62,cond:0,cond2:0,month:0,day:0};if(e==="EVENT"){let i=this._objects?.buttons?.[0]?.index??1;return{prog_type:de,cmd:1,par:0,pr2:r,month:0,day:i&255,hour:0,minute:0,days:0,cond:0,cond2:0}}return e==="YEARLY"?{prog_type:pe,cmd:1,par:0,pr2:r,month:1,day:1,hour:0,minute:0,days:0,cond:0,cond2:0}:null}async _saveDraft(){if(!(!this._editingDraft||!this._detail||!this._entryId)){this._writeFeedback="saving\u2026";try{await this.hass.connection.sendMessagePromise({type:"omni_pca/programs/write",entry_id:this._entryId,slot:this._detail.slot,program:this._editingDraft}),this._writeFeedback=`saved slot ${this._detail.slot}`,this._editingDraft=null,this._startRefreshTimer(),await this._loadList(),await this._loadDetail(this._detail.slot)}catch(e){let r=e instanceof Error?e.message:String(e);this._writeFeedback=`error: ${r}`}setTimeout(()=>{this._writeFeedback=null},4e3)}}_cancelEdit(){this._editingDraft=null,this._startRefreshTimer()}_patchDraft(e){this._editingDraft&&(this._editingDraft={...this._editingDraft,...e})}_toggleDayBit(e){if(!this._editingDraft)return;let i=(this._editingDraft.days??0)^e;this._patchDraft({days:i})}_onCommandChange(e){let r=parseInt(e.target.value,10);if(!Number.isFinite(r))return;let i=le(r),s=this._editingDraft?.pr2??0;if(i?.ref_kind&&this._objects){let o=this._pickBucket(i.ref_kind);o&&o.length>0&&!o.some(c=>c.index===s)&&(s=o[0].index)}else i?.ref_kind||(s=0);this._patchDraft({cmd:r,pr2:s})}_pickBucket(e){if(!this._objects)return null;switch(e){case"zone":return this._objects.zones;case"unit":return this._objects.units;case"area":return this._objects.areas;case"button":return this._objects.buttons;default:return null}}_bucketWithPreserve(e,r,i){let s=e??[];return i===0||s.some(o=>o.index===i)?s:[{index:i,name:`(undiscovered ${r} ${i} \u2014 preserve original)`},...s]}_onObjectChange(e){let r=parseInt(e.target.value,10);Number.isFinite(r)&&this._patchDraft({pr2:r})}_onHourChange(e){let r=parseInt(e.target.value,10);Number.isFinite(r)&&r>=0&&r<=23&&this._patchDraft({hour:r})}_onMinuteChange(e){let r=parseInt(e.target.value,10);Number.isFinite(r)&&r>=0&&r<=59&&this._patchDraft({minute:r})}_onParChange(e){let r=parseInt(e.target.value,10);Number.isFinite(r)&&r>=0&&r<=255&&this._patchDraft({par:r})}_onMonthChange(e){let r=parseInt(e.target.value,10);Number.isFinite(r)&&r>=1&&r<=12&&this._patchDraft({month:r})}_onDayChange(e){let r=parseInt(e.target.value,10);Number.isFinite(r)&&r>=1&&r<=31&&this._patchDraft({day:r})}_patchEvent(e){if(!this._editingDraft)return;let r=Me(e);this._editingDraft=De(this._editingDraft,r)}_onEventCategoryChange(e){let r=e.target.value;if(r==="button"){let i=this._objects?.buttons?.[0]?.index??1;this._patchEvent({category:"button",button:i})}else if(r==="zone"){let i=this._objects?.zones?.[0]?.index??1;this._patchEvent({category:"zone",zone:i,zoneState:1})}else if(r==="unit"){let i=this._objects?.units?.[0]?.index??1;this._patchEvent({category:"unit",unit:i,unitOn:!0})}else r==="fixed"&&this._patchEvent({category:"fixed",fixedId:772})}_onEventButtonChange(e){let r=parseInt(e.target.value,10);Number.isFinite(r)&&this._patchEvent({category:"button",button:r})}_onEventZoneChange(e){if(!this._editingDraft)return;let r=parseInt(e.target.value,10);if(!Number.isFinite(r))return;let i=C(F(this._editingDraft));this._patchEvent({category:"zone",zone:r,zoneState:i.zoneState??1})}_onEventZoneStateChange(e){if(!this._editingDraft)return;let r=parseInt(e.target.value,10);if(!Number.isFinite(r))return;let i=C(F(this._editingDraft));this._patchEvent({category:"zone",zone:i.zone??1,zoneState:r})}_onEventUnitChange(e){if(!this._editingDraft)return;let r=parseInt(e.target.value,10);if(!Number.isFinite(r))return;let i=C(F(this._editingDraft));this._patchEvent({category:"unit",unit:r,unitOn:i.unitOn??!0})}_onEventUnitOnChange(e){if(!this._editingDraft)return;let r=e.target.value==="1",i=C(F(this._editingDraft));this._patchEvent({category:"unit",unit:i.unit??1,unitOn:r})}_onEventFixedChange(e){let r=parseInt(e.target.value,10);Number.isFinite(r)&&this._patchEvent({category:"fixed",fixedId:r})}_startRefreshTimer(){this._refreshTimer===null&&(this._refreshTimer=window.setInterval(()=>{this._loadList(),this._selectedSlot!==null&&this._loadDetail(this._selectedSlot)},nt))}_stopRefreshTimer(){this._refreshTimer!==null&&(window.clearInterval(this._refreshTimer),this._refreshTimer=null)}_toggleTriggerFilter(e){let r=new Set(this._activeTriggerTypes);r.has(e)?r.delete(e):r.add(e),this._activeTriggerTypes=r,this._loadList()}_onSearchInput(e){this._searchTerm=e.target.value,this._loadList()}_clearReferenceFilter(){this._referenceFilter=null,this._loadList()}_onRowClick(e){this._selectedSlot=e,this._loadDetail(e)}_onRefClick(e,r){this._referenceFilter=`${e}:${r}`,this._selectedSlot=null,this._detail=null,this._loadList()}_closeDetail(){this._selectedSlot=null,this._detail=null}render(){return a` + ${s.t} + ${s.s?a`${s.s}`:""} + `}default:return a`${s.t}`}}var G=[{value:0,label:"Turn OFF unit",ref_kind:"unit"},{value:1,label:"Turn ON unit",ref_kind:"unit"},{value:2,label:"All OFF",ref_kind:null},{value:3,label:"All ON",ref_kind:null},{value:4,label:"Bypass zone",ref_kind:"zone"},{value:5,label:"Restore zone",ref_kind:"zone"},{value:7,label:"Execute button",ref_kind:"button"},{value:9,label:"Set unit level %",ref_kind:"unit"},{value:48,label:"Disarm area",ref_kind:"area"},{value:49,label:"Arm area Day",ref_kind:"area"},{value:50,label:"Arm area Night",ref_kind:"area"},{value:51,label:"Arm area Away",ref_kind:"area"},{value:52,label:"Arm area Vacation",ref_kind:"area"}];function H(s){return G.find(i=>i.value===s)}var de=[{bit:2,label:"Mon"},{bit:4,label:"Tue"},{bit:8,label:"Wed"},{bit:16,label:"Thu"},{bit:32,label:"Fri"},{bit:64,label:"Sat"},{bit:128,label:"Sun"}],ue=1,pe=2,he=3;var Z=[{id:768,label:"Phone line dead"},{id:769,label:"Phone ringing"},{id:770,label:"Phone off hook"},{id:771,label:"Phone on hook"},{id:772,label:"AC power lost"},{id:773,label:"AC power restored"}];function T(s){if(Z.some(i=>i.id===s))return{category:"fixed",fixedId:s};if(!(s&65280))return{category:"button",button:s&255};if((s&64512)===1024){let i=s&1023;return{category:"zone",zone:Math.floor(i/4)+1,zoneState:i%4}}if((s&64512)===2048){let i=s&1023;return{category:"unit",unit:Math.floor(i/2)+1,unitOn:(i&1)===1}}return{category:"raw",raw:s}}function me(s){switch(s.category){case"button":return(s.button??1)&255;case"zone":{let i=(s.zone??1)-1,e=(s.zoneState??0)&3;return 1024|i*4+e&1023}case"unit":{let i=(s.unit??1)-1,e=s.unitOn?1:0;return 2048|i*2+e&1023}case"fixed":return s.fixedId??768;case"raw":default:return s.raw??0}}function F(s){return(s.month??0)<<8|(s.day??0)}function Le(s,i){return{...s,month:i>>8&255,day:i&255}}var Ne=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],fe=[{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"}],ge=[{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"}];function He(s){if(s===0)return{family:"none"};let i=s>>8&252,e=(s&512)!==0;return i===0?{family:"misc",misc:s&15}:i===4?{family:"zone",index:s&255,active:e}:i===8?{family:"unit",index:s&511,active:e}:i===12?{family:"time",index:s&255,active:e}:{family:"sec",index:s>>8&15,mode:s>>12&7}}function v(s){switch(s.family){case"none":return 0;case"misc":return(s.misc??0)&15;case"zone":{let i=(s.index??0)&255;return 1024|(s.active?512:0)|i}case"unit":{let i=(s.index??0)&511;return 2048|(s.active?512:0)|i}case"time":{let i=(s.index??0)&255;return 3072|(s.active?512:0)|i}case"sec":{let i=(s.index??1)&15;return((s.mode??0)&7)<<12|i<<8}}}var je=5,Ue=6,Ye=7,ht=8,be=9,mt=10;function Be(s){let i=(s.cond??0)&255,e=(s.cond2??0)>>8&255,t=i&252,n=(i&2)!==0;return i===0&&e===0?{family:"none"}:t===0?{family:"misc",misc:i&15}:t===4?{family:"zone",index:e,active:n}:t===8?{family:"unit",index:e,active:n}:t===12?{family:"time",index:e,active:n}:{family:"sec",index:i&15,mode:i>>4&7}}function ve(s){switch(s.family){case"none":return{cond:0,cond2:0};case"misc":return{cond:(s.misc??0)&15,cond2:0};case"zone":return{cond:4|(s.active?2:0),cond2:((s.index??0)&255)<<8};case"unit":return{cond:8|(s.active?2:0),cond2:((s.index??0)&255)<<8};case"time":return{cond:12|(s.active?2:0),cond2:((s.index??0)&255)<<8};case"sec":{let i=(s.index??1)&15;return{cond:((s.mode??0)&7)<<4|i,cond2:0}}}}function We(s){return((s.cond??0)>>8&255)!==0}function _e(){return{prog_type:ht,cond:1,cond2:0,cmd:0,par:0,pr2:0,month:0,day:0,days:0,hour:0,minute:0}}function Ve(){return{..._e(),prog_type:be}}function qe(s=1){return{prog_type:mt,cmd:0,par:0,pr2:s,cond:0,cond2:0,month:0,day:0,days:0,hour:0,minute:0}}var Ge=new Set(["TIMED","EVENT","YEARLY"]),ft=["TIMED","EVENT","YEARLY","WHEN","AT","EVERY","REMARK"],gt=5e3,h=class extends x{constructor(){super(...arguments);this.narrow=!1;this._entryId=null;this._rows=[];this._total=0;this._filteredTotal=0;this._loading=!1;this._error=null;this._activeTriggerTypes=new Set;this._referenceFilter=null;this._searchTerm="";this._selectedSlot=null;this._detail=null;this._detailLoading=!1;this._fireFeedback=null;this._writeFeedback=null;this._cloneTargetSlot="";this._showCloneInput=!1;this._confirmingClear=!1;this._editingDraft=null;this._objects=null;this._chainDraft=null;this._refreshTimer=null}connectedCallback(){super.connectedCallback(),this._discoverEntry(),this._entryId&&(this._loadList(),this._startRefreshTimer())}disconnectedCallback(){super.disconnectedCallback(),this._stopRefreshTimer()}updated(e){e.has("hass")&&this._entryId===null&&(this._discoverEntry(),this._entryId&&(this._loadList(),this._startRefreshTimer()))}_discoverEntry(){this.hass?.connection&&this._discoverViaList()}async _discoverViaList(){try{let t=(await this.hass.connection.sendMessagePromise({type:"config_entries/get"})).filter(r=>r.domain==="omni_pca");if(t.length===0){this._error="No Omni panel configured. Add one via Settings \u2192 Devices & Services.";return}let n=t.find(r=>r.state==="loaded");this._entryId=(n??t[0]).entry_id,this._error=null,this._loadList(),this._startRefreshTimer()}catch(e){this._error=`Could not discover panels: ${e instanceof Error?e.message:String(e)}`}}async _loadList(){if(this._entryId){this._loading=!0,this._error=null;try{let e={type:"omni_pca/programs/list",entry_id:this._entryId};this._activeTriggerTypes.size>0&&(e.trigger_types=[...this._activeTriggerTypes]),this._referenceFilter&&(e.references_entity=this._referenceFilter),this._searchTerm&&(e.search=this._searchTerm);let t=await this.hass.connection.sendMessagePromise(e);this._rows=t.programs,this._total=t.total,this._filteredTotal=t.filtered_total}catch(e){this._error=e instanceof Error?e.message:String(e)}finally{this._loading=!1}}}async _loadDetail(e){if(this._entryId){this._detailLoading=!0,this._detail=null;try{this._detail=await this.hass.connection.sendMessagePromise({type:"omni_pca/programs/get",entry_id:this._entryId,slot:e})}catch(t){this._error=t instanceof Error?t.message:String(t)}finally{this._detailLoading=!1}}}async _fireProgram(e){if(this._entryId){this._fireFeedback="firing\u2026";try{await this.hass.connection.sendMessagePromise({type:"omni_pca/programs/fire",entry_id:this._entryId,slot:e}),this._fireFeedback=`fired slot ${e}`}catch(t){this._fireFeedback=`error: ${t instanceof Error?t.message:t}`}setTimeout(()=>{this._fireFeedback=null},4e3)}}async _clearProgram(e){if(this._entryId){this._writeFeedback="clearing\u2026";try{await this.hass.connection.sendMessagePromise({type:"omni_pca/programs/clear",entry_id:this._entryId,slot:e}),this._writeFeedback=`cleared slot ${e}`,this._confirmingClear=!1,this._selectedSlot=null,this._detail=null,await this._loadList()}catch(t){let n=t instanceof Error?t.message:String(t);this._writeFeedback=`error: ${n}`}setTimeout(()=>{this._writeFeedback=null},4e3)}}async _cloneProgram(e){if(!this._entryId)return;let t=this._cloneTargetSlot.trim(),n=parseInt(t,10);if(!Number.isFinite(n)||n<1||n>1500){this._writeFeedback="target slot must be 1..1500",setTimeout(()=>{this._writeFeedback=null},4e3);return}if(n===e){this._writeFeedback="target must differ from source",setTimeout(()=>{this._writeFeedback=null},4e3);return}this._writeFeedback="cloning\u2026";try{await this.hass.connection.sendMessagePromise({type:"omni_pca/programs/clone",entry_id:this._entryId,source_slot:e,target_slot:n}),this._writeFeedback=`cloned to slot ${n}`,this._showCloneInput=!1,this._cloneTargetSlot="",this._selectedSlot=n,await this._loadList(),await this._loadDetail(n)}catch(r){let o=r instanceof Error?r.message:String(r);this._writeFeedback=`error: ${o}`}setTimeout(()=>{this._writeFeedback=null},4e3)}_onCloneTargetInput(e){this._cloneTargetSlot=e.target.value}async _ensureObjectsLoaded(){if(!(this._objects!==null||!this._entryId))try{this._objects=await this.hass.connection.sendMessagePromise({type:"omni_pca/objects/list",entry_id:this._entryId})}catch(e){let t=e instanceof Error?e.message:String(e);console.warn("omni_pca: objects/list failed",t)}}async _beginEdit(){if(!this._detail||(await this._ensureObjectsLoaded(),!this._entryId))return;if(this._detail.kind==="chain"){this._beginChainEdit();return}if(!Ge.has(this._detail.trigger_type))return;let e=this._detail.fields??this._defaultFieldsForType(this._detail.trigger_type);e!==null&&(this._editingDraft={...e},this._stopRefreshTimer())}_beginChainEdit(){if(!this._detail||!this._detail.chain_members)return;let e=this._detail.chain_members,t=e.find(n=>n.role==="head");t&&(this._chainDraft={headSlot:t.slot,head:{...t.fields},conditions:e.filter(n=>n.role==="condition").map(n=>({...n.fields})),actions:e.filter(n=>n.role==="action").map(n=>({...n.fields}))},this._stopRefreshTimer())}_cancelChainEdit(){this._chainDraft=null,this._startRefreshTimer()}async _saveChainDraft(){if(!(!this._chainDraft||!this._entryId)){this._writeFeedback="saving chain\u2026";try{await this.hass.connection.sendMessagePromise({type:"omni_pca/programs/chain/write",entry_id:this._entryId,head_slot:this._chainDraft.headSlot,head:this._chainDraft.head,conditions:this._chainDraft.conditions,actions:this._chainDraft.actions}),this._writeFeedback=`saved chain @ slot ${this._chainDraft.headSlot}`;let e=this._chainDraft.headSlot;this._chainDraft=null,this._startRefreshTimer(),await this._loadList(),await this._loadDetail(e)}catch(e){let t=e instanceof Error?e.message:String(e);this._writeFeedback=`error: ${t}`}setTimeout(()=>{this._writeFeedback=null},4e3)}}_patchChainHead(e){this._chainDraft&&(this._chainDraft={...this._chainDraft,head:{...this._chainDraft.head,...e}})}_patchChainCondition(e,t){if(!this._chainDraft)return;let n=[...this._chainDraft.conditions];n[e]={...n[e],...t},this._chainDraft={...this._chainDraft,conditions:n}}_addChainCondition(e=!1){if(!this._chainDraft)return;let t=e?Ve():_e();this._chainDraft={...this._chainDraft,conditions:[...this._chainDraft.conditions,t]}}_removeChainCondition(e){if(!this._chainDraft)return;let t=this._chainDraft.conditions.filter((n,r)=>r!==e);this._chainDraft={...this._chainDraft,conditions:t}}_patchChainAction(e,t){if(!this._chainDraft)return;let n=[...this._chainDraft.actions];n[e]={...n[e],...t},this._chainDraft={...this._chainDraft,actions:n}}_addChainAction(){if(!this._chainDraft)return;let e=this._objects?.units?.[0]?.index??1;this._chainDraft={...this._chainDraft,actions:[...this._chainDraft.actions,qe(e)]}}_removeChainAction(e){if(!this._chainDraft||this._chainDraft.actions.length<=1)return;let t=this._chainDraft.actions.filter((n,r)=>r!==e);this._chainDraft={...this._chainDraft,actions:t}}_defaultFieldsForType(e){let t=this._objects?.units?.[0]?.index??1;if(e==="TIMED")return{prog_type:ue,cmd:1,par:0,pr2:t,hour:6,minute:0,days:62,cond:0,cond2:0,month:0,day:0};if(e==="EVENT"){let n=this._objects?.buttons?.[0]?.index??1;return{prog_type:pe,cmd:1,par:0,pr2:t,month:0,day:n&255,hour:0,minute:0,days:0,cond:0,cond2:0}}return e==="YEARLY"?{prog_type:he,cmd:1,par:0,pr2:t,month:1,day:1,hour:0,minute:0,days:0,cond:0,cond2:0}:null}async _saveDraft(){if(!(!this._editingDraft||!this._detail||!this._entryId)){this._writeFeedback="saving\u2026";try{await this.hass.connection.sendMessagePromise({type:"omni_pca/programs/write",entry_id:this._entryId,slot:this._detail.slot,program:this._editingDraft}),this._writeFeedback=`saved slot ${this._detail.slot}`,this._editingDraft=null,this._startRefreshTimer(),await this._loadList(),await this._loadDetail(this._detail.slot)}catch(e){let t=e instanceof Error?e.message:String(e);this._writeFeedback=`error: ${t}`}setTimeout(()=>{this._writeFeedback=null},4e3)}}_cancelEdit(){this._editingDraft=null,this._startRefreshTimer()}_patchDraft(e){this._editingDraft&&(this._editingDraft={...this._editingDraft,...e})}_toggleDayBit(e){if(!this._editingDraft)return;let n=(this._editingDraft.days??0)^e;this._patchDraft({days:n})}_onCommandChange(e){let t=parseInt(e.target.value,10);if(!Number.isFinite(t))return;let n=H(t),r=this._editingDraft?.pr2??0;if(n?.ref_kind&&this._objects){let o=this._pickBucket(n.ref_kind);o&&o.length>0&&!o.some(c=>c.index===r)&&(r=o[0].index)}else n?.ref_kind||(r=0);this._patchDraft({cmd:t,pr2:r})}_pickBucket(e){if(!this._objects)return null;switch(e){case"zone":return this._objects.zones;case"unit":return this._objects.units;case"area":return this._objects.areas;case"button":return this._objects.buttons;default:return null}}_bucketWithPreserve(e,t,n){let r=e??[];return n===0||r.some(o=>o.index===n)?r:[{index:n,name:`(undiscovered ${t} ${n} \u2014 preserve original)`},...r]}_onObjectChange(e){let t=parseInt(e.target.value,10);Number.isFinite(t)&&this._patchDraft({pr2:t})}_onHourChange(e){let t=parseInt(e.target.value,10);Number.isFinite(t)&&t>=0&&t<=23&&this._patchDraft({hour:t})}_onMinuteChange(e){let t=parseInt(e.target.value,10);Number.isFinite(t)&&t>=0&&t<=59&&this._patchDraft({minute:t})}_onParChange(e){let t=parseInt(e.target.value,10);Number.isFinite(t)&&t>=0&&t<=255&&this._patchDraft({par:t})}_onMonthChange(e){let t=parseInt(e.target.value,10);Number.isFinite(t)&&t>=1&&t<=12&&this._patchDraft({month:t})}_onDayChange(e){let t=parseInt(e.target.value,10);Number.isFinite(t)&&t>=1&&t<=31&&this._patchDraft({day:t})}_patchEvent(e){if(!this._editingDraft)return;let t=me(e);this._editingDraft=Le(this._editingDraft,t)}_onEventCategoryChange(e){let t=e.target.value;if(t==="button"){let n=this._objects?.buttons?.[0]?.index??1;this._patchEvent({category:"button",button:n})}else if(t==="zone"){let n=this._objects?.zones?.[0]?.index??1;this._patchEvent({category:"zone",zone:n,zoneState:1})}else if(t==="unit"){let n=this._objects?.units?.[0]?.index??1;this._patchEvent({category:"unit",unit:n,unitOn:!0})}else t==="fixed"&&this._patchEvent({category:"fixed",fixedId:772})}_onEventButtonChange(e){let t=parseInt(e.target.value,10);Number.isFinite(t)&&this._patchEvent({category:"button",button:t})}_onEventZoneChange(e){if(!this._editingDraft)return;let t=parseInt(e.target.value,10);if(!Number.isFinite(t))return;let n=T(F(this._editingDraft));this._patchEvent({category:"zone",zone:t,zoneState:n.zoneState??1})}_onEventZoneStateChange(e){if(!this._editingDraft)return;let t=parseInt(e.target.value,10);if(!Number.isFinite(t))return;let n=T(F(this._editingDraft));this._patchEvent({category:"zone",zone:n.zone??1,zoneState:t})}_onEventUnitChange(e){if(!this._editingDraft)return;let t=parseInt(e.target.value,10);if(!Number.isFinite(t))return;let n=T(F(this._editingDraft));this._patchEvent({category:"unit",unit:t,unitOn:n.unitOn??!0})}_onEventUnitOnChange(e){if(!this._editingDraft)return;let t=e.target.value==="1",n=T(F(this._editingDraft));this._patchEvent({category:"unit",unit:n.unit??1,unitOn:t})}_onEventFixedChange(e){let t=parseInt(e.target.value,10);Number.isFinite(t)&&this._patchEvent({category:"fixed",fixedId:t})}_startRefreshTimer(){this._refreshTimer===null&&(this._refreshTimer=window.setInterval(()=>{this._loadList(),this._selectedSlot!==null&&this._loadDetail(this._selectedSlot)},gt))}_stopRefreshTimer(){this._refreshTimer!==null&&(window.clearInterval(this._refreshTimer),this._refreshTimer=null)}_toggleTriggerFilter(e){let t=new Set(this._activeTriggerTypes);t.has(e)?t.delete(e):t.add(e),this._activeTriggerTypes=t,this._loadList()}_onSearchInput(e){this._searchTerm=e.target.value,this._loadList()}_clearReferenceFilter(){this._referenceFilter=null,this._loadList()}_onRowClick(e){this._selectedSlot=e,this._loadDetail(e)}_onRefClick(e,t){this._referenceFilter=`${e}:${t}`,this._selectedSlot=null,this._detail=null,this._loadList()}_closeDetail(){this._selectedSlot=null,this._detail=null}render(){return a`
@@ -37,7 +37,7 @@ var Oe=Object.defineProperty;var He=Object.getOwnPropertyDescriptor;var u=(n,t,e @input=${this._onSearchInput} />
- ${it.map(e=>a` + ${ft.map(e=>a`
- `}_renderDetail(){if(this._detailLoading)return a``;if(this._detail===null)return a``;let e=this._detail;return this._editingDraft!==null?this._renderEditor(e):a` + `}_renderDetail(){if(this._detailLoading)return a``;if(this._detail===null)return a``;let e=this._detail;return this._editingDraft!==null?this._renderEditor(e):this._chainDraft!==null?this._renderChainEditor(e):a` - `}_renderEditor(e){let r=this._editingDraft,i=e.trigger_type;return a` + `}_renderEditor(e){let t=this._editingDraft,n=e.trigger_type;return a` - `}_renderTriggerSection(e){switch(e.prog_type){case ce:return this._renderTimedTrigger(e);case de:return this._renderEventTrigger(e);case pe:return this._renderYearlyTrigger(e);default:return a`
+ `}_renderTriggerSection(e){switch(e.prog_type){case ue:return this._renderTimedTrigger(e);case pe:return this._renderEventTrigger(e);case he:return this._renderYearlyTrigger(e);default:return a`
Editing program type ${e.prog_type} is not supported.
`}}_renderTimedTrigger(e){return a`
@@ -215,64 +215,64 @@ var Oe=Object.defineProperty;var He=Object.getOwnPropertyDescriptor;var u=(n,t,e
Days
- ${Re.map(r=>{let i=((e.days??0)&r.bit)!==0;return a` + ${de.map(t=>{let n=((e.days??0)&t.bit)!==0;return a` + class="day-toggle ${n?"active":""}" + @click=${()=>this._toggleDayBit(t.bit)} + >${t.label} `})}
- `}_renderEventTrigger(e){let r=F(e),i=C(r);return a` + `}_renderEventTrigger(e){let t=F(e),n=T(t);return a`
Trigger event - ${this._renderEventCategoryFields(i)} + ${this._renderEventCategoryFields(n)}
- `}_renderEventCategoryFields(e){if(e.category==="button"){let r=this._bucketWithPreserve(this._objects?.buttons??null,"button",e.button??0);return a` + `}_renderEventCategoryFields(e){if(e.category==="button"){let t=this._bucketWithPreserve(this._objects?.buttons??null,"button",e.button??0);return a` `}if(e.category==="zone"){let r=this._bucketWithPreserve(this._objects?.zones??null,"zone",e.zone??0);return a` + `}if(e.category==="zone"){let t=this._bucketWithPreserve(this._objects?.zones??null,"zone",e.zone??0);return a` `}if(e.category==="unit"){let r=this._bucketWithPreserve(this._objects?.units??null,"unit",e.unit??0);return a` + `}if(e.category==="unit"){let t=this._bucketWithPreserve(this._objects?.units??null,"unit",e.unit??0);return a`
- `}_renderActionSection(e){let r=le(e.cmd??0),i=r?.ref_kind?this._bucketWithPreserve(this._pickBucket(r.ref_kind),r.ref_kind,e.pr2??0):null,s=e.cmd===9;return a` + `}_renderActionSection(e){let t=H(e.cmd??0),n=t?.ref_kind?this._bucketWithPreserve(this._pickBucket(t.ref_kind),t.ref_kind,e.pr2??0):null,r=e.cmd===9;return a`
Action - ${r?.ref_kind?a` + ${t?.ref_kind?a` `:""} - ${s?a` + ${r?a`
- `}_renderConditionSlot(e,r,i){let s=Ne(r),o=c=>{let l=this._objects?.zones?.[0]?.index??1,h=this._objects?.units?.[0]?.index??1,f=this._objects?.areas?.[0]?.index??1,d;switch(c){case"none":d={family:"none"};break;case"misc":d={family:"misc",misc:1};break;case"zone":d={family:"zone",index:l,active:!1};break;case"unit":d={family:"unit",index:h,active:!0};break;case"time":d={family:"time",index:1,active:!0};break;case"sec":d={family:"sec",index:f,mode:0};break}i(_(d))};return a` + `}_renderConditionSlot(e,t,n){let r=He(t),o=c=>{let l=this._objects?.zones?.[0]?.index??1,u=this._objects?.units?.[0]?.index??1,p=this._objects?.areas?.[0]?.index??1,d;switch(c){case"none":d={family:"none"};break;case"misc":d={family:"misc",misc:1};break;case"zone":d={family:"zone",index:l,active:!1};break;case"unit":d={family:"unit",index:u,active:!0};break;case"time":d={family:"time",index:1,active:!0};break;case"sec":d={family:"sec",index:p,mode:0};break}n(v(d))};return a`
- ${this._renderConditionSubfields(s,i)} + ${this._renderConditionSubfields(r,n)}
- `}_renderConditionSubfields(e,r){if(e.family==="none")return a``;if(e.family==="zone"){let i=this._bucketWithPreserve(this._objects?.zones??null,"zone",e.index??0);return a` + `}_renderConditionSubfields(e,t){if(e.family==="none")return a``;if(e.family==="zone"){let n=this._bucketWithPreserve(this._objects?.zones??null,"zone",e.index??0);return a` `}if(e.family==="unit"){let i=this._bucketWithPreserve(this._objects?.units??null,"unit",e.index??0);return a` + `}if(e.family==="unit"){let n=this._bucketWithPreserve(this._objects?.units??null,"unit",e.index??0);return a` `}if(e.family==="sec"){let i=this._bucketWithPreserve(this._objects?.areas??null,"area",e.index??0);return a` + `}if(e.family==="sec"){let n=this._bucketWithPreserve(this._objects?.areas??null,"area",e.index??0);return a` `:a` `}};p.styles=Z` + `}_renderChainEditor(e){let t=this._chainDraft;return a` + + `}_renderChainHeadSection(e){return e.prog_type===je?this._renderEventTriggerChain(e):e.prog_type===Ue?this._renderTimedTriggerChain(e):e.prog_type===Ye?this._renderEveryTriggerChain(e):a` +
+ Editing trigger type ${e.prog_type} (chain head) is not supported. +
`}_renderTimedTriggerChain(e){return a` +
+ AT (trigger) +
+ + : + +
+
+ ${de.map(t=>{let n=((e.days??0)&t.bit)!==0;return a` + `})} +
+
+ `}_renderEventTriggerChain(e){let t=(e.month??0)<<8|(e.day??0),n=T(t),r=o=>{let c=me(o);this._patchChainHead({month:c>>8&255,day:c&255})};return a` +
+ WHEN (trigger event) + + ${this._renderChainEventSubfields(n,r)} +
+ `}_renderChainEventSubfields(e,t){if(e.category==="button"){let n=this._bucketWithPreserve(this._objects?.buttons??null,"button",e.button??0);return a` + `}if(e.category==="zone"){let n=this._bucketWithPreserve(this._objects?.zones??null,"zone",e.zone??0);return a` + + `}if(e.category==="unit"){let n=this._bucketWithPreserve(this._objects?.units??null,"unit",e.unit??0);return a` + + `}return e.category==="fixed"?a` + `:a`
Unrecognised event ID. Pick a category to redefine.
`}_renderEveryTriggerChain(e){let t=((e.cond??0)&255)<<8|(e.cond2??0)>>8&255;return a` +
+ EVERY (interval, seconds) + +
+ `}_renderChainConditionsSection(e){return a` +
+ + Conditions (${e.length}) + + + + ${e.length===0?a` +
+ No conditions — chain fires unconditionally when triggered. +
`:""} + ${e.map((t,n)=>this._renderChainConditionRow(t,n))} +
+ `}_renderChainConditionRow(e,t){let n=e.prog_type===be;if(We(e))return a` +
+
+ ${n?"OR IF":"AND IF"} (structured comparison — read-only) + +
+
+ This condition uses a structured comparison (TEMP > N etc.). + Editing structured-OP records is not yet supported; it's + preserved on save. +
+
`;let r=Be(e);return a` +
+
+ ${n?"OR IF":"AND IF"} + +
+ ${this._renderChainCondFamily(r,t)} +
`}_renderChainCondFamily(e,t){let n=o=>{let c=this._objects?.zones?.[0]?.index??1,l=this._objects?.units?.[0]?.index??1,u=this._objects?.areas?.[0]?.index??1,p;switch(o){case"none":p={family:"none"};break;case"misc":p={family:"misc",misc:1};break;case"zone":p={family:"zone",index:c,active:!1};break;case"unit":p={family:"unit",index:l,active:!0};break;case"time":p={family:"time",index:1,active:!0};break;case"sec":p={family:"sec",index:u,mode:0};break}let d=ve(p);this._patchChainCondition(t,d)},r=o=>{this._patchChainCondition(t,ve(o))};return a` + + ${this._renderChainCondSubfields(e,r)} + `}_renderChainCondSubfields(e,t){if(e.family==="zone"){let n=this._bucketWithPreserve(this._objects?.zones??null,"zone",e.index??0);return a` + + `}if(e.family==="unit"){let n=this._bucketWithPreserve(this._objects?.units??null,"unit",e.index??0);return a` + + `}if(e.family==="sec"){let n=this._bucketWithPreserve(this._objects?.areas??null,"area",e.index??0);return a` + + `}return e.family==="time"?a` + + `:a` + `}_renderChainActionsSection(e){return a` +
+ + Actions (${e.length}) + + + ${e.map((t,n)=>this._renderChainActionRow(t,n,e.length))} +
+ `}_renderChainActionRow(e,t,n){let r=H(e.cmd??0),o=r?.ref_kind?this._bucketWithPreserve(this._pickBucket(r.ref_kind),r.ref_kind,e.pr2??0):null,c=e.cmd===9;return a` +
+
+ ${t===0?"THEN":"AND"} + ${n>1?a` + `:""} +
+ + ${r?.ref_kind?a` + `:""} + ${c?a` + `:""} +
+ `}};h.styles=J` :host { display: block; min-height: 100vh; @@ -849,7 +1167,38 @@ var Oe=Object.defineProperty;var He=Object.getOwnPropertyDescriptor;var u=(n,t,e font-weight: 600; color: var(--primary-text-color, #000); } - `,u([O({attribute:!1})],p.prototype,"hass",2),u([O({attribute:!1})],p.prototype,"narrow",2),u([m()],p.prototype,"_entryId",2),u([m()],p.prototype,"_rows",2),u([m()],p.prototype,"_total",2),u([m()],p.prototype,"_filteredTotal",2),u([m()],p.prototype,"_loading",2),u([m()],p.prototype,"_error",2),u([m()],p.prototype,"_activeTriggerTypes",2),u([m()],p.prototype,"_referenceFilter",2),u([m()],p.prototype,"_searchTerm",2),u([m()],p.prototype,"_selectedSlot",2),u([m()],p.prototype,"_detail",2),u([m()],p.prototype,"_detailLoading",2),u([m()],p.prototype,"_fireFeedback",2),u([m()],p.prototype,"_writeFeedback",2),u([m()],p.prototype,"_cloneTargetSlot",2),u([m()],p.prototype,"_showCloneInput",2),u([m()],p.prototype,"_confirmingClear",2),u([m()],p.prototype,"_editingDraft",2),u([m()],p.prototype,"_objects",2),p=u([Fe("omni-panel-programs")],p);export{p as OmniPanelPrograms}; + .cond-row-header { + display: flex; justify-content: space-between; align-items: center; + margin-bottom: 4px; + } + .mini-btn { + border: 1px solid var(--divider-color, #ccc); + background: var(--card-background-color, #fff); + color: inherit; + padding: 2px 8px; + border-radius: 3px; + font-size: 0.78rem; + cursor: pointer; + font-family: inherit; + margin-left: 6px; + } + .mini-btn:hover { background: var(--secondary-background-color, #eee); } + .mini-btn.danger { + color: var(--error-color, #db4437); + border-color: var(--error-color, #db4437); + } + .structured-cond { + background: rgba(255, 152, 0, 0.08); /* subtle warning tint */ + } + .chain-meta { + margin-top: 8px; + padding: 8px 10px; + font-size: 0.82rem; + color: var(--secondary-text-color, #666); + background: var(--secondary-background-color, #f5f5f5); + border-radius: 4px; + } + `,m([N({attribute:!1})],h.prototype,"hass",2),m([N({attribute:!1})],h.prototype,"narrow",2),m([f()],h.prototype,"_entryId",2),m([f()],h.prototype,"_rows",2),m([f()],h.prototype,"_total",2),m([f()],h.prototype,"_filteredTotal",2),m([f()],h.prototype,"_loading",2),m([f()],h.prototype,"_error",2),m([f()],h.prototype,"_activeTriggerTypes",2),m([f()],h.prototype,"_referenceFilter",2),m([f()],h.prototype,"_searchTerm",2),m([f()],h.prototype,"_selectedSlot",2),m([f()],h.prototype,"_detail",2),m([f()],h.prototype,"_detailLoading",2),m([f()],h.prototype,"_fireFeedback",2),m([f()],h.prototype,"_writeFeedback",2),m([f()],h.prototype,"_cloneTargetSlot",2),m([f()],h.prototype,"_showCloneInput",2),m([f()],h.prototype,"_confirmingClear",2),m([f()],h.prototype,"_editingDraft",2),m([f()],h.prototype,"_objects",2),m([f()],h.prototype,"_chainDraft",2),h=m([Oe("omni-panel-programs")],h);export{h as OmniPanelPrograms}; /*! Bundled license information: @lit/reactive-element/css-tag.js: diff --git a/dev/artifacts/screenshots/2026-05-16/01-overview.png b/dev/artifacts/screenshots/2026-05-16/01-overview.png index eacacf1..3e54603 100644 Binary files a/dev/artifacts/screenshots/2026-05-16/01-overview.png and b/dev/artifacts/screenshots/2026-05-16/01-overview.png differ diff --git a/dev/artifacts/screenshots/2026-05-16/08-side-panel-programs.png b/dev/artifacts/screenshots/2026-05-16/08-side-panel-programs.png index 7aa74da..0d2d58a 100644 Binary files a/dev/artifacts/screenshots/2026-05-16/08-side-panel-programs.png and b/dev/artifacts/screenshots/2026-05-16/08-side-panel-programs.png differ diff --git a/dev/artifacts/screenshots/2026-05-16/09-side-panel-detail.png b/dev/artifacts/screenshots/2026-05-16/09-side-panel-detail.png index 929ba80..345a7f8 100644 Binary files a/dev/artifacts/screenshots/2026-05-16/09-side-panel-detail.png and b/dev/artifacts/screenshots/2026-05-16/09-side-panel-detail.png differ diff --git a/dev/artifacts/screenshots/2026-05-16/10-side-panel-editor.png b/dev/artifacts/screenshots/2026-05-16/10-side-panel-editor.png index 044786f..dae21ef 100644 Binary files a/dev/artifacts/screenshots/2026-05-16/10-side-panel-editor.png and b/dev/artifacts/screenshots/2026-05-16/10-side-panel-editor.png differ diff --git a/dev/artifacts/screenshots/2026-05-16/11-chain-editor.png b/dev/artifacts/screenshots/2026-05-16/11-chain-editor.png new file mode 100644 index 0000000..569fbbf Binary files /dev/null and b/dev/artifacts/screenshots/2026-05-16/11-chain-editor.png differ diff --git a/tests/ha_integration/test_program_websocket.py b/tests/ha_integration/test_program_websocket.py index a131771..03ac50f 100644 --- a/tests/ha_integration/test_program_websocket.py +++ b/tests/ha_integration/test_program_websocket.py @@ -45,6 +45,27 @@ def seeded_programs() -> dict[int, Program]: # WHEN zone 1 changes to NOT_READY (event_id = 0x0401) month=0x04, day=0x01, ), + # A clausal chain spanning slots 200..203: WHEN zone 1 not-ready + # AND IF unit 1 ON THEN turn ON unit 2 AND turn OFF unit 1. + 200: Program( + slot=200, prog_type=int(ProgramType.WHEN), + # event_id = 0x0401 (zone 1 not-ready) packed in month/day + month=0x04, day=0x01, + ), + 201: Program( + slot=201, prog_type=int(ProgramType.AND), + # Traditional AND: family byte 0x0A = CTRL+ON, instance 1. + # and_family = cond & 0xFF, and_instance = (cond2>>8) & 0xFF. + cond=0x000A, cond2=0x0100, + ), + 202: Program( + slot=202, prog_type=int(ProgramType.THEN), + cmd=int(Command.UNIT_ON), pr2=2, + ), + 203: Program( + slot=203, prog_type=int(ProgramType.THEN), + cmd=int(Command.UNIT_OFF), pr2=1, + ), } @@ -93,17 +114,14 @@ async def test_ws_list_programs_returns_summaries( response = await client.receive_json() assert response["success"] is True result = response["result"] - assert result["total"] == 3 - assert result["filtered_total"] == 3 + # 3 compact-form programs (12, 42, 99) + 1 clausal chain (head at + # slot 200, spanning 200..203). The chain renders as a single row. + assert result["total"] == 4 + assert result["filtered_total"] == 4 rows_by_slot = {row["slot"]: row for row in result["programs"]} - # Both TIMED programs and the EVENT program land in the response. - assert rows_by_slot.keys() == {12, 42, 99} - # Each row has the metadata the frontend needs. - for row in result["programs"]: - assert row["kind"] == "compact" - assert row["trigger_type"] in ("TIMED", "EVENT") - assert isinstance(row["summary"], list) - assert row["summary"] # non-empty token list + assert rows_by_slot.keys() == {12, 42, 99, 200} + assert rows_by_slot[200]["kind"] == "chain" + assert rows_by_slot[12]["kind"] == "compact" async def test_ws_list_programs_filter_by_trigger_type( @@ -135,8 +153,11 @@ async def test_ws_list_programs_filter_by_referenced_entity( }) response = await client.receive_json() result = response["result"] - assert result["filtered_total"] == 1 - assert result["programs"][0]["slot"] == 42 + # Slot 42 ("Turn ON KITCHEN_OVERHEAD" = unit 2) plus the seeded chain + # at slot 200 (action: Turn ON unit 2) both reference unit:2. + assert result["filtered_total"] == 2 + slots = {r["slot"] for r in result["programs"]} + assert slots == {42, 200} async def test_ws_list_programs_search_substring( @@ -151,9 +172,13 @@ async def test_ws_list_programs_search_substring( }) response = await client.receive_json() result = response["result"] - # Only slot 42 ("Turn ON KITCHEN_OVERHEAD") mentions kitchen. - assert result["filtered_total"] == 1 - assert result["programs"][0]["slot"] == 42 + # Slot 42 ("Turn ON KITCHEN_OVERHEAD" — truncated to 12 chars on + # wire = "KITCHEN_OVER") matches. The chain at slot 200 also has + # an action against unit 2 which renders with the same truncated + # name, so it matches too. + assert result["filtered_total"] == 2 + slots = {r["slot"] for r in result["programs"]} + assert slots == {42, 200} async def test_ws_list_programs_pagination( @@ -168,7 +193,8 @@ async def test_ws_list_programs_pagination( }) response = await client.receive_json() result = response["result"] - assert result["filtered_total"] == 3 + # 4 list rows total: 3 compact + 1 chain head. + assert result["filtered_total"] == 4 assert len(result["programs"]) == 2 assert [row["slot"] for row in result["programs"]] == [42, 99] @@ -462,6 +488,167 @@ async def test_ws_list_objects_returns_named_buckets( assert zones_by_idx[1] == "FRONT_DOOR" +async def test_ws_get_chain_returns_member_fields( + hass: HomeAssistant, configured_panel, hass_ws_client +) -> None: + """Chain detail response includes a chain_members array with each + member's role + raw fields, so the editor can render an editable + row per slot.""" + client = await hass_ws_client(hass) + await client.send_json_auto_id({ + "type": "omni_pca/programs/get", + "entry_id": configured_panel.entry_id, + "slot": 200, # head of the seeded chain + }) + response = await client.receive_json() + assert response["success"] is True + result = response["result"] + assert result["kind"] == "chain" + members = result["chain_members"] + roles = [m["role"] for m in members] + assert roles == ["head", "condition", "action", "action"] + # Head carries the event_id (zone 1 NOT_READY = 0x0401). + head_fields = members[0]["fields"] + assert head_fields["prog_type"] == int(ProgramType.WHEN) + assert head_fields["month"] == 0x04 + assert head_fields["day"] == 0x01 + # Condition is a Traditional AND record with family CTRL+ON, unit 1. + cond_fields = members[1]["fields"] + assert cond_fields["prog_type"] == int(ProgramType.AND) + assert cond_fields["cond"] == 0x000A + assert cond_fields["cond2"] == 0x0100 + + +async def test_ws_chain_write_replaces_in_place( + hass: HomeAssistant, configured_panel, hass_ws_client +) -> None: + """Same-length rewrite leaves the chain footprint unchanged but + updates every member's bytes.""" + client = await hass_ws_client(hass) + coordinator = hass.data[DOMAIN][configured_panel.entry_id] + # Existing chain: slots 200..203. + assert {200, 201, 202, 203} <= coordinator.data.programs.keys() + await client.send_json_auto_id({ + "type": "omni_pca/programs/chain/write", + "entry_id": configured_panel.entry_id, + "head_slot": 200, + "head": { + "prog_type": int(ProgramType.WHEN), + "month": 0x04, "day": 0x02, # zone 1 trouble (id 0x0402) + }, + "conditions": [ + # AND IF unit 2 ON (family 0x0A, instance 2) + {"prog_type": int(ProgramType.AND), + "cond": 0x000A, "cond2": 0x0200}, + ], + "actions": [ + {"prog_type": int(ProgramType.THEN), + "cmd": int(Command.UNIT_OFF), "pr2": 2}, + {"prog_type": int(ProgramType.THEN), + "cmd": int(Command.UNIT_ON), "pr2": 1}, + ], + }) + response = await client.receive_json() + assert response["success"] is True + assert response["result"]["written_slots"] == [200, 201, 202, 203] + assert response["result"]["cleared_slots"] == [] + # Coordinator state reflects the new bytes. + assert coordinator.data.programs[200].day == 0x02 + assert coordinator.data.programs[201].cond2 == 0x0200 + assert coordinator.data.programs[202].cmd == int(Command.UNIT_OFF) + + +async def test_ws_chain_write_shrinks_and_clears( + hass: HomeAssistant, configured_panel, hass_ws_client +) -> None: + """Shorter rewrite clears the trailing old chain slots.""" + client = await hass_ws_client(hass) + coordinator = hass.data[DOMAIN][configured_panel.entry_id] + await client.send_json_auto_id({ + "type": "omni_pca/programs/chain/write", + "entry_id": configured_panel.entry_id, + "head_slot": 200, + "head": { + "prog_type": int(ProgramType.WHEN), + "month": 0x04, "day": 0x01, + }, + # No conditions, one action — chain shrinks from 4 slots to 2. + "conditions": [], + "actions": [ + {"prog_type": int(ProgramType.THEN), + "cmd": int(Command.UNIT_ON), "pr2": 1}, + ], + }) + response = await client.receive_json() + assert response["success"] is True + assert response["result"]["written_slots"] == [200, 201] + assert sorted(response["result"]["cleared_slots"]) == [202, 203] + # Cleared slots are gone from the coordinator's view. + assert 202 not in coordinator.data.programs + assert 203 not in coordinator.data.programs + + +async def test_ws_chain_write_refuses_to_trample( + hass: HomeAssistant, configured_panel, hass_ws_client +) -> None: + """Expanding a chain into a slot that already holds another program + is refused — protects against accidental data loss.""" + client = await hass_ws_client(hass) + coordinator = hass.data[DOMAIN][configured_panel.entry_id] + # Seed a sentinel program at slot 204 (right after the chain) so an + # expand attempt collides. + coordinator.data.programs[204] = Program( + slot=204, prog_type=int(ProgramType.TIMED), + cmd=int(Command.UNIT_ON), pr2=1, + hour=12, minute=0, days=int(Days.MONDAY), + ) + await client.send_json_auto_id({ + "type": "omni_pca/programs/chain/write", + "entry_id": configured_panel.entry_id, + "head_slot": 200, + "head": {"prog_type": int(ProgramType.WHEN), + "month": 0x04, "day": 0x01}, + "conditions": [ + {"prog_type": int(ProgramType.AND), + "cond": 0x000A, "cond2": 0x0100}, + # Adding a second condition pushes the chain from 4 to 5 + # slots → slot 204 collision. + {"prog_type": int(ProgramType.AND), + "cond": 0x000A, "cond2": 0x0200}, + ], + "actions": [ + {"prog_type": int(ProgramType.THEN), + "cmd": int(Command.UNIT_ON), "pr2": 2}, + {"prog_type": int(ProgramType.THEN), + "cmd": int(Command.UNIT_OFF), "pr2": 1}, + ], + }) + response = await client.receive_json() + assert response["success"] is False + assert response["error"]["code"] == "invalid" + # The sentinel program is untouched. + assert coordinator.data.programs[204].cmd == int(Command.UNIT_ON) + + +async def test_ws_chain_write_rejects_zero_actions( + hass: HomeAssistant, configured_panel, hass_ws_client +) -> None: + """A chain with no THEN actions is meaningless — refuse it.""" + client = await hass_ws_client(hass) + await client.send_json_auto_id({ + "type": "omni_pca/programs/chain/write", + "entry_id": configured_panel.entry_id, + "head_slot": 200, + "head": {"prog_type": int(ProgramType.WHEN), + "month": 0x04, "day": 0x01}, + "conditions": [], + "actions": [], + }) + response = await client.receive_json() + assert response["success"] is False + assert response["error"]["code"] == "invalid" + + async def test_ws_list_programs_live_state_overlay_zone( hass: HomeAssistant, configured_panel, hass_ws_client ) -> None: