Skip to content

Commit 4fc9039

Browse files
committed
Add OpenDTU energy and inverter telemetry sensors
1 parent 8a529ff commit 4fc9039

5 files changed

Lines changed: 216 additions & 13 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ All notable changes to this project are documented here.
66

77
### Added
88

9+
- ESPHome sensors for daily and lifetime production, summed only across microinverters selected in `microinverter_map`.
10+
- ESPHome sensors for average mapped-inverter temperature and aggregate inverter efficiency.
11+
12+
### Changed
13+
14+
- Extended OpenDTU livedata parsing to consume `INV["0"]` production, temperature, efficiency, and DC-power fields in addition to the AC channel.
15+
16+
### Added
17+
918
- Selectable `sdm630` and `dtsu666` meter profiles through `meter_profile`.
1019
- CHINT DTSU666 instantaneous voltage, current, active-power, and frequency register mapping based on the published CHINT Modbus specification.
1120
- Separate profile descriptor tables, a shared `meter_profile_codec`, and CI build configurations for SDM630 and DTSU666.

‎README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ flowchart TB
6969
Each microinverter (**MI**) only needs radio reach to **OpenDTU**. The bridge sums per-phase active, reactive and apparent power plus current from every mapped inverter and encodes the result using the selected meter profile.
7070

7171
- Parses all electrical fields supplied by the OpenDTU AC channel (`inverters[].AC["0"]` → voltage, current, active power, reactive power, power factor and frequency)
72+
- Reads production, temperature and efficiency from each mapped inverter's `inverters[].INV["0"]` channel; the bridge does not use OpenDTU's global total, which can include unmapped inverters
7273
- Maps microinverters to grid phases via `microinverter_map` (several inverters can share a phase; current, active power, reactive power and apparent power are summed, voltage and frequency are averaged)
7374
- Inverts the OpenDTU generation sign for directional values: active power is negative for export in both profiles, SDM630 retains directional current for compatibility, and DTSU666 exposes positive RMS current as required by its register map
7475
- Serves the selected meter register window on `slave_address` (`0x02` for Deye Grid Tie Meter 2), silently ignoring Deye queries to `0x01` (main meter address)
@@ -194,6 +195,8 @@ When `publish_sensors: true` (default), the component registers:
194195
- L1/L2/L3 and total reactive-power sensors (`ReactivePower` from OpenDTU)
195196
- L1/L2/L3 and total apparent-power sensors (derived from OpenDTU active power and power factor)
196197
- L1/L2/L3 and total power-factor sensors (aggregated from active and apparent power)
198+
- Total production today [Wh] and total lifetime production [kWh], summed across mapped microinverters
199+
- Average mapped-inverter temperature and aggregate inverter efficiency
197200
- Total active-power and frequency sensors
198201
- **WebSocket Status** and **WebSocket Data Valid** (diagnostic binary sensors)
199202
- **Board Restart** button and **Component Version** text sensor (diagnostic)

‎components/opendtu_meter_bridge/__init__.py‎

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,22 +22,30 @@
2222
CONF_PORT,
2323
DEVICE_CLASS_CONNECTIVITY,
2424
DEVICE_CLASS_CURRENT,
25+
DEVICE_CLASS_ENERGY,
2526
DEVICE_CLASS_FREQUENCY,
2627
DEVICE_CLASS_APPARENT_POWER,
2728
DEVICE_CLASS_POWER,
2829
DEVICE_CLASS_POWER_FACTOR,
2930
DEVICE_CLASS_REACTIVE_POWER,
3031
DEVICE_CLASS_RESTART,
32+
DEVICE_CLASS_TEMPERATURE,
3133
DEVICE_CLASS_VOLTAGE,
3234
ENTITY_CATEGORY_DIAGNOSTIC,
3335
ICON_RESTART,
3436
STATE_CLASS_MEASUREMENT,
37+
STATE_CLASS_TOTAL,
38+
STATE_CLASS_TOTAL_INCREASING,
3539
UNIT_AMPERE,
40+
UNIT_CELSIUS,
3641
UNIT_HERTZ,
42+
UNIT_KILOWATT_HOURS,
43+
UNIT_PERCENT,
3744
UNIT_VOLT,
3845
UNIT_VOLT_AMPS,
3946
UNIT_VOLT_AMPS_REACTIVE,
4047
UNIT_WATT,
48+
UNIT_WATT_HOURS,
4149
)
4250

4351
# ESPHome 2026.7 introduced the ModbusServer codegen type. Earlier supported
@@ -53,7 +61,7 @@
5361
DEPENDENCIES = ["wifi"]
5462
CONFLICTS_WITH = ["opendtu_sdm630"]
5563

56-
COMPONENT_VERSION = "0.2.1"
64+
COMPONENT_VERSION = "0.2.2"
5765

5866
CONF_HOST = "host"
5967
CONF_PATH = "path"
@@ -94,6 +102,10 @@
94102
CONF_POWER_FACTOR_L2 = "power_factor_l2"
95103
CONF_POWER_FACTOR_L3 = "power_factor_l3"
96104
CONF_TOTAL_POWER_FACTOR = "total_power_factor"
105+
CONF_TOTAL_YIELD_DAY = "total_yield_day"
106+
CONF_TOTAL_YIELD_TOTAL = "total_yield_total"
107+
CONF_AVERAGE_TEMPERATURE = "average_temperature"
108+
CONF_EFFICIENCY = "efficiency"
97109
CONF_FREQUENCY = "frequency"
98110
CONF_WEBSOCKET_CONNECTED = "websocket_connected"
99111
CONF_DATA_VALID = "data_valid"
@@ -175,6 +187,37 @@
175187
state_class=STATE_CLASS_MEASUREMENT,
176188
)
177189

190+
YIELD_DAY_SENSOR_SCHEMA = sensor.sensor_schema(
191+
sensor.Sensor,
192+
unit_of_measurement=UNIT_WATT_HOURS,
193+
device_class=DEVICE_CLASS_ENERGY,
194+
accuracy_decimals=0,
195+
state_class=STATE_CLASS_TOTAL,
196+
)
197+
198+
YIELD_TOTAL_SENSOR_SCHEMA = sensor.sensor_schema(
199+
sensor.Sensor,
200+
unit_of_measurement=UNIT_KILOWATT_HOURS,
201+
device_class=DEVICE_CLASS_ENERGY,
202+
accuracy_decimals=3,
203+
state_class=STATE_CLASS_TOTAL_INCREASING,
204+
)
205+
206+
TEMPERATURE_SENSOR_SCHEMA = sensor.sensor_schema(
207+
sensor.Sensor,
208+
unit_of_measurement=UNIT_CELSIUS,
209+
device_class=DEVICE_CLASS_TEMPERATURE,
210+
accuracy_decimals=1,
211+
state_class=STATE_CLASS_MEASUREMENT,
212+
)
213+
214+
EFFICIENCY_SENSOR_SCHEMA = sensor.sensor_schema(
215+
sensor.Sensor,
216+
unit_of_measurement=UNIT_PERCENT,
217+
accuracy_decimals=1,
218+
state_class=STATE_CLASS_MEASUREMENT,
219+
)
220+
178221
FREQUENCY_SENSOR_SCHEMA = sensor.sensor_schema(
179222
sensor.Sensor,
180223
unit_of_measurement=UNIT_HERTZ,
@@ -227,6 +270,10 @@
227270
CONF_POWER_FACTOR_L2: ("L2 Power Factor", POWER_FACTOR_SENSOR_SCHEMA),
228271
CONF_POWER_FACTOR_L3: ("L3 Power Factor", POWER_FACTOR_SENSOR_SCHEMA),
229272
CONF_TOTAL_POWER_FACTOR: ("Total Power Factor", POWER_FACTOR_SENSOR_SCHEMA),
273+
CONF_TOTAL_YIELD_DAY: ("Total Yield Today", YIELD_DAY_SENSOR_SCHEMA),
274+
CONF_TOTAL_YIELD_TOTAL: ("Total Yield", YIELD_TOTAL_SENSOR_SCHEMA),
275+
CONF_AVERAGE_TEMPERATURE: ("Average Inverter Temperature", TEMPERATURE_SENSOR_SCHEMA),
276+
CONF_EFFICIENCY: ("Inverter Efficiency", EFFICIENCY_SENSOR_SCHEMA),
230277
CONF_FREQUENCY: ("Frequency", FREQUENCY_SENSOR_SCHEMA),
231278
CONF_WEBSOCKET_CONNECTED: ("WebSocket Status", BINARY_CONNECTIVITY_SCHEMA),
232279
CONF_DATA_VALID: ("WebSocket Data Valid", BINARY_DATA_VALID_SCHEMA),
@@ -255,6 +302,10 @@
255302
CONF_POWER_FACTOR_L2: "set_power_factor_l2_sensor",
256303
CONF_POWER_FACTOR_L3: "set_power_factor_l3_sensor",
257304
CONF_TOTAL_POWER_FACTOR: "set_total_power_factor_sensor",
305+
CONF_TOTAL_YIELD_DAY: "set_total_yield_day_sensor",
306+
CONF_TOTAL_YIELD_TOTAL: "set_total_yield_total_sensor",
307+
CONF_AVERAGE_TEMPERATURE: "set_average_temperature_sensor",
308+
CONF_EFFICIENCY: "set_efficiency_sensor",
258309
CONF_FREQUENCY: "set_frequency_sensor",
259310
CONF_WEBSOCKET_CONNECTED: "set_websocket_connected_binary_sensor",
260311
CONF_DATA_VALID: "set_data_valid_binary_sensor",
@@ -338,6 +389,10 @@ def _normalize_meter_profile(config):
338389
cv.Optional(CONF_POWER_FACTOR_L2): POWER_FACTOR_SENSOR_SCHEMA,
339390
cv.Optional(CONF_POWER_FACTOR_L3): POWER_FACTOR_SENSOR_SCHEMA,
340391
cv.Optional(CONF_TOTAL_POWER_FACTOR): POWER_FACTOR_SENSOR_SCHEMA,
392+
cv.Optional(CONF_TOTAL_YIELD_DAY): YIELD_DAY_SENSOR_SCHEMA,
393+
cv.Optional(CONF_TOTAL_YIELD_TOTAL): YIELD_TOTAL_SENSOR_SCHEMA,
394+
cv.Optional(CONF_AVERAGE_TEMPERATURE): TEMPERATURE_SENSOR_SCHEMA,
395+
cv.Optional(CONF_EFFICIENCY): EFFICIENCY_SENSOR_SCHEMA,
341396
cv.Optional(CONF_FREQUENCY): FREQUENCY_SENSOR_SCHEMA,
342397
cv.Optional(CONF_WEBSOCKET_CONNECTED): BINARY_CONNECTIVITY_SCHEMA,
343398
cv.Optional(CONF_DATA_VALID): BINARY_DATA_VALID_SCHEMA,

‎components/opendtu_meter_bridge/opendtu_meter_bridge.cpp‎

Lines changed: 127 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,14 @@ void OpenDtuMeterBridge::mark_data_stale_and_reset_() {
139139
}
140140
this->measured_frequency_ = 0.0f;
141141
this->has_frequency_data_ = false;
142+
this->total_yield_day_ = 0.0f;
143+
this->total_yield_total_ = 0.0f;
144+
this->average_temperature_ = 0.0f;
145+
this->efficiency_ = 0.0f;
146+
this->has_yield_day_data_ = false;
147+
this->has_yield_total_data_ = false;
148+
this->has_temperature_data_ = false;
149+
this->has_efficiency_data_ = false;
142150
this->sync_modbus_registers_locked_();
143151
xSemaphoreGive(this->data_mutex_);
144152
this->publish_state_();
@@ -212,6 +220,18 @@ void OpenDtuMeterBridge::publish_state_() {
212220
if (this->total_power_factor_sensor_ != nullptr) {
213221
this->total_power_factor_sensor_->publish_state(this->get_total_power_factor());
214222
}
223+
if (this->total_yield_day_sensor_ != nullptr) {
224+
this->total_yield_day_sensor_->publish_state(this->get_total_yield_day());
225+
}
226+
if (this->total_yield_total_sensor_ != nullptr) {
227+
this->total_yield_total_sensor_->publish_state(this->get_total_yield_total());
228+
}
229+
if (this->average_temperature_sensor_ != nullptr) {
230+
this->average_temperature_sensor_->publish_state(this->get_average_temperature());
231+
}
232+
if (this->efficiency_sensor_ != nullptr) {
233+
this->efficiency_sensor_->publish_state(this->get_efficiency());
234+
}
215235
if (this->frequency_sensor_ != nullptr) {
216236
this->frequency_sensor_->publish_state(this->get_frequency());
217237
}
@@ -226,17 +246,21 @@ void OpenDtuMeterBridge::publish_state_() {
226246
#endif
227247
}
228248

229-
float OpenDtuMeterBridge::json_field_v_(const cJSON *ac0, const char *key) {
230-
const cJSON *field = cJSON_GetObjectItemCaseSensitive(ac0, key);
249+
bool OpenDtuMeterBridge::json_field_v_(const cJSON *object, const char *key, float &value) {
250+
value = 0.0f;
251+
const cJSON *field = cJSON_GetObjectItemCaseSensitive(object, key);
231252
if (field == nullptr) {
232-
return 0.0f;
253+
return false;
233254
}
234255
const cJSON *v = cJSON_GetObjectItemCaseSensitive(field, "v");
235256
if (cJSON_IsNumber(v)) {
236257
float out = (float) v->valuedouble;
237-
return float_is_finite(out) ? out : 0.0f;
258+
if (float_is_finite(out)) {
259+
value = out;
260+
return true;
261+
}
238262
}
239-
return 0.0f;
263+
return false;
240264
}
241265

242266
int OpenDtuMeterBridge::find_inverter_index_(const cJSON *inverters, const MicroinverterMapEntry &entry) {
@@ -279,6 +303,16 @@ void OpenDtuMeterBridge::process_livedata_(const char *json, size_t len) {
279303
PhaseAccumulator tmp[4] = {};
280304
float frequency_sum = 0.0f;
281305
uint8_t frequency_count = 0;
306+
float yield_day_total = 0.0f;
307+
float yield_total_total = 0.0f;
308+
float temperature_sum = 0.0f;
309+
uint8_t temperature_count = 0;
310+
float dc_power_total = 0.0f;
311+
float ac_power_total = 0.0f;
312+
float efficiency_sum = 0.0f;
313+
uint8_t efficiency_count = 0;
314+
bool has_yield_day = false;
315+
bool has_yield_total = false;
282316

283317
for (const auto &entry : this->microinverter_map_) {
284318
uint8_t grid_phase = entry.grid_phase;
@@ -307,12 +341,18 @@ void OpenDtuMeterBridge::process_livedata_(const char *json, size_t len) {
307341
continue;
308342
}
309343

310-
float voltage = this->json_field_v_(ac0, "Voltage");
311-
float current = this->json_field_v_(ac0, "Current");
312-
float power = this->json_field_v_(ac0, "Power");
313-
float reactive_power = this->json_field_v_(ac0, "ReactivePower");
314-
float power_factor = this->json_field_v_(ac0, "PowerFactor");
315-
float frequency = this->json_field_v_(ac0, "Frequency");
344+
float voltage = 0.0f;
345+
float current = 0.0f;
346+
float power = 0.0f;
347+
float reactive_power = 0.0f;
348+
float power_factor = 0.0f;
349+
float frequency = 0.0f;
350+
this->json_field_v_(ac0, "Voltage", voltage);
351+
this->json_field_v_(ac0, "Current", current);
352+
const bool has_power = this->json_field_v_(ac0, "Power", power);
353+
this->json_field_v_(ac0, "ReactivePower", reactive_power);
354+
this->json_field_v_(ac0, "PowerFactor", power_factor);
355+
this->json_field_v_(ac0, "Frequency", frequency);
316356

317357
if (float_is_finite(voltage) && voltage >= VOLTAGE_MIN_V && voltage <= VOLTAGE_MAX_V) {
318358
tmp[grid_phase].voltage_sum += voltage;
@@ -333,6 +373,36 @@ void OpenDtuMeterBridge::process_livedata_(const char *json, size_t len) {
333373
frequency_sum += frequency;
334374
frequency_count++;
335375
}
376+
377+
cJSON *inverter = cJSON_GetObjectItemCaseSensitive(inv, "INV");
378+
cJSON *inverter0 = (inverter != nullptr) ? cJSON_GetObjectItemCaseSensitive(inverter, "0") : nullptr;
379+
if (inverter0 == nullptr) {
380+
continue;
381+
}
382+
383+
float value = 0.0f;
384+
if (this->json_field_v_(inverter0, "YieldDay", value)) {
385+
yield_day_total += value;
386+
has_yield_day = true;
387+
}
388+
if (this->json_field_v_(inverter0, "YieldTotal", value)) {
389+
yield_total_total += value;
390+
has_yield_total = true;
391+
}
392+
if (this->json_field_v_(inverter0, "Temperature", value)) {
393+
temperature_sum += value;
394+
temperature_count++;
395+
}
396+
if (this->json_field_v_(inverter0, "Efficiency", value)) {
397+
efficiency_sum += value;
398+
efficiency_count++;
399+
}
400+
if (has_power) {
401+
ac_power_total += std::fabs(power);
402+
}
403+
if (this->json_field_v_(inverter0, "Power DC", value) && value > 0.0f) {
404+
dc_power_total += value;
405+
}
336406
}
337407

338408
xSemaphoreTake(this->data_mutex_, portMAX_DELAY);
@@ -355,6 +425,24 @@ void OpenDtuMeterBridge::process_livedata_(const char *json, size_t len) {
355425
this->measured_frequency_ = 0.0f;
356426
this->has_frequency_data_ = false;
357427
}
428+
this->total_yield_day_ = has_yield_day && float_is_finite(yield_day_total) ? yield_day_total : 0.0f;
429+
this->total_yield_total_ = has_yield_total && float_is_finite(yield_total_total) ? yield_total_total : 0.0f;
430+
this->average_temperature_ = temperature_count > 0 && float_is_finite(temperature_sum)
431+
? temperature_sum / (float) temperature_count
432+
: 0.0f;
433+
if (dc_power_total > 0.0f && float_is_finite(ac_power_total) && float_is_finite(dc_power_total)) {
434+
this->efficiency_ = ac_power_total / dc_power_total * 100.0f;
435+
this->has_efficiency_data_ = float_is_finite(this->efficiency_);
436+
} else if (efficiency_count > 0 && float_is_finite(efficiency_sum)) {
437+
this->efficiency_ = efficiency_sum / (float) efficiency_count;
438+
this->has_efficiency_data_ = true;
439+
} else {
440+
this->efficiency_ = 0.0f;
441+
this->has_efficiency_data_ = false;
442+
}
443+
this->has_yield_day_data_ = has_yield_day && float_is_finite(this->total_yield_day_);
444+
this->has_yield_total_data_ = has_yield_total && float_is_finite(this->total_yield_total_);
445+
this->has_temperature_data_ = temperature_count > 0 && float_is_finite(this->average_temperature_);
358446
this->last_data_us_ = esp_timer_get_time();
359447
this->data_stale_ = false;
360448
this->sync_modbus_registers_locked_();
@@ -735,6 +823,34 @@ float OpenDtuMeterBridge::get_total_power_factor() {
735823
return std::fabs(total_power) / total_apparent_power;
736824
}
737825

826+
float OpenDtuMeterBridge::get_total_yield_day() {
827+
xSemaphoreTake(this->data_mutex_, portMAX_DELAY);
828+
const float value = !this->data_stale_ && this->has_yield_day_data_ ? this->total_yield_day_ : 0.0f;
829+
xSemaphoreGive(this->data_mutex_);
830+
return float_is_finite(value) ? value : 0.0f;
831+
}
832+
833+
float OpenDtuMeterBridge::get_total_yield_total() {
834+
xSemaphoreTake(this->data_mutex_, portMAX_DELAY);
835+
const float value = !this->data_stale_ && this->has_yield_total_data_ ? this->total_yield_total_ : 0.0f;
836+
xSemaphoreGive(this->data_mutex_);
837+
return float_is_finite(value) ? value : 0.0f;
838+
}
839+
840+
float OpenDtuMeterBridge::get_average_temperature() {
841+
xSemaphoreTake(this->data_mutex_, portMAX_DELAY);
842+
const float value = !this->data_stale_ && this->has_temperature_data_ ? this->average_temperature_ : 0.0f;
843+
xSemaphoreGive(this->data_mutex_);
844+
return float_is_finite(value) ? value : 0.0f;
845+
}
846+
847+
float OpenDtuMeterBridge::get_efficiency() {
848+
xSemaphoreTake(this->data_mutex_, portMAX_DELAY);
849+
const float value = !this->data_stale_ && this->has_efficiency_data_ ? this->efficiency_ : 0.0f;
850+
xSemaphoreGive(this->data_mutex_);
851+
return float_is_finite(value) ? value : 0.0f;
852+
}
853+
738854
float OpenDtuMeterBridge::get_frequency() {
739855
xSemaphoreTake(this->data_mutex_, portMAX_DELAY);
740856
float value = this->data_stale_

0 commit comments

Comments
 (0)