panel: clausal chain editor (WHEN/AT/EVERY + AND/OR/THEN)
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run

Multi-record clausal programs are now editable end-to-end. A chain
spans N consecutive slots — head (WHEN/AT/EVERY) + zero-or-more
AND/OR condition records + one-or-more THEN action records — so
"editing" means rewriting the whole run, validating that any
expansion doesn't trample adjacent programs, and clearing any old
slots when the chain shrinks.

H1 — backend:

* programs/get for chains now returns chain_members[] with each
  member's slot + role + raw fields. The editor uses this to seed
  one editable form-row per slot.
* New programs/chain/write command: takes head_slot + head dict +
  conditions[] + actions[], does N sequential download_program
  calls, then clears any old chain slots that fell outside the new
  range. Validates:
    - head_slot + new_len doesn't extend past slot 1500
    - any expansion-into slot not already part of THIS chain is FREE
      (anti-trample: refuse rather than overwrite an adjacent program)
    - at least one THEN action present (empty chain rejected)
  Updates coordinator.data.programs immediately so subsequent list
  calls reflect the edit before the next poll.

H2 — TS helpers:

* AND-record encoding mirrors compact-form cond family bytes
  (0x04 ZONE / 0x08 CTRL / 0x0C TIME / 0x00 OTHER + 0x10+ SEC) but
  with a slightly different bit layout: the family byte lives at
  fields.cond & 0xFF (disk byte 1) and the instance at
  (fields.cond2 >> 8) & 0xFF (disk byte 3). The selector bit is
  family's bit 0x02 instead of cond's 0x0200. decodeAndCondition /
  encodeAndCondition handle both directions; round-trip exact.
* isStructuredAnd helper detects records with OP > 0 (TEMP > N
  comparisons etc.); those render read-only in the chain editor
  with a warning banner.
* emptyAndRecord / emptyOrRecord / emptyThenRecord helpers for
  the add-condition / add-action buttons.

H3 — chain editor UI:

* New _chainDraft state (parallel to _editingDraft for compact form)
  with head + conditions[] + actions[] arrays. Mutation helpers
  preserve immutability via array-copy-then-patch.
* "Edit" button on chain detail now opens the chain editor instead
  of returning early (previous read-only behaviour).
* Three sub-renderers: trigger section dispatches on prog_type
  (WHEN→event-id builder reusing the EVENT helpers, AT→time+days
  reusing TIMED layout, EVERY→single seconds input that packs into
  cond+cond2), conditions section with per-row add/remove (separate
  + AND IF and + OR IF buttons in the legend), actions section with
  per-row add/remove (+ THEN button; at least one action enforced).
* Structured-OP AND records render with an explanatory read-only
  banner and a × button to drop the row entirely — preserves the
  data when the user doesn't touch it, lets them remove it cleanly
  when they want to.
* Each row picks objects via _bucketWithPreserve so out-of-range
  zone/unit/area indices stay safe.

5 new HA integration tests:
* get-chain returns chain_members with correct roles + raw fields
* chain/write in-place rewrite preserves footprint, updates bytes
* chain/write shrink clears the trailing old slots
* chain/write refuses to trample an adjacent program on expansion
* chain/write rejects zero-actions submission

Live screenshot 11-chain-editor.png: state injection into the side
panel (real panel has no chains) shows the editor rendering a sample
WHEN zone-state → AND IF unit ON → 2x THEN action chain with every
control populated and functional.

Full suite: 653 passed, 1 skipped (up from 648, 5 new chain tests).
Frontend bundle: 82 KB minified (up from 63 KB).
This commit is contained in:
Ryan Malloy 2026-05-17 02:09:04 -06:00
parent 5870e2f7ee
commit 9ca4da98e8
10 changed files with 1721 additions and 134 deletions

View File

@ -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<void> {
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<void> {
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<ProgramFields>): void {
if (!this._chainDraft) return;
this._chainDraft = {
...this._chainDraft,
head: { ...this._chainDraft.head, ...patch },
};
}
private _patchChainCondition(idx: number, patch: Partial<ProgramFields>): 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<ProgramFields>): 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`
<aside class="detail">
<header>
@ -785,7 +915,9 @@ export class OmniPanelPrograms extends LitElement {
class="fire"
@click=${() => this._fireProgram(d.slot)}
> Fire now</button>
${d.kind === "compact" && EDITABLE_PROG_TYPES.has(d.trigger_type) ? html`
${
(d.kind === "compact" && EDITABLE_PROG_TYPES.has(d.trigger_type))
|| d.kind === "chain" ? html`
<button
type="button"
class="secondary"
@ -1378,6 +1510,587 @@ export class OmniPanelPrograms extends LitElement {
</label>`;
}
// ---- clausal chain editor -----------------------------------------
private _renderChainEditor(d: ProgramDetail): TemplateResult {
const draft = this._chainDraft!;
return html`
<aside class="detail editor">
<header>
<div>
<span class="trigger-badge trigger-${d.trigger_type.toLowerCase()}">
EDIT ${d.trigger_type}
</span>
<span class="slot">head @ slot #${draft.headSlot}</span>
</div>
<button type="button" class="close" @click=${this._cancelChainEdit}>×</button>
</header>
<div class="editor-body">
${this._renderChainHeadSection(draft.head)}
${this._renderChainConditionsSection(draft.conditions)}
${this._renderChainActionsSection(draft.actions)}
<div class="chain-meta">
Chain will occupy <strong>${1 + draft.conditions.length + draft.actions.length}</strong>
consecutive slots starting at #${draft.headSlot}.
</div>
</div>
<footer>
<button type="button" class="primary" @click=${this._saveChainDraft}>
Save chain
</button>
<button type="button" class="secondary" @click=${this._cancelChainEdit}>
Cancel
</button>
${this._writeFeedback ? html`
<span class="fire-feedback">${this._writeFeedback}</span>` : ""}
</footer>
</aside>
`;
}
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`
<div class="conditions-readonly">
Editing trigger type ${head.prog_type} (chain head) is not supported.
</div>`;
}
private _renderTimedTriggerChain(head: ProgramFields): TemplateResult {
// Same fields as TIMED compact: hour/minute/days.
return html`
<fieldset>
<legend>AT (trigger)</legend>
<div class="row">
<label>
Hour
<input type="number" min="0" max="23"
.value=${String(head.hour ?? 0)}
@input=${(e: Event) => this._patchChainHead({
hour: parseInt((e.target as HTMLInputElement).value, 10) || 0,
})}
/>
</label>
<span class="time-colon">:</span>
<label>
Minute
<input type="number" min="0" max="59"
.value=${String(head.minute ?? 0)}
@input=${(e: Event) => this._patchChainHead({
minute: parseInt((e.target as HTMLInputElement).value, 10) || 0,
})}
/>
</label>
</div>
<div class="days-row">
${DAY_BITS.map((d) => {
const active = ((head.days ?? 0) & d.bit) !== 0;
return html`
<button type="button"
class="day-toggle ${active ? "active" : ""}"
@click=${() => this._patchChainHead({
days: (head.days ?? 0) ^ d.bit,
})}
>${d.label}</button>`;
})}
</div>
</fieldset>
`;
}
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`
<fieldset>
<legend>WHEN (trigger event)</legend>
<label class="block">
Category
<select @change=${(e: Event) => {
const cat = (e.target as HTMLSelectElement).value as EventCategory;
if (cat === "button") {
const fb = this._objects?.buttons?.[0]?.index ?? 1;
setEvent({ category: "button", button: fb });
} else if (cat === "zone") {
const fz = this._objects?.zones?.[0]?.index ?? 1;
setEvent({ category: "zone", zone: fz, zoneState: 1 });
} else if (cat === "unit") {
const fu = this._objects?.units?.[0]?.index ?? 1;
setEvent({ category: "unit", unit: fu, unitOn: true });
} else if (cat === "fixed") {
setEvent({ category: "fixed", fixedId: 772 });
}
}}>
<option value="button" ?selected=${decoded.category === "button"}>Button press</option>
<option value="zone" ?selected=${decoded.category === "zone"}>Zone state change</option>
<option value="unit" ?selected=${decoded.category === "unit"}>Unit state change</option>
<option value="fixed" ?selected=${decoded.category === "fixed"}>Fixed (phone / AC)</option>
${decoded.category === "raw" ? html`
<option value="raw" selected>Raw 0x${eventId.toString(16).padStart(4, "0")}</option>` : ""}
</select>
</label>
${this._renderChainEventSubfields(decoded, setEvent)}
</fieldset>
`;
}
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`
<label class="block">
Button
<select @change=${(e: Event) => setEvent({
category: "button",
button: parseInt((e.target as HTMLSelectElement).value, 10),
})}>
${buttons.map((b) => html`
<option .value=${String(b.index)}
?selected=${b.index === decoded.button}>
#${b.index} ${b.name}
</option>
`)}
</select>
</label>`;
}
if (decoded.category === "zone") {
const zones = this._bucketWithPreserve(
this._objects?.zones ?? null, "zone", decoded.zone ?? 0,
);
return html`
<label class="block">
Zone
<select @change=${(e: Event) => setEvent({
...decoded, category: "zone",
zone: parseInt((e.target as HTMLSelectElement).value, 10),
zoneState: decoded.zoneState ?? 1,
})}>
${zones.map((z) => html`
<option .value=${String(z.index)}
?selected=${z.index === decoded.zone}>
#${z.index} ${z.name}
</option>
`)}
</select>
</label>
<label class="block">
Becomes
<select @change=${(e: Event) => setEvent({
...decoded, category: "zone",
zone: decoded.zone ?? 1,
zoneState: parseInt((e.target as HTMLSelectElement).value, 10),
})}>
<option value="0" ?selected=${decoded.zoneState === 0}>secure</option>
<option value="1" ?selected=${decoded.zoneState === 1}>not ready</option>
<option value="2" ?selected=${decoded.zoneState === 2}>trouble</option>
<option value="3" ?selected=${decoded.zoneState === 3}>tamper</option>
</select>
</label>`;
}
if (decoded.category === "unit") {
const units = this._bucketWithPreserve(
this._objects?.units ?? null, "unit", decoded.unit ?? 0,
);
return html`
<label class="block">
Unit
<select @change=${(e: Event) => setEvent({
...decoded, category: "unit",
unit: parseInt((e.target as HTMLSelectElement).value, 10),
unitOn: decoded.unitOn ?? true,
})}>
${units.map((u) => html`
<option .value=${String(u.index)}
?selected=${u.index === decoded.unit}>
#${u.index} ${u.name}
</option>
`)}
</select>
</label>
<label class="block">
Turns
<select @change=${(e: Event) => setEvent({
...decoded, category: "unit",
unit: decoded.unit ?? 1,
unitOn: (e.target as HTMLSelectElement).value === "1",
})}>
<option value="1" ?selected=${decoded.unitOn === true}>ON</option>
<option value="0" ?selected=${decoded.unitOn === false}>OFF</option>
</select>
</label>`;
}
if (decoded.category === "fixed") {
return html`
<label class="block">
Event
<select @change=${(e: Event) => setEvent({
category: "fixed",
fixedId: parseInt((e.target as HTMLSelectElement).value, 10),
})}>
${FIXED_EVENTS.map((f) => html`
<option .value=${String(f.id)}
?selected=${f.id === decoded.fixedId}>
${f.label}
</option>
`)}
</select>
</label>`;
}
return html`<div class="conditions-readonly">Unrecognised event ID. Pick a category to redefine.</div>`;
}
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`
<fieldset>
<legend>EVERY (interval, seconds)</legend>
<label class="block">
Seconds between fires
<input type="number" min="1" max="65535"
.value=${String(interval || 1)}
@input=${(e: Event) => {
const sec = parseInt((e.target as HTMLInputElement).value, 10);
if (!Number.isFinite(sec) || sec < 1) return;
this._patchChainHead({
cond: (sec >> 8) & 0xFF,
cond2: (sec & 0xFF) << 8,
});
}}
/>
</label>
</fieldset>
`;
}
private _renderChainConditionsSection(conds: ProgramFields[]): TemplateResult {
return html`
<fieldset>
<legend>
Conditions (${conds.length})
<button type="button" class="mini-btn" @click=${() => this._addChainCondition(false)}>
+ AND IF
</button>
<button type="button" class="mini-btn" @click=${() => this._addChainCondition(true)}>
+ OR IF
</button>
</legend>
${conds.length === 0 ? html`
<div class="conditions-readonly">
No conditions chain fires unconditionally when triggered.
</div>` : ""}
${conds.map((c, idx) => this._renderChainConditionRow(c, idx))}
</fieldset>
`;
}
private _renderChainConditionRow(
cond: ProgramFields, idx: number,
): TemplateResult {
const isOr = cond.prog_type === PROGRAM_TYPE_OR;
if (isStructuredAnd(cond)) {
return html`
<div class="cond-slot structured-cond">
<div>
<strong>${isOr ? "OR IF" : "AND IF"}</strong> (structured comparison read-only)
<button type="button" class="mini-btn danger"
@click=${() => this._removeChainCondition(idx)}>×</button>
</div>
<div class="conditions-readonly">
This condition uses a structured comparison (TEMP &gt; N etc.).
Editing structured-OP records is not yet supported; it's
preserved on save.
</div>
</div>`;
}
const decoded = decodeAndCondition(cond);
return html`
<div class="cond-slot">
<div class="cond-row-header">
<strong>${isOr ? "OR IF" : "AND IF"}</strong>
<button type="button" class="mini-btn danger"
@click=${() => this._removeChainCondition(idx)}>×</button>
</div>
${this._renderChainCondFamily(decoded, idx)}
</div>`;
}
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`
<label class="block">
Family
<select @change=${(e: Event) =>
setFamily((e.target as HTMLSelectElement).value as CondFamily)}>
<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</option>
</select>
</label>
${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`
<label class="block">
Zone
<select @change=${(e: Event) => setDecoded({
...decoded, index: parseInt((e.target as HTMLSelectElement).value, 10),
})}>
${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) => setDecoded({
...decoded, active: (e.target as HTMLSelectElement).value === "1",
})}>
<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) => setDecoded({
...decoded, index: parseInt((e.target as HTMLSelectElement).value, 10),
})}>
${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) => setDecoded({
...decoded, active: (e.target as HTMLSelectElement).value === "1",
})}>
<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) => setDecoded({
...decoded, index: parseInt((e.target as HTMLSelectElement).value, 10),
})}>
${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) => setDecoded({
...decoded, mode: parseInt((e.target as HTMLSelectElement).value, 10),
})}>
${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)) setDecoded({ ...decoded, index: idx });
}}
/>
</label>
<label class="block">
Is
<select @change=${(e: Event) => setDecoded({
...decoded, active: (e.target as HTMLSelectElement).value === "1",
})}>
<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) => setDecoded({
family: "misc",
misc: parseInt((e.target as HTMLSelectElement).value, 10),
})}>
${MISC_CONDITIONALS.map((m) => html`
<option .value=${String(m.value)} ?selected=${m.value === decoded.misc}>
${m.label}
</option>`)}
</select>
</label>`;
}
private _renderChainActionsSection(actions: ProgramFields[]): TemplateResult {
return html`
<fieldset>
<legend>
Actions (${actions.length})
<button type="button" class="mini-btn"
@click=${() => this._addChainAction()}>+ THEN</button>
</legend>
${actions.map((a, idx) => this._renderChainActionRow(a, idx, actions.length))}
</fieldset>
`;
}
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`
<div class="cond-slot">
<div class="cond-row-header">
<strong>${idx === 0 ? "THEN" : "AND"}</strong>
${total > 1 ? html`
<button type="button" class="mini-btn danger"
@click=${() => this._removeChainAction(idx)}>×</button>` : ""}
</div>
<label class="block">
Command
<select @change=${(e: Event) => {
const value = parseInt((e.target as HTMLSelectElement).value, 10);
const opt = commandOptionFor(value);
let newPr2 = action.pr2 ?? 0;
if (opt?.ref_kind && this._objects) {
const bucket = this._pickBucket(opt.ref_kind);
if (bucket && bucket.length > 0 &&
!bucket.some((o) => o.index === newPr2)) {
newPr2 = bucket[0].index;
}
} else if (!opt?.ref_kind) {
newPr2 = 0;
}
this._patchChainAction(idx, { cmd: value, pr2: newPr2 });
}}>
${COMMAND_OPTIONS.map((c) => html`
<option .value=${String(c.value)} ?selected=${c.value === action.cmd}>
${c.label}
</option>`)}
</select>
</label>
${cmdOpt?.ref_kind ? html`
<label class="block">
${cmdOpt.ref_kind[0].toUpperCase() + cmdOpt.ref_kind.slice(1)}
<select @change=${(e: Event) => {
const v = parseInt((e.target as HTMLSelectElement).value, 10);
if (Number.isFinite(v)) this._patchChainAction(idx, { pr2: v });
}}>
${(objectBucket ?? []).map((o) => html`
<option .value=${String(o.index)} ?selected=${o.index === action.pr2}>
#${o.index} ${o.name}
</option>`)}
</select>
</label>` : ""}
${showsLevelPercent ? html`
<label class="block">
Level (0..100)
<input type="number" min="0" max="100"
.value=${String(action.par ?? 0)}
@input=${(e: Event) => {
const v = parseInt((e.target as HTMLInputElement).value, 10);
if (Number.isFinite(v) && v >= 0 && v <= 100) {
this._patchChainAction(idx, { par: v });
}
}}
/>
</label>` : ""}
</div>
`;
}
// -- 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;
}
`;
}

View File

@ -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: {

View File

@ -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)

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 325 KiB

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

View File

@ -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: