canbus: confirm command path live + frame docs as device integration

Command path proven end to end on the bus (node F8 interior lights, on/off/on),
each answering a distinct fresh challenge; bare opcodes without the exchange are
ignored. ids_can_auth.h verified bit-exact against ids_can_auth.py and the
captured/live pairs.

- idscan_cmd.py: stdlib socketcan tool running the full page-42/43 exchange
- esphome/onecontrol-canbus.yaml: correct IDS-CAN read dispatch (was stale RV-C
  DGN code) + command path wired to the auth header
- README/memory: document the read map + command authentication; rename
  sniff/ -> captures/; neutral device-integration framing throughout

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wes
2026-06-12 11:20:12 -04:00
co-authored by Claude Opus 4.8
parent 840cfaf5fc
commit 742ef49c8a
14 changed files with 470 additions and 245 deletions
+166 -149
View File
@@ -1,27 +1,34 @@
# OneControl via CANbus (IDS-CAN) # OneControl CAN integration (Lippert IDS-CAN)
Direct **CANbus** integration for the Lippert OneControl (UNITY **X180T**) A local Home Assistant integration for my own RV's Lippert OneControl
system — the successor to the BLE-gateway approach in this repo's `src/` + (UNITY **X180T**) system, talking to it directly over its **CAN network**
`custom_components/`. The BLE path works but is laggy and brittle (connection- instead of through the Bluetooth gateway. The Bluetooth path in this repo's
based GATT, ~30 s idle timeout, per-reconnect TEA auth, single shared Pi radio, `src/` + `custom_components/` works but is laggy and brittle (connection-based
fragile SMP pairing). The OneControl panel is just a gateway bolted onto a CAN GATT, ~30 s idle timeout, a per-reconnect handshake, a single shared Pi radio,
backbone; tapping the bus gives **no bond/auth/timeout, instant latency, and fragile pairing). The OneControl panel is just a gateway sitting on a CAN
visibility into everything on the network** (incl. signals the BLE protocol never backbone, so connecting to the bus directly gives instant latency, no
exposed, like the DSI fault). connection/timeout churn, and visibility into every signal the modules
broadcast — including ones the Bluetooth API never surfaced, like the
water-heater DSI fault.
**Status:** first sniff done 2026-06-11 — **the bus is NOT RV-C.** It runs This file documents the on-wire message format so the ESP32 node can present the
Lippert's proprietary **IDS-CAN**: 250 kbit/s, but **11-bit standard IDs** coach's tanks, lights, switches, and awning as native HA entities. Everything
(plus a handful of 29-bit frames for telemetry/sync). The protocol structure below comes from live bus captures of my own coach in `captures/*.log`.
and device map below are from live captures in `sniff/*.log`.
**Status:** Despite Lippert's "RV-C" branding, the bus is **not** RV-C. It runs
Lippert's own **IDS-CAN**: 250 kbit/s, **11-bit standard IDs** (plus a handful of
29-bit frames for telemetry and directed messages). The **read path is fully
mapped**, and the **command path is implemented and confirmed by live actuation
(2026-06-12)** — see below.
--- ---
## IDS-CAN findings (2026-06-11, captures: `sniff/baseline-*.log`, `sniff/toggletest-*.log`) ## Protocol notes (captures: `captures/baseline-*.log`, `captures/toggletest-*.log`)
### Frame structure ### Frame structure
11-bit ID = **`(page << 8) | node_addr`**. Every node broadcasts its pages at 11-bit ID = **`(page << 8) | node_addr`**. Every node broadcasts its pages at
**1 Hz** (plus immediate rebroadcast on change). Pages seen: **1 Hz** (plus an immediate rebroadcast on change). Pages observed:
| Page | Content | | Page | Content |
|------|---------| |------|---------|
@@ -34,7 +41,7 @@ and device map below are from live captures in `sniff/*.log`.
### Device classes (page-2 `type` byte) ### Device classes (page-2 `type` byte)
- **`0x0A` = tank.** Page 3 = **1 byte, level in percent** (0x42=66%, 0x21=33%). - **`0x0A` = tank.** Page 3 = **1 byte, level in percent** (0x42 = 66%, 0x21 = 33%).
- **`0x1E` = switched load** (lights/pump/heater). Page 3 = 6 bytes: - **`0x1E` = switched load** (lights/pump/heater). Page 3 = 6 bytes:
`b0` bit0 = **ON/OFF**, `b2..b3` (BE) = live **current/level reading** that `b0` bit0 = **ON/OFF**, `b2..b3` (BE) = live **current/level reading** that
soft-ramps on switch-on and decays on switch-off (interior lights ramped soft-ramps on switch-on and decays on switch-off (interior lights ramped
@@ -55,7 +62,7 @@ and device map below are from live captures in `sniff/*.log`.
| `FE` | **black tank** | type 0x0A; 66%→33% on drain (2026-06-11) ✓; also owns the 7FE counter | | `FE` | **black tank** | type 0x0A; 66%→33% on drain (2026-06-11) ✓; also owns the 7FE counter |
| `E2` | **fresh tank** | type 0x0A, page3 = 0x00 = 0% ✓ | | `E2` | **fresh tank** | type 0x0A, page3 = 0x00 = 0% ✓ |
| `2A` | **exterior lights** | type 0x1E; toggle test t≈6976 s | | `2A` | **exterior lights** | type 0x1E; toggle test t≈6976 s |
| `F8` | **interior lights** | type 0x1E; toggle test t≈5161 s | | `F8` | **interior lights** | type 0x1E; toggle test t≈5161 s; **operated live 2026-06-12** |
| `95` | **water heater** | type 0x1E; toggle test t≈8594 s | | `95` | **water heater** | type 0x1E; toggle test t≈8594 s |
| `61` | **water pump** | type 0x1E; toggle test 2026-06-11 (on 13.5s / off 23.8s) ✓ | | `61` | **water pump** | type 0x1E; toggle test 2026-06-11 (on 13.5s / off 23.8s) ✓ |
| `89` | unknown switched load | type 0x1E, never toggled (furnace? DSI?) | | `89` | unknown switched load | type 0x1E, never toggled (furnace? DSI?) |
@@ -66,126 +73,132 @@ and device map below are from live captures in `sniff/*.log`.
### 29-bit extended frames (directed messages) ### 29-bit extended frames (directed messages)
Extended ID = **`(src_node << 18) | flags? | (dest_node << 8) | page`** Extended ID = **`(src_node << 18) | (dir << 16) | (dest_node << 8) | page`**,
where `dir` = 0 for a `01`→node message and 1 for a node→`01` message
(verified: pump event `0185FC42` = src `61` → dest `FC`; awning `01D5FC42` = (verified: pump event `0185FC42` = src `61` → dest `FC`; awning `01D5FC42` =
src `75` → dest `FC`; replies `03F0<node>43` = src `FC` → dest node, page 43). src `75` → dest `FC`; replies `03F0<node>43` = src `FC` → dest node, page 43).
- `01F5FC11` (src `7D``FC`) / `02B90111` (src `AE``01`) — periodic, - `01F5FC11` (src `7D``FC`) / `02B90111` (src `AE``01`) — periodic,
payload `00 2B 0D 4x <rolling>`: `b2..b3` ≈ 0x0D4647 → /256 = **13.27 V ⇒ payload `00 2B 0D 4x <rolling>`: `b2..b3` ≈ 0x0D4647 → /256 = **13.27 V ⇒
battery voltage**, last byte looks like a checksum. (BLE read 13.09 V the battery voltage**, last byte looks like a checksum. (Bluetooth read 13.09 V the
same day; charger float plausible.) Note the *source* being `7D`/`AE` same day; charger float plausible.) The *source* being `7D`/`AE` suggests those
suggests those modules carry the battery-sense wire, not the controller. modules carry the battery-sense wire, not the controller.
- On every state change: a burst of `xxxxFC02` IDs (every node → dest `FC`) - On every state change: a burst of `xxxxFC02` IDs (every node → dest `FC`)
flip a `55``AA` marker (state-change announce/sync flood), plus a per-event flip a `55``AA` marker (a state-change announce/sync broadcast), plus a
handshake pair src-node→`FC` page 42 / `FC`→node page 43 with per-event handshake pair (src-node→`FC` page 42 / `FC`→node page 43) — not
random-looking bytes — not needed for sensing. needed for sensing.
### Command path (DECODED 2026-06-11 — `sniff/app-commands-*.log`) ### Command messages (captures: `captures/app-commands-*.log`)
The command opcode is a **zero-payload (DLC 0) extended frame** `0x0006<node><op>` A command is a **zero-payload (DLC 0) 29-bit frame** `0x0006<node><op>`
(`op`: `01`=on, `00`=off/stop, `02`=movement-retract). The BLE app's taps appear (`op`: `01`=on, `00`=off/stop, `02`=movement-retract). The app's button presses
on the bus as these, ~300 ms before the page-3 state flips. BUT — appear on the bus as exactly these, ~300 ms before the page-3 state updates.
**WRITE IS AUTH-GATED — and the gate is now CRACKED (2026-06-12, see below).** Each command is preceded by a short **challenge-response authentication
Each command is wrapped in a **rolling challenge-response** the bare opcode exchange** — the module won't act on a bare opcode:
won't pass:
``` ```
01 → node page42 "00 04" # controller: "arm me a challenge" 01 → node page42 "00 04" # controller requests a challenge
node → 01 page42 "00 04 <CC CC CC CC>" # module: random 4-byte challenge node → 01 page42 "00 04 <CC CC CC CC>" # module returns a 4-byte challenge
01 → node page43 "00 04 <RR RR RR RR>" # controller: correct response 01 → node page43 "00 04 <RR RR RR RR>" # controller returns the matching response
node → 01 page43 "00 04" # module: ack node → 01 page43 "00 04" # module acknowledges
01 → node 0x0006<node><op> ×3 # the actual command (now honored) 01 → node 0x0006<node><op> ×3 # command (now acted on)
01 → node page45 / node → 01 page45 # post-status (00, then 0E) 01 → node page45 / node → 01 page45 # post-status (00, then 0E)
``` ```
The challenge is **fresh every time** (interior lights: `F7 74 0A 20` then The challenge is **fresh on every press** (interior lights returned `F7 74 0A 20`
`ED C9 28 1A` on two presses → different responses), so captured frames can't then `ED C9 28 1A` on two consecutive presses → different responses), so a
be replayed. **Verified empirically:** spoofing bare `cansend can0 00062A00#` previously captured exchange can't be re-used. Confirmed: re-sending a captured
×3 (ext lights, no handshake) — frames hit the bus (TX confirmed, self-echo opcode on its own — `cansend can0 00062A00#` ×3 with no live exchange — reaches
seen) but the load **did not actuate**. The module ignores an unauthenticated the bus (TX echoed back) but the module ignores it. The integration therefore
opcode. performs the same handshake the OEM app does.
It uses a **different key** from the BLE TEA auth (`tea(612643285, 0x21CA0C06) = The authentication uses a **different key** from the Bluetooth side
0x87AC5CBD ≠` the observed `0xCC18366B`) but, as it turns out, the **same (`tea(612643285, 0x21CA0C06) = 0x87AC5CBD ≠` the observed `0xCC18366B`) but the
family**: a TEA/XTEA Feistel. Lippert put a second, separately-keyed auth on the **same algorithm family** — a 32-round TEA/XTEA transform. Lippert applies a
CAN write path. second, independently-keyed authentication on the CAN command path.
**Dataset for the crack: `sniff/2A-auth-pairs.txt`** (42 pairs, node `2A`) + **Reference dataset:** `captures/2A-auth-pairs.txt` (42 challenge/response pairs,
`sniff/auth-pairs-multinode-2026-06-11.txt` (9 more across nodes `61`/`75`/`F8` node `2A`) + `captures/auth-pairs-multinode-2026-06-11.txt` (9 more across nodes
+2 on `2A`) — **51 pairs / 4 nodes**, captured 2026-06-11 (app-driven). `61`/`75`/`F8`, +2 on `2A`) — **51 pairs across 4 nodes**, captured 2026-06-11
from app-driven commands. `captures/analyze_auth.py` characterizes
`response = f(challenge)`: a keyed nonlinear transform (not GF(2)-affine — the 51
input-differences span the full 32-dim space yet contradict a linear fit; not
affine over Z/2³²; full byte diffusion; balanced bits), consistent with the
TEA/XTEA family.
Structural analysis of `response = f(challenge)` (script `sniff/analyze_auth.py`): ### Authentication implementation — `ids_can_auth.py` (2026-06-12)
**genuine keyed nonlinear block cipher.** Ruled out by the data — **not**
GF(2)-affine (the 51 input-differences span the full 32-dim space yet contradict
a linear fit, so the obstacle is *structure, not too few pairs* — a linear map
would have over-solved at ~33), **not** affine over Z/2³² (49/51 miss), and no
output byte is a function of any single input byte (full byte diffusion). Bits
are balanced. ⇒ TEA/XTEA/Speck-family with an unknown key, exactly as the BLE
side uses TEA.
That structural read said the function was unrecoverable from random pairs and `response = Encrypt(challenge, session_key)`, both 32-bit **big-endian** (the 4
pointed at recovering the key rather than cryptanalyzing the captures — which is payload bytes after the `00 04` prefix). The transform is a **32-round TEA/XTEA
exactly what happened. Feistel** (delta `0x9E3779B9`) with baked-in round constants, keyed by a
per-**session** 32-bit value the protocol calls the "Cypher". The protocol
defines five session keys (the memorable hex values are the protocol's own
constants):
#### ✅ SOLVED (2026-06-12) — `ids_can_auth.py` | Session | Key | Use |
The cipher is a **32-round TEA/XTEA Feistel** (delta `0x9E3779B9`) keyed by a
per-**session** 32-bit "Cypher", with the round constants baked in. There are
five sessions — the joke hex values confirm they're the genuine keys:
| Session | Cypher | Use |
|---------|--------|-----| |---------|--------|-----|
| MANUFACTURING | `0xB16BA115` | factory features | | MANUFACTURING | `0xB16BA115` | factory features |
| DIAGNOSTIC | `0xBABECAFE` | diagnostic tool (← likely unlocks the DSI fault path) | | DIAGNOSTIC | `0xBABECAFE` | diagnostic tool (← likely the path that carries the DSI fault) |
| REPROGRAMMING | `0xDEADBEEF` | firmware reflash | | REPROGRAMMING | `0xDEADBEEF` | firmware reflash |
| **REMOTE_CONTROL** | **`0xB16B00B5`** | **on/off/move — this is the write gate** | | **REMOTE_CONTROL** | **`0xB16B00B5`** | **on/off/move — this is the command-path key** |
| DAQ | `0x0B00B135` | data acquisition | | DAQ | `0x0B00B135` | data acquisition |
`response = Encrypt(challenge, 0xB16B00B5)`, both 32-bit **big-endian** (the 4 `remote_control_response(challenge)` returns the value the module expects.
payload bytes after `00 04`). **Verified 51/51** against every captured pair, **Validated against all 51 captured pairs** across four nodes (2A 44/44, 61 2/2,
all four nodes (2A 44/44, 61 2/2, 75 3/3, F8 2/2) REMOTE_CONTROL is unique 75 3/3, F8 2/2): REMOTE_CONTROL is the unique session key that matches every pair
(every other key misses 51/51), and it's **one global key, not per-node**. So to (the other four miss all 51), and it's **one global key, shared by all nodes**.
actuate a load: catch the module's page-42 challenge, compute the response, send So to operate a load: read the module's page-42 challenge, compute the response,
it on page-43, then send the opcode. Reference impl + self-test in send it on page-43, then send the opcode. Reference implementation + self-test in
`ids_can_auth.py` (`python3 ids_can_auth.py <challenge_hex>`). No firmware dump `ids_can_auth.py` (`python3 ids_can_auth.py <challenge_hex>` prints a response;
was needed; the 51 captures were the verification oracle. `python3 ids_can_auth.py` runs the 51/51 self-test).
> Movement nodes use the **same gate.** App-driven awning (`75`) commands in ### Confirmed by live actuation (2026-06-12) — `idscan_cmd.py`
> `sniff/app-commands-*.log` show the full nonce handshake (node→01 page42
> challenge `01D50142` + 01→node page43 response), identical to the switched
> loads — *not* the commander-only/no-reply pattern an earlier jog test
> suggested. NOT spoof-tested (don't actuate a motor unattended).
**Bottom line: READ is fully open** (all sensors + states from broadcasts, zero `idscan_cmd.py` drives the whole exchange end-to-end over socketcan (raw AF_CAN,
auth) **and WRITE is now unlocked** the command-auth cipher is cracked stdlib only). Tested on node **`F8` (interior lights)**: three consecutive
(`ids_can_auth.py`), so the CAN path can both sense and actuate. The BLE operations (**on → off → on**), each answering a **distinct fresh challenge**
integration is no longer the only way to control loads; next step is wiring the (`660E04A0`, `0BF53691`, `10FAEEA8`), with the module's page-3 broadcast read
challenge-response into the ESPHome node's `switch`/`cover` actions (the bare back before and after to confirm the result each time — `b0` bit0 tracked the
opcode in the command DGN now just needs the page-42/43 handshake in front of command (1→1, 1→0, 0→1) and the level byte ramped accordingly. The command path
it). Movement nodes (slides/jacks) still want a careful first actuation test. works.
```sh
python3 idscan_cmd.py F8 on # node_hex on|off ; needs can0 up
```
Movement nodes (awning `75`, slides, jacks) use the **same** authentication —
the app-driven awning commands in `captures/app-commands-*.log` show the identical
page-42/43 exchange. Not yet operated this way; exercise a motor only while
watching it.
**Bottom line: read is fully open** (all sensors + states from broadcasts, no
authentication) **and command is implemented and proven** (`ids_can_auth.py` +
`idscan_cmd.py`). The CAN path can both sense and operate the system, so the
Bluetooth integration is no longer needed for control. Next step: fold the
challenge-response into the ESPHome node's `switch`/`light`/`cover` actions (the
opcode just needs the page-42/43 exchange in front of it).
Other app-session traffic (not control): `701` = controller heartbeat during a Other app-session traffic (not control): `701` = controller heartbeat during a
BLE session; src 01 → node pages `30/31` = paged descriptor/table reads the app Bluetooth session; src `01` → node pages `30/31` = paged descriptor/table reads
uses to build its UI. the app uses to build its UI.
**Open read-side items:** identify node `89` (last untoggled 0x1E load) and **Open read-side items:** identify node `89` (last unmapped 0x1E load) and
`6A`/`7F`/`9C` (movement — slide?), find battery SoC / the "4 green lights" `6A`/`7F`/`9C` (movement — slide?), and find the battery SoC / "4 green lights"
source. source.
### TODO: capture the DSI fault (planned 2026-06-12) ### Planned: capture the DSI fault (2026-06-12)
The water-heater DSI fault is almost certainly on the bus but every capture so The water-heater DSI fault is almost certainly on the bus, but every capture so
far is of a *healthy* heater, so the fault encoding is unknown. **Plan:** close far is of a *healthy* heater, so the fault encoding is unknown. **Plan:** close
the propane tank valve, run the water heater on gas until it locks out (DSI the propane tank valve, run the water heater on gas until it locks out (DSI fault
fault light on panel), capture ~20 s with the CANable, then diff against a light on the panel), capture ~20 s, then diff against a healthy baseline. Prime
healthy baseline. Prime suspects (both sit at a constant "all-clear" sentinel suspects (both sit at a constant "all-clear" value in current captures):
in current captures): - **node `95` (heater) page-3 `b1`** — always `0xFF`; expect it to drop a bit on fault.
- **node `95` (heater) page-3 `b1`** — always `0xFF`; expect it to drop/clear a bit on fault.
- **node `AE` (type 0x27, ?LP-gas/diagnostics) page-3** — always `0x00`; expect non-zero on fault. - **node `AE` (type 0x27, ?LP-gas/diagnostics) page-3** — always `0x00`; expect non-zero on fault.
Whichever flips → becomes a `binary_sensor` in the ESPHome node (the DSI fault Whichever flips → becomes a `binary_sensor` in the ESPHome node (the DSI fault
the BLE app never exposed). Reset = reopen valve, re-light. the Bluetooth app never exposed). Reset = reopen valve, re-light.
--- ---
@@ -193,25 +206,24 @@ the BLE app never exposed). Reset = reopen valve, re-light.
| Item | Notes | | Item | Notes |
|------|-------| |------|-------|
| **CANable 2.0** USB-CAN | RE/sniffing from xarl. candleLight/gs_usb fw → native socketcan (`can0`). | | **CANable 2.0** USB-CAN | Bus capture/bring-up from xarl → socketcan (`can0`). |
| **Waveshare SN65HVD230** transceiver | 3.3 V, **onboard 120 Ω terminator** → use as the bus-END node. | | **Waveshare SN65HVD230** transceiver | 3.3 V, **onboard 120 Ω terminator** → use as the bus-END node. |
| **ESP32** devboard (`esp32dev` WROOM) | Native TWAI/CAN peripheral; ESPHome `esp32_can`. Spare from the gazebo build. | | **ESP32** devboard (`esp32dev` WROOM) | Native TWAI/CAN peripheral; ESPHome `esp32_can`. Spare from the gazebo build. |
| **Molex Mini-Fit Jr.** 2-pin pigtail (female) | Mates the panel's spare CAN **data** port. ~$20 assortment pack, not the $30 Lippert #331111. | | **Molex Mini-Fit Jr.** 2-pin pigtail (female) | Mates the panel's spare CAN **data** port. ~$20 assortment pack, not the $30 Lippert #331111. |
## System facts (from `lippert_control_panel_specs.pdf`, doc CCD-0004084, + web) ## System facts (from `lippert_control_panel_specs.pdf`, doc CCD-0004084, + web)
- **Controller:** UNITY **X180T**. Lippert brands it "RV-C" but the bus - **Controller:** UNITY **X180T**. Lippert brands it "RV-C", but the bus actually
actually runs **IDS-CAN** (proprietary): 250 kbit/s, 11-bit IDs — see runs **IDS-CAN** (proprietary): 250 kbit/s, 11-bit IDs — see protocol notes above.
findings above.
- **Topology:** daisy-chain; each module has **two 2-pin CAN data ports**. - **Topology:** daisy-chain; each module has **two 2-pin CAN data ports**.
**CAN data = 2-wire pair (CAN-H/CAN-L)** on a **Molex Mini-Fit Jr.** (4.2 mm) **CAN data = 2-wire pair (CAN-H/CAN-L)** on a **Molex Mini-Fit Jr.** (4.2 mm)
connector; Lippert's data pair is **red/black**. Power is a SEPARATE 2-pin connector; Lippert's data pair is **red/black**. Power is a SEPARATE 2-pin
harness. Bus terminated at **both ends** by a 2-pin terminator plug (120 Ω H↔L). harness. Bus terminated at **both ends** by a 2-pin terminator plug (120 Ω H↔L).
- **Tank senders wire directly into the X180T** (DSI/FRESH/BLACK/GRAY/GRAY2 - **Tank senders wire directly into the X180T** (DSI/FRESH/BLACK/GRAY/GRAY2
terminal block) → controller reads resistive senders and broadcasts levels on terminal block) → the controller reads resistive senders and broadcasts levels
CAN from its own source address (not separate tank modules). on CAN from its own source address (not separate tank modules).
## Physical tap (4 screws, fully reversible) ## Physical connection (4 screws, fully reversible)
1. Pull the monitor panel (4 screws). 1. Pull the monitor panel (4 screws).
2. Find the **data** port — the one with the **terminating resistor** plugged in 2. Find the **data** port — the one with the **terminating resistor** plugged in
@@ -225,25 +237,33 @@ the BLE app never exposed). Reset = reopen valve, re-light.
+ your node). **Never** add a terminated node in the *middle* of the bus. + your node). **Never** add a terminated node in the *middle* of the bus.
5. Revert = unplug, re-seat the terminator. 5. Revert = unplug, re-seat the terminator.
CAN-H vs CAN-L: can't hurt anything if swapped — bus just goes silent, flip the CAN-H vs CAN-L: harmless if swapped — the bus just goes silent; flip the two
two wires (or pop the Mini-Fit Jr. terminals and reorder). wires (or pop the Mini-Fit Jr. terminals and reorder).
--- ---
## Sniffing workflow (do this first, before the ESP32 build) ## Capturing bus traffic
On xarl with the CANable (see `sniff/log-can.sh`): On xarl with the CANable (see `captures/log-can.sh` for an `up`/`rec`/`watch`/`down`
helper):
```sh ```sh
sudo pacman -S can-utils paru -S can-utils # AUR on Arch (not in the repos)
sudo ip link set can0 up type can bitrate 250000 sudo slcand -o -s5 /dev/ttyACMx can0 # this CANable shipped with slcan fw
candump can0 # any traffic at 250k ⇒ confirmed RV-C sudo ip link set can0 up
candump -ta -x can0 | tee sniff/$(date +%F)-idle.log # timestamped raw log candump -ta -x can0 | tee captures/$(date +%F)-idle.log # timestamped raw log
cansniffer -c can0 # color diff view — toggle a load, watch which bytes move cansniffer -c can0 # color diff view — operate a load, watch which bytes move
``` ```
**Mapping method (same as the BLE RE, but easier — broadcast, no auth):** > **CANable firmware note:** this unit enumerates as a serial device
flip ONE physical load (or watch ONE tank), see which **node + page + byte** > (`/dev/ttyACM*`), so it needs `slcand` to bridge it to a `can0` socketcan
> interface (`-s5` = 250 kbit/s). If it ever re-enumerates to a different
> `ttyACM` number, restart `slcand` against the new path. A candleLight/gs_usb
> reflash would instead give a native `can0` via
> `ip link set can0 up type can bitrate 250000`.
**Mapping method** (easy, because everything is broadcast and unauthenticated):
operate ONE physical load (or watch ONE tank), see which **node + page + byte**
changes, record it in the node map above. Repeat per device. changes, record it in the node map above. Repeat per device.
Decode each 11-bit ID as: Decode each 11-bit ID as:
@@ -252,45 +272,42 @@ page = (id >> 8) & 0x7 # message page
node = id & 0xFF # node address (which module) node = id & 0xFF # node address (which module)
``` ```
> Note: the CANable 2.0 shipped with **slcan** firmware (enumerates as ### Device inventory (from the Bluetooth notes — what to look for on the bus)
> `ttyACM0`, not gs_usb). Bridge it: `sudo slcand -o -s5 /dev/ttyACM0 can0`
> (`-s5` = 250k) then `sudo ip link set can0 up`. `log-can.sh`'s plain
> `ip link ... type can bitrate` path only applies after a candleLight reflash.
### Known device inventory (from the BLE RE — what to hunt for on the bus) | Bluetooth DevID | Component |
|-----------------|-----------|
| 4 | water pump |
| 5 | gas water heater |
| 6 | exterior lights |
| 7 | interior lights |
| 8 | grey tank 2 |
| 9 | grey tank 1 |
| 10 | black tank |
| 11 | fresh water tank |
| 2, 3 | slide / awning |
| — | battery voltage |
| BLE DevID | Component | Expect on CAN | > The Bluetooth DevID numbers do **not** map to IDS-CAN node addresses — the
|-----------|-----------|---------------| > table is just the checklist of loads to identify on the bus (all now found; see
| 4 | water pump | DC_DIMMER/switch instance | > the node map).
| 5 | gas water heater | DC_DIMMER/switch instance |
| 6 | exterior lights | DC_DIMMER instance |
| 7 | interior lights | DC_DIMMER instance |
| 8 | grey tank 2 | TANK_STATUS instance |
| 9 | grey tank 1 | TANK_STATUS instance |
| 10 | black tank | TANK_STATUS instance |
| 11 | fresh water tank | TANK_STATUS instance |
| 2,3 | slide / awning | DC_MOTOR / window-shade DGN |
| — | battery voltage | DC_SOURCE_STATUS_1 |
> The BLE DevID numbering does **not** transfer to RV-C instance numbers — the
> table is just the checklist of loads to identify by sniffing.
--- ---
## ESPHome node ## ESPHome node
`esphome/onecontrol-canbus.yaml` — ESP32 `esp32_can` listener (catch-all `esphome/onecontrol-canbus.yaml` — ESP32 `esp32_can` listener (catch-all
`on_frame`DGN dispatcher → template sensors/switches). Mirrors the `on_frame`IDS-CAN dispatcher → template sensors/switches). Mirrors the
`gazebo-fan-proxy` pattern: USB flash once, OTA after; native HA entities on the `gazebo-fan-proxy` pattern: USB flash once, OTA after; native HA entities on the
campsite Pi over the ESPHome API. While RE'ing, it logs every decoded frame at campsite Pi over the ESPHome API. During bring-up it logs every decoded frame at
`DEBUG` so the ESP can double as a sniffer. Fill in instances/byte-math in the `DEBUG` so the ESP can double as a monitor. Fill in the node/byte math in the
lambda as the DGN map firms up; wire the command DGN into the `switch` actions lambda from the node map; wire the command path (page-42/43 exchange +
last. `ids_can_auth` response, then the opcode) into the `switch`/`light`/`cover`
actions last.
## References ## References
- RV-C spec & DGN tables: <https://www.rv-c.com/> - `ids_can_auth.py` — IDS-CAN command authentication (response computation + self-test)
- CoachProxy / coachproxyos (open RV-C decode prior art) - `idscan_cmd.py` — socketcan command tool (the full exchange, proven on node F8)
- `rvc2mqtt`, `rvc-monitor` (DGN→MQTT mappings to crib) - Lippert OneControl: <https://www.lippert.com/brands/onecontrol>
- Lippert OneControl (RV-C): <https://www.lippert.com/brands/onecontrol> - RV-C background (for contrast — this bus is **not** RV-C): <https://www.rv-c.com/>
- BLE-side protocol (this repo): `../docs/PROTOCOL_FINDINGS.md` - Bluetooth-side protocol notes (this repo): `../docs/PROTOCOL_FINDINGS.md`
@@ -1,18 +1,21 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Bring up the CANable at RV-C speed and log timestamped raw frames. # Bring up the CANable at IDS-CAN speed and log timestamped raw frames.
# #
# Usage: # Usage:
# ./log-can.sh # bring up can0 @ 250k, live candump # ./log-can.sh # bring up can0 @ 250k, live candump
# ./log-can.sh rec NAME # also tee a timestamped log to NAME-<date>.log # ./log-can.sh rec NAME # also tee a timestamped log to NAME-<date>.log
# ./log-can.sh sniff # cansniffer color-diff view (toggle a load, watch bytes) # ./log-can.sh watch # cansniffer color-diff view (operate a load, watch bytes)
# ./log-can.sh down # take the interface down # ./log-can.sh down # take the interface down
# #
# Requires: can-utils (pacman -S can-utils). CANable 2.0 with candleLight/gs_usb # Requires: can-utils (paru -S can-utils on Arch). This CANable shipped with
# firmware enumerates as a native socketcan device (default can0). # slcan firmware (a /dev/ttyACM* serial device) — bridge it to can0 first with
# sudo slcand -o -s5 /dev/ttyACMx can0
# A candleLight/gs_usb reflash would instead give a native socketcan can0 and let
# the up() path below set the bitrate directly.
set -euo pipefail set -euo pipefail
IFACE="${IFACE:-can0}" IFACE="${IFACE:-can0}"
BITRATE=250000 # RV-C is always 250k BITRATE=250000 # IDS-CAN is 250k
CMD="${1:-up}" CMD="${1:-up}"
up() { up() {
@@ -31,7 +34,7 @@ case "$CMD" in
name="${2:-onecontrol}"; out="$(dirname "$0")/${name}-$(date +%F_%H%M%S).log" name="${2:-onecontrol}"; out="$(dirname "$0")/${name}-$(date +%F_%H%M%S).log"
echo "logging -> $out (Ctrl-C to stop)" echo "logging -> $out (Ctrl-C to stop)"
exec candump -ta -x "$IFACE" | tee "$out" ;; exec candump -ta -x "$IFACE" | tee "$out" ;;
sniff) up; exec cansniffer -c "$IFACE" ;; watch) up; exec cansniffer -c "$IFACE" ;;
down) sudo ip link set "$IFACE" down; echo "$IFACE down" ;; down) sudo ip link set "$IFACE" down; echo "$IFACE down" ;;
*) echo "usage: $0 {up|rec NAME|sniff|down}" >&2; exit 2 ;; *) echo "usage: $0 {up|rec NAME|watch|down}" >&2; exit 2 ;;
esac esac
+2 -2
View File
@@ -1,5 +1,5 @@
#pragma once #pragma once
// IDS-CAN command-auth response cipher for the OneControl write path. // IDS-CAN command authentication response for the OneControl integration.
// //
// 32-round TEA/XTEA-family Feistel, delta 0x9E3779B9, keyed by a per-session // 32-round TEA/XTEA-family Feistel, delta 0x9E3779B9, keyed by a per-session
// 32-bit "Cypher" with the round constants baked in. // 32-bit "Cypher" with the round constants baked in.
@@ -13,7 +13,7 @@
namespace ids_can_auth { namespace ids_can_auth {
// Per-session keys ("Cypher"). REMOTE_CONTROL gates on/off/move. // Per-session keys ("Cypher"). REMOTE_CONTROL is the session for on/off/move.
enum SessionKey : uint32_t { enum SessionKey : uint32_t {
MANUFACTURING = 0xB16BA115u, MANUFACTURING = 0xB16BA115u,
DIAGNOSTIC = 0xBABECAFEu, DIAGNOSTIC = 0xBABECAFEu,
+169 -73
View File
@@ -1,17 +1,19 @@
# OneControl RV-C CANbus node # OneControl IDS-CAN node
# #
# ESP32 (native TWAI/CAN) + external SN65HVD230 transceiver, tapped into the # ESP32 (native TWAI/CAN) + external SN65HVD230 transceiver, connected to the
# Lippert UNITY X180T RV-C bus at the monitor panel's spare CAN *data* port. # Lippert UNITY X180T CAN bus at the monitor panel's spare CAN *data* port.
# Listens to RV-C broadcasts and republishes them as native HA entities; the # Listens to the modules' broadcasts and republishes them as native HA entities,
# command/write path (switches) is stubbed until the DGN map is RE'd. # and issues commands using the panel's challenge/response authentication.
# #
# RV-C = 250 kbit/s, 29-bit extended IDs. See ../README.md for the tap procedure, # IDS-CAN = 250 kbit/s. Read broadcasts are 11-bit standard frames,
# the (unverified) DGN map, and the sniffing workflow. Flash over USB first # id = (page << 8) | node. Commands and their authentication exchange use 29-bit
# extended frames. See ../README.md for the connection procedure, the node map,
# and the message format. Flash over USB first
# (`esphome run onecontrol-canbus.yaml`), OTA thereafter. # (`esphome run onecontrol-canbus.yaml`), OTA thereafter.
substitutions: substitutions:
name: onecontrol-canbus name: onecontrol-canbus
friendly_name: OneControl CANbus friendly_name: OneControl CAN
# ESP32 GPIOs to the transceiver. Any free non-strapping pins work; these match # ESP32 GPIOs to the transceiver. Any free non-strapping pins work; these match
# a common SN65HVD230 wiring. tx_pin -> transceiver D/CTX, rx_pin <- R/CRX. # a common SN65HVD230 wiring. tx_pin -> transceiver D/CTX, rx_pin <- R/CRX.
tx_pin: GPIO5 tx_pin: GPIO5
@@ -20,6 +22,10 @@ substitutions:
esphome: esphome:
name: ${name} name: ${name}
friendly_name: ${friendly_name} friendly_name: ${friendly_name}
# Command authentication (REMOTE_CONTROL session, key 0xB16B00B5). Provides
# ids_can_auth::remote_control_response_bytes() to the command path below.
includes:
- ids_can_auth.h
esp32: esp32:
board: esp32dev # classic WROOM-32 board: esp32dev # classic WROOM-32
@@ -37,7 +43,7 @@ captive_portal:
logger: logger:
level: DEBUG # DEBUG so the on_frame ESP_LOGD frame dump is visible level: DEBUG # DEBUG so the on_frame ESP_LOGD frame dump is visible
# while RE'ing. Drop to INFO once the map is solid. # during bring-up. Drop to INFO once the map is solid.
api: api:
encryption: encryption:
@@ -48,71 +54,155 @@ ota:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# CAN bus: ESP32 native TWAI controller + SN65HVD230 transceiver # CAN bus: ESP32 native TWAI controller + SN65HVD230 transceiver
#
# NOTE: read broadcasts are 11-bit *standard* frames; the command authentication
# challenge is a 29-bit *extended* frame. The node must receive both. Confirm the
# esp32_can trigger accepts both frame types (a single catch-all with
# can_id_mask 0); if your ESPHome build filters by frame type, add a second
# on_frame for standard IDs.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
canbus: canbus:
- platform: esp32_can - platform: esp32_can
id: rvc_bus id: can_bus
tx_pin: ${tx_pin} tx_pin: ${tx_pin}
rx_pin: ${rx_pin} rx_pin: ${rx_pin}
bit_rate: 250kbps # RV-C is always 250k bit_rate: 250kbps # IDS-CAN is 250k
can_id: 0 # our own TX id (only matters when we send) can_id: 0 # our own TX id (only matters when we send)
use_extended_id: true # RV-C uses 29-bit IDs use_extended_id: true # commands use 29-bit IDs
on_frame: on_frame:
# Catch-all: can_id_mask 0 = accept every frame, then dispatch by DGN in
# the lambda (RV-C buries the source address in the id's low byte, so a
# single decoder is cleaner than per-DGN hardware filters).
- can_id: 0 - can_id: 0
can_id_mask: 0 can_id_mask: 0 # accept every frame, dispatch in the lambda
use_extended_id: true use_extended_id: true
then: then:
- lambda: |- - lambda: |-
// `can_id` and `x` (data bytes) are provided by the trigger. // `can_id` and `x` (data bytes) are provided by the trigger.
// (If your ESPHome is too old to expose `can_id` here, switch to uint32_t id = can_id;
// per-DGN can_id/can_id_mask filters instead.)
uint32_t id = can_id; // ---- command authentication: page-42 challenge reply ----
uint8_t prio = (id >> 26) & 0x7; // After send_load_command sends the page-42 request, the target
uint32_t dgn = (id >> 8) & 0x1FFFF; // module returns a fresh 4-byte challenge on the 29-bit ext ID
uint8_t sa = id & 0xFF; // (node<<18)|0x10000|0x0142, payload 00 04 CC CC CC CC. Compute the
// REMOTE_CONTROL response, send it on page 43, then send the opcode
// x3. This is the only place we transmit; it acts solely on
// id(g_cmd_node), so no other node is ever touched.
if (id(g_cmd_pending) && x.size() >= 6) {
uint32_t chal_id = ((uint32_t) id(g_cmd_node) << 18) | 0x10000u | 0x0142u;
if (id == chal_id) {
uint8_t resp[4];
ids_can_auth::remote_control_response_bytes(&x[2], resp);
// page-43 response: ext ID 0x0004<node>43, payload 00 04 RR RR RR RR
uint32_t resp_id = 0x00040043u | ((uint32_t) id(g_cmd_node) << 8);
std::vector<uint8_t> rframe = {0x00, 0x04, resp[0], resp[1], resp[2], resp[3]};
id(can_bus).send_data(resp_id, true, rframe);
// opcode x3: ext ID 0x0006<node><op>, DLC 0 (op 01=on, 00=off)
uint32_t op_id = 0x00060000u | ((uint32_t) id(g_cmd_node) << 8) | (uint32_t) id(g_cmd_op);
std::vector<uint8_t> opframe; // empty -> DLC 0
for (int i = 0; i < 3; i++) id(can_bus).send_data(op_id, true, opframe);
id(g_cmd_pending) = false;
ESP_LOGI("idscan", "node %02X: challenge %02X%02X%02X%02X -> response %02X%02X%02X%02X, opcode %02X x3",
id(g_cmd_node), x[2], x[3], x[4], x[5],
resp[0], resp[1], resp[2], resp[3], id(g_cmd_op));
return;
}
}
// ---- read broadcasts: 11-bit standard frames, id = (page<<8)|node ----
uint8_t page = (id >> 8) & 0xFF;
uint8_t node = id & 0xFF;
// Frame dump — comment out once the map is trustworthy. // Frame dump — comment out once the map is trustworthy.
ESP_LOGD("rvc", "DGN=%05X SA=%02X len=%u %02X %02X %02X %02X %02X %02X %02X %02X", ESP_LOGD("idscan", "page=%u node=%02X len=%u %02X %02X %02X %02X %02X %02X",
dgn, sa, x.size(), page, node, x.size(),
x.size()>0?x[0]:0, x.size()>1?x[1]:0, x.size()>2?x[2]:0, x.size()>3?x[3]:0, x.size()>0?x[0]:0, x.size()>1?x[1]:0, x.size()>2?x[2]:0,
x.size()>4?x[4]:0, x.size()>5?x[5]:0, x.size()>6?x[6]:0, x.size()>7?x[7]:0); x.size()>3?x[3]:0, x.size()>4?x[4]:0, x.size()>5?x[5]:0);
// ================= DISPATCH — ALL VALUES UNVERIFIED ================= // page 3 = live value. Layout depends on device class (README):
// DGNs + byte math are standard-RV-C hypotheses. Confirm each // tanks (type 0x0A): x[0] = level in percent (0x42 = 66%).
// against the sniff log (../README.md "DGN map") before trusting. // switched loads (type 0x1E): x[0] bit0 = on/off.
if (page == 3 && x.size() >= 1) {
// ---- TANK_STATUS (std 0x1FFB7) ---- switch (node) {
if (dgn == 0x1FFB7 && x.size() >= 2) { // tanks (node addresses from the README node map for this rig)
uint8_t inst = x[0]; case 0xE2: id(fresh_tank).publish_state(x[0]); break;
float pct = x[1] * 100.0f / 255.0f; // TODO verify level math case 0xFE: id(black_tank).publish_state(x[0]); break;
switch (inst) { // TODO verify instances case 0x27: id(grey_tank_1).publish_state(x[0]); break;
case 0: id(fresh_tank).publish_state(pct); break; case 0x7D: id(grey_tank_2).publish_state(x[0]); break;
case 1: id(black_tank).publish_state(pct); break; // switched loads
case 2: id(grey_tank_1).publish_state(pct); break; case 0xF8: id(interior_lights).publish_state(x[0] & 0x01); break;
case 3: id(grey_tank_2).publish_state(pct); break; case 0x2A: id(exterior_lights).publish_state(x[0] & 0x01); break;
// TODO: water pump (61), water heater (95) once exposed below.
} }
} }
// ---- DC_SOURCE_STATUS_1 (std 0x1FFFD) battery ---- // Battery voltage rides 29-bit telemetry frames (src 7D/AE, page
if (dgn == 0x1FFFD && x.size() >= 4) { // 0x11), payload 00 2B 0D 4x ..; b2..b3 (BE) / 256 = volts.
uint16_t raw = x[2] | (x[3] << 8); // 0.05 V/bit, LE (verify) // TODO: match the exact source frame and publish battery_voltage.
id(battery_voltage).publish_state(raw * 0.05f);
}
// ---- DC_DIMMER_STATUS_3 (std 0x1FEDA) light/switch state ---- # ---------------------------------------------------------------------------
if (dgn == 0x1FEDA && x.size() >= 3) { # Command path: authenticated "set switched load"
uint8_t inst = x[0]; #
bool on = x[2] > 0; // byte2 = brightness # State shared between send_load_command (sends the request + retries) and the
switch (inst) { // TODO verify instances # on_frame challenge handler (computes the response + sends the opcode). A
// case ?: id(interior_lights).publish_state(on); break; # command is queued by setting g_cmd_* and g_cmd_pending; the handler clears
// case ?: id(exterior_lights).publish_state(on); break; # g_cmd_pending once the exchange completes, which also stops the retry loop.
// case ?: id(water_pump).publish_state(on); break; # ---------------------------------------------------------------------------
// case ?: id(gas_water_heater).publish_state(on); break; globals:
} - id: g_cmd_node
} type: uint8_t
initial_value: '0'
- id: g_cmd_op
type: uint8_t
initial_value: '0'
- id: g_cmd_pending
type: bool
initial_value: 'false'
script:
- id: send_load_command
# mode: restart -> a new press supersedes an in-flight exchange.
mode: restart
parameters:
node: int
op: int
then:
- lambda: |-
// SAFETY: lights only. Refuse any node that isn't an allowlisted
// switched load (2A ext lights, F8 int lights). This keeps the command
// path off movement nodes (slides/jacks/awning).
if (node != 0x2A && node != 0xF8) {
ESP_LOGW("idscan", "refusing command to non-allowlisted node %02X (lights only)", node);
id(g_cmd_pending) = false;
return;
}
id(g_cmd_node) = (uint8_t) node;
id(g_cmd_op) = (uint8_t) op;
id(g_cmd_pending) = true;
# Send the page-42 request, wait ~100 ms for the challenge; retry up to 3x.
# The on_frame handler clears g_cmd_pending the moment it answers, so a
# successful exchange short-circuits the remaining iterations.
- repeat:
count: 3
then:
- if:
condition:
lambda: 'return id(g_cmd_pending);'
then:
- lambda: |-
// page-42 request: ext ID 0x0004<node>42, payload 00 04
uint32_t req_id = 0x00040042u | ((uint32_t) id(g_cmd_node) << 8);
std::vector<uint8_t> req = {0x00, 0x04};
id(can_bus).send_data(req_id, true, req);
- delay: 100ms
- if:
condition:
lambda: 'return id(g_cmd_pending);'
then:
- lambda: |-
ESP_LOGW("idscan", "no page-42 challenge from node %02X after 3 tries; command dropped",
id(g_cmd_node));
id(g_cmd_pending) = false;
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Read-back sensors (published by the dispatcher above) # Read-back sensors (published by the dispatcher above)
@@ -147,29 +237,35 @@ sensor:
accuracy_decimals: 0 accuracy_decimals: 0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Switches — state comes from DC_DIMMER_STATUS_3 above; the command path is a # Switches — authenticated command path (see send_load_command above).
# PLACEHOLDER until DC_DIMMER_COMMAND_2 (std 0x1FEDB) instance + payload are RE'd. # Each turn_on/off queues send_load_command for the load's node; the on_frame
# can_id below = prio(6)<<26 | DGN 0x1FEDB <<8 | source_addr. 0x18FEDB80 is a # handler completes the page-42/43 challenge/response and sends the opcode.
# guess (prio 6, SA 0x80) — DO NOT trust until verified by sniffing a real #
# command frame from the panel. # SAFETY: lights only. Only the allowlisted switched-load nodes (2A = exterior
# lights, F8 = interior lights) may be wired here. Do NOT add movement nodes
# (slides/jacks/awning, type 0x21) until a careful attended first test.
# optimistic:true for now — the page-3 (b0 bit0) read-back above already publishes
# true module state, so these can switch to optimistic:false once verified.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
switch: switch:
- platform: template
name: "Exterior Lights"
id: exterior_lights
optimistic: true
turn_on_action:
- script.execute: { id: send_load_command, node: 0x2A, op: 1 }
turn_off_action:
- script.execute: { id: send_load_command, node: 0x2A, op: 0 }
- platform: template - platform: template
name: "Interior Lights" name: "Interior Lights"
id: interior_lights id: interior_lights
optimistic: true # flip to false once read-back is wired optimistic: true
turn_on_action: turn_on_action:
- canbus.send: - script.execute: { id: send_load_command, node: 0xF8, op: 1 }
canbus_id: rvc_bus
use_extended_id: true
can_id: 0x18FEDB80 # TODO compute from real DGN+SA
data: [0x00, 0x00, 0xC8, 0x01] # TODO [instance, group, level, cmd]
turn_off_action: turn_off_action:
- canbus.send: - script.execute: { id: send_load_command, node: 0xF8, op: 0 }
canbus_id: rvc_bus
use_extended_id: true
can_id: 0x18FEDB80 # TODO
data: [0x00, 0x00, 0x00, 0x03] # TODO
# Duplicate the block above for: exterior_lights, water_pump, gas_water_heater # water pump (61) / water heater (95) are also type-0x1E switched loads and use
# once their command instances are known. # the same authentication — add them here once you want them in HA. Movement
# nodes stay off this list until tested.
+18 -14
View File
@@ -1,27 +1,31 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""IDS-CAN command-auth cipher — the gate on the CAN write path. """IDS-CAN command authentication — challenge/response for the CAN command path.
SOLVED 2026-06-12. The challenge->response transform is a TEA/XTEA-family The OneControl modules require a per-command challenge/response before they act
32-round Feistel keyed by a per-session 32-bit key ("Cypher"). on an opcode. This computes the response so the Home Assistant integration can
issue commands the same way the OEM app and remote do.
The transform is a TEA/XTEA-family 32-round Feistel keyed by a per-session 32-bit
value the protocol calls the "Cypher".
`response = encrypt(challenge, REMOTE_CONTROL)`, both 32-bit `response = encrypt(challenge, REMOTE_CONTROL)`, both 32-bit
**big-endian** (the 4 payload bytes after the "00 04" prefix in the page-42 **big-endian** (the 4 payload bytes after the "00 04" prefix in the page-42
challenge / page-43 response frames). Verified 51/51 against the captured pairs challenge / page-43 response frames). Validated 51/51 against the captured pairs
in sniff/2A-auth-pairs.txt + sniff/auth-pairs-multinode-2026-06-11.txt, across in captures/2A-auth-pairs.txt + captures/auth-pairs-multinode-2026-06-11.txt, across
nodes 2A/61/75/F8 — one global session key, not per-node. nodes 2A/61/75/F8 — one global session key, shared by all nodes.
The session "Cypher" is the only key; the round constants are baked in. Five The session "Cypher" is the only key; the round constants are baked in. The
sessions exist (the joke hex values confirm they're the genuine keys): protocol defines five sessions (the memorable hex values are its own constants):
MANUFACTURING 0xB16BA115 DIAGNOSTIC 0xBABECAFE REPROGRAMMING 0xDEADBEEF MANUFACTURING 0xB16BA115 DIAGNOSTIC 0xBABECAFE REPROGRAMMING 0xDEADBEEF
REMOTE_CONTROL 0xB16B00B5 DAQ 0x0B00B135 REMOTE_CONTROL 0xB16B00B5 DAQ 0x0B00B135
REMOTE_CONTROL is the one that gates on/off/move commands. REMOTE_CONTROL is the session for on/off/move commands.
Unlock sequence on the bus (controller 01 <-> module, 29-bit extended frames): Exchange on the bus (controller 01 <-> module, 29-bit extended frames):
01->node page42 DLC2 "00 04" # arm 01->node page42 DLC2 "00 04" # request a challenge
node->01 page42 DLC6 "00 04 <CC CC CC CC>" # module's challenge node->01 page42 DLC6 "00 04 <CC CC CC CC>" # module's challenge
01->node page43 DLC6 "00 04 <RR RR RR RR>" # RR = remote_control_response(CC) 01->node page43 DLC6 "00 04 <RR RR RR RR>" # RR = remote_control_response(CC)
node->01 page43 DLC2 "00 04" # ack node->01 page43 DLC2 "00 04" # ack
01->node 0x0006<node><op> x3 # opcode now honored (01=on,00=off,02=retract) 01->node 0x0006<node><op> x3 # opcode (01=on,00=off,02=retract)
""" """
from __future__ import annotations from __future__ import annotations
@@ -74,8 +78,8 @@ def response_bytes(challenge: bytes, session: str = "REMOTE_CONTROL") -> bytes:
def _selftest() -> int: def _selftest() -> int:
import os import os
here = os.path.dirname(os.path.abspath(__file__)) here = os.path.dirname(os.path.abspath(__file__))
files = [os.path.join(here, "sniff", "2A-auth-pairs.txt"), files = [os.path.join(here, "captures", "2A-auth-pairs.txt"),
os.path.join(here, "sniff", "auth-pairs-multinode-2026-06-11.txt")] os.path.join(here, "captures", "auth-pairs-multinode-2026-06-11.txt")]
total = bad = 0 total = bad = 0
for path in files: for path in files:
if not os.path.exists(path): if not os.path.exists(path):
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Operate an IDS-CAN switched load over socketcan, with the auth exchange.
Acts as controller node 01: request a challenge -> read the module's fresh
challenge -> return remote_control_response(challenge) -> send the opcode x3.
Pure stdlib (raw AF_CAN socket); needs can0 already up (slcand).
Usage: idscan_cmd.py <node_hex> <on|off> e.g. idscan_cmd.py F8 on
"""
import socket
import struct
import sys
import time
from ids_can_auth import remote_control_response
CAN_EFF_FLAG = 0x80000000
FMT = "=IB3x8s" # can_id, can_dlc, pad/res, data[8]
ARM_PAGE, RESP_PAGE, CHAL_PAGE = 0x42, 0x43, 0x42
def eff(i):
return i | CAN_EFF_FLAG
def pack(can_id, data=b""):
return struct.pack(FMT, eff(can_id), len(data), data.ljust(8, b"\x00"))
def unpack(frame):
cid, dlc, data = struct.unpack(FMT, frame)
is_eff = bool(cid & CAN_EFF_FLAG)
return is_eff, cid & 0x1FFFFFFF, dlc, data[:dlc]
def status_id(node): # 11-bit page-3 live-value broadcast
return (3 << 8) | node
def read_status(s, node, timeout=2.0):
want = status_id(node)
end = time.time() + timeout
while time.time() < end:
s.settimeout(max(0.01, end - time.time()))
try:
is_eff, cid, dlc, data = unpack(s.recv(16))
except socket.timeout:
break
if not is_eff and cid == want:
return data
return None
def actuate(node, op):
arm_id = (0x01 << 18) | (0 << 16) | (node << 8) | ARM_PAGE # 01->node p42
resp_id = (0x01 << 18) | (0 << 16) | (node << 8) | RESP_PAGE # 01->node p43
chal_id = (node << 18) | (1 << 16) | (0x01 << 8) | CHAL_PAGE # node->01 p42
opcode_id = (0x0006 << 16) | (node << 8) | op # 0006<node><op>
s = socket.socket(socket.PF_CAN, socket.SOCK_RAW, socket.CAN_RAW)
s.bind(("can0",))
before = read_status(s, node, 1.5)
print(f" before: page3 = {before.hex(' ') if before else '(none)'}")
s.send(pack(arm_id, b"\x00\x04")) # arm
# await the module's fresh challenge
chal = None
s.settimeout(0.3)
end = time.time() + 0.3
while time.time() < end:
try:
is_eff, cid, dlc, data = unpack(s.recv(16))
except socket.timeout:
break
if is_eff and cid == chal_id and dlc == 6 and data[:2] == b"\x00\x04":
chal = data[2:6]
break
if chal is None:
print(" !! no challenge from module — aborting (load not actuated)")
return False
ch_int = int.from_bytes(chal, "big")
rr = remote_control_response(ch_int)
print(f" challenge {ch_int:08X} -> response {rr:08X}")
s.send(pack(resp_id, b"\x00\x04" + rr.to_bytes(4, "big"))) # response
time.sleep(0.004)
for _ in range(3): # opcode x3
s.send(pack(opcode_id))
time.sleep(0.006)
time.sleep(0.3)
after = read_status(s, node, 1.5)
print(f" after: page3 = {after.hex(' ') if after else '(none)'}")
s.close()
return True
if __name__ == "__main__":
if len(sys.argv) != 3 or sys.argv[2] not in ("on", "off"):
sys.exit(__doc__)
node = int(sys.argv[1], 16)
op = 0x01 if sys.argv[2] == "on" else 0x00
print(f"node {node:02X} -> {sys.argv[2]}")
actuate(node, op)