[selec_meter] Add EM4M (3-phase) support via model option (#17782)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Sourabh Jaiswal
2026-10-13 02:58:25 +00:00
committed by GitHub
co-authored by Claude Sonnet 5 pre-commit-ci-lite[bot] J. Nick Koston J. Nick Koston
parent 8acf885241
commit 5017165800
13 changed files with 1105 additions and 82 deletions
+369 -30
View File
@@ -1,54 +1,358 @@
#include "selec_meter.h"
#include "selec_meter_registers.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include <cinttypes>
#include <cmath>
#include <cstdio>
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<const uint16_t> registers, uint16_t start_address, uint16_t reg, bool word_swapped) {
auto value = word_swapped ? helpers::value_at<helpers::SensorValueType::FP32_R>(registers, start_address, reg)
: helpers::value_at<helpers::SensorValueType::FP32>(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<const uint16_t> 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<uint8_t>(current) + 1; next < static_cast<uint8_t>(ReadState::READ_STATE_IDLE); next++) {
if (this->wanted_(static_cast<ReadState>(next)))
return static_cast<ReadState>(next);
}
return ReadState::READ_STATE_IDLE;
}
void SelecMeter::on_read_input_registers(uint16_t start_address, std::span<const uint16_t> 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<helpers::SensorValueType::FP32_R>(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<uint8_t>(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<const uint8_t> /*request_pdu*/) {
this->fail_current_read_(LOG_STR("No Modbus response"));
return false;
}
void SelecMeter::on_not_sent(std::span<const uint8_t> /*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<const uint16_t> registers) {
auto serial =
this->word_swap_
? helpers::value_at<helpers::SensorValueType::U_DWORD_R>(registers, EM4M_SERIAL_NUMBER, EM4M_SERIAL_NUMBER)
: helpers::value_at<helpers::SensorValueType::U_DWORD>(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<const uint16_t> 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<const uint16_t> registers, std::span<const FloatField> fields,
uint8_t min_count) {
if (registers.size() < min_count) {
ESP_LOGW(TAG, "Short response: %u registers", static_cast<unsigned>(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<const uint16_t> 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<const uint16_t> 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
@@ -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 <span>
@@ -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<const uint16_t> registers,
modbus::ResponseStatus status) override;
bool on_no_response(std::span<const uint8_t> request_pdu) override;
void on_not_sent(std::span<const uint8_t> 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<const uint16_t> registers, std::span<const FloatField> fields, uint8_t min_count);
bool decode_em2m_(std::span<const uint16_t> registers);
#ifdef USE_SELEC_METER_EM4M
bool decode_em4m_(std::span<const uint16_t> registers);
#ifdef USE_TEXT_SENSOR
void decode_serial_number_(std::span<const uint16_t> registers);
#endif
#ifdef USE_BINARY_SENSOR
void decode_dg_sensing_(std::span<const uint16_t> 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
@@ -1,30 +1,117 @@
#pragma once
#include <cstdint>
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
+239 -23
View File
@@ -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
)
+1
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"})