diff --git a/canbus/README.md b/canbus/README.md index f4e0d7d..f2da4ee 100644 --- a/canbus/README.md +++ b/canbus/README.md @@ -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 `03F043` = src `FC` → dest node, page 43). - `01F5FC11` (src `7D` → `FC`) / `02B90111` (src `AE` → `01`) — periodic, payload `00 2B 0D 4x `: `b2..b3` ≈ 0x0D46–47 → /256 = **13.27 V ⇒ - battery voltage**, last byte looks like a checksum. (BLE read 13.09 V the - same day; charger float plausible.) Note the *source* being `7D`/`AE` - suggests those modules carry the battery-sense wire, not the controller. + 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` -(`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` +(`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 " # module: random 4-byte challenge -01 → node page43 "00 04 " # controller: correct response -node → 01 page43 "00 04" # module: ack -01 → node 0x0006 ×3 # the actual command (now honored) +01 → node page42 "00 04" # controller requests a challenge +node → 01 page42 "00 04 " # module returns a 4-byte challenge +01 → node page43 "00 04 " # controller returns the matching response +node → 01 page43 "00 04" # module acknowledges +01 → node 0x0006 ×3 # command (now acted on) 01 → node page45 / node → 01 page45 # post-status (00, then 0E) ``` -The challenge is **fresh 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 `). 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 ` 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: -- CoachProxy / coachproxyos (open RV-C decode prior art) -- `rvc2mqtt`, `rvc-monitor` (DGN→MQTT mappings to crib) -- Lippert OneControl (RV-C): -- BLE-side protocol (this repo): `../docs/PROTOCOL_FINDINGS.md` +- `ids_can_auth.py` — IDS-CAN command authentication (response computation + self-test) +- `idscan_cmd.py` — socketcan command tool (the full exchange, proven on node F8) +- Lippert OneControl: +- RV-C background (for contrast — this bus is **not** RV-C): +- Bluetooth-side protocol notes (this repo): `../docs/PROTOCOL_FINDINGS.md` diff --git a/canbus/sniff/2A-auth-pairs.txt b/canbus/captures/2A-auth-pairs.txt similarity index 100% rename from canbus/sniff/2A-auth-pairs.txt rename to canbus/captures/2A-auth-pairs.txt diff --git a/canbus/sniff/analyze_auth.py b/canbus/captures/analyze_auth.py similarity index 100% rename from canbus/sniff/analyze_auth.py rename to canbus/captures/analyze_auth.py diff --git a/canbus/sniff/app-commands-2026-06-11_230059.log b/canbus/captures/app-commands-2026-06-11_230059.log similarity index 100% rename from canbus/sniff/app-commands-2026-06-11_230059.log rename to canbus/captures/app-commands-2026-06-11_230059.log diff --git a/canbus/sniff/auth-pairs-multinode-2026-06-11.txt b/canbus/captures/auth-pairs-multinode-2026-06-11.txt similarity index 100% rename from canbus/sniff/auth-pairs-multinode-2026-06-11.txt rename to canbus/captures/auth-pairs-multinode-2026-06-11.txt diff --git a/canbus/sniff/baseline-2026-06-11_223823.log b/canbus/captures/baseline-2026-06-11_223823.log similarity index 100% rename from canbus/sniff/baseline-2026-06-11_223823.log rename to canbus/captures/baseline-2026-06-11_223823.log diff --git a/canbus/sniff/extlight-authpairs-2026-06-11_231227.log b/canbus/captures/extlight-authpairs-2026-06-11_231227.log similarity index 100% rename from canbus/sniff/extlight-authpairs-2026-06-11_231227.log rename to canbus/captures/extlight-authpairs-2026-06-11_231227.log diff --git a/canbus/sniff/log-can.sh b/canbus/captures/log-can.sh similarity index 59% rename from canbus/sniff/log-can.sh rename to canbus/captures/log-can.sh index de4aee2..1e8b70e 100755 --- a/canbus/sniff/log-can.sh +++ b/canbus/captures/log-can.sh @@ -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-.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 diff --git a/canbus/sniff/pump-awning-2026-06-11_225552.log b/canbus/captures/pump-awning-2026-06-11_225552.log similarity index 100% rename from canbus/sniff/pump-awning-2026-06-11_225552.log rename to canbus/captures/pump-awning-2026-06-11_225552.log diff --git a/canbus/sniff/toggletest-2026-06-11_224019.log b/canbus/captures/toggletest-2026-06-11_224019.log similarity index 100% rename from canbus/sniff/toggletest-2026-06-11_224019.log rename to canbus/captures/toggletest-2026-06-11_224019.log diff --git a/canbus/esphome/ids_can_auth.h b/canbus/esphome/ids_can_auth.h index b7fbcbb..766f0c8 100644 --- a/canbus/esphome/ids_can_auth.h +++ b/canbus/esphome/ids_can_auth.h @@ -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, diff --git a/canbus/esphome/onecontrol-canbus.yaml b/canbus/esphome/onecontrol-canbus.yaml index aa910b3..12d4398 100644 --- a/canbus/esphome/onecontrol-canbus.yaml +++ b/canbus/esphome/onecontrol-canbus.yaml @@ -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 0x000443, payload 00 04 RR RR RR RR + uint32_t resp_id = 0x00040043u | ((uint32_t) id(g_cmd_node) << 8); + std::vector 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, 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 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 0x000442, payload 00 04 + uint32_t req_id = 0x00040042u | ((uint32_t) id(g_cmd_node) << 8); + std::vector 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. diff --git a/canbus/ids_can_auth.py b/canbus/ids_can_auth.py index 782ca25..cc2dd11 100644 --- a/canbus/ids_can_auth.py +++ b/canbus/ids_can_auth.py @@ -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 " # module's challenge 01->node page43 DLC6 "00 04 " # RR = remote_control_response(CC) node->01 page43 DLC2 "00 04" # ack - 01->node 0x0006 x3 # opcode now honored (01=on,00=off,02=retract) + 01->node 0x0006 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): diff --git a/canbus/idscan_cmd.py b/canbus/idscan_cmd.py new file mode 100644 index 0000000..6ac4510 --- /dev/null +++ b/canbus/idscan_cmd.py @@ -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 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 + + 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)