From 4bd62f539c4792bafac85cb1625c5427feb31de7 Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Tue, 26 May 2026 13:45:06 +0200 Subject: [PATCH 01/29] Initial AI-assisted esphome device code for Owon B35T+ multimeter. --- esphome/owon-b35t.yaml | 388 ++++++++++++++++++++++++++++++++++++ esphome/owon_b35t.h | 439 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 827 insertions(+) create mode 100644 esphome/owon-b35t.yaml create mode 100644 esphome/owon_b35t.h diff --git a/esphome/owon-b35t.yaml b/esphome/owon-b35t.yaml new file mode 100644 index 0000000..4e3722a --- /dev/null +++ b/esphome/owon-b35t.yaml @@ -0,0 +1,388 @@ +substitutions: + name: "owon-b35t" + friendly_name: "OWON B35T Multimeter" + device_description: "M5Stack Core 1 BLE client for OWON B35T/B35T+ multimeter with local graphical display" + owon_mac_address: !secret owon_b35t_mac_address + +esphome: + name: ${name} + friendly_name: ${friendly_name} + comment: ${device_description} + min_version: 2024.6.0 + includes: + - owon_b35t.h + project: + name: "custom.owon-b35t-m5stack" + version: "1.0" + +esp32: + board: m5stack-core-esp32 + framework: + type: esp-idf + advanced: + minimum_chip_revision: "3.1" + +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: "OWON B35T Fallback Hotspot" + password: !secret fallback_psk + +captive_portal: + +esp32_ble_tracker: + scan_parameters: + active: true + +ble_client: + - mac_address: ${owon_mac_address} + id: owon_ble_client + on_connect: + then: + - lambda: |- + owon_meter.on_connect(); + on_disconnect: + then: + - lambda: |- + owon_meter.on_disconnect(); + +spi: + clk_pin: GPIO18 + mosi_pin: GPIO23 + miso_pin: GPIO19 + +output: + - platform: ledc + pin: GPIO32 + id: lcd_backlight + +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: + [ + " ", + "!", + "%", + "+", + "-", + ".", + "/", + "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", + "c", + "d", + "e", + "f", + "g", + "i", + "k", + "m", + "n", + "o", + "r", + "s", + "t", + "u", + "v", + "w", + "y", + "z", + "°", + "µ", + "Ω", + ] + +display: + - platform: ili9xxx + id: lcd + model: M5STACK + cs_pin: GPIO14 + dc_pin: GPIO27 + reset_pin: GPIO33 + invert_colors: false + color_palette: 8BIT + rotation: 0 + update_interval: 500ms + lambda: |- + owon_meter.render(it, id(meter_font)); + +binary_sensor: + - platform: gpio + id: button_a + pin: + number: GPIO39 + inverted: true + internal: true + on_press: + then: + - lambda: |- + owon_meter.previous_button(); + - platform: gpio + id: button_b + pin: + number: GPIO38 + inverted: true + 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" + 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" + 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: gpio + id: button_c + pin: + number: GPIO37 + inverted: true + internal: true + on_press: + then: + - lambda: |- + owon_meter.next_button(); + + - platform: template + name: "${friendly_name} Connected" + lambda: |- + return owon_meter.connected; + - platform: template + name: "${friendly_name} Overload" + lambda: |- + return owon_meter.overload; + - platform: template + name: "${friendly_name} Low Battery" + lambda: |- + return owon_meter.low_battery; + - platform: template + name: "${friendly_name} Auto Range" + lambda: |- + return owon_meter.auto_range(); + - platform: template + name: "${friendly_name} Hold" + lambda: |- + return owon_meter.hold(); + - platform: template + name: "${friendly_name} Relative" + lambda: |- + return owon_meter.relative(); + - platform: template + name: "${friendly_name} AC" + lambda: |- + return owon_meter.ac(); + - platform: template + name: "${friendly_name} DC" + lambda: |- + return owon_meter.dc(); + - platform: template + name: "${friendly_name} Continuity" + lambda: |- + return owon_meter.continuity(); + - platform: template + name: "${friendly_name} Diode" + lambda: |- + return owon_meter.diode(); + +sensor: + - 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: template + name: "${friendly_name} Display Value" + id: owon_display_value + accuracy_decimals: 6 + update_interval: 1s + lambda: |- + return owon_meter.has_reading && !owon_meter.overload ? owon_meter.value() : NAN; + + - platform: template + name: "${friendly_name} Base Value" + id: owon_base_value + accuracy_decimals: 9 + update_interval: 1s + lambda: |- + return owon_meter.has_reading && !owon_meter.overload ? owon_meter.value_base() : NAN; + + - platform: template + name: "${friendly_name} Voltage" + device_class: voltage + unit_of_measurement: "V" + accuracy_decimals: 6 + update_interval: 1s + lambda: |- + return owon_meter.kind() == owon_b35t::Meter::KIND_VOLTAGE && !owon_meter.overload ? owon_meter.value_base() : NAN; + + - platform: template + name: "${friendly_name} Current" + device_class: current + unit_of_measurement: "A" + accuracy_decimals: 6 + update_interval: 1s + lambda: |- + return owon_meter.kind() == owon_b35t::Meter::KIND_CURRENT && !owon_meter.overload ? owon_meter.value_base() : NAN; + + - platform: template + name: "${friendly_name} Resistance" + unit_of_measurement: "Ω" + accuracy_decimals: 3 + update_interval: 1s + lambda: |- + return owon_meter.kind() == owon_b35t::Meter::KIND_RESISTANCE && !owon_meter.overload ? owon_meter.value_base() : NAN; + + - platform: template + name: "${friendly_name} Frequency" + device_class: frequency + unit_of_measurement: "Hz" + accuracy_decimals: 3 + update_interval: 1s + lambda: |- + return owon_meter.kind() == owon_b35t::Meter::KIND_FREQUENCY && !owon_meter.overload ? owon_meter.value_base() : NAN; + + - platform: template + name: "${friendly_name} Capacitance" + unit_of_measurement: "F" + accuracy_decimals: 12 + update_interval: 1s + lambda: |- + return owon_meter.kind() == owon_b35t::Meter::KIND_CAPACITANCE && !owon_meter.overload ? owon_meter.value_base() : NAN; + + - platform: template + name: "${friendly_name} Temperature" + device_class: temperature + unit_of_measurement: "°C" + accuracy_decimals: 2 + update_interval: 1s + lambda: |- + if (owon_meter.kind() == owon_b35t::Meter::KIND_TEMP_C && !owon_meter.overload) return owon_meter.value(); + if (owon_meter.kind() == owon_b35t::Meter::KIND_TEMP_F && !owon_meter.overload) return (owon_meter.value() - 32.0f) * 5.0f / 9.0f; + return NAN; + + - platform: template + name: "${friendly_name} Duty Cycle" + unit_of_measurement: "%" + accuracy_decimals: 2 + update_interval: 1s + lambda: |- + return owon_meter.kind() == owon_b35t::Meter::KIND_DUTY && !owon_meter.overload ? owon_meter.value() : NAN; + +text_sensor: + - platform: template + name: "${friendly_name} Reading" + update_interval: 1s + lambda: |- + return owon_meter.reading_text(); + - platform: template + name: "${friendly_name} Unit" + update_interval: 1s + lambda: |- + return std::string(owon_meter.scale()) + owon_meter.unit(); + - platform: template + name: "${friendly_name} Mode" + update_interval: 1s + lambda: |- + return owon_meter.mode_text(); + - platform: template + name: "${friendly_name} Meter Type" + update_interval: 1s + lambda: |- + return owon_meter.is_plus ? std::string("B35T+") : std::string("B35T"); + - platform: template + name: "${friendly_name} Selected Button" + update_interval: 1s + lambda: |- + return std::string(owon_meter.selected_button_name()); diff --git a/esphome/owon_b35t.h b/esphome/owon_b35t.h new file mode 100644 index 0000000..0f27633 --- /dev/null +++ b/esphome/owon_b35t.h @@ -0,0 +1,439 @@ +/* + * ESPHome helper for OWON B35T/B35T+ BLE meter on M5Stack Core 1. + * Parser is based on the standalone Arduino sketch by Reaper7 + * (Beerware license, Revision 42) and Dean Cording's owonb35 notes. + */ +#pragma once + +#include +#include +#include +#include +#include +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/components/display/display.h" + +namespace owon_b35t { + +using esphome::Color; +using esphome::display::Display; + +static const char *const TAG = "owon_b35t"; + +class Meter { + public: + static constexpr uint8_t REGPLUSMINUS = 0x00; + static constexpr uint8_t FLAGPLUS = 0b00101011; + static constexpr uint8_t FLAGMINUS = 0b00101101; + static constexpr uint8_t REGDIG1 = 0x01; + static constexpr uint8_t REGDIG2 = 0x02; + static constexpr uint8_t REGDIG3 = 0x03; + static constexpr uint8_t REGDIG4 = 0x04; + static constexpr uint8_t REGPOINT = 0x06; + static constexpr uint8_t FLAGPOINT0 = 0b00110000; + static constexpr uint8_t FLAGPOINT1 = 0b00110001; + static constexpr uint8_t FLAGPOINT2 = 0b00110010; + static constexpr uint8_t FLAGPOINT3 = 0b00110100; + static constexpr uint8_t REGMODE = 0x07; + static constexpr uint8_t FLAGMODEHOLD = 0b00000010; + static constexpr uint8_t FLAGMODEREL = 0b00000100; + static constexpr uint8_t FLAGMODEAC = 0b00001000; + static constexpr uint8_t FLAGMODEDC = 0b00010000; + static constexpr uint8_t FLAGMODEAUTO = 0b00100000; + static constexpr uint8_t REGMINMAX = 0x08; + static constexpr uint8_t FLAGMIN = 0b00010000; + static constexpr uint8_t FLAGMAX = 0b00100000; + static constexpr uint8_t REGSCALE = 0x09; + static constexpr uint8_t FLAGSCALEDUTY = 0b00000010; + static constexpr uint8_t FLAGSCALEDIODE = 0b00000100; + static constexpr uint8_t FLAGSCALEBUZZ = 0b00001000; + static constexpr uint8_t FLAGSCALEMEGA = 0b00010000; + static constexpr uint8_t FLAGSCALEKILO = 0b00100000; + static constexpr uint8_t FLAGSCALEMILLI = 0b01000000; + static constexpr uint8_t FLAGSCALEMICRO = 0b10000000; + static constexpr uint8_t REGUNIT = 0x0a; + static constexpr uint8_t FLAGUNITFAHR = 0b00000001; + static constexpr uint8_t FLAGUNITGRAD = 0b00000010; + static constexpr uint8_t FLAGUNITNF = 0b00000100; + static constexpr uint8_t FLAGUNITHZ = 0b00001000; + static constexpr uint8_t FLAGUNITHFE = 0b00010000; + static constexpr uint8_t FLAGUNITOHM = 0b00100000; + static constexpr uint8_t FLAGUNITAMP = 0b01000000; + static constexpr uint8_t FLAGUNITVOLT = 0b10000000; + + bool connected{false}; + bool write_available{false}; + bool is_plus{false}; + bool low_battery{false}; + bool overload{false}; + bool has_reading{false}; + uint8_t selected_button{1}; + uint32_t last_notify_ms{0}; + + bool handle_notify(const std::vector &data) { + if (data.size() > sizeof(this->raw_)) + return false; + if (data.size() == 6 && data[1] >= 0xF0) { + memset(this->raw_, 0, sizeof(this->raw_)); + memcpy(this->raw_, data.data(), data.size()); + this->is_plus = true; + this->parse_plus_(); + } else if (data.size() == 14 && data[12] == 0x0D && data[13] == 0x0A) { + memset(this->value_, 0, sizeof(this->value_)); + memcpy(this->value_, data.data(), data.size()); + this->is_plus = false; + } else { + ESP_LOGW(TAG, "Ignoring unexpected OWON frame length=%u", static_cast(data.size())); + return false; + } + + this->overload = memcmp(this->value_, OVERLOAD_FRAME, sizeof(OVERLOAD_FRAME)) == 0; + this->display_value = this->calc_display_value_(); + this->base_value = this->calc_base_value_(); + this->has_reading = true; + this->last_notify_ms = millis(); + return true; + } + + void on_connect() { + this->connected = true; + this->write_available = true; + } + + void on_disconnect() { + this->connected = false; + this->write_available = false; + } + + float value() const { return this->display_value; } + float value_base() const { return this->base_value; } + bool negative() const { return (this->value_[REGPLUSMINUS] & FLAGMINUS) == FLAGMINUS; } + bool auto_range() const { return (this->value_[REGMODE] & FLAGMODEAUTO) == FLAGMODEAUTO; } + bool hold() const { return (this->value_[REGMODE] & FLAGMODEHOLD) == FLAGMODEHOLD; } + bool relative() const { return (this->value_[REGMODE] & FLAGMODEREL) == FLAGMODEREL; } + bool ac() const { return (this->value_[REGMODE] & FLAGMODEAC) == FLAGMODEAC; } + bool dc() const { return (this->value_[REGMODE] & FLAGMODEDC) == FLAGMODEDC; } + bool min_mode() const { return (this->value_[REGMINMAX] & FLAGMIN) == FLAGMIN; } + bool max_mode() const { return (this->value_[REGMINMAX] & FLAGMAX) == FLAGMAX; } + bool diode() const { return (this->value_[REGSCALE] & FLAGSCALEDIODE) == FLAGSCALEDIODE; } + bool continuity() const { return (this->value_[REGSCALE] & FLAGSCALEBUZZ) == FLAGSCALEBUZZ; } + + const char *unit() const { + switch (this->value_[REGUNIT]) { + case FLAGUNITFAHR: return "°F"; + case FLAGUNITGRAD: return "°C"; + case FLAGUNITNF: return "nF"; + case FLAGUNITHZ: return "Hz"; + case FLAGUNITHFE: return "hFE"; + case FLAGUNITOHM: return "Ω"; + case FLAGUNITAMP: return "A"; + case FLAGUNITVOLT: return "V"; + default: return ""; + } + } + + const char *scale() const { + if ((this->value_[REGSCALE] & FLAGSCALEDUTY) == FLAGSCALEDUTY) return "%"; + if ((this->value_[REGSCALE] & FLAGSCALEMEGA) == FLAGSCALEMEGA) return "M"; + if ((this->value_[REGSCALE] & FLAGSCALEKILO) == FLAGSCALEKILO) return "k"; + if ((this->value_[REGSCALE] & FLAGSCALEMILLI) == FLAGSCALEMILLI) return "m"; + if ((this->value_[REGSCALE] & FLAGSCALEMICRO) == FLAGSCALEMICRO) return "µ"; + return ""; + } + + std::string mode_text() const { + std::string out; + if (this->dc()) out += "DC "; + if (this->ac()) out += "AC "; + if (this->auto_range()) out += "AUTO "; + if (this->hold()) out += "HOLD "; + if (this->relative()) out += "REL "; + if (this->min_mode()) out += "MIN "; + if (this->max_mode()) out += "MAX "; + if (this->diode()) out += "DIODE "; + if (this->continuity()) out += "CONT "; + if (!out.empty()) out.pop_back(); + return out; + } + + std::string reading_text() const { + if (!this->connected) return "Disconnected"; + if (!this->has_reading) return "Waiting for data"; + if (this->overload) return "OL " + std::string(this->scale()) + this->unit(); + char buf[48]; + snprintf(buf, sizeof(buf), "%s%.4g %s%s", this->negative() ? "-" : "", std::fabs(this->display_value), this->scale(), this->unit()); + std::string out(buf); + auto mode = this->mode_text(); + if (!mode.empty()) out += " " + mode; + return out; + } + + enum Kind { KIND_OTHER, KIND_VOLTAGE, KIND_CURRENT, KIND_RESISTANCE, KIND_FREQUENCY, KIND_CAPACITANCE, KIND_TEMP_C, KIND_TEMP_F, KIND_DUTY }; + Kind kind() const { + if ((this->value_[REGSCALE] & FLAGSCALEDUTY) == FLAGSCALEDUTY) return KIND_DUTY; + switch (this->value_[REGUNIT]) { + case FLAGUNITVOLT: return KIND_VOLTAGE; + case FLAGUNITAMP: return KIND_CURRENT; + case FLAGUNITOHM: return KIND_RESISTANCE; + case FLAGUNITHZ: return KIND_FREQUENCY; + case FLAGUNITNF: return KIND_CAPACITANCE; + case FLAGUNITGRAD: return KIND_TEMP_C; + case FLAGUNITFAHR: return KIND_TEMP_F; + default: return KIND_OTHER; + } + } + + const char *selected_button_name() const { + static const char *const names[] = {"SELECT", "RANGE", "HLD/LIG", "REL/BT", "HZ/DUTY", "MAX/MIN"}; + uint8_t index = this->selected_button; + if (index < 1) index = 1; + if (index > 6) index = 6; + return names[index - 1]; + } + + void previous_button() { + if (this->selected_button > 1) this->selected_button--; + } + void next_button() { + if (this->selected_button < 6) this->selected_button++; + } + + void render(Display &it, esphome::display::BaseFont *font) { + const Color bg(0, 0, 0); + const Color fg(210, 210, 210); + const Color inactive(45, 45, 45); + const Color yellow(255, 220, 0); + const Color blue(0, 80, 255); + const Color cyan(0, 255, 255); + const Color magenta(255, 0, 255); + const Color red(255, 0, 0); + const Color green(0, 220, 0); + const Color orange(255, 165, 0); + + it.fill(bg); + this->label_(it, font, 12, 8, "BAT", this->low_battery ? red : green); + this->label_(it, font, 46, 8, "BLE", this->connected ? blue : inactive); + this->label_(it, font, 86, 8, "AUTO", this->auto_range() ? fg : inactive); + this->label_(it, font, 138, 8, "MAX", this->max_mode() ? red : inactive); + this->label_(it, font, 178, 8, "MIN", this->min_mode() ? green : inactive); + this->label_(it, font, 218, 8, "HOLD", this->hold() ? blue : inactive); + this->label_(it, font, 270, 8, "REL", this->relative() ? Color(128, 128, 0) : inactive); + + this->label_(it, font, 8, 72, "DC", this->dc() ? cyan : inactive); + this->label_(it, font, 8, 96, "AC", this->ac() ? magenta : inactive); + + if (!this->connected) { + this->draw_digits_(it, "----", false, inactive); + it.print(160, 148, font, inactive, esphome::display::TextAlign::CENTER, "scan/connect"); + } else if (!this->has_reading) { + this->draw_digits_(it, "8888", false, inactive); + it.print(160, 148, font, inactive, esphome::display::TextAlign::CENTER, "waiting"); + } else if (this->overload) { + this->draw_digits_(it, " OL ", false, fg); + } else { + char d[5]; + d[0] = this->digit_char_(REGDIG1); + d[1] = this->digit_char_(REGDIG2); + d[2] = this->digit_char_(REGDIG3); + d[3] = this->digit_char_(REGDIG4); + d[4] = 0; + this->draw_digits_(it, d, this->negative(), fg); + this->draw_decimal_points_(it, fg); + } + + std::string unit_line = std::string(this->scale()) + this->unit(); + it.print(270, 140, font, yellow, esphome::display::TextAlign::CENTER, unit_line.c_str()); + this->label_(it, font, 240, 168, "DIODE", this->diode() ? magenta : inactive); + this->label_(it, font, 240, 190, "BUZZ", this->continuity() ? orange : inactive); + + this->draw_bargraph_(it, this->has_reading && !this->overload ? this->digits_from_buffer_() : 0, this->has_reading && !this->overload); + + it.filled_rectangle(34, 212, 40, 24, this->write_available ? fg : inactive); + it.filled_rectangle(108, 212, 100, 24, this->write_available ? fg : inactive); + it.filled_rectangle(242, 212, 40, 24, this->write_available ? fg : inactive); + it.print(54, 216, font, bg, esphome::display::TextAlign::TOP_CENTER, "<"); + it.print(158, 216, font, bg, esphome::display::TextAlign::TOP_CENTER, this->selected_button_name()); + it.print(262, 216, font, bg, esphome::display::TextAlign::TOP_CENTER, ">"); + } + + private: + uint8_t raw_[14]{}; + uint8_t value_[14]{}; + float display_value{NAN}; + float base_value{NAN}; + static constexpr uint8_t OVERLOAD_FRAME[5] = {0x2B, 0x3F, 0x30, 0x3A, 0x3F}; + + uint16_t digits_from_buffer_() const { + uint16_t out = 0; + if (this->value_[REGDIG1] >= '0' && this->value_[REGDIG1] <= '9') out += (this->value_[REGDIG1] - '0') * 1000; + if (this->value_[REGDIG2] >= '0' && this->value_[REGDIG2] <= '9') out += (this->value_[REGDIG2] - '0') * 100; + if (this->value_[REGDIG3] >= '0' && this->value_[REGDIG3] <= '9') out += (this->value_[REGDIG3] - '0') * 10; + if (this->value_[REGDIG4] >= '0' && this->value_[REGDIG4] <= '9') out += (this->value_[REGDIG4] - '0'); + return out; + } + + float calc_display_value_() const { + if (this->overload) return NAN; + uint8_t decimal = 0; + switch (this->value_[REGPOINT] & 0x07) { + case 0b001: decimal = 1; break; + case 0b010: decimal = 2; break; + case 0b100: decimal = 3; break; + default: break; + } + float v = static_cast(this->digits_from_buffer_()) / std::pow(10.0f, decimal); + return this->negative() ? -v : v; + } + + float calc_base_value_() const { + if (std::isnan(this->display_value)) return NAN; + if (this->value_[REGUNIT] == FLAGUNITNF) return this->display_value * 1e-9f; + if ((this->value_[REGSCALE] & FLAGSCALEMEGA) == FLAGSCALEMEGA) return this->display_value * 1e6f; + if ((this->value_[REGSCALE] & FLAGSCALEKILO) == FLAGSCALEKILO) return this->display_value * 1e3f; + if ((this->value_[REGSCALE] & FLAGSCALEMILLI) == FLAGSCALEMILLI) return this->display_value * 1e-3f; + if ((this->value_[REGSCALE] & FLAGSCALEMICRO) == FLAGSCALEMICRO) return this->display_value * 1e-6f; + return this->display_value; + } + + void parse_plus_() { + memset(this->value_, 0, sizeof(this->value_)); + this->value_[5] = 0x20; + this->value_[12] = 0x0D; + this->value_[13] = 0x0A; + + uint16_t pair1 = static_cast(this->raw_[0]) | (static_cast(this->raw_[1]) << 8); + uint8_t function = (pair1 >> 6) & 0x0F; + uint8_t scale = (pair1 >> 3) & 0x07; + uint8_t decimal = pair1 & 0x07; + + switch (decimal) { + case 0: this->value_[REGPOINT] = FLAGPOINT0; break; + case 1: this->value_[REGPOINT] = FLAGPOINT3; break; + case 2: this->value_[REGPOINT] = FLAGPOINT2; break; + case 3: this->value_[REGPOINT] = FLAGPOINT1; break; + default: break; + } + + switch (function) { + case 0: this->value_[REGUNIT] |= FLAGUNITVOLT; this->value_[REGMODE] |= FLAGMODEDC; break; + case 1: this->value_[REGUNIT] |= FLAGUNITVOLT; this->value_[REGMODE] |= FLAGMODEAC; break; + case 2: this->value_[REGUNIT] |= FLAGUNITAMP; this->value_[REGMODE] |= FLAGMODEDC; break; + case 3: this->value_[REGUNIT] |= FLAGUNITAMP; this->value_[REGMODE] |= FLAGMODEAC; break; + case 4: this->value_[REGUNIT] |= FLAGUNITOHM; break; + case 5: this->value_[REGUNIT] |= FLAGUNITNF; break; + case 6: this->value_[REGUNIT] |= FLAGUNITHZ; break; + case 7: this->value_[REGSCALE] |= FLAGSCALEDUTY; break; + case 8: this->value_[REGUNIT] |= FLAGUNITGRAD; break; + case 9: this->value_[REGUNIT] |= FLAGUNITFAHR; break; + case 10: this->value_[REGSCALE] |= FLAGSCALEDIODE; break; + case 11: this->value_[REGSCALE] |= FLAGSCALEBUZZ; break; + case 12: this->value_[REGUNIT] |= FLAGUNITHFE; break; + default: break; + } + + switch (scale) { + case 2: this->value_[REGSCALE] |= FLAGSCALEMICRO; break; + case 3: this->value_[REGSCALE] |= FLAGSCALEMILLI; break; + case 5: this->value_[REGSCALE] |= FLAGSCALEKILO; break; + case 6: this->value_[REGSCALE] |= FLAGSCALEMEGA; break; + default: break; + } + + uint16_t pair2 = static_cast(this->raw_[2]) | (static_cast(this->raw_[3]) << 8); + if (pair2 & (1 << 0)) this->value_[REGMODE] |= FLAGMODEHOLD; + if (pair2 & (1 << 1)) this->value_[REGMODE] |= FLAGMODEREL; + if (pair2 & (1 << 2)) this->value_[REGMODE] |= FLAGMODEAUTO; + this->low_battery = (pair2 & (1 << 3)) != 0; + if (pair2 & (1 << 4)) this->value_[REGMINMAX] |= FLAGMIN; + if (pair2 & (1 << 5)) this->value_[REGMINMAX] |= FLAGMAX; + + uint16_t pair3 = static_cast(this->raw_[4]) | (static_cast(this->raw_[5]) << 8); + if (decimal < 7) { + uint16_t digits = pair3; + if (pair3 < 0x7FFF) { + this->value_[REGPLUSMINUS] = FLAGPLUS; + } else { + this->value_[REGPLUSMINUS] = FLAGMINUS; + digits = pair3 & 0x7FFF; + } + this->value_[REGDIG1] = '0' + ((digits / 1000) % 10); + this->value_[REGDIG2] = '0' + ((digits / 100) % 10); + this->value_[REGDIG3] = '0' + ((digits / 10) % 10); + this->value_[REGDIG4] = '0' + (digits % 10); + } else { + memcpy(this->value_, OVERLOAD_FRAME, sizeof(OVERLOAD_FRAME)); + } + } + + char digit_char_(uint8_t reg) const { + uint8_t c = this->value_[reg]; + return (c >= '0' && c <= '9') ? static_cast(c) : ' '; + } + + void label_(Display &it, esphome::display::BaseFont *font, int x, int y, const char *text, Color color) { + it.print(x, y, font, color, esphome::display::TextAlign::TOP_LEFT, text); + } + + void draw_digits_(Display &it, const char *text, bool negative, Color color) { + if (negative) it.filled_rectangle(8, 88, 24, 8, color); + int x = 42; + for (int i = 0; i < 4; i++) { + this->draw_seven_segment_(it, x + i * 55, 38, 42, 82, text[i], color); + } + } + + void draw_decimal_points_(Display &it, Color color) { + uint8_t p = this->value_[REGPOINT]; + if ((p & FLAGPOINT1) == FLAGPOINT1) it.filled_rectangle(92, 116, 8, 10, color); + if ((p & FLAGPOINT2) == FLAGPOINT2) it.filled_rectangle(147, 116, 8, 10, color); + if ((p & FLAGPOINT3) == FLAGPOINT3) it.filled_rectangle(202, 116, 8, 10, color); + } + + void draw_segment_(Display &it, int x, int y, int w, int h, Color color) { it.filled_rectangle(x, y, w, h, color); } + + void draw_seven_segment_(Display &it, int x, int y, int w, int h, char ch, Color color) { + bool a=false,b=false,c=false,d=false,e=false,f=false,g=false; + switch (ch) { + case '0': a=b=c=d=e=f=true; break; + case '1': b=c=true; break; + case '2': a=b=d=e=g=true; break; + case '3': a=b=c=d=g=true; break; + case '4': b=c=f=g=true; break; + case '5': a=c=d=f=g=true; break; + case '6': a=c=d=e=f=g=true; break; + case '7': a=b=c=true; break; + case '8': a=b=c=d=e=f=g=true; break; + case '9': a=b=c=d=f=g=true; break; + case 'O': a=b=c=d=e=f=true; break; + case 'L': d=e=f=true; break; + case '-': g=true; break; + default: break; + } + int t = 8; + if (a) this->draw_segment_(it, x + t, y, w - 2*t, t, color); + if (b) this->draw_segment_(it, x + w - t, y + t, t, h/2 - t, color); + if (c) this->draw_segment_(it, x + w - t, y + h/2, t, h/2 - t, color); + if (d) this->draw_segment_(it, x + t, y + h - t, w - 2*t, t, color); + if (e) this->draw_segment_(it, x, y + h/2, t, h/2 - t, color); + if (f) this->draw_segment_(it, x, y + t, t, h/2 - t, color); + if (g) this->draw_segment_(it, x + t, y + h/2 - t/2, w - 2*t, t, color); + } + + void draw_bargraph_(Display &it, uint16_t digits, bool active) { + const Color fg(255, 255, 255); + const Color inactive(45, 45, 45); + uint16_t mapped = active ? static_cast(digits * 240 / 6000) : 0; + if (mapped > 240) mapped = 240; + for (uint16_t i = 0; i <= 240; i += 4) { + Color col = (active && i <= mapped) ? fg : inactive; + int h = (i % 40 == 0) ? 20 : ((i % 20 == 0) ? 15 : 10); + it.vertical_line(40 + i, 185 - h, h, col); + } + it.horizontal_line(35, 185, 250, inactive); + } +}; + +} // namespace owon_b35t + +static owon_b35t::Meter owon_meter; From 91f3f69bd90f42395a15208414e5a4d4ffd3dcfb Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Tue, 26 May 2026 15:34:03 +0200 Subject: [PATCH 02/29] Optimized for mem pressure, inverted colors. --- esphome/owon-b35t.yaml | 128 +++++++---------------------------------- esphome/owon_b35t.h | 2 + 2 files changed, 23 insertions(+), 107 deletions(-) diff --git a/esphome/owon-b35t.yaml b/esphome/owon-b35t.yaml index 4e3722a..2f0a8f9 100644 --- a/esphome/owon-b35t.yaml +++ b/esphome/owon-b35t.yaml @@ -44,11 +44,25 @@ wifi: ssid: "OWON B35T Fallback Hotspot" password: !secret fallback_psk -captive_portal: +# Disabled to save RAM on the M5Stack Core 1. Re-enable temporarily if Wi-Fi recovery is needed. +# captive_portal: + +interval: + - interval: 10s + then: + - lambda: |- + ESP_LOGI("mem", "heap free=%u min_free=%u internal_free=%u internal_largest=%u dma_free=%u dma_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))); esp32_ble_tracker: scan_parameters: active: true + continuous: true ble_client: - mac_address: ${owon_mac_address} @@ -158,7 +172,7 @@ display: cs_pin: GPIO14 dc_pin: GPIO27 reset_pin: GPIO33 - invert_colors: false + invert_colors: true color_palette: 8BIT rotation: 0 update_interval: 500ms @@ -235,34 +249,6 @@ binary_sensor: name: "${friendly_name} Low Battery" lambda: |- return owon_meter.low_battery; - - platform: template - name: "${friendly_name} Auto Range" - lambda: |- - return owon_meter.auto_range(); - - platform: template - name: "${friendly_name} Hold" - lambda: |- - return owon_meter.hold(); - - platform: template - name: "${friendly_name} Relative" - lambda: |- - return owon_meter.relative(); - - platform: template - name: "${friendly_name} AC" - lambda: |- - return owon_meter.ac(); - - platform: template - name: "${friendly_name} DC" - lambda: |- - return owon_meter.dc(); - - platform: template - name: "${friendly_name} Continuity" - lambda: |- - return owon_meter.continuity(); - - platform: template - name: "${friendly_name} Diode" - lambda: |- - return owon_meter.diode(); sensor: - platform: ble_client @@ -286,7 +272,7 @@ sensor: name: "${friendly_name} Display Value" id: owon_display_value accuracy_decimals: 6 - update_interval: 1s + update_interval: 2s lambda: |- return owon_meter.has_reading && !owon_meter.overload ? owon_meter.value() : NAN; @@ -294,95 +280,23 @@ sensor: name: "${friendly_name} Base Value" id: owon_base_value accuracy_decimals: 9 - update_interval: 1s + update_interval: 2s lambda: |- return owon_meter.has_reading && !owon_meter.overload ? owon_meter.value_base() : NAN; - - platform: template - name: "${friendly_name} Voltage" - device_class: voltage - unit_of_measurement: "V" - accuracy_decimals: 6 - update_interval: 1s - lambda: |- - return owon_meter.kind() == owon_b35t::Meter::KIND_VOLTAGE && !owon_meter.overload ? owon_meter.value_base() : NAN; - - - platform: template - name: "${friendly_name} Current" - device_class: current - unit_of_measurement: "A" - accuracy_decimals: 6 - update_interval: 1s - lambda: |- - return owon_meter.kind() == owon_b35t::Meter::KIND_CURRENT && !owon_meter.overload ? owon_meter.value_base() : NAN; - - - platform: template - name: "${friendly_name} Resistance" - unit_of_measurement: "Ω" - accuracy_decimals: 3 - update_interval: 1s - lambda: |- - return owon_meter.kind() == owon_b35t::Meter::KIND_RESISTANCE && !owon_meter.overload ? owon_meter.value_base() : NAN; - - - platform: template - name: "${friendly_name} Frequency" - device_class: frequency - unit_of_measurement: "Hz" - accuracy_decimals: 3 - update_interval: 1s - lambda: |- - return owon_meter.kind() == owon_b35t::Meter::KIND_FREQUENCY && !owon_meter.overload ? owon_meter.value_base() : NAN; - - - platform: template - name: "${friendly_name} Capacitance" - unit_of_measurement: "F" - accuracy_decimals: 12 - update_interval: 1s - lambda: |- - return owon_meter.kind() == owon_b35t::Meter::KIND_CAPACITANCE && !owon_meter.overload ? owon_meter.value_base() : NAN; - - - platform: template - name: "${friendly_name} Temperature" - device_class: temperature - unit_of_measurement: "°C" - accuracy_decimals: 2 - update_interval: 1s - lambda: |- - if (owon_meter.kind() == owon_b35t::Meter::KIND_TEMP_C && !owon_meter.overload) return owon_meter.value(); - if (owon_meter.kind() == owon_b35t::Meter::KIND_TEMP_F && !owon_meter.overload) return (owon_meter.value() - 32.0f) * 5.0f / 9.0f; - return NAN; - - - platform: template - name: "${friendly_name} Duty Cycle" - unit_of_measurement: "%" - accuracy_decimals: 2 - update_interval: 1s - lambda: |- - return owon_meter.kind() == owon_b35t::Meter::KIND_DUTY && !owon_meter.overload ? owon_meter.value() : NAN; - text_sensor: - platform: template name: "${friendly_name} Reading" - update_interval: 1s + update_interval: 2s lambda: |- return owon_meter.reading_text(); - platform: template name: "${friendly_name} Unit" - update_interval: 1s + update_interval: 2s lambda: |- return std::string(owon_meter.scale()) + owon_meter.unit(); - platform: template name: "${friendly_name} Mode" - update_interval: 1s + update_interval: 2s lambda: |- return owon_meter.mode_text(); - - platform: template - name: "${friendly_name} Meter Type" - update_interval: 1s - lambda: |- - return owon_meter.is_plus ? std::string("B35T+") : std::string("B35T"); - - platform: template - name: "${friendly_name} Selected Button" - update_interval: 1s - lambda: |- - return std::string(owon_meter.selected_button_name()); diff --git a/esphome/owon_b35t.h b/esphome/owon_b35t.h index 0f27633..bba7ca2 100644 --- a/esphome/owon_b35t.h +++ b/esphome/owon_b35t.h @@ -10,6 +10,8 @@ #include #include #include +#include "esp_heap_caps.h" +#include "esp_system.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/components/display/display.h" From e5104fc005f60685e721519f6e5a4b73fca7f8ec Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Tue, 26 May 2026 16:03:25 +0200 Subject: [PATCH 03/29] Removed 2 unneeded values from HA reporting, fixed mV reporting value --- esphome/owon-b35t.yaml | 16 ---------------- esphome/owon_b35t.h | 13 +++++++++++++ 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/esphome/owon-b35t.yaml b/esphome/owon-b35t.yaml index 2f0a8f9..796668c 100644 --- a/esphome/owon-b35t.yaml +++ b/esphome/owon-b35t.yaml @@ -268,22 +268,6 @@ sensor: name: "${friendly_name} WiFi Signal" update_interval: 60s - - platform: template - name: "${friendly_name} Display Value" - id: owon_display_value - accuracy_decimals: 6 - update_interval: 2s - lambda: |- - return owon_meter.has_reading && !owon_meter.overload ? owon_meter.value() : NAN; - - - platform: template - name: "${friendly_name} Base Value" - id: owon_base_value - accuracy_decimals: 9 - update_interval: 2s - lambda: |- - return owon_meter.has_reading && !owon_meter.overload ? owon_meter.value_base() : NAN; - text_sensor: - platform: template name: "${friendly_name} Reading" diff --git a/esphome/owon_b35t.h b/esphome/owon_b35t.h index bba7ca2..d824472 100644 --- a/esphome/owon_b35t.h +++ b/esphome/owon_b35t.h @@ -277,6 +277,19 @@ class Meter { float calc_display_value_() const { if (this->overload) return NAN; + + if (this->is_plus) { + uint16_t pair1 = static_cast(this->raw_[0]) | (static_cast(this->raw_[1]) << 8); + uint8_t decimal = pair1 & 0x07; + if (decimal >= 7) return NAN; + + uint16_t pair3 = static_cast(this->raw_[4]) | (static_cast(this->raw_[5]) << 8); + bool negative = pair3 >= 0x7FFF; + uint16_t digits = negative ? (pair3 & 0x7FFF) : pair3; + float v = static_cast(digits) / std::pow(10.0f, decimal); + return negative ? -v : v; + } + uint8_t decimal = 0; switch (this->value_[REGPOINT] & 0x07) { case 0b001: decimal = 1; break; From 6f4e2b9319e53a62b9127a16242ba6f991b64ae8 Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Tue, 26 May 2026 16:44:25 +0200 Subject: [PATCH 04/29] Moved AC/DC out of the way of the minus sign, added an h glyph for hFE display. --- esphome/owon-b35t.yaml | 7 +++++++ esphome/owon_b35t.h | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/esphome/owon-b35t.yaml b/esphome/owon-b35t.yaml index 796668c..ea6bc6f 100644 --- a/esphome/owon-b35t.yaml +++ b/esphome/owon-b35t.yaml @@ -75,6 +75,12 @@ ble_client: then: - lambda: |- owon_meter.on_disconnect(); + - logger.log: + level: WARN + format: "OWON BLE meter disconnected; restarting M5Stack to reclaim heap" + - delay: 1s + - lambda: |- + esp_restart(); spi: clk_pin: GPIO18 @@ -147,6 +153,7 @@ font: "e", "f", "g", + "h", "i", "k", "m", diff --git a/esphome/owon_b35t.h b/esphome/owon_b35t.h index d824472..a96c67d 100644 --- a/esphome/owon_b35t.h +++ b/esphome/owon_b35t.h @@ -222,8 +222,8 @@ class Meter { this->label_(it, font, 218, 8, "HOLD", this->hold() ? blue : inactive); this->label_(it, font, 270, 8, "REL", this->relative() ? Color(128, 128, 0) : inactive); - this->label_(it, font, 8, 72, "DC", this->dc() ? cyan : inactive); - this->label_(it, font, 8, 96, "AC", this->ac() ? magenta : inactive); + this->label_(it, font, 8, 66, "DC", this->dc() ? cyan : inactive); + this->label_(it, font, 8, 102, "AC", this->ac() ? magenta : inactive); if (!this->connected) { this->draw_digits_(it, "----", false, inactive); From 23cbd520a67842c455797357c428a634cb640dcb Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Tue, 26 May 2026 17:03:52 +0200 Subject: [PATCH 05/29] Repositioned DIODE/BUZZ and moved them out of the meter bar. Also slightly reworked 7-segment display font. Not perfect, yet though --- esphome/owon_b35t.h | 105 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 86 insertions(+), 19 deletions(-) diff --git a/esphome/owon_b35t.h b/esphome/owon_b35t.h index a96c67d..626838b 100644 --- a/esphome/owon_b35t.h +++ b/esphome/owon_b35t.h @@ -23,6 +23,44 @@ using esphome::display::Display; static const char *const TAG = "owon_b35t"; +static const uint8_t DIODE_BMP[32] = { + 0b00001000, 0b00011000, + 0b00001100, 0b00011000, + 0b00001110, 0b00011000, + 0b00001111, 0b00011000, + 0b00001111, 0b10011000, + 0b00001111, 0b11011000, + 0b00001111, 0b11111000, + 0b11111111, 0b11111111, + 0b11111111, 0b11111111, + 0b00001111, 0b11111000, + 0b00001111, 0b11011000, + 0b00001111, 0b10011000, + 0b00001111, 0b00011000, + 0b00001110, 0b00011000, + 0b00001100, 0b00011000, + 0b00001000, 0b00011000, +}; + +static const uint8_t BUZZ_BMP[32] = { + 0b00000000, 0b11000000, + 0b00000001, 0b11000000, + 0b00000011, 0b11000001, + 0b00000111, 0b11000001, + 0b00001111, 0b11000101, + 0b11111111, 0b11000101, + 0b11111111, 0b11010101, + 0b11111111, 0b11010101, + 0b11111111, 0b11010101, + 0b11111111, 0b11010101, + 0b11111111, 0b11000101, + 0b00001111, 0b11000101, + 0b00000111, 0b11000001, + 0b00000011, 0b11000001, + 0b00000001, 0b11000000, + 0b00000000, 0b11000000, +}; + class Meter { public: static constexpr uint8_t REGPLUSMINUS = 0x00; @@ -204,7 +242,8 @@ class Meter { void render(Display &it, esphome::display::BaseFont *font) { const Color bg(0, 0, 0); const Color fg(210, 210, 210); - const Color inactive(45, 45, 45); + // Chosen to map to a neutral dark gray in the RGB332 8-bit display palette. + const Color inactive(80, 80, 80); const Color yellow(255, 220, 0); const Color blue(0, 80, 255); const Color cyan(0, 255, 255); @@ -246,10 +285,10 @@ class Meter { std::string unit_line = std::string(this->scale()) + this->unit(); it.print(270, 140, font, yellow, esphome::display::TextAlign::CENTER, unit_line.c_str()); - this->label_(it, font, 240, 168, "DIODE", this->diode() ? magenta : inactive); - this->label_(it, font, 240, 190, "BUZZ", this->continuity() ? orange : inactive); this->draw_bargraph_(it, this->has_reading && !this->overload ? this->digits_from_buffer_() : 0, this->has_reading && !this->overload); + this->draw_icon_(it, 300, 148, 16, 16, DIODE_BMP, this->diode() ? magenta : inactive); + this->draw_icon_(it, 300, 174, 16, 16, BUZZ_BMP, this->continuity() ? orange : inactive); it.filled_rectangle(34, 212, 40, 24, this->write_available ? fg : inactive); it.filled_rectangle(108, 212, 100, 24, this->write_available ? fg : inactive); @@ -391,21 +430,48 @@ class Meter { } void draw_digits_(Display &it, const char *text, bool negative, Color color) { - if (negative) it.filled_rectangle(8, 88, 24, 8, color); - int x = 42; + if (negative) this->draw_segment_(it, 8, 88, 26, 9, true, color); + constexpr int digit_x = 40; + constexpr int digit_y = 35; + constexpr int digit_w = 50; + constexpr int digit_h = 88; + constexpr int digit_distance = 64; for (int i = 0; i < 4; i++) { - this->draw_seven_segment_(it, x + i * 55, 38, 42, 82, text[i], color); + this->draw_seven_segment_(it, digit_x + i * digit_distance, digit_y, digit_w, digit_h, text[i], color); } } void draw_decimal_points_(Display &it, Color color) { uint8_t p = this->value_[REGPOINT]; - if ((p & FLAGPOINT1) == FLAGPOINT1) it.filled_rectangle(92, 116, 8, 10, color); - if ((p & FLAGPOINT2) == FLAGPOINT2) it.filled_rectangle(147, 116, 8, 10, color); - if ((p & FLAGPOINT3) == FLAGPOINT3) it.filled_rectangle(202, 116, 8, 10, color); + if ((p & FLAGPOINT1) == FLAGPOINT1) it.filled_rectangle(95, 117, 8, 10, color); + if ((p & FLAGPOINT2) == FLAGPOINT2) it.filled_rectangle(159, 117, 8, 10, color); + if ((p & FLAGPOINT3) == FLAGPOINT3) it.filled_rectangle(223, 117, 8, 10, color); } - void draw_segment_(Display &it, int x, int y, int w, int h, Color color) { it.filled_rectangle(x, y, w, h, color); } + void draw_segment_(Display &it, int x, int y, int w, int h, bool horizontal, Color color) { + if (horizontal) { + int cap = h / 2; + it.filled_rectangle(x + cap, y, w - 2 * cap, h, color); + it.filled_triangle(x, y + cap, x + cap, y, x + cap, y + h, color); + it.filled_triangle(x + w, y + cap, x + w - cap, y, x + w - cap, y + h, color); + } else { + int cap = w / 2; + it.filled_rectangle(x, y + cap, w, h - 2 * cap, color); + it.filled_triangle(x + cap, y, x, y + cap, x + w, y + cap, color); + it.filled_triangle(x + cap, y + h, x, y + h - cap, x + w, y + h - cap, color); + } + } + + void draw_icon_(Display &it, int x, int y, int w, int h, const uint8_t *data, Color color) { + for (int row = 0; row < h; row++) { + for (int col = 0; col < w; col++) { + uint8_t byte = data[row * ((w + 7) / 8) + (col / 8)]; + if ((byte & (0x80 >> (col % 8))) != 0) { + it.draw_pixel_at(x + col, y + row, color); + } + } + } + } void draw_seven_segment_(Display &it, int x, int y, int w, int h, char ch, Color color) { bool a=false,b=false,c=false,d=false,e=false,f=false,g=false; @@ -425,19 +491,20 @@ class Meter { case '-': g=true; break; default: break; } - int t = 8; - if (a) this->draw_segment_(it, x + t, y, w - 2*t, t, color); - if (b) this->draw_segment_(it, x + w - t, y + t, t, h/2 - t, color); - if (c) this->draw_segment_(it, x + w - t, y + h/2, t, h/2 - t, color); - if (d) this->draw_segment_(it, x + t, y + h - t, w - 2*t, t, color); - if (e) this->draw_segment_(it, x, y + h/2, t, h/2 - t, color); - if (f) this->draw_segment_(it, x, y + t, t, h/2 - t, color); - if (g) this->draw_segment_(it, x + t, y + h/2 - t/2, w - 2*t, t, color); + int t = 10; + int half = h / 2; + if (a) this->draw_segment_(it, x + t / 2, y, w - t, t, true, color); + if (b) this->draw_segment_(it, x + w - t, y + t / 2, t, half - t, false, color); + if (c) this->draw_segment_(it, x + w - t, y + half + t / 2, t, half - t, false, color); + if (d) this->draw_segment_(it, x + t / 2, y + h - t, w - t, t, true, color); + if (e) this->draw_segment_(it, x, y + half + t / 2, t, half - t, false, color); + if (f) this->draw_segment_(it, x, y + t / 2, t, half - t, false, color); + if (g) this->draw_segment_(it, x + t / 2, y + half - t / 2, w - t, t, true, color); } void draw_bargraph_(Display &it, uint16_t digits, bool active) { const Color fg(255, 255, 255); - const Color inactive(45, 45, 45); + const Color inactive(80, 80, 80); uint16_t mapped = active ? static_cast(digits * 240 / 6000) : 0; if (mapped > 240) mapped = 240; for (uint16_t i = 0; i <= 240; i += 4) { From 08201f1bc14a92a94194ada028a531e41ce1b4aa Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Tue, 26 May 2026 17:10:43 +0200 Subject: [PATCH 06/29] Uniform separation of 7-segment display font elements. --- esphome/owon_b35t.h | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/esphome/owon_b35t.h b/esphome/owon_b35t.h index 626838b..89b6cc3 100644 --- a/esphome/owon_b35t.h +++ b/esphome/owon_b35t.h @@ -493,13 +493,24 @@ class Meter { } int t = 10; int half = h / 2; - if (a) this->draw_segment_(it, x + t / 2, y, w - t, t, true, color); - if (b) this->draw_segment_(it, x + w - t, y + t / 2, t, half - t, false, color); - if (c) this->draw_segment_(it, x + w - t, y + half + t / 2, t, half - t, false, color); - if (d) this->draw_segment_(it, x + t / 2, y + h - t, w - t, t, true, color); - if (e) this->draw_segment_(it, x, y + half + t / 2, t, half - t, false, color); - if (f) this->draw_segment_(it, x, y + t / 2, t, half - t, false, color); - if (g) this->draw_segment_(it, x + t / 2, y + half - t / 2, w - t, t, true, color); + int gap = 1; + + int top_y = y; + int mid_y = y + half - t / 2; + int bot_y = y + h - t; + + int upper_v_y = top_y + t + gap; + int upper_v_h = mid_y - gap - upper_v_y; + int lower_v_y = mid_y + t + gap; + int lower_v_h = bot_y - gap - lower_v_y; + + if (a) this->draw_segment_(it, x + t / 2, top_y, w - t, t, true, color); + if (b && upper_v_h > 0) this->draw_segment_(it, x + w - t, upper_v_y, t, upper_v_h, false, color); + if (c && lower_v_h > 0) this->draw_segment_(it, x + w - t, lower_v_y, t, lower_v_h, false, color); + if (d) this->draw_segment_(it, x + t / 2, bot_y, w - t, t, true, color); + if (e && lower_v_h > 0) this->draw_segment_(it, x, lower_v_y, t, lower_v_h, false, color); + if (f && upper_v_h > 0) this->draw_segment_(it, x, upper_v_y, t, upper_v_h, false, color); + if (g) this->draw_segment_(it, x + t / 2, mid_y, w - t, t, true, color); } void draw_bargraph_(Display &it, uint16_t digits, bool active) { From 676829a988b81f1709aed4db7e877378d49724ee Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Tue, 26 May 2026 17:21:11 +0200 Subject: [PATCH 07/29] Remove mode from Reading value - already present in Mode. --- esphome/owon_b35t.h | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/esphome/owon_b35t.h b/esphome/owon_b35t.h index 89b6cc3..3b01072 100644 --- a/esphome/owon_b35t.h +++ b/esphome/owon_b35t.h @@ -203,10 +203,7 @@ class Meter { if (this->overload) return "OL " + std::string(this->scale()) + this->unit(); char buf[48]; snprintf(buf, sizeof(buf), "%s%.4g %s%s", this->negative() ? "-" : "", std::fabs(this->display_value), this->scale(), this->unit()); - std::string out(buf); - auto mode = this->mode_text(); - if (!mode.empty()) out += " " + mode; - return out; + return std::string(buf); } enum Kind { KIND_OTHER, KIND_VOLTAGE, KIND_CURRENT, KIND_RESISTANCE, KIND_FREQUENCY, KIND_CAPACITANCE, KIND_TEMP_C, KIND_TEMP_F, KIND_DUTY }; From 2fcf71d8ce139aebc37f1320a9813c41f5cc5796 Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Tue, 26 May 2026 17:36:58 +0200 Subject: [PATCH 08/29] Added Bluetooth and battery icons instead of text indicators --- esphome/owon_b35t.h | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/esphome/owon_b35t.h b/esphome/owon_b35t.h index 3b01072..9173dd2 100644 --- a/esphome/owon_b35t.h +++ b/esphome/owon_b35t.h @@ -23,6 +23,44 @@ using esphome::display::Display; static const char *const TAG = "owon_b35t"; +static const uint8_t ACCU_BMP[32] = { + 0b00000000, 0b00000000, + 0b00000000, 0b00000000, + 0b00000000, 0b00000000, + 0b11111111, 0b11111110, + 0b10000000, 0b00000010, + 0b10000000, 0b00000011, + 0b10000000, 0b00000011, + 0b10000000, 0b00000011, + 0b10000000, 0b00000011, + 0b10000000, 0b00000011, + 0b10000000, 0b00000011, + 0b10000000, 0b00000010, + 0b11111111, 0b11111110, + 0b00000000, 0b00000000, + 0b00000000, 0b00000000, + 0b00000000, 0b00000000, +}; + +static const uint8_t BLE_BMP[32] = { + 0b00000001, 0b10000000, + 0b00000001, 0b11000000, + 0b00010001, 0b01100000, + 0b00011001, 0b00110000, + 0b00001101, 0b00011000, + 0b00000111, 0b00110000, + 0b00000011, 0b01100000, + 0b00000001, 0b11000000, + 0b00000001, 0b11000000, + 0b00000011, 0b01100000, + 0b00000111, 0b00110000, + 0b00001101, 0b00011000, + 0b00011001, 0b00110000, + 0b00010001, 0b01100000, + 0b00000001, 0b11000000, + 0b00000001, 0b10000000, +}; + static const uint8_t DIODE_BMP[32] = { 0b00001000, 0b00011000, 0b00001100, 0b00011000, @@ -250,8 +288,8 @@ class Meter { const Color orange(255, 165, 0); it.fill(bg); - this->label_(it, font, 12, 8, "BAT", this->low_battery ? red : green); - this->label_(it, font, 46, 8, "BLE", this->connected ? blue : inactive); + this->draw_icon_(it, 12, 8, 16, 16, ACCU_BMP, this->low_battery ? red : green); + this->draw_icon_(it, 46, 8, 16, 16, BLE_BMP, this->connected ? blue : inactive); this->label_(it, font, 86, 8, "AUTO", this->auto_range() ? fg : inactive); this->label_(it, font, 138, 8, "MAX", this->max_mode() ? red : inactive); this->label_(it, font, 178, 8, "MIN", this->min_mode() ? green : inactive); From 69c2166414478d49a3900e992d98c04deba7911f Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Tue, 26 May 2026 18:00:35 +0200 Subject: [PATCH 09/29] Archived the Core1 Owon multimeter config --- esphome/archive/owon-b35t-m5stack-core1.yaml | 293 ++++++++++ esphome/archive/owon_b35t-m5stack-core1.h | 567 +++++++++++++++++++ 2 files changed, 860 insertions(+) create mode 100644 esphome/archive/owon-b35t-m5stack-core1.yaml create mode 100644 esphome/archive/owon_b35t-m5stack-core1.h diff --git a/esphome/archive/owon-b35t-m5stack-core1.yaml b/esphome/archive/owon-b35t-m5stack-core1.yaml new file mode 100644 index 0000000..896bef0 --- /dev/null +++ b/esphome/archive/owon-b35t-m5stack-core1.yaml @@ -0,0 +1,293 @@ +substitutions: + name: "owon-b35t" + friendly_name: "OWON B35T Multimeter" + device_description: "M5Stack Core 1 BLE client for OWON B35T/B35T+ multimeter with local graphical display" + owon_mac_address: !secret owon_b35t_mac_address + +esphome: + name: ${name} + friendly_name: ${friendly_name} + comment: ${device_description} + min_version: 2024.6.0 + includes: + - owon_b35t-m5stack-core1.h + project: + name: "custom.owon-b35t-m5stack" + version: "1.0" + +esp32: + board: m5stack-core-esp32 + framework: + type: esp-idf + advanced: + minimum_chip_revision: "3.1" + +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: "OWON B35T Fallback Hotspot" + password: !secret fallback_psk + +# Disabled to save RAM on the M5Stack Core 1. Re-enable temporarily if Wi-Fi recovery is needed. +# captive_portal: + +interval: + - interval: 10s + then: + - lambda: |- + ESP_LOGI("mem", "heap free=%u min_free=%u internal_free=%u internal_largest=%u dma_free=%u dma_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))); + +esp32_ble_tracker: + scan_parameters: + active: true + continuous: true + +ble_client: + - mac_address: ${owon_mac_address} + id: owon_ble_client + on_connect: + then: + - lambda: |- + owon_meter.on_connect(); + on_disconnect: + then: + - lambda: |- + owon_meter.on_disconnect(); + - logger.log: + level: WARN + format: "OWON BLE meter disconnected; restarting M5Stack to reclaim heap" + - delay: 1s + - lambda: |- + esp_restart(); + +spi: + clk_pin: GPIO18 + mosi_pin: GPIO23 + miso_pin: GPIO19 + +output: + - platform: ledc + pin: GPIO32 + id: lcd_backlight + +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: + [ + " ", + "!", + "%", + "+", + "-", + ".", + "/", + "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", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "k", + "m", + "n", + "o", + "r", + "s", + "t", + "u", + "v", + "w", + "y", + "z", + "°", + "µ", + "Ω", + ] + +display: + - platform: ili9xxx + id: lcd + model: M5STACK + cs_pin: GPIO14 + dc_pin: GPIO27 + reset_pin: GPIO33 + invert_colors: true + color_palette: 8BIT + rotation: 0 + update_interval: 500ms + lambda: |- + owon_meter.render(it, id(meter_font)); + +binary_sensor: + - platform: gpio + id: button_a + pin: + number: GPIO39 + inverted: true + internal: true + on_press: + then: + - lambda: |- + owon_meter.previous_button(); + - platform: gpio + id: button_b + pin: + number: GPIO38 + inverted: true + 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" + 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" + 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: gpio + id: button_c + pin: + number: GPIO37 + inverted: true + internal: true + on_press: + then: + - lambda: |- + owon_meter.next_button(); + + - platform: template + name: "${friendly_name} Connected" + lambda: |- + return owon_meter.connected; + - platform: template + name: "${friendly_name} Overload" + lambda: |- + return owon_meter.overload; + - platform: template + name: "${friendly_name} Low Battery" + lambda: |- + return owon_meter.low_battery; + +sensor: + - 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 + +text_sensor: + - platform: template + name: "${friendly_name} Reading" + update_interval: 2s + lambda: |- + return owon_meter.reading_text(); + - platform: template + name: "${friendly_name} Unit" + update_interval: 2s + lambda: |- + return std::string(owon_meter.scale()) + owon_meter.unit(); + - platform: template + name: "${friendly_name} Mode" + update_interval: 2s + lambda: |- + return owon_meter.mode_text(); diff --git a/esphome/archive/owon_b35t-m5stack-core1.h b/esphome/archive/owon_b35t-m5stack-core1.h new file mode 100644 index 0000000..9173dd2 --- /dev/null +++ b/esphome/archive/owon_b35t-m5stack-core1.h @@ -0,0 +1,567 @@ +/* + * ESPHome helper for OWON B35T/B35T+ BLE meter on M5Stack Core 1. + * Parser is based on the standalone Arduino sketch by Reaper7 + * (Beerware license, Revision 42) and Dean Cording's owonb35 notes. + */ +#pragma once + +#include +#include +#include +#include +#include +#include "esp_heap_caps.h" +#include "esp_system.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/components/display/display.h" + +namespace owon_b35t { + +using esphome::Color; +using esphome::display::Display; + +static const char *const TAG = "owon_b35t"; + +static const uint8_t ACCU_BMP[32] = { + 0b00000000, 0b00000000, + 0b00000000, 0b00000000, + 0b00000000, 0b00000000, + 0b11111111, 0b11111110, + 0b10000000, 0b00000010, + 0b10000000, 0b00000011, + 0b10000000, 0b00000011, + 0b10000000, 0b00000011, + 0b10000000, 0b00000011, + 0b10000000, 0b00000011, + 0b10000000, 0b00000011, + 0b10000000, 0b00000010, + 0b11111111, 0b11111110, + 0b00000000, 0b00000000, + 0b00000000, 0b00000000, + 0b00000000, 0b00000000, +}; + +static const uint8_t BLE_BMP[32] = { + 0b00000001, 0b10000000, + 0b00000001, 0b11000000, + 0b00010001, 0b01100000, + 0b00011001, 0b00110000, + 0b00001101, 0b00011000, + 0b00000111, 0b00110000, + 0b00000011, 0b01100000, + 0b00000001, 0b11000000, + 0b00000001, 0b11000000, + 0b00000011, 0b01100000, + 0b00000111, 0b00110000, + 0b00001101, 0b00011000, + 0b00011001, 0b00110000, + 0b00010001, 0b01100000, + 0b00000001, 0b11000000, + 0b00000001, 0b10000000, +}; + +static const uint8_t DIODE_BMP[32] = { + 0b00001000, 0b00011000, + 0b00001100, 0b00011000, + 0b00001110, 0b00011000, + 0b00001111, 0b00011000, + 0b00001111, 0b10011000, + 0b00001111, 0b11011000, + 0b00001111, 0b11111000, + 0b11111111, 0b11111111, + 0b11111111, 0b11111111, + 0b00001111, 0b11111000, + 0b00001111, 0b11011000, + 0b00001111, 0b10011000, + 0b00001111, 0b00011000, + 0b00001110, 0b00011000, + 0b00001100, 0b00011000, + 0b00001000, 0b00011000, +}; + +static const uint8_t BUZZ_BMP[32] = { + 0b00000000, 0b11000000, + 0b00000001, 0b11000000, + 0b00000011, 0b11000001, + 0b00000111, 0b11000001, + 0b00001111, 0b11000101, + 0b11111111, 0b11000101, + 0b11111111, 0b11010101, + 0b11111111, 0b11010101, + 0b11111111, 0b11010101, + 0b11111111, 0b11010101, + 0b11111111, 0b11000101, + 0b00001111, 0b11000101, + 0b00000111, 0b11000001, + 0b00000011, 0b11000001, + 0b00000001, 0b11000000, + 0b00000000, 0b11000000, +}; + +class Meter { + public: + static constexpr uint8_t REGPLUSMINUS = 0x00; + static constexpr uint8_t FLAGPLUS = 0b00101011; + static constexpr uint8_t FLAGMINUS = 0b00101101; + static constexpr uint8_t REGDIG1 = 0x01; + static constexpr uint8_t REGDIG2 = 0x02; + static constexpr uint8_t REGDIG3 = 0x03; + static constexpr uint8_t REGDIG4 = 0x04; + static constexpr uint8_t REGPOINT = 0x06; + static constexpr uint8_t FLAGPOINT0 = 0b00110000; + static constexpr uint8_t FLAGPOINT1 = 0b00110001; + static constexpr uint8_t FLAGPOINT2 = 0b00110010; + static constexpr uint8_t FLAGPOINT3 = 0b00110100; + static constexpr uint8_t REGMODE = 0x07; + static constexpr uint8_t FLAGMODEHOLD = 0b00000010; + static constexpr uint8_t FLAGMODEREL = 0b00000100; + static constexpr uint8_t FLAGMODEAC = 0b00001000; + static constexpr uint8_t FLAGMODEDC = 0b00010000; + static constexpr uint8_t FLAGMODEAUTO = 0b00100000; + static constexpr uint8_t REGMINMAX = 0x08; + static constexpr uint8_t FLAGMIN = 0b00010000; + static constexpr uint8_t FLAGMAX = 0b00100000; + static constexpr uint8_t REGSCALE = 0x09; + static constexpr uint8_t FLAGSCALEDUTY = 0b00000010; + static constexpr uint8_t FLAGSCALEDIODE = 0b00000100; + static constexpr uint8_t FLAGSCALEBUZZ = 0b00001000; + static constexpr uint8_t FLAGSCALEMEGA = 0b00010000; + static constexpr uint8_t FLAGSCALEKILO = 0b00100000; + static constexpr uint8_t FLAGSCALEMILLI = 0b01000000; + static constexpr uint8_t FLAGSCALEMICRO = 0b10000000; + static constexpr uint8_t REGUNIT = 0x0a; + static constexpr uint8_t FLAGUNITFAHR = 0b00000001; + static constexpr uint8_t FLAGUNITGRAD = 0b00000010; + static constexpr uint8_t FLAGUNITNF = 0b00000100; + static constexpr uint8_t FLAGUNITHZ = 0b00001000; + static constexpr uint8_t FLAGUNITHFE = 0b00010000; + static constexpr uint8_t FLAGUNITOHM = 0b00100000; + static constexpr uint8_t FLAGUNITAMP = 0b01000000; + static constexpr uint8_t FLAGUNITVOLT = 0b10000000; + + bool connected{false}; + bool write_available{false}; + bool is_plus{false}; + bool low_battery{false}; + bool overload{false}; + bool has_reading{false}; + uint8_t selected_button{1}; + uint32_t last_notify_ms{0}; + + bool handle_notify(const std::vector &data) { + if (data.size() > sizeof(this->raw_)) + return false; + if (data.size() == 6 && data[1] >= 0xF0) { + memset(this->raw_, 0, sizeof(this->raw_)); + memcpy(this->raw_, data.data(), data.size()); + this->is_plus = true; + this->parse_plus_(); + } else if (data.size() == 14 && data[12] == 0x0D && data[13] == 0x0A) { + memset(this->value_, 0, sizeof(this->value_)); + memcpy(this->value_, data.data(), data.size()); + this->is_plus = false; + } else { + ESP_LOGW(TAG, "Ignoring unexpected OWON frame length=%u", static_cast(data.size())); + return false; + } + + this->overload = memcmp(this->value_, OVERLOAD_FRAME, sizeof(OVERLOAD_FRAME)) == 0; + this->display_value = this->calc_display_value_(); + this->base_value = this->calc_base_value_(); + this->has_reading = true; + this->last_notify_ms = millis(); + return true; + } + + void on_connect() { + this->connected = true; + this->write_available = true; + } + + void on_disconnect() { + this->connected = false; + this->write_available = false; + } + + float value() const { return this->display_value; } + float value_base() const { return this->base_value; } + bool negative() const { return (this->value_[REGPLUSMINUS] & FLAGMINUS) == FLAGMINUS; } + bool auto_range() const { return (this->value_[REGMODE] & FLAGMODEAUTO) == FLAGMODEAUTO; } + bool hold() const { return (this->value_[REGMODE] & FLAGMODEHOLD) == FLAGMODEHOLD; } + bool relative() const { return (this->value_[REGMODE] & FLAGMODEREL) == FLAGMODEREL; } + bool ac() const { return (this->value_[REGMODE] & FLAGMODEAC) == FLAGMODEAC; } + bool dc() const { return (this->value_[REGMODE] & FLAGMODEDC) == FLAGMODEDC; } + bool min_mode() const { return (this->value_[REGMINMAX] & FLAGMIN) == FLAGMIN; } + bool max_mode() const { return (this->value_[REGMINMAX] & FLAGMAX) == FLAGMAX; } + bool diode() const { return (this->value_[REGSCALE] & FLAGSCALEDIODE) == FLAGSCALEDIODE; } + bool continuity() const { return (this->value_[REGSCALE] & FLAGSCALEBUZZ) == FLAGSCALEBUZZ; } + + const char *unit() const { + switch (this->value_[REGUNIT]) { + case FLAGUNITFAHR: return "°F"; + case FLAGUNITGRAD: return "°C"; + case FLAGUNITNF: return "nF"; + case FLAGUNITHZ: return "Hz"; + case FLAGUNITHFE: return "hFE"; + case FLAGUNITOHM: return "Ω"; + case FLAGUNITAMP: return "A"; + case FLAGUNITVOLT: return "V"; + default: return ""; + } + } + + const char *scale() const { + if ((this->value_[REGSCALE] & FLAGSCALEDUTY) == FLAGSCALEDUTY) return "%"; + if ((this->value_[REGSCALE] & FLAGSCALEMEGA) == FLAGSCALEMEGA) return "M"; + if ((this->value_[REGSCALE] & FLAGSCALEKILO) == FLAGSCALEKILO) return "k"; + if ((this->value_[REGSCALE] & FLAGSCALEMILLI) == FLAGSCALEMILLI) return "m"; + if ((this->value_[REGSCALE] & FLAGSCALEMICRO) == FLAGSCALEMICRO) return "µ"; + return ""; + } + + std::string mode_text() const { + std::string out; + if (this->dc()) out += "DC "; + if (this->ac()) out += "AC "; + if (this->auto_range()) out += "AUTO "; + if (this->hold()) out += "HOLD "; + if (this->relative()) out += "REL "; + if (this->min_mode()) out += "MIN "; + if (this->max_mode()) out += "MAX "; + if (this->diode()) out += "DIODE "; + if (this->continuity()) out += "CONT "; + if (!out.empty()) out.pop_back(); + return out; + } + + std::string reading_text() const { + if (!this->connected) return "Disconnected"; + if (!this->has_reading) return "Waiting for data"; + if (this->overload) return "OL " + std::string(this->scale()) + this->unit(); + char buf[48]; + snprintf(buf, sizeof(buf), "%s%.4g %s%s", this->negative() ? "-" : "", std::fabs(this->display_value), this->scale(), this->unit()); + return std::string(buf); + } + + enum Kind { KIND_OTHER, KIND_VOLTAGE, KIND_CURRENT, KIND_RESISTANCE, KIND_FREQUENCY, KIND_CAPACITANCE, KIND_TEMP_C, KIND_TEMP_F, KIND_DUTY }; + Kind kind() const { + if ((this->value_[REGSCALE] & FLAGSCALEDUTY) == FLAGSCALEDUTY) return KIND_DUTY; + switch (this->value_[REGUNIT]) { + case FLAGUNITVOLT: return KIND_VOLTAGE; + case FLAGUNITAMP: return KIND_CURRENT; + case FLAGUNITOHM: return KIND_RESISTANCE; + case FLAGUNITHZ: return KIND_FREQUENCY; + case FLAGUNITNF: return KIND_CAPACITANCE; + case FLAGUNITGRAD: return KIND_TEMP_C; + case FLAGUNITFAHR: return KIND_TEMP_F; + default: return KIND_OTHER; + } + } + + const char *selected_button_name() const { + static const char *const names[] = {"SELECT", "RANGE", "HLD/LIG", "REL/BT", "HZ/DUTY", "MAX/MIN"}; + uint8_t index = this->selected_button; + if (index < 1) index = 1; + if (index > 6) index = 6; + return names[index - 1]; + } + + void previous_button() { + if (this->selected_button > 1) this->selected_button--; + } + void next_button() { + if (this->selected_button < 6) this->selected_button++; + } + + void render(Display &it, esphome::display::BaseFont *font) { + const Color bg(0, 0, 0); + const Color fg(210, 210, 210); + // Chosen to map to a neutral dark gray in the RGB332 8-bit display palette. + const Color inactive(80, 80, 80); + const Color yellow(255, 220, 0); + const Color blue(0, 80, 255); + const Color cyan(0, 255, 255); + const Color magenta(255, 0, 255); + const Color red(255, 0, 0); + const Color green(0, 220, 0); + const Color orange(255, 165, 0); + + it.fill(bg); + this->draw_icon_(it, 12, 8, 16, 16, ACCU_BMP, this->low_battery ? red : green); + this->draw_icon_(it, 46, 8, 16, 16, BLE_BMP, this->connected ? blue : inactive); + this->label_(it, font, 86, 8, "AUTO", this->auto_range() ? fg : inactive); + this->label_(it, font, 138, 8, "MAX", this->max_mode() ? red : inactive); + this->label_(it, font, 178, 8, "MIN", this->min_mode() ? green : inactive); + this->label_(it, font, 218, 8, "HOLD", this->hold() ? blue : inactive); + this->label_(it, font, 270, 8, "REL", this->relative() ? Color(128, 128, 0) : inactive); + + this->label_(it, font, 8, 66, "DC", this->dc() ? cyan : inactive); + this->label_(it, font, 8, 102, "AC", this->ac() ? magenta : inactive); + + if (!this->connected) { + this->draw_digits_(it, "----", false, inactive); + it.print(160, 148, font, inactive, esphome::display::TextAlign::CENTER, "scan/connect"); + } else if (!this->has_reading) { + this->draw_digits_(it, "8888", false, inactive); + it.print(160, 148, font, inactive, esphome::display::TextAlign::CENTER, "waiting"); + } else if (this->overload) { + this->draw_digits_(it, " OL ", false, fg); + } else { + char d[5]; + d[0] = this->digit_char_(REGDIG1); + d[1] = this->digit_char_(REGDIG2); + d[2] = this->digit_char_(REGDIG3); + d[3] = this->digit_char_(REGDIG4); + d[4] = 0; + this->draw_digits_(it, d, this->negative(), fg); + this->draw_decimal_points_(it, fg); + } + + std::string unit_line = std::string(this->scale()) + this->unit(); + it.print(270, 140, font, yellow, esphome::display::TextAlign::CENTER, unit_line.c_str()); + + this->draw_bargraph_(it, this->has_reading && !this->overload ? this->digits_from_buffer_() : 0, this->has_reading && !this->overload); + this->draw_icon_(it, 300, 148, 16, 16, DIODE_BMP, this->diode() ? magenta : inactive); + this->draw_icon_(it, 300, 174, 16, 16, BUZZ_BMP, this->continuity() ? orange : inactive); + + it.filled_rectangle(34, 212, 40, 24, this->write_available ? fg : inactive); + it.filled_rectangle(108, 212, 100, 24, this->write_available ? fg : inactive); + it.filled_rectangle(242, 212, 40, 24, this->write_available ? fg : inactive); + it.print(54, 216, font, bg, esphome::display::TextAlign::TOP_CENTER, "<"); + it.print(158, 216, font, bg, esphome::display::TextAlign::TOP_CENTER, this->selected_button_name()); + it.print(262, 216, font, bg, esphome::display::TextAlign::TOP_CENTER, ">"); + } + + private: + uint8_t raw_[14]{}; + uint8_t value_[14]{}; + float display_value{NAN}; + float base_value{NAN}; + static constexpr uint8_t OVERLOAD_FRAME[5] = {0x2B, 0x3F, 0x30, 0x3A, 0x3F}; + + uint16_t digits_from_buffer_() const { + uint16_t out = 0; + if (this->value_[REGDIG1] >= '0' && this->value_[REGDIG1] <= '9') out += (this->value_[REGDIG1] - '0') * 1000; + if (this->value_[REGDIG2] >= '0' && this->value_[REGDIG2] <= '9') out += (this->value_[REGDIG2] - '0') * 100; + if (this->value_[REGDIG3] >= '0' && this->value_[REGDIG3] <= '9') out += (this->value_[REGDIG3] - '0') * 10; + if (this->value_[REGDIG4] >= '0' && this->value_[REGDIG4] <= '9') out += (this->value_[REGDIG4] - '0'); + return out; + } + + float calc_display_value_() const { + if (this->overload) return NAN; + + if (this->is_plus) { + uint16_t pair1 = static_cast(this->raw_[0]) | (static_cast(this->raw_[1]) << 8); + uint8_t decimal = pair1 & 0x07; + if (decimal >= 7) return NAN; + + uint16_t pair3 = static_cast(this->raw_[4]) | (static_cast(this->raw_[5]) << 8); + bool negative = pair3 >= 0x7FFF; + uint16_t digits = negative ? (pair3 & 0x7FFF) : pair3; + float v = static_cast(digits) / std::pow(10.0f, decimal); + return negative ? -v : v; + } + + uint8_t decimal = 0; + switch (this->value_[REGPOINT] & 0x07) { + case 0b001: decimal = 1; break; + case 0b010: decimal = 2; break; + case 0b100: decimal = 3; break; + default: break; + } + float v = static_cast(this->digits_from_buffer_()) / std::pow(10.0f, decimal); + return this->negative() ? -v : v; + } + + float calc_base_value_() const { + if (std::isnan(this->display_value)) return NAN; + if (this->value_[REGUNIT] == FLAGUNITNF) return this->display_value * 1e-9f; + if ((this->value_[REGSCALE] & FLAGSCALEMEGA) == FLAGSCALEMEGA) return this->display_value * 1e6f; + if ((this->value_[REGSCALE] & FLAGSCALEKILO) == FLAGSCALEKILO) return this->display_value * 1e3f; + if ((this->value_[REGSCALE] & FLAGSCALEMILLI) == FLAGSCALEMILLI) return this->display_value * 1e-3f; + if ((this->value_[REGSCALE] & FLAGSCALEMICRO) == FLAGSCALEMICRO) return this->display_value * 1e-6f; + return this->display_value; + } + + void parse_plus_() { + memset(this->value_, 0, sizeof(this->value_)); + this->value_[5] = 0x20; + this->value_[12] = 0x0D; + this->value_[13] = 0x0A; + + uint16_t pair1 = static_cast(this->raw_[0]) | (static_cast(this->raw_[1]) << 8); + uint8_t function = (pair1 >> 6) & 0x0F; + uint8_t scale = (pair1 >> 3) & 0x07; + uint8_t decimal = pair1 & 0x07; + + switch (decimal) { + case 0: this->value_[REGPOINT] = FLAGPOINT0; break; + case 1: this->value_[REGPOINT] = FLAGPOINT3; break; + case 2: this->value_[REGPOINT] = FLAGPOINT2; break; + case 3: this->value_[REGPOINT] = FLAGPOINT1; break; + default: break; + } + + switch (function) { + case 0: this->value_[REGUNIT] |= FLAGUNITVOLT; this->value_[REGMODE] |= FLAGMODEDC; break; + case 1: this->value_[REGUNIT] |= FLAGUNITVOLT; this->value_[REGMODE] |= FLAGMODEAC; break; + case 2: this->value_[REGUNIT] |= FLAGUNITAMP; this->value_[REGMODE] |= FLAGMODEDC; break; + case 3: this->value_[REGUNIT] |= FLAGUNITAMP; this->value_[REGMODE] |= FLAGMODEAC; break; + case 4: this->value_[REGUNIT] |= FLAGUNITOHM; break; + case 5: this->value_[REGUNIT] |= FLAGUNITNF; break; + case 6: this->value_[REGUNIT] |= FLAGUNITHZ; break; + case 7: this->value_[REGSCALE] |= FLAGSCALEDUTY; break; + case 8: this->value_[REGUNIT] |= FLAGUNITGRAD; break; + case 9: this->value_[REGUNIT] |= FLAGUNITFAHR; break; + case 10: this->value_[REGSCALE] |= FLAGSCALEDIODE; break; + case 11: this->value_[REGSCALE] |= FLAGSCALEBUZZ; break; + case 12: this->value_[REGUNIT] |= FLAGUNITHFE; break; + default: break; + } + + switch (scale) { + case 2: this->value_[REGSCALE] |= FLAGSCALEMICRO; break; + case 3: this->value_[REGSCALE] |= FLAGSCALEMILLI; break; + case 5: this->value_[REGSCALE] |= FLAGSCALEKILO; break; + case 6: this->value_[REGSCALE] |= FLAGSCALEMEGA; break; + default: break; + } + + uint16_t pair2 = static_cast(this->raw_[2]) | (static_cast(this->raw_[3]) << 8); + if (pair2 & (1 << 0)) this->value_[REGMODE] |= FLAGMODEHOLD; + if (pair2 & (1 << 1)) this->value_[REGMODE] |= FLAGMODEREL; + if (pair2 & (1 << 2)) this->value_[REGMODE] |= FLAGMODEAUTO; + this->low_battery = (pair2 & (1 << 3)) != 0; + if (pair2 & (1 << 4)) this->value_[REGMINMAX] |= FLAGMIN; + if (pair2 & (1 << 5)) this->value_[REGMINMAX] |= FLAGMAX; + + uint16_t pair3 = static_cast(this->raw_[4]) | (static_cast(this->raw_[5]) << 8); + if (decimal < 7) { + uint16_t digits = pair3; + if (pair3 < 0x7FFF) { + this->value_[REGPLUSMINUS] = FLAGPLUS; + } else { + this->value_[REGPLUSMINUS] = FLAGMINUS; + digits = pair3 & 0x7FFF; + } + this->value_[REGDIG1] = '0' + ((digits / 1000) % 10); + this->value_[REGDIG2] = '0' + ((digits / 100) % 10); + this->value_[REGDIG3] = '0' + ((digits / 10) % 10); + this->value_[REGDIG4] = '0' + (digits % 10); + } else { + memcpy(this->value_, OVERLOAD_FRAME, sizeof(OVERLOAD_FRAME)); + } + } + + char digit_char_(uint8_t reg) const { + uint8_t c = this->value_[reg]; + return (c >= '0' && c <= '9') ? static_cast(c) : ' '; + } + + void label_(Display &it, esphome::display::BaseFont *font, int x, int y, const char *text, Color color) { + it.print(x, y, font, color, esphome::display::TextAlign::TOP_LEFT, text); + } + + void draw_digits_(Display &it, const char *text, bool negative, Color color) { + if (negative) this->draw_segment_(it, 8, 88, 26, 9, true, color); + constexpr int digit_x = 40; + constexpr int digit_y = 35; + constexpr int digit_w = 50; + constexpr int digit_h = 88; + constexpr int digit_distance = 64; + for (int i = 0; i < 4; i++) { + this->draw_seven_segment_(it, digit_x + i * digit_distance, digit_y, digit_w, digit_h, text[i], color); + } + } + + void draw_decimal_points_(Display &it, Color color) { + uint8_t p = this->value_[REGPOINT]; + if ((p & FLAGPOINT1) == FLAGPOINT1) it.filled_rectangle(95, 117, 8, 10, color); + if ((p & FLAGPOINT2) == FLAGPOINT2) it.filled_rectangle(159, 117, 8, 10, color); + if ((p & FLAGPOINT3) == FLAGPOINT3) it.filled_rectangle(223, 117, 8, 10, color); + } + + void draw_segment_(Display &it, int x, int y, int w, int h, bool horizontal, Color color) { + if (horizontal) { + int cap = h / 2; + it.filled_rectangle(x + cap, y, w - 2 * cap, h, color); + it.filled_triangle(x, y + cap, x + cap, y, x + cap, y + h, color); + it.filled_triangle(x + w, y + cap, x + w - cap, y, x + w - cap, y + h, color); + } else { + int cap = w / 2; + it.filled_rectangle(x, y + cap, w, h - 2 * cap, color); + it.filled_triangle(x + cap, y, x, y + cap, x + w, y + cap, color); + it.filled_triangle(x + cap, y + h, x, y + h - cap, x + w, y + h - cap, color); + } + } + + void draw_icon_(Display &it, int x, int y, int w, int h, const uint8_t *data, Color color) { + for (int row = 0; row < h; row++) { + for (int col = 0; col < w; col++) { + uint8_t byte = data[row * ((w + 7) / 8) + (col / 8)]; + if ((byte & (0x80 >> (col % 8))) != 0) { + it.draw_pixel_at(x + col, y + row, color); + } + } + } + } + + void draw_seven_segment_(Display &it, int x, int y, int w, int h, char ch, Color color) { + bool a=false,b=false,c=false,d=false,e=false,f=false,g=false; + switch (ch) { + case '0': a=b=c=d=e=f=true; break; + case '1': b=c=true; break; + case '2': a=b=d=e=g=true; break; + case '3': a=b=c=d=g=true; break; + case '4': b=c=f=g=true; break; + case '5': a=c=d=f=g=true; break; + case '6': a=c=d=e=f=g=true; break; + case '7': a=b=c=true; break; + case '8': a=b=c=d=e=f=g=true; break; + case '9': a=b=c=d=f=g=true; break; + case 'O': a=b=c=d=e=f=true; break; + case 'L': d=e=f=true; break; + case '-': g=true; break; + default: break; + } + int t = 10; + int half = h / 2; + int gap = 1; + + int top_y = y; + int mid_y = y + half - t / 2; + int bot_y = y + h - t; + + int upper_v_y = top_y + t + gap; + int upper_v_h = mid_y - gap - upper_v_y; + int lower_v_y = mid_y + t + gap; + int lower_v_h = bot_y - gap - lower_v_y; + + if (a) this->draw_segment_(it, x + t / 2, top_y, w - t, t, true, color); + if (b && upper_v_h > 0) this->draw_segment_(it, x + w - t, upper_v_y, t, upper_v_h, false, color); + if (c && lower_v_h > 0) this->draw_segment_(it, x + w - t, lower_v_y, t, lower_v_h, false, color); + if (d) this->draw_segment_(it, x + t / 2, bot_y, w - t, t, true, color); + if (e && lower_v_h > 0) this->draw_segment_(it, x, lower_v_y, t, lower_v_h, false, color); + if (f && upper_v_h > 0) this->draw_segment_(it, x, upper_v_y, t, upper_v_h, false, color); + if (g) this->draw_segment_(it, x + t / 2, mid_y, w - t, t, true, color); + } + + void draw_bargraph_(Display &it, uint16_t digits, bool active) { + const Color fg(255, 255, 255); + const Color inactive(80, 80, 80); + uint16_t mapped = active ? static_cast(digits * 240 / 6000) : 0; + if (mapped > 240) mapped = 240; + for (uint16_t i = 0; i <= 240; i += 4) { + Color col = (active && i <= mapped) ? fg : inactive; + int h = (i % 40 == 0) ? 20 : ((i % 20 == 0) ? 15 : 10); + it.vertical_line(40 + i, 185 - h, h, col); + } + it.horizontal_line(35, 185, 250, inactive); + } +}; + +} // namespace owon_b35t + +static owon_b35t::Meter owon_meter; From 1b3b4870e7c552f3a6e27aea1be295c27dd4d2e9 Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Tue, 26 May 2026 19:10:30 +0200 Subject: [PATCH 10/29] Ported the code to M5Stack Core2 (different display) and removed reboot on disconnect --- ...b35t.yaml => owon-b35t-m5stack-core2.yaml} | 104 ++++++++++-------- ...{owon_b35t.h => owon_b35t-m5stack-core2.h} | 87 +++++++++++++++ 2 files changed, 148 insertions(+), 43 deletions(-) rename esphome/{owon-b35t.yaml => owon-b35t-m5stack-core2.yaml} (78%) rename esphome/{owon_b35t.h => owon_b35t-m5stack-core2.h} (87%) diff --git a/esphome/owon-b35t.yaml b/esphome/owon-b35t-m5stack-core2.yaml similarity index 78% rename from esphome/owon-b35t.yaml rename to esphome/owon-b35t-m5stack-core2.yaml index ea6bc6f..d314250 100644 --- a/esphome/owon-b35t.yaml +++ b/esphome/owon-b35t-m5stack-core2.yaml @@ -1,7 +1,7 @@ substitutions: - name: "owon-b35t" - friendly_name: "OWON B35T Multimeter" - device_description: "M5Stack Core 1 BLE client for OWON B35T/B35T+ multimeter with local graphical display" + name: "owon-b35t-m5stack-core2" + friendly_name: "OWON B35T Multimeter Core2" + device_description: "M5Stack Core2 BLE client for OWON B35T/B35T+ multimeter with local graphical display" owon_mac_address: !secret owon_b35t_mac_address esphome: @@ -10,17 +10,27 @@ esphome: comment: ${device_description} min_version: 2024.6.0 includes: - - owon_b35t.h + - owon_b35t-m5stack-core2.h + on_boot: + priority: 850 + then: + - lambda: |- + owon_b35t::core2_axp192_init(id(core2_i2c)); project: - name: "custom.owon-b35t-m5stack" + name: "custom.owon-b35t-m5stack-core2" version: "1.0" esp32: - board: m5stack-core-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 logger: level: INFO @@ -44,20 +54,21 @@ wifi: ssid: "OWON B35T Fallback Hotspot" password: !secret fallback_psk -# Disabled to save RAM on the M5Stack Core 1. Re-enable temporarily if Wi-Fi recovery is needed. -# captive_portal: +captive_portal: interval: - interval: 10s then: - lambda: |- - ESP_LOGI("mem", "heap free=%u min_free=%u internal_free=%u internal_largest=%u dma_free=%u dma_largest=%u", + 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_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: @@ -75,22 +86,24 @@ ble_client: then: - lambda: |- owon_meter.on_disconnect(); - - logger.log: - level: WARN - format: "OWON BLE meter disconnected; restarting M5Stack to reclaim heap" - - delay: 1s - - lambda: |- - esp_restart(); spi: clk_pin: GPIO18 mosi_pin: GPIO23 - miso_pin: GPIO19 + +i2c: + id: core2_i2c + sda: GPIO21 + scl: GPIO22 + scan: true output: - - platform: ledc - pin: GPIO32 + - platform: template + type: float id: lcd_backlight + write_action: + - lambda: |- + owon_b35t::core2_axp192_set_backlight(state); light: - platform: monochromatic @@ -173,35 +186,38 @@ font: ] display: - - platform: ili9xxx + - platform: mipi_spi id: lcd - model: M5STACK - cs_pin: GPIO14 - dc_pin: GPIO27 - reset_pin: GPIO33 - invert_colors: true - color_palette: 8BIT - rotation: 0 + model: M5CORE2 update_interval: 500ms lambda: |- owon_meter.render(it, id(meter_font)); +touchscreen: + - platform: ft63x6 + id: touch + display: lcd + binary_sensor: - - platform: gpio + - platform: touchscreen id: button_a - pin: - number: GPIO39 - inverted: true + touchscreen_id: touch + x_min: 34 + x_max: 74 + y_min: 212 + y_max: 240 internal: true on_press: then: - lambda: |- owon_meter.previous_button(); - - platform: gpio + - platform: touchscreen id: button_b - pin: - number: GPIO38 - inverted: true + touchscreen_id: touch + x_min: 108 + x_max: 208 + y_min: 212 + y_max: 240 internal: true on_click: - min_length: 50ms @@ -233,11 +249,13 @@ binary_sensor: 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: gpio + - platform: touchscreen id: button_c - pin: - number: GPIO37 - inverted: true + touchscreen_id: touch + x_min: 242 + x_max: 282 + y_min: 212 + y_max: 240 internal: true on_press: then: @@ -278,16 +296,16 @@ sensor: text_sensor: - platform: template name: "${friendly_name} Reading" - update_interval: 2s + update_interval: 1s lambda: |- return owon_meter.reading_text(); - platform: template name: "${friendly_name} Unit" - update_interval: 2s + update_interval: 1s lambda: |- return std::string(owon_meter.scale()) + owon_meter.unit(); - platform: template name: "${friendly_name} Mode" - update_interval: 2s + update_interval: 1s lambda: |- return owon_meter.mode_text(); diff --git a/esphome/owon_b35t.h b/esphome/owon_b35t-m5stack-core2.h similarity index 87% rename from esphome/owon_b35t.h rename to esphome/owon_b35t-m5stack-core2.h index 9173dd2..95c7602 100644 --- a/esphome/owon_b35t.h +++ b/esphome/owon_b35t-m5stack-core2.h @@ -13,6 +13,7 @@ #include "esp_heap_caps.h" #include "esp_system.h" #include "esphome/core/helpers.h" +#include "esphome/components/i2c/i2c.h" #include "esphome/core/log.h" #include "esphome/components/display/display.h" @@ -22,6 +23,92 @@ using esphome::Color; using esphome::display::Display; static const char *const TAG = "owon_b35t"; +static const char *const POWER_TAG = "core2_power"; +static constexpr uint8_t AXP192_ADDR = 0x34; +static esphome::i2c::I2CDevice axp192; +static bool axp192_ready = false; + +static bool axp_write(uint8_t reg, uint8_t value) { + if (!axp192_ready) return false; + bool ok = axp192.write_byte(reg, value); + if (!ok) ESP_LOGW(POWER_TAG, "AXP192 write reg 0x%02X failed", reg); + return ok; +} + +static bool axp_read(uint8_t reg, uint8_t *value) { + if (!axp192_ready) return false; + bool ok = axp192.read_byte(reg, value); + if (!ok) ESP_LOGW(POWER_TAG, "AXP192 read reg 0x%02X failed", reg); + return ok; +} + +static void axp_update(uint8_t reg, uint8_t clear_mask, uint8_t set_mask) { + uint8_t value = 0; + if (!axp_read(reg, &value)) return; + value = (value & ~clear_mask) | set_mask; + axp_write(reg, value); +} + +static uint8_t axp_dc_voltage_data(uint16_t millivolts) { + if (millivolts < 700) millivolts = 700; + if (millivolts > 3500) millivolts = 3500; + return static_cast((millivolts - 700) / 25) & 0x7F; +} + +static uint8_t axp_ldo_voltage_data(uint16_t millivolts) { + if (millivolts < 1800) millivolts = 1800; + if (millivolts > 3300) millivolts = 3300; + return static_cast((millivolts - 1800) / 100) & 0x0F; +} + +static void core2_axp192_set_lcd_voltage(uint16_t millivolts) { + uint8_t value = 0; + axp_read(0x27, &value); + axp_write(0x27, (value & 0x80) | axp_dc_voltage_data(millivolts)); // DCDC3, LCD backlight +} + +static void core2_axp192_set_backlight(float brightness) { + if (brightness <= 0.0f) { + axp_update(0x12, 0x02, 0x00); // DCDC3 off + return; + } + if (brightness > 1.0f) brightness = 1.0f; + uint16_t millivolts = static_cast(2400 + brightness * 900); + core2_axp192_set_lcd_voltage(millivolts); + axp_update(0x12, 0x00, 0x02); // DCDC3 on +} + +static void core2_axp192_init(esphome::i2c::I2CBus *bus) { + axp192.set_i2c_bus(bus); + axp192.set_i2c_address(AXP192_ADDR); + axp192_ready = true; + ESP_LOGI(POWER_TAG, "Initializing M5Stack Core2 AXP192 LCD power"); + axp_update(0x30, 0xF9, 0x02); // Disable VBUS current limit, preserve bit 2. + axp_update(0x92, 0x07, 0x00); // GPIO1 open-drain output. + axp_update(0x93, 0x07, 0x00); // GPIO2 open-drain output. + axp_write(0x35, 0xA2); // RTC battery charging. + + uint8_t value = 0; + axp_read(0x26, &value); + axp_write(0x26, (value & 0x80) | axp_dc_voltage_data(3350)); // DCDC1 ESP32 VDD. + core2_axp192_set_lcd_voltage(2800); + + uint8_t ldo2 = axp_ldo_voltage_data(3300); + uint8_t ldo3 = axp_ldo_voltage_data(2000); + axp_write(0x28, (ldo2 << 4) | ldo3); // LDO2 LCD logic/SD, LDO3 vibrator. + axp_update(0x12, 0x00, 0x07); // Enable DCDC1, DCDC3, LDO2. + axp_write(0x82, 0xFF); // ADCs on. + axp_update(0x95, 0x8D, 0x84); // GPIO4 setup, as M5Core2 library does. + axp_write(0x36, 0x4C); // Power key timing. + + // LCD reset through AXP192 GPIO4. + axp_update(0x96, 0x02, 0x00); + delay(100); + axp_update(0x96, 0x00, 0x02); + delay(100); + + core2_axp192_set_backlight(1.0f); +} static const uint8_t ACCU_BMP[32] = { 0b00000000, 0b00000000, From b3e307dcba1b0d90ab060a75f4eeefd0bc4eee0b Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Tue, 26 May 2026 19:19:22 +0200 Subject: [PATCH 11/29] RAM optimization, deactivated recording of display values. --- esphome/owon-b35t-m5stack-core2.yaml | 1 + recorder.yaml | 58 ++++++++++++++-------------- 2 files changed, 31 insertions(+), 28 deletions(-) diff --git a/esphome/owon-b35t-m5stack-core2.yaml b/esphome/owon-b35t-m5stack-core2.yaml index d314250..32ead6b 100644 --- a/esphome/owon-b35t-m5stack-core2.yaml +++ b/esphome/owon-b35t-m5stack-core2.yaml @@ -26,6 +26,7 @@ esp32: framework: type: esp-idf advanced: + minimum_chip_revision: "3.1" sram1_as_iram: true psram: diff --git a/recorder.yaml b/recorder.yaml index 22e5cfb..f497a64 100644 --- a/recorder.yaml +++ b/recorder.yaml @@ -1,9 +1,9 @@ - db_url: !secret ha_recorder_dburl - # Commit to db only every X seconds - commit_interval: 60 - # Delete events and states older than 1 week - auto_purge: true - purge_keep_days: 7 +db_url: !secret ha_recorder_dburl +# Commit to db only every X seconds +commit_interval: 60 +# Delete events and states older than 1 week +auto_purge: true +purge_keep_days: 7 # include: # entity_globs: # - sensor.rd6018_controller_* @@ -15,27 +15,29 @@ # - sensor # - switch # - media_player - exclude: -# domains: -# - automation -# - updater - entity_globs: - - weather.zuhause_* - - sensor.*_power_factor - - sensor.dwd* - - binary_sensor.*firmware_update - - binary_sensor.*_overpowering - - binary_sensor.*_overheating - - sensor.*_wi_fi_signal - - sensor.*_wifi_strenght - - sensor.*_uptime - - sensor.sun* - entities: - - sun.sun # Don't record sun data - - sensor.fritzbox_device_uptime - - sensor.time - - sensor.awtrix_kitchen_current_app - - sensor.awtrix_desk_current_app +exclude: + # domains: + # - automation + # - updater + entity_globs: + - weather.zuhause_* + - sensor.*_power_factor + - sensor.dwd* + - binary_sensor.*firmware_update + - binary_sensor.*_overpowering + - binary_sensor.*_overheating + - sensor.*_wi_fi_signal + - sensor.*_wifi_strenght + - sensor.*_uptime + - sensor.sun* + entities: + - sun.sun # Don't record sun data + - sensor.fritzbox_device_uptime + - sensor.time + - sensor.awtrix_kitchen_current_app + - sensor.awtrix_desk_current_app + - sensor.owon_b35t_multimeter_core2_owon_b35t_multimeter_core2_reading + - sensor.owon_b35t_multimeter_core2_owon_b35t_multimeter_core2_mode + - sensor.owon_b35t_multimeter_core2_owon_b35t_multimeter_core2_unit # event_types: # - call_service # Don't record service calls - From f4f19eb847211b19331c280693f77cebf1598b5a Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Tue, 26 May 2026 19:25:56 +0200 Subject: [PATCH 12/29] Added attribution to original author and AI usage hint. --- esphome/owon-b35t-m5stack-core2.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/owon-b35t-m5stack-core2.yaml b/esphome/owon-b35t-m5stack-core2.yaml index 32ead6b..39803b4 100644 --- a/esphome/owon-b35t-m5stack-core2.yaml +++ b/esphome/owon-b35t-m5stack-core2.yaml @@ -1,3 +1,7 @@ +# Derived work based on https://github.com/reaper7/M5Stack_BLE_client_Owon_B35T by reaper7. +# AI (ChatGPT) has been used to adopt the Arduino sketch to ESPHome. +# Ported to M5Stack Core2 due to memory constraints. + substitutions: name: "owon-b35t-m5stack-core2" friendly_name: "OWON B35T Multimeter Core2" From e2eb886eef88a47b6eef24f0aee3bd0852a05780 Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Tue, 26 May 2026 19:35:45 +0200 Subject: [PATCH 13/29] Renamed .h file to match .yaml filename. --- .../{owon_b35t-m5stack-core2.h => owon-b35t-m5stack-core2.h} | 0 esphome/owon-b35t-m5stack-core2.yaml | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename esphome/{owon_b35t-m5stack-core2.h => owon-b35t-m5stack-core2.h} (100%) diff --git a/esphome/owon_b35t-m5stack-core2.h b/esphome/owon-b35t-m5stack-core2.h similarity index 100% rename from esphome/owon_b35t-m5stack-core2.h rename to esphome/owon-b35t-m5stack-core2.h diff --git a/esphome/owon-b35t-m5stack-core2.yaml b/esphome/owon-b35t-m5stack-core2.yaml index 39803b4..8f3422b 100644 --- a/esphome/owon-b35t-m5stack-core2.yaml +++ b/esphome/owon-b35t-m5stack-core2.yaml @@ -14,7 +14,7 @@ esphome: comment: ${device_description} min_version: 2024.6.0 includes: - - owon_b35t-m5stack-core2.h + - owon-b35t-m5stack-core2.h on_boot: priority: 850 then: From 2dbdfd213c77008eea3e7ac4db5e53bd36b0177d Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Thu, 28 May 2026 12:29:52 +0200 Subject: [PATCH 14/29] Deactivate bargraph after BLE disconnect --- .gitignore | 1 - esphome/owon-b35t-m5stack-core2.h | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 01c3840..1dea950 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,3 @@ home-assistant.log* zigbee.db* *__pycache__* .ha_run.lock - diff --git a/esphome/owon-b35t-m5stack-core2.h b/esphome/owon-b35t-m5stack-core2.h index 95c7602..647d5e4 100644 --- a/esphome/owon-b35t-m5stack-core2.h +++ b/esphome/owon-b35t-m5stack-core2.h @@ -408,7 +408,8 @@ class Meter { std::string unit_line = std::string(this->scale()) + this->unit(); it.print(270, 140, font, yellow, esphome::display::TextAlign::CENTER, unit_line.c_str()); - this->draw_bargraph_(it, this->has_reading && !this->overload ? this->digits_from_buffer_() : 0, this->has_reading && !this->overload); + bool bargraph_active = this->connected && this->has_reading && !this->overload; + this->draw_bargraph_(it, bargraph_active ? this->digits_from_buffer_() : 0, bargraph_active); this->draw_icon_(it, 300, 148, 16, 16, DIODE_BMP, this->diode() ? magenta : inactive); this->draw_icon_(it, 300, 174, 16, 16, BUZZ_BMP, this->continuity() ? orange : inactive); From 6038075e9e799f95e2ac6e40ff89bbc134bb58f6 Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Thu, 28 May 2026 13:29:39 +0200 Subject: [PATCH 15/29] Fixed off-by-one error in rendering of the 7-segment segments. --- esphome/owon-b35t-m5stack-core2.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/owon-b35t-m5stack-core2.h b/esphome/owon-b35t-m5stack-core2.h index 647d5e4..3dd7ae5 100644 --- a/esphome/owon-b35t-m5stack-core2.h +++ b/esphome/owon-b35t-m5stack-core2.h @@ -574,12 +574,12 @@ class Meter { void draw_segment_(Display &it, int x, int y, int w, int h, bool horizontal, Color color) { if (horizontal) { int cap = h / 2; - it.filled_rectangle(x + cap, y, w - 2 * cap, h, color); + it.filled_rectangle(x + cap, y, w - 2 * cap, h +1, color); it.filled_triangle(x, y + cap, x + cap, y, x + cap, y + h, color); it.filled_triangle(x + w, y + cap, x + w - cap, y, x + w - cap, y + h, color); } else { int cap = w / 2; - it.filled_rectangle(x, y + cap, w, h - 2 * cap, color); + it.filled_rectangle(x, y + cap, w +1, h - 2 * cap, color); it.filled_triangle(x + cap, y, x, y + cap, x + w, y + cap, color); it.filled_triangle(x + cap, y + h, x, y + h - cap, x + w, y + h - cap, color); } From f0cb74cc270273590fc74f82d7ec51a0831d6d6c Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Thu, 28 May 2026 13:40:46 +0200 Subject: [PATCH 16/29] Deactivate all indicator lights on meter disconnect. --- esphome/owon-b35t-m5stack-core2.h | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/esphome/owon-b35t-m5stack-core2.h b/esphome/owon-b35t-m5stack-core2.h index 3dd7ae5..a217f1d 100644 --- a/esphome/owon-b35t-m5stack-core2.h +++ b/esphome/owon-b35t-m5stack-core2.h @@ -375,16 +375,17 @@ class Meter { const Color orange(255, 165, 0); it.fill(bg); - this->draw_icon_(it, 12, 8, 16, 16, ACCU_BMP, this->low_battery ? red : green); + bool status_active = this->connected && this->has_reading; + this->draw_icon_(it, 12, 8, 16, 16, ACCU_BMP, status_active ? (this->low_battery ? red : green) : inactive); this->draw_icon_(it, 46, 8, 16, 16, BLE_BMP, this->connected ? blue : inactive); - this->label_(it, font, 86, 8, "AUTO", this->auto_range() ? fg : inactive); - this->label_(it, font, 138, 8, "MAX", this->max_mode() ? red : inactive); - this->label_(it, font, 178, 8, "MIN", this->min_mode() ? green : inactive); - this->label_(it, font, 218, 8, "HOLD", this->hold() ? blue : inactive); - this->label_(it, font, 270, 8, "REL", this->relative() ? Color(128, 128, 0) : inactive); + this->label_(it, font, 86, 8, "AUTO", status_active && this->auto_range() ? fg : inactive); + this->label_(it, font, 138, 8, "MAX", status_active && this->max_mode() ? red : inactive); + this->label_(it, font, 178, 8, "MIN", status_active && this->min_mode() ? green : inactive); + this->label_(it, font, 218, 8, "HOLD", status_active && this->hold() ? blue : inactive); + this->label_(it, font, 270, 8, "REL", status_active && this->relative() ? Color(128, 128, 0) : inactive); - this->label_(it, font, 8, 66, "DC", this->dc() ? cyan : inactive); - this->label_(it, font, 8, 102, "AC", this->ac() ? magenta : inactive); + this->label_(it, font, 8, 66, "DC", status_active && this->dc() ? cyan : inactive); + this->label_(it, font, 8, 102, "AC", status_active && this->ac() ? magenta : inactive); if (!this->connected) { this->draw_digits_(it, "----", false, inactive); @@ -405,13 +406,15 @@ class Meter { this->draw_decimal_points_(it, fg); } - std::string unit_line = std::string(this->scale()) + this->unit(); - it.print(270, 140, font, yellow, esphome::display::TextAlign::CENTER, unit_line.c_str()); + if (status_active) { + std::string unit_line = std::string(this->scale()) + this->unit(); + it.print(270, 140, font, yellow, esphome::display::TextAlign::CENTER, unit_line.c_str()); + } - bool bargraph_active = this->connected && this->has_reading && !this->overload; + bool bargraph_active = status_active && !this->overload; this->draw_bargraph_(it, bargraph_active ? this->digits_from_buffer_() : 0, bargraph_active); - this->draw_icon_(it, 300, 148, 16, 16, DIODE_BMP, this->diode() ? magenta : inactive); - this->draw_icon_(it, 300, 174, 16, 16, BUZZ_BMP, this->continuity() ? orange : inactive); + this->draw_icon_(it, 300, 148, 16, 16, DIODE_BMP, status_active && this->diode() ? magenta : inactive); + this->draw_icon_(it, 300, 174, 16, 16, BUZZ_BMP, status_active && this->continuity() ? orange : inactive); it.filled_rectangle(34, 212, 40, 24, this->write_available ? fg : inactive); it.filled_rectangle(108, 212, 100, 24, this->write_available ? fg : inactive); From 3f890ae02fb7e06ebe1a05bdb47701475dd18d56 Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Thu, 28 May 2026 14:35:12 +0200 Subject: [PATCH 17/29] Intregrated Atorch BLE proxy and migrated device name to a generic one. --- esphome/{ => archive}/atorch-ble-proxy.yaml | 0 .../{ => archive}/owon-b35t-m5stack-core2.h | 0 .../owon-b35t-m5stack-core2.yaml | 0 esphome/lab-ble-proxy-owon.h | 658 ++++++++++++++++++ esphome/lab-ble-proxy.yaml | 381 ++++++++++ 5 files changed, 1039 insertions(+) rename esphome/{ => archive}/atorch-ble-proxy.yaml (100%) rename esphome/{ => archive}/owon-b35t-m5stack-core2.h (100%) rename esphome/{ => archive}/owon-b35t-m5stack-core2.yaml (100%) create mode 100644 esphome/lab-ble-proxy-owon.h create mode 100644 esphome/lab-ble-proxy.yaml diff --git a/esphome/atorch-ble-proxy.yaml b/esphome/archive/atorch-ble-proxy.yaml similarity index 100% rename from esphome/atorch-ble-proxy.yaml rename to esphome/archive/atorch-ble-proxy.yaml diff --git a/esphome/owon-b35t-m5stack-core2.h b/esphome/archive/owon-b35t-m5stack-core2.h similarity index 100% rename from esphome/owon-b35t-m5stack-core2.h rename to esphome/archive/owon-b35t-m5stack-core2.h diff --git a/esphome/owon-b35t-m5stack-core2.yaml b/esphome/archive/owon-b35t-m5stack-core2.yaml similarity index 100% rename from esphome/owon-b35t-m5stack-core2.yaml rename to esphome/archive/owon-b35t-m5stack-core2.yaml diff --git a/esphome/lab-ble-proxy-owon.h b/esphome/lab-ble-proxy-owon.h new file mode 100644 index 0000000..a217f1d --- /dev/null +++ b/esphome/lab-ble-proxy-owon.h @@ -0,0 +1,658 @@ +/* + * ESPHome helper for OWON B35T/B35T+ BLE meter on M5Stack Core 1. + * Parser is based on the standalone Arduino sketch by Reaper7 + * (Beerware license, Revision 42) and Dean Cording's owonb35 notes. + */ +#pragma once + +#include +#include +#include +#include +#include +#include "esp_heap_caps.h" +#include "esp_system.h" +#include "esphome/core/helpers.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/log.h" +#include "esphome/components/display/display.h" + +namespace owon_b35t { + +using esphome::Color; +using esphome::display::Display; + +static const char *const TAG = "owon_b35t"; +static const char *const POWER_TAG = "core2_power"; +static constexpr uint8_t AXP192_ADDR = 0x34; +static esphome::i2c::I2CDevice axp192; +static bool axp192_ready = false; + +static bool axp_write(uint8_t reg, uint8_t value) { + if (!axp192_ready) return false; + bool ok = axp192.write_byte(reg, value); + if (!ok) ESP_LOGW(POWER_TAG, "AXP192 write reg 0x%02X failed", reg); + return ok; +} + +static bool axp_read(uint8_t reg, uint8_t *value) { + if (!axp192_ready) return false; + bool ok = axp192.read_byte(reg, value); + if (!ok) ESP_LOGW(POWER_TAG, "AXP192 read reg 0x%02X failed", reg); + return ok; +} + +static void axp_update(uint8_t reg, uint8_t clear_mask, uint8_t set_mask) { + uint8_t value = 0; + if (!axp_read(reg, &value)) return; + value = (value & ~clear_mask) | set_mask; + axp_write(reg, value); +} + +static uint8_t axp_dc_voltage_data(uint16_t millivolts) { + if (millivolts < 700) millivolts = 700; + if (millivolts > 3500) millivolts = 3500; + return static_cast((millivolts - 700) / 25) & 0x7F; +} + +static uint8_t axp_ldo_voltage_data(uint16_t millivolts) { + if (millivolts < 1800) millivolts = 1800; + if (millivolts > 3300) millivolts = 3300; + return static_cast((millivolts - 1800) / 100) & 0x0F; +} + +static void core2_axp192_set_lcd_voltage(uint16_t millivolts) { + uint8_t value = 0; + axp_read(0x27, &value); + axp_write(0x27, (value & 0x80) | axp_dc_voltage_data(millivolts)); // DCDC3, LCD backlight +} + +static void core2_axp192_set_backlight(float brightness) { + if (brightness <= 0.0f) { + axp_update(0x12, 0x02, 0x00); // DCDC3 off + return; + } + if (brightness > 1.0f) brightness = 1.0f; + uint16_t millivolts = static_cast(2400 + brightness * 900); + core2_axp192_set_lcd_voltage(millivolts); + axp_update(0x12, 0x00, 0x02); // DCDC3 on +} + +static void core2_axp192_init(esphome::i2c::I2CBus *bus) { + axp192.set_i2c_bus(bus); + axp192.set_i2c_address(AXP192_ADDR); + axp192_ready = true; + ESP_LOGI(POWER_TAG, "Initializing M5Stack Core2 AXP192 LCD power"); + axp_update(0x30, 0xF9, 0x02); // Disable VBUS current limit, preserve bit 2. + axp_update(0x92, 0x07, 0x00); // GPIO1 open-drain output. + axp_update(0x93, 0x07, 0x00); // GPIO2 open-drain output. + axp_write(0x35, 0xA2); // RTC battery charging. + + uint8_t value = 0; + axp_read(0x26, &value); + axp_write(0x26, (value & 0x80) | axp_dc_voltage_data(3350)); // DCDC1 ESP32 VDD. + core2_axp192_set_lcd_voltage(2800); + + uint8_t ldo2 = axp_ldo_voltage_data(3300); + uint8_t ldo3 = axp_ldo_voltage_data(2000); + axp_write(0x28, (ldo2 << 4) | ldo3); // LDO2 LCD logic/SD, LDO3 vibrator. + axp_update(0x12, 0x00, 0x07); // Enable DCDC1, DCDC3, LDO2. + axp_write(0x82, 0xFF); // ADCs on. + axp_update(0x95, 0x8D, 0x84); // GPIO4 setup, as M5Core2 library does. + axp_write(0x36, 0x4C); // Power key timing. + + // LCD reset through AXP192 GPIO4. + axp_update(0x96, 0x02, 0x00); + delay(100); + axp_update(0x96, 0x00, 0x02); + delay(100); + + core2_axp192_set_backlight(1.0f); +} + +static const uint8_t ACCU_BMP[32] = { + 0b00000000, 0b00000000, + 0b00000000, 0b00000000, + 0b00000000, 0b00000000, + 0b11111111, 0b11111110, + 0b10000000, 0b00000010, + 0b10000000, 0b00000011, + 0b10000000, 0b00000011, + 0b10000000, 0b00000011, + 0b10000000, 0b00000011, + 0b10000000, 0b00000011, + 0b10000000, 0b00000011, + 0b10000000, 0b00000010, + 0b11111111, 0b11111110, + 0b00000000, 0b00000000, + 0b00000000, 0b00000000, + 0b00000000, 0b00000000, +}; + +static const uint8_t BLE_BMP[32] = { + 0b00000001, 0b10000000, + 0b00000001, 0b11000000, + 0b00010001, 0b01100000, + 0b00011001, 0b00110000, + 0b00001101, 0b00011000, + 0b00000111, 0b00110000, + 0b00000011, 0b01100000, + 0b00000001, 0b11000000, + 0b00000001, 0b11000000, + 0b00000011, 0b01100000, + 0b00000111, 0b00110000, + 0b00001101, 0b00011000, + 0b00011001, 0b00110000, + 0b00010001, 0b01100000, + 0b00000001, 0b11000000, + 0b00000001, 0b10000000, +}; + +static const uint8_t DIODE_BMP[32] = { + 0b00001000, 0b00011000, + 0b00001100, 0b00011000, + 0b00001110, 0b00011000, + 0b00001111, 0b00011000, + 0b00001111, 0b10011000, + 0b00001111, 0b11011000, + 0b00001111, 0b11111000, + 0b11111111, 0b11111111, + 0b11111111, 0b11111111, + 0b00001111, 0b11111000, + 0b00001111, 0b11011000, + 0b00001111, 0b10011000, + 0b00001111, 0b00011000, + 0b00001110, 0b00011000, + 0b00001100, 0b00011000, + 0b00001000, 0b00011000, +}; + +static const uint8_t BUZZ_BMP[32] = { + 0b00000000, 0b11000000, + 0b00000001, 0b11000000, + 0b00000011, 0b11000001, + 0b00000111, 0b11000001, + 0b00001111, 0b11000101, + 0b11111111, 0b11000101, + 0b11111111, 0b11010101, + 0b11111111, 0b11010101, + 0b11111111, 0b11010101, + 0b11111111, 0b11010101, + 0b11111111, 0b11000101, + 0b00001111, 0b11000101, + 0b00000111, 0b11000001, + 0b00000011, 0b11000001, + 0b00000001, 0b11000000, + 0b00000000, 0b11000000, +}; + +class Meter { + public: + static constexpr uint8_t REGPLUSMINUS = 0x00; + static constexpr uint8_t FLAGPLUS = 0b00101011; + static constexpr uint8_t FLAGMINUS = 0b00101101; + static constexpr uint8_t REGDIG1 = 0x01; + static constexpr uint8_t REGDIG2 = 0x02; + static constexpr uint8_t REGDIG3 = 0x03; + static constexpr uint8_t REGDIG4 = 0x04; + static constexpr uint8_t REGPOINT = 0x06; + static constexpr uint8_t FLAGPOINT0 = 0b00110000; + static constexpr uint8_t FLAGPOINT1 = 0b00110001; + static constexpr uint8_t FLAGPOINT2 = 0b00110010; + static constexpr uint8_t FLAGPOINT3 = 0b00110100; + static constexpr uint8_t REGMODE = 0x07; + static constexpr uint8_t FLAGMODEHOLD = 0b00000010; + static constexpr uint8_t FLAGMODEREL = 0b00000100; + static constexpr uint8_t FLAGMODEAC = 0b00001000; + static constexpr uint8_t FLAGMODEDC = 0b00010000; + static constexpr uint8_t FLAGMODEAUTO = 0b00100000; + static constexpr uint8_t REGMINMAX = 0x08; + static constexpr uint8_t FLAGMIN = 0b00010000; + static constexpr uint8_t FLAGMAX = 0b00100000; + static constexpr uint8_t REGSCALE = 0x09; + static constexpr uint8_t FLAGSCALEDUTY = 0b00000010; + static constexpr uint8_t FLAGSCALEDIODE = 0b00000100; + static constexpr uint8_t FLAGSCALEBUZZ = 0b00001000; + static constexpr uint8_t FLAGSCALEMEGA = 0b00010000; + static constexpr uint8_t FLAGSCALEKILO = 0b00100000; + static constexpr uint8_t FLAGSCALEMILLI = 0b01000000; + static constexpr uint8_t FLAGSCALEMICRO = 0b10000000; + static constexpr uint8_t REGUNIT = 0x0a; + static constexpr uint8_t FLAGUNITFAHR = 0b00000001; + static constexpr uint8_t FLAGUNITGRAD = 0b00000010; + static constexpr uint8_t FLAGUNITNF = 0b00000100; + static constexpr uint8_t FLAGUNITHZ = 0b00001000; + static constexpr uint8_t FLAGUNITHFE = 0b00010000; + static constexpr uint8_t FLAGUNITOHM = 0b00100000; + static constexpr uint8_t FLAGUNITAMP = 0b01000000; + static constexpr uint8_t FLAGUNITVOLT = 0b10000000; + + bool connected{false}; + bool write_available{false}; + bool is_plus{false}; + bool low_battery{false}; + bool overload{false}; + bool has_reading{false}; + uint8_t selected_button{1}; + uint32_t last_notify_ms{0}; + + bool handle_notify(const std::vector &data) { + if (data.size() > sizeof(this->raw_)) + return false; + if (data.size() == 6 && data[1] >= 0xF0) { + memset(this->raw_, 0, sizeof(this->raw_)); + memcpy(this->raw_, data.data(), data.size()); + this->is_plus = true; + this->parse_plus_(); + } else if (data.size() == 14 && data[12] == 0x0D && data[13] == 0x0A) { + memset(this->value_, 0, sizeof(this->value_)); + memcpy(this->value_, data.data(), data.size()); + this->is_plus = false; + } else { + ESP_LOGW(TAG, "Ignoring unexpected OWON frame length=%u", static_cast(data.size())); + return false; + } + + this->overload = memcmp(this->value_, OVERLOAD_FRAME, sizeof(OVERLOAD_FRAME)) == 0; + this->display_value = this->calc_display_value_(); + this->base_value = this->calc_base_value_(); + this->has_reading = true; + this->last_notify_ms = millis(); + return true; + } + + void on_connect() { + this->connected = true; + this->write_available = true; + } + + void on_disconnect() { + this->connected = false; + this->write_available = false; + } + + float value() const { return this->display_value; } + float value_base() const { return this->base_value; } + bool negative() const { return (this->value_[REGPLUSMINUS] & FLAGMINUS) == FLAGMINUS; } + bool auto_range() const { return (this->value_[REGMODE] & FLAGMODEAUTO) == FLAGMODEAUTO; } + bool hold() const { return (this->value_[REGMODE] & FLAGMODEHOLD) == FLAGMODEHOLD; } + bool relative() const { return (this->value_[REGMODE] & FLAGMODEREL) == FLAGMODEREL; } + bool ac() const { return (this->value_[REGMODE] & FLAGMODEAC) == FLAGMODEAC; } + bool dc() const { return (this->value_[REGMODE] & FLAGMODEDC) == FLAGMODEDC; } + bool min_mode() const { return (this->value_[REGMINMAX] & FLAGMIN) == FLAGMIN; } + bool max_mode() const { return (this->value_[REGMINMAX] & FLAGMAX) == FLAGMAX; } + bool diode() const { return (this->value_[REGSCALE] & FLAGSCALEDIODE) == FLAGSCALEDIODE; } + bool continuity() const { return (this->value_[REGSCALE] & FLAGSCALEBUZZ) == FLAGSCALEBUZZ; } + + const char *unit() const { + switch (this->value_[REGUNIT]) { + case FLAGUNITFAHR: return "°F"; + case FLAGUNITGRAD: return "°C"; + case FLAGUNITNF: return "nF"; + case FLAGUNITHZ: return "Hz"; + case FLAGUNITHFE: return "hFE"; + case FLAGUNITOHM: return "Ω"; + case FLAGUNITAMP: return "A"; + case FLAGUNITVOLT: return "V"; + default: return ""; + } + } + + const char *scale() const { + if ((this->value_[REGSCALE] & FLAGSCALEDUTY) == FLAGSCALEDUTY) return "%"; + if ((this->value_[REGSCALE] & FLAGSCALEMEGA) == FLAGSCALEMEGA) return "M"; + if ((this->value_[REGSCALE] & FLAGSCALEKILO) == FLAGSCALEKILO) return "k"; + if ((this->value_[REGSCALE] & FLAGSCALEMILLI) == FLAGSCALEMILLI) return "m"; + if ((this->value_[REGSCALE] & FLAGSCALEMICRO) == FLAGSCALEMICRO) return "µ"; + return ""; + } + + std::string mode_text() const { + std::string out; + if (this->dc()) out += "DC "; + if (this->ac()) out += "AC "; + if (this->auto_range()) out += "AUTO "; + if (this->hold()) out += "HOLD "; + if (this->relative()) out += "REL "; + if (this->min_mode()) out += "MIN "; + if (this->max_mode()) out += "MAX "; + if (this->diode()) out += "DIODE "; + if (this->continuity()) out += "CONT "; + if (!out.empty()) out.pop_back(); + return out; + } + + std::string reading_text() const { + if (!this->connected) return "Disconnected"; + if (!this->has_reading) return "Waiting for data"; + if (this->overload) return "OL " + std::string(this->scale()) + this->unit(); + char buf[48]; + snprintf(buf, sizeof(buf), "%s%.4g %s%s", this->negative() ? "-" : "", std::fabs(this->display_value), this->scale(), this->unit()); + return std::string(buf); + } + + enum Kind { KIND_OTHER, KIND_VOLTAGE, KIND_CURRENT, KIND_RESISTANCE, KIND_FREQUENCY, KIND_CAPACITANCE, KIND_TEMP_C, KIND_TEMP_F, KIND_DUTY }; + Kind kind() const { + if ((this->value_[REGSCALE] & FLAGSCALEDUTY) == FLAGSCALEDUTY) return KIND_DUTY; + switch (this->value_[REGUNIT]) { + case FLAGUNITVOLT: return KIND_VOLTAGE; + case FLAGUNITAMP: return KIND_CURRENT; + case FLAGUNITOHM: return KIND_RESISTANCE; + case FLAGUNITHZ: return KIND_FREQUENCY; + case FLAGUNITNF: return KIND_CAPACITANCE; + case FLAGUNITGRAD: return KIND_TEMP_C; + case FLAGUNITFAHR: return KIND_TEMP_F; + default: return KIND_OTHER; + } + } + + const char *selected_button_name() const { + static const char *const names[] = {"SELECT", "RANGE", "HLD/LIG", "REL/BT", "HZ/DUTY", "MAX/MIN"}; + uint8_t index = this->selected_button; + if (index < 1) index = 1; + if (index > 6) index = 6; + return names[index - 1]; + } + + void previous_button() { + if (this->selected_button > 1) this->selected_button--; + } + void next_button() { + if (this->selected_button < 6) this->selected_button++; + } + + void render(Display &it, esphome::display::BaseFont *font) { + const Color bg(0, 0, 0); + const Color fg(210, 210, 210); + // Chosen to map to a neutral dark gray in the RGB332 8-bit display palette. + const Color inactive(80, 80, 80); + const Color yellow(255, 220, 0); + const Color blue(0, 80, 255); + const Color cyan(0, 255, 255); + const Color magenta(255, 0, 255); + const Color red(255, 0, 0); + const Color green(0, 220, 0); + const Color orange(255, 165, 0); + + it.fill(bg); + bool status_active = this->connected && this->has_reading; + this->draw_icon_(it, 12, 8, 16, 16, ACCU_BMP, status_active ? (this->low_battery ? red : green) : inactive); + this->draw_icon_(it, 46, 8, 16, 16, BLE_BMP, this->connected ? blue : inactive); + this->label_(it, font, 86, 8, "AUTO", status_active && this->auto_range() ? fg : inactive); + this->label_(it, font, 138, 8, "MAX", status_active && this->max_mode() ? red : inactive); + this->label_(it, font, 178, 8, "MIN", status_active && this->min_mode() ? green : inactive); + this->label_(it, font, 218, 8, "HOLD", status_active && this->hold() ? blue : inactive); + this->label_(it, font, 270, 8, "REL", status_active && this->relative() ? Color(128, 128, 0) : inactive); + + this->label_(it, font, 8, 66, "DC", status_active && this->dc() ? cyan : inactive); + this->label_(it, font, 8, 102, "AC", status_active && this->ac() ? magenta : inactive); + + if (!this->connected) { + this->draw_digits_(it, "----", false, inactive); + it.print(160, 148, font, inactive, esphome::display::TextAlign::CENTER, "scan/connect"); + } else if (!this->has_reading) { + this->draw_digits_(it, "8888", false, inactive); + it.print(160, 148, font, inactive, esphome::display::TextAlign::CENTER, "waiting"); + } else if (this->overload) { + this->draw_digits_(it, " OL ", false, fg); + } else { + char d[5]; + d[0] = this->digit_char_(REGDIG1); + d[1] = this->digit_char_(REGDIG2); + d[2] = this->digit_char_(REGDIG3); + d[3] = this->digit_char_(REGDIG4); + d[4] = 0; + this->draw_digits_(it, d, this->negative(), fg); + this->draw_decimal_points_(it, fg); + } + + if (status_active) { + std::string unit_line = std::string(this->scale()) + this->unit(); + it.print(270, 140, font, yellow, esphome::display::TextAlign::CENTER, unit_line.c_str()); + } + + bool bargraph_active = status_active && !this->overload; + this->draw_bargraph_(it, bargraph_active ? this->digits_from_buffer_() : 0, bargraph_active); + this->draw_icon_(it, 300, 148, 16, 16, DIODE_BMP, status_active && this->diode() ? magenta : inactive); + this->draw_icon_(it, 300, 174, 16, 16, BUZZ_BMP, status_active && this->continuity() ? orange : inactive); + + it.filled_rectangle(34, 212, 40, 24, this->write_available ? fg : inactive); + it.filled_rectangle(108, 212, 100, 24, this->write_available ? fg : inactive); + it.filled_rectangle(242, 212, 40, 24, this->write_available ? fg : inactive); + it.print(54, 216, font, bg, esphome::display::TextAlign::TOP_CENTER, "<"); + it.print(158, 216, font, bg, esphome::display::TextAlign::TOP_CENTER, this->selected_button_name()); + it.print(262, 216, font, bg, esphome::display::TextAlign::TOP_CENTER, ">"); + } + + private: + uint8_t raw_[14]{}; + uint8_t value_[14]{}; + float display_value{NAN}; + float base_value{NAN}; + static constexpr uint8_t OVERLOAD_FRAME[5] = {0x2B, 0x3F, 0x30, 0x3A, 0x3F}; + + uint16_t digits_from_buffer_() const { + uint16_t out = 0; + if (this->value_[REGDIG1] >= '0' && this->value_[REGDIG1] <= '9') out += (this->value_[REGDIG1] - '0') * 1000; + if (this->value_[REGDIG2] >= '0' && this->value_[REGDIG2] <= '9') out += (this->value_[REGDIG2] - '0') * 100; + if (this->value_[REGDIG3] >= '0' && this->value_[REGDIG3] <= '9') out += (this->value_[REGDIG3] - '0') * 10; + if (this->value_[REGDIG4] >= '0' && this->value_[REGDIG4] <= '9') out += (this->value_[REGDIG4] - '0'); + return out; + } + + float calc_display_value_() const { + if (this->overload) return NAN; + + if (this->is_plus) { + uint16_t pair1 = static_cast(this->raw_[0]) | (static_cast(this->raw_[1]) << 8); + uint8_t decimal = pair1 & 0x07; + if (decimal >= 7) return NAN; + + uint16_t pair3 = static_cast(this->raw_[4]) | (static_cast(this->raw_[5]) << 8); + bool negative = pair3 >= 0x7FFF; + uint16_t digits = negative ? (pair3 & 0x7FFF) : pair3; + float v = static_cast(digits) / std::pow(10.0f, decimal); + return negative ? -v : v; + } + + uint8_t decimal = 0; + switch (this->value_[REGPOINT] & 0x07) { + case 0b001: decimal = 1; break; + case 0b010: decimal = 2; break; + case 0b100: decimal = 3; break; + default: break; + } + float v = static_cast(this->digits_from_buffer_()) / std::pow(10.0f, decimal); + return this->negative() ? -v : v; + } + + float calc_base_value_() const { + if (std::isnan(this->display_value)) return NAN; + if (this->value_[REGUNIT] == FLAGUNITNF) return this->display_value * 1e-9f; + if ((this->value_[REGSCALE] & FLAGSCALEMEGA) == FLAGSCALEMEGA) return this->display_value * 1e6f; + if ((this->value_[REGSCALE] & FLAGSCALEKILO) == FLAGSCALEKILO) return this->display_value * 1e3f; + if ((this->value_[REGSCALE] & FLAGSCALEMILLI) == FLAGSCALEMILLI) return this->display_value * 1e-3f; + if ((this->value_[REGSCALE] & FLAGSCALEMICRO) == FLAGSCALEMICRO) return this->display_value * 1e-6f; + return this->display_value; + } + + void parse_plus_() { + memset(this->value_, 0, sizeof(this->value_)); + this->value_[5] = 0x20; + this->value_[12] = 0x0D; + this->value_[13] = 0x0A; + + uint16_t pair1 = static_cast(this->raw_[0]) | (static_cast(this->raw_[1]) << 8); + uint8_t function = (pair1 >> 6) & 0x0F; + uint8_t scale = (pair1 >> 3) & 0x07; + uint8_t decimal = pair1 & 0x07; + + switch (decimal) { + case 0: this->value_[REGPOINT] = FLAGPOINT0; break; + case 1: this->value_[REGPOINT] = FLAGPOINT3; break; + case 2: this->value_[REGPOINT] = FLAGPOINT2; break; + case 3: this->value_[REGPOINT] = FLAGPOINT1; break; + default: break; + } + + switch (function) { + case 0: this->value_[REGUNIT] |= FLAGUNITVOLT; this->value_[REGMODE] |= FLAGMODEDC; break; + case 1: this->value_[REGUNIT] |= FLAGUNITVOLT; this->value_[REGMODE] |= FLAGMODEAC; break; + case 2: this->value_[REGUNIT] |= FLAGUNITAMP; this->value_[REGMODE] |= FLAGMODEDC; break; + case 3: this->value_[REGUNIT] |= FLAGUNITAMP; this->value_[REGMODE] |= FLAGMODEAC; break; + case 4: this->value_[REGUNIT] |= FLAGUNITOHM; break; + case 5: this->value_[REGUNIT] |= FLAGUNITNF; break; + case 6: this->value_[REGUNIT] |= FLAGUNITHZ; break; + case 7: this->value_[REGSCALE] |= FLAGSCALEDUTY; break; + case 8: this->value_[REGUNIT] |= FLAGUNITGRAD; break; + case 9: this->value_[REGUNIT] |= FLAGUNITFAHR; break; + case 10: this->value_[REGSCALE] |= FLAGSCALEDIODE; break; + case 11: this->value_[REGSCALE] |= FLAGSCALEBUZZ; break; + case 12: this->value_[REGUNIT] |= FLAGUNITHFE; break; + default: break; + } + + switch (scale) { + case 2: this->value_[REGSCALE] |= FLAGSCALEMICRO; break; + case 3: this->value_[REGSCALE] |= FLAGSCALEMILLI; break; + case 5: this->value_[REGSCALE] |= FLAGSCALEKILO; break; + case 6: this->value_[REGSCALE] |= FLAGSCALEMEGA; break; + default: break; + } + + uint16_t pair2 = static_cast(this->raw_[2]) | (static_cast(this->raw_[3]) << 8); + if (pair2 & (1 << 0)) this->value_[REGMODE] |= FLAGMODEHOLD; + if (pair2 & (1 << 1)) this->value_[REGMODE] |= FLAGMODEREL; + if (pair2 & (1 << 2)) this->value_[REGMODE] |= FLAGMODEAUTO; + this->low_battery = (pair2 & (1 << 3)) != 0; + if (pair2 & (1 << 4)) this->value_[REGMINMAX] |= FLAGMIN; + if (pair2 & (1 << 5)) this->value_[REGMINMAX] |= FLAGMAX; + + uint16_t pair3 = static_cast(this->raw_[4]) | (static_cast(this->raw_[5]) << 8); + if (decimal < 7) { + uint16_t digits = pair3; + if (pair3 < 0x7FFF) { + this->value_[REGPLUSMINUS] = FLAGPLUS; + } else { + this->value_[REGPLUSMINUS] = FLAGMINUS; + digits = pair3 & 0x7FFF; + } + this->value_[REGDIG1] = '0' + ((digits / 1000) % 10); + this->value_[REGDIG2] = '0' + ((digits / 100) % 10); + this->value_[REGDIG3] = '0' + ((digits / 10) % 10); + this->value_[REGDIG4] = '0' + (digits % 10); + } else { + memcpy(this->value_, OVERLOAD_FRAME, sizeof(OVERLOAD_FRAME)); + } + } + + char digit_char_(uint8_t reg) const { + uint8_t c = this->value_[reg]; + return (c >= '0' && c <= '9') ? static_cast(c) : ' '; + } + + void label_(Display &it, esphome::display::BaseFont *font, int x, int y, const char *text, Color color) { + it.print(x, y, font, color, esphome::display::TextAlign::TOP_LEFT, text); + } + + void draw_digits_(Display &it, const char *text, bool negative, Color color) { + if (negative) this->draw_segment_(it, 8, 88, 26, 9, true, color); + constexpr int digit_x = 40; + constexpr int digit_y = 35; + constexpr int digit_w = 50; + constexpr int digit_h = 88; + constexpr int digit_distance = 64; + for (int i = 0; i < 4; i++) { + this->draw_seven_segment_(it, digit_x + i * digit_distance, digit_y, digit_w, digit_h, text[i], color); + } + } + + void draw_decimal_points_(Display &it, Color color) { + uint8_t p = this->value_[REGPOINT]; + if ((p & FLAGPOINT1) == FLAGPOINT1) it.filled_rectangle(95, 117, 8, 10, color); + if ((p & FLAGPOINT2) == FLAGPOINT2) it.filled_rectangle(159, 117, 8, 10, color); + if ((p & FLAGPOINT3) == FLAGPOINT3) it.filled_rectangle(223, 117, 8, 10, color); + } + + void draw_segment_(Display &it, int x, int y, int w, int h, bool horizontal, Color color) { + if (horizontal) { + int cap = h / 2; + it.filled_rectangle(x + cap, y, w - 2 * cap, h +1, color); + it.filled_triangle(x, y + cap, x + cap, y, x + cap, y + h, color); + it.filled_triangle(x + w, y + cap, x + w - cap, y, x + w - cap, y + h, color); + } else { + int cap = w / 2; + it.filled_rectangle(x, y + cap, w +1, h - 2 * cap, color); + it.filled_triangle(x + cap, y, x, y + cap, x + w, y + cap, color); + it.filled_triangle(x + cap, y + h, x, y + h - cap, x + w, y + h - cap, color); + } + } + + void draw_icon_(Display &it, int x, int y, int w, int h, const uint8_t *data, Color color) { + for (int row = 0; row < h; row++) { + for (int col = 0; col < w; col++) { + uint8_t byte = data[row * ((w + 7) / 8) + (col / 8)]; + if ((byte & (0x80 >> (col % 8))) != 0) { + it.draw_pixel_at(x + col, y + row, color); + } + } + } + } + + void draw_seven_segment_(Display &it, int x, int y, int w, int h, char ch, Color color) { + bool a=false,b=false,c=false,d=false,e=false,f=false,g=false; + switch (ch) { + case '0': a=b=c=d=e=f=true; break; + case '1': b=c=true; break; + case '2': a=b=d=e=g=true; break; + case '3': a=b=c=d=g=true; break; + case '4': b=c=f=g=true; break; + case '5': a=c=d=f=g=true; break; + case '6': a=c=d=e=f=g=true; break; + case '7': a=b=c=true; break; + case '8': a=b=c=d=e=f=g=true; break; + case '9': a=b=c=d=f=g=true; break; + case 'O': a=b=c=d=e=f=true; break; + case 'L': d=e=f=true; break; + case '-': g=true; break; + default: break; + } + int t = 10; + int half = h / 2; + int gap = 1; + + int top_y = y; + int mid_y = y + half - t / 2; + int bot_y = y + h - t; + + int upper_v_y = top_y + t + gap; + int upper_v_h = mid_y - gap - upper_v_y; + int lower_v_y = mid_y + t + gap; + int lower_v_h = bot_y - gap - lower_v_y; + + if (a) this->draw_segment_(it, x + t / 2, top_y, w - t, t, true, color); + if (b && upper_v_h > 0) this->draw_segment_(it, x + w - t, upper_v_y, t, upper_v_h, false, color); + if (c && lower_v_h > 0) this->draw_segment_(it, x + w - t, lower_v_y, t, lower_v_h, false, color); + if (d) this->draw_segment_(it, x + t / 2, bot_y, w - t, t, true, color); + if (e && lower_v_h > 0) this->draw_segment_(it, x, lower_v_y, t, lower_v_h, false, color); + if (f && upper_v_h > 0) this->draw_segment_(it, x, upper_v_y, t, upper_v_h, false, color); + if (g) this->draw_segment_(it, x + t / 2, mid_y, w - t, t, true, color); + } + + void draw_bargraph_(Display &it, uint16_t digits, bool active) { + const Color fg(255, 255, 255); + const Color inactive(80, 80, 80); + uint16_t mapped = active ? static_cast(digits * 240 / 6000) : 0; + if (mapped > 240) mapped = 240; + for (uint16_t i = 0; i <= 240; i += 4) { + Color col = (active && i <= mapped) ? fg : inactive; + int h = (i % 40 == 0) ? 20 : ((i % 20 == 0) ? 15 : 10); + it.vertical_line(40 + i, 185 - h, h, col); + } + it.horizontal_line(35, 185, 250, inactive); + } +}; + +} // namespace owon_b35t + +static owon_b35t::Meter owon_meter; diff --git a/esphome/lab-ble-proxy.yaml b/esphome/lab-ble-proxy.yaml new file mode 100644 index 0000000..e722c19 --- /dev/null +++ b/esphome/lab-ble-proxy.yaml @@ -0,0 +1,381 @@ +# Derived work based on https://github.com/reaper7/M5Stack_BLE_client_Owon_B35T by reaper7. +# AI (ChatGPT) has been used to adopt the Arduino sketch to ESPHome. +# Ported to M5Stack Core2 due to memory constraints. + +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-owon.h + on_boot: + priority: 850 + then: + - 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: + +interval: + - interval: 10s + then: + - 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: + - lambda: |- + owon_meter.on_connect(); + on_disconnect: + then: + - lambda: |- + owon_meter.on_disconnect(); + + - mac_address: ${dl24_mac_address} + id: atorch_ble_client + +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: + - 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: + [ + " ", + "!", + "%", + "+", + "-", + ".", + "/", + "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", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "k", + "m", + "n", + "o", + "r", + "s", + "t", + "u", + "v", + "w", + "y", + "z", + "°", + "µ", + "Ω", + ] + +display: + - platform: mipi_spi + id: lcd + model: M5CORE2 + update_interval: 500ms + lambda: |- + owon_meter.render(it, id(meter_font)); + +touchscreen: + - platform: ft63x6 + id: touch + display: lcd + +binary_sensor: + - 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: + - 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" + 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" + 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: + - lambda: |- + owon_meter.next_button(); + + - platform: template + name: "${friendly_name} OWON Connected" + lambda: |- + return owon_meter.connected; + - platform: template + name: "${friendly_name} OWON Overload" + lambda: |- + return owon_meter.overload; + - platform: template + name: "${friendly_name} OWON Low Battery" + lambda: |- + return owon_meter.low_battery; + + - platform: atorch_dl24 + atorch_dl24_id: atorch0 + running: + name: "${friendly_name} Atorch Running" + +sensor: + - 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" + current: + name: "${friendly_name} Atorch Current" + power: + name: "${friendly_name} Atorch Power" + capacity: + name: "${friendly_name} Atorch Capacity" + energy: + name: "${friendly_name} Atorch Energy" + temperature: + name: "${friendly_name} Atorch Temperature" + dim_backlight: + name: "${friendly_name} Atorch Dim Backlight" + runtime: + name: "${friendly_name} Atorch Runtime" + +text_sensor: + - platform: template + name: "${friendly_name} OWON Reading" + update_interval: 1s + lambda: |- + return owon_meter.reading_text(); + - platform: template + name: "${friendly_name} OWON Unit" + update_interval: 1s + lambda: |- + return std::string(owon_meter.scale()) + owon_meter.unit(); + - platform: template + name: "${friendly_name} OWON Mode" + update_interval: 1s + lambda: |- + return owon_meter.mode_text(); + + - platform: atorch_dl24 + atorch_dl24_id: atorch0 + runtime_formatted: + name: "${friendly_name} Atorch Runtime Formatted" + +button: + - platform: atorch_dl24 + atorch_dl24_id: atorch0 + reset_energy: + name: "${friendly_name} Atorch Reset Energy" + reset_capacity: + name: "${friendly_name} Atorch Reset Capacity" + reset_runtime: + name: "${friendly_name} Atorch Reset Runtime" + reset_all: + name: "${friendly_name} Atorch Reset All" + 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" From f9b32d06a4ef8f1c7d593fc740ea01912afd1c48 Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Thu, 28 May 2026 16:04:53 +0200 Subject: [PATCH 18/29] Expose buttons Owon multimeter buttons to HA. --- esphome/lab-ble-proxy.yaml | 59 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/esphome/lab-ble-proxy.yaml b/esphome/lab-ble-proxy.yaml index e722c19..7f5ea67 100644 --- a/esphome/lab-ble-proxy.yaml +++ b/esphome/lab-ble-proxy.yaml @@ -379,3 +379,62 @@ button: name: "${friendly_name} Atorch Setup" enter: name: "${friendly_name} Atorch Enter" + - 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" + 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" + value: !lambda "return std::vector({2, 0x01});" + + - platform: template + name: "OWON HLD/LIG" + 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" + value: !lambda "return std::vector({3, 0x01});" + + - platform: template + name: "OWON REL/BT" + 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" + value: !lambda "return std::vector({4, 0x01});" + + - 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" + 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" + value: !lambda "return std::vector({6, 0x01});" From 22e5f9d76770ff7806e2747b9d80a1c9244c43c4 Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Thu, 28 May 2026 22:09:54 +0200 Subject: [PATCH 19/29] Implemented rough 1st iteration of dc-load value screen. --- esphome/lab-ble-proxy-owon.h | 166 +++++++++++++++++++++++++---------- esphome/lab-ble-proxy.yaml | 67 +++++++++----- 2 files changed, 167 insertions(+), 66 deletions(-) diff --git a/esphome/lab-ble-proxy-owon.h b/esphome/lab-ble-proxy-owon.h index a217f1d..e47acea 100644 --- a/esphome/lab-ble-proxy-owon.h +++ b/esphome/lab-ble-proxy-owon.h @@ -1,5 +1,5 @@ /* - * ESPHome helper for OWON B35T/B35T+ BLE meter on M5Stack Core 1. + * ESPHome helper for OWON B35T/B35T+ BLE meter on M5Stack Core 2. * Parser is based on the standalone Arduino sketch by Reaper7 * (Beerware license, Revision 42) and Dean Cording's owonb35 notes. */ @@ -361,7 +361,11 @@ class Meter { if (this->selected_button < 6) this->selected_button++; } - void render(Display &it, esphome::display::BaseFont *font) { + void render(esphome::display::Display &it, esphome::display::BaseFont *font, + int display_page = 0, bool atorch_running = false, + float atorch_voltage = NAN, float atorch_current = NAN, float atorch_power = NAN, + float atorch_capacity = NAN, float atorch_energy = NAN, float atorch_temperature = NAN, + const char *atorch_runtime = "--:--:--") { const Color bg(0, 0, 0); const Color fg(210, 210, 210); // Chosen to map to a neutral dark gray in the RGB332 8-bit display palette. @@ -374,54 +378,98 @@ class Meter { const Color green(0, 220, 0); const Color orange(255, 165, 0); - it.fill(bg); - bool status_active = this->connected && this->has_reading; - this->draw_icon_(it, 12, 8, 16, 16, ACCU_BMP, status_active ? (this->low_battery ? red : green) : inactive); - this->draw_icon_(it, 46, 8, 16, 16, BLE_BMP, this->connected ? blue : inactive); - this->label_(it, font, 86, 8, "AUTO", status_active && this->auto_range() ? fg : inactive); - this->label_(it, font, 138, 8, "MAX", status_active && this->max_mode() ? red : inactive); - this->label_(it, font, 178, 8, "MIN", status_active && this->min_mode() ? green : inactive); - this->label_(it, font, 218, 8, "HOLD", status_active && this->hold() ? blue : inactive); - this->label_(it, font, 270, 8, "REL", status_active && this->relative() ? Color(128, 128, 0) : inactive); + if (display_page == 0) { + // --- PAGE 1: OWON Multimeter --- + it.fill(bg); + bool status_active = this->connected && this->has_reading; + this->draw_icon_(it, 12, 8, 16, 16, ACCU_BMP, status_active ? (this->low_battery ? red : green) : inactive); + this->draw_icon_(it, 46, 8, 16, 16, BLE_BMP, this->connected ? blue : inactive); + this->label_(it, font, 86, 8, "AUTO", status_active && this->auto_range() ? fg : inactive); + this->label_(it, font, 138, 8, "MAX", status_active && this->max_mode() ? red : inactive); + this->label_(it, font, 178, 8, "MIN", status_active && this->min_mode() ? green : inactive); + this->label_(it, font, 218, 8, "HOLD", status_active && this->hold() ? blue : inactive); + this->label_(it, font, 270, 8, "REL", status_active && this->relative() ? Color(128, 128, 0) : inactive); - this->label_(it, font, 8, 66, "DC", status_active && this->dc() ? cyan : inactive); - this->label_(it, font, 8, 102, "AC", status_active && this->ac() ? magenta : inactive); + this->label_(it, font, 8, 66, "DC", status_active && this->dc() ? cyan : inactive); + this->label_(it, font, 8, 102, "AC", status_active && this->ac() ? magenta : inactive); - if (!this->connected) { - this->draw_digits_(it, "----", false, inactive); - it.print(160, 148, font, inactive, esphome::display::TextAlign::CENTER, "scan/connect"); - } else if (!this->has_reading) { - this->draw_digits_(it, "8888", false, inactive); - it.print(160, 148, font, inactive, esphome::display::TextAlign::CENTER, "waiting"); - } else if (this->overload) { - this->draw_digits_(it, " OL ", false, fg); + if (!this->connected) { + this->draw_digits_(it, "----", false, inactive); + it.print(160, 148, font, inactive, esphome::display::TextAlign::CENTER, "scan/connect"); + } else if (!this->has_reading) { + this->draw_digits_(it, "8888", false, inactive); + it.print(160, 148, font, inactive, esphome::display::TextAlign::CENTER, "waiting"); + } else if (this->overload) { + this->draw_digits_(it, " OL ", false, fg); + } else { + char d[5]; + d[0] = this->digit_char_(REGDIG1); + d[1] = this->digit_char_(REGDIG2); + d[2] = this->digit_char_(REGDIG3); + d[3] = this->digit_char_(REGDIG4); + d[4] = 0; + this->draw_digits_(it, d, this->negative(), fg); + this->draw_decimal_points_(it, fg); + } + + if (status_active) { + std::string unit_line = std::string(this->scale()) + this->unit(); + it.print(270, 140, font, yellow, esphome::display::TextAlign::CENTER, unit_line.c_str()); + } + + bool bargraph_active = status_active && !this->overload; + this->draw_bargraph_(it, bargraph_active ? this->digits_from_buffer_() : 0, bargraph_active); + this->draw_icon_(it, 300, 148, 16, 16, DIODE_BMP, status_active && this->diode() ? magenta : inactive); + this->draw_icon_(it, 300, 174, 16, 16, BUZZ_BMP, status_active && this->continuity() ? orange : inactive); + + it.filled_rectangle(34, 212, 40, 24, this->write_available ? fg : inactive); + it.filled_rectangle(108, 212, 100, 24, this->write_available ? fg : inactive); + it.filled_rectangle(242, 212, 40, 24, this->write_available ? fg : inactive); + it.print(54, 216, font, bg, esphome::display::TextAlign::TOP_CENTER, "<"); + it.print(158, 216, font, bg, esphome::display::TextAlign::TOP_CENTER, this->selected_button_name()); + it.print(262, 216, font, bg, esphome::display::TextAlign::TOP_CENTER, ">"); } else { - char d[5]; - d[0] = this->digit_char_(REGDIG1); - d[1] = this->digit_char_(REGDIG2); - d[2] = this->digit_char_(REGDIG3); - d[3] = this->digit_char_(REGDIG4); - d[4] = 0; - this->draw_digits_(it, d, this->negative(), fg); - this->draw_decimal_points_(it, fg); + // --- PAGE 2: Atorch DL24 DC load --- + it.fill(bg); + const Color panel(18, 24, 32); + const Color panel_dim(10, 14, 20); + const Color border(55, 70, 86); + const Color white(245, 245, 245); + + char voltage_text[24]; + char current_text[24]; + char power_text[24]; + char capacity_text[24]; + char energy_text[24]; + char temperature_text[24]; + this->format_metric_(voltage_text, sizeof(voltage_text), atorch_voltage, "V", 2); + this->format_metric_(current_text, sizeof(current_text), atorch_current, "A", 3); + this->format_metric_(power_text, sizeof(power_text), atorch_power, "W", 2); + this->format_metric_(capacity_text, sizeof(capacity_text), atorch_capacity, "Ah", 3); + this->format_metric_(energy_text, sizeof(energy_text), atorch_energy, "Wh", 3); + this->format_metric_(temperature_text, sizeof(temperature_text), atorch_temperature, "°C", 1); + + it.filled_rectangle(0, 0, 320, 30, panel_dim); + it.print(10, 7, font, cyan, esphome::display::TextAlign::TOP_LEFT, "ATORCH DL24"); + it.filled_rectangle(230, 6, 80, 18, atorch_running ? green : inactive); + it.print(270, 8, font, bg, esphome::display::TextAlign::TOP_CENTER, atorch_running ? "RUNNING" : "STOPPED"); + + this->draw_metric_card_(it, font, 10, 42, 145, 70, "VOLTAGE", voltage_text, cyan, panel, border); + this->draw_metric_card_(it, font, 165, 42, 145, 70, "CURRENT", current_text, orange, panel, border); + this->draw_metric_card_(it, font, 10, 122, 145, 70, "POWER", power_text, yellow, panel, border); + this->draw_metric_card_(it, font, 165, 122, 145, 70, "TEMP", temperature_text, magenta, panel, border); + + it.filled_rectangle(10, 202, 300, 30, panel_dim); + it.filled_rectangle(10, 202, 300, 1, border); + it.filled_rectangle(10, 231, 300, 1, border); + it.filled_rectangle(10, 202, 1, 30, border); + it.filled_rectangle(309, 202, 1, 30, border); + it.print(24, 209, font, inactive, esphome::display::TextAlign::TOP_LEFT, "CAP"); + it.print(67, 209, font, white, esphome::display::TextAlign::TOP_LEFT, capacity_text); + it.print(143, 209, font, inactive, esphome::display::TextAlign::TOP_LEFT, "ENERGY"); + it.print(206, 209, font, white, esphome::display::TextAlign::TOP_LEFT, energy_text); + it.print(306, 209, font, green, esphome::display::TextAlign::TOP_RIGHT, atorch_runtime); } - - if (status_active) { - std::string unit_line = std::string(this->scale()) + this->unit(); - it.print(270, 140, font, yellow, esphome::display::TextAlign::CENTER, unit_line.c_str()); - } - - bool bargraph_active = status_active && !this->overload; - this->draw_bargraph_(it, bargraph_active ? this->digits_from_buffer_() : 0, bargraph_active); - this->draw_icon_(it, 300, 148, 16, 16, DIODE_BMP, status_active && this->diode() ? magenta : inactive); - this->draw_icon_(it, 300, 174, 16, 16, BUZZ_BMP, status_active && this->continuity() ? orange : inactive); - - it.filled_rectangle(34, 212, 40, 24, this->write_available ? fg : inactive); - it.filled_rectangle(108, 212, 100, 24, this->write_available ? fg : inactive); - it.filled_rectangle(242, 212, 40, 24, this->write_available ? fg : inactive); - it.print(54, 216, font, bg, esphome::display::TextAlign::TOP_CENTER, "<"); - it.print(158, 216, font, bg, esphome::display::TextAlign::TOP_CENTER, this->selected_button_name()); - it.print(262, 216, font, bg, esphome::display::TextAlign::TOP_CENTER, ">"); } private: @@ -551,6 +599,32 @@ class Meter { return (c >= '0' && c <= '9') ? static_cast(c) : ' '; } + void format_metric_(char *buffer, size_t size, float value, const char *unit, uint8_t decimals) const { + if (!std::isfinite(value)) { + snprintf(buffer, size, "-- %s", unit); + return; + } + char format[12]; + snprintf(format, sizeof(format), "%%.%uf %%s", decimals); + snprintf(buffer, size, format, value, unit); + } + + void draw_metric_card_(Display &it, esphome::display::BaseFont *font, int x, int y, int w, int h, + const char *title, const char *value, Color accent, Color fill, Color border) { + const Color bg(0, 0, 0); + const Color fg(235, 235, 235); + const Color inactive(90, 100, 110); + it.filled_rectangle(x, y, w, h, fill); + it.filled_rectangle(x, y, w, 2, accent); + it.filled_rectangle(x, y + h - 1, w, 1, border); + it.filled_rectangle(x, y, 1, h, border); + it.filled_rectangle(x + w - 1, y, 1, h, border); + it.print(x + 10, y + 10, font, inactive, esphome::display::TextAlign::TOP_LEFT, title); + it.print(x + w / 2, y + 38, font, fg, esphome::display::TextAlign::CENTER, value); + it.filled_rectangle(x + 10, y + h - 11, w - 20, 3, bg); + it.filled_rectangle(x + 10, y + h - 11, w - 20, 1, accent); + } + void label_(Display &it, esphome::display::BaseFont *font, int x, int y, const char *text, Color color) { it.print(x, y, font, color, esphome::display::TextAlign::TOP_LEFT, text); } diff --git a/esphome/lab-ble-proxy.yaml b/esphome/lab-ble-proxy.yaml index 7f5ea67..2e5ebf3 100644 --- a/esphome/lab-ble-proxy.yaml +++ b/esphome/lab-ble-proxy.yaml @@ -1,6 +1,7 @@ # Derived work based on https://github.com/reaper7/M5Stack_BLE_client_Owon_B35T by reaper7. -# AI (ChatGPT) has been used to adopt the Arduino sketch to ESPHome. +# AI (ChatGPT (GPT5.5), Qwen3.6) 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. substitutions: name: "lab-ble-proxy" @@ -68,6 +69,12 @@ wifi: captive_portal: +globals: + - id: display_page + type: int + initial_value: "0" + restore_value: no + interval: - interval: 10s then: @@ -146,6 +153,7 @@ font: "-", ".", "/", + ":", "0", "1", "2", @@ -182,6 +190,7 @@ font: "Y", "Z", "a", + "b", "c", "d", "e", @@ -190,9 +199,11 @@ font: "h", "i", "k", + "l", "m", "n", "o", + "p", "r", "s", "t", @@ -212,7 +223,19 @@ display: model: M5CORE2 update_interval: 500ms lambda: |- - owon_meter.render(it, id(meter_font)); + owon_meter.render( + it, + id(meter_font), + id(display_page), + id(atorch_running).state, + 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).has_state() ? id(atorch_energy).state : NAN, + id(atorch_temperature).has_state() ? id(atorch_temperature).state : NAN, + id(atorch_runtime_formatted).has_state() ? id(atorch_runtime_formatted).state.c_str() : "--:--:--" + ); touchscreen: - platform: ft63x6 @@ -220,6 +243,18 @@ touchscreen: display: lcd 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: + - lambda: |- + id(display_page) = 1 - id(display_page); + id(lcd).update(); - platform: touchscreen id: button_a touchscreen_id: touch @@ -296,11 +331,6 @@ binary_sensor: lambda: |- return owon_meter.low_battery; - - platform: atorch_dl24 - atorch_dl24_id: atorch0 - running: - name: "${friendly_name} Atorch Running" - sensor: - platform: ble_client type: characteristic @@ -323,20 +353,22 @@ sensor: 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" - energy: - name: "${friendly_name} Atorch Energy" + id: atorch_capacity temperature: name: "${friendly_name} Atorch Temperature" + id: atorch_temperature dim_backlight: name: "${friendly_name} Atorch Dim Backlight" - runtime: - name: "${friendly_name} Atorch Runtime" + id: atorch_dim_backlight text_sensor: - platform: template @@ -355,11 +387,6 @@ text_sensor: lambda: |- return owon_meter.mode_text(); - - platform: atorch_dl24 - atorch_dl24_id: atorch0 - runtime_formatted: - name: "${friendly_name} Atorch Runtime Formatted" - button: - platform: atorch_dl24 atorch_dl24_id: atorch0 @@ -400,7 +427,7 @@ button: value: !lambda "return std::vector({2, 0x01});" - platform: template - name: "OWON HLD/LIG" + name: "OWON HOLD | LIGHT" id: owon_btn_hold on_press: - ble_client.ble_write: @@ -410,7 +437,7 @@ button: value: !lambda "return std::vector({3, 0x01});" - platform: template - name: "OWON REL/BT" + name: "OWON RELATIVE | BT" id: owon_btn_rel on_press: - ble_client.ble_write: @@ -420,7 +447,7 @@ button: value: !lambda "return std::vector({4, 0x01});" - platform: template - name: "OWON HZ/DUTY" + name: "OWON HZ | DUTY" id: owon_btn_hz on_press: - ble_client.ble_write: @@ -430,7 +457,7 @@ button: value: !lambda "return std::vector({5, 0x01});" - platform: template - name: "OWON MAX/MIN" + name: "OWON MAX | MIN" id: owon_btn_maxmin on_press: - ble_client.ble_write: From 058c186d63a794faca031c7c527807ef64a03f2c Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Thu, 28 May 2026 22:24:46 +0200 Subject: [PATCH 20/29] Remove dead values from HA and rendering code. Also increase font size. --- esphome/lab-ble-proxy-owon.h | 48 ++++++++++++++++---------------- esphome/lab-ble-proxy.yaml | 53 +++++++++++++++++++++++++++++++++--- 2 files changed, 72 insertions(+), 29 deletions(-) diff --git a/esphome/lab-ble-proxy-owon.h b/esphome/lab-ble-proxy-owon.h index e47acea..ad36bca 100644 --- a/esphome/lab-ble-proxy-owon.h +++ b/esphome/lab-ble-proxy-owon.h @@ -362,10 +362,10 @@ class Meter { } void render(esphome::display::Display &it, esphome::display::BaseFont *font, - int display_page = 0, bool atorch_running = false, + esphome::display::BaseFont *value_font, int display_page = 0, + bool atorch_connected = false, float atorch_voltage = NAN, float atorch_current = NAN, float atorch_power = NAN, - float atorch_capacity = NAN, float atorch_energy = NAN, float atorch_temperature = NAN, - const char *atorch_runtime = "--:--:--") { + float atorch_capacity = NAN, float atorch_temperature = NAN) { const Color bg(0, 0, 0); const Color fg(210, 210, 210); // Chosen to map to a neutral dark gray in the RGB332 8-bit display palette. @@ -440,35 +440,32 @@ class Meter { char current_text[24]; char power_text[24]; char capacity_text[24]; - char energy_text[24]; char temperature_text[24]; this->format_metric_(voltage_text, sizeof(voltage_text), atorch_voltage, "V", 2); this->format_metric_(current_text, sizeof(current_text), atorch_current, "A", 3); this->format_metric_(power_text, sizeof(power_text), atorch_power, "W", 2); this->format_metric_(capacity_text, sizeof(capacity_text), atorch_capacity, "Ah", 3); - this->format_metric_(energy_text, sizeof(energy_text), atorch_energy, "Wh", 3); this->format_metric_(temperature_text, sizeof(temperature_text), atorch_temperature, "°C", 1); it.filled_rectangle(0, 0, 320, 30, panel_dim); it.print(10, 7, font, cyan, esphome::display::TextAlign::TOP_LEFT, "ATORCH DL24"); - it.filled_rectangle(230, 6, 80, 18, atorch_running ? green : inactive); - it.print(270, 8, font, bg, esphome::display::TextAlign::TOP_CENTER, atorch_running ? "RUNNING" : "STOPPED"); + if (atorch_connected) { + this->draw_icon_(it, 152, 7, 16, 16, BLE_BMP, blue); + } + it.print(310, 7, font, inactive, esphome::display::TextAlign::TOP_RIGHT, "DC LOAD"); - this->draw_metric_card_(it, font, 10, 42, 145, 70, "VOLTAGE", voltage_text, cyan, panel, border); - this->draw_metric_card_(it, font, 165, 42, 145, 70, "CURRENT", current_text, orange, panel, border); - this->draw_metric_card_(it, font, 10, 122, 145, 70, "POWER", power_text, yellow, panel, border); - this->draw_metric_card_(it, font, 165, 122, 145, 70, "TEMP", temperature_text, magenta, panel, border); + this->draw_metric_card_(it, font, value_font, 10, 42, 145, 76, "VOLTAGE", voltage_text, cyan, panel, border); + this->draw_metric_card_(it, font, value_font, 165, 42, 145, 76, "CURRENT", current_text, orange, panel, border); + this->draw_metric_card_(it, font, value_font, 10, 128, 145, 76, "POWER", power_text, yellow, panel, border); + this->draw_metric_card_(it, font, value_font, 165, 128, 145, 76, "TEMP", temperature_text, magenta, panel, border); - it.filled_rectangle(10, 202, 300, 30, panel_dim); - it.filled_rectangle(10, 202, 300, 1, border); - it.filled_rectangle(10, 231, 300, 1, border); - it.filled_rectangle(10, 202, 1, 30, border); - it.filled_rectangle(309, 202, 1, 30, border); - it.print(24, 209, font, inactive, esphome::display::TextAlign::TOP_LEFT, "CAP"); - it.print(67, 209, font, white, esphome::display::TextAlign::TOP_LEFT, capacity_text); - it.print(143, 209, font, inactive, esphome::display::TextAlign::TOP_LEFT, "ENERGY"); - it.print(206, 209, font, white, esphome::display::TextAlign::TOP_LEFT, energy_text); - it.print(306, 209, font, green, esphome::display::TextAlign::TOP_RIGHT, atorch_runtime); + it.filled_rectangle(10, 212, 300, 22, panel_dim); + it.filled_rectangle(10, 212, 300, 1, border); + it.filled_rectangle(10, 233, 300, 1, border); + it.filled_rectangle(10, 212, 1, 22, border); + it.filled_rectangle(309, 212, 1, 22, border); + it.print(24, 216, font, inactive, esphome::display::TextAlign::TOP_LEFT, "CAPACITY"); + it.print(306, 212, value_font, white, esphome::display::TextAlign::TOP_RIGHT, capacity_text); } } @@ -609,8 +606,9 @@ class Meter { snprintf(buffer, size, format, value, unit); } - void draw_metric_card_(Display &it, esphome::display::BaseFont *font, int x, int y, int w, int h, - const char *title, const char *value, Color accent, Color fill, Color border) { + void draw_metric_card_(Display &it, esphome::display::BaseFont *label_font, esphome::display::BaseFont *value_font, + int x, int y, int w, int h, const char *title, const char *value, + Color accent, Color fill, Color border) { const Color bg(0, 0, 0); const Color fg(235, 235, 235); const Color inactive(90, 100, 110); @@ -619,8 +617,8 @@ class Meter { it.filled_rectangle(x, y + h - 1, w, 1, border); it.filled_rectangle(x, y, 1, h, border); it.filled_rectangle(x + w - 1, y, 1, h, border); - it.print(x + 10, y + 10, font, inactive, esphome::display::TextAlign::TOP_LEFT, title); - it.print(x + w / 2, y + 38, font, fg, esphome::display::TextAlign::CENTER, value); + it.print(x + 10, y + 9, label_font, inactive, esphome::display::TextAlign::TOP_LEFT, title); + it.print(x + w / 2, y + 43, value_font, fg, esphome::display::TextAlign::CENTER, value); it.filled_rectangle(x + 10, y + h - 11, w - 20, 3, bg); it.filled_rectangle(x + 10, y + h - 11, w - 20, 1, accent); } diff --git a/esphome/lab-ble-proxy.yaml b/esphome/lab-ble-proxy.yaml index 2e5ebf3..5833167 100644 --- a/esphome/lab-ble-proxy.yaml +++ b/esphome/lab-ble-proxy.yaml @@ -74,6 +74,10 @@ globals: type: int initial_value: "0" restore_value: no + - id: atorch_connected + type: bool + initial_value: "false" + restore_value: no interval: - interval: 10s @@ -108,6 +112,16 @@ ble_client: - mac_address: ${dl24_mac_address} id: atorch_ble_client + on_connect: + then: + - lambda: |- + id(atorch_connected) = true; + id(lcd).update(); + on_disconnect: + then: + - lambda: |- + id(atorch_connected) = false; + id(lcd).update(); atorch_dl24: - id: atorch0 @@ -217,6 +231,33 @@ font: "Ω", ] + - file: "fonts/Roboto-Medium.ttf" + id: atorch_value_font + size: 22 + glyphs: + [ + " ", + "+", + "-", + ".", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "A", + "C", + "V", + "W", + "h", + "°", + ] + display: - platform: mipi_spi id: lcd @@ -226,15 +267,14 @@ display: owon_meter.render( it, id(meter_font), + id(atorch_value_font), id(display_page), - id(atorch_running).state, + 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).has_state() ? id(atorch_energy).state : NAN, - id(atorch_temperature).has_state() ? id(atorch_temperature).state : NAN, - id(atorch_runtime_formatted).has_state() ? id(atorch_runtime_formatted).state.c_str() : "--:--:--" + id(atorch_temperature).has_state() ? id(atorch_temperature).state : NAN ); touchscreen: @@ -330,6 +370,11 @@ binary_sensor: name: "${friendly_name} OWON Low Battery" lambda: |- return owon_meter.low_battery; + - platform: template + name: "${friendly_name} Atorch Connected" + device_class: connectivity + lambda: |- + return id(atorch_connected); sensor: - platform: ble_client From 816cc35861abe94aa516d03cfc1078c8271205cc Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Thu, 28 May 2026 22:49:32 +0200 Subject: [PATCH 21/29] Implement dc-load inactivity display --- .../{lab-ble-proxy-owon.h => lab-ble-proxy.h} | 37 ++++++++++++------- esphome/lab-ble-proxy.yaml | 12 +++++- 2 files changed, 35 insertions(+), 14 deletions(-) rename esphome/{lab-ble-proxy-owon.h => lab-ble-proxy.h} (94%) diff --git a/esphome/lab-ble-proxy-owon.h b/esphome/lab-ble-proxy.h similarity index 94% rename from esphome/lab-ble-proxy-owon.h rename to esphome/lab-ble-proxy.h index ad36bca..29e0460 100644 --- a/esphome/lab-ble-proxy-owon.h +++ b/esphome/lab-ble-proxy.h @@ -431,10 +431,22 @@ class Meter { } else { // --- PAGE 2: Atorch DL24 DC load --- it.fill(bg); - const Color panel(18, 24, 32); + const Color panel_active(18, 24, 32); + const Color panel_inactive(12, 12, 12); const Color panel_dim(10, 14, 20); - const Color border(55, 70, 86); + const Color border_active(55, 70, 86); + const Color border_inactive(42, 42, 42); const Color white(245, 245, 245); + const Color dim_value(110, 110, 110); + const Color dim_accent(65, 65, 65); + const Color panel = atorch_connected ? panel_active : panel_inactive; + const Color border = atorch_connected ? border_active : border_inactive; + const Color value_color = atorch_connected ? white : dim_value; + const Color voltage_color = atorch_connected ? cyan : dim_accent; + const Color current_color = atorch_connected ? orange : dim_accent; + const Color power_color = atorch_connected ? yellow : dim_accent; + const Color temp_color = atorch_connected ? magenta : dim_accent; + const Color header_color = atorch_connected ? cyan : dim_value; char voltage_text[24]; char current_text[24]; @@ -447,25 +459,25 @@ class Meter { this->format_metric_(capacity_text, sizeof(capacity_text), atorch_capacity, "Ah", 3); this->format_metric_(temperature_text, sizeof(temperature_text), atorch_temperature, "°C", 1); - it.filled_rectangle(0, 0, 320, 30, panel_dim); - it.print(10, 7, font, cyan, esphome::display::TextAlign::TOP_LEFT, "ATORCH DL24"); + it.filled_rectangle(0, 0, 320, 30, atorch_connected ? panel_dim : panel_inactive); + it.print(10, 7, font, header_color, esphome::display::TextAlign::TOP_LEFT, "ATORCH DL24"); if (atorch_connected) { this->draw_icon_(it, 152, 7, 16, 16, BLE_BMP, blue); } it.print(310, 7, font, inactive, esphome::display::TextAlign::TOP_RIGHT, "DC LOAD"); - this->draw_metric_card_(it, font, value_font, 10, 42, 145, 76, "VOLTAGE", voltage_text, cyan, panel, border); - this->draw_metric_card_(it, font, value_font, 165, 42, 145, 76, "CURRENT", current_text, orange, panel, border); - this->draw_metric_card_(it, font, value_font, 10, 128, 145, 76, "POWER", power_text, yellow, panel, border); - this->draw_metric_card_(it, font, value_font, 165, 128, 145, 76, "TEMP", temperature_text, magenta, panel, border); + this->draw_metric_card_(it, font, value_font, 10, 42, 145, 76, "VOLTAGE", voltage_text, voltage_color, panel, border, value_color); + this->draw_metric_card_(it, font, value_font, 165, 42, 145, 76, "CURRENT", current_text, current_color, panel, border, value_color); + this->draw_metric_card_(it, font, value_font, 10, 128, 145, 76, "POWER", power_text, power_color, panel, border, value_color); + this->draw_metric_card_(it, font, value_font, 165, 128, 145, 76, "TEMP", temperature_text, temp_color, panel, border, value_color); - it.filled_rectangle(10, 212, 300, 22, panel_dim); + it.filled_rectangle(10, 212, 300, 22, atorch_connected ? panel_dim : panel_inactive); it.filled_rectangle(10, 212, 300, 1, border); it.filled_rectangle(10, 233, 300, 1, border); it.filled_rectangle(10, 212, 1, 22, border); it.filled_rectangle(309, 212, 1, 22, border); it.print(24, 216, font, inactive, esphome::display::TextAlign::TOP_LEFT, "CAPACITY"); - it.print(306, 212, value_font, white, esphome::display::TextAlign::TOP_RIGHT, capacity_text); + it.print(306, 212, value_font, value_color, esphome::display::TextAlign::TOP_RIGHT, capacity_text); } } @@ -608,9 +620,8 @@ class Meter { void draw_metric_card_(Display &it, esphome::display::BaseFont *label_font, esphome::display::BaseFont *value_font, int x, int y, int w, int h, const char *title, const char *value, - Color accent, Color fill, Color border) { + Color accent, Color fill, Color border, Color value_color) { const Color bg(0, 0, 0); - const Color fg(235, 235, 235); const Color inactive(90, 100, 110); it.filled_rectangle(x, y, w, h, fill); it.filled_rectangle(x, y, w, 2, accent); @@ -618,7 +629,7 @@ class Meter { it.filled_rectangle(x, y, 1, h, border); it.filled_rectangle(x + w - 1, y, 1, h, border); it.print(x + 10, y + 9, label_font, inactive, esphome::display::TextAlign::TOP_LEFT, title); - it.print(x + w / 2, y + 43, value_font, fg, esphome::display::TextAlign::CENTER, value); + it.print(x + w / 2, y + 43, value_font, value_color, esphome::display::TextAlign::CENTER, value); it.filled_rectangle(x + 10, y + h - 11, w - 20, 3, bg); it.filled_rectangle(x + 10, y + h - 11, w - 20, 1, accent); } diff --git a/esphome/lab-ble-proxy.yaml b/esphome/lab-ble-proxy.yaml index 5833167..40939f4 100644 --- a/esphome/lab-ble-proxy.yaml +++ b/esphome/lab-ble-proxy.yaml @@ -18,7 +18,7 @@ esphome: comment: ${device_description} min_version: 2024.6.0 includes: - - lab-ble-proxy-owon.h + - lab-ble-proxy.h on_boot: priority: 850 then: @@ -105,10 +105,16 @@ ble_client: then: - lambda: |- owon_meter.on_connect(); + id(display_page) = 0; + id(lcd).update(); on_disconnect: then: - lambda: |- owon_meter.on_disconnect(); + if (id(display_page) == 0 && id(atorch_connected)) { + id(display_page) = 1; + } + id(lcd).update(); - mac_address: ${dl24_mac_address} id: atorch_ble_client @@ -116,11 +122,15 @@ ble_client: then: - lambda: |- id(atorch_connected) = true; + id(display_page) = 1; id(lcd).update(); on_disconnect: then: - lambda: |- id(atorch_connected) = false; + if (id(display_page) == 1 && owon_meter.connected) { + id(display_page) = 0; + } id(lcd).update(); atorch_dl24: From 5b287303f1a193d7b214e8b40ebf4ffa1f833a60 Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Thu, 28 May 2026 22:58:15 +0200 Subject: [PATCH 22/29] Add backlight idle script --- esphome/lab-ble-proxy.yaml | 51 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/esphome/lab-ble-proxy.yaml b/esphome/lab-ble-proxy.yaml index 40939f4..22d203e 100644 --- a/esphome/lab-ble-proxy.yaml +++ b/esphome/lab-ble-proxy.yaml @@ -79,6 +79,34 @@ globals: 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: + lambda: |- + return !owon_meter.connected && !id(atorch_connected); + then: + - light.turn_on: + id: backlight + brightness: 50% + - delay: 3min + - if: + condition: + lambda: |- + return !owon_meter.connected && !id(atorch_connected); + then: + - light.turn_off: backlight + interval: - interval: 10s then: @@ -103,6 +131,7 @@ ble_client: id: owon_ble_client on_connect: then: + - script.execute: wake_backlight - lambda: |- owon_meter.on_connect(); id(display_page) = 0; @@ -115,11 +144,18 @@ ble_client: id(display_page) = 1; } id(lcd).update(); + - if: + condition: + 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 - lambda: |- id(atorch_connected) = true; id(display_page) = 1; @@ -132,6 +168,12 @@ ble_client: id(display_page) = 0; } id(lcd).update(); + - if: + condition: + lambda: |- + return !owon_meter.connected && !id(atorch_connected); + then: + - script.execute: backlight_idle atorch_dl24: - id: atorch0 @@ -291,6 +333,15 @@ touchscreen: - platform: ft63x6 id: touch display: lcd + on_touch: + then: + - script.execute: wake_backlight + - if: + condition: + lambda: |- + return !owon_meter.connected && !id(atorch_connected); + then: + - script.execute: backlight_idle binary_sensor: - platform: touchscreen From d7337fd6345bce258c30dba4bbb93514bcef3f9f Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Sat, 30 May 2026 08:22:32 +0200 Subject: [PATCH 23/29] Set (somewhat) appropiate mdi icons for OWON HA sensors. --- esphome/lab-ble-proxy.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/esphome/lab-ble-proxy.yaml b/esphome/lab-ble-proxy.yaml index 22d203e..b364dce 100644 --- a/esphome/lab-ble-proxy.yaml +++ b/esphome/lab-ble-proxy.yaml @@ -421,19 +421,23 @@ binary_sensor: - platform: template name: "${friendly_name} OWON Connected" + icon: "mdi:bluetooth-connect" 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); @@ -480,15 +484,18 @@ text_sensor: - platform: template name: "${friendly_name} OWON Reading" update_interval: 1s + icon: "mdi:gauge" lambda: |- return owon_meter.reading_text(); - platform: template name: "${friendly_name} OWON Unit" update_interval: 1s + icon: "mdi:ruler" lambda: |- return std::string(owon_meter.scale()) + owon_meter.unit(); - platform: template name: "${friendly_name} OWON Mode" + icon: "mdi:knob" update_interval: 1s lambda: |- return owon_meter.mode_text(); From 31848bf26cb0a477a7ca55064f8eae83f946a24c Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Sat, 30 May 2026 16:49:57 +0200 Subject: [PATCH 24/29] Add energy monitor using riemann sum algorithm. --- esphome/lab-ble-proxy.h | 10 +++++++--- esphome/lab-ble-proxy.yaml | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/esphome/lab-ble-proxy.h b/esphome/lab-ble-proxy.h index 29e0460..da40bac 100644 --- a/esphome/lab-ble-proxy.h +++ b/esphome/lab-ble-proxy.h @@ -365,7 +365,7 @@ class Meter { esphome::display::BaseFont *value_font, int display_page = 0, bool atorch_connected = false, float atorch_voltage = NAN, float atorch_current = NAN, float atorch_power = NAN, - float atorch_capacity = NAN, float atorch_temperature = NAN) { + float atorch_capacity = NAN, float atorch_energy = NAN, float atorch_temperature = NAN) { const Color bg(0, 0, 0); const Color fg(210, 210, 210); // Chosen to map to a neutral dark gray in the RGB332 8-bit display palette. @@ -452,11 +452,13 @@ class Meter { char current_text[24]; char power_text[24]; char capacity_text[24]; + char energy_text[24]; char temperature_text[24]; this->format_metric_(voltage_text, sizeof(voltage_text), atorch_voltage, "V", 2); this->format_metric_(current_text, sizeof(current_text), atorch_current, "A", 3); this->format_metric_(power_text, sizeof(power_text), atorch_power, "W", 2); this->format_metric_(capacity_text, sizeof(capacity_text), atorch_capacity, "Ah", 3); + this->format_metric_(energy_text, sizeof(energy_text), atorch_energy, "Wh", 3); this->format_metric_(temperature_text, sizeof(temperature_text), atorch_temperature, "°C", 1); it.filled_rectangle(0, 0, 320, 30, atorch_connected ? panel_dim : panel_inactive); @@ -476,8 +478,10 @@ class Meter { it.filled_rectangle(10, 233, 300, 1, border); it.filled_rectangle(10, 212, 1, 22, border); it.filled_rectangle(309, 212, 1, 22, border); - it.print(24, 216, font, inactive, esphome::display::TextAlign::TOP_LEFT, "CAPACITY"); - it.print(306, 212, value_font, value_color, esphome::display::TextAlign::TOP_RIGHT, capacity_text); + it.print(20, 216, font, inactive, esphome::display::TextAlign::TOP_LEFT, "CAP"); + it.print(64, 216, font, value_color, esphome::display::TextAlign::TOP_LEFT, capacity_text); + it.print(170, 216, font, inactive, esphome::display::TextAlign::TOP_LEFT, "ENERGY"); + it.print(306, 216, font, value_color, esphome::display::TextAlign::TOP_RIGHT, energy_text); } } diff --git a/esphome/lab-ble-proxy.yaml b/esphome/lab-ble-proxy.yaml index b364dce..4934182 100644 --- a/esphome/lab-ble-proxy.yaml +++ b/esphome/lab-ble-proxy.yaml @@ -326,6 +326,7 @@ display: 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 ); @@ -480,6 +481,19 @@ sensor: name: "${friendly_name} Atorch Dim Backlight" id: atorch_dim_backlight + - 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" From 2a1902ccf1d3c8afc088c1599dbef917954271d4 Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Sat, 30 May 2026 17:06:31 +0200 Subject: [PATCH 25/29] Add reset all / reset energy function to also clear the local meter --- esphome/lab-ble-proxy.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/lab-ble-proxy.yaml b/esphome/lab-ble-proxy.yaml index 4934182..2c56ae0 100644 --- a/esphome/lab-ble-proxy.yaml +++ b/esphome/lab-ble-proxy.yaml @@ -519,12 +519,16 @@ button: 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: + - sensor.integration.reset: atorch_energy_calculated usb_plus: name: "${friendly_name} Atorch Plus" usb_minus: From c21f2a5c7b7c58a5d08f5a844a32e13dad43ca75 Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Sat, 30 May 2026 17:49:56 +0200 Subject: [PATCH 26/29] Display aestethics: taller 7-segments, repositioned minus, mode and icons --- esphome/lab-ble-proxy.h | 63 ++++++++++++++++++++++++++++++++--------- 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/esphome/lab-ble-proxy.h b/esphome/lab-ble-proxy.h index da40bac..74e40fa 100644 --- a/esphome/lab-ble-proxy.h +++ b/esphome/lab-ble-proxy.h @@ -148,6 +148,44 @@ static const uint8_t BLE_BMP[32] = { 0b00000001, 0b10000000, }; +static const uint8_t HOLD_BMP[32] = { + 0b01111111, 0b11111110, + 0b11111111, 0b11111111, + 0b11100011, 0b11000111, + 0b11100011, 0b11000111, + 0b11100011, 0b11000111, + 0b11100011, 0b11000111, + 0b11100011, 0b11000111, + 0b11100000, 0b00000111, + 0b11100000, 0b00000111, + 0b11100011, 0b11000111, + 0b11100011, 0b11000111, + 0b11100011, 0b11000111, + 0b11100011, 0b11000111, + 0b11100011, 0b11000111, + 0b11111111, 0b11111111, + 0b01111111, 0b11111110, +}; + +static const uint8_t REL_BMP[32] = { + 0b00000001, 0b00000000, + 0b00000001, 0b00000000, + 0b00000011, 0b10000000, + 0b00000011, 0b10000000, + 0b00000110, 0b11000000, + 0b00000110, 0b11000000, + 0b00001100, 0b01100000, + 0b00001100, 0b01100000, + 0b00011000, 0b00110000, + 0b00011000, 0b00110000, + 0b00110000, 0b00011000, + 0b00110000, 0b00011000, + 0b01100000, 0b00001100, + 0b01100000, 0b00001100, + 0b11111111, 0b11111110, + 0b11111111, 0b11111110, +}; + static const uint8_t DIODE_BMP[32] = { 0b00001000, 0b00011000, 0b00001100, 0b00011000, @@ -387,11 +425,13 @@ class Meter { this->label_(it, font, 86, 8, "AUTO", status_active && this->auto_range() ? fg : inactive); this->label_(it, font, 138, 8, "MAX", status_active && this->max_mode() ? red : inactive); this->label_(it, font, 178, 8, "MIN", status_active && this->min_mode() ? green : inactive); - this->label_(it, font, 218, 8, "HOLD", status_active && this->hold() ? blue : inactive); - this->label_(it, font, 270, 8, "REL", status_active && this->relative() ? Color(128, 128, 0) : inactive); + this->draw_icon_(it, 218, 8, 16, 16, HOLD_BMP, status_active && this->hold() ? blue : inactive); + this->draw_icon_(it, 244, 8, 16, 16, REL_BMP, status_active && this->relative() ? Color(128, 128, 0) : inactive); + this->draw_icon_(it, 270, 8, 16, 16, DIODE_BMP, status_active && this->diode() ? magenta : inactive); + this->draw_icon_(it, 296, 8, 16, 16, BUZZ_BMP, status_active && this->continuity() ? orange : inactive); - this->label_(it, font, 8, 66, "DC", status_active && this->dc() ? cyan : inactive); - this->label_(it, font, 8, 102, "AC", status_active && this->ac() ? magenta : inactive); + this->label_(it, font, 8, 104, "DC", status_active && this->dc() ? cyan : inactive); + this->label_(it, font, 8, 124, "AC", status_active && this->ac() ? magenta : inactive); if (!this->connected) { this->draw_digits_(it, "----", false, inactive); @@ -414,14 +454,11 @@ class Meter { if (status_active) { std::string unit_line = std::string(this->scale()) + this->unit(); - it.print(270, 140, font, yellow, esphome::display::TextAlign::CENTER, unit_line.c_str()); + it.print(316, 141, font, yellow, esphome::display::TextAlign::BOTTOM_RIGHT, unit_line.c_str()); } bool bargraph_active = status_active && !this->overload; this->draw_bargraph_(it, bargraph_active ? this->digits_from_buffer_() : 0, bargraph_active); - this->draw_icon_(it, 300, 148, 16, 16, DIODE_BMP, status_active && this->diode() ? magenta : inactive); - this->draw_icon_(it, 300, 174, 16, 16, BUZZ_BMP, status_active && this->continuity() ? orange : inactive); - it.filled_rectangle(34, 212, 40, 24, this->write_available ? fg : inactive); it.filled_rectangle(108, 212, 100, 24, this->write_available ? fg : inactive); it.filled_rectangle(242, 212, 40, 24, this->write_available ? fg : inactive); @@ -643,11 +680,11 @@ class Meter { } void draw_digits_(Display &it, const char *text, bool negative, Color color) { - if (negative) this->draw_segment_(it, 8, 88, 26, 9, true, color); + if (negative) this->draw_segment_(it, 8, 83, 26, 9, true, color); constexpr int digit_x = 40; constexpr int digit_y = 35; constexpr int digit_w = 50; - constexpr int digit_h = 88; + constexpr int digit_h = 106; constexpr int digit_distance = 64; for (int i = 0; i < 4; i++) { this->draw_seven_segment_(it, digit_x + i * digit_distance, digit_y, digit_w, digit_h, text[i], color); @@ -656,9 +693,9 @@ class Meter { void draw_decimal_points_(Display &it, Color color) { uint8_t p = this->value_[REGPOINT]; - if ((p & FLAGPOINT1) == FLAGPOINT1) it.filled_rectangle(95, 117, 8, 10, color); - if ((p & FLAGPOINT2) == FLAGPOINT2) it.filled_rectangle(159, 117, 8, 10, color); - if ((p & FLAGPOINT3) == FLAGPOINT3) it.filled_rectangle(223, 117, 8, 10, color); + if ((p & FLAGPOINT1) == FLAGPOINT1) it.filled_rectangle(95, 135, 8, 10, color); + if ((p & FLAGPOINT2) == FLAGPOINT2) it.filled_rectangle(159, 135, 8, 10, color); + if ((p & FLAGPOINT3) == FLAGPOINT3) it.filled_rectangle(223, 135, 8, 10, color); } void draw_segment_(Display &it, int x, int y, int w, int h, bool horizontal, Color color) { From 2c16ba167941f77d77a694814449362a961ee298 Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Sat, 30 May 2026 18:49:16 +0200 Subject: [PATCH 27/29] Separated long-press buttons into own HA entityies to make them usable --- esphome/lab-ble-proxy.yaml | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/esphome/lab-ble-proxy.yaml b/esphome/lab-ble-proxy.yaml index 2c56ae0..30bfc58 100644 --- a/esphome/lab-ble-proxy.yaml +++ b/esphome/lab-ble-proxy.yaml @@ -558,7 +558,7 @@ button: value: !lambda "return std::vector({2, 0x01});" - platform: template - name: "OWON HOLD | LIGHT" + name: "OWON HOLD " id: owon_btn_hold on_press: - ble_client.ble_write: @@ -568,7 +568,17 @@ button: value: !lambda "return std::vector({3, 0x01});" - platform: template - name: "OWON RELATIVE | BT" + 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" + value: !lambda "return std::vector({3, 0x00});" + + - platform: template + name: "OWON RELATIVE" id: owon_btn_rel on_press: - ble_client.ble_write: @@ -577,6 +587,16 @@ button: characteristic_uuid: "0000fff3-0000-1000-8000-00805f9b34fb" 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" + value: !lambda "return std::vector({4, 0x00});" + - platform: template name: "OWON HZ | DUTY" id: owon_btn_hz From 4e5df1ad0cfdfb04fb1c3aff99693d76e5f64ff5 Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Sat, 30 May 2026 19:00:18 +0200 Subject: [PATCH 28/29] Updated file comments --- esphome/lab-ble-proxy.h | 3 +++ esphome/lab-ble-proxy.yaml | 8 ++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/esphome/lab-ble-proxy.h b/esphome/lab-ble-proxy.h index 74e40fa..1486cd6 100644 --- a/esphome/lab-ble-proxy.h +++ b/esphome/lab-ble-proxy.h @@ -2,7 +2,10 @@ * ESPHome helper for OWON B35T/B35T+ BLE meter on M5Stack Core 2. * Parser is based on the standalone Arduino sketch by Reaper7 * (Beerware license, Revision 42) and Dean Cording's owonb35 notes. + * Rendering code and some functionality modified to suppport the + * Atorch DL24 dc load. */ + #pragma once #include diff --git a/esphome/lab-ble-proxy.yaml b/esphome/lab-ble-proxy.yaml index 30bfc58..6cb8107 100644 --- a/esphome/lab-ble-proxy.yaml +++ b/esphome/lab-ble-proxy.yaml @@ -1,7 +1,11 @@ +# 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), Qwen3.6) has been used to adopt the Arduino sketch to ESPHome. +# 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. +# Integrated Atorch BLE proxy functionality from https://github.com/syssi/esphome-atorch-dl24 by syssi. substitutions: name: "lab-ble-proxy" From cecec14be109062b2f10ef8935d88d7390fe6888 Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Sat, 30 May 2026 19:12:03 +0200 Subject: [PATCH 29/29] Added usage info. --- esphome/lab-ble-proxy.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/lab-ble-proxy.yaml b/esphome/lab-ble-proxy.yaml index 6cb8107..c0a4f80 100644 --- a/esphome/lab-ble-proxy.yaml +++ b/esphome/lab-ble-proxy.yaml @@ -6,6 +6,9 @@ # 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"