Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/.esphome/
/.venv/
/secrets.yaml
/tests/secrets.yaml

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Each microinverter (**MI**) only needs radio reach to **OpenDTU**. The bridge su
- [OpenDTU](https://github.com/tbnobody/OpenDTU) running and reachable on your network (WebSocket `/livedata`, dashboard password), with **single-phase Hoymiles** microinverters
- A **second ESP32** for this bridge (separate from the OpenDTU ESP32 in the tested setup)
- RS485-to-TTL converter (no DE/RE pin required in the tested setup)
- ESPHome **≥ 2025.6.0**
- ESPHome **≥ 2026.7.0**
- **Modbus master** (e.g. Deye hybrid inverter with Grid Tie Meter 2 / Eastron type) reading **Eastron SDM630** over Modbus RTU (**9600 8N1**)
- **Home Assistant is not required** - ESPHome alone is enough to build, flash, and run this component

Expand Down
5 changes: 3 additions & 2 deletions components/opendtu_sdm630/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import binary_sensor, button, esp32, modbus, sensor, text_sensor
from esphome.components import binary_sensor, button, esp32, modbus, sensor, text_sensor, wifi
from esphome.components.modbus import CONF_MODBUS_ID
from esphome.const import (
CONF_ID,
Expand Down Expand Up @@ -192,7 +192,7 @@ def _ensure_default_entities(config):
cv.Optional(CONF_PATH, default="/livedata"): cv.string,
cv.Optional(CONF_USERNAME, default="admin"): cv.string,
cv.Required(CONF_PASSWORD): cv.string,
cv.Required(CONF_MODBUS_ID): cv.use_id(modbus.Modbus),
cv.Required(CONF_MODBUS_ID): cv.use_id(modbus.ModbusServer),
cv.Optional(CONF_SLAVE_ADDRESS, default=0x02): cv.hex_uint8_t,
cv.Optional(CONF_DATA_TIMEOUT, default="15s"): cv.positive_time_period_milliseconds,
cv.Optional(CONF_DEFAULT_VOLTAGE, default=230.0): cv.float_,
Expand Down Expand Up @@ -281,4 +281,5 @@ async def to_code(config):
version_ts = await text_sensor.new_text_sensor(config[CONF_COMPONENT_VERSION])
cg.add(var.set_component_version_text_sensor(version_ts))

wifi.request_wifi_connect_state_listener()
_register_idf_components()
43 changes: 11 additions & 32 deletions components/opendtu_sdm630/opendtu_sdm630.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ static constexpr uint16_t REG_POWER_L2 = 0x000E;
static constexpr uint16_t REG_POWER_L3 = 0x0010;
static constexpr uint16_t REG_POWER_TOTAL = 0x0034;
static constexpr uint16_t REG_FREQUENCY = 0x0046;
static constexpr uint8_t SDM630_FACTORY_SLAVE_ADDRESS = 0x01;

static constexpr float VOLTAGE_MIN_V = 100.0f;
static constexpr float VOLTAGE_MAX_V = 300.0f;
Expand Down Expand Up @@ -528,37 +527,24 @@ float OpenDtuSdm630::get_frequency() {

bool OpenDtuSdm630::is_data_valid() { return !this->data_stale_ && this->ws_connected_; }

void OpenDtuSdm630ModbusServer::on_modbus_read_registers(uint8_t function_code, uint16_t start_address,
uint16_t number_of_registers) {
if (function_code != 0x03 && function_code != 0x04) {
return;
}
modbus::ResponseStatus OpenDtuSdm630ModbusServer::on_read_registers(uint16_t start_address,
uint16_t number_of_registers,
modbus::RegisterValues &registers) {
if (this->bridge_ == nullptr) {
return;
return modbus::ModbusExceptionCode::SERVICE_DEVICE_FAILURE;
}

std::vector<uint8_t> response;
response.reserve(number_of_registers * 2);

for (uint16_t offset = 0; offset < number_of_registers; offset++) {
uint32_t address = (uint32_t) start_address + offset;
if (address >= OpenDtuSdm630::MODBUS_REG_COUNT) {
std::vector<uint8_t> error_response;
error_response.push_back(this->address_);
error_response.push_back(function_code | 0x80);
error_response.push_back(0x02);
this->send_raw(error_response);
return;
return modbus::ModbusExceptionCode::ILLEGAL_DATA_ADDRESS;
}
uint16_t value = this->bridge_->get_modbus_register((uint16_t) address);
response.push_back((uint8_t) (value >> 8));
response.push_back((uint8_t) (value & 0xFFu));
registers.push_back(this->bridge_->get_modbus_register((uint16_t) address));
}

this->send(function_code, start_address, number_of_registers, response.size(), response.data());
return {};
}

void OpenDtuSdm630::set_modbus_server(modbus::Modbus *parent, uint8_t slave_address) {
void OpenDtuSdm630::set_modbus_server(modbus::ModbusServerHub *parent, uint8_t slave_address) {
this->modbus_parent_ = parent;
this->modbus_slave_address_ = slave_address;
}
Expand All @@ -568,17 +554,10 @@ void OpenDtuSdm630::setup() {
this->sync_modbus_registers_();
if (this->modbus_parent_ != nullptr && this->modbus_slave_address_ != 0) {
this->modbus_server_device_.set_bridge(this);
this->modbus_server_device_.set_parent(this->modbus_parent_);
this->modbus_server_device_.set_address(this->modbus_slave_address_);
this->modbus_parent_->register_device(&this->modbus_server_device_);

if (this->modbus_slave_address_ != SDM630_FACTORY_SLAVE_ADDRESS) {
this->modbus_silence_device_.set_parent(this->modbus_parent_);
this->modbus_silence_device_.set_address(SDM630_FACTORY_SLAVE_ADDRESS);
this->modbus_parent_->register_device(&this->modbus_silence_device_);
}
}
#ifdef USE_WIFI_LISTENERS
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
wifi::global_wifi_component->add_connect_state_listener(this);
#endif
this->publish_state_();
Expand All @@ -589,8 +568,8 @@ void OpenDtuSdm630::setup() {
#endif
}

#ifdef USE_WIFI_LISTENERS
void OpenDtuSdm630::on_wifi_connect_state(const std::string &ssid, const wifi::bssid_t &bssid) {
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
void OpenDtuSdm630::on_wifi_connect_state(StringRef ssid, std::span<const uint8_t, 6> bssid) {
if (ssid.empty()) {
ESP_LOGW(TAG, "WiFi disconnected, stopping WebSocket client");
this->stop_websocket_();
Expand Down
26 changes: 10 additions & 16 deletions components/opendtu_sdm630/opendtu_sdm630.h
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include "esphome/core/defines.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/core/string_ref.h"
#include "esphome/components/modbus/modbus.h"
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
Expand All @@ -28,6 +29,7 @@
#include <freertos/semphr.h>

#include <cstdint>
#include <span>
#include <string>
#include <vector>

Expand All @@ -48,25 +50,18 @@ struct PhaseData {

class OpenDtuSdm630;

class OpenDtuSdm630ModbusServer : public modbus::ModbusDevice {
class OpenDtuSdm630ModbusServer : public modbus::ModbusServerDevice {
public:
void set_bridge(OpenDtuSdm630 *bridge) { this->bridge_ = bridge; }
void on_modbus_data(const std::vector<uint8_t> &data) override {}
void on_modbus_read_registers(uint8_t function_code, uint16_t start_address,
uint16_t number_of_registers) override;
modbus::ResponseStatus on_read_registers(uint16_t start_address, uint16_t number_of_registers,
modbus::RegisterValues &registers) override;

protected:
OpenDtuSdm630 *bridge_{nullptr};
};

// Swallows frames at factory address 0x01 to suppress unknown-address logs.
class ModbusSilenceDevice : public modbus::ModbusDevice {
public:
void on_modbus_data(const std::vector<uint8_t> &data) override {}
};

class OpenDtuSdm630 : public Component
#ifdef USE_WIFI_LISTENERS
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
,
public wifi::WiFiConnectStateListener
#endif
Expand All @@ -81,7 +76,7 @@ class OpenDtuSdm630 : public Component
void set_default_voltage(float value) { this->default_voltage_ = value; }
void set_default_frequency(float value) { this->default_frequency_ = value; }
void set_component_version(const std::string &version) { this->component_version_ = version; }
void set_modbus_server(modbus::Modbus *parent, uint8_t slave_address);
void set_modbus_server(modbus::ModbusServerHub *parent, uint8_t slave_address);
void add_microinverter_map_by_serial(const std::string &inverter_serial, uint8_t grid_phase);
void add_microinverter_map_by_name(const std::string &inverter_name, uint8_t grid_phase);

Expand Down Expand Up @@ -128,8 +123,8 @@ class OpenDtuSdm630 : public Component
void dump_config() override;
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }

#ifdef USE_WIFI_LISTENERS
void on_wifi_connect_state(const std::string &ssid, const wifi::bssid_t &bssid) override;
#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
void on_wifi_connect_state(StringRef ssid, std::span<const uint8_t, 6> bssid) override;
#endif

protected:
Expand Down Expand Up @@ -178,10 +173,9 @@ class OpenDtuSdm630 : public Component
volatile int64_t last_data_us_{0};
volatile bool data_stale_{true};

modbus::Modbus *modbus_parent_{nullptr};
modbus::ModbusServerHub *modbus_parent_{nullptr};
uint8_t modbus_slave_address_{0};
OpenDtuSdm630ModbusServer modbus_server_device_{};
ModbusSilenceDevice modbus_silence_device_{};

#ifdef USE_SENSOR
sensor::Sensor *voltage_l1_sensor_{nullptr};
Expand Down
2 changes: 1 addition & 1 deletion opendtu_sdm630.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ substitutions:
esphome:
name: ${device_name}
friendly_name: OpenDTU to SDM630
min_version: "2025.6.0"
min_version: "2026.7.0"
name_add_mac_suffix: true

esp32:
Expand Down
2 changes: 1 addition & 1 deletion tests/opendtu_sdm630_ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ opendtu_sdm630:

esphome:
name: ${device_name}
min_version: "2025.6.0"
min_version: "2026.7.0"

esp32:
variant: esp32
Expand Down
Loading