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"