16 Commits

Author SHA1 Message Date
9cdb312baf program writeback: DownloadProgram wire + HA write API + Clear/Clone UI
Some checks are pending
Validate / HACS validation (push) Waiting to run
Validate / Hassfest (push) Waiting to run
The program viewer goes from read-only to write-capable. Three layers
land together because a partial implementation isn't actionable.

D1 — wire path:

* OmniClient.download_program(slot, program) — sends opcode 8
  (clsOLMsg2DownloadProgram, clsHAC.cs:1133-1140) with the 2-byte BE
  slot + Program.encode_wire_bytes(). Validates slot range 1..1500
  client-side. Maps Ack → success, Nak → CommandFailedError, any
  other opcode → OmniConnectionError.
* OmniClient.clear_program(slot) — convenience that writes an all-zero
  body. Mock treats this as deletion (removes the slot from
  state.programs) so subsequent reads see it as undefined.
* MockPanel handles DownloadProgram on the v2 dispatch path —
  receive 2-byte slot + 14-byte body, store in state.programs, ack.
* OmniClientV1.download_program raises NotImplementedError. v1 only
  has the bulk DownloadPrograms flow which clears everything before
  rewriting — destructive for HA's edit-one-program use case.
  Documented in the docstring so callers know to route v1 users to
  a v2 connection.

Tests cover: write-then-read round-trip, overwrite of existing slot,
clear deletes the slot, range validation, v1 not-implemented.

D2 — HA websocket commands:

* omni_pca/programs/clear — writes zero body, updates coordinator.
  data.programs immediately so the next list call shows the deletion.
  Returns ``{slot, cleared: true}``. Maps NotImplementedError on v1
  panels to the ``not_supported`` error code.
* omni_pca/programs/clone — copies source_slot → target_slot, with
  the slot field re-stamped. Refuses identical source/target,
  refuses missing source. Same coordinator update pattern.

5 new HA-integration tests covering clear, clone happy path, clone
to same slot, clone from missing source.

D3 — Clear/Clone UI in the side panel:

* "Clone…" button reveals an inline target-slot input (number,
  1..1500). Enter or "Clone" button calls the WS command, then
  navigates the detail panel to the new clone so the user sees the
  result.
* "Clear" button shows an inline confirmation row ("Clear slot N?
  This deletes the program from the panel.") with Yes/Cancel. Yes
  closes the detail panel and refreshes the list — the slot is gone.
* Both surface feedback via the same _writeFeedback state used by
  Fire now (auto-clears after 4 seconds).
* Three new button styles (.primary, .secondary, .danger) and the
  .action-row composite used for both inline prompts.

What's NOT shipped here: a real visual editor for trigger/condition/
action fields. That's a follow-up (~600 lines of new TS + careful
validation work). The current "Cut 1" UX is enough for the common
"I accidentally created a program, clear it" and "I want a variant
of this program, give me a copy in an empty slot" workflows.

Full suite: 643 passed, 1 skipped (up from 634).
Frontend bundle: 38 KB minified (up from 34 KB with the write UI).
2026-05-16 01:14:54 -06:00
8250df0206 pca_file: TimeAdj, AlarmResetTime, ArmingConfirmation, TwoWayAudio
Four more scalars sandwiched around the thermostat arrays
(clsHAC.cs:3303-3321):

  3394: TimeAdj             — daily clock-drift adjust minute (1..59, default 30)
  3395: AlarmResetTime      — alarm-clear retry delay (1..30 default 6; 30..60/30 on EURO)
  3396: ArmingConfirmation  — beep on successful arm (bool)
  3461: TwoWayAudio         — central-station 2-way audio on alarm (bool)

3461 sits right after Thermostat.Type[1..64] @ 3397..3460.

Live fixture: time_adj=30 (panel default), alarm_reset_time=4,
arming_confirmation=False, two_way_audio=False — coherent
plain-vanilla home-install values.

Full suite: 499 passed, 1 skipped.
2026-05-14 01:10:33 -06:00
c7eb92122b pca_file: ZoneOptions + thermostat type/areas — per-object props done
Closes out the per-object property triad. These three fields live
deep in the installer section past the zone-area / button-area-group
arrays (clsHAC.cs:3290-3416):

  3330..3393: Thermostats[1..64].Areas  (area-membership bitmask)
  3397..3460: Thermostats[1..64].Type   (raw enuThermostatType)
  3553..3728: Zones[1..176].ZoneOptions (raw options byte)

Offsets derived from the OMNI_PRO_II CAP constants (numConsoles=16,
numTstats=64, numDCMCodes=16, numMessageGroups=16, numSerialPorts=6,
numSCI+numUART=5) plus its feature set — SuperviseBell +
SuperviseExteriorSounder + ZoneResistors + Addressable + UPB all
present, contributing exactly 9 conditional bytes before
ReportBypassRestore. Verified empirically: ZoneOptions is a clean
176-byte run of the default value 4, bounded by CrossZoneTimer=60
as the canary byte just before it.

New PcaAccount fields: zone_options, thermostat_types,
thermostat_areas. MockZoneState gains `options`, MockThermostatState
gains `thermostat_type` + `areas`. The mock's zone and thermostat
Properties replies now serve the real values instead of the
hardcoded 0 / 1 they used before — so HA discovery against
MockState.from_pca gets the complete per-object property set.

Live fixture: all 176 zones at the default options=4, both named
thermostats type 1, thermostat areas 0xFF (all) → normalised to
area 1 in the mock (consistent with the unit-area handling).

With this the OMNI_PRO_II SetupData decode is functionally complete
for every per-object property a consumer would want — zones, units,
areas, thermostats all carry type + area + options sourced from the
file rather than faked.

Full suite: 499 passed, 1 skipped.
2026-05-13 23:33:37 -06:00
e61e37a3fc pca_file: finish SetupData — telephony, misc scalars, DCM block
Final SetupData sweep. Everything still walked-but-discarded is now
captured.

Telephony / dialer (user-section head + installer):
  telephone_access, answer_outside_call, remote_commands_ok,
  rings_before_answer, dial_mode, my_phone_number (PII, repr=False),
  callback_number.

Misc panel scalars:
  high_security, freeze_alarm, flash_light_num (BE u16 — the X10 unit
  flashed on alarm), announce_alarms, house_code, zone_expansions,
  num_exp_enc, num_thermostats, exterior_horn_delay, dialout_delay,
  verify_fire_alarms, enable_console_emg, time_format, date_format,
  ac_power_freq, dead_line_detect, off_hook_detect.

DCM (Digital Communicator Module) — the alarm-dialer block — as a
new DcmConfig dataclass: primary/backup phone numbers (PII,
repr=False) + account IDs, dcm_type, supervisory test schedule +
code, the 176-entry per-zone alarm-code table, and the 8 emergency
event codes (Freeze/Fire/Police/Aux/Duress/BatteryLow/FireZone/Cancel).

The phone-number strings use clsHardwareArray.ReadString — a
fixed-width MaxLength+1 slot whose content runs until the first 0xFF,
with no length prefix (distinct from the String8 used by Names).
New _read_hw_string helper handles that format. "-" is the panel's
blank-number sentinel.

Live fixture decodes coherently: telephone access on, 8 rings before
answer, panel's own number "208-854-7071", HouseCode A, 64
thermostats, fire-alarm verification on. DCM is unconfigured for
central-station monitoring (blank "-" numbers, 0xAAAA default
account IDs) but the per-zone alarm-code table is fully populated.

With this, the OMNI_PRO_II SetupData block is essentially fully
decoded — every field clsHAC._ParseSetupData reads up through the
zone-area / button-area-group arrays is now surfaced on PcaAccount.

Full suite: 499 passed, 1 skipped.
2026-05-13 23:13:27 -06:00
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