From 8250df02061cb2223ff0a72c70f0d56405529f4b Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Thu, 14 May 2026 01:10:33 -0600 Subject: [PATCH] pca_file: TimeAdj, AlarmResetTime, ArmingConfirmation, TwoWayAudio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four more scalars sandwiched around the thermostat arrays (clsHAC.cs:3303-3321): 3394: TimeAdj — daily clock-drift adjust minute (1..59, default 30) 3395: AlarmResetTime — alarm-clear retry delay (1..30 default 6; 30..60/30 on EURO) 3396: ArmingConfirmation — beep on successful arm (bool) 3461: TwoWayAudio — central-station 2-way audio on alarm (bool) 3461 sits right after Thermostat.Type[1..64] @ 3397..3460. Live fixture: time_adj=30 (panel default), alarm_reset_time=4, arming_confirmation=False, two_way_audio=False — coherent plain-vanilla home-install values. Full suite: 499 passed, 1 skipped. --- src/omni_pca/pca_file.py | 43 ++++++++++++++++++++++++++++++++++ tests/test_e2e_program_echo.py | 6 +++++ 2 files changed, 49 insertions(+) diff --git a/src/omni_pca/pca_file.py b/src/omni_pca/pca_file.py index 7d6b292..60b5b9e 100644 --- a/src/omni_pca/pca_file.py +++ b/src/omni_pca/pca_file.py @@ -280,6 +280,21 @@ class PcaAccount: thermostat_types: dict[int, int] = field(default_factory=dict) thermostat_areas: dict[int, int] = field(default_factory=dict) + # Four more SetupData scalars sandwiched around the thermostat arrays: + # time_adj — minutes past midnight when the panel applies its daily + # clock-drift adjustment (range 1..59, default 30) + # alarm_reset_time — seconds the panel waits before allowing + # re-arming after an alarm clears (1..30 default 6 on standard + # panels, 30..60/30 on EURO EN50131 panels) + # arming_confirmation — whether the panel beeps to acknowledge + # successful arming (bool, default False) + # two_way_audio — whether the panel routes two-way audio to the + # central station during alarms (bool, default False) + time_adj: int = 0 + alarm_reset_time: int = 0 + arming_confirmation: bool = False + two_way_audio: bool = False + # 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 @@ -652,7 +667,17 @@ _CAP_OMNI_PRO_II: dict[str, int] = { # 3552: CrossZoneTimer (boundary canary = 60) # 3553..3728: Zones[1..176].ZoneOptions (raw options byte, default 4) "thermostatAreasOffset": 3330, + # Three single-byte scalars sandwiched between Thermostat.Areas[64] + # and Thermostat.Type[64] (clsHAC.cs:3303-3314): + # 3394: TimeAdj (range 1..59, default 30) + # 3395: AlarmResetTime (range 1..30 default 6; 30..60/30 on EURO) + # 3396: ArmingConfirmation (bool, default false) + "timeAdjOffset": 3394, + "alarmResetTimeOffset": 3395, + "armingConfirmationOffset": 3396, "thermostatTypeOffset": 3397, + # TwoWayAudio (bool) sits immediately after Thermostat.Type[64]. + "twoWayAudioOffset": 3461, "zoneOptionsOffset": 3553, # Unit index ranges → unit type derivation. Per CAP for OMNI_PRO_II: "firstX10": 1, "lastX10": 256, @@ -876,6 +901,10 @@ class _ConnectionWalk: 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) + time_adj: int = 0 + alarm_reset_time: int = 0 + arming_confirmation: bool = False + two_way_audio: bool = False 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) @@ -1095,6 +1124,12 @@ def _walk_to_connection(r: PcaReader, cap: dict[str, int]) -> _ConnectionWalk: chars.append(chr(setup_data[pos])) return "".join(chars) + # Four scalars sandwiched around the thermostat arrays. + time_adj = _read_scalar_byte("timeAdjOffset") + alarm_reset_time = _read_scalar_byte("alarmResetTimeOffset") + arming_confirmation = _read_bool("armingConfirmationOffset") + two_way_audio = _read_bool("twoWayAudioOffset") + # Telephony / dialer scalars + the panel's outgoing phone number. telephone_access = _read_bool("telephoneAccessOffset") answer_outside_call = _read_bool("answerOutsideCallOffset") @@ -1314,6 +1349,10 @@ def _walk_to_connection(r: PcaReader, cap: dict[str, int]) -> _ConnectionWalk: zone_options=zone_options, thermostat_types=thermostat_types, thermostat_areas=thermostat_areas, + time_adj=time_adj, + alarm_reset_time=alarm_reset_time, + arming_confirmation=arming_confirmation, + two_way_audio=two_way_audio, area_entry_delays=area_entry_delays, area_exit_delays=area_exit_delays, area_entry_chime=area_entry_chime, @@ -1459,6 +1498,10 @@ def parse_pca_file(path_or_bytes: str | os.PathLike[str] | bytes, key: int) -> P account.zone_options = walk.zone_options account.thermostat_types = walk.thermostat_types account.thermostat_areas = walk.thermostat_areas + account.time_adj = walk.time_adj + account.alarm_reset_time = walk.alarm_reset_time + account.arming_confirmation = walk.arming_confirmation + account.two_way_audio = walk.two_way_audio 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 4dc2108..b35c1e5 100644 --- a/tests/test_e2e_program_echo.py +++ b/tests/test_e2e_program_echo.py @@ -490,6 +490,12 @@ async def test_mockstate_from_pca_serves_real_panel_programs() -> None: assert state.thermostats[1].thermostat_type == 1 assert state.thermostats[1].areas == 0x01 + # Four scalars sandwiched around the thermostat arrays. + assert acct.time_adj == 30 # panel default + assert 1 <= acct.alarm_reset_time <= 30 # in valid standard range + assert acct.arming_confirmation is False + assert acct.two_way_audio is False + panel = MockPanel(controller_key=CONTROLLER_KEY, state=state) async with panel.serve(transport="tcp") as (host, port): async with OmniClient(