From 0c245b0af5b1c64834cb7913fc04b26f6561223f Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sun, 10 May 2026 17:44:27 -0600 Subject: [PATCH] Per-packet diagrams: every PacketType now has a wire-format visual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/assets/diagrams/packet-control-empty.svg Covers the five empty-payload control types: NoMessage (0x00), ClientRequestNewSession (0x01), ClientSessionTerminated (0x05), ControllerSessionTerminated (0x06), ControllerCannotStartNewSession (0x07). Single 4-byte header diagram with the type byte highlighted, '(no payload)' shown as a dashed empty box, and all five type values enumerated below. src/assets/diagrams/packet-controller-ack-new-session.svg Type 0x02. 11 bytes total — 4-byte header + 2-byte protocol version (0x00 0x01) + 5-byte SessionID nonce. SessionID cells in accent colour because they're what feed quirk #1's session key XOR mix. Bottom annotation explains the proto-version is hard-coded. src/assets/diagrams/packet-secure-session.svg Types 0x03 and 0x04 share the same shape both directions. Two-row layout: top shows the plaintext (5-byte SessionID echo + 11 zero-pad cells with 'zero pad to 16 bytes'); bottom shows the wire form (header + 16-byte AES ciphertext block). Highlights the symmetric client-up/controller-down design. src/assets/diagrams/packet-omnilink-message.svg Covers all four conversation packet types in one diagram: 0x10 (v1 encrypted), 0x11 (v1 plaintext), 0x20 (v2 encrypted), 0x21 (v2 plaintext). Top row shows the encrypted variant with N x 16-byte ciphertext blocks; brace down to bottom row showing the inner Message format (start byte + length + opcode + data + CRC u16 LE). Same diagram serves all 4 since the layout is identical except for the type byte and whether AES is applied. src/content/docs/reference/protocol.mdx Added five new sections so every PacketType is now documented: - NoMessage (0x00) folded into the ClientRequestNewSession heading (same empty-payload layout) - ClientSessionTerminated (0x05) / ControllerSessionTerminated (0x06) / ControllerCannotStartNewSession (0x07) get a combined section with a one-paragraph explanation of when each fires - OmniLinkMessage (0x10), OmniLinkUnencryptedMessage (0x11), OmniLink2Message (0x20), OmniLink2UnencryptedMessage (0x21) consolidated into one section with the diagram + a note about which TCP/PC-Access actually uses ClientRequestSecureSession + ControllerAckSecureSession merged into one section since they share the diagram; original prose preserved as #### subsections beneath. Build: 23 pages clean. Protocol page now 118 KB (was 92 KB) — six inline SVG titles confirmed via grep on the rendered HTML. Every packet type defined in omni_pca.opcodes.PacketType (12 values) is now visualized in the docs. --- src/assets/diagrams/packet-control-empty.svg | 49 +++++++++++ .../packet-controller-ack-new-session.svg | 66 +++++++++++++++ .../diagrams/packet-omnilink-message.svg | 77 ++++++++++++++++++ src/assets/diagrams/packet-secure-session.svg | 81 +++++++++++++++++++ src/content/docs/reference/protocol.mdx | 64 ++++++++++++++- 5 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 src/assets/diagrams/packet-control-empty.svg create mode 100644 src/assets/diagrams/packet-controller-ack-new-session.svg create mode 100644 src/assets/diagrams/packet-omnilink-message.svg create mode 100644 src/assets/diagrams/packet-secure-session.svg diff --git a/src/assets/diagrams/packet-control-empty.svg b/src/assets/diagrams/packet-control-empty.svg new file mode 100644 index 0000000..243cad4 --- /dev/null +++ b/src/assets/diagrams/packet-control-empty.svg @@ -0,0 +1,49 @@ + + Empty-payload control packets + Five packet types share an identical four-byte structure with no payload: NoMessage (0x00), ClientRequestNewSession (0x01), ClientSessionTerminated (0x05), ControllerSessionTerminated (0x06), and ControllerCannotStartNewSession (0x07). The wire packet is exactly four bytes total. + + + + Empty-payload control packets + 4 bytes total on the wire — header only, no payload. + + + + + + + + + + + + + + seq_hi + u8 + seq_lo + u8 + type + u8 + reserved + 0x00 + (no payload) + + + + SEQ NUMBER · u16 BE + PACKET TYPE + + + type = + 0x00 NoMessage · 0x01 ClientRequestNewSession · 0x05 ClientSessionTerminated + 0x06 ControllerSessionTerminated · 0x07 ControllerCannotStartNewSession + diff --git a/src/assets/diagrams/packet-controller-ack-new-session.svg b/src/assets/diagrams/packet-controller-ack-new-session.svg new file mode 100644 index 0000000..63cf770 --- /dev/null +++ b/src/assets/diagrams/packet-controller-ack-new-session.svg @@ -0,0 +1,66 @@ + + ControllerAckNewSession (type 0x02) + Reply from the controller to a new session request. Carries a 7-byte payload: a hard-coded protocol version (00 01) followed by a 5-byte random SessionID nonce. The SessionID is what gets XOR-mixed with the ControllerKey to derive the AES session key. + + + + ControllerAckNewSession (type 0x02) + 11 bytes total. Plaintext on the wire. + + + + + + + + seq_hi + u8 + seq_lo + u8 + type + 0x02 + resv + 0x00 + + + + + proto_hi + 0x00 + proto_lo + 0x01 + + + + + + + + sid 0 + u8 + sid 1 + u8 + sid 2 + u8 + sid 3 + u8 + sid 4 + u8 + + + HEADER (4 bytes) + PROTO VERSION + SESSION ID (5-byte nonce) — feeds session key XOR mix + + PC Access hard-rejects any proto version other than 00 01. The SessionID is freshly random per session. + diff --git a/src/assets/diagrams/packet-omnilink-message.svg b/src/assets/diagrams/packet-omnilink-message.svg new file mode 100644 index 0000000..54d1dba --- /dev/null +++ b/src/assets/diagrams/packet-omnilink-message.svg @@ -0,0 +1,77 @@ + + OmniLinkMessage and OmniLink2Message — encrypted vs unencrypted variants + Four packet types share this layout. v1 encrypted (0x10), v1 plaintext (0x11), v2 encrypted (0x20), and v2 plaintext (0x21). The payload is one or more 16-byte AES blocks for encrypted variants, or the inner Message bytes laid out directly for unencrypted variants. The inner Message starts with a start byte (0x41 for v1, 0x21 for v2), a length, the opcode, data, and a two-byte CRC. + + + + OmniLinkMessage / OmniLink2Message — four variants + v1 = 0x10 encrypted · 0x11 plaintext · v2 = 0x20 encrypted · 0x21 plaintext · same shape, different framing. + + + encrypted variants (0x10 / 0x20) — payload is N × 16 bytes of AES ciphertext: + + + + + + + seq_hi + seq_lo + type + 10 / 20 + resv + + + + + + block 1 + 16 B + block 2 + 16 B + block 3 + + block N + 16 B + + + HEADER (4 B) + CIPHERTEXT (per-block whitened) + + + + decrypts + unwhitens to → + + + inner Message (after AES + unwhiten, or directly on the wire for 0x11 / 0x21): + + + + + + + + start + 41/21 + length + u8 + opcode + u8 + data + N bytes + CRC u16 + LE · MODBUS + + diff --git a/src/assets/diagrams/packet-secure-session.svg b/src/assets/diagrams/packet-secure-session.svg new file mode 100644 index 0000000..89fbf9e --- /dev/null +++ b/src/assets/diagrams/packet-secure-session.svg @@ -0,0 +1,81 @@ + + ClientRequestSecureSession (0x03) and ControllerAckSecureSession (0x04) + Both secure-session packets carry the same payload shape: the 5-byte SessionID echoed back, zero-padded to a 16-byte AES block, encrypted with the freshly-derived session key, and per-block whitened. 20 bytes total on the wire. + + + + ClientRequestSecureSession (0x03) · ControllerAckSecureSession (0x04) + Same shape both directions. 20 bytes total. Encrypted payload = SessionID echo, zero-padded, AES-encrypted with per-block whitening. + + + plaintext (before AES + whitening): + + + + + + + + sid 0 + sid 1 + sid 2 + sid 3 + sid 4 + u8 + u8 + u8 + u8 + u8 + + + + + + + + + + + + + + …zero pad to 16 bytes… + + + + SESSION ID ECHO + ZERO PADDING + + + on the wire (after AES + whitening): + + + + + + + + seq_hi + seq_lo + type + 03/04 + resv + 0x00 + + + + 16-byte AES-128 ciphertext (one block) + + diff --git a/src/content/docs/reference/protocol.mdx b/src/content/docs/reference/protocol.mdx index bd9c1eb..767e8be 100644 --- a/src/content/docs/reference/protocol.mdx +++ b/src/content/docs/reference/protocol.mdx @@ -5,6 +5,10 @@ description: Byte-level handshake, packet layouts, key derivation, and steady-st import HandshakeSequence from '../../../assets/diagrams/handshake-sequence.svg?raw'; import PacketStructure from '../../../assets/diagrams/packet-structure.svg?raw'; +import PacketEmpty from '../../../assets/diagrams/packet-control-empty.svg?raw'; +import PacketAckNew from '../../../assets/diagrams/packet-controller-ack-new-session.svg?raw'; +import PacketSecureSession from '../../../assets/diagrams/packet-secure-session.svg?raw'; +import PacketOmniLink from '../../../assets/diagrams/packet-omnilink-message.svg?raw';
@@ -81,14 +85,23 @@ UDP / 1808 TCP). 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) +### `NoMessage` (type 0x00) and `ClientRequestNewSession` (type 0x01) + +
+ +Both have empty payloads. `NoMessage` is the protocol's keepalive — sent +when the client wants to confirm the TCP connection is alive without doing +anything. `ClientRequestNewSession` is step 1 of the handshake. Wire = 4 +bytes total either way (just the header). | Offset | Size | Field | Notes | |-------:|-----:|-------|-------| -| — | 0 | (no payload) | `clsOmniLinkPacket.Data == null` (line 1283/1688). Wire = 4 bytes total: `00 01 01 00`. | +| — | 0 | (no payload) | `clsOmniLinkPacket.Data == null` (line 1283/1688). | ### `ControllerAckNewSession` (type 0x02) +
+ Payload size **7 bytes** (TCP reader hardcodes `tcpReadBytes(array, 7)` on this type, line 1714). @@ -100,7 +113,19 @@ type, line 1714). Total wire packet: 4-byte header + 7-byte payload = 11 bytes. -### `ClientRequestSecureSession` (type 0x03) +### `ClientRequestSecureSession` (type 0x03) and `ControllerAckSecureSession` (type 0x04) + +
+ +Both directions carry the same 5-byte SessionID echo, zero-padded to a +16-byte AES block, encrypted, and per-block whitened. The client builds +step 3 with the SessionID it received in step 2; the controller replies in +step 4 with the same SessionID re-encrypted. The implicit "did the AES +round-trip succeed?" is the only proof both sides have the same key — ECB +provides no integrity check, so the wrong key produces 16 bytes of garbage +that the receiver will dutifully accept (see notes below the next table). + +#### `ClientRequestSecureSession` plaintext layout Payload **before encryption** is 5 bytes; **on the wire** it is 16 bytes (one AES block). @@ -118,7 +143,7 @@ Then `EncryptPacket` runs (lines 396-401): controller can only decrypt this if it computed the same key from its own `ControllerKey` and the SessionID it generated. -### `ControllerAckSecureSession` (type 0x04) +#### `ControllerAckSecureSession` (type 0x04) Payload size **16 bytes** on the wire (TCP reader hardcodes `tcpReadBytes(array, 16)`, line 1722-1729). @@ -136,6 +161,37 @@ 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. +### `ClientSessionTerminated` (type 0x05), `ControllerSessionTerminated` (type 0x06), `ControllerCannotStartNewSession` (type 0x07) + +All three share the empty-payload layout. `ClientSessionTerminated` is sent +by `OmniConnection.close()` for a graceful shutdown. `ControllerSessionTerminated` +is what the panel sends on a wrong key, a session timeout, or when it kicks +an idle client. `ControllerCannotStartNewSession` is the panel saying "I'm +already talking to someone; the protocol is single-client" — it arrives in +response to a `ClientRequestNewSession` when an existing session is open. + +The four-byte wire form for all three is just `[seq_hi seq_lo TYPE 0x00]`. + +### `OmniLinkMessage` (type 0x10), `OmniLinkUnencryptedMessage` (type 0x11), `OmniLink2Message` (type 0x20), `OmniLink2UnencryptedMessage` (type 0x21) + +
+ +These four packet types are the actual conversation — every command, +status query, and unsolicited push event flows through one of them. The +shape is the same; the type byte tells you whether the payload is +encrypted (`0x10` / `0x20`) or laid bare on the wire (`0x11` / `0x21`), +and which protocol version (`v1` for `0x1x`, `v2` for `0x2x`). + +The encrypted variants on TCP/v2 are what PC Access actually uses +day-to-day. The unencrypted variants exist for the serial path and for +diagnostics. PC Access never sends `OmniLink2UnencryptedMessage` over +TCP — the panel would accept it but no production deployment uses it. + +The inner Message format is documented at the top of this page (start +byte, length, opcode, data, CRC-16/MODBUS). The opcode tables for v1 and +v2 live in [`omni_pca.opcodes`](https://github.com/rsp2k/omni-pca/blob/main/src/omni_pca/opcodes.py) +— too long to reproduce here. + ### v2 `Login` (inner opcode 42) — *defined but unused on TCP* If the protocol ever calls for it, the layout is (`clsOL2MsgLogin.cs`):