5 Commits

Author SHA1 Message Date
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
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
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