diff --git a/esphome/components/selec_meter/selec_meter.cpp b/esphome/components/selec_meter/selec_meter.cpp index a11cc6a008..9a895b0e0e 100644 --- a/esphome/components/selec_meter/selec_meter.cpp +++ b/esphome/components/selec_meter/selec_meter.cpp @@ -1,54 +1,358 @@ #include "selec_meter.h" #include "selec_meter_registers.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" +#include +#include +#include + namespace esphome::selec_meter { namespace helpers = modbus::helpers; ESPHOME_LOG_TAG(TAG, "selec_meter"); -static const uint8_t MODBUS_REGISTER_COUNT = 34; // 34 x 16-bit registers +static constexpr uint8_t EM2M_REGISTER_COUNT = 34; // 34 x 16-bit registers + +// Durable refusals an optional read tolerates before it is given up on +static constexpr uint8_t MAX_OPTIONAL_READ_FAILURES = 5; + +static const LogString *read_state_name(ReadState state) { + switch (state) { + case ReadState::READ_STATE_MAIN_BLOCK: + return LOG_STR("main block"); + case ReadState::READ_STATE_SERIAL_NUMBER: + return LOG_STR("serial number"); + case ReadState::READ_STATE_DG_SENSING: + return LOG_STR("DG sensing"); + case ReadState::READ_STATE_IDLE: + default: + return LOG_STR("idle"); + } +} + +// FP32 at `reg` in the configured word order (MSRF high word first, LSRF low word first); NaN when +// `reg` is not wholly inside `registers` +static float read_float(std::span registers, uint16_t start_address, uint16_t reg, bool word_swapped) { + auto value = word_swapped ? helpers::value_at(registers, start_address, reg) + : helpers::value_at(registers, start_address, reg); + return value.value_or(NAN); +} + +// Publishes the field at `reg` (x1000 when `kilo`) unless it decodes to NaN/Inf, e.g. an unloaded +// phase's power factor; a non-finite field is skipped on its own so the others still publish. +static void publish_float(sensor::Sensor *sens, std::span registers, uint16_t reg, bool kilo, + bool word_swapped) { + float value = read_float(registers, 0, reg, word_swapped); + if (!std::isfinite(value)) { + ESP_LOGD(TAG, "Skipping non-finite value for '%s'", sens->get_name().c_str()); + return; + } + sens->publish_state(kilo ? value * 1000.0f : value); +} + +#if defined(USE_SELEC_METER_EM4M) && (defined(USE_TEXT_SENSOR) || defined(USE_BINARY_SENSOR)) +// Counts one failure of an optional read; callers only run while `failures` is below the limit +static void note_optional_read_failure(uint8_t &failures, const LogString *name) { + if (++failures == MAX_OPTIONAL_READ_FAILURES) { + ESP_LOGW(TAG, "Reading %s failed %u times in a row, giving up", LOG_STR_ARG(name), failures); + } +} +#endif + +SelecMeter::ReadRange SelecMeter::read_range_(ReadState state) const { + switch (state) { + case ReadState::READ_STATE_MAIN_BLOCK: +#ifdef USE_SELEC_METER_EM4M + if (this->model_ == Model::MODEL_EM4M) + return {0, EM4M_REGISTER_COUNT}; +#endif + return {0, EM2M_REGISTER_COUNT}; + case ReadState::READ_STATE_SERIAL_NUMBER: + return {EM4M_SERIAL_NUMBER, 2}; + case ReadState::READ_STATE_DG_SENSING: + return {EM4M_DG_SENSING, 2}; + case ReadState::READ_STATE_IDLE: + default: + return {0, 0}; + } +} + +bool SelecMeter::wanted_(ReadState state) const { + switch (state) { + case ReadState::READ_STATE_MAIN_BLOCK: + return true; +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) + case ReadState::READ_STATE_SERIAL_NUMBER: + return this->serial_number_sensor_ != nullptr && this->serial_number_failures_ < MAX_OPTIONAL_READ_FAILURES; +#endif +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) + case ReadState::READ_STATE_DG_SENSING: + return this->dg_sensing_sensor_ != nullptr && this->dg_sensing_failures_ < MAX_OPTIONAL_READ_FAILURES; +#endif + default: + return false; + } +} + +ReadState SelecMeter::next_read_state_after_(ReadState current) const { + for (auto next = static_cast(current) + 1; next < static_cast(ReadState::READ_STATE_IDLE); next++) { + if (this->wanted_(static_cast(next))) + return static_cast(next); + } + return ReadState::READ_STATE_IDLE; +} void SelecMeter::on_read_input_registers(uint16_t start_address, std::span registers, modbus::ResponseStatus status) { - if (!modbus::succeeded(status)) - return; // the hub already logs exception responses - - // Publish a sensor if both of its registers are in this response; skipping absent registers keeps - // this correct for any read range, so the poll may be split into multiple requests. - // Values are 32-bit floats, low word first. - auto publish = [&](sensor::Sensor *sensor, uint16_t reg, float unit) -> void { - if (sensor == nullptr) - return; - if (auto value = helpers::value_at(registers, start_address, reg)) - sensor->publish_state(*value * unit); + ReadState current = this->read_state_; + if (current == ReadState::READ_STATE_IDLE) { + ESP_LOGW(TAG, "Unexpected response while idle, dropping"); + return; + } + // start_address comes from the exact request this answers, so a stale reply cannot match another read + if (start_address != this->read_range_(current).start) { + this->fail_current_read_(LOG_STR("Response does not match pending read"), /*durable=*/false); + return; + } + if (status.has_value()) { + const auto code = *status; + ESP_LOGW(TAG, "Modbus error 0x%02X while reading %s", static_cast(code), + LOG_STR_ARG(read_state_name(current))); + // Illegal function/address are durable; busy, acknowledge and the rest are transient + const bool durable = + code == modbus::ExceptionCode::ILLEGAL_FUNCTION || code == modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS; + this->fail_current_read_(nullptr, durable); + return; + } + switch (current) { + case ReadState::READ_STATE_MAIN_BLOCK: { +#ifdef USE_SELEC_METER_EM4M + bool ok = this->model_ == Model::MODEL_EM4M ? this->decode_em4m_(registers) : this->decode_em2m_(registers); +#else + bool ok = this->decode_em2m_(registers); +#endif + if (ok) { + this->status_clear_warning(); + } else { + this->status_set_warning(); + } + break; + } +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) + case ReadState::READ_STATE_SERIAL_NUMBER: + this->decode_serial_number_(registers); + break; +#endif +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) + case ReadState::READ_STATE_DG_SENSING: + this->decode_dg_sensing_(registers); + break; +#endif + default: + break; + } + // Safe per modbus.h's callback contract: sending from inside a callback is picked up normally. + this->start_read_(this->next_read_state_after_(current)); +} + +void SelecMeter::fail_current_read_(const LogString *reason, bool durable) { + if (this->read_state_ == ReadState::READ_STATE_IDLE) { + ESP_LOGW(TAG, "Unexpected failure while idle, dropping"); + return; + } + ReadState failed_state = this->read_state_; + if (reason != nullptr) { + ESP_LOGW(TAG, "%s while reading %s", LOG_STR_ARG(reason), LOG_STR_ARG(read_state_name(failed_state))); + } + // Only the main block drives component health; an unsupported optional read is not a fault + if (failed_state == ReadState::READ_STATE_MAIN_BLOCK) + this->status_set_warning(); +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) + if (failed_state == ReadState::READ_STATE_SERIAL_NUMBER && durable) + note_optional_read_failure(this->serial_number_failures_, read_state_name(failed_state)); +#endif +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) + if (failed_state == ReadState::READ_STATE_DG_SENSING && durable) + note_optional_read_failure(this->dg_sensing_failures_, read_state_name(failed_state)); +#endif + this->start_read_(this->next_read_state_after_(failed_state)); +} + +bool SelecMeter::on_no_response(std::span /*request_pdu*/) { + this->fail_current_read_(LOG_STR("No Modbus response")); + return false; +} + +void SelecMeter::on_not_sent(std::span /*request_pdu*/) { + this->fail_current_read_(LOG_STR("Modbus request not sent")); +} + +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) +void SelecMeter::decode_serial_number_(std::span registers) { + auto serial = + this->word_swap_ + ? helpers::value_at(registers, EM4M_SERIAL_NUMBER, EM4M_SERIAL_NUMBER) + : helpers::value_at(registers, EM4M_SERIAL_NUMBER, EM4M_SERIAL_NUMBER); + if (!serial) { + ESP_LOGW(TAG, "Unexpected response size for serial number"); + note_optional_read_failure(this->serial_number_failures_, read_state_name(ReadState::READ_STATE_SERIAL_NUMBER)); + return; + } + char buf[9]; + snprintf(buf, sizeof(buf), "%08" PRIX32, *serial); + this->serial_number_sensor_->publish_state(buf); + // The serial number never changes, so stop reading it + this->serial_number_failures_ = MAX_OPTIONAL_READ_FAILURES; +} +#endif + +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) +void SelecMeter::decode_dg_sensing_(std::span registers) { + float value = read_float(registers, EM4M_DG_SENSING, EM4M_DG_SENSING, this->word_swap_); + if (!std::isfinite(value)) { + ESP_LOGW(TAG, "Short or non-finite DG sensing response, ignoring"); + note_optional_read_failure(this->dg_sensing_failures_, read_state_name(ReadState::READ_STATE_DG_SENSING)); + return; + } + this->dg_sensing_sensor_->publish_state(value != 0); + this->dg_sensing_failures_ = 0; +} +#endif + +bool SelecMeter::publish_fields_(std::span registers, std::span fields, + uint8_t min_count) { + if (registers.size() < min_count) { + ESP_LOGW(TAG, "Short response: %u registers", static_cast(registers.size())); + return false; + } + for (const auto &entry : fields) { + FloatField field; + progmem_memcpy(&field, &entry, sizeof(field)); + sensor::Sensor *sens = this->*field.sensor; + if (sens != nullptr) + publish_float(sens, registers, field.reg, field.kilo, this->word_swap_); + } + return true; +} + +bool SelecMeter::decode_em2m_(std::span registers) { + static constexpr FloatField EM2M_FIELDS[] PROGMEM = { + {&SelecMeter::total_active_energy_sensor_, SELEC_TOTAL_ACTIVE_ENERGY, false}, + {&SelecMeter::import_active_energy_sensor_, SELEC_IMPORT_ACTIVE_ENERGY, false}, + {&SelecMeter::export_active_energy_sensor_, SELEC_EXPORT_ACTIVE_ENERGY, false}, + {&SelecMeter::total_reactive_energy_sensor_, SELEC_TOTAL_REACTIVE_ENERGY, false}, + {&SelecMeter::import_reactive_energy_sensor_, SELEC_IMPORT_REACTIVE_ENERGY, false}, + {&SelecMeter::export_reactive_energy_sensor_, SELEC_EXPORT_REACTIVE_ENERGY, false}, + {&SelecMeter::apparent_energy_sensor_, SELEC_APPARENT_ENERGY, false}, + {&SelecMeter::active_power_sensor_, SELEC_ACTIVE_POWER, true}, + {&SelecMeter::reactive_power_sensor_, SELEC_REACTIVE_POWER, true}, + {&SelecMeter::apparent_power_sensor_, SELEC_APPARENT_POWER, true}, + {&SelecMeter::voltage_sensor_, SELEC_VOLTAGE, false}, + {&SelecMeter::current_sensor_, SELEC_CURRENT, false}, + {&SelecMeter::power_factor_sensor_, SELEC_POWER_FACTOR, false}, + {&SelecMeter::frequency_sensor_, SELEC_FREQUENCY, false}, + {&SelecMeter::maximum_demand_active_power_sensor_, SELEC_MAXIMUM_DEMAND_ACTIVE_POWER, true}, + {&SelecMeter::maximum_demand_reactive_power_sensor_, SELEC_MAXIMUM_DEMAND_REACTIVE_POWER, true}, + {&SelecMeter::maximum_demand_apparent_power_sensor_, SELEC_MAXIMUM_DEMAND_APPARENT_POWER, true}, }; + return this->publish_fields_(registers, EM2M_FIELDS, EM2M_REGISTER_COUNT); +} + +#ifdef USE_SELEC_METER_EM4M +bool SelecMeter::decode_em4m_(std::span registers) { + static constexpr FloatField EM4M_FIELDS[] PROGMEM = { + // Common quantities, shared sensor keys with EM2M + {&SelecMeter::voltage_sensor_, EM4M_VOLTAGE, false}, + {&SelecMeter::current_sensor_, EM4M_CURRENT, false}, + {&SelecMeter::active_power_sensor_, EM4M_ACTIVE_POWER, true}, + {&SelecMeter::reactive_power_sensor_, EM4M_REACTIVE_POWER, true}, + {&SelecMeter::apparent_power_sensor_, EM4M_APPARENT_POWER, true}, + {&SelecMeter::power_factor_sensor_, EM4M_POWER_FACTOR, false}, + {&SelecMeter::frequency_sensor_, EM4M_FREQUENCY, false}, + {&SelecMeter::maximum_demand_active_power_sensor_, EM4M_MAXIMUM_DEMAND_ACTIVE_POWER, true}, + {&SelecMeter::maximum_demand_reactive_power_sensor_, EM4M_MAXIMUM_DEMAND_REACTIVE_POWER, true}, + {&SelecMeter::maximum_demand_apparent_power_sensor_, EM4M_MAXIMUM_DEMAND_APPARENT_POWER, true}, + {&SelecMeter::import_active_energy_sensor_, EM4M_IMPORT_ACTIVE_ENERGY, false}, + {&SelecMeter::export_active_energy_sensor_, EM4M_EXPORT_ACTIVE_ENERGY, false}, + {&SelecMeter::import_reactive_energy_sensor_, EM4M_IMPORT_REACTIVE_ENERGY, false}, + {&SelecMeter::export_reactive_energy_sensor_, EM4M_EXPORT_REACTIVE_ENERGY, false}, + // Line-to-line and net (mains/DG) quantities have no per-phase equivalent + {&SelecMeter::voltage_l12_sensor_, EM4M_VOLTAGE_L12, false}, + {&SelecMeter::voltage_l23_sensor_, EM4M_VOLTAGE_L23, false}, + {&SelecMeter::voltage_l31_sensor_, EM4M_VOLTAGE_L31, false}, + {&SelecMeter::average_voltage_ll_sensor_, EM4M_AVERAGE_VOLTAGE_LL, false}, + {&SelecMeter::net_active_energy_mains_sensor_, EM4M_NET_ACTIVE_ENERGY_MAINS, false}, + {&SelecMeter::net_reactive_energy_mains_sensor_, EM4M_NET_REACTIVE_ENERGY_MAINS, false}, + {&SelecMeter::net_apparent_energy_mains_sensor_, EM4M_NET_APPARENT_ENERGY_MAINS, false}, + {&SelecMeter::net_active_energy_dg_sensor_, EM4M_NET_ACTIVE_ENERGY_DG, false}, + {&SelecMeter::net_reactive_energy_dg_sensor_, EM4M_NET_REACTIVE_ENERGY_DG, false}, + {&SelecMeter::net_apparent_energy_dg_sensor_, EM4M_NET_APPARENT_ENERGY_DG, false}, + }; + + // reg_l1 is the L1 register; L2/L3 follow EM4M_PHASE_STRIDE apart + struct PhaseField { + sensor::Sensor *Phase::*sensor; + uint16_t reg_l1; + bool kilo; + }; + static constexpr PhaseField PHASE_FIELDS[] PROGMEM = { + {&Phase::voltage_sensor_, EM4M_VOLTAGE_L1, false}, + {&Phase::current_sensor_, EM4M_CURRENT_L1, false}, + {&Phase::active_power_sensor_, EM4M_ACTIVE_POWER_L1, true}, + {&Phase::reactive_power_sensor_, EM4M_REACTIVE_POWER_L1, true}, + {&Phase::apparent_power_sensor_, EM4M_APPARENT_POWER_L1, true}, + {&Phase::power_factor_sensor_, EM4M_POWER_FACTOR_L1, false}, + {&Phase::import_active_energy_sensor_, EM4M_IMPORT_ACTIVE_ENERGY_L1, false}, + {&Phase::export_active_energy_sensor_, EM4M_EXPORT_ACTIVE_ENERGY_L1, false}, + {&Phase::import_reactive_energy_sensor_, EM4M_IMPORT_REACTIVE_ENERGY_L1, false}, + {&Phase::export_reactive_energy_sensor_, EM4M_EXPORT_REACTIVE_ENERGY_L1, false}, + {&Phase::apparent_energy_sensor_, EM4M_APPARENT_ENERGY_L1, false}, + }; + + if (!this->publish_fields_(registers, EM4M_FIELDS, EM4M_REGISTER_COUNT)) + return false; + for (const auto &entry : PHASE_FIELDS) { + PhaseField field; + progmem_memcpy(&field, &entry, sizeof(field)); + for (uint8_t phase = 0; phase < 3; phase++) { + sensor::Sensor *sens = this->phases_[phase].*field.sensor; + if (sens != nullptr) + publish_float(sens, registers, field.reg_l1 + phase * EM4M_PHASE_STRIDE, field.kilo, this->word_swap_); + } + } + return true; +} +#endif + +void SelecMeter::update() { + if (this->read_state_ != ReadState::READ_STATE_IDLE) { + ESP_LOGD(TAG, "Skipping update: previous read cycle (%s) still in progress", + LOG_STR_ARG(read_state_name(this->read_state_))); + return; + } + this->start_read_(ReadState::READ_STATE_MAIN_BLOCK); +} - publish(this->total_active_energy_sensor_, SELEC_TOTAL_ACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->import_active_energy_sensor_, SELEC_IMPORT_ACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->export_active_energy_sensor_, SELEC_EXPORT_ACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->total_reactive_energy_sensor_, SELEC_TOTAL_REACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->import_reactive_energy_sensor_, SELEC_IMPORT_REACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->export_reactive_energy_sensor_, SELEC_EXPORT_REACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->apparent_energy_sensor_, SELEC_APPARENT_ENERGY, NO_DEC_UNIT); - publish(this->active_power_sensor_, SELEC_ACTIVE_POWER, MULTIPLY_THOUSAND_UNIT); - publish(this->reactive_power_sensor_, SELEC_REACTIVE_POWER, MULTIPLY_THOUSAND_UNIT); - publish(this->apparent_power_sensor_, SELEC_APPARENT_POWER, MULTIPLY_THOUSAND_UNIT); - publish(this->voltage_sensor_, SELEC_VOLTAGE, NO_DEC_UNIT); - publish(this->current_sensor_, SELEC_CURRENT, NO_DEC_UNIT); - publish(this->power_factor_sensor_, SELEC_POWER_FACTOR, NO_DEC_UNIT); - publish(this->frequency_sensor_, SELEC_FREQUENCY, NO_DEC_UNIT); - publish(this->maximum_demand_active_power_sensor_, SELEC_MAXIMUM_DEMAND_ACTIVE_POWER, MULTIPLY_THOUSAND_UNIT); - publish(this->maximum_demand_reactive_power_sensor_, SELEC_MAXIMUM_DEMAND_REACTIVE_POWER, MULTIPLY_THOUSAND_UNIT); - publish(this->maximum_demand_apparent_power_sensor_, SELEC_MAXIMUM_DEMAND_APPARENT_POWER, MULTIPLY_THOUSAND_UNIT); +void SelecMeter::start_read_(ReadState state) { + this->read_state_ = state; + if (state == ReadState::READ_STATE_IDLE) + return; + const ReadRange range = this->read_range_(state); + // A refused send gets no callback, so unwind here like the async failure paths + if (!this->read_input_registers(range.start, range.count)) + this->fail_current_read_(LOG_STR("Modbus request refused")); } -void SelecMeter::update() { this->read_input_registers(0, MODBUS_REGISTER_COUNT); } void SelecMeter::dump_config() { ESP_LOGCONFIG(TAG, "SELEC Meter:\n" + " Model: %s\n" + " Byte Order: %s\n" " Address: 0x%02X", + this->model_ == Model::MODEL_EM4M ? LOG_STR_LITERAL("EM4M") : LOG_STR_LITERAL("EM2M"), + this->word_swap_ ? LOG_STR_LITERAL("little_endian (LSRF)") : LOG_STR_LITERAL("big_endian (MSRF)"), this->address_); LOG_SENSOR(" ", "Total Active Energy", this->total_active_energy_sensor_); LOG_SENSOR(" ", "Import Active Energy", this->import_active_energy_sensor_); @@ -67,6 +371,41 @@ void SelecMeter::dump_config() { LOG_SENSOR(" ", "Maximum Demand Active Power", this->maximum_demand_active_power_sensor_); LOG_SENSOR(" ", "Maximum Demand Reactive Power", this->maximum_demand_reactive_power_sensor_); LOG_SENSOR(" ", "Maximum Demand Apparent Power", this->maximum_demand_apparent_power_sensor_); +#ifdef USE_SELEC_METER_EM4M + if (this->model_ == Model::MODEL_EM4M) { + LOG_SENSOR(" ", "Voltage L1-L2", this->voltage_l12_sensor_); + LOG_SENSOR(" ", "Voltage L2-L3", this->voltage_l23_sensor_); + LOG_SENSOR(" ", "Voltage L3-L1", this->voltage_l31_sensor_); + LOG_SENSOR(" ", "Average Voltage LL", this->average_voltage_ll_sensor_); + for (uint8_t phase = 0; phase < 3; phase++) { + const auto &p = this->phases_[phase]; + ESP_LOGCONFIG(TAG, " Phase %c", 'A' + phase); + LOG_SENSOR(" ", "Voltage", p.voltage_sensor_); + LOG_SENSOR(" ", "Current", p.current_sensor_); + LOG_SENSOR(" ", "Active Power", p.active_power_sensor_); + LOG_SENSOR(" ", "Reactive Power", p.reactive_power_sensor_); + LOG_SENSOR(" ", "Apparent Power", p.apparent_power_sensor_); + LOG_SENSOR(" ", "Power Factor", p.power_factor_sensor_); + LOG_SENSOR(" ", "Import Active Energy", p.import_active_energy_sensor_); + LOG_SENSOR(" ", "Export Active Energy", p.export_active_energy_sensor_); + LOG_SENSOR(" ", "Import Reactive Energy", p.import_reactive_energy_sensor_); + LOG_SENSOR(" ", "Export Reactive Energy", p.export_reactive_energy_sensor_); + LOG_SENSOR(" ", "Apparent Energy", p.apparent_energy_sensor_); + } + LOG_SENSOR(" ", "Net Active Energy (Mains)", this->net_active_energy_mains_sensor_); + LOG_SENSOR(" ", "Net Reactive Energy (Mains)", this->net_reactive_energy_mains_sensor_); + LOG_SENSOR(" ", "Net Apparent Energy (Mains)", this->net_apparent_energy_mains_sensor_); + LOG_SENSOR(" ", "Net Active Energy (DG)", this->net_active_energy_dg_sensor_); + LOG_SENSOR(" ", "Net Reactive Energy (DG)", this->net_reactive_energy_dg_sensor_); + LOG_SENSOR(" ", "Net Apparent Energy (DG)", this->net_apparent_energy_dg_sensor_); + } +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Serial Number", this->serial_number_sensor_); +#endif +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "DG Sensing", this->dg_sensing_sensor_); +#endif +#endif } } // namespace esphome::selec_meter diff --git a/esphome/components/selec_meter/selec_meter.h b/esphome/components/selec_meter/selec_meter.h index 470242c918..98fea2987e 100644 --- a/esphome/components/selec_meter/selec_meter.h +++ b/esphome/components/selec_meter/selec_meter.h @@ -1,8 +1,15 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/log.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif #include @@ -15,6 +22,24 @@ namespace esphome::selec_meter { public: \ void set_##name##_sensor(sensor::Sensor *(name)) { this->name##_sensor_ = name; } +// Per-phase sensor in the Phase struct, setter indexed by phase (0 = L1/A, 1 = L2/B, 2 = L3/C) +#define SELEC_METER_PHASE_SENSOR(name) \ + void set_##name##_sensor(uint8_t phase, sensor::Sensor *(name)) { this->phases_[phase].name##_sensor_ = name; } + +enum class Model : uint8_t { + MODEL_EM2M, + MODEL_EM4M, +}; + +// Read cycle steps in the order they run. The extra reads (serial number, DG sensing) sit far outside +// the main block and get their own transaction. +enum class ReadState : uint8_t { + READ_STATE_MAIN_BLOCK, + READ_STATE_SERIAL_NUMBER, + READ_STATE_DG_SENSING, + READ_STATE_IDLE, +}; + class SelecMeter final : public PollingComponent, public modbus::ModbusClientDevice { public: SELEC_METER_SENSOR(total_active_energy) @@ -35,12 +60,122 @@ class SelecMeter final : public PollingComponent, public modbus::ModbusClientDev SELEC_METER_SENSOR(maximum_demand_reactive_power) SELEC_METER_SENSOR(maximum_demand_apparent_power) +#ifdef USE_SELEC_METER_EM4M + // EM4M per-phase sensors, indexed by phase (see Phase below) + SELEC_METER_PHASE_SENSOR(voltage) + SELEC_METER_PHASE_SENSOR(current) + SELEC_METER_PHASE_SENSOR(active_power) + SELEC_METER_PHASE_SENSOR(reactive_power) + SELEC_METER_PHASE_SENSOR(apparent_power) + SELEC_METER_PHASE_SENSOR(power_factor) + SELEC_METER_PHASE_SENSOR(import_active_energy) + SELEC_METER_PHASE_SENSOR(export_active_energy) + SELEC_METER_PHASE_SENSOR(import_reactive_energy) + SELEC_METER_PHASE_SENSOR(export_reactive_energy) + SELEC_METER_PHASE_SENSOR(apparent_energy) + + // Line-to-line and aggregate sensors, not tied to one phase + SELEC_METER_SENSOR(voltage_l12) + SELEC_METER_SENSOR(voltage_l23) + SELEC_METER_SENSOR(voltage_l31) + SELEC_METER_SENSOR(average_voltage_ll) + SELEC_METER_SENSOR(net_active_energy_mains) + SELEC_METER_SENSOR(net_reactive_energy_mains) + SELEC_METER_SENSOR(net_apparent_energy_mains) + SELEC_METER_SENSOR(net_active_energy_dg) + SELEC_METER_SENSOR(net_reactive_energy_dg) + SELEC_METER_SENSOR(net_apparent_energy_dg) +#endif + + void set_model(Model model) { this->model_ = model; } + // true: registers are word-swapped (LSRF / Mid Little Endian, CDAB byte order) + // false: registers are in the meter's straightforward order (MSRF / Big Endian, ABCD byte order) + void set_word_swap(bool word_swap) { this->word_swap_ = word_swap; } +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) + void set_serial_number_sensor(text_sensor::TextSensor *serial_number) { this->serial_number_sensor_ = serial_number; } +#endif +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) + void set_dg_sensing_sensor(binary_sensor::BinarySensor *dg_sensing) { this->dg_sensing_sensor_ = dg_sensing; } +#endif + void update() override; void on_read_input_registers(uint16_t start_address, std::span registers, modbus::ResponseStatus status) override; + bool on_no_response(std::span request_pdu) override; + void on_not_sent(std::span request_pdu) override; void dump_config() override; + + protected: + // One float field of the main block: target sensor, register and whether it is published x1000 + struct FloatField { + sensor::Sensor *SelecMeter::*sensor; + uint16_t reg; + bool kilo; + }; + + // Publishes each configured field in `fields` (a PROGMEM table); false on a payload shorter than + // `min_count`, and the caller sets or clears the warning from the result + bool publish_fields_(std::span registers, std::span fields, uint8_t min_count); + bool decode_em2m_(std::span registers); +#ifdef USE_SELEC_METER_EM4M + bool decode_em4m_(std::span registers); +#ifdef USE_TEXT_SENSOR + void decode_serial_number_(std::span registers); +#endif +#ifdef USE_BINARY_SENSOR + void decode_dg_sensing_(std::span registers); +#endif +#endif + // First register and register count of the read for `state` + struct ReadRange { + uint16_t start; + uint8_t count; + }; + ReadRange read_range_(ReadState state) const; + // Whether the read cycle should run `state` on this pass + bool wanted_(ReadState state) const; + // Next wanted step after `current`, so one failed range does not cancel the other reads + ReadState next_read_state_after_(ReadState current) const; + // Sends the read for `state`; safe from a callback per the modbus callback contract + void start_read_(ReadState state); + // Clears the pending read and advances the chain. Only a MAIN_BLOCK failure sets the warning and + // only a `durable` refusal counts toward MAX_OPTIONAL_READ_FAILURES; `reason` nullptr means already logged + void fail_current_read_(const LogString *reason, bool durable = false); + +#ifdef USE_SELEC_METER_EM4M + // phases_[0..2] are L1/L2/L3 (A/B/C), EM4M_PHASE_STRIDE registers apart + struct Phase { + sensor::Sensor *voltage_sensor_{nullptr}; + sensor::Sensor *current_sensor_{nullptr}; + sensor::Sensor *active_power_sensor_{nullptr}; + sensor::Sensor *reactive_power_sensor_{nullptr}; + sensor::Sensor *apparent_power_sensor_{nullptr}; + sensor::Sensor *power_factor_sensor_{nullptr}; + sensor::Sensor *import_active_energy_sensor_{nullptr}; + sensor::Sensor *export_active_energy_sensor_{nullptr}; + sensor::Sensor *import_reactive_energy_sensor_{nullptr}; + sensor::Sensor *export_reactive_energy_sensor_{nullptr}; + sensor::Sensor *apparent_energy_sensor_{nullptr}; + }; + Phase phases_[3]; +#endif + + Model model_{Model::MODEL_EM2M}; + bool word_swap_{false}; + ReadState read_state_{ReadState::READ_STATE_IDLE}; + + // An optional read stops once its counter reaches MAX_OPTIONAL_READ_FAILURES +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) + text_sensor::TextSensor *serial_number_sensor_{nullptr}; + // Also set to the limit once the serial number is published, since it never changes + uint8_t serial_number_failures_{0}; +#endif +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) + binary_sensor::BinarySensor *dg_sensing_sensor_{nullptr}; + uint8_t dg_sensing_failures_{0}; +#endif }; } // namespace esphome::selec_meter diff --git a/esphome/components/selec_meter/selec_meter_registers.h b/esphome/components/selec_meter/selec_meter_registers.h index d299560aab..e7678ccabe 100644 --- a/esphome/components/selec_meter/selec_meter_registers.h +++ b/esphome/components/selec_meter/selec_meter_registers.h @@ -1,30 +1,117 @@ #pragma once +#include + namespace esphome::selec_meter { -static const float TWO_DEC_UNIT = 0.01; -static const float ONE_DEC_UNIT = 0.1; -static const float NO_DEC_UNIT = 1; -static const float MULTIPLY_TEN_UNIT = 10; -static const float MULTIPLY_THOUSAND_UNIT = 1000; - /* PHASE STATUS REGISTERS */ -static const uint16_t SELEC_TOTAL_ACTIVE_ENERGY = 0x0000; -static const uint16_t SELEC_IMPORT_ACTIVE_ENERGY = 0x0002; -static const uint16_t SELEC_EXPORT_ACTIVE_ENERGY = 0x0004; -static const uint16_t SELEC_TOTAL_REACTIVE_ENERGY = 0x0006; -static const uint16_t SELEC_IMPORT_REACTIVE_ENERGY = 0x0008; -static const uint16_t SELEC_EXPORT_REACTIVE_ENERGY = 0x000A; -static const uint16_t SELEC_APPARENT_ENERGY = 0x000C; -static const uint16_t SELEC_ACTIVE_POWER = 0x000E; -static const uint16_t SELEC_REACTIVE_POWER = 0x0010; -static const uint16_t SELEC_APPARENT_POWER = 0x0012; -static const uint16_t SELEC_VOLTAGE = 0x0014; -static const uint16_t SELEC_CURRENT = 0x0016; -static const uint16_t SELEC_POWER_FACTOR = 0x0018; -static const uint16_t SELEC_FREQUENCY = 0x001A; -static const uint16_t SELEC_MAXIMUM_DEMAND_ACTIVE_POWER = 0x001C; -static const uint16_t SELEC_MAXIMUM_DEMAND_REACTIVE_POWER = 0x001E; -static const uint16_t SELEC_MAXIMUM_DEMAND_APPARENT_POWER = 0x0020; +static constexpr uint16_t SELEC_TOTAL_ACTIVE_ENERGY = 0x0000; +static constexpr uint16_t SELEC_IMPORT_ACTIVE_ENERGY = 0x0002; +static constexpr uint16_t SELEC_EXPORT_ACTIVE_ENERGY = 0x0004; +static constexpr uint16_t SELEC_TOTAL_REACTIVE_ENERGY = 0x0006; +static constexpr uint16_t SELEC_IMPORT_REACTIVE_ENERGY = 0x0008; +static constexpr uint16_t SELEC_EXPORT_REACTIVE_ENERGY = 0x000A; +static constexpr uint16_t SELEC_APPARENT_ENERGY = 0x000C; +static constexpr uint16_t SELEC_ACTIVE_POWER = 0x000E; +static constexpr uint16_t SELEC_REACTIVE_POWER = 0x0010; +static constexpr uint16_t SELEC_APPARENT_POWER = 0x0012; +static constexpr uint16_t SELEC_VOLTAGE = 0x0014; +static constexpr uint16_t SELEC_CURRENT = 0x0016; +static constexpr uint16_t SELEC_POWER_FACTOR = 0x0018; +static constexpr uint16_t SELEC_FREQUENCY = 0x001A; +static constexpr uint16_t SELEC_MAXIMUM_DEMAND_ACTIVE_POWER = 0x001C; +static constexpr uint16_t SELEC_MAXIMUM_DEMAND_REACTIVE_POWER = 0x001E; +static constexpr uint16_t SELEC_MAXIMUM_DEMAND_APPARENT_POWER = 0x0020; + +/* EM4M REGISTERS -- offsets are register indices from input register 30000 */ +static constexpr uint8_t EM4M_REGISTER_COUNT = 114; // 114 x 16-bit registers + +/* EM4M -- common quantities shared with the EM2M sensor keys */ +static constexpr uint16_t EM4M_VOLTAGE = 0x0006; // Average Voltage LN +static constexpr uint16_t EM4M_CURRENT = 0x0016; // Average Current +static constexpr uint16_t EM4M_ACTIVE_POWER = 0x002A; // Total kW +static constexpr uint16_t EM4M_REACTIVE_POWER = 0x002C; // Total kVAr +static constexpr uint16_t EM4M_APPARENT_POWER = 0x002E; // Total kVA +static constexpr uint16_t EM4M_POWER_FACTOR = 0x0036; // Average PF +static constexpr uint16_t EM4M_FREQUENCY = 0x0038; +static constexpr uint16_t EM4M_MAXIMUM_DEMAND_ACTIVE_POWER = 0x0046; +static constexpr uint16_t EM4M_MAXIMUM_DEMAND_REACTIVE_POWER = 0x0048; +static constexpr uint16_t EM4M_MAXIMUM_DEMAND_APPARENT_POWER = 0x004A; +static constexpr uint16_t EM4M_IMPORT_ACTIVE_ENERGY = 0x0058; // Total kWh - Import +static constexpr uint16_t EM4M_EXPORT_ACTIVE_ENERGY = 0x005A; // Total kWh - Export +static constexpr uint16_t EM4M_IMPORT_REACTIVE_ENERGY = 0x0068; // Total kVArh - Import +static constexpr uint16_t EM4M_EXPORT_REACTIVE_ENERGY = 0x006A; // Total kVArh - Export +static constexpr uint16_t EM4M_AVERAGE_VOLTAGE_LL = 0x000E; // Average Voltage LL + +// Net (import - export) energy totals, split by source. Only meaningful when the +// meter's "Dual Source" setting is Yes; DG registers read 0 on single-source installs. +static constexpr uint16_t EM4M_NET_ACTIVE_ENERGY_MAINS = 0x003A; // Total Net kWh (MAINS) +static constexpr uint16_t EM4M_NET_REACTIVE_ENERGY_MAINS = 0x003C; // Total Net kVArh (MAINS) +static constexpr uint16_t EM4M_NET_APPARENT_ENERGY_MAINS = 0x003E; // Total Net kVAh (MAINS) +static constexpr uint16_t EM4M_NET_ACTIVE_ENERGY_DG = 0x0040; // Total Net kWh (DG) +static constexpr uint16_t EM4M_NET_REACTIVE_ENERGY_DG = 0x0042; // Total Net kVArh (DG) +static constexpr uint16_t EM4M_NET_APPARENT_ENERGY_DG = 0x0044; // Total Net kVAh (DG) + +/* EM4M -- per-phase quantities, no EM2M equivalent */ +static constexpr uint16_t EM4M_VOLTAGE_L1 = 0x0000; // Voltage V1N +static constexpr uint16_t EM4M_VOLTAGE_L2 = 0x0002; // Voltage V2N +static constexpr uint16_t EM4M_VOLTAGE_L3 = 0x0004; // Voltage V3N +static constexpr uint16_t EM4M_VOLTAGE_L12 = 0x0008; // Voltage V12 +static constexpr uint16_t EM4M_VOLTAGE_L23 = 0x000A; // Voltage V23 +static constexpr uint16_t EM4M_VOLTAGE_L31 = 0x000C; // Voltage V31 +static constexpr uint16_t EM4M_CURRENT_L1 = 0x0010; +static constexpr uint16_t EM4M_CURRENT_L2 = 0x0012; +static constexpr uint16_t EM4M_CURRENT_L3 = 0x0014; +static constexpr uint16_t EM4M_ACTIVE_POWER_L1 = 0x0018; // kW1 +static constexpr uint16_t EM4M_ACTIVE_POWER_L2 = 0x001A; // kW2 +static constexpr uint16_t EM4M_ACTIVE_POWER_L3 = 0x001C; // kW3 +static constexpr uint16_t EM4M_REACTIVE_POWER_L1 = 0x001E; // kVAr1 +static constexpr uint16_t EM4M_REACTIVE_POWER_L2 = 0x0020; // kVAr2 +static constexpr uint16_t EM4M_REACTIVE_POWER_L3 = 0x0022; // kVAr3 +static constexpr uint16_t EM4M_APPARENT_POWER_L1 = 0x0024; // kVA1 +static constexpr uint16_t EM4M_APPARENT_POWER_L2 = 0x0026; // kVA2 +static constexpr uint16_t EM4M_APPARENT_POWER_L3 = 0x0028; // kVA3 +static constexpr uint16_t EM4M_POWER_FACTOR_L1 = 0x0030; +static constexpr uint16_t EM4M_POWER_FACTOR_L2 = 0x0032; +static constexpr uint16_t EM4M_POWER_FACTOR_L3 = 0x0034; +static constexpr uint16_t EM4M_IMPORT_ACTIVE_ENERGY_L1 = 0x004C; // kWh1 - Import +static constexpr uint16_t EM4M_IMPORT_ACTIVE_ENERGY_L2 = 0x004E; // kWh2 - Import +static constexpr uint16_t EM4M_IMPORT_ACTIVE_ENERGY_L3 = 0x0050; // kWh3 - Import +static constexpr uint16_t EM4M_EXPORT_ACTIVE_ENERGY_L1 = 0x0052; // kWh1 - Export +static constexpr uint16_t EM4M_EXPORT_ACTIVE_ENERGY_L2 = 0x0054; // kWh2 - Export +static constexpr uint16_t EM4M_EXPORT_ACTIVE_ENERGY_L3 = 0x0056; // kWh3 - Export +static constexpr uint16_t EM4M_IMPORT_REACTIVE_ENERGY_L1 = 0x005C; // kVArh1 - Import +static constexpr uint16_t EM4M_IMPORT_REACTIVE_ENERGY_L2 = 0x005E; // kVArh2 - Import +static constexpr uint16_t EM4M_IMPORT_REACTIVE_ENERGY_L3 = 0x0060; // kVArh3 - Import +static constexpr uint16_t EM4M_EXPORT_REACTIVE_ENERGY_L1 = 0x0062; // kVArh1 - Export +static constexpr uint16_t EM4M_EXPORT_REACTIVE_ENERGY_L2 = 0x0064; // kVArh2 - Export +static constexpr uint16_t EM4M_EXPORT_REACTIVE_ENERGY_L3 = 0x0066; // kVArh3 - Export +static constexpr uint16_t EM4M_APPARENT_ENERGY_L1 = 0x006C; // kVAh-1 +static constexpr uint16_t EM4M_APPARENT_ENERGY_L2 = 0x006E; // kVAh-2 +static constexpr uint16_t EM4M_APPARENT_ENERGY_L3 = 0x0070; // kVAh-3 + +// The decoder reads L2/L3 as the L1 register plus one or two strides; the L2/L3 names above +// document the datasheet and are checked against that stride here. +static constexpr uint16_t EM4M_PHASE_STRIDE = 2; +#define SELEC_EM4M_CHECK_STRIDE(name) \ + static_assert(EM4M_##name##_L2 == EM4M_##name##_L1 + EM4M_PHASE_STRIDE && \ + EM4M_##name##_L3 == EM4M_##name##_L1 + 2 * EM4M_PHASE_STRIDE, \ + #name " per-phase registers must be EM4M_PHASE_STRIDE apart"); +SELEC_EM4M_CHECK_STRIDE(VOLTAGE) +SELEC_EM4M_CHECK_STRIDE(CURRENT) +SELEC_EM4M_CHECK_STRIDE(ACTIVE_POWER) +SELEC_EM4M_CHECK_STRIDE(REACTIVE_POWER) +SELEC_EM4M_CHECK_STRIDE(APPARENT_POWER) +SELEC_EM4M_CHECK_STRIDE(POWER_FACTOR) +SELEC_EM4M_CHECK_STRIDE(IMPORT_ACTIVE_ENERGY) +SELEC_EM4M_CHECK_STRIDE(EXPORT_ACTIVE_ENERGY) +SELEC_EM4M_CHECK_STRIDE(IMPORT_REACTIVE_ENERGY) +SELEC_EM4M_CHECK_STRIDE(EXPORT_REACTIVE_ENERGY) +SELEC_EM4M_CHECK_STRIDE(APPARENT_ENERGY) +#undef SELEC_EM4M_CHECK_STRIDE + +// Far outside the main 0-113 register block; read via a separate Modbus transaction. +static constexpr uint16_t EM4M_SERIAL_NUMBER = 0x02AC; // Serial No. (Hex), 30684 +static constexpr uint16_t EM4M_DG_SENSING = 0x02C6; // DG Sensing (1: Pass, 0: Fail), 30710 } // namespace esphome::selec_meter diff --git a/esphome/components/selec_meter/sensor.py b/esphome/components/selec_meter/sensor.py index 04aef00d46..5e6709ec97 100644 --- a/esphome/components/selec_meter/sensor.py +++ b/esphome/components/selec_meter/sensor.py @@ -1,5 +1,11 @@ import esphome.codegen as cg -from esphome.components import modbus, sensor +from esphome.components import binary_sensor, modbus, sensor, text_sensor +from esphome.components.const import ( + BYTE_ORDER_BIG, + BYTE_ORDER_LITTLE, + CONF_BYTE_ORDER, + CONF_SERIAL_NUMBER, +) import esphome.config_validation as cv from esphome.const import ( CONF_ACTIVE_POWER, @@ -11,6 +17,10 @@ from esphome.const import ( CONF_ID, CONF_IMPORT_ACTIVE_ENERGY, CONF_IMPORT_REACTIVE_ENERGY, + CONF_MODEL, + CONF_PHASE_A, + CONF_PHASE_B, + CONF_PHASE_C, CONF_POWER_FACTOR, CONF_REACTIVE_POWER, CONF_VOLTAGE, @@ -24,6 +34,7 @@ from esphome.const import ( DEVICE_CLASS_VOLTAGE, ICON_CURRENT_AC, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, UNIT_HERTZ, @@ -37,9 +48,22 @@ from esphome.const import ( ) from esphome.types import ConfigType -AUTO_LOAD = ["modbus"] CODEOWNERS = ["@sourabhjaiswal"] +CONF_DG_SENSING = "dg_sensing" + + +def AUTO_LOAD(config: ConfigType) -> list[str]: + return ["modbus"] + [ + component + for key, component in ( + (CONF_SERIAL_NUMBER, "text_sensor"), + (CONF_DG_SENSING, "binary_sensor"), + ) + if not config or key in config + ] + + CONF_TOTAL_ACTIVE_ENERGY = "total_active_energy" CONF_TOTAL_REACTIVE_ENERGY = "total_reactive_energy" CONF_APPARENT_ENERGY = "apparent_energy" @@ -47,19 +71,68 @@ CONF_MAXIMUM_DEMAND_ACTIVE_POWER = "maximum_demand_active_power" CONF_MAXIMUM_DEMAND_REACTIVE_POWER = "maximum_demand_reactive_power" CONF_MAXIMUM_DEMAND_APPARENT_POWER = "maximum_demand_apparent_power" +CONF_VOLTAGE_L12 = "voltage_l12" +CONF_VOLTAGE_L23 = "voltage_l23" +CONF_VOLTAGE_L31 = "voltage_l31" + +CONF_AVERAGE_VOLTAGE_LL = "average_voltage_ll" +CONF_NET_ACTIVE_ENERGY_MAINS = "net_active_energy_mains" +CONF_NET_REACTIVE_ENERGY_MAINS = "net_reactive_energy_mains" +CONF_NET_APPARENT_ENERGY_MAINS = "net_apparent_energy_mains" +CONF_NET_ACTIVE_ENERGY_DG = "net_active_energy_dg" +CONF_NET_REACTIVE_ENERGY_DG = "net_reactive_energy_dg" +CONF_NET_APPARENT_ENERGY_DG = "net_apparent_energy_dg" selec_meter_ns = cg.esphome_ns.namespace("selec_meter") SelecMeter = selec_meter_ns.class_( "SelecMeter", cg.PollingComponent, modbus.ModbusClientDevice ) +SelecMeterModel = selec_meter_ns.enum("Model", is_class=True) -SENSORS = { +MODEL_EM2M = "em2m" +MODEL_EM4M = "em4m" +MODELS = { + MODEL_EM2M: SelecMeterModel.MODEL_EM2M, + MODEL_EM4M: SelecMeterModel.MODEL_EM4M, +} + +# Register word order of the FP32 values: big_endian sends the high word first (ABCD), +# little_endian the low word first (CDAB). msrf/lsrf are aliases matching the meter's own +# register 40070 (Endianness Selection) menu names. +BYTE_ORDER_MSRF = "msrf" +BYTE_ORDER_LSRF = "lsrf" +# Maps the YAML option directly to the C++ word_swap bool -- no enum needed on the C++ side. +BYTE_ORDER_WORD_SWAP = { + BYTE_ORDER_BIG: False, + BYTE_ORDER_LITTLE: True, + BYTE_ORDER_MSRF: False, + BYTE_ORDER_LSRF: True, +} +validate_byte_order = cv.enum(BYTE_ORDER_WORD_SWAP, lower=True) + +# EM2M-only: total combined energy figures with no non-DG equivalent on the EM4M. +EM2M_SENSORS = { CONF_TOTAL_ACTIVE_ENERGY: sensor.sensor_schema( unit_of_measurement=UNIT_KILOWATT_HOURS, accuracy_decimals=2, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), + CONF_TOTAL_REACTIVE_ENERGY: sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=2, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + CONF_APPARENT_ENERGY: sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_HOURS, + accuracy_decimals=2, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), +} + +# Sensors available on both EM2M and EM4M. On EM4M these read the aggregate +# (Total/Average) registers rather than a single-phase measurement. +COMMON_SENSORS = { CONF_IMPORT_ACTIVE_ENERGY: sensor.sensor_schema( unit_of_measurement=UNIT_KILOWATT_HOURS, accuracy_decimals=2, @@ -72,11 +145,6 @@ SENSORS = { device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), - CONF_TOTAL_REACTIVE_ENERGY: sensor.sensor_schema( - unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, - accuracy_decimals=2, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), CONF_IMPORT_REACTIVE_ENERGY: sensor.sensor_schema( unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, accuracy_decimals=2, @@ -87,11 +155,6 @@ SENSORS = { accuracy_decimals=2, state_class=STATE_CLASS_TOTAL_INCREASING, ), - CONF_APPARENT_ENERGY: sensor.sensor_schema( - unit_of_measurement=UNIT_KILOVOLT_AMPS_HOURS, - accuracy_decimals=2, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), CONF_ACTIVE_POWER: sensor.sensor_schema( unit_of_measurement=UNIT_WATT, accuracy_decimals=3, @@ -154,13 +217,115 @@ SENSORS = { ), } -CONFIG_SCHEMA = ( - cv.Schema({cv.GenerateID(): cv.declare_id(SelecMeter)}) - .extend( - {cv.Optional(sensor_name): schema for sensor_name, schema in SENSORS.items()} + +def _sensor_group(*keys: str, **schema_kwargs) -> dict[str, cv.Schema]: + """One identical sensor_schema() per key.""" + return {key: sensor.sensor_schema(**schema_kwargs) for key in keys} + + +# EM4M-only, genuinely per-phase quantities, nested under phase_a/phase_b/phase_c like +# sdm_meter/atm90e32 do for 3-phase meters. +PHASE_SENSORS = { + key: {**COMMON_SENSORS, **EM2M_SENSORS}[key] + for key in ( + CONF_VOLTAGE, + CONF_CURRENT, + CONF_ACTIVE_POWER, + CONF_REACTIVE_POWER, + CONF_APPARENT_POWER, + CONF_POWER_FACTOR, + CONF_IMPORT_ACTIVE_ENERGY, + CONF_EXPORT_ACTIVE_ENERGY, + CONF_IMPORT_REACTIVE_ENERGY, + CONF_EXPORT_REACTIVE_ENERGY, + CONF_APPARENT_ENERGY, ) - .extend(cv.polling_component_schema("10s")) - .extend(modbus.modbus_device_schema(0x01)) +} + +PHASE_SCHEMA = cv.Schema( + {cv.Optional(key): schema for key, schema in PHASE_SENSORS.items()} +) + +# EM4M-only, line-to-line and mains/DG net totals -- these don't map to a single phase, so they +# stay flat rather than living under phase_a/phase_b/phase_c. +EM4M_SENSORS = { + **_sensor_group( + CONF_VOLTAGE_L12, + CONF_VOLTAGE_L23, + CONF_VOLTAGE_L31, + CONF_AVERAGE_VOLTAGE_LL, + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=2, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + # Net (import - export) energy totals, split by source. Only meaningful when the meter's + # "Dual Source" setting is Yes; DG registers read 0 on single-source installs. + **_sensor_group( + CONF_NET_ACTIVE_ENERGY_MAINS, + CONF_NET_ACTIVE_ENERGY_DG, + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=2, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL, + ), + **_sensor_group( + CONF_NET_REACTIVE_ENERGY_MAINS, + CONF_NET_REACTIVE_ENERGY_DG, + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=2, + state_class=STATE_CLASS_TOTAL, + ), + **_sensor_group( + CONF_NET_APPARENT_ENERGY_MAINS, + CONF_NET_APPARENT_ENERGY_DG, + unit_of_measurement=UNIT_KILOVOLT_AMPS_HOURS, + accuracy_decimals=2, + state_class=STATE_CLASS_TOTAL, + ), +} + +PHASES = (CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C) + + +def _optional(sensors: dict[str, cv.Schema]) -> dict[cv.Optional, cv.Schema]: + return {cv.Optional(key): schema for key, schema in sensors.items()} + + +_BASE_SCHEMA = cv.Schema({cv.GenerateID(): cv.declare_id(SelecMeter)}).extend( + cv.polling_component_schema("10s"), modbus.modbus_device_schema(0x01) +) + +# EM2M keeps this component's original word-swapped decode; EM4M uses the meter's factory +# default (MSRF / Big Endian), confirmed against real hardware. +CONFIG_SCHEMA = cv.typed_schema( + { + MODEL_EM2M: _BASE_SCHEMA.extend( + { + cv.Optional( + CONF_BYTE_ORDER, default=BYTE_ORDER_LITTLE + ): validate_byte_order, + **_optional(COMMON_SENSORS), + **_optional(EM2M_SENSORS), + } + ), + MODEL_EM4M: _BASE_SCHEMA.extend( + { + cv.Optional( + CONF_BYTE_ORDER, default=BYTE_ORDER_BIG + ): validate_byte_order, + **_optional(COMMON_SENSORS), + **_optional(EM4M_SENSORS), + **{cv.Optional(phase): PHASE_SCHEMA for phase in PHASES}, + cv.Optional(CONF_SERIAL_NUMBER): text_sensor.text_sensor_schema(), + cv.Optional(CONF_DG_SENSING): binary_sensor.binary_sensor_schema(), + } + ), + }, + key=CONF_MODEL, + default_type=MODEL_EM2M, + enum=MODELS, + lower=True, ) @@ -175,7 +340,58 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) - for name in SENSORS: - if name in config: - sens = await sensor.new_sensor(config[name]) - cg.add(getattr(var, f"set_{name}_sensor")(sens)) + cg.add(var.set_model(config[CONF_MODEL])) + cg.add(var.set_word_swap(config[CONF_BYTE_ORDER])) + if config[CONF_MODEL] == MODEL_EM4M: + cg.add_define("USE_SELEC_METER_EM4M") + sensors = sensor.sub_sensors(config) + await sensors(CONF_TOTAL_ACTIVE_ENERGY, var.set_total_active_energy_sensor) + await sensors(CONF_IMPORT_ACTIVE_ENERGY, var.set_import_active_energy_sensor) + await sensors(CONF_EXPORT_ACTIVE_ENERGY, var.set_export_active_energy_sensor) + await sensors(CONF_TOTAL_REACTIVE_ENERGY, var.set_total_reactive_energy_sensor) + await sensors(CONF_IMPORT_REACTIVE_ENERGY, var.set_import_reactive_energy_sensor) + await sensors(CONF_EXPORT_REACTIVE_ENERGY, var.set_export_reactive_energy_sensor) + await sensors(CONF_APPARENT_ENERGY, var.set_apparent_energy_sensor) + await sensors(CONF_ACTIVE_POWER, var.set_active_power_sensor) + await sensors(CONF_REACTIVE_POWER, var.set_reactive_power_sensor) + await sensors(CONF_APPARENT_POWER, var.set_apparent_power_sensor) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER_FACTOR, var.set_power_factor_sensor) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) + await sensors( + CONF_MAXIMUM_DEMAND_ACTIVE_POWER, var.set_maximum_demand_active_power_sensor + ) + await sensors( + CONF_MAXIMUM_DEMAND_REACTIVE_POWER, var.set_maximum_demand_reactive_power_sensor + ) + await sensors( + CONF_MAXIMUM_DEMAND_APPARENT_POWER, var.set_maximum_demand_apparent_power_sensor + ) + await sensors(CONF_VOLTAGE_L12, var.set_voltage_l12_sensor) + await sensors(CONF_VOLTAGE_L23, var.set_voltage_l23_sensor) + await sensors(CONF_VOLTAGE_L31, var.set_voltage_l31_sensor) + await sensors(CONF_AVERAGE_VOLTAGE_LL, var.set_average_voltage_ll_sensor) + await sensors(CONF_NET_ACTIVE_ENERGY_MAINS, var.set_net_active_energy_mains_sensor) + await sensors( + CONF_NET_REACTIVE_ENERGY_MAINS, var.set_net_reactive_energy_mains_sensor + ) + await sensors( + CONF_NET_APPARENT_ENERGY_MAINS, var.set_net_apparent_energy_mains_sensor + ) + await sensors(CONF_NET_ACTIVE_ENERGY_DG, var.set_net_active_energy_dg_sensor) + await sensors(CONF_NET_REACTIVE_ENERGY_DG, var.set_net_reactive_energy_dg_sensor) + await sensors(CONF_NET_APPARENT_ENERGY_DG, var.set_net_apparent_energy_dg_sensor) + for phase_index, phase in enumerate(PHASES): + if (phase_config := config.get(phase)) is None: + continue + for name in PHASE_SENSORS: + if name in phase_config: + sens = await sensor.new_sensor(phase_config[name]) + cg.add(getattr(var, f"set_{name}_sensor")(phase_index, sens)) + await text_sensor.sub_text_sensors(config)( + CONF_SERIAL_NUMBER, var.set_serial_number_sensor + ) + await binary_sensor.sub_binary_sensors(config)( + CONF_DG_SENSING, var.set_dg_sensing_sensor + ) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index fc6dd82ed9..a7d2084a54 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -251,6 +251,7 @@ #define USE_SAFE_MODE_CALLBACK #define ESPHOME_SAFE_MODE_CALLBACK_COUNT 1 #define USE_SELECT +#define USE_SELEC_METER_EM4M #define USE_SENSOR #define USE_SENSOR_FILTER #define USE_SERIAL_PROXY diff --git a/tests/components/selec_meter/common-em4m.yaml b/tests/components/selec_meter/common-em4m.yaml new file mode 100644 index 0000000000..793e3c30a6 --- /dev/null +++ b/tests/components/selec_meter/common-em4m.yaml @@ -0,0 +1,129 @@ +sensor: + - platform: selec_meter + modbus_id: modbus_bus + model: em4m + byte_order: little_endian + phase_a: + voltage: + name: SelecEM4M Voltage L1 + current: + name: SelecEM4M Current L1 + active_power: + name: SelecEM4M Active Power L1 + reactive_power: + name: SelecEM4M Reactive Power L1 + apparent_power: + name: SelecEM4M Apparent Power L1 + power_factor: + name: SelecEM4M Power Factor L1 + import_active_energy: + name: SelecEM4M Import Active Energy L1 + export_active_energy: + name: SelecEM4M Export Active Energy L1 + import_reactive_energy: + name: SelecEM4M Import Reactive Energy L1 + export_reactive_energy: + name: SelecEM4M Export Reactive Energy L1 + apparent_energy: + name: SelecEM4M Apparent Energy L1 + phase_b: + voltage: + name: SelecEM4M Voltage L2 + current: + name: SelecEM4M Current L2 + active_power: + name: SelecEM4M Active Power L2 + reactive_power: + name: SelecEM4M Reactive Power L2 + apparent_power: + name: SelecEM4M Apparent Power L2 + power_factor: + name: SelecEM4M Power Factor L2 + import_active_energy: + name: SelecEM4M Import Active Energy L2 + export_active_energy: + name: SelecEM4M Export Active Energy L2 + import_reactive_energy: + name: SelecEM4M Import Reactive Energy L2 + export_reactive_energy: + name: SelecEM4M Export Reactive Energy L2 + apparent_energy: + name: SelecEM4M Apparent Energy L2 + phase_c: + voltage: + name: SelecEM4M Voltage L3 + current: + name: SelecEM4M Current L3 + active_power: + name: SelecEM4M Active Power L3 + reactive_power: + name: SelecEM4M Reactive Power L3 + apparent_power: + name: SelecEM4M Apparent Power L3 + power_factor: + name: SelecEM4M Power Factor L3 + import_active_energy: + name: SelecEM4M Import Active Energy L3 + export_active_energy: + name: SelecEM4M Export Active Energy L3 + import_reactive_energy: + name: SelecEM4M Import Reactive Energy L3 + export_reactive_energy: + name: SelecEM4M Export Reactive Energy L3 + apparent_energy: + name: SelecEM4M Apparent Energy L3 + voltage_l12: + name: SelecEM4M Voltage L12 + voltage_l23: + name: SelecEM4M Voltage L23 + voltage_l31: + name: SelecEM4M Voltage L31 + average_voltage_ll: + name: SelecEM4M Average Voltage LL + active_power: + name: SelecEM4M Active Power + reactive_power: + name: SelecEM4M Reactive Power + apparent_power: + name: SelecEM4M Apparent Power + voltage: + name: SelecEM4M Average Voltage + current: + name: SelecEM4M Average Current + power_factor: + name: SelecEM4M Average Power Factor + frequency: + name: SelecEM4M Frequency + import_active_energy: + name: SelecEM4M Total Import Active Energy + export_active_energy: + name: SelecEM4M Total Export Active Energy + import_reactive_energy: + name: SelecEM4M Total Import Reactive Energy + export_reactive_energy: + name: SelecEM4M Total Export Reactive Energy + net_active_energy_mains: + name: SelecEM4M Net Active Energy Mains + net_reactive_energy_mains: + name: SelecEM4M Net Reactive Energy Mains + net_apparent_energy_mains: + name: SelecEM4M Net Apparent Energy Mains + net_active_energy_dg: + name: SelecEM4M Net Active Energy DG + net_reactive_energy_dg: + name: SelecEM4M Net Reactive Energy DG + net_apparent_energy_dg: + name: SelecEM4M Net Apparent Energy DG + maximum_demand_active_power: + name: SelecEM4M Maximum Demand Active Power + disabled_by_default: true + maximum_demand_reactive_power: + name: SelecEM4M Maximum Demand Reactive Power + disabled_by_default: true + maximum_demand_apparent_power: + name: SelecEM4M Maximum Demand Apparent Power + disabled_by_default: true + serial_number: + name: SelecEM4M Serial Number + dg_sensing: + name: SelecEM4M DG Sensing diff --git a/tests/components/selec_meter/test-em4m.esp32-idf.yaml b/tests/components/selec_meter/test-em4m.esp32-idf.yaml new file mode 100644 index 0000000000..56c7c38bf9 --- /dev/null +++ b/tests/components/selec_meter/test-em4m.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + flow_control_pin: GPIO26 + +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + selec_meter: !include common-em4m.yaml diff --git a/tests/components/selec_meter/test-em4m.esp8266-ard.yaml b/tests/components/selec_meter/test-em4m.esp8266-ard.yaml new file mode 100644 index 0000000000..7cdabad7d6 --- /dev/null +++ b/tests/components/selec_meter/test-em4m.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + flow_control_pin: GPIO15 + +packages: + modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml + selec_meter: !include common-em4m.yaml diff --git a/tests/components/selec_meter/test.esp32-idf.yaml b/tests/components/selec_meter/test.esp32-idf.yaml index 4df9f5863e..3c588d0afc 100644 --- a/tests/components/selec_meter/test.esp32-idf.yaml +++ b/tests/components/selec_meter/test.esp32-idf.yaml @@ -4,6 +4,5 @@ substitutions: flow_control_pin: GPIO26 packages: + selec_meter: !include common.yaml modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/selec_meter/test.esp8266-ard.yaml b/tests/components/selec_meter/test.esp8266-ard.yaml index 6daa08c22b..6f4125b47b 100644 --- a/tests/components/selec_meter/test.esp8266-ard.yaml +++ b/tests/components/selec_meter/test.esp8266-ard.yaml @@ -4,6 +4,5 @@ substitutions: flow_control_pin: GPIO15 packages: + selec_meter: !include common.yaml modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml - -<<: !include common.yaml diff --git a/tests/components/selec_meter/test.rp2040-ard.yaml b/tests/components/selec_meter/test.rp2040-ard.yaml index a65500ccc3..687622481e 100644 --- a/tests/components/selec_meter/test.rp2040-ard.yaml +++ b/tests/components/selec_meter/test.rp2040-ard.yaml @@ -4,6 +4,5 @@ substitutions: flow_control_pin: GPIO6 packages: + selec_meter: !include common.yaml modbus: !include ../../test_build_components/common/modbus/rp2040-ard.yaml - -<<: !include common.yaml diff --git a/tests/unit_tests/components/selec_meter/__init__.py b/tests/unit_tests/components/selec_meter/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/selec_meter/test_sensor.py b/tests/unit_tests/components/selec_meter/test_sensor.py new file mode 100644 index 0000000000..b4b14cd7e1 --- /dev/null +++ b/tests/unit_tests/components/selec_meter/test_sensor.py @@ -0,0 +1,103 @@ +"""Tests for the selec_meter configuration schema.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from esphome.components.selec_meter import sensor as selec_meter +import esphome.config_validation as cv +from esphome.types import ConfigType + + +def _validate(config: dict[str, Any]) -> ConfigType: + return selec_meter.CONFIG_SCHEMA(config) + + +def test_model_defaults_to_em2m() -> None: + config = _validate({"voltage": {"name": "Voltage"}}) + assert config["model"] == selec_meter.MODEL_EM2M + + +def test_model_is_case_insensitive() -> None: + config = _validate({"model": "EM4M"}) + assert config["model"] == selec_meter.MODEL_EM4M + + +def test_model_rejects_unknown_value() -> None: + with pytest.raises(cv.Invalid, match="Unknown value 'em9m'"): + _validate({"model": "em9m"}) + + +@pytest.mark.parametrize( + ("model", "key", "value"), + [ + ("em2m", "voltage_l12", {"name": "Test"}), + ("em2m", "serial_number", {"name": "Test"}), + ("em2m", "dg_sensing", {"name": "Test"}), + ("em2m", "phase_a", {"voltage": {"name": "Voltage"}}), + ("em2m", "phase_b", {"voltage": {"name": "Voltage"}}), + ("em2m", "phase_c", {"voltage": {"name": "Voltage"}}), + ("em4m", "total_active_energy", {"name": "Test"}), + ("em4m", "total_reactive_energy", {"name": "Test"}), + ("em4m", "apparent_energy", {"name": "Test"}), + ], +) +def test_key_rejected_on_wrong_model(model: str, key: str, value: Any) -> None: + with pytest.raises(cv.Invalid, match="extra keys not allowed") as err: + _validate({"model": model, key: value}) + assert err.value.path == [key] + + +def test_em4m_only_key_rejected_on_default_model() -> None: + with pytest.raises(cv.Invalid, match="extra keys not allowed"): + _validate({"voltage_l12": {"name": "Test"}}) + + +def test_em4m_with_phases_accepted() -> None: + config = _validate( + { + "model": "em4m", + "phase_a": {"voltage": {"name": "Voltage L1"}}, + "phase_c": {"power_factor": {"name": "PF L3"}}, + } + ) + assert "voltage" in config["phase_a"] + assert "power_factor" in config["phase_c"] + + +@pytest.mark.parametrize( + ("model", "byte_order", "word_swap"), + [ + ("em2m", "little_endian", True), + ("em4m", "big_endian", False), + ], +) +def test_byte_order_default_per_model( + model: str, byte_order: str, word_swap: bool +) -> None: + config = _validate({"model": model}) + assert config["byte_order"] == byte_order + assert config["byte_order"].enum_value is word_swap + + +@pytest.mark.parametrize( + ("model", "byte_order", "word_swap"), + [ + ("em2m", "big_endian", False), + ("em4m", "LITTLE_ENDIAN", True), + ("em4m", "lsrf", True), + ("em2m", "msrf", False), + ], +) +def test_byte_order_explicit_override( + model: str, byte_order: str, word_swap: bool +) -> None: + config = _validate({"model": model, "byte_order": byte_order}) + assert config["byte_order"].enum_value is word_swap + + +def test_byte_order_rejects_unknown_value() -> None: + with pytest.raises(cv.Invalid, match="Unknown value"): + _validate({"byte_order": "middle_endian"})