pca_file: HouseCodeFormat, TimeClocks, Installer/PCAccess codes

Three more SetupData fields, plus a refinement that uses one of them:

* HouseCodes.EnableExtCode[1..16] at user-section offset 1917 —
  one enuHouseCodeFormat byte per 16-unit X10 group. Live fixture:
  HouseCode 1 = HLC (5), HouseCodes 2..16 = Extended (1).

* Six 5-byte clsWhen structs at 1863..1892 — TimeClock 1/2/3 On/Off
  schedules. Exposed as a new TimeClock dataclass tuple. Live fixture
  TC1 = On 22:30 → Off 06:00 daily (outdoor-lights pattern).

* InstallerCode (BE u16 @ 2995), EnablePCAccess (bool @ 2997),
  PCAccessCode (BE u16 @ 2998) — both codes are PII so repr=False.

Refinement: unit_types for X10 units now resolves the specific
sub-type via the HouseCodeFormat table instead of the previous
collapsed Standard. Mapping mirrors clsUnit.CalculateUnitType
(clsUnit.cs:928-999) including the (Number-1)%8==0 split for HLC
(HLCRoom vs HLCLoad) and ZWave (ViziaRoomController vs ViziaLoad).

Live fixture now reports the right thing: unit 1 (ROOM ONE) is
HLCRoom (5), units 2-8 (FRONT PORCH, PENDANT LTS, …) are HLCLoad (6),
unit 9 is HLCRoom again, etc. Units 17-256 under HouseCodes 2..16 are
all Extended (2). Total: 2 HLCRoom + 14 HLCLoad + 240 Extended +
136 Output + 119 Flag = 511 unit slots, matches numUnits exactly.

Full suite: 499 passed, 1 skipped.
This commit is contained in:
Ryan Malloy 2026-05-13 09:13:40 -06:00
parent 7683557bbb
commit b8745e17de
2 changed files with 185 additions and 6 deletions

View File

@ -301,6 +301,30 @@ class PcaAccount:
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)
# 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,
@ -410,6 +434,20 @@ _CAP_OMNI_PRO_II: dict[str, int] = {
"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
@ -553,6 +591,31 @@ def _walk_to_remarks(r: PcaReader) -> dict[int, str]:
return {}
@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 _ConnectionWalk:
"""Side-channel output of :func:`_walk_to_connection`.
@ -589,6 +652,11 @@ class _ConnectionWalk:
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
dst_start_month: int = 0
dst_start_week: int = 0
dst_end_month: int = 0
@ -681,6 +749,44 @@ def _walk_to_connection(r: PcaReader, cap: dict[str, int]) -> _ConnectionWalk:
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")
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
)
# Unit type + area assignment, per unit index.
#
# Unit *type* is derived from which CAP range the index falls in
@ -727,6 +833,28 @@ def _walk_to_connection(r: PcaReader, cap: dict[str, int]) -> _ConnectionWalk:
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)
@ -736,7 +864,22 @@ def _walk_to_connection(r: PcaReader, cap: dict[str, int]) -> _ConnectionWalk:
max_units = cap.get("max_units", 0)
for u in range(1, max_units + 1):
if f_x10 and f_x10 <= u <= l_x10:
unit_types[u] = 1 # enuOL2UnitType.Standard (collapsed X10 default)
# 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
)
@ -816,6 +959,11 @@ def _walk_to_connection(r: PcaReader, cap: dict[str, int]) -> _ConnectionWalk:
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,
dst_start_month=dst_start_month,
dst_start_week=dst_start_week,
dst_end_month=dst_end_month,
@ -925,6 +1073,11 @@ def parse_pca_file(path_or_bytes: str | os.PathLike[str] | bytes, key: int) -> P
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.dst_start_month = walk.dst_start_month
account.dst_start_week = walk.dst_start_week
account.dst_end_month = walk.dst_end_month

View File

@ -388,17 +388,43 @@ async def test_mockstate_from_pca_serves_real_panel_programs() -> None:
assert acct.dst_end_month == 11
assert acct.dst_end_week == 1
# Unit type derivation by index range:
assert acct.unit_types[1] == 1 # X10 → Standard
# Unit type derivation — X10 sub-types resolved via HouseCodeFormat.
# HouseCode 1 in this fixture is HLC (5), so units 1..16 split into
# HLCRoom (Number-1 ≡ 0 mod 8) and HLCLoad. HouseCodes 2..16 are
# Extended (1), so units 17..256 are enuOL2UnitType.Extended (2).
assert acct.unit_types[1] == 5 # ROOM ONE → HLCRoom
assert acct.unit_types[2] == 6 # FRONT PORCH → HLCLoad
assert acct.unit_types[9] == 5 # next room-slot → HLCRoom
assert acct.unit_types[17] == 2 # HouseCode 2 Extended → Extended
assert acct.unit_types[257] == 13 # ExpEnc → Output
assert acct.unit_types[385] == 13 # VoltOut → Output
assert acct.unit_types[393] == 12 # FlagOut → Flag
# Unit type/areas threaded into MockUnitState — every unit is X10
# type 1 (the named ones in this fixture are all X10).
assert state.units[1].unit_type == 1
# Unit type/areas threaded into MockUnitState — first 16 units are
# under HouseCode 1 (HLC).
assert state.units[1].unit_type == 5 # ROOM ONE → HLCRoom
# Area was 0xff (panel default = "all") → normalized to 0x01 in mock.
assert state.units[1].areas == 0x01
# HouseCodes.EnableExtCode raw bytes.
assert acct.house_code_formats[1] == 5 # HLC
assert all(v == 1 for v in (
acct.house_code_formats[i] for i in range(2, 17)
)) # all Extended
# TimeClock 1: outdoor-lights schedule On 22:30 → Off 06:00 daily.
tc1_on, tc1_off = acct.time_clocks[0], acct.time_clocks[1]
assert (tc1_on.hour, tc1_on.minute) == (22, 30)
assert tc1_on.days == 0xFE # every day (bits 1..7)
assert (tc1_off.hour, tc1_off.minute) == (6, 0)
# Installer / PCAccess codes (PII; both repr=False).
assert 0 < acct.installer_code <= 0xFFFF
assert 0 < acct.pc_access_code <= 0xFFFF
assert acct.enable_pc_access is True
r = repr(acct)
assert "installer_code" not in r
assert "pc_access_code" not in r
# Codes: PINs decode as BE u16. PII fields not in repr().
assert acct.code_authority[1] == 1 # COMPUTER → User
assert acct.code_authority[4] == 2 # Debra → Manager