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).
This commit is contained in:
Ryan Malloy 2026-05-12 04:57:48 -06:00
parent 23f56e701b
commit e560d98f87
2 changed files with 228 additions and 4 deletions

View File

@ -765,15 +765,88 @@ class Program:
@property @property
def event_id(self) -> int: def event_id(self) -> int:
"""The 16-bit event identifier (only meaningful for EVENT type). """The 16-bit event identifier (only meaningful for EVENT or WHEN type).
Composed as ``(month << 8) | day`` per ``clsProgram.Evt``. For Composed as ``(month << 8) | day`` per ``clsProgram.Evt``. Holds the
non-EVENT program types this is a curiosity at best it will same wire-form value for both compact-form ``EVENT`` records and
still be a 16-bit value but the calendar fields it draws from multi-record ``WHEN`` records both use bytes 9-10 in BE order
(the file-form Mon/Day swap is undone by ``from_file_record``).
For non-EVENT / non-WHEN types this is a curiosity: the value is
still a 16-bit composition, but the calendar fields it draws from
carry their direct meaning instead. carry their direct meaning instead.
""" """
return ((self.month & 0xFF) << 8) | (self.day & 0xFF) return ((self.month & 0xFF) << 8) | (self.day & 0xFF)
# ---- multi-record (firmware ≥3.0.0) decoder properties ----
def is_multi_record(self) -> bool:
"""True iff this record is one of the multi-record ProgTypes.
Multi-record types (``WHEN`` / ``AT`` / ``EVERY`` / ``AND`` /
``OR`` / ``THEN``, values 5-10) appear only on firmware
3.0.0. They form a sequential block: one record per visual
line in the PC Access program-block editor. A complete block
is therefore a *contiguous run* of multi-record records, not
a single record.
"""
return self.prog_type >= ProgramType.WHEN
@property
def and_family(self) -> int:
"""For AND records (ProgType=8): the condition family + operand bits.
Mirrors the *high byte* of the compact-form ``cond`` u16 same
``ProgramCond`` family codes (``ZONE=0x04``, ``CTRL=0x08``,
``TIME=0x0C``, ``SEC=0x10``) plus bit 1 (= bit 9 of the u16)
as the operand (e.g. ``0x0A`` = CTRL + ON, ``0x06`` = ZONE +
NOT_READY).
Empirical evidence: ``AND IF ZONE 5 SECURE`` ``0x04``,
``AND IF UNIT 1 ON`` ``0x0A``, ``AND IF NEVER`` ``0x00``.
Only meaningful when ``prog_type == AND``. For other types the
value is whatever happens to be at byte 1 of the record.
"""
# Disk byte 1 ↔ low byte of the LE-decoded ``cond`` field
# (the Read function's LE swap puts disk byte 1 into the high
# nibble of the in-memory u16, which we then expose as ``cond``).
return self.cond & 0xFF
@property
def and_instance(self) -> int:
"""For AND records (ProgType=8): the object/instance number.
Stored as a BE u16 at bytes 3-4 of the AND record. Returns:
zone # for ZONE family, unit # for CTRL family,
``MiscConditional`` value for OTHER family, etc.
Empirical evidence: ``AND IF ZONE 5 SECURE`` 5,
``AND IF UNIT 1 ON`` 1, ``AND IF NEVER`` 1
(MiscConditional.NEVER).
Only meaningful when ``prog_type == AND``.
"""
# Disk bytes 3-4 = BE u16, but ``cond2`` was LE-decoded.
# The BE-interpreted value is the byte-swap of ``cond2``.
return ((self.cond2 & 0xFF) << 8) | ((self.cond2 >> 8) & 0xFF)
@property
def every_interval(self) -> int:
"""For EVERY records (ProgType=7): the recurrence interval.
Stored as a BE u16 at bytes 3-4. PC Access exposes preset
values like "5 SECONDS", "10 SECONDS", "1 MINUTE", etc.; the
unit of the integer (seconds vs minutes vs hours) is decided
by the controller firmware needs more captures with varied
UI selections to disambiguate. The "5 SECONDS" UI default
encodes as ``every_interval == 5``.
Only meaningful when ``prog_type == EVERY``.
"""
# Same byte-swap rationale as ``and_instance`` — bytes 3-4
# are BE on disk but ``cond2`` is LE-decoded.
return ((self.cond2 & 0xFF) << 8) | ((self.cond2 >> 8) & 0xFF)
def is_empty(self) -> bool: def is_empty(self) -> bool:
"""True iff the encoded record would be all-zero. """True iff the encoded record would be all-zero.

View File

@ -417,3 +417,154 @@ def test_misc_conditional_enum_matches_csharp() -> None:
assert MiscConditional.AC_POWER_OFF == 8 assert MiscConditional.AC_POWER_OFF == 8
assert MiscConditional.BATTERY_OK == 11 assert MiscConditional.BATTERY_OK == 11
assert MiscConditional.ENERGY_COST_CRITICAL == 15 assert MiscConditional.ENERGY_COST_CRITICAL == 15
# ---- multi-record (firmware ≥3.0.0) decoder properties ----------------
def test_is_multi_record_classifier() -> None:
"""Compact-form ProgTypes (0-4) are NOT multi-record; 5-10 ARE."""
for pt in (
ProgramType.FREE,
ProgramType.TIMED,
ProgramType.EVENT,
ProgramType.YEARLY,
ProgramType.REMARK,
):
p = Program(prog_type=int(pt))
assert not p.is_multi_record(), f"{pt.name} should NOT be multi-record"
for pt in (
ProgramType.WHEN,
ProgramType.AT,
ProgramType.EVERY,
ProgramType.AND,
ProgramType.OR,
ProgramType.THEN,
):
p = Program(prog_type=int(pt))
assert p.is_multi_record(), f"{pt.name} SHOULD be multi-record"
def test_when_event_id_zone_5_secure() -> None:
"""WHEN record bytes 9-10 = (family, instance) in BE wire form.
Empirical capture: "WHEN ZONE 5 SECURE" yields bytes 9-10 = [04, 05]
event_id = 0x0405 (= (ZONE=4, instance=5)).
"""
body = bytes.fromhex("05 00 00 00 00 00 00 00 00 04 05 00 00 00".replace(" ", ""))
p = Program.from_file_record(body, slot=17)
assert p.prog_type == ProgramType.WHEN
assert p.event_id == 0x0405
# The family code 0x04 in the high byte matches ProgramCond.ZONE
assert (p.event_id >> 8) & 0xFC == 0x04 # ZONE family
assert p.event_id & 0xFF == 0x05 # zone # 5
def test_when_event_id_zone_1_secure() -> None:
"""Second WHEN capture: ZONE 1 SECURE → event_id 0x0401."""
body = bytes.fromhex("05 00 00 00 00 00 00 00 00 04 01 00 00 00".replace(" ", ""))
p = Program.from_file_record(body, slot=6)
assert p.prog_type == ProgramType.WHEN
assert p.event_id == 0x0401
def test_every_interval_5_seconds() -> None:
"""EVERY record: interval at bytes 3-4 BE.
Empirical capture: "EVERY 5 SECONDS" trigger yields
08 00 00 00 05 00 ... at byte positions 0-5 (ProgType=7 at byte 0,
then zeros until byte 4 = 0x05 holding the interval low byte).
"""
body = bytes.fromhex("07 00 00 00 05 00 00 00 00 00 00 00 00 00".replace(" ", ""))
p = Program.from_file_record(body, slot=2)
assert p.prog_type == ProgramType.EVERY
assert p.every_interval == 5
def test_and_unit_1_on() -> None:
"""AND IF UNIT 1 ON: byte 1 = 0x0A (CTRL family + ON bit), bytes 3-4 BE = 1.
Empirical capture from block 9 slot 18 the structured AND test.
"""
body = bytes.fromhex("08 0a 00 00 01 00 00 00 00 00 00 00 00 00".replace(" ", ""))
p = Program.from_file_record(body, slot=18)
assert p.prog_type == ProgramType.AND
# Byte 1 = 0x0a in the high byte means CTRL family (0x08) + ON bit (0x02)
assert p.and_family == 0x0A
# Family code (top 6 bits): CTRL = 0x08
assert p.and_family & 0xFC == 0x08
# Operand bit (bit 1 of family byte = bit 9 of compact cond u16): ON
assert p.and_family & 0x02 == 0x02
# Instance = unit #
assert p.and_instance == 1
def test_and_zone_5_secure() -> None:
"""AND IF ZONE 5 SECURE: byte 1 = 0x04 (ZONE + SECURE), bytes 3-4 BE = 5."""
body = bytes.fromhex("08 04 00 00 05 00 00 00 00 00 00 00 00 00".replace(" ", ""))
p = Program.from_file_record(body, slot=7)
assert p.prog_type == ProgramType.AND
assert p.and_family == 0x04 # ZONE family, SECURE operand (bit 1 = 0)
assert p.and_family & 0xFC == 0x04 # ZONE family
assert p.and_family & 0x02 == 0 # SECURE (operand bit clear)
assert p.and_instance == 5 # zone # 5
def test_and_never() -> None:
"""AND IF NEVER: byte 1 = 0x00 (OTHER family), bytes 3-4 BE = 1 (NEVER value)."""
body = bytes.fromhex("08 00 00 00 01 00 00 00 00 00 00 00 00 00".replace(" ", ""))
p = Program.from_file_record(body, slot=8)
assert p.prog_type == ProgramType.AND
assert p.and_family == 0x00 # OTHER family
assert p.and_instance == int(MiscConditional.NEVER) # = 1
def test_at_record_layout() -> None:
"""AT record (multi-record TIMED): same byte layout as compact TIMED.
Empirical capture: AT 12:01 AM all-7-days yields:
06 00 00 00 00 00 00 00 00 05 0c fe 00 01
Where bytes 9-10 = [05, 0c] (month=5, day=12; no Mon/Day swap
since AT isn't EVENT-typed), byte 11 = 0xfe (Days: all 7),
bytes 12-13 = 00:01.
"""
body = bytes.fromhex("06 00 00 00 00 00 00 00 00 05 0c fe 00 01".replace(" ", ""))
p = Program.from_file_record(body, slot=7)
assert p.prog_type == ProgramType.AT
assert p.month == 5
assert p.day == 12
assert p.days == 0xFE # MTWTFSS (bit 1 through bit 7)
assert p.hour == 0
assert p.minute == 1
def test_or_record_is_pure_discriminator() -> None:
"""OR record: only ProgType set, all other bytes zero."""
body = bytes.fromhex("09 00 00 00 00 00 00 00 00 00 00 00 00 00".replace(" ", ""))
p = Program.from_file_record(body, slot=10)
assert p.prog_type == ProgramType.OR
assert p.cond == 0
assert p.cond2 == 0
assert p.cmd == 0
assert p.par == 0
assert p.pr2 == 0
assert p.month == 0
assert p.day == 0
assert p.days == 0
assert p.hour == 0
assert p.minute == 0
def test_then_record_uses_compact_action_layout() -> None:
"""THEN record (multi-record action): same cmd/par/pr2 layout as compact form.
Empirical capture: THEN UNIT 1 ON yields
0a 00 00 00 00 01 00 01 00 00 00 00 00 00
with cmd=1 (On), par=0, pr2=1 (UNIT 1, LE).
"""
body = bytes.fromhex("0a 00 00 00 00 01 00 01 00 00 00 00 00 00".replace(" ", ""))
p = Program.from_file_record(body, slot=10)
assert p.prog_type == ProgramType.THEN
assert p.cmd == 1 # enuUnitCommand.On
assert p.par == 0
assert p.pr2 == 1 # UNIT 1 (LE u16 at bytes 7-8, same as compact)