From 61ae95997cbf46a4e31a770cc11410031a8564ae Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Tue, 12 May 2026 02:35:03 -0600 Subject: [PATCH] programs: fix cond/cond2/pr2 byte order (LE, not BE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/omni_pca/programs.py | 126 +++++++++++++++++++++++++++++++-------- tests/test_programs.py | 17 ++++-- 2 files changed, 114 insertions(+), 29 deletions(-) diff --git a/src/omni_pca/programs.py b/src/omni_pca/programs.py index f76eabf..e94fb6d 100644 --- a/src/omni_pca/programs.py +++ b/src/omni_pca/programs.py @@ -14,12 +14,12 @@ set, so each record is **14 bytes** with two condition slots: Offset Field ========== ============================================== 0 ``prog_type`` (``ProgramType`` enum) -1-2 ``cond`` (BE u16; opaque this pass) -3-4 ``cond2`` (BE u16; opaque this pass) +1-2 ``cond`` (LE u16; first AND-IF condition) +3-4 ``cond2`` (LE u16; second AND-IF condition) 5 ``cmd`` (``Command`` enum from :mod:`omni_pca.commands`) 6 ``par`` (byte parameter) -7-8 ``pr2`` (BE u16, usually object#) +7-8 ``pr2`` (LE u16, usually object#) 9-10 ``month, day`` (or ``day, month`` in the .pca on-disk layout when ProgType==Event; see "Mon/Day swap" below) @@ -28,6 +28,12 @@ Offset Field 13 ``minute`` (0-59) ========== ============================================== +**Byte order:** all 16-bit fields above are **little-endian** +(byte N is the low byte, byte N+1 is the high byte). This was +empirically confirmed against PC Access — see findings notes +in ``pca-re/clausal-re/FINDINGS.md``. Older versions of this +module decoded them as BE; the LE encoding is correct. + When ``prog_type == Remark`` (4), bytes 1-4 hold a 32-bit BE RemarkID instead of cond/cond2; the lookup table that resolves an ID back to the user-visible remark text lives elsewhere on disk @@ -94,23 +100,89 @@ MAX_PROGRAMS = 1500 class ProgramType(IntEnum): """Program record discriminator (``enuProgramType``). - The first five values are the actual stored types; values 5..10 - are connector tokens used by PC Access's program-line editor to - string multiple records into one user-visible "line". The - multi-record encoding is not yet reverse-engineered. + The 11 values split into two encoding families. Which family a + block uses depends on the panel firmware version + (see :data:`MIN_FIRMWARE_MULTILINE_PROGRAMS`) — **older firmware + can only express the compact family**: + + * **Compact** (``FREE`` / ``TIMED`` / ``EVENT`` / ``YEARLY`` / + ``REMARK``, values 0-4): always available. The whole user-visible + block (1 trigger + up to 2 AND conditions + 1 action) fits in one + 14-byte record. The trigger discriminates the form; cmd/par/pr2 + carry the inline action; cond/cond2 carry up to two AND-IF + conditions. PC Access calls this a "simple program". + * **Multi-record** (``WHEN`` / ``AT`` / ``EVERY`` / ``AND`` / ``OR`` + / ``THEN``, values 5-10): one record per "line" in the block. + Used when the block would need 3+ conditions, an OR alternative, + or a comment block — anything the compact form can't express. + Requires the ``MultiLinePrograms`` capability flag on the panel, + which on the OmniPro II is gated to firmware ≥3.0.0 + (clsCapOMNI_PRO_II.cs:290 — ``Features.Add(MultiLinePrograms, + 196608u)``). On firmware <3.0 these ProgType values simply + cannot appear; PC Access's "Or" button and "Add Comment Block" + menu item are disabled. + + Reference: + clsAutomationBlock.cs BuildLines() lines 80-131 for the + compact-vs-multi rendering; clsCapOMNI_PRO_II.cs:290 for the + firmware gate; frmAutomationEditBlock.cs:809-823 + (``MustBeSimpleProgram()``) for the toolbar enable logic. """ FREE = 0 # unused slot (all bytes zero) - TIMED = 1 # fires at a specific time of day on selected days - EVENT = 2 # fires when a panel event occurs (zone open, etc.) - YEARLY = 3 # fires on a specific calendar date each year + TIMED = 1 # compact: time-of-day trigger + inline action + EVENT = 2 # compact: panel event (zone, security, etc.) + action + YEARLY = 3 # compact: yearly date trigger + inline action REMARK = 4 # stores a 32-bit RemarkID + remark-text association - WHEN = 5 # connector (multi-record line, RE-pending) - AT = 6 # connector - EVERY = 7 # connector - AND = 8 # connector - OR = 9 # connector - THEN = 10 # connector + WHEN = 5 # multi-record: event-trigger record (firmware ≥3.0.0) + AT = 6 # multi-record: time-trigger record (firmware ≥3.0.0) + EVERY = 7 # multi-record: recurring-trigger record (firmware ≥3.0.0) + AND = 8 # multi-record: AND-condition record (firmware ≥3.0.0) + OR = 9 # multi-record: OR-alternative record (firmware ≥3.0.0) + THEN = 10 # multi-record: action record (firmware ≥3.0.0) + + +def pack_firmware_version(major: int, minor: int, revision: int = 0) -> int: + """Pack a firmware version into HAI's 24-bit comparison form. + + HAI's capability tables compare against a single u32 packed as + ``major * 65536 + minor * 256 + revision`` (clsHAC.FW vs the + second arg of ``Features.Add``). The constants in this module + use this same packing so callers can compare directly: + + >>> pack_firmware_version(2, 16, 1) + 135169 + >>> pack_firmware_version(3, 0, 0) + 196608 + """ + return (major & 0xFF) << 16 | (minor & 0xFF) << 8 | (revision & 0xFF) + + +MIN_FIRMWARE_MULTILINE_PROGRAMS = 196608 # 3.0.0 +"""Earliest OmniPro II firmware that supports multi-record programs. + +Below this version, ProgType values 5-10 (``WHEN`` / ``AT`` / ``EVERY`` +/ ``AND`` / ``OR`` / ``THEN``) cannot be produced by PC Access and +will not appear in the panel's program table. The user-visible +limitation: any block that would need three or more AND-IF conditions, +or any ``Or`` alternative, can't be authored. Compact-form blocks +(values 0-4, with up to 2 inline cond/cond2 AND conditions) remain +available. + +Mirrors ``Features.Add(enuFeature.MultiLinePrograms, 196608u)`` in +clsCapOMNI_PRO_II.cs:290. +""" + +MIN_FIRMWARE_DOUBLE_PROGRAM_CONDITIONAL = 0 # always +"""Earliest firmware that supports two inline AND conditions +(``cond`` AND ``cond2`` together) per compact program record. + +For the OmniPro II this is always on (no version gate in +clsCapOMNI_PRO_II.cs:265 — ``Features.Add(DoubleProgramConditional)`` +with no version arg). The 14-byte ``PROGRAM_BYTES`` constant assumes +this feature: on models without DPC the record would be 12 bytes +and ``cond2`` would not exist. +""" class ProgramCond(IntEnum): @@ -381,6 +453,7 @@ def _decode_common(body: bytes) -> dict[str, object]: if prog_type == ProgramType.REMARK: # bytes 1-4 are a single BE u32 RemarkID instead of cond/cond2. + # (RemarkID is the one BE field — cond/cond2/pr2 are LE.) remark_id: int | None = ( (body[1] << 24) | (body[2] << 16) | (body[3] << 8) | body[4] ) @@ -388,12 +461,14 @@ def _decode_common(body: bytes) -> dict[str, object]: cond2 = 0 else: remark_id = None - cond = (body[1] << 8) | body[2] - cond2 = (body[3] << 8) | body[4] + # cond, cond2, pr2 are little-endian u16 — empirically confirmed + # by authoring known programs in PC Access and diffing bytes. + cond = (body[2] << 8) | body[1] + cond2 = (body[4] << 8) | body[3] cmd = body[5] par = body[6] - pr2 = (body[7] << 8) | body[8] + pr2 = (body[8] << 8) | body[7] days = body[11] hour = body[12] minute = body[13] @@ -426,14 +501,15 @@ def _encode_common(p: Program) -> bytearray: buf[3] = (rid >> 8) & 0xFF buf[4] = rid & 0xFF else: - buf[1] = (p.cond >> 8) & 0xFF - buf[2] = p.cond & 0xFF - buf[3] = (p.cond2 >> 8) & 0xFF - buf[4] = p.cond2 & 0xFF + # cond, cond2, pr2 are little-endian — see _decode_common + buf[1] = p.cond & 0xFF + buf[2] = (p.cond >> 8) & 0xFF + buf[3] = p.cond2 & 0xFF + buf[4] = (p.cond2 >> 8) & 0xFF buf[5] = p.cmd & 0xFF buf[6] = p.par & 0xFF - buf[7] = (p.pr2 >> 8) & 0xFF - buf[8] = p.pr2 & 0xFF + buf[7] = p.pr2 & 0xFF + buf[8] = (p.pr2 >> 8) & 0xFF # 9, 10 filled by encode_{wire,file}_bytes buf[11] = p.days & 0xFF buf[12] = p.hour & 0xFF diff --git a/tests/test_programs.py b/tests/test_programs.py index eb8ac5f..57410b6 100644 --- a/tests/test_programs.py +++ b/tests/test_programs.py @@ -33,16 +33,25 @@ from omni_pca.programs import ( def test_timed_decodes_canonical_example() -> None: - """The worked example from the docs page — TIMED program.""" + """The worked example from the docs page — TIMED program. + + ``cond``, ``cond2`` and ``pr2`` are **little-endian** u16 fields: + byte N is the low byte, byte N+1 the high byte. The byte vector + below comes from ``Our_House.pca`` slot 22 (a real TIMED program + for an HLC scene at 07:15 weekday mornings). + """ body = bytes.fromhex("018d099b094403010008 0c3e070f".replace(" ", "")) p = Program.from_file_record(body, slot=22) assert p.slot == 22 assert p.prog_type == ProgramType.TIMED - assert p.cond == 0x8D09 - assert p.cond2 == 0x9B09 + # bytes 1,2 = [8d 09] → LE u16 = 0x098D + assert p.cond == 0x098D + # bytes 3,4 = [9b 09] → LE u16 = 0x099B + assert p.cond2 == 0x099B assert p.cmd == 0x44 assert p.par == 3 - assert p.pr2 == 0x0100 + # bytes 7,8 = [01 00] → LE u16 = 0x0001 (object #1) + assert p.pr2 == 0x0001 assert p.month == 8 assert p.day == 12 assert p.days == 0x3E