diff --git a/CODEOWNERS b/CODEOWNERS index e1287ca275..13fae0664b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -350,6 +350,7 @@ esphome/components/mipi_spi/* @clydebarrow esphome/components/mitsubishi/* @RubyBailey esphome/components/mitsubishi_cn105/* @crnjan esphome/components/mixer/speaker/* @kahrendt +esphome/components/mk2pvrouter/* @FredM67 esphome/components/mlx90393/* @functionpointer esphome/components/mlx90614/* @jesserockz esphome/components/mmc5603/* @benhoff diff --git a/esphome/components/growatt_solar/growatt_solar.cpp b/esphome/components/growatt_solar/growatt_solar.cpp index d2102496a2..08c3966ed9 100644 --- a/esphome/components/growatt_solar/growatt_solar.cpp +++ b/esphome/components/growatt_solar/growatt_solar.cpp @@ -1,105 +1,68 @@ #include "growatt_solar.h" -#include "esphome/core/application.h" -#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::growatt_solar { +namespace helpers = modbus::helpers; + static const char *const TAG = "growatt_solar"; static const uint8_t MODBUS_REGISTER_COUNT[] = {33, 95}; // indexed with enum GrowattProtocolVersion -void GrowattSolar::loop() { - // If update() was unable to send we retry until we can send. - if (!this->waiting_to_update_) - return; - update(); -} +void GrowattSolar::update() { this->read_input_registers(0, MODBUS_REGISTER_COUNT[this->protocol_version_]); } -void GrowattSolar::update() { - // If our last send has had no reply yet, and it wasn't that long ago, do nothing. - const uint32_t now = App.get_loop_component_start_time(); - if (now - this->last_send_ < this->get_update_interval() / 2) { - return; - } - - // The bus might be slow, or there might be other devices, or other components might be talking to our device. - if (!this->ready_for_immediate_send()) { - this->waiting_to_update_ = true; - return; - } - - this->waiting_to_update_ = false; - this->read_input_registers(0, MODBUS_REGISTER_COUNT[this->protocol_version_]); - this->last_send_ = millis(); -} - -void GrowattSolar::on_response(std::span request_pdu, std::span response_pdu) { - auto data = modbus::helpers::server_pdu_payload(response_pdu); - // Other components might be sending commands to our device. But we don't get called with enough - // context to know what is what. So if we didn't do a send, we ignore the data. - if (!this->last_send_) - return; - this->last_send_ = 0; - - // Also ignore the data if the message is too short. Otherwise we will publish invalid values. - if (data.size() < MODBUS_REGISTER_COUNT[this->protocol_version_] * 2) +void GrowattSolar::on_read_input_registers(uint16_t start_address, std::span registers, + modbus::ResponseStatus status) { + if (!modbus::succeeded(status)) return; - auto publish_1_reg_sensor_state = [&](sensor::Sensor *sensor, size_t i, float unit) -> void { + // Publish a sensor if its register(s) are in this response; skipping absent registers keeps this + // correct for any read range, so the poll may be split into multiple requests. + auto publish_1_reg_sensor_state = [&](sensor::Sensor *sensor, uint16_t reg, float unit) -> void { if (sensor == nullptr) return; - float value = encode_uint16(data[i * 2], data[i * 2 + 1]) * unit; - sensor->publish_state(value); + if (auto value = helpers::value_at(registers, start_address, reg)) + sensor->publish_state(*value * unit); }; - auto publish_2_reg_sensor_state = [&](sensor::Sensor *sensor, size_t reg1, size_t reg2, float unit) -> void { - float value = ((encode_uint16(data[reg1 * 2], data[reg1 * 2 + 1]) << 16) + - encode_uint16(data[reg2 * 2], data[reg2 * 2 + 1])) * - unit; - if (sensor != nullptr) - sensor->publish_state(value); + auto publish_2_reg_sensor_state = [&](sensor::Sensor *sensor, uint16_t reg, float unit) -> void { + if (sensor == nullptr) + return; + if (auto value = helpers::value_at(registers, start_address, reg)) + sensor->publish_state(*value * unit); }; switch (this->protocol_version_) { case RTU: { publish_1_reg_sensor_state(this->inverter_status_, RTU_INVERTER_STATUS, 1); - publish_2_reg_sensor_state(this->pv_active_power_sensor_, RTU_PV_ACTIVE_POWER, RTU_PV_ACTIVE_POWER + 1, - ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pv_active_power_sensor_, RTU_PV_ACTIVE_POWER, ONE_DEC_UNIT); publish_1_reg_sensor_state(this->pvs_[0].voltage_sensor_, RTU_PV1_VOLTAGE, ONE_DEC_UNIT); publish_1_reg_sensor_state(this->pvs_[0].current_sensor_, RTU_PV1_CURRENT, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, RTU_PV1_ACTIVE_POWER, RTU_PV1_ACTIVE_POWER + 1, - ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, RTU_PV1_ACTIVE_POWER, ONE_DEC_UNIT); publish_1_reg_sensor_state(this->pvs_[1].voltage_sensor_, RTU_PV2_VOLTAGE, ONE_DEC_UNIT); publish_1_reg_sensor_state(this->pvs_[1].current_sensor_, RTU_PV2_CURRENT, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, RTU_PV2_ACTIVE_POWER, RTU_PV2_ACTIVE_POWER + 1, - ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, RTU_PV2_ACTIVE_POWER, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->grid_active_power_sensor_, RTU_GRID_ACTIVE_POWER, RTU_GRID_ACTIVE_POWER + 1, - ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->grid_active_power_sensor_, RTU_GRID_ACTIVE_POWER, ONE_DEC_UNIT); publish_1_reg_sensor_state(this->grid_frequency_sensor_, RTU_GRID_FREQUENCY, TWO_DEC_UNIT); publish_1_reg_sensor_state(this->phases_[0].voltage_sensor_, RTU_PHASE1_VOLTAGE, ONE_DEC_UNIT); publish_1_reg_sensor_state(this->phases_[0].current_sensor_, RTU_PHASE1_CURRENT, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, RTU_PHASE1_ACTIVE_POWER, - RTU_PHASE1_ACTIVE_POWER + 1, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, RTU_PHASE1_ACTIVE_POWER, ONE_DEC_UNIT); publish_1_reg_sensor_state(this->phases_[1].voltage_sensor_, RTU_PHASE2_VOLTAGE, ONE_DEC_UNIT); publish_1_reg_sensor_state(this->phases_[1].current_sensor_, RTU_PHASE2_CURRENT, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, RTU_PHASE2_ACTIVE_POWER, - RTU_PHASE2_ACTIVE_POWER + 1, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, RTU_PHASE2_ACTIVE_POWER, ONE_DEC_UNIT); publish_1_reg_sensor_state(this->phases_[2].voltage_sensor_, RTU_PHASE3_VOLTAGE, ONE_DEC_UNIT); publish_1_reg_sensor_state(this->phases_[2].current_sensor_, RTU_PHASE3_CURRENT, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, RTU_PHASE3_ACTIVE_POWER, - RTU_PHASE3_ACTIVE_POWER + 1, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, RTU_PHASE3_ACTIVE_POWER, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->today_production_, RTU_TODAY_PRODUCTION, RTU_TODAY_PRODUCTION + 1, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->total_energy_production_, RTU_TOTAL_ENERGY_PRODUCTION, - RTU_TOTAL_ENERGY_PRODUCTION + 1, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->today_production_, RTU_TODAY_PRODUCTION, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->total_energy_production_, RTU_TOTAL_ENERGY_PRODUCTION, ONE_DEC_UNIT); publish_1_reg_sensor_state(this->inverter_module_temp_, RTU_INVERTER_MODULE_TEMP, ONE_DEC_UNIT); break; @@ -107,42 +70,33 @@ void GrowattSolar::on_response(std::span request_pdu, std::spaninverter_status_, RTU2_INVERTER_STATUS, 1); - publish_2_reg_sensor_state(this->pv_active_power_sensor_, RTU2_PV_ACTIVE_POWER, RTU2_PV_ACTIVE_POWER + 1, - ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pv_active_power_sensor_, RTU2_PV_ACTIVE_POWER, ONE_DEC_UNIT); publish_1_reg_sensor_state(this->pvs_[0].voltage_sensor_, RTU2_PV1_VOLTAGE, ONE_DEC_UNIT); publish_1_reg_sensor_state(this->pvs_[0].current_sensor_, RTU2_PV1_CURRENT, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, RTU2_PV1_ACTIVE_POWER, RTU2_PV1_ACTIVE_POWER + 1, - ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pvs_[0].active_power_sensor_, RTU2_PV1_ACTIVE_POWER, ONE_DEC_UNIT); publish_1_reg_sensor_state(this->pvs_[1].voltage_sensor_, RTU2_PV2_VOLTAGE, ONE_DEC_UNIT); publish_1_reg_sensor_state(this->pvs_[1].current_sensor_, RTU2_PV2_CURRENT, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, RTU2_PV2_ACTIVE_POWER, RTU2_PV2_ACTIVE_POWER + 1, - ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->pvs_[1].active_power_sensor_, RTU2_PV2_ACTIVE_POWER, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->grid_active_power_sensor_, RTU2_GRID_ACTIVE_POWER, RTU2_GRID_ACTIVE_POWER + 1, - ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->grid_active_power_sensor_, RTU2_GRID_ACTIVE_POWER, ONE_DEC_UNIT); publish_1_reg_sensor_state(this->grid_frequency_sensor_, RTU2_GRID_FREQUENCY, TWO_DEC_UNIT); publish_1_reg_sensor_state(this->phases_[0].voltage_sensor_, RTU2_PHASE1_VOLTAGE, ONE_DEC_UNIT); publish_1_reg_sensor_state(this->phases_[0].current_sensor_, RTU2_PHASE1_CURRENT, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, RTU2_PHASE1_ACTIVE_POWER, - RTU2_PHASE1_ACTIVE_POWER + 1, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[0].active_power_sensor_, RTU2_PHASE1_ACTIVE_POWER, ONE_DEC_UNIT); publish_1_reg_sensor_state(this->phases_[1].voltage_sensor_, RTU2_PHASE2_VOLTAGE, ONE_DEC_UNIT); publish_1_reg_sensor_state(this->phases_[1].current_sensor_, RTU2_PHASE2_CURRENT, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, RTU2_PHASE2_ACTIVE_POWER, - RTU2_PHASE2_ACTIVE_POWER + 1, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[1].active_power_sensor_, RTU2_PHASE2_ACTIVE_POWER, ONE_DEC_UNIT); publish_1_reg_sensor_state(this->phases_[2].voltage_sensor_, RTU2_PHASE3_VOLTAGE, ONE_DEC_UNIT); publish_1_reg_sensor_state(this->phases_[2].current_sensor_, RTU2_PHASE3_CURRENT, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, RTU2_PHASE3_ACTIVE_POWER, - RTU2_PHASE3_ACTIVE_POWER + 1, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->phases_[2].active_power_sensor_, RTU2_PHASE3_ACTIVE_POWER, ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->today_production_, RTU2_TODAY_PRODUCTION, RTU2_TODAY_PRODUCTION + 1, - ONE_DEC_UNIT); - publish_2_reg_sensor_state(this->total_energy_production_, RTU2_TOTAL_ENERGY_PRODUCTION, - RTU2_TOTAL_ENERGY_PRODUCTION + 1, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->today_production_, RTU2_TODAY_PRODUCTION, ONE_DEC_UNIT); + publish_2_reg_sensor_state(this->total_energy_production_, RTU2_TOTAL_ENERGY_PRODUCTION, ONE_DEC_UNIT); publish_1_reg_sensor_state(this->inverter_module_temp_, RTU2_INVERTER_MODULE_TEMP, ONE_DEC_UNIT); break; diff --git a/esphome/components/growatt_solar/growatt_solar.h b/esphome/components/growatt_solar/growatt_solar.h index a172f49001..5b96521476 100644 --- a/esphome/components/growatt_solar/growatt_solar.h +++ b/esphome/components/growatt_solar/growatt_solar.h @@ -17,59 +17,59 @@ enum GrowattProtocolVersion { }; // Register addresses for the RTU protocol. -constexpr size_t RTU_INVERTER_STATUS = 0; // length = 1 -constexpr size_t RTU_PV_ACTIVE_POWER = 1; // length = 2 -constexpr size_t RTU_PV1_VOLTAGE = 3; // length = 1 -constexpr size_t RTU_PV1_CURRENT = 4; // length = 1 -constexpr size_t RTU_PV1_ACTIVE_POWER = 5; // length = 2 -constexpr size_t RTU_PV2_VOLTAGE = 7; // length = 1 -constexpr size_t RTU_PV2_CURRENT = 8; // length = 1 -constexpr size_t RTU_PV2_ACTIVE_POWER = 9; // length = 2 -constexpr size_t RTU_GRID_ACTIVE_POWER = 11; // length = 2 -constexpr size_t RTU_GRID_FREQUENCY = 13; // length = 1 -constexpr size_t RTU_PHASE1_VOLTAGE = 14; // length = 1 -constexpr size_t RTU_PHASE1_CURRENT = 15; // length = 1 -constexpr size_t RTU_PHASE1_ACTIVE_POWER = 16; // length = 2 -constexpr size_t RTU_PHASE2_VOLTAGE = 18; // length = 1 -constexpr size_t RTU_PHASE2_CURRENT = 19; // length = 1 -constexpr size_t RTU_PHASE2_ACTIVE_POWER = 20; // length = 2 -constexpr size_t RTU_PHASE3_VOLTAGE = 22; // length = 1 -constexpr size_t RTU_PHASE3_CURRENT = 23; // length = 1 -constexpr size_t RTU_PHASE3_ACTIVE_POWER = 24; // length = 2 -constexpr size_t RTU_TODAY_PRODUCTION = 26; // length = 2 -constexpr size_t RTU_TOTAL_ENERGY_PRODUCTION = 28; // length = 2 -constexpr size_t RTU_INVERTER_MODULE_TEMP = 32; // length = 1 +constexpr uint16_t RTU_INVERTER_STATUS = 0; // length = 1 +constexpr uint16_t RTU_PV_ACTIVE_POWER = 1; // length = 2 +constexpr uint16_t RTU_PV1_VOLTAGE = 3; // length = 1 +constexpr uint16_t RTU_PV1_CURRENT = 4; // length = 1 +constexpr uint16_t RTU_PV1_ACTIVE_POWER = 5; // length = 2 +constexpr uint16_t RTU_PV2_VOLTAGE = 7; // length = 1 +constexpr uint16_t RTU_PV2_CURRENT = 8; // length = 1 +constexpr uint16_t RTU_PV2_ACTIVE_POWER = 9; // length = 2 +constexpr uint16_t RTU_GRID_ACTIVE_POWER = 11; // length = 2 +constexpr uint16_t RTU_GRID_FREQUENCY = 13; // length = 1 +constexpr uint16_t RTU_PHASE1_VOLTAGE = 14; // length = 1 +constexpr uint16_t RTU_PHASE1_CURRENT = 15; // length = 1 +constexpr uint16_t RTU_PHASE1_ACTIVE_POWER = 16; // length = 2 +constexpr uint16_t RTU_PHASE2_VOLTAGE = 18; // length = 1 +constexpr uint16_t RTU_PHASE2_CURRENT = 19; // length = 1 +constexpr uint16_t RTU_PHASE2_ACTIVE_POWER = 20; // length = 2 +constexpr uint16_t RTU_PHASE3_VOLTAGE = 22; // length = 1 +constexpr uint16_t RTU_PHASE3_CURRENT = 23; // length = 1 +constexpr uint16_t RTU_PHASE3_ACTIVE_POWER = 24; // length = 2 +constexpr uint16_t RTU_TODAY_PRODUCTION = 26; // length = 2 +constexpr uint16_t RTU_TOTAL_ENERGY_PRODUCTION = 28; // length = 2 +constexpr uint16_t RTU_INVERTER_MODULE_TEMP = 32; // length = 1 // Input register addresses for the RTU2 protocol as described // in the "GROWATT INVERTER MODBUS PROTOCOL_II V1.39" document. -constexpr size_t RTU2_INVERTER_STATUS = 0; // length = 1 -constexpr size_t RTU2_PV_ACTIVE_POWER = 1; // length = 2 -constexpr size_t RTU2_PV1_VOLTAGE = 3; // length = 1 -constexpr size_t RTU2_PV1_CURRENT = 4; // length = 1 -constexpr size_t RTU2_PV1_ACTIVE_POWER = 5; // length = 2 -constexpr size_t RTU2_PV2_VOLTAGE = 7; // length = 1 -constexpr size_t RTU2_PV2_CURRENT = 8; // length = 1 -constexpr size_t RTU2_PV2_ACTIVE_POWER = 9; // length = 2 -constexpr size_t RTU2_GRID_ACTIVE_POWER = 35; // length = 2 -constexpr size_t RTU2_GRID_FREQUENCY = 37; // length = 1 -constexpr size_t RTU2_PHASE1_VOLTAGE = 38; // length = 1 -constexpr size_t RTU2_PHASE1_CURRENT = 39; // length = 1 -constexpr size_t RTU2_PHASE1_ACTIVE_POWER = 40; // length = 2 -constexpr size_t RTU2_PHASE2_VOLTAGE = 42; // length = 1 -constexpr size_t RTU2_PHASE2_CURRENT = 43; // length = 1 -constexpr size_t RTU2_PHASE2_ACTIVE_POWER = 44; // length = 2 -constexpr size_t RTU2_PHASE3_VOLTAGE = 46; // length = 1 -constexpr size_t RTU2_PHASE3_CURRENT = 47; // length = 1 -constexpr size_t RTU2_PHASE3_ACTIVE_POWER = 48; // length = 2 -constexpr size_t RTU2_TODAY_PRODUCTION = 53; // length = 2 -constexpr size_t RTU2_TOTAL_ENERGY_PRODUCTION = 55; // length = 2 -constexpr size_t RTU2_INVERTER_MODULE_TEMP = 93; // length = 1 +constexpr uint16_t RTU2_INVERTER_STATUS = 0; // length = 1 +constexpr uint16_t RTU2_PV_ACTIVE_POWER = 1; // length = 2 +constexpr uint16_t RTU2_PV1_VOLTAGE = 3; // length = 1 +constexpr uint16_t RTU2_PV1_CURRENT = 4; // length = 1 +constexpr uint16_t RTU2_PV1_ACTIVE_POWER = 5; // length = 2 +constexpr uint16_t RTU2_PV2_VOLTAGE = 7; // length = 1 +constexpr uint16_t RTU2_PV2_CURRENT = 8; // length = 1 +constexpr uint16_t RTU2_PV2_ACTIVE_POWER = 9; // length = 2 +constexpr uint16_t RTU2_GRID_ACTIVE_POWER = 35; // length = 2 +constexpr uint16_t RTU2_GRID_FREQUENCY = 37; // length = 1 +constexpr uint16_t RTU2_PHASE1_VOLTAGE = 38; // length = 1 +constexpr uint16_t RTU2_PHASE1_CURRENT = 39; // length = 1 +constexpr uint16_t RTU2_PHASE1_ACTIVE_POWER = 40; // length = 2 +constexpr uint16_t RTU2_PHASE2_VOLTAGE = 42; // length = 1 +constexpr uint16_t RTU2_PHASE2_CURRENT = 43; // length = 1 +constexpr uint16_t RTU2_PHASE2_ACTIVE_POWER = 44; // length = 2 +constexpr uint16_t RTU2_PHASE3_VOLTAGE = 46; // length = 1 +constexpr uint16_t RTU2_PHASE3_CURRENT = 47; // length = 1 +constexpr uint16_t RTU2_PHASE3_ACTIVE_POWER = 48; // length = 2 +constexpr uint16_t RTU2_TODAY_PRODUCTION = 53; // length = 2 +constexpr uint16_t RTU2_TOTAL_ENERGY_PRODUCTION = 55; // length = 2 +constexpr uint16_t RTU2_INVERTER_MODULE_TEMP = 93; // length = 1 class GrowattSolar final : public PollingComponent, public modbus::ModbusClientDevice { public: - void loop() override; void update() override; - void on_response(std::span request_pdu, std::span response_pdu) override; + void on_read_input_registers(uint16_t start_address, std::span registers, + modbus::ResponseStatus status) override; void dump_config() override; void set_protocol_version(GrowattProtocolVersion protocol_version) { this->protocol_version_ = protocol_version; } @@ -104,9 +104,6 @@ class GrowattSolar final : public PollingComponent, public modbus::ModbusClientD } protected: - bool waiting_to_update_{false}; - uint32_t last_send_{0}; - struct GrowattPhase { sensor::Sensor *voltage_sensor_{nullptr}; sensor::Sensor *current_sensor_{nullptr}; diff --git a/esphome/components/havells_solar/havells_solar.cpp b/esphome/components/havells_solar/havells_solar.cpp index 6af72c352b..d43dfbb89a 100644 --- a/esphome/components/havells_solar/havells_solar.cpp +++ b/esphome/components/havells_solar/havells_solar.cpp @@ -1,124 +1,71 @@ #include "havells_solar.h" #include "havells_solar_registers.h" -#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::havells_solar { +namespace helpers = modbus::helpers; + static const char *const TAG = "havells_solar"; static const uint8_t MODBUS_REGISTER_COUNT = 48; // 48 x 16-bit registers -void HavellsSolar::on_response(std::span request_pdu, std::span response_pdu) { - auto data = modbus::helpers::server_pdu_payload(response_pdu); - if (data.size() < MODBUS_REGISTER_COUNT * 2) { - ESP_LOGW(TAG, "Invalid size for HavellsSolar!"); - return; - } +void HavellsSolar::on_read_holding_registers(uint16_t start_address, std::span registers, + modbus::ResponseStatus status) { + if (!modbus::succeeded(status)) + return; // the hub already logs exception responses - /* Usage: returns the float value of 1 register read by modbus - Arg1: Register address * number of bytes per register - Arg2: Multiplier for final register value - */ - auto havells_solar_get_2_registers = [&](size_t i, float unit) -> float { - uint32_t temp = encode_uint32(data[i], data[i + 1], data[i + 2], data[i + 3]); - return temp * unit; + // Publish a sensor if its register(s) are in this response; skipping absent registers keeps this + // correct for any read range, so the poll may be split into multiple requests. + auto publish_1_register = [&](sensor::Sensor *sensor, uint16_t reg, float unit) -> void { + if (sensor == nullptr) + return; + if (auto value = helpers::value_at(registers, start_address, reg)) + sensor->publish_state(*value * unit); }; - /* Usage: returns the float value of 2 registers read by modbus - Arg1: Register address * number of bytes per register - Arg2: Multiplier for final register value - */ - auto havells_solar_get_1_register = [&](size_t i, float unit) -> float { - uint16_t temp = encode_uint16(data[i], data[i + 1]); - return temp * unit; + auto publish_2_registers = [&](sensor::Sensor *sensor, uint16_t reg, float unit) -> void { + if (sensor == nullptr) + return; + if (auto value = helpers::value_at(registers, start_address, reg)) + sensor->publish_state(*value * unit); }; for (uint8_t i = 0; i < 3; i++) { - auto phase = this->phases_[i]; + auto &phase = this->phases_[i]; if (!phase.setup) continue; - - float voltage = havells_solar_get_1_register(HAVELLS_PHASE_1_VOLTAGE * 2 + (i * 4), ONE_DEC_UNIT); - float current = havells_solar_get_1_register(HAVELLS_PHASE_1_CURRENT * 2 + (i * 4), TWO_DEC_UNIT); - - if (phase.voltage_sensor_ != nullptr) - phase.voltage_sensor_->publish_state(voltage); - if (phase.current_sensor_ != nullptr) - phase.current_sensor_->publish_state(current); + publish_1_register(phase.voltage_sensor_, HAVELLS_PHASE_1_VOLTAGE + i * 2, ONE_DEC_UNIT); + publish_1_register(phase.current_sensor_, HAVELLS_PHASE_1_CURRENT + i * 2, TWO_DEC_UNIT); } for (uint8_t i = 0; i < 2; i++) { - auto pv = this->pvs_[i]; + auto &pv = this->pvs_[i]; if (!pv.setup) continue; - - float voltage = havells_solar_get_1_register(HAVELLS_PV_1_VOLTAGE * 2 + (i * 4), ONE_DEC_UNIT); - float current = havells_solar_get_1_register(HAVELLS_PV_1_CURRENT * 2 + (i * 4), TWO_DEC_UNIT); - float active_power = havells_solar_get_1_register(HAVELLS_PV_1_POWER * 2 + (i * 2), MULTIPLY_TEN_UNIT); - float voltage_sampled_by_secondary_cpu = - havells_solar_get_1_register(HAVELLS_PV1_VOLTAGE_SAMPLED_BY_SECONDARY_CPU * 2 + (i * 2), ONE_DEC_UNIT); - float insulation_of_p_to_ground = - havells_solar_get_1_register(HAVELLS_PV1_INSULATION_OF_P_TO_GROUND * 2 + (i * 2), NO_DEC_UNIT); - - if (pv.voltage_sensor_ != nullptr) - pv.voltage_sensor_->publish_state(voltage); - if (pv.current_sensor_ != nullptr) - pv.current_sensor_->publish_state(current); - if (pv.active_power_sensor_ != nullptr) - pv.active_power_sensor_->publish_state(active_power); - if (pv.voltage_sampled_by_secondary_cpu_sensor_ != nullptr) - pv.voltage_sampled_by_secondary_cpu_sensor_->publish_state(voltage_sampled_by_secondary_cpu); - if (pv.insulation_of_p_to_ground_sensor_ != nullptr) - pv.insulation_of_p_to_ground_sensor_->publish_state(insulation_of_p_to_ground); + publish_1_register(pv.voltage_sensor_, HAVELLS_PV_1_VOLTAGE + i * 2, ONE_DEC_UNIT); + publish_1_register(pv.current_sensor_, HAVELLS_PV_1_CURRENT + i * 2, TWO_DEC_UNIT); + publish_1_register(pv.active_power_sensor_, HAVELLS_PV_1_POWER + i, MULTIPLY_TEN_UNIT); + publish_1_register(pv.voltage_sampled_by_secondary_cpu_sensor_, HAVELLS_PV1_VOLTAGE_SAMPLED_BY_SECONDARY_CPU + i, + ONE_DEC_UNIT); + publish_1_register(pv.insulation_of_p_to_ground_sensor_, HAVELLS_PV1_INSULATION_OF_P_TO_GROUND + i, NO_DEC_UNIT); } - float frequency = havells_solar_get_1_register(HAVELLS_GRID_FREQUENCY * 2, TWO_DEC_UNIT); - float active_power = havells_solar_get_1_register(HAVELLS_SYSTEM_ACTIVE_POWER * 2, MULTIPLY_TEN_UNIT); - float reactive_power = havells_solar_get_1_register(HAVELLS_SYSTEM_REACTIVE_POWER * 2, TWO_DEC_UNIT); - float today_production = havells_solar_get_1_register(HAVELLS_TODAY_PRODUCTION * 2, TWO_DEC_UNIT); - float total_energy_production = havells_solar_get_2_registers(HAVELLS_TOTAL_ENERGY_PRODUCTION * 2, NO_DEC_UNIT); - float total_generation_time = havells_solar_get_2_registers(HAVELLS_TOTAL_GENERATION_TIME * 2, NO_DEC_UNIT); - float today_generation_time = havells_solar_get_1_register(HAVELLS_TODAY_GENERATION_TIME * 2, NO_DEC_UNIT); - float inverter_module_temp = havells_solar_get_1_register(HAVELLS_INVERTER_MODULE_TEMP * 2, NO_DEC_UNIT); - float inverter_inner_temp = havells_solar_get_1_register(HAVELLS_INVERTER_INNER_TEMP * 2, NO_DEC_UNIT); - float inverter_bus_voltage = havells_solar_get_1_register(HAVELLS_INVERTER_BUS_VOLTAGE * 2, NO_DEC_UNIT); - float insulation_pv_n_to_ground = havells_solar_get_1_register(HAVELLS_INSULATION_OF_PV_N_TO_GROUND * 2, NO_DEC_UNIT); - float gfci_value = havells_solar_get_1_register(HAVELLS_GFCI_VALUE * 2, NO_DEC_UNIT); - float dci_of_r = havells_solar_get_1_register(HAVELLS_DCI_OF_R * 2, NO_DEC_UNIT); - float dci_of_s = havells_solar_get_1_register(HAVELLS_DCI_OF_S * 2, NO_DEC_UNIT); - float dci_of_t = havells_solar_get_1_register(HAVELLS_DCI_OF_T * 2, NO_DEC_UNIT); - - if (this->frequency_sensor_ != nullptr) - this->frequency_sensor_->publish_state(frequency); - if (this->active_power_sensor_ != nullptr) - this->active_power_sensor_->publish_state(active_power); - if (this->reactive_power_sensor_ != nullptr) - this->reactive_power_sensor_->publish_state(reactive_power); - if (this->today_production_sensor_ != nullptr) - this->today_production_sensor_->publish_state(today_production); - if (this->total_energy_production_sensor_ != nullptr) - this->total_energy_production_sensor_->publish_state(total_energy_production); - if (this->total_generation_time_sensor_ != nullptr) - this->total_generation_time_sensor_->publish_state(total_generation_time); - if (this->today_generation_time_sensor_ != nullptr) - this->today_generation_time_sensor_->publish_state(today_generation_time); - if (this->inverter_module_temp_sensor_ != nullptr) - this->inverter_module_temp_sensor_->publish_state(inverter_module_temp); - if (this->inverter_inner_temp_sensor_ != nullptr) - this->inverter_inner_temp_sensor_->publish_state(inverter_inner_temp); - if (this->inverter_bus_voltage_sensor_ != nullptr) - this->inverter_bus_voltage_sensor_->publish_state(inverter_bus_voltage); - if (this->insulation_pv_n_to_ground_sensor_ != nullptr) - this->insulation_pv_n_to_ground_sensor_->publish_state(insulation_pv_n_to_ground); - if (this->gfci_value_sensor_ != nullptr) - this->gfci_value_sensor_->publish_state(gfci_value); - if (this->dci_of_r_sensor_ != nullptr) - this->dci_of_r_sensor_->publish_state(dci_of_r); - if (this->dci_of_s_sensor_ != nullptr) - this->dci_of_s_sensor_->publish_state(dci_of_s); - if (this->dci_of_t_sensor_ != nullptr) - this->dci_of_t_sensor_->publish_state(dci_of_t); + publish_1_register(this->frequency_sensor_, HAVELLS_GRID_FREQUENCY, TWO_DEC_UNIT); + publish_1_register(this->active_power_sensor_, HAVELLS_SYSTEM_ACTIVE_POWER, MULTIPLY_TEN_UNIT); + publish_1_register(this->reactive_power_sensor_, HAVELLS_SYSTEM_REACTIVE_POWER, TWO_DEC_UNIT); + publish_1_register(this->today_production_sensor_, HAVELLS_TODAY_PRODUCTION, TWO_DEC_UNIT); + publish_2_registers(this->total_energy_production_sensor_, HAVELLS_TOTAL_ENERGY_PRODUCTION, NO_DEC_UNIT); + publish_2_registers(this->total_generation_time_sensor_, HAVELLS_TOTAL_GENERATION_TIME, NO_DEC_UNIT); + publish_1_register(this->today_generation_time_sensor_, HAVELLS_TODAY_GENERATION_TIME, NO_DEC_UNIT); + publish_1_register(this->inverter_module_temp_sensor_, HAVELLS_INVERTER_MODULE_TEMP, NO_DEC_UNIT); + publish_1_register(this->inverter_inner_temp_sensor_, HAVELLS_INVERTER_INNER_TEMP, NO_DEC_UNIT); + publish_1_register(this->inverter_bus_voltage_sensor_, HAVELLS_INVERTER_BUS_VOLTAGE, NO_DEC_UNIT); + publish_1_register(this->insulation_pv_n_to_ground_sensor_, HAVELLS_INSULATION_OF_PV_N_TO_GROUND, NO_DEC_UNIT); + publish_1_register(this->gfci_value_sensor_, HAVELLS_GFCI_VALUE, NO_DEC_UNIT); + publish_1_register(this->dci_of_r_sensor_, HAVELLS_DCI_OF_R, NO_DEC_UNIT); + publish_1_register(this->dci_of_s_sensor_, HAVELLS_DCI_OF_S, NO_DEC_UNIT); + publish_1_register(this->dci_of_t_sensor_, HAVELLS_DCI_OF_T, NO_DEC_UNIT); } void HavellsSolar::update() { this->read_holding_registers(0, MODBUS_REGISTER_COUNT); } diff --git a/esphome/components/havells_solar/havells_solar.h b/esphome/components/havells_solar/havells_solar.h index ed5d13b8b6..a77b8bf977 100644 --- a/esphome/components/havells_solar/havells_solar.h +++ b/esphome/components/havells_solar/havells_solar.h @@ -77,7 +77,8 @@ class HavellsSolar final : public PollingComponent, public modbus::ModbusClientD void update() override; - void on_response(std::span request_pdu, std::span response_pdu) override; + void on_read_holding_registers(uint16_t start_address, std::span registers, + modbus::ResponseStatus status) override; void dump_config() override; diff --git a/esphome/components/kuntze/kuntze.cpp b/esphome/components/kuntze/kuntze.cpp index c47a80777c..cb04afe437 100644 --- a/esphome/components/kuntze/kuntze.cpp +++ b/esphome/components/kuntze/kuntze.cpp @@ -1,87 +1,74 @@ #include "kuntze.h" -#include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include "esphome/core/application.h" namespace esphome::kuntze { static const char *const TAG = "kuntze"; -static const uint16_t REGISTER[] = {4136, 4160, 4680, 6000, 4688, 4728, 5832}; +static constexpr uint16_t REGISTER_PH = 4136; +static constexpr uint16_t REGISTER_TEMPERATURE = 4160; +static constexpr uint16_t REGISTER_DIS1 = 4680; +static constexpr uint16_t REGISTER_DIS2 = 6000; +static constexpr uint16_t REGISTER_REDOX = 4688; +static constexpr uint16_t REGISTER_EC = 4728; +static constexpr uint16_t REGISTER_OCI = 5832; +static constexpr uint16_t REGISTER[] = {REGISTER_PH, REGISTER_TEMPERATURE, REGISTER_DIS1, REGISTER_DIS2, + REGISTER_REDOX, REGISTER_EC, REGISTER_OCI}; -// Maximum bytes to log for Modbus responses (2 registers = 4, plus count = 5) -static constexpr size_t KUNTZE_MAX_LOG_BYTES = 8; +void Kuntze::on_read_holding_registers(uint16_t start_address, std::span registers, + modbus::ResponseStatus status) { + if (!modbus::succeeded(status) || registers.size() < 2) + return; -void Kuntze::on_response(std::span request_pdu, std::span response_pdu) { - auto data = modbus::helpers::server_pdu_payload(response_pdu); - auto get_16bit = [&](int i) -> uint16_t { return (uint16_t(data[i * 2]) << 8) | uint16_t(data[i * 2 + 1]); }; + // Each value is a register pair: the reading, then the number of decimal places in its low byte. + float value = registers[0]; + for (uint16_t i = 0; i < (registers[1] & 0xFF); i++) + value /= 10.0f; - this->waiting_ = false; -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(KUNTZE_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "Data: %s", format_hex_pretty_to(hex_buf, data.data(), data.size())); - - float value = (float) get_16bit(0); - for (int i = 0; i < data[3]; i++) - value /= 10.0; - switch (this->state_) { - case 1: + switch (start_address) { + case REGISTER_PH: ESP_LOGD(TAG, "pH=%.1f", value); if (this->ph_sensor_ != nullptr) this->ph_sensor_->publish_state(value); break; - case 2: + case REGISTER_TEMPERATURE: ESP_LOGD(TAG, "temperature=%.1f", value); if (this->temperature_sensor_ != nullptr) this->temperature_sensor_->publish_state(value); break; - case 3: + case REGISTER_DIS1: ESP_LOGD(TAG, "DIS1=%.1f", value); if (this->dis1_sensor_ != nullptr) this->dis1_sensor_->publish_state(value); break; - case 4: + case REGISTER_DIS2: ESP_LOGD(TAG, "DIS2=%.1f", value); if (this->dis2_sensor_ != nullptr) this->dis2_sensor_->publish_state(value); break; - case 5: + case REGISTER_REDOX: ESP_LOGD(TAG, "REDOX=%.1f", value); if (this->redox_sensor_ != nullptr) this->redox_sensor_->publish_state(value); break; - case 6: + case REGISTER_EC: ESP_LOGD(TAG, "EC=%.1f", value); if (this->ec_sensor_ != nullptr) this->ec_sensor_->publish_state(value); break; - case 7: + case REGISTER_OCI: ESP_LOGD(TAG, "OCI=%.1f", value); if (this->oci_sensor_ != nullptr) this->oci_sensor_->publish_state(value); break; } - if (++this->state_ > 7) - this->state_ = 0; } -void Kuntze::loop() { - uint32_t now = App.get_loop_component_start_time(); - // timeout after 15 seconds - if (this->waiting_ && (now - this->last_send_ > 15000)) { - ESP_LOGW(TAG, "timed out waiting for response"); - this->waiting_ = false; - } - if (this->waiting_ || (this->state_ == 0)) - return; - this->last_send_ = now; - this->read_holding_registers(REGISTER[this->state_ - 1], 2); - this->waiting_ = true; +void Kuntze::update() { + for (uint16_t reg : REGISTER) + this->read_holding_registers(reg, 2); } -void Kuntze::update() { this->state_ = 1; } - void Kuntze::dump_config() { ESP_LOGCONFIG(TAG, "Kuntze:\n" diff --git a/esphome/components/kuntze/kuntze.h b/esphome/components/kuntze/kuntze.h index 28c8089748..84197b379d 100644 --- a/esphome/components/kuntze/kuntze.h +++ b/esphome/components/kuntze/kuntze.h @@ -18,18 +18,14 @@ class Kuntze final : public PollingComponent, public modbus::ModbusClientDevice void set_ec_sensor(sensor::Sensor *ec_sensor) { ec_sensor_ = ec_sensor; } void set_oci_sensor(sensor::Sensor *oci_sensor) { oci_sensor_ = oci_sensor; } - void loop() override; void update() override; - void on_response(std::span request_pdu, std::span response_pdu) override; + void on_read_holding_registers(uint16_t start_address, std::span registers, + modbus::ResponseStatus status) override; void dump_config() override; protected: - int state_{0}; - bool waiting_{false}; - uint32_t last_send_{0}; - sensor::Sensor *ph_sensor_{nullptr}; sensor::Sensor *temperature_sensor_{nullptr}; sensor::Sensor *dis1_sensor_{nullptr}; diff --git a/esphome/components/light/esp_color_correction.cpp b/esphome/components/light/esp_color_correction.cpp index e793226bb1..12eb6a3008 100644 --- a/esphome/components/light/esp_color_correction.cpp +++ b/esphome/components/light/esp_color_correction.cpp @@ -5,7 +5,11 @@ namespace esphome::light { uint8_t ESPColorCorrection::gamma_correct_(uint8_t value) const { if (this->gamma_table_ == nullptr) return value; - return static_cast((progmem_read_uint16(&this->gamma_table_[value]) + 128) / 257); + uint16_t table_value = progmem_read_uint16(&this->gamma_table_[value]); + uint8_t result = (table_value + 128) / 257; + if (result == 0 && table_value != 0) + return 1; + return result; } uint8_t ESPColorCorrection::gamma_uncorrect_(uint8_t value) const { diff --git a/esphome/components/mk2pvrouter/__init__.py b/esphome/components/mk2pvrouter/__init__.py new file mode 100644 index 0000000000..d00b4ce8d0 --- /dev/null +++ b/esphome/components/mk2pvrouter/__init__.py @@ -0,0 +1,69 @@ +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_TAG +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +CODEOWNERS = ["@FredM67"] +DEPENDENCIES = ["uart"] + +mk2pvrouter_ns = cg.esphome_ns.namespace("mk2pvrouter") +Mk2PVRouter = mk2pvrouter_ns.class_("Mk2PVRouter", cg.Component, uart.UARTDevice) + +CONF_MK2PVROUTER_ID = "mk2pvrouter_id" + +# Tags are copied into a fixed-size buffer (MAX_TAG_SIZE = 8 in mk2pvrouter.h), +# which needs room for a trailing null terminator. +MAX_TAG_LEN = 7 + +MK2PVROUTER_LISTENER_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_MK2PVROUTER_ID): cv.use_id(Mk2PVRouter), + cv.Required(CONF_TAG): cv.All( + cv.string_strict, cv.Length(min=1, max=MAX_TAG_LEN), lambda x: x.upper() + ), + } +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(Mk2PVRouter), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA) +) + + +def final_validate(config: ConfigType) -> None: + # Validate UART settings + schema = uart.final_validate_device_schema( + "mk2pvrouter", + baud_rate=9600, + parity="EVEN", + data_bits=7, + stop_bits=1, + require_rx=True, + require_tx=False, + ) + schema(config) + + +FINAL_VALIDATE_SCHEMA = final_validate + + +_request_listener_slot = cg.slot_counter("MK2PVROUTER_LISTENER_COUNT") + + +async def register_mk2pvrouter_listener(mk2pvrouter: MockObj, var: MockObj) -> None: + """Register a listener with its hub and count it for the compile-time buffer size.""" + _request_listener_slot() + cg.add(mk2pvrouter.register_mk2pvrouter_listener(var)) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) diff --git a/esphome/components/mk2pvrouter/mk2pvrouter.cpp b/esphome/components/mk2pvrouter/mk2pvrouter.cpp new file mode 100644 index 0000000000..a9c922602b --- /dev/null +++ b/esphome/components/mk2pvrouter/mk2pvrouter.cpp @@ -0,0 +1,177 @@ +#include "mk2pvrouter.h" +#include "esphome/core/log.h" +#include + +namespace esphome::mk2pvrouter { + +static const char *const TAG = "mk2pvrouter"; + +constexpr uint8_t START_FRAME = 0x2; +constexpr uint8_t END_FRAME = 0x3; +constexpr uint8_t LINE_FEED = 0xa; +constexpr uint8_t CARRIAGE_RETURN = 0xd; +constexpr uint8_t TAB = 0x9; +constexpr uint8_t MAX_ITERATIONS = 128; +constexpr uint8_t CRC_MASK = 0x3F; +constexpr uint8_t CRC_OFFSET = 0x20; + +// Extracts a TAB-delimited field from [buf_start, buf_end) into dest. +// Returns the field length, or 0 if no TAB was found, or the (uncopied) field +// length if it's >= max_len. +static size_t get_field(char *dest, const char *buf_start, const char *buf_end, size_t max_len) { + const auto *const field_end = static_cast(memchr(buf_start, TAB, buf_end - buf_start)); + if (!field_end) + return 0; + const size_t len = field_end - buf_start; + if (len >= max_len) { + ESP_LOGE(TAG, "Field too long: %zu bytes (max %zu)", len, max_len); + return len; + } + + memcpy(dest, buf_start, len); + dest[len] = '\0'; // Null-terminate + return len; +} + +// Calculates the CRC (checksum) for a given group of characters. +uint8_t Mk2PVRouter::calculate_crc_(const char *grp, size_t grp_len) { + uint8_t crc_tmp{0}; + const auto effective_len = grp_len - CRC_SUFFIX_LEN; + for (size_t i = 0; i < effective_len; i++) { + crc_tmp += grp[i]; + } + crc_tmp &= CRC_MASK; + crc_tmp += CRC_OFFSET; + return crc_tmp; +} + +// Verifies the CRC of a group against its trailing CRC byte. +bool Mk2PVRouter::check_crc_(const char *grp, const char *grp_end) { + const auto grp_len = grp_end - grp; + if (grp_len < static_cast(CRC_SUFFIX_LEN)) { + ESP_LOGE(TAG, "Empty or too short group"); + return false; + } + const auto raw_crc = grp[grp_len - 1]; + + const auto calculated_crc = this->calculate_crc_(grp, grp_len); + + if (raw_crc != calculated_crc) { + ESP_LOGE(TAG, "CRC mismatch: expected %d, got %d", calculated_crc, raw_crc); + return false; + } + return true; +} + +// Validates, parses, and publishes a single tag/value group. +void Mk2PVRouter::process_group_(const char *grp, const char *grp_end) { + if (!this->check_crc_(grp, grp_end)) + return; + + size_t field_len = get_field(this->tag_, grp, grp_end, MAX_TAG_SIZE); + if (!field_len || field_len >= MAX_TAG_SIZE) { + ESP_LOGE(TAG, "Invalid tag"); + return; + } + const auto *val_start = grp + field_len + 1; // Skip tag + TAB. + + field_len = get_field(this->val_, val_start, grp_end, MAX_VAL_SIZE); + if (!field_len || field_len >= MAX_VAL_SIZE) { + ESP_LOGE(TAG, "Invalid value for tag %s", this->tag_); + return; + } + + this->publish_value_(this->tag_, this->val_); +} + +// Reads characters until `c` is found or the internal buffer is full. +bool Mk2PVRouter::read_chars_until_(bool drop, uint8_t c) { + size_t j{0}; + + while (this->available() > 0 && j++ < MAX_ITERATIONS) { + const auto received = this->read(); + if (received < 0) + continue; + if (received == c) + return true; + if (drop) + continue; + if (this->buf_index_ >= (sizeof(this->buf_) - 1)) { + ESP_LOGW(TAG, "Internal buffer full"); + this->buf_index_ = 0; + this->state_ = State::WAITING_FOR_START; + return false; + } + this->buf_[this->buf_index_++] = received; + } + + return false; +} + +void Mk2PVRouter::loop() { + switch (this->state_) { + case State::WAITING_FOR_START: + ESP_LOGVV(TAG, "State: WAITING_FOR_START"); + if (this->read_chars_until_(true, START_FRAME)) + this->state_ = State::START_FRAME_RECEIVED; + break; + case State::START_FRAME_RECEIVED: + ESP_LOGVV(TAG, "State: START_FRAME_RECEIVED"); + if (this->read_chars_until_(false, END_FRAME)) + this->state_ = State::END_FRAME_RECEIVED; + break; + case State::END_FRAME_RECEIVED: { + ESP_LOGVV(TAG, "State: END_FRAME_RECEIVED -> processing"); + + if (this->buf_index_ == 0) { + this->state_ = State::WAITING_FOR_START; + break; + } + + auto *buf_finger = this->buf_; + auto *buf_end = this->buf_ + this->buf_index_; + + // Each group: 0xa(LF) | Tag | 0x9(TAB) | Data | 0x9(TAB) | CRC | 0xd(CR) + // CRC is computed over "Tag | TAB | Data | TAB". + while ((buf_finger = static_cast(memchr(buf_finger, LINE_FEED, buf_end - buf_finger))) != nullptr) { + ++buf_finger; // Skip LF to the start of the group. + + auto *const grp_end = static_cast(memchr(buf_finger, CARRIAGE_RETURN, buf_end - buf_finger)); + if (!grp_end) { + ESP_LOGE(TAG, "No group found"); + break; + } + + this->process_group_(buf_finger, grp_end); + + buf_finger = grp_end; // grp_end is always < buf_end, so this stays in bounds. + } + this->buf_index_ = 0; + this->state_ = State::WAITING_FOR_START; + break; + } + } +} + +void Mk2PVRouter::publish_value_(const char *tag, const char *val) { +#ifdef MK2PVROUTER_LISTENER_COUNT + for (auto *element : this->mk2pvrouter_listeners_) { + if (strcmp(tag, element->get_tag()) != 0) + continue; + element->publish_val(val); + } +#endif +} + +void Mk2PVRouter::dump_config() { + ESP_LOGCONFIG(TAG, "Mk2PVRouter:"); + this->check_uart_settings(BAUD_RATE, 1, uart::UART_CONFIG_PARITY_EVEN, 7); +} + +#ifdef MK2PVROUTER_LISTENER_COUNT +void Mk2PVRouter::register_mk2pvrouter_listener(Mk2PVRouterListener *listener) { + this->mk2pvrouter_listeners_.push_back(listener); +} +#endif + +} // namespace esphome::mk2pvrouter diff --git a/esphome/components/mk2pvrouter/mk2pvrouter.h b/esphome/components/mk2pvrouter/mk2pvrouter.h new file mode 100644 index 0000000000..f542436f1d --- /dev/null +++ b/esphome/components/mk2pvrouter/mk2pvrouter.h @@ -0,0 +1,69 @@ +#pragma once + +#include "esphome/components/uart/uart.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" + +namespace esphome::mk2pvrouter { +/* + * Buffer sizes based on the mk2pvrouter telemetry protocol, as implemented by the + * firmware's teleinfo.h (see github.com/FredM67/PVRouter-{1,3}-phase): + * - Tags: max 4 chars (S_MC is longest), most are 1-2 chars (P, V1, R2, etc.) + * - Values: max 6 digits signed (-10000), typical 1-5 digits. Energy (E) is a daily + * counter reset at midnight, so it stays well within 6 digits. + * - Frame: STX + multiple lines (LF+tag+TAB+value+TAB+crc+CR) + ETX + * - Line format: \n\t\t\r (8-15 bytes per line) + * - Multi-phase with all features: ~150-200 bytes + */ +static constexpr uint8_t MAX_TAG_SIZE = 8; // S_MC (4) + digit (1) + null (1) + margin (2) +static constexpr uint8_t MAX_VAL_SIZE = 8; // -10000 (6) + null (1) + margin (1) +static constexpr uint16_t MAX_BUF_SIZE = 256; // Full frame with all features enabled + +// Listener interface for entities that want updates for a specific tag. +class Mk2PVRouterListener { + public: + explicit Mk2PVRouterListener(const char *tag) : tag_(tag) {} + virtual ~Mk2PVRouterListener() = default; + const char *get_tag() const { return this->tag_; } + virtual void publish_val(const char *val) = 0; + + protected: + const char *tag_; +}; + +// Reads frames via UART, validates their CRC, and publishes tag/value pairs to listeners. +class Mk2PVRouter final : public Component, public uart::UARTDevice { + public: +#ifdef MK2PVROUTER_LISTENER_COUNT + void register_mk2pvrouter_listener(Mk2PVRouterListener *listener); +#endif + void loop() override; + void dump_config() override; + + protected: + static constexpr size_t CRC_SUFFIX_LEN = 1; + static constexpr uint32_t BAUD_RATE = 9600; + + enum class State : uint8_t { + WAITING_FOR_START, + START_FRAME_RECEIVED, + END_FRAME_RECEIVED, + }; + +#ifdef MK2PVROUTER_LISTENER_COUNT + StaticVector mk2pvrouter_listeners_; +#endif + uint16_t buf_index_{0}; + State state_{State::WAITING_FOR_START}; + char tag_[MAX_TAG_SIZE]; + char val_[MAX_VAL_SIZE]; + char buf_[MAX_BUF_SIZE]; // Large buffer last to reduce padding + + bool read_chars_until_(bool drop, uint8_t c); + uint8_t calculate_crc_(const char *grp, size_t grp_len); + bool check_crc_(const char *grp, const char *grp_end); + void process_group_(const char *grp, const char *grp_end); + void publish_value_(const char *tag, const char *val); +}; +} // namespace esphome::mk2pvrouter diff --git a/esphome/components/mk2pvrouter/sensor/__init__.py b/esphome/components/mk2pvrouter/sensor/__init__.py new file mode 100644 index 0000000000..14fc48a626 --- /dev/null +++ b/esphome/components/mk2pvrouter/sensor/__init__.py @@ -0,0 +1,27 @@ +import esphome.codegen as cg +from esphome.components import sensor +from esphome.const import CONF_ID, CONF_TAG +from esphome.types import ConfigType + +from .. import ( + CONF_MK2PVROUTER_ID, + MK2PVROUTER_LISTENER_SCHEMA, + mk2pvrouter_ns, + register_mk2pvrouter_listener, +) + +Mk2PVRouterSensor = mk2pvrouter_ns.class_( + "Mk2PVRouterSensor", sensor.Sensor, cg.Component +) + +CONFIG_SCHEMA = sensor.sensor_schema(Mk2PVRouterSensor).extend( + MK2PVROUTER_LISTENER_SCHEMA +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID], config[CONF_TAG]) + await cg.register_component(var, config) + await sensor.register_sensor(var, config) + mk2pvrouter = await cg.get_variable(config[CONF_MK2PVROUTER_ID]) + await register_mk2pvrouter_listener(mk2pvrouter, var) diff --git a/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.cpp b/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.cpp new file mode 100644 index 0000000000..96f1ff5954 --- /dev/null +++ b/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.cpp @@ -0,0 +1,24 @@ +#include "mk2pvrouter_sensor.h" +#include "esphome/core/log.h" + +namespace esphome::mk2pvrouter { + +static const char *const TAG = "mk2pvrouter_sensor"; + +Mk2PVRouterSensor::Mk2PVRouterSensor(const char *tag) : Mk2PVRouterListener(tag) {} + +void Mk2PVRouterSensor::publish_val(const char *val) { + auto result = parse_number(val); + if (!result.has_value()) { + ESP_LOGW(TAG, "Failed to parse value '%s' for tag '%s'", val, this->get_tag()); + return; + } + this->publish_state(result.value()); +} + +void Mk2PVRouterSensor::dump_config() { + LOG_SENSOR(" ", "Mk2PVRouter Sensor", this); + ESP_LOGCONFIG(TAG, " Tag: %s", this->get_tag()); +} + +} // namespace esphome::mk2pvrouter diff --git a/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.h b/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.h new file mode 100644 index 0000000000..e4da41e384 --- /dev/null +++ b/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.h @@ -0,0 +1,15 @@ +#pragma once + +#include "esphome/components/mk2pvrouter/mk2pvrouter.h" +#include "esphome/components/sensor/sensor.h" + +namespace esphome::mk2pvrouter { + +class Mk2PVRouterSensor final : public Mk2PVRouterListener, public sensor::Sensor, public Component { + public: + explicit Mk2PVRouterSensor(const char *tag); + void publish_val(const char *val) override; + void dump_config() override; +}; + +} // namespace esphome::mk2pvrouter diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index 92bd06cdf5..d80e6c86ad 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -292,25 +292,52 @@ std::optional payload_to_number(const uint8_t *data, size_t size, Senso } std::optional registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type) { - const size_t required_size = required_payload_size(sensor_value_type); - if (required_size == 0) { - return 0; // RAW/unsupported: nothing to read + // RAW and BIT carry no fixed-width number, so there is nothing to decode whatever the span holds. + // register_width_for() reports 1 for them, so this must be checked before the width test below. + if (sensor_value_type == SensorValueType::RAW || sensor_value_type == SensorValueType::BIT) { + return 0; } - const size_t required_words = required_size / 2; + const uint16_t required_words = register_width_for(sensor_value_type); if (required_words > count) { - ESP_LOGE(TAG, "not enough registers for value type=%u count=%zu required=%zu", - static_cast(sensor_value_type), count, required_words); + ESP_LOGE(TAG, "not enough registers for value type=%u count=%zu required=%u", + static_cast(sensor_value_type), count, static_cast(required_words)); return std::nullopt; } - // Serialize the needed words back to big-endian bytes and reuse the audited byte decoder so the - // sign-extension behaviour stays identical to the wire path. - uint8_t bytes[8]; // at most 4 registers (QWORD) - for (size_t i = 0; i < required_words; i++) { - uint16_t reg = registers[i]; - bytes[i * 2] = static_cast(reg >> 8); - bytes[i * 2 + 1] = static_cast(reg & 0xFF); + // Registers are the wire's own unit, so decode them directly rather than serializing back to bytes. + // Each case defers to registers_to_value() so the word order and sign rules have one definition, with + // two deliberate exceptions matching what the byte decoder returned: the float types yield their bit + // pattern rather than a float, and U_QWORD shares the signed branch because the return type is int64_t. + switch (sensor_value_type) { + case SensorValueType::U_WORD: + return registers_to_value(registers); + case SensorValueType::U_WORD_S: + return registers_to_value(registers); + case SensorValueType::S_WORD: + return registers_to_value(registers); + case SensorValueType::S_WORD_S: + return registers_to_value(registers); + case SensorValueType::U_DWORD: + return registers_to_value(registers); + case SensorValueType::U_DWORD_R: + return registers_to_value(registers); + case SensorValueType::S_DWORD: + return registers_to_value(registers); + case SensorValueType::S_DWORD_R: + return registers_to_value(registers); + case SensorValueType::FP32: + return registers_to_uint32(registers[0], registers[1]); + case SensorValueType::FP32_R: + return registers_to_uint32(registers[1], registers[0]); + // Signed for both: an unsigned QWORD above INT64_MAX has to come back as a negative int64_t. + case SensorValueType::U_QWORD: + case SensorValueType::S_QWORD: + return registers_to_value(registers); + case SensorValueType::U_QWORD_R: + case SensorValueType::S_QWORD_R: + return registers_to_value(registers); + default: + return 0; } - return payload_to_number(bytes, required_size, sensor_value_type, 0, 0xFFFFFFFF); } // Append a 16-bit value to a PDU in big-endian (wire) byte order. diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index a070ce250c..9488a88088 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -229,7 +229,7 @@ inline bool value_type_is_float(SensorValueType v) { } /// Number of 16-bit registers a value of this type occupies (RAW counts as one register). -inline uint16_t register_width_for(SensorValueType v) { +constexpr uint16_t register_width_for(SensorValueType v) { switch (v) { case SensorValueType::U_DWORD: case SensorValueType::S_DWORD: @@ -473,6 +473,87 @@ inline int64_t payload_to_number(const std::vector &data, SensorValueTy */ std::optional registers_to_number(const uint16_t *registers, size_t count, SensorValueType sensor_value_type); +/// Combine two register words into a 32-bit value. +constexpr uint32_t registers_to_uint32(uint16_t high_word, uint16_t low_word) { + return (static_cast(high_word) << 16) | low_word; +} + +/// Combine four register words into a 64-bit value, most significant word first. +constexpr uint64_t registers_to_uint64(uint16_t word0, uint16_t word1, uint16_t word2, uint16_t word3) { + return (static_cast(registers_to_uint32(word0, word1)) << 32) | registers_to_uint32(word2, word3); +} + +// Always false, whatever the type: it exists only to make the static_assert below depend on the +// template argument. Not a queryable trait. +template inline constexpr bool VALUE_TYPE_SUPPORTED = false; + +/** Decode one value whose type is known at compile time, from registers in host byte order. + * Unlike registers_to_number(), the type is a template argument, so only the one decode is compiled + * and the caller gets the value's natural type back rather than an int64_t. The "_R" types take the + * low word first; the rest take the high word first. + * Supports every fixed-width type: the WORD, DWORD, QWORD and FP32 families, including their _S and + * _R forms. RAW and BIT have no fixed width and fail to compile. + * Use register_width_for() for the number of registers the caller must supply. + * Note that the FP32 branches are only usable in a constant expression where std::bit_cast is + * available; elsewhere bit_cast falls back to a non-constexpr memcpy (see core/helpers.h). + */ +template constexpr auto registers_to_value(const uint16_t *registers) { + if constexpr (VALUE_TYPE == SensorValueType::U_WORD) { + return registers[0]; + } else if constexpr (VALUE_TYPE == SensorValueType::S_WORD) { + return static_cast(registers[0]); + } else if constexpr (VALUE_TYPE == SensorValueType::U_WORD_S) { + return byteswap(registers[0]); + } else if constexpr (VALUE_TYPE == SensorValueType::S_WORD_S) { + return static_cast(byteswap(registers[0])); + } else if constexpr (VALUE_TYPE == SensorValueType::U_DWORD) { + return registers_to_uint32(registers[0], registers[1]); + } else if constexpr (VALUE_TYPE == SensorValueType::U_DWORD_R) { + return registers_to_uint32(registers[1], registers[0]); + } else if constexpr (VALUE_TYPE == SensorValueType::S_DWORD) { + return static_cast(registers_to_uint32(registers[0], registers[1])); + } else if constexpr (VALUE_TYPE == SensorValueType::S_DWORD_R) { + return static_cast(registers_to_uint32(registers[1], registers[0])); + } else if constexpr (VALUE_TYPE == SensorValueType::FP32) { + return bit_cast(registers_to_uint32(registers[0], registers[1])); + } else if constexpr (VALUE_TYPE == SensorValueType::FP32_R) { + return bit_cast(registers_to_uint32(registers[1], registers[0])); + } else if constexpr (VALUE_TYPE == SensorValueType::U_QWORD) { + return registers_to_uint64(registers[0], registers[1], registers[2], registers[3]); + } else if constexpr (VALUE_TYPE == SensorValueType::U_QWORD_R) { + return registers_to_uint64(registers[3], registers[2], registers[1], registers[0]); + } else if constexpr (VALUE_TYPE == SensorValueType::S_QWORD) { + return static_cast(registers_to_uint64(registers[0], registers[1], registers[2], registers[3])); + } else if constexpr (VALUE_TYPE == SensorValueType::S_QWORD_R) { + return static_cast(registers_to_uint64(registers[3], registers[2], registers[1], registers[0])); + } else { + static_assert(VALUE_TYPE_SUPPORTED, "registers_to_value() does not support this value type"); + } +} + +/// The type registers_to_value() yields for a given value type. Distinct from modbus::RegisterValues, +/// which is a container of raw words. +template +using RegisterValueType = decltype(registers_to_value(static_cast(nullptr))); + +/** The value stored at an absolute register address, or nullopt when it is not wholly inside this + * response. Lets a device decode by address rather than by offset, so a poll split across several + * requests needs no extra bookkeeping: a value outside the response simply yields nullopt. + * @param registers the response registers, in host byte order + * @param start_address the address the response begins at + * @param address the address of the wanted value + */ +template +constexpr std::optional> value_at(std::span registers, + uint16_t start_address, uint16_t address) { + if (address < start_address) + return std::nullopt; + const size_t offset = static_cast(address) - start_address; + if (offset + register_width_for(VALUE_TYPE) > registers.size()) + return std::nullopt; + return registers_to_value(registers.data() + offset); +} + /// The widest standard numeric value (a QWORD) spans 4 registers, so one entity value never writes more. static constexpr uint16_t MAX_FEW_REGISTERS = 4; diff --git a/esphome/components/pzemac/pzemac.cpp b/esphome/components/pzemac/pzemac.cpp index d817888922..409de91124 100644 --- a/esphome/components/pzemac/pzemac.cpp +++ b/esphome/components/pzemac/pzemac.cpp @@ -3,62 +3,64 @@ namespace esphome::pzemac { +namespace helpers = modbus::helpers; + static const char *const TAG = "pzemac"; static const uint8_t PZEM_CMD_RESET_ENERGY = 0x42; static const uint8_t PZEM_REGISTER_COUNT = 10; // 10x 16-bit registers -void PZEMAC::on_response(std::span request_pdu, std::span response_pdu) { - auto data = modbus::helpers::server_pdu_payload(response_pdu); - if (data.size() < 20) { - ESP_LOGW(TAG, "Invalid size for PZEM AC!"); +// Register map, see https://github.com/esphome/feature-requests/issues/49#issuecomment-538636809 +// 32-bit values are two registers, low word first. +static const uint16_t PZEM_REGISTER_VOLTAGE = 0; // 1 register, 0.1 V +static const uint16_t PZEM_REGISTER_CURRENT = 1; // 2 registers, 0.001 A +static const uint16_t PZEM_REGISTER_ACTIVE_POWER = 3; // 2 registers, 0.1 W +static const uint16_t PZEM_REGISTER_ACTIVE_ENERGY = 5; // 2 registers, 1 Wh +static const uint16_t PZEM_REGISTER_FREQUENCY = 7; // 1 register, 0.1 Hz +static const uint16_t PZEM_REGISTER_POWER_FACTOR = 8; // 1 register, 0.01 + +void PZEMAC::on_read_input_registers(uint16_t start_address, std::span registers, + modbus::ResponseStatus status) { + if (!modbus::succeeded(status)) + return; // the hub already logs exception responses + + // Publish a sensor if its register(s) are in this response; skipping absent registers keeps this + // correct for any read range, so the poll may be split into multiple requests. + auto publish_1_register = [&](sensor::Sensor *sensor, uint16_t reg, float divisor) -> void { + if (sensor == nullptr) + return; + if (auto value = helpers::value_at(registers, start_address, reg)) + sensor->publish_state(*value / divisor); + }; + + auto publish_2_registers = [&](sensor::Sensor *sensor, uint16_t reg, float divisor) -> void { + if (sensor == nullptr) + return; + if (auto value = helpers::value_at(registers, start_address, reg)) + sensor->publish_state(*value / divisor); + }; + + publish_1_register(this->voltage_sensor_, PZEM_REGISTER_VOLTAGE, 10.0f); + publish_2_registers(this->current_sensor_, PZEM_REGISTER_CURRENT, 1000.0f); + publish_2_registers(this->power_sensor_, PZEM_REGISTER_ACTIVE_POWER, 10.0f); + publish_2_registers(this->energy_sensor_, PZEM_REGISTER_ACTIVE_ENERGY, 1.0f); + publish_1_register(this->frequency_sensor_, PZEM_REGISTER_FREQUENCY, 10.0f); + publish_1_register(this->power_factor_sensor_, PZEM_REGISTER_POWER_FACTOR, 100.0f); +} + +void PZEMAC::on_custom_response(std::span request_pdu, std::span response_pdu, + modbus::ResponseStatus status) { + // The only custom request this component sends is the energy reset; acknowledge its echo here so + // the default unhandled-response warning stays meaningful. + if (!request_pdu.empty() && request_pdu[0] == PZEM_CMD_RESET_ENERGY) { + if (modbus::succeeded(status)) { + ESP_LOGD(TAG, "Energy reset acknowledged"); + } else { + ESP_LOGW(TAG, "Energy reset rejected"); + } return; } - - // See https://github.com/esphome/feature-requests/issues/49#issuecomment-538636809 - // 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 - // 01 04 14 08 D1 00 6C 00 00 00 F4 00 00 00 26 00 00 01 F4 00 64 00 00 51 34 - // Id Cc Sz Volt- Current---- Power------ Energy----- Frequ PFact Alarm Crc-- - // 0 2 6 10 14 16 - - auto pzem_get_16bit = [&](size_t i) -> uint16_t { - return (uint16_t(data[i + 0]) << 8) | (uint16_t(data[i + 1]) << 0); - }; - auto pzem_get_32bit = [&](size_t i) -> uint32_t { - return (uint32_t(pzem_get_16bit(i + 2)) << 16) | (uint32_t(pzem_get_16bit(i + 0)) << 0); - }; - - uint16_t raw_voltage = pzem_get_16bit(0); - float voltage = raw_voltage / 10.0f; // max 6553.5 V - - uint32_t raw_current = pzem_get_32bit(2); - float current = raw_current / 1000.0f; // max 4294967.295 A - - uint32_t raw_active_power = pzem_get_32bit(6); - float active_power = raw_active_power / 10.0f; // max 429496729.5 W - - float active_energy = static_cast(pzem_get_32bit(10)); - - uint16_t raw_frequency = pzem_get_16bit(14); - float frequency = raw_frequency / 10.0f; - - uint16_t raw_power_factor = pzem_get_16bit(16); - float power_factor = raw_power_factor / 100.0f; - - ESP_LOGD(TAG, "PZEM AC: V=%.1f V, I=%.3f A, P=%.1f W, E=%.1f Wh, F=%.1f Hz, PF=%.2f", voltage, current, active_power, - active_energy, frequency, power_factor); - if (this->voltage_sensor_ != nullptr) - this->voltage_sensor_->publish_state(voltage); - if (this->current_sensor_ != nullptr) - this->current_sensor_->publish_state(current); - if (this->power_sensor_ != nullptr) - this->power_sensor_->publish_state(active_power); - if (this->energy_sensor_ != nullptr) - this->energy_sensor_->publish_state(active_energy); - if (this->frequency_sensor_ != nullptr) - this->frequency_sensor_->publish_state(frequency); - if (this->power_factor_sensor_ != nullptr) - this->power_factor_sensor_->publish_state(power_factor); + modbus::ModbusClientDevice::on_custom_response(request_pdu, response_pdu, status); } void PZEMAC::update() { this->read_input_registers(0, PZEM_REGISTER_COUNT); } diff --git a/esphome/components/pzemac/pzemac.h b/esphome/components/pzemac/pzemac.h index 171212d3ee..723b21e0b0 100644 --- a/esphome/components/pzemac/pzemac.h +++ b/esphome/components/pzemac/pzemac.h @@ -22,7 +22,10 @@ class PZEMAC final : public PollingComponent, public modbus::ModbusClientDevice void update() override; - void on_response(std::span request_pdu, std::span response_pdu) override; + void on_read_input_registers(uint16_t start_address, std::span registers, + modbus::ResponseStatus status) override; + void on_custom_response(std::span request_pdu, std::span response_pdu, + modbus::ResponseStatus status) override; void dump_config() override; diff --git a/esphome/components/pzemdc/pzemdc.cpp b/esphome/components/pzemdc/pzemdc.cpp index 926ad83f09..eb9a355806 100644 --- a/esphome/components/pzemdc/pzemdc.cpp +++ b/esphome/components/pzemdc/pzemdc.cpp @@ -3,55 +3,63 @@ namespace esphome::pzemdc { +namespace helpers = modbus::helpers; + static const char *const TAG = "pzemdc"; static const uint8_t PZEM_CMD_RESET_ENERGY = 0x42; -static const uint8_t PZEM_REGISTER_COUNT = 10; // 10x 16-bit registers +static const uint8_t PZEM_REGISTER_COUNT = 8; // 8x 16-bit registers -void PZEMDC::on_response(std::span request_pdu, std::span response_pdu) { - auto data = modbus::helpers::server_pdu_payload(response_pdu); - if (data.size() < 16) { - ESP_LOGW(TAG, "Invalid size for PZEM DC!"); - return; - } +// Register map, see https://github.com/esphome/feature-requests/issues/49#issuecomment-538636809 +// 32-bit values are two registers, low word first. +static const uint16_t PZEM_REGISTER_VOLTAGE = 0; // 1 register, 0.01 V +static const uint16_t PZEM_REGISTER_CURRENT = 1; // 1 register, 0.01 A +static const uint16_t PZEM_REGISTER_POWER = 2; // 2 registers, 0.1 W +static const uint16_t PZEM_REGISTER_ENERGY = 4; // 2 registers, 1 Wh - // See https://github.com/esphome/feature-requests/issues/49#issuecomment-538636809 - // 0 1 2 3 4 5 6 7 = ModBus register - // 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 = Buffer index - // 01 04 10 05 40 00 0A 00 0D 00 00 00 02 00 00 00 00 00 00 D6 29 - // Id Cc Sz Volt- Curre Power------ Energy----- HiAlm LoAlm Crc-- +void PZEMDC::on_read_input_registers(uint16_t start_address, std::span registers, + modbus::ResponseStatus status) { + if (!modbus::succeeded(status)) + return; // the hub already logs exception responses - auto pzem_get_16bit = [&](size_t i) -> uint16_t { - return (uint16_t(data[i + 0]) << 8) | (uint16_t(data[i + 1]) << 0); - }; - auto pzem_get_32bit = [&](size_t i) -> uint32_t { - return (uint32_t(pzem_get_16bit(i + 2)) << 16) | (uint32_t(pzem_get_16bit(i + 0)) << 0); + // Publish a sensor if its register(s) are in this response; skipping absent registers keeps this + // correct for any read range, so the poll may be split into multiple requests. + auto publish_1_register = [&](sensor::Sensor *sensor, uint16_t reg, float divisor) -> void { + if (sensor == nullptr) + return; + if (auto value = helpers::value_at(registers, start_address, reg)) + sensor->publish_state(*value / divisor); }; - uint16_t raw_voltage = pzem_get_16bit(0); - float voltage = raw_voltage / 100.0f; // max 655.35 V + auto publish_2_registers = [&](sensor::Sensor *sensor, uint16_t reg, float divisor) -> void { + if (sensor == nullptr) + return; + if (auto value = helpers::value_at(registers, start_address, reg)) + sensor->publish_state(*value / divisor); + }; - uint16_t raw_current = pzem_get_16bit(2); - float current = raw_current / 100.0f; // max 655.35 A - - uint32_t raw_power = pzem_get_32bit(4); - float power = raw_power / 10.0f; // max 429496729.5 W - - uint32_t raw_energy = pzem_get_32bit(8); - float energy = raw_energy / 1000.0f; // max 4294967.295 kWh - - ESP_LOGD(TAG, "PZEM DC: V=%.1f V, I=%.3f A, P=%.1f W", voltage, current, power); - if (this->voltage_sensor_ != nullptr) - this->voltage_sensor_->publish_state(voltage); - if (this->current_sensor_ != nullptr) - this->current_sensor_->publish_state(current); - if (this->power_sensor_ != nullptr) - this->power_sensor_->publish_state(power); - if (this->energy_sensor_ != nullptr) - this->energy_sensor_->publish_state(energy); + publish_1_register(this->voltage_sensor_, PZEM_REGISTER_VOLTAGE, 100.0f); + publish_1_register(this->current_sensor_, PZEM_REGISTER_CURRENT, 100.0f); + publish_2_registers(this->power_sensor_, PZEM_REGISTER_POWER, 10.0f); + publish_2_registers(this->energy_sensor_, PZEM_REGISTER_ENERGY, 1000.0f); } -void PZEMDC::update() { this->read_input_registers(0, 8); } +void PZEMDC::on_custom_response(std::span request_pdu, std::span response_pdu, + modbus::ResponseStatus status) { + // The only custom request this component sends is the energy reset; acknowledge its echo here so + // the default unhandled-response warning stays meaningful. + if (!request_pdu.empty() && request_pdu[0] == PZEM_CMD_RESET_ENERGY) { + if (modbus::succeeded(status)) { + ESP_LOGD(TAG, "Energy reset acknowledged"); + } else { + ESP_LOGW(TAG, "Energy reset rejected"); + } + return; + } + modbus::ModbusClientDevice::on_custom_response(request_pdu, response_pdu, status); +} + +void PZEMDC::update() { this->read_input_registers(0, PZEM_REGISTER_COUNT); } void PZEMDC::dump_config() { ESP_LOGCONFIG(TAG, "PZEMDC:\n" diff --git a/esphome/components/pzemdc/pzemdc.h b/esphome/components/pzemdc/pzemdc.h index b7657608e6..69c8a9dd6c 100644 --- a/esphome/components/pzemdc/pzemdc.h +++ b/esphome/components/pzemdc/pzemdc.h @@ -18,7 +18,10 @@ class PZEMDC final : public PollingComponent, public modbus::ModbusClientDevice void update() override; - void on_response(std::span request_pdu, std::span response_pdu) override; + void on_read_input_registers(uint16_t start_address, std::span registers, + modbus::ResponseStatus status) override; + void on_custom_response(std::span request_pdu, std::span response_pdu, + modbus::ResponseStatus status) override; void dump_config() override; diff --git a/esphome/components/sdm_meter/sdm_meter.cpp b/esphome/components/sdm_meter/sdm_meter.cpp index 1ebc7fa3d8..c1b359cc97 100644 --- a/esphome/components/sdm_meter/sdm_meter.cpp +++ b/esphome/components/sdm_meter/sdm_meter.cpp @@ -1,85 +1,48 @@ #include "sdm_meter.h" #include "sdm_meter_registers.h" -#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::sdm_meter { +namespace helpers = modbus::helpers; + static const char *const TAG = "sdm_meter"; -static const uint8_t MODBUS_REGISTER_COUNT = 80; // 74 x 16-bit registers +static const uint8_t MODBUS_REGISTER_COUNT = 80; // 80 x 16-bit registers (40 float values) -void SDMMeter::on_response(std::span request_pdu, std::span response_pdu) { - auto data = modbus::helpers::server_pdu_payload(response_pdu); - if (data.size() < MODBUS_REGISTER_COUNT * 2) { - ESP_LOGW(TAG, "Invalid size for SDMMeter!"); - return; - } +void SDMMeter::on_read_input_registers(uint16_t start_address, std::span registers, + modbus::ResponseStatus status) { + if (!modbus::succeeded(status)) + return; // the hub already logs exception responses - auto sdm_meter_get_float = [&](size_t i) -> float { - uint32_t temp = encode_uint32(data[i], data[i + 1], data[i + 2], data[i + 3]); - float f; - memcpy(&f, &temp, sizeof(f)); - return f; + // 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. + auto publish = [&](uint16_t reg, sensor::Sensor *sensor) { + if (sensor == nullptr) + return; + if (auto value = helpers::value_at(registers, start_address, reg)) + sensor->publish_state(*value); }; for (uint8_t i = 0; i < 3; i++) { - auto phase = this->phases_[i]; + auto &phase = this->phases_[i]; if (!phase.setup) continue; - - float voltage = sdm_meter_get_float(SDM_PHASE_1_VOLTAGE * 2 + (i * 4)); - float current = sdm_meter_get_float(SDM_PHASE_1_CURRENT * 2 + (i * 4)); - float active_power = sdm_meter_get_float(SDM_PHASE_1_ACTIVE_POWER * 2 + (i * 4)); - float apparent_power = sdm_meter_get_float(SDM_PHASE_1_APPARENT_POWER * 2 + (i * 4)); - float reactive_power = sdm_meter_get_float(SDM_PHASE_1_REACTIVE_POWER * 2 + (i * 4)); - float power_factor = sdm_meter_get_float(SDM_PHASE_1_POWER_FACTOR * 2 + (i * 4)); - float phase_angle = sdm_meter_get_float(SDM_PHASE_1_ANGLE * 2 + (i * 4)); - - ESP_LOGD( - TAG, - "SDMMeter Phase %c: V=%.3f V, I=%.3f A, Active P=%.3f W, Apparent P=%.3f VA, Reactive P=%.3f var, PF=%.3f, " - "PA=%.3f °", - i + 'A', voltage, current, active_power, apparent_power, reactive_power, power_factor, phase_angle); - if (phase.voltage_sensor_ != nullptr) - phase.voltage_sensor_->publish_state(voltage); - if (phase.current_sensor_ != nullptr) - phase.current_sensor_->publish_state(current); - if (phase.active_power_sensor_ != nullptr) - phase.active_power_sensor_->publish_state(active_power); - if (phase.apparent_power_sensor_ != nullptr) - phase.apparent_power_sensor_->publish_state(apparent_power); - if (phase.reactive_power_sensor_ != nullptr) - phase.reactive_power_sensor_->publish_state(reactive_power); - if (phase.power_factor_sensor_ != nullptr) - phase.power_factor_sensor_->publish_state(power_factor); - if (phase.phase_angle_sensor_ != nullptr) - phase.phase_angle_sensor_->publish_state(phase_angle); + publish(SDM_PHASE_1_VOLTAGE + i * 2, phase.voltage_sensor_); + publish(SDM_PHASE_1_CURRENT + i * 2, phase.current_sensor_); + publish(SDM_PHASE_1_ACTIVE_POWER + i * 2, phase.active_power_sensor_); + publish(SDM_PHASE_1_APPARENT_POWER + i * 2, phase.apparent_power_sensor_); + publish(SDM_PHASE_1_REACTIVE_POWER + i * 2, phase.reactive_power_sensor_); + publish(SDM_PHASE_1_POWER_FACTOR + i * 2, phase.power_factor_sensor_); + publish(SDM_PHASE_1_ANGLE + i * 2, phase.phase_angle_sensor_); } - float total_power = sdm_meter_get_float(SDM_TOTAL_SYSTEM_POWER * 2); - float frequency = sdm_meter_get_float(SDM_FREQUENCY * 2); - float import_active_energy = sdm_meter_get_float(SDM_IMPORT_ACTIVE_ENERGY * 2); - float export_active_energy = sdm_meter_get_float(SDM_EXPORT_ACTIVE_ENERGY * 2); - float import_reactive_energy = sdm_meter_get_float(SDM_IMPORT_REACTIVE_ENERGY * 2); - float export_reactive_energy = sdm_meter_get_float(SDM_EXPORT_REACTIVE_ENERGY * 2); - - ESP_LOGD(TAG, "SDMMeter: F=%.3f Hz, Im.A.E=%.3f Wh, Ex.A.E=%.3f Wh, Im.R.E=%.3f VARh, Ex.R.E=%.3f VARh, T.P=%.3f W", - frequency, import_active_energy, export_active_energy, import_reactive_energy, export_reactive_energy, - total_power); - - if (this->total_power_sensor_ != nullptr) - this->total_power_sensor_->publish_state(total_power); - if (this->frequency_sensor_ != nullptr) - this->frequency_sensor_->publish_state(frequency); - if (this->import_active_energy_sensor_ != nullptr) - this->import_active_energy_sensor_->publish_state(import_active_energy); - if (this->export_active_energy_sensor_ != nullptr) - this->export_active_energy_sensor_->publish_state(export_active_energy); - if (this->import_reactive_energy_sensor_ != nullptr) - this->import_reactive_energy_sensor_->publish_state(import_reactive_energy); - if (this->export_reactive_energy_sensor_ != nullptr) - this->export_reactive_energy_sensor_->publish_state(export_reactive_energy); + publish(SDM_TOTAL_SYSTEM_POWER, this->total_power_sensor_); + publish(SDM_FREQUENCY, this->frequency_sensor_); + publish(SDM_IMPORT_ACTIVE_ENERGY, this->import_active_energy_sensor_); + publish(SDM_EXPORT_ACTIVE_ENERGY, this->export_active_energy_sensor_); + publish(SDM_IMPORT_REACTIVE_ENERGY, this->import_reactive_energy_sensor_); + publish(SDM_EXPORT_REACTIVE_ENERGY, this->export_reactive_energy_sensor_); } void SDMMeter::update() { this->read_input_registers(0, MODBUS_REGISTER_COUNT); } diff --git a/esphome/components/sdm_meter/sdm_meter.h b/esphome/components/sdm_meter/sdm_meter.h index e09b74bbc0..80370010bd 100644 --- a/esphome/components/sdm_meter/sdm_meter.h +++ b/esphome/components/sdm_meter/sdm_meter.h @@ -55,7 +55,8 @@ class SDMMeter final : public PollingComponent, public modbus::ModbusClientDevic void update() override; - void on_response(std::span request_pdu, std::span response_pdu) override; + void on_read_input_registers(uint16_t start_address, std::span registers, + modbus::ResponseStatus status) override; void dump_config() override; diff --git a/esphome/components/selec_meter/selec_meter.cpp b/esphome/components/selec_meter/selec_meter.cpp index 688923d8e6..3ad1f8b87c 100644 --- a/esphome/components/selec_meter/selec_meter.cpp +++ b/esphome/components/selec_meter/selec_meter.cpp @@ -1,84 +1,47 @@ #include "selec_meter.h" #include "selec_meter_registers.h" -#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::selec_meter { +namespace helpers = modbus::helpers; + static const char *const TAG = "selec_meter"; static const uint8_t MODBUS_REGISTER_COUNT = 34; // 34 x 16-bit registers -void SelecMeter::on_response(std::span request_pdu, std::span response_pdu) { - auto data = modbus::helpers::server_pdu_payload(response_pdu); - if (data.size() < MODBUS_REGISTER_COUNT * 2) { - ESP_LOGW(TAG, "Invalid size for SelecMeter!"); - return; - } +void SelecMeter::on_read_input_registers(uint16_t start_address, std::span registers, + modbus::ResponseStatus status) { + if (!modbus::succeeded(status)) + return; // the hub already logs exception responses - auto selec_meter_get_float = [&](size_t i, float unit) -> float { - uint32_t temp = encode_uint32(data[i + 2], data[i + 3], data[i], data[i + 1]); - - float f; - memcpy(&f, &temp, sizeof(f)); - return (f * unit); + // Publish a sensor if both of its registers are in this response; skipping absent registers keeps + // this correct for any read range, so the poll may be split into multiple requests. + // Values are 32-bit floats, low word first. + auto publish = [&](sensor::Sensor *sensor, uint16_t reg, float unit) -> void { + if (sensor == nullptr) + return; + if (auto value = helpers::value_at(registers, start_address, reg)) + sensor->publish_state(*value * unit); }; - float total_active_energy = selec_meter_get_float(SELEC_TOTAL_ACTIVE_ENERGY * 2, NO_DEC_UNIT); - float import_active_energy = selec_meter_get_float(SELEC_IMPORT_ACTIVE_ENERGY * 2, NO_DEC_UNIT); - float export_active_energy = selec_meter_get_float(SELEC_EXPORT_ACTIVE_ENERGY * 2, NO_DEC_UNIT); - float total_reactive_energy = selec_meter_get_float(SELEC_TOTAL_REACTIVE_ENERGY * 2, NO_DEC_UNIT); - float import_reactive_energy = selec_meter_get_float(SELEC_IMPORT_REACTIVE_ENERGY * 2, NO_DEC_UNIT); - float export_reactive_energy = selec_meter_get_float(SELEC_EXPORT_REACTIVE_ENERGY * 2, NO_DEC_UNIT); - float apparent_energy = selec_meter_get_float(SELEC_APPARENT_ENERGY * 2, NO_DEC_UNIT); - float active_power = selec_meter_get_float(SELEC_ACTIVE_POWER * 2, MULTIPLY_THOUSAND_UNIT); - float reactive_power = selec_meter_get_float(SELEC_REACTIVE_POWER * 2, MULTIPLY_THOUSAND_UNIT); - float apparent_power = selec_meter_get_float(SELEC_APPARENT_POWER * 2, MULTIPLY_THOUSAND_UNIT); - float voltage = selec_meter_get_float(SELEC_VOLTAGE * 2, NO_DEC_UNIT); - float current = selec_meter_get_float(SELEC_CURRENT * 2, NO_DEC_UNIT); - float power_factor = selec_meter_get_float(SELEC_POWER_FACTOR * 2, NO_DEC_UNIT); - float frequency = selec_meter_get_float(SELEC_FREQUENCY * 2, NO_DEC_UNIT); - float maximum_demand_active_power = - selec_meter_get_float(SELEC_MAXIMUM_DEMAND_ACTIVE_POWER * 2, MULTIPLY_THOUSAND_UNIT); - float maximum_demand_reactive_power = - selec_meter_get_float(SELEC_MAXIMUM_DEMAND_REACTIVE_POWER * 2, MULTIPLY_THOUSAND_UNIT); - float maximum_demand_apparent_power = - selec_meter_get_float(SELEC_MAXIMUM_DEMAND_APPARENT_POWER * 2, MULTIPLY_THOUSAND_UNIT); - - if (this->total_active_energy_sensor_ != nullptr) - this->total_active_energy_sensor_->publish_state(total_active_energy); - if (this->import_active_energy_sensor_ != nullptr) - this->import_active_energy_sensor_->publish_state(import_active_energy); - if (this->export_active_energy_sensor_ != nullptr) - this->export_active_energy_sensor_->publish_state(export_active_energy); - if (this->total_reactive_energy_sensor_ != nullptr) - this->total_reactive_energy_sensor_->publish_state(total_reactive_energy); - if (this->import_reactive_energy_sensor_ != nullptr) - this->import_reactive_energy_sensor_->publish_state(import_reactive_energy); - if (this->export_reactive_energy_sensor_ != nullptr) - this->export_reactive_energy_sensor_->publish_state(export_reactive_energy); - if (this->apparent_energy_sensor_ != nullptr) - this->apparent_energy_sensor_->publish_state(apparent_energy); - if (this->active_power_sensor_ != nullptr) - this->active_power_sensor_->publish_state(active_power); - if (this->reactive_power_sensor_ != nullptr) - this->reactive_power_sensor_->publish_state(reactive_power); - if (this->apparent_power_sensor_ != nullptr) - this->apparent_power_sensor_->publish_state(apparent_power); - if (this->voltage_sensor_ != nullptr) - this->voltage_sensor_->publish_state(voltage); - if (this->current_sensor_ != nullptr) - this->current_sensor_->publish_state(current); - if (this->power_factor_sensor_ != nullptr) - this->power_factor_sensor_->publish_state(power_factor); - if (this->frequency_sensor_ != nullptr) - this->frequency_sensor_->publish_state(frequency); - if (this->maximum_demand_active_power_sensor_ != nullptr) - this->maximum_demand_active_power_sensor_->publish_state(maximum_demand_active_power); - if (this->maximum_demand_reactive_power_sensor_ != nullptr) - this->maximum_demand_reactive_power_sensor_->publish_state(maximum_demand_reactive_power); - if (this->maximum_demand_apparent_power_sensor_ != nullptr) - this->maximum_demand_apparent_power_sensor_->publish_state(maximum_demand_apparent_power); + 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::update() { this->read_input_registers(0, MODBUS_REGISTER_COUNT); } diff --git a/esphome/components/selec_meter/selec_meter.h b/esphome/components/selec_meter/selec_meter.h index 5ae1f9bf99..470242c918 100644 --- a/esphome/components/selec_meter/selec_meter.h +++ b/esphome/components/selec_meter/selec_meter.h @@ -37,7 +37,8 @@ class SelecMeter final : public PollingComponent, public modbus::ModbusClientDev void update() override; - void on_response(std::span request_pdu, std::span response_pdu) override; + void on_read_input_registers(uint16_t start_address, std::span registers, + modbus::ResponseStatus status) override; void dump_config() override; }; diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index e5fbb8ba07..41dd32ea66 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -336,10 +336,8 @@ void log_update_interval(const char *tag, PollingComponent *component) { uint32_t update_interval = component->get_update_interval(); if (update_interval == SCHEDULER_DONT_RUN) { ESP_LOGCONFIG(tag, " Update Interval: never"); - } else if (update_interval < 100) { - ESP_LOGCONFIG(tag, " Update Interval: %.3fs", update_interval / 1000.0f); } else { - ESP_LOGCONFIG(tag, " Update Interval: %.1fs", update_interval / 1000.0f); + ESP_LOGCONFIG(tag, " Update Interval: %" PRIu32 ".%03" PRIu32 "s", update_interval / 1000, update_interval % 1000); } } float Component::get_actual_setup_priority() const { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 90ecfea72a..625d4879f5 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -134,6 +134,7 @@ #define MDNS_DYNAMIC_TXT_COUNT 2 #define MICRONOVA_LISTENER_COUNT 1 #define USE_MICRONOVA_WRITER +#define MK2PVROUTER_LISTENER_COUNT 1 #define SERIAL_PROXY_COUNT 2 #define SNTP_SERVER_COUNT 3 #define USE_MEDIA_PLAYER diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 6bfe5c9e3c..433d2547b0 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -568,7 +568,7 @@ size_t value_accuracy_to_buf(std::span buf, float } // Fallback for NaN/Inf/high accuracy/out-of-range - int len = snprintf(buf.data(), buf.size(), "%.*f", accuracy_decimals, value); + int len = snprintf(buf.data(), buf.size(), "%.*f", accuracy_decimals, static_cast(value)); if (len < 0) return 0; return static_cast(len) >= buf.size() ? buf.size() - 1 : static_cast(len); @@ -586,16 +586,30 @@ size_t value_accuracy_with_uom_to_buf(std::span bu } int8_t step_to_accuracy_decimals(float step) { - // use printf %g to find number of digits based on temperature step - char buf[32]; - snprintf(buf, sizeof buf, "%.5g", step); - - std::string str{buf}; - size_t dot_pos = str.find('.'); - if (dot_pos == std::string::npos) + // Decimals needed to show the step at five significant digits, trailing zeros dropped. + if (!std::isfinite(step) || step == 0.0f) return 0; - - return str.length() - dot_pos - 1; + float mantissa = std::fabs(step); + int8_t decimals = 4; // decimals needed for five significant digits when mantissa is in [1, 10) + while (mantissa >= 10.0f) { + mantissa /= 10.0f; + decimals--; + } + while (mantissa < 1.0f) { + mantissa *= 10.0f; + decimals++; + } + if (decimals <= 0) + return 0; + float scaled = mantissa * 10000.0f; + auto digits = static_cast(scaled); + if (scaled - static_cast(digits) >= 0.5f) + digits++; + while (decimals > 0 && digits % 10 == 0) { + digits /= 10; + decimals--; + } + return decimals; } // Map a base64/base64url character to its 6-bit value (0-63) arithmetically. diff --git a/requirements.txt b/requirements.txt index da100ad0cd..63abc9c645 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,7 @@ ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 pillow==12.3.0 -resvg-py==0.4.0 +resvg-py==0.5.0 freetype-py==2.5.1 jinja2==3.1.6 bleak==3.0.2 diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp index a031dcb36f..baf688fc8a 100644 --- a/tests/components/core/test_helpers.cpp +++ b/tests/components/core/test_helpers.cpp @@ -328,10 +328,10 @@ TEST(StepToAccuracyDecimals, RoundsUpToWholeNumber) { } TEST(StepToAccuracyDecimals, OutsideFixedNotationRange) { - // %.5g prints these in exponent form, so the count comes from parsing "1e-05" or "1.2346e+05". - EXPECT_EQ(step_to_accuracy_decimals(0.00001f), 0); + // %.5g would print these in exponent form; the count is now the real one rather than a parse of "1e-05". + EXPECT_EQ(step_to_accuracy_decimals(0.00001f), 5); EXPECT_EQ(step_to_accuracy_decimals(0.000125f), 6); - EXPECT_EQ(step_to_accuracy_decimals(123456.0f), 8); + EXPECT_EQ(step_to_accuracy_decimals(123456.0f), 0); EXPECT_EQ(step_to_accuracy_decimals(1000000.0f), 0); } diff --git a/tests/components/light/test_gamma_correction.cpp b/tests/components/light/test_gamma_correction.cpp new file mode 100644 index 0000000000..4b8d83c544 --- /dev/null +++ b/tests/components/light/test_gamma_correction.cpp @@ -0,0 +1,92 @@ +#include + +#include +#include +#include +#include + +#include "esphome/components/light/esp_color_correction.h" + +namespace esphome::light::testing { + +namespace { + +// A representative fixture for ESPColorCorrection/gamma_table_reverse_search tests below -- +// not a spec for generate_gamma_table() itself, which the Python tests own. +std::array build_gamma_table(double gamma) { + std::array table{}; + table[0] = 0; + for (int i = 1; i < 256; i++) { + double raw = std::round(std::pow(i / 255.0, gamma) * 65535.0); + table[i] = static_cast(std::max(1.0, std::min(65535.0, raw))); + } + return table; +} + +// Bundles a table with an ESPColorCorrection pointing at it, since the correction only holds +// a raw pointer into the table and doesn't own it. +struct GammaFixture { + explicit GammaFixture(double gamma) : table(build_gamma_table(gamma)) { correction.set_gamma_table(table.data()); } + std::array table; + ESPColorCorrection correction; +}; + +} // namespace + +// Regression test for esphome/esphome#18842: ESPColorCorrection's own 16-bit -> 8-bit +// conversion must never round a non-zero table entry down to a zero 8-bit output. +TEST(GammaCorrection, NonZeroInputsSurviveConversion) { + for (double gamma : {1.0, 1.8, 2.0, 2.2, 2.8, 3.0, 4.0}) { + GammaFixture fixture(gamma); + for (int i = 1; i < 256; i++) { + EXPECT_GE(fixture.correction.color_correct_red(i), 1) << "gamma=" << gamma << " index=" << i; + } + } +} + +TEST(GammaCorrection, ZeroInputStaysZero) { + for (double gamma : {1.0, 2.2, 2.8, 4.0}) { + GammaFixture fixture(gamma); + EXPECT_EQ(fixture.correction.color_correct_red(0), 0) << "gamma=" << gamma; + } +} + +TEST(GammaCorrection, FullBrightnessStaysFull) { + for (double gamma : {1.0, 2.2, 2.8, 4.0}) { + GammaFixture fixture(gamma); + EXPECT_EQ(fixture.correction.color_correct_red(255), 255) << "gamma=" << gamma; + } +} + +// Reproduces the reporter's own numbers from esphome/esphome#18842 at gamma=2.8: codes +// 1-27 previously collapsed to an 8-bit output of 0 and must now be non-zero. +TEST(GammaCorrection, DeadZoneFixedAtGamma28) { + GammaFixture fixture(2.8); + for (int i = 1; i < 28; i++) { + EXPECT_GE(fixture.correction.color_correct_red(i), 1) << "index=" << i << " still collapses to 0"; + } +} + +TEST(GammaCorrection, ReverseSearchFindsLargestIndexLessEqualTarget) { + auto table = build_gamma_table(2.8); + for (uint16_t target : {0, 128, 129, 135, 1000, 32768, 65535}) { + uint8_t lo = gamma_table_reverse_search(table.data(), target); + EXPECT_LE(table[lo], target) << "target=" << target; + if (lo < 255) { + EXPECT_GT(table[lo + 1], target) << "target=" << target; + } + } +} + +// color_uncorrect_* binary-searches the table via gamma_table_reverse_search(). +TEST(GammaCorrection, UncorrectStaysMonotonic) { + GammaFixture fixture(2.8); + uint8_t prev = 0; + for (int i = 1; i < 256; i++) { + uint8_t result = fixture.correction.color_uncorrect_red(i); + EXPECT_GE(result, prev) << "index=" << i; + prev = result; + } +} + +} // namespace esphome::light::testing diff --git a/tests/components/mk2pvrouter/common.yaml b/tests/components/mk2pvrouter/common.yaml new file mode 100644 index 0000000000..4421c09854 --- /dev/null +++ b/tests/components/mk2pvrouter/common.yaml @@ -0,0 +1,46 @@ +mk2pvrouter: + id: test_mk2pvrouter + uart_id: uart_bus + +sensor: + - platform: mk2pvrouter + name: Power + tag: P + mk2pvrouter_id: test_mk2pvrouter + unit_of_measurement: W + device_class: power + state_class: measurement + accuracy_decimals: 0 + + - platform: mk2pvrouter + name: Voltage + tag: V + mk2pvrouter_id: test_mk2pvrouter + unit_of_measurement: V + device_class: voltage + state_class: measurement + accuracy_decimals: 2 + filters: + # Device sends voltage * 100 + - multiply: 0.01 + + - platform: mk2pvrouter + name: Energy + tag: E + mk2pvrouter_id: test_mk2pvrouter + unit_of_measurement: Wh + device_class: energy + state_class: total_increasing + accuracy_decimals: 0 + + - platform: mk2pvrouter + name: Temperature + tag: T1 + mk2pvrouter_id: test_mk2pvrouter + unit_of_measurement: "°C" + device_class: temperature + state_class: measurement + accuracy_decimals: 2 + filters: + # Device sends temperature * 100 + - multiply: 0.01 diff --git a/tests/components/mk2pvrouter/test.esp32-idf.yaml b/tests/components/mk2pvrouter/test.esp32-idf.yaml new file mode 100644 index 0000000000..66539a4dd7 --- /dev/null +++ b/tests/components/mk2pvrouter/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_9600_even_7bits: !include ../../test_build_components/common/uart_9600_even_7bits/esp32-idf.yaml + mk2pvrouter: !include common.yaml diff --git a/tests/components/mk2pvrouter/test.esp8266-ard.yaml b/tests/components/mk2pvrouter/test.esp8266-ard.yaml new file mode 100644 index 0000000000..50a45a6ca5 --- /dev/null +++ b/tests/components/mk2pvrouter/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_9600_even_7bits: !include ../../test_build_components/common/uart_9600_even_7bits/esp8266-ard.yaml + mk2pvrouter: !include common.yaml diff --git a/tests/components/mk2pvrouter/test.rp2040-ard.yaml b/tests/components/mk2pvrouter/test.rp2040-ard.yaml new file mode 100644 index 0000000000..f8a5a620b3 --- /dev/null +++ b/tests/components/mk2pvrouter/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_9600_even_7bits: !include ../../test_build_components/common/uart_9600_even_7bits/rp2040-ard.yaml + mk2pvrouter: !include common.yaml diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp index 87af49710f..a42625760d 100644 --- a/tests/components/modbus/modbus_helpers_test.cpp +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -427,11 +427,132 @@ TEST(ModbusHelpersTest, RegistersToNumberMatchesPayloadToNumber) { } } +TEST(ModbusHelpersTest, RegistersToNumberMatchesPayloadToNumberForQwords) { + // The word shuffle the QWORD_R decode replaces is the least obvious code in the byte path, so pin + // it against that path rather than against registers_to_value(). The top bit is set, which is where + // U_QWORD's unsigned value and this function's int64_t return deliberately diverge. + const uint16_t registers[] = {0xF123, 0x4567, 0x89AB, 0xCDEF}; + const std::vector bytes{0xF1, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF}; + for (auto value_type : + {SensorValueType::U_QWORD, SensorValueType::S_QWORD, SensorValueType::U_QWORD_R, SensorValueType::S_QWORD_R}) { + EXPECT_EQ(registers_to_number(registers, 4, value_type), + payload_to_number(std::span(bytes), value_type, 0, 0xFFFFFFFF)) + << "value_type=" << static_cast(value_type); + } +} + +TEST(ModbusHelpersTest, RegistersToNumberTreatsRawAndBitAsNothingToDecode) { + // Both have no fixed-width number, so they decode to 0 whatever the span holds - including none. + const uint16_t registers[] = {0x1234}; + EXPECT_EQ(registers_to_number(registers, 1, SensorValueType::RAW), std::optional(0)); + EXPECT_EQ(registers_to_number(registers, 0, SensorValueType::RAW), std::optional(0)); + EXPECT_EQ(registers_to_number(registers, 0, SensorValueType::BIT), std::optional(0)); +} + TEST(ModbusHelpersTest, RegistersToNumberRejectsTruncatedMultiRegisterValue) { const uint16_t registers[] = {0x1234}; EXPECT_FALSE(registers_to_number(registers, 1, SensorValueType::U_DWORD).has_value()); } +// --- registers_to_value ---------------------------------------------------- +// registers_to_number() dispatches to registers_to_value(), so this checks the dispatch table picks +// the right specialisation for each type, not that two implementations agree. The independent check +// against the byte decoder is RegistersToNumberMatchesPayloadToNumber below. + +template void expect_matches_registers_to_number(const uint16_t *registers) { + const auto expected = registers_to_number(registers, register_width_for(VALUE_TYPE), VALUE_TYPE); + // Plain control flow rather than ASSERT_TRUE: the optional analysis does not see through the macro. + if (!expected.has_value()) { + ADD_FAILURE() << "registers_to_number() returned no value for value_type=" << static_cast(VALUE_TYPE); + return; + } + const int64_t number = expected.value(); + if constexpr (VALUE_TYPE == SensorValueType::FP32 || VALUE_TYPE == SensorValueType::FP32_R) { + EXPECT_FLOAT_EQ(registers_to_value(registers), bit_cast(static_cast(number))) + << "value_type=" << static_cast(VALUE_TYPE); + } else { + EXPECT_EQ(static_cast(registers_to_value(registers)), number) + << "value_type=" << static_cast(VALUE_TYPE); + } +} + +TEST(ModbusHelpersTest, RegistersToValueMatchesRegistersToNumber) { + // A high bit in each word exercises sign handling and word order together. + const uint16_t registers[] = {0x8001, 0xFE02}; + expect_matches_registers_to_number(registers); + expect_matches_registers_to_number(registers); + expect_matches_registers_to_number(registers); + expect_matches_registers_to_number(registers); + expect_matches_registers_to_number(registers); + expect_matches_registers_to_number(registers); + expect_matches_registers_to_number(registers); + expect_matches_registers_to_number(registers); + expect_matches_registers_to_number(registers); + expect_matches_registers_to_number(registers); +} + +TEST(ModbusHelpersTest, RegistersToUint32CombinesWordsHighFirst) { + EXPECT_EQ(registers_to_uint32(0x1234, 0x5678), 0x12345678u); +} + +// --- value_at --------------------------------------------------------------- +// Addresses are absolute; anything not wholly inside the response yields nullopt. + +TEST(ModbusHelpersTest, ValueAtDecodesByAbsoluteAddress) { + const uint16_t registers[] = {0x1111, 0x2222, 0x3333}; + const std::span span(registers, 3); + EXPECT_EQ(value_at(span, 100, 100), std::optional(0x1111)); + EXPECT_EQ(value_at(span, 100, 102), std::optional(0x3333)); + EXPECT_EQ(value_at(span, 100, 101), std::optional(0x22223333u)); + // Types whose RegisterValueType<> is not an unsigned integer, and the widest bounds check. + const uint16_t floats[] = {0x4048, 0xF5C3, 0xF5C3, 0x4048}; + const std::span float_span(floats, 4); + EXPECT_FLOAT_EQ(value_at(float_span, 10, 10).value_or(0.0f), 3.14f); + EXPECT_FLOAT_EQ(value_at(float_span, 10, 12).value_or(0.0f), 3.14f); + EXPECT_EQ(value_at(float_span, 10, 10), std::optional(0x4048F5C3F5C34048ULL)); + EXPECT_FALSE(value_at(float_span, 10, 11).has_value()); +} + +TEST(ModbusHelpersTest, ValueAtIsUsableInAConstantExpression) { + static constexpr uint16_t REGISTERS[] = {0x1234, 0x5678}; + static_assert(value_at(REGISTERS, 7, 7).value_or(0) == 0x12345678u); + static_assert(!value_at(REGISTERS, 7, 6).has_value()); +} + +TEST(ModbusHelpersTest, ValueAtRejectsAddressesOutsideTheResponse) { + const uint16_t registers[] = {0x1111, 0x2222, 0x3333}; + const std::span span(registers, 3); + // Below the response: must not wrap when the subtraction would go negative. + EXPECT_FALSE(value_at(span, 100, 99).has_value()); + EXPECT_FALSE(value_at(span, 100, 0).has_value()); + // Past the end, and a multi-register value truncated by the end of the response. + EXPECT_FALSE(value_at(span, 100, 103).has_value()); + EXPECT_FALSE(value_at(span, 100, 102).has_value()); + EXPECT_TRUE(value_at(span, 100, 101).has_value()); +} + +TEST(ModbusHelpersTest, ValueAtHandlesAnEmptyResponse) { + EXPECT_FALSE(value_at(std::span(), 0, 0).has_value()); +} + +// --- QWORD decoding --------------------------------------------------------- + +TEST(ModbusHelpersTest, RegistersToValueDecodesQwordBothWordOrders) { + const uint16_t registers[] = {0x0123, 0x4567, 0x89AB, 0xCDEF}; + EXPECT_EQ(registers_to_value(registers), 0x0123456789ABCDEFULL); + const uint16_t reversed[] = {0xCDEF, 0x89AB, 0x4567, 0x0123}; + EXPECT_EQ(registers_to_value(reversed), 0x0123456789ABCDEFULL); + // Signed reading of the same bits, and the sign-extreme case. + EXPECT_EQ(registers_to_value(registers), 0x0123456789ABCDEFLL); + const uint16_t negative[] = {0xFFFF, 0xFFFF, 0xFFFF, 0xFFFE}; + EXPECT_EQ(registers_to_value(negative), -2); + EXPECT_EQ(registers_to_value(negative), 0xFFFFFFFFFFFFFFFEULL); +} + +TEST(ModbusHelpersTest, RegistersToUint64CombinesWordsHighFirst) { + EXPECT_EQ(registers_to_uint64(0x0123, 0x4567, 0x89AB, 0xCDEF), 0x0123456789ABCDEFULL); +} + // --- packed bit helpers ------------------------------------------------------ TEST(ModbusHelpersTest, PackBitsAppendsToContainer) { diff --git a/tests/test_build_components/common/uart_9600_even_7bits/esp32-ard.yaml b/tests/test_build_components/common/uart_9600_even_7bits/esp32-ard.yaml new file mode 100644 index 0000000000..f0d24b9a18 --- /dev/null +++ b/tests/test_build_components/common/uart_9600_even_7bits/esp32-ard.yaml @@ -0,0 +1,14 @@ +# Common UART configuration for ESP32 Arduino tests - 9600 baud, EVEN parity, 7 data bits + +substitutions: + tx_pin: GPIO17 + rx_pin: GPIO16 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 9600 + parity: EVEN + data_bits: 7 + stop_bits: 1 diff --git a/tests/test_build_components/common/uart_9600_even_7bits/esp32-idf.yaml b/tests/test_build_components/common/uart_9600_even_7bits/esp32-idf.yaml new file mode 100644 index 0000000000..e85fa7fc71 --- /dev/null +++ b/tests/test_build_components/common/uart_9600_even_7bits/esp32-idf.yaml @@ -0,0 +1,14 @@ +# Common UART configuration for ESP32 IDF tests - 9600 baud, EVEN parity, 7 data bits + +substitutions: + tx_pin: GPIO17 + rx_pin: GPIO16 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 9600 + parity: EVEN + data_bits: 7 + stop_bits: 1 diff --git a/tests/test_build_components/common/uart_9600_even_7bits/esp8266-ard.yaml b/tests/test_build_components/common/uart_9600_even_7bits/esp8266-ard.yaml new file mode 100644 index 0000000000..488bfdbeab --- /dev/null +++ b/tests/test_build_components/common/uart_9600_even_7bits/esp8266-ard.yaml @@ -0,0 +1,14 @@ +# Common UART configuration for ESP8266 Arduino tests - 9600 baud even parity, 7 data bits + +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 9600 + parity: EVEN + data_bits: 7 + stop_bits: 1 diff --git a/tests/test_build_components/common/uart_9600_even_7bits/rp2040-ard.yaml b/tests/test_build_components/common/uart_9600_even_7bits/rp2040-ard.yaml new file mode 100644 index 0000000000..08bec00820 --- /dev/null +++ b/tests/test_build_components/common/uart_9600_even_7bits/rp2040-ard.yaml @@ -0,0 +1,14 @@ +# Common UART configuration for RP2040 Arduino tests - 9600 baud even parity, 7 data bits + +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO1 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 9600 + parity: EVEN + data_bits: 7 + stop_bits: 1 diff --git a/tests/unit_tests/components/light/test_gamma_table.py b/tests/unit_tests/components/light/test_gamma_table.py index a302a355dc..75c3f18e42 100644 --- a/tests/unit_tests/components/light/test_gamma_table.py +++ b/tests/unit_tests/components/light/test_gamma_table.py @@ -53,9 +53,12 @@ def test_nonzero_indices_are_nonzero(gamma: float) -> None: assert table[i] >= 1, f"gamma={gamma}, index {i}: got {table[i]}" -@pytest.mark.parametrize("gamma", [1.0, 2.0, 2.2, 2.8, 3.0]) +@pytest.mark.parametrize("gamma", [1.0, 1.8, 2.0, 2.2, 2.8, 3.0, 4.0]) def test_table_monotonically_nondecreasing(gamma: float) -> None: - """The gamma table must be monotonically non-decreasing.""" + """The gamma table must be monotonically non-decreasing. + + gamma_table_reverse_search()'s binary search depends on this. + """ table = generate_gamma_table(gamma) for i in range(1, 256): assert table[i] >= table[i - 1], ( @@ -115,3 +118,13 @@ def test_lut_output_monotonically_nondecreasing() -> None: result = _simulate_gamma_correct_lut(table, value) assert result >= prev, f"value={value}: result {result} < previous {prev}" prev = result + + +def test_table_matches_raw_power_curve() -> None: + """Check the gamma table against known good values for gamma=2.8.""" + table = generate_gamma_table(2.8) + golden = {1: 1, 5: 1, 15: 24, 27: 122, 28: 135, 100: 4766, 200: 33193, 254: 64818} + for i, expected in golden.items(): + assert table[i] == expected, ( + f"index {i}: table[{i}]={table[i]} expected {expected}" + )