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
248 lines
8.4 KiB
Python
248 lines
8.4 KiB
Python
"""Unit tests for omni_pca.programs.
|
|
|
|
Three layers of evidence (no external oracle, so we triangulate):
|
|
|
|
* **Golden bytes per ProgramType** — hand-curated byte vectors that
|
|
exercise the layout-specific paths (Mon/Day swap for Event, Remark
|
|
variant with RemarkID at bytes 1-4).
|
|
* **Round-trip property** — random 14-byte inputs survive
|
|
``decode(b).encode() == b`` for both wire and file layouts.
|
|
* **Unknown-enum tolerance** — bytes outside ``ProgramType`` /
|
|
``Command`` enum domains pass through without raising.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import struct
|
|
import warnings
|
|
|
|
import pytest
|
|
|
|
from omni_pca.programs import (
|
|
MAX_PROGRAMS,
|
|
PROGRAM_BYTES,
|
|
Days,
|
|
Program,
|
|
ProgramType,
|
|
decode_program_table,
|
|
iter_defined,
|
|
)
|
|
|
|
# ---- golden bytes ---------------------------------------------------------
|
|
|
|
|
|
def test_timed_decodes_canonical_example() -> None:
|
|
"""The worked example from the docs page — TIMED program."""
|
|
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
|
|
assert p.cmd == 0x44
|
|
assert p.par == 3
|
|
assert p.pr2 == 0x0100
|
|
assert p.month == 8
|
|
assert p.day == 12
|
|
assert p.days == 0x3E
|
|
assert p.hour == 7
|
|
assert p.minute == 15
|
|
assert p.remark_id is None
|
|
# round-trip on file form
|
|
assert p.encode_file_record() == body
|
|
|
|
|
|
def test_event_swaps_mon_day_on_file_layout() -> None:
|
|
"""EVENT programs store [day, month] at offsets 9/10 on disk.
|
|
|
|
File bytes 9-10 = ``05 0c`` should decode to day=5, month=12 (not
|
|
month=5, day=12). Encoding back must preserve the swap so the
|
|
raw .pca slot bytes don't drift.
|
|
"""
|
|
body = bytes.fromhex("020c04000001010000050cff070f") # 14 bytes
|
|
assert len(body) == 14
|
|
p = Program.from_file_record(body, slot=1)
|
|
assert p.prog_type == ProgramType.EVENT
|
|
# Disk had [05, 0c] but EVENT swap means [day, mon].
|
|
assert p.day == 5
|
|
assert p.month == 12
|
|
# Round-trip MUST re-apply the swap.
|
|
assert p.encode_file_record() == body
|
|
|
|
|
|
def test_event_no_swap_on_wire_layout() -> None:
|
|
"""Same EVENT-type bytes via the wire decoder: NO swap.
|
|
|
|
On the wire ``clsOLMsgProgramData`` always stores [month, day] at
|
|
offsets 9/10 regardless of prog_type — only the .pca file form
|
|
swaps. This test catches a regression where we accidentally swap
|
|
in the wire path.
|
|
"""
|
|
body = bytes.fromhex("020c04000001010000050cff070f")
|
|
p = Program.from_wire_bytes(body, slot=1)
|
|
assert p.prog_type == ProgramType.EVENT
|
|
# Wire form: byte 9 = month, byte 10 = day. NO swap.
|
|
assert p.month == 5
|
|
assert p.day == 12
|
|
assert p.encode_wire_bytes() == body
|
|
|
|
|
|
def test_yearly_program() -> None:
|
|
"""YEARLY programs use month + day fields semantically — no swap."""
|
|
body = bytes.fromhex("03000000000100008b010a0f00000001")[:14]
|
|
p = Program.from_file_record(body, slot=10)
|
|
assert p.prog_type == ProgramType.YEARLY
|
|
assert p.month == 0x01
|
|
assert p.day == 0x0A
|
|
assert p.encode_file_record() == body
|
|
|
|
|
|
def test_remark_uses_bytes_1_to_4_as_remark_id() -> None:
|
|
"""REMARK programs (prog_type=4) pack a 32-bit BE RemarkID into
|
|
bytes 1-4 in place of cond + cond2."""
|
|
remark_id = 0xDEADBEEF
|
|
body = (
|
|
bytes([int(ProgramType.REMARK)])
|
|
+ struct.pack(">I", remark_id)
|
|
+ bytes(9)
|
|
)
|
|
assert len(body) == 14
|
|
p = Program.from_file_record(body)
|
|
assert p.prog_type == ProgramType.REMARK
|
|
assert p.remark_id == remark_id
|
|
# cond / cond2 are zeroed in the dataclass — the bytes there are
|
|
# the RemarkID, not condition fields.
|
|
assert p.cond == 0
|
|
assert p.cond2 == 0
|
|
# Round-trip restores the RemarkID bytes verbatim.
|
|
assert p.encode_file_record() == body
|
|
|
|
|
|
def test_all_zero_slot_is_empty() -> None:
|
|
"""A free slot decodes cleanly and round-trips."""
|
|
p = Program.from_file_record(b"\x00" * 14, slot=999)
|
|
assert p.is_empty()
|
|
assert p.prog_type == ProgramType.FREE
|
|
assert p.encode_file_record() == b"\x00" * 14
|
|
assert p.encode_wire_bytes() == b"\x00" * 14
|
|
|
|
|
|
# ---- round-trip property ---------------------------------------------------
|
|
|
|
|
|
def test_random_round_trip_wire() -> None:
|
|
"""500 random 14-byte inputs: ``decode_wire → encode_wire == input``.
|
|
|
|
The wire path is the simpler one (no Mon/Day swap), so it should
|
|
round-trip every byte pattern losslessly.
|
|
"""
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore", UserWarning)
|
|
for _ in range(500):
|
|
body = os.urandom(14)
|
|
# Skip Remark inputs in this round — the dataclass discards
|
|
# cond/cond2 for Remark types and re-derives them from
|
|
# remark_id, but with no separate cond field we'd lose
|
|
# bytes that happen to differ; the next test covers Remark
|
|
# explicitly.
|
|
if body[0] == int(ProgramType.REMARK):
|
|
continue
|
|
p = Program.from_wire_bytes(body)
|
|
assert p.encode_wire_bytes() == body
|
|
|
|
|
|
def test_random_round_trip_file() -> None:
|
|
"""500 random 14-byte inputs through the file (Mon/Day swap) form."""
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore", UserWarning)
|
|
for _ in range(500):
|
|
body = os.urandom(14)
|
|
if body[0] == int(ProgramType.REMARK):
|
|
continue
|
|
p = Program.from_file_record(body)
|
|
assert p.encode_file_record() == body
|
|
|
|
|
|
def test_remark_round_trip() -> None:
|
|
"""Remark variant round-trip — explicitly, with random RemarkIDs."""
|
|
for _ in range(200):
|
|
remark_id_bytes = os.urandom(4)
|
|
body = (
|
|
bytes([int(ProgramType.REMARK)])
|
|
+ remark_id_bytes
|
|
+ os.urandom(9)
|
|
)
|
|
p_file = Program.from_file_record(body)
|
|
assert p_file.encode_file_record() == body
|
|
p_wire = Program.from_wire_bytes(body)
|
|
assert p_wire.encode_wire_bytes() == body
|
|
|
|
|
|
# ---- unknown-enum tolerance ------------------------------------------------
|
|
|
|
|
|
def test_unknown_prog_type_passes_through_with_warning() -> None:
|
|
"""Bytes outside ProgramType (0..10) decode to a raw int + warning.
|
|
|
|
Reset the once-per-process cache first; otherwise earlier random
|
|
round-trip tests may have already seen this value and silenced
|
|
the warning.
|
|
"""
|
|
import omni_pca.programs as pm
|
|
pm._warned_unknown.clear()
|
|
body = bytes([0x42]) + bytes(13)
|
|
with warnings.catch_warnings(record=True) as caught:
|
|
warnings.simplefilter("always")
|
|
p = Program.from_wire_bytes(body)
|
|
assert p.prog_type == 0x42
|
|
assert p.encode_wire_bytes() == body
|
|
assert any("ProgramType" in str(w.message) for w in caught)
|
|
|
|
|
|
def test_unknown_cmd_passes_through() -> None:
|
|
"""Unrecognised cmd bytes decode without raising."""
|
|
body = bytes([int(ProgramType.TIMED), 0, 0, 0, 0, 0xFA, 0]) + bytes(7)
|
|
p = Program.from_wire_bytes(body)
|
|
assert p.cmd == 0xFA
|
|
assert p.encode_wire_bytes() == body
|
|
|
|
|
|
# ---- table decode ----------------------------------------------------------
|
|
|
|
|
|
def test_decode_program_table_size_validation() -> None:
|
|
with pytest.raises(ValueError, match="must be 21000 bytes"):
|
|
decode_program_table(b"\x00" * 100)
|
|
|
|
|
|
def test_decode_program_table_round_trip_all_zero() -> None:
|
|
"""All-zero 21000-byte blob round-trips, slot numbers are 1..1500."""
|
|
blob = b"\x00" * (MAX_PROGRAMS * PROGRAM_BYTES)
|
|
programs = decode_program_table(blob)
|
|
assert len(programs) == MAX_PROGRAMS
|
|
assert programs[0].slot == 1
|
|
assert programs[-1].slot == MAX_PROGRAMS
|
|
assert all(p.is_empty() for p in programs)
|
|
assert list(iter_defined(programs)) == []
|
|
rebuilt = b"".join(p.encode_file_record() for p in programs)
|
|
assert rebuilt == blob
|
|
|
|
|
|
def test_iter_defined_filters_empty() -> None:
|
|
p1 = Program(prog_type=int(ProgramType.TIMED), slot=1, cmd=1, hour=8)
|
|
p2 = Program(slot=2) # empty
|
|
p3 = Program(prog_type=int(ProgramType.EVENT), slot=3, cmd=2)
|
|
defined = list(iter_defined((p1, p2, p3)))
|
|
assert defined == [p1, p3]
|
|
|
|
|
|
# ---- Days bitmask sanity --------------------------------------------------
|
|
|
|
|
|
def test_days_bitmask_values() -> None:
|
|
"""Sanity-check the enuDays values against the C# definition."""
|
|
assert Days.MONDAY == 0x02
|
|
assert Days.SUNDAY == 0x80
|
|
assert Days.MONDAY | Days.WEDNESDAY | Days.FRIDAY == 0x2A
|