From f05203b9e38c2eeb3719c52f257d40f1775f7c47 Mon Sep 17 00:00:00 2001 From: Wesley Ray Date: Fri, 12 Jun 2026 11:34:21 -0400 Subject: [PATCH] =?UTF-8?q?canbus:=20hard=20safety=20gate=20=E2=80=94=20sl?= =?UTF-8?q?ides/jacks=20are=20control-panel=20only,=20never=20over=20CAN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- canbus/HANDOFF.md | 15 +++++-- canbus/esphome/command_guard.h | 31 +++++++++++++++ canbus/esphome/onecontrol-canbus.yaml | 57 +++++++++++++++++++++++---- 3 files changed, 93 insertions(+), 10 deletions(-) create mode 100644 canbus/esphome/command_guard.h diff --git a/canbus/HANDOFF.md b/canbus/HANDOFF.md index 0a64237..fc79388 100644 --- a/canbus/HANDOFF.md +++ b/canbus/HANDOFF.md @@ -80,9 +80,18 @@ The DSI fault is already decoded and wired in (see below) — no capture needed. ## Safety notes -- **Command path is lights-only by allowlist.** Movement nodes use the same - authentication but are untested from this node — don't add them until you can - watch the motor on the first actuation. +- **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). diff --git a/canbus/esphome/command_guard.h b/canbus/esphome/command_guard.h new file mode 100644 index 0000000..a03c586 --- /dev/null +++ b/canbus/esphome/command_guard.h @@ -0,0 +1,31 @@ +#pragma once +// Command-path safety policy for the OneControl integration. +// +// POLICY: slides and jacks are operated from the physical control panel 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; +// * 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". +// +// 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. + +#include + +namespace onecontrol { + +inline bool command_blocked(uint8_t node, uint8_t node_type) { + // Known slide / jack / movement-class nodes on this rig — always refused. + if (node == 0x6A || node == 0x7F || node == 0x9C) return true; + // Any movement-class (0x21) node except the awning (0x75). + if (node_type == 0x21 && node != 0x75) return true; + return false; +} + +} // namespace onecontrol diff --git a/canbus/esphome/onecontrol-canbus.yaml b/canbus/esphome/onecontrol-canbus.yaml index 9e49446..ac30e5a 100644 --- a/canbus/esphome/onecontrol-canbus.yaml +++ b/canbus/esphome/onecontrol-canbus.yaml @@ -22,10 +22,12 @@ 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. + # Command authentication (REMOTE_CONTROL session, key 0xB16B00B5) provides + # ids_can_auth::remote_control_response_bytes(); command_guard.h provides + # onecontrol::command_blocked() — the slides/jacks-are-panel-only safety rule. includes: - ids_can_auth.h + - command_guard.h esp32: board: esp32dev # classic WROOM-32 @@ -88,6 +90,19 @@ canbus: 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) { + // HARD SAFETY GATE (authoritative). This is the only place a + // command is ever transmitted, so the slides/jacks-are-panel- + // only rule is enforced here, right before TX, regardless of how + // the command was queued. If blocked: send nothing — no + // response, no opcode — and clear the pending command. + uint8_t n = id(g_cmd_node); + if (onecontrol::command_blocked(n, id(g_node_type)[n])) { + ESP_LOGE("idscan", "SAFETY: refusing command to motor node %02X " + "(slides/jacks are control-panel only)", n); + id(g_cmd_pending) = false; + return; + } + uint8_t resp[4]; ids_can_auth::remote_control_response_bytes(&x[2], resp); @@ -113,6 +128,13 @@ canbus: uint8_t page = (id >> 8) & 0xFF; uint8_t node = id & 0xFF; + // Track each node's device class from its page-2 identity broadcast + // (x[3] = type byte). The safety gate uses this to refuse any + // motor-class (0x21) node that isn't the awning. + if (page == 2 && x.size() >= 4) { + id(g_node_type)[node] = x[3]; + } + // Frame dump — comment out once the map is trustworthy. ESP_LOGD("idscan", "page=%u node=%02X len=%u %02X %02X %02X %02X %02X %02X", page, node, x.size(), @@ -169,6 +191,11 @@ globals: - id: g_cmd_pending type: bool initial_value: 'false' + # Device class per node, learned from page-2 identity broadcasts (0 = not yet + # seen). Feeds the motor-output safety gate in command_guard.h. + - id: g_node_type + type: 'std::array' + initial_value: '{}' script: - id: send_load_command @@ -179,9 +206,20 @@ script: 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). + // SAFETY, layer 1 — motor-output gate. Slides/jacks are control-panel + // only; refuse any motor node that isn't the awning. Same rule the + // transmit point enforces (command_guard.h), checked here too so a + // blocked command never even starts the exchange. + if (onecontrol::command_blocked((uint8_t) node, id(g_node_type)[(uint8_t) node])) { + ESP_LOGE("idscan", "SAFETY: refusing command to motor node %02X " + "(slides/jacks are control-panel only)", (uint8_t) node); + 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); id(g_cmd_pending) = false; @@ -266,8 +304,13 @@ binary_sensor: # 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. +# lights, F8 = interior lights) may be wired here. +# +# 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). # 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. # ---------------------------------------------------------------------------