diff --git a/CODEOWNERS b/CODEOWNERS index c582728e76..52fec0a7ac 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -429,6 +429,7 @@ esphome/components/psram/* @esphome/core esphome/components/pulse_meter/* @cstaahl @stevebaxter @TrentHouliston esphome/components/pvvx_mithermometer/* @pasiz esphome/components/pylontech/* @functionpointer +esphome/components/pzem6l24/* @nuttytree esphome/components/qmi8658/* @clydebarrow esphome/components/qmp6988/* @andrewpc esphome/components/qr_code/* @wjtje diff --git a/esphome/components/pzem6l24/__init__.py b/esphome/components/pzem6l24/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/pzem6l24/pzem6l24.cpp b/esphome/components/pzem6l24/pzem6l24.cpp new file mode 100644 index 0000000000..f9f97606a8 --- /dev/null +++ b/esphome/components/pzem6l24/pzem6l24.cpp @@ -0,0 +1,335 @@ +#include "pzem6l24.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::pzem6l24 { + +static const char *const TAG = "pzem6l24"; + +// ----------------------------------------------------------------------- +// Register map (input registers, starting address 0x0000): +// +// The PZEM-6L24 returns all register bytes in little-endian order, +// i.e. the low byte of each 16-bit register is transmitted first. +// 32-bit quantities occupy two consecutive registers with the low +// word at the lower address. +// +// NOTE: this is the opposite of standard Modbus, and of the single-phase +// pzemac component, which decodes big-endian. It is not an oversight: the +// byte order below was established against a live PZEM-6L24, so please do +// not "correct" it to big-endian without a device to verify against. +// +// Byte offset = register_address × 2 +// +// 0x0000 (byte 0) – Voltage A (uint16, ×0.1 V) +// 0x0001 (byte 2) – Voltage B (uint16, ×0.1 V) +// 0x0002 (byte 4) – Voltage C (uint16, ×0.1 V) +// 0x0003 (byte 6) – Current A (uint16, ×0.01 A) +// 0x0004 (byte 8) – Current B (uint16, ×0.01 A) +// 0x0005 (byte 10) – Current C (uint16, ×0.01 A) +// 0x0006 (byte 12) – Frequency A (uint16, ×0.01 Hz) +// 0x0007 (byte 14) – Frequency B (uint16, ×0.01 Hz) +// 0x0008 (byte 16) – Frequency C (uint16, ×0.01 Hz) +// 0x0009 (byte 18) – Voltage angle B (uint16, ×0.01 °) +// 0x000A (byte 20) – Voltage angle C (uint16, ×0.01 °) +// 0x000B (byte 22) – Current angle A (uint16, ×0.01 °) +// 0x000C (byte 24) – Current angle B (uint16, ×0.01 °) +// 0x000D (byte 26) – Current angle C (uint16, ×0.01 °) +// 0x000E (byte 28) – Active power A (int32 lo-word, ×0.1 W) +// 0x000F (byte 30) – Active power A (int32 hi-word) +// 0x0010 (byte 32) – Active power B (int32 lo-word, ×0.1 W) +// 0x0011 (byte 34) – Active power B (int32 hi-word) +// 0x0012 (byte 36) – Active power C (int32 lo-word, ×0.1 W) +// 0x0013 (byte 38) – Active power C (int32 hi-word) +// 0x0014 (byte 40) – Reactive power A (int32 lo-word, ×0.1 var) +// 0x0015 (byte 42) – Reactive power A (int32 hi-word) +// 0x0016 (byte 44) – Reactive power B (int32 lo-word, ×0.1 var) +// 0x0017 (byte 46) – Reactive power B (int32 hi-word) +// 0x0018 (byte 48) – Reactive power C (int32 lo-word, ×0.1 var) +// 0x0019 (byte 50) – Reactive power C (int32 hi-word) +// 0x001A (byte 52) – Apparent power A (int32 lo-word, ×0.1 VA) +// 0x001B (byte 54) – Apparent power A (int32 hi-word) +// 0x001C (byte 56) – Apparent power B (int32 lo-word, ×0.1 VA) +// 0x001D (byte 58) – Apparent power B (int32 hi-word) +// 0x001E (byte 60) – Apparent power C (int32 lo-word, ×0.1 VA) +// 0x001F (byte 62) – Apparent power C (int32 hi-word) +// 0x0020 (byte 64) – Total active pwr (int32 lo-word, ×0.1 W) +// 0x0021 (byte 66) – Total active pwr (int32 hi-word) +// 0x0022 (byte 68) – Total reactive pwr (int32 lo-word, ×0.1 var) +// 0x0023 (byte 70) – Total reactive pwr (int32 hi-word) +// 0x0024 (byte 72) – Total apparent pwr (int32 lo-word, ×0.1 VA) +// 0x0025 (byte 74) – Total apparent pwr (int32 hi-word) +// 0x0026 (byte 76) – Power factor A/B: hi-byte = A (×0.01), lo-byte = B (×0.01) +// 0x0027 (byte 78) – Power factor C/tot:hi-byte = C (×0.01), lo-byte = total (×0.01) +// 0x0028 (byte 80) – Active energy A (uint32 lo-word, ×0.1 kWh) +// 0x0029 (byte 82) – Active energy A (uint32 hi-word) +// 0x002A (byte 84) – Active energy B (uint32 lo-word, ×0.1 kWh) +// 0x002B (byte 86) – Active energy B (uint32 hi-word) +// 0x002C (byte 88) – Active energy C (uint32 lo-word, ×0.1 kWh) +// 0x002D (byte 90) – Active energy C (uint32 hi-word) +// 0x002E (byte 92) – Reactive energy A (uint32 lo-word, ×0.1 kvarh) +// 0x002F (byte 94) – Reactive energy A (uint32 hi-word) +// 0x0030 (byte 96) – Reactive energy B (uint32 lo-word, ×0.1 kvarh) +// 0x0031 (byte 98) – Reactive energy B (uint32 hi-word) +// 0x0032 (byte 100)– Reactive energy C (uint32 lo-word, ×0.1 kvarh) +// 0x0033 (byte 102)– Reactive energy C (uint32 hi-word) +// 0x0034 (byte 104)– Apparent energy A (uint32 lo-word, ×0.1 kVAh) +// 0x0035 (byte 106)– Apparent energy A (uint32 hi-word) +// 0x0036 (byte 108)– Apparent energy B (uint32 lo-word, ×0.1 kVAh) +// 0x0037 (byte 110)– Apparent energy B (uint32 hi-word) +// 0x0038 (byte 112)– Apparent energy C (uint32 lo-word, ×0.1 kVAh) +// 0x0039 (byte 114)– Apparent energy C (uint32 hi-word) +// 0x003A (byte 116)– Total active nrg (uint32 lo-word, ×0.1 kWh) +// 0x003B (byte 118)– Total active nrg (uint32 hi-word) +// 0x003C (byte 120)– Total reactive nrg (uint32 lo-word, ×0.1 kvarh) +// 0x003D (byte 122)– Total reactive nrg (uint32 hi-word) +// 0x003E (byte 124)– Total apparent nrg (uint32 lo-word, ×0.1 kVAh) +// 0x003F (byte 126)– Total apparent nrg (uint32 hi-word) +// ----------------------------------------------------------------------- + +// Width of a quantity in the register map above. +enum RegType : uint8_t { + REG_U8, // single byte (the packed power factors) + REG_U16, // one register, unsigned + REG_U32, // two registers, unsigned, low word first + REG_I32, // two registers, signed, low word first +}; + +// Scale factor of a quantity; the table has only these two, so a byte replaces a float per entry. +enum Scale : uint8_t { + SCALE_DECI, // ×0.1 + SCALE_CENTI, // ×0.01 +}; + +// One decodable quantity: where it lives in the payload, how to read it and which sensor it feeds. +// Copied out of flash with memcpy, so it must stay trivially copyable. +struct SensorEntry { + sensor::Sensor *PZEM6L24::*member; + uint8_t offset; + RegType type; + Scale scale; +}; +static_assert(std::is_trivially_copyable_v, "SENSORS is copied out of flash with memcpy"); + +// True for the periodic register read issued by update(); the only other request is the 0x42 reset. +static bool is_register_read(std::span request_pdu) { + return modbus::helpers::pdu_function_code(request_pdu) == + static_cast(modbus::FunctionCode::READ_INPUT_REGISTERS); +} + +void PZEM6L24::on_response(std::span request_pdu, std::span response_pdu) { + if (!is_register_read(request_pdu)) { + return; + } + this->read_finished_(); + const auto data = modbus::helpers::server_pdu_payload(response_pdu); + // Anything but exactly PZEM_PAYLOAD_SIZE bytes cannot be decoded by the table. + if (data.size() != PZEM_PAYLOAD_SIZE) { + ESP_LOGW(TAG, "Invalid data size for PZEM-6L24: expected %zu bytes, got %zu", PZEM_PAYLOAD_SIZE, data.size()); + this->read_failed_(); + return; + } + this->consecutive_failures_ = 0; + this->publish_(data.data()); +} + +void PZEM6L24::on_error(std::span request_pdu, modbus::ExceptionCode) { + this->request_failed_(request_pdu); +} + +bool PZEM6L24::on_no_response(std::span request_pdu) { + this->request_failed_(request_pdu); + return false; // no retry; the next update() polls again. +} + +void PZEM6L24::on_not_sent(std::span request_pdu) { this->request_failed_(request_pdu); } + +// A register read that produced no measurements counts toward blanking; a failed energy reset does +// not, but the user is told. The hub has already logged the cause. +void PZEM6L24::request_failed_(std::span request_pdu) { + if (is_register_read(request_pdu)) { + this->read_finished_(); + this->read_failed_(); + } else { + ESP_LOGW(TAG, "Energy reset failed; the counters were not cleared"); + } +} + +// `data` points at PZEM_PAYLOAD_SIZE validated bytes, or is nullptr to blank every sensor; both walk +// the same table. +void PZEM6L24::publish_(const uint8_t *data) { + const bool available = data != nullptr; + + // Byte offset, width and scale for every quantity, in register-map order. All three phases share the + // same grid frequency, so phase A's register is reported. + // + // PROGMEM: on ESP8266 .rodata is DRAM, so the table would otherwise cost ~280 bytes of RAM; the price + // is one 8-byte copy per sensor per poll. No name column for the same reason; dump_config() names + // every sensor from flash. + static constexpr SensorEntry SENSORS[] PROGMEM = { + // Voltages (×0.1 V) + {&PZEM6L24::voltage_a_, 0, REG_U16, SCALE_DECI}, + {&PZEM6L24::voltage_b_, 2, REG_U16, SCALE_DECI}, + {&PZEM6L24::voltage_c_, 4, REG_U16, SCALE_DECI}, + // Currents (×0.01 A) + {&PZEM6L24::current_a_, 6, REG_U16, SCALE_CENTI}, + {&PZEM6L24::current_b_, 8, REG_U16, SCALE_CENTI}, + {&PZEM6L24::current_c_, 10, REG_U16, SCALE_CENTI}, + // Frequency (×0.01 Hz) + {&PZEM6L24::frequency_, 12, REG_U16, SCALE_CENTI}, + // Active powers (×0.1 W, signed) + {&PZEM6L24::active_power_a_, 28, REG_I32, SCALE_DECI}, + {&PZEM6L24::active_power_b_, 32, REG_I32, SCALE_DECI}, + {&PZEM6L24::active_power_c_, 36, REG_I32, SCALE_DECI}, + {&PZEM6L24::total_active_power_, 64, REG_I32, SCALE_DECI}, + // Reactive powers (×0.1 var, signed) + {&PZEM6L24::reactive_power_a_, 40, REG_I32, SCALE_DECI}, + {&PZEM6L24::reactive_power_b_, 44, REG_I32, SCALE_DECI}, + {&PZEM6L24::reactive_power_c_, 48, REG_I32, SCALE_DECI}, + {&PZEM6L24::total_reactive_power_, 68, REG_I32, SCALE_DECI}, + // Apparent powers (×0.1 VA, signed) + {&PZEM6L24::apparent_power_a_, 52, REG_I32, SCALE_DECI}, + {&PZEM6L24::apparent_power_b_, 56, REG_I32, SCALE_DECI}, + {&PZEM6L24::apparent_power_c_, 60, REG_I32, SCALE_DECI}, + {&PZEM6L24::total_apparent_power_, 72, REG_I32, SCALE_DECI}, + // Power factors (×0.01), packed two per register: + // register 0x0026 (bytes 76/77): lo-byte = phase B, hi-byte = phase A + // register 0x0027 (bytes 78/79): lo-byte = combined, hi-byte = phase C + {&PZEM6L24::power_factor_a_, 77, REG_U8, SCALE_CENTI}, + {&PZEM6L24::power_factor_b_, 76, REG_U8, SCALE_CENTI}, + {&PZEM6L24::power_factor_c_, 79, REG_U8, SCALE_CENTI}, + {&PZEM6L24::total_power_factor_, 78, REG_U8, SCALE_CENTI}, + // Active energies (×0.1 kWh, unsigned) + {&PZEM6L24::active_energy_a_, 80, REG_U32, SCALE_DECI}, + {&PZEM6L24::active_energy_b_, 84, REG_U32, SCALE_DECI}, + {&PZEM6L24::active_energy_c_, 88, REG_U32, SCALE_DECI}, + {&PZEM6L24::total_active_energy_, 116, REG_U32, SCALE_DECI}, + // Reactive energies (×0.1 kvarh, unsigned) + {&PZEM6L24::reactive_energy_a_, 92, REG_U32, SCALE_DECI}, + {&PZEM6L24::reactive_energy_b_, 96, REG_U32, SCALE_DECI}, + {&PZEM6L24::reactive_energy_c_, 100, REG_U32, SCALE_DECI}, + {&PZEM6L24::total_reactive_energy_, 120, REG_U32, SCALE_DECI}, + // Apparent energies (×0.1 kVAh, unsigned) + {&PZEM6L24::apparent_energy_a_, 104, REG_U32, SCALE_DECI}, + {&PZEM6L24::apparent_energy_b_, 108, REG_U32, SCALE_DECI}, + {&PZEM6L24::apparent_energy_c_, 112, REG_U32, SCALE_DECI}, + {&PZEM6L24::total_apparent_energy_, 124, REG_U32, SCALE_DECI}, + }; + + for (const SensorEntry &flash_entry : SENSORS) { + SensorEntry entry; + progmem_memcpy(&entry, &flash_entry, sizeof(entry)); + sensor::Sensor *sens = this->*entry.member; + if (sens == nullptr) + continue; + if (!available) { + sens->publish_state(NAN); + continue; + } + // No default: an added RegType must fail to compile. The wire is little-endian, hence the reversed + // byte arguments. + const size_t o = entry.offset; + float raw = 0.0f; + switch (entry.type) { + case REG_U8: + raw = data[o]; + break; + case REG_U16: + raw = encode_uint16(data[o + 1], data[o]); + break; + case REG_U32: + raw = encode_uint32(data[o + 3], data[o + 2], data[o + 1], data[o]); + break; + case REG_I32: + raw = static_cast(encode_uint32(data[o + 3], data[o + 2], data[o + 1], data[o])); + break; + } + sens->publish_state(raw * (entry.scale == SCALE_CENTI ? 0.01f : 0.1f)); + } +} + +void PZEM6L24::update() { + if (this->read_input_registers(0x0000, PZEM_REGISTER_COUNT)) { + this->reads_outstanding_++; + } else if (this->reads_outstanding_ == 0) { + // Refused with nothing in flight: no callback is coming, and the hub has logged why. A refusal + // while a read is outstanding is a duplicate of it, which still resolves in that read's callback. + this->read_failed_(); + } +} + +void PZEM6L24::dump_config() { + ESP_LOGCONFIG(TAG, + "PZEM-6L24:\n" + " Address: 0x%02X", + this->address_); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Voltage A", this->voltage_a_); + LOG_SENSOR(" ", "Voltage B", this->voltage_b_); + LOG_SENSOR(" ", "Voltage C", this->voltage_c_); + LOG_SENSOR(" ", "Current A", this->current_a_); + LOG_SENSOR(" ", "Current B", this->current_b_); + LOG_SENSOR(" ", "Current C", this->current_c_); + LOG_SENSOR(" ", "Active Power A", this->active_power_a_); + LOG_SENSOR(" ", "Active Power B", this->active_power_b_); + LOG_SENSOR(" ", "Active Power C", this->active_power_c_); + LOG_SENSOR(" ", "Reactive Power A", this->reactive_power_a_); + LOG_SENSOR(" ", "Reactive Power B", this->reactive_power_b_); + LOG_SENSOR(" ", "Reactive Power C", this->reactive_power_c_); + LOG_SENSOR(" ", "Apparent Power A", this->apparent_power_a_); + LOG_SENSOR(" ", "Apparent Power B", this->apparent_power_b_); + LOG_SENSOR(" ", "Apparent Power C", this->apparent_power_c_); + LOG_SENSOR(" ", "Power Factor A", this->power_factor_a_); + LOG_SENSOR(" ", "Power Factor B", this->power_factor_b_); + LOG_SENSOR(" ", "Power Factor C", this->power_factor_c_); + LOG_SENSOR(" ", "Active Energy A", this->active_energy_a_); + LOG_SENSOR(" ", "Active Energy B", this->active_energy_b_); + LOG_SENSOR(" ", "Active Energy C", this->active_energy_c_); + LOG_SENSOR(" ", "Reactive Energy A", this->reactive_energy_a_); + LOG_SENSOR(" ", "Reactive Energy B", this->reactive_energy_b_); + LOG_SENSOR(" ", "Reactive Energy C", this->reactive_energy_c_); + LOG_SENSOR(" ", "Apparent Energy A", this->apparent_energy_a_); + LOG_SENSOR(" ", "Apparent Energy B", this->apparent_energy_b_); + LOG_SENSOR(" ", "Apparent Energy C", this->apparent_energy_c_); + LOG_SENSOR(" ", "Frequency", this->frequency_); + LOG_SENSOR(" ", "Total Active Power", this->total_active_power_); + LOG_SENSOR(" ", "Total Reactive Power", this->total_reactive_power_); + LOG_SENSOR(" ", "Total Apparent Power", this->total_apparent_power_); + LOG_SENSOR(" ", "Total Power Factor", this->total_power_factor_); + LOG_SENSOR(" ", "Total Active Energy", this->total_active_energy_); + LOG_SENSOR(" ", "Total Reactive Energy", this->total_reactive_energy_); + LOG_SENSOR(" ", "Total Apparent Energy", this->total_apparent_energy_); +} + +// Blank after MAX_CONSECUTIVE_READ_FAILURES; stays blanked until a poll succeeds. +void PZEM6L24::read_failed_() { + if (this->consecutive_failures_ >= MAX_CONSECUTIVE_READ_FAILURES) { + return; + } + if (++this->consecutive_failures_ == MAX_CONSECUTIVE_READ_FAILURES) { + ESP_LOGW(TAG, "No valid reading in %u consecutive polls; the readings are now unavailable", + MAX_CONSECUTIVE_READ_FAILURES); + this->publish_(nullptr); + } +} + +// One terminal has arrived for a register read, so that read is no longer in flight. +void PZEM6L24::read_finished_() { + if (this->reads_outstanding_ > 0) { + this->reads_outstanding_--; + } +} + +void PZEM6L24::reset_energy(ResetPhase phase_option) { + const auto pdu = build_reset_pdu(phase_option); + // A refused request gets no callback, so report it here. + if (!this->queue_pdu(pdu)) { + this->request_failed_(pdu); + } +} + +} // namespace esphome::pzem6l24 diff --git a/esphome/components/pzem6l24/pzem6l24.h b/esphome/components/pzem6l24/pzem6l24.h new file mode 100644 index 0000000000..bf13f25053 --- /dev/null +++ b/esphome/components/pzem6l24/pzem6l24.h @@ -0,0 +1,161 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/modbus/modbus.h" + +#include +#include + +namespace esphome::pzem6l24 { + +// Options for the reset_energy action +enum ResetPhase : uint8_t { + RESET_PHASE_A = 0x00, // Reset phase A energy only + RESET_PHASE_B = 0x01, // Reset phase B energy only + RESET_PHASE_C = 0x02, // Reset phase C energy only + RESET_PHASE_COMBINED = 0x03, // Reset combined (sum) energy only + RESET_PHASE_ALL = 0x0F, // Reset all energy counters +}; + +// Reset energy function code (PZEM-6L24 specific, non-standard Modbus) +static constexpr uint8_t PZEM_CMD_RESET_ENERGY = 0x42; + +// Input registers read per poll (0x0000 - 0x003F inclusive) and the payload size that yields. +static constexpr uint8_t PZEM_REGISTER_COUNT = 64; +static constexpr size_t PZEM_PAYLOAD_SIZE = PZEM_REGISTER_COUNT * 2; + +// Failed polls tolerated before the readings are blanked; one collision on a shared RS-485 bus should +// not take every entity unavailable for a whole update interval. +static constexpr uint8_t MAX_CONSECUTIVE_READ_FAILURES = 3; + +// The energy reset command as it goes on the wire: function code, reserved byte, phase selector. The +// hub adds the address and CRC. Split out so the phase byte can be pinned by a test. +constexpr std::array build_reset_pdu(ResetPhase phase) { + return {PZEM_CMD_RESET_ENERGY, 0x00, static_cast(phase)}; +} + +class PZEM6L24 final : public PollingComponent, public modbus::ModbusClientDevice { + public: + // Per-phase sensors; each setter is named after its config key + void set_voltage_a_sensor(sensor::Sensor *sensor) { this->voltage_a_ = sensor; } + void set_voltage_b_sensor(sensor::Sensor *sensor) { this->voltage_b_ = sensor; } + void set_voltage_c_sensor(sensor::Sensor *sensor) { this->voltage_c_ = sensor; } + + void set_current_a_sensor(sensor::Sensor *sensor) { this->current_a_ = sensor; } + void set_current_b_sensor(sensor::Sensor *sensor) { this->current_b_ = sensor; } + void set_current_c_sensor(sensor::Sensor *sensor) { this->current_c_ = sensor; } + + void set_active_power_a_sensor(sensor::Sensor *sensor) { this->active_power_a_ = sensor; } + void set_active_power_b_sensor(sensor::Sensor *sensor) { this->active_power_b_ = sensor; } + void set_active_power_c_sensor(sensor::Sensor *sensor) { this->active_power_c_ = sensor; } + + void set_reactive_power_a_sensor(sensor::Sensor *sensor) { this->reactive_power_a_ = sensor; } + void set_reactive_power_b_sensor(sensor::Sensor *sensor) { this->reactive_power_b_ = sensor; } + void set_reactive_power_c_sensor(sensor::Sensor *sensor) { this->reactive_power_c_ = sensor; } + + void set_apparent_power_a_sensor(sensor::Sensor *sensor) { this->apparent_power_a_ = sensor; } + void set_apparent_power_b_sensor(sensor::Sensor *sensor) { this->apparent_power_b_ = sensor; } + void set_apparent_power_c_sensor(sensor::Sensor *sensor) { this->apparent_power_c_ = sensor; } + + void set_power_factor_a_sensor(sensor::Sensor *sensor) { this->power_factor_a_ = sensor; } + void set_power_factor_b_sensor(sensor::Sensor *sensor) { this->power_factor_b_ = sensor; } + void set_power_factor_c_sensor(sensor::Sensor *sensor) { this->power_factor_c_ = sensor; } + + void set_active_energy_a_sensor(sensor::Sensor *sensor) { this->active_energy_a_ = sensor; } + void set_active_energy_b_sensor(sensor::Sensor *sensor) { this->active_energy_b_ = sensor; } + void set_active_energy_c_sensor(sensor::Sensor *sensor) { this->active_energy_c_ = sensor; } + + void set_reactive_energy_a_sensor(sensor::Sensor *sensor) { this->reactive_energy_a_ = sensor; } + void set_reactive_energy_b_sensor(sensor::Sensor *sensor) { this->reactive_energy_b_ = sensor; } + void set_reactive_energy_c_sensor(sensor::Sensor *sensor) { this->reactive_energy_c_ = sensor; } + + void set_apparent_energy_a_sensor(sensor::Sensor *sensor) { this->apparent_energy_a_ = sensor; } + void set_apparent_energy_b_sensor(sensor::Sensor *sensor) { this->apparent_energy_b_ = sensor; } + void set_apparent_energy_c_sensor(sensor::Sensor *sensor) { this->apparent_energy_c_ = sensor; } + + // Combined sensors + void set_frequency_sensor(sensor::Sensor *sensor) { this->frequency_ = sensor; } + void set_total_active_power_sensor(sensor::Sensor *sensor) { this->total_active_power_ = sensor; } + void set_total_reactive_power_sensor(sensor::Sensor *sensor) { this->total_reactive_power_ = sensor; } + void set_total_apparent_power_sensor(sensor::Sensor *sensor) { this->total_apparent_power_ = sensor; } + void set_total_power_factor_sensor(sensor::Sensor *sensor) { this->total_power_factor_ = sensor; } + void set_total_active_energy_sensor(sensor::Sensor *sensor) { this->total_active_energy_ = sensor; } + void set_total_reactive_energy_sensor(sensor::Sensor *sensor) { this->total_reactive_energy_ = sensor; } + void set_total_apparent_energy_sensor(sensor::Sensor *sensor) { this->total_apparent_energy_ = sensor; } + + // Queues the energy reset command for the selected phase(s); the pzem6l24.reset_energy action calls this. + void reset_energy(ResetPhase phase_option); + + void update() override; + + void on_response(std::span request_pdu, std::span response_pdu) override; + + void on_error(std::span request_pdu, modbus::ExceptionCode exception_code) override; + + bool on_no_response(std::span request_pdu) override; + + void on_not_sent(std::span request_pdu) override; + + void dump_config() override; + + protected: + void publish_(const uint8_t *data); + void request_failed_(std::span request_pdu); + void read_failed_(); + void read_finished_(); + + // Register reads accepted by the hub but not yet resolved by a terminal callback. + uint8_t reads_outstanding_{0}; + // Failed polls since the last good one; stops counting at MAX_CONSECUTIVE_READ_FAILURES. + uint8_t consecutive_failures_{0}; + + // Per-phase sensors + sensor::Sensor *voltage_a_{nullptr}; + sensor::Sensor *voltage_b_{nullptr}; + sensor::Sensor *voltage_c_{nullptr}; + + sensor::Sensor *current_a_{nullptr}; + sensor::Sensor *current_b_{nullptr}; + sensor::Sensor *current_c_{nullptr}; + + sensor::Sensor *active_power_a_{nullptr}; + sensor::Sensor *active_power_b_{nullptr}; + sensor::Sensor *active_power_c_{nullptr}; + + sensor::Sensor *reactive_power_a_{nullptr}; + sensor::Sensor *reactive_power_b_{nullptr}; + sensor::Sensor *reactive_power_c_{nullptr}; + + sensor::Sensor *apparent_power_a_{nullptr}; + sensor::Sensor *apparent_power_b_{nullptr}; + sensor::Sensor *apparent_power_c_{nullptr}; + + sensor::Sensor *power_factor_a_{nullptr}; + sensor::Sensor *power_factor_b_{nullptr}; + sensor::Sensor *power_factor_c_{nullptr}; + + sensor::Sensor *active_energy_a_{nullptr}; + sensor::Sensor *active_energy_b_{nullptr}; + sensor::Sensor *active_energy_c_{nullptr}; + + sensor::Sensor *reactive_energy_a_{nullptr}; + sensor::Sensor *reactive_energy_b_{nullptr}; + sensor::Sensor *reactive_energy_c_{nullptr}; + + sensor::Sensor *apparent_energy_a_{nullptr}; + sensor::Sensor *apparent_energy_b_{nullptr}; + sensor::Sensor *apparent_energy_c_{nullptr}; + + // Combined sensors + sensor::Sensor *frequency_{nullptr}; + sensor::Sensor *total_active_power_{nullptr}; + sensor::Sensor *total_reactive_power_{nullptr}; + sensor::Sensor *total_apparent_power_{nullptr}; + sensor::Sensor *total_power_factor_{nullptr}; + sensor::Sensor *total_active_energy_{nullptr}; + sensor::Sensor *total_reactive_energy_{nullptr}; + sensor::Sensor *total_apparent_energy_{nullptr}; +}; + +} // namespace esphome::pzem6l24 diff --git a/esphome/components/pzem6l24/sensor.py b/esphome/components/pzem6l24/sensor.py new file mode 100644 index 0000000000..fd0430d85b --- /dev/null +++ b/esphome/components/pzem6l24/sensor.py @@ -0,0 +1,198 @@ +from esphome import automation +from esphome.automation import maybe_simple_id +import esphome.codegen as cg +from esphome.components import modbus, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ADDRESS, + CONF_FREQUENCY, + CONF_ID, + DEVICE_CLASS_APPARENT_POWER, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_FREQUENCY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, + DEVICE_CLASS_REACTIVE_ENERGY, + DEVICE_CLASS_REACTIVE_POWER, + DEVICE_CLASS_VOLTAGE, + ICON_CURRENT_AC, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_AMPERE, + UNIT_HERTZ, + UNIT_KILOVOLT_AMPS_HOURS, + UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + UNIT_KILOWATT_HOURS, + UNIT_VOLT, + UNIT_VOLT_AMPS, + UNIT_VOLT_AMPS_REACTIVE, + UNIT_WATT, +) +from esphome.types import ConfigType + +AUTO_LOAD = ["modbus"] +CODEOWNERS = ["@nuttytree"] + +pzem6l24_ns = cg.esphome_ns.namespace("pzem6l24") +PZEM6L24 = pzem6l24_ns.class_( + "PZEM6L24", cg.PollingComponent, modbus.ModbusClientDevice +) + +ResetPhase = pzem6l24_ns.enum("ResetPhase") +RESET_PHASE_OPTIONS = { + "all": ResetPhase.RESET_PHASE_ALL, + "a": ResetPhase.RESET_PHASE_A, + "b": ResetPhase.RESET_PHASE_B, + "c": ResetPhase.RESET_PHASE_C, + "combined": ResetPhase.RESET_PHASE_COMBINED, +} + +# Combined config keys +CONF_TOTAL_ACTIVE_POWER = "total_active_power" +CONF_TOTAL_REACTIVE_POWER = "total_reactive_power" +CONF_TOTAL_APPARENT_POWER = "total_apparent_power" +CONF_TOTAL_POWER_FACTOR = "total_power_factor" +CONF_TOTAL_ACTIVE_ENERGY = "total_active_energy" +CONF_TOTAL_REACTIVE_ENERGY = "total_reactive_energy" +CONF_TOTAL_APPARENT_ENERGY = "total_apparent_energy" +CONF_PHASE = "phase" + + +_VOLTAGE_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, +) +_CURRENT_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, +) +_ACTIVE_POWER_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, +) +_REACTIVE_POWER_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT_AMPS_REACTIVE, + accuracy_decimals=1, + device_class=DEVICE_CLASS_REACTIVE_POWER, + state_class=STATE_CLASS_MEASUREMENT, +) +_APPARENT_POWER_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT_AMPS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_APPARENT_POWER, + state_class=STATE_CLASS_MEASUREMENT, +) +_POWER_FACTOR_SCHEMA = sensor.sensor_schema( + accuracy_decimals=2, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, +) +_ACTIVE_ENERGY_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, +) +_REACTIVE_ENERGY_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_REACTIVE_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, +) +_APPARENT_ENERGY_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_HOURS, + accuracy_decimals=1, + state_class=STATE_CLASS_TOTAL_INCREASING, +) +_FREQUENCY_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_HERTZ, + icon=ICON_CURRENT_AC, + accuracy_decimals=2, + device_class=DEVICE_CLASS_FREQUENCY, + state_class=STATE_CLASS_MEASUREMENT, +) + +# Quantities measured once per phase; the config key is "_". +_PHASE_QUANTITIES = { + "voltage": _VOLTAGE_SCHEMA, + "current": _CURRENT_SCHEMA, + "active_power": _ACTIVE_POWER_SCHEMA, + "reactive_power": _REACTIVE_POWER_SCHEMA, + "apparent_power": _APPARENT_POWER_SCHEMA, + "power_factor": _POWER_FACTOR_SCHEMA, + "active_energy": _ACTIVE_ENERGY_SCHEMA, + "reactive_energy": _REACTIVE_ENERGY_SCHEMA, + "apparent_energy": _APPARENT_ENERGY_SCHEMA, +} + +# Quantities the meter reports once, keyed by config key. +_COMBINED_QUANTITIES = { + CONF_FREQUENCY: _FREQUENCY_SCHEMA, + CONF_TOTAL_ACTIVE_POWER: _ACTIVE_POWER_SCHEMA, + CONF_TOTAL_REACTIVE_POWER: _REACTIVE_POWER_SCHEMA, + CONF_TOTAL_APPARENT_POWER: _APPARENT_POWER_SCHEMA, + CONF_TOTAL_POWER_FACTOR: _POWER_FACTOR_SCHEMA, + CONF_TOTAL_ACTIVE_ENERGY: _ACTIVE_ENERGY_SCHEMA, + CONF_TOTAL_REACTIVE_ENERGY: _REACTIVE_ENERGY_SCHEMA, + CONF_TOTAL_APPARENT_ENERGY: _APPARENT_ENERGY_SCHEMA, +} + +# Every sensor by config key; the C++ setter is "set__sensor". +_SENSORS = { + f"{name}_{phase}": schema + for name, schema in _PHASE_QUANTITIES.items() + for phase in "abc" +} | _COMBINED_QUANTITIES + +# The meter answers unit addresses 1 to 247; 0 is the Modbus broadcast address and 248 to 255 are reserved. +_ADDRESS_SCHEMA = cv.All( + cv.hex_uint8_t, + cv.Range(min=1, max=247, msg="The PZEM-6L24 answers unit addresses 1 to 247 only"), +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(PZEM6L24), + **{cv.Optional(key): schema for key, schema in _SENSORS.items()}, + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(modbus.modbus_device_schema(0x01)) + .extend({cv.Optional(CONF_ADDRESS, default=0x01): _ADDRESS_SCHEMA}) +) + + +automation.register_apply_action( + "pzem6l24.reset_energy", + maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(PZEM6L24), + cv.Optional(CONF_PHASE, default="all"): cv.enum( + RESET_PHASE_OPTIONS, lower=True + ), + } + ), + automation.ApplyField(CONF_PHASE, "reset_energy", ResetPhase), +) + + +FINAL_VALIDATE_SCHEMA = modbus.final_validate_modbus_device("pzem6l24", role="client") + + +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 key in _SENSORS: + if (conf := config.get(key)) is not None: + sens = await sensor.new_sensor(conf) + cg.add(getattr(var, f"set_{key}_sensor")(sens)) diff --git a/tests/component_tests/pzem6l24/__init__.py b/tests/component_tests/pzem6l24/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/pzem6l24/test_address.py b/tests/component_tests/pzem6l24/test_address.py new file mode 100644 index 0000000000..a9e14361c0 --- /dev/null +++ b/tests/component_tests/pzem6l24/test_address.py @@ -0,0 +1,28 @@ +"""The PZEM-6L24 answers unit addresses 1 to 247 only.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import modbus +from esphome.components.pzem6l24.sensor import CONFIG_SCHEMA +from esphome.const import CONF_ADDRESS +from esphome.types import ConfigType + + +def _sensor(**extra: object) -> ConfigType: + return CONFIG_SCHEMA({modbus.CONF_MODBUS_ID: "bus", **extra}) + + +def test_address_defaults_to_one() -> None: + assert _sensor()[CONF_ADDRESS] == 1 + + +@pytest.mark.parametrize("address", [1, 247]) +def test_address_in_unit_range_accepted(address: int) -> None: + assert _sensor(**{CONF_ADDRESS: address})[CONF_ADDRESS] == address + + +@pytest.mark.parametrize("address", [0, 248, 255]) +def test_address_outside_unit_range_rejected(address: int) -> None: + with pytest.raises(cv.Invalid): + _sensor(**{CONF_ADDRESS: address}) diff --git a/tests/components/pzem6l24/common.h b/tests/components/pzem6l24/common.h new file mode 100644 index 0000000000..5ceab79517 --- /dev/null +++ b/tests/components/pzem6l24/common.h @@ -0,0 +1,46 @@ +#pragma once +#include +#include +#include +#include +#include "esphome/components/pzem6l24/pzem6l24.h" + +namespace esphome::pzem6l24::testing { + +// The request PDU update() puts on the wire: read 64 input registers from 0x0000. +static constexpr uint8_t READ_REQUEST_PDU[] = {0x04, 0x00, 0x00, 0x00, PZEM_REGISTER_COUNT}; +// The request PDU reset_energy() puts on the wire for every phase. +static constexpr auto RESET_REQUEST_PDU = build_reset_pdu(RESET_PHASE_ALL); + +// Builds the 128-byte register payload the meter returns, writing each quantity in the +// little-endian byte order documented in pzem6l24.cpp's register map. +class PayloadBuilder { + public: + PayloadBuilder &u8(size_t offset, uint8_t value) { + this->data_[offset] = value; + return *this; + } + PayloadBuilder &u16(size_t offset, uint16_t value) { + this->data_[offset] = static_cast(value & 0xFF); + this->data_[offset + 1] = static_cast(value >> 8); + return *this; + } + PayloadBuilder &u32(size_t offset, uint32_t value) { + this->u16(offset, static_cast(value & 0xFFFF)); + this->u16(offset + 2, static_cast(value >> 16)); + return *this; + } + PayloadBuilder &i32(size_t offset, int32_t value) { return this->u32(offset, static_cast(value)); } + + // Wraps the payload in a read-input-registers response PDU: function code, byte count, data. + std::vector response_pdu() const { + std::vector pdu{0x04, static_cast(PZEM_PAYLOAD_SIZE)}; + pdu.insert(pdu.end(), this->data_.begin(), this->data_.end()); + return pdu; + } + + protected: + std::array data_{}; +}; + +} // namespace esphome::pzem6l24::testing diff --git a/tests/components/pzem6l24/common.yaml b/tests/components/pzem6l24/common.yaml new file mode 100644 index 0000000000..25cb86cc84 --- /dev/null +++ b/tests/components/pzem6l24/common.yaml @@ -0,0 +1,82 @@ +esphome: + on_boot: + then: + - pzem6l24.reset_energy: pzem6l24_1 + - pzem6l24.reset_energy: + id: pzem6l24_1 + phase: b + +sensor: + - platform: pzem6l24 + modbus_id: modbus_bus + id: pzem6l24_1 + voltage_a: + name: PZEM6L24 Voltage A + voltage_b: + name: PZEM6L24 Voltage B + voltage_c: + name: PZEM6L24 Voltage C + current_a: + name: PZEM6L24 Current A + current_b: + name: PZEM6L24 Current B + current_c: + name: PZEM6L24 Current C + active_power_a: + name: PZEM6L24 Active Power A + active_power_b: + name: PZEM6L24 Active Power B + active_power_c: + name: PZEM6L24 Active Power C + reactive_power_a: + name: PZEM6L24 Reactive Power A + reactive_power_b: + name: PZEM6L24 Reactive Power B + reactive_power_c: + name: PZEM6L24 Reactive Power C + apparent_power_a: + name: PZEM6L24 Apparent Power A + apparent_power_b: + name: PZEM6L24 Apparent Power B + apparent_power_c: + name: PZEM6L24 Apparent Power C + power_factor_a: + name: PZEM6L24 Power Factor A + power_factor_b: + name: PZEM6L24 Power Factor B + power_factor_c: + name: PZEM6L24 Power Factor C + active_energy_a: + name: PZEM6L24 Active Energy A + active_energy_b: + name: PZEM6L24 Active Energy B + active_energy_c: + name: PZEM6L24 Active Energy C + reactive_energy_a: + name: PZEM6L24 Reactive Energy A + reactive_energy_b: + name: PZEM6L24 Reactive Energy B + reactive_energy_c: + name: PZEM6L24 Reactive Energy C + apparent_energy_a: + name: PZEM6L24 Apparent Energy A + apparent_energy_b: + name: PZEM6L24 Apparent Energy B + apparent_energy_c: + name: PZEM6L24 Apparent Energy C + frequency: + name: PZEM6L24 Frequency + total_active_power: + name: PZEM6L24 Total Active Power + total_reactive_power: + name: PZEM6L24 Total Reactive Power + total_apparent_power: + name: PZEM6L24 Total Apparent Power + total_power_factor: + name: PZEM6L24 Total Power Factor + total_active_energy: + name: PZEM6L24 Total Active Energy + total_reactive_energy: + name: PZEM6L24 Total Reactive Energy + total_apparent_energy: + name: PZEM6L24 Total Apparent Energy diff --git a/tests/components/pzem6l24/sensor/pzem6l24_test.cpp b/tests/components/pzem6l24/sensor/pzem6l24_test.cpp new file mode 100644 index 0000000000..6dbb4ebf02 --- /dev/null +++ b/tests/components/pzem6l24/sensor/pzem6l24_test.cpp @@ -0,0 +1,315 @@ +#include "../common.h" + +#include +#include +#include + +namespace esphome::pzem6l24::testing { + +namespace { + +// A payload whose raw values are distinct per quantity, so a transposed offset shows up as a wrong +// value rather than a coincidental match. Registers the component does not read are filled with 0xEE. +PayloadBuilder make_reference_payload() { + PayloadBuilder p; + // Voltages (×0.1 V) + p.u16(0, 2301).u16(2, 2302).u16(4, 2303); + // Currents (×0.01 A) + p.u16(6, 1234).u16(8, 1235).u16(10, 1236); + // Frequency phase A (×0.01 Hz); phases B/C and the angle registers are not read. + p.u16(12, 5001); + for (size_t offset = 14; offset < 28; offset++) { + p.u8(offset, 0xEE); + } + // Active power (×0.1 W, signed); phase C exercises the sign extension. + p.i32(28, 15001).i32(32, 15002).i32(36, -15003).i32(64, 45006); + // Reactive power (×0.1 var, signed) + p.i32(40, 2001).i32(44, 2002).i32(48, 2003).i32(68, 6006); + // Apparent power (×0.1 VA, signed) + p.i32(52, 3001).i32(56, 3002).i32(60, 3003).i32(72, 9006); + // Power factors (×0.01), packed two per register: 77=A, 76=B, 79=C, 78=combined + p.u8(77, 98).u8(76, 97).u8(79, 96).u8(78, 95); + // Active energy (×0.1 kWh) + p.u32(80, 100001).u32(84, 100002).u32(88, 100003).u32(116, 300006); + // Reactive energy (×0.1 kvarh) + p.u32(92, 200001).u32(96, 200002).u32(100, 200003).u32(120, 600006); + // Apparent energy (×0.1 kVAh); phase A exceeds 16 bits to exercise the high word. + p.u32(104, 300001).u32(108, 300002).u32(112, 300003).u32(124, 900006); + return p; +} + +// One PZEM6L24 with every sensor it can drive attached, so a test can assert that each byte offset in +// the register map reaches the sensor it is documented to feed. +struct Harness { + PZEM6L24 pzem; + + sensor::Sensor voltage_a, voltage_b, voltage_c; + sensor::Sensor current_a, current_b, current_c; + sensor::Sensor active_power_a, active_power_b, active_power_c; + sensor::Sensor reactive_power_a, reactive_power_b, reactive_power_c; + sensor::Sensor apparent_power_a, apparent_power_b, apparent_power_c; + sensor::Sensor power_factor_a, power_factor_b, power_factor_c; + sensor::Sensor active_energy_a, active_energy_b, active_energy_c; + sensor::Sensor reactive_energy_a, reactive_energy_b, reactive_energy_c; + sensor::Sensor apparent_energy_a, apparent_energy_b, apparent_energy_c; + sensor::Sensor frequency; + sensor::Sensor total_active_power, total_reactive_power, total_apparent_power; + sensor::Sensor total_power_factor; + sensor::Sensor total_active_energy, total_reactive_energy, total_apparent_energy; + + Harness() { + this->pzem.set_voltage_a_sensor(&this->voltage_a); + this->pzem.set_voltage_b_sensor(&this->voltage_b); + this->pzem.set_voltage_c_sensor(&this->voltage_c); + this->pzem.set_current_a_sensor(&this->current_a); + this->pzem.set_current_b_sensor(&this->current_b); + this->pzem.set_current_c_sensor(&this->current_c); + this->pzem.set_active_power_a_sensor(&this->active_power_a); + this->pzem.set_active_power_b_sensor(&this->active_power_b); + this->pzem.set_active_power_c_sensor(&this->active_power_c); + this->pzem.set_reactive_power_a_sensor(&this->reactive_power_a); + this->pzem.set_reactive_power_b_sensor(&this->reactive_power_b); + this->pzem.set_reactive_power_c_sensor(&this->reactive_power_c); + this->pzem.set_apparent_power_a_sensor(&this->apparent_power_a); + this->pzem.set_apparent_power_b_sensor(&this->apparent_power_b); + this->pzem.set_apparent_power_c_sensor(&this->apparent_power_c); + this->pzem.set_power_factor_a_sensor(&this->power_factor_a); + this->pzem.set_power_factor_b_sensor(&this->power_factor_b); + this->pzem.set_power_factor_c_sensor(&this->power_factor_c); + this->pzem.set_active_energy_a_sensor(&this->active_energy_a); + this->pzem.set_active_energy_b_sensor(&this->active_energy_b); + this->pzem.set_active_energy_c_sensor(&this->active_energy_c); + this->pzem.set_reactive_energy_a_sensor(&this->reactive_energy_a); + this->pzem.set_reactive_energy_b_sensor(&this->reactive_energy_b); + this->pzem.set_reactive_energy_c_sensor(&this->reactive_energy_c); + this->pzem.set_apparent_energy_a_sensor(&this->apparent_energy_a); + this->pzem.set_apparent_energy_b_sensor(&this->apparent_energy_b); + this->pzem.set_apparent_energy_c_sensor(&this->apparent_energy_c); + this->pzem.set_frequency_sensor(&this->frequency); + this->pzem.set_total_active_power_sensor(&this->total_active_power); + this->pzem.set_total_reactive_power_sensor(&this->total_reactive_power); + this->pzem.set_total_apparent_power_sensor(&this->total_apparent_power); + this->pzem.set_total_power_factor_sensor(&this->total_power_factor); + this->pzem.set_total_active_energy_sensor(&this->total_active_energy); + this->pzem.set_total_reactive_energy_sensor(&this->total_reactive_energy); + this->pzem.set_total_apparent_energy_sensor(&this->total_apparent_energy); + } + + // A good poll, as the hub would deliver it. + void poll_ok() { this->pzem.on_response(READ_REQUEST_PDU, make_reference_payload().response_pdu()); } +}; + +// A good poll followed by MAX_CONSECUTIVE_READ_FAILURES calls of `fail` must blank every reading. +template void expect_blanked_after_repeated(Harness &h, F &&fail) { + h.poll_ok(); + ASSERT_FALSE(std::isnan(h.voltage_a.state)); + for (int i = 0; i < MAX_CONSECUTIVE_READ_FAILURES; i++) { + fail(); + } + EXPECT_TRUE(std::isnan(h.voltage_a.state)); + EXPECT_TRUE(std::isnan(h.total_active_energy.state)); +} + +} // namespace + +TEST(PZEM6L24Test, DecodesEveryRegisterToItsSensor) { + Harness h; + const auto response = make_reference_payload().response_pdu(); + + h.pzem.on_response(READ_REQUEST_PDU, response); + + EXPECT_FLOAT_EQ(h.voltage_a.state, 2301 * 0.1f); + EXPECT_FLOAT_EQ(h.voltage_b.state, 2302 * 0.1f); + EXPECT_FLOAT_EQ(h.voltage_c.state, 2303 * 0.1f); + + EXPECT_FLOAT_EQ(h.current_a.state, 1234 * 0.01f); + EXPECT_FLOAT_EQ(h.current_b.state, 1235 * 0.01f); + EXPECT_FLOAT_EQ(h.current_c.state, 1236 * 0.01f); + + EXPECT_FLOAT_EQ(h.frequency.state, 5001 * 0.01f); + + EXPECT_FLOAT_EQ(h.active_power_a.state, 15001 * 0.1f); + EXPECT_FLOAT_EQ(h.active_power_b.state, 15002 * 0.1f); + EXPECT_FLOAT_EQ(h.active_power_c.state, -15003 * 0.1f); + EXPECT_FLOAT_EQ(h.total_active_power.state, 45006 * 0.1f); + + EXPECT_FLOAT_EQ(h.reactive_power_a.state, 2001 * 0.1f); + EXPECT_FLOAT_EQ(h.reactive_power_b.state, 2002 * 0.1f); + EXPECT_FLOAT_EQ(h.reactive_power_c.state, 2003 * 0.1f); + EXPECT_FLOAT_EQ(h.total_reactive_power.state, 6006 * 0.1f); + + EXPECT_FLOAT_EQ(h.apparent_power_a.state, 3001 * 0.1f); + EXPECT_FLOAT_EQ(h.apparent_power_b.state, 3002 * 0.1f); + EXPECT_FLOAT_EQ(h.apparent_power_c.state, 3003 * 0.1f); + EXPECT_FLOAT_EQ(h.total_apparent_power.state, 9006 * 0.1f); + + EXPECT_FLOAT_EQ(h.power_factor_a.state, 98 * 0.01f); + EXPECT_FLOAT_EQ(h.power_factor_b.state, 97 * 0.01f); + EXPECT_FLOAT_EQ(h.power_factor_c.state, 96 * 0.01f); + EXPECT_FLOAT_EQ(h.total_power_factor.state, 95 * 0.01f); + + EXPECT_FLOAT_EQ(h.active_energy_a.state, 100001 * 0.1f); + EXPECT_FLOAT_EQ(h.active_energy_b.state, 100002 * 0.1f); + EXPECT_FLOAT_EQ(h.active_energy_c.state, 100003 * 0.1f); + EXPECT_FLOAT_EQ(h.total_active_energy.state, 300006 * 0.1f); + + EXPECT_FLOAT_EQ(h.reactive_energy_a.state, 200001 * 0.1f); + EXPECT_FLOAT_EQ(h.reactive_energy_b.state, 200002 * 0.1f); + EXPECT_FLOAT_EQ(h.reactive_energy_c.state, 200003 * 0.1f); + EXPECT_FLOAT_EQ(h.total_reactive_energy.state, 600006 * 0.1f); + + EXPECT_FLOAT_EQ(h.apparent_energy_a.state, 300001 * 0.1f); + EXPECT_FLOAT_EQ(h.apparent_energy_b.state, 300002 * 0.1f); + EXPECT_FLOAT_EQ(h.apparent_energy_c.state, 300003 * 0.1f); + EXPECT_FLOAT_EQ(h.total_apparent_energy.state, 900006 * 0.1f); +} + +// Unconfigured sensors must be skipped rather than dereferenced. +TEST(PZEM6L24Test, PublishesOnlyConfiguredSensors) { + PZEM6L24 pzem; + sensor::Sensor voltage_a; + pzem.set_voltage_a_sensor(&voltage_a); + + pzem.on_response(READ_REQUEST_PDU, make_reference_payload().response_pdu()); + + EXPECT_TRUE(voltage_a.has_state()); + EXPECT_FLOAT_EQ(voltage_a.state, 2301 * 0.1f); +} + +// The acknowledgement of the 0x42 reset command carries no measurements and must not be decoded. +TEST(PZEM6L24Test, IgnoresResetAcknowledgement) { + Harness h; + const uint8_t ack_pdu[] = {0x42, 0x00, 0x0F}; + + h.pzem.on_response(RESET_REQUEST_PDU, ack_pdu); + + EXPECT_FALSE(h.voltage_a.has_state()); + EXPECT_FALSE(h.total_active_energy.has_state()); +} + +// A truncated response must be rejected rather than decoded from out-of-range bytes. +TEST(PZEM6L24Test, PublishesNanOnShortPayload) { + Harness h; + std::vector short_pdu{0x04, 10}; + short_pdu.resize(12, 0x11); + expect_blanked_after_repeated(h, [&] { h.pzem.on_response(READ_REQUEST_PDU, short_pdu); }); +} + +// A byte-count-0 reply the hub still dispatches is as undecodable as any other wrong size. +TEST(PZEM6L24Test, PublishesNanOnEmptyPayload) { + Harness h; + const uint8_t empty_pdu[] = {0x04, 0x00}; + expect_blanked_after_repeated(h, [&] { h.pzem.on_response(READ_REQUEST_PDU, empty_pdu); }); +} + +// A response longer than the register map did not come from the expected frame layout. +TEST(PZEM6L24Test, PublishesNanOnOversizedPayload) { + Harness h; + auto long_pdu = make_reference_payload().response_pdu(); + long_pdu.push_back(0x11); + expect_blanked_after_repeated(h, [&] { h.pzem.on_response(READ_REQUEST_PDU, long_pdu); }); +} + +TEST(PZEM6L24Test, PublishesNanWhenTheMeterDoesNotRespond) { + Harness h; + expect_blanked_after_repeated(h, [&] { EXPECT_FALSE(h.pzem.on_no_response(READ_REQUEST_PDU)); }); +} + +TEST(PZEM6L24Test, PublishesNanOnExceptionResponse) { + Harness h; + expect_blanked_after_repeated( + h, [&] { h.pzem.on_error(READ_REQUEST_PDU, modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS); }); +} + +// A read dropped from the transmit queue never reaches the meter. +TEST(PZEM6L24Test, PublishesNanWhenTheReadIsNotSent) { + Harness h; + expect_blanked_after_repeated(h, [&] { h.pzem.on_not_sent(READ_REQUEST_PDU); }); +} + +// Readings ride out isolated failures, and a good poll restarts the count. +TEST(PZEM6L24Test, KeepsReadingsUntilFailuresReachTheThreshold) { + Harness h; + h.poll_ok(); + + for (int i = 0; i < MAX_CONSECUTIVE_READ_FAILURES - 1; i++) { + h.pzem.on_no_response(READ_REQUEST_PDU); + } + EXPECT_FLOAT_EQ(h.voltage_a.state, 2301 * 0.1f); + + h.poll_ok(); + for (int i = 0; i < MAX_CONSECUTIVE_READ_FAILURES - 1; i++) { + h.pzem.on_error(READ_REQUEST_PDU, modbus::ExceptionCode::SERVICE_DEVICE_FAILURE); + } + + EXPECT_FLOAT_EQ(h.voltage_a.state, 2301 * 0.1f); +} + +// update()'s refusal branch is pinned against a real hub in both directions. +TEST(PZEM6L24Test, KeepsReadingsWhenAPollIsAbsorbedIntoAReadInFlight) { + // Declared before the harness so it outlives it: ~ModbusClientDevice clears its frames from the hub. + modbus::ModbusClientHub hub; + Harness h; + h.pzem.set_parent(&hub); + h.pzem.set_address(0x01); + h.poll_ok(); + + // A read entry serves at most two requests; the third poll is refused while two callbacks are owed. + h.pzem.update(); + h.pzem.update(); + h.pzem.update(); + + EXPECT_FLOAT_EQ(h.voltage_a.state, 2301 * 0.1f); + EXPECT_FLOAT_EQ(h.total_active_energy.state, 300006 * 0.1f); +} + +// A refusal with nothing in flight means no callback is coming, so it counts as a failed poll. +TEST(PZEM6L24Test, PublishesNanWhenThePollCannotBeQueued) { + modbus::ModbusClientHub hub; + Harness h; + h.pzem.set_parent(&hub); + h.pzem.set_address(0x01); + + // Fill the transmit queue with frames for another address, so the poll is refused rather than absorbed. + for (uint16_t i = 0; i < modbus::MODBUS_TX_BUFFER_SIZE; i++) { + const uint8_t filler_pdu[] = {0x04, 0x00, static_cast(i), 0x00, 0x01}; + ASSERT_TRUE(hub.queue_pdu(0x02, filler_pdu)); + } + + expect_blanked_after_repeated(h, [&] { h.pzem.update(); }); +} + +// A failed reset command says nothing about the measurements, so it must not blank them. +TEST(PZEM6L24Test, KeepsReadingsWhenTheResetCommandFails) { + Harness h; + h.poll_ok(); + + for (int i = 0; i < MAX_CONSECUTIVE_READ_FAILURES; i++) { + EXPECT_FALSE(h.pzem.on_no_response(RESET_REQUEST_PDU)); + h.pzem.on_error(RESET_REQUEST_PDU, modbus::ExceptionCode::ILLEGAL_FUNCTION); + h.pzem.on_not_sent(RESET_REQUEST_PDU); + } + + EXPECT_FLOAT_EQ(h.voltage_a.state, 2301 * 0.1f); + EXPECT_FLOAT_EQ(h.total_active_energy.state, 300006 * 0.1f); +} + +// The reset is irreversible, so the phase selector byte is pinned here. +TEST(PZEM6L24Test, BuildsTheResetFrameForEveryPhase) { + const std::array, 5> cases{{ + {RESET_PHASE_A, 0x00}, + {RESET_PHASE_B, 0x01}, + {RESET_PHASE_C, 0x02}, + {RESET_PHASE_COMBINED, 0x03}, + {RESET_PHASE_ALL, 0x0F}, + }}; + + for (const auto &[phase, selector] : cases) { + const auto pdu = build_reset_pdu(phase); + EXPECT_EQ(pdu[0], 0x42) << "function code for selector " << static_cast(selector); + EXPECT_EQ(pdu[1], 0x00) << "reserved byte for selector " << static_cast(selector); + EXPECT_EQ(pdu[2], selector); + } +} + +} // namespace esphome::pzem6l24::testing diff --git a/tests/components/pzem6l24/test.esp32-idf.yaml b/tests/components/pzem6l24/test.esp32-idf.yaml new file mode 100644 index 0000000000..9fb11cd095 --- /dev/null +++ b/tests/components/pzem6l24/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + flow_control_pin: GPIO13 + +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + pzem6l24: !include common.yaml diff --git a/tests/components/pzem6l24/test.esp8266-ard.yaml b/tests/components/pzem6l24/test.esp8266-ard.yaml new file mode 100644 index 0000000000..e2b6a4d58f --- /dev/null +++ b/tests/components/pzem6l24/test.esp8266-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + +packages: + modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml + pzem6l24: !include common.yaml diff --git a/tests/components/pzem6l24/test.rp2040-ard.yaml b/tests/components/pzem6l24/test.rp2040-ard.yaml new file mode 100644 index 0000000000..e2b164efd2 --- /dev/null +++ b/tests/components/pzem6l24/test.rp2040-ard.yaml @@ -0,0 +1,7 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + modbus: !include ../../test_build_components/common/modbus/rp2040-ard.yaml + pzem6l24: !include common.yaml