mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 02:02:51 +00:00
[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:
co-authored by
Claude Sonnet 5
pre-commit-ci-lite[bot]
J. Nick Koston
J. Nick Koston
parent
8acf885241
commit
5017165800
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"})
|
||||
Reference in New Issue
Block a user