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).
This commit is contained in:
parent
ef7d53c468
commit
61ae95997c
@ -14,12 +14,12 @@ set, so each record is **14 bytes** with two condition slots:
|
|||||||
Offset Field
|
Offset Field
|
||||||
========== ==============================================
|
========== ==============================================
|
||||||
0 ``prog_type`` (``ProgramType`` enum)
|
0 ``prog_type`` (``ProgramType`` enum)
|
||||||
1-2 ``cond`` (BE u16; opaque this pass)
|
1-2 ``cond`` (LE u16; first AND-IF condition)
|
||||||
3-4 ``cond2`` (BE u16; opaque this pass)
|
3-4 ``cond2`` (LE u16; second AND-IF condition)
|
||||||
5 ``cmd`` (``Command`` enum from
|
5 ``cmd`` (``Command`` enum from
|
||||||
:mod:`omni_pca.commands`)
|
:mod:`omni_pca.commands`)
|
||||||
6 ``par`` (byte parameter)
|
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
|
9-10 ``month, day`` (or ``day, month`` in the .pca
|
||||||
on-disk layout when ProgType==Event;
|
on-disk layout when ProgType==Event;
|
||||||
see "Mon/Day swap" below)
|
see "Mon/Day swap" below)
|
||||||
@ -28,6 +28,12 @@ Offset Field
|
|||||||
13 ``minute`` (0-59)
|
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
|
When ``prog_type == Remark`` (4), bytes 1-4 hold a 32-bit BE
|
||||||
RemarkID instead of cond/cond2; the lookup table that resolves an
|
RemarkID instead of cond/cond2; the lookup table that resolves an
|
||||||
ID back to the user-visible remark text lives elsewhere on disk
|
ID back to the user-visible remark text lives elsewhere on disk
|
||||||
@ -94,23 +100,89 @@ MAX_PROGRAMS = 1500
|
|||||||
class ProgramType(IntEnum):
|
class ProgramType(IntEnum):
|
||||||
"""Program record discriminator (``enuProgramType``).
|
"""Program record discriminator (``enuProgramType``).
|
||||||
|
|
||||||
The first five values are the actual stored types; values 5..10
|
The 11 values split into two encoding families. Which family a
|
||||||
are connector tokens used by PC Access's program-line editor to
|
block uses depends on the panel firmware version
|
||||||
string multiple records into one user-visible "line". The
|
(see :data:`MIN_FIRMWARE_MULTILINE_PROGRAMS`) — **older firmware
|
||||||
multi-record encoding is not yet reverse-engineered.
|
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)
|
FREE = 0 # unused slot (all bytes zero)
|
||||||
TIMED = 1 # fires at a specific time of day on selected days
|
TIMED = 1 # compact: time-of-day trigger + inline action
|
||||||
EVENT = 2 # fires when a panel event occurs (zone open, etc.)
|
EVENT = 2 # compact: panel event (zone, security, etc.) + action
|
||||||
YEARLY = 3 # fires on a specific calendar date each year
|
YEARLY = 3 # compact: yearly date trigger + inline action
|
||||||
REMARK = 4 # stores a 32-bit RemarkID + remark-text association
|
REMARK = 4 # stores a 32-bit RemarkID + remark-text association
|
||||||
WHEN = 5 # connector (multi-record line, RE-pending)
|
WHEN = 5 # multi-record: event-trigger record (firmware ≥3.0.0)
|
||||||
AT = 6 # connector
|
AT = 6 # multi-record: time-trigger record (firmware ≥3.0.0)
|
||||||
EVERY = 7 # connector
|
EVERY = 7 # multi-record: recurring-trigger record (firmware ≥3.0.0)
|
||||||
AND = 8 # connector
|
AND = 8 # multi-record: AND-condition record (firmware ≥3.0.0)
|
||||||
OR = 9 # connector
|
OR = 9 # multi-record: OR-alternative record (firmware ≥3.0.0)
|
||||||
THEN = 10 # connector
|
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):
|
class ProgramCond(IntEnum):
|
||||||
@ -381,6 +453,7 @@ def _decode_common(body: bytes) -> dict[str, object]:
|
|||||||
|
|
||||||
if prog_type == ProgramType.REMARK:
|
if prog_type == ProgramType.REMARK:
|
||||||
# bytes 1-4 are a single BE u32 RemarkID instead of cond/cond2.
|
# 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 = (
|
remark_id: int | None = (
|
||||||
(body[1] << 24) | (body[2] << 16) | (body[3] << 8) | body[4]
|
(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
|
cond2 = 0
|
||||||
else:
|
else:
|
||||||
remark_id = None
|
remark_id = None
|
||||||
cond = (body[1] << 8) | body[2]
|
# cond, cond2, pr2 are little-endian u16 — empirically confirmed
|
||||||
cond2 = (body[3] << 8) | body[4]
|
# 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]
|
cmd = body[5]
|
||||||
par = body[6]
|
par = body[6]
|
||||||
pr2 = (body[7] << 8) | body[8]
|
pr2 = (body[8] << 8) | body[7]
|
||||||
days = body[11]
|
days = body[11]
|
||||||
hour = body[12]
|
hour = body[12]
|
||||||
minute = body[13]
|
minute = body[13]
|
||||||
@ -426,14 +501,15 @@ def _encode_common(p: Program) -> bytearray:
|
|||||||
buf[3] = (rid >> 8) & 0xFF
|
buf[3] = (rid >> 8) & 0xFF
|
||||||
buf[4] = rid & 0xFF
|
buf[4] = rid & 0xFF
|
||||||
else:
|
else:
|
||||||
buf[1] = (p.cond >> 8) & 0xFF
|
# cond, cond2, pr2 are little-endian — see _decode_common
|
||||||
buf[2] = p.cond & 0xFF
|
buf[1] = p.cond & 0xFF
|
||||||
buf[3] = (p.cond2 >> 8) & 0xFF
|
buf[2] = (p.cond >> 8) & 0xFF
|
||||||
buf[4] = p.cond2 & 0xFF
|
buf[3] = p.cond2 & 0xFF
|
||||||
|
buf[4] = (p.cond2 >> 8) & 0xFF
|
||||||
buf[5] = p.cmd & 0xFF
|
buf[5] = p.cmd & 0xFF
|
||||||
buf[6] = p.par & 0xFF
|
buf[6] = p.par & 0xFF
|
||||||
buf[7] = (p.pr2 >> 8) & 0xFF
|
buf[7] = p.pr2 & 0xFF
|
||||||
buf[8] = p.pr2 & 0xFF
|
buf[8] = (p.pr2 >> 8) & 0xFF
|
||||||
# 9, 10 filled by encode_{wire,file}_bytes
|
# 9, 10 filled by encode_{wire,file}_bytes
|
||||||
buf[11] = p.days & 0xFF
|
buf[11] = p.days & 0xFF
|
||||||
buf[12] = p.hour & 0xFF
|
buf[12] = p.hour & 0xFF
|
||||||
|
|||||||
@ -33,16 +33,25 @@ from omni_pca.programs import (
|
|||||||
|
|
||||||
|
|
||||||
def test_timed_decodes_canonical_example() -> None:
|
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(" ", ""))
|
body = bytes.fromhex("018d099b094403010008 0c3e070f".replace(" ", ""))
|
||||||
p = Program.from_file_record(body, slot=22)
|
p = Program.from_file_record(body, slot=22)
|
||||||
assert p.slot == 22
|
assert p.slot == 22
|
||||||
assert p.prog_type == ProgramType.TIMED
|
assert p.prog_type == ProgramType.TIMED
|
||||||
assert p.cond == 0x8D09
|
# bytes 1,2 = [8d 09] → LE u16 = 0x098D
|
||||||
assert p.cond2 == 0x9B09
|
assert p.cond == 0x098D
|
||||||
|
# bytes 3,4 = [9b 09] → LE u16 = 0x099B
|
||||||
|
assert p.cond2 == 0x099B
|
||||||
assert p.cmd == 0x44
|
assert p.cmd == 0x44
|
||||||
assert p.par == 3
|
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.month == 8
|
||||||
assert p.day == 12
|
assert p.day == 12
|
||||||
assert p.days == 0x3E
|
assert p.days == 0x3E
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user