From 994608a4f6d575880b40731f30dee45aae0a2f97 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Wed, 13 May 2026 08:19:38 -0600 Subject: [PATCH] pca_file + v2 client: area flags + Area-N fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SetupData side (clsHAC.cs:3020-3038): five contiguous bool[8] arrays immediately after ExitDelay carry per-area config flags. Offsets: 1787..1794: EntryChime 1795..1802: QuickArm 1803..1810: AutoBypass 1811..1818: AllOnForAlarm 1819..1826: TroubleBeep Verified against live fixture: area 1 shows real homeowner choices (QuickArm + AllOnForAlarm enabled, others off), unused areas 2-8 carry the panel defaults (EntryChime/AutoBypass/TroubleBeep on by default). PerimeterChime and AudibleExitDelay aren't in this contiguous block — they live past FlashLightNum, HouseCodes flags, and 6 TimeClock When-structs. Deferred. New PcaAccount fields: area_entry_chime, area_quick_arm, area_auto_bypass, area_all_on_for_alarm, area_trouble_beep — all dict[int, bool]. MockAreaState gains the same five fields. They aren't carried in the Properties reply on the wire (the OL2 message format doesn't have them), so they live on MockState for snapshots and any future SetupData-aware code, but don't surface through HA discovery yet. v2 client list_area_names fallback: when the Properties walk turns up no named areas (common — most homes don't name them), synthesize "Area 1".."Area 8" so HA's _discover_areas has slots to walk. Mirrors the v1 adapter behaviour exactly. Knock-on win in the live-fixture HA test: area 1 now reaches coordinator.data.areas with its configured 60s/90s delays from SetupData, end-to-end through .pca → MockState → wire Properties → HA's AreaProperties parser. Full suite: 499 passed, 1 skipped. --- src/omni_pca/client.py | 14 ++++- src/omni_pca/mock_panel.py | 13 +++++ src/omni_pca/pca_file.py | 68 +++++++++++++++++++++---- tests/ha_integration/test_pca_source.py | 15 +++--- tests/test_e2e_program_echo.py | 15 ++++++ 5 files changed, 107 insertions(+), 18 deletions(-) diff --git a/src/omni_pca/client.py b/src/omni_pca/client.py index 7cce095..9ab435e 100644 --- a/src/omni_pca/client.py +++ b/src/omni_pca/client.py @@ -730,10 +730,22 @@ class OmniClient: ) async def list_area_names(self) -> dict[int, str]: - return await self._walk_named_objects( + """Return area names, falling back to "Area N" when none are named. + + Most installs assign no user-visible name to areas — single-area + homes don't bother, and even multi-area installs commonly leave + area names blank. HA needs *something* to label each area entity, + so we synthesize "Area 1".."Area 8" (the Omni Pro II cap) when + the Properties walk returns no names. Mirrors the v1 adapter's + list_area_names fallback in omni_pca.v1.adapter. + """ + named = await self._walk_named_objects( ObjectType.AREA, lambda r: (r.index, r.name) if isinstance(r, AreaProperties) else None, ) + if named: + return named + return {i: f"Area {i}" for i in range(1, 9)} async def subscribe( self, callback: Callable[[Message], Awaitable[None]] diff --git a/src/omni_pca/mock_panel.py b/src/omni_pca/mock_panel.py index 4d68922..29ab983 100644 --- a/src/omni_pca/mock_panel.py +++ b/src/omni_pca/mock_panel.py @@ -147,6 +147,14 @@ class MockAreaState: 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 + # Boolean configuration flags — not on the wire (the Properties + # reply doesn't carry them); kept here for snapshots from .pca and + # any future SetupData-aware code paths. + entry_chime: bool = False + quick_arm: bool = False + auto_bypass: bool = False + all_on_for_alarm: bool = False + trouble_beep: bool = False @dataclass @@ -346,6 +354,11 @@ class MockState: entry_delay=acct.area_entry_delays.get(i, 30), exit_delay=acct.area_exit_delays.get(i, 60), enabled=i <= acct.num_areas_used, + entry_chime=acct.area_entry_chime.get(i, False), + quick_arm=acct.area_quick_arm.get(i, False), + auto_bypass=acct.area_auto_bypass.get(i, False), + all_on_for_alarm=acct.area_all_on_for_alarm.get(i, False), + trouble_beep=acct.area_trouble_beep.get(i, False), ) for i in sorted(in_use_areas) }, diff --git a/src/omni_pca/pca_file.py b/src/omni_pca/pca_file.py index 145cfb3..a310536 100644 --- a/src/omni_pca/pca_file.py +++ b/src/omni_pca/pca_file.py @@ -249,6 +249,25 @@ class PcaAccount: area_entry_delays: dict[int, int] = field(default_factory=dict) area_exit_delays: dict[int, int] = field(default_factory=dict) + # Per-area boolean configuration flags from SetupData user section, + # five contiguous bool[8] arrays at offset 1787..1826 + # (clsHAC.cs:3020-3038). Keys are 1-based area numbers. + # + # entry_chime — chime keypads when entry-delay zones trip + # quick_arm — allow arming without a code + # auto_bypass — silently bypass not-ready zones on arm + # all_on_for_alarm — fire every output when any alarm trips + # trouble_beep — beep keypads on a non-alarm trouble condition + # + # PerimeterChime and AudibleExitDelay are NOT in this contiguous + # block — they live deeper in the user section past FlashLightNum, + # HouseCodes flags, and 6 TimeClock When-structs. + area_entry_chime: dict[int, bool] = field(default_factory=dict) + area_quick_arm: dict[int, bool] = field(default_factory=dict) + area_auto_bypass: dict[int, bool] = field(default_factory=dict) + area_all_on_for_alarm: dict[int, bool] = field(default_factory=dict) + area_trouble_beep: dict[int, bool] = 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). @@ -310,6 +329,15 @@ _CAP_OMNI_PRO_II: dict[str, int] = { # "entryDelayOffset": 1771, "exitDelayOffset": 1779, + # Five contiguous bool[8] flag arrays immediately follow ExitDelay + # (clsHAC.cs:3020-3038). PerimeterChime and AudibleExitDelay are + # NOT contiguous — they live later, past HighSecurity, FreezeAlarm, + # FlashLightNum, HouseCodes flags, and 6 TimeClock When-structs. + "entryChimeOffset": 1787, + "quickArmOffset": 1795, + "autoBypassOffset": 1803, + "allOnForAlarmOffset": 1811, + "troubleBeepOffset": 1819, # Installer section begins at byte 2560 (clsCapOMNI_PRO_II.instSetupStart). # Layout for OMNI_PRO_II observed empirically against the live fixture @@ -466,6 +494,11 @@ class _ConnectionWalk: 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) + area_entry_chime: dict[int, bool] = field(default_factory=dict) + area_quick_arm: dict[int, bool] = field(default_factory=dict) + area_auto_bypass: dict[int, bool] = field(default_factory=dict) + area_all_on_for_alarm: dict[int, bool] = field(default_factory=dict) + area_trouble_beep: dict[int, bool] = field(default_factory=dict) temp_format: int = 0 num_areas_used: int = 0 @@ -524,17 +557,22 @@ def _walk_to_connection(r: PcaReader, cap: dict[str, int]) -> _ConnectionWalk: # 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] + def _read_area_byte_array(offset_key: str) -> dict[int, int]: + off = cap.get(offset_key) + if off is None or off + num_areas > len(setup_data): + return {} + return {i: setup_data[off + i - 1] for i in range(1, num_areas + 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] + def _read_area_bool_array(offset_key: str) -> dict[int, bool]: + return {i: bool(b) for i, b in _read_area_byte_array(offset_key).items()} + + area_entry_delays = _read_area_byte_array("entryDelayOffset") + area_exit_delays = _read_area_byte_array("exitDelayOffset") + area_entry_chime = _read_area_bool_array("entryChimeOffset") + area_quick_arm = _read_area_bool_array("quickArmOffset") + area_auto_bypass = _read_area_bool_array("autoBypassOffset") + area_all_on_for_alarm = _read_area_bool_array("allOnForAlarmOffset") + area_trouble_beep = _read_area_bool_array("troubleBeepOffset") # Scalars from the installer section. tf_off = cap.get("tempFormatOffset") @@ -584,6 +622,11 @@ def _walk_to_connection(r: PcaReader, cap: dict[str, int]) -> _ConnectionWalk: zone_areas=zone_areas, area_entry_delays=area_entry_delays, area_exit_delays=area_exit_delays, + area_entry_chime=area_entry_chime, + area_quick_arm=area_quick_arm, + area_auto_bypass=area_auto_bypass, + area_all_on_for_alarm=area_all_on_for_alarm, + area_trouble_beep=area_trouble_beep, temp_format=temp_format, num_areas_used=num_areas_used, ) @@ -677,6 +720,11 @@ def parse_pca_file(path_or_bytes: str | os.PathLike[str] | bytes, key: int) -> P account.zone_areas = walk.zone_areas account.area_entry_delays = walk.area_entry_delays account.area_exit_delays = walk.area_exit_delays + account.area_entry_chime = walk.area_entry_chime + account.area_quick_arm = walk.area_quick_arm + account.area_auto_bypass = walk.area_auto_bypass + account.area_all_on_for_alarm = walk.area_all_on_for_alarm + account.area_trouble_beep = walk.area_trouble_beep account.temp_format = walk.temp_format account.num_areas_used = walk.num_areas_used diff --git a/tests/ha_integration/test_pca_source.py b/tests/ha_integration/test_pca_source.py index f660b09..b6b52f4 100644 --- a/tests/ha_integration/test_pca_source.py +++ b/tests/ha_integration/test_pca_source.py @@ -166,13 +166,14 @@ 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). + # Areas: the live fixture has no user-assigned area names + # but the v2 client's list_area_names now falls back to + # "Area 1".."Area 8". HA's _discover_areas then enumerates + # each, walks the Properties reply, and lands the configured + # entry/exit delays from SetupData. + assert 1 in coordinator.data.areas + assert coordinator.data.areas[1].entry_delay == 60 + assert coordinator.data.areas[1].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 54cacee..e739f27 100644 --- a/tests/test_e2e_program_echo.py +++ b/tests/test_e2e_program_echo.py @@ -361,6 +361,21 @@ async def test_mockstate_from_pca_serves_real_panel_programs() -> None: assert acct.num_areas_used == 1 assert acct.area_entry_delays[1] == 60 assert acct.area_exit_delays[1] == 90 + + # Area-1 boolean flags (real homeowner-configured values): + # EntryChime OFF (no keypad chime on entry) + # QuickArm ON (arming without a code) + # AutoBypass OFF + # AllOnForAlarm ON + # TroubleBeep OFF + assert acct.area_entry_chime[1] is False + assert acct.area_quick_arm[1] is True + assert acct.area_auto_bypass[1] is False + assert acct.area_all_on_for_alarm[1] is True + assert acct.area_trouble_beep[1] is False + # And the values flowed through MockState. + assert state.areas[1].quick_arm is True + assert state.areas[1].entry_chime is False assert state.zones[1].name == "GARAGE ENTRY" assert state.units[1].name == "ROOM ONE" assert state.thermostats[1].name == "DOWNSTAIRS"