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).
Replaces the read-only "conditions present but not editable" banner
with a real editor for the cond / cond2 u16 fields on TIMED / EVENT /
YEARLY programs.
Compact-form conditions split into five families per
clsText.GetConditionalText (clsText.cs:2224-2274):
none — cond = 0 (no inline condition)
misc — family 0x00, low nibble = enuMiscConditional
(NONE / NEVER / LIGHT / DARK / PHONE_* / AC_POWER_* /
BATTERY_* / ENERGY_COST_*)
zone — family 0x04, low byte = zone, bit 0x0200 = NOT_READY
unit — family 0x08, low 9 bits = unit, bit 0x0200 = ON
time — family 0x0C, low byte = time-clock #, bit 0x0200 = enabled
sec — family >= 0x10, bits 8-11 = area, bits 12-14 = security mode
types.ts gains decodeCondition / encodeCondition + the
MISC_CONDITIONALS / SECURITY_MODE_NAMES enums. Round-trip is exact:
decode(encode(c)) === c for every supported family.
UI: two condition slots per editor (matching the two u16 fields on
the wire). Each slot has a family-picker dropdown that swaps the
sub-fields (zone picker + secure/not-ready, unit picker + on/off,
area picker + security mode, time-clock # + enabled/disabled, misc
condition picker, or "none"). Picking a family seeds sensible defaults
(NEVER for misc, first zone secure, first unit ON, area 1 disarmed,
time clock 1 enabled).
Object pickers reuse the same _bucketWithPreserve helper introduced
for the action editor, so out-of-range zone/unit/area refs in inline
conditions keep their original value with a "preserve" label.
Live smoke test against the real panel: slot #1's actual condition
"AND IF Time clock 4 is disabled" now decodes into the editor as
Family=Time clock / # = 4 / Is = disabled — exactly the on-disk state.
Frontend bundle: 63 KB minified (up from 56 KB with the new editor
section + cond helpers).
Real-world programs reference object indexes well past the coordinator's
discovery range — typical example from a live OmniPro II: a program
that's "Turn ON Unit 33025" where the unit number is a raw byte value
from undecoded extended-output addressing. The discovered units bucket
only covers slots up to ~511, so 33025 doesn't match any entry.
Before this commit the dropdown silently fell through to the first
known unit (e.g. ROOM ONE), making it look like the user had selected
that unit. The underlying draft.pr2 stayed at 33025, but a user who
glanced at the form and clicked Save would either preserve the original
(if they didn't touch the select) or accidentally clobber it with the
first list item (if they did).
Fix: _bucketWithPreserve prepends a synthesized option
"(undiscovered <kind> <idx> — preserve original)" when the current
value isn't represented. Applies in all four picker sites:
* Action object picker (Unit / Zone / Area / Button for action commands)
* EVENT trigger Button picker
* EVENT trigger Zone picker
* EVENT trigger Unit picker
The synthesized entry sits at the top of the list (visually distinct)
and is the selected default. Picking any other entry from the dropdown
then becomes an explicit choice — no more silent coercion.
Smoke-tested against the real panel: slot #1 (WHEN OPEN BIG GAR →
Turn ON Unit 33025) now shows "#33025 (undiscovered unit 33025 —
preserve original)" as the selected Unit option. Screenshots updated.
Two bugs surfaced when smoke-testing against a real OmniPro II:
1. Empty list after page load. _discoverViaList ran fire-and-forget;
connectedCallback then synchronously checked _entryId (still null
because await hadn't resolved) and skipped _loadList. The panel
rendered "No programs match the current filters" forever — until
the next 5-second poll tick, which never fires because
_startRefreshTimer was also gated on the same null check.
Fix: have _discoverViaList itself trigger _loadList and
_startRefreshTimer after _entryId lands. The connectedCallback /
updated paths can stay gated on _entryId; the discover path now
takes ownership of "do the initial load too."
2. Dev installs with both a working entry and a setup_retry entry
(mock container down, real panel up) had the panel pick the
setup_retry one first and surface "panel not configured" on every
call. Fix: prefer entries with state === "loaded" in the discover
step, falling back to first entry only when none are loaded.
Also: screenshot.py drops the seed-via-WS step (was unsafe — would
write Programs to whatever entry is loaded, including real panels).
Updates the in-page click helpers to walk the shadow DOM recursively
instead of hardcoding HA's host-element path, so detail/editor
screenshots work on the actual depth-8 element location.
Smoke test against real panel: 154 programs render correctly with
structured English, BEDTIME / OPEN BIG GAR / Zone 133 events all
decoded, B. GAR MAN DOOR [SECURE] live-state badge visible.
Detail panel + editor mode both function end-to-end.
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).
Three new pieces compose into an inline edit mode for the side panel:
E1 — omni_pca/programs/write websocket command:
Accepts a Program dict (mirrors the dataclass field by field) plus
a slot. Validates with a voluptuous schema (range checks on each
byte field, prog_type 0..10), constructs the typed Program, calls
client.download_program over the wire. Updates coordinator.data
.programs on success so the next list call reflects the edit
before the next poll catches up. Returns {slot, written: true} on
success; structured errors on validation / not_supported / write_failed.
E2 — omni_pca/objects/list:
Returns sorted {index, name} entries for zones / units / areas /
thermostats / buttons sourced from the coordinator's discovered
topology. Frontend caches the response client-side; the topology
doesn't change unless the user reloads the integration.
E3 — Frontend TIMED editor:
Detail panel grows an "Edit" button for TIMED+compact programs
(other types stay read-only with no button). Click reveals an
inline form with:
* Time row — hour / minute number inputs
* Days row — 7 toggle buttons (Mon..Sun) matching the bitmask
* Action row — Command dropdown (friendly verbs from the
COMMAND_OPTIONS table), object picker that auto-filters to
the right kind for the selected command (zone / unit / area /
button / none), and a Level % input for UNIT_LEVEL specifically
* Read-only inline-conditions notice for programs that carry
cond / cond2 (editing condition fields is a future cut)
Save sends the draft via programs/write; Cancel discards.
The poll timer pauses while editing so the form values don't
flicker mid-edit.
Scope honesty: this pass edits TIMED programs only. Other types
(EVENT / YEARLY / WHEN / AT / EVERY / REMARK) remain read-only
with Fire / Clone / Clear available. Inline AND-IF condition editing
is deferred — the conditions render as a banner. Creating new programs
uses Clone (already shipped) → edit the clone.
The _fetchProgramFields function currently seeds from defaults (6:00
weekdays, UNIT_ON to first unit) rather than pulling raw fields from
the panel because the get-detail websocket response carries rendered
tokens but not raw bytes. That's a TODO marked inline; for the
clone-then-edit workflow the defaults are fine, but editing existing
programs in place will need a tiny backend addition.
4 new HA-integration tests covering write happy path, overwrite,
invalid payload validation, and objects/list returns named buckets.
Full suite: 647 passed, 1 skipped (up from 643, 4 new tests).
Frontend bundle: 47 KB minified (up from 38 KB with editor + form code).
The program viewer goes from read-only to write-capable. Three layers
land together because a partial implementation isn't actionable.
D1 — wire path:
* OmniClient.download_program(slot, program) — sends opcode 8
(clsOLMsg2DownloadProgram, clsHAC.cs:1133-1140) with the 2-byte BE
slot + Program.encode_wire_bytes(). Validates slot range 1..1500
client-side. Maps Ack → success, Nak → CommandFailedError, any
other opcode → OmniConnectionError.
* OmniClient.clear_program(slot) — convenience that writes an all-zero
body. Mock treats this as deletion (removes the slot from
state.programs) so subsequent reads see it as undefined.
* MockPanel handles DownloadProgram on the v2 dispatch path —
receive 2-byte slot + 14-byte body, store in state.programs, ack.
* OmniClientV1.download_program raises NotImplementedError. v1 only
has the bulk DownloadPrograms flow which clears everything before
rewriting — destructive for HA's edit-one-program use case.
Documented in the docstring so callers know to route v1 users to
a v2 connection.
Tests cover: write-then-read round-trip, overwrite of existing slot,
clear deletes the slot, range validation, v1 not-implemented.
D2 — HA websocket commands:
* omni_pca/programs/clear — writes zero body, updates coordinator.
data.programs immediately so the next list call shows the deletion.
Returns ``{slot, cleared: true}``. Maps NotImplementedError on v1
panels to the ``not_supported`` error code.
* omni_pca/programs/clone — copies source_slot → target_slot, with
the slot field re-stamped. Refuses identical source/target,
refuses missing source. Same coordinator update pattern.
5 new HA-integration tests covering clear, clone happy path, clone
to same slot, clone from missing source.
D3 — Clear/Clone UI in the side panel:
* "Clone…" button reveals an inline target-slot input (number,
1..1500). Enter or "Clone" button calls the WS command, then
navigates the detail panel to the new clone so the user sees the
result.
* "Clear" button shows an inline confirmation row ("Clear slot N?
This deletes the program from the panel.") with Yes/Cancel. Yes
closes the detail panel and refreshes the list — the slot is gone.
* Both surface feedback via the same _writeFeedback state used by
Fire now (auto-clears after 4 seconds).
* Three new button styles (.primary, .secondary, .danger) and the
.action-row composite used for both inline prompts.
What's NOT shipped here: a real visual editor for trigger/condition/
action fields. That's a follow-up (~600 lines of new TS + careful
validation work). The current "Cut 1" UX is enough for the common
"I accidentally created a program, clear it" and "I want a variant
of this program, give me a copy in an empty slot" workflows.
Full suite: 643 passed, 1 skipped (up from 634).
Frontend bundle: 38 KB minified (up from 34 KB with the write UI).
Phase C of the program viewer. Replaces the "panel coming soon" stub
with a real Lit-based side panel that consumes the Phase-B websocket
commands.
Layout (top to bottom):
* Header — title + total/filtered count
* Filter bar — search box (substring match), trigger-type chips
(TIMED / EVENT / YEARLY / WHEN / AT / EVERY / REMARK), a clearable
"filtering on <ref>" pill when an entity filter is active
* Two-column body: program list on the left, slide-in detail panel
on the right. Collapses to single-column on `narrow` view.
The program list renders one row per program (or per chain head, for
clausal multi-record programs). Each row carries the slot number,
the rendered one-line summary token stream, and meta pills for
trigger type / condition count / multi-action count.
The detail panel renders the full structured-English token stream
inside a styled <pre>. A "Fire now" button calls
``omni_pca/programs/fire`` over the wire — the panel actually
runs the program. For chain detail the spanned slot range is shown
underneath.
REF tokens are rendered as `<button>` elements that click to filter
the list to "programs that mention this entity" — the most useful
navigational affordance for the "why is this happening?" use case.
Live-state badges (SECURE / NOT READY / ON 60% / Away / 72°F / …) are
appended to REF tokens via the Phase-B coordinator-backed
StateResolver. The panel polls ``programs/list`` every 5 seconds to
refresh badges; switching to push-event subscriptions is a follow-up
when polling overhead becomes visible.
Theming uses HA's standard CSS variables (--primary-color,
--card-background-color, --divider-color, etc.) so the panel inherits
the user's HA theme automatically.
Build pipeline:
* TypeScript source under ``custom_components/omni_pca/frontend/src/``
* esbuild bundles entry → ESM in one self-contained file
* Output at ``custom_components/omni_pca/www/panel.js`` (~34 KB
minified) is committed so end-users don't need Node installed
* ``npm run watch`` for HA-dev-time iteration
* tsconfig has strict mode + noUnusedLocals; bundle currently
type-checks clean
Manifest declares deps on ``http`` and ``websocket_api``; ``frontend``
and ``panel_custom`` are loaded opportunistically (they require
``hass_frontend`` which the test harness doesn't ship — keeping them
out of the manifest deps keeps tests green).
Full suite: 634 passed, 1 skipped (no test changes; the integration
side hasn't moved since Phase B).