ESPHome 2026.11.0-dev
Loading...
Searching...
No Matches
selec_meter.cpp
Go to the documentation of this file.
1#include "selec_meter.h"
3#include "esphome/core/hal.h"
4#include "esphome/core/log.h"
5
6#include <cinttypes>
7#include <cmath>
8#include <cstdio>
9
11
12namespace helpers = modbus::helpers;
13
14ESPHOME_LOG_TAG(TAG, "selec_meter");
15
16static constexpr uint8_t EM2M_REGISTER_COUNT = 34; // 34 x 16-bit registers
17
18// Durable refusals an optional read tolerates before it is given up on
19static constexpr uint8_t MAX_OPTIONAL_READ_FAILURES = 5;
20
21static const LogString *read_state_name(ReadState state) {
22 switch (state) {
24 return LOG_STR("main block");
26 return LOG_STR("serial number");
28 return LOG_STR("DG sensing");
30 default:
31 return LOG_STR("idle");
32 }
33}
34
35// FP32 at `reg` in the configured word order (MSRF high word first, LSRF low word first); NaN when
36// `reg` is not wholly inside `registers`
37static float read_float(std::span<const uint16_t> registers, uint16_t start_address, uint16_t reg, bool word_swapped) {
38 auto value = word_swapped ? helpers::value_at<helpers::SensorValueType::FP32_R>(registers, start_address, reg)
39 : helpers::value_at<helpers::SensorValueType::FP32>(registers, start_address, reg);
40 return value.value_or(NAN);
41}
42
43// Publishes the field at `reg` (x1000 when `kilo`) unless it decodes to NaN/Inf, e.g. an unloaded
44// phase's power factor; a non-finite field is skipped on its own so the others still publish.
45static void publish_float(sensor::Sensor *sens, std::span<const uint16_t> registers, uint16_t reg, bool kilo,
46 bool word_swapped) {
47 float value = read_float(registers, 0, reg, word_swapped);
48 if (!std::isfinite(value)) {
49 ESP_LOGD(TAG, "Skipping non-finite value for '%s'", LOG_STR_ARG(sens->get_log_name()));
50 return;
51 }
52 sens->publish_state(kilo ? value * 1000.0f : value);
53}
54
55#if defined(USE_SELEC_METER_EM4M) && (defined(USE_TEXT_SENSOR) || defined(USE_BINARY_SENSOR))
56// Counts one failure of an optional read; callers only run while `failures` is below the limit
57static void note_optional_read_failure(uint8_t &failures, const LogString *name) {
58 if (++failures == MAX_OPTIONAL_READ_FAILURES) {
59 ESP_LOGW(TAG, "Reading %s failed %u times in a row, giving up", LOG_STR_ARG(name), failures);
60 }
61}
62#endif
63
65 switch (state) {
67#ifdef USE_SELEC_METER_EM4M
68 if (this->model_ == Model::MODEL_EM4M)
69 return {0, EM4M_REGISTER_COUNT};
70#endif
71 return {0, EM2M_REGISTER_COUNT};
73 return {EM4M_SERIAL_NUMBER, 2};
75 return {EM4M_DG_SENSING, 2};
77 default:
78 return {0, 0};
79 }
80}
81
83 switch (state) {
85 return true;
86#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR)
88 return this->serial_number_sensor_ != nullptr && this->serial_number_failures_ < MAX_OPTIONAL_READ_FAILURES;
89#endif
90#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR)
92 return this->dg_sensing_sensor_ != nullptr && this->dg_sensing_failures_ < MAX_OPTIONAL_READ_FAILURES;
93#endif
94 default:
95 return false;
96 }
97}
98
100 for (auto next = static_cast<uint8_t>(current) + 1; next < static_cast<uint8_t>(ReadState::READ_STATE_IDLE); next++) {
101 if (this->wanted_(static_cast<ReadState>(next)))
102 return static_cast<ReadState>(next);
103 }
105}
106
107void SelecMeter::on_read_input_registers(uint16_t start_address, std::span<const uint16_t> registers,
109 ReadState current = this->read_state_;
110 if (current == ReadState::READ_STATE_IDLE) {
111 ESP_LOGW(TAG, "Unexpected response while idle, dropping");
112 return;
113 }
114 // start_address comes from the exact request this answers, so a stale reply cannot match another read
115 if (start_address != this->read_range_(current).start) {
116 this->fail_current_read_(LOG_STR("Response does not match pending read"), /*durable=*/false);
117 return;
118 }
119 if (status.has_value()) {
120 const auto code = *status;
121 ESP_LOGW(TAG, "Modbus error 0x%02X while reading %s", static_cast<uint8_t>(code),
122 LOG_STR_ARG(read_state_name(current)));
123 // Illegal function/address are durable; busy, acknowledge and the rest are transient
124 const bool durable =
126 this->fail_current_read_(nullptr, durable);
127 return;
128 }
129 switch (current) {
131#ifdef USE_SELEC_METER_EM4M
132 bool ok = this->model_ == Model::MODEL_EM4M ? this->decode_em4m_(registers) : this->decode_em2m_(registers);
133#else
134 bool ok = this->decode_em2m_(registers);
135#endif
136 this->status_comm_result(ok);
137 break;
138 }
139#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR)
141 this->decode_serial_number_(registers);
142 break;
143#endif
144#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR)
146 this->decode_dg_sensing_(registers);
147 break;
148#endif
149 default:
150 break;
151 }
152 // Safe per modbus.h's callback contract: sending from inside a callback is picked up normally.
153 this->start_read_(this->next_read_state_after_(current));
154}
155
156void SelecMeter::fail_current_read_(const LogString *reason, bool durable) {
158 ESP_LOGW(TAG, "Unexpected failure while idle, dropping");
159 return;
160 }
161 ReadState failed_state = this->read_state_;
162 if (reason != nullptr) {
163 ESP_LOGW(TAG, "%s while reading %s", LOG_STR_ARG(reason), LOG_STR_ARG(read_state_name(failed_state)));
164 }
165 // Only the main block drives component health; an unsupported optional read is not a fault
166 if (failed_state == ReadState::READ_STATE_MAIN_BLOCK)
167 this->status_set_warning();
168#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR)
169 if (failed_state == ReadState::READ_STATE_SERIAL_NUMBER && durable)
170 note_optional_read_failure(this->serial_number_failures_, read_state_name(failed_state));
171#endif
172#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR)
173 if (failed_state == ReadState::READ_STATE_DG_SENSING && durable)
174 note_optional_read_failure(this->dg_sensing_failures_, read_state_name(failed_state));
175#endif
176 this->start_read_(this->next_read_state_after_(failed_state));
177}
178
179bool SelecMeter::on_no_response(std::span<const uint8_t> /*request_pdu*/) {
180 this->fail_current_read_(LOG_STR("No Modbus response"));
181 return false;
182}
183
184void SelecMeter::on_not_sent(std::span<const uint8_t> /*request_pdu*/) {
185 this->fail_current_read_(LOG_STR("Modbus request not sent"));
186}
187
188#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR)
189void SelecMeter::decode_serial_number_(std::span<const uint16_t> registers) {
190 auto serial =
191 this->word_swap_
192 ? helpers::value_at<helpers::SensorValueType::U_DWORD_R>(registers, EM4M_SERIAL_NUMBER, EM4M_SERIAL_NUMBER)
193 : helpers::value_at<helpers::SensorValueType::U_DWORD>(registers, EM4M_SERIAL_NUMBER, EM4M_SERIAL_NUMBER);
194 if (!serial) {
195 ESP_LOGW(TAG, "Unexpected response size for serial number");
196 note_optional_read_failure(this->serial_number_failures_, read_state_name(ReadState::READ_STATE_SERIAL_NUMBER));
197 return;
198 }
199 char buf[9];
200 snprintf(buf, sizeof(buf), "%08" PRIX32, *serial);
202 // The serial number never changes, so stop reading it
203 this->serial_number_failures_ = MAX_OPTIONAL_READ_FAILURES;
204}
205#endif
206
207#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR)
208void SelecMeter::decode_dg_sensing_(std::span<const uint16_t> registers) {
209 float value = read_float(registers, EM4M_DG_SENSING, EM4M_DG_SENSING, this->word_swap_);
210 if (!std::isfinite(value)) {
211 ESP_LOGW(TAG, "Short or non-finite DG sensing response, ignoring");
212 note_optional_read_failure(this->dg_sensing_failures_, read_state_name(ReadState::READ_STATE_DG_SENSING));
213 return;
214 }
215 this->dg_sensing_sensor_->publish_state(value != 0);
216 this->dg_sensing_failures_ = 0;
217}
218#endif
219
220bool SelecMeter::publish_fields_(std::span<const uint16_t> registers, std::span<const FloatField> fields,
221 uint8_t min_count) {
222 if (registers.size() < min_count) {
223 ESP_LOGW(TAG, "Short response: %u registers", static_cast<unsigned>(registers.size()));
224 return false;
225 }
226 for (const auto &entry : fields) {
227 FloatField field;
228 progmem_memcpy(&field, &entry, sizeof(field));
229 sensor::Sensor *sens = this->*field.sensor;
230 if (sens != nullptr)
231 publish_float(sens, registers, field.reg, field.kilo, this->word_swap_);
232 }
233 return true;
234}
235
236bool SelecMeter::decode_em2m_(std::span<const uint16_t> registers) {
237 static constexpr FloatField EM2M_FIELDS[] PROGMEM = {
238 {&SelecMeter::total_active_energy_sensor_, SELEC_TOTAL_ACTIVE_ENERGY, false},
239 {&SelecMeter::import_active_energy_sensor_, SELEC_IMPORT_ACTIVE_ENERGY, false},
240 {&SelecMeter::export_active_energy_sensor_, SELEC_EXPORT_ACTIVE_ENERGY, false},
241 {&SelecMeter::total_reactive_energy_sensor_, SELEC_TOTAL_REACTIVE_ENERGY, false},
242 {&SelecMeter::import_reactive_energy_sensor_, SELEC_IMPORT_REACTIVE_ENERGY, false},
243 {&SelecMeter::export_reactive_energy_sensor_, SELEC_EXPORT_REACTIVE_ENERGY, false},
244 {&SelecMeter::apparent_energy_sensor_, SELEC_APPARENT_ENERGY, false},
245 {&SelecMeter::active_power_sensor_, SELEC_ACTIVE_POWER, true},
246 {&SelecMeter::reactive_power_sensor_, SELEC_REACTIVE_POWER, true},
247 {&SelecMeter::apparent_power_sensor_, SELEC_APPARENT_POWER, true},
248 {&SelecMeter::voltage_sensor_, SELEC_VOLTAGE, false},
249 {&SelecMeter::current_sensor_, SELEC_CURRENT, false},
250 {&SelecMeter::power_factor_sensor_, SELEC_POWER_FACTOR, false},
251 {&SelecMeter::frequency_sensor_, SELEC_FREQUENCY, false},
252 {&SelecMeter::maximum_demand_active_power_sensor_, SELEC_MAXIMUM_DEMAND_ACTIVE_POWER, true},
253 {&SelecMeter::maximum_demand_reactive_power_sensor_, SELEC_MAXIMUM_DEMAND_REACTIVE_POWER, true},
254 {&SelecMeter::maximum_demand_apparent_power_sensor_, SELEC_MAXIMUM_DEMAND_APPARENT_POWER, true},
255 };
256 return this->publish_fields_(registers, EM2M_FIELDS, EM2M_REGISTER_COUNT);
257}
258
259#ifdef USE_SELEC_METER_EM4M
260bool SelecMeter::decode_em4m_(std::span<const uint16_t> registers) {
261 static constexpr FloatField EM4M_FIELDS[] PROGMEM = {
262 // Common quantities, shared sensor keys with EM2M
263 {&SelecMeter::voltage_sensor_, EM4M_VOLTAGE, false},
264 {&SelecMeter::current_sensor_, EM4M_CURRENT, false},
265 {&SelecMeter::active_power_sensor_, EM4M_ACTIVE_POWER, true},
266 {&SelecMeter::reactive_power_sensor_, EM4M_REACTIVE_POWER, true},
267 {&SelecMeter::apparent_power_sensor_, EM4M_APPARENT_POWER, true},
268 {&SelecMeter::power_factor_sensor_, EM4M_POWER_FACTOR, false},
269 {&SelecMeter::frequency_sensor_, EM4M_FREQUENCY, false},
270 {&SelecMeter::maximum_demand_active_power_sensor_, EM4M_MAXIMUM_DEMAND_ACTIVE_POWER, true},
271 {&SelecMeter::maximum_demand_reactive_power_sensor_, EM4M_MAXIMUM_DEMAND_REACTIVE_POWER, true},
272 {&SelecMeter::maximum_demand_apparent_power_sensor_, EM4M_MAXIMUM_DEMAND_APPARENT_POWER, true},
273 {&SelecMeter::import_active_energy_sensor_, EM4M_IMPORT_ACTIVE_ENERGY, false},
274 {&SelecMeter::export_active_energy_sensor_, EM4M_EXPORT_ACTIVE_ENERGY, false},
275 {&SelecMeter::import_reactive_energy_sensor_, EM4M_IMPORT_REACTIVE_ENERGY, false},
276 {&SelecMeter::export_reactive_energy_sensor_, EM4M_EXPORT_REACTIVE_ENERGY, false},
277 // Line-to-line and net (mains/DG) quantities have no per-phase equivalent
278 {&SelecMeter::voltage_l12_sensor_, EM4M_VOLTAGE_L12, false},
279 {&SelecMeter::voltage_l23_sensor_, EM4M_VOLTAGE_L23, false},
280 {&SelecMeter::voltage_l31_sensor_, EM4M_VOLTAGE_L31, false},
281 {&SelecMeter::average_voltage_ll_sensor_, EM4M_AVERAGE_VOLTAGE_LL, false},
282 {&SelecMeter::net_active_energy_mains_sensor_, EM4M_NET_ACTIVE_ENERGY_MAINS, false},
283 {&SelecMeter::net_reactive_energy_mains_sensor_, EM4M_NET_REACTIVE_ENERGY_MAINS, false},
284 {&SelecMeter::net_apparent_energy_mains_sensor_, EM4M_NET_APPARENT_ENERGY_MAINS, false},
285 {&SelecMeter::net_active_energy_dg_sensor_, EM4M_NET_ACTIVE_ENERGY_DG, false},
286 {&SelecMeter::net_reactive_energy_dg_sensor_, EM4M_NET_REACTIVE_ENERGY_DG, false},
287 {&SelecMeter::net_apparent_energy_dg_sensor_, EM4M_NET_APPARENT_ENERGY_DG, false},
288 };
289
290 // reg_l1 is the L1 register; L2/L3 follow EM4M_PHASE_STRIDE apart
291 struct PhaseField {
292 sensor::Sensor *Phase::*sensor;
293 uint16_t reg_l1;
294 bool kilo;
295 };
296 static constexpr PhaseField PHASE_FIELDS[] PROGMEM = {
297 {&Phase::voltage_sensor_, EM4M_VOLTAGE_L1, false},
298 {&Phase::current_sensor_, EM4M_CURRENT_L1, false},
299 {&Phase::active_power_sensor_, EM4M_ACTIVE_POWER_L1, true},
300 {&Phase::reactive_power_sensor_, EM4M_REACTIVE_POWER_L1, true},
301 {&Phase::apparent_power_sensor_, EM4M_APPARENT_POWER_L1, true},
302 {&Phase::power_factor_sensor_, EM4M_POWER_FACTOR_L1, false},
303 {&Phase::import_active_energy_sensor_, EM4M_IMPORT_ACTIVE_ENERGY_L1, false},
304 {&Phase::export_active_energy_sensor_, EM4M_EXPORT_ACTIVE_ENERGY_L1, false},
305 {&Phase::import_reactive_energy_sensor_, EM4M_IMPORT_REACTIVE_ENERGY_L1, false},
306 {&Phase::export_reactive_energy_sensor_, EM4M_EXPORT_REACTIVE_ENERGY_L1, false},
307 {&Phase::apparent_energy_sensor_, EM4M_APPARENT_ENERGY_L1, false},
308 };
309
310 if (!this->publish_fields_(registers, EM4M_FIELDS, EM4M_REGISTER_COUNT))
311 return false;
312 for (const auto &entry : PHASE_FIELDS) {
313 PhaseField field;
314 progmem_memcpy(&field, &entry, sizeof(field));
315 for (uint8_t phase = 0; phase < 3; phase++) {
316 sensor::Sensor *sens = this->phases_[phase].*field.sensor;
317 if (sens != nullptr)
318 publish_float(sens, registers, field.reg_l1 + phase * EM4M_PHASE_STRIDE, field.kilo, this->word_swap_);
319 }
320 }
321 return true;
322}
323#endif
324
327 ESP_LOGD(TAG, "Skipping update: previous read cycle (%s) still in progress",
328 LOG_STR_ARG(read_state_name(this->read_state_)));
329 return;
330 }
332}
333
335 this->read_state_ = state;
336 if (state == ReadState::READ_STATE_IDLE)
337 return;
338 const ReadRange range = this->read_range_(state);
339 // A refused send gets no callback, so unwind here like the async failure paths
340 if (!this->read_input_registers(range.start, range.count))
341 this->fail_current_read_(LOG_STR("Modbus request refused"));
342}
343
345 ESP_LOGCONFIG(TAG,
346 "SELEC Meter:\n"
347 " Model: %s\n"
348 " Byte Order: %s\n"
349 " Address: 0x%02X",
350 this->model_ == Model::MODEL_EM4M ? LOG_STR_LITERAL("EM4M") : LOG_STR_LITERAL("EM2M"),
351 this->word_swap_ ? LOG_STR_LITERAL("little_endian (LSRF)") : LOG_STR_LITERAL("big_endian (MSRF)"),
352 this->address_);
353 LOG_SENSOR(" ", "Total Active Energy", this->total_active_energy_sensor_);
354 LOG_SENSOR(" ", "Import Active Energy", this->import_active_energy_sensor_);
355 LOG_SENSOR(" ", "Export Active Energy", this->export_active_energy_sensor_);
356 LOG_SENSOR(" ", "Total Reactive Energy", this->total_reactive_energy_sensor_);
357 LOG_SENSOR(" ", "Import Reactive Energy", this->import_reactive_energy_sensor_);
358 LOG_SENSOR(" ", "Export Reactive Energy", this->export_reactive_energy_sensor_);
359 LOG_SENSOR(" ", "Apparent Energy", this->apparent_energy_sensor_);
360 LOG_SENSOR(" ", "Active Power", this->active_power_sensor_);
361 LOG_SENSOR(" ", "Reactive Power", this->reactive_power_sensor_);
362 LOG_SENSOR(" ", "Apparent Power", this->apparent_power_sensor_);
363 LOG_SENSOR(" ", "Voltage", this->voltage_sensor_);
364 LOG_SENSOR(" ", "Current", this->current_sensor_);
365 LOG_SENSOR(" ", "Power Factor", this->power_factor_sensor_);
366 LOG_SENSOR(" ", "Frequency", this->frequency_sensor_);
367 LOG_SENSOR(" ", "Maximum Demand Active Power", this->maximum_demand_active_power_sensor_);
368 LOG_SENSOR(" ", "Maximum Demand Reactive Power", this->maximum_demand_reactive_power_sensor_);
369 LOG_SENSOR(" ", "Maximum Demand Apparent Power", this->maximum_demand_apparent_power_sensor_);
370#ifdef USE_SELEC_METER_EM4M
371 if (this->model_ == Model::MODEL_EM4M) {
372 LOG_SENSOR(" ", "Voltage L1-L2", this->voltage_l12_sensor_);
373 LOG_SENSOR(" ", "Voltage L2-L3", this->voltage_l23_sensor_);
374 LOG_SENSOR(" ", "Voltage L3-L1", this->voltage_l31_sensor_);
375 LOG_SENSOR(" ", "Average Voltage LL", this->average_voltage_ll_sensor_);
376 for (uint8_t phase = 0; phase < 3; phase++) {
377 const auto &p = this->phases_[phase];
378 ESP_LOGCONFIG(TAG, " Phase %c", 'A' + phase);
379 LOG_SENSOR(" ", "Voltage", p.voltage_sensor_);
380 LOG_SENSOR(" ", "Current", p.current_sensor_);
381 LOG_SENSOR(" ", "Active Power", p.active_power_sensor_);
382 LOG_SENSOR(" ", "Reactive Power", p.reactive_power_sensor_);
383 LOG_SENSOR(" ", "Apparent Power", p.apparent_power_sensor_);
384 LOG_SENSOR(" ", "Power Factor", p.power_factor_sensor_);
385 LOG_SENSOR(" ", "Import Active Energy", p.import_active_energy_sensor_);
386 LOG_SENSOR(" ", "Export Active Energy", p.export_active_energy_sensor_);
387 LOG_SENSOR(" ", "Import Reactive Energy", p.import_reactive_energy_sensor_);
388 LOG_SENSOR(" ", "Export Reactive Energy", p.export_reactive_energy_sensor_);
389 LOG_SENSOR(" ", "Apparent Energy", p.apparent_energy_sensor_);
390 }
391 LOG_SENSOR(" ", "Net Active Energy (Mains)", this->net_active_energy_mains_sensor_);
392 LOG_SENSOR(" ", "Net Reactive Energy (Mains)", this->net_reactive_energy_mains_sensor_);
393 LOG_SENSOR(" ", "Net Apparent Energy (Mains)", this->net_apparent_energy_mains_sensor_);
394 LOG_SENSOR(" ", "Net Active Energy (DG)", this->net_active_energy_dg_sensor_);
395 LOG_SENSOR(" ", "Net Reactive Energy (DG)", this->net_reactive_energy_dg_sensor_);
396 LOG_SENSOR(" ", "Net Apparent Energy (DG)", this->net_apparent_energy_dg_sensor_);
397 }
398#ifdef USE_TEXT_SENSOR
399 LOG_TEXT_SENSOR(" ", "Serial Number", this->serial_number_sensor_);
400#endif
401#ifdef USE_BINARY_SENSOR
402 LOG_BINARY_SENSOR(" ", "DG Sensing", this->dg_sensing_sensor_);
403#endif
404#endif
405}
406
407} // namespace esphome::selec_meter
uint8_t status
Definition bl0942.h:8
bool status_comm_result(bool ok)
Clears the warning when ok, else sets it with message (default "Communication failed"); returns ok.
void publish_state(bool new_state)
Publish a new state to the front-end.
binary_sensor::BinarySensor * dg_sensing_sensor_
ReadState next_read_state_after_(ReadState current) const
void start_read_(ReadState state)
void on_not_sent(std::span< const uint8_t > request_pdu) override
bool publish_fields_(std::span< const uint16_t > registers, std::span< const FloatField > fields, uint8_t min_count)
void on_read_input_registers(uint16_t start_address, std::span< const uint16_t > registers, modbus::ResponseStatus status) override
ReadRange read_range_(ReadState state) const
bool decode_em4m_(std::span< const uint16_t > registers)
void decode_serial_number_(std::span< const uint16_t > registers)
void fail_current_read_(const LogString *reason, bool durable=false)
text_sensor::TextSensor * serial_number_sensor_
bool wanted_(ReadState state) const
bool decode_em2m_(std::span< const uint16_t > registers)
void decode_dg_sensing_(std::span< const uint16_t > registers)
bool on_no_response(std::span< const uint8_t > request_pdu) override
Base-class for all sensors.
Definition sensor.h:47
void publish_state(const std::string &state)
Definition text_sensor.h:42
NameCopy * next
bool state
Definition fan.h:2
Range range
Definition msa3xx.h:0
constexpr std::optional< RegisterValueType< VALUE_TYPE > > value_at(std::span< const uint16_t > registers, uint16_t start_address, uint16_t address)
The value stored at an absolute register address, or nullopt when it is not wholly inside this respon...
std::optional< ExceptionCode > ResponseStatus
Definition modbus.h:307
ESPHOME_LOG_TAG(TAG, "selec_meter")
value[LAYOUT.day_index()]
void progmem_memcpy(void *dst, const void *src, size_t len)
Definition hal.h:56
sensor::Sensor *SelecMeter::* sensor
const uint8_t ESPHOME_WEBSERVER_INDEX_HTML[] PROGMEM
Definition web_server.h:30