diff --git a/CODEOWNERS b/CODEOWNERS index c3ca5410b1..7f2aef7be8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -28,6 +28,7 @@ esphome/components/b_parasite/* @rbaron esphome/components/ballu/* @bazuchan esphome/components/bang_bang/* @OttoWinter esphome/components/binary_sensor/* @esphome/core +esphome/components/bl0940/* @tobias- esphome/components/ble_client/* @buxtronix esphome/components/bme680_bsec/* @trvrnrth esphome/components/button/* @esphome/core diff --git a/esphome/components/bl0940/__init__.py b/esphome/components/bl0940/__init__.py new file mode 100644 index 0000000000..087626a4e7 --- /dev/null +++ b/esphome/components/bl0940/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@tobias-"] diff --git a/esphome/components/bl0940/bl0940.cpp b/esphome/components/bl0940/bl0940.cpp new file mode 100644 index 0000000000..19672e98d0 --- /dev/null +++ b/esphome/components/bl0940/bl0940.cpp @@ -0,0 +1,137 @@ +#include "bl0940.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace bl0940 { + +static const char *const TAG = "bl0940"; + +static const uint8_t BL0940_READ_COMMAND = 0x50; // 0x58 according to documentation +static const uint8_t BL0940_FULL_PACKET = 0xAA; +static const uint8_t BL0940_PACKET_HEADER = 0x55; // 0x58 according to documentation + +static const uint8_t BL0940_WRITE_COMMAND = 0xA0; // 0xA8 according to documentation +static const uint8_t BL0940_REG_I_FAST_RMS_CTRL = 0x10; +static const uint8_t BL0940_REG_MODE = 0x18; +static const uint8_t BL0940_REG_SOFT_RESET = 0x19; +static const uint8_t BL0940_REG_USR_WRPROT = 0x1A; +static const uint8_t BL0940_REG_TPS_CTRL = 0x1B; + +const uint8_t BL0940_INIT[5][6] = { + // Reset to default + {BL0940_WRITE_COMMAND, BL0940_REG_SOFT_RESET, 0x5A, 0x5A, 0x5A, 0x38}, + // Enable User Operation Write + {BL0940_WRITE_COMMAND, BL0940_REG_USR_WRPROT, 0x55, 0x00, 0x00, 0xF0}, + // 0x0100 = CF_UNABLE energy pulse, AC_FREQ_SEL 50Hz, RMS_UPDATE_SEL 800mS + {BL0940_WRITE_COMMAND, BL0940_REG_MODE, 0x00, 0x10, 0x00, 0x37}, + // 0x47FF = Over-current and leakage alarm on, Automatic temperature measurement, Interval 100mS + {BL0940_WRITE_COMMAND, BL0940_REG_TPS_CTRL, 0xFF, 0x47, 0x00, 0xFE}, + // 0x181C = Half cycle, Fast RMS threshold 6172 + {BL0940_WRITE_COMMAND, BL0940_REG_I_FAST_RMS_CTRL, 0x1C, 0x18, 0x00, 0x1B}}; + +void BL0940::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 BL0940::validate_checksum(const DataPacket *data) { + uint8_t checksum = BL0940_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, "BL0940 invalid checksum! 0x%02X != 0x%02X", checksum, data->checksum); + } + return checksum == data->checksum; +} + +void BL0940::update() { + this->flush(); + this->write_byte(BL0940_READ_COMMAND); + this->write_byte(BL0940_FULL_PACKET); +} + +void BL0940::setup() { + for (auto i : BL0940_INIT) { + this->write_array(i, 6); + delay(1); + } + this->flush(); +} + +float BL0940::update_temp_(sensor::Sensor *sensor, ube16_t temperature) const { + auto tb = (float) (temperature.h << 8 | temperature.l); + float converted_temp = ((float) 170 / 448) * (tb / 2 - 32) - 45; + if (sensor != nullptr) { + if (sensor->has_state() && std::abs(converted_temp - sensor->get_state()) > max_temperature_diff_) { + ESP_LOGD("bl0940", "Invalid temperature change. Sensor: '%s', Old temperature: %f, New temperature: %f", + sensor->get_name().c_str(), sensor->get_state(), converted_temp); + return 0.0f; + } + sensor->publish_state(converted_temp); + } + return converted_temp; +} + +void BL0940::received_package_(const DataPacket *data) const { + // Bad header + if (data->frame_header != BL0940_PACKET_HEADER) { + ESP_LOGI("bl0940", "Invalid data. Header mismatch: %d", data->frame_header); + return; + } + + float v_rms = (float) to_uint32_t(data->v_rms) / voltage_reference_; + float i_rms = (float) to_uint32_t(data->i_rms) / current_reference_; + float watt = (float) to_int32_t(data->watt) / power_reference_; + uint32_t cf_cnt = to_uint32_t(data->cf_cnt); + float total_energy_consumption = (float) cf_cnt / energy_reference_; + + float tps1 = update_temp_(internal_temperature_sensor_, data->tps1); + float tps2 = update_temp_(external_temperature_sensor_, data->tps2); + + if (voltage_sensor_ != nullptr) { + voltage_sensor_->publish_state(v_rms); + } + if (current_sensor_ != nullptr) { + current_sensor_->publish_state(i_rms); + } + if (power_sensor_ != nullptr) { + power_sensor_->publish_state(watt); + } + if (energy_sensor_ != nullptr) { + energy_sensor_->publish_state(total_energy_consumption); + } + + ESP_LOGV("bl0940", "BL0940: U %fV, I %fA, P %fW, Cnt %d, ∫P %fkWh, T1 %f°C, T2 %f°C", v_rms, i_rms, watt, cf_cnt, + total_energy_consumption, tps1, tps2); +} + +void BL0940::dump_config() { // NOLINT(readability-function-cognitive-complexity) + ESP_LOGCONFIG(TAG, "BL0940:"); + LOG_SENSOR("", "Voltage", this->voltage_sensor_); + LOG_SENSOR("", "Current", this->current_sensor_); + LOG_SENSOR("", "Power", this->power_sensor_); + LOG_SENSOR("", "Energy", this->energy_sensor_); + LOG_SENSOR("", "Internal temperature", this->internal_temperature_sensor_); + LOG_SENSOR("", "External temperature", this->external_temperature_sensor_); +} + +uint32_t BL0940::to_uint32_t(ube24_t input) { return input.h << 16 | input.m << 8 | input.l; } + +int32_t BL0940::to_int32_t(sbe24_t input) { return input.h << 16 | input.m << 8 | input.l; } + +} // namespace bl0940 +} // namespace esphome diff --git a/esphome/components/bl0940/bl0940.h b/esphome/components/bl0940/bl0940.h new file mode 100644 index 0000000000..49c8e50595 --- /dev/null +++ b/esphome/components/bl0940/bl0940.h @@ -0,0 +1,109 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/uart/uart.h" +#include "esphome/components/sensor/sensor.h" + +namespace esphome { +namespace bl0940 { + +static const float BL0940_PREF = 1430; +static const float BL0940_UREF = 33000; +static const float BL0940_IREF = 275000; // 2750 from tasmota. Seems to generate values 100 times too high + +// Measured to 297J per click according to power consumption of 5 minutes +// Converted to kWh (3.6MJ per kwH). Used to be 256 * 1638.4 +static const float BL0940_EREF = 3.6e6 / 297; + +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; // value of 0x58 according to docs. 0x55 according to Tasmota real world tests. Reality wins. + ube24_t i_fast_rms; // 0x00 + ube24_t i_rms; // 0x04 + ube24_t RESERVED0; // reserved + ube24_t v_rms; // 0x06 + ube24_t RESERVED1; // reserved + sbe24_t watt; // 0x08 + ube24_t RESERVED2; // reserved + ube24_t cf_cnt; // 0x0A + ube24_t RESERVED3; // reserved + ube16_t tps1; // 0x0c + uint8_t RESERVED4; // value of 0x00 + ube16_t tps2; // 0x0c + uint8_t RESERVED5; // value of 0x00 + uint8_t checksum; // checksum + }; +} __attribute__((packed)); + +class BL0940 : public PollingComponent, public uart::UARTDevice { + public: + void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } + void set_current_sensor(sensor::Sensor *current_sensor) { current_sensor_ = current_sensor; } + void set_power_sensor(sensor::Sensor *power_sensor) { power_sensor_ = power_sensor; } + void set_energy_sensor(sensor::Sensor *energy_sensor) { energy_sensor_ = energy_sensor; } + void set_internal_temperature_sensor(sensor::Sensor *internal_temperature_sensor) { + internal_temperature_sensor_ = internal_temperature_sensor; + } + void set_external_temperature_sensor(sensor::Sensor *external_temperature_sensor) { + external_temperature_sensor_ = external_temperature_sensor; + } + + void loop() override; + + void update() override; + void setup() override; + void dump_config() override; + + protected: + sensor::Sensor *voltage_sensor_; + sensor::Sensor *current_sensor_; + // NB This may be negative as the circuits is seemingly able to measure + // power in both directions + sensor::Sensor *power_sensor_; + sensor::Sensor *energy_sensor_; + sensor::Sensor *internal_temperature_sensor_; + sensor::Sensor *external_temperature_sensor_; + + // Max difference between two measurements of the temperature. Used to avoid noise. + float max_temperature_diff_{0}; + // Divide by this to turn into Watt + float power_reference_ = BL0940_PREF; + // Divide by this to turn into Volt + float voltage_reference_ = BL0940_UREF; + // Divide by this to turn into Ampere + float current_reference_ = BL0940_IREF; + // Divide by this to turn into kWh + float energy_reference_ = BL0940_EREF; + + float update_temp_(sensor::Sensor *sensor, ube16_t packed_temperature) const; + + 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 bl0940 +} // namespace esphome diff --git a/esphome/components/bl0940/sensor.py b/esphome/components/bl0940/sensor.py new file mode 100644 index 0000000000..ce630b7408 --- /dev/null +++ b/esphome/components/bl0940/sensor.py @@ -0,0 +1,106 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import sensor, uart +from esphome.const import ( + CONF_CURRENT, + CONF_ENERGY, + CONF_ID, + CONF_POWER, + CONF_VOLTAGE, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_VOLTAGE, + DEVICE_CLASS_TEMPERATURE, + ICON_EMPTY, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_NONE, + UNIT_AMPERE, + UNIT_CELSIUS, + UNIT_KILOWATT_HOURS, + UNIT_VOLT, + UNIT_WATT, +) + +DEPENDENCIES = ["uart"] + +CONF_INTERNAL_TEMPERATURE = "internal_temperature" +CONF_EXTERNAL_TEMPERATURE = "external_temperature" + +bl0940_ns = cg.esphome_ns.namespace("bl0940") +BL0940 = bl0940_ns.class_("BL0940", cg.PollingComponent, uart.UARTDevice) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(BL0940), + cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( + UNIT_VOLT, ICON_EMPTY, 1, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT + ), + cv.Optional(CONF_CURRENT): sensor.sensor_schema( + UNIT_AMPERE, + ICON_EMPTY, + 2, + DEVICE_CLASS_CURRENT, + STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_POWER): sensor.sensor_schema( + UNIT_WATT, ICON_EMPTY, 0, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT + ), + cv.Optional(CONF_ENERGY): sensor.sensor_schema( + UNIT_KILOWATT_HOURS, + ICON_EMPTY, + 0, + DEVICE_CLASS_ENERGY, + STATE_CLASS_NONE, + ), + cv.Optional(CONF_INTERNAL_TEMPERATURE): sensor.sensor_schema( + UNIT_CELSIUS, + ICON_EMPTY, + 0, + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_NONE, + ), + cv.Optional(CONF_EXTERNAL_TEMPERATURE): sensor.sensor_schema( + UNIT_CELSIUS, + ICON_EMPTY, + 0, + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_NONE, + ), + } + ) + .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 in config: + conf = config[CONF_CURRENT] + sens = await sensor.new_sensor(conf) + cg.add(var.set_current_sensor(sens)) + if CONF_POWER in config: + conf = config[CONF_POWER] + sens = await sensor.new_sensor(conf) + cg.add(var.set_power_sensor(sens)) + if CONF_ENERGY in config: + conf = config[CONF_ENERGY] + sens = await sensor.new_sensor(conf) + cg.add(var.set_energy_sensor(sens)) + if CONF_INTERNAL_TEMPERATURE in config: + conf = config[CONF_INTERNAL_TEMPERATURE] + sens = await sensor.new_sensor(conf) + cg.add(var.set_internal_temperature_sensor(sens)) + if CONF_EXTERNAL_TEMPERATURE in config: + conf = config[CONF_EXTERNAL_TEMPERATURE] + sens = await sensor.new_sensor(conf) + cg.add(var.set_external_temperature_sensor(sens)) diff --git a/tests/test3.yaml b/tests/test3.yaml index 61d68d824b..01600ad74b 100644 --- a/tests/test3.yaml +++ b/tests/test3.yaml @@ -455,10 +455,24 @@ sensor: active_power_b: name: ADE7953 Active Power B id: ade7953_active_power_b + - platform: bl0940 + uart_id: uart3 + voltage: + name: 'BL0940 Voltage' + current: + name: 'BL0940 Current' + power: + name: 'BL0940 Power' + energy: + name: 'BL0940 Energy' + internal_temperature: + name: 'BL0940 Internal temperature' + external_temperature: + name: 'BL0940 External temperature' - platform: pzem004t uart_id: uart3 voltage: - name: 'PZEM00T Voltage' + name: 'PZEM004T Voltage' current: name: 'PZEM004T Current' power: