# 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 00 ` — **`` = 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 `03F043` = src `FC` → dest node, page 43). - `01F5FC11` (src `7D` → `FC`) / `02B90111` (src `AE` → `01`) — periodic, payload `00 2B 0D 4x `: `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` (`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 " # module returns a 4-byte challenge 01 → node page43 "00 04 " # controller returns the matching response node → 01 page43 "00 04" # module acknowledges 01 → node 0x0006 ×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 ` 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. ### Planned: capture the DSI fault (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 the panel), capture ~20 s, then diff against a healthy baseline. Prime suspects (both sit at a constant "all-clear" value in current captures): - **node `95` (heater) page-3 `b1`** — always `0xFF`; expect it to drop a bit on fault. - **node `AE` (type 0x27, ?LP-gas/diagnostics) page-3** — always `0x00`; expect non-zero on fault. Whichever flips → becomes a `binary_sensor` in the ESPHome node (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. 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: - RV-C background (for contrast — this bus is **not** RV-C): - Bluetooth-side protocol notes (this repo): `../docs/PROTOCOL_FINDINGS.md`