# OneControl IDS-CAN node # # ESP32 (native TWAI/CAN) + external SN65HVD230 transceiver, connected to the # Lippert UNITY X180T CAN bus at the monitor panel's spare CAN *data* port. # Listens to the modules' broadcasts and republishes them as native HA entities, # and issues commands using the panel's challenge/response authentication. # # IDS-CAN = 250 kbit/s. Read broadcasts are 11-bit standard frames, # id = (page << 8) | node. Commands and their authentication exchange use 29-bit # extended frames. See ../README.md for the connection procedure, the node map, # and the message format. Flash over USB first # (`esphome run onecontrol-canbus.yaml`), OTA thereafter. substitutions: name: onecontrol-canbus friendly_name: OneControl CAN # ESP32 GPIOs to the transceiver. Any free non-strapping pins work; these match # a common SN65HVD230 wiring. tx_pin -> transceiver D/CTX, rx_pin <- R/CRX. tx_pin: GPIO5 rx_pin: GPIO4 esphome: name: ${name} friendly_name: ${friendly_name} # 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 framework: type: esp-idf wifi: ssid: !secret wifi_ssid password: !secret wifi_password ap: ssid: "OneControl-CAN Fallback" password: !secret fallback_ap_password captive_portal: logger: 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: key: !secret api_key ota: - platform: esphome # --------------------------------------------------------------------------- # CAN bus: ESP32 native TWAI controller + SN65HVD230 transceiver # # NOTE: read broadcasts are 11-bit *standard* frames; the command authentication # challenge is a 29-bit *extended* frame. The node must receive both. Confirm the # esp32_can trigger accepts both frame types (a single catch-all with # can_id_mask 0); if your ESPHome build filters by frame type, add a second # on_frame for standard IDs. # --------------------------------------------------------------------------- canbus: - platform: esp32_can id: can_bus tx_pin: ${tx_pin} rx_pin: ${rx_pin} 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: &decode_frame - lambda: |- // `can_id` and `x` (data bytes) are provided by the trigger. uint32_t id = can_id; // ---- command authentication: page-42 challenge reply ---- // After send_load_command sends the page-42 request, the target // module returns a fresh 4-byte challenge on the 29-bit ext ID // (node<<18)|0x10000|0x0142, payload 00 04 CC CC CC CC. Compute the // REMOTE_CONTROL response, send it on page 43, then send the opcode // x3. This is the only place we transmit; it acts solely on // id(g_cmd_node), so no other node is ever touched. 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); // page-43 response: ext ID 0x000443, payload 00 04 RR RR RR RR uint32_t resp_id = 0x00040043u | ((uint32_t) id(g_cmd_node) << 8); std::vector rframe = {0x00, 0x04, resp[0], resp[1], resp[2], resp[3]}; id(can_bus).send_data(resp_id, true, rframe); // opcode x3: ext ID 0x0006, DLC 0 (op 01=on, 00=off) uint32_t op_id = 0x00060000u | ((uint32_t) id(g_cmd_node) << 8) | (uint32_t) id(g_cmd_op); std::vector opframe; // empty -> DLC 0 for (int i = 0; i < 3; i++) id(can_bus).send_data(op_id, true, opframe); id(g_cmd_pending) = false; ESP_LOGI("idscan", "node %02X: challenge %02X%02X%02X%02X -> response %02X%02X%02X%02X, opcode %02X x3", id(g_cmd_node), x[2], x[3], x[4], x[5], resp[0], resp[1], resp[2], resp[3], id(g_cmd_op)); return; } } // ---- read broadcasts: 11-bit standard frames, id = (page<<8)|node ---- 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 — 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%). // switched loads (type 0x1E): x[0] bit0 = on/off. if (page == 3 && x.size() >= 1) { switch (node) { // tanks (node addresses from the README node map for this rig) case 0xE2: id(fresh_tank).publish_state(x[0]); break; case 0xFE: id(black_tank).publish_state(x[0]); break; case 0x27: id(grey_tank_1).publish_state(x[0]); break; case 0x7D: id(grey_tank_2).publish_state(x[0]); break; // switched loads case 0xF8: id(interior_lights).publish_state(x[0] & 0x01); break; case 0x2A: id(exterior_lights).publish_state(x[0] & 0x01); break; // 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. // x[3] bit7 = burner ACTUALLY FIRING (vs merely enabled): idle // reads ...00 01, a live burn reads ...00 9X with the low nibble // climbing as it heats (captured 2026-06-11). The switch state // above is "enabled"; wh_heating is "actually making heat". case 0x95: id(water_heater).publish_state(x[0] & 0x01); id(dsi_fault).publish_state(x[0] & 0x20); if (x.size() >= 4) id(wh_heating).publish_state(x[3] & 0x80); break; // furnace (node 89): propane forced-air, THERMOSTAT-controlled // (not a Lippert load) — on the bus only to report DSI state, in // the same type-0x1E encoding as the water heater. b0 bit0 = // running, bit5 (0x20) = DSI ignition lockout. A lockout also // trips the bus-wide page-0 fault flag (system_fault). Only idle // 0x80 was captured (summer bench); 0x81 running / 0xA0 fault are // inferred by parallel to node 95 — confirm on the first real // burn cycle (or a forced lockout, propane off). case 0x89: id(furnace_running).publish_state(x[0] & 0x01); id(furnace_dsi_fault).publish_state(x[0] & 0x20); break; // awning H-bridge (node 75): b0 C0 idle / C2 extending (opening) // / C3 retracting (closing); b2-3 (BE) = motor current (raw // counts, ~<1550 running incl. inrush, ~4200 at the fully-closed // stall — captured 2026-07-01). Reflect motion onto the cover and // publish current; while an auto-retract session is active, watch // for the stall spike and cut the stream (see interval + script). case 0x75: { uint16_t cur = (x.size() >= 4) ? (uint16_t)(((uint16_t)x[2] << 8) | x[3]) : 0; if (x.size() >= 4) id(awning_current).publish_state(cur); 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; // Stall gate: only while auto-retracting (C3), after the inrush // blanking window. cur>2500 for 3 consecutive frames (~150ms @ // 20Hz) = fully closed → clear the session flag (interval stops // streaming → motor halts) and mark the cover CLOSED (the one // direction we get a real end-stop, so it's no longer assumed). if (x[0] == 0xC3) id(g_awn_last_c3_ms) = millis(); if (id(g_awn_active) && x[0] == 0xC3 && x.size() >= 4) { if (millis() - id(g_awn_start_ms) > 1200) { if (cur > 2500) { if (++id(g_awn_stall_count) >= 3) { id(g_awn_active) = false; id(awning).position = esphome::cover::COVER_CLOSED; ESP_LOGI("awning", "auto-retract: stall %u -> stop (CLOSED)", cur); } } else { id(g_awn_stall_count) = 0; } } } id(awning).publish_state(); break; } // (water pump 61 is command-blocked — read-only, not exposed.) } } // page 0 b0 bit0 = a bus-wide "system fault present" flag (every // node mirrors it; healthy 0x02, fault 0x03). Read it from one node // (the heater) so we publish a single source, not 14 duplicates. if (page == 0 && node == 0x95 && x.size() >= 1) { id(system_fault).publish_state(x[0] & 0x01); } // Battery voltage rides 29-bit telemetry frames (src 7D/AE, page // 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" # # State shared between send_load_command (sends the request + retries) and the # on_frame challenge handler (computes the response + sends the opcode). A # command is queued by setting g_cmd_* and g_cmd_pending; the handler clears # g_cmd_pending once the exchange completes, which also stops the retry loop. # --------------------------------------------------------------------------- globals: - id: g_cmd_node type: uint8_t initial_value: '0' - id: g_cmd_op type: uint8_t initial_value: '0' - 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' # 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{}' # --- Awning auto-retract session state ----------------------------------- # Set true by awning_auto_retract; the 100ms interval streams the retract # opcode + page-44 keepalive while true, and the page-3 stall gate (or the # timeout) clears it. Cleared → streaming stops → motor halts within ~1s # (hold-to-run), so this bool is the master kill-switch for awning motion. - id: g_awn_active type: bool initial_value: 'false' - id: g_awn_start_ms type: uint32_t initial_value: '0' - id: g_awn_stall_count type: uint8_t initial_value: '0' - id: g_awn_ka_tick type: uint8_t initial_value: '0' # millis() of the last observed retracting (C3) frame — lets the interval end # the session ~1s after motion actually stops (whether the stall gate cut it, # the OEM controller cut at its own limit, or streaming failed to sustain). - id: g_awn_last_c3_ms type: uint32_t initial_value: '0' script: - id: send_load_command # mode: restart -> a new press supersedes an in-flight exchange. mode: restart parameters: node: int op: int then: - lambda: |- // 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 — 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; } 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 ~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: 8 then: - if: condition: lambda: 'return id(g_cmd_pending);' then: - lambda: |- // page-42 request: ext ID 0x000442, payload 00 04 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: 150ms - if: condition: lambda: 'return id(g_cmd_pending);' then: - lambda: |- ESP_LOGW("idscan", "no page-42 challenge from node %02X after 8 tries; command dropped", id(g_cmd_node)); id(g_cmd_pending) = false; # ------------------------------------------------------------------------- # Awning auto-retract — retract to the fully-closed hard stop, then stop by # sensing the motor-current stall spike (no position feedback on the bus). # # Movement is HOLD-TO-RUN: a single authenticated opcode runs the motor only # ~1s. The OEM app sustains motion by streaming the opcode (~110ms) plus a # page-44 keepalive (~510ms) after ONE auth (captured 2026-06-11). We do the # same: authenticate once via send_load_command, then the 100ms `interval` # below streams opcode+keepalive while g_awn_active. The page-3 stall gate # (case 0x75) clears g_awn_active at the closed stop; the interval's 70s # timeout is the backstop. Stop streaming = motor stops within ~1s, so this # fails safe on crash/Wi-Fi loss/stop-press. - id: awning_auto_retract mode: single # ignore re-press while a retract is already running then: - lambda: |- id(g_awn_stall_count) = 0; id(g_awn_ka_tick) = 0; id(g_awn_start_ms) = millis(); id(g_awn_last_c3_ms) = millis(); id(g_awn_active) = true; id(awning).current_operation = esphome::cover::COVER_OPERATION_CLOSING; id(awning).publish_state(); # Authenticate + start motion via the proven single-shot path; the # interval then keeps it moving until the stall gate or timeout fires. - script.execute: { id: send_load_command, node: 0x75, op: 2 } - id: awning_stop then: - lambda: 'id(g_awn_active) = false;' # halt the stream (motor stops ~1s) - script.execute: { id: send_load_command, node: 0x75, op: 0 } # --------------------------------------------------------------------------- # Awning motion streamer — while g_awn_active, refresh the hold-to-run opcode # every 100ms and the page-44 keepalive every ~500ms; enforce the safety # timeout. Idle (g_awn_active false) → returns immediately, transmits nothing. # --------------------------------------------------------------------------- interval: - interval: 100ms then: - lambda: |- if (!id(g_awn_active)) return; if (millis() - id(g_awn_start_ms) > 70000) { id(g_awn_active) = false; ESP_LOGW("awning", "auto-retract: 70s timeout -> stop"); return; } // motion-lost: once past the auth/startup window, if no retracting (C3) // frame has arrived for >1s the motor isn't moving (stall gate/OEM // cutoff/failed-to-sustain) — end the session instead of streaming on. if (millis() - id(g_awn_start_ms) > 2500 && millis() - id(g_awn_last_c3_ms) > 1000) { id(g_awn_active) = false; ESP_LOGI("awning", "auto-retract: motion ended -> stop"); return; } // stream the retract opcode (0006 75 02, DLC 0) — refreshes hold-to-run uint32_t op_id = 0x00060000u | (0x75u << 8) | 0x02u; std::vector op_empty; // DLC 0 id(can_bus).send_data(op_id, true, op_empty); // page-44 keepalive (0004 75 44, payload 00 04) every ~500ms if (++id(g_awn_ka_tick) >= 5) { id(g_awn_ka_tick) = 0; uint32_t ka_id = 0x00040044u | (0x75u << 8); std::vector ka = {0x00, 0x04}; id(can_bus).send_data(ka_id, true, ka); } # --------------------------------------------------------------------------- # Read-back sensors (published by the dispatcher above) # --------------------------------------------------------------------------- sensor: - platform: template name: "Battery Voltage" id: battery_voltage unit_of_measurement: "V" 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 unit_of_measurement: "%" accuracy_decimals: 0 - platform: template name: "Black Tank" id: black_tank unit_of_measurement: "%" accuracy_decimals: 0 - platform: template name: "Grey Tank 1" id: grey_tank_1 unit_of_measurement: "%" accuracy_decimals: 0 - platform: template name: "Grey Tank 2" id: grey_tank_2 unit_of_measurement: "%" accuracy_decimals: 0 - platform: template name: "Awning Motor Current" id: awning_current # Raw node-75 page-3 b2-3 counts (not amps): ~<1550 running incl. inrush, # ramps to a ~4200 plateau at the fully-closed hard stop. Only updates while # the motor moves (20Hz); holds last value at rest. Feeds the stall gate. unit_of_measurement: "raw" accuracy_decimals: 0 state_class: measurement # --------------------------------------------------------------------------- # Fault indicators (published by the dispatcher above) # --------------------------------------------------------------------------- binary_sensor: - platform: template name: "Water Heater DSI Fault" id: dsi_fault device_class: problem # node 95 page-3 b0 bit5 — gas ignition lockout - platform: template name: "Water Heater Heating" id: wh_heating device_class: running # node 95 page-3 x[3] bit7 — burner actively firing - platform: template name: "Furnace Running" id: furnace_running device_class: running # node 89 page-3 b0 bit0 — thermostat-driven burn - platform: template name: "Furnace DSI Fault" id: furnace_dsi_fault device_class: problem # node 89 page-3 b0 bit5 — gas ignition lockout - platform: template name: "OneControl System Fault" id: system_fault device_class: problem # page-0 b0 bit0 — bus-wide "a fault exists" # --------------------------------------------------------------------------- # Switches — authenticated command path (see send_load_command above). # 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. # # 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, 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: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. # --------------------------------------------------------------------------- # restore_mode: DISABLED on all three is load-bearing: ESPHome's default # restore "applies" the boot state BY EXECUTING THE SWITCH ACTION, which sent # a real authenticated OFF to the water heater on the 2026-06-12 OTA reboot # (send_load_command is mode:restart, so the last switch in setup order — the # heater — won the race). Boot must send nothing: the broadcasts repopulate # every state within ~1 s and are the only source of truth. switch: - platform: template name: "Exterior Lights" id: exterior_lights optimistic: false restore_mode: DISABLED turn_on_action: - script.execute: { id: send_load_command, node: 0x2A, op: 1 } turn_off_action: - script.execute: { id: send_load_command, node: 0x2A, op: 0 } - platform: template name: "Interior Lights" id: interior_lights optimistic: false restore_mode: DISABLED turn_on_action: - script.execute: { id: send_load_command, node: 0xF8, op: 1 } turn_off_action: - script.execute: { id: send_load_command, node: 0xF8, op: 0 } - platform: template name: "Water Heater" id: water_heater optimistic: false restore_mode: DISABLED 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. # # Motion is HOLD-TO-RUN (confirmed 2026-06-11 capture + 2026-07-01 live): one # opcode runs the motor ~1s. So: # CLOSE = auto-retract — stream to the fully-closed stall, stop on current # spike (awning_auto_retract script + interval + stall gate). This is # the one direction with a real end-stop, so it also marks CLOSED. # STOP = abort any active retract (clears g_awn_active) + send stop opcode. # NO open_action ON PURPOSE: a single opcode only jogs the motor ~1s (useless), # and there's no position/end-stop feedback to safely auto-extend on a timer. # Extend the awning at the OEM wall switch. Omitting open_action drops SUPPORT_OPEN # so HA shows only close/stop. assumed_state -> both stay always-pressable (no slider). # --------------------------------------------------------------------------- cover: - platform: template name: "Awning" id: awning device_class: awning assumed_state: true close_action: - script.execute: awning_auto_retract stop_action: - script.execute: awning_stop