pca_file: AccountRemarks_Extended + 9 per-family Description tables

The PCA03 post-Connection extension was previously walked as opaque
bytes — _walk_to_remarks read past AccountRemarks_Extended and the
nine 33-byte-per-slot Description tables only to advance the cursor
to the actual Remarks dict.

This pass keeps the data instead. New PcaAccount fields, all populated
only when FileVersion >= 3:

* account_remarks_extended — free-text installer notes (repr=False)
* zone/unit/button/code/thermostat/area/message/audio_source/audio_zone
  _descriptions — per-family {slot: description} dicts

Per-slot format inside each Description block is the same String8(32)
that names use elsewhere: 1 length byte + 32 padded bytes, decoded
to UTF-8 with NUL-strip. The leading u32 count can exceed the family's
actual object count (real panels write the max-slot count regardless
of how many are populated); we read all of them and filter empties.

Live fixture decodes cleanly: every Description table is empty
(homeowner never filled them in — that's reality, not a parser
fault). The hand-built synthetic test in test_pca_file proves the
decode works when the data is actually present (zones 1+2 with
descriptions "FOYER!" and "GARAGE LT").

_walk_to_remarks now returns a _RemarksWalk dataclass aggregating
all of the post-Connection extraction; existing remarks-related
tests updated to use the new return shape.

Full suite: 499 passed, 1 skipped.
This commit is contained in:
Ryan Malloy 2026-05-13 22:32:20 -06:00
parent 7b789f8cfb
commit 362580bccc
2 changed files with 119 additions and 34 deletions

View File

@ -214,6 +214,27 @@ class PcaAccount:
basis failure here doesn't break Connection extraction. basis failure here doesn't break Connection extraction.
""" """
# Free-text "account remarks" block (a PCA03 extension; appears
# between the ModemBaud flags and the nine Description blocks).
# Used by PC Access for installer notes about the site.
account_remarks_extended: str = field(default="", repr=False)
# Per-object Description tables — free-text "description" strings
# entered alongside each object's name in PC Access (e.g. zone 1's
# name is "FRONT DOOR" and its description might be "Solid wood,
# contacts at top hinge"). Keys are 1-based slot numbers, values
# are decoded UTF-8 strings (max 32 chars). Empty slots are
# omitted. Populated only for FileVersion >= 3.
zone_descriptions: dict[int, str] = field(default_factory=dict)
unit_descriptions: dict[int, str] = field(default_factory=dict)
button_descriptions: dict[int, str] = field(default_factory=dict)
code_descriptions: dict[int, str] = field(default_factory=dict)
thermostat_descriptions: dict[int, str] = field(default_factory=dict)
area_descriptions: dict[int, str] = field(default_factory=dict)
message_descriptions: dict[int, str] = field(default_factory=dict)
audio_source_descriptions: dict[int, str] = field(default_factory=dict)
audio_zone_descriptions: dict[int, str] = field(default_factory=dict)
# Per-object name tables — populated from the Names block between # Per-object name tables — populated from the Names block between
# SetupData and Voices. Keys are 1-based slot numbers; empty slots # SetupData and Voices. Keys are 1-based slot numbers; empty slots
# are omitted entirely (the panel stores them as length-0 String8 # are omitted entirely (the panel stores them as length-0 String8
@ -561,52 +582,92 @@ def _parse_header(r: PcaReader) -> tuple[str, int, int, int, int, int, str, str,
_DESCRIPTION_SLOT_BYTES: Final[int] = 1 + 32 _DESCRIPTION_SLOT_BYTES: Final[int] = 1 + 32
def _walk_to_remarks(r: PcaReader) -> dict[int, str]: @dataclass
"""Pick up just-past Connection and walk through the description class _RemarksWalk:
blocks, then read and return the Remarks dict. """Side-channel output of :func:`_walk_to_remarks`.
Layout for PCA03 (FileVersion 3, per clsHAC.cs:8055-8079): Captures everything in the post-Connection PCA03 extension:
AccountRemarks_Extended (free text), the nine per-family
Description tables, and the Remarks IDtext dict that
Remark-typed programs reference.
"""
account_remarks_extended: str = ""
zone_descriptions: dict[int, str] = field(default_factory=dict)
unit_descriptions: dict[int, str] = field(default_factory=dict)
button_descriptions: dict[int, str] = field(default_factory=dict)
code_descriptions: dict[int, str] = field(default_factory=dict)
thermostat_descriptions: dict[int, str] = field(default_factory=dict)
area_descriptions: dict[int, str] = field(default_factory=dict)
message_descriptions: dict[int, str] = field(default_factory=dict)
audio_source_descriptions: dict[int, str] = field(default_factory=dict)
audio_zone_descriptions: dict[int, str] = field(default_factory=dict)
remarks: dict[int, str] = field(default_factory=dict)
def _read_description_table(r: PcaReader) -> dict[int, str]:
"""Read a per-family Description block: u32 count, then count ×
String8(32). Returns {1-based slot: description} omitting empties.
Mirrors ``clsZones.ReadDescription`` and friends the format is
identical across object families.
"""
count = r.u32()
out: dict[int, str] = {}
for i in range(1, count + 1):
text = r.string8_fixed(32)
if text:
out[i] = text
return out
def _walk_to_remarks(r: PcaReader) -> _RemarksWalk:
"""Walk the PCA03 post-Connection extension.
Layout for PCA03 (FileVersion 3, per clsHAC.cs:8058-8079):
1. ``ModemBaud`` (u16 LE), 3× bool (1 byte each), AccountRemarks_Extended 1. ``ModemBaud`` (u16 LE), 3× bool (1 byte each), AccountRemarks_Extended
(String16: u16 length + bytes). (String16: u16 length + bytes).
2. Nine Description blocks, one per object family Zones, Units, 2. Nine Description blocks in the order Zones, Units, Buttons, Codes,
Buttons, Codes, Thermostats, Areas, Messages, AudioSources, Thermostats, Areas, Messages, AudioSources, AudioZones. Each is
AudioZones. Each is ``[u32 count] + count * 33 bytes`` (per ``[u32 count] + count * 33 bytes`` (per :data:`_DESCRIPTION_SLOT_BYTES`).
:data:`_DESCRIPTION_SLOT_BYTES`); the contents are per-object The 33-byte slots are String8(32) 1 length byte + 32 padded bytes.
free-text descriptions we don't currently surface.
3. Remarks table: 3. Remarks table:
- ``_RemarksNextID`` (u32 LE) what ``RemarksNextID()`` will - ``_RemarksNextID`` (u32 LE) what ``RemarksNextID()`` will
hand out next. hand out next.
- ``count`` (u32 LE). - ``count`` (u32 LE).
- ``count`` entries of ``[u32 LE remark_id][String16 text]``. - ``count`` entries of ``[u32 LE remark_id][String16 text]``.
Returns ``{}`` on any read failure (panels without remarks, or a Returns an empty :class:`_RemarksWalk` on any read failure (panels
file format we don't recognise, just produce an empty dict — without these blocks, or a file format we don't recognise).
callers shouldn't need to special-case that).
Reference: clsPrograms.ReadRemarks (clsPrograms.cs:148-168), Reference: clsPrograms.ReadRemarks (clsPrograms.cs:148-168),
clsAbstractNamedItem.ReadDescription, clsHAC.cs:8055-8079. clsAbstractNamedItem.ReadDescription, clsHAC.cs:8058-8079.
""" """
walk = _RemarksWalk()
try: try:
r.u16() # ModemBaud r.u16() # ModemBaud
r.u8(); r.u8(); r.u8() # PCModemInit1/2/3 enable flags r.u8(); r.u8(); r.u8() # PCModemInit1/2/3 enable flags
r.string16() # AccountRemarks_Extended (variable) walk.account_remarks_extended = r.string16()
# Nine description blocks. walk.zone_descriptions = _read_description_table(r)
for _ in range(9): walk.unit_descriptions = _read_description_table(r)
count = r.u32() walk.button_descriptions = _read_description_table(r)
if count > 0: walk.code_descriptions = _read_description_table(r)
r.bytes_(count * _DESCRIPTION_SLOT_BYTES) walk.thermostat_descriptions = _read_description_table(r)
walk.area_descriptions = _read_description_table(r)
walk.message_descriptions = _read_description_table(r)
walk.audio_source_descriptions = _read_description_table(r)
walk.audio_zone_descriptions = _read_description_table(r)
# Remarks table. # Remarks table.
r.u32() # _RemarksNextID r.u32() # _RemarksNextID
remark_count = r.u32() remark_count = r.u32()
out: dict[int, str] = {}
for _ in range(remark_count): for _ in range(remark_count):
rid = r.u32() rid = r.u32()
text = r.string16() text = r.string16()
out[rid] = text walk.remarks[rid] = text
return out return walk
except (EOFError, ValueError, struct.error): except (EOFError, ValueError, struct.error):
return {} return walk
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@ -1115,11 +1176,23 @@ def parse_pca_file(path_or_bytes: str | os.PathLike[str] | bytes, key: int) -> P
account.temp_format = walk.temp_format account.temp_format = walk.temp_format
account.num_areas_used = walk.num_areas_used account.num_areas_used = walk.num_areas_used
# PCA03+ continues past Connection with ModemBaud flags + nine # PCA03+ continues past Connection with ModemBaud flags +
# Description blocks + the Remarks table. We walk it on a # AccountRemarks_Extended + nine Description blocks + the Remarks
# best-effort basis — a failure here leaves account.remarks={} # table. We walk it on a best-effort basis — a failure here leaves
# without affecting the Connection fields above. # the post-Connection fields empty without affecting the Connection
# fields above.
if file_version >= 3: if file_version >= 3:
account.remarks = _walk_to_remarks(r) rwalk = _walk_to_remarks(r)
account.account_remarks_extended = rwalk.account_remarks_extended
account.zone_descriptions = rwalk.zone_descriptions
account.unit_descriptions = rwalk.unit_descriptions
account.button_descriptions = rwalk.button_descriptions
account.code_descriptions = rwalk.code_descriptions
account.thermostat_descriptions = rwalk.thermostat_descriptions
account.area_descriptions = rwalk.area_descriptions
account.message_descriptions = rwalk.message_descriptions
account.audio_source_descriptions = rwalk.audio_source_descriptions
account.audio_zone_descriptions = rwalk.audio_zone_descriptions
account.remarks = rwalk.remarks
return account return account

View File

@ -223,7 +223,10 @@ def test_remarks_walker_on_empty_table() -> None:
+ struct.pack("<I", 0) # count = 0 + struct.pack("<I", 0) # count = 0
) )
r = PcaReader(blob) r = PcaReader(blob)
assert _walk_to_remarks(r) == {} walk = _walk_to_remarks(r)
assert walk.remarks == {}
assert walk.account_remarks_extended == ""
assert walk.zone_descriptions == {}
def test_remarks_walker_decodes_real_entries() -> None: def test_remarks_walker_decodes_real_entries() -> None:
@ -265,20 +268,25 @@ def test_remarks_walker_decodes_real_entries() -> None:
+ remarks_block + remarks_block
) )
r = PcaReader(tail) r = PcaReader(tail)
remarks = _walk_to_remarks(r) walk = _walk_to_remarks(r)
assert remarks == { assert walk.remarks == {
1: "TURN ON LIVING ROOM LIGHTS", 1: "TURN ON LIVING ROOM LIGHTS",
7: "DOG WALK TIME", 7: "DOG WALK TIME",
0xDEADBEEF: "UTF-8 ✓ ☃ ♥", 0xDEADBEEF: "UTF-8 ✓ ☃ ♥",
} }
# The two synthetic zone-description entries decoded too.
assert walk.zone_descriptions == {1: "FOYER!", 2: "GARAGE LT"}
def test_remarks_walker_returns_empty_on_truncated_input() -> None: def test_remarks_walker_returns_empty_on_truncated_input() -> None:
"""A short/garbage tail should yield ``{}``, not raise.""" """A short/garbage tail should yield an empty walk record, not raise."""
from omni_pca.pca_file import PcaReader, _walk_to_remarks from omni_pca.pca_file import PcaReader, _walk_to_remarks
# Way too short to hold even the prelude. # Way too short to hold even the prelude.
assert _walk_to_remarks(PcaReader(b"\x00" * 5)) == {} walk = _walk_to_remarks(PcaReader(b"\x00" * 5))
assert walk.remarks == {}
assert walk.account_remarks_extended == ""
assert walk.zone_descriptions == {}
def test_remarks_resolved_against_live_fixture_is_empty_dict() -> None: def test_remarks_resolved_against_live_fixture_is_empty_dict() -> None:
@ -304,7 +312,11 @@ def test_remarks_resolved_against_live_fixture_is_empty_dict() -> None:
r.string8_fixed(120) r.string8_fixed(120)
r.string8_fixed(5) r.string8_fixed(5)
r.string8_fixed(32) r.string8_fixed(32)
assert _walk_to_remarks(r) == {} walk = _walk_to_remarks(r)
assert walk.remarks == {}
# Live fixture description tables — homeowner left them blank.
assert walk.zone_descriptions == {}
assert walk.unit_descriptions == {}
def test_pca_account_dataclass_has_programs_field() -> None: def test_pca_account_dataclass_has_programs_field() -> None: