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,
CommandOption,
DAY_BITS,
DecodedEvent,
EventCategory,
FIXED_EVENTS,
Hass,
MONTH_NAMES,
NamedObject,
ObjectListResponse,
PROGRAM_TYPE_EVENT,
PROGRAM_TYPE_TIMED,
PROGRAM_TYPE_YEARLY,
ProgramDetail,
ProgramFields,
ProgramListResponse,
ProgramRow,
commandOptionFor,
decodeEventId,
encodeEventId,
eventIdFromFields,
packEventIdIntoFields,
} 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 = [
"TIMED", "EVENT", "YEARLY", "WHEN", "AT", "EVERY", "REMARK",
] as const;
@ -284,59 +300,56 @@ export class OmniPanelPrograms extends LitElement {
private async _beginEdit(): Promise<void> {
if (!this._detail || this._detail.kind !== "compact") return;
// Only TIMED programs are editable in this pass; the others render
// a "not yet editable" banner instead.
if (this._detail.trigger_type !== "TIMED") 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();
// 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;
const programDict = await this._fetchProgramFields(
this._entryId, this._detail.slot,
// The detail response now carries raw fields directly. If they're
// missing (panel returned only tokens) we fall back to sensible
// defaults so the form at least opens — better than a hard error.
const fields = this._detail.fields ?? this._defaultFieldsForType(
this._detail.trigger_type,
);
if (programDict === null) return;
this._editingDraft = programDict;
this._stopRefreshTimer(); // pause polling while editing
if (fields === null) return;
this._editingDraft = { ...fields };
this._stopRefreshTimer();
}
private async _fetchProgramFields(
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;
private _defaultFieldsForType(triggerType: string): ProgramFields | null {
const firstUnit = this._objects?.units?.[0]?.index ?? 1;
if (triggerType === "TIMED") {
return {
prog_type: PROGRAM_TYPE_TIMED,
cmd: 1, // UNIT_ON
par: 0,
pr2: firstUnit,
cmd: 1, par: 0, pr2: firstUnit,
hour: 6, minute: 0,
days: 0x02 | 0x04 | 0x08 | 0x10 | 0x20, // Mon-Fri default
cond: 0, cond2: 0,
month: 0, day: 0,
days: 0x02 | 0x04 | 0x08 | 0x10 | 0x20, // Mon-Fri
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> {
if (!this._editingDraft || !this._detail || !this._entryId) return;
@ -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 ----------------------------------------------------
private _startRefreshTimer(): void {
@ -610,7 +733,7 @@ export class OmniPanelPrograms extends LitElement {
class="fire"
@click=${() => this._fireProgram(d.slot)}
> Fire now</button>
${d.trigger_type === "TIMED" && d.kind === "compact" ? html`
${d.kind === "compact" && EDITABLE_PROG_TYPES.has(d.trigger_type) ? html`
<button
type="button"
class="secondary"
@ -689,21 +812,62 @@ export class OmniPanelPrograms extends LitElement {
private _renderEditor(d: ProgramDetail): TemplateResult {
const draft = this._editingDraft!;
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
const triggerLabel = d.trigger_type;
return html`
<aside class="detail editor">
<header>
<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>
</div>
<button type="button" class="close" @click=${this._cancelEdit}>×</button>
</header>
<div class="editor-body">
<!-- Time of day -->
${this._renderTriggerSection(draft)}
${this._renderActionSection(draft)}
${draft.cond || draft.cond2 ? html`
<div class="conditions-readonly">
<strong>Inline conditions:</strong>
this program carries up to two inline AND-IF conditions on
the source record. They're preserved on save but editing
condition fields is not yet supported.
</div>` : ""}
</div>
<footer>
<button type="button" class="primary" @click=${this._saveDraft}>
Save
</button>
<button type="button" class="secondary" @click=${this._cancelEdit}>
Cancel
</button>
${this._writeFeedback ? html`
<span class="fire-feedback">${this._writeFeedback}</span>` : ""}
</footer>
</aside>
`;
}
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">
@ -726,8 +890,6 @@ export class OmniPanelPrograms extends LitElement {
</label>
</div>
</fieldset>
<!-- Days bitmask -->
<fieldset>
<legend>Days</legend>
<div class="days-row">
@ -743,8 +905,182 @@ export class OmniPanelPrograms extends LitElement {
})}
</div>
</fieldset>
`;
}
<!-- Action -->
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">
@ -780,27 +1116,6 @@ export class OmniPanelPrograms extends LitElement {
/>
</label>` : ""}
</fieldset>
${draft.cond || draft.cond2 ? html`
<div class="conditions-readonly">
<strong>Inline conditions:</strong>
this program has up to two inline AND-IF conditions on the
source record. They're preserved when saving but editing
condition fields is not yet supported.
</div>` : ""}
</div>
<footer>
<button type="button" class="primary" @click=${this._saveDraft}>
Save
</button>
<button type="button" class="secondary" @click=${this._cancelEdit}>
Cancel
</button>
${this._writeFeedback ? html`
<span class="fire-feedback">${this._writeFeedback}</span>` : ""}
</footer>
</aside>
`;
}

View File

@ -47,6 +47,9 @@ export interface ProgramDetail {
references: string[];
/** For chain detail: every slot the chain spans. */
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 {
@ -156,6 +159,127 @@ export const PROGRAM_TYPE_EVENT = 2;
export const PROGRAM_TYPE_YEARLY = 3;
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. */
export interface Hass {
connection: {

View File

@ -378,9 +378,34 @@ async def _ws_get_program(
"trigger_type": _classify_trigger(target),
"tokens": _tokens_to_json(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(
{
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
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(
hass: HomeAssistant, configured_panel, hass_ws_client
) -> None: