From c7eb92122b673a6adf90ade0647b5f09e90eaa7a Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Wed, 13 May 2026 23:33:37 -0600 Subject: [PATCH] =?UTF-8?q?pca=5Ffile:=20ZoneOptions=20+=20thermostat=20ty?= =?UTF-8?q?pe/areas=20=E2=80=94=20per-object=20props=20done?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/omni_pca/mock_panel.py | 20 +++++++++-- src/omni_pca/pca_file.py | 65 ++++++++++++++++++++++++++++++++++ tests/test_e2e_program_echo.py | 14 ++++++++ 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/src/omni_pca/mock_panel.py b/src/omni_pca/mock_panel.py index 99f4cc6..d97c6d5 100644 --- a/src/omni_pca/mock_panel.py +++ b/src/omni_pca/mock_panel.py @@ -173,6 +173,7 @@ class MockZoneState: loop: int = 0 # analog loop reading zone_type: int = 0 # raw enuZoneType byte (0=EntryExit default) area: int = 1 # 1-based area assignment + options: int = 0 # raw ZoneOptions bitmask (SetupData; panel default 4) @property def status_byte(self) -> int: @@ -217,6 +218,8 @@ class MockThermostatState: outdoor_temperature_raw: int = 0 horc_status: int = 0 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 @@ -348,6 +351,7 @@ class MockState: name=n, zone_type=acct.zone_types.get(i, 0), area=acct.zone_areas.get(i, 1), + options=acct.zone_options.get(i, 0), ) for i, n in acct.zone_names.items() }, @@ -382,7 +386,17 @@ class MockState: for i in sorted(in_use_areas) }, "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() }, "buttons": { @@ -987,7 +1001,7 @@ class MockPanel: zone.loop if zone else 0, zone.zone_type if zone else 0, 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) @@ -1045,7 +1059,7 @@ class MockPanel: t.system_mode if t else 0, t.fan_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) diff --git a/src/omni_pca/pca_file.py b/src/omni_pca/pca_file.py index f1d5a9e..7d6b292 100644 --- a/src/omni_pca/pca_file.py +++ b/src/omni_pca/pca_file.py @@ -263,6 +263,23 @@ class PcaAccount: # zone to area 1. Empty dict when SetupData wasn't walked successfully. 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. # Keys are 1-based area numbers (1..numAreas); typical values are # 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 "expEncAreaGroupsOffset": 3074, # (lastExpEncOut-firstExpEncOut+4)/4 = 32 bytes, 1 group/4 outputs "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: "firstX10": 1, "lastX10": 256, "firstExpEncOut": 257, "lastExpEncOut": 384, @@ -836,6 +873,9 @@ class _ConnectionWalk: 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) + 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_exit_delays: dict[int, int] = 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): 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. num_areas = cap.get("max_areas", 0) 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, zone_types=zone_types, zone_areas=zone_areas, + zone_options=zone_options, + thermostat_types=thermostat_types, + thermostat_areas=thermostat_areas, area_entry_delays=area_entry_delays, area_exit_delays=area_exit_delays, 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.zone_types = walk.zone_types 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_exit_delays = walk.area_exit_delays account.area_entry_chime = walk.area_entry_chime diff --git a/tests/test_e2e_program_echo.py b/tests/test_e2e_program_echo.py index f2d4792..4dc2108 100644 --- a/tests/test_e2e_program_echo.py +++ b/tests/test_e2e_program_echo.py @@ -475,6 +475,20 @@ async def test_mockstate_from_pca_serves_real_panel_programs() -> None: # zones in area 1. for slot, zone in state.zones.items(): 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) async with panel.serve(transport="tcp") as (host, port):