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:
parent
7b789f8cfb
commit
362580bccc
@ -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 ID→text 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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user