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.
1579 lines
68 KiB
Python
1579 lines
68 KiB
Python
"""Decryption + parsing for PC Access ``.pca`` and ``PCA01.CFG`` files.
|
||
|
||
These files are NOT AES-encrypted despite the existence of clsAES — they
|
||
use a Borland-Pascal-style 32-bit LCG keystream XORed per byte. Hardcoded
|
||
keys are baked into PC Access for the export format and the in-process
|
||
config; per-installation ``.pca`` exports use a 32-bit key stored inside
|
||
``PCA01.CFG`` (``pca_key`` field).
|
||
|
||
For the ``.pca`` body we walk header → SetupData → flags → Names → Voices
|
||
→ Programs → EventLog → Connection block; the Connection block is what
|
||
yields the panel's network address, TCP port, and 16-byte AES-128
|
||
ControllerKey used for the live secure-session handshake.
|
||
|
||
References:
|
||
clsPcaCryptFileStream.cs (lines 88-92) — Borland LCG XOR keystream
|
||
clsPcaCfg.cs — keyPC01 / keyExport constants, PCA01.CFG layout
|
||
clsHAC.cs:7943-8056 — .pca header + body walker, Connection block
|
||
clsCapOMNI_PRO_II.cs — per-model size constants used by the body walker
|
||
|
||
Cross-references (HAI OmniPro II Installation Manual):
|
||
*INSTALLER SETUP* (pca-re/docs/manuals/installation_manual/
|
||
04_INSTALLER_SETUP/) is what populates everything we then read back
|
||
from a .pca export:
|
||
SETUP CONTROL → SetupData block (panel-wide options)
|
||
SETUP ZONES → Names section (zone-name entries) + Z*_TYPE
|
||
(encoded inside SetupData)
|
||
SETUP AREAS → Names section (area-name entries) + per-area
|
||
delays and codes in SetupData
|
||
SETUP MISC → Programs section (timed scenes, energy savers)
|
||
SETUP EXPANSION → cap counters that drive how big each names
|
||
block is on the wire
|
||
APPENDIX C — ZONE AND UNIT MAPPING (12_APPENDIX_C_-_ZONE_AND_UNIT_MAPPING/)
|
||
documents the address-space layout the cap constants in
|
||
_CAP_OMNI_PRO_II below derive from (176 zones, 511 units, etc.).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
import os
|
||
import struct
|
||
import logging
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
from typing import Final
|
||
|
||
from .programs import (
|
||
MAX_PROGRAMS,
|
||
PROGRAM_BYTES,
|
||
Program,
|
||
decode_program_table,
|
||
)
|
||
|
||
_log = logging.getLogger(__name__)
|
||
|
||
KEY_PC01: Final[int] = 0x14326573 # 338847091 — clsPcaCfg.keyPC01 (PCA01.CFG)
|
||
KEY_EXPORT: Final[int] = 0x17569237 # 391549495 — clsPcaCfg.keyExport (.pca import/export)
|
||
|
||
_LCG_MULT: Final[int] = 134775813
|
||
_MASK32: Final[int] = 0xFFFFFFFF
|
||
|
||
HEADER_LEN: Final[int] = 2191
|
||
|
||
|
||
def _keystream_byte(seed: int) -> tuple[int, int]:
|
||
seed = (seed * _LCG_MULT + 1) & _MASK32
|
||
# Reference: clsPcaCryptFileStream.cs:88-92 — the `% 255` (not 256) is
|
||
# an intentional Borland Random() quirk; the keystream byte never
|
||
# produces 0xFF.
|
||
return seed, (seed >> 16) % 255
|
||
|
||
|
||
def decrypt_pca_bytes(data: bytes, key: int) -> bytes:
|
||
"""XOR-decrypt ``data`` using the Borland LCG keystream seeded with ``key``."""
|
||
seed = key & _MASK32
|
||
out = bytearray(len(data))
|
||
for i, b in enumerate(data):
|
||
seed, ks = _keystream_byte(seed)
|
||
out[i] = b ^ ks
|
||
return bytes(out)
|
||
|
||
|
||
def derive_key_from_stamp(stamp: str) -> int:
|
||
"""clsPcaCfg.SetSecurityStamp — fold each character into a 32-bit accumulator.
|
||
|
||
Useful only if the original installer stamp string is known.
|
||
"""
|
||
k = 0x12345678
|
||
for ch in stamp:
|
||
c = ord(ch) & 0xFF
|
||
k = (((k ^ c) << 7) ^ c) & _MASK32
|
||
return k
|
||
|
||
|
||
class PcaReader:
|
||
"""Buffered byte reader matching clsPcaCryptFileStream's read API.
|
||
|
||
All multi-byte integers are little-endian; both String8 and String16
|
||
are length-prefixed UTF-8. The fixed-length String variants always
|
||
consume `1 + max_len` (or `2 + max_len`) bytes regardless of declared
|
||
string length — the slack is zero-padded by the writer.
|
||
"""
|
||
|
||
__slots__ = ("buf",)
|
||
|
||
def __init__(self, plaintext: bytes) -> None:
|
||
self.buf = io.BytesIO(plaintext)
|
||
|
||
def position(self) -> int:
|
||
return self.buf.tell()
|
||
|
||
def remaining(self) -> bytes:
|
||
return self.buf.read()
|
||
|
||
def bytes_(self, n: int) -> bytes:
|
||
b = self.buf.read(n)
|
||
if len(b) != n:
|
||
raise EOFError(f"short read: wanted {n}, got {len(b)} at offset {self.buf.tell()}")
|
||
return b
|
||
|
||
def u8(self) -> int:
|
||
return self.bytes_(1)[0]
|
||
|
||
def u16(self) -> int:
|
||
return struct.unpack("<H", self.bytes_(2))[0]
|
||
|
||
def u32(self) -> int:
|
||
return struct.unpack("<I", self.bytes_(4))[0]
|
||
|
||
def string8(self) -> str:
|
||
n = self.u8()
|
||
return self.bytes_(n).decode("utf-8", errors="replace")
|
||
|
||
def string8_fixed(self, max_len: int) -> str:
|
||
n = self.u8()
|
||
eff = min(n, max_len)
|
||
s = self.bytes_(max_len)[:eff].decode("utf-8", errors="replace")
|
||
return s.rstrip("\x00")
|
||
|
||
def string16(self) -> str:
|
||
n = self.u16()
|
||
return self.bytes_(n).decode("utf-8", errors="replace")
|
||
|
||
def string16_fixed(self, max_len: int) -> str:
|
||
n = self.u16()
|
||
eff = min(n, max_len)
|
||
s = self.bytes_(max_len)[:eff].decode("utf-8", errors="replace")
|
||
return s.rstrip("\x00")
|
||
|
||
|
||
@dataclass
|
||
class PcaConfig:
|
||
"""Decoded ``PCA01.CFG`` (PC Access app-level settings)."""
|
||
|
||
version_tag: str
|
||
version: int
|
||
init_cmd1: str = ""
|
||
init_cmd2: str = ""
|
||
init_cmd3: str = ""
|
||
local_cmd: str = ""
|
||
online_cmd: str = ""
|
||
answer_cmd: str = ""
|
||
hangup_cmd: str = ""
|
||
modem_port: int = 0
|
||
modem_irq: int = 0
|
||
modem_baud_code: int | None = None
|
||
pca_key: int = 0
|
||
password: str = field(default="", repr=False)
|
||
printer_port: int = 0
|
||
serial_port: int = 0
|
||
serial_baud_code: int = 0
|
||
|
||
|
||
@dataclass
|
||
class PcaAccount:
|
||
"""Decoded ``.pca`` header + extracted Connection-block fields.
|
||
|
||
PII fields (account_name/address/phone/code, plus user code values) are
|
||
intentionally `repr=False` so they don't end up in logs by accident.
|
||
Dialer-side identification of the panel is in the Connection block:
|
||
``network_address``, ``network_port``, ``controller_key``.
|
||
"""
|
||
|
||
version_tag: str
|
||
file_version: int
|
||
model: int
|
||
firmware_major: int
|
||
firmware_minor: int
|
||
firmware_revision: int
|
||
network_address: str | None = None
|
||
network_port: int | None = None
|
||
controller_key: bytes | None = None
|
||
account_name: str = field(default="", repr=False)
|
||
account_address: str = field(default="", repr=False)
|
||
account_phone: str = field(default="", repr=False)
|
||
account_code: str = field(default="", repr=False)
|
||
account_remarks: str = field(default="", repr=False)
|
||
programs: tuple[Program, ...] = ()
|
||
"""Decoded panel automation programs (1500 slots; many usually empty).
|
||
|
||
Populated only when the .pca body is walked successfully and the
|
||
Programs block decodes without error. Empty tuple otherwise. Use
|
||
:func:`omni_pca.programs.iter_defined` to filter to in-use slots.
|
||
"""
|
||
|
||
remarks: dict[int, str] = field(default_factory=dict)
|
||
"""Resolved RemarkID → text for every Remark-typed program.
|
||
|
||
A Remark-typed program record (``ProgramType.REMARK``, byte 0 == 4)
|
||
stores a 32-bit BE RemarkID in bytes 1-4 of its 14-byte body; the
|
||
associated user-entered text lives in a separate table further down
|
||
the .pca body (after Connection + ModemBaud flags + nine 33-byte
|
||
Description blocks). The walker parses that table on a best-effort
|
||
basis — failure here doesn't break Connection extraction.
|
||
"""
|
||
|
||
# Free-text "account remarks" block (a PCA03 extension; appears
|
||
# between the ModemBaud flags and the nine Description blocks).
|
||
# Used by PC Access for installer notes about the site.
|
||
account_remarks_extended: str = field(default="", repr=False)
|
||
|
||
# Per-object Description tables — free-text "description" strings
|
||
# entered alongside each object's name in PC Access (e.g. zone 1's
|
||
# name is "FRONT DOOR" and its description might be "Solid wood,
|
||
# contacts at top hinge"). Keys are 1-based slot numbers, values
|
||
# are decoded UTF-8 strings (max 32 chars). Empty slots are
|
||
# omitted. Populated only for FileVersion >= 3.
|
||
zone_descriptions: dict[int, str] = field(default_factory=dict)
|
||
unit_descriptions: dict[int, str] = field(default_factory=dict)
|
||
button_descriptions: dict[int, str] = field(default_factory=dict)
|
||
code_descriptions: dict[int, str] = field(default_factory=dict)
|
||
thermostat_descriptions: dict[int, str] = field(default_factory=dict)
|
||
area_descriptions: dict[int, str] = field(default_factory=dict)
|
||
message_descriptions: dict[int, str] = field(default_factory=dict)
|
||
audio_source_descriptions: dict[int, str] = field(default_factory=dict)
|
||
audio_zone_descriptions: dict[int, str] = field(default_factory=dict)
|
||
|
||
# Per-object name tables — populated from the Names block between
|
||
# SetupData and Voices. Keys are 1-based slot numbers; empty slots
|
||
# are omitted entirely (the panel stores them as length-0 String8
|
||
# blobs, which we filter at read time). Other object properties
|
||
# come from the SetupData block (see ``zone_types`` for the first
|
||
# such field extracted).
|
||
zone_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)
|
||
code_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)
|
||
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)
|
||
|
||
# Per-zone area assignment from SetupData installer section. Keys
|
||
# are 1-based slot numbers (always 1..numZones); values are 1-based
|
||
# area numbers (1..numAreas). Most single-area installs assign every
|
||
# 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)
|
||
|
||
# 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
|
||
# default (15 s in the live fixture).
|
||
area_entry_delays: dict[int, int] = field(default_factory=dict)
|
||
area_exit_delays: dict[int, int] = field(default_factory=dict)
|
||
|
||
# Per-area boolean configuration flags from SetupData user section,
|
||
# five contiguous bool[8] arrays at offset 1787..1826
|
||
# (clsHAC.cs:3020-3038). Keys are 1-based area numbers.
|
||
#
|
||
# entry_chime — chime keypads when entry-delay zones trip
|
||
# quick_arm — allow arming without a code
|
||
# auto_bypass — silently bypass not-ready zones on arm
|
||
# all_on_for_alarm — fire every output when any alarm trips
|
||
# trouble_beep — beep keypads on a non-alarm trouble condition
|
||
#
|
||
# PerimeterChime and AudibleExitDelay are NOT in this contiguous
|
||
# block — they live deeper in the user section past FlashLightNum,
|
||
# HouseCodes flags, and 6 TimeClock When-structs (see
|
||
# perimeter_chime / audible_exit_delay below).
|
||
area_entry_chime: dict[int, bool] = field(default_factory=dict)
|
||
area_quick_arm: dict[int, bool] = field(default_factory=dict)
|
||
area_auto_bypass: dict[int, bool] = field(default_factory=dict)
|
||
area_all_on_for_alarm: dict[int, bool] = field(default_factory=dict)
|
||
area_trouble_beep: dict[int, bool] = field(default_factory=dict)
|
||
area_perimeter_chime: dict[int, bool] = field(default_factory=dict)
|
||
area_audible_exit_delay: dict[int, bool] = field(default_factory=dict)
|
||
|
||
# Per-unit type + area assignment derived from CAP index ranges and
|
||
# the AreaGroups arrays in SetupData. Keys are 1-based unit slots
|
||
# (1..numUnits). Values:
|
||
# unit_types[u]: raw ``enuOL2UnitType`` byte (1=Standard for X10,
|
||
# 12=Flag for FlagOut, 13=Output for VoltOut/ExpEnc). The
|
||
# X10 sub-types are collapsed to Standard — deriving the
|
||
# specific HouseCodeFormat would require the EnableExtCode
|
||
# table which we don't decode yet.
|
||
# unit_areas[u]: 8-bit area-membership bitmask. 0xFF is the panel
|
||
# default ("all areas") when no specific restriction is set;
|
||
# 0x01 means "area 1 only".
|
||
unit_types: dict[int, int] = field(default_factory=dict)
|
||
unit_areas: dict[int, int] = field(default_factory=dict)
|
||
|
||
# User codes (PIN database). 99 entries on OMNI_PRO_II.
|
||
#
|
||
# ``code_pins`` is the raw 16-bit value stored on disk (BE u16) —
|
||
# plain 4-digit PINs decode as decimal 0..9999, but the live fixture
|
||
# has some entries with values >9999 whose format isn't yet
|
||
# determined (possibly scrambled, possibly card-credential format,
|
||
# possibly partial-byte flags). Treat as opaque pending RE.
|
||
#
|
||
# ``code_pins`` is marked ``repr=False`` so a debug ``print(acct)``
|
||
# never leaks PIN material into logs. ``code_authority`` is the
|
||
# enuCodeAuthority byte (0=Disabled, 1=User, 2=Manager, 3=Installer)
|
||
# and ``code_areas`` is the area-membership bitmask (0xFF = all).
|
||
code_pins: dict[int, int] = field(default_factory=dict, repr=False)
|
||
code_authority: dict[int, int] = field(default_factory=dict)
|
||
code_areas: dict[int, int] = field(default_factory=dict)
|
||
|
||
# HouseCodes.EnableExtCode array (16 bytes on OMNI_PRO_II — one per
|
||
# 16-unit X10 group). Values are raw ``enuHouseCodeFormat`` bytes:
|
||
# 0=Standard, 1=Extended, 2=Compose, 3=UPB, 4=RadioRA, 5=HLC,
|
||
# 6=CentraLite, 7=ZWave, 8=LutronHomeWorks, 9=Clipsal_C_Bus,
|
||
# 10=Dynalite, 11=RadioRA2, 12=Somfy_SDN, 13=ZigBee, 14=KNX,
|
||
# 15=LumaNet, 16=Somfy_URTSI. ``unit_types`` for X10 units uses
|
||
# this table to resolve specific sub-types (HLCRoom vs HLCLoad,
|
||
# ViziaRoomController vs ViziaLoad, etc.) per clsUnit.CalculateUnitType.
|
||
house_code_formats: dict[int, int] = field(default_factory=dict)
|
||
|
||
# Three panel-wide time-clock schedules (TimeClock 1/2/3), each as
|
||
# an (On, Off) pair. Tuple of six TimeClocks in order:
|
||
# TC1.On, TC1.Off, TC2.On, TC2.Off, TC3.On, TC3.Off. Empty tuple
|
||
# when SetupData wasn't walked successfully.
|
||
time_clocks: tuple[TimeClock, ...] = ()
|
||
|
||
# Two panel-wide PINs for authenticated config access. PII —
|
||
# ``repr=False`` so they never leak into ``print(acct)``. Both are
|
||
# BE u16 ("4-digit decimal"). ``enable_pc_access`` is the toggle
|
||
# that lets a PC Access client connect at all.
|
||
installer_code: int = field(default=0, repr=False)
|
||
enable_pc_access: bool = False
|
||
pc_access_code: int = field(default=0, repr=False)
|
||
|
||
# Panel geographic configuration — raw bytes used by the firmware
|
||
# to compute sunrise/sunset for time-of-day programs. No N/S/E/W
|
||
# modifier at this position (those live in the WorldWideLatitude
|
||
# feature block past DST). TimeZone is hours west of UTC on
|
||
# OMNI_PRO_II (7=PDT, 8=PST).
|
||
latitude: int = 0
|
||
longitude: int = 0
|
||
time_zone: int = 0
|
||
|
||
# DST configuration (when the panel switches between DST and standard
|
||
# time). Values are raw bytes from enuDSTMonth / enuDSTWeek:
|
||
# 0 = Disabled, 1..12 = month, 1..7 = week (1=First Sunday, 2=Second,
|
||
# 3=Third, 4=Fourth, 5=Last, 6=Next to Last, 7=Third from Last).
|
||
# US default after 2007: Mar/Second, Nov/First.
|
||
dst_start_month: int = 0
|
||
dst_start_week: int = 0
|
||
dst_end_month: int = 0
|
||
dst_end_week: int = 0
|
||
|
||
# Panel-wide TempFormat (enuTempFormat: 1=Fahrenheit, 2=Celsius)
|
||
# and NumAreasUsed (count of armable security areas — 1 for a
|
||
# typical single-area home install, up to numAreas=8 on Omni Pro II).
|
||
# Both are 0 if SetupData wasn't walked successfully.
|
||
temp_format: int = 0
|
||
num_areas_used: int = 0
|
||
|
||
# Telephony / dialer configuration. ``my_phone_number`` is the
|
||
# panel's own outgoing line — PII, so ``repr=False``.
|
||
# ``dial_mode`` is raw enuDialMode (0=Tone, 1=Pulse). ``"-"`` in
|
||
# the phone-number strings is the panel's "blank number" sentinel.
|
||
telephone_access: bool = False
|
||
answer_outside_call: bool = False
|
||
remote_commands_ok: bool = False
|
||
rings_before_answer: int = 0
|
||
dial_mode: int = 0
|
||
my_phone_number: str = field(default="", repr=False)
|
||
callback_number: str = field(default="", repr=False)
|
||
|
||
# Misc panel-wide scalars from SetupData.
|
||
# high_security / freeze_alarm / announce_alarms — bool toggles
|
||
# flash_light_num — X10 unit number flashed during an alarm
|
||
# house_code — base X10 house code (1=A, 2=B, …)
|
||
# zone_expansions / num_exp_enc — expansion-module counts
|
||
# num_thermostats — configured thermostat count
|
||
# exterior_horn_delay / dialout_delay — seconds
|
||
# verify_fire_alarms / enable_console_emg — bool toggles
|
||
# time_format / date_format — raw enuTimeFormat / enuDateFormat
|
||
# ac_power_freq — raw enuFrequency (mains Hz selector)
|
||
# dead_line_detect / off_hook_detect — phone-line monitoring
|
||
high_security: bool = False
|
||
freeze_alarm: bool = False
|
||
flash_light_num: int = 0
|
||
announce_alarms: bool = False
|
||
house_code: int = 0
|
||
zone_expansions: int = 0
|
||
num_exp_enc: int = 0
|
||
num_thermostats: int = 0
|
||
exterior_horn_delay: int = 0
|
||
dialout_delay: int = 0
|
||
verify_fire_alarms: bool = False
|
||
enable_console_emg: bool = False
|
||
time_format: int = 0
|
||
date_format: int = 0
|
||
ac_power_freq: int = 0
|
||
dead_line_detect: int = 0
|
||
off_hook_detect: int = 0
|
||
|
||
# Digital Communicator Module (alarm-dialer) block — see DcmConfig.
|
||
# default_factory is a lambda because DcmConfig is defined further
|
||
# down the module (alongside TimeClock); the lambda defers the name
|
||
# lookup to instance-construction time when it's resolvable.
|
||
dcm: "DcmConfig" = field(default_factory=lambda: DcmConfig())
|
||
|
||
|
||
def parse_pca01_cfg(data: bytes, key: int = KEY_PC01) -> PcaConfig:
|
||
"""Decrypt ``data`` (raw PCA01.CFG bytes) and parse per clsPcaCfg.Read()."""
|
||
plain = decrypt_pca_bytes(data, key)
|
||
r = PcaReader(plain)
|
||
version_tag = r.string8()
|
||
try:
|
||
version = int(version_tag[3:] or "0")
|
||
except ValueError as exc:
|
||
raise ValueError(f"unrecognized CFG version tag {version_tag!r}") from exc
|
||
if version < 4:
|
||
raise ValueError(f"unsupported CFG version {version} (need >= 4)")
|
||
|
||
cfg = PcaConfig(version_tag=version_tag, version=version)
|
||
cfg.init_cmd1 = r.string8_fixed(40)
|
||
if version >= 5:
|
||
cfg.init_cmd2 = r.string8_fixed(40)
|
||
cfg.init_cmd3 = r.string8_fixed(40)
|
||
cfg.local_cmd = r.string8_fixed(40)
|
||
cfg.online_cmd = r.string8_fixed(40)
|
||
cfg.answer_cmd = r.string8_fixed(40)
|
||
cfg.hangup_cmd = r.string8_fixed(40)
|
||
cfg.modem_port = r.u16()
|
||
cfg.modem_irq = r.u16()
|
||
if version < 5:
|
||
cfg.modem_baud_code = r.u16()
|
||
cfg.pca_key = r.u32()
|
||
cfg.password = r.string8_fixed(10)
|
||
cfg.printer_port = r.u16()
|
||
cfg.serial_port = r.u16()
|
||
cfg.serial_baud_code = r.u16()
|
||
return cfg
|
||
|
||
|
||
# OMNI_PRO_II capability constants (clsCapOMNI_PRO_II.cs). For other models
|
||
# this table needs broadening — but Connection-block extraction only requires
|
||
# correct totals up to that block, and OMNI_PRO_II is the working reference.
|
||
_CAP_OMNI_PRO_II: dict[str, int] = {
|
||
"lenSetupData": 3840,
|
||
# User section walks from offset 1 (Seek(1)). Fixed-width derivation:
|
||
#
|
||
# 1..5: TelephoneAccess+AnswerOutsideCall+RemoteCommandsOK+
|
||
# RingsBeforeAnswer+DialMode (5×1 byte)
|
||
# 6..30: MyPhoneNumber (25 = 1 length + 24 payload, fixed-width)
|
||
# 31..310: Phone[0..7] (8 × 35: Number(25)+WhenOn(5)+WhenOff(5))
|
||
# 311..382: Areas[1..8].DialOrder (8 × 9 fixed-width String8)
|
||
# 383..1768: Codes[1..99] (99 × 14: Code(u16)+Authority(1)+
|
||
# Areas(1)+WhenOn(5)+WhenOff(5))
|
||
# 1769..1770: Codes[251].Code (u16)
|
||
# 1771..1778: Areas[1..8].EntryDelay (8 bytes)
|
||
# 1779..1786: Areas[1..8].ExitDelay (8 bytes)
|
||
#
|
||
# Codes block: 99 entries × 14 bytes at offset 383.
|
||
# Per-entry layout (clsHAC.cs:3001-3009):
|
||
# bytes 0..1: PIN (BE u16, clsHardwareArray.ReadUInt16)
|
||
# byte 2: Authority (enuCodeAuthority: 0=Disabled, 1=User,
|
||
# 2=Manager, 3=Installer)
|
||
# byte 3: Areas bitmask
|
||
# bytes 4..8: WhenOn (clsWhen)
|
||
# bytes 9..13: WhenOff (clsWhen)
|
||
"codesOffset": 383,
|
||
"codeEntryBytes": 14,
|
||
"entryDelayOffset": 1771,
|
||
"exitDelayOffset": 1779,
|
||
# Five contiguous bool[8] flag arrays immediately follow ExitDelay
|
||
# (clsHAC.cs:3020-3038). PerimeterChime and AudibleExitDelay are
|
||
# NOT contiguous — they live later, past HighSecurity, FreezeAlarm,
|
||
# FlashLightNum, HouseCodes flags, and 6 TimeClock When-structs.
|
||
"entryChimeOffset": 1787,
|
||
"quickArmOffset": 1795,
|
||
"autoBypassOffset": 1803,
|
||
"allOnForAlarmOffset": 1811,
|
||
"troubleBeepOffset": 1819,
|
||
# After TroubleBeep the user section continues with HighSecurity (1),
|
||
# FreezeAlarm (1), FlashLightNum_HI+LO (2; lastX10>255), HouseCodes
|
||
# EnableAllOff[16] (16), EnableAllOn[16] (16), 6×clsWhen (30),
|
||
# Latitude+Longitude+TimeZone (3), AnnounceAlarms (1) — 70 bytes —
|
||
# then the two remaining area bool[8] flags and DST scalars:
|
||
# 1897..1904: PerimeterChime[1..8]
|
||
# 1905..1912: AudibleExitDelay[1..8]
|
||
# 1913: DSTStartMonth (enuDSTMonth)
|
||
# 1914: DSTStartWeek (enuDSTWeek)
|
||
# 1915: DSTEndMonth (enuDSTMonth)
|
||
# 1916: DSTEndWeek (enuDSTWeek)
|
||
# HouseCodes.Count derives as (lastX10 - firstX10 + 1) / 16 = 16 for
|
||
# OMNI_PRO_II (clsHouseCodes.cs:35).
|
||
# Three single-byte scalars sandwiched between the TimeClocks block
|
||
# and AnnounceAlarms (clsHAC.cs:3064-3066). Latitude / Longitude are
|
||
# raw degrees (no N/S/E/W modifier in this position — those live in
|
||
# the WorldWideLatitude feature block after DST). TimeZone is the
|
||
# panel's UTC offset selector; OMNI_PRO_II uses raw hours west of
|
||
# UTC (e.g. 7 = Pacific Daylight, 8 = Pacific Standard).
|
||
"latitudeOffset": 1893,
|
||
"longitudeOffset": 1894,
|
||
"timeZoneOffset": 1895,
|
||
"perimeterChimeOffset": 1897,
|
||
"audibleExitDelayOffset": 1905,
|
||
"dstStartMonthOffset": 1913,
|
||
"dstStartWeekOffset": 1914,
|
||
"dstEndMonthOffset": 1915,
|
||
"dstEndWeekOffset": 1916,
|
||
# HouseCodes.EnableExtCode[1..16] (1 byte/HouseCode, raw
|
||
# enuHouseCodeFormat). Read order is right after the 4 DST bytes
|
||
# per clsHAC.cs:3084-3088. Live fixture: [5,1,1,...,1] = HouseCode 1
|
||
# is HLC, the rest are Extended.
|
||
"houseCodeFormatOffset": 1917,
|
||
# Six 5-byte clsWhen structs in order TC1.On, TC1.Off, TC2.On,
|
||
# TC2.Off, TC3.On, TC3.Off (clsHAC.cs:3058-3063, before
|
||
# Latitude/Longitude/TimeZone).
|
||
"timeClocksOffset": 1863,
|
||
# InstallerCode/PCAccessCode are BE u16 inside the installer
|
||
# section, sandwiching the EnablePCAccess bool at offset 2997.
|
||
"installerCodeOffset": 2995,
|
||
"enablePCAccessOffset": 2997,
|
||
"pcAccessCodeOffset": 2998,
|
||
|
||
# Installer section begins at byte 2560 (clsCapOMNI_PRO_II.instSetupStart).
|
||
# Layout for OMNI_PRO_II observed empirically against the live fixture
|
||
# and cross-checked against clsHAC._ParseSetupData (clsHAC.cs:3156-...).
|
||
#
|
||
# 2560: HouseCode (1 byte)
|
||
# 2561..2569: OutputType[0..8] (9 bytes; numVoltOutputs)
|
||
# 2570: ZoneExpansions (1 byte)
|
||
# 2571: NumExpEnc (1 byte; firstExpEncOut != 0)
|
||
# 2572..2747: ZoneType[1..176] (176 bytes; raw enuZoneType byte/zone)
|
||
# 2748..2772: DCMPhoneNumber1 (25-byte fixed-width string)
|
||
# 2773..2774: DCMAccount1 (u16)
|
||
# 2775..2799: DCMPhoneNumber2 (25)
|
||
# 2800..2801: DCMAccount2 (u16)
|
||
# 2802: DCMType (1)
|
||
# 2803..2807: DCMTestTime (5-byte clsWhen: Hr,Min,Mon,Day,DOW)
|
||
# 2808: DCMTestCode (1)
|
||
# 2809..2984: Zones[].DCMAlarmCode (176 bytes)
|
||
# 2985..2992: DCMFreezeAlm/Fire/Police/Aux/Duress/BatteryLow/FireZone/Cancel (8)
|
||
# 2993..3004: TempFormat, NumThermostats, InstallerCode(u16),
|
||
# EnablePCAccess, PCAccessCode(u16), ...
|
||
# 2993: TempFormat / 2994: NumThermostats / 2995..2996: InstallerCode
|
||
# 2997: EnablePCAccess / 2998..2999: PCAccessCode
|
||
# 3000..3024: CallBackNumber (25)
|
||
# 3025: ExteriorHornDelay / 3026: DialoutDelay / 3027: VerifyFireAlarms
|
||
# 3028: EnableConsoleEmg / 3029: TimeFormat / 3030: DateFormat
|
||
# 3031: ACPowerFreq / 3032: DeadLineDetect / 3033: OffHookDetect
|
||
# 3034: NumAreasUsed
|
||
# 3035..3050: X10 AreaGroups (16 bytes; (lastX10-firstX10+16)/16)
|
||
# 3051..3058: VoltOut AreaGroups (8; lastVoltOut-firstVoltOut+1)
|
||
# 3059..3073: FlagOut AreaGroups (15; (lastFlagOut-firstFlagOut+8)/8)
|
||
# 3074..3105: ExpEnc AreaGroups (32; (lastExpEncOut-firstExpEncOut+4)/4)
|
||
# 3106..3281: Zones[1..176].Area (176 bytes — area number per zone)
|
||
#
|
||
# Hardcoded for OMNI_PRO_II — other panels will need their own values.
|
||
"instSetupStart": 2560,
|
||
"houseCodeOffset": 2560,
|
||
"outputTypeOffset": 2561, # OutputType[0..8] (9 bytes; numVoltOutputs)
|
||
"zoneExpansionsOffset": 2570,
|
||
"numExpEncOffset": 2571,
|
||
"zoneTypeOffset": 2572,
|
||
# DCM (Digital Communicator Module) dialer block.
|
||
"dcmPhone1Offset": 2748, # 25-byte fixed-width string
|
||
"dcmAccount1Offset": 2773, # BE u16
|
||
"dcmPhone2Offset": 2775,
|
||
"dcmAccount2Offset": 2800,
|
||
"dcmTypeOffset": 2802,
|
||
"dcmTestTimeOffset": 2803, # 5-byte clsWhen
|
||
"dcmTestCodeOffset": 2808,
|
||
"dcmZoneAlarmCodesOffset": 2809, # 176 bytes, one per zone
|
||
"dcmEmergencyCodesOffset": 2985, # 8 bytes: Freeze/Fire/Police/Aux/Duress/Batt/FireZone/Cancel
|
||
"tempFormatOffset": 2993,
|
||
"numThermostatsOffset": 2994,
|
||
"callBackNumberOffset": 3000, # 25-byte fixed-width string
|
||
"exteriorHornDelayOffset": 3025,
|
||
"dialoutDelayOffset": 3026,
|
||
"verifyFireAlarmsOffset": 3027,
|
||
"enableConsoleEmgOffset": 3028,
|
||
"timeFormatOffset": 3029,
|
||
"dateFormatOffset": 3030,
|
||
"acPowerFreqOffset": 3031,
|
||
"deadLineDetectOffset": 3032,
|
||
"offHookDetectOffset": 3033,
|
||
"numAreasUsedOffset": 3034,
|
||
# User-section head: telephony flags + MyPhoneNumber.
|
||
"telephoneAccessOffset": 1,
|
||
"answerOutsideCallOffset": 2,
|
||
"remoteCommandsOkOffset": 3,
|
||
"ringsBeforeAnswerOffset": 4,
|
||
"dialModeOffset": 5,
|
||
"myPhoneNumberOffset": 6, # 25-byte fixed-width string
|
||
# Misc user-section scalars after the 5 contiguous area flags.
|
||
"highSecurityOffset": 1827,
|
||
"freezeAlarmOffset": 1828,
|
||
"flashLightNumOffset": 1829, # BE u16 (HI+LO; lastX10>255)
|
||
"announceAlarmsOffset": 1896,
|
||
# AreaGroups arrays per family — each byte is an 8-bit area-membership
|
||
# bitmask covering one or more units, sized via the CAP ranges:
|
||
"x10AreaGroupsOffset": 3035, # (lastX10-firstX10+16)/16 = 16 bytes, 1 group/16 units
|
||
"voltOutAreaGroupsOffset": 3051, # lastVoltOut-firstVoltOut+1 = 8 bytes, 1 byte/unit
|
||
"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,
|
||
# 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,
|
||
"firstExpEncOut": 257, "lastExpEncOut": 384,
|
||
"firstVoltOut": 385, "lastVoltOut": 392,
|
||
"firstFlagOut": 393, "lastFlagOut": 511,
|
||
"max_zones": 176, "lenZoneName": 15, "zones_count": 176,
|
||
"max_units": 512, "lenUnitName": 12, "units_count": 511,
|
||
"max_buttons": 128, "lenButtonName": 12, "buttons_count": 255,
|
||
"max_codes": 99, "lenCodeName": 12, "codes_count": 99,
|
||
"max_tstats": 64, "lenTstatName": 12, "tstats_count": 64,
|
||
"max_areas": 8, "lenAreaName": 12, "areas_count": 8,
|
||
"max_messages": 128, "lenMessageName": 15, "messages_count": 128,
|
||
"max_message_voices": 128,
|
||
"voice_struct_bytes": 12,
|
||
"voice_skip_bytes": 6,
|
||
"max_programs": 1500, "program_bytes": 14,
|
||
"max_event_log": 250, "event_bytes": 9,
|
||
}
|
||
|
||
|
||
def _parse_header(r: PcaReader) -> tuple[str, int, int, int, int, int, str, str, str, str, str]:
|
||
"""Read the fixed 2191-byte PCA03 header, returning (version_tag, file_version,
|
||
model, fw_major, fw_minor, fw_rev, name, address, phone, code, remarks)."""
|
||
version_tag = r.string8()
|
||
try:
|
||
file_version = int(version_tag[3:] or "0")
|
||
except ValueError as exc:
|
||
raise ValueError(f"unrecognized .pca version tag {version_tag!r}") from exc
|
||
name = r.string8_fixed(30)
|
||
address = r.string16_fixed(120)
|
||
phone = r.string8_fixed(20)
|
||
code = r.string8_fixed(4)
|
||
remarks = r.string16_fixed(2000)
|
||
if r.position() != HEADER_LEN - 4:
|
||
raise ValueError(f"header parse misaligned at offset {r.position()}, expected 2187")
|
||
model = r.u8()
|
||
fw_major = r.u8()
|
||
fw_minor = r.u8()
|
||
fw_rev = struct.unpack("<b", r.bytes_(1))[0]
|
||
return version_tag, file_version, model, fw_major, fw_minor, fw_rev, name, address, phone, code, remarks
|
||
|
||
|
||
# Description blocks each store a 32-byte fixed slot prefixed by a
|
||
# 1-byte length (clsAbstractNamedItem.ReadDescription:1-4) so each
|
||
# entry is exactly _DESCRIPTION_SLOT_BYTES on the wire regardless of
|
||
# the actual string length.
|
||
_DESCRIPTION_SLOT_BYTES: Final[int] = 1 + 32
|
||
|
||
|
||
@dataclass
|
||
class _RemarksWalk:
|
||
"""Side-channel output of :func:`_walk_to_remarks`.
|
||
|
||
Captures everything in the post-Connection PCA03 extension:
|
||
AccountRemarks_Extended (free text), the nine per-family
|
||
Description tables, and the Remarks ID→text dict that
|
||
Remark-typed programs reference.
|
||
"""
|
||
|
||
account_remarks_extended: str = ""
|
||
zone_descriptions: dict[int, str] = field(default_factory=dict)
|
||
unit_descriptions: dict[int, str] = field(default_factory=dict)
|
||
button_descriptions: dict[int, str] = field(default_factory=dict)
|
||
code_descriptions: dict[int, str] = field(default_factory=dict)
|
||
thermostat_descriptions: dict[int, str] = field(default_factory=dict)
|
||
area_descriptions: dict[int, str] = field(default_factory=dict)
|
||
message_descriptions: dict[int, str] = field(default_factory=dict)
|
||
audio_source_descriptions: dict[int, str] = field(default_factory=dict)
|
||
audio_zone_descriptions: dict[int, str] = field(default_factory=dict)
|
||
remarks: dict[int, str] = field(default_factory=dict)
|
||
|
||
|
||
def _read_description_table(r: PcaReader) -> dict[int, str]:
|
||
"""Read a per-family Description block: u32 count, then count ×
|
||
String8(32). Returns {1-based slot: description} omitting empties.
|
||
|
||
Mirrors ``clsZones.ReadDescription`` and friends — the format is
|
||
identical across object families.
|
||
"""
|
||
count = r.u32()
|
||
out: dict[int, str] = {}
|
||
for i in range(1, count + 1):
|
||
text = r.string8_fixed(32)
|
||
if text:
|
||
out[i] = text
|
||
return out
|
||
|
||
|
||
def _walk_to_remarks(r: PcaReader) -> _RemarksWalk:
|
||
"""Walk the PCA03 post-Connection extension.
|
||
|
||
Layout for PCA03 (FileVersion 3, per clsHAC.cs:8058-8079):
|
||
|
||
1. ``ModemBaud`` (u16 LE), 3× bool (1 byte each), AccountRemarks_Extended
|
||
(String16: u16 length + bytes).
|
||
2. Nine Description blocks in the order Zones, Units, Buttons, Codes,
|
||
Thermostats, Areas, Messages, AudioSources, AudioZones. Each is
|
||
``[u32 count] + count * 33 bytes`` (per :data:`_DESCRIPTION_SLOT_BYTES`).
|
||
The 33-byte slots are String8(32) — 1 length byte + 32 padded bytes.
|
||
3. Remarks table:
|
||
- ``_RemarksNextID`` (u32 LE) — what ``RemarksNextID()`` will
|
||
hand out next.
|
||
- ``count`` (u32 LE).
|
||
- ``count`` entries of ``[u32 LE remark_id][String16 text]``.
|
||
|
||
Returns an empty :class:`_RemarksWalk` on any read failure (panels
|
||
without these blocks, or a file format we don't recognise).
|
||
|
||
Reference: clsPrograms.ReadRemarks (clsPrograms.cs:148-168),
|
||
clsAbstractNamedItem.ReadDescription, clsHAC.cs:8058-8079.
|
||
"""
|
||
walk = _RemarksWalk()
|
||
try:
|
||
r.u16() # ModemBaud
|
||
r.u8(); r.u8(); r.u8() # PCModemInit1/2/3 enable flags
|
||
walk.account_remarks_extended = r.string16()
|
||
walk.zone_descriptions = _read_description_table(r)
|
||
walk.unit_descriptions = _read_description_table(r)
|
||
walk.button_descriptions = _read_description_table(r)
|
||
walk.code_descriptions = _read_description_table(r)
|
||
walk.thermostat_descriptions = _read_description_table(r)
|
||
walk.area_descriptions = _read_description_table(r)
|
||
walk.message_descriptions = _read_description_table(r)
|
||
walk.audio_source_descriptions = _read_description_table(r)
|
||
walk.audio_zone_descriptions = _read_description_table(r)
|
||
# Remarks table.
|
||
r.u32() # _RemarksNextID
|
||
remark_count = r.u32()
|
||
for _ in range(remark_count):
|
||
rid = r.u32()
|
||
text = r.string16()
|
||
walk.remarks[rid] = text
|
||
return walk
|
||
except (EOFError, ValueError, struct.error):
|
||
return walk
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class TimeClock:
|
||
"""A panel time-clock schedule (``clsWhen``).
|
||
|
||
Five raw bytes from SetupData. ``hour``/``minute`` are 0..23/0..59.
|
||
``month``/``day`` are 0 when the entry repeats by day-of-week
|
||
rather than a fixed date. ``days`` is the raw ``enuDays`` bitmask
|
||
where bit 1=Mon, bit 2=Tue, bit 3=Wed, bit 4=Thu, bit 5=Fri,
|
||
bit 6=Sat, bit 7=Sun (bit 0 unused). 0xFE = every day; 0x00 = the
|
||
entry is unscheduled / disabled.
|
||
"""
|
||
|
||
hour: int
|
||
minute: int
|
||
month: int
|
||
day: int
|
||
days: int
|
||
|
||
@classmethod
|
||
def parse(cls, data: bytes) -> TimeClock:
|
||
if len(data) < 5:
|
||
return cls(0, 0, 0, 0, 0)
|
||
return cls(data[0], data[1], data[2], data[3], data[4])
|
||
|
||
|
||
@dataclass
|
||
class DcmConfig:
|
||
"""Digital Communicator Module (alarm-dialer) configuration.
|
||
|
||
The DCM reports alarm events to a central monitoring station. All
|
||
fields come from the SetupData installer section (clsHAC.cs:3185-3208).
|
||
|
||
* ``phone_number_1`` / ``phone_number_2`` — primary / backup dialer
|
||
numbers. PII (``repr=False``); ``"-"`` is the panel's "blank
|
||
number" sentinel.
|
||
* ``account_1`` / ``account_2`` — BE u16 account identifiers the
|
||
monitoring station uses. 0xAAAA is the uninitialised default.
|
||
* ``dcm_type`` — raw ``enuDCMType`` byte (the reporting protocol —
|
||
Contact ID, 2300-baud, etc.).
|
||
* ``test_time`` — scheduled supervisory-test call (a ``TimeClock``;
|
||
all-zero means no test schedule).
|
||
* ``test_code`` — event code sent on the supervisory test call.
|
||
* ``zone_alarm_codes`` — ``{1-based zone: code byte}`` reported when
|
||
that zone alarms.
|
||
* ``emergency_codes`` — 8 event codes in the fixed order
|
||
Freeze, FireEmg, PoliceEmg, AuxEmg, Duress, BatteryLow,
|
||
FireZoneTrouble, Cancel.
|
||
"""
|
||
|
||
phone_number_1: str = field(default="", repr=False)
|
||
account_1: int = 0
|
||
phone_number_2: str = field(default="", repr=False)
|
||
account_2: int = 0
|
||
dcm_type: int = 0
|
||
test_time: TimeClock = field(default_factory=lambda: TimeClock(0, 0, 0, 0, 0))
|
||
test_code: int = 0
|
||
zone_alarm_codes: dict[int, int] = field(default_factory=dict)
|
||
emergency_codes: tuple[int, ...] = ()
|
||
|
||
|
||
@dataclass
|
||
class _ConnectionWalk:
|
||
"""Side-channel output of :func:`_walk_to_connection`.
|
||
|
||
Captures the per-object name tables + selected SetupData fields on
|
||
the way past so the caller can attach them to :class:`PcaAccount`.
|
||
Each ``*_names`` dict is ``{1-based slot: name}`` with only non-empty
|
||
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
|
||
zone_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)
|
||
code_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)
|
||
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)
|
||
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)
|
||
area_quick_arm: dict[int, bool] = field(default_factory=dict)
|
||
area_auto_bypass: dict[int, bool] = field(default_factory=dict)
|
||
area_all_on_for_alarm: dict[int, bool] = field(default_factory=dict)
|
||
area_trouble_beep: dict[int, bool] = field(default_factory=dict)
|
||
area_perimeter_chime: dict[int, bool] = field(default_factory=dict)
|
||
area_audible_exit_delay: dict[int, bool] = field(default_factory=dict)
|
||
unit_types: dict[int, int] = field(default_factory=dict)
|
||
unit_areas: dict[int, int] = field(default_factory=dict)
|
||
code_pins: dict[int, int] = field(default_factory=dict, repr=False)
|
||
code_authority: dict[int, int] = field(default_factory=dict)
|
||
code_areas: dict[int, int] = field(default_factory=dict)
|
||
house_code_formats: dict[int, int] = field(default_factory=dict)
|
||
time_clocks: tuple[TimeClock, ...] = ()
|
||
installer_code: int = 0
|
||
enable_pc_access: bool = False
|
||
pc_access_code: int = 0
|
||
latitude: int = 0
|
||
longitude: int = 0
|
||
time_zone: int = 0
|
||
dst_start_month: int = 0
|
||
dst_start_week: int = 0
|
||
dst_end_month: int = 0
|
||
dst_end_week: int = 0
|
||
temp_format: int = 0
|
||
num_areas_used: int = 0
|
||
# Telephony / dialer scalars + strings.
|
||
telephone_access: bool = False
|
||
answer_outside_call: bool = False
|
||
remote_commands_ok: bool = False
|
||
rings_before_answer: int = 0
|
||
dial_mode: int = 0
|
||
my_phone_number: str = ""
|
||
callback_number: str = ""
|
||
# Misc panel scalars.
|
||
high_security: bool = False
|
||
freeze_alarm: bool = False
|
||
flash_light_num: int = 0
|
||
announce_alarms: bool = False
|
||
house_code: int = 0
|
||
zone_expansions: int = 0
|
||
num_exp_enc: int = 0
|
||
num_thermostats: int = 0
|
||
exterior_horn_delay: int = 0
|
||
dialout_delay: int = 0
|
||
verify_fire_alarms: bool = False
|
||
enable_console_emg: bool = False
|
||
time_format: int = 0
|
||
date_format: int = 0
|
||
ac_power_freq: int = 0
|
||
dead_line_detect: int = 0
|
||
off_hook_detect: int = 0
|
||
dcm: DcmConfig = field(default_factory=DcmConfig)
|
||
|
||
|
||
def _read_name_table(r: PcaReader, count: int, name_len: int) -> dict[int, str]:
|
||
"""Read ``count`` String8(name_len) slots; return only non-empty ones.
|
||
|
||
Per-slot layout per ``clsAbstractNamedItem.ReadName`` /
|
||
``clsPcaCryptFileStream.ReadString8(out S, byte L)``:
|
||
|
||
``[1 byte actual length][name_len bytes name]``
|
||
|
||
The length byte is 0 for unused slots. We use ``string8_fixed`` to
|
||
consume exactly ``1 + name_len`` bytes per slot regardless.
|
||
"""
|
||
out: dict[int, str] = {}
|
||
for i in range(1, count + 1):
|
||
name = r.string8_fixed(name_len)
|
||
if name:
|
||
out[i] = name
|
||
return out
|
||
|
||
|
||
def _walk_to_connection(r: PcaReader, cap: dict[str, int]) -> _ConnectionWalk:
|
||
"""Walk SetupData, flags, Names, Voices, Programs, EventLog so the
|
||
next read lands on the Connection block. Captures per-object name
|
||
tables on the way past and returns them alongside the Programs blob.
|
||
|
||
Mirrors clsHAC.cs:7995-8044. The per-object names are read via
|
||
clsAbstractNamedItem.ReadName → String8(L) — see
|
||
: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).
|
||
"""
|
||
setup_data = r.bytes_(cap["lenSetupData"])
|
||
r.bytes_(10) # bool + bool + u16 + u16 + u32
|
||
|
||
# Pull ZoneType and Zones[].Area 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]
|
||
|
||
za_off = cap.get("zoneAreaOffset")
|
||
zone_areas: dict[int, int] = {}
|
||
if za_off is not None:
|
||
za_end = za_off + cap["max_zones"]
|
||
if za_end <= len(setup_data):
|
||
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]:
|
||
off = cap.get(offset_key)
|
||
if off is None or off + num_areas > len(setup_data):
|
||
return {}
|
||
return {i: setup_data[off + i - 1] for i in range(1, num_areas + 1)}
|
||
|
||
def _read_area_bool_array(offset_key: str) -> dict[int, bool]:
|
||
return {i: bool(b) for i, b in _read_area_byte_array(offset_key).items()}
|
||
|
||
area_entry_delays = _read_area_byte_array("entryDelayOffset")
|
||
area_exit_delays = _read_area_byte_array("exitDelayOffset")
|
||
area_entry_chime = _read_area_bool_array("entryChimeOffset")
|
||
area_quick_arm = _read_area_bool_array("quickArmOffset")
|
||
area_auto_bypass = _read_area_bool_array("autoBypassOffset")
|
||
area_all_on_for_alarm = _read_area_bool_array("allOnForAlarmOffset")
|
||
area_trouble_beep = _read_area_bool_array("troubleBeepOffset")
|
||
area_perimeter_chime = _read_area_bool_array("perimeterChimeOffset")
|
||
area_audible_exit_delay = _read_area_bool_array("audibleExitDelayOffset")
|
||
|
||
def _read_scalar_byte(offset_key: str) -> int:
|
||
off = cap.get(offset_key)
|
||
if off is None or off >= len(setup_data):
|
||
return 0
|
||
return setup_data[off]
|
||
|
||
dst_start_month = _read_scalar_byte("dstStartMonthOffset")
|
||
dst_start_week = _read_scalar_byte("dstStartWeekOffset")
|
||
dst_end_month = _read_scalar_byte("dstEndMonthOffset")
|
||
dst_end_week = _read_scalar_byte("dstEndWeekOffset")
|
||
|
||
# HouseCodes.EnableExtCode[1..N] — one byte per X10 house code group.
|
||
# Count derives from CAP as (lastX10 - firstX10 + 1) / 16 = 16 on
|
||
# OMNI_PRO_II (clsHouseCodes.cs:35).
|
||
hcf_off = cap.get("houseCodeFormatOffset")
|
||
f_x10_for_hc = cap.get("firstX10", 0)
|
||
l_x10_for_hc = cap.get("lastX10", 0)
|
||
n_hcf = (l_x10_for_hc - f_x10_for_hc + 1) // 16 if l_x10_for_hc else 0
|
||
house_code_formats: dict[int, int] = {}
|
||
if hcf_off is not None and hcf_off + n_hcf <= len(setup_data):
|
||
for k in range(1, n_hcf + 1):
|
||
house_code_formats[k] = setup_data[hcf_off + k - 1]
|
||
|
||
# Six 5-byte clsWhen structs for TimeClock 1/2/3 On/Off.
|
||
tc_off = cap.get("timeClocksOffset")
|
||
time_clocks: tuple[TimeClock, ...] = ()
|
||
if tc_off is not None and tc_off + 30 <= len(setup_data):
|
||
time_clocks = tuple(
|
||
TimeClock.parse(setup_data[tc_off + i * 5 : tc_off + (i + 1) * 5])
|
||
for i in range(6)
|
||
)
|
||
|
||
# InstallerCode / PCAccessCode (BE u16) flanking the EnablePCAccess
|
||
# toggle. All three live in the installer section.
|
||
def _read_be_u16(offset_key: str) -> int:
|
||
off = cap.get(offset_key)
|
||
if off is None or off + 2 > len(setup_data):
|
||
return 0
|
||
return (setup_data[off] << 8) | setup_data[off + 1]
|
||
|
||
installer_code = _read_be_u16("installerCodeOffset")
|
||
pc_access_code = _read_be_u16("pcAccessCodeOffset")
|
||
latitude = _read_scalar_byte("latitudeOffset")
|
||
longitude = _read_scalar_byte("longitudeOffset")
|
||
time_zone = _read_scalar_byte("timeZoneOffset")
|
||
epa_off = cap.get("enablePCAccessOffset")
|
||
enable_pc_access = (
|
||
bool(setup_data[epa_off])
|
||
if epa_off is not None and epa_off < len(setup_data)
|
||
else False
|
||
)
|
||
|
||
def _read_bool(offset_key: str) -> bool:
|
||
return bool(_read_scalar_byte(offset_key))
|
||
|
||
def _read_hw_string(offset_key: str, max_length: int = 24) -> str:
|
||
"""Read a clsHardwareArray.ReadString field — a fixed-width
|
||
``max_length + 1`` byte slot whose content runs until the first
|
||
0xFF (clsHardwareArray.cs:316-325). No length prefix."""
|
||
off = cap.get(offset_key)
|
||
if off is None:
|
||
return ""
|
||
chars: list[str] = []
|
||
for i in range(max_length + 1):
|
||
pos = off + i
|
||
if pos >= len(setup_data) or setup_data[pos] == 0xFF:
|
||
break
|
||
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")
|
||
remote_commands_ok = _read_bool("remoteCommandsOkOffset")
|
||
rings_before_answer = _read_scalar_byte("ringsBeforeAnswerOffset")
|
||
dial_mode = _read_scalar_byte("dialModeOffset")
|
||
my_phone_number = _read_hw_string("myPhoneNumberOffset")
|
||
callback_number = _read_hw_string("callBackNumberOffset")
|
||
|
||
# Misc panel scalars (user + installer sections).
|
||
high_security = _read_bool("highSecurityOffset")
|
||
freeze_alarm = _read_bool("freezeAlarmOffset")
|
||
flash_light_num = _read_be_u16("flashLightNumOffset")
|
||
announce_alarms = _read_bool("announceAlarmsOffset")
|
||
house_code = _read_scalar_byte("houseCodeOffset")
|
||
zone_expansions = _read_scalar_byte("zoneExpansionsOffset")
|
||
num_exp_enc = _read_scalar_byte("numExpEncOffset")
|
||
num_thermostats = _read_scalar_byte("numThermostatsOffset")
|
||
exterior_horn_delay = _read_scalar_byte("exteriorHornDelayOffset")
|
||
dialout_delay = _read_scalar_byte("dialoutDelayOffset")
|
||
verify_fire_alarms = _read_bool("verifyFireAlarmsOffset")
|
||
enable_console_emg = _read_bool("enableConsoleEmgOffset")
|
||
time_format = _read_scalar_byte("timeFormatOffset")
|
||
date_format = _read_scalar_byte("dateFormatOffset")
|
||
ac_power_freq = _read_scalar_byte("acPowerFreqOffset")
|
||
dead_line_detect = _read_scalar_byte("deadLineDetectOffset")
|
||
off_hook_detect = _read_scalar_byte("offHookDetectOffset")
|
||
|
||
# DCM (Digital Communicator Module) dialer block.
|
||
dcm_test_time_off = cap.get("dcmTestTimeOffset")
|
||
dcm_test_time = (
|
||
TimeClock.parse(setup_data[dcm_test_time_off : dcm_test_time_off + 5])
|
||
if dcm_test_time_off is not None
|
||
and dcm_test_time_off + 5 <= len(setup_data)
|
||
else TimeClock(0, 0, 0, 0, 0)
|
||
)
|
||
dcm_zac_off = cap.get("dcmZoneAlarmCodesOffset")
|
||
dcm_zone_alarm_codes: dict[int, int] = {}
|
||
n_zones_dcm = cap.get("max_zones", 0)
|
||
if dcm_zac_off is not None and dcm_zac_off + n_zones_dcm <= len(setup_data):
|
||
for z in range(1, n_zones_dcm + 1):
|
||
dcm_zone_alarm_codes[z] = setup_data[dcm_zac_off + z - 1]
|
||
dcm_emerg_off = cap.get("dcmEmergencyCodesOffset")
|
||
dcm_emergency_codes: tuple[int, ...] = ()
|
||
if dcm_emerg_off is not None and dcm_emerg_off + 8 <= len(setup_data):
|
||
dcm_emergency_codes = tuple(setup_data[dcm_emerg_off : dcm_emerg_off + 8])
|
||
dcm = DcmConfig(
|
||
phone_number_1=_read_hw_string("dcmPhone1Offset"),
|
||
account_1=_read_be_u16("dcmAccount1Offset"),
|
||
phone_number_2=_read_hw_string("dcmPhone2Offset"),
|
||
account_2=_read_be_u16("dcmAccount2Offset"),
|
||
dcm_type=_read_scalar_byte("dcmTypeOffset"),
|
||
test_time=dcm_test_time,
|
||
test_code=_read_scalar_byte("dcmTestCodeOffset"),
|
||
zone_alarm_codes=dcm_zone_alarm_codes,
|
||
emergency_codes=dcm_emergency_codes,
|
||
)
|
||
|
||
# Unit type + area assignment, per unit index.
|
||
#
|
||
# Unit *type* is derived from which CAP range the index falls in
|
||
# (clsUnit.CalculateUnitType + the AreaGroups read in
|
||
# clsHAC._ParseSetupData at clsHAC.cs:3242-3289). We collapse the
|
||
# X10 sub-types (Standard/Extended/HLC/UPB/ZWave/…) to
|
||
# enuOL2UnitType.Standard=1 since deriving them requires the
|
||
# HouseCodes EnableExtCode table; non-X10 families resolve to
|
||
# Output=13 (Voltage/ExpEnc) or Flag=12.
|
||
#
|
||
# Unit *area* is the bitmask byte from the AreaGroups array of the
|
||
# appropriate family, indexed by the unit's group:
|
||
# X10: group = (Number - firstX10) // 16
|
||
# VoltOut: group = (Number - firstVoltOut) (1 byte/unit)
|
||
# FlagOut: group = (Number - firstFlagOut) // 8
|
||
# ExpEnc: group = (Number - firstExpEncOut) // 4
|
||
# Byte 0xFF (panel default, uninitialised) is reported verbatim —
|
||
# consumers treat that as "all areas".
|
||
def _read_area_group(group_off_key: str, group_idx: int) -> int:
|
||
off = cap.get(group_off_key)
|
||
if off is None:
|
||
return 0xFF
|
||
pos = off + group_idx
|
||
if pos >= len(setup_data):
|
||
return 0xFF
|
||
return setup_data[pos]
|
||
|
||
# Codes block — extract PINs (raw BE u16), authority byte, areas
|
||
# bitmask. PINs are PII; we expose the raw value but don't print it
|
||
# in any repr (PcaAccount uses repr=False on these fields).
|
||
codes_off = cap.get("codesOffset")
|
||
code_bytes = cap.get("codeEntryBytes", 14)
|
||
num_codes = cap.get("max_codes", 0)
|
||
code_pins: dict[int, int] = {}
|
||
code_authority: dict[int, int] = {}
|
||
code_areas: dict[int, int] = {}
|
||
if codes_off is not None:
|
||
for k in range(1, num_codes + 1):
|
||
base = codes_off + (k - 1) * code_bytes
|
||
if base + 4 > len(setup_data):
|
||
break
|
||
# BE u16 (clsHardwareArray.ReadUInt16)
|
||
code_pins[k] = (setup_data[base] << 8) | setup_data[base + 1]
|
||
code_authority[k] = setup_data[base + 2]
|
||
code_areas[k] = setup_data[base + 3]
|
||
|
||
# Direct enuHouseCodeFormat → enuOL2UnitType mapping for the
|
||
# non-conditional formats (clsUnit.CalculateUnitType,
|
||
# clsUnit.cs:928-999). HLC and ZWave have a Number-position-based
|
||
# split (Room vs Load); see the inline branches below.
|
||
_HCFMT_TO_UTYPE: dict[int, int] = {
|
||
0: 1, # Standard → Standard
|
||
1: 2, # Extended → Extended
|
||
2: 3, # Compose → Compose
|
||
3: 4, # UPB → UPB
|
||
4: 8, # RadioRA → RadioRA
|
||
6: 9, # CentraLite → Centralite
|
||
8: 16, # LutronHomeWorks
|
||
9: 17, # Clipsal_C_Bus
|
||
10: 18, # Dynalite
|
||
11: 19, # RadioRA2
|
||
12: 20, # Somfy_SDN
|
||
13: 21, # ZigBee
|
||
14: 22, # KNX
|
||
15: 23, # LumaNet
|
||
16: 24, # Somfy_URTSI
|
||
}
|
||
|
||
unit_types: dict[int, int] = {}
|
||
unit_areas: dict[int, int] = {}
|
||
f_x10, l_x10 = cap.get("firstX10", 0), cap.get("lastX10", 0)
|
||
f_vo, l_vo = cap.get("firstVoltOut", 0), cap.get("lastVoltOut", 0)
|
||
f_fo, l_fo = cap.get("firstFlagOut", 0), cap.get("lastFlagOut", 0)
|
||
f_ee, l_ee = cap.get("firstExpEncOut", 0), cap.get("lastExpEncOut", 0)
|
||
max_units = cap.get("max_units", 0)
|
||
for u in range(1, max_units + 1):
|
||
if f_x10 and f_x10 <= u <= l_x10:
|
||
# Resolve specific X10 sub-type via the HouseCode containing
|
||
# this unit. House code N covers units (N-1)*16+1..N*16, so
|
||
# ((u - firstX10) // 16) + 1 is the 1-based HouseCode index.
|
||
hc_idx = (u - f_x10) // 16 + 1
|
||
hcfmt = house_code_formats.get(hc_idx, 0)
|
||
if hcfmt == 5: # HLC
|
||
# HLCRoom (5) if Number-1 is a multiple of 8, else HLCLoad (6).
|
||
unit_types[u] = 5 if (u - 1) % 8 == 0 else 6
|
||
elif hcfmt == 7: # ZWave
|
||
# ViziaRoomController (10) for "room" position, else ViziaLoad (11).
|
||
# Real-panel ViziaRoomController also requires ZWaveNodeID
|
||
# context the .pca doesn't carry; we approximate with the
|
||
# Number-position rule alone.
|
||
unit_types[u] = 10 if (u - 1) % 8 == 0 else 11
|
||
else:
|
||
unit_types[u] = _HCFMT_TO_UTYPE.get(hcfmt, 1)
|
||
unit_areas[u] = _read_area_group(
|
||
"x10AreaGroupsOffset", (u - f_x10) // 16
|
||
)
|
||
elif f_ee and f_ee <= u <= l_ee:
|
||
unit_types[u] = 13 # enuOL2UnitType.Output
|
||
unit_areas[u] = _read_area_group(
|
||
"expEncAreaGroupsOffset", (u - f_ee) // 4
|
||
)
|
||
elif f_vo and f_vo <= u <= l_vo:
|
||
unit_types[u] = 13 # enuOL2UnitType.Output
|
||
unit_areas[u] = _read_area_group(
|
||
"voltOutAreaGroupsOffset", u - f_vo
|
||
)
|
||
elif f_fo and f_fo <= u <= l_fo:
|
||
unit_types[u] = 12 # enuOL2UnitType.Flag
|
||
unit_areas[u] = _read_area_group(
|
||
"flagOutAreaGroupsOffset", (u - f_fo) // 8
|
||
)
|
||
|
||
# Scalars from the installer section.
|
||
tf_off = cap.get("tempFormatOffset")
|
||
temp_format = setup_data[tf_off] if tf_off is not None and tf_off < len(setup_data) else 0
|
||
na_off = cap.get("numAreasUsedOffset")
|
||
num_areas_used = setup_data[na_off] if na_off is not None and na_off < len(setup_data) else 0
|
||
|
||
# Object family order per clsHAC body layout:
|
||
# Zones → Units → Buttons → Codes → Thermostats → Areas → Messages.
|
||
zone_names = _read_name_table(r, cap["max_zones"], cap["lenZoneName"])
|
||
unit_names = _read_name_table(r, cap["max_units"], cap["lenUnitName"])
|
||
button_names = _read_name_table(r, cap["max_buttons"], cap["lenButtonName"])
|
||
code_names = _read_name_table(r, cap["max_codes"], cap["lenCodeName"])
|
||
thermostat_names = _read_name_table(r, cap["max_tstats"], cap["lenTstatName"])
|
||
area_names = _read_name_table(r, cap["max_areas"], cap["lenAreaName"])
|
||
message_names = _read_name_table(r, cap["max_messages"], cap["lenMessageName"])
|
||
|
||
# Voices: structured slots are 12 B (LargeVocabulary), skip slots 6 B.
|
||
voice_specs = [
|
||
(cap["max_zones"], cap["zones_count"]),
|
||
(cap["max_units"], cap["units_count"]),
|
||
(cap["max_buttons"], cap["buttons_count"]),
|
||
(cap["max_codes"], cap["codes_count"]),
|
||
(cap["max_tstats"], cap["tstats_count"]),
|
||
(cap["max_areas"], cap["areas_count"]),
|
||
(cap["max_message_voices"], cap["messages_count"]),
|
||
]
|
||
s_b = cap["voice_struct_bytes"]
|
||
k_b = cap["voice_skip_bytes"]
|
||
for max_slots, items_count in voice_specs:
|
||
struct_slots = min(items_count, max_slots)
|
||
skip_slots = max(0, max_slots - items_count)
|
||
r.bytes_(struct_slots * s_b + skip_slots * k_b)
|
||
|
||
programs_blob = r.bytes_(cap["max_programs"] * cap["program_bytes"])
|
||
r.bytes_(cap["max_event_log"] * cap["event_bytes"])
|
||
return _ConnectionWalk(
|
||
programs_blob=programs_blob,
|
||
zone_names=zone_names,
|
||
unit_names=unit_names,
|
||
button_names=button_names,
|
||
code_names=code_names,
|
||
thermostat_names=thermostat_names,
|
||
area_names=area_names,
|
||
message_names=message_names,
|
||
zone_types=zone_types,
|
||
zone_areas=zone_areas,
|
||
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,
|
||
area_quick_arm=area_quick_arm,
|
||
area_auto_bypass=area_auto_bypass,
|
||
area_all_on_for_alarm=area_all_on_for_alarm,
|
||
area_trouble_beep=area_trouble_beep,
|
||
area_perimeter_chime=area_perimeter_chime,
|
||
area_audible_exit_delay=area_audible_exit_delay,
|
||
unit_types=unit_types,
|
||
unit_areas=unit_areas,
|
||
code_pins=code_pins,
|
||
code_authority=code_authority,
|
||
code_areas=code_areas,
|
||
house_code_formats=house_code_formats,
|
||
time_clocks=time_clocks,
|
||
installer_code=installer_code,
|
||
enable_pc_access=enable_pc_access,
|
||
pc_access_code=pc_access_code,
|
||
latitude=latitude,
|
||
longitude=longitude,
|
||
time_zone=time_zone,
|
||
dst_start_month=dst_start_month,
|
||
dst_start_week=dst_start_week,
|
||
dst_end_month=dst_end_month,
|
||
dst_end_week=dst_end_week,
|
||
temp_format=temp_format,
|
||
num_areas_used=num_areas_used,
|
||
telephone_access=telephone_access,
|
||
answer_outside_call=answer_outside_call,
|
||
remote_commands_ok=remote_commands_ok,
|
||
rings_before_answer=rings_before_answer,
|
||
dial_mode=dial_mode,
|
||
my_phone_number=my_phone_number,
|
||
callback_number=callback_number,
|
||
high_security=high_security,
|
||
freeze_alarm=freeze_alarm,
|
||
flash_light_num=flash_light_num,
|
||
announce_alarms=announce_alarms,
|
||
house_code=house_code,
|
||
zone_expansions=zone_expansions,
|
||
num_exp_enc=num_exp_enc,
|
||
num_thermostats=num_thermostats,
|
||
exterior_horn_delay=exterior_horn_delay,
|
||
dialout_delay=dialout_delay,
|
||
verify_fire_alarms=verify_fire_alarms,
|
||
enable_console_emg=enable_console_emg,
|
||
time_format=time_format,
|
||
date_format=date_format,
|
||
ac_power_freq=ac_power_freq,
|
||
dead_line_detect=dead_line_detect,
|
||
off_hook_detect=off_hook_detect,
|
||
dcm=dcm,
|
||
)
|
||
|
||
|
||
def parse_pca_file(path_or_bytes: str | os.PathLike[str] | bytes, key: int) -> PcaAccount:
|
||
"""Decrypt and parse a ``.pca`` file. Returns the header + Connection block.
|
||
|
||
``key`` is the 32-bit XOR key — typically ``KEY_EXPORT`` for export files
|
||
or the ``pca_key`` field from a paired ``PCA01.CFG``.
|
||
"""
|
||
raw = (
|
||
path_or_bytes
|
||
if isinstance(path_or_bytes, bytes)
|
||
else Path(path_or_bytes).read_bytes()
|
||
)
|
||
plain = decrypt_pca_bytes(raw, key)
|
||
if len(plain) < HEADER_LEN:
|
||
raise ValueError(f"plaintext too small: {len(plain)} < header {HEADER_LEN}")
|
||
|
||
r = PcaReader(plain)
|
||
(
|
||
version_tag,
|
||
file_version,
|
||
model,
|
||
fw_major,
|
||
fw_minor,
|
||
fw_rev,
|
||
name,
|
||
address,
|
||
phone,
|
||
code,
|
||
remarks,
|
||
) = _parse_header(r)
|
||
|
||
account = PcaAccount(
|
||
version_tag=version_tag,
|
||
file_version=file_version,
|
||
model=model,
|
||
firmware_major=fw_major,
|
||
firmware_minor=fw_minor,
|
||
firmware_revision=fw_rev,
|
||
account_name=name,
|
||
account_address=address,
|
||
account_phone=phone,
|
||
account_code=code,
|
||
account_remarks=remarks,
|
||
)
|
||
|
||
if file_version < 2:
|
||
return account
|
||
|
||
try:
|
||
walk = _walk_to_connection(r, _CAP_OMNI_PRO_II)
|
||
network_address = r.string8_fixed(120)
|
||
port_str = r.string8_fixed(5)
|
||
try:
|
||
network_port = int(port_str)
|
||
except ValueError:
|
||
network_port = 4369
|
||
key_hex = r.string8_fixed(32).ljust(32, "0")[:32]
|
||
controller_key = bytes.fromhex(key_hex)
|
||
# Decode the program table — non-fatal if it fails; Connection
|
||
# block has already been read so the network/key fields land
|
||
# regardless. A malformed Programs block likely means the body
|
||
# walker misaligned for a non-OMNI_PRO_II model, in which case
|
||
# leaving programs=() is the honest answer.
|
||
try:
|
||
programs: tuple[Program, ...] = decode_program_table(walk.programs_blob)
|
||
except Exception:
|
||
_log.warning("failed to decode Programs block", exc_info=True)
|
||
programs = ()
|
||
except (EOFError, ValueError):
|
||
# Body layout depends on panel model; if the OMNI_PRO_II walker
|
||
# misaligns for another model, leave the connection fields unset
|
||
# rather than raising. The header is still useful on its own.
|
||
return account
|
||
|
||
account.network_address = network_address
|
||
account.network_port = network_port
|
||
account.controller_key = controller_key
|
||
account.programs = programs
|
||
account.zone_names = walk.zone_names
|
||
account.unit_names = walk.unit_names
|
||
account.button_names = walk.button_names
|
||
account.code_names = walk.code_names
|
||
account.thermostat_names = walk.thermostat_names
|
||
account.area_names = walk.area_names
|
||
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.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
|
||
account.area_quick_arm = walk.area_quick_arm
|
||
account.area_auto_bypass = walk.area_auto_bypass
|
||
account.area_all_on_for_alarm = walk.area_all_on_for_alarm
|
||
account.area_trouble_beep = walk.area_trouble_beep
|
||
account.area_perimeter_chime = walk.area_perimeter_chime
|
||
account.area_audible_exit_delay = walk.area_audible_exit_delay
|
||
account.unit_types = walk.unit_types
|
||
account.unit_areas = walk.unit_areas
|
||
account.code_pins = walk.code_pins
|
||
account.code_authority = walk.code_authority
|
||
account.code_areas = walk.code_areas
|
||
account.house_code_formats = walk.house_code_formats
|
||
account.time_clocks = walk.time_clocks
|
||
account.installer_code = walk.installer_code
|
||
account.enable_pc_access = walk.enable_pc_access
|
||
account.pc_access_code = walk.pc_access_code
|
||
account.latitude = walk.latitude
|
||
account.longitude = walk.longitude
|
||
account.time_zone = walk.time_zone
|
||
account.dst_start_month = walk.dst_start_month
|
||
account.dst_start_week = walk.dst_start_week
|
||
account.dst_end_month = walk.dst_end_month
|
||
account.dst_end_week = walk.dst_end_week
|
||
account.temp_format = walk.temp_format
|
||
account.num_areas_used = walk.num_areas_used
|
||
account.telephone_access = walk.telephone_access
|
||
account.answer_outside_call = walk.answer_outside_call
|
||
account.remote_commands_ok = walk.remote_commands_ok
|
||
account.rings_before_answer = walk.rings_before_answer
|
||
account.dial_mode = walk.dial_mode
|
||
account.my_phone_number = walk.my_phone_number
|
||
account.callback_number = walk.callback_number
|
||
account.high_security = walk.high_security
|
||
account.freeze_alarm = walk.freeze_alarm
|
||
account.flash_light_num = walk.flash_light_num
|
||
account.announce_alarms = walk.announce_alarms
|
||
account.house_code = walk.house_code
|
||
account.zone_expansions = walk.zone_expansions
|
||
account.num_exp_enc = walk.num_exp_enc
|
||
account.num_thermostats = walk.num_thermostats
|
||
account.exterior_horn_delay = walk.exterior_horn_delay
|
||
account.dialout_delay = walk.dialout_delay
|
||
account.verify_fire_alarms = walk.verify_fire_alarms
|
||
account.enable_console_emg = walk.enable_console_emg
|
||
account.time_format = walk.time_format
|
||
account.date_format = walk.date_format
|
||
account.ac_power_freq = walk.ac_power_freq
|
||
account.dead_line_detect = walk.dead_line_detect
|
||
account.off_hook_detect = walk.off_hook_detect
|
||
account.dcm = walk.dcm
|
||
|
||
# PCA03+ continues past Connection with ModemBaud flags +
|
||
# AccountRemarks_Extended + nine Description blocks + the Remarks
|
||
# table. We walk it on a best-effort basis — a failure here leaves
|
||
# the post-Connection fields empty without affecting the Connection
|
||
# fields above.
|
||
if file_version >= 3:
|
||
rwalk = _walk_to_remarks(r)
|
||
account.account_remarks_extended = rwalk.account_remarks_extended
|
||
account.zone_descriptions = rwalk.zone_descriptions
|
||
account.unit_descriptions = rwalk.unit_descriptions
|
||
account.button_descriptions = rwalk.button_descriptions
|
||
account.code_descriptions = rwalk.code_descriptions
|
||
account.thermostat_descriptions = rwalk.thermostat_descriptions
|
||
account.area_descriptions = rwalk.area_descriptions
|
||
account.message_descriptions = rwalk.message_descriptions
|
||
account.audio_source_descriptions = rwalk.audio_source_descriptions
|
||
account.audio_zone_descriptions = rwalk.audio_zone_descriptions
|
||
account.remarks = rwalk.remarks
|
||
|
||
return account
|