pca_file + v2 client: area flags + Area-N fallback

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.
This commit is contained in:
Ryan Malloy 2026-05-13 08:19:38 -06:00
parent 501686795b
commit 994608a4f6
5 changed files with 107 additions and 18 deletions

View File

@ -730,10 +730,22 @@ class OmniClient:
) )
async def list_area_names(self) -> dict[int, str]: 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, ObjectType.AREA,
lambda r: (r.index, r.name) if isinstance(r, AreaProperties) else None, 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( async def subscribe(
self, callback: Callable[[Message], Awaitable[None]] self, callback: Callable[[Message], Awaitable[None]]

View File

@ -147,6 +147,14 @@ class MockAreaState:
entry_delay: int = 30 # seconds; configured grace period after a door opens entry_delay: int = 30 # seconds; configured grace period after a door opens
exit_delay: int = 60 # seconds; configured grace period after arming exit_delay: int = 60 # seconds; configured grace period after arming
enabled: bool = True # whether this area is part of NumAreasUsed 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 @dataclass
@ -346,6 +354,11 @@ class MockState:
entry_delay=acct.area_entry_delays.get(i, 30), entry_delay=acct.area_entry_delays.get(i, 30),
exit_delay=acct.area_exit_delays.get(i, 60), exit_delay=acct.area_exit_delays.get(i, 60),
enabled=i <= acct.num_areas_used, 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) for i in sorted(in_use_areas)
}, },

View File

@ -249,6 +249,25 @@ class PcaAccount:
area_entry_delays: 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_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) # Panel-wide TempFormat (enuTempFormat: 1=Fahrenheit, 2=Celsius)
# and NumAreasUsed (count of armable security areas — 1 for a # and NumAreasUsed (count of armable security areas — 1 for a
# typical single-area home install, up to numAreas=8 on Omni Pro II). # 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, "entryDelayOffset": 1771,
"exitDelayOffset": 1779, "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). # Installer section begins at byte 2560 (clsCapOMNI_PRO_II.instSetupStart).
# Layout for OMNI_PRO_II observed empirically against the live fixture # 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) zone_areas: dict[int, int] = field(default_factory=dict)
area_entry_delays: 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_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 temp_format: int = 0
num_areas_used: 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. # Per-area entry/exit delays from the user section.
num_areas = cap.get("max_areas", 0) num_areas = cap.get("max_areas", 0)
entry_off = cap.get("entryDelayOffset") def _read_area_byte_array(offset_key: str) -> dict[int, int]:
area_entry_delays: dict[int, int] = {} off = cap.get(offset_key)
if entry_off is not None and entry_off + num_areas <= len(setup_data): if off is None or off + num_areas > len(setup_data):
for slot in range(1, num_areas + 1): return {}
area_entry_delays[slot] = setup_data[entry_off + slot - 1] return {i: setup_data[off + i - 1] for i in range(1, num_areas + 1)}
exit_off = cap.get("exitDelayOffset") def _read_area_bool_array(offset_key: str) -> dict[int, bool]:
area_exit_delays: dict[int, int] = {} return {i: bool(b) for i, b in _read_area_byte_array(offset_key).items()}
if exit_off is not None and exit_off + num_areas <= len(setup_data):
for slot in range(1, num_areas + 1): area_entry_delays = _read_area_byte_array("entryDelayOffset")
area_exit_delays[slot] = setup_data[exit_off + slot - 1] 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. # Scalars from the installer section.
tf_off = cap.get("tempFormatOffset") tf_off = cap.get("tempFormatOffset")
@ -584,6 +622,11 @@ def _walk_to_connection(r: PcaReader, cap: dict[str, int]) -> _ConnectionWalk:
zone_areas=zone_areas, zone_areas=zone_areas,
area_entry_delays=area_entry_delays, area_entry_delays=area_entry_delays,
area_exit_delays=area_exit_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, temp_format=temp_format,
num_areas_used=num_areas_used, 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.zone_areas = walk.zone_areas
account.area_entry_delays = walk.area_entry_delays account.area_entry_delays = walk.area_entry_delays
account.area_exit_delays = walk.area_exit_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.temp_format = walk.temp_format
account.num_areas_used = walk.num_areas_used account.num_areas_used = walk.num_areas_used

View File

@ -166,13 +166,14 @@ async def test_mockpanel_from_pca_drives_full_ha_discovery(
# zone surfaces as area=1 through the wire Properties reply. # zone surfaces as area=1 through the wire Properties reply.
for idx, z in coordinator.data.zones.items(): for idx, z in coordinator.data.zones.items():
assert z.area == 1, f"zone {idx} expected area=1 got {z.area}" assert z.area == 1, f"zone {idx} expected area=1 got {z.area}"
# Note: coordinator.data.areas is empty here because this # Areas: the live fixture has no user-assigned area names
# fixture has no user-assigned area names and the v2 client's # but the v2 client's list_area_names now falls back to
# list_area_names() only returns named areas — that's a # "Area 1".."Area 8". HA's _discover_areas then enumerates
# separate HA discovery concern. The mock *does* serve the # each, walks the Properties reply, and lands the configured
# delays in Properties replies; verify directly: # entry/exit delays from SetupData.
# (see test_e2e_program_echo.py for the MockState side, which assert 1 in coordinator.data.areas
# has Area 1 with the correct entry_delay=60 / exit_delay=90). assert coordinator.data.areas[1].entry_delay == 60
assert coordinator.data.areas[1].exit_delay == 90
finally: finally:
await hass.config_entries.async_unload(entry.entry_id) await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()

View File

@ -361,6 +361,21 @@ async def test_mockstate_from_pca_serves_real_panel_programs() -> None:
assert acct.num_areas_used == 1 assert acct.num_areas_used == 1
assert acct.area_entry_delays[1] == 60 assert acct.area_entry_delays[1] == 60
assert acct.area_exit_delays[1] == 90 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.zones[1].name == "GARAGE ENTRY"
assert state.units[1].name == "ROOM ONE" assert state.units[1].name == "ROOM ONE"
assert state.thermostats[1].name == "DOWNSTAIRS" assert state.thermostats[1].name == "DOWNSTAIRS"