From 70bf9caf583d87c1a0399c25a1a489703cdc1c7c Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Tue, 12 May 2026 22:18:32 -0600 Subject: [PATCH] pca_file: extract zone_type from SetupData installer section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/omni_pca/mock_panel.py | 29 +++++++++----- src/omni_pca/pca_file.py | 53 +++++++++++++++++++++---- tests/ha_integration/test_pca_source.py | 9 +++++ tests/test_e2e_program_echo.py | 8 ++++ 4 files changed, 82 insertions(+), 17 deletions(-) diff --git a/src/omni_pca/mock_panel.py b/src/omni_pca/mock_panel.py index b26ba12..7fce356 100644 --- a/src/omni_pca/mock_panel.py +++ b/src/omni_pca/mock_panel.py @@ -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) diff --git a/src/omni_pca/pca_file.py b/src/omni_pca/pca_file.py index da1e286..00236e0 100644 --- a/src/omni_pca/pca_file.py +++ b/src/omni_pca/pca_file.py @@ -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 diff --git a/tests/ha_integration/test_pca_source.py b/tests/ha_integration/test_pca_source.py index d09b860..57f5b73 100644 --- a/tests/ha_integration/test_pca_source.py +++ b/tests/ha_integration/test_pca_source.py @@ -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() diff --git a/tests/test_e2e_program_echo.py b/tests/test_e2e_program_echo.py index a57ee87..c818249 100644 --- a/tests/test_e2e_program_echo.py +++ b/tests/test_e2e_program_echo.py @@ -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):