OneControl Over CAN — Ditching the Bluetooth Gateway

June 2026

The Bluetooth integration worked. I could turn lights on and off, read tanks, watch the battery voltage — all from Home Assistant, no phone app. But it never felt good. The Lippert OneControl panel is a Bluetooth peripheral, and everything that makes Bluetooth Bluetooth was in the way: a connection-based GATT link with a ~30-second idle timeout, a challenge-response handshake on every reconnect, a single shared radio on the Pi that had to be babysat, and pairing that liked to fall over.

Then it clicked: the panel is just a gateway. It sits on a CAN bus with every other module in the coach — the tank controller, the light relays, the water heater — and translates a slice of that bus to Bluetooth for the app. If I tapped the bus directly, all of that connection churn evaporates. No bond, no timeout, no reconnect auth, instant latency, and I’d see everything the modules broadcast, not just the subset Lippert chose to expose over BLE.

So I did. This is the story of tapping the bus and finding out that “RV-C” was a marketing word.


”RV-C” is a lie (a useful one)

Lippert brands OneControl’s X-Series as RV-C — the open, standardized RV CANbus protocol. The spec sheet for my controller (a UNITY X180T) says RV-C. Every forum post says RV-C. RV-C is well documented: 250 kbit/s, 29-bit J1939-style IDs, published DGN (data group number) tables you can pull off the shelf from projects like CoachProxy and rvc2mqtt.

I bought a CANable 2.0 USB-CAN adapter (~$30), bridged it to a Linux can0 socket, and ran candump at 250k expecting to match frames against the RV-C DGN tables.

The IDs were 11-bit. RV-C is 29-bit. Nothing matched.

The bus runs at 250 kbit/s like RV-C, but the framing is Lippert’s own proprietary protocol — IDS-CAN. All the off-the-shelf DGN maps are dead. I was going to have to map this coach from scratch. The upside: because it’s all unauthenticated broadcast, mapping it is easy — you toggle one physical thing and watch which bytes move.

Gotcha for anyone following along: my CANable shipped with slcan firmware, so it enumerates as a serial device (/dev/ttyACM0) and needs slcand -o -s5 /dev/ttyACM0 can0 to become a real socketcan interface. A candleLight/gs_usb reflash would give you a native can0 instead. And on Arch, can-utils is in the AUR, not the repos.


The frame format

Every 11-bit ID decodes as:

page = (id >> 8) & 0x7    # which message page
node =  id       & 0xFF   # which module (node address)

Every node broadcasts its pages at 1 Hz, with an immediate rebroadcast the instant something changes. The pages that matter:

PageContent
0Node status flags
2Identity00 A3 FE <type> …, where <type> is the device class
3The live value — layout depends on the device class

Page 2’s type byte is the Rosetta Stone. It tells you what kind of device a node is, which tells you how to read its page-3 value:

  • 0x0A = tank. Page 3 is a single byte: fill level in percent. 0x42 = 66%.
  • 0x1E = switched load (lights, pump, heater). Page 3: b0 bit 0 is ON/OFF, and bytes 2–3 (big-endian) are a live current reading that soft-ramps up when you switch it on and decays when you switch it off. Watching the interior lights ramp 0x0001 → 0x028A over about a second is oddly satisfying.
  • 0x21 = H-bridge / movement (slides, awning, jacks). Page 3: b0 is 0xC0 idle, 0xC2 extending, 0xC3 retracting; bytes 2–3 are live motor current.

From there, mapping the coach was an afternoon of “operate one thing, see which node’s byte changed”:

NodeDeviceNodeDevice
27grey tank 12Aexterior lights
7Dgrey tank 2F8interior lights
FEblack tank95water heater
E2fresh tank61water pump
89furnace (read-only)75awning

The read side is completely open. No authentication, no connection, no keepalive — every sensor and every switch state is just there on the wire, broadcast once a second. For a monitoring integration, that’s the whole job done. An ESP32 listening on the bus can populate every tank, light, and voltage in Home Assistant without ever transmitting a single frame.

Writing was a different story.


The command path, and the second cipher

A command is a zero-length (DLC 0) 29-bit frame: 0x0006<node><op>, where op is 01 for on, 00 for off, 02 for retract. When you press a button in the OEM app, exactly these frames appear on the bus about 300 ms before the state updates.

I captured one, replayed it with cansend, and… nothing. The frame reached the bus (the adapter echoed it back), but the module ignored it.

Because each command is preceded by a challenge-response authentication exchange:

01 → node   page 42   "00 04"                  # controller asks for a challenge
node → 01   page 42   "00 04 <CC CC CC CC>"    # module returns a 4-byte challenge
01 → node   page 43   "00 04 <RR RR RR RR>"    # controller returns the response
node → 01   page 43   "00 04"                  # module acknowledges
01 → node   0x0006<node><op>  ×3               # the command, now acted on

The challenge is fresh on every single press — the interior lights handed me F7 74 0A 20 on one press and ED C9 28 1A on the next — so a captured exchange is worthless for replay. I had to actually compute the response.

I’d already been here once. The Bluetooth side uses a modified 32-round TEA (Tiny Encryption Algorithm) for its unlock handshake, keyed by a constant I pulled out of the decompiled app. My first guess was that the CAN side reused it. It didn’t — the BLE key produced the wrong answer. But the structure of the response (full byte diffusion, balanced bits, nonlinear, not affine over the challenge) screamed the same family: a keyed TEA/XTEA Feistel.

What I was missing was the key. Digging back through the protocol constants, IDS-CAN defines five named session keys — and the memorable hex is the protocol’s own:

SessionKeyPurpose
MANUFACTURING0xB16BA115factory features
DIAGNOSTIC0xBABECAFEdiagnostic tooling
REPROGRAMMING0xDEADBEEFfirmware reflash
REMOTE_CONTROL0xB16B00B5on / off / move
DAQ0x0B00B135data acquisition

response = TEA_encrypt(challenge, key), both 32-bit big-endian, 32 rounds, delta 0x9E3779B9. I had captured 51 real challenge/response pairs across four different nodes from watching the app drive commands. REMOTE_CONTROL (0xB16B00B5) is the unique key that reproduces all 51 — the other four session keys miss every one. And it’s a single global key: the same value authenticates every node on the bus.

So Lippert bolts a second, independently-keyed authentication onto the CAN command path, distinct from the Bluetooth one but cut from the same cloth. Two ciphers, same algorithm, different keys — belt and suspenders.

With the key in hand: read the module’s page-42 challenge, compute the page-43 response, send the opcode. Confirmed by live actuation — I ran the interior lights on → off → on, each cycle answering a distinct fresh challenge, with the page-3 broadcast reading back the new state each time. The command path works.


The fault the app couldn’t see

The panel has a physical “DSI fault” LED — direct spark ignition, the propane igniter for the water heater. The entire Bluetooth protocol never exposes it; it’s a hardware-level signal, invisible to the app.

On the bus it’s right there. I forced a real lockout (closed the propane valve and let the heater try to light until it gave up) and diffed against a healthy baseline:

  • Water-heater DSI fault = water-heater node’s page-3 b0 bit 5. Healthy reads 0x80/0x81; during lockout it read 0xA0.
  • Bus-wide “a fault exists” = every node’s page-0 b0 bit 0 flips. Any module on the bus will tell you something is wrong somewhere.

Both are now binary_sensors in Home Assistant — a diagnostic the factory app can’t give me, surfaced simply because I was reading the whole bus instead of a Bluetooth translation of part of it.


The awning is “hold-to-run”

The awning was the one moving part I actually wanted to automate — specifically, auto-retract, so a “close awning” button in HA does the whole job.

One authenticated opcode runs the motor for about a second and stops. It’s a “keep the button held” signal, not a latch. The OEM app sustains motion by streaming after a single auth: it fires the opcode every ~110 ms plus a keepalive every ~510 ms, with no re-authentication during the run — the one handshake opens a motion session that stays open as long as you keep streaming. Stop streaming, the motor stops. That’s the safety interlock, and it’s a good one.

There’s no position sensor, but the motor current on page 3 tells the story: it runs around a low value, tapers as the awning closes, then ramps sharply to a plateau when it hits the fully-closed hard stop. My ESPHome node streams the retract, watches current at 20 Hz, and cuts the moment it sees the stall ramp — then marks the cover closed. Backstops all around: a 70-second timeout, a motion-lost detector, and the fact that if the node stops streaming for any reason, the motor stops on its own.

Extend stays a single manual jog — there’s no safe “fully open” end-stop to detect, so I don’t auto-run it into the sky unattended.


Where it landed

The permanent build mirrors my other campsite BLE work (the gazebo fans): an ESP32 with an SN65HVD230 transceiver running ESPHome’s native esp32_can peripheral, tapped into the monitor panel’s spare CAN port. (The tap is four screws and fully reversible — pull the terminator plug, land CAN-H/CAN-L on the transceiver whose onboard 120 Ω re-terminates that end. The only real hazard is the near-identical 2-pin power connector right next to it; a multimeter says data idles ~2.5 V and power reads ~12 V, and 12 V into the transceiver kills it instantly.)

The ESP32 presents the tanks, lights, switches, and awning as native Home Assistant entities, with the challenge-response folded into the switch/light/cover actions. The Bluetooth integration is retired for control — the CAN path both senses and operates the coach, with none of the connection churn that started this whole detour.

Read is free. Write costs one TEA computation. And “RV-C” was IDS-CAN all along.

Wesley Ray · blog · git · resume