Replaces the read-only "structured comparison" banner with a real
editor. Structured AND records encode ``Arg1 OP Arg2`` where Arg1 is
a typed reference (Zone / Unit / Thermostat / Area / TimeDate) plus a
per-type field selector, and Arg2 is either another typed reference
or a literal constant.
I1 — TS types + decoders:
Wire layout (programs.py decoders, clsProgram.cs):
cond high byte = and_op (CondOP: 1=EQ, 2=NE, 3=LT,
4=GT, 5=ODD, 6=EVEN, 7=MULT,
8=IN, 9=NOT_IN)
cond low byte = and_arg1_argtype (CondArgType)
cond2 (whole) = and_arg1_ix (object idx; 0 for TimeDate)
cmd = and_arg1_field (per-type field selector)
par = and_arg2_argtype (Constant most common)
pr2 = and_arg2_ix (constant value or 2nd obj idx)
month = and_arg2_field
day,days = and_compconst (BE u16; usually 0)
decodeStructuredAnd / encodeStructuredAnd handle both directions;
round-trip exact.
Per-Arg1Type field menus in FIELDS_BY_TYPE — exact 1:1 with the
Python enuZoneField / enuUnitField / enuThermostatField /
enuTimeDateField enums in omni_pca.programs and the field handling
in StateEvaluator. Areas only expose "Security mode" (single useful
field). TimeDate exposes Year / Month / Day / DoW / Time / Hour /
Minute (skips the rarely-used Date / DST / SunriseSunset fields).
I2 — editor UI:
isEditableStructuredAnd guard: only opens the editor for records
matching the editor's scope (Arg1 in supported types, Arg2=Constant,
compConst=0). Out-of-scope structured records render with a
"read-only" tag — preserved on save, still removable.
Structured rows render with a "structured" tag and an orange-tinted
background to distinguish them from Traditional rows. Layout:
Arg1 type ▸ object picker ▸ Field ▸ Operator ▸ Compare against
Unary operators (ODD / EVEN) hide the Arg2 input. Changing Arg1 type
resets the Arg1 index + field to defaults so the form stays self-
consistent (no stale picker values from a previous type).
Arg2 is locked to Constant in this pass. Editing record-vs-record
comparisons (e.g. "Thermostat 1 temp > Thermostat 2 temp") is a
future cut — current real-world programs use the Constant form
exclusively per my homeowner-panel sample.
_pickBucket gains the missing "thermostat" branch (was missed in
earlier passes; only mattered now that thermostat is an Arg1Type).
Live screenshot 12-structured-and.png shows an injected chain with
both a Traditional AND (CTRL UNIT 1 ON) and a Structured AND
(Thermostat(1).Temperature > 70) — both editable end-to-end.
Frontend bundle: 88 KB minified (up from 82 KB).
Full suite: 653 passed, 1 skipped (no test changes).
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).
Bundles the program viewer side panel (Lit/TS), program writeback API
(DownloadProgram + Clear/Clone), and the manifest.json documentation
URL fix (now points at hai-omni-pro-ii.warehack.ing instead of the
repo, matching pyproject.toml).
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).
Re-tags from a tree that includes the brand assets — v2026.5.11 was
tagged before brand/icon.png landed, so the HACS submission-side
validator saw the tag as brand-less.
Library + integration version bumped to 2026.5.14; manifest requirement
pinned to the matching PyPI build. CHANGELOG entry covers everything
since 2026.5.10: SetupData decoding sweep, AND/OR evaluator, EVENT
taxonomy, WHEN/AT/EVERY clausal chains, brand inline, HACS+hassfest
workflow, GitHub URL switch, websocket side-panel, program_renderer.
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).
Phase A of the HA-side program viewer: a Python rendering library that
turns Program records and ClausalChains into structured-English text,
modeled on PC Access's program editor:
WHEN OPEN BIG GAR is pressed
AND IF BIG GARAGE DOOR is secure
THEN Turn ON BGD
Output is a Token stream rather than a flat string so the HA frontend
can identify object references (REF tokens carry entity_kind + entity_id
for navigation) and overlay live state (REF tokens carry an optional
state string consumers render as "Front Door [SECURE]").
Resolver protocols (NameResolver, StateResolver) decouple the renderer
from any specific state model. Two adapters ship:
* AccountNameResolver — wraps a PcaAccount for offline .pca rendering
* MockStateResolver — wraps a MockState; implements both protocols,
produces sensible live-state labels (SECURE / NOT READY / BYPASSED
for zones; OFF / ON / ON 60% for units; Day / Night / Away for
areas; "72°F" for thermostats)
Both compact-form programs (TIMED / EVENT / YEARLY / REMARK / FREE)
and multi-record clausal chains (WHEN / AT / EVERY + AND / OR / THEN)
render correctly. Each supports a one-line summary form for list
views and a multi-line full form for detail panels.
Decode coverage matches StateEvaluator's:
* Traditional ANDs: ZONE secure/not-ready, CTRL on/off, TIME enabled/
disabled, SEC area-mode, OTHER (NEVER, LIGHT/DARK, AC_POWER_*, etc.)
* Structured ANDs: Arg1 OP Arg2 with full field-label decoration
("FRONT_DOOR.CurrentState == 1", "DOWNSTAIRS.Temperature > 75")
* Event IDs: USER_MACRO_BUTTON, ZONE_STATE_CHANGE, UNIT_STATE_CHANGE,
AC_POWER_*, PHONE_* — natural-language phrases per category
* Commands: friendly verbs (Turn ON, Bypass, Set level X% to Y, Arm
Away, etc.) with object-kind-aware reference rendering
Live-fixture smoke test renders all 330 real programs from the
homeowner's .pca with zero errors. Real button-press programs come
out reading like English documentation of what they do.
Full suite: 624 passed, 1 skipped (up from 581, 43 renderer tests).
Custom components since HA 2026.3.0 can serve brand assets directly from
custom_components/<domain>/brand/, bypassing the home-assistant/brands
repository entirely. HACS validator reads this path before falling back
to the brands repo.
Icons rendered from hai-omni-docs's favicon.svg — abstract control panel
with LED indicators and screen lines. No HAI/Leviton trademark artwork
since this is an unofficial integration.
dev/brand/ keeps the source PNGs (non-interlaced) for re-rendering.
- manifest.json: keys reordered to domain, name, then alphabetical
- strings.json + translations/en.json: rephrase user-step description
without backticks/angle-brackets (hassfest rejects HTML in i18n strings)
- __init__.py: add CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
since async_setup exists but the integration is config-entry-only
StateEvaluator decodes AND/OR records against MockState. ProgramEngine
.use_state_evaluator() installs one bound to the engine's panel + clock
+ location. Replaces the stub that always-passes-AND-always-fails-OR.
Traditional (OP=0) decode follows clsConditionLine.Cond synthesis
(clsConditionLine.cs:17-33): disk byte 1 (= and_family) carries the
compact GetConditionalText family byte, disk bytes 3-4 (and_instance
from cond2>>8) carry the object index. Family decoding mirrors
clsText.GetConditionalText (clsText.cs:2224-2274):
family & 0xFC == 0x00 → OTHER (low 4 bits = MiscConditional)
family & 0xFC == 0x04 → ZONE (bit 0x02 = NOT_READY, else SECURE)
family & 0xFC == 0x08 → CTRL (bit 0x02 = ON, else OFF)
family & 0xFC == 0x0C → TIME (no MockState model → False)
family >= 0x10 → SEC (high nibble = mode, low = area)
Structured (OP > 0) decode uses Arg1 OP Arg2 with both sides resolved
via _resolve_arg(argtype, ix, field). MockState-backed resolution:
ZONE → loop / current_state / arming_state / latched_state
UNIT → on/off byte / time_remaining / dim level
THERMOSTAT → temp / setpoints / modes / humidity
AREA → mode
TIME_DATE → (clock-derived) year / month / day / DoW / hour /
minute / time-of-day-in-minutes
CondOP supported: EQ / NE / LT / GT / ODD / EVEN / MULTIPLE / IN /
NOT_IN. Unknown argtypes or fields raise _UnsupportedCondition
internally — the evaluator swallows it and returns False, keeping the
chain *guarded* rather than firing too eagerly.
LIGHT/DARK MiscConditional uses astral via the engine's PanelLocation
when set. When location is missing, returns False either way (don't
fire if we can't determine).
15 new tests covering each evaluator branch (Traditional ZONE secure/
not-ready/undefined, CTRL on/off/dimmed, SEC mode-match, OTHER NEVER/
DARK; Structured Zone.CurrentState EQ/NE, Thermostat.Temp GT/LT,
TimeDate.Hour/DOW EQ, TimeDate-without-clock) plus end-to-end engine
integration showing use_state_evaluator() gates a real WHEN+AND chain
and the OR-alternative path works against real state.
Full suite: 581 passed, 1 skipped (up from 563, 82 engine tests total).
Final phase of the autonomous program-execution engine. Multi-record
clausal programs (firmware ≥3.0.0) now run end-to-end:
* WHEN-headed chains dispatch through emit_event() — same code path as
raw EVENT programs, but with optional AND/OR condition guards.
* AT-headed chains schedule like TIMED (absolute or sun-relative).
* EVERY-headed chains fire on a recurring interval (every_interval
seconds — the unit derivation matches the existing programs.py
decode).
New types:
* ClausalChain dataclass — (head, conditions, actions). Built once at
engine construction; engine.chains exposes the list.
* build_chains(programs) walks a slot-ordered Program tuple,
grouping adjacent multi-record records into chains. Stops at the
next clausal head, a non-clausal record, or an empty slot. Drops
chains with no THEN action (they have no effect).
* evaluate_conditions(cs, is_satisfied=fn) — AND-of-OR-groups
evaluator. Empty conditions tuple is True; OR records start a new
group; within a group all ANDs must satisfy; overall True iff any
group satisfies. The detailed semantic decode of each AND/OR record
(zone-state checks, structured TEMP>70-style ops, …) is deferred
to a follow-up — for now ``is_satisfied`` is the integration hook
callers supply.
ProgramEngine.set_condition_evaluator(fn) lets tests / HA plug in a
state-aware evaluator. The default is a stub that passes ANDs and
fails ORs — a usable smoke-test default, deliberately not a real one.
14 new tests covering chain construction (single chain, with conditions,
with multiple THENs, adjacent chains, missing-THEN drop, non-clausal
boundary), the condition evaluator (empty/all-AND/AND-fail/OR-group
separation), and end-to-end execution (WHEN chain dispatch, condition
blocking, custom evaluator, AT chain schedule, EVERY chain interval).
With this the engine implements every program type the panel firmware
exposes — TIMED / EVENT / YEARLY compact-form plus WHEN / AT / EVERY +
AND / OR / THEN clausal. MockPanel + ProgramEngine + .pca decode +
MockState.from_pca composes into a complete "run any panel's programs
autonomously" sandbox.
Full suite: 563 passed, 1 skipped (up from 549, 64 engine tests total).
EVENT programs (ProgType=2) now fire in response to panel events.
Unlike TIMED/YEARLY there's no per-program asyncio worker — EVENT
programs sit in an {event_id → [Program]} dispatch table built at
engine.start(). External code calls engine.emit_event(id) to dispatch.
Event-ID encoding mirrors clsText.GetEventCategory (clsText.cs:1585-...).
The 16-bit ID's high bits select a category; low bits encode the
specific object number / state. Phase 4 ships helpers for the three
practically-common categories plus the hand-rolled fixed-ID events:
event_id_user_macro_button(b) — 0x0000..0x00FF
event_id_zone_state(z, st) — 0x0400 | (((z-1)*4 + st) & 0x3FF)
event_id_unit_state(u, on) — 0x0800 | (((u-1)*2 + on) & 0x3FF)
EVENT_PHONE_DEAD / _RINGING / _OFF_HOOK / _ON_HOOK = 768..771
EVENT_AC_POWER_OFF / _ON = 772 / 773
emit_event(id) returns the count of programs that fired; convenience
methods emit_user_macro_button / emit_zone_state / emit_unit_state
wrap the encoders. emit_event before start() is a no-op (no event
table built); after stop() the table clears, so another start()
rebuilds from the current panel.state.programs.
13 new tests covering the helpers' bit layouts, range checks, single-
program dispatch, no-match silent no-op, before-start no-op, multiple
programs on the same event, zone-state state-filtering, and a smoke
test against AC_POWER_OFF as a sample fixed-ID event.
Full suite: 549 passed, 1 skipped (up from 539).
YEARLY programs (month/day match at hour:minute, regardless of weekday)
now fire autonomously. Each YEARLY slot gets its own asyncio worker
running sleep-until-next-fire / fire / loop, just like TIMED.
Sunrise/sunset support lands in the same phase. New PanelLocation
dataclass wraps a geographic position; PanelLocation.from_account(acct)
builds one from the .pca's latitude/longitude/time_zone, flipping the
longitude sign to match astral's east-positive convention.
The engine's __init__ accepts an optional location=PanelLocation.
TIMED programs whose hour byte hits the AT_SUNRISE (25) or AT_SUNSET
(26) sentinels resolve against astral's computed sunrise/sunset on
each candidate day, applying the program's signed minute offset
(time_offset_minutes). Without a location supplied, sun-relative
programs are silently skipped — the same effect as an empty Days mask.
_next_yearly_fire validates the (month, day) combination — Feb 30 etc.
return None and the program never fires (matches real-panel range
checks).
12 new tests covering:
* PanelLocation longitude sign-flip and timezone derivation
* _next_yearly_fire for today / next-year rollover / disabled / invalid
* YEARLY worker end-to-end + multi-year loop
* _next_sun_relative_fire AT_SUNSET / sunrise-with-offset / no-days
* engine sun-relative skip without location
* engine sun-relative fires correctly with location
Full suite: 539 passed, 1 skipped (up from 527).
First half of the autonomous program-execution engine. Two phases land
together because Phase 1 was pure scaffolding (Clock + classifier)
and made little sense in isolation.
Phase 1 — engine foundation:
* Clock protocol with RealClock (wall time + asyncio.sleep) and
FakeClock (manual advance, no real waiting; sleepers wake in
chronological order on advance_to).
* classify(programs) splits a Program tuple into timed / event /
yearly / clausal-head buckets, dropping FREE / REMARK / unknown
records and the AND/OR/THEN clausal continuations (those are
reached by walking forward from each WHEN/AT/EVERY head, not by
classification).
* ProgramEngine class with start / stop lifecycle (idempotent +
context-manager), per-program asyncio task list, _EngineMetrics
counters.
Phase 2 — TIMED programs actually run:
* _next_absolute_fire(now, program) computes the next datetime at
which a TIMED program with TimeKind.ABSOLUTE should fire, given
its hour/minute/days mask. Walks forward up to 8 days; returns
None for empty Days mask (program is effectively disabled).
* Each TIMED program gets its own asyncio task running
sleep-until-next-fire / fire / loop. Firing dispatches the
4-byte Command wire payload (cmd / par / pr2) through
MockPanel._handle_command — same code path the v2 Command opcode
uses, so a TIMED program turning on a unit produces identical
state to a client sending the equivalent Command.
* astral added as an [engine] optional dependency, pinned to 2.2
for HA compat (HA itself pins astral==2.2). Library wired up but
not yet consumed — sunrise/sunset support lands in Phase 3.
Tests (28 new):
* RealClock and FakeClock behaviour incl. chronological wake order.
* classify against each ProgramType, unknown values, empty input.
* Engine lifecycle (idempotent start/stop, context manager,
malformed-record tolerance).
* End-to-end: TIMED UNIT_ON program fires at the right Monday 06:00,
loops correctly across weeks, never fires outside its Days mask,
ignores programs with empty Days mask.
Full suite: 527 passed, 1 skipped (up from 499).
Closes out the per-object property triad. These three fields live
deep in the installer section past the zone-area / button-area-group
arrays (clsHAC.cs:3290-3416):
3330..3393: Thermostats[1..64].Areas (area-membership bitmask)
3397..3460: Thermostats[1..64].Type (raw enuThermostatType)
3553..3728: Zones[1..176].ZoneOptions (raw options byte)
Offsets derived from the OMNI_PRO_II CAP constants (numConsoles=16,
numTstats=64, numDCMCodes=16, numMessageGroups=16, numSerialPorts=6,
numSCI+numUART=5) plus its feature set — SuperviseBell +
SuperviseExteriorSounder + ZoneResistors + Addressable + UPB all
present, contributing exactly 9 conditional bytes before
ReportBypassRestore. Verified empirically: ZoneOptions is a clean
176-byte run of the default value 4, bounded by CrossZoneTimer=60
as the canary byte just before it.
New PcaAccount fields: zone_options, thermostat_types,
thermostat_areas. MockZoneState gains `options`, MockThermostatState
gains `thermostat_type` + `areas`. The mock's zone and thermostat
Properties replies now serve the real values instead of the
hardcoded 0 / 1 they used before — so HA discovery against
MockState.from_pca gets the complete per-object property set.
Live fixture: all 176 zones at the default options=4, both named
thermostats type 1, thermostat areas 0xFF (all) → normalised to
area 1 in the mock (consistent with the unit-area handling).
With this the OMNI_PRO_II SetupData decode is functionally complete
for every per-object property a consumer would want — zones, units,
areas, thermostats all carry type + area + options sourced from the
file rather than faked.
Full suite: 499 passed, 1 skipped.
Final SetupData sweep. Everything still walked-but-discarded is now
captured.
Telephony / dialer (user-section head + installer):
telephone_access, answer_outside_call, remote_commands_ok,
rings_before_answer, dial_mode, my_phone_number (PII, repr=False),
callback_number.
Misc panel scalars:
high_security, freeze_alarm, flash_light_num (BE u16 — the X10 unit
flashed on alarm), announce_alarms, house_code, zone_expansions,
num_exp_enc, num_thermostats, exterior_horn_delay, dialout_delay,
verify_fire_alarms, enable_console_emg, time_format, date_format,
ac_power_freq, dead_line_detect, off_hook_detect.
DCM (Digital Communicator Module) — the alarm-dialer block — as a
new DcmConfig dataclass: primary/backup phone numbers (PII,
repr=False) + account IDs, dcm_type, supervisory test schedule +
code, the 176-entry per-zone alarm-code table, and the 8 emergency
event codes (Freeze/Fire/Police/Aux/Duress/BatteryLow/FireZone/Cancel).
The phone-number strings use clsHardwareArray.ReadString — a
fixed-width MaxLength+1 slot whose content runs until the first 0xFF,
with no length prefix (distinct from the String8 used by Names).
New _read_hw_string helper handles that format. "-" is the panel's
blank-number sentinel.
Live fixture decodes coherently: telephone access on, 8 rings before
answer, panel's own number "208-854-7071", HouseCode A, 64
thermostats, fire-alarm verification on. DCM is unconfigured for
central-station monitoring (blank "-" numbers, 0xAAAA default
account IDs) but the per-zone alarm-code table is fully populated.
With this, the OMNI_PRO_II SetupData block is essentially fully
decoded — every field clsHAC._ParseSetupData reads up through the
zone-area / button-area-group arrays is now surfaced on PcaAccount.
Full suite: 499 passed, 1 skipped.
The PCA03 post-Connection extension was previously walked as opaque
bytes — _walk_to_remarks read past AccountRemarks_Extended and the
nine 33-byte-per-slot Description tables only to advance the cursor
to the actual Remarks dict.
This pass keeps the data instead. New PcaAccount fields, all populated
only when FileVersion >= 3:
* account_remarks_extended — free-text installer notes (repr=False)
* zone/unit/button/code/thermostat/area/message/audio_source/audio_zone
_descriptions — per-family {slot: description} dicts
Per-slot format inside each Description block is the same String8(32)
that names use elsewhere: 1 length byte + 32 padded bytes, decoded
to UTF-8 with NUL-strip. The leading u32 count can exceed the family's
actual object count (real panels write the max-slot count regardless
of how many are populated); we read all of them and filter empties.
Live fixture decodes cleanly: every Description table is empty
(homeowner never filled them in — that's reality, not a parser
fault). The hand-built synthetic test in test_pca_file proves the
decode works when the data is actually present (zones 1+2 with
descriptions "FOYER!" and "GARAGE LT").
_walk_to_remarks now returns a _RemarksWalk dataclass aggregating
all of the post-Connection extraction; existing remarks-related
tests updated to use the new return shape.
Full suite: 499 passed, 1 skipped.
Three single-byte scalars sandwiched between the TimeClocks block and
AnnounceAlarms (clsHAC.cs:3064-3066). Raw bytes — no N/S/E/W modifier
at this position (those live in the optional WorldWideLatitude feature
block past DST). TimeZone is hours west of UTC on OMNI_PRO_II.
Live fixture decodes as 44°N / 117°W / TZ 7 (Pacific Daylight) —
matches a real northern-US install on the panel time.
PcaAccount gains latitude / longitude / time_zone (int each).
Walker reads them via the existing _read_scalar_byte helper.
Full suite: 499 passed, 1 skipped.
Three more SetupData fields, plus a refinement that uses one of them:
* HouseCodes.EnableExtCode[1..16] at user-section offset 1917 —
one enuHouseCodeFormat byte per 16-unit X10 group. Live fixture:
HouseCode 1 = HLC (5), HouseCodes 2..16 = Extended (1).
* Six 5-byte clsWhen structs at 1863..1892 — TimeClock 1/2/3 On/Off
schedules. Exposed as a new TimeClock dataclass tuple. Live fixture
TC1 = On 22:30 → Off 06:00 daily (outdoor-lights pattern).
* InstallerCode (BE u16 @ 2995), EnablePCAccess (bool @ 2997),
PCAccessCode (BE u16 @ 2998) — both codes are PII so repr=False.
Refinement: unit_types for X10 units now resolves the specific
sub-type via the HouseCodeFormat table instead of the previous
collapsed Standard. Mapping mirrors clsUnit.CalculateUnitType
(clsUnit.cs:928-999) including the (Number-1)%8==0 split for HLC
(HLCRoom vs HLCLoad) and ZWave (ViziaRoomController vs ViziaLoad).
Live fixture now reports the right thing: unit 1 (ROOM ONE) is
HLCRoom (5), units 2-8 (FRONT PORCH, PENDANT LTS, …) are HLCLoad (6),
unit 9 is HLCRoom again, etc. Units 17-256 under HouseCodes 2..16 are
all Extended (2). Total: 2 HLCRoom + 14 HLCLoad + 240 Extended +
136 Output + 119 Flag = 511 unit slots, matches numUnits exactly.
Full suite: 499 passed, 1 skipped.
Four more SetupData fields landed in one pass. The user-section walk
past the previously-mapped 5 contiguous area flags continues with 70
bytes of intervening config (HighSecurity/FreezeAlarm/FlashLightNum/
HouseCodes flags × 32 / 6 TimeClock When-structs / Latitude/Longitude/
TimeZone/AnnounceAlarms) to reach:
1897..1904: PerimeterChime[1..8] (bool[8])
1905..1912: AudibleExitDelay[1..8] (bool[8])
1913..1916: DSTStartMonth/Week, DSTEndMonth/Week (4 scalar bytes)
Live fixture DST decodes as US-standard (March / 2nd Sunday →
November / 1st Sunday). Area-1 PerimeterChime is OFF (homeowner
disabled), the panel default for unused areas 2-8 is ON.
Unit type + area assignment, derived from CAP index ranges and the
AreaGroups bitmap arrays at installer offsets 3035..3105
(clsHAC.cs:3242-3289):
X10 units (1..256) → enuOL2UnitType.Standard (1),
16 units per AreaGroups byte
ExpEnc (257..384) → Output (13), 4 units per byte
VoltOut (385..392) → Output (13), 1 byte per unit
FlagOut (393..511) → Flag (12), 8 flags per byte
The X10 sub-types (Standard/Extended/HLC/UPB/ZWave/…) collapse to
Standard since deriving them needs the HouseCodes EnableExtCode table
which we don't decode yet. Live fixture all-511-units classify
correctly: 256 X10 + 128 ExpEnc + 8 VoltOut + 119 FlagOut.
Unit areas are 8-bit membership bitmasks. The live fixture has 0xFF
everywhere ("panel default — all 8 areas"); from_pca normalises that
to 0x01 ("area 1 only") so the mock's Properties reply gives HA a
single sensible area instead of bit-set noise.
Code PINs (offset 383, 99 × 14-byte entries). Per-entry layout:
bytes 0..1: PIN (BE u16; plain 4-digit 0..9999)
byte 2: Authority (enuCodeAuthority: 0=Disabled, 1=User,
2=Manager, 3=Installer)
byte 3: Areas bitmask
bytes 4..13: WhenOn + WhenOff (2 × clsWhen)
PINs are PII — ``PcaAccount.code_pins`` is marked ``repr=False`` so
a stray ``print(acct)`` can never leak them into logs. They aren't
auto-threaded to MockState.user_codes either; tests set their own
PINs explicitly. Live-fixture decode is sane: COMPUTER=4932/User,
HOMEOWNER=1234/User, Kevin=3411/User, Debra=0000/Manager, etc.
MockAreaState gains perimeter_chime + audible_exit_delay.
MockUnitState gains unit_type + areas (and the Properties reply
serves the configured values now).
Full suite: 499 passed, 1 skipped.
SetupData side (clsHAC.cs:3020-3038): five contiguous bool[8] arrays
immediately after ExitDelay carry per-area config flags. Offsets:
1787..1794: EntryChime
1795..1802: QuickArm
1803..1810: AutoBypass
1811..1818: AllOnForAlarm
1819..1826: TroubleBeep
Verified against live fixture: area 1 shows real homeowner choices
(QuickArm + AllOnForAlarm enabled, others off), unused areas 2-8 carry
the panel defaults (EntryChime/AutoBypass/TroubleBeep on by default).
PerimeterChime and AudibleExitDelay aren't in this contiguous block —
they live past FlashLightNum, HouseCodes flags, and 6 TimeClock
When-structs. Deferred.
New PcaAccount fields:
area_entry_chime, area_quick_arm, area_auto_bypass,
area_all_on_for_alarm, area_trouble_beep — all dict[int, bool].
MockAreaState gains the same five fields. They aren't carried in the
Properties reply on the wire (the OL2 message format doesn't have
them), so they live on MockState for snapshots and any future
SetupData-aware code, but don't surface through HA discovery yet.
v2 client list_area_names fallback: when the Properties walk turns up
no named areas (common — most homes don't name them), synthesize
"Area 1".."Area 8" so HA's _discover_areas has slots to walk.
Mirrors the v1 adapter behaviour exactly.
Knock-on win in the live-fixture HA test: area 1 now reaches
coordinator.data.areas with its configured 60s/90s delays from
SetupData, end-to-end through .pca → MockState → wire Properties →
HA's AreaProperties parser.
Full suite: 499 passed, 1 skipped.
Three more SetupData fields, varying in difficulty:
* Entry/exit delays per area — in the user section, behind 280 bytes
of Phone[8] config and 1386 bytes of Codes[99]. Derived offsets by
counting fixed-width fields out from Seek(1): EntryDelay[1..8] at
offset 1771, ExitDelay[1..8] at 1779. Verified against live fixture
(area 1: entry=60s, exit=90s; unused areas: 15s/15s panel defaults).
* TempFormat at installer offset 2993 — single byte, enuTempFormat
(1=F, 2=C). Live fixture = 1 (US install).
* NumAreasUsed at installer offset 3034 — count of installer-enabled
security areas. Live fixture = 1 (single-area home).
PcaAccount now carries area_entry_delays, area_exit_delays, temp_format,
num_areas_used. MockAreaState gains entry_delay/exit_delay/enabled
fields; mock _build_area_properties serves the configured values
(was hardcoded 60/30/Enabled).
MockState.from_pca now synthesizes per-area MockAreaState entries
for the union of named areas + (1..num_areas_used), filling in delays
and enabled flag. This means a single-area install with no
user-assigned name still surfaces area 1 with the correct config —
matching what an installer would see in PC Access.
(HA's coordinator only enumerates named areas via list_area_names,
so the area properties don't yet reach the diagnostic surface for
unnamed-but-in-use areas. That's a separate filter to revisit; the
data flow through pca_file → MockState → wire Properties reply is
already correct.)
Full suite: 499 passed, 1 skipped.
Walks the OMNI_PRO_II installer section past ZoneType, DCM stuff,
thermostat config, and the X10/VoltOut/FlagOut/ExpEnc area-group
arrays to land on the 176-byte Zones[].Area block at offset 3106.
The path from instSetupStart (2560) to zone area:
ZoneType[176] → DCM phones/accounts/type/test(5-byte clsWhen) →
DCMAlarmCode[176] → 8 DCM bytes → TempFormat..NumAreasUsed (29 bytes
of misc config including 25-byte CallBackNumber) → X10 area groups
(16) → VoltOut (8) → FlagOut (15) → ExpEnc (32) → Zones[].Area (176).
Total preamble within installer section = 546 bytes. Verified against
the live fixture: 176 zones all assigned to area 1 (single-area
install), matches expectation.
PcaAccount.zone_areas now carries {slot: area_number}; MockState.from_pca
threads it through MockZoneState.area; mock _build_zone_properties already
serves it. End-to-end test verifies the area flows through to
coordinator.data.zones[*].area.
This was the largest single-RE jump in SetupData decoding so far — got
us past the variable-length DCM block by counting fixed-width fields
out from the known ZoneType end. The clsWhen=5-byte struct was the
last unknown; derived from clsHardwareArray.ReadWhen (clsHardwareArray
.cs:456-468).
Full suite: 499 passed, 1 skipped.
SetupData (3840 bytes) holds the panel's per-object property tables.
Layout for OMNI_PRO_II's installer section (Seek to instSetupStart=2560
in clsHAC._ParseSetupData at clsHAC.cs:3156):
offset 2560: HouseCode (1 byte)
offsets 2561..2569: OutputType[0..8] (9 bytes; numVoltOutputs)
offset 2570: ZoneExpansions (1 byte)
offset 2571: NumExpEnc (1 byte)
offsets 2572..2747: ZoneType[1..176] (176 bytes; raw enuZoneType per zone)
Verified against the live fixture: 2 EntryExit + 4 Perimeter + 3 AwayInt
+ 1 Extended_Range_OutdoorTemp + 166 Auxiliary (panel default for
unused slots) — matches the named-zones cross-reference exactly.
PcaAccount gains a zone_types dict (1-based slot → raw byte). The
walker stashes the SetupData blob to a buffer up front and indexes
in by offset rather than chasing the sequential parser through all
of telephony/codes/areas — that's a bigger RE pass for another day.
MockZoneState now carries zone_type and area fields. MockState.from_pca
threads acct.zone_types through, and _build_zone_properties uses the
real value instead of hardcoded 0 (EntryExit). End-to-end against
MockPanel.from_pca: HA's discovery now classifies binary vs. analog
zones correctly straight from the .pca — outdoor temp zone surfaces
as a temperature sensor entity, motion sensors as binary_sensor,
door zones as the right kind of binary_sensor.
Full suite: 499 passed, 1 skipped. RE notes in pca_file.py.
The Names block (between SetupData and Voices) was previously walked
as opaque bytes. It's actually a sequence of seven object-family
tables, each storing N × String8(L) per the
clsAbstractNamedItem.ReadName / clsPcaCryptFileStream.ReadString8(out S, byte L)
pattern. Per-slot layout is [1 byte actual length][L bytes name],
with length 0 meaning "unused".
New PcaAccount fields:
* zone_names, unit_names, button_names, code_names,
thermostat_names, area_names, message_names
— each is {1-based slot: name}, only non-empty slots.
Object *properties* (zone_type, area_membership, etc.) aren't
extracted yet — those live in SetupData, which remains opaque.
Names alone unlock the biggest win: meaningful entity labels in
HA from a .pca snapshot.
MockState.from_pca now seeds zones/units/areas/thermostats/buttons
with MockZoneState/MockUnitState/etc. instances carrying just the
name. Defaults handle everything else. A connected client sees the
real panel's names through normal wire discovery (UploadNames
streams them back, properties synth fills the rest).
New end-to-end test verifies the HA integration discovers all 16
zones, 44 units, 16 buttons, 2 thermostats from the live fixture
when the MockPanel is built via MockState.from_pca — proving the
full file → mock → wire → HA pipeline.
Live fixture: 16 zones, 44 units, 16 buttons, 8 codes, 2 thermostats,
0 areas, 8 messages, 330 programs. (Areas in this v1 install have
no user-assigned names — expected.)
Full suite: 499 passed, 1 skipped (fixture-gated).
Convenience constructor that runs parse_pca_file and seeds:
* model_byte + firmware_major/minor/revision from the .pca header,
so SystemInformation replies match the panel the file came from
* programs dict from every non-empty Program record in the 1500-slot
table, encoded back to wire bytes for direct UploadProgram /
UploadPrograms service
Per-object name/state (zones/units/areas/thermostats) isn't in the
pca_file extraction yet — those default to empty unless the caller
overrides. Easy to extend later when pca_file grows zone/unit name
parsing.
Net effect: anyone can now point a MockPanel at any .pca file and
get a hermetic replay of that install's programs over both v1 and
v2 wire dialects:
state = MockState.from_pca("My_House.pca", key=KEY_EXPORT)
panel = MockPanel(controller_key=k, state=state)
New e2e test materialises the live fixture, builds the mock from it,
streams all 330 programs back through OmniClient.iter_programs, and
asserts the slot indexes match.
Adds CONF_PCA_PATH + CONF_PCA_KEY config-flow fields. When set, the
coordinator parses programs from the .pca file at that path instead
of streaming them over the wire on every entry refresh. Useful for:
* deployments where wire enumeration is slow (1500-slot iteration)
* offline snapshots when the panel is unreachable
* deterministic test setups against a known fixture
The config-flow validates the file is readable and decrypts cleanly,
surfacing pca_not_found / pca_decode_failed errors via the strings/
en.json translations.
The .pca path is checked first in _discover_programs; if absent the
wire path runs as before. So existing deployments are unaffected.
Tests cover the success path (live fixture, 330 programs) and the
two validation failures (missing file, garbage bytes).
Coordinator's _discover_programs is no longer a placeholder. It now
drives client.iter_programs() (v2 path) or the v1 adapter's forward
to OmniClientV1.iter_programs (v1 path), populating OmniData.programs
with decoded Program records keyed by slot. Errors are logged and
swallowed so partial enumeration doesn't break entry setup —
programs are non-critical telemetry.
OmniData.programs is now dict[int, Program] rather than the
ProgramProperties dict that was an empty placeholder. The
ProgramProperties dataclass remains in models.py for the Properties
opcode reply path; only the coordinator's value type changed.
New OmniProgramsSensor on the sensor platform: a single diagnostic
entity per panel whose state is the count of defined programs and
whose 'programs' attribute lists each program's slot, type name,
and schedule fields. Easy to consume from automations and the
developer-states UI.
Mock fixture seeds three programs (TIMED+TIMED+EVENT at slots 12 /
42 / 99). New integration test verifies the sensor enumerates them
in slot-ascending order with the expected per-record fields.
Full suite: 494 passed, 1 skipped (fixture-gated).
v2 path adds an iterator over UploadProgram with request_reason=1
("next defined after slot"), mirroring the C# ReadConfig loop at
clsHAC.cs:4985 (seed call) and 5331 (per-reply re-issue). The mock
panel now honours reason=1: walks state.programs for the next
slot strictly greater than the requested one, returns EOD when none.
v1 path wraps OmniConnectionV1.iter_streaming(UploadPrograms) and
decodes each ProgramData reply into a Program. The panel already
streams in slot-ascending order from the previous commit, so the
client just decodes-and-yields.
Both methods return AsyncIterator[Program] for HA-side consumption.
Tests cover populated and empty states for both dialects, plus the
raw v2 reason=1 semantics on a single request.
MockPanel only handled the v2 (single-slot, request/reply)
UploadProgram path. v1 panels use a streaming variant:
client sends UploadPrograms (bare), panel emits one ProgramData
per defined slot, ack-walked by the client, terminated by EOD.
Wire layout is byte-identical to v2 — only the envelope opcode
and stream pattern differ (clsHAC.OL1ReadConfig at clsHAC.cs:4403,
4538-4540, 4642-4651). The mock now mirrors the UploadNames
streaming pattern with its own cursor.
Tests cover both the populated-state stream-then-EOD case and
the empty-state immediate-EOD case, alongside the existing v2
single-slot round-trip tests.
Final RE pass on the multi-record AND record extension. Authored
"AND IF DATE IS EQUAL TO 12/31" (block 12, slot 13) and resolved
the disk encoding model for the structured-OP case:
byte 0 : ProgType = 8 (AND)
byte 1 : (high byte of LE cond) = OP (enuCondOP)
byte 2 : (low byte of LE cond) = Arg1_ArgType (enuCondArgType)
bytes 3-4 : (cond2 LE) = Arg1_IX
byte 5 : (cmd byte) = Arg1_Field
byte 6 : (par byte) = Arg2_ArgType
bytes 7-8 : (pr2 LE) = Arg2_IX
byte 9 : (month byte) = Arg2_Field
bytes 10-11: (day, days bytes) = CompConst
The C# clsConditionLine.Cond property at clsConditionLine.cs:17-33
bridges the two views: for Traditional case (OP=0), the compact-form
cond u16 is SYNTHESIZED from Arg1_ArgType and Arg1_IX. The byte at
offset 2 (= Arg1_ArgType) holds the ProgramCond family code (ZONE=4,
CTRL=8, ...) when OP=0, or the enuCondArgType value (Zone=2, Unit=3,
Thermostat=4, TimeDate=7, ...) when OP > 0. Same byte, different
semantic interpretation based on OP.
New Program properties:
and_op - byte 1, enuCondOP (0 = Traditional, 1-9 = structured)
and_arg1_argtype - byte 2, family code (Trad) or CondArgType (Struct)
and_arg1_ix - bytes 3-4 raw u16 (= cond2; Python LE decode
happens to equal C# in-memory BE Arg1_IX)
and_arg1_field - byte 5
and_arg2_argtype - byte 6
and_arg2_ix - bytes 7-8 raw u16 (= pr2)
and_arg2_field - byte 9
and_compconst - bytes 10-11
The and_instance property is now smart-branched on and_op:
- Traditional: returns Arg1_IX >> 8 (instance in high byte per
clsConditionLine.Cond setter)
- Structured: returns Arg1_IX directly (raw object index)
Also fixed every_interval: per clsProgram.Interval at
clsProgram.cs:338-348, it reads (Data[2] << 8) | Data[3] which spans
the Cond and Cond2 byte ranges. The correct Python formula is
((cond & 0xFF) << 8) | ((cond2 >> 8) & 0xFF). The earlier byte-swap-of-
cond2 formula happened to work for Interval=5 but would break for
Interval > 255.
2 new tests:
test_and_structured_date_eq_1231 - the captured Date case
test_and_traditional_zone_5_secure_via_structured_view
- same vector via structured accessors
475 tests passing (up from 473).
The 6 multi-record ProgType values (WHEN/AT/EVERY/AND/OR/THEN) now have
typed accessors on the Program dataclass:
is_multi_record() - classifier for ProgTypes 5-10
event_id - WHEN trigger event-id (same property as EVENT, no
Mon/Day swap, BE wire form)
and_family - AND record byte-1 family + operand bits (mirrors
compact-form cond's high byte: ZONE=0x04, CTRL+ON=0x0A,
OTHER=0x00, etc.)
and_instance - AND record bytes 3-4 BE u16 (zone#, unit#,
MiscConditional value, ...)
every_interval - EVERY record bytes 3-4 BE u16 (recurrence interval)
AT records reuse the existing month/day/days/hour/minute fields (same
byte layout as compact-form TIMED, just with cmd/par/pr2 zero).
OR records carry no payload — only the ProgType byte distinguishes
them. THEN records reuse cmd/par/pr2 (same layout and LE byte order
as compact-form action fields).
10 new tests cover the empirical captures from pca-re/clausal-re:
- is_multi_record() classifier
- WHEN event_id for Zone 5 Secure and Zone 1 Secure
- EVERY 5 SECONDS interval decoding
- AND IF UNIT 1 ON, AND IF ZONE 5 SECURE, AND IF NEVER family+instance
- AT record month/day/days/hour/minute
- OR record all-zero invariants
- THEN record cmd/par/pr2 (UNIT 1 ON)
All byte vectors in the tests come from real PC Access captures in
pca-re/clausal-re/06-10.pca with firmware override at 3.0+.
The and_family and and_instance properties derive from the existing
cond and cond2 fields via byte-swap — disk bytes 1-4 of AND records
use BE u16 order, but Program's cond/cond2 fields are LE-decoded
(per compact-form convention). The byte-swap formula
((cond2 & 0xFF) << 8) | ((cond2 >> 8) & 0xFF) yields the BE
interpretation without re-reading raw bytes.
473 tests passing (up from 463).
Authored a controlled "AND IF ZONE 5 SECURE" condition in PC Access
and diffed against an "AND IF NEVER" capture at the same record
position:
NEVER: 08 00 00 00 01 00 00 00 ...
ZONE 5 SECURE: 08 04 00 00 05 00 00 00 ...
The zone number "5" lands in byte 4 (high-offset of the u16 at
positions 3-4). If disk were little-endian, the 5 would land at
byte 3. So Arg1_IX, Arg2_IX, and CompConst in AND records are
big-endian on disk — opposite of the compact-form cond/cond2/pr2,
which are LE. Different record families use different byte orders;
the C# encoder writes AND records' u16s directly in BE matching
in-memory Data[] layout, while compact-form fields go through the
Cond/Cond2/Pr2 setters that produce LE on the wire.
Update the AND-record comment block to reflect the resolved byte
order. The byte-1 semantic role (OP vs family code) remains open
and is noted as a future single-experiment follow-up.
No code changes — still no AndRecord decoder exposed since byte 1's
meaning is one open question away from being settled.
Multi-record AND records (ProgType=8, firmware >=3.0.0) carry a
structured condition with the byte layout from clsProgram.cs:326-436:
byte 1: OP (enuCondOP)
byte 2: Arg1_ArgType (enuCondArgType)
bytes 3-4: Arg1_IX (u16)
byte 5: Arg1_Field
byte 6: Arg2_ArgType
bytes 7-8: Arg2_IX (u16)
byte 9: Arg2_Field
bytes 10-11: CompConst (u16)
The two companion enums (CondOP, CondArgType) are exported so callers
can pattern-match on operator + arg type without re-deriving from the
C# source.
Special case noted: when OP == Arg1_Traditional (=0), the AND record's
condition is rendered from the Cond u16 (bytes 1-2) using the same
per-family scheme as compact-form cond, per clsText.cs:2281-2284. The
Arg1_*/Arg2_*/CompConst fields are only meaningful when OP > 0.
Open question logged in comments: disk byte order for the three u16
fields. clsProgram.Read reads them little-endian but the accessors
index Data[] big-endian. Our single captured example is symmetric and
doesn't disambiguate; resolution deferred until we have an authored
AND IF ZONE 5 SECURE (or similar asymmetric Arg1_IX) sample.
No decoder methods added yet — the raw 14-byte body is sufficient for
the current downstream code, and exposing partial-decode methods would
risk shipping a wrong byte-order interpretation.
The 14-byte program record's three u16 fields are little-endian, not
big-endian as the original plan assumed. Empirically confirmed by
authoring known programs in PC Access (running in a Windows XP VM)
and byte-diffing the resulting .pca file:
- UNIT 1 ON → bytes 7,8 = 01 00 → LE 0x0001 (correct), not 0x0100
- AND IF ZONE 2 SECURE → cond bytes [02, 04] → LE 0x0402 (kind=4 ZONE,
inst=2) matches the ProgramCond.ZONE family from the C# source
- Cross-check Our_House.pca's 209 TIMED records: pr2 low-byte is
almost always zero (textbook LE small-value distribution)
Also annotate the multi-record ProgType values (WHEN/AT/EVERY/AND/OR/THEN,
values 5-10) with the firmware ≥3.0.0 requirement from
clsCapOMNI_PRO_II.cs:290 — the user's 2.16A panel can't produce them,
which is why Our_House.pca contains zero such records.
New constants:
- MIN_FIRMWARE_MULTILINE_PROGRAMS (= 196608, packed 3.0.0)
- MIN_FIRMWARE_DOUBLE_PROGRAM_CONDITIONAL (= 0, always)
- pack_firmware_version() helper
Full RE notes in pca-re/clausal-re/FINDINGS.md (separate repo).
Tests: 463 passing, 1 skipped (gitignored fixture).
The 16-bit cond/cond2 fields of a program record pack a 5-family
discriminator + a per-family selector + a per-family operand. The high
byte's bits 2-7 (i.e. (cond >> 8) & 0xFC) pick the family; the rest is
family-specific:
OTHER → bits 0-3 = MiscConditional value (DARK, AC_POWER_OFF, …)
ZONE → bits 0-7 = zone #; bit 9 = NOT_READY (1) / SECURE (0)
CTRL → bits 0-8 = unit #; bit 9 = ON (1) / OFF (0)
TIME → bits 0-7 = clock#; bit 9 = ENABLED (1) / DISABLED(0)
SEC → bits 8-11 = area #; bits 12-14 = SecurityMode;
bit 15 = arming-transition flag
(only when mode != 0)
The Sec family is the catch-all default (per clsText.cs:2226-2273 the
switch falls through to it from anything not Other/Zone/Ctrl/Time).
omni_pca.programs:
* New ConditionFamily IntEnum and MiscConditional IntEnum.
* New Condition frozen dataclass with decode classmethod, is_empty,
describe (renders with index-based labels for offline use).
* New Program.condition() and Program.condition2() helpers.
omni_pca top-level: re-exports Condition, ConditionFamily, MiscConditional.
Verified against the live fixture (330 defined programs):
cond family distribution: SEC=156, TIME=8, ZONE=4, CTRL=3, OTHER=3
cond2 family distribution: SEC=21, TIME=10
tests/test_programs.py (+24 cases):
* Parametrised per-family decode with worked examples from the docs.
* Arming-transition flag asserts (mode=Off + bit 15 is NOT arming).
* Program.condition()/condition2() integration.
* OTHER ignores high bits (PC Access sometimes leaves them set).
* u16 range validation.
* MiscConditional enum values match enuMiscConditional.cs.
Full suite: 463 passed, 1 skipped (was 439 / 1).
Source: clsText.GetConditionalText (clsText.cs:2224-2273) for the
decode logic; frmAutomationEditCondition.cs:615-2550 for the encoder.
The hour byte of a TIMED program is overloaded as a 1-of-3 discriminator:
0..23 means absolute wall-clock time, 25 means sunrise-relative, 26 means
sunset-relative. For the relative forms, the minute byte is signed
(sbyte) -- positive = after, negative = before, zero = at. Source:
frmPopUpEditTime.cs:186-217 (decode) + :241-263 (encode).
This was the canary that tripped our earlier sanity test: slots 182/183
in the live fixture have hour=26 minute=246 and hour=25 minute=10 --
nominally invalid as clock times, but they're "10 min before sunset"
and "10 min after sunrise" respectively. With this commit those decode
cleanly via TimeKind.SUNSET / SUNRISE.
omni_pca.programs:
* New TimeKind IntEnum: ABSOLUTE / SUNRISE / SUNSET.
* New Program.time_kind property (classifies via hour-byte discriminator).
* New Program.time_offset_minutes property (signed minutes-offset for
sunrise/sunset; 0 for absolute).
* New Program.format_time() -> str: "07:15" | "at sunrise" | "30 min
before sunset" etc.
* Module-level _classify_time helper + sentinel constants
_HR_SUNRISE_SENTINEL=25 / _HR_SUNSET_SENTINEL=26.
omni_pca top-level: re-exports TimeKind.
tests/test_programs.py (+13 cases):
* Parametrised TimeKind classification across absolute / sunrise /
sunset including boundary cases (sbyte ±128, ±1, 0).
* Wire-bytes round-trip preserves TimeKind + offset.
tests/test_pca_file.py: tightened the previously-loosened sanity
invariant. ABSOLUTE-time TIMED programs must hit valid wall-clock
ranges (0-23 / 0-59); relative-time programs must have a valid sbyte
offset (-128..127). Both pass cleanly on the 209 TIMED programs in
the live fixture (207 absolute, 1 sunrise, 1 sunset).
Full suite: 439 passed, 1 skipped (was 426 / 1).
Decodes the remark-text dict that Remark-typed program records refer to
via their 32-bit BE RemarkID. The table lives after the Connection
block in PCA03 files; getting to it means walking past ModemBaud +
PCModemInit flags + AccountRemarks_Extended + nine 33-byte-per-entry
Description blocks (Zones, Units, Buttons, Codes, Thermostats, Areas,
Messages, AudioSources, AudioZones).
Format reverse-engineered from clsPrograms.ReadRemarks (clsPrograms.cs:
148-168) and the file-body walker in clsHAC.cs:8055-8079. Each entry is
[u32 LE remark_id][u16 LE text_length][N bytes UTF-8], preceded by a
[u32 LE _RemarksNextID][u32 LE count] header.
pca_file changes:
* PcaAccount.remarks: dict[int, str] (default {}).
* New _walk_to_remarks helper called from parse_pca_file when
file_version >= 3. Best-effort: any read failure leaves remarks={}.
* New _DESCRIPTION_SLOT_BYTES (= 33) constant.
tests/test_pca_file.py (4 new cases):
* Walker on an empty Remarks table (decode count=0 cleanly).
* Walker decodes three hand-built entries, including a UTF-8 string
with non-ASCII characters.
* Truncated input returns {} rather than raising.
* Live fixture (Our_House.pca.plain): walker consumes the prelude +
nine description blocks + zero-count remarks block without raising.
This panel has no Remark-typed programs, so {} is the expected
result -- and the *coarse* walker validation here is what proves
the description-block sizes (counts up to 511) are correct.
Full suite: 426 passed, 1 skipped (was 422 / 1).
First reverse-engineering pass on the panel's built-in automation
engine. Adds a typed Python Program dataclass that decodes/encodes the
14-byte program record used both on the wire (clsOLMsgProgramData) and
on disk (the 21,000-byte Programs block in a .pca file).
Coverage:
* enums: ProgramType, ProgramCond, Days bitmask
* Program dataclass with from_wire_bytes / from_file_record /
encode_wire_bytes / encode_file_record (Mon/Day swap for EVENT-typed
records applied on the file form only -- mirrors clsProgram.Read at
clsProgram.cs:471, while clsProgram.ToByteArray omits the swap)
* Remark variant (bytes 1-4 = BE u32 RemarkID instead of cond/cond2)
* unknown ProgType / Cmd bytes pass through as raw ints with a
once-per-process warning
* decode_program_table for the full 1500-slot .pca block
* pca_file.parse_pca_file populates PcaAccount.programs (backward-
compatible: defaults to ())
* mock_panel.MockState.programs + _reply_program_data so OmniLink2
UploadProgram (opcode 9) round-trips through the test fixture
Verification (422 passed, 1 skipped — was 400):
* 15 unit tests in test_programs.py: golden bytes for each ProgramType,
Mon/Day swap proven distinct between wire and file layouts, Remark
round-trip, 500 random-input wire+file round-trips, unknown-enum
tolerance
* 4 fixture-gated live-data tests in test_pca_file.py: all 1500 slots
decode cleanly, 330 non-empty (matches Phase 1 recon distribution
209 TIMED / 105 EVENT / 16 YEARLY), 21,000-byte byte-for-byte
round-trip against the live decrypted fixture, YEARLY month/day in
valid calendar ranges
* 3 wire-echo tests in test_e2e_program_echo.py: client drives
UploadProgram (opcode 9) through the mock, server replies with
ProgramData (opcode 10) wrapping [number_hi, number_lo, body];
full Program round-trips field-by-field, empty slots return zero
bodies, EVENT bytes are emitted in wire order (no swap)
What this pass deliberately leaves open (documented in the docs page):
* cond / cond2 internal bit split (selector vs operand)
* multi-record clausal encoding (When/At/Every/And/Or/Then)
* RemarkID -> RemarkText lookup table layout
* DPC capability flag location for non-OPII models
* TIMED time-of-day vs sunrise/sunset-relative offset flag
References:
* clsProgram.cs (entire) — field accessors, Read/Write, Evt u16
* enuProgramType.cs / enuProgramCond.cs / enuDays.cs
* Owner's Manual SETUP chapter — user-facing programming-line model
* Installation Manual SETUP MISC — installer-facing setup screen
Adds OmniLinkMessage (0x10) outer-packet handling to the mock so the
v1 path no longer requires a real panel for testing. Exercised over
UDP because OmniClientV1 is UDP-only by design, but the dispatcher
itself is transport-agnostic and the TCP _handle_client routes
OmniLinkMessage packets through the same _dispatch_v1 method too.
Coverage today:
* RequestSystemInformation (17) -> SystemInformation (18)
* RequestSystemStatus (19) -> SystemStatus (20), 8 area mode bytes
* RequestZoneStatus (21) -> ZoneStatus (22), short + long form
* RequestUnitStatus (23) -> UnitStatus (24), short + long form
(long form auto-selected for indices > 255)
* RequestThermostatStatus (30) -> ThermostatStatus (31)
* RequestAuxiliaryStatus (25) -> AuxiliaryStatus (26) (zero records)
* UploadNames (12) -> NameData (11) streaming, lock-step
Ack-driven across Zone/Unit/Button/
Area/Thermostat, terminated by EOD (3)
* Command (15) -> Ack (5) / Nak (6), reuses v2 state
mutator so light-on/off, set-level,
bypass-zone, restore-zone all work
* ExecuteSecurityCommand (102) -> Ack (5) / ExecuteSecurityCommandResponse
(103) on bad code, with structured
status byte preserved
* MessageCrcError -> v1 Nak (opcode 6)
The dispatcher writes replies wrapped in OmniLinkMessage (16) outer
packets (vs OmniLink2Message (32) used by v2) so OmniClientV1 routes
them correctly. The 4-step handshake is shared with v2 -- it's
protocol-version-agnostic at the outer-packet layer.
UploadNames state is panel-instance scoped via _upload_names_cursor
(int | None) -- there is only one active session at a time on the
mock so a single cursor suffices.
tests/test_e2e_v1_mock.py: 13 cases driving OmniClientV1 through the
mock's UDP socket, covering the full read API + UploadNames streaming
+ write methods + structured-failure path on a wrong security code.
Full suite: 400 passed, 1 skipped (was 387 / 1).
Final cross-reference round, covering the remaining files where wire
bytes have a user- or installer-facing counterpart:
v1/messages.py
New Cross-references block: SETUP ZONES + SETUP TEMPERATURES for the
fields the parsers' raw bytes ultimately come from, and APPENDIX C
for what each synthesized index means on hardware (unit 257+ =
expansion-enclosure outputs, 393+ = panel flags).
models.ZoneStatus
Status-byte bit-layout doc now also points at the Owner's Manual
CONTROL chapter's "View Zone Status" keypad screen -- same Secure /
Not Ready / Trouble / Tamper labels.
models.UnitStatus
State-byte semantics doc references the Owner's Manual CONTROL
chapter for the user-side actions (All On/All Off/Scene/Bright/Dim)
that drive units into each of these states.
mock_panel.py
Notes that the mock's plausible-but-arbitrary RequestProperties /
RequestStatus responses correspond on real hardware to what an
installer typed into INSTALLER SETUP. Production fixtures should
pre-seed MockPanel state to match a known SETUP configuration.
uv.lock
Catches up the project's own entry to omni-pca 2026.5.11 (was
pinned to 2026.5.10 from the previous lock generation).
No code changes; 387 tests still pass.