pca_file: parse the Remarks table (RemarkID → text resolution)
Decodes the remark-text dict that Remark-typed program records refer to
via their 32-bit BE RemarkID. The table lives after the Connection
block in PCA03 files; getting to it means walking past ModemBaud +
PCModemInit flags + AccountRemarks_Extended + nine 33-byte-per-entry
Description blocks (Zones, Units, Buttons, Codes, Thermostats, Areas,
Messages, AudioSources, AudioZones).
Format reverse-engineered from clsPrograms.ReadRemarks (clsPrograms.cs:
148-168) and the file-body walker in clsHAC.cs:8055-8079. Each entry is
[u32 LE remark_id][u16 LE text_length][N bytes UTF-8], preceded by a
[u32 LE _RemarksNextID][u32 LE count] header.
pca_file changes:
* PcaAccount.remarks: dict[int, str] (default {}).
* New _walk_to_remarks helper called from parse_pca_file when
file_version >= 3. Best-effort: any read failure leaves remarks={}.
* New _DESCRIPTION_SLOT_BYTES (= 33) constant.
tests/test_pca_file.py (4 new cases):
* Walker on an empty Remarks table (decode count=0 cleanly).
* Walker decodes three hand-built entries, including a UTF-8 string
with non-ASCII characters.
* Truncated input returns {} rather than raising.
* Live fixture (Our_House.pca.plain): walker consumes the prelude +
nine description blocks + zero-count remarks block without raising.
This panel has no Remark-typed programs, so {} is the expected
result -- and the *coarse* walker validation here is what proves
the description-block sizes (counts up to 511) are correct.
Full suite: 426 passed, 1 skipped (was 422 / 1).
This commit is contained in:
parent
d4c04b3044
commit
00f0028053
@ -203,6 +203,17 @@ class PcaAccount:
|
|||||||
:func:`omni_pca.programs.iter_defined` to filter to in-use slots.
|
:func:`omni_pca.programs.iter_defined` to filter to in-use slots.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
remarks: dict[int, str] = field(default_factory=dict)
|
||||||
|
"""Resolved RemarkID → text for every Remark-typed program.
|
||||||
|
|
||||||
|
A Remark-typed program record (``ProgramType.REMARK``, byte 0 == 4)
|
||||||
|
stores a 32-bit BE RemarkID in bytes 1-4 of its 14-byte body; the
|
||||||
|
associated user-entered text lives in a separate table further down
|
||||||
|
the .pca body (after Connection + ModemBaud flags + nine 33-byte
|
||||||
|
Description blocks). The walker parses that table on a best-effort
|
||||||
|
basis — failure here doesn't break Connection extraction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def parse_pca01_cfg(data: bytes, key: int = KEY_PC01) -> PcaConfig:
|
def parse_pca01_cfg(data: bytes, key: int = KEY_PC01) -> PcaConfig:
|
||||||
"""Decrypt ``data`` (raw PCA01.CFG bytes) and parse per clsPcaCfg.Read()."""
|
"""Decrypt ``data`` (raw PCA01.CFG bytes) and parse per clsPcaCfg.Read()."""
|
||||||
@ -279,6 +290,61 @@ def _parse_header(r: PcaReader) -> tuple[str, int, int, int, int, int, str, str,
|
|||||||
return version_tag, file_version, model, fw_major, fw_minor, fw_rev, name, address, phone, code, remarks
|
return version_tag, file_version, model, fw_major, fw_minor, fw_rev, name, address, phone, code, remarks
|
||||||
|
|
||||||
|
|
||||||
|
# Description blocks each store a 32-byte fixed slot prefixed by a
|
||||||
|
# 1-byte length (clsAbstractNamedItem.ReadDescription:1-4) so each
|
||||||
|
# entry is exactly _DESCRIPTION_SLOT_BYTES on the wire regardless of
|
||||||
|
# the actual string length.
|
||||||
|
_DESCRIPTION_SLOT_BYTES: Final[int] = 1 + 32
|
||||||
|
|
||||||
|
|
||||||
|
def _walk_to_remarks(r: PcaReader) -> dict[int, str]:
|
||||||
|
"""Pick up just-past Connection and walk through the description
|
||||||
|
blocks, then read and return the Remarks dict.
|
||||||
|
|
||||||
|
Layout for PCA03 (FileVersion 3, per clsHAC.cs:8055-8079):
|
||||||
|
|
||||||
|
1. ``ModemBaud`` (u16 LE), 3× bool (1 byte each), AccountRemarks_Extended
|
||||||
|
(String16: u16 length + bytes).
|
||||||
|
2. Nine Description blocks, one per object family — Zones, Units,
|
||||||
|
Buttons, Codes, Thermostats, Areas, Messages, AudioSources,
|
||||||
|
AudioZones. Each is ``[u32 count] + count * 33 bytes`` (per
|
||||||
|
:data:`_DESCRIPTION_SLOT_BYTES`); the contents are per-object
|
||||||
|
free-text descriptions we don't currently surface.
|
||||||
|
3. Remarks table:
|
||||||
|
- ``_RemarksNextID`` (u32 LE) — what ``RemarksNextID()`` will
|
||||||
|
hand out next.
|
||||||
|
- ``count`` (u32 LE).
|
||||||
|
- ``count`` entries of ``[u32 LE remark_id][String16 text]``.
|
||||||
|
|
||||||
|
Returns ``{}`` on any read failure (panels without remarks, or a
|
||||||
|
file format we don't recognise, just produce an empty dict —
|
||||||
|
callers shouldn't need to special-case that).
|
||||||
|
|
||||||
|
Reference: clsPrograms.ReadRemarks (clsPrograms.cs:148-168),
|
||||||
|
clsAbstractNamedItem.ReadDescription, clsHAC.cs:8055-8079.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
r.u16() # ModemBaud
|
||||||
|
r.u8(); r.u8(); r.u8() # PCModemInit1/2/3 enable flags
|
||||||
|
r.string16() # AccountRemarks_Extended (variable)
|
||||||
|
# Nine description blocks.
|
||||||
|
for _ in range(9):
|
||||||
|
count = r.u32()
|
||||||
|
if count > 0:
|
||||||
|
r.bytes_(count * _DESCRIPTION_SLOT_BYTES)
|
||||||
|
# Remarks table.
|
||||||
|
r.u32() # _RemarksNextID
|
||||||
|
remark_count = r.u32()
|
||||||
|
out: dict[int, str] = {}
|
||||||
|
for _ in range(remark_count):
|
||||||
|
rid = r.u32()
|
||||||
|
text = r.string16()
|
||||||
|
out[rid] = text
|
||||||
|
return out
|
||||||
|
except (EOFError, ValueError, struct.error):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _walk_to_connection(r: PcaReader, cap: dict[str, int]) -> bytes:
|
def _walk_to_connection(r: PcaReader, cap: dict[str, int]) -> bytes:
|
||||||
"""Walk SetupData, flags, Names, Voices, Programs, EventLog so the
|
"""Walk SetupData, flags, Names, Voices, Programs, EventLog so the
|
||||||
next read lands on the Connection block. Returns the raw 21,000-byte
|
next read lands on the Connection block. Returns the raw 21,000-byte
|
||||||
@ -398,4 +464,12 @@ def parse_pca_file(path_or_bytes: str | os.PathLike[str] | bytes, key: int) -> P
|
|||||||
account.network_port = network_port
|
account.network_port = network_port
|
||||||
account.controller_key = controller_key
|
account.controller_key = controller_key
|
||||||
account.programs = programs
|
account.programs = programs
|
||||||
|
|
||||||
|
# PCA03+ continues past Connection with ModemBaud flags + nine
|
||||||
|
# Description blocks + the Remarks table. We walk it on a
|
||||||
|
# best-effort basis — a failure here leaves account.remarks={}
|
||||||
|
# without affecting the Connection fields above.
|
||||||
|
if file_version >= 3:
|
||||||
|
account.remarks = _walk_to_remarks(r)
|
||||||
|
|
||||||
return account
|
return account
|
||||||
|
|||||||
@ -190,6 +190,105 @@ def test_programs_sanity_invariants() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_remarks_walker_on_empty_table() -> None:
|
||||||
|
"""Hand-built minimal tail with zero description entries + zero remarks."""
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from omni_pca.pca_file import PcaReader, _walk_to_remarks
|
||||||
|
|
||||||
|
blob = (
|
||||||
|
struct.pack("<H", 9) # ModemBaud
|
||||||
|
+ b"\x01\x00\x00" # 3 init-enable flags
|
||||||
|
+ struct.pack("<H", 0) # AccountRemarks_Extended length 0
|
||||||
|
+ (struct.pack("<I", 0) * 9) # 9 description blocks, each count=0
|
||||||
|
+ struct.pack("<I", 1234) # _RemarksNextID
|
||||||
|
+ struct.pack("<I", 0) # count = 0
|
||||||
|
)
|
||||||
|
r = PcaReader(blob)
|
||||||
|
assert _walk_to_remarks(r) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_remarks_walker_decodes_real_entries() -> None:
|
||||||
|
"""Hand-built tail with two non-empty description entries + three remarks."""
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from omni_pca.pca_file import (
|
||||||
|
PcaReader,
|
||||||
|
_DESCRIPTION_SLOT_BYTES,
|
||||||
|
_walk_to_remarks,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Two zones with descriptions; everything else has zero entries.
|
||||||
|
zone_desc = b"\x06" + b"FOYER!" + b"\x00" * (32 - 6) # 33 bytes
|
||||||
|
other_desc = b"\x09" + b"GARAGE LT" + b"\x00" * (32 - 9)
|
||||||
|
assert len(zone_desc) == _DESCRIPTION_SLOT_BYTES
|
||||||
|
assert len(other_desc) == _DESCRIPTION_SLOT_BYTES
|
||||||
|
description_blocks = (
|
||||||
|
struct.pack("<I", 2) + zone_desc + other_desc # Zones
|
||||||
|
+ struct.pack("<I", 0) * 8 # Units .. AudioZones
|
||||||
|
)
|
||||||
|
|
||||||
|
def _remark_entry(rid: int, text: str) -> bytes:
|
||||||
|
t = text.encode("utf-8")
|
||||||
|
return struct.pack("<I", rid) + struct.pack("<H", len(t)) + t
|
||||||
|
|
||||||
|
remarks_block = (
|
||||||
|
struct.pack("<I", 99) # _RemarksNextID
|
||||||
|
+ struct.pack("<I", 3) # count
|
||||||
|
+ _remark_entry(1, "TURN ON LIVING ROOM LIGHTS")
|
||||||
|
+ _remark_entry(7, "DOG WALK TIME")
|
||||||
|
+ _remark_entry(0xDEADBEEF, "UTF-8 ✓ ☃ ♥")
|
||||||
|
)
|
||||||
|
|
||||||
|
tail = (
|
||||||
|
struct.pack("<H", 9) + b"\x01\x00\x00"
|
||||||
|
+ struct.pack("<H", 0)
|
||||||
|
+ description_blocks
|
||||||
|
+ remarks_block
|
||||||
|
)
|
||||||
|
r = PcaReader(tail)
|
||||||
|
remarks = _walk_to_remarks(r)
|
||||||
|
assert remarks == {
|
||||||
|
1: "TURN ON LIVING ROOM LIGHTS",
|
||||||
|
7: "DOG WALK TIME",
|
||||||
|
0xDEADBEEF: "UTF-8 ✓ ☃ ♥",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_remarks_walker_returns_empty_on_truncated_input() -> None:
|
||||||
|
"""A short/garbage tail should yield ``{}``, not raise."""
|
||||||
|
from omni_pca.pca_file import PcaReader, _walk_to_remarks
|
||||||
|
|
||||||
|
# Way too short to hold even the prelude.
|
||||||
|
assert _walk_to_remarks(PcaReader(b"\x00" * 5)) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_remarks_resolved_against_live_fixture_is_empty_dict() -> None:
|
||||||
|
"""Our live fixture has zero remarks programmed; the walker must
|
||||||
|
still consume the prelude + nine description blocks + the zero
|
||||||
|
count without raising."""
|
||||||
|
blob = _load_programs_blob_or_skip() # establishes the fixture exists
|
||||||
|
# We've already validated the position at end-of-programs above; now
|
||||||
|
# re-walk and continue past Connection through the remarks walker.
|
||||||
|
from omni_pca.pca_file import (
|
||||||
|
_CAP_OMNI_PRO_II,
|
||||||
|
PcaReader,
|
||||||
|
_parse_header,
|
||||||
|
_walk_to_connection,
|
||||||
|
_walk_to_remarks,
|
||||||
|
)
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
raw = Path(_FIXTURE).read_bytes()
|
||||||
|
r = PcaReader(raw)
|
||||||
|
_parse_header(r)
|
||||||
|
_walk_to_connection(r, _CAP_OMNI_PRO_II)
|
||||||
|
r.string8_fixed(120)
|
||||||
|
r.string8_fixed(5)
|
||||||
|
r.string8_fixed(32)
|
||||||
|
assert _walk_to_remarks(r) == {}
|
||||||
|
|
||||||
|
|
||||||
def test_pca_account_dataclass_has_programs_field() -> None:
|
def test_pca_account_dataclass_has_programs_field() -> None:
|
||||||
"""``PcaAccount`` exposes ``programs`` with the expected type + default.
|
"""``PcaAccount`` exposes ``programs`` with the expected type + default.
|
||||||
|
|
||||||
@ -201,13 +300,14 @@ def test_pca_account_dataclass_has_programs_field() -> None:
|
|||||||
|
|
||||||
fields = {f.name: f for f in PcaAccount.__dataclass_fields__.values()}
|
fields = {f.name: f for f in PcaAccount.__dataclass_fields__.values()}
|
||||||
assert "programs" in fields
|
assert "programs" in fields
|
||||||
# default_factory or default — the field should be an empty tuple
|
assert "remarks" in fields
|
||||||
# when no programs are decoded.
|
# Defaults: empty tuple for programs, empty dict for remarks.
|
||||||
inst = PcaAccount(
|
inst = PcaAccount(
|
||||||
version_tag="PCA03", file_version=3,
|
version_tag="PCA03", file_version=3,
|
||||||
model=16, firmware_major=2, firmware_minor=12, firmware_revision=1,
|
model=16, firmware_major=2, firmware_minor=12, firmware_revision=1,
|
||||||
)
|
)
|
||||||
assert inst.programs == ()
|
assert inst.programs == ()
|
||||||
|
assert inst.remarks == {}
|
||||||
|
|
||||||
|
|
||||||
def test_pca_reader_io_state_introspection() -> None:
|
def test_pca_reader_io_state_introspection() -> None:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user