9 Commits

Author SHA1 Message Date
9ca4da98e8 panel: clausal chain editor (WHEN/AT/EVERY + AND/OR/THEN)
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
Multi-record clausal programs are now editable end-to-end. A chain
spans N consecutive slots — head (WHEN/AT/EVERY) + zero-or-more
AND/OR condition records + one-or-more THEN action records — so
"editing" means rewriting the whole run, validating that any
expansion doesn't trample adjacent programs, and clearing any old
slots when the chain shrinks.

H1 — backend:

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

H2 — TS helpers:

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

H3 — chain editor UI:

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

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

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

Full suite: 653 passed, 1 skipped (up from 648, 5 new chain tests).
Frontend bundle: 82 KB minified (up from 63 KB).
2026-05-17 02:09:04 -06:00
5870e2f7ee panel: inline AND-IF condition editor for compact-form programs
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
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).
2026-05-17 01:43:32 -06:00
6f92671cf2 panel: preserve out-of-range object refs in editor dropdowns
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
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.
2026-05-16 19:55:16 -06:00
4781f4d276 panel: trigger initial loadList from discover, prefer loaded entries
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
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.
2026-05-16 17:48:17 -06:00
14d16a5a4c 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).
2026-05-16 12:20:21 -06:00
e6308c5624 program editor — Cut 2: TIMED program edit UI
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
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).
2026-05-16 01:33:55 -06:00
9cdb312baf program writeback: DownloadProgram wire + HA write API + Clear/Clone UI
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
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).
2026-05-16 01:14:54 -06:00
f38777e219 HA: side panel frontend (Lit/TypeScript) for the program viewer
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
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).
2026-05-15 21:21:41 -06:00
ce87ebcb13 HA: websocket commands + side-panel registration
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
Phase B of the program viewer. Three websocket commands and a stub
side-panel registration wire the HA integration to consume the
program_renderer library.

Websocket commands (all namespaced ``omni_pca/programs/``):

* ``list``  — paginated, filterable summaries. Filters: trigger_types
  (TIMED / EVENT / YEARLY / WHEN / AT / EVERY), references_entity
  (e.g. ``"unit:7"``), case-insensitive substring search. Each row
  carries summary tokens + a flat ``references`` list for filter UI.
* ``get``   — full structured-English detail for a slot. Clausal
  chains return as one logical unit even when the user clicked an
  interior slot.
* ``fire``  — sends ``Command.EXECUTE_PROGRAM`` over the wire so the
  panel runs the program now. Returns ``{slot, fired: true}`` on
  success or a structured error.

Token serialisation uses short keys (k/t/ek/ei/s) for compact wire
format — the panel's 1500-slot table on a busy install fits in a few
hundred KB of JSON.

Coordinator-backed resolvers:

* ``_CoordinatorNameResolver`` — pulls names from data.zones / units /
  areas / thermostats / buttons (HA-side ZoneProperties etc.)
* ``_CoordinatorStateResolver`` — pulls live state from *_status maps
  so every websocket call sees the freshest available overlay without
  round-tripping the panel. SECURE / NOT READY / BYPASSED for zones,
  OFF / ON / ON 60% for units, Day / Night / Away for areas,
  °F for thermostats.

Side-panel registration: ``async_register_side_panel`` registers a
custom panel under ``Omni Programs`` in HA's sidebar with a
``mdi:script-text-outline`` icon. Bundle is served at
``/api/omni_pca/panel.js`` via a static-path registration. A
working stub panel.js ships now so the wiring is exercisable;
Phase C will drop the real Lit/TS bundle into the same path.

Panel registration is wrapped in a try/except + a once-per-HA-boot
guard so test environments without ``hass_frontend`` installed don't
break the rest of the integration. The manifest only lists ``http``
and ``websocket_api`` as hard dependencies for the same reason —
panel_custom is opportunistic.

10 new HA-integration tests cover list/get/fire end-to-end plus
filters, pagination, search, live-state overlay, and structured-error
returns for bad entry_id / missing slot.

Full suite: 634 passed, 1 skipped (up from 624).
2026-05-14 03:07:00 -06:00