canbus: expose water heater + awning; block water pump

Per updated policy:
- Water heater (node 95): new switch entity + on/off state read-back.
- Awning (node 75): new cover entity (open=op01 / close=op02 / stop=op00), with
  current_operation published from the page-3 motion byte (C2/C3/C0). First
  actuation must be attended; single-shot commands can't run the motor away.
- Water pump (node 61): added to command_guard denylist (winterizing-only,
  panel/app only) alongside slides/jacks. Guard re-tested 8/8 (host g++).

Switch/cover comments + HANDOFF safety notes and remaining-work updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wes
2026-06-12 11:40:54 -04:00
co-authored by Claude Opus 4.8
parent f05203b9e3
commit cda537da29
3 changed files with 100 additions and 41 deletions
+26 -18
View File
@@ -55,12 +55,16 @@ state + what's left.
publish `battery_voltage`. (See README "29-bit extended frames".) publish `battery_voltage`. (See README "29-bit extended frames".)
- Optionally add **water pump (`61`)** and **water heater (`95`)** — both are - Optionally add **water pump (`61`)** and **water heater (`95`)** — both are
ordinary switched loads, same decode + command path as the lights. ordinary switched loads, same decode + command path as the lights.
5. **Verify the command path** from HA: toggle Interior/Exterior Lights. The 5. **Verify the command path** from HA: toggle Interior/Exterior Lights and the
`send_load_command` script + `on_frame` handler do the exchange and send the Water Heater. The `send_load_command` script + `on_frame` handler do the
opcode. The node allowlist is **lights only** — keep movement nodes exchange and send the opcode.
(awning/slides/jacks, type `0x21`) off the switch list until a careful 6. **Attended awning test (motor — watch it move).** The Awning `cover` is wired
attended first test. (open/close/stop). On the first run, confirm: (a) which direction open/close
6. **(Optional) Surface at the campsite HA + bridge home** like the gazebo fans / actually go, (b) whether one command runs to the travel limit or only moves
while commands stream. If it under-travels, change `send_load_command` to
stream the opcode (repeat until Stop) — only after watching it. The single-shot
default can't run the motor away.
7. **(Optional) Surface at the campsite HA + bridge home** like the gazebo fans /
OneControl BLE devices, if you want these in the home dashboard too. 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. The DSI fault is already decoded and wired in (see below) — no capture needed.
@@ -80,18 +84,22 @@ The DSI fault is already decoded and wired in (see below) — no capture needed.
## Safety notes ## Safety notes
- **SLIDES AND JACKS ARE CONTROL-PANEL ONLY — never over CAN. This is a hard - **SLIDES, JACKS, and the WATER PUMP ARE PANEL/APP ONLY — never over CAN. This
rule, do not weaken it.** `esphome/command_guard.h` (`command_blocked()`) is the is a hard rule, do not weaken it.** `esphome/command_guard.h`
single source of truth: it refuses any motor-class (0x21) node other than the (`command_blocked()`) is the single source of truth: it refuses the slide/jack
awning, via an explicit slide/jack denylist (6A/7F/9C, effective even before a nodes (6A/7F/9C, effective even before a node's identity is heard), the water
node's identity is heard) *and* a general class check. It's enforced in two pump (61, winterizing-only), and — generally — any motor-class (0x21) node other
independent places the command-entry script and the actual transmit point — than the awning. It's enforced in two independent places (the command-entry
so loosening one does not open the other. Adding a slide/jack switch entity script and the actual transmit point), so loosening one does not open the other.
cannot actuate it; the gate drops the command before any frame goes out. Wiring a switch for a blocked node cannot actuate it; the gate drops the command
- **Command path is otherwise lights-only by allowlist.** The awning is permitted before any frame goes out.
by the safety gate but intentionally left unwired (operate it from the panel). - **Exposed (controllable) loads:** exterior lights (2A), interior lights (F8),
To expose any new switched load, add its node to the layer-2 allowlist in water heater (95) as `switch`es, and the awning (75) as a `cover`. To expose
`send_load_command`; never add a slide/jack. another *permitted* switched load, add its node to the layer-2 allowlist in
`send_load_command` and add a `switch` entity; never add a slide/jack/pump.
- **The awning is a motor — its first actuation must be attended** (see
remaining-work item 6). The wired commands are single-shot, which can't run the
motor away, but direction and latch-vs-stream behavior need a live check.
- The physical connection is fully reversible: unplug, re-seat the terminator. - The physical connection is fully reversible: unplug, re-seat the terminator.
- One transceiver = one bus-end terminator. Never add a terminated node in the - One transceiver = one bus-end terminator. Never add a terminated node in the
middle of the bus (would make three terminators). middle of the bus (would make three terminators).
+13 -5
View File
@@ -1,17 +1,23 @@
#pragma once #pragma once
// Command-path safety policy for the OneControl integration. // Command-path safety policy for the OneControl integration.
// //
// POLICY: slides and jacks are operated from the physical control panel ONLY, // POLICY: some loads are operated from the physical control panel / OEM app ONLY,
// never over CAN. command_blocked() returns true for any node this firmware must // never over CAN. command_blocked() returns true for any node this firmware must
// never send a command to. It is the single source of truth for that rule and is // never send a command to. It is the single source of truth for that rule and is
// checked in two independent places (the command-entry script and the actual // checked in two independent places (the command-entry script and the actual
// transmit point), so loosening one does not open the other. // transmit point), so loosening one does not open the other.
// //
// What it blocks: // What it blocks:
// * an explicit denylist of this rig's known slide / jack nodes (6A/7F/9C) — // * slides / jacks — never automate. Explicit denylist of this rig's nodes
// always blocked, even before we've heard their identity broadcast; // (6A/7F/9C), always blocked, even before we've heard their identity;
// * the water pump (61) — winterizing-only; operate it from the panel/app so it
// can't be left running (e.g. dry) by accident;
// * any movement/motor-class node (device class 0x21) other than the awning // * any movement/motor-class node (device class 0x21) other than the awning
// (0x75) — generalizes the rule to "any motor output that isn't the awning". // (0x75) — generalizes the slide/jack rule to "any motor that isn't the awning".
//
// Permitted (NOT blocked here): lights, water heater, and the awning. Whether a
// permitted node is actually exposed as an entity is a separate allowlist in the
// command-entry script — this gate only enforces the never-allowed set.
// //
// node_type is the page-2 identity byte (device class) for the node, or 0 if not // node_type is the page-2 identity byte (device class) for the node, or 0 if not
// yet observed. The static denylist covers the not-yet-observed window. // yet observed. The static denylist covers the not-yet-observed window.
@@ -21,8 +27,10 @@
namespace onecontrol { namespace onecontrol {
inline bool command_blocked(uint8_t node, uint8_t node_type) { inline bool command_blocked(uint8_t node, uint8_t node_type) {
// Known slide / jack / movement-class nodes on this rig — always refused. // Slide / jack / movement-class nodes on this rig — always refused.
if (node == 0x6A || node == 0x7F || node == 0x9C) return true; if (node == 0x6A || node == 0x7F || node == 0x9C) return true;
// Water pump — panel/app only (winterizing).
if (node == 0x61) return true;
// Any movement-class (0x21) node except the awning (0x75). // Any movement-class (0x21) node except the awning (0x75).
if (node_type == 0x21 && node != 0x75) return true; if (node_type == 0x21 && node != 0x75) return true;
return false; return false;
+61 -18
View File
@@ -157,8 +157,18 @@ canbus:
// water heater (node 95): b0 bit0 = on, bit5 (0x20) = DSI/gas // water heater (node 95): b0 bit0 = on, bit5 (0x20) = DSI/gas
// ignition fault/lockout (healthy: 0x80 off / 0x81 running; // ignition fault/lockout (healthy: 0x80 off / 0x81 running;
// fault: 0xA0). Confirmed by a forced lockout 2026-06-12. // fault: 0xA0). Confirmed by a forced lockout 2026-06-12.
case 0x95: id(dsi_fault).publish_state(x[0] & 0x20); break; case 0x95: id(water_heater).publish_state(x[0] & 0x01);
// TODO: water pump (61) on/off once exposed below. id(dsi_fault).publish_state(x[0] & 0x20); break;
// awning H-bridge (node 75): b0 C0 idle / C2 extending (opening)
// / C3 retracting (closing). Reflect motion onto the cover so HA
// shows open/opening/closing; assumed_state fills the resting pos.
case 0x75:
if (x[0] == 0xC2) id(awning).current_operation = COVER_OPERATION_IS_OPENING;
else if (x[0] == 0xC3) id(awning).current_operation = COVER_OPERATION_IS_CLOSING;
else id(awning).current_operation = COVER_OPERATION_IDLE;
id(awning).publish_state();
break;
// (water pump 61 is command-blocked — read-only, not exposed.)
} }
} }
@@ -216,12 +226,11 @@ script:
id(g_cmd_pending) = false; id(g_cmd_pending) = false;
return; return;
} }
// SAFETY, layer 2 — current scope is lights only. Only the allowlisted // SAFETY, layer 2 — exposed-entity allowlist. Nodes wired to entities:
// switched loads (2A ext lights, F8 int lights) may be commanded. To add // 2A ext lights, F8 int lights, 95 water heater, 75 awning (cover). Do
// the awning later, add 0x75 here; the motor gate above already permits // NOT add slide/jack/pump nodes — the gate above refuses them regardless.
// it. Do NOT add slide/jack nodes — the gate would refuse them anyway. if (node != 0x2A && node != 0xF8 && node != 0x95 && node != 0x75) {
if (node != 0x2A && node != 0xF8) { ESP_LOGW("idscan", "refusing command to non-allowlisted node %02X", node);
ESP_LOGW("idscan", "refusing command to non-allowlisted node %02X (lights only)", node);
id(g_cmd_pending) = false; id(g_cmd_pending) = false;
return; return;
} }
@@ -303,14 +312,13 @@ binary_sensor:
# Each turn_on/off queues send_load_command for the load's node; the on_frame # 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. # handler completes the page-42/43 challenge/response and sends the opcode.
# #
# SAFETY: lights only. Only the allowlisted switched-load nodes (2A = exterior # Exposed switched loads: exterior lights (2A), interior lights (F8), water
# lights, F8 = interior lights) may be wired here. # heater (95). Each must be in the layer-2 allowlist in send_load_command.
# #
# SLIDES AND JACKS ARE CONTROL-PANEL ONLY — never over CAN. This is a hard policy # SLIDES, JACKS, and the WATER PUMP ARE PANEL/APP ONLY — never over CAN. Hard
# (command_guard.h): the command path refuses any motor-class (0x21) node other # policy in command_guard.h, enforced both here and at the transmit point, so even
# than the awning, enforced both here and at the transmit point, so even adding a # adding a switch for one below cannot actuate it. The awning is permitted by the
# slide/jack switch below cannot actuate it. The awning is technically permitted # gate but needs a proper cover entity + an attended first test before it's wired.
# by the gate but is intentionally left unwired (operate it from the panel too).
# optimistic:true for now — the page-3 (b0 bit0) read-back above already publishes # 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. # true module state, so these can switch to optimistic:false once verified.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -333,6 +341,41 @@ switch:
turn_off_action: turn_off_action:
- script.execute: { id: send_load_command, node: 0xF8, op: 0 } - script.execute: { id: send_load_command, node: 0xF8, op: 0 }
# water pump (61) / water heater (95) are also type-0x1E switched loads and use - platform: template
# the same authentication — add them here once you want them in HA. Movement name: "Water Heater"
# nodes stay off this list until tested. id: water_heater
optimistic: true
turn_on_action:
- script.execute: { id: send_load_command, node: 0x95, op: 1 }
turn_off_action:
- script.execute: { id: send_load_command, node: 0x95, op: 0 }
# Slides/jacks/pump are command-blocked and must never be wired here.
# ---------------------------------------------------------------------------
# Awning (node 75) — open / close / stop cover.
#
# H-bridge motor: open = extend (op 01), close = retract (op 02), stop (op 00),
# per the captures. assumed_state -> HA shows discrete open/close/stop controls
# (no position slider); current_operation is published from the page-3 motion
# byte in the dispatcher above.
#
# ⚠️ FIRST ACTUATION MUST BE ATTENDED. We haven't confirmed whether one command
# latches the motor (runs to the travel limit) or is hold-to-run (the OEM app
# streamed repeats, which hints at hold-to-run). The single-shot command here is
# the safe default: if hold-to-run, the awning just moves a little and stops on
# its own — it can't run away. If it under-travels, stream the command (repeat
# send_load_command until stop) — do that change only after watching it move.
# ---------------------------------------------------------------------------
cover:
- platform: template
name: "Awning"
id: awning
device_class: awning
assumed_state: true
open_action:
- script.execute: { id: send_load_command, node: 0x75, op: 1 }
close_action:
- script.execute: { id: send_load_command, node: 0x75, op: 2 }
stop_action:
- script.execute: { id: send_load_command, node: 0x75, op: 0 }