Close propane valve, force a water-heater lockout, capture + diff against healthy baseline. Prime suspects: node 95 b1 (always FF) or node AE page-3 (always 00). Whichever flips becomes the DSI binary_sensor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
13 KiB
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. Replay does NOT work. 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 is not the BLE TEA cipher (tea(612643285, 0x21CA0C06) = 0x87AC5CBD ≠
the observed 0xCC18366B) — different key/algorithm. So Lippert put a second,
separate auth on the CAN write path. Cracking it = its own reversing project.
Dataset for the crack: sniff/2A-auth-pairs.txt — 42 challenge→response
pairs from node 2A, captured 2026-06-11 (app on/off ×~20). Analysis so far:
response = f(challenge) is fully deterministic (0 inconsistent responses
across the set ⇒ no counter/timestamp/session state — pure 32→32-bit block
transform), but not a constant XOR or ADD (42/42 distinct ⇒ a real cipher,
likely TEA/XTEA-family w/ unknown constants). Stateless+deterministic = solvable
offline: lift the constants from the X180T or Lippert-app firmware and verify
against this file. Collect more pairs (other nodes) anytime to widen the attack.
Movement nodes (awning
75) showed the page42/43/45 frames as commander→node only, with no nonce reply — possibly a weaker/no gate. NOT spoof-tested (don't actuate a motor unattended). Worth a careful look later.
Bottom line: READ is fully open and is the deliverable here (all sensors + states from broadcasts, zero auth). WRITE stays on the BLE integration for now (laggy but works) until/unless the CAN challenge-response is cracked.
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