From d5d2ea3d3232cdf4958c52edf488e5858e3b3de8 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sun, 10 May 2026 17:32:49 -0600 Subject: [PATCH] Diagrams: five hand-crafted SVGs explaining the protocol + architecture The auto-extracted manual SVGs were unusable PDF text-glyph soup. These are fresh, theme-aware (currentColor everywhere, accent via the --sl-color-accent CSS var), and built to teach. src/assets/diagrams/handshake-sequence.svg Sequence diagram with CLIENT and CONTROLLER swim lanes, five steps: ClientRequestNewSession -> ControllerAckNewSession (carries SessionID) -> derive SessionKey (inline note) -> ClientRequestSecureSession (encrypted, accent-coloured) -> ControllerAckSecureSession (encrypted) -> first OmniLink2Message. Plaintext arrows in currentColor, encrypted arrows in accent. src/assets/diagrams/packet-structure.svg Bytes-on-the-wire box diagram: outer Packet header (seq u16 + type + reserved + encrypted payload) decomposed below into the inner Message (start byte 0x21, length, opcode, data, CRC u16 LE). Plain vs encrypted fields colour-coded with a legend. src/assets/diagrams/session-key-derivation.svg Quirk #1 visual. Three rows of byte cells: ControllerKey (16 bytes, with bytes 0..10 in plain colour and 11..15 highlighted), SessionID (5 bytes), and the resulting SessionKey with the XOR boundary visible. XOR operator in the accent colour to draw the eye. src/assets/diagrams/per-block-whitening.svg Quirk #2 visual. seq pill at the top, three blocks below (block 1, block 2, block N) each showing 16 byte cells with the first two highlighted in accent and labelled with the seq XOR mask. Drives home that it's the SAME mask on EVERY block. src/assets/diagrams/architecture.svg Three groups (LIBRARY, HA INTEGRATION, TEST SURFACE) with boxes inside. Library shows the four protocol-layer modules + connection + client + models + events. HA shows coordinator + 8 platforms. Test surface shows MockPanel (accent-coloured), HA test harness, e2e tests, unit tests. One accent-coloured arrow runs from OmniConnection across to MockPanel labelled 'TCP/4369 (encrypted)'. src/assets/diagrams/pca-file-format.svg Key chain: hardcoded keyPC01 -> decrypts PCA01.CFG (boxes for the CFG fields including the highlighted pca_key) -> arrow showing the extracted pca_key -> decrypts the .pca file (boxes for PCA03 magic, account info, model byte, body, and the highlighted ControllerKey) -> caption 'feeds session-key derivation (quirk #1)'. Wired in via inline-SVG-via-?raw-import + set:html (so currentColor adapts to the theme). Required converting four pages to .mdx: reference/protocol.mdx + handshake + packet diagrams reference/file-format.mdx + pca-file-format diagram explanation/quirks.mdx + session-key + whitening diagrams explanation/architecture.mdx + architecture diagram Two MDX paper cuts during conversion: bare '<100ms' and '<50ms' in architecture.mdx confused the JSX parser; backticked them as . Build: 23 pages clean. Verified inline SVG ships in the rendered HTML (grep for SVG title IDs returns 2/2 hits per relevant page). Container rebuilt + redeployed. Protocol page is now 92750 bytes (was ~63000), quirks page 84156 (was ~63000). --- src/assets/diagrams/architecture.svg | 120 ++++++++++++++++++ src/assets/diagrams/handshake-sequence.svg | 75 +++++++++++ src/assets/diagrams/packet-structure.svg | 69 ++++++++++ src/assets/diagrams/pca-file-format.svg | 73 +++++++++++ src/assets/diagrams/per-block-whitening.svg | 107 ++++++++++++++++ .../diagrams/session-key-derivation.svg | 103 +++++++++++++++ .../{architecture.md => architecture.mdx} | 8 +- .../explanation/{quirks.md => quirks.mdx} | 7 + .../{file-format.md => file-format.mdx} | 4 + .../reference/{protocol.md => protocol.mdx} | 7 + 10 files changed, 571 insertions(+), 2 deletions(-) create mode 100644 src/assets/diagrams/architecture.svg create mode 100644 src/assets/diagrams/handshake-sequence.svg create mode 100644 src/assets/diagrams/packet-structure.svg create mode 100644 src/assets/diagrams/pca-file-format.svg create mode 100644 src/assets/diagrams/per-block-whitening.svg create mode 100644 src/assets/diagrams/session-key-derivation.svg rename src/content/docs/explanation/{architecture.md => architecture.mdx} (96%) rename src/content/docs/explanation/{quirks.md => quirks.mdx} (97%) rename src/content/docs/reference/{file-format.md => file-format.mdx} (98%) rename src/content/docs/reference/{protocol.md => protocol.mdx} (98%) diff --git a/src/assets/diagrams/architecture.svg b/src/assets/diagrams/architecture.svg new file mode 100644 index 0000000..ce7cf9a --- /dev/null +++ b/src/assets/diagrams/architecture.svg @@ -0,0 +1,120 @@ + + omni-pca architecture overview + Diagram of how the omni_pca library, the Home Assistant integration, the mock panel, and the test harness fit together. The library wraps a four-layer protocol stack (crypto, packet, message, opcodes) with a higher-level OmniClient. The HA integration's coordinator uses the client. The mock panel is a controller-side implementation of the same protocol used both for development and for the HA integration tests. + + + + + + + + + + + + + + + LIBRARY · omni_pca + + + + crypto + AES + whitening + + + packet + outer framing + + + message + CRC-16, opcodes + + + opcodes + v1 / v2 IntEnums + + + + OmniConnection + handshake, sequence, reader task + + + + OmniClient + typed methods, events() + + + + models + 21 dataclasses + + + events + 26 SystemEvent types + + + + HA INTEGRATION · custom_components/omni_pca + + + Coordinator + discover + poll + push + + + 8 entity platforms + alarm · binary_sensor · button + climate · event · light · sensor · switch + + + + TEST SURFACE + + + MockPanel + controller-side + async TCP server + + + HA test harness + in-process HA + 12 tests + + + e2e tests + 17 client ↔ mock + round-trips + + + unit tests + 300+ on primitives + + helpers + + + + + uses + + + + TCP/4369 (encrypted) + + + + drives + + + Solid arrows = function calls in-process · accent arrows = real TCP traffic. + The mock panel is the same thing the real panel is, on the wire — it lets every test run without hardware. + diff --git a/src/assets/diagrams/handshake-sequence.svg b/src/assets/diagrams/handshake-sequence.svg new file mode 100644 index 0000000..afdc5c5 --- /dev/null +++ b/src/assets/diagrams/handshake-sequence.svg @@ -0,0 +1,75 @@ + + Omni-Link II secure-session handshake + Sequence diagram showing four packets exchanged between client and controller to establish an encrypted session: ClientRequestNewSession, ControllerAckNewSession (carrying the SessionID), ClientRequestSecureSession (encrypted), and ControllerAckSecureSession (encrypted). After the handshake the first encrypted v2 message can be sent. + + + + + + + + + + + + + + + CLIENT + + + CONTROLLER + + + + + + + 1 + + ClientRequestNewSession (0x01) + no payload · plaintext · seq=1 + + + 2 + + ControllerAckNewSession (0x02) + 7 bytes: 00 01 + 5-byte SessionID · plaintext + + + + SessionKey = ControllerKey[0:11] + || (ControllerKey[11:16] XOR SessionID) + + + 3 + + ClientRequestSecureSession (0x03) + SessionID echoed · AES-128-ECB · per-block whitening + + + 4 + + ControllerAckSecureSession (0x04) + SessionID echoed back · proves controller has the key + + + 5 + + OmniLink2Message (0x20) + e.g. RequestSystemInformation · session is now ONLINE + diff --git a/src/assets/diagrams/packet-structure.svg b/src/assets/diagrams/packet-structure.svg new file mode 100644 index 0000000..f283fe9 --- /dev/null +++ b/src/assets/diagrams/packet-structure.svg @@ -0,0 +1,69 @@ + + Omni-Link II packet and message structure + The wire packet has a 4-byte plaintext header (sequence number, packet type, reserved) followed by an optional payload. For OmniLink2Message packets the payload is AES-encrypted bytes that decrypt to an inner Message: start char (0x21), length byte, opcode byte, data bytes, and a two-byte CRC-16/MODBUS. + + + + + Wire packet + 4-byte plaintext header + N-byte payload + + + seq (BE u16) + monotonic, skips 0 + + + type + 0x20 = v2 msg + + + reserved + 0x00 + + + AES-encrypted payload + N × 16 bytes (zero-padded, per-block whitened) + + + + decrypts to → + + + Inner message (after decrypt + unwhiten) + v2 framing: start, length, opcode, data, CRC + + + start + 0x21 + + + length + u8 of data + + + opcode + e.g. 0x16 = SysInfo + + + data + payload bytes for this opcode + + + CRC (LE u16) + CRC-16/MODBUS + + + + Plaintext on the wire + + AES-encrypted (with per-block whitening) + diff --git a/src/assets/diagrams/pca-file-format.svg b/src/assets/diagrams/pca-file-format.svg new file mode 100644 index 0000000..9ae91a2 --- /dev/null +++ b/src/assets/diagrams/pca-file-format.svg @@ -0,0 +1,73 @@ + + .pca file format and key chain + PCA01.CFG is encrypted with the hardcoded keyPC01 and contains a per-installation pca_key field. That pca_key decrypts the .pca account file, whose plaintext starts with the PCA03 magic and contains the panel's network address, port, and ControllerKey for live AES sessions. + + + + + + + + + + + + + + + keyPC01 (hardcoded) + 0x14326573 — 32 bits, in source + + + + PCA01.CFG · encrypted + Borland-Pascal LCG ⊕ stream + + CFG version tag (e.g. CFG05) + modem AT init / dial / hangup commands + port + IRQ + baud + + + pca_key (uint32) + + password, printer port, serial port… + + + + decrypts + + + + extracted → + + + + My_Account.pca · encrypted + same XOR cipher, per-install key + + PCA03 magic (5 bytes) + AccountName · AccountAddress · … + model byte (e.g. 16 = OMNI_PRO_II) + firmware major/minor/revision + + …body: zones, units, areas, programs, … + + Connection.NetworkAddress (string) + Connection.NetworkPort (string) + + + Connection.ControllerKey (16 bytes) + + + → feeds session-key derivation (quirk #1) + diff --git a/src/assets/diagrams/per-block-whitening.svg b/src/assets/diagrams/per-block-whitening.svg new file mode 100644 index 0000000..aabb026 --- /dev/null +++ b/src/assets/diagrams/per-block-whitening.svg @@ -0,0 +1,107 @@ + + Per-block XOR pre-whitening — quirk #2 + Before AES-encrypting each 16-byte block of a packet's payload, the first two bytes of every block are XORed with the packet's 16-bit sequence number, high byte first then low byte. The same XOR mask is applied to every block in the same packet. Decryption reverses the operation after AES-decrypt. + + + + + + + + + + + + seq = 0x0042 + + …the packet's 16-bit sequence number is the XOR mask source. + + + Block 1 + first 16 bytes of payload + + + + + + + + + + + + + + + + + + + ⊕00 + ⊕42 + + + + Block 2 + next 16 bytes — same mask + + + + + + + + + + + + + + + + + + ⊕00 + ⊕42 + + + + Block N + …same mask, every block + + + + + + + + + + + + + + + + + + ⊕00 + ⊕42 + + + + ⊕ seq_hi + ⊕ seq_lo + then AES + diff --git a/src/assets/diagrams/session-key-derivation.svg b/src/assets/diagrams/session-key-derivation.svg new file mode 100644 index 0000000..12a508c --- /dev/null +++ b/src/assets/diagrams/session-key-derivation.svg @@ -0,0 +1,103 @@ + + Session key derivation — quirk #1 + The 16-byte session AES key is built from the 16-byte ControllerKey and the 5-byte SessionID. Bytes 0 through 10 of the ControllerKey are kept verbatim. Bytes 11 through 15 of the ControllerKey are XORed with bytes 0 through 4 of the SessionID. The result is the per-session AES-128 key. + + + + + + + + + + + ControllerKey (16 bytes) + + + + + + + + + + + + + + + + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + + + + + + + + + 11 + 12 + 13 + 14 + 15 + + + + + 11 bytes kept verbatim → + 5 bytes XORed ↓ + + + + + + SessionID (5 bytes) + + + + + + + 0 + 1 + 2 + 3 + 4 + + + + + + + + SessionKey (16 bytes) + + + CK[0..11) — verbatim + + CK[11..16) ⊕ SessionID + + diff --git a/src/content/docs/explanation/architecture.md b/src/content/docs/explanation/architecture.mdx similarity index 96% rename from src/content/docs/explanation/architecture.md rename to src/content/docs/explanation/architecture.mdx index e031219..9cbc955 100644 --- a/src/content/docs/explanation/architecture.md +++ b/src/content/docs/explanation/architecture.mdx @@ -3,6 +3,10 @@ title: Architecture overview description: How the library, the Home Assistant integration, the mock panel, and the test stack fit together. --- +import Architecture from '../../../assets/diagrams/architecture.svg?raw'; + +
+ The project has four moving parts: 1. The Python library (`omni_pca`) — protocol, client, models. @@ -85,7 +89,7 @@ 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 +a pure function. 61 unit tests cover it; they run in `<100ms` because they don't have to boot HA. ## The mock panel @@ -184,6 +188,6 @@ What happens when a user toggles a light in the HA UI: 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 +Steps 4-13 happen in `<50ms` over a real TCP socket. Steps 15-22 happen in a similar window. The user sees the light card update on the UI essentially immediately. diff --git a/src/content/docs/explanation/quirks.md b/src/content/docs/explanation/quirks.mdx similarity index 97% rename from src/content/docs/explanation/quirks.md rename to src/content/docs/explanation/quirks.mdx index a9ef4ab..dd5bb30 100644 --- a/src/content/docs/explanation/quirks.md +++ b/src/content/docs/explanation/quirks.mdx @@ -3,6 +3,9 @@ title: The two non-public quirks description: Why public Omni-Link clients silently fail on the first encrypted message — session key XOR mix and per-block pre-whitening before AES. --- +import SessionKey from '../../../assets/diagrams/session-key-derivation.svg?raw'; +import Whitening from '../../../assets/diagrams/per-block-whitening.svg?raw'; + 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. @@ -38,6 +41,8 @@ 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. @@ -85,6 +90,8 @@ 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 diff --git a/src/content/docs/reference/file-format.md b/src/content/docs/reference/file-format.mdx similarity index 98% rename from src/content/docs/reference/file-format.md rename to src/content/docs/reference/file-format.mdx index 3e8030f..dcb5577 100644 --- a/src/content/docs/reference/file-format.md +++ b/src/content/docs/reference/file-format.mdx @@ -3,6 +3,10 @@ title: .pca and PCA01.CFG file format description: Borland-Pascal LCG XOR cipher, three keys, and the on-disk layout for HAI's PC Access account export and app-settings files. --- +import PcaFormat from '../../../assets/diagrams/pca-file-format.svg?raw'; + +
+ 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 diff --git a/src/content/docs/reference/protocol.md b/src/content/docs/reference/protocol.mdx similarity index 98% rename from src/content/docs/reference/protocol.md rename to src/content/docs/reference/protocol.mdx index 568859c..bd9c1eb 100644 --- a/src/content/docs/reference/protocol.md +++ b/src/content/docs/reference/protocol.mdx @@ -3,6 +3,11 @@ title: Omni-Link II protocol description: Byte-level handshake, packet layouts, key derivation, and steady-state encryption rules for Omni-Link II as implemented by HAI's PC Access 3.17. --- +import HandshakeSequence from '../../../assets/diagrams/handshake-sequence.svg?raw'; +import PacketStructure from '../../../assets/diagrams/packet-structure.svg?raw'; + +
+ 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 @@ -71,6 +76,8 @@ 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]`).