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:
parent
8141599b4e
commit
501686795b
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user