pca_file: ZoneOptions + thermostat type/areas — per-object props done

Closes out the per-object property triad. These three fields live
deep in the installer section past the zone-area / button-area-group
arrays (clsHAC.cs:3290-3416):

  3330..3393: Thermostats[1..64].Areas  (area-membership bitmask)
  3397..3460: Thermostats[1..64].Type   (raw enuThermostatType)
  3553..3728: Zones[1..176].ZoneOptions (raw options byte)

Offsets derived from the OMNI_PRO_II CAP constants (numConsoles=16,
numTstats=64, numDCMCodes=16, numMessageGroups=16, numSerialPorts=6,
numSCI+numUART=5) plus its feature set — SuperviseBell +
SuperviseExteriorSounder + ZoneResistors + Addressable + UPB all
present, contributing exactly 9 conditional bytes before
ReportBypassRestore. Verified empirically: ZoneOptions is a clean
176-byte run of the default value 4, bounded by CrossZoneTimer=60
as the canary byte just before it.

New PcaAccount fields: zone_options, thermostat_types,
thermostat_areas. MockZoneState gains `options`, MockThermostatState
gains `thermostat_type` + `areas`. The mock's zone and thermostat
Properties replies now serve the real values instead of the
hardcoded 0 / 1 they used before — so HA discovery against
MockState.from_pca gets the complete per-object property set.

Live fixture: all 176 zones at the default options=4, both named
thermostats type 1, thermostat areas 0xFF (all) → normalised to
area 1 in the mock (consistent with the unit-area handling).

With this the OMNI_PRO_II SetupData decode is functionally complete
for every per-object property a consumer would want — zones, units,
areas, thermostats all carry type + area + options sourced from the
file rather than faked.

Full suite: 499 passed, 1 skipped.
This commit is contained in:
Ryan Malloy 2026-05-13 23:33:37 -06:00
parent e61e37a3fc
commit c7eb92122b
3 changed files with 96 additions and 3 deletions

View File

@ -173,6 +173,7 @@ class MockZoneState:
loop: int = 0 # analog loop reading loop: int = 0 # analog loop reading
zone_type: int = 0 # raw enuZoneType byte (0=EntryExit default) zone_type: int = 0 # raw enuZoneType byte (0=EntryExit default)
area: int = 1 # 1-based area assignment area: int = 1 # 1-based area assignment
options: int = 0 # raw ZoneOptions bitmask (SetupData; panel default 4)
@property @property
def status_byte(self) -> int: def status_byte(self) -> int:
@ -217,6 +218,8 @@ class MockThermostatState:
outdoor_temperature_raw: int = 0 outdoor_temperature_raw: int = 0
horc_status: int = 0 horc_status: int = 0
status: int = 1 # 1 = communicating with the panel status: int = 1 # 1 = communicating with the panel
thermostat_type: int = 1 # raw enuThermostatType (1=AutoHeatCool)
areas: int = 0x01 # 8-bit area-membership bitmask
@dataclass @dataclass
@ -348,6 +351,7 @@ class MockState:
name=n, name=n,
zone_type=acct.zone_types.get(i, 0), zone_type=acct.zone_types.get(i, 0),
area=acct.zone_areas.get(i, 1), area=acct.zone_areas.get(i, 1),
options=acct.zone_options.get(i, 0),
) )
for i, n in acct.zone_names.items() for i, n in acct.zone_names.items()
}, },
@ -382,7 +386,17 @@ class MockState:
for i in sorted(in_use_areas) for i in sorted(in_use_areas)
}, },
"thermostats": { "thermostats": {
i: MockThermostatState(name=n) i: MockThermostatState(
name=n,
thermostat_type=acct.thermostat_types.get(i, 1),
# 0xFF (uninitialised → "all areas") normalises to
# area 1 only, consistent with the unit-area handling.
areas=(
0x01
if acct.thermostat_areas.get(i, 0xFF) == 0xFF
else acct.thermostat_areas[i]
),
)
for i, n in acct.thermostat_names.items() for i, n in acct.thermostat_names.items()
}, },
"buttons": { "buttons": {
@ -987,7 +1001,7 @@ class MockPanel:
zone.loop if zone else 0, zone.loop if zone else 0,
zone.zone_type if zone else 0, zone.zone_type if zone else 0,
zone.area if zone else 1, zone.area if zone else 1,
0, # Options (not yet sourced from SetupData) zone.options if zone else 0,
] ]
) )
+ self.state.zone_name_bytes(index) + self.state.zone_name_bytes(index)
@ -1045,7 +1059,7 @@ class MockPanel:
t.system_mode if t else 0, t.system_mode if t else 0,
t.fan_mode if t else 0, t.fan_mode if t else 0,
t.hold_mode if t else 0, t.hold_mode if t else 0,
1, # thermostat type: AUTO_HEAT_COOL t.thermostat_type if t else 1,
] ]
) )
+ self.state.thermostat_name_bytes(index) + self.state.thermostat_name_bytes(index)

View File

@ -263,6 +263,23 @@ 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-zone options bitmask from SetupData (clsHAC.cs:3405-3415).
# Raw byte, range-checked by the firmware to 0..7 (0..15 on EURO
# EN50131 panels) with a default of 4. The bits select per-zone
# behaviours (cross-zoning, swinger-shutdown participation, etc.);
# the precise bit semantics aren't decoded here. Keys are 1-based
# zone slots (1..numZones).
zone_options: dict[int, int] = field(default_factory=dict)
# Per-thermostat type + area assignment from SetupData
# (clsHAC.cs:3298-3320). ``thermostat_types`` values are raw
# ``enuThermostatType`` bytes (0=NotUsed/None, 1=AutoHeatCool, …);
# ``thermostat_areas`` values are 8-bit area-membership bitmasks
# (0xFF = all areas, the panel default). Keys are 1-based
# thermostat slots (1..numTstats).
thermostat_types: dict[int, int] = field(default_factory=dict)
thermostat_areas: dict[int, int] = field(default_factory=dict)
# Per-area entry/exit delay (seconds) from SetupData user section. # Per-area entry/exit delay (seconds) from SetupData user section.
# Keys are 1-based area numbers (1..numAreas); typical values are # Keys are 1-based area numbers (1..numAreas); typical values are
# 30/60 (entry) and 60/90 (exit). Unused areas carry the panel # 30/60 (entry) and 60/90 (exit). Unused areas carry the panel
@ -617,6 +634,26 @@ _CAP_OMNI_PRO_II: dict[str, int] = {
"flagOutAreaGroupsOffset": 3059, # (lastFlagOut-firstFlagOut+8)/8 = 15 bytes, 1 group/8 flags "flagOutAreaGroupsOffset": 3059, # (lastFlagOut-firstFlagOut+8)/8 = 15 bytes, 1 group/8 flags
"expEncAreaGroupsOffset": 3074, # (lastExpEncOut-firstExpEncOut+4)/4 = 32 bytes, 1 group/4 outputs "expEncAreaGroupsOffset": 3074, # (lastExpEncOut-firstExpEncOut+4)/4 = 32 bytes, 1 group/4 outputs
"zoneAreaOffset": 3106, "zoneAreaOffset": 3106,
# Past the zone-area / button-area-group arrays, the installer
# section continues (clsHAC.cs:3290-3416). Offsets derived from the
# OMNI_PRO_II CAP constants — numConsoles=16, numTstats=64,
# numDCMCodes=16, numMessageGroups=16, numSerialPorts=6 (→4 rates),
# numSCI+numUART=5 (→3 protocols) — and the feature set
# (SuperviseBell + SuperviseExteriorSounder + ZoneResistors +
# Addressable + UPB all present, contributing 9 conditional bytes
# before ReportBypassRestore). Verified empirically:
# 3298..3313: Consoles[1..16].Area
# 3314..3329: Consoles[1..16].Global
# 3330..3393: Thermostats[1..64].Areas (area-membership bitmask)
# 3394: TimeAdj / 3395: AlarmResetTime / 3396: ArmingConfirmation
# 3397..3460: Thermostats[1..64].Type (enuThermostatType)
# ... DCM open/close codes, message groups, serial config,
# feature-conditional fields, area exit/unvacated flags ...
# 3552: CrossZoneTimer (boundary canary = 60)
# 3553..3728: Zones[1..176].ZoneOptions (raw options byte, default 4)
"thermostatAreasOffset": 3330,
"thermostatTypeOffset": 3397,
"zoneOptionsOffset": 3553,
# Unit index ranges → unit type derivation. Per CAP for OMNI_PRO_II: # Unit index ranges → unit type derivation. Per CAP for OMNI_PRO_II:
"firstX10": 1, "lastX10": 256, "firstX10": 1, "lastX10": 256,
"firstExpEncOut": 257, "lastExpEncOut": 384, "firstExpEncOut": 257, "lastExpEncOut": 384,
@ -836,6 +873,9 @@ 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)
zone_options: dict[int, int] = field(default_factory=dict)
thermostat_types: dict[int, int] = field(default_factory=dict)
thermostat_areas: dict[int, int] = field(default_factory=dict)
area_entry_delays: 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) area_exit_delays: dict[int, int] = field(default_factory=dict)
area_entry_chime: dict[int, bool] = field(default_factory=dict) area_entry_chime: dict[int, bool] = field(default_factory=dict)
@ -945,6 +985,25 @@ 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]
zo_off = cap.get("zoneOptionsOffset")
zone_options: dict[int, int] = {}
if zo_off is not None and zo_off + cap["max_zones"] <= len(setup_data):
for slot in range(1, cap["max_zones"] + 1):
zone_options[slot] = setup_data[zo_off + slot - 1]
# Per-thermostat type + area assignment (64 slots on OMNI_PRO_II).
n_tstats = cap.get("max_tstats", 0)
tt_off = cap.get("thermostatTypeOffset")
thermostat_types: dict[int, int] = {}
if tt_off is not None and tt_off + n_tstats <= len(setup_data):
for slot in range(1, n_tstats + 1):
thermostat_types[slot] = setup_data[tt_off + slot - 1]
tha_off = cap.get("thermostatAreasOffset")
thermostat_areas: dict[int, int] = {}
if tha_off is not None and tha_off + n_tstats <= len(setup_data):
for slot in range(1, n_tstats + 1):
thermostat_areas[slot] = setup_data[tha_off + slot - 1]
# Per-area entry/exit delays from the user section. # Per-area entry/exit delays from the user section.
num_areas = cap.get("max_areas", 0) num_areas = cap.get("max_areas", 0)
def _read_area_byte_array(offset_key: str) -> dict[int, int]: def _read_area_byte_array(offset_key: str) -> dict[int, int]:
@ -1252,6 +1311,9 @@ 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,
zone_options=zone_options,
thermostat_types=thermostat_types,
thermostat_areas=thermostat_areas,
area_entry_delays=area_entry_delays, area_entry_delays=area_entry_delays,
area_exit_delays=area_exit_delays, area_exit_delays=area_exit_delays,
area_entry_chime=area_entry_chime, area_entry_chime=area_entry_chime,
@ -1394,6 +1456,9 @@ 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.zone_options = walk.zone_options
account.thermostat_types = walk.thermostat_types
account.thermostat_areas = walk.thermostat_areas
account.area_entry_delays = walk.area_entry_delays account.area_entry_delays = walk.area_entry_delays
account.area_exit_delays = walk.area_exit_delays account.area_exit_delays = walk.area_exit_delays
account.area_entry_chime = walk.area_entry_chime account.area_entry_chime = walk.area_entry_chime

View File

@ -475,6 +475,20 @@ async def test_mockstate_from_pca_serves_real_panel_programs() -> None:
# zones in area 1. # zones in area 1.
for slot, zone in state.zones.items(): for slot, zone in state.zones.items():
assert zone.area == 1, f"slot {slot} expected area=1 got {zone.area}" assert zone.area == 1, f"slot {slot} expected area=1 got {zone.area}"
# ZoneOptions — every zone carries the panel-default 4 in this fixture.
for slot, zone in state.zones.items():
assert zone.options == 4, f"slot {slot} expected options=4 got {zone.options}"
assert all(v == 4 for v in acct.zone_options.values())
assert len(acct.zone_options) == 176
# Thermostat type + area from SetupData. The two named thermostats
# (DOWNSTAIRS, UPSTAIRS) are type 1; areas were 0xFF (all) →
# normalised to area 1 only in MockState.
assert acct.thermostat_types[1] == 1
assert acct.thermostat_types[2] == 1
assert len(acct.thermostat_types) == 64
assert state.thermostats[1].thermostat_type == 1
assert state.thermostats[1].areas == 0x01
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):