pca_file: extract zone_type from SetupData installer section

SetupData (3840 bytes) holds the panel's per-object property tables.
Layout for OMNI_PRO_II's installer section (Seek to instSetupStart=2560
in clsHAC._ParseSetupData at clsHAC.cs:3156):

  offset 2560: HouseCode (1 byte)
  offsets 2561..2569: OutputType[0..8] (9 bytes; numVoltOutputs)
  offset 2570: ZoneExpansions (1 byte)
  offset 2571: NumExpEnc (1 byte)
  offsets 2572..2747: ZoneType[1..176] (176 bytes; raw enuZoneType per zone)

Verified against the live fixture: 2 EntryExit + 4 Perimeter + 3 AwayInt
+ 1 Extended_Range_OutdoorTemp + 166 Auxiliary (panel default for
unused slots) — matches the named-zones cross-reference exactly.

PcaAccount gains a zone_types dict (1-based slot → raw byte). The
walker stashes the SetupData blob to a buffer up front and indexes
in by offset rather than chasing the sequential parser through all
of telephony/codes/areas — that's a bigger RE pass for another day.

MockZoneState now carries zone_type and area fields. MockState.from_pca
threads acct.zone_types through, and _build_zone_properties uses the
real value instead of hardcoded 0 (EntryExit). End-to-end against
MockPanel.from_pca: HA's discovery now classifies binary vs. analog
zones correctly straight from the .pca — outdoor temp zone surfaces
as a temperature sensor entity, motion sensors as binary_sensor,
door zones as the right kind of binary_sensor.

Full suite: 499 passed, 1 skipped. RE notes in pca_file.py.
This commit is contained in:
Ryan Malloy 2026-05-12 22:18:32 -06:00
parent 7db9616a34
commit 70bf9caf58
4 changed files with 82 additions and 17 deletions

View File

@ -156,6 +156,8 @@ class MockZoneState:
arming_state: int = 0 # 0=disarmed, 16=armed, 32=bypassed, 48=auto-bypassed
is_bypassed: bool = False
loop: int = 0 # analog loop reading
zone_type: int = 0 # raw enuZoneType byte (0=EntryExit default)
area: int = 1 # 1-based area assignment
@property
def status_byte(self) -> int:
@ -283,11 +285,14 @@ class MockState:
table, encoded back to wire bytes so UploadProgram /
UploadPrograms serve them exactly as a real panel would.
* ``zones`` / ``units`` / ``areas`` / ``thermostats`` / ``buttons``
populated with the names from the .pca's Names section. Each
entry is a ``MockZoneState`` / ``MockUnitState`` / etc. with
only ``name`` set; other fields (zone_type, area assignment,
thermostat type, ) default to 0 because those properties live
in SetupData, which we don't decode yet.
populated with the names from the .pca's Names section.
``MockZoneState`` entries additionally carry ``zone_type``
(the raw ``enuZoneType`` byte from the SetupData installer
section), so the mock's Properties replies categorise zones
as security / temperature / humidity matching the source
panel. Other SetupData-resident fields (per-zone area
assignment, unit type, thermostat type, exit/entry delays,
options bitmasks) aren't extracted yet — those default to 0.
``user_codes`` is not seeded the .pca only stores code *names*,
not the PIN values; the panel keeps PINs in SetupData. Override
@ -315,7 +320,13 @@ class MockState:
"firmware_minor": acct.firmware_minor,
"firmware_revision": acct.firmware_revision,
"programs": programs,
"zones": {i: MockZoneState(name=n) for i, n in acct.zone_names.items()},
"zones": {
i: MockZoneState(
name=n,
zone_type=acct.zone_types.get(i, 0),
)
for i, n in acct.zone_names.items()
},
"units": {i: MockUnitState(name=n) for i, n in acct.unit_names.items()},
"areas": {i: MockAreaState(name=n) for i, n in acct.area_names.items()},
"thermostats": {
@ -922,9 +933,9 @@ class MockPanel:
index & 0xFF,
zone.status_byte if zone else 0,
zone.loop if zone else 0,
0, # Type: EntryExit
1, # Area: default to area 1
0, # Options
zone.zone_type if zone else 0,
zone.area if zone else 1,
0, # Options (not yet sourced from SetupData)
]
)
+ self.state.zone_name_bytes(index)

View File

@ -217,9 +217,9 @@ class PcaAccount:
# Per-object name tables — populated from the Names block between
# SetupData and Voices. Keys are 1-based slot numbers; empty slots
# are omitted entirely (the panel stores them as length-0 String8
# blobs, which we filter at read time). Properties beyond the name
# (zone_type, area assignment, thermostat type, etc.) live in
# SetupData and aren't extracted yet.
# blobs, which we filter at read time). Other object properties
# come from the SetupData block (see ``zone_types`` for the first
# such field extracted).
zone_names: dict[int, str] = field(default_factory=dict)
unit_names: dict[int, str] = field(default_factory=dict)
button_names: dict[int, str] = field(default_factory=dict)
@ -228,6 +228,14 @@ class PcaAccount:
area_names: dict[int, str] = field(default_factory=dict)
message_names: dict[int, str] = field(default_factory=dict)
# Zone types from SetupData installer section. Keys are 1-based slot
# numbers (always 1..numZones); values are raw ``enuZoneType`` byte
# values — see ``enuZoneType.cs`` for the full enum. Common values:
# 0x00=EntryExit, 0x01=Perimeter, 0x03=AwayInt, 0x40=Auxiliary (the
# panel default for unused slots), 0x55=Extended_Range_OutdoorTemp.
# Empty dict when SetupData wasn't walked successfully.
zone_types: dict[int, int] = field(default_factory=dict)
def parse_pca01_cfg(data: bytes, key: int = KEY_PC01) -> PcaConfig:
"""Decrypt ``data`` (raw PCA01.CFG bytes) and parse per clsPcaCfg.Read()."""
@ -267,6 +275,17 @@ def parse_pca01_cfg(data: bytes, key: int = KEY_PC01) -> PcaConfig:
# correct totals up to that block, and OMNI_PRO_II is the working reference.
_CAP_OMNI_PRO_II: dict[str, int] = {
"lenSetupData": 3840,
# Installer section begins at byte 2560 (clsCapOMNI_PRO_II.instSetupStart).
# Layout for OMNI_PRO_II observed empirically against the live fixture:
# offset 2560: HouseCode (1 byte)
# offsets 2561..2569: OutputType[0..8] (9 bytes; numVoltOutputs)
# offset 2570: ZoneExpansions (1 byte; ZoneExpansions feature)
# offset 2571: NumExpEnc (1 byte; firstExpEncOut != 0)
# offsets 2572..2747: ZoneType[1..176] (176 bytes; enuZoneType per zone)
# The trailing 12 bytes from 2560..2571 are the preamble. Hardcoded
# for OMNI_PRO_II — other panels will need their own constants.
"instSetupStart": 2560,
"zoneTypeOffset": 2572,
"max_zones": 176, "lenZoneName": 15, "zones_count": 176,
"max_units": 512, "lenUnitName": 12, "units_count": 511,
"max_buttons": 128, "lenButtonName": 12, "buttons_count": 255,
@ -363,10 +382,12 @@ def _walk_to_remarks(r: PcaReader) -> dict[int, str]:
class _ConnectionWalk:
"""Side-channel output of :func:`_walk_to_connection`.
Captures the per-object name tables on the way past so the caller
can attach them to :class:`PcaAccount`. Each ``*_names`` dict is
``{1-based slot: name}`` with only non-empty slots present
matches the "iter_defined" convention used for programs.
Captures the per-object name tables + selected SetupData fields on
the way past so the caller can attach them to :class:`PcaAccount`.
Each ``*_names`` dict is ``{1-based slot: name}`` with only non-empty
slots present matches the "iter_defined" convention used for
programs. ``zone_types`` is ``{1-based slot: enuZoneType byte}`` for
every zone slot (defined or not the array is fixed-size).
"""
programs_blob: bytes
@ -377,6 +398,7 @@ class _ConnectionWalk:
thermostat_names: dict[int, str] = field(default_factory=dict)
area_names: dict[int, str] = field(default_factory=dict)
message_names: dict[int, str] = field(default_factory=dict)
zone_types: dict[int, int] = field(default_factory=dict)
def _read_name_table(r: PcaReader, count: int, name_len: int) -> dict[int, str]:
@ -406,10 +428,23 @@ def _walk_to_connection(r: PcaReader, cap: dict[str, int]) -> _ConnectionWalk:
Mirrors clsHAC.cs:7995-8044. The per-object names are read via
clsAbstractNamedItem.ReadName String8(L) see
:func:`_read_name_table` for the per-slot layout.
SetupData is captured to a buffer up-front so we can index into its
installer section for ZoneType (offset 2572 on OMNI_PRO_II).
"""
r.bytes_(cap["lenSetupData"])
setup_data = r.bytes_(cap["lenSetupData"])
r.bytes_(10) # bool + bool + u16 + u16 + u32
# Pull ZoneType from the installer section of SetupData.
# See the comment block on _CAP_OMNI_PRO_II for layout details.
zt_off = cap.get("zoneTypeOffset")
zone_types: dict[int, int] = {}
if zt_off is not None:
zt_end = zt_off + cap["max_zones"]
if zt_end <= len(setup_data):
for slot in range(1, cap["max_zones"] + 1):
zone_types[slot] = setup_data[zt_off + slot - 1]
# Object family order per clsHAC body layout:
# Zones → Units → Buttons → Codes → Thermostats → Areas → Messages.
zone_names = _read_name_table(r, cap["max_zones"], cap["lenZoneName"])
@ -448,6 +483,7 @@ def _walk_to_connection(r: PcaReader, cap: dict[str, int]) -> _ConnectionWalk:
thermostat_names=thermostat_names,
area_names=area_names,
message_names=message_names,
zone_types=zone_types,
)
@ -535,6 +571,7 @@ def parse_pca_file(path_or_bytes: str | os.PathLike[str] | bytes, key: int) -> P
account.thermostat_names = walk.thermostat_names
account.area_names = walk.area_names
account.message_names = walk.message_names
account.zone_types = walk.zone_types
# PCA03+ continues past Connection with ModemBaud flags + nine
# Description blocks + the Remarks table. We walk it on a

View File

@ -153,6 +153,15 @@ async def test_mockpanel_from_pca_drives_full_ha_discovery(
assert len(coordinator.data.thermostats) == 2
# And the programs sensor reflects 330 from wire iter_programs.
assert len(coordinator.data.programs) == 330
# Zone types flowed from SetupData → mock → wire Properties
# reply → HA's ZoneProperties parser.
zone_types_by_slot = {
idx: z.zone_type for idx, z in coordinator.data.zones.items()
}
assert zone_types_by_slot[1] == 0x00 # GARAGE ENTRY → EntryExit
assert zone_types_by_slot[3] == 0x01 # BACK DOOR → Perimeter
assert zone_types_by_slot[7] == 0x03 # LIVINGROOM MOT → AwayInt
assert zone_types_by_slot[11] == 0x55 # OUTSIDE TEMP → outdoor temp
finally:
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()

View File

@ -350,6 +350,14 @@ async def test_mockstate_from_pca_serves_real_panel_programs() -> None:
assert state.zones[1].name == "GARAGE ENTRY"
assert state.units[1].name == "ROOM ONE"
assert state.thermostats[1].name == "DOWNSTAIRS"
# Zone types from SetupData — door zones are EntryExit (0) or
# Perimeter (1), motion sensors are AwayInt (3), the OUTSIDE TEMP
# zone is Extended_Range_OutdoorTemp (0x55).
assert state.zones[1].zone_type == 0x00 # GARAGE ENTRY → EntryExit
assert state.zones[2].zone_type == 0x00 # FRONT DOOR → EntryExit
assert state.zones[3].zone_type == 0x01 # BACK DOOR → Perimeter
assert state.zones[7].zone_type == 0x03 # LIVINGROOM MOT → AwayInt
assert state.zones[11].zone_type == 0x55 # OUTSIDE TEMP → outdoor temp
panel = MockPanel(controller_key=CONTROLLER_KEY, state=state)
async with panel.serve(transport="tcp") as (host, port):