panel: clausal chain editor (WHEN/AT/EVERY + AND/OR/THEN)
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).
@ -25,8 +25,12 @@ import {
|
|||||||
MONTH_NAMES,
|
MONTH_NAMES,
|
||||||
NamedObject,
|
NamedObject,
|
||||||
ObjectListResponse,
|
ObjectListResponse,
|
||||||
|
PROGRAM_TYPE_AT,
|
||||||
PROGRAM_TYPE_EVENT,
|
PROGRAM_TYPE_EVENT,
|
||||||
|
PROGRAM_TYPE_EVERY,
|
||||||
|
PROGRAM_TYPE_OR,
|
||||||
PROGRAM_TYPE_TIMED,
|
PROGRAM_TYPE_TIMED,
|
||||||
|
PROGRAM_TYPE_WHEN,
|
||||||
PROGRAM_TYPE_YEARLY,
|
PROGRAM_TYPE_YEARLY,
|
||||||
ProgramDetail,
|
ProgramDetail,
|
||||||
ProgramFields,
|
ProgramFields,
|
||||||
@ -34,11 +38,17 @@ import {
|
|||||||
ProgramRow,
|
ProgramRow,
|
||||||
SECURITY_MODE_NAMES,
|
SECURITY_MODE_NAMES,
|
||||||
commandOptionFor,
|
commandOptionFor,
|
||||||
|
decodeAndCondition,
|
||||||
decodeCondition,
|
decodeCondition,
|
||||||
decodeEventId,
|
decodeEventId,
|
||||||
|
emptyAndRecord,
|
||||||
|
emptyOrRecord,
|
||||||
|
emptyThenRecord,
|
||||||
|
encodeAndCondition,
|
||||||
encodeCondition,
|
encodeCondition,
|
||||||
encodeEventId,
|
encodeEventId,
|
||||||
eventIdFromFields,
|
eventIdFromFields,
|
||||||
|
isStructuredAnd,
|
||||||
packEventIdIntoFields,
|
packEventIdIntoFields,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
@ -100,6 +110,18 @@ export class OmniPanelPrograms extends LitElement {
|
|||||||
// omni_pca/programs/write websocket, Cancel discards.
|
// omni_pca/programs/write websocket, Cancel discards.
|
||||||
@state() private _editingDraft: ProgramFields | null = null;
|
@state() private _editingDraft: ProgramFields | null = null;
|
||||||
@state() private _objects: ObjectListResponse | 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;
|
private _refreshTimer: number | null = null;
|
||||||
|
|
||||||
@ -324,17 +346,18 @@ export class OmniPanelPrograms extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _beginEdit(): Promise<void> {
|
private async _beginEdit(): Promise<void> {
|
||||||
if (!this._detail || this._detail.kind !== "compact") return;
|
if (!this._detail) 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;
|
|
||||||
await this._ensureObjectsLoaded();
|
await this._ensureObjectsLoaded();
|
||||||
if (!this._entryId) return;
|
if (!this._entryId) return;
|
||||||
// The detail response now carries raw fields directly. If they're
|
if (this._detail.kind === "chain") {
|
||||||
// missing (panel returned only tokens) we fall back to sensible
|
this._beginChainEdit();
|
||||||
// defaults so the form at least opens — better than a hard error.
|
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(
|
const fields = this._detail.fields ?? this._defaultFieldsForType(
|
||||||
this._detail.trigger_type,
|
this._detail.trigger_type,
|
||||||
);
|
);
|
||||||
@ -343,6 +366,110 @@ export class OmniPanelPrograms extends LitElement {
|
|||||||
this._stopRefreshTimer();
|
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 {
|
private _defaultFieldsForType(triggerType: string): ProgramFields | null {
|
||||||
const firstUnit = this._objects?.units?.[0]?.index ?? 1;
|
const firstUnit = this._objects?.units?.[0]?.index ?? 1;
|
||||||
if (triggerType === "TIMED") {
|
if (triggerType === "TIMED") {
|
||||||
@ -767,6 +894,9 @@ export class OmniPanelPrograms extends LitElement {
|
|||||||
if (this._editingDraft !== null) {
|
if (this._editingDraft !== null) {
|
||||||
return this._renderEditor(d);
|
return this._renderEditor(d);
|
||||||
}
|
}
|
||||||
|
if (this._chainDraft !== null) {
|
||||||
|
return this._renderChainEditor(d);
|
||||||
|
}
|
||||||
return html`
|
return html`
|
||||||
<aside class="detail">
|
<aside class="detail">
|
||||||
<header>
|
<header>
|
||||||
@ -785,7 +915,9 @@ export class OmniPanelPrograms extends LitElement {
|
|||||||
class="fire"
|
class="fire"
|
||||||
@click=${() => this._fireProgram(d.slot)}
|
@click=${() => this._fireProgram(d.slot)}
|
||||||
>▶ Fire now</button>
|
>▶ 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="secondary"
|
class="secondary"
|
||||||
@ -1378,6 +1510,587 @@ export class OmniPanelPrograms extends LitElement {
|
|||||||
</label>`;
|
</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 > 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 -----------------------------------------------------------
|
// -- styles -----------------------------------------------------------
|
||||||
|
|
||||||
static styles = css`
|
static styles = css`
|
||||||
@ -1724,6 +2437,37 @@ export class OmniPanelPrograms extends LitElement {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--primary-text-color, #000);
|
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;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -50,6 +50,13 @@ export interface ProgramDetail {
|
|||||||
/** Raw Program field values; included for compact-form programs so
|
/** Raw Program field values; included for compact-form programs so
|
||||||
* the editor can seed its form from real data rather than defaults. */
|
* the editor can seed its form from real data rather than defaults. */
|
||||||
fields?: ProgramFields;
|
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 {
|
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. */
|
/** HA's hass object — minimal surface we use. */
|
||||||
export interface Hass {
|
export interface Hass {
|
||||||
connection: {
|
connection: {
|
||||||
|
|||||||
@ -368,6 +368,21 @@ async def _ws_get_program(
|
|||||||
"tokens": _tokens_to_json(tokens),
|
"tokens": _tokens_to_json(tokens),
|
||||||
"references": _extract_references(tokens),
|
"references": _extract_references(tokens),
|
||||||
"chain_slots": [m.slot for m in members if m.slot is not None],
|
"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
|
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(
|
@websocket_api.websocket_command(
|
||||||
{
|
{
|
||||||
vol.Required("type"): "omni_pca/objects/list",
|
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_clear_program)
|
||||||
websocket_api.async_register_command(hass, _ws_clone_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_write_program)
|
||||||
|
websocket_api.async_register_command(hass, _ws_chain_write)
|
||||||
websocket_api.async_register_command(hass, _ws_list_objects)
|
websocket_api.async_register_command(hass, _ws_list_objects)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 294 KiB After Width: | Height: | Size: 295 KiB |
|
Before Width: | Height: | Size: 325 KiB After Width: | Height: | Size: 325 KiB |
|
Before Width: | Height: | Size: 356 KiB After Width: | Height: | Size: 356 KiB |
BIN
dev/artifacts/screenshots/2026-05-16/11-chain-editor.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
@ -45,6 +45,27 @@ def seeded_programs() -> dict[int, Program]:
|
|||||||
# WHEN zone 1 changes to NOT_READY (event_id = 0x0401)
|
# WHEN zone 1 changes to NOT_READY (event_id = 0x0401)
|
||||||
month=0x04, day=0x01,
|
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()
|
response = await client.receive_json()
|
||||||
assert response["success"] is True
|
assert response["success"] is True
|
||||||
result = response["result"]
|
result = response["result"]
|
||||||
assert result["total"] == 3
|
# 3 compact-form programs (12, 42, 99) + 1 clausal chain (head at
|
||||||
assert result["filtered_total"] == 3
|
# 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"]}
|
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, 200}
|
||||||
assert rows_by_slot.keys() == {12, 42, 99}
|
assert rows_by_slot[200]["kind"] == "chain"
|
||||||
# Each row has the metadata the frontend needs.
|
assert rows_by_slot[12]["kind"] == "compact"
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
async def test_ws_list_programs_filter_by_trigger_type(
|
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()
|
response = await client.receive_json()
|
||||||
result = response["result"]
|
result = response["result"]
|
||||||
assert result["filtered_total"] == 1
|
# Slot 42 ("Turn ON KITCHEN_OVERHEAD" = unit 2) plus the seeded chain
|
||||||
assert result["programs"][0]["slot"] == 42
|
# 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(
|
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()
|
response = await client.receive_json()
|
||||||
result = response["result"]
|
result = response["result"]
|
||||||
# Only slot 42 ("Turn ON KITCHEN_OVERHEAD") mentions kitchen.
|
# Slot 42 ("Turn ON KITCHEN_OVERHEAD" — truncated to 12 chars on
|
||||||
assert result["filtered_total"] == 1
|
# wire = "KITCHEN_OVER") matches. The chain at slot 200 also has
|
||||||
assert result["programs"][0]["slot"] == 42
|
# 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(
|
async def test_ws_list_programs_pagination(
|
||||||
@ -168,7 +193,8 @@ async def test_ws_list_programs_pagination(
|
|||||||
})
|
})
|
||||||
response = await client.receive_json()
|
response = await client.receive_json()
|
||||||
result = response["result"]
|
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 len(result["programs"]) == 2
|
||||||
assert [row["slot"] for row in result["programs"]] == [42, 99]
|
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"
|
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(
|
async def test_ws_list_programs_live_state_overlay_zone(
|
||||||
hass: HomeAssistant, configured_panel, hass_ws_client
|
hass: HomeAssistant, configured_panel, hass_ws_client
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||