# OneControl RV-C CANbus node # # ESP32 (native TWAI/CAN) + external SN65HVD230 transceiver, tapped into the # Lippert UNITY X180T RV-C bus at the monitor panel's spare CAN *data* port. # Listens to RV-C broadcasts and republishes them as native HA entities; the # command/write path (switches) is stubbed until the DGN map is RE'd. # # RV-C = 250 kbit/s, 29-bit extended IDs. See ../README.md for the tap procedure, # the (unverified) DGN map, and the sniffing workflow. Flash over USB first # (`esphome run onecontrol-canbus.yaml`), OTA thereafter. substitutions: name: onecontrol-canbus friendly_name: OneControl CANbus # 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} 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 # while RE'ing. 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 # --------------------------------------------------------------------------- canbus: - platform: esp32_can id: rvc_bus tx_pin: ${tx_pin} rx_pin: ${rx_pin} bit_rate: 250kbps # RV-C is always 250k can_id: 0 # our own TX id (only matters when we send) use_extended_id: true # RV-C uses 29-bit IDs on_frame: # Catch-all: can_id_mask 0 = accept every frame, then dispatch by DGN in # the lambda (RV-C buries the source address in the id's low byte, so a # single decoder is cleaner than per-DGN hardware filters). - can_id: 0 can_id_mask: 0 use_extended_id: true then: - lambda: |- // `can_id` and `x` (data bytes) are provided by the trigger. // (If your ESPHome is too old to expose `can_id` here, switch to // per-DGN can_id/can_id_mask filters instead.) uint32_t id = can_id; uint8_t prio = (id >> 26) & 0x7; uint32_t dgn = (id >> 8) & 0x1FFFF; uint8_t sa = id & 0xFF; // Frame dump — comment out once the map is trustworthy. ESP_LOGD("rvc", "DGN=%05X SA=%02X len=%u %02X %02X %02X %02X %02X %02X %02X %02X", dgn, sa, 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, x.size()>6?x[6]:0, x.size()>7?x[7]:0); // ================= DISPATCH — ALL VALUES UNVERIFIED ================= // DGNs + byte math are standard-RV-C hypotheses. Confirm each // against the sniff log (../README.md "DGN map") before trusting. // ---- TANK_STATUS (std 0x1FFB7) ---- if (dgn == 0x1FFB7 && x.size() >= 2) { uint8_t inst = x[0]; float pct = x[1] * 100.0f / 255.0f; // TODO verify level math switch (inst) { // TODO verify instances case 0: id(fresh_tank).publish_state(pct); break; case 1: id(black_tank).publish_state(pct); break; case 2: id(grey_tank_1).publish_state(pct); break; case 3: id(grey_tank_2).publish_state(pct); break; } } // ---- DC_SOURCE_STATUS_1 (std 0x1FFFD) battery ---- if (dgn == 0x1FFFD && x.size() >= 4) { uint16_t raw = x[2] | (x[3] << 8); // 0.05 V/bit, LE (verify) id(battery_voltage).publish_state(raw * 0.05f); } // ---- DC_DIMMER_STATUS_3 (std 0x1FEDA) light/switch state ---- if (dgn == 0x1FEDA && x.size() >= 3) { uint8_t inst = x[0]; bool on = x[2] > 0; // byte2 = brightness switch (inst) { // TODO verify instances // case ?: id(interior_lights).publish_state(on); break; // case ?: id(exterior_lights).publish_state(on); break; // case ?: id(water_pump).publish_state(on); break; // case ?: id(gas_water_heater).publish_state(on); break; } } # --------------------------------------------------------------------------- # 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 — state comes from DC_DIMMER_STATUS_3 above; the command path is a # PLACEHOLDER until DC_DIMMER_COMMAND_2 (std 0x1FEDB) instance + payload are RE'd. # can_id below = prio(6)<<26 | DGN 0x1FEDB <<8 | source_addr. 0x18FEDB80 is a # guess (prio 6, SA 0x80) — DO NOT trust until verified by sniffing a real # command frame from the panel. # --------------------------------------------------------------------------- switch: - platform: template name: "Interior Lights" id: interior_lights optimistic: true # flip to false once read-back is wired turn_on_action: - canbus.send: canbus_id: rvc_bus use_extended_id: true can_id: 0x18FEDB80 # TODO compute from real DGN+SA data: [0x00, 0x00, 0xC8, 0x01] # TODO [instance, group, level, cmd] turn_off_action: - canbus.send: canbus_id: rvc_bus use_extended_id: true can_id: 0x18FEDB80 # TODO data: [0x00, 0x00, 0x00, 0x03] # TODO # Duplicate the block above for: exterior_lights, water_pump, gas_water_heater # once their command instances are known.