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:
+166
-149
@@ -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≈69–76 s |
|
||||
| `F8` | **interior lights** | type 0x1E; toggle test t≈51–61 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?) |
|
||||
@@ -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` ≈ 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.
|
||||
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`
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bring up the CANable at RV-C speed and log timestamped raw frames.
|
||||
# Bring up the CANable at IDS-CAN speed and log timestamped raw frames.
|
||||
#
|
||||
# Usage:
|
||||
# ./log-can.sh # bring up can0 @ 250k, live candump
|
||||
# ./log-can.sh rec NAME # also tee a timestamped log to NAME-<date>.log
|
||||
# ./log-can.sh sniff # cansniffer color-diff view (toggle a load, watch bytes)
|
||||
# ./log-can.sh watch # cansniffer color-diff view (operate a load, watch bytes)
|
||||
# ./log-can.sh down # take the interface down
|
||||
#
|
||||
# Requires: can-utils (pacman -S can-utils). CANable 2.0 with candleLight/gs_usb
|
||||
# firmware enumerates as a native socketcan device (default can0).
|
||||
# Requires: can-utils (paru -S can-utils on Arch). This CANable shipped with
|
||||
# slcan firmware (a /dev/ttyACM* serial device) — bridge it to can0 first with
|
||||
# sudo slcand -o -s5 /dev/ttyACMx can0
|
||||
# A candleLight/gs_usb reflash would instead give a native socketcan can0 and let
|
||||
# the up() path below set the bitrate directly.
|
||||
|
||||
set -euo pipefail
|
||||
IFACE="${IFACE:-can0}"
|
||||
BITRATE=250000 # RV-C is always 250k
|
||||
BITRATE=250000 # IDS-CAN is 250k
|
||||
CMD="${1:-up}"
|
||||
|
||||
up() {
|
||||
@@ -31,7 +34,7 @@ case "$CMD" in
|
||||
name="${2:-onecontrol}"; out="$(dirname "$0")/${name}-$(date +%F_%H%M%S).log"
|
||||
echo "logging -> $out (Ctrl-C to stop)"
|
||||
exec candump -ta -x "$IFACE" | tee "$out" ;;
|
||||
sniff) up; exec cansniffer -c "$IFACE" ;;
|
||||
watch) up; exec cansniffer -c "$IFACE" ;;
|
||||
down) sudo ip link set "$IFACE" down; echo "$IFACE down" ;;
|
||||
*) echo "usage: $0 {up|rec NAME|sniff|down}" >&2; exit 2 ;;
|
||||
*) echo "usage: $0 {up|rec NAME|watch|down}" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -1,5 +1,5 @@
|
||||
#pragma once
|
||||
// IDS-CAN command-auth response cipher for the OneControl write path.
|
||||
// IDS-CAN command authentication response for the OneControl integration.
|
||||
//
|
||||
// 32-round TEA/XTEA-family Feistel, delta 0x9E3779B9, keyed by a per-session
|
||||
// 32-bit "Cypher" with the round constants baked in.
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
namespace ids_can_auth {
|
||||
|
||||
// Per-session keys ("Cypher"). REMOTE_CONTROL gates on/off/move.
|
||||
// Per-session keys ("Cypher"). REMOTE_CONTROL is the session for on/off/move.
|
||||
enum SessionKey : uint32_t {
|
||||
MANUFACTURING = 0xB16BA115u,
|
||||
DIAGNOSTIC = 0xBABECAFEu,
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
# OneControl RV-C CANbus node
|
||||
# OneControl IDS-CAN node
|
||||
#
|
||||
# ESP32 (native TWAI/CAN) + external SN65HVD230 transceiver, tapped into the
|
||||
# Lippert UNITY X180T RV-C bus at the monitor panel's spare CAN *data* port.
|
||||
# Listens to RV-C broadcasts and republishes them as native HA entities; the
|
||||
# command/write path (switches) is stubbed until the DGN map is RE'd.
|
||||
# ESP32 (native TWAI/CAN) + external SN65HVD230 transceiver, connected to the
|
||||
# Lippert UNITY X180T CAN bus at the monitor panel's spare CAN *data* port.
|
||||
# Listens to the modules' broadcasts and republishes them as native HA entities,
|
||||
# and issues commands using the panel's challenge/response authentication.
|
||||
#
|
||||
# RV-C = 250 kbit/s, 29-bit extended IDs. See ../README.md for the tap procedure,
|
||||
# the (unverified) DGN map, and the sniffing workflow. Flash over USB first
|
||||
# IDS-CAN = 250 kbit/s. Read broadcasts are 11-bit standard frames,
|
||||
# id = (page << 8) | node. Commands and their authentication exchange use 29-bit
|
||||
# extended frames. See ../README.md for the connection procedure, the node map,
|
||||
# and the message format. Flash over USB first
|
||||
# (`esphome run onecontrol-canbus.yaml`), OTA thereafter.
|
||||
|
||||
substitutions:
|
||||
name: onecontrol-canbus
|
||||
friendly_name: OneControl CANbus
|
||||
friendly_name: OneControl CAN
|
||||
# ESP32 GPIOs to the transceiver. Any free non-strapping pins work; these match
|
||||
# a common SN65HVD230 wiring. tx_pin -> transceiver D/CTX, rx_pin <- R/CRX.
|
||||
tx_pin: GPIO5
|
||||
@@ -20,6 +22,10 @@ substitutions:
|
||||
esphome:
|
||||
name: ${name}
|
||||
friendly_name: ${friendly_name}
|
||||
# Command authentication (REMOTE_CONTROL session, key 0xB16B00B5). Provides
|
||||
# ids_can_auth::remote_control_response_bytes() to the command path below.
|
||||
includes:
|
||||
- ids_can_auth.h
|
||||
|
||||
esp32:
|
||||
board: esp32dev # classic WROOM-32
|
||||
@@ -37,7 +43,7 @@ captive_portal:
|
||||
|
||||
logger:
|
||||
level: DEBUG # DEBUG so the on_frame ESP_LOGD frame dump is visible
|
||||
# while RE'ing. Drop to INFO once the map is solid.
|
||||
# during bring-up. Drop to INFO once the map is solid.
|
||||
|
||||
api:
|
||||
encryption:
|
||||
@@ -48,71 +54,155 @@ ota:
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CAN bus: ESP32 native TWAI controller + SN65HVD230 transceiver
|
||||
#
|
||||
# NOTE: read broadcasts are 11-bit *standard* frames; the command authentication
|
||||
# challenge is a 29-bit *extended* frame. The node must receive both. Confirm the
|
||||
# esp32_can trigger accepts both frame types (a single catch-all with
|
||||
# can_id_mask 0); if your ESPHome build filters by frame type, add a second
|
||||
# on_frame for standard IDs.
|
||||
# ---------------------------------------------------------------------------
|
||||
canbus:
|
||||
- platform: esp32_can
|
||||
id: rvc_bus
|
||||
id: can_bus
|
||||
tx_pin: ${tx_pin}
|
||||
rx_pin: ${rx_pin}
|
||||
bit_rate: 250kbps # RV-C is always 250k
|
||||
bit_rate: 250kbps # IDS-CAN is 250k
|
||||
can_id: 0 # our own TX id (only matters when we send)
|
||||
use_extended_id: true # RV-C uses 29-bit IDs
|
||||
use_extended_id: true # commands use 29-bit IDs
|
||||
on_frame:
|
||||
# Catch-all: can_id_mask 0 = accept every frame, then dispatch by DGN in
|
||||
# the lambda (RV-C buries the source address in the id's low byte, so a
|
||||
# single decoder is cleaner than per-DGN hardware filters).
|
||||
- can_id: 0
|
||||
can_id_mask: 0
|
||||
can_id_mask: 0 # accept every frame, dispatch in the lambda
|
||||
use_extended_id: true
|
||||
then:
|
||||
- lambda: |-
|
||||
// `can_id` and `x` (data bytes) are provided by the trigger.
|
||||
// (If your ESPHome is too old to expose `can_id` here, switch to
|
||||
// per-DGN can_id/can_id_mask filters instead.)
|
||||
uint32_t id = can_id;
|
||||
uint8_t prio = (id >> 26) & 0x7;
|
||||
uint32_t dgn = (id >> 8) & 0x1FFFF;
|
||||
uint8_t sa = id & 0xFF;
|
||||
|
||||
// ---- command authentication: page-42 challenge reply ----
|
||||
// After send_load_command sends the page-42 request, the target
|
||||
// module returns a fresh 4-byte challenge on the 29-bit ext ID
|
||||
// (node<<18)|0x10000|0x0142, payload 00 04 CC CC CC CC. Compute the
|
||||
// REMOTE_CONTROL response, send it on page 43, then send the opcode
|
||||
// x3. This is the only place we transmit; it acts solely on
|
||||
// id(g_cmd_node), so no other node is ever touched.
|
||||
if (id(g_cmd_pending) && x.size() >= 6) {
|
||||
uint32_t chal_id = ((uint32_t) id(g_cmd_node) << 18) | 0x10000u | 0x0142u;
|
||||
if (id == chal_id) {
|
||||
uint8_t resp[4];
|
||||
ids_can_auth::remote_control_response_bytes(&x[2], resp);
|
||||
|
||||
// page-43 response: ext ID 0x0004<node>43, payload 00 04 RR RR RR RR
|
||||
uint32_t resp_id = 0x00040043u | ((uint32_t) id(g_cmd_node) << 8);
|
||||
std::vector<uint8_t> rframe = {0x00, 0x04, resp[0], resp[1], resp[2], resp[3]};
|
||||
id(can_bus).send_data(resp_id, true, rframe);
|
||||
|
||||
// opcode x3: ext ID 0x0006<node><op>, DLC 0 (op 01=on, 00=off)
|
||||
uint32_t op_id = 0x00060000u | ((uint32_t) id(g_cmd_node) << 8) | (uint32_t) id(g_cmd_op);
|
||||
std::vector<uint8_t> opframe; // empty -> DLC 0
|
||||
for (int i = 0; i < 3; i++) id(can_bus).send_data(op_id, true, opframe);
|
||||
|
||||
id(g_cmd_pending) = false;
|
||||
ESP_LOGI("idscan", "node %02X: challenge %02X%02X%02X%02X -> response %02X%02X%02X%02X, opcode %02X x3",
|
||||
id(g_cmd_node), x[2], x[3], x[4], x[5],
|
||||
resp[0], resp[1], resp[2], resp[3], id(g_cmd_op));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- read broadcasts: 11-bit standard frames, id = (page<<8)|node ----
|
||||
uint8_t page = (id >> 8) & 0xFF;
|
||||
uint8_t node = id & 0xFF;
|
||||
|
||||
// Frame dump — comment out once the map is trustworthy.
|
||||
ESP_LOGD("rvc", "DGN=%05X SA=%02X len=%u %02X %02X %02X %02X %02X %02X %02X %02X",
|
||||
dgn, sa, x.size(),
|
||||
x.size()>0?x[0]:0, x.size()>1?x[1]:0, x.size()>2?x[2]:0, x.size()>3?x[3]:0,
|
||||
x.size()>4?x[4]:0, x.size()>5?x[5]:0, x.size()>6?x[6]:0, x.size()>7?x[7]:0);
|
||||
ESP_LOGD("idscan", "page=%u node=%02X len=%u %02X %02X %02X %02X %02X %02X",
|
||||
page, node, x.size(),
|
||||
x.size()>0?x[0]:0, x.size()>1?x[1]:0, x.size()>2?x[2]:0,
|
||||
x.size()>3?x[3]:0, x.size()>4?x[4]:0, x.size()>5?x[5]:0);
|
||||
|
||||
// ================= DISPATCH — ALL VALUES UNVERIFIED =================
|
||||
// DGNs + byte math are standard-RV-C hypotheses. Confirm each
|
||||
// against the sniff log (../README.md "DGN map") before trusting.
|
||||
|
||||
// ---- TANK_STATUS (std 0x1FFB7) ----
|
||||
if (dgn == 0x1FFB7 && x.size() >= 2) {
|
||||
uint8_t inst = x[0];
|
||||
float pct = x[1] * 100.0f / 255.0f; // TODO verify level math
|
||||
switch (inst) { // TODO verify instances
|
||||
case 0: id(fresh_tank).publish_state(pct); break;
|
||||
case 1: id(black_tank).publish_state(pct); break;
|
||||
case 2: id(grey_tank_1).publish_state(pct); break;
|
||||
case 3: id(grey_tank_2).publish_state(pct); break;
|
||||
// page 3 = live value. Layout depends on device class (README):
|
||||
// tanks (type 0x0A): x[0] = level in percent (0x42 = 66%).
|
||||
// switched loads (type 0x1E): x[0] bit0 = on/off.
|
||||
if (page == 3 && x.size() >= 1) {
|
||||
switch (node) {
|
||||
// tanks (node addresses from the README node map for this rig)
|
||||
case 0xE2: id(fresh_tank).publish_state(x[0]); break;
|
||||
case 0xFE: id(black_tank).publish_state(x[0]); break;
|
||||
case 0x27: id(grey_tank_1).publish_state(x[0]); break;
|
||||
case 0x7D: id(grey_tank_2).publish_state(x[0]); break;
|
||||
// switched loads
|
||||
case 0xF8: id(interior_lights).publish_state(x[0] & 0x01); break;
|
||||
case 0x2A: id(exterior_lights).publish_state(x[0] & 0x01); break;
|
||||
// TODO: water pump (61), water heater (95) once exposed below.
|
||||
}
|
||||
}
|
||||
|
||||
// ---- DC_SOURCE_STATUS_1 (std 0x1FFFD) battery ----
|
||||
if (dgn == 0x1FFFD && x.size() >= 4) {
|
||||
uint16_t raw = x[2] | (x[3] << 8); // 0.05 V/bit, LE (verify)
|
||||
id(battery_voltage).publish_state(raw * 0.05f);
|
||||
}
|
||||
// Battery voltage rides 29-bit telemetry frames (src 7D/AE, page
|
||||
// 0x11), payload 00 2B 0D 4x ..; b2..b3 (BE) / 256 = volts.
|
||||
// TODO: match the exact source frame and publish battery_voltage.
|
||||
|
||||
// ---- DC_DIMMER_STATUS_3 (std 0x1FEDA) light/switch state ----
|
||||
if (dgn == 0x1FEDA && x.size() >= 3) {
|
||||
uint8_t inst = x[0];
|
||||
bool on = x[2] > 0; // byte2 = brightness
|
||||
switch (inst) { // TODO verify instances
|
||||
// case ?: id(interior_lights).publish_state(on); break;
|
||||
// case ?: id(exterior_lights).publish_state(on); break;
|
||||
// case ?: id(water_pump).publish_state(on); break;
|
||||
// case ?: id(gas_water_heater).publish_state(on); break;
|
||||
}
|
||||
# ---------------------------------------------------------------------------
|
||||
# Command path: authenticated "set switched load"
|
||||
#
|
||||
# State shared between send_load_command (sends the request + retries) and the
|
||||
# on_frame challenge handler (computes the response + sends the opcode). A
|
||||
# command is queued by setting g_cmd_* and g_cmd_pending; the handler clears
|
||||
# g_cmd_pending once the exchange completes, which also stops the retry loop.
|
||||
# ---------------------------------------------------------------------------
|
||||
globals:
|
||||
- id: g_cmd_node
|
||||
type: uint8_t
|
||||
initial_value: '0'
|
||||
- id: g_cmd_op
|
||||
type: uint8_t
|
||||
initial_value: '0'
|
||||
- id: g_cmd_pending
|
||||
type: bool
|
||||
initial_value: 'false'
|
||||
|
||||
script:
|
||||
- id: send_load_command
|
||||
# mode: restart -> a new press supersedes an in-flight exchange.
|
||||
mode: restart
|
||||
parameters:
|
||||
node: int
|
||||
op: int
|
||||
then:
|
||||
- lambda: |-
|
||||
// SAFETY: lights only. Refuse any node that isn't an allowlisted
|
||||
// switched load (2A ext lights, F8 int lights). This keeps the command
|
||||
// path off movement nodes (slides/jacks/awning).
|
||||
if (node != 0x2A && node != 0xF8) {
|
||||
ESP_LOGW("idscan", "refusing command to non-allowlisted node %02X (lights only)", node);
|
||||
id(g_cmd_pending) = false;
|
||||
return;
|
||||
}
|
||||
id(g_cmd_node) = (uint8_t) node;
|
||||
id(g_cmd_op) = (uint8_t) op;
|
||||
id(g_cmd_pending) = true;
|
||||
# Send the page-42 request, wait ~100 ms for the challenge; retry up to 3x.
|
||||
# The on_frame handler clears g_cmd_pending the moment it answers, so a
|
||||
# successful exchange short-circuits the remaining iterations.
|
||||
- repeat:
|
||||
count: 3
|
||||
then:
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return id(g_cmd_pending);'
|
||||
then:
|
||||
- lambda: |-
|
||||
// page-42 request: ext ID 0x0004<node>42, payload 00 04
|
||||
uint32_t req_id = 0x00040042u | ((uint32_t) id(g_cmd_node) << 8);
|
||||
std::vector<uint8_t> req = {0x00, 0x04};
|
||||
id(can_bus).send_data(req_id, true, req);
|
||||
- delay: 100ms
|
||||
- if:
|
||||
condition:
|
||||
lambda: 'return id(g_cmd_pending);'
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGW("idscan", "no page-42 challenge from node %02X after 3 tries; command dropped",
|
||||
id(g_cmd_node));
|
||||
id(g_cmd_pending) = false;
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Read-back sensors (published by the dispatcher above)
|
||||
@@ -147,29 +237,35 @@ sensor:
|
||||
accuracy_decimals: 0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Switches — state comes from DC_DIMMER_STATUS_3 above; the command path is a
|
||||
# PLACEHOLDER until DC_DIMMER_COMMAND_2 (std 0x1FEDB) instance + payload are RE'd.
|
||||
# can_id below = prio(6)<<26 | DGN 0x1FEDB <<8 | source_addr. 0x18FEDB80 is a
|
||||
# guess (prio 6, SA 0x80) — DO NOT trust until verified by sniffing a real
|
||||
# command frame from the panel.
|
||||
# Switches — authenticated command path (see send_load_command above).
|
||||
# Each turn_on/off queues send_load_command for the load's node; the on_frame
|
||||
# handler completes the page-42/43 challenge/response and sends the opcode.
|
||||
#
|
||||
# SAFETY: lights only. Only the allowlisted switched-load nodes (2A = exterior
|
||||
# lights, F8 = interior lights) may be wired here. Do NOT add movement nodes
|
||||
# (slides/jacks/awning, type 0x21) until a careful attended first test.
|
||||
# optimistic:true for now — the page-3 (b0 bit0) read-back above already publishes
|
||||
# true module state, so these can switch to optimistic:false once verified.
|
||||
# ---------------------------------------------------------------------------
|
||||
switch:
|
||||
- platform: template
|
||||
name: "Exterior Lights"
|
||||
id: exterior_lights
|
||||
optimistic: true
|
||||
turn_on_action:
|
||||
- script.execute: { id: send_load_command, node: 0x2A, op: 1 }
|
||||
turn_off_action:
|
||||
- script.execute: { id: send_load_command, node: 0x2A, op: 0 }
|
||||
|
||||
- platform: template
|
||||
name: "Interior Lights"
|
||||
id: interior_lights
|
||||
optimistic: true # flip to false once read-back is wired
|
||||
optimistic: true
|
||||
turn_on_action:
|
||||
- canbus.send:
|
||||
canbus_id: rvc_bus
|
||||
use_extended_id: true
|
||||
can_id: 0x18FEDB80 # TODO compute from real DGN+SA
|
||||
data: [0x00, 0x00, 0xC8, 0x01] # TODO [instance, group, level, cmd]
|
||||
- script.execute: { id: send_load_command, node: 0xF8, op: 1 }
|
||||
turn_off_action:
|
||||
- canbus.send:
|
||||
canbus_id: rvc_bus
|
||||
use_extended_id: true
|
||||
can_id: 0x18FEDB80 # TODO
|
||||
data: [0x00, 0x00, 0x00, 0x03] # TODO
|
||||
- script.execute: { id: send_load_command, node: 0xF8, op: 0 }
|
||||
|
||||
# Duplicate the block above for: exterior_lights, water_pump, gas_water_heater
|
||||
# once their command instances are known.
|
||||
# water pump (61) / water heater (95) are also type-0x1E switched loads and use
|
||||
# the same authentication — add them here once you want them in HA. Movement
|
||||
# nodes stay off this list until tested.
|
||||
|
||||
+18
-14
@@ -1,27 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
"""IDS-CAN command-auth cipher — the gate on the CAN write path.
|
||||
"""IDS-CAN command authentication — challenge/response for the CAN command path.
|
||||
|
||||
SOLVED 2026-06-12. The challenge->response transform is a TEA/XTEA-family
|
||||
32-round Feistel keyed by a per-session 32-bit key ("Cypher").
|
||||
The OneControl modules require a per-command challenge/response before they act
|
||||
on an opcode. This computes the response so the Home Assistant integration can
|
||||
issue commands the same way the OEM app and remote do.
|
||||
|
||||
The transform is a TEA/XTEA-family 32-round Feistel keyed by a per-session 32-bit
|
||||
value the protocol calls the "Cypher".
|
||||
|
||||
`response = encrypt(challenge, REMOTE_CONTROL)`, both 32-bit
|
||||
**big-endian** (the 4 payload bytes after the "00 04" prefix in the page-42
|
||||
challenge / page-43 response frames). Verified 51/51 against the captured pairs
|
||||
in sniff/2A-auth-pairs.txt + sniff/auth-pairs-multinode-2026-06-11.txt, across
|
||||
nodes 2A/61/75/F8 — one global session key, not per-node.
|
||||
challenge / page-43 response frames). Validated 51/51 against the captured pairs
|
||||
in captures/2A-auth-pairs.txt + captures/auth-pairs-multinode-2026-06-11.txt, across
|
||||
nodes 2A/61/75/F8 — one global session key, shared by all nodes.
|
||||
|
||||
The session "Cypher" is the only key; the round constants are baked in. Five
|
||||
sessions exist (the joke hex values confirm they're the genuine keys):
|
||||
The session "Cypher" is the only key; the round constants are baked in. The
|
||||
protocol defines five sessions (the memorable hex values are its own constants):
|
||||
MANUFACTURING 0xB16BA115 DIAGNOSTIC 0xBABECAFE REPROGRAMMING 0xDEADBEEF
|
||||
REMOTE_CONTROL 0xB16B00B5 DAQ 0x0B00B135
|
||||
REMOTE_CONTROL is the one that gates on/off/move commands.
|
||||
REMOTE_CONTROL is the session for on/off/move commands.
|
||||
|
||||
Unlock sequence on the bus (controller 01 <-> module, 29-bit extended frames):
|
||||
01->node page42 DLC2 "00 04" # arm
|
||||
Exchange on the bus (controller 01 <-> module, 29-bit extended frames):
|
||||
01->node page42 DLC2 "00 04" # request a challenge
|
||||
node->01 page42 DLC6 "00 04 <CC CC CC CC>" # module's challenge
|
||||
01->node page43 DLC6 "00 04 <RR RR RR RR>" # RR = remote_control_response(CC)
|
||||
node->01 page43 DLC2 "00 04" # ack
|
||||
01->node 0x0006<node><op> x3 # opcode now honored (01=on,00=off,02=retract)
|
||||
01->node 0x0006<node><op> x3 # opcode (01=on,00=off,02=retract)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -74,8 +78,8 @@ def response_bytes(challenge: bytes, session: str = "REMOTE_CONTROL") -> bytes:
|
||||
def _selftest() -> int:
|
||||
import os
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
files = [os.path.join(here, "sniff", "2A-auth-pairs.txt"),
|
||||
os.path.join(here, "sniff", "auth-pairs-multinode-2026-06-11.txt")]
|
||||
files = [os.path.join(here, "captures", "2A-auth-pairs.txt"),
|
||||
os.path.join(here, "captures", "auth-pairs-multinode-2026-06-11.txt")]
|
||||
total = bad = 0
|
||||
for path in files:
|
||||
if not os.path.exists(path):
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Operate an IDS-CAN switched load over socketcan, with the auth exchange.
|
||||
|
||||
Acts as controller node 01: request a challenge -> read the module's fresh
|
||||
challenge -> return remote_control_response(challenge) -> send the opcode x3.
|
||||
Pure stdlib (raw AF_CAN socket); needs can0 already up (slcand).
|
||||
|
||||
Usage: idscan_cmd.py <node_hex> <on|off> e.g. idscan_cmd.py F8 on
|
||||
"""
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
from ids_can_auth import remote_control_response
|
||||
|
||||
CAN_EFF_FLAG = 0x80000000
|
||||
FMT = "=IB3x8s" # can_id, can_dlc, pad/res, data[8]
|
||||
ARM_PAGE, RESP_PAGE, CHAL_PAGE = 0x42, 0x43, 0x42
|
||||
|
||||
|
||||
def eff(i):
|
||||
return i | CAN_EFF_FLAG
|
||||
|
||||
|
||||
def pack(can_id, data=b""):
|
||||
return struct.pack(FMT, eff(can_id), len(data), data.ljust(8, b"\x00"))
|
||||
|
||||
|
||||
def unpack(frame):
|
||||
cid, dlc, data = struct.unpack(FMT, frame)
|
||||
is_eff = bool(cid & CAN_EFF_FLAG)
|
||||
return is_eff, cid & 0x1FFFFFFF, dlc, data[:dlc]
|
||||
|
||||
|
||||
def status_id(node): # 11-bit page-3 live-value broadcast
|
||||
return (3 << 8) | node
|
||||
|
||||
|
||||
def read_status(s, node, timeout=2.0):
|
||||
want = status_id(node)
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
s.settimeout(max(0.01, end - time.time()))
|
||||
try:
|
||||
is_eff, cid, dlc, data = unpack(s.recv(16))
|
||||
except socket.timeout:
|
||||
break
|
||||
if not is_eff and cid == want:
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
def actuate(node, op):
|
||||
arm_id = (0x01 << 18) | (0 << 16) | (node << 8) | ARM_PAGE # 01->node p42
|
||||
resp_id = (0x01 << 18) | (0 << 16) | (node << 8) | RESP_PAGE # 01->node p43
|
||||
chal_id = (node << 18) | (1 << 16) | (0x01 << 8) | CHAL_PAGE # node->01 p42
|
||||
opcode_id = (0x0006 << 16) | (node << 8) | op # 0006<node><op>
|
||||
|
||||
s = socket.socket(socket.PF_CAN, socket.SOCK_RAW, socket.CAN_RAW)
|
||||
s.bind(("can0",))
|
||||
|
||||
before = read_status(s, node, 1.5)
|
||||
print(f" before: page3 = {before.hex(' ') if before else '(none)'}")
|
||||
|
||||
s.send(pack(arm_id, b"\x00\x04")) # arm
|
||||
# await the module's fresh challenge
|
||||
chal = None
|
||||
s.settimeout(0.3)
|
||||
end = time.time() + 0.3
|
||||
while time.time() < end:
|
||||
try:
|
||||
is_eff, cid, dlc, data = unpack(s.recv(16))
|
||||
except socket.timeout:
|
||||
break
|
||||
if is_eff and cid == chal_id and dlc == 6 and data[:2] == b"\x00\x04":
|
||||
chal = data[2:6]
|
||||
break
|
||||
if chal is None:
|
||||
print(" !! no challenge from module — aborting (load not actuated)")
|
||||
return False
|
||||
|
||||
ch_int = int.from_bytes(chal, "big")
|
||||
rr = remote_control_response(ch_int)
|
||||
print(f" challenge {ch_int:08X} -> response {rr:08X}")
|
||||
s.send(pack(resp_id, b"\x00\x04" + rr.to_bytes(4, "big"))) # response
|
||||
time.sleep(0.004)
|
||||
for _ in range(3): # opcode x3
|
||||
s.send(pack(opcode_id))
|
||||
time.sleep(0.006)
|
||||
|
||||
time.sleep(0.3)
|
||||
after = read_status(s, node, 1.5)
|
||||
print(f" after: page3 = {after.hex(' ') if after else '(none)'}")
|
||||
s.close()
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 3 or sys.argv[2] not in ("on", "off"):
|
||||
sys.exit(__doc__)
|
||||
node = int(sys.argv[1], 16)
|
||||
op = 0x01 if sys.argv[2] == "on" else 0x00
|
||||
print(f"node {node:02X} -> {sys.argv[2]}")
|
||||
actuate(node, op)
|
||||
Reference in New Issue
Block a user