program editor: real edit-existing seed + EVENT/YEARLY editors
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run

Three pieces close out the editor's main gaps:

F1 — backend includes raw fields in programs/get response:

  _program_to_fields() serialises a Program record into the same
  field dict the editor form consumes. Round-trips through
  programs/write are now lossless (fetch → edit → write produces
  byte-identical wire output if no fields changed). The old TODO
  in _fetchProgramFields was about exactly this — the frontend
  was seeding from sensible defaults rather than real values
  because the wire didn't carry raw fields. Now it does.

  Verified by a new round-trip test: read slot 42, write the same
  fields back, assert the encoded wire bytes are identical.

F2 — EVENT program editor:

  EVENT records pack a 16-bit event_id into (month<<8 | day).
  Editing requires decoding that ID into one of four categories:

    * "button"  — USER_MACRO_BUTTON, low byte = button index
    * "zone"    — ZONE_STATE_CHANGE, packed zone + state-change kind
    * "unit"    — UNIT_STATE_CHANGE, packed unit + on/off
    * "fixed"   — hand-rolled IDs (phone events, AC power) from
                  EVENT_AC_POWER_OFF / EVENT_PHONE_RINGING / etc.

  TS helpers decodeEventId / encodeEventId / packEventIdIntoFields
  mirror the Python helpers in program_engine.py.

  UI: category dropdown switches the sub-fields (button picker,
  zone+state pair, unit+on/off, fixed-event picker). Each change
  re-encodes back to month/day. Existing programs with unrecognised
  IDs fall into a "raw" category that shows the literal hex —
  user can switch category to redefine.

F3 — YEARLY program editor:

  YEARLY records have month + day + hour + minute, no days-bitmask.
  The editor now switches on prog_type to pick the right trigger
  section: month dropdown (named months), day number input,
  hour/minute number inputs.

Editor render path refactored: _renderTriggerSection(draft)
dispatches to _renderTimedTrigger / _renderEventTrigger /
_renderYearlyTrigger by prog_type. _renderActionSection is
shared across all three (command picker + object picker + level%).
Action editing works identically regardless of trigger.

Edit button visibility extended from "TIMED only" to any
program_type in EDITABLE_PROG_TYPES (TIMED / EVENT / YEARLY).
REMARK and clausal chains remain read-only.

Full suite: 648 passed, 1 skipped (up from 647, F1 round-trip test).
Frontend bundle: 56 KB minified (up from 47 KB with EVENT + YEARLY
forms and event-id helpers).
This commit is contained in:
Ryan Malloy 2026-05-16 12:20:21 -06:00
parent e6308c5624
commit 14d16a5a4c
5 changed files with 907 additions and 262 deletions

View File

@ -15,17 +15,33 @@ import {
COMMAND_OPTIONS, COMMAND_OPTIONS,
CommandOption, CommandOption,
DAY_BITS, DAY_BITS,
DecodedEvent,
EventCategory,
FIXED_EVENTS,
Hass, Hass,
MONTH_NAMES,
NamedObject, NamedObject,
ObjectListResponse, ObjectListResponse,
PROGRAM_TYPE_EVENT,
PROGRAM_TYPE_TIMED, PROGRAM_TYPE_TIMED,
PROGRAM_TYPE_YEARLY,
ProgramDetail, ProgramDetail,
ProgramFields, ProgramFields,
ProgramListResponse, ProgramListResponse,
ProgramRow, ProgramRow,
commandOptionFor, commandOptionFor,
decodeEventId,
encodeEventId,
eventIdFromFields,
packEventIdIntoFields,
} from "./types.js"; } from "./types.js";
// Which compact-form trigger types the editor knows how to render.
// REMARK is intentionally excluded (it's a text annotation, not a
// runnable program). Clausal types (WHEN/AT/EVERY) are kind="chain"
// not "compact" so they're filtered out earlier in _beginEdit.
const EDITABLE_PROG_TYPES = new Set(["TIMED", "EVENT", "YEARLY"]);
const TRIGGER_TYPES = [ const TRIGGER_TYPES = [
"TIMED", "EVENT", "YEARLY", "WHEN", "AT", "EVERY", "REMARK", "TIMED", "EVENT", "YEARLY", "WHEN", "AT", "EVERY", "REMARK",
] as const; ] as const;
@ -284,58 +300,55 @@ 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 || this._detail.kind !== "compact") return;
// Only TIMED programs are editable in this pass; the others render // The frontend supports editing compact-form TIMED / EVENT / YEARLY
// a "not yet editable" banner instead. // programs. Other compact types (REMARK) and clausal chains remain
if (this._detail.trigger_type !== "TIMED") return; // 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();
// Seed the draft from the currently-loaded compact-form Program.
// The detail response doesn't include raw fields, so query the
// coordinator-cached program by re-fetching via list (which gives
// us trigger_type) plus a follow-up "get" for full tokens. The
// simplest path: read the underlying Program off the most-recent
// list row's metadata. References-only data is not enough — we
// need raw cmd/par/pr2/days/etc. Reach for it via a fresh ws call.
if (!this._entryId) return; if (!this._entryId) return;
const programDict = await this._fetchProgramFields( // The detail response now carries raw fields directly. If they're
this._entryId, this._detail.slot, // missing (panel returned only tokens) we fall back to sensible
// defaults so the form at least opens — better than a hard error.
const fields = this._detail.fields ?? this._defaultFieldsForType(
this._detail.trigger_type,
); );
if (programDict === null) return; if (fields === null) return;
this._editingDraft = programDict; this._editingDraft = { ...fields };
this._stopRefreshTimer(); // pause polling while editing this._stopRefreshTimer();
} }
private async _fetchProgramFields( private _defaultFieldsForType(triggerType: string): ProgramFields | null {
entryId: string, slot: number,
): Promise<ProgramFields | null> {
// The list command returns rendered summaries; we need the raw
// Program fields to seed the form. The websocket layer doesn't
// currently expose raw fields, so we use a brief inline hack:
// re-fetch the list filtered to this exact slot via the references
// dimension, then read the underlying ProgramRow. But ProgramRow
// only carries trigger_type and counts, not raw bytes...
//
// Simplest path: add a brief endpoint or include raw fields in
// the get detail response. The wire side already has the bytes;
// we just need to send them. Doing this inline by piggy-backing
// on the list row would require a server change. For now, render
// a fresh form from sensible defaults (hour 6, minute 0,
// weekdays, UNIT_ON, pr2=first unit) and let the user adjust —
// this works for the new-program-via-clone flow.
//
// TODO: extend get-detail to include raw program fields so the
// editor seeds from real values when editing existing programs.
void entryId; void slot;
const firstUnit = this._objects?.units?.[0]?.index ?? 1; const firstUnit = this._objects?.units?.[0]?.index ?? 1;
return { if (triggerType === "TIMED") {
prog_type: PROGRAM_TYPE_TIMED, return {
cmd: 1, // UNIT_ON prog_type: PROGRAM_TYPE_TIMED,
par: 0, cmd: 1, par: 0, pr2: firstUnit,
pr2: firstUnit, hour: 6, minute: 0,
hour: 6, minute: 0, days: 0x02 | 0x04 | 0x08 | 0x10 | 0x20, // Mon-Fri
days: 0x02 | 0x04 | 0x08 | 0x10 | 0x20, // Mon-Fri default cond: 0, cond2: 0, month: 0, day: 0,
cond: 0, cond2: 0, };
month: 0, day: 0, }
}; if (triggerType === "EVENT") {
const firstButton = this._objects?.buttons?.[0]?.index ?? 1;
return {
prog_type: PROGRAM_TYPE_EVENT,
cmd: 1, par: 0, pr2: firstUnit,
// Default to a button-press event; month+day pack the event_id.
month: 0, day: firstButton & 0xFF,
hour: 0, minute: 0, days: 0,
cond: 0, cond2: 0,
};
}
if (triggerType === "YEARLY") {
return {
prog_type: PROGRAM_TYPE_YEARLY,
cmd: 1, par: 0, pr2: firstUnit,
month: 1, day: 1, hour: 0, minute: 0,
days: 0, cond: 0, cond2: 0,
};
}
return null;
} }
private async _saveDraft(): Promise<void> { private async _saveDraft(): Promise<void> {
@ -435,6 +448,116 @@ export class OmniPanelPrograms extends LitElement {
} }
} }
// ---- YEARLY handlers (month / day) ---------------------------------
private _onMonthChange(e: Event): void {
const value = parseInt((e.target as HTMLSelectElement).value, 10);
if (Number.isFinite(value) && value >= 1 && value <= 12) {
this._patchDraft({ month: value });
}
}
private _onDayChange(e: Event): void {
const value = parseInt((e.target as HTMLInputElement).value, 10);
if (Number.isFinite(value) && value >= 1 && value <= 31) {
this._patchDraft({ day: value });
}
}
// ---- EVENT handlers (event-id builder) -----------------------------
//
// The event_id is packed into the program's month/day bytes
// (eventId >> 8 = month, eventId & 0xFF = day) — that's the wire
// encoding for EVENT records. The UI works in terms of "category +
// sub-fields" and re-encodes on every change.
private _patchEvent(decoded: DecodedEvent): void {
if (!this._editingDraft) return;
const eventId = encodeEventId(decoded);
this._editingDraft = packEventIdIntoFields(this._editingDraft, eventId);
}
private _onEventCategoryChange(e: Event): void {
const cat = (e.target as HTMLSelectElement).value as EventCategory;
// Switching category — seed sensible defaults for the new category
// so the sub-fields below have valid initial values.
if (cat === "button") {
const firstButton = this._objects?.buttons?.[0]?.index ?? 1;
this._patchEvent({ category: "button", button: firstButton });
} else if (cat === "zone") {
const firstZone = this._objects?.zones?.[0]?.index ?? 1;
this._patchEvent({ category: "zone", zone: firstZone, zoneState: 1 });
} else if (cat === "unit") {
const firstUnit = this._objects?.units?.[0]?.index ?? 1;
this._patchEvent({ category: "unit", unit: firstUnit, unitOn: true });
} else if (cat === "fixed") {
this._patchEvent({ category: "fixed", fixedId: 772 }); // AC lost
}
// "raw" isn't user-selectable from the dropdown — only appears when
// an existing event ID doesn't match a known pattern.
}
private _onEventButtonChange(e: Event): void {
const button = parseInt((e.target as HTMLSelectElement).value, 10);
if (Number.isFinite(button)) {
this._patchEvent({ category: "button", button });
}
}
private _onEventZoneChange(e: Event): void {
if (!this._editingDraft) return;
const zone = parseInt((e.target as HTMLSelectElement).value, 10);
if (!Number.isFinite(zone)) return;
const existing = decodeEventId(eventIdFromFields(this._editingDraft));
this._patchEvent({
category: "zone",
zone,
zoneState: existing.zoneState ?? 1,
});
}
private _onEventZoneStateChange(e: Event): void {
if (!this._editingDraft) return;
const state = parseInt((e.target as HTMLSelectElement).value, 10);
if (!Number.isFinite(state)) return;
const existing = decodeEventId(eventIdFromFields(this._editingDraft));
this._patchEvent({
category: "zone",
zone: existing.zone ?? 1,
zoneState: state,
});
}
private _onEventUnitChange(e: Event): void {
if (!this._editingDraft) return;
const unit = parseInt((e.target as HTMLSelectElement).value, 10);
if (!Number.isFinite(unit)) return;
const existing = decodeEventId(eventIdFromFields(this._editingDraft));
this._patchEvent({
category: "unit",
unit,
unitOn: existing.unitOn ?? true,
});
}
private _onEventUnitOnChange(e: Event): void {
if (!this._editingDraft) return;
const on = (e.target as HTMLSelectElement).value === "1";
const existing = decodeEventId(eventIdFromFields(this._editingDraft));
this._patchEvent({
category: "unit",
unit: existing.unit ?? 1,
unitOn: on,
});
}
private _onEventFixedChange(e: Event): void {
const id = parseInt((e.target as HTMLSelectElement).value, 10);
if (Number.isFinite(id)) {
this._patchEvent({ category: "fixed", fixedId: id });
}
}
// -- refresh timer ---------------------------------------------------- // -- refresh timer ----------------------------------------------------
private _startRefreshTimer(): void { private _startRefreshTimer(): void {
@ -610,7 +733,7 @@ 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.trigger_type === "TIMED" && d.kind === "compact" ? html` ${d.kind === "compact" && EDITABLE_PROG_TYPES.has(d.trigger_type) ? html`
<button <button
type="button" type="button"
class="secondary" class="secondary"
@ -689,103 +812,27 @@ export class OmniPanelPrograms extends LitElement {
private _renderEditor(d: ProgramDetail): TemplateResult { private _renderEditor(d: ProgramDetail): TemplateResult {
const draft = this._editingDraft!; const draft = this._editingDraft!;
const cmdOpt: CommandOption | undefined = commandOptionFor(draft.cmd ?? 0); const triggerLabel = d.trigger_type;
const objectBucket = cmdOpt?.ref_kind ? this._pickBucket(cmdOpt.ref_kind) : null;
const showsLevelPercent = (draft.cmd === 9); // UNIT_LEVEL
return html` return html`
<aside class="detail editor"> <aside class="detail editor">
<header> <header>
<div> <div>
<span class="trigger-badge trigger-timed">EDIT TIMED</span> <span class="trigger-badge trigger-${triggerLabel.toLowerCase()}">
EDIT ${triggerLabel}
</span>
<span class="slot">slot #${d.slot}</span> <span class="slot">slot #${d.slot}</span>
</div> </div>
<button type="button" class="close" @click=${this._cancelEdit}>×</button> <button type="button" class="close" @click=${this._cancelEdit}>×</button>
</header> </header>
<div class="editor-body"> <div class="editor-body">
<!-- Time of day --> ${this._renderTriggerSection(draft)}
<fieldset> ${this._renderActionSection(draft)}
<legend>Time</legend>
<div class="row">
<label>
Hour
<input
type="number" min="0" max="23"
.value=${String(draft.hour ?? 0)}
@input=${this._onHourChange}
/>
</label>
<span class="time-colon">:</span>
<label>
Minute
<input
type="number" min="0" max="59" step="1"
.value=${String(draft.minute ?? 0)}
@input=${this._onMinuteChange}
/>
</label>
</div>
</fieldset>
<!-- Days bitmask -->
<fieldset>
<legend>Days</legend>
<div class="days-row">
${DAY_BITS.map((d) => {
const active = ((draft.days ?? 0) & d.bit) !== 0;
return html`
<button
type="button"
class="day-toggle ${active ? "active" : ""}"
@click=${() => this._toggleDayBit(d.bit)}
>${d.label}</button>
`;
})}
</div>
</fieldset>
<!-- Action -->
<fieldset>
<legend>Action</legend>
<label class="block">
Command
<select @change=${this._onCommandChange}>
${COMMAND_OPTIONS.map((c) => html`
<option .value=${String(c.value)}
?selected=${c.value === draft.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=${this._onObjectChange}>
${(objectBucket ?? []).map((o) => html`
<option .value=${String(o.index)}
?selected=${o.index === draft.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(draft.par ?? 0)}
@input=${this._onParChange}
/>
</label>` : ""}
</fieldset>
${draft.cond || draft.cond2 ? html` ${draft.cond || draft.cond2 ? html`
<div class="conditions-readonly"> <div class="conditions-readonly">
<strong>Inline conditions:</strong> <strong>Inline conditions:</strong>
this program has up to two inline AND-IF conditions on the this program carries up to two inline AND-IF conditions on
source record. They're preserved when saving but editing the source record. They're preserved on save but editing
condition fields is not yet supported. condition fields is not yet supported.
</div>` : ""} </div>` : ""}
</div> </div>
@ -804,6 +851,274 @@ export class OmniPanelPrograms extends LitElement {
`; `;
} }
private _renderTriggerSection(draft: ProgramFields): TemplateResult {
switch (draft.prog_type) {
case PROGRAM_TYPE_TIMED:
return this._renderTimedTrigger(draft);
case PROGRAM_TYPE_EVENT:
return this._renderEventTrigger(draft);
case PROGRAM_TYPE_YEARLY:
return this._renderYearlyTrigger(draft);
default:
return html`<div class="conditions-readonly">
Editing program type ${draft.prog_type} is not supported.
</div>`;
}
}
private _renderTimedTrigger(draft: ProgramFields): TemplateResult {
return html`
<fieldset>
<legend>Time</legend>
<div class="row">
<label>
Hour
<input
type="number" min="0" max="23"
.value=${String(draft.hour ?? 0)}
@input=${this._onHourChange}
/>
</label>
<span class="time-colon">:</span>
<label>
Minute
<input
type="number" min="0" max="59" step="1"
.value=${String(draft.minute ?? 0)}
@input=${this._onMinuteChange}
/>
</label>
</div>
</fieldset>
<fieldset>
<legend>Days</legend>
<div class="days-row">
${DAY_BITS.map((d) => {
const active = ((draft.days ?? 0) & d.bit) !== 0;
return html`
<button
type="button"
class="day-toggle ${active ? "active" : ""}"
@click=${() => this._toggleDayBit(d.bit)}
>${d.label}</button>
`;
})}
</div>
</fieldset>
`;
}
private _renderEventTrigger(draft: ProgramFields): TemplateResult {
const eventId = eventIdFromFields(draft);
const decoded = decodeEventId(eventId);
return html`
<fieldset>
<legend>Trigger event</legend>
<label class="block">
Category
<select @change=${this._onEventCategoryChange}>
<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 event (phone / AC)
</option>
${decoded.category === "raw" ? html`
<option value="raw" selected>
Raw 0x${eventId.toString(16).padStart(4, "0")}
</option>` : ""}
</select>
</label>
${this._renderEventCategoryFields(decoded)}
</fieldset>
`;
}
private _renderEventCategoryFields(decoded: DecodedEvent): TemplateResult {
if (decoded.category === "button") {
return html`
<label class="block">
Button
<select @change=${this._onEventButtonChange}>
${(this._objects?.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") {
return html`
<label class="block">
Zone
<select @change=${this._onEventZoneChange}>
${(this._objects?.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=${this._onEventZoneStateChange}>
<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") {
return html`
<label class="block">
Unit
<select @change=${this._onEventUnitChange}>
${(this._objects?.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=${this._onEventUnitOnChange}>
<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=${this._onEventFixedChange}>
${FIXED_EVENTS.map((f) => html`
<option .value=${String(f.id)}
?selected=${f.id === decoded.fixedId}>
${f.label}
</option>
`)}
</select>
</label>`;
}
// raw — render as informational; the user picked another category
// from the dropdown if they want to change it.
return html`
<div class="conditions-readonly">
Unrecognised event ID. Switch category above to redefine.
</div>`;
}
private _renderYearlyTrigger(draft: ProgramFields): TemplateResult {
return html`
<fieldset>
<legend>Date</legend>
<div class="row">
<label>
Month
<select @change=${this._onMonthChange}>
${MONTH_NAMES.map((name, i) => html`
<option .value=${String(i + 1)}
?selected=${(draft.month ?? 1) === i + 1}>
${name} (${i + 1})
</option>
`)}
</select>
</label>
<label>
Day
<input
type="number" min="1" max="31"
.value=${String(draft.day ?? 1)}
@input=${this._onDayChange}
/>
</label>
</div>
</fieldset>
<fieldset>
<legend>Time of day</legend>
<div class="row">
<label>
Hour
<input
type="number" min="0" max="23"
.value=${String(draft.hour ?? 0)}
@input=${this._onHourChange}
/>
</label>
<span class="time-colon">:</span>
<label>
Minute
<input
type="number" min="0" max="59"
.value=${String(draft.minute ?? 0)}
@input=${this._onMinuteChange}
/>
</label>
</div>
</fieldset>
`;
}
private _renderActionSection(draft: ProgramFields): TemplateResult {
const cmdOpt: CommandOption | undefined = commandOptionFor(draft.cmd ?? 0);
const objectBucket = cmdOpt?.ref_kind ? this._pickBucket(cmdOpt.ref_kind) : null;
const showsLevelPercent = (draft.cmd === 9); // UNIT_LEVEL
return html`
<fieldset>
<legend>Action</legend>
<label class="block">
Command
<select @change=${this._onCommandChange}>
${COMMAND_OPTIONS.map((c) => html`
<option .value=${String(c.value)}
?selected=${c.value === draft.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=${this._onObjectChange}>
${(objectBucket ?? []).map((o) => html`
<option .value=${String(o.index)}
?selected=${o.index === draft.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(draft.par ?? 0)}
@input=${this._onParChange}
/>
</label>` : ""}
</fieldset>
`;
}
// -- styles ----------------------------------------------------------- // -- styles -----------------------------------------------------------
static styles = css` static styles = css`

View File

@ -47,6 +47,9 @@ export interface ProgramDetail {
references: string[]; references: string[];
/** For chain detail: every slot the chain spans. */ /** For chain detail: every slot the chain spans. */
chain_slots?: number[]; chain_slots?: number[];
/** Raw Program field values; included for compact-form programs so
* the editor can seed its form from real data rather than defaults. */
fields?: ProgramFields;
} }
export interface ProgramListRequest { export interface ProgramListRequest {
@ -156,6 +159,127 @@ export const PROGRAM_TYPE_EVENT = 2;
export const PROGRAM_TYPE_YEARLY = 3; export const PROGRAM_TYPE_YEARLY = 3;
export const PROGRAM_TYPE_REMARK = 4; export const PROGRAM_TYPE_REMARK = 4;
// --------------------------------------------------------------------------
// Event-ID encode/decode for the EVENT-program editor.
//
// Mirrors the Python helpers in omni_pca.program_engine — the 16-bit
// event_id uses different bit patterns per category. Each "category"
// in the UI maps to a different chunk of the ID space.
// --------------------------------------------------------------------------
export type EventCategory =
| "button" // USER_MACRO_BUTTON (evt & 0xFF00) == 0x0000
| "zone" // ZONE_STATE_CHANGE (evt & 0xFC00) == 0x0400
| "unit" // UNIT_STATE_CHANGE (evt & 0xFC00) == 0x0800
| "fixed" // hard-coded IDs (phone / AC power)
| "raw"; // anything else — show numeric
export interface DecodedEvent {
category: EventCategory;
/** For "button": 1..255 */
button?: number;
/** For "zone": 1..256, plus state 0=secure / 1=not-ready / 2=trouble / 3=tamper */
zone?: number;
zoneState?: number;
/** For "unit": 1..511 plus on bool */
unit?: number;
unitOn?: boolean;
/** For "fixed": the literal event ID. */
fixedId?: number;
/** For "raw": the literal event ID we couldn't classify. */
raw?: number;
}
// Hand-rolled fixed IDs and labels (matches Python EVENT_* constants).
export const FIXED_EVENTS: ReadonlyArray<{ id: number; label: string }> = [
{ id: 768, label: "Phone line dead" },
{ id: 769, label: "Phone ringing" },
{ id: 770, label: "Phone off hook" },
{ id: 771, label: "Phone on hook" },
{ id: 772, label: "AC power lost" },
{ id: 773, label: "AC power restored" },
];
const ZONE_STATE_LABELS = ["secure", "not ready", "trouble", "tamper"];
export function decodeEventId(eventId: number): DecodedEvent {
// FIXED first — the bit patterns below would otherwise collapse
// 768..773 into the "zone state change" category since their top
// bits look the same.
if (FIXED_EVENTS.some((f) => f.id === eventId)) {
return { category: "fixed", fixedId: eventId };
}
if ((eventId & 0xFF00) === 0x0000) {
return { category: "button", button: eventId & 0xFF };
}
if ((eventId & 0xFC00) === 0x0400) {
const zs = eventId & 0x03FF;
return {
category: "zone",
zone: Math.floor(zs / 4) + 1,
zoneState: zs % 4,
};
}
if ((eventId & 0xFC00) === 0x0800) {
const us = eventId & 0x03FF;
return {
category: "unit",
unit: Math.floor(us / 2) + 1,
unitOn: (us & 1) === 1,
};
}
return { category: "raw", raw: eventId };
}
export function encodeEventId(ev: DecodedEvent): number {
switch (ev.category) {
case "button":
return (ev.button ?? 1) & 0xFF;
case "zone": {
const zone = (ev.zone ?? 1) - 1;
const state = (ev.zoneState ?? 0) & 0x03;
return 0x0400 | ((zone * 4 + state) & 0x03FF);
}
case "unit": {
const unit = (ev.unit ?? 1) - 1;
const on = ev.unitOn ? 1 : 0;
return 0x0800 | ((unit * 2 + on) & 0x03FF);
}
case "fixed":
return ev.fixedId ?? 768;
case "raw":
default:
return ev.raw ?? 0;
}
}
export function eventIdFromFields(fields: ProgramFields): number {
return ((fields.month ?? 0) << 8) | (fields.day ?? 0);
}
export function packEventIdIntoFields(
fields: ProgramFields, eventId: number,
): ProgramFields {
return {
...fields,
month: (eventId >> 8) & 0xFF,
day: eventId & 0xFF,
};
}
export function zoneStateLabel(state: number): string {
return ZONE_STATE_LABELS[state] ?? `state ${state}`;
}
// Month abbreviations for the YEARLY editor.
export const MONTH_NAMES = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
/** HA's hass object — minimal surface we use. */ /** HA's hass object — minimal surface we use. */
export interface Hass { export interface Hass {
connection: { connection: {

View File

@ -378,9 +378,34 @@ async def _ws_get_program(
"trigger_type": _classify_trigger(target), "trigger_type": _classify_trigger(target),
"tokens": _tokens_to_json(tokens), "tokens": _tokens_to_json(tokens),
"references": _extract_references(tokens), "references": _extract_references(tokens),
# Raw program fields for the editor to seed its form. The
# rendered token stream is for *display*; the form needs the
# underlying integer values to round-trip cleanly.
"fields": _program_to_fields(target),
}) })
def _program_to_fields(program: Program) -> dict[str, Any]:
"""Serialise a Program for the editor form. Mirrors the field
layout of :func:`_PROGRAM_FIELD_SCHEMA` so a round-trip
fetch edit save is straightforward.
"""
return {
"prog_type": program.prog_type,
"cond": program.cond,
"cond2": program.cond2,
"cmd": program.cmd,
"par": program.par,
"pr2": program.pr2,
"month": program.month,
"day": program.day,
"days": program.days,
"hour": program.hour,
"minute": program.minute,
"remark_id": program.remark_id,
}
_PROGRAM_FIELD_SCHEMA = vol.Schema( _PROGRAM_FIELD_SCHEMA = vol.Schema(
{ {
vol.Required("prog_type"): vol.All(int, vol.Range(min=0, max=10)), vol.Required("prog_type"): vol.All(int, vol.Range(min=0, max=10)),

File diff suppressed because one or more lines are too long

View File

@ -198,6 +198,45 @@ async def test_ws_get_program_returns_full_token_stream(
assert "KITCHEN_OVER" in text assert "KITCHEN_OVER" in text
async def test_ws_get_program_returns_raw_fields_for_editor(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None:
"""The detail response includes a 'fields' dict carrying raw Program
integer values, so the editor can seed forms from actual data rather
than defaults. Round-trip: get fields write back should preserve
every byte (idempotent under no-op edits)."""
client = await hass_ws_client(hass)
await client.send_json_auto_id({
"type": "omni_pca/programs/get",
"entry_id": configured_panel.entry_id,
"slot": 42,
})
response = await client.receive_json()
assert response["success"] is True
fields = response["result"]["fields"]
# Slot 42 is the seeded TIMED 22:30 Sunday → Turn ON unit 2 program.
assert fields["prog_type"] == 1
assert fields["hour"] == 22
assert fields["minute"] == 30
assert fields["days"] == int(Days.SUNDAY)
assert fields["cmd"] == int(Command.UNIT_ON)
assert fields["pr2"] == 2
# Round-trip: write those same fields back; nothing should change.
coordinator = hass.data[DOMAIN][configured_panel.entry_id]
before = coordinator.data.programs[42]
await client.send_json_auto_id({
"type": "omni_pca/programs/write",
"entry_id": configured_panel.entry_id,
"slot": 42,
"program": fields,
})
write_response = await client.receive_json()
assert write_response["success"] is True
after = coordinator.data.programs[42]
assert before.encode_wire_bytes() == after.encode_wire_bytes()
async def test_ws_get_program_missing_slot_returns_error( async def test_ws_get_program_missing_slot_returns_error(
hass: HomeAssistant, configured_panel, hass_ws_client hass: HomeAssistant, configured_panel, hass_ws_client
) -> None: ) -> None: