From 501716580043034bbf8fbd81507ecc4fbbc1cacf Mon Sep 17 00:00:00 2001 From: Sourabh Jaiswal Date: Tue, 13 Oct 2026 08:28:25 +0530 Subject: [PATCH 001/142] [selec_meter] Add EM4M (3-phase) support via model option (#17782) Co-authored-by: Claude Sonnet 5 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../components/selec_meter/selec_meter.cpp | 399 ++++++++++++++++-- esphome/components/selec_meter/selec_meter.h | 135 ++++++ .../selec_meter/selec_meter_registers.h | 133 +++++- esphome/components/selec_meter/sensor.py | 262 +++++++++++- esphome/core/defines.h | 1 + tests/components/selec_meter/common-em4m.yaml | 129 ++++++ .../selec_meter/test-em4m.esp32-idf.yaml | 8 + .../selec_meter/test-em4m.esp8266-ard.yaml | 8 + .../selec_meter/test.esp32-idf.yaml | 3 +- .../selec_meter/test.esp8266-ard.yaml | 3 +- .../selec_meter/test.rp2040-ard.yaml | 3 +- .../components/selec_meter/__init__.py | 0 .../components/selec_meter/test_sensor.py | 103 +++++ 13 files changed, 1105 insertions(+), 82 deletions(-) create mode 100644 tests/components/selec_meter/common-em4m.yaml create mode 100644 tests/components/selec_meter/test-em4m.esp32-idf.yaml create mode 100644 tests/components/selec_meter/test-em4m.esp8266-ard.yaml create mode 100644 tests/unit_tests/components/selec_meter/__init__.py create mode 100644 tests/unit_tests/components/selec_meter/test_sensor.py diff --git a/esphome/components/selec_meter/selec_meter.cpp b/esphome/components/selec_meter/selec_meter.cpp index a11cc6a008a..9a895b0e0e9 100644 --- a/esphome/components/selec_meter/selec_meter.cpp +++ b/esphome/components/selec_meter/selec_meter.cpp @@ -1,54 +1,358 @@ #include "selec_meter.h" #include "selec_meter_registers.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" +#include +#include +#include + namespace esphome::selec_meter { namespace helpers = modbus::helpers; ESPHOME_LOG_TAG(TAG, "selec_meter"); -static const uint8_t MODBUS_REGISTER_COUNT = 34; // 34 x 16-bit registers +static constexpr uint8_t EM2M_REGISTER_COUNT = 34; // 34 x 16-bit registers + +// Durable refusals an optional read tolerates before it is given up on +static constexpr uint8_t MAX_OPTIONAL_READ_FAILURES = 5; + +static const LogString *read_state_name(ReadState state) { + switch (state) { + case ReadState::READ_STATE_MAIN_BLOCK: + return LOG_STR("main block"); + case ReadState::READ_STATE_SERIAL_NUMBER: + return LOG_STR("serial number"); + case ReadState::READ_STATE_DG_SENSING: + return LOG_STR("DG sensing"); + case ReadState::READ_STATE_IDLE: + default: + return LOG_STR("idle"); + } +} + +// FP32 at `reg` in the configured word order (MSRF high word first, LSRF low word first); NaN when +// `reg` is not wholly inside `registers` +static float read_float(std::span registers, uint16_t start_address, uint16_t reg, bool word_swapped) { + auto value = word_swapped ? helpers::value_at(registers, start_address, reg) + : helpers::value_at(registers, start_address, reg); + return value.value_or(NAN); +} + +// Publishes the field at `reg` (x1000 when `kilo`) unless it decodes to NaN/Inf, e.g. an unloaded +// phase's power factor; a non-finite field is skipped on its own so the others still publish. +static void publish_float(sensor::Sensor *sens, std::span registers, uint16_t reg, bool kilo, + bool word_swapped) { + float value = read_float(registers, 0, reg, word_swapped); + if (!std::isfinite(value)) { + ESP_LOGD(TAG, "Skipping non-finite value for '%s'", sens->get_name().c_str()); + return; + } + sens->publish_state(kilo ? value * 1000.0f : value); +} + +#if defined(USE_SELEC_METER_EM4M) && (defined(USE_TEXT_SENSOR) || defined(USE_BINARY_SENSOR)) +// Counts one failure of an optional read; callers only run while `failures` is below the limit +static void note_optional_read_failure(uint8_t &failures, const LogString *name) { + if (++failures == MAX_OPTIONAL_READ_FAILURES) { + ESP_LOGW(TAG, "Reading %s failed %u times in a row, giving up", LOG_STR_ARG(name), failures); + } +} +#endif + +SelecMeter::ReadRange SelecMeter::read_range_(ReadState state) const { + switch (state) { + case ReadState::READ_STATE_MAIN_BLOCK: +#ifdef USE_SELEC_METER_EM4M + if (this->model_ == Model::MODEL_EM4M) + return {0, EM4M_REGISTER_COUNT}; +#endif + return {0, EM2M_REGISTER_COUNT}; + case ReadState::READ_STATE_SERIAL_NUMBER: + return {EM4M_SERIAL_NUMBER, 2}; + case ReadState::READ_STATE_DG_SENSING: + return {EM4M_DG_SENSING, 2}; + case ReadState::READ_STATE_IDLE: + default: + return {0, 0}; + } +} + +bool SelecMeter::wanted_(ReadState state) const { + switch (state) { + case ReadState::READ_STATE_MAIN_BLOCK: + return true; +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) + case ReadState::READ_STATE_SERIAL_NUMBER: + return this->serial_number_sensor_ != nullptr && this->serial_number_failures_ < MAX_OPTIONAL_READ_FAILURES; +#endif +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) + case ReadState::READ_STATE_DG_SENSING: + return this->dg_sensing_sensor_ != nullptr && this->dg_sensing_failures_ < MAX_OPTIONAL_READ_FAILURES; +#endif + default: + return false; + } +} + +ReadState SelecMeter::next_read_state_after_(ReadState current) const { + for (auto next = static_cast(current) + 1; next < static_cast(ReadState::READ_STATE_IDLE); next++) { + if (this->wanted_(static_cast(next))) + return static_cast(next); + } + return ReadState::READ_STATE_IDLE; +} void SelecMeter::on_read_input_registers(uint16_t start_address, std::span registers, modbus::ResponseStatus status) { - if (!modbus::succeeded(status)) - return; // the hub already logs exception responses - - // Publish a sensor if both of its registers are in this response; skipping absent registers keeps - // this correct for any read range, so the poll may be split into multiple requests. - // Values are 32-bit floats, low word first. - auto publish = [&](sensor::Sensor *sensor, uint16_t reg, float unit) -> void { - if (sensor == nullptr) - return; - if (auto value = helpers::value_at(registers, start_address, reg)) - sensor->publish_state(*value * unit); + ReadState current = this->read_state_; + if (current == ReadState::READ_STATE_IDLE) { + ESP_LOGW(TAG, "Unexpected response while idle, dropping"); + return; + } + // start_address comes from the exact request this answers, so a stale reply cannot match another read + if (start_address != this->read_range_(current).start) { + this->fail_current_read_(LOG_STR("Response does not match pending read"), /*durable=*/false); + return; + } + if (status.has_value()) { + const auto code = *status; + ESP_LOGW(TAG, "Modbus error 0x%02X while reading %s", static_cast(code), + LOG_STR_ARG(read_state_name(current))); + // Illegal function/address are durable; busy, acknowledge and the rest are transient + const bool durable = + code == modbus::ExceptionCode::ILLEGAL_FUNCTION || code == modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS; + this->fail_current_read_(nullptr, durable); + return; + } + switch (current) { + case ReadState::READ_STATE_MAIN_BLOCK: { +#ifdef USE_SELEC_METER_EM4M + bool ok = this->model_ == Model::MODEL_EM4M ? this->decode_em4m_(registers) : this->decode_em2m_(registers); +#else + bool ok = this->decode_em2m_(registers); +#endif + if (ok) { + this->status_clear_warning(); + } else { + this->status_set_warning(); + } + break; + } +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) + case ReadState::READ_STATE_SERIAL_NUMBER: + this->decode_serial_number_(registers); + break; +#endif +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) + case ReadState::READ_STATE_DG_SENSING: + this->decode_dg_sensing_(registers); + break; +#endif + default: + break; + } + // Safe per modbus.h's callback contract: sending from inside a callback is picked up normally. + this->start_read_(this->next_read_state_after_(current)); +} + +void SelecMeter::fail_current_read_(const LogString *reason, bool durable) { + if (this->read_state_ == ReadState::READ_STATE_IDLE) { + ESP_LOGW(TAG, "Unexpected failure while idle, dropping"); + return; + } + ReadState failed_state = this->read_state_; + if (reason != nullptr) { + ESP_LOGW(TAG, "%s while reading %s", LOG_STR_ARG(reason), LOG_STR_ARG(read_state_name(failed_state))); + } + // Only the main block drives component health; an unsupported optional read is not a fault + if (failed_state == ReadState::READ_STATE_MAIN_BLOCK) + this->status_set_warning(); +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) + if (failed_state == ReadState::READ_STATE_SERIAL_NUMBER && durable) + note_optional_read_failure(this->serial_number_failures_, read_state_name(failed_state)); +#endif +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) + if (failed_state == ReadState::READ_STATE_DG_SENSING && durable) + note_optional_read_failure(this->dg_sensing_failures_, read_state_name(failed_state)); +#endif + this->start_read_(this->next_read_state_after_(failed_state)); +} + +bool SelecMeter::on_no_response(std::span /*request_pdu*/) { + this->fail_current_read_(LOG_STR("No Modbus response")); + return false; +} + +void SelecMeter::on_not_sent(std::span /*request_pdu*/) { + this->fail_current_read_(LOG_STR("Modbus request not sent")); +} + +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) +void SelecMeter::decode_serial_number_(std::span registers) { + auto serial = + this->word_swap_ + ? helpers::value_at(registers, EM4M_SERIAL_NUMBER, EM4M_SERIAL_NUMBER) + : helpers::value_at(registers, EM4M_SERIAL_NUMBER, EM4M_SERIAL_NUMBER); + if (!serial) { + ESP_LOGW(TAG, "Unexpected response size for serial number"); + note_optional_read_failure(this->serial_number_failures_, read_state_name(ReadState::READ_STATE_SERIAL_NUMBER)); + return; + } + char buf[9]; + snprintf(buf, sizeof(buf), "%08" PRIX32, *serial); + this->serial_number_sensor_->publish_state(buf); + // The serial number never changes, so stop reading it + this->serial_number_failures_ = MAX_OPTIONAL_READ_FAILURES; +} +#endif + +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) +void SelecMeter::decode_dg_sensing_(std::span registers) { + float value = read_float(registers, EM4M_DG_SENSING, EM4M_DG_SENSING, this->word_swap_); + if (!std::isfinite(value)) { + ESP_LOGW(TAG, "Short or non-finite DG sensing response, ignoring"); + note_optional_read_failure(this->dg_sensing_failures_, read_state_name(ReadState::READ_STATE_DG_SENSING)); + return; + } + this->dg_sensing_sensor_->publish_state(value != 0); + this->dg_sensing_failures_ = 0; +} +#endif + +bool SelecMeter::publish_fields_(std::span registers, std::span fields, + uint8_t min_count) { + if (registers.size() < min_count) { + ESP_LOGW(TAG, "Short response: %u registers", static_cast(registers.size())); + return false; + } + for (const auto &entry : fields) { + FloatField field; + progmem_memcpy(&field, &entry, sizeof(field)); + sensor::Sensor *sens = this->*field.sensor; + if (sens != nullptr) + publish_float(sens, registers, field.reg, field.kilo, this->word_swap_); + } + return true; +} + +bool SelecMeter::decode_em2m_(std::span registers) { + static constexpr FloatField EM2M_FIELDS[] PROGMEM = { + {&SelecMeter::total_active_energy_sensor_, SELEC_TOTAL_ACTIVE_ENERGY, false}, + {&SelecMeter::import_active_energy_sensor_, SELEC_IMPORT_ACTIVE_ENERGY, false}, + {&SelecMeter::export_active_energy_sensor_, SELEC_EXPORT_ACTIVE_ENERGY, false}, + {&SelecMeter::total_reactive_energy_sensor_, SELEC_TOTAL_REACTIVE_ENERGY, false}, + {&SelecMeter::import_reactive_energy_sensor_, SELEC_IMPORT_REACTIVE_ENERGY, false}, + {&SelecMeter::export_reactive_energy_sensor_, SELEC_EXPORT_REACTIVE_ENERGY, false}, + {&SelecMeter::apparent_energy_sensor_, SELEC_APPARENT_ENERGY, false}, + {&SelecMeter::active_power_sensor_, SELEC_ACTIVE_POWER, true}, + {&SelecMeter::reactive_power_sensor_, SELEC_REACTIVE_POWER, true}, + {&SelecMeter::apparent_power_sensor_, SELEC_APPARENT_POWER, true}, + {&SelecMeter::voltage_sensor_, SELEC_VOLTAGE, false}, + {&SelecMeter::current_sensor_, SELEC_CURRENT, false}, + {&SelecMeter::power_factor_sensor_, SELEC_POWER_FACTOR, false}, + {&SelecMeter::frequency_sensor_, SELEC_FREQUENCY, false}, + {&SelecMeter::maximum_demand_active_power_sensor_, SELEC_MAXIMUM_DEMAND_ACTIVE_POWER, true}, + {&SelecMeter::maximum_demand_reactive_power_sensor_, SELEC_MAXIMUM_DEMAND_REACTIVE_POWER, true}, + {&SelecMeter::maximum_demand_apparent_power_sensor_, SELEC_MAXIMUM_DEMAND_APPARENT_POWER, true}, }; + return this->publish_fields_(registers, EM2M_FIELDS, EM2M_REGISTER_COUNT); +} + +#ifdef USE_SELEC_METER_EM4M +bool SelecMeter::decode_em4m_(std::span registers) { + static constexpr FloatField EM4M_FIELDS[] PROGMEM = { + // Common quantities, shared sensor keys with EM2M + {&SelecMeter::voltage_sensor_, EM4M_VOLTAGE, false}, + {&SelecMeter::current_sensor_, EM4M_CURRENT, false}, + {&SelecMeter::active_power_sensor_, EM4M_ACTIVE_POWER, true}, + {&SelecMeter::reactive_power_sensor_, EM4M_REACTIVE_POWER, true}, + {&SelecMeter::apparent_power_sensor_, EM4M_APPARENT_POWER, true}, + {&SelecMeter::power_factor_sensor_, EM4M_POWER_FACTOR, false}, + {&SelecMeter::frequency_sensor_, EM4M_FREQUENCY, false}, + {&SelecMeter::maximum_demand_active_power_sensor_, EM4M_MAXIMUM_DEMAND_ACTIVE_POWER, true}, + {&SelecMeter::maximum_demand_reactive_power_sensor_, EM4M_MAXIMUM_DEMAND_REACTIVE_POWER, true}, + {&SelecMeter::maximum_demand_apparent_power_sensor_, EM4M_MAXIMUM_DEMAND_APPARENT_POWER, true}, + {&SelecMeter::import_active_energy_sensor_, EM4M_IMPORT_ACTIVE_ENERGY, false}, + {&SelecMeter::export_active_energy_sensor_, EM4M_EXPORT_ACTIVE_ENERGY, false}, + {&SelecMeter::import_reactive_energy_sensor_, EM4M_IMPORT_REACTIVE_ENERGY, false}, + {&SelecMeter::export_reactive_energy_sensor_, EM4M_EXPORT_REACTIVE_ENERGY, false}, + // Line-to-line and net (mains/DG) quantities have no per-phase equivalent + {&SelecMeter::voltage_l12_sensor_, EM4M_VOLTAGE_L12, false}, + {&SelecMeter::voltage_l23_sensor_, EM4M_VOLTAGE_L23, false}, + {&SelecMeter::voltage_l31_sensor_, EM4M_VOLTAGE_L31, false}, + {&SelecMeter::average_voltage_ll_sensor_, EM4M_AVERAGE_VOLTAGE_LL, false}, + {&SelecMeter::net_active_energy_mains_sensor_, EM4M_NET_ACTIVE_ENERGY_MAINS, false}, + {&SelecMeter::net_reactive_energy_mains_sensor_, EM4M_NET_REACTIVE_ENERGY_MAINS, false}, + {&SelecMeter::net_apparent_energy_mains_sensor_, EM4M_NET_APPARENT_ENERGY_MAINS, false}, + {&SelecMeter::net_active_energy_dg_sensor_, EM4M_NET_ACTIVE_ENERGY_DG, false}, + {&SelecMeter::net_reactive_energy_dg_sensor_, EM4M_NET_REACTIVE_ENERGY_DG, false}, + {&SelecMeter::net_apparent_energy_dg_sensor_, EM4M_NET_APPARENT_ENERGY_DG, false}, + }; + + // reg_l1 is the L1 register; L2/L3 follow EM4M_PHASE_STRIDE apart + struct PhaseField { + sensor::Sensor *Phase::*sensor; + uint16_t reg_l1; + bool kilo; + }; + static constexpr PhaseField PHASE_FIELDS[] PROGMEM = { + {&Phase::voltage_sensor_, EM4M_VOLTAGE_L1, false}, + {&Phase::current_sensor_, EM4M_CURRENT_L1, false}, + {&Phase::active_power_sensor_, EM4M_ACTIVE_POWER_L1, true}, + {&Phase::reactive_power_sensor_, EM4M_REACTIVE_POWER_L1, true}, + {&Phase::apparent_power_sensor_, EM4M_APPARENT_POWER_L1, true}, + {&Phase::power_factor_sensor_, EM4M_POWER_FACTOR_L1, false}, + {&Phase::import_active_energy_sensor_, EM4M_IMPORT_ACTIVE_ENERGY_L1, false}, + {&Phase::export_active_energy_sensor_, EM4M_EXPORT_ACTIVE_ENERGY_L1, false}, + {&Phase::import_reactive_energy_sensor_, EM4M_IMPORT_REACTIVE_ENERGY_L1, false}, + {&Phase::export_reactive_energy_sensor_, EM4M_EXPORT_REACTIVE_ENERGY_L1, false}, + {&Phase::apparent_energy_sensor_, EM4M_APPARENT_ENERGY_L1, false}, + }; + + if (!this->publish_fields_(registers, EM4M_FIELDS, EM4M_REGISTER_COUNT)) + return false; + for (const auto &entry : PHASE_FIELDS) { + PhaseField field; + progmem_memcpy(&field, &entry, sizeof(field)); + for (uint8_t phase = 0; phase < 3; phase++) { + sensor::Sensor *sens = this->phases_[phase].*field.sensor; + if (sens != nullptr) + publish_float(sens, registers, field.reg_l1 + phase * EM4M_PHASE_STRIDE, field.kilo, this->word_swap_); + } + } + return true; +} +#endif + +void SelecMeter::update() { + if (this->read_state_ != ReadState::READ_STATE_IDLE) { + ESP_LOGD(TAG, "Skipping update: previous read cycle (%s) still in progress", + LOG_STR_ARG(read_state_name(this->read_state_))); + return; + } + this->start_read_(ReadState::READ_STATE_MAIN_BLOCK); +} - publish(this->total_active_energy_sensor_, SELEC_TOTAL_ACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->import_active_energy_sensor_, SELEC_IMPORT_ACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->export_active_energy_sensor_, SELEC_EXPORT_ACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->total_reactive_energy_sensor_, SELEC_TOTAL_REACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->import_reactive_energy_sensor_, SELEC_IMPORT_REACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->export_reactive_energy_sensor_, SELEC_EXPORT_REACTIVE_ENERGY, NO_DEC_UNIT); - publish(this->apparent_energy_sensor_, SELEC_APPARENT_ENERGY, NO_DEC_UNIT); - publish(this->active_power_sensor_, SELEC_ACTIVE_POWER, MULTIPLY_THOUSAND_UNIT); - publish(this->reactive_power_sensor_, SELEC_REACTIVE_POWER, MULTIPLY_THOUSAND_UNIT); - publish(this->apparent_power_sensor_, SELEC_APPARENT_POWER, MULTIPLY_THOUSAND_UNIT); - publish(this->voltage_sensor_, SELEC_VOLTAGE, NO_DEC_UNIT); - publish(this->current_sensor_, SELEC_CURRENT, NO_DEC_UNIT); - publish(this->power_factor_sensor_, SELEC_POWER_FACTOR, NO_DEC_UNIT); - publish(this->frequency_sensor_, SELEC_FREQUENCY, NO_DEC_UNIT); - publish(this->maximum_demand_active_power_sensor_, SELEC_MAXIMUM_DEMAND_ACTIVE_POWER, MULTIPLY_THOUSAND_UNIT); - publish(this->maximum_demand_reactive_power_sensor_, SELEC_MAXIMUM_DEMAND_REACTIVE_POWER, MULTIPLY_THOUSAND_UNIT); - publish(this->maximum_demand_apparent_power_sensor_, SELEC_MAXIMUM_DEMAND_APPARENT_POWER, MULTIPLY_THOUSAND_UNIT); +void SelecMeter::start_read_(ReadState state) { + this->read_state_ = state; + if (state == ReadState::READ_STATE_IDLE) + return; + const ReadRange range = this->read_range_(state); + // A refused send gets no callback, so unwind here like the async failure paths + if (!this->read_input_registers(range.start, range.count)) + this->fail_current_read_(LOG_STR("Modbus request refused")); } -void SelecMeter::update() { this->read_input_registers(0, MODBUS_REGISTER_COUNT); } void SelecMeter::dump_config() { ESP_LOGCONFIG(TAG, "SELEC Meter:\n" + " Model: %s\n" + " Byte Order: %s\n" " Address: 0x%02X", + this->model_ == Model::MODEL_EM4M ? LOG_STR_LITERAL("EM4M") : LOG_STR_LITERAL("EM2M"), + this->word_swap_ ? LOG_STR_LITERAL("little_endian (LSRF)") : LOG_STR_LITERAL("big_endian (MSRF)"), this->address_); LOG_SENSOR(" ", "Total Active Energy", this->total_active_energy_sensor_); LOG_SENSOR(" ", "Import Active Energy", this->import_active_energy_sensor_); @@ -67,6 +371,41 @@ void SelecMeter::dump_config() { LOG_SENSOR(" ", "Maximum Demand Active Power", this->maximum_demand_active_power_sensor_); LOG_SENSOR(" ", "Maximum Demand Reactive Power", this->maximum_demand_reactive_power_sensor_); LOG_SENSOR(" ", "Maximum Demand Apparent Power", this->maximum_demand_apparent_power_sensor_); +#ifdef USE_SELEC_METER_EM4M + if (this->model_ == Model::MODEL_EM4M) { + LOG_SENSOR(" ", "Voltage L1-L2", this->voltage_l12_sensor_); + LOG_SENSOR(" ", "Voltage L2-L3", this->voltage_l23_sensor_); + LOG_SENSOR(" ", "Voltage L3-L1", this->voltage_l31_sensor_); + LOG_SENSOR(" ", "Average Voltage LL", this->average_voltage_ll_sensor_); + for (uint8_t phase = 0; phase < 3; phase++) { + const auto &p = this->phases_[phase]; + ESP_LOGCONFIG(TAG, " Phase %c", 'A' + phase); + LOG_SENSOR(" ", "Voltage", p.voltage_sensor_); + LOG_SENSOR(" ", "Current", p.current_sensor_); + LOG_SENSOR(" ", "Active Power", p.active_power_sensor_); + LOG_SENSOR(" ", "Reactive Power", p.reactive_power_sensor_); + LOG_SENSOR(" ", "Apparent Power", p.apparent_power_sensor_); + LOG_SENSOR(" ", "Power Factor", p.power_factor_sensor_); + LOG_SENSOR(" ", "Import Active Energy", p.import_active_energy_sensor_); + LOG_SENSOR(" ", "Export Active Energy", p.export_active_energy_sensor_); + LOG_SENSOR(" ", "Import Reactive Energy", p.import_reactive_energy_sensor_); + LOG_SENSOR(" ", "Export Reactive Energy", p.export_reactive_energy_sensor_); + LOG_SENSOR(" ", "Apparent Energy", p.apparent_energy_sensor_); + } + LOG_SENSOR(" ", "Net Active Energy (Mains)", this->net_active_energy_mains_sensor_); + LOG_SENSOR(" ", "Net Reactive Energy (Mains)", this->net_reactive_energy_mains_sensor_); + LOG_SENSOR(" ", "Net Apparent Energy (Mains)", this->net_apparent_energy_mains_sensor_); + LOG_SENSOR(" ", "Net Active Energy (DG)", this->net_active_energy_dg_sensor_); + LOG_SENSOR(" ", "Net Reactive Energy (DG)", this->net_reactive_energy_dg_sensor_); + LOG_SENSOR(" ", "Net Apparent Energy (DG)", this->net_apparent_energy_dg_sensor_); + } +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Serial Number", this->serial_number_sensor_); +#endif +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "DG Sensing", this->dg_sensing_sensor_); +#endif +#endif } } // namespace esphome::selec_meter diff --git a/esphome/components/selec_meter/selec_meter.h b/esphome/components/selec_meter/selec_meter.h index 470242c9184..98fea2987e9 100644 --- a/esphome/components/selec_meter/selec_meter.h +++ b/esphome/components/selec_meter/selec_meter.h @@ -1,8 +1,15 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/log.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/modbus/modbus.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif #include @@ -15,6 +22,24 @@ namespace esphome::selec_meter { public: \ void set_##name##_sensor(sensor::Sensor *(name)) { this->name##_sensor_ = name; } +// Per-phase sensor in the Phase struct, setter indexed by phase (0 = L1/A, 1 = L2/B, 2 = L3/C) +#define SELEC_METER_PHASE_SENSOR(name) \ + void set_##name##_sensor(uint8_t phase, sensor::Sensor *(name)) { this->phases_[phase].name##_sensor_ = name; } + +enum class Model : uint8_t { + MODEL_EM2M, + MODEL_EM4M, +}; + +// Read cycle steps in the order they run. The extra reads (serial number, DG sensing) sit far outside +// the main block and get their own transaction. +enum class ReadState : uint8_t { + READ_STATE_MAIN_BLOCK, + READ_STATE_SERIAL_NUMBER, + READ_STATE_DG_SENSING, + READ_STATE_IDLE, +}; + class SelecMeter final : public PollingComponent, public modbus::ModbusClientDevice { public: SELEC_METER_SENSOR(total_active_energy) @@ -35,12 +60,122 @@ class SelecMeter final : public PollingComponent, public modbus::ModbusClientDev SELEC_METER_SENSOR(maximum_demand_reactive_power) SELEC_METER_SENSOR(maximum_demand_apparent_power) +#ifdef USE_SELEC_METER_EM4M + // EM4M per-phase sensors, indexed by phase (see Phase below) + SELEC_METER_PHASE_SENSOR(voltage) + SELEC_METER_PHASE_SENSOR(current) + SELEC_METER_PHASE_SENSOR(active_power) + SELEC_METER_PHASE_SENSOR(reactive_power) + SELEC_METER_PHASE_SENSOR(apparent_power) + SELEC_METER_PHASE_SENSOR(power_factor) + SELEC_METER_PHASE_SENSOR(import_active_energy) + SELEC_METER_PHASE_SENSOR(export_active_energy) + SELEC_METER_PHASE_SENSOR(import_reactive_energy) + SELEC_METER_PHASE_SENSOR(export_reactive_energy) + SELEC_METER_PHASE_SENSOR(apparent_energy) + + // Line-to-line and aggregate sensors, not tied to one phase + SELEC_METER_SENSOR(voltage_l12) + SELEC_METER_SENSOR(voltage_l23) + SELEC_METER_SENSOR(voltage_l31) + SELEC_METER_SENSOR(average_voltage_ll) + SELEC_METER_SENSOR(net_active_energy_mains) + SELEC_METER_SENSOR(net_reactive_energy_mains) + SELEC_METER_SENSOR(net_apparent_energy_mains) + SELEC_METER_SENSOR(net_active_energy_dg) + SELEC_METER_SENSOR(net_reactive_energy_dg) + SELEC_METER_SENSOR(net_apparent_energy_dg) +#endif + + void set_model(Model model) { this->model_ = model; } + // true: registers are word-swapped (LSRF / Mid Little Endian, CDAB byte order) + // false: registers are in the meter's straightforward order (MSRF / Big Endian, ABCD byte order) + void set_word_swap(bool word_swap) { this->word_swap_ = word_swap; } +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) + void set_serial_number_sensor(text_sensor::TextSensor *serial_number) { this->serial_number_sensor_ = serial_number; } +#endif +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) + void set_dg_sensing_sensor(binary_sensor::BinarySensor *dg_sensing) { this->dg_sensing_sensor_ = dg_sensing; } +#endif + void update() override; void on_read_input_registers(uint16_t start_address, std::span registers, modbus::ResponseStatus status) override; + bool on_no_response(std::span request_pdu) override; + void on_not_sent(std::span request_pdu) override; void dump_config() override; + + protected: + // One float field of the main block: target sensor, register and whether it is published x1000 + struct FloatField { + sensor::Sensor *SelecMeter::*sensor; + uint16_t reg; + bool kilo; + }; + + // Publishes each configured field in `fields` (a PROGMEM table); false on a payload shorter than + // `min_count`, and the caller sets or clears the warning from the result + bool publish_fields_(std::span registers, std::span fields, uint8_t min_count); + bool decode_em2m_(std::span registers); +#ifdef USE_SELEC_METER_EM4M + bool decode_em4m_(std::span registers); +#ifdef USE_TEXT_SENSOR + void decode_serial_number_(std::span registers); +#endif +#ifdef USE_BINARY_SENSOR + void decode_dg_sensing_(std::span registers); +#endif +#endif + // First register and register count of the read for `state` + struct ReadRange { + uint16_t start; + uint8_t count; + }; + ReadRange read_range_(ReadState state) const; + // Whether the read cycle should run `state` on this pass + bool wanted_(ReadState state) const; + // Next wanted step after `current`, so one failed range does not cancel the other reads + ReadState next_read_state_after_(ReadState current) const; + // Sends the read for `state`; safe from a callback per the modbus callback contract + void start_read_(ReadState state); + // Clears the pending read and advances the chain. Only a MAIN_BLOCK failure sets the warning and + // only a `durable` refusal counts toward MAX_OPTIONAL_READ_FAILURES; `reason` nullptr means already logged + void fail_current_read_(const LogString *reason, bool durable = false); + +#ifdef USE_SELEC_METER_EM4M + // phases_[0..2] are L1/L2/L3 (A/B/C), EM4M_PHASE_STRIDE registers apart + struct Phase { + sensor::Sensor *voltage_sensor_{nullptr}; + sensor::Sensor *current_sensor_{nullptr}; + sensor::Sensor *active_power_sensor_{nullptr}; + sensor::Sensor *reactive_power_sensor_{nullptr}; + sensor::Sensor *apparent_power_sensor_{nullptr}; + sensor::Sensor *power_factor_sensor_{nullptr}; + sensor::Sensor *import_active_energy_sensor_{nullptr}; + sensor::Sensor *export_active_energy_sensor_{nullptr}; + sensor::Sensor *import_reactive_energy_sensor_{nullptr}; + sensor::Sensor *export_reactive_energy_sensor_{nullptr}; + sensor::Sensor *apparent_energy_sensor_{nullptr}; + }; + Phase phases_[3]; +#endif + + Model model_{Model::MODEL_EM2M}; + bool word_swap_{false}; + ReadState read_state_{ReadState::READ_STATE_IDLE}; + + // An optional read stops once its counter reaches MAX_OPTIONAL_READ_FAILURES +#if defined(USE_SELEC_METER_EM4M) && defined(USE_TEXT_SENSOR) + text_sensor::TextSensor *serial_number_sensor_{nullptr}; + // Also set to the limit once the serial number is published, since it never changes + uint8_t serial_number_failures_{0}; +#endif +#if defined(USE_SELEC_METER_EM4M) && defined(USE_BINARY_SENSOR) + binary_sensor::BinarySensor *dg_sensing_sensor_{nullptr}; + uint8_t dg_sensing_failures_{0}; +#endif }; } // namespace esphome::selec_meter diff --git a/esphome/components/selec_meter/selec_meter_registers.h b/esphome/components/selec_meter/selec_meter_registers.h index d299560aaba..e7678ccabee 100644 --- a/esphome/components/selec_meter/selec_meter_registers.h +++ b/esphome/components/selec_meter/selec_meter_registers.h @@ -1,30 +1,117 @@ #pragma once +#include + namespace esphome::selec_meter { -static const float TWO_DEC_UNIT = 0.01; -static const float ONE_DEC_UNIT = 0.1; -static const float NO_DEC_UNIT = 1; -static const float MULTIPLY_TEN_UNIT = 10; -static const float MULTIPLY_THOUSAND_UNIT = 1000; - /* PHASE STATUS REGISTERS */ -static const uint16_t SELEC_TOTAL_ACTIVE_ENERGY = 0x0000; -static const uint16_t SELEC_IMPORT_ACTIVE_ENERGY = 0x0002; -static const uint16_t SELEC_EXPORT_ACTIVE_ENERGY = 0x0004; -static const uint16_t SELEC_TOTAL_REACTIVE_ENERGY = 0x0006; -static const uint16_t SELEC_IMPORT_REACTIVE_ENERGY = 0x0008; -static const uint16_t SELEC_EXPORT_REACTIVE_ENERGY = 0x000A; -static const uint16_t SELEC_APPARENT_ENERGY = 0x000C; -static const uint16_t SELEC_ACTIVE_POWER = 0x000E; -static const uint16_t SELEC_REACTIVE_POWER = 0x0010; -static const uint16_t SELEC_APPARENT_POWER = 0x0012; -static const uint16_t SELEC_VOLTAGE = 0x0014; -static const uint16_t SELEC_CURRENT = 0x0016; -static const uint16_t SELEC_POWER_FACTOR = 0x0018; -static const uint16_t SELEC_FREQUENCY = 0x001A; -static const uint16_t SELEC_MAXIMUM_DEMAND_ACTIVE_POWER = 0x001C; -static const uint16_t SELEC_MAXIMUM_DEMAND_REACTIVE_POWER = 0x001E; -static const uint16_t SELEC_MAXIMUM_DEMAND_APPARENT_POWER = 0x0020; +static constexpr uint16_t SELEC_TOTAL_ACTIVE_ENERGY = 0x0000; +static constexpr uint16_t SELEC_IMPORT_ACTIVE_ENERGY = 0x0002; +static constexpr uint16_t SELEC_EXPORT_ACTIVE_ENERGY = 0x0004; +static constexpr uint16_t SELEC_TOTAL_REACTIVE_ENERGY = 0x0006; +static constexpr uint16_t SELEC_IMPORT_REACTIVE_ENERGY = 0x0008; +static constexpr uint16_t SELEC_EXPORT_REACTIVE_ENERGY = 0x000A; +static constexpr uint16_t SELEC_APPARENT_ENERGY = 0x000C; +static constexpr uint16_t SELEC_ACTIVE_POWER = 0x000E; +static constexpr uint16_t SELEC_REACTIVE_POWER = 0x0010; +static constexpr uint16_t SELEC_APPARENT_POWER = 0x0012; +static constexpr uint16_t SELEC_VOLTAGE = 0x0014; +static constexpr uint16_t SELEC_CURRENT = 0x0016; +static constexpr uint16_t SELEC_POWER_FACTOR = 0x0018; +static constexpr uint16_t SELEC_FREQUENCY = 0x001A; +static constexpr uint16_t SELEC_MAXIMUM_DEMAND_ACTIVE_POWER = 0x001C; +static constexpr uint16_t SELEC_MAXIMUM_DEMAND_REACTIVE_POWER = 0x001E; +static constexpr uint16_t SELEC_MAXIMUM_DEMAND_APPARENT_POWER = 0x0020; + +/* EM4M REGISTERS -- offsets are register indices from input register 30000 */ +static constexpr uint8_t EM4M_REGISTER_COUNT = 114; // 114 x 16-bit registers + +/* EM4M -- common quantities shared with the EM2M sensor keys */ +static constexpr uint16_t EM4M_VOLTAGE = 0x0006; // Average Voltage LN +static constexpr uint16_t EM4M_CURRENT = 0x0016; // Average Current +static constexpr uint16_t EM4M_ACTIVE_POWER = 0x002A; // Total kW +static constexpr uint16_t EM4M_REACTIVE_POWER = 0x002C; // Total kVAr +static constexpr uint16_t EM4M_APPARENT_POWER = 0x002E; // Total kVA +static constexpr uint16_t EM4M_POWER_FACTOR = 0x0036; // Average PF +static constexpr uint16_t EM4M_FREQUENCY = 0x0038; +static constexpr uint16_t EM4M_MAXIMUM_DEMAND_ACTIVE_POWER = 0x0046; +static constexpr uint16_t EM4M_MAXIMUM_DEMAND_REACTIVE_POWER = 0x0048; +static constexpr uint16_t EM4M_MAXIMUM_DEMAND_APPARENT_POWER = 0x004A; +static constexpr uint16_t EM4M_IMPORT_ACTIVE_ENERGY = 0x0058; // Total kWh - Import +static constexpr uint16_t EM4M_EXPORT_ACTIVE_ENERGY = 0x005A; // Total kWh - Export +static constexpr uint16_t EM4M_IMPORT_REACTIVE_ENERGY = 0x0068; // Total kVArh - Import +static constexpr uint16_t EM4M_EXPORT_REACTIVE_ENERGY = 0x006A; // Total kVArh - Export +static constexpr uint16_t EM4M_AVERAGE_VOLTAGE_LL = 0x000E; // Average Voltage LL + +// Net (import - export) energy totals, split by source. Only meaningful when the +// meter's "Dual Source" setting is Yes; DG registers read 0 on single-source installs. +static constexpr uint16_t EM4M_NET_ACTIVE_ENERGY_MAINS = 0x003A; // Total Net kWh (MAINS) +static constexpr uint16_t EM4M_NET_REACTIVE_ENERGY_MAINS = 0x003C; // Total Net kVArh (MAINS) +static constexpr uint16_t EM4M_NET_APPARENT_ENERGY_MAINS = 0x003E; // Total Net kVAh (MAINS) +static constexpr uint16_t EM4M_NET_ACTIVE_ENERGY_DG = 0x0040; // Total Net kWh (DG) +static constexpr uint16_t EM4M_NET_REACTIVE_ENERGY_DG = 0x0042; // Total Net kVArh (DG) +static constexpr uint16_t EM4M_NET_APPARENT_ENERGY_DG = 0x0044; // Total Net kVAh (DG) + +/* EM4M -- per-phase quantities, no EM2M equivalent */ +static constexpr uint16_t EM4M_VOLTAGE_L1 = 0x0000; // Voltage V1N +static constexpr uint16_t EM4M_VOLTAGE_L2 = 0x0002; // Voltage V2N +static constexpr uint16_t EM4M_VOLTAGE_L3 = 0x0004; // Voltage V3N +static constexpr uint16_t EM4M_VOLTAGE_L12 = 0x0008; // Voltage V12 +static constexpr uint16_t EM4M_VOLTAGE_L23 = 0x000A; // Voltage V23 +static constexpr uint16_t EM4M_VOLTAGE_L31 = 0x000C; // Voltage V31 +static constexpr uint16_t EM4M_CURRENT_L1 = 0x0010; +static constexpr uint16_t EM4M_CURRENT_L2 = 0x0012; +static constexpr uint16_t EM4M_CURRENT_L3 = 0x0014; +static constexpr uint16_t EM4M_ACTIVE_POWER_L1 = 0x0018; // kW1 +static constexpr uint16_t EM4M_ACTIVE_POWER_L2 = 0x001A; // kW2 +static constexpr uint16_t EM4M_ACTIVE_POWER_L3 = 0x001C; // kW3 +static constexpr uint16_t EM4M_REACTIVE_POWER_L1 = 0x001E; // kVAr1 +static constexpr uint16_t EM4M_REACTIVE_POWER_L2 = 0x0020; // kVAr2 +static constexpr uint16_t EM4M_REACTIVE_POWER_L3 = 0x0022; // kVAr3 +static constexpr uint16_t EM4M_APPARENT_POWER_L1 = 0x0024; // kVA1 +static constexpr uint16_t EM4M_APPARENT_POWER_L2 = 0x0026; // kVA2 +static constexpr uint16_t EM4M_APPARENT_POWER_L3 = 0x0028; // kVA3 +static constexpr uint16_t EM4M_POWER_FACTOR_L1 = 0x0030; +static constexpr uint16_t EM4M_POWER_FACTOR_L2 = 0x0032; +static constexpr uint16_t EM4M_POWER_FACTOR_L3 = 0x0034; +static constexpr uint16_t EM4M_IMPORT_ACTIVE_ENERGY_L1 = 0x004C; // kWh1 - Import +static constexpr uint16_t EM4M_IMPORT_ACTIVE_ENERGY_L2 = 0x004E; // kWh2 - Import +static constexpr uint16_t EM4M_IMPORT_ACTIVE_ENERGY_L3 = 0x0050; // kWh3 - Import +static constexpr uint16_t EM4M_EXPORT_ACTIVE_ENERGY_L1 = 0x0052; // kWh1 - Export +static constexpr uint16_t EM4M_EXPORT_ACTIVE_ENERGY_L2 = 0x0054; // kWh2 - Export +static constexpr uint16_t EM4M_EXPORT_ACTIVE_ENERGY_L3 = 0x0056; // kWh3 - Export +static constexpr uint16_t EM4M_IMPORT_REACTIVE_ENERGY_L1 = 0x005C; // kVArh1 - Import +static constexpr uint16_t EM4M_IMPORT_REACTIVE_ENERGY_L2 = 0x005E; // kVArh2 - Import +static constexpr uint16_t EM4M_IMPORT_REACTIVE_ENERGY_L3 = 0x0060; // kVArh3 - Import +static constexpr uint16_t EM4M_EXPORT_REACTIVE_ENERGY_L1 = 0x0062; // kVArh1 - Export +static constexpr uint16_t EM4M_EXPORT_REACTIVE_ENERGY_L2 = 0x0064; // kVArh2 - Export +static constexpr uint16_t EM4M_EXPORT_REACTIVE_ENERGY_L3 = 0x0066; // kVArh3 - Export +static constexpr uint16_t EM4M_APPARENT_ENERGY_L1 = 0x006C; // kVAh-1 +static constexpr uint16_t EM4M_APPARENT_ENERGY_L2 = 0x006E; // kVAh-2 +static constexpr uint16_t EM4M_APPARENT_ENERGY_L3 = 0x0070; // kVAh-3 + +// The decoder reads L2/L3 as the L1 register plus one or two strides; the L2/L3 names above +// document the datasheet and are checked against that stride here. +static constexpr uint16_t EM4M_PHASE_STRIDE = 2; +#define SELEC_EM4M_CHECK_STRIDE(name) \ + static_assert(EM4M_##name##_L2 == EM4M_##name##_L1 + EM4M_PHASE_STRIDE && \ + EM4M_##name##_L3 == EM4M_##name##_L1 + 2 * EM4M_PHASE_STRIDE, \ + #name " per-phase registers must be EM4M_PHASE_STRIDE apart"); +SELEC_EM4M_CHECK_STRIDE(VOLTAGE) +SELEC_EM4M_CHECK_STRIDE(CURRENT) +SELEC_EM4M_CHECK_STRIDE(ACTIVE_POWER) +SELEC_EM4M_CHECK_STRIDE(REACTIVE_POWER) +SELEC_EM4M_CHECK_STRIDE(APPARENT_POWER) +SELEC_EM4M_CHECK_STRIDE(POWER_FACTOR) +SELEC_EM4M_CHECK_STRIDE(IMPORT_ACTIVE_ENERGY) +SELEC_EM4M_CHECK_STRIDE(EXPORT_ACTIVE_ENERGY) +SELEC_EM4M_CHECK_STRIDE(IMPORT_REACTIVE_ENERGY) +SELEC_EM4M_CHECK_STRIDE(EXPORT_REACTIVE_ENERGY) +SELEC_EM4M_CHECK_STRIDE(APPARENT_ENERGY) +#undef SELEC_EM4M_CHECK_STRIDE + +// Far outside the main 0-113 register block; read via a separate Modbus transaction. +static constexpr uint16_t EM4M_SERIAL_NUMBER = 0x02AC; // Serial No. (Hex), 30684 +static constexpr uint16_t EM4M_DG_SENSING = 0x02C6; // DG Sensing (1: Pass, 0: Fail), 30710 } // namespace esphome::selec_meter diff --git a/esphome/components/selec_meter/sensor.py b/esphome/components/selec_meter/sensor.py index 04aef00d46b..5e6709ec97d 100644 --- a/esphome/components/selec_meter/sensor.py +++ b/esphome/components/selec_meter/sensor.py @@ -1,5 +1,11 @@ import esphome.codegen as cg -from esphome.components import modbus, sensor +from esphome.components import binary_sensor, modbus, sensor, text_sensor +from esphome.components.const import ( + BYTE_ORDER_BIG, + BYTE_ORDER_LITTLE, + CONF_BYTE_ORDER, + CONF_SERIAL_NUMBER, +) import esphome.config_validation as cv from esphome.const import ( CONF_ACTIVE_POWER, @@ -11,6 +17,10 @@ from esphome.const import ( CONF_ID, CONF_IMPORT_ACTIVE_ENERGY, CONF_IMPORT_REACTIVE_ENERGY, + CONF_MODEL, + CONF_PHASE_A, + CONF_PHASE_B, + CONF_PHASE_C, CONF_POWER_FACTOR, CONF_REACTIVE_POWER, CONF_VOLTAGE, @@ -24,6 +34,7 @@ from esphome.const import ( DEVICE_CLASS_VOLTAGE, ICON_CURRENT_AC, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, UNIT_HERTZ, @@ -37,9 +48,22 @@ from esphome.const import ( ) from esphome.types import ConfigType -AUTO_LOAD = ["modbus"] CODEOWNERS = ["@sourabhjaiswal"] +CONF_DG_SENSING = "dg_sensing" + + +def AUTO_LOAD(config: ConfigType) -> list[str]: + return ["modbus"] + [ + component + for key, component in ( + (CONF_SERIAL_NUMBER, "text_sensor"), + (CONF_DG_SENSING, "binary_sensor"), + ) + if not config or key in config + ] + + CONF_TOTAL_ACTIVE_ENERGY = "total_active_energy" CONF_TOTAL_REACTIVE_ENERGY = "total_reactive_energy" CONF_APPARENT_ENERGY = "apparent_energy" @@ -47,19 +71,68 @@ CONF_MAXIMUM_DEMAND_ACTIVE_POWER = "maximum_demand_active_power" CONF_MAXIMUM_DEMAND_REACTIVE_POWER = "maximum_demand_reactive_power" CONF_MAXIMUM_DEMAND_APPARENT_POWER = "maximum_demand_apparent_power" +CONF_VOLTAGE_L12 = "voltage_l12" +CONF_VOLTAGE_L23 = "voltage_l23" +CONF_VOLTAGE_L31 = "voltage_l31" + +CONF_AVERAGE_VOLTAGE_LL = "average_voltage_ll" +CONF_NET_ACTIVE_ENERGY_MAINS = "net_active_energy_mains" +CONF_NET_REACTIVE_ENERGY_MAINS = "net_reactive_energy_mains" +CONF_NET_APPARENT_ENERGY_MAINS = "net_apparent_energy_mains" +CONF_NET_ACTIVE_ENERGY_DG = "net_active_energy_dg" +CONF_NET_REACTIVE_ENERGY_DG = "net_reactive_energy_dg" +CONF_NET_APPARENT_ENERGY_DG = "net_apparent_energy_dg" selec_meter_ns = cg.esphome_ns.namespace("selec_meter") SelecMeter = selec_meter_ns.class_( "SelecMeter", cg.PollingComponent, modbus.ModbusClientDevice ) +SelecMeterModel = selec_meter_ns.enum("Model", is_class=True) -SENSORS = { +MODEL_EM2M = "em2m" +MODEL_EM4M = "em4m" +MODELS = { + MODEL_EM2M: SelecMeterModel.MODEL_EM2M, + MODEL_EM4M: SelecMeterModel.MODEL_EM4M, +} + +# Register word order of the FP32 values: big_endian sends the high word first (ABCD), +# little_endian the low word first (CDAB). msrf/lsrf are aliases matching the meter's own +# register 40070 (Endianness Selection) menu names. +BYTE_ORDER_MSRF = "msrf" +BYTE_ORDER_LSRF = "lsrf" +# Maps the YAML option directly to the C++ word_swap bool -- no enum needed on the C++ side. +BYTE_ORDER_WORD_SWAP = { + BYTE_ORDER_BIG: False, + BYTE_ORDER_LITTLE: True, + BYTE_ORDER_MSRF: False, + BYTE_ORDER_LSRF: True, +} +validate_byte_order = cv.enum(BYTE_ORDER_WORD_SWAP, lower=True) + +# EM2M-only: total combined energy figures with no non-DG equivalent on the EM4M. +EM2M_SENSORS = { CONF_TOTAL_ACTIVE_ENERGY: sensor.sensor_schema( unit_of_measurement=UNIT_KILOWATT_HOURS, accuracy_decimals=2, device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), + CONF_TOTAL_REACTIVE_ENERGY: sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=2, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + CONF_APPARENT_ENERGY: sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_HOURS, + accuracy_decimals=2, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), +} + +# Sensors available on both EM2M and EM4M. On EM4M these read the aggregate +# (Total/Average) registers rather than a single-phase measurement. +COMMON_SENSORS = { CONF_IMPORT_ACTIVE_ENERGY: sensor.sensor_schema( unit_of_measurement=UNIT_KILOWATT_HOURS, accuracy_decimals=2, @@ -72,11 +145,6 @@ SENSORS = { device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), - CONF_TOTAL_REACTIVE_ENERGY: sensor.sensor_schema( - unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, - accuracy_decimals=2, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), CONF_IMPORT_REACTIVE_ENERGY: sensor.sensor_schema( unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, accuracy_decimals=2, @@ -87,11 +155,6 @@ SENSORS = { accuracy_decimals=2, state_class=STATE_CLASS_TOTAL_INCREASING, ), - CONF_APPARENT_ENERGY: sensor.sensor_schema( - unit_of_measurement=UNIT_KILOVOLT_AMPS_HOURS, - accuracy_decimals=2, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), CONF_ACTIVE_POWER: sensor.sensor_schema( unit_of_measurement=UNIT_WATT, accuracy_decimals=3, @@ -154,13 +217,115 @@ SENSORS = { ), } -CONFIG_SCHEMA = ( - cv.Schema({cv.GenerateID(): cv.declare_id(SelecMeter)}) - .extend( - {cv.Optional(sensor_name): schema for sensor_name, schema in SENSORS.items()} + +def _sensor_group(*keys: str, **schema_kwargs) -> dict[str, cv.Schema]: + """One identical sensor_schema() per key.""" + return {key: sensor.sensor_schema(**schema_kwargs) for key in keys} + + +# EM4M-only, genuinely per-phase quantities, nested under phase_a/phase_b/phase_c like +# sdm_meter/atm90e32 do for 3-phase meters. +PHASE_SENSORS = { + key: {**COMMON_SENSORS, **EM2M_SENSORS}[key] + for key in ( + CONF_VOLTAGE, + CONF_CURRENT, + CONF_ACTIVE_POWER, + CONF_REACTIVE_POWER, + CONF_APPARENT_POWER, + CONF_POWER_FACTOR, + CONF_IMPORT_ACTIVE_ENERGY, + CONF_EXPORT_ACTIVE_ENERGY, + CONF_IMPORT_REACTIVE_ENERGY, + CONF_EXPORT_REACTIVE_ENERGY, + CONF_APPARENT_ENERGY, ) - .extend(cv.polling_component_schema("10s")) - .extend(modbus.modbus_device_schema(0x01)) +} + +PHASE_SCHEMA = cv.Schema( + {cv.Optional(key): schema for key, schema in PHASE_SENSORS.items()} +) + +# EM4M-only, line-to-line and mains/DG net totals -- these don't map to a single phase, so they +# stay flat rather than living under phase_a/phase_b/phase_c. +EM4M_SENSORS = { + **_sensor_group( + CONF_VOLTAGE_L12, + CONF_VOLTAGE_L23, + CONF_VOLTAGE_L31, + CONF_AVERAGE_VOLTAGE_LL, + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=2, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + # Net (import - export) energy totals, split by source. Only meaningful when the meter's + # "Dual Source" setting is Yes; DG registers read 0 on single-source installs. + **_sensor_group( + CONF_NET_ACTIVE_ENERGY_MAINS, + CONF_NET_ACTIVE_ENERGY_DG, + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=2, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL, + ), + **_sensor_group( + CONF_NET_REACTIVE_ENERGY_MAINS, + CONF_NET_REACTIVE_ENERGY_DG, + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=2, + state_class=STATE_CLASS_TOTAL, + ), + **_sensor_group( + CONF_NET_APPARENT_ENERGY_MAINS, + CONF_NET_APPARENT_ENERGY_DG, + unit_of_measurement=UNIT_KILOVOLT_AMPS_HOURS, + accuracy_decimals=2, + state_class=STATE_CLASS_TOTAL, + ), +} + +PHASES = (CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C) + + +def _optional(sensors: dict[str, cv.Schema]) -> dict[cv.Optional, cv.Schema]: + return {cv.Optional(key): schema for key, schema in sensors.items()} + + +_BASE_SCHEMA = cv.Schema({cv.GenerateID(): cv.declare_id(SelecMeter)}).extend( + cv.polling_component_schema("10s"), modbus.modbus_device_schema(0x01) +) + +# EM2M keeps this component's original word-swapped decode; EM4M uses the meter's factory +# default (MSRF / Big Endian), confirmed against real hardware. +CONFIG_SCHEMA = cv.typed_schema( + { + MODEL_EM2M: _BASE_SCHEMA.extend( + { + cv.Optional( + CONF_BYTE_ORDER, default=BYTE_ORDER_LITTLE + ): validate_byte_order, + **_optional(COMMON_SENSORS), + **_optional(EM2M_SENSORS), + } + ), + MODEL_EM4M: _BASE_SCHEMA.extend( + { + cv.Optional( + CONF_BYTE_ORDER, default=BYTE_ORDER_BIG + ): validate_byte_order, + **_optional(COMMON_SENSORS), + **_optional(EM4M_SENSORS), + **{cv.Optional(phase): PHASE_SCHEMA for phase in PHASES}, + cv.Optional(CONF_SERIAL_NUMBER): text_sensor.text_sensor_schema(), + cv.Optional(CONF_DG_SENSING): binary_sensor.binary_sensor_schema(), + } + ), + }, + key=CONF_MODEL, + default_type=MODEL_EM2M, + enum=MODELS, + lower=True, ) @@ -175,7 +340,58 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) - for name in SENSORS: - if name in config: - sens = await sensor.new_sensor(config[name]) - cg.add(getattr(var, f"set_{name}_sensor")(sens)) + cg.add(var.set_model(config[CONF_MODEL])) + cg.add(var.set_word_swap(config[CONF_BYTE_ORDER])) + if config[CONF_MODEL] == MODEL_EM4M: + cg.add_define("USE_SELEC_METER_EM4M") + sensors = sensor.sub_sensors(config) + await sensors(CONF_TOTAL_ACTIVE_ENERGY, var.set_total_active_energy_sensor) + await sensors(CONF_IMPORT_ACTIVE_ENERGY, var.set_import_active_energy_sensor) + await sensors(CONF_EXPORT_ACTIVE_ENERGY, var.set_export_active_energy_sensor) + await sensors(CONF_TOTAL_REACTIVE_ENERGY, var.set_total_reactive_energy_sensor) + await sensors(CONF_IMPORT_REACTIVE_ENERGY, var.set_import_reactive_energy_sensor) + await sensors(CONF_EXPORT_REACTIVE_ENERGY, var.set_export_reactive_energy_sensor) + await sensors(CONF_APPARENT_ENERGY, var.set_apparent_energy_sensor) + await sensors(CONF_ACTIVE_POWER, var.set_active_power_sensor) + await sensors(CONF_REACTIVE_POWER, var.set_reactive_power_sensor) + await sensors(CONF_APPARENT_POWER, var.set_apparent_power_sensor) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_POWER_FACTOR, var.set_power_factor_sensor) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) + await sensors( + CONF_MAXIMUM_DEMAND_ACTIVE_POWER, var.set_maximum_demand_active_power_sensor + ) + await sensors( + CONF_MAXIMUM_DEMAND_REACTIVE_POWER, var.set_maximum_demand_reactive_power_sensor + ) + await sensors( + CONF_MAXIMUM_DEMAND_APPARENT_POWER, var.set_maximum_demand_apparent_power_sensor + ) + await sensors(CONF_VOLTAGE_L12, var.set_voltage_l12_sensor) + await sensors(CONF_VOLTAGE_L23, var.set_voltage_l23_sensor) + await sensors(CONF_VOLTAGE_L31, var.set_voltage_l31_sensor) + await sensors(CONF_AVERAGE_VOLTAGE_LL, var.set_average_voltage_ll_sensor) + await sensors(CONF_NET_ACTIVE_ENERGY_MAINS, var.set_net_active_energy_mains_sensor) + await sensors( + CONF_NET_REACTIVE_ENERGY_MAINS, var.set_net_reactive_energy_mains_sensor + ) + await sensors( + CONF_NET_APPARENT_ENERGY_MAINS, var.set_net_apparent_energy_mains_sensor + ) + await sensors(CONF_NET_ACTIVE_ENERGY_DG, var.set_net_active_energy_dg_sensor) + await sensors(CONF_NET_REACTIVE_ENERGY_DG, var.set_net_reactive_energy_dg_sensor) + await sensors(CONF_NET_APPARENT_ENERGY_DG, var.set_net_apparent_energy_dg_sensor) + for phase_index, phase in enumerate(PHASES): + if (phase_config := config.get(phase)) is None: + continue + for name in PHASE_SENSORS: + if name in phase_config: + sens = await sensor.new_sensor(phase_config[name]) + cg.add(getattr(var, f"set_{name}_sensor")(phase_index, sens)) + await text_sensor.sub_text_sensors(config)( + CONF_SERIAL_NUMBER, var.set_serial_number_sensor + ) + await binary_sensor.sub_binary_sensors(config)( + CONF_DG_SENSING, var.set_dg_sensing_sensor + ) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index fc6dd82ed98..a7d2084a540 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -251,6 +251,7 @@ #define USE_SAFE_MODE_CALLBACK #define ESPHOME_SAFE_MODE_CALLBACK_COUNT 1 #define USE_SELECT +#define USE_SELEC_METER_EM4M #define USE_SENSOR #define USE_SENSOR_FILTER #define USE_SERIAL_PROXY diff --git a/tests/components/selec_meter/common-em4m.yaml b/tests/components/selec_meter/common-em4m.yaml new file mode 100644 index 00000000000..793e3c30a6c --- /dev/null +++ b/tests/components/selec_meter/common-em4m.yaml @@ -0,0 +1,129 @@ +sensor: + - platform: selec_meter + modbus_id: modbus_bus + model: em4m + byte_order: little_endian + phase_a: + voltage: + name: SelecEM4M Voltage L1 + current: + name: SelecEM4M Current L1 + active_power: + name: SelecEM4M Active Power L1 + reactive_power: + name: SelecEM4M Reactive Power L1 + apparent_power: + name: SelecEM4M Apparent Power L1 + power_factor: + name: SelecEM4M Power Factor L1 + import_active_energy: + name: SelecEM4M Import Active Energy L1 + export_active_energy: + name: SelecEM4M Export Active Energy L1 + import_reactive_energy: + name: SelecEM4M Import Reactive Energy L1 + export_reactive_energy: + name: SelecEM4M Export Reactive Energy L1 + apparent_energy: + name: SelecEM4M Apparent Energy L1 + phase_b: + voltage: + name: SelecEM4M Voltage L2 + current: + name: SelecEM4M Current L2 + active_power: + name: SelecEM4M Active Power L2 + reactive_power: + name: SelecEM4M Reactive Power L2 + apparent_power: + name: SelecEM4M Apparent Power L2 + power_factor: + name: SelecEM4M Power Factor L2 + import_active_energy: + name: SelecEM4M Import Active Energy L2 + export_active_energy: + name: SelecEM4M Export Active Energy L2 + import_reactive_energy: + name: SelecEM4M Import Reactive Energy L2 + export_reactive_energy: + name: SelecEM4M Export Reactive Energy L2 + apparent_energy: + name: SelecEM4M Apparent Energy L2 + phase_c: + voltage: + name: SelecEM4M Voltage L3 + current: + name: SelecEM4M Current L3 + active_power: + name: SelecEM4M Active Power L3 + reactive_power: + name: SelecEM4M Reactive Power L3 + apparent_power: + name: SelecEM4M Apparent Power L3 + power_factor: + name: SelecEM4M Power Factor L3 + import_active_energy: + name: SelecEM4M Import Active Energy L3 + export_active_energy: + name: SelecEM4M Export Active Energy L3 + import_reactive_energy: + name: SelecEM4M Import Reactive Energy L3 + export_reactive_energy: + name: SelecEM4M Export Reactive Energy L3 + apparent_energy: + name: SelecEM4M Apparent Energy L3 + voltage_l12: + name: SelecEM4M Voltage L12 + voltage_l23: + name: SelecEM4M Voltage L23 + voltage_l31: + name: SelecEM4M Voltage L31 + average_voltage_ll: + name: SelecEM4M Average Voltage LL + active_power: + name: SelecEM4M Active Power + reactive_power: + name: SelecEM4M Reactive Power + apparent_power: + name: SelecEM4M Apparent Power + voltage: + name: SelecEM4M Average Voltage + current: + name: SelecEM4M Average Current + power_factor: + name: SelecEM4M Average Power Factor + frequency: + name: SelecEM4M Frequency + import_active_energy: + name: SelecEM4M Total Import Active Energy + export_active_energy: + name: SelecEM4M Total Export Active Energy + import_reactive_energy: + name: SelecEM4M Total Import Reactive Energy + export_reactive_energy: + name: SelecEM4M Total Export Reactive Energy + net_active_energy_mains: + name: SelecEM4M Net Active Energy Mains + net_reactive_energy_mains: + name: SelecEM4M Net Reactive Energy Mains + net_apparent_energy_mains: + name: SelecEM4M Net Apparent Energy Mains + net_active_energy_dg: + name: SelecEM4M Net Active Energy DG + net_reactive_energy_dg: + name: SelecEM4M Net Reactive Energy DG + net_apparent_energy_dg: + name: SelecEM4M Net Apparent Energy DG + maximum_demand_active_power: + name: SelecEM4M Maximum Demand Active Power + disabled_by_default: true + maximum_demand_reactive_power: + name: SelecEM4M Maximum Demand Reactive Power + disabled_by_default: true + maximum_demand_apparent_power: + name: SelecEM4M Maximum Demand Apparent Power + disabled_by_default: true + serial_number: + name: SelecEM4M Serial Number + dg_sensing: + name: SelecEM4M DG Sensing diff --git a/tests/components/selec_meter/test-em4m.esp32-idf.yaml b/tests/components/selec_meter/test-em4m.esp32-idf.yaml new file mode 100644 index 00000000000..56c7c38bf93 --- /dev/null +++ b/tests/components/selec_meter/test-em4m.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + flow_control_pin: GPIO26 + +packages: + modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml + selec_meter: !include common-em4m.yaml diff --git a/tests/components/selec_meter/test-em4m.esp8266-ard.yaml b/tests/components/selec_meter/test-em4m.esp8266-ard.yaml new file mode 100644 index 00000000000..7cdabad7d66 --- /dev/null +++ b/tests/components/selec_meter/test-em4m.esp8266-ard.yaml @@ -0,0 +1,8 @@ +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO2 + flow_control_pin: GPIO15 + +packages: + modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml + selec_meter: !include common-em4m.yaml diff --git a/tests/components/selec_meter/test.esp32-idf.yaml b/tests/components/selec_meter/test.esp32-idf.yaml index 4df9f5863e1..3c588d0afcd 100644 --- a/tests/components/selec_meter/test.esp32-idf.yaml +++ b/tests/components/selec_meter/test.esp32-idf.yaml @@ -4,6 +4,5 @@ substitutions: flow_control_pin: GPIO26 packages: + selec_meter: !include common.yaml modbus: !include ../../test_build_components/common/modbus/esp32-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/selec_meter/test.esp8266-ard.yaml b/tests/components/selec_meter/test.esp8266-ard.yaml index 6daa08c22bd..6f4125b47b4 100644 --- a/tests/components/selec_meter/test.esp8266-ard.yaml +++ b/tests/components/selec_meter/test.esp8266-ard.yaml @@ -4,6 +4,5 @@ substitutions: flow_control_pin: GPIO15 packages: + selec_meter: !include common.yaml modbus: !include ../../test_build_components/common/modbus/esp8266-ard.yaml - -<<: !include common.yaml diff --git a/tests/components/selec_meter/test.rp2040-ard.yaml b/tests/components/selec_meter/test.rp2040-ard.yaml index a65500ccc38..687622481ea 100644 --- a/tests/components/selec_meter/test.rp2040-ard.yaml +++ b/tests/components/selec_meter/test.rp2040-ard.yaml @@ -4,6 +4,5 @@ substitutions: flow_control_pin: GPIO6 packages: + selec_meter: !include common.yaml modbus: !include ../../test_build_components/common/modbus/rp2040-ard.yaml - -<<: !include common.yaml diff --git a/tests/unit_tests/components/selec_meter/__init__.py b/tests/unit_tests/components/selec_meter/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit_tests/components/selec_meter/test_sensor.py b/tests/unit_tests/components/selec_meter/test_sensor.py new file mode 100644 index 00000000000..b4b14cd7e1f --- /dev/null +++ b/tests/unit_tests/components/selec_meter/test_sensor.py @@ -0,0 +1,103 @@ +"""Tests for the selec_meter configuration schema.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from esphome.components.selec_meter import sensor as selec_meter +import esphome.config_validation as cv +from esphome.types import ConfigType + + +def _validate(config: dict[str, Any]) -> ConfigType: + return selec_meter.CONFIG_SCHEMA(config) + + +def test_model_defaults_to_em2m() -> None: + config = _validate({"voltage": {"name": "Voltage"}}) + assert config["model"] == selec_meter.MODEL_EM2M + + +def test_model_is_case_insensitive() -> None: + config = _validate({"model": "EM4M"}) + assert config["model"] == selec_meter.MODEL_EM4M + + +def test_model_rejects_unknown_value() -> None: + with pytest.raises(cv.Invalid, match="Unknown value 'em9m'"): + _validate({"model": "em9m"}) + + +@pytest.mark.parametrize( + ("model", "key", "value"), + [ + ("em2m", "voltage_l12", {"name": "Test"}), + ("em2m", "serial_number", {"name": "Test"}), + ("em2m", "dg_sensing", {"name": "Test"}), + ("em2m", "phase_a", {"voltage": {"name": "Voltage"}}), + ("em2m", "phase_b", {"voltage": {"name": "Voltage"}}), + ("em2m", "phase_c", {"voltage": {"name": "Voltage"}}), + ("em4m", "total_active_energy", {"name": "Test"}), + ("em4m", "total_reactive_energy", {"name": "Test"}), + ("em4m", "apparent_energy", {"name": "Test"}), + ], +) +def test_key_rejected_on_wrong_model(model: str, key: str, value: Any) -> None: + with pytest.raises(cv.Invalid, match="extra keys not allowed") as err: + _validate({"model": model, key: value}) + assert err.value.path == [key] + + +def test_em4m_only_key_rejected_on_default_model() -> None: + with pytest.raises(cv.Invalid, match="extra keys not allowed"): + _validate({"voltage_l12": {"name": "Test"}}) + + +def test_em4m_with_phases_accepted() -> None: + config = _validate( + { + "model": "em4m", + "phase_a": {"voltage": {"name": "Voltage L1"}}, + "phase_c": {"power_factor": {"name": "PF L3"}}, + } + ) + assert "voltage" in config["phase_a"] + assert "power_factor" in config["phase_c"] + + +@pytest.mark.parametrize( + ("model", "byte_order", "word_swap"), + [ + ("em2m", "little_endian", True), + ("em4m", "big_endian", False), + ], +) +def test_byte_order_default_per_model( + model: str, byte_order: str, word_swap: bool +) -> None: + config = _validate({"model": model}) + assert config["byte_order"] == byte_order + assert config["byte_order"].enum_value is word_swap + + +@pytest.mark.parametrize( + ("model", "byte_order", "word_swap"), + [ + ("em2m", "big_endian", False), + ("em4m", "LITTLE_ENDIAN", True), + ("em4m", "lsrf", True), + ("em2m", "msrf", False), + ], +) +def test_byte_order_explicit_override( + model: str, byte_order: str, word_swap: bool +) -> None: + config = _validate({"model": model, "byte_order": byte_order}) + assert config["byte_order"].enum_value is word_swap + + +def test_byte_order_rejects_unknown_value() -> None: + with pytest.raises(cv.Invalid, match="Unknown value"): + _validate({"byte_order": "middle_endian"}) From e289a944516584213c1adf8a7b3e5eb92a1181ce Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Mon, 12 Oct 2026 23:11:21 -0400 Subject: [PATCH 002/142] [friedrich] New Climate IR component (#9178) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/friedrich/__init__.py | 1 + esphome/components/friedrich/climate.py | 32 +++ esphome/components/friedrich/friedrich.cpp | 234 ++++++++++++++++++ esphome/components/friedrich/friedrich.h | 47 ++++ tests/components/friedrich/__init__.py | 15 ++ tests/components/friedrich/common.yaml | 5 + tests/components/friedrich/friedrich_test.cpp | 215 ++++++++++++++++ .../components/friedrich/test.esp32-idf.yaml | 4 + .../friedrich/test.esp8266-ard.yaml | 4 + 10 files changed, 558 insertions(+) create mode 100644 esphome/components/friedrich/__init__.py create mode 100644 esphome/components/friedrich/climate.py create mode 100644 esphome/components/friedrich/friedrich.cpp create mode 100644 esphome/components/friedrich/friedrich.h create mode 100644 tests/components/friedrich/__init__.py create mode 100644 tests/components/friedrich/common.yaml create mode 100644 tests/components/friedrich/friedrich_test.cpp create mode 100644 tests/components/friedrich/test.esp32-idf.yaml create mode 100644 tests/components/friedrich/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index f068a3a2215..fc14cb33d09 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -214,6 +214,7 @@ esphome/components/feedback/* @ianchi esphome/components/file/* @esphome/core esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund esphome/components/font/* @clydebarrow @esphome/core +esphome/components/friedrich/* @rwrozelle esphome/components/fs3000/* @kahrendt esphome/components/ft5x06/* @clydebarrow esphome/components/ft63x6/* @gpambrozio diff --git a/esphome/components/friedrich/__init__.py b/esphome/components/friedrich/__init__.py new file mode 100644 index 00000000000..09f1d1df4fe --- /dev/null +++ b/esphome/components/friedrich/__init__.py @@ -0,0 +1 @@ +DOMAIN = "friedrich" diff --git a/esphome/components/friedrich/climate.py b/esphome/components/friedrich/climate.py new file mode 100644 index 00000000000..52f1f35708d --- /dev/null +++ b/esphome/components/friedrich/climate.py @@ -0,0 +1,32 @@ +import esphome.codegen as cg +from esphome.components import climate_ir, remote_base +import esphome.config_validation as cv +from esphome.const import CONF_MODEL, CONF_SENSOR +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] +CODEOWNERS = ["@rwrozelle"] + +friedrich_ns = cg.esphome_ns.namespace("friedrich") +FriedrichClimate = friedrich_ns.class_("FriedrichClimate", climate_ir.ClimateIR) + +Model = friedrich_ns.enum("Model", is_class=True) +MODELS = { + "MW12Y3H": Model.MODEL_MW12Y3H, +} + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(FriedrichClimate).extend( + { + cv.Optional(CONF_MODEL, default="MW12Y3H"): cv.enum(MODELS, upper=True), + # The unit works in Fahrenheit, so a Celsius temperature sensor cannot feed it + cv.Optional(CONF_SENSOR): cv.invalid( + "'sensor' is not supported by the Friedrich climate component" + ), + } +) + + +async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("aeha") # used from C++ + var = await climate_ir.new_climate_ir(config) + cg.add(var.set_model(config[CONF_MODEL])) diff --git a/esphome/components/friedrich/friedrich.cpp b/esphome/components/friedrich/friedrich.cpp new file mode 100644 index 00000000000..b1fcfe06150 --- /dev/null +++ b/esphome/components/friedrich/friedrich.cpp @@ -0,0 +1,234 @@ +#include "friedrich.h" + +#include + +#include "esphome/components/remote_base/aeha_protocol.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::friedrich { +// clang-format off +/* +* MW12Y3H - Currently the only supported model. Model field is retained to allow future per-model code differentiation. +* AEHA +* Byte0 Fixed = 0x00 +* Byte1 Fixed = 0x08 +* Byte2 Fixed = 0x08 +* Byte3 Command: Move Louver = 0x36 Off = 0x40, else Fixed = 0x7F +* Byte4 Checksum when command, else Fixed = 0x90 +* Byte5 N/A when command, else Fixed = 0x0C +* Byte6 N/A when command, else Temp(F), add 0x80 for On +* Byte7 N/A when command, else Mode (Min Heat = 0xD0 and Coil Dry = 0x10 are not exposed) +* Byte8 N/A when command, else Fan, add 0x08 for Vertical Swing +* Byte9 N/A when command, else Fixed = 0x00 // not interested +* Byte10 N/A when command, else Fixed = 0x00 // not interested +* Byte11 N/A when command, else Fixed = 0x00 // not interested +* Byte12 N/A when command, else Economy (On = 0x00, Off = 0x04) +* Byte13 N/A when command, else CheckSum +*/ +// clang-format on +ESPHOME_LOG_TAG(TAG, "friedrich"); + +static constexpr uint8_t TEMP_MIN_NOT_HEAT = 64; +static constexpr uint8_t TEMP_DEFAULT = 72; +static constexpr uint8_t STATE_MESSAGE_LENGTH = FriedrichClimate::STATE_MESSAGE_LENGTH; +static constexpr uint8_t STATE_MESSAGE_LENGTH_UTIL = 5; +static constexpr uint16_t CARRIER_ADDRESS = 0x28C6; +static constexpr uint32_t CARRIER_FREQUENCY = 38000; + +static constexpr uint8_t BYTE3_POWER_OFF = 0x40; +static constexpr uint8_t BYTE6_POWER_ON = 0x80; +static constexpr uint8_t BYTE7_MODE_AUTO = 0x00; +static constexpr uint8_t BYTE8_FAN_AUTO = 0x00; +static constexpr uint8_t BYTE8_FAN_SWING = 0x08; + +// Every state frame: power on, economy off; bytes 6, 7, 8 and 13 are patched per state +static constexpr uint8_t STATE_TEMPLATE[STATE_MESSAGE_LENGTH] PROGMEM = { + 0x00, 0x08, 0x08, 0x7F, 0x90, 0x0C, BYTE6_POWER_ON, BYTE7_MODE_AUTO, BYTE8_FAN_AUTO, 0x00, 0x00, 0x00, 0x04, 0x00}; +// The off command, including its checksum (255 - byte 3) +static constexpr uint8_t OFF_FRAME[STATE_MESSAGE_LENGTH_UTIL] PROGMEM = {0x00, 0x08, 0x08, BYTE3_POWER_OFF, + 255 - BYTE3_POWER_OFF}; + +struct Code { + uint8_t value; // a climate enum, all of which are uint8_t + uint8_t code; +}; +static constexpr Code MODES[] PROGMEM = { + {climate::CLIMATE_MODE_HEAT_COOL, BYTE7_MODE_AUTO}, + {climate::CLIMATE_MODE_COOL, 0x80}, + {climate::CLIMATE_MODE_DRY, 0x40}, + {climate::CLIMATE_MODE_FAN_ONLY, 0xC0}, + {climate::CLIMATE_MODE_HEAT, 0x20}, +}; +static constexpr Code FANS[] PROGMEM = { + {climate::CLIMATE_FAN_AUTO, BYTE8_FAN_AUTO}, {climate::CLIMATE_FAN_HIGH, 0x80}, + {climate::CLIMATE_FAN_MEDIUM, 0x40}, {climate::CLIMATE_FAN_LOW, 0xC0}, + {climate::CLIMATE_FAN_QUIET, 0x20}, +}; + +// The entry whose value (or code) matches key. Entries are copied out of PROGMEM. +template static optional find_entry(const Code (&table)[N], uint8_t key, bool match_code) { + for (const Code &slot : table) { + Code entry; + progmem_memcpy(&entry, &slot, sizeof(entry)); + if ((match_code ? entry.code : entry.value) == key) + return entry; + } + return {}; +} + +// The temperature code is the 4-bit reversal of (F - TEMP_MIN) / TEMP_STEP; the mapping is its own inverse. +static uint8_t reverse_nibble(uint8_t value) { return reverse_bits(value) >> 4; } + +static bool matches_template(const uint8_t *data, uint8_t from, uint8_t to) { + for (uint8_t i = from; i <= to; i++) { + if (data[i] != progmem_read_byte(&STATE_TEMPLATE[i])) + return false; + } + return true; +} + +/* + * https://gist.github.com/GeorgeDewar/11171561 + * 1. Reverse (flip) bytes 6 - 13) + * 2. Sum those bytes + * 3. (208 - sum) % 256 + * 4. Reverse (flip) bytes of result + */ +static uint8_t checksum_state(const uint8_t *data) { + uint8_t sum = 0; + for (uint8_t i = 6; i < STATE_MESSAGE_LENGTH - 1; ++i) { + sum += reverse_bits(data[i]); + } + return reverse_bits(static_cast(208 - sum)); +} + +static const LogString *model_to_string(Model model) { + switch (model) { + case Model::MODEL_MW12Y3H: + default: + return LOG_STR("MW12Y3H"); + } +} + +climate::ClimateTraits FriedrichClimate::traits() { + auto traits = ClimateIR::traits(); + traits.set_temperature_unit(TemperatureUnit::FAHRENHEIT); + return traits; +} + +void FriedrichClimate::setup() { + ClimateIR::setup(); + // The base class defaults assume Celsius. This also discards a value saved in Celsius by an earlier version. + if (std::isnan(this->target_temperature) || this->target_temperature < TEMP_MIN) { + this->target_temperature = TEMP_DEFAULT; + } + this->target_temperature = std::min(this->target_temperature, TEMP_MAX); +} + +void FriedrichClimate::dump_config() { + // ClimateIR::dump_config() is not called: it prints the temperature range in Celsius + LOG_CLIMATE("", "Friedrich Climate", this); + ESP_LOGCONFIG(TAG, + " Temperature range: %u to %u °F\n" + " Model: %s", + TEMP_MIN, TEMP_MAX, LOG_STR_ARG(model_to_string(this->model_))); +} + +void FriedrichClimate::transmit_state() { + if (this->mode == climate::CLIMATE_MODE_OFF) { + this->transmit_off_(); + return; + } + + ESP_LOGV(TAG, "Transmit state"); + + // Snap to what the unit accepts (even values, 64°F minimum unless heating) and store it, + // so the state published after this call matches the frame sent. + const uint8_t effective_min = this->mode == climate::CLIMATE_MODE_HEAT ? TEMP_MIN : TEMP_MIN_NOT_HEAT; + uint8_t temperature = (uint8_t) roundf(clamp(this->target_temperature, effective_min, TEMP_MAX)); + temperature += temperature % 2; + this->target_temperature = temperature; + + const auto mode = find_entry(MODES, this->mode, false); + const auto fan = find_entry(FANS, this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO), false); + + uint8_t frame[STATE_MESSAGE_LENGTH]; + progmem_memcpy(frame, STATE_TEMPLATE, sizeof(frame)); + frame[6] |= reverse_nibble((temperature - TEMP_MIN) / TEMP_STEP); + frame[7] = mode.has_value() ? mode->code : BYTE7_MODE_AUTO; + frame[8] = fan.has_value() ? fan->code : BYTE8_FAN_AUTO; + if (this->swing_mode != climate::CLIMATE_SWING_OFF) + frame[8] |= BYTE8_FAN_SWING; + frame[STATE_MESSAGE_LENGTH - 1] = checksum_state(frame); + + this->transmit_(frame, sizeof(frame)); +} + +void FriedrichClimate::transmit_off_() { + ESP_LOGV(TAG, "Transmit off"); + + uint8_t frame[STATE_MESSAGE_LENGTH_UTIL]; + progmem_memcpy(frame, OFF_FRAME, sizeof(frame)); + this->transmit_(frame, sizeof(frame)); +} + +void FriedrichClimate::transmit_(const uint8_t *data, uint8_t len) { + ESP_LOGV(TAG, "Transmit message"); + + auto transmit = this->transmitter_->transmit(); + auto *dst = transmit.get_data(); + dst->set_carrier_frequency(CARRIER_FREQUENCY); + remote_base::AEHAProtocol().encode(dst, CARRIER_ADDRESS, data, len); + transmit.perform(); +} + +bool FriedrichClimate::on_receive(remote_base::RemoteReceiveData src) { + ESP_LOGV(TAG, "Received message"); + const auto odata = remote_base::AEHAProtocol().decode(src); + if (!odata.has_value()) + return false; + const auto &d = odata->data; + // Other vendors also use AEHA; only accept frames with our address and fixed header bytes. + if (odata->address != CARRIER_ADDRESS || d.size() < STATE_MESSAGE_LENGTH_UTIL || !matches_template(d.data(), 0, 2)) + return false; + + if (d.size() == STATE_MESSAGE_LENGTH_UTIL) { + // Not looking for other types of messages + if (d[3] != BYTE3_POWER_OFF || d[4] != 255 - d[3]) + return false; + ESP_LOGV(TAG, "Received off message"); + this->mode = climate::CLIMATE_MODE_OFF; + this->publish_state(); + return true; + } + + if (d.size() != STATE_MESSAGE_LENGTH || d[STATE_MESSAGE_LENGTH - 1] != checksum_state(d.data()) || + !matches_template(d.data(), 3, 5)) + return false; + + // Ignore frames with a mode this component does not expose (e.g. MIN_HEAT, COIL_DRY) + const auto mode = find_entry(MODES, d[7], true); + if (!mode.has_value()) { + ESP_LOGV(TAG, "Ignoring unknown mode 0x%02X", d[7]); + return false; + } + this->mode = static_cast(mode->value); + + // State frames always carry the power-on flag in bit 7 (power off is the separate 5-byte frame), so it is + // stripped rather than acted on + const uint8_t temp_code = d[6] & ~BYTE6_POWER_ON; + const uint8_t temp_index = reverse_nibble(temp_code); + if (temp_code <= 0x0F && temp_index <= (TEMP_MAX - TEMP_MIN) / TEMP_STEP) + this->target_temperature = TEMP_MIN + temp_index * TEMP_STEP; + + this->swing_mode = (d[8] & BYTE8_FAN_SWING) ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF; + const auto fan = find_entry(FANS, d[8] & ~BYTE8_FAN_SWING, true); + this->fan_mode = fan.has_value() ? static_cast(fan->value) : climate::CLIMATE_FAN_AUTO; + + this->publish_state(); + return true; +} + +} // namespace esphome::friedrich diff --git a/esphome/components/friedrich/friedrich.h b/esphome/components/friedrich/friedrich.h new file mode 100644 index 00000000000..3a0be00f00a --- /dev/null +++ b/esphome/components/friedrich/friedrich.h @@ -0,0 +1,47 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" + +namespace esphome::friedrich { + +// Extension point for other units: add new models here and branch on model_ where they differ. +// The default must stay MW12Y3H so existing configurations do not change. +enum class Model : uint8_t { + MODEL_MW12Y3H, // MW12Y3H built from a remote that only provided Fahrenheit +}; + +class FriedrichClimate : public climate_ir::ClimateIR { + public: + // The unit reports temperatures in Fahrenheit (see traits()); the unit only accepts even values. + static constexpr uint8_t TEMP_MIN = 60; // actually 64 for anything but heating + static constexpr uint8_t TEMP_MAX = 88; + static constexpr uint8_t TEMP_STEP = 2; + static constexpr uint8_t STATE_MESSAGE_LENGTH = 14; + + FriedrichClimate() + : ClimateIR(TEMP_MIN, TEMP_MAX, TEMP_STEP, true, true, + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_QUIET}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}) {} + + void set_model(Model model) { this->model_ = model; } + + protected: + void setup() override; + void dump_config() override; + climate::ClimateTraits traits() override; + /// Transmit via IR the state of this climate controller. + void transmit_state() override; + /// Transmit via IR power off command. + void transmit_off_(); + + /// Parse incoming message + bool on_receive(remote_base::RemoteReceiveData src) override; + + /// Transmit message as IR pulses + void transmit_(const uint8_t *data, uint8_t len); + + Model model_{Model::MODEL_MW12Y3H}; +}; + +} // namespace esphome::friedrich diff --git a/tests/components/friedrich/__init__.py b/tests/components/friedrich/__init__.py new file mode 100644 index 00000000000..3028881357b --- /dev/null +++ b/tests/components/friedrich/__init__.py @@ -0,0 +1,15 @@ +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + from esphome.components.remote_base import request_protocol + + # The harness walks the component manifest, not the climate platform, so declare its auto-load here + manifest.auto_load = ["climate_ir", "climate"] + + async def to_code_testing(config: ConfigType) -> None: + # The AEHA source is compiled only behind its define; climate.py requests it in the real build. + request_protocol("aeha") + + manifest.to_code = to_code_testing diff --git a/tests/components/friedrich/common.yaml b/tests/components/friedrich/common.yaml new file mode 100644 index 00000000000..e56be394c59 --- /dev/null +++ b/tests/components/friedrich/common.yaml @@ -0,0 +1,5 @@ +climate: + - platform: friedrich + name: Friedrich Climate + transmitter_id: xmitr + receiver_id: rcvr diff --git a/tests/components/friedrich/friedrich_test.cpp b/tests/components/friedrich/friedrich_test.cpp new file mode 100644 index 00000000000..610eca9c091 --- /dev/null +++ b/tests/components/friedrich/friedrich_test.cpp @@ -0,0 +1,215 @@ +#include +#include + +#include "esphome/components/friedrich/friedrich.h" +#include "esphome/components/remote_base/aeha_protocol.h" +#include "esphome/core/helpers.h" + +namespace esphome::friedrich::testing { + +using remote_base::AEHAData; +using remote_base::AEHAProtocol; + +// Records the last transmission instead of sending it +class CapturingTransmitter : public remote_base::RemoteTransmitterBase { + public: + CapturingTransmitter() : RemoteTransmitterBase(nullptr) {} + int sends{0}; + remote_base::RawTimings last; + + protected: + void send_internal(uint32_t, uint32_t) override { + this->sends++; + this->last = this->temp_.get_data(); + } +}; + +class TestFriedrich : public FriedrichClimate { + public: + using FriedrichClimate::on_receive; + using FriedrichClimate::setup; + using FriedrichClimate::traits; + using FriedrichClimate::transmit_state; +}; + +constexpr uint16_t ADDRESS = 0x28C6; +constexpr uint8_t STATE_MESSAGE_LENGTH = FriedrichClimate::STATE_MESSAGE_LENGTH; +// Byte 6 holds the temperature code (bit 7 is the power flag), byte 7 the mode, byte 8 the fan +constexpr uint8_t TEMP_72_CODE = 0x06; + +struct Fixture { + TestFriedrich sut; + CapturingTransmitter transmitter; + + Fixture() { this->sut.set_transmitter(&this->transmitter); } + + // The last transmission decoded, or an empty frame if it could not be decoded + AEHAData sent() { return AEHAProtocol().decode(rx_of(this->transmitter.last)).value_or(AEHAData{}); } + + bool receive(const AEHAData &frame) { + remote_base::RemoteTransmitData tx; + AEHAProtocol().encode(&tx, frame); + return this->sut.on_receive(rx_of(tx.get_data())); + } + + static remote_base::RemoteReceiveData rx_of(const remote_base::RawTimings &timings) { + return remote_base::RemoteReceiveData(timings, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); + } +}; + +// Independent implementation of the 14 byte frame checksum +static AEHAData state_frame(uint8_t temp_code, uint8_t mode, uint8_t fan = 0x00) { + AEHAData f{ADDRESS, {0x00, 0x08, 0x08, 0x7F, 0x90, 0x0C, (uint8_t) (temp_code | 0x80), mode, fan, 0, 0, 0, 0x04, 0}}; + uint8_t sum = 0; + for (int i = 6; i < 13; i++) + sum += reverse_bits(f.data[i]); + f.data[13] = reverse_bits(static_cast(208 - sum)); + return f; +} + +static AEHAData off_frame() { return AEHAData{ADDRESS, {0x00, 0x08, 0x08, 0x40, 0xBF}}; } + +TEST(FriedrichTests, TraitsReportFahrenheit) { + Fixture f; + auto traits = f.sut.traits(); + EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::FAHRENHEIT); + EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 60.0f); + EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 88.0f); + EXPECT_FLOAT_EQ(traits.get_visual_target_temperature_step(), 2.0f); +} + +TEST(FriedrichTests, OffIsAlwaysSentAsFiveByteFrame) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_OFF; + f.sut.transmit_state(); + ASSERT_EQ(f.transmitter.sends, 1); + EXPECT_EQ(f.sent(), off_frame()); +} + +TEST(FriedrichTests, StateFrameEncodesFahrenheitTarget) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + f.sut.target_temperature = 72; + f.sut.fan_mode = climate::CLIMATE_FAN_AUTO; + f.sut.swing_mode = climate::CLIMATE_SWING_OFF; + f.sut.transmit_state(); + EXPECT_EQ(f.sent(), state_frame(TEMP_72_CODE, 0x80)); +} + +TEST(FriedrichTests, OddTargetStepsUpToEven) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + f.sut.target_temperature = 71; + f.sut.transmit_state(); + auto data = f.sent(); + ASSERT_EQ(data.data.size(), STATE_MESSAGE_LENGTH); + EXPECT_EQ(data.data[6], TEMP_72_CODE | 0x80); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 72.0f); // stored value matches what was sent +} + +// Codes captured from the remote +TEST(FriedrichTests, EveryTemperatureEncodesToRemoteCode) { + static constexpr uint8_t CODES[] = {0x00, 0x08, 0x04, 0x0C, 0x02, 0x0A, 0x06, 0x0E, + 0x01, 0x09, 0x05, 0x0D, 0x03, 0x0B, 0x07}; + for (size_t i = 0; i < sizeof(CODES); i++) { + const auto fahrenheit = static_cast(60 + i * 2); + SCOPED_TRACE(fahrenheit); + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_HEAT; + f.sut.target_temperature = fahrenheit; + f.sut.transmit_state(); + EXPECT_EQ(f.sent(), state_frame(CODES[i], 0x20)); + + Fixture g; + EXPECT_TRUE(g.receive(state_frame(CODES[i], 0x20))); + EXPECT_FLOAT_EQ(g.sut.target_temperature, fahrenheit); + } +} + +TEST(FriedrichTests, NonHeatModesAreRaisedToSixtyFour) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + f.sut.target_temperature = 60; + f.sut.transmit_state(); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 64.0f); + auto data = f.sent(); + ASSERT_EQ(data.data.size(), STATE_MESSAGE_LENGTH); + EXPECT_EQ(data.data[6], 0x04 | 0x80); // 64 F + + f.sut.mode = climate::CLIMATE_MODE_HEAT; + f.sut.target_temperature = 60; + f.sut.transmit_state(); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 60.0f); +} + +TEST(FriedrichTests, ReceivesStateFrame) { + Fixture f; + EXPECT_TRUE(f.receive(state_frame(TEMP_72_CODE, 0x20, 0x80))); + EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_HEAT); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 72.0f); + EXPECT_EQ(f.sut.fan_mode, climate::CLIMATE_FAN_HIGH); +} + +TEST(FriedrichTests, ReceivesOffFrame) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + EXPECT_TRUE(f.receive(off_frame())); + EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_OFF); +} + +TEST(FriedrichTests, IgnoresUnknownModeWithoutChangingState) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + f.sut.target_temperature = 80; + EXPECT_FALSE(f.receive(state_frame(TEMP_72_CODE, 0xD0))); // MIN_HEAT is not exposed + EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 80.0f); +} + +TEST(FriedrichTests, IgnoresCorruptedFrames) { + struct Case { + const char *name; + AEHAData frame; + std::function corrupt; + }; + const Case cases[] = { + {"other address", off_frame(), [](AEHAData &f) { f.address = 0x1234; }}, + {"bad header byte", off_frame(), [](AEHAData &f) { f.data[1] = 0x09; }}, + {"bad off checksum", off_frame(), [](AEHAData &f) { f.data[4] ^= 0xFF; }}, + {"bad state checksum", state_frame(TEMP_72_CODE, 0x20), [](AEHAData &f) { f.data[13] ^= 0xFF; }}, + }; + for (const auto &c : cases) { + SCOPED_TRACE(c.name); + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_COOL; + AEHAData frame = c.frame; + c.corrupt(frame); + EXPECT_FALSE(f.receive(frame)); + EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_COOL); + } +} + +TEST(FriedrichTests, OwnTransmissionIsAccepted) { + Fixture f; + f.sut.mode = climate::CLIMATE_MODE_FAN_ONLY; + f.sut.target_temperature = 76; + f.sut.fan_mode = climate::CLIMATE_FAN_LOW; + f.sut.swing_mode = climate::CLIMATE_SWING_VERTICAL; + f.sut.transmit_state(); + + Fixture g; + EXPECT_TRUE(g.sut.on_receive(Fixture::rx_of(f.transmitter.last))); + EXPECT_EQ(g.sut.mode, climate::CLIMATE_MODE_FAN_ONLY); + EXPECT_FLOAT_EQ(g.sut.target_temperature, 76.0f); + EXPECT_EQ(g.sut.fan_mode, climate::CLIMATE_FAN_LOW); + EXPECT_EQ(g.sut.swing_mode, climate::CLIMATE_SWING_VERTICAL); +} + +TEST(FriedrichTests, SetupDefaultsTargetToSeventyTwoFahrenheit) { + Fixture f; + f.sut.setup(); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 72.0f); + EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_OFF); +} + +} // namespace esphome::friedrich::testing diff --git a/tests/components/friedrich/test.esp32-idf.yaml b/tests/components/friedrich/test.esp32-idf.yaml new file mode 100644 index 00000000000..bee19625978 --- /dev/null +++ b/tests/components/friedrich/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + friedrich: !include common.yaml diff --git a/tests/components/friedrich/test.esp8266-ard.yaml b/tests/components/friedrich/test.esp8266-ard.yaml new file mode 100644 index 00000000000..d58d8014944 --- /dev/null +++ b/tests/components/friedrich/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + friedrich: !include common.yaml From 7881dfc5d88bd028f61fce78b46f668db1dfcd1f Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:29:42 -0500 Subject: [PATCH 003/142] [deep_sleep] Reject wakeup_pin configs that fail after validation (#18616) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- esphome/components/deep_sleep/__init__.py | 67 +++++++++++-------- .../deep_sleep/test_deep_sleep.py | 63 +++++++++++++++++ 2 files changed, 101 insertions(+), 29 deletions(-) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 2b820c9be6c..5f340c60efe 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -143,6 +143,8 @@ def validate_wakeup_pin( processed_pins: list[ConfigType] = [{CONF_PIN: value}] else: processed_pins = list(value) + if not processed_pins: + raise cv.Invalid("'wakeup_pin' needs at least one entry") for i, pin_config in enumerate(processed_pins): # now validate each item @@ -154,29 +156,34 @@ def validate_wakeup_pin( def validate_config(config: ConfigType) -> ConfigType: - # right now only BK72XX supports the list format for wakeup pins - if CORE.is_bk72xx: - if CONF_WAKEUP_PIN_MODE in config: - wakeup_pins = config.get(CONF_WAKEUP_PIN, []) - if len(wakeup_pins) > 1: - raise cv.Invalid( - "You need to remove the global wakeup_pin_mode and define it per pin" - ) - if wakeup_pins: - if CONF_WAKEUP_PIN_MODE in wakeup_pins[0]: - raise cv.Invalid( - "Specify wakeup_pin_mode either at the top level under deep_sleep " - "or under the pin entry, not both" - ) - wakeup_pins[0][CONF_WAKEUP_PIN_MODE] = config.pop(CONF_WAKEUP_PIN_MODE) - elif ( - isinstance(config.get(CONF_WAKEUP_PIN), list) - and len(config[CONF_WAKEUP_PIN]) > 1 - ): + wakeup_pins = config.get(CONF_WAKEUP_PIN) + has_pin_mode = CONF_WAKEUP_PIN_MODE in config + if has_pin_mode and wakeup_pins is None: raise cv.Invalid( - "Your platform does not support providing multiple entries in wakeup_pin" + "'wakeup_pin_mode' requires 'wakeup_pin'", path=[CONF_WAKEUP_PIN_MODE] ) - + multiple_pins = wakeup_pins is not None and len(wakeup_pins) > 1 + # wakeup_pin is limited to ESP32 and BK72XX, and only BK72XX accepts a list + if not CORE.is_bk72xx: + if multiple_pins: + raise cv.Invalid( + "ESP32 does not support multiple entries for 'wakeup_pin'", + path=[CONF_WAKEUP_PIN], + ) + elif has_pin_mode: + if multiple_pins: + raise cv.Invalid( + "Remove the global 'wakeup_pin_mode' and define it per pin", + path=[CONF_WAKEUP_PIN_MODE], + ) + if CONF_WAKEUP_PIN_MODE in (pin := wakeup_pins[0]): + raise cv.Invalid( + "Specify 'wakeup_pin_mode' either at the top level under 'deep_sleep' " + "or under the pin entry, not both", + path=[CONF_WAKEUP_PIN, 0, CONF_WAKEUP_PIN_MODE], + ) + # Move the global mode into the single pin entry, which to_code() reads + pin[CONF_WAKEUP_PIN_MODE] = config.pop(CONF_WAKEUP_PIN_MODE) return config @@ -203,11 +210,11 @@ def _validate_sleep_duration(value: core.TimePeriod) -> core.TimePeriod: if CORE.is_bk72xx: max_duration = core.TimePeriod(hours=36) if value > max_duration: - raise cv.Invalid("sleep duration cannot be more than 36 hours on BK72XX") + raise cv.Invalid("'sleep_duration' cannot be more than 36 hours on BK72XX") elif CORE.using_zephyr: max_duration = core.TimePeriod(days=49) if value > max_duration: - raise cv.Invalid("sleep duration cannot be more than 49 days on Zephyr") + raise cv.Invalid("'sleep_duration' cannot be more than 49 days on Zephyr") return value @@ -290,7 +297,10 @@ CONFIG_SCHEMA = cv.All( cv.positive_time_period_milliseconds, _validate_sleep_duration, ), - cv.Optional(CONF_WAKEUP_PIN): validate_wakeup_pin, + cv.Optional(CONF_WAKEUP_PIN): cv.All( + cv.only_on([PLATFORM_ESP32, PLATFORM_BK72XX]), + validate_wakeup_pin, + ), cv.Optional(CONF_WAKEUP_PIN_MODE): cv.All( cv.only_on([PLATFORM_ESP32, PLATFORM_BK72XX]), cv.enum(WAKEUP_PIN_MODES, upper=True), @@ -347,11 +357,10 @@ async def to_code(config: ConfigType) -> None: if CONF_SLEEP_DURATION in config: cg.add(var.set_sleep_duration(config[CONF_SLEEP_DURATION])) - if CONF_WAKEUP_PIN in config: - pins_as_list = config.get(CONF_WAKEUP_PIN, []) + if (wakeup_pins := config.get(CONF_WAKEUP_PIN)) is not None: if CORE.is_bk72xx: - cg.add(var.init_wakeup_pins(len(pins_as_list))) - for item in pins_as_list: + cg.add(var.init_wakeup_pins(len(wakeup_pins))) + for item in wakeup_pins: cg.add( var.add_wakeup_pin( await cg.gpio_pin_expression(item[CONF_PIN]), @@ -361,7 +370,7 @@ async def to_code(config: ConfigType) -> None: ) ) else: - pin = await cg.gpio_pin_expression(pins_as_list[0][CONF_PIN]) + pin = await cg.gpio_pin_expression(wakeup_pins[0][CONF_PIN]) cg.add(var.set_wakeup_pin(pin)) if CONF_WAKEUP_PIN_MODE in config: cg.add(var.set_wakeup_pin_mode(config[CONF_WAKEUP_PIN_MODE])) diff --git a/tests/component_tests/deep_sleep/test_deep_sleep.py b/tests/component_tests/deep_sleep/test_deep_sleep.py index e68b1d17cc1..e05109f246a 100644 --- a/tests/component_tests/deep_sleep/test_deep_sleep.py +++ b/tests/component_tests/deep_sleep/test_deep_sleep.py @@ -123,3 +123,66 @@ def test_deep_sleep_bk72xx_top_level_wakeup_pin_mode_moved_onto_single_pin( assert ( result[CONF_WAKEUP_PIN][0][deep_sleep.CONF_WAKEUP_PIN_MODE] == "INVERT_WAKEUP" ) + + +def test_deep_sleep_empty_wakeup_pin_list_rejected() -> None: + """An empty wakeup_pin list is rejected at validation instead of crashing codegen.""" + with pytest.raises(cv.Invalid, match="at least one entry"): + deep_sleep.validate_wakeup_pin([]) + + +def test_deep_sleep_bk72xx_wakeup_pin_mode_without_wakeup_pin_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """On BK72xx, a top-level wakeup_pin_mode with no wakeup_pin is rejected at validation.""" + set_core_config(PlatformFramework.BK72XX_ARDUINO) + config = {deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE"} + with pytest.raises(cv.Invalid, match="requires 'wakeup_pin'"): + deep_sleep.validate_config(config) + + +def test_deep_sleep_esp32_wakeup_pin_mode_without_wakeup_pin_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """On ESP32 the same mode-only config is rejected too, instead of being silently dead.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = {deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE"} + with pytest.raises(cv.Invalid, match="requires 'wakeup_pin'"): + deep_sleep.validate_config(config) + + +@pytest.mark.parametrize( + "platform", [PlatformFramework.ESP8266_ARDUINO, PlatformFramework.NRF52_ZEPHYR] +) +def test_deep_sleep_wakeup_pin_rejected_on_unsupported_platform( + set_core_config: SetCoreConfigCallable, platform: PlatformFramework +) -> None: + """wakeup_pin is rejected where the C++ component has no wakeup pin support.""" + set_core_config(platform) + with pytest.raises(cv.Invalid, match="only available on"): + deep_sleep.CONFIG_SCHEMA({CONF_WAKEUP_PIN: "GPIO4"}) + + +def test_deep_sleep_esp32_multiple_wakeup_pins_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """ESP32 accepts only one wakeup_pin entry.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = {CONF_WAKEUP_PIN: [{"pin": "GPIO4"}, {"pin": "GPIO5"}]} + with pytest.raises(cv.Invalid, match="ESP32 does not support multiple") as exc: + deep_sleep.validate_config(config) + assert exc.value.path == [CONF_WAKEUP_PIN] + + +def test_deep_sleep_bk72xx_global_wakeup_pin_mode_with_multiple_pins_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """On BK72xx, a top-level wakeup_pin_mode cannot be applied to several pins.""" + set_core_config(PlatformFramework.BK72XX_ARDUINO) + config = { + CONF_WAKEUP_PIN: [{"pin": "GPIO12"}, {"pin": "GPIO13"}], + deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE", + } + with pytest.raises(cv.Invalid, match="define it per pin") as exc: + deep_sleep.validate_config(config) + assert exc.value.path == [deep_sleep.CONF_WAKEUP_PIN_MODE] From 25843c5242cfabe600f3e7262f869734cc5a3a43 Mon Sep 17 00:00:00 2001 From: guillempages Date: Tue, 13 Oct 2026 10:16:44 +0200 Subject: [PATCH 004/142] [image] Implement RGB111 format (#20114) Co-authored-by: J. Nick Koston --- esphome/components/image/__init__.py | 92 +++++++++++++++---- esphome/components/image/image.cpp | 35 +++++++ esphome/components/image/image.h | 2 + esphome/components/lvgl/__init__.py | 17 ++++ .../runtime_image/runtime_image.cpp | 16 ++++ tests/component_tests/image/test_init.py | 90 ++++++++++++++++++ tests/component_tests/lvgl/test_validation.py | 54 ++++++++++- tests/components/image/common.yaml | 16 ++++ tests/components/online_image/common.yaml | 11 +++ 9 files changed, 313 insertions(+), 20 deletions(-) diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index eaee31a1c7b..668a78f3cbf 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -145,13 +145,42 @@ def is_alpha_only(image: Image): return all(b.getextrema()[1] == 0 for b in image.split()[:-1]) -class ImageBinary(ImageEncoder): +class BitPackedEncoder(ImageEncoder): + """Packs pixels MSB first, with every row padded to a whole byte.""" + + def __init__( + self, + bpp: int, + width: int, + height: int, + transparency: str, + dither: Image.Dither, + invert_alpha: bool, + ): + self.bpp = bpp + super().__init__( + (width * bpp + 7) // 8, height, transparency, dither, invert_alpha + ) + self.bitno = 0 + + def add_bit(self, bit: bool) -> None: + if bit: + self.data[self.index] |= 0x80 >> self.bitno + self.bitno += 1 + if self.bitno == 8: + self.end_row() + + def end_row(self) -> None: + if self.bitno != 0: + self.bitno = 0 + self.index += 1 + + +class ImageBinary(BitPackedEncoder): allow_config = {CONF_OPAQUE, CONF_INVERT_ALPHA, CONF_CHROMA_KEY} def __init__(self, width, height, transparency, dither, invert_alpha): - self.width8 = (width + 7) // 8 - super().__init__(self.width8, height, transparency, dither, invert_alpha) - self.bitno = 0 + super().__init__(1, width, height, transparency, dither, invert_alpha) def convert(self, image, path): if is_alpha_only(image): @@ -159,22 +188,46 @@ class ImageBinary(ImageEncoder): return image.convert("1", dither=self.dither) def encode(self, pixel): - if self.invert_alpha: - pixel = not pixel - if pixel: - self.data[self.index] |= 0x80 >> (self.bitno % 8) - self.bitno += 1 - if self.bitno == 8: - self.bitno = 0 - self.index += 1 + self.add_bit(bool(pixel) != self.invert_alpha) - def end_row(self): - """ - Pad rows to a byte boundary - """ - if self.bitno != 0: - self.bitno = 0 - self.index += 1 + +# The eight colours one bit per channel can express, for dithering. +_RGB111_PALETTE = Image.new("P", (1, 1)) +_RGB111_PALETTE.putpalette([255 * int(x) for i in range(8) for x in f"{i:03b}"]) + + +class ImageRGB111(BitPackedEncoder): + allow_config = {CONF_OPAQUE, CONF_ALPHA_CHANNEL} + + def __init__( + self, + width: int, + height: int, + transparency: str, + dither: Image.Dither, + invert_alpha: bool, + ): + bpp = 4 if transparency == CONF_ALPHA_CHANNEL else 3 + super().__init__(bpp, width, height, transparency, dither, invert_alpha) + + def convert(self, image: Image, path: str): + image = image.convert("RGBA") + if self.dither != Image.Dither.FLOYDSTEINBERG: + return image + alpha = image.getchannel("A") + image = image.convert("RGB").quantize( + palette=_RGB111_PALETTE, dither=Image.Dither.FLOYDSTEINBERG + ) + image = image.convert("RGB") + image.putalpha(alpha) + return image + + def encode(self, pixel: tuple[int, int, int, int]): + r, g, b, a = pixel + if self.invert_alpha: + a ^= 0xFF + for channel in (r, g, b, a)[: self.bpp]: + self.add_bit(channel > 127) class ImageGrayscale(ImageEncoder): @@ -315,6 +368,7 @@ class ReplaceWith: IMAGE_TYPE = { "BINARY": ImageBinary, "GRAYSCALE": ImageGrayscale, + "RGB111": ImageRGB111, "RGB565": ImageRGB565, "RGB": ImageRGB, "TRANSPARENT_BINARY": ReplaceWith("'type: BINARY' and 'transparency: chroma_key'"), diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index bfe311be284..d27d110248c 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -3,6 +3,8 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" +ESPHOME_LOG_TAG(TAG, "image"); + namespace esphome::image { void Image::draw(int x, int y, display::Display *display, Color color_on, Color color_off) { @@ -36,6 +38,17 @@ void Image::draw(int x, int y, display::Display *display, Color color_on, Color } break; } + case IMAGE_TYPE_RGB111: { + for (int img_x = img_x0; img_x < w; img_x++) { + for (int img_y = img_y0; img_y < h; img_y++) { + auto color = this->get_rgb111_pixel_(img_x, img_y); + if (color.w >= 0x80) { + display->draw_pixel_at(x + img_x, y + img_y, color); + } + } + } + break; + } case IMAGE_TYPE_GRAYSCALE: for (int img_x = img_x0; img_x < w; img_x++) { for (int img_y = img_y0; img_y < h; img_y++) { @@ -91,6 +104,8 @@ Color Image::get_pixel(int x, int y, const Color color_on, const Color color_off if (this->get_binary_pixel_(x, y)) return color_on; return color_off; + case IMAGE_TYPE_RGB111: + return this->get_rgb111_pixel_(x, y); case IMAGE_TYPE_GRAYSCALE: return this->get_grayscale_pixel_(x, y); case IMAGE_TYPE_RGB565: @@ -120,6 +135,10 @@ lv_image_dsc_t *Image::get_lv_image_dsc() { this->dsc_.header.cf = LV_COLOR_FORMAT_A8; break; + case IMAGE_TYPE_RGB111: + // Rejected by the lvgl config validation + ESP_LOGE(TAG, "RGB111 images are not supported in LVGL."); + break; case IMAGE_TYPE_RGB: switch (this->transparency_) { case TRANSPARENCY_ALPHA_CHANNEL: @@ -152,6 +171,19 @@ bool Image::get_binary_pixel_(int x, int y) const { const uint32_t pos = x + y * width_8; return progmem_read_byte(this->data_start_ + (pos / 8u)) & (0x80 >> (pos % 8u)); } +Color Image::get_rgb111_pixel_(int x, int y) const { + const uint32_t bitpos = y * this->get_width_stride() * 8 + x * this->bpp_; + const uint8_t *byte = this->data_start_ + bitpos / 8; + const uint8_t shift = bitpos % 8; + // A pixel can straddle two bytes; read the second one only when it does. + uint16_t window = progmem_read_byte(byte) << 8; + if (shift + this->bpp_ > 8) + window |= progmem_read_byte(byte + 1); + const uint8_t bits = (window >> (16 - this->bpp_ - shift)) & ((1u << this->bpp_) - 1); + const uint8_t rgb = this->bpp_ == 4 ? bits >> 1 : bits; + const bool opaque = this->bpp_ == 3 || (bits & 1); + return Color(rgb & 4 ? 0xFF : 0x00, rgb & 2 ? 0xFF : 0x00, rgb & 1 ? 0xFF : 0x00, opaque ? 0xFF : 0x00); +} Color Image::get_rgb_pixel_(int x, int y) const { const uint32_t pos = (x + y * this->width_) * this->bpp_ / 8; Color color = Color(progmem_read_byte(this->data_start_ + pos + 2), progmem_read_byte(this->data_start_ + pos + 1), @@ -216,6 +248,9 @@ Image::Image(const uint8_t *data_start, int width, int height, ImageType type, T case IMAGE_TYPE_BINARY: this->bpp_ = 1; break; + case IMAGE_TYPE_RGB111: + this->bpp_ = this->transparency_ == TRANSPARENCY_ALPHA_CHANNEL ? 4 : 3; + break; case IMAGE_TYPE_GRAYSCALE: this->bpp_ = 8; break; diff --git a/esphome/components/image/image.h b/esphome/components/image/image.h index fd9e92c21d3..a94f8137f41 100644 --- a/esphome/components/image/image.h +++ b/esphome/components/image/image.h @@ -13,6 +13,7 @@ enum ImageType { IMAGE_TYPE_GRAYSCALE = 1, IMAGE_TYPE_RGB = 2, IMAGE_TYPE_RGB565 = 3, + IMAGE_TYPE_RGB111 = 4, // one bit per channel plus an optional alpha bit, for colour e-paper }; enum Transparency { @@ -44,6 +45,7 @@ class Image : public display::BaseImage { #endif protected: bool get_binary_pixel_(int x, int y) const; + Color get_rgb111_pixel_(int x, int y) const; Color get_rgb_pixel_(int x, int y) const; Color get_rgb565_pixel_(int x, int y) const; Color get_grayscale_pixel_(int x, int y) const; diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 9b2f41e0635..6e3087ba9f0 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -25,6 +25,7 @@ from esphome.components.image import ( ImageBinary, ImageGrayscale, ImageRGB, + ImageRGB111, ImageRGB565, get_image_metadata, ) @@ -43,6 +44,7 @@ from esphome.const import ( CONF_ROTATION, CONF_TIMEOUT, CONF_TRIGGER_ID, + CONF_TYPE, ) from esphome.core import CORE, ID, Lambda from esphome.cpp_generator import MockObj @@ -293,6 +295,21 @@ def final_validation(config_list): if len(config_list) != 1: multi_conf_validate(config_list) + for img in get_lv_images_used(): + path = global_config.get_path_for_id(img.id)[:-1] + # An image nested in another platform (sendspin's current_image) takes its type from the parent + while path: + image_conf = global_config.get_config_for_path(path) + if isinstance(image_conf, dict) and CONF_TYPE in image_conf: + break + path = path[:-1] + else: + continue + if IMAGE_TYPE.get(image_conf[CONF_TYPE]) is ImageRGB111: + raise cv.FinalExternalInvalid( + f"Image '{img}': LVGL does not support RGB111 images, use RGB565 or RGB instead", + path + [CONF_TYPE], + ) for w in get_focused_widgets(): path = global_config.get_path_for_id(w) widget_conf = global_config.get_config_for_path(path[:-1]) diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index 6c101945746..79d2627a11c 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -99,6 +99,22 @@ void RuntimeImage::draw_pixel(int x, int y, const Color &color) { } break; } + case image::IMAGE_TYPE_RGB111: { + uint8_t bits = (color.r >> 7) << 2 | (color.g >> 7) << 1 | (color.b >> 7); + if (this->bpp_ == 4) + bits = bits << 1 | (color.w >> 7); + const uint32_t stride = (this->buffer_width_ * this->bpp_ + 7u) / 8u; + const uint32_t bitpos = y * stride * 8 + x * this->bpp_; + uint8_t *byte = this->buffer_ + bitpos / 8; + // Place the pixel in a 16-bit window so it may straddle two bytes. + const uint8_t shift = 16 - this->bpp_ - bitpos % 8; + const uint16_t mask = ((1u << this->bpp_) - 1) << shift; + const uint16_t value = bits << shift; + byte[0] = (byte[0] & ~(mask >> 8)) | (value >> 8); + if (mask & 0xFF) + byte[1] = (byte[1] & ~(mask & 0xFF)) | (value & 0xFF); + break; + } case image::IMAGE_TYPE_GRAYSCALE: { uint32_t pos = this->get_position_(x, y); auto gray = static_cast(0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b); diff --git a/tests/component_tests/image/test_init.py b/tests/component_tests/image/test_init.py index fad8b7df093..4d62bb52a19 100644 --- a/tests/component_tests/image/test_init.py +++ b/tests/component_tests/image/test_init.py @@ -102,6 +102,16 @@ from esphome.core import CORE "File can't be opened as image", id="invalid_image_file", ), + pytest.param( + { + "id": "image_id", + "file": "image.png", + "type": "rgb111", + "transparency": "chroma_key", + }, + "Image format 'RGB111' cannot have transparency: chroma_key", + id="rgb111_with_chroma_key_transparency", + ), ], ) def test_file_platform_configuration_errors( @@ -963,3 +973,83 @@ async def test_rgb565_alpha_animation_layout_per_frame( assert all(a == 0x00 for a in frame1_alpha), ( f"Frame 1 alpha plane should be transparent, got {frame1_alpha}" ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ( + "mode", + "transparency", + "frame0_color", + "frame1_color", + "frame0_row", + "frame1_row", + ), + [ + pytest.param( + "RGB", + CONF_OPAQUE, + (255, 0, 0), + (0, 0, 255), + [0x92, 0x48], + [0x24, 0x92], + id="rgb111", + ), + pytest.param( + "RGBA", + CONF_ALPHA_CHANNEL, + (255, 0, 0, 255), + (0, 0, 255, 0), + [0x99, 0x99, 0x90], + [0x22, 0x22, 0x20], + id="rgb111_alpha", + ), + ], +) +async def test_rgb111_layout_per_frame( + tmp_path: Path, + mock_progmem_array: MagicMock, + mode: str, + transparency: str, + frame0_color: tuple[int, ...], + frame1_color: tuple[int, ...], + frame0_row: list[int], + frame1_row: list[int], +) -> None: + """RGB111 packs one bit per channel MSB first and pads every row to a byte, + so each frame is a self-contained block of row_stride * height bytes. + """ + # A width that is not a multiple of 8 leaves padding bits in every row. + width = 5 + height = 3 + frame0 = PILImage.new(mode, (width, height), frame0_color) + frame1 = PILImage.new(mode, (width, height), frame1_color) + apng_path = tmp_path / "anim.png" + frame0.save( + apng_path, + format="PNG", + save_all=True, + append_images=[frame1], + duration=100, + loop=0, + ) + + config = { + CONF_FILE: str(apng_path), + CONF_TYPE: "RGB111", + CONF_TRANSPARENCY: transparency, + CONF_DITHER: "NONE", + CONF_INVERT_ALPHA: False, + CONF_RAW_DATA_ID: "test_raw_data_id", + } + + _, _, _, _, _, frame_count = await write_image(config, all_frames=True) + assert frame_count == 2 + + _, raw_data = mock_progmem_array.call_args.args + data = [int(x) for x in raw_data] + + frame_size = len(frame0_row) * height + assert data == frame0_row * height + frame1_row * height, ( + f"Expected {frame_size} row-padded bytes per frame, got {data}" + ) diff --git a/tests/component_tests/lvgl/test_validation.py b/tests/component_tests/lvgl/test_validation.py index 9a767c0dae5..31cbccb9ff9 100644 --- a/tests/component_tests/lvgl/test_validation.py +++ b/tests/component_tests/lvgl/test_validation.py @@ -2,14 +2,23 @@ from __future__ import annotations +from contextlib import nullcontext + import pytest from esphome.components.const import BYTE_ORDER_BIG, BYTE_ORDER_LITTLE, CONF_BYTE_ORDER from esphome.components.display import add_metadata from esphome.components.lvgl import final_validation +from esphome.components.lvgl.defines import get_lv_images_used from esphome.config import Config from esphome.config_validation import Invalid -from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM +from esphome.const import ( + CONF_ID, + CONF_TYPE, + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, +) from esphome.core import CORE, ID from esphome.final_validate import full_config @@ -175,3 +184,46 @@ class TestDrawRoundingMerge: configs = [_make_lvgl_config(["my_disp"])] final_validation(configs) assert configs[0]["draw_rounding"] == 2 + + +class TestImageTypeValidation: + """Test the image types LVGL accepts.""" + + @pytest.mark.parametrize( + ("image_type", "expectation"), + [ + pytest.param( + "RGB111", pytest.raises(Invalid, match="does not support RGB111") + ), + pytest.param("RGB565", nullcontext()), + ], + ) + def test_image_type(self, image_type: str, expectation) -> None: + image_id = ID("my_image", True) + fc = full_config.get() + fc["image"] = [{CONF_ID: image_id, CONF_TYPE: image_type}] + fc.declare_ids.append((image_id, ["image", 0, CONF_ID])) + get_lv_images_used().add(image_id) + + with expectation: + final_validation([_make_lvgl_config(["my_disp"])]) + + @pytest.mark.parametrize( + ("image_type", "expectation"), + [ + pytest.param( + "RGB111", pytest.raises(Invalid, match="does not support RGB111") + ), + pytest.param("RGB565", nullcontext()), + ], + ) + def test_nested_image_takes_parent_type(self, image_type: str, expectation) -> None: + """A sendspin artwork image declares its ID below the slot that holds the type.""" + image_id = ID("album_art", True) + fc = full_config.get() + fc["image"] = [{CONF_TYPE: image_type, "current_image": {CONF_ID: image_id}}] + fc.declare_ids.append((image_id, ["image", 0, "current_image", CONF_ID])) + get_lv_images_used().add(image_id) + + with expectation: + final_validation([_make_lvgl_config(["my_disp"])]) diff --git a/tests/components/image/common.yaml b/tests/components/image/common.yaml index 5a8f9383198..6b4978fc548 100644 --- a/tests/components/image/common.yaml +++ b/tests/components/image/common.yaml @@ -10,6 +10,22 @@ image: type: BINARY transparency: chroma_key + - platform: file + id: rgb111_image + file: ../../pnglogo.png + type: RGB111 + - platform: file + id: rgb111_alpha_image + file: ../../pnglogo.png + type: RGB111 + transparency: alpha_channel + - platform: file + id: rgb111_dither_alpha_image + file: ../../pnglogo.png + type: RGB111 + dither: FloydSteinberg + transparency: alpha_channel + - platform: file id: rgba_image file: ../../pnglogo.png diff --git a/tests/components/online_image/common.yaml b/tests/components/online_image/common.yaml index df8f85596f8..2a20fa25373 100644 --- a/tests/components/online_image/common.yaml +++ b/tests/components/online_image/common.yaml @@ -37,6 +37,17 @@ image: format: PNG type: RGB transparency: chroma_key + - platform: online_image + id: online_rgb111_image + url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png + type: RGB111 + format: png + - platform: online_image + id: online_rgb111_transparent_image + url: http://www.libpng.org/pub/png/img_png/pnglogo-blk-tiny.png + type: RGB111 + transparency: alpha_channel + format: png - platform: online_image id: online_binary_bmp url: https://samples-files.com/samples/images/bmp/480-360-sample.bmp From 77c9fa2fa4557c332ea2aebd9097482a1a9fa2a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:43:02 +0200 Subject: [PATCH 005/142] [emontx] Read the emonTx firmware version (#20619) Co-authored-by: Claude Opus 5.5 --- esphome/components/emontx/emontx.cpp | 71 ++++++++++- esphome/components/emontx/emontx.h | 13 ++ .../components/emontx/text_sensor/__init__.py | 23 ++++ esphome/core/defines.h | 1 + tests/components/emontx/common.yaml | 6 + .../components/emontx/text_sensor/__init__.py | 11 ++ .../text_sensor/emontx_version_test.cpp | 116 ++++++++++++++++++ 7 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 esphome/components/emontx/text_sensor/__init__.py create mode 100644 tests/components/emontx/text_sensor/__init__.py create mode 100644 tests/components/emontx/text_sensor/emontx_version_test.cpp diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index 14dc89e56ae..9c8436c0070 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -1,19 +1,32 @@ #include "emontx.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "esphome/components/json/json_util.h" namespace esphome::emontx { ESPHOME_LOG_TAG(TAG, "emontx"); +#ifdef USE_EMONTX_VERSION +static constexpr uint32_t VERSION_REQUEST_ID = 0; +// The emonTx joins bytes received while a command is pending into that command, so 'v' waits +static constexpr uint32_t VERSION_REQUEST_DELAY_MS = 250; +// Line of the 'v' reply (and of the boot banner) carrying the release, e.g. " - Version: v1.1.1" +static const char VERSION_PREFIX[] PROGMEM = " - Version:"; +#endif + void EmonTx::setup() { this->buffer_pos_ = 0; - if (!this->lock_on_reboot_) - return; - // The line feed ends any boot noise first - ESP_LOGI(TAG, "Unlocking emonTx after boot"); - this->write_byte('\n'); - this->send_command("emonunlock"); + if (this->lock_on_reboot_) { + // The line feed ends any boot noise first + ESP_LOGI(TAG, "Unlocking emonTx after boot"); + this->write_byte('\n'); + this->send_command("emonunlock"); + } +#ifdef USE_EMONTX_VERSION + if (this->version_text_sensor_ != nullptr) + this->request_version_(); +#endif } void EmonTx::on_shutdown() { @@ -48,6 +61,11 @@ void EmonTx::loop() { uint8_t dropped; while (this->available() > 0 && this->read_byte(&dropped)) this->discarding_line_ = dropped != '\n'; +#ifdef USE_EMONTX_VERSION + // The emonTx may have been flashed, or a request dropped, during the pause + if (this->version_text_sensor_ != nullptr) + this->request_version_(); +#endif } // Read all available data to prevent UART buffer overflow @@ -77,6 +95,10 @@ void EmonTx::loop() { ESP_LOGV(TAG, "Line is JSON, parsing..."); this->parse_json_(this->buffer_.data(), len); } +#ifdef USE_EMONTX_VERSION + if (this->version_text_sensor_ != nullptr) + this->parse_version_line_(this->buffer_.data(), len); +#endif } } else if (received == '\r' || this->discarding_line_) { // Ignore CR, and the rest of a line that overflowed or was interrupted by a pause @@ -102,6 +124,16 @@ void EmonTx::parse_json_(const char *data, size_t len) { } } #endif +#ifdef USE_EMONTX_VERSION + auto msg = root["MSG"]; + if (this->version_text_sensor_ != nullptr && msg.is()) { + // MSG restarts at 1 when the emonTx reboots, as it does after a firmware update + uint32_t msg_number = msg; + if (msg_number <= this->last_msg_number_) + this->request_version_(); + this->last_msg_number_ = msg_number; + } +#endif this->json_callbacks_.call(root, StringRef(data, len)); return true; @@ -112,6 +144,30 @@ void EmonTx::parse_json_(const char *data, size_t len) { } } +#ifdef USE_EMONTX_VERSION +// The line feed ends any pending input; the reply is picked up in loop() by parse_version_line_() +void EmonTx::request_version_() { + if (this->is_paused()) + return; + this->write_byte('\n'); + this->set_timeout(VERSION_REQUEST_ID, VERSION_REQUEST_DELAY_MS, [this]() { this->send_command("v"); }); +} + +void EmonTx::parse_version_line_(const char *line, size_t len) { + constexpr size_t prefix_len = sizeof(VERSION_PREFIX) - 1; + if (len <= prefix_len || ESPHOME_strncmp_P(line, VERSION_PREFIX, prefix_len) != 0) + return; + const char *version = line + prefix_len; + const char *end = line + len; + while (version < end && *version == ' ') + version++; + if (version == end) + return; + ESP_LOGI(TAG, "emonTx firmware version: %.*s", static_cast(end - version), version); + this->version_text_sensor_->publish_state(version, end - version); +} +#endif + /** * @brief Logs the EmonTx component configuration details. */ @@ -127,6 +183,9 @@ void EmonTx::dump_config() { #else ESP_LOGCONFIG(TAG, " Sensor support: DISABLED"); #endif +#ifdef USE_EMONTX_VERSION + LOG_TEXT_SENSOR(" ", "Firmware version", this->version_text_sensor_); +#endif } /** diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index bcec7ce171d..8f6a2424c94 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -13,6 +13,9 @@ #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif +#ifdef USE_EMONTX_VERSION +#include "esphome/components/text_sensor/text_sensor.h" +#endif namespace esphome::emontx { @@ -27,6 +30,9 @@ static constexpr size_t MAX_LINE_LENGTH = 1024; * extracts tags and values, and publishes them to registered sensors. */ class EmonTx final : public Component, public uart::UARTDevice { +#ifdef USE_EMONTX_VERSION + SUB_TEXT_SENSOR(version) +#endif public: EmonTx() = default; @@ -60,6 +66,10 @@ class EmonTx final : public Component, public uart::UARTDevice { protected: void parse_json_(const char *data, size_t len); +#ifdef USE_EMONTX_VERSION + void request_version_(); + void parse_version_line_(const char *line, size_t len); +#endif #ifdef USE_SENSOR FixedVector> sensors_{}; @@ -71,6 +81,9 @@ class EmonTx final : public Component, public uart::UARTDevice { bool was_paused_{false}; bool lock_on_reboot_{false}; bool discarding_line_{false}; // Set after an overflow or a pause until the end of that line +#ifdef USE_EMONTX_VERSION + uint32_t last_msg_number_{0}; +#endif std::array buffer_{}; }; diff --git a/esphome/components/emontx/text_sensor/__init__.py b/esphome/components/emontx/text_sensor/__init__.py new file mode 100644 index 00000000000..9b3065f0f86 --- /dev/null +++ b/esphome/components/emontx/text_sensor/__init__.py @@ -0,0 +1,23 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP +from esphome.types import ConfigType + +from .. import CONF_EMONTX_ID, EmonTx + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_EMONTX_ID): cv.use_id(EmonTx), + cv.Optional(CONF_VERSION): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_CHIP + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_EMONTX_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + if await text_sensors(CONF_VERSION, hub.set_version_text_sensor) is not None: + cg.add_define("USE_EMONTX_VERSION") diff --git a/esphome/core/defines.h b/esphome/core/defines.h index a7d2084a540..d9b12fdddb1 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -68,6 +68,7 @@ #define USE_DEVICES #define USE_DISPLAY #define DISPLAY_PAGE_CHANGE_CALLBACK_COUNT 1 +#define USE_EMONTX_VERSION #define USE_ENTITY_DEVICE_CLASS #define USE_ENTITY_ICON #define USE_ENTITY_UNIT_OF_MEASUREMENT diff --git a/tests/components/emontx/common.yaml b/tests/components/emontx/common.yaml index e609a217e0e..4a5e347053a 100644 --- a/tests/components/emontx/common.yaml +++ b/tests/components/emontx/common.yaml @@ -24,3 +24,9 @@ sensor: tag_name: P1 emontx_id: test_emontx unit_of_measurement: W + +text_sensor: + - platform: emontx + emontx_id: test_emontx + version: + name: emonTx firmware version diff --git a/tests/components/emontx/text_sensor/__init__.py b/tests/components/emontx/text_sensor/__init__.py new file mode 100644 index 00000000000..60a67b5a0b6 --- /dev/null +++ b/tests/components/emontx/text_sensor/__init__.py @@ -0,0 +1,11 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The platform's own to_code needs a configured hub; only its define is wanted here. + async def to_code_testing(config: ConfigType) -> None: + cg.add_define("USE_EMONTX_VERSION") + + manifest.to_code = to_code_testing diff --git a/tests/components/emontx/text_sensor/emontx_version_test.cpp b/tests/components/emontx/text_sensor/emontx_version_test.cpp new file mode 100644 index 00000000000..fe905a0e958 --- /dev/null +++ b/tests/components/emontx/text_sensor/emontx_version_test.cpp @@ -0,0 +1,116 @@ +#include + +#include + +#include "esphome/components/emontx/emontx.h" +#include "esphome/components/text_sensor/text_sensor.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "../common.h" + +namespace esphome::emontx::testing { + +namespace { +class EmonTxVersionTest : public ::testing::Test { + protected: + void SetUp() override { + // The test main does not construct App as generated code does + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; + this->sut_.set_uart_parent(&this->uart_); + this->sut_.set_version_text_sensor(&this->version_); + } + + void TearDown() override { + App.scheduler.cancel_timeout(&this->sut_, uint32_t{0}); + App.scheduler.call(millis()); + } + + // Lets the delayed 'v' go out + static void run_timers() { + App.scheduler.call(millis()); + delay(300); // NOLINT - test only, the scheduler reads the real clock + App.scheduler.call(millis()); + } + + std::string tx_() const { return std::string(this->uart_.tx.begin(), this->uart_.tx.end()); } + + MockUARTComponent uart_; + EmonTx sut_; + text_sensor::TextSensor version_; +}; +} // namespace + +// 'v' must not reach the emonTx in the same burst as the line feed, or it joins the pending command +TEST_F(EmonTxVersionTest, SetupRequestsVersionAfterDelay) { + this->sut_.setup(); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + +TEST_F(EmonTxVersionTest, SetupUnlocksBeforeRequestingVersion) { + this->sut_.set_lock_on_reboot(true); + this->sut_.setup(); + EXPECT_EQ(this->tx_(), "\nemonunlock\n\n"); + + run_timers(); + EXPECT_EQ(this->tx_(), "\nemonunlock\n\nv\n"); +} + +TEST_F(EmonTxVersionTest, VersionLinePublished) { + this->uart_.push_rx("> Firmware:\r\n - Bootloader: UART (.bin)\r\n - Version: v1.1.1\r\n"); + this->sut_.loop(); + + ASSERT_TRUE(this->version_.has_state()); + EXPECT_EQ(this->version_.get_state(), "v1.1.1"); +} + +TEST_F(EmonTxVersionTest, OtherLinesNotPublished) { + this->uart_.push_rx(" - Build: 768be1b (Default)\r\n{\"P1\":100}\r\n - Version:\r\n - Version: \r\n"); + this->sut_.loop(); + + EXPECT_FALSE(this->version_.has_state()); +} + +// MSG restarts at 1 after a reboot, e.g. a firmware update +TEST_F(EmonTxVersionTest, MsgCounterResetRequestsVersion) { + this->uart_.push_rx("{\"MSG\":41}\n{\"MSG\":42}\n"); + this->sut_.loop(); + EXPECT_TRUE(this->uart_.tx.empty()); + + this->uart_.push_rx("{\"MSG\":1}\n"); + this->sut_.loop(); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + +// The emonTx may have been flashed during the pause, with MSG already past its old value +TEST_F(EmonTxVersionTest, ResumeRequestsVersion) { + this->sut_.set_paused(true); + this->sut_.loop(); + this->sut_.set_paused(false); + this->sut_.loop(); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + +TEST_F(EmonTxVersionTest, RequestDroppedByPauseSentAgainOnResume) { + this->sut_.setup(); + this->sut_.set_paused(true); + this->sut_.loop(); + run_timers(); + EXPECT_EQ(this->tx_(), "\n"); + + this->sut_.set_paused(false); + this->sut_.loop(); + run_timers(); + EXPECT_EQ(this->tx_(), "\n\nv\n"); +} + +} // namespace esphome::emontx::testing From 192ae31e454a022d858aed017dc646d7737cf5bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 02:19:10 -1000 Subject: [PATCH 006/142] [tests] Turn off the Hypothesis deadline for the MAC address tests (#20614) --- tests/unit_tests/test_core.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 94458109753..ff36f1da18e 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -4,7 +4,7 @@ import subprocess import sys from unittest.mock import patch -from hypothesis import given +from hypothesis import given, settings import pytest from esphome import const, core @@ -31,6 +31,7 @@ class TestHexInt: class TestMACAddress: + @settings(deadline=None) @given(value=mac_addr_strings()) def test_init__valid(self, value): core.MACAddress(*value.split(":")) @@ -40,6 +41,7 @@ class TestMACAddress: with pytest.raises(ValueError, match="MAC Address must consist of 6 items"): core.MACAddress(*value.split(":")) + @settings(deadline=None) @given(value=mac_addr_strings()) def test_str(self, value): target = core.MACAddress(*(int(v, 16) for v in value.split(":"))) From fd541bca2cfd7755f036f9f8b824a5408fec5ccd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 02:20:32 -1000 Subject: [PATCH 007/142] [core] Document constexpr constants, per-model schemas and isolated build tests in AGENTS.md (#20617) --- AGENTS.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 5031c4a457f..e00888b5f77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,6 +144,9 @@ file does, and it is the authority when they disagree. The most useful starting * **C++ Preprocessor Directives:** * **Avoid `#define` for constants:** Using `#define` for constants is discouraged and should be replaced with `const` variables or enums. + * **A constant with a compile-time initializer is `static constexpr`,** not `static const`, at namespace scope + and inside functions; `static const` is only for a value computed at run time. A function-local + `static constexpr` table is a static constant to clang-tidy, so it is `UPPER_SNAKE_CASE` too. * **Use `#define` only for:** - Conditional compilation (`#ifdef`, `#ifndef`) - Compile-time sizes calculated during Python code generation (e.g., configuring `std::array` or `StaticVector` dimensions via `cg.add_define()`) @@ -217,6 +220,9 @@ file does, and it is the authority when they disagree. The most useful starting listener base (a second vptr per entity). * **A global feature define** says the code is compiled in, not that this instance uses it: keep the per-instance check (`if (this->aux_device_ == ...)`) inside the `#ifdef`. + * **Model-specific code** sits behind a define the Python side emits only for configs that use that model + (`cg.add_define("USE_SELEC_METER_EM4M")`), so the other models' builds leave it out; the runtime model check + stays inside the `#ifdef`. * **Failures after `setup()`:** `mark_failed()` is for `setup()` only. A runtime read or write failure sets `status_set_warning()` and the next success clears it; writes skip work when `is_failed()`. * **Fixed platform facts compile to nothing:** gate on `#ifdef USE_ESP8266` and friends, not on a @@ -567,6 +573,9 @@ file does, and it is the authority when they disagree. The most useful starting An intentionally empty body (an unused `register_dumper` builder, for example) gets a one-line docstring, not `pass`. * **Configuration Validation:** + * **One schema per model:** when a component's options depend on a `model` key, use `cv.typed_schema({...}, + key=CONF_MODEL, default_type=...)` with each model's own keys and defaults, not one schema plus a validator + that rejects keys and fills defaults afterwards. * **Reuse existing validators:** Before writing a custom validator, check for an existing one in `config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components. For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`. * **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`. * **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`. @@ -609,6 +618,9 @@ file does, and it is the authority when they disagree. The most useful starting `on_tx` lambda with a static counter and `inject_to_rx_buffer` (see `uart_mock_ld2412_requery_after_giveup.yaml`). * **Keep build-test variants few:** one `test..yaml` per platform the code actually differs on. A variant per board for platform-identical code only adds CI time. + * **A test that needs a shared component in a particular state** (`esp32_ble` security settings, an option + that PSRAM rejects) cannot be grouped: the merged block wins and the whole batch fails. Add the component to + `ISOLATED_COMPONENTS` in `script/analyze_component_buses.py`, or move the option to a `test-` file. * **Component Tests:** YAML-based compilation tests are located in `tests/`. The structure is as follows: ``` tests/ From 29a668af0443ab80e29fa29753a56160bbd57db3 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:01:17 +0000 Subject: [PATCH 008/142] [esp32_camera] Fix compile warning (#20628) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/esp32_camera/esp32_camera.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32_camera/esp32_camera.cpp b/esphome/components/esp32_camera/esp32_camera.cpp index ef29fc9b25d..a36fc763a68 100644 --- a/esphome/components/esp32_camera/esp32_camera.cpp +++ b/esphome/components/esp32_camera/esp32_camera.cpp @@ -282,7 +282,7 @@ void ESP32Camera::loop() { } this->frame_count_++; if (now - this->last_log_time_ >= FRAME_LOG_INTERVAL_MS) { - ESP_LOGD(TAG, "Received %u images in last %us", this->frame_count_, FRAME_LOG_INTERVAL_MS / 1000); + ESP_LOGD(TAG, "Received %u images in last %" PRIu32 "s", this->frame_count_, FRAME_LOG_INTERVAL_MS / 1000); this->last_log_time_ = now; this->frame_count_ = 0; } From c1c1d2b2ae7d199b7579c99e93091f7d9fb8107f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 07:28:32 -1000 Subject: [PATCH 009/142] [core] Add probe_ota_key, a Noise handshake with the device that sends no image (#20601) --- esphome/__main__.py | 34 +- esphome/espota2.py | 346 +++++++++++--- tests/unit_tests/test_espota2.py | 4 +- tests/unit_tests/test_espota2_noise.py | 629 ++++++++++++++++++++++++- 4 files changed, 918 insertions(+), 95 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 2b094e67a14..82ef585e1a9 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -483,12 +483,9 @@ def has_ota() -> bool: def has_native_ota() -> bool: """Check if native API OTA upload is available (``platform: esphome``).""" - if CONF_OTA not in CORE.config: - return False - return any( - ota_item.get(CONF_PLATFORM) == CONF_ESPHOME - for ota_item in CORE.config[CONF_OTA] - ) + from esphome.espota2 import esphome_ota_items + + return bool(esphome_ota_items(CORE.config)) def has_web_server_ota() -> bool: @@ -1359,14 +1356,17 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str: return CONF_WEB_SERVER +def _esphome_ota_conf(config: ConfigType) -> ConfigType | None: + """The validated esphome OTA platform block, if configured.""" + from esphome.espota2 import esphome_ota_items + + return next(iter(esphome_ota_items(config)), None) + + def _upload_via_native_api( config: ConfigType, network_devices: list[str], args: ArgsProtocol ) -> tuple[int, str | None]: - ota_conf: ConfigType = {} - for ota_item in config.get(CONF_OTA, []): - if ota_item.get(CONF_PLATFORM) == CONF_ESPHOME: - ota_conf = ota_item - break + ota_conf = _esphome_ota_conf(config) or {} from esphome import espota2 from esphome.components.noise import static_encryption_key @@ -1457,10 +1457,8 @@ def _old_key_notice(config: ConfigType) -> None: `upload` may send a stale image that still carries the previous key.""" from esphome import espota2 - for item in config.get(CONF_OTA, []): - if item.get(CONF_PLATFORM) == CONF_ESPHOME and ( - item.get(CONF_ENCRYPTION) or {} - ).get(espota2.CONF_OLD_KEY): + for item in espota2.esphome_ota_items(config): + if (item.get(CONF_ENCRYPTION) or {}).get(espota2.CONF_OLD_KEY): _LOGGER.warning(espota2.OLD_KEY_REMOVE_NOTICE) @@ -1468,12 +1466,12 @@ def _upload_via_web_server( config: ConfigType, network_devices: list[str], binary: Path ) -> tuple[int, str | None]: from esphome import web_server_ota + from esphome.espota2 import esphome_ota_items from esphome.web_server_helpers import get_web_server_connection if any( - ota_item.get(CONF_PLATFORM) == CONF_ESPHOME - and ota_item.get(CONF_ENCRYPTION) is not None - for ota_item in config.get(CONF_OTA, []) + ota_item.get(CONF_ENCRYPTION) is not None + for ota_item in esphome_ota_items(config) ): _LOGGER.warning( "This config has OTA encryption, but the web_server OTA path sends " diff --git a/esphome/espota2.py b/esphome/espota2.py index d8a2ff9000d..14e163676d1 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Callable import contextlib +import enum import gzip import hashlib import io @@ -12,8 +13,10 @@ import socket import time from typing import Any +from esphome.const import CONF_ESPHOME, CONF_OTA, CONF_PLATFORM from esphome.core import EsphomeError from esphome.helpers import ProgressBar, resolve_ip_address +from esphome.types import ConfigType OTA_TYPE_UPDATE_APP = 0x00 OTA_TYPE_UPDATE_PARTITION_TABLE = 0x01 @@ -118,6 +121,7 @@ _AUTH_METHODS: dict[int, tuple[Callable[..., Any], int, str]] = { RESPONSE_REQUEST_SHA256_AUTH: (hashlib.sha256, 64, "SHA256"), RESPONSE_REQUEST_AUTH: (hashlib.md5, 32, "MD5"), } +_AUTH_ANSWERS = (*_AUTH_METHODS, RESPONSE_AUTH_OK) # Error response code -> human-readable message (without the "Error: " prefix; check_error() # prepends it uniformly). Looked up by check_error() to translate a single byte from the device @@ -224,10 +228,52 @@ class OTAKeyRejected(OTAError): """The Noise handshake failed on the key; another key may succeed.""" +class OTAEncryptionNotOffered(OTAError): + """A key is configured but the device cannot encrypt.""" + + +class ProbeResult(enum.Enum): + """What probe_ota_key found; only ACCEPTED means the device runs the key.""" + + ACCEPTED = "accepted" + REJECTED = "rejected" # the device runs another key + NOT_OFFERED = "not offered" # the device cannot encrypt + DEVICE_ERROR = "device error" # answered, but not about the key + UNREACHABLE = "unreachable" # no answer at all + + def __bool__(self) -> bool: + return self is ProbeResult.ACCEPTED + + +_PROBE_ANSWERS = ( + (OTAKeyRejected, ProbeResult.REJECTED), + (OTAEncryptionNotOffered, ProbeResult.NOT_OFFERED), +) + + +def esphome_ota_items(config: ConfigType) -> list[ConfigType]: + """The esphome ota entries; a raw ``ota:`` may be a mapping, not a list.""" + ota = config.get(CONF_OTA) or [] + items = [ota] if isinstance(ota, dict) else ota + return [ + item + for item in items + if isinstance(item, dict) and item.get(CONF_PLATFORM) == CONF_ESPHOME + ] + + # Uploader side options under `ota: encryption:`; the ota component imports # the names so the upload path never loads the component module CONF_ALLOW_PLAINTEXT_UPLOAD = "allow_plaintext_upload" CONF_OLD_KEY = "old_key" +ENCRYPTION_NOT_OFFERED_ADVICE = ( + "Refusing to send the image in plaintext. The running firmware predates " + "ESPHome 2026.9.0 or has no 'api: encryption: key'. With an api key, install " + "once without the 'ota: encryption:' block (that build offers encryption), " + f"then restore it; otherwise set '{CONF_ALLOW_PLAINTEXT_UPLOAD}: true' under " + "'ota: encryption:' for this one install, or flash by serial or the " + "web_server OTA platform." +) ALLOW_PLAINTEXT_UPLOAD_NOTICE = ( f"'{CONF_ALLOW_PLAINTEXT_UPLOAD}' is set; expected once, on the install that " "migrates a device which never encrypted. If this device encrypted before, " @@ -429,6 +475,22 @@ def send_check( raise OTANetworkError(f"sending {msg}: {err}") from err +def noise_handshake(psk: str, prologue: bytes) -> Any: + """A fresh NNpsk0 initiator; a bad key or a missing noise stack raises here.""" + # Lazy: the noise stack is only imported for an encrypted upload + try: + from aioesphomeapi.noise import NoiseHandshake + except ImportError as err: + raise OTAError( + "OTA encryption requires a newer aioesphomeapi; update your " + "esphome installation (pip install -U esphome) and retry" + ) from err + try: + return NoiseHandshake(psk, prologue) + except ValueError as err: + raise OTAError(f"Invalid OTA encryption key: {err}") from err + + class NoiseSocketWrapper: """Runs the OTA session inside a Noise (ChaCha20-Poly1305) transport. @@ -439,25 +501,12 @@ class NoiseSocketWrapper: """ def __init__(self, sock: socket.socket, psk: str, prologue: bytes) -> None: - # Deliberately lazy: the noise stack (noiseprotocol, cryptography) is - # only imported when an encrypted upload actually runs. - try: - from aioesphomeapi.noise import NoiseHandshake - except ImportError as err: - raise OTAError( - "OTA encryption requires a newer aioesphomeapi; update your " - "esphome installation (pip install -U esphome) and retry" - ) from err - # The aioesphomeapi import above already loaded cryptography; bind - # the exception once so recv() pays no per-frame import lookup + self._handshake = noise_handshake(psk, prologue) + # Bound once; recv() must not import per frame from cryptography.exceptions import InvalidTag self._invalid_tag = InvalidTag self._sock = sock - try: - self._handshake = NoiseHandshake(psk, prologue) - except ValueError as err: - raise OTAError(f"Invalid OTA encryption key: {err}") from err self._encrypt = None self._decrypt = None self._buffer = b"" @@ -564,43 +613,20 @@ class NoiseSocketWrapper: return data -def perform_ota( +def _negotiate_session( sock: socket.socket, - password: str | None, - file_handle: io.IOBase, - filename: Path, - ota_type: int = OTA_TYPE_UPDATE_APP, - noise_psk: str | None = None, + noise_psk: str | None, plaintext_fallback: bool = False, allow_plaintext_upload: bool = False, -) -> None: - # Validate up front; an out-of-range value would only surface as a - # ValueError deep inside send_check, bypassing OTAError handling - if not isinstance(ota_type, int) or not 0 <= ota_type <= 0xFF: - raise OTAError( - f"Invalid ota_type {ota_type!r}; expected an integer in range 0-255" - ) - if ota_type not in _SUPPORTED_OTA_TYPES: - supported = ", ".join(f"0x{t:02X}" for t in sorted(_SUPPORTED_OTA_TYPES)) - raise OTAError( - f"Unsupported OTA type 0x{ota_type:02X}; this ESPHome supports: {supported}" - ) - - if noise_psk is not None and not noise_psk: - raise OTAError( - "An empty OTA encryption key was provided; refusing to upload in plaintext" - ) - - file_contents = file_handle.read() - file_size = len(file_contents) - _LOGGER.info("Uploading %s (%s bytes)", filename, file_size) - +) -> tuple[socket.socket | NoiseSocketWrapper, int, int, bool]: + """Negotiate through the Noise handshake; returns the socket to go on with + (wrapped once encrypted), version, features, extended.""" # Enable nodelay, we need it for phase 1 sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) send_check(sock, MAGIC_BYTES, "magic bytes") _, version = receive_exactly(sock, 2, "version", RESPONSE_OK) - _LOGGER.info("Connection established; device supports OTA version %s", version) + _LOGGER.debug("Device supports OTA version %s", version) supported_versions = (OTA_VERSION_1_0, OTA_VERSION_2_0) if version not in supported_versions: raise OTAError( @@ -656,15 +682,9 @@ def perform_ota( # capture the image (wifi credentials, api key) # Remove before 2027.3.0: installing without the block no longer # falls back then; advise 'allow_plaintext_upload: true' instead - raise OTAError( + raise OTAEncryptionNotOffered( "An OTA encryption key is configured but the device did not " - "offer encryption; refusing to send the image in plaintext. " - "The running firmware predates ESPHome 2026.9.0 or has no " - "'api: encryption: key'. With an api key, install once " - "without the 'ota: encryption:' block (that build offers " - f"encryption), then restore it; otherwise set '{CONF_ALLOW_PLAINTEXT_UPLOAD}: " - "true' under 'ota: encryption:' for this one install, or flash by " - "serial or the web_server OTA platform." + "offer encryption" ) if noise_psk: # The prologue binds every negotiation byte both sides saw, so any @@ -682,9 +702,50 @@ def perform_ota( except OTANetworkError as err: # A transport fault: retry encrypted before considering plaintext raise OTAHandshakeNetworkError(str(err)) from err - _LOGGER.info("Encrypted connection established") + _LOGGER.debug("Encrypted connection established") if allow_plaintext_upload: _LOGGER.warning(ALLOW_PLAINTEXT_UPLOAD_REMOVE_WARNING) + return sock, version, features, extended_proto + + +def perform_ota( + sock: socket.socket, + password: str | None, + file_handle: io.IOBase, + filename: Path, + ota_type: int = OTA_TYPE_UPDATE_APP, + noise_psk: str | None = None, + plaintext_fallback: bool = False, + allow_plaintext_upload: bool = False, + on_upload_start: Callable[[], None] | None = None, +) -> None: + # Validate up front; an out-of-range value would only surface as a + # ValueError deep inside send_check, bypassing OTAError handling + if not isinstance(ota_type, int) or not 0 <= ota_type <= 0xFF: + raise OTAError( + f"Invalid ota_type {ota_type!r}; expected an integer in range 0-255" + ) + if ota_type not in _SUPPORTED_OTA_TYPES: + supported = ", ".join(f"0x{t:02X}" for t in sorted(_SUPPORTED_OTA_TYPES)) + raise OTAError( + f"Unsupported OTA type 0x{ota_type:02X}; this ESPHome supports: {supported}" + ) + + if noise_psk is not None and not noise_psk: + raise OTAError( + "An empty OTA encryption key was provided; refusing to upload in plaintext" + ) + + file_contents = file_handle.read() + file_size = len(file_contents) + _LOGGER.info("Uploading %s (%s bytes)", filename, file_size) + + sock, version, features, extended_proto = _negotiate_session( + sock, noise_psk, plaintext_fallback, allow_plaintext_upload + ) + _LOGGER.info("Connection established; device supports OTA version %s", version) + if isinstance(sock, NoiseSocketWrapper): + _LOGGER.info("Encrypted connection established") if ota_type != OTA_TYPE_UPDATE_APP: # Any non-app OTA type requires the extended protocol and the @@ -761,18 +822,15 @@ def perform_ota( send_check(sock, result, "auth result") receive_exactly(sock, 1, "auth result", RESPONSE_AUTH_OK) - (auth,) = receive_exactly( - sock, - 1, - "auth", - [RESPONSE_REQUEST_AUTH, RESPONSE_REQUEST_SHA256_AUTH, RESPONSE_AUTH_OK], - ) + (auth,) = receive_exactly(sock, 1, "auth", list(_AUTH_ANSWERS)) if auth != RESPONSE_AUTH_OK: hash_func, nonce_size, hash_name = _AUTH_METHODS[auth] perform_auth(sock, password, hash_func, nonce_size, hash_name) _LOGGER.info("Handshake complete") + if on_upload_start is not None: + on_upload_start() # the device may commit anything sent from here sock.settimeout(DATA_PHASE_TIMEOUT) @@ -890,6 +948,146 @@ def perform_ota( time.sleep(1) +PROBE_TIMEOUT = 120.0 +PROBE_RETRY_DELAY = 3.0 +PROBE_CONNECT_TIMEOUT = 3.0 +PROBE_HANDSHAKE_TIMEOUT = 10.0 + + +def _resolve_targets(remote_host: str | list[str], remote_port: int) -> list[Any]: + """Every address to try for the host, through the cache; OTAError when + there is none.""" + from esphome.core import CORE + + try: + res = resolve_ip_address( + remote_host, remote_port, address_cache=CORE.address_cache + ) + except EsphomeError as err: + raise OTAError(err) from err + if not res: + raise OTAError(f"No addresses to connect to for {remote_host}") + return res + + +class _DeadlineSocket: + """Re-arms the timeout before every operation so the whole negotiation, + not each read, ends by the deadline.""" + + def __init__(self, sock: socket.socket, deadline: float) -> None: + self._sock = sock + self._deadline = deadline + + def _arm(self) -> None: + remaining = self._deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("probe deadline reached") + self._sock.settimeout(min(PROBE_HANDSHAKE_TIMEOUT, remaining)) + + def recv(self, amount: int) -> bytes: + self._arm() + return self._sock.recv(amount) + + def sendall(self, data: bytes) -> None: + self._arm() + self._sock.sendall(data) + + def setsockopt(self, level: int, optname: int, value: int) -> None: + self._sock.setsockopt(level, optname, value) + + def close(self) -> None: + self._sock.close() + + +def probe_ota_key( + remote_host: str | list[str], + remote_port: int, + noise_psk: str, + *, + timeout: float = PROBE_TIMEOUT, + retry_rejected: bool = True, +) -> ProbeResult: + """Whether the device completes a Noise handshake with the key, no image + sent. Retries until the deadline, a rejection too with ``retry_rejected`` + (the old firmware may answer once more after an upload).""" + + noise_handshake(noise_psk, b"") # a local fault must not read as the device's no + deadline = time.monotonic() + timeout + + def pause(delay: float) -> None: + time.sleep(max(0.0, min(delay, deadline - time.monotonic()))) + + res: list[Any] = [] + answered: set[Any] = set() # addresses whose answer no retry changes + last_error = "no time left" + outcome = ProbeResult.UNREACHABLE # a device answer outranks a network error + while time.monotonic() < deadline: + if not res: + # A rebooting device may drop off mDNS for a while; keep asking. + # Addresses the caller pre-resolved (the address cache, or a + # literal IP) are its to keep; only a hostname is looked up again + try: + res = _resolve_targets(remote_host, remote_port) + except OTAError as err: + if outcome is ProbeResult.UNREACHABLE: + last_error = str(err) + _LOGGER.debug("Host not resolved yet (%s); retrying", err) + pause(PROBE_RETRY_DELAY) + continue + # One round: every address that has not answered, back to back + started = time.monotonic() + unreached = 0 + for af, socktype, _, _, sa in res: + if sa in answered: + continue + if (remaining := deadline - time.monotonic()) <= 0: + break + sock = socket.socket(af, socktype) + # A dead host must not eat the budget + sock.settimeout(min(PROBE_CONNECT_TIMEOUT, remaining)) + with contextlib.closing(sock): + try: + sock.connect(sa) + session = _negotiate_session( + _DeadlineSocket(sock, deadline), noise_psk + )[0] + # A password challenge also proves the key: it comes encrypted + receive_exactly(session, 1, "auth", list(_AUTH_ANSWERS)) + except (OSError, OTANetworkError) as err: + if outcome is ProbeResult.UNREACHABLE: + last_error = f"{sa[0]}: {err}" + unreached += 1 + except OTAError as err: + # Final in the precheck; a rebooting device may answer + # differently once it is back + found = next( + (r for t, r in _PROBE_ANSWERS if isinstance(err, t)), + ProbeResult.DEVICE_ERROR, + ) + # A stale address answering with some other error must + # not hide what the device said about the key + if found is not ProbeResult.DEVICE_ERROR or not any( + outcome is r for _, r in _PROBE_ANSWERS + ): + outcome, last_error = found, f"{sa[0]}: {err}" + if not retry_rejected: + answered.add(sa) + else: + _LOGGER.info("Device %s accepted the key", sa[0]) + return ProbeResult.ACCEPTED + _LOGGER.debug("Key not accepted yet (%s); retrying", last_error) + # Every address gets its own final say; a stale one cannot answer + # for the device + pending = sum(1 for *_, sa in res if sa not in answered) + if pending == 0 or time.monotonic() >= deadline: + break + if unreached == pending: + res = [] # no answer this round: the device may have a new address + pause(PROBE_RETRY_DELAY - (time.monotonic() - started)) + _LOGGER.warning("Checking the key failed, %s: %s", outcome.value, last_error) + return outcome + + def run_ota_impl_( remote_host: str | list[str], remote_port: int, @@ -900,16 +1098,13 @@ def run_ota_impl_( plaintext_fallback: bool = False, allow_plaintext_upload: bool = False, old_noise_psk: str | None = None, + on_upload_start: Callable[[], None] | None = None, ) -> tuple[int, str | None]: from esphome.core import CORE - # Handle both single host and list of hosts try: - # Resolve all hosts at once for parallel DNS resolution - res = resolve_ip_address( - remote_host, remote_port, address_cache=CORE.address_cache - ) - except EsphomeError as err: + res = _resolve_targets(remote_host, remote_port) + except OTAError: _LOGGER.error( "Error resolving IP address of %s. Is it connected to WiFi?", remote_host, @@ -920,11 +1115,7 @@ def run_ota_impl_( "(If this error persists, please set a static IP address: " "https://esphome.io/components/wifi/#manual-ips)" ) - raise OTAError(err) from err - - if not res: - _LOGGER.error("No addresses to connect to for %s", remote_host) - return 1, None + raise # Every address is tried at least once and EXTRA_UPLOAD_ATTEMPTS retries # are shared across the addresses, cycling through them. Wait before an @@ -941,6 +1132,13 @@ def run_ota_impl_( reconnect = False # the device closed on purpose; nothing to wait for attempt = 0 encryption = _EncryptionAttempt(noise_psk, plaintext_fallback, old_noise_psk) + + def upload_started() -> None: + nonlocal on_upload_start + if on_upload_start is not None: + on_upload_start() + on_upload_start = None # once per upload, whatever the retries do + while attempt < total_attempts: af, socktype, _, _, sa = res[attempt % len(res)] if not reconnect and (reached_device or attempt >= len(res)): @@ -978,6 +1176,7 @@ def run_ota_impl_( encryption.noise_psk, encryption.plaintext_fallback, allow_plaintext_upload=allow_plaintext_upload, + on_upload_start=upload_started, ) except OTAKeyRejected as err: # The device sent a reject and closed on its own, so reconnect @@ -1013,6 +1212,8 @@ def run_ota_impl_( # Device-reported error (wrong password, wrong flash size, ...); # retrying cannot succeed, so fail immediately _LOGGER.error(str(err)) + if isinstance(err, OTAEncryptionNotOffered): + _LOGGER.error(ENCRYPTION_NOT_OFFERED_ADVICE) return 1, None if encryption.tried_old_key: @@ -1038,7 +1239,11 @@ def run_ota( plaintext_fallback: bool = False, allow_plaintext_upload: bool = False, old_noise_psk: str | None = None, + on_upload_start: Callable[[], None] | None = None, ) -> tuple[int, str | None]: + """``on_upload_start`` runs once, when the device first accepted the + session and the image is about to go out: the point from which it may + commit, which a later retry or failure does not undo.""" try: return run_ota_impl_( remote_host, @@ -1050,6 +1255,7 @@ def run_ota( plaintext_fallback, allow_plaintext_upload, old_noise_psk, + on_upload_start, ) except OTAError as err: _LOGGER.error(err) diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index f1e04da8c87..e83335a175f 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -964,10 +964,10 @@ def test_run_ota_impl_device_error_not_retried( def test_run_ota_impl_no_addresses( firmware_file: Path, mock_resolve_ip: Mock, mock_sleep: Mock ) -> None: - """Test run_ota_impl_ fails cleanly when resolution yields no addresses.""" + """An empty resolution is reported once and fails cleanly.""" mock_resolve_ip.return_value = [] - result_code, result_host = espota2.run_ota_impl_( + result_code, result_host = espota2.run_ota( "test.local", 3232, "password", str(firmware_file) ) diff --git a/tests/unit_tests/test_espota2_noise.py b/tests/unit_tests/test_espota2_noise.py index ca50641309b..1adace67395 100644 --- a/tests/unit_tests/test_espota2_noise.py +++ b/tests/unit_tests/test_espota2_noise.py @@ -13,6 +13,7 @@ import base64 from collections.abc import Callable import hashlib import io +import itertools import logging from pathlib import Path import socket @@ -24,11 +25,16 @@ from unittest.mock import Mock, patch import pytest from esphome import espota2 +from esphome.core import EsphomeError +from esphome.espota2 import ProbeResult PSK = base64.b64encode(bytes(range(32))).decode() OTHER_PSK = base64.b64encode(bytes(range(1, 33))).decode() MAGIC = bytes(espota2.MAGIC_BYTES) +# One resolved loopback address, as resolve_ip_address returns it +RESOLVED = [(2, 1, 0, "", ("127.0.0.1", 1))] +TWO_ADDRESSES = [(2, 1, 0, "", ("10.0.0.1", 1)), (2, 1, 0, "", ("10.0.0.2", 1))] def _recv_exact(sock: socket.socket, amount: int) -> bytes: @@ -82,6 +88,7 @@ class FakeEncryptedDevice(threading.Thread): self.require_noise = require_noise self.prologue_features_override = prologue_features_override self.received: bytes | None = None + self.probes = 0 # clients that left right after the handshake self.error: Exception | None = None self.listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.listener.bind(("127.0.0.1", 0)) @@ -195,7 +202,12 @@ class FakeEncryptedDevice(threading.Thread): ) -> None: """The post-handshake exchange, identical over both transports.""" send_byte(espota2.RESPONSE_AUTH_OK) - recv_unit(1) # ota type + try: + recv_unit(1) # ota type + except ConnectionError: + # A key probe leaves here, like the firmware it is patterned on + self.probes += 1 + return size = int.from_bytes(recv_unit(4), "big") send_byte(espota2.RESPONSE_UPDATE_PREPARE_OK) md5_hex = recv_unit(32) @@ -250,6 +262,7 @@ def _run_ota( noise_psk: str, plaintext_fallback: bool = True, old_noise_psk: str | None = None, + **kwargs: Any, ) -> int: """Drive the retry loop, which is where the fallback and the old key reconnect.""" path = tmp_path / "firmware.bin" @@ -263,10 +276,110 @@ def _run_ota( noise_psk=noise_psk, plaintext_fallback=plaintext_fallback, old_noise_psk=old_noise_psk, + **kwargs, ) return rc +def test_on_upload_start_runs_once_the_device_accepted_the_session( + tmp_path: Path, +) -> None: + """The rejected first key never reaches the hook; the old_key reconnect does.""" + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice(psk=OTHER_PSK, connections=2) + on_upload_start = Mock() + with patch("time.sleep"): + rc = _run_ota( + device, + b"firmware", + tmp_path, + PSK, + plaintext_fallback=False, + old_noise_psk=OTHER_PSK, + on_upload_start=on_upload_start, + ) + device.join_and_check() + assert rc == 0 + on_upload_start.assert_called_once() + + +def test_on_upload_start_fires_once_across_retries() -> None: + """A data phase fault after the hook retries the upload; the hook has + already reported that the device may commit.""" + calls: list[int] = [] + + def perform(*_args: Any, **kwargs: Any) -> None: + kwargs["on_upload_start"]() + if len(calls) == 0: + calls.append(1) + raise espota2.OTANetworkError("reset") + + hook = Mock() + with ( + patch("esphome.espota2.resolve_ip_address", return_value=RESOLVED), + patch("socket.socket"), + patch("pathlib.Path.open"), + patch("esphome.espota2.perform_ota", side_effect=perform), + patch("time.sleep"), + ): + rc, _ = espota2.run_ota_impl_( + "h", 1, None, Path("fw.bin"), on_upload_start=hook + ) + assert rc == 0 + hook.assert_called_once() + + +def test_probe_result_is_true_only_when_accepted() -> None: + assert ProbeResult.ACCEPTED + assert not any(r for r in ProbeResult if r is not ProbeResult.ACCEPTED) + + +def test_probe_ota_key_keeps_the_key_answer_over_a_stale_device_error( + caplog: pytest.LogCaptureFixture, +) -> None: + """A stale address running some other listener answers with its own + error; what the device said about the key still wins.""" + with ( + patch("esphome.espota2.resolve_ip_address", return_value=TWO_ADDRESSES), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=[ + espota2.OTAKeyRejected("no"), + espota2.OTAError("unsupported OTA version 9"), + ], + ), + patch("time.sleep"), + caplog.at_level(logging.WARNING), + ): + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False) + is ProbeResult.REJECTED + ) + assert "failed, rejected: 10.0.0.1: no" in caplog.text + + +def test_on_upload_start_failure_closes_the_socket() -> None: + def start_upload(*_args: Any, **kwargs: Any) -> None: + kwargs["on_upload_start"]() + + with ( + patch("esphome.espota2.resolve_ip_address", return_value=RESOLVED), + patch("socket.socket") as sock, + patch("pathlib.Path.open"), + patch("esphome.espota2.perform_ota", side_effect=start_upload), + pytest.raises(RuntimeError, match="boom"), + ): + espota2.run_ota_impl_( + "h", + 1, + None, + Path("fw.bin"), + on_upload_start=Mock(side_effect=RuntimeError("boom")), + ) + sock.return_value.close.assert_called_once() + + THIRD_PSK = base64.b64encode(bytes(range(2, 34))).decode() @@ -363,9 +476,7 @@ def test_tampered_negotiation_breaks_handshake() -> None: def test_client_fails_closed_when_device_lacks_encryption() -> None: """With a key configured, a device not offering noise aborts the upload.""" device = FakeEncryptedDevice(offer_noise=False, require_noise=False) - with pytest.raises( - espota2.OTAError, match="refusing to send the image.*allow_plaintext_upload" - ): + with pytest.raises(espota2.OTAEncryptionNotOffered, match="did not offer"): _upload(device, b"firmware", PSK) device.join_and_check() @@ -666,6 +777,120 @@ def test_non_key_reject_reason_is_a_device_error( assert not any("Retrying in plaintext" in r.message for r in caplog.records) +def test_probe_ota_key_accepts_the_running_key( + caplog: pytest.LogCaptureFixture, +) -> None: + """The negotiation's upload-style progress lines stay below INFO.""" + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice() + device.start() + with caplog.at_level(logging.INFO): + assert ( + espota2.probe_ota_key("127.0.0.1", device.port, PSK, timeout=5) + is ProbeResult.ACCEPTED + ) + device.join_and_check() + assert device.probes == 1 + assert device.received is None + assert "accepted the key" in caplog.text + assert "Connection established" not in caplog.text + + +@pytest.mark.parametrize( + "device_kwargs", + [{"psk": OTHER_PSK}, {"offer_noise": False, "require_noise": False}], + ids=["wrong_key", "no_offer"], +) +def test_probe_ota_key_gives_up_at_the_deadline( + device_kwargs: dict[str, Any], caplog: pytest.LogCaptureFixture +) -> None: + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice(connections=2, **device_kwargs) + device.start() + with patch("time.sleep"), caplog.at_level(logging.WARNING): + assert ( + espota2.probe_ota_key("127.0.0.1", device.port, PSK, timeout=0.5) + is not ProbeResult.ACCEPTED + ) + device.join_and_check() + assert any("Checking the key failed" in r.message for r in caplog.records) + assert device.received is None + + +def test_probe_ota_key_fails_fast_on_a_definitive_answer() -> None: + """A device in steady state that rejects the key gives its final answer + on the first attempt; the precheck does not wait out the deadline.""" + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice(psk=OTHER_PSK) + device.start() + with patch("time.sleep") as sleep: + assert ( + espota2.probe_ota_key( + "127.0.0.1", device.port, PSK, timeout=30, retry_rejected=False + ) + is ProbeResult.REJECTED + ) + device.join_and_check() + sleep.assert_not_called() + + +def test_probe_ota_key_retries_after_a_transport_fault() -> None: + """The device may still be rebooting right after the upload.""" + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice(connections=2, drop_handshakes=1) + device.start() + with patch("time.sleep"): + assert ( + espota2.probe_ota_key("127.0.0.1", device.port, PSK, timeout=5) + is ProbeResult.ACCEPTED + ) + device.join_and_check() + assert device.probes == 1 + + +@pytest.mark.parametrize( + "kwargs", + [{"side_effect": EsphomeError("no such host")}, {"return_value": []}], + ids=["error", "empty"], +) +def test_probe_ota_key_resolution_failure( + kwargs: dict[str, Any], caplog: pytest.LogCaptureFixture +) -> None: + """A name that never resolves is retried until the deadline, then reported.""" + pytest.importorskip("aioesphomeapi.noise") + with ( + patch("esphome.espota2.resolve_ip_address", **kwargs) as resolve, + patch("time.sleep"), + caplog.at_level(logging.WARNING), + ): + assert ( + espota2.probe_ota_key("nowhere.local", 3232, PSK, timeout=0.2) + is not ProbeResult.ACCEPTED + ) + assert resolve.call_count > 1 + assert any("failed, unreachable" in r.message for r in caplog.records) + + +def test_probe_ota_key_resolves_again_after_a_reboot() -> None: + """The lookup fails while the device reboots, then the probe goes on.""" + pytest.importorskip("aioesphomeapi.noise") + device = FakeEncryptedDevice() + device.start() + real = espota2.resolve_ip_address + with ( + patch( + "esphome.espota2.resolve_ip_address", + side_effect=[EsphomeError("not yet"), real("127.0.0.1", device.port)], + ), + patch("time.sleep"), + ): + assert ( + espota2.probe_ota_key("127.0.0.1", device.port, PSK, timeout=5) + is ProbeResult.ACCEPTED + ) + device.join_and_check() + + def test_bare_block_refuses_a_device_that_cannot_encrypt( caplog: pytest.LogCaptureFixture, tmp_path: Path ) -> None: @@ -677,5 +902,399 @@ def test_bare_block_refuses_a_device_that_cannot_encrypt( device.join_and_check() assert rc == 1 assert device.received != b"firmware" - assert any("refusing to send the image" in r.message for r in caplog.records) + assert any("Refusing to send the image" in r.message for r in caplog.records) assert not any("Retrying in plaintext" in r.message for r in caplog.records) + + +def test_probe_ota_key_recomputes_the_budget_after_connect() -> None: + """A slow connect leaves less for the handshake than the budget had.""" + clock = [0.0] + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=RESOLVED, + ), + patch("socket.socket") as sock_cls, + patch("time.sleep"), + patch("time.monotonic", side_effect=lambda: clock[0]), + ): + sock = sock_cls.return_value + sock.connect.side_effect = lambda _sa: clock.__setitem__(0, clock[0] + 0.4) + sock.recv.return_value = b"" + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=0.5) is not ProbeResult.ACCEPTED + ) + connect_timeout, handshake_timeout = ( + c.args[0] for c in sock.settimeout.call_args_list[:2] + ) + assert connect_timeout == 0.5 + assert handshake_timeout == pytest.approx(0.1) + + +def test_probe_ota_key_tries_every_address_before_a_final_no() -> None: + """With retry_rejected off a rejection is final only once every resolved + address has answered; a stale cached IP must not veto the real device.""" + session = Mock() + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=TWO_ADDRESSES, + ), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=[espota2.OTAKeyRejected("no"), (session, 2, 0, True)], + ) as negotiate, + patch("esphome.espota2.receive_exactly"), + patch("time.sleep") as sleep, + ): + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False) + is ProbeResult.ACCEPTED + ) + assert negotiate.call_count == 2 + sleep.assert_not_called() + + +@pytest.mark.parametrize( + "answer", [espota2.RESPONSE_REQUEST_AUTH, espota2.RESPONSE_REQUEST_SHA256_AUTH] +) +def test_probe_ota_key_takes_a_password_challenge_as_proof(answer: int) -> None: + """A device with a password as well as encryption answers the handshake + with a challenge; it arrives encrypted, so the key was accepted.""" + session = Mock() + session.recv.return_value = bytes([answer]) + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=RESOLVED, + ), + patch("socket.socket"), + patch("esphome.espota2._negotiate_session", return_value=(session, 2, 0, True)), + ): + assert espota2.probe_ota_key("h", 1, PSK, timeout=30) is ProbeResult.ACCEPTED + + +def test_probe_ota_key_waits_for_every_address_to_answer() -> None: + """One address rejecting does not stand in for another that only failed + to connect so far; the one that answered is not asked again.""" + session = Mock() + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=TWO_ADDRESSES, + ), + patch("socket.socket") as sock, + patch( + "esphome.espota2._negotiate_session", + side_effect=[ + espota2.OTAKeyRejected("no"), + OSError("refused"), + (session, 2, 0, True), + ], + ) as negotiate, + patch("esphome.espota2.receive_exactly"), + patch("time.sleep"), + ): + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False) + is ProbeResult.ACCEPTED + ) + assert negotiate.call_count == 3 + assert [c.args[0] for c in sock.return_value.connect.call_args_list] == [ + ("10.0.0.1", 1), + ("10.0.0.2", 1), + ("10.0.0.2", 1), + ] + + +def test_probe_ota_key_pauses_between_rounds_once_an_address_answered() -> None: + """A stale address before a device that said no keeps the round pacing: + the device is not asked again and the stale address is not hammered.""" + clock = [0.0] + + def sleep(seconds: float) -> None: + clock[0] += seconds + + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=TWO_ADDRESSES, + ) as resolve, + patch("socket.socket") as sock_cls, + patch( + "esphome.espota2._negotiate_session", + side_effect=itertools.chain( + [OSError("refused"), espota2.OTAKeyRejected("no")], + itertools.repeat(OSError("refused")), + ), + ) as negotiate, + patch("time.sleep", side_effect=sleep) as sleeper, + patch("time.monotonic", side_effect=lambda: clock[0]), + ): + assert ProbeResult.ACCEPTED is not espota2.probe_ota_key( + "h", 1, PSK, timeout=30, retry_rejected=False + ) + # Ten rounds of one stale attempt each, a pause between every two + assert negotiate.call_count == 11 + assert sleeper.call_count == 10 + assert [c.args[0] for c in sock_cls.return_value.connect.call_args_list].count( + ("10.0.0.2", 1) + ) == 1 + # Every later round had no answer, so the host was resolved again each time + assert resolve.call_count == 10 + + +def test_probe_ota_key_keeps_trying_an_address_a_new_lookup_added() -> None: + """An address that answered earlier and is gone from the lookup does not + count against the ones still pending.""" + clock = [0.0] + + def sleep(seconds: float) -> None: + clock[0] += seconds + + with ( + patch( + "esphome.espota2.resolve_ip_address", + side_effect=itertools.chain( + [TWO_ADDRESSES], + itertools.repeat([(2, 1, 0, "", ("10.0.0.3", 1))]), + ), + ), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=itertools.chain( + [espota2.OTAKeyRejected("no")], itertools.repeat(OSError("down")) + ), + ) as negotiate, + patch("time.sleep", side_effect=sleep), + patch("time.monotonic", side_effect=lambda: clock[0]), + ): + assert ProbeResult.ACCEPTED is not espota2.probe_ota_key( + "h", 1, PSK, timeout=30, retry_rejected=False + ) + assert negotiate.call_count == 11 # the new address was tried every round + + +def test_probe_ota_key_keeps_the_device_answer_when_a_later_lookup_fails( + caplog: pytest.LogCaptureFixture, +) -> None: + clock = [0.0] + + def sleep(seconds: float) -> None: + clock[0] += seconds + + with ( + patch( + "esphome.espota2.resolve_ip_address", + side_effect=itertools.chain( + [TWO_ADDRESSES], itertools.repeat(EsphomeError("dns down")) + ), + ), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=[espota2.OTAKeyRejected("no"), OSError("down")], + ), + patch("time.sleep", side_effect=sleep), + patch("time.monotonic", side_effect=lambda: clock[0]), + caplog.at_level(logging.WARNING), + ): + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False) + is ProbeResult.REJECTED + ) + assert "failed, rejected: 10.0.0.1: no" in caplog.text + + +def test_probe_ota_key_names_a_missing_encryption_offer( + caplog: pytest.LogCaptureFixture, +) -> None: + """The upload's advice about the image does not apply to a probe.""" + with ( + patch("esphome.espota2.resolve_ip_address", return_value=RESOLVED), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=espota2.OTAEncryptionNotOffered("did not offer encryption"), + ), + patch("time.sleep"), + caplog.at_level(logging.WARNING), + ): + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False) + is ProbeResult.NOT_OFFERED + ) + assert "failed, not offered: 127.0.0.1: did not offer encryption" in caplog.text + assert "image" not in caplog.text + + +def test_probe_ota_key_resolves_again_after_a_round_without_an_answer() -> None: + """A round with no answer looks the host up again, and the device is + found under its new address.""" + session = Mock() + with ( + patch( + "esphome.espota2.resolve_ip_address", + side_effect=[ + [(2, 1, 0, "", ("10.0.0.1", 1))], + [(2, 1, 0, "", ("10.0.0.2", 1))], + ], + ) as resolve, + patch("socket.socket") as sock, + patch( + "esphome.espota2._negotiate_session", + side_effect=[OSError("unreachable"), (session, 2, 0, True)], + ), + patch("esphome.espota2.receive_exactly"), + patch("time.sleep"), + ): + assert espota2.probe_ota_key("h", 1, PSK, timeout=30) is ProbeResult.ACCEPTED + assert resolve.call_count == 2 + assert [c.args[0] for c in sock.return_value.connect.call_args_list] == [ + ("10.0.0.1", 1), + ("10.0.0.2", 1), + ] + + +def test_probe_ota_key_reports_the_device_answer_over_a_network_error( + caplog: pytest.LogCaptureFixture, +) -> None: + """A stale address failing last must not hide that the device said no.""" + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=TWO_ADDRESSES, + ), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=itertools.chain( + [espota2.OTAKeyRejected("wrong key")], + itertools.repeat(OSError("refused")), + ), + ), + patch("time.sleep"), + caplog.at_level(logging.WARNING), + ): + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=0.05, retry_rejected=False) + is ProbeResult.REJECTED + ) + assert "failed, rejected: 10.0.0.1: wrong key" in caplog.text + + +def test_probe_ota_key_reports_a_local_fault_as_its_own() -> None: + """A malformed key never reads as the device refusing it.""" + pytest.importorskip("aioesphomeapi.noise") + with ( + patch("esphome.espota2.resolve_ip_address") as resolve, + pytest.raises(espota2.OTAError, match="Invalid OTA encryption key"), + ): + espota2.probe_ota_key("h", 1, "not-base64!!!", timeout=30) + resolve.assert_not_called() + + +def test_probe_ota_key_takes_another_device_answer_as_final() -> None: + """An unsupported protocol version does not change with a retry.""" + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=RESOLVED, + ), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=espota2.OTAError("unsupported OTA version 9"), + ) as negotiate, + patch("time.sleep") as sleep, + ): + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=30, retry_rejected=False) + is ProbeResult.DEVICE_ERROR + ) + negotiate.assert_called_once() + sleep.assert_not_called() + + +def test_probe_ota_key_retries_a_device_error_while_it_reboots() -> None: + """After an upload the device may answer oddly until it is back.""" + session = Mock() + with ( + patch("esphome.espota2.resolve_ip_address", return_value=RESOLVED), + patch("socket.socket"), + patch( + "esphome.espota2._negotiate_session", + side_effect=[espota2.OTAError("busy"), (session, 2, 0, True)], + ) as negotiate, + patch("esphome.espota2.receive_exactly"), + patch("time.sleep"), + ): + assert espota2.probe_ota_key("h", 1, PSK, timeout=30) is ProbeResult.ACCEPTED + assert negotiate.call_count == 2 + + +def test_probe_ota_key_deadline_spans_the_whole_negotiation() -> None: + """Each read re-arms the timeout from what is left, so a slow peer + cannot stretch the probe one full timeout per read.""" + clock = [0.0] + sock = Mock() + + def slow_recv(_amount: int) -> bytes: + clock[0] += 0.3 + return b"\x00\x02" + + sock.recv.side_effect = slow_recv + with patch("time.monotonic", side_effect=lambda: clock[0]): + wrapped = espota2._DeadlineSocket(sock, 0.5) + wrapped.sendall(b"x") + assert wrapped.recv(2) == b"\x00\x02" + assert wrapped.recv(2) == b"\x00\x02" + with pytest.raises(TimeoutError): + wrapped.recv(1) + timeouts = [c.args[0] for c in sock.settimeout.call_args_list] + assert timeouts == [0.5, 0.5, pytest.approx(0.2)] + + +def test_probe_ota_key_stops_when_connect_used_the_budget() -> None: + """No handshake timeout is granted past the deadline.""" + clock = [0.0] + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=RESOLVED, + ), + patch("socket.socket") as sock_cls, + patch("time.sleep") as sleep, + patch("time.monotonic", side_effect=lambda: clock[0]), + ): + sock = sock_cls.return_value + sock.connect.side_effect = lambda _sa: clock.__setitem__(0, clock[0] + 1.0) + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=0.5) is not ProbeResult.ACCEPTED + ) + assert [c.args[0] for c in sock.settimeout.call_args_list] == [0.5] + sock.sendall.assert_not_called() + sleep.assert_not_called() + + +def test_probe_ota_key_never_waits_past_its_deadline() -> None: + """Connect and handshake timeouts and the retry sleep are all capped by + what is left of the budget.""" + with ( + patch( + "esphome.espota2.resolve_ip_address", + return_value=RESOLVED, + ), + patch("socket.socket") as sock_cls, + patch("time.sleep") as sleep, + ): + sock = sock_cls.return_value + sock.connect.side_effect = OSError("refused") + assert ( + espota2.probe_ota_key("h", 1, PSK, timeout=0.5) is not ProbeResult.ACCEPTED + ) + timeouts = [c.args[0] for c in sock.settimeout.call_args_list] + assert all(t <= 0.5 for t in timeouts) + assert all(c.args[0] <= 0.5 for c in sleep.call_args_list) From 4a80d785a8d95539ba9ccf6ec8ae41ff8fd56d19 Mon Sep 17 00:00:00 2001 From: Tobias Stanzel Date: Tue, 13 Oct 2026 19:52:46 +0200 Subject: [PATCH 010/142] =?UTF-8?q?[filter=5Flifetime]=20Add=20component?= =?UTF-8?q?=20with=20sensor/lambda=20support=20for=20is=5Fon=20=E2=80=A6?= =?UTF-8?q?=20(#13914)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../components/filter_lifetime/__init__.py | 2 + .../filter_lifetime/filter_lifetime.cpp | 112 +++++++++ .../filter_lifetime/filter_lifetime.h | 50 ++++ esphome/components/filter_lifetime/sensor.py | 94 ++++++++ tests/components/filter_lifetime/__init__.py | 12 + tests/components/filter_lifetime/common.yaml | 62 +++++ .../filter_lifetime/lambda_only.yaml | 8 + .../test-lambda-only.esp32-idf.yaml | 2 + .../filter_lifetime/test.esp32-idf.yaml | 2 + .../filter_lifetime/test.esp8266-ard.yaml | 2 + .../filter_lifetime/test.rp2040-ard.yaml | 2 + .../filter_lifetime/test_filter_lifetime.cpp | 224 ++++++++++++++++++ .../components/filter_lifetime/__init__.py | 0 .../components/filter_lifetime/test_sensor.py | 99 ++++++++ 15 files changed, 672 insertions(+) create mode 100644 esphome/components/filter_lifetime/__init__.py create mode 100644 esphome/components/filter_lifetime/filter_lifetime.cpp create mode 100644 esphome/components/filter_lifetime/filter_lifetime.h create mode 100644 esphome/components/filter_lifetime/sensor.py create mode 100644 tests/components/filter_lifetime/__init__.py create mode 100644 tests/components/filter_lifetime/common.yaml create mode 100644 tests/components/filter_lifetime/lambda_only.yaml create mode 100644 tests/components/filter_lifetime/test-lambda-only.esp32-idf.yaml create mode 100644 tests/components/filter_lifetime/test.esp32-idf.yaml create mode 100644 tests/components/filter_lifetime/test.esp8266-ard.yaml create mode 100644 tests/components/filter_lifetime/test.rp2040-ard.yaml create mode 100644 tests/components/filter_lifetime/test_filter_lifetime.cpp create mode 100644 tests/unit_tests/components/filter_lifetime/__init__.py create mode 100644 tests/unit_tests/components/filter_lifetime/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index fc14cb33d09..2c026b92f5b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -212,6 +212,7 @@ esphome/components/factory_reset/* @anatoly-savchenkov esphome/components/fastled_base/* @OttoWinter esphome/components/feedback/* @ianchi esphome/components/file/* @esphome/core +esphome/components/filter_lifetime/* @tuct esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund esphome/components/font/* @clydebarrow @esphome/core esphome/components/friedrich/* @rwrozelle diff --git a/esphome/components/filter_lifetime/__init__.py b/esphome/components/filter_lifetime/__init__.py new file mode 100644 index 00000000000..99a67538bf2 --- /dev/null +++ b/esphome/components/filter_lifetime/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@tuct"] +DOMAIN = "filter_lifetime" diff --git a/esphome/components/filter_lifetime/filter_lifetime.cpp b/esphome/components/filter_lifetime/filter_lifetime.cpp new file mode 100644 index 00000000000..47059a7f7a2 --- /dev/null +++ b/esphome/components/filter_lifetime/filter_lifetime.cpp @@ -0,0 +1,112 @@ +#include "filter_lifetime.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::filter_lifetime { + +ESPHOME_LOG_TAG(TAG, "filter_lifetime"); + +static constexpr uint32_t SECONDS_PER_HOUR = 3600; +static constexpr float MINUTES_PER_DAY = 24.0f * 60.0f; + +void FilterLifetime::setup() { + this->pref_ = this->make_entity_preference(); + this->pref_.load(&this->runtime_seconds_); + this->last_update_ = millis(); + this->publish_values_(); +} + +void FilterLifetime::update() { + const uint32_t now = App.get_loop_component_start_time(); + const uint32_t elapsed_ms = now - this->last_update_; + this->last_update_ = now; + + if (this->get_is_on_()) { + float speed = this->get_current_speed_(); + if (std::isnan(speed)) + speed = 0.0f; + speed = std::clamp(speed, 0.0f, 100.0f); + const uint32_t prev_hours = this->runtime_seconds_ / SECONDS_PER_HOUR; + this->carry_ms_ += static_cast(elapsed_ms * (speed / 100.0f)); + this->runtime_seconds_ += this->carry_ms_ / 1000; + this->carry_ms_ %= 1000; + // Save once per hour of runtime (and in on_shutdown): every save erases a flash sector on ESP8266. + if (this->runtime_seconds_ / SECONDS_PER_HOUR != prev_hours) + this->pref_.save(&this->runtime_seconds_); + ESP_LOGV(TAG, "Elapsed %" PRIu32 " ms at %.0f%%, runtime %" PRIu32 " s", elapsed_ms, speed, this->runtime_seconds_); + } + this->publish_values_(); +} + +void FilterLifetime::publish_values_() { + const float runtime_minutes = this->runtime_seconds_ / 60.0f; + const float max_minutes = this->max_lifetime_minutes_; + const float remaining_minutes = std::max(0.0f, max_minutes - runtime_minutes); + this->publish_state(100.0f * remaining_minutes / max_minutes); + if (this->runtime_hours_sensor_ != nullptr) + this->runtime_hours_sensor_->publish_state(runtime_minutes / 60.0f); + if (this->remaining_days_sensor_ != nullptr) + this->remaining_days_sensor_->publish_state(remaining_minutes / MINUTES_PER_DAY); +} + +void FilterLifetime::on_shutdown() { this->pref_.save(&this->runtime_seconds_); } + +void FilterLifetime::reset_filter() { + this->runtime_seconds_ = 0; + this->carry_ms_ = 0; + this->pref_.save(&this->runtime_seconds_); + this->publish_values_(); + ESP_LOGI(TAG, "Filter lifetime reset"); +} + +bool FilterLifetime::get_is_on_() { +#ifdef USE_BINARY_SENSOR + // Reads false until the sensor first publishes, so nothing accumulates before then. + if (this->is_on_sensor_ != nullptr) + return this->is_on_sensor_->state; +#endif + if (this->is_on_lambda_ != nullptr) + return this->is_on_lambda_(); + return true; +} + +float FilterLifetime::get_current_speed_() { + if (this->current_speed_sensor_ != nullptr) + return this->current_speed_sensor_->state; // NaN before the first publish, treated as 0 + if (this->current_speed_lambda_ != nullptr) + return this->current_speed_lambda_(); + return 100.0f; +} + +void FilterLifetime::dump_config() { + LOG_SENSOR("", "Filter Lifetime", this); + ESP_LOGCONFIG(TAG, + " Max lifetime: %" PRIu32 " min (%.1f days)\n" + " Runtime: %.1f h", + this->max_lifetime_minutes_, this->max_lifetime_minutes_ / MINUTES_PER_DAY, + this->runtime_seconds_ / 3600.0f); +#ifdef USE_BINARY_SENSOR + if (this->is_on_sensor_ != nullptr) { + LOG_BINARY_SENSOR(" ", "Is-on sensor", this->is_on_sensor_); + } else +#endif + { + ESP_LOGCONFIG(TAG, " Is-on: %s", + this->is_on_lambda_ != nullptr ? LOG_STR_LITERAL("lambda") : LOG_STR_LITERAL("always")); + } + if (this->current_speed_sensor_ != nullptr) { + LOG_SENSOR(" ", "Speed sensor", this->current_speed_sensor_); + } else { + ESP_LOGCONFIG(TAG, " Speed: %s", + this->current_speed_lambda_ != nullptr ? LOG_STR_LITERAL("lambda") : LOG_STR_LITERAL("100%")); + } + LOG_SENSOR(" ", "Runtime hours", this->runtime_hours_sensor_); + LOG_SENSOR(" ", "Remaining days", this->remaining_days_sensor_); + LOG_UPDATE_INTERVAL(this); +} + +} // namespace esphome::filter_lifetime diff --git a/esphome/components/filter_lifetime/filter_lifetime.h b/esphome/components/filter_lifetime/filter_lifetime.h new file mode 100644 index 00000000000..e6f40d78f5b --- /dev/null +++ b/esphome/components/filter_lifetime/filter_lifetime.h @@ -0,0 +1,50 @@ +#pragma once + +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif + +namespace esphome::filter_lifetime { + +class FilterLifetime : public sensor::Sensor, public PollingComponent { + public: + void setup() override; + void update() override; + void dump_config() override; + void on_shutdown() override; + void reset_filter(); + + void set_max_lifetime(uint32_t minutes) { this->max_lifetime_minutes_ = minutes; } + void set_is_on_lambda(bool (*is_on)()) { this->is_on_lambda_ = is_on; } +#ifdef USE_BINARY_SENSOR + void set_is_on_sensor(binary_sensor::BinarySensor *sensor) { this->is_on_sensor_ = sensor; } +#endif + void set_current_speed_lambda(float (*speed)()) { this->current_speed_lambda_ = speed; } + void set_current_speed_sensor(sensor::Sensor *sensor) { this->current_speed_sensor_ = sensor; } + void set_runtime_hours_sensor(sensor::Sensor *sensor) { this->runtime_hours_sensor_ = sensor; } + void set_remaining_days_sensor(sensor::Sensor *sensor) { this->remaining_days_sensor_ = sensor; } + + protected: + bool get_is_on_(); + float get_current_speed_(); + void publish_values_(); + + bool (*is_on_lambda_)(){nullptr}; + float (*current_speed_lambda_)(){nullptr}; +#ifdef USE_BINARY_SENSOR + binary_sensor::BinarySensor *is_on_sensor_{nullptr}; +#endif + sensor::Sensor *current_speed_sensor_{nullptr}; + sensor::Sensor *runtime_hours_sensor_{nullptr}; + sensor::Sensor *remaining_days_sensor_{nullptr}; + ESPPreferenceObject pref_; + uint32_t max_lifetime_minutes_{0}; + uint32_t last_update_{0}; + uint32_t runtime_seconds_{0}; // Speed-weighted running time, persisted + uint32_t carry_ms_{0}; // Weighted milliseconds not yet counted as a second +}; + +} // namespace esphome::filter_lifetime diff --git a/esphome/components/filter_lifetime/sensor.py b/esphome/components/filter_lifetime/sensor.py new file mode 100644 index 00000000000..58af033f329 --- /dev/null +++ b/esphome/components/filter_lifetime/sensor.py @@ -0,0 +1,94 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import binary_sensor, sensor +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_DURATION, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_HOUR, + UNIT_PERCENT, +) +from esphome.types import ConfigType + +CONF_MAX_LIFETIME = "max_lifetime" +CONF_IS_ON = "is_on" +CONF_IS_ON_SENSOR = "is_on_sensor" +CONF_CURRENT_SPEED = "current_speed" +CONF_CURRENT_SPEED_SENSOR = "current_speed_sensor" +CONF_RUNTIME_HOURS = "runtime_hours" +CONF_REMAINING_DAYS = "remaining_days" +UNIT_DAY = "d" + +filter_lifetime_ns = cg.esphome_ns.namespace("filter_lifetime") +FilterLifetime = filter_lifetime_ns.class_( + "FilterLifetime", sensor.Sensor, cg.PollingComponent +) + +CONFIG_SCHEMA = cv.All( + sensor.sensor_schema( + FilterLifetime, + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=2, + icon="mdi:air-filter", + state_class=STATE_CLASS_MEASUREMENT, + ) + .extend( + { + cv.Required(CONF_MAX_LIFETIME): cv.All( + cv.positive_time_period_minutes, + cv.Range(min=cv.TimePeriod(minutes=1)), + ), + cv.Optional(CONF_IS_ON): cv.returning_lambda, + cv.Optional(CONF_IS_ON_SENSOR): cv.use_id(binary_sensor.BinarySensor), + cv.Optional(CONF_CURRENT_SPEED): cv.returning_lambda, + cv.Optional(CONF_CURRENT_SPEED_SENSOR): cv.use_id(sensor.Sensor), + cv.Optional(CONF_RUNTIME_HOURS): sensor.sensor_schema( + unit_of_measurement=UNIT_HOUR, + accuracy_decimals=1, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_TOTAL_INCREASING, + icon="mdi:timer-outline", + ), + cv.Optional(CONF_REMAINING_DAYS): sensor.sensor_schema( + unit_of_measurement=UNIT_DAY, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + icon="mdi:calendar-clock", + ), + } + ) + .extend(cv.polling_component_schema("60s")), + cv.has_at_most_one_key(CONF_IS_ON, CONF_IS_ON_SENSOR), + cv.has_at_most_one_key(CONF_CURRENT_SPEED, CONF_CURRENT_SPEED_SENSOR), +) + + +async def to_code(config: ConfigType) -> None: + var = await sensor.new_sensor(config) + await cg.register_component(var, config) + + cg.add(var.set_max_lifetime(config[CONF_MAX_LIFETIME])) + + if (is_on := config.get(CONF_IS_ON)) is not None: + cg.add(var.set_is_on_lambda(await cg.templatable(is_on, [], bool))) + elif (is_on_sensor := config.get(CONF_IS_ON_SENSOR)) is not None: + cg.add(var.set_is_on_sensor(await cg.get_variable(is_on_sensor))) + + if (current_speed := config.get(CONF_CURRENT_SPEED)) is not None: + cg.add( + var.set_current_speed_lambda(await cg.templatable(current_speed, [], float)) + ) + elif (speed_sensor := config.get(CONF_CURRENT_SPEED_SENSOR)) is not None: + cg.add(var.set_current_speed_sensor(await cg.get_variable(speed_sensor))) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_RUNTIME_HOURS, var.set_runtime_hours_sensor) + await sensors(CONF_REMAINING_DAYS, var.set_remaining_days_sensor) + + +automation.register_apply_action( + "filter_lifetime.reset_filter", + cv.Schema({cv.GenerateID(): cv.use_id(FilterLifetime)}), + automation.ApplyCall("reset_filter()"), +) diff --git a/tests/components/filter_lifetime/__init__.py b/tests/components/filter_lifetime/__init__.py new file mode 100644 index 00000000000..70b7f08adf3 --- /dev/null +++ b/tests/components/filter_lifetime/__init__.py @@ -0,0 +1,12 @@ +from esphome.core import CORE +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + async def to_code_testing(config: ConfigType) -> None: + for platform in ("sensor", "binary_sensor"): + CORE.testing_ensure_platform_registered(platform) + + manifest.to_code = to_code_testing + manifest.dependencies = manifest.dependencies + ["sensor", "binary_sensor"] diff --git a/tests/components/filter_lifetime/common.yaml b/tests/components/filter_lifetime/common.yaml new file mode 100644 index 00000000000..1cf8a3e0b18 --- /dev/null +++ b/tests/components/filter_lifetime/common.yaml @@ -0,0 +1,62 @@ +binary_sensor: + - platform: template + name: "Fan Running" + id: fan_running + +sensor: + - platform: template + name: "Fan Speed Percent" + id: fan_speed_percent + unit_of_measurement: "%" + + # Test with lambdas + - platform: filter_lifetime + name: "Filter Lifetime Lambda" + id: filter_lifetime_lambda + max_lifetime: 365d + is_on: !lambda 'return true;' + current_speed: !lambda 'return 50.0;' + update_interval: 60s + runtime_hours: + name: "Filter Runtime Hours Lambda" + remaining_days: + name: "Filter Remaining Days Lambda" + + # Test with sensor references + - platform: filter_lifetime + name: "Filter Lifetime Sensor" + id: filter_lifetime_sensor + max_lifetime: 365d + is_on_sensor: fan_running + current_speed_sensor: fan_speed_percent + update_interval: 60s + runtime_hours: + name: "Filter Runtime Hours Sensor" + remaining_days: + name: "Filter Remaining Days Sensor" + + # Test with defaults (always on at 100%) + - platform: filter_lifetime + name: "Filter Lifetime Default" + id: filter_lifetime_default + max_lifetime: 180d + update_interval: 60s + +button: + - platform: template + name: "Reset Filter Lambda" + on_press: + - filter_lifetime.reset_filter: + id: filter_lifetime_lambda + + - platform: template + name: "Reset Filter Sensor" + on_press: + - filter_lifetime.reset_filter: + id: filter_lifetime_sensor + + - platform: template + name: "Reset Filter Default" + on_press: + - filter_lifetime.reset_filter: + id: filter_lifetime_default diff --git a/tests/components/filter_lifetime/lambda_only.yaml b/tests/components/filter_lifetime/lambda_only.yaml new file mode 100644 index 00000000000..9affd2d469f --- /dev/null +++ b/tests/components/filter_lifetime/lambda_only.yaml @@ -0,0 +1,8 @@ +sensor: + - platform: filter_lifetime + name: "Filter Lifetime Lambda Only" + max_lifetime: 180d + is_on: !lambda 'return true;' + current_speed: !lambda 'return 75.0f;' + remaining_days: + name: "Filter Remaining Days Lambda Only" diff --git a/tests/components/filter_lifetime/test-lambda-only.esp32-idf.yaml b/tests/components/filter_lifetime/test-lambda-only.esp32-idf.yaml new file mode 100644 index 00000000000..aa0d3c679ff --- /dev/null +++ b/tests/components/filter_lifetime/test-lambda-only.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + filter_lifetime: !include lambda_only.yaml diff --git a/tests/components/filter_lifetime/test.esp32-idf.yaml b/tests/components/filter_lifetime/test.esp32-idf.yaml new file mode 100644 index 00000000000..1dc5fd39b9e --- /dev/null +++ b/tests/components/filter_lifetime/test.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + filter_lifetime: !include common.yaml diff --git a/tests/components/filter_lifetime/test.esp8266-ard.yaml b/tests/components/filter_lifetime/test.esp8266-ard.yaml new file mode 100644 index 00000000000..1dc5fd39b9e --- /dev/null +++ b/tests/components/filter_lifetime/test.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + filter_lifetime: !include common.yaml diff --git a/tests/components/filter_lifetime/test.rp2040-ard.yaml b/tests/components/filter_lifetime/test.rp2040-ard.yaml new file mode 100644 index 00000000000..1dc5fd39b9e --- /dev/null +++ b/tests/components/filter_lifetime/test.rp2040-ard.yaml @@ -0,0 +1,2 @@ +packages: + filter_lifetime: !include common.yaml diff --git a/tests/components/filter_lifetime/test_filter_lifetime.cpp b/tests/components/filter_lifetime/test_filter_lifetime.cpp new file mode 100644 index 00000000000..a4c89edd856 --- /dev/null +++ b/tests/components/filter_lifetime/test_filter_lifetime.cpp @@ -0,0 +1,224 @@ +#ifdef USE_HOST + +#include + +#include +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/filter_lifetime/filter_lifetime.h" +#include "esphome/components/host/preferences.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome::filter_lifetime::testing { + +class EntityIdentity : public EntityBase { + public: + static void configure(EntityBase &entity, const char *name, uint32_t hash) { + auto configure = &EntityIdentity::configure_entity_; + (entity.*configure)(name, hash, 0); + } +}; + +class TestableFilterLifetime : public FilterLifetime { + public: + using FilterLifetime::last_update_; + using FilterLifetime::runtime_seconds_; +}; + +static bool g_is_on = true; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static float g_speed = 100.0f; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static constexpr uint32_t MAX_MINUTES = 600; // 10 hours +static constexpr uint32_t FILTER_HASH = 0x104001; + +class FilterLifetimeTest : public ::testing::Test { + protected: + TestableFilterLifetime filter_; + sensor::Sensor runtime_hours_; + sensor::Sensor remaining_days_; + float last_state_{NAN}; + unsigned publications_{0}; + + void SetUp() override { + new (&App) Application; + App.pre_setup("filter-lifetime-tests", 21, "Filter lifetime tests", 21); + host::setup_preferences(); + host::host_preferences->reset(); + g_is_on = true; + g_speed = 100.0f; + this->attach(this->filter_); + this->filter_.add_on_state_callback([this](float value) { + this->last_state_ = value; + ++this->publications_; + }); + } + + void TearDown() override { + host::host_preferences->reset(); + App.~Application(); + } + + void attach(FilterLifetime &filter) { + EntityIdentity::configure(filter, "Filter", FILTER_HASH); + filter.set_max_lifetime(MAX_MINUTES); + filter.set_is_on_lambda([]() { return g_is_on; }); + filter.set_current_speed_lambda([]() { return g_speed; }); + filter.set_runtime_hours_sensor(&this->runtime_hours_); + filter.set_remaining_days_sensor(&this->remaining_days_); + } + + // Run one update() as if elapsed_ms passed since the previous one + void elapse(uint32_t elapsed_ms) { + this->filter_.last_update_ = App.get_loop_component_start_time() - elapsed_ms; + this->filter_.update(); + } + + bool stored(uint32_t &value) { return this->filter_.make_entity_preference().load(&value); } + + static float expected_percent(uint32_t runtime_seconds) { + return 100.0f * (MAX_MINUTES - runtime_seconds / 60.0f) / MAX_MINUTES; + } +}; + +TEST_F(FilterLifetimeTest, PublishesFullLifeAtSetup) { + this->filter_.setup(); + EXPECT_EQ(this->publications_, 1u); + EXPECT_FLOAT_EQ(this->last_state_, 100.0f); + EXPECT_FLOAT_EQ(this->runtime_hours_.state, 0.0f); + EXPECT_FLOAT_EQ(this->remaining_days_.state, MAX_MINUTES / (24.0f * 60.0f)); +} + +TEST_F(FilterLifetimeTest, AccumulatesSpeedWeightedSeconds) { + this->filter_.setup(); + g_speed = 50.0f; + this->elapse(60000); + EXPECT_EQ(this->filter_.runtime_seconds_, 30u); + EXPECT_FLOAT_EQ(this->last_state_, expected_percent(30)); + EXPECT_FLOAT_EQ(this->runtime_hours_.state, 30.0f / 3600.0f); + EXPECT_FLOAT_EQ(this->remaining_days_.state, (MAX_MINUTES - 0.5f) / (24.0f * 60.0f)); +} + +TEST_F(FilterLifetimeTest, CarriesSubSecondRemainderAcrossUpdates) { + this->filter_.setup(); + g_speed = 25.0f; + for (int i = 0; i < 3; i++) + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 0u); + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); +} + +TEST_F(FilterLifetimeTest, NothingAccumulatesWhileOff) { + this->filter_.setup(); + g_is_on = false; + this->elapse(60000); + EXPECT_EQ(this->filter_.runtime_seconds_, 0u); + EXPECT_EQ(this->publications_, 2u); + EXPECT_FLOAT_EQ(this->last_state_, 100.0f); +} + +TEST_F(FilterLifetimeTest, SpeedIsClampedAndNanCountsAsZero) { + this->filter_.setup(); + g_speed = 250.0f; + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); + g_speed = NAN; + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); + g_speed = -5.0f; + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); +} + +TEST_F(FilterLifetimeTest, SpeedSensorIsUsedOnceItHasAState) { + sensor::Sensor speed; + this->filter_.set_current_speed_lambda(nullptr); + this->filter_.set_current_speed_sensor(&speed); + this->filter_.setup(); + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 0u); + speed.state = 50.0f; + this->elapse(2000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); +} + +TEST_F(FilterLifetimeTest, IsOnSensorGatesAccumulationOnceItIsOn) { + binary_sensor::BinarySensor running; + this->filter_.set_is_on_lambda(nullptr); + this->filter_.set_is_on_sensor(&running); + this->filter_.setup(); + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 0u); + running.publish_state(true); + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); + running.publish_state(false); + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); +} + +TEST_F(FilterLifetimeTest, NoInputsMeansAlwaysOnAtFullSpeed) { + this->filter_.set_is_on_lambda(nullptr); + this->filter_.set_current_speed_lambda(nullptr); + this->filter_.setup(); + this->elapse(1000); + EXPECT_EQ(this->filter_.runtime_seconds_, 1u); +} + +TEST_F(FilterLifetimeTest, SavesOnlyWhenAnHourOfRuntimeCompletes) { + this->filter_.setup(); + uint32_t value = 0; + this->elapse(3599000); + EXPECT_FALSE(this->stored(value)); + this->elapse(1000); + ASSERT_TRUE(this->stored(value)); + EXPECT_EQ(value, 3600u); + this->elapse(1000); + ASSERT_TRUE(this->stored(value)); + EXPECT_EQ(value, 3600u); +} + +TEST_F(FilterLifetimeTest, ShutdownSavesThePartialHour) { + this->filter_.setup(); + this->elapse(1000); + this->filter_.on_shutdown(); + uint32_t value = 0; + ASSERT_TRUE(this->stored(value)); + EXPECT_EQ(value, 1u); +} + +TEST_F(FilterLifetimeTest, RestoresRuntimeAtSetup) { + this->filter_.setup(); + this->elapse(1234000); + this->filter_.on_shutdown(); + + TestableFilterLifetime restored; + this->attach(restored); + restored.setup(); + EXPECT_EQ(restored.runtime_seconds_, 1234u); + EXPECT_FLOAT_EQ(restored.state, expected_percent(1234)); +} + +TEST_F(FilterLifetimeTest, ResetClearsRuntimeAndSaves) { + this->filter_.setup(); + this->elapse(5000); + this->filter_.reset_filter(); + EXPECT_EQ(this->filter_.runtime_seconds_, 0u); + EXPECT_FLOAT_EQ(this->last_state_, 100.0f); + EXPECT_FLOAT_EQ(this->runtime_hours_.state, 0.0f); + uint32_t value = 1; + ASSERT_TRUE(this->stored(value)); + EXPECT_EQ(value, 0u); +} + +TEST_F(FilterLifetimeTest, RemainingLifeStopsAtZero) { + this->filter_.set_max_lifetime(1); + this->filter_.setup(); + this->elapse(120000); + EXPECT_FLOAT_EQ(this->last_state_, 0.0f); + EXPECT_FLOAT_EQ(this->remaining_days_.state, 0.0f); + EXPECT_FLOAT_EQ(this->runtime_hours_.state, 2.0f / 60.0f); +} + +} // namespace esphome::filter_lifetime::testing + +#endif // USE_HOST diff --git a/tests/unit_tests/components/filter_lifetime/__init__.py b/tests/unit_tests/components/filter_lifetime/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit_tests/components/filter_lifetime/test_sensor.py b/tests/unit_tests/components/filter_lifetime/test_sensor.py new file mode 100644 index 00000000000..7e8d85fcda6 --- /dev/null +++ b/tests/unit_tests/components/filter_lifetime/test_sensor.py @@ -0,0 +1,99 @@ +"""Tests for the filter_lifetime configuration schema.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from esphome.components.filter_lifetime import sensor as filter_lifetime +import esphome.config_validation as cv +from esphome.const import ( + CONF_ACCURACY_DECIMALS, + CONF_DEVICE_CLASS, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + CONF_UPDATE_INTERVAL, + DEVICE_CLASS_DURATION, + STATE_CLASS_TOTAL_INCREASING, + UNIT_HOUR, + UNIT_PERCENT, +) +from esphome.core import Lambda +from esphome.types import ConfigType + + +def _validate(config: dict[str, Any]) -> ConfigType: + return filter_lifetime.CONFIG_SCHEMA({"name": "Filter", **config}) + + +def test_max_lifetime_is_stored_in_minutes() -> None: + config = _validate({"max_lifetime": "180d"}) + assert config["max_lifetime"].total_minutes == 180 * 24 * 60 + + +def test_max_lifetime_is_required() -> None: + with pytest.raises(cv.Invalid, match="required key not provided"): + _validate({}) + + +@pytest.mark.parametrize("value", ["0min", "30s", 6]) +def test_max_lifetime_rejects_values_under_one_minute(value: Any) -> None: + with pytest.raises(cv.Invalid): + _validate({"max_lifetime": value}) + + +def test_defaults() -> None: + config = _validate({"max_lifetime": "180d"}) + assert config[CONF_UPDATE_INTERVAL].total_seconds == 60 + assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_PERCENT + assert config[CONF_ACCURACY_DECIMALS] == 2 + assert "is_on" not in config + assert "current_speed" not in config + + +@pytest.mark.parametrize( + ("lambda_key", "sensor_key"), + [("is_on", "is_on_sensor"), ("current_speed", "current_speed_sensor")], +) +def test_lambda_and_sensor_are_mutually_exclusive( + lambda_key: str, sensor_key: str +) -> None: + with pytest.raises(cv.Invalid, match="Cannot specify more than one of"): + _validate( + { + "max_lifetime": "180d", + lambda_key: Lambda("return 1;"), + sensor_key: "some_id", + } + ) + + +def test_lambda_inputs_accepted() -> None: + config = _validate( + { + "max_lifetime": "180d", + "is_on": Lambda("return true;"), + "current_speed": Lambda("return 50.0f;"), + } + ) + assert config["is_on"].value == "return true;" + assert config["current_speed"].value == "return 50.0f;" + + +def test_sub_sensor_defaults() -> None: + config = _validate( + { + "max_lifetime": "180d", + "runtime_hours": {"name": "Runtime"}, + "remaining_days": {"name": "Remaining"}, + } + ) + runtime = config["runtime_hours"] + assert runtime[CONF_UNIT_OF_MEASUREMENT] == UNIT_HOUR + assert runtime[CONF_DEVICE_CLASS] == DEVICE_CLASS_DURATION + assert runtime[CONF_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + remaining = config["remaining_days"] + assert remaining[CONF_UNIT_OF_MEASUREMENT] == filter_lifetime.UNIT_DAY + assert remaining[CONF_DEVICE_CLASS] == DEVICE_CLASS_DURATION + assert remaining[CONF_ACCURACY_DECIMALS] == 0 From 95766ed3ad043f45eabf6e92b7847769427f4b41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 13 Oct 2026 20:07:32 +0200 Subject: [PATCH 011/142] [icm20689] Add ICM-20689 6-axis accelerometer/gyroscope (SPI) (#19234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ludovic BOUÉ <938089+lboue@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/icm20689/__init__.py | 13 ++ esphome/components/icm20689/icm20689.cpp | 126 ++++++++++++++++++ esphome/components/icm20689/icm20689.h | 66 +++++++++ esphome/components/icm20689/motion.py | 56 ++++++++ esphome/components/icm20689/sensor.py | 42 ++++++ tests/components/icm20689/common.yaml | 73 ++++++++++ tests/components/icm20689/test.esp32-idf.yaml | 3 + .../components/icm20689/test.esp8266-ard.yaml | 3 + .../components/icm20689/test.rp2040-ard.yaml | 3 + 10 files changed, 386 insertions(+) create mode 100644 esphome/components/icm20689/__init__.py create mode 100644 esphome/components/icm20689/icm20689.cpp create mode 100644 esphome/components/icm20689/icm20689.h create mode 100644 esphome/components/icm20689/motion.py create mode 100644 esphome/components/icm20689/sensor.py create mode 100644 tests/components/icm20689/common.yaml create mode 100644 tests/components/icm20689/test.esp32-idf.yaml create mode 100644 tests/components/icm20689/test.esp8266-ard.yaml create mode 100644 tests/components/icm20689/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 2c026b92f5b..fc53a28c0b3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -287,6 +287,7 @@ esphome/components/i2s_audio/microphone/* @jesserockz esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt esphome/components/i2s_clockless_led_strip/* @j9brown esphome/components/iaqcore/* @yozik04 +esphome/components/icm20689/* @lboue esphome/components/icnt86/* @danepowell esphome/components/ili9xxx/* @clydebarrow @nielsnl68 esphome/components/improv_base/* @esphome/core diff --git a/esphome/components/icm20689/__init__.py b/esphome/components/icm20689/__init__.py new file mode 100644 index 00000000000..b57221814e8 --- /dev/null +++ b/esphome/components/icm20689/__init__.py @@ -0,0 +1,13 @@ +import esphome.codegen as cg +from esphome.components import spi +from esphome.components.motion import MotionComponent + +CODEOWNERS = ["@lboue"] +DOMAIN = "icm20689" + +CONF_ICM20689_ID = "icm20689_id" +# C++ namespace / class +icm20689_ns = cg.esphome_ns.namespace("icm20689") +ICM20689Component = icm20689_ns.class_( + "ICM20689Component", MotionComponent, spi.SPIDevice +) diff --git a/esphome/components/icm20689/icm20689.cpp b/esphome/components/icm20689/icm20689.cpp new file mode 100644 index 00000000000..7331a3fe4f6 --- /dev/null +++ b/esphome/components/icm20689/icm20689.cpp @@ -0,0 +1,126 @@ +#include "icm20689.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::icm20689 { + +ESPHOME_LOG_TAG(TAG, "icm20689"); + +namespace { + +// Range enum values 0..3 map to full-scale settings of 2x/4x/8x/16x the base range. +constexpr float accel_scale(uint8_t range) { return (2.0f * (1U << range)) / 32768.0f; } + +constexpr float gyro_scale(uint8_t range) { return (250.0f * (1U << range)) / 32768.0f; } + +} // namespace + +void ICM20689Component::write_register_(uint8_t reg, uint8_t value) { + this->enable(); + this->write_byte(reg & 0x7F); + this->write_byte(value); + this->disable(); +} + +void ICM20689Component::read_registers_(uint8_t reg, uint8_t *data, size_t len) { + this->enable(); + this->write_byte(reg | ICM20689_READ_BIT); + this->read_array(data, len); + this->disable(); +} + +void ICM20689Component::setup() { + MotionComponent::setup(); + + this->spi_setup(); + + if (!this->spi_is_ready()) { + ESP_LOGE(TAG, "SPI device is not ready"); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + // Datasheet 4.20: a soft reset must be the first operation after power-up, + // before any register initialization. WHO_AM_I is only valid afterwards. + this->write_register_(ICM20689_REG_PWR_MGMT_1, ICM20689_SOFT_RESET); + delay(100); // NOLINT + + // The chip comes out of reset in sleep mode: wake it and select the + // auto-selected best clock source (required for full gyro performance). + this->write_register_(ICM20689_REG_PWR_MGMT_1, ICM20689_CLKSEL_AUTO); + + // USER_CTRL is not writable in sleep mode, so I2C_IF_DIS is set once awake. + // Datasheet 6.1: disable the I2C interface when using SPI. + this->write_register_(ICM20689_REG_USER_CTRL, ICM20689_I2C_IF_DIS); + + uint8_t who_am_i = 0; + this->read_registers_(ICM20689_REG_WHO_AM_I, &who_am_i, 1); + + if (who_am_i != ICM20689_WHO_AM_I_VALUE) { + ESP_LOGE(TAG, "Unexpected WHO_AM_I 0x%02X (expected 0x%02X) -- check wiring/CS", who_am_i, ICM20689_WHO_AM_I_VALUE); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + // Allow the sensor to settle after waking from sleep. + delay(50); // NOLINT + + // Configure the accelerometer and gyroscope full-scale ranges. + this->write_register_(ICM20689_REG_ACCEL_CONFIG, static_cast(this->accel_range_) << 3); + this->write_register_(ICM20689_REG_GYRO_CONFIG, static_cast(this->gyro_range_) << 3); +} + +void ICM20689Component::dump_config() { + ESP_LOGCONFIG(TAG, "ICM20689 IMU:"); + LOG_PIN(" CS Pin: ", this->cs_); + + if (this->is_failed()) { + ESP_LOGE(TAG, " " ESP_LOG_MSG_COMM_FAIL); + return; + } + + ESP_LOGCONFIG(TAG, " Accel range: ±%dg", 2 << static_cast(this->accel_range_)); + ESP_LOGCONFIG(TAG, " Gyro range: ±%d°/s", 250 << static_cast(this->gyro_range_)); + + MotionComponent::dump_config(); +} + +bool ICM20689Component::update_data(motion::MotionData &data) { + if (this->is_failed()) + return false; + + const uint8_t accel_range = static_cast(this->accel_range_); + const uint8_t gyro_range = static_cast(this->gyro_range_); + + // Read one contiguous sample: + // 0..5 accelerometer X/Y/Z + // 6..7 temperature + // 8..13 gyroscope X/Y/Z + uint8_t raw_data[14] = {}; + this->read_registers_(ICM20689_REG_ACCEL_XOUT_H, raw_data, sizeof(raw_data)); + + const float accel_factor = accel_scale(accel_range); + + data.acceleration[motion::X_AXIS] = static_cast(encode_uint16(raw_data[0], raw_data[1])) * accel_factor; + data.acceleration[motion::Y_AXIS] = static_cast(encode_uint16(raw_data[2], raw_data[3])) * accel_factor; + data.acceleration[motion::Z_AXIS] = static_cast(encode_uint16(raw_data[4], raw_data[5])) * accel_factor; + + const float gyro_factor = gyro_scale(gyro_range); + + data.angular_rate[motion::X_AXIS] = static_cast(encode_uint16(raw_data[8], raw_data[9])) * gyro_factor; + data.angular_rate[motion::Y_AXIS] = static_cast(encode_uint16(raw_data[10], raw_data[11])) * gyro_factor; + data.angular_rate[motion::Z_AXIS] = static_cast(encode_uint16(raw_data[12], raw_data[13])) * gyro_factor; + + if (!this->temperature_callback_.empty()) { + // Datasheet formula: temperature (°C) = raw / 326.8 + 25. + const float temperature = static_cast(encode_uint16(raw_data[6], raw_data[7])) / 326.8f + 25.0f; + this->temperature_callback_.call(temperature); + } + + return true; +} + +} // namespace esphome::icm20689 diff --git a/esphome/components/icm20689/icm20689.h b/esphome/components/icm20689/icm20689.h new file mode 100644 index 00000000000..08ed9d03eff --- /dev/null +++ b/esphome/components/icm20689/icm20689.h @@ -0,0 +1,66 @@ +#pragma once + +#include "esphome/components/motion/motion_component.h" +#include "esphome/components/spi/spi.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::icm20689 { + +// Register map +static constexpr uint8_t ICM20689_REG_WHO_AM_I = 0x75; +static constexpr uint8_t ICM20689_REG_PWR_MGMT_1 = 0x6B; +static constexpr uint8_t ICM20689_REG_USER_CTRL = 0x6A; +static constexpr uint8_t ICM20689_REG_GYRO_CONFIG = 0x1B; +static constexpr uint8_t ICM20689_REG_ACCEL_CONFIG = 0x1C; +static constexpr uint8_t ICM20689_REG_ACCEL_XOUT_H = 0x3B; +static constexpr uint8_t ICM20689_READ_BIT = 0x80; +static constexpr uint8_t ICM20689_WHO_AM_I_VALUE = 0x98; +static constexpr uint8_t ICM20689_SOFT_RESET = 0x81; // PWR_MGMT_1: DEVICE_RESET | CLKSEL=1 (datasheet 4.20) +static constexpr uint8_t ICM20689_CLKSEL_AUTO = 0x01; // PWR_MGMT_1: SLEEP=0, CLKSEL=1 (auto-select best clock) +static constexpr uint8_t ICM20689_I2C_IF_DIS = 0x10; // USER_CTRL bit 4 + +// Accelerometer range options: FS_SEL bits [4:3] of ACCEL_CONFIG +enum ICM20689AccelRange : uint8_t { + ICM20689_ACCEL_RANGE_2G = 0x00, + ICM20689_ACCEL_RANGE_4G = 0x01, + ICM20689_ACCEL_RANGE_8G = 0x02, + ICM20689_ACCEL_RANGE_16G = 0x03, +}; + +// Gyroscope range options: FS_SEL bits [4:3] of GYRO_CONFIG +enum ICM20689GyroRange : uint8_t { + ICM20689_GYRO_RANGE_250 = 0x00, + ICM20689_GYRO_RANGE_500 = 0x01, + ICM20689_GYRO_RANGE_1000 = 0x02, + ICM20689_GYRO_RANGE_2000 = 0x03, +}; + +class ICM20689Component final : public motion::MotionComponent, + public spi::SPIDevice { + public: + void setup() override; + void dump_config() override; + + void set_accel_range(ICM20689AccelRange range) { this->accel_range_ = range; } + void set_gyro_range(ICM20689GyroRange range) { this->gyro_range_ = range; } + + template void add_temperature_listener(F &&callback) { + this->temperature_callback_.add(std::forward(callback)); + } + + protected: + bool update_data(motion::MotionData &data) override; + + void write_register_(uint8_t reg, uint8_t value); + // Reads len bytes starting at reg in one CS-low transaction. + void read_registers_(uint8_t reg, uint8_t *data, size_t len); + + ICM20689AccelRange accel_range_{ICM20689_ACCEL_RANGE_2G}; + ICM20689GyroRange gyro_range_{ICM20689_GYRO_RANGE_250}; + + LazyCallbackManager temperature_callback_{}; +}; + +} // namespace esphome::icm20689 diff --git a/esphome/components/icm20689/motion.py b/esphome/components/icm20689/motion.py new file mode 100644 index 00000000000..7673dcf4cf4 --- /dev/null +++ b/esphome/components/icm20689/motion.py @@ -0,0 +1,56 @@ +import esphome.codegen as cg +from esphome.components import spi +from esphome.components.const import CONF_ACCELEROMETER_RANGE, CONF_GYROSCOPE_RANGE +from esphome.components.motion import motion_schema, new_motion_component +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import ICM20689Component, icm20689_ns + +DEPENDENCIES = ["spi"] + +# Enum proxies (must match the C++ enum values exactly) +ICM20689AccelRange = icm20689_ns.enum("ICM20689AccelRange") +ACCEL_RANGE_OPTIONS = { + "2G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_2G, + "4G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_4G, + "8G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_8G, + "16G": ICM20689AccelRange.ICM20689_ACCEL_RANGE_16G, +} + +ICM20689GyroRange = icm20689_ns.enum("ICM20689GyroRange") +GYRO_RANGE_OPTIONS = { + "250DPS": ICM20689GyroRange.ICM20689_GYRO_RANGE_250, + "500DPS": ICM20689GyroRange.ICM20689_GYRO_RANGE_500, + "1000DPS": ICM20689GyroRange.ICM20689_GYRO_RANGE_1000, + "2000DPS": ICM20689GyroRange.ICM20689_GYRO_RANGE_2000, +} + +# Top-level CONFIG_SCHEMA +CONFIG_SCHEMA = ( + motion_schema(ICM20689Component, has_accel=True, has_gyro=True) + .extend( + { + cv.Optional(CONF_ACCELEROMETER_RANGE, default="2G"): cv.enum( + ACCEL_RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_GYROSCOPE_RANGE, default="250DPS"): cv.enum( + GYRO_RANGE_OPTIONS, upper=True + ), + } + ) + .extend( + spi.spi_device_schema( + cs_pin_required=True, default_mode="MODE0", default_data_rate="1MHz" + ) + ) +) + + +# Code generation +async def to_code(config: ConfigType) -> None: + var = await new_motion_component(config) + await spi.register_spi_device(var, config) + + cg.add(var.set_accel_range(config[CONF_ACCELEROMETER_RANGE])) + cg.add(var.set_gyro_range(config[CONF_GYROSCOPE_RANGE])) diff --git a/esphome/components/icm20689/sensor.py b/esphome/components/icm20689/sensor.py new file mode 100644 index 00000000000..7cd5dedba07 --- /dev/null +++ b/esphome/components/icm20689/sensor.py @@ -0,0 +1,42 @@ +# YAML config keys +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_TEMPERATURE, + CONF_TYPE, + DEVICE_CLASS_TEMPERATURE, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +from . import CONF_ICM20689_ID, ICM20689Component + +AUTO_LOAD = ["icm20689"] + +CONFIG_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_TEMPERATURE, +).extend( + { + cv.Optional(CONF_TYPE): cv.one_of(CONF_TEMPERATURE), + cv.GenerateID(CONF_ICM20689_ID): cv.use_id(ICM20689Component), + } +) + + +async def to_code(config: ConfigType) -> None: + var = await sensor.new_sensor(config) + parent = await cg.get_variable(config[CONF_ICM20689_ID]) + data = MockObj("data") + value_lambda = await cg.process_lambda( + var.publish_state(data), + [(cg.float_, str(data))], + ) + cg.add(parent.add_temperature_listener(value_lambda)) diff --git a/tests/components/icm20689/common.yaml b/tests/components/icm20689/common.yaml new file mode 100644 index 00000000000..522a0a2bbf7 --- /dev/null +++ b/tests/components/icm20689/common.yaml @@ -0,0 +1,73 @@ +sensor: + - platform: icm20689 + type: temperature + name: "ICM20689 Temperature" + + - platform: motion + motion_id: icm20689_motion + type: acceleration_x + name: "ICM20689 Accel X" + accuracy_decimals: 4 + filters: + - sliding_window_moving_average: + window_size: 4 + send_every: 1 + - platform: motion + motion_id: icm20689_motion + type: acceleration_y + name: "ICM20689 Accel Y" + accuracy_decimals: 4 + - platform: motion + motion_id: icm20689_motion + type: acceleration_z + name: "ICM20689 Accel Z" + accuracy_decimals: 4 + + # Gyroscope axes (unit: °/s) + - platform: motion + motion_id: icm20689_motion + type: gyroscope_x + name: "ICM20689 Gyro X" + - platform: motion + motion_id: icm20689_motion + type: gyroscope_y + name: "ICM20689 Gyro Y" + - platform: motion + motion_id: icm20689_motion + type: gyroscope_z + name: "ICM20689 Gyro Z" + + - platform: motion + motion_id: icm20689_motion + type: angular_rate_x + name: "ICM20689 Angular Rate X" + - platform: motion + motion_id: icm20689_motion + type: angular_rate_y + name: "ICM20689 Angular Rate Y" + - platform: motion + motion_id: icm20689_motion + type: angular_rate_z + name: "ICM20689 Angular Rate Z" + + - platform: motion + motion_id: icm20689_motion + type: pitch + name: "ICM20689 Pitch" + - platform: motion + motion_id: icm20689_motion + type: roll + name: "ICM20689 Roll" + +motion: + - platform: icm20689 + id: icm20689_motion + cs_pin: GPIO5 + # Accelerometer full-scale range: 2G | 4G | 8G | 16G + accelerometer_range: 4G + # Gyroscope full-scale range: 250DPS | 500DPS | 1000DPS | 2000DPS + gyroscope_range: 500DPS + axis_map: + x: y + y: x + z: -z diff --git a/tests/components/icm20689/test.esp32-idf.yaml b/tests/components/icm20689/test.esp32-idf.yaml new file mode 100644 index 00000000000..0c9ea68ef00 --- /dev/null +++ b/tests/components/icm20689/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + icm20689: !include common.yaml diff --git a/tests/components/icm20689/test.esp8266-ard.yaml b/tests/components/icm20689/test.esp8266-ard.yaml new file mode 100644 index 00000000000..ab217e25fb8 --- /dev/null +++ b/tests/components/icm20689/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + icm20689: !include common.yaml diff --git a/tests/components/icm20689/test.rp2040-ard.yaml b/tests/components/icm20689/test.rp2040-ard.yaml new file mode 100644 index 00000000000..fc5a73cf62d --- /dev/null +++ b/tests/components/icm20689/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/rp2040-ard.yaml + icm20689: !include common.yaml From 40271fb59768552597151e14b5f2a340a810e3a8 Mon Sep 17 00:00:00 2001 From: Ryan Date: Wed, 14 Oct 2026 05:10:36 +1100 Subject: [PATCH 012/142] [haier_tundra] New Climate IR component (#20469) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 3 + esphome/components/haier_tundra/__init__.py | 2 + .../haier_tundra/button/__init__.py | 43 +++ .../button/haier_tundra_button.cpp | 18 ++ .../haier_tundra/button/haier_tundra_button.h | 25 ++ esphome/components/haier_tundra/climate.py | 51 ++++ .../components/haier_tundra/haier_tundra.cpp | 260 ++++++++++++++++++ .../components/haier_tundra/haier_tundra.h | 139 ++++++++++ .../haier_tundra/switch/__init__.py | 60 ++++ .../switch/haier_tundra_switch.cpp | 43 +++ .../haier_tundra/switch/haier_tundra_switch.h | 28 ++ tests/components/haier_tundra/common.yaml | 45 +++ .../haier_tundra/test.esp32-idf.yaml | 3 + .../haier_tundra/test.esp8266-ard.yaml | 3 + 14 files changed, 723 insertions(+) create mode 100644 esphome/components/haier_tundra/__init__.py create mode 100644 esphome/components/haier_tundra/button/__init__.py create mode 100644 esphome/components/haier_tundra/button/haier_tundra_button.cpp create mode 100644 esphome/components/haier_tundra/button/haier_tundra_button.h create mode 100644 esphome/components/haier_tundra/climate.py create mode 100644 esphome/components/haier_tundra/haier_tundra.cpp create mode 100644 esphome/components/haier_tundra/haier_tundra.h create mode 100644 esphome/components/haier_tundra/switch/__init__.py create mode 100644 esphome/components/haier_tundra/switch/haier_tundra_switch.cpp create mode 100644 esphome/components/haier_tundra/switch/haier_tundra_switch.h create mode 100644 tests/components/haier_tundra/common.yaml create mode 100644 tests/components/haier_tundra/test.esp32-idf.yaml create mode 100644 tests/components/haier_tundra/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index fc53a28c0b3..a770810490b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -243,6 +243,9 @@ esphome/components/haier/button/* @paveldn esphome/components/haier/sensor/* @paveldn esphome/components/haier/switch/* @paveldn esphome/components/haier/text_sensor/* @paveldn +esphome/components/haier_tundra/* @fauxpark +esphome/components/haier_tundra/button/* @fauxpark +esphome/components/haier_tundra/switch/* @fauxpark esphome/components/havells_solar/* @sourabhjaiswal esphome/components/hbridge/fan/* @WeekendWarrior esphome/components/hbridge/light/* @DotNetDann diff --git a/esphome/components/haier_tundra/__init__.py b/esphome/components/haier_tundra/__init__.py new file mode 100644 index 00000000000..d74ae6947f2 --- /dev/null +++ b/esphome/components/haier_tundra/__init__.py @@ -0,0 +1,2 @@ +DOMAIN = "haier_tundra" +CODEOWNERS = ["@fauxpark"] diff --git a/esphome/components/haier_tundra/button/__init__.py b/esphome/components/haier_tundra/button/__init__.py new file mode 100644 index 00000000000..2e919b80099 --- /dev/null +++ b/esphome/components/haier_tundra/button/__init__.py @@ -0,0 +1,43 @@ +import esphome.codegen as cg +from esphome.components import button +import esphome.config_validation as cv +from esphome.const import CONF_LIGHT +from esphome.types import ConfigType + +from ..climate import HaierTundra, haier_tundra_ns + +CODEOWNERS = ["@fauxpark"] + +HaierTundraButton = haier_tundra_ns.class_( + "HaierTundraButton", button.Button, cg.Component +) +ButtonKind = haier_tundra_ns.enum("ButtonKind", is_class=True) + +ICON_LED_OUTLINE = "mdi:led-outline" +ICON_SPRAY_BOTTLE = "mdi:spray-bottle" + +CONF_HAIER_TUNDRA_ID = "haier_tundra_id" +CONF_SELF_CLEAN = "self_clean" + +CONFIG_SCHEMA = cv.Schema( + { + cv.Required(CONF_HAIER_TUNDRA_ID): cv.use_id(HaierTundra), + cv.Optional(CONF_LIGHT): button.button_schema( + HaierTundraButton, icon=ICON_LED_OUTLINE + ), + cv.Optional(CONF_SELF_CLEAN): button.button_schema( + HaierTundraButton, icon=ICON_SPRAY_BOTTLE + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_HAIER_TUNDRA_ID]) + for key, kind in ( + (CONF_LIGHT, ButtonKind.BUTTON_KIND_LIGHT), + (CONF_SELF_CLEAN, ButtonKind.BUTTON_KIND_SELF_CLEAN), + ): + if (conf := config.get(key)) is not None: + var = await button.new_button(conf, hub, kind) + await cg.register_component(var, conf) diff --git a/esphome/components/haier_tundra/button/haier_tundra_button.cpp b/esphome/components/haier_tundra/button/haier_tundra_button.cpp new file mode 100644 index 00000000000..39246530bc3 --- /dev/null +++ b/esphome/components/haier_tundra/button/haier_tundra_button.cpp @@ -0,0 +1,18 @@ +#include "haier_tundra_button.h" +#include "esphome/core/log.h" + +namespace esphome::haier_tundra { + +ESPHOME_LOG_TAG(TAG, "haier_tundra.button"); + +void HaierTundraButton::dump_config() { LOG_BUTTON("", "Haier Tundra Button", this); } + +void HaierTundraButton::press_action() { + if (this->kind_ == ButtonKind::BUTTON_KIND_LIGHT) { + this->parent_->toggle_light(); + } else { + this->parent_->start_self_clean(); + } +} + +} // namespace esphome::haier_tundra diff --git a/esphome/components/haier_tundra/button/haier_tundra_button.h b/esphome/components/haier_tundra/button/haier_tundra_button.h new file mode 100644 index 00000000000..c4888a4616a --- /dev/null +++ b/esphome/components/haier_tundra/button/haier_tundra_button.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/button/button.h" +#include "esphome/components/haier_tundra/haier_tundra.h" + +namespace esphome::haier_tundra { + +enum class ButtonKind : uint8_t { + BUTTON_KIND_LIGHT, + BUTTON_KIND_SELF_CLEAN, +}; + +class HaierTundraButton final : public button::Button, public Component, public Parented { + public: + HaierTundraButton(HaierTundra *parent, ButtonKind kind) : Parented(parent), kind_(kind) {} + void dump_config() override; + + protected: + void press_action() override; + + ButtonKind kind_; +}; + +} // namespace esphome::haier_tundra diff --git a/esphome/components/haier_tundra/climate.py b/esphome/components/haier_tundra/climate.py new file mode 100644 index 00000000000..c992d14bfed --- /dev/null +++ b/esphome/components/haier_tundra/climate.py @@ -0,0 +1,51 @@ +import esphome.codegen as cg +from esphome.components import climate_ir, remote_base +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_PLATFORM +import esphome.final_validate as fv +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] + +haier_tundra_ns = cg.esphome_ns.namespace("haier_tundra") +HaierTundra = haier_tundra_ns.class_("HaierTundra", climate_ir.ClimateIR) + +CONF_UNIT = "unit" +Unit = haier_tundra_ns.enum("Unit") +UNITS = { + "a": Unit.HAIER_HEADER_UNIT_A, + "b": Unit.HAIER_HEADER_UNIT_B, +} + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(HaierTundra).extend( + { + cv.Optional(CONF_UNIT, default="a"): cv.enum(UNITS, lower=True), + } +) + + +def _final_validate_unit(config: ConfigType) -> None: + # Each transmitter may drive at most one unit A and one unit B + climates = fv.full_config.get()["climate"] + seen = set() + + for climate in climates: + if CONF_PLATFORM in climate and climate[CONF_PLATFORM] == "haier_tundra": + unit_tx_pair = ( + climate[CONF_UNIT], + climate[remote_base.CONF_TRANSMITTER_ID], + ) + + if unit_tx_pair in seen and climate[CONF_ID] == config[CONF_ID]: + raise cv.Invalid( + "Only one of each unit is allowed per transmitter", path=[CONF_UNIT] + ) + seen.add(unit_tx_pair) + + +FINAL_VALIDATE_SCHEMA = _final_validate_unit + + +async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("haier") + await climate_ir.new_climate_ir(config, config[CONF_UNIT]) diff --git a/esphome/components/haier_tundra/haier_tundra.cpp b/esphome/components/haier_tundra/haier_tundra.cpp new file mode 100644 index 00000000000..7a0b88d3aac --- /dev/null +++ b/esphome/components/haier_tundra/haier_tundra.cpp @@ -0,0 +1,260 @@ +#include "haier_tundra.h" + +namespace esphome::haier_tundra { + +// Number of bytes in decoded packet sans checksum +static constexpr uint8_t HAIER_TUNDRA_FRAME_SIZE = 13; + +void HaierTundra::transmit_state() { + // Button value does not seem to matter too much except + // when toggling light, as it has no bit flag of its own + // So in the default case we can simply set it to POWER + this->transmit_(HAIER_BUTTON_POWER); +} + +void HaierTundra::transmit_(uint8_t button) { + // Force Quiet and Turbo off if we're not in Cool or Heat mode + if (this->mode != climate::CLIMATE_MODE_COOL && this->mode != climate::CLIMATE_MODE_HEAT) { +#ifdef USE_SWITCH + if (this->quiet_mode_switch_ != nullptr) { + this->quiet_mode_switch_->publish_state(false); + } + if (this->turbo_mode_switch_ != nullptr) { + this->turbo_mode_switch_->publish_state(false); + } +#endif + + this->quiet_mode_ = false; + this->turbo_mode_ = false; + } + + remote_base::HaierData remote_state{{ + this->unit_, + static_cast(this->temperature_() << 4 | this->vertical_swing_()), + static_cast(this->horizontal_swing_() << 5), + static_cast(this->health_() << 1), + static_cast(this->power_() << 6), + static_cast(this->fan_speed_() << 5), + static_cast(this->quiet_() << 7 | this->turbo_() << 6), + static_cast(this->operation_mode_() << 5), + 0x00, + 0x00, + static_cast((button == HAIER_BUTTON_SELF_CLEAN ? 1 : 0) << 4), + 0x00, + button, + }}; + + auto transmit = this->transmitter_->transmit(); + remote_base::HaierProtocol().encode(transmit.get_data(), remote_state); + transmit.perform(); +} + +uint8_t HaierTundra::temperature_() { + return (uint8_t) roundf(clamp(this->target_temperature, HAIER_TEMP_MIN, HAIER_TEMP_MAX)) - HAIER_TEMP_MIN; +} + +uint8_t HaierTundra::vertical_swing_() { + if (this->mode == climate::CLIMATE_MODE_OFF) { + return HAIER_SWING_V_OFF; + } + switch (this->swing_mode) { + case climate::CLIMATE_SWING_VERTICAL: + case climate::CLIMATE_SWING_BOTH: + return HAIER_SWING_V_AUTO; + default: + if (this->mode == climate::CLIMATE_MODE_HEAT) { + return HAIER_SWING_V_1BOT; + } + return HAIER_SWING_V_1TOP; // Cool/Dry/Auto + } +} + +uint8_t HaierTundra::horizontal_swing_() { + switch (this->swing_mode) { + case climate::CLIMATE_SWING_HORIZONTAL: + case climate::CLIMATE_SWING_BOTH: + return HAIER_SWING_H_AUTO; + default: + return HAIER_SWING_H_MIDDLE; + } +} + +uint8_t HaierTundra::health_() { return this->health_mode_ ? 1 : 0; } + +uint8_t HaierTundra::power_() { return this->mode == climate::CLIMATE_MODE_OFF ? 0 : 1; } + +uint8_t HaierTundra::fan_speed_() { + switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) { + case climate::CLIMATE_FAN_LOW: + return HAIER_FAN_LOW; + case climate::CLIMATE_FAN_MEDIUM: + return HAIER_FAN_MED; + case climate::CLIMATE_FAN_HIGH: + return HAIER_FAN_HIGH; + case climate::CLIMATE_FAN_AUTO: + default: + return HAIER_FAN_AUTO; + } +} + +uint8_t HaierTundra::quiet_() { return this->quiet_mode_ ? 1 : 0; } + +uint8_t HaierTundra::turbo_() { return this->turbo_mode_ ? 1 : 0; } + +uint8_t HaierTundra::operation_mode_() { + switch (this->mode) { + case climate::CLIMATE_MODE_COOL: + return HAIER_MODE_COOL; + case climate::CLIMATE_MODE_DRY: + return HAIER_MODE_DRY; + case climate::CLIMATE_MODE_HEAT: + return HAIER_MODE_HEAT; + case climate::CLIMATE_MODE_FAN_ONLY: + return HAIER_MODE_FAN; + case climate::CLIMATE_MODE_HEAT_COOL: + default: + return HAIER_MODE_AUTO; + } +} + +bool HaierTundra::on_receive(remote_base::RemoteReceiveData data) { + auto haier = remote_base::HaierProtocol().decode(data); + if (haier.has_value()) { + return this->on_haier_(*haier); + } + return false; +} + +bool HaierTundra::on_haier_(const remote_base::HaierData &haier) { + // Verify frame length and header + if (haier.data.size() != HAIER_TUNDRA_FRAME_SIZE || haier.data[0] != this->unit_) { + return false; + } + + this->target_temperature = this->get_temperature_(haier.data[1] >> 4); + this->swing_mode = this->get_swing_mode_(haier.data[1] & 0xF, haier.data[2] >> 5); + this->health_mode_ = haier.data[3] & 0x2; + this->mode = this->get_operation_mode_(haier.data[4] & 0x40, haier.data[7] >> 5); + this->fan_mode = this->get_fan_speed_(haier.data[5] >> 5); + this->quiet_mode_ = haier.data[6] & 0x80; + this->turbo_mode_ = haier.data[6] & 0x40; + +#ifdef USE_SWITCH + if (this->health_mode_switch_ != nullptr) { + this->health_mode_switch_->publish_state(this->health_mode_); + } + if (this->quiet_mode_switch_ != nullptr) { + this->quiet_mode_switch_->publish_state(this->quiet_mode_); + } + if (this->turbo_mode_switch_ != nullptr) { + this->turbo_mode_switch_->publish_state(this->turbo_mode_); + } +#endif + + this->publish_state(); + + return true; +} + +float HaierTundra::get_temperature_(uint8_t temp) { return temp + HAIER_TEMP_MIN; } + +climate::ClimateSwingMode HaierTundra::get_swing_mode_(uint8_t swing_v, uint8_t swing_h) { + if (swing_v == HAIER_SWING_V_AUTO) { + if (swing_h == HAIER_SWING_H_AUTO) { + return climate::CLIMATE_SWING_BOTH; + } + + return climate::CLIMATE_SWING_VERTICAL; + } else if (swing_h == HAIER_SWING_H_AUTO) { + return climate::CLIMATE_SWING_HORIZONTAL; + } + + return climate::CLIMATE_SWING_OFF; +} + +climate::ClimateMode HaierTundra::get_operation_mode_(uint8_t power, uint8_t mode) { + if (!power) { + return climate::CLIMATE_MODE_OFF; + } + + switch (mode) { + case HAIER_MODE_COOL: + return climate::CLIMATE_MODE_COOL; + case HAIER_MODE_DRY: + return climate::CLIMATE_MODE_DRY; + case HAIER_MODE_HEAT: + return climate::CLIMATE_MODE_HEAT; + case HAIER_MODE_FAN: + return climate::CLIMATE_MODE_FAN_ONLY; + case HAIER_MODE_AUTO: + default: + return climate::CLIMATE_MODE_HEAT_COOL; + } +} + +climate::ClimateFanMode HaierTundra::get_fan_speed_(uint8_t fan) { + switch (fan) { + case HAIER_FAN_LOW: + return climate::CLIMATE_FAN_LOW; + case HAIER_FAN_MED: + return climate::CLIMATE_FAN_MEDIUM; + case HAIER_FAN_HIGH: + return climate::CLIMATE_FAN_HIGH; + case HAIER_FAN_AUTO: + default: + return climate::CLIMATE_FAN_AUTO; + } +} + +void HaierTundra::set_health_mode(bool health, bool transmit) { + this->health_mode_ = health; + + if (transmit && this->mode != climate::CLIMATE_MODE_OFF) { + this->transmit_(HAIER_BUTTON_HEALTH); + } +} + +void HaierTundra::set_quiet_mode(bool quiet, bool transmit) { + // Quiet & Turbo cannot be enabled at the same time + if (quiet) { +#ifdef USE_SWITCH + if (this->turbo_mode_switch_ != nullptr) { + this->turbo_mode_switch_->publish_state(false); + } +#endif + this->turbo_mode_ = false; + } + + this->quiet_mode_ = quiet; + if (transmit) + this->transmit_(HAIER_BUTTON_TURBO_QUIET); +} + +void HaierTundra::set_turbo_mode(bool turbo, bool transmit) { + // Quiet & Turbo cannot be enabled at the same time + if (turbo) { +#ifdef USE_SWITCH + if (this->quiet_mode_switch_ != nullptr) { + this->quiet_mode_switch_->publish_state(false); + } +#endif + this->quiet_mode_ = false; + } + + this->turbo_mode_ = turbo; + if (transmit) + this->transmit_(HAIER_BUTTON_TURBO_QUIET); +} + +void HaierTundra::toggle_light() { + if (this->mode != climate::CLIMATE_MODE_OFF) { + this->transmit_(HAIER_BUTTON_LIGHT); + } +} + +void HaierTundra::start_self_clean() { + // Can be started when off + this->transmit_(HAIER_BUTTON_SELF_CLEAN); +} + +} // namespace esphome::haier_tundra diff --git a/esphome/components/haier_tundra/haier_tundra.h b/esphome/components/haier_tundra/haier_tundra.h new file mode 100644 index 00000000000..b2bb1e51ce9 --- /dev/null +++ b/esphome/components/haier_tundra/haier_tundra.h @@ -0,0 +1,139 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" +#include "esphome/components/remote_base/haier_protocol.h" + +#ifdef USE_SWITCH +#include "esphome/components/switch/switch.h" +#endif + +namespace esphome::haier_tundra { + +// Byte 0 - Header value (Unit ID) +enum Unit : uint8_t { + HAIER_HEADER_UNIT_B = 0x59, + HAIER_HEADER_UNIT_A = 0xA6, +}; + +// Byte 1[7:4] - Temperature (˚C) 0x0..0xE +static constexpr uint8_t HAIER_TEMP_MIN = 16; +static constexpr uint8_t HAIER_TEMP_MAX = 30; + +// Byte 1[3:0] - Vertical Swing +static constexpr uint8_t HAIER_SWING_V_OFF = 0x0; // Power off value; from Auto, stops at current position +static constexpr uint8_t HAIER_SWING_V_2TOP = 0x1; // [|/ ] +static constexpr uint8_t HAIER_SWING_V_1TOP = 0x2; // [| ] (Not available in Heat mode) +static constexpr uint8_t HAIER_SWING_V_2BOT = 0x3; // [ //] +static constexpr uint8_t HAIER_SWING_V_1BOT = 0xA; // [ /] (Heat mode only) +static constexpr uint8_t HAIER_SWING_V_AUTO = 0xC; + +// Byte 2[7:5] - Horizontal Swing +static constexpr uint8_t HAIER_SWING_H_MIDDLE = 0x0; // [ || ] +static constexpr uint8_t HAIER_SWING_H_LEFT = 0x3; // [/ ] +static constexpr uint8_t HAIER_SWING_H_MIDDLE_LEFT = 0x4; // [ | ] +static constexpr uint8_t HAIER_SWING_H_MIDDLE_RIGHT = 0x5; // [ | ] +static constexpr uint8_t HAIER_SWING_H_RIGHT = 0x6; // [ \] +static constexpr uint8_t HAIER_SWING_H_AUTO = 0x7; // [/||\] + +// Byte 3[7:5] - Timer Mode +static constexpr uint8_t HAIER_TIMER_MODE_DISABLED = 0x0; +static constexpr uint8_t HAIER_TIMER_MODE_OFF = 0x1; // OFF +static constexpr uint8_t HAIER_TIMER_MODE_ON = 0x2; // ON +static constexpr uint8_t HAIER_TIMER_MODE_ON_OFF = 0x4; // ON → OFF +static constexpr uint8_t HAIER_TIMER_MODE_OFF_ON = 0x5; // ON ← OFF + +// Byte 3[1] - Health +// Byte 4[6] - Power + +// Byte 5[7:5] - Fan Speed +static constexpr uint8_t HAIER_FAN_HIGH = 0x1; +static constexpr uint8_t HAIER_FAN_MED = 0x2; +static constexpr uint8_t HAIER_FAN_LOW = 0x3; +static constexpr uint8_t HAIER_FAN_AUTO = 0x5; + +// Byte 5[4:0] - Timer Off Hours (0-23) 0x00..0x17 +// Byte 6[7] - Quiet Mode +// Byte 6[6] - Turbo Mode +// Byte 6[5:0] - Timer Off Minutes (0-59) 0x00..0x3B + +// Byte 7[7:5] - Mode +static constexpr uint8_t HAIER_MODE_AUTO = 0x0; +static constexpr uint8_t HAIER_MODE_COOL = 0x1; +static constexpr uint8_t HAIER_MODE_DRY = 0x2; +static constexpr uint8_t HAIER_MODE_HEAT = 0x4; +static constexpr uint8_t HAIER_MODE_FAN = 0x6; + +// Byte 7[4:0] - Timer On Hours (0-23) 0x00..0x17 +// Byte 8[7] - Sleep Mode +// Byte 8[5:0] - Timer On Minutes (0-59) 0x00..0x3B +// Byte 10[4] - Self Clean + +// Byte 12 - Button +static constexpr uint8_t HAIER_BUTTON_TEMP_UP = 0x00; +static constexpr uint8_t HAIER_BUTTON_TEMP_DOWN = 0x01; +static constexpr uint8_t HAIER_BUTTON_SWING_V = 0x02; +static constexpr uint8_t HAIER_BUTTON_SWING_H = 0x03; +static constexpr uint8_t HAIER_BUTTON_FAN_SPEED = 0x04; +static constexpr uint8_t HAIER_BUTTON_POWER = 0x05; +static constexpr uint8_t HAIER_BUTTON_MODE = 0x06; +static constexpr uint8_t HAIER_BUTTON_HEALTH = 0x07; +static constexpr uint8_t HAIER_BUTTON_TURBO_QUIET = 0x08; +static constexpr uint8_t HAIER_BUTTON_SLEEP = 0x0B; +static constexpr uint8_t HAIER_BUTTON_TIMER = 0x10; +static constexpr uint8_t HAIER_BUTTON_LIGHT = 0x15; +static constexpr uint8_t HAIER_BUTTON_SELF_CLEAN = 0x19; + +class HaierTundra final : public climate_ir::ClimateIR { + public: + explicit HaierTundra(Unit unit) + : climate_ir::ClimateIR(HAIER_TEMP_MIN, HAIER_TEMP_MAX, 1.0f, true, true, + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL, + climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}), + unit_(unit) {} + +#ifdef USE_SWITCH + SUB_SWITCH(health_mode); + SUB_SWITCH(quiet_mode); + SUB_SWITCH(turbo_mode); +#endif + + void set_health_mode(bool health, bool transmit = true); + void set_quiet_mode(bool quiet, bool transmit = true); + void set_turbo_mode(bool turbo, bool transmit = true); + + void toggle_light(); + void start_self_clean(); + + protected: + // Transmit via IR the state of this climate controller. + void transmit_state() override; + + void transmit_(uint8_t button); + uint8_t temperature_(); + uint8_t vertical_swing_(); + uint8_t horizontal_swing_(); + uint8_t health_(); + uint8_t power_(); + uint8_t fan_speed_(); + uint8_t quiet_(); + uint8_t turbo_(); + uint8_t operation_mode_(); + + // Handle received IR Buffer + bool on_receive(remote_base::RemoteReceiveData data) override; + bool on_haier_(const remote_base::HaierData &haier); + + float get_temperature_(uint8_t temp); + climate::ClimateSwingMode get_swing_mode_(uint8_t swing_v, uint8_t swing_h); + climate::ClimateMode get_operation_mode_(uint8_t power, uint8_t mode); + climate::ClimateFanMode get_fan_speed_(uint8_t fan); + + Unit unit_; + bool health_mode_{false}; + bool quiet_mode_{false}; + bool turbo_mode_{false}; +}; + +} // namespace esphome::haier_tundra diff --git a/esphome/components/haier_tundra/switch/__init__.py b/esphome/components/haier_tundra/switch/__init__.py new file mode 100644 index 00000000000..f62769c1350 --- /dev/null +++ b/esphome/components/haier_tundra/switch/__init__.py @@ -0,0 +1,60 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_SWITCH, ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from ..climate import HaierTundra, haier_tundra_ns + +CODEOWNERS = ["@fauxpark"] + +HaierTundraSwitch = haier_tundra_ns.class_( + "HaierTundraSwitch", switch.Switch, cg.Component +) +SwitchKind = haier_tundra_ns.enum("SwitchKind", is_class=True) + +ICON_LEAF = "mdi:leaf" +ICON_ARROW_DOWN_RIGHT = "mdi:arrow-down-right" +ICON_ARROW_UP_RIGHT_BOLD = "mdi:arrow-up-right-bold" + +CONF_HAIER_TUNDRA_ID = "haier_tundra_id" +CONF_HEALTH_MODE = "health_mode" +CONF_QUIET_MODE = "quiet_mode" +CONF_TURBO_MODE = "turbo_mode" + + +def _switch_schema(icon: str) -> cv.Schema: + return switch.switch_schema( + HaierTundraSwitch, + icon=icon, + default_restore_mode="RESTORE_DEFAULT_OFF", + device_class=DEVICE_CLASS_SWITCH, + entity_category=ENTITY_CATEGORY_CONFIG, + ) + + +CONFIG_SCHEMA = cv.Schema( + { + cv.Required(CONF_HAIER_TUNDRA_ID): cv.use_id(HaierTundra), + cv.Optional(CONF_HEALTH_MODE): _switch_schema(ICON_LEAF), + cv.Optional(CONF_QUIET_MODE): _switch_schema(ICON_ARROW_DOWN_RIGHT), + cv.Optional(CONF_TURBO_MODE): _switch_schema(ICON_ARROW_UP_RIGHT_BOLD), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_HAIER_TUNDRA_ID]) + switches = switch.sub_switches(config) + if sw := await switches( + CONF_HEALTH_MODE, hub.set_health_mode_switch, hub, SwitchKind.SWITCH_KIND_HEALTH + ): + await cg.register_component(sw, config[CONF_HEALTH_MODE]) + if sw := await switches( + CONF_QUIET_MODE, hub.set_quiet_mode_switch, hub, SwitchKind.SWITCH_KIND_QUIET + ): + await cg.register_component(sw, config[CONF_QUIET_MODE]) + if sw := await switches( + CONF_TURBO_MODE, hub.set_turbo_mode_switch, hub, SwitchKind.SWITCH_KIND_TURBO + ): + await cg.register_component(sw, config[CONF_TURBO_MODE]) diff --git a/esphome/components/haier_tundra/switch/haier_tundra_switch.cpp b/esphome/components/haier_tundra/switch/haier_tundra_switch.cpp new file mode 100644 index 00000000000..e24ada79b25 --- /dev/null +++ b/esphome/components/haier_tundra/switch/haier_tundra_switch.cpp @@ -0,0 +1,43 @@ +#include "haier_tundra_switch.h" +#include "esphome/core/log.h" + +namespace esphome::haier_tundra { + +ESPHOME_LOG_TAG(TAG, "haier_tundra.switch"); + +void HaierTundraSwitch::setup() { + auto initial = this->get_initial_state_with_restore_mode(); + if (initial.has_value()) { + const bool state = initial.value(); + this->apply_(state, false); + this->publish_state(state); + } +} + +void HaierTundraSwitch::dump_config() { LOG_SWITCH("", "Haier Tundra Switch", this); } + +void HaierTundraSwitch::write_state(bool state) { + // Quiet and Turbo can only be turned on in Cool or Heat mode + if (state && this->kind_ != SwitchKind::SWITCH_KIND_HEALTH && this->parent_->mode != climate::CLIMATE_MODE_COOL && + this->parent_->mode != climate::CLIMATE_MODE_HEAT) { + return; + } + this->apply_(state, true); + this->publish_state(state); +} + +void HaierTundraSwitch::apply_(bool state, bool transmit) { + switch (this->kind_) { + case SwitchKind::SWITCH_KIND_HEALTH: + this->parent_->set_health_mode(state, transmit); + break; + case SwitchKind::SWITCH_KIND_QUIET: + this->parent_->set_quiet_mode(state, transmit); + break; + case SwitchKind::SWITCH_KIND_TURBO: + this->parent_->set_turbo_mode(state, transmit); + break; + } +} + +} // namespace esphome::haier_tundra diff --git a/esphome/components/haier_tundra/switch/haier_tundra_switch.h b/esphome/components/haier_tundra/switch/haier_tundra_switch.h new file mode 100644 index 00000000000..d718b940a15 --- /dev/null +++ b/esphome/components/haier_tundra/switch/haier_tundra_switch.h @@ -0,0 +1,28 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/switch/switch.h" +#include "esphome/components/haier_tundra/haier_tundra.h" + +namespace esphome::haier_tundra { + +enum class SwitchKind : uint8_t { + SWITCH_KIND_HEALTH, + SWITCH_KIND_QUIET, + SWITCH_KIND_TURBO, +}; + +class HaierTundraSwitch final : public switch_::Switch, public Component, public Parented { + public: + HaierTundraSwitch(HaierTundra *parent, SwitchKind kind) : Parented(parent), kind_(kind) {} + void setup() override; + void dump_config() override; + + protected: + void write_state(bool state) override; + void apply_(bool state, bool transmit); + + SwitchKind kind_; +}; + +} // namespace esphome::haier_tundra diff --git a/tests/components/haier_tundra/common.yaml b/tests/components/haier_tundra/common.yaml new file mode 100644 index 00000000000..0388220cacd --- /dev/null +++ b/tests/components/haier_tundra/common.yaml @@ -0,0 +1,45 @@ +climate: + - platform: haier_tundra + name: Haier AC A + id: my_haier_ac_a + transmitter_id: xmitr + + - platform: haier_tundra + name: Haier AC B + id: my_haier_ac_b + transmitter_id: xmitr + unit: b + +switch: + - platform: haier_tundra + haier_tundra_id: my_haier_ac_a + quiet_mode: + name: "Quiet Mode (Unit A)" + turbo_mode: + name: "Turbo Mode (Unit A)" + health_mode: + name: "Health Mode (Unit A)" + + - platform: haier_tundra + haier_tundra_id: my_haier_ac_b + quiet_mode: + name: "Quiet Mode (Unit B)" + turbo_mode: + name: "Turbo Mode (Unit B)" + health_mode: + name: "Health Mode (Unit B)" + +button: + - platform: haier_tundra + haier_tundra_id: my_haier_ac_a + light: + name: "Toggle Light (Unit A)" + self_clean: + name: "Start Self Clean (Unit A)" + + - platform: haier_tundra + haier_tundra_id: my_haier_ac_b + light: + name: "Toggle Light (Unit B)" + self_clean: + name: "Start Self Clean (Unit B)" diff --git a/tests/components/haier_tundra/test.esp32-idf.yaml b/tests/components/haier_tundra/test.esp32-idf.yaml new file mode 100644 index 00000000000..b88eb95efe7 --- /dev/null +++ b/tests/components/haier_tundra/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + haier_tundra: !include common.yaml diff --git a/tests/components/haier_tundra/test.esp8266-ard.yaml b/tests/components/haier_tundra/test.esp8266-ard.yaml new file mode 100644 index 00000000000..888edbf140b --- /dev/null +++ b/tests/components/haier_tundra/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + haier_tundra: !include common.yaml From 5acbd44067417199f89c8c8d6abcbdd01de6b159 Mon Sep 17 00:00:00 2001 From: Ivan Kruglov <4630547+ikruglov@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:12:28 +0200 Subject: [PATCH 013/142] [pcf8574] Add reset option to preserve outputs across MCU reboots (#20589) Co-authored-by: J. Nick Koston --- esphome/components/pcf8574/__init__.py | 6 + esphome/components/pcf8574/pcf8574.cpp | 33 ++-- esphome/components/pcf8574/pcf8574.h | 11 +- tests/components/pcf8574/common.yaml | 32 ++++ tests/components/pcf8574/test_pcf8574.cpp | 189 ++++++++++++++++++++ tests/unit_tests/components/test_pcf8574.py | 35 ++++ 6 files changed, 290 insertions(+), 16 deletions(-) create mode 100644 tests/components/pcf8574/test_pcf8574.cpp create mode 100644 tests/unit_tests/components/test_pcf8574.py diff --git a/esphome/components/pcf8574/__init__.py b/esphome/components/pcf8574/__init__.py index 6c9ed3cd98b..5ede39d7145 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( CONF_MODE, CONF_NUMBER, CONF_OUTPUT, + CONF_RESET, ) from esphome.cpp_generator import MockObj from esphome.types import ConfigType @@ -26,11 +27,14 @@ PCF8574GPIOPin = pcf8574_ns.class_("PCF8574GPIOPin", cg.GPIOPin) CONF_PCF8574 = "pcf8574" CONF_PCF8575 = "pcf8575" +# Keep in sync with PCF8574Component::reset_ in pcf8574.h. +DEFAULT_RESET = True CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(PCF8574Component), cv.Optional(CONF_PCF8575, default=False): cv.boolean, + cv.Optional(CONF_RESET, default=DEFAULT_RESET): cv.boolean, cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, } ) @@ -44,6 +48,8 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) cg.add(var.set_pcf8575(config[CONF_PCF8575])) + if (reset := config[CONF_RESET]) != DEFAULT_RESET: + cg.add(var.set_reset(reset)) if interrupt_pin := config.get(CONF_INTERRUPT_PIN): cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) diff --git a/esphome/components/pcf8574/pcf8574.cpp b/esphome/components/pcf8574/pcf8574.cpp index 727e9a20265..ea1e894a530 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -12,8 +12,13 @@ void PCF8574Component::setup() { return; } - this->write_gpio_(); - this->read_gpio_(); + if (this->reset_) { + this->write_gpio_(); + this->read_gpio_(); + } else { + this->output_mask_ = this->input_mask_; + this->mode_mask_ = ~this->configured_input_pins_; + } if (this->interrupt_pin_ != nullptr) { this->interrupt_pin_->setup(); @@ -40,8 +45,9 @@ void PCF8574Component::loop() { void PCF8574Component::dump_config() { ESP_LOGCONFIG(TAG, "PCF8574:\n" - " Is PCF8575: %s", - YESNO(this->pcf8575_)); + " Is PCF8575: %s\n" + " Reset: %s", + YESNO(this->pcf8575_), YESNO(this->reset_)); LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) if (this->is_failed()) { @@ -82,20 +88,12 @@ void PCF8574Component::pin_mode(uint8_t pin, gpio::Flags flags) { bool PCF8574Component::read_gpio_() { if (this->is_failed()) return false; - bool success; - uint8_t data[2]; - if (this->pcf8575_) { - success = this->read_bytes_raw(data, 2); - this->input_mask_ = (uint16_t(data[1]) << 8) | (uint16_t(data[0]) << 0); - } else { - success = this->read_bytes_raw(data, 1); - this->input_mask_ = data[0]; - } - - if (!success) { + uint8_t data[2]{}; // Keep the bytes zero for the PCF8574's one-byte read. + if (!this->read_bytes_raw(data, this->pcf8575_ ? 2 : 1)) { this->status_set_warning(); return false; } + this->input_mask_ = (uint16_t(data[1]) << 8) | data[0]; this->status_clear_warning(); return true; } @@ -123,6 +121,11 @@ bool PCF8574Component::write_gpio_() { float PCF8574Component::get_setup_priority() const { return setup_priority::IO; } void PCF8574GPIOPin::setup() { pin_mode(flags_); } +void PCF8574GPIOPin::set_flags(gpio::Flags flags) { + this->flags_ = flags; + if (flags & gpio::FLAG_INPUT) + this->parent_->register_input_pin(this->pin_); +} void PCF8574GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } bool PCF8574GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } void PCF8574GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } diff --git a/esphome/components/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index 9879d6a47eb..465bbd7a918 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -16,6 +16,9 @@ class PCF8574Component final : public Component, PCF8574Component() = default; void set_pcf8575(bool pcf8575) { pcf8575_ = pcf8575; } + void set_reset(bool reset) { this->reset_ = reset; } + // Called by pin setters before any component's setup or GPIO writes. + void register_input_pin(uint8_t pin) { this->configured_input_pins_ |= 1 << pin; } void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } /// Check i2c availability and setup masks @@ -44,6 +47,11 @@ class PCF8574Component final : public Component, uint16_t output_mask_{0x00}; /// The state read in read_gpio_ - 1 means HIGH, 0 means LOW uint16_t input_mask_{0x00}; + // Pins configured as inputs before setup; 1 means input. With reset disabled, all other pins + // (including undeclared pins) are treated as outputs seeded from their observed levels. + uint16_t configured_input_pins_{0x00}; + // Keep in sync with DEFAULT_RESET in __init__.py; codegen omits the setter for the default. + bool reset_{true}; bool pcf8575_; ///< TRUE->16-channel PCF8575, FALSE->8-channel PCF8574 InternalGPIOPin *interrupt_pin_{nullptr}; }; @@ -63,7 +71,8 @@ class PCF8574GPIOPin final : public GPIOPin { void set_parent(PCF8574Component *parent) { parent_ = parent; } void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } - void set_flags(gpio::Flags flags) { flags_ = flags; } + // Call after set_parent() and set_pin(): input flags register the pin with the parent. + void set_flags(gpio::Flags flags); gpio::Flags get_flags() const override { return this->flags_; } diff --git a/tests/components/pcf8574/common.yaml b/tests/components/pcf8574/common.yaml index 8a26b930152..4613f4b8859 100644 --- a/tests/components/pcf8574/common.yaml +++ b/tests/components/pcf8574/common.yaml @@ -8,6 +8,16 @@ pcf8574: address: 0x22 pcf8575: false interrupt_pin: ${interrupt_pin} + reset: true + - id: pcf8574_preserved + i2c_id: i2c_bus + address: 0x23 + reset: false + - id: pcf8575_preserved + i2c_id: i2c_bus + address: 0x24 + pcf8575: true + reset: false binary_sensor: - platform: gpio @@ -18,6 +28,18 @@ binary_sensor: number: 1 mode: INPUT inverted: true + - platform: gpio + id: pcf8574_preserved_input + pin: + pcf8574: pcf8574_preserved + number: 1 + mode: INPUT + - platform: gpio + id: pcf8575_preserved_input + pin: + pcf8574: pcf8575_preserved + number: 15 + mode: INPUT output: - platform: gpio @@ -27,3 +49,13 @@ output: number: 0 mode: OUTPUT inverted: false + - platform: gpio + id: pcf8574_preserved_output + pin: + pcf8574: pcf8574_preserved + number: 0 + - platform: gpio + id: pcf8575_preserved_output + pin: + pcf8574: pcf8575_preserved + number: 8 diff --git a/tests/components/pcf8574/test_pcf8574.cpp b/tests/components/pcf8574/test_pcf8574.cpp new file mode 100644 index 00000000000..3500069ba10 --- /dev/null +++ b/tests/components/pcf8574/test_pcf8574.cpp @@ -0,0 +1,189 @@ +#include + +#include "esphome/components/pcf8574/pcf8574.h" + +namespace esphome::pcf8574::testing { + +// Model quasi-bidirectional readback separately from the output latch. +class PortBus : public i2c::I2CBus { + public: + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) override { + EXPECT_EQ(address, 0x21); + if (read_count != 0) { + EXPECT_EQ(read_count, this->width); + this->reads++; + if (this->fail_read) + return i2c::ERROR_TIMEOUT; + uint16_t levels = this->latch & ~this->external_low; + for (size_t i = 0; i < read_count; i++) + read_buffer[i] = levels >> (8 * i); + } + if (write_count != 0) { + EXPECT_EQ(write_count, this->width); + this->writes++; + this->latch = write_buffer[0]; + if (write_count == 2) + this->latch |= uint16_t(write_buffer[1]) << 8; + } + return i2c::ERROR_OK; + } + + size_t width{1}; + uint16_t latch{0}; + uint16_t external_low{0}; + unsigned reads{0}; + bool fail_read{false}; + unsigned writes{0}; +}; + +class PCF8574ResetTest : public ::testing::TestWithParam { + protected: + void SetUp() override { + this->bus_.width = GetParam() ? 2 : 1; + this->mask_ = GetParam() ? 0xFFFF : 0xFF; + this->high_pin_ = GetParam() ? 15 : 7; + this->bus_.latch = 0xA55A & this->mask_; + this->component_.set_i2c_bus(&this->bus_); + this->component_.set_i2c_address(0x21); + this->component_.set_pcf8575(GetParam()); + } + + void configure_input_(uint8_t number) { + PCF8574GPIOPin pin; + pin.set_parent(&this->component_); + pin.set_pin(number); + pin.set_flags(gpio::FLAG_INPUT); + EXPECT_EQ(this->bus_.writes, 0); + EXPECT_EQ(this->bus_.reads, 0); + } + + PortBus bus_; + PCF8574Component component_; + uint16_t mask_; + uint8_t high_pin_; +}; + +TEST_P(PCF8574ResetTest, DefaultResetRetainsStartupAndPinModeBehavior) { + this->configure_input_(1); + + this->component_.setup(); + ASSERT_EQ(this->bus_.writes, 1); + EXPECT_EQ(this->bus_.latch, this->mask_); + EXPECT_EQ(this->bus_.reads, 2); + + this->component_.pin_mode(0, gpio::FLAG_OUTPUT); + EXPECT_EQ(this->bus_.writes, 1); + + this->component_.digital_write(0, false); + EXPECT_EQ(this->bus_.latch, this->mask_ & ~1); +} + +TEST_P(PCF8574ResetTest, PreservesOtherOutputsBeforeAndAfterTheirPinSetup) { + this->component_.set_reset(false); + const uint16_t initial = this->bus_.latch; + + this->component_.setup(); + EXPECT_EQ(this->bus_.writes, 0); + EXPECT_EQ(this->bus_.reads, 1); + EXPECT_EQ(this->bus_.latch, initial); + + // Consumers can write before calling their GPIO pin's setup(). + this->component_.digital_write(0, true); + EXPECT_EQ(this->bus_.latch, initial | 1); + + this->component_.pin_mode(0, gpio::FLAG_OUTPUT); + this->component_.pin_mode(this->high_pin_, gpio::FLAG_OUTPUT); + const uint16_t high_pin_mask = 1 << this->high_pin_; + this->component_.digital_write(this->high_pin_, (initial & high_pin_mask) == 0); + const uint16_t expected = (initial | 1) ^ high_pin_mask; + EXPECT_EQ(this->bus_.latch, expected); + + this->component_.digital_write(0, false); + EXPECT_EQ(this->bus_.latch, expected & ~1); + ASSERT_EQ(this->bus_.writes, 3); +} + +TEST_P(PCF8574ResetTest, MixedInputsAreReleasedBeforeTheirConsumersInitialize) { + const uint16_t inputs = (1 << 1) | (1 << this->high_pin_); + this->bus_.latch |= inputs; + const uint16_t initial = this->bus_.latch; + this->bus_.external_low = inputs; + + this->component_.set_reset(false); + this->configure_input_(1); + this->configure_input_(this->high_pin_); + this->component_.setup(); + EXPECT_EQ(this->bus_.writes, 0); + + this->component_.digital_write(0, true); + EXPECT_EQ(this->bus_.latch, initial | 1); + + this->component_.pin_mode(1, gpio::FLAG_INPUT); + this->component_.pin_mode(this->high_pin_, gpio::FLAG_INPUT); + EXPECT_EQ(this->bus_.latch, initial | 1); + EXPECT_FALSE(this->component_.digital_read(1)); +} + +TEST_P(PCF8574ResetTest, InputSetupBeforeOutputSetupPreservesOutputs) { + this->component_.set_reset(false); + this->configure_input_(1); + const uint16_t initial = this->bus_.latch; + + this->component_.setup(); + this->component_.pin_mode(1, gpio::FLAG_INPUT); + EXPECT_EQ(this->bus_.latch, initial); + + this->component_.pin_mode(0, gpio::FLAG_OUTPUT); + this->component_.digital_write(0, true); + EXPECT_EQ(this->bus_.latch, initial | 1); +} + +TEST_P(PCF8574ResetTest, LoadedHighOutputIsLatchedLowOnFirstWrite) { + const uint16_t loaded_pin = 1 << this->high_pin_; + this->bus_.latch |= loaded_pin; + this->bus_.external_low = loaded_pin; + const uint16_t initial = this->bus_.latch; + this->component_.set_reset(false); + + this->component_.setup(); + this->component_.pin_mode(this->high_pin_, gpio::FLAG_OUTPUT); + EXPECT_EQ(this->bus_.writes, 0); + EXPECT_EQ(this->bus_.latch, initial); + + this->component_.digital_write(0, true); + EXPECT_EQ(this->bus_.latch, (initial | 1) & ~loaded_pin); +} + +TEST_P(PCF8574ResetTest, UndeclaredLowPinRemainsDrivenAfterExternalRelease) { + const uint16_t undeclared_pin = 1 << this->high_pin_; + this->bus_.latch |= undeclared_pin; + this->bus_.external_low = undeclared_pin; + const uint16_t initial = this->bus_.latch; + this->component_.set_reset(false); + + this->component_.setup(); + EXPECT_EQ(this->bus_.writes, 0); + EXPECT_EQ(this->bus_.latch, initial); + + this->component_.digital_write(0, true); + this->bus_.external_low = 0; + EXPECT_EQ(this->bus_.latch, (initial | 1) & ~undeclared_pin); + EXPECT_FALSE(this->component_.digital_read(this->high_pin_)); +} + +TEST_P(PCF8574ResetTest, InitialReadFailurePreventsAllWrites) { + this->bus_.fail_read = true; + + this->component_.set_reset(false); + this->component_.setup(); + EXPECT_TRUE(this->component_.is_failed()); + + this->component_.pin_mode(1, gpio::FLAG_INPUT); + this->component_.digital_write(0, true); + EXPECT_EQ(this->bus_.writes, 0); +} + +INSTANTIATE_TEST_SUITE_P(PCF8574AndPCF8575, PCF8574ResetTest, ::testing::Bool()); + +} // namespace esphome::pcf8574::testing diff --git a/tests/unit_tests/components/test_pcf8574.py b/tests/unit_tests/components/test_pcf8574.py new file mode 100644 index 00000000000..43751815358 --- /dev/null +++ b/tests/unit_tests/components/test_pcf8574.py @@ -0,0 +1,35 @@ +"""PCF8574 reset configuration and code generation.""" + +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from esphome.components import pcf8574 +from esphome.const import CONF_ID, CONF_RESET + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "options, expected", + [ + ({}, True), + ({"reset": True}, True), + ({"reset": False}, False), + ], +) +async def test_reset_configuration(options: dict, expected: bool) -> None: + config = pcf8574.CONFIG_SCHEMA({CONF_ID: "hub", **options}) + assert config[CONF_RESET] is expected + var = Mock() + with ( + patch.object(pcf8574.cg, "new_Pvariable", return_value=var), + patch.object(pcf8574.cg, "register_component", new_callable=AsyncMock), + patch.object(pcf8574.i2c, "register_i2c_device", new_callable=AsyncMock), + patch.object(pcf8574.cg, "add"), + ): + await pcf8574.to_code(config) + + if expected: + var.set_reset.assert_not_called() + else: + var.set_reset.assert_called_once_with(False) From d27ec8ec4966634d59dee9002bd631e71f71d458 Mon Sep 17 00:00:00 2001 From: n-IA-hane <49248235+n-IA-hane@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:13:26 +0200 Subject: [PATCH 014/142] [voice_assistant] Make TTS playback start timeout configurable (#18707) Co-authored-by: n-IA-hane --- .../components/voice_assistant/__init__.py | 16 ++++++- .../voice_assistant/voice_assistant.cpp | 2 +- .../voice_assistant/voice_assistant.h | 3 ++ .../voice_assistant/__init__.py | 0 .../voice_assistant/test_playback_timeout.py | 44 +++++++++++++++++++ .../voice_assistant/common-idf.yaml | 1 + tests/components/voice_assistant/common.yaml | 1 + .../components/voice_assistant/__init__.py | 0 .../test_tts_playback_timeout.py | 35 +++++++++++++++ 9 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/voice_assistant/__init__.py create mode 100644 tests/component_tests/voice_assistant/test_playback_timeout.py create mode 100644 tests/unit_tests/components/voice_assistant/__init__.py create mode 100644 tests/unit_tests/components/voice_assistant/test_tts_playback_timeout.py diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index d306947ec13..488e16a3839 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -1,4 +1,4 @@ -from esphome import automation +from esphome import automation, core import esphome.codegen as cg from esphome.components import media_player, micro_wake_word, microphone, speaker import esphome.config_validation as cv @@ -47,6 +47,9 @@ CONF_MICRO_WAKE_WORD = "micro_wake_word" CONF_WAKE_WORD = "wake_word" CONF_CONVERSATION_TIMEOUT = "conversation_timeout" +CONF_TTS_PLAYBACK_START_TIMEOUT = "tts_playback_start_timeout" +# Keep in sync with VoiceAssistant::tts_playback_start_timeout_ in voice_assistant.h. +TTS_PLAYBACK_START_TIMEOUT_DEFAULT = core.TimePeriod(milliseconds=2000) CONF_ON_TIMER_STARTED = "on_timer_started" CONF_ON_TIMER_UPDATED = "on_timer_updated" @@ -110,6 +113,13 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_CONVERSATION_TIMEOUT, default="300s" ): cv.positive_time_period_milliseconds, + cv.Optional( + CONF_TTS_PLAYBACK_START_TIMEOUT, + default=TTS_PLAYBACK_START_TIMEOUT_DEFAULT, + ): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(min=core.TimePeriod(milliseconds=1)), + ), cv.Optional(CONF_VOLUME_MULTIPLIER, default=1.0): cv.float_range( min=0.0, min_included=False ), @@ -218,6 +228,10 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_auto_gain(config[CONF_AUTO_GAIN])) cg.add(var.set_volume_multiplier(config[CONF_VOLUME_MULTIPLIER])) cg.add(var.set_conversation_timeout(config[CONF_CONVERSATION_TIMEOUT])) + if ( + timeout := config[CONF_TTS_PLAYBACK_START_TIMEOUT] + ) != TTS_PLAYBACK_START_TIMEOUT_DEFAULT: + cg.add(var.set_tts_playback_start_timeout(timeout)) if CONF_ON_LISTENING in config: await automation.build_automation( diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index 039410abdbf..1d66ca5950f 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -760,7 +760,7 @@ void VoiceAssistant::signal_stop_() { } void VoiceAssistant::start_playback_timeout_() { - this->set_timeout(PLAYBACK_TIMEOUT_ID, 2000, [this]() { + this->set_timeout(PLAYBACK_TIMEOUT_ID, this->tts_playback_start_timeout_, [this]() { this->cancel_timeout(SPEAKER_IDLE_TIMEOUT_ID); this->set_state_(State::RESPONSE_FINISHED, State::RESPONSE_FINISHED); diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index ae5eee3e227..139a1c84c38 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -193,6 +193,7 @@ class VoiceAssistant final : public Component { void set_auto_gain(uint8_t auto_gain) { this->auto_gain_ = auto_gain; } void set_volume_multiplier(float volume_multiplier) { this->volume_multiplier_ = volume_multiplier; } void set_conversation_timeout(uint32_t conversation_timeout) { this->conversation_timeout_ = conversation_timeout; } + void set_tts_playback_start_timeout(uint32_t timeout) { this->tts_playback_start_timeout_ = timeout; } void reset_conversation_id(); Trigger<> *get_intent_end_trigger() { return &this->intent_end_trigger_; } @@ -318,6 +319,8 @@ class VoiceAssistant final : public Component { float volume_multiplier_; uint32_t conversation_timeout_; + // Keep in sync with TTS_PLAYBACK_START_TIMEOUT_DEFAULT in __init__.py. + uint32_t tts_playback_start_timeout_{2000}; Configuration config_{}; diff --git a/tests/component_tests/voice_assistant/__init__.py b/tests/component_tests/voice_assistant/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/voice_assistant/test_playback_timeout.py b/tests/component_tests/voice_assistant/test_playback_timeout.py new file mode 100644 index 00000000000..cae93e18bf8 --- /dev/null +++ b/tests/component_tests/voice_assistant/test_playback_timeout.py @@ -0,0 +1,44 @@ +"""Test playback timeout C++ generation.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize("timeout", [None, "2s", "2000ms", "1ms", "10s"]) +def test_playback_timeout_codegen( + timeout: str | None, + tmp_path: Path, + generate_main: Callable[[Path], str], +) -> None: + config = tmp_path / "voice_assistant.yaml" + config.write_text( + """esphome: + name: test-voice-assistant +esp32: + board: esp32dev + framework: + type: esp-idf +wifi: + ssid: test-network +api: +i2s_audio: + i2s_lrclk_pin: GPIO25 + i2s_bclk_pin: GPIO26 +microphone: + - platform: i2s_audio + id: test_mic + adc_type: external + i2s_din_pin: GPIO34 +voice_assistant: + microphone: test_mic +""" + + (f" tts_playback_start_timeout: {timeout}\n" if timeout else "") + ) + main = generate_main(config) + if timeout in (None, "2s", "2000ms"): + assert "set_tts_playback_start_timeout(" not in main + else: + milliseconds = 1 if timeout == "1ms" else 10000 + assert f"set_tts_playback_start_timeout({milliseconds});" in main diff --git a/tests/components/voice_assistant/common-idf.yaml b/tests/components/voice_assistant/common-idf.yaml index 998ba90dd99..d08c76879e1 100644 --- a/tests/components/voice_assistant/common-idf.yaml +++ b/tests/components/voice_assistant/common-idf.yaml @@ -55,6 +55,7 @@ voice_assistant: speaker: va_speaker_id micro_wake_word: mww_id conversation_timeout: 60s + tts_playback_start_timeout: 10s on_listening: - logger.log: "Voice assistant microphone listening" on_start: diff --git a/tests/components/voice_assistant/common.yaml b/tests/components/voice_assistant/common.yaml index 5a5791899e2..df1fa50eac9 100644 --- a/tests/components/voice_assistant/common.yaml +++ b/tests/components/voice_assistant/common.yaml @@ -36,6 +36,7 @@ voice_assistant: channels: 0 speaker: va_speaker_id conversation_timeout: 60s + tts_playback_start_timeout: 10s on_listening: - logger.log: "Voice assistant microphone listening" on_start: diff --git a/tests/unit_tests/components/voice_assistant/__init__.py b/tests/unit_tests/components/voice_assistant/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit_tests/components/voice_assistant/test_tts_playback_timeout.py b/tests/unit_tests/components/voice_assistant/test_tts_playback_timeout.py new file mode 100644 index 00000000000..96f44a09e32 --- /dev/null +++ b/tests/unit_tests/components/voice_assistant/test_tts_playback_timeout.py @@ -0,0 +1,35 @@ +"""Validate Voice Assistant playback timeout configuration.""" + +import pytest + +from esphome.components import voice_assistant +import esphome.config_validation as cv +from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM +from esphome.core import CORE + + +@pytest.mark.parametrize( + "value", + ["0s", "0ms", {"seconds": 0}, "-1s", "500us", "1ns", "500ns", {"nanoseconds": 1}], +) +def test_playback_timeout_rejects_invalid_duration(value: str | dict[str, int]) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + with pytest.raises(cv.Invalid): + voice_assistant.CONFIG_SCHEMA({"tts_playback_start_timeout": value}) + + +@pytest.mark.parametrize( + "value, milliseconds", [("1ms", 1), ("2s", 2000), ("10s", 10000)] +) +def test_playback_timeout_accepts_positive_milliseconds( + value: str, milliseconds: int +) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = voice_assistant.CONFIG_SCHEMA({"tts_playback_start_timeout": value}) + assert config["tts_playback_start_timeout"].total_milliseconds == milliseconds + + +def test_playback_timeout_default_is_two_seconds() -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + config = voice_assistant.CONFIG_SCHEMA({}) + assert config["tts_playback_start_timeout"].total_milliseconds == 2000 From 1d8423edd802f8cb8de213c1020e1b36b769e3dc Mon Sep 17 00:00:00 2001 From: cociweb Date: Tue, 13 Oct 2026 20:14:43 +0200 Subject: [PATCH 015/142] [es8388] Add initial option configuration for DAC output and ADC input mic (#16342) Co-authored-by: J. Nick Koston --- esphome/components/es8388/es8388.cpp | 40 +++++++++++------ esphome/components/es8388/es8388.h | 15 ++++++- esphome/components/es8388/select/__init__.py | 46 ++++++++++++++++++-- esphome/core/defines.h | 1 + tests/components/es8388/common.yaml | 4 ++ 5 files changed, 87 insertions(+), 19 deletions(-) diff --git a/esphome/components/es8388/es8388.cpp b/esphome/components/es8388/es8388.cpp index b57949ccd0a..56ee1ac4a59 100644 --- a/esphome/components/es8388/es8388.cpp +++ b/esphome/components/es8388/es8388.cpp @@ -111,24 +111,36 @@ void ES8388::setup() { // Power on ADC, Enable LIN&RIN, Power off MICBIAS, set int1lp to low power mode ES8388_ERROR_FAILED(this->write_byte(ES8388_ADCPOWER, 0x09)); -#ifdef USE_SELECT +#ifdef USE_ES8388_SELECT if (this->dac_output_select_ != nullptr) { - auto dac_power = this->get_dac_power(); - if (dac_power.has_value()) { - if (this->dac_output_select_->has_index(dac_power.value())) { - this->dac_output_select_->publish_state(dac_power.value()); - } else { - ESP_LOGW(TAG, "Unknown DAC output power value: %d", dac_power.value()); + if (this->dac_output_initial_.has_value()) { + const DacOutputLine line = this->dac_output_initial_.value(); + ES8388_ERROR_FAILED(this->set_dac_output(line)); + this->dac_output_select_->publish_state(static_cast(line)); + } else { + auto dac_power = this->get_dac_power(); + if (dac_power.has_value()) { + if (this->dac_output_select_->has_index(dac_power.value())) { + this->dac_output_select_->publish_state(dac_power.value()); + } else { + ESP_LOGW(TAG, "Unknown DAC output power value: %d", dac_power.value()); + } } } } if (this->adc_input_mic_select_ != nullptr) { - auto mic_input = this->get_mic_input(); - if (mic_input.has_value()) { - if (this->adc_input_mic_select_->has_index(mic_input.value())) { - this->adc_input_mic_select_->publish_state(mic_input.value()); - } else { - ESP_LOGW(TAG, "Unknown ADC input mic value: %d", mic_input.value()); + if (this->adc_input_mic_initial_.has_value()) { + const AdcInputMicLine line = this->adc_input_mic_initial_.value(); + ES8388_ERROR_FAILED(this->set_adc_input_mic(line)); + this->adc_input_mic_select_->publish_state(static_cast(line)); + } else { + auto mic_input = this->get_mic_input(); + if (mic_input.has_value()) { + if (this->adc_input_mic_select_->has_index(mic_input.value())) { + this->adc_input_mic_select_->publish_state(mic_input.value()); + } else { + ESP_LOGW(TAG, "Unknown ADC input mic value: %d", mic_input.value()); + } } } } @@ -138,7 +150,7 @@ void ES8388::setup() { void ES8388::dump_config() { ESP_LOGCONFIG(TAG, "ES8388 Audio Codec:"); LOG_I2C_DEVICE(this); -#ifdef USE_SELECT +#ifdef USE_ES8388_SELECT LOG_SELECT(" ", "DacOutputSelect", this->dac_output_select_); LOG_SELECT(" ", "ADCInputMicSelect", this->adc_input_mic_select_); #endif diff --git a/esphome/components/es8388/es8388.h b/esphome/components/es8388/es8388.h index b01acb69c1e..b55538c94f8 100644 --- a/esphome/components/es8388/es8388.h +++ b/esphome/components/es8388/es8388.h @@ -5,7 +5,7 @@ #include "esphome/components/audio_dac/audio_dac.h" #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" -#ifdef USE_SELECT +#ifdef USE_ES8388_SELECT #include "esphome/components/select/select.h" #endif @@ -26,7 +26,7 @@ enum AdcInputMicLine : uint8_t { }; class ES8388 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { -#ifdef USE_SELECT +#ifdef USE_ES8388_SELECT SUB_SELECT(dac_output) SUB_SELECT(adc_input_mic) #endif @@ -68,11 +68,22 @@ class ES8388 final : public audio_dac::AudioDac, public Component, public i2c::I bool set_dac_output(DacOutputLine line); bool set_adc_input_mic(AdcInputMicLine line); +#ifdef USE_ES8388_SELECT + /// Line to apply in setup() instead of keeping what the codec reports. + void set_dac_output_initial(DacOutputLine line) { this->dac_output_initial_ = line; } + void set_adc_input_mic_initial(AdcInputMicLine line) { this->adc_input_mic_initial_ = line; } +#endif + protected: /// @brief Mutes or unmutes the DAC audio out /// @param mute_state True to mute, false to unmute /// @return True if successful and false otherwise bool set_mute_state_(bool mute_state); + +#ifdef USE_ES8388_SELECT + optional dac_output_initial_{}; + optional adc_input_mic_initial_{}; +#endif }; } // namespace esphome::es8388 diff --git a/esphome/components/es8388/select/__init__.py b/esphome/components/es8388/select/__init__.py index 62ddd6cb38a..f0e3f5b7c89 100644 --- a/esphome/components/es8388/select/__init__.py +++ b/esphome/components/es8388/select/__init__.py @@ -1,7 +1,11 @@ import esphome.codegen as cg from esphome.components import select import esphome.config_validation as cv -from esphome.const import ENTITY_CATEGORY_CONFIG, ICON_CHIP # noqa: F401 +from esphome.const import ( + CONF_INITIAL_OPTION, + ENTITY_CATEGORY_CONFIG, + ICON_CHIP, # noqa: F401 +) from esphome.types import ConfigType from ..audio_dac import CONF_ES8388_ID, ES8388, es8388_ns @@ -9,6 +13,21 @@ from ..audio_dac import CONF_ES8388_ID, ES8388, es8388_ns CONF_DAC_OUTPUT = "dac_output" CONF_ADC_INPUT_MIC = "adc_input_mic" +DacOutputLine = es8388_ns.enum("DacOutputLine") +DAC_OUTPUT_OPTIONS = { + "LINE1": DacOutputLine.DAC_OUTPUT_LINE1, + "LINE2": DacOutputLine.DAC_OUTPUT_LINE2, + "BOTH": DacOutputLine.DAC_OUTPUT_BOTH, +} +DAC_OUTPUT_OPTION_NAMES = list(DAC_OUTPUT_OPTIONS) +AdcInputMicLine = es8388_ns.enum("AdcInputMicLine") +ADC_INPUT_MIC_OPTIONS = { + "LINE1": AdcInputMicLine.ADC_INPUT_MIC_LINE1, + "LINE2": AdcInputMicLine.ADC_INPUT_MIC_LINE2, + "DIFFERENCE": AdcInputMicLine.ADC_INPUT_MIC_DIFFERENCE, +} +ADC_INPUT_MIC_OPTION_NAMES = list(ADC_INPUT_MIC_OPTIONS) + DacOutputSelect = es8388_ns.class_("DacOutputSelect", select.Select) ADCInputMicSelect = es8388_ns.class_("ADCInputMicSelect", select.Select) @@ -19,24 +38,45 @@ CONFIG_SCHEMA = cv.All( DacOutputSelect, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_CHIP, + ).extend( + { + cv.Optional(CONF_INITIAL_OPTION): cv.enum( + DAC_OUTPUT_OPTIONS, upper=True + ), + } ), cv.Optional(CONF_ADC_INPUT_MIC): select.select_schema( ADCInputMicSelect, entity_category=ENTITY_CATEGORY_CONFIG, icon=ICON_CHIP, + ).extend( + { + cv.Optional(CONF_INITIAL_OPTION): cv.enum( + ADC_INPUT_MIC_OPTIONS, upper=True + ), + } ), } ) async def to_code(config: ConfigType) -> None: + cg.add_define("USE_ES8388_SELECT") hub = await cg.get_variable(config[CONF_ES8388_ID]) selects = select.sub_selects(config, parent=hub) await selects( - CONF_DAC_OUTPUT, hub.set_dac_output_select, options=["LINE1", "LINE2", "BOTH"] + CONF_DAC_OUTPUT, hub.set_dac_output_select, options=DAC_OUTPUT_OPTION_NAMES ) await selects( CONF_ADC_INPUT_MIC, hub.set_adc_input_mic_select, - options=["LINE1", "LINE2", "DIFFERENCE"], + options=ADC_INPUT_MIC_OPTION_NAMES, ) + if ( + initial := config.get(CONF_DAC_OUTPUT, {}).get(CONF_INITIAL_OPTION) + ) is not None: + cg.add(hub.set_dac_output_initial(initial)) + if ( + initial := config.get(CONF_ADC_INPUT_MIC, {}).get(CONF_INITIAL_OPTION) + ) is not None: + cg.add(hub.set_adc_input_mic_initial(initial)) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index d9b12fdddb1..08f1e31d6c2 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -72,6 +72,7 @@ #define USE_ENTITY_DEVICE_CLASS #define USE_ENTITY_ICON #define USE_ENTITY_UNIT_OF_MEASUREMENT +#define USE_ES8388_SELECT #define USE_ESP32_BLE_PSRAM #define USE_ESP32_CAMERA_JPEG_CONVERSION #define USE_ESP32_HOSTED diff --git a/tests/components/es8388/common.yaml b/tests/components/es8388/common.yaml index cc7465ab935..aa08b9b0470 100644 --- a/tests/components/es8388/common.yaml +++ b/tests/components/es8388/common.yaml @@ -17,5 +17,9 @@ select: es8388_id: es8388_parent dac_output: name: "DAC Output" + id: es8388_test_dac_output_select + initial_option: BOTH adc_input_mic: name: "ADC Input MIC" + id: es8388_test_adc_input_mic_select + initial_option: LINE2 From 55261ae7e5df30e846d2e84bc1b6775b3eab49ad Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Tue, 13 Oct 2026 20:17:25 +0200 Subject: [PATCH 016/142] [tmc2209] Add driver diagnostics and stall detection (#20618) Co-authored-by: Claude Opus 5.5 --- esphome/components/tmc2209/__init__.py | 44 +++++ esphome/components/tmc2209/stepper.py | 28 ++- esphome/components/tmc2209/tmc2209.cpp | 20 ++ esphome/components/tmc2209/tmc2209.h | 39 ++++ esphome/components/tmc22xx/__init__.py | 18 +- esphome/components/tmc22xx/tmc22xx.cpp | 186 ++++++++++++++++-- esphome/components/tmc22xx/tmc22xx.h | 55 ++++++ tests/components/tmc2209/common.yaml | 29 +++ tests/components/tmc2209/test.esp32-idf.yaml | 1 + .../components/tmc2209/test.esp8266-ard.yaml | 1 + tests/components/tmc2209/test.rp2040-ard.yaml | 1 + 11 files changed, 404 insertions(+), 18 deletions(-) diff --git a/esphome/components/tmc2209/__init__.py b/esphome/components/tmc2209/__init__.py index b3822f7582e..06dd8603ff5 100644 --- a/esphome/components/tmc2209/__init__.py +++ b/esphome/components/tmc2209/__init__.py @@ -1,10 +1,54 @@ +from esphome import automation import esphome.codegen as cg from esphome.components import tmc22xx +import esphome.config_validation as cv +from esphome.const import CONF_THRESHOLD CODEOWNERS = ["@remcom"] DOMAIN = "tmc2209" +CONF_SEDN = "sedn" +CONF_SEIMIN = "seimin" +CONF_SEMAX = "semax" +CONF_SEMIN = "semin" +CONF_SEUP = "seup" +CONF_TCOOL_THRESHOLD = "tcool_threshold" + tmc2209_ns = cg.esphome_ns.namespace("tmc2209") TMC2209Stepper = tmc2209_ns.class_("TMC2209Stepper", tmc22xx.TMC22XXStepper) tmc22xx.register_actions("tmc2209", TMC2209Stepper) + +automation.register_apply_action( + "tmc2209.stallguard", + cv.Schema( + { + cv.GenerateID(): cv.use_id(TMC2209Stepper), + cv.Optional(CONF_THRESHOLD): cv.templatable(cv.uint8_t), + cv.Optional(CONF_TCOOL_THRESHOLD): cv.templatable( + cv.int_range(0, 2**20 - 1) + ), + } + ), + automation.ApplyField(CONF_THRESHOLD, "set_stallguard_threshold", cg.uint8), + automation.ApplyField(CONF_TCOOL_THRESHOLD, "set_tcool_threshold", cg.uint32), +) + +automation.register_apply_action( + "tmc2209.coolconf", + cv.Schema( + { + cv.GenerateID(): cv.use_id(TMC2209Stepper), + cv.Optional(CONF_SEMIN): cv.templatable(cv.int_range(0, 15)), + cv.Optional(CONF_SEMAX): cv.templatable(cv.int_range(0, 15)), + cv.Optional(CONF_SEUP): cv.templatable(cv.int_range(0, 3)), + cv.Optional(CONF_SEDN): cv.templatable(cv.int_range(0, 3)), + cv.Optional(CONF_SEIMIN): cv.templatable(cv.boolean), + } + ), + automation.ApplyField(CONF_SEMIN, "set_semin", cg.uint8), + automation.ApplyField(CONF_SEMAX, "set_semax", cg.uint8), + automation.ApplyField(CONF_SEUP, "set_seup", cg.uint8), + automation.ApplyField(CONF_SEDN, "set_sedn", cg.uint8), + automation.ApplyField(CONF_SEIMIN, "set_seimin", cg.bool_), +) diff --git a/esphome/components/tmc2209/stepper.py b/esphome/components/tmc2209/stepper.py index 5ef91dc591f..8867242aa25 100644 --- a/esphome/components/tmc2209/stepper.py +++ b/esphome/components/tmc2209/stepper.py @@ -1,4 +1,6 @@ +from esphome import automation from esphome.components import tmc22xx +import esphome.config_validation as cv from esphome.types import ConfigType from . import TMC2209Stepper @@ -6,9 +8,31 @@ from . import TMC2209Stepper AUTO_LOAD = ["tmc22xx"] DEPENDENCIES = ["uart"] -CONFIG_SCHEMA = tmc22xx.tmc22xx_schema(TMC2209Stepper, max_address=3) +CONF_ON_STALL = "on_stall" + + +def _validate_stall_needs_diag(config: ConfigType) -> ConfigType: + if CONF_ON_STALL in config and tmc22xx.CONF_DIAG_PIN not in config: + raise cv.Invalid( + f"'{CONF_ON_STALL}' requires '{tmc22xx.CONF_DIAG_PIN}', stalls are only reported on DIAG" + ) + return config + + +CONFIG_SCHEMA = cv.All( + tmc22xx.tmc22xx_schema( + TMC2209Stepper, + max_address=3, + extra={cv.Optional(CONF_ON_STALL): automation.validate_automation({})}, + ), + _validate_stall_needs_diag, +) FINAL_VALIDATE_SCHEMA = tmc22xx.final_validate async def to_code(config: ConfigType) -> None: - await tmc22xx.new_tmc22xx(config) + var = await tmc22xx.new_tmc22xx(config) + for conf in config.get(CONF_ON_STALL, []): + await automation.build_callback_automation( + var, "add_on_stall_callback", [], conf + ) diff --git a/esphome/components/tmc2209/tmc2209.cpp b/esphome/components/tmc2209/tmc2209.cpp index 040b362a8d5..21625a5147d 100644 --- a/esphome/components/tmc2209/tmc2209.cpp +++ b/esphome/components/tmc2209/tmc2209.cpp @@ -10,4 +10,24 @@ void TMC2209Stepper::dump_config() { TMC22XXStepper::dump_config(); } +uint32_t *TMC2209Stepper::shadow_register(uint8_t reg) { + switch (reg) { + case REG_TCOOLTHRS: + return &this->tcoolthrs_; + case REG_SGTHRS: + return &this->sgthrs_; + case REG_COOLCONF: + return &this->coolconf_; + default: + return TMC22XXStepper::shadow_register(reg); + } +} + +void TMC2209Stepper::restore_registers() { + TMC22XXStepper::restore_registers(); + this->write_register(REG_TCOOLTHRS, this->tcoolthrs_); + this->write_register(REG_SGTHRS, this->sgthrs_); + this->write_register(REG_COOLCONF, this->coolconf_); +} + } // namespace esphome::tmc2209 diff --git a/esphome/components/tmc2209/tmc2209.h b/esphome/components/tmc2209/tmc2209.h index 2f880251e56..f1c83485fa3 100644 --- a/esphome/components/tmc2209/tmc2209.h +++ b/esphome/components/tmc2209/tmc2209.h @@ -4,12 +4,51 @@ namespace esphome::tmc2209 { +using tmc22xx::RegisterField; + +// StallGuard and CoolStep registers, which the TMC2208 does not have +static constexpr uint8_t REG_TCOOLTHRS = 0x14; +static constexpr uint8_t REG_SGTHRS = 0x40; +static constexpr uint8_t REG_COOLCONF = 0x42; + +static constexpr RegisterField TCOOLTHRS{REG_TCOOLTHRS, 0, 20, false}; +static constexpr RegisterField SGTHRS{REG_SGTHRS, 0, 8, false}; +static constexpr RegisterField SEMIN{REG_COOLCONF, 0, 4, false}; +static constexpr RegisterField SEUP{REG_COOLCONF, 5, 2, false}; +static constexpr RegisterField SEMAX{REG_COOLCONF, 8, 4, false}; +static constexpr RegisterField SEDN{REG_COOLCONF, 13, 2, false}; +static constexpr RegisterField SEIMIN{REG_COOLCONF, 15, 1, false}; + class TMC2209Stepper : public tmc22xx::TMC22XXStepper { public: void dump_config() override; + /// Called when DIAG reports a stall. StallGuard only works in StealthChop, between TCOOLTHRS and TPWMTHRS. + template void add_on_stall_callback(F &&callback) { + this->stall_callback_.add(std::forward(callback)); + } + + /// A stall is reported when the StallGuard result drops below twice this value. + void set_stallguard_threshold(uint8_t threshold) { this->write_field(SGTHRS, threshold); } + /// Lower velocity limit, as TSTEP, for CoolStep and the StallGuard output on DIAG. + void set_tcool_threshold(uint32_t threshold) { this->write_field(TCOOLTHRS, threshold); } + void set_semin(uint8_t semin) { this->write_field(SEMIN, semin); } + void set_semax(uint8_t semax) { this->write_field(SEMAX, semax); } + void set_seup(uint8_t seup) { this->write_field(SEUP, seup); } + void set_sedn(uint8_t sedn) { this->write_field(SEDN, sedn); } + void set_seimin(bool seimin) { this->write_field(SEIMIN, seimin); } + protected: uint8_t expected_version() const override { return 0x21; } + uint32_t *shadow_register(uint8_t reg) override; + void restore_registers() override; + void on_diag_without_error() override { this->stall_callback_.call(); } + + LazyCallbackManager stall_callback_; + // Write-only registers with their power-on values + uint32_t tcoolthrs_{0}; + uint32_t sgthrs_{0}; + uint32_t coolconf_{0}; }; } // namespace esphome::tmc2209 diff --git a/esphome/components/tmc22xx/__init__.py b/esphome/components/tmc22xx/__init__.py index ad7221f0b21..da760dde2c5 100644 --- a/esphome/components/tmc22xx/__init__.py +++ b/esphome/components/tmc22xx/__init__.py @@ -21,6 +21,7 @@ DEPENDENCIES = ["uart"] CONF_ANALOG_CURRENT_SCALE = "analog_current_scale" CONF_CLOCK_FREQUENCY = "clock_frequency" +CONF_DIAG_PIN = "diag_pin" CONF_ENABLE_SPREADCYCLE = "enable_spreadcycle" CONF_ENN_PIN = "enn_pin" CONF_HOLD_CURRENT = "hold_current" @@ -31,6 +32,7 @@ CONF_INTERPOLATION = "interpolation" CONF_INVERSE_DIRECTION = "inverse_direction" CONF_IRUN = "irun" CONF_MICROSTEPS = "microsteps" +CONF_ON_STATUS = "on_status" CONF_OTTRIM = "ottrim" CONF_RSENSE = "rsense" CONF_RUN_CURRENT = "run_current" @@ -44,6 +46,7 @@ TMC22XXStepper = tmc22xx_ns.class_( "TMC22XXStepper", stepper.Stepper, cg.Component, uart.UARTDevice ) StandstillMode = tmc22xx_ns.enum("StandstillMode") +DriverEvent = tmc22xx_ns.enum("DriverEvent") STANDSTILL_MODES = { "normal": StandstillMode.STANDSTILL_MODE_NORMAL, @@ -56,7 +59,9 @@ validate_microsteps = cv.one_of(1, 2, 4, 8, 16, 32, 64, 128, 256, int=True) validate_current = cv.All(cv.current, cv.positive_float) -def tmc22xx_schema(cls: MockObjClass, max_address: int) -> cv.Schema: +def tmc22xx_schema( + cls: MockObjClass, max_address: int, extra: dict | None = None +) -> cv.Schema: """Return the stepper schema for a family member; `max_address` is 0 for chips without address pins.""" return cv.All( stepper.STEPPER_SCHEMA.extend( @@ -67,6 +72,7 @@ def tmc22xx_schema(cls: MockObjClass, max_address: int) -> cv.Schema: cv.Optional(CONF_STEP_PIN): pins.gpio_output_pin_schema, cv.Optional(CONF_DIR_PIN): pins.gpio_output_pin_schema, cv.Optional(CONF_INDEX_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_DIAG_PIN): pins.internal_gpio_input_pin_schema, cv.Optional(CONF_CLOCK_FREQUENCY, default="12MHz"): cv.All( cv.frequency, cv.int_range(min=1) ), @@ -79,6 +85,8 @@ def tmc22xx_schema(cls: MockObjClass, max_address: int) -> cv.Schema: cv.Optional(CONF_RUN_CURRENT): validate_current, cv.Optional(CONF_HOLD_CURRENT): validate_current, cv.Optional(CONF_MICROSTEPS, default=16): validate_microsteps, + cv.Optional(CONF_ON_STATUS): automation.validate_automation({}), + **(extra or {}), } ) .extend(cv.COMPONENT_SCHEMA) @@ -133,6 +141,7 @@ async def new_tmc22xx(config: ConfigType) -> cg.Pvariable: (CONF_STEP_PIN, var.set_step_pin), (CONF_DIR_PIN, var.set_dir_pin), (CONF_INDEX_PIN, var.set_index_pin), + (CONF_DIAG_PIN, var.set_diag_pin), ): if (pin := config.get(key)) is not None: cg.add(setter(await cg.gpio_pin_expression(pin))) @@ -145,6 +154,13 @@ async def new_tmc22xx(config: ConfigType) -> cg.Pvariable: ): if (value := config.get(key)) is not None: cg.add(setter(value)) + for conf in config.get(CONF_ON_STATUS, []): + await automation.build_callback_automation( + var, + "add_on_status_callback", + [(DriverEvent, "event"), (cg.bool_, "active")], + conf, + ) return var diff --git a/esphome/components/tmc22xx/tmc22xx.cpp b/esphome/components/tmc22xx/tmc22xx.cpp index 1628572d406..2aed97bea10 100644 --- a/esphome/components/tmc22xx/tmc22xx.cpp +++ b/esphome/components/tmc22xx/tmc22xx.cpp @@ -23,6 +23,49 @@ static constexpr float INTERNAL_RSENSE_OHM = 0.17f; // Equivalent sense resis static constexpr float APPROACH_TIME_S = 0.02f; // Skip VACTUAL writes that change the speed by less than 1/16, each write blocks the loop for the UART transfer static constexpr int32_t VACTUAL_CHANGE_DIVISOR = 16; +static constexpr uint32_t STATUS_POLL_INTERVAL_MS = 1000; +// While the driver does not answer, for example without motor supply, only poll every 10 s; each failed read +// blocks the loop for the UART timeout +static constexpr uint8_t UNREACHABLE_POLL_SKIP = 9; +static constexpr uint32_t GSTAT_FLAGS = 0x07; // reset, drv_err, uv_cp +static constexpr uint32_t DRV_STATUS_FLAGS = 0x0FFF; + +static const LogString *driver_event_to_string(DriverEvent event) { + switch (event) { + case DRIVER_EVENT_RESET: + return LOG_STR("Driver reset"); + case DRIVER_EVENT_DRIVER_ERROR: + return LOG_STR("Driver error"); + case DRIVER_EVENT_CHARGE_PUMP_UNDERVOLTAGE: + return LOG_STR("Charge pump undervoltage"); + case DRIVER_EVENT_OVERTEMPERATURE_PREWARNING: + return LOG_STR("Overtemperature prewarning"); + case DRIVER_EVENT_OVERTEMPERATURE: + return LOG_STR("Overtemperature shutdown"); + case DRIVER_EVENT_SHORT_TO_GROUND_A: + return LOG_STR("Short to ground on phase A"); + case DRIVER_EVENT_SHORT_TO_GROUND_B: + return LOG_STR("Short to ground on phase B"); + case DRIVER_EVENT_LOW_SIDE_SHORT_A: + return LOG_STR("Low side short on phase A"); + case DRIVER_EVENT_LOW_SIDE_SHORT_B: + return LOG_STR("Low side short on phase B"); + case DRIVER_EVENT_OPEN_LOAD_A: + return LOG_STR("Open load on phase A"); + case DRIVER_EVENT_OPEN_LOAD_B: + return LOG_STR("Open load on phase B"); + case DRIVER_EVENT_TEMPERATURE_120C: + return LOG_STR("Temperature above 120C"); + case DRIVER_EVENT_TEMPERATURE_143C: + return LOG_STR("Temperature above 143C"); + case DRIVER_EVENT_TEMPERATURE_150C: + return LOG_STR("Temperature above 150C"); + case DRIVER_EVENT_TEMPERATURE_157C: + return LOG_STR("Temperature above 157C"); + default: + return LOG_STR("Unknown"); + } +} // CRC-8 with polynomial 0x07 over the datagram bits in the order they go out on the wire, LSB first static uint8_t datagram_crc(const uint8_t *data, size_t len) { @@ -34,8 +77,19 @@ static uint8_t datagram_crc(const uint8_t *data, size_t len) { return crc; } +// Index into RESTORED_REGISTERS, or -1 when the register is not restored after a driver reset +static int restored_index(uint8_t reg) { + for (size_t i = 0; i < RESTORED_REGISTERS.size(); i++) { + if (RESTORED_REGISTERS[i] == reg) + return static_cast(i); + } + return -1; +} + void IRAM_ATTR IndexPulseStore::gpio_intr(IndexPulseStore *arg) { arg->pulses = arg->pulses + arg->direction; } +void IRAM_ATTR DiagPinStore::gpio_intr(DiagPinStore *arg) { arg->triggered = true; } + void TMC22XXStepper::setup() { if (this->enn_pin_ != nullptr) { this->enn_pin_->setup(); @@ -53,13 +107,54 @@ void TMC22XXStepper::setup() { ESP_LOGW(TAG, "Unexpected IC version 0x%02X", this->version_); } - // Bring write-only registers in line with their cached values and clear the reset flag + this->restore_registers(); + this->write_register(REG_GSTAT, GSTAT_FLAGS); + this->apply_config_(); + // Currents live in a write-only register, so later changes survive a driver reset through its cached value + if (!std::isnan(this->initial_run_current_)) + this->set_run_current(this->initial_run_current_); + if (!std::isnan(this->initial_hold_current_)) + this->set_hold_current(this->initial_hold_current_); + + if (this->index_pin_ != nullptr) { + this->index_pin_->setup(); + this->index_pin_->attach_interrupt(IndexPulseStore::gpio_intr, &this->index_store_, gpio::INTERRUPT_ANY_EDGE); + } else { + this->step_pin_->setup(); + this->step_pin_->digital_write(false); + this->dir_pin_->setup(); + this->dir_pin_->digital_write(false); + } + + if (this->diag_pin_ != nullptr) { + this->diag_pin_->setup(); + this->diag_pin_->attach_interrupt(DiagPinStore::gpio_intr, &this->diag_store_, gpio::INTERRUPT_RISING_EDGE); + } + // Polling notices a driver reset, and conditions that clear without a DIAG edge + this->set_interval(STATUS_POLL_INTERVAL_MS, [this]() { + if (this->status_poll_skip_ > 0) { + this->status_poll_skip_--; + return; + } + if (!this->update_status_()) + this->status_poll_skip_ = UNREACHABLE_POLL_SKIP; + }); + + this->set_enabled(true); +} + +void TMC22XXStepper::restore_registers() { this->write_register(REG_IHOLD_IRUN, this->ihold_irun_); this->write_register(REG_TPOWERDOWN, this->tpowerdown_); this->write_register(REG_TPWMTHRS, this->tpwmthrs_); this->write_register(REG_VACTUAL, 0); - this->write_register(REG_GSTAT, 0x07); + for (size_t i = 0; i < RESTORED_REGISTERS.size(); i++) { + if ((this->restored_written_ & (1u << i)) != 0) + this->write_register(RESTORED_REGISTERS[i], this->restored_values_[i]); + } +} +void TMC22XXStepper::apply_config_() { this->write_field(PDN_DISABLE, true); this->write_field(MSTEP_REG_SELECT, true); this->write_field(INTERNAL_RSENSE, std::isnan(this->rsense_)); @@ -76,28 +171,59 @@ void TMC22XXStepper::setup() { this->toff_ = *toff; this->set_microsteps(this->initial_microsteps_); - if (!std::isnan(this->initial_run_current_)) - this->set_run_current(this->initial_run_current_); - if (!std::isnan(this->initial_hold_current_)) - this->set_hold_current(this->initial_hold_current_); if (this->index_pin_ != nullptr) { // INDEX toggles on every step of the internal pulse generator that VACTUAL drives this->write_field(INDEX_OTPW, false); this->write_field(INDEX_STEP, true); - this->index_pin_->setup(); - this->index_pin_->attach_interrupt(IndexPulseStore::gpio_intr, &this->index_store_, gpio::INTERRUPT_ANY_EDGE); } else { // Step on both edges, so every step pin toggle is one step this->write_field(DEDGE, true); this->write_field(MULTISTEP_FILT, false); - this->step_pin_->setup(); - this->step_pin_->digital_write(false); - this->dir_pin_->setup(); - this->dir_pin_->digital_write(false); } +} - this->set_enabled(true); +bool TMC22XXStepper::update_status_() { + auto gstat = this->read_register(REG_GSTAT); + if (!gstat.has_value()) + return false; + uint32_t drv_status = 0; + // DRV_STATUS only feeds on_status and the DIAG check, skip the transfer when neither is used + if (this->diag_pin_ != nullptr || !this->status_callback_.empty()) { + auto value = this->read_register(REG_DRV_STATUS); + if (!value.has_value()) + return false; + drv_status = *value; + } + // GSTAT flags stay set until written with 1; they are set again while the condition lasts + if ((*gstat & GSTAT_FLAGS) != 0) + this->write_register(REG_GSTAT, *gstat & GSTAT_FLAGS); + + uint16_t status = (*gstat & GSTAT_FLAGS) | ((drv_status & DRV_STATUS_FLAGS) << 3); + // Restore before reporting, so an on_status automation for the reset sees and can change the restored settings + if ((status & (1u << DRIVER_EVENT_RESET)) != 0) { + ESP_LOGW(TAG, "Restoring settings after driver reset"); + this->restore_registers(); + // The motor stopped with the reset, ramp up again from standstill + this->current_speed_ = 0.0f; + this->set_enabled(this->enabled_); + } + uint16_t changed = status ^ this->status_; + this->status_ = status; + for (uint8_t event = 0; event < DRIVER_EVENT_COUNT; event++) { + if ((changed & (1u << event)) == 0) + continue; + bool active = (status & (1u << event)) != 0; + // Open load and temperature levels are informational, the driver keeps running + if (active && event < DRIVER_EVENT_OPEN_LOAD_A) { + ESP_LOGW(TAG, "%s", LOG_STR_ARG(driver_event_to_string(static_cast(event)))); + } else { + ESP_LOGD(TAG, "%s: %s", LOG_STR_ARG(driver_event_to_string(static_cast(event))), + active ? LOG_STR_LITERAL("active") : LOG_STR_LITERAL("cleared")); + } + this->status_callback_.call(static_cast(event), active); + } + return true; } void TMC22XXStepper::dump_config() { @@ -128,6 +254,7 @@ void TMC22XXStepper::dump_config() { LOG_PIN(" STEP Pin: ", this->step_pin_); LOG_PIN(" DIR Pin: ", this->dir_pin_); LOG_PIN(" INDEX Pin: ", this->index_pin_); + LOG_PIN(" DIAG Pin: ", this->diag_pin_); LOG_STEPPER(this); if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); @@ -137,12 +264,29 @@ void TMC22XXStepper::dump_config() { void TMC22XXStepper::loop() { if (this->index_pin_ != nullptr) this->add_index_pulses_(); + if (this->diag_store_.triggered) { + this->diag_store_.triggered = false; + // DIAG rises on a driver error, any other rise is chip specific + if (!this->update_status_()) { + ESP_LOGW(TAG, "DIAG rose, but the driver status could not be read"); + } else if ((this->status_ & GSTAT_FLAGS) == 0) { + this->on_diag_without_error(); + } + } + if (this->has_reached_target()) { this->high_freq_.stop(); + this->moving_ = false; } else { this->high_freq_.start(); if (!this->enabled_) this->set_enabled(true); + // An unreachable driver is polled only every 10 s. Read it before the first step, so settings lost when the + // motor supply dropped are restored before the move runs on the chip defaults. + if (!this->moving_ && this->status_poll_skip_ > 0) { + this->status_poll_skip_ = this->update_status_() ? 0 : UNREACHABLE_POLL_SKIP; + } + this->moving_ = true; } if (this->index_pin_ != nullptr) { @@ -311,8 +455,12 @@ bool TMC22XXStepper::write_register(uint8_t reg, uint32_t value) { } this->status_clear_warning(); - if (uint32_t *shadow = this->shadow_register(reg)) + if (uint32_t *shadow = this->shadow_register(reg)) { *shadow = value; + } else if (int index = restored_index(reg); index >= 0) { + this->restored_values_[index] = value; + this->restored_written_ |= 1u << index; + } return true; } @@ -346,7 +494,15 @@ bool TMC22XXStepper::write_field(const RegisterField &field, uint32_t value) { // Skip the UART timeout when the driver never answered during setup if (this->is_failed()) return false; - auto data = this->read_register(field.reg); + optional data; + int index = restored_index(field.reg); + if (index >= 0 && (this->restored_written_ & (1u << index)) != 0) { + // Modify the last written value, not the chip: after a driver reset that the poll has not seen yet, the chip + // holds its defaults, and those would replace the values to restore + data = this->restored_values_[index]; + } else { + data = this->read_register(field.reg); + } if (!data.has_value()) return false; uint32_t mask = field.mask(); diff --git a/esphome/components/tmc22xx/tmc22xx.h b/esphome/components/tmc22xx/tmc22xx.h index 8b0c92d3520..546a27e70d3 100644 --- a/esphome/components/tmc22xx/tmc22xx.h +++ b/esphome/components/tmc22xx/tmc22xx.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include "esphome/components/stepper/stepper.h" @@ -30,8 +31,12 @@ static constexpr uint8_t REG_TPOWERDOWN = 0x11; static constexpr uint8_t REG_TPWMTHRS = 0x13; static constexpr uint8_t REG_VACTUAL = 0x22; static constexpr uint8_t REG_CHOPCONF = 0x6C; +static constexpr uint8_t REG_DRV_STATUS = 0x6F; static constexpr uint8_t REG_PWMCONF = 0x70; +// Readable registers that a driver reset sets back to their defaults; the last written values are restored +static constexpr std::array RESTORED_REGISTERS{REG_GCONF, REG_FACTORY_CONF, REG_CHOPCONF, REG_PWMCONF}; + static constexpr RegisterField I_SCALE_ANALOG{REG_GCONF, 0, 1, false}; static constexpr RegisterField INTERNAL_RSENSE{REG_GCONF, 1, 1, false}; static constexpr RegisterField EN_SPREADCYCLE{REG_GCONF, 2, 1, false}; @@ -63,6 +68,33 @@ enum StandstillMode : uint8_t { STANDSTILL_MODE_COIL_SHORT_HS = 3, }; +/// Driver conditions reported to on_status, in the order of their status bits: GSTAT bits 0-2, then DRV_STATUS bits +/// 0-11. +enum DriverEvent : uint8_t { + DRIVER_EVENT_RESET = 0, + DRIVER_EVENT_DRIVER_ERROR, + DRIVER_EVENT_CHARGE_PUMP_UNDERVOLTAGE, + DRIVER_EVENT_OVERTEMPERATURE_PREWARNING, + DRIVER_EVENT_OVERTEMPERATURE, + DRIVER_EVENT_SHORT_TO_GROUND_A, + DRIVER_EVENT_SHORT_TO_GROUND_B, + DRIVER_EVENT_LOW_SIDE_SHORT_A, + DRIVER_EVENT_LOW_SIDE_SHORT_B, + DRIVER_EVENT_OPEN_LOAD_A, + DRIVER_EVENT_OPEN_LOAD_B, + DRIVER_EVENT_TEMPERATURE_120C, + DRIVER_EVENT_TEMPERATURE_143C, + DRIVER_EVENT_TEMPERATURE_150C, + DRIVER_EVENT_TEMPERATURE_157C, + DRIVER_EVENT_COUNT, +}; + +/// Set by a rising edge on DIAG, which signals a driver error or, on chips that support it, a stall. +struct DiagPinStore { + volatile bool triggered{false}; + static void gpio_intr(DiagPinStore *arg); +}; + /// Counts the step pulses the driver reports on INDEX while it runs from VACTUAL. struct IndexPulseStore { volatile int32_t pulses{0}; @@ -84,6 +116,7 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U void set_step_pin(GPIOPin *pin) { this->step_pin_ = pin; } void set_dir_pin(GPIOPin *pin) { this->dir_pin_ = pin; } void set_index_pin(InternalGPIOPin *pin) { this->index_pin_ = pin; } + void set_diag_pin(InternalGPIOPin *pin) { this->diag_pin_ = pin; } void set_clock_frequency(uint32_t frequency) { this->clock_frequency_ = frequency; } void set_rsense(float rsense) { this->rsense_ = rsense; } void set_vsense(bool vsense) { this->vsense_ = vsense; } @@ -97,6 +130,11 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U void set_enabled(bool enabled); bool is_enabled() const { return this->enabled_; } + /// Called with the condition and whether it became active or cleared. + template void add_on_status_callback(F &&callback) { + this->status_callback_.add(std::forward(callback)); + } + void set_microsteps(uint16_t microsteps); uint16_t get_microsteps(); void set_interpolation(bool enable) { this->write_field(INTPOL, enable); } @@ -127,6 +165,15 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U virtual uint8_t expected_version() const = 0; /// Return the cached value of a write-only register, or nullptr when the register is readable. virtual uint32_t *shadow_register(uint8_t reg); + /// Write the cached write-only registers and the last written configuration registers to the driver, at setup + /// and after a driver reset. + virtual void restore_registers(); + /// Write the settings from the configuration to the readable registers. + void apply_config_(); + /// Called when DIAG rises without a driver error. + virtual void on_diag_without_error() {} + /// Read GSTAT and DRV_STATUS and report changes. Returns false when the status could not be read. + bool update_status_(); optional current_to_scale_(float current); float full_scale_current_(); int32_t get_vactual_() const { return static_cast(extract_field(this->vactual_reg_, VACTUAL)); } @@ -139,28 +186,36 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U GPIOPin *step_pin_{nullptr}; GPIOPin *dir_pin_{nullptr}; InternalGPIOPin *index_pin_{nullptr}; + InternalGPIOPin *diag_pin_{nullptr}; + LazyCallbackManager status_callback_; uint32_t clock_frequency_{12000000}; // Write-only registers shared by all family members, with their power-on values uint32_t ihold_irun_{0x00071703}; uint32_t tpowerdown_{0x14}; uint32_t tpwmthrs_{0}; uint32_t vactual_reg_{0}; + std::array restored_values_{}; // NaN while not configured float rsense_{NAN}; float initial_run_current_{NAN}; float initial_hold_current_{NAN}; IndexPulseStore index_store_{}; uint16_t initial_microsteps_{16}; + uint16_t status_{0}; optional vsense_{}; optional ottrim_{}; uint8_t address_{0}; uint8_t toff_{3}; uint8_t version_{0}; + uint8_t restored_written_{0}; // Bit per RESTORED_REGISTERS entry that has been written + uint8_t status_poll_skip_{0}; int8_t direction_{0}; + DiagPinStore diag_store_{}; bool analog_current_scale_{false}; bool enabled_{false}; bool vsense_active_{false}; bool step_state_{false}; + bool moving_{false}; HighFrequencyLoopRequester high_freq_; }; diff --git a/tests/components/tmc2209/common.yaml b/tests/components/tmc2209/common.yaml index ff10c79edcf..e53c3ba8233 100644 --- a/tests/components/tmc2209/common.yaml +++ b/tests/components/tmc2209/common.yaml @@ -6,6 +6,7 @@ stepper: step_pin: ${step_pin} dir_pin: ${dir_pin} enn_pin: ${enn_pin} + diag_pin: ${diag_pin} rsense: 110 mOhm vsense: false ottrim: 1 @@ -15,6 +16,17 @@ stepper: max_speed: 2000 steps/s acceleration: 1000 steps/s^2 deceleration: 1000 steps/s^2 + on_stall: + - stepper.report_position: + id: tmc2209_step_dir + position: 0 + - stepper.set_target: + id: tmc2209_step_dir + target: 0 + on_status: + - logger.log: + format: "Driver event %u active %d" + args: [event, active] - platform: tmc2209 id: tmc2209_serial @@ -24,6 +36,12 @@ stepper: clock_frequency: 12MHz analog_current_scale: true max_speed: 1000 steps/s + on_status: + - if: + condition: + lambda: "return event == tmc22xx::DRIVER_EVENT_OVERTEMPERATURE_PREWARNING && active;" + then: + - tmc2209.disable: tmc2209_serial esphome: on_boot: @@ -49,6 +67,17 @@ esphome: id: tmc2209_serial irun: 20 ihold: !lambda "return 5;" + - tmc2209.stallguard: + id: tmc2209_step_dir + threshold: 50 + tcool_threshold: !lambda "return 400;" + - tmc2209.coolconf: + id: tmc2209_step_dir + semin: 5 + semax: 2 + seup: 1 + sedn: 0 + seimin: true - tmc2209.enable: tmc2209_serial - stepper.set_target: id: tmc2209_serial diff --git a/tests/components/tmc2209/test.esp32-idf.yaml b/tests/components/tmc2209/test.esp32-idf.yaml index 5634c186860..18fcfbcaed0 100644 --- a/tests/components/tmc2209/test.esp32-idf.yaml +++ b/tests/components/tmc2209/test.esp32-idf.yaml @@ -3,6 +3,7 @@ substitutions: dir_pin: GPIO13 enn_pin: GPIO14 index_pin: GPIO15 + diag_pin: GPIO4 packages: uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml diff --git a/tests/components/tmc2209/test.esp8266-ard.yaml b/tests/components/tmc2209/test.esp8266-ard.yaml index 3e4ea33f7a5..fe1c6289af0 100644 --- a/tests/components/tmc2209/test.esp8266-ard.yaml +++ b/tests/components/tmc2209/test.esp8266-ard.yaml @@ -3,6 +3,7 @@ substitutions: dir_pin: GPIO13 enn_pin: GPIO14 index_pin: GPIO15 + diag_pin: GPIO2 packages: uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml diff --git a/tests/components/tmc2209/test.rp2040-ard.yaml b/tests/components/tmc2209/test.rp2040-ard.yaml index 722f8f201fc..d7ceff9ee1f 100644 --- a/tests/components/tmc2209/test.rp2040-ard.yaml +++ b/tests/components/tmc2209/test.rp2040-ard.yaml @@ -3,6 +3,7 @@ substitutions: dir_pin: GPIO3 enn_pin: GPIO6 index_pin: GPIO7 + diag_pin: GPIO8 packages: uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml From 3063b76210acbbf4102910dfe6a5d10d5ce24c00 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 08:25:36 -1000 Subject: [PATCH 017/142] [haier_tundra] Build the IR frame on the stack (#20635) --- esphome/components/haier_tundra/haier_tundra.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/haier_tundra/haier_tundra.cpp b/esphome/components/haier_tundra/haier_tundra.cpp index 7a0b88d3aac..a3a5e0a8400 100644 --- a/esphome/components/haier_tundra/haier_tundra.cpp +++ b/esphome/components/haier_tundra/haier_tundra.cpp @@ -28,7 +28,7 @@ void HaierTundra::transmit_(uint8_t button) { this->turbo_mode_ = false; } - remote_base::HaierData remote_state{{ + const uint8_t frame[HAIER_TUNDRA_FRAME_SIZE] = { this->unit_, static_cast(this->temperature_() << 4 | this->vertical_swing_()), static_cast(this->horizontal_swing_() << 5), @@ -42,10 +42,10 @@ void HaierTundra::transmit_(uint8_t button) { static_cast((button == HAIER_BUTTON_SELF_CLEAN ? 1 : 0) << 4), 0x00, button, - }}; + }; auto transmit = this->transmitter_->transmit(); - remote_base::HaierProtocol().encode(transmit.get_data(), remote_state); + remote_base::HaierProtocol().encode(transmit.get_data(), frame, sizeof(frame)); transmit.perform(); } From a82705892800258d590e630fc8b105a00b08283d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 14 Oct 2026 05:27:50 +1100 Subject: [PATCH 018/142] [lvgl] Change deprecated calls to current version (#20620) --- esphome/components/lvgl/color_picker.h | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/esphome/components/lvgl/color_picker.h b/esphome/components/lvgl/color_picker.h index e2392694f07..1949ac00de0 100644 --- a/esphome/components/lvgl/color_picker.h +++ b/esphome/components/lvgl/color_picker.h @@ -85,7 +85,7 @@ class LvColorPickerType : public LvCompound { if (left != nullptr) { lv_obj_set_style_align(left, LV_ALIGN_LEFT_MID, LV_PART_MAIN); // A narrow hue column may be slimmer than its label. - lv_obj_add_flag(left, LV_OBJ_FLAG_OVERFLOW_VISIBLE); + lv_obj_set_flag(left, LV_OBJ_FLAG_OVERFLOW_VISIBLE, true); } // The text rows keep their natural height and the bars between them take what is left, @@ -233,7 +233,7 @@ class LvColorPickerType : public LvCompound { static lv_obj_t *create_box(lv_obj_t *parent) { auto *obj = lv_obj_create(parent); lv_obj_add_style(obj, &plain_box, LV_PART_MAIN); - lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flag(obj, LV_OBJ_FLAG_SCROLLABLE, false); return obj; } @@ -326,7 +326,7 @@ class LvColorPickerType : public LvCompound { lv_obj_set_style_radius(disc, LV_RADIUS_CIRCLE, LV_PART_MAIN); lv_obj_set_style_bg_opa(disc, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_bg_grad(disc, grad, LV_PART_MAIN); - lv_obj_remove_flag(disc, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flag(disc, LV_OBJ_FLAG_SCROLLABLE, false); return disc; } @@ -336,7 +336,7 @@ class LvColorPickerType : public LvCompound { this->wheel_ = create_disc(container, &wheel_hue_grad); // Not clickable, so touches fall through to the wheel underneath. auto *overlay = create_disc(this->wheel_, &wheel_white_grad); - lv_obj_remove_flag(overlay, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_flag(overlay, LV_OBJ_FLAG_CLICKABLE, false); this->wheel_handle_ = lv_obj_create(this->wheel_); lv_obj_set_size(this->wheel_handle_, WHEEL_HANDLE_SIZE, WHEEL_HANDLE_SIZE); @@ -345,8 +345,8 @@ class LvColorPickerType : public LvCompound { lv_obj_set_style_bg_opa(this->wheel_handle_, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_set_style_border_width(this->wheel_handle_, 2, LV_PART_MAIN); lv_obj_set_style_border_color(this->wheel_handle_, lv_color_white(), LV_PART_MAIN); - lv_obj_remove_flag(this->wheel_handle_, LV_OBJ_FLAG_CLICKABLE); - lv_obj_remove_flag(this->wheel_handle_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flag(this->wheel_handle_, LV_OBJ_FLAG_CLICKABLE, false); + lv_obj_set_flag(this->wheel_handle_, LV_OBJ_FLAG_SCROLLABLE, false); // PRESSED as well, so a tap takes effect without the finger moving. lv_obj_add_event_cb(this->wheel_, wheel_touch_cb, LV_EVENT_PRESSED, this); @@ -359,7 +359,7 @@ class LvColorPickerType : public LvCompound { void add_value_label_(SliderIndex index, lv_color_t color) { auto *slider = this->sliders_[index]; - lv_obj_add_flag(slider, LV_OBJ_FLAG_OVERFLOW_VISIBLE); + lv_obj_set_flag(slider, LV_OBJ_FLAG_OVERFLOW_VISIBLE, true); auto *label = lv_label_create(slider); this->values_[index] = label; lv_obj_set_style_align(label, LV_ALIGN_CENTER, LV_PART_MAIN); @@ -509,7 +509,7 @@ class LvColorPickerType : public LvCompound { static void bubble_events(lv_obj_t *obj) { for (uint32_t i = 0; i < lv_obj_get_child_count(obj); i++) { auto *child = lv_obj_get_child(obj, i); - lv_obj_add_flag(child, LV_OBJ_FLAG_EVENT_BUBBLE); + lv_obj_set_flag(child, LV_OBJ_FLAG_EVENT_BUBBLE, true); bubble_events(child); } } From 0821c84b7aea3c9c47aadb16c23aa7bb736a04eb Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:35:24 -0400 Subject: [PATCH 019/142] [core] Let a nested repeat shadow the outer iteration variable (#20634) --- esphome/automation.py | 17 +++++++- tests/components/script/common.yaml | 17 ++++++++ tests/unit_tests/test_automation.py | 65 ++++++++++++++++++++++++++++- 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 4a2c10cb52d..32f423d2a34 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -906,6 +906,21 @@ async def while_action_to_code( return var +def _shadow_iteration(args: TemplateArgsType) -> TemplateArgsType: + """Rename an inherited `iteration` so the inner repeat's own counter can shadow it.""" + names = {name for _, name in args} + renamed: TemplateArgsType = [] + for type_, name in args: + if name == "iteration": + depth = 1 + while f"iteration_{depth}" in names: + depth += 1 + name = f"iteration_{depth}" + names.add(name) + renamed.append((type_, name)) + return renamed + + @register_action( "repeat", RepeatAction, @@ -929,7 +944,7 @@ async def repeat_action_to_code( actions = await build_action_list( config[CONF_THEN], cg.TemplateArguments(cg.uint32, *template_arg.args), - [(cg.uint32, "iteration"), *args], + [(cg.uint32, "iteration"), *_shadow_iteration(args)], ) for action in actions: cg.add(var.add_then_action(action)) diff --git a/tests/components/script/common.yaml b/tests/components/script/common.yaml index 6b7290277a1..f8396e9648f 100644 --- a/tests/components/script/common.yaml +++ b/tests/components/script/common.yaml @@ -23,6 +23,10 @@ esphome: then: - logger.log: my_script is running +globals: + - id: nested_total + type: int + script: - id: my_script mode: single @@ -71,3 +75,16 @@ script: count: 5 then: - logger.log: looping! + - repeat: + count: 2 + then: + - repeat: + count: 2 + then: + - delay: 200ms + - lambda: 'ESP_LOGD("main", "inner iteration %u", iteration);' + - globals.set: + id: nested_total + value: !lambda |- + int total = iteration; + return total; diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index fe0b1b02ed1..b56d0a2920e 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -13,9 +13,13 @@ from esphome.automation import ( ApplyCondition, ApplyField, CallbackAutomation, + LambdaAction, + RepeatAction, TriggerForwarder, TriggerOnFalseForwarder, TriggerOnTrueForwarder, + _shadow_iteration, + build_action, build_callback_automation, build_callback_automations, build_parent_callback_automation, @@ -41,7 +45,13 @@ from esphome.automation import ( ) import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_AUTOMATION_ID, CONF_ID, CONF_THEN +from esphome.const import ( + CONF_AUTOMATION_ID, + CONF_COUNT, + CONF_ID, + CONF_THEN, + CONF_TYPE_ID, +) from esphome.core import CORE, ID, KEY_CORE, KEY_TARGET_PLATFORM, EsphomeError, Lambda from esphome.cpp_generator import MockObj, RawExpression from esphome.types import ConfigType @@ -1545,3 +1555,56 @@ async def test_build_callback_automation_forwarder(mock_cg: MockCodegen) -> None assert str(mock_cg.add.call_args.args[0]) == ( f"parent->add_cb(TriggerForwarder{{{NEW_OBJ}}})" ) + + +def test_shadow_iteration_leaves_other_names() -> None: + args = [(cg.std_string, "prefix"), (cg.uint32, "iteration_1")] + assert _shadow_iteration(args) == args + + +def test_shadow_iteration_renames_inherited_counter() -> None: + assert _shadow_iteration([(cg.uint32, "iteration"), (cg.bool_, "x")]) == [ + (cg.uint32, "iteration_1"), + (cg.bool_, "x"), + ] + + +def test_shadow_iteration_picks_first_unused_suffix() -> None: + """A counter keeps its suffix at deeper levels; the new one takes the next free name.""" + args = [ + (cg.uint32, "iteration"), + (cg.uint32, "iteration_1"), + (cg.int_, "iteration_3"), + ] + assert _shadow_iteration(args) == [ + (cg.uint32, "iteration_2"), + (cg.uint32, "iteration_1"), + (cg.int_, "iteration_3"), + ] + + +def _repeat_conf(id_: str, then: list[ConfigType]) -> ConfigType: + return { + "repeat": {CONF_COUNT: 2, CONF_THEN: then}, + CONF_TYPE_ID: ID(id_, is_declaration=True, type=RepeatAction), + } + + +@pytest.mark.asyncio +async def test_nested_repeat_lambda_parameters_are_distinct() -> None: + """Each repeat level adds its own `iteration`; inherited ones are renamed, not duplicated.""" + CORE.config = {} + log = { + "lambda": Lambda('ESP_LOGD("t", "%u", iteration);'), + CONF_TYPE_ID: ID("log_action", is_declaration=True, type=LambdaAction), + } + conf = _repeat_conf( + "outer", [_repeat_conf("middle", [_repeat_conf("inner", [log])])] + ) + await build_action(conf, cg.TemplateArguments(cg.uint8), [(cg.uint8, "level")]) + text = CORE.cpp_main_section + assert ( + "(uint32_t iteration, uint32_t iteration_2, uint32_t iteration_1, uint8_t level)" + in text + ) + assert "uint32_t iteration, uint32_t iteration)" not in text From 417ab4d2e940d54fa2d9b11eaa79946e196df1d9 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Tue, 13 Oct 2026 15:00:45 -0400 Subject: [PATCH 020/142] [openthread] Add core_options for OpenThread compile-time options (#20595) Co-authored-by: Claude Opus 5.5 --- esphome/components/openthread/__init__.py | 68 +++++++ esphome/components/openthread/const.py | 3 + .../openthread/config/custom_header.yaml | 19 ++ .../openthread/config/no_custom_header.yaml | 16 ++ tests/component_tests/openthread/test_init.py | 171 +++++++++++++++++- .../openthread/test.esp32-c6-idf.yaml | 2 + 6 files changed, 274 insertions(+), 5 deletions(-) create mode 100644 tests/component_tests/openthread/config/custom_header.yaml create mode 100644 tests/component_tests/openthread/config/no_custom_header.yaml diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 6fca8bfdd9a..2c278f12f49 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -1,3 +1,4 @@ +import re from typing import Any from esphome import automation @@ -44,9 +45,12 @@ from esphome.core import ( coroutine_with_priority, ) import esphome.final_validate as fv +from esphome.helpers import write_file_if_changed from esphome.types import ConfigType +from esphome.writer import clean_build from .const import ( + CONF_CORE_OPTIONS, CONF_DEVICE_TYPE, CONF_EXT_PAN_ID, CONF_FORCE_DATASET, @@ -59,6 +63,7 @@ from .const import ( CONF_PSKC, CONF_SRP_ID, CONF_TLV, + OPENTHREAD_CONFIG_PREFIX, ) CODEOWNERS = ["@mrene"] @@ -84,6 +89,10 @@ CONF_DEVICE_TYPES = [ "MTD", ] +OT_HEADER_DIR = "openthread" +OT_HEADER_FILE = "esphome_ot_config.h" +OT_CORE_OPTION_NAME = re.compile(r"[A-Z0-9_]+") + def _validate_txpower(value: Any) -> int | float: if CORE.is_esp32: @@ -158,6 +167,31 @@ def set_sdkconfig_options(config: ConfigType) -> None: add_idf_sdkconfig_option(f"CONFIG_OPENTHREAD_{config.get(CONF_DEVICE_TYPE)}", True) +def _write_custom_header(config: ConfigType) -> None: + """Write core_options to the OpenThread custom header.""" + if not (options := config.get(CONF_CORE_OPTIONS)): + return + + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_HEADER_CUSTOM", True) + # IDF resolves the path relative to the project (build) directory. + add_idf_sdkconfig_option("CONFIG_OPENTHREAD_CUSTOM_HEADER_PATH", OT_HEADER_DIR) + add_idf_sdkconfig_option( + "CONFIG_OPENTHREAD_CUSTOM_HEADER_FILE_NAME", OT_HEADER_FILE + ) + content = ( + "// Auto-generated by ESPHome from openthread core_options - do not edit\n" + "#pragma once\n\n" + + "".join( + f"#define {name} {value}\n" for name, value in sorted(options.items()) + ) + ) + # PlatformIO does not track this header as an OpenThread input; the esp-idf + # toolchain does. + header_path = CORE.relative_build_path(OT_HEADER_DIR, OT_HEADER_FILE) + if write_file_if_changed(header_path, content) and not CORE.using_toolchain_esp_idf: + clean_build(clear_pio_cache=False) + + openthread_ns = cg.esphome_ns.namespace("openthread") OpenThreadComponent = openthread_ns.class_("OpenThreadComponent", cg.Component) OpenThreadSrpComponent = openthread_ns.class_("OpenThreadSrpComponent", cg.Component) @@ -220,6 +254,34 @@ def _validate_platform(config: ConfigType) -> ConfigType: )(config) +def _validate_core_option_names(options: dict[str, str]) -> dict[str, str]: + for name in options: + if ( + not name.startswith(OPENTHREAD_CONFIG_PREFIX) + or name == OPENTHREAD_CONFIG_PREFIX + ): + raise cv.Invalid( + f"'{name}' is not an OpenThread core option; names must be " + f"{OPENTHREAD_CONFIG_PREFIX} followed by the option name", + path=[name], + ) + if not OT_CORE_OPTION_NAME.fullmatch(name): + raise cv.Invalid(f"'{name}' may only contain A-Z, 0-9 and _", path=[name]) + return options + + +def _validate_core_option_value(value: Any) -> str: + value = cv.string_strict(value) + if not value.strip(): + raise cv.Invalid("OpenThread core option value must not be empty") + if "\n" in value or "\r" in value: + raise cv.Invalid("OpenThread core option value must be a single line") + # GCC treats a backslash followed by spaces as a line continuation too. + if value.rstrip().endswith("\\"): + raise cv.Invalid("OpenThread core option value must not end with \\") + return value + + def _validate_tlv_hex(value: Any) -> str: s = cv.string_strict(value) if len(s) % 2 != 0: @@ -252,6 +314,11 @@ CONFIG_SCHEMA = cv.All( _validate_txpower, ), cv.Optional(CONF_POLL_PERIOD): cv.positive_time_period_milliseconds, + cv.Optional(CONF_CORE_OPTIONS): cv.All( + cv.only_on_esp32, + {cv.string_strict: _validate_core_option_value}, + _validate_core_option_names, + ), } ).extend(_CONNECTION_SCHEMA), cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV), @@ -325,6 +392,7 @@ async def to_code(config: ConfigType) -> None: if CORE.is_esp32: set_sdkconfig_options(config) + _write_custom_header(config) elif CORE.using_zephyr: # Crypto through PSA: mbedtls, plus Oberon from SDK 2.7 include_west_project("mbedtls") diff --git a/esphome/components/openthread/const.py b/esphome/components/openthread/const.py index f0274a8c9e5..bd9c8cfa1c7 100644 --- a/esphome/components/openthread/const.py +++ b/esphome/components/openthread/const.py @@ -1,3 +1,4 @@ +CONF_CORE_OPTIONS = "core_options" CONF_DEVICE_TYPE = "device_type" CONF_EXT_PAN_ID = "ext_pan_id" CONF_FORCE_DATASET = "force_dataset" @@ -10,3 +11,5 @@ CONF_POLL_PERIOD = "poll_period" CONF_PSKC = "pskc" CONF_SRP_ID = "srp_id" CONF_TLV = "tlv" + +OPENTHREAD_CONFIG_PREFIX = "OPENTHREAD_CONFIG_" diff --git a/tests/component_tests/openthread/config/custom_header.yaml b/tests/component_tests/openthread/config/custom_header.yaml new file mode 100644 index 00000000000..767497e9b2f --- /dev/null +++ b/tests/component_tests/openthread/config/custom_header.yaml @@ -0,0 +1,19 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + +network: + enable_ipv6: true + +openthread: + channel: 13 + network_name: OpenThread-8f28 + network_key: 0xdfd34f0f05cad978ec4e32b0413038ff + pan_id: 0x8f28 + core_options: + OPENTHREAD_CONFIG_COAP_BLOCKWISE_TRANSFER_ENABLE: "1" + OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT: "5" diff --git a/tests/component_tests/openthread/config/no_custom_header.yaml b/tests/component_tests/openthread/config/no_custom_header.yaml new file mode 100644 index 00000000000..df0634e98d7 --- /dev/null +++ b/tests/component_tests/openthread/config/no_custom_header.yaml @@ -0,0 +1,16 @@ +esphome: + name: test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + +network: + enable_ipv6: true + +openthread: + channel: 13 + network_name: OpenThread-8f28 + network_key: 0xdfd34f0f05cad978ec4e32b0413038ff + pan_id: 0x8f28 diff --git a/tests/component_tests/openthread/test_init.py b/tests/component_tests/openthread/test_init.py index 7ba9bdd4bba..c0e155ec7e3 100644 --- a/tests/component_tests/openthread/test_init.py +++ b/tests/component_tests/openthread/test_init.py @@ -1,11 +1,22 @@ -"""Tests for the openthread component schema.""" +"""Tests for the openthread component.""" + +from collections.abc import Callable +from pathlib import Path +from unittest.mock import MagicMock, patch import pytest -from esphome.components.esp32 import KEY_VARIANT, VARIANT_ESP32C6 -from esphome.components.openthread import CONFIG_SCHEMA -from esphome.const import PlatformFramework -from esphome.core import HexInt +from esphome.components.esp32 import ( + KEY_ESP32, + KEY_SDKCONFIG_OPTIONS, + KEY_VARIANT, + VARIANT_ESP32C6, +) +from esphome.components.openthread import CONFIG_SCHEMA, OT_HEADER_DIR, OT_HEADER_FILE +from esphome.components.openthread.const import OPENTHREAD_CONFIG_PREFIX +import esphome.config_validation as cv +from esphome.const import PlatformFramework, Toolchain +from esphome.core import CORE, HexInt from esphome.yaml_util import Sensitive from tests.component_tests.types import SetCoreConfigCallable @@ -37,3 +48,153 @@ def test_credentials_are_sensitive( assert isinstance(validated[field], Sensitive) assert validated[field] == expected assert isinstance(validated[field], HexInt) == isinstance(expected, int) + + +def _generate_in( + tmp_path: Path, yaml_text: str, generate_main: Callable[[str | Path], str] +) -> tuple[Path, MagicMock]: + """Generate from ``yaml_text`` written to tmp_path, so the build directory lands there. + + Returns the header path and the mocked ``clean_build``. + """ + config_path = tmp_path / "test.yaml" + config_path.write_text(yaml_text, encoding="utf-8") + with patch("esphome.components.openthread.clean_build") as clean_build: + generate_main(config_path) + return CORE.relative_build_path(OT_HEADER_DIR, OT_HEADER_FILE), clean_build + + +def test_core_options_written_to_header( + tmp_path: Path, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + header, _ = _generate_in( + tmp_path, + component_config_path("custom_header.yaml").read_text(encoding="utf-8"), + generate_main, + ) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert sdkconfig["CONFIG_OPENTHREAD_HEADER_CUSTOM"] is True + assert sdkconfig["CONFIG_OPENTHREAD_CUSTOM_HEADER_PATH"] == OT_HEADER_DIR + assert sdkconfig["CONFIG_OPENTHREAD_CUSTOM_HEADER_FILE_NAME"] == OT_HEADER_FILE + assert header.read_text(encoding="utf-8") == ( + "// Auto-generated by ESPHome from openthread core_options - do not edit\n" + "#pragma once\n\n" + "#define OPENTHREAD_CONFIG_COAP_BLOCKWISE_TRANSFER_ENABLE 1\n" + "#define OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT 5\n" + ) + + +def test_no_header_options_no_header( + tmp_path: Path, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + header, clean_build = _generate_in( + tmp_path, + component_config_path("no_custom_header.yaml").read_text(encoding="utf-8"), + generate_main, + ) + sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + assert "CONFIG_OPENTHREAD_HEADER_CUSTOM" not in sdkconfig + assert not header.exists() + clean_build.assert_not_called() + + +@pytest.mark.parametrize( + ("toolchain", "cleans"), + [(Toolchain.PLATFORMIO, True), (Toolchain.ESP_IDF, False)], +) +def test_header_change_cleans_only_on_platformio( + toolchain: Toolchain, + cleans: bool, + tmp_path: Path, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = component_config_path("custom_header.yaml").read_text(encoding="utf-8") + CORE.toolchain = toolchain + _, clean_build = _generate_in(tmp_path, source, generate_main) + assert clean_build.called is cleans + # Unchanged content: no clean on either toolchain. + CORE.reset() + CORE.toolchain = toolchain + _, clean_build = _generate_in(tmp_path, source, generate_main) + clean_build.assert_not_called() + # A changed value leaves sdkconfig as it was, so only this clean rebuilds OpenThread. + changed = source.replace('RETRIES_DIRECT: "5"', 'RETRIES_DIRECT: "7"') + assert changed != source + CORE.reset() + CORE.toolchain = toolchain + header, clean_build = _generate_in(tmp_path, changed, generate_main) + assert clean_build.called is cleans + assert ( + "#define OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT 7\n" + in header.read_text(encoding="utf-8") + ) + + +@pytest.mark.parametrize( + "name", + [ + "CONFIG_OPENTHREAD_JOINER", + "openthread_config_x", + "OT_CONFIG_X", + OPENTHREAD_CONFIG_PREFIX, + ], +) +def test_core_option_without_prefix_rejected( + name: str, set_core_config: SetCoreConfigCallable +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6} + ) + with pytest.raises(cv.Invalid, match=OPENTHREAD_CONFIG_PREFIX): + CONFIG_SCHEMA({"network_key": hex(NETWORK_KEY), "core_options": {name: "1"}}) + + +@pytest.mark.parametrize( + "name", + ["OPENTHREAD_CONFIG_a", "OPENTHREAD_CONFIG_A B", "OPENTHREAD_CONFIG_A-B"], +) +def test_core_option_bad_characters_rejected( + name: str, set_core_config: SetCoreConfigCallable +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6} + ) + with pytest.raises(cv.Invalid, match="may only contain A-Z, 0-9 and _"): + CONFIG_SCHEMA({"network_key": hex(NETWORK_KEY), "core_options": {name: "1"}}) + + +@pytest.mark.parametrize( + ("value", "message"), + [ + ("", "must not be empty"), + (" ", "must not be empty"), + ("1\n#define X 2", "must be a single line"), + ("1\r2", "must be a single line"), + ("1 \\", "must not end with"), + ("1 \\ ", "must not end with"), + ], +) +def test_core_option_bad_value_rejected( + value: str, message: str, set_core_config: SetCoreConfigCallable +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32C6} + ) + with pytest.raises(cv.Invalid, match=message): + CONFIG_SCHEMA( + { + "network_key": hex(NETWORK_KEY), + "core_options": {"OPENTHREAD_CONFIG_X": value}, + } + ) + + +def test_core_options_only_on_esp32(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.NRF52_ZEPHYR) + with pytest.raises(cv.Invalid, match="ESP32"): + CONFIG_SCHEMA({"tlv": TLV, "core_options": {"OPENTHREAD_CONFIG_X": "1"}}) diff --git a/tests/components/openthread/test.esp32-c6-idf.yaml b/tests/components/openthread/test.esp32-c6-idf.yaml index 92d120e5d19..746b7151f30 100644 --- a/tests/components/openthread/test.esp32-c6-idf.yaml +++ b/tests/components/openthread/test.esp32-c6-idf.yaml @@ -9,3 +9,5 @@ openthread: pskc: 0xc23a76e98f1a6483639b1ac1271e2e27 mesh_local_prefix: fd53:145f:ed22:ad81::/64 output_power: 1dBm + core_options: + OPENTHREAD_CONFIG_MAC_DEFAULT_MAX_FRAME_RETRIES_DIRECT: "5" From 918b7d255357a814327b8b7052d4cb2a3c3a9437 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:31:19 +0200 Subject: [PATCH 021/142] [script] Keep the running script.wait item out of the queue while its chain runs (#20570) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/script/script.h | 10 ++- .../script/test_script_wait_action.cpp | 86 +++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index f00ad7f4afa..efd75d27e0e 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -295,8 +295,11 @@ template class ScriptWaitAction final : public Actionnum_running_ == 0) + if (this->num_running_ == 0) { + // Nothing left to wait for, e.g. once the last queued item has run + this->disable_loop(); return; + } if (this->script_->is_running()) return; @@ -305,9 +308,10 @@ template class ScriptWaitAction final : public Actionparam_queue_.empty()) { - auto ¶ms = this->param_queue_.front(); - std::apply([this](auto &...args) { this->play_next_(args...); }, params); + // Take the item out first: the chain may call stop(), which clears the queue + auto params = std::move(this->param_queue_.front()); this->param_queue_.pop_front(); + std::apply([this](auto &...args) { this->play_next_(args...); }, params); } else { // Queue is now empty - disable loop until next play_complex this->disable_loop(); diff --git a/tests/components/script/test_script_wait_action.cpp b/tests/components/script/test_script_wait_action.cpp index 17f553996ea..c3cd55fc8ca 100644 --- a/tests/components/script/test_script_wait_action.cpp +++ b/tests/components/script/test_script_wait_action.cpp @@ -2,6 +2,7 @@ #include #include +#include #include "esphome/components/script/script.h" @@ -93,4 +94,89 @@ TEST(ScriptWaitActionTest, KeepsReferenceToNonCopyableArguments) { EXPECT_EQ(entity.value, 1); } +class StopWaitAction : public Action { + public: + void play(const std::string &value) override { + this->wait->stop_complex(); + this->seen = value; + } + Action *wait{nullptr}; + std::string seen; +}; + +// The chain after the wait may stop it, e.g. a script with mode: restart +TEST(ScriptWaitActionTest, StopDuringChainKeepsArguments) { + FakeScript script; + ScriptWaitAction wait(&script); + StopWaitAction stop; + stop.wait = &wait; + ActionList actions; + actions.add_actions({&wait, &stop}); + + // With a second item waiting, stop() clears the queue while the first item's chain runs + actions.play(std::string("argument longer than the small string buffer")); + actions.play(std::string("second argument, also longer than the buffer")); + script.running = false; + wait.loop(); + EXPECT_EQ(stop.seen, "argument longer than the small string buffer"); + wait.loop(); + EXPECT_EQ(stop.seen, "argument longer than the small string buffer"); +} + +class RestartAction : public Action { + public: + void play(const std::string &value) override { + this->seen.push_back(value); + if (this->seen.size() > 1) + return; + // What a script with mode: restart does: stop, then run again while the awaited script is busy + this->wait->stop_complex(); + this->script->running = true; + this->actions->play(std::string("restarted, longer than the small string buffer")); + } + Action *wait{nullptr}; + FakeScript *script{nullptr}; + ActionList *actions{nullptr}; + std::vector seen; +}; + +// The old pop_front() after the chain removed the item queued by the restart +TEST(ScriptWaitActionTest, RestartDuringChainKeepsNewItem) { + FakeScript script; + ScriptWaitAction wait(&script); + RestartAction restart; + ActionList actions; + actions.add_actions({&wait, &restart}); + restart.wait = &wait; + restart.script = &script; + restart.actions = &actions; + + // With a second item waiting, the stop really clears the queue; the restart then queues a new item + actions.play(std::string("first argument, longer than the small string buffer")); + actions.play(std::string("second argument, dropped by the stop")); + script.running = false; + wait.loop(); + ASSERT_EQ(restart.seen.size(), 1u); + + script.running = false; + wait.loop(); + ASSERT_EQ(restart.seen.size(), 2u); + EXPECT_EQ(restart.seen[1], "restarted, longer than the small string buffer"); +} + +TEST(ScriptWaitActionTest, LoopDisabledOnceNothingWaits) { + FakeScript script; + ScriptWaitAction wait(&script); + RecordAction record; + ActionList actions; + actions.add_actions({&wait, &record}); + + actions.play(std::string("argument")); + script.running = false; + wait.loop(); + EXPECT_EQ(record.seen, "argument"); + wait.loop(); + EXPECT_TRUE(wait.is_idle()); +} + } // namespace esphome::script::testing From 05bf7d34408e7358411c5bd1a5e24717093d7bf5 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 13 Oct 2026 22:40:56 +0200 Subject: [PATCH 022/142] [epaper_spi] Write pixel blocks straight into mono buffers (#20552) --- .../components/display/display_color_utils.h | 52 +++++ esphome/components/epaper_spi/colorconv.h | 19 +- esphome/components/epaper_spi/epaper_spi.cpp | 114 +++++++++++ esphome/components/epaper_spi/epaper_spi.h | 6 + .../components/epaper_spi/epaper_spi_mono.h | 1 + .../epaper_spi/epaper_spi_ssd1677.cpp | 4 +- .../epaper_spi/epaper_spi_ssd1677.h | 3 - .../epaper_spi/epaper_spi_ssd1677_gray4.h | 2 +- .../components/epaper_spi/epaper_spi_uc8179.h | 1 + esphome/components/it8951/it8951.cpp | 10 +- .../epaper_spi/display/test_draw_pixels.cpp | 193 ++++++++++++++++++ 11 files changed, 382 insertions(+), 23 deletions(-) create mode 100644 tests/components/epaper_spi/display/test_draw_pixels.cpp diff --git a/esphome/components/display/display_color_utils.h b/esphome/components/display/display_color_utils.h index 608642caa44..be39e2a3e2f 100644 --- a/esphome/components/display/display_color_utils.h +++ b/esphome/components/display/display_color_utils.h @@ -1,4 +1,6 @@ #pragma once +#include +#include #include "esphome/core/color.h" namespace esphome::display { @@ -154,4 +156,54 @@ class ColorUtil { return color; } }; + +/** Rec.601 luma weights (0.299/0.587/0.114), scaled by 256 */ +static constexpr uint16_t REC601_LUMA_R = 77; +static constexpr uint16_t REC601_LUMA_G = 150; +static constexpr uint16_t REC601_LUMA_B = 29; + +/** Rec.601 luma of an RGB color, for optimum perceptual brightness */ +constexpr uint8_t rec601_luma(Color color) { + return (uint8_t) ((REC601_LUMA_R * color.r + REC601_LUMA_G * color.g + REC601_LUMA_B * color.b + 128u) >> 8); +} + +/** Map an RGB color to a single monochrome bit: 1 = white, 0 = black */ +constexpr uint8_t color_to_mono(Color color) { return rec601_luma(color) >= 128 ? 1 : 0; } + +/** color_to_mono() of an RGB565 value as ColorUtil::to_color() reads it, from table lookups instead of a division + * per channel. + */ +class Rgb565ToMono { + public: + explicit Rgb565ToMono(ColorOrder order) { + // Rec.601 weight of the 5-bit, 6-bit and 5-bit fields, by the channel each one holds in this order + uint16_t high = REC601_LUMA_R, middle = REC601_LUMA_G, low = REC601_LUMA_B; + if (order == COLOR_ORDER_BGR) { + high = REC601_LUMA_B; + low = REC601_LUMA_R; + } else if (order == COLOR_ORDER_GRB) { + high = REC601_LUMA_G; + middle = REC601_LUMA_R; + } + // Each field value scaled to 0..255, then weighted + for (unsigned i = 0; i != 32; i++) { + this->high_[i] = high * esp_scale(i, 31); + this->low_[i] = low * esp_scale(i, 31); + } + for (unsigned i = 0; i != 64; i++) + this->middle_[i] = middle * esp_scale(i, 63); + } + + uint8_t operator()(uint16_t value) const { + const uint32_t weighted = this->high_[value >> 11] + this->middle_[(value >> 5) & 0x3F] + this->low_[value & 0x1F]; + return ((weighted + 128u) >> 8) >= 128 ? 1 : 0; + } + + protected: + // Weighted contribution of each value of each field + std::array high_{}; + std::array middle_{}; + std::array low_{}; +}; + } // namespace esphome::display diff --git a/esphome/components/epaper_spi/colorconv.h b/esphome/components/epaper_spi/colorconv.h index 0240aa5cccf..cb59538abab 100644 --- a/esphome/components/epaper_spi/colorconv.h +++ b/esphome/components/epaper_spi/colorconv.h @@ -2,6 +2,7 @@ #include #include +#include "esphome/components/display/display_color_utils.h" #include "esphome/core/color.h" /* Utility for converting internal \a Color RGB representation to supported IC hardware color keys @@ -13,21 +14,15 @@ namespace esphome::epaper_spi { +// The Rec.601 luma helpers and the RGB565 mono lookup live in the shared display utilities, so other +// drivers (e.g. it8951 in its 1 bit per pixel mode) can use them too. +using display::color_to_mono; +using display::rec601_luma; +using display::Rgb565ToMono; + /** Delta for when to regard as gray */ static constexpr uint8_t COLORCONV_GRAY_THRESHOLD = 50; -/** Rec.601 luma (0.299/0.587/0.114 weights, scaled by 256) for optimum perceptual brightness */ -constexpr uint8_t rec601_luma(Color color) { - return (uint8_t) ((77u * color.r + 150u * color.g + 29u * color.b + 128u) >> 8); -} - -/** Map RGB color to a single monochrome bit - * - * @param color RGB color to convert from - * @return 1 = white, 0 = black - */ -constexpr uint8_t color_to_mono(Color color) { return rec601_luma(color) >= 128 ? 1 : 0; } - /** Map RGB color to one of 4 discrete gray levels (2 bits per pixel) * * @param color RGB color to convert from diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index 0c1d9b0fafb..2a2f74763f0 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -345,6 +345,120 @@ void HOT EPaperBase::draw_pixel_at(int x, int y, Color color) { } } +namespace { +// One source row of an RGB565 block, written into a contiguous 1 bit per pixel buffer. Without SwapAxes the row lies +// along a buffer row and the bit moves by step; with it the row lies along a bit column and the byte moves by step +// rows. +template +void HOT write_rgb565_row(uint8_t *byte, uint8_t mask, int step, size_t row_width, const uint8_t *source, int count, + const Rgb565ToMono &to_mono) { + const ptrdiff_t byte_step = SwapAxes ? step * static_cast(row_width) : 0; + for (; count != 0; count--, source += 2) { + const uint16_t value = BigEndianSource ? (source[0] << 8) | source[1] : source[0] | (source[1] << 8); + if (to_mono(value)) { + *byte |= mask; + } else { + *byte &= ~mask; + } + if constexpr (SwapAxes) { + byte += byte_step; + } else if (step > 0) { + mask >>= 1; + if (mask == 0) { + mask = 0x80; + byte++; + } + } else { + mask <<= 1; + if (mask == 0) { + mask = 0x01; + byte--; + } + } + } +} +} // namespace + +// Each source column maps to one buffer coordinate and each source row to the other, each moving by 1 per pixel, so +// the visible part of a block and its dirty area are worked out once for the whole block. +void HOT EPaperBase::draw_rgb565_block_(uint8_t *buffer, int x_start, int y_start, int w, int h, const uint8_t *ptr, + ColorOrder order, bool big_endian, int x_offset, int y_offset, int x_pad) { + const bool swap = this->effective_transform_ & SWAP_XY; + const bool mirror_x = this->effective_transform_ & MIRROR_X; + const bool mirror_y = this->effective_transform_ & MIRROR_Y; + // The buffer axis a source column moves along, and the one a source row moves along + const int column_limit = swap ? this->height_ : this->width_; + const int row_limit = swap ? this->width_ : this->height_; + const bool column_mirrored = swap ? mirror_y : mirror_x; + const bool row_mirrored = swap ? mirror_x : mirror_y; + const int first_column = std::max(0, -x_start); + const int end_column = std::min(w, column_limit - x_start); + const int first_row = std::max(0, -y_start); + const int end_row = std::min(h, row_limit - y_start); + if (first_column >= end_column || first_row >= end_row) + return; + auto column_to_buffer = [&](int column) { + const int u = x_start + column; + return column_mirrored ? column_limit - 1 - u : u; + }; + auto row_to_buffer = [&](int row) { + const int v = y_start + row; + return row_mirrored ? row_limit - 1 - v : v; + }; + + const int column_low = std::min(column_to_buffer(first_column), column_to_buffer(end_column - 1)); + const int row_low = std::min(row_to_buffer(first_row), row_to_buffer(end_row - 1)); + const int columns = end_column - first_column; + const int rows = end_row - first_row; + const int x_low = swap ? row_low : column_low; + const int y_low = swap ? column_low : row_low; + const int x_high = x_low + (swap ? rows : columns); + const int y_high = y_low + (swap ? columns : rows); + + const Rgb565ToMono to_mono(order); + const size_t line_stride = x_offset + w + x_pad; + const size_t row_width = this->row_width_; + const int step = column_mirrored ? -1 : 1; + for (int row = first_row; row != end_row; row++) { + const uint8_t *source = ptr + ((y_offset + row) * line_stride + x_offset + first_column) * 2; + const int along = column_to_buffer(first_column); + const int across = row_to_buffer(row); + const int x = swap ? across : along; + const int y = swap ? along : across; + uint8_t *byte = buffer + y * row_width + x / 8; + const uint8_t mask = 0x80u >> (x % 8); + if (swap) { + if (big_endian) { + write_rgb565_row(byte, mask, step, row_width, source, columns, to_mono); + } else { + write_rgb565_row(byte, mask, step, row_width, source, columns, to_mono); + } + } else if (big_endian) { + write_rgb565_row(byte, mask, step, row_width, source, columns, to_mono); + } else { + write_rgb565_row(byte, mask, step, row_width, source, columns, to_mono); + } + } + this->x_low_ = clamp_at_most(this->x_low_, x_low); + this->x_high_ = clamp_at_least(this->x_high_, x_high); + this->y_low_ = clamp_at_most(this->y_low_, y_low); + this->y_high_ = clamp_at_least(this->y_high_, y_high); +} + +// Writes an RGB565 block of pixels, as LVGL sends them, straight into a 1 bit per pixel buffer that is one block (the +// usual case): the same result as draw_pixel_at() for each pixel. Anything else goes through draw_pixel_at(). +void HOT EPaperBase::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + size_t contiguous_length; + uint8_t *contiguous = this->buffer_.get_span(0, contiguous_length); + if (!this->buffer_is_mono() || this->get_clipping().is_set() || bitness != COLOR_BITNESS_565 || + contiguous_length != this->buffer_length_) { + Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + return; + } + this->draw_rgb565_block_(contiguous, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset, x_pad); +} + void EPaperBase::dump_config() { LOG_DISPLAY("", "E-Paper SPI", this); ESP_LOGCONFIG(TAG, diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 96755a404ec..c7ef90f1160 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -107,6 +107,8 @@ class EPaperBase : public Display, int get_width() override { return this->effective_transform_ & SWAP_XY ? this->height_ : this->width_; } int get_height() override { return this->effective_transform_ & SWAP_XY ? this->width_ : this->height_; } void draw_pixel_at(int x, int y, Color color) override; + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; // Make the next update a full one. Applied when that update starts, so one in progress is not affected. void request_full_update() { this->full_update_requested_ = true; } // True from the start of an update until the display has refreshed and gone back to sleep. @@ -128,6 +130,10 @@ class EPaperBase : public Display, bool init_buffer_(size_t buffer_length); void update_effective_transform_(); bool rotate_coordinates_(int &x, int &y); + void draw_rgb565_block_(uint8_t *buffer, int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + bool big_endian, int x_offset, int y_offset, int x_pad); + // Whether the buffer holds 1 bit per pixel as draw_pixel_at() writes it, so draw_pixels_at() can write it directly + virtual bool buffer_is_mono() const { return false; } /** * Methods that must be implemented by concrete classes to control the display diff --git a/esphome/components/epaper_spi/epaper_spi_mono.h b/esphome/components/epaper_spi/epaper_spi_mono.h index d0740595fc5..0f570149de4 100644 --- a/esphome/components/epaper_spi/epaper_spi_mono.h +++ b/esphome/components/epaper_spi/epaper_spi_mono.h @@ -15,6 +15,7 @@ class EPaperMono : public EPaperBase { } protected: + bool buffer_is_mono() const override { return true; } void refresh_screen(bool partial) override; void power_on() override {} void power_off() override{}; diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp b/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp index a9245fa1c2b..a79bbc3dc91 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp @@ -58,7 +58,9 @@ bool HOT EPaperSSD1677::transfer_data() { // A full update ignores 0x26, and the copy may not hold a real frame yet (first update after // boot): send the new frame to both planes. const bool send_copy = this->plane_ == 0 && this->update_count_ != 0; - const bool direct = send_copy || this->buffer_is_plane(); + // A mono buffer already holds the frame as a RAM plane does, so it can be sent as it is; a deeper + // buffer (the four-level model) returns false and packs the plane in plane_row(). + const bool direct = send_copy || this->buffer_is_mono(); const auto &source = send_copy ? this->sent_ : this->buffer_; const size_t row_length = this->plane_row_length_(); const size_t plane_length = row_length * this->height_; diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677.h b/esphome/components/epaper_spi/epaper_spi_ssd1677.h index ddf3d4e0892..b8c93a1ecb8 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1677.h +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677.h @@ -34,9 +34,6 @@ class EPaperSSD1677 : public EPaperMono { void init_comparison_frame_(); // Bytes in one row of a RAM plane: 8 pixels per byte, whatever the buffer holds. size_t plane_row_length_() const { return (this->width_ + 7) / 8; } - // Whether the buffer already holds the frame as a RAM plane does, so it can be sent as it is. - // A subclass with a deeper buffer returns false and overrides plane_row(). - virtual bool buffer_is_plane() const { return true; } // Row y of the frame as a RAM plane holds it, 1 bit per pixel with 1 = white. virtual void plane_row(size_t y, uint8_t *out); bool reset() override; diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h b/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h index 47436d2db1d..f21ab3eb069 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h @@ -38,9 +38,9 @@ class EPaperSSD1677Gray4 : public EPaperSSD1677 { void draw_pixel_at(int x, int y, Color color) override; protected: + bool buffer_is_mono() const override { return false; } // A partial update if partial updates are enabled and this is not a full one. bool is_partial_push_() const { return this->update_count_ != 0 && this->sent_.is_valid(); } - bool buffer_is_plane() const override { return false; } void plane_row(size_t y, uint8_t *out) override; void refresh_screen(bool partial) override; bool transfer_data() override; diff --git a/esphome/components/epaper_spi/epaper_spi_uc8179.h b/esphome/components/epaper_spi/epaper_spi_uc8179.h index 85c0eb623ee..b12ed54ef6d 100644 --- a/esphome/components/epaper_spi/epaper_spi_uc8179.h +++ b/esphome/components/epaper_spi/epaper_spi_uc8179.h @@ -37,6 +37,7 @@ class EPaperUC8179 final : public EPaperBase { } protected: + bool buffer_is_mono() const override { return true; } bool initialise(bool partial) override; bool transfer_data() override; void refresh_screen(bool partial) override; diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index fe99017774d..565f6f7f8bb 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -891,12 +891,10 @@ static uint8_t color_to_nibble(const Color &color) { if (color.raw_32 == 0xFFFFFFFF) return 0x0F; // white - // Derive luma from RGB using Rec.601 weights (0.299/0.587/0.114, scaled by - // 256). Rec.601 is the standard for converting SDR images to grayscale and - // spreads saturated colours across the mid-range; Rec.709 instead crams them - // against white/black where the 16 panel levels are hard to tell apart. - auto luma = static_cast((77u * color.r + 150u * color.g + 29u * color.b + 128u) >> 8); - return quantize_8bit_to_nibble(luma); + // Rec.601 luma is the standard for converting SDR images to grayscale and spreads saturated colours + // across the mid-range; Rec.709 instead crams them against white/black where the 16 panel levels are + // hard to tell apart. + return quantize_8bit_to_nibble(display::rec601_luma(color)); } // 4x4 ordered (Bayer) dither threshold over the weighted-luma range (0..65535). diff --git a/tests/components/epaper_spi/display/test_draw_pixels.cpp b/tests/components/epaper_spi/display/test_draw_pixels.cpp new file mode 100644 index 00000000000..a3b47ea8fc1 --- /dev/null +++ b/tests/components/epaper_spi/display/test_draw_pixels.cpp @@ -0,0 +1,193 @@ +#include + +#include +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/colorconv.h" +#include "esphome/components/epaper_spi/epaper_spi_ssd1677_gray4.h" +#include "esphome/components/epaper_spi/epaper_spi_ssd1683.h" + +namespace esphome::epaper_spi::testing { + +/// Exposes the buffer and the dirty bounds so the fast path can be compared with drawing pixel by pixel. +template class TestableDisplay : public Driver { + public: + TestableDisplay(uint16_t width, uint16_t height) : Driver("test", width, height, nullptr, 0) { + EXPECT_TRUE(this->init_buffer_(this->buffer_length_)); + this->reset_bounds(); + } + + void reset_bounds() { + this->x_low_ = this->width_; + this->y_low_ = this->height_; + this->x_high_ = 0; + this->y_high_ = 0; + } + + std::vector buffer() const { + std::vector out; + for (size_t i = 0; i != this->buffer_length_; i++) + out.push_back(this->buffer_[i]); + return out; + } + + /// Reallocates the buffer in blocks of at most max_block bytes, as SplitBuffer does when memory is fragmented. + void split_buffer(size_t max_block) { + ASSERT_TRUE(this->buffer_.init(this->buffer_length_, max_block)); + ASSERT_GT(this->buffer_.get_buffer_count(), 1u); + this->clear(); + this->reset_bounds(); + } + + std::vector bounds() const { return {this->x_low_, this->y_low_, this->x_high_, this->y_high_}; } + + void draw_one_by_one(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + // The per-pixel path the fast one must match + display::Display::draw_pixels_at( // NOLINT(bugprone-parent-virtual-call) + x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + } +}; + +/// RGB565 data with a spread of light and dark pixels, including values either side of the threshold. +static std::vector rgb565(size_t pixels, uint32_t seed) { + std::vector out; + uint32_t state = seed; + for (size_t i = 0; i != pixels; i++) { + state = state * 1103515245u + 12345u; + const uint16_t pixel = static_cast(state >> 8); + out.push_back(pixel & 0xFF); + out.push_back(pixel >> 8); + } + return out; +} + +/// Random bytes for a block of pixels, enough for 3 bytes a pixel. +static std::vector random_bytes(size_t pixels, uint32_t seed) { + std::vector out; + uint32_t state = seed; + for (size_t i = 0; i != pixels * 3; i++) { + state = state * 1103515245u + 12345u; + out.push_back(static_cast(state >> 16)); + } + return out; +} + +/// For every rotation, mirroring and byte order, with source offsets and padding, and with blocks inside the panel, +/// running off each edge and larger than the panel, drawing an RGB565 block directly gives the same buffer and the same +/// dirty area as drawing it pixel by pixel. The panel width is not a multiple of 8. +TEST(EPaperDrawPixels, SameResultAsDrawingPixelByPixel) { + struct Block { + int x, y, w, h; + }; + const Block blocks[] = {{6, 5, 12, 6}, {-3, -2, 10, 8}, {14, 8, 12, 9}, {-5, 4, 30, 3}, {-2, -3, 26, 20}}; + const display::DisplayRotation rotations[] = { + display::DISPLAY_ROTATION_0_DEGREES, display::DISPLAY_ROTATION_90_DEGREES, display::DISPLAY_ROTATION_180_DEGREES, + display::DISPLAY_ROTATION_270_DEGREES}; + // Swapping the axes comes from the 90 and 270 degree rotations + const uint8_t mirrorings[] = {NONE, MIRROR_X, MIRROR_Y, MIRROR_X | MIRROR_Y}; + uint32_t seed = 1; + for (const auto rotation : rotations) { + for (const uint8_t mirroring : mirrorings) { + for (const bool big_endian : {false, true}) { + for (const auto &block : blocks) { + TestableDisplay fast(21, 13); + TestableDisplay slow(21, 13); + for (auto *display : {&fast, &slow}) { + display->set_transform(mirroring); + display->set_rotation(rotation); + } + // a 3 pixel left offset, 2 rows skipped and 4 pixels of padding + const auto data = random_bytes((3 + block.w + 4) * (2 + block.h), seed++); + fast.draw_pixels_at(block.x, block.y, block.w, block.h, data.data(), display::COLOR_ORDER_RGB, + display::COLOR_BITNESS_565, big_endian, 3, 2, 4); + slow.draw_one_by_one(block.x, block.y, block.w, block.h, data.data(), display::COLOR_ORDER_RGB, + display::COLOR_BITNESS_565, big_endian, 3, 2, 4); + const auto where = ::testing::Message() << "rotation " << rotation << " mirroring " << int(mirroring) + << " big endian " << big_endian << " block " << block.x << "," + << block.y << " " << block.w << "x" << block.h; + EXPECT_EQ(fast.buffer(), slow.buffer()) << where; + EXPECT_EQ(fast.bounds(), slow.bounds()) << where; + } + } + } + } +} + +/// The colour order reaches the RGB565 block writer. +TEST(EPaperDrawPixels, ColourOrderIsHonoured) { + for (const auto order : {display::COLOR_ORDER_RGB, display::COLOR_ORDER_BGR, display::COLOR_ORDER_GRB}) { + TestableDisplay fast(21, 13); + TestableDisplay slow(21, 13); + const auto data = random_bytes(21 * 13, 99); + fast.draw_pixels_at(0, 0, 21, 13, data.data(), order, display::COLOR_BITNESS_565, false, 0, 0, 0); + slow.draw_one_by_one(0, 0, 21, 13, data.data(), order, display::COLOR_BITNESS_565, false, 0, 0, 0); + EXPECT_EQ(fast.buffer(), slow.buffer()) << "order " << int(order); + } +} + +/// With a clipping region set the block goes through draw_pixel_at(), which honours it. +TEST(EPaperDrawPixels, ClippingFallsBackToDrawingPixelByPixel) { + TestableDisplay fast(24, 16); + TestableDisplay slow(24, 16); + const auto data = rgb565(24 * 16, 7); + for (auto *display : {&fast, &slow}) + display->start_clipping(4, 4, 12, 10); + fast.draw_pixels_at(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0); + slow.draw_one_by_one(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0); + EXPECT_EQ(fast.buffer(), slow.buffer()); + EXPECT_EQ(fast.bounds(), (std::vector{4, 4, 12, 10})); +} + +/// A driver whose buffer is not 1 bit per pixel keeps its own draw_pixel_at() for blocks too. +TEST(EPaperDrawPixels, OtherBufferLayoutsDrawPixelByPixel) { + TestableDisplay fast(16, 4); + TestableDisplay slow(16, 4); + const auto data = rgb565(16 * 4, 11); + fast.draw_pixels_at(0, 0, 16, 4, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0); + slow.draw_one_by_one(0, 0, 16, 4, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, 0); + EXPECT_EQ(fast.buffer(), slow.buffer()); +} + +/// The RGB565 lookup gives exactly what converting the colour and thresholding it does, for every value and order. +TEST(EPaperDrawPixels, Rgb565LookupMatchesTheColourConversion) { + for (const auto order : {display::COLOR_ORDER_RGB, display::COLOR_ORDER_BGR, display::COLOR_ORDER_GRB}) { + const Rgb565ToMono to_mono(order); + size_t mismatches = 0; + for (uint32_t value = 0; value != 0x10000; value++) { + const uint8_t expected = color_to_mono(ColorUtil::to_color(value, order, display::COLOR_BITNESS_565)); + if (to_mono(static_cast(value)) != expected) + mismatches++; + } + EXPECT_EQ(mismatches, 0u) << "order " << int(order); + } +} + +/// Other bit depths, and a buffer split into several blocks, go through draw_pixel_at() and give the same result. +TEST(EPaperDrawPixels, OtherCasesDrawPixelByPixel) { + { + TestableDisplay fast(24, 16); + TestableDisplay slow(24, 16); + fast.split_buffer(7); + slow.split_buffer(7); + const auto data = rgb565(24 * 16, 5); + fast.draw_pixels_at(-2, 3, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, + 0); + slow.draw_one_by_one(-2, 3, 24, 16, data.data(), display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, false, 0, 0, + 0); + EXPECT_EQ(fast.buffer(), slow.buffer()) << "split buffer"; + EXPECT_EQ(fast.bounds(), slow.bounds()) << "split buffer"; + } + for (const auto bitness : {display::COLOR_BITNESS_888, display::COLOR_BITNESS_332}) { + TestableDisplay fast(24, 16); + TestableDisplay slow(24, 16); + const auto data = random_bytes(24 * 16, 9); + fast.draw_pixels_at(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, bitness, false, 0, 0, 0); + slow.draw_one_by_one(0, 0, 24, 16, data.data(), display::COLOR_ORDER_RGB, bitness, false, 0, 0, 0); + EXPECT_EQ(fast.buffer(), slow.buffer()) << "bitness " << int(bitness); + EXPECT_EQ(fast.bounds(), slow.bounds()) << "bitness " << int(bitness); + } +} + +} // namespace esphome::epaper_spi::testing From 8721976066be7f191ae6b481ee812d3558adb7b0 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 13 Oct 2026 22:47:09 +0200 Subject: [PATCH 023/142] [epaper_spi] Abandon an update when the busy pin never clears (#20555) --- esphome/components/epaper_spi/epaper_spi.cpp | 21 ++- esphome/components/epaper_spi/epaper_spi.h | 9 +- .../epaper_spi/epaper_spi_t133a01.cpp | 9 ++ .../epaper_spi/epaper_spi_t133a01.h | 1 + .../epaper_spi/display/test_busy_timeout.cpp | 145 ++++++++++++++++++ 5 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 tests/components/epaper_spi/display/test_busy_timeout.cpp diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index 2a2f74763f0..7b962d59ea8 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -131,9 +131,7 @@ void EPaperBase::update() { } void EPaperBase::wait_for_idle_(bool should_wait) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE this->waiting_for_idle_start_ = millis(); -#endif this->waiting_for_idle_ = should_wait; } @@ -156,6 +154,10 @@ void EPaperBase::loop() { ESP_LOGV(TAG, "Screen was busy for %u ms", (unsigned) (millis() - this->waiting_for_idle_start_)); #endif } else { + if (now - this->waiting_for_idle_start_ > this->busy_timeout_seconds_ * 1000u) { + this->abandon_update_(); + return; + } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE if (now - this->waiting_for_idle_last_print_ >= 1000) { ESP_LOGV(TAG, "Waiting for idle in state %s", this->epaper_state_to_string_()); @@ -168,6 +170,20 @@ void EPaperBase::loop() { this->process_state_(); } +// The busy pin never cleared: give up on this update so later ones are not refused, and make the next one full +void EPaperBase::abandon_update_() { + ESP_LOGE(TAG, "Display stayed busy for %u s in state %s; check the busy pin", (unsigned) this->busy_timeout_seconds_, + this->epaper_state_to_string_()); + this->status_set_warning(LOG_STR("Busy pin timeout")); + if (this->state_ == EPaperState::TRANSFER_DATA) + this->abort_transfer(); + // Leave the panel powered down rather than with its high-voltage drivers enabled + this->power_off(); + this->full_update_requested_ = true; + this->current_data_index_ = 0; + this->set_state_(EPaperState::IDLE); +} + /** * Process the state machine. * Typical state sequence: @@ -242,6 +258,7 @@ void EPaperBase::process_state_() { break; case EPaperState::DEEP_SLEEP: this->deep_sleep(); + this->status_clear_warning(); this->set_state_(EPaperState::IDLE); ESP_LOGD(TAG, "Display update took %" PRIu32 " ms", millis() - this->update_start_time_); break; diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index c7ef90f1160..7f1167ae6a4 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -127,6 +127,7 @@ class EPaperBase : public Display, virtual bool initialise(bool partial); void send_init_sequence_(const uint8_t *sequence, size_t length); void wait_for_idle_(bool should_wait); + void abandon_update_(); bool init_buffer_(size_t buffer_length); void update_effective_transform_(); bool rotate_coordinates_(int &x, int &y); @@ -143,6 +144,8 @@ class EPaperBase : public Display, * @return true if done, false if it should be called next loop */ virtual bool transfer_data() = 0; + // Called when an update is abandoned in the middle of transfer_data(), to release anything it left open + virtual void abort_transfer() {} /** * Refresh the screen after data transfer */ @@ -193,18 +196,20 @@ class EPaperBase : public Display, // point to the pixel past the last one updated, i.e. may range up to width/height. uint16_t x_low_{}, y_low_{}, x_high_{}, y_high_{}; + uint32_t waiting_for_idle_start_{}; #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE uint32_t waiting_for_idle_last_print_{}; - uint32_t waiting_for_idle_start_{}; #endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG uint32_t update_start_time_{}; #endif // properties with specific initialisers go last - EPaperState state_{EPaperState::IDLE}; uint32_t reset_duration_{10}; + // Longer than any panel takes to refresh; past it the busy pin is assumed stuck + uint16_t busy_timeout_seconds_{120}; uint8_t full_update_every_{1}; + EPaperState state_{EPaperState::IDLE}; }; } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_t133a01.cpp b/esphome/components/epaper_spi/epaper_spi_t133a01.cpp index bd5a4c9b6e2..585b21f807a 100644 --- a/esphome/components/epaper_spi/epaper_spi_t133a01.cpp +++ b/esphome/components/epaper_spi/epaper_spi_t133a01.cpp @@ -364,6 +364,15 @@ bool HOT EPaperT133A01::transfer_data() { return true; } +void EPaperT133A01::abort_transfer() { + // A data stream is open, with one of the chip selects held low, whenever transfer_data() has started sending rows + if (this->current_data_index_ == 0) + return; + this->disable(); + this->cs_pin_->digital_write(true); + this->cs1_pin_->digital_write(true); +} + void EPaperT133A01::dump_config() { EPaperBase::dump_config(); LOG_PIN(" CS Pin: ", this->cs_pin_); diff --git a/esphome/components/epaper_spi/epaper_spi_t133a01.h b/esphome/components/epaper_spi/epaper_spi_t133a01.h index 0d07fc03ae8..5d356f90b01 100644 --- a/esphome/components/epaper_spi/epaper_spi_t133a01.h +++ b/esphome/components/epaper_spi/epaper_spi_t133a01.h @@ -46,6 +46,7 @@ class EPaperT133A01 : public EPaperBase { void deep_sleep() override; bool transfer_data() override; + void abort_transfer() override; /** * Send a command (and optional data) selecting one or both controllers. diff --git a/tests/components/epaper_spi/display/test_busy_timeout.cpp b/tests/components/epaper_spi/display/test_busy_timeout.cpp new file mode 100644 index 00000000000..7080619b2ff --- /dev/null +++ b/tests/components/epaper_spi/display/test_busy_timeout.cpp @@ -0,0 +1,145 @@ +#include + +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/epaper_spi_ssd1683.h" +#include "esphome/components/epaper_spi/epaper_spi_t133a01.h" + +namespace esphome::epaper_spi::testing { + +/// A busy pin the test can hold at "busy" and release. +class SwitchableBusyPin : public RecordingPin { + public: + bool digital_read() override { return this->busy; } + + bool busy{false}; +}; + +template void loop_until_idle(T &display) { + const uint32_t deadline = millis() + 1000; + while (display.state() != EPaperState::IDLE && millis() < deadline) + display.loop(); +} + +class TestableSSD1683 : public EPaperSSD1683 { + public: + TestableSSD1683() : EPaperSSD1683("test", 16, 2, nullptr, 0) {} + + void install(spi::SPIDelegate *delegate) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + this->set_busy_pin(&this->busy); + this->set_full_update_every(5); + this->set_auto_clear(false); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + this->busy_timeout_seconds_ = 0; // abandon as soon as the stuck pin is seen, so the test does not wait + } + + void draw_whole_panel() { + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + } + + EPaperState state() const { return this->state_; } + uint8_t update_count() const { return this->update_count_; } + void set_update_count(uint8_t count) { this->update_count_ = count; } + + RecordingPin dc; + SwitchableBusyPin busy; +}; + +/// If the busy pin never clears, the update is abandoned instead of waiting for ever: the display goes back to +/// idle with a warning. Once the pin works again the next update is accepted and is a full one, even though nothing +/// was drawn since, and completing it clears the warning. +TEST(EPaperBusyTimeout, StuckBusyPinAbandonsTheUpdateAndRecovers) { + TestableSSD1683 display; + RecordingDelegate bus(&display.dc); + display.install(&bus); + display.set_update_count(3); + + // Stick the pin after the data has been sent, when the dirty area has already been emptied + display.draw_whole_panel(); + display.update(); + const uint32_t deadline = millis() + 1000; + while (display.state() <= EPaperState::TRANSFER_DATA && millis() < deadline) + display.loop(); + ASSERT_EQ(display.state(), EPaperState::POWER_ON); + display.busy.busy = true; + loop_until_idle(display); + + EXPECT_EQ(display.state(), EPaperState::IDLE) << "the update was not abandoned"; + EXPECT_TRUE(display.status_has_warning()); + + display.busy.busy = false; + display.update(); + EXPECT_NE(display.state(), EPaperState::IDLE) << "a later update was refused"; + loop_until_idle(display); + + EXPECT_EQ(display.state(), EPaperState::IDLE) << "the later update did not complete"; + EXPECT_EQ(display.update_count(), 1) << "the later update was not a full one"; + EXPECT_FALSE(display.status_has_warning()); +} + +class TestableT133A01 : public EPaperT133A01 { + public: + TestableT133A01() : EPaperT133A01("test", 8, 2, nullptr, 0) {} + + void install(spi::SPIDelegate *delegate) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + this->set_cs_pins(&this->cs, &this->cs1); + this->set_busy_pin(&this->busy); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + this->busy_timeout_seconds_ = 0; // abandon as soon as the stuck pin is seen, so the test does not wait + } + + void start_transfer() { this->set_state_(EPaperState::TRANSFER_DATA); } + EPaperState state() const { return this->state_; } + + RecordingPin dc, cs, cs1; + SwitchableBusyPin busy; +}; + +/// The T133A01 waits for the busy pin with a data stream open. Abandoning the update there must close the SPI +/// transaction and release both chip selects, or the next command would block. +TEST(EPaperBusyTimeout, AbandonedT133A01TransferReleasesTheBus) { + TestableT133A01 display; + TimedSPIDelegate delegate(MAX_TRANSFER_TIME + 5); + display.install(&delegate); + + display.start_transfer(); + display.loop(); + ASSERT_EQ(display.state(), EPaperState::TRANSFER_DATA); + ASSERT_FALSE(display.cs.level) << "the transfer should have yielded with its data stream open"; + + display.busy.busy = true; + loop_until_idle(display); + + ASSERT_EQ(display.state(), EPaperState::IDLE) << "the update was not abandoned"; + EXPECT_EQ(delegate.begin_count, delegate.end_count) << "SPI transaction left open"; + EXPECT_TRUE(display.cs.level) << "CS left asserted"; + EXPECT_TRUE(display.cs1.level) << "CS1 left asserted"; +} + +/// Abandoning an update powers the panel off, so it is not left with its high-voltage drivers enabled. +TEST(EPaperBusyTimeout, AbandonedUpdatePowersThePanelOff) { + TestableT133A01 display; + RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 5); // burn time so the transfer yields mid-stream + display.install(&bus); + + display.start_transfer(); + display.loop(); + ASSERT_EQ(display.state(), EPaperState::TRANSFER_DATA); + + display.busy.busy = true; + loop_until_idle(display); + ASSERT_EQ(display.state(), EPaperState::IDLE) << "the update was not abandoned"; + + EXPECT_NE(std::find(bus.commands.begin(), bus.commands.end(), 0x02), bus.commands.end()) + << "the panel was not powered off (POF) when the update was abandoned"; +} + +} // namespace esphome::epaper_spi::testing From f941fee5edb459fe17f1159800ee6294c4b4d34b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 11:10:14 -1000 Subject: [PATCH 024/142] [homeassistant] Add sensor platform benchmarks (#20637) --- .../bench_homeassistant_sensor.cpp | 196 ++++++++++++++++++ .../components/homeassistant/benchmark.yaml | 3 + .../homeassistant/sensor/__init__.py | 13 ++ 3 files changed, 212 insertions(+) create mode 100644 tests/benchmarks/components/homeassistant/bench_homeassistant_sensor.cpp create mode 100644 tests/benchmarks/components/homeassistant/benchmark.yaml create mode 100644 tests/benchmarks/components/homeassistant/sensor/__init__.py diff --git a/tests/benchmarks/components/homeassistant/bench_homeassistant_sensor.cpp b/tests/benchmarks/components/homeassistant/bench_homeassistant_sensor.cpp new file mode 100644 index 00000000000..8ecf56ed130 --- /dev/null +++ b/tests/benchmarks/components/homeassistant/bench_homeassistant_sensor.cpp @@ -0,0 +1,196 @@ +#include "esphome/core/defines.h" +#if defined(USE_API_PLAINTEXT) && defined(USE_API_HOMEASSISTANT_STATES) && defined(USE_SENSOR) + +#include +#include + +#include +#include + +#include "esphome/components/api/api_connection.h" +#include "esphome/components/api/api_server.h" +#include "esphome/components/homeassistant/sensor/homeassistant_sensor.h" +#include "esphome/components/socket/socket.h" +#include "esphome/core/string_ref.h" + +namespace esphome::homeassistant::benchmarks { + +static constexpr int kInnerIterations = 2000; + +// Each benchmark gets its own API server so subscriptions from one do not +// stay in the list scanned by the next. The constructor installs itself as +// global_api_server, which is what HomeassistantSensor::setup() subscribes to. +class ScopedApiServer { + public: + ScopedApiServer() : previous_(api::global_api_server) {} + ~ScopedApiServer() { api::global_api_server = this->previous_; } + api::APIServer *get() { return &this->server_; } + + protected: + api::APIServer *previous_; + api::APIServer server_; +}; + +// The handler only reads the server's subscription list, so the connection is +// never started and its socket never used. +static std::unique_ptr create_connection(api::APIServer *server) { + const int fd = ::socket(AF_INET, SOCK_STREAM, 0); + return std::make_unique(std::make_unique(fd), server); +} + +// State strings as Home Assistant sends them. The handler writes a terminator +// one byte past the state, so the buffers are mutable and the terminator it +// writes lands on the one the literal already has. +static constexpr int kStateCount = 4; +static constexpr size_t kStateBufferSize = 16; +using StateBuffers = char[kStateCount][kStateBufferSize]; +using Messages = std::array; + +static StateBuffers NUMERIC_STATES = {"100.50", "101.25", "99.75", "100.00"}; +static StateBuffers INTEGER_STATES = {"100", "101", "99", "0"}; +static StateBuffers UNAVAILABLE_STATES = {"unavailable", "unknown", "unavailable", "unknown"}; + +static Messages make_messages(const char *entity_id, const char *attribute, StateBuffers &states) { + Messages msgs; + for (int i = 0; i < kStateCount; i++) { + msgs[i].entity_id = StringRef(entity_id); + if (attribute != nullptr) { + msgs[i].attribute = StringRef(attribute); + } + msgs[i].state = StringRef(states[i]); + } + return msgs; +} + +// --- One imported sensor, numeric state that changes every message --- +// Exercises: subscription match, in-place terminator, parse_number, publish_state. + +static void HomeassistantSensor_StateResponse(benchmark::State &state) { + ScopedApiServer server; + auto conn = create_connection(server.get()); + const Messages msgs = make_messages("sensor.power", nullptr, NUMERIC_STATES); + + HomeassistantSensor sensor; + sensor.set_entity_id("sensor.power"); + sensor.setup(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->on_home_assistant_state_response(msgs[i % kStateCount]); + } + benchmark::DoNotOptimize(sensor.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(HomeassistantSensor_StateResponse); + +// --- The same sensor with integer states such as a percentage or a count --- +// Exercises: the same path with no fractional digits to parse. Only the state +// differs from the decimal case, so the gap between the two is the parse. + +static void HomeassistantSensor_StateResponse_Integer(benchmark::State &state) { + ScopedApiServer server; + auto conn = create_connection(server.get()); + const Messages msgs = make_messages("sensor.power", nullptr, INTEGER_STATES); + + HomeassistantSensor sensor; + sensor.set_entity_id("sensor.power"); + sensor.setup(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->on_home_assistant_state_response(msgs[i % kStateCount]); + } + benchmark::DoNotOptimize(sensor.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(HomeassistantSensor_StateResponse_Integer); + +// --- Attribute import: the subscription and the message both carry an attribute --- +// Exercises: the extra attribute compare before the callback. + +static void HomeassistantSensor_StateResponse_Attribute(benchmark::State &state) { + ScopedApiServer server; + auto conn = create_connection(server.get()); + const Messages msgs = make_messages("climate.living_room", "current_temperature", NUMERIC_STATES); + + HomeassistantSensor sensor; + sensor.set_entity_id("climate.living_room"); + sensor.set_attribute("current_temperature"); + sensor.setup(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->on_home_assistant_state_response(msgs[i % kStateCount]); + } + benchmark::DoNotOptimize(sensor.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(HomeassistantSensor_StateResponse_Attribute); + +// --- Sixteen imported sensors, message for the last one --- +// Exercises: the linear scan over the subscription list with a string compare +// per entry, which is what a device with many imported entities pays per update. +// The IDs share a length and differ only in the last two bytes, so every +// entry runs a near-full compare, the worst case for the scan. + +static constexpr int kSensorCount = 16; + +static void HomeassistantSensor_StateResponse_ManySubscriptions(benchmark::State &state) { + ScopedApiServer server; + auto conn = create_connection(server.get()); + + static constexpr const char *const ENTITY_IDS[kSensorCount] = { + "sensor.import_00", "sensor.import_01", "sensor.import_02", "sensor.import_03", + "sensor.import_04", "sensor.import_05", "sensor.import_06", "sensor.import_07", + "sensor.import_08", "sensor.import_09", "sensor.import_10", "sensor.import_11", + "sensor.import_12", "sensor.import_13", "sensor.import_14", "sensor.import_15", + }; + HomeassistantSensor sensors[kSensorCount]; + for (int i = 0; i < kSensorCount; i++) { + sensors[i].set_entity_id(ENTITY_IDS[i]); + sensors[i].setup(); + } + const Messages msgs = make_messages(ENTITY_IDS[kSensorCount - 1], nullptr, NUMERIC_STATES); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->on_home_assistant_state_response(msgs[i % kStateCount]); + } + benchmark::DoNotOptimize(sensors[kSensorCount - 1].state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(HomeassistantSensor_StateResponse_ManySubscriptions); + +// --- Non-numeric state such as "unavailable" --- +// Exercises: the failed parse, the warning the platform logs for it, and the +// NAN publish. The benchmark logger runs at the production default level with +// console output off, so a log line at DEBUG or above costs its formatting +// here, the same as on a device, and a log level change in the platform shows +// up as a delta. + +static void HomeassistantSensor_StateResponse_Unavailable(benchmark::State &state) { + ScopedApiServer server; + auto conn = create_connection(server.get()); + const Messages msgs = make_messages("sensor.power", nullptr, UNAVAILABLE_STATES); + + HomeassistantSensor sensor; + sensor.set_entity_id("sensor.power"); + sensor.setup(); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->on_home_assistant_state_response(msgs[i % kStateCount]); + } + benchmark::DoNotOptimize(sensor.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(HomeassistantSensor_StateResponse_Unavailable); + +} // namespace esphome::homeassistant::benchmarks + +#endif // USE_API_PLAINTEXT && USE_API_HOMEASSISTANT_STATES && USE_SENSOR diff --git a/tests/benchmarks/components/homeassistant/benchmark.yaml b/tests/benchmarks/components/homeassistant/benchmark.yaml new file mode 100644 index 00000000000..e3924a22117 --- /dev/null +++ b/tests/benchmarks/components/homeassistant/benchmark.yaml @@ -0,0 +1,3 @@ +# The homeassistant platforms subscribe through the API server. +api: +sensor: diff --git a/tests/benchmarks/components/homeassistant/sensor/__init__.py b/tests/benchmarks/components/homeassistant/sensor/__init__.py new file mode 100644 index 00000000000..c4b3f4d8d67 --- /dev/null +++ b/tests/benchmarks/components/homeassistant/sensor/__init__.py @@ -0,0 +1,13 @@ +import esphome.codegen as cg +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # In production the platform's to_code emits this define; the host build + # suppresses that to_code, and the API server needs the define for the + # state subscription path the benchmarks drive. + async def to_code(config: ConfigType) -> None: + cg.add_define("USE_API_HOMEASSISTANT_STATES") + + manifest.to_code = to_code From 42341d05f9a3fda0c2afb410e8b3cc4afa65b713 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Tue, 13 Oct 2026 23:12:08 +0200 Subject: [PATCH 025/142] [nrf52] Accept S140 7.2.0 and 7.3.0 in the sd140_v7 DFU package (#20661) Co-authored-by: Claude --- esphome/components/nrf52/__init__.py | 6 ++++-- .../components/nrf52/test_run_compile_hex.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index f2a78259973..fcdffc0c645 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -1040,11 +1040,13 @@ def run_compile(args, config: ConfigType) -> bool: framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] bootloader = zephyr_data()[KEY_BOOTLOADER] - # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes + # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes. + # The bootloader resets on a package whose sd_req does not list its SoftDevice, + # so v7 accepts S140 7.0.1, 7.2.0 and 7.3.0 (e.g. Seeed XIAO nRF52840). _GENPKG_PARAMS = { BOOTLOADER_ADAFRUIT_NRF52_SD132: ("0x0051", "0x009D"), BOOTLOADER_ADAFRUIT_NRF52_SD140_V6: ("0x0052", "0x00B6"), - BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA"), + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA,0x0100,0x0123"), } # UF2 family IDs — nRF52832 vs nRF52840 per SoftDevice variant _UF2_FAMILY_IDS = { diff --git a/tests/unit_tests/components/nrf52/test_run_compile_hex.py b/tests/unit_tests/components/nrf52/test_run_compile_hex.py index f86a29a9d24..4684f6189d0 100644 --- a/tests/unit_tests/components/nrf52/test_run_compile_hex.py +++ b/tests/unit_tests/components/nrf52/test_run_compile_hex.py @@ -128,3 +128,13 @@ def test_stale_merged_hex_is_removed_before_building( app_hex = str(build_dir / "zephyr" / "zephyr.hex") assert _uf2_hex(run_cmd) == app_hex assert _dfu_hex(run_cmd) == app_hex + + +def test_sd140_v7_dfu_accepts_all_s140_7_softdevices( + build_dir: Path, run_cmd: Mock +) -> None: + """Boards such as the Seeed XIAO nRF52840 ship S140 7.3.0, not 7.0.1.""" + _build(build_dir, cv.Version(3, 4, 0), ["zephyr/zephyr/zephyr.hex"], run_cmd) + cmd = next(c.args[0] for c in run_cmd.call_args_list if "genpkg" in c.args[0]) + sd_req = cmd[cmd.index("--sd-req") + 1].split(",") + assert sd_req == ["0x00CA", "0x0100", "0x0123"] From 0dc1d3b5a19ddd47603dc6938b419c2997acfa66 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 11:25:18 -1000 Subject: [PATCH 026/142] [toshiba] Move the lookup tables to PROGMEM (#20636) --- esphome/components/toshiba/toshiba.cpp | 50 +++++++++----------- tests/components/toshiba/toshiba_tests.cpp | 55 ++++++++++++++++++++++ 2 files changed, 77 insertions(+), 28 deletions(-) diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index 406a1bc55db..eae24641c40 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -3,8 +3,6 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" -#include - namespace esphome::toshiba { struct RacPt1411hwruFanSpeed { @@ -69,8 +67,8 @@ const uint8_t RAC_PT1411HWRU_CS_FOOTER_COOL = 0x72; const uint8_t RAC_PT1411HWRU_CS_FOOTER_HEAT = 0x7E; // RAC-PT1411HWRU Swing const uint8_t RAC_PT1411HWRU_SWING_HEADER = 0xB9; -const std::vector RAC_PT1411HWRU_SWING_VERTICAL{0xB9, 0x46, 0xF5, 0x0A, 0x04, 0xFB}; -const std::vector RAC_PT1411HWRU_SWING_OFF{0xB9, 0x46, 0xF5, 0x0A, 0x05, 0xFA}; +static constexpr uint8_t RAC_PT1411HWRU_SWING_VERTICAL[] PROGMEM = {0xB9, 0x46, 0xF5, 0x0A, 0x04, 0xFB}; +static constexpr uint8_t RAC_PT1411HWRU_SWING_OFF[] PROGMEM = {0xB9, 0x46, 0xF5, 0x0A, 0x05, 0xFA}; // RAC-PT1411HWRU Fan speeds const uint8_t RAC_PT1411HWRU_FAN_OFF = 0x7B; constexpr RacPt1411hwruFanSpeed RAC_PT1411HWRU_FAN_AUTO{0xBF, 0x66}; @@ -92,12 +90,12 @@ const uint8_t RAC_PT1411HWRU_TEMPERATURE_FAN_ONLY = 0x0E; // Hence these look-up tables. In addition, the upper nibble is used here for additional // "negative" and "fractional value" flags as required for some temperatures. // RAC-PT1411HWRU °C Temperatures (short codes) -const std::vector RAC_PT1411HWRU_TEMPERATURE_C{0x10, 0x00, 0x01, 0x03, 0x02, 0x06, 0x07, 0x05, - 0x04, 0x0C, 0x0D, 0x09, 0x08, 0x0A, 0x0B}; +static constexpr uint8_t RAC_PT1411HWRU_TEMPERATURE_C[] PROGMEM = {0x10, 0x00, 0x01, 0x03, 0x02, 0x06, 0x07, 0x05, + 0x04, 0x0C, 0x0D, 0x09, 0x08, 0x0A, 0x0B}; // RAC-PT1411HWRU °F Temperatures (short codes) -const std::vector RAC_PT1411HWRU_TEMPERATURE_F{0x10, 0x30, 0x00, 0x20, 0x01, 0x21, 0x03, 0x23, 0x02, - 0x22, 0x06, 0x26, 0x07, 0x05, 0x25, 0x04, 0x24, 0x0C, - 0x2C, 0x0D, 0x2D, 0x09, 0x08, 0x28, 0x0A, 0x2A, 0x0B}; +static constexpr uint8_t RAC_PT1411HWRU_TEMPERATURE_F[] PROGMEM = { + 0x10, 0x30, 0x00, 0x20, 0x01, 0x21, 0x03, 0x23, 0x02, 0x22, 0x06, 0x26, 0x07, 0x05, + 0x25, 0x04, 0x24, 0x0C, 0x2C, 0x0D, 0x2D, 0x09, 0x08, 0x28, 0x0A, 0x2A, 0x0B}; // RAS-2819T protocol constants const uint16_t RAS_2819T_HEADER1 = 0xC23D; @@ -213,7 +211,7 @@ static constexpr uint8_t SEIYA_SWING_HORIZONTAL = 0x09; static constexpr uint8_t SEIYA_SWING_NEUTRAL = 0x17; // no swing change in this frame // RAS-2819T temperature codes for 18-30°C -static const uint8_t RAS_2819T_TEMP_CODES[] = { +static constexpr uint8_t RAS_2819T_TEMP_CODES[] PROGMEM = { 0x10, // 18°C 0x30, // 19°C 0x20, // 20°C @@ -328,7 +326,7 @@ static uint8_t get_ras_2819t_temp_code(float temperature) { return 0x40; // Default to 24°C } - return RAS_2819T_TEMP_CODES[temp_index]; + return progmem_read_byte(&RAS_2819T_TEMP_CODES[temp_index]); } /** @@ -339,7 +337,7 @@ static float decode_ras_2819t_temperature(uint8_t temp_code) { // Find the code in the temperature array for (size_t temp_index = 0; temp_index < sizeof(RAS_2819T_TEMP_CODES); temp_index++) { - if (RAS_2819T_TEMP_CODES[temp_index] == base_temp_code) { + if (progmem_read_byte(&RAS_2819T_TEMP_CODES[temp_index]) == base_temp_code) { return static_cast(temp_index + 18); // 18°C is the minimum } } @@ -680,10 +678,10 @@ void ToshibaClimate::transmit_rac_pt1411hwru_() { index = static_cast(roundf(temp_adjd)); if (this->model_ == MODEL_RAC_PT1411HWRU_F) { - code = RAC_PT1411HWRU_TEMPERATURE_F[index]; + code = progmem_read_byte(&RAC_PT1411HWRU_TEMPERATURE_F[index]); message[9] |= RAC_PT1411HWRU_FLAG_FAH; } else { - code = RAC_PT1411HWRU_TEMPERATURE_C[index]; + code = progmem_read_byte(&RAC_PT1411HWRU_TEMPERATURE_C[index]); } if ((this->mode == climate::CLIMATE_MODE_FAN_ONLY) || (this->mode == climate::CLIMATE_MODE_OFF)) { code = RAC_PT1411HWRU_TEMPERATURE_FAN_ONLY; @@ -752,15 +750,11 @@ void ToshibaClimate::transmit_rac_pt1411hwru_() { // Swing Mode data->reset(); data->space(TOSHIBA_PACKET_SPACE); - switch (this->swing_mode) { - case climate::CLIMATE_SWING_VERTICAL: - this->encode_(data, &RAC_PT1411HWRU_SWING_VERTICAL[0], RAC_PT1411HWRU_MESSAGE_LENGTH, 1); - break; - - case climate::CLIMATE_SWING_OFF: - default: - this->encode_(data, &RAC_PT1411HWRU_SWING_OFF[0], RAC_PT1411HWRU_MESSAGE_LENGTH, 1); - } + const uint8_t *swing_frame = + this->swing_mode == climate::CLIMATE_SWING_VERTICAL ? RAC_PT1411HWRU_SWING_VERTICAL : RAC_PT1411HWRU_SWING_OFF; + uint8_t swing[RAC_PT1411HWRU_MESSAGE_LENGTH]; + progmem_memcpy(swing, swing_frame, sizeof(swing)); + this->encode_(data, swing, sizeof(swing), 1); data->space(TOSHIBA_PACKET_SPACE); transmit.perform(); @@ -1300,14 +1294,14 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { temperature_code = (message[4] >> 4) | (message[14] & RAC_PT1411HWRU_FLAG_FRAC) | (message[15] & RAC_PT1411HWRU_FLAG_NEG); if (message[15] & RAC_PT1411HWRU_FLAG_FAH) { - for (size_t i = 0; i < RAC_PT1411HWRU_TEMPERATURE_F.size(); i++) { - if (RAC_PT1411HWRU_TEMPERATURE_F[i] == temperature_code) { + for (size_t i = 0; i < sizeof(RAC_PT1411HWRU_TEMPERATURE_F); i++) { + if (progmem_read_byte(&RAC_PT1411HWRU_TEMPERATURE_F[i]) == temperature_code) { this->target_temperature = static_cast((i + TOSHIBA_RAC_PT1411HWRU_TEMP_F_MIN - 32) * 5) / 9; } } } else { - for (size_t i = 0; i < RAC_PT1411HWRU_TEMPERATURE_C.size(); i++) { - if (RAC_PT1411HWRU_TEMPERATURE_C[i] == temperature_code) { + for (size_t i = 0; i < sizeof(RAC_PT1411HWRU_TEMPERATURE_C); i++) { + if (progmem_read_byte(&RAC_PT1411HWRU_TEMPERATURE_C[i]) == temperature_code) { this->target_temperature = i + TOSHIBA_RAC_PT1411HWRU_TEMP_C_MIN; } } @@ -1333,7 +1327,7 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { break; // Swing mode case RAC_PT1411HWRU_SWING_HEADER: - if (message[4] == RAC_PT1411HWRU_SWING_VERTICAL[4]) { + if (message[4] == progmem_read_byte(&RAC_PT1411HWRU_SWING_VERTICAL[4])) { this->swing_mode = climate::CLIMATE_SWING_VERTICAL; } else { this->swing_mode = climate::CLIMATE_SWING_OFF; diff --git a/tests/components/toshiba/toshiba_tests.cpp b/tests/components/toshiba/toshiba_tests.cpp index 528af888544..3c0ce6cd6ce 100644 --- a/tests/components/toshiba/toshiba_tests.cpp +++ b/tests/components/toshiba/toshiba_tests.cpp @@ -130,6 +130,61 @@ TEST(ToshibaGeneric, WaveformIsUnchangedByTheSharedPath) { expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x30, 0xC3, 0x00, 0xF2}, GENERIC), 9); } +// The RAC-PT1411HWRU sends its main message, then a separate swing packet +// wrapped in packet spaces; both use the generic timings. +static void expect_swing_packet(const remote_base::RawTimings &actual, const std::vector &bytes) { + ASSERT_GE(actual.size(), 2u); + EXPECT_EQ(actual.front(), -10500); + EXPECT_EQ(actual.back(), -10500); + const remote_base::RawTimings frame(actual.begin() + 1, actual.end() - 1); + expect_frame(frame, waveform(bytes, GENERIC), 6); +} + +TEST(ToshibaRacPt1411hwru, SendsTheSwingFrameFromFlash) { + Fixture f(MODEL_RAC_PT1411HWRU_C); + + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_VERTICAL); + expect_swing_packet(f.last_frame(), {0xB9, 0x46, 0xF5, 0x0A, 0x04, 0xFB}); + + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_OFF); + expect_swing_packet(f.last_frame(), {0xB9, 0x46, 0xF5, 0x0A, 0x05, 0xFA}); +} + +// The temperature tables are read on the way out and searched on the way back +// in, so what one fixture sends must decode to the same state in another. +TEST(ToshibaRacPt1411hwru, TemperatureCodesRoundTrip) { + for (const Model model : {MODEL_RAC_PT1411HWRU_C, MODEL_RAC_PT1411HWRU_F}) { + for (int temperature = 16; temperature <= 30; temperature++) { + SCOPED_TRACE(::testing::Message() << "model " << model << " temperature " << temperature); + Fixture tx(model); + tx.command(climate::CLIMATE_MODE_COOL, temperature, climate::CLIMATE_FAN_AUTO); + // The main message is sent before the swing packet + const auto &main_frame = tx.transmitter.frames[tx.transmitter.frames.size() - 2]; + + Fixture rx(model); + remote_base::RemoteReceiveData data(main_frame, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); + ASSERT_TRUE(static_cast(rx.climate).on_receive(data)); + EXPECT_EQ(rx.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_NEAR(rx.climate.target_temperature, temperature, 0.3f); + } + } +} + +// The RAS-2819T table is read for the code on the way out and searched on the way back in. +TEST(ToshibaRas2819t, TemperatureCodesRoundTrip) { + for (int temperature = 18; temperature <= 30; temperature++) { + SCOPED_TRACE(::testing::Message() << "temperature " << temperature); + Fixture tx(MODEL_RAS_2819T); + tx.command(climate::CLIMATE_MODE_COOL, temperature, climate::CLIMATE_FAN_AUTO); + + Fixture rx(MODEL_RAS_2819T); + remote_base::RemoteReceiveData data(tx.last_frame(), 25, remote_base::TOLERANCE_MODE_PERCENTAGE); + ASSERT_TRUE(static_cast(rx.climate).on_receive(data)); + EXPECT_EQ(rx.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(rx.climate.target_temperature, temperature); + } +} + static bool receive(ToshibaClimate &climate, const std::vector &bytes, const Timings &t) { const remote_base::RawTimings raw = waveform(bytes, t); remote_base::RemoteReceiveData data(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); From 6c24d4aa43c72962619579038a62334126244f6d Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:05:16 +0000 Subject: [PATCH 027/142] Bump aioesphomeapi from 46.9.0 to 46.10.0 (#20563) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b7df6ca4fc7..a54897ef36d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.2.0 esptool==5.4.0 click==8.5.0 -aioesphomeapi==46.9.0 +aioesphomeapi==46.10.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 From f4a6d6f18bc3252daf851e199e239666857cfb63 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Oct 2026 01:28:07 -1000 Subject: [PATCH 028/142] [core] Create the PlatformIO HTTP cache dir before the prefetch resolve threads run (#20579) --- esphome/platformio/library.py | 7 +++---- esphome/platformio/prefetch.py | 4 ++++ esphome/platformio/runner.py | 11 +++++++++++ tests/unit_tests/test_platformio_prefetch.py | 13 +++++++++++++ tests/unit_tests/test_platformio_runner.py | 15 +++++++++++++++ 5 files changed, 46 insertions(+), 4 deletions(-) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 12aaae315e0..769b86519fa 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -620,12 +620,11 @@ def _make_registry_client() -> Any: elsewhere, not by the PlatformIO registry. """ from platformio.package.manager._registry import PackageManagerRegistryMixin - from platformio.project.helpers import get_project_cache_dir from platformio.registry.client import RegistryClient - # PlatformIO creates its HTTP cache dir without exist_ok, so two builds - # making their first registry lookup at once race on it - (Path(get_project_cache_dir()) / "http").mkdir(parents=True, exist_ok=True) + from esphome.platformio.runner import ensure_http_cache_dir + + ensure_http_cache_dir() class _Registry(PackageManagerRegistryMixin): def __init__(self) -> None: diff --git a/esphome/platformio/prefetch.py b/esphome/platformio/prefetch.py index 991164eb993..a86b5a63b97 100644 --- a/esphome/platformio/prefetch.py +++ b/esphome/platformio/prefetch.py @@ -841,6 +841,10 @@ def _prefetch(build_dir: Path, env: str) -> None: from platformio.package.meta import PackageCompatibility, PackageSpec from platformio.platform.factory import PlatformFactory + from esphome.platformio.runner import ensure_http_cache_dir + + # The resolve and install pools each build their own registry client + ensure_http_cache_dir() platform_spec, config = _project_platform_and_config( build_dir / "platformio.ini", env ) diff --git a/esphome/platformio/runner.py b/esphome/platformio/runner.py index b9fbdec38d0..cde79af0b68 100644 --- a/esphome/platformio/runner.py +++ b/esphome/platformio/runner.py @@ -106,6 +106,17 @@ def patch_file_downloader() -> None: FileDownloader.__init__ = patched_init +def ensure_http_cache_dir() -> None: + """Create PlatformIO's HTTP cache dir before anything resolves a package. + + PlatformIO creates it without exist_ok on the first registry lookup, so + concurrent lookups (the prefetch's resolve threads, or two builds) race. + """ + from platformio.project.helpers import get_project_cache_dir + + (Path(get_project_cache_dir()) / "http").mkdir(parents=True, exist_ok=True) + + def patch_registry_private_packages() -> None: """Skip PlatformIO's private-package probe; it sleeps ~500 ms per lookup. diff --git a/tests/unit_tests/test_platformio_prefetch.py b/tests/unit_tests/test_platformio_prefetch.py index 3f01fb22e6d..8a60e9c3881 100644 --- a/tests/unit_tests/test_platformio_prefetch.py +++ b/tests/unit_tests/test_platformio_prefetch.py @@ -561,6 +561,19 @@ def test_registry_lock_deadline_skips_registration(tmp_path: Path) -> None: manager.set_download_utime.assert_not_called() +def test_prefetch_creates_http_cache_dir_first( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Eight resolve threads each build a registry client whose cache + init has no exist_ok; the dir must exist before any of them run.""" + from platformio.project import helpers + + monkeypatch.setattr(helpers, "get_project_cache_dir", lambda: str(tmp_path)) + with patch.object(pf, "_project_platform_and_config", return_value=(None, None)): + pf._prefetch(tmp_path, "testenv") + assert (tmp_path / "http").is_dir() + + def test_main_interrupt_exits_quietly(tmp_path: Path) -> None: """Ctrl-C reaches the child via the shared process group; it must exit without a traceback.""" diff --git a/tests/unit_tests/test_platformio_runner.py b/tests/unit_tests/test_platformio_runner.py index 007455f45a3..b108cf298ab 100644 --- a/tests/unit_tests/test_platformio_runner.py +++ b/tests/unit_tests/test_platformio_runner.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable import io +from pathlib import Path import sys from types import ModuleType from unittest.mock import Mock @@ -110,6 +111,20 @@ def test_main_applies_registry_private_packages_patch( assert order == ["patch", "pio"] +def test_ensure_http_cache_dir_is_idempotent( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The dir exists before PlatformIO's non-exist_ok makedirs, and a + second caller (another thread or build) does not trip over it.""" + from platformio.project import helpers + + monkeypatch.setattr(helpers, "get_project_cache_dir", lambda: str(tmp_path / "c")) + + runner.ensure_http_cache_dir() + assert (tmp_path / "c" / "http").is_dir() + runner.ensure_http_cache_dir() + + # Snapshot PlatformIO's own probe at import, before any test can patch it _PIO_PROBE = RegistryClient.__dict__["allowed_private_packages"] From 39ea6a32204b8c085e6f7b25c6990c93126b502d Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 12 Oct 2026 11:22:59 -0400 Subject: [PATCH 029/142] [audio] Bump microFLAC to v0.3.0 (#20591) --- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 1480faa8c3f..332638e75bb 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -391,7 +391,7 @@ async def to_code(config: ConfigType) -> None: # Adds a define and IDF component for legacy `audio_decoder.cpp`. if data.flac_support: cg.add_define("USE_AUDIO_FLAC_SUPPORT") - add_idf_component(name="esphome/micro-flac", ref="0.2.0") + add_idf_component(name="esphome/micro-flac", ref="0.3.0") _emit_memory_pair( data.flac.buffer_memory, "CONFIG_MICRO_FLAC_PREFER_PSRAM", diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 3d9f1431f67..a490f007d94 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -10,7 +10,7 @@ dependencies: esphome/micro-decoder: version: 0.4.0 esphome/micro-flac: - version: 0.2.0 + version: 0.3.0 esphome/micro-mp3: version: 0.4.0 esphome/micro-opus: From c9664d4e4282149fe2daeafefe2ee6840b56d2ad Mon Sep 17 00:00:00 2001 From: Firat Hajiyev Date: Mon, 12 Oct 2026 13:15:13 -0400 Subject: [PATCH 030/142] [wifi] Reject unsupported WPA3 on ESP8266 (#20592) Co-authored-by: Firat17aze <187820330+Firat17aze@users.noreply.github.com> --- esphome/components/wifi/__init__.py | 7 ++ .../wifi/test_min_auth_mode.py | 79 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 tests/component_tests/wifi/test_min_auth_mode.py diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index f357bf75b35..f500bc6e3f0 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -344,6 +344,12 @@ def validate_variant(_): ) +def _validate_min_auth_mode(value: str) -> str: + if value == "WPA3" and CORE.is_esp8266: + raise cv.Invalid("ESP8266 does not support WPA3; use WPA or WPA2 instead") + return value + + def _apply_min_auth_mode_default(config): """Apply platform-specific default for min_auth_mode.""" if CONF_MIN_AUTH_MODE not in config and (CORE.is_esp8266 or CORE.is_esp32): @@ -522,6 +528,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_MIN_AUTH_MODE): cv.All( VALIDATE_WIFI_MIN_AUTH_MODE, cv.only_on([Platform.ESP32, Platform.ESP8266]), + _validate_min_auth_mode, ), cv.SplitDefault(CONF_OUTPUT_POWER, esp8266=20.0): cv.All( cv.decibel, cv.float_range(min=8.5, max=20.5) diff --git a/tests/component_tests/wifi/test_min_auth_mode.py b/tests/component_tests/wifi/test_min_auth_mode.py new file mode 100644 index 00000000000..e0e5ca98053 --- /dev/null +++ b/tests/component_tests/wifi/test_min_auth_mode.py @@ -0,0 +1,79 @@ +"""Platform restrictions for minimum Wi-Fi authentication modes.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.esp32 import KEY_VARIANT +from esphome.components.esp32.const import VARIANT_ESP32 +from esphome.components.wifi import CONF_MIN_AUTH_MODE, CONFIG_SCHEMA +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def _validate(mode: str | None = None) -> dict: + config = {"ssid": "net", "password": "secret12", "use_address": "test.local"} + if mode is not None: + config[CONF_MIN_AUTH_MODE] = mode + return CONFIG_SCHEMA(config) + + +@pytest.mark.parametrize("mode", ["WPA3", "wpa3"]) +def test_esp8266_rejects_wpa3( + set_core_config: SetCoreConfigCallable, mode: str +) -> None: + set_core_config(PlatformFramework.ESP8266_ARDUINO) + with pytest.raises(cv.Invalid, match="ESP8266 does not support WPA3") as exc: + _validate(mode) + assert exc.value.path == [CONF_MIN_AUTH_MODE] + + +@pytest.mark.parametrize( + ("platform", "mode"), + [ + (PlatformFramework.ESP8266_ARDUINO, "WPA"), + (PlatformFramework.ESP8266_ARDUINO, "WPA2"), + (PlatformFramework.ESP32_IDF, "WPA"), + (PlatformFramework.ESP32_IDF, "WPA2"), + (PlatformFramework.ESP32_IDF, "WPA3"), + (PlatformFramework.ESP32_IDF, "wpa3"), + (PlatformFramework.ESP32_ARDUINO, "WPA"), + (PlatformFramework.ESP32_ARDUINO, "WPA2"), + (PlatformFramework.ESP32_ARDUINO, "WPA3"), + ], +) +def test_supported_modes( + set_core_config: SetCoreConfigCallable, platform: PlatformFramework, mode: str +) -> None: + set_core_config( + platform, + platform_data=( + {KEY_VARIANT: VARIANT_ESP32} + if platform + in (PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO) + else None + ), + ) + assert _validate(mode)[CONF_MIN_AUTH_MODE] == mode.upper() + + +@pytest.mark.parametrize( + "platform", + [ + PlatformFramework.ESP8266_ARDUINO, + PlatformFramework.ESP32_IDF, + PlatformFramework.ESP32_ARDUINO, + ], +) +def test_default_remains_wpa2( + set_core_config: SetCoreConfigCallable, platform: PlatformFramework +) -> None: + set_core_config( + platform, + platform_data=( + {KEY_VARIANT: VARIANT_ESP32} + if platform + in (PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO) + else None + ), + ) + assert _validate()[CONF_MIN_AUTH_MODE] == "WPA2" From b43630cf8e4075d8daf6cdde01f18a471a592c90 Mon Sep 17 00:00:00 2001 From: emes30 <35312919+emes30@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:01:11 +0200 Subject: [PATCH 031/142] [rp2_pio] Use pioasm 4.1.0 so PIO programs run on RP2350 (#19260) Co-authored-by: J. Nick Koston --- esphome/components/rp2/__init__.py | 2 ++ esphome/components/rp2_pio/__init__.py | 8 ++++---- .../components/rp2040_pio_led_strip/test.rp2350-ard.yaml | 2 ++ 3 files changed, 8 insertions(+), 4 deletions(-) create mode 100644 tests/components/rp2040_pio_led_strip/test.rp2350-ard.yaml diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index 23346ca6f4b..1ac37f8e456 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -194,6 +194,8 @@ def _parse_platform_version(value: Any) -> str: # * The new version needs to be thoroughly validated before changing the # recommended version as otherwise a bunch of devices could be bricked # * For all constants below, update platformio.ini (in this repo) +# * rp2_pio pins its own pioasm build (PIOASM_REPO_VERSION in rp2_pio/__init__.py); check +# https://github.com/earlephilhower/pico-quick-toolchain/releases for a matching release # The default/recommended arduino framework version # - https://github.com/earlephilhower/arduino-pico/releases diff --git a/esphome/components/rp2_pio/__init__.py b/esphome/components/rp2_pio/__init__.py index 915ab73b876..96ed422de2d 100644 --- a/esphome/components/rp2_pio/__init__.py +++ b/esphome/components/rp2_pio/__init__.py @@ -7,9 +7,9 @@ from esphome.types import ConfigType DEPENDENCIES = ["rp2"] -PIOASM_REPO_VERSION = "1.5.0-b" +PIOASM_REPO_VERSION = "4.1.0" PIOASM_REPO_BASE = f"https://github.com/earlephilhower/pico-quick-toolchain/releases/download/{PIOASM_REPO_VERSION}" -PIOASM_VERSION = "pioasm-2e6142b.230216" +PIOASM_VERSION = "pioasm-9fdfe11.250530" PIOASM_DOWNLOADS = { "linux": { "aarch64": f"aarch64-linux-gnu.{PIOASM_VERSION}.tar.gz", @@ -20,8 +20,8 @@ PIOASM_DOWNLOADS = { "amd64": f"x86_64-w64-mingw32.{PIOASM_VERSION}.zip", }, "darwin": { - "x86_64": f"x86_64-apple-darwin14.{PIOASM_VERSION}.tar.gz", - "arm64": f"x86_64-apple-darwin14.{PIOASM_VERSION}.tar.gz", + "x86_64": f"x86_64-apple-darwin20.4.{PIOASM_VERSION}.tar.gz", + "arm64": f"aarch64-apple-darwin20.4.{PIOASM_VERSION}.tar.gz", }, } diff --git a/tests/components/rp2040_pio_led_strip/test.rp2350-ard.yaml b/tests/components/rp2040_pio_led_strip/test.rp2350-ard.yaml new file mode 100644 index 00000000000..25135f1d390 --- /dev/null +++ b/tests/components/rp2040_pio_led_strip/test.rp2350-ard.yaml @@ -0,0 +1,2 @@ +packages: + rp2040_pio_led_strip: !include common.yaml From bb0546fc0b2633db98f53afd09d5cce81c8caacd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:31:19 +0200 Subject: [PATCH 032/142] [script] Keep the running script.wait item out of the queue while its chain runs (#20570) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/script/script.h | 10 ++- .../script/test_script_wait_action.cpp | 86 +++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index f00ad7f4afa..efd75d27e0e 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -295,8 +295,11 @@ template class ScriptWaitAction final : public Actionnum_running_ == 0) + if (this->num_running_ == 0) { + // Nothing left to wait for, e.g. once the last queued item has run + this->disable_loop(); return; + } if (this->script_->is_running()) return; @@ -305,9 +308,10 @@ template class ScriptWaitAction final : public Actionparam_queue_.empty()) { - auto ¶ms = this->param_queue_.front(); - std::apply([this](auto &...args) { this->play_next_(args...); }, params); + // Take the item out first: the chain may call stop(), which clears the queue + auto params = std::move(this->param_queue_.front()); this->param_queue_.pop_front(); + std::apply([this](auto &...args) { this->play_next_(args...); }, params); } else { // Queue is now empty - disable loop until next play_complex this->disable_loop(); diff --git a/tests/components/script/test_script_wait_action.cpp b/tests/components/script/test_script_wait_action.cpp index 17f553996ea..c3cd55fc8ca 100644 --- a/tests/components/script/test_script_wait_action.cpp +++ b/tests/components/script/test_script_wait_action.cpp @@ -2,6 +2,7 @@ #include #include +#include #include "esphome/components/script/script.h" @@ -93,4 +94,89 @@ TEST(ScriptWaitActionTest, KeepsReferenceToNonCopyableArguments) { EXPECT_EQ(entity.value, 1); } +class StopWaitAction : public Action { + public: + void play(const std::string &value) override { + this->wait->stop_complex(); + this->seen = value; + } + Action *wait{nullptr}; + std::string seen; +}; + +// The chain after the wait may stop it, e.g. a script with mode: restart +TEST(ScriptWaitActionTest, StopDuringChainKeepsArguments) { + FakeScript script; + ScriptWaitAction wait(&script); + StopWaitAction stop; + stop.wait = &wait; + ActionList actions; + actions.add_actions({&wait, &stop}); + + // With a second item waiting, stop() clears the queue while the first item's chain runs + actions.play(std::string("argument longer than the small string buffer")); + actions.play(std::string("second argument, also longer than the buffer")); + script.running = false; + wait.loop(); + EXPECT_EQ(stop.seen, "argument longer than the small string buffer"); + wait.loop(); + EXPECT_EQ(stop.seen, "argument longer than the small string buffer"); +} + +class RestartAction : public Action { + public: + void play(const std::string &value) override { + this->seen.push_back(value); + if (this->seen.size() > 1) + return; + // What a script with mode: restart does: stop, then run again while the awaited script is busy + this->wait->stop_complex(); + this->script->running = true; + this->actions->play(std::string("restarted, longer than the small string buffer")); + } + Action *wait{nullptr}; + FakeScript *script{nullptr}; + ActionList *actions{nullptr}; + std::vector seen; +}; + +// The old pop_front() after the chain removed the item queued by the restart +TEST(ScriptWaitActionTest, RestartDuringChainKeepsNewItem) { + FakeScript script; + ScriptWaitAction wait(&script); + RestartAction restart; + ActionList actions; + actions.add_actions({&wait, &restart}); + restart.wait = &wait; + restart.script = &script; + restart.actions = &actions; + + // With a second item waiting, the stop really clears the queue; the restart then queues a new item + actions.play(std::string("first argument, longer than the small string buffer")); + actions.play(std::string("second argument, dropped by the stop")); + script.running = false; + wait.loop(); + ASSERT_EQ(restart.seen.size(), 1u); + + script.running = false; + wait.loop(); + ASSERT_EQ(restart.seen.size(), 2u); + EXPECT_EQ(restart.seen[1], "restarted, longer than the small string buffer"); +} + +TEST(ScriptWaitActionTest, LoopDisabledOnceNothingWaits) { + FakeScript script; + ScriptWaitAction wait(&script); + RecordAction record; + ActionList actions; + actions.add_actions({&wait, &record}); + + actions.play(std::string("argument")); + script.running = false; + wait.loop(); + EXPECT_EQ(record.seen, "argument"); + wait.loop(); + EXPECT_TRUE(wait.is_idle()); +} + } // namespace esphome::script::testing From 53d504137b9219b1c853beac247712f16a085176 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Tue, 13 Oct 2026 23:12:08 +0200 Subject: [PATCH 033/142] [nrf52] Accept S140 7.2.0 and 7.3.0 in the sd140_v7 DFU package (#20661) Co-authored-by: Claude --- esphome/components/nrf52/__init__.py | 6 ++++-- .../components/nrf52/test_run_compile_hex.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index f8649f968ed..16fddadc878 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -1039,11 +1039,13 @@ def run_compile(args, config: ConfigType) -> bool: framework_ver = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] bootloader = zephyr_data()[KEY_BOOTLOADER] - # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes + # (dev_type, sd_req) per bootloader — values from Nordic SoftDevice release notes. + # The bootloader resets on a package whose sd_req does not list its SoftDevice, + # so v7 accepts S140 7.0.1, 7.2.0 and 7.3.0 (e.g. Seeed XIAO nRF52840). _GENPKG_PARAMS = { BOOTLOADER_ADAFRUIT_NRF52_SD132: ("0x0051", "0x009D"), BOOTLOADER_ADAFRUIT_NRF52_SD140_V6: ("0x0052", "0x00B6"), - BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA"), + BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: ("0x0052", "0x00CA,0x0100,0x0123"), } # UF2 family IDs — nRF52832 vs nRF52840 per SoftDevice variant _UF2_FAMILY_IDS = { diff --git a/tests/unit_tests/components/nrf52/test_run_compile_hex.py b/tests/unit_tests/components/nrf52/test_run_compile_hex.py index f86a29a9d24..4684f6189d0 100644 --- a/tests/unit_tests/components/nrf52/test_run_compile_hex.py +++ b/tests/unit_tests/components/nrf52/test_run_compile_hex.py @@ -128,3 +128,13 @@ def test_stale_merged_hex_is_removed_before_building( app_hex = str(build_dir / "zephyr" / "zephyr.hex") assert _uf2_hex(run_cmd) == app_hex assert _dfu_hex(run_cmd) == app_hex + + +def test_sd140_v7_dfu_accepts_all_s140_7_softdevices( + build_dir: Path, run_cmd: Mock +) -> None: + """Boards such as the Seeed XIAO nRF52840 ship S140 7.3.0, not 7.0.1.""" + _build(build_dir, cv.Version(3, 4, 0), ["zephyr/zephyr/zephyr.hex"], run_cmd) + cmd = next(c.args[0] for c in run_cmd.call_args_list if "genpkg" in c.args[0]) + sd_req = cmd[cmd.index("--sd-req") + 1].split(",") + assert sd_req == ["0x00CA", "0x0100", "0x0123"] From 5a78f636b99f53f01c0bb21cf3ec6b368075b6cd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:43:50 +1300 Subject: [PATCH 034/142] Bump version to 2026.10.0b4 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index a72069f8449..614b9a6fc05 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.10.0b3 +PROJECT_NUMBER = 2026.10.0b4 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 15dcce03ff9..3888f8f95d3 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.10.0b3" +__version__ = "2026.10.0b4" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From a97ce3a20140c2deddcbc7227926c2775f7ebc48 Mon Sep 17 00:00:00 2001 From: guillempages Date: Tue, 13 Oct 2026 23:58:21 +0200 Subject: [PATCH 035/142] [image] Fix pillow deprecation warning (#20679) --- esphome/components/file/image.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index ab769954124..20eda9a3278 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -330,7 +330,9 @@ async def write_image( if byte_order is not None: # Check for valid type has already been done in validate_settings encoder.set_big_endian(byte_order == "BIG_ENDIAN") - pixels = encoder.convert(image.resize((width, height)), path).getdata() + pixels = encoder.convert( + image.resize((width, height)), path + ).get_flattened_data() for row in range(height): for col in range(width): encoder.encode(pixels[row * width + col]) From 6562c49686969f6c23baf5dcdb3cb73a12b6ec01 Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:08:13 +0100 Subject: [PATCH 036/142] [thermostat] Allow a dry-only thermostat without cool_action (#20682) --- esphome/components/thermostat/climate.py | 17 +++++---- .../config/cooling_timer_without_action.yaml | 27 ++++++++++++++ .../thermostat/config/dry_only.yaml | 30 ++++++++++++++++ .../thermostat/test_validation.py | 36 +++++++++++++++++++ 4 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 tests/component_tests/thermostat/config/cooling_timer_without_action.yaml create mode 100644 tests/component_tests/thermostat/config/dry_only.yaml create mode 100644 tests/component_tests/thermostat/test_validation.py diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index 8a954b181eb..6d8c24918d3 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -240,12 +240,6 @@ def validate_thermostat(config: ConfigType) -> ConfigType: CONF_SUPPLEMENTAL_HEATING_ACTION, CONF_SUPPLEMENTAL_HEATING_DELTA, ], - CONF_MIN_COOLING_OFF_TIME: [ - CONF_COOL_ACTION, - ], - CONF_MIN_COOLING_RUN_TIME: [ - CONF_COOL_ACTION, - ], CONF_MIN_FANNING_OFF_TIME: [ CONF_FAN_ONLY_ACTION, ], @@ -284,6 +278,17 @@ def validate_thermostat(config: ConfigType) -> ConfigType: f"{req_trigger} must be defined to use {config_trigger}" ) + # Dry mode runs on the cooling timers, so a dehumidifier needs no cool_action. + for timer in (CONF_MIN_COOLING_OFF_TIME, CONF_MIN_COOLING_RUN_TIME): + if ( + timer in config + and CONF_COOL_ACTION not in config + and CONF_DRY_ACTION not in config + ): + raise cv.Invalid( + f"{CONF_COOL_ACTION} or {CONF_DRY_ACTION} must be defined to use {timer}" + ) + if CONF_FAN_ONLY_ACTION in config: # determine validation requirements based on fan_only_action_uses_fan_mode_timer setting if config[CONF_FAN_ONLY_ACTION_USES_FAN_MODE_TIMER] is True: diff --git a/tests/component_tests/thermostat/config/cooling_timer_without_action.yaml b/tests/component_tests/thermostat/config/cooling_timer_without_action.yaml new file mode 100644 index 00000000000..31e2818d936 --- /dev/null +++ b/tests/component_tests/thermostat/config/cooling_timer_without_action.yaml @@ -0,0 +1,27 @@ +--- +esphome: + name: test + +logger: + +esp32: + board: esp32dev + +sensor: + - platform: template + id: temp_sensor + lambda: return 21.0; + +climate: + - platform: thermostat + id: heater + name: Heater + sensor: temp_sensor + min_idle_time: 30s + min_heating_off_time: 300s + min_heating_run_time: 300s + min_cooling_off_time: 300s + heat_action: + - logger.log: heat + idle_action: + - logger.log: idle diff --git a/tests/component_tests/thermostat/config/dry_only.yaml b/tests/component_tests/thermostat/config/dry_only.yaml new file mode 100644 index 00000000000..f4dd0d6ca3b --- /dev/null +++ b/tests/component_tests/thermostat/config/dry_only.yaml @@ -0,0 +1,30 @@ +--- +esphome: + name: test + +logger: + +esp32: + board: esp32dev + +sensor: + - platform: template + id: temp_sensor + lambda: return 21.0; + +climate: + - platform: thermostat + id: dehumidifier + name: Dehumidifier + sensor: temp_sensor + min_idle_time: 0s + min_cooling_off_time: 0s + min_cooling_run_time: 0s + min_fanning_off_time: 0s + min_fanning_run_time: 0s + idle_action: + - logger.log: idle + fan_only_action: + - logger.log: fan only + dry_action: + - logger.log: dry diff --git a/tests/component_tests/thermostat/test_validation.py b/tests/component_tests/thermostat/test_validation.py new file mode 100644 index 00000000000..53be07d1f72 --- /dev/null +++ b/tests/component_tests/thermostat/test_validation.py @@ -0,0 +1,36 @@ +"""Tests for the thermostat config validation.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.config import read_config +from esphome.core import CORE + + +def test_dry_only_config_is_valid( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A dehumidifier has dry mode but no cool_action; dry mode uses the cooling timers.""" + main_cpp = generate_main(component_config_path("dry_only.yaml")) + + assert "dehumidifier->set_cooling_minimum_off_time_in_sec(0);" in main_cpp + assert "dehumidifier->set_cooling_minimum_run_time_in_sec(0);" in main_cpp + assert "dehumidifier->set_supports_dry(true);" in main_cpp + assert "dehumidifier->set_supports_cool(" not in main_cpp + + +def test_cooling_timer_needs_cool_or_dry_action( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + """A cooling timer with neither cool_action nor dry_action is still rejected.""" + CORE.config_path = component_config_path("cooling_timer_without_action.yaml") + assert read_config({}) is None + captured = capsys.readouterr() + assert ( + "cool_action or dry_action must be defined to use min_cooling_off_time" + in captured.out + ) From a0c7d754cbe037e43499283f32bc30e76de597c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 12:22:35 -1000 Subject: [PATCH 037/142] [syslog] Keep the log level to severity table in flash (#20662) --- esphome/components/syslog/esphome_syslog.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/syslog/esphome_syslog.cpp b/esphome/components/syslog/esphome_syslog.cpp index a40829f3153..e9149e0dae1 100644 --- a/esphome/components/syslog/esphome_syslog.cpp +++ b/esphome/components/syslog/esphome_syslog.cpp @@ -2,12 +2,13 @@ #include "esphome/components/logger/logger.h" #include "esphome/core/application.h" +#include "esphome/core/hal.h" #include "esphome/core/time.h" namespace esphome::syslog { // Map log levels to syslog severity using an array, indexed by ESPHome log level (1-7) -constexpr int LOG_LEVEL_TO_SYSLOG_SEVERITY[] = { +static constexpr uint8_t LOG_LEVEL_TO_SYSLOG_SEVERITY[] PROGMEM = { 3, // NONE 3, // ERROR 4, // WARN @@ -35,7 +36,7 @@ void Syslog::log_(const int level, const char *tag, const char *message, size_t // Syslog PRI calculation: facility * 8 + severity int severity = 7; if ((unsigned) level <= 7) { - severity = LOG_LEVEL_TO_SYSLOG_SEVERITY[level]; + severity = progmem_read_byte(&LOG_LEVEL_TO_SYSLOG_SEVERITY[level]); } int pri = this->facility_ * 8 + severity; From e265c0c0fc8e78300f8a9897585d826545dd0820 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:30:42 -0400 Subject: [PATCH 038/142] [core] Sync the static analysis dependency pins with the code (#20681) --- esphome/components/qr_code/__init__.py | 2 +- esphome/components/runtime_image/__init__.py | 2 +- esphome/idf_component.yml | 2 ++ platformio.ini | 10 +++++----- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/esphome/components/qr_code/__init__.py b/esphome/components/qr_code/__init__.py index 222041837bd..356d78469fe 100644 --- a/esphome/components/qr_code/__init__.py +++ b/esphome/components/qr_code/__init__.py @@ -35,7 +35,7 @@ CONFIG_SCHEMA = cv.ensure_list( async def to_code(config: list[ConfigType]) -> None: - cg.add_library("wjtje/qr-code-generator-library", "^1.7.0") + cg.add_library("wjtje/qr-code-generator-library", "1.7.0") for entry in config: var = cg.new_Pvariable(entry[CONF_ID]) diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index fcf5cb770c1..bccf5248687 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -115,7 +115,7 @@ class PNGFormat(Format): def actions(self) -> None: cg.add_define("USE_RUNTIME_IMAGE_PNG") - cg.add_library("pngle", "1.1.0") + cg.add_library("kikuchan98/pngle", "1.1.0") class QOIFormat(Format): diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 1ede1f4d8de..2ef48602d1b 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -21,6 +21,8 @@ dependencies: version: "1.8.2" espressif/esp-tflite-micro: version: 1.3.3~1 + espressif/esp-nn: + version: 1.1.2 espressif/esp32-camera: version: 2.1.7 espressif/mdns: diff --git a/platformio.ini b/platformio.ini index 0d6338d6cd1..96f3d25b6eb 100644 --- a/platformio.ini +++ b/platformio.ini @@ -46,9 +46,9 @@ lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea improv/Improv@1.2.7 ; improv_serial / improv_ble - kikuchan98/pngle@1.1.0 ; online_image + kikuchan98/pngle@1.1.0 ; runtime_image ; Using the repository directly, otherwise ESP-IDF can't use the library - https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image + https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; runtime_image ; This dependency is used only in unit tests. ; Must coincide with PLATFORMIO_GOOGLE_TEST_LIB in scripts/cpp_unit_test.py ; See scripts/cpp_unit_test.py and tests/components/README.md @@ -82,7 +82,7 @@ lib_deps = esphome/noise-c@0.1.31 ; noise (api, ota) SPI ; spi (Arduino built-in) Wire ; i2c (Arduino built-int) - heman/AsyncMqttClient-esphome@1.0.0 ; mqtt + heman/AsyncMqttClient-esphome@2.0.0 ; mqtt freekode/TM1651@1.0.1 ; tm1651 tonia/HeatpumpIR@1.0.42 ; heatpumpir build_flags = @@ -252,8 +252,8 @@ lib_deps = DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt improv/Improv@1.2.7 ; improv_serial - kikuchan98/pngle@1.1.0 ; online_image - https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; online_image + kikuchan98/pngle@1.1.0 ; runtime_image + https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; runtime_image build_flags = ${common:arduino.build_flags} -DUSE_LIBRETINY From 5c23a827b65351be1a1692bc9d5c478a245793af Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Wed, 14 Oct 2026 00:35:55 +0200 Subject: [PATCH 039/142] [tmc2209] Add current and diagnostic sensors (#20638) --- esphome/components/tmc2209/sensor/__init__.py | 87 +++++++++++++++++ .../tmc2209/sensor/tmc2209_sensor.cpp | 95 +++++++++++++++++++ .../tmc2209/sensor/tmc2209_sensor.h | 35 +++++++ esphome/components/tmc2209/tmc2209.h | 3 + esphome/components/tmc22xx/tmc22xx.h | 9 ++ tests/components/tmc2209/common.yaml | 23 +++++ 6 files changed, 252 insertions(+) create mode 100644 esphome/components/tmc2209/sensor/__init__.py create mode 100644 esphome/components/tmc2209/sensor/tmc2209_sensor.cpp create mode 100644 esphome/components/tmc2209/sensor/tmc2209_sensor.h diff --git a/esphome/components/tmc2209/sensor/__init__.py b/esphome/components/tmc2209/sensor/__init__.py new file mode 100644 index 00000000000..dd5aadcf7ca --- /dev/null +++ b/esphome/components/tmc2209/sensor/__init__.py @@ -0,0 +1,87 @@ +import esphome.codegen as cg +from esphome.components import sensor +from esphome.components.tmc22xx import CONF_ANALOG_CURRENT_SCALE +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + DEVICE_CLASS_CURRENT, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_AMPERE, + UNIT_PERCENT, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +from .. import TMC2209Stepper, tmc2209_ns + +CONF_ACTUAL_CURRENT = "actual_current" +CONF_MOTOR_LOAD = "motor_load" +CONF_PWM_GRAD_AUTO = "pwm_grad_auto" +CONF_PWM_OFS_AUTO = "pwm_ofs_auto" +CONF_PWM_SCALE_AUTO = "pwm_scale_auto" +CONF_PWM_SCALE_SUM = "pwm_scale_sum" +CONF_STALLGUARD_RESULT = "stallguard_result" +CONF_TMC2209_ID = "tmc2209_id" + +TMC2209Sensor = tmc2209_ns.class_("TMC2209Sensor", cg.PollingComponent) + +_diagnostic_schema = sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(TMC2209Sensor), + cv.GenerateID(CONF_TMC2209_ID): cv.use_id(TMC2209Stepper), + cv.Optional(CONF_ACTUAL_CURRENT): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_MOTOR_LOAD): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_STALLGUARD_RESULT): _diagnostic_schema, + cv.Optional(CONF_PWM_SCALE_SUM): _diagnostic_schema, + cv.Optional(CONF_PWM_SCALE_AUTO): _diagnostic_schema, + cv.Optional(CONF_PWM_OFS_AUTO): _diagnostic_schema, + cv.Optional(CONF_PWM_GRAD_AUTO): _diagnostic_schema, + } +).extend(cv.polling_component_schema("60s")) + + +def _final_validate(config: ConfigType) -> ConfigType: + if CONF_ACTUAL_CURRENT not in config: + return config + full_config = fv.full_config.get() + path = full_config.get_path_for_id(config[CONF_TMC2209_ID])[:-1] + if full_config.get_config_for_path(path).get(CONF_ANALOG_CURRENT_SCALE): + raise cv.Invalid( + f"'{CONF_ACTUAL_CURRENT}' can't be used with '{CONF_ANALOG_CURRENT_SCALE}', " + "the current then depends on VREF", + path=[CONF_ACTUAL_CURRENT], + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_TMC2209_ID]) + var = cg.new_Pvariable(config[CONF_ID], hub) + await cg.register_component(var, config) + sensors = sensor.sub_sensors(config) + await sensors(CONF_ACTUAL_CURRENT, var.set_actual_current_sensor) + await sensors(CONF_STALLGUARD_RESULT, var.set_stallguard_result_sensor) + await sensors(CONF_MOTOR_LOAD, var.set_motor_load_sensor) + await sensors(CONF_PWM_SCALE_SUM, var.set_pwm_scale_sum_sensor) + await sensors(CONF_PWM_SCALE_AUTO, var.set_pwm_scale_auto_sensor) + await sensors(CONF_PWM_OFS_AUTO, var.set_pwm_ofs_auto_sensor) + await sensors(CONF_PWM_GRAD_AUTO, var.set_pwm_grad_auto_sensor) diff --git a/esphome/components/tmc2209/sensor/tmc2209_sensor.cpp b/esphome/components/tmc2209/sensor/tmc2209_sensor.cpp new file mode 100644 index 00000000000..a3a6237bca3 --- /dev/null +++ b/esphome/components/tmc2209/sensor/tmc2209_sensor.cpp @@ -0,0 +1,95 @@ +#include "tmc2209_sensor.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::tmc2209 { + +ESPHOME_LOG_TAG(TAG, "tmc2209.sensor"); + +using tmc22xx::TMC22XXStepper; + +// Highest StallGuard result, reached with no load +static constexpr float STALLGUARD_RESULT_MAX = 510.0f; + +static void publish_field(sensor::Sensor *sensor, const optional &data, const RegisterField &field) { + if (sensor == nullptr) + return; + if (!data.has_value()) { + sensor->publish_state(NAN); + return; + } + uint32_t value = TMC22XXStepper::extract_field(*data, field); + sensor->publish_state(field.is_signed ? static_cast(static_cast(value)) : static_cast(value)); +} + +void TMC2209Sensor::update() { + if (this->parent_->is_failed()) + return; + + // Each failed read blocks the loop for the UART timeout. Leave the bus alone while the driver is known to be + // unreachable or after the first failed read; the remaining sensors then publish NaN. + bool reachable = !this->parent_->is_unreachable(); + auto read = [this, &reachable](uint8_t reg) -> optional { + if (!reachable) + return {}; + auto data = this->parent_->read_register(reg); + reachable = data.has_value(); + return data; + }; + + if (this->actual_current_sensor_ != nullptr) { + auto drv_status = read(tmc22xx::REG_DRV_STATUS); + float current = NAN; + if (drv_status.has_value()) { + auto scale = static_cast(TMC22XXStepper::extract_field(*drv_status, tmc22xx::CS_ACTUAL)); + current = this->parent_->scale_to_current(scale); + } + this->actual_current_sensor_->publish_state(current); + } + + // StallGuard only measures the load while the motor turns + bool moving = !this->parent_->has_reached_target(); + if (this->stallguard_result_sensor_ != nullptr || (this->motor_load_sensor_ != nullptr && moving)) { + auto sg_result = read(REG_SG_RESULT); + publish_field(this->stallguard_result_sensor_, sg_result, SG_RESULT); + if (this->motor_load_sensor_ != nullptr) { + // 100% is the load at which a stall is reported, where the result drops below twice the threshold + float stall_result = 2.0f * this->parent_->get_stallguard_threshold(); + float load = NAN; + if (moving && sg_result.has_value() && stall_result < STALLGUARD_RESULT_MAX) { + float result = TMC22XXStepper::extract_field(*sg_result, SG_RESULT); + load = (STALLGUARD_RESULT_MAX - result) / (STALLGUARD_RESULT_MAX - stall_result) * 100.0f; + } + this->motor_load_sensor_->publish_state(load); + } + } else if (this->motor_load_sensor_ != nullptr) { + this->motor_load_sensor_->publish_state(NAN); + } + + if (this->pwm_scale_sum_sensor_ != nullptr || this->pwm_scale_auto_sensor_ != nullptr) { + auto pwm_scale = read(tmc22xx::REG_PWM_SCALE); + publish_field(this->pwm_scale_sum_sensor_, pwm_scale, tmc22xx::PWM_SCALE_SUM); + publish_field(this->pwm_scale_auto_sensor_, pwm_scale, tmc22xx::PWM_SCALE_AUTO); + } + + if (this->pwm_ofs_auto_sensor_ != nullptr || this->pwm_grad_auto_sensor_ != nullptr) { + auto pwm_auto = read(tmc22xx::REG_PWM_AUTO); + publish_field(this->pwm_ofs_auto_sensor_, pwm_auto, tmc22xx::PWM_OFS_AUTO); + publish_field(this->pwm_grad_auto_sensor_, pwm_auto, tmc22xx::PWM_GRAD_AUTO); + } +} + +void TMC2209Sensor::dump_config() { + ESP_LOGCONFIG(TAG, "TMC2209 Sensor:"); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Actual Current", this->actual_current_sensor_); + LOG_SENSOR(" ", "StallGuard Result", this->stallguard_result_sensor_); + LOG_SENSOR(" ", "Motor Load", this->motor_load_sensor_); + LOG_SENSOR(" ", "PWM Scale Sum", this->pwm_scale_sum_sensor_); + LOG_SENSOR(" ", "PWM Scale Auto", this->pwm_scale_auto_sensor_); + LOG_SENSOR(" ", "PWM Offset Auto", this->pwm_ofs_auto_sensor_); + LOG_SENSOR(" ", "PWM Gradient Auto", this->pwm_grad_auto_sensor_); +} + +} // namespace esphome::tmc2209 diff --git a/esphome/components/tmc2209/sensor/tmc2209_sensor.h b/esphome/components/tmc2209/sensor/tmc2209_sensor.h new file mode 100644 index 00000000000..4ebbf82259a --- /dev/null +++ b/esphome/components/tmc2209/sensor/tmc2209_sensor.h @@ -0,0 +1,35 @@ +#pragma once + +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/tmc2209/tmc2209.h" +#include "esphome/core/component.h" + +namespace esphome::tmc2209 { + +class TMC2209Sensor : public PollingComponent { + public: + explicit TMC2209Sensor(TMC2209Stepper *parent) : parent_(parent) {} + + void update() override; + void dump_config() override; + + void set_actual_current_sensor(sensor::Sensor *sensor) { this->actual_current_sensor_ = sensor; } + void set_stallguard_result_sensor(sensor::Sensor *sensor) { this->stallguard_result_sensor_ = sensor; } + void set_motor_load_sensor(sensor::Sensor *sensor) { this->motor_load_sensor_ = sensor; } + void set_pwm_scale_sum_sensor(sensor::Sensor *sensor) { this->pwm_scale_sum_sensor_ = sensor; } + void set_pwm_scale_auto_sensor(sensor::Sensor *sensor) { this->pwm_scale_auto_sensor_ = sensor; } + void set_pwm_ofs_auto_sensor(sensor::Sensor *sensor) { this->pwm_ofs_auto_sensor_ = sensor; } + void set_pwm_grad_auto_sensor(sensor::Sensor *sensor) { this->pwm_grad_auto_sensor_ = sensor; } + + protected: + TMC2209Stepper *parent_; + sensor::Sensor *actual_current_sensor_{nullptr}; + sensor::Sensor *stallguard_result_sensor_{nullptr}; + sensor::Sensor *motor_load_sensor_{nullptr}; + sensor::Sensor *pwm_scale_sum_sensor_{nullptr}; + sensor::Sensor *pwm_scale_auto_sensor_{nullptr}; + sensor::Sensor *pwm_ofs_auto_sensor_{nullptr}; + sensor::Sensor *pwm_grad_auto_sensor_{nullptr}; +}; + +} // namespace esphome::tmc2209 diff --git a/esphome/components/tmc2209/tmc2209.h b/esphome/components/tmc2209/tmc2209.h index f1c83485fa3..49923316884 100644 --- a/esphome/components/tmc2209/tmc2209.h +++ b/esphome/components/tmc2209/tmc2209.h @@ -9,10 +9,12 @@ using tmc22xx::RegisterField; // StallGuard and CoolStep registers, which the TMC2208 does not have static constexpr uint8_t REG_TCOOLTHRS = 0x14; static constexpr uint8_t REG_SGTHRS = 0x40; +static constexpr uint8_t REG_SG_RESULT = 0x41; static constexpr uint8_t REG_COOLCONF = 0x42; static constexpr RegisterField TCOOLTHRS{REG_TCOOLTHRS, 0, 20, false}; static constexpr RegisterField SGTHRS{REG_SGTHRS, 0, 8, false}; +static constexpr RegisterField SG_RESULT{REG_SG_RESULT, 0, 10, false}; static constexpr RegisterField SEMIN{REG_COOLCONF, 0, 4, false}; static constexpr RegisterField SEUP{REG_COOLCONF, 5, 2, false}; static constexpr RegisterField SEMAX{REG_COOLCONF, 8, 4, false}; @@ -30,6 +32,7 @@ class TMC2209Stepper : public tmc22xx::TMC22XXStepper { /// A stall is reported when the StallGuard result drops below twice this value. void set_stallguard_threshold(uint8_t threshold) { this->write_field(SGTHRS, threshold); } + uint8_t get_stallguard_threshold() const { return extract_field(this->sgthrs_, SGTHRS); } /// Lower velocity limit, as TSTEP, for CoolStep and the StallGuard output on DIAG. void set_tcool_threshold(uint32_t threshold) { this->write_field(TCOOLTHRS, threshold); } void set_semin(uint8_t semin) { this->write_field(SEMIN, semin); } diff --git a/esphome/components/tmc22xx/tmc22xx.h b/esphome/components/tmc22xx/tmc22xx.h index 546a27e70d3..19e4813b70d 100644 --- a/esphome/components/tmc22xx/tmc22xx.h +++ b/esphome/components/tmc22xx/tmc22xx.h @@ -33,6 +33,8 @@ static constexpr uint8_t REG_VACTUAL = 0x22; static constexpr uint8_t REG_CHOPCONF = 0x6C; static constexpr uint8_t REG_DRV_STATUS = 0x6F; static constexpr uint8_t REG_PWMCONF = 0x70; +static constexpr uint8_t REG_PWM_SCALE = 0x71; +static constexpr uint8_t REG_PWM_AUTO = 0x72; // Readable registers that a driver reset sets back to their defaults; the last written values are restored static constexpr std::array RESTORED_REGISTERS{REG_GCONF, REG_FACTORY_CONF, REG_CHOPCONF, REG_PWMCONF}; @@ -59,7 +61,12 @@ static constexpr RegisterField VSENSE{REG_CHOPCONF, 17, 1, false}; static constexpr RegisterField MRES{REG_CHOPCONF, 24, 4, false}; static constexpr RegisterField INTPOL{REG_CHOPCONF, 28, 1, false}; static constexpr RegisterField DEDGE{REG_CHOPCONF, 29, 1, false}; +static constexpr RegisterField CS_ACTUAL{REG_DRV_STATUS, 16, 5, false}; static constexpr RegisterField FREEWHEEL{REG_PWMCONF, 20, 2, false}; +static constexpr RegisterField PWM_SCALE_SUM{REG_PWM_SCALE, 0, 8, false}; +static constexpr RegisterField PWM_SCALE_AUTO{REG_PWM_SCALE, 16, 9, true}; +static constexpr RegisterField PWM_OFS_AUTO{REG_PWM_AUTO, 0, 8, false}; +static constexpr RegisterField PWM_GRAD_AUTO{REG_PWM_AUTO, 16, 8, false}; enum StandstillMode : uint8_t { STANDSTILL_MODE_NORMAL = 0, @@ -129,6 +136,8 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U /// Enable or disable the motor outputs, using the ENN pin when configured and TOFF otherwise. void set_enabled(bool enabled); bool is_enabled() const { return this->enabled_; } + /// True while the driver does not answer, until the status poll reads it again (every 10 s). + bool is_unreachable() const { return this->status_poll_skip_ > 0; } /// Called with the condition and whether it became active or cleared. template void add_on_status_callback(F &&callback) { diff --git a/tests/components/tmc2209/common.yaml b/tests/components/tmc2209/common.yaml index e53c3ba8233..c39d3fd8ba2 100644 --- a/tests/components/tmc2209/common.yaml +++ b/tests/components/tmc2209/common.yaml @@ -84,3 +84,26 @@ esphome: target: 1000 - tmc2209.disable: id: tmc2209_step_dir + +sensor: + - platform: tmc2209 + tmc2209_id: tmc2209_step_dir + update_interval: 10s + actual_current: + name: Actual Current + stallguard_result: + name: StallGuard Result + motor_load: + name: Motor Load + pwm_scale_sum: + name: PWM Scale Sum + pwm_scale_auto: + name: PWM Scale Auto + pwm_ofs_auto: + name: PWM Offset Auto + pwm_grad_auto: + name: PWM Gradient Auto + - platform: tmc2209 + tmc2209_id: tmc2209_serial + stallguard_result: + name: Serial StallGuard Result From 7d735f06774c2bd8a7cca2f5cd3a89e2ab64fe56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:37:09 +0200 Subject: [PATCH 040/142] [emontx] Lock the emonTx on reboot when its firmware supports it (#20621) Co-authored-by: Claude Opus 5.5 --- esphome/components/emontx/__init__.py | 18 +- esphome/components/emontx/emontx.cpp | 156 ++++++++++++--- esphome/components/emontx/emontx.h | 29 ++- .../components/emontx/text_sensor/__init__.py | 1 + esphome/core/defines.h | 1 + .../emontx/test_lock_on_reboot.py | 38 ++++ tests/components/emontx/common-version.yaml | 9 + tests/components/emontx/common.h | 21 ++ tests/components/emontx/common.yaml | 7 - tests/components/emontx/emontx_test.cpp | 185 +++++++++++++++++- .../emontx/test-actions.esp8266-ard.yaml | 1 + .../components/emontx/text_sensor/__init__.py | 1 + .../text_sensor/emontx_version_test.cpp | 31 +-- 13 files changed, 436 insertions(+), 62 deletions(-) create mode 100644 tests/component_tests/emontx/test_lock_on_reboot.py create mode 100644 tests/components/emontx/common-version.yaml diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index d6c46b5509e..8d2351f1fad 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -23,11 +23,18 @@ DEPENDENCIES = ["uart"] emontx_ns = cg.esphome_ns.namespace("emontx") EmonTx = emontx_ns.class_("EmonTx", cg.Component, uart.UARTDevice) +LockOnReboot = emontx_ns.enum("LockOnReboot", is_class=True) CONF_EMONTX_ID = "emontx_id" CONF_TAG_NAME = "tag_name" CONF_ON_JSON = "on_json" CONF_LOCK_ON_REBOOT = "lock_on_reboot" +LOCK_ON_REBOOT_AUTO = "auto" +LOCK_ON_REBOOT_MODES = { + LOCK_ON_REBOOT_AUTO: LockOnReboot.LOCK_ON_REBOOT_AUTO, + True: LockOnReboot.LOCK_ON_REBOOT_ALWAYS, + False: LockOnReboot.LOCK_ON_REBOOT_NEVER, +} DOMAIN = "emontx" @@ -59,7 +66,9 @@ CONFIG_SCHEMA = ( cv.GenerateID(): cv.declare_id(EmonTx), cv.Optional(CONF_ON_JSON): _synchronous_automation_schema(), cv.Optional(CONF_ON_DATA): _synchronous_automation_schema(), - cv.Optional(CONF_LOCK_ON_REBOOT, default=False): cv.boolean, + cv.Optional(CONF_LOCK_ON_REBOOT, default=LOCK_ON_REBOOT_AUTO): cv.Any( + cv.boolean, cv.one_of(LOCK_ON_REBOOT_AUTO, lower=True) + ), } ) .extend(cv.COMPONENT_SCHEMA) @@ -135,8 +144,11 @@ async def to_code(config: ConfigType) -> None: await uart.register_uart_device(var, config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) - if config[CONF_LOCK_ON_REBOOT]: - cg.add(var.set_lock_on_reboot(True)) + lock = config[CONF_LOCK_ON_REBOOT] + cg.add(var.set_lock_on_reboot(LOCK_ON_REBOOT_MODES[lock])) + if lock == LOCK_ON_REBOOT_AUTO: + # Locking depends on the firmware version the emonTx reports + cg.add_define("USE_EMONTX_VERSION") # Action: emontx.send_command diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index 9c8436c0070..dca5ae2131d 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -7,30 +7,85 @@ namespace esphome::emontx { ESPHOME_LOG_TAG(TAG, "emontx"); +static constexpr uint32_t UNLOCK_ID = 0; +// The emonTx joins bytes received while a command is pending into that command, so the next one waits +static constexpr uint32_t COMMAND_GAP_MS = 250; + #ifdef USE_EMONTX_VERSION -static constexpr uint32_t VERSION_REQUEST_ID = 0; -// The emonTx joins bytes received while a command is pending into that command, so 'v' waits -static constexpr uint32_t VERSION_REQUEST_DELAY_MS = 250; +static constexpr uint32_t VERSION_REQUEST_ID = 1; +// Without a reply, 'v' is sent again every VERSION_RETRY_FRAMES data lines, VERSION_MAX_ATTEMPTS times in all +static constexpr uint8_t VERSION_RETRY_FRAMES = 2; +static constexpr uint8_t VERSION_MAX_ATTEMPTS = 3; // Line of the 'v' reply (and of the boot banner) carrying the release, e.g. " - Version: v1.1.1" static const char VERSION_PREFIX[] PROGMEM = " - Version:"; +// Every data line starts with the message counter, in JSON or text format +static const char MSG_JSON_PREFIX[] PROGMEM = "{\"MSG\":"; +static const char MSG_TEXT_PREFIX[] PROGMEM = "MSG:"; +// First firmware with 'emonlock' and 'emonunlock'; older firmware takes them for the bootloader command +static constexpr uint32_t LOCK_MIN_MAJOR = 1; +static constexpr uint32_t LOCK_MIN_MINOR = 2; + +// True if line starts with the PROGMEM prefix and has more after it +static bool starts_with(const char *line, size_t len, const char *prefix, size_t prefix_len) { + return len > prefix_len && ESPHOME_strncmp_P(line, prefix, prefix_len) == 0; +} + +// Reads the decimal number (up to 9 digits) at pos and moves pos past it; false if there is none +static bool read_number(const char *&pos, const char *end, uint32_t &value) { + const char *start = pos; + value = 0; + while (pos < end && pos - start < 9 && *pos >= '0' && *pos <= '9') + value = value * 10 + (*pos++ - '0'); + return pos != start; +} + +// True for a release such as "v1.2.0" or "v1.3.0-rc1" from LOCK_MIN_MAJOR.LOCK_MIN_MINOR on +static bool supports_lock(const char *version, const char *end) { + if (version < end && (*version == 'v' || *version == 'V')) + version++; + uint32_t major; + if (!read_number(version, end, major) || version == end || *version != '.') + return false; + version++; + uint32_t minor; + if (!read_number(version, end, minor)) + return false; + return major > LOCK_MIN_MAJOR || (major == LOCK_MIN_MAJOR && minor >= LOCK_MIN_MINOR); +} #endif void EmonTx::setup() { this->buffer_pos_ = 0; - if (this->lock_on_reboot_) { - // The line feed ends any boot noise first - ESP_LOGI(TAG, "Unlocking emonTx after boot"); + if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_ALWAYS) { + // The line feed ends any boot noise this->write_byte('\n'); - this->send_command("emonunlock"); + this->set_timeout(UNLOCK_ID, COMMAND_GAP_MS, [this]() { + this->unlock_(); +#ifdef USE_EMONTX_VERSION + if (this->wants_version_()) + this->request_version_(); +#endif + }); + return; } #ifdef USE_EMONTX_VERSION - if (this->version_text_sensor_ != nullptr) + // With AUTO, the reply decides whether to unlock + if (this->wants_version_()) this->request_version_(); #endif } +void EmonTx::unlock_() { + ESP_LOGI(TAG, "Unlocking emonTx"); + this->send_command("emonunlock"); +} + void EmonTx::on_shutdown() { - if (!this->lock_on_reboot_) + bool lock = this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_ALWAYS; +#ifdef USE_EMONTX_VERSION + lock |= this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO && this->lock_supported_; +#endif + if (!lock) return; if (this->is_paused()) { ESP_LOGW(TAG, "Not locking emonTx before shutdown, UART is paused"); @@ -63,7 +118,7 @@ void EmonTx::loop() { this->discarding_line_ = dropped != '\n'; #ifdef USE_EMONTX_VERSION // The emonTx may have been flashed, or a request dropped, during the pause - if (this->version_text_sensor_ != nullptr) + if (this->wants_version_()) this->request_version_(); #endif } @@ -96,8 +151,10 @@ void EmonTx::loop() { this->parse_json_(this->buffer_.data(), len); } #ifdef USE_EMONTX_VERSION - if (this->version_text_sensor_ != nullptr) + if (this->wants_version_()) { + this->parse_msg_number_(this->buffer_.data(), len); this->parse_version_line_(this->buffer_.data(), len); + } #endif } } else if (received == '\r' || this->discarding_line_) { @@ -124,16 +181,6 @@ void EmonTx::parse_json_(const char *data, size_t len) { } } #endif -#ifdef USE_EMONTX_VERSION - auto msg = root["MSG"]; - if (this->version_text_sensor_ != nullptr && msg.is()) { - // MSG restarts at 1 when the emonTx reboots, as it does after a firmware update - uint32_t msg_number = msg; - if (msg_number <= this->last_msg_number_) - this->request_version_(); - this->last_msg_number_ = msg_number; - } -#endif this->json_callbacks_.call(root, StringRef(data, len)); return true; @@ -145,17 +192,50 @@ void EmonTx::parse_json_(const char *data, size_t len) { } #ifdef USE_EMONTX_VERSION -// The line feed ends any pending input; the reply is picked up in loop() by parse_version_line_() void EmonTx::request_version_() { + // The firmware may have changed; until the reply, an unknown version gets no lock commands + this->lock_supported_ = false; + this->version_frames_left_ = VERSION_RETRY_FRAMES * VERSION_MAX_ATTEMPTS; + this->send_version_request_(); +} + +// The line feed ends any pending input; the reply is picked up in loop() by parse_version_line_() +void EmonTx::send_version_request_() { if (this->is_paused()) return; this->write_byte('\n'); - this->set_timeout(VERSION_REQUEST_ID, VERSION_REQUEST_DELAY_MS, [this]() { this->send_command("v"); }); + this->set_timeout(VERSION_REQUEST_ID, COMMAND_GAP_MS, [this]() { this->send_command("v"); }); +} + +void EmonTx::parse_msg_number_(const char *line, size_t len) { + const char *pos; + if (starts_with(line, len, MSG_JSON_PREFIX, sizeof(MSG_JSON_PREFIX) - 1)) { + pos = line + sizeof(MSG_JSON_PREFIX) - 1; + } else if (starts_with(line, len, MSG_TEXT_PREFIX, sizeof(MSG_TEXT_PREFIX) - 1)) { + pos = line + sizeof(MSG_TEXT_PREFIX) - 1; + } else { + return; + } + uint32_t msg_number; + if (!read_number(pos, line + len, msg_number)) + return; + + // MSG restarts at 1 when the emonTx reboots, as it does after a firmware update + if (msg_number <= this->last_msg_number_) { + this->request_version_(); + } else if (this->version_frames_left_ > 0) { + if (--this->version_frames_left_ == 0) { + ESP_LOGW(TAG, "No firmware version from the emonTx, it may still be locked"); + } else if (this->version_frames_left_ % VERSION_RETRY_FRAMES == 0) { + this->send_version_request_(); + } + } + this->last_msg_number_ = msg_number; } void EmonTx::parse_version_line_(const char *line, size_t len) { constexpr size_t prefix_len = sizeof(VERSION_PREFIX) - 1; - if (len <= prefix_len || ESPHOME_strncmp_P(line, VERSION_PREFIX, prefix_len) != 0) + if (!starts_with(line, len, VERSION_PREFIX, prefix_len)) return; const char *version = line + prefix_len; const char *end = line + len; @@ -164,7 +244,23 @@ void EmonTx::parse_version_line_(const char *line, size_t len) { if (version == end) return; ESP_LOGI(TAG, "emonTx firmware version: %.*s", static_cast(end - version), version); - this->version_text_sensor_->publish_state(version, end - version); + this->version_frames_left_ = 0; +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR + if (this->version_text_sensor_ != nullptr) + this->version_text_sensor_->publish_state(version, end - version); +#endif + + bool supported = supports_lock(version, end); + if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO && supported && !this->unlock_sent_) { + // Once the rest of the reply has gone out; a pause drops it, and the version request on resume retries + this->set_timeout(UNLOCK_ID, COMMAND_GAP_MS, [this]() { + if (this->is_paused()) + return; + this->unlock_(); + this->unlock_sent_ = true; + }); + } + this->lock_supported_ = supported; } #endif @@ -173,7 +269,13 @@ void EmonTx::parse_version_line_(const char *line, size_t len) { */ void EmonTx::dump_config() { ESP_LOGCONFIG(TAG, "EmonTx:"); - ESP_LOGCONFIG(TAG, " Lock on reboot: %s", YESNO(this->lock_on_reboot_)); + const char *lock = LOG_STR_LITERAL("no"); + if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_ALWAYS) { + lock = LOG_STR_LITERAL("yes"); + } else if (this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO) { + lock = LOG_STR_LITERAL("auto"); + } + ESP_LOGCONFIG(TAG, " Lock on reboot: %s", lock); #ifdef USE_SENSOR ESP_LOGCONFIG(TAG, " Registered sensors: %zu", this->sensors_.size()); @@ -183,7 +285,7 @@ void EmonTx::dump_config() { #else ESP_LOGCONFIG(TAG, " Sensor support: DISABLED"); #endif -#ifdef USE_EMONTX_VERSION +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR LOG_TEXT_SENSOR(" ", "Firmware version", this->version_text_sensor_); #endif } diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index 8f6a2424c94..a8d91d36eb6 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -13,7 +13,7 @@ #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif -#ifdef USE_EMONTX_VERSION +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR #include "esphome/components/text_sensor/text_sensor.h" #endif @@ -22,6 +22,12 @@ namespace esphome::emontx { /// Maximum line length in bytes (plus one byte reserved for null terminator) static constexpr size_t MAX_LINE_LENGTH = 1024; +enum class LockOnReboot : uint8_t { + LOCK_ON_REBOOT_NEVER, + LOCK_ON_REBOOT_ALWAYS, + LOCK_ON_REBOOT_AUTO, // Only when the emonTx reports firmware v1.2.0 or later +}; + /** * @class EmonTx * @brief Main class for the EmonTx component. @@ -30,7 +36,7 @@ static constexpr size_t MAX_LINE_LENGTH = 1024; * extracts tags and values, and publishes them to registered sensors. */ class EmonTx final : public Component, public uart::UARTDevice { -#ifdef USE_EMONTX_VERSION +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR SUB_TEXT_SENSOR(version) #endif public: @@ -56,8 +62,8 @@ class EmonTx final : public Component, public uart::UARTDevice { bool is_paused() const { return this->paused_.load(std::memory_order_relaxed); } /// Lock the emonTx ('emonlock') before a reboot, so serial garbage during the reboot is not taken - /// as a command, and unlock it in setup(). Needs emonTx firmware v1.2.0 or later. - void set_lock_on_reboot(bool lock_on_reboot) { this->lock_on_reboot_ = lock_on_reboot; } + /// as a command, and unlock it after boot. Needs emonTx firmware v1.2.0 or later, which AUTO checks. + void set_lock_on_reboot(LockOnReboot lock_on_reboot) { this->lock_on_reboot_ = lock_on_reboot; } #ifdef USE_SENSOR void init_sensors(size_t count) { this->sensors_.init(count); } @@ -66,8 +72,18 @@ class EmonTx final : public Component, public uart::UARTDevice { protected: void parse_json_(const char *data, size_t len); + void unlock_(); #ifdef USE_EMONTX_VERSION + bool wants_version_() const { +#ifdef USE_EMONTX_VERSION_TEXT_SENSOR + if (this->version_text_sensor_ != nullptr) + return true; +#endif + return this->lock_on_reboot_ == LockOnReboot::LOCK_ON_REBOOT_AUTO; + } void request_version_(); + void send_version_request_(); + void parse_msg_number_(const char *line, size_t len); void parse_version_line_(const char *line, size_t len); #endif @@ -79,9 +95,12 @@ class EmonTx final : public Component, public uart::UARTDevice { uint16_t buffer_pos_{0}; std::atomic paused_{false}; bool was_paused_{false}; - bool lock_on_reboot_{false}; + LockOnReboot lock_on_reboot_{LockOnReboot::LOCK_ON_REBOOT_NEVER}; bool discarding_line_{false}; // Set after an overflow or a pause until the end of that line #ifdef USE_EMONTX_VERSION + bool lock_supported_{false}; // The last version reported is v1.2.0 or later + bool unlock_sent_{false}; // AUTO has sent 'emonunlock' since boot + uint8_t version_frames_left_{0}; // Data lines still to wait for a version reply; 0 when none is owed uint32_t last_msg_number_{0}; #endif std::array buffer_{}; diff --git a/esphome/components/emontx/text_sensor/__init__.py b/esphome/components/emontx/text_sensor/__init__.py index 9b3065f0f86..a3e381b9b5d 100644 --- a/esphome/components/emontx/text_sensor/__init__.py +++ b/esphome/components/emontx/text_sensor/__init__.py @@ -21,3 +21,4 @@ async def to_code(config: ConfigType) -> None: text_sensors = text_sensor.sub_text_sensors(config) if await text_sensors(CONF_VERSION, hub.set_version_text_sensor) is not None: cg.add_define("USE_EMONTX_VERSION") + cg.add_define("USE_EMONTX_VERSION_TEXT_SENSOR") diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 08f1e31d6c2..8d243043bde 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -69,6 +69,7 @@ #define USE_DISPLAY #define DISPLAY_PAGE_CHANGE_CALLBACK_COUNT 1 #define USE_EMONTX_VERSION +#define USE_EMONTX_VERSION_TEXT_SENSOR #define USE_ENTITY_DEVICE_CLASS #define USE_ENTITY_ICON #define USE_ENTITY_UNIT_OF_MEASUREMENT diff --git a/tests/component_tests/emontx/test_lock_on_reboot.py b/tests/component_tests/emontx/test_lock_on_reboot.py new file mode 100644 index 00000000000..6b6ba2881f6 --- /dev/null +++ b/tests/component_tests/emontx/test_lock_on_reboot.py @@ -0,0 +1,38 @@ +"""Tests for the emontx lock_on_reboot option: auto (the default), true or false.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.emontx import ( + CONF_LOCK_ON_REBOOT, + CONFIG_SCHEMA, + LOCK_ON_REBOOT_AUTO, +) + + +def _lock_key() -> cv.Optional: + return next(key for key in CONFIG_SCHEMA.schema if key == CONF_LOCK_ON_REBOOT) + + +def test_default_is_auto() -> None: + assert _lock_key().default() == LOCK_ON_REBOOT_AUTO + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("auto", LOCK_ON_REBOOT_AUTO), + ("AUTO", LOCK_ON_REBOOT_AUTO), + (True, True), + (False, False), + ("yes", True), + ("off", False), + ], +) +def test_accepted_values(value: object, expected: object) -> None: + assert CONFIG_SCHEMA.schema[_lock_key()](value) == expected + + +def test_other_values_rejected() -> None: + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA.schema[_lock_key()]("sometimes") diff --git a/tests/components/emontx/common-version.yaml b/tests/components/emontx/common-version.yaml new file mode 100644 index 00000000000..1ec3733f771 --- /dev/null +++ b/tests/components/emontx/common-version.yaml @@ -0,0 +1,9 @@ +emontx: + id: test_emontx + lock_on_reboot: true + +text_sensor: + - platform: emontx + emontx_id: test_emontx + version: + name: emonTx firmware version diff --git a/tests/components/emontx/common.h b/tests/components/emontx/common.h index 4af5a98572d..678d582f62f 100644 --- a/tests/components/emontx/common.h +++ b/tests/components/emontx/common.h @@ -8,6 +8,8 @@ #include #include "esphome/components/uart/uart_component.h" #include "esphome/components/emontx/emontx.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" namespace esphome::emontx::testing { @@ -42,4 +44,23 @@ class MockUARTComponent : public uart::UARTComponent { #endif // defined(USE_ESP8266) || defined(USE_ESP32) }; +// The test main does not construct App as generated code does +inline void construct_app() { + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; +} + +inline void cancel_timers(EmonTx *emontx) { + for (uint32_t id = 0; id < 3; id++) + App.scheduler.cancel_timeout(emontx, id); + App.scheduler.call(millis()); +} + +// Lets timeouts due within duration_ms run; the scheduler reads the real clock +inline void run_timers(uint32_t duration_ms) { + App.scheduler.call(millis()); + delay(duration_ms); // NOLINT - test only + App.scheduler.call(millis()); +} + } // namespace esphome::emontx::testing diff --git a/tests/components/emontx/common.yaml b/tests/components/emontx/common.yaml index 4a5e347053a..5c25e37abb5 100644 --- a/tests/components/emontx/common.yaml +++ b/tests/components/emontx/common.yaml @@ -8,7 +8,6 @@ button: emontx: id: test_emontx - lock_on_reboot: true on_json: - then: - logger.log: "Got JSON" @@ -24,9 +23,3 @@ sensor: tag_name: P1 emontx_id: test_emontx unit_of_measurement: W - -text_sensor: - - platform: emontx - emontx_id: test_emontx - version: - name: emonTx firmware version diff --git a/tests/components/emontx/emontx_test.cpp b/tests/components/emontx/emontx_test.cpp index 5d9fa096119..71bb6c70d36 100644 --- a/tests/components/emontx/emontx_test.cpp +++ b/tests/components/emontx/emontx_test.cpp @@ -1,5 +1,6 @@ #include +#include #include #include #include @@ -13,11 +14,16 @@ namespace { class EmonTxTest : public ::testing::Test { protected: void SetUp() override { + construct_app(); this->sut_.set_uart_parent(&this->uart_); this->sut_.add_on_data_callback([this](StringRef line) { this->lines_.emplace_back(line.c_str(), line.size()); }); this->sut_.add_on_json_callback([this](JsonObject, StringRef) { this->json_count_++; }); } + void TearDown() override { cancel_timers(&this->sut_); } + + std::string tx_() const { return std::string(this->uart_.tx.begin(), this->uart_.tx.end()); } + MockUARTComponent uart_; EmonTx sut_; std::vector lines_; @@ -160,7 +166,7 @@ TEST_F(EmonTxTest, ResumeAfterCompleteBacklogKeepsNextLine) { TEST_F(EmonTxTest, ShutdownLocksWhenEnabled) { EXPECT_CALL(this->uart_, flush()).Times(1); - this->sut_.set_lock_on_reboot(true); + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS); this->sut_.on_shutdown(); EXPECT_EQ(std::string(this->uart_.tx.begin(), this->uart_.tx.end()), "emonlock\n"); } @@ -174,16 +180,20 @@ TEST_F(EmonTxTest, ShutdownSendsNothingByDefault) { TEST_F(EmonTxTest, ShutdownSendsNothingWhilePaused) { EXPECT_CALL(this->uart_, flush()).Times(0); - this->sut_.set_lock_on_reboot(true); + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS); this->sut_.set_paused(true); this->sut_.on_shutdown(); EXPECT_TRUE(this->uart_.tx.empty()); } -TEST_F(EmonTxTest, SetupUnlocksWhenEnabled) { - this->sut_.set_lock_on_reboot(true); +// The emonTx would join "emonunlock" to the pending empty line if both came in one burst +TEST_F(EmonTxTest, SetupUnlocksAfterLineFeedWhenEnabled) { + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS); this->sut_.setup(); - EXPECT_EQ(std::string(this->uart_.tx.begin(), this->uart_.tx.end()), "\nemonunlock\n"); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(300); + EXPECT_EQ(this->tx_(), "\nemonunlock\n"); } TEST_F(EmonTxTest, SetupSendsNothingByDefault) { @@ -191,4 +201,169 @@ TEST_F(EmonTxTest, SetupSendsNothingByDefault) { EXPECT_TRUE(this->uart_.tx.empty()); } +class EmonTxLockAutoTest : public EmonTxTest { + protected: + void SetUp() override { + EmonTxTest::SetUp(); + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_AUTO); + } + + void report_version_(const char *version) { + this->uart_.push_rx(" - Version: "); + this->uart_.push_rx(version); + this->uart_.push_rx("\r\n"); + this->sut_.loop(); + } +}; + +// Only 'v' goes out until the reply shows the firmware knows 'emonunlock' +TEST_F(EmonTxLockAutoTest, SetupOnlyRequestsVersion) { + this->sut_.setup(); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + +TEST_F(EmonTxLockAutoTest, SupportedFirmwareUnlockedAndLockedOnShutdown) { + this->report_version_("v1.2.0"); + EXPECT_TRUE(this->uart_.tx.empty()); + run_timers(300); + EXPECT_EQ(this->tx_(), "emonunlock\n"); + + EXPECT_CALL(this->uart_, flush()).Times(1); + this->sut_.on_shutdown(); + EXPECT_EQ(this->tx_(), "emonunlock\nemonlock\n"); +} + +// Firmware before v1.2.0 reads "emon..." as the bootloader command +TEST_F(EmonTxLockAutoTest, OlderFirmwareGetsNoLockCommands) { + this->report_version_("v1.1.1"); + run_timers(300); + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxLockAutoTest, NothingSentWithoutVersion) { + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxLockAutoTest, VersionFormats) { + for (const char *version : {"V1.10.0", "v2.0.0", "1.2.0", "v1.3.0-rc1"}) { + this->report_version_("v0.99.0"); // So the next one is a change + this->report_version_(version); + EXPECT_CALL(this->uart_, flush()).Times(1); + this->sut_.on_shutdown(); + EXPECT_NE(this->tx_().find("emonlock"), std::string::npos) << version; + ::testing::Mock::VerifyAndClearExpectations(&this->uart_); + this->uart_.tx.clear(); + } + for (const char *version : {"v1.1.1", "v0.99.9-test", "None", "768be1b", "v1", "v.2"}) { + this->report_version_(version); + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()) << version; + ::testing::Mock::VerifyAndClearExpectations(&this->uart_); + } +} + +// The emonTx clears its lock when it reboots, so a repeated report needs no second unlock +TEST_F(EmonTxLockAutoTest, UnlockedOnlyOnce) { + this->report_version_("v1.2.0"); + run_timers(300); + this->report_version_("v1.2.0"); + run_timers(300); + EXPECT_EQ(this->tx_(), "emonunlock\n"); +} + +TEST_F(EmonTxLockAutoTest, RequestRetriedEverySecondDataLine) { + this->sut_.setup(); + run_timers(300); + this->uart_.push_rx("{\"MSG\":10}\n"); + this->sut_.loop(); + EXPECT_EQ(this->tx_(), "\nv\n"); + + // Text format counts too + this->uart_.push_rx("MSG:11,V1:230.1\n"); + this->sut_.loop(); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\n\nv\n"); + + this->report_version_("v1.1.1"); + this->uart_.push_rx("{\"MSG\":12}\n{\"MSG\":13}\n{\"MSG\":14}\n"); + this->sut_.loop(); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\n\nv\n"); +} + +// Each attempt sends its line feed at once; counting those needs no wait for the delayed 'v' +TEST_F(EmonTxLockAutoTest, RetriesStopAfterThreeAttempts) { + this->sut_.setup(); + for (int msg = 10; msg < 20; msg++) { + char line[24]; + snprintf(line, sizeof(line), "{\"MSG\":%d}\n", msg); + this->uart_.push_rx(line); + this->sut_.loop(); + } + EXPECT_EQ(this->tx_(), "\n\n\n"); +} + +// E.g. a downgrade: after the reboot, the new firmware never answers 'v' +TEST_F(EmonTxLockAutoTest, NoLockAfterUnansweredRequest) { + this->report_version_("v1.2.0"); + run_timers(300); + for (int msg : {50, 1, 2, 3, 4, 5, 6, 7}) { + char line[24]; + snprintf(line, sizeof(line), "MSG:%d\n", msg); + this->uart_.push_rx(line); + this->sut_.loop(); + } + this->uart_.tx.clear(); + + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxLockAutoTest, NoLockWhileVersionPending) { + this->report_version_("v1.2.0"); + run_timers(300); + this->uart_.push_rx("MSG:50\nMSG:1\n"); + this->sut_.loop(); + this->uart_.tx.clear(); + + EXPECT_CALL(this->uart_, flush()).Times(0); + this->sut_.on_shutdown(); + EXPECT_TRUE(this->uart_.tx.empty()); +} + +TEST_F(EmonTxLockAutoTest, UnlockDroppedByPauseSentAfterResume) { + this->report_version_("v1.2.0"); + this->sut_.set_paused(true); + this->sut_.loop(); + run_timers(300); + EXPECT_TRUE(this->uart_.tx.empty()); + + // Resume asks for the version again, and its reply brings the unlock + this->sut_.set_paused(false); + this->sut_.loop(); + run_timers(300); + this->uart_.push_rx("> Firmware:\r\n"); // Ends the line skipped after a resume + this->report_version_("v1.2.0"); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\nemonunlock\n"); +} + +// The lines of a 'v' reply before the version line are not data lines +TEST_F(EmonTxLockAutoTest, ReplyBannerNotCountedAsDataLines) { + this->sut_.setup(); + run_timers(300); + this->uart_.push_rx( + "> Board:\r\n - emonPi3/emonTx6 (arch. rev. 1)\r\n> Firmware:\r\n - Bootloader: UART (.bin)\r\n"); + this->sut_.loop(); + run_timers(300); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + } // namespace esphome::emontx::testing diff --git a/tests/components/emontx/test-actions.esp8266-ard.yaml b/tests/components/emontx/test-actions.esp8266-ard.yaml index 4be79a40dac..04cc5b89f56 100644 --- a/tests/components/emontx/test-actions.esp8266-ard.yaml +++ b/tests/components/emontx/test-actions.esp8266-ard.yaml @@ -2,3 +2,4 @@ packages: uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml emontx: !include common.yaml actions: !include common-actions.yaml + version: !include common-version.yaml diff --git a/tests/components/emontx/text_sensor/__init__.py b/tests/components/emontx/text_sensor/__init__.py index 60a67b5a0b6..5bffc87e0f0 100644 --- a/tests/components/emontx/text_sensor/__init__.py +++ b/tests/components/emontx/text_sensor/__init__.py @@ -7,5 +7,6 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: # The platform's own to_code needs a configured hub; only its define is wanted here. async def to_code_testing(config: ConfigType) -> None: cg.add_define("USE_EMONTX_VERSION") + cg.add_define("USE_EMONTX_VERSION_TEXT_SENSOR") manifest.to_code = to_code_testing diff --git a/tests/components/emontx/text_sensor/emontx_version_test.cpp b/tests/components/emontx/text_sensor/emontx_version_test.cpp index fe905a0e958..34f6315f3ab 100644 --- a/tests/components/emontx/text_sensor/emontx_version_test.cpp +++ b/tests/components/emontx/text_sensor/emontx_version_test.cpp @@ -4,8 +4,6 @@ #include "esphome/components/emontx/emontx.h" #include "esphome/components/text_sensor/text_sensor.h" -#include "esphome/core/application.h" -#include "esphome/core/hal.h" #include "../common.h" namespace esphome::emontx::testing { @@ -14,24 +12,15 @@ namespace { class EmonTxVersionTest : public ::testing::Test { protected: void SetUp() override { - // The test main does not construct App as generated code does - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; + construct_app(); this->sut_.set_uart_parent(&this->uart_); this->sut_.set_version_text_sensor(&this->version_); } - void TearDown() override { - App.scheduler.cancel_timeout(&this->sut_, uint32_t{0}); - App.scheduler.call(millis()); - } + void TearDown() override { cancel_timers(&this->sut_); } // Lets the delayed 'v' go out - static void run_timers() { - App.scheduler.call(millis()); - delay(300); // NOLINT - test only, the scheduler reads the real clock - App.scheduler.call(millis()); - } + static void run_timers() { testing::run_timers(300); } std::string tx_() const { return std::string(this->uart_.tx.begin(), this->uart_.tx.end()); } @@ -51,8 +40,11 @@ TEST_F(EmonTxVersionTest, SetupRequestsVersionAfterDelay) { } TEST_F(EmonTxVersionTest, SetupUnlocksBeforeRequestingVersion) { - this->sut_.set_lock_on_reboot(true); + this->sut_.set_lock_on_reboot(LockOnReboot::LOCK_ON_REBOOT_ALWAYS); this->sut_.setup(); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(); EXPECT_EQ(this->tx_(), "\nemonunlock\n\n"); run_timers(); @@ -88,6 +80,15 @@ TEST_F(EmonTxVersionTest, MsgCounterResetRequestsVersion) { EXPECT_EQ(this->tx_(), "\nv\n"); } +TEST_F(EmonTxVersionTest, TextMsgCounterResetRequestsVersion) { + this->uart_.push_rx("MSG:41,V1:230.1\r\nMSG:1,V1:230.4\r\n"); + this->sut_.loop(); + EXPECT_EQ(this->tx_(), "\n"); + + run_timers(); + EXPECT_EQ(this->tx_(), "\nv\n"); +} + // The emonTx may have been flashed during the pause, with MSG already past its old value TEST_F(EmonTxVersionTest, ResumeRequestsVersion) { this->sut_.set_paused(true); From f265c46c2152e640b33eb899838ed98154e1ff12 Mon Sep 17 00:00:00 2001 From: Andrej Friesen Date: Wed, 14 Oct 2026 00:50:43 +0200 Subject: [PATCH 041/142] [PCA9505] Add PCA9505/PCA9506 40-channel GPIO expander (#20667) --- CODEOWNERS | 1 + esphome/components/pca9505/__init__.py | 79 +++++ esphome/components/pca9505/pca9505.cpp | 165 +++++++++ esphome/components/pca9505/pca9505.h | 72 ++++ tests/components/pca9505/common.yaml | 46 +++ tests/components/pca9505/test.esp32-idf.yaml | 6 + .../components/pca9505/test.esp8266-ard.yaml | 6 + tests/components/pca9505/test.rp2040-ard.yaml | 6 + tests/components/pca9505/test_pca9505.cpp | 312 ++++++++++++++++++ 9 files changed, 693 insertions(+) create mode 100644 esphome/components/pca9505/__init__.py create mode 100644 esphome/components/pca9505/pca9505.cpp create mode 100644 esphome/components/pca9505/pca9505.h create mode 100644 tests/components/pca9505/common.yaml create mode 100644 tests/components/pca9505/test.esp32-idf.yaml create mode 100644 tests/components/pca9505/test.esp8266-ard.yaml create mode 100644 tests/components/pca9505/test.rp2040-ard.yaml create mode 100644 tests/components/pca9505/test_pca9505.cpp diff --git a/CODEOWNERS b/CODEOWNERS index a770810490b..95d8c49c23e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -439,6 +439,7 @@ esphome/components/ota/* @esphome/core esphome/components/output/* @esphome/core esphome/components/packet_transport/* @clydebarrow esphome/components/pca6416a/* @Mat931 +esphome/components/pca9505/* @ajfriesen @ssieb esphome/components/pca9554/* @bdraco @clydebarrow @hwstar esphome/components/pcf85063/* @brogon esphome/components/pcf8563/* @KoenBreeman diff --git a/esphome/components/pca9505/__init__.py b/esphome/components/pca9505/__init__.py new file mode 100644 index 00000000000..7b0d5ac1732 --- /dev/null +++ b/esphome/components/pca9505/__init__.py @@ -0,0 +1,79 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_INPUT, + CONF_INTERRUPT_PIN, + CONF_INVERTED, + CONF_MODE, + CONF_NUMBER, + CONF_OUTPUT, +) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +AUTO_LOAD = ["gpio_expander"] +CODEOWNERS = ["@ssieb", "@ajfriesen"] +DEPENDENCIES = ["i2c"] +DOMAIN = "pca9505" +MULTI_CONF = True + +pca9505_ns = cg.esphome_ns.namespace("pca9505") + +PCA9505Component = pca9505_ns.class_("PCA9505Component", cg.Component, i2c.I2CDevice) +PCA9505GPIOPin = pca9505_ns.class_( + "PCA9505GPIOPin", cg.GPIOPin, cg.Parented.template(PCA9505Component) +) + +CONF_PCA9505 = "pca9505" +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.Required(CONF_ID): cv.declare_id(PCA9505Component), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x20)) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + + +def validate_mode(value: ConfigType) -> ConfigType: + if not (value[CONF_INPUT] or value[CONF_OUTPUT]): + raise cv.Invalid("Mode must be either input or output") + if value[CONF_INPUT] and value[CONF_OUTPUT]: + raise cv.Invalid("Mode must be either input or output") + return value + + +PCA9505_PIN_SCHEMA = pins.gpio_base_schema( + PCA9505GPIOPin, + cv.int_range(min=0, max=39), + modes=[CONF_INPUT, CONF_OUTPUT], + mode_validator=validate_mode, +).extend( + { + cv.Required(CONF_PCA9505): cv.use_id(PCA9505Component), + } +) + + +@pins.PIN_SCHEMA_REGISTRY.register(CONF_PCA9505, PCA9505_PIN_SCHEMA) +async def pca9505_pin_to_code(config: ConfigType) -> MockObj: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_parented(var, config[CONF_PCA9505]) + + cg.add(var.set_pin(config[CONF_NUMBER])) + cg.add(var.set_inverted(config[CONF_INVERTED])) + cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) + return var diff --git a/esphome/components/pca9505/pca9505.cpp b/esphome/components/pca9505/pca9505.cpp new file mode 100644 index 00000000000..20f62c14721 --- /dev/null +++ b/esphome/components/pca9505/pca9505.cpp @@ -0,0 +1,165 @@ +#include "pca9505.h" +#include "esphome/core/log.h" + +namespace esphome::pca9505 { + +// Each register block holds one byte per port. Add the port number to reach a single +// port, or set the auto-increment bit to transfer the whole block in one go. +static constexpr uint8_t INPUT_REG = 0x00; +static constexpr uint8_t OUTPUT_REG = 0x08; +static constexpr uint8_t INVERT_REG = 0x10; +static constexpr uint8_t CONFIG_REG = 0x18; +static constexpr uint8_t MASK_REG = 0x20; +static constexpr uint8_t AUTO_INCREMENT = 0x80; + +ESPHOME_LOG_TAG(TAG, "pca9505"); + +void PCA9505Component::setup() { + // Test to see if device exists + if (!this->read_bank_(0)) { + ESP_LOGE(TAG, "PCA9505 not detected at 0x%02X", this->address_); + this->mark_failed(); + return; + } + + // No polarity inversion + this->write_all_banks_(INVERT_REG, 0x00); + // All outputs low, before anything is switched to being an output + this->write_all_banks_(OUTPUT_REG, 0x00); + // All inputs at initialization. The part sees a 1 as an input, so this is the + // inverse of config_, which is all zeroes here. + this->write_all_banks_(CONFIG_REG, 0xFF); + + // Mask every interrupt. This must be written, not assumed: the chip keeps its + // registers through an MCU reboot, so a previous run may have left pins unmasked. + for (uint8_t &bank : this->mask_) { + bank = 0xFF; + } + this->write_all_banks_(MASK_REG, 0xFF); + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->interrupt_pin_->attach_interrupt(&PCA9505Component::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + // Don't invalidate cache on read - only invalidate when interrupt fires + this->set_invalidate_on_read_(false); + } + + // Nothing to poll until pin_mode() registers an input pin. + this->disable_loop(); +} + +void IRAM_ATTR PCA9505Component::gpio_intr(PCA9505Component *arg) { arg->enable_loop_soon_any_context(); } + +void PCA9505Component::loop() { + // Invalidate the cache so the next digital_read() triggers a fresh I2C read + this->reset_pin_cache_(); + // Only disable the loop once INT has actually gone HIGH. Input transitions that straddle the + // I2C read leave INT asserted without re-firing a falling edge, which would strand us with + // stale state forever; keep looping until the line is released so we self-heal. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) { + this->disable_loop(); + } +} + +void PCA9505Component::dump_config() { + ESP_LOGCONFIG(TAG, "PCA9505:"); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_I2C_DEVICE(this) + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +bool PCA9505Component::digital_read_hw(uint8_t pin) { return this->read_bank_(pin / 8); } + +bool PCA9505Component::digital_read_cache(uint8_t pin) { return this->input_[pin / 8] & (1 << (pin % 8)); } + +void PCA9505Component::digital_write_hw(uint8_t pin, bool value) { + const uint8_t bank = pin / 8; + const uint8_t mask = 1 << (pin % 8); + if (value) { + this->output_[bank] |= mask; + } else { + this->output_[bank] &= ~mask; + } + this->write_bank_(OUTPUT_REG, bank, this->output_[bank]); +} + +void PCA9505Component::pin_mode(uint8_t pin, gpio::Flags flags) { + const uint8_t bank = pin / 8; + const uint8_t mask = 1 << (pin % 8); + if (flags & gpio::FLAG_INPUT) { + this->config_[bank] &= ~mask; + // An input may raise INT; the polling loop is only needed when there is no interrupt pin. + this->mask_[bank] &= ~mask; + if (this->interrupt_pin_ == nullptr) { + this->enable_loop(); + } + } else if (flags & gpio::FLAG_OUTPUT) { + this->config_[bank] |= mask; + // An output must never raise INT. + this->mask_[bank] |= mask; + } + // Invert as the part sees a 1 as an input + this->write_bank_(CONFIG_REG, bank, ~this->config_[bank]); + // Without an interrupt pin INT is unconnected, so the masks written at setup can stay + if (this->interrupt_pin_ != nullptr) { + this->write_bank_(MASK_REG, bank, this->mask_[bank]); + } +} + +bool PCA9505Component::read_bank_(uint8_t bank) { + if (this->is_failed()) + return false; + + if (this->read_register(INPUT_REG + bank, &this->input_[bank], 1) != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR("Failed to read input register")); + return false; + } + this->status_clear_warning(); + return true; +} + +bool PCA9505Component::write_bank_(uint8_t reg, uint8_t bank, uint8_t value) { + if (this->is_failed()) + return false; + + if (this->write_register(reg + bank, &value, 1) != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR("Failed to write register")); + return false; + } + this->status_clear_warning(); + return true; +} + +bool PCA9505Component::write_all_banks_(uint8_t reg, uint8_t value) { + if (this->is_failed()) + return false; + + uint8_t values[BANK_COUNT]; + for (uint8_t &bank : values) { + bank = value; + } + if (this->write_register(reg | AUTO_INCREMENT, values, sizeof(values)) != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR("Failed to write register")); + return false; + } + this->status_clear_warning(); + return true; +} + +float PCA9505Component::get_setup_priority() const { return setup_priority::IO; } + +void PCA9505GPIOPin::setup() { this->pin_mode(this->flags_); } + +void PCA9505GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } + +bool PCA9505GPIOPin::digital_read() { return this->parent_->digital_read(this->pin_) != this->inverted_; } + +void PCA9505GPIOPin::digital_write(bool value) { this->parent_->digital_write(this->pin_, value != this->inverted_); } + +size_t PCA9505GPIOPin::dump_summary(char *buffer, size_t len) const { + return buf_append_printf(buffer, len, 0, "%u via PCA9505", this->pin_); +} + +} // namespace esphome::pca9505 diff --git a/esphome/components/pca9505/pca9505.h b/esphome/components/pca9505/pca9505.h new file mode 100644 index 00000000000..29c4f18361c --- /dev/null +++ b/esphome/components/pca9505/pca9505.h @@ -0,0 +1,72 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/components/gpio_expander/cached_gpio.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::pca9505 { + +/// Number of I/O pins on the PCA9505 and PCA9506. +static constexpr uint8_t PIN_COUNT = 40; +/// The pins are grouped into five ports of eight, each with its own set of registers. +static constexpr uint8_t BANK_COUNT = PIN_COUNT / 8; + +class PCA9505Component final : public Component, + public i2c::I2CDevice, + public gpio_expander::CachedGpioExpander { + public: + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + + void setup() override; + void loop() override; + void pin_mode(uint8_t pin, gpio::Flags flags); + + float get_setup_priority() const override; + + void dump_config() override; + + protected: + static void IRAM_ATTR gpio_intr(PCA9505Component *arg); + + bool digital_read_hw(uint8_t pin) override; + bool digital_read_cache(uint8_t pin) override; + void digital_write_hw(uint8_t pin, bool value) override; + + bool read_bank_(uint8_t bank); + bool write_bank_(uint8_t reg, uint8_t bank, uint8_t value); + bool write_all_banks_(uint8_t reg, uint8_t value); + + /// The pin config per bank - 1 means OUTPUT, 0 means INPUT + uint8_t config_[BANK_COUNT]{}; + /// The output state per bank - 1 means HIGH, 0 means LOW + uint8_t output_[BANK_COUNT]{}; + /// The input pin states per bank - 1 means HIGH, 0 means LOW + uint8_t input_[BANK_COUNT]{}; + /// Which pins may pull INT low - 1 means masked, matching the chip + uint8_t mask_[BANK_COUNT]{}; + InternalGPIOPin *interrupt_pin_{nullptr}; +}; + +class PCA9505GPIOPin final : public GPIOPin, public Parented { + public: + void setup() override; + void pin_mode(gpio::Flags flags) override; + bool digital_read() override; + void digital_write(bool value) override; + size_t dump_summary(char *buffer, size_t len) const override; + + void set_pin(uint8_t pin) { this->pin_ = pin; } + void set_inverted(bool inverted) { this->inverted_ = inverted; } + void set_flags(gpio::Flags flags) { this->flags_ = flags; } + + gpio::Flags get_flags() const override { return this->flags_; } + + protected: + uint8_t pin_{0}; + bool inverted_{false}; + gpio::Flags flags_{}; +}; + +} // namespace esphome::pca9505 diff --git a/tests/components/pca9505/common.yaml b/tests/components/pca9505/common.yaml new file mode 100644 index 00000000000..53614dbcdda --- /dev/null +++ b/tests/components/pca9505/common.yaml @@ -0,0 +1,46 @@ +pca9505: + - id: pca9505_hub + i2c_id: i2c_bus + address: 0x26 + - id: pca9505_hub_second + interrupt_pin: ${interrupt_pin} + i2c_id: i2c_bus + address: 0x27 + +binary_sensor: + # Pins 0 and 39 are the first and last pins, so between them they cover every + # bank index the five-port register maths has to get right. + - platform: gpio + id: pca9505_input_first + name: PCA9505 Binary Sensor First + pin: + pca9505: pca9505_hub + number: 0 + mode: INPUT + inverted: true + - platform: gpio + id: pca9505_input_last + name: PCA9505 Binary Sensor Last + pin: + pca9505: pca9505_hub_second + number: 39 + mode: INPUT + inverted: false + +switch: + - platform: gpio + id: pca9505_output_low + name: PCA9505 Switch Low + pin: + pca9505: pca9505_hub + number: 7 + mode: OUTPUT + inverted: false + - platform: gpio + id: pca9505_output_high + name: PCA9505 Switch High + pin: + pca9505: pca9505_hub + number: 32 + mode: OUTPUT + inverted: true diff --git a/tests/components/pca9505/test.esp32-idf.yaml b/tests/components/pca9505/test.esp32-idf.yaml new file mode 100644 index 00000000000..0dc4dcfd657 --- /dev/null +++ b/tests/components/pca9505/test.esp32-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + pca9505: !include common.yaml diff --git a/tests/components/pca9505/test.esp8266-ard.yaml b/tests/components/pca9505/test.esp8266-ard.yaml new file mode 100644 index 00000000000..84c7105a5a0 --- /dev/null +++ b/tests/components/pca9505/test.esp8266-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + pca9505: !include common.yaml diff --git a/tests/components/pca9505/test.rp2040-ard.yaml b/tests/components/pca9505/test.rp2040-ard.yaml new file mode 100644 index 00000000000..9d79eb4f3bd --- /dev/null +++ b/tests/components/pca9505/test.rp2040-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO2 + +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + pca9505: !include common.yaml diff --git a/tests/components/pca9505/test_pca9505.cpp b/tests/components/pca9505/test_pca9505.cpp new file mode 100644 index 00000000000..4ee55fd805f --- /dev/null +++ b/tests/components/pca9505/test_pca9505.cpp @@ -0,0 +1,312 @@ +#include + +#include "esphome/components/pca9505/pca9505.h" + +namespace esphome::pca9505::testing { + +// Models the PCA9505 register map: five ports, each with its own input, output, +// polarity inversion and configuration byte. The command byte selects a register +// and its top bit asks the part to auto-increment through the block. +class FakeBus : public i2c::I2CBus { + public: + static constexpr uint8_t ADDRESS = 0x26; + + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, + size_t read_count) override { + EXPECT_EQ(address, ADDRESS); + if (this->fail) + return i2c::ERROR_TIMEOUT; + + // Every transfer starts with the command byte. + EXPECT_GE(write_count, 1u); + const uint8_t command = write_buffer[0]; + const bool auto_increment = (command & 0x80) != 0; + uint8_t *block = this->block_for(command & 0x7F); + const uint8_t index = (command & 0x7F) % 8; + + if (read_count != 0) { + this->reads++; + // Without auto-increment the part keeps returning the same register. + EXPECT_TRUE(auto_increment || read_count == 1); + for (size_t i = 0; i < read_count; i++) + read_buffer[i] = block[auto_increment ? index + i : index]; + return i2c::ERROR_OK; + } + + this->writes++; + EXPECT_TRUE(auto_increment || write_count == 2); + for (size_t i = 1; i < write_count; i++) + block[auto_increment ? index + i - 1 : index] = write_buffer[i]; + return i2c::ERROR_OK; + } + + uint8_t *block_for(uint8_t reg) { + if (reg < 0x08) + return this->input; + if (reg < 0x10) + return this->output; + if (reg < 0x18) + return this->invert; + if (reg < 0x20) + return this->config; + return this->mask; + } + + // Power-on reset defaults from the datasheet. + uint8_t input[BANK_COUNT]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + uint8_t output[BANK_COUNT]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + uint8_t invert[BANK_COUNT]{0x00, 0x00, 0x00, 0x00, 0x00}; + uint8_t config[BANK_COUNT]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + uint8_t mask[BANK_COUNT]{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + + unsigned reads{0}; + unsigned writes{0}; + bool fail{false}; +}; + +class PCA9505Test : public ::testing::Test { + protected: + void SetUp() override { + this->component_.set_i2c_bus(&this->bus_); + this->component_.set_i2c_address(FakeBus::ADDRESS); + } + + PCA9505GPIOPin make_pin_(uint8_t number, gpio::Flags flags, bool inverted = false) { + PCA9505GPIOPin pin; + pin.set_parent(&this->component_); + pin.set_pin(number); + pin.set_flags(flags); + pin.set_inverted(inverted); + return pin; + } + + FakeBus bus_; + PCA9505Component component_; + InternalGPIOPin int_pin_; // digital_read() returns the inverted flag on the host, so it doubles as the level +}; + +TEST_F(PCA9505Test, SetupLeavesEveryPinAnInputWithOutputsLow) { + this->component_.setup(); + + ASSERT_FALSE(this->component_.is_failed()); + for (uint8_t bank = 0; bank < BANK_COUNT; bank++) { + EXPECT_EQ(this->bus_.config[bank], 0xFF) << "bank " << int(bank) << " should still be an input"; + EXPECT_EQ(this->bus_.output[bank], 0x00) << "bank " << int(bank) << " should be driven low"; + EXPECT_EQ(this->bus_.invert[bank], 0x00) << "bank " << int(bank) << " should not be inverted"; + } +} + +TEST_F(PCA9505Test, SetupFailsWhenTheDeviceIsAbsent) { + this->bus_.fail = true; + this->component_.setup(); + + EXPECT_TRUE(this->component_.is_failed()); +} + +// The chip has 40 pins, so pin numbers reach past the width of an int shift. +// Every pin must land in its own bank and bit. +TEST_F(PCA9505Test, EachPinMapsToItsOwnBankAndBit) { + this->component_.setup(); + + for (uint8_t number = 0; number < PIN_COUNT; number++) { + PCA9505GPIOPin pin = this->make_pin_(number, gpio::FLAG_OUTPUT); + pin.setup(); + pin.digital_write(true); + + for (uint8_t bank = 0; bank < BANK_COUNT; bank++) { + const uint8_t want = (bank == number / 8) ? uint8_t(1 << (number % 8)) : 0x00; + ASSERT_EQ(this->bus_.output[bank], want) << "pin " << int(number) << " wrote outside its own bit"; + } + + pin.digital_write(false); + ASSERT_EQ(this->bus_.output[number / 8], 0x00) << "pin " << int(number) << " did not clear"; + } +} + +TEST_F(PCA9505Test, ConfiguringAnOutputOnlyTouchesItsOwnBank) { + this->component_.setup(); + + PCA9505GPIOPin pin = this->make_pin_(35, gpio::FLAG_OUTPUT); + pin.setup(); + + // Pin 35 is bit 3 of bank 4. The part reads a 1 as an input, so that bit clears. + EXPECT_EQ(this->bus_.config[4], 0xFF & ~(1 << 3)); + for (uint8_t bank = 0; bank < 4; bank++) + EXPECT_EQ(this->bus_.config[bank], 0xFF) << "bank " << int(bank) << " should be untouched"; +} + +TEST_F(PCA9505Test, ReadsReportTheLevelOnTheHighestPin) { + this->component_.setup(); + PCA9505GPIOPin pin = this->make_pin_(39, gpio::FLAG_INPUT); + pin.setup(); + + this->bus_.input[4] = 0x80; // pin 39 high + EXPECT_TRUE(pin.digital_read()); + + this->component_.loop(); // clears the cache so the next read hits the bus + this->bus_.input[4] = 0x00; + EXPECT_FALSE(pin.digital_read()); +} + +TEST_F(PCA9505Test, InvertedPinFlipsTheReportedLevel) { + this->component_.setup(); + PCA9505GPIOPin pin = this->make_pin_(39, gpio::FLAG_INPUT, /*inverted=*/true); + pin.setup(); + + this->bus_.input[4] = 0x80; + EXPECT_FALSE(pin.digital_read()); + + this->component_.loop(); + this->bus_.input[4] = 0x00; + EXPECT_TRUE(pin.digital_read()); +} + +// Eight pins sharing a port must cost one transfer per loop, not eight. +TEST_F(PCA9505Test, PinsInOneBankShareASingleRead) { + this->component_.setup(); + + PCA9505GPIOPin pins[8]; + for (uint8_t i = 0; i < 8; i++) { + pins[i] = this->make_pin_(8 + i, gpio::FLAG_INPUT); + pins[i].setup(); + } + + this->bus_.input[1] = 0xA5; + const unsigned before = this->bus_.reads; + for (uint8_t i = 0; i < 8; i++) + EXPECT_EQ(pins[i].digital_read(), ((0xA5 >> i) & 1) != 0) << "pin " << int(8 + i); + EXPECT_EQ(this->bus_.reads - before, 1u); +} + +// Pins in different ports are cached separately, so each port costs its own read. +TEST_F(PCA9505Test, EachBankIsReadSeparately) { + this->component_.setup(); + + PCA9505GPIOPin pins[BANK_COUNT]; + for (uint8_t bank = 0; bank < BANK_COUNT; bank++) { + pins[bank] = this->make_pin_(bank * 8, gpio::FLAG_INPUT); + pins[bank].setup(); + } + + const unsigned before = this->bus_.reads; + for (auto &pin : pins) + pin.digital_read(); + EXPECT_EQ(this->bus_.reads - before, BANK_COUNT); +} + +TEST_F(PCA9505Test, WritesAreSkippedOnceSetupHasFailed) { + this->bus_.fail = true; + this->component_.setup(); + ASSERT_TRUE(this->component_.is_failed()); + + this->bus_.fail = false; + const unsigned before = this->bus_.writes; + PCA9505GPIOPin pin = this->make_pin_(0, gpio::FLAG_OUTPUT); + pin.setup(); + pin.digital_write(true); + EXPECT_EQ(this->bus_.writes, before); +} + +// A 1 in a mask register blocks that pin from pulling INT low. +TEST_F(PCA9505Test, SetupMasksEveryInterrupt) { + // Pretend a previous run left port 2 unmasked. The chip keeps its registers + // through an MCU reboot, so setup must write them rather than assume the reset state. + this->bus_.mask[2] = 0x00; + this->component_.setup(); + + for (uint8_t bank = 0; bank < BANK_COUNT; bank++) + EXPECT_EQ(this->bus_.mask[bank], 0xFF) << "bank " << int(bank) << " should be masked"; +} + +TEST_F(PCA9505Test, ConfiguringAnInputUnmasksOnlyThatPin) { + this->component_.set_interrupt_pin(&this->int_pin_); + this->component_.setup(); + + PCA9505GPIOPin pin = this->make_pin_(34, gpio::FLAG_INPUT); + pin.setup(); + + // Pin 34 is bit 2 of bank 4, and a 0 lets it through. + EXPECT_EQ(this->bus_.mask[4], 0xFF & ~(1 << 2)); + for (uint8_t bank = 0; bank < 4; bank++) + EXPECT_EQ(this->bus_.mask[bank], 0xFF) << "bank " << int(bank) << " should be untouched"; +} + +TEST_F(PCA9505Test, ConfiguringAnOutputLeavesItMasked) { + this->component_.set_interrupt_pin(&this->int_pin_); + this->component_.setup(); + + PCA9505GPIOPin pin = this->make_pin_(34, gpio::FLAG_OUTPUT); + pin.setup(); + + // An output must not be able to pull INT low. + EXPECT_EQ(this->bus_.mask[4], 0xFF); +} + +TEST_F(PCA9505Test, WithoutAnInterruptPinTheMasksAreNotRewritten) { + this->component_.setup(); + const unsigned before = this->bus_.writes; + + PCA9505GPIOPin pin = this->make_pin_(34, gpio::FLAG_INPUT); + pin.setup(); + + EXPECT_EQ(this->bus_.writes, before + 1); // the configuration register only + EXPECT_EQ(this->bus_.mask[4], 0xFF); +} + +// Without an interrupt pin an input turns the polling loop on; with one it stays off. +TEST_F(PCA9505Test, AnInputEnablesPollingOnlyWithoutAnInterruptPin) { + this->component_.setup(); + ASSERT_TRUE(this->component_.is_idle()); + PCA9505GPIOPin polled = this->make_pin_(3, gpio::FLAG_INPUT); + polled.setup(); + EXPECT_TRUE(this->component_.is_in_loop_state()); + + PCA9505Component with_interrupt; + with_interrupt.set_i2c_bus(&this->bus_); + with_interrupt.set_i2c_address(FakeBus::ADDRESS); + with_interrupt.set_interrupt_pin(&this->int_pin_); + with_interrupt.setup(); + ASSERT_TRUE(with_interrupt.is_idle()); + PCA9505GPIOPin pin; + pin.set_parent(&with_interrupt); + pin.set_pin(3); + pin.set_flags(gpio::FLAG_INPUT); + pin.setup(); + EXPECT_TRUE(with_interrupt.is_idle()); +} + +// With an interrupt pin a read is served from the cache until loop() clears it. +TEST_F(PCA9505Test, InterruptPinKeepsTheCacheUntilTheLoopRuns) { + this->component_.set_interrupt_pin(&this->int_pin_); + this->component_.setup(); + PCA9505GPIOPin pin = this->make_pin_(3, gpio::FLAG_INPUT); + pin.setup(); + + const unsigned before = this->bus_.reads; + pin.digital_read(); + pin.digital_read(); + EXPECT_EQ(this->bus_.reads, before + 1); + + this->component_.loop(); + pin.digital_read(); + EXPECT_EQ(this->bus_.reads, before + 2); +} + +// The loop only turns itself off once INT has gone high again, so a change that +// lands during the read cannot leave the cache stale with no edge to wake it. +TEST_F(PCA9505Test, LoopStaysOnUntilIntIsReleased) { + this->component_.set_interrupt_pin(&this->int_pin_); + this->component_.setup(); + this->component_.enable_loop(); + ASSERT_TRUE(this->component_.is_in_loop_state()); + + this->int_pin_.set_inverted(false); // the host pin reads back its inverted flag: INT still low + this->component_.loop(); + EXPECT_TRUE(this->component_.is_in_loop_state()); + + this->int_pin_.set_inverted(true); // INT released + this->component_.loop(); + EXPECT_TRUE(this->component_.is_idle()); +} + +} // namespace esphome::pca9505::testing From d275926f6513edb2dfa1347f1583dbd053746584 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 13 Oct 2026 17:55:22 -0500 Subject: [PATCH 042/142] [mdns] Advertise the connected interface in the network TXT record (#20673) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/mdns/mdns_component.cpp | 53 ++++++++++++++++++- esphome/components/mdns/mdns_component.h | 22 ++++++++ esphome/components/mdns/mdns_esp32.cpp | 19 ++++++- esphome/components/mdns/mdns_rp2.cpp | 19 +++++++ .../mdns/common-dual-interface.yaml | 9 ++++ .../mdns/test-dual-interface.esp32-idf.yaml | 17 ++++++ .../mdns/test-dual-interface.rp2040-ard.yaml | 17 ++++++ 7 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 tests/components/mdns/common-dual-interface.yaml create mode 100644 tests/components/mdns/test-dual-interface.esp32-idf.yaml create mode 100644 tests/components/mdns/test-dual-interface.rp2040-ard.yaml diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 4397c101fc5..0e363852688 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -20,6 +20,10 @@ #ifdef USE_DASHBOARD_IMPORT #include "esphome/components/dashboard_import/dashboard_import.h" #endif +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT +#include "esphome/components/ethernet/ethernet_component.h" +#include "esphome/components/wifi/wifi_component.h" +#endif namespace esphome::mdns { @@ -39,6 +43,45 @@ MDNS_STATIC_CONST_CHAR(SERVICE_TCP, "_tcp"); // Wrap build-time defines into flash storage MDNS_STATIC_CONST_CHAR(VALUE_VERSION, ESPHOME_VERSION); +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT +MDNS_STATIC_CONST_CHAR(NETWORK_WIFI, "wifi"); +MDNS_STATIC_CONST_CHAR(NETWORK_ETHERNET, "ethernet"); + +// Same order as the default route in NetworkComponent::loop() +const char *MDNSComponent::connected_network_() const { + const bool ethernet = ethernet::global_eth_component->is_connected(); + const bool wifi = wifi::global_wifi_component->is_connected(); +#ifdef USE_NETWORK_PRIMARY_INTERFACE_WIFI + if (wifi) + return NETWORK_WIFI; +#endif + if (ethernet) + return NETWORK_ETHERNET; + if (wifi) + return NETWORK_WIFI; + return nullptr; +} + +void MDNSComponent::update_network_txt_() { + if (this->network_txt_ == nullptr) + return; + // With no interface connected, keep the last value instead of flapping to wifi + const char *network = this->connected_network_(); + if (network == nullptr || network == this->network_txt_) + return; + if (!this->publish_network_txt_(network)) { + // The _esphomelib service is missing, so a retry would fail too + this->network_txt_ = nullptr; + return; + } + ESP_LOGD(TAG, "Network TXT: %s", network); + this->network_txt_ = network; +#ifdef USE_MDNS_STORE_SERVICES + this->network_record_->value = MDNS_STR(network); +#endif +} +#endif + void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_register) { #ifdef USE_MDNS_STORE_SERVICES auto &services = this->services_; @@ -160,7 +203,15 @@ void MDNSComponent::compile_records_(StaticVectorconnected_network_(); + this->network_txt_ = network != nullptr ? network : NETWORK_WIFI; + txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(this->network_txt_)}); +#ifdef USE_MDNS_STORE_SERVICES + // services is services_ here, which never reallocates + this->network_record_ = &txt_records.back(); +#endif +#elif defined(USE_WIFI) MDNS_STATIC_CONST_CHAR(NETWORK_WIFI, "wifi"); txt_records.push_back({MDNS_STR(TXT_NETWORK), MDNS_STR(NETWORK_WIFI)}); #elif defined(USE_ETHERNET) diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 93d9eb1e385..f2ea7c19149 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -32,6 +32,12 @@ #define USE_MDNS_DEVICE_INFO_TXT #endif +// With both wifi and ethernet, the _esphomelib "network" TXT value follows the connected interface. +// OpenThread hands its services to the SRP client once, so it keeps the fixed value. +#if defined(USE_API) && defined(USE_WIFI) && defined(USE_ETHERNET) && !defined(USE_OPENTHREAD) +#define USE_MDNS_DYNAMIC_NETWORK_TXT +#endif + namespace esphome::mdns { // Helper struct that identifies strings that may be stored in flash storage (similar to LogString) @@ -150,6 +156,22 @@ class MDNSComponent final : public Component void setup_buffers_and_register_(PlatformRegisterFn platform_register); +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT + static constexpr uint32_t NETWORK_TXT_CHECK_INTERVAL_MS = 1000; + /// The first connected interface in network: priority order ("ethernet" or "wifi"), else nullptr. + const char *connected_network_() const; + /// Check the connected interface and publish a changed "network" TXT value. + void update_network_txt_(); + /// Platform-specific: replace the "network" TXT value of the registered _esphomelib service. + bool publish_network_txt_(const char *network); + /// Advertised value; nullptr until the _esphomelib service is registered, and after a failed publish. + const char *network_txt_{nullptr}; +#ifdef USE_MDNS_STORE_SERVICES + /// The "network" record in services_, kept in step with network_txt_. + MDNSTXTRecord *network_record_{nullptr}; +#endif +#endif + #ifdef USE_MDNS_DYNAMIC_TXT /// Storage for runtime-generated TXT values from user lambdas /// Pre-sized at compile time via MDNS_DYNAMIC_TXT_COUNT to avoid heap allocations. diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index 1df5b9217e5..afa30eb5f30 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -89,7 +89,24 @@ bool MDNSComponent::set_service_enabled(const char *service_type, const char *pr } #endif // USE_MDNS_SUPPORTS_ENABLE_DISABLE && !USE_OPENTHREAD -void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp32); } +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT +bool MDNSComponent::publish_network_txt_(const char *network) { + // ESP-IDF copies the value and announces the changed record + esp_err_t err = mdns_service_txt_item_set("_esphomelib", "_tcp", "network", network); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to update network TXT: %s", esp_err_to_name(err)); + return false; + } + return true; +} +#endif + +void MDNSComponent::setup() { + this->setup_buffers_and_register_(register_esp32); +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT + this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, [this]() { this->update_network_txt_(); }); +#endif +} void MDNSComponent::on_shutdown() { #ifndef USE_OPENTHREAD diff --git a/esphome/components/mdns/mdns_rp2.cpp b/esphome/components/mdns/mdns_rp2.cpp index 7eaac594fb5..603f3f0417c 100644 --- a/esphome/components/mdns/mdns_rp2.cpp +++ b/esphome/components/mdns/mdns_rp2.cpp @@ -46,7 +46,26 @@ void MDNSComponent::start_polling_window_() { } #endif +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT +ESPHOME_LOG_TAG(TAG, "mdns"); + +bool MDNSComponent::publish_network_txt_(const char *network) { + // LEAmDNS replaces the value of an existing key; announce() sends the change + if (!MDNS.addServiceTxt("esphomelib", "tcp", "network", network)) { + ESP_LOGW(TAG, "Failed to update network TXT"); + return false; + } + MDNS.announce(); + this->start_polling_window_(); + return true; +} +#endif + void MDNSComponent::setup() { +#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT + // No-op until the first IP-up event registers the services + this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, [this]() { this->update_network_txt_(); }); +#endif // arduino-pico stubs out LwipIntf::stateUpCB (the netif status callback LEAmDNS uses // on ESP8266 for auto-restart), so we must drive begin()/notifyAPChange() from our // own IP state listener. Both WiFi and Ethernet have the same listener signature — diff --git a/tests/components/mdns/common-dual-interface.yaml b/tests/components/mdns/common-dual-interface.yaml new file mode 100644 index 00000000000..97ae545f2bc --- /dev/null +++ b/tests/components/mdns/common-dual-interface.yaml @@ -0,0 +1,9 @@ +# wifi and ethernet together: the _esphomelib "network" TXT value follows +# the connected interface, so the update path is compiled. +wifi: + ssid: MySSID + password: password1 + +api: + +mdns: diff --git a/tests/components/mdns/test-dual-interface.esp32-idf.yaml b/tests/components/mdns/test-dual-interface.esp32-idf.yaml new file mode 100644 index 00000000000..d480824494b --- /dev/null +++ b/tests/components/mdns/test-dual-interface.esp32-idf.yaml @@ -0,0 +1,17 @@ +packages: + mdns: !include common-dual-interface.yaml + +ethernet: + type: W5500 + clk_pin: GPIO19 + mosi_pin: GPIO21 + miso_pin: GPIO23 + cs_pin: GPIO18 + interrupt_pin: GPIO36 + reset_pin: GPIO22 + clock_speed: 10Mhz + +network: + priority: + - ethernet + - wifi diff --git a/tests/components/mdns/test-dual-interface.rp2040-ard.yaml b/tests/components/mdns/test-dual-interface.rp2040-ard.yaml new file mode 100644 index 00000000000..3836afb7eef --- /dev/null +++ b/tests/components/mdns/test-dual-interface.rp2040-ard.yaml @@ -0,0 +1,17 @@ +packages: + mdns: !include common-dual-interface.yaml + +ethernet: + type: W5500 + clk_pin: 18 + mosi_pin: 19 + miso_pin: 16 + cs_pin: 17 + interrupt_pin: 21 + reset_pin: 20 + +# wifi first, so the USE_NETWORK_PRIMARY_INTERFACE_WIFI order is compiled +network: + priority: + - wifi + - ethernet From 63616bf642e3505c655076f6d8847d0e403cd31e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 13:06:27 -1000 Subject: [PATCH 043/142] [rtttl] Play a configured song from static storage without copying it (#19569) --- esphome/components/rtttl/__init__.py | 13 ++- esphome/components/rtttl/rtttl.cpp | 106 ++++++++++++++---- esphome/components/rtttl/rtttl.h | 69 +++++++++--- tests/components/rtttl/test_rtttl_parser.cpp | 109 +++++++++++++++++++ 4 files changed, 255 insertions(+), 42 deletions(-) create mode 100644 tests/components/rtttl/test_rtttl_parser.cpp diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index 47b8f26e3ff..8bff8bb58dc 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -14,6 +14,8 @@ _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@glmnet", "@ximex"] DOMAIN = "rtttl" CONF_RTTTL = "rtttl" +# The player keeps the song length in 16 bits +MAX_SONG_LENGTH = 65535 CONF_ON_FINISHED_PLAYBACK = "on_finished_playback" rtttl_ns = cg.esphome_ns.namespace("rtttl") @@ -97,16 +99,23 @@ async def to_code(config: ConfigType) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) +def _static_song(config: ConfigType, value: str) -> str: + # A constant song plays straight from static storage, flash on ESP8266, with no copy per play + return f"esphome::rtttl::StaticSong{{{cg.FlashStringLiteral(value)}}}" + + automation.register_apply_action( "rtttl.play", cv.maybe_simple_value( { cv.GenerateID(CONF_ID): cv.use_id(Rtttl), - cv.Required(CONF_RTTTL): cv.templatable(cv.string), + cv.Required(CONF_RTTTL): cv.templatable( + cv.All(cv.string, cv.ByteLength(max=MAX_SONG_LENGTH)) + ), }, key=CONF_RTTTL, ), - automation.ApplyField(CONF_RTTTL, "play", cg.std_string), + automation.ApplyField(CONF_RTTTL, "play", cg.std_string, const_fn=_static_song), ) automation.register_apply_action( diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp index fb2bf63a646..a1d49c65c72 100644 --- a/esphome/components/rtttl/rtttl.cpp +++ b/esphome/components/rtttl/rtttl.cpp @@ -1,5 +1,8 @@ #include "rtttl.h" +#include #include +#include +#include #include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" @@ -147,14 +150,14 @@ void Rtttl::loop() { #endif // USE_SPEAKER // Align to note: most rtttl's out there does not add any space after the ',' separator but just in case - while (this->position_ < this->rtttl_.length()) { - char c = this->rtttl_[this->position_]; + while (this->position_ < this->song_len_) { + char c = this->song_at_(this->position_); if (c != ',' && c != ' ') break; this->position_++; } - if (this->position_ >= this->rtttl_.length()) { + if (this->position_ >= this->song_len_) { this->finish_(); return; } @@ -169,12 +172,12 @@ void Rtttl::loop() { this->note_duration_ = this->wholenote_duration_ / this->default_note_denominator_; } - uint8_t note_index_in_octave = note_index_from_char(this->rtttl_[this->position_]); + uint8_t note_index_in_octave = note_index_from_char(this->song_at_(this->position_)); this->position_++; // Now, get optional '#' sharp - if (this->rtttl_[this->position_] == '#') { + if (this->song_at_(this->position_) == '#') { note_index_in_octave++; this->position_++; } @@ -192,7 +195,7 @@ void Rtttl::loop() { } // Now, get optional '.' dotted note - if (this->rtttl_[this->position_] == '.') { + if (this->song_at_(this->position_) == '.') { this->note_duration_ += this->note_duration_ / 2; // Duration +50% this->position_++; } @@ -264,15 +267,68 @@ void Rtttl::loop() { } void Rtttl::play(std::string rtttl) { - if (this->state_ != State::STOPPED && this->state_ != State::STOPPING) { - size_t pos = this->rtttl_.find(':'); - size_t len = (pos != std::string::npos) ? pos : this->rtttl_.length(); - ESP_LOGW(TAG, "Already playing: %.*s", (int) len, this->rtttl_.c_str()); + if (this->is_busy_()) { return; } + this->owned_song_ = std::move(rtttl); + this->set_song_(this->owned_song_.data(), this->owned_song_.size()); + this->start_(); +} - this->rtttl_ = std::move(rtttl); +void Rtttl::play(StaticSong song) { + if (this->is_busy_()) { + return; + } + // Release a previously owned song; the static one needs no storage. + std::string().swap(this->owned_song_); + const char *data = reinterpret_cast(song.rtttl); + this->set_song_(data, ESPHOME_strlen_P(data)); + this->start_(); +} +void Rtttl::set_song_(const char *song, size_t len) { + this->song_ = song; + // Anything past what the 16 bit length holds is dropped; the validator keeps configured songs shorter. + this->song_len_ = static_cast(std::min(len, std::numeric_limits::max())); +} + +bool Rtttl::is_busy_() const { + if (this->state_ == State::STOPPED || this->state_ == State::STOPPING) { + return false; + } + char name[SONG_NAME_LENGTH_LIMIT + 1]; + this->song_name_(name, sizeof(name), this->song_find_(':', 0, this->song_len_)); + ESP_LOGW(TAG, "Already playing: %s", name); + return true; +} + +size_t Rtttl::song_find_(char c, size_t from, size_t end) const { + for (size_t pos = from; pos < end; pos++) { + if (this->song_at_(pos) == c) + return pos; + } + return end; +} + +size_t Rtttl::song_find_control_(char key, size_t from, size_t end) const { + size_t pos = from; + while ((pos = this->song_find_(key, pos, end)) < end) { + if (this->song_at_(pos + 1) == '=') + return pos; + pos++; + } + return end; +} + +void Rtttl::song_name_(char *buf, size_t size, size_t name_len) const { + size_t len = std::min(name_len, size - 1); + for (size_t pos = 0; pos < len; pos++) { + buf[pos] = this->song_at_(pos); + } + buf[len] = '\0'; +} + +void Rtttl::start_() { this->default_note_denominator_ = DEFAULT_NOTE_DENOMINATOR; this->default_octave_ = DEFAULT_OCTAVE; this->note_duration_ = 0; @@ -281,9 +337,9 @@ void Rtttl::play(std::string rtttl) { uint16_t num; // Used for: default note-denominator, default octave, BPM // Get name - this->position_ = this->rtttl_.find(':'); + this->position_ = this->song_find_(':', 0, this->song_len_); - if (this->position_ == std::string::npos) { + if (this->position_ == this->song_len_) { ESP_LOGE(TAG, "Unable to determine name; missing ':'"); return; } @@ -292,18 +348,20 @@ void Rtttl::play(std::string rtttl) { static_cast(SONG_NAME_LENGTH_LIMIT)); return; } - ESP_LOGD(TAG, "Playing song %.*s", (int) this->position_, this->rtttl_.c_str()); + char name[SONG_NAME_LENGTH_LIMIT + 1]; + this->song_name_(name, sizeof(name), this->position_); + ESP_LOGD(TAG, "Playing song %s", name); size_t name_end_position = this->position_; - size_t control_end = this->rtttl_.find(':', name_end_position + 1); - if (control_end == std::string::npos) { + size_t control_end = this->song_find_(':', name_end_position + 1, this->song_len_); + if (control_end == this->song_len_) { ESP_LOGE(TAG, "Missing second ':'"); return; } // Get default duration - size_t pos = this->rtttl_.find("d=", name_end_position); - if (pos == std::string::npos || pos >= control_end) { + size_t pos = this->song_find_control_('d', name_end_position, control_end); + if (pos == control_end) { ESP_LOGW(TAG, "Missing 'd='; use default duration %d", this->default_note_denominator_); } else { this->position_ = pos + 2; @@ -317,8 +375,8 @@ void Rtttl::play(std::string rtttl) { } // Get default octave - pos = this->rtttl_.find("o=", name_end_position); - if (pos == std::string::npos || pos >= control_end) { + pos = this->song_find_control_('o', name_end_position, control_end); + if (pos == control_end) { ESP_LOGW(TAG, "Missing 'o='; use default octave %d", this->default_octave_); } else { this->position_ = pos + 2; @@ -332,8 +390,8 @@ void Rtttl::play(std::string rtttl) { } // Get BPM - pos = this->rtttl_.find("b=", name_end_position); - if (pos == std::string::npos || pos >= control_end) { + pos = this->song_find_control_('b', name_end_position, control_end); + if (pos == control_end) { ESP_LOGW(TAG, "Missing 'b='; use default BPM %d", bpm); } else { this->position_ = pos + 2; @@ -387,7 +445,7 @@ void Rtttl::stop() { } #endif // USE_SPEAKER - this->position_ = this->rtttl_.length(); + this->position_ = this->song_len_; this->note_duration_ = 0; } @@ -411,7 +469,7 @@ void Rtttl::finish_() { #endif // USE_SPEAKER // Ensure no more notes are played in case finish_() is called for an error. - this->position_ = this->rtttl_.length(); + this->position_ = this->song_len_; this->note_duration_ = 0; } diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index 055204571e1..ade2dcd3205 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -3,6 +3,7 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" #ifdef USE_OUTPUT #include "esphome/components/output/float_output.h" @@ -14,6 +15,10 @@ namespace esphome::rtttl { +namespace testing { +class RtttlParserAccess; +} // namespace testing + inline constexpr uint8_t DEFAULT_NOTE_DENOMINATOR = 4; // Default note-denominator (quarter note) inline constexpr uint8_t DEFAULT_OCTAVE = 6; // Default octave for a note (see: `MIN_OCTAVE`, `MAX_OCTAVE` in `rtttl.cpp`) @@ -26,6 +31,11 @@ enum class State : uint8_t { STOPPING, }; +/// A song in storage that outlives playback, such as a literal from generated code; PROGMEM on ESP8266. +struct StaticSong { + ProgmemStr rtttl; +}; + class Rtttl final : public Component { public: #ifdef USE_OUTPUT @@ -38,7 +48,10 @@ class Rtttl final : public Component { void dump_config() override; void loop() override; + /// Play a song; the string is copied so it may come from anywhere. void play(std::string rtttl); + /// Play a song from static storage without copying it. + void play(StaticSong song); void stop(); float get_gain() { return this->gain_; } @@ -55,11 +68,29 @@ class Rtttl final : public Component { protected: inline uint16_t get_integer_() { uint16_t ret = 0; - while (isdigit(this->rtttl_[this->position_])) { - ret = (ret * 10) + (this->rtttl_[this->position_++] - '0'); + while (isdigit(this->song_at_(this->position_))) { + ret = (ret * 10) + (this->song_at_(this->position_++) - '0'); } return ret; } + /// The byte of the song at pos, or NUL past its end; the read is flash safe for a PROGMEM song on ESP8266. + char song_at_(size_t pos) const { + if (pos >= this->song_len_) + return '\0'; + return static_cast(progmem_read_byte(reinterpret_cast(this->song_) + pos)); + } + /// Index of the first `c` in [from, end), or `end` when absent. + size_t song_find_(char c, size_t from, size_t end) const; + /// Index of `key` followed by '=' in [from, end), or `end` when absent. + size_t song_find_control_(char key, size_t from, size_t end) const; + /// Copies the first name_len bytes of the song, its name, into buf for logging. + void song_name_(char *buf, size_t size, size_t name_len) const; + /// Points the view at a song, clamped to what song_len_ can hold. + void set_song_(const char *song, size_t len); + /// Parses the header and starts playback of the song the view points at. + void start_(); + /// Logs and returns true when a song is still playing, so a new one must wait. + bool is_busy_() const; /** * @brief Finalizes the playback of the RTTTL string. * @@ -70,26 +101,30 @@ class Rtttl final : public Component { void finish_(); void set_state_(State state); - /// The RTTTL string to play. - std::string rtttl_; - /// The current position in the RTTTL string. - size_t position_{0}; - /// The default duration of a note (e.g. 4 for a quarter note). - uint8_t default_note_denominator_{DEFAULT_NOTE_DENOMINATOR}; - /// The default octave for a note. - uint8_t default_octave_{DEFAULT_OCTAVE}; - /// The duration of the current note in milliseconds. - uint16_t note_duration_{0}; - /// The duration of a whole note in milliseconds. - uint16_t wholenote_duration_; - /// The current state of the RTTTL player. - State state_{State::STOPPED}; + /// The song being played; points at owned_song_ or at static storage. + const char *song_{nullptr}; + /// Backing store for play(std::string). + std::string owned_song_; /// The time in milliseconds since microcontroller boot when the last note was started. uint32_t last_note_start_time_; /// The frequency of the current note in Hz. uint32_t output_freq_{0}; /// The gain of the output. float gain_{0.6f}; + /// Length of the song in bytes; songs are a few hundred bytes, so 16 bits is ample. + uint16_t song_len_{0}; + /// The current position in the song. + uint16_t position_{0}; + /// The duration of the current note in milliseconds. + uint16_t note_duration_{0}; + /// The duration of a whole note in milliseconds. + uint16_t wholenote_duration_; + /// The default duration of a note (e.g. 4 for a quarter note). + uint8_t default_note_denominator_{DEFAULT_NOTE_DENOMINATOR}; + /// The default octave for a note. + uint8_t default_octave_{DEFAULT_OCTAVE}; + /// The current state of the RTTTL player. + State state_{State::STOPPED}; #ifdef USE_OUTPUT /// The output to write the sound to. @@ -113,6 +148,8 @@ class Rtttl final : public Component { /// The callback to call when playback is finished. CallbackManager on_finished_playback_callback_; #endif + + friend class testing::RtttlParserAccess; }; } // namespace esphome::rtttl diff --git a/tests/components/rtttl/test_rtttl_parser.cpp b/tests/components/rtttl/test_rtttl_parser.cpp new file mode 100644 index 00000000000..3c706f29877 --- /dev/null +++ b/tests/components/rtttl/test_rtttl_parser.cpp @@ -0,0 +1,109 @@ +#include + +#include + +#include "esphome/components/rtttl/rtttl.h" + +namespace esphome::rtttl::testing { + +// Reads the parser state the tests check; Rtttl is final, so this is a friend rather than a subclass. +class RtttlParserAccess { + public: + static uint8_t denominator(const Rtttl &p) { return p.default_note_denominator_; } + static uint8_t octave(const Rtttl &p) { return p.default_octave_; } + static uint16_t wholenote(const Rtttl &p) { return p.wholenote_duration_; } + static uint16_t note_duration(const Rtttl &p) { return p.note_duration_; } + static uint32_t freq(const Rtttl &p) { return p.output_freq_; } + static size_t position(const Rtttl &p) { return p.position_; } + static char at(const Rtttl &p, size_t pos) { return p.song_at_(pos); } + static void set_running(Rtttl &p) { p.state_ = State::RUNNING; } +}; + +using Access = RtttlParserAccess; + +static const char SONG[] = "test:d=8,o=5,b=100:c"; + +TEST(RtttlParser, StaticSongParsesTheHeader) { + Rtttl player; + player.play(StaticSong{SONG}); + EXPECT_EQ(Access::denominator(player), 8); + EXPECT_EQ(Access::octave(player), 5); + EXPECT_EQ(Access::wholenote(player), 60 * 1000 * 4 / 100); + // Positioned on the first note after the second ':' + EXPECT_EQ(Access::position(player), 19u); + EXPECT_EQ(Access::at(player, Access::position(player)), 'c'); +} + +TEST(RtttlParser, OwnedSongParsesTheSameAndOutlivesTheCaller) { + Rtttl player; + { + std::string song(SONG); + player.play(song); + } + EXPECT_EQ(Access::denominator(player), 8); + EXPECT_EQ(Access::octave(player), 5); + EXPECT_EQ(Access::at(player, 0), 't'); + EXPECT_EQ(Access::at(player, Access::position(player)), 'c'); + EXPECT_EQ(Access::at(player, 100), '\0'); +} + +TEST(RtttlParser, ControlOrderDoesNotMatter) { + for (const char *song : {"a:b=100,o=5,d=8:c", "a:o=5,b=100,d=8:c", "a:d=8,b=100,o=5:c"}) { + Rtttl player; + player.play(StaticSong{song}); + EXPECT_EQ(Access::denominator(player), 8) << song; + EXPECT_EQ(Access::octave(player), 5) << song; + EXPECT_EQ(Access::wholenote(player), 2400) << song; + } +} + +TEST(RtttlParser, MissingControlsUseDefaults) { + Rtttl player; + player.play(StaticSong{"a::c"}); + EXPECT_EQ(Access::denominator(player), DEFAULT_NOTE_DENOMINATOR); + EXPECT_EQ(Access::octave(player), DEFAULT_OCTAVE); + EXPECT_EQ(Access::wholenote(player), 60 * 1000 * 4 / 63); +} + +TEST(RtttlParser, NotesReadFromStaticStorage) { + Rtttl player; + player.play(StaticSong{"t:d=4,o=5,b=120:8c,p"}); + Access::set_running(player); + player.loop(); + // C5 at 523 Hz for an eighth of a 2000 ms whole note + EXPECT_EQ(Access::freq(player), 523u); + EXPECT_EQ(Access::note_duration(player), 250); + player.loop(); + // A pause for a default quarter note + EXPECT_EQ(Access::freq(player), 0u); + EXPECT_EQ(Access::note_duration(player), 500); + player.loop(); + // End of the song + EXPECT_EQ(Access::at(player, Access::position(player)), '\0'); +} + +TEST(RtttlParser, RefusesANewSongWhilePlaying) { + Rtttl player; + player.play(StaticSong{"first:d=8:c"}); + Access::set_running(player); + player.play(std::string("second:d=16:c")); + EXPECT_EQ(Access::denominator(player), 8); + EXPECT_EQ(Access::at(player, 0), 'f'); +} + +TEST(RtttlParser, ClampsASongLongerThanTheLengthField) { + Rtttl player; + player.play(std::string(70000, 'a')); + EXPECT_EQ(Access::at(player, 65534), 'a'); + EXPECT_EQ(Access::at(player, 65535), '\0'); + EXPECT_EQ(Access::note_duration(player), 0); +} + +TEST(RtttlParser, RejectsASongWithoutAName) { + Rtttl player; + player.play(StaticSong{"no colon here"}); + EXPECT_EQ(Access::note_duration(player), 0); + EXPECT_EQ(Access::position(player), 13u); +} + +} // namespace esphome::rtttl::testing From 36e1d831d3b73b83bc2dec4bece3d6f7f9c339a5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 13:18:35 -1000 Subject: [PATCH 044/142] [core] Add a decimal fast path to `parse_number` (#20639) --- esphome/core/helpers.cpp | 52 +++++++++++++++ esphome/core/helpers.h | 8 +-- tests/components/core/test_parse_float.cpp | 77 ++++++++++++++++++++++ 3 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 tests/components/core/test_parse_float.cpp diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 906f9fb12ab..872bb199084 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #ifdef USE_ESP32 @@ -315,6 +316,57 @@ size_t truncate_name_to(char *buffer, size_t buffer_size, const char *name, size // Parsing & formatting +// A plain decimal converts exactly with one division: the digits accumulate +// into an integer that stays below 2^24 and the fraction's scale is a power of +// ten up to 1e9 (2^9 * 5^9), so both convert to float exactly and IEEE division +// rounds the quotient correctly, the same result strtof produces. +static constexpr uint32_t PARSE_FLOAT_MANTISSA_LIMIT = 1000000; // one more digit stays below 2^24 +static constexpr uint32_t PARSE_FLOAT_MAX_FRACTION_DIGITS = 9; + +optional parse_float(const char *str) { + const char *p = str; + const bool negative = *p == '-'; + if (negative || *p == '+') + p++; + const char *digits = p; + const char *dot = nullptr; + uint32_t mantissa = 0; + for (;; p++) { + const uint32_t d = static_cast(*p) - '0'; + if (d > 9) { + if (*p == '.' && dot == nullptr) { + dot = p; + continue; + } + break; + } + if (mantissa >= PARSE_FLOAT_MANTISSA_LIMIT) + break; + mantissa = mantissa * 10 + d; + } + const uint32_t fraction_digits = dot != nullptr ? p - dot - 1 : 0; + const bool any_digit = p - digits > (dot != nullptr ? 1 : 0); + if (*p == '\0' && any_digit && fraction_digits <= PARSE_FLOAT_MAX_FRACTION_DIGITS) { + // The mantissa is exact in a float, so the sign goes on before the one rounding step + float value = static_cast(mantissa); + if (negative) + value = -value; + if (fraction_digits != 0) { + uint32_t scale = 10; + for (uint32_t i = 1; i < fraction_digits; i++) + scale *= 10; + value /= static_cast(scale); + } + return value; + } + // Exponents, hex, inf, nan, leading whitespace and longer inputs take the libc path. + char *end = nullptr; + const float value = ::strtof(str, &end); + if (end == str || *end != '\0' || value == HUGE_VALF) + return {}; + return value; +} + size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) { size_t chars = std::min(length, 2 * count); for (size_t i = 2 * count - chars; i < 2 * count; i++, str++) { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index bcb03b010f6..15f247cadbc 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1299,13 +1299,11 @@ template::value && std::is_signed parse_number(const std::string &str) { return parse_number(str.c_str()); } +/// Parse a decimal floating-point number; plain decimals skip the libc parser. +optional parse_float(const char *str); /// Parse a decimal floating-point number from a null-terminated string. template::value), int> = 0> optional parse_number(const char *str) { - char *end = nullptr; - float value = ::strtof(str, &end); - if (end == str || *end != '\0' || value == HUGE_VALF) - return {}; - return value; + return parse_float(str); } /// Parse a decimal floating-point number. template::value), int> = 0> diff --git a/tests/components/core/test_parse_float.cpp b/tests/components/core/test_parse_float.cpp new file mode 100644 index 00000000000..1d52f8afc8f --- /dev/null +++ b/tests/components/core/test_parse_float.cpp @@ -0,0 +1,77 @@ +#include + +#include +#include +#include +#include +#include + +#include "esphome/core/helpers.h" + +namespace esphome::core::testing { + +static uint32_t bits(float value) { + uint32_t out; + memcpy(&out, &value, sizeof(out)); + return out; +} + +// strtof is the reference: every accepted input must give the same bits. +static void expect_matches_strtof(const char *str) { + char *end = nullptr; + const float expected = ::strtof(str, &end); + const auto parsed = parse_number(str); + if (!parsed.has_value()) { + ADD_FAILURE() << str << " was rejected"; + return; + } + const float actual = parsed.value(); + EXPECT_EQ(bits(actual), bits(expected)) << str << " parsed " << actual << " expected " << expected; +} + +TEST(ParseFloat, PlainDecimalsMatchStrtof) { + for (const char *str : + {"0", "-0", "+0", "1", "-1", "100.50", "99.75", "0.1", "0.3", + "2.675", "9999999", "0.9999999", "1234.567", "-1234.567", "0.000001", "0.000000001", "12.", ".5", + "-.5", "007", "00.50", "123.4567", "0.009999999", "0.001234567", "-0.009999999"}) { + expect_matches_strtof(str); + } +} + +TEST(ParseFloat, SweepOfTwoDecimalValuesMatchesStrtof) { + char buf[16]; + for (int i = -20000; i <= 20000; i++) { + snprintf(buf, sizeof(buf), "%s%d.%02d", i < 0 ? "-" : "", std::abs(i) / 100, std::abs(i) % 100); + expect_matches_strtof(buf); + } +} + +// A fraction longer than the fast path accepts must fall back, however long it is. +TEST(ParseFloat, LongFractionsMatchStrtof) { + std::string str = "0."; + str.append(300, '0'); + str += "25"; + expect_matches_strtof(str.c_str()); + expect_matches_strtof(("-" + str).c_str()); + str = "1."; + str.append(300, '0'); + expect_matches_strtof(str.c_str()); +} + +TEST(ParseFloat, LibcPathInputsMatchStrtof) { + for (const char *str : {"1e3", "-2.5E-2", "0x10", "nan", " 12", "123456789", "16777215", "1.0000000", "0.0000000001", + "0.00000000001", "1.23456789", "3.4028235e38"}) { + expect_matches_strtof(str); + } +} + +// Positive overflow and a positive literal infinity both come back as HUGE_VALF from strtof, and the parser +// rejects both; the negative forms pass through, as they did before the fast path. +TEST(ParseFloat, RejectsInvalidInput) { + for (const char *str : + {"", "-", "+", ".", "abc", "12abc", "1.2.3", "1,5", "12 ", "--1", "1e", "1e400", "inf", "infinity"}) { + EXPECT_FALSE(parse_number(str).has_value()) << str; + } +} + +} // namespace esphome::core::testing From f61955baa2d7f1b44ce5e8fa58f027f837546c52 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 13:21:23 -1000 Subject: [PATCH 045/142] [core] Declare the remaining log tags with ESPHOME_LOG_TAG (#20666) --- esphome/components/bedjet/bedjet_const.h | 3 ++- esphome/components/bme280_i2c/bme280_i2c.h | 3 ++- esphome/components/bmp280_i2c/bmp280_i2c.h | 3 ++- esphome/components/bmp581_i2c/bmp581_i2c.h | 3 ++- esphome/components/chsc6x/chsc6x_touchscreen.h | 2 +- esphome/components/cst816/touchscreen/cst816_touchscreen.h | 2 +- esphome/components/cst9220/touchscreen/cst9220_touchscreen.h | 2 +- esphome/components/ezo/ezo.h | 3 ++- esphome/components/lvgl/lvgl_esphome.h | 2 +- esphome/components/mapping/mapping.h | 2 +- esphome/components/mipi_dsi/mipi_dsi.h | 2 +- esphome/components/mipi_rgb/mipi_rgb.h | 3 ++- esphome/components/mipi_spi/mipi_spi.h | 3 ++- esphome/components/runtime_stats/runtime_stats.h | 2 +- esphome/components/sdl/sdl_esphome.h | 2 +- esphome/components/spi_led_strip/spi_led_strip.h | 2 +- esphome/components/st7701s/st7701s.h | 3 ++- esphome/components/usb_host/usb_host.h | 3 ++- esphome/components/usb_uart/usb_uart.h | 3 ++- esphome/components/zio_ultrasonic/zio_ultrasonic.h | 3 ++- esphome/core/log.h | 5 +++-- esphome/core/wake/wake_host.cpp | 2 +- 22 files changed, 35 insertions(+), 23 deletions(-) diff --git a/esphome/components/bedjet/bedjet_const.h b/esphome/components/bedjet/bedjet_const.h index eb777a6a834..aa84cdddde9 100644 --- a/esphome/components/bedjet/bedjet_const.h +++ b/esphome/components/bedjet/bedjet_const.h @@ -1,10 +1,11 @@ #pragma once #include +#include "esphome/core/log.h" namespace esphome::bedjet { -static const char *const TAG = "bedjet"; +ESPHOME_LOG_TAG(TAG, "bedjet"); /// Converts a BedJet fan step to a speed percentage, in the range of 5% to 100%. inline static uint8_t bedjet_fan_step_to_speed(const uint8_t fan) { diff --git a/esphome/components/bme280_i2c/bme280_i2c.h b/esphome/components/bme280_i2c/bme280_i2c.h index 501556d3c40..45c3e5b260f 100644 --- a/esphome/components/bme280_i2c/bme280_i2c.h +++ b/esphome/components/bme280_i2c/bme280_i2c.h @@ -2,10 +2,11 @@ #include "esphome/components/bme280_base/bme280_base.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/log.h" namespace esphome::bme280_i2c { -static const char *const TAG = "bme280_i2c.sensor"; +ESPHOME_LOG_TAG(TAG, "bme280_i2c.sensor"); class BME280I2CComponent final : public esphome::bme280_base::BME280Component, public i2c::I2CDevice { bool read_byte(uint8_t a_register, uint8_t *data) override; diff --git a/esphome/components/bmp280_i2c/bmp280_i2c.h b/esphome/components/bmp280_i2c/bmp280_i2c.h index a19203ff0a0..f2ec93176ef 100644 --- a/esphome/components/bmp280_i2c/bmp280_i2c.h +++ b/esphome/components/bmp280_i2c/bmp280_i2c.h @@ -2,10 +2,11 @@ #include "esphome/components/bmp280_base/bmp280_base.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/log.h" namespace esphome::bmp280_i2c { -static const char *const TAG = "bmp280_i2c.sensor"; +ESPHOME_LOG_TAG(TAG, "bmp280_i2c.sensor"); /// This class implements support for the BMP280 Temperature+Pressure i2c sensor. class BMP280I2CComponent final : public esphome::bmp280_base::BMP280Component, public i2c::I2CDevice { diff --git a/esphome/components/bmp581_i2c/bmp581_i2c.h b/esphome/components/bmp581_i2c/bmp581_i2c.h index 126ffd6a606..24929c7d8b0 100644 --- a/esphome/components/bmp581_i2c/bmp581_i2c.h +++ b/esphome/components/bmp581_i2c/bmp581_i2c.h @@ -2,10 +2,11 @@ #include "esphome/components/bmp581_base/bmp581_base.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/log.h" namespace esphome::bmp581_i2c { -static const char *const TAG = "bmp581_i2c.sensor"; +ESPHOME_LOG_TAG(TAG, "bmp581_i2c.sensor"); /// This class implements support for the BMP581 Temperature+Pressure i2c sensor. class BMP581I2CComponent final : public esphome::bmp581_base::BMP581Component, public i2c::I2CDevice { diff --git a/esphome/components/chsc6x/chsc6x_touchscreen.h b/esphome/components/chsc6x/chsc6x_touchscreen.h index 84e539e5f25..9278546ee94 100644 --- a/esphome/components/chsc6x/chsc6x_touchscreen.h +++ b/esphome/components/chsc6x/chsc6x_touchscreen.h @@ -8,7 +8,7 @@ namespace esphome::chsc6x { -static const char *const TAG = "chsc6x.touchscreen"; +ESPHOME_LOG_TAG(TAG, "chsc6x.touchscreen"); static const uint8_t CHSC6X_REG_STATUS = 0x00; static const uint8_t CHSC6X_REG_STATUS_TOUCH = 0x00; diff --git a/esphome/components/cst816/touchscreen/cst816_touchscreen.h b/esphome/components/cst816/touchscreen/cst816_touchscreen.h index 84b561c7345..72dda4e9854 100644 --- a/esphome/components/cst816/touchscreen/cst816_touchscreen.h +++ b/esphome/components/cst816/touchscreen/cst816_touchscreen.h @@ -8,7 +8,7 @@ namespace esphome::cst816 { -static const char *const TAG = "cst816.touchscreen"; +ESPHOME_LOG_TAG(TAG, "cst816.touchscreen"); static const uint8_t REG_STATUS = 0x00; static const uint8_t REG_TOUCH_NUM = 0x02; diff --git a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h index 17050e2429a..5a8e043d654 100644 --- a/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h +++ b/esphome/components/cst9220/touchscreen/cst9220_touchscreen.h @@ -8,7 +8,7 @@ namespace esphome::cst9220 { -static const char *const TAG = "cst9220.touchscreen"; +ESPHOME_LOG_TAG(TAG, "cst9220.touchscreen"); // The CST92xx family uses 16-bit (big-endian) register addresses. static const uint16_t REG_TOUCH_DATA = 0xD000; // touch report diff --git a/esphome/components/ezo/ezo.h b/esphome/components/ezo/ezo.h index a20419eceb1..7a3e84f56ca 100644 --- a/esphome/components/ezo/ezo.h +++ b/esphome/components/ezo/ezo.h @@ -3,11 +3,12 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/log.h" #include namespace esphome::ezo { -static const char *const TAG = "ezo.sensor"; +ESPHOME_LOG_TAG(TAG, "ezo.sensor"); enum EzoCommandType : uint8_t { EZO_READ = 0, diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 4da8a6a1a30..75f926d865e 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -214,7 +214,7 @@ enum class Orientation : uint8_t { }; class LvglComponent final : public PollingComponent { - constexpr static const char *const TAG = "lvgl"; + constexpr static const char *const TAG = "lvgl"; // NOLINT: lvgl does not build for ESP8266 public: LvglComponent(std::vector displays, float buffer_frac, bool full_refresh, int draw_rounding, diff --git a/esphome/components/mapping/mapping.h b/esphome/components/mapping/mapping.h index d6790caa35d..e4985c91fa1 100644 --- a/esphome/components/mapping/mapping.h +++ b/esphome/components/mapping/mapping.h @@ -19,7 +19,7 @@ using alloc_string_t = std::basic_string, RAMAlloca * @tparam V The type of the value in the mapping. Should be a basic type or pointer. */ -static const char *const TAG = "mapping"; +ESPHOME_LOG_TAG(TAG, "mapping"); template class Mapping { public: diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index 4243d3116cf..4df22354681 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -18,7 +18,7 @@ namespace esphome::mipi_dsi { -constexpr static const char *const TAG = "display.mipi_dsi"; +ESPHOME_LOG_TAG(TAG, "display.mipi_dsi"); const uint8_t SW_RESET_CMD = 0x01; const uint8_t SLEEP_OUT = 0x11; const uint8_t SDIR_CMD = 0xC7; diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index 8b29e273abd..01b1ebee91f 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -2,6 +2,7 @@ #if defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S31) #include "esphome/core/gpio.h" +#include "esphome/core/log.h" #include "esphome/components/display/display.h" #include #ifdef USE_SPI @@ -10,7 +11,7 @@ namespace esphome::mipi_rgb { -constexpr static const char *const TAG = "display.mipi_rgb"; +ESPHOME_LOG_TAG(TAG, "display.mipi_rgb"); const uint8_t SW_RESET_CMD = 0x01; const uint8_t SLEEP_OUT = 0x11; const uint8_t SDIR_CMD = 0xC7; diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 48a2cafbdd3..96f26ef19b7 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -7,10 +7,11 @@ #include "esphome/components/display/display_color_utils.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" +#include "esphome/core/log.h" namespace esphome::mipi_spi { -constexpr static const char *const TAG = "display.mipi_spi"; +ESPHOME_LOG_TAG(TAG, "display.mipi_spi"); // Maximum bytes to log for commands (truncated if larger) static constexpr size_t MIPI_SPI_MAX_CMD_LOG_BYTES = 64; diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 2c783a0df30..61b152d7d36 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -15,7 +15,7 @@ class Component; // Forward declaration namespace runtime_stats { -static const char *const TAG = "runtime_stats"; +ESPHOME_LOG_TAG(TAG, "runtime_stats"); class RuntimeStatsCollector final { public: diff --git a/esphome/components/sdl/sdl_esphome.h b/esphome/components/sdl/sdl_esphome.h index 54f0d2573fd..c58de643cc8 100644 --- a/esphome/components/sdl/sdl_esphome.h +++ b/esphome/components/sdl/sdl_esphome.h @@ -13,7 +13,7 @@ namespace esphome::sdl { -constexpr static const char *const TAG = "sdl"; +ESPHOME_LOG_TAG(TAG, "sdl"); class Sdl final : public display::Display, public snapshot::Snapshot { public: diff --git a/esphome/components/spi_led_strip/spi_led_strip.h b/esphome/components/spi_led_strip/spi_led_strip.h index 20b9c25c2e9..a5b10a0e9a6 100644 --- a/esphome/components/spi_led_strip/spi_led_strip.h +++ b/esphome/components/spi_led_strip/spi_led_strip.h @@ -7,7 +7,7 @@ namespace esphome::spi_led_strip { -static const char *const TAG = "spi_led_strip"; +ESPHOME_LOG_TAG(TAG, "spi_led_strip"); class SpiLedStrip final : public light::AddressableLight, public spi::SPIDevice { diff --git a/esphome/components/st7701s/st7701s.h b/esphome/components/st7701s/st7701s.h index d44f8c68590..fe29d794141 100644 --- a/esphome/components/st7701s/st7701s.h +++ b/esphome/components/st7701s/st7701s.h @@ -8,13 +8,14 @@ #include "esphome/core/component.h" #include "esphome/components/spi/spi.h" #include "esphome/components/display/display.h" +#include "esphome/core/log.h" #include "esp_lcd_panel_ops.h" #include "esp_lcd_panel_rgb.h" namespace esphome::st7701s { -constexpr static const char *const TAG = "display.st7701s"; +ESPHOME_LOG_TAG(TAG, "display.st7701s"); const uint8_t SW_RESET_CMD = 0x01; const uint8_t SLEEP_OUT = 0x11; const uint8_t SDIR_CMD = 0xC7; diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index aaab8e22a18..bbb6f15f292 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -12,6 +12,7 @@ #include #include "esphome/core/lock_free_queue.h" #include "esphome/core/event_pool.h" +#include "esphome/core/log.h" #include #include @@ -39,7 +40,7 @@ namespace esphome::usb_host { // - Main loop controls backpressure by deciding when to restart after consuming data // The atomic bitmask ensures thread-safe allocation/deallocation without mutex blocking. -static const char *const TAG = "usb_host"; +ESPHOME_LOG_TAG(TAG, "usb_host"); // Forward declarations struct TransferRequest; diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 4e52fb60e20..b54641884e6 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -9,6 +9,7 @@ #include "esphome/components/usb_host/usb_host.h" #include "esphome/core/lock_free_queue.h" #include "esphome/core/event_pool.h" +#include "esphome/core/log.h" #include #include @@ -19,7 +20,7 @@ class USBUartComponent; class USBUartChannelBase; class USBUartTypePL2303; -static const char *const TAG = "usb_uart"; +ESPHOME_LOG_TAG(TAG, "usb_uart"); static constexpr uint8_t USB_CDC_SUBCLASS_ACM = 0x02; static constexpr uint8_t USB_SUBCLASS_COMMON = 0x02; diff --git a/esphome/components/zio_ultrasonic/zio_ultrasonic.h b/esphome/components/zio_ultrasonic/zio_ultrasonic.h index 1dbce873073..12ea8e8ade0 100644 --- a/esphome/components/zio_ultrasonic/zio_ultrasonic.h +++ b/esphome/components/zio_ultrasonic/zio_ultrasonic.h @@ -3,8 +3,9 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/log.h" -static const char *const TAG = "Zio Ultrasonic"; +ESPHOME_LOG_TAG(TAG, "Zio Ultrasonic"); namespace esphome::zio_ultrasonic { diff --git a/esphome/core/log.h b/esphome/core/log.h index 145ba3a6f8a..840da292aee 100644 --- a/esphome/core/log.h +++ b/esphome/core/log.h @@ -190,8 +190,9 @@ struct LogString; #endif -// Declares a static log tag at namespace scope in a .cpp file; on ESP8266 it lives in flash, so read it with -// the _P functions, never strcmp/strlen. +// Declares a static log tag at namespace scope, in a source file or a header; on ESP8266 it lives in flash, so +// read it with the _P functions, never strcmp/strlen. Not usable for a class member: the ESP8266 form is a +// PROGMEM array, which cannot be initialized in a class. #ifdef USE_ESP8266 #define ESPHOME_LOG_TAG(name, tag) static const char name[] PROGMEM = tag #else diff --git a/esphome/core/wake/wake_host.cpp b/esphome/core/wake/wake_host.cpp index 9fc1c717032..e2353db28f4 100644 --- a/esphome/core/wake/wake_host.cpp +++ b/esphome/core/wake/wake_host.cpp @@ -22,7 +22,7 @@ namespace esphome { // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) std::atomic g_wake_requested{0}; -static const char *const TAG = "wake"; +ESPHOME_LOG_TAG(TAG, "wake"); namespace internal { // File-scope state — referenced inline by wake_drain_notifications() and From 92f6ab836744c0ed25ddb7e37c9245be37eb4724 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 13:21:24 -1000 Subject: [PATCH 046/142] [ci] Lint log tags in headers and core (#20668) --- esphome/components/ili9xxx/ili9xxx_display.h | 3 ++- esphome/components/qspi_dbi/qspi_dbi.h | 3 ++- esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h | 2 +- script/ci-custom.py | 10 ++++--- tests/script/test_ci_custom.py | 28 +++++++++++++++++++- 5 files changed, 39 insertions(+), 7 deletions(-) diff --git a/esphome/components/ili9xxx/ili9xxx_display.h b/esphome/components/ili9xxx/ili9xxx_display.h index 2529e54021b..77cee44ae68 100644 --- a/esphome/components/ili9xxx/ili9xxx_display.h +++ b/esphome/components/ili9xxx/ili9xxx_display.h @@ -2,12 +2,13 @@ #include "esphome/components/spi/spi.h" #include "esphome/components/display/display_buffer.h" #include "esphome/components/display/display_color_utils.h" +#include "esphome/core/log.h" #include "ili9xxx_defines.h" #include "ili9xxx_init.h" namespace esphome::ili9xxx { -static const char *const TAG = "ili9xxx"; +ESPHOME_LOG_TAG(TAG, "ili9xxx"); const size_t ILI9XXX_TRANSFER_BUFFER_SIZE = 126; // ensure this is divisible by 6 enum ILI9XXXColorMode { diff --git a/esphome/components/qspi_dbi/qspi_dbi.h b/esphome/components/qspi_dbi/qspi_dbi.h index 8a1bf0d4c24..eca062f4711 100644 --- a/esphome/components/qspi_dbi/qspi_dbi.h +++ b/esphome/components/qspi_dbi/qspi_dbi.h @@ -8,12 +8,13 @@ #include "esphome/components/display/display.h" #include "esphome/components/display/display_buffer.h" #include "esphome/components/display/display_color_utils.h" +#include "esphome/core/log.h" #include "esp_lcd_panel_rgb.h" namespace esphome::qspi_dbi { -constexpr static const char *const TAG = "display.qspi_dbi"; +ESPHOME_LOG_TAG(TAG, "display.qspi_dbi"); static const uint8_t SW_RESET_CMD = 0x01; static const uint8_t SLEEP_OUT = 0x11; static const uint8_t NORON = 0x13; diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h index df0e2b0b167..ad2f6e80c26 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.h @@ -16,7 +16,7 @@ namespace esphome::rpi_dpi_rgb { -constexpr static const char *const TAG = "rpi_dpi_rgb"; +ESPHOME_LOG_TAG(TAG, "rpi_dpi_rgb"); class RpiDpiRgb final : public display::Display { public: diff --git a/script/ci-custom.py b/script/ci-custom.py index 8361387af01..4431f62cedd 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -1269,11 +1269,15 @@ def lint_no_std_nothrow(fname, match): ) +# Every C++ source and header; plain C files cannot use the macro +LOG_TAG_INCLUDE = ["esphome/*.cpp", "esphome/*.h"] + + @lint_re_check( r"^\s*(?:(?:static|constexpr|inline)\s+)*(?:const\s+)?char\s*(?:\*\s*(?:const\s+)?TAG|\s(?:const\s+)?TAG\s*\[\w*\])" - r"\s*(?:=\s*\{?|[{(])\s*\"", + r"\s*(?:=\s*\{?|[{(])\s*\"" + CPP_RE_EOL, prefilter="TAG", - include=["esphome/components/*.cpp", "esphome/components/**/*.cpp"], + include=LOG_TAG_INCLUDE, ) def lint_log_tag_macro(fname, match): return ( @@ -1292,7 +1296,7 @@ def lint_log_tag_macro(fname, match): r"|\bstd::string\s+\w+\s*[={(]\s*TAG\b", mask=True, prefilter="TAG", - include=["esphome/components/*.cpp", "esphome/components/**/*.cpp"], + include=LOG_TAG_INCLUDE, ) def lint_log_tag_as_string(fname, match): return ( diff --git a/tests/script/test_ci_custom.py b/tests/script/test_ci_custom.py index f303d19bb0d..621ffd93a42 100644 --- a/tests/script/test_ci_custom.py +++ b/tests/script/test_ci_custom.py @@ -400,6 +400,7 @@ def test_ternary_error_message_names_the_literal() -> None: 'static constexpr char TAG[] = {"x"};', 'static const char TAG[8] = "x";', ' static const char *const TAG = "x";', + 'constexpr static const char *const TAG = "x";', ], ) def test_log_tag_macro_flags_plain_tags(line: str) -> None: @@ -408,12 +409,37 @@ def test_log_tag_macro_flags_plain_tags(line: str) -> None: @pytest.mark.parametrize( "line", - ['ESPHOME_LOG_TAG(TAG, "x");', 'static const char *const NAME = "x";'], + [ + 'ESPHOME_LOG_TAG(TAG, "x");', + 'static const char *const NAME = "x";', + 'static const char *const TAG = "x"; // NOLINT', + ], ) def test_log_tag_macro_ignores_macro_and_other_names(line: str) -> None: assert not ci_custom.lint_log_tag_macro("test.cpp", line + "\n") +@pytest.mark.parametrize( + ("fname", "covered"), + [ + ("esphome/components/x/x.cpp", True), + ("esphome/components/x/x.h", True), + ("esphome/components/x/sub/x.h", True), + ("esphome/core/wake/wake_host.cpp", True), + ("esphome/components/x/x.c", False), + ], +) +def test_log_tag_lints_cover_headers_and_core(fname: str, covered: bool) -> None: + for name, line in ( + ("lint_log_tag_macro", 'static const char *const TAG = "x";\n'), + ("lint_log_tag_as_string", "strcmp(TAG, name);\n"), + ): + lint = next( + c for c in ci_custom.LINT_CONTENT_CHECKS if c["func"].__name__ == name + ) + assert bool(ci_custom.run_check(lint, fname, fname, line)) is covered + + @pytest.mark.parametrize( "line", [ From c6e90de262e87ba0d05db0a7d705fd3f7a0f3a22 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 13:21:24 -1000 Subject: [PATCH 047/142] [ci] Catch out-of-class member log tags and keep midea's in flash (#20674) --- esphome/components/ble_client/automation.cpp | 2 +- esphome/components/midea/ac_adapter.cpp | 2 +- esphome/components/midea/ac_adapter.h | 2 +- script/ci-custom.py | 8 +++++--- tests/script/test_ci_custom.py | 5 +++++ 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/esphome/components/ble_client/automation.cpp b/esphome/components/ble_client/automation.cpp index cd2802f6173..b0fac5b1218 100644 --- a/esphome/components/ble_client/automation.cpp +++ b/esphome/components/ble_client/automation.cpp @@ -4,7 +4,7 @@ namespace esphome::ble_client { -const char *const Automation::TAG = "ble_client.automation"; +const char *const Automation::TAG = "ble_client.automation"; // NOLINT: ble_client never builds for ESP8266 } // namespace esphome::ble_client diff --git a/esphome/components/midea/ac_adapter.cpp b/esphome/components/midea/ac_adapter.cpp index 30771d25ca2..e362c241e8c 100644 --- a/esphome/components/midea/ac_adapter.cpp +++ b/esphome/components/midea/ac_adapter.cpp @@ -5,7 +5,7 @@ namespace esphome::midea::ac { -const char *const Constants::TAG = "midea"; +const char Constants::TAG[] PROGMEM = "midea"; const char *const Constants::FREEZE_PROTECTION = "Freeze Protection"; const char *const Constants::SILENT = "Silent"; const char *const Constants::TURBO = "Turbo"; diff --git a/esphome/components/midea/ac_adapter.h b/esphome/components/midea/ac_adapter.h index 4a888ee8ff2..62e93d2465a 100644 --- a/esphome/components/midea/ac_adapter.h +++ b/esphome/components/midea/ac_adapter.h @@ -17,7 +17,7 @@ using MideaPreset = dudanov::midea::ac::Preset; class Constants { public: - static const char *const TAG; + static const char TAG[]; // PROGMEM, like ESPHOME_LOG_TAG static const char *const FREEZE_PROTECTION; static const char *const SILENT; static const char *const TURBO; diff --git a/script/ci-custom.py b/script/ci-custom.py index 4431f62cedd..5b268d62969 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -1274,7 +1274,7 @@ LOG_TAG_INCLUDE = ["esphome/*.cpp", "esphome/*.h"] @lint_re_check( - r"^\s*(?:(?:static|constexpr|inline)\s+)*(?:const\s+)?char\s*(?:\*\s*(?:const\s+)?TAG|\s(?:const\s+)?TAG\s*\[\w*\])" + r"^\s*(?:(?:static|constexpr|inline)\s+)*(?:const\s+)?char\s*(?:\*\s*(?:const\s+)?(?:\w+::)*TAG|\s(?:const\s+)?(?:\w+::)*TAG\s*\[\w*\])" r"\s*(?:=\s*\{?|[{(])\s*\"" + CPP_RE_EOL, prefilter="TAG", include=LOG_TAG_INCLUDE, @@ -1285,6 +1285,8 @@ def lint_log_tag_macro(fname, match): f"ESP8266.\n" f" Before: {highlight('static const char *const TAG = "name";')}\n" f" After: {highlight('ESPHOME_LOG_TAG(TAG, "name");')}\n" + f"A class member tag cannot use the macro; declare {highlight('static const char TAG[];')} in the class " + f"and define {highlight('const char Foo::TAG[] PROGMEM = "name";')} in the .cpp.\n" f"Never pass such a TAG to set_timeout/set_interval names or string functions.\n" f"(If strictly necessary, add `// NOLINT` to the end of the line)" ) @@ -1292,8 +1294,8 @@ def lint_log_tag_macro(fname, match): @lint_re_check( r"(?:\b(?:set_timeout|set_interval|set_retry|cancel_timeout|cancel_interval|cancel_retry|defer)" - r"|\b(?:strcmp|strncmp|strcasecmp|strlen|strcpy|strncpy)|std::string)\s*\((?:(?:[^();]|\([^()]*\))*,)?\s*TAG\b" - r"|\bstd::string\s+\w+\s*[={(]\s*TAG\b", + r"|\b(?:strcmp|strncmp|strcasecmp|strlen|strcpy|strncpy)|std::string)\s*\((?:(?:[^();]|\([^()]*\))*,)?\s*(?:\w+::)*TAG\b" + r"|\bstd::string\s+\w+\s*[={(]\s*(?:\w+::)*TAG\b", mask=True, prefilter="TAG", include=LOG_TAG_INCLUDE, diff --git a/tests/script/test_ci_custom.py b/tests/script/test_ci_custom.py index 621ffd93a42..e65bf9755e3 100644 --- a/tests/script/test_ci_custom.py +++ b/tests/script/test_ci_custom.py @@ -401,6 +401,8 @@ def test_ternary_error_message_names_the_literal() -> None: 'static const char TAG[8] = "x";', ' static const char *const TAG = "x";', 'constexpr static const char *const TAG = "x";', + 'const char *const Constants::TAG = "x";', + 'const char Constants::TAG[] = "x";', ], ) def test_log_tag_macro_flags_plain_tags(line: str) -> None: @@ -413,6 +415,7 @@ def test_log_tag_macro_flags_plain_tags(line: str) -> None: 'ESPHOME_LOG_TAG(TAG, "x");', 'static const char *const NAME = "x";', 'static const char *const TAG = "x"; // NOLINT', + 'const char Constants::TAG[] PROGMEM = "x";', ], ) def test_log_tag_macro_ignores_macro_and_other_names(line: str) -> None: @@ -452,6 +455,8 @@ def test_log_tag_lints_cover_headers_and_core(fname: str, covered: bool) -> None "std::string(TAG);", "std::string name = TAG;", "std::string name{TAG};", + "strlen(Constants::TAG);", + "this->set_timeout(Constants::TAG, 100, cb);", ], ) def test_log_tag_as_string_flags_string_uses(line: str) -> None: From 3cdcf0417a62809452aba6abb054654f14ec050d Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:31:08 +0100 Subject: [PATCH 048/142] [lvgl] Make the list_outside_block tests work offline (#20676) --- tests/component_tests/lvgl/config/image.png | Bin 0 -> 78 bytes .../lvgl/config/list_outside_block_test.yaml | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 tests/component_tests/lvgl/config/image.png diff --git a/tests/component_tests/lvgl/config/image.png b/tests/component_tests/lvgl/config/image.png new file mode 100644 index 0000000000000000000000000000000000000000..dec8dd15317b06fcf0915663767b66aef8ddd225 GIT binary patch literal 78 zcmeAS@N?(olHy`uVBq!ia0y~yVBi2@Mg|53hBqb6Y77hvqMj~}Ar-fhf1JN?Vgoym ewB$eIKkN+pkJ(J(vVYG5DfD#pb6Mw<&;$Ua^c4;O literal 0 HcmV?d00001 diff --git a/tests/component_tests/lvgl/config/list_outside_block_test.yaml b/tests/component_tests/lvgl/config/list_outside_block_test.yaml index 44407348999..58898c89b12 100644 --- a/tests/component_tests/lvgl/config/list_outside_block_test.yaml +++ b/tests/component_tests/lvgl/config/list_outside_block_test.yaml @@ -30,7 +30,7 @@ display: image: - platform: file - file: mdi:battery + file: image.png id: my_image resize: 8x8 type: binary From 95711d83d1cb164f197165703bb04e994a65297b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 14:29:29 -1000 Subject: [PATCH 049/142] [ili9xxx] Set removal of the deprecated component for 2027.5.0 (#20687) --- esphome/components/ili9xxx/__init__.py | 1 + esphome/components/ili9xxx/display.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/ili9xxx/__init__.py b/esphome/components/ili9xxx/__init__.py index 2803b439156..3efebabd885 100644 --- a/esphome/components/ili9xxx/__init__.py +++ b/esphome/components/ili9xxx/__init__.py @@ -3,4 +3,5 @@ DOMAIN = "ili9xxx" DEPRECATED_COMPONENT = """ The 'ili9xxx' component is deprecated and no new models will be added to it. New model PRs should target the newer and more performant 'mipi_spi' component. +It will be removed in ESPHome 2027.5.0. """ diff --git a/esphome/components/ili9xxx/display.py b/esphome/components/ili9xxx/display.py index 64f87c167c1..5928eb5771f 100644 --- a/esphome/components/ili9xxx/display.py +++ b/esphome/components/ili9xxx/display.py @@ -220,8 +220,10 @@ FINAL_VALIDATE_SCHEMA = final_validate async def to_code(config: ConfigType) -> None: + # Remove before 2027.5.0 LOGGER.warning( - "The 'ili9xxx' component is deprecated, it is recommended to use 'mipi_spi' instead." + "The 'ili9xxx' component is deprecated and will be removed in ESPHome 2027.5.0, " + "migrate to 'mipi_spi' instead." ) rhs = MODELS[config[CONF_MODEL]].new() var = cg.Pvariable(config[CONF_ID], rhs) From 96902cc1222b64cd288fcb805563fbadfcc653ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 14:29:52 -1000 Subject: [PATCH 050/142] [qspi_dbi] Set removal of the deprecated component for 2027.5.0 (#20690) --- esphome/components/qspi_dbi/__init__.py | 1 + esphome/components/qspi_dbi/display.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/qspi_dbi/__init__.py b/esphome/components/qspi_dbi/__init__.py index abf64f675af..a6dee79abb0 100644 --- a/esphome/components/qspi_dbi/__init__.py +++ b/esphome/components/qspi_dbi/__init__.py @@ -6,4 +6,5 @@ CONF_DRAW_FROM_ORIGIN = "draw_from_origin" DEPRECATED_COMPONENT = """ The 'qspi_dbi' component is deprecated and no new models will be added to it. New model PRs should target the newer and more performant 'mipi_spi' component. +It will be removed in ESPHome 2027.5.0. """ diff --git a/esphome/components/qspi_dbi/display.py b/esphome/components/qspi_dbi/display.py index dce1a95687d..cf2a6441a6c 100644 --- a/esphome/components/qspi_dbi/display.py +++ b/esphome/components/qspi_dbi/display.py @@ -165,8 +165,10 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: + # Remove before 2027.5.0 LOGGER.warning( - "The 'qspi_dbi' component is deprecated, it is recommended to use 'mipi_spi' instead." + "The 'qspi_dbi' component is deprecated and will be removed in ESPHome 2027.5.0, " + "migrate to 'mipi_spi' instead." ) var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) From 9238fcc72727f4f1ca575afc706e4a3250c1c0cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 14:30:21 -1000 Subject: [PATCH 051/142] [st7735] Set removal of the deprecated component for 2027.5.0 (#20688) --- esphome/components/st7735/__init__.py | 1 + esphome/components/st7735/display.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/st7735/__init__.py b/esphome/components/st7735/__init__.py index 176eb233e17..d6e5035a9a4 100644 --- a/esphome/components/st7735/__init__.py +++ b/esphome/components/st7735/__init__.py @@ -7,4 +7,5 @@ st7735_ns = cg.esphome_ns.namespace("st7735") DEPRECATED_COMPONENT = """ The 'st7735' component is deprecated and no new models will be added to it. New model PRs should target the newer and more performant 'mipi_spi' component. +It will be removed in ESPHome 2027.5.0. """ diff --git a/esphome/components/st7735/display.py b/esphome/components/st7735/display.py index 5cbcee12ab3..d6774f4639e 100644 --- a/esphome/components/st7735/display.py +++ b/esphome/components/st7735/display.py @@ -92,8 +92,10 @@ async def setup_st7735(var: MockObj, config: ConfigType) -> None: async def to_code(config: ConfigType) -> None: + # Remove before 2027.5.0 LOGGER.warning( - "The 'st7735' component is deprecated, it is recommended to use 'mipi_spi' instead." + "The 'st7735' component is deprecated and will be removed in ESPHome 2027.5.0, " + "migrate to 'mipi_spi' instead." ) var = cg.new_Pvariable( config[CONF_ID], From 3b261d2cf0dca7517f0050f50f1444679288eb18 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 14:30:52 -1000 Subject: [PATCH 052/142] [st7789v] Set removal of the deprecated component for 2027.5.0 (#20689) --- esphome/components/st7789v/__init__.py | 1 + esphome/components/st7789v/display.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/st7789v/__init__.py b/esphome/components/st7789v/__init__.py index e67f4809128..dfe139d0183 100644 --- a/esphome/components/st7789v/__init__.py +++ b/esphome/components/st7789v/__init__.py @@ -7,4 +7,5 @@ st7789v_ns = cg.esphome_ns.namespace("st7789v") DEPRECATED_COMPONENT = """ The 'st7789v' component is deprecated and no new functionality will be added to it. PRs should target the newer and more performant 'mipi_spi' component. +It will be removed in ESPHome 2027.5.0. """ diff --git a/esphome/components/st7789v/display.py b/esphome/components/st7789v/display.py index fa72c7c328a..82d3f0b26d6 100644 --- a/esphome/components/st7789v/display.py +++ b/esphome/components/st7789v/display.py @@ -183,8 +183,10 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( async def to_code(config: ConfigType) -> None: + # Remove before 2027.5.0 LOGGER.warning( - "The 'st7789v' component is deprecated, it is recommended to use 'mipi_spi' instead." + "The 'st7789v' component is deprecated and will be removed in ESPHome 2027.5.0, " + "migrate to 'mipi_spi' instead." ) var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) From 52a5e6a0072faeb94264e58d8c102f3e0e264ed5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 14:32:18 -1000 Subject: [PATCH 053/142] [rpi_dpi_rgb] Set removal of the deprecated component for 2027.5.0 (#20691) --- esphome/components/rpi_dpi_rgb/__init__.py | 1 + esphome/components/rpi_dpi_rgb/display.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/rpi_dpi_rgb/__init__.py b/esphome/components/rpi_dpi_rgb/__init__.py index 20629fd7305..6b274b9b734 100644 --- a/esphome/components/rpi_dpi_rgb/__init__.py +++ b/esphome/components/rpi_dpi_rgb/__init__.py @@ -4,4 +4,5 @@ DOMAIN = "rpi_dpi_rgb" DEPRECATED_COMPONENT = """ The 'rpi_dpi_rgb' component is deprecated and no new models will be added to it. New model PRs should target the newer and more performant 'mipi_rgb' component. +It will be removed in ESPHome 2027.5.0. """ diff --git a/esphome/components/rpi_dpi_rgb/display.py b/esphome/components/rpi_dpi_rgb/display.py index 1ca29a32597..be6ca6c2a74 100644 --- a/esphome/components/rpi_dpi_rgb/display.py +++ b/esphome/components/rpi_dpi_rgb/display.py @@ -132,8 +132,10 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: + # Remove before 2027.5.0 LOGGER.warning( - "The 'rpi_dpi_rgb' component is deprecated, it is recommended to use 'mipi_rgb' instead." + "The 'rpi_dpi_rgb' component is deprecated and will be removed in ESPHome 2027.5.0, " + "migrate to 'mipi_rgb' instead." ) var = cg.new_Pvariable(config[CONF_ID]) await display.register_display(var, config) From f505212e64c4a8975e5d74a5aa2ae7a018399e9e Mon Sep 17 00:00:00 2001 From: Felipe Guedes Date: Wed, 14 Oct 2026 02:03:05 +0100 Subject: [PATCH 054/142] [epaper_spi] Add Waveshare 2.66" e-Paper Module (B) support (#20546) --- .../epaper_spi/models/waveshare_b.py | 8 +++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/esphome/components/epaper_spi/models/waveshare_b.py b/esphome/components/epaper_spi/models/waveshare_b.py index 688e7164566..67a0cf5f233 100644 --- a/esphome/components/epaper_spi/models/waveshare_b.py +++ b/esphome/components/epaper_spi/models/waveshare_b.py @@ -24,3 +24,11 @@ WaveshareB( data_rate="10MHz", minimum_update_interval="1s", ) + +WaveshareB( + "waveshare-2.66in-b", + width=152, + height=296, + data_rate="10MHz", + minimum_update_interval="15s", +) diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 3839b4ff87a..a2c343be836 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -234,6 +234,27 @@ display: it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK); it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0)); + # Waveshare 2.66" B 3-color e-paper (152x296, BWR, SSD1680) + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.66in-b + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color::BLACK); + it.circle(it.get_width() / 2, it.get_height() / 2, 15, Color(255, 0, 0)); + # Good Display GDEY0213F51 4-color e-paper (128x250, BWYR, JD79661) - platform: epaper_spi spi_id: spi_bus From c9cf0ad92eb085ba2fccaedfda4e91abe6c029b4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:37:36 -0400 Subject: [PATCH 055/142] Bump platformdirs from 4.12.3 to 4.12.4 (#20697) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a9dbaa3b8d7..fcbcb171246 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,7 +31,7 @@ smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64" smp==4.1.0; platform_system == "Darwin" and platform_machine == "x86_64" requests==2.34.2 py7zr==1.1.3 -platformdirs==4.12.3 # native esp-idf toolchain global cache dir +platformdirs==4.12.4 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver filelock==4.0.12 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg From 576d3325febf66cdcd32e2637a330ef08b2f2c00 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:37:47 -1000 Subject: [PATCH 056/142] Bump astral-sh/setup-uv from 10.2.0 to 10.3.0 in /.github/actions/restore-python (#20700) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index affe8fc2d6b..4b8e10053ea 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can From c95c17c4e7bd0a948040a18bc1b8cc69195e844c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:38:17 -1000 Subject: [PATCH 057/142] Bump pyupgrade from 3.21.2 to 3.22.0 (#20698) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 727e8e2f9aa..e06494573c6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,7 +36,7 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/asottile/pyupgrade - rev: v3.21.2 + rev: v3.22.0 hooks: - id: pyupgrade args: [--py312-plus] diff --git a/requirements_test.txt b/requirements_test.txt index 0f7d3762554..91ddfa7102d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,7 +1,7 @@ pylint==4.1.2 flake8==7.4.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py ruff==0.16.10 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -pyupgrade==3.21.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +pyupgrade==3.22.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py prek==0.5.5 # .github/workflows/ci.yml reads this pin yamlrocks==0.6.1 # used by script/sync_dependency_versions.py From 426a4a2dce80c4bc823e77d298ba25d4d9ff5da2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:38:26 -1000 Subject: [PATCH 058/142] Bump filelock from 4.0.12 to 4.1.0 (#20696) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index fcbcb171246..4ead538d8e1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,7 +33,7 @@ requests==2.34.2 py7zr==1.1.3 platformdirs==4.12.4 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver -filelock==4.0.12 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +filelock==4.1.0 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.3 From 81e5b50087d7ab0fca84824a5092e65aabcf1266 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:38:35 -1000 Subject: [PATCH 059/142] Bump backports-zstd from 1.7.0 to 1.8.0 (#20695) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4ead538d8e1..ca8c489116d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 -backports.zstd==1.7.0; python_version < "3.14" # esphome.helpers.zstd_module; compression.zstd is in the standard library from 3.14 +backports.zstd==1.8.0; python_version < "3.14" # esphome.helpers.zstd_module; compression.zstd is in the standard library from 3.14 pillow==12.3.0 resvg-py==0.5.0 freetype-py==2.5.1 From d452dbce30fd4195c8003430b8abfdd249566f56 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:38:39 +0000 Subject: [PATCH 060/142] Bump actions/download-artifact from 8.0.1 to 8.0.2 (#20703) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-docker.yml | 2 +- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 5bcdb22cb86..f3457aad4ea 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -210,7 +210,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download image artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: compile-test-image.tar.zst - name: Load image diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 285d6869e12..fee9fea64e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1740,13 +1740,13 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Download target analysis JSON - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: memory-analysis-target path: ./memory-analysis continue-on-error: true - name: Download PR analysis JSON - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: memory-analysis-pr path: ./memory-analysis diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index efccad9ad96..add0f80b243 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -195,7 +195,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download digests - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: digests-* path: /tmp/digests From 7cb5906d2abaebf4e98bd44b0fa795623fa955ef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:38:42 +0000 Subject: [PATCH 061/142] Bump actions/upload-artifact from 7.0.1 to 7.0.2 (#20702) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci-docker.yml | 2 +- .github/workflows/ci.yml | 10 +++++----- .github/workflows/release.yml | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index d0bd1de9ef1..a516e868bef 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -97,7 +97,7 @@ jobs: run: git diff - if: failure() name: Archive artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: generated-proto-files path: | diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index f3457aad4ea..8546daa555f 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -128,7 +128,7 @@ jobs: - name: Upload compile-test image artifact if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: # The tar is already compressed, so upload it as-is. archive: false # skips the redundant zip and makes the file name the artifact name diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fee9fea64e1..741a0d7deb7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -477,7 +477,7 @@ jobs: # Tests that compare rendered output write the image they actually got here, so a # failure can be looked at without reproducing the whole build locally. if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: integration-test-artifacts-${{ matrix.bucket.name }} path: test_artifacts/ @@ -488,7 +488,7 @@ jobs: # script/update_integration_test_durations.py; only full matrix dev # runs produce usable data. if: github.ref == 'refs/heads/dev' && needs.determine-jobs.outputs.integration-run-all == 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: junit-integration-${{ strategy.job-index }} path: junit-integration.xml @@ -528,7 +528,7 @@ jobs: script/check_import_time.py --check --har importtime.har - name: Upload waterfall HAR if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: import-time-waterfall path: importtime.har @@ -1647,7 +1647,7 @@ jobs: fi - name: Upload memory analysis JSON - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: memory-analysis-target path: memory-analysis-target.json @@ -1710,7 +1710,7 @@ jobs: --platform "$platform" - name: Upload memory analysis JSON - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: memory-analysis-pr path: memory-analysis-pr.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index add0f80b243..c0991a6308f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -162,7 +162,7 @@ jobs: # version: ${{ needs.init.outputs.tag }} - name: Upload digests - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: digests-${{ matrix.platform.arch }} path: /tmp/digests From c5522abc7137eef0742020c2678c77d4ab26a7e9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:39:11 -0400 Subject: [PATCH 062/142] Bump github/codeql-action/analyze from 4.38.2 to 4.38.3 (#20699) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index da02a5dd09f..4d441c73c14 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: category: "/language:${{matrix.language}}" From bc39d56bb48a9eb10b37d8e86194acf5c9eb3295 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:39:29 -1000 Subject: [PATCH 063/142] Bump CodSpeedHQ/action from 5.4.0 to 5.4.2 (#20705) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 741a0d7deb7..3e27a3aeb52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -626,7 +626,7 @@ jobs: apt-get install -y libc6-dbg - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@c4fd08a3a159bd0cc208da1e0edf32b8c47d75e5 # v5.4.0 + uses: CodSpeedHQ/action@f842b9a3ffe213dbb81768fd7f7eeb4dfcf1c369 # v5.4.2 with: run: | . venv/bin/activate From 16134ee993ddb5f8e416f8c9fd8a63b72b5e325f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:39:38 -1000 Subject: [PATCH 064/142] Bump astral-sh/setup-uv from 10.2.0 to 10.3.0 (#20704) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci.yml | 6 +++--- .github/workflows/sync-device-classes.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index a516e868bef..9ca9d68d525 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -29,7 +29,7 @@ jobs: - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull-request-only workflow: a save could never be shared and diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e27a3aeb52..d68bb93ec9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -438,7 +438,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -1448,7 +1448,7 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index a0725374f92..ba0404011be 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``prek`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the From 8fce95454ae731ec8fa55926c7b9e79e3f4fb25b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:39:49 -1000 Subject: [PATCH 065/142] Bump github/codeql-action/init from 4.38.2 to 4.38.3 (#20701) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4d441c73c14..caf8c6d8c8f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} From 59f55dbe9ee457948402e68b36c21e13e95a4b81 Mon Sep 17 00:00:00 2001 From: fredkotze <40389058+fredkotze@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:40:28 +1100 Subject: [PATCH 066/142] [epaper_spi] Add Waveshare 2.13in-G (JD79660) display models (#18141) Co-authored-by: J. Nick Koston --- .../components/epaper_spi/models/jd79660.py | 44 ++++++++++++++++++- .../epaper_spi/test.esp32-s3-idf.yaml | 34 ++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/esphome/components/epaper_spi/models/jd79660.py b/esphome/components/epaper_spi/models/jd79660.py index a70d3fe522e..783dc8953a5 100644 --- a/esphome/components/epaper_spi/models/jd79660.py +++ b/esphome/components/epaper_spi/models/jd79660.py @@ -87,6 +87,38 @@ jd79660.extend( ), ) +# Waveshare 2.13-G (V2 hardware/init revision, PCB marked "rev 2.1" or later) +# +# Vendor init derived from vendor sample code +# +# Compatible MIT license, see esphome/LICENSE file. +# +# Note: busy pin uses LOW=busy, HIGH=idle. Configure with inverted: true in YAML. +# Note: panel is 122px wide, but vendor code declares the TRES register as a fixed +# 124 on this revision. width=124 is declared here (rather than 122) purely so the +# transferred bytes-per-row matches what the vendor init tells the IC to expect; the +# extra 2 columns fall outside the visible glass. The model width must equal the +# TRES value in initsequence, so overriding dimensions: in YAML brings the shear back. +# +# fmt: off +jd79660.extend( + "Waveshare-2.13in-G", + width=124, + height=250, + + initsequence=( + (0x61, 0x00, 0x7C, 0x00, 0xFA,), # TRES: 124x250 fixed (124 = TRES stride value) + (0xE9, 0x01,), + # Power On (0x04): Must be early part of init seq = Disabled later! + (0x04,), + ), + fast_update=( + (0xE0, 0x02,), + (0xE6, 0x5A,), + (0xA5,), + ), +) + # Waveshare 7.5-H # # Vendor init derived from vendor sample code @@ -130,7 +162,7 @@ jd79660.extend( # Note: busy pin uses LOW=busy, HIGH=idle. # # fmt: off -jd79660.extend( +gdey0213f51 = jd79660.extend( "goodisplay-gdey0213f51-2.13", width=128, height=250, @@ -154,3 +186,13 @@ jd79660.extend( (0x04,), ), ) + +# Waveshare 2.13-G (V1 hardware/init revision, older stock): the same panel and init as the Good +# Display entry above, byte for byte, so it only adds the name. Vendor sample: +# +# Compatible MIT license, see esphome/LICENSE file. +# +# Note: busy pin uses LOW=busy, HIGH=idle. Configure with inverted: true in YAML. +# Note: width=128 is the TRES value the init sequence programs, not the 122 visible columns; overriding +# dimensions: in YAML shears the image. Not tested on V1 hardware. +gdey0213f51.extend("Waveshare-2.13in-G-V1") diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index a2c343be836..f6091e5715c 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -87,6 +87,40 @@ display: number: GPIO4 inverted: true + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.13in-g + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + inverted: true + + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.13in-g-v1 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + inverted: true + - platform: epaper_spi model: seeed-reterminal-e1002 - platform: epaper_spi From 9ee4314bc2642b9c4c53317a54ecf46e2facdca4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:41:30 -1000 Subject: [PATCH 067/142] Bump esptool from 5.4.0 to 5.5.0 (#20693) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ca8c489116d..5397959f63f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ tzlocal==5.4.4 # from time tzdata>=2026.5 # from time pyserial==3.5 platformio==6.2.0 -esptool==5.4.0 +esptool==5.5.0 click==8.5.0 aioesphomeapi==46.10.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi From 613973b4327fb933b7101c14740b350a1431abb5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:53:44 -0400 Subject: [PATCH 068/142] Bump ruff from 0.16.10 to 0.17.0 (#20694) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- esphome/components/zigbee/zigbee_zephyr.py | 4 ++-- requirements_test.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e06494573c6..48bb3a69da4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.16.10 + rev: v0.17.0 hooks: # Run the linter. - id: ruff diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index d468c9881d0..f7da147b88f 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -1,4 +1,4 @@ -import datetime +import datetime as dt import random import esphome.codegen as cg @@ -176,7 +176,7 @@ async def _attr_to_code(config: ConfigType) -> None: basic_attrs.date_code, # Local build time, matching the esp32 implementation # (App.get_build_time() in C++). - datetime.datetime.now().astimezone().strftime("%Y%m%d %H%M%S"), + dt.datetime.now().astimezone().strftime("%Y%m%d %H%M%S"), ), zigbee_assign( basic_attrs.power_source, diff --git a/requirements_test.txt b/requirements_test.txt index 91ddfa7102d..65e8a63bfe9 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.1.2 flake8==7.4.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -ruff==0.16.10 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +ruff==0.17.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py pyupgrade==3.22.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py prek==0.5.5 # .github/workflows/ci.yml reads this pin yamlrocks==0.6.1 # used by script/sync_dependency_versions.py From 4b8af08155eebac3ae39da1412cb7b92f04bdf42 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 13 Oct 2026 20:57:59 -0500 Subject: [PATCH 069/142] [network] Notify listeners when the active interface changes (#20684) Co-authored-by: Claude Opus 5.5 --- esphome/components/mdns/__init__.py | 19 +++++++ esphome/components/mdns/mdns_component.cpp | 22 ++++++-- esphome/components/mdns/mdns_component.h | 21 +++++--- esphome/components/mdns/mdns_esp32.cpp | 5 +- esphome/components/mdns/mdns_rp2.cpp | 3 +- esphome/components/network/__init__.py | 33 +++++++++++- .../components/network/network_component.cpp | 22 +++++--- .../components/network/network_component.h | 32 ++++++++++++ esphome/core/defines.h | 6 +++ .../config/priority_ethernet_first_api.yaml | 28 ++++++++++ .../network/config/priority_rp2040_api.yaml | 26 ++++++++++ .../network/test_active_interface.py | 52 +++++++++++++++++++ 12 files changed, 247 insertions(+), 22 deletions(-) create mode 100644 tests/component_tests/network/config/priority_ethernet_first_api.yaml create mode 100644 tests/component_tests/network/config/priority_rp2040_api.yaml create mode 100644 tests/component_tests/network/test_active_interface.py diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index e49a3897fee..bc431599248 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -3,7 +3,9 @@ from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option from esphome.config_helpers import filter_source_files_from_platform, get_logger_level import esphome.config_validation as cv from esphome.const import ( + CONF_API, CONF_DISABLED, + CONF_ETHERNET, CONF_ID, CONF_OPENTHREAD, CONF_PORT, @@ -285,6 +287,23 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) + # With both wifi and ethernet, the _esphomelib "network" TXT value follows the + # connected interface. OpenThread hands its services to the SRP client once, + # so it keeps the fixed value. + if ( + CONF_API in CORE.config + and CONF_WIFI in CORE.config + and CONF_ETHERNET in CORE.config + and CONF_OPENTHREAD not in CORE.config + ): + from esphome.components import network + + cg.add_define("USE_MDNS_DYNAMIC_NETWORK_TXT") + # Where the network component tracks the default route (ESP32), follow + # it; otherwise mdns checks the connected interface every second. + if await network.register_active_interface_listener(var): + cg.add_define("USE_MDNS_ACTIVE_INTERFACE_LISTENER") + for service in config[CONF_SERVICES]: txt_records = [ await _mdns_txt_record_templated(var, txt_key, txt_value) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 0e363852688..32a84671fea 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -62,12 +62,9 @@ const char *MDNSComponent::connected_network_() const { return nullptr; } -void MDNSComponent::update_network_txt_() { - if (this->network_txt_ == nullptr) - return; +void MDNSComponent::update_network_txt_(const char *network) { // With no interface connected, keep the last value instead of flapping to wifi - const char *network = this->connected_network_(); - if (network == nullptr || network == this->network_txt_) + if (this->network_txt_ == nullptr || network == nullptr || network == this->network_txt_) return; if (!this->publish_network_txt_(network)) { // The _esphomelib service is missing, so a retry would fail too @@ -80,6 +77,21 @@ void MDNSComponent::update_network_txt_() { this->network_record_->value = MDNS_STR(network); #endif } + +#ifdef USE_MDNS_ACTIVE_INTERFACE_LISTENER +void MDNSComponent::on_active_interface(network::NetworkInterface interface) { + switch (interface) { + case network::NetworkInterface::NETWORK_INTERFACE_ETHERNET: + this->update_network_txt_(NETWORK_ETHERNET); + break; + case network::NetworkInterface::NETWORK_INTERFACE_WIFI: + this->update_network_txt_(NETWORK_WIFI); + break; + default: + break; + } +} +#endif #endif void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_register) { diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index f2ea7c19149..06d10d2d818 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -32,10 +32,11 @@ #define USE_MDNS_DEVICE_INFO_TXT #endif -// With both wifi and ethernet, the _esphomelib "network" TXT value follows the connected interface. -// OpenThread hands its services to the SRP client once, so it keeps the fixed value. -#if defined(USE_API) && defined(USE_WIFI) && defined(USE_ETHERNET) && !defined(USE_OPENTHREAD) -#define USE_MDNS_DYNAMIC_NETWORK_TXT +// USE_MDNS_DYNAMIC_NETWORK_TXT (wifi + ethernet + api): the _esphomelib "network" TXT value follows the +// connected interface. With USE_MDNS_ACTIVE_INTERFACE_LISTENER (ESP32) the network component reports +// default route changes; otherwise the value is checked every NETWORK_TXT_CHECK_INTERVAL_MS. +#ifdef USE_MDNS_ACTIVE_INTERFACE_LISTENER +#include "esphome/components/network/network_component.h" #endif namespace esphome::mdns { @@ -79,6 +80,10 @@ class MDNSComponent final : public Component , public ethernet::EthernetIPStateListener #endif +#ifdef USE_MDNS_ACTIVE_INTERFACE_LISTENER + , + public network::ActiveInterfaceListener +#endif { public: void setup() override; @@ -140,6 +145,10 @@ class MDNSComponent final : public Component } #endif +#ifdef USE_MDNS_ACTIVE_INTERFACE_LISTENER + void on_active_interface(network::NetworkInterface interface) override; +#endif + #ifdef USE_MDNS_EVENT_DRIVEN_POLLING void on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, const network::IPAddress &dns2) override; @@ -160,8 +169,8 @@ class MDNSComponent final : public Component static constexpr uint32_t NETWORK_TXT_CHECK_INTERVAL_MS = 1000; /// The first connected interface in network: priority order ("ethernet" or "wifi"), else nullptr. const char *connected_network_() const; - /// Check the connected interface and publish a changed "network" TXT value. - void update_network_txt_(); + /// Publish network as the "network" TXT value if it changed; nullptr keeps the current value. + void update_network_txt_(const char *network); /// Platform-specific: replace the "network" TXT value of the registered _esphomelib service. bool publish_network_txt_(const char *network); /// Advertised value; nullptr until the _esphomelib service is registered, and after a failed publish. diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index afa30eb5f30..d84a2551202 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -103,8 +103,9 @@ bool MDNSComponent::publish_network_txt_(const char *network) { void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp32); -#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT - this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, [this]() { this->update_network_txt_(); }); +#if defined(USE_MDNS_DYNAMIC_NETWORK_TXT) && !defined(USE_MDNS_ACTIVE_INTERFACE_LISTENER) + this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, + [this]() { this->update_network_txt_(this->connected_network_()); }); #endif } diff --git a/esphome/components/mdns/mdns_rp2.cpp b/esphome/components/mdns/mdns_rp2.cpp index 603f3f0417c..f27b6b49812 100644 --- a/esphome/components/mdns/mdns_rp2.cpp +++ b/esphome/components/mdns/mdns_rp2.cpp @@ -64,7 +64,8 @@ bool MDNSComponent::publish_network_txt_(const char *network) { void MDNSComponent::setup() { #ifdef USE_MDNS_DYNAMIC_NETWORK_TXT // No-op until the first IP-up event registers the services - this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, [this]() { this->update_network_txt_(); }); + this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, + [this]() { this->update_network_txt_(this->connected_network_()); }); #endif // arduino-pico stubs out LwipIntf::stateUpCB (the netif status callback LEAmDNS uses // on ESP8266 for auto-restart), so we must drive begin()/notifyAPChange() from our diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 1c08202d411..89835128fe2 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -100,6 +100,37 @@ if ( network_ns = cg.esphome_ns.namespace("network") NetworkComponent = network_ns.class_("NetworkComponent", cg.Component) + +_request_active_interface_listener_slot = cg.slot_counter( + "NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT" +) + + +def has_active_interface() -> bool: + """Return True when NetworkComponent tracks the interface holding the default route. + + That is ESP32 with more than one interface in 'network: priority:', the same + condition that emits USE_NETWORK_DEFAULT_ROUTE. + """ + return CORE.is_esp32 and len(CORE.config.get(DOMAIN, {}).get(CONF_PRIORITY, [])) > 1 + + +async def register_active_interface_listener(listener: cg.MockObj) -> bool: + """Register an ActiveInterfaceListener with the NetworkComponent. + + The listener is notified from the main loop when the interface holding the + default route changes. Returns False, and registers nothing, when + has_active_interface() is False; the caller then needs another way to + follow interface changes. + """ + if not has_active_interface(): + return False + _request_active_interface_listener_slot() + network = await cg.get_variable(CORE.config[DOMAIN][CONF_ID]) + cg.add(network.add_active_interface_listener(listener)) + return True + + IPAddress = network_ns.class_("IPAddress") @@ -402,7 +433,7 @@ async def to_code(config: ConfigType) -> None: # frameworks build from source. # The ethernet/wifi-only assumption behind the arbitration is enforced in # _final_validate() so a future unsupported type fails as a config error. - if len(priority_list) > 1 and CORE.is_esp32: + if has_active_interface(): cg.add_define("USE_NETWORK_DEFAULT_ROUTE") # Have lwIP switch to the DNS servers of the netif that owns the # default route whenever the arbitration changes it. diff --git a/esphome/components/network/network_component.cpp b/esphome/components/network/network_component.cpp index fad7a385fdd..ec6959c2d00 100644 --- a/esphome/components/network/network_component.cpp +++ b/esphome/components/network/network_component.cpp @@ -78,16 +78,24 @@ void NetworkComponent::loop() { // USE_NETWORK_PRIMARY_INTERFACE_WIFI is emitted for a wifi-first priority list; // it selects the reported address in util.cpp and doubles as the route-order // pivot here — the two uses must stay in sync. - esp_netif_t *best; + esp_netif_t *wifi = connected_wifi_netif(); + esp_netif_t *ethernet = connected_ethernet_netif(); #ifdef USE_NETWORK_PRIMARY_INTERFACE_WIFI - best = connected_wifi_netif(); - if (best == nullptr) - best = connected_ethernet_netif(); + esp_netif_t *best = wifi != nullptr ? wifi : ethernet; #else - best = connected_ethernet_netif(); - if (best == nullptr) - best = connected_wifi_netif(); + esp_netif_t *best = ethernet != nullptr ? ethernet : wifi; #endif + NetworkInterface active = NetworkInterface::NETWORK_INTERFACE_NONE; + if (best != nullptr) { + active = best == ethernet ? NetworkInterface::NETWORK_INTERFACE_ETHERNET : NetworkInterface::NETWORK_INTERFACE_WIFI; + } + if (active != this->active_interface_) { + this->active_interface_ = active; +#ifdef NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT + for (auto *listener : this->active_interface_listeners_) + listener->on_active_interface(active); +#endif + } if (best == nullptr) { // Forget the last winner: stopping its netif cleared lwIP's default route and // IDF's manual override suppresses re-election, so reconnect must re-assert it. diff --git a/esphome/components/network/network_component.h b/esphome/components/network/network_component.h index 870530974ea..eff08524a6f 100644 --- a/esphome/components/network/network_component.h +++ b/esphome/components/network/network_component.h @@ -4,12 +4,32 @@ #include "esphome/core/component.h" #ifdef USE_NETWORK_DEFAULT_ROUTE +#include "esphome/core/helpers.h" // Forward declaration matching esp_netif's own typedef; avoids pulling esp_netif.h // into this header. using esp_netif_t = struct esp_netif_obj; #endif namespace esphome::network { + +#ifdef USE_NETWORK_DEFAULT_ROUTE +/// The interface holding the default route. Lists only the interfaces loop() arbitrates +/// (ARBITRATED_NETWORK_TYPES in __init__.py); extend both together. +enum class NetworkInterface : uint8_t { + NETWORK_INTERFACE_NONE, + NETWORK_INTERFACE_ETHERNET, + NETWORK_INTERFACE_WIFI, +}; + +#ifdef NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT +/// Notified from the main loop when the interface holding the default route changes. +class ActiveInterfaceListener { + public: + virtual void on_active_interface(NetworkInterface interface) = 0; +}; +#endif +#endif + class NetworkComponent final : public Component { public: void setup() override; @@ -20,6 +40,14 @@ class NetworkComponent final : public Component { #ifdef USE_NETWORK_DEFAULT_ROUTE void loop() override; + /// The first connected interface in network: priority order; NONE while none is connected. + NetworkInterface get_active_interface() const { return this->active_interface_; } +#ifdef NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT + void add_active_interface_listener(ActiveInterfaceListener *listener) { + this->active_interface_listeners_.push_back(listener); + } +#endif + protected: // Verify-lwIP-route interval for the settled state; keeps the global lwIP core // mutex off the hot loop path. @@ -27,6 +55,10 @@ class NetworkComponent final : public Component { // Last netif this component made the default; avoids redundant esp_netif calls. esp_netif_t *default_netif_{nullptr}; uint32_t last_route_check_{0}; +#ifdef NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT + StaticVector active_interface_listeners_; +#endif + NetworkInterface active_interface_{NetworkInterface::NETWORK_INTERFACE_NONE}; #endif }; } // namespace esphome::network diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 8d243043bde..1fa417d7afd 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -217,6 +217,7 @@ #define TCA8418_LISTENER_COUNT 1 #define USE_MEDIA_PLAYER #define USE_MEDIA_SOURCE +#define NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT 1 #define USE_NETWORK #define USE_NETWORK_DEFAULT_ROUTE #define USE_NETWORK_PRIMARY_INTERFACE_WIFI @@ -567,6 +568,10 @@ #endif #ifndef USE_OPENTHREAD #define USE_MDNS_SUPPORTS_ENABLE_DISABLE +#ifdef USE_ETHERNET +#define USE_MDNS_ACTIVE_INTERFACE_LISTENER +#define USE_MDNS_DYNAMIC_NETWORK_TXT +#endif #endif #endif @@ -647,6 +652,7 @@ #define USE_ETHERNET_SPI #endif #define USE_ETHERNET_W5500 +#define USE_MDNS_DYNAMIC_NETWORK_TXT #define USE_WIFI_IP_STATE_LISTENERS #define ESPHOME_WIFI_IP_STATE_LISTENERS 2 #define USE_ETHERNET_IP_STATE_LISTENERS diff --git a/tests/component_tests/network/config/priority_ethernet_first_api.yaml b/tests/component_tests/network/config/priority_ethernet_first_api.yaml new file mode 100644 index 00000000000..31ff65b5bc2 --- /dev/null +++ b/tests/component_tests/network/config/priority_ethernet_first_api.yaml @@ -0,0 +1,28 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +ethernet: + type: W5500 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + clock_speed: 10Mhz + +network: + priority: + - ethernet + - wifi + +api: diff --git a/tests/component_tests/network/config/priority_rp2040_api.yaml b/tests/component_tests/network/config/priority_rp2040_api.yaml new file mode 100644 index 00000000000..a74442032da --- /dev/null +++ b/tests/component_tests/network/config/priority_rp2040_api.yaml @@ -0,0 +1,26 @@ +esphome: + name: test + +rp2: + board: rpipicow + +wifi: + ssid: "test_ssid" + password: "test_password" + +ethernet: + type: W5500 + clk_pin: 18 + mosi_pin: 19 + miso_pin: 16 + cs_pin: 17 + interrupt_pin: 21 + reset_pin: 20 + mac_address: "02:AA:BB:CC:DD:01" + +network: + priority: + - ethernet + - wifi + +api: diff --git a/tests/component_tests/network/test_active_interface.py b/tests/component_tests/network/test_active_interface.py new file mode 100644 index 00000000000..2509f24ffb6 --- /dev/null +++ b/tests/component_tests/network/test_active_interface.py @@ -0,0 +1,52 @@ +"""mDNS follows the interface holding the default route through an +ActiveInterfaceListener, only where NetworkComponent tracks that interface +(ESP32 with more than one interface) and the network TXT record exists.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.core import CORE + +DYNAMIC_TXT_DEFINE = "USE_MDNS_DYNAMIC_NETWORK_TXT" +LISTENER_DEFINES = { + "USE_MDNS_ACTIVE_INTERFACE_LISTENER", + "NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT", +} + + +def _defines() -> set[str]: + return {define.name for define in CORE.defines} + + +def test_mdns_registers_on_esp32_with_wifi_ethernet_and_api( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("priority_ethernet_first_api.yaml")) + + assert "add_active_interface_listener(" in main_cpp + assert _defines() >= LISTENER_DEFINES | {DYNAMIC_TXT_DEFINE} + + +@pytest.mark.parametrize( + ("fixture", "dynamic_txt"), + [ + # No api means no _esphomelib service, so no network TXT record + pytest.param("priority_ethernet_first.yaml", False, id="esp32_without_api"), + # No route arbitration on RP2; mDNS keeps its periodic check there + pytest.param("priority_rp2040_api.yaml", True, id="rp2040"), + ], +) +def test_mdns_does_not_register( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + fixture: str, + dynamic_txt: bool, +) -> None: + main_cpp = generate_main(component_config_path(fixture)) + + assert "add_active_interface_listener(" not in main_cpp + assert not LISTENER_DEFINES & _defines() + assert (DYNAMIC_TXT_DEFINE in _defines()) is dynamic_txt From f45e6f0bf47a818b8bcb4afff47e2a8a505306b4 Mon Sep 17 00:00:00 2001 From: Andres Signes Grimalt Date: Wed, 14 Oct 2026 04:03:18 +0200 Subject: [PATCH 070/142] [midea] Add display_control and send the display toggle over UART without a transmitter (#18671) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/midea/air_conditioner.cpp | 34 +++++++++++++++---- esphome/components/midea/air_conditioner.h | 9 +++++ esphome/components/midea/climate.py | 24 +++++++++++++ tests/components/midea/common-uart_only.yaml | 6 ++++ tests/components/midea/common.yaml | 1 + .../midea/test-uart_only.esp32-idf.yaml | 8 +++++ 6 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 tests/components/midea/common-uart_only.yaml create mode 100644 tests/components/midea/test-uart_only.esp32-idf.yaml diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index 24bbfe76b0b..fce8ab99a9e 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -127,6 +127,19 @@ ClimateTraits AirConditioner::traits() { return traits; } +#ifdef USE_REMOTE_TRANSMITTER +static const LogString *display_control_str(DisplayControl control) { + switch (control) { + case DisplayControl::DISPLAY_CONTROL_UART: + return LOG_STR("UART"); + case DisplayControl::DISPLAY_CONTROL_IR: + return LOG_STR("IR"); + default: + return LOG_STR("AUTO"); + } +} +#endif + void AirConditioner::dump_config() { ESP_LOGCONFIG(Constants::TAG, "MideaDongle:\n" @@ -135,7 +148,10 @@ void AirConditioner::dump_config() { " [x] Request attempts: %d", this->base_.getPeriod(), this->base_.getTimeout(), this->base_.getNumAttempts()); #ifdef USE_REMOTE_TRANSMITTER - ESP_LOGCONFIG(Constants::TAG, " [x] Using RemoteTransmitter"); + ESP_LOGCONFIG(Constants::TAG, + " [x] Using RemoteTransmitter\n" + " [x] Display control: %s", + LOG_STR_ARG(display_control_str(this->display_control_))); #endif if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK) { this->base_.getCapabilities().dump(); @@ -183,16 +199,20 @@ void AirConditioner::do_swing_step() { } void AirConditioner::do_display_toggle() { - if (this->base_.getCapabilities().supportLightControl()) { - this->base_.displayToggle(); - } else { #ifdef USE_REMOTE_TRANSMITTER + // AUTO uses IR unless the appliance reports light control over UART. Without a transmitter + // built in, every setting sends the UART request: Mundoclima, Rotenso and others accept it + // without advertising it in the 0xB5 capabilities report. + const bool use_ir = this->display_control_ == DisplayControl::DISPLAY_CONTROL_IR || + (this->display_control_ == DisplayControl::DISPLAY_CONTROL_AUTO && + !this->base_.getCapabilities().supportLightControl()); + if (use_ir) { IrSpecialData data(0x08); this->transmitter_.transmit(data); -#else - ESP_LOGW(Constants::TAG, "Action needs remote_transmitter component"); -#endif + return; } +#endif + this->base_.displayToggle(); } } // namespace esphome::midea::ac diff --git a/esphome/components/midea/air_conditioner.h b/esphome/components/midea/air_conditioner.h index 9977d2088ff..607027d9962 100644 --- a/esphome/components/midea/air_conditioner.h +++ b/esphome/components/midea/air_conditioner.h @@ -21,12 +21,20 @@ using climate::ClimateModeMask; using climate::ClimateSwingModeMask; using climate::ClimatePresetMask; +// How the display toggle reaches the appliance; the AUTO rules are in do_display_toggle() +enum class DisplayControl : uint8_t { + DISPLAY_CONTROL_UART, // always the UART request + DISPLAY_CONTROL_AUTO, // UART when the appliance reports it or no transmitter is built in, else IR + DISPLAY_CONTROL_IR, // always the IR remote, needs a transmitter +}; + class AirConditioner final : public ApplianceBase, public climate::Climate { public: void dump_config() override; void set_outdoor_temperature_sensor(Sensor *sensor) { this->outdoor_sensor_ = sensor; } void set_humidity_setpoint_sensor(Sensor *sensor) { this->humidity_sensor_ = sensor; } void set_power_sensor(Sensor *sensor) { this->power_sensor_ = sensor; } + void set_display_control(DisplayControl control) { this->display_control_ = control; } void on_status_change() override; /* ############### */ @@ -54,6 +62,7 @@ class AirConditioner final : public ApplianceBase ConfigType: + """`display_control: IR` needs a `remote_transmitter` to send through; UART and AUTO do not.""" + if config.get(CONF_DISPLAY_CONTROL) == "IR" and CONF_TRANSMITTER_ID not in config: + raise cv.Invalid( + f"'{CONF_DISPLAY_CONTROL}: IR' requires '{CONF_TRANSMITTER_ID}' to be set" + ) + return config + CONFIG_SCHEMA = cv.All( climate.climate_schema(AirConditioner) @@ -100,6 +121,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_BEEPER, default=False): cv.boolean, cv.Optional(CONF_AUTOCONF, default=True): cv.boolean, + cv.Optional(CONF_DISPLAY_CONTROL, default="AUTO"): validate_display_control, cv.Optional(CONF_SUPPORTED_MODES): cv.ensure_list(validate_modes), cv.Optional(CONF_SUPPORTED_SWING_MODES): cv.ensure_list( validate_swing_modes @@ -134,6 +156,7 @@ CONFIG_SCHEMA = cv.All( ) .extend(uart.UART_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA), + validate_display_control_with_transmitter, cv.only_on( [ PLATFORM_ESP32, @@ -204,6 +227,7 @@ async def to_code(config): cg.add(var.set_transmitter(transmitter_)) cg.add(var.set_beeper_feedback(config[CONF_BEEPER])) cg.add(var.set_autoconf(config[CONF_AUTOCONF])) + cg.add(var.set_display_control(config[CONF_DISPLAY_CONTROL])) if CONF_SUPPORTED_MODES in config: cg.add(var.set_supported_modes(config[CONF_SUPPORTED_MODES])) if CONF_SUPPORTED_SWING_MODES in config: diff --git a/tests/components/midea/common-uart_only.yaml b/tests/components/midea/common-uart_only.yaml new file mode 100644 index 00000000000..d05df221732 --- /dev/null +++ b/tests/components/midea/common-uart_only.yaml @@ -0,0 +1,6 @@ +climate: + - platform: midea + id: midea_uart_only + name: Midea Climate UART Only + autoconf: true + display_control: UART diff --git a/tests/components/midea/common.yaml b/tests/components/midea/common.yaml index 25fc2debcd2..bade07be43f 100644 --- a/tests/components/midea/common.yaml +++ b/tests/components/midea/common.yaml @@ -33,6 +33,7 @@ climate: timeout: 2s beeper: false autoconf: true + display_control: AUTO visual: min_temperature: 17 °C max_temperature: 30 °C diff --git a/tests/components/midea/test-uart_only.esp32-idf.yaml b/tests/components/midea/test-uart_only.esp32-idf.yaml new file mode 100644 index 00000000000..ce09bbb7b7d --- /dev/null +++ b/tests/components/midea/test-uart_only.esp32-idf.yaml @@ -0,0 +1,8 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + midea: !include common-uart_only.yaml + +# The MideaUART library includes esp_wifi.h on ESP-IDF +wifi: + ssid: MySSID + password: password1 From 7b591b26d692b5b06e0f8927123831dacd2bed2e Mon Sep 17 00:00:00 2001 From: willumpie82 Date: Wed, 14 Oct 2026 04:19:58 +0200 Subject: [PATCH 071/142] [sy6970] Service the I2C watchdog so charge/LED settings persist (#19424) Co-authored-by: Willem Oldemans <> Co-authored-by: Claude Sonnet 5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/sy6970/__init__.py | 14 ++++++ esphome/components/sy6970/sy6970.cpp | 70 ++++++++++++++++++++++++++- esphome/components/sy6970/sy6970.h | 20 +++++++- tests/components/sy6970/common.yaml | 1 + 4 files changed, 101 insertions(+), 4 deletions(-) diff --git a/esphome/components/sy6970/__init__.py b/esphome/components/sy6970/__init__.py index 90bd9f46911..c1c770aa591 100644 --- a/esphome/components/sy6970/__init__.py +++ b/esphome/components/sy6970/__init__.py @@ -17,12 +17,22 @@ CONF_CHARGE_CURRENT = "charge_current" CONF_PRECHARGE_CURRENT = "precharge_current" CONF_CHARGE_ENABLED = "charge_enabled" CONF_ENABLE_ADC = "enable_adc" +CONF_I2C_WATCHDOG_TIMEOUT = "i2c_watchdog_timeout" sy6970_ns = cg.esphome_ns.namespace("sy6970") SY6970Component = sy6970_ns.class_( "SY6970Component", cg.PollingComponent, i2c.I2CDevice ) SY6970Listener = sy6970_ns.class_("SY6970Listener") +I2CWatchdogTimeout = sy6970_ns.enum("I2CWatchdogTimeout") + +# REG07[5:4]; the component kicks the watchdog itself, so the chip's 40S default can stay on +I2C_WATCHDOG_TIMEOUTS = { + "DISABLED": I2CWatchdogTimeout.I2C_WATCHDOG_DISABLED, + "40S": I2CWatchdogTimeout.I2C_WATCHDOG_40S, + "80S": I2CWatchdogTimeout.I2C_WATCHDOG_80S, + "160S": I2CWatchdogTimeout.I2C_WATCHDOG_160S, +} CONFIG_SCHEMA = ( cv.Schema( @@ -43,6 +53,9 @@ CONFIG_SCHEMA = ( ), cv.Optional(CONF_CHARGE_ENABLED, default=True): cv.boolean, cv.Optional(CONF_ENABLE_ADC, default=True): cv.boolean, + cv.Optional(CONF_I2C_WATCHDOG_TIMEOUT, default="40S"): cv.enum( + I2C_WATCHDOG_TIMEOUTS, upper=True + ), } ) .extend(cv.polling_component_schema("5s")) @@ -60,6 +73,7 @@ async def to_code(config: ConfigType) -> None: config[CONF_PRECHARGE_CURRENT], config[CONF_CHARGE_ENABLED], config[CONF_ENABLE_ADC], + config[CONF_I2C_WATCHDOG_TIMEOUT], ) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/sy6970/sy6970.cpp b/esphome/components/sy6970/sy6970.cpp index 3f381002cbd..5e1611ea558 100644 --- a/esphome/components/sy6970/sy6970.cpp +++ b/esphome/components/sy6970/sy6970.cpp @@ -6,6 +6,25 @@ namespace esphome::sy6970 { ESPHOME_LOG_TAG(TAG, "sy6970"); +static const LogString *i2c_watchdog_timeout_to_string(I2CWatchdogTimeout timeout) { + switch (timeout) { + case I2C_WATCHDOG_DISABLED: + return LOG_STR("Disabled"); + case I2C_WATCHDOG_40S: + return LOG_STR("40s"); + case I2C_WATCHDOG_80S: + return LOG_STR("80s"); + case I2C_WATCHDOG_160S: + return LOG_STR("160s"); + default: + return LOG_STR("Unknown"); + } +} + +static constexpr uint32_t I2C_WATCHDOG_KICK_INTERVAL_ID = 0; +// Well under the 40 s default, so a failed kick or a timeout the chip did not accept still leaves margin +static constexpr uint32_t I2C_WATCHDOG_KICK_INTERVAL_MS = 10000; + bool SY6970Component::read_all_registers_() { // Read all registers from 0x00 to 0x14 in one transaction (21 bytes) // This includes unused registers 0x0F, 0x10 for performance @@ -51,6 +70,11 @@ void SY6970Component::setup() { ESP_LOGW(TAG, "Unexpected chip ID: 0x%02X (expected 0x00)", chip_id); } + // Service the watchdog first, so the settings below are not reverted while they are written + ESP_LOGV(TAG, "Setting I2C watchdog timeout to %s", + LOG_STR_ARG(i2c_watchdog_timeout_to_string(this->i2c_watchdog_timeout_))); + this->set_i2c_watchdog_timeout(this->i2c_watchdog_timeout_); + // Apply configuration options (all have defaults now) ESP_LOGV(TAG, "Setting LED enabled to %s", ONOFF(this->led_enabled_)); this->set_led_enabled(this->led_enabled_); @@ -85,9 +109,11 @@ void SY6970Component::dump_config() { " Charge Current: %u mA\n" " Precharge Current: %u mA\n" " Charge Enabled: %s\n" - " ADC Enabled: %s", + " ADC Enabled: %s\n" + " I2C Watchdog Timeout: %s", ONOFF(this->led_enabled_), this->input_current_limit_, this->charge_voltage_, this->charge_current_, - this->precharge_current_, ONOFF(this->charge_enabled_), ONOFF(this->enable_adc_)); + this->precharge_current_, ONOFF(this->charge_enabled_), ONOFF(this->enable_adc_), + LOG_STR_ARG(i2c_watchdog_timeout_to_string(this->i2c_watchdog_timeout_))); LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); if (this->is_failed()) { @@ -198,4 +224,44 @@ void SY6970Component::set_enable_adc_measure(bool enabled) { this->update_register_(SY6970_REG_ADC_CONTROL, 0xC0, enabled ? 0xC0 : 0x00); } +void SY6970Component::set_i2c_watchdog_timeout(I2CWatchdogTimeout timeout) { + if (this->is_failed()) + return; + + if (timeout != I2C_WATCHDOG_DISABLED) { + // Changing the timeout does not restart the chip's timer, which may be close to expiring + this->reset_i2c_watchdog(); + } + + // REG07 bits 5:4 (WATCHDOG[1:0]) + bool written = this->update_register_(SY6970_REG_TIMER_CONTROL, 0x30, static_cast(timeout) << 4); + if (!written) { + ESP_LOGW(TAG, "Failed to set I2C watchdog timeout to %s", LOG_STR_ARG(i2c_watchdog_timeout_to_string(timeout))); + } + + if (timeout == I2C_WATCHDOG_DISABLED && written) { + this->i2c_watchdog_timeout_ = timeout; + this->cancel_interval(I2C_WATCHDOG_KICK_INTERVAL_ID); + return; + } + if (timeout != I2C_WATCHDOG_DISABLED) { + this->i2c_watchdog_timeout_ = timeout; + } + // Keeps kicking after a failed write too: the chip may still be on its 40 s default + this->set_interval(I2C_WATCHDOG_KICK_INTERVAL_ID, I2C_WATCHDOG_KICK_INTERVAL_MS, + [this]() { this->reset_i2c_watchdog(); }); +} + +bool SY6970Component::reset_i2c_watchdog() { + if (this->is_failed()) + return false; + + // WD_RST, self-clearing + if (!this->update_register_(SY6970_REG_SYS_CONTROL, 0x40, 0x40)) { + ESP_LOGW(TAG, "I2C watchdog reset failed, settings may revert"); + return false; + } + return true; +} + } // namespace esphome::sy6970 diff --git a/esphome/components/sy6970/sy6970.h b/esphome/components/sy6970/sy6970.h index 06f0615ab4b..b9c4f1d4870 100644 --- a/esphome/components/sy6970/sy6970.h +++ b/esphome/components/sy6970/sy6970.h @@ -62,6 +62,15 @@ enum ChargeStatus { CHARGE_STATUS_CHARGE_DONE = 3, }; +// REG07[5:4]. When the watchdog elapses the chip reverts CHG_CONFIG and STAT_DIS to their defaults, +// so an enabled watchdog is kicked on its own interval. +enum I2CWatchdogTimeout { + I2C_WATCHDOG_DISABLED = 0, + I2C_WATCHDOG_40S = 1, + I2C_WATCHDOG_80S = 2, + I2C_WATCHDOG_160S = 3, +}; + // Structure to hold all register data read in one transaction struct SY6970Data { uint8_t registers[21]; // Registers 0x00-0x14 (includes unused 0x0F, 0x10) @@ -76,14 +85,16 @@ class SY6970Listener { class SY6970Component final : public PollingComponent, public i2c::I2CDevice { public: SY6970Component(bool led_enabled, uint16_t input_current_limit, uint16_t charge_voltage, uint16_t charge_current, - uint16_t precharge_current, bool charge_enabled, bool enable_adc) + uint16_t precharge_current, bool charge_enabled, bool enable_adc, + I2CWatchdogTimeout i2c_watchdog_timeout) : led_enabled_(led_enabled), input_current_limit_(input_current_limit), charge_voltage_(charge_voltage), charge_current_(charge_current), precharge_current_(precharge_current), charge_enabled_(charge_enabled), - enable_adc_(enable_adc) {} + enable_adc_(enable_adc), + i2c_watchdog_timeout_(i2c_watchdog_timeout) {} void setup() override; void dump_config() override; void update() override; @@ -99,6 +110,10 @@ class SY6970Component final : public PollingComponent, public i2c::I2CDevice { void set_charge_enabled(bool enabled); void set_led_enabled(bool enabled); void set_enable_adc_measure(bool enabled = true); + void set_i2c_watchdog_timeout(I2CWatchdogTimeout timeout); + + /// Kick the I2C watchdog (REG03 bit 6); runs on its own interval while enabled. Returns false when not written. + bool reset_i2c_watchdog(); protected: bool read_all_registers_(); @@ -116,6 +131,7 @@ class SY6970Component final : public PollingComponent, public i2c::I2CDevice { uint16_t precharge_current_; bool charge_enabled_; bool enable_adc_; + I2CWatchdogTimeout i2c_watchdog_timeout_; }; } // namespace esphome::sy6970 diff --git a/tests/components/sy6970/common.yaml b/tests/components/sy6970/common.yaml index 53699fe6fb8..74f57f28210 100644 --- a/tests/components/sy6970/common.yaml +++ b/tests/components/sy6970/common.yaml @@ -9,6 +9,7 @@ sy6970: precharge_current: 128 charge_enabled: true enable_adc: true + i2c_watchdog_timeout: 80S update_interval: 5s sensor: From bb1119d83f5ba0bb4b932aed88a3cf824a812d95 Mon Sep 17 00:00:00 2001 From: Christopher Reimer Date: Wed, 14 Oct 2026 04:34:03 +0200 Subject: [PATCH 072/142] [esp32_can] Support 20 kbit/s on ESP32 revision 2 or later (#20426) --- esphome/components/esp32_can/canbus.py | 22 ++++++++ esphome/components/esp32_can/esp32_can.cpp | 4 ++ .../esp32_can/test-20kbps.esp32-idf.yaml | 11 ++++ tests/unit_tests/components/test_esp32_can.py | 54 +++++++++++++++++++ 4 files changed, 91 insertions(+) create mode 100644 tests/components/esp32_can/test-20kbps.esp32-idf.yaml create mode 100644 tests/unit_tests/components/test_esp32_can.py diff --git a/esphome/components/esp32_can/canbus.py b/esphome/components/esp32_can/canbus.py index 2713bae241a..ae299370cd7 100644 --- a/esphome/components/esp32_can/canbus.py +++ b/esphome/components/esp32_can/canbus.py @@ -6,6 +6,8 @@ import esphome.codegen as cg from esphome.components import canbus from esphome.components.canbus import CONF_BIT_RATE, CanbusComponent, CanSpeed from esphome.components.esp32 import ( + CONF_MINIMUM_CHIP_REVISION, + KEY_ESP32, VARIANT_ESP32, VARIANT_ESP32C3, VARIANT_ESP32C5, @@ -20,6 +22,8 @@ from esphome.components.esp32 import ( ) import esphome.config_validation as cv from esphome.const import ( + CONF_ADVANCED, + CONF_FRAMEWORK, CONF_ID, CONF_MODE, CONF_RX_PIN, @@ -27,6 +31,7 @@ from esphome.const import ( CONF_TX_PIN, CONF_TX_QUEUE_LEN, ) +import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@Sympatron"] @@ -49,6 +54,7 @@ CAN_MODES = { # See ESP-IDF Programming Guide --> API Reference --> Two-Wire Automotive Interface (TWAI) CAN_SPEEDS_ESP32 = { + "20KBPS": CanSpeed.CAN_20KBPS, "25KBPS": CanSpeed.CAN_25KBPS, "50KBPS": CanSpeed.CAN_50KBPS, "100KBPS": CanSpeed.CAN_100KBPS, @@ -114,6 +120,22 @@ CONFIG_SCHEMA = canbus.CANBUS_SCHEMA.extend( ) +def _final_validate(config: ConfigType) -> None: + if get_esp32_variant() == VARIANT_ESP32 and config[CONF_BIT_RATE] == "20KBPS": + esp32_config = fv.full_config.get()[KEY_ESP32] + min_revision = esp32_config[CONF_FRAMEWORK][CONF_ADVANCED].get( + CONF_MINIMUM_CHIP_REVISION + ) + if min_revision is None or min_revision < "2.0": + raise cv.Invalid( + "20KBPS on ESP32 requires esp32.framework.advanced.minimum_chip_revision: '2.0' or higher", + path=[CONF_BIT_RATE], + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + def get_default_tx_enqueue_timeout(bit_rate: str) -> int: bit_rate_numeric = canbus.get_rate(bit_rate) bits_per_packet = 140 # ~max CAN message length diff --git a/esphome/components/esp32_can/esp32_can.cpp b/esphome/components/esp32_can/esp32_can.cpp index b98ba853e78..d84b58c5b0c 100644 --- a/esphome/components/esp32_can/esp32_can.cpp +++ b/esphome/components/esp32_can/esp32_can.cpp @@ -33,6 +33,10 @@ static bool get_bitrate(canbus::CanSpeed bitrate, twai_timing_config_t *t_config case canbus::CAN_16KBPS: *t_config = (twai_timing_config_t) TWAI_TIMING_CONFIG_16KBITS(); return true; +#endif +#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || \ + defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || \ + defined(USE_ESP32_VARIANT_ESP32S3) || (defined(USE_ESP32_VARIANT_ESP32) && CONFIG_ESP32_REV_MIN_FULL >= 200) case canbus::CAN_20KBPS: *t_config = (twai_timing_config_t) TWAI_TIMING_CONFIG_20KBITS(); return true; diff --git a/tests/components/esp32_can/test-20kbps.esp32-idf.yaml b/tests/components/esp32_can/test-20kbps.esp32-idf.yaml new file mode 100644 index 00000000000..fb60cb7363f --- /dev/null +++ b/tests/components/esp32_can/test-20kbps.esp32-idf.yaml @@ -0,0 +1,11 @@ +esp32: + framework: + advanced: + minimum_chip_revision: "2.0" + +canbus: + - platform: esp32_can + rx_pin: GPIO14 + tx_pin: GPIO12 + can_id: 4 + bit_rate: 20kbps diff --git a/tests/unit_tests/components/test_esp32_can.py b/tests/unit_tests/components/test_esp32_can.py new file mode 100644 index 00000000000..5b70ce1d812 --- /dev/null +++ b/tests/unit_tests/components/test_esp32_can.py @@ -0,0 +1,54 @@ +"""Tests for ESP32 CAN bitrate validation.""" + +import pytest + +from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT +from esphome.components.esp32_can import canbus +import esphome.config_validation as cv +from esphome.core import CORE +import esphome.final_validate as fv + + +@pytest.mark.parametrize( + ("revision", "supported"), + [ + (None, False), + ("0.0", False), + ("1.0", False), + ("1.1", False), + ("2.0", True), + ("3.0", True), + ("3.1", True), + ], +) +def test_esp32_20kbps_minimum_revision(revision: str | None, supported: bool) -> None: + """Require the chip revision needed by the ESP-IDF timing macro.""" + CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32"} + advanced = {} if revision is None else {"minimum_chip_revision": revision} + token = fv.full_config.set({"esp32": {"framework": {"advanced": advanced}}}) + try: + config = {"bit_rate": canbus.validate_bit_rate("20kbps")} + if supported: + canbus.FINAL_VALIDATE_SCHEMA(config) + else: + with pytest.raises(cv.Invalid, match="minimum_chip_revision") as error: + canbus.FINAL_VALIDATE_SCHEMA(config) + assert error.value.path == ["bit_rate"] + finally: + fv.full_config.reset(token) + + +@pytest.mark.parametrize("variant", ["ESP32", "ESP32C3", "ESP32S3"]) +def test_other_bitrates(variant: str) -> None: + """Existing bitrates must not require a minimum chip revision.""" + CORE.data[KEY_ESP32] = {KEY_VARIANT: variant} + config = {"bit_rate": canbus.validate_bit_rate("25kbps")} + canbus.FINAL_VALIDATE_SCHEMA(config) + + +@pytest.mark.parametrize("variant", ["ESP32C3", "ESP32S2", "ESP32S3", "ESP32C6"]) +def test_other_variants_20kbps(variant: str) -> None: + """Other supported ESP32 variants must retain 20 kbit/s support.""" + CORE.data[KEY_ESP32] = {KEY_VARIANT: variant} + config = {"bit_rate": canbus.validate_bit_rate("20kbps")} + canbus.FINAL_VALIDATE_SCHEMA(config) From 07029d00775a76427d0ff70011025750eef93fe6 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:47:31 -0500 Subject: [PATCH 073/142] [sen6x] Add measurement control and maintenance actions (#18783) Co-authored-by: J. Nick Koston --- esphome/components/sen6x/sen6x.cpp | 111 ++++++++++++++++++++++++++++- esphome/components/sen6x/sen6x.h | 13 ++++ esphome/components/sen6x/sensor.py | 20 ++++++ tests/components/sen6x/common.yaml | 14 ++++ 4 files changed, 156 insertions(+), 2 deletions(-) diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index 91762e0f079..5087b112a8d 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -14,6 +14,15 @@ static constexpr uint32_t POLL_INTERVAL = 50; // 50 ms between poll attempts // Numeric timeout IDs. Each chain is sequential, so only one timeout per ID is active at a time. static constexpr uint32_t TIMEOUT_POLL = 1; static constexpr uint32_t TIMEOUT_SETUP_STEP = 2; +static constexpr uint32_t TIMEOUT_STARTUP = 3; +// Post-command wait times from the datasheet: start execution (4.8.1), stop execution (4.8.2), +// fan cleaning (4.8.22), SHT heater (4.8.23), CO2 conditioning after start on SEN63C/SEN69C (4.8.1) +static constexpr uint32_t START_MEASUREMENT_DELAY = 50; +static constexpr uint32_t STOP_MEASUREMENT_DELAY = 1400; +static constexpr uint32_t FAN_CLEANING_DELAY = 10000; +static constexpr uint32_t SHT_HEATER_DELAY = 20000; +static constexpr uint32_t CO2_CONDITIONING_DELAY = 24000; +static constexpr uint32_t STARTUP_DELAY = 60000; // ms before published values are considered valid static constexpr uint16_t SEN6X_CMD_GET_DATA_READY_STATUS = 0x0202; static constexpr uint16_t SEN6X_CMD_GET_FIRMWARE_VERSION = 0xD100; static constexpr uint16_t SEN6X_CMD_GET_PRODUCT_NAME = 0xD014; @@ -27,6 +36,9 @@ static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN68 = 0x0467; static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN69C = 0x04B5; static constexpr uint16_t SEN6X_CMD_START_MEASUREMENTS = 0x0021; +static constexpr uint16_t SEN6X_CMD_STOP_MEASUREMENTS = 0x0104; +static constexpr uint16_t SEN6X_CMD_START_FAN_CLEANING = 0x5607; +static constexpr uint16_t SEN6X_CMD_ACTIVATE_SHT_HEATER = 0x6765; static constexpr uint16_t SEN6X_CMD_RESET = 0xD304; static constexpr uint16_t SEN6X_CMD_VOC_ALGORITHM_TUNING = 0x60D0; static constexpr uint16_t SEN6X_CMD_NOX_ALGORITHM_TUNING = 0x60E1; @@ -191,7 +203,7 @@ void SEN6XComponent::finish_setup_() { return; } - this->set_timeout(60000, [this]() { this->startup_complete_ = true; }); + this->on_measurement_started_(); this->initialized_ = true; ESP_LOGD(TAG, "Initialized"); } @@ -240,7 +252,7 @@ void SEN6XComponent::dump_config() { } void SEN6XComponent::update() { - if (!this->initialized_) { + if (!this->initialized_ || !this->measuring_) { return; } @@ -442,6 +454,101 @@ void SEN6XComponent::parse_and_publish_measurements_() { this->status_clear_warning(); } +// True while the previous command's execution or settling time has not elapsed +bool SEN6XComponent::command_blocked_() const { return millis() - this->command_started_at_ < this->command_wait_; } + +void SEN6XComponent::set_command_wait_(uint32_t wait_ms) { + this->command_started_at_ = millis(); + this->command_wait_ = wait_ms; +} + +void SEN6XComponent::on_measurement_started_() { + this->measuring_ = true; + this->set_command_wait_(START_MEASUREMENT_DELAY); + // SEN63C/SEN69C condition the CO2 sensor for 24 s after a start; block restarts until then + if (this->sen6x_type_ == SEN63C || this->sen6x_type_ == SEN69C) { + this->co2_started_at_ = millis(); + this->co2_wait_ = CO2_CONDITIONING_DELAY; + } + // Values need the warm-up period again after every start + this->startup_complete_ = false; + this->set_timeout(TIMEOUT_STARTUP, STARTUP_DELAY, [this]() { this->startup_complete_ = true; }); +} + +void SEN6XComponent::start_measurement() { + if (!this->initialized_ || this->measuring_) + return; + // The CO2 conditioning window only blocks restarting, not other commands + if (this->command_blocked_() || millis() - this->co2_started_at_ < this->co2_wait_) { + ESP_LOGW(TAG, "Device busy"); + return; + } + if (!this->write_command(SEN6X_CMD_START_MEASUREMENTS)) { + this->status_set_warning(); + ESP_LOGW(TAG, "Start measurement failed (%d)", this->last_error_); + return; + } + this->on_measurement_started_(); +} + +void SEN6XComponent::stop_measurement() { + if (!this->initialized_ || !this->measuring_) + return; + if (this->command_blocked_()) { + ESP_LOGW(TAG, "Device busy"); + return; + } + // Stop any in-flight polling before the device goes idle + this->cancel_timeout(TIMEOUT_POLL); + if (!this->write_command(SEN6X_CMD_STOP_MEASUREMENTS)) { + this->status_set_warning(); + ESP_LOGW(TAG, "Stop measurement failed (%d)", this->last_error_); + return; + } + this->measuring_ = false; + this->set_command_wait_(STOP_MEASUREMENT_DELAY); +} + +void SEN6XComponent::start_fan_cleaning() { + if (!this->initialized_) + return; + // The device only accepts this command in idle mode (datasheet section 4.8.22) + if (this->measuring_) { + ESP_LOGW(TAG, "Fan cleaning requires idle mode; stop the measurement first"); + return; + } + if (this->command_blocked_()) { + ESP_LOGW(TAG, "Device busy"); + return; + } + if (!this->write_command(SEN6X_CMD_START_FAN_CLEANING)) { + this->status_set_warning(); + ESP_LOGW(TAG, "Fan cleaning failed (%d)", this->last_error_); + return; + } + this->set_command_wait_(FAN_CLEANING_DELAY); +} + +void SEN6XComponent::activate_sht_heater() { + if (!this->initialized_) + return; + // The device only accepts this command in idle mode (datasheet section 4.8.23) + if (this->measuring_) { + ESP_LOGW(TAG, "SHT heater requires idle mode; stop the measurement first"); + return; + } + if (this->command_blocked_()) { + ESP_LOGW(TAG, "Device busy"); + return; + } + if (!this->write_command(SEN6X_CMD_ACTIVATE_SHT_HEATER)) { + this->status_set_warning(); + ESP_LOGW(TAG, "SHT heater failed (%d)", this->last_error_); + return; + } + this->set_command_wait_(SHT_HEATER_DELAY); +} + SEN6XComponent::Sen6xType SEN6XComponent::infer_type_from_product_name_(const std::string &product_name) { if (product_name == "SEN62") return SEN62; diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index 64ce3371fce..588a466d714 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -58,6 +58,10 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S NOX_STD_INITIAL, gain_factor}; } + void start_measurement(); + void stop_measurement(); + void start_fan_cleaning(); + void activate_sht_heater(); protected: Sen6xType infer_type_from_product_name_(const std::string &product_name); @@ -65,6 +69,9 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S void finish_setup_(); bool write_config_words_(uint16_t i2c_command, const uint16_t *data, uint8_t len); bool write_tuning_parameters_(uint16_t i2c_command, const GasTuning &tuning); + bool command_blocked_() const; + void set_command_wait_(uint32_t wait_ms); + void on_measurement_started_(); void poll_data_ready_(); void read_measurements_(); void parse_and_publish_measurements_(); @@ -74,6 +81,11 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S optional voc_tuning_params_; optional nox_tuning_params_; Sen6xType sen6x_type_{UNKNOWN}; + // Post-command wait windows, stored as start time plus duration so elapsed-time checks survive millis() wrap + uint32_t command_started_at_{0}; + uint32_t command_wait_{0}; + uint32_t co2_started_at_{0}; + uint32_t co2_wait_{0}; uint16_t read_cmd_{0}; uint8_t setup_step_index_{0}; uint8_t firmware_version_major_{0}; @@ -81,6 +93,7 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S uint8_t poll_retries_remaining_{0}; uint8_t read_words_{0}; bool initialized_{false}; + bool measuring_{false}; bool startup_complete_{false}; }; diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index 4c0242f2e3f..a5c46801887 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -1,3 +1,5 @@ +from esphome import automation +from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import i2c, sensirion_common, sensor from esphome.components.const import CONF_NOX_INDEX, CONF_VOC_INDEX @@ -209,3 +211,21 @@ async def to_code(config: ConfigType) -> None: args.append(std_initial) args.append(tuning[CONF_GAIN_FACTOR]) cg.add(getattr(var, setter)(*args)) + + +SEN6X_ACTION_SCHEMA = maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(SEN6XComponent), + } +) + + +for _name, _call in ( + ("sen6x.start_measurement", "start_measurement()"), + ("sen6x.stop_measurement", "stop_measurement()"), + ("sen6x.start_fan_cleaning", "start_fan_cleaning()"), + ("sen6x.activate_sht_heater", "activate_sht_heater()"), +): + automation.register_apply_action( + _name, SEN6X_ACTION_SCHEMA, automation.ApplyCall(_call) + ) diff --git a/tests/components/sen6x/common.yaml b/tests/components/sen6x/common.yaml index c9b6f22c0fc..6ab80a76b21 100644 --- a/tests/components/sen6x/common.yaml +++ b/tests/components/sen6x/common.yaml @@ -48,3 +48,17 @@ sensor: formaldehyde: name: Formaldehyde address: 0x6B + +button: + - platform: template + name: Fan Cleaning + on_press: + - sen6x.stop_measurement: sen6x_sensor + - delay: 2s + - sen6x.start_fan_cleaning: sen6x_sensor + - delay: 11s + - sen6x.start_measurement: sen6x_sensor + - platform: template + name: SHT Heater + on_press: + - sen6x.activate_sht_heater: sen6x_sensor From 40e12cd0d893f496c78ccbacdb5df529e64cd783 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 17:01:47 -1000 Subject: [PATCH 074/142] [image] Add CodSpeed benchmarks for image, runtime image and display block draws (#20649) --- .../benchmarks/components/display/__init__.py | 7 + .../display/bench_draw_pixels_at.cpp | 82 ++++++ tests/benchmarks/components/image/__init__.py | 7 + .../components/image/bench_image_draw.cpp | 245 ++++++++++++++++++ .../bench_runtime_image_draw.cpp | 125 +++++++++ tests/testing_helpers.py | 22 ++ 6 files changed, 488 insertions(+) create mode 100644 tests/benchmarks/components/display/__init__.py create mode 100644 tests/benchmarks/components/display/bench_draw_pixels_at.cpp create mode 100644 tests/benchmarks/components/image/__init__.py create mode 100644 tests/benchmarks/components/image/bench_image_draw.cpp create mode 100644 tests/benchmarks/components/runtime_image/bench_runtime_image_draw.cpp diff --git a/tests/benchmarks/components/display/__init__.py b/tests/benchmarks/components/display/__init__.py new file mode 100644 index 00000000000..34eec8f3748 --- /dev/null +++ b/tests/benchmarks/components/display/__init__.py @@ -0,0 +1,7 @@ +from tests.testing_helpers import ComponentManifestOverride, add_defines_to_code + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # Loops that only exist on boards with PSRAM are gated on this define; + # set it so the host benchmark measures the same code those boards run. + add_defines_to_code(manifest, "USE_PSRAM") diff --git a/tests/benchmarks/components/display/bench_draw_pixels_at.cpp b/tests/benchmarks/components/display/bench_draw_pixels_at.cpp new file mode 100644 index 00000000000..da96dd83b9a --- /dev/null +++ b/tests/benchmarks/components/display/bench_draw_pixels_at.cpp @@ -0,0 +1,82 @@ +#include + +#include +#include + +#include "esphome/components/display/display_buffer.h" + +namespace esphome::display::benchmarks { + +static constexpr int kWidth = 128; +static constexpr int kHeight = 96; +// Square frame so a rotated block stays inside it. +static constexpr int kStride = kWidth > kHeight ? kWidth : kHeight; + +// A buffered display without its own draw_pixels_at(), so every block goes +// through the base class fallback and the DisplayBuffer pixel path. +class BenchDisplay : public DisplayBuffer { + public: + BenchDisplay() : frame_(std::make_unique(kStride * kStride)) {} + void update() override {} + DisplayType get_display_type() override { return DisplayType::DISPLAY_TYPE_COLOR; } + int get_width_internal() override { return kStride; } + int get_height_internal() override { return kStride; } + + protected: + void draw_absolute_pixel_internal(int x, int y, Color color) override { this->frame_[y * kStride + x] = color.r; } + + std::unique_ptr frame_; +}; + +// Deterministic pseudo random pixel bytes, sized for the widest format. +static std::unique_ptr make_pixels() { + constexpr size_t bytes = kWidth * kHeight * 3; + auto data = std::make_unique(bytes); + uint32_t seed = 11897; + for (size_t i = 0; i < bytes; i++) { + seed = seed * 1103515245u + 12345u; + data[i] = static_cast(seed >> 16); + } + return data; +} + +// One full block the way LVGL hands a display its rendering. +static void draw_block(benchmark::State &state, ColorBitness bitness, bool big_endian, + DisplayRotation rotation = DISPLAY_ROTATION_0_DEGREES) { + auto data = make_pixels(); + BenchDisplay display; + display.set_rotation(rotation); + for (auto _ : state) { + display.draw_pixels_at(0, 0, kWidth, kHeight, data.get(), COLOR_ORDER_RGB, bitness, big_endian, 0, 0, 0); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} + +static void DrawPixelsAt_RGB565_BigEndian(benchmark::State &state) { draw_block(state, COLOR_BITNESS_565, true); } +BENCHMARK(DrawPixelsAt_RGB565_BigEndian); + +static void DrawPixelsAt_RGB565_LittleEndian(benchmark::State &state) { draw_block(state, COLOR_BITNESS_565, false); } +BENCHMARK(DrawPixelsAt_RGB565_LittleEndian); + +static void DrawPixelsAt_RGB888(benchmark::State &state) { draw_block(state, COLOR_BITNESS_888, true); } +BENCHMARK(DrawPixelsAt_RGB888); + +static void DrawPixelsAt_RGB332(benchmark::State &state) { draw_block(state, COLOR_BITNESS_332, true); } +BENCHMARK(DrawPixelsAt_RGB332); + +static void DrawPixelsAt_RGB565_Rotated(benchmark::State &state) { + draw_block(state, COLOR_BITNESS_565, true, DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(DrawPixelsAt_RGB565_Rotated); + +static void DrawPixelsAt_RGB888_Rotated(benchmark::State &state) { + draw_block(state, COLOR_BITNESS_888, true, DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(DrawPixelsAt_RGB888_Rotated); + +static void DrawPixelsAt_RGB332_Rotated(benchmark::State &state) { + draw_block(state, COLOR_BITNESS_332, true, DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(DrawPixelsAt_RGB332_Rotated); + +} // namespace esphome::display::benchmarks diff --git a/tests/benchmarks/components/image/__init__.py b/tests/benchmarks/components/image/__init__.py new file mode 100644 index 00000000000..34eec8f3748 --- /dev/null +++ b/tests/benchmarks/components/image/__init__.py @@ -0,0 +1,7 @@ +from tests.testing_helpers import ComponentManifestOverride, add_defines_to_code + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # Loops that only exist on boards with PSRAM are gated on this define; + # set it so the host benchmark measures the same code those boards run. + add_defines_to_code(manifest, "USE_PSRAM") diff --git a/tests/benchmarks/components/image/bench_image_draw.cpp b/tests/benchmarks/components/image/bench_image_draw.cpp new file mode 100644 index 00000000000..6bdd0acffbc --- /dev/null +++ b/tests/benchmarks/components/image/bench_image_draw.cpp @@ -0,0 +1,245 @@ +#include + +#include +#include +#include + +#include "esphome/components/display/display_buffer.h" +#include "esphome/components/image/image.h" + +namespace esphome::image::benchmarks { + +static constexpr int kWidth = 128; +static constexpr int kHeight = 96; +// Square frame so a rotated draw of the image stays inside it. +static constexpr int kStride = kWidth > kHeight ? kWidth : kHeight; + +// One byte per pixel frame buffer with no hardware behind it, so the +// measurement is Image::draw() plus the DisplayBuffer pixel path every +// buffered display shares. Everything fits the host cache, so this counts +// the per pixel work, not what a frame buffer in PSRAM costs. +class BenchDisplay : public display::DisplayBuffer { + public: + BenchDisplay() : frame_(std::make_unique(kStride * kStride)) {} + void update() override {} + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } + int get_width_internal() override { return kStride; } + int get_height_internal() override { return kStride; } + + protected: + void draw_absolute_pixel_internal(int x, int y, Color color) override { this->frame_[y * kStride + x] = color.r; } + + std::unique_ptr frame_; + + public: + uint8_t pixel(int x, int y) const { return this->frame_[y * kStride + x]; } +}; + +// Models a driver with a bulk path: rows of little endian RGB565 are copied +// straight into a 16 bit frame, as the TFT drivers do for their own format. +// Image::draw() only reaches it once it hands opaque images to +// draw_pixels_at(); until then this case matches ImageDraw_RGB565. +class BulkBenchDisplay : public BenchDisplay { + public: + BulkBenchDisplay() : frame565_(std::make_unique(kStride * kStride)) {} + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override { + if (bitness != display::COLOR_BITNESS_565 || big_endian) { + BenchDisplay::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + return; + } + const size_t stride = x_offset + w + x_pad; + for (int y = 0; y < h; y++) { + memcpy(this->frame565_.get() + (y_start + y) * kStride + x_start, ptr + ((y_offset + y) * stride + x_offset) * 2, + w * 2); + } + } + + protected: + std::unique_ptr frame565_; +}; + +// Draws the image at rotation 0 and 90 and checks the second frame is the +// transpose of the first, which is what a wrong traversal order would break. +static bool frames_match(Image &image) { + BenchDisplay plain; + BenchDisplay rotated; + rotated.set_rotation(display::DISPLAY_ROTATION_90_DEGREES); + image.draw(0, 0, &plain, display::COLOR_ON, display::COLOR_OFF); + image.draw(0, 0, &rotated, display::COLOR_ON, display::COLOR_OFF); + for (int y = 0; y < kHeight; y++) { + for (int x = 0; x < kWidth; x++) { + // DisplayBuffer maps (x, y) to (width - y - 1, x) at 90 degrees + if (plain.pixel(x, y) != rotated.pixel(kStride - y - 1, x)) + return false; + } + } + return true; +} + +// Deterministic pseudo random pixel bytes so the alpha branch sees a mix of +// pixels. Sized for the widest format, the image reads only what it needs. +static std::unique_ptr make_pixels() { + constexpr size_t bytes = kWidth * kHeight * 4; + auto data = std::make_unique(bytes); + uint32_t seed = 11897; + for (size_t i = 0; i < bytes; i++) { + seed = seed * 1103515245u + 12345u; + data[i] = static_cast(seed >> 16); + } + return data; +} + +static void draw_image(benchmark::State &state, ImageType type, Transparency transparency, + display::DisplayRotation rotation = display::DISPLAY_ROTATION_0_DEGREES) { + auto data = make_pixels(); + Image image(data.get(), kWidth, kHeight, type, transparency); + BenchDisplay display; + display.set_rotation(rotation); + if (!frames_match(image)) { + state.SkipWithError("rotated draw is not the transpose of the plain draw"); + return; + } + + for (auto _ : state) { + image.draw(0, 0, &display, display::COLOR_ON, display::COLOR_OFF); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} + +static void ImageDraw_Binary(benchmark::State &state) { draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_OPAQUE); } +BENCHMARK(ImageDraw_Binary); + +static void ImageDraw_Grayscale(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_OPAQUE); +} +BENCHMARK(ImageDraw_Grayscale); + +static void ImageDraw_RGB565(benchmark::State &state) { draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE); } +BENCHMARK(ImageDraw_RGB565); + +static void ImageDraw_RGB(benchmark::State &state) { draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_OPAQUE); } +BENCHMARK(ImageDraw_RGB); + +// The same opaque RGB565 image on a display with a bulk path. +static void ImageDraw_RGB565_Bulk(benchmark::State &state) { + auto data = make_pixels(); + Image image(data.get(), kWidth, kHeight, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE); + BulkBenchDisplay display; + for (auto _ : state) { + image.draw(0, 0, &display, display::COLOR_ON, display::COLOR_OFF); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} +BENCHMARK(ImageDraw_RGB565_Bulk); + +static void ImageDraw_RGB_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB_Rotated); + +static void ImageDraw_Binary_Transparent(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(ImageDraw_Binary_Transparent); + +static void ImageDraw_Grayscale_ChromaKey(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(ImageDraw_Grayscale_ChromaKey); + +static void ImageDraw_Grayscale_Alpha(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_ALPHA_CHANNEL); +} +BENCHMARK(ImageDraw_Grayscale_Alpha); + +static void ImageDraw_RGB_Alpha(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_ALPHA_CHANNEL); +} +BENCHMARK(ImageDraw_RGB_Alpha); + +static void ImageDraw_RGB_ChromaKey(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(ImageDraw_RGB_ChromaKey); + +static void ImageDraw_RGB565_ChromaKey(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(ImageDraw_RGB565_ChromaKey); + +static void ImageDraw_RGB565_Alpha(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_ALPHA_CHANNEL); +} +BENCHMARK(ImageDraw_RGB565_Alpha); + +static void ImageDraw_RGB111(benchmark::State &state) { draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_OPAQUE); } +BENCHMARK(ImageDraw_RGB111); + +static void ImageDraw_RGB111_Alpha(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_ALPHA_CHANNEL); +} +BENCHMARK(ImageDraw_RGB111_Alpha); + +// The same cases with the display rotated 90 degrees, which walks the other loop order. +static void ImageDraw_Binary_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Binary_Rotated); + +static void ImageDraw_Grayscale_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Grayscale_Rotated); + +static void ImageDraw_RGB565_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB565_Rotated); + +static void ImageDraw_Binary_Transparent_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Binary_Transparent_Rotated); + +static void ImageDraw_Grayscale_ChromaKey_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Grayscale_ChromaKey_Rotated); + +static void ImageDraw_Grayscale_Alpha_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Grayscale_Alpha_Rotated); + +static void ImageDraw_RGB_Alpha_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB_Alpha_Rotated); + +static void ImageDraw_RGB_ChromaKey_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB_ChromaKey_Rotated); + +static void ImageDraw_RGB565_ChromaKey_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB565_ChromaKey_Rotated); + +static void ImageDraw_RGB565_Alpha_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB565_Alpha_Rotated); + +static void ImageDraw_RGB111_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB111_Rotated); + +static void ImageDraw_RGB111_Alpha_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB111_Alpha_Rotated); + +} // namespace esphome::image::benchmarks diff --git a/tests/benchmarks/components/runtime_image/bench_runtime_image_draw.cpp b/tests/benchmarks/components/runtime_image/bench_runtime_image_draw.cpp new file mode 100644 index 00000000000..fd3cf6fb773 --- /dev/null +++ b/tests/benchmarks/components/runtime_image/bench_runtime_image_draw.cpp @@ -0,0 +1,125 @@ +#include + +#include +#include + +#include "esphome/components/display/display_buffer.h" +#include "esphome/components/runtime_image/runtime_image.h" + +namespace esphome::runtime_image::benchmarks { + +static constexpr int kWidth = 128; +static constexpr int kHeight = 96; + +// One byte per pixel frame buffer with no hardware behind it, so the +// measurement is the image draw plus the DisplayBuffer pixel path every +// buffered display shares. +class BenchDisplay : public display::DisplayBuffer { + public: + BenchDisplay() : frame_(std::make_unique(kWidth * kHeight)) {} + void update() override {} + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } + int get_width_internal() override { return kWidth; } + int get_height_internal() override { return kHeight; } + + protected: + void draw_absolute_pixel_internal(int x, int y, Color color) override { this->frame_[y * kWidth + x] = color.r; } + + std::unique_ptr frame_; +}; + +// A runtime image filled the way a decoder fills it, one draw_pixel() per +// pixel, then published as decoded so draw() uses the buffer. +class BenchImage : public RuntimeImage { + public: + BenchImage(image::ImageType type, image::Transparency transparency, bool big_endian) + : RuntimeImage(JPEG, type, transparency, nullptr, big_endian, kWidth, kHeight) {} + + bool fill() { + if (this->resize(kWidth, kHeight) == 0) + return false; + uint32_t seed = 11897; + for (int y = 0; y < kHeight; y++) { + for (int x = 0; x < kWidth; x++) { + seed = seed * 1103515245u + 12345u; + // Every fourth pixel is transparent so the chroma key cases hit both branches + const uint8_t alpha = (x & 3) == 0 ? 0x00 : 0xFF; + this->draw_pixel(x, y, Color(seed >> 24, seed >> 16, seed >> 8, alpha)); + } + } + this->width_ = this->buffer_width_; + this->height_ = this->buffer_height_; + this->data_start_ = this->buffer_; + return true; + } +}; + +// The decoder side: writing every pixel of the image into its buffer. +static void fill_image(benchmark::State &state, image::ImageType type, image::Transparency transparency) { + BenchImage image(type, transparency, false); + for (auto _ : state) { + if (!image.fill()) { + state.SkipWithError("buffer allocation failed"); + return; + } + benchmark::DoNotOptimize(image.get_data_start()); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} + +// The display side: drawing the decoded image onto a buffered display. +static void draw_image(benchmark::State &state, image::ImageType type, image::Transparency transparency, + bool big_endian = false) { + BenchImage image(type, transparency, big_endian); + if (!image.fill()) { + state.SkipWithError("buffer allocation failed"); + return; + } + BenchDisplay display; + for (auto _ : state) { + image.draw(0, 0, &display, display::COLOR_ON, display::COLOR_OFF); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} + +static void RuntimeImageFill_RGB565(benchmark::State &state) { + fill_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageFill_RGB565); + +static void RuntimeImageFill_RGB565_ChromaKey(benchmark::State &state) { + fill_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(RuntimeImageFill_RGB565_ChromaKey); + +static void RuntimeImageFill_RGB(benchmark::State &state) { + fill_image(state, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageFill_RGB); + +static void RuntimeImageDraw_RGB565(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageDraw_RGB565); + +static void RuntimeImageDraw_RGB565_BigEndian(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE, true); +} +BENCHMARK(RuntimeImageDraw_RGB565_BigEndian); + +static void RuntimeImageDraw_RGB565_ChromaKey(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(RuntimeImageDraw_RGB565_ChromaKey); + +static void RuntimeImageDraw_RGB(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageDraw_RGB); + +static void RuntimeImageDraw_Grayscale(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_GRAYSCALE, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageDraw_Grayscale); + +} // namespace esphome::runtime_image::benchmarks diff --git a/tests/testing_helpers.py b/tests/testing_helpers.py index 20b76697a19..23bef718694 100644 --- a/tests/testing_helpers.py +++ b/tests/testing_helpers.py @@ -1,6 +1,7 @@ from typing import Any from esphome.loader import ComponentManifest, _replace_component_manifest +from esphome.types import ConfigType class ComponentManifestOverride: @@ -61,3 +62,24 @@ def set_testing_manifest(domain: str, manifest: ComponentManifestOverride) -> No directory provides an ``override_manifest`` function. """ _replace_component_manifest(domain, manifest) + + +def add_defines_to_code(manifest: ComponentManifestOverride, *defines: str) -> None: + """Keep the component's real ``to_code`` and add the given defines after it. + + For host test and benchmark builds of code that is gated on a platform + define, such as loops that only exist on boards with PSRAM. + """ + from esphome import codegen as cg + + manifest.enable_codegen() + original_to_code = manifest.to_code + + async def to_code(config: ConfigType) -> None: + await original_to_code(config) + for define in defines: + cg.add_define(define) + + if hasattr(original_to_code, "priority"): + to_code.priority = original_to_code.priority + manifest.to_code = to_code From 5718e4b779991c7200a1d18d513bbae38dbea5bc Mon Sep 17 00:00:00 2001 From: techdregs <127994432+techdregs@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:06:56 -0400 Subject: [PATCH 075/142] [tmp102] Add text sensor for diagnostics (#20629) Co-authored-by: J. Nick Koston --- esphome/components/tmp102/text_sensor.py | 31 ++++++ esphome/components/tmp102/tmp102.cpp | 105 ++++++++++++++++-- esphome/components/tmp102/tmp102.h | 32 +++++- esphome/core/defines.h | 1 + .../tmp102/config/duplicate_status.yaml | 23 ++++ .../component_tests/tmp102/config/status.yaml | 20 ++++ tests/component_tests/tmp102/test_config.py | 24 ++++ tests/components/tmp102/__init__.py | 4 +- tests/components/tmp102/common.yaml | 5 + .../test-text-sensor-only.esp32-idf.yaml | 12 ++ tests/components/tmp102/test_tmp102.cpp | 94 ++++++++++++++++ 11 files changed, 339 insertions(+), 12 deletions(-) create mode 100644 esphome/components/tmp102/text_sensor.py create mode 100644 tests/component_tests/tmp102/config/duplicate_status.yaml create mode 100644 tests/component_tests/tmp102/config/status.yaml create mode 100644 tests/components/tmp102/test-text-sensor-only.esp32-idf.yaml diff --git a/esphome/components/tmp102/text_sensor.py b/esphome/components/tmp102/text_sensor.py new file mode 100644 index 00000000000..5d901bfab89 --- /dev/null +++ b/esphome/components/tmp102/text_sensor.py @@ -0,0 +1,31 @@ +"""TMP102 threshold status entity.""" + +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_TMP102_ID, TMP102Component, validate_unique_child + +CONFIG_SCHEMA = text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC +).extend( + { + cv.GenerateID(CONF_TMP102_ID): cv.use_id(TMP102Component), + } +) + + +def _final_validate(config: ConfigType) -> ConfigType: + return validate_unique_child(config, "text_sensor") + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_TMP102_TEXT_SENSOR") + parent = await cg.get_variable(config[CONF_TMP102_ID]) + var = await text_sensor.new_text_sensor(config) + cg.add(parent.set_threshold_status_text_sensor(var)) diff --git a/esphome/components/tmp102/tmp102.cpp b/esphome/components/tmp102/tmp102.cpp index bb82975dbc3..06d94f1ba86 100644 --- a/esphome/components/tmp102/tmp102.cpp +++ b/esphome/components/tmp102/tmp102.cpp @@ -1,8 +1,14 @@ #include "tmp102.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" +#ifdef USE_TMP102_TEXT_SENSOR +#include "esphome/core/progmem.h" +#endif #include +#ifdef USE_TMP102_TEXT_SENSOR +#include +#endif namespace esphome::tmp102 { @@ -12,6 +18,30 @@ static constexpr uint32_t READ_TEMP_TIMEOUT_ID = 0; static constexpr uint8_t TMP102_REGISTER_TEMPERATURE = 0x00; static constexpr float TMP102_CONVERSION_FACTOR = 0.0625f; + +enum TMP102ThresholdStatus : uint8_t { + TMP102_THRESHOLD_STATUS_NOT_CONFIGURED, + TMP102_THRESHOLD_STATUS_SETUP_FAILED, + TMP102_THRESHOLD_STATUS_OK, + TMP102_THRESHOLD_STATUS_RESTORE_RESET, + TMP102_THRESHOLD_STATUS_RESTORE_RESET_SAVE_FAILED, + TMP102_THRESHOLD_STATUS_PREFERENCE_SAVE_FAILED, + TMP102_THRESHOLD_STATUS_RECOVERY_FAILED, + TMP102_THRESHOLD_STATUS_RECOVERED, + TMP102_THRESHOLD_STATUS_WRITE_HIGH_FAILED, + TMP102_THRESHOLD_STATUS_WRITE_LOW_FAILED, + TMP102_THRESHOLD_STATUS_SENSOR_SETUP_FAILED, + TMP102_THRESHOLD_STATUS_COUNT, +}; + +#ifdef USE_TMP102_TEXT_SENSOR +PROGMEM_STRING_TABLE(ThresholdStatusStrings, "Not configured", "Setup failed", "OK", "Restored thresholds reset", + "Restore reset; save failed", "Preference save failed", "Configuration recovery failed", + "Configuration restored", "Write failed: high threshold", "Write failed: low threshold", + "Rejected: sensor setup failed"); +static_assert(ThresholdStatusStrings::COUNT == TMP102_THRESHOLD_STATUS_COUNT); +#endif + #if defined(USE_TMP102_CONFIGURE) || defined(USE_TMP102_BINARY_SENSOR) static constexpr uint8_t TMP102_REGISTER_CONFIGURATION = 0x01; static constexpr uint16_t TMP102_ALERT_BIT = 0x0020; @@ -42,15 +72,30 @@ static uint16_t encode_temperature(float temperature, bool extended) { return static_cast(static_cast(static_cast(steps)) << (extended ? 3 : 4)); } #endif +#endif +#if defined(USE_TMP102_CONFIGURE) || defined(USE_TMP102_TEXT_SENSOR) void TMP102Component::setup() { - if (!this->configure_) +#ifdef USE_TMP102_CONFIGURE + if (this->configure_) { + this->setup_configured_(); return; + } +#endif + this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_NOT_CONFIGURED); +} +#endif + +#ifdef USE_TMP102_CONFIGURE +void TMP102Component::setup_configured_() { + bool corrected = false; #ifdef USE_TMP102_NUMBER float restored[2]{}; - this->prepare_number_limits_(restored); + bool restore_corrected[2]{}; + corrected = this->prepare_number_limits_(restored, restore_corrected); #endif if (!this->write_configuration_()) { + this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_SETUP_FAILED); this->mark_failed(); return; } @@ -61,14 +106,17 @@ void TMP102Component::setup() { if (control == nullptr) continue; const float value = this->get_limit_temperature_(static_cast(limit)); - if (value != restored[limit]) + if (restore_corrected[limit] || value != restored[limit]) saved &= control->save_value_(value); control->publish_state(value); } if (!saved) { ESP_LOGW(TAG, "Failed to save threshold preferences"); + this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_RESTORE_RESET_SAVE_FAILED); + return; } #endif + this->publish_threshold_status_(corrected ? TMP102_THRESHOLD_STATUS_RESTORE_RESET : TMP102_THRESHOLD_STATUS_OK); } #endif @@ -83,6 +131,9 @@ void TMP102Component::dump_config() { #ifdef USE_TMP102_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Alert", this->alert_binary_sensor_); #endif +#ifdef USE_TMP102_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Threshold Status", this->threshold_status_text_sensor_); +#endif #ifdef USE_TMP102_CONFIGURE if (!this->configure_) return; @@ -203,6 +254,9 @@ bool TMP102Component::check_configuration_() { this->set_warning_and_invalidate_alert_(); if (!this->write_configuration_()) { ESP_LOGW(TAG, "Failed to restore configuration and thresholds"); + this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_RECOVERY_FAILED); + } else { + this->publish_threshold_status_(TMP102_THRESHOLD_STATUS_RECOVERED); } return false; } @@ -228,22 +282,25 @@ void TMP102Component::set_limit_control(TMP102LimitNumber *number) { this->limit_controls_[number->limit_type_] = number; } -void TMP102Component::prepare_number_limits_(float *restored) { +bool TMP102Component::prepare_number_limits_(float *restored, bool *restore_corrected) { const bool extended = this->config_ & TMP102_EXTENDED_MODE_BIT; uint16_t raw[2] = {this->low_limit_, this->high_limit_}; + bool corrected = false; for (uint8_t limit = 0; limit < 2; ++limit) { TMP102LimitNumber *control = this->limit_controls_[limit]; if (control == nullptr) continue; - restored[limit] = control->load_value_(decode_temperature(raw[limit], extended)); + restored[limit] = control->load_value_(decode_temperature(raw[limit], extended), restore_corrected[limit]); + corrected |= restore_corrected[limit]; raw[limit] = encode_temperature(restored[limit], extended); } if (static_cast(raw[TMP102_LIMIT_LOW]) > static_cast(raw[TMP102_LIMIT_HIGH])) { ESP_LOGW(TAG, "Restored thresholds are inverted; using configured values"); - return; + return true; } this->low_limit_ = raw[TMP102_LIMIT_LOW]; this->high_limit_ = raw[TMP102_LIMIT_HIGH]; + return corrected; } float TMP102Component::get_limit_temperature_(TMP102LimitType limit) const { @@ -261,10 +318,13 @@ bool TMP102Component::set_limit_temperature_(TMP102LimitType limit, float temper ESP_LOGW(TAG, "Rejected %s threshold %.4f°C: crosses %s threshold %.4f°C", high ? LOG_STR_LITERAL("high") : LOG_STR_LITERAL("low"), decode_temperature(raw, extended), high ? LOG_STR_LITERAL("low") : LOG_STR_LITERAL("high"), decode_temperature(other, extended)); + this->publish_threshold_rejection_(high, decode_temperature(raw, extended), decode_temperature(other, extended)); return false; } if (!this->write_register_(static_cast(TMP102_REGISTER_LOW_LIMIT + limit), raw)) { this->status_set_warning(); + this->publish_threshold_status_(high ? TMP102_THRESHOLD_STATUS_WRITE_HIGH_FAILED + : TMP102_THRESHOLD_STATUS_WRITE_LOW_FAILED); return false; } (high ? this->high_limit_ : this->low_limit_) = raw; @@ -273,6 +333,32 @@ bool TMP102Component::set_limit_temperature_(TMP102LimitType limit, float temper #endif #endif +#ifdef USE_TMP102_TEXT_SENSOR +void TMP102Component::publish_threshold_status_(uint8_t status) { + if (this->threshold_status_text_sensor_ == nullptr) + return; + char text[40]; + ESPHOME_strncpy_P( + text, + reinterpret_cast(ThresholdStatusStrings::get_progmem_str(status, TMP102_THRESHOLD_STATUS_OK)), + sizeof(text)); + text[sizeof(text) - 1] = '\0'; + this->threshold_status_text_sensor_->publish_state(text); +} + +void TMP102Component::publish_threshold_rejection_(bool high, float value, float other) { + if (this->threshold_status_text_sensor_ == nullptr) + return; + char text[96]; + if (high) { + ESPHOME_snprintf_P(text, sizeof(text), ESPHOME_PSTR("Rejected: high %.4f°C crosses low %.4f°C"), value, other); + } else { + ESPHOME_snprintf_P(text, sizeof(text), ESPHOME_PSTR("Rejected: low %.4f°C crosses high %.4f°C"), value, other); + } + this->threshold_status_text_sensor_->publish_state(text); +} +#endif + #ifdef USE_TMP102_BINARY_SENSOR void TMP102Component::invalidate_alert_() { if (this->alert_binary_sensor_ != nullptr) @@ -294,7 +380,7 @@ bool TMP102Component::read_alert_state_() { #endif #ifdef USE_TMP102_NUMBER -float TMP102LimitNumber::load_value_(float fallback) { +float TMP102LimitNumber::load_value_(float fallback, bool &corrected) { float value = fallback; if (this->restore_value_) { this->pref_ = this->make_entity_preference(); @@ -303,6 +389,7 @@ float TMP102LimitNumber::load_value_(float fallback) { if (!std::isfinite(value) || value < this->traits.get_min_value() || value > this->traits.get_max_value()) { ESP_LOGW(TAG, "Invalid restored threshold; using configured value"); value = fallback; + corrected = true; } return value; } @@ -313,6 +400,7 @@ void TMP102LimitNumber::control(float value) { // A chip that failed setup holds nothing the entity could show if (this->parent_->is_failed()) { ESP_LOGW(TAG, "Rejected threshold change: sensor setup failed"); + this->parent_->publish_threshold_status_(TMP102_THRESHOLD_STATUS_SENSOR_SETUP_FAILED); return; } const bool accepted = this->parent_->set_limit_temperature_(this->limit_type_, value); @@ -320,6 +408,9 @@ void TMP102LimitNumber::control(float value) { this->publish_state(value); if (accepted && !this->save_value_(value)) { ESP_LOGW(TAG, "Failed to save threshold preference"); + this->parent_->publish_threshold_status_(TMP102_THRESHOLD_STATUS_PREFERENCE_SAVE_FAILED); + } else if (accepted) { + this->parent_->publish_threshold_status_(TMP102_THRESHOLD_STATUS_OK); } } #endif diff --git a/esphome/components/tmp102/tmp102.h b/esphome/components/tmp102/tmp102.h index d88dde78344..34cdfc973c1 100644 --- a/esphome/components/tmp102/tmp102.h +++ b/esphome/components/tmp102/tmp102.h @@ -12,11 +12,20 @@ #include "esphome/core/preferences.h" #endif #include "esphome/components/sensor/sensor.h" +#ifdef USE_TMP102_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif #include namespace esphome::tmp102 { +#ifdef USE_HOST +namespace testing { +class TMP102Test; +} +#endif + #ifdef USE_TMP102_NUMBER enum TMP102LimitType : uint8_t { TMP102_LIMIT_LOW = 0, @@ -31,8 +40,13 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu #ifdef USE_TMP102_BINARY_SENSOR SUB_BINARY_SENSOR(alert) #endif -#ifdef USE_TMP102_CONFIGURE +#ifdef USE_TMP102_TEXT_SENSOR + SUB_TEXT_SENSOR(threshold_status) +#endif +#if defined(USE_TMP102_CONFIGURE) || defined(USE_TMP102_TEXT_SENSOR) void setup() override; +#endif +#ifdef USE_TMP102_CONFIGURE void set_configuration(uint16_t config, uint16_t high_limit, uint16_t low_limit) { this->config_ = config; this->high_limit_ = high_limit; @@ -47,6 +61,13 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu void update() override; protected: +#ifdef USE_TMP102_TEXT_SENSOR + void publish_threshold_status_(uint8_t status); + void publish_threshold_rejection_(bool high, float value, float other); +#else + void publish_threshold_status_(uint8_t) {} + void publish_threshold_rejection_(bool, float, float) {} +#endif void set_warning_and_invalidate_alert_() { this->status_set_warning(); #ifdef USE_TMP102_BINARY_SENSOR @@ -58,6 +79,7 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu void invalidate_alert_(); #endif #ifdef USE_TMP102_CONFIGURE + void setup_configured_(); bool write_register_(uint8_t reg, uint16_t value); bool write_configuration_(); bool check_configuration_(); @@ -67,11 +89,10 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu friend class TMP102LimitNumber; bool set_limit_temperature_(TMP102LimitType limit, float temperature); float get_limit_temperature_(TMP102LimitType limit) const; - void prepare_number_limits_(float *restored); + bool prepare_number_limits_(float *restored, bool *restore_corrected); TMP102LimitNumber *limit_controls_[2]{}; #endif - uint16_t config_{0}; uint16_t high_limit_{0}; uint16_t low_limit_{0}; @@ -87,7 +108,10 @@ class TMP102LimitNumber final : public number::Number, public Parented None: + source = generate_main(component_config_path("status.yaml")) + assert "set_threshold_status_text_sensor" in source + assert "set_configuration" not in source + defines = " ".join(define.name for define in CORE.defines) + assert "USE_TMP102_TEXT_SENSOR" in defines + assert "USE_TMP102_CONFIGURE" not in defines + + +def test_duplicate_status_entities_are_rejected( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + CORE.config_path = component_config_path("duplicate_status.yaml") + assert read_config({}) is None + assert ( + "Only one TMP102 text_sensor may reference each temperature sensor" + in capsys.readouterr().out + ) + + def test_threshold_numbers_enable_configuration_once( generate_main: Callable[[Path], str], component_config_path: Callable[[str], Path], diff --git a/tests/components/tmp102/__init__.py b/tests/components/tmp102/__init__.py index 346efac83ec..60de2d34958 100644 --- a/tests/components/tmp102/__init__.py +++ b/tests/components/tmp102/__init__.py @@ -6,11 +6,12 @@ from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config: ConfigType) -> None: - for platform in ("sensor", "binary_sensor", "number"): + for platform in ("sensor", "binary_sensor", "number", "text_sensor"): CORE.testing_ensure_platform_registered(platform) cg.add_define("USE_TMP102_CONFIGURE") cg.add_define("USE_TMP102_BINARY_SENSOR") cg.add_define("USE_TMP102_NUMBER") + cg.add_define("USE_TMP102_TEXT_SENSOR") manifest.to_code = to_code_testing manifest.dependencies = manifest.dependencies + [ @@ -18,4 +19,5 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "sensor", "binary_sensor", "number", + "text_sensor", ] diff --git a/tests/components/tmp102/common.yaml b/tests/components/tmp102/common.yaml index dfc2e2e2245..3d1f66da5bb 100644 --- a/tests/components/tmp102/common.yaml +++ b/tests/components/tmp102/common.yaml @@ -30,3 +30,8 @@ binary_sensor: - platform: tmp102 tmp102_id: tmp102_extended_temperature name: TMP102 Alert + +text_sensor: + - platform: tmp102 + tmp102_id: tmp102_extended_temperature + name: TMP102 Threshold Status diff --git a/tests/components/tmp102/test-text-sensor-only.esp32-idf.yaml b/tests/components/tmp102/test-text-sensor-only.esp32-idf.yaml new file mode 100644 index 00000000000..21880a6338f --- /dev/null +++ b/tests/components/tmp102/test-text-sensor-only.esp32-idf.yaml @@ -0,0 +1,12 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +sensor: + - platform: tmp102 + id: tmp102_text_sensor_parent + name: TMP102 Temperature + +text_sensor: + - platform: tmp102 + tmp102_id: tmp102_text_sensor_parent + name: TMP102 Threshold Status diff --git a/tests/components/tmp102/test_tmp102.cpp b/tests/components/tmp102/test_tmp102.cpp index 6c294f4cdc6..a5b65b060d4 100644 --- a/tests/components/tmp102/test_tmp102.cpp +++ b/tests/components/tmp102/test_tmp102.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include "esphome/components/host/preferences.h" #include "esphome/components/tmp102/tmp102.h" @@ -29,6 +30,7 @@ class RegisterBus final : public i2c::I2CBus { unsigned transactions{0}; unsigned writes{0}; uint8_t pointer{0}; + std::function on_write; i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, size_t out_size) override { @@ -42,6 +44,10 @@ class RegisterBus final : public i2c::I2CBus { return i2c::ERROR_NOT_ACKNOWLEDGED; ++this->writes; this->registers[this->pointer] = (uint16_t(data[1]) << 8) | data[2]; + if (this->on_write) { + auto callback = std::move(this->on_write); + callback(); + } } } if (out_size != 0) { @@ -59,6 +65,7 @@ class TMP102Test : public ::testing::Test { RegisterBus bus_; TMP102Component chip_; binary_sensor::BinarySensor alert_; + text_sensor::TextSensor status_; TMP102LimitNumber high_{TMP102_LIMIT_HIGH}; TMP102LimitNumber low_{TMP102_LIMIT_LOW}; unsigned publications_{0}; @@ -122,6 +129,8 @@ class TMP102Test : public ::testing::Test { static void save(TMP102LimitNumber &number, float value) { ASSERT_TRUE(number.make_entity_preference().save(&value)); } + + static void fail_saves(TMP102LimitNumber &number) { number.pref_ = {}; } }; TEST_F(TMP102Test, LegacySetupAndContinuousReadDoNotWriteRegisters) { @@ -136,6 +145,24 @@ TEST_F(TMP102Test, LegacySetupAndContinuousReadDoNotWriteRegisters) { EXPECT_EQ(this->bus_.registers, original); } +TEST_F(TMP102Test, ReadOnlyStatusDoesNotConfigureOrWriteRegisters) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + const auto original = this->bus_.registers; + this->chip_.setup(); + EXPECT_EQ(this->bus_.transactions, 0); + EXPECT_EQ(this->bus_.registers, original); + ASSERT_TRUE(this->status_.has_state()); + EXPECT_EQ(this->status_.state, "Not configured"); +} + +TEST_F(TMP102Test, ConfiguredSetupPublishesStatus) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + this->configure(); + this->chip_.setup(); + ASSERT_TRUE(this->status_.has_state()); + EXPECT_EQ(this->status_.state, "OK"); +} + TEST_F(TMP102Test, SampleFormatOverridesConfiguredModeInBothDirections) { for (bool configured_extended : {false, true}) { this->configure(configured_extended ? 0x0010 : 0, configured_extended ? 0x2800 : 0x5000, @@ -408,6 +435,7 @@ TEST_F(TMP102Test, FailureOfAnySetupWriteMarksComponentFailed) { } TEST_F(TMP102Test, InvalidRestoredPairIsRepairedAfterSuccessfulSetup) { + this->chip_.set_threshold_status_text_sensor(&this->status_); this->numbers_(); save(this->high_, 20); save(this->low_, 35); @@ -416,30 +444,53 @@ TEST_F(TMP102Test, InvalidRestoredPairIsRepairedAfterSuccessfulSetup) { EXPECT_FLOAT_EQ(this->low_.state, 25); EXPECT_FLOAT_EQ(saved(this->high_), 30); EXPECT_FLOAT_EQ(saved(this->low_), 25); + EXPECT_EQ(this->status_.state, "Restored thresholds reset"); +} + +TEST_F(TMP102Test, InvalidRestoredPairAndSaveFailurePublishCombinedStatus) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + this->numbers_(); + save(this->high_, 20); + save(this->low_, 35); + this->bus_.on_write = [this]() { + fail_saves(this->high_); + fail_saves(this->low_); + }; + this->chip_.setup(); + EXPECT_FLOAT_EQ(this->high_.state, 30); + EXPECT_FLOAT_EQ(this->low_.state, 25); + EXPECT_EQ(this->status_.state, "Restore reset; save failed"); } TEST_F(TMP102Test, InvalidRestoredValuesAreRepaired) { + this->chip_.set_threshold_status_text_sensor(&this->status_); this->numbers_(); save(this->high_, NAN); save(this->low_, INFINITY); this->chip_.setup(); EXPECT_FLOAT_EQ(this->high_.state, 30); EXPECT_FLOAT_EQ(this->low_.state, 25); + EXPECT_FLOAT_EQ(saved(this->high_), 30); + EXPECT_FLOAT_EQ(saved(this->low_), 25); + EXPECT_EQ(this->status_.state, "Restored thresholds reset"); } TEST_F(TMP102Test, FailedSetupDoesNotOverwritePreferences) { + this->chip_.set_threshold_status_text_sensor(&this->status_); this->numbers_(); save(this->high_, 90); save(this->low_, 85); this->bus_.fail_write = 3; this->chip_.setup(); EXPECT_TRUE(this->chip_.is_failed()); + EXPECT_EQ(this->status_.state, "Setup failed"); EXPECT_FALSE(this->high_.has_state()); EXPECT_FLOAT_EQ(saved(this->high_), 90); EXPECT_FLOAT_EQ(saved(this->low_), 85); } TEST_F(TMP102Test, FailedSetupRejectsThresholdChanges) { + this->chip_.set_threshold_status_text_sensor(&this->status_); this->numbers_(); save(this->high_, 90); this->bus_.fail_write = 3; @@ -450,6 +501,7 @@ TEST_F(TMP102Test, FailedSetupRejectsThresholdChanges) { EXPECT_FALSE(this->high_.has_state()); EXPECT_EQ(this->bus_.writes, writes); EXPECT_FLOAT_EQ(saved(this->high_), 90); + EXPECT_EQ(this->status_.state, "Rejected: sensor setup failed"); } TEST_F(TMP102Test, SetupOnlySavesChangedThresholds) { @@ -464,6 +516,7 @@ TEST_F(TMP102Test, SetupOnlySavesChangedThresholds) { } TEST_F(TMP102Test, FailedNumberWriteKeepsAcceptedState) { + this->chip_.set_threshold_status_text_sensor(&this->status_); this->numbers_(); save(this->high_, 90); save(this->low_, 85); @@ -475,6 +528,47 @@ TEST_F(TMP102Test, FailedNumberWriteKeepsAcceptedState) { EXPECT_FLOAT_EQ(this->high_.state, 90); EXPECT_EQ(publications, 1); EXPECT_FLOAT_EQ(saved(this->high_), 90); + EXPECT_EQ(this->status_.state, "Write failed: high threshold"); + + this->bus_.fail_write = 2; + this->low_.make_call().set_value(80).perform(); + EXPECT_FLOAT_EQ(this->low_.state, 85); + EXPECT_FLOAT_EQ(saved(this->low_), 85); + EXPECT_EQ(this->status_.state, "Write failed: low threshold"); +} + +TEST_F(TMP102Test, PreferenceSaveFailurePublishesStatus) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + this->numbers_(); + this->chip_.setup(); + fail_saves(this->high_); + this->high_.make_call().set_value(35).perform(); + EXPECT_FLOAT_EQ(this->high_.state, 35); + EXPECT_EQ(this->status_.state, "Preference save failed"); +} + +TEST_F(TMP102Test, RejectedNumberWritePublishesStatus) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + this->numbers_(); + this->chip_.setup(); + this->high_.make_call().set_value(20).perform(); + EXPECT_EQ(this->status_.state, "Rejected: high 20.0000°C crosses low 25.0000°C"); + this->high_.make_call().set_value(35).perform(); + EXPECT_EQ(this->status_.state, "OK"); +} + +TEST_F(TMP102Test, ConfigurationRecoveryPublishesStatus) { + this->chip_.set_threshold_status_text_sensor(&this->status_); + this->configure(); + this->chip_.setup(); + this->bus_.registers[1] = 0; + this->chip_.update(); + EXPECT_EQ(this->status_.state, "Configuration restored"); + + this->bus_.registers[1] = 0; + this->bus_.fail_write = 1; + this->chip_.update(); + EXPECT_EQ(this->status_.state, "Configuration recovery failed"); } TEST_F(TMP102Test, RestoreDisabledNeverChangesSavedValue) { From 5171113c870a58c0237f4546ac38d8f42e25bd76 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 17:17:48 -1000 Subject: [PATCH 076/142] [core] Let PROGMEM_STRING_TABLE hold more than 255 bytes (#20646) --- esphome/core/progmem.h | 35 ++++++++-- .../core/test_progmem_string_table.cpp | 65 +++++++++++++++++++ 2 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 tests/components/core/test_progmem_string_table.cpp diff --git a/esphome/core/progmem.h b/esphome/core/progmem.h index 54fc42dde16..677ef0e2afb 100644 --- a/esphome/core/progmem.h +++ b/esphome/core/progmem.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -68,6 +69,7 @@ template struct FixedString { }; /// Compile-time string table that packs strings into a single blob with offset lookup. +/// Blobs over 255 bytes have no offsets and are scanned, so keep those for rarely read strings. /// Use PROGMEM_STRING_TABLE macro to instantiate with proper flash placement on ESP8266. /// /// Example: @@ -78,6 +80,11 @@ template struct FixedString { template struct ProgmemStringTable { static constexpr size_t COUNT = sizeof...(Strs); static constexpr size_t BLOB_SIZE = (0 + ... + (Strs.size() + 1)); + /// Length of the longest string, for sizing a RAM copy on ESP8266 + static constexpr size_t MAX_LENGTH = std::max({Strs.size()...}); + /// One-byte offsets address up to 255 bytes; a larger blob has no offset table and is scanned instead, + /// which costs less flash than wider offsets for the rarely read tables that get this big. + static constexpr bool INDEXED = BLOB_SIZE <= UINT8_MAX; /// Generate packed string blob at compile time static constexpr auto make_blob() { @@ -91,18 +98,28 @@ template struct ProgmemStringTable { return result; } - /// Generate offset table at compile time (uint8_t limits blob to 255 bytes) + /// Generate offset table at compile time static constexpr auto make_offsets() { static_assert(COUNT > 0, "PROGMEM_STRING_TABLE must contain at least one string"); static_assert(COUNT <= 255, "PROGMEM_STRING_TABLE supports at most 255 strings with uint8_t indices"); - static_assert(BLOB_SIZE <= 255, "PROGMEM_STRING_TABLE blob exceeds 255 bytes; use fewer/shorter strings"); - std::array result{}; - size_t pos = 0, idx = 0; - ((result[idx++] = static_cast(pos), pos += Strs.size() + 1), ...); - return result; + if constexpr (INDEXED) { + std::array result{}; + size_t pos = 0, idx = 0; + ((result[idx++] = static_cast(pos), pos += Strs.size() + 1), ...); + return result; + } else { + return std::array{}; + } } }; +/// Skip idx strings in a packed NUL-separated blob that may live in flash. +inline const char *progmem_skip_strings(const char *blob, uint8_t idx) { + while (idx-- > 0) + blob += ESPHOME_strlen_P(blob) + 1; + return blob; +} + // Forward declaration for LogString (defined in log.h) struct LogString; @@ -115,6 +132,7 @@ struct LogString; static constexpr size_t COUNT = Table::COUNT; \ static constexpr uint8_t LAST_INDEX = COUNT - 1; \ static constexpr size_t BLOB_SIZE = Table::BLOB_SIZE; \ + static constexpr size_t MAX_LENGTH = Table::MAX_LENGTH; \ static constexpr auto BLOB ESPHOME_PROGMEM_STRING_TABLE_STORAGE = Table::make_blob(); \ static constexpr auto OFFSETS ESPHOME_PROGMEM_STRING_TABLE_STORAGE = Table::make_offsets(); \ static const char *get_(uint8_t idx, uint8_t fallback) { \ @@ -122,7 +140,10 @@ struct LogString; idx = fallback; \ /* std::launder is used here to prevent the inter-procedural analysis that */ \ /* causes the false positive that the string is not null terminated */ \ - return std::launder(&BLOB[::esphome::progmem_read_byte(&OFFSETS[idx])]); \ + if constexpr (Table::INDEXED) \ + return std::launder(&BLOB[::esphome::progmem_read_byte(&OFFSETS[idx])]); \ + else \ + return std::launder(::esphome::progmem_skip_strings(BLOB.data(), idx)); \ } \ static ::ProgmemStr get_progmem_str(uint8_t idx, uint8_t fallback) { \ return reinterpret_cast<::ProgmemStr>(get_(idx, fallback)); \ diff --git a/tests/components/core/test_progmem_string_table.cpp b/tests/components/core/test_progmem_string_table.cpp new file mode 100644 index 00000000000..4069bd4d2ab --- /dev/null +++ b/tests/components/core/test_progmem_string_table.cpp @@ -0,0 +1,65 @@ +#include + +#include + +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" + +namespace esphome::core::testing { + +PROGMEM_STRING_TABLE(SmallStrings, "OFF", "HEAT", "COOL", "UNKNOWN"); + +// Eight strings of 40 characters: a 328-byte blob, past what one-byte offsets can address, so it is scanned. +PROGMEM_STRING_TABLE(LargeStrings, "0123456789012345678901234567890123456789", + "1123456789012345678901234567890123456789", "2123456789012345678901234567890123456789", + "3123456789012345678901234567890123456789", "4123456789012345678901234567890123456789", + "5123456789012345678901234567890123456789", "6123456789012345678901234567890123456789", + "7123456789012345678901234567890123456789"); + +// Five 50-character strings pack into exactly 255 bytes; one more byte switches to scanning. +PROGMEM_STRING_TABLE(Exactly255, "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789"); +PROGMEM_STRING_TABLE(Exactly256, "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789", + "01234567890123456789012345678901234567890123456789x"); + +TEST(ProgmemStringTable, ScansInsteadOfIndexingAfter255Bytes) { + static_assert(Exactly255::BLOB_SIZE == 255); + static_assert(Exactly255::Table::INDEXED); + static_assert(Exactly256::BLOB_SIZE == 256); + static_assert(!Exactly256::Table::INDEXED); + static_assert(Exactly256::OFFSETS.empty()); + EXPECT_EQ(Exactly255::OFFSETS[4], 204); + EXPECT_STREQ(reinterpret_cast(Exactly256::get_progmem_str(4, 0)), + "01234567890123456789012345678901234567890123456789x"); +} + +TEST(ProgmemStringTable, SmallTableKeepsOneByteOffsets) { + static_assert(SmallStrings::BLOB_SIZE <= 255); + static_assert(SmallStrings::Table::INDEXED); + static_assert(SmallStrings::MAX_LENGTH == 7); + EXPECT_STREQ(reinterpret_cast(SmallStrings::get_log_str(0, SmallStrings::LAST_INDEX)), "OFF"); + EXPECT_STREQ(reinterpret_cast(SmallStrings::get_log_str(2, SmallStrings::LAST_INDEX)), "COOL"); + EXPECT_STREQ(reinterpret_cast(SmallStrings::get_log_str(200, SmallStrings::LAST_INDEX)), "UNKNOWN"); +} + +TEST(ProgmemStringTable, LargeTableIsScanned) { + static_assert(LargeStrings::BLOB_SIZE > 255); + static_assert(!LargeStrings::Table::INDEXED); + static_assert(LargeStrings::MAX_LENGTH == 40); + EXPECT_STREQ(reinterpret_cast(LargeStrings::get_progmem_str(0, LargeStrings::LAST_INDEX)), + "0123456789012345678901234567890123456789"); + EXPECT_STREQ(reinterpret_cast(LargeStrings::get_progmem_str(7, LargeStrings::LAST_INDEX)), + "7123456789012345678901234567890123456789"); + EXPECT_STREQ(reinterpret_cast(LargeStrings::get_progmem_str(8, LargeStrings::LAST_INDEX)), + "7123456789012345678901234567890123456789"); + EXPECT_STREQ(reinterpret_cast(LargeStrings::get_progmem_str(3, LargeStrings::LAST_INDEX)), + "3123456789012345678901234567890123456789"); +} + +} // namespace esphome::core::testing From e41d16ba7e156315e7eb1217196fbb34332e7b4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 17:23:28 -1000 Subject: [PATCH 077/142] [haier] Keep the alarm message table in flash (#20641) --- esphome/components/haier/hon_climate.cpp | 36 +++++++++++++-- esphome/components/haier/hon_packet.h | 56 ------------------------ 2 files changed, 33 insertions(+), 59 deletions(-) diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index 5e672c3e65d..72b1e26818b 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -3,6 +3,7 @@ #include "esphome/components/climate/climate.h" #include "esphome/components/uart/uart.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" #include "hon_climate.h" #include "hon_packet.h" @@ -20,6 +21,27 @@ constexpr size_t ALARM_STATUS_REQUEST_INTERVAL_MS = 600000; const uint8_t ONE_BUF[] = {0x00, 0x01}; const uint8_t ZERO_BUF[] = {0x00, 0x00}; +// Alarm texts indexed by alarm code; the last entry is the fallback for unknown codes +PROGMEM_STRING_TABLE( + HonAlarmMessages, "Outdoor module failure", "Outdoor defrost sensor failure", + "Outdoor compressor exhaust sensor failure", "Outdoor EEPROM abnormality", "Indoor coil sensor failure", + "Indoor-outdoor communication failure", "Power supply overvoltage protection", + "Communication failure between panel and indoor unit", "Outdoor compressor overheat protection", + "Outdoor environmental sensor abnormality", "Full water protection", "Indoor EEPROM failure", + "Outdoor out air sensor failure", "CBD and module communication failure", "Indoor DC fan failure", + "Outdoor DC fan failure", "Door switch failure", "Dust filter needs cleaning reminder", "Water shortage protection", + "Humidity sensor failure", "Indoor temperature sensor failure", "Manipulator limit failure", + "Indoor PM2.5 sensor failure", "Outdoor PM2.5 sensor failure", "Indoor heating overload/high load alarm", + "Outdoor AC current protection", "Outdoor compressor operation abnormality", "Outdoor DC current protection", + "Outdoor no-load failure", "CT current abnormality", "Indoor cooling freeze protection", + "High and low pressure protection", "Compressor out air temperature is too high", + "Outdoor evaporator sensor failure", "Outdoor cooling overload", "Water pump drainage failure", + "Three-phase power supply failure", "Four-way valve failure", "External alarm/scraper flow switch failure", + "Temperature cutoff protection alarm", "Different mode operation failure", "Electronic expansion valve failure", + "Dual heat source sensor Tw failure", "Communication failure with the wired controller", + "Indoor unit address duplication failure", "50Hz zero crossing failure", "Outdoor unit failure", + "Formaldehyde sensor failure", "VOC sensor failure", "CO2 sensor failure", "Firewall failure", "Unknown"); + HonClimate::HonClimate() : cleaning_status_(CleaningState::NO_CLEANING), got_valid_outdoor_temp_(false), active_alarms_{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, @@ -687,9 +709,17 @@ void HonClimate::process_alarm_message_(const uint8_t *packet, uint8_t size, boo if ((packet[2 + i] & alarm_bit) != (this->active_alarms_[i] & alarm_bit)) { bool alarm_status = (packet[2 + i] & alarm_bit) != 0; int log_level = alarm_status ? ESPHOME_LOG_LEVEL_WARN : ESPHOME_LOG_LEVEL_INFO; - const char *alarm_message = alarm_code < esphome::haier::hon_protocol::HON_ALARM_COUNT - ? esphome::haier::hon_protocol::HON_ALARM_MESSAGES[alarm_code].c_str() - : "Unknown"; +#ifdef USE_ESP8266 + // Static so a delayed on_alarm automation can still read it, but only until the next + // alarm change, including a later one in this packet; `code` stays correct + static char alarm_message[HonAlarmMessages::MAX_LENGTH + 1]; + ESPHOME_strncpy_P(alarm_message, + reinterpret_cast( + HonAlarmMessages::get_progmem_str(alarm_code, HonAlarmMessages::LAST_INDEX)), + sizeof(alarm_message)); +#else + const char *alarm_message = HonAlarmMessages::get_progmem_str(alarm_code, HonAlarmMessages::LAST_INDEX); +#endif esp_log_printf_(log_level, TAG, __LINE__, "Alarm %s (%d): %s", alarm_status ? "activated" : "deactivated", alarm_code, alarm_message); if (alarm_status) { diff --git a/esphome/components/haier/hon_packet.h b/esphome/components/haier/hon_packet.h index 63799f04ba1..1d17acb868a 100644 --- a/esphome/components/haier/hon_packet.h +++ b/esphome/components/haier/hon_packet.h @@ -197,60 +197,4 @@ enum class SubcommandsControl : uint16_t { // content: all values like in status packet) }; -const std::string HON_ALARM_MESSAGES[] = { - "Outdoor module failure", - "Outdoor defrost sensor failure", - "Outdoor compressor exhaust sensor failure", - "Outdoor EEPROM abnormality", - "Indoor coil sensor failure", - "Indoor-outdoor communication failure", - "Power supply overvoltage protection", - "Communication failure between panel and indoor unit", - "Outdoor compressor overheat protection", - "Outdoor environmental sensor abnormality", - "Full water protection", - "Indoor EEPROM failure", - "Outdoor out air sensor failure", - "CBD and module communication failure", - "Indoor DC fan failure", - "Outdoor DC fan failure", - "Door switch failure", - "Dust filter needs cleaning reminder", - "Water shortage protection", - "Humidity sensor failure", - "Indoor temperature sensor failure", - "Manipulator limit failure", - "Indoor PM2.5 sensor failure", - "Outdoor PM2.5 sensor failure", - "Indoor heating overload/high load alarm", - "Outdoor AC current protection", - "Outdoor compressor operation abnormality", - "Outdoor DC current protection", - "Outdoor no-load failure", - "CT current abnormality", - "Indoor cooling freeze protection", - "High and low pressure protection", - "Compressor out air temperature is too high", - "Outdoor evaporator sensor failure", - "Outdoor cooling overload", - "Water pump drainage failure", - "Three-phase power supply failure", - "Four-way valve failure", - "External alarm/scraper flow switch failure", - "Temperature cutoff protection alarm", - "Different mode operation failure", - "Electronic expansion valve failure", - "Dual heat source sensor Tw failure", - "Communication failure with the wired controller", - "Indoor unit address duplication failure", - "50Hz zero crossing failure", - "Outdoor unit failure", - "Formaldehyde sensor failure", - "VOC sensor failure", - "CO2 sensor failure", - "Firewall failure", -}; - -constexpr size_t HON_ALARM_COUNT = sizeof(HON_ALARM_MESSAGES) / sizeof(HON_ALARM_MESSAGES[0]); - } // namespace esphome::haier::hon_protocol From c1fa73470e7c4cc6409da6bb97ce6b20a8c34ea5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 17:31:13 -1000 Subject: [PATCH 078/142] [display] Walk the blit fallback in frame buffer order and resolve the bitness once (#20651) --- esphome/components/display/display.cpp | 80 ++++++++----- esphome/components/display/display.h | 11 ++ .../components/display/display_color_utils.h | 22 ++++ esphome/components/epaper_spi/epaper_spi.h | 1 + esphome/components/it8951/it8951.h | 1 + esphome/components/mipi_spi/mipi_spi.h | 18 +-- tests/components/display/__init__.py | 20 ++++ .../display/test_draw_pixels_at.cpp | 109 ++++++++++++++++++ 8 files changed, 222 insertions(+), 40 deletions(-) create mode 100644 tests/components/display/__init__.py create mode 100644 tests/components/display/test_draw_pixels_at.cpp diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index 0bd8346953c..f006c64be76 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -5,6 +5,7 @@ #include "display_color_utils.h" #include "esphome/core/application.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::display { @@ -52,40 +53,65 @@ void Display::line_at_angle(int x, int y, int angle, int start_radius, int stop_ this->line(x1, y1, x2, y2, color); } -void Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, - ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { - size_t line_stride = x_offset + w + x_pad; // length of each source line in pixels - uint32_t color_value; +// flatten keeps to_color() inlined in the pixel loop; in PSRAM builds GCC at +// -Os stops inlining it once there are two call sites, which costs a call and +// spills per pixel. Without PSRAM there is one call site and it changes +// nothing, and draw_pixel_at() stays a virtual call either way. +template +static void __attribute__((flatten)) +draw_packed_pixels(Display *display, int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + bool big_endian, int x_offset, int y_offset, int x_pad) { + const size_t line_stride = x_offset + w + x_pad; // length of each source line in pixels + // BITNESS is a constant here, so to_color()'s three per pixel divisions + // become a multiply and shift and its bitness switch folds. + const auto draw_source_pixel = [&](int x, int y, size_t source_idx) ESPHOME_ALWAYS_INLINE { + display->draw_pixel_at( + x + x_start, y + y_start, + ColorUtil::to_color(ColorUtil::read_packed(ptr, source_idx, big_endian), order, BITNESS)); + }; + // The source and frame buffers are row-major, so walking rows keeps the read + // and the write sequential. When the display swaps the axes before writing, + // walking columns keeps the write sequential instead; with the frame buffer + // in PSRAM a strided write costs more than a strided read. +#ifdef USE_PSRAM + if (display->pixel_axes_swapped()) { + size_t column_idx = y_offset * line_stride + x_offset; + for (int x = 0; x != w; x++, column_idx++) { + size_t source_idx = column_idx; + for (int y = 0; y != h; y++, source_idx += line_stride) { + draw_source_pixel(x, y, source_idx); + } + } + return; + } +#endif + // Row walk: builds without PSRAM, and PSRAM builds whose axes are not swapped. for (int y = 0; y != h; y++) { size_t source_idx = (y_offset + y) * line_stride + x_offset; - size_t source_idx_mod; for (int x = 0; x != w; x++, source_idx++) { - switch (bitness) { - default: - color_value = ptr[source_idx]; - break; - case COLOR_BITNESS_565: - source_idx_mod = source_idx * 2; - if (big_endian) { - color_value = (ptr[source_idx_mod] << 8) + ptr[source_idx_mod + 1]; - } else { - color_value = ptr[source_idx_mod] + (ptr[source_idx_mod + 1] << 8); - } - break; - case COLOR_BITNESS_888: - source_idx_mod = source_idx * 3; - if (big_endian) { - color_value = (ptr[source_idx_mod + 0] << 16) + (ptr[source_idx_mod + 1] << 8) + ptr[source_idx_mod + 2]; - } else { - color_value = ptr[source_idx_mod + 0] + (ptr[source_idx_mod + 1] << 8) + (ptr[source_idx_mod + 2] << 16); - } - break; - } - this->draw_pixel_at(x + x_start, y + y_start, ColorUtil::to_color(color_value, order, bitness)); + draw_source_pixel(x, y, source_idx); } } } +void Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + switch (bitness) { + case COLOR_BITNESS_565: + draw_packed_pixels(this, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset, + x_pad); + break; + case COLOR_BITNESS_888: + draw_packed_pixels(this, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset, + x_pad); + break; + default: + draw_packed_pixels(this, x_start, y_start, w, h, ptr, order, big_endian, x_offset, y_offset, + x_pad); + break; + } +} + void HOT Display::horizontal_line(int x, int y, int width, Color color) { // Future: Could be made more efficient by manipulating buffer directly in certain rotations. for (int i = x; i < x + width; i++) diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index 7d1053f4fa7..1a45c4d6750 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -139,6 +139,11 @@ enum DisplayRotation { DISPLAY_ROTATION_270_DEGREES = 270, }; +/// Whether a rotation swaps the x and y axes. +constexpr bool rotation_swaps_axes(DisplayRotation rotation) { + return rotation == DISPLAY_ROTATION_90_DEGREES || rotation == DISPLAY_ROTATION_270_DEGREES; +} + const int EDGES_TRIGON = 3; const int EDGES_TRIANGLE = 3; const int EDGES_TETRAGON = 4; @@ -717,6 +722,12 @@ class Display : public PollingComponent { virtual DisplayRotation get_rotation() const { return this->rotation_; } + /// True when draw_pixel_at() swaps x and y before writing, so pixels that are + /// neighbours along x land a row apart in the frame buffer. Not the same as + /// get_width()/get_height() swapping: a display that rotates in hardware + /// reports swapped dimensions but writes unswapped pixels. + virtual bool pixel_axes_swapped() const { return rotation_swaps_axes(this->rotation_); } + /** Get the type of display that the buffer corresponds to. In case of dynamically configurable displays, * returns the type the display is currently configured to. */ diff --git a/esphome/components/display/display_color_utils.h b/esphome/components/display/display_color_utils.h index be39e2a3e2f..3cf4a54999e 100644 --- a/esphome/components/display/display_color_utils.h +++ b/esphome/components/display/display_color_utils.h @@ -2,6 +2,7 @@ #include #include #include "esphome/core/color.h" +#include "esphome/core/helpers.h" namespace esphome::display { enum ColorOrder : uint8_t { COLOR_ORDER_RGB = 0, COLOR_ORDER_BGR = 1, COLOR_ORDER_GRB = 2 }; @@ -10,6 +11,27 @@ inline static uint8_t esp_scale(uint8_t i, uint8_t scale, uint8_t max_value = 25 class ColorUtil { public: + /// Reads one packed pixel of the given bitness from a source buffer into the + /// value to_color() takes. BITNESS is a template argument so the byte layout + /// folds; pass it on to to_color() as a constant too, and its channel scaling + /// divides by a constant, which the compiler turns into a multiply and shift. + template + static ESPHOME_ALWAYS_INLINE uint32_t read_packed(const uint8_t *ptr, size_t index, bool big_endian) { + if constexpr (BITNESS == COLOR_BITNESS_565) { + const size_t idx = index * 2; + if (big_endian) + return (ptr[idx] << 8) + ptr[idx + 1]; + return ptr[idx] + (ptr[idx + 1] << 8); + } else if constexpr (BITNESS == COLOR_BITNESS_888) { + const size_t idx = index * 3; + if (big_endian) + return (ptr[idx + 0] << 16) + (ptr[idx + 1] << 8) + ptr[idx + 2]; + return ptr[idx + 0] + (ptr[idx + 1] << 8) + (ptr[idx + 2] << 16); + } else { + return ptr[index]; + } + } + static Color to_color(uint32_t colorcode, ColorOrder color_order, ColorBitness color_bitness = ColorBitness::COLOR_BITNESS_888, bool right_bit_aligned = true) { uint8_t first_color, second_color, third_color; diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 7f1167ae6a4..384b9cb0181 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -106,6 +106,7 @@ class EPaperBase : public Display, int get_width() override { return this->effective_transform_ & SWAP_XY ? this->height_ : this->width_; } int get_height() override { return this->effective_transform_ & SWAP_XY ? this->width_ : this->height_; } + bool pixel_axes_swapped() const override { return this->effective_transform_ & SWAP_XY; } void draw_pixel_at(int x, int y, Color color) override; void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; diff --git a/esphome/components/it8951/it8951.h b/esphome/components/it8951/it8951.h index e1273587c45..e99e905ebf5 100644 --- a/esphome/components/it8951/it8951.h +++ b/esphome/components/it8951/it8951.h @@ -204,6 +204,7 @@ class IT8951Display : public Display, ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; int get_width() override { return (this->effective_transform_ & TRANSFORM_SWAP_XY) ? this->height_ : this->width_; } int get_height() override { return (this->effective_transform_ & TRANSFORM_SWAP_XY) ? this->width_ : this->height_; } + bool pixel_axes_swapped() const override { return this->effective_transform_ & TRANSFORM_SWAP_XY; } protected: int get_height_internal() override { return this->height_; } diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 96f26ef19b7..3a772862f31 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -130,19 +130,8 @@ class MipiSpi : public display::Display, } display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } - int get_width() override { - if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || - this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) - return HEIGHT; - return WIDTH; - } - - int get_height() override { - if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || - this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) - return WIDTH; - return HEIGHT; - } + int get_width() override { return display::rotation_swaps_axes(this->rotation_) ? HEIGHT : WIDTH; } + int get_height() override { return display::rotation_swaps_axes(this->rotation_) ? WIDTH : HEIGHT; } void set_init_sequence(const uint8_t *sequence, size_t len) { this->init_sequence_ = sequence; @@ -621,6 +610,9 @@ class MipiSpiBuffer #endif } + // With hardware rotation the buffer is written in drawn coordinates. + bool pixel_axes_swapped() const override { return !HAS_HARDWARE_ROTATION && display::Display::pixel_axes_swapped(); } + // Draw a pixel at the given coordinates. void draw_pixel_at(int x, int y, Color color) override { if (this->is_point_clipped(x, y)) diff --git a/tests/components/display/__init__.py b/tests/components/display/__init__.py new file mode 100644 index 00000000000..db75e3e6360 --- /dev/null +++ b/tests/components/display/__init__.py @@ -0,0 +1,20 @@ +from pathlib import Path + +import esphome.codegen as cg +from esphome.const import CONF_ESPHOME, CONF_INCLUDES +from esphome.core import CORE +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + +TESTS_DIR = Path(__file__).parent + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The column walk in Display::draw_pixels_at() is only built with PSRAM; define it + # when display's own tests are built so both walk orders run, and leave other + # components' test binaries on the plain build. + async def to_code_testing(config: ConfigType) -> None: + if any(Path(p) == TESTS_DIR for p in CORE.config[CONF_ESPHOME][CONF_INCLUDES]): + cg.add_define("USE_PSRAM") + + manifest.to_code = to_code_testing diff --git a/tests/components/display/test_draw_pixels_at.cpp b/tests/components/display/test_draw_pixels_at.cpp new file mode 100644 index 00000000000..c8c32d15145 --- /dev/null +++ b/tests/components/display/test_draw_pixels_at.cpp @@ -0,0 +1,109 @@ +#include + +#include +#include +#include +#include + +#include "esphome/components/display/display.h" + +namespace esphome::display::testing { + +// Records every pixel write; pixel_axes_swapped() picks the walk order. +class RecordingDisplay : public Display { + public: + void set_axes_swapped(bool swapped) { this->axes_swapped_ = swapped; } + bool pixel_axes_swapped() const override { return this->axes_swapped_; } + void draw_pixel_at(int x, int y, Color color) override { this->pixels_.emplace_back(x, y, color.raw_32); } + void update() override {} + DisplayType get_display_type() override { return DisplayType::DISPLAY_TYPE_COLOR; } + int get_width_internal() override { return 64; } + int get_height_internal() override { return 64; } + + std::vector> sorted_pixels() const { + auto sorted = this->pixels_; + std::sort(sorted.begin(), sorted.end()); + return sorted; + } + size_t count() const { return this->pixels_.size(); } + const std::vector> &pixels() const { return this->pixels_; } + + protected: + bool axes_swapped_{false}; + std::vector> pixels_; +}; + +// A 7x5 region with a 2 pixel left offset, a 3 line top offset and 4 pixels of +// right padding, so the source index arithmetic of both walks is exercised. +static constexpr int W = 7, H = 5, X_OFFSET = 2, Y_OFFSET = 3, X_PAD = 4; +static constexpr int LINE_STRIDE = X_OFFSET + W + X_PAD; +static constexpr int LINES = Y_OFFSET + H; + +static std::vector make_source(int bytes_per_pixel) { + std::vector source(LINE_STRIDE * LINES * bytes_per_pixel); + uint8_t value = 1; + for (auto &byte : source) + byte = value++; + return source; +} + +static void expect_walks_match(ColorBitness bitness, int bytes_per_pixel, bool big_endian) { + const auto source = make_source(bytes_per_pixel); + RecordingDisplay rows; + RecordingDisplay columns; + columns.set_axes_swapped(true); + rows.draw_pixels_at(10, 20, W, H, source.data(), COLOR_ORDER_RGB, bitness, big_endian, X_OFFSET, Y_OFFSET, X_PAD); + columns.draw_pixels_at(10, 20, W, H, source.data(), COLOR_ORDER_RGB, bitness, big_endian, X_OFFSET, Y_OFFSET, X_PAD); + EXPECT_EQ(rows.count(), static_cast(W * H)); + EXPECT_EQ(columns.count(), static_cast(W * H)); + EXPECT_EQ(rows.sorted_pixels(), columns.sorted_pixels()); +} + +TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk332) { expect_walks_match(COLOR_BITNESS_332, 1, true); } +TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk565BigEndian) { expect_walks_match(COLOR_BITNESS_565, 2, true); } +TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk565LittleEndian) { expect_walks_match(COLOR_BITNESS_565, 2, false); } +TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk888BigEndian) { expect_walks_match(COLOR_BITNESS_888, 3, true); } +TEST(DrawPixelsAt, ColumnWalkMatchesRowWalk888LittleEndian) { expect_walks_match(COLOR_BITNESS_888, 3, false); } + +// One known pixel per bitness and byte order, so a decode that swapped bytes or channels +// could not pass the walk comparison above by being wrong the same way twice. +static void expect_golden(ColorBitness bitness, std::initializer_list bytes, bool big_endian, Color expected) { + const std::vector source(bytes); + RecordingDisplay display; + display.draw_pixels_at(3, 4, 1, 1, source.data(), COLOR_ORDER_RGB, bitness, big_endian, 0, 0, 0); + ASSERT_EQ(display.count(), 1u); + EXPECT_EQ(display.pixels()[0], std::make_tuple(3, 4, expected.raw_32)); +} + +TEST(DrawPixelsAt, Golden565BigEndian) { + expect_golden(COLOR_BITNESS_565, {0xE0, 0x07}, true, Color(230, 0, 57)); // 0xE007: r 28, g 0, b 7 +} +TEST(DrawPixelsAt, Golden565LittleEndian) { + expect_golden(COLOR_BITNESS_565, {0xE0, 0x07}, false, Color(0, 255, 0)); // 0x07E0: pure green +} +TEST(DrawPixelsAt, Golden888BigEndian) { + expect_golden(COLOR_BITNESS_888, {0x10, 0x20, 0x30}, true, Color(0x10, 0x20, 0x30)); +} +TEST(DrawPixelsAt, Golden888LittleEndian) { + expect_golden(COLOR_BITNESS_888, {0x10, 0x20, 0x30}, false, Color(0x30, 0x20, 0x10)); +} +TEST(DrawPixelsAt, Golden332) { + expect_golden(COLOR_BITNESS_332, {0xE3}, true, Color(255, 0, 255)); // r 7 of 7, g 0, b 3 of 3 +} + +// The column walk really is a different order, otherwise the tests above prove nothing. +TEST(DrawPixelsAt, ColumnWalkChangesOrder) { + const auto source = make_source(1); + RecordingDisplay rows; + RecordingDisplay columns; + columns.set_axes_swapped(true); + rows.draw_pixels_at(0, 0, W, H, source.data(), COLOR_ORDER_RGB, COLOR_BITNESS_332, true, X_OFFSET, Y_OFFSET, X_PAD); + columns.draw_pixels_at(0, 0, W, H, source.data(), COLOR_ORDER_RGB, COLOR_BITNESS_332, true, X_OFFSET, Y_OFFSET, + X_PAD); + ASSERT_GT(rows.count(), 1u); + ASSERT_GT(columns.count(), 1u); + EXPECT_EQ(std::get<0>(rows.pixels()[1]), 1); // the row walk steps x first + EXPECT_EQ(std::get<1>(columns.pixels()[1]), 1); // the column walk steps y first +} + +} // namespace esphome::display::testing From 887d4c3a8337d4c62c391b90176fe97b9b1ee798 Mon Sep 17 00:00:00 2001 From: Ryan Ludwig <20423360+steamEngineer@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:36:15 -0700 Subject: [PATCH 079/142] [esp32_can] Add NO_ACK mode (#20706) Co-authored-by: Cursor --- esphome/components/esp32_can/canbus.py | 1 + esphome/components/esp32_can/esp32_can.cpp | 7 +++++-- esphome/components/esp32_can/esp32_can.h | 1 + tests/components/esp32_can/common.yaml | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/esphome/components/esp32_can/canbus.py b/esphome/components/esp32_can/canbus.py index ae299370cd7..f8f76b2d578 100644 --- a/esphome/components/esp32_can/canbus.py +++ b/esphome/components/esp32_can/canbus.py @@ -47,6 +47,7 @@ CanMode = esp32_can_ns.enum("CanMode") CAN_MODES = { "NORMAL": CanMode.CAN_MODE_NORMAL, "LISTENONLY": CanMode.CAN_MODE_LISTEN_ONLY, + "NO_ACK": CanMode.CAN_MODE_NO_ACK, } # Currently the driver only supports a subset of the bit rates defined in canbus diff --git a/esphome/components/esp32_can/esp32_can.cpp b/esphome/components/esp32_can/esp32_can.cpp index d84b58c5b0c..ee677a08005 100644 --- a/esphome/components/esp32_can/esp32_can.cpp +++ b/esphome/components/esp32_can/esp32_can.cpp @@ -79,10 +79,13 @@ bool ESP32Can::setup_internal() { } // Select TWAI mode based on configuration - twai_mode_t twai_mode = (this->mode_ == CAN_MODE_LISTEN_ONLY) ? TWAI_MODE_LISTEN_ONLY : TWAI_MODE_NORMAL; - + twai_mode_t twai_mode = TWAI_MODE_NORMAL; if (this->mode_ == CAN_MODE_LISTEN_ONLY) { + twai_mode = TWAI_MODE_LISTEN_ONLY; ESP_LOGI(TAG, "CAN bus configured in LISTEN_ONLY mode (passive, no ACKs)"); + } else if (this->mode_ == CAN_MODE_NO_ACK) { + twai_mode = TWAI_MODE_NO_ACK; + ESP_LOGI(TAG, "CAN bus configured in NO_ACK mode (transmit without requiring ACK)"); } twai_general_config_t g_config = diff --git a/esphome/components/esp32_can/esp32_can.h b/esphome/components/esp32_can/esp32_can.h index f9549bea346..176a3b57f5e 100644 --- a/esphome/components/esp32_can/esp32_can.h +++ b/esphome/components/esp32_can/esp32_can.h @@ -12,6 +12,7 @@ namespace esphome::esp32_can { enum CanMode : uint8_t { CAN_MODE_NORMAL = 0, CAN_MODE_LISTEN_ONLY = 1, + CAN_MODE_NO_ACK = 2, }; class ESP32Can final : public canbus::Canbus { diff --git a/tests/components/esp32_can/common.yaml b/tests/components/esp32_can/common.yaml index a26c5d35a34..01b208995df 100644 --- a/tests/components/esp32_can/common.yaml +++ b/tests/components/esp32_can/common.yaml @@ -18,7 +18,7 @@ canbus: tx_pin: ${tx_pin} can_id: 4 bit_rate: 50kbps - mode: NORMAL + mode: NO_ACK on_frame: - can_id: 500 then: From 60a1d70805b4cc810928ea4174af3c7b3bcc8fde Mon Sep 17 00:00:00 2001 From: Dmitrii Kuminov Date: Tue, 13 Oct 2026 20:46:54 -0700 Subject: [PATCH 080/142] [core] Move deferred include processing to parse_yaml (#16344) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/yaml_util.py | 14 ++++++++++---- tests/unit_tests/test_yaml_util.py | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index b5e2ec17e8a..261d8b50e36 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -1120,8 +1120,12 @@ def _load_yaml_internal(fname: Path, *, track_document_range: bool = True) -> An res = parse_yaml(fname, f_handle, track_document_range=track_document_range) except (UnicodeDecodeError, OSError) as err: raise EsphomeError(f"Error reading file {fname}: {err}") from err - # Top-level !include returns a deferred IncludeFile; resolve it so - # callers always receive the final content. + return res + + +def _resolve_include(res: Any) -> Any: + # A document that is just `!include` parses to a deferred IncludeFile; resolve it so + # every parse_yaml caller, including the VS Code and ACE loaders, gets the final content. if isinstance(res, IncludeFile): res = res.load() if res.should_load() else {} return res @@ -1147,7 +1151,7 @@ def parse_yaml( # tracked mode; reject the combination instead of half-applying it. raise ValueError("track_document_range=False requires the default yaml_loader") try: - return _load_yaml_internal_with_type( + res = _load_yaml_internal_with_type( ESPHomeLoader, file_name, file_handle, @@ -1159,13 +1163,15 @@ def parse_yaml( # readable exceptions # Rewind the stream so we can try again file_handle.seek(0, 0) - return _load_yaml_internal_with_type( + res = _load_yaml_internal_with_type( ESPHomePurePythonLoader, file_name, file_handle, yaml_loader, track_document_range=track_document_range, ) + # Resolved after the fallback so a failed first parse never loads the include twice + return _resolve_include(res) def _load_yaml_internal_with_type( diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index c3276823ecc..655fac6bf26 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -954,6 +954,23 @@ def test_yaml_merge_chain_include_resolves() -> None: assert config["base"]["existing"] == "value" +def test_parse_yaml_custom_loader_resolves_root_include() -> None: + """A document that is only `!include` is resolved for a custom loader too.""" + parent = Path("/fake/main.yaml") + loader_calls: list[Path] = [] + + def custom_loader(fname: Path) -> dict[str, int]: + loader_calls.append(fname) + return {"x": 1} + + config = yaml_util.parse_yaml( + parent, io.StringIO("!include inner.yaml\n"), custom_loader + ) + + assert config == {"x": 1} + assert loader_calls == [Path("/fake/inner.yaml")] + + def test_yaml_merge_chain_include_depth_exceeded() -> None: """Chain includes in merge keys exceeding depth limit raise a clear error.""" parent = Path("/fake/main.yaml") From 1338902ac5e5f098801e01d79f42d9d0e1422b26 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 18:06:22 -1000 Subject: [PATCH 081/142] [heatpumpir] Link only the configured protocol classes (#20707) --- esphome/components/heatpumpir/climate.py | 4 +- esphome/components/heatpumpir/heatpumpir.cpp | 132 ++++++++++--------- esphome/components/heatpumpir/heatpumpir.h | 32 +++-- 3 files changed, 90 insertions(+), 78 deletions(-) diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index 2583839ca81..42ef79d31d2 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -126,8 +126,8 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - var = await climate_ir.new_climate_ir(config) - cg.add(var.set_protocol(config[CONF_PROTOCOL])) + tag = heatpumpir_ns.ProtocolTag.template(config[CONF_PROTOCOL]) + var = await climate_ir.new_climate_ir(config, tag()) cg.add(var.set_horizontal_default(config[CONF_HORIZONTAL_DEFAULT])) cg.add(var.set_vertical_default(config[CONF_VERTICAL_DEFAULT])) cg.add(var.set_max_temperature(config[CONF_MAX_TEMPERATURE])) diff --git a/esphome/components/heatpumpir/heatpumpir.cpp b/esphome/components/heatpumpir/heatpumpir.cpp index b4597fe4abf..a23d69e3b22 100644 --- a/esphome/components/heatpumpir/heatpumpir.cpp +++ b/esphome/components/heatpumpir/heatpumpir.cpp @@ -5,7 +5,6 @@ #if defined(USE_ARDUINO) || defined(USE_ESP32) #include -#include #include #include #include "esphome/components/remote_base/remote_base.h" @@ -45,71 +44,76 @@ class IRSenderESPHome : public IRSender { ESPHOME_LOG_TAG(TAG, "heatpumpir.climate"); -const std::map> PROTOCOL_CONSTRUCTOR_MAP = { - {PROTOCOL_AUX, []() { return new AUXHeatpumpIR(); }}, // NOLINT - {PROTOCOL_BALLU, []() { return new BalluHeatpumpIR(); }}, // NOLINT - {PROTOCOL_CARRIER_MCA, []() { return new CarrierMCAHeatpumpIR(); }}, // NOLINT - {PROTOCOL_CARRIER_NQV, []() { return new CarrierNQVHeatpumpIR(); }}, // NOLINT - {PROTOCOL_DAIKIN_ARC417, []() { return new DaikinHeatpumpARC417IR(); }}, // NOLINT - {PROTOCOL_DAIKIN_ARC480, []() { return new DaikinHeatpumpARC480A14IR(); }}, // NOLINT - {PROTOCOL_DAIKIN, []() { return new DaikinHeatpumpIR(); }}, // NOLINT - {PROTOCOL_ELECTROLUXYAL, []() { return new ElectroluxYALHeatpumpIR(); }}, // NOLINT - {PROTOCOL_FUEGO, []() { return new FuegoHeatpumpIR(); }}, // NOLINT - {PROTOCOL_FUJITSU_AWYZ, []() { return new FujitsuHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREE, []() { return new GreeGenericHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREEYAA, []() { return new GreeYAAHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREEYAN, []() { return new GreeYANHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREEYAC, []() { return new GreeYACHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREEYT, []() { return new GreeYTHeatpumpIR(); }}, // NOLINT - {PROTOCOL_GREEYAP, []() { return new GreeYAPHeatpumpIR(); }}, // NOLINT - {PROTOCOL_HISENSE_AUD, []() { return new HisenseHeatpumpIR(); }}, // NOLINT - {PROTOCOL_HITACHI, []() { return new HitachiHeatpumpIR(); }}, // NOLINT - {PROTOCOL_HYUNDAI, []() { return new HyundaiHeatpumpIR(); }}, // NOLINT - {PROTOCOL_IVT, []() { return new IVTHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MIDEA, []() { return new MideaHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_FA, []() { return new MitsubishiFAHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_FD, []() { return new MitsubishiFDHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_FE, []() { return new MitsubishiFEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_HEAVY_FDTC, []() { return new MitsubishiHeavyFDTCHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_HEAVY_ZJ, []() { return new MitsubishiHeavyZJHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_HEAVY_ZM, []() { return new MitsubishiHeavyZMHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_HEAVY_ZMP, []() { return new MitsubishiHeavyZMPHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_KJ, []() { return new MitsubishiKJHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_MSC, []() { return new MitsubishiMSCHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_MSY, []() { return new MitsubishiMSYHeatpumpIR(); }}, // NOLINT - {PROTOCOL_MITSUBISHI_SEZ, []() { return new MitsubishiSEZKDXXHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_CKP, []() { return new PanasonicCKPHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_DKE, []() { return new PanasonicDKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_EKE, []() { return new PanasonicEKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_JKE, []() { return new PanasonicJKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_LKE, []() { return new PanasonicLKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_NKE, []() { return new PanasonicNKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_SAMSUNG_AQV, []() { return new SamsungAQVHeatpumpIR(); }}, // NOLINT - {PROTOCOL_SAMSUNG_FJM, []() { return new SamsungFJMHeatpumpIR(); }}, // NOLINT - {PROTOCOL_SHARP, []() { return new SharpHeatpumpIR(); }}, // NOLINT - {PROTOCOL_TOSHIBA_DAISEIKAI, []() { return new ToshibaDaiseikaiHeatpumpIR(); }}, // NOLINT - {PROTOCOL_TOSHIBA, []() { return new ToshibaHeatpumpIR(); }}, // NOLINT - {PROTOCOL_ZHLT01, []() { return new ZHLT01HeatpumpIR(); }}, // NOLINT - {PROTOCOL_NIBE, []() { return new NibeHeatpumpIR(); }}, // NOLINT - {PROTOCOL_QLIMA_1, []() { return new Qlima1HeatpumpIR(); }}, // NOLINT - {PROTOCOL_QLIMA_2, []() { return new Qlima2HeatpumpIR(); }}, // NOLINT - {PROTOCOL_SAMSUNG_AQV12MSAN, []() { return new SamsungAQV12MSANHeatpumpIR(); }}, // NOLINT - {PROTOCOL_ZHJG01, []() { return new ZHJG01HeatpumpIR(); }}, // NOLINT - {PROTOCOL_AIRWAY, []() { return new AIRWAYHeatpumpIR(); }}, // NOLINT - {PROTOCOL_BGH_AUD, []() { return new BGHHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PANASONIC_ALTDKE, []() { return new PanasonicAltDKEHeatpumpIR(); }}, // NOLINT - {PROTOCOL_PHILCO_PHS32, []() { return new PhilcoPHS32HeatpumpIR(); }}, // NOLINT - {PROTOCOL_VAILLANTVAI8, []() { return new VaillantHeatpumpIR(); }}, // NOLINT - {PROTOCOL_R51M, []() { return new R51MHeatpumpIR(); }}, // NOLINT -}; +template struct ProtocolClass; + +template HeatpumpIR *create_heatpump_ir() { + return new typename ProtocolClass

::type(); // NOLINT +} + +// Codegen calls only the configured protocols, so the linker drops the other classes +#define HEATPUMPIR_PROTOCOL(protocol, cls) \ + template<> struct ProtocolClass { using type = cls; }; \ + template HeatpumpIR *create_heatpump_ir(); + +HEATPUMPIR_PROTOCOL(PROTOCOL_AUX, AUXHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_BALLU, BalluHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_CARRIER_MCA, CarrierMCAHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_CARRIER_NQV, CarrierNQVHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_DAIKIN_ARC417, DaikinHeatpumpARC417IR) +HEATPUMPIR_PROTOCOL(PROTOCOL_DAIKIN_ARC480, DaikinHeatpumpARC480A14IR) +HEATPUMPIR_PROTOCOL(PROTOCOL_DAIKIN, DaikinHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_ELECTROLUXYAL, ElectroluxYALHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_FUEGO, FuegoHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_FUJITSU_AWYZ, FujitsuHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREE, GreeGenericHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYAA, GreeYAAHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYAN, GreeYANHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYAC, GreeYACHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYT, GreeYTHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_GREEYAP, GreeYAPHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_HISENSE_AUD, HisenseHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_HITACHI, HitachiHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_HYUNDAI, HyundaiHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_IVT, IVTHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MIDEA, MideaHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_FA, MitsubishiFAHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_FD, MitsubishiFDHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_FE, MitsubishiFEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_HEAVY_FDTC, MitsubishiHeavyFDTCHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_HEAVY_ZJ, MitsubishiHeavyZJHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_HEAVY_ZM, MitsubishiHeavyZMHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_HEAVY_ZMP, MitsubishiHeavyZMPHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_KJ, MitsubishiKJHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_MSC, MitsubishiMSCHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_MSY, MitsubishiMSYHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_MITSUBISHI_SEZ, MitsubishiSEZKDXXHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_CKP, PanasonicCKPHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_DKE, PanasonicDKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_EKE, PanasonicEKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_JKE, PanasonicJKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_LKE, PanasonicLKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_NKE, PanasonicNKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_SAMSUNG_AQV, SamsungAQVHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_SAMSUNG_FJM, SamsungFJMHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_SHARP, SharpHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_TOSHIBA_DAISEIKAI, ToshibaDaiseikaiHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_TOSHIBA, ToshibaHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_ZHLT01, ZHLT01HeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_NIBE, NibeHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_QLIMA_1, Qlima1HeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_QLIMA_2, Qlima2HeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_SAMSUNG_AQV12MSAN, SamsungAQV12MSANHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_ZHJG01, ZHJG01HeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_AIRWAY, AIRWAYHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_BGH_AUD, BGHHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PANASONIC_ALTDKE, PanasonicAltDKEHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_PHILCO_PHS32, PhilcoPHS32HeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_VAILLANTVAI8, VaillantHeatpumpIR) +HEATPUMPIR_PROTOCOL(PROTOCOL_R51M, R51MHeatpumpIR) + +#undef HEATPUMPIR_PROTOCOL void HeatpumpIRClimate::setup() { - auto protocol_constructor = PROTOCOL_CONSTRUCTOR_MAP.find(protocol_); - if (protocol_constructor == PROTOCOL_CONSTRUCTOR_MAP.end()) { - ESP_LOGE(TAG, "Invalid protocol"); - return; - } - this->heatpump_ir_ = protocol_constructor->second(); climate_ir::ClimateIR::setup(); if (this->sensor_) { this->sensor_->add_on_state_callback([this](float state) { diff --git a/esphome/components/heatpumpir/heatpumpir.h b/esphome/components/heatpumpir/heatpumpir.h index d00fa91d05a..dfa5c40366a 100644 --- a/esphome/components/heatpumpir/heatpumpir.h +++ b/esphome/components/heatpumpir/heatpumpir.h @@ -89,25 +89,21 @@ enum VerticalDirection { VERTICAL_DIRECTION_DOWN = 5, }; +/// Creates the HeatpumpIR library class for a protocol; instantiated in heatpumpir.cpp. +template HeatpumpIR *create_heatpump_ir(); + +/// Selects the protocol at compile time so the enum and the library object always match. +template struct ProtocolTag {}; + // Temperature const float TEMP_MIN = 0; // Celsius const float TEMP_MAX = 100; // Celsius class HeatpumpIRClimate final : public climate_ir::ClimateIR { public: - HeatpumpIRClimate() - : climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX, 1.0f, true, true, - {climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH, - climate::CLIMATE_FAN_AUTO}, - {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL, - climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_BOTH}) {} + template + explicit HeatpumpIRClimate(ProtocolTag

/*tag*/) : HeatpumpIRClimate(P, create_heatpump_ir

()) {} void setup() override; - void set_protocol(Protocol protocol) { - this->protocol_ = protocol; - if (this->has_fan_byte_presets_()) { - this->presets_ = {climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_ECO, climate::CLIMATE_PRESET_BOOST}; - } - } void set_horizontal_default(HorizontalDirection horizontal_direction) { this->default_horizontal_direction_ = horizontal_direction; } @@ -119,6 +115,18 @@ class HeatpumpIRClimate final : public climate_ir::ClimateIR { void set_min_temperature(float temperature) { this->min_temperature_ = temperature; } protected: + HeatpumpIRClimate(Protocol protocol, HeatpumpIR *heatpump_ir) + : climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX, 1.0f, true, true, + {climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH, + climate::CLIMATE_FAN_AUTO}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL, + climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_BOTH}), + heatpump_ir_(heatpump_ir), + protocol_(protocol) { + if (this->has_fan_byte_presets_()) { + this->presets_ = {climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_ECO, climate::CLIMATE_PRESET_BOOST}; + } + } HeatpumpIR *heatpump_ir_; void control(const climate::ClimateCall &call) override; /// Transmit via IR the state of this climate controller. From 1146b77836f59944a18001d95e675bfb67f1f5d9 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Wed, 14 Oct 2026 08:19:17 +0300 Subject: [PATCH 082/142] [socket] Add TcpClientLink::connecting() (#20722) --- esphome/components/socket/tcp_client_link.h | 2 ++ .../socket/test_tcp_client_link_host.cpp | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/esphome/components/socket/tcp_client_link.h b/esphome/components/socket/tcp_client_link.h index f77ee050555..517a83375df 100644 --- a/esphome/components/socket/tcp_client_link.h +++ b/esphome/components/socket/tcp_client_link.h @@ -61,6 +61,8 @@ class TcpClientLink { void close(); bool connected() const { return this->connected_; } + /// A connect attempt is in flight. + bool connecting() const { return this->sock_ != nullptr && !this->connected_; } bool ready() const { return this->sock_ != nullptr && this->sock_->ready(); } /// Shared retry clock, also usable for a listen socket. void note_attempt() { this->last_attempt_ms_ = App.get_loop_component_start_time(); } diff --git a/tests/components/socket/test_tcp_client_link_host.cpp b/tests/components/socket/test_tcp_client_link_host.cpp index 98be8190b00..a5be9225e52 100644 --- a/tests/components/socket/test_tcp_client_link_host.cpp +++ b/tests/components/socket/test_tcp_client_link_host.cpp @@ -102,6 +102,19 @@ TEST_F(TcpClientLinkClock, PendingConnectTimesOutIntoBackoff) { EXPECT_TRUE(this->link_.in_backoff()); } +TEST_F(TcpClientLinkClock, ConnectingUntilThePendingAttemptEnds) { + this->link_.begin("link_test"); + EXPECT_FALSE(this->link_.connecting()); + this->set_pending_socket(); + this->link_.note_attempt(); + this->link_.poll(); + EXPECT_TRUE(this->link_.connecting()); + set_loop_time(1000 + 10000); + this->link_.poll(); + EXPECT_FALSE(this->link_.connecting()); + EXPECT_FALSE(this->link_.connected()); +} + TEST_F(TcpClientLinkClock, ConnectTimeoutFollowsALongerInterval) { this->link_.set_reconnect_interval(20000); this->link_.begin("link_test"); @@ -177,6 +190,7 @@ TEST_F(TcpClientLinkClock, BackoffSpansAMillisWrap) { TEST(TcpClientLink, AdoptedSocketFlushesQueuedBytes) { LinkPeer p; ASSERT_TRUE(p.link_.connected()); + EXPECT_FALSE(p.link_.connecting()); EXPECT_EQ(p.link_.queue(reinterpret_cast("ping"), 4), 4u); EXPECT_TRUE(p.link_.flush_tx()); char buf[8]; From a97604d5746ed7c3045ca539ea319ce52edfd0b1 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 14 Oct 2026 01:10:51 -0500 Subject: [PATCH 083/142] [esp32_ble] Rely on preferences for NVS init (#20708) Co-authored-by: Claude Opus 5.5 --- esphome/components/esp32/preferences.cpp | 1 + esphome/components/esp32_ble/ble.cpp | 16 ++++------------ esphome/components/esp32_ble/ble.h | 1 - .../esp32_ble_beacon/esp32_ble_beacon.cpp | 1 - .../components/esp32_ble_server/ble_server.cpp | 1 - .../esp32_ble_tracker/esp32_ble_tracker.cpp | 1 - esphome/components/zigbee/zigbee_esp32.cpp | 1 - 7 files changed, 5 insertions(+), 17 deletions(-) diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index 02978ae0f7a..87cb3345b08 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -131,6 +131,7 @@ bool ESP32PreferenceBackend::load(uint8_t *data, size_t len) { void ESP32Preferences::open() { // Runs from app_main() before the logger is initialized; any logging here // must be deferred. See s_open_err and make_preference() below. + // Components that need NVS check nvs_handle instead of initializing it again. nvs_flash_init(); esp_err_t err = nvs_open("esphome", NVS_READWRITE, &this->nvs_handle); if (err == 0) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 11290a95f41..1379584120b 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -5,6 +5,7 @@ #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/preferences.h" #ifndef CONFIG_BT_CONTROLLER_DISABLED #include @@ -28,7 +29,6 @@ extern "C" { #include #include #include -#include #ifdef USE_ARDUINO // Prevent Arduino from releasing BT memory at startup (esp32-hal-misc.c). @@ -77,8 +77,9 @@ static constexpr uint32_t HOSTED_BT_WDT_TIMEOUT_MS = 60000; void ESP32BLE::setup() { global_ble = this; - if (!ble_pre_setup_()) { - ESP_LOGE(TAG, "BLE could not be prepared for configuration"); + // Preferences opens NVS in app_main(); a zero handle means it could not be opened, even after an erase + if (global_preferences->nvs_handle == 0) { + ESP_LOGE(TAG, "NVS unavailable"); this->mark_failed(); return; } @@ -185,15 +186,6 @@ void ESP32BLE::advertising_remove_service_uuid(ESPBTUUID uuid) { } #endif -bool ESP32BLE::ble_pre_setup_() { - esp_err_t err = nvs_flash_init(); - if (err != ESP_OK) { - ESP_LOGE(TAG, "nvs_flash_init failed: %d", err); - return false; - } - return true; -} - #ifdef USE_ESP32_BLE_ADVERTISING void ESP32BLE::advertising_init_() { if (this->advertising_ != nullptr) diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 950ae6e0bc1..ef326e1532d 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -185,7 +185,6 @@ class ESP32BLE final : public Component { } this->ble_events_.get_and_reset_dropped_count(); } - bool ble_pre_setup_(); #ifdef USE_ESP32_BLE_ADVERTISING void advertising_init_(); #endif diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp index 7481d2c612c..430393f2d81 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp @@ -10,7 +10,6 @@ #include #include #include -#include #include #include "esphome/core/hal.h" diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index 650a765b6a5..0dec0a74c5d 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -7,7 +7,6 @@ #ifdef USE_ESP32 -#include #include #include #ifndef CONFIG_BT_CONTROLLER_DISABLED diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 8d3148c2fc7..cd1c16e8f5d 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -16,7 +16,6 @@ #include #include #include -#include #include #ifdef USE_OTA diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 68c0d005b57..29bbdc2b0b5 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -5,7 +5,6 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_check.h" -#include "nvs_flash.h" #include "zigbee_attribute_esp32.h" #include "zigbee_esp32.h" #include "esphome/core/application.h" From 7f2c3ba3f5540e241d5aa110967d4d36db410a5e Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Wed, 14 Oct 2026 09:11:47 +0300 Subject: [PATCH 084/142] [display] Let other components draw into a display (#20622) --- esphome/components/display/__init__.py | 36 +++++++ esphome/components/epaper_spi/display.py | 7 +- esphome/components/it8951/display.py | 6 +- .../config/epaper_spi_owner_auto_id.yaml | 28 ++++++ .../display/config/it8951_owner_auto_id.yaml | 21 ++++ .../display/config/no_owner.yaml | 25 +++++ .../display/config/owner_by_id.yaml | 33 +++++++ .../fake_display_writer/__init__.py | 20 ++++ .../display/test_external_writer.py | 98 +++++++++++++++++++ 9 files changed, 263 insertions(+), 11 deletions(-) create mode 100644 tests/component_tests/display/config/epaper_spi_owner_auto_id.yaml create mode 100644 tests/component_tests/display/config/it8951_owner_auto_id.yaml create mode 100644 tests/component_tests/display/config/no_owner.yaml create mode 100644 tests/component_tests/display/config/owner_by_id.yaml create mode 100644 tests/component_tests/display/external_components/fake_display_writer/__init__.py create mode 100644 tests/component_tests/display/test_external_writer.py diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 0b871af30cd..22e3d9320b0 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -250,6 +250,42 @@ def add_metadata( ) +KEY_EXTERNAL_WRITERS = "external_writers" + + +def _get_external_writer_list() -> list[tuple[ID, str]]: + """Get the raw external writer list. Each entry is (display id, owner).""" + return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_EXTERNAL_WRITERS, []) + + +def register_external_writer(display_id: ID, owner: str) -> None: + """Declare that ``owner`` draws into the display from outside its lambda or pages. + + Call from the owner's CONFIG_SCHEMA. The ID may still be unresolved; the ID pass + resolves it in place before final validation. + """ + _get_external_writer_list().append((display_id, owner)) + + +def get_external_writers(display_id: ID) -> list[str]: + """Get the owners registered for a display. Only valid once IDs are resolved.""" + return [ + owner + for id_, owner in _get_external_writer_list() + if id_.id is not None and id_.id == display_id.id + ] + + +def has_external_writer(display_id: ID) -> bool: + """Tell a driver's final validation that something else draws into the display. + + LVGL anywhere in the config also counts, as the drivers assumed before. + """ + from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN + + return LVGL_DOMAIN in full_config.get() or bool(get_external_writers(display_id)) + + async def register_display(var, config): await cg.register_component(var, config) await setup_display_core_(var, config) diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 487e8e5ef40..06aa53acb69 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -40,7 +40,6 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.cpp_generator import RawExpression -from esphome.final_validate import full_config from . import models @@ -198,15 +197,11 @@ def _final_validate(config) -> None: "epaper_spi", require_miso=False, require_mosi=True )(config) - global_config = full_config.get() - from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN - if CONF_LAMBDA not in config and CONF_PAGES not in config: - if LVGL_DOMAIN in global_config: + if display.has_external_writer(config[CONF_ID]): if CONF_UPDATE_INTERVAL not in config: config[CONF_UPDATE_INTERVAL] = update_interval("never") else: - # If no drawing methods are configured, and LVGL is not enabled, show a test card config[CONF_SHOW_TEST_CARD] = True elif CONF_UPDATE_INTERVAL not in config: config[CONF_UPDATE_INTERVAL] = update_interval("1min") diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py index 76fbadd1968..d9c80ebf43d 100644 --- a/esphome/components/it8951/display.py +++ b/esphome/components/it8951/display.py @@ -37,7 +37,6 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.cpp_generator import RawExpression -from esphome.final_validate import full_config from esphome.types import ConfigType AUTO_LOAD = ["split_buffer"] @@ -348,11 +347,8 @@ def _final_validate(config: ConfigType) -> None: config ) - global_config = full_config.get() - from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN - if CONF_LAMBDA not in config and CONF_PAGES not in config: - if LVGL_DOMAIN in global_config: + if display.has_external_writer(config[CONF_ID]): if CONF_UPDATE_INTERVAL not in config: config[CONF_UPDATE_INTERVAL] = update_interval("never") else: diff --git a/tests/component_tests/display/config/epaper_spi_owner_auto_id.yaml b/tests/component_tests/display/config/epaper_spi_owner_auto_id.yaml new file mode 100644 index 00000000000..73d993fb290 --- /dev/null +++ b/tests/component_tests/display/config/epaper_spi_owner_auto_id.yaml @@ -0,0 +1,28 @@ +esphome: + name: test + +esp32: + board: esp32dev + +external_components: + - source: + type: local + path: ../external_components + +spi: + clk_pin: GPIO14 + mosi_pin: GPIO12 + miso_pin: GPIO13 + +display: + - platform: epaper_spi + model: ssd1677 + dimensions: + width: 200 + height: 200 + cs_pin: GPIO5 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO25 + +fake_display_writer: diff --git a/tests/component_tests/display/config/it8951_owner_auto_id.yaml b/tests/component_tests/display/config/it8951_owner_auto_id.yaml new file mode 100644 index 00000000000..ed6b0fd8994 --- /dev/null +++ b/tests/component_tests/display/config/it8951_owner_auto_id.yaml @@ -0,0 +1,21 @@ +esphome: + name: test + +esp32: + board: esp32dev + +external_components: + - source: + type: local + path: ../external_components + +spi: + clk_pin: GPIO14 + mosi_pin: GPIO12 + miso_pin: GPIO13 + +display: + - platform: it8951 + model: m5stack-m5paper + +fake_display_writer: diff --git a/tests/component_tests/display/config/no_owner.yaml b/tests/component_tests/display/config/no_owner.yaml new file mode 100644 index 00000000000..4a4714779a2 --- /dev/null +++ b/tests/component_tests/display/config/no_owner.yaml @@ -0,0 +1,25 @@ +esphome: + name: test + +esp32: + board: esp32dev + +spi: + clk_pin: GPIO14 + mosi_pin: GPIO12 + miso_pin: GPIO13 + +display: + - platform: it8951 + id: it8951_display + model: m5stack-m5paper + - platform: epaper_spi + id: epaper_display + model: ssd1677 + dimensions: + width: 200 + height: 200 + cs_pin: GPIO5 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO25 diff --git a/tests/component_tests/display/config/owner_by_id.yaml b/tests/component_tests/display/config/owner_by_id.yaml new file mode 100644 index 00000000000..e3be24deffe --- /dev/null +++ b/tests/component_tests/display/config/owner_by_id.yaml @@ -0,0 +1,33 @@ +esphome: + name: test + +esp32: + board: esp32dev + +external_components: + - source: + type: local + path: ../external_components + +spi: + clk_pin: GPIO14 + mosi_pin: GPIO12 + miso_pin: GPIO13 + +display: + - platform: it8951 + id: it8951_display + model: m5stack-m5paper + - platform: epaper_spi + id: epaper_display + model: ssd1677 + dimensions: + width: 200 + height: 200 + cs_pin: GPIO5 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO25 + +fake_display_writer: + display_id: epaper_display diff --git a/tests/component_tests/display/external_components/fake_display_writer/__init__.py b/tests/component_tests/display/external_components/fake_display_writer/__init__.py new file mode 100644 index 00000000000..5e1388578fa --- /dev/null +++ b/tests/component_tests/display/external_components/fake_display_writer/__init__.py @@ -0,0 +1,20 @@ +"""External component that draws into a display from outside its lambda or pages.""" + +from esphome.components import display +import esphome.config_validation as cv +from esphome.const import CONF_DISPLAY_ID +from esphome.types import ConfigType + +DEPENDENCIES = ["display"] +DOMAIN = "fake_display_writer" + + +def _register_writer(config: ConfigType) -> ConfigType: + display.register_external_writer(config[CONF_DISPLAY_ID], DOMAIN) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema({cv.GenerateID(CONF_DISPLAY_ID): cv.use_id(display.Display)}), + _register_writer, +) diff --git a/tests/component_tests/display/test_external_writer.py b/tests/component_tests/display/test_external_writer.py new file mode 100644 index 00000000000..c51bf15b579 --- /dev/null +++ b/tests/component_tests/display/test_external_writer.py @@ -0,0 +1,98 @@ +"""Tests for the display external writer registry.""" + +from collections.abc import Callable, Generator +from pathlib import Path +import sys + +import pytest + +from esphome import loader +from esphome.components.display import ( + Display, + get_external_writers, + has_external_writer, + register_external_writer, +) +from esphome.core import ID +from esphome.final_validate import full_config + +TEST_CARD = "show_test_card()" +UPDATE_NEVER = "set_update_interval(4294967295UL)" + + +@pytest.fixture +def unload_fake_writer() -> Generator[None]: + """Drop the fake external component so later tests start clean.""" + yield + loader._COMPONENT_CACHE.pop("fake_display_writer", None) + sys.modules.pop("esphome.components.fake_display_writer", None) + loader.clear_component_meta_finders() + + +def test_unresolved_id_matches_after_resolution() -> None: + """An auto-picked ID registered during schema validation matches once resolved.""" + pending = ID(None, is_declaration=False, type=Display) + register_external_writer(pending, "owner") + declared = ID("epd", is_declaration=True, type=Display) + + assert get_external_writers(declared) == [] + assert has_external_writer(declared) is False + + pending.id = "epd" + assert get_external_writers(declared) == ["owner"] + assert has_external_writer(declared) is True + assert get_external_writers(ID("other", is_declaration=True, type=Display)) == [] + + +def test_all_owners_returned() -> None: + register_external_writer(ID("epd"), "first") + register_external_writer(ID("epd"), "second") + register_external_writer(ID("other"), "third") + + assert get_external_writers(ID("epd")) == ["first", "second"] + + +def test_lvgl_counts_as_external_writer() -> None: + assert has_external_writer(ID("epd")) is False + full_config.get()["lvgl"] = [] + assert has_external_writer(ID("epd")) is True + + +def test_drivers_show_test_card_without_owner( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("no_owner.yaml")) + + assert f"it8951_display->{TEST_CARD}" in main_cpp + assert f"epaper_display->{TEST_CARD}" in main_cpp + assert UPDATE_NEVER not in main_cpp + + +@pytest.mark.usefixtures("unload_fake_writer") +@pytest.mark.parametrize( + "config_file", ["it8951_owner_auto_id.yaml", "epaper_spi_owner_auto_id.yaml"] +) +def test_owned_display_has_no_test_card( + config_file: str, + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The owner picks the display ID automatically, so it resolves after the schema runs.""" + main_cpp = generate_main(component_config_path(config_file)) + + assert TEST_CARD not in main_cpp + assert UPDATE_NEVER in main_cpp + + +@pytest.mark.usefixtures("unload_fake_writer") +def test_only_owned_display_loses_test_card( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("owner_by_id.yaml")) + + assert f"it8951_display->{TEST_CARD}" in main_cpp + assert f"epaper_display->{TEST_CARD}" not in main_cpp + assert f"epaper_display->{UPDATE_NEVER}" in main_cpp + assert f"it8951_display->{UPDATE_NEVER}" not in main_cpp From 35b416c6c0bc85135811bca6a98150c3160d5558 Mon Sep 17 00:00:00 2001 From: Anunay Kulshrestha Date: Wed, 14 Oct 2026 08:19:06 +0000 Subject: [PATCH 085/142] [web_server_idf] Keep active SSE sessions out of the LRU purge (#17383) Co-authored-by: Claude Fable 5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../web_server_idf/web_server_idf.cpp | 34 +++++++++++++++++-- .../web_server_idf/web_server_idf.h | 12 +++++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index d9a7d65e8ce..456c9be2c9a 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -959,10 +959,39 @@ void AsyncEventSourceResponse::process_close_() { } } +void AsyncEventSourceResponse::refresh_lru_() { + // httpd only advances a session's LRU counter when a request arrives, and an event stream receives one + // request ever, so the purge that frees a socket for a new client would evict the live streams first. + // The HTTPD task owns the session table, so the refresh runs there. + // A stalled stream is the socket the purge should take, and a zeroed fd has no session left to refresh. + if (this->send_failure_started_ms_ != 0 || this->fd_.load() == 0) + return; + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_lru_refresh_ms_ < LRU_REFRESH_INTERVAL_MS || + this->lru_work_queued_.load(std::memory_order_acquire)) { + return; + } + this->last_lru_refresh_ms_ = now; // a full queue also waits for the next interval + this->lru_work_queued_.store(true, std::memory_order_release); + if (httpd_queue_work(this->hd_, &AsyncEventSourceResponse::refresh_lru_work, this) != ESP_OK) { + this->lru_work_queued_.store(false, std::memory_order_release); + } +} + +void AsyncEventSourceResponse::refresh_lru_work(void *arg) { + auto *response = static_cast(arg); + const int fd = response->fd_.load(); + if (response->owns_session_(fd)) { + httpd_sess_update_lru_counter(response->hd_, fd); + } + // Release self only after the HTTPD-task callback has finished every access. + response->lru_work_queued_.store(false, std::memory_order_release); +} + void AsyncEventSourceResponse::close_session_work(void *arg) { auto *response = static_cast(arg); const int fd = response->fd_.load(); - if (fd != 0 && httpd_sess_get_ctx(response->hd_, fd) == response) { + if (response->owns_session_(fd)) { // The HTTPD task remains the session owner. Shutting the socket down makes // its next select/recv path delete the session and invoke destroy(). shutdown(fd, SHUT_RDWR); @@ -976,7 +1005,7 @@ ssize_t AsyncEventSourceResponse::send_(struct iovec *iov, int iovcnt) { // httpd frees a session before closing its socket, so the fd may already be a new client's. // Treated as would-block; the stall timer ends a session that never becomes ours again. const int fd = this->fd_.load(); - if (httpd_sess_get_ctx(this->hd_, fd) != this) { + if (!this->owns_session_(fd)) { return 0; } struct msghdr msg {}; @@ -1085,6 +1114,7 @@ void AsyncEventSourceResponse::loop() { this->process_close_(); return; } + this->refresh_lru_(); drain_tail_(); process_deferred_queue_(); if (this->close_requested_) diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 4d4490d9076..c25dd8d609f 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -339,10 +339,15 @@ class AsyncEventSourceResponse { }; void process_close_(); static void close_session_work(void *arg); + void refresh_lru_(); + static void refresh_lru_work(void *arg); + // httpd frees a session before closing its socket, so the fd may already belong to a new client + bool owns_session_(int fd) const { return fd != 0 && httpd_sess_get_ctx(this->hd_, fd) == this; } - // Deletable only after destroy() zeroed fd_ and no queued HTTPD close work still references this object. + // Deletable only after destroy() zeroed fd_ and no queued HTTPD work still references this object. bool safe_to_delete_() const { - return this->fd_.load() == 0 && !this->close_work_queued_.load(std::memory_order_acquire); + return this->fd_.load() == 0 && !this->close_work_queued_.load(std::memory_order_acquire) && + !this->lru_work_queued_.load(std::memory_order_acquire); } static void destroy(void *p); @@ -357,11 +362,13 @@ class AsyncEventSourceResponse { RAMUniquePtr tail_; uint32_t send_failure_started_ms_{0}; // Zero means no send stall in progress. uint32_t next_close_attempt_ms_{0}; + uint32_t last_lru_refresh_ms_{0}; uint16_t tail_cap_{0}; uint16_t tail_len_{0}; // Zero means nothing pending uint16_t tail_sent_{0}; // Set on the main loop before queueing close work, cleared by the HTTPD-task callback when done. std::atomic close_work_queued_{false}; + std::atomic lru_work_queued_{false}; // Main-loop only; the HTTPD task never reads or writes these flags. bool close_requested_{false}; bool close_retry_warning_logged_{false}; @@ -383,6 +390,7 @@ class AsyncEventSourceResponse { static constexpr uint32_t SEND_STALL_TIMEOUT_MS = 20000; static constexpr uint32_t CLOSE_RETRY_INTERVAL_MS = 250; static constexpr uint32_t CLOSE_CONFIRM_INTERVAL_MS = 1000; + static constexpr uint32_t LRU_REFRESH_INTERVAL_MS = 1000; }; using AsyncEventSourceClient = AsyncEventSourceResponse; From 56cc03c4d4e6696fc1d4f0ca745748a57205ac0c Mon Sep 17 00:00:00 2001 From: Pavlo Dudnytskyi Date: Wed, 14 Oct 2026 11:27:18 +0200 Subject: [PATCH 086/142] [haier] Haier Select component added (#16148) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Pavlo Dudnytskyi Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/haier/hon_climate.cpp | 176 ++++++++++++++++-- esphome/components/haier/hon_climate.h | 38 ++++ esphome/components/haier/select/__init__.py | 67 +++++++ .../haier/select/horizontal_airflow.h | 15 ++ .../haier/select/vertical_airflow.h | 15 ++ tests/components/haier/common.yaml | 8 + 7 files changed, 309 insertions(+), 11 deletions(-) create mode 100644 esphome/components/haier/select/__init__.py create mode 100644 esphome/components/haier/select/horizontal_airflow.h create mode 100644 esphome/components/haier/select/vertical_airflow.h diff --git a/CODEOWNERS b/CODEOWNERS index 95d8c49c23e..1282e9d66b9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -240,6 +240,7 @@ esphome/components/gt911/* @clydebarrow @jesserockz esphome/components/haier/* @paveldn esphome/components/haier/binary_sensor/* @paveldn esphome/components/haier/button/* @paveldn +esphome/components/haier/select/* @paveldn esphome/components/haier/sensor/* @paveldn esphome/components/haier/switch/* @paveldn esphome/components/haier/text_sensor/* @paveldn diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index 72b1e26818b..59484aab16d 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -2,6 +2,8 @@ #include #include "esphome/components/climate/climate.h" #include "esphome/components/uart/uart.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/progmem.h" #include "hon_climate.h" @@ -12,6 +14,26 @@ using namespace esphome::uart; namespace esphome::haier { +static bool vertical_axis_is_auto(climate::ClimateSwingMode swing_mode) { + return (swing_mode == climate::CLIMATE_SWING_VERTICAL) || (swing_mode == climate::CLIMATE_SWING_BOTH); +} + +static bool horizontal_axis_is_auto(climate::ClimateSwingMode swing_mode) { + return (swing_mode == climate::CLIMATE_SWING_HORIZONTAL) || (swing_mode == climate::CLIMATE_SWING_BOTH); +} + +static bool is_auto(hon_protocol::VerticalSwingMode mode) { + return (mode == hon_protocol::VerticalSwingMode::AUTO) || (mode == hon_protocol::VerticalSwingMode::AUTO_SPECIAL); +} + +static bool is_auto(hon_protocol::HorizontalSwingMode mode) { return mode == hon_protocol::HorizontalSwingMode::AUTO; } + +// A position the user just set wins over the appliance's echo of the old one for this long +constexpr uint32_t AIRFLOW_SELECT_DEBOUNCE_MS = 10000; +static bool in_debounce(uint32_t set_time, uint32_t now) { + return set_time != 0 && now - set_time < AIRFLOW_SELECT_DEBOUNCE_MS; +} + ESPHOME_LOG_TAG(TAG, "haier.climate"); constexpr size_t SIGNAL_LEVEL_UPDATE_INTERVAL_MS = 10000; constexpr int PROTOCOL_OUTDOOR_TEMPERATURE_OFFSET = -64; @@ -89,21 +111,60 @@ bool HonClimate::get_quiet_mode_state() const { } esphome::optional HonClimate::get_vertical_airflow() const { + if (!this->current_vertical_swing_.has_value()) { + return this->current_vertical_swing_; + } + // While the appliance swings, or right after the user chose a position, report the chosen position + if (is_auto(this->current_vertical_swing_.value()) || + in_debounce(this->vertical_direction_set_time_, App.get_loop_component_start_time())) { + return this->settings_.last_vertiacal_swing; + } return this->current_vertical_swing_; -}; +} void HonClimate::set_vertical_airflow(hon_protocol::VerticalSwingMode direction) { + if (is_auto(direction)) { + ESP_LOGW(TAG, "Vertical AUTO mode is not allowed for airflow position selector"); + return; + } + if (this->settings_.last_vertiacal_swing != direction) { + this->settings_.last_vertiacal_swing = direction; + this->hon_rtc_.save(&this->settings_); + } this->pending_vertical_direction_ = direction; this->force_send_control_ = true; + this->vertical_direction_set_time_ = App.get_loop_component_start_time(); +#ifdef USE_SELECT + this->publish_airflow_selects_(); +#endif } esphome::optional HonClimate::get_horizontal_airflow() const { + if (!this->current_horizontal_swing_.has_value()) { + return this->current_horizontal_swing_; + } + if (is_auto(this->current_horizontal_swing_.value()) || + in_debounce(this->horizontal_direction_set_time_, App.get_loop_component_start_time())) { + return this->settings_.last_horizontal_swing; + } return this->current_horizontal_swing_; } void HonClimate::set_horizontal_airflow(hon_protocol::HorizontalSwingMode direction) { + if (is_auto(direction)) { + ESP_LOGW(TAG, "Horizontal AUTO mode is not allowed for airflow position selector"); + return; + } + if (this->settings_.last_horizontal_swing != direction) { + this->settings_.last_horizontal_swing = direction; + this->hon_rtc_.save(&this->settings_); + } this->pending_horizontal_direction_ = direction; this->force_send_control_ = true; + this->horizontal_direction_set_time_ = App.get_loop_component_start_time(); +#ifdef USE_SELECT + this->publish_airflow_selects_(); +#endif } const char *HonClimate::get_cleaning_status_text() const { @@ -533,6 +594,18 @@ void HonClimate::initialization() { } else { this->settings_ = {hon_protocol::VerticalSwingMode::CENTER, hon_protocol::HorizontalSwingMode::CENTER, true, false}; } + bool settings_fixed = false; + if (is_auto(this->settings_.last_vertiacal_swing)) { + this->settings_.last_vertiacal_swing = hon_protocol::VerticalSwingMode::CENTER; + settings_fixed = true; + } + if (is_auto(this->settings_.last_horizontal_swing)) { + this->settings_.last_horizontal_swing = hon_protocol::HorizontalSwingMode::CENTER; + settings_fixed = true; + } + if (settings_fixed) { + this->hon_rtc_.save(&this->settings_); + } this->current_vertical_swing_ = this->settings_.last_vertiacal_swing; this->current_horizontal_swing_ = this->settings_.last_horizontal_swing; this->quiet_mode_state_ = this->settings_.quiet_mode_state ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF; @@ -668,12 +741,21 @@ haier_protocol::HaierMessage HonClimate::get_control_message() { } } } + const ClimateSwingMode target_swing_mode = this->current_hvac_settings_.swing_mode.value_or(this->swing_mode); if (this->pending_vertical_direction_.has_value()) { - out_data->vertical_swing_mode = (uint8_t) this->pending_vertical_direction_.value(); + if (!vertical_axis_is_auto(target_swing_mode)) { + out_data->vertical_swing_mode = (uint8_t) this->pending_vertical_direction_.value(); + } else { + ESP_LOGD(TAG, "Vertical swing is on; the position is kept for when it is turned off"); + } this->pending_vertical_direction_.reset(); } if (this->pending_horizontal_direction_.has_value()) { - out_data->horizontal_swing_mode = (uint8_t) this->pending_horizontal_direction_.value(); + if (!horizontal_axis_is_auto(target_swing_mode)) { + out_data->horizontal_swing_mode = (uint8_t) this->pending_horizontal_direction_.value(); + } else { + ESP_LOGD(TAG, "Horizontal swing is on; the position is kept for when it is turned off"); + } this->pending_horizontal_direction_.reset(); } { @@ -840,6 +922,41 @@ void HonClimate::set_quiet_mode_switch(switch_::Switch *sw) { } #endif // USE_SWITCH +#ifdef USE_SELECT +template +static void publish_airflow_index(select::Select *sel, const std::array &order, Mode mode) { + if (sel == nullptr) { + return; + } + // Saved settings never hold AUTO after initialization(); any other value outside the table shows as Center + size_t index = N; + size_t center = 0; + for (size_t i = 0; i < N; i++) { + const Mode entry = airflow_order_at(order, i); + if (entry == mode) { + index = i; + } else if (entry == Mode::CENTER) { + center = i; + } + } + if (index == N) { + index = center; + } + const auto active = sel->active_index(); + if (active.has_value() && *active == index) { + return; + } + sel->publish_state(index); +} + +void HonClimate::publish_airflow_selects_() { + publish_airflow_index(this->vertical_airflow_select_, VERTICAL_SWING_MODES_ORDER, + this->settings_.last_vertiacal_swing); + publish_airflow_index(this->horizontal_airflow_select_, HORIZONTAL_SWING_MODES_ORDER, + this->settings_.last_horizontal_swing); +} +#endif // USE_SELECT + haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t *packet_buffer, uint8_t size) { size_t expected_size = 2 + this->status_message_header_size_ + this->real_control_packet_size_ + this->real_sensors_packet_size_; @@ -1074,16 +1191,25 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t * // Saving last known non auto mode for vertical and horizontal swing this->current_vertical_swing_ = (hon_protocol::VerticalSwingMode) packet.control.vertical_swing_mode; this->current_horizontal_swing_ = (hon_protocol::HorizontalSwingMode) packet.control.horizontal_swing_mode; - bool save_settings = ((this->current_vertical_swing_.value() != hon_protocol::VerticalSwingMode::AUTO) && - (this->current_vertical_swing_.value() != hon_protocol::VerticalSwingMode::AUTO_SPECIAL) && - (this->current_vertical_swing_.value() != this->settings_.last_vertiacal_swing)) || - ((this->current_horizontal_swing_.value() != hon_protocol::HorizontalSwingMode::AUTO) && - (this->current_horizontal_swing_.value() != this->settings_.last_horizontal_swing)); - if (save_settings) { - this->settings_.last_vertiacal_swing = this->current_vertical_swing_.value(); - this->settings_.last_horizontal_swing = this->current_horizontal_swing_.value(); + const uint32_t now_ms = App.get_loop_component_start_time(); + bool save_vertical = !in_debounce(this->vertical_direction_set_time_, now_ms) && + !is_auto(this->current_vertical_swing_.value()) && + (this->current_vertical_swing_.value() != this->settings_.last_vertiacal_swing); + bool save_horizontal = !in_debounce(this->horizontal_direction_set_time_, now_ms) && + !is_auto(this->current_horizontal_swing_.value()) && + (this->current_horizontal_swing_.value() != this->settings_.last_horizontal_swing); + if (save_vertical || save_horizontal) { + if (save_vertical) { + this->settings_.last_vertiacal_swing = this->current_vertical_swing_.value(); + } + if (save_horizontal) { + this->settings_.last_horizontal_swing = this->current_horizontal_swing_.value(); + } this->hon_rtc_.save(&this->settings_); } +#ifdef USE_SELECT + this->publish_airflow_selects_(); +#endif should_publish = should_publish || (old_swing_mode != this->swing_mode); } this->last_valid_status_timestamp_ = std::chrono::steady_clock::now(); @@ -1259,8 +1385,10 @@ void HonClimate::fill_control_messages_queue_() { (uint8_t) hon_protocol::DataParameters::SET_POINT, buffer, 2); } + ClimateSwingMode target_swing_mode = this->swing_mode; // Vertical swing mode if (climate_control.swing_mode.has_value()) { + target_swing_mode = climate_control.swing_mode.value(); uint8_t vertical_swing_buf[] = {0x00, (uint8_t) hon_protocol::VerticalSwingMode::AUTO}; uint8_t horizontal_swing_buf[] = {0x00, (uint8_t) hon_protocol::HorizontalSwingMode::AUTO}; switch (climate_control.swing_mode.value()) { @@ -1286,6 +1414,32 @@ void HonClimate::fill_control_messages_queue_() { (uint8_t) hon_protocol::DataParameters::VERTICAL_SWING_MODE, vertical_swing_buf, 2); } + // A swing change above already sent both axes from settings_.last_*, which set_*_airflow() updated + const bool swing_sent = climate_control.swing_mode.has_value(); + if (this->pending_vertical_direction_.has_value()) { + if (vertical_axis_is_auto(target_swing_mode)) { + ESP_LOGD(TAG, "Vertical swing is on; the position is kept for when it is turned off"); + } else if (!swing_sent) { + uint8_t vertical_swing_buf[] = {0x00, (uint8_t) this->pending_vertical_direction_.value()}; + this->control_messages_queue_.emplace(haier_protocol::FrameType::CONTROL, + (uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER + + (uint8_t) hon_protocol::DataParameters::VERTICAL_SWING_MODE, + vertical_swing_buf, 2); + } + this->pending_vertical_direction_.reset(); + } + if (this->pending_horizontal_direction_.has_value()) { + if (horizontal_axis_is_auto(target_swing_mode)) { + ESP_LOGD(TAG, "Horizontal swing is on; the position is kept for when it is turned off"); + } else if (!swing_sent) { + uint8_t horizontal_swing_buf[] = {0x00, (uint8_t) this->pending_horizontal_direction_.value()}; + this->control_messages_queue_.emplace(haier_protocol::FrameType::CONTROL, + (uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER + + (uint8_t) hon_protocol::DataParameters::HORIZONTAL_SWING_MODE, + horizontal_swing_buf, 2); + } + this->pending_horizontal_direction_.reset(); + } // Fan mode if (climate_control.fan_mode.has_value()) { switch (climate_control.fan_mode.value()) { diff --git a/esphome/components/haier/hon_climate.h b/esphome/components/haier/hon_climate.h index a34b4422c69..db4ef4590bf 100644 --- a/esphome/components/haier/hon_climate.h +++ b/esphome/components/haier/hon_climate.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #ifdef USE_SENSOR @@ -14,12 +15,36 @@ #ifdef USE_SWITCH #include "esphome/components/switch/switch.h" #endif +#ifdef USE_SELECT +#include "esphome/components/select/select.h" +#endif #include "esphome/core/automation.h" +#include "esphome/core/hal.h" #include "haier_base.h" #include "hon_packet.h" namespace esphome::haier { +#ifdef USE_SELECT +// Positions in the order the select platform lists them; in flash on ESP8266, so read with airflow_order_at() +inline constexpr std::array VERTICAL_SWING_MODES_ORDER PROGMEM{ + hon_protocol::VerticalSwingMode::HEALTH_UP, hon_protocol::VerticalSwingMode::MAX_UP, + hon_protocol::VerticalSwingMode::UP, hon_protocol::VerticalSwingMode::CENTER, + hon_protocol::VerticalSwingMode::DOWN, hon_protocol::VerticalSwingMode::MAX_DOWN, + hon_protocol::VerticalSwingMode::HEALTH_DOWN, +}; +inline constexpr std::array HORIZONTAL_SWING_MODES_ORDER PROGMEM{ + hon_protocol::HorizontalSwingMode::MAX_LEFT, hon_protocol::HorizontalSwingMode::LEFT, + hon_protocol::HorizontalSwingMode::CENTER, hon_protocol::HorizontalSwingMode::RIGHT, + hon_protocol::HorizontalSwingMode::MAX_RIGHT, +}; + +template Mode airflow_order_at(const std::array &order, size_t index) { + static_assert(sizeof(Mode) == 1); + return static_cast(progmem_read_byte(reinterpret_cast(order.data() + index))); +} +#endif + enum class CleaningState : uint8_t { NO_CLEANING = 0, SELF_CLEAN = 1, @@ -100,6 +125,16 @@ class HonClimate final : public HaierClimateBase { protected: switch_::Switch *beeper_switch_{nullptr}; switch_::Switch *quiet_mode_switch_{nullptr}; +#endif +#ifdef USE_SELECT + public: + void set_vertical_airflow_select(select::Select *sel) { this->vertical_airflow_select_ = sel; } + void set_horizontal_airflow_select(select::Select *sel) { this->horizontal_airflow_select_ = sel; } + + protected: + void publish_airflow_selects_(); + select::Select *vertical_airflow_select_{nullptr}; + select::Select *horizontal_airflow_select_{nullptr}; #endif public: HonClimate(); @@ -195,6 +230,9 @@ class HonClimate final : public HaierClimateBase { uint8_t big_data_counter_{0}; esphome::optional current_vertical_swing_{}; esphome::optional current_horizontal_swing_{}; + // millis() of the last user-set position per axis, 0 when none + uint32_t vertical_direction_set_time_{0}; + uint32_t horizontal_direction_set_time_{0}; HonSettings settings_{}; ESPPreferenceObject hon_rtc_; SwitchState quiet_mode_state_{SwitchState::SWITCH_OFF}; diff --git a/esphome/components/haier/select/__init__.py b/esphome/components/haier/select/__init__.py new file mode 100644 index 00000000000..4198ba82081 --- /dev/null +++ b/esphome/components/haier/select/__init__.py @@ -0,0 +1,67 @@ +import esphome.codegen as cg +from esphome.components import select +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from ..climate import ( + CONF_HAIER_ID, + CONF_HORIZONTAL_AIRFLOW, + CONF_VERTICAL_AIRFLOW, + HonClimate, + haier_ns, +) + +CODEOWNERS = ["@paveldn"] +VerticalAirflowSelect = haier_ns.class_("VerticalAirflowSelect", select.Select) +HorizontalAirflowSelect = haier_ns.class_("HorizontalAirflowSelect", select.Select) + +# Same order as VERTICAL_SWING_MODES_ORDER and HORIZONTAL_SWING_MODES_ORDER in hon_climate.h +AIRFLOW_VERTICAL_DIRECTION_OPTIONS = [ + "Health Up", + "Max Up", + "Up", + "Center", + "Down", + "Max Down", + "Health Down", +] + +AIRFLOW_HORIZONTAL_DIRECTION_OPTIONS = [ + "Max Left", + "Left", + "Center", + "Right", + "Max Right", +] + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_HAIER_ID): cv.use_id(HonClimate), + cv.Optional(CONF_VERTICAL_AIRFLOW): select.select_schema( + VerticalAirflowSelect, + icon="mdi:arrow-expand-vertical", + entity_category=ENTITY_CATEGORY_CONFIG, + ), + cv.Optional(CONF_HORIZONTAL_AIRFLOW): select.select_schema( + HorizontalAirflowSelect, + icon="mdi:arrow-expand-horizontal", + entity_category=ENTITY_CATEGORY_CONFIG, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_HAIER_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_VERTICAL_AIRFLOW, + hub.set_vertical_airflow_select, + options=AIRFLOW_VERTICAL_DIRECTION_OPTIONS, + ) + await selects( + CONF_HORIZONTAL_AIRFLOW, + hub.set_horizontal_airflow_select, + options=AIRFLOW_HORIZONTAL_DIRECTION_OPTIONS, + ) diff --git a/esphome/components/haier/select/horizontal_airflow.h b/esphome/components/haier/select/horizontal_airflow.h new file mode 100644 index 00000000000..54f46bfcb19 --- /dev/null +++ b/esphome/components/haier/select/horizontal_airflow.h @@ -0,0 +1,15 @@ +#pragma once + +#include "esphome/components/select/select.h" +#include "../hon_climate.h" + +namespace esphome::haier { + +class HorizontalAirflowSelect : public select::Select, public Parented { + protected: + void control(size_t index) override { + this->parent_->set_horizontal_airflow(airflow_order_at(HORIZONTAL_SWING_MODES_ORDER, index)); + } +}; + +} // namespace esphome::haier diff --git a/esphome/components/haier/select/vertical_airflow.h b/esphome/components/haier/select/vertical_airflow.h new file mode 100644 index 00000000000..81ac1b39d2c --- /dev/null +++ b/esphome/components/haier/select/vertical_airflow.h @@ -0,0 +1,15 @@ +#pragma once + +#include "esphome/components/select/select.h" +#include "../hon_climate.h" + +namespace esphome::haier { + +class VerticalAirflowSelect : public select::Select, public Parented { + protected: + void control(size_t index) override { + this->parent_->set_vertical_airflow(airflow_order_at(VERTICAL_SWING_MODES_ORDER, index)); + } +}; + +} // namespace esphome::haier diff --git a/tests/components/haier/common.yaml b/tests/components/haier/common.yaml index ab39dfa61d5..2c0283215f6 100644 --- a/tests/components/haier/common.yaml +++ b/tests/components/haier/common.yaml @@ -121,3 +121,11 @@ switch: name: Haier health mode quiet_mode: name: Haier quiet mode + +select: + - platform: haier + haier_id: haier_ac + horizontal_airflow: + name: Haier airflow horizontal + vertical_airflow: + name: Haier airflow vertical From 34e1cf96d583156bebf26ecf8ca305962a04a36f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 02:58:56 -1000 Subject: [PATCH 087/142] [bmi270] Build the component on ESP8266 in CI (#20731) --- tests/components/bmi270/test.esp8266-ard.yaml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 tests/components/bmi270/test.esp8266-ard.yaml diff --git a/tests/components/bmi270/test.esp8266-ard.yaml b/tests/components/bmi270/test.esp8266-ard.yaml new file mode 100644 index 00000000000..7163710d74b --- /dev/null +++ b/tests/components/bmi270/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + bmi270: !include common.yaml From d9298cb7b91c3a238af6a1346b53d7b87ff8bf0c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 02:59:27 -1000 Subject: [PATCH 088/142] [seeed_mr60bha2] Compile without sensors or binary sensors (#20729) --- esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp | 12 ++++++++++++ .../seeed_mr60bha2/common-binary-sensor.yaml | 9 +++++++++ tests/components/seeed_mr60bha2/common-minimal.yaml | 3 +++ .../seeed_mr60bha2/test-binary-sensor.esp32-idf.yaml | 3 +++ .../seeed_mr60bha2/test-minimal.esp32-idf.yaml | 3 +++ tests/components/seeed_mr60bha2/test.esp32-idf.yaml | 3 +++ .../components/seeed_mr60bha2/test.esp8266-ard.yaml | 3 +++ 7 files changed, 36 insertions(+) create mode 100644 tests/components/seeed_mr60bha2/common-binary-sensor.yaml create mode 100644 tests/components/seeed_mr60bha2/common-minimal.yaml create mode 100644 tests/components/seeed_mr60bha2/test-binary-sensor.esp32-idf.yaml create mode 100644 tests/components/seeed_mr60bha2/test-minimal.esp32-idf.yaml create mode 100644 tests/components/seeed_mr60bha2/test.esp32-idf.yaml create mode 100644 tests/components/seeed_mr60bha2/test.esp8266-ard.yaml diff --git a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp index c5d12924583..e86909d0491 100644 --- a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp +++ b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp @@ -162,6 +162,7 @@ bool MR60BHA2Component::validate_message_() { void MR60BHA2Component::process_frame_(uint16_t frame_id, uint16_t frame_type, const uint8_t *data, size_t length) { switch (frame_type) { case BREATH_RATE_TYPE_BUFFER: +#ifdef USE_SENSOR if (this->breath_rate_sensor_ != nullptr && length >= 4) { uint32_t current_breath_rate_int = encode_uint32(data[3], data[2], data[1], data[0]); if (current_breath_rate_int != 0) { @@ -170,11 +171,14 @@ void MR60BHA2Component::process_frame_(uint16_t frame_id, uint16_t frame_type, c this->breath_rate_sensor_->publish_state(breath_rate_float); } } +#endif break; case PEOPLE_EXIST_TYPE_BUFFER: +#ifdef USE_BINARY_SENSOR if (this->has_target_binary_sensor_ != nullptr && length >= 2) { uint16_t has_target_int = encode_uint16(data[1], data[0]); this->has_target_binary_sensor_->publish_state(has_target_int); +#ifdef USE_SENSOR if (has_target_int == 0) { if (this->breath_rate_sensor_ != nullptr) this->breath_rate_sensor_->publish_state(0.0); @@ -185,9 +189,12 @@ void MR60BHA2Component::process_frame_(uint16_t frame_id, uint16_t frame_type, c if (this->num_targets_sensor_ != nullptr) this->num_targets_sensor_->publish_state(0); } +#endif } +#endif break; case HEART_RATE_TYPE_BUFFER: +#ifdef USE_SENSOR if (this->heart_rate_sensor_ != nullptr && length >= 4) { uint32_t current_heart_rate_int = encode_uint32(data[3], data[2], data[1], data[0]); if (current_heart_rate_int != 0) { @@ -196,8 +203,10 @@ void MR60BHA2Component::process_frame_(uint16_t frame_id, uint16_t frame_type, c this->heart_rate_sensor_->publish_state(heart_rate_float); } } +#endif break; case DISTANCE_TYPE_BUFFER: +#ifdef USE_SENSOR if (length >= 1 && data[0] != 0) { if (this->distance_sensor_ != nullptr && length >= 8) { uint32_t current_distance_int = encode_uint32(data[7], data[6], data[5], data[4]); @@ -206,12 +215,15 @@ void MR60BHA2Component::process_frame_(uint16_t frame_id, uint16_t frame_type, c this->distance_sensor_->publish_state(distance_float); } } +#endif break; case PRINT_CLOUD_BUFFER: +#ifdef USE_SENSOR if (this->num_targets_sensor_ != nullptr && length >= 4) { uint32_t current_num_targets_int = encode_uint32(data[3], data[2], data[1], data[0]); this->num_targets_sensor_->publish_state(current_num_targets_int); } +#endif break; default: break; diff --git a/tests/components/seeed_mr60bha2/common-binary-sensor.yaml b/tests/components/seeed_mr60bha2/common-binary-sensor.yaml new file mode 100644 index 00000000000..b329cdbef90 --- /dev/null +++ b/tests/components/seeed_mr60bha2/common-binary-sensor.yaml @@ -0,0 +1,9 @@ +# The hub with only the presence binary sensor, which nests a USE_SENSOR guard in process_frame_() +seeed_mr60bha2: + id: my_seeed_mr60bha2_binary_sensor + +binary_sensor: + - platform: seeed_mr60bha2 + mr60bha2_id: my_seeed_mr60bha2_binary_sensor + has_target: + name: "Person Information Only" diff --git a/tests/components/seeed_mr60bha2/common-minimal.yaml b/tests/components/seeed_mr60bha2/common-minimal.yaml new file mode 100644 index 00000000000..571e5a57386 --- /dev/null +++ b/tests/components/seeed_mr60bha2/common-minimal.yaml @@ -0,0 +1,3 @@ +# The hub alone, without the sensors and binary sensor that process_frame_() guards +seeed_mr60bha2: + id: my_seeed_mr60bha2_minimal diff --git a/tests/components/seeed_mr60bha2/test-binary-sensor.esp32-idf.yaml b/tests/components/seeed_mr60bha2/test-binary-sensor.esp32-idf.yaml new file mode 100644 index 00000000000..0f5c050ba73 --- /dev/null +++ b/tests/components/seeed_mr60bha2/test-binary-sensor.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + seeed_mr60bha2: !include common-binary-sensor.yaml diff --git a/tests/components/seeed_mr60bha2/test-minimal.esp32-idf.yaml b/tests/components/seeed_mr60bha2/test-minimal.esp32-idf.yaml new file mode 100644 index 00000000000..a97c76ca583 --- /dev/null +++ b/tests/components/seeed_mr60bha2/test-minimal.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + seeed_mr60bha2: !include common-minimal.yaml diff --git a/tests/components/seeed_mr60bha2/test.esp32-idf.yaml b/tests/components/seeed_mr60bha2/test.esp32-idf.yaml new file mode 100644 index 00000000000..18801075447 --- /dev/null +++ b/tests/components/seeed_mr60bha2/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + seeed_mr60bha2: !include common.yaml diff --git a/tests/components/seeed_mr60bha2/test.esp8266-ard.yaml b/tests/components/seeed_mr60bha2/test.esp8266-ard.yaml new file mode 100644 index 00000000000..4f849c315e2 --- /dev/null +++ b/tests/components/seeed_mr60bha2/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + seeed_mr60bha2: !include common.yaml From 66ea99ce65a3c7d6221c98139a6f2d21b69a414a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 03:00:08 -1000 Subject: [PATCH 089/142] [seeed_mr60fda2] Compile without binary sensors or selects (#20726) --- esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp | 8 ++++++++ tests/components/seeed_mr60fda2/common-minimal.yaml | 3 +++ .../components/seeed_mr60fda2/test-minimal.esp32-idf.yaml | 3 +++ tests/components/seeed_mr60fda2/test.esp32-idf.yaml | 3 +++ tests/components/seeed_mr60fda2/test.esp8266-ard.yaml | 3 +++ 5 files changed, 20 insertions(+) create mode 100644 tests/components/seeed_mr60fda2/common-minimal.yaml create mode 100644 tests/components/seeed_mr60fda2/test-minimal.esp32-idf.yaml create mode 100644 tests/components/seeed_mr60fda2/test.esp32-idf.yaml create mode 100644 tests/components/seeed_mr60fda2/test.esp8266-ard.yaml diff --git a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp index 332c59061b1..6b5fb087171 100644 --- a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp +++ b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp @@ -96,6 +96,7 @@ static bool validate_checksum(const uint8_t *data, size_t len, uint8_t expected_ return calculate_checksum(data, len) == expected_checksum; } +#ifdef USE_SELECT static uint8_t find_nearest_index(float value, const float *arr, int size) { int nearest_index = 0; float min_diff = std::abs(value - arr[0]); @@ -108,6 +109,7 @@ static uint8_t find_nearest_index(float value, const float *arr, int size) { } return nearest_index; } +#endif /** * @brief Convert a float value to a byte array. @@ -250,15 +252,19 @@ void MR60FDA2Component::split_frame_(uint8_t buffer) { void MR60FDA2Component::process_frame_() { switch (this->current_frame_type_) { case IS_FALL_TYPE_BUFFER: +#ifdef USE_BINARY_SENSOR if (this->fall_detected_binary_sensor_ != nullptr) { this->fall_detected_binary_sensor_->publish_state(this->current_frame_buf_[LEN_TO_HEAD_CKSUM]); } +#endif this->current_frame_locate_ = LOCATE_FRAME_HEADER; break; case PEOPLE_EXIST_TYPE_BUFFER: +#ifdef USE_BINARY_SENSOR if (this->people_exist_binary_sensor_ != nullptr) this->people_exist_binary_sensor_->publish_state(this->current_frame_buf_[LEN_TO_HEAD_CKSUM]); +#endif this->current_frame_locate_ = LOCATE_FRAME_HEADER; break; @@ -293,6 +299,7 @@ void MR60FDA2Component::process_frame_() { float install_height_float = 0; float height_threshold_float = 0; uint32_t current_sensitivity = 0; +#ifdef USE_SELECT if (this->install_height_select_ != nullptr) { uint32_t current_install_height_int = encode_uint32(current_data_buf_[3], current_data_buf_[2], current_data_buf_[1], current_data_buf_[0]); @@ -318,6 +325,7 @@ void MR60FDA2Component::process_frame_() { uint32_t select_index = find_nearest_index(current_sensitivity, SENSITIVITY, 3); this->sensitivity_select_->publish_state(select_index); } +#endif ESP_LOGD(TAG, "Mounting height: %.2f, Height threshold: %.2f, Sensitivity: %" PRIu32, install_height_float, height_threshold_float, current_sensitivity); diff --git a/tests/components/seeed_mr60fda2/common-minimal.yaml b/tests/components/seeed_mr60fda2/common-minimal.yaml new file mode 100644 index 00000000000..da329aefbe3 --- /dev/null +++ b/tests/components/seeed_mr60fda2/common-minimal.yaml @@ -0,0 +1,3 @@ +# The hub alone, without the binary sensors and selects that process_frame_() guards +seeed_mr60fda2: + id: my_seeed_mr60fda2_minimal diff --git a/tests/components/seeed_mr60fda2/test-minimal.esp32-idf.yaml b/tests/components/seeed_mr60fda2/test-minimal.esp32-idf.yaml new file mode 100644 index 00000000000..4338267485c --- /dev/null +++ b/tests/components/seeed_mr60fda2/test-minimal.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + seeed_mr60fda2: !include common-minimal.yaml diff --git a/tests/components/seeed_mr60fda2/test.esp32-idf.yaml b/tests/components/seeed_mr60fda2/test.esp32-idf.yaml new file mode 100644 index 00000000000..07faf09a8df --- /dev/null +++ b/tests/components/seeed_mr60fda2/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + seeed_mr60fda2: !include common.yaml diff --git a/tests/components/seeed_mr60fda2/test.esp8266-ard.yaml b/tests/components/seeed_mr60fda2/test.esp8266-ard.yaml new file mode 100644 index 00000000000..f059ca9f66f --- /dev/null +++ b/tests/components/seeed_mr60fda2/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + seeed_mr60fda2: !include common.yaml From d6abf35138be086d4b7f81685de8d49e06ddaf6d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 03:01:10 -1000 Subject: [PATCH 090/142] [sx126x][sx127x] Stop the watchdog reset loop when a packet is sent before setup (#20727) --- esphome/components/sx126x/sx126x.cpp | 7 +++++++ esphome/components/sx126x/sx126x.h | 2 +- esphome/components/sx127x/sx127x.cpp | 7 +++++++ esphome/components/sx127x/sx127x.h | 2 +- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index 6c1281c89b1..0bac0eb0466 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -1,4 +1,5 @@ #include "sx126x.h" +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -313,6 +314,11 @@ void SX126x::set_packet_params_(uint8_t payload_length) { } SX126xError SX126x::transmit_packet(const uint8_t *data, size_t len) { + // Callers such as a template number's on_value can run before setup() or after a failed setup + if (!this->is_ready()) { + ESP_LOGW(TAG, "Radio not ready, packet dropped"); + return SX126xError::SX126X_ERROR_NOT_READY; + } if (this->payload_length_ > 0 && this->payload_length_ != len) { ESP_LOGE(TAG, "Packet size does not match config"); return SX126xError::INVALID_PARAMS; @@ -333,6 +339,7 @@ SX126xError SX126x::transmit_packet(const uint8_t *data, size_t len) { // wait until transmit completes, typically the delay will be less than 100 ms uint32_t start = millis(); while (!this->dio1_pin_->digital_read()) { + App.feed_wdt(); if (millis() - start > TRANSMIT_TIMEOUT_MS) { ESP_LOGE(TAG, "Transmit packet failure"); ret = SX126xError::TIMEOUT; diff --git a/esphome/components/sx126x/sx126x.h b/esphome/components/sx126x/sx126x.h index 536bb93c48f..aa275c9e972 100644 --- a/esphome/components/sx126x/sx126x.h +++ b/esphome/components/sx126x/sx126x.h @@ -48,7 +48,7 @@ enum SX126xBw : uint8_t { SX126X_BW_500000, }; -enum class SX126xError { NONE = 0, TIMEOUT, INVALID_PARAMS }; +enum class SX126xError { NONE = 0, TIMEOUT, INVALID_PARAMS, SX126X_ERROR_NOT_READY }; class SX126xListener { public: diff --git a/esphome/components/sx127x/sx127x.cpp b/esphome/components/sx127x/sx127x.cpp index 16c3d1dd21f..fb816094c29 100644 --- a/esphome/components/sx127x/sx127x.cpp +++ b/esphome/components/sx127x/sx127x.cpp @@ -1,4 +1,5 @@ #include "sx127x.h" +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -256,6 +257,11 @@ size_t SX127x::get_max_packet_size() { } SX127xError SX127x::transmit_packet(const uint8_t *data, size_t len) { + // Callers such as a template number's on_value can run before setup() or after a failed setup + if (!this->is_ready()) { + ESP_LOGW(TAG, "Radio not ready, packet dropped"); + return SX127xError::SX127X_ERROR_NOT_READY; + } if (this->payload_length_ > 0 && this->payload_length_ != len) { ESP_LOGE(TAG, "Packet size does not match config"); return SX127xError::INVALID_PARAMS; @@ -292,6 +298,7 @@ SX127xError SX127x::transmit_packet(const uint8_t *data, size_t len) { // wait until transmit completes, typically the delay will be less than 100 ms uint32_t start = millis(); while (!this->dio0_pin_->digital_read()) { + App.feed_wdt(); if (millis() - start > 4000) { ESP_LOGE(TAG, "Transmit packet failure"); ret = SX127xError::TIMEOUT; diff --git a/esphome/components/sx127x/sx127x.h b/esphome/components/sx127x/sx127x.h index 27031d98b2b..547c52e7bb3 100644 --- a/esphome/components/sx127x/sx127x.h +++ b/esphome/components/sx127x/sx127x.h @@ -36,7 +36,7 @@ enum SX127xBw : uint8_t { SX127X_BW_500_0, }; -enum class SX127xError { NONE = 0, TIMEOUT, INVALID_PARAMS }; +enum class SX127xError { NONE = 0, TIMEOUT, INVALID_PARAMS, SX127X_ERROR_NOT_READY }; class SX127xListener { public: From 25d4b88605566506212cacbf7ecb2f2d06c8a010 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 03:01:59 -1000 Subject: [PATCH 091/142] [nfc] Add unit tests for the URI record prefix codes (#20724) --- tests/components/nfc/test_ndef_record_uri.cpp | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/components/nfc/test_ndef_record_uri.cpp diff --git a/tests/components/nfc/test_ndef_record_uri.cpp b/tests/components/nfc/test_ndef_record_uri.cpp new file mode 100644 index 00000000000..5629c23c9f6 --- /dev/null +++ b/tests/components/nfc/test_ndef_record_uri.cpp @@ -0,0 +1,96 @@ +#include + +#include +#include +#include + +#include "esphome/components/nfc/ndef_record_uri.h" + +namespace esphome::nfc::testing { + +static std::string decode(const std::vector &payload) { return NdefRecordUri(payload).get_payload(); } + +static uint8_t encoded_prefix(const std::string &uri) { return NdefRecordUri(uri).get_encoded_payload()[0]; } + +TEST(NdefRecordUri, DecodeExpandsPrefixCode) { + EXPECT_EQ(decode({0x01, 'a', '.', 'b'}), "http://www.a.b"); + EXPECT_EQ(decode({0x04, 'a', '.', 'b'}), "https://a.b"); + EXPECT_EQ(decode({0x07, 'a'}), "ftp://anonymous:anonymous@a"); +} + +// URI identifier codes from the NFC Forum URI Record Type Definition, kept separate from the +// component's table so a reordered entry fails here +static constexpr const char *SPEC_PREFIXES[] = {"", + "http://www.", + "https://www.", + "http://", + "https://", + "tel:", + "mailto:", + "ftp://anonymous:anonymous@", + "ftp://ftp.", + "ftps://", + "sftp://", + "smb://", + "nfs://", + "ftp://", + "dav://", + "news:", + "telnet://", + "imap:", + "rtsp://", + "urn:", + "pop:", + "sip:", + "sips:", + "tftp:", + "btspp://", + "btl2cap://", + "btgoep://", + "tcpobex://", + "irdaobex://", + "file://", + "urn:epc:id:", + "urn:epc:tag:", + "urn:epc:pat:", + "urn:epc:raw:", + "urn:epc:", + "urn:nfc:"}; + +TEST(NdefRecordUri, DecodeEveryPrefixCode) { + for (size_t code = 1; code < std::size(SPEC_PREFIXES); code++) + EXPECT_EQ(decode({static_cast(code), 'x'}), std::string(SPEC_PREFIXES[code]) + "x") << "code " << code; +} + +TEST(NdefRecordUri, DecodeAcceptsLastPrefixCode) { EXPECT_EQ(decode({0x23, 'x'}), "urn:nfc:x"); } + +TEST(NdefRecordUri, DecodeIgnoresUnknownPrefixCode) { + EXPECT_EQ(decode({0x00, 'x'}), "x"); + EXPECT_EQ(decode({0x24, 'x'}), "x"); +} + +// Encode takes the first prefix in table order that matches, so codes 0x1E to 0x23 are never written; +// these tests pin that current behaviour, not the spec's longest match +TEST(NdefRecordUri, EncodePicksFirstMatchingPrefix) { + EXPECT_EQ(encoded_prefix("https://www.a.b"), 0x02); + EXPECT_EQ(encoded_prefix("http://a.b"), 0x03); + EXPECT_EQ(encoded_prefix("urn:epc:id:x"), 0x13); +} + +TEST(NdefRecordUri, EncodeStopsBeforeLastPrefixCode) { + // urn:nfc: (0x23) is never chosen; the shorter urn: (0x13) matches first + EXPECT_EQ(encoded_prefix("urn:nfc:x"), 0x13); +} + +TEST(NdefRecordUri, EncodeStripsPrefix) { + const std::vector expected = {0x02, 'a', '.', 'b'}; + EXPECT_EQ(NdefRecordUri("https://www.a.b").get_encoded_payload(), expected); +} + +TEST(NdefRecordUri, EncodeWithoutPrefix) { + EXPECT_EQ(encoded_prefix("ht"), 0x00); + const std::vector expected = {0x00, 'x', 'y'}; + EXPECT_EQ(NdefRecordUri("xy").get_encoded_payload(), expected); +} + +} // namespace esphome::nfc::testing From 10f42e3e7c79ba77015a4e332cf2bfda1148347b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 03:03:34 -1000 Subject: [PATCH 092/142] [shelly_dimmer] Build the STM32 firmware update path in CI (#20677) --- tests/components/shelly_dimmer/common-update.yaml | 15 +++++++++++++++ .../shelly_dimmer/test-update.esp8266-ard.yaml | 3 +++ 2 files changed, 18 insertions(+) create mode 100644 tests/components/shelly_dimmer/common-update.yaml create mode 100644 tests/components/shelly_dimmer/test-update.esp8266-ard.yaml diff --git a/tests/components/shelly_dimmer/common-update.yaml b/tests/components/shelly_dimmer/common-update.yaml new file mode 100644 index 00000000000..babf55b2300 --- /dev/null +++ b/tests/components/shelly_dimmer/common-update.yaml @@ -0,0 +1,15 @@ +light: + - platform: shelly_dimmer + name: Shelly Dimmer Light + power: + name: Shelly Dimmer Power + voltage: + name: Shelly Dimmer Voltage + current: + name: Shelly Dimmer Current + max_brightness: 500 + firmware: + version: "51.6" + update: true + nrst_pin: 13 + boot0_pin: 14 diff --git a/tests/components/shelly_dimmer/test-update.esp8266-ard.yaml b/tests/components/shelly_dimmer/test-update.esp8266-ard.yaml new file mode 100644 index 00000000000..185686969df --- /dev/null +++ b/tests/components/shelly_dimmer/test-update.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + shelly_dimmer: !include common-update.yaml From 9c568525b0229b319d5832ab17bdb18f7bd1fc4b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 03:08:41 -1000 Subject: [PATCH 093/142] [core] Add CodSpeed benchmarks for importing entity components (#20648) --- .../python/test_component_import_bench.py | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 tests/benchmarks/python/test_component_import_bench.py diff --git a/tests/benchmarks/python/test_component_import_bench.py b/tests/benchmarks/python/test_component_import_bench.py new file mode 100644 index 00000000000..35981cb7ee4 --- /dev/null +++ b/tests/benchmarks/python/test_component_import_bench.py @@ -0,0 +1,105 @@ +"""CodSpeed benchmarks for importing entity components. + +Every ``esphome config`` or ``esphome compile`` imports the Python module of +each component the YAML uses. A component that imports its optional +integrations (mqtt, web_server, zigbee) at module scope drags their whole +import chains in for every user, so the cost of a fresh import is what these +benchmarks pin down. + +The core of esphome is imported once up front and stays loaded; each run +drops only the component modules from ``sys.modules`` and imports the +component again, so the import machinery and the module bodies of the +component and everything it pulls in run every time, as they do in a new +process, while a change to the core moves no number here. The modules and +registry entries the session had before a case are put back afterwards, so +later tests keep the objects they already hold. + +Skipped when ``pytest-codspeed`` isn't installed so the regular unit-test +suite keeps working unchanged. +""" + +from __future__ import annotations + +from collections.abc import Generator, Iterable +import importlib +import sys + +import pytest + +pytest.importorskip("pytest_codspeed") + +# Load the core before the first case so no case pays for it. +from esphome import automation, pins # noqa: E402 +import esphome.codegen # noqa: E402, F401 +import esphome.config_validation # noqa: E402, F401 + +COMPONENTS = ("binary_sensor", "sensor", "logger") +COMPONENT_PACKAGE = "esphome.components" +COMPONENT_PREFIX = COMPONENT_PACKAGE + "." +# Core registries that component module bodies write to when they are imported +REGISTRIES = ( + automation.ACTION_REGISTRY, + automation.CONDITION_REGISTRY, + pins.PIN_SCHEMA_REGISTRY, +) + + +def _component_modules() -> dict[str, object]: + # The package itself goes too: ``from esphome.components import x`` is + # answered from its attributes, so a stale package would hide the import. + return { + n: m + for n, m in sys.modules.items() + if n == COMPONENT_PACKAGE or n.startswith(COMPONENT_PREFIX) + } + + +def _forget_component_modules() -> None: + for name in _component_modules(): + del sys.modules[name] + + +def _import_component(name: str) -> object: + _forget_component_modules() + return importlib.import_module(COMPONENT_PREFIX + name) + + +_MISSING = object() + + +def _parent_bindings(names: Iterable[str]) -> list[tuple[object, str, object]]: + # An import binds the module to its parent package; a package may hold something else + # under that name (substitutions keeps a Jinja() instance as ``jinja``), so the value + # the parent has now is what goes back, not the module. + bindings = [] + for name in names: + parent, _, child = name.rpartition(".") + bindings.append( + (sys.modules[parent], child, getattr(sys.modules[parent], child, _MISSING)) + ) + return bindings + + +@pytest.fixture +def restore_component_modules() -> Generator[None]: + saved = _component_modules() + saved_bindings = _parent_bindings(saved) + saved_registries = [dict(registry) for registry in REGISTRIES] + yield + _forget_component_modules() + sys.modules.update(saved) + for parent, child, value in saved_bindings: + if value is _MISSING: + delattr(parent, child) + else: + setattr(parent, child, value) + # The re-imported bodies registered their actions, conditions and pin schemas again + for registry, entries in zip(REGISTRIES, saved_registries, strict=True): + registry.clear() + registry.update(entries) + + +@pytest.mark.usefixtures("restore_component_modules") +@pytest.mark.parametrize("component", COMPONENTS) +def test_import_component(benchmark, component: str) -> None: + benchmark(_import_component, component) From 7d7a78308d0259f07747e33e5a08de7b39b520fd Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:05:02 -0400 Subject: [PATCH 094/142] [ci] Test tinyusb_keyboard in isolation (#20749) --- script/analyze_component_buses.py | 1 + 1 file changed, 1 insertion(+) diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index f7680f105ca..fee1438636b 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -98,6 +98,7 @@ ISOLATED_COMPONENTS = { "neopixelbus": "RMT type conflict with ESP32 Arduino/ESP-IDF headers (enum vs struct rmt_channel_t)", "packages": "cannot merge packages", "tinyusb": "Conflicts with usb_host component - cannot be used together", + "tinyusb_keyboard": "Depends on tinyusb which conflicts with usb_host", "uart_mux": "Depends on tinyusb which conflicts with usb_host", "usb_cdc_acm": "Depends on tinyusb which conflicts with usb_host", } From 0132f722a359e41297297a003c149725897f50ed Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 14 Oct 2026 13:24:36 -0400 Subject: [PATCH 095/142] [audio] Bump esp-audio-libs to v4.1.0 (#20754) --- esphome/components/audio/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 332638e75bb..36a9161eb45 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -338,7 +338,7 @@ async def to_code(config: ConfigType) -> None: # HTTPS streams verify the server against the root certificate bundle require_certificate_bundle() - add_idf_component(name="esphome/esp-audio-libs", ref="4.0.1") + add_idf_component(name="esphome/esp-audio-libs", ref="4.1.0") data = _get_data() diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 2ef48602d1b..87ec5cdcfc4 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -4,7 +4,7 @@ dependencies: esphome/dlms_parser: version: 2.2.0 esphome/esp-audio-libs: - version: 4.0.1 + version: 4.1.0 esphome/esp-micro-speech-features: version: 1.2.3 esphome/micro-decoder: From 9fe75e262f629d6692553d017efe94afcbb19c34 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:09:53 -0400 Subject: [PATCH 096/142] [ci] Add a host clang-tidy environment and job (#20747) --- .github/workflows/ci.yml | 26 +++++++++--- esphome/components/host/core.cpp | 4 +- esphome/components/host/hal.cpp | 2 +- esphome/components/host/helpers.cpp | 4 +- esphome/components/host/preferences.cpp | 8 ++-- .../http_request/http_request_host.cpp | 4 +- esphome/components/ota/ota_backend_host.cpp | 34 +++++++-------- .../prometheus/prometheus_handler.cpp | 2 +- .../prometheus/prometheus_handler.h | 2 +- esphome/components/sdl/sdl_esphome.cpp | 6 +-- esphome/components/sdl/sdl_esphome.h | 4 +- esphome/components/snapshot/gif.cpp | 6 +-- esphome/components/snapshot/snapshot.cpp | 2 +- .../components/uart/uart_component_host.cpp | 7 ++-- esphome/components/uart/uart_component_host.h | 6 ++- .../web_server_base/web_server_base.cpp | 2 +- .../web_server_base/web_server_base.h | 2 +- esphome/core/defines.h | 8 +++- platformio.ini | 17 ++++++++ script/clang-tidy | 42 +++++++++++++++---- 20 files changed, 125 insertions(+), 63 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d68bb93ec9f..04df45d7df7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,8 +81,8 @@ jobs: - name: Install apt packages (cached) uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 with: - packages: libsdl2-dev ccache - version: 1.1 + packages: libsdl2-dev libmbedtls-dev ccache + version: 1.2 determine-jobs: name: Determine which jobs to run @@ -415,8 +415,8 @@ jobs: timeout-minutes: 10 uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 with: - packages: libsdl2-dev ccache - version: 1.1 + packages: libsdl2-dev libmbedtls-dev ccache + version: 1.2 - name: Set up Python 3.13 id: python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -695,6 +695,10 @@ jobs: name: Run script/clang-tidy for RP2 options: --environment rp2-tidy --grep USE_RP2 pio_cache_key: tidyrp2 + - id: clang-tidy + name: Run script/clang-tidy for host + options: --environment host-tidy --grep USE_HOST + pio_cache_key: tidyhost - id: clang-tidy name: Run script/clang-tidy for LibreTiny environments: bk72xx-tidy ln882h-tidy rtl87xxb-tidy rtl87xxc-tidy @@ -707,6 +711,16 @@ jobs: with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 + - name: Install apt packages (cached) + if: matrix.pio_cache_key == 'tidyhost' + # libsdl2-dev is needed by the sdl component on the host, libmbedtls-dev + # by dsmr and dlms_meter. Packages and version must match seed-apt-cache + # exactly. + timeout-minutes: 10 + uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 + with: + packages: libsdl2-dev libmbedtls-dev ccache + version: 1.2 - name: Restore Python id: restore-python @@ -1102,8 +1116,8 @@ jobs: timeout-minutes: 10 uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 with: - packages: libsdl2-dev ccache - version: 1.1 + packages: libsdl2-dev libmbedtls-dev ccache + version: 1.2 - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/esphome/components/host/core.cpp b/esphome/components/host/core.cpp index f753534efd9..2cc455e3f45 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -59,8 +59,8 @@ namespace esphome::host { char **get_argv() { return s_argv; } const std::string &get_exe_path() { - static const std::string empty; - return s_exe_path != nullptr ? *s_exe_path : empty; + static const std::string EMPTY; + return s_exe_path != nullptr ? *s_exe_path : EMPTY; } void arm_reexec(const std::string &path) { diff --git a/esphome/components/host/hal.cpp b/esphome/components/host/hal.cpp index 9108c1ea9d3..caee316a53e 100644 --- a/esphome/components/host/hal.cpp +++ b/esphome/components/host/hal.cpp @@ -5,7 +5,7 @@ #include "esphome/core/log.h" #include "core.h" -#include +#include #include #include #include diff --git a/esphome/components/host/helpers.cpp b/esphome/components/host/helpers.cpp index 9e203efcb9c..f8b7a0c5f08 100644 --- a/esphome/components/host/helpers.cpp +++ b/esphome/components/host/helpers.cpp @@ -39,8 +39,8 @@ bool Mutex::try_lock() { return static_cast(handle_)->try_lock(); void Mutex::unlock() { static_cast(handle_)->unlock(); } void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter) - static const uint8_t esphome_host_mac_address[MAC_ADDRESS_SIZE] = USE_ESPHOME_HOST_MAC_ADDRESS; - memcpy(mac, esphome_host_mac_address, sizeof(esphome_host_mac_address)); + static constexpr uint8_t HOST_MAC_ADDRESS[MAC_ADDRESS_SIZE] = USE_ESPHOME_HOST_MAC_ADDRESS; + memcpy(mac, HOST_MAC_ADDRESS, sizeof(HOST_MAC_ADDRESS)); } } // namespace esphome diff --git a/esphome/components/host/preferences.cpp b/esphome/components/host/preferences.cpp index 9a8208e96ea..0f6b03ff0b5 100644 --- a/esphome/components/host/preferences.cpp +++ b/esphome/components/host/preferences.cpp @@ -2,6 +2,7 @@ #include #include +#include #include "preferences.h" #include "esphome/core/application.h" #include "esphome/core/log.h" @@ -45,11 +46,10 @@ void HostPreferences::setup_() { break; if (fread(&len, sizeof(len), 1, fp) != 1) break; - uint8_t data[len]; - if (fread(data, sizeof(uint8_t), len, fp) != len) + std::vector data(len); + if (fread(data.data(), sizeof(uint8_t), len, fp) != len) break; - std::vector vec(data, data + len); - this->data_[key] = vec; + this->data_[key] = std::move(data); } fclose(fp); } diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index 0130445acdc..1856b97e6e9 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -53,7 +53,7 @@ std::shared_ptr HttpRequestHost::perform(const char *url, const c for (const auto &[name, value] : request_headers) { h_headers.emplace(name, value); } - httplib::Client client(scheme_host.c_str()); + httplib::Client client(scheme_host); if (!client.is_valid()) { ESP_LOGE(TAG, "HTTP Request failed; Invalid URL: %s", url); return nullptr; @@ -114,7 +114,7 @@ std::shared_ptr HttpRequestHost::perform(const char *url, const c } container->content_length = container->response_body_.size(); - for (auto header : response.headers) { + for (const auto &header : response.headers) { ESP_LOGD(TAG, "Header: %s: %s", header.first.c_str(), header.second.c_str()); auto lower_name = str_lower_case(header.first); // NOLINT if (should_collect_header(lower_case_collect_headers, lower_name)) { diff --git a/esphome/components/ota/ota_backend_host.cpp b/esphome/components/ota/ota_backend_host.cpp index 923b0afe18a..765d6a04c08 100644 --- a/esphome/components/ota/ota_backend_host.cpp +++ b/esphome/components/ota/ota_backend_host.cpp @@ -32,7 +32,7 @@ ESPHOME_LOG_TAG(TAG, "ota"); constexpr size_t MAX_OTA_SIZE = 256u * 1024u * 1024u; // 256 MiB constexpr size_t HEADER_PEEK_SIZE = 64; -ssize_t read_header_(const char *path, uint8_t *buf, size_t len) { +ssize_t read_header(const char *path, uint8_t *buf, size_t len) { int fd = ::open(path, O_RDONLY); if (fd < 0) return -1; @@ -50,7 +50,7 @@ struct ElfIdent { uint16_t e_type; }; -ElfIdent parse_elf_(const uint8_t *buf, size_t len) { +ElfIdent parse_elf(const uint8_t *buf, size_t len) { ElfIdent out{}; if (len < EI_NIDENT + 4) return out; @@ -76,17 +76,17 @@ ElfIdent parse_elf_(const uint8_t *buf, size_t len) { return out; } -bool validate_elf_(const char *staging_path, const std::string &exe_path) { +bool validate_elf(const char *staging_path, const std::string &exe_path) { uint8_t new_buf[HEADER_PEEK_SIZE]; uint8_t cur_buf[HEADER_PEEK_SIZE]; - ssize_t new_n = read_header_(staging_path, new_buf, sizeof(new_buf)); - ssize_t cur_n = read_header_(exe_path.c_str(), cur_buf, sizeof(cur_buf)); + ssize_t new_n = read_header(staging_path, new_buf, sizeof(new_buf)); + ssize_t cur_n = read_header(exe_path.c_str(), cur_buf, sizeof(cur_buf)); if (new_n < static_cast(EI_NIDENT + 4) || cur_n < static_cast(EI_NIDENT + 4)) { ESP_LOGE(TAG, "ELF header read failed"); return false; } - ElfIdent new_id = parse_elf_(new_buf, new_n); - ElfIdent cur_id = parse_elf_(cur_buf, cur_n); + ElfIdent new_id = parse_elf(new_buf, new_n); + ElfIdent cur_id = parse_elf(cur_buf, cur_n); if (!new_id.valid) { ESP_LOGE(TAG, "Uploaded payload is not a valid ELF"); return false; @@ -122,7 +122,7 @@ struct MachOIdent { uint32_t cpusubtype; }; -MachOIdent parse_macho_(const uint8_t *buf, size_t len) { +MachOIdent parse_macho(const uint8_t *buf, size_t len) { MachOIdent out{}; // mach_header is the common prefix of mach_header and mach_header_64; // cputype/cpusubtype/filetype have identical offsets in both. @@ -153,18 +153,18 @@ MachOIdent parse_macho_(const uint8_t *buf, size_t len) { return out; } -bool validate_macho_(const char *staging_path, const std::string &exe_path) { +bool validate_macho(const char *staging_path, const std::string &exe_path) { uint8_t new_buf[HEADER_PEEK_SIZE]; uint8_t cur_buf[HEADER_PEEK_SIZE]; - ssize_t new_n = read_header_(staging_path, new_buf, sizeof(new_buf)); - ssize_t cur_n = read_header_(exe_path.c_str(), cur_buf, sizeof(cur_buf)); + ssize_t new_n = read_header(staging_path, new_buf, sizeof(new_buf)); + ssize_t cur_n = read_header(exe_path.c_str(), cur_buf, sizeof(cur_buf)); if (new_n < static_cast(sizeof(struct mach_header)) || cur_n < static_cast(sizeof(struct mach_header))) { ESP_LOGE(TAG, "Mach-O header read failed"); return false; } - MachOIdent new_id = parse_macho_(new_buf, new_n); - MachOIdent cur_id = parse_macho_(cur_buf, cur_n); + MachOIdent new_id = parse_macho(new_buf, new_n); + MachOIdent cur_id = parse_macho(cur_buf, cur_n); if (!new_id.valid) { ESP_LOGE(TAG, "Uploaded payload is not a valid thin Mach-O executable"); return false; @@ -182,11 +182,11 @@ bool validate_macho_(const char *staging_path, const std::string &exe_path) { } #endif // __APPLE__ -bool validate_executable_(const char *staging_path, const std::string &exe_path) { +bool validate_executable(const char *staging_path, const std::string &exe_path) { #ifdef __linux__ - return validate_elf_(staging_path, exe_path); + return validate_elf(staging_path, exe_path); #elif defined(__APPLE__) - return validate_macho_(staging_path, exe_path); + return validate_macho(staging_path, exe_path); #else (void) staging_path; (void) exe_path; @@ -298,7 +298,7 @@ OTAResponseTypes HostOTABackend::end() { ::close(this->fd_); this->fd_ = -1; - if (!validate_executable_(this->staging_path_.c_str(), this->final_path_)) { + if (!validate_executable(this->staging_path_.c_str(), this->final_path_)) { ::unlink(this->staging_path_.c_str()); this->staging_path_.clear(); return OTA_RESPONSE_ERROR_UPDATE_END; diff --git a/esphome/components/prometheus/prometheus_handler.cpp b/esphome/components/prometheus/prometheus_handler.cpp index 00c2e043102..b2bd7bd9bd7 100644 --- a/esphome/components/prometheus/prometheus_handler.cpp +++ b/esphome/components/prometheus/prometheus_handler.cpp @@ -1,5 +1,5 @@ #include "prometheus_handler.h" -#ifdef USE_NETWORK +#if defined(USE_NETWORK) && !defined(USE_HOST) #include "esphome/core/application.h" namespace esphome::prometheus { diff --git a/esphome/components/prometheus/prometheus_handler.h b/esphome/components/prometheus/prometheus_handler.h index 9dcbf7e6869..ae5bbcae74a 100644 --- a/esphome/components/prometheus/prometheus_handler.h +++ b/esphome/components/prometheus/prometheus_handler.h @@ -1,6 +1,6 @@ #pragma once #include "esphome/core/defines.h" -#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) && !defined(USE_HOST) #include #include diff --git a/esphome/components/sdl/sdl_esphome.cpp b/esphome/components/sdl/sdl_esphome.cpp index a764b74581f..5dd25ec2bb8 100644 --- a/esphome/components/sdl/sdl_esphome.cpp +++ b/esphome/components/sdl/sdl_esphome.cpp @@ -157,7 +157,7 @@ void Sdl::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t * Display::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); } else { auto stride = x_offset + w + x_pad; - auto data = ptr + (stride * y_offset + x_offset) * 2; + const auto *data = ptr + (stride * y_offset + x_offset) * 2; SDL_UpdateTexture(this->texture_, &rect, data, stride * 2); } this->redraw_(rect); @@ -199,7 +199,7 @@ void Sdl::process_key(uint32_t keycode, bool down) { callback->second(down); } -Sdl *Sdl::instance_for_window_(uint32_t window_id) { +Sdl *Sdl::instance_for_window(uint32_t window_id) { SDL_Window *window = SDL_GetWindowFromID(window_id); if (window == nullptr) return nullptr; @@ -293,7 +293,7 @@ void Sdl::loop() { continue; } - Sdl *target = instance_for_window_(window_id); + Sdl *target = instance_for_window(window_id); if (target == nullptr) { // Nothing to route this to: the window has gone, or it is not one of ours. Say so, otherwise // input that stops working leaves no trace at all. diff --git a/esphome/components/sdl/sdl_esphome.h b/esphome/components/sdl/sdl_esphome.h index c58de643cc8..c927004e1f6 100644 --- a/esphome/components/sdl/sdl_esphome.h +++ b/esphome/components/sdl/sdl_esphome.h @@ -42,7 +42,7 @@ class Sdl final : public display::Display, public snapshot::Snapshot { float get_setup_priority() const override { return setup_priority::HARDWARE; } void dump_config() override { LOG_DISPLAY("", "SDL", this); } template void add_key_listener(int32_t keycode, F &&callback) { - if (!this->key_callbacks_.count(keycode)) { + if (!this->key_callbacks_.contains(keycode)) { this->key_callbacks_[keycode] = CallbackManager(); } this->key_callbacks_[keycode].add(std::forward(callback)); @@ -66,7 +66,7 @@ class Sdl final : public display::Display, public snapshot::Snapshot { bool capture_bgr(uint8_t *dest, size_t row_stride) override; void handle_event_(const SDL_Event &event); /// The display owning the given window, or nullptr if it is not one of ours. - static Sdl *instance_for_window_(uint32_t window_id); + static Sdl *instance_for_window(uint32_t window_id); SDL_Renderer *renderer_{}; SDL_Window *window_{}; SDL_Texture *texture_{}; diff --git a/esphome/components/snapshot/gif.cpp b/esphome/components/snapshot/gif.cpp index 5650c1c404b..73a16b896e2 100644 --- a/esphome/components/snapshot/gif.cpp +++ b/esphome/components/snapshot/gif.cpp @@ -50,9 +50,9 @@ struct BitWriter { uint32_t buffer{0}; unsigned count{0}; - void put(uint32_t code, unsigned size) { - this->buffer |= code << this->count; - this->count += size; + void put(uint32_t value, unsigned bits) { + this->buffer |= value << this->count; + this->count += bits; while (this->count >= 8) { this->out.push_back(static_cast(this->buffer & 0xFF)); this->buffer >>= 8; diff --git a/esphome/components/snapshot/snapshot.cpp b/esphome/components/snapshot/snapshot.cpp index 58406f17ffd..fb99154cc06 100644 --- a/esphome/components/snapshot/snapshot.cpp +++ b/esphome/components/snapshot/snapshot.cpp @@ -55,7 +55,7 @@ std::string sanitise_filename(const char *const name, const char *extension, boo break; } char c = *p; - if (!(std::isalnum(static_cast(c)) || c == '.' || c == '_' || c == '-')) { + if (!std::isalnum(static_cast(c)) && c != '.' && c != '_' && c != '-') { c = '_'; changed = true; } diff --git a/esphome/components/uart/uart_component_host.cpp b/esphome/components/uart/uart_component_host.cpp index 7fb704536dd..833ccc425d3 100644 --- a/esphome/components/uart/uart_component_host.cpp +++ b/esphome/components/uart/uart_component_host.cpp @@ -9,11 +9,11 @@ #error This HostUartComponent implementation is not supported on this host OS #endif -#include -#include +#include +#include +#include #include #include -#include #include #include @@ -210,7 +210,6 @@ void HostUartComponent::write_array(const uint8_t *data, size_t len) { this->debug_callback_.call(UART_DIRECTION_TX, data[i]); } #endif - return; } bool HostUartComponent::peek_byte(uint8_t *data) { diff --git a/esphome/components/uart/uart_component_host.h b/esphome/components/uart/uart_component_host.h index bca62debf10..27869e969c4 100644 --- a/esphome/components/uart/uart_component_host.h +++ b/esphome/components/uart/uart_component_host.h @@ -2,6 +2,8 @@ #ifdef USE_HOST +#include + #include "esphome/core/component.h" #include "esphome/core/log.h" #include "uart_component.h" @@ -10,7 +12,7 @@ namespace esphome::uart { class HostUartComponent final : public UARTComponent, public Component { public: - virtual ~HostUartComponent(); + ~HostUartComponent(); void setup() override; void dump_config() override; float get_setup_priority() const override { return setup_priority::BUS; } @@ -19,7 +21,7 @@ class HostUartComponent final : public UARTComponent, public Component { bool read_array(uint8_t *data, size_t len) override; size_t available() override; UARTFlushResult flush() override; - void set_name(std::string port_name) { port_name_ = port_name; }; + void set_name(std::string port_name) { this->port_name_ = std::move(port_name); } protected: void update_error_(const std::string &error); diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index 873c5b5a497..50144b6df73 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -1,5 +1,5 @@ #include "web_server_base.h" -#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) && !defined(USE_HOST) namespace esphome::web_server_base { diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 72d3bf75b1c..38098971f4f 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -1,6 +1,6 @@ #pragma once #include "esphome/core/defines.h" -#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) +#if defined(USE_NETWORK) && !defined(USE_ZEPHYR) && !defined(USE_HOST) #include #include "esphome/core/progmem.h" diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 234160fc290..f589155c97f 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -331,7 +331,7 @@ #define USE_NOISE #define USE_NOISE_SPARE_EPHEMERAL #define USE_SHA256 -#ifndef USE_RP2 // no MQTT backend or esp_wireguard library on RP2 +#if !defined(USE_RP2) && !defined(USE_HOST) // no MQTT backend or esp_wireguard library on RP2 and host #define USE_MQTT #define USE_MQTT_COVER_JSON #define USE_WIREGUARD @@ -420,7 +420,9 @@ #define USE_ESPNOW #define USE_ESPNOW_MAX_PAYLOAD_SIZE 1470 +#ifndef USE_HOST // web_server, captive_portal: no ESPAsyncWebServer on host #define USE_CAPTIVE_PORTAL +#endif #define USE_WIFI_SCAN_RESULTS_LOCK #define USE_ESP32_BLE #define USE_ESP32_BLE_MAX_CONNECTIONS 3 @@ -478,6 +480,7 @@ #define USE_SPI #define USE_SPI_PSRAM_DMA #define USE_VOICE_ASSISTANT +#ifndef USE_HOST #define USE_WEBSERVER #define USE_WEBSERVER_AUTH #define USE_WEBSERVER_AUTH_DIGEST @@ -489,6 +492,7 @@ #define USE_WEBSERVER_ALLOWED_ORIGINS #define WEB_SERVER_DEFAULT_HEADERS_COUNT 1 #define USE_CAPTIVE_PORTAL_GZIP +#endif #define USE_WIFI_11KV_SUPPORT #define USE_WIFI_FAST_CONNECT #define USE_WIFI_FAST_CONNECT_IN_FLASH @@ -691,6 +695,8 @@ #endif #ifdef USE_HOST +#define USE_ESPHOME_HOST_MAC_ADDRESS \ + { 0x98, 0x35, 0x69, 0xab, 0xf6, 0x79 } #define USE_HTTP_REQUEST_RESPONSE // Host only: the uart arm would shadow the native logger UART arms in other envs #define USE_IMPROV_SERIAL_UART diff --git a/platformio.ini b/platformio.ini index 96f3d25b6eb..9e05356665a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -654,6 +654,23 @@ build_flags = build_unflags = ${common.build_unflags} +[env:host-tidy] +extends = env:host +; every library a header under esphome/ may include: a missing one is a +; fatal error that breaks the all-include analysis for every later header +lib_deps = + ${common.lib_deps} + ${common:idf-component-libs.lib_deps} + ${env:host.lib_deps} +build_flags = + ${env:host.build_flags} + ${flags:clangtidy.build_flags} + ; analyze with the same language model as the embedded targets + -fno-exceptions + -fno-rtti + -I/usr/include/SDL2 ; sdl + -D__LINUX__ ; runtime_image: JPEGDEC's host detection, see its __init__.py + ;;;;;;;; nRF52 ;;;;;;;; [env:nrf52] diff --git a/script/clang-tidy b/script/clang-tidy index a2becb59cc8..3f923403a38 100755 --- a/script/clang-tidy +++ b/script/clang-tidy @@ -68,6 +68,9 @@ def clang_options(idedata, environment): # extract target architecture from triplet in g++ filename triplet = Path(idedata["cxx_path"]).name[:-4] + # env:host compiles with the system g++, so there is no triplet and the + # host's own target and headers are the right ones. + native = environment.startswith("host") if triplet.startswith("xtensa-"): # clang has an Xtensa frontend, but only a generic core -- the esp32 IDF # toolchain headers (xtruntime, xtensa/config) need the GCC core config @@ -83,6 +86,8 @@ def clang_options(idedata, environment): cmd.append("-D__XTENSA__") cmd.append("-D__XTENSA_EL__") cmd.append("-D_LIBC") + elif native: + cmd.append("-Qunused-arguments") else: # RISC-V (and other non-Xtensa targets) have a real clang backend, so # compile for the actual triplet. Espressif's RISC-V GCC -march adds @@ -118,7 +123,7 @@ def clang_options(idedata, environment): "-mfp16-format=ieee", "--param=min-pagesize=0", ) - else: + elif not native: cmd.extend( [ # disable built-in include directories from the host @@ -127,10 +132,11 @@ def clang_options(idedata, environment): ) # set flags + if not native: + # disable built-in include directories from the host + cmd.append("-nostdinc") cmd.extend( [ - # disable built-in include directories from the host - "-nostdinc", # allow to condition code on the presence of clang-tidy "-DCLANG_TIDY", # (esp-idf) Fix __once_callable in some libstdc++ headers @@ -314,11 +320,17 @@ def run_tidy( if args.environment.startswith(("rp2", "bk72xx", "ln882h", "rtl87xx")): # MMIO peripheral access on these bare-metal platforms is all # fixed-address. + disabled_checks.append("-clang-analyzer-core.FixedAddressDereference") + if args.environment.startswith("host"): + # glibc's assert() expands to a call the check mistakes for a + # constant condition. + disabled_checks += ["-misc-static-assert", "-cert-dcl03-c"] + if args.environment.startswith(("rp2", "bk72xx", "ln882h", "rtl87xx", "host")): # bugprone-pointer-arithmetic-on-polymorphic-object (and its # cert-ctr56-cpp alias) crashes clang-tidy 22 with infinite matcher - # recursion on lvgl_esphome.h under the RP2 defines. + # recursion on lvgl_esphome.h under the RP2 defines and on the + # all-include file under the host defines. disabled_checks += [ - "-clang-analyzer-core.FixedAddressDereference", "-bugprone-pointer-arithmetic-on-polymorphic-object", "-cert-ctr56-cpp", ] @@ -327,6 +339,8 @@ def run_tidy( if disabled_checks: invocation.append("--checks=" + ",".join(disabled_checks)) invocation.append(f"--header-filter={Path(basepath).resolve()}/.*") + # Vendored third-party headers are kept as upstream wrote them. + invocation.append("--exclude-header-filter=.*/http_request/httplib\\.h") invocation.append(str(Path(path).resolve())) invocation.append("--") invocation.extend(options) @@ -448,6 +462,11 @@ def main(): if args.grep: files = filter_grep(files, args.grep) + # Not every tidy environment carries googletest (LibreTiny does not), + # so a platform grep that matched a unit test would fail on + # . The tests are analyzed by the full ESP32 IDF run, + # which has no --grep. + files = [f for f in files if not f.startswith("tests/")] if args.exclude_grep: excluded = set(filter_grep(files, args.exclude_grep)) @@ -464,11 +483,16 @@ def main(): # Add header file for checking (before early exit check) if args.all_headers and args.split_at in (None, 1): + all_headers = [ + os.path.relpath(p, cwd) for p in git_ls_files(["esphome/**/*.h"]) + ] + # The path filter applies to headers too: a component left out because + # it does not build in this environment must not come back via the + # all-include file. + if args.files: + all_headers = [p for p in all_headers if file_name_re.search(p)] # When --changed is used, only include changed headers instead of all headers if args.changed: - all_headers = [ - os.path.relpath(p, cwd) for p in git_ls_files(["esphome/**/*.h"]) - ] changed_headers = filter_changed(all_headers) if changed_headers: build_all_include(changed_headers) @@ -476,7 +500,7 @@ def main(): else: print("No changed headers to check") else: - build_all_include() + build_all_include(all_headers) files.insert(0, temp_header_file) print(f"Added all-include header file, new total: {len(files)}") From 91e0edc11d1e750136080aadf39add0599046809 Mon Sep 17 00:00:00 2001 From: Adam <37312852+Aptimex@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:15:48 -0600 Subject: [PATCH 097/142] [pn532] Fix NDEF parsing greater than 255 bytes (#18188) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/pn532/pn532.h | 11 +- .../pn532/pn532_mifare_ultralight.cpp | 88 ++++-- tests/components/pn532/pn532_test.cpp | 12 +- .../pn532/test_mifare_ultralight_ndef.cpp | 273 ++++++++++++++++++ 4 files changed, 346 insertions(+), 38 deletions(-) create mode 100644 tests/components/pn532/test_mifare_ultralight_ndef.cpp diff --git a/esphome/components/pn532/pn532.h b/esphome/components/pn532/pn532.h index 5502eac6ec8..3a9099f3210 100644 --- a/esphome/components/pn532/pn532.h +++ b/esphome/components/pn532/pn532.h @@ -50,13 +50,15 @@ static constexpr size_t PN532_FRAME_MAX_DATA_SIZE = 254; static constexpr size_t PN532_FRAME_MAX_SIZE = 1 + 6 + PN532_FRAME_MAX_DATA_SIZE + 2; /// Holds one frame in either direction so bus traffic never allocates using PN532Frame = StaticVector; -/// Holds pages 3 to 6 (16 bytes) plus an NDEF message of up to 255 bytes and its TLV header, rounded up to whole reads -using UltralightReadBuffer = StaticVector; +/// Longest NDEF message accepted from a MIFARE Ultralight/NTAG tag (the user memory of an NTAG216) +static constexpr uint32_t MIFARE_ULTRALIGHT_MAX_NDEF_SIZE = 888; /// Longest NDEF message accepted from a MIFARE Classic tag (the capacity of a 4K tag) static constexpr uint32_t MIFARE_CLASSIC_MAX_NDEF_SIZE = 3440; /// A MIFARE READ answers with 16 bytes: one Classic block or four Ultralight pages static constexpr size_t MIFARE_READ_SIZE = 16; using MifareReadData = std::array; +/// Pages 3 to 6, read in one go: the capability container and the start of the NDEF TLV +using UltralightHeader = std::array; class PN532BinarySensor; @@ -134,10 +136,11 @@ class PN532 : public PollingComponent { bool write_mifare_classic_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *message); std::unique_ptr read_mifare_ultralight_tag_(nfc::NfcTagUid &uid); - bool read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, UltralightReadBuffer &data); + /// Fills `data` with the bytes from `start_page` onward + bool read_mifare_ultralight_bytes_(uint8_t start_page, std::span data); bool is_mifare_ultralight_formatted_(std::span page_3_to_6); uint16_t read_mifare_ultralight_capacity_(); - bool find_mifare_ultralight_ndef_(std::span page_3_to_6, uint8_t &message_length, + bool find_mifare_ultralight_ndef_(std::span page_3_to_6, uint32_t &message_length, uint8_t &message_start_index); bool write_mifare_ultralight_page_(uint8_t page_num, std::span write_data); bool write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *message); diff --git a/esphome/components/pn532/pn532_mifare_ultralight.cpp b/esphome/components/pn532/pn532_mifare_ultralight.cpp index afb60bd843a..e5d764d732f 100644 --- a/esphome/components/pn532/pn532_mifare_ultralight.cpp +++ b/esphome/components/pn532/pn532_mifare_ultralight.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include "pn532.h" @@ -10,10 +11,9 @@ namespace esphome::pn532 { ESPHOME_LOG_TAG(TAG, "pn532.mifare_ultralight"); std::unique_ptr PN532::read_mifare_ultralight_tag_(nfc::NfcTagUid &uid) { - UltralightReadBuffer data; + UltralightHeader data; // pages 3 to 6 contain various info we are interested in -- do one read to grab it all - if (!this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE * nfc::MIFARE_ULTRALIGHT_READ_SIZE, - data)) { + if (!this->read_mifare_ultralight_bytes_(3, data)) { return make_unique(uid, nfc::NFC_FORUM_TYPE_2); } @@ -22,49 +22,68 @@ std::unique_ptr PN532::read_mifare_ultralight_tag_(nfc::NfcTagUid & return make_unique(uid, nfc::NFC_FORUM_TYPE_2); } - uint8_t message_length; + uint32_t message_length; uint8_t message_start_index; if (!this->find_mifare_ultralight_ndef_(data, message_length, message_start_index)) { ESP_LOGW(TAG, "Couldn't find NDEF message"); return make_unique(uid, nfc::NFC_FORUM_TYPE_2); } - ESP_LOGVV(TAG, "NDEF message length: %u, start: %u", message_length, message_start_index); + ESP_LOGVV(TAG, "NDEF message length: %" PRIu32 ", start: %u", message_length, message_start_index); if (message_length == 0) { return make_unique(uid, nfc::NFC_FORUM_TYPE_2); } - // we already read pages 3-6 earlier -- pick up where we left off so we're not re-reading pages - const uint8_t read_length = message_length + message_start_index > 12 ? message_length + message_start_index - 12 : 0; + + // data[2] is the CC byte read earlier as part of pages 3-6, same value used by read_mifare_ultralight_capacity_() + const uint32_t capacity = data[2] * 8U; + const uint32_t total_length = message_length + message_start_index; + if (total_length > capacity) { + ESP_LOGW(TAG, "NDEF message of %" PRIu32 " bytes at offset %u exceeds tag capacity %" PRIu32, message_length, + message_start_index, capacity); + return make_unique(uid, nfc::NFC_FORUM_TYPE_2); + } + if (message_length > MIFARE_ULTRALIGHT_MAX_NDEF_SIZE) { + ESP_LOGE(TAG, "NDEF message too long: %" PRIu32 " bytes", message_length); + return make_unique(uid, nfc::NFC_FORUM_TYPE_2); + } + + // we already read pages 3-6 earlier, pick up where we left off so we're not re-reading pages + constexpr size_t header_message_bytes = MIFARE_READ_SIZE - nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; // pages 4 to 6 + const uint16_t read_length = + total_length > header_message_bytes ? static_cast(total_length - header_message_bytes) : 0; + // A message up to the old 255 byte limit stays on the stack, a longer one borrows the heap for the read + SmallBufferWithHeapFallback buffer(data.size() + read_length); + std::span message(buffer.get(), data.size() + read_length); + std::copy(data.begin(), data.end(), message.begin()); if (read_length) { - if (!read_mifare_ultralight_bytes_(nfc::MIFARE_ULTRALIGHT_DATA_START_PAGE + 3, read_length, data)) { + if (!this->read_mifare_ultralight_bytes_(nfc::MIFARE_ULTRALIGHT_DATA_START_PAGE + 3, + message.subspan(data.size()))) { ESP_LOGE(TAG, "Error reading tag data"); return make_unique(uid, nfc::NFC_FORUM_TYPE_2); } } // skip page 3 as well as any bytes ahead of message_start_index const size_t skip = message_start_index + nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; - if (skip >= data.size()) { + if (skip >= message.size()) { return make_unique(uid, nfc::NFC_FORUM_TYPE_2); } return make_unique(uid, nfc::NFC_FORUM_TYPE_2, - make_unique(std::span(data).subspan(skip))); + make_unique(std::span(message).subspan(skip))); } -bool PN532::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, UltralightReadBuffer &data) { +bool PN532::read_mifare_ultralight_bytes_(uint8_t start_page, std::span data) { MifareReadData chunk; - for (uint8_t i = 0; i * MIFARE_READ_SIZE < num_bytes; i++) { + for (uint8_t i = 0; i * MIFARE_READ_SIZE < data.size(); i++) { // a READ returns 4 pages (16 bytes) if (!this->mifare_read_(uint8_t(i * nfc::MIFARE_ULTRALIGHT_READ_SIZE + start_page), chunk)) { return false; } // keep only the bytes still wanted from this read - const uint16_t remaining = num_bytes - i * MIFARE_READ_SIZE; - const size_t count = std::min(MIFARE_READ_SIZE, remaining); - for (const uint8_t byte : std::span(chunk).subspan(0, count)) { - data.push_back(byte); - } + const size_t offset = i * MIFARE_READ_SIZE; + const size_t count = std::min(MIFARE_READ_SIZE, data.size() - offset); + std::copy_n(chunk.begin(), count, data.begin() + offset); } char data_buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; @@ -82,15 +101,15 @@ bool PN532::is_mifare_ultralight_formatted_(const std::span page_ } uint16_t PN532::read_mifare_ultralight_capacity_() { - UltralightReadBuffer data; - if (this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE, data) && data.size() > 2) { + std::array data; + if (this->read_mifare_ultralight_bytes_(3, data)) { ESP_LOGV(TAG, "Tag capacity is %u bytes", data[2] * 8U); return data[2] * 8U; } return 0; } -bool PN532::find_mifare_ultralight_ndef_(const std::span page_3_to_6, uint8_t &message_length, +bool PN532::find_mifare_ultralight_ndef_(const std::span page_3_to_6, uint32_t &message_length, uint8_t &message_start_index) { const uint8_t p4_offset = nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; // page 4 will begin 4 bytes into the vector @@ -98,16 +117,31 @@ bool PN532::find_mifare_ultralight_ndef_(const std::span page_3_t return false; } + uint8_t tlv_offset; if (page_3_to_6[p4_offset + 0] == 0x03) { - message_length = page_3_to_6[p4_offset + 1]; - message_start_index = 2; - return true; + tlv_offset = 0; } else if (page_3_to_6[p4_offset + 5] == 0x03) { - message_length = page_3_to_6[p4_offset + 6]; - message_start_index = 7; - return true; + tlv_offset = 5; + } else { + return false; } - return false; + + // Long-form TLV length: 0x03 0xFF LEN_HI LEN_LO, used whenever the message is >= 255 bytes. + // Short-form is just 0x03 LEN. See NFC Forum Type 2 Tag Operation spec section 2.3. + if (page_3_to_6[p4_offset + tlv_offset + 1] == 0xFF) { + if (page_3_to_6.size() <= p4_offset + tlv_offset + 3) { + // long-form marker present but the length bytes weren't read -- fail explicitly instead of + // misreporting a 255-byte short-form message + return false; + } + message_length = + (static_cast(page_3_to_6[p4_offset + tlv_offset + 2]) << 8) | page_3_to_6[p4_offset + tlv_offset + 3]; + message_start_index = tlv_offset + 4; + } else { + message_length = page_3_to_6[p4_offset + tlv_offset + 1]; + message_start_index = tlv_offset + 2; + } + return true; } bool PN532::write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, nfc::NdefMessage *message) { diff --git a/tests/components/pn532/pn532_test.cpp b/tests/components/pn532/pn532_test.cpp index 75f9cf6d088..25125877f25 100644 --- a/tests/components/pn532/pn532_test.cpp +++ b/tests/components/pn532/pn532_test.cpp @@ -163,9 +163,8 @@ TEST(PN532Mifare, UltralightReadTrimsLastChunk) { pn532.responses.push_back(first); pn532.responses.push_back(second); - UltralightReadBuffer data; - ASSERT_TRUE(pn532.read_mifare_ultralight_bytes_(4, 20, data)); - ASSERT_EQ(data.size(), 20u); + std::array data{}; + ASSERT_TRUE(pn532.read_mifare_ultralight_bytes_(4, data)); EXPECT_EQ(data[15], 15); EXPECT_EQ(data[16], 0x10); EXPECT_EQ(data[19], 0x13); @@ -177,15 +176,14 @@ TEST(PN532Mifare, UltralightReadTrimsLastChunk) { TEST(PN532Mifare, UltralightReadRejectsBadResponses) { FakePN532 pn532; - UltralightReadBuffer data; + std::array data{}; pn532.responses.push_back({0x00, 0x01, 0x02}); // short response - EXPECT_FALSE(pn532.read_mifare_ultralight_bytes_(4, 16, data)); + EXPECT_FALSE(pn532.read_mifare_ultralight_bytes_(4, data)); std::vector failed(17, 0x00); failed[0] = 0x01; // timeout status pn532.responses.push_back(failed); - data.clear(); - EXPECT_FALSE(pn532.read_mifare_ultralight_bytes_(4, 16, data)); + EXPECT_FALSE(pn532.read_mifare_ultralight_bytes_(4, data)); } } // namespace esphome::pn532 diff --git a/tests/components/pn532/test_mifare_ultralight_ndef.cpp b/tests/components/pn532/test_mifare_ultralight_ndef.cpp new file mode 100644 index 00000000000..8d1547fc0e4 --- /dev/null +++ b/tests/components/pn532/test_mifare_ultralight_ndef.cpp @@ -0,0 +1,273 @@ +#include + +#include +#include +#include + +#include "esphome/components/nfc/ndef_message.h" +#include "esphome/components/nfc/ndef_record.h" +#include "esphome/components/nfc/nfc.h" +#include "esphome/components/pn532/pn532.h" + +namespace esphome::pn532::testing { + +/// Exposes the protected Ultralight helpers and stands in for the bus. Each MIFARE READ is answered with the +/// 16 bytes of `tag_memory` starting at the requested page, so the full read path can run against synthetic tags. +class TestablePN532 : public PN532 { + public: + using PN532::find_mifare_ultralight_ndef_; + using PN532::read_mifare_ultralight_tag_; + + /// Tag memory starting at page 0 (4 bytes per page) + std::vector tag_memory; + + protected: + bool is_read_ready() override { return true; } + bool write_data(std::span data) override { + this->last_written_.assign(data.begin(), data.end()); + return true; + } + // only used for ACK frames; index 0 is the I2C status byte + bool read_data(PN532Frame &data, size_t len) override { + data = {0x01, 0x00, 0x00, 0xFF, 0x00, 0xFF, 0x00}; + return true; + } + bool read_response(uint8_t command, PN532Frame &data) override { + // frame is preamble, start code (2), LEN, LCS, TFI, InDataExchange, Tg, READ, page, DCS, postamble + if (this->last_written_.size() < 3) + return false; + const size_t start = this->last_written_[this->last_written_.size() - 3] * nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; + data.clear(); + data.push_back(0x00); // status: success + for (size_t i = start; i < start + MIFARE_READ_SIZE; i++) { + data.push_back(i < this->tag_memory.size() ? this->tag_memory[i] : 0x00); + } + return true; + } + + std::vector last_written_; +}; + +/// Builds Ultralight/NTAG tag memory holding `tlv` at page 4, with a capability container that reports +/// `capacity_bytes` of user memory. +std::vector make_tag_memory(const std::vector &tlv, uint16_t capacity_bytes) { + // pages 0-2: UID, lock bytes; page 3: CC (magic, version, size / 8, access) + std::vector memory(12, 0x00); + memory.insert(memory.end(), {0xE1, 0x10, static_cast(capacity_bytes / 8), 0x00}); + memory.insert(memory.end(), tlv.begin(), tlv.end()); + memory.resize(16 + capacity_bytes, 0x00); + return memory; +} + +/// Wraps an encoded NDEF message in a Type 2 Tag TLV, using the long form when it is 255 bytes or more. +std::vector wrap_tlv(const std::vector &encoded) { + std::vector tlv = {0x03}; + if (encoded.size() < 255) { + tlv.push_back(static_cast(encoded.size())); + } else { + tlv.insert(tlv.end(), {0xFF, static_cast(encoded.size() >> 8), static_cast(encoded.size())}); + } + tlv.insert(tlv.end(), encoded.begin(), encoded.end()); + tlv.push_back(0xFE); + return tlv; +} + +// Builds a 16-byte page_3_to_6 buffer: 4 filler bytes for page 3 (unused by this function), +// followed by the given bytes starting at page 4, zero-padded out to the real 4-page read size. +std::vector make_page_3_to_6(std::initializer_list page4_onward) { + std::vector data = {0x00, 0x00, 0x00, 0x00}; + data.insert(data.end(), page4_onward); + data.resize(16, 0x00); + return data; +} + +TEST(FindMifareUltralightNdef, ShortFormAtPage4Start) { + TestablePN532 pn532; + // 0x03 LEN -- message starts right after. + auto data = make_page_3_to_6({0x03, 0x20}); + + uint32_t message_length = 0; + uint8_t message_start_index = 0; + ASSERT_TRUE(pn532.find_mifare_ultralight_ndef_(data, message_length, message_start_index)); + EXPECT_EQ(message_length, 0x20u); + EXPECT_EQ(message_start_index, 2); +} + +TEST(FindMifareUltralightNdef, ShortFormAfterLeadingPadding) { + TestablePN532 pn532; + // A non-0x03 byte at page 4 (e.g. a lock/CC TLV) pushes the message TLV to offset 5. + auto data = make_page_3_to_6({0x01, 0x00, 0x00, 0x00, 0x00, 0x03, 0x10}); + + uint32_t message_length = 0; + uint8_t message_start_index = 0; + ASSERT_TRUE(pn532.find_mifare_ultralight_ndef_(data, message_length, message_start_index)); + EXPECT_EQ(message_length, 0x10u); + EXPECT_EQ(message_start_index, 7); +} + +// Regression test: prior to this fix, a >=255 byte NDEF message's TLV length was always read as +// a single byte, so the 0xFF long-form marker was mistaken for a length of 255 and the two real +// length bytes were mistaken for message content. That misalignment is what produced the +// "Corrupt record encountered; NdefMessage constructor aborting" error for large tags +TEST(FindMifareUltralightNdef, LongFormAtPage4Start) { + TestablePN532 pn532; + // 0x03 FF 01 2C -> long-form length = 0x012C = 300 + auto data = make_page_3_to_6({0x03, 0xFF, 0x01, 0x2C, 0xD1, 0x01, 0x0C, 0x55}); + + uint32_t message_length = 0; + uint8_t message_start_index = 0; + ASSERT_TRUE(pn532.find_mifare_ultralight_ndef_(data, message_length, message_start_index)); + EXPECT_EQ(message_length, 300u); + EXPECT_EQ(message_start_index, 4); +} + +TEST(FindMifareUltralightNdef, LongFormAfterLeadingPadding) { + TestablePN532 pn532; + auto data = make_page_3_to_6({0x01, 0x00, 0x00, 0x00, 0x00, 0x03, 0xFF, 0x02, 0x00}); + + uint32_t message_length = 0; + uint8_t message_start_index = 0; + ASSERT_TRUE(pn532.find_mifare_ultralight_ndef_(data, message_length, message_start_index)); + EXPECT_EQ(message_length, 0x0200u); // 512 + EXPECT_EQ(message_start_index, 9); +} + +TEST(FindMifareUltralightNdef, TooShortBufferIsRejected) { + TestablePN532 pn532; + std::vector data = {0x00, 0x00, 0x00, 0x00, 0x03, 0x05}; // well under the size floor + + uint32_t message_length = 0; + uint8_t message_start_index = 0; + EXPECT_FALSE(pn532.find_mifare_ultralight_ndef_(data, message_length, message_start_index)); +} + +TEST(FindMifareUltralightNdef, NoTlvTagIsRejected) { + TestablePN532 pn532; + auto data = make_page_3_to_6({0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00}); + + uint32_t message_length = 0; + uint8_t message_start_index = 0; + EXPECT_FALSE(pn532.find_mifare_ultralight_ndef_(data, message_length, message_start_index)); +} + +// Regression test: the entry size guard (page_3_to_6.size() > p4_offset + 6) is loose enough to admit +// buffers that are too short to hold the two long-form length bytes once the TLV is offset by leading +// padding (tlv_offset == 5). Before this fix, that case silently fell through to the short-form branch +// and reported a bogus 255-byte message instead of failing. +TEST(FindMifareUltralightNdef, LongFormMarkerWithMissingLengthBytesIsRejected) { + TestablePN532 pn532; + // Padding pushes the TLV to offset 5; 0x03 FF present, but the two length bytes are missing (size 11). + std::vector data = {0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x03, 0xFF}; + ASSERT_EQ(data.size(), 11u); + + uint32_t message_length = 0; + uint8_t message_start_index = 0; + EXPECT_FALSE(pn532.find_mifare_ultralight_ndef_(data, message_length, message_start_index)); +} + +/// End-to-end regression guard: builds a real 3-record NDEF message (URL, text, and a MIME +/// record) padded past 255 bytes and wraps it in a Type 2 Tag TLV the way a real tag stores it, +/// then runs the wrapper through find_mifare_ultralight_ndef_ to locate it, and feeds the located slice back +/// into NdefMessage to confirm all three records survive the round trip. +TEST(FindMifareUltralightNdef, LongFormMessageRoundTripsThroughNdefMessage) { + nfc::NdefMessage message; + message.add_uri_record("https://esphome.io/"); + message.add_text_record("Hello, ESPHome!"); + + std::string json_payload = R"({"key":"value","pad":")" + std::string(255, 'x') + R"("})"; + auto json_record = make_unique(); + json_record->set_tnf(nfc::TNF_MIME_MEDIA); + json_record->set_type("application/json"); + json_record->set_payload(json_payload); + message.add_record(std::move(json_record)); + + auto encoded = message.encode(); + ASSERT_GE(encoded.size(), 255u) << "test fixture must exercise the long-form TLV path"; + + // Wrap exactly like write_mifare_ultralight_tag_ does: 0x03 FF LEN_HI LEN_LO ... 0xFE + std::vector wrapped = {0x03, 0xFF, static_cast((encoded.size() >> 8) & 0xFF), + static_cast(encoded.size() & 0xFF)}; + wrapped.insert(wrapped.end(), encoded.begin(), encoded.end()); + wrapped.push_back(0xFE); + + // Real tag memory is [page 3 (4 bytes, unused here)] [wrapped TLV ...]. + std::vector tag_memory = {0x00, 0x00, 0x00, 0x00}; + tag_memory.insert(tag_memory.end(), wrapped.begin(), wrapped.end()); + + // find_mifare_ultralight_ndef_ only ever sees the first 16 bytes (pages 3-6) when locating the + // TLV header -- exactly what read_mifare_ultralight_tag_'s first, fixed-size read provides. + std::vector page_3_to_6(tag_memory.begin(), tag_memory.begin() + 16); + + TestablePN532 pn532; + uint32_t message_length = 0; + uint8_t message_start_index = 0; + ASSERT_TRUE(pn532.find_mifare_ultralight_ndef_(page_3_to_6, message_length, message_start_index)); + EXPECT_EQ(message_length, encoded.size()); + EXPECT_EQ(message_start_index, 4); + + // Reconstruct what read_mifare_ultralight_tag_ hands to NdefMessage: page 3 trimmed off, then + // sliced to [message_start_index, message_start_index + message_length). + std::vector full_tag_data(tag_memory.begin() + 4, tag_memory.end()); + std::vector message_bytes(full_tag_data.begin() + message_start_index, + full_tag_data.begin() + message_start_index + message_length); + + nfc::NdefMessage parsed(message_bytes); + const auto &records = parsed.get_records(); + ASSERT_EQ(records.size(), 3u); + EXPECT_EQ(records[0]->get_type(), "U"); + EXPECT_EQ(records[0]->get_payload(), "https://esphome.io/"); + EXPECT_EQ(records[1]->get_type(), "T"); + EXPECT_EQ(records[1]->get_payload(), "Hello, ESPHome!"); + EXPECT_EQ(records[2]->get_type(), "application/json"); + EXPECT_EQ(records[2]->get_payload(), json_payload); +} + +/// End-to-end: a tag holding a message well past 255 bytes is read in full. The read buffer must grow to fit the +/// whole message; a fixed 255-byte buffer would cut it short and the records would fail to parse. +TEST(ReadMifareUltralightTag, LongFormMessageIsReadInFull) { + nfc::NdefMessage message; + message.add_uri_record("https://esphome.io/"); + const std::string text(600, 'x'); + message.add_text_record(text); + auto encoded = message.encode(); + ASSERT_GT(encoded.size(), 600u); + + TestablePN532 pn532; + pn532.tag_memory = make_tag_memory(wrap_tlv(encoded), 888); // NTAG216 + nfc::NfcTagUid uid = {0x04, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06}; + + auto tag = pn532.read_mifare_ultralight_tag_(uid); + ASSERT_TRUE(tag->has_ndef_message()); + const auto &records = tag->get_ndef_message()->get_records(); + ASSERT_EQ(records.size(), 2u); + EXPECT_EQ(records[0]->get_payload(), "https://esphome.io/"); + EXPECT_EQ(records[1]->get_payload(), text); +} + +TEST(ReadMifareUltralightTag, ShortFormMessageIsRead) { + nfc::NdefMessage message; + message.add_uri_record("https://esphome.io/"); + auto encoded = message.encode(); + + TestablePN532 pn532; + pn532.tag_memory = make_tag_memory(wrap_tlv(encoded), 144); // NTAG213 + nfc::NfcTagUid uid = {0x04, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06}; + + auto tag = pn532.read_mifare_ultralight_tag_(uid); + ASSERT_TRUE(tag->has_ndef_message()); + const auto &records = tag->get_ndef_message()->get_records(); + ASSERT_EQ(records.size(), 1u); + EXPECT_EQ(records[0]->get_payload(), "https://esphome.io/"); +} + +/// A length larger than the tag's capacity is rejected before any further pages are read. +TEST(ReadMifareUltralightTag, LengthBeyondCapacityIsRejected) { + TestablePN532 pn532; + pn532.tag_memory = make_tag_memory({0x03, 0xFF, 0x01, 0x2C}, 144); // claims 300 bytes on a 144-byte tag + nfc::NfcTagUid uid = {0x04, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06}; + + auto tag = pn532.read_mifare_ultralight_tag_(uid); + EXPECT_FALSE(tag->has_ndef_message()); +} + +} // namespace esphome::pn532::testing From 67307f0eeb1c1447cf547b0ad1adf3d756bbb6a7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:31:06 -0400 Subject: [PATCH 098/142] [ci] Select the feature-gated files the platform clang-tidy jobs were missing (#20743) --- .github/workflows/ci.yml | 10 +++++----- .../esp32_hosted/esp_now_hosted.cpp | 19 +++++++++++++------ esphome/components/ln882h_ble/ln882h_ble.cpp | 3 ++- .../components/shelly_dimmer/stm32flash.cpp | 4 ++-- .../socket/lwip_raw_common_impl.cpp | 7 +++---- 5 files changed, 25 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04df45d7df7..33534f1f63a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -680,7 +680,7 @@ jobs: include: - id: clang-tidy name: Run script/clang-tidy for ESP8266 - options: --environment esp8266-arduino-tidy --grep USE_ESP8266 + options: --environment esp8266-arduino-tidy --grep USE_ESP8266 --grep USE_SOCKET_IMPL_LWIP_TCP --grep USE_SHD_FIRMWARE_DATA pio_cache_key: tidyesp8266 - id: clang-tidy name: Run script/clang-tidy for ESP32 Arduino @@ -693,7 +693,7 @@ jobs: ignore_errors: false - id: clang-tidy name: Run script/clang-tidy for RP2 - options: --environment rp2-tidy --grep USE_RP2 + options: --environment rp2-tidy --grep USE_RP2 --grep USE_BLE_SCAN_RESPONSE_MERGER pio_cache_key: tidyrp2 - id: clang-tidy name: Run script/clang-tidy for host @@ -702,7 +702,7 @@ jobs: - id: clang-tidy name: Run script/clang-tidy for LibreTiny environments: bk72xx-tidy ln882h-tidy rtl87xxb-tidy rtl87xxc-tidy - options: --grep USE_LIBRETINY --grep USE_BK72XX --grep USE_RTL87XX --grep USE_LN882X + options: --grep USE_LIBRETINY --grep USE_BK72XX --grep USE_RTL87XX --grep USE_LN882X --grep USE_LN882H_BLE --grep USE_SOCKET_IMPL_LWIP_SOCKETS pio_cache_key: tidylibretiny steps: @@ -1003,14 +1003,14 @@ jobs: - id: clang-tidy name: Run script/clang-tidy for ESP32 S3 # yamllint disable-line rule:line-length - options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC + options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC --grep SOC_I2S_SUPPORTS_TDM tidy_environment: esp32s3-idf-tidy - id: clang-tidy name: Run script/clang-tidy for ESP32 P4 # P4 has no native Wi-Fi/BLE; those run over the hosted co-processor, # so their code paths differ -- lint them under the P4 build too. # yamllint disable-line rule:line-length - options: --environment esp32p4-idf-tidy --grep USE_ESP32_VARIANT_ESP32P4 --grep USE_ESP32_HOSTED --grep USE_WIFI --grep USE_BLE + options: --environment esp32p4-idf-tidy --grep USE_ESP32_VARIANT_ESP32P4 --grep CONFIG_IDF_TARGET_ESP32P4 --grep USE_ESP32_HOSTED --grep USE_WIFI --grep USE_BLE tidy_environment: esp32p4-idf-tidy - id: clang-tidy name: Run script/clang-tidy for ESP32 C6 diff --git a/esphome/components/esp32_hosted/esp_now_hosted.cpp b/esphome/components/esp32_hosted/esp_now_hosted.cpp index 03b03fd983f..d226a83c1eb 100644 --- a/esphome/components/esp32_hosted/esp_now_hosted.cpp +++ b/esphome/components/esp32_hosted/esp_now_hosted.cpp @@ -42,6 +42,7 @@ extern "C" { } #include "esp_now_hosted_rpc.h" +#include "esphome/core/log.h" namespace { @@ -52,6 +53,8 @@ ESPHOME_LOG_TAG(TAG, "esp_now_hosted"); // single esp-hosted RPC RX thread. Serializing requests keeps the shared // response slot race-free; a sequence number stops a late/stale response from // being mistaken for ours. +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables): the shim is +// C-ABI state shared between the main loop and the esp-hosted RX thread. SemaphoreHandle_t g_req_mutex = nullptr; SemaphoreHandle_t g_resp_sem = nullptr; // given when the matching RESP lands bool g_setup_done = false; // set only after setup fully succeeds @@ -85,6 +88,7 @@ constexpr size_t ESP_NOW_HOSTED_MAX_PEERS = 20; uint8_t g_peer_cache[ESP_NOW_HOSTED_MAX_PEERS][6]; size_t g_peer_count = 0; portMUX_TYPE g_peer_lock = portMUX_INITIALIZER_UNLOCKED; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) // Caller must hold g_peer_lock. int peer_cache_find_locked(const uint8_t *mac) { @@ -231,12 +235,14 @@ esp_err_t ensure_setup() { g_resp_sem = xSemaphoreCreateBinary(); if (g_req_mutex == nullptr || g_resp_sem == nullptr) return ESP_ERR_NO_MEM; - esp_err_t err; - if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RESP, on_resp, nullptr)) != ESP_OK) + esp_err_t err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RESP, on_resp, nullptr); + if (err != ESP_OK) return err; - if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RECV, on_recv, nullptr)) != ESP_OK) + err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_RECV, on_recv, nullptr); + if (err != ESP_OK) return err; - if ((err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_SEND, on_send, nullptr)) != ESP_OK) + err = esp_hosted_register_custom_callback(ESP_NOW_HOSTED_MSG_SEND, on_send, nullptr); + if (err != ESP_OK) return err; g_setup_done = true; return ESP_OK; @@ -422,10 +428,11 @@ esp_err_t esp_now_send(const uint8_t *peer_addr, const uint8_t *data, size_t len uint8_t hdr[sizeof(esp_now_hosted_send_req_t)]; auto *s = reinterpret_cast(hdr); s->has_addr = peer_addr != nullptr ? 1 : 0; - if (peer_addr != nullptr) + if (peer_addr != nullptr) { memcpy(s->peer_addr, peer_addr, 6); - else + } else { memset(s->peer_addr, 0, 6); + } s->data_len = static_cast(len); // Fire-and-forget (wait=false): native esp_now_send returns once the frame is // queued, with the real TX result delivered later through the send callback. diff --git a/esphome/components/ln882h_ble/ln882h_ble.cpp b/esphome/components/ln882h_ble/ln882h_ble.cpp index 8f569086de2..b903b2752ed 100644 --- a/esphome/components/ln882h_ble/ln882h_ble.cpp +++ b/esphome/components/ln882h_ble/ln882h_ble.cpp @@ -246,7 +246,7 @@ static void ble_scan_callback(void *param) { memcpy(slot->data, info->data, slot->data_len); s_ble->push_scan_report(slot); -} +} // NOLINT(clang-analyzer-unix.Malloc) the slot is a pool entry handed to push_scan_report(), not a leak BLEScanReport *LN882HBLE::allocate_scan_report() { BLEScanReport *slot = this->report_pool_.allocate(); @@ -287,6 +287,7 @@ void LN882HBLE::enable() { return; this->state_ = BLEComponentState::ENABLING; + // NOLINTNEXTLINE(performance-no-int-to-ptr) - MMIO register at a fixed address *reinterpret_cast(BLE_COEX_PTI_REG_ADDR) = BLE_COEX_PTI_ENABLE_ALL; soc_module_clk_gate_enable(CLK_G_BLE); diff --git a/esphome/components/shelly_dimmer/stm32flash.cpp b/esphome/components/shelly_dimmer/stm32flash.cpp index c33876e4943..5996929ab84 100644 --- a/esphome/components/shelly_dimmer/stm32flash.cpp +++ b/esphome/components/shelly_dimmer/stm32flash.cpp @@ -117,7 +117,7 @@ namespace esphome::shelly_dimmer { namespace { int flash_addr_to_page_ceil(const stm32_unique_ptr &stm, uint32_t addr) { - if (!(addr >= stm->dev->fl_start && addr <= stm->dev->fl_end)) + if (addr < stm->dev->fl_start || addr > stm->dev->fl_end) return 0; int page = 0; @@ -729,7 +729,7 @@ stm32_err_t stm32_write_memory(const stm32_unique_ptr &stm, uint32_t address, co uint8_t buf[256 + 2]; buf[0] = aligned_len - 1; - for (auto i = 0; i < len; i++) { + for (unsigned int i = 0; i < len; i++) { cs ^= data[i]; buf[i + 1] = data[i]; } diff --git a/esphome/components/socket/lwip_raw_common_impl.cpp b/esphome/components/socket/lwip_raw_common_impl.cpp index 4ec0a373550..52e9c2eec5e 100644 --- a/esphome/components/socket/lwip_raw_common_impl.cpp +++ b/esphome/components/socket/lwip_raw_common_impl.cpp @@ -81,14 +81,13 @@ bool sockaddr_to_lwip(const struct sockaddr *addr, socklen_t addrlen, ip_addr_t bool sockaddr_to_lwip_bind(sa_family_t family, const struct sockaddr *addr, socklen_t addrlen, ip_addr_t *ip, uint16_t *port) { - if (!sockaddr_to_lwip(addr, addrlen, ip, port)) - return false; + const bool ok = sockaddr_to_lwip(addr, addrlen, ip, port); #if LWIP_IPV6 // Only promote wildcard binds — a specific address must keep filtering - if (family == AF_INET6 && ip_addr_isany_val(*ip)) + if (ok && family == AF_INET6 && ip_addr_isany_val(*ip)) IP_SET_TYPE_VAL(*ip, IPADDR_TYPE_ANY); #endif - return true; + return ok; } int lwip_err_to_errno(err_t err) { From 91633594454b2d76be0cee83fc5187b016096da7 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:31:39 -0400 Subject: [PATCH 099/142] [core] Analyze the ethernet component under ESP-IDF (#20745) --- esphome/core/defines.h | 4 ++-- esphome/idf_component.yml | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index f589155c97f..3aa4c8e700b 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -526,7 +526,8 @@ #endif #ifdef USE_ARDUINO -#define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 7) +#define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 12) +#endif #define USE_ETHERNET #define USE_ETHERNET_LAN8720 #define USE_ETHERNET_RTL8201 @@ -557,7 +558,6 @@ #define USE_ETHERNET_DISCONNECT_TRIGGER #define ESPHOME_ETHERNET_IP_STATE_LISTENERS 2 #define ESPHOME_ETHERNET_PHY_REGISTER_COUNT 2 -#endif #ifdef USE_ESP32 #define USE_MICRO_WAKE_WORD diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 87ec5cdcfc4..372059bd96f 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -85,6 +85,9 @@ dependencies: version: "1.1.0" rules: - if: "idf_version >=6.0.0" + # Keep in sync with esphome/components/ethernet/__init__.py + espressif/ch390: + version: "0.3.0" espressif/esp_tinyusb: version: "2.2.1" rules: From 928459d16f13c9600ab91f2c41cd1bf0812fa049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcus=20Vo=C3=9F?= <46140304+mvoss96@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:33:11 +0200 Subject: [PATCH 100/142] [cse7766] Keep last power reading between measurements at low load (#20631) Co-authored-by: J. Nick Koston --- esphome/components/cse7766/cse7766.cpp | 26 +++- esphome/components/cse7766/cse7766.h | 4 +- tests/components/cse7766/common.h | 40 ++++++ .../cse7766/sensor/cse7766_test.cpp | 117 ++++++++++++++++++ 4 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 tests/components/cse7766/common.h create mode 100644 tests/components/cse7766/sensor/cse7766_test.cpp diff --git a/esphome/components/cse7766/cse7766.cpp b/esphome/components/cse7766/cse7766.cpp index bbe76855fb8..81f831672bd 100644 --- a/esphome/components/cse7766/cse7766.cpp +++ b/esphome/components/cse7766/cse7766.cpp @@ -8,6 +8,9 @@ namespace esphome::cse7766 { ESPHOME_LOG_TAG(TAG, "cse7766"); +// The chip only sends the time since the last pulse once a cycle is longer than 1 s (in us) +static constexpr uint32_t MIN_HELD_POWER_CYCLE = 1000000; + void CSE7766Component::loop() { const uint32_t now = App.get_loop_component_start_time(); if (now - this->last_transmission_ >= 500) { @@ -154,18 +157,33 @@ void CSE7766Component::parse_data_() { if (power_cycle_exceeds_range) { // Datasheet: power cycle exceeding range means active power is 0 have_power = true; + this->last_power_cycle_ = 0; if (this->power_sensor_ != nullptr) { this->power_sensor_->publish_state(0.0f); } } else if (have_power) { power = power_coeff / float(power_cycle); + this->last_power_cycle_ = power_cycle; if (this->power_sensor_ != nullptr) { this->power_sensor_->publish_state(power); } - } else if (this->power_sensor_ != nullptr) { - // No valid power measurement from chip - publish 0W to avoid stale readings - // This typically happens when current is below the measurable threshold (~50mA) - this->power_sensor_->publish_state(0.0f); + } else if (this->last_power_cycle_ > MIN_HELD_POWER_CYCLE && power_cycle <= 2 * this->last_power_cycle_) { + // No new measurement in this frame. With cycles over 1 s the register holds the last + // cycle or the time elapsed since the last pulse. While that time is within twice the + // last cycle, the next pulse is not overdue, so the last measurement still applies. + // Shorter cycles never get here in normal operation and keep the previous behaviour. + have_power = true; + power = power_coeff / float(this->last_power_cycle_); + if (this->power_sensor_ != nullptr) { + this->power_sensor_->publish_state(power); + } + } else { + // The next pulse is overdue: the load has dropped, report 0 W. A load that drops to less + // than half also reads 0 W here until its first new pulse arrives. + this->last_power_cycle_ = 0; + if (this->power_sensor_ != nullptr) { + this->power_sensor_->publish_state(0.0f); + } } float current = 0.0f; diff --git a/esphome/components/cse7766/cse7766.h b/esphome/components/cse7766/cse7766.h index 8a57816a590..1206ad4e71f 100644 --- a/esphome/components/cse7766/cse7766.h +++ b/esphome/components/cse7766/cse7766.h @@ -36,6 +36,7 @@ class CSE7766Component final : public Component, public uart::UARTDevice { uint8_t raw_data_[CSE7766_RAW_DATA_SIZE]; uint8_t raw_data_index_{0}; + uint16_t cf_pulses_last_{0}; uint32_t last_transmission_{0}; sensor::Sensor *voltage_sensor_{nullptr}; sensor::Sensor *current_sensor_{nullptr}; @@ -45,7 +46,8 @@ class CSE7766Component final : public Component, public uart::UARTDevice { sensor::Sensor *reactive_power_sensor_{nullptr}; sensor::Sensor *power_factor_sensor_{nullptr}; uint32_t cf_pulses_total_{0}; - uint16_t cf_pulses_last_{0}; + // Power cycle of the last frame that carried a new power measurement, 0 if none + uint32_t last_power_cycle_{0}; }; } // namespace esphome::cse7766 diff --git a/tests/components/cse7766/common.h b/tests/components/cse7766/common.h new file mode 100644 index 00000000000..b69c04d3287 --- /dev/null +++ b/tests/components/cse7766/common.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "esphome/components/uart/uart_component.h" + +namespace esphome::cse7766::testing { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector rx; + + void push_rx(const std::vector &data) { this->rx.insert(this->rx.end(), data.begin(), data.end()); } + + // UARTComponent + void write_array(const uint8_t *data, size_t len) override {} + + bool read_array(uint8_t *data, size_t len) override { + if (this->rx.size() < len) { + return false; + } + std::copy(this->rx.begin(), this->rx.begin() + len, data); + this->rx.erase(this->rx.begin(), this->rx.begin() + len); + return true; + } + + size_t available() override { return this->rx.size(); } + + MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override)); + MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); + MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif // defined(USE_ESP8266) || defined(USE_ESP32) +}; + +} // namespace esphome::cse7766::testing diff --git a/tests/components/cse7766/sensor/cse7766_test.cpp b/tests/components/cse7766/sensor/cse7766_test.cpp new file mode 100644 index 00000000000..e5bcda8fa98 --- /dev/null +++ b/tests/components/cse7766/sensor/cse7766_test.cpp @@ -0,0 +1,117 @@ +#include + +#include +#include + +#include "esphome/components/cse7766/cse7766.h" +#include "esphome/components/sensor/sensor.h" +#include "../common.h" + +namespace esphome::cse7766::testing { + +namespace { + +// Coefficients and cycles taken from a Sonoff S60 (CSE7759B). With this power coefficient a power +// cycle of 2507613 is about 2.16 W, and the cycle register advances by about 1050000 per second. +constexpr uint32_t VOLTAGE_COEFF = 0x030BB0; +constexpr uint32_t VOLTAGE_CYCLE = 0x000352; +constexpr uint32_t CURRENT_COEFF = 0x004137; +constexpr uint32_t CURRENT_CYCLE = 0x053339; +constexpr uint32_t POWER_COEFF = 5425000; +constexpr uint32_t LOW_LOAD_CYCLE = 2507613; +constexpr uint32_t SHORT_CYCLE = 900000; // about 6 W, just under 1 s +constexpr uint32_t HIGH_LOAD_CYCLE = 2000; + +constexpr uint8_t HEADER_OK = 0x55; +constexpr uint8_t HEADER_POWER_CYCLE_EXCEEDS_RANGE = 0xF2; +constexpr uint8_t ADJ_NEW_POWER = 0x71; +constexpr uint8_t ADJ_NO_NEW_POWER = 0x61; + +void put24(std::vector &frame, uint32_t value) { + frame.push_back(value >> 16); + frame.push_back(value >> 8); + frame.push_back(value); +} + +std::vector make_frame(uint8_t header, uint32_t power_cycle, uint8_t adj) { + std::vector frame{header, 0x5A}; + put24(frame, VOLTAGE_COEFF); + put24(frame, VOLTAGE_CYCLE); + put24(frame, CURRENT_COEFF); + put24(frame, CURRENT_CYCLE); + put24(frame, POWER_COEFF); + put24(frame, power_cycle); + frame.push_back(adj); + frame.push_back(0x0B); + frame.push_back(0x81); + uint8_t checksum = 0; + for (size_t i = 2; i < frame.size(); i++) { + checksum += frame[i]; + } + frame.push_back(checksum); + return frame; +} + +class CSE7766Test : public ::testing::Test { + protected: + void SetUp() override { + this->sut_.set_uart_parent(&this->uart_); + this->sut_.set_power_sensor(&this->power_); + } + + float feed_(uint8_t header, uint32_t power_cycle, uint8_t adj) { + this->uart_.push_rx(make_frame(header, power_cycle, adj)); + this->sut_.loop(); + return this->power_.state; + } + + MockUARTComponent uart_; + CSE7766Component sut_; + sensor::Sensor power_; +}; + +} // namespace + +TEST_F(CSE7766Test, NewMeasurementIsReported) { + EXPECT_NEAR(this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NEW_POWER), 2.163f, 0.001f); +} + +// At low load one power cycle spans many frames. Frames in between carry the last cycle or the +// time since the last pulse, and must not be reported as 0 W. +TEST_F(CSE7766Test, LowLoadKeepsLastMeasurementBetweenPulses) { + this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NEW_POWER); + EXPECT_NEAR(this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NO_NEW_POWER), 2.163f, 0.001f); + EXPECT_NEAR(this->feed_(HEADER_OK, 1078140, ADJ_NO_NEW_POWER), 2.163f, 0.001f); + EXPECT_NEAR(this->feed_(HEADER_OK, 4900000, ADJ_NO_NEW_POWER), 2.163f, 0.001f); +} + +// Below 1 s the chip does not send waiting times in normal operation, so such a frame +// means the pulse is overdue and 0 W is reported at once, as before +TEST_F(CSE7766Test, ShortCycleReportsZeroWithoutNewMeasurement) { + this->feed_(HEADER_OK, SHORT_CYCLE, ADJ_NEW_POWER); + EXPECT_EQ(this->feed_(HEADER_OK, 1050000, ADJ_NO_NEW_POWER), 0.0f); +} + +TEST_F(CSE7766Test, OverduePulseReportsZero) { + this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NEW_POWER); + EXPECT_EQ(this->feed_(HEADER_OK, 2 * LOW_LOAD_CYCLE + 1, ADJ_NO_NEW_POWER), 0.0f); + // Stays 0 even when a later frame shows a short cycle again, until a new measurement arrives + EXPECT_EQ(this->feed_(HEADER_OK, 1000000, ADJ_NO_NEW_POWER), 0.0f); +} + +// A large load switching off: the first frame without a new measurement already shows about one +// second since the last pulse, far beyond the 2 ms cycle, so 0 W is reported at once. +TEST_F(CSE7766Test, HighLoadSwitchingOffReportsZeroAtOnce) { + EXPECT_NEAR(this->feed_(HEADER_OK, HIGH_LOAD_CYCLE, ADJ_NEW_POWER), 2712.5f, 0.1f); + EXPECT_EQ(this->feed_(HEADER_OK, 1072601, ADJ_NO_NEW_POWER), 0.0f); +} + +TEST_F(CSE7766Test, NoMeasurementYetReportsZero) { EXPECT_EQ(this->feed_(HEADER_OK, 1078140, ADJ_NO_NEW_POWER), 0.0f); } + +TEST_F(CSE7766Test, PowerCycleExceedsRangeReportsZero) { + this->feed_(HEADER_OK, LOW_LOAD_CYCLE, ADJ_NEW_POWER); + EXPECT_EQ(this->feed_(HEADER_POWER_CYCLE_EXCEEDS_RANGE, 9000000, ADJ_NO_NEW_POWER), 0.0f); + EXPECT_EQ(this->feed_(HEADER_OK, 1078140, ADJ_NO_NEW_POWER), 0.0f); +} + +} // namespace esphome::cse7766::testing From 7f144365ba6a7d9e7fff72f296b5f14c3240cb0d Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Wed, 14 Oct 2026 20:35:29 +0200 Subject: [PATCH 101/142] [fusb302b] Add USB PD sink protocol (#20632) Co-authored-by: Claude Opus 5.5 --- CODEOWNERS | 1 + esphome/components/fusb302b/__init__.py | 2 + esphome/components/fusb302b/pd.cpp | 330 ++++++++++++++++ esphome/components/fusb302b/pd.h | 178 +++++++++ tests/components/fusb302b/pd_test.cpp | 481 ++++++++++++++++++++++++ 5 files changed, 992 insertions(+) create mode 100644 esphome/components/fusb302b/__init__.py create mode 100644 esphome/components/fusb302b/pd.cpp create mode 100644 esphome/components/fusb302b/pd.h create mode 100644 tests/components/fusb302b/pd_test.cpp diff --git a/CODEOWNERS b/CODEOWNERS index 1282e9d66b9..7473c80cdc6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -219,6 +219,7 @@ esphome/components/friedrich/* @rwrozelle esphome/components/fs3000/* @kahrendt esphome/components/ft5x06/* @clydebarrow esphome/components/ft63x6/* @gpambrozio +esphome/components/fusb302b/* @remcom esphome/components/gcja5/* @gcormier esphome/components/gdk101/* @Szewcson esphome/components/gl_r01_i2c/* @pkejval diff --git a/esphome/components/fusb302b/__init__.py b/esphome/components/fusb302b/__init__.py new file mode 100644 index 00000000000..3cf9043f418 --- /dev/null +++ b/esphome/components/fusb302b/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@remcom"] +DOMAIN = "fusb302b" diff --git a/esphome/components/fusb302b/pd.cpp b/esphome/components/fusb302b/pd.cpp new file mode 100644 index 00000000000..4b6931b38fa --- /dev/null +++ b/esphome/components/fusb302b/pd.cpp @@ -0,0 +1,330 @@ +#include "pd.h" + +#include + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::fusb302b { + +ESPHOME_LOG_TAG(TAG, "fusb302b.pd"); + +static constexpr uint32_t AMS_TIMEOUT_MS = 2000; +// tSinkRequest: delay before repeating a request that the source answered with Wait +static constexpr uint32_t SINK_REQUEST_MS = 100; +static constexpr uint8_t MAX_WAIT_RETRIES = 10; + +// USB PD 6.4.1.3.1 Sink Fixed Supply Power Data Object flags; only the first (5 V) object carries them +static constexpr uint32_t SINK_PDO_HIGHER_CAPABILITY = 1u << 28; +static constexpr uint32_t SINK_PDO_USB_COMMS_CAPABLE = 1u << 26; +// Operational current advertised for every sink object: 3 A (300 x 10 mA) +static constexpr uint16_t SINK_PDO_CURRENT = 300; + +// USB PD 6.4.1.2.3 Source Fixed Supply Power Data Object; other PDO types only report their type +static PdContract parse_pdo(uint32_t pdo) { + PdContract info{}; + info.type = static_cast(pdo >> 30); + if (info.type == PD_PDO_TYPE_FIXED_SUPPLY) { + info.max_v = (pdo >> 10) & 0x3FF; + info.max_i = pdo & 0x3FF; + } + return info; +} + +// USB PD 6.4.1.3.1 Sink Fixed Supply Power Data Object; voltage in 50 mV units, current in 10 mA units +static constexpr uint32_t sink_fixed_pdo(uint16_t voltage, uint16_t current) { + return (static_cast(PD_PDO_TYPE_FIXED_SUPPLY) << 30) | (static_cast(voltage & 0x3FF) << 10) | + (current & 0x3FF); +} + +void PdMsg::set_header(uint16_t header) { + this->type = header & 0x1F; + this->spec_rev = static_cast((header >> 6) & 0x3); + this->id = (header >> 9) & 0x7; + this->num_of_obj = (header >> 12) & 0x7; + this->extended = (header >> 15) & 0x1; +} + +uint16_t PdMsg::get_coded_header() const { + // Data role UFP (bit 5 = 0), power role sink (bit 8 = 0) + return static_cast(this->type & 0x1F) | (static_cast(this->spec_rev) << 6) | + (static_cast(this->id & 0x7) << 9) | (static_cast(this->num_of_obj & 0x7) << 12) | + (static_cast(this->extended) << 15); +} + +PdMsg PowerDelivery::make_control_msg_(PdControlMsgType type) const { + PdMsg msg; + msg.type = type; + msg.id = this->msg_counter_ % 8; + return msg; +} + +PdMsg PowerDelivery::make_data_msg_(PdDataMsgType type, const uint32_t *objects, uint8_t len) const { + PdMsg msg; + msg.type = type; + msg.id = this->msg_counter_ % 8; + msg.num_of_obj = len; + memcpy(msg.data_objects, objects, len * sizeof(uint32_t)); + return msg; +} + +void PowerDelivery::reset_protocol_() { + this->last_received_msg_id_ = 255; + this->msg_counter_ = 0; + this->active_ams_ = false; + this->ps_rdy_expected_ = false; + this->retry_request_pending_ = false; + this->wait_retries_ = 0; +} + +void PowerDelivery::set_state_(PdState state) { + if (this->state_.exchange(state) != state) + this->on_state_changed(); +} + +void PowerDelivery::set_contract_(const PdContract &contract) { + this->accepted_contract_ = contract; + const uint32_t packed = PackedContract::from(contract).value; + if (this->contract_.exchange(packed) != packed) + this->on_state_changed(); +} + +void PowerDelivery::clear_contract_() { + this->accepted_contract_ = {}; + if (this->contract_.exchange(0) != 0) + this->on_state_changed(); +} + +void PowerDelivery::set_state_and_contract_(PdState state, const PdContract &contract) { + this->accepted_contract_ = contract; + const uint32_t packed = PackedContract::from(contract).value; + // The contract is stored first, so a reader that sees the new state also sees the new contract + const bool contract_changed = this->contract_.exchange(packed) != packed; + const bool state_changed = this->state_.exchange(state) != state; + if (contract_changed || state_changed) + this->on_state_changed(); +} + +void PowerDelivery::set_ams_(bool active) { + this->active_ams_ = active; + // A new sequence has no accepted request yet, and an ended one can no longer complete + this->ps_rdy_expected_ = false; + if (active) + this->ams_start_ = millis(); +} + +bool PowerDelivery::check_ams_() { + if (this->active_ams_ && millis() - this->ams_start_ > AMS_TIMEOUT_MS) + this->set_ams_(false); + return this->active_ams_; +} + +void PowerDelivery::check_request_retry_() { + if (!this->retry_request_pending_ || millis() - this->wait_received_ms_ < SINK_REQUEST_MS) + return; + if (this->send_request_()) { + this->retry_request_pending_ = false; + } else if (this->wait_retries_ < MAX_WAIT_RETRIES) { + // Try again after another tSinkRequest; failed attempts count against the same limit as Wait replies + this->wait_retries_++; + this->wait_received_ms_ = millis(); + } else { + ESP_LOGW(TAG, "Giving up repeating the request"); + this->retry_request_pending_ = false; + this->request_failed_(); + } +} + +bool PowerDelivery::send_request_() { + this->set_ams_(true); + if (this->send_message(this->make_data_msg_(PD_DATA_REQUEST, &this->last_rdo_, 1))) + return true; + ESP_LOGW(TAG, "Sending the request failed"); + this->set_ams_(false); + return false; +} + +void PowerDelivery::request_failed_() { + // A contract already in place stays valid. Without one, report that negotiation is over at default power. + if (this->state_ == PdState::PD_STATE_DEFAULT_CONTRACT) + this->set_state_(PdState::PD_STATE_PD_TIMEOUT); +} + +void PowerDelivery::check_transition_timeout_() { + // The AMS that sent the request only ends on PS_RDY, so an ended AMS while in transition means a timeout + if (this->state_ != PdState::PD_STATE_TRANSITION || this->check_ams_()) + return; + ESP_LOGW(TAG, "Source did not report the new supply ready, falling back to default power"); + this->set_state_and_contract_(PdState::PD_STATE_DEFAULT_CONTRACT, DEFAULT_CONTRACT); + this->on_transition_timeout(); +} + +void PowerDelivery::handle_transmit_failed_() { + // USB PD 6.12.2.2: the counter also moves on after a failed transmission. The source may have received the + // message and only its GoodCRC was lost, so reusing the MessageID would make it drop the next one as a repeat. + ESP_LOGD(TAG, "Message %u not acknowledged", this->msg_counter_ % 8); + this->msg_counter_++; +} + +void PowerDelivery::handle_message_(const PdMsg &msg) { + if (msg.num_of_obj == 0 && msg.type == PD_CNTRL_GOODCRC) { + // A GoodCRC carries the MessageID it acknowledges; a duplicate or stray one must not advance the counter + if (msg.id != this->msg_counter_ % 8) { + ESP_LOGD(TAG, "Ignoring GoodCRC for MessageID %u, expected %u", msg.id, this->msg_counter_ % 8); + return; + } + // Our last message was received, so the next one gets a new MessageID + this->msg_counter_++; + return; + } + // Soft_Reset always has MessageID 0, so it must not be dropped as a retransmission of the last message + if (msg.num_of_obj == 0 && msg.type == PD_CNTRL_SOFT_RESET) { + this->handle_soft_reset_(msg); + return; + } + // Retransmissions repeat the MessageID of a message that was already handled + if (msg.id == this->last_received_msg_id_) + return; + this->last_received_msg_id_ = msg.id; + + if (msg.num_of_obj == 0) { + this->handle_control_message_(msg); + } else { + this->handle_data_message_(msg); + } +} + +void PowerDelivery::handle_data_message_(const PdMsg &msg) { + if (msg.extended || msg.type != PD_DATA_SOURCE_CAP) { + // BIST and Vendor_Defined messages must not be answered with Reject, so unsupported data messages are ignored + ESP_LOGV(TAG, "Ignoring data message type %u%s", msg.type, + msg.extended ? LOG_STR_LITERAL(" (extended)") : LOG_STR_LITERAL("")); + return; + } + this->on_source_caps_received(); + this->respond_to_source_caps_(msg); +} + +void PowerDelivery::handle_control_message_(const PdMsg &msg) { + switch (msg.type) { + case PD_CNTRL_ACCEPT: + if (!this->check_ams_()) { + ESP_LOGD(TAG, "Ignoring Accept outside a request"); + break; + } + this->ps_rdy_expected_ = true; + if (this->requested_contract_ != this->accepted_contract_) + this->set_state_and_contract_(PdState::PD_STATE_TRANSITION, this->requested_contract_); + break; + case PD_CNTRL_PS_RDY: + // check_ams_() first, so a PS_RDY that arrives after the sequence timed out is ignored too + if (!this->check_ams_() || !this->ps_rdy_expected_) { + ESP_LOGD(TAG, "Ignoring PS_RDY without an accepted request"); + break; + } + this->set_ams_(false); + this->set_state_(PdState::PD_STATE_EXPLICIT_CONTRACT); + break; + case PD_CNTRL_REJECT: + if (!this->check_ams_()) { + ESP_LOGD(TAG, "Ignoring Reject outside a request"); + break; + } + ESP_LOGW(TAG, "Source rejected the request"); + this->set_ams_(false); + this->request_failed_(); + break; + case PD_CNTRL_WAIT: + if (!this->check_ams_()) { + ESP_LOGD(TAG, "Ignoring Wait outside a request"); + break; + } + this->set_ams_(false); + if (this->wait_retries_ < MAX_WAIT_RETRIES) { + ESP_LOGD(TAG, "Source asked to wait, repeating the request"); + this->wait_retries_++; + this->retry_request_pending_ = true; + this->wait_received_ms_ = millis(); + } else { + ESP_LOGW(TAG, "Source kept answering Wait, giving up"); + this->request_failed_(); + } + break; + case PD_CNTRL_PING: + case PD_CNTRL_NOT_SUPPORTED: + break; + case PD_CNTRL_GET_SINK_CAP: { + // 5 V first, then the requested voltage. Higher Capability tells the source that 5 V is not enough for + // full operation. + const uint16_t request_v = this->request_voltage_ * 20; // 50 mV units + uint32_t pdos[2]; + uint8_t count = 0; + pdos[count++] = sink_fixed_pdo(100, SINK_PDO_CURRENT) | SINK_PDO_USB_COMMS_CAPABLE | + (request_v > 100 ? SINK_PDO_HIGHER_CAPABILITY : 0); + if (request_v > 100) + pdos[count++] = sink_fixed_pdo(request_v, SINK_PDO_CURRENT); + this->send_message(this->make_data_msg_(PD_DATA_SINK_CAP, pdos, count)); + break; + } + default: + // Messages are sent as PD 2.0, which has no Not_Supported message + this->send_message(this->make_control_msg_(PD_CNTRL_REJECT)); + break; + } +} + +void PowerDelivery::handle_soft_reset_(const PdMsg &msg) { + this->reset_protocol_(); + this->last_received_msg_id_ = msg.id; + // If this fails the source escalates to a hard reset. The hook still runs, so the PHY starts waiting for source + // capabilities and recovers on its own if neither follows. + if (!this->send_message(this->make_control_msg_(PD_CNTRL_ACCEPT))) { + ESP_LOGW(TAG, "Accepting the soft reset failed"); + } + // A soft reset keeps the power level, but the outcome of a transition in progress is unknown + if (this->state_ == PdState::PD_STATE_TRANSITION) + this->set_state_and_contract_(PdState::PD_STATE_DEFAULT_CONTRACT, DEFAULT_CONTRACT); + this->on_soft_reset_received(); +} + +void PowerDelivery::respond_to_source_caps_(const PdMsg &msg) { + // Pick the highest fixed supply PDO that does not exceed the requested voltage. + // The first PDO is always the 5 V fixed supply. + const uint16_t request_v = this->request_voltage_ * 20; // 50 mV units + PdContract selected{}; + uint8_t position = 0; // 1-based object position, 0 = none + for (uint8_t idx = 0; idx < msg.num_of_obj; idx++) { + PdContract info = parse_pdo(msg.data_objects[idx]); + if (info.type != PD_PDO_TYPE_FIXED_SUPPLY) + continue; + if (position == 0 || (info.max_v <= request_v && info.max_v > selected.max_v)) { + selected = info; + position = idx + 1; + } + } + + // USB PD 6.4.2 Request Data Object: no USB suspend (bit 24), USB communications capable (bit 25) + uint32_t rdo = (1u << 24) | (1u << 25); + if (position == 0) { + ESP_LOGW(TAG, "No usable PDO in source capabilities, requesting 5 V"); + // Object 1 (5 V fixed supply), 300 mA maximum, 100 mA operating current + selected = PdContract{PD_PDO_TYPE_FIXED_SUPPLY, 100, 30}; + position = 1; + rdo |= (30u << 0) | (10u << 10); + } else { + // Request the full current the source offers + const uint32_t current = selected.max_i; + rdo |= (current << 0) | (current << 10); + } + rdo |= static_cast(position) << 28; + ESP_LOGD(TAG, "Requesting PDO %u: %.2f V", position, selected.max_v * 0.05f); + this->requested_contract_ = selected; + this->last_rdo_ = rdo; + this->retry_request_pending_ = false; + this->wait_retries_ = 0; + // A repeat would come too late: the source resets if it gets no Request within tSenderResponse (24-30 ms) + if (!this->send_request_()) + this->request_failed_(); +} + +} // namespace esphome::fusb302b diff --git a/esphome/components/fusb302b/pd.h b/esphome/components/fusb302b/pd.h new file mode 100644 index 00000000000..26c75239152 --- /dev/null +++ b/esphome/components/fusb302b/pd.h @@ -0,0 +1,178 @@ +#pragma once + +#include +#include +#include +#include + +namespace esphome::fusb302b { + +static constexpr uint8_t PD_MAX_NUM_DATA_OBJECTS = 7; +// Standard Power Range fixed supplies, which are the only ones this sink negotiates +static constexpr uint8_t MIN_REQUEST_VOLTAGE = 5; +static constexpr uint8_t MAX_REQUEST_VOLTAGE = 20; + +enum PdSpecRevision : uint8_t { + PD_SPEC_REV_1 = 0, + PD_SPEC_REV_2 = 1, + PD_SPEC_REV_3 = 2, +}; + +enum PdDataMsgType : uint8_t { + PD_DATA_SOURCE_CAP = 0x01, + PD_DATA_REQUEST = 0x02, + PD_DATA_SINK_CAP = 0x04, +}; + +enum PdControlMsgType : uint8_t { + PD_CNTRL_GOODCRC = 0x01, + PD_CNTRL_ACCEPT = 0x03, + PD_CNTRL_REJECT = 0x04, + PD_CNTRL_PING = 0x05, + PD_CNTRL_PS_RDY = 0x06, + PD_CNTRL_GET_SINK_CAP = 0x08, + PD_CNTRL_WAIT = 0x0C, + PD_CNTRL_SOFT_RESET = 0x0D, + PD_CNTRL_NOT_SUPPORTED = 0x10, +}; + +enum PdPdoType : uint8_t { + PD_PDO_TYPE_FIXED_SUPPLY = 0, + PD_PDO_TYPE_BATTERY = 1, + PD_PDO_TYPE_VARIABLE_SUPPLY = 2, + PD_PDO_TYPE_AUGMENTED = 3, +}; + +enum class PdState : uint8_t { + PD_STATE_DISCONNECTED, + /// Negotiation is over without a PD contract (no capabilities, or the request was not accepted); the source + /// supplies Type-C default power. + PD_STATE_PD_TIMEOUT, + PD_STATE_DEFAULT_CONTRACT, + PD_STATE_TRANSITION, + PD_STATE_EXPLICIT_CONTRACT, + PD_STATE_ERROR, +}; + +/// Returns true for the states in which a source is attached and supplying power. +inline bool is_connected_state(PdState state) { + return state == PdState::PD_STATE_DEFAULT_CONTRACT || state == PdState::PD_STATE_TRANSITION || + state == PdState::PD_STATE_EXPLICIT_CONTRACT || state == PdState::PD_STATE_PD_TIMEOUT; +} + +struct PdContract { + PdPdoType type{PD_PDO_TYPE_FIXED_SUPPLY}; + uint16_t max_v{0}; // 50 mV units + uint16_t max_i{0}; // 10 mA units + + bool operator==(const PdContract &other) const { + return this->max_v == other.max_v && this->max_i == other.max_i && this->type == other.type; + } + bool operator!=(const PdContract &other) const { return !(*this == other); } +}; + +// Type-C default power: 5 V (100 x 50 mV) at 500 mA (50 x 10 mA) +static constexpr PdContract DEFAULT_CONTRACT{PD_PDO_TYPE_FIXED_SUPPLY, 100, 50}; + +/// A contract packed into one word so it can be shared between tasks as an atomic. +/// 0 means there is no contract (nothing attached). +struct PackedContract { + uint32_t value{0}; + + static PackedContract from(const PdContract &contract) { + return {(static_cast(contract.max_v) << 16) | contract.max_i}; + } + bool is_set() const { return this->value != 0; } + float voltage() const { return (this->value >> 16) * 0.05f; } + float current() const { return (this->value & 0xFFFF) * 0.01f; } +}; + +struct PdMsg { + uint8_t type{0}; + PdSpecRevision spec_rev{PD_SPEC_REV_2}; + uint8_t id{0}; + uint8_t num_of_obj{0}; + bool extended{false}; + uint32_t data_objects[PD_MAX_NUM_DATA_OBJECTS]{}; + + void set_header(uint16_t header); + uint16_t get_coded_header() const; +}; + +/// USB-PD sink protocol logic, independent of the PHY chip. +/// Everything except the getters and set_request_voltage() runs in the PHY task, which must also call +/// check_transition_timeout_() and check_request_retry_() regularly; the protocol has no timers of its own. +class PowerDelivery { + public: + void set_request_voltage(uint8_t voltage) { + this->request_voltage_ = std::clamp(voltage, MIN_REQUEST_VOLTAGE, MAX_REQUEST_VOLTAGE); + } + uint8_t get_request_voltage() const { return this->request_voltage_; } + + PdState get_state() const { return this->state_; } + bool is_connected() const { return is_connected_state(this->state_); } + + protected: + /// Returns false when the message could not be handed to the chip; the PHY logs the failure. + virtual bool send_message(const PdMsg &msg) = 0; + /// Called after state_ or contract_ changed, to let the main loop publish it. + virtual void on_state_changed() = 0; + /// Called after the source soft reset the protocol; it sends new source capabilities next. + virtual void on_soft_reset_received() = 0; + /// Called when source capabilities arrive, before the request for them is sent. + virtual void on_source_caps_received() = 0; + /// Called after an accepted request was not followed by PS_RDY in time and the state fell back to default power; + /// the PHY sends a soft reset so the source offers its capabilities again. + virtual void on_transition_timeout() = 0; + + void handle_message_(const PdMsg &msg); + /// Called by the PHY when the chip gave up on a message after its retries without receiving a GoodCRC. + void handle_transmit_failed_(); + void reset_protocol_(); + void set_state_(PdState state); + void set_contract_(const PdContract &contract); + void clear_contract_(); + /// Stores both before calling on_state_changed() once, so the main loop never sees the new state with the old + /// contract. + void set_state_and_contract_(PdState state, const PdContract &contract); + void set_ams_(bool active); + /// Returns true while an atomic message sequence is in progress; ends it after a timeout. + bool check_ams_(); + /// Falls back to default power when the source accepted a request but did not report the new supply ready in time. + void check_transition_timeout_(); + /// Sends the last request again once tSinkRequest has passed after the source answered Wait. + void check_request_retry_(); + + PdMsg make_control_msg_(PdControlMsgType type) const; + PdMsg make_data_msg_(PdDataMsgType type, const uint32_t *objects, uint8_t len) const; + + // Shared with the main loop + std::atomic state_{PdState::PD_STATE_DISCONNECTED}; + std::atomic contract_{0}; + std::atomic request_voltage_{5}; + + // PHY task only + uint32_t ams_start_{0}; + uint32_t wait_received_ms_{0}; + bool retry_request_pending_{false}; + + private: + void handle_data_message_(const PdMsg &msg); + void handle_control_message_(const PdMsg &msg); + void handle_soft_reset_(const PdMsg &msg); + void respond_to_source_caps_(const PdMsg &msg); + bool send_request_(); + void request_failed_(); + + PdContract requested_contract_{}; + PdContract accepted_contract_{}; + uint32_t last_rdo_{0}; + bool active_ams_{false}; + // Set by Accept; PS_RDY is only valid after the source accepted the request of the current sequence + bool ps_rdy_expected_{false}; + uint8_t last_received_msg_id_{255}; + uint8_t msg_counter_{0}; + uint8_t wait_retries_{0}; +}; + +} // namespace esphome::fusb302b diff --git a/tests/components/fusb302b/pd_test.cpp b/tests/components/fusb302b/pd_test.cpp new file mode 100644 index 00000000000..c122d59b039 --- /dev/null +++ b/tests/components/fusb302b/pd_test.cpp @@ -0,0 +1,481 @@ +#include +#include + +#include "esphome/components/fusb302b/pd.h" + +namespace esphome::fusb302b::testing { + +static constexpr uint32_t fixed_pdo(uint16_t mv, uint16_t ma) { return ((mv / 50u) << 10) | (ma / 10u); } +// An augmented (PPS) object; only its type matters to the sink +static constexpr uint32_t AUGMENTED_PDO = static_cast(PD_PDO_TYPE_AUGMENTED) << 30; +// Unknown to the sink, so it must be answered with Reject +static constexpr uint8_t PD_CNTRL_DR_SWAP = 0x09; + +class FakePhy : public PowerDelivery { + public: + using PowerDelivery::ams_start_; + using PowerDelivery::check_ams_; + using PowerDelivery::check_transition_timeout_; + using PowerDelivery::check_request_retry_; + using PowerDelivery::contract_; + using PowerDelivery::handle_message_; + using PowerDelivery::handle_transmit_failed_; + using PowerDelivery::retry_request_pending_; + using PowerDelivery::set_state_and_contract_; + using PowerDelivery::wait_received_ms_; + + std::vector sent; + bool send_ok{true}; + int send_attempts{0}; + int state_changes{0}; + int soft_resets{0}; + int source_caps{0}; + int transition_timeouts{0}; + + void receive(uint8_t type, uint8_t id, std::vector objects = {}) { + PdMsg msg; + msg.type = type; + msg.id = id; + msg.num_of_obj = objects.size(); + for (size_t i = 0; i < objects.size(); i++) + msg.data_objects[i] = objects[i]; + this->handle_message_(msg); + } + void receive_source_caps(uint8_t id) { + this->receive(PD_DATA_SOURCE_CAP, id, + {fixed_pdo(5000, 3000), fixed_pdo(9000, 3000), fixed_pdo(15000, 3000), fixed_pdo(20000, 2250)}); + } + PackedContract contract() const { return PackedContract{this->contract_}; } + // What the PHY does when a source is attached + void attach() { this->set_state_and_contract_(PdState::PD_STATE_DEFAULT_CONTRACT, DEFAULT_CONTRACT); } + // Moves the timestamps back instead of sleeping, since millis() is real time on the host + void advance_ms(uint32_t ms) { + this->ams_start_ -= ms; + this->wait_received_ms_ -= ms; + } + + protected: + bool send_message(const PdMsg &msg) override { + this->send_attempts++; + if (!this->send_ok) + return false; + this->sent.push_back(msg); + return true; + } + void on_state_changed() override { this->state_changes++; } + void on_soft_reset_received() override { this->soft_resets++; } + void on_source_caps_received() override { + // The hook runs before the request is sent + EXPECT_EQ(this->sent.size(), static_cast(this->source_caps)); + this->source_caps++; + } + void on_transition_timeout() override { this->transition_timeouts++; } +}; + +TEST(FUSB302BPd, HeaderRoundTrip) { + PdMsg msg; + msg.type = PD_DATA_REQUEST; + msg.spec_rev = PD_SPEC_REV_2; + msg.id = 5; + msg.num_of_obj = 1; + // Type 2, spec revision 1 at bit 6, MessageID 5 at bit 9, one object at bit 12 + EXPECT_EQ(msg.get_coded_header(), 0x1A42); + + PdMsg decoded; + decoded.set_header(0x1A42 | (1u << 15)); + EXPECT_EQ(decoded.type, PD_DATA_REQUEST); + EXPECT_EQ(decoded.spec_rev, PD_SPEC_REV_2); + EXPECT_EQ(decoded.id, 5); + EXPECT_EQ(decoded.num_of_obj, 1); + EXPECT_TRUE(decoded.extended); +} + +TEST(FUSB302BPd, RequestsHighestVoltageNotAboveLimit) { + FakePhy pd; + pd.set_request_voltage(18); + pd.receive_source_caps(0); + EXPECT_EQ(pd.source_caps, 1); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].type, PD_DATA_REQUEST); + EXPECT_EQ(pd.sent[0].data_objects[0] >> 28, 3u); // 15 V is object 3 + EXPECT_EQ(pd.sent[0].data_objects[0] & 0x3FF, 300u); +} + +TEST(FUSB302BPd, LowestRequestPicksFirstObject) { + FakePhy pd; + pd.set_request_voltage(5); + pd.receive_source_caps(0); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].data_objects[0] >> 28, 1u); +} + +TEST(FUSB302BPd, NonFixedObjectKeepsItsPosition) { + FakePhy pd; + pd.set_request_voltage(9); + pd.receive(PD_DATA_SOURCE_CAP, 0, {fixed_pdo(5000, 3000), AUGMENTED_PDO, fixed_pdo(9000, 2000)}); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].data_objects[0] >> 28, 3u); + EXPECT_EQ(pd.sent[0].data_objects[0] & 0x3FF, 200u); +} + +TEST(FUSB302BPd, NoFixedObjectFallsBackToObjectOne) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive(PD_DATA_SOURCE_CAP, 0, {AUGMENTED_PDO, AUGMENTED_PDO}); + ASSERT_EQ(pd.sent.size(), 1u); + const uint32_t rdo = pd.sent[0].data_objects[0]; + EXPECT_EQ(rdo >> 28, 1u); + EXPECT_EQ(rdo & 0x3FF, 30u); // 300 mA maximum + EXPECT_EQ((rdo >> 10) & 0x3FF, 10u); // 100 mA operating +} + +TEST(FUSB302BPd, AcceptThenPsRdyReachesExplicitContract) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_ACCEPT, 1); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_TRANSITION); + pd.receive(PD_CNTRL_PS_RDY, 2); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 20.0f); + EXPECT_FLOAT_EQ(pd.contract().current(), 2.25f); +} + +TEST(FUSB302BPd, AcceptPublishesStateAndContractOnce) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.state_changes = 0; + pd.receive(PD_CNTRL_ACCEPT, 1); + EXPECT_EQ(pd.state_changes, 1); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_TRANSITION); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 20.0f); +} + +TEST(FUSB302BPd, AcceptForSameContractSkipsTransition) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_ACCEPT, 1); + pd.receive(PD_CNTRL_PS_RDY, 2); + pd.receive_source_caps(3); + pd.receive(PD_CNTRL_ACCEPT, 4); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT); + pd.receive(PD_CNTRL_PS_RDY, 5); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT); + EXPECT_FALSE(pd.check_ams_()); +} + +TEST(FUSB302BPd, PsRdyWithoutAcceptIsIgnored) { + FakePhy pd; + pd.receive(PD_CNTRL_PS_RDY, 0); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DISCONNECTED); + + pd.receive_source_caps(1); + pd.receive(PD_CNTRL_PS_RDY, 2); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DISCONNECTED); +} + +TEST(FUSB302BPd, PsRdyAfterRejectIsIgnored) { + FakePhy pd; + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_REJECT, 1); + EXPECT_FALSE(pd.check_ams_()); + pd.receive(PD_CNTRL_PS_RDY, 2); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DISCONNECTED); +} + +TEST(FUSB302BPd, PsRdyAfterSequenceTimeoutIsIgnored) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_ACCEPT, 1); + pd.advance_ms(2001); + pd.receive(PD_CNTRL_PS_RDY, 2); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_TRANSITION); +} + +TEST(FUSB302BPd, TransitionTimeoutFallsBackToDefault) { + FakePhy pd; + pd.attach(); + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_ACCEPT, 1); + pd.check_transition_timeout_(); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_TRANSITION); + EXPECT_EQ(pd.transition_timeouts, 0); + + pd.advance_ms(2001); + pd.state_changes = 0; + pd.check_transition_timeout_(); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DEFAULT_CONTRACT); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 5.0f); + EXPECT_FLOAT_EQ(pd.contract().current(), 0.5f); + EXPECT_EQ(pd.state_changes, 1); + EXPECT_EQ(pd.transition_timeouts, 1); + + // Reported once, and a late PS_RDY does not bring back the unconfirmed contract + pd.check_transition_timeout_(); + pd.receive(PD_CNTRL_PS_RDY, 2); + EXPECT_EQ(pd.transition_timeouts, 1); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DEFAULT_CONTRACT); +} + +TEST(FUSB302BPd, RequestVoltageIsClamped) { + FakePhy pd; + pd.set_request_voltage(255); + EXPECT_EQ(pd.get_request_voltage(), MAX_REQUEST_VOLTAGE); + pd.set_request_voltage(0); + EXPECT_EQ(pd.get_request_voltage(), MIN_REQUEST_VOLTAGE); +} + +TEST(FUSB302BPd, WaitRepeatsRequestAfterSinkRequestTime) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_WAIT, 1); + EXPECT_FALSE(pd.check_ams_()); + pd.check_request_retry_(); + EXPECT_EQ(pd.sent.size(), 1u); + pd.advance_ms(101); + pd.check_request_retry_(); + ASSERT_EQ(pd.sent.size(), 2u); + EXPECT_EQ(pd.sent[1].type, PD_DATA_REQUEST); + EXPECT_EQ(pd.sent[1].data_objects[0], pd.sent[0].data_objects[0]); + EXPECT_TRUE(pd.check_ams_()); +} + +TEST(FUSB302BPd, WaitRetriesStopAtLimit) { + FakePhy pd; + pd.attach(); + pd.receive_source_caps(0); + for (uint8_t i = 1; i <= 10; i++) { + pd.receive(PD_CNTRL_WAIT, i % 8); + pd.advance_ms(101); + pd.check_request_retry_(); + } + EXPECT_EQ(pd.sent.size(), 11u); + pd.receive(PD_CNTRL_WAIT, 3); + EXPECT_FALSE(pd.retry_request_pending_); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); + pd.advance_ms(101); + pd.check_request_retry_(); + EXPECT_EQ(pd.sent.size(), 11u); +} + +TEST(FUSB302BPd, FailedRequestEndsNegotiation) { + FakePhy pd; + pd.attach(); + pd.set_request_voltage(20); + pd.send_ok = false; + pd.receive_source_caps(0); + EXPECT_EQ(pd.send_attempts, 1); + EXPECT_FALSE(pd.check_ams_()); + EXPECT_FALSE(pd.retry_request_pending_); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); + pd.receive(PD_CNTRL_ACCEPT, 1); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 5.0f); +} + +TEST(FUSB302BPd, FailedRetryIsTriedAgain) { + FakePhy pd; + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_WAIT, 1); + pd.send_ok = false; + pd.advance_ms(101); + pd.check_request_retry_(); + EXPECT_EQ(pd.send_attempts, 2); + EXPECT_TRUE(pd.retry_request_pending_); + EXPECT_FALSE(pd.check_ams_()); + + pd.send_ok = true; + pd.check_request_retry_(); + EXPECT_EQ(pd.send_attempts, 2); // waits another tSinkRequest first + pd.advance_ms(101); + pd.check_request_retry_(); + EXPECT_EQ(pd.sent.size(), 2u); + EXPECT_FALSE(pd.retry_request_pending_); +} + +TEST(FUSB302BPd, FailedRetriesStopAtLimit) { + FakePhy pd; + pd.attach(); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_WAIT, 1); + pd.send_ok = false; + for (int i = 0; i < 20 && pd.retry_request_pending_; i++) { + pd.advance_ms(101); + pd.check_request_retry_(); + } + EXPECT_FALSE(pd.retry_request_pending_); + // The first request, then one attempt for each of the 10 allowed repeats + EXPECT_EQ(pd.send_attempts, 11); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); +} + +TEST(FUSB302BPd, RejectedFirstRequestEndsNegotiation) { + FakePhy pd; + pd.attach(); + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_REJECT, 1); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_PD_TIMEOUT); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 5.0f); +} + +TEST(FUSB302BPd, RejectedRenegotiationKeepsContract) { + FakePhy pd; + pd.attach(); + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_ACCEPT, 1); + pd.receive(PD_CNTRL_PS_RDY, 2); + pd.set_request_voltage(9); + pd.receive_source_caps(3); + pd.receive(PD_CNTRL_REJECT, 4); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_EXPLICIT_CONTRACT); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 20.0f); +} + +TEST(FUSB302BPd, RejectAndWaitOutsideRequestAreIgnored) { + FakePhy pd; + pd.attach(); + pd.receive(PD_CNTRL_REJECT, 0); + pd.receive(PD_CNTRL_WAIT, 1); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DEFAULT_CONTRACT); + EXPECT_FALSE(pd.retry_request_pending_); +} + +TEST(FUSB302BPd, UnsupportedDataMessageIsIgnored) { + FakePhy pd; + pd.receive(PD_DATA_REQUEST, 0, {0}); + EXPECT_TRUE(pd.sent.empty()); + EXPECT_EQ(pd.source_caps, 0); +} + +TEST(FUSB302BPd, AcceptOutsideSequenceIsIgnored) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_REJECT, 1); + pd.receive(PD_CNTRL_ACCEPT, 2); + pd.receive(PD_CNTRL_PS_RDY, 3); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DISCONNECTED); + EXPECT_FALSE(pd.contract().is_set()); +} + +TEST(FUSB302BPd, GoodCrcMustMatchMessageId) { + FakePhy pd; + pd.receive(PD_CNTRL_DR_SWAP, 0); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].id, 0); + + pd.receive(PD_CNTRL_GOODCRC, 1); + pd.receive(PD_CNTRL_DR_SWAP, 1); + ASSERT_EQ(pd.sent.size(), 2u); + EXPECT_EQ(pd.sent[1].id, 0); + + pd.receive(PD_CNTRL_GOODCRC, 0); + pd.receive(PD_CNTRL_GOODCRC, 0); // duplicate + pd.receive(PD_CNTRL_DR_SWAP, 2); + ASSERT_EQ(pd.sent.size(), 3u); + EXPECT_EQ(pd.sent[2].id, 1); +} + +TEST(FUSB302BPd, FailedTransmitAdvancesMessageId) { + FakePhy pd; + pd.receive_source_caps(0); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].id, 0); + pd.handle_transmit_failed_(); + // A late GoodCRC for the failed message must not advance the counter a second time + pd.receive(PD_CNTRL_GOODCRC, 0); + pd.receive(PD_CNTRL_DR_SWAP, 1); + ASSERT_EQ(pd.sent.size(), 2u); + EXPECT_EQ(pd.sent[1].id, 1); +} + +TEST(FUSB302BPd, RetransmissionIsIgnored) { + FakePhy pd; + pd.receive_source_caps(0); + pd.receive_source_caps(0); + EXPECT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.source_caps, 1); +} + +TEST(FUSB302BPd, SoftResetIsAccepted) { + FakePhy pd; + pd.receive(PD_CNTRL_SOFT_RESET, 0); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].type, PD_CNTRL_ACCEPT); + EXPECT_EQ(pd.soft_resets, 1); +} + +TEST(FUSB302BPd, SoftResetHookRunsWhenAcceptFails) { + FakePhy pd; + pd.send_ok = false; + pd.receive(PD_CNTRL_SOFT_RESET, 0); + EXPECT_EQ(pd.send_attempts, 1); + EXPECT_EQ(pd.soft_resets, 1); +} + +TEST(FUSB302BPd, SoftResetDuringTransitionFallsBackToDefault) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive_source_caps(0); + pd.receive(PD_CNTRL_ACCEPT, 1); + pd.state_changes = 0; + pd.receive(PD_CNTRL_SOFT_RESET, 0); + EXPECT_EQ(pd.state_changes, 1); + EXPECT_EQ(pd.get_state(), PdState::PD_STATE_DEFAULT_CONTRACT); + EXPECT_FLOAT_EQ(pd.contract().voltage(), 5.0f); + EXPECT_FLOAT_EQ(pd.contract().current(), 0.5f); +} + +TEST(FUSB302BPd, UnknownControlMessageIsRejected) { + FakePhy pd; + pd.receive(PD_CNTRL_DR_SWAP, 0); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].type, PD_CNTRL_REJECT); +} + +TEST(FUSB302BPd, SinkCapAt5VHasOneObject) { + FakePhy pd; + pd.receive(PD_CNTRL_GET_SINK_CAP, 0); + ASSERT_EQ(pd.sent.size(), 1u); + EXPECT_EQ(pd.sent[0].type, PD_DATA_SINK_CAP); + ASSERT_EQ(pd.sent[0].num_of_obj, 1); + const uint32_t pdo = pd.sent[0].data_objects[0]; + EXPECT_EQ((pdo >> 10) & 0x3FF, 100u); + EXPECT_EQ(pdo & 0x3FF, 300u); + EXPECT_FALSE(pdo & (1u << 28)); + EXPECT_TRUE(pdo & (1u << 26)); +} + +TEST(FUSB302BPd, SinkCapListsRequestedVoltage) { + FakePhy pd; + pd.set_request_voltage(20); + pd.receive(PD_CNTRL_GET_SINK_CAP, 0); + ASSERT_EQ(pd.sent.size(), 1u); + ASSERT_EQ(pd.sent[0].num_of_obj, 2); + EXPECT_TRUE(pd.sent[0].data_objects[0] & (1u << 28)); + const uint32_t pdo = pd.sent[0].data_objects[1]; + EXPECT_EQ(pdo >> 30, 0u); + EXPECT_EQ((pdo >> 10) & 0x3FF, 400u); + EXPECT_EQ(pdo & 0x3FF, 300u); + EXPECT_EQ(pdo >> 25, 0u); // capability flags are only valid in the first object +} + +TEST(FUSB302BPd, SinkCapForOutOfRangeVoltageStaysValid) { + FakePhy pd; + pd.set_request_voltage(255); + pd.receive(PD_CNTRL_GET_SINK_CAP, 0); + ASSERT_EQ(pd.sent.size(), 1u); + ASSERT_EQ(pd.sent[0].num_of_obj, 2); + const uint32_t pdo = pd.sent[0].data_objects[1]; + EXPECT_EQ((pdo >> 10) & 0x3FF, 400u); + EXPECT_EQ(pdo >> 20, 0u); // nothing spills into the peak current or flag bits +} + +} // namespace esphome::fusb302b::testing From a1f740b71bda88bcf060d9f5ce05491e0ef5ba0d Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:36:17 -0400 Subject: [PATCH 102/142] [lvgl] Keep LV_CONF_PATH one argument when the build path has a space (#20758) --- esphome/components/lvgl/__init__.py | 14 +++++-- .../lvgl/test_lv_conf_path_flag.py | 39 +++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 tests/component_tests/lvgl/test_lv_conf_path_flag.py diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 6e3087ba9f0..6806eab0cf3 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -335,6 +335,16 @@ def final_validation(config_list): widget_type.final_validate(name, conf, widget_conf, path[1:]) +def lv_conf_path_flag(lv_conf_h_file: Path) -> str: + """The ``-DLV_CONF_PATH`` build flag, decoded identically by the shell + lexers (PlatformIO, host, ESP8266) and the quoted CMake arguments + (ESP-IDF, Zephyr) the build flags pass through.""" + # forward slashes for Windows, then escape what shlex or a quoted CMake + # argument would not pass through literally + path = re.sub(r"[^\w/.:+=,-]", r"\\\g<0>", Path(lv_conf_h_file).as_posix()) + return f'-DLV_CONF_PATH=\\"{path}\\"' + + async def to_code(configs): config_0 = configs[0] # Global configuration @@ -563,9 +573,7 @@ async def to_code(configs): if write_file_if_changed(lv_conf_h_file, generate_lv_conf_h()): clean_build(clear_pio_cache=False) cg.add_build_flag("-DLV_CONF_H=1") - # handle windows paths in a way that doesn't break the generated C++ - lv_conf_h_path = Path(lv_conf_h_file).as_posix() - cg.add_build_flag(f'-DLV_CONF_PATH=\\"{lv_conf_h_path}\\"') + cg.add_build_flag(lv_conf_path_flag(lv_conf_h_file)) cg.add_build_flag("-DLV_KCONFIG_IGNORE") for prop in df.get_remapped_uses(): diff --git a/tests/component_tests/lvgl/test_lv_conf_path_flag.py b/tests/component_tests/lvgl/test_lv_conf_path_flag.py new file mode 100644 index 00000000000..e13a0bcc376 --- /dev/null +++ b/tests/component_tests/lvgl/test_lv_conf_path_flag.py @@ -0,0 +1,39 @@ +"""The ``-DLV_CONF_PATH`` build flag carries a path. PlatformIO, the host +build and the native ESP8266 build shell-lex build flags, while the ESP-IDF +and Zephyr builds write each flag into a quoted CMake argument; both must +decode it to one ``-DLV_CONF_PATH="..."`` argument, including when the +project path contains a space.""" + +from __future__ import annotations + +from pathlib import Path +import re +import shlex + +import pytest + +from esphome.components.lvgl import lv_conf_path_flag + + +def _cmake_quoted_argument(flag: str) -> str: + """Decode ``flag`` as CMake decodes a quoted argument: a backslash escapes the next character.""" + return re.sub(r"\\(.)", r"\1", flag) + + +@pytest.mark.parametrize( + "build_path", + [ + "/home/user/.esphome/build/node", + "/Users/me/with space/.esphome/build/node", + "/srv/odd 'quote' $(and)/build", + "C:/Users/Kris Maddens/esphome/.esphome/build/node", + ], + ids=["plain", "space", "shell-specials", "windows"], +) +def test_flag_is_one_argument_everywhere(build_path: str) -> None: + lv_conf_h_file = Path(build_path) / "src" / "lv_conf.h" + flag = lv_conf_path_flag(lv_conf_h_file) + expected = f'-DLV_CONF_PATH="{lv_conf_h_file.as_posix()}"' + + assert shlex.split(flag) == [expected] + assert _cmake_quoted_argument(flag) == expected From b994e081c30b21a8ecf222e794030bc439353e67 Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:42:47 +0100 Subject: [PATCH 103/142] [bm8563] Convert the weekday to and from the chip's 0-6 range (#20737) --- esphome/components/bm8563/bm8563.cpp | 5 +- tests/components/bm8563/__init__.py | 6 ++ tests/components/bm8563/test_bm8563.cpp | 77 +++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 tests/components/bm8563/__init__.py create mode 100644 tests/components/bm8563/test_bm8563.cpp diff --git a/esphome/components/bm8563/bm8563.cpp b/esphome/components/bm8563/bm8563.cpp index df1cf96d698..de6b51ab1f5 100644 --- a/esphome/components/bm8563/bm8563.cpp +++ b/esphome/components/bm8563/bm8563.cpp @@ -100,7 +100,8 @@ void BM8563::get_date_(ESPTime &time) { this->read_register(DATE_FIRST_REG, buf, sizeof(buf)); time.day_of_month = this->bcd2_to_byte_(buf[0] & 0x3f); - time.day_of_week = this->bcd2_to_byte_(buf[1] & 0x07); + // The chip counts weekdays 0-6 from Sunday (not BCD), ESPTime counts 1-7 + time.day_of_week = (buf[1] & 0x07) + 1; time.month = this->bcd2_to_byte_(buf[2] & 0x1f); uint8_t year_byte = this->bcd2_to_byte_(buf[3] & 0xff); @@ -115,7 +116,7 @@ void BM8563::get_date_(ESPTime &time) { void BM8563::set_date_(const ESPTime &time) { uint8_t buf[4] = { this->byte_to_bcd2_(time.day_of_month), - this->byte_to_bcd2_(time.day_of_week), + static_cast(time.day_of_week - 1), this->byte_to_bcd2_(time.month), this->byte_to_bcd2_(time.year % 100), }; diff --git a/tests/components/bm8563/__init__.py b/tests/components/bm8563/__init__.py new file mode 100644 index 00000000000..9465ea845e2 --- /dev/null +++ b/tests/components/bm8563/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The driver lives in the time platform, so pull in what it builds against. + manifest.dependencies = manifest.dependencies + ["i2c", "time"] diff --git a/tests/components/bm8563/test_bm8563.cpp b/tests/components/bm8563/test_bm8563.cpp new file mode 100644 index 00000000000..3514702e3b4 --- /dev/null +++ b/tests/components/bm8563/test_bm8563.cpp @@ -0,0 +1,77 @@ +#include + +#include +#include + +#include "esphome/components/bm8563/bm8563.h" +#include "esphome/core/time.h" + +namespace esphome::bm8563::testing { + +static constexpr uint8_t ADDRESS = 0x51; +static constexpr uint8_t WEEKDAYS_REG = 0x06; +static constexpr uint8_t CHIP_DAYS_PER_WEEK = 7; // The chip counts weekdays 0-6, Sunday = 0 + +// The chip's 16 registers behind an address pointer that moves on after every byte. +class RegisterBus final : public i2c::I2CBus { + public: + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + EXPECT_EQ(address, ADDRESS); + if (size != 0) { + this->pointer_ = data[0]; + for (size_t i = 1; i < size; i++) + this->registers[this->next_()] = data[i]; + } + for (size_t i = 0; i < out_size; i++) + out[i] = this->registers[this->next_()]; + return i2c::ERROR_OK; + } + + std::array registers{}; + + protected: + size_t next_() { return this->pointer_++ % this->registers.size(); } + + uint8_t pointer_{0}; +}; + +// write_time() takes the host's current time, so reading it back never changes the host clock. +class BM8563Test : public ::testing::Test { + protected: + void SetUp() override { + this->rtc_.set_i2c_bus(&this->bus_); + this->rtc_.set_i2c_address(ADDRESS); + this->rtc_.add_on_time_sync_callback([this]() { ++this->syncs_; }); + this->rtc_.setup(); + } + + RegisterBus bus_; + BM8563 rtc_; + unsigned syncs_{0}; +}; + +TEST_F(BM8563Test, WriteTimeStoresTheChipsWeekday) { + // Read the clock on both sides of the write, in case UTC midnight falls in between. + const int before = ESPTime::from_epoch_utc(::time(nullptr)).day_of_week - 1; + this->rtc_.write_time(); + const int after = ESPTime::from_epoch_utc(::time(nullptr)).day_of_week - 1; + const int stored = this->bus_.registers[WEEKDAYS_REG]; + EXPECT_TRUE(stored == before || stored == after) << "stored " << stored << ", expected " << before; +} + +// After a write the chip keeps counting on its own and goes from Saturday (6) back to Sunday (0) at midnight. +// Seven steps from the written value visit every weekday once, and each has to read back as a valid time. +// Only the weekday moves, so the time read back is still the host's time. +TEST_F(BM8563Test, WeekdayRoundTripsThroughSundayAndSaturday) { + this->rtc_.write_time(); + uint8_t &weekday = this->bus_.registers[WEEKDAYS_REG]; + for (uint8_t day = 0; day < CHIP_DAYS_PER_WEEK; day++) { + weekday = (weekday + 1) % CHIP_DAYS_PER_WEEK; + const unsigned syncs_before = this->syncs_; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, syncs_before + 1) << "chip weekday " << unsigned(weekday); + } +} + +} // namespace esphome::bm8563::testing From fee47fe2f16340a5f39f50c556fe65575f9acdaa Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:43:23 +0100 Subject: [PATCH 104/142] [pcf85063] Convert the weekday to and from the chip's 0-6 range (#20736) --- esphome/components/pcf85063/pcf85063.cpp | 5 +- tests/components/pcf85063/__init__.py | 6 ++ tests/components/pcf85063/test_pcf85063.cpp | 77 +++++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 tests/components/pcf85063/__init__.py create mode 100644 tests/components/pcf85063/test_pcf85063.cpp diff --git a/esphome/components/pcf85063/pcf85063.cpp b/esphome/components/pcf85063/pcf85063.cpp index 8ce76e9781c..bafe19ed2a4 100644 --- a/esphome/components/pcf85063/pcf85063.cpp +++ b/esphome/components/pcf85063/pcf85063.cpp @@ -37,7 +37,8 @@ void PCF85063Component::read_time() { .second = uint8_t(pcf85063_.reg.second + 10 * pcf85063_.reg.second_10), .minute = uint8_t(pcf85063_.reg.minute + 10u * pcf85063_.reg.minute_10), .hour = uint8_t(pcf85063_.reg.hour + 10u * pcf85063_.reg.hour_10), - .day_of_week = uint8_t(pcf85063_.reg.weekday), + // The chip counts weekdays 0-6 from Sunday, ESPTime counts 1-7 + .day_of_week = uint8_t(this->pcf85063_.reg.weekday + 1), .day_of_month = uint8_t(pcf85063_.reg.day + 10u * pcf85063_.reg.day_10), .month = uint8_t(pcf85063_.reg.month + 10u * pcf85063_.reg.month_10), .year = uint16_t(pcf85063_.reg.year + 10u * pcf85063_.reg.year_10 + 2000), @@ -62,7 +63,7 @@ void PCF85063Component::write_time() { pcf85063_.reg.month_10 = now.month / 10; pcf85063_.reg.day = now.day_of_month % 10; pcf85063_.reg.day_10 = now.day_of_month / 10; - pcf85063_.reg.weekday = now.day_of_week; + this->pcf85063_.reg.weekday = now.day_of_week - 1; pcf85063_.reg.hour = now.hour % 10; pcf85063_.reg.hour_10 = now.hour / 10; pcf85063_.reg.minute = now.minute % 10; diff --git a/tests/components/pcf85063/__init__.py b/tests/components/pcf85063/__init__.py new file mode 100644 index 00000000000..9465ea845e2 --- /dev/null +++ b/tests/components/pcf85063/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The driver lives in the time platform, so pull in what it builds against. + manifest.dependencies = manifest.dependencies + ["i2c", "time"] diff --git a/tests/components/pcf85063/test_pcf85063.cpp b/tests/components/pcf85063/test_pcf85063.cpp new file mode 100644 index 00000000000..0e5c5e6c20d --- /dev/null +++ b/tests/components/pcf85063/test_pcf85063.cpp @@ -0,0 +1,77 @@ +#include + +#include +#include + +#include "esphome/components/pcf85063/pcf85063.h" +#include "esphome/core/time.h" + +namespace esphome::pcf85063::testing { + +static constexpr uint8_t ADDRESS = 0x51; +static constexpr uint8_t WEEKDAYS_REG = 0x08; +static constexpr uint8_t CHIP_DAYS_PER_WEEK = 7; // The chip counts weekdays 0-6, Sunday = 0 + +// The chip's 18 registers behind an address pointer that moves on after every byte. +class RegisterBus final : public i2c::I2CBus { + public: + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + EXPECT_EQ(address, ADDRESS); + if (size != 0) { + this->pointer_ = data[0]; + for (size_t i = 1; i < size; i++) + this->registers[this->next_()] = data[i]; + } + for (size_t i = 0; i < out_size; i++) + out[i] = this->registers[this->next_()]; + return i2c::ERROR_OK; + } + + std::array registers{}; + + protected: + size_t next_() { return this->pointer_++ % this->registers.size(); } + + uint8_t pointer_{0}; +}; + +// write_time() takes the host's current time, so reading it back never changes the host clock. +class PCF85063Test : public ::testing::Test { + protected: + void SetUp() override { + this->rtc_.set_i2c_bus(&this->bus_); + this->rtc_.set_i2c_address(ADDRESS); + this->rtc_.add_on_time_sync_callback([this]() { ++this->syncs_; }); + this->rtc_.setup(); + } + + RegisterBus bus_; + PCF85063Component rtc_; + unsigned syncs_{0}; +}; + +TEST_F(PCF85063Test, WriteTimeStoresTheChipsWeekday) { + // Read the clock on both sides of the write, in case UTC midnight falls in between. + const int before = ESPTime::from_epoch_utc(::time(nullptr)).day_of_week - 1; + this->rtc_.write_time(); + const int after = ESPTime::from_epoch_utc(::time(nullptr)).day_of_week - 1; + const int stored = this->bus_.registers[WEEKDAYS_REG]; + EXPECT_TRUE(stored == before || stored == after) << "stored " << stored << ", expected " << before; +} + +// After a write the chip keeps counting on its own and goes from Saturday (6) back to Sunday (0) at midnight. +// Seven steps from the written value visit every weekday once, and each has to read back as a valid time. +// Only the weekday moves, so the time read back is still the host's time. +TEST_F(PCF85063Test, WeekdayRoundTripsThroughSundayAndSaturday) { + this->rtc_.write_time(); + uint8_t &weekday = this->bus_.registers[WEEKDAYS_REG]; + for (uint8_t day = 0; day < CHIP_DAYS_PER_WEEK; day++) { + weekday = (weekday + 1) % CHIP_DAYS_PER_WEEK; + const unsigned syncs_before = this->syncs_; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, syncs_before + 1) << "chip weekday " << unsigned(weekday); + } +} + +} // namespace esphome::pcf85063::testing From 56f1a2bf460c8cdf3e19efad97c03689b9259d19 Mon Sep 17 00:00:00 2001 From: Fexiven <48439988+Fexiven@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:45:21 +0200 Subject: [PATCH 105/142] [midea_portasplit] Add Midea PortaSplit climate component (#18033) Co-authored-by: Claude Opus 4.8 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../components/midea_portasplit/__init__.py | 1 + .../components/midea_portasplit/climate.py | 30 ++ .../midea_portasplit/midea_portasplit.cpp | 337 ++++++++++++++++++ .../midea_portasplit/midea_portasplit.h | 63 ++++ tests/components/midea_portasplit/common.yaml | 4 + .../midea_portasplit/test.esp32-idf.yaml | 3 + .../midea_portasplit/test.esp8266-ard.yaml | 3 + 8 files changed, 442 insertions(+) create mode 100644 esphome/components/midea_portasplit/__init__.py create mode 100644 esphome/components/midea_portasplit/climate.py create mode 100644 esphome/components/midea_portasplit/midea_portasplit.cpp create mode 100644 esphome/components/midea_portasplit/midea_portasplit.h create mode 100644 tests/components/midea_portasplit/common.yaml create mode 100644 tests/components/midea_portasplit/test.esp32-idf.yaml create mode 100644 tests/components/midea_portasplit/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 7473c80cdc6..56cb685ec53 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -387,6 +387,7 @@ esphome/components/microphone/* @jesserockz @kahrendt esphome/components/mics_4514/* @jesserockz esphome/components/midea/* @dudanov esphome/components/midea_ir/* @dudanov +esphome/components/midea_portasplit/* @Fexiven esphome/components/mipi_dsi/* @clydebarrow esphome/components/mipi_rgb/* @clydebarrow esphome/components/mipi_spi/* @clydebarrow diff --git a/esphome/components/midea_portasplit/__init__.py b/esphome/components/midea_portasplit/__init__.py new file mode 100644 index 00000000000..e94ead74d25 --- /dev/null +++ b/esphome/components/midea_portasplit/__init__.py @@ -0,0 +1 @@ +DOMAIN = "midea_portasplit" diff --git a/esphome/components/midea_portasplit/climate.py b/esphome/components/midea_portasplit/climate.py new file mode 100644 index 00000000000..efac42a403b --- /dev/null +++ b/esphome/components/midea_portasplit/climate.py @@ -0,0 +1,30 @@ +import esphome.codegen as cg +from esphome.components import climate, uart +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import DOMAIN + +CODEOWNERS = ["@Fexiven"] +DEPENDENCIES = ["uart"] + +midea_portasplit_ns = cg.esphome_ns.namespace("midea_portasplit") +PortaSplitClimate = midea_portasplit_ns.class_( + "PortaSplitClimate", climate.Climate, uart.UARTDevice, cg.Component +) + +CONFIG_SCHEMA = ( + climate.climate_schema(PortaSplitClimate) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + DOMAIN, baud_rate=9600, require_tx=True, require_rx=True +) + + +async def to_code(config: ConfigType) -> None: + var = await climate.new_climate(config) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) diff --git a/esphome/components/midea_portasplit/midea_portasplit.cpp b/esphome/components/midea_portasplit/midea_portasplit.cpp new file mode 100644 index 00000000000..a1f22226d9c --- /dev/null +++ b/esphome/components/midea_portasplit/midea_portasplit.cpp @@ -0,0 +1,337 @@ +#include "midea_portasplit.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::midea_portasplit { + +ESPHOME_LOG_TAG(TAG, "midea_portasplit"); + +static constexpr uint32_t FOLLOWUP_TIMEOUT_ID = 0; + +static constexpr uint32_t STATUS_INTERVAL_MS = 30000; +static constexpr uint32_t NETWORK_INTERVAL_MS = 10000; +static constexpr uint32_t FOLLOWUP_DELAY_MS = 1500; +// At 9600 baud bytes within a frame arrive about 1 ms apart. +static constexpr uint32_t RX_TIMEOUT_MS = 100; + +// Ten header bytes plus CRC8 and checksum. +static constexpr size_t FRAME_OVERHEAD = 12; +static constexpr size_t MAX_TX_PAYLOAD = 24; + +static constexpr float MIN_TARGET_TEMP = 16.0f; +static constexpr float MAX_TARGET_TEMP = 30.0f; + +// The AC expects its Wi-Fi module to answer the 0x63 keepalive poll and to send periodic 0x0D network status +// frames. These frames use the XOR sync byte (0x1E ^ 0xAC = 0xB2). The AC does not use the IP address; the +// fixed frame below was captured from the original module. +static constexpr uint8_t MODULE_FRAME[31] PROGMEM = {0xAA, 0x1E, 0xAC, 0xB2, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x01, + 0x01, 0x04, 0xC0, 0xA8, 0xB2, 0x72, 0x00, 0x00, 0x00, 0x01, 0x01, + 0x01, 0x28, 0x09, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00}; +static constexpr uint8_t STATUS_QUERY[12] PROGMEM = {0x41, 0x21, 0x00, 0xFF, 0x03, 0xFF, + 0x00, 0x02, 0x00, 0x00, 0x00, 0x00}; +// Same capability queries the original module sends at boot. +static constexpr uint8_t CAPS_0[] PROGMEM = {0xB5, 0x01, 0x00}; +static constexpr uint8_t CAPS_1[] PROGMEM = {0xB5, 0x01, 0x01, 0x01}; + +// Two's complement of the sum of all bytes after the 0xAA start marker. +static uint8_t checksum(const uint8_t *frame, size_t len) { return (uint8_t) -sum_bytes(frame + 1, len - 1); } + +static uint8_t climate_mode_to_midea(climate::ClimateMode mode) { + switch (mode) { + case climate::CLIMATE_MODE_COOL: + return 2; + case climate::CLIMATE_MODE_DRY: + return 3; + case climate::CLIMATE_MODE_HEAT: + return 4; + case climate::CLIMATE_MODE_FAN_ONLY: + return 5; + case climate::CLIMATE_MODE_HEAT_COOL: + default: + return 1; + } +} + +static climate::ClimateMode midea_mode_to_climate(uint8_t mode) { + switch (mode) { + case 2: + return climate::CLIMATE_MODE_COOL; + case 3: + return climate::CLIMATE_MODE_DRY; + case 4: + return climate::CLIMATE_MODE_HEAT; + case 5: + return climate::CLIMATE_MODE_FAN_ONLY; + case 1: + default: + return climate::CLIMATE_MODE_HEAT_COOL; + } +} + +static uint8_t fan_mode_to_midea(climate::ClimateFanMode mode) { + switch (mode) { + case climate::CLIMATE_FAN_QUIET: + return 20; + case climate::CLIMATE_FAN_LOW: + return 40; + case climate::CLIMATE_FAN_MEDIUM: + return 60; + case climate::CLIMATE_FAN_HIGH: + return 80; + default: + return FAN_AUTO; + } +} + +// The AC reports its own fan steps (30 for low, 50 for medium), so classify by range. +static climate::ClimateFanMode midea_fan_to_climate(uint8_t fan) { + if (fan > 100) + return climate::CLIMATE_FAN_AUTO; + if (fan <= 30) + return climate::CLIMATE_FAN_QUIET; + if (fan <= 50) + return climate::CLIMATE_FAN_LOW; + if (fan <= 70) + return climate::CLIMATE_FAN_MEDIUM; + return climate::CLIMATE_FAN_HIGH; +} + +void PortaSplitClimate::setup() { + // The status interval starts with a random offset of up to 5 s, which also serves as the boot query. + this->set_timeout(10000, [this]() { this->send_capability_queries_(); }); + this->set_interval(STATUS_INTERVAL_MS, [this]() { this->send_status_query_(); }); + this->set_interval(NETWORK_INTERVAL_MS, [this]() { this->send_module_frame_(0x0D, this->net_counter_++, 0x08); }); +} + +void PortaSplitClimate::loop() { + const uint32_t now = App.get_loop_component_start_time(); + uint8_t buf[32]; + size_t n; + while ((n = std::min((size_t) this->available(), sizeof(buf))) != 0 && this->read_array(buf, n)) { + for (size_t i = 0; i < n; i++) + this->rx_feed_(buf[i]); + this->rx_last_byte_ = now; + } + // The line went quiet mid-frame; drop it so its bytes cannot swallow the next one. + if (this->rx_len_ != 0 && now - this->rx_last_byte_ > RX_TIMEOUT_MS) + this->rx_len_ = 0; +} + +void PortaSplitClimate::dump_config() { + ESP_LOGCONFIG(TAG, "Midea PortaSplit:"); + this->dump_traits_(TAG); +} + +climate::ClimateTraits PortaSplitClimate::traits() { + auto traits = climate::ClimateTraits(); + traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); + traits.set_visual_min_temperature(MIN_TARGET_TEMP); + traits.set_visual_max_temperature(MAX_TARGET_TEMP); + traits.set_visual_temperature_step(0.5f); + traits.set_supported_modes({ + climate::CLIMATE_MODE_OFF, + climate::CLIMATE_MODE_HEAT_COOL, + climate::CLIMATE_MODE_COOL, + climate::CLIMATE_MODE_DRY, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_MODE_FAN_ONLY, + }); + traits.set_supported_fan_modes({ + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_FAN_QUIET, + climate::CLIMATE_FAN_LOW, + climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH, + }); + traits.set_supported_swing_modes({ + climate::CLIMATE_SWING_OFF, + climate::CLIMATE_SWING_VERTICAL, + }); + traits.set_supported_presets({ + climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_SLEEP, climate::CLIMATE_PRESET_BOOST, + climate::CLIMATE_PRESET_ECO, + climate::CLIMATE_PRESET_AWAY, // Frost protection (8 degC heating) + }); + return traits; +} + +void PortaSplitClimate::control(const climate::ClimateCall &call) { + if (call.get_mode().has_value()) { + const climate::ClimateMode mode = *call.get_mode(); + this->st_power_ = mode != climate::CLIMATE_MODE_OFF; + if (this->st_power_) + this->st_mode_ = climate_mode_to_midea(mode); + // Frost protection only runs while heating. + if (mode != climate::CLIMATE_MODE_HEAT) + this->st_frost_ = false; + } + if (call.get_target_temperature().has_value()) { + const float target = clamp(*call.get_target_temperature(), MIN_TARGET_TEMP, MAX_TARGET_TEMP); + this->st_target_half_ = (uint8_t) lroundf(target * 2.0f); + } + if (call.get_fan_mode().has_value()) + this->st_fan_ = fan_mode_to_midea(*call.get_fan_mode()); + if (call.get_swing_mode().has_value()) + this->st_swing_ = *call.get_swing_mode() == climate::CLIMATE_SWING_VERTICAL; + if (call.get_preset().has_value()) { + const climate::ClimatePreset preset = *call.get_preset(); + this->st_turbo_ = preset == climate::CLIMATE_PRESET_BOOST; + this->st_sleep_ = preset == climate::CLIMATE_PRESET_SLEEP; + this->st_eco_ = preset == climate::CLIMATE_PRESET_ECO; + this->st_frost_ = preset == climate::CLIMATE_PRESET_AWAY; + if (this->st_frost_) { + this->st_mode_ = climate_mode_to_midea(climate::CLIMATE_MODE_HEAT); + this->st_power_ = true; + } + } + this->send_set_command_(); +} + +void PortaSplitClimate::send_frame_(uint8_t proto, uint8_t type, const uint8_t *payload, size_t len) { + uint8_t f[FRAME_OVERHEAD + MAX_TX_PAYLOAD] = {0}; + const size_t total = FRAME_OVERHEAD + len; + f[0] = 0xAA; + f[1] = (uint8_t) (total - 1); + f[2] = 0xAC; + // f[3] is the sync byte and must stay 0x00 on the PortaSplit; f[4..7] are reserved. + f[8] = proto; + f[9] = type; + progmem_memcpy(&f[10], payload, len); + f[10 + len] = crc8(&f[10], (uint8_t) len); + f[11 + len] = checksum(f, 11 + len); + this->write_array(f, total); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_pretty_size(sizeof(f))]; + ESP_LOGV(TAG, "TX: %s", format_hex_pretty_to(hex_buf, f, total)); +#endif +} + +void PortaSplitClimate::send_set_command_() { + uint8_t p[MAX_TX_PAYLOAD] = {0}; + const uint8_t whole = this->st_target_half_ >> 1; + const bool half = this->st_target_half_ & 1; + p[0] = 0x40; + p[1] = 0x42; // 0x02 is mandatory, 0x40 enables the beeper + if (this->st_power_) + p[1] |= 0x01; + p[2] = (uint8_t) ((this->st_mode_ << 5) | (half ? 0x10 : 0x00) | ((whole - 16) & 0x0F)); + p[3] = this->st_fan_; + p[4] = 0x7F; + p[5] = 0x7F; + p[6] = 0xF0; + p[7] = this->st_swing_ ? 0x3C : 0x30; + p[8] = this->st_turbo_ ? 0x20 : 0x00; + p[9] = (uint8_t) ((this->st_ion_ ? 0x20 : 0x00) | (this->st_eco_ ? 0x80 : 0x00)); + p[10] = this->st_sleep_ ? 0x01 : 0x00; + p[18] = (uint8_t) ((whole - 12) & 0x1F); + p[19] = 0x80; + p[21] = 0x0A; + p[22] = this->st_frost_ ? 0x80 : 0x00; + p[23] = this->msg_id_++; + this->send_frame_(0x02, 0x02, p, sizeof(p)); + // Read back the state the AC actually applied. + this->set_timeout(FOLLOWUP_TIMEOUT_ID, FOLLOWUP_DELAY_MS, [this]() { this->send_status_query_(); }); +} + +void PortaSplitClimate::send_status_query_() { + uint8_t p[sizeof(STATUS_QUERY)]; + progmem_memcpy(p, STATUS_QUERY, sizeof(p)); + p[11] = this->msg_id_++; + this->send_frame_(0x00, 0x03, p, sizeof(p)); +} + +void PortaSplitClimate::send_capability_queries_() { + this->send_frame_(0x03, 0x03, CAPS_0, sizeof(CAPS_0)); + this->set_timeout(1000, [this]() { this->send_frame_(0x03, 0x03, CAPS_1, sizeof(CAPS_1)); }); +} + +void PortaSplitClimate::send_module_frame_(uint8_t type, uint8_t counter, uint8_t wifi_flags) { + uint8_t f[sizeof(MODULE_FRAME)]; + progmem_memcpy(f, MODULE_FRAME, sizeof(f)); + f[6] = counter; + f[9] = type; + f[25] = wifi_flags; + f[30] = checksum(f, 30); + this->write_array(f, sizeof(f)); +} + +void PortaSplitClimate::rx_feed_(uint8_t b) { + if (this->rx_len_ == 0 && b != 0xAA) + return; + this->rx_[this->rx_len_++] = b; + if (this->rx_len_ < 3) + return; + const size_t total = (size_t) this->rx_[1] + 1; + // Every frame carries the appliance type 0xAC; anything else is a false start. + if (this->rx_len_ == 3 && (total < FRAME_OVERHEAD || total > MAX_FRAME || this->rx_[2] != 0xAC)) { + this->rx_len_ = 0; + return; + } + if (this->rx_len_ < total) + return; + this->rx_len_ = 0; + if (checksum(this->rx_.data(), total - 1) != this->rx_[total - 1]) { + ESP_LOGW(TAG, "RX checksum mismatch, dropping frame"); + return; + } +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_pretty_size(MAX_FRAME)]; + ESP_LOGV(TAG, "RX: %s", format_hex_pretty_to(hex_buf, this->rx_.data(), total)); +#endif + this->handle_frame_(this->rx_.data(), total); +} + +void PortaSplitClimate::handle_frame_(const uint8_t *f, size_t len) { + if (f[9] == 0x63) { + this->send_module_frame_(0x63, 0, 0x00); + } else if (f[9] == 0x05 && f[10] == 0xB5) { + // The original module acknowledges telemetry pushes by echoing them back. + this->write_array(f, len); + } else if (f[10] == 0xC0) { + this->handle_c0_(f, len); + } +} + +void PortaSplitClimate::handle_c0_(const uint8_t *f, size_t len) { + if (len < 26) + return; + const uint8_t mode = (f[12] >> 5) & 0x07; + const uint8_t target_half = (uint8_t) ((((f[12] & 0x0F) + 16) << 1) | ((f[12] & 0x10) ? 1 : 0)); + const climate::ClimateFanMode fan = midea_fan_to_climate(f[13]); + + this->st_power_ = f[11] & 0x01; + if (mode >= 1 && mode <= 5) + this->st_mode_ = mode; + this->st_target_half_ = target_half; + this->st_fan_ = fan_mode_to_midea(fan); + this->st_swing_ = (f[17] & 0x0F) == 0x0C; + this->st_turbo_ = f[18] & 0x20; + this->st_ion_ = f[19] & 0x20; + this->st_eco_ = f[19] & 0x10; // Set command uses bit 7, readback uses bit 4. + this->st_sleep_ = f[20] & 0x01; + this->st_frost_ = len > 31 && (f[31] & 0x80); + + this->mode = this->st_power_ ? midea_mode_to_climate(this->st_mode_) : climate::CLIMATE_MODE_OFF; + this->target_temperature = target_half / 2.0f; + // 0x00 and 0xFF mean the indoor probe gave no reading. + const uint8_t raw_temp = f[21]; + this->current_temperature = (raw_temp == 0x00 || raw_temp == 0xFF) ? NAN : ((float) raw_temp - 50.0f) / 2.0f; + this->fan_mode = fan; + this->swing_mode = this->st_swing_ ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF; + if (this->st_frost_) { + this->preset = climate::CLIMATE_PRESET_AWAY; + } else if (this->st_turbo_) { + this->preset = climate::CLIMATE_PRESET_BOOST; + } else if (this->st_sleep_) { + this->preset = climate::CLIMATE_PRESET_SLEEP; + } else if (this->st_eco_) { + this->preset = climate::CLIMATE_PRESET_ECO; + } else { + this->preset = climate::CLIMATE_PRESET_NONE; + } + this->publish_state(); +} + +} // namespace esphome::midea_portasplit diff --git a/esphome/components/midea_portasplit/midea_portasplit.h b/esphome/components/midea_portasplit/midea_portasplit.h new file mode 100644 index 00000000000..22e71a42793 --- /dev/null +++ b/esphome/components/midea_portasplit/midea_portasplit.h @@ -0,0 +1,63 @@ +#pragma once + +// Midea PortaSplit climate component. +// +// The PortaSplit uses sync byte 0x00 for climate frames and silently drops the +// XOR sync byte (LEN ^ 0xAC) frames that the `midea` component sends, which is +// why the stock `midea` platform cannot drive this unit. +// +// Frame: AA | LEN | AC | SYNC | 00 00 00 00 | PROTO | TYPE | PAYLOAD | CRC8 | CS + +#include "esphome/core/component.h" +#include "esphome/components/uart/uart.h" +#include "esphome/components/climate/climate.h" + +#include + +namespace esphome::midea_portasplit { + +static constexpr uint8_t FAN_AUTO = 102; + +class PortaSplitClimate : public climate::Climate, public uart::UARTDevice, public Component { + public: + void setup() override; + void loop() override; + void dump_config() override; + + climate::ClimateTraits traits() override; + void control(const climate::ClimateCall &call) override; + + protected: + static constexpr size_t MAX_FRAME = 128; + + // The payload may live in PROGMEM. + void send_frame_(uint8_t proto, uint8_t type, const uint8_t *payload, size_t len); + void send_set_command_(); + void send_status_query_(); + void send_module_frame_(uint8_t type, uint8_t counter, uint8_t wifi_flags); + void send_capability_queries_(); + + void rx_feed_(uint8_t b); + void handle_frame_(const uint8_t *f, size_t len); + void handle_c0_(const uint8_t *f, size_t len); + + uint32_t rx_last_byte_{0}; + // Last known device state, mirrored from C0 so a set command does not clobber it. + uint8_t st_target_half_{48}; // Target temperature in half degrees + uint8_t st_mode_{1}; + uint8_t st_fan_{FAN_AUTO}; + bool st_power_ : 1 {false}; + bool st_swing_ : 1 {false}; + bool st_turbo_ : 1 {false}; + bool st_ion_ : 1 {false}; + bool st_sleep_ : 1 {false}; + bool st_eco_ : 1 {false}; + bool st_frost_ : 1 {false}; + + uint8_t msg_id_{1}; + uint8_t net_counter_{0}; + uint8_t rx_len_{0}; + std::array rx_{}; +}; + +} // namespace esphome::midea_portasplit diff --git a/tests/components/midea_portasplit/common.yaml b/tests/components/midea_portasplit/common.yaml new file mode 100644 index 00000000000..8e5c9519479 --- /dev/null +++ b/tests/components/midea_portasplit/common.yaml @@ -0,0 +1,4 @@ +climate: + - platform: midea_portasplit + id: midea_portasplit_unit + name: PortaSplit diff --git a/tests/components/midea_portasplit/test.esp32-idf.yaml b/tests/components/midea_portasplit/test.esp32-idf.yaml new file mode 100644 index 00000000000..844ec272d2c --- /dev/null +++ b/tests/components/midea_portasplit/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + midea_portasplit: !include common.yaml diff --git a/tests/components/midea_portasplit/test.esp8266-ard.yaml b/tests/components/midea_portasplit/test.esp8266-ard.yaml new file mode 100644 index 00000000000..a84a5a57722 --- /dev/null +++ b/tests/components/midea_portasplit/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + midea_portasplit: !include common.yaml From 7afc2768f84fb68bdd44a9ab2991030fb782fe8e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 08:45:51 -1000 Subject: [PATCH 106/142] [esp8266] Check that every component's saved values fit together (#20686) --- esphome/components/esp8266/__init__.py | 11 ++ esphome/components/esp8266/const.py | 87 +++++++++++++- esphome/components/esp8266/crash_handler.cpp | 12 +- esphome/components/esp8266/preferences.cpp | 30 +++-- esphome/components/esp8266/rtc_layout.h | 18 +++ esphome/core/defines.h | 3 + esphome/preferences.py | 14 +++ .../components/esp8266/test_preferences.py | 107 ++++++++++++++++++ 8 files changed, 257 insertions(+), 25 deletions(-) create mode 100644 esphome/components/esp8266/rtc_layout.h create mode 100644 tests/unit_tests/components/esp8266/test_preferences.py diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index d95cbd239b5..27c6fb1958d 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -53,10 +53,14 @@ from .const import ( KEY_SERIAL1_REQUIRED, KEY_SERIAL_REQUIRED, KEY_WAVEFORM_REQUIRED, + PREFERENCES_RTC_EBOOT_WORDS, + PREFERENCES_RTC_NORMAL_WORDS, THROW_STUBS_HEADER, + check_preferences_fit, enable_serial, enable_serial1, esp8266_ns, + preferences_flash_words, ) from .gpio import PinInitialState, add_pin_initial_states_array @@ -467,6 +471,12 @@ async def to_code(config: ConfigType) -> None: # Other projects like Tasmota & ESPEasy also use this cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") + cg.add_define("ESP8266_PREFERENCES_RTC_NORMAL_WORDS", PREFERENCES_RTC_NORMAL_WORDS) + cg.add_define("ESP8266_PREFERENCES_RTC_EBOOT_WORDS", PREFERENCES_RTC_EBOOT_WORDS) + cg.add_define( + "ESP8266_PREFERENCES_FLASH_WORDS", + preferences_flash_words(config[CONF_RESTORE_FROM_FLASH]), + ) if config[CONF_RESTORE_FROM_FLASH]: cg.add_define("USE_ESP8266_PREFERENCES_FLASH") @@ -528,6 +538,7 @@ async def to_code(config: ConfigType) -> None: CORE.add_job(add_pin_initial_states_array) CORE.add_job(finalize_waveform_config) CORE.add_job(finalize_serial_config) + CORE.add_job(check_preferences_fit) @coroutine_with_priority(CoroPriority.WORKAROUNDS) diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index fb50b6a5ca2..177bf540473 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -1,9 +1,13 @@ +import logging + import esphome.codegen as cg # Re-exported from the shared definition; here it indexes the BOARDS # metadata dicts, whose entries in boards.py spell the literal. from esphome.const import KEY_FLASH_SIZE # noqa: F401 # pylint: disable=unused-import -from esphome.core import CORE +from esphome.core import CORE, CoroPriority, EsphomeError, coroutine_with_priority + +_LOGGER = logging.getLogger(__name__) KEY_ESP8266 = "esp8266" KEY_BOARD = "board" @@ -24,6 +28,87 @@ KEY_LDSCRIPT = "ldscript" # Crash-decode tools, resolved once per run by __init__ KEY_DECODE_TOOLS = "decode_tools" +# Saved-settings space in 4-byte words; preferences.cpp gets these as defines. A value cannot span the two +# RTC regions, and the flash store is smaller when only explicit flash preferences use it. +PREFERENCES_RTC_NORMAL_WORDS = 78 +PREFERENCES_RTC_EBOOT_WORDS = 32 +PREFERENCES_RTC_WORDS = PREFERENCES_RTC_NORMAL_WORDS + PREFERENCES_RTC_EBOOT_WORDS +PREFERENCES_FLASH_WORDS = 128 +PREFERENCES_FLASH_WORDS_WITHOUT_RESTORE = 64 +KEY_PREFERENCES = "preferences" + + +def preferences_flash_words(restore_from_flash: bool) -> int: + """Size of the flash store for this configuration.""" + return ( + PREFERENCES_FLASH_WORDS + if restore_from_flash + else PREFERENCES_FLASH_WORDS_WITHOUT_RESTORE + ) + + +def preference_words(value_bytes: int) -> int: + """Words a saved value takes, checksum word included (rtc_pref_bytes_to_words plus one).""" + return (value_bytes + 3) // 4 + 1 + + +def reserve_preferences( + count: int, value_bytes: int, in_flash: bool | None = None +) -> None: + """Record saved values a component creates; check_preferences_fit() adds them all up. + + in_flash is the value's own storage, as passed to make_preference(); None is the two-argument + form, which follows restore_from_flash. + """ + if in_flash is None: + in_flash = CORE.config[KEY_ESP8266][CONF_RESTORE_FROM_FLASH] + reservations = CORE.data.setdefault(KEY_ESP8266, {}).setdefault(KEY_PREFERENCES, []) + reservations.extend([(preference_words(value_bytes), in_flash)] * count) + + +def _rtc_values_can_split(values: list[int]) -> bool: + """Whether some order fills the two RTC regions without a value spanning them.""" + total = sum(values) + if total <= PREFERENCES_RTC_NORMAL_WORDS: + return True + # Bit i of reachable is set when some subset of the values sums to i + reachable = 1 + for words in values: + reachable |= reachable << words + lowest = max(0, total - PREFERENCES_RTC_EBOOT_WORDS) + highest = min(total, PREFERENCES_RTC_NORMAL_WORDS) + return any(reachable >> normal & 1 for normal in range(lowest, highest + 1)) + + +@coroutine_with_priority(CoroPriority.FINAL) +async def check_preferences_fit() -> None: + """Fail the build when the reserved saved values cannot fit the space preferences.cpp has.""" + flash_store = preferences_flash_words( + CORE.config[KEY_ESP8266][CONF_RESTORE_FROM_FLASH] + ) + reservations = CORE.data[KEY_ESP8266].get(KEY_PREFERENCES, []) + flash_words = sum(w for w, in_flash in reservations if in_flash) + rtc_values = [w for w, in_flash in reservations if not in_flash] + if flash_words > flash_store: + raise EsphomeError( + f"Saved values need {flash_words} words but the ESP8266 flash store has {flash_store}; " + "use fewer or smaller saved values" + ) + if not _rtc_values_can_split(rtc_values): + raise EsphomeError( + f"Saved values need {sum(rtc_values)} words but ESP8266 RTC memory has {PREFERENCES_RTC_WORDS} in " + f"regions of {PREFERENCES_RTC_NORMAL_WORDS} and {PREFERENCES_RTC_EBOOT_WORDS} that a value cannot " + "span; use fewer or smaller saved values, or store them in flash" + ) + if sum(rtc_values) > PREFERENCES_RTC_NORMAL_WORDS: + _LOGGER.warning( + "Saved values reserve %d of %d words of ESP8266 RTC memory; the fit depends on setup order, and " + "saved settings that do not reserve are not counted", + sum(rtc_values), + PREFERENCES_RTC_WORDS, + ) + + # esp8266 namespace is already defined by arduino, manually prefix esphome esp8266_ns = cg.global_ns.namespace("esphome").namespace("esp8266") diff --git a/esphome/components/esp8266/crash_handler.cpp b/esphome/components/esp8266/crash_handler.cpp index 1ba96eaebb2..8513d213155 100644 --- a/esphome/components/esp8266/crash_handler.cpp +++ b/esphome/components/esp8266/crash_handler.cpp @@ -4,6 +4,7 @@ #ifdef USE_ESP8266_CRASH_HANDLER #include "crash_handler.h" +#include "rtc_layout.h" #include "esphome/core/log.h" #include @@ -46,11 +47,8 @@ static inline bool IRAM_ATTR is_code_addr(uint32_t val) { // Recover the actual code address from a windowed-ABI return address on the stack. static inline uint32_t IRAM_ATTR recover_code_addr(uint32_t val) { return (val & XTENSA_ADDR_MASK) | XTENSA_CODE_BASE; } -// RTC user memory layout for crash backtrace data. -// User-accessible RTC memory: blocks 64-191 (each block = 4 bytes). -// We use blocks 174-191 (last 18 blocks, 72 bytes) to minimize conflicts. +// Crash data lives in the last esphome::esp8266::RTC_CRASH_WORDS of user RTC memory (rtc_layout.h). // Store 16 raw candidates, filter to real return addresses at log time. -static constexpr uint8_t RTC_CRASH_BASE = 174; static constexpr size_t MAX_BACKTRACE = 16; // Magic word packs sentinel, version, and count into one uint32_t: @@ -78,7 +76,7 @@ struct RtcCrashData { uint32_t backtrace[MAX_BACKTRACE]; uint32_t epc1; // Fault PC, used to filter duplicates }; -static_assert(sizeof(RtcCrashData) == 72, "RtcCrashData must fit in 18 RTC blocks"); +static_assert(sizeof(RtcCrashData) == esphome::esp8266::RTC_CRASH_WORDS * 4, "RtcCrashData must fill its RTC words"); namespace esphome::esp8266 { @@ -129,7 +127,7 @@ static const LogString *get_reset_reason(uint32_t reason) { // Returns the number of valid backtrace entries (0 if no data found). static uint8_t read_rtc_backtrace(uint32_t *backtrace, size_t max_entries) { RtcCrashData rtc_data; - if (!system_rtc_mem_read(RTC_CRASH_BASE, &rtc_data, sizeof(rtc_data))) + if (!system_rtc_mem_read(esphome::esp8266::RTC_CRASH_BASE, &rtc_data, sizeof(rtc_data))) return 0; uint32_t magic = rtc_data.magic; if ((magic & CRASH_SENTINEL_MASK) != CRASH_SENTINEL || (magic & CRASH_VERSION_MASK) != CRASH_VERSION) @@ -233,7 +231,7 @@ extern "C" void IRAM_ATTR custom_crash_callback(struct rst_info *rst_info, uint3 data.epc1 = epc1; data.magic = CRASH_SENTINEL | CRASH_VERSION | count; - system_rtc_mem_write(RTC_CRASH_BASE, &data, sizeof(data)); + system_rtc_mem_write(esphome::esp8266::RTC_CRASH_BASE, &data, sizeof(data)); } #endif // USE_ESP8266_CRASH_HANDLER diff --git a/esphome/components/esp8266/preferences.cpp b/esphome/components/esp8266/preferences.cpp index b731187f9c3..3c1e89673ea 100644 --- a/esphome/components/esp8266/preferences.cpp +++ b/esphome/components/esp8266/preferences.cpp @@ -6,6 +6,7 @@ extern "C" { } #include "preferences.h" +#include "rtc_layout.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/preferences_rtc.h" @@ -17,31 +18,26 @@ namespace esphome::esp8266 { ESPHOME_LOG_TAG(TAG, "preferences"); static constexpr uint32_t ESP_RTC_USER_MEM_START = 0x60001200; -static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_WORDS = 128; +static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_WORDS = RTC_USER_MEM_WORDS; static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_BYTES = ESP_RTC_USER_MEM_SIZE_WORDS * 4; -// RTC memory layout: +// RTC memory layout (sizes come from const.py through codegen): // - Eboot region: RTC words 0-31 (reserved, mapped from preference offset 78-109) // - Normal region: RTC words 32-109 (mapped from preference offset 0-77) // - Crash handler: RTC words 110-127 (reserved for crash_handler.cpp backtrace data) -static constexpr uint32_t RTC_EBOOT_REGION_WORDS = 32; // Words 0-31 reserved for eboot -static constexpr uint32_t RTC_NORMAL_REGION_WORDS = 78; // Words 32-109 for normal prefs +static constexpr uint32_t RTC_EBOOT_REGION_WORDS = ESP8266_PREFERENCES_RTC_EBOOT_WORDS; +static constexpr uint32_t RTC_NORMAL_REGION_WORDS = ESP8266_PREFERENCES_RTC_NORMAL_WORDS; static constexpr uint32_t PREF_TOTAL_WORDS = RTC_EBOOT_REGION_WORDS + RTC_NORMAL_REGION_WORDS; // 110 +static_assert(PREF_TOTAL_WORDS <= RTC_CRASH_BASE - RTC_SYSTEM_USER_BASE, + "preference regions overlap the crash handler's RTC words"); // Maximum preference size in words (limited by uint8_t length_words field) static constexpr uint32_t MAX_PREFERENCE_WORDS = 255; #define ESP_RTC_USER_MEM ((uint32_t *) ESP_RTC_USER_MEM_START) -// Flash storage size depends on esp8266 -> restore_from_flash YAML option (default: false). -// When enabled (USE_ESP8266_PREFERENCES_FLASH), all preferences default to flash and need -// 128 words (512 bytes). When disabled, only explicit flash prefs use this storage so -// 64 words (256 bytes) suffices since most preferences go to RTC memory instead. -#ifdef USE_ESP8266_PREFERENCES_FLASH -static constexpr uint32_t ESP8266_FLASH_STORAGE_SIZE = 128; -#else -static constexpr uint32_t ESP8266_FLASH_STORAGE_SIZE = 64; -#endif +// Smaller without restore_from_flash, when only explicit flash preferences live here +static constexpr uint32_t ESP8266_FLASH_STORAGE_SIZE = ESP8266_PREFERENCES_FLASH_WORDS; static uint32_t s_flash_storage[ESP8266_FLASH_STORAGE_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -60,7 +56,7 @@ static inline bool esp_rtc_user_mem_write(uint32_t index, uint32_t value) { if (index >= ESP_RTC_USER_MEM_SIZE_WORDS) { return false; } - if (index < 32 && s_prevent_write) { + if (index < RTC_EBOOT_REGION_WORDS && s_prevent_write) { return false; } @@ -123,7 +119,7 @@ static bool load_from_rtc(size_t offset, uint32_t *data, size_t len) { // Maximum buffer for any single preference - bounded by storage sizes. // Flash prefs: bounded by ESP8266_FLASH_STORAGE_SIZE (128 or 64 words). -// RTC prefs: bounded by RTC_NORMAL_REGION_WORDS (96) - a single pref can't span both RTC regions. +// RTC prefs: bounded by RTC_NORMAL_REGION_WORDS (78) - a single pref can't span both RTC regions. static constexpr size_t PREF_MAX_BUFFER_WORDS = ESP8266_FLASH_STORAGE_SIZE > RTC_NORMAL_REGION_WORDS ? ESP8266_FLASH_STORAGE_SIZE : RTC_NORMAL_REGION_WORDS; @@ -180,8 +176,8 @@ ESPPreferenceObject ESP8266Preferences::make_preference(size_t length, uint32_t } else { uint32_t start = this->current_offset; bool in_normal = start < RTC_NORMAL_REGION_WORDS; - // Normal: offset 0-95 maps to RTC offset 32-127 - // Eboot: offset 96-127 maps to RTC offset 0-31 + // Normal: offset 0-77 maps to RTC offset 32-109 + // Eboot: offset 78-109 maps to RTC offset 0-31 if (in_normal && start + total_words > RTC_NORMAL_REGION_WORDS) { // start is in normal but end is not -> switch to Eboot this->current_offset = start = RTC_NORMAL_REGION_WORDS; diff --git a/esphome/components/esp8266/rtc_layout.h b/esphome/components/esp8266/rtc_layout.h new file mode 100644 index 00000000000..0320d67a198 --- /dev/null +++ b/esphome/components/esp8266/rtc_layout.h @@ -0,0 +1,18 @@ +#pragma once + +#ifdef USE_ESP8266 + +#include + +namespace esphome::esp8266 { + +// User RTC memory is system blocks 64-191, one 4-byte word each. preferences.cpp owns the first +// words, crash_handler.cpp the last; const.py hands preferences.cpp its region sizes. +static constexpr uint32_t RTC_SYSTEM_USER_BASE = 64; +static constexpr uint32_t RTC_USER_MEM_WORDS = 128; +static constexpr uint32_t RTC_CRASH_WORDS = 18; +static constexpr uint32_t RTC_CRASH_BASE = RTC_SYSTEM_USER_BASE + RTC_USER_MEM_WORDS - RTC_CRASH_WORDS; + +} // namespace esphome::esp8266 + +#endif // USE_ESP8266 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 3aa4c8e700b..50d8285cc1c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -607,6 +607,9 @@ #define USE_ESP8266_LOGGER_SERIAL #define USE_ESP8266_LOGGER_SERIAL1 #define USE_ESP8266_PREFERENCES_FLASH +#define ESP8266_PREFERENCES_FLASH_WORDS 128 +#define ESP8266_PREFERENCES_RTC_EBOOT_WORDS 32 +#define ESP8266_PREFERENCES_RTC_NORMAL_WORDS 78 #define USE_ESP8266_UART_SERIAL #define USE_ESP8266_UART_SERIAL1 #define USE_HTTP_REQUEST_ESP8266_HTTPS diff --git a/esphome/preferences.py b/esphome/preferences.py index 6e7e4eda33d..c9a465d6252 100644 --- a/esphome/preferences.py +++ b/esphome/preferences.py @@ -102,3 +102,17 @@ def is_in_flash(value: str) -> bool: if not in_flash: request_rtc_storage() return in_flash + + +def reserve_preferences( + count: int, value_bytes: int, in_flash: bool | None = None +) -> None: + """Reserve saved values from to_code so a platform with a small store can check they all fit. + + in_flash is the value's own storage (is_in_flash(config[CONF_STORAGE])); leave it None for a + component that calls the two-argument make_preference() and takes the platform default. + """ + if CORE.is_esp8266: + from esphome.components.esp8266.const import reserve_preferences as reserve + + reserve(count, value_bytes, in_flash) diff --git a/tests/unit_tests/components/esp8266/test_preferences.py b/tests/unit_tests/components/esp8266/test_preferences.py new file mode 100644 index 00000000000..2111e068245 --- /dev/null +++ b/tests/unit_tests/components/esp8266/test_preferences.py @@ -0,0 +1,107 @@ +"""The config-time fit check must agree with make_preference() in preferences.cpp.""" + +import asyncio + +import pytest + +from esphome import preferences +from esphome.components.esp8266.const import ( + KEY_ESP8266, + KEY_PREFERENCES, + check_preferences_fit, + preference_words, + reserve_preferences, +) +from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM +from esphome.core import CORE, EsphomeError + + +@pytest.fixture(autouse=True) +def esp8266_core() -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp8266"} + CORE.data[KEY_ESP8266] = {} + CORE.config = {KEY_ESP8266: {"restore_from_flash": False}} + + +def _check() -> None: + asyncio.run(check_preferences_fit()) + + +@pytest.mark.parametrize( + ("value_bytes", "words"), [(0, 1), (1, 2), (4, 2), (5, 3), (94, 25)] +) +def test_preference_words_rounds_up_and_adds_the_checksum( + value_bytes: int, words: int +) -> None: + assert preference_words(value_bytes) == words + + +@pytest.mark.parametrize( + ("restore_from_flash", "in_flash", "fitting", "match"), + [ + # 25 words each: 3 in the 78-word region and 1 in the 32-word one + (False, False, 4, "RTC memory has 110"), + (False, None, 4, "RTC memory has 110"), + # an explicit RTC value stays in RTC whatever restore_from_flash says + (True, False, 4, "RTC memory has 110"), + # 125 of 128 flash words, by the two-argument default or explicitly + (True, None, 5, "flash store has 128"), + (True, True, 5, "flash store has 128"), + # explicit flash values without restore_from_flash use the 64-word store + (False, True, 2, "flash store has 64"), + ], +) +def test_reservations_add_up_against_the_store_they_land_in( + restore_from_flash: bool, in_flash: bool | None, fitting: int, match: str +) -> None: + CORE.config[KEY_ESP8266]["restore_from_flash"] = restore_from_flash + reserve_preferences(fitting - 1, 94, in_flash) # one component + reserve_preferences(1, 94, in_flash) # another component + _check() + reserve_preferences(1, 94, in_flash) + with pytest.raises(EsphomeError, match=match): + _check() + + +def test_values_that_cannot_split_between_the_regions_fail() -> None: + # 5 x 20 words is 100 of 110, but the normal region takes 3 and the eboot region 1 + reserve_preferences(5, 76) + with pytest.raises(EsphomeError, match="cannot span"): + _check() + + +def test_a_value_larger_than_either_region_fails() -> None: + reserve_preferences(1, 400) + with pytest.raises(EsphomeError, match="cannot span"): + _check() + + +def test_a_split_that_works_in_some_order_passes_with_a_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + # 78 + 32: one value fills each region exactly + reserve_preferences(1, 308) + reserve_preferences(1, 124) + _check() + assert "depends on setup order" in caplog.text + + +def test_nothing_reserved_passes_quietly(caplog: pytest.LogCaptureFixture) -> None: + _check() + assert caplog.text == "" + + +def test_the_check_runs_as_a_scheduled_job() -> None: + reserve_preferences(3, 400) + CORE.add_job(check_preferences_fit) + with pytest.raises(EsphomeError, match="cannot span"): + CORE.flush_tasks() + + +def test_components_reserve_through_esphome_preferences() -> None: + preferences.reserve_preferences(2, 94, in_flash=True) + assert CORE.data[KEY_ESP8266][KEY_PREFERENCES] == [(25, True), (25, True)] + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + CORE.data[KEY_ESP8266] = {} + preferences.reserve_preferences(2, 94) + assert KEY_PREFERENCES not in CORE.data[KEY_ESP8266] From 163b09a473def4aea19488a7363564bf32a812c4 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 14 Oct 2026 14:47:25 -0400 Subject: [PATCH 107/142] [microphone] Speed up MicrophoneSource audio conversion (#20755) --- .../microphone/microphone_source.cpp | 73 ++++++++----------- 1 file changed, 30 insertions(+), 43 deletions(-) diff --git a/esphome/components/microphone/microphone_source.cpp b/esphome/components/microphone/microphone_source.cpp index 288e9f8c8e5..8ef091d3a40 100644 --- a/esphome/components/microphone/microphone_source.cpp +++ b/esphome/components/microphone/microphone_source.cpp @@ -1,9 +1,20 @@ #include "microphone_source.h" +#include + +#include // esp-audio-libs +#include // esp-audio-libs + namespace esphome::microphone { -static const int32_t Q25_MAX_VALUE = (1 << 25) - 1; -static const int32_t Q25_MIN_VALUE = ~Q25_MAX_VALUE; +// Builds the exact LinearGain for an integer factor without the float dB conversion: the factor is +// mantissa / 2^31 * 2^exponent, with the exponent set to the factor's bit width. +static esp_audio_libs::gain::LinearGain factor_to_linear_gain(uint32_t factor) { + esp_audio_libs::gain::LinearGain gain; + gain.exponent = 32 - __builtin_clz(factor); + gain.mantissa_q31 = static_cast(factor << (31 - gain.exponent)); + return gain; +} audio::AudioStreamInfo MicrophoneSource::get_audio_stream_info() { return audio::AudioStreamInfo(this->bits_per_sample_, this->channels_.count(), @@ -26,50 +37,26 @@ void MicrophoneSource::stop() { } void MicrophoneSource::process_audio_(const std::vector &data, std::vector &filtered_data) { - // - Bit depth conversions are obtained by truncating bits or padding with zeros - no dithering is applied. - // - In the comments, Qxx refers to a fixed point number with xx bits of precision for representing fractional values. - // For example, audio with a bit depth of 16 can store a sample in a int16, which can be considered a Q15 number. - // - All samples are converted to Q25 before applying the gain factor - this results in a small precision loss for - // data with 32 bits per sample. Since the maximum gain factor is 64 = (1<<6), this ensures that applying the gain - // will never overflow a 32 bit signed integer. This still retains more bit depth than what is audibly noticeable. - // - Loops for reading/writing data buffers are unrolled, assuming little endian, for a small performance increase. + const audio::AudioStreamInfo source_info = this->mic_->get_audio_stream_info(); + const uint32_t total_frames = source_info.bytes_to_frames(data.size()); + const uint8_t target_bytes_per_sample = (this->bits_per_sample_ + 7) / 8; - const size_t source_bytes_per_sample = this->mic_->get_audio_stream_info().samples_to_bytes(1); - const uint32_t source_channels = this->mic_->get_audio_stream_info().get_channels(); - - const size_t source_bytes_per_frame = this->mic_->get_audio_stream_info().frames_to_bytes(1); - - const uint32_t total_frames = this->mic_->get_audio_stream_info().bytes_to_frames(data.size()); - const size_t target_bytes_per_sample = (this->bits_per_sample_ + 7) / 8; - const size_t target_bytes_per_frame = target_bytes_per_sample * this->channels_.count(); - - filtered_data.resize(target_bytes_per_frame * total_frames); - - uint8_t *current_data = filtered_data.data(); - - for (uint32_t frame_index = 0; frame_index < total_frames; ++frame_index) { - for (uint32_t channel_index = 0; channel_index < source_channels; ++channel_index) { - if (this->channels_.test(channel_index)) { - // Channel's current sample is included in the target mask. Convert bits per sample, if necessary. - - const uint32_t sample_index = frame_index * source_bytes_per_frame + channel_index * source_bytes_per_sample; - - int32_t sample = audio::unpack_audio_sample_to_q31(&data[sample_index], source_bytes_per_sample); // Q31 - sample >>= 6; // Q31 -> Q25 - - // Apply gain using multiplication - sample *= this->gain_factor_; // Q25 - - // Clamp ``sample`` in case gain multiplication overflows 25 bits - sample = clamp(sample, Q25_MIN_VALUE, Q25_MAX_VALUE); // Q25 - - sample *= (1 << 6); // Q25 -> Q31 - - audio::pack_q31_as_audio_sample(sample, current_data, target_bytes_per_sample); - current_data = current_data + target_bytes_per_sample; - } + // Enabled channels in ascending order, which is their order in each output frame + std::array channel_map; + uint8_t target_channels = 0; + for (size_t channel = 0; channel < this->channels_.size(); ++channel) { + if (this->channels_.test(channel)) { + channel_map[target_channels++] = static_cast(channel); } } + + filtered_data.resize(static_cast(total_frames) * target_bytes_per_sample * target_channels); + + // Applies the gain at full precision before narrowing; samples saturate at full scale, and narrowing rounds + esp_audio_libs::pcm_convert::copy_frames_with_gain(data.data(), filtered_data.data(), source_info.samples_to_bytes(1), + source_info.get_channels(), target_bytes_per_sample, + channel_map.data(), target_channels, + factor_to_linear_gain(this->gain_factor_), total_frames); } } // namespace esphome::microphone From 3fa72e61c60b55a3ba2570edf2dd084baa9aa58f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 08:55:39 -1000 Subject: [PATCH 108/142] [mqtt] Build the mqtt triggers with build_trigger_callback (#20433) --- esphome/components/mqtt/__init__.py | 85 ++++++++-------- esphome/components/mqtt/mqtt_client.cpp | 96 ++++++++----------- esphome/components/mqtt/mqtt_client.h | 90 +++++++++-------- esphome/components/mqtt/mqtt_component.cpp | 8 -- esphome/components/mqtt/mqtt_component.h | 8 +- esphome/core/helpers.h | 35 ++++++- tests/components/core/test_callback.cpp | 92 ++++++++++++++++++ tests/components/mqtt/common-triggers.yaml | 25 +++++ .../mqtt/test-triggers.esp32-idf.yaml | 3 + 9 files changed, 284 insertions(+), 158 deletions(-) create mode 100644 tests/components/core/test_callback.cpp create mode 100644 tests/components/mqtt/common-triggers.yaml create mode 100644 tests/components/mqtt/test-triggers.esp32-idf.yaml diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 826d4e32ef2..fe426827ce6 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -53,7 +53,6 @@ from esphome.const import ( CONF_SUBSCRIBE_QOS, CONF_TOPIC, CONF_TOPIC_PREFIX, - CONF_TRIGGER_ID, CONF_USE_ABBREVIATIONS, CONF_USERNAME, CONF_WILL_MESSAGE, @@ -119,18 +118,6 @@ MQTTMessage = mqtt_ns.struct("MQTTMessage") MQTTClientDisconnectReason = mqtt_ns.enum("MQTTClientDisconnectReason") MQTTClientComponent = mqtt_ns.class_("MQTTClientComponent", cg.Component) MQTTPublishJsonAction = mqtt_ns.class_("MQTTPublishJsonAction", automation.Action) -MQTTMessageTrigger = mqtt_ns.class_( - "MQTTMessageTrigger", automation.Trigger.template(cg.std_string), cg.Component -) -MQTTJsonMessageTrigger = mqtt_ns.class_( - "MQTTJsonMessageTrigger", automation.Trigger.template(cg.JsonObjectConst) -) -MQTTConnectTrigger = mqtt_ns.class_( - "MQTTConnectTrigger", automation.Trigger.template(cg.bool_) -) -MQTTDisconnectTrigger = mqtt_ns.class_( - "MQTTDisconnectTrigger", automation.Trigger.template(MQTTClientDisconnectReason) -) MQTTComponent = mqtt_ns.class_("MQTTComponent", cg.Component) MQTTAlarmControlPanelComponent = mqtt_ns.class_( @@ -229,7 +216,6 @@ JSON_MESSAGE_AUTOMATION_SCHEMA = automation.validate_synchronous_automation( "on_json_message", "the parsed JSON document", { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(MQTTJsonMessageTrigger), cv.Required(CONF_TOPIC): cv.subscribe_topic, cv.Optional(CONF_QOS, default=0): cv.mqtt_qos, }, @@ -295,21 +281,10 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_REBOOT_TIMEOUT, default="15min" ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_ON_CONNECT): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(MQTTConnectTrigger), - } - ), - cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - MQTTDisconnectTrigger - ), - } - ), + cv.Optional(CONF_ON_CONNECT): automation.validate_automation(), + cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(), cv.Optional(CONF_ON_MESSAGE): automation.validate_automation( { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(MQTTMessageTrigger), cv.Required(CONF_TOPIC): cv.subscribe_topic, cv.Optional(CONF_QOS, default=0): cv.mqtt_qos, cv.Optional(CONF_PAYLOAD): cv.string_strict, @@ -346,6 +321,18 @@ def exp_mqtt_message(config): ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_CONNECT, "set_on_connect", [(cg.bool_, "session_present")] + ), + automation.CallbackAutomation( + CONF_ON_DISCONNECT, + "set_on_disconnect", + [(MQTTClientDisconnectReason, "reason")], + ), +) + + @coroutine_with_priority(CoroPriority.WEB) async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) @@ -460,29 +447,37 @@ async def to_code(config): cg.add_define("USE_MQTT_IDF_ENQUEUE") # end esp-idf + # The client queues subscriptions until it connects, so they can be made at construction. for conf in config.get(CONF_ON_MESSAGE, []): - trig = cg.new_Pvariable(conf[CONF_TRIGGER_ID], conf[CONF_TOPIC]) - cg.add(trig.set_qos(conf[CONF_QOS])) - if CONF_PAYLOAD in conf: - cg.add(trig.set_payload(conf[CONF_PAYLOAD])) - await cg.register_component(trig, conf) - await automation.build_automation(trig, [(cg.std_string, "x")], conf) + callback = await automation.build_trigger_callback( + [(cg.std_string, "x")], + conf, + params=[(cg.std_string, "topic"), (cg.std_string, "payload")], + forward=["payload"], + # The length is compared first; the literal stays in flash on ESP8266. + when=automation.ApplyCall( + "StringRef(payload) == {}", + ((CONF_PAYLOAD, cg.std_string, automation.string_ref_literal),), + ), + ) + cg.add( + var.subscribe(cg.progmem_string(conf[CONF_TOPIC]), callback, conf[CONF_QOS]) + ) for conf in config.get(CONF_ON_JSON_MESSAGE, []): - trig = cg.new_Pvariable(conf[CONF_TRIGGER_ID], conf[CONF_TOPIC], conf[CONF_QOS]) - await automation.build_automation(trig, [(cg.JsonObjectConst, "x")], conf) - - for conf in config.get(CONF_ON_CONNECT, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(cg.bool_, "session_present")], conf + callback = await automation.build_trigger_callback( + [(cg.JsonObjectConst, "x")], + conf, + params=[(cg.std_string, "topic"), (cg.JsonObject, "root")], + forward=["root"], + ) + cg.add( + var.subscribe_json( + cg.progmem_string(conf[CONF_TOPIC]), callback, conf[CONF_QOS] + ) ) - for conf in config.get(CONF_ON_DISCONNECT, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(MQTTClientDisconnectReason, "reason")], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) cg.add(var.set_publish_nan_as_none(config[CONF_PUBLISH_NAN_AS_NONE])) diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 6e5fad3efc7..803f486333a 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -461,34 +461,18 @@ void MQTTClientComponent::resubscribe_subscriptions_() { } } -void MQTTClientComponent::subscribe(const std::string &topic, mqtt_callback_t callback, uint8_t qos) { - MQTTSubscription subscription{ - .topic = topic, +void MQTTClientComponent::add_subscription_(std::string &&topic, mqtt_subscription_callback_t callback, bool boxed, + uint8_t qos) { + auto &subscription = this->subscriptions_.emplace_back(MQTTSubscription{ + .topic = std::move(topic), .qos = qos, - .callback = std::move(callback), .subscribed = false, + .boxed = boxed, + .removed = false, + .callback = callback, .resubscribe_timeout = 0, - }; + }); this->resubscribe_subscription_(&subscription); - this->subscriptions_.push_back(subscription); -} - -void MQTTClientComponent::subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos) { - auto f = [callback](const std::string &topic, const std::string &payload) { - json::parse_json(payload, [topic, callback](JsonObject root) -> bool { - callback(topic, root); - return true; - }); - }; - MQTTSubscription subscription{ - .topic = topic, - .qos = qos, - .callback = f, - .subscribed = false, - .resubscribe_timeout = 0, - }; - this->resubscribe_subscription_(&subscription); - this->subscriptions_.push_back(subscription); } void MQTTClientComponent::unsubscribe(const std::string &topic) { @@ -502,10 +486,25 @@ void MQTTClientComponent::unsubscribe(const std::string &topic) { this->status_momentary_warning(1000); } - auto it = subscriptions_.begin(); - while (it != subscriptions_.end()) { - if (it->topic == topic) { - it = subscriptions_.erase(it); + for (auto &subscription : this->subscriptions_) { + if (subscription.topic == topic) + subscription.removed = true; + } + // A callback may be running; the outermost dispatch erases them once it returns. + if (this->dispatching_) { + this->removed_while_dispatching_ = true; + } else { + this->erase_removed_subscriptions_(); + } +} + +void MQTTClientComponent::erase_removed_subscriptions_() { + auto it = this->subscriptions_.begin(); + while (it != this->subscriptions_.end()) { + if (it->removed) { + if (it->boxed) + it->callback.free_boxed(); + it = this->subscriptions_.erase(it); } else { ++it; } @@ -660,9 +659,21 @@ void MQTTClientComponent::on_message(const std::string &topic, const std::string // in simple tests but will cause crashes with complex automations. this->defer([this, topic, payload]() { #endif - for (auto &subscription : this->subscriptions_) { - if (topic_match(topic.c_str(), subscription.topic.c_str())) - subscription.callback(topic, payload); + // Indexed with the count taken up front: a callback may subscribe, which can reallocate the + // vector, and the new entry waits for the next message. + // Restored rather than cleared, so only the outermost dispatch erases what was unsubscribed meanwhile. + const bool was_dispatching = this->dispatching_; + this->dispatching_ = true; + const size_t count = this->subscriptions_.size(); + for (size_t i = 0; i < count; i++) { + const auto &subscription = this->subscriptions_[i]; + if (!subscription.removed && topic_match(topic.c_str(), subscription.topic.c_str())) + subscription.callback.call(topic, payload); + } + this->dispatching_ = was_dispatching; + if (!was_dispatching && this->removed_while_dispatching_) { + this->removed_while_dispatching_ = false; + this->erase_removed_subscriptions_(); } #ifdef USE_ESP8266 }); @@ -762,29 +773,6 @@ void MQTTClientComponent::set_on_disconnect(mqtt_on_disconnect_callback_t &&call MQTTClientComponent *global_mqtt_client = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -// MQTTMessageTrigger -MQTTMessageTrigger::MQTTMessageTrigger(std::string topic) : topic_(std::move(topic)) {} -void MQTTMessageTrigger::setup() { - global_mqtt_client->subscribe( - this->topic_, - [this](const std::string &topic, const std::string &payload) { - if (this->payload_.has_value() && payload != *this->payload_) { - return; - } - - this->trigger(payload); - }, - this->qos_); -} -void MQTTMessageTrigger::dump_config() { - ESP_LOGCONFIG(TAG, - "MQTT Message Trigger:\n" - " Topic: '%s'\n" - " QoS: %u", - this->topic_.c_str(), this->qos_); -} -float MQTTMessageTrigger::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } - } // namespace esphome::mqtt #endif // USE_MQTT diff --git a/esphome/components/mqtt/mqtt_client.h b/esphome/components/mqtt/mqtt_client.h index ced9c84e100..30edb812975 100644 --- a/esphome/components/mqtt/mqtt_client.h +++ b/esphome/components/mqtt/mqtt_client.h @@ -31,19 +31,24 @@ namespace esphome::mqtt { using mqtt_on_connect_callback_t = std::function; using mqtt_on_disconnect_callback_t = std::function; -/** Callback for MQTT subscriptions. - * - * First parameter is the topic, the second one is the payload. - */ -using mqtt_callback_t = std::function; -using mqtt_json_callback_t = std::function; +/// Stored callback of an MQTT subscription: the topic, then the payload. +using mqtt_subscription_callback_t = Callback; + +// Remove before 2027.5.0 +using mqtt_callback_t ESPDEPRECATED("subscribe() takes any callable; pass the lambda directly. Removed in 2027.5.0", + "2026.11.0") = std::function; +using mqtt_json_callback_t ESPDEPRECATED( + "subscribe_json() takes any callable; pass the lambda directly. Removed in 2027.5.0", + "2026.11.0") = std::function; /// internal struct for MQTT subscriptions. struct MQTTSubscription { std::string topic; uint8_t qos; - mqtt_callback_t callback; bool subscribed; + bool boxed; // made by create_boxed(), freed on unsubscribe + bool removed; // unsubscribed while a callback ran, erased once dispatch returns + mqtt_subscription_callback_t callback; uint32_t resubscribe_timeout; }; @@ -165,12 +170,21 @@ class MQTTClientComponent final : public Component { bool is_log_message_enabled() const; /** Subscribe to an MQTT topic and call callback when a message is received. + * + * A callback that fits in a pointer (capture at most `this`) is stored inline; a larger one is + * boxed on the heap and freed by unsubscribe(). * * @param topic The topic. Wildcards are currently not supported. * @param callback The callback function. * @param qos The QoS of this subscription. */ - void subscribe(const std::string &topic, mqtt_callback_t callback, uint8_t qos = 0); + template void subscribe(std::string topic, F &&callback, uint8_t qos = 0) { + constexpr bool boxed = !mqtt_subscription_callback_t::fits_inline(); + this->add_subscription_(std::move(topic), + boxed ? mqtt_subscription_callback_t::create_boxed(std::forward(callback)) + : mqtt_subscription_callback_t::create(std::forward(callback)), + boxed, qos); + } /** Subscribe to a MQTT topic and automatically parse JSON payload. * @@ -181,11 +195,21 @@ class MQTTClientComponent final : public Component { * received. * @param qos The QoS of this subscription. */ - void subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos = 0); + template void subscribe_json(std::string topic, F &&callback, uint8_t qos = 0) { + static_assert(std::is_invocable_v &, const std::string &, JsonObject>, + "subscribe_json() needs a callback that does not mutate its captures"); + this->subscribe( + std::move(topic), + [cb = std::forward(callback)](const std::string &topic, const std::string &payload) { + call_json(cb, topic, payload); + }, + qos); + } /** Unsubscribe from an MQTT topic. * * If multiple existing subscriptions to the same topic exist, all of them will be removed. + * From inside a subscription callback they stop receiving messages at once and are erased once it returns. * * @param topic The topic to unsubscribe from. * Must match the topic in the original subscribe or subscribe_json call exactly. @@ -285,6 +309,14 @@ class MQTTClientComponent final : public Component { bool subscribe_(const char *topic, uint8_t qos); void resubscribe_subscription_(MQTTSubscription *sub); + template static void call_json(const F &callback, const std::string &topic, const std::string &payload) { + JsonDocument doc = json::parse_json(payload); + if (doc.overflowed() || doc.isNull()) + return; + callback(topic, doc.as()); + } + void erase_removed_subscriptions_(); + void add_subscription_(std::string &&topic, mqtt_subscription_callback_t callback, bool boxed, uint8_t qos); void resubscribe_subscriptions_(); MQTTCredentials credentials_; @@ -336,48 +368,12 @@ class MQTTClientComponent final : public Component { bool publish_nan_as_none_{false}; bool wait_for_connection_{false}; + bool dispatching_{false}; + bool removed_while_dispatching_{false}; }; extern MQTTClientComponent *global_mqtt_client; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -class MQTTMessageTrigger final : public Trigger, public Component { - public: - explicit MQTTMessageTrigger(std::string topic); - - void set_qos(uint8_t qos) { this->qos_ = qos; } - void set_payload(const std::string &payload) { this->payload_ = payload; } - void setup() override; - void dump_config() override; - float get_setup_priority() const override; - - protected: - std::string topic_; - uint8_t qos_{0}; - optional payload_; -}; - -class MQTTJsonMessageTrigger final : public Trigger { - public: - explicit MQTTJsonMessageTrigger(const std::string &topic, uint8_t qos) { - global_mqtt_client->subscribe_json( - topic, [this](const std::string &topic, JsonObject root) { this->trigger(root); }, qos); - } -}; - -class MQTTConnectTrigger final : public Trigger { - public: - explicit MQTTConnectTrigger(MQTTClientComponent *client) { - client->set_on_connect([this](bool session_present) { this->trigger(session_present); }); - } -}; - -class MQTTDisconnectTrigger final : public Trigger { - public: - explicit MQTTDisconnectTrigger(MQTTClientComponent *client) { - client->set_on_disconnect([this](MQTTClientDisconnectReason reason) { this->trigger(reason); }); - } -}; - template class MQTTPublishJsonAction final : public Action { public: MQTTPublishJsonAction(MQTTClientComponent *parent) : parent_(parent) {} diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index adb5a958287..7e4b8be725d 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -342,14 +342,6 @@ bool MQTTComponent::is_discovery_enabled() const { return this->discovery_enabled_ && global_mqtt_client->is_discovery_enabled(); } -void MQTTComponent::subscribe(const std::string &topic, mqtt_callback_t callback, uint8_t qos) { - global_mqtt_client->subscribe(topic, std::move(callback), qos); -} - -void MQTTComponent::subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos) { - global_mqtt_client->subscribe_json(topic, callback, qos); -} - MQTTComponent::MQTTComponent() = default; float MQTTComponent::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } diff --git a/esphome/components/mqtt/mqtt_component.h b/esphome/components/mqtt/mqtt_component.h index 141b5f12b38..461db5d3e76 100644 --- a/esphome/components/mqtt/mqtt_component.h +++ b/esphome/components/mqtt/mqtt_component.h @@ -259,7 +259,9 @@ class MQTTComponent : public Component { * @param callback The callback that will be called when a message with matching topic is received. * @param qos The MQTT quality of service. Defaults to 0. */ - void subscribe(const std::string &topic, mqtt_callback_t callback, uint8_t qos = 0); + template void subscribe(std::string topic, F &&callback, uint8_t qos = 0) { + global_mqtt_client->subscribe(std::move(topic), std::forward(callback), qos); + } /** Subscribe to a MQTT topic and automatically parse JSON payload. * @@ -270,7 +272,9 @@ class MQTTComponent : public Component { * received. * @param qos The MQTT quality of service. Defaults to 0. */ - void subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos = 0); + template void subscribe_json(std::string topic, F &&callback, uint8_t qos = 0) { + global_mqtt_client->subscribe_json(std::move(topic), std::forward(callback), qos); + } protected: static constexpr uint32_t SEND_DEFER_ID = 0; diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 15f247cadbc..e628b02ac94 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1725,7 +1725,8 @@ enum class TemperatureUnit : uint8_t { /// No null check, no exceptions, no heap allocation for small trivially-copyable callables. /// /// With C++20 if constexpr, automatically detects [this] lambdas (sizeof <= sizeof(void*), -/// trivially copyable) and stores them inline. Larger callables are heap-allocated. +/// trivially copyable, callable as const) and stores them inline. Larger callables, and mutable +/// ones whose state must survive between calls, are heap-allocated. template struct Callback; template struct Callback { @@ -1740,11 +1741,19 @@ template struct Callback { /// Invoke the callback. Only valid on Callbacks created via create(), never on default-constructed instances. void call(Ts... args) const { this->fn_(this->ctx_, std::forward(args)...); } + /// Whether create() stores F inline in the ctx pointer. The inline path invokes a copy, so a + /// callable that mutates its captures (a mutable lambda) is kept whole on the heap instead. + template static constexpr bool fits_inline() { + using DecayF = std::decay_t; + return sizeof(DecayF) <= sizeof(void *) && std::is_trivially_copyable_v && + std::is_invocable_v; + } + /// Create from any callable. Small trivially-copyable callables (like [this] lambdas) /// are stored inline in the ctx pointer without heap allocation. template static Callback create(F &&callable) { using DecayF = std::decay_t; - if constexpr (sizeof(DecayF) <= sizeof(void *) && std::is_trivially_copyable_v) { + if constexpr (fits_inline()) { // Small trivial callable (e.g. [this]() { this->method(); }) - store inline in ctx. // Safe under C++20 (P0593R6): byte copy into aligned storage implicitly // creates objects of implicit-lifetime types (trivially copyable qualifies). @@ -1767,6 +1776,28 @@ template struct Callback { return {[](void *c, Ts... args) { (*static_cast(c))(args...); }, static_cast(stored)}; } } + + /// Heap home of a callable stored by create_boxed(); the header knows how to free it. + struct Box { + void (*free)(Box *box); + }; + + /// Store any callable on the heap with a deleter, for an owner that can later free_boxed() it. + template static Callback create_boxed(F &&callable) { + struct Boxed : Box { + std::decay_t fn; + }; + auto *box = new Boxed{{[](Box *b) { delete static_cast(b); }}, // NOLINT(cppcoreguidelines-owning-memory) + std::forward(callable)}; + return {[](void *c, Ts... args) { static_cast(static_cast(c))->fn(args...); }, + static_cast(box)}; + } + + /// Free the callable of a Callback made by create_boxed(); the Callback must not be called afterwards. + void free_boxed() const { + auto *box = static_cast(this->ctx_); + box->free(box); + } }; /// Grow a CallbackManager's backing array to exactly size+1. Defined in helpers.cpp. diff --git a/tests/components/core/test_callback.cpp b/tests/components/core/test_callback.cpp new file mode 100644 index 00000000000..7cf969734ad --- /dev/null +++ b/tests/components/core/test_callback.cpp @@ -0,0 +1,92 @@ +#include + +#include "esphome/core/helpers.h" + +namespace esphome::core::testing { + +using IntCallback = Callback; + +// Counts destructions of the copies a callable carries, to check a boxed capture is freed exactly once. +struct DestroyCounter { + int *destroyed; + bool live{true}; + explicit DestroyCounter(int *count) : destroyed(count) {} + DestroyCounter(const DestroyCounter &other) = delete; + DestroyCounter(DestroyCounter &&other) noexcept : destroyed(other.destroyed) { other.live = false; } + DestroyCounter &operator=(const DestroyCounter &other) = delete; + DestroyCounter &operator=(DestroyCounter &&other) = delete; + ~DestroyCounter() { + if (this->live) + (*this->destroyed)++; + } +}; + +template bool fits_inline(const F & /*callable*/) { return IntCallback::fits_inline(); } + +TEST(CallbackFitsInline, PointerSizedConstCallableFits) { + int value = 0; + EXPECT_TRUE(fits_inline([&value](int x) { value = x; })); +} + +TEST(CallbackFitsInline, MutableCallableDoesNotFit) { + int calls = 0; + EXPECT_FALSE(fits_inline([calls](int) mutable { calls++; })); +} + +TEST(CallbackFitsInline, LargeCallableDoesNotFit) { + int a = 0; + int b = 0; + EXPECT_FALSE(fits_inline([&a, &b](int x) { a = b = x; })); +} + +TEST(CallbackCreate, InlineCallableIsCalled) { + int value = 0; + auto cb = IntCallback::create([&value](int x) { value = x; }); + cb.call(42); + EXPECT_EQ(value, 42); +} + +int mutable_seen = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +// A pointer-sized mutable lambda used to be stored inline and run on a copy, losing its state. +TEST(CallbackCreate, MutableStateIsKeptAcrossCalls) { + auto counter = [n = 0](int x) mutable { mutable_seen = (n += x); }; + static_assert(sizeof(counter) <= sizeof(void *) && std::is_trivially_copyable_v); + // create() never frees its heap path; held for the whole run like a callback registered in setup(). + static auto cb = IntCallback::create(counter); + cb.call(1); + cb.call(2); + EXPECT_EQ(mutable_seen, 3); +} + +TEST(CallbackCreateBoxed, MutableStateIsKeptAcrossCalls) { + int seen = 0; + auto cb = IntCallback::create_boxed([count = 0, &seen](int x) mutable { + count += x; + seen = count; + }); + cb.call(1); + cb.call(2); + cb.call(3); + EXPECT_EQ(seen, 6); + cb.free_boxed(); +} + +TEST(CallbackCreateBoxed, FreeDestroysCaptureExactlyOnce) { + int destroyed = 0; + int calls = 0; + { + auto cb = IntCallback::create_boxed([counter = DestroyCounter(&destroyed), &calls](int) { calls++; }); + EXPECT_EQ(destroyed, 0); + cb.call(0); + cb.call(0); + EXPECT_EQ(destroyed, 0); + cb.free_boxed(); + EXPECT_EQ(destroyed, 1); + } + // The Callback itself owns nothing, so going out of scope frees nothing more + EXPECT_EQ(destroyed, 1); + EXPECT_EQ(calls, 2); +} + +} // namespace esphome::core::testing diff --git a/tests/components/mqtt/common-triggers.yaml b/tests/components/mqtt/common-triggers.yaml new file mode 100644 index 00000000000..3bb22c7c53c --- /dev/null +++ b/tests/components/mqtt/common-triggers.yaml @@ -0,0 +1,25 @@ +# on_message with a payload filter, a subscription callback larger than a pointer with its unsubscribe, +# a callback that unsubscribes itself, and a mutable callback, which keeps its state instead of being copied per call. +mqtt: + id: mqtt_client + on_message: + - topic: livingroom/ota_mode + payload: "ON" + qos: 1 + then: + - logger.log: Got livingroom/ota_mode ON + +esphome: + on_boot: + - lambda: |- + std::string tag = "boxed"; + id(mqtt_client).subscribe("some/topic/boxed", [tag](const std::string &topic, const std::string &payload) { + ESP_LOGD("test", "%s %s", tag.c_str(), payload.c_str()); + }); + id(mqtt_client).unsubscribe("some/topic/boxed"); + id(mqtt_client).subscribe("some/topic/once", [](const std::string &topic, const std::string &payload) { + id(mqtt_client).unsubscribe(topic); + }); + id(mqtt_client).subscribe("some/topic/counted", [n = 0](const std::string &topic, const std::string &payload) mutable { + ESP_LOGD("test", "message %d: %s", ++n, payload.c_str()); + }); diff --git a/tests/components/mqtt/test-triggers.esp32-idf.yaml b/tests/components/mqtt/test-triggers.esp32-idf.yaml new file mode 100644 index 00000000000..b34863e8737 --- /dev/null +++ b/tests/components/mqtt/test-triggers.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + common: !include common.yaml + triggers: !include common-triggers.yaml From 15fb7f775e67cf500707de098597de4ef660a56c Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Wed, 14 Oct 2026 20:59:21 +0200 Subject: [PATCH 109/142] [tmc2209] Add chopper and PWM tuning actions (#20753) --- esphome/components/tmc22xx/__init__.py | 77 ++++++++++++++++++++++++++ esphome/components/tmc22xx/tmc22xx.cpp | 7 +++ esphome/components/tmc22xx/tmc22xx.h | 24 ++++++++ tests/components/tmc2209/common.yaml | 15 +++++ 4 files changed, 123 insertions(+) diff --git a/esphome/components/tmc22xx/__init__.py b/esphome/components/tmc22xx/__init__.py index da760dde2c5..82ac29c7d5b 100644 --- a/esphome/components/tmc22xx/__init__.py +++ b/esphome/components/tmc22xx/__init__.py @@ -24,7 +24,9 @@ CONF_CLOCK_FREQUENCY = "clock_frequency" CONF_DIAG_PIN = "diag_pin" CONF_ENABLE_SPREADCYCLE = "enable_spreadcycle" CONF_ENN_PIN = "enn_pin" +CONF_HEND = "hend" CONF_HOLD_CURRENT = "hold_current" +CONF_HSTRT = "hstrt" CONF_IHOLD = "ihold" CONF_IHOLDDELAY = "iholddelay" CONF_INDEX_PIN = "index_pin" @@ -34,9 +36,18 @@ CONF_IRUN = "irun" CONF_MICROSTEPS = "microsteps" CONF_ON_STATUS = "on_status" CONF_OTTRIM = "ottrim" +CONF_PWM_AUTOGRAD = "pwm_autograd" +CONF_PWM_AUTOSCALE = "pwm_autoscale" +CONF_PWM_FREQ = "pwm_freq" +CONF_PWM_GRAD = "pwm_grad" +CONF_PWM_LIM = "pwm_lim" +CONF_PWM_OFS = "pwm_ofs" +CONF_PWM_REG = "pwm_reg" CONF_RSENSE = "rsense" CONF_RUN_CURRENT = "run_current" CONF_STANDSTILL_MODE = "standstill_mode" +CONF_TBL = "tbl" +CONF_TOFF = "toff" CONF_TPOWERDOWN = "tpowerdown" CONF_TPWM_THRESHOLD = "tpwm_threshold" CONF_VSENSE = "vsense" @@ -110,6 +121,29 @@ def _validate_analog_current_scale(config: ConfigType) -> ConfigType: return config +def _validate_chopconf(config: ConfigType) -> ConfigType: + # Only values given as constants can be checked here, not lambdas + toff, tbl = config.get(CONF_TOFF), config.get(CONF_TBL) + # TBL is never written at setup, so the chip may still hold a blank time below 2 + if toff == 1 and (tbl is None or (isinstance(tbl, int) and tbl < 2)): + raise cv.Invalid( + f"'{CONF_TOFF}: 1' needs '{CONF_TBL}' set to 2 or higher in the same " + "action", + path=[CONF_TOFF], + ) + hend, hstrt = config.get(CONF_HEND), config.get(CONF_HSTRT) + # Register values are offsets: HEND 0-15 is -3 to 12, HSTRT 0-7 is 1 to 8 + if isinstance(hend, int) and isinstance(hstrt, int): + hysteresis = (hend - 3) + (hstrt + 1) + if hysteresis > 16: + raise cv.Invalid( + f"'{CONF_HEND}' and '{CONF_HSTRT}' give a hysteresis of " + f"{hysteresis}, the limit is 16", + path=[CONF_HEND], + ) + return config + + def final_validate(config: ConfigType) -> ConfigType: """Reject two drivers of the family with the same address on one UART bus.""" for other in fv.full_config.get().get("stepper", []): @@ -225,3 +259,46 @@ def register_actions(domain: str, cls: MockObjClass) -> None: CONF_STANDSTILL_MODE, "set_standstill_mode", StandstillMode ), ) + + automation.register_apply_action( + f"{domain}.chopconf", + cv.All( + cv.Schema( + { + **id_schema, + cv.Optional(CONF_TOFF): cv.templatable(cv.int_range(1, 15)), + cv.Optional(CONF_TBL): cv.templatable(cv.int_range(0, 3)), + cv.Optional(CONF_HEND): cv.templatable(cv.int_range(0, 15)), + cv.Optional(CONF_HSTRT): cv.templatable(cv.int_range(0, 7)), + } + ), + _validate_chopconf, + ), + automation.ApplyField(CONF_TOFF, "set_toff", cg.uint8), + automation.ApplyField(CONF_TBL, "set_tbl", cg.uint8), + automation.ApplyField(CONF_HEND, "set_hend", cg.uint8), + automation.ApplyField(CONF_HSTRT, "set_hstrt", cg.uint8), + ) + + automation.register_apply_action( + f"{domain}.pwmconf", + cv.Schema( + { + **id_schema, + cv.Optional(CONF_PWM_OFS): cv.templatable(cv.uint8_t), + cv.Optional(CONF_PWM_GRAD): cv.templatable(cv.uint8_t), + cv.Optional(CONF_PWM_FREQ): cv.templatable(cv.int_range(0, 3)), + cv.Optional(CONF_PWM_AUTOSCALE): cv.templatable(cv.boolean), + cv.Optional(CONF_PWM_AUTOGRAD): cv.templatable(cv.boolean), + cv.Optional(CONF_PWM_REG): cv.templatable(cv.int_range(1, 15)), + cv.Optional(CONF_PWM_LIM): cv.templatable(cv.int_range(0, 15)), + } + ), + automation.ApplyField(CONF_PWM_OFS, "set_pwm_ofs", cg.uint8), + automation.ApplyField(CONF_PWM_GRAD, "set_pwm_grad", cg.uint8), + automation.ApplyField(CONF_PWM_FREQ, "set_pwm_freq", cg.uint8), + automation.ApplyField(CONF_PWM_AUTOSCALE, "set_pwm_autoscale", cg.bool_), + automation.ApplyField(CONF_PWM_AUTOGRAD, "set_pwm_autograd", cg.bool_), + automation.ApplyField(CONF_PWM_REG, "set_pwm_reg", cg.uint8), + automation.ApplyField(CONF_PWM_LIM, "set_pwm_lim", cg.uint8), + ) diff --git a/esphome/components/tmc22xx/tmc22xx.cpp b/esphome/components/tmc22xx/tmc22xx.cpp index 2aed97bea10..7bb756819bd 100644 --- a/esphome/components/tmc22xx/tmc22xx.cpp +++ b/esphome/components/tmc22xx/tmc22xx.cpp @@ -362,6 +362,13 @@ void TMC22XXStepper::set_enabled(bool enabled) { this->enabled_ = enabled; } +void TMC22XXStepper::set_toff(uint8_t toff) { + this->toff_ = toff; + // Without an ENN pin, TOFF=0 is what keeps the outputs disabled + if (this->enabled_ || this->enn_pin_ != nullptr) + this->write_field(TOFF, toff); +} + void TMC22XXStepper::set_microsteps(uint16_t microsteps) { for (uint8_t mres = 0; mres <= 8; mres++) { if ((256u >> mres) == microsteps) { diff --git a/esphome/components/tmc22xx/tmc22xx.h b/esphome/components/tmc22xx/tmc22xx.h index 19e4813b70d..2e8644de53a 100644 --- a/esphome/components/tmc22xx/tmc22xx.h +++ b/esphome/components/tmc22xx/tmc22xx.h @@ -57,12 +57,22 @@ static constexpr RegisterField TPOWERDOWN{REG_TPOWERDOWN, 0, 8, false}; static constexpr RegisterField TPWMTHRS{REG_TPWMTHRS, 0, 20, false}; static constexpr RegisterField VACTUAL{REG_VACTUAL, 0, 24, true}; static constexpr RegisterField TOFF{REG_CHOPCONF, 0, 4, false}; +static constexpr RegisterField HSTRT{REG_CHOPCONF, 4, 3, false}; +static constexpr RegisterField HEND{REG_CHOPCONF, 7, 4, false}; +static constexpr RegisterField TBL{REG_CHOPCONF, 15, 2, false}; static constexpr RegisterField VSENSE{REG_CHOPCONF, 17, 1, false}; static constexpr RegisterField MRES{REG_CHOPCONF, 24, 4, false}; static constexpr RegisterField INTPOL{REG_CHOPCONF, 28, 1, false}; static constexpr RegisterField DEDGE{REG_CHOPCONF, 29, 1, false}; static constexpr RegisterField CS_ACTUAL{REG_DRV_STATUS, 16, 5, false}; +static constexpr RegisterField PWM_OFS{REG_PWMCONF, 0, 8, false}; +static constexpr RegisterField PWM_GRAD{REG_PWMCONF, 8, 8, false}; +static constexpr RegisterField PWM_FREQ{REG_PWMCONF, 16, 2, false}; +static constexpr RegisterField PWM_AUTOSCALE{REG_PWMCONF, 18, 1, false}; +static constexpr RegisterField PWM_AUTOGRAD{REG_PWMCONF, 19, 1, false}; static constexpr RegisterField FREEWHEEL{REG_PWMCONF, 20, 2, false}; +static constexpr RegisterField PWM_REG{REG_PWMCONF, 24, 4, false}; +static constexpr RegisterField PWM_LIM{REG_PWMCONF, 28, 4, false}; static constexpr RegisterField PWM_SCALE_SUM{REG_PWM_SCALE, 0, 8, false}; static constexpr RegisterField PWM_SCALE_AUTO{REG_PWM_SCALE, 16, 9, true}; static constexpr RegisterField PWM_OFS_AUTO{REG_PWM_AUTO, 0, 8, false}; @@ -160,6 +170,20 @@ class TMC22XXStepper : public stepper::Stepper, public Component, public uart::U void set_iholddelay(uint8_t delay) { this->write_field(IHOLDDELAY, delay); } void set_tpowerdown(uint8_t tpowerdown) { this->write_field(TPOWERDOWN, tpowerdown); } void set_standstill_mode(StandstillMode mode) { this->write_field(FREEWHEEL, mode); } + + /// Chopper off time (1-15). TOFF=0 is used to disable the outputs, so this is applied on the next enable. + void set_toff(uint8_t toff); + void set_tbl(uint8_t tbl) { this->write_field(TBL, tbl); } + void set_hend(uint8_t hend) { this->write_field(HEND, hend); } + void set_hstrt(uint8_t hstrt) { this->write_field(HSTRT, hstrt); } + + void set_pwm_ofs(uint8_t ofs) { this->write_field(PWM_OFS, ofs); } + void set_pwm_grad(uint8_t grad) { this->write_field(PWM_GRAD, grad); } + void set_pwm_freq(uint8_t freq) { this->write_field(PWM_FREQ, freq); } + void set_pwm_autoscale(bool enable) { this->write_field(PWM_AUTOSCALE, enable); } + void set_pwm_autograd(bool enable) { this->write_field(PWM_AUTOGRAD, enable); } + void set_pwm_reg(uint8_t reg) { this->write_field(PWM_REG, reg); } + void set_pwm_lim(uint8_t lim) { this->write_field(PWM_LIM, lim); } /// Convert a current scale (0-31) to A RMS, without analog current scale. float scale_to_current(uint8_t scale); diff --git a/tests/components/tmc2209/common.yaml b/tests/components/tmc2209/common.yaml index c39d3fd8ba2..8714db23f42 100644 --- a/tests/components/tmc2209/common.yaml +++ b/tests/components/tmc2209/common.yaml @@ -78,6 +78,21 @@ esphome: seup: 1 sedn: 0 seimin: true + - tmc2209.chopconf: + id: tmc2209_step_dir + toff: 4 + tbl: 2 + hend: 0 + hstrt: !lambda "return 5;" + - tmc2209.pwmconf: + id: tmc2209_serial + pwm_ofs: 36 + pwm_grad: 14 + pwm_freq: 1 + pwm_autoscale: true + pwm_autograd: true + pwm_reg: 4 + pwm_lim: 12 - tmc2209.enable: tmc2209_serial - stepper.set_target: id: tmc2209_serial From c5a624a1f2e9be7985bd03bd81ae8c3f0eb36568 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:21:35 -0400 Subject: [PATCH 110/142] [tests] Count OTA key probe retries on a fake clock (#20761) --- tests/unit_tests/test_espota2_noise.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/unit_tests/test_espota2_noise.py b/tests/unit_tests/test_espota2_noise.py index 1adace67395..f447e03986e 100644 --- a/tests/unit_tests/test_espota2_noise.py +++ b/tests/unit_tests/test_espota2_noise.py @@ -858,16 +858,23 @@ def test_probe_ota_key_resolution_failure( ) -> None: """A name that never resolves is retried until the deadline, then reported.""" pytest.importorskip("aioesphomeapi.noise") + clock = [0.0] + + def sleep(seconds: float) -> None: + clock[0] += seconds + with ( patch("esphome.espota2.resolve_ip_address", **kwargs) as resolve, - patch("time.sleep"), + patch("time.sleep", side_effect=sleep), + patch("time.monotonic", side_effect=lambda: clock[0]), caplog.at_level(logging.WARNING), ): assert ( - espota2.probe_ota_key("nowhere.local", 3232, PSK, timeout=0.2) + espota2.probe_ota_key("nowhere.local", 3232, PSK, timeout=10) is not ProbeResult.ACCEPTED ) - assert resolve.call_count > 1 + # Looked up at 0, 3, 6 and 9 s; the last pause stops at the deadline + assert resolve.call_count == 4 assert any("failed, unreachable" in r.message for r in caplog.records) From e1a996435dc1a3373a72e1c8740449179510b07c Mon Sep 17 00:00:00 2001 From: Fabio Pugliese Ornellas Date: Wed, 14 Oct 2026 20:29:16 +0100 Subject: [PATCH 111/142] [ld2450] Add polygon zones evaluated on the device (#20154) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/ld2450/__init__.py | 11 + esphome/components/ld2450/binary_sensor.py | 36 ++- esphome/components/ld2450/ld2450.cpp | 34 +- esphome/components/ld2450/ld2450.h | 13 +- esphome/components/ld2450/polygon.cpp | 123 +++++++ esphome/components/ld2450/polygon.h | 53 ++++ esphome/components/ld2450/polygon_zone.cpp | 60 ++++ esphome/components/ld2450/polygon_zone.h | 63 ++++ esphome/components/ld2450/sensor.py | 2 +- esphome/components/ld2450/text.py | 120 +++++++ esphome/components/ld2450/text_sensor.py | 2 +- esphome/core/defines.h | 1 + tests/component_tests/ld2450/__init__.py | 0 .../ld2450/config/esp8266_zones.yaml | 31 ++ .../ld2450/test_polygon_pattern.py | 64 ++++ .../ld2450/test_polygon_zone_presence.py | 45 +++ .../ld2450/test_polygon_zones.py | 163 ++++++++++ tests/components/ld2450/__init__.py | 9 + tests/components/ld2450/common.yaml | 14 + tests/components/ld2450/ld2450_polygon.cpp | 162 ++++++++++ .../uart_mock_ld2450_has_target_only.yaml | 248 +++++++++++++++ ...uart_mock_ld2450_polygon_zone_restore.yaml | 55 ++++ ...uart_mock_ld2450_polygon_zone_timeout.yaml | 300 ++++++++++++++++++ .../uart_mock_ld2450_polygon_zones.yaml | 246 ++++++++++++++ .../test_uart_mock_ld2450_has_target_only.py | 64 ++++ ...t_uart_mock_ld2450_polygon_zone_restore.py | 117 +++++++ ...t_uart_mock_ld2450_polygon_zone_timeout.py | 102 ++++++ .../test_uart_mock_ld2450_polygon_zones.py | 160 ++++++++++ 28 files changed, 2291 insertions(+), 7 deletions(-) create mode 100644 esphome/components/ld2450/polygon.cpp create mode 100644 esphome/components/ld2450/polygon.h create mode 100644 esphome/components/ld2450/polygon_zone.cpp create mode 100644 esphome/components/ld2450/polygon_zone.h create mode 100644 esphome/components/ld2450/text.py create mode 100644 tests/component_tests/ld2450/__init__.py create mode 100644 tests/component_tests/ld2450/config/esp8266_zones.yaml create mode 100644 tests/component_tests/ld2450/test_polygon_pattern.py create mode 100644 tests/component_tests/ld2450/test_polygon_zone_presence.py create mode 100644 tests/component_tests/ld2450/test_polygon_zones.py create mode 100644 tests/components/ld2450/__init__.py create mode 100644 tests/components/ld2450/ld2450_polygon.cpp create mode 100644 tests/integration/fixtures/uart_mock_ld2450_has_target_only.yaml create mode 100644 tests/integration/fixtures/uart_mock_ld2450_polygon_zone_restore.yaml create mode 100644 tests/integration/fixtures/uart_mock_ld2450_polygon_zone_timeout.yaml create mode 100644 tests/integration/fixtures/uart_mock_ld2450_polygon_zones.yaml create mode 100644 tests/integration/test_uart_mock_ld2450_has_target_only.py create mode 100644 tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py create mode 100644 tests/integration/test_uart_mock_ld2450_polygon_zone_timeout.py create mode 100644 tests/integration/test_uart_mock_ld2450_polygon_zones.py diff --git a/esphome/components/ld2450/__init__.py b/esphome/components/ld2450/__init__.py index a54e83f2875..066ace871f8 100644 --- a/esphome/components/ld2450/__init__.py +++ b/esphome/components/ld2450/__init__.py @@ -1,6 +1,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_ON_DATA, CONF_THROTTLE from esphome.types import ConfigType @@ -16,6 +17,8 @@ LD2450Component = ld2450_ns.class_("LD2450Component", cg.Component, uart.UARTDev CONF_LD2450_ID = "ld2450_id" +CONF_POLYGON_ZONE_ID = "polygon_zone_id" +CONF_POLYGON_ZONES = "polygon_zones" CONFIG_SCHEMA = cv.All( cv.Schema( @@ -50,6 +53,14 @@ _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation(CONF_ON_DATA, "add_on_data_callback"), ) +# Polygon zone sources build only when zones are configured +FILTER_SOURCE_FILES = filter_source_files_from_defines( + { + "polygon.cpp": "LD2450_POLYGON_ZONE_COUNT", + "polygon_zone.cpp": "LD2450_POLYGON_ZONE_COUNT", + } +) + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/ld2450/binary_sensor.py b/esphome/components/ld2450/binary_sensor.py index c7c1b6b46af..41f514472e5 100644 --- a/esphome/components/ld2450/binary_sensor.py +++ b/esphome/components/ld2450/binary_sensor.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import ( + CONF_FILTERS, CONF_HAS_MOVING_TARGET, CONF_HAS_STILL_TARGET, CONF_HAS_TARGET, @@ -11,7 +12,14 @@ from esphome.const import ( ) from esphome.types import ConfigType -from . import CONF_LD2450_ID, LD2450Component +from . import ( # noqa: F401 pylint: disable=unused-import + CONF_LD2450_ID, + CONF_POLYGON_ZONE_ID, + CONF_POLYGON_ZONES, + FILTER_SOURCE_FILES, + LD2450Component, +) +from .text import PolygonZone DEPENDENCIES = ["ld2450"] @@ -19,6 +27,18 @@ ICON_MEDITATION = "mdi:meditation" ICON_SHIELD_ACCOUNT = "mdi:shield-account" ICON_TARGET_ACCOUNT = "mdi:target-account" + +def _reject_filters(config: ConfigType) -> ConfigType: + # Clearing a polygon makes presence unknown without going through filters, so they would get out of step + if CONF_FILTERS in config: + raise cv.Invalid( + "Polygon zone presence binary sensors can't have filters; use the ld2450 number platform's " + "'presence_timeout' to keep presence on after targets leave", + path=[CONF_FILTERS], + ) + return config + + CONFIG_SCHEMA = { cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), cv.GenerateID(CONF_LD2450_ID): cv.use_id(LD2450Component), @@ -37,6 +57,16 @@ CONFIG_SCHEMA = { filters=[{"settle": cv.TimePeriod(milliseconds=1000)}], icon=ICON_MEDITATION, ), + cv.Optional(CONF_POLYGON_ZONES): cv.ensure_list( + binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + ).extend( + { + cv.Required(CONF_POLYGON_ZONE_ID): cv.use_id(PolygonZone), + } + ), + _reject_filters, + ), } @@ -46,3 +76,7 @@ async def to_code(config: ConfigType) -> None: await binary_sensors(CONF_HAS_TARGET, hub.set_target_binary_sensor) await binary_sensors(CONF_HAS_MOVING_TARGET, hub.set_moving_target_binary_sensor) await binary_sensors(CONF_HAS_STILL_TARGET, hub.set_still_target_binary_sensor) + for presence_conf in config.get(CONF_POLYGON_ZONES, []): + presence = await binary_sensor.new_binary_sensor(presence_conf) + zone = await cg.get_variable(presence_conf[CONF_POLYGON_ZONE_ID]) + cg.add(zone.set_presence_binary_sensor(presence)) diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index b43493c26e9..d2a0e04d122 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -179,6 +179,11 @@ void LD2450Component::setup() { this->pref_ = this->presence_timeout_number_->make_entity_preference(); this->set_presence_timeout(); } +#endif +#ifdef LD2450_POLYGON_ZONE_COUNT + for (PolygonZone *zone : this->polygon_zones_) { + zone->setup(); + } #endif this->restart_and_read_all_info(); } @@ -265,6 +270,12 @@ void LD2450Component::dump_config() { LOG_BUTTON(" ", "FactoryReset", this->factory_reset_button_); LOG_BUTTON(" ", "Restart", this->restart_button_); #endif +#ifdef LD2450_POLYGON_ZONE_COUNT + ESP_LOGCONFIG(TAG, "Polygon Zones:"); + for (PolygonZone *zone : this->polygon_zones_) { + zone->dump_config(); + } +#endif } void LD2450Component::loop() { @@ -465,15 +476,15 @@ void LD2450Component::handle_periodic_data_() { #if defined(USE_BINARY_SENSOR) || defined(USE_SENSOR) || defined(USE_TEXT_SENSOR) // Loop thru targets for (index = 0; index < MAX_TARGETS; index++) { -#ifdef USE_SENSOR // X start = TARGET_X + index * 8; is_moving = false; - // tx is used for further calculations, so always needs to be populated + // tx and ty are used for further calculations, so always need to be populated tx = ld2450::decode_coordinate(this->buffer_data_[start], this->buffer_data_[start + 1]); // Y start = TARGET_Y + index * 8; ty = ld2450::decode_coordinate(this->buffer_data_[start], this->buffer_data_[start + 1]); +#ifdef USE_SENSOR // RESOLUTION start = TARGET_RESOLUTION + index * 8; res = (this->buffer_data_[start + 1] << 8) | this->buffer_data_[start]; @@ -542,6 +553,23 @@ void LD2450Component::handle_periodic_data_() { still_target_count = target_count - moving_target_count; #endif +#ifdef LD2450_POLYGON_ZONE_COUNT + const uint32_t now = App.get_loop_component_start_time(); + for (PolygonZone *zone : this->polygon_zones_) { + if (!zone->has_polygon()) + continue; + bool target_inside = false; + for (const Target &target : this->target_info_) { + // Untracked targets are stored as (0, 0) + if ((target.x != 0 || target.y != 0) && zone->contains(target.x, target.y)) { + target_inside = true; + break; + } + } + zone->update(target_inside, now, this->get_timeout_status_(zone->get_last_inside_ms())); + } +#endif + #ifdef USE_SENSOR // Loop thru zones uint8_t zone_still_targets = 0; @@ -602,7 +630,7 @@ void LD2450Component::handle_periodic_data_() { } } #endif -#ifdef USE_SENSOR +#ifdef USE_BINARY_SENSOR // For presence timeout check if (target_count > 0) { this->presence_millis_ = App.get_loop_component_start_time(); diff --git a/esphome/components/ld2450/ld2450.h b/esphome/components/ld2450/ld2450.h index d6285cce8af..f85a26ba675 100644 --- a/esphome/components/ld2450/ld2450.h +++ b/esphome/components/ld2450/ld2450.h @@ -24,6 +24,10 @@ #include "esphome/components/binary_sensor/binary_sensor.h" #endif +#ifdef LD2450_POLYGON_ZONE_COUNT +#include "polygon_zone.h" +#endif + #include "esphome/components/ld24xx/ld24xx.h" #include "esphome/components/uart/uart.h" #include "esphome/core/helpers.h" @@ -137,6 +141,9 @@ class LD2450Component : public Component, public uart::UARTDevice { void set_zone_target_count_sensor(uint8_t zone, sensor::Sensor *s); void set_zone_still_target_count_sensor(uint8_t zone, sensor::Sensor *s); void set_zone_moving_target_count_sensor(uint8_t zone, sensor::Sensor *s); +#endif +#ifdef LD2450_POLYGON_ZONE_COUNT + void register_polygon_zone(PolygonZone *zone) { this->polygon_zones_.push_back(zone); } #endif void reset_radar_zone(); void set_radar_zone(int32_t zone_type, int32_t zone1_x1, int32_t zone1_y1, int32_t zone1_x2, int32_t zone1_y2, @@ -167,7 +174,7 @@ class LD2450Component : public Component, public uart::UARTDevice { uint32_t presence_millis_ = 0; uint32_t still_presence_millis_ = 0; uint32_t moving_presence_millis_ = 0; - uint32_t timeout_ = 5; + uint32_t timeout_ = DEFAULT_PRESENCE_TIMEOUT * 1000; // presence_timeout, in ms uint8_t buffer_data_[MAX_LINE_LENGTH]; uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0}; uint8_t version_[6] = {0, 0, 0, 0, 0, 0}; @@ -200,6 +207,10 @@ class LD2450Component : public Component, public uart::UARTDevice { std::array direction_text_sensors_{}; #endif +#ifdef LD2450_POLYGON_ZONE_COUNT + StaticVector polygon_zones_; +#endif + LazyCallbackManager data_callback_; }; diff --git a/esphome/components/ld2450/polygon.cpp b/esphome/components/ld2450/polygon.cpp new file mode 100644 index 00000000000..46d607d3519 --- /dev/null +++ b/esphome/components/ld2450/polygon.cpp @@ -0,0 +1,123 @@ +#include "polygon.h" + +#include "esphome/core/helpers.h" + +#include + +namespace esphome::ld2450 { + +namespace { + +class Scanner { + public: + Scanner(const char *str, size_t len) : pos_(str), end_(str + len) {} + + bool at_end() const { return this->pos_ == this->end_; } + + void skip_spaces() { + while (this->pos_ != this->end_ && *this->pos_ == ' ') + this->pos_++; + } + + bool consume(char c) { + this->skip_spaces(); + if (this->pos_ != this->end_ && *this->pos_ == c) { + this->pos_++; + return true; + } + return false; + } + + /// Read an integer in [min, max]; returns false if there is none or it is out of range. + bool read_int(int16_t min, int16_t max, int16_t &out) { + bool negative = this->consume('-'); + int32_t value = 0; + uint8_t digits = 0; + while (this->pos_ != this->end_ && *this->pos_ >= '0' && *this->pos_ <= '9') { + // Six digits is already beyond any valid coordinate; stop before value can overflow + if (++digits > 6) + return false; + value = value * 10 + (*this->pos_ - '0'); + this->pos_++; + } + if (digits == 0) + return false; + if (negative) + value = -value; + if (value < min || value > max) + return false; + out = static_cast(value); + return true; + } + + protected: + const char *pos_; + const char *end_; +}; + +} // namespace + +bool Polygon::is_valid() const { + if (this->count == 0) + return true; + if (this->count < MIN_POLYGON_POINTS || this->count > MAX_POLYGON_POINTS) + return false; + for (uint8_t i = 0; i < this->count; i++) { + const Point &p = this->points[i]; + if (p.x < POLYGON_MIN_X || p.x > POLYGON_MAX_X || p.y < POLYGON_MIN_Y || p.y > POLYGON_MAX_Y) + return false; + } + return true; +} + +bool Polygon::contains(int16_t x, int16_t y) const { + bool inside = false; + for (uint8_t i = 0, j = this->count - 1; i < this->count; j = i++) { + const Point &a = this->points[i]; + const Point &b = this->points[j]; + if ((a.y > y) == (b.y > y)) + continue; + // Does the edge a-b cross the ray going from (x, y) towards +x? Compare + // x - a.x < (y - a.y) * (b.x - a.x) / (b.y - a.y) without division; values fit in 32 bits. + const int32_t dy = static_cast(b.y) - a.y; + const int32_t lhs = (static_cast(x) - a.x) * dy; + const int32_t rhs = (static_cast(y) - a.y) * (static_cast(b.x) - a.x); + if (dy > 0 ? lhs < rhs : lhs > rhs) + inside = !inside; + } + return inside; +} + +bool Polygon::parse(const char *str, size_t len) { + Polygon result; + Scanner scanner(str, len); + scanner.skip_spaces(); + while (!scanner.at_end()) { + if (result.count == MAX_POLYGON_POINTS) + return false; + Point &p = result.points[result.count]; + if (!scanner.read_int(POLYGON_MIN_X, POLYGON_MAX_X, p.x) || !scanner.consume(',') || + !scanner.read_int(POLYGON_MIN_Y, POLYGON_MAX_Y, p.y)) + return false; + result.count++; + bool separator = scanner.consume(';'); + scanner.skip_spaces(); + if (!separator && !scanner.at_end()) + return false; + } + if (!result.is_valid()) + return false; + *this = result; + return true; +} + +void Polygon::format(char *buf) const { + size_t pos = 0; + buf[0] = '\0'; + for (uint8_t i = 0; i < this->count; i++) { + pos = buf_append_printf(buf, POLYGON_STR_SIZE, pos, "%s%d,%d", i == 0 ? "" : ";", this->points[i].x, + this->points[i].y); + } +} + +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/polygon.h b/esphome/components/ld2450/polygon.h new file mode 100644 index 00000000000..24e5be8855b --- /dev/null +++ b/esphome/components/ld2450/polygon.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include + +namespace esphome::ld2450 { + +static constexpr uint8_t MIN_POLYGON_POINTS = 3; +// The most points whose text always fits Home Assistant's 255-character limit: 23 x "-4860,7560" + 22 x ';' = 252 +static constexpr uint8_t MAX_POLYGON_POINTS = 23; +// Coordinate limits of the radar detection area, in mm +static constexpr int16_t POLYGON_MIN_X = -4860; +static constexpr int16_t POLYGON_MAX_X = 4860; +static constexpr int16_t POLYGON_MIN_Y = 0; +static constexpr int16_t POLYGON_MAX_Y = 7560; +// Longest point is "-4860,7560;" (11 characters), plus the null terminator +static constexpr size_t POLYGON_STR_SIZE = MAX_POLYGON_POINTS * 11 + 1; + +struct Point { + int16_t x; + int16_t y; +}; + +/// A polygon in radar coordinates. Trivially copyable so it can be saved to preferences as-is. +struct Polygon { + std::array points{}; + uint8_t count{0}; + + /// True when the polygon has no points, which disables the zone. + bool empty() const { return this->count == 0; } + + /// True when the polygon is empty, or has a valid number of points that are all inside the radar area. + bool is_valid() const; + + /// Return whether the point is inside the polygon (even-odd rule). Always false for an empty polygon. + bool contains(int16_t x, int16_t y) const; + + /** Parse "x,y;x,y;..." (mm) into this polygon. + * + * Spaces around numbers and a trailing ';' are allowed. An empty or all-space string gives an empty polygon. + * @return false if the text is invalid; the polygon is then left unchanged. + */ + bool parse(const char *str, size_t len); + + /// Write the polygon as "x,y;x,y;..." into buf, which must hold at least POLYGON_STR_SIZE bytes. + void format(char *buf) const; +}; + +// text.py counts on this size +static_assert(sizeof(Polygon) == 94, "Polygon layout changed; update POLYGON_SAVED_BYTES in text.py"); + +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/polygon_zone.cpp b/esphome/components/ld2450/polygon_zone.cpp new file mode 100644 index 00000000000..ecafd40360b --- /dev/null +++ b/esphome/components/ld2450/polygon_zone.cpp @@ -0,0 +1,60 @@ +#include "polygon_zone.h" +#ifdef LD2450_POLYGON_ZONE_COUNT + +#include "esphome/core/log.h" + +namespace esphome::ld2450 { + +ESPHOME_LOG_TAG(TAG, "ld2450.polygon_zone"); + +void PolygonZone::setup() { + // Bump the version when Polygon's layout changes + this->pref_ = this->make_entity_preference(1); + Polygon saved; + if (this->pref_.load(&saved)) { + if (saved.is_valid()) { + this->polygon_ = saved; + } else { + ESP_LOGW(TAG, "'%s': saved polygon is invalid; zone disabled", this->get_name().c_str()); + } + } + // Presence stays unknown until the first radar frame + this->publish_polygon_(); +} + +void PolygonZone::dump_config() { + char polygon_s[POLYGON_STR_SIZE]; + this->polygon_.format(polygon_s); + LOG_TEXT(" ", "PolygonZone", this); + ESP_LOGCONFIG(TAG, " Polygon: '%s'", polygon_s); + LOG_BINARY_SENSOR(" ", "Presence", this->presence_); +} + +void PolygonZone::control(const std::string &value) { + Polygon polygon; + if (!polygon.parse(value.c_str(), value.size())) { + ESP_LOGW(TAG, "'%s': invalid polygon '%s'; expected 'x,y;x,y;...' with %u to %u points", this->get_name().c_str(), + value.c_str(), MIN_POLYGON_POINTS, MAX_POLYGON_POINTS); + } else if (!this->pref_.save(&polygon)) { + // On ESP8266, the space for saved settings is small and can run out with several zones + ESP_LOGW(TAG, "'%s': could not save the polygon; keeping the previous one", this->get_name().c_str()); + } else { + this->polygon_ = polygon; + // A target seen inside the previous polygon must not keep the new one on + this->last_inside_ms_ = 0; + if (this->polygon_.empty()) + this->presence_->invalidate_state(); + } + // On error this publishes the previous polygon again, so the frontend shows the value in use + this->publish_polygon_(); +} + +void PolygonZone::publish_polygon_() { + char polygon_s[POLYGON_STR_SIZE]; + this->polygon_.format(polygon_s); + this->publish_state(polygon_s); +} + +} // namespace esphome::ld2450 + +#endif // LD2450_POLYGON_ZONE_COUNT diff --git a/esphome/components/ld2450/polygon_zone.h b/esphome/components/ld2450/polygon_zone.h new file mode 100644 index 00000000000..5a6caaa3282 --- /dev/null +++ b/esphome/components/ld2450/polygon_zone.h @@ -0,0 +1,63 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef LD2450_POLYGON_ZONE_COUNT + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/text/text.h" +#include "esphome/core/preferences.h" +#include "polygon.h" + +namespace esphome::ld2450 { + +/** A zone with any polygon shape. The text state holds the polygon, so it can be edited and read back from HA. + * + * The polygon is in the radar's raw coordinates (mm), and targets are checked against it as decoded from each + * radar frame, before any sensor filter. Filters on the target X/Y sensors change only what they publish, so a + * filter that changes their values (multiply, offset, lambda, ...) puts them in a different frame from the polygon. + * + * Like the hub's has_target binary sensors, presence stays on until presence_timeout has passed since a target was + * last inside the zone. + */ +class PolygonZone : public text::Text { + public: + /// Config validation guarantees every zone gets exactly one presence binary sensor. + void set_presence_binary_sensor(binary_sensor::BinarySensor *presence) { this->presence_ = presence; } + + /// Load the saved polygon and publish it. Called from the hub's setup(). + void setup(); + void dump_config(); + /** Publish presence for one radar frame. A zone without a polygon keeps presence unknown. + * + * @param target_inside whether any target is inside the zone in this frame. + * @param now the frame's time, in ms. + * @param timed_out whether presence_timeout has passed since get_last_inside_ms(). + */ + void update(bool target_inside, uint32_t now, bool timed_out) { + if (this->polygon_.empty()) + return; + if (target_inside) { + this->last_inside_ms_ = now; + this->presence_->publish_state(true); + } else if (timed_out) { + this->presence_->publish_state(false); + } + } + /// When a target was last inside the zone, in ms; 0 if never since the polygon was set. + uint32_t get_last_inside_ms() const { return this->last_inside_ms_; } + bool contains(int16_t x, int16_t y) const { return this->polygon_.contains(x, y); } + bool has_polygon() const { return !this->polygon_.empty(); } + + protected: + void control(const std::string &value) override; + void publish_polygon_(); + + binary_sensor::BinarySensor *presence_{nullptr}; + Polygon polygon_; + ESPPreferenceObject pref_; + uint32_t last_inside_ms_{0}; +}; + +} // namespace esphome::ld2450 + +#endif // LD2450_POLYGON_ZONE_COUNT diff --git a/esphome/components/ld2450/sensor.py b/esphome/components/ld2450/sensor.py index 6f2056330bd..4600f27ce0d 100644 --- a/esphome/components/ld2450/sensor.py +++ b/esphome/components/ld2450/sensor.py @@ -17,7 +17,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from . import CONF_LD2450_ID, LD2450Component +from . import CONF_LD2450_ID, FILTER_SOURCE_FILES, LD2450Component # noqa: F401 pylint: disable=unused-import DEPENDENCIES = ["ld2450"] diff --git a/esphome/components/ld2450/text.py b/esphome/components/ld2450/text.py new file mode 100644 index 00000000000..ebe8fc77131 --- /dev/null +++ b/esphome/components/ld2450/text.py @@ -0,0 +1,120 @@ +from esphome import preferences +import esphome.codegen as cg +from esphome.components import text +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_PLATFORM, ENTITY_CATEGORY_CONFIG +import esphome.final_validate as fv +from esphome.types import ConfigType + +from . import ( # noqa: F401 pylint: disable=unused-import + CONF_LD2450_ID, + CONF_POLYGON_ZONE_ID, + CONF_POLYGON_ZONES, + DOMAIN, + FILTER_SOURCE_FILES, + LD2450Component, + ld2450_ns, +) + +DEPENDENCIES = ["ld2450"] + +ICON_VECTOR_POLYGON = "mdi:vector-polygon" + +# Must match MIN_POLYGON_POINTS and MAX_POLYGON_POINTS in polygon.h +MIN_POLYGON_POINTS = 3 +MAX_POLYGON_POINTS = 23 +# Matches what Polygon::parse() accepts, apart from coordinate ranges, which only the device checks: +# empty, or MIN_POLYGON_POINTS to MAX_POLYGON_POINTS "x,y" points separated by ';'. HA validates input with it. +# No character classes: the HA frontend compiles it with the JS "v" flag, HA core with Python re. +POLYGON_PATTERN = ( + r"^ *$|^ *-?\d{1,4} *, *\d{1,4} *" + rf"(; *-?\d{{1,4}} *, *\d{{1,4}} *){{{MIN_POLYGON_POINTS - 1},{MAX_POLYGON_POINTS - 1}}};? *$" +) +# Home Assistant's limit for a state; the longest polygon the device publishes is 252 characters +POLYGON_MAX_LENGTH = 255 + +# sizeof(Polygon), pinned by a static_assert in polygon.h +POLYGON_SAVED_BYTES = 94 + +PolygonZone = ld2450_ns.class_("PolygonZone", text.Text) + +_request_polygon_zone_slot = cg.slot_counter("LD2450_POLYGON_ZONE_COUNT") + + +def _require_manual_id(config: ConfigType) -> ConfigType: + if not config[CONF_ID].is_manual: + raise cv.Invalid( + f"'{CONF_ID}' is required, so the zone's presence binary sensor can refer to it", + path=[CONF_ID], + ) + return config + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD2450_ID): cv.use_id(LD2450Component), + # Polygons are in raw radar mm, checked before sensor filters (polygon_zone.h) + cv.Required(CONF_POLYGON_ZONES): cv.ensure_list( + text.text_schema( + PolygonZone, + entity_category=ENTITY_CATEGORY_CONFIG, + icon=ICON_VECTOR_POLYGON, + mode="TEXT", + ), + _require_manual_id, + ), + } +) + + +def _final_validate(config: ConfigType) -> None: + """Each polygon zone must have exactly one presence binary sensor, on the same LD2450.""" + full_config = fv.full_config.get() + # Polygon zone id -> the LD2450 id of each presence binary sensor that uses it + references: dict[str, list[str]] = {} + for binary_sensor_conf in full_config.get("binary_sensor", []): + if binary_sensor_conf.get(CONF_PLATFORM) != "ld2450": + continue + hub_id = binary_sensor_conf[CONF_LD2450_ID].id + for presence_conf in binary_sensor_conf.get(CONF_POLYGON_ZONES, []): + zone_id = presence_conf[CONF_POLYGON_ZONE_ID].id + references.setdefault(zone_id, []).append(hub_id) + + zone_hub_id = config[CONF_LD2450_ID].id + for index, zone_conf in enumerate(config[CONF_POLYGON_ZONES]): + zone_id = zone_conf[CONF_ID].id + hub_ids = references.get(zone_id, []) + if len(hub_ids) != 1: + problem = ( + "has no presence binary sensor" + if not hub_ids + else "is used by more than one presence binary sensor" + ) + raise cv.Invalid( + f"Polygon zone '{zone_id}' {problem}; each zone needs exactly one, under " + f"'binary_sensor: - platform: ld2450' -> '{CONF_POLYGON_ZONES}'", + path=[CONF_POLYGON_ZONES, index], + ) + if hub_ids[0] != zone_hub_id: + raise cv.Invalid( + f"Polygon zone '{zone_id}' belongs to LD2450 '{zone_hub_id}', but its presence " + f"binary sensor is under LD2450 '{hub_ids[0]}'; use the same '{CONF_LD2450_ID}'", + path=[CONF_POLYGON_ZONES, index], + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LD2450_ID]) + # Each zone saves its polygon; the ESP8266 checks that every component's saved values fit + preferences.reserve_preferences( + len(config[CONF_POLYGON_ZONES]), POLYGON_SAVED_BYTES + ) + for zone_conf in config[CONF_POLYGON_ZONES]: + zone = await text.new_text( + zone_conf, max_length=POLYGON_MAX_LENGTH, pattern=POLYGON_PATTERN + ) + _request_polygon_zone_slot(str(hub)) + cg.add(hub.register_polygon_zone(zone)) diff --git a/esphome/components/ld2450/text_sensor.py b/esphome/components/ld2450/text_sensor.py index 3eaf6c7fcad..01821a4f7ef 100644 --- a/esphome/components/ld2450/text_sensor.py +++ b/esphome/components/ld2450/text_sensor.py @@ -14,7 +14,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from . import CONF_LD2450_ID, LD2450Component +from . import CONF_LD2450_ID, FILTER_SOURCE_FILES, LD2450Component # noqa: F401 pylint: disable=unused-import DEPENDENCIES = ["ld2450"] diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 50d8285cc1c..6075fc93797 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -157,6 +157,7 @@ #define MDNS_SERVICE_COUNT 3 #define USE_MDNS_DYNAMIC_TXT #define MDNS_DYNAMIC_TXT_COUNT 2 +#define LD2450_POLYGON_ZONE_COUNT 2 #define MICRONOVA_LISTENER_COUNT 1 #define USE_MICRONOVA_WRITER #define MK2PVROUTER_LISTENER_COUNT 1 diff --git a/tests/component_tests/ld2450/__init__.py b/tests/component_tests/ld2450/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/ld2450/config/esp8266_zones.yaml b/tests/component_tests/ld2450/config/esp8266_zones.yaml new file mode 100644 index 00000000000..19794626809 --- /dev/null +++ b/tests/component_tests/ld2450/config/esp8266_zones.yaml @@ -0,0 +1,31 @@ +esphome: + name: test + +esp8266: + board: d1_mini + +uart: + tx_pin: GPIO0 + rx_pin: GPIO2 + baud_rate: 256000 + +ld2450: + id: radar + +text: + - platform: ld2450 + ld2450_id: radar + polygon_zones: + - id: couch + name: Couch + - id: desk + name: Desk + +binary_sensor: + - platform: ld2450 + ld2450_id: radar + polygon_zones: + - polygon_zone_id: couch + name: Couch Occupied + - polygon_zone_id: desk + name: Desk Occupied diff --git a/tests/component_tests/ld2450/test_polygon_pattern.py b/tests/component_tests/ld2450/test_polygon_pattern.py new file mode 100644 index 00000000000..4fb57ae6894 --- /dev/null +++ b/tests/component_tests/ld2450/test_polygon_pattern.py @@ -0,0 +1,64 @@ +"""Tests for the polygon zone text pattern and maximum length. + +Home Assistant validates polygon input with the pattern before it reaches the device, so the pattern must accept +what Polygon::parse() accepts. Coordinate ranges are the one exception: only the device checks those. +""" + +from __future__ import annotations + +import re + +import pytest + +from esphome.components.ld2450.text import ( + MAX_POLYGON_POINTS, + POLYGON_MAX_LENGTH, + POLYGON_PATTERN, +) + +# The same inputs as the C++ parser tests (ld2450_polygon.cpp), without out-of-range values +ACCEPTED = [ + "", + " ", + "-1000,500;1000,500;0,3000", + " -1000 , 500 ; 1000,500; 0,3000; ", + "-4860,0;4860,0;4860,7560", +] +REJECTED = [ + "0,0", + "0,0;100,100", + "junk", + "1,2;3", + "1,2;3,4;5,", + "1;2;3", + "1,2 3,4 5,6", + "1,2;;3,4;5,6", + "--1,2;3,4;5,6", + "1,2;3,4;5,6x", + "1,2;\t3,4;5,6", +] + + +@pytest.mark.parametrize("value", ACCEPTED) +def test_pattern_accepts_valid_polygons(value: str) -> None: + assert re.fullmatch(POLYGON_PATTERN, value) + + +@pytest.mark.parametrize("value", REJECTED) +def test_pattern_rejects_invalid_polygons(value: str) -> None: + assert not re.fullmatch(POLYGON_PATTERN, value) + + +def test_point_count_limits() -> None: + assert re.fullmatch(POLYGON_PATTERN, ";".join(["0,0"] * MAX_POLYGON_POINTS)) + assert not re.fullmatch( + POLYGON_PATTERN, ";".join(["0,0"] * (MAX_POLYGON_POINTS + 1)) + ) + + +def test_longest_polygon_fits_max_length() -> None: + longest = ";".join(["-4860,7560"] * MAX_POLYGON_POINTS) + assert re.fullmatch(POLYGON_PATTERN, longest) + assert len(longest) <= POLYGON_MAX_LENGTH + # MAX_POLYGON_POINTS is the most that always fits + assert len(longest + ";-4860,7560") > POLYGON_MAX_LENGTH diff --git a/tests/component_tests/ld2450/test_polygon_zone_presence.py b/tests/component_tests/ld2450/test_polygon_zone_presence.py new file mode 100644 index 00000000000..8ea8e3a67a0 --- /dev/null +++ b/tests/component_tests/ld2450/test_polygon_zone_presence.py @@ -0,0 +1,45 @@ +"""Tests for the ld2450 polygon zone presence binary sensor schema.""" + +from __future__ import annotations + +import pytest + +from esphome.components.ld2450 import CONF_POLYGON_ZONE_ID, CONF_POLYGON_ZONES +from esphome.components.ld2450.binary_sensor import ( + CONFIG_SCHEMA as BINARY_SENSOR_CONFIG_SCHEMA, +) +import esphome.config_validation as cv +from esphome.const import CONF_FILTERS, CONF_NAME, PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _presence(**extra: object) -> ConfigType: + return { + CONF_POLYGON_ZONES: [ + {CONF_POLYGON_ZONE_ID: "couch_zone", CONF_NAME: "Couch Occupied", **extra} + ] + } + + +def test_presence_without_filters_passes( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + + cv.Schema(BINARY_SENSOR_CONFIG_SCHEMA)(_presence()) + + +def test_presence_with_filters_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """Clearing a polygon bypasses filters, so they would get out of step with the zone.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises( + cv.Invalid, + match=r"^Polygon zone presence binary sensors can't have filters; .*presence_timeout", + ): + cv.Schema(BINARY_SENSOR_CONFIG_SCHEMA)( + _presence(**{CONF_FILTERS: [{"delayed_off": "1s"}]}) + ) diff --git a/tests/component_tests/ld2450/test_polygon_zones.py b/tests/component_tests/ld2450/test_polygon_zones.py new file mode 100644 index 00000000000..f63d3336782 --- /dev/null +++ b/tests/component_tests/ld2450/test_polygon_zones.py @@ -0,0 +1,163 @@ +"""Tests for the ld2450 polygon zone validation. + +Polygon zones are declared in the text platform and their presence sensors in the +binary sensor platform, so the pairing between the two is checked at final validation. +""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.esp8266.const import ( + KEY_ESP8266, + KEY_PREFERENCES, + preference_words, +) +from esphome.components.ld2450 import CONF_POLYGON_ZONE_ID, CONF_POLYGON_ZONES +from esphome.components.ld2450.text import ( + CONFIG_SCHEMA as TEXT_CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA as TEXT_FINAL_VALIDATE_SCHEMA, + POLYGON_SAVED_BYTES, +) +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_NAME, CONF_PLATFORM, PlatformFramework +from esphome.core import CORE, ID +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +HUB_ID = "ld2450_hub" +OTHER_HUB_ID = "ld2450_other" + + +def _text_config(*zone_ids: str) -> ConfigType: + """A text platform config declaring the given polygon zones.""" + return { + "ld2450_id": ID(HUB_ID, is_declaration=False, type="ld2450"), + CONF_POLYGON_ZONES: [ + {CONF_ID: ID(zone_id, is_declaration=True), CONF_NAME: zone_id} + for zone_id in zone_ids + ], + } + + +def _binary_sensors(*zone_ids: str, hub_id: str = HUB_ID) -> list[ConfigType]: + """A binary sensor platform list with one presence sensor per given zone reference.""" + return [ + { + CONF_PLATFORM: "ld2450", + "ld2450_id": ID(hub_id, is_declaration=False, type="ld2450"), + CONF_POLYGON_ZONES: [ + {CONF_POLYGON_ZONE_ID: ID(zone_id, is_declaration=False)} + for zone_id in zone_ids + ], + } + ] + + +def test_zones_paired_with_one_presence_pass( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={"binary_sensor": _binary_sensors("couch", "desk")}, + ) + + TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch", "desk")) + + +def test_zone_without_presence_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={"binary_sensor": _binary_sensors("couch")}, + ) + + with pytest.raises( + cv.Invalid, + match=r"^Polygon zone 'desk' has no presence binary sensor; .* @ data\['polygon_zones'\]\[1\]$", + ): + TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch", "desk")) + + +def test_zone_without_any_binary_sensor_platform_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, full_config={}) + + with pytest.raises(cv.Invalid, match="'couch' has no presence binary sensor"): + TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch")) + + +def test_zone_with_two_presences_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """Two references to one zone are rejected, even across binary sensor platform entries.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={ + "binary_sensor": _binary_sensors("couch") + _binary_sensors("couch") + }, + ) + + with pytest.raises( + cv.Invalid, + match=r"^Polygon zone 'couch' is used by more than one presence binary sensor; ", + ): + TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch")) + + +def test_other_platforms_are_ignored( + set_core_config: SetCoreConfigCallable, +) -> None: + """Binary sensors of other platforms never count as presence for a zone.""" + other = {CONF_PLATFORM: "template", CONF_POLYGON_ZONES: []} + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={"binary_sensor": [other, *_binary_sensors("couch")]}, + ) + + TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch")) + + +def test_zone_without_id_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """The presence sensor refers to the zone by id, so an automatic id is no use.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match=r"^'id' is required, so the zone's presence"): + TEXT_CONFIG_SCHEMA({CONF_POLYGON_ZONES: [{CONF_NAME: "Couch Zone Polygon"}]}) + + +def test_presence_on_another_ld2450_is_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """The zone's own LD2450 drives its presence, so the sensor must name the same one.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={"binary_sensor": _binary_sensors("couch", hub_id=OTHER_HUB_ID)}, + ) + + with pytest.raises( + cv.Invalid, + match=( + r"^Polygon zone 'couch' belongs to LD2450 'ld2450_hub', but its presence binary " + r"sensor is under LD2450 'ld2450_other'; .* @ data\['polygon_zones'\]\[0\]$" + ), + ): + TEXT_FINAL_VALIDATE_SCHEMA(_text_config("couch")) + + +def test_esp8266_zones_reserve_their_saved_space( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Every zone reserves its polygon, so the ESP8266 can check that all saved values fit together.""" + generate_main(component_config_path("esp8266_zones.yaml")) + + words = preference_words(POLYGON_SAVED_BYTES) + assert CORE.data[KEY_ESP8266][KEY_PREFERENCES] == [(words, False)] * 2 diff --git a/tests/components/ld2450/__init__.py b/tests/components/ld2450/__init__.py new file mode 100644 index 00000000000..a4f30dd1f7c --- /dev/null +++ b/tests/components/ld2450/__init__.py @@ -0,0 +1,9 @@ +from esphome.loader import FileResource +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # ld2450 is MULTI_CONF, so no codegen runs here to keep polygon.cpp; the tests need it + manifest.resources = manifest.resources + [ + FileResource("esphome.components.ld2450", "polygon.cpp") + ] diff --git a/tests/components/ld2450/common.yaml b/tests/components/ld2450/common.yaml index 617228ca34a..f0d5ecb04fd 100644 --- a/tests/components/ld2450/common.yaml +++ b/tests/components/ld2450/common.yaml @@ -96,6 +96,20 @@ binary_sensor: name: Moving Target has_still_target: name: Still Target + polygon_zones: + - polygon_zone_id: ld2450_couch_zone + name: Couch Occupied + - polygon_zone_id: ld2450_desk_zone + name: Desk Occupied + +text: + - platform: ld2450 + ld2450_id: ld2450_radar + polygon_zones: + - id: ld2450_couch_zone + name: Couch Zone Polygon + - id: ld2450_desk_zone + name: Desk Zone Polygon switch: - platform: ld2450 diff --git a/tests/components/ld2450/ld2450_polygon.cpp b/tests/components/ld2450/ld2450_polygon.cpp new file mode 100644 index 00000000000..b087e83d9c8 --- /dev/null +++ b/tests/components/ld2450/ld2450_polygon.cpp @@ -0,0 +1,162 @@ +#include +#include +#include "esphome/components/ld2450/polygon.h" + +namespace esphome::ld2450::testing { + +static bool parse(Polygon &polygon, const char *str) { return polygon.parse(str, strlen(str)); } + +static std::string format(const Polygon &polygon) { + char buf[POLYGON_STR_SIZE]; + polygon.format(buf); + return buf; +} + +// --- Parsing --- + +TEST(LD2450PolygonTest, ParseValid) { + Polygon polygon; + ASSERT_TRUE(parse(polygon, "-1000,500;1000,500;0,3000")); + ASSERT_EQ(polygon.count, 3); + EXPECT_EQ(polygon.points[0].x, -1000); + EXPECT_EQ(polygon.points[0].y, 500); + EXPECT_EQ(polygon.points[2].x, 0); + EXPECT_EQ(polygon.points[2].y, 3000); + EXPECT_EQ(format(polygon), "-1000,500;1000,500;0,3000"); +} + +TEST(LD2450PolygonTest, ParseAllowsSpacesAndTrailingSeparator) { + Polygon polygon; + ASSERT_TRUE(parse(polygon, " -1000 , 500 ; 1000,500; 0,3000; ")); + EXPECT_EQ(format(polygon), "-1000,500;1000,500;0,3000"); +} + +TEST(LD2450PolygonTest, ParseEmptyDisablesZone) { + Polygon polygon; + ASSERT_TRUE(parse(polygon, "-1000,500;1000,500;0,3000")); + ASSERT_TRUE(parse(polygon, "")); + EXPECT_TRUE(polygon.empty()); + ASSERT_TRUE(parse(polygon, "-1000,500;1000,500;0,3000")); + ASSERT_TRUE(parse(polygon, " ")); + EXPECT_TRUE(polygon.empty()); + EXPECT_EQ(format(polygon), ""); +} + +TEST(LD2450PolygonTest, ParseLimits) { + Polygon polygon; + EXPECT_TRUE(parse(polygon, "-4860,0;4860,0;4860,7560")); + EXPECT_FALSE(parse(polygon, "-4861,0;4860,0;4860,7560")); + EXPECT_FALSE(parse(polygon, "-4860,0;4861,0;4860,7560")); + EXPECT_FALSE(parse(polygon, "-4860,-1;4860,0;4860,7560")); + EXPECT_FALSE(parse(polygon, "-4860,0;4860,0;4860,7561")); + EXPECT_FALSE(parse(polygon, "-4860,0;4860,0;4860,99999999999")); +} + +TEST(LD2450PolygonTest, ParsePointCount) { + Polygon polygon; + EXPECT_FALSE(parse(polygon, "0,0")); + EXPECT_FALSE(parse(polygon, "0,0;100,100")); + + std::string text; + for (int i = 0; i < MAX_POLYGON_POINTS; i++) { + text += "-4860,7560;"; + } + ASSERT_TRUE(parse(polygon, text.c_str())); + EXPECT_EQ(polygon.count, MAX_POLYGON_POINTS); + // The longest possible polygon fits in the format buffer + EXPECT_EQ(format(polygon) + ";", text); + + text += "0,0"; + EXPECT_FALSE(parse(polygon, text.c_str())); +} + +TEST(LD2450PolygonTest, ParseInvalidKeepsPreviousPolygon) { + Polygon polygon; + ASSERT_TRUE(parse(polygon, "-1000,500;1000,500;0,3000")); + const char *invalid[] = { + "junk", + "1,2;3", + "1,2;3,4;5,", + "1;2;3", + "1,2 3,4 5,6", + "1,2;;3,4;5,6", + "--1,2;3,4;5,6", + "1,2;3,4;5,6x", + // Only spaces are allowed around numbers, as in the text entity's pattern + "1,2;\t3,4;5,6", + }; + for (const char *str : invalid) { + EXPECT_FALSE(parse(polygon, str)) << str; + EXPECT_EQ(format(polygon), "-1000,500;1000,500;0,3000") << str; + } +} + +TEST(LD2450PolygonTest, IsValid) { + Polygon polygon; + EXPECT_TRUE(polygon.is_valid()); + polygon.count = 2; + EXPECT_FALSE(polygon.is_valid()); + polygon.count = MAX_POLYGON_POINTS + 1; + EXPECT_FALSE(polygon.is_valid()); + polygon.count = 3; + EXPECT_TRUE(polygon.is_valid()); + polygon.points[1].y = -5; + EXPECT_FALSE(polygon.is_valid()); +} + +// --- Point in polygon --- + +TEST(LD2450PolygonTest, ContainsEmpty) { + Polygon polygon; + EXPECT_FALSE(polygon.contains(0, 0)); + EXPECT_FALSE(polygon.contains(100, 1000)); +} + +TEST(LD2450PolygonTest, ContainsSquare) { + Polygon polygon; + ASSERT_TRUE(parse(polygon, "-1000,1000;1000,1000;1000,3000;-1000,3000")); + EXPECT_TRUE(polygon.contains(0, 2000)); + EXPECT_TRUE(polygon.contains(-999, 1001)); + EXPECT_TRUE(polygon.contains(999, 2999)); + EXPECT_FALSE(polygon.contains(0, 999)); + EXPECT_FALSE(polygon.contains(0, 3001)); + EXPECT_FALSE(polygon.contains(-1001, 2000)); + EXPECT_FALSE(polygon.contains(1001, 2000)); +} + +TEST(LD2450PolygonTest, ContainsWithReversedWinding) { + Polygon clockwise; + Polygon counter_clockwise; + ASSERT_TRUE(parse(clockwise, "0,1000;2000,3000;-2000,3000")); + ASSERT_TRUE(parse(counter_clockwise, "-2000,3000;2000,3000;0,1000")); + for (int16_t x = -2500; x <= 2500; x += 250) { + for (int16_t y = 500; y <= 3500; y += 250) { + EXPECT_EQ(clockwise.contains(x, y), counter_clockwise.contains(x, y)) << x << "," << y; + } + } + EXPECT_TRUE(clockwise.contains(0, 2000)); + EXPECT_FALSE(clockwise.contains(1500, 2000)); +} + +TEST(LD2450PolygonTest, ContainsConcave) { + // U shape: the notch between the two arms is outside + Polygon polygon; + ASSERT_TRUE(parse(polygon, "-3000,1000;3000,1000;3000,4000;1000,4000;1000,2000;-1000,2000;-1000,4000;-3000,4000")); + EXPECT_TRUE(polygon.contains(-2000, 3000)); + EXPECT_TRUE(polygon.contains(2000, 3000)); + EXPECT_TRUE(polygon.contains(0, 1500)); + EXPECT_FALSE(polygon.contains(0, 3000)); + EXPECT_FALSE(polygon.contains(0, 4500)); +} + +TEST(LD2450PolygonTest, ContainsFarOutsideRadarArea) { + // The radar reports 15-bit coordinates; the cross products must not overflow + Polygon polygon; + ASSERT_TRUE(parse(polygon, "-4860,0;4860,0;4860,7560;-4860,7560")); + EXPECT_FALSE(polygon.contains(32767, 32767)); + EXPECT_FALSE(polygon.contains(-32767, -32767)); + EXPECT_FALSE(polygon.contains(-32767, 3000)); + EXPECT_TRUE(polygon.contains(0, 3000)); +} + +} // namespace esphome::ld2450::testing diff --git a/tests/integration/fixtures/uart_mock_ld2450_has_target_only.yaml b/tests/integration/fixtures/uart_mock_ld2450_has_target_only.yaml new file mode 100644 index 00000000000..df0b4ceb518 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2450_has_target_only.yaml @@ -0,0 +1,248 @@ +esphome: + name: uart-mock-ld2450-has-target + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2450's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 256000 + auto_start: false + responses: + # Generic ACK for any command, to unblock setup commands + - expect_tx: [0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, + 0x04, 0x00, + 0xFF, 0x01, + 0x00, 0x00, + 0x04, 0x03, 0x02, 0x01, + ] + + # One frame every 100 ms, as the radar sends them. Coordinates are sign-magnitude, with the high bit set for + # positive values. + injections: + # Target 1 at (-500, 1000) + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0xF4, 0x01, 0xE8, 0x83, 0x00, 0x00, 0x40, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # No targets for 2 s: has_target must hold for the default 5 s presence_timeout + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + +ld2450: + id: ld2450_dev + uart_id: mock_uart + +# Only has_target: no sensor platform and no presence_timeout number +binary_sensor: + - platform: ld2450 + ld2450_id: ld2450_dev + has_target: + name: "Has Target" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/fixtures/uart_mock_ld2450_polygon_zone_restore.yaml b/tests/integration/fixtures/uart_mock_ld2450_polygon_zone_restore.yaml new file mode 100644 index 00000000000..ad7aec4e8c0 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2450_polygon_zone_restore.yaml @@ -0,0 +1,55 @@ +esphome: + name: uart-mock-ld2450-zone-restore + +host: +api: + batch_delay: 0ms +logger: + level: DEBUG + +preferences: + flash_write_interval: 0s + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2450's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 256000 + responses: + # Generic ACK for any command, to unblock setup commands + - expect_tx: [0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, + 0x04, 0x00, + 0xFF, 0x01, + 0x00, 0x00, + 0x04, 0x03, 0x02, 0x01, + ] + +ld2450: + id: ld2450_dev + uart_id: mock_uart + +text: + - platform: ld2450 + ld2450_id: ld2450_dev + polygon_zones: + - id: restore_zone + name: "Restore Zone Polygon" + +binary_sensor: + - platform: ld2450 + ld2450_id: ld2450_dev + polygon_zones: + - polygon_zone_id: restore_zone + name: "Restore Zone Presence" diff --git a/tests/integration/fixtures/uart_mock_ld2450_polygon_zone_timeout.yaml b/tests/integration/fixtures/uart_mock_ld2450_polygon_zone_timeout.yaml new file mode 100644 index 00000000000..112e75f5ab7 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2450_polygon_zone_timeout.yaml @@ -0,0 +1,300 @@ +esphome: + name: uart-mock-ld2450-zone-timeout + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2450's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 256000 + auto_start: false + responses: + # Generic ACK for any command, to unblock setup commands + - expect_tx: [0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, + 0x04, 0x00, + 0xFF, 0x01, + 0x00, 0x00, + 0x04, 0x03, 0x02, 0x01, + ] + + # One frame every 100 ms, as the radar sends them. Target 1 is either at (-500, 1000), inside the zone, or at + # (300, 400), outside it. Coordinates are sign-magnitude, with the high bit set for positive values. + injections: + # Inside the zone + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0xF4, 0x01, 0xE8, 0x83, 0x00, 0x00, 0x40, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside for one frame: radar noise that presence_timeout must hide + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Inside again: the last inside frame + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0xF4, 0x01, 0xE8, 0x83, 0x00, 0x00, 0x40, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside: presence clears once presence_timeout passes + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + # Outside + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + +ld2450: + id: ld2450_dev + uart_id: mock_uart + +text: + - platform: ld2450 + ld2450_id: ld2450_dev + polygon_zones: + - id: hold_zone + name: "Hold Zone Polygon" + +binary_sensor: + - platform: ld2450 + ld2450_id: ld2450_dev + polygon_zones: + - polygon_zone_id: hold_zone + name: "Hold Zone Presence" + +number: + - platform: ld2450 + ld2450_id: ld2450_dev + presence_timeout: + name: "Presence Timeout" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/fixtures/uart_mock_ld2450_polygon_zones.yaml b/tests/integration/fixtures/uart_mock_ld2450_polygon_zones.yaml new file mode 100644 index 00000000000..5cc4784f814 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2450_polygon_zones.yaml @@ -0,0 +1,246 @@ +esphome: + name: uart-mock-ld2450-zones + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2450's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 256000 + auto_start: false + responses: + # Generic ACK for any command, to unblock setup commands + - expect_tx: [0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, + 0x04, 0x00, + 0xFF, 0x01, + 0x00, 0x00, + 0x04, 0x03, 0x02, 0x01, + ] + + injections: + # Frame 1: target 1 at (-500, 1000), target 2 at (200, 500) + # Coordinates are sign-magnitude, with the high bit set for positive values. + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0xF4, 0x01, 0xE8, 0x83, 0x00, 0x00, 0x40, 0x01, + 0xC8, 0x80, 0xF4, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + + # Frame 2: target 1 at (300, 400), no other targets + - delay: 200ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x2C, 0x81, 0x90, 0x81, 0x00, 0x00, 0x64, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + + # Frame 3: no targets + - delay: 200ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + + # No targets, frame after frame, until presence_timeout (1 s) has cleared every zone + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + - delay: 100ms + inject_rx: + [ + 0xAA, 0xFF, 0x03, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x55, 0xCC, + ] + +ld2450: + id: ld2450_dev + uart_id: mock_uart + +text: + - platform: ld2450 + ld2450_id: ld2450_dev + polygon_zones: + - id: left_zone + name: "Left Zone Polygon" + - id: near_zone + name: "Near Zone Polygon" + - id: unset_zone + name: "Unset Zone Polygon" + +binary_sensor: + - platform: ld2450 + ld2450_id: ld2450_dev + polygon_zones: + - polygon_zone_id: left_zone + name: "Left Zone Presence" + - polygon_zone_id: near_zone + name: "Near Zone Presence" + - polygon_zone_id: unset_zone + name: "Unset Zone Presence" + +number: + - platform: ld2450 + ld2450_id: ld2450_dev + presence_timeout: + name: "Presence Timeout" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: 'id(mock_uart).start_scenario();' diff --git a/tests/integration/test_uart_mock_ld2450_has_target_only.py b/tests/integration/test_uart_mock_ld2450_has_target_only.py new file mode 100644 index 00000000000..56e66ad65fb --- /dev/null +++ b/tests/integration/test_uart_mock_ld2450_has_target_only.py @@ -0,0 +1,64 @@ +"""Integration test for LD2450 has_target without the sensor and number platforms. + +Tests: +1. has_target turns on when only binary sensors are configured (target coordinates are still decoded) +2. has_target holds for the default presence_timeout of 5 s, without the presence_timeout number +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import BinarySensorInfo, BinarySensorState, ButtonInfo, EntityState +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +# The fixture sends one frame with a target, then 2 s of frames without one +SCENARIO_S = 2.1 + + +@pytest.mark.asyncio +async def test_uart_mock_ld2450_has_target_only( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test has_target with no sensor platform and no presence_timeout number.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + has_target = require_entity(entities, "has_target", BinarySensorInfo) + history: list[bool] = [] + turned_on = asyncio.get_running_loop().create_future() + + def on_state(state: EntityState) -> None: + if isinstance(state, BinarySensorState) and state.key == has_target.key: + history.append(state.state) + if state.state and not turned_on.done(): + turned_on.set_result(None) + + helper = InitialStateHelper(entities) + client.subscribe_states(helper.on_state_wrapper(on_state)) + await helper.wait_for_initial_states() + + start_btn = require_entity(entities, "start_scenario", ButtonInfo) + client.button_command(start_btn.key) + await asyncio.wait_for(turned_on, timeout=5.0) + await asyncio.sleep(SCENARIO_S) + + assert history == [True], ( + "has_target must turn on and hold through 2 s without targets, " + f"got {history}" + ) diff --git a/tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py b/tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py new file mode 100644 index 00000000000..2b25e65e190 --- /dev/null +++ b/tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py @@ -0,0 +1,117 @@ +"""Integration test for restoring LD2450 polygon zones from saved preferences. + +Tests: +1. A polygon set from the API is restored after a restart +2. A saved polygon that is no longer valid is reported and leaves the zone disabled +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +import socket +import struct + +from aioesphomeapi import TextInfo, TextState +import pytest + +from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client +from .host_prefs import clear_host_prefs, read_host_prefs, write_host_prefs +from .state_utils import InitialStateHelper, require_entity +from .types import CompileFunction, ConfigWriter + +DEVICE_NAME = "uart-mock-ld2450-zone-restore" +ZONE_POLYGON = "-1000,800;0,800;0,1500;-1000,1500" +# The saved Polygon starts with its points as little-endian int16 x, y pairs +SAVED_POLYGON_PREFIX = struct.pack(" None: + """Test that polygon zones are restored after a restart, and invalid saved polygons are rejected.""" + port, port_socket = reserved_tcp_port + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + clear_host_prefs(DEVICE_NAME) + + binary_path = await compile_esphome(await write_yaml_config(yaml_config)) + # Release the reserved port so the binary can bind to it + port_socket.close() + + async def initial_polygon(line_callback=None) -> str: + """Start the binary and return the zone's polygon state as first published.""" + async with ( + run_binary_and_wait_for_port( + binary_path, "127.0.0.1", port, line_callback=line_callback + ), + wait_and_connect_api_client(port=port) as client, + ): + entities, _ = await client.list_entities_services() + polygon = require_entity(entities, "restore_zone_polygon", TextInfo) + helper = InitialStateHelper(entities) + client.subscribe_states(helper.on_state_wrapper(lambda state: None)) + await helper.wait_for_initial_states() + state = helper.initial_states[polygon.key] + assert isinstance(state, TextState) + return state.state + + # First run: set a polygon, which is saved + async with ( + run_binary_and_wait_for_port(binary_path, "127.0.0.1", port), + wait_and_connect_api_client(port=port) as client, + ): + entities, _ = await client.list_entities_services() + polygon = require_entity(entities, "restore_zone_polygon", TextInfo) + loop = asyncio.get_running_loop() + published = loop.create_future() + + def on_state(state: object) -> None: + if ( + isinstance(state, TextState) + and state.key == polygon.key + and state.state == ZONE_POLYGON + and not published.done() + ): + published.set_result(None) + + helper = InitialStateHelper(entities) + client.subscribe_states(helper.on_state_wrapper(on_state)) + await helper.wait_for_initial_states() + assert helper.initial_states[polygon.key].state == "" + + client.text_command(polygon.key, ZONE_POLYGON) + await asyncio.wait_for(published, timeout=5.0) + + # Second run: the polygon is restored + assert await initial_polygon() == ZONE_POLYGON + + # Third run: a saved polygon with too few points is rejected + prefs = read_host_prefs(DEVICE_NAME) + keys = [key for key, data in prefs.items() if data.startswith(SAVED_POLYGON_PREFIX)] + assert len(keys) == 1, f"saved polygon not found in {prefs}" + data = bytearray(prefs[keys[0]]) + data[COUNT_OFFSET] = 2 + prefs[keys[0]] = bytes(data) + write_host_prefs(DEVICE_NAME, prefs) + + warnings: list[str] = [] + + def capture_warning(line: str) -> None: + if "saved polygon is invalid" in line: + warnings.append(line) + + assert await initial_polygon(capture_warning) == "" + assert warnings, "expected a warning about the invalid saved polygon" + + clear_host_prefs(DEVICE_NAME) diff --git a/tests/integration/test_uart_mock_ld2450_polygon_zone_timeout.py b/tests/integration/test_uart_mock_ld2450_polygon_zone_timeout.py new file mode 100644 index 00000000000..9aba119713f --- /dev/null +++ b/tests/integration/test_uart_mock_ld2450_polygon_zone_timeout.py @@ -0,0 +1,102 @@ +"""Integration test for LD2450 polygon zone presence with presence_timeout. + +Tests: +1. A target leaving the zone for a single radar frame does not clear presence +2. Presence clears once presence_timeout has passed since a target was last inside the zone +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from aioesphomeapi import ( + BinarySensorInfo, + BinarySensorState, + ButtonInfo, + EntityState, + NumberInfo, + NumberState, + TextInfo, + TextState, +) +import pytest + +from .host_prefs import clear_host_prefs +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +DEVICE_NAME = "uart-mock-ld2450-zone-timeout" +ZONE_POLYGON = "-1000,800;0,800;0,1500;-1000,1500" +PRESENCE_TIMEOUT_S = 1 + + +@pytest.mark.asyncio +async def test_uart_mock_ld2450_polygon_zone_timeout( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that polygon zone presence follows presence_timeout.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + # The polygon and presence_timeout are saved, so a previous run would otherwise restore them + clear_host_prefs(DEVICE_NAME) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + polygon = require_entity(entities, "hold_zone_polygon", TextInfo) + presence = require_entity(entities, "hold_zone_presence", BinarySensorInfo) + timeout = require_entity(entities, "presence_timeout", NumberInfo) + + loop = asyncio.get_running_loop() + # (time received, state) for each presence change + presence_history: list[tuple[float, bool]] = [] + cleared = loop.create_future() + polygon_set = loop.create_future() + timeout_set = loop.create_future() + + def on_state(state: EntityState) -> None: + if isinstance(state, BinarySensorState) and state.key == presence.key: + presence_history.append((loop.time(), state.state)) + if state.state is False and not cleared.done(): + cleared.set_result(None) + elif ( + isinstance(state, TextState) + and state.key == polygon.key + and state.state == ZONE_POLYGON + and not polygon_set.done() + ): + polygon_set.set_result(None) + elif ( + isinstance(state, NumberState) + and state.key == timeout.key + and state.state == PRESENCE_TIMEOUT_S + and not timeout_set.done() + ): + timeout_set.set_result(None) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + client.text_command(polygon.key, ZONE_POLYGON) + client.number_command(timeout.key, PRESENCE_TIMEOUT_S) + await asyncio.wait_for(asyncio.gather(polygon_set, timeout_set), timeout=5.0) + + start_btn = require_entity(entities, "start_scenario", ButtonInfo) + client.button_command(start_btn.key) + await asyncio.wait_for(cleared, timeout=5.0) + + # The one-frame blip outside never reaches HA + assert [state for _, state in presence_history] == [True, False] + # The last inside frame comes 200 ms after the first, then presence_timeout must pass + (on_time, _), (off_time, _) = presence_history + assert off_time - on_time >= PRESENCE_TIMEOUT_S diff --git a/tests/integration/test_uart_mock_ld2450_polygon_zones.py b/tests/integration/test_uart_mock_ld2450_polygon_zones.py new file mode 100644 index 00000000000..c77b7288e4c --- /dev/null +++ b/tests/integration/test_uart_mock_ld2450_polygon_zones.py @@ -0,0 +1,160 @@ +"""Integration test for LD2450 polygon zones with mock UART. + +Tests: +1. Polygons start empty and their presence sensors start unknown +2. A polygon set from the API is published back in canonical form +3. Invalid polygon text is rejected and the previous polygon is published again +4. Presence turns on and off as targets move in and out of each polygon +5. A zone without a polygon keeps presence unknown, and clearing a polygon makes it unknown again +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +import re + +from aioesphomeapi import ( + BinarySensorInfo, + BinarySensorState, + ButtonInfo, + EntityState, + NumberInfo, + NumberState, + TextInfo, + TextState, +) +import pytest + +from esphome.components.ld2450.text import POLYGON_MAX_LENGTH, POLYGON_PATTERN + +from .host_prefs import clear_host_prefs +from .state_utils import InitialStateHelper, StateWaiter, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + +DEVICE_NAME = "uart-mock-ld2450-zones" +LEFT_POLYGON = "-1000,800;0,800;0,1500;-1000,1500" +NEAR_POLYGON = "0,0;1000,0;1000,600;0,600" + + +@pytest.mark.asyncio +async def test_uart_mock_ld2450_polygon_zones( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test LD2450 polygon zone configuration and presence detection.""" + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + yaml_config = yaml_config.replace( + "EXTERNAL_COMPONENT_PATH", external_components_path + ) + # Polygons are saved, so a previous run would otherwise restore them + clear_host_prefs(DEVICE_NAME) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + polygons = { + name: require_entity(entities, f"{name}_zone_polygon", TextInfo) + for name in ("left", "near", "unset") + } + presences = { + name: require_entity(entities, f"{name}_zone_presence", BinarySensorInfo) + for name in ("left", "near", "unset") + } + presence_names = {info.key: name for name, info in presences.items()} + # None records presence going back to unknown + presence_history: dict[str, list[bool | None]] = { + name: [] for name in presences + } + waiter = StateWaiter() + expect = waiter.expect + + def on_state(state: EntityState) -> None: + if isinstance(state, BinarySensorState) and state.key in presence_names: + presence_history[presence_names[state.key]].append( + None if state.missing_state else state.state + ) + waiter.on_state(state) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + await initial_state_helper.wait_for_initial_states() + + for name, polygon in polygons.items(): + # Home Assistant validates input and the zone card finds zones with these + assert polygon.pattern == POLYGON_PATTERN + assert polygon.max_length == POLYGON_MAX_LENGTH + assert initial_state_helper.initial_states[polygon.key].state == "" + assert initial_state_helper.initial_states[ + presences[name].key + ].missing_state + + async def set_polygon(name: str, value: str, expected: str) -> None: + key = polygons[name].key + published = expect(lambda s: isinstance(s, TextState) and s.key == key) + client.text_command(key, value) + state = await published + assert state.state == expected + assert re.fullmatch(POLYGON_PATTERN, state.state) + + # Spaces and a trailing separator are accepted and removed + await set_polygon( + "left", f" {LEFT_POLYGON.replace(';', ' ; ')} ;", LEFT_POLYGON + ) + await set_polygon("near", NEAR_POLYGON, NEAR_POLYGON) + # Invalid text keeps the polygon in use + await set_polygon("near", "0,0;1000,0", NEAR_POLYGON) + await set_polygon("near", "junk", NEAR_POLYGON) + + # Zone presence follows presence_timeout; keep the scenario short + timeout = require_entity(entities, "presence_timeout", NumberInfo) + timeout_set = expect( + lambda s: ( + isinstance(s, NumberState) and s.key == timeout.key and s.state == 1 + ) + ) + client.number_command(timeout.key, 1) + await timeout_set + + left_cleared = expect( + lambda s: ( + isinstance(s, BinarySensorState) + and s.key == presences["left"].key + and s.state is False + ) + ) + near_cleared = expect( + lambda s: ( + isinstance(s, BinarySensorState) + and s.key == presences["near"].key + and s.state is False + ) + ) + + start_btn = require_entity(entities, "start_scenario", ButtonInfo) + client.button_command(start_btn.key) + + await asyncio.gather(left_cleared, near_cleared) + + # Frame 1: (-500, 1000) is in the left zone and (200, 500) is in the near zone. + # Frame 2: (300, 400) is in the near zone only; the left zone clears 1 s after frame 1. + # Frame 3 onwards: no targets; the near zone clears 1 s after frame 2. + assert presence_history["left"] == [True, False] + assert presence_history["near"] == [True, False] + assert presence_history["unset"] == [] + + left_unknown = expect( + lambda s: ( + isinstance(s, BinarySensorState) + and s.key == presences["left"].key + and s.missing_state + ) + ) + await set_polygon("left", "", "") + await left_unknown + assert presence_history["left"] == [True, False, None] From e869d1f7ab374ca862eba02964a042c4e87938ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 09:56:09 -1000 Subject: [PATCH 112/142] [ci] Give the BK72xx clang-tidy environment the static allocation flag the real build has (#20764) --- platformio.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/platformio.ini b/platformio.ini index 9e05356665a..fe61470a358 100644 --- a/platformio.ini +++ b/platformio.ini @@ -599,6 +599,8 @@ build_flags = ${flags:clangtidy.build_flags} -DUSE_BK72XX -DUSE_LIBRETINY_VARIANT_BK7231N + ; The libretiny component adds this at code generation so FreeRTOSQueue can use xQueueCreateStatic + -DconfigSUPPORT_STATIC_ALLOCATION=1 build_unflags = ${common.build_unflags} From 3c4cf50cb6b844e098e47d3d6b64fd55939a7fac Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:02:51 +0100 Subject: [PATCH 113/142] [core] Point compile errors after a lambda at the right line (#20740) Co-authored-by: J. Nick Koston --- esphome/core/__init__.py | 13 ++++++-- esphome/cpp_generator.py | 12 ++++--- esphome/writer.py | 23 +++++++++++-- esphome/yaml_util.py | 17 +++++++--- tests/unit_tests/test_cpp_generator.py | 45 ++++++++++++++++++++++++-- tests/unit_tests/test_writer.py | 30 ++++++++++++++++- tests/unit_tests/test_yaml_util.py | 18 +++++++++++ 7 files changed, 142 insertions(+), 16 deletions(-) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 61092ec4272..9b8f9f98642 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -442,6 +442,16 @@ class ID: ) +# Replaced by write_cpp with a #line back into main.cpp +LINE_DIRECTIVE_RESET = "// ESPHOME_LINE_DIRECTIVE_RESET" + + +def line_directive(document: str, line: int) -> str: + """A #line directive naming the 1-based ``line`` of ``document``.""" + escaped = document.replace("\\", "\\\\") + return f'#line {line} "{escaped}"' + + class DocumentLocation: def __init__(self, document: str, line: int, column: int): self.document: str = document @@ -457,8 +467,7 @@ class DocumentLocation: @property def as_line_directive(self): - document_path = str(self.document).replace("\\", "\\\\") - return f'#line {self.line + 1} "{document_path}"' + return line_directive(str(self.document), self.line + 1) class DocumentRange: diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 1196669662c..d0fc126957f 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -8,6 +8,7 @@ from typing import Any from esphome.core import ( CORE, ID, + LINE_DIRECTIVE_RESET, Define, EnumValue, EsphomeError, @@ -214,9 +215,13 @@ class LambdaExpression(Expression): if self.return_type is not None: cpp += f" -> {self.return_type}" cpp += " {\n" + body = f"{self.content}\n" if self.source is not None: - cpp += f"{self.source.as_line_directive}\n" - cpp += f"{self.content}\n}}" + body = f"{self.source.as_line_directive}\n{body}" + # A #line in the body, the source's or lvgl's own marks, needs the reset after it + if "#line " in body: + body += f"{LINE_DIRECTIVE_RESET}\n" + cpp += f"{body}}}" return indent_all_but_first_and_last(cpp) @property @@ -946,8 +951,7 @@ async def process_lambda( parts[i * 3 + 2] = "" if isinstance(value, ESPHomeDataBase) and value.esp_range is not None: - location = value.esp_range.start_mark - location.line += value.content_offset + location = value.content_start else: location = None return LambdaExpression(parts, parameters, capture, return_type, location) diff --git a/esphome/writer.py b/esphome/writer.py index 559d7d85828..934595ef7e8 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -15,7 +15,7 @@ from esphome.const import ( SOURCE_FILE_EXTENSIONS, __version__, ) -from esphome.core import CORE, EsphomeError +from esphome.core import CORE, LINE_DIRECTIVE_RESET, EsphomeError, line_directive from esphome.helpers import ( copy_file_if_changed, cpp_string_escape, @@ -514,6 +514,25 @@ const char ESPHOME_COMMENT_STR[] = "{escaped_comment}"; """ +def _reset_line_directives(text: str, path: Path) -> str: + """Point the code after each lambda back at its own line of *path*. + + A lambda's #line directive points the compiler at the YAML; without a reset + every later line of main.cpp would be reported against the YAML too. + """ + if LINE_DIRECTIVE_RESET not in text: + return text + document = str(path.absolute()) + lines = text.split("\n") + for index, line in enumerate(lines): + if line.endswith(LINE_DIRECTIVE_RESET): + # The directive numbers the line after it: index + 2, counted from 1 + lines[index] = line.replace( + LINE_DIRECTIVE_RESET, line_directive(document, index + 2) + ) + return "\n".join(lines) + + def write_cpp(code_s): path = CORE.relative_src_path("main.cpp") if path.is_file(): @@ -540,7 +559,7 @@ def write_cpp(code_s): f"{code_format[1] + CPP_AUTO_GENERATE_BEGIN}\n{code_s}{CPP_AUTO_GENERATE_END}" ) full_file += code_format[2] - write_file_if_changed(path, full_file) + write_file_if_changed(path, _reset_line_directives(full_file, path)) def clean_cmake_cache(): diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index 261d8b50e36..6b7e49d3802 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -29,6 +29,7 @@ from esphome.config_helpers import Extend, Remove from esphome.const import CONF_DEFAULTS from esphome.core import ( CORE, + DocumentLocation, DocumentRange, EsphomeError, Lambda, @@ -142,14 +143,20 @@ class ESPHomeDataBase: def content_offset(self): return getattr(self, "_content_offset", 0) + @property + def content_start(self) -> DocumentLocation: + """Where the value's own text starts: the line after the marker for a block scalar.""" + start = self._esp_range.start_mark + return DocumentLocation( + start.document, start.line + self.content_offset, start.column + ) + def from_node(self, node): # pylint: disable=attribute-defined-outside-init self._esp_range = DocumentRange.from_marks(node.start_mark, node.end_mark) - if ( - isinstance(node, yaml.ScalarNode) - and node.style is not None - and node.style in "|>" - ): + # Only block scalars (| and >) start their content on the next line. A plain + # scalar's style is "", which `in "|>"` would also match. + if isinstance(node, yaml.ScalarNode) and node.style in ("|", ">"): self._content_offset = 1 def from_database(self, database): diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index 9529e448008..ec88c583baa 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -1,16 +1,19 @@ from collections.abc import Iterator +import io import math +from pathlib import Path import pytest -from esphome import cpp_generator as cg, cpp_types as ct +from esphome import config_validation as cv, cpp_generator as cg, cpp_types as ct from esphome.const import ( KEY_CORE, KEY_TARGET_PLATFORM, PLATFORM_ESP32, PLATFORM_ESP8266, ) -from esphome.core import CORE, ID, EsphomeError +from esphome.core import CORE, ID, LINE_DIRECTIVE_RESET, DocumentLocation, EsphomeError +from esphome.yaml_util import parse_yaml class TestExpressions: @@ -244,6 +247,22 @@ class TestLambdaExpression: "[captured_var](int32_t x) -> int32_t {\n return captured_var + x;\n}" ) + def test_str__with_source_marks_end_of_body(self) -> None: + """A lambda from the YAML marks the end of its body for the line reset.""" + target = cg.LambdaExpression( + ("return 42;",), (), "", int, source=DocumentLocation("test.yaml", 4, 0) + ) + + actual = str(target) + + assert actual == ( + "[]() -> int32_t {\n" + ' #line 5 "test.yaml"\n' + " return 42;\n" + f" {LINE_DIRECTIVE_RESET}\n" + "}" + ) + class TestCallLambda: """Tests for the call_lambda() function.""" @@ -735,6 +754,28 @@ class TestProcessLambda: with pytest.raises(AssertionError): await cg.process_lambda(lambda_obj, [(int,)]) + def test_str__with_line_mark_in_body_marks_end_of_body(self) -> None: + """A #line written into the body, as lvgl does, gets the reset too.""" + target = cg.LambdaExpression(('#line 3 "test.yaml"\nfoo();',), (), "", None) + + actual = str(target) + + assert actual == ( + f'[]() {{\n #line 3 "test.yaml"\n foo();\n {LINE_DIRECTIVE_RESET}\n}}' + ) + + async def test_process_lambda__twice_keeps_line(self) -> None: + """Processing one lambda twice, as a YAML alias does, keeps its line.""" + config = parse_yaml(Path("test.yaml"), io.StringIO("x: |-\n return 42;\n")) + lambda_obj = cv.lambda_(config["x"]) + + first = await cg.process_lambda(lambda_obj, []) + second = await cg.process_lambda(lambda_obj, []) + + # The body of the block scalar is the second line, 1 counted from 0 + assert first.source.line == 1 + assert second.source.line == 1 + @pytest.mark.asyncio async def test_templatable__string_with_std_string_returns_flash_literal() -> None: diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index a1ae7f9ec77..67acfa6a189 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -22,7 +22,9 @@ from esphome.const import ( PLATFORM_RP2, PLATFORM_RTL87XX, ) -from esphome.core import EsphomeError +from esphome.core import DocumentLocation, EsphomeError, line_directive +from esphome.cpp_generator import LambdaExpression +from esphome.helpers import indent from esphome.storage_json import StorageJSON from esphome.writer import ( CPP_AUTO_GENERATE_BEGIN, @@ -965,6 +967,32 @@ def test_write_cpp_creates_new_file( assert "App.loop();" in written_content +@pytest.mark.usefixtures("mock_copy_src_tree") +@patch("esphome.writer.write_file_if_changed") +@patch("esphome.writer.CORE") +def test_write_cpp_resets_line_after_lambda( + mock_core: MagicMock, + mock_write_file: MagicMock, + tmp_path: Path, +) -> None: + """The code after a YAML lambda is reported against main.cpp again.""" + main_cpp = tmp_path / "main.cpp" + mock_core.relative_src_path.return_value = main_cpp + mock_core.cpp_global_section = "" + lambda_ = LambdaExpression( + ("foo();",), (), source=DocumentLocation("test.yaml", 4, 0) + ) + + write_cpp(indent(f"bar({lambda_});\nbaz();") + "\n") + + lines = mock_write_file.call_args[0][1].split("\n") + closing = lines.index(" });") + assert lines[closing - 3].strip() == '#line 5 "test.yaml"' + # The directive names the line after it, which is closing + 1 (1-based) + assert lines[closing - 1].strip() == line_directive(str(main_cpp), closing + 1) + assert lines[closing + 1] == " baz();" + + @pytest.mark.usefixtures("mock_copy_src_tree") @patch("esphome.writer.CORE") def test_write_cpp_with_missing_end_marker( diff --git a/tests/unit_tests/test_yaml_util.py b/tests/unit_tests/test_yaml_util.py index 655fac6bf26..2a4b5f8e5c5 100644 --- a/tests/unit_tests/test_yaml_util.py +++ b/tests/unit_tests/test_yaml_util.py @@ -2317,3 +2317,21 @@ def test_find_secret_references_in_flow_collections() -> None: """Flow mapping and sequence punctuation is not part of the secret name.""" text = "a: {password: !secret wifi_pw}\nb: [!secret one, !secret 'two']\n" assert yaml_util.find_secret_references(text) == {"wifi_pw", "one", "two"} + + +@pytest.mark.parametrize( + ("yaml_text", "expected_offset"), + [ + ("value: return 42;\n", 0), + ('value: "return 42;"\n', 0), + ("value: |-\n return 42;\n", 1), + ("value: >-\n return 42;\n", 1), + ], +) +def test_content_offset_only_for_block_scalars( + yaml_text: str, expected_offset: int +) -> None: + """A one-line lambda starts on its own line; only | and > move it to the next.""" + doc = yaml_util.parse_yaml(Path("test.yaml"), io.StringIO(yaml_text)) + assert doc["value"].content_offset == expected_offset + assert doc["value"].content_start.line == expected_offset From 1779b011c9bbb55b0d441f8296b740189487e4e4 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:21:45 +0000 Subject: [PATCH 114/142] Bump aioesphomeapi from 46.10.0 to 46.11.0 (#20766) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 5397959f63f..a247eb4ba15 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.2.0 esptool==5.5.0 click==8.5.0 -aioesphomeapi==46.10.0 +aioesphomeapi==46.11.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 From 6d173c7371a00f76e39d1f46faaa5cdd2ec274d5 Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:35:01 +0100 Subject: [PATCH 115/142] [bq25186] Set i2c_id in the component tests (#20742) --- tests/components/bq25186/common.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/bq25186/common.yaml b/tests/components/bq25186/common.yaml index 295a68d262b..916fedec3d1 100644 --- a/tests/components/bq25186/common.yaml +++ b/tests/components/bq25186/common.yaml @@ -12,6 +12,7 @@ esphome: bq25186: - id: bq25186_charger + i2c_id: i2c_bus configure_on_boot: false fast_charge_current: 150mA battery_regulation_voltage: 4.2V @@ -20,6 +21,7 @@ bq25186: charge_enabled: true # Second charger with the boot-time write enabled - id: bq25186_boot + i2c_id: i2c_bus address: 0x6B configure_on_boot: true fast_charge_current: 20mA From a4278b8decaa087f593f4a50418e8b2167e2df31 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 10:35:33 -1000 Subject: [PATCH 116/142] [ci] Run benchmarks when a component the Python import benchmark imports changes (#20763) --- script/determine-jobs.py | 68 ++++++++++++++++++++++++ tests/script/test_determine_jobs.py | 82 +++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 9d146c94a0b..e1483446d4d 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -49,6 +49,7 @@ Options: from __future__ import annotations import argparse +import ast from collections import Counter from collections.abc import Callable from enum import StrEnum @@ -58,6 +59,7 @@ import math import os from pathlib import Path import statistics +import subprocess import sys from typing import Any @@ -69,6 +71,7 @@ from clang_tidy_hash import ( ) from helpers import ( CPP_FILE_EXTENSIONS, + ESPHOME_COMPONENTS_PATH, ESPHOME_TESTS_COMPONENTS_PATH, INTEGRATION_TESTS_PATH, PYTHON_FILE_EXTENSIONS, @@ -805,6 +808,9 @@ def determine_cpp_unit_tests( # Paths within tests/benchmarks/ that contain component benchmark files BENCHMARKS_COMPONENTS_PATH = "tests/benchmarks/components" +# The Python benchmark that imports components; its COMPONENTS tuple says which ones +PYTHON_IMPORT_BENCHMARK_FILE = "tests/benchmarks/python/test_component_import_bench.py" + # Files that, when changed, should trigger benchmark runs BENCHMARK_INFRASTRUCTURE_FILES = frozenset( { @@ -815,6 +821,53 @@ BENCHMARK_INFRASTRUCTURE_FILES = frozenset( ) +def _benchmark_components_tuple(path: Path) -> frozenset[str]: + """Return the benchmark file's ``COMPONENTS`` tuple. + + Raises when the file has none, so renaming it fails CI instead of quietly + turning the trigger off. + """ + for node in ast.parse(path.read_text(encoding="utf-8")).body: + if isinstance(node, ast.Assign) and any( + isinstance(t, ast.Name) and t.id == "COMPONENTS" for t in node.targets + ): + return frozenset(ast.literal_eval(node.value)) + raise RuntimeError(f"{PYTHON_IMPORT_BENCHMARK_FILE} has no COMPONENTS tuple") + + +# Imports the given components and prints every component whose module got loaded +_IMPORT_PROBE = ( + "import importlib, sys\n" + "for c in sys.argv[1:]: importlib.import_module('esphome.components.' + c)\n" + "print(*{n.split('.')[2] for n in sys.modules if n.startswith('esphome.components.')})" +) + + +def python_import_benchmarked_components() -> frozenset[str] | None: + """Return the components loaded by importing the benchmark's ``COMPONENTS``. + + Imported in a fresh interpreter from the checkout, so the set is exactly what + the benchmark times. None when the import fails. + """ + path = Path(root_path) / PYTHON_IMPORT_BENCHMARK_FILE + if not path.is_file(): + return frozenset() + result = subprocess.run( + [sys.executable, "-c", _IMPORT_PROBE, *_benchmark_components_tuple(path)], + cwd=root_path, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + print( + f"Benchmark import probe failed, running benchmarks:\n{result.stderr}", + file=sys.stderr, + ) + return None + return frozenset(result.stdout.split()) + + def should_run_benchmarks(branch: str | None = None) -> bool: """Determine if benchmarks (C++ and Python) should run based on changed files. @@ -831,6 +884,8 @@ def should_run_benchmarks(branch: str | None = None) -> bool: 4. A directly changed component has benchmark files (no dependency expansion) 5. Benchmark infrastructure changed (tests/benchmarks/*, script/cpp_benchmark.py, script/build_helpers.py, script/setup_codspeed_lib.py) + 6. A Python file changed in a component that importing the Python import + benchmark's ``COMPONENTS`` loads, even if it has no C++ benchmark Unlike unit tests, benchmarks do NOT expand to dependent components. Changing ``sensor`` does not trigger ``api`` benchmarks just because @@ -862,6 +917,19 @@ def should_run_benchmarks(branch: str | None = None) -> bool: ): return True + # The Python import benchmark times importing its components and what they pull + # in, so their Python sources move its numbers even without a C++ benchmark. + # A failed import runs the benchmarks rather than risk skipping them. + changed_python = { + get_component_from_path(f) + for f in files + if f.startswith(ESPHOME_COMPONENTS_PATH) and f.endswith(PYTHON_FILE_EXTENSIONS) + } + if changed_python: + loaded = python_import_benchmarked_components() + if loaded is None or changed_python & loaded: + return True + # Check if any directly changed component has benchmarks benchmarks_dir = Path(root_path) / BENCHMARKS_COMPONENTS_PATH if not benchmarks_dir.is_dir(): diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index e63e0849deb..6b63712f2a4 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -4,6 +4,7 @@ from collections.abc import Generator import importlib.util import json from pathlib import Path +import subprocess import sys from unittest.mock import Mock, patch @@ -2784,6 +2785,87 @@ def test_should_run_benchmarks_core_benchmark_file_change() -> None: assert determine_jobs.should_run_benchmarks() is True +def test_python_import_benchmarked_components_imports_real_components() -> None: + """The set is what importing the real benchmark's components loads.""" + loaded = determine_jobs.python_import_benchmarked_components() + # mqtt is not listed in COMPONENTS; sensor imports it at module scope + assert {"binary_sensor", "sensor", "logger", "mqtt"} <= loaded + + +def test_should_run_benchmarks_python_import_benchmarked_component() -> None: + """A Python change in a component the import benchmark loads runs benchmarks. + + logger has no C++ benchmark, so before this a change to its __init__.py + skipped the Python import benchmark that times it. + """ + for changed, expected in [ + ("esphome/components/logger/__init__.py", True), + ("esphome/components/logger/select/__init__.py", True), + ("esphome/components/mqtt/__init__.py", True), + ("esphome/components/logger/logger.cpp", False), + ("tests/components/logger/common.yaml", False), + ("esphome/components/wifi/__init__.py", False), + ]: + with ( + patch.object(determine_jobs, "changed_files", return_value=[changed]), + patch.object( + determine_jobs, + "python_import_benchmarked_components", + return_value=frozenset({"logger", "mqtt"}), + ), + ): + assert determine_jobs.should_run_benchmarks() is expected, changed + + +def test_should_run_benchmarks_when_import_probe_fails() -> None: + """A component import that fails runs the benchmarks instead of skipping them.""" + with ( + patch.object( + determine_jobs, + "changed_files", + return_value=["esphome/components/wifi/__init__.py"], + ), + patch.object( + determine_jobs, "python_import_benchmarked_components", return_value=None + ), + ): + assert determine_jobs.should_run_benchmarks() is True + + +def test_python_import_benchmarked_components_logs_probe_failure( + capsys: pytest.CaptureFixture[str], +) -> None: + """A failed probe returns None and prints why on stderr, never stdout.""" + failed = subprocess.CompletedProcess( + args=[], returncode=1, stdout="", stderr="ModuleNotFoundError: boom" + ) + with patch.object(determine_jobs.subprocess, "run", return_value=failed): + assert determine_jobs.python_import_benchmarked_components() is None + captured = capsys.readouterr() + assert "ModuleNotFoundError: boom" in captured.err + assert captured.out == "" + + +def test_python_import_benchmarked_components_missing_file(tmp_path: Path) -> None: + """Without the benchmark file no component is treated as benchmarked.""" + with patch.object(determine_jobs, "root_path", str(tmp_path)): + assert determine_jobs.python_import_benchmarked_components() == frozenset() + + +def test_python_import_benchmarked_components_requires_components( + tmp_path: Path, +) -> None: + """A benchmark file without COMPONENTS fails loudly instead of disabling the rule.""" + bench = tmp_path / determine_jobs.PYTHON_IMPORT_BENCHMARK_FILE + bench.parent.mkdir(parents=True) + bench.write_text('NAMES = ("logger",)\n') + with ( + patch.object(determine_jobs, "root_path", str(tmp_path)), + pytest.raises(RuntimeError, match="COMPONENTS"), + ): + determine_jobs.python_import_benchmarked_components() + + def test_should_run_benchmarks_benchmarked_component_change(tmp_path: Path) -> None: """Test benchmarks trigger when a benchmarked component changes.""" # Create a fake benchmarks directory with an 'api' component From c56bb140379962d69d6f02e8a683a64bdfef40a5 Mon Sep 17 00:00:00 2001 From: AndreKR Date: Wed, 14 Oct 2026 22:39:55 +0200 Subject: [PATCH 117/142] [mqtt] Print crash info over MQTT (#17935) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/api/api_connection.cpp | 12 +++ esphome/components/api/api_connection.h | 25 +---- esphome/components/esp32/__init__.py | 1 + esphome/components/esp32/crash_handler.cpp | 5 +- esphome/components/esp32/crash_handler.h | 3 + esphome/components/esp8266/__init__.py | 1 + esphome/components/logger/logger.cpp | 2 + esphome/components/logger/logger.h | 10 ++ esphome/components/logger/logger_esp32.cpp | 11 ++ esphome/components/logger/logger_esp8266.cpp | 5 + esphome/components/logger/logger_rp2.cpp | 5 + esphome/components/mqtt/mqtt_client.cpp | 50 +++++++-- esphome/components/mqtt/mqtt_client.h | 11 ++ esphome/components/rp2/__init__.py | 1 + esphome/core/defines.h | 1 + esphome/mqtt.py | 38 +++++-- tests/unit_tests/test_mqtt.py | 107 ++++++++++++++++++- 17 files changed, 246 insertions(+), 42 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index fd51aae1be8..ca2397b5d78 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1,4 +1,7 @@ #include "api_connection.h" +#if defined(USE_LOGGER) && defined(USE_CRASH_HANDLER) +#include "esphome/components/logger/logger.h" +#endif #ifdef USE_API #include "api_connection_buffer.h" // for the APIServer-dependent APIConnection inlines #ifdef USE_API_NOISE @@ -451,6 +454,15 @@ bool APIConnection::send_disconnect_response_() { DisconnectResponse resp; return this->send_message(resp); } +void APIConnection::on_subscribe_logs_request(const SubscribeLogsRequest &msg) { + this->flags_.log_subscription = msg.level; + if (msg.dump_config) + App.schedule_dump_config(); +#if defined(USE_LOGGER) && defined(USE_CRASH_HANDLER) + logger::log_crash_to_client(); +#endif +} + void APIConnection::on_disconnect_response() { // Don't close socket here, let APIServer::loop() do it // so getpeername() still works for the disconnect trigger diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 9f784185b83..863e378a5e5 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -15,15 +15,6 @@ #include "subscribe_state.h" #include "esphome/core/application.h" #include "esphome/core/component.h" -#ifdef USE_ESP32_CRASH_HANDLER -#include "esphome/components/esp32/crash_handler.h" -#endif -#ifdef USE_RP2_CRASH_HANDLER -#include "esphome/components/rp2/crash_handler.h" -#endif -#ifdef USE_ESP8266_CRASH_HANDLER -#include "esphome/components/esp8266/crash_handler.h" -#endif #include "esphome/core/entity_base.h" #include "esphome/core/log.h" #include "esphome/core/string_ref.h" @@ -298,21 +289,7 @@ class APIConnection final : public APIServerConnectionBase { this->begin_iterator_(ActiveIterator::INITIAL_STATE); } } - void on_subscribe_logs_request(const SubscribeLogsRequest &msg) { - this->flags_.log_subscription = msg.level; - if (msg.dump_config) - App.schedule_dump_config(); -#ifdef USE_ESP32_CRASH_HANDLER - esp32::crash_handler_log(); - esp32::crash_handler_clear(); -#endif -#ifdef USE_RP2_CRASH_HANDLER - rp2::crash_handler_log(); -#endif -#ifdef USE_ESP8266_CRASH_HANDLER - esp8266::crash_handler_log(); -#endif - } + void on_subscribe_logs_request(const SubscribeLogsRequest &msg); #ifdef USE_API_HOMEASSISTANT_SERVICES void on_subscribe_homeassistant_services_request() { this->flags_.service_call_subscription = true; } #endif diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 422413a269f..25a00b522c0 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2920,6 +2920,7 @@ async def _finalize_arduino_aware_flags(): if not arduino_linked: cg.add_build_flag("-Wl,--wrap=esp_panic_handler") cg.add_define("USE_ESP32_CRASH_HANDLER") + cg.add_define("USE_CRASH_HANDLER") elif not CORE.using_arduino: cg.add_build_flag("-DUSE_ARDUINO") diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 182bc5f2d6c..bc28dde8d63 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -161,7 +161,7 @@ static bool s_crash_data_valid = false; // NOLINT(cppcoreguidelines-avoid-non-c namespace esphome::esp32 { -ESPHOME_LOG_TAG(TAG, "esp32.crash"); +ESPHOME_LOG_TAG(TAG, CRASH_LOG_TAG); // RAM copy of the build timestamp. The generated constant lives in flash, // which the panic handler must not read (cache may be disabled during @@ -175,7 +175,8 @@ static uint32_t s_current_build_time = static_cast(ESPHOME_BUILD_TIME) // Validate the NOINIT record. Runs on every has_data() call; re-running is // harmless and the magic is left alone so the record survives an OTA -// rollback reboot, crash_handler_clear() drops it once an API client has it. +// rollback reboot, crash_handler_clear() drops it once a log client has it +// (logger::log_crash_to_client(), for the API and MQTT). static void read_crash_data() { if (s_raw_crash_data.magic == CRASH_MAGIC && s_raw_crash_data.version == CRASH_DATA_VERSION) { s_crash_data_valid = true; diff --git a/esphome/components/esp32/crash_handler.h b/esphome/components/esp32/crash_handler.h index 314be80314c..e145a4a6032 100644 --- a/esphome/components/esp32/crash_handler.h +++ b/esphome/components/esp32/crash_handler.h @@ -4,6 +4,9 @@ namespace esphome::esp32 { +/// The tag the crash record is logged under, so a client can check the level in force for it +inline constexpr const char *CRASH_LOG_TAG = "esp32.crash"; + /// Log crash data if a crash was detected on previous boot. void crash_handler_log(); diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 27c6fb1958d..09ced18fd67 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -420,6 +420,7 @@ async def to_code(config: ConfigType) -> None: cg.add_define("ESPHOME_VARIANT", "ESP8266") cg.add_define(ThreadModel.SINGLE) cg.add_define("USE_ESP8266_CRASH_HANDLER") + cg.add_define("USE_CRASH_HANDLER") enable_scanf_float = config.get(CONF_ENABLE_SCANF_FLOAT) if enable_scanf_float is None and lambdas_use_scanf_float(CORE.config): diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 24a6586ae2f..ac7bfef48af 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -142,6 +142,8 @@ void Logger::log_vprintf_(uint8_t level, const char *tag, int line, const __Flas } #endif // USE_ESP8266 +uint8_t Logger::get_log_level(const char *tag) { return this->level_for(tag); } + inline uint8_t Logger::level_for(const char *tag) { #ifdef USE_LOGGER_RUNTIME_TAG_LEVELS // Skip the lookup when no per-tag levels are set diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index b82aaa7c219..475ec93e81f 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -178,6 +178,8 @@ class Logger final : public Component { void set_log_level(const char *tag, uint8_t log_level); #endif uint8_t get_log_level() { return this->current_level_; } + /// The level in force for a tag: its own when one is set, else the default + uint8_t get_log_level(const char *tag); // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) @@ -511,6 +513,14 @@ class LoggerMessageTrigger final : public Triggerget_log_level(esp32::CRASH_LOG_TAG) < ESPHOME_LOG_LEVEL_ERROR) + return; + esp32::crash_handler_log(); + esp32::crash_handler_clear(); +} +#endif + } // namespace esphome::logger #endif diff --git a/esphome/components/logger/logger_esp8266.cpp b/esphome/components/logger/logger_esp8266.cpp index 6822e8f590f..418ab3d95d9 100644 --- a/esphome/components/logger/logger_esp8266.cpp +++ b/esphome/components/logger/logger_esp8266.cpp @@ -48,6 +48,11 @@ const LogString *Logger::get_uart_selection_() { #endif } +#ifdef USE_CRASH_HANDLER +bool crash_log_pending() { return esp8266::crash_handler_has_data(); } +void log_crash_to_client() { esp8266::crash_handler_log(); } +#endif + } // namespace esphome::logger #if !defined(USE_ESP8266_LOGGER_SERIAL) && !defined(USE_ESP8266_LOGGER_SERIAL1) diff --git a/esphome/components/logger/logger_rp2.cpp b/esphome/components/logger/logger_rp2.cpp index 518a1d7b1d2..6d190df5cdb 100644 --- a/esphome/components/logger/logger_rp2.cpp +++ b/esphome/components/logger/logger_rp2.cpp @@ -49,5 +49,10 @@ const LogString *Logger::get_uart_selection_() { } } +#ifdef USE_CRASH_HANDLER +bool crash_log_pending() { return rp2::crash_handler_has_data(); } +void log_crash_to_client() { rp2::crash_handler_log(); } +#endif + } // namespace esphome::logger #endif // USE_RP2 diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 803f486333a..297a94d2a97 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -49,6 +49,13 @@ MQTTClientComponent::MQTTClientComponent() { } // Connection +// Room for "esphome//" with the longest kind, "discover" +static constexpr size_t ESPHOME_TOPIC_BUFFER_SIZE = sizeof("esphome/discover/") + ESPHOME_DEVICE_NAME_MAX_LEN; + +static void format_esphome_topic(char (&topic)[ESPHOME_TOPIC_BUFFER_SIZE], const char *kind) { + buf_append_printf(topic, sizeof(topic), 0, "esphome/%s/%s", kind, App.get_name().c_str()); +} + void MQTTClientComponent::setup() { this->mqtt_backend_.set_on_message( [this](const char *topic, const char *payload, size_t len, size_t index, size_t total) { @@ -78,6 +85,13 @@ void MQTTClientComponent::setup() { this, [](void *self, uint8_t level, const char *tag, const char *message, size_t message_len) { static_cast(self)->on_log(level, tag, message, message_len); }); +#ifdef USE_MQTT_CRASH_LOG + // `esphome logs` announces itself here, like an API client's log subscription, and the record stays + // loggable until the next boot, so the offer does too. A log topic that drops errors would swallow the + // replay, so it is not offered. The topic is rebuilt from the device name when needed, so nothing is + // stored for it. + this->crash_log_offered_ = logger::crash_log_pending() && this->log_level_ >= ESPHOME_LOG_LEVEL_ERROR; +#endif } #endif @@ -87,10 +101,8 @@ void MQTTClientComponent::setup() { 2); // Format topic on stack - subscribe() copies it - // "esphome/ping/" (13) + name (ESPHOME_DEVICE_NAME_MAX_LEN) + null (1) - constexpr size_t ping_topic_buffer_size = 13 + ESPHOME_DEVICE_NAME_MAX_LEN + 1; - char ping_topic[ping_topic_buffer_size]; - buf_append_printf(ping_topic, sizeof(ping_topic), 0, "esphome/ping/%s", App.get_name().c_str()); + char ping_topic[ESPHOME_TOPIC_BUFFER_SIZE]; + format_esphome_topic(ping_topic, "ping"); this->subscribe( ping_topic, [this](const std::string &topic, const std::string &payload) { this->send_device_info_(); }, 2); } @@ -105,10 +117,8 @@ void MQTTClientComponent::send_device_info_() { return; } // Format topic on stack to avoid heap allocation - // "esphome/discover/" (17) + name (ESPHOME_DEVICE_NAME_MAX_LEN) + null (1) - constexpr size_t topic_buffer_size = 17 + ESPHOME_DEVICE_NAME_MAX_LEN + 1; - char topic[topic_buffer_size]; - buf_append_printf(topic, sizeof(topic), 0, "esphome/discover/%s", App.get_name().c_str()); + char topic[ESPHOME_TOPIC_BUFFER_SIZE]; + format_esphome_topic(topic, "discover"); // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson this->publish_json( @@ -354,6 +364,14 @@ void MQTTClientComponent::check_connected() { delay(100); // NOLINT this->resubscribe_subscriptions_(); +#ifdef USE_MQTT_CRASH_LOG + // Best effort: a refused subscription is tried again at the next connection + if (this->crash_log_offered_) { + char topic[ESPHOME_TOPIC_BUFFER_SIZE]; + format_esphome_topic(topic, "logs"); + this->subscribe_(topic, 0); + } +#endif this->send_device_info_(); for (MQTTComponent *component : this->children_) @@ -461,6 +479,19 @@ void MQTTClientComponent::resubscribe_subscriptions_() { } } +#ifdef USE_MQTT_CRASH_LOG +void MQTTClientComponent::handle_crash_log_request_(const std::string &topic, const std::string &payload) { + // Only the CLI's empty request counts: a log_topic pointing at this topic would otherwise replay the crash + // log for every line it logs + if (!this->crash_log_offered_ || !payload.empty()) + return; + char logs_topic[ESPHOME_TOPIC_BUFFER_SIZE]; + format_esphome_topic(logs_topic, "logs"); + if (topic == logs_topic) + logger::log_crash_to_client(); +} +#endif + void MQTTClientComponent::add_subscription_(std::string &&topic, mqtt_subscription_callback_t callback, bool boxed, uint8_t qos) { auto &subscription = this->subscriptions_.emplace_back(MQTTSubscription{ @@ -658,6 +689,9 @@ void MQTTClientComponent::on_message(const std::string &topic, const std::string // DO NOT REMOVE THIS DEFER without understanding the above. It may appear to work // in simple tests but will cause crashes with complex automations. this->defer([this, topic, payload]() { +#endif +#ifdef USE_MQTT_CRASH_LOG + this->handle_crash_log_request_(topic, payload); #endif // Indexed with the count taken up front: a callback may subscribe, which can reallocate the // vector, and the new entry waits for the next message. diff --git a/esphome/components/mqtt/mqtt_client.h b/esphome/components/mqtt/mqtt_client.h index 30edb812975..1de100019e3 100644 --- a/esphome/components/mqtt/mqtt_client.h +++ b/esphome/components/mqtt/mqtt_client.h @@ -24,6 +24,11 @@ #include +// The crash log replay needs the logger, which mqtt does not depend on +#if defined(USE_LOGGER) && defined(USE_CRASH_HANDLER) +#define USE_MQTT_CRASH_LOG +#endif + namespace esphome::mqtt { /** Callback for MQTT events. @@ -316,6 +321,9 @@ class MQTTClientComponent final : public Component { callback(topic, doc.as()); } void erase_removed_subscriptions_(); +#ifdef USE_MQTT_CRASH_LOG + void handle_crash_log_request_(const std::string &topic, const std::string &payload); +#endif void add_subscription_(std::string &&topic, mqtt_subscription_callback_t callback, bool boxed, uint8_t qos); void resubscribe_subscriptions_(); @@ -359,6 +367,9 @@ class MQTTClientComponent final : public Component { bool dns_resolved_{false}; bool dns_resolve_error_{false}; bool enable_on_boot_{true}; +#ifdef USE_MQTT_CRASH_LOG + bool crash_log_offered_{false}; +#endif std::vector children_; uint32_t reboot_timeout_{300000}; uint32_t connect_begin_; diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index d46858d3977..1c74c8d5ac3 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -392,6 +392,7 @@ async def to_code(config: ConfigType) -> None: "USE_RP2040_WATCHDOG_TIMEOUT", config[CONF_WATCHDOG_TIMEOUT] ) # back-compat alias cg.add_define("USE_RP2_CRASH_HANDLER") + cg.add_define("USE_CRASH_HANDLER") cg.add_define("USE_RP2040_CRASH_HANDLER") # back-compat alias _configure_lwip() diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 6075fc93797..3d14494df7a 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -58,6 +58,7 @@ #define USE_CLIMATE_VISUAL_OVERRIDES #define USE_CONTROLLER_REGISTRY #define USE_COVER +#define USE_CRASH_HANDLER #define USE_DATETIME #define USE_DATETIME_DATE #define USE_DATETIME_DATETIME diff --git a/esphome/mqtt.py b/esphome/mqtt.py index 62deafb09a2..6f0426e4f49 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING import paho.mqtt.client as mqtt from esphome.const import ( + CONF_BIRTH_MESSAGE, CONF_BROKER, CONF_CERTIFICATE_AUTHORITY, CONF_CLIENT_CERTIFICATE, @@ -21,7 +22,9 @@ from esphome.const import ( CONF_MQTT, CONF_NAME, CONF_PASSWORD, + CONF_PAYLOAD, CONF_PORT, + CONF_RETAIN, CONF_SKIP_CERT_CN_CHECK, CONF_TOPIC, CONF_TOPIC_PREFIX, @@ -319,13 +322,36 @@ def show_logs(config, topic=None, username=None, password=None, client_id=None): return 1 _LOGGER.info("Starting log output from %s", topic) - def on_message(client, userdata, msg): - time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]") - payload = msg.payload.decode(errors="backslashreplace") - message = time_ + payload - safe_print(message) + logs_topic = f"esphome/logs/{config[CONF_ESPHOME][CONF_NAME]}" + # Ask when the retained birth message reports online: a request sent while the device is down is + # lost, and only a retained message tells a CLI that connects later that the device is already up. + # An empty payload deletes the retained message instead, so it counts as no birth message. + birth = config.get(CONF_MQTT, {}).get(CONF_BIRTH_MESSAGE) or {} + birth_payload = birth.get(CONF_PAYLOAD) + birth_topic = birth.get(CONF_TOPIC) if birth_payload else None + ask_on_connect = birth_topic is None or not birth[CONF_RETAIN] - return initialize(config, [topic], on_message, None, username, password, client_id) + def request_crash_log(client): + _LOGGER.debug("Requesting the crash log via topic: %s", logs_topic) + client.publish(logs_topic, None, retain=False) + + def on_message(client, userdata, msg): + payload = msg.payload.decode(errors="backslashreplace") + if msg.topic == birth_topic and payload == birth_payload: + request_crash_log(client) + # The birth topic may sit inside the log subscription, so print by what was asked for + if mqtt.topic_matches_sub(topic, msg.topic): + time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]") + safe_print(time_ + payload) + + def on_connect(client, userdata, flags, return_code): + if ask_on_connect: + request_crash_log(client) + + subscriptions = [topic] if birth_topic is None else [topic, birth_topic] + return initialize( + config, subscriptions, on_message, on_connect, username, password, client_id + ) def clear_topic(config, topic, username=None, password=None, client_id=None): diff --git a/tests/unit_tests/test_mqtt.py b/tests/unit_tests/test_mqtt.py index 1ae10d0eb54..ad45db88ca2 100644 --- a/tests/unit_tests/test_mqtt.py +++ b/tests/unit_tests/test_mqtt.py @@ -9,9 +9,112 @@ from unittest.mock import MagicMock, patch import pytest -from esphome.const import CONF_BROKER, CONF_ESPHOME, CONF_MQTT, CONF_NAME +from esphome.const import ( + CONF_BIRTH_MESSAGE, + CONF_BROKER, + CONF_ESPHOME, + CONF_MQTT, + CONF_NAME, + CONF_PAYLOAD, + CONF_RETAIN, + CONF_TOPIC, +) from esphome.core import EsphomeError -from esphome.mqtt import get_esphome_device_ip +from esphome.mqtt import get_esphome_device_ip, show_logs + + +@pytest.mark.parametrize( + "birth", + [ + None, + {CONF_TOPIC: "test-device/status", CONF_PAYLOAD: "online", CONF_RETAIN: False}, + {CONF_TOPIC: "test-device/status", CONF_PAYLOAD: "", CONF_RETAIN: True}, + ], + ids=["no birth message", "unretained birth message", "empty birth payload"], +) +@patch("esphome.mqtt.initialize") +def test_show_logs_asks_on_connect( + mock_initialize: MagicMock, birth: dict | None +) -> None: + """Without a retained birth message nothing tells a later CLI the device is up, so it asks on connect.""" + config = _discovery_config() + if birth is not None: + config[CONF_MQTT][CONF_BIRTH_MESSAGE] = birth + + show_logs(config) + + on_connect = mock_initialize.call_args.args[3] + client = MagicMock() + on_connect(client, None, None, 0) + client.publish.assert_called_once_with( + "esphome/logs/test-device", None, retain=False + ) + if birth is None or not birth[CONF_PAYLOAD]: + assert mock_initialize.call_args.args[1] == ["test-device/debug"] + + +@patch("esphome.mqtt.safe_print") +@patch("esphome.mqtt.initialize") +def test_show_logs_requests_crash_log_when_device_reports_online( + mock_initialize: MagicMock, mock_print: MagicMock +) -> None: + """With a retained birth message the request follows the device coming online, not the CLI connecting.""" + config = _discovery_config() + config[CONF_MQTT][CONF_BIRTH_MESSAGE] = { + CONF_TOPIC: "test-device/status", + CONF_PAYLOAD: "online", + CONF_RETAIN: True, + } + + show_logs(config) + + assert mock_initialize.call_args.args[1] == [ + "test-device/debug", + "test-device/status", + ] + on_message = mock_initialize.call_args.args[2] + on_connect = mock_initialize.call_args.args[3] + client = MagicMock() + on_connect(client, None, None, 0) + client.publish.assert_not_called() + + on_message(client, None, MagicMock(topic="test-device/status", payload=b"offline")) + client.publish.assert_not_called() + on_message(client, None, MagicMock(topic="test-device/status", payload=b"online")) + client.publish.assert_called_once_with( + "esphome/logs/test-device", None, retain=False + ) + mock_print.assert_not_called() + + on_message( + client, None, MagicMock(topic="test-device/debug", payload=b"[I][app]: hi") + ) + assert mock_print.call_args.args[0].endswith("[I][app]: hi") + client.publish.assert_called_once() + + +@patch("esphome.mqtt.safe_print") +@patch("esphome.mqtt.initialize") +def test_show_logs_prints_a_birth_message_inside_the_log_subscription( + mock_initialize: MagicMock, mock_print: MagicMock +) -> None: + """A birth topic covered by the log topic is still printed, and still requests the crash log.""" + config = _discovery_config() + config[CONF_MQTT][CONF_BIRTH_MESSAGE] = { + CONF_TOPIC: "test-device/status", + CONF_PAYLOAD: "online", + CONF_RETAIN: True, + } + + show_logs(config, topic="test-device/#") + + on_message = mock_initialize.call_args.args[2] + client = MagicMock() + on_message(client, None, MagicMock(topic="test-device/status", payload=b"online")) + client.publish.assert_called_once_with( + "esphome/logs/test-device", None, retain=False + ) + assert mock_print.call_args.args[0].endswith("online") def test_get_esphome_device_ip_empty_broker() -> None: From ac74796263f8e5d4b1f6b657f2128c3599511440 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 10:52:13 -1000 Subject: [PATCH 118/142] [friedrich] Clear saved preferences in the test fixture (#20770) --- tests/components/friedrich/friedrich_test.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/components/friedrich/friedrich_test.cpp b/tests/components/friedrich/friedrich_test.cpp index 610eca9c091..baf4e53652e 100644 --- a/tests/components/friedrich/friedrich_test.cpp +++ b/tests/components/friedrich/friedrich_test.cpp @@ -4,6 +4,7 @@ #include "esphome/components/friedrich/friedrich.h" #include "esphome/components/remote_base/aeha_protocol.h" #include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" namespace esphome::friedrich::testing { @@ -41,7 +42,12 @@ struct Fixture { TestFriedrich sut; CapturingTransmitter transmitter; - Fixture() { this->sut.set_transmitter(&this->transmitter); } + Fixture() { + // Climate tests share one preference key, so clear what an earlier test saved or setup restores it + if (global_preferences != nullptr) + global_preferences->reset(); + this->sut.set_transmitter(&this->transmitter); + } // The last transmission decoded, or an empty frame if it could not be decoded AEHAData sent() { return AEHAProtocol().decode(rx_of(this->transmitter.last)).value_or(AEHAData{}); } From d3668a71866fbf52e85010aa6403a385d42c20f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 11:13:32 -1000 Subject: [PATCH 119/142] [mqtt] Match subscription topics in a loop instead of recursing per character (#20767) --- esphome/components/mqtt/mqtt_client.cpp | 58 +----------- esphome/components/mqtt/mqtt_topic_match.h | 28 ++++++ tests/components/mqtt/test_topic_match.cpp | 103 +++++++++++++++++++++ 3 files changed, 132 insertions(+), 57 deletions(-) create mode 100644 esphome/components/mqtt/mqtt_topic_match.h create mode 100644 tests/components/mqtt/test_topic_match.cpp diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 297a94d2a97..dd376251399 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -16,6 +16,7 @@ #include "lwip/dns.h" #include "lwip/err.h" #include "mqtt_component.h" +#include "mqtt_topic_match.h" #ifdef USE_API #include "esphome/components/api/api_server.h" @@ -611,63 +612,6 @@ void MQTTClientComponent::disable() { this->on_shutdown(); } -/** Check if the message topic matches the given subscription topic - * - * INFO: MQTT spec mandates that topics must not be empty and must be valid NULL-terminated UTF-8 strings. - * - * @param message The message topic that was received from the MQTT server. Note: this must not contain - * wildcard characters as mandated by the MQTT spec. - * @param subscription The subscription topic we are matching against. - * @param is_normal Is this a "normal" topic - Does the message topic not begin with a "$". - * @param past_separator Are we past the first '/' topic separator. - * @return true if the subscription topic matches the message topic, false otherwise. - */ -static bool topic_match(const char *message, const char *subscription, bool is_normal, bool past_separator) { - // Reached end of both strings at the same time, this means we have a successful match - if (*message == '\0' && *subscription == '\0') - return true; - - // Either the message or the subscribe are at the end. This means they don't match. - if (*message == '\0' || *subscription == '\0') - return false; - - bool do_wildcards = is_normal || past_separator; - - if (*subscription == '+' && do_wildcards) { - // single level wildcard - // consume + from subscription - subscription++; - // consume everything from message until '/' found or end of string - while (*message != '\0' && *message != '/') { - message++; - } - // after this, both pointers will point to a '/' or to the end of the string - - return topic_match(message, subscription, is_normal, true); - } - - if (*subscription == '#' && do_wildcards) { - // multilevel wildcard - MQTT mandates that this must be at end of subscribe topic - return true; - } - - // this handles '/' and normal characters at the same time. - if (*message != *subscription) - return false; - - past_separator = past_separator || *subscription == '/'; - - // consume characters - subscription++; - message++; - - return topic_match(message, subscription, is_normal, past_separator); -} - -static bool topic_match(const char *message, const char *subscription) { - return topic_match(message, subscription, *message != '\0' && *message != '$', false); -} - void MQTTClientComponent::on_message(const std::string &topic, const std::string &payload) { #ifdef USE_ESP8266 // IMPORTANT: This defer is REQUIRED to prevent stack overflow crashes on ESP8266. diff --git a/esphome/components/mqtt/mqtt_topic_match.h b/esphome/components/mqtt/mqtt_topic_match.h new file mode 100644 index 00000000000..84172bc805e --- /dev/null +++ b/esphome/components/mqtt/mqtt_topic_match.h @@ -0,0 +1,28 @@ +#pragma once + +namespace esphome::mqtt { + +/// True if the message topic matches the subscription, with + and # wildcards. +/// A loop on purpose: recursing per character overflowed the ESP32 loop task's stack on long topics. +inline bool topic_match(const char *message, const char *subscription) { + // First-level wildcards do not match topics that begin with '$' + bool wildcards = *message != '$'; + while (*message != '\0' && *subscription != '\0') { + if (wildcards && *subscription == '#') + return true; + if (wildcards && *subscription == '+') { + subscription++; + while (*message != '\0' && *message != '/') + message++; + continue; + } + if (*message != *subscription) + return false; + wildcards |= *subscription == '/'; + message++; + subscription++; + } + return *message == *subscription; // a match only if both ended +} + +} // namespace esphome::mqtt diff --git a/tests/components/mqtt/test_topic_match.cpp b/tests/components/mqtt/test_topic_match.cpp new file mode 100644 index 00000000000..b51f45ce478 --- /dev/null +++ b/tests/components/mqtt/test_topic_match.cpp @@ -0,0 +1,103 @@ +#include + +#include +#include + +#include "esphome/components/mqtt/mqtt_topic_match.h" + +namespace esphome::mqtt::testing { + +// The previous recursive implementation, kept to check the loop matches it +static bool reference_match(const char *message, const char *subscription, bool is_normal, bool past_separator) { + if (*message == '\0' && *subscription == '\0') + return true; + if (*message == '\0' || *subscription == '\0') + return false; + bool do_wildcards = is_normal || past_separator; + if (*subscription == '+' && do_wildcards) { + subscription++; + while (*message != '\0' && *message != '/') + message++; + return reference_match(message, subscription, is_normal, true); + } + if (*subscription == '#' && do_wildcards) + return true; + if (*message != *subscription) + return false; + past_separator = past_separator || *subscription == '/'; + return reference_match(message + 1, subscription + 1, is_normal, past_separator); +} + +static bool reference_match(const char *message, const char *subscription) { + return reference_match(message, subscription, *message != '\0' && *message != '$', false); +} + +TEST(MQTTTopicMatch, Exact) { + EXPECT_TRUE(topic_match("a/b/c", "a/b/c")); + EXPECT_FALSE(topic_match("a/b/c", "a/b")); + EXPECT_FALSE(topic_match("a/b", "a/b/c")); + EXPECT_FALSE(topic_match("a/b/c", "a/b/d")); +} + +TEST(MQTTTopicMatch, SingleLevelWildcard) { + EXPECT_TRUE(topic_match("a/b/c", "a/+/c")); + EXPECT_TRUE(topic_match("a/b/c", "+/+/+")); + EXPECT_TRUE(topic_match("a//c", "a/+/c")); + EXPECT_FALSE(topic_match("a/b/c", "a/+")); + EXPECT_FALSE(topic_match("a/b/c/d", "a/+/c")); +} + +TEST(MQTTTopicMatch, MultiLevelWildcard) { + EXPECT_TRUE(topic_match("a/b/c", "a/#")); + EXPECT_TRUE(topic_match("a/b/c", "#")); + EXPECT_TRUE(topic_match("a/b/c", "a/+/#")); + EXPECT_FALSE(topic_match("b/c", "a/#")); +} + +TEST(MQTTTopicMatch, DollarTopicsSkipFirstLevelWildcards) { + EXPECT_FALSE(topic_match("$SYS/broker", "#")); + EXPECT_FALSE(topic_match("$SYS/broker", "+/broker")); + EXPECT_TRUE(topic_match("$SYS/broker", "$SYS/#")); + EXPECT_TRUE(topic_match("$SYS/broker", "$SYS/+")); +} + +TEST(MQTTTopicMatch, EdgeCases) { + EXPECT_TRUE(topic_match("", "")); + EXPECT_FALSE(topic_match("a", "")); + // Kept from the previous code; the MQTT spec has "#" match the parent level and "+" an empty last level + EXPECT_FALSE(topic_match("a/b", "a/b/#")); + EXPECT_FALSE(topic_match("a/", "a/+")); + EXPECT_TRUE(topic_match("a/$x", "a/+")); +} + +// Recursion overflowed the ESP32 loop stack at about 250 characters +TEST(MQTTTopicMatch, LongTopic) { + std::string topic(4096, 'x'); + topic[100] = '/'; + EXPECT_TRUE(topic_match(topic.c_str(), topic.c_str())); + std::string other = topic; + other.back() = 'y'; + EXPECT_FALSE(topic_match(topic.c_str(), other.c_str())); +} + +TEST(MQTTTopicMatch, MatchesPreviousImplementation) { + // Small alphabet so wildcards, separators and '$' meet often + static constexpr char MESSAGE_CHARS[] = {'a', 'b', '/', '$'}; + static constexpr char SUBSCRIPTION_CHARS[] = {'a', 'b', '/', '$', '+', '#'}; + std::mt19937 rng(1234); // NOLINT(cert-msc32-c,cert-msc51-cpp,bugprone-random-generator-seed) reproducible + auto random_string = [&rng](const char *chars, size_t count) { + std::string s(rng() % 8, ' '); + for (char &c : s) + c = chars[rng() % count]; + return s; + }; + for (int i = 0; i < 20000; i++) { + std::string message = random_string(MESSAGE_CHARS, sizeof(MESSAGE_CHARS)); + std::string subscription = random_string(SUBSCRIPTION_CHARS, sizeof(SUBSCRIPTION_CHARS)); + ASSERT_EQ(topic_match(message.c_str(), subscription.c_str()), + reference_match(message.c_str(), subscription.c_str())) + << "message='" << message << "' subscription='" << subscription << "'"; + } +} + +} // namespace esphome::mqtt::testing From cd97c9a9cb23d2a961215bd7a49c325da1cc95b7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:29:17 +0000 Subject: [PATCH 120/142] Bump platformdirs from 4.12.4 to 4.13.0 (#20772) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a247eb4ba15..9105f3f9c0a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,7 +31,7 @@ smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64" smp==4.1.0; platform_system == "Darwin" and platform_machine == "x86_64" requests==2.34.2 py7zr==1.1.3 -platformdirs==4.12.4 # native esp-idf toolchain global cache dir +platformdirs==4.13.0 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver filelock==4.1.0 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg From 61f3de0bc0b17377e33ffc66052a0f8ef7e09d6e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:30:26 +0000 Subject: [PATCH 121/142] Bump pylint from 4.1.2 to 4.1.3 (#20773) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 65e8a63bfe9..b36562727f8 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,4 @@ -pylint==4.1.2 +pylint==4.1.3 flake8==7.4.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py ruff==0.17.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py pyupgrade==3.22.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py From 3df936087676b5f9ffb7cf73ef4808f589159715 Mon Sep 17 00:00:00 2001 From: "nullable.eth" <2248325+nullable-eth@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:39:30 -0400 Subject: [PATCH 122/142] [i2s_audio] Add keep_alive option to speaker to prevent pop/click on audio start (#15565) Co-authored-by: J. Nick Koston --- .../components/i2s_audio/speaker/__init__.py | 24 +++++++ .../i2s_audio/speaker/i2s_audio_speaker.cpp | 64 ++++++++++--------- .../i2s_audio/speaker/i2s_audio_speaker.h | 9 ++- .../speaker/i2s_audio_speaker_standard.cpp | 15 ++++- tests/component_tests/i2s_audio/__init__.py | 0 .../i2s_audio/config/keep_alive.yaml | 16 +++++ .../config/keep_alive_shared_bus.yaml | 19 ++++++ .../config/keep_alive_shared_mic.yaml | 22 +++++++ .../i2s_audio/config/keep_alive_spdif.yaml | 20 ++++++ .../component_tests/i2s_audio/test_config.py | 36 +++++++++++ 10 files changed, 191 insertions(+), 34 deletions(-) create mode 100644 tests/component_tests/i2s_audio/__init__.py create mode 100644 tests/component_tests/i2s_audio/config/keep_alive.yaml create mode 100644 tests/component_tests/i2s_audio/config/keep_alive_shared_bus.yaml create mode 100644 tests/component_tests/i2s_audio/config/keep_alive_shared_mic.yaml create mode 100644 tests/component_tests/i2s_audio/config/keep_alive_spdif.yaml create mode 100644 tests/component_tests/i2s_audio/test_config.py diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index 4dc15681bfa..e4ea186199d 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -1,6 +1,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import audio, esp32, speaker +from esphome.components.microphone import DOMAIN as MICROPHONE_DOMAIN from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( @@ -14,9 +15,11 @@ from esphome.const import ( CONF_SAMPLE_RATE, CONF_TIMEOUT, ) +import esphome.final_validate as fv from esphome.types import ConfigType from .. import ( + CONF_I2S_AUDIO_ID, CONF_I2S_DOUT_PIN, CONF_I2S_MODE, CONF_LEFT, @@ -44,6 +47,7 @@ I2SAudioSpeaker = i2s_audio_ns.class_("I2SAudioSpeaker", I2SAudioSpeakerBase) CONF_DAC_TYPE = "dac_type" CONF_I2S_COMM_FMT = "i2s_comm_fmt" +CONF_KEEP_ALIVE = "keep_alive" CONF_SPDIF_MODE = "spdif_mode" I2SAudioSpeakerBase = i2s_audio_ns.class_( @@ -173,6 +177,7 @@ BASE_SCHEMA = ( cv.positive_time_period_milliseconds, cv.one_of(CONF_NEVER, lower=True), ), + cv.Optional(CONF_KEEP_ALIVE, default=False): cv.boolean, } ) .extend(cv.COMPONENT_SCHEMA) @@ -235,6 +240,23 @@ def _final_validate(config: ConfigType) -> None: raise cv.Invalid("SPDIF mode requires 'i2s_comm_fmt: stand_i2s'") if config[CONF_MCLK_MULTIPLE] != 256: raise cv.Invalid("SPDIF mode requires 'mclk_multiple: 256'") + if config[CONF_KEEP_ALIVE]: + raise cv.Invalid("SPDIF mode does not support 'keep_alive'") + + if config[CONF_KEEP_ALIVE]: + # A kept channel holds the port between sessions, so nothing else may share the bus + bus = config[CONF_I2S_AUDIO_ID] + full_config = fv.full_config.get() + on_bus = sum( + 1 + for domain in (speaker.DOMAIN, MICROPHONE_DOMAIN) + for entry in full_config.get(domain, []) + if entry.get(CONF_I2S_AUDIO_ID) == bus + ) + if on_bus > 1: + raise cv.Invalid( + "'keep_alive' needs its own I2S bus; another speaker or microphone shares this one" + ) FINAL_VALIDATE_SCHEMA = _final_validate @@ -262,6 +284,8 @@ async def to_code(config: ConfigType) -> None: if config[CONF_TIMEOUT] != CONF_NEVER: cg.add(var.set_timeout(config[CONF_TIMEOUT])) cg.add(var.set_buffer_duration(config[CONF_BUFFER_DURATION])) + if config[CONF_KEEP_ALIVE]: + cg.add(var.set_keep_alive(True)) # The SPDIF encoder and speaker are fully #ifdef'd on USE_I2S_AUDIO_SPDIF_MODE, diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 8d586ea4e01..c644ece5428 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -56,8 +56,9 @@ void I2SAudioSpeakerBase::dump_config() { ESP_LOGCONFIG(TAG, "Speaker:\n" " Pin: %d\n" - " Buffer duration: %" PRIu32, - static_cast(this->dout_pin_), this->buffer_duration_ms_); + " Buffer duration: %" PRIu32 "\n" + " Keep alive: %s", + static_cast(this->dout_pin_), this->buffer_duration_ms_, YESNO(this->keep_alive_)); if (this->timeout_.has_value()) { ESP_LOGCONFIG(TAG, " Timeout: %" PRIu32 " ms", this->timeout_.value()); } @@ -105,7 +106,13 @@ void I2SAudioSpeakerBase::loop() { vTaskDelete(this->speaker_task_handle_); this->speaker_task_handle_ = nullptr; - this->stop_i2s_driver_(); + // The task raises this bit before it exits, possibly a loop pass before TASK_STOPPED is seen, so read it + // live here; a channel the task could not bring up is rebuilt next time rather than kept + const bool task_failed = xEventGroupGetBits(this->event_group_) & SpeakerEventGroupBits::ERR_ESP_NO_MEM; + if (task_failed) { + ESP_LOGE(TAG, "Speaker task setup failed (allocation, preload, or channel enable)"); + } + this->stop_i2s_driver_(this->keep_alive_ && !task_failed); // ALL_BITS includes COMMAND_START. Take the bits from the clear itself, not from the snapshot at // the top of loop(): the audio source's task can raise a start at any point above, including // during stop_i2s_driver_(), and nothing would ever re-issue it. @@ -121,11 +128,6 @@ void I2SAudioSpeakerBase::loop() { this->state_ = speaker::STATE_STOPPED; } - if (event_group_bits & SpeakerEventGroupBits::ERR_ESP_NO_MEM) { - ESP_LOGE(TAG, "Speaker task setup failed (allocation, preload, or channel enable)"); - xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); - } - // Handle the speaker's state switch (this->state_) { case speaker::STATE_STARTING: @@ -133,8 +135,8 @@ void I2SAudioSpeakerBase::loop() { break; } - // Still starting up or winding down from a previous run - if ((this->tx_handle_ != nullptr) || (this->speaker_task_handle_ != nullptr)) { + // Still winding down from a previous run + if (this->speaker_task_handle_ != nullptr) { break; } @@ -154,7 +156,7 @@ void I2SAudioSpeakerBase::loop() { if (this->speaker_task_handle_ == nullptr) { ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); this->status_momentary_error(1000); - this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt + this->stop_i2s_driver_(false); // Stops the driver to return the lock; will be reloaded in next attempt } break; case speaker::STATE_RUNNING: // Intentional fallthrough @@ -270,31 +272,25 @@ esp_err_t I2SAudioSpeakerBase::init_i2s_channel_(const i2s_chan_config_t &chan_c err = i2s_channel_init_std_mode(this->tx_handle_, &std_cfg); if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to initialize I2S channel"); - i2s_del_channel(this->tx_handle_); - this->tx_handle_ = nullptr; + this->delete_channel_(); this->parent_->unlock(); return err; } + // Both queues are drained by the speaker task before it preloads, whether the channel is new or kept if (this->i2s_event_queue_ == nullptr) { this->i2s_event_queue_ = xQueueCreate(event_queue_size, sizeof(int64_t)); - } else { - // Reset queue to clear any stale events from previous task - xQueueReset(this->i2s_event_queue_); } // Lockstep records queue. One record per in-flight DMA buffer; sized to match the I2S event queue // so a fully-saturated DMA pipeline cannot overflow either side before drain. if (this->write_records_queue_ == nullptr) { this->write_records_queue_ = xQueueCreate(event_queue_size, sizeof(uint32_t)); - } else { - xQueueReset(this->write_records_queue_); } if (this->i2s_event_queue_ == nullptr || this->write_records_queue_ == nullptr) { ESP_LOGE(TAG, "Failed to allocate I2S event queue(s)"); - i2s_del_channel(this->tx_handle_); - this->tx_handle_ = nullptr; + this->delete_channel_(); this->parent_->unlock(); return ESP_ERR_NO_MEM; } @@ -302,22 +298,30 @@ esp_err_t I2SAudioSpeakerBase::init_i2s_channel_(const i2s_chan_config_t &chan_c return ESP_OK; } -void I2SAudioSpeakerBase::stop_i2s_driver_() { +void I2SAudioSpeakerBase::stop_i2s_driver_(bool keep_channel) { if (this->tx_handle_ != nullptr) { i2s_channel_disable(this->tx_handle_); - i2s_del_channel(this->tx_handle_); - this->tx_handle_ = nullptr; - - // i2s_del_channel() leaves dout wired to this port's data-out signal in the GPIO matrix: it only - // clears an internal reservation mask, never the esp_rom_gpio_connect_out_signal() routing that - // setup installed. If another speaker reuses this port (shared bus), its audio still reaches our - // dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a - // SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise. - this->park_dout_pin_(); + // A kept channel lets the next start skip i2s_new_channel and i2s_channel_init_std_mode, whose GPIO and + // clock reconfiguration is the audible pop + if (!keep_channel) { + this->delete_channel_(); + } } this->parent_->unlock(); } +void I2SAudioSpeakerBase::delete_channel_() { + i2s_del_channel(this->tx_handle_); + this->tx_handle_ = nullptr; + + // i2s_del_channel() leaves dout wired to this port's data-out signal in the GPIO matrix: it only + // clears an internal reservation mask, never the esp_rom_gpio_connect_out_signal() routing that + // setup installed. If another speaker reuses this port (shared bus), its audio still reaches our + // dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a + // SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise. + this->park_dout_pin_(); +} + bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx) { int64_t now = esp_timer_get_time(); diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index 965eede0201..d73d2da8794 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -54,6 +54,7 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public void set_buffer_duration(uint32_t buffer_duration_ms) { this->buffer_duration_ms_ = buffer_duration_ms; } void set_timeout(uint32_t ms) { this->timeout_ = ms; } + void set_keep_alive(bool keep_alive) { this->keep_alive_ = keep_alive; } void set_dout_pin(uint8_t pin) { this->dout_pin_ = (gpio_num_t) pin; } /// @brief Get the I2S TX channel handle @@ -126,12 +127,15 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public esp_err_t init_i2s_channel_(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg, size_t event_queue_size); - /// @brief Stops the I2S driver and unlocks the I2S port - void stop_i2s_driver_(); + /// @brief Stops the I2S driver and unlocks the I2S port; keep_channel leaves the disabled channel allocated + void stop_i2s_driver_(bool keep_channel); /// @brief Detaches dout from the I2S signal and drives it low void park_dout_pin_(); + /// @brief Deletes the I2S channel and parks dout; the caller holds the bus lock + void delete_channel_(); + /// @brief Called in loop() when the task has stopped. Override for mode-specific cleanup. virtual void on_task_stopped() {} @@ -177,6 +181,7 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public optional timeout_; bool pause_state_{false}; + bool keep_alive_{false}; // Smooths software gain changes. The main loop posts targets, the speaker task processes; // GainRamp's mailbox makes that safe. The main loop is the only poster. diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index fbc1edbde93..1a6b021fffa 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -75,6 +75,9 @@ void I2SAudioSpeaker::dump_config() { void I2SAudioSpeaker::run_speaker_task() { xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STARTING); + // Stale records from the previous run would pair with the wrong DMA events + xQueueReset(this->i2s_event_queue_); + xQueueReset(this->write_records_queue_); const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * DMA_BUFFERS_COUNT; // Ensure ring buffer duration is at least the duration of all DMA buffers @@ -389,8 +392,6 @@ void I2SAudioSpeaker::run_speaker_task() { } esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) { - this->current_stream_info_ = audio_stream_info; - if ((this->i2s_role_ & I2S_ROLE_SLAVE) && (this->sample_rate_ != audio_stream_info.get_sample_rate())) { // NOLINT // Can't reconfigure I2S bus, so the sample rate must match the configured value ESP_LOGE(TAG, "Incompatible stream settings"); @@ -428,6 +429,16 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream return ESP_ERR_INVALID_STATE; } + // Reuse a kept channel while the stream format matches + if (this->tx_handle_ != nullptr) { + if (this->current_stream_info_ == audio_stream_info) { + return ESP_OK; + } + this->delete_channel_(); + } + + this->current_stream_info_ = audio_stream_info; + // The DMA buffers hold output-format (post-narrowing) samples, so size them from the output stream info. uint32_t dma_buffer_length = dma_buffer_frames(this->output_stream_info_); diff --git a/tests/component_tests/i2s_audio/__init__.py b/tests/component_tests/i2s_audio/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/i2s_audio/config/keep_alive.yaml b/tests/component_tests/i2s_audio/config/keep_alive.yaml new file mode 100644 index 00000000000..fd418bd4af5 --- /dev/null +++ b/tests/component_tests/i2s_audio/config/keep_alive.yaml @@ -0,0 +1,16 @@ +--- +esphome: + name: i2s-test + +esp32: + board: esp32dev + +i2s_audio: + i2s_bclk_pin: GPIO27 + i2s_lrclk_pin: GPIO26 + +speaker: + - platform: i2s_audio + dac_type: external + i2s_dout_pin: GPIO12 + keep_alive: true diff --git a/tests/component_tests/i2s_audio/config/keep_alive_shared_bus.yaml b/tests/component_tests/i2s_audio/config/keep_alive_shared_bus.yaml new file mode 100644 index 00000000000..2eecf2088fa --- /dev/null +++ b/tests/component_tests/i2s_audio/config/keep_alive_shared_bus.yaml @@ -0,0 +1,19 @@ +--- +esphome: + name: i2s-test + +esp32: + board: esp32dev + +i2s_audio: + i2s_bclk_pin: GPIO27 + i2s_lrclk_pin: GPIO26 + +speaker: + - platform: i2s_audio + dac_type: external + i2s_dout_pin: GPIO12 + keep_alive: true + - platform: i2s_audio + dac_type: external + i2s_dout_pin: GPIO13 diff --git a/tests/component_tests/i2s_audio/config/keep_alive_shared_mic.yaml b/tests/component_tests/i2s_audio/config/keep_alive_shared_mic.yaml new file mode 100644 index 00000000000..c95ff1abbc6 --- /dev/null +++ b/tests/component_tests/i2s_audio/config/keep_alive_shared_mic.yaml @@ -0,0 +1,22 @@ +--- +esphome: + name: i2s-test + +esp32: + board: esp32dev + +i2s_audio: + i2s_bclk_pin: GPIO27 + i2s_lrclk_pin: GPIO26 + +speaker: + - platform: i2s_audio + dac_type: external + i2s_dout_pin: GPIO12 + keep_alive: true + +microphone: + - platform: i2s_audio + i2s_din_pin: GPIO34 + adc_type: external + pdm: false diff --git a/tests/component_tests/i2s_audio/config/keep_alive_spdif.yaml b/tests/component_tests/i2s_audio/config/keep_alive_spdif.yaml new file mode 100644 index 00000000000..0e194bebef5 --- /dev/null +++ b/tests/component_tests/i2s_audio/config/keep_alive_spdif.yaml @@ -0,0 +1,20 @@ +--- +esphome: + name: i2s-test + +esp32: + board: esp32dev + +i2s_audio: + i2s_bclk_pin: GPIO27 + i2s_lrclk_pin: GPIO26 + +speaker: + - platform: i2s_audio + dac_type: external + i2s_dout_pin: GPIO12 + spdif_mode: true + use_apll: true + sample_rate: 48000 + channel: stereo + keep_alive: true diff --git a/tests/component_tests/i2s_audio/test_config.py b/tests/component_tests/i2s_audio/test_config.py new file mode 100644 index 00000000000..bd5c3a598fa --- /dev/null +++ b/tests/component_tests/i2s_audio/test_config.py @@ -0,0 +1,36 @@ +"""Exercise the I2S speaker keep_alive validation.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.config import read_config +from esphome.core import CORE + + +def test_keep_alive_generates_setter( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("keep_alive.yaml")) + assert "set_keep_alive(true)" in source + + +@pytest.mark.parametrize( + ("config_file", "message"), + [ + ("keep_alive_shared_bus.yaml", "needs its own I2S bus"), + ("keep_alive_shared_mic.yaml", "needs its own I2S bus"), + ("keep_alive_spdif.yaml", "SPDIF mode does not support 'keep_alive'"), + ], +) +def test_keep_alive_rejected( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], + config_file: str, + message: str, +) -> None: + CORE.config_path = component_config_path(config_file) + assert read_config({}) is None + assert message in capsys.readouterr().out From 04715fb32bf711b89b04bb606fad55f977f0fcd1 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:41:12 -0400 Subject: [PATCH 123/142] [usb_uart] Expose `interface_class` and `port_number` (#20659) Co-authored-by: J. Nick Koston --- esphome/components/api/api.proto | 7 ++- esphome/components/api/api_pb2.cpp | 4 ++ esphome/components/api/api_pb2.h | 2 + esphome/components/api/api_pb2_dump.cpp | 2 + .../components/serial_proxy/serial_proxy.cpp | 2 + esphome/components/usb_uart/ch34x.cpp | 8 --- esphome/components/usb_uart/cp210x.cpp | 10 ++-- esphome/components/usb_uart/ft23xx.cpp | 1 + esphome/components/usb_uart/pl2303.cpp | 5 +- esphome/components/usb_uart/usb_uart.cpp | 6 ++- esphome/components/usb_uart/usb_uart.h | 21 +++++++- tests/components/serial_proxy/__init__.py | 16 ++++++ .../test_usb_device_descriptor.cpp | 51 +++++++++++++++++++ 13 files changed, 115 insertions(+), 20 deletions(-) create mode 100644 tests/components/serial_proxy/__init__.py create mode 100644 tests/components/serial_proxy/test_usb_device_descriptor.cpp diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index f8f31d81df0..d55cf10c47d 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -3043,12 +3043,15 @@ enum SerialProxyIdentityFlag { // IDs below are empty } -// The descriptor fields of a USB device as seen by the host stack +// The descriptor fields of a USB device as seen by the host stack, and the port's index +// among those a vendor driver exposes on the interface message UsbDeviceDescriptor { uint32 vendor_id = 1; uint32 product_id = 2; uint32 bcd_device = 3; - uint32 interface_number = 4; // bInterfaceNumber the host driver binds to + uint32 interface_number = 4; // bInterfaceNumber the port is bound through + uint32 interface_class = 5; // bInterfaceClass of that interface + uint32 port_number = 6; } // The identity of the device behind a port. Identifies one physical device among others of diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 8847249b173..bcfa86763a1 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -4175,6 +4175,8 @@ uint8_t *UsbDeviceDescriptor::encode_msg(const void *self, uint8_t *__restrict__ pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.product_id); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.bcd_device); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.interface_number); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.interface_class); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.port_number); return pos; } uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) { @@ -4184,6 +4186,8 @@ uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) { size += ProtoSize::calc_uint32(1, msg.product_id); size += ProtoSize::calc_uint32(1, msg.bcd_device); size += ProtoSize::calc_uint32(1, msg.interface_number); + size += ProtoSize::calc_uint32(1, msg.interface_class); + size += ProtoSize::calc_uint32(1, msg.port_number); return size; } uint8_t *SerialProxyIdentity::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 14204e97aaa..7c8082a8c7c 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -4083,6 +4083,8 @@ class UsbDeviceDescriptor final : public ProtoMessage { uint32_t product_id{0}; uint32_t bcd_device{0}; uint32_t interface_number{0}; + uint32_t interface_class{0}; + uint32_t port_number{0}; static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 3c6f5302b55..ec224c46e29 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -2942,6 +2942,8 @@ const char *UsbDeviceDescriptor::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("product_id"), this->product_id); dump_field(out, ESPHOME_PSTR("bcd_device"), this->bcd_device); dump_field(out, ESPHOME_PSTR("interface_number"), this->interface_number); + dump_field(out, ESPHOME_PSTR("interface_class"), this->interface_class); + dump_field(out, ESPHOME_PSTR("port_number"), this->port_number); return out.c_str(); } const char *SerialProxyIdentity::dump_to(DumpBuffer &out) const { diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index 9623eb21b3c..03d289225f2 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -418,6 +418,8 @@ void SerialProxy::fill_identity_([[maybe_unused]] IdentityScratch &scratch, api: msg.usb.product_id = scratch.product_id; msg.usb.bcd_device = scratch.bcd_device; msg.usb.interface_number = this->usb_channel_->get_interface_number(); + msg.usb.interface_class = this->usb_channel_->get_interface_class(); + msg.usb.port_number = this->usb_channel_->get_port_number(); msg.manufacturer = StringRef(scratch.manufacturer); msg.product = StringRef(scratch.product); msg.serial_number = StringRef(scratch.serial_number); diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index ed5a034b82f..dce07a64a53 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -152,14 +152,6 @@ bool USBUartTypeCH34X::config_step(USBUartChannelBase *channel, uint8_t step, bo } } -std::vector USBUartTypeCH34X::parse_descriptors(usb_device_handle_t dev_hdl) { - auto result = USBUartTypeCdcAcm::parse_descriptors(dev_hdl); - // ch34x doesn't use the interrupt endpoint, and we don't have endpoints to spare - for (auto &cdc_dev : result) { - cdc_dev.interrupt_interface_number = 0xFF; - } - return result; -} } // namespace esphome::usb_uart #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index e920da6c267..34b76f88541 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -88,11 +88,11 @@ std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev ESP_LOGE(TAG, "in_ep: usb_parse_endpoint_descriptor_by_index failed"); continue; } - if (in_ep->bEndpointAddress & usb_host::USB_DIR_IN) { - cdc_devs.push_back({CdcEps{nullptr, in_ep, out_ep, data_desc->bInterfaceNumber}}); - } else { - cdc_devs.push_back({CdcEps{nullptr, out_ep, in_ep, data_desc->bInterfaceNumber}}); - } + CdcEps eps = in_ep->bEndpointAddress & usb_host::USB_DIR_IN + ? CdcEps{nullptr, in_ep, out_ep, data_desc->bInterfaceNumber} + : CdcEps{nullptr, out_ep, in_ep, data_desc->bInterfaceNumber}; + eps.bulk_interface_class = data_desc->bInterfaceClass; + cdc_devs.push_back(eps); } return cdc_devs; } diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index e10da78aaf0..805a4adfce3 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -217,6 +217,7 @@ static optional get_uart(const usb_config_desc_t *config_desc, uint8_t i } eps.bulk_interface_number = intf_desc->bInterfaceNumber; + eps.bulk_interface_class = intf_desc->bInterfaceClass; return eps; } diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index 36107c093c4..0430b1eeacb 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -189,7 +189,10 @@ std::vector USBUartTypePL2303::parse_descriptors(usb_device_handle_t dev } if (in_ep && out_ep) { - cdc_devs.push_back(CdcEps{notify_ep, in_ep, out_ep, intf->bInterfaceNumber, intf->bInterfaceNumber}); + CdcEps eps{notify_ep, in_ep, out_ep, intf->bInterfaceNumber, intf->bInterfaceNumber}; + eps.bulk_interface_class = intf->bInterfaceClass; + eps.interrupt_interface_class = intf->bInterfaceClass; + cdc_devs.push_back(eps); break; // PL2303 is single-port } } diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index f61642566cb..744b79f4e43 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -41,14 +41,17 @@ static optional get_cdc(const usb_config_desc_t *config_desc, uint8_t in if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_INT) { eps.notify_ep = ep; eps.interrupt_interface_number = intf_desc->bInterfaceNumber; + eps.interrupt_interface_class = intf_desc->bInterfaceClass; } else if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_BULK && ep->bEndpointAddress & usb_host::USB_DIR_IN && (eps.bulk_interface_number == 0xFF || eps.bulk_interface_number == intf_desc->bInterfaceNumber)) { eps.in_ep = ep; eps.bulk_interface_number = intf_desc->bInterfaceNumber; + eps.bulk_interface_class = intf_desc->bInterfaceClass; } else if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_BULK && !(ep->bEndpointAddress & usb_host::USB_DIR_IN) && (eps.bulk_interface_number == 0xFF || eps.bulk_interface_number == intf_desc->bInterfaceNumber)) { eps.out_ep = ep; eps.bulk_interface_number = intf_desc->bInterfaceNumber; + eps.bulk_interface_class = intf_desc->bInterfaceClass; } else { ESP_LOGE(TAG, "Unexpected endpoint attributes: %02X", ep->bmAttributes); continue; @@ -431,8 +434,7 @@ void USBUartTypeCdcAcm::on_connected() { if (channel->cdc_dev_.interrupt_interface_number != 0xFF && channel->cdc_dev_.interrupt_interface_number != channel->cdc_dev_.bulk_interface_number) { if (!this->claim_comm_interface_) { - ESP_LOGD(TAG, "Skipping comm interface %d (claim_comm_interface: false)", - channel->cdc_dev_.interrupt_interface_number); + ESP_LOGD(TAG, "Comm interface %d left unclaimed", channel->cdc_dev_.interrupt_interface_number); } else { auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_, channel->cdc_dev_.interrupt_interface_number, 0); diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index b54641884e6..ba073404e2f 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -40,6 +40,9 @@ struct CdcEps { // must remain valid even when the interface itself is not claimed. uint8_t interrupt_interface_number{0xFF}; bool interrupt_interface_claimed{false}; + // bInterfaceClass of each interface above, which decides how a host binds the port + uint8_t bulk_interface_class{0}; + uint8_t interrupt_interface_class{0}; }; enum CH34xChipType : uint8_t { @@ -187,6 +190,16 @@ class USBUartChannelBase : public uart::UARTComponent, public Parentedcdc_dev_.bulk_interface_number; } + /// bInterfaceClass of the interface `get_interface_number()` names + uint8_t get_interface_class() const { + return this->cdc_dev_.interrupt_interface_number != 0xFF ? this->cdc_dev_.interrupt_interface_class + : this->cdc_dev_.bulk_interface_class; + } + + /// Index of this port among the ports on its interface. Every driver here exposes one + /// port per interface; a multiplexed driver would index its channels. + uint8_t get_port_number() const { return 0; } + protected: // Not directly instantiable; construct a concrete channel type instead. USBUartChannelBase(uint8_t index, uint16_t buffer_size) : input_buffer_(RingBuffer(buffer_size)), index_(index) {} @@ -318,13 +331,17 @@ class USBUartTypeCP210X : public USBUartTypeCdcAcm { }; class USBUartTypeCH34X : public USBUartTypeCdcAcm { public: - USBUartTypeCH34X(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {} + USBUartTypeCH34X(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) { + // ch34x doesn't use the interrupt endpoint, and we don't have endpoints to spare. The + // comm interface is left in place unclaimed: on the CDC-style chips it is still the + // interface a host binds, and so the one the port is identified by. + this->claim_comm_interface_ = false; + } void dump_config() override; protected: bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; bool config_device_step(uint8_t step, bool ok, const uint8_t *response) override; - std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; private: CH34xChipType chiptype_{CHIP_UNKNOWN}; diff --git a/tests/components/serial_proxy/__init__.py b/tests/components/serial_proxy/__init__.py new file mode 100644 index 00000000000..913e8d8b37e --- /dev/null +++ b/tests/components/serial_proxy/__init__.py @@ -0,0 +1,16 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The harness gives a multi-instance component an empty list and never calls its to_code, + # so take a single empty config instead and emit what the proxy messages and handlers + # need. No proxy is configured, so the count is 0, which keeps the capability messages as + # the api tests expect them; the USB identity stays out, since the USB channel exists on the ESP32 only. + manifest.multi_conf = False + + async def to_code_testing(config): + cg.add_define("USE_SERIAL_PROXY") + cg.add_define("SERIAL_PROXY_COUNT", 0) + + manifest.to_code = to_code_testing diff --git a/tests/components/serial_proxy/test_usb_device_descriptor.cpp b/tests/components/serial_proxy/test_usb_device_descriptor.cpp new file mode 100644 index 00000000000..c76757bbee6 --- /dev/null +++ b/tests/components/serial_proxy/test_usb_device_descriptor.cpp @@ -0,0 +1,51 @@ +#include + +#include +#include + +#include "esphome/components/api/api_buffer.h" +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/proto.h" + +namespace esphome::api::testing { + +// Encodes into a buffer of exactly the calculated size, so any overrun trips the debug bounds check. +static std::vector encode(const UsbDeviceDescriptor &msg) { + const uint32_t size = msg.calculate_size(); + APIBuffer buf; + EXPECT_TRUE(buf.resize(size == 0 ? 1 : size)); + uint8_t *pos = buf.data(); +#ifdef ESPHOME_DEBUG_API + uint8_t *proto_debug_end_ = buf.data() + buf.size(); +#endif + uint8_t *end = UsbDeviceDescriptor::encode_msg(&msg, pos PROTO_ENCODE_DEBUG_ARG); + EXPECT_EQ(static_cast(end - buf.data()), size); + return {buf.data(), end}; +} + +TEST(UsbDeviceDescriptor, EncodesTheInterfaceClassAndPort) { + UsbDeviceDescriptor msg; + msg.vendor_id = 0x10C4; + msg.product_id = 0xEA60; + msg.bcd_device = 0x0100; + msg.interface_number = 1; + msg.interface_class = 0xFF; + msg.port_number = 2; + + const std::vector expected = {0x08, 0xC4, 0x21, 0x10, 0xE0, 0xD4, 0x03, 0x18, 0x80, + 0x02, 0x20, 0x01, 0x28, 0xFF, 0x01, 0x30, 0x02}; + EXPECT_EQ(encode(msg), expected); +} + +TEST(UsbDeviceDescriptor, ZeroClassAndPortLeaveTheWireAsBefore) { + // The new fields are additive with zero defaults, so an old client sees the message it always did + UsbDeviceDescriptor msg; + msg.vendor_id = 0x303A; + msg.product_id = 0x1001; + msg.interface_number = 1; + + const std::vector expected = {0x08, 0xBA, 0x60, 0x10, 0x81, 0x20, 0x20, 0x01}; + EXPECT_EQ(encode(msg), expected); +} + +} // namespace esphome::api::testing From 2c6f59738cf868b38cd76675f6cb948025f75fc6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:41:28 +0000 Subject: [PATCH 124/142] Bump filelock from 4.1.0 to 4.1.1 (#20778) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9105f3f9c0a..ca5c5af39ac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,7 +33,7 @@ requests==2.34.2 py7zr==1.1.3 platformdirs==4.13.0 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver -filelock==4.1.0 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +filelock==4.1.1 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.3 From dcb943ded39dc1a7ec470dbfa7f461fc5041ab65 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 11:45:54 -1000 Subject: [PATCH 125/142] [noise] Initialize the protocol id's reserved field for ESP-IDF 6.1 (#20779) --- esphome/components/noise/noise_handshake.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/noise/noise_handshake.cpp b/esphome/components/noise/noise_handshake.cpp index 181dd63bb21..46a1314d23c 100644 --- a/esphome/components/noise/noise_handshake.cpp +++ b/esphome/components/noise/noise_handshake.cpp @@ -38,6 +38,7 @@ int NoiseResponderHandshake::init(const NoiseContext &ctx, const uint8_t *prolog .cipher_id = NOISE_CIPHER_CHACHAPOLY, .hash_id = NOISE_HASH_SHA256, .hybrid_id = NOISE_DH_NONE, + .reserved = {}, }; int err = noise_handshakestate_new_by_id(&this->handshake_, &nid, NOISE_ROLE_RESPONDER); From ea76073d869b83a3055ffc56f0feb9fab7f452aa Mon Sep 17 00:00:00 2001 From: n-IA-hane <49248235+n-IA-hane@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:42:24 +0200 Subject: [PATCH 126/142] [micro_wake_word] Allow selecting the inference task core (#20126) Co-authored-by: n-IA-hane Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../components/micro_wake_word/__init__.py | 34 ++++++++ .../micro_wake_word/micro_wake_word.cpp | 13 ++- esphome/core/defines.h | 1 + esphome/core/static_task.cpp | 6 +- esphome/core/static_task.h | 3 +- .../micro_wake_word/test.esp32-idf.yaml | 3 +- .../micro_wake_word/test.esp32-s3-idf.yaml | 1 + .../micro_wake_word/test_task_core.py | 86 +++++++++++++++++++ 8 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 tests/unit_tests/components/micro_wake_word/test_task_core.py diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index 9477ebc850e..ca65fa1a5e6 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -13,6 +13,7 @@ from esphome.components.http_request import validate_url import esphome.config_validation as cv from esphome.const import ( CONF_FILE, + CONF_FRAMEWORK, CONF_ID, CONF_INTERNAL, CONF_MICROPHONE, @@ -26,10 +27,12 @@ from esphome.const import ( CONF_TYPE, CONF_URL, CONF_USERNAME, + PLATFORM_ESP32, TYPE_GIT, TYPE_LOCAL, ) from esphome.core import CORE, HexInt +import esphome.final_validate as fv from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -41,6 +44,8 @@ DEPENDENCIES = ["microphone"] DOMAIN = "micro_wake_word" +CONF_TASK_CORE = "task_core" + CONF_FEATURE_STEP_SIZE = "feature_step_size" CONF_MODELS = "models" CONF_ON_WAKE_WORD_DETECTED = "on_wake_word_detected" @@ -407,6 +412,27 @@ def _download_http_models(config: ConfigType) -> ConfigType: return config +def _validate_task_core(config: ConfigType) -> ConfigType: + if config[CONF_TASK_CORE] != 1: + return config + esp32.only_on_variant( + supported=[ + esp32.VARIANT_ESP32, + esp32.VARIANT_ESP32S3, + esp32.VARIANT_ESP32P4, + esp32.VARIANT_ESP32H4, + esp32.VARIANT_ESP32S31, + ], + msg_prefix="micro_wake_word task_core: 1", + )(config) + sdkconfig = fv.full_config.get()[PLATFORM_ESP32][CONF_FRAMEWORK][ + esp32.CONF_SDKCONFIG_OPTIONS + ] + if sdkconfig.get("CONFIG_FREERTOS_UNICORE") == "y": + raise cv.Invalid("task_core: 1 requires FreeRTOS dual-core mode") + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -427,6 +453,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_VAD): _maybe_empty_vad_schema, cv.Optional(CONF_STOP_AFTER_DETECTION, default=True): cv.boolean, + cv.Optional(CONF_TASK_CORE, default=-1): cv.int_range(min=-1, max=1), cv.Optional(CONF_PREFER_INTERNAL_RAM, default=False): cv.boolean, cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_MODEL): cv.invalid( @@ -509,6 +536,7 @@ FINAL_VALIDATE_SCHEMA = cv.All( }, extra=cv.ALLOW_EXTRA, ), + _validate_task_core, _feature_step_size_validate, ) @@ -521,6 +549,12 @@ async def to_code(config): cg.add(var.set_microphone_source(mic_source)) cg.add_define("USE_MICRO_WAKE_WORD") + cg.add_define( + "MICRO_WAKE_WORD_TASK_CORE", + cg.RawExpression("tskNO_AFFINITY") + if config[CONF_TASK_CORE] == -1 + else config[CONF_TASK_CORE], + ) if config[CONF_PREFER_INTERNAL_RAM]: cg.add_define("USE_MICRO_WAKE_WORD_INTERNAL_RAM") ota.request_ota_state_listeners() diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 4ae66d69bf0..6b33a3a1b56 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -17,6 +17,10 @@ namespace esphome::micro_wake_word { +// Config validation checks the YAML, this checks the build: a unicore sdkconfig reached any other way +static_assert(MICRO_WAKE_WORD_TASK_CORE == tskNO_AFFINITY || MICRO_WAKE_WORD_TASK_CORE < portNUM_PROCESSORS, + "micro_wake_word task_core is not a core of this build"); + ESPHOME_LOG_TAG(TAG, "micro_wake_word"); static const ssize_t DETECTION_QUEUE_LENGTH = 5; @@ -84,6 +88,12 @@ static const LogString *micro_wake_word_state_to_string(State state) { void MicroWakeWord::dump_config() { ESP_LOGCONFIG(TAG, "microWakeWord:"); + // tskNO_AFFINITY is a cast expression, so this cannot be a preprocessor test; the compiler folds it + if (MICRO_WAKE_WORD_TASK_CORE == tskNO_AFFINITY) { + ESP_LOGCONFIG(TAG, " Inference task core: any"); + } else { + ESP_LOGCONFIG(TAG, " Inference task core: %d", MICRO_WAKE_WORD_TASK_CORE); + } ESP_LOGCONFIG(TAG, " models:"); for (auto &model : this->wake_word_models_) { model->log_model_config(); @@ -477,7 +487,8 @@ void MicroWakeWord::loop() { } if (!this->inference_task_.create(MicroWakeWord::inference_task, "mww", INFERENCE_TASK_STACK_SIZE, - (void *) this, INFERENCE_TASK_PRIORITY, this->task_stack_in_psram_)) { + (void *) this, INFERENCE_TASK_PRIORITY, this->task_stack_in_psram_, + MICRO_WAKE_WORD_TASK_CORE)) { FrontendFreeStateContents(&this->frontend_state_); // Deallocate frontend state this->status_momentary_error(1000); } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 3d14494df7a..0547ba6a82d 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -562,6 +562,7 @@ #define ESPHOME_ETHERNET_PHY_REGISTER_COUNT 2 #ifdef USE_ESP32 +#define MICRO_WAKE_WORD_TASK_CORE 0 #define USE_MICRO_WAKE_WORD #define USE_MICRO_WAKE_WORD_INTERNAL_RAM #define USE_MICRO_WAKE_WORD_VAD diff --git a/esphome/core/static_task.cpp b/esphome/core/static_task.cpp index 87629c2507d..dd7309f8f09 100644 --- a/esphome/core/static_task.cpp +++ b/esphome/core/static_task.cpp @@ -7,7 +7,7 @@ namespace esphome { bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority, - bool use_psram) { + bool use_psram, BaseType_t core_id) { if (this->handle_ != nullptr) { // Task is already created; must call destroy() first return false; @@ -38,8 +38,8 @@ bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size return false; } - this->handle_ = - xTaskCreateStatic(fn, name, this->stack_size_, param, priority, this->stack_buffer_.get(), this->tcb_.get()); + this->handle_ = xTaskCreateStaticPinnedToCore(fn, name, this->stack_size_, param, priority, this->stack_buffer_.get(), + this->tcb_.get(), core_id); if (this->handle_ == nullptr) { this->deallocate(); return false; diff --git a/esphome/core/static_task.h b/esphome/core/static_task.h index 10c55f70c62..999e92a4b41 100644 --- a/esphome/core/static_task.h +++ b/esphome/core/static_task.h @@ -37,9 +37,10 @@ class StaticTask { /// @param param Parameter passed to task function /// @param priority FreeRTOS task priority /// @param use_psram If true, allocate stack in PSRAM; otherwise internal RAM + /// @param core_id CPU core, or tskNO_AFFINITY to leave scheduling unrestricted /// @return true on success; false if either allocation fails or the task cannot be created bool create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority, - bool use_psram); + bool use_psram, BaseType_t core_id = tskNO_AFFINITY); /// @brief Delete the task, keeping the TCB and stack buffer allocated for reuse by a subsequent create() call. /// The task must have finished its work and parked itself, either suspended or blocked indefinitely: it is diff --git a/tests/components/micro_wake_word/test.esp32-idf.yaml b/tests/components/micro_wake_word/test.esp32-idf.yaml index fa3984d57e0..726013a0602 100644 --- a/tests/components/micro_wake_word/test.esp32-idf.yaml +++ b/tests/components/micro_wake_word/test.esp32-idf.yaml @@ -3,5 +3,4 @@ substitutions: packages: i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml - -<<: !include common.yaml + micro_wake_word: !include common.yaml diff --git a/tests/components/micro_wake_word/test.esp32-s3-idf.yaml b/tests/components/micro_wake_word/test.esp32-s3-idf.yaml index 8806d8046d9..3ed671a764d 100644 --- a/tests/components/micro_wake_word/test.esp32-s3-idf.yaml +++ b/tests/components/micro_wake_word/test.esp32-s3-idf.yaml @@ -6,4 +6,5 @@ packages: micro_wake_word: !include common.yaml micro_wake_word: + task_core: 1 prefer_internal_ram: true diff --git a/tests/unit_tests/components/micro_wake_word/test_task_core.py b/tests/unit_tests/components/micro_wake_word/test_task_core.py new file mode 100644 index 00000000000..563022a2400 --- /dev/null +++ b/tests/unit_tests/components/micro_wake_word/test_task_core.py @@ -0,0 +1,86 @@ +"""Validate inference task affinity before generating firmware.""" + +import pytest + +from esphome.components import audio, micro_wake_word as mww +import esphome.config_validation as cv +from esphome.const import KEY_CORE, KEY_ESP32, KEY_TARGET_PLATFORM, KEY_VARIANT +from esphome.core import CORE +import esphome.final_validate as fv + + +@pytest.mark.parametrize( + "variant", + ["ESP32", "ESP32S3", "ESP32P4", "ESP32H4", "ESP32S31", "ESP32S2", "ESP32C3"], +) +@pytest.mark.parametrize("core", [-1, 0, 1]) +def test_task_core_matches_target(variant: str, core: int) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + CORE.data[KEY_ESP32] = {KEY_VARIANT: variant} + token = fv.full_config.set({"esp32": {"framework": {"sdkconfig_options": {}}}}) + try: + config = mww.CONFIG_SCHEMA({"task_core": core}) + if core == 1 and variant in ("ESP32S2", "ESP32C3"): + with pytest.raises(cv.Invalid, match="only available"): + mww._validate_task_core(config) + else: + assert mww._validate_task_core(config)["task_core"] == core + finally: + fv.full_config.reset(token) + + +@pytest.mark.parametrize("core", [-2, 2]) +def test_invalid_task_core(core: int) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + with pytest.raises(cv.Invalid): + mww.CONFIG_SCHEMA({"task_core": core}) + + +def test_default_task_core_is_unpinned() -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + assert mww.CONFIG_SCHEMA({})["task_core"] == -1 + + +@pytest.mark.parametrize("unicore", ["y", "n", "true", "1"]) +def test_core_one_matches_freertos_unicore_setting(unicore: str) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32S3"} + token = fv.full_config.set( + { + "esp32": { + "framework": {"sdkconfig_options": {"CONFIG_FREERTOS_UNICORE": unicore}} + } + } + ) + try: + if unicore == "y": + with pytest.raises(cv.Invalid, match="dual-core"): + mww._validate_task_core({"task_core": 1}) + else: + assert mww._validate_task_core({"task_core": 1})["task_core"] == 1 + finally: + fv.full_config.reset(token) + + +@pytest.mark.parametrize("variant", ["ESP32S3", "ESP32C3"]) +def test_task_core_final_validation( + variant: str, monkeypatch: pytest.MonkeyPatch +) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} + CORE.data[KEY_ESP32] = {KEY_VARIANT: variant} + # Isolate microphone capabilities; exercise the real final-validation chain. + monkeypatch.setattr( + audio, + "final_validate_audio_schema", + lambda *args, **kwargs: lambda config: config, + ) + token = fv.full_config.set({"esp32": {"framework": {"sdkconfig_options": {}}}}) + try: + config = mww.CONFIG_SCHEMA({"task_core": 1}) + if variant == "ESP32C3": + with pytest.raises(cv.Invalid, match="only available"): + mww.FINAL_VALIDATE_SCHEMA(config) + else: + mww.FINAL_VALIDATE_SCHEMA(config) + finally: + fv.full_config.reset(token) From 0e2c4cf5c2e85c9fb7b6e571af6184f7a0872716 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:43:07 -1000 Subject: [PATCH 127/142] Bump aioesphomeapi from 46.11.0 to 46.12.0 (#20781) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ca5c5af39ac..0c1a1fb6235 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.2.0 esptool==5.5.0 click==8.5.0 -aioesphomeapi==46.11.0 +aioesphomeapi==46.12.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 From 48a0a41cce3b705c85cea1bc96f3b17c2822969a Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Wed, 14 Oct 2026 19:49:37 -0300 Subject: [PATCH 128/142] [wifi] Implement access point WISP mode (#13633) Co-authored-by: J. Nick Koston Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/network/__init__.py | 33 +++- esphome/components/wifi/__init__.py | 93 ++++++++- esphome/components/wifi/wifi_component.cpp | 49 +++-- esphome/components/wifi/wifi_component.h | 19 +- .../wifi/wifi_component_esp_idf.cpp | 99 ++++++++++ esphome/core/defines.h | 4 + .../network/config/high_perf_wifi_only.yaml | 11 ++ .../network/test_high_performance.py | 17 +- .../components/wifi/test-wisp.esp32-idf.yaml | 8 + .../wifi/validate-wisp-idf6.esp32-idf.yaml | 16 ++ tests/unit_tests/components/test_wifi.py | 180 +++++++++++++++++- 11 files changed, 501 insertions(+), 28 deletions(-) create mode 100644 tests/component_tests/network/config/high_perf_wifi_only.yaml create mode 100644 tests/components/wifi/test-wisp.esp32-idf.yaml create mode 100644 tests/components/wifi/validate-wisp-idf6.esp32-idf.yaml diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 89835128fe2..0227e0a6bd4 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -27,7 +27,8 @@ _LOGGER = logging.getLogger(__name__) # High performance networking tracking infrastructure # Components can request high performance networking and this configures lwip and WiFi settings -KEY_HIGH_PERFORMANCE_NETWORKING = "high_performance_networking" +KEY_HIGH_PERFORMANCE_NETWORKING = "high_performance_networking" # lwip tier +KEY_HIGH_PERFORMANCE_WIFI = "high_performance_wifi" # WiFi driver buffers CONF_ENABLE_HIGH_PERFORMANCE = "enable_high_performance" CONF_TCP_SEND_BUFFER = "tcp_send_buffer" @@ -225,9 +226,26 @@ def require_high_performance_networking() -> None: _request_high_performance_networking, ) """ - # Only set up once (idempotent - multiple components can call this) - if not CORE.data.get(KEY_HIGH_PERFORMANCE_NETWORKING, False): - CORE.data[KEY_HIGH_PERFORMANCE_NETWORKING] = True + CORE.data[KEY_HIGH_PERFORMANCE_NETWORKING] = True + require_high_performance_wifi() + + +def require_high_performance_wifi() -> None: + """Request only the WiFi driver part of high performance networking. + + For traffic that passes through the device below its own sockets, such as the + NAT of a WISP access point: more RX/TX buffers, AMPDU aggregation and a deeper + lwip input mailbox help every forwarded packet, while the larger TCP windows + would only serve the device's own connections and cost internal RAM for nothing. + ``network: enable_high_performance: false`` turns the mailbox part off like the + lwip tier; the WiFi driver settings follow the component request alone. + """ + CORE.data[KEY_HIGH_PERFORMANCE_WIFI] = True + + +def has_high_performance_wifi() -> bool: + """True when a component asked for the high performance WiFi driver settings.""" + return CORE.data.get(KEY_HIGH_PERFORMANCE_WIFI, False) def has_high_performance_networking() -> bool: @@ -519,6 +537,13 @@ async def to_code(config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_LWIP_TCP_WND_DEFAULT", tcp_window) add_idf_sdkconfig_option("CONFIG_LWIP_TCP_RECVMBOX_SIZE", 64) add_idf_sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE", tcpip_mailbox) + elif ( + CORE.is_esp32 and enable_high_perf is not False and has_high_performance_wifi() + ): + # Forwarded frames still queue through tcpip_input, so the WiFi-only tier deepens that mailbox too + add_idf_sdkconfig_option( + "CONFIG_LWIP_TCPIP_RECVMBOX_SIZE", TCPIP_RECVMBOX_OPTIMIZED + ) # After the high performance block so an explicit size wins over the # bundle's 65534 (last write wins in the sdkconfig store). diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 667e288fd72..583a9ed9992 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -18,8 +18,9 @@ from esphome.components.esp32 import ( from esphome.components.network import ( add_use_address, get_network_priority, - has_high_performance_networking, + has_high_performance_wifi, ip_address_literal, + require_high_performance_wifi, ) from esphome.components.psram import is_guaranteed as psram_is_guaranteed from esphome.config_helpers import filter_source_files_from_platform @@ -160,6 +161,8 @@ CONF_BAND_MODE = "band_mode" CONF_MIN_AUTH_MODE = "min_auth_mode" CONF_PHY_MODE = "phy_mode" CONF_POST_CONNECT_ROAMING = "post_connect_roaming" +CONF_AP_MODE = "ap_mode" +CONF_DNS_SERVERS = "dns_servers" # Maximum number of WiFi networks that can be configured # Limited to 127 because selected_sta_index_ is int8_t in C++ @@ -170,6 +173,7 @@ MAX_WIFI_NETWORKS = 127 # get best-effort connection attempts. Longer timeout ensures we exhaust all options # before falling back to AP mode. Aligned with improv wifi_timeout default. DEFAULT_AP_TIMEOUT = "90s" +DEFAULT_AP_TIMEOUT_PERIOD = cv.time_period(DEFAULT_AP_TIMEOUT) DEFAULT_REBOOT_TIMEOUT = "15min" # Both defaults also match the C++ initializers in wifi_component.h; codegen skips # the setter when the config equals them. @@ -209,6 +213,32 @@ WIFI_8266_PHY_MODES = { "11G": WiFi8266PhyMode.WIFI_8266_PHY_MODE_11G, "11N": WiFi8266PhyMode.WIFI_8266_PHY_MODE_11N, } + +WIFI_AP_MODE_FALLBACK = "FALLBACK" +WIFI_AP_MODE_WISP = "WISP" + + +def _validate_ap_mode(value: str) -> str: + value = cv.one_of(WIFI_AP_MODE_FALLBACK, WIFI_AP_MODE_WISP, upper=True)(value) + if value == WIFI_AP_MODE_WISP: + # esp_netif_napt_enable() exists from ESP-IDF 5.1 + cv.require_framework_version( + esp_idf=cv.Version(5, 1, 0), extra_message="WISP mode routes through NAT" + )(value) + return value + + +def _validate_dns_servers(value: list) -> list: + if any(ip.is_unspecified for ip in value): + raise cv.Invalid("0.0.0.0 is not a DNS server the access point can offer") + if len(value) > 1: + cv.require_framework_version( + esp_idf=cv.Version(6, 0, 0), + extra_message="The access point offers a second DNS server from ESP-IDF 6.0", + )(value) + return value + + WiFiConnectedCondition = wifi_ns.class_("WiFiConnectedCondition", Condition) WiFiEnabledCondition = wifi_ns.class_("WiFiEnabledCondition", Condition) WiFiAPActiveCondition = wifi_ns.class_("WiFiAPActiveCondition", Condition) @@ -304,6 +334,12 @@ WIFI_NETWORK_AP = WIFI_NETWORK_BASE.extend( cv.Optional( CONF_AP_TIMEOUT, default=DEFAULT_AP_TIMEOUT ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_AP_MODE, default=WIFI_AP_MODE_FALLBACK): _validate_ap_mode, + cv.Optional(CONF_DNS_SERVERS): cv.All( + cv.ensure_list(cv.ipv4address), + cv.Length(min=1, max=2), + _validate_dns_servers, + ), } ) @@ -312,6 +348,19 @@ def wifi_network_ap(value): if value is None: value = {} config = WIFI_NETWORK_AP(value) + if config[CONF_AP_MODE] == WIFI_AP_MODE_WISP: + if not config.get(CONF_PASSWORD): + raise cv.Invalid( + "WISP mode requires a password for the access point", + path=[CONF_PASSWORD], + ) + if config[CONF_AP_TIMEOUT].total_milliseconds == 0: + raise cv.Invalid( + "ap_timeout is how long the bridge waits before a failed start is tried again; it cannot be 0", + path=[CONF_AP_TIMEOUT], + ) + elif CONF_DNS_SERVERS in config: + raise cv.Invalid("dns_servers requires ap_mode: WISP", path=[CONF_DNS_SERVERS]) if CONF_MANUAL_IP in config and CORE.is_rp2: raise cv.Invalid( "Manual AP IP configuration is not supported on RP2040. " @@ -370,7 +419,30 @@ def final_validate(config): raise cv.Invalid( "Please specify at least an SSID or an Access Point to create." ) - if has_ap and not has_captive_portal and not has_web_server: + + ap_is_wisp = has_ap and config[CONF_AP][CONF_AP_MODE] == WIFI_AP_MODE_WISP + if ap_is_wisp: + if not has_sta: + raise cv.Invalid( + "WISP mode requires at least one STA network (wifi.ssid or wifi.networks)" + ) + if has_captive_portal: + raise cv.Invalid( + "captive_portal is not supported together with ap_mode: WISP" + ) + # Every client packet passes through the WiFi driver, none through the device's own sockets + require_high_performance_wifi() + if CONF_DNS_SERVERS not in config[CONF_AP]: + # The access point passes the station's resolvers on, which a static address has to name + for network in [config, *config.get(CONF_NETWORKS, [])]: + static = network.get(CONF_MANUAL_IP) + if static and static[CONF_DNS1].is_unspecified: + raise cv.Invalid( + "manual_ip needs dns1 in WISP mode unless ap.dns_servers is set", + path=[CONF_MANUAL_IP, CONF_DNS1], + ) + + if has_ap and not has_captive_portal and not has_web_server and not ap_is_wisp: _LOGGER.warning( "WiFi AP is configured but neither captive_portal nor web_server is enabled. " "The AP will not be usable for configuration or monitoring. " @@ -708,8 +780,19 @@ async def to_code(config): lambda ap: cg.add(var.set_ap(wifi_network(conf, ap, ip_config))), ) # Skip the setter when the config matches the C++ initializer. - if (ap_timeout := conf[CONF_AP_TIMEOUT]) != cv.time_period(DEFAULT_AP_TIMEOUT): + if (ap_timeout := conf[CONF_AP_TIMEOUT]) != DEFAULT_AP_TIMEOUT_PERIOD: cg.add(var.set_ap_timeout(ap_timeout)) + if conf[CONF_AP_MODE] == WIFI_AP_MODE_WISP: + cg.add_define("USE_WIFI_AP_WISP") + add_idf_sdkconfig_option("CONFIG_LWIP_IP_FORWARD", True) + add_idf_sdkconfig_option("CONFIG_LWIP_IPV4_NAPT", True) + if dns_servers := conf.get(CONF_DNS_SERVERS): + cg.add_define("USE_WIFI_AP_WISP_STATIC_DNS") + # As lwIP stores an IPv4 address: the four bytes read as one little-endian word + for i, ip in enumerate(dns_servers): + cg.add_define( + f"WIFI_AP_WISP_DNS_{i}", int.from_bytes(ip.packed, "little") + ) cg.add_define("USE_WIFI_AP") # The LN882H radio cannot run the AP and STA together; the fallback AP # takes turns with the networks instead. @@ -823,8 +906,8 @@ async def to_code(config): if config.get(CONF_USE_PSRAM): add_idf_sdkconfig_option("CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP", True) - # Apply high performance WiFi settings if high performance networking is enabled - if CORE.is_esp32 and has_high_performance_networking(): + # Apply high performance WiFi settings if a component asked for them + if CORE.is_esp32 and has_high_performance_wifi(): # Check if PSRAM is guaranteed (set by psram component during final validation) psram_guaranteed = psram_is_guaranteed() diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 62732e09153..70b208769b7 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -655,10 +655,10 @@ void WiFiComponent::setup() { this->configured_power_save_ = this->power_save_; #endif -#if defined(USE_PROVISIONING) && defined(USE_WIFI_AP) +#if defined(USE_PROVISIONING) && defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP) // The access point and the portals on it are provisioning surfaces: once the window has // closed, end them (mirrors the teardown on a successful connection). The fallback block in - // loop() is gated so none of them is started again afterwards. + // loop() is gated so none of them is started again afterwards. A bridge AP carries no portal. if (provisioning::global_provisioning_manager != nullptr) { provisioning::global_provisioning_manager->add_on_closed_callback([this]() { this->end_ap_portal_(); // no-op when nothing is active, so not tied to ap_setup_ @@ -911,8 +911,8 @@ void WiFiComponent::loop() { // The networks are not tried while the AP is up, so it pauses for // them once nobody has used it for a while. Without networks the AP // is all there is and stays. - if (this->has_sta() && now - this->ap_exclusive_changed_ > WIFI_AP_EXCLUSIVE_DWELL_MS && - (this->ap_clients_ == 0 || now - this->ap_exclusive_changed_ > WIFI_AP_EXCLUSIVE_MAX_DWELL_MS)) + if (this->has_sta() && now - this->ap_changed_ > WIFI_AP_EXCLUSIVE_DWELL_MS && + (this->ap_clients_ == 0 || now - this->ap_changed_ > WIFI_AP_EXCLUSIVE_MAX_DWELL_MS)) this->pause_exclusive_ap_(); break; #endif @@ -922,12 +922,20 @@ void WiFiComponent::loop() { return; } -#ifdef USE_WIFI_AP - bool provisioning_closed = false; -#ifdef USE_PROVISIONING +#ifdef USE_WIFI_AP_WISP + // The bridge comes up once it has a resolver to offer; a start that failed is tried again after ap_timeout + if (!this->ap_setup_ && this->ap_dns_known_() && + (this->ap_changed_ == 0 || now - this->ap_changed_ > this->ap_timeout_)) { + ESP_LOGI(TAG, "Starting AP"); + this->ap_changed_ = now; + this->setup_ap_config_(); + } +#elif defined(USE_WIFI_AP) // Once the provisioning window has closed, don't bring up the fallback AP (or // the captive portal on it) - the device must stay unprovisionable until it is // power-cycled. + bool provisioning_closed = false; +#ifdef USE_PROVISIONING provisioning_closed = provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed(); #endif @@ -937,12 +945,12 @@ void WiFiComponent::loop() { #ifdef USE_WIFI_AP_EXCLUSIVE // After a pause, or a start that failed, the networks get a full // ap_timeout before the AP is tried again. - && now - this->ap_exclusive_changed_ > this->ap_timeout_ + && now - this->ap_changed_ > this->ap_timeout_ #endif ) { ESP_LOGI(TAG, "Starting fallback AP"); #ifdef USE_WIFI_AP_EXCLUSIVE - this->ap_exclusive_changed_ = now; + this->ap_changed_ = now; #endif this->setup_ap_config_(); // Where the AP runs on its own, a portal with no AP behind it would only stretch the cooldowns. @@ -1063,9 +1071,9 @@ void WiFiComponent::setup_ap_config_() { } } this->ap_setup_ = this->wifi_start_ap_(this->ap_); -#ifdef USE_WIFI_AP_EXCLUSIVE - // A failed start must not leave the radio in AP mode, which would refuse - // the STA attempts until the next try. +#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_WISP) + // A failed start must not leave the radio in AP mode: on an exclusive radio it would refuse + // the STA attempts until the next try, and a bridge would take clients it cannot serve. if (!this->ap_setup_) this->wifi_mode_({}, false); #endif @@ -1108,7 +1116,7 @@ void WiFiComponent::pause_exclusive_ap_() { this->ap_setup_ = false; // Clients drop with the AP without a disconnect event for each. this->ap_clients_ = 0; - this->ap_exclusive_changed_ = App.get_loop_component_start_time(); + this->ap_changed_ = App.get_loop_component_start_time(); // A fresh connection cycle, as at boot, rather than resuming the one the // AP cut short. this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT; @@ -1117,6 +1125,13 @@ void WiFiComponent::pause_exclusive_ap_() { } #endif +#ifdef USE_WIFI_AP_WISP +void WiFiComponent::fail_bridge_() { + this->ap_setup_ = false; + this->wifi_mode_({}, false); +} +#endif + void WiFiComponent::disable_ap_() { this->end_ap_portal_(); ESP_LOGD(TAG, "Disabling AP"); @@ -1452,6 +1467,12 @@ void WiFiComponent::disable() { #endif this->wifi_disconnect_(); this->wifi_mode_(false, false); +#ifdef USE_WIFI_AP + this->ap_setup_ = false; // the AP went down with the radio; enable() may bring it up again +#endif +#ifdef USE_WIFI_AP_WISP + this->ap_changed_ = 0; // at once, not after ap_timeout +#endif } void WiFiComponent::start_scanning() { @@ -1742,7 +1763,7 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { // Reset to initial phase on successful connection (don't log transition, just reset state) this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT; this->num_retried_ = 0; -#ifdef USE_WIFI_AP +#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP) // the bridge AP stays up for its clients if (this->has_ap()) { this->disable_ap_(); } diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index c1f4061a534..964de6996a3 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -721,6 +721,19 @@ class WiFiComponent final : public Component { #endif #endif // USE_WIFI_AP +#ifdef USE_WIFI_AP_WISP + /// Whether there is an IPv4 resolver to offer AP clients: a configured one, or the station's + bool ap_dns_known_(); + /// Hand the AP DHCP server its resolvers; false when one could not be set + bool write_ap_dns_(); + /// Take the bridge down so loop() runs its setup again after ap_timeout + void fail_bridge_(); +#ifndef USE_WIFI_AP_WISP_STATIC_DNS + /// Pass the station's resolvers on to AP clients when they changed + void apply_ap_dns_(); +#endif +#endif + void print_connect_params_(); WiFiAP build_params_for_current_phase_(); @@ -971,8 +984,8 @@ class WiFiComponent final : public Component { float output_power_{NAN}; uint32_t action_started_; uint32_t last_connected_{0}; -#ifdef USE_WIFI_AP_EXCLUSIVE - uint32_t ap_exclusive_changed_{0}; // When the AP was last started or paused +#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_WISP) + uint32_t ap_changed_{0}; // When the AP was last started or paused #endif uint32_t reboot_timeout_{900000}; // Keep in sync with DEFAULT_REBOOT_TIMEOUT in __init__.py uint32_t roaming_last_check_{0}; @@ -982,7 +995,7 @@ class WiFiComponent final : public Component { // WiFiAP is byte aligned unless manual IP or EAP is enabled; placed before the // 1-byte members so they pack into its trailing bytes instead of padding after it WiFiAP ap_; -#endif +#endif // USE_WIFI_AP // 1-byte enums and integers WiFiComponentState state_{WIFI_COMPONENT_STATE_OFF}; diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index bdca350ddeb..10dd67dba92 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -1054,6 +1054,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { const auto &it = data->data.ip_got_ip; ESP_LOGV(TAG, "static_ip=" IPSTR " gateway=" IPSTR, IP2STR(&it.ip_info.ip), IP2STR(&it.ip_info.gw)); this->got_ipv4_address_ = true; +#if defined(USE_WIFI_AP_WISP) && !defined(USE_WIFI_AP_WISP_STATIC_DNS) + this->apply_ap_dns_(); +#endif #ifdef USE_WIFI_LEASE_CACHE this->save_lease_settings_(); // events are drained on the main loop #endif @@ -1172,6 +1175,14 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_START) { ESP_LOGV(TAG, "AP start"); this->ap_started_ = true; +#ifdef USE_WIFI_AP_WISP + // NAPT needs the interface up, so it cannot be part of the setup. Without it the clients have no way out, so + // the AP goes down and its setup runs again, which brings it back up through this event. + if (esp_err_t err = esp_netif_napt_enable(s_ap_netif); err != ESP_OK) { + ESP_LOGE(TAG, "Failed to enable NAT: %s", esp_err_to_name(err)); + this->fail_bridge_(); + } +#endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_STOP) { ESP_LOGV(TAG, "AP stop"); @@ -1349,6 +1360,19 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional &manual_ip) { } #endif +#ifdef USE_WIFI_AP_WISP + // Offer resolvers through DHCP: the configured ones, otherwise the station's. Without them the server would + // offer the AP's own address, so a failure fails the setup and it is tried again. + uint8_t dns_offer = 1; + err = + esp_netif_dhcps_option(s_ap_netif, ESP_NETIF_OP_SET, ESP_NETIF_DOMAIN_NAME_SERVER, &dns_offer, sizeof(dns_offer)); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to enable DHCP DNS option: %s", esp_err_to_name(err)); + } + if (err != ESP_OK || !this->write_ap_dns_()) + return false; +#endif + err = esp_netif_dhcps_start(s_ap_netif); if (err != ESP_OK) { @@ -1489,6 +1513,81 @@ network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { return network::IPAddress(dns_ip); } +#ifdef USE_WIFI_AP_WISP +// A DHCP server interface takes a backup resolver from ESP-IDF 6.0 +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) +static constexpr size_t AP_DNS_COUNT = 2; +#else +static constexpr size_t AP_DNS_COUNT = 1; +#endif +#ifdef USE_WIFI_AP_WISP_STATIC_DNS +// The configured resolvers as lwIP stores them, from codegen +static constexpr uint32_t AP_DNS[] = { + WIFI_AP_WISP_DNS_0, +#ifdef WIFI_AP_WISP_DNS_1 + WIFI_AP_WISP_DNS_1, +#endif +}; +#endif + +static uint32_t ap_dns_to_offer(size_t i) { +#ifdef USE_WIFI_AP_WISP_STATIC_DNS + return i < std::size(AP_DNS) ? AP_DNS[i] : 0; +#else + return ip_addr_get_ip4_u32(dns_getserver(i)); +#endif +} + +bool WiFiComponent::ap_dns_known_() { return ap_dns_to_offer(0) != 0; } + +bool WiFiComponent::write_ap_dns_() { + bool ok = true; + for (size_t i = 0; i < AP_DNS_COUNT; i++) { + const uint32_t addr = ap_dns_to_offer(i); + if (addr == 0) + continue; + esp_netif_dns_info_t info{}; + info.ip.type = ESP_IPADDR_TYPE_V4; + info.ip.u_addr.ip4.addr = addr; + esp_err_t err = esp_netif_set_dns_info(s_ap_netif, static_cast(i), &info); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to set DHCP DNS server %u: %s", (unsigned) i, esp_err_to_name(err)); + ok = false; + } else { + ESP_LOGV(TAG, "DHCP DNS server: " IPSTR, IP2STR(&info.ip.u_addr.ip4)); + } + } + return ok; +} + +#ifndef USE_WIFI_AP_WISP_STATIC_DNS +void WiFiComponent::apply_ap_dns_() { + if (!this->ap_setup_) + return; // loop() brings the bridge up once the station has a resolver, and its setup writes them + // The DHCP server holds what it offers, so there is nothing to cache; a failed update is noticed again with the + // next lease. A resolver the station lost stays offered: set_dns_info rejects an empty address. + bool changed = false; + for (size_t i = 0; i < AP_DNS_COUNT && !changed; i++) { + const uint32_t addr = ap_dns_to_offer(i); + esp_netif_dns_info_t current{}; + changed = + addr != 0 && (esp_netif_get_dns_info(s_ap_netif, static_cast(i), ¤t) != ESP_OK || + current.ip.u_addr.ip4.addr != addr); + } + if (!changed) + return; + // The IP setup programs the DHCP server from scratch without touching the soft AP, so clients stay joined +#ifdef USE_WIFI_MANUAL_IP + const bool ok = this->wifi_ap_ip_config_(this->ap_.get_manual_ip()); +#else + const bool ok = this->wifi_ap_ip_config_({}); +#endif + if (!ok) + this->fail_bridge_(); +} +#endif // USE_WIFI_AP_WISP_STATIC_DNS +#endif // USE_WIFI_AP_WISP + } // namespace esphome::wifi #endif // USE_ESP32 #endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0547ba6a82d..7763812054a 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -500,6 +500,10 @@ #define USE_WIFI_FAST_CONNECT_IN_FLASH #define USE_WIFI_LEASE_CACHE #define USE_WIFI_LEASE_CACHE_IN_FLASH +#define USE_WIFI_AP_WISP +#define USE_WIFI_AP_WISP_STATIC_DNS +#define WIFI_AP_WISP_DNS_0 0x08080808 +#define WIFI_AP_WISP_DNS_1 0x04040808 #define USE_WIFI_PHY_MODE #define USE_WIFI_IP_STATE_LISTENERS #define USE_WIFI_SCAN_RESULTS_LISTENERS diff --git a/tests/component_tests/network/config/high_perf_wifi_only.yaml b/tests/component_tests/network/config/high_perf_wifi_only.yaml new file mode 100644 index 00000000000..61dfde3e039 --- /dev/null +++ b/tests/component_tests/network/config/high_perf_wifi_only.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" diff --git a/tests/component_tests/network/test_high_performance.py b/tests/component_tests/network/test_high_performance.py index c291806ac4b..8452a56a057 100644 --- a/tests/component_tests/network/test_high_performance.py +++ b/tests/component_tests/network/test_high_performance.py @@ -9,7 +9,10 @@ from pathlib import Path import pytest -from esphome.components.network import require_high_performance_networking +from esphome.components.network import ( + require_high_performance_networking, + require_high_performance_wifi, +) from tests.component_tests.network import sdkconfig_option @@ -52,3 +55,15 @@ def test_lwip_tier( assert sdkconfig_option("CONFIG_LWIP_WND_SCALE") is window_scale # Wifi RX buffers really go to PSRAM, so the wifi tier is never downgraded assert sdkconfig_option("CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM") == wifi_rx_buffers + + +def test_wifi_only_request_leaves_lwip_alone( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A WiFi-only request sizes the driver buffers but not the TCP windows.""" + require_high_performance_wifi() + generate_main(component_config_path("high_perf_wifi_only.yaml")) + assert sdkconfig_option("CONFIG_LWIP_TCP_WND_DEFAULT") is None + assert sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE") == 64 + assert sdkconfig_option("CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM") == 64 diff --git a/tests/components/wifi/test-wisp.esp32-idf.yaml b/tests/components/wifi/test-wisp.esp32-idf.yaml new file mode 100644 index 00000000000..757e825358c --- /dev/null +++ b/tests/components/wifi/test-wisp.esp32-idf.yaml @@ -0,0 +1,8 @@ +wifi: + ap: + ssid: WISP AP + password: wisp12345678 + ap_mode: wisp + +packages: + wifi: !include common.yaml diff --git a/tests/components/wifi/validate-wisp-idf6.esp32-idf.yaml b/tests/components/wifi/validate-wisp-idf6.esp32-idf.yaml new file mode 100644 index 00000000000..cd54b3b144b --- /dev/null +++ b/tests/components/wifi/validate-wisp-idf6.esp32-idf.yaml @@ -0,0 +1,16 @@ +# A second DNS server needs ESP-IDF 6.0; config-only, a build would re-use the 5.x build dir +esp32: + framework: + version: 6.0.1 + +wifi: + ap: + ssid: WISP AP + password: wisp12345678 + ap_mode: wisp + dns_servers: + - 8.8.8.8 + - 8.8.4.4 + +packages: + wifi: !include common.yaml diff --git a/tests/unit_tests/components/test_wifi.py b/tests/unit_tests/components/test_wifi.py index 3899b3d8540..b09e37aacf1 100644 --- a/tests/unit_tests/components/test_wifi.py +++ b/tests/unit_tests/components/test_wifi.py @@ -1,22 +1,42 @@ """Tests for WiFi component public helpers.""" +from ipaddress import IPv4Address + import pytest from esphome.components.esp32 import const +from esphome.components.network import ( + KEY_HIGH_PERFORMANCE_NETWORKING, + KEY_HIGH_PERFORMANCE_WIFI, +) from esphome.components.wifi import ( + CONF_AP_MODE, + CONF_AP_TIMEOUT, + CONF_DNS_SERVERS, check_placeholder_credentials, + final_validate as wifi_final_validate, has_native_wifi, variant_has_wifi, + wifi_network_ap, ) +import esphome.config_validation as cv from esphome.const import ( CONF_AP, + CONF_DNS1, + CONF_MANUAL_IP, CONF_NETWORKS, + CONF_PASSWORD, CONF_SSID, CONF_WIFI, + KEY_CORE, + KEY_FRAMEWORK_VERSION, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, PLACEHOLDER_WIFI_SSID, Platform, ) -from esphome.core import EsphomeError, Lambda +from esphome.core import CORE, EsphomeError, Lambda +import esphome.final_validate as fv @pytest.mark.parametrize( @@ -197,3 +217,161 @@ def test_check_placeholder_credentials_skips_template_ssid() -> None: """A templated (Lambda) SSID is not a string and is skipped.""" config = _wifi_config(networks=[{CONF_SSID: Lambda('return "x";')}]) assert check_placeholder_credentials(config) is None + + +@pytest.fixture +def wisp_core(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> None: + """An ESP32 build; the parameter overrides the framework or the ESP-IDF version.""" + params = getattr(request, "param", {}) + monkeypatch.setitem( + CORE.data, + KEY_CORE, + { + KEY_TARGET_PLATFORM: Platform.ESP32, + KEY_TARGET_FRAMEWORK: params.get("framework", "esp-idf"), + KEY_FRAMEWORK_VERSION: params.get("idf", cv.Version(5, 4, 0)), + }, + ) + + +def _wisp_ap(**overrides: object) -> dict: + return {CONF_AP_MODE: "WISP", CONF_PASSWORD: "wisp12345678", **overrides} + + +def _final_validate(config: dict, full_extra: dict | None = None) -> None: + token = fv.full_config.set({CONF_WIFI: config, **(full_extra or {})}) + try: + wifi_final_validate(config) + finally: + fv.full_config.reset(token) + + +@pytest.mark.usefixtures("wisp_core") +@pytest.mark.parametrize( + "ap", [{CONF_AP_MODE: "WISP"}, _wisp_ap(**{CONF_PASSWORD: ""})] +) +def test_wisp_requires_ap_password(ap: dict) -> None: + """WISP must not route upstream access through an open access point.""" + with pytest.raises(cv.Invalid, match="password"): + wifi_network_ap(ap) + + +@pytest.mark.usefixtures("wisp_core") +def test_wisp_accepts_ap_password() -> None: + assert wifi_network_ap(_wisp_ap())[CONF_AP_MODE] == "WISP" + + +@pytest.mark.usefixtures("wisp_core") +def test_wisp_rejects_zero_ap_timeout() -> None: + """Zero disables the fallback AP; the bridge would retry a failed start on every loop pass.""" + with pytest.raises(cv.Invalid, match="ap_timeout"): + wifi_network_ap(_wisp_ap(**{CONF_AP_TIMEOUT: "0s"})) + wifi_network_ap(_wisp_ap(**{CONF_AP_TIMEOUT: "30s"})) + + +@pytest.mark.usefixtures("wisp_core") +def test_dns_servers_reject_unspecified_address() -> None: + """0.0.0.0 would leave the clients with the access point as their resolver.""" + with pytest.raises(cv.Invalid, match="0.0.0.0"): + wifi_network_ap(_wisp_ap(dns_servers=["0.0.0.0"])) + + +@pytest.mark.usefixtures("wisp_core") +def test_dns_servers_require_wisp() -> None: + with pytest.raises(cv.Invalid, match="dns_servers requires ap_mode: WISP"): + wifi_network_ap({CONF_DNS_SERVERS: ["1.1.1.1"]}) + + +@pytest.mark.usefixtures("wisp_core") +def test_dns_servers_limited_to_two() -> None: + """The access point DHCP server has a main and a backup resolver.""" + with pytest.raises(cv.Invalid): + wifi_network_ap(_wisp_ap(dns_servers=["1.1.1.1", "8.8.8.8", "9.9.9.9"])) + + +@pytest.mark.parametrize( + ("wisp_core", "valid"), + [({"idf": cv.Version(5, 5, 5)}, False), ({"idf": cv.Version(6, 0, 1)}, True)], + indirect=["wisp_core"], +) +def test_second_dns_server_requires_idf_6(wisp_core: None, valid: bool) -> None: + ap = _wisp_ap(dns_servers=["1.1.1.1", "8.8.8.8"]) + if valid: + wifi_network_ap(ap) + else: + with pytest.raises(cv.Invalid, match="6.0.0"): + wifi_network_ap(ap) + + +@pytest.mark.parametrize("wisp_core", [{"framework": "arduino"}], indirect=True) +def test_wisp_requires_esp_idf(wisp_core: None) -> None: + """NAT is only available with the ESP-IDF framework.""" + with pytest.raises(cv.Invalid, match="incompatible"): + wifi_network_ap(_wisp_ap()) + + +@pytest.mark.parametrize("wisp_core", [{"idf": cv.Version(5, 0, 9)}], indirect=True) +def test_wisp_requires_idf_5_1(wisp_core: None) -> None: + """esp_netif_napt_enable() is missing before ESP-IDF 5.1.""" + with pytest.raises(cv.Invalid, match="5.1.0"): + wifi_network_ap(_wisp_ap()) + + +@pytest.mark.usefixtures("wisp_core") +def test_wisp_requires_sta_network() -> None: + """WISP has nothing to share without an upstream network.""" + with pytest.raises(cv.Invalid, match="at least one STA network"): + _final_validate({CONF_NETWORKS: [], CONF_AP: _wisp_ap()}) + + +@pytest.mark.usefixtures("wisp_core") +def test_wisp_rejects_captive_portal() -> None: + with pytest.raises(cv.Invalid, match="captive_portal"): + _final_validate( + {CONF_NETWORKS: [{}], CONF_AP: _wisp_ap()}, {"captive_portal": {}} + ) + + +def _manual_ip(dns1: str) -> dict: + return {CONF_DNS1: IPv4Address(dns1)} + + +@pytest.mark.usefixtures("wisp_core") +def test_wisp_manual_ip_needs_dns1() -> None: + """The bridge passes the station's resolvers on, which a static address must name.""" + with pytest.raises(cv.Invalid, match="dns1"): + _final_validate( + { + CONF_NETWORKS: [{CONF_MANUAL_IP: _manual_ip("0.0.0.0")}], + CONF_AP: _wisp_ap(), + } + ) + with pytest.raises(cv.Invalid, match="dns1"): + _final_validate( + { + CONF_MANUAL_IP: _manual_ip("0.0.0.0"), + CONF_NETWORKS: [{}], + CONF_AP: _wisp_ap(), + } + ) + + +@pytest.mark.usefixtures("wisp_core") +def test_wisp_manual_ip_with_dns1_or_dns_servers() -> None: + _final_validate( + {CONF_NETWORKS: [{CONF_MANUAL_IP: _manual_ip("1.1.1.1")}], CONF_AP: _wisp_ap()} + ) + _final_validate( + { + CONF_NETWORKS: [{CONF_MANUAL_IP: _manual_ip("0.0.0.0")}], + CONF_AP: _wisp_ap(**{CONF_DNS_SERVERS: [IPv4Address("1.1.1.1")]}), + } + ) + + +@pytest.mark.usefixtures("wisp_core") +def test_wisp_final_validate_accepts_sta_and_ap() -> None: + _final_validate({CONF_NETWORKS: [{}], CONF_AP: _wisp_ap()}) + # NAT needs the WiFi driver buffers, not the lwip TCP windows + assert CORE.data.get(KEY_HIGH_PERFORMANCE_WIFI) is True + assert not CORE.data.get(KEY_HIGH_PERFORMANCE_NETWORKING) From d8ca4a9460129bcd9c6d528a222df283fe008951 Mon Sep 17 00:00:00 2001 From: Giovanni <105289353+GioMez@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:50:41 +0200 Subject: [PATCH 129/142] [audio_dac] Add ES9038Q2M Katana platform (#15017) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../components/es9038q2m_katana/__init__.py | 3 + .../components/es9038q2m_katana/audio_dac.py | 92 +++++++++ .../es9038q2m_katana/es9038q2m_katana.cpp | 176 ++++++++++++++++++ .../es9038q2m_katana/es9038q2m_katana.h | 62 ++++++ tests/components/es9038q2m_katana/common.yaml | 16 ++ .../es9038q2m_katana/test.esp32-idf.yaml | 3 + .../es9038q2m_katana/test.esp8266-ard.yaml | 3 + 8 files changed, 356 insertions(+) create mode 100644 esphome/components/es9038q2m_katana/__init__.py create mode 100644 esphome/components/es9038q2m_katana/audio_dac.py create mode 100644 esphome/components/es9038q2m_katana/es9038q2m_katana.cpp create mode 100644 esphome/components/es9038q2m_katana/es9038q2m_katana.h create mode 100644 tests/components/es9038q2m_katana/common.yaml create mode 100644 tests/components/es9038q2m_katana/test.esp32-idf.yaml create mode 100644 tests/components/es9038q2m_katana/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 56cb685ec53..6adf2a93594 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -183,6 +183,7 @@ esphome/components/es7243e/* @kbx81 esphome/components/es8156/* @kbx81 esphome/components/es8311/* @kahrendt @kroimon esphome/components/es8388/* @P4uLT +esphome/components/es9038q2m_katana/* @GioMez esphome/components/esp32/* @esphome/core esphome/components/esp32_ble/* @bdraco @jesserockz @Rapsssito esphome/components/esp32_ble_client/* @bdraco @jesserockz diff --git a/esphome/components/es9038q2m_katana/__init__.py b/esphome/components/es9038q2m_katana/__init__.py new file mode 100644 index 00000000000..5950268ad04 --- /dev/null +++ b/esphome/components/es9038q2m_katana/__init__.py @@ -0,0 +1,3 @@ +CODEOWNERS = ["@GioMez"] +DEPENDENCIES = ["i2c"] +DOMAIN = "es9038q2m_katana" diff --git a/esphome/components/es9038q2m_katana/audio_dac.py b/esphome/components/es9038q2m_katana/audio_dac.py new file mode 100644 index 00000000000..f5c14d234c6 --- /dev/null +++ b/esphome/components/es9038q2m_katana/audio_dac.py @@ -0,0 +1,92 @@ +import esphome.codegen as cg +from esphome.components import audio_dac, i2c +import esphome.config_validation as cv +from esphome.const import CONF_BITS_PER_SAMPLE, CONF_ID, CONF_SAMPLE_RATE +from esphome.types import ConfigType + +CONF_DEEMPHASIS = "deemphasis" +CONF_DOP = "dop" +CONF_FILTER_SHAPE = "filter_shape" + +# Fields of the Katana format register: channel layout, word length and rate code. +FORMAT_STEREO = 0x80 +BITS_PER_SAMPLE_CODES = {16: 0x10, 24: 0x20, 32: 0x30} +SAMPLE_RATE_CODES = { + 44100: 0x04, + 48000: 0x05, + 88200: 0x06, + 96000: 0x07, + 176400: 0x08, + 192000: 0x09, + 352800: 0x0A, + 384000: 0x0B, +} + +es9038q2m_katana_ns = cg.esphome_ns.namespace("es9038q2m_katana") +ES9038Q2MKatana = es9038q2m_katana_ns.class_( + "ES9038Q2MKatana", + audio_dac.AudioDac, + cg.Component, + i2c.I2CDevice, +) + +FilterShape = es9038q2m_katana_ns.enum("FilterShape") +FILTER_SHAPES = { + "linear_phase_fast": FilterShape.FILTER_SHAPE_LINEAR_PHASE_FAST, + "linear_phase_slow": FilterShape.FILTER_SHAPE_LINEAR_PHASE_SLOW, + "minimum_phase_fast": FilterShape.FILTER_SHAPE_MIN_PHASE_FAST, + "minimum_phase_slow": FilterShape.FILTER_SHAPE_MIN_PHASE_SLOW, + "apodizing": FilterShape.FILTER_SHAPE_APODIZING, + "hybrid": FilterShape.FILTER_SHAPE_HYBRID, + "brick_wall": FilterShape.FILTER_SHAPE_BRICK_WALL, +} + +DeemphasisMode = es9038q2m_katana_ns.enum("DeemphasisMode") +DEEMPHASIS_MODES = { + "bypass": DeemphasisMode.DEEMPHASIS_BYPASS, + "32khz": DeemphasisMode.DEEMPHASIS_32KHZ, + "44_1khz": DeemphasisMode.DEEMPHASIS_44_1KHZ, + "48khz": DeemphasisMode.DEEMPHASIS_48KHZ, +} + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ES9038Q2MKatana), + cv.Optional(CONF_FILTER_SHAPE, default="apodizing"): cv.enum( + FILTER_SHAPES, lower=True + ), + cv.Optional(CONF_BITS_PER_SAMPLE, default="16bit"): cv.All( + cv.float_with_unit("bits", "bit"), + cv.one_of(*BITS_PER_SAMPLE_CODES, int=True), + ), + cv.Optional(CONF_SAMPLE_RATE, default=48000): cv.one_of( + *SAMPLE_RATE_CODES, int=True + ), + cv.Optional(CONF_DEEMPHASIS, default="bypass"): cv.enum( + DEEMPHASIS_MODES, lower=True + ), + # Only switches the control logic; the source must already send DoP frames. + cv.Optional(CONF_DOP, default=False): cv.boolean, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x30)) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + cg.add(var.set_filter_shape(config[CONF_FILTER_SHAPE])) + cg.add( + var.set_format( + FORMAT_STEREO + | BITS_PER_SAMPLE_CODES[config[CONF_BITS_PER_SAMPLE]] + | SAMPLE_RATE_CODES[config[CONF_SAMPLE_RATE]] + ) + ) + cg.add(var.set_deemphasis_mode(config[CONF_DEEMPHASIS])) + cg.add(var.set_dop_enabled(config[CONF_DOP])) diff --git a/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp b/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp new file mode 100644 index 00000000000..464f971acef --- /dev/null +++ b/esphome/components/es9038q2m_katana/es9038q2m_katana.cpp @@ -0,0 +1,176 @@ +#include "es9038q2m_katana.h" + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::es9038q2m_katana { + +ESPHOME_LOG_TAG(TAG, "es9038q2m_katana"); + +namespace { + +constexpr uint8_t KATANA_CHIP_ID = 0x30; + +constexpr uint8_t KATANA_REG_CHIP_ID = 0x00; +constexpr uint8_t KATANA_REG_RESET = 0x01; +constexpr uint8_t KATANA_REG_VOLUME_1 = 0x02; +constexpr uint8_t KATANA_REG_VOLUME_2 = 0x03; +constexpr uint8_t KATANA_REG_MUTE = 0x04; +constexpr uint8_t KATANA_REG_DSP_PROGRAM = 0x05; +constexpr uint8_t KATANA_REG_DEEMPHASIS = 0x06; +constexpr uint8_t KATANA_REG_DOP = 0x07; +constexpr uint8_t KATANA_REG_FORMAT = 0x08; +constexpr uint8_t KATANA_REG_COMMAND = 0x09; +constexpr uint8_t KATANA_REG_MUTE_STREAM = 0x0A; + +constexpr float KATANA_VOLUME_MIDPOINT = 0.5f; +constexpr float KATANA_VOLUME_MIDPOINT_STEPS = 50.0f; // -25 dB at 50% volume. +// Cubic amplitude gain: -20 * log10(volume^3) / 0.5 attenuation steps. +constexpr float KATANA_VOLUME_CUBIC_SCALE = 120.0f; +constexpr uint8_t KATANA_VOLUME_FULL_ATTENUATION = 255; + +// The control logic ignores writes for this long after a reset. +constexpr uint32_t RESET_SETTLE_DELAY_MS = 10; + +const LogString *filter_shape_to_str(FilterShape shape) { + switch (shape) { + case FILTER_SHAPE_LINEAR_PHASE_FAST: + return LOG_STR("linear_phase_fast"); + case FILTER_SHAPE_LINEAR_PHASE_SLOW: + return LOG_STR("linear_phase_slow"); + case FILTER_SHAPE_MIN_PHASE_FAST: + return LOG_STR("minimum_phase_fast"); + case FILTER_SHAPE_MIN_PHASE_SLOW: + return LOG_STR("minimum_phase_slow"); + case FILTER_SHAPE_APODIZING: + return LOG_STR("apodizing"); + case FILTER_SHAPE_HYBRID: + return LOG_STR("hybrid"); + default: + return LOG_STR("brick_wall"); + } +} + +const LogString *deemphasis_to_str(DeemphasisMode mode) { + switch (mode) { + case DEEMPHASIS_32KHZ: + return LOG_STR("32khz"); + case DEEMPHASIS_44_1KHZ: + return LOG_STR("44_1khz"); + case DEEMPHASIS_48KHZ: + return LOG_STR("48khz"); + default: + return LOG_STR("bypass"); + } +} + +} // namespace + +void ES9038Q2MKatana::setup() { + uint8_t chip_id = 0; + if (!this->read_byte(KATANA_REG_CHIP_ID, &chip_id)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if (chip_id != KATANA_CHIP_ID) { + ESP_LOGE(TAG, "Unexpected chip ID 0x%02X", chip_id); + this->mark_failed(LOG_STR("Unexpected chip ID")); + return; + } + if (!this->write_byte(KATANA_REG_RESET, 0x01)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + delay(RESET_SETTLE_DELAY_MS); + + // MUTE_STREAM is the gate the Linux driver closes between streams; ESPHome has no such + // notion, so it stays open and mute is only ever driven through MUTE. + if (!this->write_byte(KATANA_REG_FORMAT, this->format_) || !this->write_byte(KATANA_REG_COMMAND, 0x00) || + !this->write_byte(KATANA_REG_DSP_PROGRAM, this->filter_shape_) || + !this->write_byte(KATANA_REG_DEEMPHASIS, this->deemphasis_mode_) || + !this->write_byte(KATANA_REG_DOP, this->dop_enabled_ ? 1 : 0) || !this->write_volume_() || + !this->write_byte(KATANA_REG_MUTE, this->is_muted_ ? 1 : 0) || !this->write_byte(KATANA_REG_MUTE_STREAM, 0x00)) { + this->mark_failed(LOG_STR("Failed to configure the control logic")); + } +} + +void ES9038Q2MKatana::dump_config() { + ESP_LOGCONFIG(TAG, + "ES9038Q2M Katana:\n" + " Filter shape: %s\n" + " De-emphasis: %s\n" + " Format register: 0x%02X\n" + " DoP: %s", + LOG_STR_ARG(filter_shape_to_str(this->filter_shape_)), + LOG_STR_ARG(deemphasis_to_str(this->deemphasis_mode_)), this->format_, ONOFF(this->dop_enabled_)); + LOG_I2C_DEVICE(this); + if (this->is_failed()) + return; +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + for (uint8_t reg = KATANA_REG_CHIP_ID; reg <= KATANA_REG_MUTE_STREAM; reg++) { + uint8_t value = 0; + if (this->read_byte(reg, &value)) { + ESP_LOGV(TAG, " Register 0x%02X: 0x%02X", reg, value); + } else { + ESP_LOGV(TAG, " Register 0x%02X: read failed", reg); + } + } +#endif +} + +// Both channel registers follow ESPHome's single master volume. +bool ES9038Q2MKatana::write_volume_() { + return this->write_byte(KATANA_REG_VOLUME_1, this->volume_reg_) && + this->write_byte(KATANA_REG_VOLUME_2, this->volume_reg_); +} + +// Records the outcome of a runtime write; the cached state only changes on success. +bool ES9038Q2MKatana::check_write_(bool ok) { + if (ok) { + this->status_clear_warning(); + } else { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + } + return ok; +} + +bool ES9038Q2MKatana::set_volume(float volume) { + if (this->is_failed()) + return false; + volume = clamp(volume, 0.0f, 1.0f); + const uint8_t previous = this->volume_reg_; + float attenuation = KATANA_VOLUME_FULL_ATTENUATION; + if (volume >= KATANA_VOLUME_MIDPOINT) { + attenuation = KATANA_VOLUME_MIDPOINT_STEPS * (1.0f - volume) / (1.0f - KATANA_VOLUME_MIDPOINT); + } else if (volume > 0.0f) { + attenuation = KATANA_VOLUME_MIDPOINT_STEPS - KATANA_VOLUME_CUBIC_SCALE * log10f(volume / KATANA_VOLUME_MIDPOINT); + } + this->volume_reg_ = + static_cast(lroundf(clamp(attenuation, 0.0f, static_cast(KATANA_VOLUME_FULL_ATTENUATION)))); + if (this->check_write_(this->write_volume_())) + return true; + this->volume_reg_ = previous; + return false; +} + +float ES9038Q2MKatana::volume() { + if (this->volume_reg_ == KATANA_VOLUME_FULL_ATTENUATION) + return 0.0f; + if (this->volume_reg_ <= KATANA_VOLUME_MIDPOINT_STEPS) + return 1.0f - this->volume_reg_ * (1.0f - KATANA_VOLUME_MIDPOINT) / KATANA_VOLUME_MIDPOINT_STEPS; + return KATANA_VOLUME_MIDPOINT * + powf(10.0f, (KATANA_VOLUME_MIDPOINT_STEPS - this->volume_reg_) / KATANA_VOLUME_CUBIC_SCALE); +} + +bool ES9038Q2MKatana::set_mute_state_(bool mute_state) { + if (this->is_failed()) + return false; + if (!this->check_write_(this->write_byte(KATANA_REG_MUTE, mute_state ? 1 : 0))) + return false; + this->is_muted_ = mute_state; + return true; +} + +} // namespace esphome::es9038q2m_katana diff --git a/esphome/components/es9038q2m_katana/es9038q2m_katana.h b/esphome/components/es9038q2m_katana/es9038q2m_katana.h new file mode 100644 index 00000000000..1d900f9bab4 --- /dev/null +++ b/esphome/components/es9038q2m_katana/es9038q2m_katana.h @@ -0,0 +1,62 @@ +#pragma once + +#include "esphome/components/audio_dac/audio_dac.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::es9038q2m_katana { + +// DSP program numbers of the Katana control interface; there is no program 5. +enum FilterShape : uint8_t { + FILTER_SHAPE_LINEAR_PHASE_FAST = 0, + FILTER_SHAPE_LINEAR_PHASE_SLOW = 1, + FILTER_SHAPE_MIN_PHASE_FAST = 2, + FILTER_SHAPE_MIN_PHASE_SLOW = 3, + FILTER_SHAPE_APODIZING = 4, + FILTER_SHAPE_HYBRID = 6, + FILTER_SHAPE_BRICK_WALL = 7, +}; + +enum DeemphasisMode : uint8_t { + DEEMPHASIS_BYPASS = 0, + DEEMPHASIS_32KHZ = 1, + DEEMPHASIS_44_1KHZ = 2, + DEEMPHASIS_48KHZ = 3, +}; + +// Boards that expose an ES9038Q2M through the Allo Katana control logic at I2C address 0x30. +// Ported from the Linux driver sound/soc/bcm/allo-katana-codec.c; the DAC's own registers +// are never written, the control logic applies the configuration itself. +class ES9038Q2MKatana : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + + // Cubic below 50%, then 0.5 dB per 1% above, meeting at -25 dB. + // Zero selects full attenuation (-127.5 dB). + bool set_volume(float volume) override; + float volume() override; + bool set_mute_off() override { return this->set_mute_state_(false); } + bool set_mute_on() override { return this->set_mute_state_(true); } + bool is_muted() override { return this->is_muted_; } + + void set_filter_shape(FilterShape shape) { this->filter_shape_ = shape; } + void set_format(uint8_t format) { this->format_ = format; } + void set_deemphasis_mode(DeemphasisMode mode) { this->deemphasis_mode_ = mode; } + void set_dop_enabled(bool enabled) { this->dop_enabled_ = enabled; } + + protected: + bool set_mute_state_(bool mute_state); + bool write_volume_(); + bool check_write_(bool ok); + + FilterShape filter_shape_{FILTER_SHAPE_APODIZING}; + DeemphasisMode deemphasis_mode_{DEEMPHASIS_BYPASS}; + uint8_t format_{0}; + // Attenuation in 0.5 dB steps: 0 is loudest, 255 quietest. Starts 20 dB down. + uint8_t volume_reg_{0x28}; + bool dop_enabled_{false}; +}; + +} // namespace esphome::es9038q2m_katana diff --git a/tests/components/es9038q2m_katana/common.yaml b/tests/components/es9038q2m_katana/common.yaml new file mode 100644 index 00000000000..074eb47eeaa --- /dev/null +++ b/tests/components/es9038q2m_katana/common.yaml @@ -0,0 +1,16 @@ +esphome: + on_boot: + then: + - audio_dac.mute_off: + - audio_dac.mute_on: + - audio_dac.set_volume: + volume: 50% + +audio_dac: + - platform: es9038q2m_katana + i2c_id: i2c_bus + filter_shape: hybrid + bits_per_sample: 24bit + sample_rate: 96000 + deemphasis: 48khz + dop: true diff --git a/tests/components/es9038q2m_katana/test.esp32-idf.yaml b/tests/components/es9038q2m_katana/test.esp32-idf.yaml new file mode 100644 index 00000000000..c5822be8f86 --- /dev/null +++ b/tests/components/es9038q2m_katana/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + es9038q2m_katana: !include common.yaml diff --git a/tests/components/es9038q2m_katana/test.esp8266-ard.yaml b/tests/components/es9038q2m_katana/test.esp8266-ard.yaml new file mode 100644 index 00000000000..b2ff0d1aceb --- /dev/null +++ b/tests/components/es9038q2m_katana/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + es9038q2m_katana: !include common.yaml From 4df6291febc11f21b3be58f6761b62af2f4e5af7 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Wed, 14 Oct 2026 18:56:51 -0400 Subject: [PATCH 130/142] [api][sendspin] Add the Sendspin client id to the capabilities message (#20775) Co-authored-by: J. Nick Koston --- esphome/components/api/api.proto | 3 +++ esphome/components/api/api_connection.cpp | 1 + esphome/components/api/api_pb2.cpp | 2 ++ esphome/components/api/api_pb2.h | 1 + esphome/components/api/api_pb2_dump.cpp | 1 + esphome/components/sendspin/sendspin_hub.cpp | 8 ++++++++ esphome/components/sendspin/sendspin_hub.h | 6 ++++++ tests/unit_tests/components/api/test_api_proto.py | 2 +- 8 files changed, 23 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index d55cf10c47d..41ece9275fd 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -403,6 +403,9 @@ message WizardCapabilities { message SendspinCapabilities { // Bitmask of the Sendspin features this device supports uint32 feature_flags = 1; + // The Sendspin client_id: base64url of the client's static public key, 43 characters. + // Empty until the Sendspin client has started once since boot. + string client_id = 2 [(max_data_length) = 43]; } message DeviceCapabilitiesResponse { diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index ca2397b5d78..bcb6486a84d 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -2131,6 +2131,7 @@ bool APIConnection::send_device_capabilities_response_() { #endif #if defined(USE_SENDSPIN) && defined(USE_ESP_IDF) resp.sendspin.feature_flags = sendspin_::global_sendspin_hub->get_feature_flags(); + resp.sendspin.client_id = sendspin_::global_sendspin_hub->get_client_id(); #endif return this->send_message(resp); } diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index bcfa86763a1..227af11bc08 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -310,12 +310,14 @@ uint32_t WizardCapabilities::calc_size_msg(const void *self) { uint8_t *SendspinCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { const auto &msg = *static_cast(self); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.feature_flags); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.client_id); return pos; } uint32_t SendspinCapabilities::calc_size_msg(const void *self) { const auto &msg = *static_cast(self); uint32_t size = 0; size += ProtoSize::calc_uint32(1, msg.feature_flags); + size += !msg.client_id.empty() ? 2 + msg.client_id.size() : 0; return size; } #endif diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 7c8082a8c7c..7ec8955610c 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -750,6 +750,7 @@ class WizardCapabilities final : public ProtoMessage { class SendspinCapabilities final : public ProtoMessage { public: uint32_t feature_flags{0}; + StringRef client_id{nullptr, 0}; // null until set, encode only static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index ec224c46e29..23ce6319ed1 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1119,6 +1119,7 @@ const char *WizardCapabilities::dump_to(DumpBuffer &out) const { const char *SendspinCapabilities::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinCapabilities")); dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); + dump_field(out, ESPHOME_PSTR("client_id"), this->client_id); return out.c_str(); } #endif diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 8b5eaeeed23..701676503f4 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -243,6 +243,14 @@ std::optional SendspinHub::get_pairing_token() const { return this->client_->pairing_token(); } +// THREAD CONTEXT: Main loop +StringRef SendspinHub::get_client_id() const { + if (this->client_ == nullptr) { + return StringRef(); + } + return StringRef(this->client_->client_id()); +} + // THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components) void SendspinHub::set_unpaired_access_enabled(bool enabled) { this->unpaired_access_ = enabled; diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index cd8d0d0afaf..23c60d215b6 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -198,6 +198,12 @@ class SendspinHub final : public Component, /// started once; it stays available after the client stops. Main loop only. std::optional get_pairing_token() const; + /// @brief Returns the Sendspin client_id: base64url of the client's static public key. + /// + /// Empty until the client has started once; it stays set after the client stops. The returned StringRef points into + /// the client's string, which start() may rewrite, so do not keep it past the current call. Main loop only. + StringRef get_client_id() const; + /// @brief Sets the device information reported to the server in the `client/hello` message. /// /// Each takes a pointer to a string literal emitted by codegen, so it must stay valid for the diff --git a/tests/unit_tests/components/api/test_api_proto.py b/tests/unit_tests/components/api/test_api_proto.py index ca3c0ce7b58..68e3cab2a21 100644 --- a/tests/unit_tests/components/api/test_api_proto.py +++ b/tests/unit_tests/components/api/test_api_proto.py @@ -71,7 +71,7 @@ NEW_CAPABILITY_FIELDS: dict[str, dict[str, int]] = { "sendspin": 6, }, "WizardCapabilities": {"configured": 1}, - "SendspinCapabilities": {"feature_flags": 1}, + "SendspinCapabilities": {"feature_flags": 1, "client_id": 2}, "BluetoothProxyCapabilities": { "feature_flags": 1, "mac_address": 2, From 471774cdbfb4962bb171cfd11f4a0af11604ed4f Mon Sep 17 00:00:00 2001 From: gledian Date: Thu, 15 Oct 2026 01:14:04 +0200 Subject: [PATCH 131/142] [ecocomfort2] Fix the clock sync and read boost from the unit state (#20782) Co-authored-by: Claude Opus 5.5 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../ecocomfort2/ecocomfort2_fan.cpp | 7 +--- .../ecocomfort2/ecocomfort2_hub.cpp | 36 ++++++++++--------- .../components/ecocomfort2/ecocomfort2_hub.h | 4 +-- 3 files changed, 22 insertions(+), 25 deletions(-) diff --git a/esphome/components/ecocomfort2/ecocomfort2_fan.cpp b/esphome/components/ecocomfort2/ecocomfort2_fan.cpp index c9d64892c48..d41bd311b7d 100644 --- a/esphome/components/ecocomfort2/ecocomfort2_fan.cpp +++ b/esphome/components/ecocomfort2/ecocomfort2_fan.cpp @@ -118,13 +118,8 @@ void Ecocomfort2Fan::on_status() { int new_speed = this->speed; uint8_t base_speed = raw_speed & SPEED_MASK; - if (raw_speed & FLAG_BOOST) { - new_speed = SPEED_COUNT; - } else if (raw_speed & FLAG_NIGHT) { - new_speed = SPEED_SLEEP; - } else if (base_speed >= SPEED_SLEEP && base_speed <= SPEED_VEL3) { + if (base_speed >= SPEED_SLEEP && base_speed <= SPEED_VEL3) new_speed = base_speed; - } const Preset *preset = find_preset(mode, (raw_speed & FLAG_AUTO) != 0); const char *new_preset = preset != nullptr ? preset->name : nullptr; bool same_preset = new_preset == nullptr ? !this->has_preset_mode() : this->get_preset_mode().c_str() == new_preset; diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.cpp b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp index 00ce0f20804..223648e3276 100644 --- a/esphome/components/ecocomfort2/ecocomfort2_hub.cpp +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp @@ -205,6 +205,10 @@ void Ecocomfort2Hub::publish_unknown_() { sensor->publish_state(NAN); } #endif +#ifdef USE_BINARY_SENSOR + if (this->boost_binary_sensor_ != nullptr) + this->boost_binary_sensor_->invalidate_state(); +#endif } // C_OPER: mode byte, speed/flags byte @@ -216,10 +220,6 @@ void Ecocomfort2Hub::on_oper_(const uint8_t *data, uint16_t len) { this->mode_ = data[0]; this->speed_ = data[1]; ESP_LOGD(TAG, "[%s] C_OPER: mode=0x%02x, speed=0x%02x", this->parent_->address_str(), this->mode_, this->speed_); -#ifdef USE_BINARY_SENSOR - if (this->boost_binary_sensor_ != nullptr) - this->boost_binary_sensor_->publish_state((this->speed_ & FLAG_BOOST) != 0); -#endif #ifdef ECOCOMFORT2_CHILD_COUNT for (auto *child : this->children_) { child->on_status(); @@ -227,7 +227,9 @@ void Ecocomfort2Hub::on_oper_(const uint8_t *data, uint16_t len) { #endif } -// C_STATE: bytes 3-4 temperature and 5-6 humidity in hundredths, 7-8 VOC in ppb, byte 2 the air flow direction +// C_STATE: bytes 0-1 the mode and speed the unit runs; byte 1 also has the boost flag (0x40) and the advanced +// control step (0x20). Byte 2 the air flow direction, bytes 3-4 temperature and 5-6 humidity in hundredths, +// 7-8 VOC in ppb void Ecocomfort2Hub::on_state_(const uint8_t *data, uint16_t len) { if (len < 9) { ESP_LOGW(TAG, "[%s] C_STATE too short: %u bytes", this->parent_->address_str(), len); @@ -236,8 +238,12 @@ void Ecocomfort2Hub::on_state_(const uint8_t *data, uint16_t len) { const float temperature = static_cast(encode_uint16(data[3], data[4])) / 100.0f; const float humidity = encode_uint16(data[5], data[6]) / 100.0f; const uint16_t voc = encode_uint16(data[7], data[8]); - ESP_LOGD(TAG, "[%s] C_STATE: %.2f °C, %.2f %%, %u ppb, direction=%u", this->parent_->address_str(), temperature, - humidity, voc, data[2]); + ESP_LOGD(TAG, "[%s] C_STATE: mode=0x%02x, speed=0x%02x, %.2f °C, %.2f %%, %u ppb, direction=%u", + this->parent_->address_str(), data[0], data[1], temperature, humidity, voc, data[2]); +#ifdef USE_BINARY_SENSOR + if (this->boost_binary_sensor_ != nullptr) + this->boost_binary_sensor_->publish_state((data[1] & FLAG_BOOST) != 0); +#endif #ifdef USE_SENSOR if (this->temperature_sensor_ != nullptr) this->temperature_sensor_->publish_state(temperature); @@ -267,7 +273,7 @@ void Ecocomfort2Hub::on_info_(const uint8_t *data, uint16_t len) { } #ifdef USE_TIME -// C_CLOCK: hour, minute, second, weekday with Monday as 0, day, month, year as two bytes +// C_CLOCK: two-digit year, month, day, weekday with Monday as 0, hour, minute, second, automatic DST flag void Ecocomfort2Hub::sync_clock_() { if (!this->is_ready() || this->char_handle_clock_ == 0) return; @@ -276,15 +282,11 @@ void Ecocomfort2Hub::sync_clock_() { return; // ESPTime counts Sunday=1 .. Saturday=7, the unit Monday=0 .. Sunday=6 const uint8_t weekday = (now.day_of_week + 5) % 7; - uint8_t data[8]{now.hour, - now.minute, - now.second, - weekday, - now.day_of_month, - now.month, - static_cast(now.year >> 8), - static_cast(now.year)}; - ESP_LOGD(TAG, "[%s] Syncing clock to %02u:%02u:%02u", this->parent_->address_str(), now.hour, now.minute, now.second); + // The time source already applies DST and every sync rewrites the clock, so the unit must not shift it again + uint8_t data[8]{ + static_cast(now.year % 100), now.month, now.day_of_month, weekday, now.hour, now.minute, now.second, 0}; + ESP_LOGD(TAG, "[%s] Syncing clock to %04u-%02u-%02u %02u:%02u:%02u", this->parent_->address_str(), now.year, + now.month, now.day_of_month, now.hour, now.minute, now.second); auto status = esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_clock_, sizeof(data), data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.h b/esphome/components/ecocomfort2/ecocomfort2_hub.h index ed046fe0495..2f65914ddec 100644 --- a/esphome/components/ecocomfort2/ecocomfort2_hub.h +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.h @@ -32,7 +32,8 @@ static constexpr uint8_t OPER_OUT = 0x02; static constexpr uint8_t OPER_IN_OUT = 0x03; static constexpr uint8_t OPER_SENSOR_OR_AUTO = 0x04; -// C_OPER byte 1: speed in the low nibble, flags in the high nibble +// Speed byte: speed in the low nibble, flags in the high nibble. +// C_OPER carries FLAG_AUTO; the unit reports FLAG_BOOST in byte 1 of C_STATE. static constexpr uint8_t SPEED_OFF = 0x00; static constexpr uint8_t SPEED_SLEEP = 0x01; static constexpr uint8_t SPEED_VEL1 = 0x02; @@ -41,7 +42,6 @@ static constexpr uint8_t SPEED_VEL3 = 0x04; static constexpr uint8_t SPEED_MASK = 0x0F; static constexpr uint8_t FLAG_AUTO = 0x10; static constexpr uint8_t FLAG_BOOST = 0x40; -static constexpr uint8_t FLAG_NIGHT = 0x80; /** Hub component for Ecocomfort 2.0 VMC via BLE. */ class Ecocomfort2Hub : public esphome::ble_client::BLEClientNode, public PollingComponent { From eafeef7cb8177a53c418177579ef10e7176be6a5 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Thu, 15 Oct 2026 01:18:20 +0200 Subject: [PATCH 132/142] [fusb302b] Add FUSB302B driver (#20760) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/fusb302b/__init__.py | 38 ++ esphome/components/fusb302b/fusb302b.cpp | 415 ++++++++++++++++++ esphome/components/fusb302b/fusb302b.h | 74 ++++ .../components/fusb302b/fusb302b_registers.h | 94 ++++ esphome/components/fusb302b/pd.h | 1 + tests/components/fusb302b/common.yaml | 4 + tests/components/fusb302b/test.esp32-idf.yaml | 6 + 7 files changed, 632 insertions(+) create mode 100644 esphome/components/fusb302b/fusb302b.cpp create mode 100644 esphome/components/fusb302b/fusb302b.h create mode 100644 esphome/components/fusb302b/fusb302b_registers.h create mode 100644 tests/components/fusb302b/common.yaml create mode 100644 tests/components/fusb302b/test.esp32-idf.yaml diff --git a/esphome/components/fusb302b/__init__.py b/esphome/components/fusb302b/__init__.py index 3cf9043f418..212ef057e94 100644 --- a/esphome/components/fusb302b/__init__.py +++ b/esphome/components/fusb302b/__init__.py @@ -1,2 +1,40 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_INTERRUPT_PIN +from esphome.types import ConfigType + CODEOWNERS = ["@remcom"] +DEPENDENCIES = ["i2c"] DOMAIN = "fusb302b" + +CONF_REQUEST_VOLTAGE = "request_voltage" + +fusb302b_ns = cg.esphome_ns.namespace("fusb302b") +FUSB302B = fusb302b_ns.class_("FUSB302B", cg.Component, i2c.I2CDevice) + +# USB PD fixed supply voltages are 5, 9, 12, 15 and 20 V; the highest one that does not exceed the +# requested voltage is chosen. +validate_voltage = cv.int_range(min=5, max=20) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(FUSB302B), + cv.Required(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pullup_pin_schema, + cv.Required(CONF_REQUEST_VOLTAGE): validate_voltage, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x22)), + cv.only_on_esp32, +) + + +async def to_code(config: ConfigType) -> None: + interrupt_pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN]) + var = cg.new_Pvariable(config[CONF_ID], interrupt_pin) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + cg.add(var.set_request_voltage(config[CONF_REQUEST_VOLTAGE])) diff --git a/esphome/components/fusb302b/fusb302b.cpp b/esphome/components/fusb302b/fusb302b.cpp new file mode 100644 index 00000000000..6cffbf114f7 --- /dev/null +++ b/esphome/components/fusb302b/fusb302b.cpp @@ -0,0 +1,415 @@ +#include "fusb302b.h" + +#ifdef USE_ESP32 + +#include "esphome/core/log.h" + +#include "fusb302b_registers.h" + +namespace esphome::fusb302b { + +ESPHOME_LOG_TAG(TAG, "fusb302b"); + +// Task notification bits +static constexpr uint32_t NOTIFY_IRQ = 1 << 0; +static constexpr uint32_t NOTIFY_STOP = 1 << 1; + +static constexpr uint32_t TASK_STACK_SIZE = 4096; +static constexpr UBaseType_t TASK_PRIORITY = 18; + +static constexpr uint32_t STARTUP_DELAY_MS = 2000; +static constexpr uint32_t CC_SETTLE_MS = 5; +static constexpr uint32_t CC_POLL_INTERVAL_MS = 500; +static constexpr uint32_t PD_POLL_INTERVAL_MS = 100; +// Safety net in case an interrupt edge is missed while attached and idle +static constexpr uint32_t IDLE_POLL_INTERVAL_MS = 1000; +// Time to wait for the source to send its capabilities on its own after attach +static constexpr uint32_t SOURCE_CAP_WAIT_MS = 5000; +// Time to wait after each GET_SOURCE_CAP / soft reset before the next recovery step +static constexpr uint32_t SOURCE_CAP_RETRY_MS = 2500; +// Upper bound on back-to-back interrupt handling while INT_N stays asserted +static constexpr uint8_t MAX_IRQ_ROUNDS = 8; +// BC_LVL is read this many extra times to confirm a stable measurement +static constexpr uint8_t CC_STABLE_READS = 5; +static constexpr uint8_t CC_LEVEL_UNSTABLE = 0xFF; + +void IRAM_ATTR FUSB302B::gpio_intr(FUSB302B *arg) { + TaskHandle_t handle = arg->task_.get_handle(); + if (handle == nullptr) + return; + BaseType_t woken = pdFALSE; + xTaskNotifyFromISR(handle, NOTIFY_IRQ, eSetBits, &woken); + portYIELD_FROM_ISR(woken); +} + +void FUSB302B::task_func(void *arg) { static_cast(arg)->run_task_(); } + +void FUSB302B::setup() { + uint8_t device_id = 0; + if (!this->read_byte(FUSB_DEVICE_ID, &device_id)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + const uint8_t version = device_id & FUSB_DEVICE_ID_VERSION_MASK; + if (version != FUSB_DEVICE_ID_VERSION_A && version != FUSB_DEVICE_ID_VERSION_B) { + ESP_LOGE(TAG, "Unknown device ID 0x%02X", device_id); + this->mark_failed(); + return; + } + ESP_LOGD(TAG, "Device ID 0x%02X (product %u, revision %u)", device_id, (device_id >> 2) & 0x3, device_id & 0x3); + if (!this->init_chip_()) { + this->mark_failed(LOG_STR("Initialization failed")); + return; + } + + this->interrupt_pin_->setup(); + // The task must exist before the interrupt is attached, since the ISR notifies it + if (!this->task_.create(FUSB302B::task_func, "fusb302b", TASK_STACK_SIZE, this, TASK_PRIORITY, false)) { + this->mark_failed(LOG_STR("Failed to create task")); + return; + } + this->interrupt_pin_->attach_interrupt(&FUSB302B::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); +} + +void FUSB302B::dump_config() { + ESP_LOGCONFIG(TAG, + "FUSB302B:\n" + " Request Voltage: %u V", + this->get_request_voltage()); + LOG_I2C_DEVICE(this); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); +} + +void FUSB302B::on_shutdown() { + this->interrupt_pin_->detach_interrupt(); + // The task parks itself instead of being deleted, so it can never be stopped in the middle of an I2C transfer + if (this->task_.is_created()) + xTaskNotify(this->task_.get_handle(), NOTIFY_STOP, eSetBits); +} + +void FUSB302B::run_task_() { + // Give the source time to settle after power-up before the first CC measurement + vTaskDelay(pdMS_TO_TICKS(STARTUP_DELAY_MS)); + + while (true) { + uint32_t wait_ms = CC_POLL_INTERVAL_MS; + if (this->attached_) { + const bool busy = this->waiting_for_source_caps_ || this->retry_request_pending_ || this->check_ams_(); + wait_ms = busy ? PD_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS; + } + uint32_t bits = 0; + xTaskNotifyWait(0, UINT32_MAX, &bits, pdMS_TO_TICKS(wait_ms)); + + if (bits & NOTIFY_STOP) { + while (true) + vTaskSuspend(nullptr); + } + + // INT_N is held low until the interrupt registers are read, so a new event that arrives while it is + // already low has no falling edge: keep servicing while the pin stays asserted. + if (bits & NOTIFY_IRQ) + this->handle_interrupt_(); + for (uint8_t i = 0; i < MAX_IRQ_ROUNDS && !this->interrupt_pin_->digital_read(); i++) + this->handle_interrupt_(); + + if (!this->attached_) { + this->try_attach_(); + continue; + } + this->check_transition_timeout_(); + this->check_request_retry_(); + this->check_source_caps_(); + } +} + +bool FUSB302B::init_chip_() { + // Software reset restores all registers to their defaults + if (!this->write_byte(FUSB_RESET, FUSB_RESET_SW_RES)) + return false; + if (!this->reset_pd_()) + return false; + + // Unmask the interrupts used for PD messaging and VBUS changes + if (!this->write_byte(FUSB_MASK1, FUSB_MASK1_DEFAULT) || !this->write_byte(FUSB_MASKA, 0) || + !this->write_byte(FUSB_MASKB, 0)) + return false; + + // Let the chip retransmit unacknowledged messages up to 3 times + uint8_t control3 = 0; + if (!this->read_byte(FUSB_CONTROL3, &control3)) + return false; + control3 = + (control3 & ~FUSB_CONTROL3_N_RETRIES_MASK) | (3 << FUSB_CONTROL3_N_RETRIES_SHIFT) | FUSB_CONTROL3_AUTO_RETRY; + if (!this->write_byte(FUSB_CONTROL3, control3)) + return false; + + if (!this->write_byte(FUSB_POWER, FUSB_POWER_ALL)) + return false; + return this->reset_pd_(); +} + +bool FUSB302B::reset_pd_() { + this->reset_protocol_(); + // Writing CONTROL0 as a whole byte also clears INT_MASK, which is what enables the INT_N pin + return this->write_byte(FUSB_CONTROL0, FUSB_CONTROL0_TX_FLUSH) && + this->write_byte(FUSB_CONTROL1, FUSB_CONTROL1_RX_FLUSH) && this->write_byte(FUSB_RESET, FUSB_RESET_PD_RESET); +} + +bool FUSB302B::enable_auto_crc_() { + uint8_t switches1 = 0; + return this->read_byte(FUSB_SWITCHES1, &switches1) && + this->write_byte(FUSB_SWITCHES1, switches1 | FUSB_SWITCHES1_AUTO_CRC); +} + +void FUSB302B::handle_interrupt_() { + // STATUS0A, STATUS1A, INTERRUPTA, INTERRUPTB, STATUS0, STATUS1, INTERRUPT; reading clears the interrupts + uint8_t regs[7]; + if (this->read_register(FUSB_STATUS0A, regs, sizeof(regs)) != i2c::ERROR_OK) { + this->enter_error_(); + return; + } + const uint8_t interrupta = regs[2]; + const uint8_t status0 = regs[4]; + uint8_t status1 = regs[5]; + const uint8_t interrupt = regs[6]; + + if ((interrupt & FUSB_INTERRUPT_I_VBUSOK) && !(status0 & FUSB_STATUS0_VBUSOK)) { + if (this->attached_) + this->detach_(); + return; + } + if (interrupta & FUSB_INTERRUPTA_I_HARDRST) { + if (this->attached_) + this->handle_hard_reset_(); + return; + } + if (interrupta & FUSB_INTERRUPTA_I_SOFTRST) { + ESP_LOGV(TAG, "Soft reset received"); + } + if ((interrupta & FUSB_INTERRUPTA_I_RETRYFAIL) && this->attached_) + this->handle_transmit_failed_(); + + if (!this->attached_) + return; + PdMsg msg; + while (!(status1 & FUSB_STATUS1_RX_EMPTY)) { + bool is_sop = false; + if (!this->read_message_(msg, is_sop)) { + ESP_LOGW(TAG, "Reading message failed"); + // A partial read leaves the FIFO misaligned + this->write_byte(FUSB_CONTROL1, FUSB_CONTROL1_RX_FLUSH); + return; + } + // Only SOP packets are addressed to a sink; SOP'/SOP'' are for cable plugs + if (is_sop) + this->handle_message_(msg); + if (!this->read_byte(FUSB_STATUS1, &status1)) + return; + } +} + +bool FUSB302B::read_message_(PdMsg &msg, bool &is_sop) { + uint8_t token = 0; + uint8_t header[2]; + if (!this->read_byte(FUSB_FIFOS, &token) || this->read_register(FUSB_FIFOS, header, 2) != i2c::ERROR_OK) + return false; + msg.set_header(encode_uint16(header[1], header[0])); + + // Data objects, followed by the CRC32 that the chip already checked + uint8_t buf[PD_MAX_NUM_DATA_OBJECTS * 4 + 4]; + if (this->read_register(FUSB_FIFOS, buf, msg.num_of_obj * 4 + 4) != i2c::ERROR_OK) + return false; + for (uint8_t i = 0; i < msg.num_of_obj; i++) { + const uint8_t *obj = &buf[i * 4]; + msg.data_objects[i] = encode_uint32(obj[3], obj[2], obj[1], obj[0]); + } + is_sop = (token & FUSB_FIFO_RX_TOKEN_BITS) == FUSB_FIFO_RX_SOP; + return true; +} + +bool FUSB302B::send_message(const PdMsg &msg) { + // Register address, then the TX tokens; written in one transfer without the copy write_register() makes + uint8_t buf[1 + 4 + 1 + 2 + PD_MAX_NUM_DATA_OBJECTS * 4 + 4]; + size_t len = 0; + const uint16_t header = msg.get_coded_header(); + + buf[len++] = FUSB_FIFOS; + buf[len++] = FUSB_TX_TOKEN_SYNC1; + buf[len++] = FUSB_TX_TOKEN_SYNC1; + buf[len++] = FUSB_TX_TOKEN_SYNC1; + buf[len++] = FUSB_TX_TOKEN_SYNC2; + buf[len++] = FUSB_TX_TOKEN_PACKSYM | (msg.num_of_obj * 4 + 2); + buf[len++] = header & 0xFF; + buf[len++] = header >> 8; + for (uint8_t i = 0; i < msg.num_of_obj; i++) { + const uint32_t obj = msg.data_objects[i]; + buf[len++] = obj & 0xFF; + buf[len++] = (obj >> 8) & 0xFF; + buf[len++] = (obj >> 16) & 0xFF; + buf[len++] = obj >> 24; + } + buf[len++] = FUSB_TX_TOKEN_JAM_CRC; + buf[len++] = FUSB_TX_TOKEN_EOP; + buf[len++] = FUSB_TX_TOKEN_TXOFF; + buf[len++] = FUSB_TX_TOKEN_TXON; + + i2c::ErrorCode err = this->write(buf, len); + if (err != i2c::ERROR_OK) { + ESP_LOGW(TAG, "Sending message type %u failed (error %d)", msg.type, static_cast(err)); + return false; + } + return true; +} + +bool FUSB302B::measure_cc_(uint8_t meas_switch, uint8_t &level) { + if (!this->write_byte(FUSB_SWITCHES0, FUSB_SWITCHES0_PDWN_1 | FUSB_SWITCHES0_PDWN_2 | meas_switch)) + return false; + vTaskDelay(pdMS_TO_TICKS(CC_SETTLE_MS)); + uint8_t status0 = 0; + if (!this->read_byte(FUSB_STATUS0, &status0)) + return false; + level = status0 & FUSB_STATUS0_BC_LVL; + for (uint8_t i = 0; i < CC_STABLE_READS; i++) { + if (!this->read_byte(FUSB_STATUS0, &status0)) + return false; + if ((status0 & FUSB_STATUS0_BC_LVL) != level) { + level = CC_LEVEL_UNSTABLE; + return true; + } + } + return true; +} + +void FUSB302B::try_attach_() { + if (this->state_ == PdState::PD_STATE_ERROR) { + if (!this->init_chip_()) + return; + ESP_LOGI(TAG, "Communication restored"); + this->set_state_(PdState::PD_STATE_DISCONNECTED); + } + + uint8_t status0 = 0; + if (!this->write_byte(FUSB_POWER, FUSB_POWER_ALL) || !this->read_byte(FUSB_STATUS0, &status0)) { + this->enter_error_(); + return; + } + // A source may present its CC pull-up before VBUS is on; detach is only detected when VBUS drops + if (!(status0 & FUSB_STATUS0_VBUSOK)) { + this->cc_ambiguous_logged_ = false; + return; + } + + uint8_t cc1 = 0; + uint8_t cc2 = 0; + if (!this->write_byte(FUSB_SWITCHES1, FUSB_SWITCHES1_SPECREV_REV2) || + !this->write_byte(FUSB_MEASURE, FUSB_MEASURE_MDAC_CC) || !this->measure_cc_(FUSB_SWITCHES0_MEAS_CC1, cc1) || + !this->measure_cc_(FUSB_SWITCHES0_MEAS_CC2, cc2)) { + this->enter_error_(); + return; + } + // Exactly one CC line carries the source pull-up; the other one is open + uint8_t meas_switch; + uint8_t tx_switch; + if (cc1 != CC_LEVEL_UNSTABLE && cc1 > 0 && cc2 == 0) { + meas_switch = FUSB_SWITCHES0_MEAS_CC1; + tx_switch = FUSB_SWITCHES1_TXCC1; + } else if (cc2 != CC_LEVEL_UNSTABLE && cc2 > 0 && cc1 == 0) { + meas_switch = FUSB_SWITCHES0_MEAS_CC2; + tx_switch = FUSB_SWITCHES1_TXCC2; + } else { + if (!this->cc_ambiguous_logged_) { + ESP_LOGD(TAG, "VBUS present but no single CC line with a source pull-up (CC1 %u, CC2 %u)", cc1, cc2); + this->cc_ambiguous_logged_ = true; + } + return; + } + + if (!this->write_byte(FUSB_SWITCHES0, FUSB_SWITCHES0_PDWN_1 | FUSB_SWITCHES0_PDWN_2 | meas_switch) || + !this->write_byte(FUSB_SWITCHES1, tx_switch | FUSB_SWITCHES1_SPECREV_REV2) || !this->enable_auto_crc_() || + !this->reset_pd_()) { + this->enter_error_(); + return; + } + ESP_LOGD(TAG, "Attached on CC%u", meas_switch == FUSB_SWITCHES0_MEAS_CC1 ? 1 : 2); + this->attached_ = true; + this->cc_ambiguous_logged_ = false; + this->start_source_cap_wait_(SourceCapStep::SOURCE_CAP_STEP_WAIT); + this->set_state_and_contract_(PdState::PD_STATE_DEFAULT_CONTRACT, DEFAULT_CONTRACT); +} + +void FUSB302B::detach_() { + ESP_LOGD(TAG, "Detached (VBUS lost)"); + this->attached_ = false; + this->waiting_for_source_caps_ = false; + this->reset_pd_(); + this->clear_contract_(); + this->set_state_(PdState::PD_STATE_DISCONNECTED); +} + +void FUSB302B::handle_hard_reset_() { + ESP_LOGD(TAG, "Hard reset received"); + // The source returns to Type-C default power and sends its capabilities again + // Also flushes the RX FIFO, so the caller has nothing left to read + if (!this->reset_pd_()) { + this->enter_error_(); + return; + } + this->set_state_and_contract_(PdState::PD_STATE_DEFAULT_CONTRACT, DEFAULT_CONTRACT); + this->start_source_cap_wait_(SourceCapStep::SOURCE_CAP_STEP_WAIT); +} + +void FUSB302B::enter_error_() { + if (this->state_ == PdState::PD_STATE_ERROR) + return; + ESP_LOGW(TAG, "Communication with the chip failed"); + this->attached_ = false; + this->waiting_for_source_caps_ = false; + this->clear_contract_(); + this->set_state_(PdState::PD_STATE_ERROR); +} + +void FUSB302B::send_soft_reset_() { + this->reset_pd_(); + this->send_message(this->make_control_msg_(PD_CNTRL_SOFT_RESET)); + // The source answers a soft reset with new source capabilities + this->start_source_cap_wait_(SourceCapStep::SOURCE_CAP_STEP_SOFT_RESET_SENT); +} + +void FUSB302B::start_source_cap_wait_(SourceCapStep step) { + this->waiting_for_source_caps_ = true; + this->source_cap_step_ = step; + this->source_cap_step_start_ = millis(); +} + +void FUSB302B::check_source_caps_() { + if (!this->waiting_for_source_caps_ || this->check_ams_()) + return; + const uint32_t elapsed = millis() - this->source_cap_step_start_; + switch (this->source_cap_step_) { + case SourceCapStep::SOURCE_CAP_STEP_WAIT: + if (elapsed < SOURCE_CAP_WAIT_MS) + return; + ESP_LOGD(TAG, "No source capabilities received, requesting them"); + this->send_message(this->make_control_msg_(PD_CNTRL_GET_SOURCE_CAP)); + this->source_cap_step_ = SourceCapStep::SOURCE_CAP_STEP_GET_SENT; + break; + case SourceCapStep::SOURCE_CAP_STEP_GET_SENT: + if (elapsed < SOURCE_CAP_RETRY_MS) + return; + ESP_LOGD(TAG, "No source capabilities received, sending soft reset"); + this->send_soft_reset_(); + return; + case SourceCapStep::SOURCE_CAP_STEP_SOFT_RESET_SENT: + if (elapsed < SOURCE_CAP_RETRY_MS) + return; + ESP_LOGW(TAG, "PD negotiation failed, staying at 5 V"); + this->waiting_for_source_caps_ = false; + this->set_state_(PdState::PD_STATE_PD_TIMEOUT); + return; + } + this->source_cap_step_start_ = millis(); +} + +} // namespace esphome::fusb302b + +#endif // USE_ESP32 diff --git a/esphome/components/fusb302b/fusb302b.h b/esphome/components/fusb302b/fusb302b.h new file mode 100644 index 00000000000..341d64558df --- /dev/null +++ b/esphome/components/fusb302b/fusb302b.h @@ -0,0 +1,74 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/static_task.h" + +#include "pd.h" + +namespace esphome::fusb302b { + +/// FUSB302B USB Type-C / Power Delivery sink controller. +/// +/// A dedicated task owns all I2C traffic to the chip so that PD replies meet the protocol timing +/// even while the main loop is busy. +class FUSB302B : public PowerDelivery, public Component, public i2c::I2CDevice { + public: + explicit FUSB302B(InternalGPIOPin *interrupt_pin) : interrupt_pin_(interrupt_pin) {} + + void setup() override; + void dump_config() override; + void on_shutdown() override; + float get_setup_priority() const override { return setup_priority::HARDWARE; } + + protected: + enum class SourceCapStep : uint8_t { + SOURCE_CAP_STEP_WAIT, + SOURCE_CAP_STEP_GET_SENT, + SOURCE_CAP_STEP_SOFT_RESET_SENT, + }; + + static void gpio_intr(FUSB302B *arg); + static void task_func(void *arg); + + bool send_message(const PdMsg &msg) override; + void on_state_changed() override {} + void on_soft_reset_received() override { this->start_source_cap_wait_(SourceCapStep::SOURCE_CAP_STEP_WAIT); } + void on_source_caps_received() override { this->waiting_for_source_caps_ = false; } + void on_transition_timeout() override { this->send_soft_reset_(); } + + // PHY task only + [[noreturn]] void run_task_(); + bool init_chip_(); + bool reset_pd_(); + bool enable_auto_crc_(); + void handle_interrupt_(); + bool read_message_(PdMsg &msg, bool &is_sop); + void try_attach_(); + bool measure_cc_(uint8_t meas_switch, uint8_t &level); + void detach_(); + void handle_hard_reset_(); + void start_source_cap_wait_(SourceCapStep step); + void send_soft_reset_(); + void check_source_caps_(); + void enter_error_(); + + InternalGPIOPin *interrupt_pin_; + StaticTask task_; + + // PHY task only + uint32_t source_cap_step_start_{0}; + SourceCapStep source_cap_step_{SourceCapStep::SOURCE_CAP_STEP_WAIT}; + bool attached_{false}; + bool waiting_for_source_caps_{false}; + bool cc_ambiguous_logged_{false}; +}; + +} // namespace esphome::fusb302b + +#endif // USE_ESP32 diff --git a/esphome/components/fusb302b/fusb302b_registers.h b/esphome/components/fusb302b/fusb302b_registers.h new file mode 100644 index 00000000000..3efaf57215f --- /dev/null +++ b/esphome/components/fusb302b/fusb302b_registers.h @@ -0,0 +1,94 @@ +#pragma once + +#include + +namespace esphome::fusb302b { + +// Register addresses +static constexpr uint8_t FUSB_DEVICE_ID = 0x01; +static constexpr uint8_t FUSB_SWITCHES0 = 0x02; +static constexpr uint8_t FUSB_SWITCHES1 = 0x03; +static constexpr uint8_t FUSB_MEASURE = 0x04; +static constexpr uint8_t FUSB_CONTROL0 = 0x06; +static constexpr uint8_t FUSB_CONTROL1 = 0x07; +static constexpr uint8_t FUSB_CONTROL3 = 0x09; +static constexpr uint8_t FUSB_MASK1 = 0x0A; +static constexpr uint8_t FUSB_POWER = 0x0B; +static constexpr uint8_t FUSB_RESET = 0x0C; +static constexpr uint8_t FUSB_MASKA = 0x0E; +static constexpr uint8_t FUSB_MASKB = 0x0F; +static constexpr uint8_t FUSB_STATUS0A = 0x3C; +static constexpr uint8_t FUSB_STATUS0 = 0x40; +static constexpr uint8_t FUSB_STATUS1 = 0x41; +static constexpr uint8_t FUSB_FIFOS = 0x43; + +// DEVICE_ID: version in bits 7:4, product variant (I2C address 0x22-0x25) in bits 3:2, silicon revision in bits 1:0 +static constexpr uint8_t FUSB_DEVICE_ID_VERSION_MASK = 0xF0; +static constexpr uint8_t FUSB_DEVICE_ID_VERSION_A = 0x80; +static constexpr uint8_t FUSB_DEVICE_ID_VERSION_B = 0x90; + +// SWITCHES0 bits +static constexpr uint8_t FUSB_SWITCHES0_MEAS_CC2 = 1 << 3; +static constexpr uint8_t FUSB_SWITCHES0_MEAS_CC1 = 1 << 2; +static constexpr uint8_t FUSB_SWITCHES0_PDWN_2 = 1 << 1; +static constexpr uint8_t FUSB_SWITCHES0_PDWN_1 = 1 << 0; + +// SWITCHES1 bits +static constexpr uint8_t FUSB_SWITCHES1_SPECREV_REV2 = 0x01 << 5; +static constexpr uint8_t FUSB_SWITCHES1_AUTO_CRC = 1 << 2; +static constexpr uint8_t FUSB_SWITCHES1_TXCC2 = 1 << 1; +static constexpr uint8_t FUSB_SWITCHES1_TXCC1 = 1 << 0; + +// MEASURE value used for CC detection +static constexpr uint8_t FUSB_MEASURE_MDAC_CC = 49; + +// CONTROL0 bits +static constexpr uint8_t FUSB_CONTROL0_TX_FLUSH = 1 << 6; + +// CONTROL1 bits +static constexpr uint8_t FUSB_CONTROL1_RX_FLUSH = 1 << 2; + +// CONTROL3 bits +static constexpr uint8_t FUSB_CONTROL3_N_RETRIES_SHIFT = 1; +static constexpr uint8_t FUSB_CONTROL3_N_RETRIES_MASK = 0x3 << FUSB_CONTROL3_N_RETRIES_SHIFT; +static constexpr uint8_t FUSB_CONTROL3_AUTO_RETRY = 1 << 0; + +// MASK1: mask the ACTIVITY, CRC_CHK and BC_LVL interrupts +static constexpr uint8_t FUSB_MASK1_DEFAULT = 0x51; + +// POWER bits +static constexpr uint8_t FUSB_POWER_ALL = 0x0F; + +// RESET bits +static constexpr uint8_t FUSB_RESET_PD_RESET = 1 << 1; +static constexpr uint8_t FUSB_RESET_SW_RES = 1 << 0; + +// INTERRUPTA bits +static constexpr uint8_t FUSB_INTERRUPTA_I_RETRYFAIL = 1 << 4; +static constexpr uint8_t FUSB_INTERRUPTA_I_SOFTRST = 1 << 1; +static constexpr uint8_t FUSB_INTERRUPTA_I_HARDRST = 1 << 0; + +// INTERRUPT bits +static constexpr uint8_t FUSB_INTERRUPT_I_VBUSOK = 1 << 7; + +// STATUS0 bits +static constexpr uint8_t FUSB_STATUS0_VBUSOK = 1 << 7; +static constexpr uint8_t FUSB_STATUS0_BC_LVL = 0x03; + +// STATUS1 bits +static constexpr uint8_t FUSB_STATUS1_RX_EMPTY = 1 << 5; + +// RX FIFO token +static constexpr uint8_t FUSB_FIFO_RX_TOKEN_BITS = 0xE0; +static constexpr uint8_t FUSB_FIFO_RX_SOP = 0xE0; + +// TX FIFO tokens +static constexpr uint8_t FUSB_TX_TOKEN_TXON = 0xA1; +static constexpr uint8_t FUSB_TX_TOKEN_SYNC1 = 0x12; +static constexpr uint8_t FUSB_TX_TOKEN_SYNC2 = 0x13; +static constexpr uint8_t FUSB_TX_TOKEN_PACKSYM = 0x80; +static constexpr uint8_t FUSB_TX_TOKEN_JAM_CRC = 0xFF; +static constexpr uint8_t FUSB_TX_TOKEN_EOP = 0x14; +static constexpr uint8_t FUSB_TX_TOKEN_TXOFF = 0xFE; + +} // namespace esphome::fusb302b diff --git a/esphome/components/fusb302b/pd.h b/esphome/components/fusb302b/pd.h index 26c75239152..66918ce98da 100644 --- a/esphome/components/fusb302b/pd.h +++ b/esphome/components/fusb302b/pd.h @@ -30,6 +30,7 @@ enum PdControlMsgType : uint8_t { PD_CNTRL_REJECT = 0x04, PD_CNTRL_PING = 0x05, PD_CNTRL_PS_RDY = 0x06, + PD_CNTRL_GET_SOURCE_CAP = 0x07, PD_CNTRL_GET_SINK_CAP = 0x08, PD_CNTRL_WAIT = 0x0C, PD_CNTRL_SOFT_RESET = 0x0D, diff --git a/tests/components/fusb302b/common.yaml b/tests/components/fusb302b/common.yaml new file mode 100644 index 00000000000..e684462ba7c --- /dev/null +++ b/tests/components/fusb302b/common.yaml @@ -0,0 +1,4 @@ +fusb302b: + id: pd + interrupt_pin: ${interrupt_pin} + request_voltage: 20 diff --git a/tests/components/fusb302b/test.esp32-idf.yaml b/tests/components/fusb302b/test.esp32-idf.yaml new file mode 100644 index 00000000000..b9ca02073b4 --- /dev/null +++ b/tests/components/fusb302b/test.esp32-idf.yaml @@ -0,0 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + fusb302b: !include common.yaml From d0b9f195b0c92f8e6611b6d23d4e539221984ba1 Mon Sep 17 00:00:00 2001 From: Gilles Devillers Date: Thu, 15 Oct 2026 01:23:37 +0200 Subject: [PATCH 133/142] [wifi] Apply reboot_timeout with a fallback AP unless a station is connected to it (#9958) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/wifi/__init__.py | 3 + esphome/components/wifi/wifi_component.cpp | 60 ++++++++++++------- esphome/components/wifi/wifi_component.h | 20 ++++++- .../wifi/wifi_component_esp8266.cpp | 7 +++ .../wifi/wifi_component_esp_idf.cpp | 20 +++++++ .../wifi/wifi_component_libretiny.cpp | 5 +- .../components/wifi/wifi_component_pico_w.cpp | 42 ++++++++++++- tests/components/wifi/test.esp8266-ard.yaml | 3 + tests/components/wifi/test.rp2040-ard.yaml | 8 ++- tests/components/wifi/test.rtl87xx-ard.yaml | 8 ++- 10 files changed, 147 insertions(+), 29 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 583a9ed9992..bc730fe66fa 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -798,6 +798,9 @@ async def to_code(config): # takes turns with the networks instead. if CORE.is_ln882x: cg.add_define("USE_WIFI_AP_EXCLUSIVE") + # The Pico W driver drops the AP station events; wifi_component_pico_w.cpp counts them + if CORE.is_rp2: + cg.add_build_flag("-Wl,--wrap=cyw43_cb_process_async_event") # ESP32: register the WiFi stack with the esp32 sdkconfig reconciler, which # drops SoftAP support / the LWIP DHCP server when AP mode is unused. diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 70b208769b7..221efef07d9 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -672,7 +672,7 @@ void WiFiComponent::setup() { return; } #endif - this->wifi_mode_({}, false); + this->disable_ap_(); } }); } @@ -953,8 +953,9 @@ void WiFiComponent::loop() { this->ap_changed_ = now; #endif this->setup_ap_config_(); - // Where the AP runs on its own, a portal with no AP behind it would only stretch the cooldowns. - if (!WIFI_AP_EXCLUSIVE || this->ap_setup_) { + // A portal is only started behind an AP that came up: the portal keeps a reference to the web server + // on every start, and a failed start is tried again later + if (this->ap_setup_) { #ifdef USE_CAPTIVE_PORTAL // Reset so we force one full scan after captive portal starts // (previous scans were filtered because captive portal wasn't active yet) @@ -977,22 +978,28 @@ void WiFiComponent::loop() { #endif - if (!this->has_ap() && this->reboot_timeout_ != 0) { - if (now - this->last_connected_ > this->reboot_timeout_) { - bool suppress = false; -#ifdef USE_PROVISIONING - // Don't reboot while a provisioning window is pending (device unprovisioned). - // The device is legitimately waiting to be onboarded (Wi-Fi must come up - // before the controller can set credentials), and an auto-reboot would reopen - // the window without the deliberate power cycle / reset that is meant to be - // required. Resumes normal reboot behavior once provisioned. - suppress = provisioning::global_provisioning_manager != nullptr && - provisioning::global_provisioning_manager->window_pending(); + if (this->reboot_timeout_ != 0 && now - this->last_connected_ > this->reboot_timeout_) { + bool suppress = false; +#if defined(USE_WIFI_AP) && (defined(USE_RTL87XX) || defined(USE_LN882X)) + // The Realtek driver reports no stations and the Lightning one is unverified, so a running fallback AP + // holds the reboot there as it always did + suppress = this->ap_setup_; +#elif defined(USE_WIFI_AP) + // A station on the fallback AP is using the device + suppress = this->ap_setup_ && this->ap_clients_ != 0; #endif - if (!suppress) { - ESP_LOGE(TAG, "Can't connect; rebooting"); - App.reboot(); - } +#ifdef USE_PROVISIONING + // Don't reboot while a provisioning window is pending (device unprovisioned). + // The device is legitimately waiting to be onboarded (Wi-Fi must come up + // before the controller can set credentials), and an auto-reboot would reopen + // the window without the deliberate power cycle / reset that is meant to be + // required. Resumes normal reboot behavior once provisioned. + suppress |= provisioning::global_provisioning_manager != nullptr && + provisioning::global_provisioning_manager->window_pending(); +#endif + if (!suppress) { + ESP_LOGE(TAG, "Can't connect; rebooting"); + App.reboot(); } } } @@ -1070,6 +1077,8 @@ void WiFiComponent::setup_ap_config_() { this->ap_.set_ssid(ssid_buf); } } + // Reset before the AP exists: afterwards only the platform's event path writes the counter + this->ap_clients_ = 0; this->ap_setup_ = this->wifi_start_ap_(this->ap_); #if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_WISP) // A failed start must not leave the radio in AP mode: on an exclusive radio it would refuse @@ -1113,9 +1122,6 @@ void WiFiComponent::pause_exclusive_ap_() { ESP_LOGI(TAG, "Pausing AP to try the networks"); // Scanning and connecting turn STA back on. this->disable_ap_(); - this->ap_setup_ = false; - // Clients drop with the AP without a disconnect event for each. - this->ap_clients_ = 0; this->ap_changed_ = App.get_loop_component_start_time(); // A fresh connection cycle, as at boot, rather than resuming the one the // AP cut short. @@ -1128,6 +1134,7 @@ void WiFiComponent::pause_exclusive_ap_() { #ifdef USE_WIFI_AP_WISP void WiFiComponent::fail_bridge_() { this->ap_setup_ = false; + this->ap_clients_ = 0; // dropped with the AP, without a disconnect event for each this->wifi_mode_({}, false); } #endif @@ -1136,6 +1143,10 @@ void WiFiComponent::disable_ap_() { this->end_ap_portal_(); ESP_LOGD(TAG, "Disabling AP"); this->wifi_mode_({}, false); + // The fallback block may bring the AP back after a later disconnect; its clients dropped with it without + // a disconnect event for each + this->ap_setup_ = false; + this->ap_clients_ = 0; } void WiFiComponent::set_ap(const WiFiAP &ap) { @@ -1468,7 +1479,10 @@ void WiFiComponent::disable() { this->wifi_disconnect_(); this->wifi_mode_(false, false); #ifdef USE_WIFI_AP - this->ap_setup_ = false; // the AP went down with the radio; enable() may bring it up again + // The AP went down with the radio; enable() has to configure it, and its portal, again + this->end_ap_portal_(); + this->ap_setup_ = false; + this->ap_clients_ = 0; #endif #ifdef USE_WIFI_AP_WISP this->ap_changed_ = 0; // at once, not after ap_timeout @@ -1774,6 +1788,8 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { } #endif + // The fallback and reboot checks later in this pass must see the connection, not the old timestamp + this->last_connected_ = now; this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTED; // Refresh is_connected() cache; loop()'s refresh ran before this transition. this->update_connected_state_(); diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 964de6996a3..4c346cb40a7 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -50,6 +50,13 @@ extern "C" { } #include + +#ifdef USE_WIFI_AP +// The linker routes the driver's firmware events here; wifi/__init__.py adds the wrap flag +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" void __wrap_cyw43_cb_process_async_event(void *cb_data, const cyw43_async_event_t *ev); +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +#endif #endif #if defined(USE_ESP32) && defined(SOC_WIFI_SUPPORT_5G) @@ -919,6 +926,11 @@ class WiFiComponent final : public Component { #ifdef USE_RP2 static int s_wifi_scan_result(void *env, const cyw43_ev_scan_result_t *result); void wifi_scan_result_(void *env, const cyw43_ev_scan_result_t *result); +#ifdef USE_WIFI_AP + // NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + friend void ::__wrap_cyw43_cb_process_async_event(void *cb_data, const cyw43_async_event_t *ev); + // NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +#endif #endif #ifdef USE_LIBRETINY @@ -1079,8 +1091,12 @@ class WiFiComponent final : public Component { bool scan_done_{false}; bool ap_setup_{false}; bool ap_started_{false}; -#ifdef USE_WIFI_AP_EXCLUSIVE - uint8_t ap_clients_{0}; // Devices joined to the AP, which keep it from pausing +#ifdef USE_WIFI_AP + // Stations joined to the AP; they hold the reboot timeout and keep an exclusive AP from pausing. + // Counted on the main loop on ESP32 and LibreTiny (queued events), in the SDK callback on ESP8266 and + // in the driver's interrupt context on the Pico W. The main loop only reads the byte while the AP is up + // and only writes it (a reset) while the AP is down, so no lock is needed. + uint8_t ap_clients_{0}; #endif bool passive_scan_{false}; bool has_saved_wifi_settings_{false}; diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 41b51460006..61c66079b63 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -572,6 +572,9 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { break; } case EVENT_SOFTAPMODE_STACONNECTED: { +#ifdef USE_WIFI_AP + global_wifi_component->ap_clients_++; +#endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE auto it = event->event_info.sta_connected; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; @@ -581,6 +584,10 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { break; } case EVENT_SOFTAPMODE_STADISCONNECTED: { +#ifdef USE_WIFI_AP + if (global_wifi_component->ap_clients_ > 0) + global_wifi_component->ap_clients_--; +#endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE auto it = event->event_info.sta_disconnected; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 10dd67dba92..c31fe336484 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -928,6 +928,15 @@ const char *get_disconnect_reason_str(uint8_t reason) { } } +#ifdef USE_WIFI_AP +// The driver's own station list cannot drift, so the count is read from it rather than kept by arithmetic +static void read_ap_clients(uint8_t &count) { + wifi_sta_list_t sta_list; + if (esp_wifi_ap_get_sta_list(&sta_list) == ESP_OK) + count = sta_list.num; +} +#endif + bool WiFiComponent::wifi_loop_() { // Use pop() directly instead of empty() — pop() costs 1 memw (acquire on tail_), // while empty() costs 2 memw (acquire on both head_ and tail_) on Xtensa. @@ -945,6 +954,11 @@ bool WiFiComponent::wifi_loop_() { uint16_t dropped = this->event_queue_.get_and_reset_dropped_count(); if (dropped > 0) { ESP_LOGW(TAG, "Dropped %u WiFi events due to buffer overflow", dropped); +#ifdef USE_WIFI_AP + // A dropped join or leave is made up for by reading the list again + if (this->ap_setup_) + read_ap_clients(this->ap_clients_); +#endif } return true; } @@ -1197,6 +1211,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { #endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_STACONNECTED) { +#ifdef USE_WIFI_AP + read_ap_clients(this->ap_clients_); +#endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE const auto &it = data->data.ap_staconnected; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; @@ -1205,6 +1222,9 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { #endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_STADISCONNECTED) { +#ifdef USE_WIFI_AP + read_ap_clients(this->ap_clients_); +#endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE const auto &it = data->data.ap_stadisconnected; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index ca32f3d3ffa..099eefd61da 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -629,7 +629,7 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { break; } case ESPHOME_EVENT_ID_WIFI_AP_STACONNECTED: { -#ifdef USE_WIFI_AP_EXCLUSIVE +#ifdef USE_WIFI_AP this->ap_clients_++; #endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE @@ -641,7 +641,7 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { break; } case ESPHOME_EVENT_ID_WIFI_AP_STADISCONNECTED: { -#ifdef USE_WIFI_AP_EXCLUSIVE +#ifdef USE_WIFI_AP if (this->ap_clients_ > 0) this->ap_clients_--; #endif @@ -881,6 +881,7 @@ bool WiFiComponent::wifi_loop_() { // so if pop() returned nullptr above we can skip this check. uint16_t dropped = this->event_queue_.get_and_reset_dropped_count(); if (dropped > 0) { + // A dropped station event cannot be made up for: the BK72xx core has no station query ESP_LOGW(TAG, "Dropped %" PRIu16 " WiFi events due to buffer overflow", dropped); } return true; diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 5b05132f4f7..c1620154e6c 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -59,6 +59,11 @@ bool WiFiComponent::wifi_mode_(optional sta, optional ap) { if (ap.value()) { cyw43_wifi_set_up(&cyw43_state, CYW43_ITF_AP, true, CYW43_COUNTRY_WORLDWIDE); } else { +#ifdef USE_WIFI_AP + // Takes down the Arduino AP interface and its DHCP server; a later beginAP() is refused + // while they are still up + WiFi.disconnectAP(); +#endif cyw43_wifi_set_up(&cyw43_state, CYW43_ITF_AP, false, CYW43_COUNTRY_WORLDWIDE); } this->ap_started_ = ap.value(); @@ -238,12 +243,16 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { // Pass nullptr for empty password — CYW43 uses the password pointer (not length) // to choose between OPEN and WPA2 auth mode. const char *ap_password = ap.password_.empty() ? nullptr : ap.password_.c_str(); - WiFi.beginAP(ap.ssid_.c_str(), ap_password, ap.has_channel() ? ap.get_channel() : 1); + if (WiFi.beginAP(ap.ssid_.c_str(), ap_password, ap.has_channel() ? ap.get_channel() : 1) != WL_CONNECTED) { + ESP_LOGW(TAG, "beginAP failed"); + return false; + } return true; } network::IPAddress WiFiComponent::wifi_soft_ap_ip() { return {(const ip_addr_t *) WiFi.softAPIP()}; } + #endif // USE_WIFI_AP bool WiFiComponent::wifi_disconnect_() { @@ -372,5 +381,36 @@ bool WiFiComponent::wifi_loop_() { void WiFiComponent::wifi_pre_setup_() {} } // namespace esphome::wifi + +#ifdef USE_WIFI_AP +// Firmware event ids the driver header leaves out +static constexpr uint32_t AP_STATION_ASSOC_IND = 8; + +// The driver hands every firmware event to cyw43_cb_process_async_event and ignores the AP station +// ones, so they are counted here before it sees them. Runs in the driver's context. Verified with +// arduino-pico 6.1.1 on a Pico 2W: a join gives AUTH_IND then ASSOC_IND, a leave gives DISASSOC_IND. +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +// Names are mandated by the --wrap linker mechanism. +extern "C" void __real_cyw43_cb_process_async_event(void *cb_data, const cyw43_async_event_t *ev); +extern "C" void __wrap_cyw43_cb_process_async_event(void *cb_data, const cyw43_async_event_t *ev) { + if (ev->interface == CYW43_ITF_AP) { + switch (ev->event_type) { + case AP_STATION_ASSOC_IND: + // A refused association carries its status here and has no leave event to balance it. A + // reassociation comes from a station that is already counted, whether or not its leave was seen. + if (ev->status == CYW43_STATUS_SUCCESS) + esphome::wifi::global_wifi_component->ap_clients_++; + break; + case CYW43_EV_DISASSOC_IND: + case CYW43_EV_DEAUTH_IND: + if (esphome::wifi::global_wifi_component->ap_clients_ > 0) + esphome::wifi::global_wifi_component->ap_clients_--; + break; + } + } + __real_cyw43_cb_process_async_event(cb_data, ev); +} +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +#endif // USE_WIFI_AP #endif #endif diff --git a/tests/components/wifi/test.esp8266-ard.yaml b/tests/components/wifi/test.esp8266-ard.yaml index ffeec136d3a..6b552c33da8 100644 --- a/tests/components/wifi/test.esp8266-ard.yaml +++ b/tests/components/wifi/test.esp8266-ard.yaml @@ -2,6 +2,9 @@ wifi: min_auth_mode: WPA2 post_connect_roaming: true phy_mode: 11G + ap: + ssid: Fallback AP + password: fallback_password packages: - !include common.yaml diff --git a/tests/components/wifi/test.rp2040-ard.yaml b/tests/components/wifi/test.rp2040-ard.yaml index dade44d145b..ec545f7350e 100644 --- a/tests/components/wifi/test.rp2040-ard.yaml +++ b/tests/components/wifi/test.rp2040-ard.yaml @@ -1 +1,7 @@ -<<: !include common.yaml +wifi: + ap: + ssid: Fallback AP + password: fallback_password + +packages: + wifi: !include common.yaml diff --git a/tests/components/wifi/test.rtl87xx-ard.yaml b/tests/components/wifi/test.rtl87xx-ard.yaml index dade44d145b..ec545f7350e 100644 --- a/tests/components/wifi/test.rtl87xx-ard.yaml +++ b/tests/components/wifi/test.rtl87xx-ard.yaml @@ -1 +1,7 @@ -<<: !include common.yaml +wifi: + ap: + ssid: Fallback AP + password: fallback_password + +packages: + wifi: !include common.yaml From fe57eef3f66a56815faa9fc025c2f3ea5e325127 Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:28:01 +0100 Subject: [PATCH 134/142] [core] Test that includes inside a package resolve next to the package (#20739) --- tests/unit_tests/core/test_config.py | 30 +++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index 883f9d8b3d5..bb565564c6e 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -17,6 +17,8 @@ from esphome.const import ( CONF_BUILD_PATH, CONF_DEVICES, CONF_ESPHOME, + CONF_INCLUDES, + CONF_INCLUDES_C, CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, KEY_CORE, @@ -36,7 +38,7 @@ from esphome.core.config import ( validate_hostname, ) -from .common import load_config_from_fixture +from .common import load_config_from_fixture, load_config_from_yaml FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "config" @@ -689,6 +691,32 @@ def test_valid_include_invalid_extension(tmp_path: Path) -> None: valid_include(str(invalid_file)) +def test_includes_in_a_package_resolve_next_to_the_package( + tmp_path: Path, yaml_file: Callable[[str], Path] +) -> None: + """A package's includes resolve next to the package file (issue #14583).""" + package_dir = tmp_path / "packages" / "helper" + package_dir.mkdir(parents=True) + (package_dir / "my_helper.h").write_text("#pragma once\n") + (package_dir / "my_c_helper.h").write_text("#pragma once\n") + (package_dir / "helper.yaml").write_text( + "esphome:\n includes:\n - my_helper.h\n includes_c:\n - my_c_helper.h\n" + ) + + result = load_config_from_yaml( + yaml_file, + "esphome:\n" + " name: test\n" + "host:\n" + "packages:\n" + " - !include packages/helper/helper.yaml\n", + ) + + assert result is not None + assert result[CONF_ESPHOME][CONF_INCLUDES] == [str(package_dir / "my_helper.h")] + assert result[CONF_ESPHOME][CONF_INCLUDES_C] == [str(package_dir / "my_c_helper.h")] + + def test_valid_project_name_valid() -> None: """Test valid_project_name accepts valid project names.""" assert valid_project_name("esphome.my_project") == "esphome.my_project" From af709ac9a5c145beb88ac74abe60850487cffbba Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 14 Oct 2026 18:30:36 -0500 Subject: [PATCH 135/142] [openthread] Fail instead of aborting when NVS is unavailable (#20717) Co-authored-by: Claude Opus 5.5 --- esphome/components/openthread/openthread.cpp | 15 +++++++++++++++ .../components/openthread/openthread_esp.cpp | 17 ++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index 4986850cd4f..c4ab27689e7 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -58,6 +58,9 @@ void OpenThreadComponent::on_state_changed(otChangedFlags flags, void *context) // Gets the off-mesh routable address std::optional OpenThreadComponent::get_omr_address() { + // A failed setup never creates the lock, and acquire() would abort waiting for it + if (this->is_failed()) + return {}; InstanceLock lock = InstanceLock::acquire(); return this->get_omr_address_(lock); } @@ -122,6 +125,10 @@ void OpenThreadSrpComponent::srp_factory_reset_callback(otError err, const otSrp } void OpenThreadSrpComponent::setup() { + if (global_openthread_component->is_failed()) { + this->mark_failed(); + return; + } otError error; InstanceLock lock = InstanceLock::acquire(); otInstance *instance = lock.get_instance(); @@ -228,6 +235,9 @@ void *OpenThreadSrpComponent::pool_alloc_(size_t size) { } bool OpenThreadComponent::teardown() { + // Nothing was started + if (this->is_failed()) + return true; switch (this->teardown_stage_) { case TeardownStage::TEARDOWN_STAGE_NOT_STARTED: { { @@ -272,6 +282,11 @@ bool OpenThreadComponent::teardown() { } void OpenThreadComponent::on_factory_reset(std::function callback) { + if (this->is_failed()) { + // No SRP registration to remove; reset right away + callback(); + return; + } this->factory_reset_external_callback_ = std::move(callback); ESP_LOGD(TAG, "Start Removal SRP Host and Services"); otError error; diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index f3860b2cc81..de1295fa26a 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -10,12 +10,12 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/preferences.h" #include "esp_err.h" #include "esp_netif.h" #include "esp_openthread_netif_glue.h" #include "esp_vfs_eventfd.h" -#include "nvs_flash.h" ESPHOME_LOG_TAG(TAG, "openthread"); @@ -29,8 +29,14 @@ void OpenThreadComponent::setup() { esp_vfs_eventfd_config_t eventfd_config = { .max_fds = 3, }; - // Network interface setup handled by network component - ESP_ERROR_CHECK(nvs_flash_init()); + // Network interface setup handled by network component. + // Preferences opens NVS in app_main(); OpenThread keeps its dataset there, and esp_openthread_start() + // aborts without it, so fail this component instead. + if (global_preferences->nvs_handle == 0) { + ESP_LOGE(TAG, "NVS unavailable"); + this->mark_failed(); + return; + } ESP_ERROR_CHECK(esp_vfs_eventfd_register(&eventfd_config)); esp_openthread_config_t config = {.netif_config = ESP_NETIF_DEFAULT_OPENTHREAD(), @@ -153,6 +159,11 @@ InstanceLock InstanceLock::acquire() { ESP_LOGE(TAG, "OpenThread component torn down, cannot acquire instance lock"); abort(); } + // A failed setup() never creates the lock; say so now instead of after the wait below + if (global_openthread_component->is_failed()) { + ESP_LOGE(TAG, "Setup failed, cannot acquire instance lock"); + abort(); + } // Wait for the lock to be created before attempting to acquire it. // esp_openthread_lock_acquire() will assert-crash if called before esp_openthread_start(). constexpr uint32_t lock_init_timeout_ms = 10000; From 908c2f67783933104cbdd0939674ffa3237e38e1 Mon Sep 17 00:00:00 2001 From: Egor Vorontsov Date: Thu, 15 Oct 2026 03:29:47 +0300 Subject: [PATCH 136/142] [wifi] Add Wi-Fi Easy Connect (DPP) provisioning on the ESP32 (#17934) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/wifi/__init__.py | 64 ++++++++- esphome/components/wifi/wifi_component.cpp | 126 ++++++++++++++---- esphome/components/wifi/wifi_component.h | 47 +++++++ .../wifi/wifi_component_esp_idf.cpp | 103 ++++++++++++++ esphome/core/defines.h | 2 + tests/components/wifi/test-dpp.esp32-idf.yaml | 12 ++ .../wifi/validate-dpp-bare.esp32-idf.yaml | 6 + tests/unit_tests/components/test_wifi.py | 9 ++ 8 files changed, 341 insertions(+), 28 deletions(-) create mode 100644 tests/components/wifi/test-dpp.esp32-idf.yaml create mode 100644 tests/components/wifi/validate-dpp-bare.esp32-idf.yaml diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index bc730fe66fa..5879729a22b 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -31,6 +31,7 @@ from esphome.const import ( CONF_CERTIFICATE, CONF_CERTIFICATE_AUTHORITY, CONF_CHANNEL, + CONF_CHANNELS, CONF_DNS1, CONF_DNS2, CONF_DOMAIN, @@ -158,9 +159,13 @@ def has_native_wifi( CONF_SAVE = "save" CONF_BAND_MODE = "band_mode" +CONF_DEVICE_INFO = "device_info" +CONF_DPP = "dpp" CONF_MIN_AUTH_MODE = "min_auth_mode" +CONF_ON_DPP_URI = "on_dpp_uri" CONF_PHY_MODE = "phy_mode" CONF_POST_CONNECT_ROAMING = "post_connect_roaming" +CONF_PRIVATE_KEY = "private_key" CONF_AP_MODE = "ap_mode" CONF_DNS_SERVERS = "dns_servers" @@ -178,6 +183,10 @@ DEFAULT_REBOOT_TIMEOUT = "15min" # Both defaults also match the C++ initializers in wifi_component.h; codegen skips # the setter when the config equals them. +# Similar to AP timeout, but reduced to allow repetitive enrollment attempts +# in the case of failure. Does not affect AP or scanning. +DEFAULT_DPP_TIMEOUT = "1min" + wifi_ns = cg.esphome_ns.namespace("wifi") EAPAuth = wifi_ns.struct("EAPAuth") ManualIP = wifi_ns.struct("ManualIP") @@ -250,6 +259,14 @@ WiFiConfigureAction = wifi_ns.class_( ) +def _validate_dpp_device_info(value: object) -> str: + """The supplicant puts the text on a space separated command line and reads it up to the next space.""" + value = cv.string_strict(value) + if any(c.isspace() for c in value): + raise cv.Invalid("device_info cannot contain whitespace") + return value + + def validate_password(value): value = cv.string_strict(value) if not value: @@ -316,6 +333,26 @@ EAP_AUTH_SCHEMA = cv.All( cv.has_at_least_one_key(CONF_IDENTITY, CONF_CERTIFICATE), ) +# Wi-Fi Easy Connect (DPP) enrollee; the ESP-IDF supplicant supports the QR code method +DPP_SCHEMA = cv.Schema( + { + cv.Optional(CONF_PRIVATE_KEY): cv.sensitive(cv.string_strict), + cv.Optional(CONF_DEVICE_INFO): _validate_dpp_device_info, + cv.Optional(CONF_CHANNELS, default=[6]): cv.ensure_list( + cv.int_range(min=1, max=14) + ), + cv.Optional( + CONF_TIMEOUT, default=DEFAULT_DPP_TIMEOUT + ): cv.positive_time_period_milliseconds, + } +) + + +def _dpp_schema(value: ConfigType | None) -> ConfigType: + # A bare `dpp:` takes every default + return DPP_SCHEMA({} if value is None else value) + + WIFI_NETWORK_BASE = cv.Schema( { cv.GenerateID(): cv.declare_id(WiFiAP), @@ -410,15 +447,18 @@ def _apply_min_auth_mode_default(config): def final_validate(config): has_sta = bool(config.get(CONF_NETWORKS, True)) has_ap = CONF_AP in config + has_dpp = CONF_DPP in config full_config = fv.full_config.get() has_improv = "improv_ble" in full_config has_improv_serial = "improv_serial" in full_config has_captive_portal = "captive_portal" in full_config has_web_server = "web_server" in full_config - if not (has_sta or has_ap or has_improv or has_improv_serial): + if not (has_sta or has_ap or has_dpp or has_improv or has_improv_serial): raise cv.Invalid( - "Please specify at least an SSID or an Access Point to create." + "Please specify at least an SSID or an Access Point to create, or configure DPP (Easy Connect)." ) + if CONF_ON_DPP_URI in config and not has_dpp: + raise cv.Invalid(f"'{CONF_ON_DPP_URI}' requires '{CONF_DPP}'") ap_is_wisp = has_ap and config[CONF_AP][CONF_AP_MODE] == WIFI_AP_MODE_WISP if ap_is_wisp: @@ -615,6 +655,7 @@ CONFIG_SCHEMA = cv.All( ): STA_MANUAL_IP_SCHEMA, cv.Optional(CONF_EAP): EAP_AUTH_SCHEMA, cv.Optional(CONF_AP): wifi_network_ap, + cv.Optional(CONF_DPP): cv.All(cv.only_on_esp32, _dpp_schema), cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, cv.Optional( CONF_REBOOT_TIMEOUT, default=DEFAULT_REBOOT_TIMEOUT @@ -661,6 +702,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation( single=True ), + cv.Optional(CONF_ON_DPP_URI): automation.validate_automation(single=True), cv.Optional(CONF_USE_PSRAM): cv.All( cv.only_on_esp32, cv.requires_component("psram"), cv.boolean ), @@ -807,6 +849,18 @@ async def to_code(config): if CORE.is_esp32: request_wifi(ap=CONF_AP in config) + if (dpp := config.get(CONF_DPP)) is not None: + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_DPP_SUPPORT", True) + add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_ENABLE_DPP", True) + if (key := dpp.get(CONF_PRIVATE_KEY)) is not None: + cg.add(var.set_dpp_key(key)) + if (info := dpp.get(CONF_DEVICE_INFO)) is not None: + cg.add(var.set_dpp_device_info(info)) + cg.add(var.set_dpp_channels(",".join(str(c) for c in dpp[CONF_CHANNELS]))) + if (timeout := dpp[CONF_TIMEOUT]) != cv.time_period(DEFAULT_DPP_TIMEOUT): + cg.add(var.set_dpp_timeout(timeout)) + cg.add_define("USE_WIFI_DPP") + # Disable Enterprise WiFi support if no EAP is configured if CORE.is_esp32: add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", has_eap) @@ -977,6 +1031,12 @@ async def to_code(config): var.get_disconnect_trigger(), [], on_disconnect_config ) + if (on_dpp_uri := config.get(CONF_ON_DPP_URI)) is not None: + cg.add_define("USE_WIFI_DPP_URI_TRIGGER") + await automation.build_callback_automation( + var, "add_on_dpp_uri_callback", [(cg.const_char_ptr, "uri")], on_dpp_uri + ) + CORE.add_job(final_step) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 221efef07d9..837c0a1a9d6 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -187,11 +187,11 @@ bool CompactString::operator==(const StringRef &other) const { /// │ ↓ │ /// │ 5. FAILED → RESTARTING_ADAPTER │ /// │ - Normal: restart adapter, clear state │ -/// │ - AP/improv active: skip restart, just disconnect │ +/// │ - AP/DPP/improv active: skip restart, just disconnect │ /// │ ↓ │ /// │ 6. Loop back to start: │ /// │ - If first network is hidden → EXPLICIT_HIDDEN (retry cycle) │ -/// │ - If AP/improv active → RETRY_HIDDEN (blind retry, see below) │ +/// │ - If AP/DPP/improv active → RETRY_HIDDEN (blind retry, see below)│ /// │ - Otherwise → SCAN_CONNECTING (rescan) │ /// │ ↓ │ /// │ 7. RESCAN → Apply stored priorities, sort again │ @@ -655,12 +655,17 @@ void WiFiComponent::setup() { this->configured_power_save_ = this->power_save_; #endif -#if defined(USE_PROVISIONING) && defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP) - // The access point and the portals on it are provisioning surfaces: once the window has - // closed, end them (mirrors the teardown on a successful connection). The fallback block in - // loop() is gated so none of them is started again afterwards. A bridge AP carries no portal. +#if defined(USE_PROVISIONING) && (defined(USE_WIFI_DPP) || (defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP))) + // The access point, the portals on it and the DPP listen are provisioning surfaces: once the + // window has closed, end them (mirrors the teardown on a successful connection). The blocks in + // loop() that start them are gated so none of them is started again afterwards. A bridge AP + // carries no portal and stays up. if (provisioning::global_provisioning_manager != nullptr) { provisioning::global_provisioning_manager->add_on_closed_callback([this]() { +#ifdef USE_WIFI_DPP + this->stop_dpp_listen_(); +#endif +#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP) this->end_ap_portal_(); // no-op when nothing is active, so not tied to ap_setup_ if (this->ap_setup_) { ESP_LOGD(TAG, "Provisioning window closed; disabling AP"); @@ -674,6 +679,7 @@ void WiFiComponent::setup() { #endif this->disable_ap_(); } +#endif // USE_WIFI_AP }); } #endif @@ -790,6 +796,11 @@ void WiFiComponent::start() { this->start_ap_portal_(); #endif // USE_WIFI_AP } +#ifdef USE_WIFI_DPP + // Without networks there is nothing to try first, so listen for a configurator right away + if (!this->has_sta() && this->is_dpp_active_() && this->wifi_mode_(true, {})) + this->start_dpp_listen_(millis()); +#endif #ifdef USE_IMPROV_BLE if (!this->has_sta() && improv_ble::global_improv_component != nullptr) { if (this->wifi_mode_(true, {})) @@ -849,7 +860,7 @@ void WiFiComponent::loop() { break; } // Use longer cooldown when a portal/improv is active to avoid disrupting a user on the AP - bool portal_active = this->is_ap_portal_active_() || this->is_improv_ble_active_(); + bool portal_active = this->is_provisioning_active_(); uint32_t cooldown_duration = portal_active ? WIFI_COOLDOWN_WITH_AP_ACTIVE_MS : WIFI_COOLDOWN_DURATION_MS; if (now - this->action_started_ > cooldown_duration) { // After cooldown we either restarted the adapter because of @@ -934,12 +945,7 @@ void WiFiComponent::loop() { // Once the provisioning window has closed, don't bring up the fallback AP (or // the captive portal on it) - the device must stay unprovisionable until it is // power-cycled. - bool provisioning_closed = false; -#ifdef USE_PROVISIONING - provisioning_closed = - provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed(); -#endif - if (this->has_ap() && !this->ap_setup_ && !provisioning_closed) { + if (this->has_ap() && !this->ap_setup_ && !this->is_provisioning_closed_()) { if (this->ap_timeout_ != 0 && (now - this->last_connected_ > this->ap_timeout_) #ifdef USE_WIFI_AP_EXCLUSIVE @@ -975,7 +981,6 @@ void WiFiComponent::loop() { improv_ble::global_improv_component->start(); } } - #endif if (this->reboot_timeout_ != 0 && now - this->last_connected_ > this->reboot_timeout_) { @@ -1004,6 +1009,15 @@ void WiFiComponent::loop() { } } +#ifdef USE_WIFI_DPP + // Without networks no cooldown starts the listen, so a failed or abandoned enrollment is listened for again + // here, at most once per timeout + if (!this->has_sta() && !this->dpp_listening_ && this->state_ != WIFI_COMPONENT_STATE_DISABLED && + now - this->dpp_listen_started_ > this->dpp_timeout_ && this->is_dpp_active_() && this->wifi_mode_(true, {})) { + this->start_dpp_listen_(now); + } +#endif + #if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) // Check if power save mode needs to be updated based on high-performance requests if (this->high_performance_semaphore_ != nullptr) { @@ -1356,13 +1370,23 @@ void WiFiComponent::start_connecting(const WiFiAP &ap) { ESP_LOGE(TAG, "wifi_sta_connect_ failed"); // Enter cooldown to allow WiFi hardware to stabilize // (immediate failure suggests hardware not ready, different from connection timeout) - this->state_ = WIFI_COMPONENT_STATE_COOLDOWN; - } else { - this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTING; + this->start_cooldown_(); + return; } + this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTING; this->action_started_ = millis(); } +void WiFiComponent::start_cooldown_() { + this->state_ = WIFI_COMPONENT_STATE_COOLDOWN; + this->action_started_ = millis(); +#ifdef USE_WIFI_DPP + // The cooldown is the configurator's window; the attempt that ends it stops the listen again + if (!this->dpp_listening_ && this->is_dpp_active_() && this->wifi_mode_(true, {})) + this->start_dpp_listen_(this->action_started_); +#endif +} + const LogString *get_signal_bars(int8_t rssi) { // LOWER ONE QUARTER BLOCK // Unicode: U+2582, UTF-8: E2 96 82 @@ -1475,6 +1499,9 @@ void WiFiComponent::disable() { this->state_ = WIFI_COMPONENT_STATE_DISABLED; #ifdef USE_WIFI_LEASE_CACHE this->cancel_timeout(LEASE_RENEW_TIMEOUT_ID); +#endif +#ifdef USE_WIFI_DPP + this->stop_dpp_listen_(); #endif this->wifi_disconnect_(); this->wifi_mode_(false, false); @@ -1731,6 +1758,12 @@ void WiFiComponent::dump_config() { ESP_LOGCONFIG(TAG, " Disabled"); return; } +#ifdef USE_WIFI_DPP + ESP_LOGCONFIG(TAG, + " DPP channels: %s\n" + " DPP timeout: %" PRIu32 " ms", + this->dpp_channels_, this->dpp_timeout_); +#endif #if defined(USE_ESP32) && defined(SOC_WIFI_SUPPORT_5G) const char *band_mode_s; switch (this->band_mode_) { @@ -1782,6 +1815,9 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { this->disable_ap_(); } #endif +#ifdef USE_WIFI_DPP + this->stop_dpp_listen_(); +#endif #ifdef USE_IMPROV_BLE if (this->is_improv_ble_active_()) { improv_ble::global_improv_component->stop(); @@ -1959,7 +1995,7 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() { } // No hidden networks - always go through RESTARTING_ADAPTER phase // This ensures num_retried_ gets reset and a fresh scan is triggered - // The actual adapter restart will be skipped if captive portal/improv is active + // The actual adapter restart will be skipped if DPP/captive portal/improv is active return WiFiRetryPhase::RESTARTING_ADAPTER; case WiFiRetryPhase::RETRY_HIDDEN: @@ -1982,7 +2018,7 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() { } // Exhausted all potentially hidden SSIDs - always go through RESTARTING_ADAPTER // This ensures num_retried_ gets reset and a fresh scan is triggered - // The actual adapter restart will be skipped if captive portal/improv is active + // The actual adapter restart will be skipped if DPP/captive portal/improv is active return WiFiRetryPhase::RESTARTING_ADAPTER; case WiFiRetryPhase::RESTARTING_ADAPTER: @@ -2012,14 +2048,14 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() { // attempting to connect to all configured networks in sequence. // Captive portal needs scan results to show available networks. // If captive portal is active, only skip scanning if we've done a scan after it started. - // If only improv is active (no captive portal), skip scanning since improv doesn't need results. + // If only DPP or improv is active (no captive portal), skip scanning since neither needs results. if (this->is_captive_portal_active_()) { if (this->has_completed_scan_after_captive_portal_start_) { return WiFiRetryPhase::RETRY_HIDDEN; } // Need to scan for captive portal - } else if (this->is_improv_ble_active_()) { - // Improv doesn't need scan results + } else if (this->is_improv_ble_active_() || this->is_dpp_active_()) { + // Improv and DPP don't need scan results return WiFiRetryPhase::RETRY_HIDDEN; } return WiFiRetryPhase::SCAN_CONNECTING; @@ -2109,7 +2145,7 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { // Skip actual adapter restart if a portal/improv is active // This allows state machine to reset num_retried_ and trigger fresh scan // without disrupting the portal/improv connection - if (!this->is_ap_portal_active_() && !this->is_improv_ble_active_()) { + if (!this->is_provisioning_active_()) { this->restart_adapter(); } else { // Even when skipping full restart, disconnect to clear driver state @@ -2117,15 +2153,14 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { this->wifi_disconnect_(); } // Clear scan flag - we're starting a new retry cycle - // This is critical for captive portal/improv flow: when determine_next_phase_() + // This is critical for DPP/captive portal/improv flow: when determine_next_phase_() // returns RETRY_HIDDEN (because scanning is skipped), find_next_hidden_sta_() // will see BLIND_RETRY mode and treat ALL networks as candidates, // effectively cycling through all configured networks without scan results. this->retry_hidden_mode_ = RetryHiddenMode::BLIND_RETRY; // Always enter cooldown after restart (or skip-restart) to allow stabilization // Use extended cooldown when AP is active to avoid constant scanning that blocks DNS - this->state_ = WIFI_COMPONENT_STATE_COOLDOWN; - this->action_started_ = millis(); + this->start_cooldown_(); // Return true to indicate we should wait (go to COOLDOWN) instead of immediately connecting return true; @@ -2361,6 +2396,32 @@ void WiFiComponent::set_power_save_mode(WiFiPowerSaveMode power_save) { #endif } +#ifdef USE_WIFI_DPP +void WiFiComponent::start_dpp_listen_(uint32_t now) { + this->dpp_listen_started_ = now; + this->dpp_listening_ = this->wifi_dpp_start_listen_(); +} + +void WiFiComponent::stop_dpp_listen_() { + if (!this->dpp_listening_) + return; + this->wifi_dpp_stop_listen_(); + this->dpp_listening_ = false; +} +#endif + +bool WiFiComponent::is_dpp_active_() { +#ifdef USE_WIFI_DPP + // A configurator is waited for once the networks have had their timeout, or from boot without any; the retry + // machine then treats DPP like a portal (blind retries, a long cooldown) whether the supplicant is listening at + // this moment or not + return !this->is_provisioning_closed_() && + (!this->has_sta() || App.get_loop_component_start_time() - this->last_connected_ > this->dpp_timeout_); +#else + return false; +#endif +} + bool WiFiComponent::is_captive_portal_active_() { #ifdef USE_CAPTIVE_PORTAL return captive_portal::global_captive_portal != nullptr && captive_portal::global_captive_portal->is_active(); @@ -2406,6 +2467,19 @@ bool WiFiComponent::is_improv_ble_active_() { #endif } +bool WiFiComponent::is_provisioning_active_() { + return this->is_ap_portal_active_() || this->is_improv_ble_active_() || this->is_dpp_active_(); +} + +bool WiFiComponent::is_provisioning_closed_() { +#ifdef USE_PROVISIONING + // The device stays unprovisionable until it is power-cycled + return provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed(); +#else + return false; +#endif +} + #if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) bool WiFiComponent::request_high_performance() { // Already configured for high performance - request satisfied diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 4c346cb40a7..db4cbae8389 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -486,6 +487,21 @@ class WiFiComponent final : public Component { void set_ap_timeout(uint32_t ap_timeout) { ap_timeout_ = ap_timeout; } #endif // USE_WIFI_AP +#ifdef USE_WIFI_DPP + // Code generation passes string literals that live in flash + void set_dpp_key(const char *key) { this->dpp_key_ = key; } + void set_dpp_device_info(const char *info) { this->dpp_device_info_ = info; } + void set_dpp_channels(const char *channels) { this->dpp_channels_ = channels; } + void set_dpp_timeout(uint32_t timeout) { this->dpp_timeout_ = timeout; } +#ifdef USE_WIFI_DPP_URI_TRIGGER + template void add_on_dpp_uri_callback(F &&callback) { + this->dpp_uri_callback_.add(std::forward(callback)); + } +#endif + /// The Easy Connect URI to show as a QR code, nullptr until the supplicant has generated it + const char *get_dpp_uri() const { return this->dpp_uri_.get(); } +#endif + void enable(); void disable(); bool is_disabled() { return this->state_ == WIFI_COMPONENT_STATE_DISABLED; } @@ -851,6 +867,23 @@ class WiFiComponent final : public Component { network::IPAddress wifi_gateway_ip_(); network::IPAddress wifi_dns_ip_(int num); +#ifdef USE_WIFI_DPP + // Starts listening for a DPP configurator; the platform half reports whether the driver accepted + void start_dpp_listen_(uint32_t now); + void stop_dpp_listen_(); +#endif + /// Wait before the next attempt; while a configurator is waited for, the wait is its listen window + void start_cooldown_(); +#ifdef USE_WIFI_DPP + void wifi_dpp_init_(); + bool wifi_dpp_start_listen_(); + void wifi_dpp_stop_listen_(); +#endif + bool is_dpp_active_(); + /// A portal, improv or DPP is waiting for credentials, so the retry machine must not disturb the radio + bool is_provisioning_active_(); + /// The provisioning window has closed, so no new provisioning surface may start + bool is_provisioning_closed_(); bool is_captive_portal_active_(); /// captive_portal or the web_server AP mode is serving a user on the access point bool is_ap_portal_active_(); @@ -1008,6 +1041,17 @@ class WiFiComponent final : public Component { // 1-byte members so they pack into its trailing bytes instead of padding after it WiFiAP ap_; #endif // USE_WIFI_AP +#ifdef USE_WIFI_DPP + const char *dpp_key_{nullptr}; + const char *dpp_device_info_{nullptr}; + const char *dpp_channels_{nullptr}; + std::unique_ptr dpp_uri_; // written by the event task before the URI event is queued +#ifdef USE_WIFI_DPP_URI_TRIGGER + CallbackManager dpp_uri_callback_; +#endif + uint32_t dpp_timeout_{60000}; // Keep in sync with DEFAULT_DPP_TIMEOUT in __init__.py + uint32_t dpp_listen_started_{0}; +#endif // 1-byte enums and integers WiFiComponentState state_{WIFI_COMPONENT_STATE_OFF}; @@ -1091,6 +1135,9 @@ class WiFiComponent final : public Component { bool scan_done_{false}; bool ap_setup_{false}; bool ap_started_{false}; +#ifdef USE_WIFI_DPP + bool dpp_listening_{false}; +#endif #ifdef USE_WIFI_AP // Stations joined to the AP; they hold the reboot timeout and keep an exclusive AP from pausing. // Counted on the main loop on ESP32 and LibreTiny (queued events), in the SDK callback on ESP8266 and diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index c31fe336484..71e8511e03e 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -24,6 +24,10 @@ #endif #endif +#ifdef USE_WIFI_DPP +#include +#endif + #ifdef USE_WIFI_AP #include "dhcpserver/dhcpserver.h" #endif // USE_WIFI_AP @@ -64,6 +68,15 @@ static bool s_sta_connect_error = false; // NOLINT(cppcoreguidelines-avoid- static bool s_sta_connecting = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static bool s_wifi_started = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#ifdef USE_WIFI_DPP +/// Terminated copies of the received station credentials; the supplicant's wifi_config_t would grow every event + +static void copy_terminated(char *dst, const uint8_t *src, size_t len) { + memcpy(dst, src, len); + dst[len] = '\0'; +} +#endif + struct IDFWiFiEvent { esp_event_base_t event_base; int32_t event_id; @@ -76,6 +89,10 @@ struct IDFWiFiEvent { wifi_event_ap_stadisconnected_t ap_stadisconnected; wifi_event_ap_probe_req_rx_t ap_probe_req_rx; wifi_event_bss_rssi_low_t bss_rssi_low; +#ifdef USE_WIFI_DPP + SavedWifiSettings *dpp_credentials; // owned by the event; the rare 98 byte record does not widen the union + wifi_event_dpp_failed_t dpp_failed; +#endif ip_event_got_ip_t ip_got_ip; #if USE_NETWORK_IPV6 ip_event_got_ip6_t ip_got_ip6; @@ -125,6 +142,23 @@ void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, voi memcpy(&event.data.ap_staconnected, event_data, sizeof(wifi_event_ap_staconnected_t)); } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_AP_STADISCONNECTED) { memcpy(&event.data.ap_stadisconnected, event_data, sizeof(wifi_event_ap_stadisconnected_t)); +#ifdef USE_WIFI_DPP + } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_DPP_URI_READY) { + // Kept on the component for the whole boot; the queued event only announces it + const auto *uri = static_cast(event_data); + const size_t len = strnlen(uri->uri, uri->uri_data_len); + auto copy = std::make_unique(len + 1); + copy_terminated(copy.get(), reinterpret_cast(uri->uri), len); + global_wifi_component->dpp_uri_ = std::move(copy); + } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_DPP_CFG_RECVD) { + const auto &sta = static_cast(event_data)->wifi_cfg.sta; + auto *credentials = new SavedWifiSettings; // NOLINT(cppcoreguidelines-owning-memory) + copy_terminated(credentials->ssid, sta.ssid, sizeof(sta.ssid)); + copy_terminated(credentials->password, sta.password, sizeof(sta.password)); + event.data.dpp_credentials = credentials; + } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_DPP_FAILED) { + memcpy(&event.data.dpp_failed, event_data, sizeof(wifi_event_dpp_failed_t)); +#endif #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) } else if (event_base == IP_EVENT && event_id == IP_EVENT_ASSIGNED_IP_TO_CLIENT) { memcpy(&event.data.ip_assigned_ip_to_client, event_data, sizeof(ip_event_assigned_ip_to_client_t)); @@ -141,6 +175,10 @@ void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, voi auto *to_send = new IDFWiFiEvent; // NOLINT(cppcoreguidelines-owning-memory) memcpy(to_send, &event, sizeof(IDFWiFiEvent)); if (!global_wifi_component->event_queue_.push(to_send)) { +#ifdef USE_WIFI_DPP + if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_DPP_CFG_RECVD) + delete to_send->data.dpp_credentials; // NOLINT(cppcoreguidelines-owning-memory) +#endif delete to_send; // NOLINT(cppcoreguidelines-owning-memory) } } @@ -224,6 +262,9 @@ void WiFiComponent::wifi_lazy_init_() { } s_wifi_started = true; this->wifi_initialized_ = true; +#ifdef USE_WIFI_DPP + this->wifi_dpp_init_(); +#endif return; } @@ -242,9 +283,28 @@ void WiFiComponent::wifi_lazy_init_() { ESP_LOGE(TAG, "esp_wifi_set_storage failed: %s", esp_err_to_name(err)); return; } + this->wifi_initialized_ = true; +#ifdef USE_WIFI_DPP + this->wifi_dpp_init_(); +#endif } +#ifdef USE_WIFI_DPP +void WiFiComponent::wifi_dpp_init_() { + // With no callback the supplicant posts WIFI_EVENT_DPP_* events, handled with the other events + esp_err_t err = esp_supp_dpp_init(nullptr); + if (err != ESP_OK) { + ESP_LOGW(TAG, "%s failed: %s", LOG_STR_LITERAL("esp_supp_dpp_init"), esp_err_to_name(err)); + return; + } + err = esp_supp_dpp_bootstrap_gen(this->dpp_channels_, DPP_BOOTSTRAP_QR_CODE, this->dpp_key_, this->dpp_device_info_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "%s failed: %s", LOG_STR_LITERAL("esp_supp_dpp_bootstrap_gen"), esp_err_to_name(err)); + } +} +#endif + bool WiFiComponent::wifi_mode_(optional sta, optional ap) { esp_err_t err; wifi_mode_t current_mode = WIFI_MODE_NULL; @@ -354,6 +414,10 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { // enable STA if (!this->wifi_mode_(true, {})) return false; +#ifdef USE_WIFI_DPP + // A connect attempt ends the supplicant's listen; the next cooldown starts it again + this->stop_dpp_listen_(); +#endif // https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/network/esp_wifi.html#_CPPv417wifi_sta_config_t wifi_config_t conf; @@ -1232,6 +1296,31 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { ESP_LOGV(TAG, "AP client disconnected MAC=%s", mac_buf); #endif +#ifdef USE_WIFI_DPP + } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_DPP_URI_READY) { + ESP_LOGD(TAG, "DPP URI: %s", this->dpp_uri_.get()); +#ifdef USE_WIFI_DPP_URI_TRIGGER + this->dpp_uri_callback_.call(this->dpp_uri_.get()); +#endif + + } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_DPP_CFG_RECVD) { + const std::unique_ptr credentials(data->data.dpp_credentials); + this->dpp_listening_ = false; + ESP_LOGI(TAG, "DPP configured network " LOG_SECRET("'%s'"), credentials->ssid); + // The reboot timer starts over for the connect attempt, as after a boot with saved credentials + this->last_connected_ = App.get_loop_component_start_time(); + // Saved like portal credentials; from a cooldown this already restarts the retries + this->save_wifi_sta(credentials->ssid, credentials->password); + if (this->state_ == WIFI_COMPONENT_STATE_OFF || this->state_ == WIFI_COMPONENT_STATE_AP) { + // Without networks the station machine was never started; an AP without a portal sits in AP + this->start_connecting(*this->get_selected_sta_()); + } + + } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_DPP_FAILED) { + this->dpp_listening_ = false; + ESP_LOGW(TAG, "DPP failed: %s", esp_err_to_name(data->data.dpp_failed.failure_reason)); +#endif // USE_WIFI_DPP + #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) } else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_ASSIGNED_IP_TO_CLIENT) { const auto &it = data->data.ip_assigned_ip_to_client; @@ -1463,6 +1552,20 @@ network::IPAddress WiFiComponent::wifi_soft_ap_ip() { } #endif // USE_WIFI_AP +#ifdef USE_WIFI_DPP +bool WiFiComponent::wifi_dpp_start_listen_() { + esp_err_t err = esp_supp_dpp_start_listen(); + if (err != ESP_OK) { + ESP_LOGW(TAG, "%s failed: %s", LOG_STR_LITERAL("esp_supp_dpp_start_listen"), esp_err_to_name(err)); + return false; + } + ESP_LOGD(TAG, "DPP listening for a configurator"); + return true; +} + +void WiFiComponent::wifi_dpp_stop_listen_() { esp_supp_dpp_stop_listen(); } +#endif + bool WiFiComponent::wifi_disconnect_() { return esp_wifi_disconnect(); } bssid_t WiFiComponent::wifi_bssid() { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7763812054a..e0164c69245 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -496,6 +496,8 @@ #define USE_CAPTIVE_PORTAL_GZIP #endif #define USE_WIFI_11KV_SUPPORT +#define USE_WIFI_DPP +#define USE_WIFI_DPP_URI_TRIGGER #define USE_WIFI_FAST_CONNECT #define USE_WIFI_FAST_CONNECT_IN_FLASH #define USE_WIFI_LEASE_CACHE diff --git a/tests/components/wifi/test-dpp.esp32-idf.yaml b/tests/components/wifi/test-dpp.esp32-idf.yaml new file mode 100644 index 00000000000..29ec40ba797 --- /dev/null +++ b/tests/components/wifi/test-dpp.esp32-idf.yaml @@ -0,0 +1,12 @@ +wifi: + dpp: + channels: [1, 6, 11] + device_info: ESPHome + timeout: 30s + on_dpp_uri: + - logger.log: + format: "DPP URI %s" + args: [uri] + +packages: + wifi: !include common.yaml diff --git a/tests/components/wifi/validate-dpp-bare.esp32-idf.yaml b/tests/components/wifi/validate-dpp-bare.esp32-idf.yaml new file mode 100644 index 00000000000..cdb69281c45 --- /dev/null +++ b/tests/components/wifi/validate-dpp-bare.esp32-idf.yaml @@ -0,0 +1,6 @@ +# A bare dpp: takes every default +wifi: + dpp: + +packages: + wifi: !include common.yaml diff --git a/tests/unit_tests/components/test_wifi.py b/tests/unit_tests/components/test_wifi.py index b09e37aacf1..e52ec99418a 100644 --- a/tests/unit_tests/components/test_wifi.py +++ b/tests/unit_tests/components/test_wifi.py @@ -13,6 +13,7 @@ from esphome.components.wifi import ( CONF_AP_MODE, CONF_AP_TIMEOUT, CONF_DNS_SERVERS, + _validate_dpp_device_info, check_placeholder_credentials, final_validate as wifi_final_validate, has_native_wifi, @@ -219,6 +220,14 @@ def test_check_placeholder_credentials_skips_template_ssid() -> None: assert check_placeholder_credentials(config) is None +@pytest.mark.parametrize("info", ["ESPHome bench", "a\tb", " x"]) +def test_dpp_device_info_rejects_whitespace(info: str) -> None: + """The supplicant reads the text up to the first space.""" + with pytest.raises(cv.Invalid, match="whitespace"): + _validate_dpp_device_info(info) + assert _validate_dpp_device_info("bench-01") == "bench-01" + + @pytest.fixture def wisp_core(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> None: """An ESP32 build; the parameter overrides the framework or the ESP-IDF version.""" From 92e0e6db6b10ab47c774a8867d264b2e028aecea Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:40:07 -0400 Subject: [PATCH 137/142] [wifi] Call esp_supp_dpp_init without a callback on ESP-IDF 6 (#20789) --- esphome/components/wifi/wifi_component_esp_idf.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 71e8511e03e..d308ad2b977 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -293,7 +293,11 @@ void WiFiComponent::wifi_lazy_init_() { #ifdef USE_WIFI_DPP void WiFiComponent::wifi_dpp_init_() { // With no callback the supplicant posts WIFI_EVENT_DPP_* events, handled with the other events +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + esp_err_t err = esp_supp_dpp_init(); +#else esp_err_t err = esp_supp_dpp_init(nullptr); +#endif if (err != ESP_OK) { ESP_LOGW(TAG, "%s failed: %s", LOG_STR_LITERAL("esp_supp_dpp_init"), esp_err_to_name(err)); return; From 0f408234d043a3ed6a65183494bb02e785be99d2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:53:17 -0400 Subject: [PATCH 138/142] [core] Add ksz8851snl to the static analysis manifest for IDF 6 (#20788) --- esphome/idf_component.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 372059bd96f..5ed84fa2a3a 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -85,6 +85,10 @@ dependencies: version: "1.1.0" rules: - if: "idf_version >=6.0.0" + espressif/ksz8851snl: + version: "1.2.0" + rules: + - if: "idf_version >=6.0.0" # Keep in sync with esphome/components/ethernet/__init__.py espressif/ch390: version: "0.3.0" From 22defcd7042768c0ed52e5392ff888bd81ce8883 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 15:59:56 -1000 Subject: [PATCH 139/142] [esp32_ble_client] Value-initialize the connection parameters for ESP-IDF 6.1 (#20776) --- esphome/components/esp32_ble_client/ble_client_base.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index dde47b0c5e4..6905ef82228 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -300,7 +300,7 @@ void BLEClientBase::log_warning_(const char *message) { esp_err_t BLEClientBase::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, const char *param_type) { - esp_ble_conn_update_params_t conn_params = {{0}}; + esp_ble_conn_update_params_t conn_params = {}; memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); conn_params.min_int = min_interval; conn_params.max_int = max_interval; From 6edfbb594df63f615b907907e6482a1a4882617f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 16:00:07 -1000 Subject: [PATCH 140/142] [bluetooth_connection] Value-initialize the connection parameters for ESP-IDF 6.1 (#20774) --- .../bluetooth_connection/bluetooth_connection_bluedroid.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index 7b66c671835..5fe1460c661 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -382,7 +382,7 @@ void BluedroidGattClient::set_disconnecting_() { esp_err_t BluedroidGattClient::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, const char *param_type) { - esp_ble_conn_update_params_t conn_params = {{0}}; + esp_ble_conn_update_params_t conn_params = {}; memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); conn_params.min_int = min_interval; conn_params.max_int = max_interval; From f7057e1270a97eb9ecf743e81834884e0927efa7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 16:01:45 -1000 Subject: [PATCH 141/142] [tests] Reset saved preferences before every C++ unit test (#20771) --- tests/components/daikin/daikin_regression_tests.cpp | 2 +- tests/components/daikin/daikin_tests.cpp | 2 +- tests/components/exponential_moving_average/common.h | 3 --- tests/components/friedrich/friedrich_test.cpp | 8 +------- tests/components/main.cpp | 8 +++++++- tests/components/mistral_ir/mistral_ir_tests.cpp | 2 +- tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp | 2 +- tests/components/whirlpool/whirlpool_tests.cpp | 4 ---- 8 files changed, 12 insertions(+), 19 deletions(-) diff --git a/tests/components/daikin/daikin_regression_tests.cpp b/tests/components/daikin/daikin_regression_tests.cpp index fd695b9ca48..fc642a84e9d 100644 --- a/tests/components/daikin/daikin_regression_tests.cpp +++ b/tests/components/daikin/daikin_regression_tests.cpp @@ -121,7 +121,7 @@ struct Rig { DaikinClimate climate; Rig() { - // All instances share one preference key; clear it so setup starts from defaults + // A second fixture in the same test would otherwise restore what the first saved if (global_preferences != nullptr) global_preferences->reset(); static const bool APP_CONSTRUCTED = (new (&App) Application(), true); diff --git a/tests/components/daikin/daikin_tests.cpp b/tests/components/daikin/daikin_tests.cpp index 5a71cf41b7f..0664a82571c 100644 --- a/tests/components/daikin/daikin_tests.cpp +++ b/tests/components/daikin/daikin_tests.cpp @@ -143,7 +143,7 @@ struct Fixture { /// Pass keep_preferences to restore what an earlier fixture saved explicit Fixture(bool keep_preferences = false) { - // Every fixture shares one preference key, so clear what an earlier test saved or setup restores it + // A second fixture in the same test would otherwise restore what the first saved if (!keep_preferences && global_preferences != nullptr) global_preferences->reset(); // The test main does not construct App as generated code does; the boost timer needs its scheduler diff --git a/tests/components/exponential_moving_average/common.h b/tests/components/exponential_moving_average/common.h index 599c36bb0d5..84b9a24a522 100644 --- a/tests/components/exponential_moving_average/common.h +++ b/tests/components/exponential_moving_average/common.h @@ -8,7 +8,6 @@ #include #include "esphome/components/exponential_moving_average/exponential_moving_average_sensor.h" -#include "esphome/core/preferences.h" #ifdef USE_HOST #include "esphome/components/host/preferences.h" #endif @@ -33,11 +32,9 @@ class ExponentialMovingAverageTest : public ::testing::Test { #ifdef USE_HOST host::setup_preferences(); #endif - global_preferences->reset(); } void TearDown() override { - global_preferences->reset(); if (this->saved_prefdir_.has_value()) { setenv("ESPHOME_PREFDIR", this->saved_prefdir_->c_str(), 1); } else { diff --git a/tests/components/friedrich/friedrich_test.cpp b/tests/components/friedrich/friedrich_test.cpp index baf4e53652e..610eca9c091 100644 --- a/tests/components/friedrich/friedrich_test.cpp +++ b/tests/components/friedrich/friedrich_test.cpp @@ -4,7 +4,6 @@ #include "esphome/components/friedrich/friedrich.h" #include "esphome/components/remote_base/aeha_protocol.h" #include "esphome/core/helpers.h" -#include "esphome/core/preferences.h" namespace esphome::friedrich::testing { @@ -42,12 +41,7 @@ struct Fixture { TestFriedrich sut; CapturingTransmitter transmitter; - Fixture() { - // Climate tests share one preference key, so clear what an earlier test saved or setup restores it - if (global_preferences != nullptr) - global_preferences->reset(); - this->sut.set_transmitter(&this->transmitter); - } + Fixture() { this->sut.set_transmitter(&this->transmitter); } // The last transmission decoded, or an empty frame if it could not be decoded AEHAData sent() { return AEHAProtocol().decode(rx_of(this->transmitter.last)).value_or(AEHAData{}); } diff --git a/tests/components/main.cpp b/tests/components/main.cpp index f1ceb732f91..4020dd9a505 100644 --- a/tests/components/main.cpp +++ b/tests/components/main.cpp @@ -2,6 +2,7 @@ #include "esphome/components/logger/logger.h" #include "esphome/core/application.h" +#include "esphome/core/preferences.h" /* This special main.cpp replaces the default one. @@ -22,7 +23,12 @@ void original_setup() { // The loop clock and current component one test moves must not leak into the next, like CORE.reset() in Python class ResetAppListener : public ::testing::EmptyTestEventListener { - void OnTestStart(const ::testing::TestInfo &) override { esphome::LoopBlockingGuard reset{nullptr, nullptr, 0}; } + void OnTestStart(const ::testing::TestInfo &) override { + esphome::LoopBlockingGuard reset{nullptr, nullptr, 0}; + // Entities of the same kind share preference keys, so a value an earlier test saved would be restored + if (esphome::global_preferences != nullptr) + esphome::global_preferences->reset(); + } }; void setup() { diff --git a/tests/components/mistral_ir/mistral_ir_tests.cpp b/tests/components/mistral_ir/mistral_ir_tests.cpp index 37db86d10a1..9b489154d02 100644 --- a/tests/components/mistral_ir/mistral_ir_tests.cpp +++ b/tests/components/mistral_ir/mistral_ir_tests.cpp @@ -81,7 +81,7 @@ struct Fixture { MistralIR climate; Fixture() { - // Every fixture shares one preference key, so clear what an earlier test saved or setup restores it + // A second fixture in the same test would otherwise restore what the first saved if (global_preferences != nullptr) global_preferences->reset(); // The test main does not construct App as generated code does diff --git a/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp b/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp index e7a5e2c18f2..293c363d5d6 100644 --- a/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp +++ b/tests/components/pd_pioneer_ir/pd_pioneer_ir_tests.cpp @@ -69,7 +69,7 @@ struct Fixture { PDPioneerIR climate; Fixture() { - // Every fixture shares one preference key, so clear what an earlier test saved or setup restores it + // A second fixture in the same test would otherwise restore what the first saved if (global_preferences != nullptr) global_preferences->reset(); // The test main does not construct App as generated code does diff --git a/tests/components/whirlpool/whirlpool_tests.cpp b/tests/components/whirlpool/whirlpool_tests.cpp index ef628abe54d..96cd288c61b 100644 --- a/tests/components/whirlpool/whirlpool_tests.cpp +++ b/tests/components/whirlpool/whirlpool_tests.cpp @@ -7,7 +7,6 @@ #include "esphome/components/remote_base/remote_base.h" #include "esphome/components/whirlpool/whirlpool.h" -#include "esphome/core/preferences.h" namespace esphome::whirlpool::testing { @@ -122,9 +121,6 @@ struct Fixture { WhirlpoolClimate climate; explicit Fixture(Model model) { - // Every fixture shares one preference key, so clear what an earlier test saved or setup restores it - if (global_preferences != nullptr) - global_preferences->reset(); this->climate.set_model(model); this->climate.set_transmitter(&this->transmitter); this->climate.setup(); From f3eaf698d6d35b8fbd5c3990d99772adaec2739c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:05:01 -0400 Subject: [PATCH 142/142] [mipi_rgb] Rotate pure RGB panels in software (#20757) --- .../components/mipi_rgb/models/__init__.py | 6 +++- .../mipi_rgb/test_mipi_rgb_config.py | 35 +++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/esphome/components/mipi_rgb/models/__init__.py b/esphome/components/mipi_rgb/models/__init__.py index 9e3fe2a476f..62f8c7789d4 100644 --- a/esphome/components/mipi_rgb/models/__init__.py +++ b/esphome/components/mipi_rgb/models/__init__.py @@ -9,6 +9,10 @@ class RgbDriverChip(DriverChip): def transforms(self) -> set[str]: """ Return the set of transformations supported by this driver chip. - RGB displays do not support axis swapping, so this method removes CONF_SWAP_XY + RGB displays do not support axis swapping, so this method removes CONF_SWAP_XY. + A model with an empty init sequence has no command interface at all, so it + cannot apply any transform in hardware. """ + if self.initsequence is not None and len(self.initsequence) == 0: + return set() return super().transforms - {CONF_SWAP_XY} diff --git a/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py index e677577ec08..58e7ba9d959 100644 --- a/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py +++ b/tests/component_tests/mipi_rgb/test_mipi_rgb_config.py @@ -93,12 +93,12 @@ def test_transform_matches_model_support( from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS - # ESP32-8048S070 supports both mirror axes but not swap_xy (RGB displays + # The full-height ST7701S supports both mirror axes but not swap_xy (RGB displays # never support axis swapping). - model = MODELS["ESP32-8048S070"] + model = MODELS["ST7701S"] assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y} - base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21} + base = {"model": "ST7701S", "data_pins": DATA_PINS, "pclk_pin": 21} CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": False}}) # An unsupported axis may be explicitly disabled (a harmless no-op)... @@ -139,6 +139,35 @@ def test_st7701s_only_supports_mirror_x( CONFIG_SCHEMA({**base, "transform": {"mirror_x": True, "mirror_y": True}}) +def test_pure_rgb_model_has_no_hardware_transform( + set_core_config: SetCoreConfigCallable, +) -> None: + """A model with no init sequence has no command interface, so no transform + can be applied in hardware and every rotation must be done in software. + + Previously such models claimed mirror_x/mirror_y support, so a rotation of + 180 was handed to the (nonexistent) hardware and the display was not rotated. + """ + _set_s3(set_core_config) + + from esphome.components.mipi_rgb.display import CONFIG_SCHEMA, MODELS + + model = MODELS["ESP32-8048S070"] + assert model.initsequence == () + assert model.transforms == set() + + base = {"model": "ESP32-8048S070", "data_pins": DATA_PINS, "pclk_pin": 21} + for rotation in (90, 180, 270): + config = CONFIG_SCHEMA({**base, "id": f"rot_{rotation}", "rotation": rotation}) + assert not model.rotation_as_transform(config) + metadata = get_display_metadata(config["id"]) + assert metadata.rotation == rotation + assert not metadata.has_hardware_rotation + + with pytest.raises(cv.Invalid, match="'mirror_x' is not supported by this model"): + CONFIG_SCHEMA({**base, "transform": {"mirror_x": True}}) + + def test_metadata_records_rotation( set_core_config: SetCoreConfigCallable, ) -> None: