From cda537da29ac2f46ca51bd7d1a451e45466e8963 Mon Sep 17 00:00:00 2001 From: Wesley Ray Date: Fri, 12 Jun 2026 11:40:54 -0400 Subject: [PATCH] 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 --- canbus/HANDOFF.md | 44 +++++++++------ canbus/esphome/command_guard.h | 18 ++++-- canbus/esphome/onecontrol-canbus.yaml | 79 +++++++++++++++++++++------ 3 files changed, 100 insertions(+), 41 deletions(-) diff --git a/canbus/HANDOFF.md b/canbus/HANDOFF.md index fc79388..8e87aee 100644 --- a/canbus/HANDOFF.md +++ b/canbus/HANDOFF.md @@ -55,12 +55,16 @@ state + what's left. publish `battery_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. -5. **Verify the command path** from HA: toggle Interior/Exterior Lights. The - `send_load_command` script + `on_frame` handler do the exchange and send the - opcode. The node allowlist is **lights only** — keep movement nodes - (awning/slides/jacks, type `0x21`) off the switch list until a careful - attended first test. -6. **(Optional) Surface at the campsite HA + bridge home** like the gazebo fans / +5. **Verify the command path** from HA: toggle Interior/Exterior Lights and the + Water Heater. The `send_load_command` script + `on_frame` handler do the + exchange and send the opcode. +6. **Attended awning test (motor — watch it move).** The Awning `cover` is wired + (open/close/stop). On the first run, confirm: (a) which direction open/close + 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. 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 -- **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. +- **SLIDES, JACKS, and the WATER PUMP ARE PANEL/APP 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 the slide/jack + nodes (6A/7F/9C, effective even before a node's identity is heard), the water + pump (61, winterizing-only), and — generally — any motor-class (0x21) node other + than the awning. It's enforced in two independent places (the command-entry + script and the actual transmit point), so loosening one does not open the other. + Wiring a switch for a blocked node cannot actuate it; the gate drops the command + before any frame goes out. +- **Exposed (controllable) loads:** exterior lights (2A), interior lights (F8), + water heater (95) as `switch`es, and the awning (75) as a `cover`. To expose + 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. - One transceiver = one bus-end terminator. Never add a terminated node in the middle of the bus (would make three terminators). diff --git a/canbus/esphome/command_guard.h b/canbus/esphome/command_guard.h index a03c586..7be8d77 100644 --- a/canbus/esphome/command_guard.h +++ b/canbus/esphome/command_guard.h @@ -1,17 +1,23 @@ #pragma once // 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 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 // transmit point), so loosening one does not open the other. // // What it blocks: -// * an explicit denylist of this rig's known slide / jack nodes (6A/7F/9C) — -// always blocked, even before we've heard their identity broadcast; +// * slides / jacks — never automate. Explicit denylist of this rig's nodes +// (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 -// (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 // yet observed. The static denylist covers the not-yet-observed window. @@ -21,8 +27,10 @@ namespace onecontrol { 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; + // Water pump — panel/app only (winterizing). + if (node == 0x61) return true; // Any movement-class (0x21) node except the awning (0x75). if (node_type == 0x21 && node != 0x75) return true; return false; diff --git a/canbus/esphome/onecontrol-canbus.yaml b/canbus/esphome/onecontrol-canbus.yaml index ac30e5a..62e2999 100644 --- a/canbus/esphome/onecontrol-canbus.yaml +++ b/canbus/esphome/onecontrol-canbus.yaml @@ -157,8 +157,18 @@ canbus: // water heater (node 95): b0 bit0 = on, bit5 (0x20) = DSI/gas // ignition fault/lockout (healthy: 0x80 off / 0x81 running; // fault: 0xA0). Confirmed by a forced lockout 2026-06-12. - case 0x95: id(dsi_fault).publish_state(x[0] & 0x20); break; - // TODO: water pump (61) on/off once exposed below. + case 0x95: id(water_heater).publish_state(x[0] & 0x01); + 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; return; } - // SAFETY, layer 2 — current scope is lights only. Only the allowlisted - // switched loads (2A ext lights, F8 int lights) may be commanded. To add - // the awning later, add 0x75 here; the motor gate above already permits - // it. Do NOT add slide/jack nodes — the gate would refuse them anyway. - if (node != 0x2A && node != 0xF8) { - ESP_LOGW("idscan", "refusing command to non-allowlisted node %02X (lights only)", node); + // SAFETY, layer 2 — exposed-entity allowlist. Nodes wired to entities: + // 2A ext lights, F8 int lights, 95 water heater, 75 awning (cover). Do + // NOT add slide/jack/pump nodes — the gate above refuses them regardless. + if (node != 0x2A && node != 0xF8 && node != 0x95 && node != 0x75) { + ESP_LOGW("idscan", "refusing command to non-allowlisted node %02X", node); id(g_cmd_pending) = false; return; } @@ -303,14 +312,13 @@ binary_sensor: # 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. +# Exposed switched loads: exterior lights (2A), interior lights (F8), water +# 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 -# (command_guard.h): the command path refuses any motor-class (0x21) node other -# than the awning, enforced both here and at the transmit point, so even adding a -# slide/jack switch below cannot actuate it. The awning is technically permitted -# by the gate but is intentionally left unwired (operate it from the panel too). +# SLIDES, JACKS, and the WATER PUMP ARE PANEL/APP ONLY — never over CAN. Hard +# policy in command_guard.h, enforced both here and at the transmit point, so even +# adding a switch for one below cannot actuate it. The awning is permitted by the +# gate but needs a proper cover entity + an attended first test before it's wired. # 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. # --------------------------------------------------------------------------- @@ -333,6 +341,41 @@ switch: turn_off_action: - script.execute: { id: send_load_command, node: 0xF8, op: 0 } - # 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. + - platform: template + name: "Water Heater" + 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 }