# Connects to an OWON BT35T/BT35T+ multimeter and an Atorch DL24 dc-load and mirrors their # displays. Also sends those values to Home Assistant for logging and dashboard display. # Also exposes actionable buttons to HA to control the devices. # # Derived work based on https://github.com/reaper7/M5Stack_BLE_client_Owon_B35T by reaper7. # AI (ChatGPT (GPT5.5) has been used to adopt the Arduino sketch to ESPHome # Ported to M5Stack Core2 due to memory constraints. # Integrated Atorch BLE proxy functionality from https://github.com/syssi/esphome-atorch-dl24 by syssi. # Soft buttons work as indicated for OWON meter. Left/right so select action, middle to execute. # Long-press is supported. Display switches to last connected device, tap center of screen to # switch. The dc-load has no on-device buttons, but both are available from HA. substitutions: name: "lab-ble-proxy" friendly_name: "Lab BLE Proxy" device_description: "M5Stack Core2 BLE client for OWON B35T/B35T+ multimeter and Atorch DL24 DC load" owon_mac_address: !secret owon_b35t_mac_address dl24_mac_address: !secret dl24_mac_address external_components_source: github://syssi/esphome-atorch-dl24@main atorch_project_version: "2.1.0" esphome: name: ${name} friendly_name: ${friendly_name} comment: ${device_description} min_version: 2024.6.0 includes: - lab-ble-proxy.h on_boot: priority: 850 then: # The Core2 LCD/backlight rails are controlled by the AXP192 PMIC. ESPHome's # stock M5CORE2 display setup does not initialize those rails here, so the # custom header performs the M5Stack-specific power sequencing once I2C exists. - lambda: |- owon_b35t::core2_axp192_init(id(core2_i2c)); project: name: "custom.lab-ble-proxy-m5stack-core2" version: "1.0" esp32: board: m5stack-core2 flash_size: 16MB framework: type: esp-idf advanced: minimum_chip_revision: "3.1" sram1_as_iram: true psram: mode: quad speed: 80MHz external_components: - source: ${external_components_source} refresh: 0s logger: level: INFO api: encryption: key: !secret apikey ota: platform: esphome password: !secret ota wifi: ssid: "Voltage-legacy" password: !secret voltage_legacy_psk #use_address: ${name}.home power_save_mode: none fast_connect: on min_auth_mode: WPA2 ap: ssid: "Lab BLE Proxy Fallback Hotspot" password: !secret fallback_psk captive_portal: globals: # 0 = OWON meter page, 1 = Atorch DL24 page. This is intentionally volatile so # every boot starts with the multimeter view until one device connects. - id: display_page type: int initial_value: "0" restore_value: no # The Atorch external component does not expose a simple connected flag that the # display lambda can query, so BLE callbacks maintain this state explicitly. - id: atorch_connected type: bool initial_value: "false" restore_value: no script: - id: wake_backlight mode: restart then: - script.stop: backlight_idle - light.turn_on: id: backlight brightness: 100% - id: backlight_idle mode: restart then: - delay: 2min - if: condition: # Only dim when both BLE devices are disconnected; active lab instruments # keep the display fully lit so readings remain visible at a glance. lambda: |- return !owon_meter.connected && !id(atorch_connected); then: - light.turn_on: id: backlight brightness: 70% - delay: 3min - if: condition: # Re-check before turning the backlight off, because a connection may # have been established during the preceding delay. lambda: |- return !owon_meter.connected && !id(atorch_connected); then: - light.turn_off: backlight interval: - interval: 10s then: # Periodic memory telemetry is useful on the Core2 because BLE, PSRAM, the # MIPI framebuffer, and external components can fragment different heap pools. - lambda: |- ESP_LOGI("mem", "heap free=%u min_free=%u internal_free=%u internal_largest=%u dma_free=%u dma_largest=%u psram_free=%u psram_largest=%u", static_cast(esp_get_free_heap_size()), static_cast(esp_get_minimum_free_heap_size()), static_cast(heap_caps_get_free_size(MALLOC_CAP_INTERNAL)), static_cast(heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL)), static_cast(heap_caps_get_free_size(MALLOC_CAP_DMA)), static_cast(heap_caps_get_largest_free_block(MALLOC_CAP_DMA)), static_cast(heap_caps_get_free_size(MALLOC_CAP_SPIRAM)), static_cast(heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM))); esp32_ble_tracker: scan_parameters: active: true continuous: true ble_client: - mac_address: ${owon_mac_address} id: owon_ble_client on_connect: then: - script.execute: wake_backlight # Notify the custom parser/display object and switch to the meter page when # the OWON connects, so the on-device screen follows the active instrument. - lambda: |- owon_meter.on_connect(); id(display_page) = 0; id(lcd).update(); on_disconnect: then: # Keep the UI useful after a disconnect: if the Atorch is still online, # automatically fall back from the OWON page to the Atorch page. - lambda: |- owon_meter.on_disconnect(); if (id(display_page) == 0 && id(atorch_connected)) { id(display_page) = 1; } id(lcd).update(); - if: condition: # Start the idle timer only after both BLE clients are gone; otherwise # the remaining connected instrument still deserves an active display. lambda: |- return !owon_meter.connected && !id(atorch_connected); then: - script.execute: backlight_idle - mac_address: ${dl24_mac_address} id: atorch_ble_client on_connect: then: - script.execute: wake_backlight # The Atorch component handles measurements, while this explicit flag/page # switch keeps the shared display state synchronized with the BLE client. - lambda: |- id(atorch_connected) = true; id(display_page) = 1; id(lcd).update(); on_disconnect: then: # If the load disconnects while the meter is still connected, move the # physical display back to the meter instead of leaving a stale load page. - lambda: |- id(atorch_connected) = false; if (id(display_page) == 1 && owon_meter.connected) { id(display_page) = 0; } id(lcd).update(); - if: condition: # Same idle rule as OWON disconnect: only dim/off when neither BLE # target is connected anymore. lambda: |- return !owon_meter.connected && !id(atorch_connected); then: - script.execute: backlight_idle atorch_dl24: - id: atorch0 ble_client_id: atorch_ble_client check_crc: false throttle: 0s spi: clk_pin: GPIO18 mosi_pin: GPIO23 i2c: id: core2_i2c sda: GPIO21 scl: GPIO22 scan: true output: - platform: template type: float id: lcd_backlight write_action: # Bridge ESPHome's generic monochromatic light level to the Core2-specific # AXP192 backlight voltage helper implemented in lab-ble-proxy.h. - lambda: |- owon_b35t::core2_axp192_set_backlight(state); light: - platform: monochromatic output: lcd_backlight name: "${friendly_name} Backlight" id: backlight restore_mode: ALWAYS_ON font: - file: "fonts/Roboto-Regular.ttf" id: meter_font size: 15 # Glyphs are whitelisted to save flash/RAM; include symbols used by units, # status labels, soft-button names, and Atorch metric text. glyphs: [ " ", "!", "%", "+", "-", ".", "/", ":", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "<", ">", "A", "B", "C", "D", "E", "F", "G", "H", "I", "L", "M", "N", "O", "P", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "a", "b", "c", "d", "e", "f", "g", "h", "i", "k", "l", "m", "n", "o", "p", "r", "s", "t", "u", "v", "w", "y", "z", "°", "µ", "Ω", ] - file: "fonts/Roboto-Medium.ttf" id: atorch_value_font size: 22 # Larger Atorch value font only needs numeric characters and measurement units. glyphs: [ " ", "+", "-", ".", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "A", "C", "V", "W", "h", "°", ] display: - platform: mipi_spi id: lcd model: M5CORE2 update_interval: 500ms # Rendering is delegated to the custom C++ helper because ESPHome display YAML # primitives would be unwieldy for the OWON seven-segment clone and Atorch dashboard. lambda: |- owon_meter.render( it, id(meter_font), id(atorch_value_font), id(display_page), id(atorch_connected), id(atorch_voltage).has_state() ? id(atorch_voltage).state : NAN, id(atorch_current).has_state() ? id(atorch_current).state : NAN, id(atorch_power).has_state() ? id(atorch_power).state : NAN, id(atorch_capacity).has_state() ? id(atorch_capacity).state : NAN, id(atorch_energy_calculated).has_state() ? id(atorch_energy_calculated).state : NAN, id(atorch_temperature).has_state() ? id(atorch_temperature).state : NAN ); touchscreen: - platform: ft63x6 id: touch display: lcd on_touch: then: - script.execute: wake_backlight - if: condition: # Touching an idle/disconnected unit wakes the display briefly, then # restarts the idle timer if no BLE target is available. lambda: |- return !owon_meter.connected && !id(atorch_connected); then: - script.execute: backlight_idle binary_sensor: - platform: touchscreen touchscreen_id: touch id: btn_toggle_page x_min: 70 x_max: 250 y_min: 45 y_max: 195 on_press: then: # The large middle touch zone toggles between the two custom render pages. - lambda: |- id(display_page) = 1 - id(display_page); id(lcd).update(); - platform: touchscreen id: button_a touchscreen_id: touch x_min: 34 x_max: 74 y_min: 212 y_max: 240 internal: true on_press: then: # Left soft button changes which OWON remote command the center button will send. - lambda: |- owon_meter.previous_button(); - platform: touchscreen id: button_b touchscreen_id: touch x_min: 108 x_max: 208 y_min: 212 y_max: 240 internal: true on_click: - min_length: 50ms max_length: 1500ms then: - logger.log: level: INFO format: "OWON short press: %s" args: ["owon_meter.selected_button_name()"] - ble_client.ble_write: id: owon_ble_client service_uuid: "0000fff0-0000-1000-8000-00805f9b34fb" characteristic_uuid: "0000fff3-0000-1000-8000-00805f9b34fb" # OWON remote-control payload: first byte selects the meter button, # second byte selects the normal/short-press action variant. value: !lambda |- std::vector data = {owon_meter.selected_button, 0x01}; return data; - min_length: 1500ms max_length: 5000ms then: - logger.log: level: INFO format: "OWON long press: %s" args: ["owon_meter.selected_button_name()"] - ble_client.ble_write: id: owon_ble_client service_uuid: "0000fff0-0000-1000-8000-00805f9b34fb" characteristic_uuid: "0000fff3-0000-1000-8000-00805f9b34fb" # Long-press encoding is not uniform: SELECT and HZ/DUTY still use # 0x01, while RANGE/HOLD/REL/MAX-MIN use 0x00 for their alternate action. value: !lambda |- uint8_t press_type = (owon_meter.selected_button == 1 || owon_meter.selected_button == 5) ? 0x01 : 0x00; std::vector data = {owon_meter.selected_button, press_type}; return data; - platform: touchscreen id: button_c touchscreen_id: touch x_min: 242 x_max: 282 y_min: 212 y_max: 240 internal: true on_press: then: # Right soft button advances to the next OWON command label. - lambda: |- owon_meter.next_button(); - platform: template name: "${friendly_name} OWON Connected" icon: "mdi:bluetooth-connect" # Template binary sensors expose state maintained by the custom OWON parser object. lambda: |- return owon_meter.connected; - platform: template name: "${friendly_name} OWON Overload" icon: "mdi:debug-step-over" lambda: |- return owon_meter.overload; - platform: template name: "${friendly_name} OWON Low Battery" icon: "mdi:battery-low" lambda: |- return owon_meter.low_battery; - platform: template name: "${friendly_name} Atorch Connected" device_class: connectivity icon: "mdi:bluetooth-connect" lambda: |- return id(atorch_connected); sensor: # This hidden characteristic sensor is the bridge from OWON BLE notifications into # custom C++ parsing. The returned float becomes the source numeric meter reading. - platform: ble_client type: characteristic ble_client_id: owon_ble_client id: owon_notify_source internal: true service_uuid: "0000fff0-0000-1000-8000-00805f9b34fb" characteristic_uuid: "0000fff4-0000-1000-8000-00805f9b34fb" notify: true update_interval: never lambda: |- owon_meter.handle_notify(x); return owon_meter.value(); - platform: wifi_signal name: "${friendly_name} WiFi Signal" update_interval: 60s - platform: atorch_dl24 atorch_dl24_id: atorch0 voltage: name: "${friendly_name} Atorch Voltage" id: atorch_voltage current: name: "${friendly_name} Atorch Current" id: atorch_current power: name: "${friendly_name} Atorch Power" id: atorch_power capacity: name: "${friendly_name} Atorch Capacity" id: atorch_capacity temperature: name: "${friendly_name} Atorch Temperature" id: atorch_temperature dim_backlight: name: "${friendly_name} Atorch Dim Backlight" id: atorch_dim_backlight # The DL24 component exposes power, but this config computes Wh locally so the # accumulated energy can be restored and reset alongside Atorch's own counters. - platform: integration name: "${friendly_name} Atorch Energy" id: atorch_energy_calculated sensor: atorch_power time_unit: h unit_of_measurement: "Wh" icon: "mdi:lightning-bolt" device_class: energy state_class: total_increasing accuracy_decimals: 3 restore: true integration_method: trapezoid text_sensor: - platform: template name: "${friendly_name} OWON Reading" update_interval: 1s icon: "mdi:gauge" # Human-readable mirror of the meter LCD, including waiting/disconnected/OL states. lambda: |- return owon_meter.reading_text(); - platform: template name: "${friendly_name} OWON Unit" update_interval: 1s icon: "mdi:ruler" # Combines SI prefix and unit into one HA text entity, e.g. mV, kΩ, µA, %. lambda: |- return std::string(owon_meter.scale()) + owon_meter.unit(); - platform: template name: "${friendly_name} OWON Mode" icon: "mdi:knob" update_interval: 1s # Exposes decoded annunciators such as AC/DC, AUTO, HOLD, REL, MIN/MAX. lambda: |- return owon_meter.mode_text(); button: - platform: atorch_dl24 atorch_dl24_id: atorch0 reset_energy: name: "${friendly_name} Atorch Reset Energy" on_press: - sensor.integration.reset: atorch_energy_calculated reset_capacity: name: "${friendly_name} Atorch Reset Capacity" reset_runtime: name: "${friendly_name} Atorch Reset Runtime" reset_all: name: "${friendly_name} Atorch Reset All" on_press: # Keep the locally integrated Wh counter in sync with the DL24 reset-all command. - sensor.integration.reset: atorch_energy_calculated usb_plus: name: "${friendly_name} Atorch Plus" usb_minus: name: "${friendly_name} Atorch Minus" setup: name: "${friendly_name} Atorch Setup" enter: name: "${friendly_name} Atorch Enter" # Home Assistant button entities for direct OWON remote commands. The payloads # are the same two-byte command format used by the touchscreen soft buttons. - platform: template name: "OWON SELECT" id: owon_btn_select on_press: - ble_client.ble_write: id: owon_ble_client service_uuid: "0000fff0-0000-1000-8000-00805f9b34fb" characteristic_uuid: "0000fff3-0000-1000-8000-00805f9b34fb" # SELECT normal press. value: !lambda "return std::vector({1, 0x01});" - platform: template name: "OWON RANGE" id: owon_btn_range on_press: - ble_client.ble_write: id: owon_ble_client service_uuid: "0000fff0-0000-1000-8000-00805f9b34fb" characteristic_uuid: "0000fff3-0000-1000-8000-00805f9b34fb" # RANGE normal press. value: !lambda "return std::vector({2, 0x01});" - platform: template name: "OWON HOLD " id: owon_btn_hold on_press: - ble_client.ble_write: id: owon_ble_client service_uuid: "0000fff0-0000-1000-8000-00805f9b34fb" characteristic_uuid: "0000fff3-0000-1000-8000-00805f9b34fb" # HOLD is the short/normal action on the shared HLD/LIG physical key. value: !lambda "return std::vector({3, 0x01});" - platform: template name: "OWON LIGHT" id: owon_btn_light on_press: - ble_client.ble_write: id: owon_ble_client service_uuid: "0000fff0-0000-1000-8000-00805f9b34fb" characteristic_uuid: "0000fff3-0000-1000-8000-00805f9b34fb" # LIGHT is the alternate/long action on the same HLD/LIG key. value: !lambda "return std::vector({3, 0x00});" - platform: template name: "OWON RELATIVE" id: owon_btn_rel on_press: - ble_client.ble_write: id: owon_ble_client service_uuid: "0000fff0-0000-1000-8000-00805f9b34fb" characteristic_uuid: "0000fff3-0000-1000-8000-00805f9b34fb" # RELATIVE is the short/normal action on the shared REL/BT key. value: !lambda "return std::vector({4, 0x01});" - platform: template name: "OWON BT" id: owon_btn_bt on_press: - ble_client.ble_write: id: owon_ble_client service_uuid: "0000fff0-0000-1000-8000-00805f9b34fb" characteristic_uuid: "0000fff3-0000-1000-8000-00805f9b34fb" # BT is the alternate/long action on the same REL/BT key. value: !lambda "return std::vector({4, 0x00});" - platform: template name: "OWON HZ | DUTY" id: owon_btn_hz on_press: - ble_client.ble_write: id: owon_ble_client service_uuid: "0000fff0-0000-1000-8000-00805f9b34fb" characteristic_uuid: "0000fff3-0000-1000-8000-00805f9b34fb" # HZ/DUTY command uses the normal action variant. value: !lambda "return std::vector({5, 0x01});" - platform: template name: "OWON MAX | MIN" id: owon_btn_maxmin on_press: - ble_client.ble_write: id: owon_ble_client service_uuid: "0000fff0-0000-1000-8000-00805f9b34fb" characteristic_uuid: "0000fff3-0000-1000-8000-00805f9b34fb" # MAX/MIN command uses the normal action variant. value: !lambda "return std::vector({6, 0x01});"