From 5d767d9f92291ddda3673cf3e769f8fd03961283 Mon Sep 17 00:00:00 2001 From: patagona Date: Sun, 29 Sep 2024 02:39:51 +0200 Subject: [PATCH] Initial DALY Modbus BMS implementation --- esphome/components/daly_hkms_bms/__init__.py | 33 +++++++ .../daly_hkms_bms/daly_hkms_bms.cpp | 92 +++++++++++++++++++ .../components/daly_hkms_bms/daly_hkms_bms.h | 35 +++++++ esphome/components/daly_hkms_bms/sensor.py | 70 ++++++++++++++ 4 files changed, 230 insertions(+) create mode 100644 esphome/components/daly_hkms_bms/__init__.py create mode 100644 esphome/components/daly_hkms_bms/daly_hkms_bms.cpp create mode 100644 esphome/components/daly_hkms_bms/daly_hkms_bms.h create mode 100644 esphome/components/daly_hkms_bms/sensor.py diff --git a/esphome/components/daly_hkms_bms/__init__.py b/esphome/components/daly_hkms_bms/__init__.py new file mode 100644 index 0000000000..d2a5eccbd9 --- /dev/null +++ b/esphome/components/daly_hkms_bms/__init__.py @@ -0,0 +1,33 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import modbus +from esphome.const import CONF_ID, CONF_ADDRESS + +CODEOWNERS = ["@patagonaa"] +MULTI_CONF = True +DEPENDENCIES = ["modbus"] + +CONF_DALY_HKMS_BMS_ID = "daly_hkms_bms_id" + +daly_hkms_bms = cg.esphome_ns.namespace("daly_hkms_bms") +DalyHkmsBmsComponent = daly_hkms_bms.class_( + "DalyHkmsBmsComponent", cg.PollingComponent, modbus.ModbusDevice +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(DalyHkmsBmsComponent), + cv.GenerateID(modbus.CONF_MODBUS_ID): cv.use_id(modbus.Modbus), + cv.Optional(CONF_ADDRESS, default=1): cv.positive_int, + } + ) + .extend(cv.polling_component_schema("30s")) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await modbus.register_modbus_device(var, config) + cg.add(var.set_daly_address(config[CONF_ADDRESS])) diff --git a/esphome/components/daly_hkms_bms/daly_hkms_bms.cpp b/esphome/components/daly_hkms_bms/daly_hkms_bms.cpp new file mode 100644 index 0000000000..237e9127f0 --- /dev/null +++ b/esphome/components/daly_hkms_bms/daly_hkms_bms.cpp @@ -0,0 +1,92 @@ +#include "daly_hkms_bms.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace daly_hkms_bms { + +static const char *const TAG = "daly_hkms_bms"; + +// the DALY BMS only _kinda_ does Modbus. The device address is offset by 0x80 (so BMS #1 has address 0x81) +// which would be fine, however the Modbus address of the response has a different offset of 0x50, +// which makes this very much non-standard-compliant... +static const uint8_t DALY_MODBUS_REQUEST_ADDRESS_OFFSET = 0x80; +static const uint8_t DALY_MODBUS_RESPONSE_ADDRESS_OFFSET = 0x50; + +static const uint8_t MODBUS_CMD_READ_HOLDING_REGISTERS = 0x03; + +static const uint16_t MODBUS_ADDR_SUM_VOLT = 56; +static const uint16_t MODBUS_REGISTER_COUNT = 3; + +void DalyHkmsBmsComponent::set_daly_address(uint8_t daly_address) { + this->daly_address_ = daly_address; + + // set ModbusDevice address to the response address so the modbus component forwards + // the response of this device to this component + uint8_t modbus_response_address = daly_address + DALY_MODBUS_RESPONSE_ADDRESS_OFFSET; + this->set_address(modbus_response_address); +} + +void DalyHkmsBmsComponent::loop() { + // If update() was unable to send we retry until we can send. + if (!this->waiting_to_update_) + return; + update(); +} + +void DalyHkmsBmsComponent::update() { + // If our last send has had no reply yet, and it wasn't that long ago, do nothing. + uint32_t now = millis(); + if (now - this->last_send_ < this->get_update_interval() / 2) { + return; + } + + // The bus might be slow, or there might be other devices, or other components might be talking to our device. + if (this->waiting_for_response()) { + this->waiting_to_update_ = true; + return; + } + + this->waiting_to_update_ = false; + + // send the request using Modbus directly instead of ModbusDevice so we can send the data with the request address + this->parent_->send(this->daly_address_ + DALY_MODBUS_REQUEST_ADDRESS_OFFSET, MODBUS_CMD_READ_HOLDING_REGISTERS, MODBUS_ADDR_SUM_VOLT, MODBUS_REGISTER_COUNT, 0, nullptr); + + this->last_send_ = millis(); +} + +void DalyHkmsBmsComponent::on_modbus_data(const std::vector &data) { + // Other components might be sending commands to our device. But we don't get called with enough + // context to know what is what. So if we didn't do a send, we ignore the data. + if (!this->last_send_) + return; + this->last_send_ = 0; + + // Also ignore the data if the message is too short. Otherwise we will publish invalid values. + if (data.size() < MODBUS_REGISTER_COUNT * 2) + return; + +#ifdef USE_SENSOR + if (this->voltage_sensor_) { + float voltage = encode_uint16(data[0], data[1]) / 10.0; + this->voltage_sensor_->publish_state(voltage); + } + + if (this->current_sensor_) { + float current = (encode_uint16(data[2], data[3]) - 30000) / 10.0; + this->current_sensor_->publish_state(current); + } + + if (this->battery_level_sensor_) { + float current = encode_uint16(data[4], data[5]) / 10.0; + this->battery_level_sensor_->publish_state(current); + } +#endif +} + +void DalyHkmsBmsComponent::dump_config() { + ESP_LOGCONFIG(TAG, "DALY HKMS BMS:"); + ESP_LOGCONFIG(TAG, " Address: 0x%02X", this->daly_address_); +} + +} // namespace daly_hkms_bms +} // namespace esphome diff --git a/esphome/components/daly_hkms_bms/daly_hkms_bms.h b/esphome/components/daly_hkms_bms/daly_hkms_bms.h new file mode 100644 index 0000000000..07eede0e25 --- /dev/null +++ b/esphome/components/daly_hkms_bms/daly_hkms_bms.h @@ -0,0 +1,35 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/modbus/modbus.h" + +#include + +namespace esphome { +namespace daly_hkms_bms { + +class DalyHkmsBmsComponent : public PollingComponent, public modbus::ModbusDevice { + public: + void loop() override; + void update() override; + void on_modbus_data(const std::vector &data) override; + void dump_config() override; + + void set_daly_address(uint8_t address); + +#ifdef USE_SENSOR + SUB_SENSOR(voltage) + SUB_SENSOR(current) + SUB_SENSOR(battery_level) +#endif + + protected: + bool waiting_to_update_; + uint32_t last_send_; + uint8_t daly_address_; + +}; + +} // namespace daly_hkms_bms +} // namespace esphome diff --git a/esphome/components/daly_hkms_bms/sensor.py b/esphome/components/daly_hkms_bms/sensor.py new file mode 100644 index 0000000000..e221958bbe --- /dev/null +++ b/esphome/components/daly_hkms_bms/sensor.py @@ -0,0 +1,70 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import sensor +from esphome.const import ( + CONF_CURRENT, + CONF_ID, + CONF_VOLTAGE, + CONF_BATTERY_LEVEL, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_POWER, + DEVICE_CLASS_VOLTAGE, + DEVICE_CLASS_BATTERY, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_AMPERE, + UNIT_CELSIUS, + UNIT_VOLT, + UNIT_WATT, + UNIT_PERCENT, + ICON_PERCENT, +) +from . import DalyHkmsBmsComponent, CONF_DALY_HKMS_BMS_ID + +ICON_CURRENT_DC = "mdi:current-dc" + +TYPES = [ + CONF_VOLTAGE, + CONF_CURRENT, + CONF_BATTERY_LEVEL, +] + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(CONF_DALY_HKMS_BMS_ID): cv.use_id(DalyHkmsBmsComponent), + + 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): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + icon=ICON_CURRENT_DC, + accuracy_decimals=1, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + icon=ICON_PERCENT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) +) + +async def setup_conf(config, key, hub): + if sensor_config := config.get(key): + sens = await sensor.new_sensor(sensor_config) + cg.add(getattr(hub, f"set_{key}_sensor")(sens)) + +async def to_code(config): + hub = await cg.get_variable(config[CONF_DALY_HKMS_BMS_ID]) + for key in TYPES: + await setup_conf(config, key, hub) \ No newline at end of file