pca_file: TimeAdj, AlarmResetTime, ArmingConfirmation, TwoWayAudio

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.
This commit is contained in:
Ryan Malloy 2026-05-14 01:10:33 -06:00
parent c7eb92122b
commit 8250df0206
2 changed files with 49 additions and 0 deletions

View File

@ -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

View File

@ -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(