pca_file: extract per-zone Area assignment from SetupData
Walks the OMNI_PRO_II installer section past ZoneType, DCM stuff,
thermostat config, and the X10/VoltOut/FlagOut/ExpEnc area-group
arrays to land on the 176-byte Zones[].Area block at offset 3106.
The path from instSetupStart (2560) to zone area:
ZoneType[176] → DCM phones/accounts/type/test(5-byte clsWhen) →
DCMAlarmCode[176] → 8 DCM bytes → TempFormat..NumAreasUsed (29 bytes
of misc config including 25-byte CallBackNumber) → X10 area groups
(16) → VoltOut (8) → FlagOut (15) → ExpEnc (32) → Zones[].Area (176).
Total preamble within installer section = 546 bytes. Verified against
the live fixture: 176 zones all assigned to area 1 (single-area
install), matches expectation.
PcaAccount.zone_areas now carries {slot: area_number}; MockState.from_pca
threads it through MockZoneState.area; mock _build_zone_properties already
serves it. End-to-end test verifies the area flows through to
coordinator.data.zones[*].area.
This was the largest single-RE jump in SetupData decoding so far — got
us past the variable-length DCM block by counting fixed-width fields
out from the known ZoneType end. The clsWhen=5-byte struct was the
last unknown; derived from clsHardwareArray.ReadWhen (clsHardwareArray
.cs:456-468).
Full suite: 499 passed, 1 skipped.
This commit is contained in:
parent
70bf9caf58
commit
8141599b4e
@ -324,6 +324,7 @@ class MockState:
|
||||
i: MockZoneState(
|
||||
name=n,
|
||||
zone_type=acct.zone_types.get(i, 0),
|
||||
area=acct.zone_areas.get(i, 1),
|
||||
)
|
||||
for i, n in acct.zone_names.items()
|
||||
},
|
||||
|
||||
@ -236,6 +236,12 @@ class PcaAccount:
|
||||
# Empty dict when SetupData wasn't walked successfully.
|
||||
zone_types: dict[int, int] = field(default_factory=dict)
|
||||
|
||||
# Per-zone area assignment from SetupData installer section. Keys
|
||||
# are 1-based slot numbers (always 1..numZones); values are 1-based
|
||||
# area numbers (1..numAreas). Most single-area installs assign every
|
||||
# zone to area 1. Empty dict when SetupData wasn't walked successfully.
|
||||
zone_areas: dict[int, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
def parse_pca01_cfg(data: bytes, key: int = KEY_PC01) -> PcaConfig:
|
||||
"""Decrypt ``data`` (raw PCA01.CFG bytes) and parse per clsPcaCfg.Read()."""
|
||||
@ -276,16 +282,42 @@ def parse_pca01_cfg(data: bytes, key: int = KEY_PC01) -> PcaConfig:
|
||||
_CAP_OMNI_PRO_II: dict[str, int] = {
|
||||
"lenSetupData": 3840,
|
||||
# Installer section begins at byte 2560 (clsCapOMNI_PRO_II.instSetupStart).
|
||||
# Layout for OMNI_PRO_II observed empirically against the live fixture:
|
||||
# offset 2560: HouseCode (1 byte)
|
||||
# offsets 2561..2569: OutputType[0..8] (9 bytes; numVoltOutputs)
|
||||
# offset 2570: ZoneExpansions (1 byte; ZoneExpansions feature)
|
||||
# offset 2571: NumExpEnc (1 byte; firstExpEncOut != 0)
|
||||
# offsets 2572..2747: ZoneType[1..176] (176 bytes; enuZoneType per zone)
|
||||
# The trailing 12 bytes from 2560..2571 are the preamble. Hardcoded
|
||||
# for OMNI_PRO_II — other panels will need their own constants.
|
||||
# Layout for OMNI_PRO_II observed empirically against the live fixture
|
||||
# and cross-checked against clsHAC._ParseSetupData (clsHAC.cs:3156-...).
|
||||
#
|
||||
# 2560: HouseCode (1 byte)
|
||||
# 2561..2569: OutputType[0..8] (9 bytes; numVoltOutputs)
|
||||
# 2570: ZoneExpansions (1 byte)
|
||||
# 2571: NumExpEnc (1 byte; firstExpEncOut != 0)
|
||||
# 2572..2747: ZoneType[1..176] (176 bytes; raw enuZoneType byte/zone)
|
||||
# 2748..2772: DCMPhoneNumber1 (25-byte fixed-width string)
|
||||
# 2773..2774: DCMAccount1 (u16)
|
||||
# 2775..2799: DCMPhoneNumber2 (25)
|
||||
# 2800..2801: DCMAccount2 (u16)
|
||||
# 2802: DCMType (1)
|
||||
# 2803..2807: DCMTestTime (5-byte clsWhen: Hr,Min,Mon,Day,DOW)
|
||||
# 2808: DCMTestCode (1)
|
||||
# 2809..2984: Zones[].DCMAlarmCode (176 bytes)
|
||||
# 2985..2992: DCMFreezeAlm/Fire/Police/Aux/Duress/BatteryLow/FireZone/Cancel (8)
|
||||
# 2993..3004: TempFormat, NumThermostats, InstallerCode(u16),
|
||||
# EnablePCAccess, PCAccessCode(u16), ...
|
||||
# 2993: TempFormat / 2994: NumThermostats / 2995..2996: InstallerCode
|
||||
# 2997: EnablePCAccess / 2998..2999: PCAccessCode
|
||||
# 3000..3024: CallBackNumber (25)
|
||||
# 3025: ExteriorHornDelay / 3026: DialoutDelay / 3027: VerifyFireAlarms
|
||||
# 3028: EnableConsoleEmg / 3029: TimeFormat / 3030: DateFormat
|
||||
# 3031: ACPowerFreq / 3032: DeadLineDetect / 3033: OffHookDetect
|
||||
# 3034: NumAreasUsed
|
||||
# 3035..3050: X10 AreaGroups (16 bytes; (lastX10-firstX10+16)/16)
|
||||
# 3051..3058: VoltOut AreaGroups (8; lastVoltOut-firstVoltOut+1)
|
||||
# 3059..3073: FlagOut AreaGroups (15; (lastFlagOut-firstFlagOut+8)/8)
|
||||
# 3074..3105: ExpEnc AreaGroups (32; (lastExpEncOut-firstExpEncOut+4)/4)
|
||||
# 3106..3281: Zones[1..176].Area (176 bytes — area number per zone)
|
||||
#
|
||||
# Hardcoded for OMNI_PRO_II — other panels will need their own values.
|
||||
"instSetupStart": 2560,
|
||||
"zoneTypeOffset": 2572,
|
||||
"zoneAreaOffset": 3106,
|
||||
"max_zones": 176, "lenZoneName": 15, "zones_count": 176,
|
||||
"max_units": 512, "lenUnitName": 12, "units_count": 511,
|
||||
"max_buttons": 128, "lenButtonName": 12, "buttons_count": 255,
|
||||
@ -399,6 +431,7 @@ class _ConnectionWalk:
|
||||
area_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_areas: dict[int, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _read_name_table(r: PcaReader, count: int, name_len: int) -> dict[int, str]:
|
||||
@ -435,7 +468,7 @@ def _walk_to_connection(r: PcaReader, cap: dict[str, int]) -> _ConnectionWalk:
|
||||
setup_data = r.bytes_(cap["lenSetupData"])
|
||||
r.bytes_(10) # bool + bool + u16 + u16 + u32
|
||||
|
||||
# Pull ZoneType from the installer section of SetupData.
|
||||
# Pull ZoneType and Zones[].Area from the installer section of SetupData.
|
||||
# See the comment block on _CAP_OMNI_PRO_II for layout details.
|
||||
zt_off = cap.get("zoneTypeOffset")
|
||||
zone_types: dict[int, int] = {}
|
||||
@ -445,6 +478,14 @@ def _walk_to_connection(r: PcaReader, cap: dict[str, int]) -> _ConnectionWalk:
|
||||
for slot in range(1, cap["max_zones"] + 1):
|
||||
zone_types[slot] = setup_data[zt_off + slot - 1]
|
||||
|
||||
za_off = cap.get("zoneAreaOffset")
|
||||
zone_areas: dict[int, int] = {}
|
||||
if za_off is not None:
|
||||
za_end = za_off + cap["max_zones"]
|
||||
if za_end <= len(setup_data):
|
||||
for slot in range(1, cap["max_zones"] + 1):
|
||||
zone_areas[slot] = setup_data[za_off + slot - 1]
|
||||
|
||||
# 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"])
|
||||
@ -484,6 +525,7 @@ def _walk_to_connection(r: PcaReader, cap: dict[str, int]) -> _ConnectionWalk:
|
||||
area_names=area_names,
|
||||
message_names=message_names,
|
||||
zone_types=zone_types,
|
||||
zone_areas=zone_areas,
|
||||
)
|
||||
|
||||
|
||||
@ -572,6 +614,7 @@ def parse_pca_file(path_or_bytes: str | os.PathLike[str] | bytes, key: int) -> P
|
||||
account.area_names = walk.area_names
|
||||
account.message_names = walk.message_names
|
||||
account.zone_types = walk.zone_types
|
||||
account.zone_areas = walk.zone_areas
|
||||
|
||||
# PCA03+ continues past Connection with ModemBaud flags + nine
|
||||
# Description blocks + the Remarks table. We walk it on a
|
||||
|
||||
@ -162,6 +162,10 @@ async def test_mockpanel_from_pca_drives_full_ha_discovery(
|
||||
assert zone_types_by_slot[3] == 0x01 # BACK DOOR → Perimeter
|
||||
assert zone_types_by_slot[7] == 0x03 # LIVINGROOM MOT → AwayInt
|
||||
assert zone_types_by_slot[11] == 0x55 # OUTSIDE TEMP → outdoor temp
|
||||
# Per-zone area assignments — single-area install, every
|
||||
# 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}"
|
||||
finally:
|
||||
await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@ -358,6 +358,10 @@ async def test_mockstate_from_pca_serves_real_panel_programs() -> None:
|
||||
assert state.zones[3].zone_type == 0x01 # BACK DOOR → Perimeter
|
||||
assert state.zones[7].zone_type == 0x03 # LIVINGROOM MOT → AwayInt
|
||||
assert state.zones[11].zone_type == 0x55 # OUTSIDE TEMP → outdoor temp
|
||||
# Zone area assignments from SetupData — single-area install, all
|
||||
# zones in area 1.
|
||||
for slot, zone in state.zones.items():
|
||||
assert zone.area == 1, f"slot {slot} expected area=1 got {zone.area}"
|
||||
|
||||
panel = MockPanel(controller_key=CONTROLLER_KEY, state=state)
|
||||
async with panel.serve(transport="tcp") as (host, port):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user