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]`).