The Gatus webhook delivered to a channel nobody reads; the secret on the Pi now holds the confirmed server-alerts webhook (URL stays out of the repo as before). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
OneControl CAN integration (Lippert IDS-CAN)
A local Home Assistant integration for my own RV's Lippert OneControl
(UNITY X180T) system, talking to it directly over its CAN network
instead of through the Bluetooth gateway. The Bluetooth path in this repo's
src/ + custom_components/ works but is laggy and brittle (connection-based
GATT, ~30 s idle timeout, a per-reconnect handshake, a single shared Pi radio,
fragile pairing). The OneControl panel is just a gateway sitting on a CAN
backbone, so connecting to the bus directly gives instant latency, no
connection/timeout churn, and visibility into every signal the modules
broadcast — including ones the Bluetooth API never surfaced, like the
water-heater DSI fault.
This file documents the on-wire message format so the ESP32 node can present the
coach's tanks, lights, switches, and awning as native HA entities. Everything
below comes from live bus captures of my own coach in captures/*.log.
Status: Despite Lippert's "RV-C" branding, the bus is not RV-C. It runs Lippert's own IDS-CAN: 250 kbit/s, 11-bit standard IDs (plus a handful of 29-bit frames for telemetry and directed messages). The read path is fully mapped, and the command path is implemented and confirmed by live actuation (2026-06-12) — see below.
Protocol notes (captures: captures/baseline-*.log, captures/toggletest-*.log)
Frame structure
11-bit ID = (page << 8) | node_addr. Every node broadcasts its pages at
1 Hz (plus an immediate rebroadcast on change). Pages observed:
| 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; operated live 2026-06-12 ✓ |
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) | (dir << 16) | (dest_node << 8) | page,
where dir = 0 for a 01→node message and 1 for a node→01 message
(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. (Bluetooth read 13.09 V the same day; charger float plausible.) 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 (a state-change announce/sync broadcast), plus a per-event handshake pair (src-node→FCpage 42 /FC→node page 43) — not needed for sensing.
Command messages (captures: captures/app-commands-*.log)
A command is a zero-payload (DLC 0) 29-bit frame 0x0006<node><op>
(op: 01=on, 00=off/stop, 02=movement-retract). The app's button presses
appear on the bus as exactly these, ~300 ms before the page-3 state updates.
Each command is preceded by a short challenge-response authentication exchange — the module won't act on a bare opcode:
01 → node page42 "00 04" # controller requests a challenge
node → 01 page42 "00 04 <CC CC CC CC>" # module returns a 4-byte challenge
01 → node page43 "00 04 <RR RR RR RR>" # controller returns the matching response
node → 01 page43 "00 04" # module acknowledges
01 → node 0x0006<node><op> ×3 # command (now acted on)
01 → node page45 / node → 01 page45 # post-status (00, then 0E)
The challenge is fresh on every press (interior lights returned F7 74 0A 20
then ED C9 28 1A on two consecutive presses → different responses), so a
previously captured exchange can't be re-used. Confirmed: re-sending a captured
opcode on its own — cansend can0 00062A00# ×3 with no live exchange — reaches
the bus (TX echoed back) but the module ignores it. The integration therefore
performs the same handshake the OEM app does.
The authentication uses a different key from the Bluetooth side
(tea(612643285, 0x21CA0C06) = 0x87AC5CBD ≠ the observed 0xCC18366B) but the
same algorithm family — a 32-round TEA/XTEA transform. Lippert applies a
second, independently-keyed authentication on the CAN command path.
Reference dataset: captures/2A-auth-pairs.txt (42 challenge/response pairs,
node 2A) + captures/auth-pairs-multinode-2026-06-11.txt (9 more across nodes
61/75/F8, +2 on 2A) — 51 pairs across 4 nodes, captured 2026-06-11
from app-driven commands. captures/analyze_auth.py characterizes
response = f(challenge): a keyed nonlinear transform (not GF(2)-affine — the 51
input-differences span the full 32-dim space yet contradict a linear fit; not
affine over Z/2³²; full byte diffusion; balanced bits), consistent with the
TEA/XTEA family.
Authentication implementation — ids_can_auth.py (2026-06-12)
response = Encrypt(challenge, session_key), both 32-bit big-endian (the 4
payload bytes after the 00 04 prefix). The transform is a 32-round TEA/XTEA
Feistel (delta 0x9E3779B9) with baked-in round constants, keyed by a
per-session 32-bit value the protocol calls the "Cypher". The protocol
defines five session keys (the memorable hex values are the protocol's own
constants):
| Session | Key | Use |
|---|---|---|
| MANUFACTURING | 0xB16BA115 |
factory features |
| DIAGNOSTIC | 0xBABECAFE |
diagnostic tool (← likely the path that carries the DSI fault) |
| REPROGRAMMING | 0xDEADBEEF |
firmware reflash |
| REMOTE_CONTROL | 0xB16B00B5 |
on/off/move — this is the command-path key |
| DAQ | 0x0B00B135 |
data acquisition |
remote_control_response(challenge) returns the value the module expects.
Validated against all 51 captured pairs across four nodes (2A 44/44, 61 2/2,
75 3/3, F8 2/2): REMOTE_CONTROL is the unique session key that matches every pair
(the other four miss all 51), and it's one global key, shared by all nodes.
So to operate a load: read the module's page-42 challenge, compute the response,
send it on page-43, then send the opcode. Reference implementation + self-test in
ids_can_auth.py (python3 ids_can_auth.py <challenge_hex> prints a response;
python3 ids_can_auth.py runs the 51/51 self-test).
Confirmed by live actuation (2026-06-12) — idscan_cmd.py
idscan_cmd.py drives the whole exchange end-to-end over socketcan (raw AF_CAN,
stdlib only). Tested on node F8 (interior lights): three consecutive
operations (on → off → on), each answering a distinct fresh challenge
(660E04A0, 0BF53691, 10FAEEA8), with the module's page-3 broadcast read
back before and after to confirm the result each time — b0 bit0 tracked the
command (1→1, 1→0, 0→1) and the level byte ramped accordingly. The command path
works.
python3 idscan_cmd.py F8 on # node_hex on|off ; needs can0 up
Movement nodes (awning 75, slides, jacks) use the same authentication —
the app-driven awning commands in captures/app-commands-*.log show the identical
page-42/43 exchange. Not yet operated this way; exercise a motor only while
watching it.
Bottom line: read is fully open (all sensors + states from broadcasts, no
authentication) and command is implemented and proven (ids_can_auth.py +
idscan_cmd.py). The CAN path can both sense and operate the system, so the
Bluetooth integration is no longer needed for control. Next step: fold the
challenge-response into the ESPHome node's switch/light/cover actions (the
opcode just needs the page-42/43 exchange in front of it).
Other app-session traffic (not control): 701 = controller heartbeat during a
Bluetooth 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 unmapped 0x1E load) and
6A/7F/9C (movement — slide?), and find the battery SoC / "4 green lights"
source.
DSI fault — decoded (2026-06-12, captures/dsi-fault-*.log)
Forced a real lockout (propane valve closed, heater run on gas until it gave up) and diffed against the healthy baseline. Two signals:
- Water-heater DSI fault = node
95page-3b0bit5 (0x20). Healthy heater reads0x80(off) or0x81(running); during the lockout it read0xA0(bit0 cleared, bit5 set) for every sample. Bit5 never appears healthy → it's the gas-ignition lockout flag. (b1stays0xFFandnode AEstays0x00— the two earlier suspects were both wrong.) - Bus-wide "system fault present" = page-0
b0bit0 (0x01). Every node's page-0b0flipped0x02→0x03during the fault, so any node carries a generic "a fault exists somewhere" flag.
Both are wired into the ESPHome node as binary_sensors (device_class: problem) — the DSI fault the Bluetooth app never exposed. Reset = reopen valve,
re-light.
Hardware (BOM)
| Item | Notes |
|---|---|
| CANable 2.0 USB-CAN | Bus capture/bring-up from xarl → 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 protocol notes 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) → the controller reads resistive senders and broadcasts levels on CAN from its own source address (not separate tank modules).
Physical connection (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.
- This rig's pigtail (as crimped): green = CAN-L, blue = CAN-H.
- Revert = unplug, re-seat the terminator.
CAN-H vs CAN-L: harmless if swapped — the bus just goes silent; flip the two wires (or pop the Mini-Fit Jr. terminals and reorder).
Capturing bus traffic
On xarl with the CANable (see captures/log-can.sh for an up/rec/watch/down
helper):
paru -S can-utils # AUR on Arch (not in the repos)
sudo slcand -o -s5 /dev/ttyACMx can0 # this CANable shipped with slcan fw
sudo ip link set can0 up
candump -ta -x can0 | tee captures/$(date +%F)-idle.log # timestamped raw log
cansniffer -c can0 # color diff view — operate a load, watch which bytes move
CANable firmware note: this unit enumerates as a serial device (
/dev/ttyACM*), so it needsslcandto bridge it to acan0socketcan interface (-s5= 250 kbit/s). If it ever re-enumerates to a differentttyACMnumber, restartslcandagainst the new path. A candleLight/gs_usb reflash would instead give a nativecan0viaip link set can0 up type can bitrate 250000.
Mapping method (easy, because everything is broadcast and unauthenticated): operate 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)
Device inventory (from the Bluetooth notes — what to look for on the bus)
| Bluetooth DevID | Component |
|---|---|
| 4 | water pump |
| 5 | gas water heater |
| 6 | exterior lights |
| 7 | interior lights |
| 8 | grey tank 2 |
| 9 | grey tank 1 |
| 10 | black tank |
| 11 | fresh water tank |
| 2, 3 | slide / awning |
| — | battery voltage |
The Bluetooth DevID numbers do not map to IDS-CAN node addresses — the table is just the checklist of loads to identify on the bus (all now found; see the node map).
ESPHome node
esphome/onecontrol-canbus.yaml — ESP32 esp32_can listener (catch-all
on_frame → IDS-CAN 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. During bring-up it logs every decoded frame at
DEBUG so the ESP can double as a monitor. Fill in the node/byte math in the
lambda from the node map; wire the command path (page-42/43 exchange +
ids_can_auth response, then the opcode) into the switch/light/cover
actions last.
References
ids_can_auth.py— IDS-CAN command authentication (response computation + self-test)idscan_cmd.py— socketcan command tool (the full exchange, proven on node F8)- Lippert OneControl: https://www.lippert.com/brands/onecontrol
- RV-C background (for contrast — this bus is not RV-C): https://www.rv-c.com/
- Bluetooth-side protocol notes (this repo):
../docs/PROTOCOL_FINDINGS.md