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:
parent
c5e72c679b
commit
9dbe563aed
2
.gitignore
vendored
2
.gitignore
vendored
@ -22,3 +22,5 @@ pnpm-debug.log*
|
|||||||
|
|
||||||
# macOS-specific files
|
# macOS-specific files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
# ---- builder ----
|
# ---- 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 \
|
ENV ASTRO_TELEMETRY_DISABLED=1 \
|
||||||
NODE_ENV=production \
|
NODE_ENV=production \
|
||||||
@ -19,7 +21,7 @@ COPY . .
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ---- runtime ----
|
# ---- runtime ----
|
||||||
FROM caddy:2-alpine AS runtime
|
FROM caddy:latest AS runtime
|
||||||
|
|
||||||
# Run as non-root.
|
# Run as non-root.
|
||||||
RUN addgroup -S docs && adduser -S -G docs docs
|
RUN addgroup -S docs && adduser -S -G docs docs
|
||||||
|
|||||||
@ -1,6 +1,199 @@
|
|||||||
---
|
---
|
||||||
title: Changelog
|
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
|
||||||
|
|||||||
@ -1,6 +1,189 @@
|
|||||||
---
|
---
|
||||||
title: Architecture
|
title: Architecture overview
|
||||||
description: How the library, the integration, and the panel fit together.
|
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.
|
||||||
|
|||||||
@ -1,6 +1,161 @@
|
|||||||
---
|
---
|
||||||
title: The PC Access bug
|
title: The PC Access LargeVocabulary bug
|
||||||
description: A latent defect in HAI's official client and why it matters.
|
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.
|
||||||
|
|||||||
@ -1,6 +1,211 @@
|
|||||||
---
|
---
|
||||||
title: The two non-public quirks
|
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.
|
||||||
|
|||||||
@ -1,6 +1,903 @@
|
|||||||
---
|
---
|
||||||
title: Journey
|
title: The Journey
|
||||||
description: Chronological retrospective of the omni-pca reverse-engineering work.
|
description: Chronological retrospective of a few days reverse-engineering HAI's PC Access 3.17 and building a Python library and Home Assistant integration on top of it.
|
||||||
---
|
---
|
||||||
|
|
||||||
TODO: filled by parallel agent — chronological retrospective of the reverse-engineering work.
|
Raw chronological notes from a few days reverse-engineering HAI's PC Access
|
||||||
|
3.17, then writing a Python library and a Home Assistant integration to talk
|
||||||
|
to the panel directly. Dated. Append-only-ish.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-05-10 morning — the pile of binaries
|
||||||
|
|
||||||
|
We started with a directory called `PC Access/` that had clearly been zipped
|
||||||
|
up off a Mac and handed around. The giveaway was `._*` files next to every
|
||||||
|
real file:
|
||||||
|
|
||||||
|
```text
|
||||||
|
-rw------- 1 kdm kdm 120 Aug 15 2016 ._Newtonsoft.Json.dll
|
||||||
|
-rw------- 1 kdm kdm 484352 Aug 15 2016 Newtonsoft.Json.dll
|
||||||
|
```
|
||||||
|
|
||||||
|
That's AppleDouble cruft: macOS extended attributes shimmed into companion
|
||||||
|
files when an HFS+ volume gets archived to a non-Apple filesystem. 120 bytes
|
||||||
|
of resource fork garbage per real file. Useless. Touched everything from the
|
||||||
|
PC Access install date (Mar 2018) all the way back to a 2006 firmware
|
||||||
|
updater. Whoever extracted this had been carrying it across Macs for years.
|
||||||
|
|
||||||
|
What we actually had:
|
||||||
|
|
||||||
|
| File | Size | What it is |
|
||||||
|
|------|-----:|-----|
|
||||||
|
| `PCA3U_EN.exe` | 5.4 MB | The PC Access GUI, a .NET assembly (v3.17.0.843, 2018-01-02) |
|
||||||
|
| `PCA1106W.exe` | 3.3 MB | Older native C++ version from 2008 |
|
||||||
|
| `f_update.exe` | 437 KB | Native firmware updater (2006) |
|
||||||
|
| `OT7FileUploaderLib.dll` | 16 KB | OmniTouch 7 firmware uploader |
|
||||||
|
| `Our House.pca` | 144 KB | A panel config file. High entropy. Not ours. |
|
||||||
|
| `PCA01.CFG` | 318 B | App settings. Also encrypted. |
|
||||||
|
| `Serial Number.txt` | 20 B | A 20-char license key |
|
||||||
|
|
||||||
|
`Our House.pca` was the interesting one. Entropy 7.994 bits per byte —
|
||||||
|
either compressed, encrypted, or both. No magic bytes. No structure visible
|
||||||
|
in the first 256 bytes. It also had someone else's account name embedded in
|
||||||
|
the metadata: this panel had been bought used and shipped with the previous
|
||||||
|
owner's config still on it. We held that thought.
|
||||||
|
|
||||||
|
`file PCA3U_EN.exe` came back with `Mono/.Net assembly`. That was the single
|
||||||
|
biggest piece of luck in the whole project: a .NET assembly means
|
||||||
|
ilspycmd will give us back readable C# in seconds. Beats staring at IDA
|
||||||
|
listings of Borland C++ runtime stubs all afternoon, which is what
|
||||||
|
`PCA1106W.exe` would have made us do.
|
||||||
|
|
||||||
|
## 2026-05-10 — decompile and skim
|
||||||
|
|
||||||
|
We ran ilspycmd 10.0.1.8346 over `PCA3U_EN.exe`. 898 typedefs. They cleanly
|
||||||
|
split into two namespaces:
|
||||||
|
|
||||||
|
- `HAI_Shared` — the domain model, the wire protocol, the crypto, all of
|
||||||
|
it reusable across HAI's product line (Omni, Lumina, HMS).
|
||||||
|
- `PCAccess3` — just UI. Forms, controls, window positions.
|
||||||
|
|
||||||
|
That's the prize: `HAI_Shared` is essentially a free protocol implementation
|
||||||
|
library, written by people who actually know how the panel works, sitting
|
||||||
|
there in C# waiting to be read.
|
||||||
|
|
||||||
|
First skim of `HAI_Shared`:
|
||||||
|
|
||||||
|
- `clsOmniLinkPacket` — outer transport packet. 4-byte header
|
||||||
|
(`[seq_hi][seq_lo][type][reserved=0]`) + payload. Sequence number is
|
||||||
|
big-endian. There are 12 packet types: NewSession, AckNewSession,
|
||||||
|
RequestSecureSession, AckSecureSession, two flavors of SessionTerminated,
|
||||||
|
the `OmniLinkMessage` (encrypted, v1) and `OmniLink2Message` (encrypted,
|
||||||
|
v2) wrappers, plus their unencrypted twins.
|
||||||
|
- `clsOmniLinkMessage` — inner application message.
|
||||||
|
`[StartChar][MessageLength][...payload, payload[0]=opcode...][CRC_lo][CRC_hi]`.
|
||||||
|
CRC is CRC-16/MODBUS with poly `0xA001`. Standard.
|
||||||
|
- `clsAES` — the panel's symmetric crypto. AES-128, ECB,
|
||||||
|
`PaddingMode.Zeros`, key reused as IV (which is fine in ECB but a code
|
||||||
|
smell that hints at someone copy-pasting from a textbook).
|
||||||
|
- `enuOmniLink2MessageType` — 83 v2 opcodes. Login, Logout,
|
||||||
|
RequestSystemInformation, RequestExtendedStatus, Command, ZigBee
|
||||||
|
pass-through, firmware upload, etc.
|
||||||
|
- `clsCapOMNI_PRO_II`, `clsCapLUMINA`, `clsCapHMS950e`, … — per-model
|
||||||
|
capability classes carrying constants like `numZones=176`,
|
||||||
|
`numUnits=511`. Real domain model, not a config file.
|
||||||
|
|
||||||
|
Wrote those down in our findings notes and pushed on.
|
||||||
|
|
||||||
|
## 2026-05-10 — the cipher that wasn't AES
|
||||||
|
|
||||||
|
Then we hit the file format. The `.pca` and `.CFG` blobs *look* like AES-CBC
|
||||||
|
ciphertext. They aren't. From `clsPcaCryptFileStream`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private byte oldRandom(byte max) {
|
||||||
|
RandomSeed = RandomSeed * 134775813 + 1;
|
||||||
|
return (byte)((RandomSeed >> 16) % max);
|
||||||
|
}
|
||||||
|
// per byte: ciphertext = plaintext ^ oldRandom(255) // mod 255, not 256
|
||||||
|
```
|
||||||
|
|
||||||
|
That multiplier — `134775813` = `0x08088405` — is the Borland Delphi /
|
||||||
|
Turbo Pascal `Random()` LCG. So someone wrote this thing in Delphi
|
||||||
|
originally, ported it to C#, and kept the exact same PRNG so existing
|
||||||
|
.pca files would still decrypt. The mod-255 (not 256) stays in too, which
|
||||||
|
means the keystream byte is in `[0..254]`, never `0xFF`. It doesn't lose
|
||||||
|
information — it just shifts the output distribution. Quirky but not broken.
|
||||||
|
|
||||||
|
Two hardcoded 32-bit keys live in `clsPcaCfg`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private readonly uint keyPC01 = 338847091u; // 0x142A3D33 — for PCA01.CFG
|
||||||
|
public readonly uint keyExport = 391549495u; // for exported .pca files
|
||||||
|
```
|
||||||
|
|
||||||
|
And a third path: `SetSecurityStamp(string S)` derives a per-installation
|
||||||
|
key from a stamp string:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
uint num = 305419896u; // 0x12345678 — developer Easter egg as init value
|
||||||
|
foreach (char c in S)
|
||||||
|
num = ((num ^ c) << 7) ^ c;
|
||||||
|
Key = num;
|
||||||
|
```
|
||||||
|
|
||||||
|
`0x12345678` as an init constant is the giveaway: someone was bored at the
|
||||||
|
keyboard the day they wrote this. It's the kind of thing you grep for. The
|
||||||
|
actual hash function, `((k ^ c) << 7) ^ c`, is fine — not cryptographic,
|
||||||
|
but fine for "let me derive a per-install key from a serial number."
|
||||||
|
|
||||||
|
## 2026-05-10 — the wrong-key-looks-right problem
|
||||||
|
|
||||||
|
We wrote a Python decryptor in maybe an hour: a generator that yields
|
||||||
|
keystream bytes, an XOR over the file. Easy.
|
||||||
|
|
||||||
|
Then we hit a subtle thing. The first script auto-tried the two known keys
|
||||||
|
and picked the one whose plaintext "looked more printable". It picked
|
||||||
|
`keyExport`, ran the parser, and got nonsense — but a *plausible* kind of
|
||||||
|
nonsense: short non-empty strings, non-zero counter values, generally the
|
||||||
|
texture of real binary data.
|
||||||
|
|
||||||
|
Turns out **printable-character ratio is a terrible heuristic for binary
|
||||||
|
file plaintext.** Random noise is, on average, slightly more "printable"
|
||||||
|
than a real binary file padded with zeros and length-prefixed strings —
|
||||||
|
because random noise has a uniform distribution and a real file has long
|
||||||
|
runs of `0x00` (which falls outside the 32–127 printable range).
|
||||||
|
|
||||||
|
We replaced it with something concrete and stupid:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def score(pt):
|
||||||
|
n = pt[0]
|
||||||
|
if not (1 <= n <= 64): return 0
|
||||||
|
tag = pt[1:1+n]
|
||||||
|
if all(32 <= b < 127 for b in tag):
|
||||||
|
return 100 + n
|
||||||
|
return 0
|
||||||
|
```
|
||||||
|
|
||||||
|
The first byte is a String8 length, and the next `n` bytes should be the
|
||||||
|
ASCII version tag like `CFG05` or `PCA03`. If it parses cleanly, the key is
|
||||||
|
right; if not, it isn't. Robust because it's not statistical.
|
||||||
|
|
||||||
|
`PCA01.CFG` decrypted with `keyPC01`. First bytes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
00000000 05 43 46 47 30 35 17 41 ... .CFG05.A
|
||||||
|
```
|
||||||
|
|
||||||
|
`CFG05`. Format version 5. Walked the rest of the schema (modem strings,
|
||||||
|
port number, key field, password) and pulled out the prize:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pca_key = 0xC1A280B2 (3,248,652,466)
|
||||||
|
password = "PASSWORD" # factory default, never changed
|
||||||
|
```
|
||||||
|
|
||||||
|
So the per-installation `.pca` key was sitting inside `PCA01.CFG` the whole
|
||||||
|
time, encrypted with a hardcoded key that's right there in the binary. The
|
||||||
|
`keyExport` path is only for files that were exported for sharing, which is
|
||||||
|
*not* what `Our House.pca` was — it was the live in-place config.
|
||||||
|
|
||||||
|
Decrypted `Our House.pca` with `0xC1A280B2`. First bytes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
00000000 05 50 43 41 30 33 ... .PCA03
|
||||||
|
```
|
||||||
|
|
||||||
|
`PCA03`. File format v3. Right key.
|
||||||
|
|
||||||
|
## 2026-05-10 — the 2191-byte header parses byte-perfect
|
||||||
|
|
||||||
|
Read `clsHAC.ReadFileHeader` to figure out the layout:
|
||||||
|
|
||||||
|
```text
|
||||||
|
String8 version_tag "PCA03"
|
||||||
|
String8(30) AccountName
|
||||||
|
String16(120) AccountAddress
|
||||||
|
String8(20) AccountPhone
|
||||||
|
String8(4) AccountCode
|
||||||
|
String16(2000) AccountRemarks
|
||||||
|
byte Model
|
||||||
|
byte MajorVersion
|
||||||
|
byte MinorVersion
|
||||||
|
sbyte Revision
|
||||||
|
```
|
||||||
|
|
||||||
|
One thing about `ReadString8(out S, byte L)`: it always consumes `1 + L`
|
||||||
|
bytes regardless of the declared string length. So the strings are
|
||||||
|
fixed-width slots with a length prefix, not variable-length.
|
||||||
|
|
||||||
|
Total header size: 2191 bytes.
|
||||||
|
|
||||||
|
Then we found the validation block at `clsHAC.cs:7943`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (num == 2191) { /* header read OK */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
If your byte counter doesn't equal 2191 after parsing the header, you got it
|
||||||
|
wrong. It did. That was the moment we knew the parser was correct: not by
|
||||||
|
inspection of the output, but by hitting an exact magic number that the
|
||||||
|
original code was checking against.
|
||||||
|
|
||||||
|
Decoded header:
|
||||||
|
|
||||||
|
- Model byte = `0x10` = `enuModel.OMNI_PRO_II`
|
||||||
|
- Firmware: 2.12 r1
|
||||||
|
- AccountName / Address / Phone — the previous owner's PII
|
||||||
|
- 8 user codes, all still factory default `12345678`
|
||||||
|
|
||||||
|
That last one stung. The panel had probably been sitting on someone's wall
|
||||||
|
for a decade with `12345678` as the master code. (Not our panel, yet — but
|
||||||
|
our panel was about to inherit it.) Plaintext stays in
|
||||||
|
`extracted/Our_House.pca.plain` and that path stays in `.gitignore`. All
|
||||||
|
future notes redact PII.
|
||||||
|
|
||||||
|
## 2026-05-10 — walking the body
|
||||||
|
|
||||||
|
Header was 2191 bytes; the file is 144 KB. Plenty more to parse before we'd
|
||||||
|
hit the network connection block where the AES key for live-panel talk is
|
||||||
|
stored. The body layout (from `clsHAC.ReadFromFile`) is documented in
|
||||||
|
detail in [the file format reference](/reference/file-format/).
|
||||||
|
|
||||||
|
## 2026-05-10 — the latent bug in PC Access itself
|
||||||
|
|
||||||
|
Each "Voice" block lets the panel speak the name of an object. Six phrases
|
||||||
|
per object (`numVoicePhrases = 6`). The C# reads them like this:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
byte[] B = new byte[CAP.numVoicePhrases]; // 6 bytes
|
||||||
|
for (int i = 1; i <= GetFileMaxX(); i++) {
|
||||||
|
num = (i > Count)
|
||||||
|
? num + FS.ReadByteArray(out B, B.Length) // skip path: 6 bytes
|
||||||
|
: num + _Items[i-1].Voice.Read(FS); // structured path
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The "structured path" calls `clsVoiceWordArray.Read`, which branches on
|
||||||
|
whether the panel has the `LargeVocabulary` feature:
|
||||||
|
|
||||||
|
- LargeVocabulary present → 6 phrases × **2 bytes** (UInt16) = **12 bytes**
|
||||||
|
- LargeVocabulary absent → 6 phrases × 1 byte = 6 bytes
|
||||||
|
|
||||||
|
OMNI_PRO_II *has* LargeVocabulary. So the structured path reads 12 bytes per
|
||||||
|
slot. But the **skip path** in the loop above always reads 6 bytes, no
|
||||||
|
matter what. There's no `if (LargeVocabulary) B = new byte[12];`.
|
||||||
|
|
||||||
|
If `Count == GetFileMaxX()` (every slot is filled), this never matters — the
|
||||||
|
skip path is never taken. For every block on our panel except one, that's
|
||||||
|
true. But Units has `Count = 511` and `GetFileMaxX = 512`, so exactly one
|
||||||
|
slot takes the skip path, reads 6 bytes when it should have read 12, and
|
||||||
|
the next 6 bytes — which are actually the start of the *next* block — get
|
||||||
|
treated as the tail of the current slot. The parser walks 6 bytes off the
|
||||||
|
rails and never recovers.
|
||||||
|
|
||||||
|
The C# code in the wild gets away with this because `Count >= Max` for
|
||||||
|
basically all real panels in deployment. But it's a real bug — it would bite
|
||||||
|
if a model ever shipped with LargeVocabulary AND had Buttons or Messages
|
||||||
|
with `Count < Max`. We patched our parser; the original is still wrong.
|
||||||
|
|
||||||
|
Found it by hex-dumping the file, locating the panel IP address
|
||||||
|
(`192.168.1.9`) at byte offset `0xe2d8`, and back-solving the diff between
|
||||||
|
where we expected to land and where the IP actually was. The gap was exactly
|
||||||
|
6684 bytes, which is `(512-1)*6` worth of voice slots read at half the right
|
||||||
|
size. Math checked out. Off by N. Full writeup in [the PC Access bug
|
||||||
|
explainer](/explanation/pc-access-bug/).
|
||||||
|
|
||||||
|
## 2026-05-10 — the prize
|
||||||
|
|
||||||
|
After the Voices, the body has Programs (1500 × 14 B), EventLog (250 ×
|
||||||
|
9 B), and then — for a v3 file with the Ethernet feature — the Connection
|
||||||
|
block:
|
||||||
|
|
||||||
|
```text
|
||||||
|
String8(120) Connection.NetworkAddress
|
||||||
|
String8(5) port-string
|
||||||
|
String8(32) ControllerKey-as-hex
|
||||||
|
```
|
||||||
|
|
||||||
|
For our panel:
|
||||||
|
|
||||||
|
- IP: `192.168.1.9`
|
||||||
|
- Port: `4369`
|
||||||
|
- ControllerKey: 16 bytes of AES-128 key, extracted at file offset `0xe2d8`
|
||||||
|
|
||||||
|
Total bytes to that point: `2191 + 3840 + 10 + 15407 + 13374 + 21000 + 2250 = 58072 = 0xe2d8`.
|
||||||
|
Exactly the offset where the IP appears in the hex dump. Done.
|
||||||
|
|
||||||
|
That key plus the right handshake = direct talk to the panel.
|
||||||
|
|
||||||
|
## 2026-05-10 — the two non-public quirks
|
||||||
|
|
||||||
|
Now we needed to read `clsOmniLinkConnection.cs`. It's 2109 lines of state
|
||||||
|
machine for the secure-session handshake, the keepalive timer, the TCP
|
||||||
|
framing, and the encryption. We expected a textbook AES session: send
|
||||||
|
client-hello, get server-hello, derive key from PIN somehow, encrypt
|
||||||
|
everything from then on.
|
||||||
|
|
||||||
|
What we found instead were two surprises that no public Omni-Link write-up
|
||||||
|
we'd seen mentions. Both of them look like quirks. Both of them will reject
|
||||||
|
your client with `ControllerSessionTerminated` if you skip them.
|
||||||
|
|
||||||
|
### Quirk 1 — the session key is not the ControllerKey
|
||||||
|
|
||||||
|
You'd expect the AES session key to be the ControllerKey verbatim. It isn't.
|
||||||
|
From `clsOmniLinkConnection.cs:1886-1892`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
SessionKey = new byte[16];
|
||||||
|
ControllerKey.CopyTo(SessionKey, 0);
|
||||||
|
for (int j = 0; j < 5; j++)
|
||||||
|
{
|
||||||
|
SessionKey[11 + j] = (byte)(ControllerKey[11 + j] ^ SessionID[j]);
|
||||||
|
}
|
||||||
|
AES = new clsAES(SessionKey);
|
||||||
|
```
|
||||||
|
|
||||||
|
The first 11 bytes of the session key are the ControllerKey verbatim. The
|
||||||
|
last 5 bytes are the ControllerKey XORed with a 5-byte `SessionID` nonce
|
||||||
|
that the controller sent in `ControllerAckNewSession`. That's the entire
|
||||||
|
key derivation. No PBKDF2, no HKDF, no PIN, no salt. Just five bytes of XOR.
|
||||||
|
|
||||||
|
The same five-byte block appears twice in the source — once for UDP (line
|
||||||
|
1423) and once for TCP (line 1886). Identical.
|
||||||
|
|
||||||
|
The implication for someone writing a client is: if you encrypt your
|
||||||
|
`ClientRequestSecureSession` with the raw ControllerKey, the panel decrypts
|
||||||
|
it to garbage and disconnects you. You have to wait for the nonce, mix it
|
||||||
|
in, *then* encrypt.
|
||||||
|
|
||||||
|
### Quirk 2 — per-block XOR pre-whitening before AES
|
||||||
|
|
||||||
|
This one is the real headline. Before AES-encrypting any payload block, the
|
||||||
|
first two bytes of every 16-byte block get XORed with the packet's sequence
|
||||||
|
number. Same XOR mask, every block of the packet. From
|
||||||
|
`clsOmniLinkConnection.cs:396-401`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
for (num = 0; num < PKT.Data.Length; num += 16)
|
||||||
|
{
|
||||||
|
PKT.Data[num] = (byte)(PKT.Data[num] ^ ((PKT.SequenceNumber & 0xFF00) >> 8));
|
||||||
|
PKT.Data[num + 1] = (byte)(PKT.Data[num + 1] ^ (PKT.SequenceNumber & 0xFF));
|
||||||
|
}
|
||||||
|
PKT.Data = AES.Encrypt(PKT.Data);
|
||||||
|
```
|
||||||
|
|
||||||
|
And then the inverse on receive (`:413-417`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
PKT.Data = AES.Decrypt(PKT.Data);
|
||||||
|
for (int i = 0; i < PKT.Data.Length; i += 16)
|
||||||
|
{
|
||||||
|
PKT.Data[i] = (byte)(PKT.Data[i] ^ ((PKT.SequenceNumber & 0xFF00) >> 8));
|
||||||
|
PKT.Data[i + 1] = (byte)(PKT.Data[i + 1] ^ (PKT.SequenceNumber & 0xFF));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
So the on-the-wire encryption is "AES-128-ECB of (payload XOR-prewhitened
|
||||||
|
with the seq number, two bytes per block)". A naive Omni-Link client that
|
||||||
|
just AES-ECB-encrypts the raw payload will produce ciphertext the panel
|
||||||
|
won't accept.
|
||||||
|
|
||||||
|
It feels weak — an attacker with a known-plaintext for one block can
|
||||||
|
recover the seq XOR mask trivially, and from there the whitening is
|
||||||
|
unprotected. But it's the protocol. The panel won't talk to you without it.
|
||||||
|
|
||||||
|
We think the original intent might have been something like nonce-mixing
|
||||||
|
(use the seq as a per-packet salt to defeat ECB block-repetition attacks),
|
||||||
|
and the implementation got cargo-culted from one block to all blocks of the
|
||||||
|
packet. Doesn't matter. Implement it. Move on. Full writeup in [the quirks
|
||||||
|
explainer](/explanation/quirks/).
|
||||||
|
|
||||||
|
A bonus surprise: **there is no separate `Login` step on TCP.** The C#
|
||||||
|
defines `clsOL2MsgLogin` (v2 Login, opcode 42) but never instantiates it on
|
||||||
|
the TCP path. Possessing the right ControllerKey *is* the authentication.
|
||||||
|
The login opcode appears to be a serial-only artifact from before the
|
||||||
|
Ethernet module existed. The v1 serial path *does* construct `clsOLMsgLogin`
|
||||||
|
with the user's PIN; the v2 TCP path goes straight from
|
||||||
|
`ControllerAckSecureSession` to `RequestSystemInformation`.
|
||||||
|
|
||||||
|
We documented all of this in our [protocol reference](/reference/protocol/)
|
||||||
|
while it was fresh.
|
||||||
|
|
||||||
|
## 2026-05-10 around noon — first commit
|
||||||
|
|
||||||
|
```text
|
||||||
|
9a02418 Initial scaffold + protocol primitives
|
||||||
|
```
|
||||||
|
|
||||||
|
uv project, ruff, pytest, mypy strict, MIT, README, gitignore explicitly
|
||||||
|
protecting any `.pca` or panel keys. Date-versioned (CalVer): `2026.5.10`.
|
||||||
|
The library lives in `src/omni_pca/`:
|
||||||
|
|
||||||
|
- `crypto.py` — AES-128-ECB plus the per-block XOR seq pre-whitening and the
|
||||||
|
`SessionKey = CK[0:11] || (CK[11:16] XOR SessionID)` derivation
|
||||||
|
- `opcodes.py` — all 12 packet types, all 104 v1 opcodes, all 83 v2 opcodes,
|
||||||
|
all transcribed by hand from the decompiled enums
|
||||||
|
- `packet.py` — outer `Packet` with `encode()`/`decode()`
|
||||||
|
- `message.py` — inner `Message` with CRC-16/MODBUS
|
||||||
|
- `pca_file.py` — Borland LCG cipher, `PcaReader`, parsers for both `.pca`
|
||||||
|
and `.CFG`
|
||||||
|
|
||||||
|
49 tests passed, ruff clean. The protocol unit tests use canned bytes
|
||||||
|
extracted from the C# source; they don't need a panel to run.
|
||||||
|
|
||||||
|
## 2026-05-10 1pm — mock panel as ground truth
|
||||||
|
|
||||||
|
Second commit:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1901d6e Async client + mock panel + e2e roundtrip
|
||||||
|
```
|
||||||
|
|
||||||
|
The async client (`OmniConnection`, `OmniClient`) runs the four-step
|
||||||
|
secure-session handshake, frames TCP correctly (read first 16-byte block,
|
||||||
|
decrypt, learn `MessageLength`, read the rest), keeps a per-direction
|
||||||
|
monotonic sequence number that wraps `0xFFFF → 1` (skipping 0 because the
|
||||||
|
controller uses 0 for unsolicited packets), and dispatches solicited
|
||||||
|
replies to a Future while shoving unsolicited packets into a queue.
|
||||||
|
|
||||||
|
That's all well and good, but how do we test it without a panel? The panel
|
||||||
|
was at `192.168.1.9` last we knew, and we had no idea if its network module
|
||||||
|
was even on. Building a real Omni controller emulator in Python turned out
|
||||||
|
to be the right answer.
|
||||||
|
|
||||||
|
`mock_panel.py` is a TCP server that:
|
||||||
|
|
||||||
|
- accepts `ClientRequestNewSession`, generates a 5-byte SessionID, sends back
|
||||||
|
`ControllerAckNewSession` with the version bytes `00 01` prepended
|
||||||
|
- derives the same SessionKey the client did (using the same XOR-mix)
|
||||||
|
- decrypts the `ClientRequestSecureSession`, validates that the 5-byte echo
|
||||||
|
matches the SessionID it just sent, sends back the symmetric
|
||||||
|
`ControllerAckSecureSession` (re-encrypting the same SessionID)
|
||||||
|
- handles `RequestSystemInformation`, `RequestSystemStatus`,
|
||||||
|
`RequestProperties` (Zone/Unit/Area, both absolute index and rel=1
|
||||||
|
iteration with EOD termination), and Naks anything else
|
||||||
|
|
||||||
|
It's a thin emulator but it's a *complete* protocol counterpart. Six
|
||||||
|
end-to-end tests connect a real `OmniClient` over a real TCP socket to a
|
||||||
|
real `MockPanel` and exchange real frames. They prove the handshake, the
|
||||||
|
AES, the XOR whitening, and the sequence numbering all agree — because if
|
||||||
|
any one of them is wrong, decryption produces garbage and the connection
|
||||||
|
drops.
|
||||||
|
|
||||||
|
That ground-truth check was load-bearing. It meant we could iterate on the
|
||||||
|
client all afternoon without worrying that some bug in our encryption was
|
||||||
|
being masked by a bug in our framing.
|
||||||
|
|
||||||
|
## 2026-05-10 ~1:10pm — the HA scaffold
|
||||||
|
|
||||||
|
Third commit:
|
||||||
|
|
||||||
|
```text
|
||||||
|
2e43936 HA custom_component scaffold (binary_sensor for zones)
|
||||||
|
```
|
||||||
|
|
||||||
|
Drop-in Home Assistant integration at `custom_components/omni_pca/`:
|
||||||
|
manifest, config_flow with auth + reauth, coordinator with reconnect logic,
|
||||||
|
binary_sensor for each named zone with `device_class` derived from
|
||||||
|
`zone_type` (OPENING, MOTION, SMOKE, etc.). 12 unit tests for
|
||||||
|
`parse_controller_key()` because that's the one piece of pure logic worth
|
||||||
|
pinning down hard.
|
||||||
|
|
||||||
|
Status of the HA component itself wasn't validated against a running Home
|
||||||
|
Assistant — that comes next. But the HACS manifest is there, so once we
|
||||||
|
trust it we can drop it in.
|
||||||
|
|
||||||
|
## 2026-05-10 2pm — fleshing out the model surface
|
||||||
|
|
||||||
|
Fourth commit:
|
||||||
|
|
||||||
|
```text
|
||||||
|
08974e2 Models: 16 status/properties dataclasses + enums + temp converters
|
||||||
|
```
|
||||||
|
|
||||||
|
The Omni protocol has a wide object surface — Zones, Units, Areas,
|
||||||
|
Thermostats, Buttons, Programs, Codes, Messages, Aux Sensors, Audio Zones,
|
||||||
|
Audio Sources, User Settings — and each has both a "properties" record
|
||||||
|
(configured, mostly static) and a "status" record (live state).
|
||||||
|
|
||||||
|
Wrote frozen-slots dataclasses for all of them, with `.parse(payload)`
|
||||||
|
classmethods that decode the byte layouts straight from the C# field
|
||||||
|
definitions. Added IntEnums for the dispatch tags (`ObjectType`,
|
||||||
|
`SecurityMode`, `HvacMode`, `FanMode`, `HoldMode`, `ThermostatKind`,
|
||||||
|
`ZoneType`, `UserSettingKind`).
|
||||||
|
|
||||||
|
One small surprise from `clsText.cs`: the temperature encoding the panel
|
||||||
|
uses is *linear*, not the non-linear thermistor scale we'd guessed it might
|
||||||
|
be. `C = raw / 2 - 40`. Easy.
|
||||||
|
|
||||||
|
42 new tests. 139 total.
|
||||||
|
|
||||||
|
## 2026-05-10 ~2:15pm — commands and events
|
||||||
|
|
||||||
|
Fifth commit:
|
||||||
|
|
||||||
|
```text
|
||||||
|
68cf44a Library v1.0 phase B: command opcodes + typed system events
|
||||||
|
```
|
||||||
|
|
||||||
|
`commands.py` — the `Command` IntEnum, sourced from `enuUnitCommand.cs`
|
||||||
|
which is the canonical "all commands" enum despite the misleading name (it
|
||||||
|
covers HVAC, security, scene, button, message commands too — not just
|
||||||
|
units). One naming weirdness: `enuUnitCommand.UserSetting` (104) is actually
|
||||||
|
EXECUTE_PROGRAM. Renamed for clarity in our enum and left the original C#
|
||||||
|
alias documented inline so anyone cross-referencing won't get confused.
|
||||||
|
|
||||||
|
`OmniClient` got 18 new methods: `execute_command`,
|
||||||
|
`execute_security_command`, `acknowledge_alerts`, `get_object_status`,
|
||||||
|
`get_extended_status`, plus convenience wrappers (`turn_unit_on`,
|
||||||
|
`set_unit_level`, `bypass_zone`, `set_thermostat_heat_setpoint_raw`, …).
|
||||||
|
All the command methods raise `CommandFailedError` on Nak.
|
||||||
|
|
||||||
|
`events.py` — the `SystemEvents` (opcode 55) decoder. The panel pushes
|
||||||
|
batches of these unsolicited; each batch contains multiple events of
|
||||||
|
different types (zone state changes, unit state changes, arming changes,
|
||||||
|
alarm activated, AC lost, battery low, phone line dead, X10 codes received,
|
||||||
|
…). 28 dispatch tags, 26 typed event subclasses, an `UnknownEvent` catch-all
|
||||||
|
for opcode values we don't know yet, and an `EventStream` helper that
|
||||||
|
flattens batches across messages.
|
||||||
|
|
||||||
|
55 new tests. 194 total.
|
||||||
|
|
||||||
|
## 2026-05-10 ~2:30pm — stateful mock and the full v1.0 surface
|
||||||
|
|
||||||
|
Sixth commit:
|
||||||
|
|
||||||
|
```text
|
||||||
|
c26db62 Library v1.0 phase C: stateful mock + e2e for the new surface
|
||||||
|
```
|
||||||
|
|
||||||
|
The mock got real state. `MockUnitState`, `MockAreaState`, `MockZoneState`,
|
||||||
|
`MockThermostatState`, plus a `user_codes` table for security validation.
|
||||||
|
All the new opcodes wired through:
|
||||||
|
|
||||||
|
- `Command` (20) → Ack with state mutation, dispatching UNIT_ON, UNIT_OFF,
|
||||||
|
UNIT_LEVEL, BYPASS_ZONE, RESTORE_ZONE, SET_THERMOSTAT_HEAT, etc.
|
||||||
|
- `ExecuteSecurityCommand` (74) → Ack on a valid code, Nak on invalid
|
||||||
|
- `RequestStatus` (34) → `Status` (35) for the four object kinds with
|
||||||
|
hard-coded record sizes per `clsOL2MsgStatus.cs:13-27`
|
||||||
|
- `RequestExtendedStatus` (58) → `ExtendedStatus` (59) with the
|
||||||
|
`object_length` prefix and the richer per-type fields
|
||||||
|
- `AcknowledgeAlerts` (60) → Ack
|
||||||
|
- And synthesized `SystemEvents` (55) pushed with `seq=0` whenever state
|
||||||
|
changes, so the e2e tests can subscribe to events through the real client
|
||||||
|
API and watch them roundtrip cleanly through `events.parse_events()`
|
||||||
|
|
||||||
|
9 new e2e tests — arm/disarm with code validation, unit on/off/level, zone
|
||||||
|
bypass/restore, thermostat setpoint, push events for arming and unit
|
||||||
|
changes, acknowledge_alerts. 203 total passing, 2 skipped (the HA harness
|
||||||
|
and a `.pca` fixture we don't ship).
|
||||||
|
|
||||||
|
The library has the v1.0 surface: read, command, status, extended status,
|
||||||
|
events. All exercised by an in-process emulator that speaks the same
|
||||||
|
protocol as the real panel.
|
||||||
|
|
||||||
|
## 2026-05-10 afternoon — trying to find the real panel
|
||||||
|
|
||||||
|
Now the part that didn't go well.
|
||||||
|
|
||||||
|
The `.pca` file said the panel lived at `192.168.1.9:4369`. Tried to
|
||||||
|
connect: nothing. TCP SYN, no SYN-ACK. Pinged: silent. nmap'd the subnet to
|
||||||
|
make sure we were on the right network:
|
||||||
|
|
||||||
|
- `192.168.1.7`, `.8`, `.11` — open ports including SSH with banner
|
||||||
|
`SSH-2.0-dropbear_2018.76`. Three OmniTouch 7 touchscreens. They're the
|
||||||
|
wall-mounted controllers; they live on the same LAN as the panel, speak
|
||||||
|
Omni-Link II to the panel themselves, and run a stripped Linux with
|
||||||
|
dropbear for the firmware updater. Confirmed by the SSH banner date
|
||||||
|
(2018) lining up with the OmniTouch 7 firmware era.
|
||||||
|
- `.6` — likely the panel itself, but no open ports, no response.
|
||||||
|
- `.9` — also dark. The 2018 IP either changed or the network module was
|
||||||
|
disabled at some point.
|
||||||
|
|
||||||
|
So the panel is sitting there, doing its job (the touchscreens clearly work
|
||||||
|
— they're on the network), but its Ethernet/Omni-Link II module is either
|
||||||
|
turned off in the panel's setup menu or the network bridge hardware is bad.
|
||||||
|
We have the ControllerKey, we have the right port, we have a fully-tested
|
||||||
|
client and a mock panel that proves the client works end-to-end — but we
|
||||||
|
can't prove it against the real thing yet.
|
||||||
|
|
||||||
|
We have, in other words, built the world's most thoroughly-tested unused
|
||||||
|
integration. There is something quietly funny about that.
|
||||||
|
|
||||||
|
The fix is physical: walk over to the panel, find the menu that enables the
|
||||||
|
Ethernet module, save, reboot. Then the live validation becomes a
|
||||||
|
five-minute test. Until then, the mock is the best we have, and the mock
|
||||||
|
is a faithful enough emulator that we trust it.
|
||||||
|
|
||||||
|
## 2026-05-10 evening — HA rebuild Phase A
|
||||||
|
|
||||||
|
The first HA scaffold (a placeholder `binary_sensor` for zones, written
|
||||||
|
before the library was complete) needed to come down and get rebuilt on the
|
||||||
|
v1.0 surface. The interesting design choice: how should the coordinator
|
||||||
|
pull state?
|
||||||
|
|
||||||
|
Option A: re-poll everything every N seconds.
|
||||||
|
Option B: rely on the panel's unsolicited push messages and only poll as a
|
||||||
|
backstop.
|
||||||
|
|
||||||
|
We picked B. The Omni panel is genuinely chatty — when a zone trips, when
|
||||||
|
an area arms, when AC fails, when a unit toggles, the panel pushes a
|
||||||
|
`SystemEvents` packet within a few hundred ms. Our `OmniConnection` already
|
||||||
|
decodes those into typed `SystemEvent` objects via an async iterator
|
||||||
|
(`client.events()`). The coordinator now runs a long-lived background task
|
||||||
|
consuming that iterator and patches the relevant slice of state in-place,
|
||||||
|
then calls `async_set_updated_data()` so HA reacts immediately. The
|
||||||
|
30-second poll is a safety net for state we missed.
|
||||||
|
|
||||||
|
The piece that took longer than expected was extracting pure functions from
|
||||||
|
the entity-class soup so we could unit-test without HA installed in the
|
||||||
|
venv. We ended up with `helpers.py`: zone-type → device-class mapping,
|
||||||
|
latched-vs-current-condition logic per zone family, name prettifier
|
||||||
|
(`FRONT_DOOR` → `Front Door`). 61 unit tests for `helpers.py` alone, all
|
||||||
|
running without importing `homeassistant.*`. Sounds excessive until you
|
||||||
|
remember that pure-function tests are the only ones that run in <100ms; you
|
||||||
|
don't want to wait 15 seconds for HA to boot just to verify that zone-type
|
||||||
|
32 (FIRE) maps to `BinarySensorDeviceClass.SMOKE`.
|
||||||
|
|
||||||
|
## 2026-05-10 evening — HA Phase B (the entity build-out)
|
||||||
|
|
||||||
|
Six platforms in one pass: `alarm_control_panel` (per area, with code
|
||||||
|
validation), `light` (per unit, dimmable), `switch` (per zone for bypass
|
||||||
|
control), `climate` (per thermostat, full HVAC modes), `sensor` (analog
|
||||||
|
zones + thermostat readings + panel telemetry), `button` (per panel macro),
|
||||||
|
`event` (one per panel relaying typed push events as HA event_types).
|
||||||
|
|
||||||
|
The mapping work was repetitive but mostly mechanical. The interesting bits:
|
||||||
|
|
||||||
|
- The Omni unit "state" byte is overloaded: 0=off, 1=on (relay),
|
||||||
|
100..200=brightness percent (state - 100), plus weird ranges for scene
|
||||||
|
levels (2..13) and ramping codes (17..25). Encoded as a pair of pure
|
||||||
|
helpers (`omni_state_to_ha_brightness` / `ha_brightness_to_omni_percent`)
|
||||||
|
so the conversion is unit-tested.
|
||||||
|
- Omni's `SecurityMode` enum has *both* steady-state values (Off=0, Day=1,
|
||||||
|
Away=3, …) *and* arming-in-progress values (ArmingDay=9, ArmingAway=11,
|
||||||
|
…). The HA `AlarmControlPanelState` mapping needs to bucket the 9..14
|
||||||
|
range into HA's `arming` state regardless of destination. Plus
|
||||||
|
alarm_active overrides everything to `triggered`, and entry-timer running
|
||||||
|
means `pending`, exit-timer means `arming`. All of this lives in one pure
|
||||||
|
`security_mode_to_alarm_state()` function so it's unit-testable end to
|
||||||
|
end.
|
||||||
|
- The HA `event` platform is newer than we'd realised. It exposes push
|
||||||
|
events as a single entity per integration with `event_types` and
|
||||||
|
`event_data`. Automations key on `platform: event` filtering by
|
||||||
|
`event_type`. We surface 12 event-type strings: `zone_state_changed`,
|
||||||
|
`unit_state_changed`, `arming_changed`, `alarm_activated`,
|
||||||
|
`alarm_cleared`, `ac_lost`, `ac_restored`, `battery_low`,
|
||||||
|
`battery_restored`, `user_macro_button`, `phone_line_dead`,
|
||||||
|
`phone_line_restored`, plus an `unknown` catch-all for the 14 less common
|
||||||
|
SystemEvent subclasses.
|
||||||
|
|
||||||
|
Skipped the `scene` platform entirely. Omni "scenes" are actually just
|
||||||
|
user-named button macros — the underlying call is the same `execute_button`
|
||||||
|
that the `button` platform already exposes. Adding a parallel scene wrapper
|
||||||
|
would just double-count entities. Documented the choice in the integration
|
||||||
|
README.
|
||||||
|
|
||||||
|
## 2026-05-10 evening — HA Phase C (services + diagnostics)
|
||||||
|
|
||||||
|
Seven services, all routed through a `services.py` module that's
|
||||||
|
idempotently registered on first config-entry setup and unloaded on the
|
||||||
|
last config-entry teardown:
|
||||||
|
|
||||||
|
```text
|
||||||
|
omni_pca.bypass_zone
|
||||||
|
omni_pca.restore_zone
|
||||||
|
omni_pca.execute_program
|
||||||
|
omni_pca.show_message
|
||||||
|
omni_pca.clear_message
|
||||||
|
omni_pca.acknowledge_alerts
|
||||||
|
omni_pca.send_command (raw escape hatch)
|
||||||
|
```
|
||||||
|
|
||||||
|
Each takes an `entry_id` field with HA's `config_entry` selector so the UI
|
||||||
|
gives users a panel picker. `services.yaml` declares the schema;
|
||||||
|
`services.py` enforces it via `voluptuous`.
|
||||||
|
|
||||||
|
Diagnostics endpoint dumps a redacted snapshot for bug reports:
|
||||||
|
`controller_key` redacted via `async_redact_data`; zone/unit/area names
|
||||||
|
hashed with sha256 so structure is visible without leaking PII; counts per
|
||||||
|
object type; last event class; last update success timestamp. Useful one
|
||||||
|
day, useless until then, but it's three lines and HA users expect it.
|
||||||
|
|
||||||
|
## 2026-05-10 evening — "wait, did we mock the panel enough?"
|
||||||
|
|
||||||
|
The thinking-out-loud moment that caught a real bug. The HA test harness
|
||||||
|
was about to be set up; before doing that, the question was: does the mock
|
||||||
|
actually answer every opcode the HA coordinator calls?
|
||||||
|
|
||||||
|
Mapped HA-side calls to mock-side handlers. Most matched. But the HA
|
||||||
|
coordinator walks `RequestProperties` for object types Thermostat (6) and
|
||||||
|
Button (3), and the mock's `_reply_properties` only knew about
|
||||||
|
Zone/Unit/Area. Both would have returned `Nak`, the coordinator would have
|
||||||
|
moved on, and HA would have discovered zero thermostats and zero buttons no
|
||||||
|
matter how `MockState` was seeded.
|
||||||
|
|
||||||
|
Added the two handlers (each ~30 lines: build the per-object Properties body
|
||||||
|
matching the wire format documented in `models.ThermostatProperties.parse`
|
||||||
|
/ `models.ButtonProperties.parse`), plus two e2e tests that drive the walk
|
||||||
|
with `OmniClient` and assert the parses come out clean. Caught it before
|
||||||
|
HA ever touched the mock.
|
||||||
|
|
||||||
|
This is the kind of bug that *would* have shown up the first time you tried
|
||||||
|
the integration: zero climate entities, zero button entities, no error
|
||||||
|
message because the panel just said "no, I have no thermostats here". You'd
|
||||||
|
spend an hour staring at it. Mock-the-whole-protocol pays for itself the
|
||||||
|
first time it catches one of these.
|
||||||
|
|
||||||
|
## 2026-05-10 evening — HA test harness, the rough patches
|
||||||
|
|
||||||
|
`pytest-homeassistant-custom-component` is the standard HA dev test harness.
|
||||||
|
It pins to a specific HA version (we got `2026.5.1` paired with HA
|
||||||
|
`2026.5.x`) and provides fixtures to spin up HA in-process per test. Sounds
|
||||||
|
simple. Three rough patches:
|
||||||
|
|
||||||
|
1. **`requires-python` conflict.** Our library targets `>=3.12`. HA
|
||||||
|
`2026.5+` requires `>=3.14.2`. uv resolves dependency groups against the
|
||||||
|
project's `requires-python` and refused to install the test harness
|
||||||
|
because it couldn't find a Python version satisfying both. Bumped the
|
||||||
|
project to `>=3.14.2` — fine for HA users (HA already needs 3.14),
|
||||||
|
library users on older Python pin to a previous omni-pca version.
|
||||||
|
2. **`pytest_socket` blocks our e2e tests.** The HA harness installs
|
||||||
|
`pytest_socket` globally to keep HA unit tests hermetic. That broke our
|
||||||
|
existing 17 e2e tests that legitimately need to talk to a localhost
|
||||||
|
MockPanel over a real TCP socket. Fix: a top-level `tests/conftest.py`
|
||||||
|
autouse fixture requesting the harness's `socket_enabled` fixture, which
|
||||||
|
re-enables sockets by default. HA-side tests can opt back into the
|
||||||
|
strict policy if they want.
|
||||||
|
3. **`CONF_ENTRY_ID` doesn't exist in HA.** Our `services.py` was importing
|
||||||
|
`CONF_ENTRY_ID` from `homeassistant.const`. The harness import-test
|
||||||
|
caught it: HA exports the constant as `ATTR_CONFIG_ENTRY_ID`, not
|
||||||
|
`CONF_ENTRY_ID`. Without the harness, this would have crashed on first
|
||||||
|
install in a real HA. Worth the harness already.
|
||||||
|
|
||||||
|
Then teardown started hanging. Each test passed (5-15 seconds for HA boot +
|
||||||
|
entity discovery + assertions) but the harness's `verify_cleanup` timed out
|
||||||
|
waiting for the coordinator's background event-listener task to finish. The
|
||||||
|
coordinator's `async_shutdown()` cancels it cleanly — but the harness was
|
||||||
|
tearing the test down without calling unload first. Fix: convert the
|
||||||
|
`configured_panel` fixture into a generator and call
|
||||||
|
`hass.config_entries.async_unload()` in the teardown branch. With that, all
|
||||||
|
12 HA-side tests run in 0.74 seconds total (each one boots HA, runs config
|
||||||
|
flow, asserts, unloads).
|
||||||
|
|
||||||
|
Final score: 351 tests pass, 1 skipped (the gitignored `.pca` fixture),
|
||||||
|
ruff clean across `src/ tests/ custom_components/`.
|
||||||
|
|
||||||
|
## 2026-05-10 late evening — docker dev stack
|
||||||
|
|
||||||
|
Wanted a one-command setup so the integration could be browsed manually and
|
||||||
|
screenshotted for the README. `docker-compose.yml` with two services: real
|
||||||
|
HA `2026.5` from upstream + a sidecar running the mock panel.
|
||||||
|
|
||||||
|
The interesting wrinkle: the mock panel container needs to import
|
||||||
|
`omni_pca`. Mounting the project read-only and running `uv` inside the
|
||||||
|
container failed because uv tried to recreate the host's `.venv` and the
|
||||||
|
mount was read-only. Fix: mount only `src/` and `run_mock_panel.py`, set
|
||||||
|
`PYTHONPATH=/tmp/mock/src`, install just `cryptography` via
|
||||||
|
`uv pip install --system`, run the script directly. No package install, no
|
||||||
|
venv, just a Python interpreter with the right import path.
|
||||||
|
|
||||||
|
## 2026-05-10 late evening — automated HA onboarding + screenshots
|
||||||
|
|
||||||
|
`dev/screenshot.py` does the entire flow:
|
||||||
|
|
||||||
|
1. POST `/api/onboarding/users` to create the demo user (returns
|
||||||
|
`auth_code`)
|
||||||
|
2. POST `/auth/token` with `grant_type=authorization_code` to get the access
|
||||||
|
token (HA doesn't support password grant)
|
||||||
|
3. On subsequent runs: log in via `/auth/login_flow` (cleaner than re-using
|
||||||
|
a saved token; the token expires in 30 minutes anyway)
|
||||||
|
4. POST `/api/config/config_entries/flow` to start the omni_pca config flow,
|
||||||
|
then post the user-input dict to complete it
|
||||||
|
5. Cache the panel's device_id by calling HA's template endpoint
|
||||||
|
(`{{ device_id('sensor.omni_pro_ii_panel_model') }}`) — which is a
|
||||||
|
delightfully clean way to ask HA "what's the device id for this entity?"
|
||||||
|
6. Launch headless chromium via the `playwright` Python package, inject
|
||||||
|
`localStorage.hassTokens` so it skips the login screen, navigate to six
|
||||||
|
deep-linked pages and screenshot each
|
||||||
|
|
||||||
|
The whole script is ~250 lines and produces six PNGs. The
|
||||||
|
`04-panel-device.png` is the headline shot: HA's device page for "Omni
|
||||||
|
Pro II / by HAI / Leviton / Firmware: 2.12r1" with all the Controls
|
||||||
|
(lights, buttons, areas, thermostats), Activity panel, Diagnostics
|
||||||
|
download. Every entity from the mock visible in real HA UI in the right
|
||||||
|
shape.
|
||||||
|
|
||||||
|
A nice side-effect: HA's onboarding wizard has a "We found compatible
|
||||||
|
devices!" step that scans the network for known integrations. Our manifest
|
||||||
|
got picked up — "HAI/Leviton Omni Panel" appeared in that list during
|
||||||
|
onboarding even though we hadn't done anything explicit to register it for
|
||||||
|
discovery. The integration name and `iot_class` in `manifest.json` was
|
||||||
|
enough.
|
||||||
|
|
||||||
|
## What's left for future sessions
|
||||||
|
|
||||||
|
The panel's network module is still off. When it comes back online, the
|
||||||
|
moment of truth is one TCP connect to `192.168.1.6:4369` (or wherever it
|
||||||
|
lives now) and one `RequestSystemInformation`. If the reply is `Omni Pro II
|
||||||
|
/ 2.12 r1` the entire stack — file decryption, key extraction, key
|
||||||
|
derivation, XOR pre-whitening, AES, framing, sequencing — was right end to
|
||||||
|
end. The mock says yes. We'll find out.
|
||||||
|
|
||||||
|
Other backlog items:
|
||||||
|
|
||||||
|
- `Programs` discovery (no `RequestProperties` opcode for Programs; current
|
||||||
|
implementation returns an empty dict — needs a real protocol path or a
|
||||||
|
separate `RequestProgramData` style call)
|
||||||
|
- HACS submission once we've validated against the live panel
|
||||||
|
- Maybe publish `omni-pca` to PyPI so the HA `manifest.json` requirements
|
||||||
|
line works without a wheel install
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Things worth remembering
|
||||||
|
|
||||||
|
**The "wrong key looks plausible" problem is real and recurring.**
|
||||||
|
Statistical heuristics (entropy, printable ratio, frequency analysis) are
|
||||||
|
great for telling random noise from English; they're terrible for telling
|
||||||
|
random noise from binary file plaintext. When a file format has a known
|
||||||
|
header magic, parse-the-magic beats every heuristic.
|
||||||
|
|
||||||
|
**Magic numbers in source code are gifts.** `0x12345678` as an init value,
|
||||||
|
`134775813` as an LCG multiplier, `2191` as a header length — each one is
|
||||||
|
a hard checkpoint that tells you, on first try, whether the next four hours
|
||||||
|
are going to be productive or not.
|
||||||
|
|
||||||
|
**A complete protocol counterpart is worth more than ten times its LOC in
|
||||||
|
confidence.** The mock panel was maybe 400 lines of code and it eliminated
|
||||||
|
an entire category of "is the client wrong or am I holding it wrong"
|
||||||
|
questions. Every test that connects a real client to it through real TCP is
|
||||||
|
a test that the entire stack — handshake, encryption, framing, sequencing —
|
||||||
|
agrees with itself.
|
||||||
|
|
||||||
|
**Quirk #2 (the per-block XOR pre-whitening) is the kind of thing nobody
|
||||||
|
finds without doing the work.** It's not in `jomnilinkII`, not in
|
||||||
|
`pyomnilink`, not in the public Omni-Link II writeups we checked. The
|
||||||
|
decompiled C# was unambiguous and twice-redundant (once for encrypt, once
|
||||||
|
for decrypt). Without those exact six lines of source, an OSS client that
|
||||||
|
did everything else right would still get `ControllerSessionTerminated` on
|
||||||
|
the first encrypted message, with no useful diagnostic.
|
||||||
|
|
||||||
|
**The latent LargeVocabulary bug in PC Access is harmless but symptomatic.**
|
||||||
|
It's a copy-paste mistake — the skip path uses a buffer sized for the
|
||||||
|
no-LargeVocabulary case while the structured path uses the LargeVocabulary
|
||||||
|
size. Every panel in deployment satisfies `Count >= Max` for the affected
|
||||||
|
blocks, so the bug never fires. But it would, on a model that doesn't, and
|
||||||
|
PC Access would silently mis-parse its own config file. The kind of bug
|
||||||
|
that lives in shipping code for a decade because nobody runs the unhappy
|
||||||
|
path.
|
||||||
|
|
||||||
|
**Pure functions are the cheapest thing in test suites.** The HA
|
||||||
|
custom_component grew six entity platforms before it had any HA test harness
|
||||||
|
installed. Every translation between Omni's wire encoding and HA's UI
|
||||||
|
encoding lives in `helpers.py` as a pure function with no HA imports. 61
|
||||||
|
unit tests for those alone, all running in <100ms. When the harness arrived,
|
||||||
|
the only thing left to test was the wiring itself — and the wiring tests
|
||||||
|
run in 0.74 seconds for the entire 12-test HA-side suite because the pure
|
||||||
|
parts already had coverage.
|
||||||
|
|
||||||
|
**Mocking the entire protocol counterpart, not just the surface, catches
|
||||||
|
whole categories of bugs.** When the mock and the client were both being
|
||||||
|
grown, a "did we mock enough?" check caught two missing `RequestProperties`
|
||||||
|
handlers (Thermostat and Button). HA would have discovered zero of either
|
||||||
|
type silently. With the real-world panel offline, mock-the-protocol is the
|
||||||
|
only way to trust the stack — but even with the panel available, it's the
|
||||||
|
only way to trust changes without rebooting hardware between every edit.
|
||||||
|
|
||||||
|
**`pytest_socket` and "real network in tests" can coexist.** HA's test
|
||||||
|
harness disables sockets globally to keep core unit tests hermetic. Our
|
||||||
|
integration tests need real TCP to talk to the in-process MockPanel. The
|
||||||
|
fix is one autouse fixture that requests the harness's `socket_enabled`
|
||||||
|
fixture; takes ten seconds, lets both worlds work without modification.
|
||||||
|
|
||||||
|
**The "build the integration without a real device" loop is unreasonably
|
||||||
|
effective.** With the docker dev stack, the full flow is `make dev-up`,
|
||||||
|
click through HA onboarding (or run `screenshot.py` to do it via REST), see
|
||||||
|
your entities. Make a code change, `docker compose restart homeassistant`,
|
||||||
|
refresh the browser, see the change. Repeat. The panel itself becomes
|
||||||
|
optional for ~95% of the development. The other 5% is the live-validation
|
||||||
|
lap when the panel comes back online.
|
||||||
|
|||||||
@ -1,6 +1,302 @@
|
|||||||
---
|
---
|
||||||
title: File format
|
title: .pca and PCA01.CFG file format
|
||||||
description: On-disk layout of .pca and PCA01.CFG files.
|
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.
|
||||||
|
|||||||
@ -1,6 +1,206 @@
|
|||||||
---
|
---
|
||||||
title: HA entities
|
title: Home Assistant entity catalogue
|
||||||
description: Home Assistant entities exposed by the integration.
|
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.
|
||||||
|
|||||||
@ -1,6 +1,158 @@
|
|||||||
---
|
---
|
||||||
title: HA services
|
title: Home Assistant services
|
||||||
description: Home Assistant services exposed by the integration.
|
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.
|
||||||
|
|||||||
@ -1,6 +1,227 @@
|
|||||||
---
|
---
|
||||||
title: Library API
|
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).
|
||||||
|
|||||||
@ -1,6 +1,345 @@
|
|||||||
---
|
---
|
||||||
title: Protocol
|
title: Omni-Link II protocol
|
||||||
description: Omni-Link II handshake, packet/message structure, and opcode reference.
|
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`.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user