# 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 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 | | `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) | flags? | (dest_node << 8) | page`** (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. (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` (`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 " # module: random 4-byte challenge 01 → node page43 "00 04 " # controller: correct response node → 01 page43 "00 04" # module: ack 01 → node 0x0006 ×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. ### TODO: capture the DSI fault (planned 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 panel), capture ~20 s with the CANable, then diff against a healthy baseline. Prime suspects (both sit at a constant "all-clear" sentinel in current captures): - **node `95` (heater) page-3 `b1`** — always `0xFF`; expect it to drop/clear 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 BLE app never exposed). Reset = reopen valve, re-light. --- ## 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: - CoachProxy / coachproxyos (open RV-C decode prior art) - `rvc2mqtt`, `rvc-monitor` (DGN→MQTT mappings to crib) - Lippert OneControl (RV-C): - BLE-side protocol (this repo): `../docs/PROTOCOL_FINDINGS.md`