From 8141599b4e4b3921a62768605aeaeda53a484359 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Tue, 12 May 2026 22:26:25 -0600 Subject: [PATCH] pca_file: extract per-zone Area assignment from SetupData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walks the OMNI_PRO_II installer section past ZoneType, DCM stuff, thermostat config, and the X10/VoltOut/FlagOut/ExpEnc area-group arrays to land on the 176-byte Zones[].Area block at offset 3106. The path from instSetupStart (2560) to zone area: ZoneType[176] → DCM phones/accounts/type/test(5-byte clsWhen) → DCMAlarmCode[176] → 8 DCM bytes → TempFormat..NumAreasUsed (29 bytes of misc config including 25-byte CallBackNumber) → X10 area groups (16) → VoltOut (8) → FlagOut (15) → ExpEnc (32) → Zones[].Area (176). Total preamble within installer section = 546 bytes. Verified against the live fixture: 176 zones all assigned to area 1 (single-area install), matches expectation. PcaAccount.zone_areas now carries {slot: area_number}; MockState.from_pca threads it through MockZoneState.area; mock _build_zone_properties already serves it. End-to-end test verifies the area flows through to coordinator.data.zones[*].area. This was the largest single-RE jump in SetupData decoding so far — got us past the variable-length DCM block by counting fixed-width fields out from the known ZoneType end. The clsWhen=5-byte struct was the last unknown; derived from clsHardwareArray.ReadWhen (clsHardwareArray .cs:456-468). Full suite: 499 passed, 1 skipped. --- src/omni_pca/mock_panel.py | 1 + src/omni_pca/pca_file.py | 61 +++++++++++++++++++++---- tests/ha_integration/test_pca_source.py | 4 ++ tests/test_e2e_program_echo.py | 4 ++ 4 files changed, 61 insertions(+), 9 deletions(-) diff --git a/src/omni_pca/mock_panel.py b/src/omni_pca/mock_panel.py index 7fce356..e734f5f 100644 --- a/src/omni_pca/mock_panel.py +++ b/src/omni_pca/mock_panel.py @@ -324,6 +324,7 @@ class MockState: i: MockZoneState( name=n, zone_type=acct.zone_types.get(i, 0), + area=acct.zone_areas.get(i, 1), ) for i, n in acct.zone_names.items() }, diff --git a/src/omni_pca/pca_file.py b/src/omni_pca/pca_file.py index 00236e0..65e0004 100644 --- a/src/omni_pca/pca_file.py +++ b/src/omni_pca/pca_file.py @@ -236,6 +236,12 @@ class PcaAccount: # Empty dict when SetupData wasn't walked successfully. zone_types: dict[int, int] = field(default_factory=dict) + # Per-zone area assignment from SetupData installer section. Keys + # are 1-based slot numbers (always 1..numZones); values are 1-based + # area numbers (1..numAreas). Most single-area installs assign every + # zone to area 1. Empty dict when SetupData wasn't walked successfully. + zone_areas: 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().""" @@ -276,16 +282,42 @@ def parse_pca01_cfg(data: bytes, key: int = KEY_PC01) -> PcaConfig: _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. + # Layout for OMNI_PRO_II observed empirically against the live fixture + # and cross-checked against clsHAC._ParseSetupData (clsHAC.cs:3156-...). + # + # 2560: HouseCode (1 byte) + # 2561..2569: OutputType[0..8] (9 bytes; numVoltOutputs) + # 2570: ZoneExpansions (1 byte) + # 2571: NumExpEnc (1 byte; firstExpEncOut != 0) + # 2572..2747: ZoneType[1..176] (176 bytes; raw enuZoneType byte/zone) + # 2748..2772: DCMPhoneNumber1 (25-byte fixed-width string) + # 2773..2774: DCMAccount1 (u16) + # 2775..2799: DCMPhoneNumber2 (25) + # 2800..2801: DCMAccount2 (u16) + # 2802: DCMType (1) + # 2803..2807: DCMTestTime (5-byte clsWhen: Hr,Min,Mon,Day,DOW) + # 2808: DCMTestCode (1) + # 2809..2984: Zones[].DCMAlarmCode (176 bytes) + # 2985..2992: DCMFreezeAlm/Fire/Police/Aux/Duress/BatteryLow/FireZone/Cancel (8) + # 2993..3004: TempFormat, NumThermostats, InstallerCode(u16), + # EnablePCAccess, PCAccessCode(u16), ... + # 2993: TempFormat / 2994: NumThermostats / 2995..2996: InstallerCode + # 2997: EnablePCAccess / 2998..2999: PCAccessCode + # 3000..3024: CallBackNumber (25) + # 3025: ExteriorHornDelay / 3026: DialoutDelay / 3027: VerifyFireAlarms + # 3028: EnableConsoleEmg / 3029: TimeFormat / 3030: DateFormat + # 3031: ACPowerFreq / 3032: DeadLineDetect / 3033: OffHookDetect + # 3034: NumAreasUsed + # 3035..3050: X10 AreaGroups (16 bytes; (lastX10-firstX10+16)/16) + # 3051..3058: VoltOut AreaGroups (8; lastVoltOut-firstVoltOut+1) + # 3059..3073: FlagOut AreaGroups (15; (lastFlagOut-firstFlagOut+8)/8) + # 3074..3105: ExpEnc AreaGroups (32; (lastExpEncOut-firstExpEncOut+4)/4) + # 3106..3281: Zones[1..176].Area (176 bytes — area number per zone) + # + # Hardcoded for OMNI_PRO_II — other panels will need their own values. "instSetupStart": 2560, "zoneTypeOffset": 2572, + "zoneAreaOffset": 3106, "max_zones": 176, "lenZoneName": 15, "zones_count": 176, "max_units": 512, "lenUnitName": 12, "units_count": 511, "max_buttons": 128, "lenButtonName": 12, "buttons_count": 255, @@ -399,6 +431,7 @@ class _ConnectionWalk: 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) + zone_areas: dict[int, int] = field(default_factory=dict) def _read_name_table(r: PcaReader, count: int, name_len: int) -> dict[int, str]: @@ -435,7 +468,7 @@ def _walk_to_connection(r: PcaReader, cap: dict[str, int]) -> _ConnectionWalk: setup_data = r.bytes_(cap["lenSetupData"]) r.bytes_(10) # bool + bool + u16 + u16 + u32 - # Pull ZoneType from the installer section of SetupData. + # Pull ZoneType and Zones[].Area 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] = {} @@ -445,6 +478,14 @@ def _walk_to_connection(r: PcaReader, cap: dict[str, int]) -> _ConnectionWalk: for slot in range(1, cap["max_zones"] + 1): zone_types[slot] = setup_data[zt_off + slot - 1] + za_off = cap.get("zoneAreaOffset") + zone_areas: dict[int, int] = {} + if za_off is not None: + za_end = za_off + cap["max_zones"] + if za_end <= len(setup_data): + for slot in range(1, cap["max_zones"] + 1): + zone_areas[slot] = setup_data[za_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"]) @@ -484,6 +525,7 @@ def _walk_to_connection(r: PcaReader, cap: dict[str, int]) -> _ConnectionWalk: area_names=area_names, message_names=message_names, zone_types=zone_types, + zone_areas=zone_areas, ) @@ -572,6 +614,7 @@ def parse_pca_file(path_or_bytes: str | os.PathLike[str] | bytes, key: int) -> P account.area_names = walk.area_names account.message_names = walk.message_names account.zone_types = walk.zone_types + account.zone_areas = walk.zone_areas # 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 57f5b73..fea394a 100644 --- a/tests/ha_integration/test_pca_source.py +++ b/tests/ha_integration/test_pca_source.py @@ -162,6 +162,10 @@ async def test_mockpanel_from_pca_drives_full_ha_discovery( 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 + # Per-zone area assignments — single-area install, every + # zone surfaces as area=1 through the wire Properties reply. + for idx, z in coordinator.data.zones.items(): + assert z.area == 1, f"zone {idx} expected area=1 got {z.area}" 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 c818249..cb7d07e 100644 --- a/tests/test_e2e_program_echo.py +++ b/tests/test_e2e_program_echo.py @@ -358,6 +358,10 @@ async def test_mockstate_from_pca_serves_real_panel_programs() -> None: 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 + # Zone area assignments from SetupData — single-area install, all + # zones in area 1. + for slot, zone in state.zones.items(): + assert zone.area == 1, f"slot {slot} expected area=1 got {zone.area}" panel = MockPanel(controller_key=CONTROLLER_KEY, state=state) async with panel.serve(transport="tcp") as (host, port):