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
|
entry_timer: int = 0
|
||||||
exit_timer: int = 0
|
exit_timer: int = 0
|
||||||
alarms: 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
|
@dataclass
|
||||||
@ -314,6 +317,14 @@ class MockState:
|
|||||||
for p in acct.programs
|
for p in acct.programs
|
||||||
if p.slot is not None and not p.is_empty()
|
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] = {
|
defaults: dict[str, Any] = {
|
||||||
"model_byte": acct.model,
|
"model_byte": acct.model,
|
||||||
"firmware_major": acct.firmware_major,
|
"firmware_major": acct.firmware_major,
|
||||||
@ -329,7 +340,15 @@ class MockState:
|
|||||||
for i, n in acct.zone_names.items()
|
for i, n in acct.zone_names.items()
|
||||||
},
|
},
|
||||||
"units": {i: MockUnitState(name=n) for i, n in acct.unit_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": {
|
"thermostats": {
|
||||||
i: MockThermostatState(name=n)
|
i: MockThermostatState(name=n)
|
||||||
for i, n in acct.thermostat_names.items()
|
for i, n in acct.thermostat_names.items()
|
||||||
@ -1035,9 +1054,9 @@ class MockPanel:
|
|||||||
area.alarms if area else 0,
|
area.alarms if area else 0,
|
||||||
area.entry_timer if area else 0,
|
area.entry_timer if area else 0,
|
||||||
area.exit_timer if area else 0,
|
area.exit_timer if area else 0,
|
||||||
1, # Enabled
|
(1 if (area and area.enabled) else 0),
|
||||||
60, # ExitDelay (s)
|
area.exit_delay if area else 60,
|
||||||
30, # EntryDelay (s)
|
area.entry_delay if area else 30,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
+ self.state.area_name_bytes(index)
|
+ 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 to area 1. Empty dict when SetupData wasn't walked successfully.
|
||||||
zone_areas: dict[int, int] = field(default_factory=dict)
|
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:
|
def parse_pca01_cfg(data: bytes, key: int = KEY_PC01) -> PcaConfig:
|
||||||
"""Decrypt ``data`` (raw PCA01.CFG bytes) and parse per clsPcaCfg.Read()."""
|
"""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.
|
# correct totals up to that block, and OMNI_PRO_II is the working reference.
|
||||||
_CAP_OMNI_PRO_II: dict[str, int] = {
|
_CAP_OMNI_PRO_II: dict[str, int] = {
|
||||||
"lenSetupData": 3840,
|
"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).
|
# 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
|
||||||
# and cross-checked against clsHAC._ParseSetupData (clsHAC.cs:3156-...).
|
# 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.
|
# Hardcoded for OMNI_PRO_II — other panels will need their own values.
|
||||||
"instSetupStart": 2560,
|
"instSetupStart": 2560,
|
||||||
"zoneTypeOffset": 2572,
|
"zoneTypeOffset": 2572,
|
||||||
|
"tempFormatOffset": 2993,
|
||||||
|
"numAreasUsedOffset": 3034,
|
||||||
"zoneAreaOffset": 3106,
|
"zoneAreaOffset": 3106,
|
||||||
"max_zones": 176, "lenZoneName": 15, "zones_count": 176,
|
"max_zones": 176, "lenZoneName": 15, "zones_count": 176,
|
||||||
"max_units": 512, "lenUnitName": 12, "units_count": 511,
|
"max_units": 512, "lenUnitName": 12, "units_count": 511,
|
||||||
@ -432,6 +464,10 @@ class _ConnectionWalk:
|
|||||||
message_names: dict[int, str] = field(default_factory=dict)
|
message_names: dict[int, str] = field(default_factory=dict)
|
||||||
zone_types: dict[int, int] = field(default_factory=dict)
|
zone_types: dict[int, int] = field(default_factory=dict)
|
||||||
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_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]:
|
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):
|
for slot in range(1, cap["max_zones"] + 1):
|
||||||
zone_areas[slot] = setup_data[za_off + slot - 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:
|
# Object family order per clsHAC body layout:
|
||||||
# Zones → Units → Buttons → Codes → Thermostats → Areas → Messages.
|
# Zones → Units → Buttons → Codes → Thermostats → Areas → Messages.
|
||||||
zone_names = _read_name_table(r, cap["max_zones"], cap["lenZoneName"])
|
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,
|
message_names=message_names,
|
||||||
zone_types=zone_types,
|
zone_types=zone_types,
|
||||||
zone_areas=zone_areas,
|
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.message_names = walk.message_names
|
||||||
account.zone_types = walk.zone_types
|
account.zone_types = walk.zone_types
|
||||||
account.zone_areas = walk.zone_areas
|
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
|
# PCA03+ continues past Connection with ModemBaud flags + nine
|
||||||
# Description blocks + the Remarks table. We walk it on a
|
# 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.
|
# 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
|
||||||
|
# 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:
|
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()
|
||||||
|
|||||||
@ -345,8 +345,22 @@ async def test_mockstate_from_pca_serves_real_panel_programs() -> None:
|
|||||||
assert len(state.units) == 44
|
assert len(state.units) == 44
|
||||||
assert len(state.buttons) == 16
|
assert len(state.buttons) == 16
|
||||||
assert len(state.thermostats) == 2
|
assert len(state.thermostats) == 2
|
||||||
# Areas in this fixture have no names — that's fine, just verify.
|
# Areas: this fixture has no user-assigned names but
|
||||||
assert len(state.areas) == 0
|
# 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.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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user