21 Commits

Author SHA1 Message Date
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
290ba5a78d programs: add structured-OP AND decoder properties
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).
2026-05-12 15:35:01 -06:00
e560d98f87 programs: add multi-record decoder properties (firmware >=3.0 records)
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).
2026-05-12 04:57:48 -06:00
61ae95997c programs: fix cond/cond2/pr2 byte order (LE, not BE)
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).
2026-05-12 02:35:03 -06:00
ef7d53c468 programs: decode cond / cond2 into Condition (family + selector + operand)
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.
2026-05-11 22:34:50 -06:00
eb1a632ef2 programs: decode TIMED sunrise/sunset-relative time encoding
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).
2026-05-11 21:38:28 -06:00
00f0028053 pca_file: parse the Remarks table (RemarkID → text resolution)
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).
2026-05-11 21:33:53 -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
0e3835d4ff MockPanel: v1 wire dispatch for hermetic OmniClientV1 tests
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).
2026-05-11 16:32:51 -06:00
92c8b695b4 v1-over-UDP: parallel OmniClientV1 for panels that listen UDP-only
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).
2026-05-11 01:08:01 -06:00
7f82dbbbfa UDP transport: parallel codepath in OmniConnection + MockPanel
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.
2026-05-10 20:42:43 -06:00
df8b6128ea HA test harness + docker dev stack — both proven green
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.
2026-05-10 15:37:48 -06:00
93b7e1f604 Mock: add Thermostat + Button RequestProperties handlers
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.
2026-05-10 15:09:31 -06:00
57b8aa4b04 HA Phase B: alarm + light + switch + climate + sensor + button + event
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/.
2026-05-10 14:59:18 -06:00
e8ed7d1b89 HA Phase A: rebuild coordinator + binary_sensor on v1.0 client + JOURNEY.md
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.
2026-05-10 14:48:50 -06:00
c26db62959 Library v1.0 phase C: stateful mock + e2e for the new surface
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.
2026-05-10 14:28:35 -06:00
68cf44a585 Library v1.0 phase B: command opcodes + typed system events
src/omni_pca/commands.py — Command IntEnum (64 values, sourced from
enuUnitCommand.cs which is the canonical 'enuCommand' despite the misleading
name) + SecurityCommandResponse + CommandFailedError exception. Notable
discovery: enuUnitCommand.UserSetting (104) is actually EXECUTE_PROGRAM;
renamed for clarity, C# alias documented inline.

src/omni_pca/client.py — 18 new methods on OmniClient:
  Core: execute_command, execute_security_command, acknowledge_alerts,
        get_object_status, get_extended_status
  Wrappers: turn_unit_on/off, set_unit_level, bypass_zone, restore_zone,
        set_thermostat_{system,fan,hold}_mode,
        set_thermostat_{heat,cool}_setpoint_raw,
        execute_button, execute_program, show_message, clear_message
  All command methods raise CommandFailedError on Nak.

src/omni_pca/events.py — typed SystemEvents (opcode 55) decoder.
- EventType IntEnum (28 dispatch tags)
- 26 SystemEvent subclasses + UnknownEvent catch-all
  Includes: ZoneStateChanged, UnitStateChanged, ArmingChanged,
  AlarmActivated/Cleared, AcLost/Restored, BatteryLow/Restored,
  PhoneLine{Off,On,Dead,Restored}, UserMacroButton, ProLinkMessage,
  CentraLiteSwitch, X10CodeReceived, AllOnOff, DcmTrouble/Ok,
  EnergyCostChanged, CameraTrigger, AccessReaderEvent, UpbLinkEvent
- SystemEvents packets carry MULTIPLE events; public API is
  parse_events(message) -> list[SystemEvent], plus SystemEvent.parse()
- EventStream helper that flattens batches across messages
- Wiring of OmniClient.events() left for next pass

55 new tests across both files. 194 pass, 2 pre-existing skips. Ruff clean.
2026-05-10 14:17:12 -06:00
08974e2ec4 Models: 16 status/properties dataclasses + enums + temp converters
src/omni_pca/models.py — adds typed parsers for the full Omni object
surface beyond the initial Zone/Unit/Area properties:
- ZoneStatus, UnitStatus, AreaStatus (live state)
- ThermostatProperties, ThermostatStatus (with omni_temp_to_celsius/_fahrenheit
  via clsText.DecodeTempRaw — the scale is LINEAR, not non-linear as I'd
  hypothesized: C = raw/2 - 40)
- ButtonProperties, ProgramProperties, CodeProperties, MessageProperties
- AuxSensorStatus
- AudioZoneProperties + AudioZoneStatus
- AudioSourceProperties + AudioSourceStatus
- UserSettingProperties + UserSettingStatus

Module helpers:
- IntEnums: ObjectType, SecurityMode, HvacMode, FanMode, HoldMode,
  ThermostatKind, ZoneType, UserSettingKind
- OBJECT_TYPE_TO_PROPERTIES / OBJECT_TYPE_TO_STATUS dispatch tables
- omni_temp_to_celsius/_fahrenheit linear conversion (citation: clsText.cs)

42 new tests in tests/test_models_extended.py; 139 total pass.
2026-05-10 14:02:16 -06:00
2e439364bd HA custom_component scaffold (binary_sensor for zones)
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.
2026-05-10 13:09:27 -06:00
1901d6ec87 Async client + mock panel + e2e roundtrip
src/omni_pca/connection.py — low-level OmniConnection
- 4-step secure-session handshake (NewSession, SecureSession)
- Per-direction monotonic seq with 0xFFFF -> 1 wraparound (skips 0)
- TCP framing: read first 16-byte block, decrypt, learn length, read rest
- Reader task dispatches solicited replies to Future, unsolicited to queue
- Custom exceptions: HandshakeError, InvalidEncryptionKeyError, ProtocolError,
  RequestTimeoutError

src/omni_pca/models.py — typed response objects
- SystemInformation (with model_name lookup), SystemStatus, ZoneProperties,
  UnitProperties, AreaProperties — all frozen+slots dataclasses with
  .parse(payload) classmethods

src/omni_pca/client.py — high-level OmniClient
- get_system_information / get_system_status / get_object_properties
- list_{zone,unit,area}_names walks via RequestProperties rel=1
- subscribe(callback) for unsolicited messages

src/omni_pca/mock_panel.py — async TCP server emulating an Omni Pro II
- Full handshake (controller side), seedable MockState
- Implements RequestSystemInformation, RequestSystemStatus,
  RequestProperties (Zone/Unit/Area, both absolute and rel=1 iteration
  with EOD termination); Nak for everything else
- 'omni-pca mock-panel' CLI subcommand

tests/ — 85 passed, 1 skip (live fixture)
- 23 unit tests for connection/models/client (canned-server fixtures)
- 7 unit tests for mock panel (raw protocol drive)
- 6 e2e tests: real OmniClient over real TCP to real MockPanel,
  proves handshake + AES + whitening + sequencing all agree
2026-05-10 13:02:49 -06:00
9a024181ae Initial scaffold + protocol primitives
Project scaffold (uv, pyproject.toml CalVer 2026.5.10, ruff, pytest, mypy
strict config, MIT, README, .gitignore protecting any .pca / panel keys).

Library primitives (src/omni_pca/):
- crypto.py     AES-128-ECB + per-block XOR seq pre-whitening, session-key
                derivation (CK[0:11] || (CK[11:16] XOR SessionID))
- opcodes.py    Byte-exact PacketType (12), v1 MessageType (104),
                v2 MessageType (83), ConnectionType, ProtocolVersion
- packet.py     Outer Packet dataclass with encode/decode
- message.py    Inner Message + CRC-16/MODBUS, helpers for v1/v2
- pca_file.py   Borland LCG XOR cipher, PcaReader, .pca + .CFG parsers
                (KEY_PC01 = 0x14326573, KEY_EXPORT = 0x17569237 — fixed
                from initial typo; verified via test_keys_match_decompiled)
- __main__.py   CLI: 'omni-pca decode-pca <file> --field {host,port,...}'
                PII opt-in via --include-pii

49 tests pass, 1 skipped (live fixture). Ruff clean.
2026-05-10 12:46:26 -06:00