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`
@@ -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
+2 -2
View File
@@ -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,
+169 -73
View File
@@ -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;
uint32_t id = can_id;
// ---- 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
View File
@@ -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):
+105
View File
@@ -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)