# 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() to the command path below. includes: - ids_can_auth.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 # DEBUG so the on_frame ESP_LOGD frame dump is visible # during bring-up. Drop to INFO once the map is solid. 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 on_frame: - can_id: 0 can_id_mask: 0 # accept every frame, dispatch in the lambda use_extended_id: true then: - 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) { 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; // 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); // 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; // TODO: water pump (61), water heater (95) once exposed below. } } // 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. # --------------------------------------------------------------------------- # 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' 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: 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). if (node != 0x2A && node != 0xF8) { ESP_LOGW("idscan", "refusing command to non-allowlisted node %02X (lights only)", 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 ~100 ms for the challenge; retry up to 3x. # The on_frame handler clears g_cmd_pending the moment it answers, so a # successful exchange short-circuits the remaining iterations. - repeat: count: 3 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: 100ms - 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", id(g_cmd_node)); id(g_cmd_pending) = false; # --------------------------------------------------------------------------- # 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 - 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 # --------------------------------------------------------------------------- # 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. # # 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. # 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. # --------------------------------------------------------------------------- switch: - platform: template name: "Exterior Lights" id: exterior_lights optimistic: true 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: true 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 } # water pump (61) / water heater (95) are also type-0x1E switched loads and use # the same authentication — add them here once you want them in HA. Movement # nodes stay off this list until tested.