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
|
arming_state: int = 0 # 0=disarmed, 16=armed, 32=bypassed, 48=auto-bypassed
|
||||||
is_bypassed: bool = False
|
is_bypassed: bool = False
|
||||||
loop: int = 0 # analog loop reading
|
loop: int = 0 # analog loop reading
|
||||||
|
zone_type: int = 0 # raw enuZoneType byte (0=EntryExit default)
|
||||||
|
area: int = 1 # 1-based area assignment
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def status_byte(self) -> int:
|
def status_byte(self) -> int:
|
||||||
@ -283,11 +285,14 @@ class MockState:
|
|||||||
table, encoded back to wire bytes so UploadProgram /
|
table, encoded back to wire bytes so UploadProgram /
|
||||||
UploadPrograms serve them exactly as a real panel would.
|
UploadPrograms serve them exactly as a real panel would.
|
||||||
* ``zones`` / ``units`` / ``areas`` / ``thermostats`` / ``buttons``
|
* ``zones`` / ``units`` / ``areas`` / ``thermostats`` / ``buttons``
|
||||||
— populated with the names from the .pca's Names section. Each
|
— populated with the names from the .pca's Names section.
|
||||||
entry is a ``MockZoneState`` / ``MockUnitState`` / etc. with
|
``MockZoneState`` entries additionally carry ``zone_type``
|
||||||
only ``name`` set; other fields (zone_type, area assignment,
|
(the raw ``enuZoneType`` byte from the SetupData installer
|
||||||
thermostat type, …) default to 0 because those properties live
|
section), so the mock's Properties replies categorise zones
|
||||||
in SetupData, which we don't decode yet.
|
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*,
|
``user_codes`` is not seeded — the .pca only stores code *names*,
|
||||||
not the PIN values; the panel keeps PINs in SetupData. Override
|
not the PIN values; the panel keeps PINs in SetupData. Override
|
||||||
@ -315,7 +320,13 @@ class MockState:
|
|||||||
"firmware_minor": acct.firmware_minor,
|
"firmware_minor": acct.firmware_minor,
|
||||||
"firmware_revision": acct.firmware_revision,
|
"firmware_revision": acct.firmware_revision,
|
||||||
"programs": programs,
|
"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()},
|
"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=n) for i, n in acct.area_names.items()},
|
||||||
"thermostats": {
|
"thermostats": {
|
||||||
@ -922,9 +933,9 @@ class MockPanel:
|
|||||||
index & 0xFF,
|
index & 0xFF,
|
||||||
zone.status_byte if zone else 0,
|
zone.status_byte if zone else 0,
|
||||||
zone.loop if zone else 0,
|
zone.loop if zone else 0,
|
||||||
0, # Type: EntryExit
|
zone.zone_type if zone else 0,
|
||||||
1, # Area: default to area 1
|
zone.area if zone else 1,
|
||||||
0, # Options
|
0, # Options (not yet sourced from SetupData)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
+ self.state.zone_name_bytes(index)
|
+ self.state.zone_name_bytes(index)
|
||||||
|
|||||||
@ -217,9 +217,9 @@ class PcaAccount:
|
|||||||
# Per-object name tables — populated from the Names block between
|
# Per-object name tables — populated from the Names block between
|
||||||
# SetupData and Voices. Keys are 1-based slot numbers; empty slots
|
# SetupData and Voices. Keys are 1-based slot numbers; empty slots
|
||||||
# are omitted entirely (the panel stores them as length-0 String8
|
# are omitted entirely (the panel stores them as length-0 String8
|
||||||
# blobs, which we filter at read time). Properties beyond the name
|
# blobs, which we filter at read time). Other object properties
|
||||||
# (zone_type, area assignment, thermostat type, etc.) live in
|
# come from the SetupData block (see ``zone_types`` for the first
|
||||||
# SetupData and aren't extracted yet.
|
# such field extracted).
|
||||||
zone_names: dict[int, str] = field(default_factory=dict)
|
zone_names: dict[int, str] = field(default_factory=dict)
|
||||||
unit_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)
|
button_names: dict[int, str] = field(default_factory=dict)
|
||||||
@ -228,6 +228,14 @@ class PcaAccount:
|
|||||||
area_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)
|
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:
|
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()."""
|
||||||
@ -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.
|
# 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,
|
||||||
|
# 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_zones": 176, "lenZoneName": 15, "zones_count": 176,
|
||||||
"max_units": 512, "lenUnitName": 12, "units_count": 511,
|
"max_units": 512, "lenUnitName": 12, "units_count": 511,
|
||||||
"max_buttons": 128, "lenButtonName": 12, "buttons_count": 255,
|
"max_buttons": 128, "lenButtonName": 12, "buttons_count": 255,
|
||||||
@ -363,10 +382,12 @@ def _walk_to_remarks(r: PcaReader) -> dict[int, str]:
|
|||||||
class _ConnectionWalk:
|
class _ConnectionWalk:
|
||||||
"""Side-channel output of :func:`_walk_to_connection`.
|
"""Side-channel output of :func:`_walk_to_connection`.
|
||||||
|
|
||||||
Captures the per-object name tables on the way past so the caller
|
Captures the per-object name tables + selected SetupData fields on
|
||||||
can attach them to :class:`PcaAccount`. Each ``*_names`` dict is
|
the way past so the caller can attach them to :class:`PcaAccount`.
|
||||||
``{1-based slot: name}`` with only non-empty slots present —
|
Each ``*_names`` dict is ``{1-based slot: name}`` with only non-empty
|
||||||
matches the "iter_defined" convention used for programs.
|
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
|
programs_blob: bytes
|
||||||
@ -377,6 +398,7 @@ class _ConnectionWalk:
|
|||||||
thermostat_names: dict[int, str] = field(default_factory=dict)
|
thermostat_names: dict[int, str] = field(default_factory=dict)
|
||||||
area_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)
|
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]:
|
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
|
Mirrors clsHAC.cs:7995-8044. The per-object names are read via
|
||||||
clsAbstractNamedItem.ReadName → String8(L) — see
|
clsAbstractNamedItem.ReadName → String8(L) — see
|
||||||
:func:`_read_name_table` for the per-slot layout.
|
: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
|
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:
|
# 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"])
|
||||||
@ -448,6 +483,7 @@ def _walk_to_connection(r: PcaReader, cap: dict[str, int]) -> _ConnectionWalk:
|
|||||||
thermostat_names=thermostat_names,
|
thermostat_names=thermostat_names,
|
||||||
area_names=area_names,
|
area_names=area_names,
|
||||||
message_names=message_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.thermostat_names = walk.thermostat_names
|
||||||
account.area_names = walk.area_names
|
account.area_names = walk.area_names
|
||||||
account.message_names = walk.message_names
|
account.message_names = walk.message_names
|
||||||
|
account.zone_types = walk.zone_types
|
||||||
|
|
||||||
# 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
|
||||||
|
|||||||
@ -153,6 +153,15 @@ async def test_mockpanel_from_pca_drives_full_ha_discovery(
|
|||||||
assert len(coordinator.data.thermostats) == 2
|
assert len(coordinator.data.thermostats) == 2
|
||||||
# And the programs sensor reflects 330 from wire iter_programs.
|
# And the programs sensor reflects 330 from wire iter_programs.
|
||||||
assert len(coordinator.data.programs) == 330
|
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:
|
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()
|
||||||
|
|||||||
@ -350,6 +350,14 @@ async def test_mockstate_from_pca_serves_real_panel_programs() -> None:
|
|||||||
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"
|
||||||
|
# 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)
|
panel = MockPanel(controller_key=CONTROLLER_KEY, state=state)
|
||||||
async with panel.serve(transport="tcp") as (host, port):
|
async with panel.serve(transport="tcp") as (host, port):
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user