Files
lippert-onecontrol/canbus/README.md
T
wesandClaude Fable 5 b97401fec8 canbus: reverse-engineer OneControl IDS-CAN bus (read fully mapped, write auth-gated)
Tapped the X180T's CAN bus via CANable 2.0 at the monitor panel's terminator
port. The bus is NOT RV-C — it's Lippert's proprietary IDS-CAN (250k, 11-bit
IDs, (page<<8)|node, 1 Hz broadcasts).

Read side fully mapped from live captures:
- device classes (page-2 type byte: 0x0A tank, 0x1E switched load, 0x21 motor)
- node map for this rig (Catalina 263BHSCK): tanks 27/E2/7D/FE, lights 2A/F8,
  heater 95, pump 61, awning 75 (+ direction & live motor current)
- battery voltage on 29-bit extended frames

Write side: commands are DLC-0 ext frames 0006<node><op>, but auth-gated by a
rolling challenge-response (page 42/43). Replay confirmed dead (spoofed cansend
did not actuate). Not the BLE TEA cypher. response=f(challenge) is deterministic
(no session state) so crackable offline later — seeded 42 pairs in
sniff/2A-auth-pairs.txt.

Includes raw captures (sniff/*.log, force-added past *.log ignore), a read-only
esp32_can ESPHome skeleton, and the log-can.sh sniff helper. Full writeup in
canbus/README.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:20:49 -04:00

244 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `7D``FC`) / `02B90111` (src `AE``01`) — 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 `55``AA` 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. 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.
---
## 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`):
```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
- 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`