The CAN write gate (page-42/43 challenge/response) is a 32-round TEA/XTEA-family Feistel keyed by a per-session 32-bit key; REMOTE_CONTROL = 0xB16B00B5. Verified 51/51 against captured challenge/response pairs across nodes 2A/61/75/F8 (one global key, not per-node), so the CAN path can now actuate, not just sense. - ids_can_auth.py Python reference + self-test (51/51) - esphome/ids_can_auth.h C++ port for the ESP32 node (host-tested 8/8) - sniff/analyze_auth.py structural analysis (rules out affine; confirms keyed cipher) - sniff/auth-pairs-multinode-2026-06-11.txt +9 pairs across 4 nodes - README document the cipher, session keys, unlock sequence Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
297 lines
15 KiB
Markdown
297 lines
15 KiB
Markdown
# OneControl via CANbus (IDS-CAN)
|
||
|
||
Direct **CANbus** integration for the Lippert OneControl (UNITY **X180T**)
|
||
system — the successor to the BLE-gateway approach in this repo's `src/` +
|
||
`custom_components/`. The BLE path works but is laggy and brittle (connection-
|
||
based GATT, ~30 s idle timeout, per-reconnect TEA auth, single shared Pi radio,
|
||
fragile SMP pairing). The OneControl panel is just a gateway bolted onto a CAN
|
||
backbone; tapping the bus gives **no bond/auth/timeout, instant latency, and
|
||
visibility into everything on the network** (incl. signals the BLE protocol never
|
||
exposed, like the DSI fault).
|
||
|
||
**Status:** first sniff done 2026-06-11 — **the bus is NOT RV-C.** It runs
|
||
Lippert's proprietary **IDS-CAN**: 250 kbit/s, but **11-bit standard IDs**
|
||
(plus a handful of 29-bit frames for telemetry/sync). The protocol structure
|
||
and device map below are from live captures in `sniff/*.log`.
|
||
|
||
---
|
||
|
||
## IDS-CAN findings (2026-06-11, captures: `sniff/baseline-*.log`, `sniff/toggletest-*.log`)
|
||
|
||
### Frame structure
|
||
|
||
11-bit ID = **`(page << 8) | node_addr`**. Every node broadcasts its pages at
|
||
**1 Hz** (plus immediate rebroadcast on change). Pages seen:
|
||
|
||
| Page | Content |
|
||
|------|---------|
|
||
| 0 | Node status: `b0` flags (bit2 = "state changing" transient), b1.. static (`14 00 00 00 1C 38 DF` common) |
|
||
| 1 | All-zero (4 bytes) for ordinary nodes |
|
||
| 2 | Identity: `00 A3 FE <type> 00 <b5> <b6> <b7>` — **`<type>` = device class** |
|
||
| 3 | **The live value** — layout depends on device class (see below) |
|
||
| 6 | Single byte, only on special nodes `01`/`FC`/`FE` |
|
||
| 7 | Only `7FE`: byte3 = 1 Hz incrementing counter (uptime/heartbeat) |
|
||
|
||
### Device classes (page-2 `type` byte)
|
||
|
||
- **`0x0A` = tank.** Page 3 = **1 byte, level in percent** (0x42=66%, 0x21=33%).
|
||
- **`0x1E` = switched load** (lights/pump/heater). Page 3 = 6 bytes:
|
||
`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
|
||
0x0001→0x028A over ~1 s).
|
||
- **`0x21` = H-bridge/movement** (slide/awning/jacks). Page 3 = 6 bytes:
|
||
`b0` = `0xC0` idle, **`0xC2` = extending (out), `0xC3` = retracting (in)**
|
||
(confirmed twice: wall-jog order + app commands 2026-06-11); `b2..b3` (BE) =
|
||
**live motor current** (~0x500–0x620 while running, settles to 0 at stop).
|
||
- **`0x27`, `0x2B`** = unknown (nodes `AE`, `FC`).
|
||
|
||
### Node map (this rig — Catalina 263BHSCK, panel 28475)
|
||
|
||
| Node | Device | Evidence |
|
||
|------|--------|----------|
|
||
| `01` | controller (X180T?) | special pages; `301` status bit flickers at idle |
|
||
| `27` | **grey tank 1** | type 0x0A, page3 = 0x21 = 33% ✓ |
|
||
| `7D` | **grey tank 2** | type 0x0A; stayed 66% when black was drained |
|
||
| `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% ✓ |
|
||
| `2A` | **exterior lights** | type 0x1E; toggle test t≈69–76 s |
|
||
| `F8` | **interior lights** | type 0x1E; toggle test t≈51–61 s |
|
||
| `95` | **water heater** | type 0x1E; toggle test t≈85–94 s |
|
||
| `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?) |
|
||
| `75` | **awning** | type 0x21; jog test 2026-06-11 — b0 C0→C3 (in?) →C0→C2 (out?) with motor current on b2-3 |
|
||
| `6A`, `7F`, `9C` | slide / jacks / movement class | type 0x21, untested |
|
||
| `AE` | unknown (type 0x27, page3=0x00) | LP gas sensor? |
|
||
| `FC` | special node (type 0x2B, page 6) | panel/BLE gateway? |
|
||
|
||
### 29-bit extended frames (directed messages)
|
||
|
||
Extended ID = **`(src_node << 18) | flags? | (dest_node << 8) | page`**
|
||
(verified: pump event `0185FC42` = src `61` → dest `FC`; awning `01D5FC42` =
|
||
src `75` → dest `FC`; replies `03F0<node>43` = src `FC` → dest node, page 43).
|
||
|
||
- `01F5FC11` (src `7D` → `FC`) / `02B90111` (src `AE` → `01`) — periodic,
|
||
payload `00 2B 0D 4x <rolling>`: `b2..b3` ≈ 0x0D46–47 → /256 = **13.27 V ⇒
|
||
battery voltage**, last byte looks like a checksum. (BLE read 13.09 V the
|
||
same day; charger float plausible.) Note the *source* being `7D`/`AE`
|
||
suggests those modules carry the battery-sense wire, not the controller.
|
||
- 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
|
||
handshake pair src-node→`FC` page 42 / `FC`→node page 43 with
|
||
random-looking bytes — not needed for sensing.
|
||
|
||
### Command path (DECODED 2026-06-11 — `sniff/app-commands-*.log`)
|
||
|
||
The command opcode is a **zero-payload (DLC 0) extended frame** `0x0006<node><op>`
|
||
(`op`: `01`=on, `00`=off/stop, `02`=movement-retract). The BLE app's taps appear
|
||
on the bus as these, ~300 ms before the page-3 state flips. BUT —
|
||
|
||
**WRITE IS AUTH-GATED — and the gate is now CRACKED (2026-06-12, see below).**
|
||
Each command is wrapped in a **rolling challenge-response** the bare opcode
|
||
won't pass:
|
||
|
||
```
|
||
01 → node page42 "00 04" # controller: "arm me a challenge"
|
||
node → 01 page42 "00 04 <CC CC CC CC>" # module: random 4-byte challenge
|
||
01 → node page43 "00 04 <RR RR RR RR>" # controller: correct response
|
||
node → 01 page43 "00 04" # module: ack
|
||
01 → node 0x0006<node><op> ×3 # the actual command (now honored)
|
||
01 → node page45 / node → 01 page45 # post-status (00, then 0E)
|
||
```
|
||
|
||
The challenge is **fresh every time** (interior lights: `F7 74 0A 20` then
|
||
`ED C9 28 1A` on two presses → different responses), so captured frames can't
|
||
be replayed. **Verified empirically:** spoofing bare `cansend can0 00062A00#`
|
||
×3 (ext lights, no handshake) — frames hit the bus (TX confirmed, self-echo
|
||
seen) but the load **did not actuate**. The module ignores an unauthenticated
|
||
opcode.
|
||
|
||
It uses a **different key** from the BLE TEA auth (`tea(612643285, 0x21CA0C06) =
|
||
0x87AC5CBD ≠` the observed `0xCC18366B`) — but, as it turns out, the **same
|
||
family**: a TEA/XTEA Feistel. Lippert put a second, separately-keyed auth on the
|
||
CAN write path.
|
||
|
||
**Dataset for the crack: `sniff/2A-auth-pairs.txt`** (42 pairs, node `2A`) +
|
||
`sniff/auth-pairs-multinode-2026-06-11.txt` (9 more across nodes `61`/`75`/`F8`
|
||
+2 on `2A`) — **51 pairs / 4 nodes**, captured 2026-06-11 (app-driven).
|
||
|
||
Structural analysis of `response = f(challenge)` (script `sniff/analyze_auth.py`):
|
||
**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
|
||
pointed at recovering the key rather than cryptanalyzing the captures — which is
|
||
exactly what happened.
|
||
|
||
#### ✅ SOLVED (2026-06-12) — `ids_can_auth.py`
|
||
|
||
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 |
|
||
| DIAGNOSTIC | `0xBABECAFE` | diagnostic tool (← likely unlocks the DSI fault path) |
|
||
| REPROGRAMMING | `0xDEADBEEF` | firmware reflash |
|
||
| **REMOTE_CONTROL** | **`0xB16B00B5`** | **on/off/move — this is the write gate** |
|
||
| DAQ | `0x0B00B135` | data acquisition |
|
||
|
||
`response = Encrypt(challenge, 0xB16B00B5)`, both 32-bit **big-endian** (the 4
|
||
payload bytes after `00 04`). **Verified 51/51** against every captured pair,
|
||
all four nodes (2A 44/44, 61 2/2, 75 3/3, F8 2/2) — REMOTE_CONTROL is unique
|
||
(every other key misses 51/51), and it's **one global key, not per-node**. So to
|
||
actuate a load: catch the module's page-42 challenge, compute the response, send
|
||
it on page-43, then send the opcode. Reference impl + self-test in
|
||
`ids_can_auth.py` (`python3 ids_can_auth.py <challenge_hex>`). No firmware dump
|
||
was needed; the 51 captures were the verification oracle.
|
||
|
||
> Movement nodes use the **same gate.** App-driven awning (`75`) commands in
|
||
> `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
|
||
auth) **and WRITE is now unlocked** — the command-auth cipher is cracked
|
||
(`ids_can_auth.py`), so the CAN path can both sense and actuate. The BLE
|
||
integration is no longer the only way to control loads; next step is wiring the
|
||
challenge-response into the ESPHome node's `switch`/`cover` actions (the bare
|
||
opcode in the command DGN now just needs the page-42/43 handshake in front of
|
||
it). Movement nodes (slides/jacks) still want a careful first actuation test.
|
||
|
||
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
|
||
uses to build its UI.
|
||
|
||
**Open read-side items:** identify node `89` (last untoggled 0x1E load) and
|
||
`6A`/`7F`/`9C` (movement — slide?), find battery SoC / the "4 green lights"
|
||
source.
|
||
|
||
### TODO: capture the DSI fault (planned 2026-06-12)
|
||
|
||
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
|
||
the propane tank valve, run the water heater on gas until it locks out (DSI
|
||
fault light on panel), capture ~20 s with the CANable, then diff against a
|
||
healthy baseline. Prime suspects (both sit at a constant "all-clear" sentinel
|
||
in current captures):
|
||
- **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.
|
||
|
||
Whichever flips → becomes a `binary_sensor` in the ESPHome node (the DSI fault
|
||
the BLE app never exposed). Reset = reopen valve, re-light.
|
||
|
||
---
|
||
|
||
## Hardware (BOM)
|
||
|
||
| Item | Notes |
|
||
|------|-------|
|
||
| **CANable 2.0** USB-CAN | RE/sniffing from xarl. candleLight/gs_usb fw → native socketcan (`can0`). |
|
||
| **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. |
|
||
| **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)
|
||
|
||
- **Controller:** UNITY **X180T**. Lippert brands it "RV-C" but the bus
|
||
actually runs **IDS-CAN** (proprietary): 250 kbit/s, 11-bit IDs — see
|
||
findings above.
|
||
- **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)
|
||
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).
|
||
- **Tank senders wire directly into the X180T** (DSI/FRESH/BLACK/GRAY/GRAY2
|
||
terminal block) → controller reads resistive senders and broadcasts levels on
|
||
CAN from its own source address (not separate tank modules).
|
||
|
||
## Physical tap (4 screws, fully reversible)
|
||
|
||
1. Pull the monitor panel (4 screws).
|
||
2. Find the **data** port — the one with the **terminating resistor** plugged in
|
||
(and/or where the controller's data harness lands). **NOT** the look-alike
|
||
2-pin **power** connector.
|
||
- ⚠️ **Multimeter check first:** data idles **~2.5 V** (recessive) across the
|
||
pins; **power reads ~12 V**. 12 V into CAN-H/L kills the SN65HVD230/CANable.
|
||
3. Unplug the **terminator**, plug your Mini-Fit Jr. pigtail into that port.
|
||
4. Land the two wires on the transceiver's CAN-H / CAN-L. The transceiver's
|
||
onboard 120 Ω re-terminates that end (keeps exactly 2 terminators: controller
|
||
+ your node). **Never** add a terminated node in the *middle* of the bus.
|
||
5. Revert = unplug, re-seat the terminator.
|
||
|
||
CAN-H vs CAN-L: can't hurt anything if swapped — bus just goes silent, flip the
|
||
two wires (or pop the Mini-Fit Jr. terminals and reorder).
|
||
|
||
---
|
||
|
||
## Sniffing workflow (do this first, before the ESP32 build)
|
||
|
||
On xarl with the CANable (see `sniff/log-can.sh`):
|
||
|
||
```sh
|
||
sudo pacman -S can-utils
|
||
sudo ip link set can0 up type can bitrate 250000
|
||
candump can0 # any traffic at 250k ⇒ confirmed RV-C
|
||
candump -ta -x can0 | tee sniff/$(date +%F)-idle.log # timestamped raw log
|
||
cansniffer -c can0 # color diff view — toggle a load, watch which bytes move
|
||
```
|
||
|
||
**Mapping method (same as the BLE RE, but easier — broadcast, no auth):**
|
||
flip ONE physical load (or watch ONE tank), see which **node + page + byte**
|
||
changes, record it in the node map above. Repeat per device.
|
||
|
||
Decode each 11-bit ID as:
|
||
```
|
||
page = (id >> 8) & 0x7 # message page
|
||
node = id & 0xFF # node address (which module)
|
||
```
|
||
|
||
> Note: the CANable 2.0 shipped with **slcan** firmware (enumerates as
|
||
> `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)
|
||
|
||
| BLE DevID | Component | Expect on CAN |
|
||
|-----------|-----------|---------------|
|
||
| 4 | water pump | DC_DIMMER/switch instance |
|
||
| 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/onecontrol-canbus.yaml` — ESP32 `esp32_can` listener (catch-all
|
||
`on_frame` → DGN dispatcher → template sensors/switches). Mirrors 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
|
||
`DEBUG` so the ESP can double as a sniffer. Fill in instances/byte-math in the
|
||
lambda as the DGN map firms up; wire the command DGN into the `switch` actions
|
||
last.
|
||
|
||
## References
|
||
|
||
- RV-C spec & DGN tables: <https://www.rv-c.com/>
|
||
- CoachProxy / coachproxyos (open RV-C decode prior art)
|
||
- `rvc2mqtt`, `rvc-monitor` (DGN→MQTT mappings to crib)
|
||
- Lippert OneControl (RV-C): <https://www.lippert.com/brands/onecontrol>
|
||
- BLE-side protocol (this repo): `../docs/PROTOCOL_FINDINGS.md`
|