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).
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