canbus: battery decode, dual on_frame triggers, ground-truth switches

Flash-session firmware work, live on the node since 2026-06-12:
- battery voltage decode (29-bit page-0x11 telemetry, b2..b3 BE / 256),
  gated to extended frames so an 11-bit node 0x11 can never spoof it,
  with a delta/throttle filter to stop 1/256-V jitter churning both
  HA recorders and the MQTT bridge
- second on_frame trigger (use_extended_id: false) sharing the decode
  lambda via YAML anchor — this esp32_can build filters triggers by
  frame type, so a single trigger silently dropped the 11-bit reads
- switches optimistic:false now the page-3 read-back is verified live
- arm retry widened to 8x150ms; module-side ~2s post-success cooldown
  documented
- canbus component logs to INFO (per-frame DEBUG dump saturated serial)
- toolchain fixes: named std::array initializer, namespaced cover enums

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wes
2026-06-12 15:32:12 -04:00
co-authored by Claude Fable 5
parent 81bd58206d
commit e8b2447d62
+69 -23
View File
@@ -44,8 +44,14 @@ wifi:
captive_portal: captive_portal:
logger: logger:
level: DEBUG # DEBUG so the on_frame ESP_LOGD frame dump is visible level: DEBUG # global DEBUG so entity "Sending state" publishes and
# during bring-up. Drop to INFO once the map is solid. # the command exchange (idscan) are visible during
# bring-up. Drop the whole thing to INFO once happy.
logs:
canbus: INFO # the esp32_can component logs EVERY received frame at
# DEBUG (~50/s) — that floods the 115200 serial link and
# starves wifi/api/sensor logs. Silence it; our decode
# is what we care about, not the raw component dump.
api: api:
encryption: encryption:
@@ -71,11 +77,17 @@ canbus:
bit_rate: 250kbps # IDS-CAN is 250k bit_rate: 250kbps # IDS-CAN is 250k
can_id: 0 # our own TX id (only matters when we send) can_id: 0 # our own TX id (only matters when we send)
use_extended_id: true # commands use 29-bit IDs use_extended_id: true # commands use 29-bit IDs
# This ESPHome build filters on_frame by frame type, so a single trigger only
# ever fires for one kind. We need BOTH: read broadcasts (tanks/lights/heater/
# awning) are 11-bit *standard* frames; the command challenge + battery
# telemetry are 29-bit *extended* frames. Two triggers, one shared lambda
# (YAML anchor) — the lambda branches on the id itself, so it's correct for
# either frame type.
on_frame: on_frame:
- can_id: 0 - can_id: 0
can_id_mask: 0 # accept every frame, dispatch in the lambda can_id_mask: 0 # accept every frame, dispatch in the lambda
use_extended_id: true use_extended_id: true
then: then: &decode_frame
- lambda: |- - lambda: |-
// `can_id` and `x` (data bytes) are provided by the trigger. // `can_id` and `x` (data bytes) are provided by the trigger.
uint32_t id = can_id; uint32_t id = can_id;
@@ -135,11 +147,14 @@ canbus:
id(g_node_type)[node] = x[3]; id(g_node_type)[node] = x[3];
} }
// Frame dump — comment out once the map is trustworthy. // Frame dump — confirmed both frame types decode (2026-06-12), now
ESP_LOGD("idscan", "page=%u node=%02X len=%u %02X %02X %02X %02X %02X %02X", // silenced: at DEBUG it logs every frame (~50/s across both
page, node, x.size(), // triggers) and saturates the 115200 serial link. Uncomment to
x.size()>0?x[0]:0, x.size()>1?x[1]:0, x.size()>2?x[2]:0, // re-map the bus.
x.size()>3?x[3]:0, x.size()>4?x[4]:0, x.size()>5?x[5]:0); // ESP_LOGD("idscan", "page=%u node=%02X len=%u %02X %02X %02X %02X %02X %02X",
// page, node, x.size(),
// x.size()>0?x[0]:0, x.size()>1?x[1]:0, x.size()>2?x[2]:0,
// x.size()>3?x[3]:0, x.size()>4?x[4]:0, x.size()>5?x[5]:0);
// page 3 = live value. Layout depends on device class (README): // page 3 = live value. Layout depends on device class (README):
// tanks (type 0x0A): x[0] = level in percent (0x42 = 66%). // tanks (type 0x0A): x[0] = level in percent (0x42 = 66%).
@@ -163,9 +178,9 @@ canbus:
// / C3 retracting (closing). Reflect motion onto the cover so HA // / C3 retracting (closing). Reflect motion onto the cover so HA
// shows open/opening/closing; assumed_state fills the resting pos. // shows open/opening/closing; assumed_state fills the resting pos.
case 0x75: case 0x75:
if (x[0] == 0xC2) id(awning).current_operation = COVER_OPERATION_IS_OPENING; if (x[0] == 0xC2) id(awning).current_operation = esphome::cover::COVER_OPERATION_OPENING;
else if (x[0] == 0xC3) id(awning).current_operation = COVER_OPERATION_IS_CLOSING; else if (x[0] == 0xC3) id(awning).current_operation = esphome::cover::COVER_OPERATION_CLOSING;
else id(awning).current_operation = COVER_OPERATION_IDLE; else id(awning).current_operation = esphome::cover::COVER_OPERATION_IDLE;
id(awning).publish_state(); id(awning).publish_state();
break; break;
// (water pump 61 is command-blocked — read-only, not exposed.) // (water pump 61 is command-blocked — read-only, not exposed.)
@@ -180,8 +195,23 @@ canbus:
} }
// Battery voltage rides 29-bit telemetry frames (src 7D/AE, page // Battery voltage rides 29-bit telemetry frames (src 7D/AE, page
// 0x11), payload 00 2B 0D 4x ..; b2..b3 (BE) / 256 = volts. // 0x11), payload 00 2B 0D 4x <rolling>; b2..b3 (BE) / 256 = volts.
// TODO: match the exact source frame and publish battery_voltage. // The low byte of the 29-bit id is the page (0x11); the "00 2B"
// prefix gates out any other page-0x11 traffic. Match on signature
// rather than the exact source id so either telemetry module
// (7D->FC or AE->01) feeds the same sensor. id > 0x7FF restricts the
// match to 29-bit frames: this lambda also runs for 11-bit broadcasts
// (second trigger), where the low byte is a NODE address — a node
// 0x11 would otherwise spoof the battery reading.
if (id > 0x7FFu && (id & 0xFFu) == 0x11u && x.size() >= 4 && x[0] == 0x00 && x[1] == 0x2B) {
float volts = ((uint16_t) x[2] << 8 | x[3]) / 256.0f;
id(battery_voltage).publish_state(volts);
}
# Second trigger: 11-bit standard frames (the read broadcasts). Same lambda.
- can_id: 0
can_id_mask: 0
use_extended_id: false
then: *decode_frame
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Command path: authenticated "set switched load" # Command path: authenticated "set switched load"
@@ -205,7 +235,10 @@ globals:
# seen). Feeds the motor-output safety gate in command_guard.h. # seen). Feeds the motor-output safety gate in command_guard.h.
- id: g_node_type - id: g_node_type
type: 'std::array<uint8_t, 256>' type: 'std::array<uint8_t, 256>'
initial_value: '{}' # Name the type in the initializer: a bare '{}' is ambiguous between the
# GlobalsComponent<T>(T) and (std::array<...>) constructors under the current
# toolchain. zero-initialized -> every node starts as type 0 (not yet seen).
initial_value: 'std::array<uint8_t, 256>{}'
script: script:
- id: send_load_command - id: send_load_command
@@ -237,11 +270,16 @@ script:
id(g_cmd_node) = (uint8_t) node; id(g_cmd_node) = (uint8_t) node;
id(g_cmd_op) = (uint8_t) op; id(g_cmd_op) = (uint8_t) op;
id(g_cmd_pending) = true; id(g_cmd_pending) = true;
# Send the page-42 request, wait ~100 ms for the challenge; retry up to 3x. # Send the page-42 request, wait ~150 ms for the challenge; retry up to 8x
# (~1.2 s window) to ride out an occasional dropped arm on the busy bus.
# NOTE (confirmed live 2026-06-12): the module imposes a ~2 s cooldown after
# a SUCCESSFUL session — it won't issue a new challenge during it, so a
# second command to the same load <~2 s later is dropped regardless of opcode
# (this is module-side, not a bug; normal HA toggles are spaced far enough).
# The on_frame handler clears g_cmd_pending the moment it answers, so a # The on_frame handler clears g_cmd_pending the moment it answers, so a
# successful exchange short-circuits the remaining iterations. # successful exchange short-circuits the remaining iterations.
- repeat: - repeat:
count: 3 count: 8
then: then:
- if: - if:
condition: condition:
@@ -252,13 +290,13 @@ script:
uint32_t req_id = 0x00040042u | ((uint32_t) id(g_cmd_node) << 8); uint32_t req_id = 0x00040042u | ((uint32_t) id(g_cmd_node) << 8);
std::vector<uint8_t> req = {0x00, 0x04}; std::vector<uint8_t> req = {0x00, 0x04};
id(can_bus).send_data(req_id, true, req); id(can_bus).send_data(req_id, true, req);
- delay: 100ms - delay: 150ms
- if: - if:
condition: condition:
lambda: 'return id(g_cmd_pending);' lambda: 'return id(g_cmd_pending);'
then: then:
- lambda: |- - lambda: |-
ESP_LOGW("idscan", "no page-42 challenge from node %02X after 3 tries; command dropped", ESP_LOGW("idscan", "no page-42 challenge from node %02X after 8 tries; command dropped",
id(g_cmd_node)); id(g_cmd_node));
id(g_cmd_pending) = false; id(g_cmd_pending) = false;
@@ -273,6 +311,13 @@ sensor:
device_class: voltage device_class: voltage
state_class: measurement state_class: measurement
accuracy_decimals: 2 accuracy_decimals: 2
# The raw value jitters at 1/256 V every telemetry frame; unfiltered, that's
# constant state churn into both HA recorders + retained MQTT over the WG
# tunnel. Pass real moves (>0.05 V) immediately, else at most one per minute.
filters:
- or:
- delta: 0.05
- throttle: 60s
- platform: template - platform: template
name: "Fresh Water Tank" name: "Fresh Water Tank"
id: fresh_tank id: fresh_tank
@@ -319,14 +364,15 @@ binary_sensor:
# policy in command_guard.h, enforced both here and at the transmit point, so even # 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 # 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. # 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 # optimistic:false: the page-3 (b0 bit0) read-back above publishes true module
# true module state, so these can switch to optimistic:false once verified. # state (verified live 2026-06-12), so HA shows ground truth — a dropped command
# self-corrects within ~1 s instead of an optimistic echo falsely reporting success.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
switch: switch:
- platform: template - platform: template
name: "Exterior Lights" name: "Exterior Lights"
id: exterior_lights id: exterior_lights
optimistic: true optimistic: false
turn_on_action: turn_on_action:
- script.execute: { id: send_load_command, node: 0x2A, op: 1 } - script.execute: { id: send_load_command, node: 0x2A, op: 1 }
turn_off_action: turn_off_action:
@@ -335,7 +381,7 @@ switch:
- platform: template - platform: template
name: "Interior Lights" name: "Interior Lights"
id: interior_lights id: interior_lights
optimistic: true optimistic: false
turn_on_action: turn_on_action:
- script.execute: { id: send_load_command, node: 0xF8, op: 1 } - script.execute: { id: send_load_command, node: 0xF8, op: 1 }
turn_off_action: turn_off_action:
@@ -344,7 +390,7 @@ switch:
- platform: template - platform: template
name: "Water Heater" name: "Water Heater"
id: water_heater id: water_heater
optimistic: true optimistic: false
turn_on_action: turn_on_action:
- script.execute: { id: send_load_command, node: 0x95, op: 1 } - script.execute: { id: send_load_command, node: 0x95, op: 1 }
turn_off_action: turn_off_action: