pca_file: PerimeterChime/AudibleExitDelay, DST, unit type+area, code PINs

Four more SetupData fields landed in one pass. The user-section walk
past the previously-mapped 5 contiguous area flags continues with 70
bytes of intervening config (HighSecurity/FreezeAlarm/FlashLightNum/
HouseCodes flags × 32 / 6 TimeClock When-structs / Latitude/Longitude/
TimeZone/AnnounceAlarms) to reach:

  1897..1904: PerimeterChime[1..8]    (bool[8])
  1905..1912: AudibleExitDelay[1..8]  (bool[8])
  1913..1916: DSTStartMonth/Week, DSTEndMonth/Week (4 scalar bytes)

Live fixture DST decodes as US-standard (March / 2nd Sunday →
November / 1st Sunday). Area-1 PerimeterChime is OFF (homeowner
disabled), the panel default for unused areas 2-8 is ON.

Unit type + area assignment, derived from CAP index ranges and the
AreaGroups bitmap arrays at installer offsets 3035..3105
(clsHAC.cs:3242-3289):

  X10 units    (1..256)   → enuOL2UnitType.Standard (1),
                            16 units per AreaGroups byte
  ExpEnc       (257..384) → Output (13), 4 units per byte
  VoltOut      (385..392) → Output (13), 1 byte per unit
  FlagOut      (393..511) → Flag (12), 8 flags per byte

The X10 sub-types (Standard/Extended/HLC/UPB/ZWave/…) collapse to
Standard since deriving them needs the HouseCodes EnableExtCode table
which we don't decode yet. Live fixture all-511-units classify
correctly: 256 X10 + 128 ExpEnc + 8 VoltOut + 119 FlagOut.

Unit areas are 8-bit membership bitmasks. The live fixture has 0xFF
everywhere ("panel default — all 8 areas"); from_pca normalises that
to 0x01 ("area 1 only") so the mock's Properties reply gives HA a
single sensible area instead of bit-set noise.

Code PINs (offset 383, 99 × 14-byte entries). Per-entry layout:
  bytes 0..1: PIN (BE u16; plain 4-digit 0..9999)
  byte 2:     Authority (enuCodeAuthority: 0=Disabled, 1=User,
              2=Manager, 3=Installer)
  byte 3:     Areas bitmask
  bytes 4..13: WhenOn + WhenOff (2 × clsWhen)

PINs are PII — ``PcaAccount.code_pins`` is marked ``repr=False`` so
a stray ``print(acct)`` can never leak them into logs. They aren't
auto-threaded to MockState.user_codes either; tests set their own
PINs explicitly. Live-fixture decode is sane: COMPUTER=4932/User,
HOMEOWNER=1234/User, Kevin=3411/User, Debra=0000/Manager, etc.

MockAreaState gains perimeter_chime + audible_exit_delay.
MockUnitState gains unit_type + areas (and the Properties reply
serves the configured values now).

Full suite: 499 passed, 1 skipped.
This commit is contained in:
Ryan Malloy 2026-05-13 08:40:27 -06:00
parent 994608a4f6
commit 7683557bbb
3 changed files with 256 additions and 4 deletions

View File

@ -132,6 +132,8 @@ class MockUnitState:
name: str = "" name: str = ""
state: int = 0 # 0=off, 1=on, 100..200=brightness percent (raw Omni) state: int = 0 # 0=off, 1=on, 100..200=brightness percent (raw Omni)
time_remaining: int = 0 time_remaining: int = 0
unit_type: int = 1 # enuOL2UnitType (1=Standard); see clsUnit.cs:928 family
areas: int = 0x01 # bitmask of area membership (bit 0 = area 1, ...)
@dataclass @dataclass
@ -155,6 +157,8 @@ class MockAreaState:
auto_bypass: bool = False auto_bypass: bool = False
all_on_for_alarm: bool = False all_on_for_alarm: bool = False
trouble_beep: bool = False trouble_beep: bool = False
perimeter_chime: bool = False
audible_exit_delay: bool = False
@dataclass @dataclass
@ -347,7 +351,20 @@ class MockState:
) )
for i, n in acct.zone_names.items() for i, n in acct.zone_names.items()
}, },
"units": {i: MockUnitState(name=n) for i, n in acct.unit_names.items()}, "units": {
i: MockUnitState(
name=n,
unit_type=acct.unit_types.get(i, 1),
# 0xFF (uninitialised → "all areas") and 0x01 are the
# two common values. If the panel doesn't have a
# specific restriction, fall back to "area 1 only"
# so HA's area-filtering produces sensible defaults.
areas=(acct.unit_areas.get(i, 0x01) or 0x01)
if acct.unit_areas.get(i, 0x01) != 0xFF
else 0x01,
)
for i, n in acct.unit_names.items()
},
"areas": { "areas": {
i: MockAreaState( i: MockAreaState(
name=acct.area_names.get(i, ""), name=acct.area_names.get(i, ""),
@ -359,6 +376,8 @@ class MockState:
auto_bypass=acct.area_auto_bypass.get(i, False), auto_bypass=acct.area_auto_bypass.get(i, False),
all_on_for_alarm=acct.area_all_on_for_alarm.get(i, False), all_on_for_alarm=acct.area_all_on_for_alarm.get(i, False),
trouble_beep=acct.area_trouble_beep.get(i, False), trouble_beep=acct.area_trouble_beep.get(i, False),
perimeter_chime=acct.area_perimeter_chime.get(i, False),
audible_exit_delay=acct.area_audible_exit_delay.get(i, False),
) )
for i in sorted(in_use_areas) for i in sorted(in_use_areas)
}, },
@ -990,11 +1009,11 @@ class MockPanel:
unit.state if unit else 0, unit.state if unit else 0,
(unit.time_remaining >> 8) & 0xFF if unit else 0, (unit.time_remaining >> 8) & 0xFF if unit else 0,
unit.time_remaining & 0xFF if unit else 0, unit.time_remaining & 0xFF if unit else 0,
1, # UnitType: Standard unit.unit_type if unit else 1,
] ]
) )
+ self.state.unit_name_bytes(index) + self.state.unit_name_bytes(index)
+ bytes([0, 1]) # reserved + UnitAreas (default area 1) + bytes([0, (unit.areas if unit else 0x01)])
) )
return encode_v2(OmniLink2MessageType.Properties, body) return encode_v2(OmniLink2MessageType.Properties, body)

View File

@ -261,12 +261,55 @@ class PcaAccount:
# #
# PerimeterChime and AudibleExitDelay are NOT in this contiguous # PerimeterChime and AudibleExitDelay are NOT in this contiguous
# block — they live deeper in the user section past FlashLightNum, # block — they live deeper in the user section past FlashLightNum,
# HouseCodes flags, and 6 TimeClock When-structs. # 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_entry_chime: dict[int, bool] = field(default_factory=dict)
area_quick_arm: 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_auto_bypass: dict[int, bool] = field(default_factory=dict)
area_all_on_for_alarm: 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_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)
# 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) # Panel-wide TempFormat (enuTempFormat: 1=Fahrenheit, 2=Celsius)
# and NumAreasUsed (count of armable security areas — 1 for a # and NumAreasUsed (count of armable security areas — 1 for a
@ -327,6 +370,16 @@ _CAP_OMNI_PRO_II: dict[str, int] = {
# 1771..1778: Areas[1..8].EntryDelay (8 bytes) # 1771..1778: Areas[1..8].EntryDelay (8 bytes)
# 1779..1786: Areas[1..8].ExitDelay (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, "entryDelayOffset": 1771,
"exitDelayOffset": 1779, "exitDelayOffset": 1779,
# Five contiguous bool[8] flag arrays immediately follow ExitDelay # Five contiguous bool[8] flag arrays immediately follow ExitDelay
@ -338,6 +391,25 @@ _CAP_OMNI_PRO_II: dict[str, int] = {
"autoBypassOffset": 1803, "autoBypassOffset": 1803,
"allOnForAlarmOffset": 1811, "allOnForAlarmOffset": 1811,
"troubleBeepOffset": 1819, "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).
"perimeterChimeOffset": 1897,
"audibleExitDelayOffset": 1905,
"dstStartMonthOffset": 1913,
"dstStartWeekOffset": 1914,
"dstEndMonthOffset": 1915,
"dstEndWeekOffset": 1916,
# Installer section begins at byte 2560 (clsCapOMNI_PRO_II.instSetupStart). # Installer section begins at byte 2560 (clsCapOMNI_PRO_II.instSetupStart).
# Layout for OMNI_PRO_II observed empirically against the live fixture # Layout for OMNI_PRO_II observed empirically against the live fixture
@ -377,7 +449,18 @@ _CAP_OMNI_PRO_II: dict[str, int] = {
"zoneTypeOffset": 2572, "zoneTypeOffset": 2572,
"tempFormatOffset": 2993, "tempFormatOffset": 2993,
"numAreasUsedOffset": 3034, "numAreasUsedOffset": 3034,
# 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, "zoneAreaOffset": 3106,
# 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_zones": 176, "lenZoneName": 15, "zones_count": 176,
"max_units": 512, "lenUnitName": 12, "units_count": 511, "max_units": 512, "lenUnitName": 12, "units_count": 511,
"max_buttons": 128, "lenButtonName": 12, "buttons_count": 255, "max_buttons": 128, "lenButtonName": 12, "buttons_count": 255,
@ -499,6 +582,17 @@ class _ConnectionWalk:
area_auto_bypass: 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_all_on_for_alarm: dict[int, bool] = field(default_factory=dict)
area_trouble_beep: 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)
dst_start_month: int = 0
dst_start_week: int = 0
dst_end_month: int = 0
dst_end_week: int = 0
temp_format: int = 0 temp_format: int = 0
num_areas_used: int = 0 num_areas_used: int = 0
@ -573,6 +667,94 @@ def _walk_to_connection(r: PcaReader, cap: dict[str, int]) -> _ConnectionWalk:
area_auto_bypass = _read_area_bool_array("autoBypassOffset") area_auto_bypass = _read_area_bool_array("autoBypassOffset")
area_all_on_for_alarm = _read_area_bool_array("allOnForAlarmOffset") area_all_on_for_alarm = _read_area_bool_array("allOnForAlarmOffset")
area_trouble_beep = _read_area_bool_array("troubleBeepOffset") 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")
# 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]
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:
unit_types[u] = 1 # enuOL2UnitType.Standard (collapsed X10 default)
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. # Scalars from the installer section.
tf_off = cap.get("tempFormatOffset") tf_off = cap.get("tempFormatOffset")
@ -627,6 +809,17 @@ def _walk_to_connection(r: PcaReader, cap: dict[str, int]) -> _ConnectionWalk:
area_auto_bypass=area_auto_bypass, area_auto_bypass=area_auto_bypass,
area_all_on_for_alarm=area_all_on_for_alarm, area_all_on_for_alarm=area_all_on_for_alarm,
area_trouble_beep=area_trouble_beep, 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,
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, temp_format=temp_format,
num_areas_used=num_areas_used, num_areas_used=num_areas_used,
) )
@ -725,6 +918,17 @@ def parse_pca_file(path_or_bytes: str | os.PathLike[str] | bytes, key: int) -> P
account.area_auto_bypass = walk.area_auto_bypass account.area_auto_bypass = walk.area_auto_bypass
account.area_all_on_for_alarm = walk.area_all_on_for_alarm account.area_all_on_for_alarm = walk.area_all_on_for_alarm
account.area_trouble_beep = walk.area_trouble_beep 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.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.temp_format = walk.temp_format
account.num_areas_used = walk.num_areas_used account.num_areas_used = walk.num_areas_used

View File

@ -368,14 +368,43 @@ async def test_mockstate_from_pca_serves_real_panel_programs() -> None:
# AutoBypass OFF # AutoBypass OFF
# AllOnForAlarm ON # AllOnForAlarm ON
# TroubleBeep OFF # TroubleBeep OFF
# PerimeterChime OFF (homeowner disabled)
# AudibleExitDelay ON
assert acct.area_entry_chime[1] is False assert acct.area_entry_chime[1] is False
assert acct.area_quick_arm[1] is True assert acct.area_quick_arm[1] is True
assert acct.area_auto_bypass[1] is False assert acct.area_auto_bypass[1] is False
assert acct.area_all_on_for_alarm[1] is True assert acct.area_all_on_for_alarm[1] is True
assert acct.area_trouble_beep[1] is False assert acct.area_trouble_beep[1] is False
assert acct.area_perimeter_chime[1] is False
assert acct.area_audible_exit_delay[1] is True
# And the values flowed through MockState. # And the values flowed through MockState.
assert state.areas[1].quick_arm is True assert state.areas[1].quick_arm is True
assert state.areas[1].entry_chime is False assert state.areas[1].entry_chime is False
assert state.areas[1].perimeter_chime is False
# DST configuration — US default (Mar/2nd Sun, Nov/1st Sun).
assert acct.dst_start_month == 3
assert acct.dst_start_week == 2
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
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
# Area was 0xff (panel default = "all") → normalized to 0x01 in mock.
assert state.units[1].areas == 0x01
# 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
assert acct.code_authority[5] == 3 # Cage → Installer
assert 0 <= acct.code_pins[1] <= 0xFFFF
assert "code_pins" not in repr(acct)
assert state.zones[1].name == "GARAGE ENTRY" assert state.zones[1].name == "GARAGE ENTRY"
assert state.units[1].name == "ROOM ONE" assert state.units[1].name == "ROOM ONE"
assert state.thermostats[1].name == "DOWNSTAIRS" assert state.thermostats[1].name == "DOWNSTAIRS"