Files
lippert-onecontrol/canbus/README.md
T
wesandClaude Fable 5 840cfaf5fc canbus: solve IDS-CAN command-auth cipher; add reference implementations
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>
2026-06-12 00:22:09 -04:00

15 KiB
Raw Blame History

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 (~0x5000x620 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≈6976 s
F8 interior lights type 0x1E; toggle test t≈5161 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) ✓
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 7DFC) / 02B90111 (src AE01) — periodic, 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 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 55AA 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):

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