From 501686795bc2bdefcde6dd84a0876221829713ce Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Tue, 12 May 2026 22:35:55 -0600 Subject: [PATCH] pca_file: extract entry/exit delays, TempFormat, NumAreasUsed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more SetupData fields, varying in difficulty: * Entry/exit delays per area — in the user section, behind 280 bytes of Phone[8] config and 1386 bytes of Codes[99]. Derived offsets by counting fixed-width fields out from Seek(1): EntryDelay[1..8] at offset 1771, ExitDelay[1..8] at 1779. Verified against live fixture (area 1: entry=60s, exit=90s; unused areas: 15s/15s panel defaults). * TempFormat at installer offset 2993 — single byte, enuTempFormat (1=F, 2=C). Live fixture = 1 (US install). * NumAreasUsed at installer offset 3034 — count of installer-enabled security areas. Live fixture = 1 (single-area home). PcaAccount now carries area_entry_delays, area_exit_delays, temp_format, num_areas_used. MockAreaState gains entry_delay/exit_delay/enabled fields; mock _build_area_properties serves the configured values (was hardcoded 60/30/Enabled). MockState.from_pca now synthesizes per-area MockAreaState entries for the union of named areas + (1..num_areas_used), filling in delays and enabled flag. This means a single-area install with no user-assigned name still surfaces area 1 with the correct config — matching what an installer would see in PC Access. (HA's coordinator only enumerates named areas via list_area_names, so the area properties don't yet reach the diagnostic surface for unnamed-but-in-use areas. That's a separate filter to revisit; the data flow through pca_file → MockState → wire Properties reply is already correct.) Full suite: 499 passed, 1 skipped. --- src/omni_pca/mock_panel.py | 27 +++++++++-- src/omni_pca/pca_file.py | 64 +++++++++++++++++++++++++ tests/ha_integration/test_pca_source.py | 7 +++ tests/test_e2e_program_echo.py | 18 ++++++- 4 files changed, 110 insertions(+), 6 deletions(-) diff --git a/src/omni_pca/mock_panel.py b/src/omni_pca/mock_panel.py index e734f5f..4d68922 100644 --- a/src/omni_pca/mock_panel.py +++ b/src/omni_pca/mock_panel.py @@ -144,6 +144,9 @@ class MockAreaState: entry_timer: int = 0 exit_timer: int = 0 alarms: int = 0 + entry_delay: int = 30 # seconds; configured grace period after a door opens + exit_delay: int = 60 # seconds; configured grace period after arming + enabled: bool = True # whether this area is part of NumAreasUsed @dataclass @@ -314,6 +317,14 @@ class MockState: for p in acct.programs if p.slot is not None and not p.is_empty() } + # Union of named areas and the "in-use" range from NumAreasUsed — + # an area is part of the install if either it has a user-assigned + # name OR the installer told the panel to use it. Most homes have + # a single unnamed area 1 + num_areas_used=1, which produces + # areas={1: MockAreaState(name="", enabled=True, ...)}. + in_use_areas = set(acct.area_names) | set( + range(1, acct.num_areas_used + 1) + ) defaults: dict[str, Any] = { "model_byte": acct.model, "firmware_major": acct.firmware_major, @@ -329,7 +340,15 @@ class MockState: 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()}, + "areas": { + i: MockAreaState( + name=acct.area_names.get(i, ""), + entry_delay=acct.area_entry_delays.get(i, 30), + exit_delay=acct.area_exit_delays.get(i, 60), + enabled=i <= acct.num_areas_used, + ) + for i in sorted(in_use_areas) + }, "thermostats": { i: MockThermostatState(name=n) for i, n in acct.thermostat_names.items() @@ -1035,9 +1054,9 @@ class MockPanel: area.alarms if area else 0, area.entry_timer if area else 0, area.exit_timer if area else 0, - 1, # Enabled - 60, # ExitDelay (s) - 30, # EntryDelay (s) + (1 if (area and area.enabled) else 0), + area.exit_delay if area else 60, + area.entry_delay if area else 30, ] ) + self.state.area_name_bytes(index) diff --git a/src/omni_pca/pca_file.py b/src/omni_pca/pca_file.py index 65e0004..145cfb3 100644 --- a/src/omni_pca/pca_file.py +++ b/src/omni_pca/pca_file.py @@ -242,6 +242,20 @@ class PcaAccount: # zone to area 1. Empty dict when SetupData wasn't walked successfully. zone_areas: dict[int, int] = field(default_factory=dict) + # Per-area entry/exit delay (seconds) from SetupData user section. + # Keys are 1-based area numbers (1..numAreas); typical values are + # 30/60 (entry) and 60/90 (exit). Unused areas carry the panel + # default (15 s in the live fixture). + area_entry_delays: dict[int, int] = field(default_factory=dict) + area_exit_delays: dict[int, int] = field(default_factory=dict) + + # Panel-wide TempFormat (enuTempFormat: 1=Fahrenheit, 2=Celsius) + # and NumAreasUsed (count of armable security areas — 1 for a + # typical single-area home install, up to numAreas=8 on Omni Pro II). + # Both are 0 if SetupData wasn't walked successfully. + temp_format: int = 0 + num_areas_used: int = 0 + def parse_pca01_cfg(data: bytes, key: int = KEY_PC01) -> PcaConfig: """Decrypt ``data`` (raw PCA01.CFG bytes) and parse per clsPcaCfg.Read().""" @@ -281,6 +295,22 @@ 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, + # User section walks from offset 1 (Seek(1)). Fixed-width derivation: + # + # 1..5: TelephoneAccess+AnswerOutsideCall+RemoteCommandsOK+ + # RingsBeforeAnswer+DialMode (5×1 byte) + # 6..30: MyPhoneNumber (25 = 1 length + 24 payload, fixed-width) + # 31..310: Phone[0..7] (8 × 35: Number(25)+WhenOn(5)+WhenOff(5)) + # 311..382: Areas[1..8].DialOrder (8 × 9 fixed-width String8) + # 383..1768: Codes[1..99] (99 × 14: Code(u16)+Authority(1)+ + # Areas(1)+WhenOn(5)+WhenOff(5)) + # 1769..1770: Codes[251].Code (u16) + # 1771..1778: Areas[1..8].EntryDelay (8 bytes) + # 1779..1786: Areas[1..8].ExitDelay (8 bytes) + # + "entryDelayOffset": 1771, + "exitDelayOffset": 1779, + # Installer section begins at byte 2560 (clsCapOMNI_PRO_II.instSetupStart). # Layout for OMNI_PRO_II observed empirically against the live fixture # and cross-checked against clsHAC._ParseSetupData (clsHAC.cs:3156-...). @@ -317,6 +347,8 @@ _CAP_OMNI_PRO_II: dict[str, int] = { # Hardcoded for OMNI_PRO_II — other panels will need their own values. "instSetupStart": 2560, "zoneTypeOffset": 2572, + "tempFormatOffset": 2993, + "numAreasUsedOffset": 3034, "zoneAreaOffset": 3106, "max_zones": 176, "lenZoneName": 15, "zones_count": 176, "max_units": 512, "lenUnitName": 12, "units_count": 511, @@ -432,6 +464,10 @@ class _ConnectionWalk: 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) + area_entry_delays: dict[int, int] = field(default_factory=dict) + area_exit_delays: dict[int, int] = field(default_factory=dict) + temp_format: int = 0 + num_areas_used: int = 0 def _read_name_table(r: PcaReader, count: int, name_len: int) -> dict[int, str]: @@ -486,6 +522,26 @@ def _walk_to_connection(r: PcaReader, cap: dict[str, int]) -> _ConnectionWalk: for slot in range(1, cap["max_zones"] + 1): zone_areas[slot] = setup_data[za_off + slot - 1] + # Per-area entry/exit delays from the user section. + num_areas = cap.get("max_areas", 0) + entry_off = cap.get("entryDelayOffset") + area_entry_delays: dict[int, int] = {} + if entry_off is not None and entry_off + num_areas <= len(setup_data): + for slot in range(1, num_areas + 1): + area_entry_delays[slot] = setup_data[entry_off + slot - 1] + + exit_off = cap.get("exitDelayOffset") + area_exit_delays: dict[int, int] = {} + if exit_off is not None and exit_off + num_areas <= len(setup_data): + for slot in range(1, num_areas + 1): + area_exit_delays[slot] = setup_data[exit_off + slot - 1] + + # Scalars from the installer section. + tf_off = cap.get("tempFormatOffset") + temp_format = setup_data[tf_off] if tf_off is not None and tf_off < len(setup_data) else 0 + na_off = cap.get("numAreasUsedOffset") + num_areas_used = setup_data[na_off] if na_off is not None and na_off < len(setup_data) else 0 + # 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"]) @@ -526,6 +582,10 @@ def _walk_to_connection(r: PcaReader, cap: dict[str, int]) -> _ConnectionWalk: message_names=message_names, zone_types=zone_types, zone_areas=zone_areas, + area_entry_delays=area_entry_delays, + area_exit_delays=area_exit_delays, + temp_format=temp_format, + num_areas_used=num_areas_used, ) @@ -615,6 +675,10 @@ def parse_pca_file(path_or_bytes: str | os.PathLike[str] | bytes, key: int) -> P account.message_names = walk.message_names account.zone_types = walk.zone_types account.zone_areas = walk.zone_areas + account.area_entry_delays = walk.area_entry_delays + account.area_exit_delays = walk.area_exit_delays + account.temp_format = walk.temp_format + account.num_areas_used = walk.num_areas_used # 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 fea394a..f660b09 100644 --- a/tests/ha_integration/test_pca_source.py +++ b/tests/ha_integration/test_pca_source.py @@ -166,6 +166,13 @@ async def test_mockpanel_from_pca_drives_full_ha_discovery( # 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}" + # Note: coordinator.data.areas is empty here because this + # fixture has no user-assigned area names and the v2 client's + # list_area_names() only returns named areas — that's a + # separate HA discovery concern. The mock *does* serve the + # delays in Properties replies; verify directly: + # (see test_e2e_program_echo.py for the MockState side, which + # has Area 1 with the correct entry_delay=60 / exit_delay=90). 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 cb7d07e..54cacee 100644 --- a/tests/test_e2e_program_echo.py +++ b/tests/test_e2e_program_echo.py @@ -345,8 +345,22 @@ async def test_mockstate_from_pca_serves_real_panel_programs() -> None: assert len(state.units) == 44 assert len(state.buttons) == 16 assert len(state.thermostats) == 2 - # Areas in this fixture have no names — that's fine, just verify. - assert len(state.areas) == 0 + # Areas: this fixture has no user-assigned names but + # NumAreasUsed=1, so MockState.from_pca synthesizes a single + # unnamed area 1 with the .pca's entry/exit delays. + assert len(state.areas) == 1 + assert state.areas[1].name == "" + assert state.areas[1].entry_delay == 60 # configured in PC Access + assert state.areas[1].exit_delay == 90 + assert state.areas[1].enabled is True + + # Sanity-check the raw PcaAccount scalars too. + from omni_pca.pca_file import parse_pca_file + acct = parse_pca_file(encrypted, key=KEY_EXPORT) + assert acct.temp_format == 1 # 1 = Fahrenheit + assert acct.num_areas_used == 1 + assert acct.area_entry_delays[1] == 60 + assert acct.area_exit_delays[1] == 90 assert state.zones[1].name == "GARAGE ENTRY" assert state.units[1].name == "ROOM ONE" assert state.thermostats[1].name == "DOWNSTAIRS"