Per policy, the firmware must never command a slide or jack. command_guard.h adds command_blocked(node, type): refuses any motor-class (0x21) node except the awning (0x75), with an explicit slide/jack denylist (6A/7F/9C) that holds even before a node's page-2 identity is observed. Enforced in two independent places — the command-entry script (send_load_command) and the actual transmit point (on_frame, right before TX) — so loosening one can't open the other. Adding a slide/jack switch entity cannot actuate it. Node device class is learned from page-2 broadcasts (g_node_type). Predicate unit-tested 9/9 (host g++). Switch comments + HANDOFF safety notes updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6.2 KiB
6.2 KiB
Handoff — finish & flash the OneControl IDS-CAN node
Goal: complete and flash an ESP32 firmware that puts our RV's Lippert
OneControl system (tanks, lights, switches) into Home Assistant as native
entities, talking directly to the OneControl CAN network. This replaces the
slower Bluetooth integration in ../src/ + ../custom_components/.
Read README.md first — it has the full message-format documentation, the node
map for this coach, and the wiring procedure. This file is just the current
state + what's left.
Current status (2026-06-12)
- Message format documented and confirmed against live captures. The bus is
Lippert IDS-CAN (250 kbit/s, 11-bit IDs), not RV-C. Every tank, light,
pump, heater, the awning, and battery voltage is decoded — see the node map in
README.md. - Read path: modules broadcast their state continuously, no authentication.
- Command path: each command is preceded by a short challenge/response
authentication exchange (the same one the OEM app uses). Implemented in
ids_can_auth.py(reference) andesphome/ids_can_auth.h(the firmware copy, verified bit-exact against the Python and against captured/live values). - Proven working end to end.
idscan_cmd.pyran the full exchange over a USB CAN adapter and operated the interior lights (nodeF8) on/off/on, each answering a distinct fresh challenge, with the module's own status broadcast confirming the result. A bare command with no exchange is ignored. - Firmware compiled successfully last night (
esphome/.esphome/build/…).
Hardware (assembly-ready)
- ESP32 WROOM devboard (
esp32dev) + Waveshare SN65HVD230 transceiver (3.3 V logic, onboard 120 Ω terminator → this node is the bus-END node). - Power: buck converter dialed to 5 V, tapped from the panel's 12 V supply (common ground with the bus — good). Feed the ESP32 5 V pin.
- Connection: Molex Mini-Fit Jr. pigtail into the panel's CAN data port
(the one with the terminator), per
README.md→ "Physical connection". ⚠️ Meter the port first: data idles ~2.5 V, power reads ~12 V; 12 V on CAN-H/L destroys the transceiver. - GPIO: transceiver
CTX/D← ESP32GPIO5(tx_pin),CRX/R→GPIO4(rx_pin). Adjustsubstitutions:in the YAML if you wire differently.
Remaining work (in order)
secrets.yaml— copyesphome/secrets.yaml.example, fill in WiFi, the ESPHomeapi_key, and the fallback-AP password. It's git-ignored.- First flash over USB:
esphome run esphome/onecontrol-canbus.yaml(pick the serial port). OTA after that. Mirrors the gazebo-fan-proxy workflow. - Confirm the read entities populate in HA once it's on the bus: the four
tanks and the two light switches publish from the page-3 broadcasts. Watch the
DEBUG frame dump (
logger: level: DEBUG) to confirm frames are decoded; drop to INFO when happy. - Finish the two open read items in the YAML lambda:
- Battery voltage — rides a 29-bit telemetry frame (src
7D/AE, page0x11), bytes 2–3 big-endian / 256 = volts. Match that frame and publishbattery_voltage. (See README "29-bit extended frames".) - Optionally add water pump (
61) and water heater (95) — both are ordinary switched loads, same decode + command path as the lights.
- Battery voltage — rides a 29-bit telemetry frame (src
- Verify the command path from HA: toggle Interior/Exterior Lights. The
send_load_commandscript +on_framehandler do the exchange and send the opcode. The node allowlist is lights only — keep movement nodes (awning/slides/jacks, type0x21) off the switch list until a careful attended first test. - (Optional) Surface at the campsite HA + bridge home like the gazebo fans / OneControl BLE devices, if you want these in the home dashboard too.
The DSI fault is already decoded and wired in (see below) — no capture needed.
File map
| File | What it is |
|---|---|
esphome/onecontrol-canbus.yaml |
the ESP32 firmware (read dispatch + command path) — the thing to finish & flash |
esphome/ids_can_auth.h |
command-authentication response, used by the YAML lambda |
esphome/secrets.yaml.example |
template for the git-ignored secrets |
ids_can_auth.py |
Python reference for the same authentication + 51/51 self-test |
idscan_cmd.py |
desktop tool that proved the command path over a USB CAN adapter |
captures/ |
raw bus logs + the challenge/response pairs + analyze_auth.py |
captures/log-can.sh |
bring up the USB CAN adapter and log frames |
README.md |
full message-format documentation + node map + wiring |
Safety notes
- SLIDES AND JACKS ARE CONTROL-PANEL ONLY — never over CAN. This is a hard
rule, do not weaken it.
esphome/command_guard.h(command_blocked()) is the single source of truth: it refuses any motor-class (0x21) node other than the awning, via an explicit slide/jack denylist (6A/7F/9C, effective even before a node's identity is heard) and a general class check. It's enforced in two independent places — the command-entry script and the actual transmit point — so loosening one does not open the other. Adding a slide/jack switch entity cannot actuate it; the gate drops the command before any frame goes out. - Command path is otherwise lights-only by allowlist. The awning is permitted
by the safety gate but intentionally left unwired (operate it from the panel).
To expose any new switched load, add its node to the layer-2 allowlist in
send_load_command; never add a slide/jack. - The physical connection is fully reversible: unplug, re-seat the terminator.
- One transceiver = one bus-end terminator. Never add a terminated node in the middle of the bus (would make three terminators).
DSI fault — DECODED (2026-06-12)
Forced a real lockout and captured it (captures/dsi-fault-*.log). Already wired
into the YAML as two binary_sensors; nothing left to do here.
- Water Heater DSI Fault = node
95page-3b0bit5 (0x20). Healthy heater =0x80off /0x81running; lockout =0xA0. - OneControl System Fault = page-0
b0bit0 (0x01), a bus-wide "fault exists somewhere" flag (read from node95's page 0 in the lambda).