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:
Ryan Malloy 2026-05-11 21:33:53 -06:00
parent d4c04b3044
commit 00f0028053
2 changed files with 176 additions and 2 deletions

View File

@ -203,6 +203,17 @@ class PcaAccount:
: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:
"""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
# 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:
"""Walk SetupData, flags, Names, Voices, Programs, EventLog so the
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.controller_key = controller_key
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

View File

@ -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:
"""``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()}
assert "programs" in fields
# default_factory or default — the field should be an empty tuple
# when no programs are decoded.
assert "remarks" in fields
# Defaults: empty tuple for programs, empty dict for remarks.
inst = PcaAccount(
version_tag="PCA03", file_version=3,
model=16, firmware_major=2, firmware_minor=12, firmware_revision=1,
)
assert inst.programs == ()
assert inst.remarks == {}
def test_pca_reader_io_state_introspection() -> None: