74 Commits

Author SHA1 Message Date
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
73f05188dd release: 2026.5.16
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
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).
v2026.5.16
2026-05-16 01:29:25 -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
821a402d32 release: 2026.5.14
Some checks failed
Validate / HACS validation (push) Has been cancelled
Validate / Hassfest (push) Has been cancelled
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.
v2026.5.14
2026-05-14 03:12:06 -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
0026c5b00a program_renderer: structured-English token streams for HA UI
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
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).
2026-05-14 02:58:51 -06:00
172aa2974a brand: ship icon.png + icon@2x.png inline (HA 2026.3 brands-proxy API)
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
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.
2026-05-14 02:54:38 -06:00
56d288db37 hassfest: fix manifest key order, drop markdown from i18n, add CONFIG_SCHEMA
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
- 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
2026-05-14 02:49:49 -06:00
d4c4e530f6 program_engine: real AND/OR condition evaluator
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
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).
2026-05-14 02:39:41 -06:00
16655da34c hacs: switch canonical URLs to github.com/rsp2k/omni-pca + add validation CI
- manifest.json documentation/issue_tracker → GitHub (where HACS users land)
- README install instructions → `pip install omni-pca` (now on PyPI)
- pyproject.toml URLs → Repository / Issues / Changelog / Documentation
- custom_components README → HACS default-catalog install flow
- .github/workflows/validate.yml: hacs/action + hassfest on push/PR/weekly

Library remains importable from PyPI; integration tracks the same release tag.
2026-05-14 02:35:21 -06:00
116591be90 dev: refresh integration screenshots (2026-05-10 + 2026-05-11) 2026-05-14 02:32:54 -06:00
cc32081caf program_engine: Phase 5 — clausal chains (WHEN/AT/EVERY + AND/OR/THEN)
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).
2026-05-14 01:34:19 -06:00
269d0e897d program_engine: Phase 4 — EVENT programs + event taxonomy
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).
2026-05-14 01:30:23 -06:00
d6205cd330 program_engine: Phase 3 — YEARLY + sunrise/sunset
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).
2026-05-14 01:27:54 -06:00
2cc28b0e50 program_engine: Phases 1+2 — Clock abstraction + TIMED execution
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).
2026-05-14 01:25:14 -06:00
8250df0206 pca_file: TimeAdj, AlarmResetTime, ArmingConfirmation, TwoWayAudio
Four more scalars sandwiched around the thermostat arrays
(clsHAC.cs:3303-3321):

  3394: TimeAdj             — daily clock-drift adjust minute (1..59, default 30)
  3395: AlarmResetTime      — alarm-clear retry delay (1..30 default 6; 30..60/30 on EURO)
  3396: ArmingConfirmation  — beep on successful arm (bool)
  3461: TwoWayAudio         — central-station 2-way audio on alarm (bool)

3461 sits right after Thermostat.Type[1..64] @ 3397..3460.

Live fixture: time_adj=30 (panel default), alarm_reset_time=4,
arming_confirmation=False, two_way_audio=False — coherent
plain-vanilla home-install values.

Full suite: 499 passed, 1 skipped.
2026-05-14 01:10:33 -06:00
c7eb92122b pca_file: ZoneOptions + thermostat type/areas — per-object props done
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.
2026-05-13 23:33:37 -06:00
e61e37a3fc pca_file: finish SetupData — telephony, misc scalars, DCM block
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.
2026-05-13 23:13:27 -06:00
362580bccc pca_file: AccountRemarks_Extended + 9 per-family Description tables
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.
2026-05-13 22:32:20 -06:00
7b789f8cfb pca_file: Latitude / Longitude / TimeZone from SetupData
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.
2026-05-13 16:39:30 -06:00
b8745e17de pca_file: HouseCodeFormat, TimeClocks, Installer/PCAccess codes
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.
2026-05-13 09:13:40 -06:00
7683557bbb pca_file: PerimeterChime/AudibleExitDelay, DST, unit type+area, code PINs
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.
2026-05-13 08:40:27 -06:00
994608a4f6 pca_file + v2 client: area flags + Area-N fallback
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.
2026-05-13 08:19:38 -06:00
501686795b pca_file: extract entry/exit delays, TempFormat, NumAreasUsed
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.
2026-05-12 22:35:55 -06:00
8141599b4e pca_file: extract per-zone Area assignment from SetupData
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.
2026-05-12 22:26:25 -06:00
70bf9caf58 pca_file: extract zone_type from SetupData installer section
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.
2026-05-12 22:18:32 -06:00
7db9616a34 pca_file: extract Zone/Unit/Button/Code/Tstat/Area/Message names
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).
2026-05-12 20:34:00 -06:00
390f3a9dc0 mock_panel: MockState.from_pca builds state from a real .pca file
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.
2026-05-12 20:25:02 -06:00
e57fbc41e3 HA: optional .pca file as alternate source for panel programs
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).
2026-05-12 19:15:32 -06:00
b412dc0f37 HA: discover programs over the wire + diagnostic sensor
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).
2026-05-12 19:10:32 -06:00
4ad20c9350 clients: iter_programs() for both v1 and v2 wire dialects
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.
2026-05-12 19:07:42 -06:00
933d326dd3 mock_panel: v1 UploadPrograms streaming + program-echo tests
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.
2026-05-12 18:21:05 -06:00
290ba5a78d programs: add structured-OP AND decoder properties
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).
2026-05-12 15:35:01 -06:00
e560d98f87 programs: add multi-record decoder properties (firmware >=3.0 records)
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).
2026-05-12 04:57:48 -06:00
23f56e701b programs: AND-record u16 fields are big-endian on disk (verified)
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.
2026-05-12 03:47:52 -06:00
4be4101f37 programs: document AND-record field layout + add CondOP/CondArgType enums
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.
2026-05-12 03:19:48 -06:00
61ae95997c programs: fix cond/cond2/pr2 byte order (LE, not BE)
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).
2026-05-12 02:35:03 -06:00
ef7d53c468 programs: decode cond / cond2 into Condition (family + selector + operand)
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.
2026-05-11 22:34:50 -06:00
eb1a632ef2 programs: decode TIMED sunrise/sunset-relative time encoding
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).
2026-05-11 21:38:28 -06:00
00f0028053 pca_file: parse the Remarks table (RemarkID → text resolution)
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).
2026-05-11 21:33:53 -06:00
d4c04b3044 programs: typed decoder/encoder for the 14-byte program record
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
2026-05-11 19:48:00 -06:00
0e3835d4ff MockPanel: v1 wire dispatch for hermetic OmniClientV1 tests
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).
2026-05-11 16:32:51 -06:00
dd53b2a89a docs: third cross-ref pass + sync uv.lock to 2026.5.11
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.
2026-05-11 15:54:40 -06:00
24eecceff9 docs: second cross-ref pass (HvacMode/FanMode/HoldMode, pca_file, v1/connection)
Follow-up to 0d6465d, sprinkling pca-re/docs/manuals/ citations into
three more files that map to user-visible or installer-visible panel
behavior:

models.HvacMode / FanMode / HoldMode
  Docstrings now explain which values correspond to keypad menu picks
  vs. programmatic-only states, and point at the Owner's Manual
  *Scene Commands* chapter where each menu is laid out.

pca_file.py
  Module docstring adds Cross-references to the Installation Manual's
  *INSTALLER SETUP* chapter (SETUP CONTROL/ZONES/AREAS/MISC/EXPANSION)
  -- those are the keypad screens that produce the very SetupData /
  Names / Programs blocks the parser walks. Also points at APPENDIX C
  (zone and unit mapping) for where the _CAP_OMNI_PRO_II numbers come
  from on the panel side.

v1/connection.py
  Module docstring adds cross-references to the docs-site pages that
  explain (a) the non-public handshake quirks the v1 connection relies
  on for crypto and (b) why subsequent RequestUnitStatus calls need
  the long-form BE u16 payload (Appendix C zone/unit mapping again).

No code changes, doc-only; 387 tests still pass.
2026-05-11 15:33:14 -06:00
0d6465dad0 docs: cross-reference manuals from SecurityMode, ZoneType, events, commands
Sprinkle pca-re/docs/manuals/ citations into the four files that map
hardest to user-visible panel behavior, so a reader chasing "why is
this byte 0x03 here" lands on the right manual chapter directly from
the source.

models.SecurityMode
  Per-value comments summarising what each arming mode means at the
  keypad (entry/exit delays, which zones it arms, when to use it).
  Points at the Owner's Manual SECURITY_SYSTEM_OPERATION chapter where
  these semantics are spelled out for end users.

models.ZoneType
  Class docstring now points at the Installation Manual SETUP ZONES
  table where each numeric byte value is named -- the byte values and
  short names we chose match that table one-for-one, so a reader can
  cross-walk the v1 ZoneStatus byte to "PERIMETER" / "AWAY INT" / etc.
  by row.

events.py
  Module docstring adds Cross-references to APPENDIX A (Contact ID
  reporting format) and APPENDIX B (digital communicator code sheet)
  in the Installation Manual -- the central-station codes a panel
  transmits for each AlarmKind correspond directly to those tables.

commands.py
  Module docstring points at the Owner's Manual CONTROL, Scene Commands,
  and SECURITY SYSTEM OPERATION chapters so the reader can tie each
  enuUnitCommand byte to the user-facing keypad path that triggers it.

No code changes; all 387 tests still pass.
2026-05-11 14:51:19 -06:00