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:
parent
23f56e701b
commit
e560d98f87
@ -765,15 +765,88 @@ class Program:
|
||||
|
||||
@property
|
||||
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
|
||||
non-EVENT program types this is a curiosity at best — it will
|
||||
still be a 16-bit value but the calendar fields it draws from
|
||||
Composed as ``(month << 8) | day`` per ``clsProgram.Evt``. Holds the
|
||||
same wire-form value for both compact-form ``EVENT`` records and
|
||||
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.
|
||||
"""
|
||||
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:
|
||||
"""True iff the encoded record would be all-zero.
|
||||
|
||||
|
||||
@ -417,3 +417,154 @@ def test_misc_conditional_enum_matches_csharp() -> None:
|
||||
assert MiscConditional.AC_POWER_OFF == 8
|
||||
assert MiscConditional.BATTERY_OK == 11
|
||||
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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user