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.
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.
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.
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.
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.
Adds CONF_PCA_PATH + CONF_PCA_KEY config-flow fields. When set, the
coordinator parses programs from the .pca file at that path instead
of streaming them over the wire on every entry refresh. Useful for:
* deployments where wire enumeration is slow (1500-slot iteration)
* offline snapshots when the panel is unreachable
* deterministic test setups against a known fixture
The config-flow validates the file is readable and decrypts cleanly,
surfacing pca_not_found / pca_decode_failed errors via the strings/
en.json translations.
The .pca path is checked first in _discover_programs; if absent the
wire path runs as before. So existing deployments are unaffected.
Tests cover the success path (live fixture, 330 programs) and the
two validation failures (missing file, garbage bytes).
Coordinator's _discover_programs is no longer a placeholder. It now
drives client.iter_programs() (v2 path) or the v1 adapter's forward
to OmniClientV1.iter_programs (v1 path), populating OmniData.programs
with decoded Program records keyed by slot. Errors are logged and
swallowed so partial enumeration doesn't break entry setup —
programs are non-critical telemetry.
OmniData.programs is now dict[int, Program] rather than the
ProgramProperties dict that was an empty placeholder. The
ProgramProperties dataclass remains in models.py for the Properties
opcode reply path; only the coordinator's value type changed.
New OmniProgramsSensor on the sensor platform: a single diagnostic
entity per panel whose state is the count of defined programs and
whose 'programs' attribute lists each program's slot, type name,
and schedule fields. Easy to consume from automations and the
developer-states UI.
Mock fixture seeds three programs (TIMED+TIMED+EVENT at slots 12 /
42 / 99). New integration test verifies the sensor enumerates them
in slot-ascending order with the expected per-record fields.
Full suite: 494 passed, 1 skipped (fixture-gated).
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.
Final RE pass on the multi-record AND record extension. Authored
"AND IF DATE IS EQUAL TO 12/31" (block 12, slot 13) and resolved
the disk encoding model for the structured-OP case:
byte 0 : ProgType = 8 (AND)
byte 1 : (high byte of LE cond) = OP (enuCondOP)
byte 2 : (low byte of LE cond) = Arg1_ArgType (enuCondArgType)
bytes 3-4 : (cond2 LE) = Arg1_IX
byte 5 : (cmd byte) = Arg1_Field
byte 6 : (par byte) = Arg2_ArgType
bytes 7-8 : (pr2 LE) = Arg2_IX
byte 9 : (month byte) = Arg2_Field
bytes 10-11: (day, days bytes) = CompConst
The C# clsConditionLine.Cond property at clsConditionLine.cs:17-33
bridges the two views: for Traditional case (OP=0), the compact-form
cond u16 is SYNTHESIZED from Arg1_ArgType and Arg1_IX. The byte at
offset 2 (= Arg1_ArgType) holds the ProgramCond family code (ZONE=4,
CTRL=8, ...) when OP=0, or the enuCondArgType value (Zone=2, Unit=3,
Thermostat=4, TimeDate=7, ...) when OP > 0. Same byte, different
semantic interpretation based on OP.
New Program properties:
and_op - byte 1, enuCondOP (0 = Traditional, 1-9 = structured)
and_arg1_argtype - byte 2, family code (Trad) or CondArgType (Struct)
and_arg1_ix - bytes 3-4 raw u16 (= cond2; Python LE decode
happens to equal C# in-memory BE Arg1_IX)
and_arg1_field - byte 5
and_arg2_argtype - byte 6
and_arg2_ix - bytes 7-8 raw u16 (= pr2)
and_arg2_field - byte 9
and_compconst - bytes 10-11
The and_instance property is now smart-branched on and_op:
- Traditional: returns Arg1_IX >> 8 (instance in high byte per
clsConditionLine.Cond setter)
- Structured: returns Arg1_IX directly (raw object index)
Also fixed every_interval: per clsProgram.Interval at
clsProgram.cs:338-348, it reads (Data[2] << 8) | Data[3] which spans
the Cond and Cond2 byte ranges. The correct Python formula is
((cond & 0xFF) << 8) | ((cond2 >> 8) & 0xFF). The earlier byte-swap-of-
cond2 formula happened to work for Interval=5 but would break for
Interval > 255.
2 new tests:
test_and_structured_date_eq_1231 - the captured Date case
test_and_traditional_zone_5_secure_via_structured_view
- same vector via structured accessors
475 tests passing (up from 473).
The 6 multi-record ProgType values (WHEN/AT/EVERY/AND/OR/THEN) now have
typed accessors on the Program dataclass:
is_multi_record() - classifier for ProgTypes 5-10
event_id - WHEN trigger event-id (same property as EVENT, no
Mon/Day swap, BE wire form)
and_family - AND record byte-1 family + operand bits (mirrors
compact-form cond's high byte: ZONE=0x04, CTRL+ON=0x0A,
OTHER=0x00, etc.)
and_instance - AND record bytes 3-4 BE u16 (zone#, unit#,
MiscConditional value, ...)
every_interval - EVERY record bytes 3-4 BE u16 (recurrence interval)
AT records reuse the existing month/day/days/hour/minute fields (same
byte layout as compact-form TIMED, just with cmd/par/pr2 zero).
OR records carry no payload — only the ProgType byte distinguishes
them. THEN records reuse cmd/par/pr2 (same layout and LE byte order
as compact-form action fields).
10 new tests cover the empirical captures from pca-re/clausal-re:
- is_multi_record() classifier
- WHEN event_id for Zone 5 Secure and Zone 1 Secure
- EVERY 5 SECONDS interval decoding
- AND IF UNIT 1 ON, AND IF ZONE 5 SECURE, AND IF NEVER family+instance
- AT record month/day/days/hour/minute
- OR record all-zero invariants
- THEN record cmd/par/pr2 (UNIT 1 ON)
All byte vectors in the tests come from real PC Access captures in
pca-re/clausal-re/06-10.pca with firmware override at 3.0+.
The and_family and and_instance properties derive from the existing
cond and cond2 fields via byte-swap — disk bytes 1-4 of AND records
use BE u16 order, but Program's cond/cond2 fields are LE-decoded
(per compact-form convention). The byte-swap formula
((cond2 & 0xFF) << 8) | ((cond2 >> 8) & 0xFF) yields the BE
interpretation without re-reading raw bytes.
473 tests passing (up from 463).
The 14-byte program record's three u16 fields are little-endian, not
big-endian as the original plan assumed. Empirically confirmed by
authoring known programs in PC Access (running in a Windows XP VM)
and byte-diffing the resulting .pca file:
- UNIT 1 ON → bytes 7,8 = 01 00 → LE 0x0001 (correct), not 0x0100
- AND IF ZONE 2 SECURE → cond bytes [02, 04] → LE 0x0402 (kind=4 ZONE,
inst=2) matches the ProgramCond.ZONE family from the C# source
- Cross-check Our_House.pca's 209 TIMED records: pr2 low-byte is
almost always zero (textbook LE small-value distribution)
Also annotate the multi-record ProgType values (WHEN/AT/EVERY/AND/OR/THEN,
values 5-10) with the firmware ≥3.0.0 requirement from
clsCapOMNI_PRO_II.cs:290 — the user's 2.16A panel can't produce them,
which is why Our_House.pca contains zero such records.
New constants:
- MIN_FIRMWARE_MULTILINE_PROGRAMS (= 196608, packed 3.0.0)
- MIN_FIRMWARE_DOUBLE_PROGRAM_CONDITIONAL (= 0, always)
- pack_firmware_version() helper
Full RE notes in pca-re/clausal-re/FINDINGS.md (separate repo).
Tests: 463 passing, 1 skipped (gitignored fixture).
The 16-bit cond/cond2 fields of a program record pack a 5-family
discriminator + a per-family selector + a per-family operand. The high
byte's bits 2-7 (i.e. (cond >> 8) & 0xFC) pick the family; the rest is
family-specific:
OTHER → bits 0-3 = MiscConditional value (DARK, AC_POWER_OFF, …)
ZONE → bits 0-7 = zone #; bit 9 = NOT_READY (1) / SECURE (0)
CTRL → bits 0-8 = unit #; bit 9 = ON (1) / OFF (0)
TIME → bits 0-7 = clock#; bit 9 = ENABLED (1) / DISABLED(0)
SEC → bits 8-11 = area #; bits 12-14 = SecurityMode;
bit 15 = arming-transition flag
(only when mode != 0)
The Sec family is the catch-all default (per clsText.cs:2226-2273 the
switch falls through to it from anything not Other/Zone/Ctrl/Time).
omni_pca.programs:
* New ConditionFamily IntEnum and MiscConditional IntEnum.
* New Condition frozen dataclass with decode classmethod, is_empty,
describe (renders with index-based labels for offline use).
* New Program.condition() and Program.condition2() helpers.
omni_pca top-level: re-exports Condition, ConditionFamily, MiscConditional.
Verified against the live fixture (330 defined programs):
cond family distribution: SEC=156, TIME=8, ZONE=4, CTRL=3, OTHER=3
cond2 family distribution: SEC=21, TIME=10
tests/test_programs.py (+24 cases):
* Parametrised per-family decode with worked examples from the docs.
* Arming-transition flag asserts (mode=Off + bit 15 is NOT arming).
* Program.condition()/condition2() integration.
* OTHER ignores high bits (PC Access sometimes leaves them set).
* u16 range validation.
* MiscConditional enum values match enuMiscConditional.cs.
Full suite: 463 passed, 1 skipped (was 439 / 1).
Source: clsText.GetConditionalText (clsText.cs:2224-2273) for the
decode logic; frmAutomationEditCondition.cs:615-2550 for the encoder.
The hour byte of a TIMED program is overloaded as a 1-of-3 discriminator:
0..23 means absolute wall-clock time, 25 means sunrise-relative, 26 means
sunset-relative. For the relative forms, the minute byte is signed
(sbyte) -- positive = after, negative = before, zero = at. Source:
frmPopUpEditTime.cs:186-217 (decode) + :241-263 (encode).
This was the canary that tripped our earlier sanity test: slots 182/183
in the live fixture have hour=26 minute=246 and hour=25 minute=10 --
nominally invalid as clock times, but they're "10 min before sunset"
and "10 min after sunrise" respectively. With this commit those decode
cleanly via TimeKind.SUNSET / SUNRISE.
omni_pca.programs:
* New TimeKind IntEnum: ABSOLUTE / SUNRISE / SUNSET.
* New Program.time_kind property (classifies via hour-byte discriminator).
* New Program.time_offset_minutes property (signed minutes-offset for
sunrise/sunset; 0 for absolute).
* New Program.format_time() -> str: "07:15" | "at sunrise" | "30 min
before sunset" etc.
* Module-level _classify_time helper + sentinel constants
_HR_SUNRISE_SENTINEL=25 / _HR_SUNSET_SENTINEL=26.
omni_pca top-level: re-exports TimeKind.
tests/test_programs.py (+13 cases):
* Parametrised TimeKind classification across absolute / sunrise /
sunset including boundary cases (sbyte ±128, ±1, 0).
* Wire-bytes round-trip preserves TimeKind + offset.
tests/test_pca_file.py: tightened the previously-loosened sanity
invariant. ABSOLUTE-time TIMED programs must hit valid wall-clock
ranges (0-23 / 0-59); relative-time programs must have a valid sbyte
offset (-128..127). Both pass cleanly on the 209 TIMED programs in
the live fixture (207 absolute, 1 sunrise, 1 sunset).
Full suite: 439 passed, 1 skipped (was 426 / 1).
Decodes the remark-text dict that Remark-typed program records refer to
via their 32-bit BE RemarkID. The table lives after the Connection
block in PCA03 files; getting to it means walking past ModemBaud +
PCModemInit flags + AccountRemarks_Extended + nine 33-byte-per-entry
Description blocks (Zones, Units, Buttons, Codes, Thermostats, Areas,
Messages, AudioSources, AudioZones).
Format reverse-engineered from clsPrograms.ReadRemarks (clsPrograms.cs:
148-168) and the file-body walker in clsHAC.cs:8055-8079. Each entry is
[u32 LE remark_id][u16 LE text_length][N bytes UTF-8], preceded by a
[u32 LE _RemarksNextID][u32 LE count] header.
pca_file changes:
* PcaAccount.remarks: dict[int, str] (default {}).
* New _walk_to_remarks helper called from parse_pca_file when
file_version >= 3. Best-effort: any read failure leaves remarks={}.
* New _DESCRIPTION_SLOT_BYTES (= 33) constant.
tests/test_pca_file.py (4 new cases):
* Walker on an empty Remarks table (decode count=0 cleanly).
* Walker decodes three hand-built entries, including a UTF-8 string
with non-ASCII characters.
* Truncated input returns {} rather than raising.
* Live fixture (Our_House.pca.plain): walker consumes the prelude +
nine description blocks + zero-count remarks block without raising.
This panel has no Remark-typed programs, so {} is the expected
result -- and the *coarse* walker validation here is what proves
the description-block sizes (counts up to 511) are correct.
Full suite: 426 passed, 1 skipped (was 422 / 1).
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
Adds OmniLinkMessage (0x10) outer-packet handling to the mock so the
v1 path no longer requires a real panel for testing. Exercised over
UDP because OmniClientV1 is UDP-only by design, but the dispatcher
itself is transport-agnostic and the TCP _handle_client routes
OmniLinkMessage packets through the same _dispatch_v1 method too.
Coverage today:
* RequestSystemInformation (17) -> SystemInformation (18)
* RequestSystemStatus (19) -> SystemStatus (20), 8 area mode bytes
* RequestZoneStatus (21) -> ZoneStatus (22), short + long form
* RequestUnitStatus (23) -> UnitStatus (24), short + long form
(long form auto-selected for indices > 255)
* RequestThermostatStatus (30) -> ThermostatStatus (31)
* RequestAuxiliaryStatus (25) -> AuxiliaryStatus (26) (zero records)
* UploadNames (12) -> NameData (11) streaming, lock-step
Ack-driven across Zone/Unit/Button/
Area/Thermostat, terminated by EOD (3)
* Command (15) -> Ack (5) / Nak (6), reuses v2 state
mutator so light-on/off, set-level,
bypass-zone, restore-zone all work
* ExecuteSecurityCommand (102) -> Ack (5) / ExecuteSecurityCommandResponse
(103) on bad code, with structured
status byte preserved
* MessageCrcError -> v1 Nak (opcode 6)
The dispatcher writes replies wrapped in OmniLinkMessage (16) outer
packets (vs OmniLink2Message (32) used by v2) so OmniClientV1 routes
them correctly. The 4-step handshake is shared with v2 -- it's
protocol-version-agnostic at the outer-packet layer.
UploadNames state is panel-instance scoped via _upload_names_cursor
(int | None) -- there is only one active session at a time on the
mock so a single cursor suffices.
tests/test_e2e_v1_mock.py: 13 cases driving OmniClientV1 through the
mock's UDP socket, covering the full read API + UploadNames streaming
+ write methods + structured-failure path on a wrong security code.
Full suite: 400 passed, 1 skipped (was 387 / 1).
Some Omni network modules are configured for UDP, in which case PC Access
falls back to the v1 wire protocol (OmniLinkMessage outer = 0x10, inner
StartChar 0x5A, typed Request*Status opcodes) instead of v2's TCP path
(OmniLink2Message + StartChar 0x21 + parameterised RequestProperties).
This adds a parallel implementation rather than overloading the v2 path.
omni_pca/v1/
connection.py UDP-only OmniConnectionV1; reuses crypto + handshake,
routes post-handshake messages through OmniLinkMessage
(0x10) wrapping v1 inner format. Adds iter_streaming
for the lock-step UploadNames/Acknowledge/EOD pattern.
messages.py Block parsers for the typed v1 status replies (zone,
unit, thermostat, aux), v1 SystemStatus, and NameData
(handles both one-byte and two-byte NameNumber forms).
client.py OmniClientV1: read API (get_system_information,
get_*_status), discovery (iter_names + list_*_names),
write API (execute_command, execute_security_command,
turn_unit_*, set_unit_level, bypass/restore_zone,
execute_button, set_thermostat_*). acknowledge_alerts
is a no-op (v1 has no equivalent opcode).
Discovery uses bare UploadNames; panel streams every defined name across
all types in a fixed order with per-record Acknowledge. Verified against
firmware 2.12 — pulled 16 zones, 44 units, 16 buttons, 8 codes,
2 thermostats, 8 messages in one stream.
src/omni_pca/message.py
Fix flipped START_CHAR_V1_* constants. enuOmniLinkMessageFormat says
Addressable=0x41 and NonAddressable=0x5A; our names had them swapped.
Wire bytes were unchanged, so existing tests kept passing — but
encode_v1() with no serial_address now correctly emits 0x5A, which
is what UDP needs.
tests/
test_v1_messages.py 22 cases; payloads are real wire captures
from a firmware-2.12 panel via probe_v1_recon.
test_v1_client_commands.py 20 cases; payload-packing for the Command
and ExecuteSecurityCommand opcodes,
including BE u16 parameter2 and the
digit-by-digit security code form.
dev/
probe_v1.py Phase-1 smoke: handshake + RequestSystemInformation.
probe_v1_recon.py Raw opcode dump for protocol reconnaissance.
probe_v1_stream.py Streaming UploadNames flow exploration.
probe_v1_client.py Full read-path smoke test via OmniClientV1.
probe_v1_write.py Live no-op execute_command round-trip.
.gitignore: ignore dev/.omni_key (probe scripts read controller key from
this file as one fallback option).
Discovery on firmware 2.12: Request*ExtendedStatus opcodes (63/65/69)
NAK on this firmware — only the basic Request*Status opcodes are
implemented, so OmniClientV1 uses those (3 bytes/unit, 7 bytes/tstat,
4 bytes/aux records). HA still gets enough signal for polling; full
properties discovery uses streaming UploadNames instead.
Test totals: 387 passed, 1 skipped (existing fixture skip).
The C# decompile shows enuOmniLinkConnectionType has both Network_TCP=4
and Network_UDP=3 (clsOmniLinkConnection.cs uses udpSend/tcpSend
parallel paths), and clsHAC carries an enuPreferredNetworkProtocol
{TCP, UDP} per-installation byte. User reports their panel is
configured for UDP. The TCP-only assumption was too narrow.
Wire format is identical: same Packet/Message framing, same handshake,
same per-block whitening, same opcodes, same port. Only differences:
* UDP is connectionless; each datagram = one Packet (no stream framing)
* UDP needs explicit retry-on-timeout for reliability
src/omni_pca/connection.py:
- New constructor args: transport: Literal['tcp','udp']='tcp',
udp_retry_count: int = 3
- connect()/close() branch on transport — TCP keeps the existing
asyncio.open_connection + StreamReader/Writer + reader_task path;
UDP uses asyncio.get_running_loop().create_datagram_endpoint with
remote_addr= so transport.sendto(data) works without per-datagram
addrs. The reader_task is TCP-only.
- _write_packet branches between writer.write and udp_transport.sendto
- request() loops up to (1 + udp_retry_count) attempts on UDP, retrying
on RequestTimeoutError; TCP gets a single attempt (existing behavior)
- New _OmniDatagramProtocol that decodes each datagram into a Packet
and delegates to the shared _dispatch (which already knows how to
route handshake / solicited / unsolicited)
src/omni_pca/mock_panel.py:
- serve(transport='tcp'|'udp') public arg; defaults preserve existing
TCP behavior. Internally splits into _serve_tcp / _serve_udp.
- New _MockServerDatagramProtocol that mirrors _handle_client for UDP.
Tracks one active client by addr (single-session, matches Omni's
single-client constraint). Reuses the panel's existing _dispatch_v2,
_reply_*, _build_* helpers — the dispatch logic is unchanged, only
the transport framing differs.
- New _schedule_udp_push for synthesized SystemEvents (seq=0) push
to the active client's addr after state mutations.
src/omni_pca/client.py:
- OmniClient gains transport= and udp_retry_count= kwargs that pass
through to OmniConnection. Default is 'tcp' so existing callers
are unaffected.
tests/test_e2e_udp.py — 6 e2e tests:
- handshake roundtrip
- get_system_information
- arm area with right code
- arm with wrong code -> CommandFailedError
- turn unit on -> push UnitStateChanged event
- wrong ControllerKey -> HandshakeError
All run under 0.2s. Combined with the existing TCP suite: 357 tests
pass (was 351), ruff clean across src/ tests/.
The HA integration's config_flow still defaults to TCP; users on UDP
panels can manually set transport= via the OmniClient init path. A
follow-up commit will add transport to the HA config flow as a
dropdown option.
Pytest harness (in-process HA + MockPanel)
==========================================
pyproject.toml — bumps requires-python to 3.14.2 to align with HA 2026.5.x
which is what pytest-homeassistant-custom-component pins. Dev group 'ha'
pulls the harness; .python-version updated to 3.14.
src/omni_pca/mock_panel.py — Thermostat (6) and Button (3) RequestProperties
handlers added (previous commit). Without these the HA coordinator's
discovery walk produced empty thermostat/button dicts.
custom_components/omni_pca/services.py — fix CONF_ENTRY_ID import: HA
exports it as ATTR_CONFIG_ENTRY_ID, not CONF_ENTRY_ID. Aliased on import.
tests/conftest.py — re-enables sockets globally (the HA harness installs
pytest_socket which otherwise blocks our network e2e tests).
tests/ha_integration/ — new directory with full HA boot harness:
conftest.py:
- autouse enable_custom_integrations so HA loads our component
- autouse expected_lingering_tasks=True (background event listener)
- autouse _short_scan_interval (1s instead of 30s for fast tests)
- panel fixture: MockPanel on a random localhost port for each test
- configured_panel fixture: builds a MockConfigEntry, runs setup,
yields, then unloads on teardown so the coordinator's reader task
and OmniClient socket close cleanly (otherwise verify_cleanup hangs)
test_setup.py — 12 tests:
- integration loads + system_info populated
- alarm_control_panel/light/switch/climate/button/event/binary_sensor
entities materialise per platform
- unload_entry tears down cleanly
- turning a light on via HA service updates the mock state
- arming via HA service with the right code transitions the area
- arming with wrong code keeps the area disarmed and surfaces error
Total: 351 passed, 1 skipped (PCA fixture). Ruff clean across src/ tests/
custom_components/. The 12 HA integration tests run in <1s end-to-end —
they boot HA in-process, drive the config flow, exercise services, and
verify state mutations on the mock side.
Docker dev stack (manual smoke / screenshots)
=============================================
dev/docker-compose.yml — HA 2026.5 container + MockPanel sidecar.
dev/run_mock_panel.py — long-running mock with a populated state
(5 zones, 4 units, 2 areas, 2 thermostats, 3 buttons, codes 1234/5678).
dev/Makefile — make dev-up / dev-logs / dev-down / dev-mock / dev-reset.
dev/README.md — onboarding walkthrough (host=host.docker.internal,
port=14369, controller_key=000102030405060708090a0b0c0d0e0f).
.gitignore — adds ha-config/ so the persisted HA state from the dev
stack doesn't get committed.
The HA coordinator walks ObjectType.THERMOSTAT (6) and ObjectType.BUTTON
(3) via raw RequestProperties to discover them — the high-level
get_object_properties() path only knows zones/units/areas in v1.0. The
mock was returning Nak for both, which made HA discover zero thermostats
and zero buttons no matter how MockState was seeded.
src/omni_pca/mock_panel.py:
- New MockButtonState dataclass (just a name)
- MockState gains buttons: dict[int, MockButtonState] (with the same
bare-string -> dataclass __post_init__ promotion as the others)
- _OBJ_BUTTON = 3, _BUTTON_NAME_LEN = 12, _THERMOSTAT_NAME_LEN = 12
constants
- thermostat_name_bytes() / button_name_bytes() helpers
- _build_thermostat_properties() emits the 23-byte Properties body
matching ThermostatProperties.parse offsets (object number BE u16,
communicating flag, current temp, heat/cool setpoints, system/fan/
hold modes, thermostat type, 12-byte NUL-padded name)
- _build_button_properties() emits the 15-byte body (object number BE
u16 + 12-byte name)
- _reply_properties / _object_store dispatch both new types
tests/test_e2e_client_mock.py — two new e2e tests drive raw
RequestProperties walks for thermostats and buttons against a seeded
mock and assert ThermostatProperties / ButtonProperties parse cleanly,
mirroring what the HA coordinator's _walk_properties() does.
333 tests pass (was 331); ruff clean. Mock surface now matches every
opcode the HA coordinator and entity platforms actually call.
custom_components/omni_pca/ — six new platform modules wrapping the
v1.0 client surface. Every command method catches CommandFailedError
and re-raises HomeAssistantError so panel rejections (bad code, etc.)
become user-friendly HA errors instead of silent failures.
alarm_control_panel.py — OmniAreaAlarmPanel per discovered area.
Supports ARM_HOME (Day) / ARM_NIGHT / ARM_AWAY / ARM_VACATION /
ARM_CUSTOM_BYPASS (Day-Instant). State derives from area_status via
pure helpers.security_mode_to_alarm_state which handles arming-in-
progress, entry/exit timers, and active-alarm overrides.
light.py — OmniUnitLight per discovered unit (every unit; non-dimmable
units silently ignore brightness, no harm done). Brightness conversion
via helpers.omni_state_to_ha_brightness / ha_brightness_to_omni_percent
(Omni state byte: 0=off, 1=on, 100..200=brightness percent).
switch.py — OmniZoneBypassSwitch per binary zone. CONFIG entity_category;
pairs with the existing diagnostic 'zone bypassed' binary_sensor.
climate.py — OmniThermostatClimate per discovered thermostat.
Supports OFF / HEAT / COOL / HEAT_COOL hvac_modes; auto / on / diffuse
fan_modes; none / hold / vacation preset_modes. Single-setpoint and
range setpoint via TARGET_TEMPERATURE_RANGE. Fahrenheit native (Omni
panels are F-native; HA handles unit conversion downstream).
sensor.py — analog zones (temperature/humidity/power) + per-thermostat
diagnostic temp/humidity/outdoor sensors + OmniSystemModelSensor
+ OmniLastEventSensor (event_class + parsed event fields as attrs).
button.py — OmniPanelButton per discovered button macro. Programs not
yet exposed because the library lacks RequestProperties for Programs.
event.py — single OmniPanelEvent per panel relaying typed SystemEvents
via _trigger_event. event_types: zone_state_changed, unit_state_changed,
arming_changed, alarm_activated/cleared, ac_lost/restored,
battery_low/restored, user_macro_button, phone_line_dead/restored.
Automations key off platform: event + event_type filter.
helpers.py — extended with security_mode_to_alarm_state,
ARM_SERVICE_TO_SECURITY_MODE, omni_state_to_ha_brightness +
ha_brightness_to_omni_percent, omni/ha_{hvac,fan,hold} round-trips,
fahrenheit_to_omni_raw / celsius_to_omni_raw, analog_zone_device_class,
EVENT_TYPES tuple, event_type_for(class_name).
__init__.py — PLATFORMS extended to all 8 entity types.
scene.py intentionally NOT created — Omni 'scenes' are user-defined
button macros, already covered by the button platform. Documented in
README; revisit if/when the library gains scene-discovery opcodes.
tests/test_ha_helpers.py: +67 unit tests covering every new helper.
331 tests pass (was 264). Ruff clean across src/ tests/ custom_components/.
custom_components/omni_pca/coordinator.py — full rewrite:
- Long-lived OmniClient for entry lifetime
- One-shot discovery: system info + zone/unit/area/thermostat/button names
via list_*_names + per-index get_object_properties
- Periodic poll (30s default): get_extended_status for zones/units/thermostats,
get_object_status for areas, skip empty discoveries
- Background _run_event_listener task consuming client.events(), patches
state in-place and async_set_updated_data on push:
ZoneStateChanged -> patch zone_status raw byte
UnitStateChanged -> patch unit_status state, preserve brightness
ArmingChanged -> patch area_status mode + last_user
AlarmActivated/Cleared -> trigger refresh
AcLost/Restored, BatteryLow/Restored -> recorded for sensors
- InvalidEncryptionKeyError/HandshakeError -> ConfigEntryAuthFailed (HA reauth)
- OmniConnectionError/RequestTimeoutError -> UpdateFailed + drop client
- Event task cancelled in async_shutdown
custom_components/omni_pca/binary_sensor.py — full rewrite:
- OmniZoneBinarySensor per discovered zone (device class from zone type:
smoke/water/freeze use latched-alarm bit; doors/motion use current condition)
- OmniZoneBypassedBinarySensor per zone (DIAGNOSTIC, PROBLEM)
- OmniSystemAcBinarySensor (POWER, prefers AcLost/AcRestored push)
- OmniSystemBatteryBinarySensor (BATTERY)
- OmniSystemTroubleBinarySensor (PROBLEM)
custom_components/omni_pca/helpers.py — pure functions extracted for testing:
- device_class_for_zone_type, is_binary_zone_type, use_latched_alarm_for_zone,
prettify_name. 61 unit tests in tests/test_ha_helpers.py.
docs/JOURNEY.md — 4383-word raw chronological retrospective of the whole
arc from binary archive to working library. 18 dated sections including
the 2191-byte magic-number header validation moment, the two non-public
protocol quirks, the offline-panel comedy. Source material for future
writeups (intentionally raw, not polished).
264 tests pass (was 203, +61 helper tests). Ruff clean across all dirs.
src/omni_pca/client.py — wire OmniClient.events() that returns an async
iterator over typed SystemEvent objects (built on events.EventStream).
src/omni_pca/mock_panel.py — substantial expansion:
- Per-object state dataclasses (MockUnitState, MockAreaState, MockZoneState,
MockThermostatState) plus user_codes table for security validation
- Backward-compat: existing callers passing {idx: 'NAME'} strings still work
via __post_init__ string-promotion to the matching Mock*State instance
- New opcode handlers:
Command (20) -> Ack with state mutation, dispatches
UNIT_ON/OFF/LEVEL, BYPASS/RESTORE_ZONE,
SET_THERMOSTAT_HEAT/COOL/SYS/FAN/HOLD
ExecuteSecurityCommand (74) -> Ack on valid code (mode applied);
Nak on invalid code
RequestStatus (34) -> Status (35) for Zone/Unit/Area/Thermostat
hard-coded record sizes per
clsOL2MsgStatus.cs:13-27
RequestExtendedStatus (58) -> ExtendedStatus (59) with object_length
prefix, richer fields per object type
AcknowledgeAlerts (60) -> Ack
- Synthesized SystemEvents (55) push on state change with seq=0; events round-
trip cleanly through events.parse_events() (validated by tests, not just
asserted in code)
tests/test_e2e_client_mock.py — +9 e2e tests covering arm/disarm with code
validation, unit on/off/level, zone bypass/restore, thermostat setpoint,
push events for arming and unit changes, acknowledge_alerts.
203 passed (was 194), 2 skipped (HA harness + .pca fixture). Ruff clean.
Library v1.0 surface complete: read-only, command, status, extended status,
events. Next: rebuild the HA custom_component on top of this.
custom_components/omni_pca/ — drop-in HA integration:
- manifest.json (HA 2026.x, iot_class=local_push, requires omni-pca lib)
- config_flow.py — host/port/controller_key with auth + reauth steps,
parse_controller_key() extracted as pure testable function
- coordinator.py — OmniDataUpdateCoordinator with long-lived OmniClient,
unsolicited push wiring, ConfigEntryAuthFailed on bad key, reconnect on err
- binary_sensor.py — one entity per named zone, zone_type -> device_class map
(OPENING/MOTION/SMOKE/etc), is_on derived from ZoneProperties.status
- const.py, strings.json, translations/en.json, README.md
- hacs.json at root for HACS distribution
tests: 97 pass + 2 skip (HA harness not installed; importorskip in
test_ha_imports.py). 12 cases for parse_controller_key validation.
Ruff clean across src/ tests/ custom_components/. Status of HA component
itself NOT validated against a running HA — needs that next.