From 7a778f3f330ca607f2772501eac82a043d0346d3 Mon Sep 17 00:00:00 2001 From: "I. Tomita" Date: Thu, 21 Apr 2022 01:11:25 +0300 Subject: [PATCH] Add support for BL0939 (Sonoff Dual R3 V2 powermeter) (#3300) --- CODEOWNERS | 1 + esphome/components/bl0939/__init__.py | 1 + esphome/components/bl0939/bl0939.cpp | 144 ++++++++++++++++++++++++++ esphome/components/bl0939/bl0939.h | 107 +++++++++++++++++++ esphome/components/bl0939/sensor.py | 123 ++++++++++++++++++++++ tests/test3.yaml | 24 +++++ 6 files changed, 400 insertions(+) create mode 100644 esphome/components/bl0939/__init__.py create mode 100644 esphome/components/bl0939/bl0939.cpp create mode 100644 esphome/components/bl0939/bl0939.h create mode 100644 esphome/components/bl0939/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index c9df669f03..7fd049f46e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -31,6 +31,7 @@ esphome/components/bang_bang/* @OttoWinter esphome/components/bedjet/* @jhansche esphome/components/bh1750/* @OttoWinter esphome/components/binary_sensor/* @esphome/core +esphome/components/bl0939/* @ziceva esphome/components/bl0940/* @tobias- esphome/components/ble_client/* @buxtronix esphome/components/bme680_bsec/* @trvrnrth diff --git a/esphome/components/bl0939/__init__.py b/esphome/components/bl0939/__init__.py new file mode 100644 index 0000000000..9bd4598dd2 --- /dev/null +++ b/esphome/components/bl0939/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@ziceva"] diff --git a/esphome/components/bl0939/bl0939.cpp b/esphome/components/bl0939/bl0939.cpp new file mode 100644 index 0000000000..61d7835a4b --- /dev/null +++ b/esphome/components/bl0939/bl0939.cpp @@ -0,0 +1,144 @@ +#include "bl0939.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace bl0939 { + +static const char *const TAG = "bl0939"; + +// https://www.belling.com.cn/media/file_object/bel_product/BL0939/datasheet/BL0939_V1.2_cn.pdf +// (unfortunatelly chinese, but the protocol can be understood with some translation tool) +static const uint8_t BL0939_READ_COMMAND = 0x55; // 0x5{A4,A3,A2,A1} +static const uint8_t BL0939_FULL_PACKET = 0xAA; +static const uint8_t BL0939_PACKET_HEADER = 0x55; + +static const uint8_t BL0939_WRITE_COMMAND = 0xA5; // 0xA{A4,A3,A2,A1} +static const uint8_t BL0939_REG_IA_FAST_RMS_CTRL = 0x10; +static const uint8_t BL0939_REG_IB_FAST_RMS_CTRL = 0x1E; +static const uint8_t BL0939_REG_MODE = 0x18; +static const uint8_t BL0939_REG_SOFT_RESET = 0x19; +static const uint8_t BL0939_REG_USR_WRPROT = 0x1A; +static const uint8_t BL0939_REG_TPS_CTRL = 0x1B; + +const uint8_t BL0939_INIT[6][6] = { + // Reset to default + {BL0939_WRITE_COMMAND, BL0939_REG_SOFT_RESET, 0x5A, 0x5A, 0x5A, 0x33}, + // Enable User Operation Write + {BL0939_WRITE_COMMAND, BL0939_REG_USR_WRPROT, 0x55, 0x00, 0x00, 0xEB}, + // 0x0100 = CF_UNABLE energy pulse, AC_FREQ_SEL 50Hz, RMS_UPDATE_SEL 800mS + {BL0939_WRITE_COMMAND, BL0939_REG_MODE, 0x00, 0x10, 0x00, 0x32}, + // 0x47FF = Over-current and leakage alarm on, Automatic temperature measurement, Interval 100mS + {BL0939_WRITE_COMMAND, BL0939_REG_TPS_CTRL, 0xFF, 0x47, 0x00, 0xF9}, + // 0x181C = Half cycle, Fast RMS threshold 6172 + {BL0939_WRITE_COMMAND, BL0939_REG_IA_FAST_RMS_CTRL, 0x1C, 0x18, 0x00, 0x16}, + // 0x181C = Half cycle, Fast RMS threshold 6172 + {BL0939_WRITE_COMMAND, BL0939_REG_IB_FAST_RMS_CTRL, 0x1C, 0x18, 0x00, 0x08}}; + +void BL0939::loop() { + DataPacket buffer; + if (!this->available()) { + return; + } + if (read_array((uint8_t *) &buffer, sizeof(buffer))) { + if (validate_checksum(&buffer)) { + received_package_(&buffer); + } + } else { + ESP_LOGW(TAG, "Junk on wire. Throwing away partial message"); + while (read() >= 0) + ; + } +} + +bool BL0939::validate_checksum(const DataPacket *data) { + uint8_t checksum = BL0939_READ_COMMAND; + // Whole package but checksum + for (uint32_t i = 0; i < sizeof(data->raw) - 1; i++) { + checksum += data->raw[i]; + } + checksum ^= 0xFF; + if (checksum != data->checksum) { + ESP_LOGW(TAG, "BL0939 invalid checksum! 0x%02X != 0x%02X", checksum, data->checksum); + } + return checksum == data->checksum; +} + +void BL0939::update() { + this->flush(); + this->write_byte(BL0939_READ_COMMAND); + this->write_byte(BL0939_FULL_PACKET); +} + +void BL0939::setup() { + for (auto *i : BL0939_INIT) { + this->write_array(i, 6); + delay(1); + } + this->flush(); +} + +void BL0939::received_package_(const DataPacket *data) const { + // Bad header + if (data->frame_header != BL0939_PACKET_HEADER) { + ESP_LOGI("bl0939", "Invalid data. Header mismatch: %d", data->frame_header); + return; + } + + float v_rms = (float) to_uint32_t(data->v_rms) / voltage_reference_; + float ia_rms = (float) to_uint32_t(data->ia_rms) / current_reference_; + float ib_rms = (float) to_uint32_t(data->ib_rms) / current_reference_; + float a_watt = (float) to_int32_t(data->a_watt) / power_reference_; + float b_watt = (float) to_int32_t(data->b_watt) / power_reference_; + int32_t cfa_cnt = to_int32_t(data->cfa_cnt); + int32_t cfb_cnt = to_int32_t(data->cfb_cnt); + float a_energy_consumption = (float) cfa_cnt / energy_reference_; + float b_energy_consumption = (float) cfb_cnt / energy_reference_; + float total_energy_consumption = a_energy_consumption + b_energy_consumption; + + if (voltage_sensor_ != nullptr) { + voltage_sensor_->publish_state(v_rms); + } + if (current_sensor_1_ != nullptr) { + current_sensor_1_->publish_state(ia_rms); + } + if (current_sensor_2_ != nullptr) { + current_sensor_2_->publish_state(ib_rms); + } + if (power_sensor_1_ != nullptr) { + power_sensor_1_->publish_state(a_watt); + } + if (power_sensor_2_ != nullptr) { + power_sensor_2_->publish_state(b_watt); + } + if (energy_sensor_1_ != nullptr) { + energy_sensor_1_->publish_state(a_energy_consumption); + } + if (energy_sensor_2_ != nullptr) { + energy_sensor_2_->publish_state(b_energy_consumption); + } + if (energy_sensor_sum_ != nullptr) { + energy_sensor_sum_->publish_state(total_energy_consumption); + } + + ESP_LOGV("bl0939", "BL0939: U %fV, I1 %fA, I2 %fA, P1 %fW, P2 %fW, CntA %d, CntB %d, ∫P1 %fkWh, ∫P2 %fkWh", v_rms, + ia_rms, ib_rms, a_watt, b_watt, cfa_cnt, cfb_cnt, a_energy_consumption, b_energy_consumption); +} + +void BL0939::dump_config() { // NOLINT(readability-function-cognitive-complexity) + ESP_LOGCONFIG(TAG, "BL0939:"); + LOG_SENSOR("", "Voltage", this->voltage_sensor_); + LOG_SENSOR("", "Current 1", this->current_sensor_1_); + LOG_SENSOR("", "Current 2", this->current_sensor_2_); + LOG_SENSOR("", "Power 1", this->power_sensor_1_); + LOG_SENSOR("", "Power 2", this->power_sensor_2_); + LOG_SENSOR("", "Energy 1", this->energy_sensor_1_); + LOG_SENSOR("", "Energy 2", this->energy_sensor_2_); + LOG_SENSOR("", "Energy sum", this->energy_sensor_sum_); +} + +uint32_t BL0939::to_uint32_t(ube24_t input) { return input.h << 16 | input.m << 8 | input.l; } + +int32_t BL0939::to_int32_t(sbe24_t input) { return input.h << 16 | input.m << 8 | input.l; } + +} // namespace bl0939 +} // namespace esphome diff --git a/esphome/components/bl0939/bl0939.h b/esphome/components/bl0939/bl0939.h new file mode 100644 index 0000000000..5221ae26e7 --- /dev/null +++ b/esphome/components/bl0939/bl0939.h @@ -0,0 +1,107 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/uart/uart.h" +#include "esphome/components/sensor/sensor.h" + +namespace esphome { +namespace bl0939 { + +// https://datasheet.lcsc.com/lcsc/2108071830_BL-Shanghai-Belling-BL0939_C2841044.pdf +// (unfortunatelly chinese, but the formulas can be easily understood) +// Sonoff Dual R3 V2 has the exact same resistor values for the current shunts (RL=1miliOhm) +// and for the voltage divider (R1=0.51kOhm, R2=5*390kOhm) +// as in the manufacturer's reference circuit, so the same formulas were used here (Vref=1.218V) +static const float BL0939_IREF = 324004 * 1 / 1.218; +static const float BL0939_UREF = 79931 * 0.51 * 1000 / (1.218 * (5 * 390 + 0.51)); +static const float BL0939_PREF = 4046 * 1 * 0.51 * 1000 / (1.218 * 1.218 * (5 * 390 + 0.51)); +static const float BL0939_EREF = 3.6e6 * 4046 * 1 * 0.51 * 1000 / (1638.4 * 256 * 1.218 * 1.218 * (5 * 390 + 0.51)); + +struct ube24_t { // NOLINT(readability-identifier-naming,altera-struct-pack-align) + uint8_t l; + uint8_t m; + uint8_t h; +} __attribute__((packed)); + +struct ube16_t { // NOLINT(readability-identifier-naming,altera-struct-pack-align) + uint8_t l; + uint8_t h; +} __attribute__((packed)); + +struct sbe24_t { // NOLINT(readability-identifier-naming,altera-struct-pack-align) + uint8_t l; + uint8_t m; + int8_t h; +} __attribute__((packed)); + +// Caveat: All these values are big endian (low - middle - high) + +union DataPacket { // NOLINT(altera-struct-pack-align) + uint8_t raw[35]; + struct { + uint8_t frame_header; // 0x55 according to docs + ube24_t ia_fast_rms; + ube24_t ia_rms; + ube24_t ib_rms; + ube24_t v_rms; + ube24_t ib_fast_rms; + sbe24_t a_watt; + sbe24_t b_watt; + sbe24_t cfa_cnt; + sbe24_t cfb_cnt; + ube16_t tps1; + uint8_t RESERVED1; // value of 0x00 + ube16_t tps2; + uint8_t RESERVED2; // value of 0x00 + uint8_t checksum; // checksum + }; +} __attribute__((packed)); + +class BL0939 : public PollingComponent, public uart::UARTDevice { + public: + void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } + void set_current_sensor_1(sensor::Sensor *current_sensor_1) { current_sensor_1_ = current_sensor_1; } + void set_current_sensor_2(sensor::Sensor *current_sensor_2) { current_sensor_2_ = current_sensor_2; } + void set_power_sensor_1(sensor::Sensor *power_sensor_1) { power_sensor_1_ = power_sensor_1; } + void set_power_sensor_2(sensor::Sensor *power_sensor_2) { power_sensor_2_ = power_sensor_2; } + void set_energy_sensor_1(sensor::Sensor *energy_sensor_1) { energy_sensor_1_ = energy_sensor_1; } + void set_energy_sensor_2(sensor::Sensor *energy_sensor_2) { energy_sensor_2_ = energy_sensor_2; } + void set_energy_sensor_sum(sensor::Sensor *energy_sensor_sum) { energy_sensor_sum_ = energy_sensor_sum; } + + void loop() override; + + void update() override; + void setup() override; + void dump_config() override; + + protected: + sensor::Sensor *voltage_sensor_; + sensor::Sensor *current_sensor_1_; + sensor::Sensor *current_sensor_2_; + // NB This may be negative as the circuits is seemingly able to measure + // power in both directions + sensor::Sensor *power_sensor_1_; + sensor::Sensor *power_sensor_2_; + sensor::Sensor *energy_sensor_1_; + sensor::Sensor *energy_sensor_2_; + sensor::Sensor *energy_sensor_sum_; + + // Divide by this to turn into Watt + float power_reference_ = BL0939_PREF; + // Divide by this to turn into Volt + float voltage_reference_ = BL0939_UREF; + // Divide by this to turn into Ampere + float current_reference_ = BL0939_IREF; + // Divide by this to turn into kWh + float energy_reference_ = BL0939_EREF; + + static uint32_t to_uint32_t(ube24_t input); + + static int32_t to_int32_t(sbe24_t input); + + static bool validate_checksum(const DataPacket *data); + + void received_package_(const DataPacket *data) const; +}; +} // namespace bl0939 +} // namespace esphome diff --git a/esphome/components/bl0939/sensor.py b/esphome/components/bl0939/sensor.py new file mode 100644 index 0000000000..bcc72ad61a --- /dev/null +++ b/esphome/components/bl0939/sensor.py @@ -0,0 +1,123 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import sensor, uart +from esphome.const import ( + CONF_ID, + CONF_VOLTAGE, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + UNIT_AMPERE, + UNIT_KILOWATT_HOURS, + UNIT_VOLT, + UNIT_WATT, +) + +DEPENDENCIES = ["uart"] + +CONF_CURRENT_1 = "current_1" +CONF_CURRENT_2 = "current_2" +CONF_ACTIVE_POWER_1 = "active_power_1" +CONF_ACTIVE_POWER_2 = "active_power_2" +CONF_ENERGY_1 = "energy_1" +CONF_ENERGY_2 = "energy_2" +CONF_ENERGY_TOTAL = "energy_total" + +bl0939_ns = cg.esphome_ns.namespace("bl0939") +BL0939 = bl0939_ns.class_("BL0939", cg.PollingComponent, uart.UARTDevice) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(BL0939), + cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_CURRENT_1): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_CURRENT_2): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_ACTIVE_POWER_1): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_ACTIVE_POWER_2): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_ENERGY_1): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + ), + cv.Optional(CONF_ENERGY_2): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + ), + cv.Optional(CONF_ENERGY_TOTAL): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(uart.UART_DEVICE_SCHEMA) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + + if CONF_VOLTAGE in config: + conf = config[CONF_VOLTAGE] + sens = await sensor.new_sensor(conf) + cg.add(var.set_voltage_sensor(sens)) + if CONF_CURRENT_1 in config: + conf = config[CONF_CURRENT_1] + sens = await sensor.new_sensor(conf) + cg.add(var.set_current_sensor_1(sens)) + if CONF_CURRENT_2 in config: + conf = config[CONF_CURRENT_2] + sens = await sensor.new_sensor(conf) + cg.add(var.set_current_sensor_2(sens)) + if CONF_ACTIVE_POWER_1 in config: + conf = config[CONF_ACTIVE_POWER_1] + sens = await sensor.new_sensor(conf) + cg.add(var.set_power_sensor_1(sens)) + if CONF_ACTIVE_POWER_2 in config: + conf = config[CONF_ACTIVE_POWER_2] + sens = await sensor.new_sensor(conf) + cg.add(var.set_power_sensor_2(sens)) + if CONF_ENERGY_1 in config: + conf = config[CONF_ENERGY_1] + sens = await sensor.new_sensor(conf) + cg.add(var.set_energy_sensor_1(sens)) + if CONF_ENERGY_2 in config: + conf = config[CONF_ENERGY_2] + sens = await sensor.new_sensor(conf) + cg.add(var.set_energy_sensor_2(sens)) + if CONF_ENERGY_TOTAL in config: + conf = config[CONF_ENERGY_TOTAL] + sens = await sensor.new_sensor(conf) + cg.add(var.set_energy_sensor_sum(sens)) diff --git a/tests/test3.yaml b/tests/test3.yaml index 58cb14740f..29a70d3cc3 100644 --- a/tests/test3.yaml +++ b/tests/test3.yaml @@ -256,6 +256,12 @@ uart: tx_pin: GPIO4 rx_pin: GPIO5 baud_rate: 38400 + - id: uart8 + tx_pin: GPIO4 + rx_pin: GPIO5 + baud_rate: 4800 + parity: NONE + stop_bits: 2 # Specifically added for testing debug with no options at all. debug: @@ -477,6 +483,24 @@ sensor: active_power_b: name: ADE7953 Active Power B id: ade7953_active_power_b + - platform: bl0939 + uart_id: uart8 + voltage: + name: 'BL0939 Voltage' + current_1: + name: 'BL0939 Current 1' + current_2: + name: 'BL0939 Current 2' + active_power_1: + name: 'BL0939 Active Power 1' + active_power_2: + name: 'BL0939 Active Power 2' + energy_1: + name: 'BL0939 Energy 1' + energy_2: + name: 'BL0939 Energy 2' + energy_total: + name: 'BL0939 Total energy' - platform: bl0940 uart_id: uart3 voltage: