pca_file: extract entry/exit delays, TempFormat, NumAreasUsed

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.
This commit is contained in:
Ryan Malloy 2026-05-12 22:35:55 -06:00
parent 8141599b4e
commit 501686795b
4 changed files with 110 additions and 6 deletions

View File

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

View File

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

View File

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

View File

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