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]:
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]]

View File

@ -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)
},

View File

@ -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

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.
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()

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.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"