From e8b2447d621ce4ac121f436fce5744592e6740d4 Mon Sep 17 00:00:00 2001 From: Wesley Ray Date: Fri, 12 Jun 2026 15:32:12 -0400 Subject: [PATCH] canbus: battery decode, dual on_frame triggers, ground-truth switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- canbus/esphome/onecontrol-canbus.yaml | 92 ++++++++++++++++++++------- 1 file changed, 69 insertions(+), 23 deletions(-) diff --git a/canbus/esphome/onecontrol-canbus.yaml b/canbus/esphome/onecontrol-canbus.yaml index 62e2999..ef4028a 100644 --- a/canbus/esphome/onecontrol-canbus.yaml +++ b/canbus/esphome/onecontrol-canbus.yaml @@ -44,8 +44,14 @@ wifi: captive_portal: logger: - level: DEBUG # DEBUG so the on_frame ESP_LOGD frame dump is visible - # during bring-up. Drop to INFO once the map is solid. + level: DEBUG # global DEBUG so entity "Sending state" publishes and + # 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: encryption: @@ -71,11 +77,17 @@ canbus: bit_rate: 250kbps # IDS-CAN is 250k can_id: 0 # our own TX id (only matters when we send) 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: - can_id: 0 can_id_mask: 0 # accept every frame, dispatch in the lambda use_extended_id: true - then: + then: &decode_frame - lambda: |- // `can_id` and `x` (data bytes) are provided by the trigger. uint32_t id = can_id; @@ -135,11 +147,14 @@ canbus: 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(), - 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); + // Frame dump — confirmed both frame types decode (2026-06-12), now + // silenced: at DEBUG it logs every frame (~50/s across both + // triggers) and saturates the 115200 serial link. Uncomment to + // re-map the bus. + // 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): // 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 // 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; + if (x[0] == 0xC2) id(awning).current_operation = esphome::cover::COVER_OPERATION_OPENING; + else if (x[0] == 0xC3) id(awning).current_operation = esphome::cover::COVER_OPERATION_CLOSING; + else id(awning).current_operation = esphome::cover::COVER_OPERATION_IDLE; id(awning).publish_state(); break; // (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 - // 0x11), payload 00 2B 0D 4x ..; b2..b3 (BE) / 256 = volts. - // TODO: match the exact source frame and publish battery_voltage. + // 0x11), payload 00 2B 0D 4x ; b2..b3 (BE) / 256 = volts. + // 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" @@ -205,7 +235,10 @@ globals: # seen). Feeds the motor-output safety gate in command_guard.h. - id: g_node_type type: 'std::array' - initial_value: '{}' + # Name the type in the initializer: a bare '{}' is ambiguous between the + # GlobalsComponent(T) and (std::array<...>) constructors under the current + # toolchain. zero-initialized -> every node starts as type 0 (not yet seen). + initial_value: 'std::array{}' script: - id: send_load_command @@ -237,11 +270,16 @@ script: id(g_cmd_node) = (uint8_t) node; id(g_cmd_op) = (uint8_t) op; 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 # successful exchange short-circuits the remaining iterations. - repeat: - count: 3 + count: 8 then: - if: condition: @@ -252,13 +290,13 @@ script: uint32_t req_id = 0x00040042u | ((uint32_t) id(g_cmd_node) << 8); std::vector req = {0x00, 0x04}; id(can_bus).send_data(req_id, true, req); - - delay: 100ms + - delay: 150ms - if: condition: lambda: 'return id(g_cmd_pending);' then: - 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_pending) = false; @@ -273,6 +311,13 @@ sensor: device_class: voltage state_class: measurement 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 name: "Fresh Water 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 # 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. +# optimistic:false: the page-3 (b0 bit0) read-back above publishes true module +# 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: - platform: template name: "Exterior Lights" id: exterior_lights - optimistic: true + optimistic: false turn_on_action: - script.execute: { id: send_load_command, node: 0x2A, op: 1 } turn_off_action: @@ -335,7 +381,7 @@ switch: - platform: template name: "Interior Lights" id: interior_lights - optimistic: true + optimistic: false turn_on_action: - script.execute: { id: send_load_command, node: 0xF8, op: 1 } turn_off_action: @@ -344,7 +390,7 @@ switch: - platform: template name: "Water Heater" id: water_heater - optimistic: true + optimistic: false turn_on_action: - script.execute: { id: send_load_command, node: 0x95, op: 1 } turn_off_action: