canbus: confirm command path live + frame docs as device integration

Command path proven end to end on the bus (node F8 interior lights, on/off/on),
each answering a distinct fresh challenge; bare opcodes without the exchange are
ignored. ids_can_auth.h verified bit-exact against ids_can_auth.py and the
captured/live pairs.

- idscan_cmd.py: stdlib socketcan tool running the full page-42/43 exchange
- esphome/onecontrol-canbus.yaml: correct IDS-CAN read dispatch (was stale RV-C
  DGN code) + command path wired to the auth header
- README/memory: document the read map + command authentication; rename
  sniff/ -> captures/; neutral device-integration framing throughout

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wes
2026-06-12 11:20:12 -04:00
co-authored by Claude Opus 4.8
parent 840cfaf5fc
commit 742ef49c8a
14 changed files with 470 additions and 245 deletions
+166 -149
View File
@@ -1,27 +1,34 @@
# OneControl via CANbus (IDS-CAN)
# OneControl CAN integration (Lippert 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).
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.
**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`.
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.
---
## IDS-CAN findings (2026-06-11, captures: `sniff/baseline-*.log`, `sniff/toggletest-*.log`)
## 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 immediate rebroadcast on change). Pages seen:
**1 Hz** (plus an immediate rebroadcast on change). Pages observed:
| Page | Content |
|------|---------|
@@ -34,7 +41,7 @@ and device map below are from live captures in `sniff/*.log`.
### Device classes (page-2 `type` byte)
- **`0x0A` = tank.** Page 3 = **1 byte, level in percent** (0x42=66%, 0x21=33%).
- **`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
@@ -55,7 +62,7 @@ and device map below are from live captures in `sniff/*.log`.
| `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 |
| `F8` | **interior lights** | type 0x1E; toggle test t≈5161 s; **operated live 2026-06-12** |
| `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?) |
@@ -66,126 +73,132 @@ and device map below are from live captures in `sniff/*.log`.
### 29-bit extended frames (directed messages)
Extended ID = **`(src_node << 18) | flags? | (dest_node << 8) | page`**
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` ≈ 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.
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 (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.
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 path (DECODED 2026-06-11 — `sniff/app-commands-*.log`)
### Command messages (captures: `captures/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 —
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.
**WRITE IS AUTH-GATED — and the gate is now CRACKED (2026-06-12, see below).**
Each command is wrapped in a **rolling challenge-response** the bare opcode
won't pass:
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: "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 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 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.
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.
It uses a **different key** from the BLE TEA auth (`tea(612643285, 0x21CA0C06) =
0x87AC5CBD ≠` the observed `0xCC18366B`) but, as it turns out, the **same
family**: a TEA/XTEA Feistel. Lippert put a second, separately-keyed auth on the
CAN write path.
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.
**Dataset for the crack: `sniff/2A-auth-pairs.txt`** (42 pairs, node `2A`) +
`sniff/auth-pairs-multinode-2026-06-11.txt` (9 more across nodes `61`/`75`/`F8`
+2 on `2A`) — **51 pairs / 4 nodes**, captured 2026-06-11 (app-driven).
**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.
Structural analysis of `response = f(challenge)` (script `sniff/analyze_auth.py`):
**genuine keyed nonlinear block cipher.** Ruled out by the data — **not**
GF(2)-affine (the 51 input-differences span the full 32-dim space yet contradict
a linear fit, so the obstacle is *structure, not too few pairs* — a linear map
would have over-solved at ~33), **not** affine over Z/2³² (49/51 miss), and no
output byte is a function of any single input byte (full byte diffusion). Bits
are balanced. ⇒ TEA/XTEA/Speck-family with an unknown key, exactly as the BLE
side uses TEA.
### Authentication implementation — `ids_can_auth.py` (2026-06-12)
That structural read said the function was unrecoverable from random pairs and
pointed at recovering the key rather than cryptanalyzing the captures — which is
exactly what happened.
`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):
#### ✅ SOLVED (2026-06-12) — `ids_can_auth.py`
The cipher is a **32-round TEA/XTEA Feistel** (delta `0x9E3779B9`) keyed by a
per-**session** 32-bit "Cypher", with the round constants baked in. There are
five sessions — the joke hex values confirm they're the genuine keys:
| Session | Cypher | Use |
| Session | Key | Use |
|---------|--------|-----|
| MANUFACTURING | `0xB16BA115` | factory features |
| DIAGNOSTIC | `0xBABECAFE` | diagnostic tool (← likely unlocks the DSI fault path) |
| 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 write gate** |
| **REMOTE_CONTROL** | **`0xB16B00B5`** | **on/off/move — this is the command-path key** |
| DAQ | `0x0B00B135` | data acquisition |
`response = Encrypt(challenge, 0xB16B00B5)`, both 32-bit **big-endian** (the 4
payload bytes after `00 04`). **Verified 51/51** against every captured pair,
all four nodes (2A 44/44, 61 2/2, 75 3/3, F8 2/2) REMOTE_CONTROL is unique
(every other key misses 51/51), and it's **one global key, not per-node**. So to
actuate a load: catch the module's page-42 challenge, compute the response, send
it on page-43, then send the opcode. Reference impl + self-test in
`ids_can_auth.py` (`python3 ids_can_auth.py <challenge_hex>`). No firmware dump
was needed; the 51 captures were the verification oracle.
`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).
> Movement nodes use the **same gate.** App-driven awning (`75`) commands in
> `sniff/app-commands-*.log` show the full nonce handshake (node→01 page42
> challenge `01D50142` + 01→node page43 response), identical to the switched
> loads — *not* the commander-only/no-reply pattern an earlier jog test
> suggested. NOT spoof-tested (don't actuate a motor unattended).
### Confirmed by live actuation (2026-06-12) — `idscan_cmd.py`
**Bottom line: READ is fully open** (all sensors + states from broadcasts, zero
auth) **and WRITE is now unlocked** the command-auth cipher is cracked
(`ids_can_auth.py`), so the CAN path can both sense and actuate. The BLE
integration is no longer the only way to control loads; next step is wiring the
challenge-response into the ESPHome node's `switch`/`cover` actions (the bare
opcode in the command DGN now just needs the page-42/43 handshake in front of
it). Movement nodes (slides/jacks) still want a careful first actuation test.
`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
BLE session; src 01 → node pages `30/31` = paged descriptor/table reads the app
uses to build its UI.
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 untoggled 0x1E load) and
`6A`/`7F`/`9C` (movement — slide?), find battery SoC / the "4 green lights"
**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.
### TODO: capture the DSI fault (planned 2026-06-12)
### Planned: capture the DSI fault (2026-06-12)
The water-heater DSI fault is almost certainly on the bus but every capture so
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.
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 BLE app never exposed). Reset = reopen valve, re-light.
the Bluetooth app never exposed). Reset = reopen valve, re-light.
---
@@ -193,25 +206,24 @@ the BLE app never exposed). Reset = reopen valve, re-light.
| Item | Notes |
|------|-------|
| **CANable 2.0** USB-CAN | RE/sniffing from xarl. candleLight/gs_usb fw → native socketcan (`can0`). |
| **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
findings above.
- **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) → controller reads resistive senders and broadcasts levels on
CAN from its own source address (not separate tank modules).
terminal block) → the controller reads resistive senders and broadcasts levels
on CAN from its own source address (not separate tank modules).
## Physical tap (4 screws, fully reversible)
## 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
@@ -225,25 +237,33 @@ the BLE app never exposed). Reset = reopen valve, re-light.
+ 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).
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).
---
## Sniffing workflow (do this first, before the ESP32 build)
## Capturing bus traffic
On xarl with the CANable (see `sniff/log-can.sh`):
On xarl with the CANable (see `captures/log-can.sh` for an `up`/`rec`/`watch`/`down`
helper):
```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
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
```
**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**
> **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:
@@ -252,45 +272,42 @@ 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.
### Device inventory (from the Bluetooth notes — what to look for on the bus)
### Known device inventory (from the BLE RE — what to hunt 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 |
| 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.
> 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`DGN dispatcher → template sensors/switches). Mirrors the
`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. 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.
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
- 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`
- `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`