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.
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).
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.
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.
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.
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