canbus: hard safety gate — slides/jacks are control-panel only, never over CAN

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>
This commit is contained in:
wes
2026-06-12 11:34:21 -04:00
co-authored by Claude Opus 4.8
parent d57c2b21d2
commit f05203b9e3
3 changed files with 93 additions and 10 deletions
+31
View File
@@ -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 <cstdint>
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
+50 -7
View File
@@ -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<uint8_t, 256>'
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.
# ---------------------------------------------------------------------------