12 Commits

Author SHA1 Message Date
7b789f8cfb pca_file: Latitude / Longitude / TimeZone from SetupData
Three single-byte scalars sandwiched between the TimeClocks block and
AnnounceAlarms (clsHAC.cs:3064-3066). Raw bytes — no N/S/E/W modifier
at this position (those live in the optional WorldWideLatitude feature
block past DST). TimeZone is hours west of UTC on OMNI_PRO_II.

Live fixture decodes as 44°N / 117°W / TZ 7 (Pacific Daylight) —
matches a real northern-US install on the panel time.

PcaAccount gains latitude / longitude / time_zone (int each).
Walker reads them via the existing _read_scalar_byte helper.

Full suite: 499 passed, 1 skipped.
2026-05-13 16:39:30 -06:00
b8745e17de 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.
2026-05-13 09:13:40 -06:00
7683557bbb 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.
2026-05-13 08:40:27 -06:00
994608a4f6 pca_file + v2 client: area flags + Area-N fallback
SetupData side (clsHAC.cs:3020-3038): five contiguous bool[8] arrays
immediately after ExitDelay carry per-area config flags. Offsets:

  1787..1794: EntryChime
  1795..1802: QuickArm
  1803..1810: AutoBypass
  1811..1818: AllOnForAlarm
  1819..1826: TroubleBeep

Verified against live fixture: area 1 shows real homeowner choices
(QuickArm + AllOnForAlarm enabled, others off), unused areas 2-8 carry
the panel defaults (EntryChime/AutoBypass/TroubleBeep on by default).

PerimeterChime and AudibleExitDelay aren't in this contiguous block —
they live past FlashLightNum, HouseCodes flags, and 6 TimeClock
When-structs. Deferred.

New PcaAccount fields:
  area_entry_chime, area_quick_arm, area_auto_bypass,
  area_all_on_for_alarm, area_trouble_beep — all dict[int, bool].

MockAreaState gains the same five fields. They aren't carried in the
Properties reply on the wire (the OL2 message format doesn't have
them), so they live on MockState for snapshots and any future
SetupData-aware code, but don't surface through HA discovery yet.

v2 client list_area_names fallback: when the Properties walk turns up
no named areas (common — most homes don't name them), synthesize
"Area 1".."Area 8" so HA's _discover_areas has slots to walk.
Mirrors the v1 adapter behaviour exactly.

Knock-on win in the live-fixture HA test: area 1 now reaches
coordinator.data.areas with its configured 60s/90s delays from
SetupData, end-to-end through .pca → MockState → wire Properties →
HA's AreaProperties parser.

Full suite: 499 passed, 1 skipped.
2026-05-13 08:19:38 -06:00
501686795b pca_file: extract entry/exit delays, TempFormat, NumAreasUsed
Three more SetupData fields, varying in difficulty:

* Entry/exit delays per area — in the user section, behind 280 bytes
  of Phone[8] config and 1386 bytes of Codes[99]. Derived offsets by
  counting fixed-width fields out from Seek(1): EntryDelay[1..8] at
  offset 1771, ExitDelay[1..8] at 1779. Verified against live fixture
  (area 1: entry=60s, exit=90s; unused areas: 15s/15s panel defaults).

* TempFormat at installer offset 2993 — single byte, enuTempFormat
  (1=F, 2=C). Live fixture = 1 (US install).

* NumAreasUsed at installer offset 3034 — count of installer-enabled
  security areas. Live fixture = 1 (single-area home).

PcaAccount now carries area_entry_delays, area_exit_delays, temp_format,
num_areas_used. MockAreaState gains entry_delay/exit_delay/enabled
fields; mock _build_area_properties serves the configured values
(was hardcoded 60/30/Enabled).

MockState.from_pca now synthesizes per-area MockAreaState entries
for the union of named areas + (1..num_areas_used), filling in delays
and enabled flag. This means a single-area install with no
user-assigned name still surfaces area 1 with the correct config —
matching what an installer would see in PC Access.

(HA's coordinator only enumerates named areas via list_area_names,
so the area properties don't yet reach the diagnostic surface for
unnamed-but-in-use areas. That's a separate filter to revisit; the
data flow through pca_file → MockState → wire Properties reply is
already correct.)

Full suite: 499 passed, 1 skipped.
2026-05-12 22:35:55 -06:00
8141599b4e pca_file: extract per-zone Area assignment from SetupData
Walks the OMNI_PRO_II installer section past ZoneType, DCM stuff,
thermostat config, and the X10/VoltOut/FlagOut/ExpEnc area-group
arrays to land on the 176-byte Zones[].Area block at offset 3106.

The path from instSetupStart (2560) to zone area:

  ZoneType[176] → DCM phones/accounts/type/test(5-byte clsWhen) →
  DCMAlarmCode[176] → 8 DCM bytes → TempFormat..NumAreasUsed (29 bytes
  of misc config including 25-byte CallBackNumber) → X10 area groups
  (16) → VoltOut (8) → FlagOut (15) → ExpEnc (32) → Zones[].Area (176).

Total preamble within installer section = 546 bytes. Verified against
the live fixture: 176 zones all assigned to area 1 (single-area
install), matches expectation.

PcaAccount.zone_areas now carries {slot: area_number}; MockState.from_pca
threads it through MockZoneState.area; mock _build_zone_properties already
serves it. End-to-end test verifies the area flows through to
coordinator.data.zones[*].area.

This was the largest single-RE jump in SetupData decoding so far — got
us past the variable-length DCM block by counting fixed-width fields
out from the known ZoneType end. The clsWhen=5-byte struct was the
last unknown; derived from clsHardwareArray.ReadWhen (clsHardwareArray
.cs:456-468).

Full suite: 499 passed, 1 skipped.
2026-05-12 22:26:25 -06:00
70bf9caf58 pca_file: extract zone_type from SetupData installer section
SetupData (3840 bytes) holds the panel's per-object property tables.
Layout for OMNI_PRO_II's installer section (Seek to instSetupStart=2560
in clsHAC._ParseSetupData at clsHAC.cs:3156):

  offset 2560: HouseCode (1 byte)
  offsets 2561..2569: OutputType[0..8] (9 bytes; numVoltOutputs)
  offset 2570: ZoneExpansions (1 byte)
  offset 2571: NumExpEnc (1 byte)
  offsets 2572..2747: ZoneType[1..176] (176 bytes; raw enuZoneType per zone)

Verified against the live fixture: 2 EntryExit + 4 Perimeter + 3 AwayInt
+ 1 Extended_Range_OutdoorTemp + 166 Auxiliary (panel default for
unused slots) — matches the named-zones cross-reference exactly.

PcaAccount gains a zone_types dict (1-based slot → raw byte). The
walker stashes the SetupData blob to a buffer up front and indexes
in by offset rather than chasing the sequential parser through all
of telephony/codes/areas — that's a bigger RE pass for another day.

MockZoneState now carries zone_type and area fields. MockState.from_pca
threads acct.zone_types through, and _build_zone_properties uses the
real value instead of hardcoded 0 (EntryExit). End-to-end against
MockPanel.from_pca: HA's discovery now classifies binary vs. analog
zones correctly straight from the .pca — outdoor temp zone surfaces
as a temperature sensor entity, motion sensors as binary_sensor,
door zones as the right kind of binary_sensor.

Full suite: 499 passed, 1 skipped. RE notes in pca_file.py.
2026-05-12 22:18:32 -06:00
7db9616a34 pca_file: extract Zone/Unit/Button/Code/Tstat/Area/Message names
The Names block (between SetupData and Voices) was previously walked
as opaque bytes. It's actually a sequence of seven object-family
tables, each storing N × String8(L) per the
clsAbstractNamedItem.ReadName / clsPcaCryptFileStream.ReadString8(out S, byte L)
pattern. Per-slot layout is [1 byte actual length][L bytes name],
with length 0 meaning "unused".

New PcaAccount fields:
* zone_names, unit_names, button_names, code_names,
  thermostat_names, area_names, message_names
  — each is {1-based slot: name}, only non-empty slots.

Object *properties* (zone_type, area_membership, etc.) aren't
extracted yet — those live in SetupData, which remains opaque.
Names alone unlock the biggest win: meaningful entity labels in
HA from a .pca snapshot.

MockState.from_pca now seeds zones/units/areas/thermostats/buttons
with MockZoneState/MockUnitState/etc. instances carrying just the
name. Defaults handle everything else. A connected client sees the
real panel's names through normal wire discovery (UploadNames
streams them back, properties synth fills the rest).

New end-to-end test verifies the HA integration discovers all 16
zones, 44 units, 16 buttons, 2 thermostats from the live fixture
when the MockPanel is built via MockState.from_pca — proving the
full file → mock → wire → HA pipeline.

Live fixture: 16 zones, 44 units, 16 buttons, 8 codes, 2 thermostats,
0 areas, 8 messages, 330 programs. (Areas in this v1 install have
no user-assigned names — expected.)

Full suite: 499 passed, 1 skipped (fixture-gated).
2026-05-12 20:34:00 -06:00
390f3a9dc0 mock_panel: MockState.from_pca builds state from a real .pca file
Convenience constructor that runs parse_pca_file and seeds:
* model_byte + firmware_major/minor/revision from the .pca header,
  so SystemInformation replies match the panel the file came from
* programs dict from every non-empty Program record in the 1500-slot
  table, encoded back to wire bytes for direct UploadProgram /
  UploadPrograms service

Per-object name/state (zones/units/areas/thermostats) isn't in the
pca_file extraction yet — those default to empty unless the caller
overrides. Easy to extend later when pca_file grows zone/unit name
parsing.

Net effect: anyone can now point a MockPanel at any .pca file and
get a hermetic replay of that install's programs over both v1 and
v2 wire dialects:

    state = MockState.from_pca("My_House.pca", key=KEY_EXPORT)
    panel = MockPanel(controller_key=k, state=state)

New e2e test materialises the live fixture, builds the mock from it,
streams all 330 programs back through OmniClient.iter_programs, and
asserts the slot indexes match.
2026-05-12 20:25:02 -06:00
4ad20c9350 clients: iter_programs() for both v1 and v2 wire dialects
v2 path adds an iterator over UploadProgram with request_reason=1
("next defined after slot"), mirroring the C# ReadConfig loop at
clsHAC.cs:4985 (seed call) and 5331 (per-reply re-issue). The mock
panel now honours reason=1: walks state.programs for the next
slot strictly greater than the requested one, returns EOD when none.

v1 path wraps OmniConnectionV1.iter_streaming(UploadPrograms) and
decodes each ProgramData reply into a Program. The panel already
streams in slot-ascending order from the previous commit, so the
client just decodes-and-yields.

Both methods return AsyncIterator[Program] for HA-side consumption.
Tests cover populated and empty states for both dialects, plus the
raw v2 reason=1 semantics on a single request.
2026-05-12 19:07:42 -06:00
933d326dd3 mock_panel: v1 UploadPrograms streaming + program-echo tests
MockPanel only handled the v2 (single-slot, request/reply)
UploadProgram path. v1 panels use a streaming variant:
client sends UploadPrograms (bare), panel emits one ProgramData
per defined slot, ack-walked by the client, terminated by EOD.

Wire layout is byte-identical to v2 — only the envelope opcode
and stream pattern differ (clsHAC.OL1ReadConfig at clsHAC.cs:4403,
4538-4540, 4642-4651). The mock now mirrors the UploadNames
streaming pattern with its own cursor.

Tests cover both the populated-state stream-then-EOD case and
the empty-state immediate-EOD case, alongside the existing v2
single-slot round-trip tests.
2026-05-12 18:21:05 -06:00
d4c04b3044 programs: typed decoder/encoder for the 14-byte program record
First reverse-engineering pass on the panel's built-in automation
engine. Adds a typed Python Program dataclass that decodes/encodes the
14-byte program record used both on the wire (clsOLMsgProgramData) and
on disk (the 21,000-byte Programs block in a .pca file).

Coverage:
* enums: ProgramType, ProgramCond, Days bitmask
* Program dataclass with from_wire_bytes / from_file_record /
  encode_wire_bytes / encode_file_record (Mon/Day swap for EVENT-typed
  records applied on the file form only -- mirrors clsProgram.Read at
  clsProgram.cs:471, while clsProgram.ToByteArray omits the swap)
* Remark variant (bytes 1-4 = BE u32 RemarkID instead of cond/cond2)
* unknown ProgType / Cmd bytes pass through as raw ints with a
  once-per-process warning
* decode_program_table for the full 1500-slot .pca block
* pca_file.parse_pca_file populates PcaAccount.programs (backward-
  compatible: defaults to ())
* mock_panel.MockState.programs + _reply_program_data so OmniLink2
  UploadProgram (opcode 9) round-trips through the test fixture

Verification (422 passed, 1 skipped — was 400):
* 15 unit tests in test_programs.py: golden bytes for each ProgramType,
  Mon/Day swap proven distinct between wire and file layouts, Remark
  round-trip, 500 random-input wire+file round-trips, unknown-enum
  tolerance
* 4 fixture-gated live-data tests in test_pca_file.py: all 1500 slots
  decode cleanly, 330 non-empty (matches Phase 1 recon distribution
  209 TIMED / 105 EVENT / 16 YEARLY), 21,000-byte byte-for-byte
  round-trip against the live decrypted fixture, YEARLY month/day in
  valid calendar ranges
* 3 wire-echo tests in test_e2e_program_echo.py: client drives
  UploadProgram (opcode 9) through the mock, server replies with
  ProgramData (opcode 10) wrapping [number_hi, number_lo, body];
  full Program round-trips field-by-field, empty slots return zero
  bodies, EVENT bytes are emitted in wire order (no swap)

What this pass deliberately leaves open (documented in the docs page):
* cond / cond2 internal bit split (selector vs operand)
* multi-record clausal encoding (When/At/Every/And/Or/Then)
* RemarkID -> RemarkText lookup table layout
* DPC capability flag location for non-OPII models
* TIMED time-of-day vs sunrise/sunset-relative offset flag

References:
* clsProgram.cs (entire) — field accessors, Read/Write, Evt u16
* enuProgramType.cs / enuProgramCond.cs / enuDays.cs
* Owner's Manual SETUP chapter — user-facing programming-line model
* Installation Manual SETUP MISC — installer-facing setup screen
2026-05-11 19:48:00 -06:00