pca_file: extract zone_type from SetupData installer section
SetupData (3840 bytes) holds the panel's per-object property tables. Layout for OMNI_PRO_II's installer section (Seek to instSetupStart=2560 in clsHAC._ParseSetupData at clsHAC.cs:3156): offset 2560: HouseCode (1 byte) offsets 2561..2569: OutputType[0..8] (9 bytes; numVoltOutputs) offset 2570: ZoneExpansions (1 byte) offset 2571: NumExpEnc (1 byte) offsets 2572..2747: ZoneType[1..176] (176 bytes; raw enuZoneType per zone) Verified against the live fixture: 2 EntryExit + 4 Perimeter + 3 AwayInt + 1 Extended_Range_OutdoorTemp + 166 Auxiliary (panel default for unused slots) — matches the named-zones cross-reference exactly. PcaAccount gains a zone_types dict (1-based slot → raw byte). The walker stashes the SetupData blob to a buffer up front and indexes in by offset rather than chasing the sequential parser through all of telephony/codes/areas — that's a bigger RE pass for another day. MockZoneState now carries zone_type and area fields. MockState.from_pca threads acct.zone_types through, and _build_zone_properties uses the real value instead of hardcoded 0 (EntryExit). End-to-end against MockPanel.from_pca: HA's discovery now classifies binary vs. analog zones correctly straight from the .pca — outdoor temp zone surfaces as a temperature sensor entity, motion sensors as binary_sensor, door zones as the right kind of binary_sensor. Full suite: 499 passed, 1 skipped. RE notes in pca_file.py.
This commit is contained in:
parent
7db9616a34
commit
70bf9caf58
@ -156,6 +156,8 @@ class MockZoneState:
|
||||
arming_state: int = 0 # 0=disarmed, 16=armed, 32=bypassed, 48=auto-bypassed
|
||||
is_bypassed: bool = False
|
||||
loop: int = 0 # analog loop reading
|
||||
zone_type: int = 0 # raw enuZoneType byte (0=EntryExit default)
|
||||
area: int = 1 # 1-based area assignment
|
||||
|
||||
@property
|
||||
def status_byte(self) -> int:
|
||||
@ -283,11 +285,14 @@ class MockState:
|
||||
table, encoded back to wire bytes so UploadProgram /
|
||||
UploadPrograms serve them exactly as a real panel would.
|
||||
* ``zones`` / ``units`` / ``areas`` / ``thermostats`` / ``buttons``
|
||||
— populated with the names from the .pca's Names section. Each
|
||||
entry is a ``MockZoneState`` / ``MockUnitState`` / etc. with
|
||||
only ``name`` set; other fields (zone_type, area assignment,
|
||||
thermostat type, …) default to 0 because those properties live
|
||||
in SetupData, which we don't decode yet.
|
||||
— populated with the names from the .pca's Names section.
|
||||
``MockZoneState`` entries additionally carry ``zone_type``
|
||||
(the raw ``enuZoneType`` byte from the SetupData installer
|
||||
section), so the mock's Properties replies categorise zones
|
||||
as security / temperature / humidity matching the source
|
||||
panel. Other SetupData-resident fields (per-zone area
|
||||
assignment, unit type, thermostat type, exit/entry delays,
|
||||
options bitmasks) aren't extracted yet — those default to 0.
|
||||
|
||||
``user_codes`` is not seeded — the .pca only stores code *names*,
|
||||
not the PIN values; the panel keeps PINs in SetupData. Override
|
||||
@ -315,7 +320,13 @@ class MockState:
|
||||
"firmware_minor": acct.firmware_minor,
|
||||
"firmware_revision": acct.firmware_revision,
|
||||
"programs": programs,
|
||||
"zones": {i: MockZoneState(name=n) for i, n in acct.zone_names.items()},
|
||||
"zones": {
|
||||
i: MockZoneState(
|
||||
name=n,
|
||||
zone_type=acct.zone_types.get(i, 0),
|
||||
)
|
||||
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()},
|
||||
"thermostats": {
|
||||
@ -922,9 +933,9 @@ class MockPanel:
|
||||
index & 0xFF,
|
||||
zone.status_byte if zone else 0,
|
||||
zone.loop if zone else 0,
|
||||
0, # Type: EntryExit
|
||||
1, # Area: default to area 1
|
||||
0, # Options
|
||||
zone.zone_type if zone else 0,
|
||||
zone.area if zone else 1,
|
||||
0, # Options (not yet sourced from SetupData)
|
||||
]
|
||||
)
|
||||
+ self.state.zone_name_bytes(index)
|
||||
|
||||
@ -217,9 +217,9 @@ class PcaAccount:
|
||||
# Per-object name tables — populated from the Names block between
|
||||
# SetupData and Voices. Keys are 1-based slot numbers; empty slots
|
||||
# are omitted entirely (the panel stores them as length-0 String8
|
||||
# blobs, which we filter at read time). Properties beyond the name
|
||||
# (zone_type, area assignment, thermostat type, etc.) live in
|
||||
# SetupData and aren't extracted yet.
|
||||
# blobs, which we filter at read time). Other object properties
|
||||
# come from the SetupData block (see ``zone_types`` for the first
|
||||
# such field extracted).
|
||||
zone_names: dict[int, str] = field(default_factory=dict)
|
||||
unit_names: dict[int, str] = field(default_factory=dict)
|
||||
button_names: dict[int, str] = field(default_factory=dict)
|
||||
@ -228,6 +228,14 @@ class PcaAccount:
|
||||
area_names: dict[int, str] = field(default_factory=dict)
|
||||
message_names: dict[int, str] = field(default_factory=dict)
|
||||
|
||||
# Zone types from SetupData installer section. Keys are 1-based slot
|
||||
# numbers (always 1..numZones); values are raw ``enuZoneType`` byte
|
||||
# values — see ``enuZoneType.cs`` for the full enum. Common values:
|
||||
# 0x00=EntryExit, 0x01=Perimeter, 0x03=AwayInt, 0x40=Auxiliary (the
|
||||
# panel default for unused slots), 0x55=Extended_Range_OutdoorTemp.
|
||||
# Empty dict when SetupData wasn't walked successfully.
|
||||
zone_types: 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()."""
|
||||
@ -267,6 +275,17 @@ 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,
|
||||
# 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.
|
||||
"instSetupStart": 2560,
|
||||
"zoneTypeOffset": 2572,
|
||||
"max_zones": 176, "lenZoneName": 15, "zones_count": 176,
|
||||
"max_units": 512, "lenUnitName": 12, "units_count": 511,
|
||||
"max_buttons": 128, "lenButtonName": 12, "buttons_count": 255,
|
||||
@ -363,10 +382,12 @@ def _walk_to_remarks(r: PcaReader) -> dict[int, str]:
|
||||
class _ConnectionWalk:
|
||||
"""Side-channel output of :func:`_walk_to_connection`.
|
||||
|
||||
Captures the per-object name tables on the way past so the caller
|
||||
can attach them to :class:`PcaAccount`. Each ``*_names`` dict is
|
||||
``{1-based slot: name}`` with only non-empty slots present —
|
||||
matches the "iter_defined" convention used for programs.
|
||||
Captures the per-object name tables + selected SetupData fields on
|
||||
the way past so the caller can attach them to :class:`PcaAccount`.
|
||||
Each ``*_names`` dict is ``{1-based slot: name}`` with only non-empty
|
||||
slots present — matches the "iter_defined" convention used for
|
||||
programs. ``zone_types`` is ``{1-based slot: enuZoneType byte}`` for
|
||||
every zone slot (defined or not — the array is fixed-size).
|
||||
"""
|
||||
|
||||
programs_blob: bytes
|
||||
@ -377,6 +398,7 @@ class _ConnectionWalk:
|
||||
thermostat_names: dict[int, str] = field(default_factory=dict)
|
||||
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)
|
||||
|
||||
|
||||
def _read_name_table(r: PcaReader, count: int, name_len: int) -> dict[int, str]:
|
||||
@ -406,10 +428,23 @@ def _walk_to_connection(r: PcaReader, cap: dict[str, int]) -> _ConnectionWalk:
|
||||
Mirrors clsHAC.cs:7995-8044. The per-object names are read via
|
||||
clsAbstractNamedItem.ReadName → String8(L) — see
|
||||
:func:`_read_name_table` for the per-slot layout.
|
||||
|
||||
SetupData is captured to a buffer up-front so we can index into its
|
||||
installer section for ZoneType (offset 2572 on OMNI_PRO_II).
|
||||
"""
|
||||
r.bytes_(cap["lenSetupData"])
|
||||
setup_data = r.bytes_(cap["lenSetupData"])
|
||||
r.bytes_(10) # bool + bool + u16 + u16 + u32
|
||||
|
||||
# Pull ZoneType 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] = {}
|
||||
if zt_off is not None:
|
||||
zt_end = zt_off + cap["max_zones"]
|
||||
if zt_end <= len(setup_data):
|
||||
for slot in range(1, cap["max_zones"] + 1):
|
||||
zone_types[slot] = setup_data[zt_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"])
|
||||
@ -448,6 +483,7 @@ def _walk_to_connection(r: PcaReader, cap: dict[str, int]) -> _ConnectionWalk:
|
||||
thermostat_names=thermostat_names,
|
||||
area_names=area_names,
|
||||
message_names=message_names,
|
||||
zone_types=zone_types,
|
||||
)
|
||||
|
||||
|
||||
@ -535,6 +571,7 @@ def parse_pca_file(path_or_bytes: str | os.PathLike[str] | bytes, key: int) -> P
|
||||
account.thermostat_names = walk.thermostat_names
|
||||
account.area_names = walk.area_names
|
||||
account.message_names = walk.message_names
|
||||
account.zone_types = walk.zone_types
|
||||
|
||||
# PCA03+ continues past Connection with ModemBaud flags + nine
|
||||
# Description blocks + the Remarks table. We walk it on a
|
||||
|
||||
@ -153,6 +153,15 @@ async def test_mockpanel_from_pca_drives_full_ha_discovery(
|
||||
assert len(coordinator.data.thermostats) == 2
|
||||
# And the programs sensor reflects 330 from wire iter_programs.
|
||||
assert len(coordinator.data.programs) == 330
|
||||
# Zone types flowed from SetupData → mock → wire Properties
|
||||
# reply → HA's ZoneProperties parser.
|
||||
zone_types_by_slot = {
|
||||
idx: z.zone_type for idx, z in coordinator.data.zones.items()
|
||||
}
|
||||
assert zone_types_by_slot[1] == 0x00 # GARAGE ENTRY → EntryExit
|
||||
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
|
||||
finally:
|
||||
await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@ -350,6 +350,14 @@ async def test_mockstate_from_pca_serves_real_panel_programs() -> None:
|
||||
assert state.zones[1].name == "GARAGE ENTRY"
|
||||
assert state.units[1].name == "ROOM ONE"
|
||||
assert state.thermostats[1].name == "DOWNSTAIRS"
|
||||
# Zone types from SetupData — door zones are EntryExit (0) or
|
||||
# Perimeter (1), motion sensors are AwayInt (3), the OUTSIDE TEMP
|
||||
# zone is Extended_Range_OutdoorTemp (0x55).
|
||||
assert state.zones[1].zone_type == 0x00 # GARAGE ENTRY → EntryExit
|
||||
assert state.zones[2].zone_type == 0x00 # FRONT DOOR → EntryExit
|
||||
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
|
||||
|
||||
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