320 lines
16 KiB
Markdown
320 lines
16 KiB
Markdown
# 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:
|
||
`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** (~0x500–0x620 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≈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` (src `7D` → `FC`) / `02B90111` (src `AE` → `01`) — periodic,
|
||
payload `00 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* 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 (a state-change announce/sync broadcast), plus a
|
||
per-event handshake pair (src-node→`FC` page 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.
|
||
|
||
```sh
|
||
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 `95` page-3 `b0` bit5 (`0x20`).** Healthy heater
|
||
reads `0x80` (off) or `0x81` (running); during the lockout it read **`0xA0`**
|
||
(bit0 cleared, bit5 set) for every sample. Bit5 never appears healthy → it's
|
||
the gas-ignition lockout flag. (`b1` stays `0xFF` and `node AE` stays `0x00` —
|
||
the two earlier suspects were both wrong.)
|
||
- **Bus-wide "system fault present" = page-0 `b0` bit0 (`0x01`).** *Every* node's
|
||
page-0 `b0` flipped `0x02`→`0x03` during the fault, so any node carries a
|
||
generic "a fault exists somewhere" flag.
|
||
|
||
Both are wired into the ESPHome node as `binary_sensor`s (`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)
|
||
|
||
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.
|
||
- **This rig's pigtail (as crimped):** **green = CAN-L, blue = CAN-H.**
|
||
5. 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):
|
||
|
||
```sh
|
||
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 needs `slcand` to bridge it to a `can0` socketcan
|
||
> interface (`-s5` = 250 kbit/s). If it ever re-enumerates to a different
|
||
> `ttyACM` number, restart `slcand` against the new path. A candleLight/gs_usb
|
||
> reflash would instead give a native `can0` via
|
||
> `ip 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`
|