From 9dbe563aedbd81f4dbaf89280ad08238ffd8a762 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sun, 10 May 2026 17:05:25 -0600 Subject: [PATCH] Content + docker pin: 13-page Starlight site live behind Caddy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/content/docs/ — twelve pages totalling ~18,800 words, ported from the omni-pca repo's docs and reference material: index.mdx (377 w) landing page with three CardGrid links start/quickstart.md (572 w) three flows: decode .pca / talk to panel / install in HA reference/protocol.md (2525 w) byte-level Omni-Link II spec, full packet+message layouts, the two non-public quirks, opcode tables reference/file-format.md (1593 w) XOR-LCG cipher, key derivation, PCA01.CFG schema, .pca PCA03 header reference/library-api.md (1170 w) module-by-module Python API summary reference/ha-entities.md (1070 w) per-platform entity catalogue reference/ha-services.md (567 w) seven services + automation YAML explanation/quirks.md (1448 w) the headline RE essay — session-key XOR mix + per-block whitening, why no public client documents them explanation/architecture.md (1123 w) library + HA + mock + tests explanation/pc-access-bug.md (1131 w) LargeVocabulary off-by-N journey.md (6194 w) chronological retrospective ported from omni-pca/docs/JOURNEY.md changelog.md (1213 w) full 2026.5.10 release notes Dockerfile — pinned node:lts-alpine and caddy:latest (registry-1 .docker.io was returning 'tls: internal error' on node:25-alpine and caddy:2-alpine pulls; the pinned tags are cached locally and work). TODO comment notes to bump back to node:25 once registry stabilises. .gitignore — added .env / .env.local just in case. Build: 13 pages built clean in 1.83s, sitemap + Pagefind search index emitted. Container runs at hai-omni-docs-docs (caddy network), accepts requests with Host: hai-omni-pro-ii.warehack.ing, returns rendered Starlight HTML with title/description meta intact. Once DNS for hai-omni-pro-ii.warehack.ing points at the host, the site is live. --- .gitignore | 2 + Dockerfile | 6 +- src/content/docs/changelog.md | 197 +++- src/content/docs/explanation/architecture.md | 189 +++- src/content/docs/explanation/pc-access-bug.md | 161 +++- src/content/docs/explanation/quirks.md | 209 +++- src/content/docs/journey.md | 903 +++++++++++++++++- src/content/docs/reference/file-format.md | 302 +++++- src/content/docs/reference/ha-entities.md | 206 +++- src/content/docs/reference/ha-services.md | 158 ++- src/content/docs/reference/library-api.md | 225 ++++- src/content/docs/reference/protocol.md | 345 ++++++- 12 files changed, 2874 insertions(+), 29 deletions(-) diff --git a/.gitignore b/.gitignore index f5dd383..615fec6 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ pnpm-debug.log* # macOS-specific files .DS_Store +.env +.env.local diff --git a/Dockerfile b/Dockerfile index a2b9729..6d899f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,9 @@ # syntax=docker/dockerfile:1.7 # ---- builder ---- -FROM node:25-alpine AS builder +# Pinned to lts-alpine so it works against Docker Hub's cached image set. +# Bump to node:25-alpine when 25 stabilises in the registry. +FROM node:lts-alpine AS builder ENV ASTRO_TELEMETRY_DISABLED=1 \ NODE_ENV=production \ @@ -19,7 +21,7 @@ COPY . . RUN npm run build # ---- runtime ---- -FROM caddy:2-alpine AS runtime +FROM caddy:latest AS runtime # Run as non-root. RUN addgroup -S docs && adduser -S -G docs docs diff --git a/src/content/docs/changelog.md b/src/content/docs/changelog.md index 6c0314c..092039e 100644 --- a/src/content/docs/changelog.md +++ b/src/content/docs/changelog.md @@ -1,6 +1,199 @@ --- title: Changelog -description: Notable changes to omni-pca. +description: Notable changes to omni-pca. Date-based versioning (CalVer); each release date corresponds to a backwards-incompatible boundary. --- -TODO: filled by parallel agent. +All notable changes to this project. Date-based versioning +([CalVer](https://calver.org/), `YYYY.M.D`); each release date corresponds +to a backwards-incompatible boundary. + +## [2026.5.10] — 2026-05-10 + +First release. Working library + Home Assistant custom component, validated +end-to-end against an in-process mock panel and a real HA instance running +in Docker. Not yet validated against a live panel because the user's +panel's network module is currently off. + +### Protocol layer (the reverse engineering) + +- Decompiled HAI's PC Access 3.17 (.NET) with ilspycmd; identified two + namespaces — `HAI_Shared` (protocol/crypto/domain) and `PCAccess3` (UI). + Decompilation lives in `pca-re/decompiled/`. +- Reverse-engineered the `.pca` and `PCA01.CFG` file format — Borland-Pascal + LCG keystream XORed byte-by-byte. Two hardcoded keys: + - `KEY_PC01 = 0x14326573` for `PCA01.CFG` + - `KEY_EXPORT = 0x17569237` for import/export `.pca` + Per-installation `.pca` files use a third key derived from the panel's + installer code; that key is stored in plaintext inside `PCA01.CFG` after + first-stage decryption. +- Documented the Omni-Link II wire protocol byte-for-byte (see [the + protocol reference](/reference/protocol/)), including **two non-public + quirks** absent from `jomnilinkII`, `pyomnilink`, and every public + Omni-Link writeup we found: + 1. **Session key = `ControllerKey[0:11] || (ControllerKey[11:16] XOR SessionID[0:5])`** + — not just the panel's ControllerKey directly. Source: + `clsOmniLinkConnection.cs:1886-1892`. + 2. **Per-block XOR pre-whitening before AES** — first two bytes of every + 16-byte block are XORed with the packet's 16-bit sequence number, same + mask all blocks. Source: `clsOmniLinkConnection.cs:396-401`. +- Located a latent bug in PC Access itself: a `LargeVocabulary` skip-path + uses a buffer sized for the non-LargeVocabulary case. Harmless on every + shipping panel (the count check always satisfies the constraint) but + documented in [the PC Access bug + explainer](/explanation/pc-access-bug/). + +### Library — `omni_pca` + +- `crypto.py` — AES-128-ECB with PaddingMode.Zeros semantics, + `derive_session_key()`, per-block XOR pre-whitening, + `encrypt_message_payload()` / `decrypt_message_payload()`. All citations + to C# source line numbers. +- `opcodes.py` — Three IntEnums byte-exact to the C# decompilation: + `PacketType` (12 values), `OmniLinkMessageType` (104 v1 opcodes), + `OmniLink2MessageType` (83 v2 opcodes). Plus `ConnectionType`, + `ProtocolVersion`. +- `packet.py` / `message.py` — Outer `Packet` (4-byte header + payload) and + inner `Message` framing. CRC-16/MODBUS (poly `0xA001`). +- `pca_file.py` — Borland LCG XOR cipher, `PcaReader` with + `u8/u16/u32/string8/string8_fixed/string16/string16_fixed`, + `parse_pca01_cfg()`, `parse_pca_file()`. Account-info fields default + `repr=False` to avoid accidental PII leakage in logs. +- `connection.py` — `OmniConnection`: async TCP, full secure-session + handshake (4 packets), monotonic per-direction sequence numbers with + `0xFFFF → 1` wraparound (skips 0), TCP framing that decrypts the first + 16-byte block to learn the inner message length, reader task dispatching + solicited replies to Futures and unsolicited messages to a queue, + automatic reconnect on `OmniConnectionError`, custom exceptions + (`HandshakeError`, `InvalidEncryptionKeyError`, `ProtocolError`, + `RequestTimeoutError`). +- `models.py` — 21 typed frozen-slots dataclasses for every Omni object: + `SystemInformation`, `SystemStatus`, `ZoneProperties/Status`, + `UnitProperties/Status`, `AreaProperties/Status`, + `ThermostatProperties/Status`, `ButtonProperties`, `ProgramProperties`, + `CodeProperties`, `MessageProperties`, `AuxSensorStatus`, + `AudioZoneProperties/Status`, `AudioSourceProperties/Status`, + `UserSettingProperties/Status`. Plus `SecurityMode`, `HvacMode`, + `FanMode`, `HoldMode`, `ZoneType`, `ObjectType` enums and temperature + converters (Omni's linear `°F = round(raw * 9/10) - 40`). +- `commands.py` — `Command` IntEnum (64 values, sourced from + `enuUnitCommand.cs` which is the canonical command enum despite the + misleading name), `SecurityCommandResponse`, `CommandFailedError`. +- `client.py` — High-level `OmniClient` with 18 methods: + `get_system_information`, `get_system_status`, `get_object_properties`, + `list_*_names`, `execute_security_command`, `execute_command`, + `get_object_status`, `get_extended_status`, `acknowledge_alerts`, typed + 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`), `events()` async + iterator over typed `SystemEvent` objects. +- `events.py` — `SystemEvent` hierarchy. 26 typed subclasses + (`ZoneStateChanged`, `UnitStateChanged`, `ArmingChanged`, + `AlarmActivated/Cleared`, `AcLost/Restored`, `BatteryLow/Restored`, + `UserMacroButton`, `PhoneLineDead/Restored`, …) + `UnknownEvent` + catch-all. SystemEvents (opcode 55) packets carry multiple events; + `parse_events()` returns a list. `EventStream` flattens batches across + messages. +- `mock_panel.py` — Stateful async TCP server emulating an Omni Pro II + controller. Handles handshake, `RequestSystemInformation/Status`, + `RequestProperties` for Zone/Unit/Area/Thermostat/Button, + `RequestStatus`/`RequestExtendedStatus`, `Command`, + `ExecuteSecurityCommand`, `AcknowledgeAlerts`. State changes push + synthesized `SystemEvents` packets back to the client. +- `__main__.py` — CLI: `omni-pca decode-pca [--field controller_key|host|port] [--include-pii]`, + `omni-pca mock-panel`, `omni-pca version`. PII opt-in. + +### Home Assistant integration — `custom_components/omni_pca/` + +- `coordinator.py` — `OmniDataUpdateCoordinator` with long-lived + `OmniClient`, one-time discovery pass at first refresh (enumerates zones, + units, areas, thermostats, buttons), periodic 30s poll for live state, + background event-listener task consuming `client.events()` and patching + state in-place on each push. `ConfigEntryAuthFailed` on + `InvalidEncryptionKeyError` triggers HA's reauth flow. +- Eight platforms wrapping the library client: + - `alarm_control_panel` — one per area, supports + Day/Night/Away/Vacation/DayInstant arm modes with code validation + - `binary_sensor` — one per binary zone (state + bypass diagnostic) plus + 3 system-level (AC, battery, trouble) + - `button` — one per panel button macro + - `climate` — one per thermostat (OFF/HEAT/COOL/HEAT_COOL + fan + preset + modes) + - `event` — one per panel, relays 12 typed event types to HA automations + - `light` — one per unit (dimmable; non-dimmable relays silently ignore + brightness) + - `sensor` — analog zones (temperature/humidity/power), per-thermostat + diagnostic temp/humidity/outdoor sensors, panel model+firmware sensor, + last-event sensor + - `switch` — per-zone bypass control (config entity_category) +- `config_flow.py` — User + reauth steps. Host/port/controller_key with hex + validation. Probes the panel via `OmniClient.get_system_information()` + before creating the entry; surfaces auth/cannot_connect errors with + HA-friendly strings. +- `services.yaml` + `services.py` — 7 services (`bypass_zone`, + `restore_zone`, `execute_program`, `show_message`, `clear_message`, + `acknowledge_alerts`, `send_command`). Idempotent registration; each + takes a `config_entry` selector so users pick the panel. +- `diagnostics.py` — Snapshot dump with controller key redacted and + zone/unit/area names sha256-hashed. +- `helpers.py` — Pure functions for everything HA-shape-dependent: + zone-type→device-class, brightness conversion, HVAC mode round-trip, + temperature inverse, alarm state translation, event-type strings. No + `homeassistant.*` imports; 61 unit tests covering it. +- `manifest.json` — `iot_class: local_push`, `version: 2026.5.10`, + `config_flow: true`, requires `omni-pca==2026.5.10`. +- `hacs.json` at project root for HACS distribution. + +### Tests + +- **351 passing, 1 skipped.** Ruff clean across `src/`, `tests/`, + `custom_components/`. +- 17 e2e tests connecting `OmniClient` to `MockPanel` over real TCP, + proving the full handshake + encryption + framing stack roundtrips. +- 12 HA-side integration tests using `pytest-homeassistant-custom-component` + — boot HA in-process, drive the config flow, exercise services, verify + state mutations. Full HA-side suite runs in <1 second. +- 61 unit tests on `custom_components/omni_pca/helpers.py` running without + HA installed. +- Unit tests for every library module (crypto KAT vectors, CRC-16, + packet/message ser-de, .pca decrypt, command payloads, event parsing). + +### Developer tooling + +- `dev/docker-compose.yml` + `dev/Makefile` — One-command HA + MockPanel + stack for manual smoke testing and screenshot capture. +- `dev/run_mock_panel.py` — Long-running mock seeded with 5 zones, 4 units, + 2 areas, 2 thermostats, 3 buttons, 2 user codes. +- `dev/screenshot.py` — End-to-end automated demo: onboards HA via REST, + adds the integration via config-flow API, drives headless chromium via + playwright to capture six deep-linked PNGs (overview, integrations list, + integration detail, device page, entities table, developer states). + +### Documentation + +- [The Journey](/journey/) — chronological retrospective of the + reverse-engineering work. +- [Protocol reference](/reference/protocol/) — byte-level handshake spec + with C# source line citations. +- [File format reference](/reference/file-format/) — `.pca` body schema + and the LargeVocabulary latent bug. +- [Library API](/reference/library-api/) — module surface for `omni_pca`. +- [HA entities](/reference/ha-entities/) and [HA services](/reference/ha-services/). +- [Quirks explainer](/explanation/quirks/) and [architecture + overview](/explanation/architecture/). + +### Known gaps + +- **Live panel validation**: blocked on the user's panel's Ethernet module + being enabled. Mock panel proves the stack roundtrips; the live lap is + one TCP connect away once the panel is reachable. +- **Programs discovery**: the library's v1.0 has no `RequestProperties` + path for Program objects; the HA coordinator returns an empty programs + dict. Programs can still be executed by index via the + `omni_pca.execute_program` service. +- **PyPI publish**: `omni-pca` not yet on PyPI; HA `manifest.json` + requirements line will only resolve once it is. For now users either + install the wheel manually or pip-install from a Git URL. +- **HACS submission**: pending live-panel validation. + +[2026.5.10]: https://github.com/rsp2k/omni-pca/releases/tag/v2026.5.10 diff --git a/src/content/docs/explanation/architecture.md b/src/content/docs/explanation/architecture.md index d136ead..e031219 100644 --- a/src/content/docs/explanation/architecture.md +++ b/src/content/docs/explanation/architecture.md @@ -1,6 +1,189 @@ --- -title: Architecture -description: How the library, the integration, and the panel fit together. +title: Architecture overview +description: How the library, the Home Assistant integration, the mock panel, and the test stack fit together. --- -TODO: filled by parallel agent. +The project has four moving parts: + +1. The Python library (`omni_pca`) — protocol, client, models. +2. The Home Assistant custom component (`custom_components/omni_pca/`) — eight + entity platforms on top of the library. +3. The mock panel (`omni_pca.mock_panel`) — a controller-side emulator that + speaks the same protocol as a real panel. +4. The test harness — pytest suites that exercise (1) against (3) over real + TCP, and (2) against (3) inside a real in-process Home Assistant. + +Plus a docker dev stack that wires HA + mock together for manual smoke +testing and screenshot capture. + +## The library + +```text +omni_pca/ +├── crypto.py AES-128-ECB + per-block XOR pre-whitening + SessionKey derivation +├── opcodes.py PacketType, OmniLinkMessageType, OmniLink2MessageType IntEnums +├── packet.py Outer 4-byte-header + payload framing +├── message.py Inner Message + CRC-16/MODBUS +├── connection.py OmniConnection: async TCP + handshake + reader task +├── client.py OmniClient: typed methods on top of OmniConnection +├── commands.py Command IntEnum + CommandFailedError +├── events.py SystemEvent hierarchy + EventStream iterator +├── models.py 21 frozen-slots dataclasses for every panel object +├── pca_file.py Borland LCG cipher + .pca / .CFG parsers +├── mock_panel.py Stateful controller-side emulator +└── __main__.py omni-pca CLI: decode-pca, mock-panel, version +``` + +Three layers, each shorter than the next: + +- **Protocol layer** — `crypto`, `packet`, `message`, `opcodes`. Pure + byte-mangling. No I/O. +- **Connection layer** — `connection`. Async TCP, secure-session handshake, + per-direction sequence numbers, reader task that dispatches solicited + replies to Futures and unsolicited messages to a queue. +- **Client layer** — `client`, `commands`, `events`, `models`. Typed methods, + parsed dataclasses, typed event stream. + +`OmniClient` is the surface most users want. `OmniConnection` is exposed for +power users who need raw `Message` round-trips. The protocol layer is +exposed because it's useful for testing. + +## The HA integration + +```text +custom_components/omni_pca/ +├── __init__.py setup_entry / unload_entry +├── manifest.json iot_class: local_push, requires omni-pca==2026.5.10 +├── coordinator.py OmniDataUpdateCoordinator: long-lived OmniClient + event listener +├── config_flow.py User + reauth flows (host/port/key, hex validation) +├── helpers.py Pure functions for everything HA-shape-dependent +├── services.py Idempotent service registration + voluptuous schemas +├── services.yaml UI-side service descriptions +├── diagnostics.py Redacted snapshot dump for bug reports +├── alarm_control_panel.py +├── binary_sensor.py +├── button.py +├── climate.py +├── event.py +├── light.py +├── sensor.py +└── switch.py +``` + +`OmniDataUpdateCoordinator` keeps a single long-lived `OmniClient`, runs a +one-time discovery pass at first refresh (enumerates zones, units, areas, +thermostats, buttons), and starts a background task consuming +`client.events()`. Every push event mutates the in-memory state dict and +calls `async_set_updated_data()`, which fans out to the entity platforms. +A 30-second poll backstops anything that didn't push. + +The eight entity platforms are thin: each constructs entities from the +coordinator's discovered objects and reads state from the live state dict. +Service handlers in `services.py` translate HA service calls into client +method calls. + +`helpers.py` is a strict no-HA-imports zone. Every translation between +Omni's wire encoding and HA's UI encoding (zone-type → device-class, +brightness conversion, HVAC mode round-trip, alarm state) lives there as +a pure function. 61 unit tests cover it; they run in <100ms because they +don't have to boot HA. + +## The mock panel + +```text +omni_pca/mock_panel.py + ├── MockUnitState, MockAreaState, MockZoneState, MockThermostatState + ├── user_codes table for security validation + ├── handshake handler (same crypto as the client) + ├── opcode handlers: + │ RequestSystemInformation / SystemStatus + │ RequestProperties (Zone/Unit/Area/Thermostat/Button) + │ RequestStatus / RequestExtendedStatus + │ Command (with state mutation) + │ ExecuteSecurityCommand (with code validation) + │ AcknowledgeAlerts + └── synthesised SystemEvents (opcode 55) on every state change +``` + +The mock is a TCP server that runs the controller half of the protocol. +Same handshake, same key derivation, same per-block XOR pre-whitening, same +CRC, same opcodes. State changes push synthesised `SystemEvents` packets +back to the client with `seq=0` (the unsolicited semantics). + +The point is not to be a complete production simulator (it is not — many +opcodes are stubbed or unimplemented). The point is to be a +*bidirectionally faithful* protocol counterpart for the surface the library +actually uses, so the test suite can prove the stack roundtrips without +needing real hardware. + +## The HA test harness + +`pytest-homeassistant-custom-component` runs a real Home Assistant +in-process per test. Tests boot HA, run the omni_pca config flow against +the mock panel, exercise services, and assert on entity state. The full +HA-side suite is 12 tests and runs in 0.74 seconds. + +The combined test surface: + +- **Library unit tests** — crypto KAT vectors, CRC, packet/message ser-de, + `.pca` decrypt, command payloads, event parsing. +- **Library e2e tests (17)** — `OmniClient` ↔ `MockPanel` over real TCP. + Proves the handshake, encryption, framing, and sequencing all agree + bidirectionally. +- **HA helpers unit tests (61)** — pure-function translations, no HA + imports. +- **HA-side integration tests (12)** — real in-process HA driving the + integration against `MockPanel`. + +351 tests pass, 1 skipped (a gitignored `.pca` fixture). Ruff clean. + +## The dev docker stack + +```text +dev/ +├── docker-compose.yml HA 2026.5 + MockPanel sidecar +├── Makefile make dev-up / dev-down / dev-logs / dev-reset +├── run_mock_panel.py Long-running mock seeded with realistic data +└── screenshot.py Onboard HA via REST + drive playwright for screenshots +``` + +`make dev-up` brings up real Home Assistant in a container with the +integration mounted read-only, plus a sidecar running the mock panel on +port 14369. `screenshot.py` POSTs to HA's onboarding API, runs the +config flow via REST, and uses headless playwright to deep-link six pages +for the README screenshots. The whole flow is 100% scripted; no manual +clicking. + +## Request lifecycle + +What happens when a user toggles a light in the HA UI: + +```text +1. HA UI emits light.turn_on(entity_id="light.front_porch", brightness=128) +2. light.py OmniLight.async_turn_on() called +3. helpers.py ha_brightness_to_omni_percent(128) → 50 +4. coordinator await client.set_unit_level(unit_index=12, percent=50) +5. client.py execute_command(Command.UNIT_LEVEL, parameter1=50, parameter2=12) +6. client.py build payload [0x09, 0x32, 0x00, 0x0C] +7. connection.py wrap as inner Message (StartChar + length + payload + CRC) +8. crypto.py zero-pad to 16, XOR-whiten with seq, AES-encrypt +9. connection.py frame as 4-byte header + ciphertext, asyncio.write +10. -- TCP --> to MockPanel (or real panel) at 192.168.1.9:4369 +11. mock_panel.py read header + first 16 bytes, AES-decrypt, peek MessageLength +12. mock_panel.py read rest, AES-decrypt + un-whiten, parse Command +13. mock_panel.py mutate MockUnitState[12].state = 150 (=100+50) +14. mock_panel.py send Ack reply on the same seq number +15. mock_panel.py synthesise SystemEvents (opcode 55) with UnitStateChanged +16. -- TCP <-- to client +17. connection.py reader task: classify Ack as solicited → resolve Future for step (4) +18. connection.py reader task: classify SystemEvents as unsolicited → push to queue +19. events.py EventStream.__anext__() yields UnitStateChanged +20. coordinator background task receives event, patches state dict +21. coordinator async_set_updated_data() fires +22. light.py OmniLight._handle_coordinator_update() reads new state +23. HA UI re-renders the light card +``` + +Steps 4-13 happen in <50ms over a real TCP socket. Steps 15-22 happen in a +similar window. The user sees the light card update on the UI essentially +immediately. diff --git a/src/content/docs/explanation/pc-access-bug.md b/src/content/docs/explanation/pc-access-bug.md index 561b58e..ce8e151 100644 --- a/src/content/docs/explanation/pc-access-bug.md +++ b/src/content/docs/explanation/pc-access-bug.md @@ -1,6 +1,161 @@ --- -title: The PC Access bug -description: A latent defect in HAI's official client and why it matters. +title: The PC Access LargeVocabulary bug +description: A latent off-by-N defect in HAI's own .pca parser, harmless on every shipping panel except one corner case. --- -TODO: filled by parallel agent. +While reverse-engineering the `.pca` body parser we hit a desync: our parser +walked off the rails by exactly 6684 bytes between the Voices blocks and the +Connection block. The cause turned out to be a real bug in PC Access itself — +a copy-paste mistake in the C# that's been shipping for at least a decade. +It's harmless on every panel currently in deployment, except for one corner +case nobody seems to have hit. We documented it here because if you write +your own `.pca` parser, you have to replicate the asymmetry to stay aligned. + +## What the LargeVocabulary feature is + +The Omni panel can be programmed to *speak* the names of objects when +events happen — "front door opened", "alarm activated in main area". Each +nameable object (zone, unit, area, thermostat, button, message) gets up to +six "voice phrases" associated with it. The phrases are stored on the panel +as an integer index into the panel's vocabulary table. + +Two flavours: + +- **Small vocabulary** — vocabulary index fits in one byte. Six phrases per + object → 6 bytes per object. +- **Large vocabulary** — vocabulary index needs two bytes (`UInt16`). Six + phrases per object → **12 bytes per object**. + +OMNI_PRO_II has the LargeVocabulary feature. So a real Omni Pro II `.pca` +file stores 12 bytes per voice slot, not 6. + +## The mismatch + +Each voice block in `.pca` is read by a loop like this (paraphrased from +`clsHAC.cs`): + +```csharp +byte[] B = new byte[CAP.numVoicePhrases]; // 6 bytes +for (int i = 1; i <= GetFileMaxX(); i++) { + num = (i > Count) + ? num + FS.ReadByteArray(out B, B.Length) // skip path: 6 bytes + : num + _Items[i-1].Voice.Read(FS); // structured path +} +``` + +The **structured path** calls `clsVoiceWordArray.Read`, which branches on +LargeVocabulary: + +- LargeVocabulary present → 6 phrases × **2 bytes** = **12 bytes** +- LargeVocabulary absent → 6 phrases × 1 byte = 6 bytes + +But the **skip path** in the loop above always reads 6 bytes from the buffer +`B = new byte[CAP.numVoicePhrases]`, no matter what. There is no +`if (LargeVocabulary) B = new byte[12];` next to it. + +So when LargeVocabulary is on: + +- For slots `i <= Count` (defined): the structured path reads 12 bytes. +- For slots `i > Count` (undefined / padding): the skip path reads 6 bytes. + +The mismatch is silent unless `Count != GetFileMaxX()`. If every slot is +filled (`Count == Max`), the skip path is never taken and the bug never +fires. + +## Why it's harmless in deployment + +For nearly every block on a shipping OMNI_PRO_II panel, `Count == Max`: + +| Items | `Count` | `GetFileMaxX` | structured slots | skip slots | +|---|---:|---:|---:|---:| +| Zones | 176 | 176 | 176 | 0 | +| **Units** | **511** | **512** | **511** | **1** | +| Buttons | 255 | 128 | 128 | 0 | +| Codes | 99 | 99 | 99 | 0 | +| Thermostats | 64 | 64 | 64 | 0 | +| Areas | 8 | 8 | 8 | 0 | +| Messages | 128 | 128 | 128 | 0 | + +Only **Units** has the asymmetry, and only by exactly one slot. That single +skipped slot reads 6 bytes from a buffer where 12 bytes were written. The +6 bytes that should have been the second half of the last unit's voice +slot get treated as the start of the next block, and the parser walks 6 +bytes off the rails. + +The C# code in the wild gets away with this on Units specifically because +nothing downstream cares about the contents of the over-skipped block — the +parser is just trying to count bytes to advance past Voices and reach the +next field. PC Access doesn't actually use the voice data on the skipped +slot for anything visible. So the bug is structurally present but +operationally invisible. + +The real concern is hypothetical: if a future model ever shipped with +LargeVocabulary AND `Count < Max` for any of Buttons / Messages / something +that *does* get used downstream, the same off-by-N would silently misparse +the file from that point on. PC Access would behave correctly today, on +every panel currently in deployment. But the failure mode lives in the +code, waiting. + +## How we found it + +Backwards. The `.pca` plaintext had the panel's IP address (`192.168.1.9`) +embedded somewhere in the body. A hex search for the ASCII bytes +`31 39 32 2e 31 36 38 2e 31 2e 39` found them at file offset `0xe2d8` +(58072 decimal). Our parser, using a 6-byte voice slot, was landing at a +different offset. + +```text +expected (parser) offset: 64756 +actual offset of IP: 58072 +diff: 6684 bytes +``` + +`6684 = (512 - 1) * 6 + 6 = (511 * 6) + 6`. That's `511 * 6` bytes of voice +slots read at half the right size, plus the one extra skip-path slot also +read at the wrong size. The arithmetic matched too cleanly to be +coincidence — somewhere our parser was reading 6 bytes per Units voice slot +when it should have been reading 12. + +Cross-referencing `clsVoiceWordArray.Read` against the Voices loop in +`clsHAC.ReadFromFile` made the asymmetry obvious. The structured path +already knew about LargeVocabulary; the skip path didn't. + +We patched our parser: + +```python +def voices_block_size(count: int, max_slots: int, large_vocab: bool) -> int: + structured = min(count, max_slots) * (12 if large_vocab else 6) + skipped = max(0, max_slots - count) * 6 # always 6, mirroring the C# bug + return structured + skipped +``` + +That formula matches the C#'s observed behaviour exactly. With it, our +parser lands on `0xe2d8` and the Connection block parses cleanly. + +## Lesson + +Latent bugs in shipping software can survive a decade because nobody runs +the unhappy path. The PC Access voice-block code has been wrong since +LargeVocabulary was added — quite a long time ago — and shipped to every +Omni Pro II customer. It hasn't caused a visible failure because the only +block where the asymmetry triggers is the one block where it doesn't matter +downstream. + +The fix in HAI's source is one line: + +```csharp +byte[] B = new byte[CAP.numVoicePhrases * (LargeVocabulary ? 2 : 1)]; +``` + +But we don't get to make it. So our parser keeps the wrong-sized skip +buffer for byte-count parity with the file, and our docs document that +parity so the next person looking at this file format doesn't lose half +an afternoon to it. + +## See also + +- [File format reference](/reference/file-format/) — the byte-level layout + of `.pca`, with the LargeVocabulary slot size correctly applied to the + Units Voices block. +- [The Journey](/journey/#2026-05-10---the-latent-bug-in-pc-access-itself) — + how this bug surfaced during the initial parse. diff --git a/src/content/docs/explanation/quirks.md b/src/content/docs/explanation/quirks.md index 2e2e391..a9ef4ab 100644 --- a/src/content/docs/explanation/quirks.md +++ b/src/content/docs/explanation/quirks.md @@ -1,6 +1,211 @@ --- title: The two non-public quirks -description: Why public Omni-Link clients silently fail on the first encrypted message. +description: Why public Omni-Link clients silently fail on the first encrypted message — session key XOR mix and per-block pre-whitening before AES. --- -TODO: filled by parallel agent. +The Omni-Link II protocol, as documented in the publicly-available spec, looks +like a textbook AES-128-ECB session over TCP: handshake, derive a key, encrypt +everything from then on. As implemented by HAI's PC Access 3.17, it isn't. +There are two quirks in the way the session key is derived and the way payload +blocks are encrypted that are not in any third-party Omni-Link writeup we +could find. Both are unambiguous in the decompiled C# (`clsOmniLinkConnection.cs`). +Both are load-bearing: if a client skips either, the panel accepts the +connection, completes the unencrypted handshake, and then drops the session +on the first encrypted message — `ControllerSessionTerminated`, no +diagnostic, no log. + +## Why these quirks exist (informed speculation) + +Both quirks have the texture of *defense by inconvenience*. Neither makes the +protocol meaningfully harder to attack — anyone with a packet capture and the +`ControllerKey` can reproduce both transformations in a few lines of code. +But both add just enough complexity that a casual reverse engineer reading +the public spec will write a client that doesn't work, and won't have an +obvious explanation for why. + +It looks like the kind of thing where someone on the original team said +"let's not make it trivial for the obvious clones," and the implementation +has the slight inelegance of cargo-culted-from-one-block-to-all-blocks that +suggests it was added by hand rather than designed in. The first quirk may +also have been an attempt at session-key freshness — mix a controller-supplied +nonce so that two sessions with the same `ControllerKey` don't use literally +the same AES key. That's a reasonable goal; a 5-byte XOR is just an unusual +way to achieve it. + +Whatever the origin, both quirks are stable across the firmware versions PC +Access 3.17 supports (the v2-on-TCP path), and both must be implemented +exactly to talk to the panel. + +## Quirk #1 — session key XOR mix + +The `ControllerKey` is the 16-byte AES-128 key that lives in the panel's +NVRAM and inside the encrypted `.pca` config file. The naive expectation is +that this key is what AES uses for the session. It isn't. + +From `clsOmniLinkConnection.cs:1886-1892` (the TCP path): + +```csharp +SessionKey = new byte[16]; +ControllerKey.CopyTo(SessionKey, 0); +for (int j = 0; j < 5; j++) +{ + SessionKey[11 + j] = (byte)(ControllerKey[11 + j] ^ SessionID[j]); +} +AES = new clsAES(SessionKey); +``` + +The first 11 bytes of the session key are the `ControllerKey` verbatim. The +last 5 bytes are the `ControllerKey` XORed with a 5-byte `SessionID` nonce +that the controller sent in the unencrypted `ControllerAckNewSession` packet. +That's the entire key derivation. No PBKDF2, no HKDF, no PIN, no salt. Five +bytes of XOR. + +The same five-byte block appears at `:1423-1429` for the UDP path. Identical. + +The Python equivalent: + +```python +def derive_session_key(controller_key: bytes, session_id: bytes) -> bytes: + assert len(controller_key) == 16 + assert len(session_id) == 5 + sk = bytearray(controller_key) + for j in range(5): + sk[11 + j] ^= session_id[j] + return bytes(sk) +``` + +A naive client that uses `ControllerKey` directly as the AES key will +encrypt `ClientRequestSecureSession` (the first encrypted packet) with the +wrong key. The panel decrypts it to garbage — ECB has no integrity check, so +no exception fires; the panel just sees that the SessionID echo doesn't match +what it sent — and drops the session with `ControllerSessionTerminated`. +PC Access surfaces this as `InvalidEncryptionKey`, which sounds like "your +ControllerKey is wrong" but really means "your *derived* key is wrong, which +in practice is always because you didn't apply the XOR mix." + +## Quirk #2 — per-block XOR pre-whitening before AES + +This is the headline. + +Before AES-encrypting any payload block, the *first two bytes of every +16-byte block* get XORed with the packet's 16-bit sequence number. Same XOR +mask, every block of the packet. From `clsOmniLinkConnection.cs:396-401`: + +```csharp +for (num = 0; num < PKT.Data.Length; num += 16) +{ + PKT.Data[num] = (byte)(PKT.Data[num] ^ ((PKT.SequenceNumber & 0xFF00) >> 8)); + PKT.Data[num + 1] = (byte)(PKT.Data[num + 1] ^ (PKT.SequenceNumber & 0xFF)); +} +PKT.Data = AES.Encrypt(PKT.Data); +``` + +And the inverse on receive (`:413-417`): + +```csharp +PKT.Data = AES.Decrypt(PKT.Data); +for (int i = 0; i < PKT.Data.Length; i += 16) +{ + PKT.Data[i] = (byte)(PKT.Data[i] ^ ((PKT.SequenceNumber & 0xFF00) >> 8)); + PKT.Data[i + 1] = (byte)(PKT.Data[i + 1] ^ (PKT.SequenceNumber & 0xFF)); +} +``` + +So the on-the-wire encryption is "AES-128-ECB of (payload XOR-prewhitened +with the seq number, two bytes per block)". This is *not* CBC. It is *not* +CTR. It is an outer transformation applied to the plaintext before AES +sees it (and reversed after AES decryption on the wire), independent of +AES's mode. + +The Python equivalent: + +```python +def whiten(data: bytes, seq: int) -> bytes: + out = bytearray(data) + seq_hi = (seq >> 8) & 0xFF + seq_lo = seq & 0xFF + for i in range(0, len(out), 16): + out[i] ^= seq_hi + out[i + 1] ^= seq_lo + return bytes(out) + +def encrypt_payload(payload: bytes, seq: int, session_key: bytes) -> bytes: + # payload is already zero-padded to a 16-byte multiple by the caller. + return aes_ecb_encrypt(whiten(payload, seq), session_key) + +def decrypt_payload(ciphertext: bytes, seq: int, session_key: bytes) -> bytes: + return whiten(aes_ecb_decrypt(ciphertext, session_key), seq) +``` + +The `whiten` function is its own inverse — XOR is symmetric — so the same +helper works both directions. + +Cryptographically this is weak. An attacker with a known-plaintext for one +block can recover both bytes of the seq XOR mask by XORing the plaintext +against the un-AES'd ciphertext. From there the AES-encrypted bits are +unprotected by the whitening. It feels like the original intent might have +been nonce-mixing — use the seq as a per-packet salt to defeat ECB's +identical-block-equals-identical-ciphertext property — and the implementation +got cargo-culted from one block (where it would have been roughly +defensible) to every block of the packet (where it isn't doing useful work +beyond the first one). Doesn't matter. It's the protocol. Implement it. Move on. + +## Why public OSS Omni-Link clients miss these + +The two non-trivial public Omni-Link II clients we checked are +[`jomnilinkII`](https://github.com/digitaldan/jomnilinkII) (Java) and +[`pyomnilink`](https://github.com/excalq/pyomnilink) (Python), plus a +handful of writeups on personal blogs. None of them describe either quirk. +We can't be sure from the outside why, but two plausible explanations: + +1. **Inherited working code from a pre-quirk firmware era.** If an early + version of the panel firmware used `ControllerKey` directly as the + session key and didn't have the XOR pre-whitening, an OSS client + written against that firmware would just keep working as long as the + panel maintained backward compatibility on the wire — even though new + firmware added the quirks for new clients. We don't have the firmware + history to confirm or refute this. +2. **Serial-only / unencrypted paths.** Both quirks live in the + `clsOmniLinkConnection.EncryptPacket` / `DecryptPacket` methods, which + are only invoked on packet types `OmniLinkMessage` (0x10) and + `OmniLink2Message` (0x20). The *unencrypted* twin packet types (0x11, + 0x21) bypass them entirely. A client that only ever talks to the panel + over the unencrypted v1 serial path would never need them. + +Either way, the practical outcome is that an existing OSS client is not a +useful reference for someone trying to write a v2-on-TCP encrypted client +from scratch. The decompiled PC Access C# is. + +## The mock panel as proof + +The most direct way to prove our implementation of both quirks is correct is +to build a controller-side emulator that round-trips with the client. +`omni_pca.mock_panel.MockPanel` is exactly that: a TCP server that runs the +controller half of the handshake, derives the same `SessionKey`, applies +the same per-block XOR pre-whitening, and decodes / encodes real Omni-Link II +messages. The library's e2e test suite connects a real `OmniClient` to a +real `MockPanel` over a real TCP socket and exchanges real frames. Seventeen +of those tests cover the secure-session handshake, encrypted command +roundtrips, and the unsolicited push-event stream. + +If either quirk were implemented incorrectly on either side, decryption +would produce garbage and the connection would drop. The fact that all +seventeen tests pass — including ones that subscribe to events and watch +them roundtrip cleanly through the encrypted channel — is bidirectional +validation that we have both quirks right. + +That doesn't prove they're right against a *real* HAI panel. The user's +panel is currently offline (Ethernet module disabled at the panel firmware), +and the live-validation lap is on the backlog. But round-tripping with a +faithful emulator is meaningful evidence that the spec we extracted from +the C# is internally consistent — and that's the work that the public +clients didn't do. + +## See also + +- [Protocol reference](/reference/protocol/) — full byte-level handshake + including both quirks in their natural place in the flow. +- [Architecture overview](/explanation/architecture/) — how the mock panel + fits into the test stack. +- [The Journey](/journey/) — what it took to find the quirks in the first + place. diff --git a/src/content/docs/journey.md b/src/content/docs/journey.md index 7a0c8f6..a6d3125 100644 --- a/src/content/docs/journey.md +++ b/src/content/docs/journey.md @@ -1,6 +1,903 @@ --- -title: Journey -description: Chronological retrospective of the omni-pca reverse-engineering work. +title: The Journey +description: Chronological retrospective of a few days reverse-engineering HAI's PC Access 3.17 and building a Python library and Home Assistant integration on top of it. --- -TODO: filled by parallel agent — chronological retrospective of the reverse-engineering work. +Raw chronological notes from a few days reverse-engineering HAI's PC Access +3.17, then writing a Python library and a Home Assistant integration to talk +to the panel directly. Dated. Append-only-ish. + +--- + +## 2026-05-10 morning — the pile of binaries + +We started with a directory called `PC Access/` that had clearly been zipped +up off a Mac and handed around. The giveaway was `._*` files next to every +real file: + +```text +-rw------- 1 kdm kdm 120 Aug 15 2016 ._Newtonsoft.Json.dll +-rw------- 1 kdm kdm 484352 Aug 15 2016 Newtonsoft.Json.dll +``` + +That's AppleDouble cruft: macOS extended attributes shimmed into companion +files when an HFS+ volume gets archived to a non-Apple filesystem. 120 bytes +of resource fork garbage per real file. Useless. Touched everything from the +PC Access install date (Mar 2018) all the way back to a 2006 firmware +updater. Whoever extracted this had been carrying it across Macs for years. + +What we actually had: + +| File | Size | What it is | +|------|-----:|-----| +| `PCA3U_EN.exe` | 5.4 MB | The PC Access GUI, a .NET assembly (v3.17.0.843, 2018-01-02) | +| `PCA1106W.exe` | 3.3 MB | Older native C++ version from 2008 | +| `f_update.exe` | 437 KB | Native firmware updater (2006) | +| `OT7FileUploaderLib.dll` | 16 KB | OmniTouch 7 firmware uploader | +| `Our House.pca` | 144 KB | A panel config file. High entropy. Not ours. | +| `PCA01.CFG` | 318 B | App settings. Also encrypted. | +| `Serial Number.txt` | 20 B | A 20-char license key | + +`Our House.pca` was the interesting one. Entropy 7.994 bits per byte — +either compressed, encrypted, or both. No magic bytes. No structure visible +in the first 256 bytes. It also had someone else's account name embedded in +the metadata: this panel had been bought used and shipped with the previous +owner's config still on it. We held that thought. + +`file PCA3U_EN.exe` came back with `Mono/.Net assembly`. That was the single +biggest piece of luck in the whole project: a .NET assembly means +ilspycmd will give us back readable C# in seconds. Beats staring at IDA +listings of Borland C++ runtime stubs all afternoon, which is what +`PCA1106W.exe` would have made us do. + +## 2026-05-10 — decompile and skim + +We ran ilspycmd 10.0.1.8346 over `PCA3U_EN.exe`. 898 typedefs. They cleanly +split into two namespaces: + +- `HAI_Shared` — the domain model, the wire protocol, the crypto, all of + it reusable across HAI's product line (Omni, Lumina, HMS). +- `PCAccess3` — just UI. Forms, controls, window positions. + +That's the prize: `HAI_Shared` is essentially a free protocol implementation +library, written by people who actually know how the panel works, sitting +there in C# waiting to be read. + +First skim of `HAI_Shared`: + +- `clsOmniLinkPacket` — outer transport packet. 4-byte header + (`[seq_hi][seq_lo][type][reserved=0]`) + payload. Sequence number is + big-endian. There are 12 packet types: NewSession, AckNewSession, + RequestSecureSession, AckSecureSession, two flavors of SessionTerminated, + the `OmniLinkMessage` (encrypted, v1) and `OmniLink2Message` (encrypted, + v2) wrappers, plus their unencrypted twins. +- `clsOmniLinkMessage` — inner application message. + `[StartChar][MessageLength][...payload, payload[0]=opcode...][CRC_lo][CRC_hi]`. + CRC is CRC-16/MODBUS with poly `0xA001`. Standard. +- `clsAES` — the panel's symmetric crypto. AES-128, ECB, + `PaddingMode.Zeros`, key reused as IV (which is fine in ECB but a code + smell that hints at someone copy-pasting from a textbook). +- `enuOmniLink2MessageType` — 83 v2 opcodes. Login, Logout, + RequestSystemInformation, RequestExtendedStatus, Command, ZigBee + pass-through, firmware upload, etc. +- `clsCapOMNI_PRO_II`, `clsCapLUMINA`, `clsCapHMS950e`, … — per-model + capability classes carrying constants like `numZones=176`, + `numUnits=511`. Real domain model, not a config file. + +Wrote those down in our findings notes and pushed on. + +## 2026-05-10 — the cipher that wasn't AES + +Then we hit the file format. The `.pca` and `.CFG` blobs *look* like AES-CBC +ciphertext. They aren't. From `clsPcaCryptFileStream`: + +```csharp +private byte oldRandom(byte max) { + RandomSeed = RandomSeed * 134775813 + 1; + return (byte)((RandomSeed >> 16) % max); +} +// per byte: ciphertext = plaintext ^ oldRandom(255) // mod 255, not 256 +``` + +That multiplier — `134775813` = `0x08088405` — is the Borland Delphi / +Turbo Pascal `Random()` LCG. So someone wrote this thing in Delphi +originally, ported it to C#, and kept the exact same PRNG so existing +.pca files would still decrypt. The mod-255 (not 256) stays in too, which +means the keystream byte is in `[0..254]`, never `0xFF`. It doesn't lose +information — it just shifts the output distribution. Quirky but not broken. + +Two hardcoded 32-bit keys live in `clsPcaCfg`: + +```csharp +private readonly uint keyPC01 = 338847091u; // 0x142A3D33 — for PCA01.CFG +public readonly uint keyExport = 391549495u; // for exported .pca files +``` + +And a third path: `SetSecurityStamp(string S)` derives a per-installation +key from a stamp string: + +```csharp +uint num = 305419896u; // 0x12345678 — developer Easter egg as init value +foreach (char c in S) + num = ((num ^ c) << 7) ^ c; +Key = num; +``` + +`0x12345678` as an init constant is the giveaway: someone was bored at the +keyboard the day they wrote this. It's the kind of thing you grep for. The +actual hash function, `((k ^ c) << 7) ^ c`, is fine — not cryptographic, +but fine for "let me derive a per-install key from a serial number." + +## 2026-05-10 — the wrong-key-looks-right problem + +We wrote a Python decryptor in maybe an hour: a generator that yields +keystream bytes, an XOR over the file. Easy. + +Then we hit a subtle thing. The first script auto-tried the two known keys +and picked the one whose plaintext "looked more printable". It picked +`keyExport`, ran the parser, and got nonsense — but a *plausible* kind of +nonsense: short non-empty strings, non-zero counter values, generally the +texture of real binary data. + +Turns out **printable-character ratio is a terrible heuristic for binary +file plaintext.** Random noise is, on average, slightly more "printable" +than a real binary file padded with zeros and length-prefixed strings — +because random noise has a uniform distribution and a real file has long +runs of `0x00` (which falls outside the 32–127 printable range). + +We replaced it with something concrete and stupid: + +```python +def score(pt): + n = pt[0] + if not (1 <= n <= 64): return 0 + tag = pt[1:1+n] + if all(32 <= b < 127 for b in tag): + return 100 + n + return 0 +``` + +The first byte is a String8 length, and the next `n` bytes should be the +ASCII version tag like `CFG05` or `PCA03`. If it parses cleanly, the key is +right; if not, it isn't. Robust because it's not statistical. + +`PCA01.CFG` decrypted with `keyPC01`. First bytes: + +```text +00000000 05 43 46 47 30 35 17 41 ... .CFG05.A +``` + +`CFG05`. Format version 5. Walked the rest of the schema (modem strings, +port number, key field, password) and pulled out the prize: + +```text +pca_key = 0xC1A280B2 (3,248,652,466) +password = "PASSWORD" # factory default, never changed +``` + +So the per-installation `.pca` key was sitting inside `PCA01.CFG` the whole +time, encrypted with a hardcoded key that's right there in the binary. The +`keyExport` path is only for files that were exported for sharing, which is +*not* what `Our House.pca` was — it was the live in-place config. + +Decrypted `Our House.pca` with `0xC1A280B2`. First bytes: + +```text +00000000 05 50 43 41 30 33 ... .PCA03 +``` + +`PCA03`. File format v3. Right key. + +## 2026-05-10 — the 2191-byte header parses byte-perfect + +Read `clsHAC.ReadFileHeader` to figure out the layout: + +```text +String8 version_tag "PCA03" +String8(30) AccountName +String16(120) AccountAddress +String8(20) AccountPhone +String8(4) AccountCode +String16(2000) AccountRemarks +byte Model +byte MajorVersion +byte MinorVersion +sbyte Revision +``` + +One thing about `ReadString8(out S, byte L)`: it always consumes `1 + L` +bytes regardless of the declared string length. So the strings are +fixed-width slots with a length prefix, not variable-length. + +Total header size: 2191 bytes. + +Then we found the validation block at `clsHAC.cs:7943`: + +```csharp +if (num == 2191) { /* header read OK */ } +``` + +If your byte counter doesn't equal 2191 after parsing the header, you got it +wrong. It did. That was the moment we knew the parser was correct: not by +inspection of the output, but by hitting an exact magic number that the +original code was checking against. + +Decoded header: + +- Model byte = `0x10` = `enuModel.OMNI_PRO_II` +- Firmware: 2.12 r1 +- AccountName / Address / Phone — the previous owner's PII +- 8 user codes, all still factory default `12345678` + +That last one stung. The panel had probably been sitting on someone's wall +for a decade with `12345678` as the master code. (Not our panel, yet — but +our panel was about to inherit it.) Plaintext stays in +`extracted/Our_House.pca.plain` and that path stays in `.gitignore`. All +future notes redact PII. + +## 2026-05-10 — walking the body + +Header was 2191 bytes; the file is 144 KB. Plenty more to parse before we'd +hit the network connection block where the AES key for live-panel talk is +stored. The body layout (from `clsHAC.ReadFromFile`) is documented in +detail in [the file format reference](/reference/file-format/). + +## 2026-05-10 — the latent bug in PC Access itself + +Each "Voice" block lets the panel speak the name of an object. Six phrases +per object (`numVoicePhrases = 6`). The C# reads them like this: + +```csharp +byte[] B = new byte[CAP.numVoicePhrases]; // 6 bytes +for (int i = 1; i <= GetFileMaxX(); i++) { + num = (i > Count) + ? num + FS.ReadByteArray(out B, B.Length) // skip path: 6 bytes + : num + _Items[i-1].Voice.Read(FS); // structured path +} +``` + +The "structured path" calls `clsVoiceWordArray.Read`, which branches on +whether the panel has the `LargeVocabulary` feature: + +- LargeVocabulary present → 6 phrases × **2 bytes** (UInt16) = **12 bytes** +- LargeVocabulary absent → 6 phrases × 1 byte = 6 bytes + +OMNI_PRO_II *has* LargeVocabulary. So the structured path reads 12 bytes per +slot. But the **skip path** in the loop above always reads 6 bytes, no +matter what. There's no `if (LargeVocabulary) B = new byte[12];`. + +If `Count == GetFileMaxX()` (every slot is filled), this never matters — the +skip path is never taken. For every block on our panel except one, that's +true. But Units has `Count = 511` and `GetFileMaxX = 512`, so exactly one +slot takes the skip path, reads 6 bytes when it should have read 12, and +the next 6 bytes — which are actually the start of the *next* block — get +treated as the tail of the current slot. The parser walks 6 bytes off the +rails and never recovers. + +The C# code in the wild gets away with this because `Count >= Max` for +basically all real panels in deployment. But it's a real bug — it would bite +if a model ever shipped with LargeVocabulary AND had Buttons or Messages +with `Count < Max`. We patched our parser; the original is still wrong. + +Found it by hex-dumping the file, locating the panel IP address +(`192.168.1.9`) at byte offset `0xe2d8`, and back-solving the diff between +where we expected to land and where the IP actually was. The gap was exactly +6684 bytes, which is `(512-1)*6` worth of voice slots read at half the right +size. Math checked out. Off by N. Full writeup in [the PC Access bug +explainer](/explanation/pc-access-bug/). + +## 2026-05-10 — the prize + +After the Voices, the body has Programs (1500 × 14 B), EventLog (250 × +9 B), and then — for a v3 file with the Ethernet feature — the Connection +block: + +```text +String8(120) Connection.NetworkAddress +String8(5) port-string +String8(32) ControllerKey-as-hex +``` + +For our panel: + +- IP: `192.168.1.9` +- Port: `4369` +- ControllerKey: 16 bytes of AES-128 key, extracted at file offset `0xe2d8` + +Total bytes to that point: `2191 + 3840 + 10 + 15407 + 13374 + 21000 + 2250 = 58072 = 0xe2d8`. +Exactly the offset where the IP appears in the hex dump. Done. + +That key plus the right handshake = direct talk to the panel. + +## 2026-05-10 — the two non-public quirks + +Now we needed to read `clsOmniLinkConnection.cs`. It's 2109 lines of state +machine for the secure-session handshake, the keepalive timer, the TCP +framing, and the encryption. We expected a textbook AES session: send +client-hello, get server-hello, derive key from PIN somehow, encrypt +everything from then on. + +What we found instead were two surprises that no public Omni-Link write-up +we'd seen mentions. Both of them look like quirks. Both of them will reject +your client with `ControllerSessionTerminated` if you skip them. + +### Quirk 1 — the session key is not the ControllerKey + +You'd expect the AES session key to be the ControllerKey verbatim. It isn't. +From `clsOmniLinkConnection.cs:1886-1892`: + +```csharp +SessionKey = new byte[16]; +ControllerKey.CopyTo(SessionKey, 0); +for (int j = 0; j < 5; j++) +{ + SessionKey[11 + j] = (byte)(ControllerKey[11 + j] ^ SessionID[j]); +} +AES = new clsAES(SessionKey); +``` + +The first 11 bytes of the session key are the ControllerKey verbatim. The +last 5 bytes are the ControllerKey XORed with a 5-byte `SessionID` nonce +that the controller sent in `ControllerAckNewSession`. That's the entire +key derivation. No PBKDF2, no HKDF, no PIN, no salt. Just five bytes of XOR. + +The same five-byte block appears twice in the source — once for UDP (line +1423) and once for TCP (line 1886). Identical. + +The implication for someone writing a client is: if you encrypt your +`ClientRequestSecureSession` with the raw ControllerKey, the panel decrypts +it to garbage and disconnects you. You have to wait for the nonce, mix it +in, *then* encrypt. + +### Quirk 2 — per-block XOR pre-whitening before AES + +This one is the real headline. Before AES-encrypting any payload block, the +first two bytes of every 16-byte block get XORed with the packet's sequence +number. Same XOR mask, every block of the packet. From +`clsOmniLinkConnection.cs:396-401`: + +```csharp +for (num = 0; num < PKT.Data.Length; num += 16) +{ + PKT.Data[num] = (byte)(PKT.Data[num] ^ ((PKT.SequenceNumber & 0xFF00) >> 8)); + PKT.Data[num + 1] = (byte)(PKT.Data[num + 1] ^ (PKT.SequenceNumber & 0xFF)); +} +PKT.Data = AES.Encrypt(PKT.Data); +``` + +And then the inverse on receive (`:413-417`): + +```csharp +PKT.Data = AES.Decrypt(PKT.Data); +for (int i = 0; i < PKT.Data.Length; i += 16) +{ + PKT.Data[i] = (byte)(PKT.Data[i] ^ ((PKT.SequenceNumber & 0xFF00) >> 8)); + PKT.Data[i + 1] = (byte)(PKT.Data[i + 1] ^ (PKT.SequenceNumber & 0xFF)); +} +``` + +So the on-the-wire encryption is "AES-128-ECB of (payload XOR-prewhitened +with the seq number, two bytes per block)". A naive Omni-Link client that +just AES-ECB-encrypts the raw payload will produce ciphertext the panel +won't accept. + +It feels weak — an attacker with a known-plaintext for one block can +recover the seq XOR mask trivially, and from there the whitening is +unprotected. But it's the protocol. The panel won't talk to you without it. + +We think the original intent might have been something like nonce-mixing +(use the seq as a per-packet salt to defeat ECB block-repetition attacks), +and the implementation got cargo-culted from one block to all blocks of the +packet. Doesn't matter. Implement it. Move on. Full writeup in [the quirks +explainer](/explanation/quirks/). + +A bonus surprise: **there is no separate `Login` step on TCP.** The C# +defines `clsOL2MsgLogin` (v2 Login, opcode 42) but never instantiates it on +the TCP path. Possessing the right ControllerKey *is* the authentication. +The login opcode appears to be a serial-only artifact from before the +Ethernet module existed. The v1 serial path *does* construct `clsOLMsgLogin` +with the user's PIN; the v2 TCP path goes straight from +`ControllerAckSecureSession` to `RequestSystemInformation`. + +We documented all of this in our [protocol reference](/reference/protocol/) +while it was fresh. + +## 2026-05-10 around noon — first commit + +```text +9a02418 Initial scaffold + protocol primitives +``` + +uv project, ruff, pytest, mypy strict, MIT, README, gitignore explicitly +protecting any `.pca` or panel keys. Date-versioned (CalVer): `2026.5.10`. +The library lives in `src/omni_pca/`: + +- `crypto.py` — AES-128-ECB plus the per-block XOR seq pre-whitening and the + `SessionKey = CK[0:11] || (CK[11:16] XOR SessionID)` derivation +- `opcodes.py` — all 12 packet types, all 104 v1 opcodes, all 83 v2 opcodes, + all transcribed by hand from the decompiled enums +- `packet.py` — outer `Packet` with `encode()`/`decode()` +- `message.py` — inner `Message` with CRC-16/MODBUS +- `pca_file.py` — Borland LCG cipher, `PcaReader`, parsers for both `.pca` + and `.CFG` + +49 tests passed, ruff clean. The protocol unit tests use canned bytes +extracted from the C# source; they don't need a panel to run. + +## 2026-05-10 1pm — mock panel as ground truth + +Second commit: + +```text +1901d6e Async client + mock panel + e2e roundtrip +``` + +The async client (`OmniConnection`, `OmniClient`) runs the four-step +secure-session handshake, frames TCP correctly (read first 16-byte block, +decrypt, learn `MessageLength`, read the rest), keeps a per-direction +monotonic sequence number that wraps `0xFFFF → 1` (skipping 0 because the +controller uses 0 for unsolicited packets), and dispatches solicited +replies to a Future while shoving unsolicited packets into a queue. + +That's all well and good, but how do we test it without a panel? The panel +was at `192.168.1.9` last we knew, and we had no idea if its network module +was even on. Building a real Omni controller emulator in Python turned out +to be the right answer. + +`mock_panel.py` is a TCP server that: + +- accepts `ClientRequestNewSession`, generates a 5-byte SessionID, sends back + `ControllerAckNewSession` with the version bytes `00 01` prepended +- derives the same SessionKey the client did (using the same XOR-mix) +- decrypts the `ClientRequestSecureSession`, validates that the 5-byte echo + matches the SessionID it just sent, sends back the symmetric + `ControllerAckSecureSession` (re-encrypting the same SessionID) +- handles `RequestSystemInformation`, `RequestSystemStatus`, + `RequestProperties` (Zone/Unit/Area, both absolute index and rel=1 + iteration with EOD termination), and Naks anything else + +It's a thin emulator but it's a *complete* protocol counterpart. Six +end-to-end tests connect a real `OmniClient` over a real TCP socket to a +real `MockPanel` and exchange real frames. They prove the handshake, the +AES, the XOR whitening, and the sequence numbering all agree — because if +any one of them is wrong, decryption produces garbage and the connection +drops. + +That ground-truth check was load-bearing. It meant we could iterate on the +client all afternoon without worrying that some bug in our encryption was +being masked by a bug in our framing. + +## 2026-05-10 ~1:10pm — the HA scaffold + +Third commit: + +```text +2e43936 HA custom_component scaffold (binary_sensor for zones) +``` + +Drop-in Home Assistant integration at `custom_components/omni_pca/`: +manifest, config_flow with auth + reauth, coordinator with reconnect logic, +binary_sensor for each named zone with `device_class` derived from +`zone_type` (OPENING, MOTION, SMOKE, etc.). 12 unit tests for +`parse_controller_key()` because that's the one piece of pure logic worth +pinning down hard. + +Status of the HA component itself wasn't validated against a running Home +Assistant — that comes next. But the HACS manifest is there, so once we +trust it we can drop it in. + +## 2026-05-10 2pm — fleshing out the model surface + +Fourth commit: + +```text +08974e2 Models: 16 status/properties dataclasses + enums + temp converters +``` + +The Omni protocol has a wide object surface — Zones, Units, Areas, +Thermostats, Buttons, Programs, Codes, Messages, Aux Sensors, Audio Zones, +Audio Sources, User Settings — and each has both a "properties" record +(configured, mostly static) and a "status" record (live state). + +Wrote frozen-slots dataclasses for all of them, with `.parse(payload)` +classmethods that decode the byte layouts straight from the C# field +definitions. Added IntEnums for the dispatch tags (`ObjectType`, +`SecurityMode`, `HvacMode`, `FanMode`, `HoldMode`, `ThermostatKind`, +`ZoneType`, `UserSettingKind`). + +One small surprise from `clsText.cs`: the temperature encoding the panel +uses is *linear*, not the non-linear thermistor scale we'd guessed it might +be. `C = raw / 2 - 40`. Easy. + +42 new tests. 139 total. + +## 2026-05-10 ~2:15pm — commands and events + +Fifth commit: + +```text +68cf44a Library v1.0 phase B: command opcodes + typed system events +``` + +`commands.py` — the `Command` IntEnum, sourced from `enuUnitCommand.cs` +which is the canonical "all commands" enum despite the misleading name (it +covers HVAC, security, scene, button, message commands too — not just +units). One naming weirdness: `enuUnitCommand.UserSetting` (104) is actually +EXECUTE_PROGRAM. Renamed for clarity in our enum and left the original C# +alias documented inline so anyone cross-referencing won't get confused. + +`OmniClient` got 18 new methods: `execute_command`, +`execute_security_command`, `acknowledge_alerts`, `get_object_status`, +`get_extended_status`, plus convenience wrappers (`turn_unit_on`, +`set_unit_level`, `bypass_zone`, `set_thermostat_heat_setpoint_raw`, …). +All the command methods raise `CommandFailedError` on Nak. + +`events.py` — the `SystemEvents` (opcode 55) decoder. The panel pushes +batches of these unsolicited; each batch contains multiple events of +different types (zone state changes, unit state changes, arming changes, +alarm activated, AC lost, battery low, phone line dead, X10 codes received, +…). 28 dispatch tags, 26 typed event subclasses, an `UnknownEvent` catch-all +for opcode values we don't know yet, and an `EventStream` helper that +flattens batches across messages. + +55 new tests. 194 total. + +## 2026-05-10 ~2:30pm — stateful mock and the full v1.0 surface + +Sixth commit: + +```text +c26db62 Library v1.0 phase C: stateful mock + e2e for the new surface +``` + +The mock got real state. `MockUnitState`, `MockAreaState`, `MockZoneState`, +`MockThermostatState`, plus a `user_codes` table for security validation. +All the new opcodes wired through: + +- `Command` (20) → Ack with state mutation, dispatching UNIT_ON, UNIT_OFF, + UNIT_LEVEL, BYPASS_ZONE, RESTORE_ZONE, SET_THERMOSTAT_HEAT, etc. +- `ExecuteSecurityCommand` (74) → Ack on a valid code, Nak on invalid +- `RequestStatus` (34) → `Status` (35) for the four object kinds with + hard-coded record sizes per `clsOL2MsgStatus.cs:13-27` +- `RequestExtendedStatus` (58) → `ExtendedStatus` (59) with the + `object_length` prefix and the richer per-type fields +- `AcknowledgeAlerts` (60) → Ack +- And synthesized `SystemEvents` (55) pushed with `seq=0` whenever state + changes, so the e2e tests can subscribe to events through the real client + API and watch them roundtrip cleanly through `events.parse_events()` + +9 new e2e tests — arm/disarm with code validation, unit on/off/level, zone +bypass/restore, thermostat setpoint, push events for arming and unit +changes, acknowledge_alerts. 203 total passing, 2 skipped (the HA harness +and a `.pca` fixture we don't ship). + +The library has the v1.0 surface: read, command, status, extended status, +events. All exercised by an in-process emulator that speaks the same +protocol as the real panel. + +## 2026-05-10 afternoon — trying to find the real panel + +Now the part that didn't go well. + +The `.pca` file said the panel lived at `192.168.1.9:4369`. Tried to +connect: nothing. TCP SYN, no SYN-ACK. Pinged: silent. nmap'd the subnet to +make sure we were on the right network: + +- `192.168.1.7`, `.8`, `.11` — open ports including SSH with banner + `SSH-2.0-dropbear_2018.76`. Three OmniTouch 7 touchscreens. They're the + wall-mounted controllers; they live on the same LAN as the panel, speak + Omni-Link II to the panel themselves, and run a stripped Linux with + dropbear for the firmware updater. Confirmed by the SSH banner date + (2018) lining up with the OmniTouch 7 firmware era. +- `.6` — likely the panel itself, but no open ports, no response. +- `.9` — also dark. The 2018 IP either changed or the network module was + disabled at some point. + +So the panel is sitting there, doing its job (the touchscreens clearly work +— they're on the network), but its Ethernet/Omni-Link II module is either +turned off in the panel's setup menu or the network bridge hardware is bad. +We have the ControllerKey, we have the right port, we have a fully-tested +client and a mock panel that proves the client works end-to-end — but we +can't prove it against the real thing yet. + +We have, in other words, built the world's most thoroughly-tested unused +integration. There is something quietly funny about that. + +The fix is physical: walk over to the panel, find the menu that enables the +Ethernet module, save, reboot. Then the live validation becomes a +five-minute test. Until then, the mock is the best we have, and the mock +is a faithful enough emulator that we trust it. + +## 2026-05-10 evening — HA rebuild Phase A + +The first HA scaffold (a placeholder `binary_sensor` for zones, written +before the library was complete) needed to come down and get rebuilt on the +v1.0 surface. The interesting design choice: how should the coordinator +pull state? + +Option A: re-poll everything every N seconds. +Option B: rely on the panel's unsolicited push messages and only poll as a +backstop. + +We picked B. The Omni panel is genuinely chatty — when a zone trips, when +an area arms, when AC fails, when a unit toggles, the panel pushes a +`SystemEvents` packet within a few hundred ms. Our `OmniConnection` already +decodes those into typed `SystemEvent` objects via an async iterator +(`client.events()`). The coordinator now runs a long-lived background task +consuming that iterator and patches the relevant slice of state in-place, +then calls `async_set_updated_data()` so HA reacts immediately. The +30-second poll is a safety net for state we missed. + +The piece that took longer than expected was extracting pure functions from +the entity-class soup so we could unit-test without HA installed in the +venv. We ended up with `helpers.py`: zone-type → device-class mapping, +latched-vs-current-condition logic per zone family, name prettifier +(`FRONT_DOOR` → `Front Door`). 61 unit tests for `helpers.py` alone, all +running without importing `homeassistant.*`. Sounds excessive until you +remember that pure-function tests are the only ones that run in <100ms; you +don't want to wait 15 seconds for HA to boot just to verify that zone-type +32 (FIRE) maps to `BinarySensorDeviceClass.SMOKE`. + +## 2026-05-10 evening — HA Phase B (the entity build-out) + +Six platforms in one pass: `alarm_control_panel` (per area, with code +validation), `light` (per unit, dimmable), `switch` (per zone for bypass +control), `climate` (per thermostat, full HVAC modes), `sensor` (analog +zones + thermostat readings + panel telemetry), `button` (per panel macro), +`event` (one per panel relaying typed push events as HA event_types). + +The mapping work was repetitive but mostly mechanical. The interesting bits: + +- The Omni unit "state" byte is overloaded: 0=off, 1=on (relay), + 100..200=brightness percent (state - 100), plus weird ranges for scene + levels (2..13) and ramping codes (17..25). Encoded as a pair of pure + helpers (`omni_state_to_ha_brightness` / `ha_brightness_to_omni_percent`) + so the conversion is unit-tested. +- Omni's `SecurityMode` enum has *both* steady-state values (Off=0, Day=1, + Away=3, …) *and* arming-in-progress values (ArmingDay=9, ArmingAway=11, + …). The HA `AlarmControlPanelState` mapping needs to bucket the 9..14 + range into HA's `arming` state regardless of destination. Plus + alarm_active overrides everything to `triggered`, and entry-timer running + means `pending`, exit-timer means `arming`. All of this lives in one pure + `security_mode_to_alarm_state()` function so it's unit-testable end to + end. +- The HA `event` platform is newer than we'd realised. It exposes push + events as a single entity per integration with `event_types` and + `event_data`. Automations key on `platform: event` filtering by + `event_type`. We surface 12 event-type strings: `zone_state_changed`, + `unit_state_changed`, `arming_changed`, `alarm_activated`, + `alarm_cleared`, `ac_lost`, `ac_restored`, `battery_low`, + `battery_restored`, `user_macro_button`, `phone_line_dead`, + `phone_line_restored`, plus an `unknown` catch-all for the 14 less common + SystemEvent subclasses. + +Skipped the `scene` platform entirely. Omni "scenes" are actually just +user-named button macros — the underlying call is the same `execute_button` +that the `button` platform already exposes. Adding a parallel scene wrapper +would just double-count entities. Documented the choice in the integration +README. + +## 2026-05-10 evening — HA Phase C (services + diagnostics) + +Seven services, all routed through a `services.py` module that's +idempotently registered on first config-entry setup and unloaded on the +last config-entry teardown: + +```text +omni_pca.bypass_zone +omni_pca.restore_zone +omni_pca.execute_program +omni_pca.show_message +omni_pca.clear_message +omni_pca.acknowledge_alerts +omni_pca.send_command (raw escape hatch) +``` + +Each takes an `entry_id` field with HA's `config_entry` selector so the UI +gives users a panel picker. `services.yaml` declares the schema; +`services.py` enforces it via `voluptuous`. + +Diagnostics endpoint dumps a redacted snapshot for bug reports: +`controller_key` redacted via `async_redact_data`; zone/unit/area names +hashed with sha256 so structure is visible without leaking PII; counts per +object type; last event class; last update success timestamp. Useful one +day, useless until then, but it's three lines and HA users expect it. + +## 2026-05-10 evening — "wait, did we mock the panel enough?" + +The thinking-out-loud moment that caught a real bug. The HA test harness +was about to be set up; before doing that, the question was: does the mock +actually answer every opcode the HA coordinator calls? + +Mapped HA-side calls to mock-side handlers. Most matched. But the HA +coordinator walks `RequestProperties` for object types Thermostat (6) and +Button (3), and the mock's `_reply_properties` only knew about +Zone/Unit/Area. Both would have returned `Nak`, the coordinator would have +moved on, and HA would have discovered zero thermostats and zero buttons no +matter how `MockState` was seeded. + +Added the two handlers (each ~30 lines: build the per-object Properties body +matching the wire format documented in `models.ThermostatProperties.parse` +/ `models.ButtonProperties.parse`), plus two e2e tests that drive the walk +with `OmniClient` and assert the parses come out clean. Caught it before +HA ever touched the mock. + +This is the kind of bug that *would* have shown up the first time you tried +the integration: zero climate entities, zero button entities, no error +message because the panel just said "no, I have no thermostats here". You'd +spend an hour staring at it. Mock-the-whole-protocol pays for itself the +first time it catches one of these. + +## 2026-05-10 evening — HA test harness, the rough patches + +`pytest-homeassistant-custom-component` is the standard HA dev test harness. +It pins to a specific HA version (we got `2026.5.1` paired with HA +`2026.5.x`) and provides fixtures to spin up HA in-process per test. Sounds +simple. Three rough patches: + +1. **`requires-python` conflict.** Our library targets `>=3.12`. HA + `2026.5+` requires `>=3.14.2`. uv resolves dependency groups against the + project's `requires-python` and refused to install the test harness + because it couldn't find a Python version satisfying both. Bumped the + project to `>=3.14.2` — fine for HA users (HA already needs 3.14), + library users on older Python pin to a previous omni-pca version. +2. **`pytest_socket` blocks our e2e tests.** The HA harness installs + `pytest_socket` globally to keep HA unit tests hermetic. That broke our + existing 17 e2e tests that legitimately need to talk to a localhost + MockPanel over a real TCP socket. Fix: a top-level `tests/conftest.py` + autouse fixture requesting the harness's `socket_enabled` fixture, which + re-enables sockets by default. HA-side tests can opt back into the + strict policy if they want. +3. **`CONF_ENTRY_ID` doesn't exist in HA.** Our `services.py` was importing + `CONF_ENTRY_ID` from `homeassistant.const`. The harness import-test + caught it: HA exports the constant as `ATTR_CONFIG_ENTRY_ID`, not + `CONF_ENTRY_ID`. Without the harness, this would have crashed on first + install in a real HA. Worth the harness already. + +Then teardown started hanging. Each test passed (5-15 seconds for HA boot + +entity discovery + assertions) but the harness's `verify_cleanup` timed out +waiting for the coordinator's background event-listener task to finish. The +coordinator's `async_shutdown()` cancels it cleanly — but the harness was +tearing the test down without calling unload first. Fix: convert the +`configured_panel` fixture into a generator and call +`hass.config_entries.async_unload()` in the teardown branch. With that, all +12 HA-side tests run in 0.74 seconds total (each one boots HA, runs config +flow, asserts, unloads). + +Final score: 351 tests pass, 1 skipped (the gitignored `.pca` fixture), +ruff clean across `src/ tests/ custom_components/`. + +## 2026-05-10 late evening — docker dev stack + +Wanted a one-command setup so the integration could be browsed manually and +screenshotted for the README. `docker-compose.yml` with two services: real +HA `2026.5` from upstream + a sidecar running the mock panel. + +The interesting wrinkle: the mock panel container needs to import +`omni_pca`. Mounting the project read-only and running `uv` inside the +container failed because uv tried to recreate the host's `.venv` and the +mount was read-only. Fix: mount only `src/` and `run_mock_panel.py`, set +`PYTHONPATH=/tmp/mock/src`, install just `cryptography` via +`uv pip install --system`, run the script directly. No package install, no +venv, just a Python interpreter with the right import path. + +## 2026-05-10 late evening — automated HA onboarding + screenshots + +`dev/screenshot.py` does the entire flow: + +1. POST `/api/onboarding/users` to create the demo user (returns + `auth_code`) +2. POST `/auth/token` with `grant_type=authorization_code` to get the access + token (HA doesn't support password grant) +3. On subsequent runs: log in via `/auth/login_flow` (cleaner than re-using + a saved token; the token expires in 30 minutes anyway) +4. POST `/api/config/config_entries/flow` to start the omni_pca config flow, + then post the user-input dict to complete it +5. Cache the panel's device_id by calling HA's template endpoint + (`{{ device_id('sensor.omni_pro_ii_panel_model') }}`) — which is a + delightfully clean way to ask HA "what's the device id for this entity?" +6. Launch headless chromium via the `playwright` Python package, inject + `localStorage.hassTokens` so it skips the login screen, navigate to six + deep-linked pages and screenshot each + +The whole script is ~250 lines and produces six PNGs. The +`04-panel-device.png` is the headline shot: HA's device page for "Omni +Pro II / by HAI / Leviton / Firmware: 2.12r1" with all the Controls +(lights, buttons, areas, thermostats), Activity panel, Diagnostics +download. Every entity from the mock visible in real HA UI in the right +shape. + +A nice side-effect: HA's onboarding wizard has a "We found compatible +devices!" step that scans the network for known integrations. Our manifest +got picked up — "HAI/Leviton Omni Panel" appeared in that list during +onboarding even though we hadn't done anything explicit to register it for +discovery. The integration name and `iot_class` in `manifest.json` was +enough. + +## What's left for future sessions + +The panel's network module is still off. When it comes back online, the +moment of truth is one TCP connect to `192.168.1.6:4369` (or wherever it +lives now) and one `RequestSystemInformation`. If the reply is `Omni Pro II +/ 2.12 r1` the entire stack — file decryption, key extraction, key +derivation, XOR pre-whitening, AES, framing, sequencing — was right end to +end. The mock says yes. We'll find out. + +Other backlog items: + +- `Programs` discovery (no `RequestProperties` opcode for Programs; current + implementation returns an empty dict — needs a real protocol path or a + separate `RequestProgramData` style call) +- HACS submission once we've validated against the live panel +- Maybe publish `omni-pca` to PyPI so the HA `manifest.json` requirements + line works without a wheel install + +--- + +## Things worth remembering + +**The "wrong key looks plausible" problem is real and recurring.** +Statistical heuristics (entropy, printable ratio, frequency analysis) are +great for telling random noise from English; they're terrible for telling +random noise from binary file plaintext. When a file format has a known +header magic, parse-the-magic beats every heuristic. + +**Magic numbers in source code are gifts.** `0x12345678` as an init value, +`134775813` as an LCG multiplier, `2191` as a header length — each one is +a hard checkpoint that tells you, on first try, whether the next four hours +are going to be productive or not. + +**A complete protocol counterpart is worth more than ten times its LOC in +confidence.** The mock panel was maybe 400 lines of code and it eliminated +an entire category of "is the client wrong or am I holding it wrong" +questions. Every test that connects a real client to it through real TCP is +a test that the entire stack — handshake, encryption, framing, sequencing — +agrees with itself. + +**Quirk #2 (the per-block XOR pre-whitening) is the kind of thing nobody +finds without doing the work.** It's not in `jomnilinkII`, not in +`pyomnilink`, not in the public Omni-Link II writeups we checked. The +decompiled C# was unambiguous and twice-redundant (once for encrypt, once +for decrypt). Without those exact six lines of source, an OSS client that +did everything else right would still get `ControllerSessionTerminated` on +the first encrypted message, with no useful diagnostic. + +**The latent LargeVocabulary bug in PC Access is harmless but symptomatic.** +It's a copy-paste mistake — the skip path uses a buffer sized for the +no-LargeVocabulary case while the structured path uses the LargeVocabulary +size. Every panel in deployment satisfies `Count >= Max` for the affected +blocks, so the bug never fires. But it would, on a model that doesn't, and +PC Access would silently mis-parse its own config file. The kind of bug +that lives in shipping code for a decade because nobody runs the unhappy +path. + +**Pure functions are the cheapest thing in test suites.** The HA +custom_component grew six entity platforms before it had any HA test harness +installed. Every translation between Omni's wire encoding and HA's UI +encoding lives in `helpers.py` as a pure function with no HA imports. 61 +unit tests for those alone, all running in <100ms. When the harness arrived, +the only thing left to test was the wiring itself — and the wiring tests +run in 0.74 seconds for the entire 12-test HA-side suite because the pure +parts already had coverage. + +**Mocking the entire protocol counterpart, not just the surface, catches +whole categories of bugs.** When the mock and the client were both being +grown, a "did we mock enough?" check caught two missing `RequestProperties` +handlers (Thermostat and Button). HA would have discovered zero of either +type silently. With the real-world panel offline, mock-the-protocol is the +only way to trust the stack — but even with the panel available, it's the +only way to trust changes without rebooting hardware between every edit. + +**`pytest_socket` and "real network in tests" can coexist.** HA's test +harness disables sockets globally to keep core unit tests hermetic. Our +integration tests need real TCP to talk to the in-process MockPanel. The +fix is one autouse fixture that requests the harness's `socket_enabled` +fixture; takes ten seconds, lets both worlds work without modification. + +**The "build the integration without a real device" loop is unreasonably +effective.** With the docker dev stack, the full flow is `make dev-up`, +click through HA onboarding (or run `screenshot.py` to do it via REST), see +your entities. Make a code change, `docker compose restart homeassistant`, +refresh the browser, see the change. Repeat. The panel itself becomes +optional for ~95% of the development. The other 5% is the live-validation +lap when the panel comes back online. diff --git a/src/content/docs/reference/file-format.md b/src/content/docs/reference/file-format.md index 1efc921..3e8030f 100644 --- a/src/content/docs/reference/file-format.md +++ b/src/content/docs/reference/file-format.md @@ -1,6 +1,302 @@ --- -title: File format -description: On-disk layout of .pca and PCA01.CFG files. +title: .pca and PCA01.CFG file format +description: Borland-Pascal LCG XOR cipher, three keys, and the on-disk layout for HAI's PC Access account export and app-settings files. --- -TODO: filled by parallel agent — `.pca` / `PCA01.CFG` layout. +The `.pca` and `PCA01.CFG` files written by HAI's PC Access are *not* AES. +Despite the existence of `clsAES` in the same binary, both file formats use a +Borland-Pascal-style linear-congruential generator (LCG) keystream XORed +byte-by-byte with the plaintext. The cipher is decades old and lives on for +backward compatibility with files emitted by earlier Delphi versions of the +same product line. + +This page covers the cipher, the three keys, and the byte-level layout up to +the `Connection` block — which is what you need to extract the panel's +network address and AES `ControllerKey` for the [secure +session](/reference/protocol/). + +:::tip[Want the key fast?] +The CLI bundled with `omni-pca` does this for you: + +```bash +uvx omni-pca decode-pca path/to/yours.pca --field controller_key +``` + +See the [quick start](/start/quickstart/) for the full set of `--field` +options. +::: + +## The XOR-LCG cipher + +`clsPcaCryptFileStream` implements the file format for both `Our House.pca` +and `PCA01.CFG`. The keystream comes out of a Borland Delphi / Turbo Pascal +`Random()` LCG: + +```csharp +private byte oldRandom(byte max) { + RandomSeed = RandomSeed * 134775813 + 1; + return (byte)((RandomSeed >> 16) % max); +} +// per byte: ciphertext = plaintext ^ oldRandom(255) // mod 255, not 256 +``` + +- **Multiplier `134775813` = `0x08088405`** — the Borland Pascal `Random()` + constant. Someone wrote this thing in Delphi originally, ported it to C#, + and kept the exact same PRNG so old `.pca` files still decrypt. +- **`% 255`, not `% 256`.** The keystream byte is in `[0..254]`, never `0xFF`. + Doesn't lose information (output distribution is just shifted), but worth + noting if you're tracking down off-by-one weirdness. +- **Integrity check:** rolling CRC-32 over plaintext, stored separately + (`clsCRC32`). The cipher itself has no MAC. + +A minimal Python decryptor: + +```python +def keystream(seed: int): + while True: + seed = (seed * 134775813 + 1) & 0xFFFFFFFF + yield (seed >> 16) % 255 + +def decrypt(ciphertext: bytes, key: int) -> bytes: + ks = keystream(key) + return bytes(b ^ next(ks) for b in ciphertext) +``` + +## The three keys + +Two are hardcoded; the third is per-installation and lives inside `PCA01.CFG` +after first-stage decryption. + +```csharp +// clsPcaCfg +private readonly uint keyPC01 = 338847091u; // 0x142A3D33 for PCA01.CFG +public readonly uint keyExport = 391549495u; // 0x17579817 for exported .pca files +``` + +The third path: `SetSecurityStamp(string S)` derives a per-installation key +from a stamp string: + +```csharp +uint num = 305419896u; // 0x12345678 — developer Easter egg as init value +foreach (char c in S) + num = ((num ^ c) << 7) ^ c; +Key = num; +``` + +`0x12345678` as an initialization constant is a giveaway someone was bored +at the keyboard — it's the kind of thing you grep binaries for. + +### Which key to try + +| File | Cipher key | Where it comes from | +|------|-----------|---------------------| +| `PCA01.CFG` (app settings) | `0x142A3D33` (`keyPC01`) | hardcoded in `clsPcaCfg` | +| `*.pca` exported via PC Access "Export" | `0x17579817` (`keyExport`) | hardcoded in `clsPcaCfg` | +| `*.pca` written in-place by PC Access | per-install `Key` | inside the decrypted `PCA01.CFG` | + +`clsHAC.ReadFromFile` confirms the rule (line 8003): + +```csharp +clsPcaCryptFileStream2 = isImportFile + ? new clsPcaCryptFileStream(_FileName, FileMode.Open, CFG.keyExport) + : new clsPcaCryptFileStream(_FileName, FileMode.Open, CFG.Key); +``` + +### How to know you got the right key + +Statistical heuristics (entropy, printable-character ratio) are bad here +because random noise has *higher* printable-byte ratio than a real binary +plaintext padded with zeros and length-prefixed strings. Use the structural +magic instead: + +```python +def score(plaintext: bytes) -> int: + n = plaintext[0] + if not (1 <= n <= 64): return 0 + tag = plaintext[1:1+n] + if all(32 <= b < 127 for b in tag): + return 100 + n + return 0 +``` + +The first byte is a `String8` length prefix; the next `n` bytes should be the +ASCII version tag like `CFG05` or `PCA03`. If it parses cleanly, the key is +right. + +## `PCA01.CFG` plaintext schema + +After XOR-decrypting with `keyPC01`: + +```text +String8 version_tag ; e.g. "CFG05" — first byte is length=5, then 5 ASCII chars +String8 InitCmd1 +[String8 InitCmd2, InitCmd3 if v >= 5] +String8 LocalCmd, OnlineCmd, AnswerCmd, HangupCmd +UInt16 ModemPortNumber, ModemIRQ +[UInt16 ModemBaud if v < 5] +UInt32 Key ; <-- the per-installation .pca encryption key +String8 Password (10 max) +UInt16 PrinterPort +UInt16 SerialPortNumber, SerialBaudCode +ReservedBytes (up to 2048) +``` + +If a `.pca` file was exported on the same machine that wrote the `PCA01.CFG`, +its key is also sitting inside `PCA01.CFG.Key`. So the workflow is: decrypt +`PCA01.CFG` with the hardcoded key, lift `Key` out, then use that to decrypt +the `.pca`. + +All multi-byte ints in this stream are **little-endian** +(`_WriteByte(I & 0xFF)` first). Strings are length-prefixed: `string8` = +u8 length + N data bytes; `string16` = u16 length + N data bytes. + +## `.pca` file format (PCA03) + +After XOR-decrypting with the per-install key, the file starts with a fixed +2191-byte header, then a model-dependent body. + +### Header (2191 bytes) + +`clsHAC.ReadFileHeader`: + +| Offset | Type | Field | Notes | +|-------:|------|-------|-------| +| 0 | String8 | `version_tag` | `PCA03` (file format v3) | +| 6 | String8(30) | AccountName | fixed-len 30 (1 len + 30 data) | +| 37 | String16(120) | AccountAddress | fixed-len 120 (2 len + 120 data) | +| 159 | String8(20) | AccountPhone | | +| 180 | String8(4) | AccountCode | short alarm-account ID | +| 185 | String16(2000) | AccountRemarks | up to 2000 bytes | +| 2187 | byte | Model | enuModel — `0x10` = OMNI_PRO_II | +| 2188 | byte | MajorVersion | firmware major | +| 2189 | byte | MinorVersion | firmware minor | +| 2190 | sbyte | Revision | firmware revision (negative = beta) | + +A useful cross-check: `clsHAC.cs:7943` has `if (num == 2191) { /* header read OK */ }`. +If your byte counter doesn't equal 2191 after parsing the header, you parsed +it wrong. + +`ReadString8(out S, byte L)` always consumes `1 + L` bytes regardless of the +declared string length. The strings are fixed-width slots with a length +prefix, not variable-length records. + +### Body layout (per `clsHAC.ReadFromFile`, file v3) + +After the 2191-byte header, the body is: + +```text +ByteArray SetupData.data (3840 bytes for OMNI_PRO_II) +bool slRequireCodeForSecurity +bool slPasswordOnRestore +UInt16 _discarded +UInt16 EventLog.Count +UInt32 _discarded + +ZoneNames, UnitNames, ButtonNames, CodeNames, ThermostatNames, + AreaNames, MessageNames + +ZoneVoices, UnitVoices, ButtonVoices, CodeVoices, ThermostatVoices, + AreaVoices, MessageVoices + +Programs (1500 × 14 B for OMNI_PRO_II = 21000 B) +EventLog (250 × 9 B = 2250 B) + +# v >= 2: +if Ethernet feature: + String8(120) Connection.NetworkAddress + String8(5) port-string ("4369" default if parse fails) + String8(32) ControllerKey-as-hex <-- 32 hex chars = 16-byte AES-128 key +UInt16 Connection.ModemBaud +bool×3 PCModemInitCommand{1,2,3}Enabled +String16 AccountRemarks_Extended + +# v >= 3: +ZoneDescriptions, UnitDescriptions, ButtonDescriptions, CodeDescriptions, + ThermostatDescriptions, AreaDescriptions, MessageDescriptions, + AudioSourceDescriptions, AudioZoneDescriptions, ProgramRemarks +# UserSettings, AccessControl, UPB/Leviton/Phantom/CentraLite/HLC/ZWave/Compose scenes +# SetupData2/3/4 (extended setup blocks) +``` + +### Names blocks + +Each Names block is `max_slots * (1 + lenXName)` bytes — a flat array of +fixed-width length-prefixed name slots. + +For OMNI_PRO_II: + +| Block | max_slots | name slot size | total | +|---|---:|---:|---:| +| Zones | 176 | 1+15=16 | 2816 | +| Units | 512 | 1+12=13 | 6656 | +| Buttons | 128 | 1+12=13 | 1664 | +| Codes | 99 | 1+12=13 | 1287 | +| Thermostats | 64 | 1+12=13 | 832 | +| Areas | 8 | 1+12=13 | 104 | +| Messages | 128 | 1+15=16 | 2048 | +| **subtotal** | | | **15407** | + +### Voices blocks + +Each "Voice" block lets the panel speak the name of an object. Six phrases +per object. The structured record size depends on the `LargeVocabulary` +feature: present → 12 bytes per slot (six `UInt16`s), absent → 6 bytes. + +OMNI_PRO_II *has* LargeVocabulary, so each slot is 12 bytes: + +| Block | bytes | +|---|---:| +| Zones.Voices | 176 × 12 = 2112 | +| Units.Voices | 511 × 12 + 1 × 6 = 6138 | +| Buttons.Voices | 128 × 12 = 1536 | +| Codes.Voices | 99 × 12 = 1188 | +| Thermostats.Voices | 64 × 12 = 768 | +| Areas.Voices | 8 × 12 = 96 | +| Messages.Voices | 128 × 12 = 1536 | +| **subtotal** | **13374** | + +The `Units` line has the irregular `+ 1 × 6` because of a [latent bug in PC +Access](/explanation/pc-access-bug/): `Count = 511` but `GetFileMaxX = 512`, +and the skip path uses a 6-byte buffer instead of 12. One slot reads short +and the parser desyncs unless you replicate the asymmetry. + +### Connection block (v >= 2 + Ethernet feature) + +`clsHAC.cs:8044-8056`: + +| Field | Type | Bytes | +|---|---|---:| +| `Connection.NetworkAddress` | String8(120) | 1+120 = 121 | +| port-string | String8(5) | 1+5 = 6 | +| ControllerKey-as-hex | String8(32) | 1+32 = 33 | + +After read, port-string is parsed as decimal (default 4369 on parse failure) +and the 32-char hex string is right-padded with `'0'` to 32 chars then fed +through `clsUtil.HexString2ByteArray` to produce the **16-byte AES-128 +ControllerKey**. + +### Verified totals (OMNI_PRO_II) + +```text +header 2191 +SetupData 3840 +flags+counters 10 +Names (Z+U+Bn+Cd+Tst+Ar+Msg) 15407 +Voices (with LargeVocab fix) 13374 +Programs 21000 +EventLog 2250 +--------------------------------- +running total to Connection: 58072 = 0xe2d8 +``` + +For one validation: the panel IP appeared at file offset `0xe2d8` in our +test `.pca`. Match. + +## The LargeVocabulary latent bug + +There's a real but harmless bug in PC Access's own parser around the Voices +blocks. It's harmless on every shipping panel because the count check +happens to satisfy the constraint — except in one corner case. We cover +it in detail in [the PC Access bug +explainer](/explanation/pc-access-bug/), because it matters if you write +your own parser. diff --git a/src/content/docs/reference/ha-entities.md b/src/content/docs/reference/ha-entities.md index 176f0a2..2ad07f4 100644 --- a/src/content/docs/reference/ha-entities.md +++ b/src/content/docs/reference/ha-entities.md @@ -1,6 +1,206 @@ --- -title: HA entities -description: Home Assistant entities exposed by the integration. +title: Home Assistant entity catalogue +description: One device per panel plus typed entities for every named object — alarm panels, lights, binary sensors, climates, sensors, buttons, switches, and a typed event relay. --- -TODO: filled by parallel agent — Home Assistant entity catalog. +The integration creates one HA device per Omni panel. Every named object on +the controller (zones, units, areas, thermostats, buttons) is materialised +as one or more typed entities below. Discovery happens once at first +refresh; live state propagates over the panel's unsolicited push channel +within one TCP round-trip, with a 30-second poll backstopping anything that +didn't push. + +| Platform | Entity | Per | +|---|---|---| +| `alarm_control_panel` | Area arm/disarm with code | discovered area | +| `binary_sensor` | Zone open/tripped | binary zone | +| `binary_sensor` | Zone bypassed (diagnostic) | binary zone | +| `binary_sensor` | AC power, backup battery, system trouble | panel | +| `button` | Panel button macro | discovered button | +| `climate` | Thermostat (heat/cool/auto, fan, hold) | discovered thermostat | +| `event` | Typed push event relay | panel | +| `light` | Unit on/off + brightness | discovered unit | +| `sensor` | Analog zone (temp/humidity/power) | analog zone | +| `sensor` | Thermostat current temp / humidity / outdoor temp | thermostat | +| `sensor` | Panel model + firmware, last event class | panel | +| `switch` | Zone bypass toggle | binary zone | + +## `alarm_control_panel` + +One per discovered area (`OmniAreaAlarmPanel`). Surfaces the area's current +`SecurityMode` translated into HA's `AlarmControlPanelState` enum: + +- `OFF` → `disarmed` +- `DAY` → `armed_home` +- `NIGHT` → `armed_night` +- `AWAY` → `armed_away` +- `VACATION` → `armed_vacation` +- `DAY_INSTANT` → `armed_custom_bypass` +- `ARMING_*` (security modes 9..14) → `arming` +- entry-timer running → `pending` +- `alarms != 0` → `triggered` + +Supported features: `ARM_HOME`, `ARM_NIGHT`, `ARM_AWAY`, `ARM_VACATION`, +`ARM_CUSTOM_BYPASS`. Code validation is enforced server-side: the user's +PIN is sent through `ExecuteSecurityCommand` (opcode 74). Wrong code raises +an HA `ServiceValidationError`. + +Attributes: `area_index`, `mode_name`, `entry_timer_secs`, `exit_timer_secs`, +`alarm_active`, `alarm_bitfield`. + +## `binary_sensor` + +Three flavours. + +**Per binary zone — open/tripped (`OmniZoneBinarySensor`).** +`device_class` is derived from the zone's `ZoneType`: + +| ZoneType | HA `BinarySensorDeviceClass` | +|----------|------------------------------| +| ENTRY_EXIT, PERIMETER, NIGHT_INTERIOR, AWAY_INTERIOR, *_DELAY, LATCHING_* | `door` / `window` (opening) | +| FIRE, FIRE_EMERGENCY, FIRE_TAMPER | `smoke` | +| GAS | `gas` | +| WATER | `moisture` | +| FREEZE | `cold` | +| TAMPER, LATCHING_TAMPER | `tamper` | +| TROUBLE | `problem` | +| temperature/humidity types | (handled by `sensor` instead) | + +State: `on` when the zone is open / not-secure / tripped. Attributes: +`zone_index`, `zone_type`, `area`, `current_state`, `latched_state`, +`arming_state`, `is_in_alarm`, `is_trouble`. + +**Per binary zone — bypass diagnostic (`OmniZoneBypassBinarySensor`).** +`entity_category = DIAGNOSTIC`, on iff the zone is currently bypassed +(user or auto-bypass). + +**Per panel — system trouble triplet.** Three diagnostic sensors per panel: + +- `binary_sensor.{name}_ac_power` — on iff AC is OK (inverted from `AcLost`) +- `binary_sensor.{name}_backup_battery` — on iff battery OK +- `binary_sensor.{name}_system_trouble` — on iff any current trouble + +These mirror the typed events `AcLost`/`AcRestored`, `BatteryLow`/ +`BatteryRestored`, and `DcmTrouble`/`DcmOk`. + +## `button` + +One `OmniButton` per discovered panel button macro. Pressing the HA button +dispatches a `Command.EXECUTE_BUTTON` (parameter2 = button index). No state. + +This is the only entity for "scenes" — Omni "scenes" are user-named button +macros, so adding a parallel `scene` platform would just double-count. + +## `climate` + +One `OmniClimate` per discovered thermostat. Maps the panel's `HvacMode` ++ `FanMode` + `HoldMode` triple onto HA's `HVACMode`, fan modes, and preset +modes. Setpoints are translated through the `omni_temp_to_*` helpers so HA +can display whatever unit the user prefers. + +Supported features: `TARGET_TEMPERATURE` (single setpoint), or +`TARGET_TEMPERATURE_RANGE` when in `HEAT_COOL`/auto mode. Fan: `auto`, `on`, +`cycle`. Preset: `none`, `hold`, `vacation`. + +Attributes: `thermostat_index`, `humidity_percent`, `outdoor_temperature_*`, +`humidify_setpoint_raw`, `dehumidify_setpoint_raw`, `horc_status` (1=heating +active, 2=cooling active). + +## `event` + +One `OmniPanelEvent` per panel. Surfaces the typed push-event stream as a +single HA `event` entity with `event_types`: + +```text +zone_state_changed unit_state_changed arming_changed +alarm_activated alarm_cleared +ac_lost ac_restored +battery_low battery_restored +user_macro_button phone_line_dead phone_line_restored +``` + +Plus an `unknown` catch-all for the 14 less-common SystemEvent subclasses. + +Each event carries the originating dataclass's fields in `event_data` +(zone index, area, alarm type, etc.), plus a `raw_word` for debugging. + +Automations key on `platform: state` filtered by `attributes.event_type`. +See [HA services → automation example](/reference/ha-services/) for a +worked snippet. + +## `light` + +One `OmniLight` per discovered unit. Dimmer state is a single byte that +encodes a lot: + +| State byte | Meaning | +|------------|---------| +| 0 | Off | +| 1 | On (relay; no level info — exposed as 100% brightness) | +| 2..13 | Scene A..L (state - 63 → ASCII) | +| 17..25 | Dim 1..9 (state - 16) | +| 26 | Blink | +| 33..41 | Brighten 1..9 (state - 32) | +| 100..200 | Brightness 0..100% (state - 100) | + +Non-dimmable relays silently ignore brightness. Conversion is done in pure +helpers (`omni_state_to_ha_brightness`, `ha_brightness_to_omni_percent`) +unit-tested without HA in the venv. + +Attributes: `unit_index`, `time_remaining_secs` (panel-side timer for +auto-off; 0 = indefinite). + +## `sensor` + +Three flavours. + +**Per analog zone.** Zones with `ZoneType` in +{TEMPERATURE, OUTDOOR_TEMP, HUMIDITY, TEMP_ALARM, ENERGY_SAVER, FREEZE} get a +`sensor` entity instead of (or in addition to) a binary sensor. Unit is +inferred from the zone type — temperature in `°F`/`°C`, humidity as `%`, +power-related as `W`. + +**Per thermostat.** Three sub-sensors per thermostat: + +- `sensor.{name}_temperature` — current measured temp +- `sensor.{name}_humidity` — humidity percent +- `sensor.{name}_outdoor_temperature` — outdoor temp (if reported) + +**Per panel.** Two info sensors: + +- `sensor.{name}_panel_model` — model name + firmware version (state) +- `sensor.{name}_last_event` — class name of the most recent SystemEvent + (`ZoneStateChanged`, `ArmingChanged`, etc.) with the raw word as an + attribute + +## `switch` + +One `OmniZoneBypassSwitch` per binary zone, `entity_category = CONFIG`. +Toggling the switch dispatches `Command.BYPASS_ZONE` / +`Command.RESTORE_ZONE`. State mirrors the bypass diagnostic binary sensor +above. + +## What gets discovered + +Only objects with a name set on the panel are discovered — that's the +panel's own definition of "this slot is in use". To populate names, use +PC Access's "Names" page (or any other Omni programmer). On a fresh +factory-default panel you'll see zero entities; configure object names +first, then reload the integration. + +## Where to look in source + +| Class | File | +|-------|------| +| `OmniAreaAlarmPanel` | `custom_components/omni_pca/alarm_control_panel.py` | +| `OmniZoneBinarySensor`, `OmniZoneBypassBinarySensor` | `binary_sensor.py` | +| `OmniButton` | `button.py` | +| `OmniClimate` | `climate.py` | +| `OmniPanelEvent` | `event.py` | +| `OmniLight` | `light.py` | +| sensor classes | `sensor.py` | +| `OmniZoneBypassSwitch` | `switch.py` | + +All of them sit on top of `OmniDataUpdateCoordinator` (`coordinator.py`), +which keeps a long-lived `OmniClient`, runs one-time discovery on first +refresh, and patches state in-place from the typed event stream. diff --git a/src/content/docs/reference/ha-services.md b/src/content/docs/reference/ha-services.md index 2056918..bcbf841 100644 --- a/src/content/docs/reference/ha-services.md +++ b/src/content/docs/reference/ha-services.md @@ -1,6 +1,158 @@ --- -title: HA services -description: Home Assistant services exposed by the integration. +title: Home Assistant services +description: Seven services for direct panel control from automations — bypass/restore zones, run programs, show/clear messages, acknowledge alerts, and a raw command escape hatch. --- -TODO: filled by parallel agent — Home Assistant service catalog. +The integration registers seven services under the `omni_pca.*` namespace. +Every service takes an `entry_id` field with HA's `config_entry` selector so +you pick the right panel when you have multiple configured. + +Field schemas are sourced from `custom_components/omni_pca/services.yaml` +and enforced by `voluptuous` in `services.py`. + +## `omni_pca.bypass_zone` + +Bypass a single zone. The panel ignores the zone's state until restored. + +| Field | Required | Notes | +|-------|----------|-------| +| `entry_id` | yes | Config entry of the panel. | +| `zone_index` | yes | 1-based zone number (1..176). | + +```yaml +service: omni_pca.bypass_zone +data: + entry_id: 01J8K9XYZ... # from a config_entry selector + zone_index: 7 +``` + +## `omni_pca.restore_zone` + +Restore a previously bypassed zone. + +| Field | Required | Notes | +|-------|----------|-------| +| `entry_id` | yes | Config entry of the panel. | +| `zone_index` | yes | 1-based zone number (1..176). | + +```yaml +service: omni_pca.restore_zone +data: + entry_id: 01J8K9XYZ... + zone_index: 7 +``` + +## `omni_pca.execute_program` + +Run a stored program by its 1-based index. + +| Field | Required | Notes | +|-------|----------|-------| +| `entry_id` | yes | Config entry of the panel. | +| `program_index` | yes | 1-based program number (1..1024). | + +```yaml +service: omni_pca.execute_program +data: + entry_id: 01J8K9XYZ... + program_index: 42 +``` + +Useful when you know the program number from PC Access but the integration +hasn't surfaced it as a button entity (the v1.0 library doesn't have a +`RequestProperties` path for Program objects). + +## `omni_pca.show_message` + +Display a stored message on panel consoles. + +| Field | Required | Notes | +|-------|----------|-------| +| `entry_id` | yes | Config entry of the panel. | +| `message_index` | yes | 1-based stored message number (1..128). | + +```yaml +service: omni_pca.show_message +data: + entry_id: 01J8K9XYZ... + message_index: 3 +``` + +## `omni_pca.clear_message` + +Clear the currently displayed message. + +| Field | Required | Notes | +|-------|----------|-------| +| `entry_id` | yes | Config entry of the panel. | +| `message_index` | yes | 1-based message number to clear (1..128). | + +```yaml +service: omni_pca.clear_message +data: + entry_id: 01J8K9XYZ... + message_index: 3 +``` + +## `omni_pca.acknowledge_alerts` + +Acknowledge all outstanding alerts and trouble conditions. + +| Field | Required | Notes | +|-------|----------|-------| +| `entry_id` | yes | Config entry of the panel. | + +```yaml +service: omni_pca.acknowledge_alerts +data: + entry_id: 01J8K9XYZ... +``` + +## `omni_pca.send_command` + +Power-user escape hatch. Sends a raw `Command` (opcode 20) with arbitrary +parameters. See the [`Command` enum reference](/reference/library-api/) for +valid byte values. + +| Field | Required | Default | Notes | +|-------|----------|---------|-------| +| `entry_id` | yes | — | Config entry of the panel. | +| `command` | yes | — | Numeric `Command` enum value (0..255). | +| `parameter1` | no | 0 | Single byte (0..255). | +| `parameter2` | no | 0 | BE uint16 (0..65535) — almost always the object number. | + +```yaml +# Equivalent to omni_pca.execute_program with index=42: +service: omni_pca.send_command +data: + entry_id: 01J8K9XYZ... + command: 104 # Command.EXECUTE_PROGRAM + parameter1: 0 + parameter2: 42 +``` + +## Automation example + +Notify on any alarm activation in real time: + +```yaml +automation: + - alias: Notify on alarm + trigger: + - platform: state + entity_id: event.panel_events + condition: > + {{ trigger.to_state.attributes.event_type == "alarm_activated" }} + action: + - service: notify.mobile_app + data: + title: ALARM + message: > + Area {{ trigger.to_state.attributes.area_index }} + ({{ trigger.to_state.attributes.alarm_type }}) +``` + +The `event.panel_events` entity is created automatically by the integration's +[`event` platform](/reference/ha-entities/#event); it relays every typed +push event from the panel as a state-change with `event_type` and +`event_data` attributes. diff --git a/src/content/docs/reference/library-api.md b/src/content/docs/reference/library-api.md index 09d5596..1b919cb 100644 --- a/src/content/docs/reference/library-api.md +++ b/src/content/docs/reference/library-api.md @@ -1,6 +1,227 @@ --- title: Library API -description: Public surface of the omni_pca Python library. +description: Module-level reference for the omni_pca Python library — client, connection primitives, dataclasses, command and event enums, mock panel. --- -TODO: filled by parallel agent — `omni_pca` Python API surface. +`omni_pca` is split into a thin protocol layer (`crypto`, `packet`, `message`, +`connection`), a domain layer (`models`, `commands`, `events`, `opcodes`), a +high-level client (`client`), and a controller-side emulator (`mock_panel`). +This page is the surface index. Source has type hints and docstrings on every +public symbol. + +## `omni_pca.client.OmniClient` + +High-level async client. Handles the full secure-session handshake on +`__aenter__`, dispatches solicited replies via Futures, and exposes typed +methods that send the right opcode and parse the reply payload into a +[dataclass](#omni_pcamodels). Use as an async context manager. + +### Lifecycle + +| Method | Description | +|--------|-------------| +| `OmniClient(host, port=4369, *, controller_key, timeout=5.0)` | Construct. Doesn't connect yet. | +| `async __aenter__()` | Run the four-step handshake; transition to OnlineSecure. | +| `async __aexit__(...)` | Cancel subscriber task; close the TCP socket. | +| `connection` (property) | The underlying `OmniConnection` for advanced use. | + +### System info & status + +| Method | Returns | +|--------|---------| +| `await get_system_information()` | `SystemInformation` (model byte, firmware, local phone). | +| `await get_system_status()` | `SystemStatus` (panel time, battery, area-alarm bytes). | + +### Object discovery + +| Method | Returns | +|--------|---------| +| `await get_object_properties(object_type, index)` | One `*Properties` dataclass for the given object. | +| `await list_zone_names()` | `dict[int, str]` of named zones. | +| `await list_unit_names()` | `dict[int, str]` of named units. | +| `await list_area_names()` | `dict[int, str]` of named areas. | + +### Status queries + +| Method | Returns | +|--------|---------| +| `await get_object_status(object_type, start, end=None)` | List of basic-Status records (fixed per-type sizes). | +| `await get_extended_status(object_type, start, end=None)` | List of ExtendedStatus records (length-prefixed). | + +### Commands + +| Method | Wire mapping | +|--------|--------------| +| `await execute_command(command, parameter1=0, parameter2=0)` | Generic Command (opcode 20). | +| `await execute_security_command(area, mode, code)` | ExecuteSecurityCommand (opcode 74). | +| `await acknowledge_alerts()` | AcknowledgeAlerts (opcode 60). | +| `await turn_unit_on(index)` | `Command.UNIT_ON` | +| `await turn_unit_off(index)` | `Command.UNIT_OFF` | +| `await set_unit_level(index, percent)` | `Command.UNIT_LEVEL`, parameter1 = percent. | +| `await bypass_zone(index, code=0)` | `Command.BYPASS_ZONE` | +| `await restore_zone(index, code=0)` | `Command.RESTORE_ZONE` | +| `await set_thermostat_system_mode(index, mode)` | `Command.SET_THERMOSTAT_SYSTEM_MODE` | +| `await set_thermostat_fan_mode(index, mode)` | `Command.SET_THERMOSTAT_FAN_MODE` | +| `await set_thermostat_hold_mode(index, mode)` | `Command.SET_THERMOSTAT_HOLD_MODE` | +| `await set_thermostat_heat_setpoint_raw(index, raw)` | `Command.SET_THERMOSTAT_HEAT_SETPOINT` | +| `await set_thermostat_cool_setpoint_raw(index, raw)` | `Command.SET_THERMOSTAT_COOL_SETPOINT` | +| `await execute_button(index)` | `Command.EXECUTE_BUTTON` | +| `await execute_program(index)` | `Command.EXECUTE_PROGRAM` | +| `await show_message(index, beep=True)` | `Command.SHOW_MESSAGE_WITH_BEEP` / `_NO_BEEP` | +| `await clear_message(index)` | `Command.CLEAR_MESSAGE` | + +### Events + +| Method | Returns | +|--------|---------| +| `events()` | `AsyncIterator[SystemEvent]` over decoded push notifications. | +| `await subscribe(callback)` | Background task that hands every unsolicited `Message` to `callback`. | + +## `omni_pca.connection.OmniConnection` + +Low-level primitives. You usually want `OmniClient` instead. + +| Method | Description | +|--------|-------------| +| `await connect()` | Open TCP, run secure-session handshake. | +| `await close()` | Cancel reader task, send terminator, close socket. | +| `await request(opcode, payload=b"")` | Send one inner `Message`, return the matching reply. | +| `unsolicited()` | `AsyncIterator[Message]` of all push messages (any opcode). | +| `is_connected` (property) | True between successful `connect()` and `close()`. | + +Custom exceptions: `OmniConnectionError`, `HandshakeError`, +`InvalidEncryptionKeyError`, `ProtocolError`, `RequestTimeoutError`. + +## `omni_pca.models` + +Frozen-slots dataclasses for every Omni object's Properties + Status payload. +Each carries a `parse(payload: bytes) -> Self` classmethod that decodes one +record. + +| Class | Used for | +|-------|---------| +| `SystemInformation` | RequestSystemInformation reply. | +| `SystemStatus` | RequestSystemStatus reply. | +| `ZoneProperties` / `ZoneStatus` | Per-zone configuration / live state. | +| `UnitProperties` / `UnitStatus` | Per-unit (light/relay/scene). | +| `AreaProperties` / `AreaStatus` | Per-area (security mode + alarms). | +| `ThermostatProperties` / `ThermostatStatus` | Per-thermostat config + readings. | +| `ButtonProperties` | Index + name (buttons carry no state). | +| `ProgramProperties` | Index + raw 14-byte program body + optional remark. | +| `CodeProperties` | User code slot (name only — digits never exposed). | +| `MessageProperties` | Stored panel message. | +| `AuxSensorStatus` | Auxiliary temp/humidity/output sensor. | +| `AudioZoneProperties` / `AudioZoneStatus` | Audio zone (power, source, volume, mute). | +| `AudioSourceProperties` / `AudioSourceStatus` | Audio source metadata stream. | +| `UserSettingProperties` / `UserSettingStatus` | Programmable user-setting value. | + +Plus enums: `ObjectType`, `SecurityMode`, `HvacMode`, `FanMode`, `HoldMode`, +`ThermostatKind`, `ZoneType`, `UserSettingKind`. + +Two temperature converters mirror `clsText.DecodeTempRaw`: + +```python +omni_temp_to_celsius(raw) # °C = raw / 2 - 40 +omni_temp_to_fahrenheit(raw) # °F = int(raw * 9 / 10 + 0.5) - 40 (matches PC Access display) +``` + +Dispatch tables: `OBJECT_TYPE_TO_PROPERTIES`, `OBJECT_TYPE_TO_STATUS`. +Type unions: `PropertiesReply`, `StatusReply`. + +## `omni_pca.commands.Command` + +The `Command` `IntEnum` (64 values) sourced from `enuUnitCommand.cs`. Every +member cites the line in the C# source where its byte value is defined. +Members are grouped: unit/lighting (`UNIT_OFF`..`RADIO_RA_PHANTOM_ON`), +security (`SECURITY_OFF`..`SECURITY_ARMING_NIGHT_DELAYED`), energy +(`ENERGY_OFF`/`ENERGY_ON`), thermostat (`SET_THERMOSTAT_HEAT_SETPOINT`.. +`SET_THERMOSTAT_DEHUMIDIFY_SETPOINT`), display messages +(`SHOW_MESSAGE_WITH_BEEP`..`EMAIL_MESSAGE`), scenes/misc +(`SCENE_OFF`..`STOP`), audio (`AUDIO_ZONE`..`AUDIO_KEY_PRESS`). + +Source: [`omni_pca/commands.py`](https://github.com/rsp2k/omni-pca/blob/main/src/omni_pca/commands.py). + +Companion types: + +- `SecurityCommandResponse` — status byte returned by ExecuteSecurityCommand + (SUCCESS, INVALID_CODE, INVALID_SECURITY_MODE, INVALID_AREA, ZONES_NOT_READY, + INSTALLER_RESTORE_NEEDED, CODE_LOCKED_OUT, INVALID). +- `CommandFailedError(ProtocolError)` — raised on Nak or non-zero + ExecuteSecurityCommandResponse status. `failure_code` attribute carries + the raw status byte. + +## `omni_pca.events.SystemEvent` + +Base class for every typed push event. The panel batches notifications into +a single `SystemEvents` (opcode 55) message; each batched event is a single +16-bit big-endian word in the message payload, classified by bit-field into +one of 26 typed subclasses: + +`UserMacroButton`, `ProLinkMessage`, `CentraLiteSwitch`, `AlarmActivated`, +`AlarmCleared`, `ZoneStateChanged`, `UnitStateChanged`, `X10CodeReceived`, +`AllOnOff`, `PhoneLineDead`, `PhoneLineRinging`, `PhoneLineOffHook`, +`PhoneLineOnHook`, `AcLost`, `AcRestored`, `BatteryLow`, `BatteryRestored`, +`DcmTrouble`, `DcmOk`, `EnergyCostChanged`, `CameraTrigger`, +`AccessReaderEvent`, `UpbLinkEvent`, `ArmingChanged`, plus an `UnknownEvent` +catch-all. + +Helpers: + +- `parse_events(message)` — decode one `SystemEvents` message into a list of + typed events. +- `EventStream(source)` — async iterator that flattens batches across an + underlying connection's `unsolicited()` stream. Yields one typed event per + iterator step. +- `EVENT_REGISTRY: dict[int, type[SystemEvent]]` — discriminator-to-class + map for routing. + +## `omni_pca.opcodes` + +Three `IntEnum`s, byte-exact to the C# decompilation: + +- `PacketType` — outer-frame packet type (12 values). +- `OmniLinkMessageType` — inner v1 (legacy) opcode (104 values). +- `OmniLink2MessageType` — inner v2 (modern TCP) opcode (83 values). + +Plus `ConnectionType` (None/Modem/Serial/Network_UDP/Network_TCP) and +`ProtocolVersion` (V1/V2). + +## `omni_pca.mock_panel.MockPanel` + +A controller-side emulator: a TCP server that speaks the same Omni-Link II +protocol as a real panel. Handshake, encryption, framing all match +bidirectionally. Used by the test suite (17 e2e tests connect a real +`OmniClient` to a real `MockPanel` over real TCP) and by the dev docker +stack for clicking around the HA integration without hardware. + +```python +import asyncio +from omni_pca.mock_panel import MockPanel + +async def main(): + async with MockPanel( + controller_key=bytes.fromhex("000102030405060708090a0b0c0d0e0f"), + ).serve(port=14369): + await asyncio.sleep(3600) # serve until cancelled + +asyncio.run(main()) +``` + +The mock seeds itself with a small set of zones, units, areas, thermostats, +buttons, codes; mutates state on `Command` / `ExecuteSecurityCommand`; and +synthesizes `SystemEvents` push messages on every state change. + +## CLI + +The package ships a `omni-pca` console script: + +| Command | Description | +|---------|-------------| +| `omni-pca decode-pca FILE [--field NAME] [--include-pii]` | Decrypt and parse a `.pca` file; print one field or the whole structure. | +| `omni-pca mock-panel [--port 14369] [--key HEX]` | Run the mock panel as a standalone server. | +| `omni-pca version` | Print package version. | + +--- + +For full details, the source has type hints and docstrings everywhere; see +[github.com/rsp2k/omni-pca](https://github.com/rsp2k/omni-pca). diff --git a/src/content/docs/reference/protocol.md b/src/content/docs/reference/protocol.md index 30ae671..568859c 100644 --- a/src/content/docs/reference/protocol.md +++ b/src/content/docs/reference/protocol.md @@ -1,6 +1,345 @@ --- -title: Protocol -description: Omni-Link II handshake, packet/message structure, and opcode reference. +title: Omni-Link II protocol +description: Byte-level handshake, packet layouts, key derivation, and steady-state encryption rules for Omni-Link II as implemented by HAI's PC Access 3.17. --- -TODO: filled by parallel agent — handshake, packet/message structure, opcodes. +TCP/v2 PC Access opens a secure session by exchanging two unencrypted control +packets to derive a per-session AES-128-ECB key from the panel's 16-byte +`ControllerKey` XOR-mixed with a 5-byte controller-supplied `SessionID`, then +everything that follows is AES-ECB-encrypted with a per-block sequence-number +XOR pre-whitening. **There is no separate v2 Login step on TCP** — possessing +the right `ControllerKey` *is* the authentication. + +All line citations are into `decompiled/project/HAI_Shared/clsOmniLinkConnection.cs` +unless otherwise noted. + +:::note[The two surprises] +Two parts of the handshake are not in any third-party Omni-Link writeup we +found. They are load-bearing: skip either and the panel drops your session. +See [the quirks explainer](/explanation/quirks/) for why they exist. +::: + +## TL;DR — the load-bearing surprises + +1. **Session key is NOT just the ControllerKey.** Bytes `[11..16)` (the last + 5 bytes) are XORed with the 5-byte SessionID returned by the controller. + Bytes `[0..11)` are the ControllerKey verbatim. (lines 1423-1429 / 1886-1892) +2. **Per-block pre-whitening before AES.** Before `AES.Encrypt` is called on + the packet payload, the *first two bytes of every 16-byte block* are XORed + with the packet's 16-bit sequence number (high byte first). Decryption + reverses it. (lines 396-401 / 413-417) +3. **No separate v2 Login on TCP.** `clsOL2MsgLogin` is defined but never + constructed in the decompiled binary on the TCP path. Once + `ControllerAckSecureSession` arrives, PC Access immediately starts issuing + real commands. v2 `Login` (opcode 42) appears to be a serial-only / legacy + artifact for TCP usage. The v1 serial path *does* send `clsOLMsgLogin` + (lines 1137-1162). +4. **Sequence number is per-direction monotonic, never resets, and the + controller echoes the client's seq on solicited replies.** Unsolicited + packets arrive with seq = 0. (lines 1796, 1389, 1847) +5. **PaddingMode.Zeros + per-block reads.** TCP framing reads exactly one + 16-byte AES block first, decrypts it to learn `MessageLength`, then reads + enough additional 16-byte blocks to cover the rest. (lines 1731-1759) +6. **`ControllerAckNewSession` protocol-version field is `00 01` literal**, + not a free-form ushort. PC Access hard-rejects anything else. (lines 1416, 1879) + +## Connect-to-first-command flow (TCP / Omni-Link II v2) + +| # | Sender | Packet type | Outer seq | Encrypted? | Payload bytes (after 4-byte header) | Expected response | +|---|--------|-------------|-----------|------------|--------------------------------------|-------------------| +| 0 | client | (TCP SYN to `:4369`) | — | — | — | TCP SYN-ACK | +| 1 | client | `ClientRequestNewSession` (0x01) | `1` | no | *empty* (data length = 0) | `ControllerAckNewSession` | +| 2 | controller | `ControllerAckNewSession` (0x02) | `1` (echoes client) | no | 7 bytes: `00 01` + 5-byte SessionID | client computes SessionKey, sends step 3 | +| 3 | client | `ClientRequestSecureSession` (0x03) | `2` | **yes (AES with new SessionKey)** | 5 bytes = SessionID, padded to 16, XOR-whitened, AES-encrypted (ciphertext is 16 bytes) | `ControllerAckSecureSession` | +| 4 | controller | `ControllerAckSecureSession` (0x04) | `2` | yes | 16 bytes ciphertext (decrypts to 5-byte SessionID + zero pad — the controller proves it knows the key by encrypting the same SessionID back) | client transitions to `OnlineSecure` | +| 5 | client | `OmniLink2Message` (0x20) wrapping any v2 opcode (e.g., `RequestSystemInformation` = 22) | `3` | yes | inner v2 message, padded, whitened, AES'd | matching v2 reply | + +Implementation detail (line 1697): the client calls `tcpSend()` only once +before entering the receive loop — the same loop drains response-handler +callbacks (`HRP`) which queue the next packet, so step-3 is enqueued *inside* +the response handler for step-2 and gets sent automatically (lines 1864-1921). + +Encryption applies on transmit only when `PKT.Data != null && PKT.Data.Length > 1` +(line 374). The empty-payload `ClientRequestNewSession` is therefore sent in +the clear regardless of the `OmniLink2Message` packet type's "encrypted" +semantics. + +On `ControllerSessionTerminated` arriving instead of `ControllerAckSecureSession`, +the client treats it as `InvalidEncryptionKey` — the controller's way of +saying "your derived key didn't decrypt my echo correctly" (lines 1477-1480 +UDP / 1808 TCP). + +## Packet payload byte layouts + +All offsets are **into the packet payload**, i.e., after the 4-byte outer +header (`[seq_hi][seq_lo][type][reserved=0]`). + +### `ClientRequestNewSession` (type 0x01) + +| Offset | Size | Field | Notes | +|-------:|-----:|-------|-------| +| — | 0 | (no payload) | `clsOmniLinkPacket.Data == null` (line 1283/1688). Wire = 4 bytes total: `00 01 01 00`. | + +### `ControllerAckNewSession` (type 0x02) + +Payload size **7 bytes** (TCP reader hardcodes `tcpReadBytes(array, 7)` on this +type, line 1714). + +| Offset | Size | Field | Notes | +|-------:|-----:|-------|-------| +| 0 | 1 | ProtoVersionHi | must be `0x00` (line 1416) | +| 1 | 1 | ProtoVersionLo | must be `0x01` (line 1416). Together they encode "Omni-Link II protocol v0001". | +| 2 | 5 | SessionID | random nonce. Stored into `SessionID[0..4]` (lines 1418-1422). | + +Total wire packet: 4-byte header + 7-byte payload = 11 bytes. + +### `ClientRequestSecureSession` (type 0x03) + +Payload **before encryption** is 5 bytes; **on the wire** it is 16 bytes (one +AES block). + +Plaintext layout (lines 1430-1438): + +| Offset | Size | Field | Notes | +|-------:|-----:|-------|-------| +| 0 | 5 | SessionID | echo of the controller's nonce | +| 5 | 11 | zero pad | added by `EncryptPacket` (`PaddingMode.Zeros`, line 382-393) | + +Then `EncryptPacket` runs (lines 396-401): +1. For block 0 (the only block): `data[0] ^= seq_hi`, `data[1] ^= seq_lo`. +2. `AES.Encrypt(data)` using **the freshly derived SessionKey** — so the + controller can only decrypt this if it computed the same key from its own + `ControllerKey` and the SessionID it generated. + +### `ControllerAckSecureSession` (type 0x04) + +Payload size **16 bytes** on the wire (TCP reader hardcodes +`tcpReadBytes(array, 16)`, line 1722-1729). + +After `DecryptPacket` (un-AES + un-XOR-whitening), plaintext is symmetric to +step 3: 5 bytes of SessionID + 11 zero bytes. The client doesn't actually +re-validate the contents — it just trusts that successful AES decryption (no +exception) means key match (lines 1471-1475 / 1933-1937). `clsAES.Decrypt` +returns `null` on exception (line 53 of `clsAES.cs`); `DecryptPacket` returns +`false` then; the response handler still treats it as an unrecognized reply +and disconnects. + +ECB has no integrity check. AES decryption with the wrong key returns 16 +bytes of garbage (no exception), so the *only* thing that protects the client +from accepting a wrong-key ack is the controller pre-validating step 3 and +sending `ControllerSessionTerminated` instead. + +### v2 `Login` (inner opcode 42) — *defined but unused on TCP* + +If the protocol ever calls for it, the layout is (`clsOL2MsgLogin.cs`): + +| Offset | Size | Field | Notes | +|-------:|-----:|-------|-------| +| 0 | 1 | StartChar | `0x21` | +| 1 | 1 | MessageLength | `5` | +| 2 | 1 | opcode | `42` (`Login`) | +| 3 | 1 | Code1 | digit 1 of PIN, packed as raw byte (NOT ASCII — value is the digit `0..9`) | +| 4 | 1 | Code2 | digit 2 | +| 5 | 1 | Code3 | digit 3 | +| 6 | 1 | Code4 | digit 4 | +| 7 | 2 | CRC | CRC-16/MODBUS over bytes 1..6 | + +Compare to **v1 `Login` (`clsOLMsgLogin.cs`)** — identical layout but with +`MessageType = enuOmniLinkMessageType.Login` (=32 in the v1 enum). The v1 +version *is* sent by `serialHandleLoginTest` after a successful serial CTS +handshake (lines 1137-1160) using either `SerialCode` (typed-in user PIN) +or `1,1,1,1` if no code is set. + +Response opcode for Login would be `Ack=1` on success or `Nak=2` on bad PIN +(`clsOL2MsgAcknowledge.cs`, `clsOL2MsgNegativeAcknowledge.cs`) — payload is +just `[0x21][0x01][0x01 or 0x02][CRC1][CRC2]`. But again, **the TCP path +never sends it**. + +## Session key derivation + +The single most important paragraph in this document. + +```text +SessionKey[16] = ControllerKey[0..11) || (ControllerKey[11..16) XOR SessionID[0..5)) +``` + +Pseudocode: + +```python +SessionKey = bytearray(ControllerKey) # copy 16 bytes +for j in range(5): + SessionKey[11 + j] ^= SessionID[j] # last 5 bytes only +``` + +Source proof (TCP, `clsOmniLinkConnection.cs:1886-1892`): + +```csharp +SessionKey = new byte[16]; +ControllerKey.CopyTo(SessionKey, 0); +for (int j = 0; j < 5; j++) +{ + SessionKey[11 + j] = (byte)(ControllerKey[11 + j] ^ SessionID[j]); +} +AES = new clsAES(SessionKey); +``` + +Identical block at `clsOmniLinkConnection.cs:1423-1429` for the UDP path. The +session key is also used as the AES IV (irrelevant in ECB), via `clsAES`'s +constructor (line 21 of `clsAES.cs`). + +The ControllerKey is the 16-byte panel-side AES key that lives in the panel's +NVRAM and inside the encrypted `.pca` config file (see [the file format +reference](/reference/file-format/) — extracted from `Connection.ControllerKey` +at `clsHAC.cs:8044-8056`). + +**No PIN, no installer code, no salt, no KDF.** Pure XOR-mixing — anyone with +a packet capture and the ControllerKey can compute the SessionKey trivially. + +## Steady-state encryption + +For every outbound encrypted packet (`OmniLinkMessage` 0x10 or +`OmniLink2Message` 0x20): + +1. Build the inner-message bytes: + `[StartChar=0x41|0x21][MessageLength][...payload...][CRC1][CRC2]` + (CRC = CRC-16/MODBUS over `[MessageLength..end-of-payload]`, see + `clsOmniLinkMessage._crcCalculate`). +2. Total size = `MessageLength + 4`. Zero-pad to next multiple of 16 (lines + 378-395). +3. **For each 16-byte block**, XOR the first two bytes with the outer + packet's sequence number: `block[0] ^= seq_hi; block[1] ^= seq_lo;` + (lines 396-400). Same XOR is applied to *every* block, not just the first. +4. AES-128-ECB encrypt the whole padded buffer with `SessionKey` (line 401). + `PaddingMode.Zeros` is set but at this point the buffer is already a + 16-byte multiple, so AES adds no further padding. +5. Frame as `[seq_hi][seq_lo][0x20][0x00] + ciphertext`. + +Inverse on receive: AES-decrypt → XOR-unwhiten the first two bytes of every +block with the outer seq → consume. + +### Receive framing on TCP (lines 1731-1759) + +- Read 4-byte header. +- Read **exactly one 16-byte block** (`tcpReadBytes(array, 16)`). +- Build a temp packet with that one block, decrypt it. +- Look at decrypted `Data[1]` (= the inner `MessageLength` field — `Data[0]` + is StartChar `0x21`). +- Total inner-message size = `MessageLength + 4`; we already have 16 bytes; + need `MessageLength + 4 - 16 = MessageLength - 12` more bytes, **rounded up + to a multiple of 16**. +- Read those extra bytes, append, pass the *full* ciphertext through + `DecryptPacket` again at the higher level. + +:::caution[Gotcha for a Python client] +The listener decrypts the first block twice — once "peeking" to learn +`MessageLength`, then again on the assembled buffer. AES-ECB is stateless so +this works, but make sure your decrypt-twice doesn't accidentally double-XOR +the whitening. The reference code calls `DecryptPacket` on a *fresh* +`clsOmniLinkPacket` each time (lines 1738, 1824). +::: + +Per-message encryption is opt-out via `clsOmniLinkMessageQueueItem.Encrypt` +(line 495). When `Encrypt = false`, the packet type is downgraded to +`OmniLink2UnencryptedMessage` (0x21) and `EncryptPacket` is bypassed (lines +2001-2008). HAI_Shared never sets `Encrypt = false` once `OnlineSecure` — +the field exists for the Login-on-serial path. + +## Sequence numbers, keepalive, teardown + +### Sequence numbers + +- Client side: `pktSequence` is set to **1** on TCP/UDP connect (lines 1251, + 1619), then `pktSequence++` happens **inside `tcpSend`/`udpSend` immediately + before sending** (lines 1525, 1987). So the very first + `ClientRequestNewSession` goes out with seq = 2: connect sets + `pktSequence = 1`, then `tcpSend` increments to 2 and sends. The original + "1" is the post-init value, the wire value is 2. Subsequent client + packets are 3, 4, 5, … +- The controller **echoes** the client's seq on solicited replies (line 1796: + `clsOmniLinkPacket2.SequenceNumber == pktSequence` is the match condition). +- **Unsolicited** packets (alarm events, status changes) arrive with `seq = 0` + and are routed to `HUP` (`HandleUnsolicitedPacketDelegate`, lines 1389-1397, + 1847-1854). +- Wraparound: `pktSequence` is `ushort`. After 65535 it overflows to 0 in C#. + The 0 value collides with the "unsolicited" semantics — the source has no + special-case wraparound code. `omni-pca` skips 0 on wrap. +- Encrypted whitening is keyed off the seq, so a Python client *must* keep + the client and controller views of seq in lock-step. + +### Keepalive + +- `WatchdogInterval = 30000` ms (line 19). Set in the `clsOmniLinkConnection` + constructor. +- `WatchdogTimeout` (lines 334-351) fires when 30s elapses with no packet + activity AND the message queue is empty: + - Protocol v1: sends `clsOLMsgAcknowledge` + - Protocol v2: sends `clsOL2MsgAcknowledge` (opcode 1, `Ack`) +- The watchdog is "armed" by `Watchdog.Change(WatchdogInterval, -1)` everywhere + a packet is sent; each send resets the 30s clock. +- So the keepalive cadence is "30s of silence → emit a 1-byte Ack message". + +### Teardown + +- **Graceful client teardown (UDP):** `udpDisconnect` sends a + `ClientSessionTerminated` (0x05) packet with the next `pktSequence` and + *no payload* (lines 1501-1505), sleeps 100 ms, then closes the socket. +- **Graceful client teardown (TCP):** `tcpDisconnect` (lines 1950-1969) + **does NOT send `ClientSessionTerminated`**. It just closes the socket. + The controller is expected to notice TCP RST/FIN and clean up its session + state on its own. A Python client should probably send the terminator + anyway for cleanliness. +- **Controller-initiated teardown:** `ControllerSessionTerminated` (0x06). + Client transitions to `Offline` and reports `ControllerSessionTerminated` + (line 1804) or, if it arrived before secure-session was up, + `InvalidEncryptionKey` (lines 1480, 1808). +- **`ControllerCannotStartNewSession` (0x07):** sent by panel if it's at max + sessions, etc. Client gives up immediately (lines 1449-1452). + +## Open questions + +1. **`ClientSessionTerminated` payload** — `udpDisconnect` writes a 4-byte + header with empty Data. Is the controller fine with that, or does it + expect the SessionID echoed back? The source unambiguously sends nothing, + so we trust it, but worth verifying on a live panel. +2. **`ControllerAckSecureSession` plaintext** — asserted to be "5 bytes + SessionID + 11 zeros" based on the symmetric design, but the client never + inspects the plaintext — it only checks that decryption didn't throw. The + actual bytes the panel returns could be anything (e.g., all-zero, or a + different challenge). Need a packet capture to confirm. +3. **Session ID uniqueness** — the source treats SessionID as a 5-byte + opaque blob; nothing constrains it to be random. If the panel issues a + predictable / time-based / counter SessionID, the per-session key + collapses to just the ControllerKey for any given ID. Not a flaw to fix + in our client, but a fingerprinting opportunity. +4. **v2 Login on TCP** — `clsOL2MsgLogin` is defined but never instantiated. + Strongly suggests the v2/TCP path never uses it. The higher-level UI + (`PCAccess3` namespace) might construct it via reflection or + string-based dispatch. If a real panel rejects post-handshake commands + without a Login, the next thing to try is sending a `clsOL2MsgLogin` with + the user's PIN as 4 raw bytes and looking for `Ack`/`Nak`. +5. **Sequence number wraparound** — untested. The source has no special-case + logic; presumably PC Access just never runs that long. A long-running + daemon should at least log when it's about to wrap, or force a session + restart at seq ≈ 65000. +6. **Per-block XOR-whitening: every block, not just the first.** The source + unambiguously XORs the *same two bytes of the seq* into *every* block's + first two bytes (line 396-400 loop). All blocks within one packet get + identical whitening. Confirmed by re-reading three times — it does feel + weak (an attacker who has known-plaintext for one block can recover the + seq XOR mask, and from there the AES key bit is unprotected). +7. **`ControllerAckNewSession` byte 0/1 = `00 01`** — called "ProtoVersionHi/Lo" + here by analogy with the published Omni-Link spec. The source just + hard-checks `B[4]==0 && B[5]==1`. Could equally be a flag word or a + "new-session-OK" status code. Doesn't change client behaviour, but worth + noting. + +## Cross-references + +- Outer packet layout: `clsOmniLinkPacket.cs` +- Inner v2 message layout: `clsOmniLink2Message.cs`, `clsOmniLinkMessage.cs` +- AES wrapper: `clsAES.cs` (just a thin `RijndaelManaged` shim) +- Packet types enum: `enuOmniLinkPacketType.cs` +- v2 opcodes enum: `enuOmniLink2MessageType.cs` +- Where `ControllerKey` lives in the `.pca` file: see [the file format + reference](/reference/file-format/) — extracted from + `Connection.ControllerKey` at `clsHAC.cs:8044-8056`.