-
-
Save jnimmo/5182c59011a16c94293935d06c7857a9 to your computer and use it in GitHub Desktop.
ESPHome BThome V2 configuration for the Ioniq EV 28kw with the MeatPi WiCAN interface
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| ## Telemetry setup | |
| # This ESPHome config broadcasts car telemetry (SoC, battery power, speed, charging status, odometer, etc.) | |
| # as [BTHome](https://bthome.io/) BLE advertisements — no WiFi/App required, just passive BLE beacons. | |
| # On the receiving end, the [BTHome Monitor](https://apps.apple.com/app/bthome-monitor) iOS app | |
| # listens for these advertisements and uses its webhook support to forward the decoded sensor | |
| # data to [ABRP](https://abrp.com/) (A Better Route Planner), acting as a simple BLE-to-HTTP bridge | |
| # so ABRP gets live vehicle data without any direct integration on the ESPHome side | |
| # last_sleep_reason codes: | |
| # 1 = quick check, no activity detected | |
| # 2 = was awake, activity ended (wake sensor off) | |
| # 5 = low voltage protection | |
| # | |
| substitutions: | |
| device_name: car | |
| charging_voltage_threshold: "14" # 12V voltage at/above this implies DC-DC/charger active | |
| low_voltage_threshold: "12.0" | |
| low_voltage_sleep_duration: 60min | |
| quick_check_interval: 15min # deep sleep between 12V voltage checks | |
| wifi_first_boot_duration: 5min # WiFi/OTA window after a power-plug boot | |
| beacon_every_n_checks: "16" # BLE broadcast of last-known values every Nth quick check | |
| beacon_duration: 30s | |
| hotspot1_ssid: !secret hotspot1_ssid | |
| hotspot2_ssid: !secret hotspot2_ssid | |
| hotspot3_ssid: !secret hotspot3_ssid | |
| hotspot1_password: !secret hotspot1_password | |
| hotspot2_password: !secret hotspot2_password | |
| hotspot3_password: !secret hotspot3_password | |
| esp32: | |
| variant: ESP32C3 | |
| board: esp32-c3-devkitm-1 | |
| framework: | |
| type: esp-idf | |
| globals: | |
| - id: sleep_mode_enabled | |
| type: "bool" | |
| restore_value: yes | |
| initial_value: "true" | |
| - id: added_wifi_hotspots | |
| type: "bool" | |
| restore_value: no | |
| initial_value: "false" | |
| - id: last_odometer_value | |
| type: "float" | |
| restore_value: yes | |
| - id: pending_status_update | |
| type: "bool" | |
| restore_value: no | |
| initial_value: "false" | |
| - id: last_hv_charging_state | |
| type: "bool" | |
| restore_value: yes | |
| initial_value: "false" | |
| - id: last_ac_present_state | |
| type: "bool" | |
| restore_value: yes | |
| initial_value: "false" | |
| - id: last_state_of_charge | |
| type: uint8_t | |
| restore_value: yes | |
| initial_value: "0" | |
| - id: frame_received | |
| type: "bool" | |
| restore_value: no | |
| initial_value: "false" | |
| - id: received_data | |
| type: "std::vector<uint8_t>" | |
| restore_value: no | |
| initial_value: "std::vector<uint8_t>()" | |
| - id: expected_length | |
| type: "int" | |
| restore_value: no | |
| initial_value: "0" | |
| - id: next_frame_seq | |
| type: "uint8_t" | |
| restore_value: no | |
| initial_value: "0x21" # Start wfith 0x21 after FF | |
| - id: can_soh_raw | |
| type: "float" | |
| restore_value: no | |
| initial_value: "NAN" | |
| - id: can_soh_display | |
| type: "float" | |
| restore_value: no | |
| initial_value: "NAN" | |
| - id: can_soc_bms | |
| type: "float" | |
| restore_value: no | |
| initial_value: "NAN" | |
| - id: can_battery_dc_voltage | |
| type: "float" | |
| restore_value: no | |
| initial_value: "0" | |
| - id: can_battery_current_signed | |
| type: "float" | |
| restore_value: no | |
| initial_value: "0" | |
| - id: can_battery_power | |
| type: "float" | |
| restore_value: no | |
| initial_value: "0" | |
| - id: can_operating_hours_float | |
| type: "float" | |
| restore_value: no | |
| initial_value: "NAN" | |
| - id: can_speed_mph | |
| type: "float" | |
| restore_value: no | |
| initial_value: "NAN" | |
| - id: can_ignition_status | |
| type: "bool" | |
| restore_value: no | |
| initial_value: "NAN" | |
| - id: trip_startsoc | |
| type: "float" | |
| restore_value: no | |
| initial_value: "0" | |
| - id: trip_startodo | |
| type: "float" | |
| restore_value: no | |
| initial_value: "0" | |
| - id: last_canbus_packet | |
| type: "std::string" | |
| restore_value: no | |
| initial_value: '""' | |
| - id: is_manual_request | |
| type: "bool" | |
| restore_value: no | |
| initial_value: "false" | |
| - id: last_sleep_reason | |
| type: "int" | |
| restore_value: yes | |
| initial_value: "0" | |
| - id: bthome_packet_id | |
| type: uint8_t | |
| restore_value: no | |
| initial_value: "0" | |
| - id: car_was_on | |
| type: "bool" | |
| restore_value: yes | |
| initial_value: "false" | |
| - id: decision_voltage | |
| type: "float" | |
| restore_value: no | |
| initial_value: "0" | |
| - id: checks_since_beacon | |
| type: "int" | |
| restore_value: yes | |
| initial_value: "0" | |
| - id: last_soc_display | |
| type: uint8_t | |
| restore_value: yes | |
| initial_value: "0" | |
| external_components: | |
| - source: | |
| type: git | |
| url: https://github.com/jnimmo/esphome-bthome | |
| ref: main | |
| components: [bthome] | |
| esphome: | |
| name: car-wican | |
| on_shutdown: | |
| then: | |
| # Transceiver off in sleep, mirroring wican-fw's can_disable() before sleep. | |
| # GPIOs float in deep sleep on the C3, so hold the pin or the transceiver | |
| # enable is indeterminate while sleeping. | |
| - switch.turn_off: can_enabled | |
| - lambda: |- | |
| gpio_hold_en(GPIO_NUM_6); | |
| gpio_deep_sleep_hold_en(); | |
| on_boot: | |
| priority: 210 | |
| then: | |
| # Release the deep-sleep hold on the CAN transceiver enable pin so the | |
| # can_enabled switch controls it again this wake cycle. | |
| - lambda: |- | |
| gpio_deep_sleep_hold_dis(); | |
| gpio_hold_dis(GPIO_NUM_6); | |
| - script.execute: restore_sensor_values | |
| - script.execute: read_decision_voltage | |
| - script.wait: read_decision_voltage | |
| - if: | |
| condition: | |
| binary_sensor.is_on: first_boot | |
| then: | |
| - text_sensor.template.publish: | |
| id: boot_reason | |
| state: "First boot" | |
| - script.execute: | |
| id: full_wake | |
| open_wifi: true | |
| else: | |
| - if: | |
| condition: | |
| lambda: return id(decision_voltage) < ${low_voltage_threshold}; | |
| then: | |
| - logger.log: "Low voltage, going back to sleep." | |
| - globals.set: | |
| id: last_sleep_reason | |
| value: "5" | |
| - deep_sleep.enter: | |
| id: deep_sleep_1 | |
| sleep_duration: ${low_voltage_sleep_duration} | |
| else: | |
| - if: | |
| condition: | |
| lambda: return id(decision_voltage) >= ${charging_voltage_threshold} || id(car_was_on); | |
| then: | |
| - script.execute: | |
| id: full_wake | |
| open_wifi: false | |
| else: | |
| # Quick check: no activity. No CAN, no WiFi, minimal awake time. | |
| - globals.set: | |
| id: last_sleep_reason | |
| value: "1" | |
| - lambda: id(checks_since_beacon) += 1; | |
| - if: | |
| condition: | |
| lambda: return id(checks_since_beacon) >= ${beacon_every_n_checks}; | |
| then: | |
| - globals.set: | |
| id: checks_since_beacon | |
| value: "0" | |
| - script.execute: ble_beacon | |
| - script.wait: ble_beacon | |
| - deep_sleep.enter: | |
| id: deep_sleep_1 | |
| sleep_duration: ${quick_check_interval} | |
| logger: | |
| level: DEBUG #NONE# ERROR #INFO #DEBUG #VERBOSE | |
| baud_rate: 0 #to disable logging via UART | |
| # logs: | |
| # text_sensor: ERROR | |
| # homeassistant.sensor: ERROR | |
| # canbus: INFO | |
| # light: INFO | |
| deep_sleep: | |
| id: deep_sleep_1 | |
| run_duration: 50s # failsafe only; paths either prevent sleep or enter it explicitly | |
| sleep_duration: ${quick_check_interval} | |
| # car ap is set to channel 6 | |
| api: | |
| reboot_timeout: 0s | |
| encryption: | |
| key: | |
| on_client_connected: | |
| - logger.log: | |
| format: "Client %s connected to API with IP %s" | |
| args: ["client_info.c_str()", "client_address.c_str()"] | |
| services: | |
| - service: send_manual_canbus_request | |
| variables: | |
| can_id: string | |
| data: string | |
| then: | |
| - logger.log: | |
| format: "Sending manual CAN bus request: can_id=0x%s, data=%s" | |
| args: ["can_id.c_str()", "data.c_str()"] | |
| - globals.set: | |
| id: is_manual_request | |
| value: "true" | |
| - globals.set: | |
| id: frame_received | |
| value: "false" | |
| - lambda: |- | |
| std::vector<uint8_t> data_vector; | |
| for (size_t i = 0; i < data.length(); i += 2) { | |
| uint8_t byte = strtol(data.substr(i, 2).c_str(), NULL, 16); | |
| data_vector.push_back(byte); | |
| } | |
| int can_id_int = strtol(can_id.c_str(), NULL, 16); | |
| id(can0)->send_data(can_id_int, false, data_vector); | |
| - wait_until: | |
| condition: | |
| lambda: return id(frame_received); | |
| timeout: 1sec | |
| - globals.set: | |
| id: is_manual_request | |
| value: "false" | |
| text_sensor: | |
| - platform: template | |
| id: boot_reason | |
| name: "Wake reason" | |
| - platform: wifi_info | |
| ssid: | |
| name: Connected SSID | |
| id: connected_ssid | |
| - platform: template | |
| id: last_canbus_packet_sensor | |
| name: "Last CANBus packet" | |
| update_interval: 10s | |
| wifi: | |
| id: wifi_component | |
| use_address: 192.168.178.210 | |
| reboot_timeout: 0s | |
| enable_on_boot: false | |
| on_connect: | |
| - light.turn_on: | |
| id: green_led | |
| effect: "None" | |
| on_disconnect: | |
| - light.turn_on: | |
| id: green_led | |
| effect: "searching" | |
| networks: | |
| - ssid: !secret wifi_ssid | |
| password: !secret wifi_password | |
| priority: 0 | |
| manual_ip: | |
| static_ip: 192.168.178.210 | |
| gateway: 192.168.178.1 | |
| subnet: 255.255.255.0 | |
| dns1: 192.168.178.1 | |
| - ssid: !secret hotspot1_ssid | |
| password: !secret hotspot1_password | |
| priority: 1 | |
| - ssid: !secret hotspot2_ssid | |
| password: !secret hotspot2_password | |
| priority: 2 | |
| - ssid: !secret hotspot3_ssid | |
| password: !secret hotspot3_password | |
| priority: 3 | |
| - ssid: !secret hotspot4_ssid | |
| password: !secret hotspot4_password | |
| priority: 4 | |
| ota: | |
| platform: esphome | |
| password: "be45eb97911e8d734f302a1045defb9f" | |
| output: | |
| - id: blue_led_output | |
| platform: gpio | |
| pin: 7 | |
| # GPIO8/9 are strapping pins, but the WiCAN board hardwires its LEDs to them | |
| - id: green_led_output | |
| platform: gpio | |
| pin: { number: 8, inverted: true, ignore_strapping_warning: true } | |
| - id: yellow_led_output | |
| platform: gpio | |
| pin: { number: 9, inverted: true, ignore_strapping_warning: true } | |
| light: | |
| - platform: binary | |
| id: blue_led | |
| output: blue_led_output | |
| internal: True | |
| effects: | |
| - strobe: | |
| name: "sleeping" | |
| colors: | |
| - state: True | |
| duration: 500ms | |
| - state: False | |
| duration: 500ms | |
| - strobe: | |
| name: "sleep" | |
| colors: | |
| - state: True | |
| duration: 2000ms | |
| - state: False | |
| duration: 2000ms | |
| restore_mode: ALWAYS_OFF # stays dark on quick checks; full_wake turns it on | |
| - platform: binary | |
| id: green_led | |
| output: green_led_output | |
| effects: | |
| - strobe: | |
| name: "searching" | |
| colors: | |
| - state: True | |
| duration: 100ms | |
| - state: False | |
| duration: 3000ms | |
| - platform: binary | |
| id: yellow_led | |
| output: yellow_led_output | |
| button: | |
| - platform: restart | |
| name: "${device_name} Restart" | |
| - platform: template | |
| name: Query Car Status | |
| id: query_status | |
| on_press: | |
| - script.execute: update_car_sensors | |
| canbus: | |
| - platform: esp32_can | |
| id: can0 | |
| tx_pin: 0 | |
| rx_pin: 3 | |
| bit_rate: 500kbps | |
| can_id: 0 # mandatory but we do not use it | |
| on_frame: | |
| - can_id: 0 | |
| can_id_mask: 0 | |
| then: | |
| - lambda: |- | |
| auto data_pretty = remote_transmission_request ? "n/a" : format_hex_pretty(x).c_str(); | |
| ESP_LOGD("eup_dump", "can_id: 0x%08x, rtr: %d, length: %d, content: %s", can_id, remote_transmission_request, x.size(), data_pretty); | |
| uint8_t frame_type = (x[0] & 0xF0) >> 4; // Extract the first 4 bits | |
| uint8_t frame_index = x[0] & 0x0F; // Extract the next 4 bits | |
| if (frame_type == 0x00) { | |
| // This is a single-frame message | |
| id(expected_length) = x[0] & 0x0F; // Extract the length from the first 4 bits | |
| id(received_data).clear(); | |
| id(frame_received) = true; | |
| id(next_frame_seq) = 0x01; // Reset for next multi-frame message | |
| // Append the data (excluding the first byte which contains the length) | |
| for (int i = 1; i < x.size(); i++) { | |
| id(received_data).push_back(x[i]); | |
| } | |
| // Extract any single message data here | |
| } else if (frame_type == 0x01) { | |
| // This is the start of a multi-frame message | |
| // Extract the message length from the next 12 bits | |
| id(expected_length) = ((x[0] & 0x0F) << 8) | x[1]; | |
| ESP_LOGD("eup_dump", "multi-frame message header, total frame length: %d", id(expected_length)); | |
| id(received_data).clear(); | |
| id(next_frame_seq) = 0x01; | |
| id(frame_received) = false; | |
| // Append the first part of the data (after the size bytes) | |
| for (int i = 2; i < 8; i++) { | |
| id(received_data).push_back(x[i]); | |
| } | |
| // Send Flow Control frame to request Consecutive Frames | |
| id(can0)->send_data(can_id - 0x08, false, {0x30, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}); | |
| } else if (frame_type == 0x02) { | |
| ESP_LOGD("eup_dump", "Received frame_index: %02X, Expected next_frame_seq: %02X", frame_index, id(next_frame_seq)); | |
| if (frame_index == id(next_frame_seq)) { | |
| // This is a Consecutive Frame | |
| for (int i = 1; i < 8; i++) { | |
| id(received_data).push_back(x[i]); | |
| } | |
| id(next_frame_seq) = (id(next_frame_seq) == 0x0F) ? 0x00 : id(next_frame_seq) + 1; | |
| // Check if reassembly is complete | |
| if (id(received_data).size() >= id(expected_length)) { | |
| // Convert the vector to a formatted string | |
| std::string assembled_data; | |
| for (uint8_t byte : id(received_data)) { | |
| char buffer[4]; | |
| sprintf(buffer, "%02X.", byte); | |
| assembled_data += buffer; | |
| } | |
| // Print the formatted string | |
| ESP_LOGI("eup_dump", "Recieved completed canbus frame"); | |
| ESP_LOGD("AssembledFrame", "Complete Frame: %s", assembled_data.c_str()); | |
| if (id(is_manual_request)) { | |
| id(last_canbus_packet) = assembled_data.c_str(); | |
| id(last_canbus_packet_sensor).publish_state(assembled_data.c_str()); | |
| } | |
| if (can_id == 0x7EC && id(received_data)[1] == 0x05) { | |
| if (id(received_data).size() >= 45) { | |
| float soh_raw = ((id(received_data)[27] << 8) | id(received_data)[28]) / 10.0; | |
| id(state_of_health).publish_state(soh_raw); | |
| } | |
| // State of Charge Display | |
| float soc_display = (id(received_data).size() > 33) ? id(received_data)[33] / 2 : -1; | |
| id(state_of_charge).publish_state(soc_display); | |
| } | |
| else if (can_id == 0x7EC && id(received_data)[1] == 0x01) { | |
| // State of Charge BMS | |
| float soc_bms = (id(received_data).size() > 6) ? id(received_data)[6] / 2 : -1; | |
| id(state_of_charge_bms).publish_state(soc_bms); | |
| if (id(trip_startsoc) == 0) { | |
| id(trip_startodo) = soc_bms; | |
| } | |
| // HV Battery Voltage | |
| id(can_battery_dc_voltage) = ((id(received_data)[14] * 256) + id(received_data)[15]) / 10.0; | |
| // Battery Current | |
| int signed_byte_k = (id(received_data)[12] < 128) ? id(received_data)[12] : id(received_data)[12] - 256; | |
| id(can_battery_current_signed) = (signed_byte_k * 256 + id(received_data)[13]) / 10.0; | |
| // Battery temperatures | |
| int battery_max_temp = (id(received_data)[16] < 128) ? id(received_data)[16] : id(received_data)[16] - 256; | |
| int battery_min_temp = (id(received_data)[17] < 128) ? id(received_data)[17] : id(received_data)[17] - 256; | |
| id(battery_temperature).publish_state((battery_min_temp + battery_max_temp)/2); | |
| // Charging Status | |
| bool hv_charging_status = (id(received_data)[11] >> 7) & 1; | |
| id(hv_charging).publish_state(hv_charging_status); | |
| bool ac_present_status = (id(received_data)[11] >> 5) & 1; | |
| id(ac_present).publish_state(ac_present_status); | |
| // CCS charging port | |
| bool fast_charging_status = (id(received_data)[11] >> 6) & 1; | |
| id(fast_charging).publish_state(fast_charging_status); | |
| // BMS Ignition (published at end of lambda) | |
| id(can_ignition_status) = (id(received_data)[52] >> 2) & 1; | |
| // Operating hours | |
| float operating_hours_float = ((id(received_data)[48] << 24) | (id(received_data)[49] << 16) | (id(received_data)[50] << 8) | id(received_data)[51]) / 3600.0; | |
| id(operating_hours).publish_state(operating_hours_float); | |
| // Extract and publish Cumulative Energy Charged | |
| if (id(received_data).size() > 48) { | |
| float cumulative_energy_charged_kw = ((id(received_data)[40] << 24) | (id(received_data)[41] << 16) | (id(received_data)[42] << 8) | id(received_data)[43]) / 10.0; | |
| float cumulative_energy_discharged_kw = ((id(received_data)[44] << 24) | (id(received_data)[45] << 16) | (id(received_data)[46] << 8) | id(received_data)[47]) / 10.0; | |
| id(cumulative_energy_charged).publish_state(cumulative_energy_charged_kw); | |
| id(cumulative_energy_discharged).publish_state(cumulative_energy_discharged_kw); | |
| } | |
| // Boot flags | |
| if (id(state_of_charge_bms).state != id(last_state_of_charge) || hv_charging_status != id(last_hv_charging_state) || ac_present_status != id(last_ac_present_state)) { | |
| id(pending_status_update) = true; | |
| id(pending_status_update_sensor).publish_state(true); | |
| id(last_state_of_charge) = id(state_of_charge_bms).state; | |
| } | |
| } | |
| else if (can_id == 0x7EA && id(received_data)[1] == 0x01) { | |
| // response from 7E2 (published end of lambda) | |
| id(can_speed_mph) = ((signed int)(id(received_data)[16]) * 256 + id(received_data)[15]) / 100.0; | |
| } | |
| else if (can_id == 0x7EE && id(received_data)[1] == 0x80 && id(received_data).size() > 14) { | |
| // response to 0x7E6, containing ambient temperature | |
| float ambient_temperature_float = (((id(received_data)[14] < 128) ? id(received_data)[14] : id(received_data)[14] - 256) - 80) / 2.0; | |
| id(ambient_temperature).publish_state(ambient_temperature_float); | |
| } | |
| else if (can_id == 0x7CE && id(received_data)[1] == 0xB0 && id(received_data).size() > 11) { | |
| // response to 7C6, containing odometer | |
| uint32_t odometer_value = (static_cast<uint32_t>(id(received_data)[9]) << 16) | | |
| (static_cast<uint32_t>(id(received_data)[10]) << 8) | | |
| static_cast<uint32_t>(id(received_data)[11]); | |
| id(odometer).publish_state(odometer_value); | |
| if (id(trip_startodo) == 0) { | |
| id(trip_startodo) = odometer_value; | |
| } | |
| } | |
| id(next_frame_seq) = 0x01; // Reset for next multi-frame message | |
| id(frame_received) = true; | |
| } | |
| } | |
| } else { | |
| ESP_LOGI("eup_dump", "Unexpected frame"); | |
| } | |
| switch: | |
| - platform: gpio | |
| id: can_enabled | |
| name: "Enable CAN Interface" | |
| pin: | |
| number: 6 | |
| inverted: true | |
| restore_mode: ALWAYS_OFF # enabled by full_wake only | |
| - platform: template | |
| id: sleep_mode | |
| name: "${device_name} Sleep Mode" | |
| optimistic: true | |
| restore_mode: RESTORE_DEFAULT_ON | |
| on_turn_on: | |
| - logger.log: "Sleep mode: ON" | |
| on_turn_off: | |
| - logger.log: "Sleep mode: OFF" | |
| interval: | |
| # every ~10 seconds while fully awake (never during a quick check) | |
| - interval: 9s | |
| then: | |
| - if: | |
| condition: | |
| switch.is_on: can_enabled | |
| then: | |
| - script.execute: update_car_sensors | |
| - script.wait: update_car_sensors | |
| sensor: | |
| - platform: template | |
| id: state_of_health | |
| name: "State of Health" | |
| unit_of_measurement: "%" | |
| accuracy_decimals: 2 | |
| state_class: measurement | |
| filters: | |
| - filter_out: nan | |
| - platform: template | |
| id: state_of_charge | |
| name: "State of Charge (Display)" | |
| unit_of_measurement: "%" | |
| accuracy_decimals: 0 | |
| device_class: battery | |
| state_class: measurement | |
| filters: | |
| - filter_out: nan | |
| - delta: 1.0 | |
| on_value: | |
| then: | |
| - lambda: if (x > 0) id(last_soc_display) = (uint8_t) x; | |
| - platform: template | |
| id: state_of_charge_bms | |
| name: "State of Charge (BMS)" | |
| unit_of_measurement: "%" | |
| accuracy_decimals: 2 | |
| device_class: battery | |
| state_class: measurement | |
| filters: | |
| - filter_out: nan | |
| - delta: 0.01 | |
| - clamp: | |
| min_value: -5 | |
| max_value: 105 | |
| ignore_out_of_range: true | |
| on_value: | |
| then: | |
| - lambda: if (x > 0) id(last_state_of_charge) = (uint8_t) x; | |
| - platform: template | |
| id: cumulative_energy_charged | |
| name: "Cumulative Energy Charged" | |
| unit_of_measurement: "kWh" | |
| accuracy_decimals: 0 | |
| device_class: energy | |
| state_class: total_increasing | |
| filters: | |
| - filter_out: nan | |
| - delta: 1.0 | |
| - platform: template | |
| id: cumulative_energy_discharged | |
| name: "Cumulative Energy Discharged" | |
| unit_of_measurement: "kWh" | |
| accuracy_decimals: 0 | |
| device_class: energy | |
| state_class: total_increasing | |
| filters: | |
| - filter_out: nan | |
| - delta: 1.0 | |
| - platform: template | |
| id: battery_current | |
| name: "Battery Current" | |
| unit_of_measurement: "A" | |
| accuracy_decimals: 2 | |
| device_class: current | |
| state_class: measurement | |
| filters: | |
| - filter_out: nan | |
| - delta: 0.2 | |
| - platform: template | |
| id: battery_power | |
| name: "Battery Power" | |
| unit_of_measurement: "kW" | |
| accuracy_decimals: 2 | |
| device_class: power | |
| state_class: measurement | |
| filters: | |
| - filter_out: nan | |
| - delta: 0.05 | |
| - platform: template | |
| id: hv_battery_voltage | |
| name: "HV Battery Voltage" | |
| unit_of_measurement: "V" | |
| accuracy_decimals: 0 | |
| device_class: voltage | |
| state_class: measurement | |
| filters: | |
| - filter_out: nan | |
| - delta: 1 | |
| - platform: template | |
| id: real_vehicle_speed | |
| name: "Real Vehicle Speed" | |
| unit_of_measurement: "km/h" | |
| accuracy_decimals: 0 | |
| device_class: speed | |
| state_class: measurement | |
| filters: | |
| - filter_out: nan | |
| - clamp: | |
| min_value: 0 | |
| max_value: 140 | |
| ignore_out_of_range: true | |
| - platform: template | |
| id: operating_hours | |
| name: "Operating hours" | |
| unit_of_measurement: "h" | |
| accuracy_decimals: 0 | |
| device_class: duration | |
| state_class: total_increasing | |
| update_interval: 5min | |
| filters: | |
| - filter_out: nan | |
| - platform: template | |
| id: odometer | |
| name: "Odometer" | |
| unit_of_measurement: "km" | |
| accuracy_decimals: 0 | |
| device_class: distance | |
| state_class: total_increasing | |
| filters: | |
| - filter_out: nan | |
| # Capture immediately so the periodic preferences sync persists it while | |
| # driving — waiting for on_shutdown loses the value on power cuts and is | |
| # subject to shutdown-hook ordering. | |
| on_value: | |
| then: | |
| - lambda: if (x > 0) id(last_odometer_value) = x; | |
| - platform: template | |
| id: ambient_temperature | |
| name: "Ambient temperature" | |
| unit_of_measurement: "°C" | |
| device_class: temperature | |
| state_class: measurement | |
| update_interval: 1min | |
| filters: | |
| - filter_out: nan | |
| - clamp: | |
| min_value: -10 | |
| max_value: 50 | |
| ignore_out_of_range: true | |
| - platform: adc | |
| id: starter_battery_voltage | |
| name: "${device_name} Battery Voltage" | |
| pin: 4 | |
| attenuation: 12db # (was 11db, renamed upstream) https://github.com/meatpiHQ/wican-fw/blob/bf212132f8e506f2c520e917daf86e53a1070302/main/sleep_mode.c#L234 | |
| filters: | |
| - lambda: return x * 116 / 16; # https://github.com/meatpiHQ/wican-fw/blob/bf212132f8e506f2c520e917daf86e53a1070302/main/sleep_mode.c#L397 | |
| - sliding_window_moving_average: | |
| window_size: 5 # Number of samples to average | |
| send_every: 5 # How often to send the averaged value | |
| send_first_at: 1 # When to send the first averaged value | |
| update_interval: 5s # How often to update the sensor reading | |
| unit_of_measurement: V | |
| accuracy_decimals: 1 | |
| device_class: voltage | |
| state_class: measurement | |
| - platform: template | |
| id: battery_temperature | |
| name: "Average battery temperature" | |
| unit_of_measurement: "°C" | |
| device_class: temperature | |
| accuracy_decimals: 1 | |
| state_class: measurement | |
| filters: | |
| - filter_out: nan | |
| - platform: template | |
| id: last_state_of_charge_sensor | |
| name: "Last state of charge value" | |
| lambda: |- | |
| return id(last_state_of_charge); | |
| filters: | |
| - delta: 1 | |
| - platform: template | |
| id: battery_power_watts | |
| internal: true | |
| unit_of_measurement: "W" | |
| device_class: power | |
| state_class: measurement | |
| - platform: template | |
| id: speed_ms | |
| internal: true | |
| unit_of_measurement: "m/s" | |
| - platform: template | |
| id: bthome_packet_id_sensor | |
| internal: true | |
| binary_sensor: | |
| # - platform: wireguard | |
| # status: | |
| # name: 'WireGuard Status' | |
| - platform: template | |
| id: pending_status_update_on_wake | |
| name: "Pending Status Update on Wakeup" | |
| - platform: template | |
| id: first_boot | |
| name: "First boot" | |
| - platform: template | |
| id: pending_status_update_sensor | |
| name: "Pending Status Update" | |
| lambda: |- | |
| return id(pending_status_update); | |
| - platform: template | |
| id: wake | |
| name: "Wake sensor" | |
| filters: | |
| - delayed_off: 1min | |
| # Ignition alone keeps the board awake (DC-DC doesn't always hold >=13V while | |
| # driving). hv_charging covers active charge sessions; ac_present deliberately | |
| # excluded so a completed charge with the cable still plugged in doesn't hold | |
| # the board awake while the DC-DC is off. | |
| lambda: |- | |
| bool ign = id(ignition).has_state() && id(ignition).state; | |
| bool charging = (id(hv_charging).has_state() && id(hv_charging).state) || | |
| (id(fast_charging).has_state() && id(fast_charging).state); | |
| bool v_charging = id(starter_battery_voltage).has_state() && | |
| id(starter_battery_voltage).state >= ${charging_voltage_threshold}; | |
| return ign || charging || v_charging || !id(sleep_mode).state; | |
| on_state: | |
| then: | |
| if: | |
| condition: | |
| binary_sensor.is_on: wake | |
| then: | |
| - logger.log: "Wake sensor is on, preventing deep sleep" | |
| - deep_sleep.prevent: deep_sleep_1 | |
| else: | |
| - if: | |
| condition: | |
| and: | |
| - binary_sensor.is_on: first_boot | |
| - script.is_running: wifi_auto_off | |
| then: | |
| # Don't let the 50s run_duration failsafe cut the first-boot | |
| # WiFi/OTA window short; wifi_auto_off re-checks when it closes. | |
| - logger.log: "Holding sleep until first-boot WiFi window closes" | |
| else: | |
| - globals.set: | |
| id: last_sleep_reason | |
| value: "2" | |
| - lambda: id(car_was_on) = id(ignition).has_state() && id(ignition).state; | |
| - deep_sleep.allow: deep_sleep_1 | |
| - platform: status | |
| id: statussensor | |
| - platform: homeassistant | |
| entity_id: input_boolean.disable_car_sleep | |
| id: disable_sleep | |
| trigger_on_initial_state: true # This is important! | |
| on_state: | |
| then: | |
| if: | |
| condition: | |
| lambda: return x; | |
| then: | |
| - switch.turn_off: sleep_mode | |
| - platform: template | |
| id: hv_charging | |
| name: "High Voltage Battery Charging" | |
| on_state: | |
| then: | |
| - globals.set: | |
| id: last_hv_charging_state | |
| value: !lambda "return x;" | |
| - platform: template | |
| id: ac_present | |
| name: "AC charger present" | |
| on_state: | |
| then: | |
| - globals.set: | |
| id: last_ac_present_state | |
| value: !lambda "return x;" | |
| - platform: template | |
| id: fast_charging | |
| name: "DC charger present" | |
| # Combined "any charger connected" (AC or DC) for the BTHome plug broadcast | |
| - platform: template | |
| id: charger_connected | |
| name: "Charger connected" | |
| lambda: |- | |
| if (!id(ac_present).has_state() && !id(fast_charging).has_state()) | |
| return {}; | |
| return (id(ac_present).has_state() && id(ac_present).state) || | |
| (id(fast_charging).has_state() && id(fast_charging).state); | |
| - platform: template | |
| id: ignition | |
| name: "Ignition" | |
| on_state: | |
| then: | |
| - globals.set: | |
| id: car_was_on | |
| value: !lambda "return x;" | |
| - platform: template | |
| id: normal_voltage | |
| name: "Starter battery normal voltage" | |
| lambda: |- | |
| return id(starter_battery_voltage).has_state() && id(starter_battery_voltage).state >= ${low_voltage_threshold}; | |
| script: | |
| - id: restore_sensor_values | |
| mode: single | |
| then: | |
| - lambda: id(first_boot).publish_state(esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_UNDEFINED); | |
| - lambda: id(odometer).publish_state(id(last_odometer_value)); | |
| - lambda: id(ac_present).publish_state(id(last_ac_present_state)); | |
| - lambda: id(hv_charging).publish_state(id(last_hv_charging_state)); | |
| - lambda: id(state_of_charge_bms).publish_state(id(last_state_of_charge)); | |
| - lambda: id(state_of_charge).publish_state(id(last_soc_display)); | |
| # Restore pending status update to a sensor so we can determine the boot reason | |
| - lambda: id(pending_status_update_on_wake).publish_state(id(pending_status_update)); | |
| # Fast 12V read for the boot decision, mirroring wican-fw sleep_mode.c: | |
| # burst-sample the ADC directly (bypassing the sensor's 5s interval and | |
| # sliding-window filter) so we can decide in ~150ms instead of ~25s. | |
| - id: read_decision_voltage | |
| mode: single | |
| then: | |
| - lambda: |- | |
| float sum = 0; | |
| int valid = 0; | |
| for (int i = 0; i < 16; i++) { | |
| float v = id(starter_battery_voltage).sample(); | |
| if (!std::isnan(v)) { | |
| sum += v; | |
| valid++; | |
| } | |
| delay(2); | |
| } | |
| id(decision_voltage) = valid > 0 ? (sum / valid) * 116.0f / 16.0f : 0.0f; | |
| ESP_LOGI("boot", "Decision voltage: %.2fV (%d samples, car_was_on=%d)", | |
| id(decision_voltage), valid, id(car_was_on)); | |
| - id: full_wake | |
| mode: single | |
| parameters: | |
| open_wifi: bool | |
| then: | |
| - deep_sleep.prevent: deep_sleep_1 | |
| - light.turn_on: | |
| id: blue_led | |
| effect: "None" | |
| - switch.turn_on: can_enabled | |
| - delay: 100ms # transceiver wake-up | |
| # - ble.enable: | |
| - if: | |
| condition: | |
| lambda: return open_wifi; | |
| then: | |
| - wifi.enable: | |
| - light.turn_on: | |
| id: green_led | |
| effect: "searching" | |
| - script.execute: wifi_auto_off | |
| - script.execute: update_car_sensors | |
| - script.wait: update_car_sensors | |
| - lambda: id(car_was_on) = id(ignition).has_state() && id(ignition).state; | |
| # The wake sensor now decides whether we stay awake or go back to sleep. | |
| # BLE-only broadcast of last-known values so Home Assistant's BTHome | |
| # sensors stay fresh (and prove the device alive) during long parks. | |
| - id: ble_beacon | |
| mode: single | |
| then: | |
| - logger.log: "Broadcasting BLE beacon with last-known values" | |
| # - ble.enable: | |
| - lambda: |- | |
| id(bthome_packet_id)++; | |
| id(bthome_packet_id_sensor).publish_state((float)id(bthome_packet_id)); | |
| id(state_of_charge).publish_state(id(last_soc_display)); | |
| id(state_of_charge_bms).publish_state(id(last_state_of_charge)); | |
| id(hv_charging).publish_state(id(last_hv_charging_state)); | |
| id(odometer).publish_state(id(last_odometer_value)); | |
| id(battery_power_watts).publish_state(0.0f); | |
| id(speed_ms).publish_state(0.0f); | |
| - delay: ${beacon_duration} | |
| # - ble.disable: | |
| - id: update_car_sensors | |
| mode: queued | |
| then: | |
| - logger.log: | |
| format: "Sending canbus status request" | |
| level: INFO | |
| - globals.set: | |
| id: frame_received | |
| value: "false" | |
| - lambda: |- | |
| id(can_battery_current_signed) = 0; | |
| id(can_battery_power) = 0; | |
| id(can_ignition_status) = false; | |
| id(can_speed_mph) = 0; | |
| id(can_battery_dc_voltage) = 0; | |
| - light.turn_on: | |
| id: yellow_led | |
| effect: "None" | |
| - canbus.send: | |
| # 7E4 - 2101, returns on 7EC - | |
| can_id: 0x7E4 | |
| data: [0x02, 0x21, 0x01, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA] | |
| - wait_until: | |
| condition: | |
| lambda: return id(frame_received); | |
| timeout: 1sec | |
| - globals.set: | |
| id: frame_received | |
| value: "false" | |
| - canbus.send: | |
| # 7E4 - 2105, returns on 7EC | |
| can_id: 0x7E4 | |
| data: [0x02, 0x21, 0x05, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA] | |
| - wait_until: | |
| condition: | |
| lambda: return id(frame_received); | |
| timeout: 1sec | |
| - lambda: |- | |
| // Update sensors | |
| id(ignition).publish_state(id(can_ignition_status)); | |
| id(battery_current).publish_state(id(can_battery_current_signed)); | |
| id(battery_power).publish_state((id(can_battery_dc_voltage) * id(can_battery_current_signed))/1000); | |
| id(hv_battery_voltage).publish_state(id(can_battery_dc_voltage)); | |
| - if: | |
| condition: | |
| binary_sensor.is_on: ignition | |
| then: | |
| - globals.set: | |
| id: frame_received | |
| value: "false" | |
| - canbus.send: | |
| # 7E2 - 2101, Real vehicle speed, returns on 7EA | |
| can_id: 0x7E2 | |
| data: [0x02, 0x21, 0x01, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA] | |
| - wait_until: | |
| condition: | |
| lambda: return id(frame_received); | |
| timeout: 1sec | |
| - globals.set: | |
| id: frame_received | |
| value: "false" | |
| - canbus.send: | |
| # Ambient temperature returns on 7EE | |
| can_id: 0x7E6 | |
| data: [0x02, 0x21, 0x80, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA] | |
| - wait_until: | |
| condition: | |
| lambda: return id(frame_received); | |
| timeout: 1sec | |
| - globals.set: | |
| id: frame_received | |
| value: "false" | |
| - canbus.send: | |
| # Odometer | |
| can_id: 0x7C6 # Returns on 7CE | |
| data: [0x03, 0x22, 0xB0, 0x02, 0xAA, 0xAA, 0xAA, 0xAA] | |
| - wait_until: | |
| condition: | |
| lambda: return id(frame_received); | |
| timeout: 1sec | |
| - lambda: |- | |
| id(real_vehicle_speed).publish_state(id(can_speed_mph) * 1.60934); | |
| - light.turn_off: yellow_led | |
| - lambda: |- | |
| id(bthome_packet_id)++; | |
| id(bthome_packet_id_sensor).publish_state((float)id(bthome_packet_id)); | |
| id(battery_power_watts).publish_state( | |
| id(battery_power).has_state() ? id(battery_power).state * 1000.0f : 0.0f); | |
| id(speed_ms).publish_state( | |
| id(real_vehicle_speed).has_state() ? id(real_vehicle_speed).state / 3.6f : 0.0f); | |
| - id: wifi_auto_off | |
| mode: single | |
| then: | |
| - delay: ${wifi_first_boot_duration} | |
| - wifi.disable: | |
| - logger.log: "WiFi disabled after first-boot window" | |
| - light.turn_off: green_led | |
| # The wake sensor may have gone off while the window was open (sleep was | |
| # held, see wake on_state) — re-evaluate now that the window has closed. | |
| - if: | |
| condition: | |
| binary_sensor.is_off: wake | |
| then: | |
| - globals.set: | |
| id: last_sleep_reason | |
| value: "2" | |
| - lambda: id(car_was_on) = id(ignition).has_state() && id(ignition).state; | |
| - deep_sleep.allow: deep_sleep_1 | |
| bthome: | |
| ble_stack: nimble | |
| min_interval: 2s | |
| max_interval: 5s | |
| tx_power: 6 | |
| sensors: | |
| # - type: packet_id | |
| # id: bthome_packet_id_sensor | |
| - type: battery | |
| id: state_of_charge | |
| - type: power_sint32 | |
| id: battery_power_watts | |
| - type: speed | |
| id: speed_ms | |
| - type: count_uint32 | |
| id: odometer | |
| - type: temperature_sint8 | |
| id: battery_temperature | |
| binary_sensors: | |
| - type: plug | |
| id: charger_connected | |
| - type: battery_charging | |
| id: hv_charging |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
would you like to share your config?
Edit: got it working