Content + docker pin: 13-page Starlight site live behind Caddy

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.
This commit is contained in:
Ryan Malloy 2026-05-10 17:05:25 -06:00
parent c5e72c679b
commit 9dbe563aed
12 changed files with 2874 additions and 29 deletions

2
.gitignore vendored
View File

@ -22,3 +22,5 @@ pnpm-debug.log*
# macOS-specific files
.DS_Store
.env
.env.local

View File

@ -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

View File

@ -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 <file> [--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

View File

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

View File

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

View File

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

View File

@ -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 32127 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.

View File

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

View File

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

View File

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

View File

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

View File

@ -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`.