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>
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:b0bit0 = 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=0xC0idle,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 (nodesAE,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(src7D→FC) /02B90111(srcAE→01) — periodic, payload00 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 being7D/AEsuggests those modules carry the battery-sense wire, not the controller.- On every state change: a burst of
xxxxFC02IDs (every node → destFC) flip a55↔AAmarker (state-change announce/sync flood), plus a per-event handshake pair src-node→FCpage 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 insniff/app-commands-*.logshow the full nonce handshake (node→01 page42 challenge01D50142+ 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-3b1— always0xFF; expect it to drop/clear a bit on fault. - node
AE(type 0x27, ?LP-gas/diagnostics) page-3 — always0x00; 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)
- Pull the monitor panel (4 screws).
- 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.
- Unplug the terminator, plug your Mini-Fit Jr. pigtail into that port.
- 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.
- 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) thensudo ip link set can0 up.log-can.sh's plainip link ... type can bitratepath 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