From 18168ad7fda9cb7f93a6d5b5c183038954318076 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 15:07:15 -1000 Subject: [PATCH 01/72] [sensor] Use std::array in CalibrateLinearFilter (#15263) --- esphome/components/sensor/__init__.py | 4 +++- esphome/components/sensor/filter.cpp | 11 ++++------- esphome/components/sensor/filter.h | 16 ++++++++++++---- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 8abba17ff9..650f5ed826 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -770,7 +770,9 @@ async def calibrate_linear_filter_to_code(config, filter_id): linear_functions = [[k, b, float("NaN")]] elif config[CONF_METHOD] == "exact": linear_functions = map_linear(x, y) - return cg.new_Pvariable(filter_id, linear_functions) + return cg.new_Pvariable( + filter_id, cg.TemplateArguments(len(linear_functions)), linear_functions + ) CONF_DEGREE = "degree" diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 7b7a968f48..6a90a5af66 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -385,13 +385,10 @@ void HeartbeatFilter::setup() { float HeartbeatFilter::get_setup_priority() const { return setup_priority::HARDWARE; } -CalibrateLinearFilter::CalibrateLinearFilter(std::initializer_list> linear_functions) - : linear_functions_(linear_functions) {} - -optional CalibrateLinearFilter::new_value(float value) { - for (const auto &f : this->linear_functions_) { - if (!std::isfinite(f[2]) || value < f[2]) - return (value * f[0]) + f[1]; +optional calibrate_linear_compute(const std::array *functions, size_t count, float value) { + for (size_t i = 0; i < count; i++) { + if (!std::isfinite(functions[i][2]) || value < functions[i][2]) + return (value * functions[i][0]) + functions[i][1]; } return NAN; } diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 26a03acde5..cb4abd154a 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -528,13 +528,21 @@ template class OrFilter : public Filter { bool has_value_{false}; }; -class CalibrateLinearFilter : public Filter { +/// Non-template helper for linear calibration (implementation in filter.cpp) +optional calibrate_linear_compute(const std::array *functions, size_t count, float value); + +/// N is set by code generation to match the exact number of calibration segments. +template class CalibrateLinearFilter : public Filter { public: - explicit CalibrateLinearFilter(std::initializer_list> linear_functions); - optional new_value(float value) override; + explicit CalibrateLinearFilter(std::initializer_list> linear_functions) { + init_array_from(this->linear_functions_, linear_functions); + } + optional new_value(float value) override { + return calibrate_linear_compute(this->linear_functions_.data(), N, value); + } protected: - FixedVector> linear_functions_; + std::array, N> linear_functions_{}; }; /// Non-template helper for polynomial calibration (implementation in filter.cpp) From ffbbe5eab33e9d18e8e209fa55b3d24de21f765f Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Mon, 30 Mar 2026 08:55:40 +0200 Subject: [PATCH 02/72] [nextion] Fix log level for command processing limit message (#15302) --- esphome/components/nextion/nextion.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index 97d9b36e4c..b0d8ba92f7 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -423,7 +423,7 @@ void Nextion::process_nextion_commands_() { DELIMITER_SIZE)) != std::string::npos) { #ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP if (++commands_processed > this->max_commands_per_loop_) { - ESP_LOGW(TAG, "Command processing limit exceeded"); + ESP_LOGV(TAG, "Command limit reached, deferring"); break; } #endif // USE_NEXTION_MAX_COMMANDS_PER_LOOP From 95b0e6061795f6e4ba2d7474674cbbd1a07a7347 Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Mon, 30 Mar 2026 09:20:04 +0200 Subject: [PATCH 03/72] [nextion] Add accessor const qualifiers, return by ref, and deprecate `get_wave_chan_id()` (#15204) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- .../binary_sensor/nextion_binarysensor.h | 6 ++-- esphome/components/nextion/nextion.cpp | 30 +++++----------- .../nextion/nextion_component_base.h | 34 +++++++++---------- .../nextion/sensor/nextion_sensor.h | 5 ++- .../nextion/switch/nextion_switch.h | 2 +- .../nextion/text_sensor/nextion_textsensor.h | 2 +- 6 files changed, 32 insertions(+), 47 deletions(-) diff --git a/esphome/components/nextion/binary_sensor/nextion_binarysensor.h b/esphome/components/nextion/binary_sensor/nextion_binarysensor.h index b6b23ada85..baab47851c 100644 --- a/esphome/components/nextion/binary_sensor/nextion_binarysensor.h +++ b/esphome/components/nextion/binary_sensor/nextion_binarysensor.h @@ -21,15 +21,15 @@ class NextionBinarySensor : public NextionComponent, void process_touch(uint8_t page_id, uint8_t component_id, bool state) override; // Set the components page id for Nextion Touch Component - void set_page_id(uint8_t page_id) { page_id_ = page_id; } + void set_page_id(uint8_t page_id) { this->page_id_ = page_id; } // Set the components component id for Nextion Touch Component - void set_component_id(uint8_t component_id) { component_id_ = component_id; } + void set_component_id(uint8_t component_id) { this->component_id_ = component_id; } void set_state(bool state) override { this->set_state(state, true, true); } void set_state(bool state, bool publish) override { this->set_state(state, publish, true); } void set_state(bool state, bool publish, bool send_to_nextion) override; - NextionQueueType get_queue_type() override { return NextionQueueType::BINARY_SENSOR; } + NextionQueueType get_queue_type() const override { return NextionQueueType::BINARY_SENSOR; } void set_state_from_string(const std::string &state_value, bool publish, bool send_to_nextion) override {} void set_state_from_int(int state_value, bool publish, bool send_to_nextion) override { this->set_state(state_value != 0, publish, send_to_nextion); diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index b0d8ba92f7..22fb3ce937 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -281,7 +281,7 @@ void Nextion::print_queue_members_() { ESP_LOGN(TAG, "Queue null"); } else { ESP_LOGN(TAG, "Queue type: %d:%s, name: %s", i->component->get_queue_type(), - i->component->get_queue_type_string().c_str(), i->component->get_variable_name().c_str()); + i->component->get_queue_type_string(), i->component->get_variable_name().c_str()); } } ESP_LOGN(TAG, "*******************************************"); @@ -607,7 +607,7 @@ void Nextion::process_nextion_commands_() { ESP_LOGE(TAG, "String return but '%s' not text sensor", component->get_variable_name().c_str()); } else { ESP_LOGN(TAG, "String resp: '%s' id: %s type: %s", to_process.c_str(), component->get_variable_name().c_str(), - component->get_queue_type_string().c_str()); + component->get_queue_type_string()); } delete nb; // NOLINT(cppcoreguidelines-owning-memory) @@ -649,7 +649,7 @@ void Nextion::process_nextion_commands_() { component->get_queue_type()); } else { ESP_LOGN(TAG, "Numeric: %s type %d:%s val %d", component->get_variable_name().c_str(), - component->get_queue_type(), component->get_queue_type_string().c_str(), value); + component->get_queue_type(), component->get_queue_type_string(), value); component->set_state_from_int(value, true, false); } @@ -842,24 +842,10 @@ void Nextion::process_nextion_commands_() { if (this->max_q_age_ms_ > 0 && !this->nextion_queue_.empty() && ms - this->nextion_queue_.front()->queue_time > this->max_q_age_ms_) { for (auto it = this->nextion_queue_.begin(); it != this->nextion_queue_.end();) { - NextionComponentBase *component = (*it)->component; if (ms - (*it)->queue_time > this->max_q_age_ms_) { - if ((*it)->queue_time == 0) { - ESP_LOGD(TAG, "Remove old queue '%s':'%s' (t=0)", component->get_queue_type_string().c_str(), - component->get_variable_name().c_str()); - } - - if (component->get_variable_name() == "sleep_wake") { - this->is_sleeping_ = false; - } - - if ((*it)->pending_command.empty()) { - ESP_LOGD(TAG, "Remove old queue '%s':'%s'", component->get_queue_type_string().c_str(), - component->get_variable_name().c_str()); - } else { - ESP_LOGD(TAG, "Remove old queue '%s':'%s' cmd:'%s'", component->get_queue_type_string().c_str(), - component->get_variable_name().c_str(), (*it)->pending_command.c_str()); - } + NextionComponentBase *component = (*it)->component; + ESP_LOGV(TAG, "Remove old queue '%s':'%s'", component->get_queue_type_string(), + component->get_variable_name().c_str()); if (component->get_queue_type() == NextionQueueType::NO_RESULT) { if (component->get_variable_name() == "sleep_wake") { @@ -940,7 +926,7 @@ void Nextion::all_components_send_state_(bool force_update) { binarysensortype->send_state_to_nextion(); } for (auto *sensortype : this->sensortype_) { - if ((force_update || sensortype->get_needs_to_send_update()) && sensortype->get_wave_chan_id() == 0) + if ((force_update || sensortype->get_needs_to_send_update()) && sensortype->get_wave_channel_id() == 0) sensortype->send_state_to_nextion(); } for (auto *switchtype : this->switchtype_) { @@ -1236,7 +1222,7 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) { nextion_queue->component = component; nextion_queue->queue_time = App.get_loop_component_start_time(); - ESP_LOGN(TAG, "Queue %s: %s", component->get_queue_type_string().c_str(), component->get_variable_name().c_str()); + ESP_LOGN(TAG, "Queue %s: %s", component->get_queue_type_string(), component->get_variable_name().c_str()); std::string command = "get " + component->get_variable_name_to_send(); diff --git a/esphome/components/nextion/nextion_component_base.h b/esphome/components/nextion/nextion_component_base.h index fe0692b875..4d5550d406 100644 --- a/esphome/components/nextion/nextion_component_base.h +++ b/esphome/components/nextion/nextion_component_base.h @@ -1,4 +1,6 @@ #pragma once + +#include #include #include #include "esphome/core/defines.h" @@ -35,12 +37,8 @@ class NextionComponentBase { virtual ~NextionComponentBase() = default; void set_variable_name(const std::string &variable_name, const std::string &variable_name_to_send = "") { - variable_name_ = variable_name; - if (variable_name_to_send.empty()) { - variable_name_to_send_ = variable_name; - } else { - variable_name_to_send_ = variable_name_to_send; - } + this->variable_name_ = variable_name; + this->variable_name_to_send_ = variable_name_to_send.empty() ? variable_name : variable_name_to_send; } virtual void update_component_settings(){}; @@ -64,14 +62,14 @@ class NextionComponentBase { virtual void set_state(const std::string &state, bool publish) {} virtual void set_state(const std::string &state, bool publish, bool send_to_nextion){}; - uint8_t get_component_id() { return this->component_id_; } - void set_component_id(uint8_t component_id) { component_id_ = component_id; } + uint8_t get_component_id() const { return this->component_id_; } + void set_component_id(uint8_t component_id) { this->component_id_ = component_id; } - uint8_t get_wave_channel_id() { return this->wave_chan_id_; } + uint8_t get_wave_channel_id() const { return this->wave_chan_id_; } void set_wave_channel_id(uint8_t wave_chan_id) { this->wave_chan_id_ = wave_chan_id; } - std::vector get_wave_buffer() { return this->wave_buffer_; } - size_t get_wave_buffer_size() { return this->wave_buffer_.size(); } + const std::vector &get_wave_buffer() const { return this->wave_buffer_; } + size_t get_wave_buffer_size() const { return this->wave_buffer_.size(); } void clear_wave_buffer(size_t buffer_sent) { if (this->wave_buffer_.size() <= buffer_sent) { this->wave_buffer_.clear(); @@ -80,15 +78,17 @@ class NextionComponentBase { } } - std::string get_variable_name() { return this->variable_name_; } - std::string get_variable_name_to_send() { return this->variable_name_to_send_; } - virtual NextionQueueType get_queue_type() { return NextionQueueType::NO_RESULT; } - virtual std::string get_queue_type_string() { return NEXTION_QUEUE_TYPE_STRINGS[this->get_queue_type()]; } + const std::string &get_variable_name() const { return this->variable_name_; } + const std::string &get_variable_name_to_send() const { return this->variable_name_to_send_; } + virtual NextionQueueType get_queue_type() const { return NextionQueueType::NO_RESULT; } + virtual const char *get_queue_type_string() const { return NEXTION_QUEUE_TYPE_STRINGS[this->get_queue_type()]; } virtual void set_state_from_int(int state_value, bool publish, bool send_to_nextion){}; virtual void set_state_from_string(const std::string &state_value, bool publish, bool send_to_nextion){}; virtual void send_state_to_nextion(){}; - bool get_needs_to_send_update() { return this->needs_to_send_update_; } - uint8_t get_wave_chan_id() { return this->wave_chan_id_; } + bool get_needs_to_send_update() const { return this->needs_to_send_update_; } + // Remove before 2026.10.0 + ESPDEPRECATED("Use get_wave_channel_id() instead. Will be removed in 2026.10.0", "2026.4.0") + uint8_t get_wave_chan_id() const { return this->get_wave_channel_id(); } void set_wave_max_length(int wave_max_length) { this->wave_max_length_ = wave_max_length; } protected: diff --git a/esphome/components/nextion/sensor/nextion_sensor.h b/esphome/components/nextion/sensor/nextion_sensor.h index e4dde9a513..b1902f9b1b 100644 --- a/esphome/components/nextion/sensor/nextion_sensor.h +++ b/esphome/components/nextion/sensor/nextion_sensor.h @@ -17,7 +17,7 @@ class NextionSensor : public NextionComponent, public sensor::Sensor, public Pol void update() override; void add_to_wave_buffer(float state); void set_precision(uint8_t precision) { this->precision_ = precision; } - void set_component_id(uint8_t component_id) { component_id_ = component_id; } + void set_component_id(uint8_t component_id) { this->component_id_ = component_id; } void set_wave_channel_id(uint8_t wave_chan_id) { this->wave_chan_id_ = wave_chan_id; } void set_wave_max_value(uint32_t wave_maxvalue) { this->wave_maxvalue_ = wave_maxvalue; } void process_sensor(const std::string &variable_name, int state) override; @@ -27,9 +27,8 @@ class NextionSensor : public NextionComponent, public sensor::Sensor, public Pol void set_state(float state, bool publish, bool send_to_nextion) override; void set_waveform_send_last_value(bool send_last_value) { this->send_last_value_ = send_last_value; } - uint8_t get_wave_chan_id() { return this->wave_chan_id_; } void set_wave_max_length(int wave_max_length) { this->wave_max_length_ = wave_max_length; } - NextionQueueType get_queue_type() override { + NextionQueueType get_queue_type() const override { return this->wave_chan_id_ == UINT8_MAX ? NextionQueueType::SENSOR : NextionQueueType::WAVEFORM_SENSOR; } void set_state_from_string(const std::string &state_value, bool publish, bool send_to_nextion) override {} diff --git a/esphome/components/nextion/switch/nextion_switch.h b/esphome/components/nextion/switch/nextion_switch.h index 1548287473..c371ea3fc6 100644 --- a/esphome/components/nextion/switch/nextion_switch.h +++ b/esphome/components/nextion/switch/nextion_switch.h @@ -21,7 +21,7 @@ class NextionSwitch : public NextionComponent, public switch_::Switch, public Po void set_state(bool state, bool publish, bool send_to_nextion) override; void send_state_to_nextion() override { this->set_state(this->state, false, true); }; - NextionQueueType get_queue_type() override { return NextionQueueType::SWITCH; } + NextionQueueType get_queue_type() const override { return NextionQueueType::SWITCH; } void set_state_from_string(const std::string &state_value, bool publish, bool send_to_nextion) override {} void set_state_from_int(int state_value, bool publish, bool send_to_nextion) override { this->set_state(state_value != 0, publish, send_to_nextion); diff --git a/esphome/components/nextion/text_sensor/nextion_textsensor.h b/esphome/components/nextion/text_sensor/nextion_textsensor.h index 5716d0a008..7c08e47189 100644 --- a/esphome/components/nextion/text_sensor/nextion_textsensor.h +++ b/esphome/components/nextion/text_sensor/nextion_textsensor.h @@ -22,7 +22,7 @@ class NextionTextSensor : public NextionComponent, public text_sensor::TextSenso void set_state(const std::string &state, bool publish, bool send_to_nextion) override; void send_state_to_nextion() override { this->set_state(this->state, false, true); }; - NextionQueueType get_queue_type() override { return NextionQueueType::TEXT_SENSOR; } + NextionQueueType get_queue_type() const override { return NextionQueueType::TEXT_SENSOR; } void set_state_from_int(int state_value, bool publish, bool send_to_nextion) override {} void set_state_from_string(const std::string &state_value, bool publish, bool send_to_nextion) override { this->set_state(state_value, publish, send_to_nextion); From cd3c2ae77e0e88291d28372eb9a1a54d39e52b16 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 29 Mar 2026 22:45:46 -1000 Subject: [PATCH 04/72] Bump aioesphomeapi from 44.8.0 to 44.8.1 (#15309) 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 c74dd265c7..0df5caf181 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.1 esphome-dashboard==20260210.0 -aioesphomeapi==44.8.0 +aioesphomeapi==44.8.1 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From d420e7bc236984f9e711e8f7669025f5f5eb090c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Mar 2026 02:57:27 -1000 Subject: [PATCH 05/72] [modbus_controller] Fix off-by-one bounds check in byte_from_hex_str (#15301) --- esphome/components/modbus_controller/modbus_controller.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 78c3b95965..693908dca4 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -81,15 +81,15 @@ inline ModbusFunctionCode modbus_register_write_function(ModbusRegisterType reg_ inline uint8_t c_to_hex(char c) { return (c >= 'A') ? (c >= 'a') ? (c - 'a' + 10) : (c - 'A' + 10) : (c - '0'); } /** Get a byte from a hex string - * hex_byte_from_str("1122",1) returns uint_8 value 0x22 == 34 - * hex_byte_from_str("1122",0) returns 0x11 + * byte_from_hex_str("1122", 1) returns uint_8 value 0x22 == 34 + * byte_from_hex_str("1122", 0) returns 0x11 * @param value string containing hex encoding * @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in * the hex string is byte_pos * 2 * @return byte value */ inline uint8_t byte_from_hex_str(const std::string &value, uint8_t pos) { - if (value.length() < pos * 2 + 1) + if (value.length() < pos * 2 + 2) return 0; return (c_to_hex(value[pos * 2]) << 4) | c_to_hex(value[pos * 2 + 1]); } From 1bc6a8d95698f2913f202c11b1af13986f6c453a Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:54:09 +0200 Subject: [PATCH 06/72] [nextion] Fix queue age check using inconsistent time sources (#15317) --- esphome/components/nextion/nextion.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index 22fb3ce937..bb3e12be50 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -1034,7 +1034,7 @@ void Nextion::add_no_result_to_queue_(const std::string &variable_name) { nextion_queue->component = new nextion::NextionComponentBase; nextion_queue->component->set_variable_name(variable_name); - nextion_queue->queue_time = millis(); + nextion_queue->queue_time = App.get_loop_component_start_time(); this->nextion_queue_.push_back(nextion_queue); From 31574a427bf721f429d6fad82f25b360ce255aad Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Mon, 30 Mar 2026 09:56:47 -0700 Subject: [PATCH 07/72] [modbus] Share helper functions across modbus components - part A (#15291) Co-authored-by: J. Nick Koston --- esphome/components/modbus/helpers.py | 83 +++++++++++++ esphome/components/modbus/modbus_helpers.h | 106 +++++++++++++++++ .../components/modbus_controller/__init__.py | 87 ++------------ .../binary_sensor/__init__.py | 2 +- .../modbus_controller/modbus_controller.cpp | 4 +- .../modbus_controller/modbus_controller.h | 109 ++++-------------- .../modbus_controller/number/__init__.py | 6 +- .../modbus_controller/output/__init__.py | 2 +- .../modbus_controller/select/__init__.py | 9 +- .../modbus_controller/sensor/__init__.py | 3 +- .../modbus_controller/switch/__init__.py | 2 +- .../modbus_controller/text_sensor/__init__.py | 2 +- 12 files changed, 231 insertions(+), 184 deletions(-) create mode 100644 esphome/components/modbus/helpers.py create mode 100644 esphome/components/modbus/modbus_helpers.h diff --git a/esphome/components/modbus/helpers.py b/esphome/components/modbus/helpers.py new file mode 100644 index 0000000000..6f97f1e605 --- /dev/null +++ b/esphome/components/modbus/helpers.py @@ -0,0 +1,83 @@ +import esphome.codegen as cg + +modbus_ns = cg.esphome_ns.namespace("modbus") +modbus_helpers_ns = modbus_ns.namespace("helpers") + +ModbusFunctionCode_ns = modbus_ns.namespace("ModbusFunctionCode") +ModbusFunctionCode = ModbusFunctionCode_ns.enum("ModbusFunctionCode") + +MODBUS_FUNCTION_CODE = { + "read_coils": ModbusFunctionCode.READ_COILS, + "read_discrete_inputs": ModbusFunctionCode.READ_DISCRETE_INPUTS, + "read_holding_registers": ModbusFunctionCode.READ_HOLDING_REGISTERS, + "read_input_registers": ModbusFunctionCode.READ_INPUT_REGISTERS, + "write_single_coil": ModbusFunctionCode.WRITE_SINGLE_COIL, + "write_single_register": ModbusFunctionCode.WRITE_SINGLE_REGISTER, + "write_multiple_coils": ModbusFunctionCode.WRITE_MULTIPLE_COILS, + "write_multiple_registers": ModbusFunctionCode.WRITE_MULTIPLE_REGISTERS, +} + +ModbusRegisterType_ns = modbus_ns.namespace("ModbusRegisterType") +ModbusRegisterType = ModbusRegisterType_ns.enum("ModbusRegisterType") + +MODBUS_WRITE_REGISTER_TYPE = { + "custom": ModbusRegisterType.CUSTOM, + "coil": ModbusRegisterType.COIL, + "holding": ModbusRegisterType.HOLDING, +} + +MODBUS_REGISTER_TYPE = { + **MODBUS_WRITE_REGISTER_TYPE, + "discrete_input": ModbusRegisterType.DISCRETE_INPUT, + "read": ModbusRegisterType.READ, +} + +SensorValueType_ns = modbus_helpers_ns.namespace("SensorValueType") +SensorValueType = SensorValueType_ns.enum("SensorValueType") +SENSOR_VALUE_TYPE = { + "RAW": SensorValueType.RAW, + "U_WORD": SensorValueType.U_WORD, + "S_WORD": SensorValueType.S_WORD, + "U_DWORD": SensorValueType.U_DWORD, + "U_DWORD_R": SensorValueType.U_DWORD_R, + "S_DWORD": SensorValueType.S_DWORD, + "S_DWORD_R": SensorValueType.S_DWORD_R, + "U_QWORD": SensorValueType.U_QWORD, + "U_QWORD_R": SensorValueType.U_QWORD_R, + "S_QWORD": SensorValueType.S_QWORD, + "S_QWORD_R": SensorValueType.S_QWORD_R, + "FP32": SensorValueType.FP32, + "FP32_R": SensorValueType.FP32_R, +} + +TYPE_REGISTER_MAP = { + "RAW": 1, + "U_WORD": 1, + "S_WORD": 1, + "U_DWORD": 2, + "U_DWORD_R": 2, + "S_DWORD": 2, + "S_DWORD_R": 2, + "U_QWORD": 4, + "U_QWORD_R": 4, + "S_QWORD": 4, + "S_QWORD_R": 4, + "FP32": 2, + "FP32_R": 2, +} + +CPP_TYPE_REGISTER_MAP = { + "RAW": cg.uint16, + "U_WORD": cg.uint16, + "S_WORD": cg.int16, + "U_DWORD": cg.uint32, + "U_DWORD_R": cg.uint32, + "S_DWORD": cg.int32, + "S_DWORD_R": cg.int32, + "U_QWORD": cg.uint64, + "U_QWORD_R": cg.uint64, + "S_QWORD": cg.int64, + "S_QWORD_R": cg.int64, + "FP32": cg.float_, + "FP32_R": cg.float_, +} diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h new file mode 100644 index 0000000000..9f78de1c21 --- /dev/null +++ b/esphome/components/modbus/modbus_helpers.h @@ -0,0 +1,106 @@ +#pragma once + +#include + +#include "esphome/core/helpers.h" +#include "esphome/components/modbus/modbus_definitions.h" + +namespace esphome::modbus::helpers { + +enum class SensorValueType : uint8_t { + RAW = 0x00, // variable length + U_WORD = 0x1, // 1 Register unsigned + U_DWORD = 0x2, // 2 Registers unsigned + S_WORD = 0x3, // 1 Register signed + S_DWORD = 0x4, // 2 Registers signed + BIT = 0x5, + U_DWORD_R = 0x6, // 2 Registers unsigned + S_DWORD_R = 0x7, // 2 Registers unsigned + U_QWORD = 0x8, + S_QWORD = 0x9, + U_QWORD_R = 0xA, + S_QWORD_R = 0xB, + FP32 = 0xC, + FP32_R = 0xD +}; + +inline bool value_type_is_float(SensorValueType v) { + return v == SensorValueType::FP32 || v == SensorValueType::FP32_R; +} + +inline ModbusFunctionCode modbus_register_read_function(ModbusRegisterType reg_type) { + switch (reg_type) { + case ModbusRegisterType::COIL: + return ModbusFunctionCode::READ_COILS; + case ModbusRegisterType::DISCRETE_INPUT: + return ModbusFunctionCode::READ_DISCRETE_INPUTS; + case ModbusRegisterType::HOLDING: + return ModbusFunctionCode::READ_HOLDING_REGISTERS; + case ModbusRegisterType::READ: + return ModbusFunctionCode::READ_INPUT_REGISTERS; + default: + return ModbusFunctionCode::CUSTOM; + } +} + +inline ModbusFunctionCode modbus_register_write_function(ModbusRegisterType reg_type) { + switch (reg_type) { + case ModbusRegisterType::COIL: + return ModbusFunctionCode::WRITE_SINGLE_COIL; + case ModbusRegisterType::DISCRETE_INPUT: + return ModbusFunctionCode::CUSTOM; + case ModbusRegisterType::HOLDING: + return ModbusFunctionCode::READ_WRITE_MULTIPLE_REGISTERS; + case ModbusRegisterType::READ: + default: + return ModbusFunctionCode::CUSTOM; + } +} + +inline uint8_t c_to_hex(char c) { return (c >= 'A') ? (c >= 'a') ? (c - 'a' + 10) : (c - 'A' + 10) : (c - '0'); } + +/** Get a byte from a hex string + * byte_from_hex_str("1122", 1) returns uint_8 value 0x22 == 34 + * byte_from_hex_str("1122", 0) returns 0x11 + * @param value string containing hex encoding + * @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in + * the hex string is byte_pos * 2 + * @return byte value + */ +inline uint8_t byte_from_hex_str(const std::string &value, uint8_t pos) { + if (value.length() < pos * 2 + 2) + return 0; + return (c_to_hex(value[pos * 2]) << 4) | c_to_hex(value[pos * 2 + 1]); +} + +/** Get a word from a hex string + * @param value string containing hex encoding + * @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in + * the hex string is byte_pos * 2 + * @return word value + */ +inline uint16_t word_from_hex_str(const std::string &value, uint8_t pos) { + return byte_from_hex_str(value, pos) << 8 | byte_from_hex_str(value, pos + 1); +} + +/** Get a dword from a hex string + * @param value string containing hex encoding + * @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in + * the hex string is byte_pos * 2 + * @return dword value + */ +inline uint32_t dword_from_hex_str(const std::string &value, uint8_t pos) { + return word_from_hex_str(value, pos) << 16 | word_from_hex_str(value, pos + 2); +} + +/** Get a qword from a hex string + * @param value string containing hex encoding + * @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in + * the hex string is byte_pos * 2 + * @return qword value + */ +inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) { + return static_cast(dword_from_hex_str(value, pos)) << 32 | dword_from_hex_str(value, pos + 4); +} + +} // namespace esphome::modbus::helpers diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index cb0969913a..9e332425a6 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -4,6 +4,13 @@ from esphome import automation import esphome.codegen as cg from esphome.components import modbus from esphome.components.const import CONF_ENABLED +from esphome.components.modbus.helpers import ( + CPP_TYPE_REGISTER_MAP, + MODBUS_REGISTER_TYPE, + SENSOR_VALUE_TYPE, + TYPE_REGISTER_MAP, + ModbusRegisterType, +) import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_NAME, CONF_OFFSET from esphome.cpp_helpers import logging @@ -41,7 +48,6 @@ CONF_SERVER_REGISTERS = "server_registers" MULTI_CONF = True modbus_controller_ns = cg.esphome_ns.namespace("modbus_controller") -modbus_ns = cg.esphome_ns.namespace("modbus") ModbusController = modbus_controller_ns.class_( "ModbusController", cg.PollingComponent, modbus.ModbusDevice ) @@ -50,85 +56,6 @@ SensorItem = modbus_controller_ns.struct("SensorItem") ServerCourtesyResponse = modbus_controller_ns.struct("ServerCourtesyResponse") ServerRegister = modbus_controller_ns.struct("ServerRegister") -ModbusFunctionCode_ns = modbus_ns.namespace("ModbusFunctionCode") -ModbusFunctionCode = ModbusFunctionCode_ns.enum("ModbusFunctionCode") -MODBUS_FUNCTION_CODE = { - "read_coils": ModbusFunctionCode.READ_COILS, - "read_discrete_inputs": ModbusFunctionCode.READ_DISCRETE_INPUTS, - "read_holding_registers": ModbusFunctionCode.READ_HOLDING_REGISTERS, - "read_input_registers": ModbusFunctionCode.READ_INPUT_REGISTERS, - "write_single_coil": ModbusFunctionCode.WRITE_SINGLE_COIL, - "write_single_register": ModbusFunctionCode.WRITE_SINGLE_REGISTER, - "write_multiple_coils": ModbusFunctionCode.WRITE_MULTIPLE_COILS, - "write_multiple_registers": ModbusFunctionCode.WRITE_MULTIPLE_REGISTERS, -} - -ModbusRegisterType_ns = modbus_controller_ns.namespace("ModbusRegisterType") -ModbusRegisterType = ModbusRegisterType_ns.enum("ModbusRegisterType") - -MODBUS_WRITE_REGISTER_TYPE = { - "custom": ModbusRegisterType.CUSTOM, - "coil": ModbusRegisterType.COIL, - "holding": ModbusRegisterType.HOLDING, -} - -MODBUS_REGISTER_TYPE = { - **MODBUS_WRITE_REGISTER_TYPE, - "discrete_input": ModbusRegisterType.DISCRETE_INPUT, - "read": ModbusRegisterType.READ, -} - -SensorValueType_ns = modbus_controller_ns.namespace("SensorValueType") -SensorValueType = SensorValueType_ns.enum("SensorValueType") -SENSOR_VALUE_TYPE = { - "RAW": SensorValueType.RAW, - "U_WORD": SensorValueType.U_WORD, - "S_WORD": SensorValueType.S_WORD, - "U_DWORD": SensorValueType.U_DWORD, - "U_DWORD_R": SensorValueType.U_DWORD_R, - "S_DWORD": SensorValueType.S_DWORD, - "S_DWORD_R": SensorValueType.S_DWORD_R, - "U_QWORD": SensorValueType.U_QWORD, - "U_QWORD_R": SensorValueType.U_QWORD_R, - "S_QWORD": SensorValueType.S_QWORD, - "S_QWORD_R": SensorValueType.S_QWORD_R, - "FP32": SensorValueType.FP32, - "FP32_R": SensorValueType.FP32_R, -} - -TYPE_REGISTER_MAP = { - "RAW": 1, - "U_WORD": 1, - "S_WORD": 1, - "U_DWORD": 2, - "U_DWORD_R": 2, - "S_DWORD": 2, - "S_DWORD_R": 2, - "U_QWORD": 4, - "U_QWORD_R": 4, - "S_QWORD": 4, - "S_QWORD_R": 4, - "FP32": 2, - "FP32_R": 2, -} - -CPP_TYPE_REGISTER_MAP = { - "RAW": cg.uint16, - "U_WORD": cg.uint16, - "S_WORD": cg.int16, - "U_DWORD": cg.uint32, - "U_DWORD_R": cg.uint32, - "S_DWORD": cg.int32, - "S_DWORD_R": cg.int32, - "U_QWORD": cg.uint64, - "U_QWORD_R": cg.uint64, - "S_QWORD": cg.int64, - "S_QWORD_R": cg.int64, - "FP32": cg.float_, - "FP32_R": cg.float_, -} - - _LOGGER = logging.getLogger(__name__) SERVER_COURTESY_RESPONSE_SCHEMA = cv.Schema( diff --git a/esphome/components/modbus_controller/binary_sensor/__init__.py b/esphome/components/modbus_controller/binary_sensor/__init__.py index 2ae008f630..18d017e13f 100644 --- a/esphome/components/modbus_controller/binary_sensor/__init__.py +++ b/esphome/components/modbus_controller/binary_sensor/__init__.py @@ -1,10 +1,10 @@ import esphome.codegen as cg from esphome.components import binary_sensor +from esphome.components.modbus.helpers import MODBUS_REGISTER_TYPE import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID from .. import ( - MODBUS_REGISTER_TYPE, ModbusItemBaseSchema, SensorItem, add_modbus_base_properties, diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index ea6ba9d085..38eaea2d1c 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -535,7 +535,7 @@ ModbusCommandItem ModbusCommandItem::create_read_command( ModbusCommandItem cmd; cmd.modbusdevice = modbusdevice; cmd.register_type = register_type; - cmd.function_code = modbus_register_read_function(register_type); + cmd.function_code = modbus::helpers::modbus_register_read_function(register_type); cmd.register_address = start_address; cmd.register_count = register_count; cmd.on_data_func = std::move(handler); @@ -548,7 +548,7 @@ ModbusCommandItem ModbusCommandItem::create_read_command(ModbusController *modbu ModbusCommandItem cmd; cmd.modbusdevice = modbusdevice; cmd.register_type = register_type; - cmd.function_code = modbus_register_read_function(register_type); + cmd.function_code = modbus::helpers::modbus_register_read_function(register_type); cmd.register_address = start_address; cmd.register_count = register_count; cmd.on_data_func = [modbusdevice](ModbusRegisterType register_type, uint16_t start_address, diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 693908dca4..438eb12c2a 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -3,6 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/modbus/modbus.h" +#include "esphome/components/modbus/modbus_helpers.h" #include "esphome/core/automation.h" #include @@ -19,109 +20,43 @@ class ModbusController; using modbus::ModbusFunctionCode; using modbus::ModbusRegisterType; using modbus::ModbusExceptionCode; +using modbus::helpers::SensorValueType; -enum class SensorValueType : uint8_t { - RAW = 0x00, // variable length - U_WORD = 0x1, // 1 Register unsigned - U_DWORD = 0x2, // 2 Registers unsigned - S_WORD = 0x3, // 1 Register signed - S_DWORD = 0x4, // 2 Registers signed - BIT = 0x5, - U_DWORD_R = 0x6, // 2 Registers unsigned - S_DWORD_R = 0x7, // 2 Registers unsigned - U_QWORD = 0x8, - S_QWORD = 0x9, - U_QWORD_R = 0xA, - S_QWORD_R = 0xB, - FP32 = 0xC, - FP32_R = 0xD -}; - -inline bool value_type_is_float(SensorValueType v) { - return v == SensorValueType::FP32 || v == SensorValueType::FP32_R; -} +// Remove before 2026.10.0 — these helpers have moved to modbus::helpers +ESPDEPRECATED("Use modbus::helpers::value_type_is_float() instead. Removed in 2026.10.0", "2026.4.0") +inline bool value_type_is_float(SensorValueType v) { return modbus::helpers::value_type_is_float(v); } +ESPDEPRECATED("Use modbus::helpers::modbus_register_read_function() instead. Removed in 2026.10.0", "2026.4.0") inline ModbusFunctionCode modbus_register_read_function(ModbusRegisterType reg_type) { - switch (reg_type) { - case ModbusRegisterType::COIL: - return ModbusFunctionCode::READ_COILS; - break; - case ModbusRegisterType::DISCRETE_INPUT: - return ModbusFunctionCode::READ_DISCRETE_INPUTS; - break; - case ModbusRegisterType::HOLDING: - return ModbusFunctionCode::READ_HOLDING_REGISTERS; - break; - case ModbusRegisterType::READ: - return ModbusFunctionCode::READ_INPUT_REGISTERS; - break; - default: - return ModbusFunctionCode::CUSTOM; - break; - } + return modbus::helpers::modbus_register_read_function(reg_type); } + +ESPDEPRECATED("Use modbus::helpers::modbus_register_write_function() instead. Removed in 2026.10.0", "2026.4.0") inline ModbusFunctionCode modbus_register_write_function(ModbusRegisterType reg_type) { - switch (reg_type) { - case ModbusRegisterType::COIL: - return ModbusFunctionCode::WRITE_SINGLE_COIL; - break; - case ModbusRegisterType::DISCRETE_INPUT: - return ModbusFunctionCode::CUSTOM; - break; - case ModbusRegisterType::HOLDING: - return ModbusFunctionCode::READ_WRITE_MULTIPLE_REGISTERS; - break; - case ModbusRegisterType::READ: - default: - return ModbusFunctionCode::CUSTOM; - break; - } + return modbus::helpers::modbus_register_write_function(reg_type); } -inline uint8_t c_to_hex(char c) { return (c >= 'A') ? (c >= 'a') ? (c - 'a' + 10) : (c - 'A' + 10) : (c - '0'); } +ESPDEPRECATED("Use modbus::helpers::c_to_hex() instead. Removed in 2026.10.0", "2026.4.0") +inline uint8_t c_to_hex(char c) { return modbus::helpers::c_to_hex(c); } -/** Get a byte from a hex string - * byte_from_hex_str("1122", 1) returns uint_8 value 0x22 == 34 - * byte_from_hex_str("1122", 0) returns 0x11 - * @param value string containing hex encoding - * @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in - * the hex string is byte_pos * 2 - * @return byte value - */ +ESPDEPRECATED("Use modbus::helpers::byte_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0") inline uint8_t byte_from_hex_str(const std::string &value, uint8_t pos) { - if (value.length() < pos * 2 + 2) - return 0; - return (c_to_hex(value[pos * 2]) << 4) | c_to_hex(value[pos * 2 + 1]); + return modbus::helpers::byte_from_hex_str(value, pos); } -/** Get a word from a hex string - * @param value string containing hex encoding - * @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in - * the hex string is byte_pos * 2 - * @return word value - */ +ESPDEPRECATED("Use modbus::helpers::word_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0") inline uint16_t word_from_hex_str(const std::string &value, uint8_t pos) { - return byte_from_hex_str(value, pos) << 8 | byte_from_hex_str(value, pos + 1); + return modbus::helpers::word_from_hex_str(value, pos); } -/** Get a dword from a hex string - * @param value string containing hex encoding - * @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in - * the hex string is byte_pos * 2 - * @return dword value - */ +ESPDEPRECATED("Use modbus::helpers::dword_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0") inline uint32_t dword_from_hex_str(const std::string &value, uint8_t pos) { - return word_from_hex_str(value, pos) << 16 | word_from_hex_str(value, pos + 2); + return modbus::helpers::dword_from_hex_str(value, pos); } -/** Get a qword from a hex string - * @param value string containing hex encoding - * @param position offset in bytes. Because each byte is encoded in 2 hex digits the position of the original byte in - * the hex string is byte_pos * 2 - * @return qword value - */ +ESPDEPRECATED("Use modbus::helpers::qword_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0") inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) { - return static_cast(dword_from_hex_str(value, pos)) << 32 | dword_from_hex_str(value, pos + 4); + return modbus::helpers::qword_from_hex_str(value, pos); } // Extract data from modbus response buffer @@ -585,7 +520,7 @@ inline float payload_to_float(const std::vector &data, const SensorItem int64_t number = payload_to_number(data, item.sensor_value_type, item.offset, item.bitmask); float float_value; - if (value_type_is_float(item.sensor_value_type)) { + if (modbus::helpers::value_type_is_float(item.sensor_value_type)) { float_value = bit_cast(static_cast(number)); } else { float_value = static_cast(number); @@ -597,7 +532,7 @@ inline float payload_to_float(const std::vector &data, const SensorItem inline std::vector float_to_payload(float value, SensorValueType value_type) { int64_t val; - if (value_type_is_float(value_type)) { + if (modbus::helpers::value_type_is_float(value_type)) { val = bit_cast(value); } else { val = llroundf(value); diff --git a/esphome/components/modbus_controller/number/__init__.py b/esphome/components/modbus_controller/number/__init__.py index b5efd7abf0..7563adfad9 100644 --- a/esphome/components/modbus_controller/number/__init__.py +++ b/esphome/components/modbus_controller/number/__init__.py @@ -1,5 +1,9 @@ import esphome.codegen as cg from esphome.components import number +from esphome.components.modbus.helpers import ( + MODBUS_WRITE_REGISTER_TYPE, + SENSOR_VALUE_TYPE, +) import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, @@ -11,8 +15,6 @@ from esphome.const import ( ) from .. import ( - MODBUS_WRITE_REGISTER_TYPE, - SENSOR_VALUE_TYPE, ModbusItemBaseSchema, SensorItem, add_modbus_base_properties, diff --git a/esphome/components/modbus_controller/output/__init__.py b/esphome/components/modbus_controller/output/__init__.py index 1800a90d57..1ec4afd997 100644 --- a/esphome/components/modbus_controller/output/__init__.py +++ b/esphome/components/modbus_controller/output/__init__.py @@ -1,10 +1,10 @@ import esphome.codegen as cg from esphome.components import output +from esphome.components.modbus.helpers import SENSOR_VALUE_TYPE import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID, CONF_MULTIPLY from .. import ( - SENSOR_VALUE_TYPE, ModbusItemBaseSchema, SensorItem, modbus_calc_properties, diff --git a/esphome/components/modbus_controller/select/__init__.py b/esphome/components/modbus_controller/select/__init__.py index c94532da51..334a4dfd76 100644 --- a/esphome/components/modbus_controller/select/__init__.py +++ b/esphome/components/modbus_controller/select/__init__.py @@ -1,15 +1,10 @@ import esphome.codegen as cg from esphome.components import select +from esphome.components.modbus.helpers import SENSOR_VALUE_TYPE, TYPE_REGISTER_MAP import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_OPTIMISTIC -from .. import ( - SENSOR_VALUE_TYPE, - TYPE_REGISTER_MAP, - ModbusController, - SensorItem, - modbus_controller_ns, -) +from .. import ModbusController, SensorItem, modbus_controller_ns from ..const import ( CONF_FORCE_NEW_RANGE, CONF_MODBUS_CONTROLLER_ID, diff --git a/esphome/components/modbus_controller/sensor/__init__.py b/esphome/components/modbus_controller/sensor/__init__.py index d8fce54ece..5b72586c66 100644 --- a/esphome/components/modbus_controller/sensor/__init__.py +++ b/esphome/components/modbus_controller/sensor/__init__.py @@ -1,11 +1,10 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.components.modbus.helpers import MODBUS_REGISTER_TYPE, SENSOR_VALUE_TYPE import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID from .. import ( - MODBUS_REGISTER_TYPE, - SENSOR_VALUE_TYPE, ModbusItemBaseSchema, SensorItem, add_modbus_base_properties, diff --git a/esphome/components/modbus_controller/switch/__init__.py b/esphome/components/modbus_controller/switch/__init__.py index e325e6198e..a40c15ab92 100644 --- a/esphome/components/modbus_controller/switch/__init__.py +++ b/esphome/components/modbus_controller/switch/__init__.py @@ -1,10 +1,10 @@ import esphome.codegen as cg from esphome.components import switch +from esphome.components.modbus.helpers import MODBUS_REGISTER_TYPE import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ASSUMED_STATE, CONF_ID from .. import ( - MODBUS_REGISTER_TYPE, ModbusItemBaseSchema, SensorItem, add_modbus_base_properties, diff --git a/esphome/components/modbus_controller/text_sensor/__init__.py b/esphome/components/modbus_controller/text_sensor/__init__.py index 35cae645e1..995357143e 100644 --- a/esphome/components/modbus_controller/text_sensor/__init__.py +++ b/esphome/components/modbus_controller/text_sensor/__init__.py @@ -1,10 +1,10 @@ import esphome.codegen as cg from esphome.components import text_sensor +from esphome.components.modbus.helpers import MODBUS_REGISTER_TYPE import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID from .. import ( - MODBUS_REGISTER_TYPE, ModbusItemBaseSchema, SensorItem, add_modbus_base_properties, From 1a86e88373996828d5927c08ad53318e6340cb04 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 30 Mar 2026 13:15:02 -0500 Subject: [PATCH 08/72] [thermostat] Fix stale `max_runtime_exceeded` causing spurious supplemental heating/cooling (#15274) --- .../components/thermostat/thermostat_climate.cpp | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index d52a22f880..eb3e756bc2 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -606,6 +606,16 @@ void ThermostatClimate::switch_to_action_(climate::ClimateAction action, bool pu } void ThermostatClimate::switch_to_supplemental_action_(climate::ClimateAction action) { + // Always cancel max-runtime timers and clear exceeded flags when transitioning to idle/off, + // even if supplemental_action_ is already idle (early-return path). This prevents a stale + // heating_max_runtime_exceeded_ flag from triggering supplemental on the next heating cycle + // when HEATING_MAX_RUN_TIME fires while the main action is already IDLE. + if (action == climate::CLIMATE_ACTION_OFF || action == climate::CLIMATE_ACTION_IDLE) { + this->cancel_timer_(thermostat::THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME); + this->cancel_timer_(thermostat::THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME); + this->cooling_max_runtime_exceeded_ = false; + this->heating_max_runtime_exceeded_ = false; + } // setup_complete_ helps us ensure an action is called immediately after boot if ((action == this->supplemental_action_) && this->setup_complete_) { // already in target mode @@ -975,8 +985,10 @@ void ThermostatClimate::cooling_on_timer_callback_() { void ThermostatClimate::fan_mode_timer_callback_() { ESP_LOGVV(TAG, "fan_mode timer expired"); this->switch_to_fan_mode_(this->fan_mode.value_or(climate::CLIMATE_FAN_ON)); - if (this->supports_fan_only_action_uses_fan_mode_timer_) + if (this->supports_fan_only_action_uses_fan_mode_timer_) { this->switch_to_action_(this->compute_action_()); + this->switch_to_supplemental_action_(this->compute_supplemental_action_()); + } } void ThermostatClimate::fanning_off_timer_callback_() { From ddb188e8f03d7c113c690154d9f386af188d0f4f Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 30 Mar 2026 13:15:13 -0500 Subject: [PATCH 09/72] [bme68x_bsec2] Fix warning spam, code clean-up (#15258) --- .../components/bme68x_bsec2/bme68x_bsec2.cpp | 67 +++++++++---------- .../components/bme68x_bsec2/bme68x_bsec2.h | 62 ++++++++--------- 2 files changed, 62 insertions(+), 67 deletions(-) diff --git a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp index cf516f6ca6..d9e00e65b2 100644 --- a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp +++ b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp @@ -6,10 +6,7 @@ #ifdef USE_BSEC2 #include "bme68x_bsec2.h" -#include - -namespace esphome { -namespace bme68x_bsec2 { +namespace esphome::bme68x_bsec2 { #define BME68X_BSEC2_ALGORITHM_OUTPUT_LOG(a) (a == ALGORITHM_OUTPUT_CLASSIFICATION ? "Classification" : "Regression") #define BME68X_BSEC2_OPERATING_AGE_LOG(o) (o == OPERATING_AGE_4D ? "4 days" : "28 days") @@ -18,9 +15,19 @@ namespace bme68x_bsec2 { static const char *const TAG = "bme68x_bsec2.sensor"; -static const std::string IAQ_ACCURACY_STATES[4] = {"Stabilizing", "Uncertain", "Calibrating", "Calibrated"}; +static constexpr const char *const IAQ_ACCURACY_STATES[4] = {"Stabilizing", "Uncertain", "Calibrating", "Calibrated"}; + +static bool is_no_new_data_warning(int8_t status) { +#ifdef BME68X_W_NO_NEW_DATA + return status == BME68X_W_NO_NEW_DATA; +#else + return status == 2; +#endif +} void BME68xBSEC2Component::setup() { + this->warn_if_blocking_over_ = 60; // initial reads may block for up to 60ms + this->bsec_status_ = bsec_init_m(&this->bsec_instance_); if (this->bsec_status_ != BSEC_OK) { this->mark_failed(); @@ -114,7 +121,8 @@ void BME68xBSEC2Component::loop() { } else { this->status_clear_error(); } - if (this->bsec_status_ > BSEC_OK || this->bme68x_status_ > BME68X_OK) { + const bool has_bme68x_warning = this->bme68x_status_ > BME68X_OK && !is_no_new_data_warning(this->bme68x_status_); + if (this->bsec_status_ > BSEC_OK || has_bme68x_warning) { this->status_set_warning(); } else { this->status_clear_warning(); @@ -130,7 +138,7 @@ void BME68xBSEC2Component::loop() { void BME68xBSEC2Component::set_config_(const uint8_t *config, uint32_t len) { if (len > BSEC_MAX_PROPERTY_BLOB_SIZE) { - ESP_LOGE(TAG, "Configuration is larger than BSEC_MAX_PROPERTY_BLOB_SIZE"); + ESP_LOGE(TAG, "Configuration blob too large"); this->mark_failed(); return; } @@ -212,14 +220,12 @@ void BME68xBSEC2Component::run_() { if (curr_time_ns < this->bsec_settings_.next_call) { return; } - uint8_t status; - ESP_LOGV(TAG, "Performing sensor run"); struct bme68x_conf bme68x_conf; this->bsec_status_ = bsec_sensor_control_m(&this->bsec_instance_, curr_time_ns, &this->bsec_settings_); if (this->bsec_status_ < BSEC_OK) { - ESP_LOGW(TAG, "Failed to fetch sensor control settings (BSEC2 error code %d)", this->bsec_status_); + ESP_LOGW(TAG, "Fetching control settings failed (BSEC2 error code %d)", this->bsec_status_); return; } @@ -235,9 +241,9 @@ void BME68xBSEC2Component::run_() { this->bme68x_heatr_conf_.heatr_temp = this->bsec_settings_.heater_temperature; this->bme68x_heatr_conf_.heatr_dur = this->bsec_settings_.heater_duration; - // status = bme68x_set_op_mode(this->bsec_settings_.op_mode, &this->bme68x_); - status = bme68x_set_heatr_conf(BME68X_FORCED_MODE, &this->bme68x_heatr_conf_, &this->bme68x_); - status = bme68x_set_op_mode(BME68X_FORCED_MODE, &this->bme68x_); + // this->bme68x_status_ = bme68x_set_op_mode(this->bsec_settings_.op_mode, &this->bme68x_); + this->bme68x_status_ = bme68x_set_heatr_conf(BME68X_FORCED_MODE, &this->bme68x_heatr_conf_, &this->bme68x_); + this->bme68x_status_ = bme68x_set_op_mode(BME68X_FORCED_MODE, &this->bme68x_); this->op_mode_ = BME68X_FORCED_MODE; ESP_LOGV(TAG, "Using forced mode"); @@ -259,9 +265,8 @@ void BME68xBSEC2Component::run_() { BSEC_TOTAL_HEAT_DUR - (bme68x_get_meas_dur(BME68X_PARALLEL_MODE, &bme68x_conf, &this->bme68x_) / INT64_C(1000)); - status = bme68x_set_heatr_conf(BME68X_PARALLEL_MODE, &this->bme68x_heatr_conf_, &this->bme68x_); - - status = bme68x_set_op_mode(BME68X_PARALLEL_MODE, &this->bme68x_); + this->bme68x_status_ = bme68x_set_heatr_conf(BME68X_PARALLEL_MODE, &this->bme68x_heatr_conf_, &this->bme68x_); + this->bme68x_status_ = bme68x_set_op_mode(BME68X_PARALLEL_MODE, &this->bme68x_); this->op_mode_ = BME68X_PARALLEL_MODE; ESP_LOGV(TAG, "Using parallel mode"); } @@ -282,24 +287,15 @@ void BME68xBSEC2Component::run_() { this->trigger_time_ns_ = curr_time_ns; this->set_timeout("read", meas_dur / 1000, [this]() { this->read_(this->trigger_time_ns_); }); } else { - ESP_LOGV(TAG, "Measurement not required"); - this->read_(curr_time_ns); + ESP_LOGV(TAG, "Measurement not required, queueing immediate read"); + this->trigger_time_ns_ = curr_time_ns; + this->set_timeout("read", 0, [this]() { this->read_(this->trigger_time_ns_); }); } } void BME68xBSEC2Component::read_(int64_t trigger_time_ns) { ESP_LOGV(TAG, "Reading data"); - if (this->bsec_settings_.trigger_measurement) { - uint8_t current_op_mode; - this->bme68x_status_ = bme68x_get_op_mode(¤t_op_mode, &this->bme68x_); - - if (current_op_mode == BME68X_SLEEP_MODE) { - ESP_LOGV(TAG, "Still in sleep mode, doing nothing"); - return; - } - } - if (!this->bsec_settings_.process_data) { ESP_LOGV(TAG, "Data processing not required"); return; @@ -309,12 +305,16 @@ void BME68xBSEC2Component::read_(int64_t trigger_time_ns) { uint8_t nFields = 0; this->bme68x_status_ = bme68x_get_data(this->op_mode_, &data[0], &nFields, &this->bme68x_); + if (is_no_new_data_warning(this->bme68x_status_)) { + ESP_LOGV(TAG, "BME68X did not provide new data"); + return; + } if (this->bme68x_status_ != BME68X_OK) { - ESP_LOGW(TAG, "Failed to get sensor data (BME68X error code %d)", this->bme68x_status_); + ESP_LOGW(TAG, "Fetching data failed (BME68X error code %d)", this->bme68x_status_); return; } if (nFields < 1) { - ESP_LOGD(TAG, "BME68X did not provide new data"); + ESP_LOGV(TAG, "BME68X did not provide new fields"); return; } @@ -373,7 +373,7 @@ void BME68xBSEC2Component::read_(int64_t trigger_time_ns) { uint8_t num_outputs = BSEC_NUMBER_OUTPUTS; this->bsec_status_ = bsec_do_steps_m(&this->bsec_instance_, inputs, num_inputs, outputs, &num_outputs); if (this->bsec_status_ != BSEC_OK) { - ESP_LOGW(TAG, "BSEC2 failed to process signals (BSEC2 error code %d)", this->bsec_status_); + ESP_LOGW(TAG, "Signal processing failed (BSEC2 error code %d)", this->bsec_status_); return; } if (num_outputs < 1) { @@ -474,7 +474,7 @@ void BME68xBSEC2Component::publish_sensor_(sensor::Sensor *sensor, float value, #endif #ifdef USE_TEXT_SENSOR -void BME68xBSEC2Component::publish_sensor_(text_sensor::TextSensor *sensor, const std::string &value) { +void BME68xBSEC2Component::publish_sensor_(text_sensor::TextSensor *sensor, const char *value) { if (!sensor || (sensor->has_state() && sensor->state == value)) { return; } @@ -526,6 +526,5 @@ void BME68xBSEC2Component::save_state_(uint8_t accuracy) { ESP_LOGI(TAG, "Saved state"); } -} // namespace bme68x_bsec2 -} // namespace esphome +} // namespace esphome::bme68x_bsec2 #endif diff --git a/esphome/components/bme68x_bsec2/bme68x_bsec2.h b/esphome/components/bme68x_bsec2/bme68x_bsec2.h index 1ed72eee03..9317229a1f 100644 --- a/esphome/components/bme68x_bsec2/bme68x_bsec2.h +++ b/esphome/components/bme68x_bsec2/bme68x_bsec2.h @@ -19,8 +19,7 @@ #include -namespace esphome { -namespace bme68x_bsec2 { +namespace esphome::bme68x_bsec2 { enum AlgorithmOutput { ALGORITHM_OUTPUT_IAQ, @@ -97,7 +96,7 @@ class BME68xBSEC2Component : public Component { void publish_sensor_(sensor::Sensor *sensor, float value, bool change_only = false); #endif #ifdef USE_TEXT_SENSOR - void publish_sensor_(text_sensor::TextSensor *sensor, const std::string &value); + void publish_sensor_(text_sensor::TextSensor *sensor, const char *value); #endif void load_state_(); @@ -108,39 +107,12 @@ class BME68xBSEC2Component : public Component { struct bme68x_dev bme68x_; bsec_bme_settings_t bsec_settings_; bsec_version_t version_; - uint8_t bsec_instance_[BSEC_INSTANCE_SIZE]; - struct bme68x_heatr_conf bme68x_heatr_conf_; - uint8_t op_mode_; // operating mode of sensor - bsec_library_return_t bsec_status_{BSEC_OK}; - int8_t bme68x_status_{BME68X_OK}; - - int64_t last_time_ms_{0}; - int64_t trigger_time_ns_{0}; // Stored for set_timeout lambda to help avoid heap allocation on supported 32-bit - // toolchains with small std::function SBO - uint32_t millis_overflow_counter_{0}; std::queue> queue_; + ESPPreferenceObject bsec_state_; uint8_t const *bsec2_configuration_{nullptr}; - uint32_t bsec2_configuration_length_{0}; - bool bsec2_blob_configured_{false}; - - ESPPreferenceObject bsec_state_; - uint32_t state_save_interval_ms_{21600000}; // 6 hours - 4 times a day - uint32_t last_state_save_ms_ = 0; - - float temperature_offset_{0}; - - AlgorithmOutput algorithm_output_{ALGORITHM_OUTPUT_IAQ}; - OperatingAge operating_age_{OPERATING_AGE_28D}; - Voltage voltage_{VOLTAGE_3_3V}; - - SampleRate sample_rate_{SAMPLE_RATE_LP}; // Core/gas sample rate - SampleRate temperature_sample_rate_{SAMPLE_RATE_DEFAULT}; - SampleRate pressure_sample_rate_{SAMPLE_RATE_DEFAULT}; - SampleRate humidity_sample_rate_{SAMPLE_RATE_DEFAULT}; - #ifdef USE_SENSOR sensor::Sensor *temperature_sensor_{nullptr}; sensor::Sensor *pressure_sensor_{nullptr}; @@ -155,8 +127,32 @@ class BME68xBSEC2Component : public Component { #ifdef USE_TEXT_SENSOR text_sensor::TextSensor *iaq_accuracy_text_sensor_{nullptr}; #endif + + int64_t last_time_ms_{0}; + int64_t trigger_time_ns_{0}; // Stored for set_timeout lambda to help avoid heap allocation on supported 32-bit + // toolchains with small std::function SBO + + uint32_t state_save_interval_ms_{21600000}; // 6 hours - 4 times a day + uint32_t last_state_save_ms_{0}; + uint32_t millis_overflow_counter_{0}; + uint32_t bsec2_configuration_length_{0}; + bsec_library_return_t bsec_status_{BSEC_OK}; + + float temperature_offset_{0}; + + AlgorithmOutput algorithm_output_{ALGORITHM_OUTPUT_IAQ}; + OperatingAge operating_age_{OPERATING_AGE_28D}; + Voltage voltage_{VOLTAGE_3_3V}; + SampleRate sample_rate_{SAMPLE_RATE_LP}; // Core/gas sample rate + SampleRate temperature_sample_rate_{SAMPLE_RATE_DEFAULT}; + SampleRate pressure_sample_rate_{SAMPLE_RATE_DEFAULT}; + SampleRate humidity_sample_rate_{SAMPLE_RATE_DEFAULT}; + + uint8_t bsec_instance_[BSEC_INSTANCE_SIZE]; + uint8_t op_mode_; // operating mode of sensor + int8_t bme68x_status_{BME68X_OK}; + bool bsec2_blob_configured_{false}; }; -} // namespace bme68x_bsec2 -} // namespace esphome +} // namespace esphome::bme68x_bsec2 #endif From 45e6d49d36ebbb1d2d3a19d4cd6704ac2112e01a Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 30 Mar 2026 13:15:27 -0500 Subject: [PATCH 10/72] [shtcx] Code clean-up (#15261) --- esphome/components/shtcx/shtcx.cpp | 18 +++++++----------- esphome/components/shtcx/shtcx.h | 16 +++++++++------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/esphome/components/shtcx/shtcx.cpp b/esphome/components/shtcx/shtcx.cpp index ec12a5babd..9ec0a2cdb7 100644 --- a/esphome/components/shtcx/shtcx.cpp +++ b/esphome/components/shtcx/shtcx.cpp @@ -2,16 +2,15 @@ #include "esphome/core/log.h" #include "esphome/core/hal.h" -namespace esphome { -namespace shtcx { +namespace esphome::shtcx { static const char *const TAG = "shtcx"; -static const uint16_t SHTCX_COMMAND_SLEEP = 0xB098; -static const uint16_t SHTCX_COMMAND_WAKEUP = 0x3517; -static const uint16_t SHTCX_COMMAND_READ_ID_REGISTER = 0xEFC8; -static const uint16_t SHTCX_COMMAND_SOFT_RESET = 0x805D; -static const uint16_t SHTCX_COMMAND_POLLING_H = 0x7866; +static constexpr uint16_t SHTCX_COMMAND_SLEEP = 0xB098; +static constexpr uint16_t SHTCX_COMMAND_WAKEUP = 0x3517; +static constexpr uint16_t SHTCX_COMMAND_READ_ID_REGISTER = 0xEFC8; +static constexpr uint16_t SHTCX_COMMAND_SOFT_RESET = 0x805D; +static constexpr uint16_t SHTCX_COMMAND_POLLING_H = 0x7866; static const LogString *shtcx_type_to_string(SHTCXType type) { switch (type) { @@ -91,8 +90,6 @@ void SHTCXComponent::update() { } else { temperature = 175.0f * float(raw_data[0]) / 65536.0f - 45.0f; humidity = 100.0f * float(raw_data[1]) / 65536.0f; - - ESP_LOGD(TAG, "Temperature=%.2f°C Humidity=%.2f%%", temperature, humidity); } if (this->temperature_sensor_ != nullptr) this->temperature_sensor_->publish_state(temperature); @@ -117,5 +114,4 @@ void SHTCXComponent::wake_up() { delayMicroseconds(200); } -} // namespace shtcx -} // namespace esphome +} // namespace esphome::shtcx diff --git a/esphome/components/shtcx/shtcx.h b/esphome/components/shtcx/shtcx.h index f9778dce8d..a86b204e2b 100644 --- a/esphome/components/shtcx/shtcx.h +++ b/esphome/components/shtcx/shtcx.h @@ -4,10 +4,13 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/sensirion_common/i2c_sensirion.h" -namespace esphome { -namespace shtcx { +namespace esphome::shtcx { -enum SHTCXType { SHTCX_TYPE_SHTC3 = 0, SHTCX_TYPE_SHTC1, SHTCX_TYPE_UNKNOWN }; +enum SHTCXType : uint8_t { + SHTCX_TYPE_SHTC3 = 0, + SHTCX_TYPE_SHTC1, + SHTCX_TYPE_UNKNOWN, +}; /// This class implements support for the SHT3x-DIS family of temperature+humidity i2c sensors. class SHTCXComponent : public PollingComponent, public sensirion_common::SensirionI2CDevice { @@ -23,11 +26,10 @@ class SHTCXComponent : public PollingComponent, public sensirion_common::Sensiri void wake_up(); protected: - SHTCXType type_; - uint16_t sensor_id_; sensor::Sensor *temperature_sensor_{nullptr}; sensor::Sensor *humidity_sensor_{nullptr}; + uint16_t sensor_id_; + SHTCXType type_; }; -} // namespace shtcx -} // namespace esphome +} // namespace esphome::shtcx From b579758c469eebbcb23fecefa3043530f493e913 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 30 Mar 2026 13:15:37 -0500 Subject: [PATCH 11/72] [dht] Code clean-up (#15271) --- esphome/components/dht/dht.cpp | 28 +++++++++++----------------- esphome/components/dht/dht.h | 13 +++++-------- 2 files changed, 16 insertions(+), 25 deletions(-) diff --git a/esphome/components/dht/dht.cpp b/esphome/components/dht/dht.cpp index fef247f168..5b7b6a268f 100644 --- a/esphome/components/dht/dht.cpp +++ b/esphome/components/dht/dht.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace dht { +namespace esphome::dht { static const char *const TAG = "dht"; @@ -45,16 +44,13 @@ void DHT::update() { } if (success) { - ESP_LOGD(TAG, "Temperature %.1f°C Humidity %.1f%%", temperature, humidity); - if (this->temperature_sensor_ != nullptr) this->temperature_sensor_->publish_state(temperature); if (this->humidity_sensor_ != nullptr) this->humidity_sensor_->publish_state(humidity); this->status_clear_warning(); } else { - ESP_LOGW(TAG, "Invalid readings! Check pin number and pull-up resistor%s.", - this->is_auto_detect_ ? " and try manually specifying the model" : ""); + ESP_LOGW(TAG, "Invalid readings"); if (this->temperature_sensor_ != nullptr) this->temperature_sensor_->publish_state(NAN); if (this->humidity_sensor_ != nullptr) @@ -73,8 +69,7 @@ bool HOT IRAM_ATTR DHT::read_sensor_(float *temperature, float *humidity, bool r *temperature = NAN; int error_code = 0; - int8_t i = 0; - uint8_t data[5] = {0, 0, 0, 0, 0}; + uint8_t data[5] = {}; #ifndef USE_ESP32 this->pin_.pin_mode(gpio::FLAG_OUTPUT); @@ -107,7 +102,9 @@ bool HOT IRAM_ATTR DHT::read_sensor_(float *temperature, float *humidity, bool r uint8_t bit = 7; uint8_t byte = 0; - for (i = -1; i < 40; i++) { + // On 32-bit Xtensa/RISC-V cores, int8_t would require masking/sign-extension for comparisons + // vs. native int. Using int i is native word size — small win in the timing-critical section. + for (int i = -1; i < 40; i++) { uint32_t start_time = micros(); // Wait for rising edge @@ -156,11 +153,9 @@ bool HOT IRAM_ATTR DHT::read_sensor_(float *temperature, float *humidity, bool r } } } - if (!report_errors && error_code != 0) - return false; - - if (error_code) { - ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); + if (error_code != 0) { + if (report_errors) + ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); return false; } @@ -177,7 +172,7 @@ bool HOT IRAM_ATTR DHT::read_sensor_(float *temperature, float *humidity, bool r if (checksum_a != data[4] && checksum_b != data[4]) { if (report_errors) { - ESP_LOGW(TAG, "Checksum invalid: %u!=%u", checksum_a, data[4]); + ESP_LOGW(TAG, "Invalid checksum"); } return false; } @@ -234,5 +229,4 @@ bool HOT IRAM_ATTR DHT::read_sensor_(float *temperature, float *humidity, bool r return true; } -} // namespace dht -} // namespace esphome +} // namespace esphome::dht diff --git a/esphome/components/dht/dht.h b/esphome/components/dht/dht.h index 4671ee6f27..0c535f7cf6 100644 --- a/esphome/components/dht/dht.h +++ b/esphome/components/dht/dht.h @@ -4,10 +4,9 @@ #include "esphome/core/hal.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace dht { +namespace esphome::dht { -enum DHTModel { +enum DHTModel : uint8_t { DHT_MODEL_AUTO_DETECT = 0, DHT_MODEL_DHT11, DHT_MODEL_DHT22, @@ -42,7 +41,6 @@ class DHT : public PollingComponent { this->t_pin_ = pin; this->pin_ = pin->to_isr(); } - void set_model(DHTModel model) { model_ = model; } void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } void set_humidity_sensor(sensor::Sensor *humidity_sensor) { humidity_sensor_ = humidity_sensor; } @@ -55,13 +53,12 @@ class DHT : public PollingComponent { protected: bool read_sensor_(float *temperature, float *humidity, bool report_errors); + sensor::Sensor *temperature_sensor_{nullptr}; + sensor::Sensor *humidity_sensor_{nullptr}; InternalGPIOPin *t_pin_; ISRInternalGPIOPin pin_; DHTModel model_{DHT_MODEL_AUTO_DETECT}; bool is_auto_detect_{false}; - sensor::Sensor *temperature_sensor_{nullptr}; - sensor::Sensor *humidity_sensor_{nullptr}; }; -} // namespace dht -} // namespace esphome +} // namespace esphome::dht From ad3f6ae3139b1ad8cb60bc27bb9c4ddc45336f2b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Mar 2026 08:20:52 -1000 Subject: [PATCH 12/72] [automation] Remove actions_end_ pointer from ActionList to save RAM (#15283) --- esphome/core/automation.h | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/esphome/core/automation.h b/esphome/core/automation.h index fc2cad99be..05c7f19588 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -419,44 +419,48 @@ template class Action { template class ActionList { public: void add_action(Action *action) { - if (this->actions_end_ == nullptr) { - this->actions_begin_ = action; - } else { - this->actions_end_->next_ = action; - } - this->actions_end_ = action; + // Walk to end of chain - action lists are short and only built during setup() + Action **tail = &this->actions_; + while (*tail != nullptr) + tail = &(*tail)->next_; + *tail = action; } void add_actions(const std::initializer_list *> &actions) { + // Find tail once, then append all actions in a single pass + Action **tail = &this->actions_; + while (*tail != nullptr) + tail = &(*tail)->next_; for (auto *action : actions) { - this->add_action(action); + *tail = action; + tail = &action->next_; } } // Force-inline: part of the Trigger→Automation→ActionList forwarding // chain collapsed to reduce automation call stack depth. inline void play(const Ts &...x) ESPHOME_ALWAYS_INLINE { - if (this->actions_begin_ != nullptr) - this->actions_begin_->play_complex(x...); + if (this->actions_ != nullptr) + this->actions_->play_complex(x...); } void play_tuple(const std::tuple &tuple) { this->play_tuple_(tuple, std::make_index_sequence{}); } void stop() { - if (this->actions_begin_ != nullptr) - this->actions_begin_->stop_complex(); + if (this->actions_ != nullptr) + this->actions_->stop_complex(); } - bool empty() const { return this->actions_begin_ == nullptr; } + bool empty() const { return this->actions_ == nullptr; } /// Check if any action in this action list is currently running. bool is_running() { - if (this->actions_begin_ == nullptr) + if (this->actions_ == nullptr) return false; - return this->actions_begin_->is_running(); + return this->actions_->is_running(); } /// Return the number of actions in this action list that are currently running. int num_running() { - if (this->actions_begin_ == nullptr) + if (this->actions_ == nullptr) return 0; - return this->actions_begin_->num_running_total(); + return this->actions_->num_running_total(); } protected: @@ -464,8 +468,7 @@ template class ActionList { this->play(std::get(tuple)...); } - Action *actions_begin_{nullptr}; - Action *actions_end_{nullptr}; + Action *actions_{nullptr}; }; template class Automation { From ffee4c22b3416d774f7cf398ffbe1b03523b168e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Mar 2026 08:21:58 -1000 Subject: [PATCH 13/72] [esp32_ble] Devirtualize BLE event handler dispatch (#15310) --- esphome/components/esp32_ble/__init__.py | 66 +++++++++++++++--- esphome/components/esp32_ble/ble.cpp | 21 ++---- esphome/components/esp32_ble/ble.h | 69 +++++++------------ .../components/esp32_ble_beacon/__init__.py | 1 - .../esp32_ble_beacon/esp32_ble_beacon.h | 4 +- .../components/esp32_ble_server/__init__.py | 1 - .../components/esp32_ble_server/ble_server.h | 7 +- .../components/esp32_ble_tracker/__init__.py | 2 - .../esp32_ble_tracker/esp32_ble_tracker.h | 13 ++-- esphome/core/helpers.h | 32 +++++++++ 10 files changed, 128 insertions(+), 88 deletions(-) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 43208eb87e..2e5e358753 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -134,10 +134,38 @@ class HandlerCounts: _handler_counts = HandlerCounts() +def _add_callback( + parent_var: cg.MockObj, + method: str, + handler_var: cg.MockObj, + params: str, + call_args: str, +) -> None: + """Generate a lambda callback that forwards to a handler method. + + Uses a braced scope with a local pointer variable so the generated C++ + lambda captures only that pointer, avoiding GCC warnings about capturing + variables with static storage duration. + """ + cg.add( + cg.RawStatement( + f"{{ auto *h = {handler_var}; " + f"{parent_var}->{method}(" + f"[h]({params}) {{ h->{call_args}; }}); }}" + ) + ) + + def register_gap_event_handler(parent_var: cg.MockObj, handler_var: cg.MockObj) -> None: """Register a GAP event handler and track the count.""" _handler_counts.gap_event += 1 - cg.add(parent_var.register_gap_event_handler(handler_var)) + _add_callback( + parent_var, + "add_gap_event_callback", + handler_var, + "esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param", + "gap_event_handler(event, param)", + ) def register_gap_scan_event_handler( @@ -145,7 +173,13 @@ def register_gap_scan_event_handler( ) -> None: """Register a GAP scan event handler and track the count.""" _handler_counts.gap_scan_event += 1 - cg.add(parent_var.register_gap_scan_event_handler(handler_var)) + _add_callback( + parent_var, + "add_gap_scan_event_callback", + handler_var, + "const esphome::esp32_ble::BLEScanResult &scan_result", + "gap_scan_event_handler(scan_result)", + ) def register_gattc_event_handler( @@ -153,7 +187,13 @@ def register_gattc_event_handler( ) -> None: """Register a GATTc event handler and track the count.""" _handler_counts.gattc_event += 1 - cg.add(parent_var.register_gattc_event_handler(handler_var)) + _add_callback( + parent_var, + "add_gattc_event_callback", + handler_var, + "esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param", + "gattc_event_handler(event, gattc_if, param)", + ) def register_gatts_event_handler( @@ -161,7 +201,13 @@ def register_gatts_event_handler( ) -> None: """Register a GATTs event handler and track the count.""" _handler_counts.gatts_event += 1 - cg.add(parent_var.register_gatts_event_handler(handler_var)) + _add_callback( + parent_var, + "add_gatts_event_callback", + handler_var, + "esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, esp_ble_gatts_cb_param_t *param", + "gatts_event_handler(event, gatts_if, param)", + ) def register_ble_status_event_handler( @@ -169,7 +215,13 @@ def register_ble_status_event_handler( ) -> None: """Register a BLE status event handler and track the count.""" _handler_counts.ble_status_event += 1 - cg.add(parent_var.register_ble_status_event_handler(handler_var)) + _add_callback( + parent_var, + "add_ble_status_event_callback", + handler_var, + "", + "ble_before_disabled_event_handler()", + ) def register_bt_logger(*loggers: BTLoggers) -> None: @@ -225,10 +277,6 @@ NO_BLUETOOTH_VARIANTS = [const.VARIANT_ESP32S2] esp32_ble_ns = cg.esphome_ns.namespace("esp32_ble") ESP32BLE = esp32_ble_ns.class_("ESP32BLE", cg.Component) -GAPEventHandler = esp32_ble_ns.class_("GAPEventHandler") -GATTcEventHandler = esp32_ble_ns.class_("GATTcEventHandler") -GATTsEventHandler = esp32_ble_ns.class_("GATTsEventHandler") - BLEEnabledCondition = esp32_ble_ns.class_("BLEEnabledCondition", automation.Condition) BLEEnableAction = esp32_ble_ns.class_("BLEEnableAction", automation.Action) BLEDisableAction = esp32_ble_ns.class_("BLEDisableAction", automation.Action) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 317f8fd11b..2cd2ec67f7 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -408,9 +408,7 @@ void ESP32BLE::loop() { esp_gatt_if_t gatts_if = ble_event->event_.gatts.gatts_if; esp_ble_gatts_cb_param_t *param = &ble_event->event_.gatts.gatts_param; ESP_LOGV(TAG, "gatts_event [esp_gatt_if: %d] - %d", gatts_if, event); - for (auto *gatts_handler : this->gatts_event_handlers_) { - gatts_handler->gatts_event_handler(event, gatts_if, param); - } + this->gatts_event_callbacks_.call(event, gatts_if, param); break; } #endif @@ -420,9 +418,7 @@ void ESP32BLE::loop() { esp_gatt_if_t gattc_if = ble_event->event_.gattc.gattc_if; esp_ble_gattc_cb_param_t *param = &ble_event->event_.gattc.gattc_param; ESP_LOGV(TAG, "gattc_event [esp_gatt_if: %d] - %d", gattc_if, event); - for (auto *gattc_handler : this->gattc_event_handlers_) { - gattc_handler->gattc_event_handler(event, gattc_if, param); - } + this->gattc_event_callbacks_.call(event, gattc_if, param); break; } #endif @@ -431,10 +427,7 @@ void ESP32BLE::loop() { switch (gap_event) { case ESP_GAP_BLE_SCAN_RESULT_EVT: #ifdef ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT - // Use the new scan event handler - no memcpy! - for (auto *scan_handler : this->gap_scan_event_handlers_) { - scan_handler->gap_scan_event_handler(ble_event->scan_result()); - } + this->gap_scan_event_callbacks_.call(ble_event->scan_result()); #endif break; @@ -478,9 +471,7 @@ void ESP32BLE::loop() { } // clang-format on // Dispatch to all registered handlers - for (auto *gap_handler : this->gap_event_handlers_) { - gap_handler->gap_event_handler(gap_event, param); - } + this->gap_event_callbacks_.call(gap_event, param); } #endif break; @@ -518,9 +509,7 @@ void ESP32BLE::loop_handle_state_transition_not_active_() { ESP_LOGD(TAG, "Disabling"); #ifdef ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT - for (auto *ble_event_handler : this->ble_status_event_handlers_) { - ble_event_handler->ble_before_disabled_event_handler(); - } + this->ble_status_event_callbacks_.call(); #endif if (!ble_dismantle_()) { diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 82b2789461..de8c8c2343 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -87,37 +87,6 @@ enum BLEComponentState : uint8_t { BLE_COMPONENT_STATE_ACTIVE, }; -class GAPEventHandler { - public: - virtual void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) = 0; -}; - -class GAPScanEventHandler { - public: - virtual void gap_scan_event_handler(const BLEScanResult &scan_result) = 0; -}; - -#ifdef USE_ESP32_BLE_CLIENT -class GATTcEventHandler { - public: - virtual void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, - esp_ble_gattc_cb_param_t *param) = 0; -}; -#endif - -#ifdef USE_ESP32_BLE_SERVER -class GATTsEventHandler { - public: - virtual void gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, - esp_ble_gatts_cb_param_t *param) = 0; -}; -#endif - -class BLEStatusEventHandler { - public: - virtual void ble_before_disabled_event_handler() = 0; -}; - class ESP32BLE : public Component { public: void set_io_capability(IoCapability io_capability) { this->io_cap_ = (esp_ble_io_cap_t) io_capability; } @@ -154,22 +123,28 @@ class ESP32BLE : public Component { #endif #ifdef ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT - void register_gap_event_handler(GAPEventHandler *handler) { this->gap_event_handlers_.push_back(handler); } + template void add_gap_event_callback(F &&callback) { + this->gap_event_callbacks_.add(std::forward(callback)); + } #endif #ifdef ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT - void register_gap_scan_event_handler(GAPScanEventHandler *handler) { - this->gap_scan_event_handlers_.push_back(handler); + template void add_gap_scan_event_callback(F &&callback) { + this->gap_scan_event_callbacks_.add(std::forward(callback)); } #endif #if defined(USE_ESP32_BLE_CLIENT) && defined(ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT) - void register_gattc_event_handler(GATTcEventHandler *handler) { this->gattc_event_handlers_.push_back(handler); } + template void add_gattc_event_callback(F &&callback) { + this->gattc_event_callbacks_.add(std::forward(callback)); + } #endif #if defined(USE_ESP32_BLE_SERVER) && defined(ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT) - void register_gatts_event_handler(GATTsEventHandler *handler) { this->gatts_event_handlers_.push_back(handler); } + template void add_gatts_event_callback(F &&callback) { + this->gatts_event_callbacks_.add(std::forward(callback)); + } #endif #ifdef ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT - void register_ble_status_event_handler(BLEStatusEventHandler *handler) { - this->ble_status_event_handlers_.push_back(handler); + template void add_ble_status_event_callback(F &&callback) { + this->ble_status_event_callbacks_.add(std::forward(callback)); } #endif void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } @@ -202,21 +177,27 @@ class ESP32BLE : public Component { private: template friend void enqueue_ble_event(Args... args); - // Handler vectors - use StaticVector when counts are known at compile time #ifdef ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT - StaticVector gap_event_handlers_; + StaticCallbackManager + gap_event_callbacks_; #endif #ifdef ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT - StaticVector gap_scan_event_handlers_; + StaticCallbackManager + gap_scan_event_callbacks_; #endif #if defined(USE_ESP32_BLE_CLIENT) && defined(ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT) - StaticVector gattc_event_handlers_; + StaticCallbackManager + gattc_event_callbacks_; #endif #if defined(USE_ESP32_BLE_SERVER) && defined(ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT) - StaticVector gatts_event_handlers_; + StaticCallbackManager + gatts_event_callbacks_; #endif #ifdef ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT - StaticVector ble_status_event_handlers_; + StaticCallbackManager ble_status_event_callbacks_; #endif // Large objects (size depends on template parameters, but typically aligned to 4 bytes) diff --git a/esphome/components/esp32_ble_beacon/__init__.py b/esphome/components/esp32_ble_beacon/__init__.py index 04c783980d..e2e790164e 100644 --- a/esphome/components/esp32_ble_beacon/__init__.py +++ b/esphome/components/esp32_ble_beacon/__init__.py @@ -13,7 +13,6 @@ esp32_ble_beacon_ns = cg.esphome_ns.namespace("esp32_ble_beacon") ESP32BLEBeacon = esp32_ble_beacon_ns.class_( "ESP32BLEBeacon", cg.Component, - esp32_ble.GAPEventHandler, cg.Parented.template(esp32_ble.ESP32BLE), ) CONF_MAJOR = "major" diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h index 7a0424f3aa..e16c413179 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.h @@ -35,7 +35,7 @@ using esp_ble_ibeacon_t = struct { using namespace esp32_ble; -class ESP32BLEBeacon : public Component, public GAPEventHandler, public Parented { +class ESP32BLEBeacon : public Component, public Parented { public: explicit ESP32BLEBeacon(const std::array &uuid) : uuid_(uuid) {} @@ -51,7 +51,7 @@ class ESP32BLEBeacon : public Component, public GAPEventHandler, public Parented #ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID void set_tx_power(esp_power_level_t val) { this->tx_power_ = val; } #endif - void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; + void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param); protected: void on_advertise_(); diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 827ddba955..57106cd93b 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -72,7 +72,6 @@ BLECharacteristic_ns = esp32_ble_server_ns.namespace("BLECharacteristic") BLEServer = esp32_ble_server_ns.class_( "BLEServer", cg.Component, - esp32_ble.GATTsEventHandler, cg.Parented.template(esp32_ble.ESP32BLE), ) esp32_ble_server_automations_ns = esp32_ble_server_ns.namespace( diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index 1b419d2ee4..9708ed40c8 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -24,7 +24,7 @@ namespace esp32_ble_server { using namespace esp32_ble; using namespace bytebuffer; -class BLEServer : public Component, public GATTsEventHandler, public BLEStatusEventHandler, public Parented { +class BLEServer : public Component, public Parented { public: void setup() override; void loop() override; @@ -53,10 +53,9 @@ class BLEServer : public Component, public GATTsEventHandler, public BLEStatusEv const uint16_t *get_clients() const { return this->clients_; } uint8_t get_client_count() const { return this->client_count_; } - void gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, - esp_ble_gatts_cb_param_t *param) override; + void gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, esp_ble_gatts_cb_param_t *param); - void ble_before_disabled_event_handler() override; + void ble_before_disabled_event_handler(); // Direct callback registration - supports multiple callbacks void on_connect(std::function &&callback) { diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index c5e8f3178d..b9c4c28ccf 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -90,8 +90,6 @@ esp32_ble_tracker_ns = cg.esphome_ns.namespace("esp32_ble_tracker") ESP32BLETracker = esp32_ble_tracker_ns.class_( "ESP32BLETracker", cg.Component, - esp32_ble.GAPEventHandler, - esp32_ble.GATTcEventHandler, cg.Parented.template(esp32_ble.ESP32BLE), ) ESPBTClient = esp32_ble_tracker_ns.class_("ESPBTClient") diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index f50ed107b6..ff69a4dcd2 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -291,10 +291,6 @@ class ESPBTClient : public ESPBTDeviceListener { }; class ESP32BLETracker : public Component, - public GAPEventHandler, - public GAPScanEventHandler, - public GATTcEventHandler, - public BLEStatusEventHandler, #ifdef USE_OTA_STATE_LISTENER public ota::OTAGlobalStateListener, #endif @@ -325,11 +321,10 @@ class ESP32BLETracker : public Component, void start_scan(); void stop_scan(); - void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, - esp_ble_gattc_cb_param_t *param) override; - void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; - void gap_scan_event_handler(const BLEScanResult &scan_result) override; - void ble_before_disabled_event_handler() override; + void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param); + void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param); + void gap_scan_event_handler(const BLEScanResult &scan_result); + void ble_before_disabled_event_handler(); #ifdef USE_OTA_STATE_LISTENER void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override; diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 66ba166445..f96b888e28 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1830,6 +1830,38 @@ template class CallbackManager { std::vector> callbacks_; }; +/** CallbackManager backed by StaticVector for compile-time-known callback counts. + * + * Drop-in replacement for CallbackManager that avoids std::vector template bloat + * (_M_realloc_insert, etc.) when the maximum number of callbacks is known at compile time. + * + * @tparam N Maximum number of callbacks (compile-time constant, typically from cg.add_define()) + * @tparam Ts The arguments for the callbacks, wrapped in void(). + */ +template class StaticCallbackManager; + +template class StaticCallbackManager { + public: + /// Add any callable. Small trivially-copyable callables (like [this] lambdas) + /// are stored inline without heap allocation. + template void add(F &&callback) { this->add_(Callback::create(std::forward(callback))); } + + /// Call all callbacks in this manager. + void call(Ts... args) { + for (auto &cb : this->callbacks_) + cb.call(args...); + } + size_t size() const { return this->callbacks_.size(); } + + /// Call all callbacks in this manager. + void operator()(Ts... args) { call(args...); } + + protected: + /// Non-template core to avoid code duplication per lambda type. + void add_(Callback cb) { this->callbacks_.push_back(cb); } + StaticVector, N> callbacks_; +}; + template class LazyCallbackManager; /** Lazy-allocating callback manager that only allocates memory when callbacks are registered. From 8969eb76e9bdd12734f96af0200c89a18b70bc75 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Mar 2026 08:24:17 -1000 Subject: [PATCH 14/72] [wifi] Avoid redundant SDK calls in WiFi loop on ESP8266 (#15303) --- esphome/components/wifi/wifi_component.cpp | 8 +--- esphome/components/wifi/wifi_component.h | 13 +++++- .../wifi/wifi_component_esp8266.cpp | 44 ++++++++++--------- 3 files changed, 36 insertions(+), 29 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index db20332667..7b31a22ed5 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -784,7 +784,8 @@ void WiFiComponent::loop() { } case WIFI_COMPONENT_STATE_STA_CONNECTED: { - if (!this->is_connected_()) { + // Use cached connected_ set unconditionally at the top of loop() + if (!this->connected_) { ESP_LOGW(TAG, "Connection lost; reconnecting"); this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTING; this->retry_connect(); @@ -2129,11 +2130,6 @@ void WiFiComponent::retry_connect() { } void WiFiComponent::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } -bool WiFiComponent::is_connected_() const { - return this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED && - this->wifi_sta_connect_status_() == WiFiSTAConnectStatus::CONNECTED && !this->error_from_callback_; -} -void WiFiComponent::update_connected_state_() { this->connected_ = this->is_connected_(); } void WiFiComponent::set_power_save_mode(WiFiPowerSaveMode power_save) { this->power_save_ = power_save; #if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 8dfe5fa7af..073341fe79 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -670,8 +670,11 @@ class WiFiComponent final : public Component { bool wifi_sta_connect_(const WiFiAP &ap); void wifi_pre_setup_(); WiFiSTAConnectStatus wifi_sta_connect_status_() const; - bool is_connected_() const; - void update_connected_state_(); + bool is_connected_() const { + return this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED && + this->wifi_sta_connect_status_() == WiFiSTAConnectStatus::CONNECTED && !this->error_from_callback_; + } + void update_connected_state_() { this->connected_ = this->is_connected_(); } bool wifi_scan_start_(bool passive); #ifdef USE_WIFI_AP @@ -811,6 +814,12 @@ class WiFiComponent final : public Component { uint8_t num_ipv6_addresses_{0}; #endif /* USE_NETWORK_IPV6 */ bool error_from_callback_{false}; +#ifdef USE_ESP8266 + // ESP8266WiFiSTAState enum, defined in wifi_component_esp8266.cpp. + // Written from SDK system context (wifi_event_callback) — uint8_t writes + // are atomic on Xtensa LX106 so no synchronization is needed. + uint8_t sta_state_{0}; +#endif RetryHiddenMode retry_hidden_mode_{RetryHiddenMode::BLIND_RETRY}; RoamingState roaming_state_{RoamingState::IDLE}; bssid_t roaming_target_bssid_{}; // BSSID of the AP we're trying to roam to diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 03800cc3a9..cb53d3ac1b 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -44,11 +44,14 @@ namespace esphome::wifi { static const char *const TAG = "wifi_esp8266"; -static bool s_sta_connected = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static bool s_sta_got_ip = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static bool s_sta_connect_not_found = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static bool s_sta_connect_error = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static bool s_sta_connecting = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +enum class ESP8266WiFiSTAState : uint8_t { + IDLE, // Not connecting + CONNECTING, // Connection in progress + ASSOCIATED, // Associated to AP, waiting for IP + CONNECTED, // Successfully connected with IP + ERROR_NOT_FOUND, // AP not found (probe failed) + ERROR_FAILED, // Connection failed (auth, timeout, etc.) +}; bool WiFiComponent::wifi_mode_(optional sta, optional ap) { uint8_t current_mode = wifi_get_opmode(); @@ -359,11 +362,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { // Reset flags, do this _before_ wifi_station_connect as the callback method // may be called from wifi_station_connect - s_sta_connecting = true; - s_sta_connected = false; - s_sta_got_ip = false; - s_sta_connect_error = false; - s_sta_connect_not_found = false; + this->sta_state_ = static_cast(ESP8266WiFiSTAState::CONNECTING); ETS_UART_INTR_DISABLE(); ret = wifi_station_connect(); @@ -493,7 +492,7 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { ESP_LOGV(TAG, "Connected ssid='%.*s' bssid=%s channel=%u", it.ssid_len, (const char *) it.ssid, bssid_buf, it.channel); #endif - s_sta_connected = true; + global_wifi_component->sta_state_ = static_cast(ESP8266WiFiSTAState::ASSOCIATED); #ifdef USE_WIFI_CONNECT_STATE_LISTENERS // Defer listener notification until state machine reaches STA_CONNECTED // This ensures wifi.connected condition returns true in listener automations @@ -506,16 +505,14 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { if (it.reason == REASON_NO_AP_FOUND) { ESP_LOGW(TAG, "Disconnected ssid='%.*s' reason='Probe Request Unsuccessful'", it.ssid_len, (const char *) it.ssid); - s_sta_connect_not_found = true; + global_wifi_component->sta_state_ = static_cast(ESP8266WiFiSTAState::ERROR_NOT_FOUND); } else { char bssid_s[18]; format_mac_addr_upper(it.bssid, bssid_s); ESP_LOGW(TAG, "Disconnected ssid='%.*s' bssid=" LOG_SECRET("%s") " reason='%s'", it.ssid_len, (const char *) it.ssid, bssid_s, LOG_STR_ARG(get_disconnect_reason_str(it.reason))); - s_sta_connect_error = true; + global_wifi_component->sta_state_ = static_cast(ESP8266WiFiSTAState::ERROR_FAILED); } - s_sta_connected = false; - s_sta_connecting = false; global_wifi_component->error_from_callback_ = true; #ifdef USE_WIFI_CONNECT_STATE_LISTENERS global_wifi_component->pending_.disconnect = true; @@ -541,7 +538,7 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { mask_buf[network::IP_ADDRESS_BUFFER_SIZE]; ESP_LOGV(TAG, "static_ip=%s gateway=%s netmask=%s", network::IPAddress(&it.ip).str_to(ip_buf), network::IPAddress(&it.gw).str_to(gw_buf), network::IPAddress(&it.mask).str_to(mask_buf)); - s_sta_got_ip = true; + global_wifi_component->sta_state_ = static_cast(ESP8266WiFiSTAState::CONNECTED); #ifdef USE_WIFI_IP_STATE_LISTENERS // Defer listener callbacks to main loop - system context has limited stack global_wifi_component->pending_.got_ip = true; @@ -636,17 +633,22 @@ void WiFiComponent::wifi_pre_setup_() { } WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() const { - station_status_t status = wifi_station_get_connect_status(); - if (status == STATION_GOT_IP) + // Use cached state from wifi_event_callback() instead of calling + // wifi_station_get_connect_status() which queries the SDK every time. + // Use if statements with early returns instead of switch to avoid GCC + // generating a CSWTCH lookup table in .rodata (flash) on ESP8266. + auto state = static_cast(this->sta_state_); + if (state == ESP8266WiFiSTAState::CONNECTED) return WiFiSTAConnectStatus::CONNECTED; - if (status == STATION_NO_AP_FOUND) + if (state == ESP8266WiFiSTAState::ERROR_NOT_FOUND) return WiFiSTAConnectStatus::ERROR_NETWORK_NOT_FOUND; - if (status == STATION_CONNECT_FAIL || status == STATION_WRONG_PASSWORD) + if (state == ESP8266WiFiSTAState::ERROR_FAILED) return WiFiSTAConnectStatus::ERROR_CONNECT_FAILED; - if (status == STATION_CONNECTING) + if (state == ESP8266WiFiSTAState::CONNECTING || state == ESP8266WiFiSTAState::ASSOCIATED) return WiFiSTAConnectStatus::CONNECTING; return WiFiSTAConnectStatus::IDLE; } + bool WiFiComponent::wifi_scan_start_(bool passive) { // enable STA if (!this->wifi_mode_(true, {})) From 46ea61666e97061d32a115b29a38328553067e33 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Mar 2026 08:24:34 -1000 Subject: [PATCH 15/72] [wifi] Replace FreeRTOS queue with LockFreeQueue on ESP-IDF (#15306) --- esphome/components/wifi/wifi_component.h | 11 ++++++++ .../wifi/wifi_component_esp_idf.cpp | 26 ++++++------------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 073341fe79..9a08902d47 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -6,6 +6,9 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" +#ifdef USE_ESP32 +#include "esphome/core/lock_free_queue.h" +#endif #include "esphome/core/string_ref.h" #include @@ -727,6 +730,7 @@ class WiFiComponent final : public Component { #ifdef USE_ESP32 void wifi_process_event_(IDFWiFiEvent *data); + friend void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data); #endif #ifdef USE_RP2040 @@ -871,6 +875,13 @@ class WiFiComponent final : public Component { bool is_high_performance_mode_{false}; #endif +#ifdef USE_ESP32 + // Lock-free SPSC queue for WiFi events from ESP-IDF event handler. + // 17 slots = 16 usable (ring buffer reserves one slot). WiFi events are rare. + // Placed at end of class to avoid padding between smaller fields. + LockFreeQueue event_queue_; +#endif + private: // Stores a pointer to a string literal (static storage duration). // ONLY set from Python-generated code with string literals - never dynamic strings. diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index d8b3db9667..4097df80af 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -47,7 +47,6 @@ namespace esphome::wifi { static const char *const TAG = "wifi_esp32"; static EventGroupHandle_t s_wifi_event_group; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static QueueHandle_t s_event_queue; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static esp_netif_t *s_sta_netif = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) #ifdef USE_WIFI_AP static esp_netif_t *s_ap_netif = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -132,11 +131,10 @@ void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, voi return; } - // copy to heap to keep queue object small + // copy to heap — WiFi events are rare so heap alloc is fine auto *to_send = new IDFWiFiEvent; // NOLINT(cppcoreguidelines-owning-memory) memcpy(to_send, &event, sizeof(IDFWiFiEvent)); - // don't block, we may miss events but the core can handle that - if (xQueueSend(s_event_queue, &to_send, 0L) != pdPASS) { + if (!global_wifi_component->event_queue_.push(to_send)) { delete to_send; // NOLINT(cppcoreguidelines-owning-memory) } } @@ -157,12 +155,6 @@ void WiFiComponent::wifi_pre_setup_() { ESP_LOGE(TAG, "xEventGroupCreate failed"); return; } - // NOLINTNEXTLINE(bugprone-sizeof-expression) - s_event_queue = xQueueCreate(64, sizeof(IDFWiFiEvent *)); - if (s_event_queue == nullptr) { - ESP_LOGE(TAG, "xQueueCreate failed"); - return; - } err = esp_event_loop_create_default(); if (err != ERR_OK) { ESP_LOGE(TAG, "esp_event_loop_create_default failed: %s", esp_err_to_name(err)); @@ -724,16 +716,14 @@ const char *get_disconnect_reason_str(uint8_t reason) { } void WiFiComponent::wifi_loop_() { - while (true) { - IDFWiFiEvent *data; - if (xQueueReceive(s_event_queue, &data, 0L) != pdTRUE) { - // no event ready - break; - } + 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); + } - // process event + IDFWiFiEvent *data; + while ((data = this->event_queue_.pop()) != nullptr) { wifi_process_event_(data); - delete data; // NOLINT(cppcoreguidelines-owning-memory) } } From 8688ef7125cd7eac012ec26e90b0e4c63e67ebb1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Mar 2026 08:24:48 -1000 Subject: [PATCH 16/72] [benchmark] Fix decode benchmarks being optimized away by compiler (#15293) --- .../components/api/bench_proto_decode.cpp | 46 +++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/tests/benchmarks/components/api/bench_proto_decode.cpp b/tests/benchmarks/components/api/bench_proto_decode.cpp index 113201dd8a..961c629f2a 100644 --- a/tests/benchmarks/components/api/bench_proto_decode.cpp +++ b/tests/benchmarks/components/api/bench_proto_decode.cpp @@ -10,10 +10,9 @@ namespace esphome::api::benchmarks { // sub-microsecond benchmarks. static constexpr int kInnerIterations = 2000; -// Helper: encode a message into a buffer and return it. -// Benchmarks encode once in setup, then decode the resulting bytes in a loop. -// This keeps decode benchmarks in sync with the actual protobuf schema — -// hand-encoded byte arrays would silently break when fields change. +// Helper: encode a message into an APIBuffer for reuse in decode benchmarks. +// Optimization barriers are applied to the decode target objects via +// DoNotOptimize/ClobberMemory, not to this buffer. template static APIBuffer encode_message(const T &msg) { APIBuffer buffer; uint32_t size = msg.calculate_size(); @@ -23,6 +22,12 @@ template static APIBuffer encode_message(const T &msg) { return buffer; } +/// Force a pointer through an asm barrier so the compiler cannot +/// prove its contents are unchanged across iterations. +/// benchmark::DoNotOptimize/ClobberMemory are insufficient under +/// CodSpeed's valgrind-based instrumentation. +static void escape(void *p) { asm volatile("" : : "g"(p) : "memory"); } + // --- HelloRequest decode (string + varint fields) --- static void Decode_HelloRequest(benchmark::State &state) { @@ -31,13 +36,18 @@ static void Decode_HelloRequest(benchmark::State &state) { source.api_version_major = 1; source.api_version_minor = 10; auto encoded = encode_message(source); + auto *data = encoded.data(); + auto size = encoded.size(); + benchmark::DoNotOptimize(data); + benchmark::DoNotOptimize(size); for (auto _ : state) { - HelloRequest msg; for (int i = 0; i < kInnerIterations; i++) { - msg.decode(encoded.data(), encoded.size()); + HelloRequest msg; + escape(&msg); + msg.decode(data, size); + escape(&msg); } - benchmark::DoNotOptimize(msg.api_version_major); } state.SetItemsProcessed(state.iterations() * kInnerIterations); } @@ -50,13 +60,18 @@ static void Decode_SwitchCommandRequest(benchmark::State &state) { source.key = 0x12345678; source.state = true; auto encoded = encode_message(source); + auto *data = encoded.data(); + auto size = encoded.size(); + benchmark::DoNotOptimize(data); + benchmark::DoNotOptimize(size); for (auto _ : state) { - SwitchCommandRequest msg; for (int i = 0; i < kInnerIterations; i++) { - msg.decode(encoded.data(), encoded.size()); + SwitchCommandRequest msg; + escape(&msg); + msg.decode(data, size); + escape(&msg); } - benchmark::DoNotOptimize(msg.state); } state.SetItemsProcessed(state.iterations() * kInnerIterations); } @@ -78,13 +93,18 @@ static void Decode_LightCommandRequest(benchmark::State &state) { source.has_effect = true; source.effect = StringRef::from_lit("rainbow"); auto encoded = encode_message(source); + auto *data = encoded.data(); + auto size = encoded.size(); + benchmark::DoNotOptimize(data); + benchmark::DoNotOptimize(size); for (auto _ : state) { - LightCommandRequest msg; for (int i = 0; i < kInnerIterations; i++) { - msg.decode(encoded.data(), encoded.size()); + LightCommandRequest msg; + escape(&msg); + msg.decode(data, size); + escape(&msg); } - benchmark::DoNotOptimize(msg.brightness); } state.SetItemsProcessed(state.iterations() * kInnerIterations); } From 8561a8c495afdf7caaffb3e043c644dbed7474e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Mar 2026 08:48:04 -1000 Subject: [PATCH 17/72] [core] Suppress component source overflow warnings in testing mode (#15320) --- esphome/cpp_helpers.py | 12 +++++++----- tests/unit_tests/test_cpp_helpers.py | 21 ++++++++++++++++++++- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index e7ff2965c8..479090016f 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -71,11 +71,13 @@ def register_component_source(name: str) -> int: return pool.sources[name] idx = len(pool.sources) + 1 if idx > _MAX_COMPONENT_SOURCES: - _LOGGER.warning( - "Too many unique component source names (max %d), '%s' will show as ''", - _MAX_COMPONENT_SOURCES, - name, - ) + if not CORE.testing_mode: + _LOGGER.warning( + "Too many unique component source names (max %d), " + "'%s' will show as ''", + _MAX_COMPONENT_SOURCES, + name, + ) return 0 pool.sources[name] = idx _ensure_source_table_registered() diff --git a/tests/unit_tests/test_cpp_helpers.py b/tests/unit_tests/test_cpp_helpers.py index 52424a7cb2..a76ea21c23 100644 --- a/tests/unit_tests/test_cpp_helpers.py +++ b/tests/unit_tests/test_cpp_helpers.py @@ -140,9 +140,28 @@ def test_register_component_source_overflow_warns( sources={f"comp_{i}": i + 1 for i in range(0xFF)}, table_registered=True, ) - monkeypatch.setattr(ch, "CORE", Mock(data={ch._COMPONENT_SOURCE_DOMAIN: pool})) + monkeypatch.setattr( + ch, "CORE", Mock(data={ch._COMPONENT_SOURCE_DOMAIN: pool}, testing_mode=False) + ) with caplog.at_level(logging.WARNING): idx = register_component_source("overflow_component") assert idx == 0 assert "Too many unique component source names" in caplog.text assert "overflow_component" in caplog.text + + +def test_register_component_source_overflow_suppressed_in_testing_mode( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + # Pre-fill pool to max + pool = ComponentSourcePool( + sources={f"comp_{i}": i + 1 for i in range(0xFF)}, + table_registered=True, + ) + monkeypatch.setattr( + ch, "CORE", Mock(data={ch._COMPONENT_SOURCE_DOMAIN: pool}, testing_mode=True) + ) + with caplog.at_level(logging.WARNING): + idx = register_component_source("overflow_component") + assert idx == 0 + assert "Too many unique component source names" not in caplog.text From f25fa7123599fe3a33fff4c652486b6fe6c23e47 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 31 Mar 2026 06:25:15 +1000 Subject: [PATCH 18/72] [lvgl] Fix align_to directives (#15311) --- esphome/components/lvgl/__init__.py | 15 +++++++++++++-- esphome/components/lvgl/defines.py | 1 + esphome/components/lvgl/lvgl_esphome.h | 15 ++++++++++++--- esphome/components/lvgl/trigger.py | 25 +++++++++++++++++++++++-- esphome/components/lvgl/types.py | 4 ++-- tests/components/lvgl/lvgl-package.yaml | 4 +++- 6 files changed, 54 insertions(+), 10 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index a6afa12afa..6377183ef4 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -48,6 +48,7 @@ from esphome.yaml_util import load_yaml from . import defines as df, helpers, lv_validation as lvalid, widgets from .automation import focused_widgets, layers_to_code, lvgl_update, refreshed_widgets +from .defines import CONF_ALIGN_TO_LAMBDA_ID from .encoders import ( ENCODERS_CONFIG, encoders_to_code, @@ -69,8 +70,16 @@ from .schemas import ( ) from .styles import styles_to_code, theme_to_code from .touchscreens import touchscreen_schema, touchscreens_to_code -from .trigger import add_on_boot_triggers, generate_triggers -from .types import IdleTrigger, PlainTrigger, lv_font_t, lv_group_t, lv_style_t, lvgl_ns +from .trigger import add_on_boot_triggers, generate_align_tos, generate_triggers +from .types import ( + IdleTrigger, + PlainTrigger, + lv_font_t, + lv_group_t, + lv_lambda_t, + lv_style_t, + lvgl_ns, +) from .widgets import ( LvScrActType, Widget, @@ -345,6 +354,7 @@ async def to_code(configs): Widget.widgets_completed = True async with LvContext(): await generate_triggers() + await generate_align_tos(configs[0]) for config in configs: lv_component = await cg.get_variable(config[CONF_ID]) await generate_page_triggers(config) @@ -458,6 +468,7 @@ LVGL_SCHEMA = cv.All( .extend( { cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent), + cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t), cv.GenerateID(df.CONF_DISPLAYS): display_schema, cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16), cv.Optional( diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 0a53d88669..72345ca98e 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -504,6 +504,7 @@ CONF_ACCEPTED_CHARS = "accepted_chars" CONF_ADJUSTABLE = "adjustable" CONF_ALIGN = "align" CONF_ALIGN_TO = "align_to" +CONF_ALIGN_TO_LAMBDA_ID = "align_to_lambda_id" CONF_ANGLE_RANGE = "angle_range" CONF_ANIMATED = "animated" CONF_ANIMATION = "animation" diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 8de82d50c0..21d1e0d417 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -128,10 +128,19 @@ class LvPageType : public Parented { bool skip; }; -using LvLambdaType = std::function; -using set_value_lambda_t = std::function; using event_callback_t = void(lv_event_t *); -using text_lambda_t = std::function; + +class LvLambdaComponent : public Component { + public: + LvLambdaComponent(void (*callback)()) : callback_(callback) {} + + void setup() override { this->callback_(); } + // execute after the LvglComponent is setup + float get_setup_priority() const override { return setup_priority::PROCESSOR - 5; } + + protected: + void (*callback_)(); +}; template class ObjUpdateAction : public Action { public: diff --git a/esphome/components/lvgl/trigger.py b/esphome/components/lvgl/trigger.py index 077ff06bb7..54309cdf89 100644 --- a/esphome/components/lvgl/trigger.py +++ b/esphome/components/lvgl/trigger.py @@ -8,10 +8,13 @@ from esphome.const import ( CONF_X, CONF_Y, ) +from esphome.cpp_generator import new_Pvariable +from esphome.cpp_helpers import register_component from .defines import ( CONF_ALIGN, CONF_ALIGN_TO, + CONF_ALIGN_TO_LAMBDA_ID, DIRECTIONS, LV_EVENT_MAP, LV_EVENT_TRIGGERS, @@ -89,14 +92,32 @@ async def generate_triggers(): await add_on_boot_triggers(w.config.get(CONF_ON_BOOT, ())) - # Generate align to directives while we're here - if align_to := w.config.get(CONF_ALIGN_TO): + +async def generate_align_tos(config: dict): + """ + Called once, with a full lvgl configuration to emit deferred align_to actions as a component + that executes after the LVGL setup. This is required since align_to actions are not recalculated on layout changes + and so must be applied after the display is properly laid out. + :param config: + :return: + """ + align_tos = tuple( + w for w in widget_map.values() if w.config and CONF_ALIGN_TO in w.config + ) + if align_tos: + async with LambdaContext(where="align_to") as context: + for w in align_tos: + align_to = w.config[CONF_ALIGN_TO] target = widget_map[align_to[CONF_ID]].obj align = literal(align_to[CONF_ALIGN]) x = align_to[CONF_X] y = align_to[CONF_Y] lv.obj_align_to(w.obj, target, align, x, y) + action_id = config[CONF_ALIGN_TO_LAMBDA_ID] + var = new_Pvariable(action_id, await context.get_lambda()) + await register_component(var, {}) + async def add_trigger(conf, w, *events, is_selected=None): is_selected = is_selected or w.is_selected() diff --git a/esphome/components/lvgl/types.py b/esphome/components/lvgl/types.py index 8343a542a9..686e429267 100644 --- a/esphome/components/lvgl/types.py +++ b/esphome/components/lvgl/types.py @@ -1,7 +1,7 @@ from esphome import automation, codegen as cg from esphome.const import CONF_TEXT, CONF_VALUE from esphome.cpp_generator import MockObj -from esphome.cpp_types import esphome_ns +from esphome.cpp_types import Component, esphome_ns from .defines import lvgl_ns @@ -51,7 +51,7 @@ IdleTrigger = lvgl_ns.class_("IdleTrigger", automation.Trigger.template()) ObjUpdateAction = lvgl_ns.class_("ObjUpdateAction", automation.Action) LvglCondition = lvgl_ns.class_("LvglCondition", automation.Condition) LvglAction = lvgl_ns.class_("LvglAction", automation.Action) -lv_lambda_t = lvgl_ns.class_("LvLambdaType") +lv_lambda_t = lvgl_ns.class_("LvLambdaComponent", Component) LvCompound = lvgl_ns.class_("LvCompound") lv_font_t = cg.global_ns.class_("lv_font_t") lv_style_t = cg.global_ns.struct("lv_style_t") diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index b168578a98..821476a72b 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -288,7 +288,9 @@ lvgl: - label: text: "Hello shiny day" text_color: 0xFFFFFF - align: bottom_mid + align_to: + id: hello_label + align: OUT_LEFT_TOP - label: id: setup_lambda_label # Test lambda in widget property during setup (LvContext) From c5eb0eb984deae5f95edeb9b9f873feeb6aec785 Mon Sep 17 00:00:00 2001 From: Ardumine <61353807+Ardumine@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:50:11 +0100 Subject: [PATCH 19/72] [internal_temperature] Add nRF52 Zephyr support (#15297) --- .../internal_temperature.cpp | 46 +++++++++++++++++++ .../components/internal_temperature/sensor.py | 9 +++- .../test.nrf52-adafruit.yaml | 1 + 3 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 tests/components/internal_temperature/test.nrf52-adafruit.yaml diff --git a/esphome/components/internal_temperature/internal_temperature.cpp b/esphome/components/internal_temperature/internal_temperature.cpp index 34d7baf880..567ae6170e 100644 --- a/esphome/components/internal_temperature/internal_temperature.cpp +++ b/esphome/components/internal_temperature/internal_temperature.cpp @@ -22,11 +22,18 @@ extern "C" { uint32_t temp_single_get_current_temperature(uint32_t *temp_value); } #endif // USE_BK72XX +#if defined(USE_ZEPHYR) && defined(USE_NRF52) +#include +#include +#endif // USE_ZEPHYR && USE_NRF52 namespace esphome { namespace internal_temperature { static const char *const TAG = "internal_temperature"; +#if defined(USE_ZEPHYR) && defined(USE_NRF52) +static const struct device *const DIE_TEMPERATURE_SENSOR = DEVICE_DT_GET_ONE(nordic_nrf_temp); +#endif // USE_ZEPHYR && USE_NRF52 #ifdef USE_ESP32 #if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || \ @@ -36,6 +43,37 @@ static temperature_sensor_handle_t tsensNew = NULL; #endif // USE_ESP32 void InternalTemperatureSensor::update() { +#if defined(USE_ZEPHYR) && defined(USE_NRF52) + struct sensor_value value; + int result = sensor_sample_fetch(DIE_TEMPERATURE_SENSOR); + if (result != 0) { + ESP_LOGE(TAG, "Failed to fetch nRF52 die temperature sample (%d)", result); + if (!this->has_state()) { + this->publish_state(NAN); + } + return; + } + + result = sensor_channel_get(DIE_TEMPERATURE_SENSOR, SENSOR_CHAN_DIE_TEMP, &value); + if (result != 0) { + ESP_LOGE(TAG, "Failed to get nRF52 die temperature (%d)", result); + if (!this->has_state()) { + this->publish_state(NAN); + } + return; + } + + const float temperature = value.val1 + (value.val2 / 1000000.0f); + if (std::isfinite(temperature)) { + this->publish_state(temperature); + } else { + ESP_LOGD(TAG, "Ignoring invalid nRF52 temperature (value=%.1f)", temperature); + if (!this->has_state()) { + this->publish_state(NAN); + } + } +#else + float temperature = NAN; bool success = false; #ifdef USE_ESP32 @@ -79,9 +117,17 @@ void InternalTemperatureSensor::update() { this->publish_state(NAN); } } +#endif // USE_ZEPHYR && USE_NRF52 } void InternalTemperatureSensor::setup() { +#if defined(USE_ZEPHYR) && defined(USE_NRF52) + if (!device_is_ready(DIE_TEMPERATURE_SENSOR)) { + ESP_LOGE(TAG, "nRF52 die temperature sensor device %s not ready", DIE_TEMPERATURE_SENSOR->name); + this->mark_failed(); + return; + } +#endif // USE_ZEPHYR && USE_NRF52 #ifdef USE_ESP32 #if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || \ diff --git a/esphome/components/internal_temperature/sensor.py b/esphome/components/internal_temperature/sensor.py index 93b98a30f4..965e7f0520 100644 --- a/esphome/components/internal_temperature/sensor.py +++ b/esphome/components/internal_temperature/sensor.py @@ -1,15 +1,18 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.components.zephyr import zephyr_add_prj_conf import esphome.config_validation as cv from esphome.const import ( DEVICE_CLASS_TEMPERATURE, ENTITY_CATEGORY_DIAGNOSTIC, PLATFORM_BK72XX, PLATFORM_ESP32, + PLATFORM_NRF52, PLATFORM_RP2040, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, ) +from esphome.core import CORE internal_temperature_ns = cg.esphome_ns.namespace("internal_temperature") InternalTemperatureSensor = internal_temperature_ns.class_( @@ -25,10 +28,14 @@ CONFIG_SCHEMA = cv.All( state_class=STATE_CLASS_MEASUREMENT, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ).extend(cv.polling_component_schema("60s")), - cv.only_on([PLATFORM_ESP32, PLATFORM_RP2040, PLATFORM_BK72XX]), + cv.only_on([PLATFORM_ESP32, PLATFORM_RP2040, PLATFORM_BK72XX, PLATFORM_NRF52]), ) async def to_code(config): var = await sensor.new_sensor(config) await cg.register_component(var, config) + + if CORE.using_zephyr and CORE.is_nrf52: + zephyr_add_prj_conf("SENSOR", True) + zephyr_add_prj_conf("TEMP_NRF5", True) diff --git a/tests/components/internal_temperature/test.nrf52-adafruit.yaml b/tests/components/internal_temperature/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/internal_temperature/test.nrf52-adafruit.yaml @@ -0,0 +1 @@ +<<: !include common.yaml From 58df755d8bfe43123d59033b82c4dc3f82b0197e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:27:30 -1000 Subject: [PATCH 20/72] Bump requests from 2.33.0 to 2.33.1 (#15324) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0df5caf181..8ad5528c95 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ freetype-py==2.5.1 jinja2==3.1.6 bleak==2.1.1 smpclient==6.0.0 -requests==2.33.0 +requests==2.33.1 # esp-idf >= 5.0 requires this pyparsing >= 3.0 From 53b2a03c80d22a99fb13aa5f09d665773e414402 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:56:05 -0400 Subject: [PATCH 21/72] [multiple] Fix -Wformat and -Wextra warnings across 33 component files (#15321) --- esphome/components/adc/adc_sensor_esp32.cpp | 10 ++++-- esphome/components/api/api_connection.cpp | 12 +++---- .../media_source/audio_file_media_source.cpp | 3 +- esphome/components/bm8563/bm8563.cpp | 7 ++-- .../components/bme68x_bsec2/bme68x_bsec2.cpp | 4 +-- esphome/components/dlms_meter/dlms_meter.cpp | 4 ++- .../components/esp32_touch/esp32_touch.cpp | 2 +- .../components/espnow/espnow_component.cpp | 4 ++- .../components/http_request/http_request.cpp | 2 +- esphome/components/hub75/hub75.cpp | 4 ++- esphome/components/infrared/infrared.cpp | 11 +++--- esphome/components/inkplate/inkplate.cpp | 34 ++++++++++--------- esphome/components/ld2450/ld2450.cpp | 3 +- .../components/max7219digit/max7219digit.cpp | 5 ++- esphome/components/modbus/modbus.cpp | 2 +- .../components/nextion/nextion_commands.cpp | 4 +-- esphome/components/qmp6988/qmp6988.cpp | 6 ++-- esphome/components/rd03d/rd03d.cpp | 4 ++- .../remote_base/symphony_protocol.cpp | 10 +++--- .../components/runtime_image/bmp_decoder.cpp | 4 ++- .../components/serial_proxy/serial_proxy.cpp | 24 +++++++------ esphome/components/spa06_base/spa06_base.cpp | 4 ++- esphome/components/sps30/sps30.cpp | 8 +++-- .../thermostat/thermostat_climate.cpp | 8 +++-- .../components/tormatic/tormatic_cover.cpp | 11 +++--- .../components/tormatic/tormatic_protocol.h | 4 ++- .../uart/uart_component_esp_idf.cpp | 2 +- .../climate/uponor_smatrix_climate.cpp | 4 ++- .../sensor/uponor_smatrix_sensor.cpp | 4 ++- .../uponor_smatrix/uponor_smatrix.cpp | 14 ++++---- esphome/components/vl53l0x/vl53l0x_sensor.cpp | 6 ++-- .../components/water_heater/water_heater.cpp | 5 ++- .../components/zwave_proxy/zwave_proxy.cpp | 4 ++- 33 files changed, 145 insertions(+), 88 deletions(-) diff --git a/esphome/components/adc/adc_sensor_esp32.cpp b/esphome/components/adc/adc_sensor_esp32.cpp index 1d3138623e..fc707013a8 100644 --- a/esphome/components/adc/adc_sensor_esp32.cpp +++ b/esphome/components/adc/adc_sensor_esp32.cpp @@ -2,6 +2,7 @@ #include "adc_sensor.h" #include "esphome/core/log.h" +#include namespace esphome { namespace adc { @@ -346,7 +347,8 @@ float ADCSensor::sample_autorange_() { ESP_LOGVV(TAG, "Autorange summary:"); ESP_LOGVV(TAG, " Raw readings: 12db=%d, 6db=%d, 2.5db=%d, 0db=%d", raw12, raw6, raw2, raw0); ESP_LOGVV(TAG, " Voltages: 12db=%.6f, 6db=%.6f, 2.5db=%.6f, 0db=%.6f", mv12, mv6, mv2, mv0); - ESP_LOGVV(TAG, " Coefficients: c12=%u, c6=%u, c2=%u, c0=%u, sum=%u", c12, c6, c2, c0, csum); + ESP_LOGVV(TAG, " Coefficients: c12=%" PRIu32 ", c6=%" PRIu32 ", c2=%" PRIu32 ", c0=%" PRIu32 ", sum=%" PRIu32, c12, + c6, c2, c0, csum); if (csum == 0) { ESP_LOGE(TAG, "Invalid weight sum in autorange calculation"); @@ -354,8 +356,10 @@ float ADCSensor::sample_autorange_() { } const float final_result = (mv12 * c12 + mv6 * c6 + mv2 * c2 + mv0 * c0) / csum; - ESP_LOGV(TAG, "Autorange final: (%.6f*%u + %.6f*%u + %.6f*%u + %.6f*%u)/%u = %.6fV", mv12, c12, mv6, c6, mv2, c2, mv0, - c0, csum, final_result); + ESP_LOGV(TAG, + "Autorange final: (%.6f*%" PRIu32 " + %.6f*%" PRIu32 " + %.6f*%" PRIu32 " + %.6f*%" PRIu32 ")/%" PRIu32 + " = %.6fV", + mv12, c12, mv6, c6, mv2, c2, mv0, c0, csum, final_result); return final_result; } diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 0a99adcacf..79df85ada3 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1465,7 +1465,7 @@ void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent void APIConnection::on_serial_proxy_configure_request(const SerialProxyConfigureRequest &msg) { auto &proxies = App.get_serial_proxies(); if (msg.instance >= proxies.size()) { - ESP_LOGW(TAG, "Serial proxy instance %u out of range (max %u)", msg.instance, + ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range (max %" PRIu32 ")", msg.instance, static_cast(proxies.size())); return; } @@ -1476,7 +1476,7 @@ void APIConnection::on_serial_proxy_configure_request(const SerialProxyConfigure void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest &msg) { auto &proxies = App.get_serial_proxies(); if (msg.instance >= proxies.size()) { - ESP_LOGW(TAG, "Serial proxy instance %u out of range", msg.instance); + ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance); return; } proxies[msg.instance]->write_from_client(msg.data, msg.data_len); @@ -1485,7 +1485,7 @@ void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg) { auto &proxies = App.get_serial_proxies(); if (msg.instance >= proxies.size()) { - ESP_LOGW(TAG, "Serial proxy instance %u out of range", msg.instance); + ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance); return; } proxies[msg.instance]->set_modem_pins(msg.line_states); @@ -1494,7 +1494,7 @@ void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetM void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg) { auto &proxies = App.get_serial_proxies(); if (msg.instance >= proxies.size()) { - ESP_LOGW(TAG, "Serial proxy instance %u out of range", msg.instance); + ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance); return; } SerialProxyGetModemPinsResponse resp{}; @@ -1506,7 +1506,7 @@ void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetM void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { auto &proxies = App.get_serial_proxies(); if (msg.instance >= proxies.size()) { - ESP_LOGW(TAG, "Serial proxy instance %u out of range", msg.instance); + ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance); return; } switch (msg.type) { @@ -1536,7 +1536,7 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { break; } default: - ESP_LOGW(TAG, "Unknown serial proxy request type: %u", static_cast(msg.type)); + ESP_LOGW(TAG, "Unknown serial proxy request type: %" PRIu32, static_cast(msg.type)); break; } } diff --git a/esphome/components/audio_file/media_source/audio_file_media_source.cpp b/esphome/components/audio_file/media_source/audio_file_media_source.cpp index 120f871d2f..fbb5ecd88d 100644 --- a/esphome/components/audio_file/media_source/audio_file_media_source.cpp +++ b/esphome/components/audio_file/media_source/audio_file_media_source.cpp @@ -4,6 +4,7 @@ #include "esphome/components/audio/audio_decoder.h" +#include #include namespace esphome::audio_file { @@ -249,7 +250,7 @@ void AudioFileMediaSource::decode_task(void *params) { audio::AudioStreamInfo stream_info = decoder->get_audio_stream_info().value(); - ESP_LOGD(TAG, "Bits per sample: %d, Channels: %d, Sample rate: %d", stream_info.get_bits_per_sample(), + ESP_LOGD(TAG, "Bits per sample: %d, Channels: %d, Sample rate: %" PRIu32, stream_info.get_bits_per_sample(), stream_info.get_channels(), stream_info.get_sample_rate()); if (stream_info.get_bits_per_sample() != 16 || stream_info.get_channels() > 2) { diff --git a/esphome/components/bm8563/bm8563.cpp b/esphome/components/bm8563/bm8563.cpp index 269acfea44..062094c036 100644 --- a/esphome/components/bm8563/bm8563.cpp +++ b/esphome/components/bm8563/bm8563.cpp @@ -1,4 +1,7 @@ #include "bm8563.h" + +#include + #include "esphome/core/log.h" namespace esphome::bm8563 { @@ -146,10 +149,10 @@ optional BM8563::read_register_(uint8_t reg) { } void BM8563::set_timer_irq_(uint32_t duration_s) { - ESP_LOGI(TAG, "Timer Duration: %u s", duration_s); + ESP_LOGI(TAG, "Timer Duration: %" PRIu32 " s", duration_s); if (duration_s > MAX_TIMER_DURATION_S) { - ESP_LOGW(TAG, "Timer duration %u s exceeds maximum %u s", duration_s, MAX_TIMER_DURATION_S); + ESP_LOGW(TAG, "Timer duration %" PRIu32 " s exceeds maximum %" PRIu32 " s", duration_s, MAX_TIMER_DURATION_S); return; } diff --git a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp index d9e00e65b2..d4ac57d750 100644 --- a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp +++ b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp @@ -89,7 +89,7 @@ void BME68xBSEC2Component::dump_config() { " Operating age: %s\n" " Sample rate: %s\n" " Voltage: %s\n" - " State save interval: %ims\n" + " State save interval: %" PRIu32 "ms\n" " Temperature offset: %.2f", BME68X_BSEC2_OPERATING_AGE_LOG(this->operating_age_), BME68X_BSEC2_SAMPLE_RATE_LOG(this->sample_rate_), BME68X_BSEC2_VOLTAGE_LOG(this->voltage_), this->state_save_interval_ms_, this->temperature_offset_); @@ -283,7 +283,7 @@ void BME68xBSEC2Component::run_() { if (this->bsec_settings_.trigger_measurement && this->bsec_settings_.op_mode != BME68X_SLEEP_MODE) { bme68x_get_conf(&bme68x_conf, &this->bme68x_); uint32_t meas_dur = bme68x_get_meas_dur(this->op_mode_, &bme68x_conf, &this->bme68x_); - ESP_LOGV(TAG, "Queueing read in %uus", meas_dur); + ESP_LOGV(TAG, "Queueing read in %" PRIu32 "us", meas_dur); this->trigger_time_ns_ = curr_time_ns; this->set_timeout("read", meas_dur / 1000, [this]() { this->read_(this->trigger_time_ns_); }); } else { diff --git a/esphome/components/dlms_meter/dlms_meter.cpp b/esphome/components/dlms_meter/dlms_meter.cpp index 052a0f4d01..b732e71d24 100644 --- a/esphome/components/dlms_meter/dlms_meter.cpp +++ b/esphome/components/dlms_meter/dlms_meter.cpp @@ -1,5 +1,7 @@ #include "dlms_meter.h" +#include + #if defined(USE_ESP8266_FRAMEWORK_ARDUINO) #include #elif defined(USE_ESP32) @@ -21,7 +23,7 @@ void DlmsMeterComponent::dump_config() { ESP_LOGCONFIG(TAG, "DLMS Meter:\n" " Provider: %s\n" - " Read Timeout: %u ms", + " Read Timeout: %" PRIu32 " ms", provider_name, this->read_timeout_); #define DLMS_METER_LOG_SENSOR(s) LOG_SENSOR(" ", #s, this->s##_sensor_); DLMS_METER_SENSOR_LIST(DLMS_METER_LOG_SENSOR, ) diff --git a/esphome/components/esp32_touch/esp32_touch.cpp b/esphome/components/esp32_touch/esp32_touch.cpp index 0d331b29d6..e44bc807e9 100644 --- a/esphome/components/esp32_touch/esp32_touch.cpp +++ b/esphome/components/esp32_touch/esp32_touch.cpp @@ -217,7 +217,7 @@ void ESP32TouchComponent::setup() { for (uint32_t i = 0; i < ONESHOT_SCAN_COUNT; i++) { err = touch_sensor_trigger_oneshot_scanning(this->sens_handle_, ONESHOT_SCAN_TIMEOUT_MS); if (err != ESP_OK) { - ESP_LOGW(TAG, "Oneshot scan %d failed: %s", i, esp_err_to_name(err)); + ESP_LOGW(TAG, "Oneshot scan %" PRIu32 " failed: %s", i, esp_err_to_name(err)); } } diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index 78916891f4..0dc0f12e7e 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -4,6 +4,8 @@ #include "espnow_err.h" +#include + #include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" @@ -266,7 +268,7 @@ void ESPNowComponent::loop() { if (wifi::global_wifi_component != nullptr && wifi::global_wifi_component->is_connected()) { int32_t new_channel = wifi::global_wifi_component->get_wifi_channel(); if (new_channel != this->wifi_channel_) { - ESP_LOGI(TAG, "Wifi Channel is changed from %d to %d.", this->wifi_channel_, new_channel); + ESP_LOGI(TAG, "Wifi Channel is changed from %d to %" PRId32 ".", this->wifi_channel_, new_channel); this->wifi_channel_ = new_channel; } } diff --git a/esphome/components/http_request/http_request.cpp b/esphome/components/http_request/http_request.cpp index 6590d2018e..2c74638f12 100644 --- a/esphome/components/http_request/http_request.cpp +++ b/esphome/components/http_request/http_request.cpp @@ -11,7 +11,7 @@ static const char *const TAG = "http_request"; void HttpRequestComponent::dump_config() { ESP_LOGCONFIG(TAG, "HTTP Request:\n" - " Timeout: %ums\n" + " Timeout: %" PRIu32 "ms\n" " User-Agent: %s\n" " Follow redirects: %s\n" " Redirect limit: %d", diff --git a/esphome/components/hub75/hub75.cpp b/esphome/components/hub75/hub75.cpp index cf8661b2b3..ba652d427d 100644 --- a/esphome/components/hub75/hub75.cpp +++ b/esphome/components/hub75/hub75.cpp @@ -1,6 +1,8 @@ #include "hub75_component.h" #include "esphome/core/application.h" +#include + #ifdef USE_ESP32 namespace esphome::hub75 { @@ -58,7 +60,7 @@ void HUB75Display::dump_config() { config_.pins.oe, config_.pins.clk); ESP_LOGCONFIG(TAG, - " Clock Speed: %u MHz\n" + " Clock Speed: %" PRIu32 " MHz\n" " Latch Blanking: %i\n" " Clock Phase: %s\n" " Min Refresh Rate: %i Hz\n" diff --git a/esphome/components/infrared/infrared.cpp b/esphome/components/infrared/infrared.cpp index 658c9fd0df..9b97995a96 100644 --- a/esphome/components/infrared/infrared.cpp +++ b/esphome/components/infrared/infrared.cpp @@ -1,4 +1,7 @@ #include "infrared.h" + +#include + #include "esphome/core/log.h" #ifdef USE_API @@ -100,7 +103,7 @@ void Infrared::control(const InfraredCall &call) { // Zero-copy from packed protobuf data transmit_data->set_data_from_packed_sint32(call.get_packed_data(), call.get_packed_length(), call.get_packed_count()); - ESP_LOGD(TAG, "Transmitting packed raw timings: count=%u, repeat=%u", call.get_packed_count(), + ESP_LOGD(TAG, "Transmitting packed raw timings: count=%" PRIu16 ", repeat=%" PRIu32, call.get_packed_count(), call.get_repeat_count()); } else if (call.is_base64url()) { // Decode base64url (URL-safe) into transmit buffer @@ -113,16 +116,16 @@ void Infrared::control(const InfraredCall &call) { for (int32_t timing : transmit_data->get_data()) { int32_t abs_timing = timing < 0 ? -timing : timing; if (abs_timing > max_timing_us) { - ESP_LOGE(TAG, "Invalid timing value: %d µs (max %d)", timing, max_timing_us); + ESP_LOGE(TAG, "Invalid timing value: %" PRId32 " µs (max %" PRId32 ")", timing, max_timing_us); return; } } - ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%u", transmit_data->get_data().size(), + ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%" PRIu32, transmit_data->get_data().size(), call.get_repeat_count()); } else { // From vector (lambdas/automations) transmit_data->set_data(call.get_raw_timings()); - ESP_LOGD(TAG, "Transmitting raw timings: count=%zu, repeat=%u", call.get_raw_timings().size(), + ESP_LOGD(TAG, "Transmitting raw timings: count=%zu, repeat=%" PRIu32, call.get_raw_timings().size(), call.get_repeat_count()); } diff --git a/esphome/components/inkplate/inkplate.cpp b/esphome/components/inkplate/inkplate.cpp index 3b4b1a63d5..0511b451a8 100644 --- a/esphome/components/inkplate/inkplate.cpp +++ b/esphome/components/inkplate/inkplate.cpp @@ -3,6 +3,8 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include + #include namespace esphome { @@ -193,7 +195,7 @@ void Inkplate::dump_config() { ESP_LOGCONFIG(TAG, " Greyscale: %s\n" " Partial Updating: %s\n" - " Full Update Every: %d", + " Full Update Every: %" PRIu32, YESNO(this->greyscale_), YESNO(this->partial_updating_), this->full_update_every_); // Log pins LOG_PIN(" CKV Pin: ", this->ckv_pin_); @@ -306,7 +308,7 @@ void Inkplate::fill(Color color) { // If clipping is active, fall back to base implementation if (this->get_clipping().is_set()) { Display::fill(color); - ESP_LOGV(TAG, "Fill finished (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Fill finished (%" PRIu32 "ms)", millis() - start_time); return; } @@ -329,12 +331,12 @@ void Inkplate::display() { this->display3b_(); } else { if (this->partial_updating_ && this->partial_update_()) { - ESP_LOGV(TAG, "Display finished (partial) (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Display finished (partial) (%" PRIu32 "ms)", millis() - start_time); return; } this->display1b_(); } - ESP_LOGV(TAG, "Display finished (full) (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Display finished (full) (%" PRIu32 "ms)", millis() - start_time); } void Inkplate::display1b_() { @@ -409,7 +411,7 @@ void Inkplate::display1b_() { uint32_t clock = (1UL << this->cl_pin_->get_pin()); uint32_t data_mask = this->get_data_pin_mask_(); - ESP_LOGV(TAG, "Display1b start loops (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Display1b start loops (%" PRIu32 "ms)", millis() - start_time); for (uint8_t k = 0; k < rep; k++) { buffer_ptr = &this->buffer_[this->get_buffer_length_() - 1]; @@ -440,7 +442,7 @@ void Inkplate::display1b_() { } delayMicroseconds(230); } - ESP_LOGV(TAG, "Display1b first loop x %d (%ums)", 4, millis() - start_time); + ESP_LOGV(TAG, "Display1b first loop x %d (%" PRIu32 "ms)", 4, millis() - start_time); buffer_ptr = &this->buffer_[this->get_buffer_length_() - 1]; vscan_start_(); @@ -469,7 +471,7 @@ void Inkplate::display1b_() { vscan_end_(); } delayMicroseconds(230); - ESP_LOGV(TAG, "Display1b second loop (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Display1b second loop (%" PRIu32 "ms)", millis() - start_time); if (this->model_ == INKPLATE_6_PLUS) { clean_fast_(2, 2); @@ -495,13 +497,13 @@ void Inkplate::display1b_() { vscan_end_(); } delayMicroseconds(230); - ESP_LOGV(TAG, "Display1b third loop (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Display1b third loop (%" PRIu32 "ms)", millis() - start_time); } vscan_start_(); eink_off_(); this->block_partial_ = false; this->partial_updates_ = 0; - ESP_LOGV(TAG, "Display1b finished (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Display1b finished (%" PRIu32 "ms)", millis() - start_time); } void Inkplate::display3b_() { @@ -614,7 +616,7 @@ void Inkplate::display3b_() { clean_fast_(3, 1); vscan_start_(); eink_off_(); - ESP_LOGV(TAG, "Display3b finished (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Display3b finished (%" PRIu32 "ms)", millis() - start_time); } bool Inkplate::partial_update_() { @@ -641,7 +643,7 @@ bool Inkplate::partial_update_() { this->partial_buffer_2_[n--] = LUTW[diffw & 0x0F] & LUTB[diffb & 0x0F]; } } - ESP_LOGV(TAG, "Partial update buffer built after (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Partial update buffer built after (%" PRIu32 "ms)", millis() - start_time); int rep = (this->model_ == INKPLATE_6_V2) ? 6 : 5; @@ -667,7 +669,7 @@ bool Inkplate::partial_update_() { vscan_end_(); } delayMicroseconds(230); - ESP_LOGV(TAG, "Partial update loop k=%d (%ums)", k, millis() - start_time); + ESP_LOGV(TAG, "Partial update loop k=%d (%" PRIu32 "ms)", k, millis() - start_time); } clean_fast_(2, 2); clean_fast_(3, 1); @@ -675,7 +677,7 @@ bool Inkplate::partial_update_() { eink_off_(); memcpy(this->buffer_, this->partial_buffer_, this->get_buffer_length_()); - ESP_LOGV(TAG, "Partial update finished (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Partial update finished (%" PRIu32 "ms)", millis() - start_time); return true; } @@ -730,7 +732,7 @@ void Inkplate::clean() { clean_fast_(0, 8); // Black to Black clean_fast_(2, 1); // Black to White clean_fast_(1, 10); // White to White - ESP_LOGV(TAG, "Clean finished (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Clean finished (%" PRIu32 "ms)", millis() - start_time); } void Inkplate::clean_fast_(uint8_t c, uint8_t rep) { @@ -773,9 +775,9 @@ void Inkplate::clean_fast_(uint8_t c, uint8_t rep) { vscan_end_(); } delayMicroseconds(230); - ESP_LOGV(TAG, "Clean fast rep loop %d finished (%ums)", k, millis() - start_time); + ESP_LOGV(TAG, "Clean fast rep loop %d finished (%" PRIu32 "ms)", k, millis() - start_time); } - ESP_LOGV(TAG, "Clean fast finished (%ums)", millis() - start_time); + ESP_LOGV(TAG, "Clean fast finished (%" PRIu32 "ms)", millis() - start_time); } void Inkplate::pins_z_state_() { diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index 6230a8c30b..58c3cac42d 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -10,6 +10,7 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" +#include #include #include @@ -575,7 +576,7 @@ void LD2450Component::handle_periodic_data_() { if (this->get_timeout_status_(this->presence_millis_)) { this->target_binary_sensor_->publish_state(false); } else { - ESP_LOGV(TAG, "Clear presence waiting timeout: %d", this->timeout_); + ESP_LOGV(TAG, "Clear presence waiting timeout: %" PRIu32, this->timeout_); } } } diff --git a/esphome/components/max7219digit/max7219digit.cpp b/esphome/components/max7219digit/max7219digit.cpp index cdceafad50..f9b46cf797 100644 --- a/esphome/components/max7219digit/max7219digit.cpp +++ b/esphome/components/max7219digit/max7219digit.cpp @@ -6,6 +6,7 @@ #include "max7219font.h" #include +#include namespace esphome { namespace max7219digit { @@ -92,7 +93,9 @@ void MAX7219Component::loop() { if (this->scroll_mode_ == ScrollMode::STOP) { if (static_cast(this->stepsleft_ + get_width_internal()) == first_line_size + 1) { if (millis_since_last_scroll < this->scroll_dwell_) { - ESP_LOGVV(TAG, "Dwell time at end of string in case of stop at end. Step %d, since last scroll %d, dwell %d.", + ESP_LOGVV(TAG, + "Dwell time at end of string in case of stop at end. Step %d, since last scroll %" PRIu32 + ", dwell %d.", this->stepsleft_, millis_since_last_scroll, this->scroll_dwell_); return; } diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 4146a54c87..3b1a038be3 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -313,7 +313,7 @@ void Modbus::send_next_frame_() { this->last_send_ = millis(); this->tx_buffer_.pop_front(); if (!this->tx_buffer_.empty()) { - ESP_LOGV(TAG, "Write queue contains %" PRIu32 " items.", this->tx_buffer_.size()); + ESP_LOGV(TAG, "Write queue contains %zu items.", this->tx_buffer_.size()); } } diff --git a/esphome/components/nextion/nextion_commands.cpp b/esphome/components/nextion/nextion_commands.cpp index 6718646efa..6c8e0f18bc 100644 --- a/esphome/components/nextion/nextion_commands.cpp +++ b/esphome/components/nextion/nextion_commands.cpp @@ -319,14 +319,14 @@ void Nextion::filled_circle(uint16_t center_x, uint16_t center_y, uint16_t radiu void Nextion::qrcode(uint16_t x1, uint16_t y1, const char *content, uint16_t size, uint16_t background_color, uint16_t foreground_color, int32_t logo_pic, uint8_t border_width) { this->add_no_result_to_queue_with_printf_( - "qrcode", "qrcode %" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu8 ",%" PRIu8 ",\"%s\"", x1, + "qrcode", "qrcode %" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRId32 ",%" PRIu8 ",\"%s\"", x1, y1, size, background_color, foreground_color, logo_pic, border_width, content); } void Nextion::qrcode(uint16_t x1, uint16_t y1, const char *content, uint16_t size, Color background_color, Color foreground_color, int32_t logo_pic, uint8_t border_width) { this->add_no_result_to_queue_with_printf_( - "qrcode", "qrcode %" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu8 ",%" PRIu8 ",\"%s\"", x1, + "qrcode", "qrcode %" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRId32 ",%" PRIu8 ",\"%s\"", x1, y1, size, display::ColorUtil::color_to_565(background_color), display::ColorUtil::color_to_565(foreground_color), logo_pic, border_width, content); } diff --git a/esphome/components/qmp6988/qmp6988.cpp b/esphome/components/qmp6988/qmp6988.cpp index 17d91c3633..976efe7910 100644 --- a/esphome/components/qmp6988/qmp6988.cpp +++ b/esphome/components/qmp6988/qmp6988.cpp @@ -1,4 +1,6 @@ #include "qmp6988.h" + +#include #include namespace esphome { @@ -129,7 +131,7 @@ bool QMP6988Component::get_calibration_data_() { ESP_LOGV(TAG, "Calibration data:\n" - " COE_a0[%d] COE_a1[%d] COE_a2[%d] COE_b00[%d]\n" + " COE_a0[%" PRId32 "] COE_a1[%d] COE_a2[%d] COE_b00[%" PRId32 "]\n" " COE_bt1[%d] COE_bt2[%d] COE_bp1[%d] COE_b11[%d]\n" " COE_bp2[%d] COE_b12[%d] COE_b21[%d] COE_bp3[%d]", qmp6988_data_.qmp6988_cali.COE_a0, qmp6988_data_.qmp6988_cali.COE_a1, qmp6988_data_.qmp6988_cali.COE_a2, @@ -153,7 +155,7 @@ bool QMP6988Component::get_calibration_data_() { qmp6988_data_.ik.bp3 = 2915L * (int64_t) qmp6988_data_.qmp6988_cali.COE_bp3 + 157155561L; // 28Q65 ESP_LOGV(TAG, "Int calibration data:\n" - " a0[%d] a1[%d] a2[%d] b00[%d]\n" + " a0[%" PRId32 "] a1[%" PRId32 "] a2[%" PRId32 "] b00[%" PRId32 "]\n" " bt1[%lld] bt2[%lld] bp1[%lld] b11[%lld]\n" " bp2[%lld] b12[%lld] b21[%lld] bp3[%lld]", qmp6988_data_.ik.a0, qmp6988_data_.ik.a1, qmp6988_data_.ik.a2, qmp6988_data_.ik.b00, qmp6988_data_.ik.bt1, diff --git a/esphome/components/rd03d/rd03d.cpp b/esphome/components/rd03d/rd03d.cpp index d47347fcfa..c9c6a546ab 100644 --- a/esphome/components/rd03d/rd03d.cpp +++ b/esphome/components/rd03d/rd03d.cpp @@ -1,6 +1,8 @@ #include "rd03d.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" + +#include #include namespace esphome::rd03d { @@ -56,7 +58,7 @@ void RD03DComponent::dump_config() { *this->tracking_mode_ == TrackingMode::SINGLE_TARGET ? "single" : "multi"); } if (this->throttle_ > 0) { - ESP_LOGCONFIG(TAG, " Throttle: %ums", this->throttle_); + ESP_LOGCONFIG(TAG, " Throttle: %" PRIu32 "ms", this->throttle_); } #ifdef USE_SENSOR LOG_SENSOR(" ", "Target Count", this->target_count_sensor_); diff --git a/esphome/components/remote_base/symphony_protocol.cpp b/esphome/components/remote_base/symphony_protocol.cpp index f30a980d91..6844e449ed 100644 --- a/esphome/components/remote_base/symphony_protocol.cpp +++ b/esphome/components/remote_base/symphony_protocol.cpp @@ -1,6 +1,8 @@ #include "symphony_protocol.h" #include "esphome/core/log.h" +#include + namespace esphome { namespace remote_base { @@ -26,8 +28,8 @@ static constexpr uint32_t INTER_FRAME_GAP_US = 34760; void SymphonyProtocol::encode(RemoteTransmitData *dst, const SymphonyData &data) { dst->set_carrier_frequency(CARRIER_FREQUENCY); - ESP_LOGD(TAG, "Sending Symphony: data=0x%0*X nbits=%u repeats=%u", (data.nbits + 3) / 4, (uint32_t) data.data, - data.nbits, data.repeats); + ESP_LOGD(TAG, "Sending Symphony: data=0x%0*" PRIX32 " nbits=%" PRIu8 " repeats=%" PRIu8, (data.nbits + 3) / 4, + (uint32_t) data.data, data.nbits, data.repeats); // Each bit produces a mark+space (2 entries). We fold the inter-frame/footer gap // into the last bit's space of each frame to avoid over-length gaps. dst->reserve(data.nbits * 2u * data.repeats); @@ -112,8 +114,8 @@ optional SymphonyProtocol::decode(RemoteReceiveData src) { } void SymphonyProtocol::dump(const SymphonyData &data) { - const int32_t hex_width = (data.nbits + 3) / 4; // pad to nibble width - ESP_LOGI(TAG, "Received Symphony: data=0x%0*X, nbits=%d", hex_width, (uint32_t) data.data, data.nbits); + const int hex_width = (data.nbits + 3) / 4; // pad to nibble width + ESP_LOGI(TAG, "Received Symphony: data=0x%0*" PRIX32 ", nbits=%" PRIu8, hex_width, (uint32_t) data.data, data.nbits); } } // namespace remote_base diff --git a/esphome/components/runtime_image/bmp_decoder.cpp b/esphome/components/runtime_image/bmp_decoder.cpp index 174f924b28..6a1bd61d86 100644 --- a/esphome/components/runtime_image/bmp_decoder.cpp +++ b/esphome/components/runtime_image/bmp_decoder.cpp @@ -6,6 +6,8 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include + namespace esphome::runtime_image { static const char *const TAG = "image_decoder.bmp"; @@ -107,7 +109,7 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) { } if (this->compression_method_ != 0) { - ESP_LOGE(TAG, "Unsupported compression method: %d", this->compression_method_); + ESP_LOGE(TAG, "Unsupported compression method: %" PRIu32, this->compression_method_); return DECODE_ERROR_UNSUPPORTED_FORMAT; } diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index f3c256c62a..04c94e9292 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -3,6 +3,8 @@ #ifdef USE_SERIAL_PROXY #include "esphome/core/log.h" + +#include #include "esphome/core/util.h" #ifdef USE_API @@ -74,7 +76,7 @@ void __attribute__((noinline)) SerialProxy::read_and_send_(size_t available) { void SerialProxy::dump_config() { ESP_LOGCONFIG(TAG, - "Serial Proxy [%u]:\n" + "Serial Proxy [%" PRIu32 "]:\n" " Name: %s\n" " Port Type: %s\n" " RTS Pin: %s\n" @@ -89,7 +91,9 @@ void SerialProxy::dump_config() { void SerialProxy::configure(uint32_t baudrate, bool flow_control, uint8_t parity, uint8_t stop_bits, uint8_t data_size) { - ESP_LOGD(TAG, "Configuring serial proxy [%u]: baud=%u, flow_ctrl=%s, parity=%u, stop=%u, data=%u", + ESP_LOGD(TAG, + "Configuring serial proxy [%" PRIu32 "]: baud=%" PRIu32 ", flow_ctrl=%s, parity=%" PRIu8 ", stop=%" PRIu8 + ", data=%" PRIu8, this->instance_index_, baudrate, YESNO(flow_control), parity, stop_bits, data_size); auto *uart_comp = this->parent_; @@ -148,7 +152,7 @@ void SerialProxy::write_from_client(const uint8_t *data, size_t len) { void SerialProxy::set_modem_pins(uint32_t line_states) { const bool rts = (line_states & SERIAL_PROXY_LINE_STATE_FLAG_RTS) != 0; const bool dtr = (line_states & SERIAL_PROXY_LINE_STATE_FLAG_DTR) != 0; - ESP_LOGV(TAG, "Setting modem pins [%u]: RTS=%s, DTR=%s", this->instance_index_, ONOFF(rts), ONOFF(dtr)); + ESP_LOGV(TAG, "Setting modem pins [%" PRIu32 "]: RTS=%s, DTR=%s", this->instance_index_, ONOFF(rts), ONOFF(dtr)); if (this->rts_pin_ != nullptr) { this->rts_state_ = rts; @@ -161,12 +165,12 @@ void SerialProxy::set_modem_pins(uint32_t line_states) { } uint32_t SerialProxy::get_modem_pins() const { - return (this->rts_state_ ? SERIAL_PROXY_LINE_STATE_FLAG_RTS : 0u) | - (this->dtr_state_ ? SERIAL_PROXY_LINE_STATE_FLAG_DTR : 0u); + return (this->rts_state_ ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) | + (this->dtr_state_ ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u); } uart::UARTFlushResult SerialProxy::flush_port() { - ESP_LOGV(TAG, "Flushing serial proxy [%u]", this->instance_index_); + ESP_LOGV(TAG, "Flushing serial proxy [%" PRIu32 "]", this->instance_index_); return this->flush(); } @@ -180,19 +184,19 @@ void SerialProxy::serial_proxy_request(api::APIConnection *api_connection, api:: } this->api_connection_ = api_connection; this->enable_loop(); - ESP_LOGV(TAG, "API connection subscribed to serial proxy [%u]", this->instance_index_); + ESP_LOGV(TAG, "API connection subscribed to serial proxy [%" PRIu32 "]", this->instance_index_); break; case api::enums::SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE: if (this->api_connection_ != api_connection) { - ESP_LOGV(TAG, "API connection is not subscribed to serial proxy [%u]", this->instance_index_); + ESP_LOGV(TAG, "API connection is not subscribed to serial proxy [%" PRIu32 "]", this->instance_index_); return; } this->api_connection_ = nullptr; this->disable_loop(); - ESP_LOGV(TAG, "API connection unsubscribed from serial proxy [%u]", this->instance_index_); + ESP_LOGV(TAG, "API connection unsubscribed from serial proxy [%" PRIu32 "]", this->instance_index_); break; default: - ESP_LOGW(TAG, "Unknown serial proxy request type: %u", static_cast(type)); + ESP_LOGW(TAG, "Unknown serial proxy request type: %" PRIu32, static_cast(type)); break; } } diff --git a/esphome/components/spa06_base/spa06_base.cpp b/esphome/components/spa06_base/spa06_base.cpp index 36268aa9a2..b0490628cb 100644 --- a/esphome/components/spa06_base/spa06_base.cpp +++ b/esphome/components/spa06_base/spa06_base.cpp @@ -1,5 +1,7 @@ #include "spa06_base.h" +#include + #include "esphome/core/helpers.h" namespace esphome::spa06_base { @@ -195,7 +197,7 @@ bool SPA06Component::read_coefficients_() { ESP_LOGV(TAG, "Coefficients:\n" " c0: %i, c1: %i,\n" - " c00: %i, c10: %i, c20: %i, c30: %i, c40: %i,\n" + " c00: %" PRIi32 ", c10: %" PRIi32 ", c20: %i, c30: %i, c40: %i,\n" " c01: %i, c11: %i, c21: %i, c31: %i", this->c0_, this->c1_, this->c00_, this->c10_, this->c20_, this->c30_, this->c40_, this->c01_, this->c11_, this->c21_, this->c31_); diff --git a/esphome/components/sps30/sps30.cpp b/esphome/components/sps30/sps30.cpp index dbb44743d2..e4fc4ffd31 100644 --- a/esphome/components/sps30/sps30.cpp +++ b/esphome/components/sps30/sps30.cpp @@ -2,6 +2,8 @@ #include "esphome/core/log.h" #include "sps30.h" +#include + namespace esphome { namespace sps30 { @@ -105,7 +107,7 @@ void SPS30Component::dump_config() { " Firmware version v%0d.%0d", this->serial_number_, this->raw_firmware_version_ >> 8, this->raw_firmware_version_ & 0xFF); if (this->idle_interval_.has_value()) { - ESP_LOGCONFIG(TAG, " Idle interval: %us", this->idle_interval_.value() / 1000); + ESP_LOGCONFIG(TAG, " Idle interval: %" PRIu32 "s", this->idle_interval_.value() / 1000); } LOG_SENSOR(" ", "PM1.0 Weight Concentration", this->pm_1_0_sensor_); LOG_SENSOR(" ", "PM2.5 Weight Concentration", this->pm_2_5_sensor_); @@ -142,8 +144,8 @@ void SPS30Component::update() { // If its not time to take an action, do nothing. const uint32_t update_start_ms = millis(); if (this->next_state_ != NONE && (int32_t) (this->next_state_ms_ - update_start_ms) > 0) { - ESP_LOGD(TAG, "Sensor waiting for %ums before transitioning to state %d.", (this->next_state_ms_ - update_start_ms), - this->next_state_); + ESP_LOGD(TAG, "Sensor waiting for %" PRIu32 "ms before transitioning to state %d.", + (this->next_state_ms_ - update_start_ms), this->next_state_); return; } diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index eb3e756bc2..d979359c1f 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -2,6 +2,7 @@ #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include namespace esphome::thermostat { @@ -1346,15 +1347,16 @@ void ThermostatClimate::set_timer_duration_in_sec_(ThermostatClimateTimerIndex t if (elapsed >= new_duration_ms) { // Timer should complete immediately (including when new_duration_ms is 0) - ESP_LOGVV(TAG, "timer %d completing immediately (elapsed %d >= new %d)", timer_index, elapsed, new_duration_ms); + ESP_LOGVV(TAG, "timer %d completing immediately (elapsed %" PRIu32 " >= new %" PRIu32 ")", timer_index, elapsed, + new_duration_ms); this->timer_[timer_index].active = false; // Trigger the timer callback immediately this->call_timer_callback_(timer_index); return; } else { // Adjust timer to run for remaining time - keep original start time - ESP_LOGVV(TAG, "timer %d adjusted: elapsed %d, new total %d, remaining %d", timer_index, elapsed, new_duration_ms, - new_duration_ms - elapsed); + ESP_LOGVV(TAG, "timer %d adjusted: elapsed %" PRIu32 ", new total %" PRIu32 ", remaining %" PRIu32, timer_index, + elapsed, new_duration_ms, new_duration_ms - elapsed); this->timer_[timer_index].time = new_duration_ms; return; } diff --git a/esphome/components/tormatic/tormatic_cover.cpp b/esphome/components/tormatic/tormatic_cover.cpp index 37a269088e..77c2e87717 100644 --- a/esphome/components/tormatic/tormatic_cover.cpp +++ b/esphome/components/tormatic/tormatic_cover.cpp @@ -1,3 +1,4 @@ +#include #include #include "tormatic_cover.h" @@ -120,11 +121,11 @@ void Tormatic::recalibrate_duration_(GateStatus s) { if (s == OPENED) { this->open_duration_ = now - this->direction_start_time_; - ESP_LOGI(TAG, "Recalibrated the gate's open duration to %dms", this->open_duration_); + ESP_LOGI(TAG, "Recalibrated the gate's open duration to %" PRIu32 "ms", this->open_duration_); } if (s == CLOSED) { this->close_duration_ = now - this->direction_start_time_; - ESP_LOGI(TAG, "Recalibrated the gate's close duration to %dms", this->close_duration_); + ESP_LOGI(TAG, "Recalibrated the gate's close duration to %" PRIu32 "ms", this->close_duration_); } this->direction_start_time_ = 0; @@ -269,7 +270,7 @@ optional Tormatic::read_gate_status_() { switch (hdr.type) { case STATUS: { if (hdr.payload_size() != sizeof(StatusReply)) { - ESP_LOGE(TAG, "Header specifies payload size %d but size of StatusReply is %d", hdr.payload_size(), + ESP_LOGE(TAG, "Header specifies payload size %" PRIu32 " but size of StatusReply is %zu", hdr.payload_size(), sizeof(StatusReply)); } @@ -294,7 +295,7 @@ optional Tormatic::read_gate_status_() { default: // Unknown message type, drain the remaining amount of bytes specified in // the header. - ESP_LOGE(TAG, "Reading remaining %d payload bytes of unknown type 0x%x", hdr.payload_size(), hdr.type); + ESP_LOGE(TAG, "Reading remaining %" PRIu32 " payload bytes of unknown type 0x%x", hdr.payload_size(), hdr.type); break; } @@ -339,7 +340,7 @@ template optional Tormatic::read_data_() { } obj.byteswap(); - ESP_LOGV(TAG, "Read %s in %d ms", obj.print().c_str(), millis() - start); + ESP_LOGV(TAG, "Read %s in %" PRIu32 " ms", obj.print().c_str(), millis() - start); return obj; } diff --git a/esphome/components/tormatic/tormatic_protocol.h b/esphome/components/tormatic/tormatic_protocol.h index 26a634b630..269b63ff78 100644 --- a/esphome/components/tormatic/tormatic_protocol.h +++ b/esphome/components/tormatic/tormatic_protocol.h @@ -1,5 +1,7 @@ #pragma once +#include + #include "esphome/components/cover/cover.h" /** @@ -86,7 +88,7 @@ struct MessageHeader { std::string print() { // 64 bytes: "MessageHeader: seq " + uint16 + ", len " + uint32 + ", type " + type + safety margin char buf[64]; - buf_append_printf(buf, sizeof(buf), 0, "MessageHeader: seq %d, len %d, type %s", this->seq, this->len, + buf_append_printf(buf, sizeof(buf), 0, "MessageHeader: seq %d, len %" PRIu32 ", type %s", this->seq, this->len, message_type_to_str(this->type)); return buf; } diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index cd77cd1189..6d9d44e97f 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -296,7 +296,7 @@ void IDFUARTComponent::set_rx_timeout(size_t rx_timeout) { void IDFUARTComponent::write_array(const uint8_t *data, size_t len) { int32_t write_len = uart_write_bytes(this->uart_num_, data, len); if (write_len != (int32_t) len) { - ESP_LOGW(TAG, "uart_write_bytes failed: %d != %zu", write_len, len); + ESP_LOGW(TAG, "uart_write_bytes failed: %" PRId32 " != %zu", write_len, len); this->mark_failed(); } #ifdef USE_UART_DEBUGGER diff --git a/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.cpp b/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.cpp index 3eae4d2d96..512a258122 100644 --- a/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.cpp +++ b/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.cpp @@ -3,6 +3,8 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include + namespace esphome { namespace uponor_smatrix { @@ -10,7 +12,7 @@ static const char *const TAG = "uponor_smatrix.climate"; void UponorSmatrixClimate::dump_config() { LOG_CLIMATE("", "Uponor Smatrix Climate", this); - ESP_LOGCONFIG(TAG, " Device address: 0x%08X", this->address_); + ESP_LOGCONFIG(TAG, " Device address: 0x%08" PRIX32, this->address_); } void UponorSmatrixClimate::loop() { diff --git a/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.cpp b/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.cpp index 7ee12edcdb..5f690a6879 100644 --- a/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.cpp +++ b/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.cpp @@ -1,6 +1,8 @@ #include "uponor_smatrix_sensor.h" #include "esphome/core/log.h" +#include + namespace esphome { namespace uponor_smatrix { @@ -9,7 +11,7 @@ static const char *const TAG = "uponor_smatrix.sensor"; void UponorSmatrixSensor::dump_config() { ESP_LOGCONFIG(TAG, "Uponor Smatrix Sensor\n" - " Device address: 0x%08X", + " Device address: 0x%08" PRIX32, this->address_); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "External Temperature", this->external_temperature_sensor_); diff --git a/esphome/components/uponor_smatrix/uponor_smatrix.cpp b/esphome/components/uponor_smatrix/uponor_smatrix.cpp index 4c3a4b05df..1fd53955a0 100644 --- a/esphome/components/uponor_smatrix/uponor_smatrix.cpp +++ b/esphome/components/uponor_smatrix/uponor_smatrix.cpp @@ -3,6 +3,8 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include + namespace esphome { namespace uponor_smatrix { @@ -24,7 +26,7 @@ void UponorSmatrixComponent::dump_config() { #ifdef USE_TIME if (this->time_id_ != nullptr) { ESP_LOGCONFIG(TAG, " Time synchronization: YES"); - ESP_LOGCONFIG(TAG, " Time master device address: 0x%08X", this->time_device_address_); + ESP_LOGCONFIG(TAG, " Time master device address: 0x%08" PRIX32 "", this->time_device_address_); } #endif @@ -33,7 +35,7 @@ void UponorSmatrixComponent::dump_config() { if (!this->unknown_devices_.empty()) { ESP_LOGCONFIG(TAG, " Detected unknown device addresses:"); for (auto device_address : this->unknown_devices_) { - ESP_LOGCONFIG(TAG, " 0x%08X", device_address); + ESP_LOGCONFIG(TAG, " 0x%08" PRIX32 "", device_address); } } } @@ -103,14 +105,14 @@ bool UponorSmatrixComponent::parse_byte_(uint8_t byte) { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char hex_buf[format_hex_size(UPONOR_MAX_LOG_BYTES)]; #endif - ESP_LOGV(TAG, "Received packet: addr=%08X, data=%s, crc=%04X", device_address, + ESP_LOGV(TAG, "Received packet: addr=%08" PRIX32 ", data=%s, crc=%04X", device_address, format_hex_to(hex_buf, &packet[4], packet_len - 6), crc); // Handle packet size_t data_len = (packet_len - 6) / 3; if (data_len == 0) { if (packet[4] == UPONOR_ID_REQUEST) - ESP_LOGVV(TAG, "Ignoring request packet for device 0x%08X", device_address); + ESP_LOGVV(TAG, "Ignoring request packet for device 0x%08" PRIX32 "", device_address); return true; } @@ -135,7 +137,7 @@ bool UponorSmatrixComponent::parse_byte_(uint8_t byte) { if (data[i].id == UPONOR_ID_DATETIME1) found_time = true; if (found_temperature && found_time) { - ESP_LOGI(TAG, "Using detected time device address 0x%08X", device_address); + ESP_LOGI(TAG, "Using detected time device address 0x%08" PRIX32 "", device_address); this->time_device_address_ = device_address; break; } @@ -154,7 +156,7 @@ bool UponorSmatrixComponent::parse_byte_(uint8_t byte) { // Log unknown device addresses if (!found && !this->unknown_devices_.count(device_address)) { - ESP_LOGI(TAG, "Received packet for unknown device address 0x%08X ", device_address); + ESP_LOGI(TAG, "Received packet for unknown device address 0x%08" PRIX32 " ", device_address); this->unknown_devices_.insert(device_address); } diff --git a/esphome/components/vl53l0x/vl53l0x_sensor.cpp b/esphome/components/vl53l0x/vl53l0x_sensor.cpp index 8a76ed7760..58b5a42675 100644 --- a/esphome/components/vl53l0x/vl53l0x_sensor.cpp +++ b/esphome/components/vl53l0x/vl53l0x_sensor.cpp @@ -1,6 +1,8 @@ #include "vl53l0x_sensor.h" #include "esphome/core/log.h" +#include + /* * Most of the code in this integration is based on the VL53L0x library * by Pololu (Pololu Corporation), which in turn is based on the VL53L0X @@ -28,8 +30,8 @@ void VL53L0XSensor::dump_config() { LOG_PIN(" Enable Pin: ", this->enable_pin_); } ESP_LOGCONFIG(TAG, - " Timeout: %u%s\n" - " Timing Budget %uus ", + " Timeout: %" PRIu32 "%s\n" + " Timing Budget %" PRIu32 "us ", this->timeout_us_, this->timeout_us_ > 0 ? "us" : " (no timeout)", this->measurement_timing_budget_us_); } diff --git a/esphome/components/water_heater/water_heater.cpp b/esphome/components/water_heater/water_heater.cpp index 9a74877f0a..9ee8faadee 100644 --- a/esphome/components/water_heater/water_heater.cpp +++ b/esphome/components/water_heater/water_heater.cpp @@ -1,5 +1,7 @@ #include "water_heater.h" #include "esphome/core/log.h" + +#include #include "esphome/core/application.h" #include "esphome/core/controller_registry.h" #include "esphome/core/progmem.h" @@ -110,7 +112,8 @@ void WaterHeaterCall::validate_() { auto traits = this->parent_->get_traits(); if (this->mode_.has_value()) { if (!traits.supports_mode(*this->mode_)) { - ESP_LOGW(TAG, "'%s' - Mode %d not supported", this->parent_->get_name().c_str(), *this->mode_); + ESP_LOGW(TAG, "'%s' - Mode %" PRIu32 " not supported", this->parent_->get_name().c_str(), + static_cast(*this->mode_)); this->mode_.reset(); } } diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index ad4357663f..7653d2b678 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -3,6 +3,8 @@ #ifdef USE_API #include "esphome/components/api/api_server.h" + +#include #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -160,7 +162,7 @@ void ZWaveProxy::zwave_proxy_request(api::APIConnection *api_connection, api::en break; default: - ESP_LOGW(TAG, "Unknown request type: %d", type); + ESP_LOGW(TAG, "Unknown request type: %" PRIu32, static_cast(type)); break; } } From ef65e47bc58ef6df76506be39060ce9114d9e3ca Mon Sep 17 00:00:00 2001 From: Guillermo Ruffino Date: Mon, 30 Mar 2026 21:08:50 -0300 Subject: [PATCH 22/72] [schema] generator fixes (#15276) --- esphome/components/sensor/__init__.py | 4 + esphome/config_validation.py | 4 + script/build_language_schema.py | 206 ++++++++++++++++++-------- 3 files changed, 151 insertions(+), 63 deletions(-) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 650f5ed826..626466eefa 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -113,6 +113,7 @@ from esphome.core.entity_helpers import ( setup_unit_of_measurement, ) from esphome.cpp_generator import MockObj, MockObjClass +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.util import Registry CODEOWNERS = ["@esphome/core"] @@ -229,7 +230,10 @@ _SENSOR_ENTITY_CATEGORIES = { } +@schema_extractor("enum") def sensor_entity_category(value): + if value == SCHEMA_EXTRACT: + return _SENSOR_ENTITY_CATEGORIES return cv.enum(_SENSOR_ENTITY_CATEGORIES, lower=True)(value) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 56f255a076..45d2cd8117 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -417,10 +417,14 @@ def icon(value): return value +@schema_extractor("use_id") def sub_device_id(value: str | None) -> core.ID | None: # Lazy import to avoid circular imports from esphome.core.config import Device + if value == SCHEMA_EXTRACT: + return Device + if not value: return None diff --git a/script/build_language_schema.py b/script/build_language_schema.py index bea540dc63..09ff999901 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -67,19 +67,16 @@ def get_component_names(): # pylint: disable-next=redefined-outer-name,reimported from esphome.loader import CORE_COMPONENTS_PATH - component_names = ["esphome", "sensor", "esp32", "esp8266"] - skip_components = [] - - for d in CORE_COMPONENTS_PATH.iterdir(): - if ( - not d.name.startswith("__") - and d.is_dir() - and d.name not in component_names - and d.name not in skip_components - ): - component_names.append(d.name) - - return sorted(component_names) + # return sorted( + # ["esphome", "sensor", "esp32", "esp8266", "adc", "touchscreen", "xpt2046"] + # ) + return sorted( + [ + d.name + for d in CORE_COMPONENTS_PATH.iterdir() + if not d.name.startswith("__") and d.is_dir() + ] + ) def load_components(): @@ -120,39 +117,57 @@ from esphome.util import Registry # noqa: E402 # pylint: enable=wrong-import-position +def sort_obj(obj): + if isinstance(obj, dict): + return {k: sort_obj(v) for k, v in sorted(obj.items(), key=lambda x: str(x[0]))} + if isinstance(obj, list): + return [sort_obj(item) for item in obj] + return obj + + def write_file(name, obj): full_path = Path(args.output_path) / f"{name}.json" + sorted_obj = sort_obj(obj) if JSON_DUMP_PRETTY: - json_str = json.dumps(obj, indent=2) + json_str = json.dumps(sorted_obj, indent=2) else: - json_str = json.dumps(obj, separators=(",", ":")) + json_str = json.dumps(sorted_obj, separators=(",", ":")) write_file_if_changed(full_path, json_str) - print(f"Wrote {full_path}") def delete_extra_files(keep_names): output_path = Path(args.output_path) + count = 0 for d in output_path.iterdir(): if d.suffix == ".json" and d.stem not in keep_names: + count += 1 d.unlink() - print(f"Deleted {d}") + return count def register_module_schemas(key, module, manifest=None): + count = 0 for name, schema in module_schemas(module): + count += 1 register_known_schema(key, name, schema) - if manifest and manifest.multi_conf and S_CONFIG_SCHEMA in output[key][S_SCHEMAS]: + if ( + manifest + and manifest.multi_conf + and key in output + and S_CONFIG_SCHEMA in output[key][S_SCHEMAS] + ): # Multi conf should allow list of components # not sure about 2nd part of the if, might be useless config (e.g. as3935) output[key][S_SCHEMAS][S_CONFIG_SCHEMA]["is_list"] = True + return count def register_known_schema(module, name, schema): if module not in output: output[module] = {S_SCHEMAS: {}} config = convert_config(schema, f"{module}/{name}") - if S_TYPE not in config: + if S_TYPE not in config and name != "FINAL_VALIDATE_SCHEMA" and module != "core": print(f"Config var without type: {module}.{name}") output[module][S_SCHEMAS][name] = config @@ -175,14 +190,23 @@ def module_schemas(module): except OSError: # some empty __init__ files module_str = "" - schemas = {} + schemas = [] for m_attr_name in dir(module): m_attr_obj = getattr(module, m_attr_name) if is_convertible_schema(m_attr_obj): - schemas[module_str.find(m_attr_name)] = [m_attr_name, m_attr_obj] + # Find where the name is assigned in the module source to preserve + # definition order. Using ^NAME\s*= (multiline) targets assignments + # at column 0, so "CONFIG_SCHEMA" won't collide with "CONFIG_SCHEMA_BASE". + match = re.search( + r"^" + re.escape(m_attr_name) + r"\s*=", + module_str, + re.MULTILINE, + ) + pos = match.start() if match else -1 + schemas.append((pos, m_attr_name, m_attr_obj)) - for pos in sorted(schemas.keys()): - yield schemas[pos] + for _, name, obj in sorted(schemas, key=lambda x: x[0]): + yield name, obj found_registries = {} @@ -240,9 +264,16 @@ def add_module_registries(domain, module): if len(parts) == 2: reg_domain = parts[0] reg_entry_name = parts[1] - else: - reg_domain = ".".join([parts[1], parts[0]]) - reg_entry_name = parts[2] + elif len(parts) == 3: + # is a platform or a component? + if parts[0] in schema_core[S_PLATFORMS]: + reg_domain = ".".join([parts[1], parts[0]]) + reg_entry_name = parts[2] + elif parts[0] in schema_core[S_COMPONENTS]: + reg_domain = parts[0] + reg_entry_name = ".".join([parts[1], parts[2]]) + else: + print(f"registry {name} is unknown") if reg_domain not in output: output[reg_domain] = {} @@ -252,8 +283,6 @@ def add_module_registries(domain, module): attr_obj[name].schema, f"{reg_domain}/{reg_type}/{reg_entry_name}" ) - # print(f"{domain} - {attr_name} - {name}") - def do_pins(): # do pin registries @@ -330,6 +359,35 @@ def fix_font(): ) +def fix_globals(): + if "globals" not in output: + return + from esphome.components.globals import _NON_RESTORING_SCHEMA + + config = convert_config(_NON_RESTORING_SCHEMA, "globals/CONFIG_SCHEMA") + config["is_list"] = True + output["globals"][S_SCHEMAS][S_CONFIG_SCHEMA] = config + + +def fix_mapping(): + if "mapping" not in output: + return + from esphome.components.mapping import BASE_SCHEMA + + config = convert_config(BASE_SCHEMA, "mapping/CONFIG_SCHEMA") + output["mapping"][S_SCHEMAS][S_CONFIG_SCHEMA] = config + + +def fix_image(): + if "image" not in output: + return + from esphome.components.image import IMAGE_SCHEMA + + config = convert_config(IMAGE_SCHEMA, "image/CONFIG_SCHEMA") + config["is_list"] = True + output["image"][S_SCHEMAS][S_CONFIG_SCHEMA] = config + + def fix_menu(): if "display_menu_base" not in output: return @@ -355,7 +413,7 @@ def fix_menu(): # 4. Configure menu items inside as recursive menu = schemas["MENU_TYPES"][S_SCHEMA][S_CONFIG_VARS]["items"]["types"]["menu"] menu[S_CONFIG_VARS].pop("items") - menu[S_EXTENDS] = ["display_menu_base.MENU_TYPES"] + menu[S_EXTENDS].append("display_menu_base.MENU_TYPES") def get_logger_tags(): @@ -531,7 +589,6 @@ def shrink(): else: arr_s.pop(S_EXTENDS) arr_s |= key_s[S_SCHEMA] - print(x) # simple types should be spread on each component, # for enums so far these are logger.is_log_level, cover.validate_cover_state and pulse_counter.sensor.COUNT_MODE_SCHEMA @@ -580,6 +637,10 @@ def shrink(): domain_schemas[S_SCHEMAS].pop(schema_name) +def is_cv_invalid(schema): + return repr(schema).startswith(".validator") + + def build_schema(): print("Building schema") @@ -610,7 +671,8 @@ def build_schema(): output[domain] = {S_COMPONENTS: {}, S_SCHEMAS: {}} platforms[domain] = {} elif manifest.config_schema is not None: - # e.g. dallas + if is_cv_invalid(manifest.config_schema): + continue output[domain] = {S_SCHEMAS: {S_CONFIG_SCHEMA: {}}} # Generate platforms (e.g. sensor, binary_sensor, climate ) @@ -621,7 +683,9 @@ def build_schema(): # Generate components for domain, manifest in components.items(): if domain not in platforms: - if manifest.config_schema is not None: + if manifest.config_schema is not None and not is_cv_invalid( + manifest.config_schema + ): core_components[domain] = {} if len(manifest.dependencies) > 0: core_components[domain]["dependencies"] = manifest.dependencies @@ -630,14 +694,15 @@ def build_schema(): for platform in platforms: platform_manifest = get_platform(domain=platform, platform=domain) if platform_manifest is not None: - output[platform][S_COMPONENTS][domain] = {} - if len(platform_manifest.dependencies) > 0: - output[platform][S_COMPONENTS][domain]["dependencies"] = ( - platform_manifest.dependencies - ) - register_module_schemas( + count = register_module_schemas( f"{domain}.{platform}", platform_manifest.module, platform_manifest ) + if count > 0: + output[platform][S_COMPONENTS].setdefault(domain, {}) + if len(platform_manifest.dependencies) > 0: + output[platform][S_COMPONENTS][domain]["dependencies"] = ( + platform_manifest.dependencies + ) # Do registries add_module_registries("core", automation) @@ -657,6 +722,9 @@ def build_schema(): fix_remote_receiver() fix_script() fix_font() + fix_globals() + fix_mapping() + fix_image() add_logger_tags() shrink() fix_menu() @@ -677,12 +745,19 @@ def build_schema(): # bundle core inside esphome data["esphome"]["core"] = data.pop("core")["core"] + if GENERATED_ID_TYPES: + print( + "Unconsumed id_type matchers:", + [id_type for _, id_type in GENERATED_ID_TYPES], + ) + if args.check: # do not gen files return for c, s in data.items(): write_file(c, s) - delete_extra_files(data.keys()) + deleted = delete_extra_files(data.keys()) + print(f"Written {len(data.items())} deleted {deleted} files.") def is_convertible_schema(schema): @@ -711,6 +786,30 @@ def convert_config(schema, path): return converted +GENERATED_ID_TYPES = [ + ( + lambda p: p.startswith("i2c/CONFIG_SCHEMA/") and p.endswith("/id"), + {"class": "i2c::I2CBus", "parents": ["Component"]}, + ), + ( + lambda p: p == "uart/CONFIG_SCHEMA/val 1/ext0/all/id", + {"class": "uart::UARTComponent", "parents": ["Component"]}, + ), + ( + lambda p: p == "http_request/CONFIG_SCHEMA/val 1/ext0/all/id", + {"class": "http_request::HttpRequestComponent", "parents": ["Component"]}, + ), + ( + lambda p: ( + p + == "uptime.sensor/CONFIG_SCHEMA/type_timestamp/ext0/ext1/all/time_id/val 1" + ), + {}, + ), + (lambda p: p == "esp_ldo/action/voltage.adjust/all/all/id", {}), +] + + def convert(schema, config_var, path): """config_var can be a config_var or a schema: both are dicts config_var has a S_TYPE property, if this is S_SCHEMA, then it has a S_SCHEMA property @@ -718,9 +817,6 @@ def convert(schema, config_var, path): """ repr_schema = repr(schema) - if path.startswith("ads1115.sensor") and path.endswith("gain"): - print(path) - if repr_schema in known_schemas: schema_info = known_schemas[(repr_schema)] for schema_instance, name in schema_info: @@ -841,8 +937,6 @@ def convert(schema, config_var, path): schema({"delay": "1s"}) except cv.Invalid: config_var["has_required_var"] = True - else: - print("figure out " + path) elif schema_type == "effects": config_var[S_TYPE] = "registry" config_var["registry"] = "light.effects" @@ -879,8 +973,6 @@ def convert(schema, config_var, path): "id" ]["id_type"]["class"] config_var[S_TYPE] = "use_id" - else: - print("TODO deferred?") elif isinstance(data, str): # TODO: Figure out why pipsolar does this config_var["use_id_type"] = data @@ -890,23 +982,11 @@ def convert(schema, config_var, path): else: raise TypeError("Unknown extracted schema type") elif config_var.get("key") == "GeneratedID": - if path.startswith("i2c/CONFIG_SCHEMA/") and path.endswith("/id"): - config_var["id_type"] = { - "class": "i2c::I2CBus", - "parents": ["Component"], - } - elif path == "uart/CONFIG_SCHEMA/val 1/ext0/all/id": - config_var["id_type"] = { - "class": "uart::UARTComponent", - "parents": ["Component"], - } - elif path == "http_request/CONFIG_SCHEMA/val 1/ext0/all/id": - config_var["id_type"] = { - "class": "http_request::HttpRequestComponent", - "parents": ["Component"], - } - elif path == "pins/esp32/val 1/id": - config_var["id_type"] = "pin" + for i, (matcher, id_type) in enumerate(GENERATED_ID_TYPES): + if matcher(path): + config_var["id_type"] = id_type + GENERATED_ID_TYPES.pop(i) + break else: print("Cannot determine id_type for " + path) From a3913b98ba4d41263d9022a09fb91d21b4747384 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 Mar 2026 17:05:48 -1000 Subject: [PATCH 23/72] [wifi] Move LibreTiny WiFi STA state to member variable (#15305) --- esphome/components/wifi/wifi_component.h | 8 +++--- .../wifi/wifi_component_libretiny.cpp | 25 +++++++++---------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 9a08902d47..665dec37d5 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -818,10 +818,10 @@ class WiFiComponent final : public Component { uint8_t num_ipv6_addresses_{0}; #endif /* USE_NETWORK_IPV6 */ bool error_from_callback_{false}; -#ifdef USE_ESP8266 - // ESP8266WiFiSTAState enum, defined in wifi_component_esp8266.cpp. - // Written from SDK system context (wifi_event_callback) — uint8_t writes - // are atomic on Xtensa LX106 so no synchronization is needed. +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) + // Platform-specific STA state enum, defined in platform cpp file. + // On ESP8266, written from SDK system context (wifi_event_callback) — + // uint8_t writes are atomic on Xtensa LX106 so no synchronization is needed. uint8_t sta_state_{0}; #endif RetryHiddenMode retry_hidden_mode_{RetryHiddenMode::BLIND_RETRY}; diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index b049a0413c..9565ffa747 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -97,8 +97,6 @@ enum class LTWiFiSTAState : uint8_t { ERROR_FAILED, // Connection failed (auth, timeout, etc.) }; -static LTWiFiSTAState s_sta_state = LTWiFiSTAState::IDLE; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) - // Count of ignored disconnect events during connection - too many indicates real failure static uint8_t s_ignored_disconnect_count = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) // Threshold for ignored disconnect events before treating as connection failure @@ -223,7 +221,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { this->wifi_apply_hostname_(); // Reset state machine and disconnect counter before connecting - s_sta_state = LTWiFiSTAState::CONNECTING; + this->sta_state_ = static_cast(LTWiFiSTAState::CONNECTING); s_ignored_disconnect_count = 0; WiFiStatus status = WiFi.begin(ap.ssid_.c_str(), ap.password_.empty() ? NULL : ap.password_.c_str(), @@ -459,7 +457,7 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { } case ESPHOME_EVENT_ID_WIFI_STA_STOP: { ESP_LOGV(TAG, "STA stop"); - s_sta_state = LTWiFiSTAState::IDLE; + this->sta_state_ = static_cast(LTWiFiSTAState::IDLE); break; } case ESPHOME_EVENT_ID_WIFI_STA_CONNECTED: { @@ -479,7 +477,7 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { // For static IP configurations, GOT_IP event may not fire, so set connected state here #ifdef USE_WIFI_MANUAL_IP if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) { - s_sta_state = LTWiFiSTAState::CONNECTED; + this->sta_state_ = static_cast(LTWiFiSTAState::CONNECTED); #ifdef USE_WIFI_IP_STATE_LISTENERS this->notify_ip_state_listeners_(); #endif @@ -501,12 +499,13 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { // Only ignore benign reasons - real failures like NO_AP_FOUND should still be processed. // However, if we get too many of these events (IGNORED_DISCONNECT_THRESHOLD), treat it // as a real connection failure to avoid waiting the full timeout for a failing connection. - if (it.ssid_len == 0 && s_sta_state == LTWiFiSTAState::CONNECTING && it.reason != WIFI_REASON_NO_AP_FOUND) { + if (it.ssid_len == 0 && this->sta_state_ == static_cast(LTWiFiSTAState::CONNECTING) && + it.reason != WIFI_REASON_NO_AP_FOUND) { s_ignored_disconnect_count++; if (s_ignored_disconnect_count >= IGNORED_DISCONNECT_THRESHOLD) { ESP_LOGW(TAG, "Too many disconnect events (%u) while connecting, treating as failure (reason=%s)", s_ignored_disconnect_count, get_disconnect_reason_str(it.reason)); - s_sta_state = LTWiFiSTAState::ERROR_FAILED; + this->sta_state_ = static_cast(LTWiFiSTAState::ERROR_FAILED); WiFi.disconnect(); this->error_from_callback_ = true; // Don't break - fall through to notify listeners @@ -520,13 +519,13 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { if (it.reason == WIFI_REASON_NO_AP_FOUND) { ESP_LOGW(TAG, "Disconnected ssid='%.*s' reason='Probe Request Unsuccessful'", it.ssid_len, (const char *) it.ssid); - s_sta_state = LTWiFiSTAState::ERROR_NOT_FOUND; + this->sta_state_ = static_cast(LTWiFiSTAState::ERROR_NOT_FOUND); } else { char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(it.bssid, bssid_s); ESP_LOGW(TAG, "Disconnected ssid='%.*s' bssid=" LOG_SECRET("%s") " reason='%s'", it.ssid_len, (const char *) it.ssid, bssid_s, get_disconnect_reason_str(it.reason)); - s_sta_state = LTWiFiSTAState::ERROR_FAILED; + this->sta_state_ = static_cast(LTWiFiSTAState::ERROR_FAILED); } uint8_t reason = it.reason; @@ -551,7 +550,7 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { ESP_LOGW(TAG, "Potential Authmode downgrade detected, disconnecting"); WiFi.disconnect(); this->error_from_callback_ = true; - s_sta_state = LTWiFiSTAState::ERROR_FAILED; + this->sta_state_ = static_cast(LTWiFiSTAState::ERROR_FAILED); } break; } @@ -559,7 +558,7 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { char ip_buf[network::IP_ADDRESS_BUFFER_SIZE], gw_buf[network::IP_ADDRESS_BUFFER_SIZE]; ESP_LOGV(TAG, "static_ip=%s gateway=%s", network::IPAddress(WiFi.localIP()).str_to(ip_buf), network::IPAddress(WiFi.gatewayIP()).str_to(gw_buf)); - s_sta_state = LTWiFiSTAState::CONNECTED; + this->sta_state_ = static_cast(LTWiFiSTAState::CONNECTED); #ifdef USE_WIFI_IP_STATE_LISTENERS this->notify_ip_state_listeners_(); #endif @@ -637,7 +636,7 @@ void WiFiComponent::wifi_pre_setup_() { WiFiSTAConnectStatus WiFiComponent::wifi_sta_connect_status_() const { // Use state machine instead of querying WiFi.status() directly // State is updated in main loop from queued events, ensuring thread safety - switch (s_sta_state) { + switch (static_cast(this->sta_state_)) { case LTWiFiSTAState::CONNECTED: return WiFiSTAConnectStatus::CONNECTED; case LTWiFiSTAState::ERROR_NOT_FOUND: @@ -758,7 +757,7 @@ network::IPAddress WiFiComponent::wifi_soft_ap_ip() { return {WiFi.softAPIP()}; bool WiFiComponent::wifi_disconnect_() { // Reset state first so disconnect events aren't ignored // and wifi_sta_connect_status_() returns IDLE instead of CONNECTING - s_sta_state = LTWiFiSTAState::IDLE; + this->sta_state_ = static_cast(LTWiFiSTAState::IDLE); return WiFi.disconnect(); } From ceb3cb2ae797611e73f14f3887df812778600ed6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:22:29 -0400 Subject: [PATCH 24/72] [haier] Fix hOn half-degree temperature setting (#15312) --- esphome/components/haier/hon_climate.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index 1cee95bf16..1e9cb42f38 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -675,7 +675,6 @@ haier_protocol::HaierMessage HonClimate::get_control_message() { this->quiet_mode_state_ = (SwitchState) ((uint8_t) this->quiet_mode_state_ & 0b01); } out_data->beeper_status = ((!this->get_beeper_state()) || (!has_hvac_settings)) ? 1 : 0; - control_out_buffer[4] = 0; // This byte should be cleared before setting values out_data->display_status = this->get_display_state() ? 1 : 0; this->display_status_ = (SwitchState) ((uint8_t) this->display_status_ & 0b01); out_data->health_mode = this->get_health_mode() ? 1 : 0; From c64bc2496093dfd0f107e15473adff8437574cc9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Mar 2026 07:34:54 -1000 Subject: [PATCH 25/72] [preferences] Reduce log verbosity for unchanged NVS/FDB writes (#15332) --- esphome/components/esp32/preferences.cpp | 12 ++++++++---- esphome/components/libretiny/preferences.cpp | 13 +++++++++---- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index e88ace3e6b..bc0a34ebe8 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -129,11 +129,15 @@ bool ESP32Preferences::sync() { } s_pending_save.clear(); - ESP_LOGD(TAG, "Writing %d items: %d cached, %d written, %d failed", cached + written + failed, cached, written, - failed); if (failed > 0) { - ESP_LOGE(TAG, "Writing %d items failed. Last error=%s for key=%" PRIu32, failed, esp_err_to_name(last_err), - last_key); + ESP_LOGE(TAG, "Writing %d items: %d cached, %d written, %d failed. Last error=%s for key=%" PRIu32, + cached + written + failed, cached, written, failed, esp_err_to_name(last_err), last_key); + } else if (written > 0) { + ESP_LOGD(TAG, "Writing %d items: %d cached, %d written, %d failed", cached + written + failed, cached, written, + failed); + } else { + ESP_LOGV(TAG, "Writing %d items: %d cached, %d written, %d failed", cached + written + failed, cached, written, + failed); } // note: commit on esp-idf currently is a no-op, nvs_set_blob always writes diff --git a/esphome/components/libretiny/preferences.cpp b/esphome/components/libretiny/preferences.cpp index 344ca4a8b3..fba6717294 100644 --- a/esphome/components/libretiny/preferences.cpp +++ b/esphome/components/libretiny/preferences.cpp @@ -108,16 +108,21 @@ bool LibreTinyPreferences::sync() { } written++; } else { - ESP_LOGD(TAG, "FDB data not changed; skipping %" PRIu32 " len=%zu", save.key, save.data.size()); + ESP_LOGV(TAG, "FDB data not changed; skipping %" PRIu32 " len=%zu", save.key, save.data.size()); cached++; } } s_pending_save.clear(); - ESP_LOGD(TAG, "Writing %d items: %d cached, %d written, %d failed", cached + written + failed, cached, written, - failed); if (failed > 0) { - ESP_LOGE(TAG, "Writing %d items failed. Last error=%d for key=%" PRIu32, failed, last_err, last_key); + ESP_LOGE(TAG, "Writing %d items: %d cached, %d written, %d failed. Last error=%d for key=%" PRIu32, + cached + written + failed, cached, written, failed, last_err, last_key); + } else if (written > 0) { + ESP_LOGD(TAG, "Writing %d items: %d cached, %d written, %d failed", cached + written + failed, cached, written, + failed); + } else { + ESP_LOGV(TAG, "Writing %d items: %d cached, %d written, %d failed", cached + written + failed, cached, written, + failed); } return failed == 0; From 9b97e95cf3620dc3aad8715e011ec1b89cdbf112 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Mar 2026 07:42:12 -1000 Subject: [PATCH 26/72] [binary_sensor] Add on_multi_click integration test (#15329) --- .../fixtures/multi_click_trigger.yaml | 105 ++++++++++++++++++ tests/integration/test_multi_click_trigger.py | 82 ++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 tests/integration/fixtures/multi_click_trigger.yaml create mode 100644 tests/integration/test_multi_click_trigger.py diff --git a/tests/integration/fixtures/multi_click_trigger.yaml b/tests/integration/fixtures/multi_click_trigger.yaml new file mode 100644 index 0000000000..3bd53d594c --- /dev/null +++ b/tests/integration/fixtures/multi_click_trigger.yaml @@ -0,0 +1,105 @@ +esphome: + name: test-multi-click + +host: +api: + batch_delay: 0ms + services: + - service: run_all_tests + then: + # Prime the binary sensor with an initial OFF state. + # trigger_on_initial_state defaults to false, so the first + # state change from unknown won't fire callbacks. + - binary_sensor.template.publish: + id: test_button + state: false + - delay: 50ms + + # Test 1: Single click (ON < 50ms, OFF >= 30ms) + - binary_sensor.template.publish: + id: test_button + state: true + - delay: 20ms + - binary_sensor.template.publish: + id: test_button + state: false + # Wait for single click trigger (30ms) + cooldown (100ms) + margin + - delay: 200ms + + # Test 2: Double click (ON < 50ms, OFF < 25ms, ON < 50ms, OFF >= 25ms) + - binary_sensor.template.publish: + id: test_button + state: true + - delay: 20ms + - binary_sensor.template.publish: + id: test_button + state: false + - delay: 15ms + - binary_sensor.template.publish: + id: test_button + state: true + - delay: 20ms + - binary_sensor.template.publish: + id: test_button + state: false + # Wait for double click trigger (25ms) + cooldown (100ms) + margin + - delay: 200ms + + # Test 3: Long press (ON >= 80ms) + - binary_sensor.template.publish: + id: test_button + state: true + - delay: 100ms + - binary_sensor.template.publish: + id: test_button + state: false + +logger: + level: VERBOSE + +globals: + - id: single_click_count + type: int + initial_value: "0" + - id: double_click_count + type: int + initial_value: "0" + - id: long_press_count + type: int + initial_value: "0" + +binary_sensor: + - platform: template + name: "Test Button" + id: test_button + on_multi_click: + # Single press + - timing: + - ON for at most 50ms + - OFF for at least 30ms + invalid_cooldown: 100ms + then: + - lambda: |- + id(single_click_count) += 1; + ESP_LOGI("multi_click_test", "SINGLE_CLICK count=%d", id(single_click_count)); + + # Double press + - timing: + - ON for at most 50ms + - OFF for at most 25ms + - ON for at most 50ms + - OFF for at least 25ms + invalid_cooldown: 100ms + then: + - lambda: |- + id(double_click_count) += 1; + ESP_LOGI("multi_click_test", "DOUBLE_CLICK count=%d", id(double_click_count)); + + # Long press + - timing: + - ON for at least 80ms + invalid_cooldown: 100ms + then: + - lambda: |- + id(long_press_count) += 1; + ESP_LOGI("multi_click_test", "LONG_PRESS count=%d", id(long_press_count)); diff --git a/tests/integration/test_multi_click_trigger.py b/tests/integration/test_multi_click_trigger.py new file mode 100644 index 0000000000..8a020dd18b --- /dev/null +++ b/tests/integration/test_multi_click_trigger.py @@ -0,0 +1,82 @@ +"""Integration test for on_multi_click binary sensor automation. + +Tests that on_multi_click correctly triggers for single click, double click, +and long press patterns using a template binary sensor with timing +orchestrated entirely in YAML. + +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_multi_click_trigger( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that on_multi_click triggers for single, double, and long press patterns.""" + loop = asyncio.get_running_loop() + + single_click_pattern = re.compile(r"SINGLE_CLICK count=(\d+)") + double_click_pattern = re.compile(r"DOUBLE_CLICK count=(\d+)") + long_press_pattern = re.compile(r"LONG_PRESS count=(\d+)") + + single_click_future: asyncio.Future[int] = loop.create_future() + double_click_future: asyncio.Future[int] = loop.create_future() + long_press_future: asyncio.Future[int] = loop.create_future() + + def check_output(line: str) -> None: + """Check log output for multi-click trigger messages.""" + if m := single_click_pattern.search(line): + if not single_click_future.done(): + single_click_future.set_result(int(m.group(1))) + elif m := double_click_pattern.search(line): + if not double_click_future.done(): + double_click_future.set_result(int(m.group(1))) + elif (m := long_press_pattern.search(line)) and not long_press_future.done(): + long_press_future.set_result(int(m.group(1))) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + _entities, services = await client.list_entities_services() + + test_service = next((s for s in services if s.name == "run_all_tests"), None) + assert test_service is not None, "run_all_tests service not found" + + # Kick off the entire test sequence (runs in YAML with delays) + await client.execute_service(test_service, {}) + + # Wait for all three triggers + try: + count = await asyncio.wait_for(single_click_future, timeout=5.0) + except TimeoutError: + pytest.fail( + "Timeout waiting for SINGLE_CLICK - on_multi_click did not trigger." + ) + assert count == 1, f"Expected single click count=1, got {count}" + + try: + count = await asyncio.wait_for(double_click_future, timeout=5.0) + except TimeoutError: + pytest.fail( + "Timeout waiting for DOUBLE_CLICK - on_multi_click did not trigger." + ) + assert count == 1, f"Expected double click count=1, got {count}" + + try: + count = await asyncio.wait_for(long_press_future, timeout=5.0) + except TimeoutError: + pytest.fail( + "Timeout waiting for LONG_PRESS - on_multi_click did not trigger." + ) + assert count == 1, f"Expected long press count=1, got {count}" From 2c9a3051d6e90e6a89da2a08d52d93160288c300 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Mar 2026 07:43:18 -1000 Subject: [PATCH 27/72] [api] Use memcpy for fixed32 decode on little-endian platforms (#15292) --- esphome/components/api/proto.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/proto.cpp b/esphome/components/api/proto.cpp index 4f5b3f0918..d9fe0fe461 100644 --- a/esphome/components/api/proto.cpp +++ b/esphome/components/api/proto.cpp @@ -257,7 +257,13 @@ void ProtoDecodableMessage::decode(const uint8_t *buffer, size_t length) { ESP_LOGV(TAG, "Out-of-bounds Fixed32-bit at offset %ld", (long) (ptr - buffer)); return; } - uint32_t val = encode_uint32(ptr[3], ptr[2], ptr[1], ptr[0]); + uint32_t val; +#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ + // Protobuf fixed32 is little-endian — direct load on LE platforms + memcpy(&val, ptr, 4); +#else + val = encode_uint32(ptr[3], ptr[2], ptr[1], ptr[0]); +#endif if (!this->decode_32bit(field_id, Proto32Bit(val))) { ESP_LOGV(TAG, "Cannot decode 32-bit field %" PRIu32 " with value %" PRIu32 "!", field_id, val); } From 2449aa75af91ba01b3b812d5fde43d73eb918d5a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Mar 2026 07:45:23 -1000 Subject: [PATCH 28/72] [http_request] Fix crash when esp_http_client_init fails (#15328) --- .../http_request/http_request_idf.cpp | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index dda61e2400..30f53eecdc 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -17,6 +17,7 @@ namespace esphome::http_request { static const char *const TAG = "http_request.idf"; +static constexpr uint32_t ERROR_DURATION_MS = 1000; struct UserData { const std::vector &lower_case_collect_headers; @@ -57,7 +58,7 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { if (!network::is_connected()) { - this->status_momentary_error("failed", 1000); + this->status_momentary_error("failed", ERROR_DURATION_MS); ESP_LOGE(TAG, "HTTP Request failed; Not connected to network"); return nullptr; } @@ -74,7 +75,7 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c } else if (method == "PATCH") { method_idf = HTTP_METHOD_PATCH; } else { - this->status_momentary_error("failed", 1000); + this->status_momentary_error("failed", ERROR_DURATION_MS); ESP_LOGE(TAG, "HTTP Request failed; Unsupported method"); return nullptr; } @@ -112,6 +113,11 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c config.event_handler = http_event_handler; esp_http_client_handle_t client = esp_http_client_init(&config); + if (client == nullptr) { + this->status_momentary_error("failed", ERROR_DURATION_MS); + ESP_LOGE(TAG, "HTTP Request failed; client could not be initialized"); + return nullptr; + } std::shared_ptr container = std::make_shared(client); container->set_parent(this); @@ -129,7 +135,7 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c esp_err_t err = esp_http_client_open(client, body_len); if (err != ESP_OK) { - this->status_momentary_error("failed", 1000); + this->status_momentary_error("failed", ERROR_DURATION_MS); ESP_LOGE(TAG, "HTTP Request failed: %s", esp_err_to_name(err)); esp_http_client_cleanup(client); return nullptr; @@ -151,7 +157,7 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c } if (err != ESP_OK) { - this->status_momentary_error("failed", 1000); + this->status_momentary_error("failed", ERROR_DURATION_MS); ESP_LOGE(TAG, "HTTP Request failed: %s", esp_err_to_name(err)); esp_http_client_cleanup(client); return nullptr; @@ -176,7 +182,7 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c err = esp_http_client_set_redirection(client); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_http_client_set_redirection failed: %s", esp_err_to_name(err)); - this->status_momentary_error("failed", 1000); + this->status_momentary_error("failed", ERROR_DURATION_MS); esp_http_client_cleanup(client); return nullptr; } @@ -189,7 +195,7 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c err = esp_http_client_open(client, 0); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_http_client_open failed: %s", esp_err_to_name(err)); - this->status_momentary_error("failed", 1000); + this->status_momentary_error("failed", ERROR_DURATION_MS); esp_http_client_cleanup(client); return nullptr; } @@ -214,7 +220,7 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c } ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url.c_str(), container->status_code); - this->status_momentary_error("failed", 1000); + this->status_momentary_error("failed", ERROR_DURATION_MS); return container; } From 26b426bbffd28611d0ff4880b4196c8a0bde4d13 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 31 Mar 2026 14:34:16 -0500 Subject: [PATCH 29/72] [zwave_proxy] Clear Home ID on USB modem disconnect (#15327) --- esphome/components/uart/uart_component.h | 4 + esphome/components/usb_uart/usb_uart.h | 1 + .../components/zwave_proxy/zwave_proxy.cpp | 76 +++++++++++++++++-- esphome/components/zwave_proxy/zwave_proxy.h | 6 ++ 4 files changed, 80 insertions(+), 7 deletions(-) diff --git a/esphome/components/uart/uart_component.h b/esphome/components/uart/uart_component.h index abc77fbae8..afd3ad5777 100644 --- a/esphome/components/uart/uart_component.h +++ b/esphome/components/uart/uart_component.h @@ -85,6 +85,10 @@ class UARTComponent { // @return UARTFlushResult indicating whether the flush was confirmed, timed out, failed, or assumed successful. virtual UARTFlushResult flush() = 0; + // Returns true if the underlying transport is connected and operational. + // Hardware UARTs always return true. USB-backed UARTs override to reflect actual connection state. + virtual bool is_connected() { return true; } + // Sets the maximum time to wait for TX to drain during flush(). // Only meaningful on ESP32 (IDF). Other platforms ignore this value. // @param flush_timeout_ms Timeout in milliseconds; 0 means wait indefinitely. diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 8a47f0cf4b..8e8e65032d 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -140,6 +140,7 @@ class USBUartChannel : public uart::UARTComponent, public Parentedinput_buffer_.get_available(); } + bool is_connected() override { return this->initialised_.load(); } uart::UARTFlushResult flush() override; void check_logger_conflict() override {} void set_parity(UARTParityOptions parity) { this->parity_ = parity; } diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index 7653d2b678..ecb38b25e7 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -22,6 +22,8 @@ static constexpr uint8_t ZWAVE_COMMAND_GET_NETWORK_IDS = 0x20; static constexpr uint8_t ZWAVE_COMMAND_TYPE_RESPONSE = 0x01; // Response type field value static constexpr uint8_t ZWAVE_MIN_GET_NETWORK_IDS_LENGTH = 9; // TYPE + CMD + HOME_ID(4) + NODE_ID + checksum static constexpr uint32_t HOME_ID_TIMEOUT_MS = 100; // Timeout for waiting for home ID during setup +static constexpr uint32_t RECONNECT_DELAY_MS = 500; // Delay between home ID query attempts after reconnect +static constexpr uint8_t MAX_QUERY_RETRIES = 5; // Max attempts to query home ID after reconnect static uint8_t calculate_frame_checksum(const uint8_t *data, uint8_t length) { // Calculate Z-Wave frame checksum @@ -38,7 +40,10 @@ ZWaveProxy::ZWaveProxy() { global_zwave_proxy = this; } void ZWaveProxy::setup() { this->setup_time_ = App.get_loop_component_start_time(); - this->send_simple_command_(ZWAVE_COMMAND_GET_NETWORK_IDS); + this->was_connected_ = this->parent_->is_connected(); + if (this->was_connected_) { + this->send_simple_command_(ZWAVE_COMMAND_GET_NETWORK_IDS); + } } float ZWaveProxy::get_setup_priority() const { @@ -84,6 +89,14 @@ void ZWaveProxy::loop() { this->api_connection_ = nullptr; // Unsubscribe if disconnected } + const bool connected = this->parent_->is_connected(); + if (this->was_connected_ != connected) { + this->on_connection_changed_(connected); + } + if (this->reconnect_time_ != 0) { + this->retry_home_id_query_(); + } + this->process_uart_(); this->status_clear_warning(); } @@ -167,6 +180,55 @@ void ZWaveProxy::zwave_proxy_request(api::APIConnection *api_connection, api::en } } +void ZWaveProxy::on_connection_changed_(bool connected) { + this->was_connected_ = connected; + if (connected) { + ESP_LOGD(TAG, "Modem reconnected"); + this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; + this->buffer_index_ = 0; + this->last_response_ = 0; + this->in_bootloader_ = false; + // Defer the query — the modem needs time to initialize after power is applied + this->reconnect_time_ = App.get_loop_component_start_time(); + this->query_retries_ = 0; + } else { + ESP_LOGW(TAG, "Modem disconnected"); + this->clear_home_id_(); + } +} + +void ZWaveProxy::retry_home_id_query_() { + if (this->home_id_ready_) { + // Got the home ID, cancel remaining retries + this->reconnect_time_ = 0; + return; + } + if (App.get_loop_component_start_time() - this->reconnect_time_ <= RECONNECT_DELAY_MS) { + return; // Not yet time for next attempt + } + this->reconnect_time_ = App.get_loop_component_start_time(); // Reset timer for next retry + this->query_retries_++; + if (this->query_retries_ <= MAX_QUERY_RETRIES) { + ESP_LOGD(TAG, "Querying Home ID (attempt %u)", this->query_retries_); + this->send_simple_command_(ZWAVE_COMMAND_GET_NETWORK_IDS); + } else { + ESP_LOGW(TAG, "Failed to read Home ID after %u attempts", MAX_QUERY_RETRIES); + this->reconnect_time_ = 0; + } +} + +void ZWaveProxy::clear_home_id_() { + static constexpr uint8_t ZERO_HOME_ID[ZWAVE_HOME_ID_SIZE] = {}; + if (this->set_home_id_(ZERO_HOME_ID)) { + this->send_homeid_changed_msg_(); + } + this->home_id_ready_ = false; + this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; + this->buffer_index_ = 0; + this->last_response_ = 0; + this->in_bootloader_ = false; +} + bool ZWaveProxy::set_home_id_(const uint8_t *new_home_id) { if (std::memcmp(this->home_id_.data(), new_home_id, this->home_id_.size()) == 0) { ESP_LOGV(TAG, "Home ID unchanged"); @@ -309,7 +371,7 @@ void ZWaveProxy::parse_start_(uint8_t byte) { this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_START; switch (byte) { case ZWAVE_FRAME_TYPE_START: - ESP_LOGVV(TAG, "Received START"); + ESP_LOGV(TAG, "Received START"); if (this->in_bootloader_) { ESP_LOGD(TAG, "Exited bootloader mode"); this->in_bootloader_ = false; @@ -318,7 +380,7 @@ void ZWaveProxy::parse_start_(uint8_t byte) { this->parsing_state_ = ZWAVE_PARSING_STATE_WAIT_LENGTH; return; case ZWAVE_FRAME_TYPE_BL_MENU: - ESP_LOGVV(TAG, "Received BL_MENU"); + ESP_LOGV(TAG, "Received BL_MENU"); if (!this->in_bootloader_) { ESP_LOGD(TAG, "Entered bootloader mode"); this->in_bootloader_ = true; @@ -327,16 +389,16 @@ void ZWaveProxy::parse_start_(uint8_t byte) { this->parsing_state_ = ZWAVE_PARSING_STATE_READ_BL_MENU; return; case ZWAVE_FRAME_TYPE_BL_BEGIN_UPLOAD: - ESP_LOGVV(TAG, "Received BL_BEGIN_UPLOAD"); + ESP_LOGV(TAG, "Received BL_BEGIN_UPLOAD"); break; case ZWAVE_FRAME_TYPE_ACK: - ESP_LOGVV(TAG, "Received ACK"); + ESP_LOGV(TAG, "Received ACK"); break; case ZWAVE_FRAME_TYPE_NAK: - ESP_LOGW(TAG, "Received NAK"); + ESP_LOGV(TAG, "Received NAK"); break; case ZWAVE_FRAME_TYPE_CAN: - ESP_LOGW(TAG, "Received CAN"); + ESP_LOGV(TAG, "Received CAN"); break; default: ESP_LOGW(TAG, "Unrecognized START: 0x%02X", byte); diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h index 12cb9a90a1..0b810de29f 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.h +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -65,6 +65,9 @@ class ZWaveProxy : public uart::UARTDevice, public Component { protected: bool set_home_id_(const uint8_t *new_home_id); // Store a new home ID. Returns true if it changed. + void clear_home_id_(); // Clear home ID and notify API clients + void on_connection_changed_(bool connected); // Handle modem connect/disconnect transitions + void retry_home_id_query_(); // Retry home ID query after reconnect void send_homeid_changed_msg_(api::APIConnection *conn = nullptr); void send_simple_command_(uint8_t command_id); bool parse_byte_(uint8_t byte); // Returns true if frame parsing was completed (a frame is ready in the buffer) @@ -80,14 +83,17 @@ class ZWaveProxy : public uart::UARTDevice, public Component { // Pointers and 32-bit values (aligned together) api::APIConnection *api_connection_{nullptr}; // Current subscribed client uint32_t setup_time_{0}; // Time when setup() was called + uint32_t reconnect_time_{0}; // Timestamp of reconnect detection (0 = no pending query) // Small values (grouped by size to minimize padding) uint16_t buffer_index_{0}; // Index for populating the data buffer uint16_t end_frame_after_{0}; // Payload reception ends after this index uint8_t last_response_{0}; // Last response type sent + uint8_t query_retries_{0}; // Number of home ID query attempts after reconnect ZWaveParsingState parsing_state_{ZWAVE_PARSING_STATE_WAIT_START}; bool in_bootloader_{false}; // True if the device is detected to be in bootloader mode bool home_id_ready_{false}; // True when home ID has been received from Z-Wave module + bool was_connected_{false}; // Previous UART connection state for edge detection }; extern ZWaveProxy *global_zwave_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) From da6c4e20fef3f92dfc25ecd5f34df76d2c8baf1a Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 1 Apr 2026 06:29:57 +1000 Subject: [PATCH 30/72] [lvgl] Fixes #2 (#15161) --- esphome/components/lvgl/automation.py | 4 +- esphome/components/lvgl/defines.py | 1 + esphome/components/lvgl/lvcode.py | 5 -- esphome/components/lvgl/lvgl_esphome.cpp | 26 ++++---- esphome/components/lvgl/number/__init__.py | 4 +- esphome/components/lvgl/schemas.py | 67 ++++++++++++++++----- esphome/components/lvgl/switch/__init__.py | 4 +- esphome/components/lvgl/text/__init__.py | 4 +- esphome/components/lvgl/trigger.py | 3 + esphome/components/lvgl/widgets/meter.py | 63 ++++++++++--------- esphome/components/lvgl/widgets/tileview.py | 2 +- tests/components/lvgl/lvgl-package.yaml | 34 ++++++++++- 12 files changed, 145 insertions(+), 72 deletions(-) diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index 24579e5be8..50e6db74b8 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -136,7 +136,7 @@ async def update_to_code(config, action_id, template_arg, args): widget.type.w_type.value_property is not None and widget.type.w_type.value_property in config ): - lv.event_send(widget.obj, UPDATE_EVENT, nullptr) + lv_obj.send_event(widget.obj, UPDATE_EVENT, nullptr) widgets = await get_widgets(config[CONF_ID]) return await action_to_code( @@ -455,6 +455,6 @@ async def obj_refresh_to_code(config, action_id, template_arg, args): widget.type.w_type.value_property is not None and widget.type.w_type.value_property in config ): - lv.event_send(widget.obj, UPDATE_EVENT, nullptr) + lv_obj.send_event(widget.obj, UPDATE_EVENT, nullptr) return await action_to_code(widget, do_refresh, action_id, template_arg, args) diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index 72345ca98e..de5835d7a6 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -541,6 +541,7 @@ CONF_END_ANGLE = "end_angle" CONF_END_VALUE = "end_value" CONF_ENTER_BUTTON = "enter_button" CONF_ENTRIES = "entries" +CONF_EXT_CLICK_AREA = "ext_click_area" CONF_FLAGS = "flags" CONF_FLEX_FLOW = "flex_flow" CONF_FLEX_ALIGN_MAIN = "flex_align_main" diff --git a/esphome/components/lvgl/lvcode.py b/esphome/components/lvgl/lvcode.py index 146b261f26..eb8f7d4437 100644 --- a/esphome/components/lvgl/lvcode.py +++ b/esphome/components/lvgl/lvcode.py @@ -253,14 +253,10 @@ class MockLv: A mock object that can be used to generate LVGL calls. """ - # Mapping for LVGL 9 - ATTR_MAP = {"event_send": "obj_send_event", "dither": "bg_dither_mode"} - def __init__(self, base): self.base = base def __getattr__(self, attr: str) -> "MockLv": - attr = MockLv.ATTR_MAP.get(attr, attr) return MockLv(f"{self.base}{attr}") def append(self, expression): @@ -314,7 +310,6 @@ class ReturnStatement(ExpressionStatement): class LvExpr(MockLv): def __getattr__(self, attr: str) -> "MockLv": - attr = MockLv.ATTR_MAP.get(attr, attr) return LvExpr(f"{self.base}{attr}") def append(self, expression): diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index bf86a4e9ee..a5075cb614 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -343,26 +343,26 @@ void IndicatorLine::set_value(int value) { } void IndicatorLine::update_length_() { - uint32_t actual_needle_length; - auto radius = lv_obj_get_width(lv_obj_get_parent(this->obj)) / 2; + auto cx = lv_obj_get_width(lv_obj_get_parent(this->obj)) / 2; + auto cy = lv_obj_get_height(lv_obj_get_parent(this->obj)) / 2; + auto radius = clamp_at_most(cx, cy); auto length = lv_obj_get_style_length(this->obj, LV_PART_MAIN); auto radial_offset = lv_obj_get_style_radial_offset(this->obj, LV_PART_MAIN); if (LV_COORD_IS_PCT(radial_offset)) { radial_offset = radius * LV_COORD_GET_PCT(radial_offset) / 100; } if (LV_COORD_IS_PCT(length)) { - actual_needle_length = radius * LV_COORD_GET_PCT(length) / 100; + length = radius * LV_COORD_GET_PCT(length) / 100; } else if (length < 0) { - actual_needle_length = radius + length; - } else { - actual_needle_length = length; + length += radius; } auto x = lv_trigo_cos(this->angle_) / 32768.0f; auto y = lv_trigo_sin(this->angle_) / 32768.0f; + // radius here also represents the offset of the scale center from top left this->points_[0].x = radius + radial_offset * x; this->points_[0].y = radius + radial_offset * y; - this->points_[1].x = x * actual_needle_length + radius; - this->points_[1].y = y * actual_needle_length + radius; + this->points_[1].x = radius + x * (radial_offset + length); + this->points_[1].y = radius + y * (radial_offset + length); lv_obj_refresh_self_size(this->obj); lv_obj_invalidate(this->obj); } @@ -682,15 +682,15 @@ void lv_scale_draw_event_cb(lv_event_t *e, int16_t range_start, int16_t range_en auto *line_dsc = static_cast(lv_draw_task_get_draw_dsc(task)); int tick = line_dsc->base.id2; if (tick >= range_start && tick <= range_end) { - unsigned range = range_end - range_start; + int ratio; if (local) { + int range = range_end - range_start; tick -= range_start; + ratio = range == 0 ? 0 : (tick * 255) / range; } else { - range = lv_scale_get_total_tick_count(scale) - 1; + // total tick count is guaranteed to be at least 2. + ratio = (line_dsc->base.id1 * 255) / (lv_scale_get_total_tick_count(scale) - 1); } - if (range == 0) - range = 1; - auto ratio = (tick * 255) / range; line_dsc->color = lv_color_mix(color_end, color_start, ratio); line_dsc->width += width; } diff --git a/esphome/components/lvgl/number/__init__.py b/esphome/components/lvgl/number/__init__.py index c48e051eac..d80e93708b 100644 --- a/esphome/components/lvgl/number/__init__.py +++ b/esphome/components/lvgl/number/__init__.py @@ -12,7 +12,7 @@ from ..lvcode import ( UPDATE_EVENT, LambdaContext, ReturnStatement, - lv, + lv_obj, lvgl_static, ) from ..types import LV_EVENT, LvNumber, lvgl_ns @@ -40,7 +40,7 @@ async def to_code(config): await widget.set_property( "value", MockObj("v") * MockObj(widget.get_scale()), config[CONF_ANIMATED] ) - lv.event_send(widget.obj, API_EVENT, cg.nullptr) + lv_obj.send_event(widget.obj, API_EVENT, cg.nullptr) event_code = ( LV_EVENT.VALUE_CHANGED if not config[CONF_UPDATE_ON_RELEASE] diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index bcbb193ce3..9c9504f05f 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -146,26 +146,41 @@ def point_schema(value): # All LVGL styles and their validators -STYLE_PROPS = { +BASE_PROPS = { "align": df.CHILD_ALIGNMENTS.one_of, - "arc_opa": lvalid.opacity, + "anim_duration": lvalid.lv_milliseconds, "arc_color": lvalid.lv_color, + "arc_opa": lvalid.opacity, "arc_rounded": lvalid.lv_bool, "arc_width": lvalid.pixels, - "anim_time": lvalid.lv_milliseconds, + "base_dir": df.LvConstant("LV_BASE_DIR_", "LTR", "RTL", "AUTO").one_of, "bg_color": lvalid.lv_color, "bg_grad": lv_gradient, "bg_grad_color": lvalid.lv_color, - "bg_dither_mode": df.LvConstant("LV_DITHER_", "NONE", "ORDERED", "ERR_DIFF").one_of, "bg_grad_dir": LV_GRAD_DIR.one_of, + "bg_grad_opa": lvalid.opacity, "bg_grad_stop": lvalid.stop_value, "bg_image_opa": lvalid.opacity, "bg_image_recolor": lvalid.lv_color, "bg_image_recolor_opa": lvalid.opacity, "bg_image_src": lvalid.lv_image, "bg_image_tiled": lvalid.lv_bool, + "bg_main_opa": lvalid.opacity, "bg_main_stop": lvalid.stop_value, "bg_opa": lvalid.opacity, + "blend_mode": df.LvConstant( + "LV_BLEND_MODE_", + "NORMAL", + "ADDITIVE", + "SUBTRACTIVE", + "MULTIPLY", + "DIFFERENCE", + ).one_of, + "blur_backdrop": lvalid.lv_bool, + "blur_quality": df.LvConstant( + "LV_BLUR_QUALITY_", "AUTO", "SPEED", "PRECISION" + ).one_of, + "blur_radius": lvalid.lv_positive_int, "border_color": lvalid.lv_color, "border_opa": lvalid.opacity, "border_post": lvalid.lv_bool, @@ -175,33 +190,53 @@ STYLE_PROPS = { "border_width": lvalid.lv_positive_int, "clip_corner": lvalid.lv_bool, "color_filter_opa": lvalid.opacity, + "drop_shadow_color": lvalid.lv_color, + "drop_shadow_offset_x": lvalid.lv_int, + "drop_shadow_offset_y": lvalid.lv_int, + "drop_shadow_opa": lvalid.opacity, + "drop_shadow_quality": df.LvConstant( + "LV_BLUR_QUALITY_", "AUTO", "SPEED", "PRECISION" + ).one_of, + "drop_shadow_radius": lvalid.lv_positive_int, "height": lvalid.size, + "image_opa": lvalid.opacity, "image_recolor": lvalid.lv_color, "image_recolor_opa": lvalid.opacity, + "length": lvalid.pixels_or_percent, "line_color": lvalid.lv_color, "line_dash_gap": lvalid.lv_positive_int, "line_dash_width": lvalid.lv_positive_int, "line_opa": lvalid.opacity, "line_rounded": lvalid.lv_bool, "line_width": lvalid.lv_positive_int, + "margin_bottom": lvalid.padding, + "margin_left": lvalid.padding, + "margin_right": lvalid.padding, + "margin_top": lvalid.padding, + "max_height": lvalid.pixels_or_percent, + "max_width": lvalid.pixels_or_percent, + "min_height": lvalid.pixels_or_percent, + "min_width": lvalid.pixels_or_percent, "opa": lvalid.opacity, "opa_layered": lvalid.opacity, "outline_color": lvalid.lv_color, "outline_opa": lvalid.opacity, "outline_pad": lvalid.padding, "outline_width": lvalid.pixels, - "length": lvalid.pixels_or_percent, "pad_all": lvalid.padding, "pad_bottom": lvalid.padding, "pad_left": lvalid.padding, + "pad_radial": lvalid.padding, "pad_right": lvalid.padding, "pad_top": lvalid.padding, "radial_offset": lvalid.size, + "radius": lvalid.lv_fraction, + "recolor": lvalid.lv_color, + "recolor_opa": lvalid.opacity, + "rotary_sensitivity": lvalid.lv_positive_int, "shadow_color": lvalid.lv_color, "shadow_offset_x": lvalid.lv_int, "shadow_offset_y": lvalid.lv_int, - "shadow_ofs_x": lvalid.lv_int, - "shadow_ofs_y": lvalid.lv_int, "shadow_opa": lvalid.opacity, "shadow_spread": lvalid.lv_int, "shadow_width": lvalid.lv_positive_int, @@ -216,7 +251,9 @@ STYLE_PROPS = { "text_letter_space": lvalid.lv_positive_int, "text_line_space": lvalid.lv_positive_int, "text_opa": lvalid.opacity, - "transform_angle": lvalid.lv_angle, + "text_outline_stroke_color": lvalid.lv_color, + "text_outline_stroke_opa": lvalid.opacity, + "text_outline_stroke_width": lvalid.lv_positive_int, "transform_height": lvalid.pixels_or_percent, "transform_pivot_x": lvalid.pixels_or_percent, "transform_pivot_y": lvalid.pixels_or_percent, @@ -226,20 +263,17 @@ STYLE_PROPS = { "transform_scale_y": lvalid.scale, "transform_skew_x": lvalid.lv_angle, "transform_skew_y": lvalid.lv_angle, - "transform_zoom": lvalid.scale, + "transform_width": lvalid.pixels_or_percent, + "translate_radial": lvalid.lv_int, "translate_x": lvalid.pixels_or_percent, "translate_y": lvalid.pixels_or_percent, - "max_height": lvalid.pixels_or_percent, - "max_width": lvalid.pixels_or_percent, - "min_height": lvalid.pixels_or_percent, - "min_width": lvalid.pixels_or_percent, - "radius": lvalid.lv_fraction, "width": lvalid.size, "x": lvalid.pixels_or_percent, "y": lvalid.pixels_or_percent, } STYLE_REMAP = { + "anim_time": "anim_duration", "transform_angle": "transform_rotation", "transform_zoom": "transform_scale", "zoom": "scale", @@ -249,6 +283,10 @@ STYLE_REMAP = { "r_mod": "length", } +STYLE_PROPS = BASE_PROPS | { + p: BASE_PROPS[v] for p, v in STYLE_REMAP.items() if v in BASE_PROPS +} + def remap_property(prop, record=True): """ @@ -394,6 +432,7 @@ def obj_schema(widget_type: WidgetType): return ( part_schema(widget_type.parts) .extend(ALIGN_TO_SCHEMA) + .extend({cv.Optional(df.CONF_EXT_CLICK_AREA): lvalid.pixels}) .extend(automation_schema(widget_type.w_type)) .extend( { diff --git a/esphome/components/lvgl/switch/__init__.py b/esphome/components/lvgl/switch/__init__.py index 6d10a70d85..a43851b4a3 100644 --- a/esphome/components/lvgl/switch/__init__.py +++ b/esphome/components/lvgl/switch/__init__.py @@ -13,8 +13,8 @@ from ..lvcode import ( LambdaContext, LvConditional, LvContext, - lv, lv_add, + lv_obj, lvgl_static, ) from ..types import LV_EVENT, LV_STATE, lv_pseudo_button_t, lvgl_ns @@ -39,7 +39,7 @@ async def to_code(config): widget.add_state(LV_STATE.CHECKED) cond.else_() widget.clear_state(LV_STATE.CHECKED) - lv.event_send(widget.obj, API_EVENT, cg.nullptr) + lv_obj.send_event(widget.obj, API_EVENT, cg.nullptr) control.add(switch_id.publish_state(v)) switch = cg.new_Pvariable(config[CONF_ID], await control.get_lambda()) await cg.register_component(switch, config) diff --git a/esphome/components/lvgl/text/__init__.py b/esphome/components/lvgl/text/__init__.py index eb56cdb7a7..190ecacda5 100644 --- a/esphome/components/lvgl/text/__init__.py +++ b/esphome/components/lvgl/text/__init__.py @@ -10,8 +10,8 @@ from ..lvcode import ( UPDATE_EVENT, LambdaContext, LvContext, - lv, lv_add, + lv_obj, lvgl_static, ) from ..types import LV_EVENT, LvText, lvgl_ns @@ -33,7 +33,7 @@ async def to_code(config): await wait_for_widgets() async with LambdaContext([(cg.std_string, "text_value")]) as control: await widget.set_property("text", "text_value.c_str()") - lv.event_send(widget.obj, API_EVENT, cg.nullptr) + lv_obj.send_event(widget.obj, API_EVENT, cg.nullptr) control.add(textvar.publish_state(widget.get_value())) async with LambdaContext(EVENT_ARG) as lamb: lv_add(textvar.publish_state(widget.get_value())) diff --git a/esphome/components/lvgl/trigger.py b/esphome/components/lvgl/trigger.py index 54309cdf89..c52d213e15 100644 --- a/esphome/components/lvgl/trigger.py +++ b/esphome/components/lvgl/trigger.py @@ -15,6 +15,7 @@ from .defines import ( CONF_ALIGN, CONF_ALIGN_TO, CONF_ALIGN_TO_LAMBDA_ID, + CONF_EXT_CLICK_AREA, DIRECTIONS, LV_EVENT_MAP, LV_EVENT_TRIGGERS, @@ -113,6 +114,8 @@ async def generate_align_tos(config: dict): x = align_to[CONF_X] y = align_to[CONF_Y] lv.obj_align_to(w.obj, target, align, x, y) + if ext_click_area := w.config.get(CONF_EXT_CLICK_AREA): + lv.obj_set_ext_click_area(w.obj, ext_click_area) action_id = config[CONF_ALIGN_TO_LAMBDA_ID] var = new_Pvariable(action_id, await context.get_lambda()) diff --git a/esphome/components/lvgl/widgets/meter.py b/esphome/components/lvgl/widgets/meter.py index 494f811a8e..ab65a7c47d 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -56,11 +56,11 @@ from ..lv_validation import ( lv_float, lv_image, lv_int, + lv_positive_int, opacity, padding, pixels, pixels_or_percent, - pixels_or_percent_validator, requires_component, size, ) @@ -88,7 +88,10 @@ CONF_COLOR_START = "color_start" CONF_DRAW_TICKS_ON_TOP = "draw_ticks_on_top" CONF_IMAGE_ID = "image_id" CONF_INDICATORS = "indicators" +CONF_DASH_GAP = "dash_gap" +CONF_DASH_WIDTH = "dash_width" CONF_LINE_ID = "line_id" +CONF_ROUNDED = "rounded" CONF_LABEL_GAP = "label_gap" CONF_MAJOR = "major" CONF_METER = "meter" @@ -135,9 +138,12 @@ INDICATOR_LINE_SCHEMA = cv.Schema( { cv.Optional(CONF_WIDTH, default=4): cv.int_, cv.Optional(CONF_COLOR, default=0): lv_color, + cv.Optional(CONF_ROUNDED, default=True): lv_bool, + cv.Optional(CONF_DASH_GAP): lv_positive_int, + cv.Optional(CONF_DASH_WIDTH): lv_positive_int, cv.Optional(CONF_R_MOD): padding, - cv.Optional(CONF_LENGTH): pixels_or_percent_validator, - cv.Optional(CONF_RADIAL_OFFSET, 0): pixels_or_percent_validator, + cv.Optional(CONF_LENGTH): pixels_or_percent, + cv.Optional(CONF_RADIAL_OFFSET): pixels_or_percent, cv.Optional(CONF_VALUE, default=0.0): lv_float, cv.Optional(CONF_OPA, default=1.0): opacity, } @@ -249,17 +255,17 @@ SCALE_SCHEMA = cv.Schema( { cv.Optional(CONF_COUNT, default=12): cv.int_range(min=2), cv.Optional(CONF_WIDTH, default=2): cv.positive_int, - cv.Optional(CONF_LENGTH, default=10): size, - cv.Optional(CONF_RADIAL_OFFSET, default=0): size, + cv.Optional(CONF_LENGTH, default=10): cv.positive_int, + cv.Optional(CONF_RADIAL_OFFSET): cv.positive_int, cv.Optional(CONF_COLOR, default=0x808080): lv_color, cv.Optional(CONF_MAJOR): cv.Schema( { cv.Optional(CONF_STRIDE, default=3): cv.positive_int, cv.Optional(CONF_WIDTH, default=5): size, - cv.Optional(CONF_LENGTH, default="15%"): size, - cv.Optional(CONF_RADIAL_OFFSET, default=0): size, + cv.Optional(CONF_LENGTH, default=12): cv.positive_int, + cv.Optional(CONF_RADIAL_OFFSET): cv.positive_int, cv.Optional(CONF_COLOR, default=0): lv_color, - cv.Optional(CONF_LABEL_GAP, default=4): size, + cv.Optional(CONF_LABEL_GAP, default=4): cv.int_, } ), } @@ -466,11 +472,15 @@ class MeterType(WidgetType): CONF_OPA: v[CONF_OPA], CONF_LINE_WIDTH: v[CONF_WIDTH], "line_color": v[CONF_COLOR], - "line_rounded": True, + "line_rounded": v[CONF_ROUNDED], CONF_ALIGN: CHILD_ALIGNMENTS.TOP_LEFT, CONF_LENGTH: length, - CONF_RADIAL_OFFSET: v[CONF_RADIAL_OFFSET], } + if radial_offset := v.get(CONF_RADIAL_OFFSET): + props[CONF_RADIAL_OFFSET] = radial_offset + for option in (CONF_DASH_WIDTH, CONF_DASH_GAP): + if option in v: + props["line_" + option] = v[option] lw = await widget_to_code(props, line_indicator_type, scale_var) await set_indicator_values(lw, v) @@ -478,10 +488,8 @@ class MeterType(WidgetType): add_lv_use(CONF_IMAGE) src = v[CONF_SRC] src_data = get_image_metadata(src.id) - pivot_x = await pixels.process(v[CONF_PIVOT_X]) - pivot_y = await pixels.process( - v.get(CONF_PIVOT_Y, src_data.height // 2) - ) + pivot_x = v[CONF_PIVOT_X] + pivot_y = v.get(CONF_PIVOT_Y, src_data.height // 2) props = { CONF_X: src_data.width // 2 - pivot_x, "transform_pivot_x": pivot_x, @@ -511,11 +519,12 @@ class MeterType(WidgetType): lv_obj.set_style_line_width( scale_var, await size.process(ticks[CONF_WIDTH]), LV_PART.ITEMS ) - lv_obj.set_style_radial_offset( - scale_var, - await size.process(ticks[CONF_RADIAL_OFFSET]), - LV_PART.ITEMS, - ) + if radial_offset := ticks.get(CONF_RADIAL_OFFSET): + lv_obj.set_style_radial_offset( + scale_var, + -radial_offset, + LV_PART.ITEMS, + ) lv_obj.set_style_line_color( scale_var, await lv_color.process(ticks[CONF_COLOR]), @@ -536,11 +545,12 @@ class MeterType(WidgetType): await size.process(major[CONF_LENGTH]), LV_PART.INDICATOR, ) - lv_obj.set_style_radial_offset( - scale_var, - await size.process(ticks[CONF_RADIAL_OFFSET]), - LV_PART.INDICATOR, - ) + if radial_offset := major.get(CONF_RADIAL_OFFSET): + lv_obj.set_style_radial_offset( + scale_var, + -radial_offset, + LV_PART.INDICATOR, + ) lv_obj.set_style_line_width( scale_var, await size.process(major[CONF_WIDTH]), @@ -553,12 +563,9 @@ class MeterType(WidgetType): ) # Set label gap (padding) - label_gap = await size.process(major[CONF_LABEL_GAP]) - if isinstance(label_gap, int): - label_gap -= DEFAULT_LABEL_GAP lv_obj.set_style_pad_radial( scale_var, - label_gap, + major[CONF_LABEL_GAP] - DEFAULT_LABEL_GAP, LV_PART.INDICATOR, ) else: diff --git a/esphome/components/lvgl/widgets/tileview.py b/esphome/components/lvgl/widgets/tileview.py index 8e9d95f349..4657d628de 100644 --- a/esphome/components/lvgl/widgets/tileview.py +++ b/esphome/components/lvgl/widgets/tileview.py @@ -129,6 +129,6 @@ async def tileview_select(config, action_id, template_arg, args): lv.tileview_set_tile_by_index( widgets[0].obj, column, row, literal(config[CONF_ANIMATED]) ) - lv.event_send(w.obj, LV_EVENT.VALUE_CHANGED, cg.nullptr) + lv_obj.send_event(w.obj, LV_EVENT.VALUE_CHANGED, cg.nullptr) return await action_to_code(widgets, do_select, action_id, template_arg, args) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 821476a72b..b8c9a1809e 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -232,7 +232,7 @@ lvgl: - roller: id: lv_roller visible_row_count: 2 - anim_time: 500ms + anim_duration: 500ms options: - Nov - Dec @@ -317,20 +317,27 @@ lvgl: align: top_left - container: align: center + anim_duration: 1s arc_opa: COVER arc_color: 0xFF0000 arc_rounded: false arc_width: 3 - anim_time: 1s + base_dir: auto bg_color: light_blue bg_grad_color: light_blue bg_grad_dir: hor + bg_grad_opa: cover bg_grad_stop: 128 bg_image_opa: transp bg_image_recolor: light_blue bg_image_recolor_opa: 50% + bg_main_opa: cover bg_main_stop: 0 bg_opa: 20% + blend_mode: normal + blur_backdrop: false + blur_quality: auto + blur_radius: 0 border_color: 0x00FF00 border_opa: cover border_post: true @@ -338,7 +345,15 @@ lvgl: border_width: 4 clip_corner: false color_filter_opa: transp + drop_shadow_color: 0x000000 + drop_shadow_offset_x: 5 + drop_shadow_offset_y: 5 + drop_shadow_opa: cover + drop_shadow_quality: precision + drop_shadow_radius: 10 + ext_click_area: 100px height: 50% + image_opa: cover image_recolor: light_blue image_recolor_opa: cover line_width: 10 @@ -346,6 +361,10 @@ lvgl: line_dash_gap: 10 line_rounded: false line_color: light_blue + margin_bottom: 4 + margin_left: 4 + margin_right: 4 + margin_top: 4 opa: cover opa_layered: cover outline_color: light_blue @@ -355,8 +374,12 @@ lvgl: pad_all: 10px pad_bottom: 10px pad_left: 10px + pad_radial: 0 pad_right: 10px pad_top: 10px + recolor: 0xFF0000 + recolor_opa: transp + rotary_sensitivity: 256 shadow_color: light_blue shadow_opa: cover shadow_spread: 5 @@ -368,6 +391,9 @@ lvgl: text_letter_space: 4 text_line_space: 4 text_opa: cover + text_outline_stroke_color: 0x000000 + text_outline_stroke_opa: cover + text_outline_stroke_width: 2 transform_rotation: 90 transform_height: 100 transform_pivot_x: 50% @@ -377,8 +403,10 @@ lvgl: transform_scale_y: 0.8 transform_skew_x: 10 transform_skew_y: 20 + transform_width: 100 shadow_offset_x: 3 shadow_offset_y: 3 + translate_radial: 0 translate_x: 10 translate_y: 10 max_height: 100 @@ -1053,7 +1081,7 @@ lvgl: - ticks: width: 1 count: 61 - length: 20% + length: 20 radial_offset: 5 color: 0xFFFFFF major: From 2cb987095da9ca82aaa6f4412184f8cda9fc8090 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Tue, 31 Mar 2026 13:48:16 -0700 Subject: [PATCH 31/72] [modbus] Share helper functions across modbus components - part B (#14172) Co-authored-by: J. Nick Koston Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/modbus/modbus_helpers.cpp | 139 ++++++++++++++++++ esphome/components/modbus/modbus_helpers.h | 101 +++++++++++++ .../binary_sensor/modbus_binarysensor.cpp | 4 +- .../modbus_controller/modbus_controller.cpp | 134 +---------------- .../modbus_controller/modbus_controller.h | 109 ++++---------- .../number/modbus_number.cpp | 2 +- .../output/modbus_output.cpp | 2 +- .../select/modbus_select.cpp | 4 +- .../switch/modbus_switch.cpp | 4 +- 9 files changed, 277 insertions(+), 222 deletions(-) create mode 100644 esphome/components/modbus/modbus_helpers.cpp diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp new file mode 100644 index 0000000000..77190b2846 --- /dev/null +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -0,0 +1,139 @@ +#include "modbus_helpers.h" +#include "esphome/core/log.h" + +namespace esphome::modbus::helpers { + +static const char *const TAG = "modbus_helpers"; + +void number_to_payload(std::vector &data, int64_t value, SensorValueType value_type) { + switch (value_type) { + case SensorValueType::U_WORD: + case SensorValueType::S_WORD: + data.push_back(value & 0xFFFF); + break; + case SensorValueType::U_DWORD: + case SensorValueType::S_DWORD: + case SensorValueType::FP32: + data.push_back((value & 0xFFFF0000) >> 16); + data.push_back(value & 0xFFFF); + break; + case SensorValueType::U_DWORD_R: + case SensorValueType::S_DWORD_R: + case SensorValueType::FP32_R: + data.push_back(value & 0xFFFF); + data.push_back((value & 0xFFFF0000) >> 16); + break; + case SensorValueType::U_QWORD: + case SensorValueType::S_QWORD: + data.push_back((value & 0xFFFF000000000000) >> 48); + data.push_back((value & 0xFFFF00000000) >> 32); + data.push_back((value & 0xFFFF0000) >> 16); + data.push_back(value & 0xFFFF); + break; + case SensorValueType::U_QWORD_R: + case SensorValueType::S_QWORD_R: + data.push_back(value & 0xFFFF); + data.push_back((value & 0xFFFF0000) >> 16); + data.push_back((value & 0xFFFF00000000) >> 32); + data.push_back((value & 0xFFFF000000000000) >> 48); + break; + default: + ESP_LOGE(TAG, "Invalid data type for modbus number to payload conversion: %d", static_cast(value_type)); + break; + } +} + +int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, + uint32_t bitmask) { + int64_t value = 0; // int64_t because it can hold signed and unsigned 32 bits + + if (offset > data.size()) { + ESP_LOGE(TAG, "not enough data for value"); + return value; + } + + size_t size = data.size() - offset; + bool error = false; + switch (sensor_value_type) { + case SensorValueType::U_WORD: + if (size >= 2) { + value = mask_and_shift_by_rightbit(get_data(data, offset), + bitmask); // default is 0xFFFF ; + } else { + error = true; + } + break; + case SensorValueType::U_DWORD: + case SensorValueType::FP32: + if (size >= 4) { + value = get_data(data, offset); + value = mask_and_shift_by_rightbit((uint32_t) value, bitmask); + } else { + error = true; + } + break; + case SensorValueType::U_DWORD_R: + case SensorValueType::FP32_R: + if (size >= 4) { + value = get_data(data, offset); + value = static_cast(value & 0xFFFF) << 16 | (value & 0xFFFF0000) >> 16; + value = mask_and_shift_by_rightbit((uint32_t) value, bitmask); + } else { + error = true; + } + break; + case SensorValueType::S_WORD: + if (size >= 2) { + value = mask_and_shift_by_rightbit(get_data(data, offset), + bitmask); // default is 0xFFFF ; + } else { + error = true; + } + break; + case SensorValueType::S_DWORD: + if (size >= 4) { + value = mask_and_shift_by_rightbit(get_data(data, offset), bitmask); + } else { + error = true; + } + break; + case SensorValueType::S_DWORD_R: { + if (size >= 4) { + value = get_data(data, offset); + // Currently the high word is at the low position + // the sign bit is therefore at low before the switch + uint32_t sign_bit = (value & 0x8000) << 16; + value = mask_and_shift_by_rightbit( + static_cast(((value & 0x7FFF) << 16 | (value & 0xFFFF0000) >> 16) | sign_bit), bitmask); + } else { + error = true; + } + } break; + case SensorValueType::U_QWORD: + case SensorValueType::S_QWORD: + // Ignore bitmask for QWORD + if (size >= 8) { + value = get_data(data, offset); + } else { + error = true; + } + break; + case SensorValueType::U_QWORD_R: + case SensorValueType::S_QWORD_R: { + // Ignore bitmask for QWORD + if (size >= 8) { + uint64_t tmp = get_data(data, offset); + value = (tmp << 48) | (tmp >> 48) | ((tmp & 0xFFFF0000) << 16) | ((tmp >> 16) & 0xFFFF0000); + } else { + error = true; + } + } break; + case SensorValueType::RAW: + default: + break; + } + if (error) + ESP_LOGE(TAG, "not enough data for value"); + return value; +} +} // namespace esphome::modbus::helpers diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index 9f78de1c21..84897bcad3 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include "esphome/core/helpers.h" #include "esphome/components/modbus/modbus_definitions.h" @@ -103,4 +105,103 @@ inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) { return static_cast(dword_from_hex_str(value, pos)) << 32 | dword_from_hex_str(value, pos + 4); } +// Extract data from modbus response buffer +/** Extract data from modbus response buffer + * @param T one of supported integer data types int_8,int_16,int_32,int_64 + * @param data modbus response buffer (uint8_t) + * @param buffer_offset offset in bytes. + * @return value of type T extracted from buffer + */ +template T get_data(const std::vector &data, size_t buffer_offset) { + if (sizeof(T) == sizeof(uint8_t)) { + return T(data[buffer_offset]); + } + if (sizeof(T) == sizeof(uint16_t)) { + return T((uint16_t(data[buffer_offset + 0]) << 8) | (uint16_t(data[buffer_offset + 1]) << 0)); + } + + if (sizeof(T) == sizeof(uint32_t)) { + return static_cast(get_data(data, buffer_offset)) << 16 | + static_cast(get_data(data, buffer_offset + 2)); + } + + if (sizeof(T) == sizeof(uint64_t)) { + return static_cast(get_data(data, buffer_offset)) << 32 | + (static_cast(get_data(data, buffer_offset + 4))); + } + + static_assert(sizeof(T) == sizeof(uint8_t) || sizeof(T) == sizeof(uint16_t) || sizeof(T) == sizeof(uint32_t) || + sizeof(T) == sizeof(uint64_t), + "Unsupported type size in get_data; only 1, 2, 4, or 8-byte integer types are supported."); + + return T{}; +} + +/** Extract coil data from modbus response buffer + * Responses for coil are packed into bytes . + * coil 3 is bit 3 of the first response byte + * coil 9 is bit 2 of the second response byte + * @param coil number of the cil + * @param data modbus response buffer (uint8_t) + * @return content of coil register + */ +inline bool coil_from_vector(int coil, const std::vector &data) { + auto data_byte = coil / 8; + return (data[data_byte] & (1 << (coil % 8))) > 0; +} + +/** Extract bits from value and shift right according to the bitmask + * if the bitmask is 0x00F0 we want the values frrom bit 5 - 8. + * the result is then shifted right by the position if the first right set bit in the mask + * Useful for modbus data where more than one value is packed in a 16 bit register + * Example: on Epever the "Length of night" register 0x9065 encodes values of the whole night length of time as + * D15 - D8 = hour, D7 - D0 = minute + * To get the hours use mask 0xFF00 and 0x00FF for the minute + * @param data an integral value between 16 aand 32 bits, + * @param bitmask the bitmask to apply + */ +template N mask_and_shift_by_rightbit(N data, uint32_t mask) { + auto result = (mask & data); + if (result == 0 || mask == 0xFFFFFFFF) { + return result; + } + for (size_t pos = 0; pos < sizeof(N) << 3; pos++) { + if (pos < 32 && (mask & (1UL << pos)) != 0) + return result >> pos; + } + return 0; +} + +/** Convert float value to vector suitable for sending + * @param data target for payload + * @param value float value to convert + * @param value_type defines if 16/32 or FP32 is used + * @return vector containing the modbus register words in correct order + */ +void number_to_payload(std::vector &data, int64_t value, SensorValueType value_type); + +/** Convert vector response payload to number. + * @param data payload with the data to convert + * @param sensor_value_type defines if 16/32/64 bits or FP32 is used + * @param offset offset to the data in data + * @param bitmask bitmask used for masking and shifting + * @return 64-bit number of the payload + */ +int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, + uint32_t bitmask); + +inline std::vector float_to_payload(float value, SensorValueType value_type) { + int64_t val; + + if (value_type_is_float(value_type)) { + val = bit_cast(value); + } else { + val = llroundf(value); + } + + std::vector data; + number_to_payload(data, val, value_type); + return data; +} + } // namespace esphome::modbus::helpers diff --git a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp index c3eb3d4411..1ea3041b4d 100644 --- a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp +++ b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp @@ -15,10 +15,10 @@ void ModbusBinarySensor::parse_and_publish(const std::vector &data) { case ModbusRegisterType::DISCRETE_INPUT: case ModbusRegisterType::COIL: // offset for coil is the actual number of the coil not the byte offset - value = coil_from_vector(this->offset, data); + value = modbus::helpers::coil_from_vector(this->offset, data); break; default: - value = get_data(data, this->offset) & this->bitmask; + value = modbus::helpers::get_data(data, this->offset) & this->bitmask; break; } // Is there a lambda registered diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 38eaea2d1c..3c4ceaf62d 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -140,7 +140,7 @@ void ModbusController::on_modbus_read_registers(uint8_t function_code, uint16_t std::vector payload; payload.reserve(server_register->register_count * 2); - number_to_payload(payload, value, server_register->value_type); + modbus::helpers::number_to_payload(payload, value, server_register->value_type); sixteen_bit_response.insert(sixteen_bit_response.end(), payload.cbegin(), payload.cend()); current_address += server_register->register_count; found = true; @@ -258,7 +258,7 @@ void ModbusController::on_modbus_write_registers(uint8_t function_code, const st // Actually write to the registers: if (!for_each_register([&data](ServerRegister *server_register, uint16_t offset) { - int64_t number = payload_to_number(data, server_register->value_type, offset, 0xFFFFFFFF); + int64_t number = modbus::helpers::payload_to_number(data, server_register->value_type, offset, 0xFFFFFFFF); return server_register->write_lambda(number); })) { this->send_error(function_code, ModbusExceptionCode::SERVICE_DEVICE_FAILURE); @@ -517,7 +517,8 @@ void ModbusController::loop() { void ModbusController::on_write_register_response(ModbusRegisterType register_type, uint16_t start_address, const std::vector &data) { - ESP_LOGV(TAG, "Command ACK 0x%X %d ", get_data(data, 0), get_data(data, 1)); + ESP_LOGV(TAG, "Command ACK 0x%X %d ", modbus::helpers::get_data(data, 0), + modbus::helpers::get_data(data, 1)); } void ModbusController::dump_sensors_() { @@ -710,132 +711,5 @@ bool ModbusCommandItem::is_equal(const ModbusCommandItem &other) { other.register_type == this->register_type && other.function_code == this->function_code; } -void number_to_payload(std::vector &data, int64_t value, SensorValueType value_type) { - switch (value_type) { - case SensorValueType::U_WORD: - case SensorValueType::S_WORD: - data.push_back(value & 0xFFFF); - break; - case SensorValueType::U_DWORD: - case SensorValueType::S_DWORD: - case SensorValueType::FP32: - data.push_back((value & 0xFFFF0000) >> 16); - data.push_back(value & 0xFFFF); - break; - case SensorValueType::U_DWORD_R: - case SensorValueType::S_DWORD_R: - case SensorValueType::FP32_R: - data.push_back(value & 0xFFFF); - data.push_back((value & 0xFFFF0000) >> 16); - break; - case SensorValueType::U_QWORD: - case SensorValueType::S_QWORD: - data.push_back((value & 0xFFFF000000000000) >> 48); - data.push_back((value & 0xFFFF00000000) >> 32); - data.push_back((value & 0xFFFF0000) >> 16); - data.push_back(value & 0xFFFF); - break; - case SensorValueType::U_QWORD_R: - case SensorValueType::S_QWORD_R: - data.push_back(value & 0xFFFF); - data.push_back((value & 0xFFFF0000) >> 16); - data.push_back((value & 0xFFFF00000000) >> 32); - data.push_back((value & 0xFFFF000000000000) >> 48); - break; - default: - ESP_LOGE(TAG, "Invalid data type for modbus number to payload conversation: %d", - static_cast(value_type)); - break; - } -} - -int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, - uint32_t bitmask) { - int64_t value = 0; // int64_t because it can hold signed and unsigned 32 bits - - size_t size = data.size() - offset; - bool error = false; - switch (sensor_value_type) { - case SensorValueType::U_WORD: - if (size >= 2) { - value = mask_and_shift_by_rightbit(get_data(data, offset), bitmask); // default is 0xFFFF ; - } else { - error = true; - } - break; - case SensorValueType::U_DWORD: - case SensorValueType::FP32: - if (size >= 4) { - value = get_data(data, offset); - value = mask_and_shift_by_rightbit((uint32_t) value, bitmask); - } else { - error = true; - } - break; - case SensorValueType::U_DWORD_R: - case SensorValueType::FP32_R: - if (size >= 4) { - value = get_data(data, offset); - value = static_cast(value & 0xFFFF) << 16 | (value & 0xFFFF0000) >> 16; - value = mask_and_shift_by_rightbit((uint32_t) value, bitmask); - } else { - error = true; - } - break; - case SensorValueType::S_WORD: - if (size >= 2) { - value = mask_and_shift_by_rightbit(get_data(data, offset), - bitmask); // default is 0xFFFF ; - } else { - error = true; - } - break; - case SensorValueType::S_DWORD: - if (size >= 4) { - value = mask_and_shift_by_rightbit(get_data(data, offset), bitmask); - } else { - error = true; - } - break; - case SensorValueType::S_DWORD_R: { - if (size >= 4) { - value = get_data(data, offset); - // Currently the high word is at the low position - // the sign bit is therefore at low before the switch - uint32_t sign_bit = (value & 0x8000) << 16; - value = mask_and_shift_by_rightbit( - static_cast(((value & 0x7FFF) << 16 | (value & 0xFFFF0000) >> 16) | sign_bit), bitmask); - } else { - error = true; - } - } break; - case SensorValueType::U_QWORD: - case SensorValueType::S_QWORD: - // Ignore bitmask for QWORD - if (size >= 8) { - value = get_data(data, offset); - } else { - error = true; - } - break; - case SensorValueType::U_QWORD_R: - case SensorValueType::S_QWORD_R: { - // Ignore bitmask for QWORD - if (size >= 8) { - uint64_t tmp = get_data(data, offset); - value = (tmp << 48) | (tmp >> 48) | ((tmp & 0xFFFF0000) << 16) | ((tmp >> 16) & 0xFFFF0000); - } else { - error = true; - } - } break; - case SensorValueType::RAW: - default: - break; - } - if (error) - ESP_LOGE(TAG, "not enough data for value"); - return value; -} - } // namespace modbus_controller } // namespace esphome diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 438eb12c2a..6c6c748b73 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -59,83 +59,38 @@ inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) { return modbus::helpers::qword_from_hex_str(value, pos); } -// Extract data from modbus response buffer -/** Extract data from modbus response buffer - * @param T one of supported integer data types int_8,int_16,int_32,int_64 - * @param data modbus response buffer (uint8_t) - * @param buffer_offset offset in bytes. - * @return value of type T extracted from buffer - */ -template T get_data(const std::vector &data, size_t buffer_offset) { - if (sizeof(T) == sizeof(uint8_t)) { - return T(data[buffer_offset]); - } - if (sizeof(T) == sizeof(uint16_t)) { - return T((uint16_t(data[buffer_offset + 0]) << 8) | (uint16_t(data[buffer_offset + 1]) << 0)); - } - - if (sizeof(T) == sizeof(uint32_t)) { - return get_data(data, buffer_offset) << 16 | get_data(data, (buffer_offset + 2)); - } - - if (sizeof(T) == sizeof(uint64_t)) { - return static_cast(get_data(data, buffer_offset)) << 32 | - (static_cast(get_data(data, buffer_offset + 4))); - } +template +ESPDEPRECATED("Use modbus::helpers::get_data() instead. Removed in 2026.10.0", "2026.4.0") +T get_data(const std::vector &data, size_t buffer_offset) { + return modbus::helpers::get_data(data, buffer_offset); } -/** Extract coil data from modbus response buffer - * Responses for coil are packed into bytes . - * coil 3 is bit 3 of the first response byte - * coil 9 is bit 2 of the second response byte - * @param coil number of the cil - * @param data modbus response buffer (uint8_t) - * @return content of coil register - */ +ESPDEPRECATED("Use modbus::helpers::coil_from_vector() instead. Removed in 2026.10.0", "2026.4.0") inline bool coil_from_vector(int coil, const std::vector &data) { - auto data_byte = coil / 8; - return (data[data_byte] & (1 << (coil % 8))) > 0; + return modbus::helpers::coil_from_vector(coil, data); } -/** Extract bits from value and shift right according to the bitmask - * if the bitmask is 0x00F0 we want the values frrom bit 5 - 8. - * the result is then shifted right by the position if the first right set bit in the mask - * Useful for modbus data where more than one value is packed in a 16 bit register - * Example: on Epever the "Length of night" register 0x9065 encodes values of the whole night length of time as - * D15 - D8 = hour, D7 - D0 = minute - * To get the hours use mask 0xFF00 and 0x00FF for the minute - * @param data an integral value between 16 aand 32 bits, - * @param bitmask the bitmask to apply - */ -template N mask_and_shift_by_rightbit(N data, uint32_t mask) { - auto result = (mask & data); - if (result == 0 || mask == 0xFFFFFFFF) { - return result; - } - for (size_t pos = 0; pos < sizeof(N) << 3; pos++) { - if ((mask & (1UL << pos)) != 0) - return result >> pos; - } - return 0; +template +ESPDEPRECATED("Use modbus::helpers::mask_and_shift_by_rightbit() instead. Removed in 2026.10.0", "2026.4.0") +N mask_and_shift_by_rightbit(N data, uint32_t mask) { + return modbus::helpers::mask_and_shift_by_rightbit(data, mask); } -/** Convert float value to vector suitable for sending - * @param data target for payload - * @param value float value to convert - * @param value_type defines if 16/32 or FP32 is used - * @return vector containing the modbus register words in correct order - */ -void number_to_payload(std::vector &data, int64_t value, SensorValueType value_type); +ESPDEPRECATED("Use modbus::helpers::number_to_payload() instead. Removed in 2026.10.0", "2026.4.0") +inline void number_to_payload(std::vector &data, int64_t value, SensorValueType value_type) { + modbus::helpers::number_to_payload(data, value, value_type); +} -/** Convert vector response payload to number. - * @param data payload with the data to convert - * @param sensor_value_type defines if 16/32/64 bits or FP32 is used - * @param offset offset to the data in data - * @param bitmask bitmask used for masking and shifting - * @return 64-bit number of the payload - */ -int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, - uint32_t bitmask); +ESPDEPRECATED("Use modbus::helpers::payload_to_number() instead. Removed in 2026.10.0", "2026.4.0") +inline int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, + uint32_t bitmask) { + return modbus::helpers::payload_to_number(data, sensor_value_type, offset, bitmask); +} + +ESPDEPRECATED("Use modbus::helpers::float_to_payload() instead. Removed in 2026.10.0", "2026.4.0") +inline std::vector float_to_payload(float value, SensorValueType value_type) { + return modbus::helpers::float_to_payload(value, value_type); +} class ModbusController; @@ -517,7 +472,7 @@ class ModbusController : public PollingComponent, public modbus::ModbusDevice { * @return float value of data */ inline float payload_to_float(const std::vector &data, const SensorItem &item) { - int64_t number = payload_to_number(data, item.sensor_value_type, item.offset, item.bitmask); + int64_t number = modbus::helpers::payload_to_number(data, item.sensor_value_type, item.offset, item.bitmask); float float_value; if (modbus::helpers::value_type_is_float(item.sensor_value_type)) { @@ -529,19 +484,5 @@ inline float payload_to_float(const std::vector &data, const SensorItem return float_value; } -inline std::vector float_to_payload(float value, SensorValueType value_type) { - int64_t val; - - if (modbus::helpers::value_type_is_float(value_type)) { - val = bit_cast(value); - } else { - val = llroundf(value); - } - - std::vector data; - number_to_payload(data, val, value_type); - return data; -} - } // namespace modbus_controller } // namespace esphome diff --git a/esphome/components/modbus_controller/number/modbus_number.cpp b/esphome/components/modbus_controller/number/modbus_number.cpp index 4a3ec1fc41..ed5d91ec5b 100644 --- a/esphome/components/modbus_controller/number/modbus_number.cpp +++ b/esphome/components/modbus_controller/number/modbus_number.cpp @@ -62,7 +62,7 @@ void ModbusNumber::control(float value) { this->parent_->on_write_register_response(write_cmd.register_type, this->start_address, data); }); } else { - data = float_to_payload(write_value, this->sensor_value_type); + data = modbus::helpers::float_to_payload(write_value, this->sensor_value_type); ESP_LOGD(TAG, "Updating register: connected Sensor=%s start address=0x%X register count=%d new value=%.02f (val=%.02f)", diff --git a/esphome/components/modbus_controller/output/modbus_output.cpp b/esphome/components/modbus_controller/output/modbus_output.cpp index f02d9397ca..e7f1a39716 100644 --- a/esphome/components/modbus_controller/output/modbus_output.cpp +++ b/esphome/components/modbus_controller/output/modbus_output.cpp @@ -34,7 +34,7 @@ void ModbusFloatOutput::write_state(float value) { } // lambda didn't set payload if (data.empty()) { - data = float_to_payload(value, this->sensor_value_type); + data = modbus::helpers::float_to_payload(value, this->sensor_value_type); } ESP_LOGD(TAG, "Updating register: start address=0x%X register count=%d new value=%.02f (val=%.02f)", diff --git a/esphome/components/modbus_controller/select/modbus_select.cpp b/esphome/components/modbus_controller/select/modbus_select.cpp index e2a54d3f60..2cff7e89ee 100644 --- a/esphome/components/modbus_controller/select/modbus_select.cpp +++ b/esphome/components/modbus_controller/select/modbus_select.cpp @@ -9,7 +9,7 @@ static const char *const TAG = "modbus_controller.select"; void ModbusSelect::dump_config() { LOG_SELECT(TAG, "Modbus Controller Select", this); } void ModbusSelect::parse_and_publish(const std::vector &data) { - int64_t value = payload_to_number(data, this->sensor_value_type, this->offset, this->bitmask); + int64_t value = modbus::helpers::payload_to_number(data, this->sensor_value_type, this->offset, this->bitmask); ESP_LOGD(TAG, "New select value %lld from payload", value); @@ -61,7 +61,7 @@ void ModbusSelect::control(size_t index) { } if (data.empty()) { - number_to_payload(data, *mapval, this->sensor_value_type); + modbus::helpers::number_to_payload(data, *mapval, this->sensor_value_type); } else { ESP_LOGV(TAG, "Using payload from write lambda"); } diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index 68aa37c9ed..dbaff04cc6 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -33,10 +33,10 @@ void ModbusSwitch::parse_and_publish(const std::vector &data) { case ModbusRegisterType::DISCRETE_INPUT: case ModbusRegisterType::COIL: // offset for coil is the actual number of the coil not the byte offset - value = coil_from_vector(this->offset, data); + value = modbus::helpers::coil_from_vector(this->offset, data); break; default: - value = get_data(data, this->offset) & this->bitmask; + value = modbus::helpers::get_data(data, this->offset) & this->bitmask; break; } From 64e836f9c8da7cb68ade3cb430f9dbb7b4cecc9b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:49:17 -1000 Subject: [PATCH 32/72] Bump CodSpeedHQ/action from 4.12.1 to 4.13.0 (#15340) 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 ab7a750388..71703652e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -339,7 +339,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@1c8ae4843586d3ba879736b7f6b7b0c990757fab # v4 + uses: CodSpeedHQ/action@d872884a306dd4853acf0f584f4b706cf0cc72a2 # v4 with: run: ${{ steps.build.outputs.binary }} mode: simulation From 2064eef273c878191cd29799329c049693fa60d4 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:53:12 -0400 Subject: [PATCH 33/72] [esp32_hosted] Guard against empty firmware URL in perform() (#15338) --- .../components/esp32_hosted/update/esp32_hosted_update.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index dcd6e643c2..af35d32888 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -448,6 +448,13 @@ void Esp32HostedUpdate::perform(bool force) { return; } +#ifdef USE_ESP32_HOSTED_HTTP_UPDATE + if (this->firmware_url_.empty()) { + ESP_LOGW(TAG, "No firmware URL available, run check first"); + return; + } +#endif + update::UpdateState prev_state = this->state_; this->state_ = update::UPDATE_STATE_INSTALLING; this->update_info_.has_progress = false; From 66b6d36a260dd1900c1786f9edc98c47157c8255 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 1 Apr 2026 07:04:10 +1000 Subject: [PATCH 34/72] [lvgl] Fixes #3 (#15304) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/lvgl/__init__.py | 10 ++----- esphome/components/lvgl/defines.py | 4 --- esphome/components/lvgl/styles.py | 31 +++------------------ esphome/components/lvgl/widgets/__init__.py | 7 +++-- esphome/components/lvgl/widgets/canvas.py | 2 -- esphome/components/lvgl/widgets/keyboard.py | 26 +++++++++++++---- esphome/components/lvgl/widgets/label.py | 2 +- esphome/components/lvgl/widgets/line.py | 19 ++++++------- esphome/components/lvgl/widgets/msgbox.py | 8 ++++-- esphome/components/lvgl/widgets/qrcode.py | 3 +- esphome/components/lvgl/widgets/tabview.py | 4 +-- 11 files changed, 48 insertions(+), 68 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 6377183ef4..736fba759f 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -380,7 +380,8 @@ async def to_code(configs): # This must be done after all widgets are created for comp in helpers.lvgl_components_required: cg.add_define(f"USE_LVGL_{comp.upper()}") - lv_image_formats = df.get_color_formats().copy() + # Currently always need RGB565 for the display buffer, and ARGB8888 is used for layer blending + lv_image_formats = {"RGB565", "ARGB8888"} if { "transform_rotation", "transform_scale", @@ -388,10 +389,6 @@ async def to_code(configs): "transform_scale_y", } & styles_used: df.add_define("LV_COLOR_SCREEN_TRANSP", "1") - lv_image_formats.add("ARGB8888") - lv_image_formats.add( - "RGB565" - ) # Currently always need RGB565 for the display buffer for use in helpers.lv_uses: df.add_define(f"LV_USE_{use.upper()}") cg.add_define(f"USE_LVGL_{use.upper()}") @@ -401,9 +398,6 @@ async def to_code(configs): metadata = get_image_metadata(image_id.id) image_type = IMAGE_TYPE[metadata.image_type] transparent = metadata.transparency != CONF_OPAQUE - if transparent: - # Internal draw layer will use ARGB8888 - lv_image_formats.add("ARGB8888") if image_type == ImageBinary: lv_image_formats.add("I1") if image_type == ImageGrayscale: diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index de5835d7a6..dd51a2f519 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -52,10 +52,6 @@ def get_remapped_uses(): return get_data(KEY_REMAPPED_USES, set()) -def get_color_formats(): - return get_data(KEY_COLOR_FORMATS, set()) - - def add_warning(msg: str): get_warnings().add(msg) diff --git a/esphome/components/lvgl/styles.py b/esphome/components/lvgl/styles.py index 6f43e78f90..793290de73 100644 --- a/esphome/components/lvgl/styles.py +++ b/esphome/components/lvgl/styles.py @@ -4,26 +4,12 @@ import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.core import ID -from .defines import ( - CONF_STYLE_DEFINITIONS, - CONF_THEME, - CONF_TOP_LAYER, - LValidator, - literal, -) +from .defines import CONF_STYLE_DEFINITIONS, CONF_THEME, LValidator, literal from .helpers import add_lv_use -from .lvcode import LambdaContext, LocalVariable, lv +from .lvcode import LambdaContext, lv from .schemas import ALL_STYLES, FULL_STYLE_SCHEMA, remap_property -from .types import ObjUpdateAction, lv_obj_t, lv_style_t -from .widgets import ( - Widget, - add_widgets, - collect_parts, - set_obj_properties, - theme_widget_map, - wait_for_widgets, -) -from .widgets.obj import obj_spec +from .types import ObjUpdateAction, lv_style_t +from .widgets import collect_parts, theme_widget_map, wait_for_widgets def has_style_props(config) -> bool: @@ -112,12 +98,3 @@ async def theme_to_code(config): for state, props in states.items() } theme_widget_map[w_name] = styles - - -async def add_top_layer(lv_component, config): - top_layer = lv.disp_get_layer_top(lv_component.var.get_disp()) - if top_conf := config.get(CONF_TOP_LAYER): - with LocalVariable("top_layer", lv_obj_t, top_layer) as top_layer_obj: - top_w = Widget(top_layer_obj, obj_spec, top_conf) - await set_obj_properties(top_w, top_conf) - await add_widgets(top_w, top_conf) diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index b383196963..0ac4062106 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -1,5 +1,4 @@ import sys -from typing import Any from esphome import codegen as cg, config_validation as cv from esphome.automation import register_action @@ -405,7 +404,11 @@ class Widget: # Map of widgets to their config, used for trigger generation -widget_map: dict[Any, Widget] = {} +widget_map: dict[ID, Widget] = {} + + +def is_widget_completed(name: ID) -> bool: + return name in widget_map class LvScrActType(WidgetType): diff --git a/esphome/components/lvgl/widgets/canvas.py b/esphome/components/lvgl/widgets/canvas.py index 0e40d0dfbe..f12766bae1 100644 --- a/esphome/components/lvgl/widgets/canvas.py +++ b/esphome/components/lvgl/widgets/canvas.py @@ -42,7 +42,6 @@ from ..defines import ( CONF_SRC, CONF_START_ANGLE, addr, - get_color_formats, literal, ) from ..lv_validation import ( @@ -99,7 +98,6 @@ class CanvasType(WidgetType): # RGB565 is 16-bit (2 bytes per pixel), ARGB8888 is 32-bit (4 bytes per pixel) if config[CONF_TRANSPARENT]: color_format = "LV_COLOR_FORMAT_ARGB8888" - get_color_formats().add("ARGB8888") else: color_format = "LV_COLOR_FORMAT_NATIVE" diff --git a/esphome/components/lvgl/widgets/keyboard.py b/esphome/components/lvgl/widgets/keyboard.py index d4a71078d0..029ca5f684 100644 --- a/esphome/components/lvgl/widgets/keyboard.py +++ b/esphome/components/lvgl/widgets/keyboard.py @@ -1,12 +1,15 @@ from esphome.components.key_provider import KeyProvider import esphome.config_validation as cv from esphome.const import CONF_ITEMS, CONF_MODE +from esphome.core import CORE from esphome.cpp_types import std_string +from .. import LvContext from ..defines import CONF_MAIN, KEYBOARD_MODES, literal -from ..helpers import add_lv_use, lvgl_components_required +from ..helpers import lvgl_components_required from ..types import LvCompound, LvType -from . import Widget, WidgetType, get_widgets +from . import Widget, WidgetType, get_widgets, is_widget_completed +from .buttonmatrix import CONF_BUTTONMATRIX from .textarea import CONF_TEXTAREA, lv_textarea_t CONF_KEYBOARD = "keyboard" @@ -41,16 +44,27 @@ class KeyboardType(WidgetType): ) def get_uses(self): - return CONF_KEYBOARD, CONF_TEXTAREA + return CONF_KEYBOARD, CONF_TEXTAREA, CONF_BUTTONMATRIX async def to_code(self, w: Widget, config: dict): lvgl_components_required.add("KEY_LISTENER") lvgl_components_required.add(CONF_KEYBOARD) - add_lv_use("btnmatrix") if mode := config.get(CONF_MODE): await w.set_property(CONF_MODE, await KEYBOARD_MODES.process(mode)) - if ta := await get_widgets(config, CONF_TEXTAREA): - await w.set_property(CONF_TEXTAREA, ta[0].obj) + if textarea := config.get(CONF_TEXTAREA): + # If a textarea is configured, it must be generated before the keyboard can attach it. + # If not yet configured, defer the attachment code. + + async def add_textarea(): + async with LvContext(): + await w.set_property( + CONF_TEXTAREA, (await get_widgets(config, CONF_TEXTAREA))[0].obj + ) + + if is_widget_completed(textarea): + await add_textarea() + else: + CORE.add_job(add_textarea) keyboard_spec = KeyboardType() diff --git a/esphome/components/lvgl/widgets/label.py b/esphome/components/lvgl/widgets/label.py index bb5900b8c9..5ac92f2717 100644 --- a/esphome/components/lvgl/widgets/label.py +++ b/esphome/components/lvgl/widgets/label.py @@ -35,7 +35,7 @@ class LabelType(WidgetType): if (value := config.get(CONF_TEXT)) is not None: await w.set_property(CONF_TEXT, await lv_text.process(value)) await w.set_property(CONF_LONG_MODE, config) - await w.set_property(CONF_RECOLOR, config) + await w.set_property(CONF_RECOLOR, config, processor=lv_bool) label_spec = LabelType() diff --git a/esphome/components/lvgl/widgets/line.py b/esphome/components/lvgl/widgets/line.py index a9b202163f..3112cc28d0 100644 --- a/esphome/components/lvgl/widgets/line.py +++ b/esphome/components/lvgl/widgets/line.py @@ -17,11 +17,6 @@ lv_point_t = cg.global_ns.struct("lv_point_t") lv_point_precise_t = cg.global_ns.struct("lv_point_precise_t") -LINE_SCHEMA = { - cv.Required(CONF_POINTS): cv.ensure_list(point_schema), -} - - async def process_coord(coord): if isinstance(coord, Lambda): return call_lambda(await cg.process_lambda(coord, [], return_type=lv_coord_t)) @@ -34,15 +29,17 @@ class LineType(WidgetType): CONF_LINE, LvType("LvLineType", parents=(LvCompound,)), (CONF_MAIN,), - LINE_SCHEMA, + schema={cv.Required(CONF_POINTS): cv.ensure_list(point_schema)}, + modify_schema={cv.Optional(CONF_POINTS): cv.ensure_list(point_schema)}, ) async def to_code(self, w: Widget, config): - points = [ - [await process_coord(p[CONF_X]), await process_coord(p[CONF_Y])] - for p in config[CONF_POINTS] - ] - lv_add(w.var.set_points(points)) + if CONF_POINTS in config: + points = [ + [await process_coord(p[CONF_X]), await process_coord(p[CONF_Y])] + for p in config[CONF_POINTS] + ] + lv_add(w.var.set_points(points)) line_spec = LineType() diff --git a/esphome/components/lvgl/widgets/msgbox.py b/esphome/components/lvgl/widgets/msgbox.py index af27ee7553..d0e6bfa3a2 100644 --- a/esphome/components/lvgl/widgets/msgbox.py +++ b/esphome/components/lvgl/widgets/msgbox.py @@ -33,6 +33,7 @@ from ..styles import LVStyle from ..types import LV_EVENT, lv_obj_t from . import Widget, WidgetType, add_widgets, set_obj_properties, widget_to_code from .button import button_spec, lv_button_t +from .img import CONF_IMAGE from .label import CONF_LABEL from .obj import obj_spec @@ -41,7 +42,7 @@ CONF_MSGBOX = "msgbox" OUTER_STYLE = LVStyle( "msgbox_outer", { - "bg_opa": 128, + "bg_opa": 0.5, "bg_color": "black", "border_width": 0, "pad_all": 0, @@ -119,6 +120,7 @@ async def msgbox_to_code(top_layer, conf): CONF_BUTTON, CONF_LABEL, CONF_MSGBOX, + CONF_IMAGE, *button_spec.get_uses(), ) if CONF_BUTTON_STYLE in conf: @@ -156,7 +158,7 @@ async def msgbox_to_code(top_layer, conf): with LocalVariable( "close_btn_", lv_obj_t, lv_expr.msgbox_add_close_button(msgbox) ) as close_btn: - lv_obj.remove_event_cb(close_btn, nullptr) + lv_obj.remove_event(close_btn, 0) lv_obj.add_event_cb( close_btn, await close_action.get_lambda(), @@ -170,6 +172,6 @@ async def msgbox_to_code(top_layer, conf): async def msgboxes_to_code(lv_component, config): - top_layer = lv.disp_get_layer_top(lv_component.get_disp()) + top_layer = lv_expr.disp_get_layer_top(lv_component.get_disp()) for conf in config.get(CONF_MSGBOXES, ()): await msgbox_to_code(top_layer, conf) diff --git a/esphome/components/lvgl/widgets/qrcode.py b/esphome/components/lvgl/widgets/qrcode.py index 82c4370543..df76ab6bb0 100644 --- a/esphome/components/lvgl/widgets/qrcode.py +++ b/esphome/components/lvgl/widgets/qrcode.py @@ -2,7 +2,7 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_SIZE, CONF_TEXT -from ..defines import CONF_MAIN, get_color_formats +from ..defines import CONF_MAIN from ..lv_validation import color, lv_color, lv_int, lv_text from ..lvcode import LocalVariable, lv from ..schemas import TEXT_SCHEMA @@ -44,7 +44,6 @@ class QrCodeType(WidgetType): return CONF_CANVAS, CONF_IMAGE async def to_code(self, w: Widget, config): - get_color_formats().add("ARGB8888") await w.set_property( CONF_LIGHT_COLOR, await lv_color.process(config.get(CONF_LIGHT_COLOR)) ) diff --git a/esphome/components/lvgl/widgets/tabview.py b/esphome/components/lvgl/widgets/tabview.py index 60ba664f04..7629b03e9d 100644 --- a/esphome/components/lvgl/widgets/tabview.py +++ b/esphome/components/lvgl/widgets/tabview.py @@ -26,7 +26,7 @@ from ..schemas import container_schema, part_schema from ..types import LV_EVENT, LvType, ObjUpdateAction, lv_obj_t, lv_obj_t_ptr from . import Widget, WidgetType, add_widgets, get_widgets, set_obj_properties from .button import button_spec -from .buttonmatrix import buttonmatrix_spec +from .buttonmatrix import CONF_BUTTONMATRIX, buttonmatrix_spec from .obj import obj_spec CONF_TABVIEW = "tabview" @@ -73,7 +73,7 @@ class TabviewType(WidgetType): ) def get_uses(self): - return "btnmatrix", TYPE_FLEX + return CONF_BUTTONMATRIX, TYPE_FLEX async def to_code(self, w: Widget, config: dict): await w.set_property( From 9dca7e0daf015db9a2dbe1cad391a000df335ae6 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:01:33 -0400 Subject: [PATCH 35/72] [tormatic] Fix UART stream desync on ESP32 (#15337) --- .../components/tormatic/tormatic_cover.cpp | 67 ++++++++++++++----- esphome/components/tormatic/tormatic_cover.h | 1 + 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/esphome/components/tormatic/tormatic_cover.cpp b/esphome/components/tormatic/tormatic_cover.cpp index 77c2e87717..a58228a219 100644 --- a/esphome/components/tormatic/tormatic_cover.cpp +++ b/esphome/components/tormatic/tormatic_cover.cpp @@ -10,6 +10,10 @@ namespace tormatic { static const char *const TAG = "tormatic.cover"; +// Time to poll the UART when flushing after desync. At 9600 baud, a full +// 12-byte message takes ~12.5ms, so 15ms guarantees all bytes have arrived. +static constexpr uint32_t DRAIN_TIMEOUT_MS = 15; + using namespace esphome::cover; void Tormatic::setup() { @@ -256,32 +260,51 @@ void Tormatic::stop_at_target_() { // Read a GateStatus from the unit. The unit only sends messages in response to // status requests or commands, so a message needs to be sent first. optional Tormatic::read_gate_status_() { - if (this->available() < sizeof(MessageHeader)) { + if (!this->pending_hdr_) { + if (this->available() < sizeof(MessageHeader)) { + return {}; + } + + this->pending_hdr_ = this->read_data_(); + if (!this->pending_hdr_) { + return {}; + } + + // Sanity check: valid messages have small payloads (3-4 bytes). A large + // or impossible payload_size means the stream is out of sync (corrupted + // byte, dropped data, etc.). Flush the buffer so we can resync on the + // next request/response cycle. + if (this->pending_hdr_->payload_size() > sizeof(CommandRequestReply)) { + ESP_LOGW(TAG, "Unexpected payload size %" PRIu32 ", flushing rx buffer", this->pending_hdr_->payload_size()); + this->pending_hdr_.reset(); + this->drain_rx_(); + return {}; + } + } + + // Wait for all payload bytes to arrive before processing. + if (this->available() < this->pending_hdr_->payload_size()) { return {}; } - auto o_hdr = this->read_data_(); - if (!o_hdr) { - ESP_LOGE(TAG, "Timeout reading message header"); - return {}; - } - auto hdr = o_hdr.value(); + auto hdr = *this->pending_hdr_; + this->pending_hdr_.reset(); switch (hdr.type) { case STATUS: { if (hdr.payload_size() != sizeof(StatusReply)) { ESP_LOGE(TAG, "Header specifies payload size %" PRIu32 " but size of StatusReply is %zu", hdr.payload_size(), sizeof(StatusReply)); + this->drain_rx_(hdr.payload_size()); + return {}; } - // Read a StatusReply requested by update(). auto o_status = this->read_data_(); if (!o_status) { return {}; } - auto status = o_status.value(); - return status.state; + return o_status->state; } case COMMAND: @@ -344,16 +367,24 @@ template optional Tormatic::read_data_() { return obj; } -// Drain up to n amount of bytes from the uart rx buffer. +// Drain bytes from the uart rx buffer. When n > 0, drain exactly n bytes +// (caller must ensure they are available). When n == 0, poll for 15ms to +// guarantee a full packet time at 9600 baud has elapsed, consuming any +// bytes still in transit. void Tormatic::drain_rx_(uint16_t n) { uint8_t data; - uint16_t count = 0; - while (this->available()) { - this->read_byte(&data); - count++; - - if (n > 0 && count >= n) { - return; + if (n > 0) { + for (uint16_t i = 0; i < n; i++) { + if (!this->read_byte(&data)) { + return; + } + } + } else { + uint32_t start = millis(); + while (millis() - start < DRAIN_TIMEOUT_MS) { + if (this->available()) { + this->read_byte(&data); + } } } } diff --git a/esphome/components/tormatic/tormatic_cover.h b/esphome/components/tormatic/tormatic_cover.h index 534d4bef14..34483ed6a3 100644 --- a/esphome/components/tormatic/tormatic_cover.h +++ b/esphome/components/tormatic/tormatic_cover.h @@ -43,6 +43,7 @@ class Tormatic : public cover::Cover, public uart::UARTDevice, public PollingCom void handle_gate_status_(GateStatus s); uint32_t seq_tx_{0}; + optional pending_hdr_{}; GateStatus current_status_{PAUSED}; From 23dcc5389d12cf4bbfccc0238c959ba84aa77f13 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Mar 2026 12:59:45 -1000 Subject: [PATCH 36/72] [time] Fix strftime %Z and %z returning wrong timezone (#15330) --- esphome/components/time/posix_tz.cpp | 13 +++++++ esphome/components/time/posix_tz.h | 3 ++ esphome/core/time.cpp | 54 ++++++++++++++++++++++++++-- 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index 4d1f0c74c2..f388267abd 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -4,6 +4,7 @@ #include "posix_tz.h" #include +#include namespace esphome::time { @@ -442,6 +443,18 @@ bool parse_posix_tz(const char *tz_string, ParsedTimezone &result) { return internal::parse_dst_rule(p, result.dst_end); } +// Format a POSIX offset (positive = west) as "+HHMM" / "-HHMM" for display. +// Convention: negate POSIX sign so east-of-UTC is positive (ISO 8601 / RFC 2822). +void format_designation(int32_t posix_offset, char *buf, size_t buf_size) { + int32_t display = -posix_offset; + char sign = display >= 0 ? '+' : '-'; + if (display < 0) + display = -display; + int h = display / 3600; + int m = (display % 3600) / 60; + snprintf(buf, buf_size, "%c%02d%02d", sign, h, m); +} + bool epoch_to_local_tm(time_t utc_epoch, const ParsedTimezone &tz, struct tm *out_tm) { if (!out_tm) { return false; diff --git a/esphome/components/time/posix_tz.h b/esphome/components/time/posix_tz.h index c71ba15cd1..be1ddfd689 100644 --- a/esphome/components/time/posix_tz.h +++ b/esphome/components/time/posix_tz.h @@ -36,6 +36,9 @@ struct ParsedTimezone { bool has_dst() const { return this->dst_start.type != DSTRuleType::NONE; } }; +/// Format a POSIX offset as "+HHMM"/"-HHMM" into buf (must be >= 6 bytes). +void format_designation(int32_t posix_offset, char *buf, size_t buf_size); + /// Parse a POSIX TZ string into a ParsedTimezone struct. /// /// @deprecated Remove before 2026.9.0 (bridge code for backward compatibility). diff --git a/esphome/core/time.cpp b/esphome/core/time.cpp index 650c61d37b..b6fc9b90ad 100644 --- a/esphome/core/time.cpp +++ b/esphome/core/time.cpp @@ -2,6 +2,9 @@ #include "helpers.h" #include +#ifdef USE_TIME_TIMEZONE +#include "esphome/components/time/posix_tz.h" +#endif namespace esphome { @@ -14,12 +17,59 @@ uint8_t days_in_month(uint8_t month, uint16_t year) { size_t ESPTime::strftime(char *buffer, size_t buffer_len, const char *format) { struct tm c_tm = this->to_c_tm(); +#ifdef USE_TIME_TIMEZONE + // ::strftime uses libc's internal timezone state for %Z and %z, but we + // eliminated setenv("TZ")/tzset() on embedded platforms to save flash. + // Substitute %Z and %z with correct values from our parsed timezone. + // Quick scan: does format contain %Z or %z (but not %%Z/%%z)? + bool needs_subst = false; + for (const char *p = format; *p; p++) { + if (*p == '%' && *(p + 1)) { + p++; + if (*p == '%') + continue; // %% is a literal %, skip + if (*p == 'Z' || *p == 'z') { + needs_subst = true; + break; + } + } + } + if (needs_subst) { + const auto &tz = time::get_global_tz(); + char designation[6]; // "+HHMM" + null + int32_t offset = c_tm.tm_isdst > 0 ? tz.dst_offset_seconds : tz.std_offset_seconds; + time::format_designation(offset, designation, sizeof(designation)); + + char modified[STRFTIME_BUFFER_SIZE]; + char *out = modified; + char *out_end = modified + sizeof(modified) - 1; + for (const char *p = format; *p && out < out_end; p++) { + if (*p == '%') { + if (*(p + 1) == '%') { + // %% → copy both percent signs (literal %) + *out++ = *p++; + if (out < out_end) + *out++ = *p; + } else if (*(p + 1) == 'Z' || *(p + 1) == 'z') { + p++; // skip the Z/z + for (const char *d = designation; *d && out < out_end; d++) + *out++ = *d; + } else { + *out++ = *p; + } + } else { + *out++ = *p; + } + } + *out = '\0'; + return ::strftime(buffer, buffer_len, modified, &c_tm); + } +#endif return ::strftime(buffer, buffer_len, format, &c_tm); } size_t ESPTime::strftime_to(std::span buffer, const char *format) { - struct tm c_tm = this->to_c_tm(); - size_t len = ::strftime(buffer.data(), buffer.size(), format, &c_tm); + size_t len = this->strftime(buffer.data(), buffer.size(), format); if (len > 0) { return len; } From 15bcd62f222ce4e24be1ea3f6e37b0d1c0b04cab Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:59:53 +1300 Subject: [PATCH 37/72] [internal_temperature] Move code into platform specific files (#15339) --- .../internal_temperature.h | 10 +- .../internal_temperature_bk72xx.cpp | 41 ++++++++ .../internal_temperature_common.cpp | 10 ++ ...ure.cpp => internal_temperature_esp32.cpp} | 96 ++----------------- .../internal_temperature_rp2040.cpp | 31 ++++++ .../internal_temperature_zephyr.cpp | 56 +++++++++++ .../components/internal_temperature/sensor.py | 17 ++++ 7 files changed, 170 insertions(+), 91 deletions(-) create mode 100644 esphome/components/internal_temperature/internal_temperature_bk72xx.cpp create mode 100644 esphome/components/internal_temperature/internal_temperature_common.cpp rename esphome/components/internal_temperature/{internal_temperature.cpp => internal_temperature_esp32.cpp} (54%) create mode 100644 esphome/components/internal_temperature/internal_temperature_rp2040.cpp create mode 100644 esphome/components/internal_temperature/internal_temperature_zephyr.cpp diff --git a/esphome/components/internal_temperature/internal_temperature.h b/esphome/components/internal_temperature/internal_temperature.h index 78e3bcef7d..4810e8478d 100644 --- a/esphome/components/internal_temperature/internal_temperature.h +++ b/esphome/components/internal_temperature/internal_temperature.h @@ -1,18 +1,18 @@ #pragma once -#include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" -namespace esphome { -namespace internal_temperature { +namespace esphome::internal_temperature { class InternalTemperatureSensor : public sensor::Sensor, public PollingComponent { public: +#if defined(USE_ESP32) || (defined(USE_ZEPHYR) && defined(USE_NRF52)) void setup() override; +#endif // USE_ESP32 || (USE_ZEPHYR && USE_NRF52) void dump_config() override; void update() override; }; -} // namespace internal_temperature -} // namespace esphome +} // namespace esphome::internal_temperature diff --git a/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp b/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp new file mode 100644 index 0000000000..31a92f90a5 --- /dev/null +++ b/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp @@ -0,0 +1,41 @@ +#ifdef USE_BK72XX + +#include "esphome/core/log.h" +#include "internal_temperature.h" + +extern "C" { +uint32_t temp_single_get_current_temperature(uint32_t *temp_value); +} + +namespace esphome::internal_temperature { + +static const char *const TAG = "internal_temperature.bk72xx"; + +void InternalTemperatureSensor::update() { + float temperature = NAN; + bool success = false; + + uint32_t raw, result; + result = temp_single_get_current_temperature(&raw); + success = (result == 0); +#if defined(USE_LIBRETINY_VARIANT_BK7231N) + temperature = raw * -0.38f + 156.0f; +#elif defined(USE_LIBRETINY_VARIANT_BK7231T) + temperature = raw * 0.04f; +#else // USE_LIBRETINY_VARIANT + temperature = raw * 0.128f; +#endif // USE_LIBRETINY_VARIANT + + if (success && std::isfinite(temperature)) { + this->publish_state(temperature); + } else { + ESP_LOGD(TAG, "Ignoring invalid temperature (success=%d, value=%.1f)", success, temperature); + if (!this->has_state()) { + this->publish_state(NAN); + } + } +} + +} // namespace esphome::internal_temperature + +#endif // USE_BK72XX diff --git a/esphome/components/internal_temperature/internal_temperature_common.cpp b/esphome/components/internal_temperature/internal_temperature_common.cpp new file mode 100644 index 0000000000..89a7d34333 --- /dev/null +++ b/esphome/components/internal_temperature/internal_temperature_common.cpp @@ -0,0 +1,10 @@ +#include "esphome/core/log.h" +#include "internal_temperature.h" + +namespace esphome::internal_temperature { + +static const char *const TAG = "internal_temperature"; + +void InternalTemperatureSensor::dump_config() { LOG_SENSOR("", "Internal Temperature Sensor", this); } + +} // namespace esphome::internal_temperature diff --git a/esphome/components/internal_temperature/internal_temperature.cpp b/esphome/components/internal_temperature/internal_temperature_esp32.cpp similarity index 54% rename from esphome/components/internal_temperature/internal_temperature.cpp rename to esphome/components/internal_temperature/internal_temperature_esp32.cpp index 567ae6170e..09121fa9c9 100644 --- a/esphome/components/internal_temperature/internal_temperature.cpp +++ b/esphome/components/internal_temperature/internal_temperature_esp32.cpp @@ -1,7 +1,8 @@ -#include "internal_temperature.h" -#include "esphome/core/log.h" - #ifdef USE_ESP32 + +#include "esphome/core/log.h" +#include "internal_temperature.h" + #if defined(USE_ESP32_VARIANT_ESP32) // there is no official API available on the original ESP32 extern "C" { @@ -13,70 +14,20 @@ uint8_t temprature_sens_read(); defined(USE_ESP32_VARIANT_ESP32S3) #include "driver/temperature_sensor.h" #endif // USE_ESP32_VARIANT -#endif // USE_ESP32 -#ifdef USE_RP2040 -#include "Arduino.h" -#endif // USE_RP2040 -#ifdef USE_BK72XX -extern "C" { -uint32_t temp_single_get_current_temperature(uint32_t *temp_value); -} -#endif // USE_BK72XX -#if defined(USE_ZEPHYR) && defined(USE_NRF52) -#include -#include -#endif // USE_ZEPHYR && USE_NRF52 -namespace esphome { -namespace internal_temperature { +namespace esphome::internal_temperature { + +static const char *const TAG = "internal_temperature.esp32"; -static const char *const TAG = "internal_temperature"; -#if defined(USE_ZEPHYR) && defined(USE_NRF52) -static const struct device *const DIE_TEMPERATURE_SENSOR = DEVICE_DT_GET_ONE(nordic_nrf_temp); -#endif // USE_ZEPHYR && USE_NRF52 -#ifdef USE_ESP32 #if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ 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) static temperature_sensor_handle_t tsensNew = NULL; #endif // USE_ESP32_VARIANT -#endif // USE_ESP32 void InternalTemperatureSensor::update() { -#if defined(USE_ZEPHYR) && defined(USE_NRF52) - struct sensor_value value; - int result = sensor_sample_fetch(DIE_TEMPERATURE_SENSOR); - if (result != 0) { - ESP_LOGE(TAG, "Failed to fetch nRF52 die temperature sample (%d)", result); - if (!this->has_state()) { - this->publish_state(NAN); - } - return; - } - - result = sensor_channel_get(DIE_TEMPERATURE_SENSOR, SENSOR_CHAN_DIE_TEMP, &value); - if (result != 0) { - ESP_LOGE(TAG, "Failed to get nRF52 die temperature (%d)", result); - if (!this->has_state()) { - this->publish_state(NAN); - } - return; - } - - const float temperature = value.val1 + (value.val2 / 1000000.0f); - if (std::isfinite(temperature)) { - this->publish_state(temperature); - } else { - ESP_LOGD(TAG, "Ignoring invalid nRF52 temperature (value=%.1f)", temperature); - if (!this->has_state()) { - this->publish_state(NAN); - } - } -#else - float temperature = NAN; bool success = false; -#ifdef USE_ESP32 #if defined(USE_ESP32_VARIANT_ESP32) uint8_t raw = temprature_sens_read(); ESP_LOGV(TAG, "Raw temperature value: %d", raw); @@ -92,23 +43,7 @@ void InternalTemperatureSensor::update() { ESP_LOGE(TAG, "Reading failed (%d)", result); } #endif // USE_ESP32_VARIANT -#endif // USE_ESP32 -#ifdef USE_RP2040 - temperature = analogReadTemp(); - success = (temperature != 0.0f); -#endif // USE_RP2040 -#ifdef USE_BK72XX - uint32_t raw, result; - result = temp_single_get_current_temperature(&raw); - success = (result == 0); -#if defined(USE_LIBRETINY_VARIANT_BK7231N) - temperature = raw * -0.38f + 156.0f; -#elif defined(USE_LIBRETINY_VARIANT_BK7231T) - temperature = raw * 0.04f; -#else // USE_LIBRETINY_VARIANT - temperature = raw * 0.128f; -#endif // USE_LIBRETINY_VARIANT -#endif // USE_BK72XX + if (success && std::isfinite(temperature)) { this->publish_state(temperature); } else { @@ -117,18 +52,9 @@ void InternalTemperatureSensor::update() { this->publish_state(NAN); } } -#endif // USE_ZEPHYR && USE_NRF52 } void InternalTemperatureSensor::setup() { -#if defined(USE_ZEPHYR) && defined(USE_NRF52) - if (!device_is_ready(DIE_TEMPERATURE_SENSOR)) { - ESP_LOGE(TAG, "nRF52 die temperature sensor device %s not ready", DIE_TEMPERATURE_SENSOR->name); - this->mark_failed(); - return; - } -#endif // USE_ZEPHYR && USE_NRF52 -#ifdef USE_ESP32 #if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ 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) @@ -148,10 +74,8 @@ void InternalTemperatureSensor::setup() { return; } #endif // USE_ESP32_VARIANT -#endif // USE_ESP32 } -void InternalTemperatureSensor::dump_config() { LOG_SENSOR("", "Internal Temperature Sensor", this); } +} // namespace esphome::internal_temperature -} // namespace internal_temperature -} // namespace esphome +#endif // USE_ESP32 diff --git a/esphome/components/internal_temperature/internal_temperature_rp2040.cpp b/esphome/components/internal_temperature/internal_temperature_rp2040.cpp new file mode 100644 index 0000000000..66dee9faf7 --- /dev/null +++ b/esphome/components/internal_temperature/internal_temperature_rp2040.cpp @@ -0,0 +1,31 @@ +#ifdef USE_RP2040 + +#include "esphome/core/log.h" +#include "internal_temperature.h" + +#include "Arduino.h" + +namespace esphome::internal_temperature { + +static const char *const TAG = "internal_temperature.rp2040"; + +void InternalTemperatureSensor::update() { + float temperature = NAN; + bool success = false; + + temperature = analogReadTemp(); + success = (temperature != 0.0f); + + if (success && std::isfinite(temperature)) { + this->publish_state(temperature); + } else { + ESP_LOGD(TAG, "Ignoring invalid temperature (success=%d, value=%.1f)", success, temperature); + if (!this->has_state()) { + this->publish_state(NAN); + } + } +} + +} // namespace esphome::internal_temperature + +#endif // USE_RP2040 diff --git a/esphome/components/internal_temperature/internal_temperature_zephyr.cpp b/esphome/components/internal_temperature/internal_temperature_zephyr.cpp new file mode 100644 index 0000000000..be72ab6f51 --- /dev/null +++ b/esphome/components/internal_temperature/internal_temperature_zephyr.cpp @@ -0,0 +1,56 @@ +#if defined(USE_ZEPHYR) && defined(USE_NRF52) + +#include "esphome/core/log.h" +#include "internal_temperature.h" + +#include +#include + +namespace esphome::internal_temperature { + +static const char *const TAG = "internal_temperature.zephyr"; + +static const struct device *const DIE_TEMPERATURE_SENSOR = DEVICE_DT_GET_ONE(nordic_nrf_temp); + +void InternalTemperatureSensor::update() { + struct sensor_value value; + int result = sensor_sample_fetch(DIE_TEMPERATURE_SENSOR); + if (result != 0) { + ESP_LOGE(TAG, "Failed to fetch nRF52 die temperature sample (%d)", result); + if (!this->has_state()) { + this->publish_state(NAN); + } + return; + } + + result = sensor_channel_get(DIE_TEMPERATURE_SENSOR, SENSOR_CHAN_DIE_TEMP, &value); + if (result != 0) { + ESP_LOGE(TAG, "Failed to get nRF52 die temperature (%d)", result); + if (!this->has_state()) { + this->publish_state(NAN); + } + return; + } + + const float temperature = value.val1 + (value.val2 / 1000000.0f); + if (std::isfinite(temperature)) { + this->publish_state(temperature); + } else { + ESP_LOGD(TAG, "Ignoring invalid nRF52 temperature (value=%.1f)", temperature); + if (!this->has_state()) { + this->publish_state(NAN); + } + } +} + +void InternalTemperatureSensor::setup() { + if (!device_is_ready(DIE_TEMPERATURE_SENSOR)) { + ESP_LOGE(TAG, "nRF52 die temperature sensor device %s not ready", DIE_TEMPERATURE_SENSOR->name); + this->mark_failed(); + return; + } +} + +} // namespace esphome::internal_temperature + +#endif // USE_ZEPHYR && USE_NRF52 diff --git a/esphome/components/internal_temperature/sensor.py b/esphome/components/internal_temperature/sensor.py index 965e7f0520..6d79e08675 100644 --- a/esphome/components/internal_temperature/sensor.py +++ b/esphome/components/internal_temperature/sensor.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import sensor from esphome.components.zephyr import zephyr_add_prj_conf +from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( DEVICE_CLASS_TEMPERATURE, @@ -11,6 +12,7 @@ from esphome.const import ( PLATFORM_RP2040, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, + PlatformFramework, ) from esphome.core import CORE @@ -39,3 +41,18 @@ async def to_code(config): if CORE.using_zephyr and CORE.is_nrf52: zephyr_add_prj_conf("SENSOR", True) zephyr_add_prj_conf("TEMP_NRF5", True) + + +FILTER_SOURCE_FILES = filter_source_files_from_platform( + { + "internal_temperature_esp32.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, + "internal_temperature_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "internal_temperature_bk72xx.cpp": { + PlatformFramework.BK72XX_ARDUINO, + }, + "internal_temperature_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, + } +) From b71c406e704f1d751484404737a91c2b8035ddbb Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Wed, 1 Apr 2026 01:04:07 +0200 Subject: [PATCH 38/72] [uart] fix baud rate not applied on `load_settings()` for ESP32 (IDF) (#15341) --- .../uart/uart_component_esp_idf.cpp | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index 6d9d44e97f..93e43e0372 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -147,6 +147,20 @@ void IDFUARTComponent::load_settings(bool dump_config) { return; } + // uart_param_config must be called after uart_driver_install and before any + // other uart_set_*() calls. The driver installation resets the UART peripheral + // registers to their default state, overwriting any previously configured baud + // rate or framing settings. Calling uart_param_config here ensures the requested + // settings are applied after the reset and before pin routing, inversion, and + // threshold configuration. + uart_config_t uart_config = this->get_config_(); + err = uart_param_config(this->uart_num_, &uart_config); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_param_config failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1; int8_t rx = this->rx_pin_ != nullptr ? this->rx_pin_->get_pin() : -1; int8_t flow_control = this->flow_control_pin_ != nullptr ? this->flow_control_pin_->get_pin() : -1; @@ -214,22 +228,15 @@ void IDFUARTComponent::load_settings(bool dump_config) { return; } + // Per ESP-IDF docs, uart_set_mode() must be called only after uart_driver_install(). auto mode = this->flow_control_pin_ != nullptr ? UART_MODE_RS485_HALF_DUPLEX : UART_MODE_UART; - err = uart_set_mode(this->uart_num_, mode); // per docs, must be called only after uart_driver_install() + err = uart_set_mode(this->uart_num_, mode); if (err != ESP_OK) { ESP_LOGW(TAG, "uart_set_mode failed: %s", esp_err_to_name(err)); this->mark_failed(); return; } - uart_config_t uart_config = this->get_config_(); - err = uart_param_config(this->uart_num_, &uart_config); - if (err != ESP_OK) { - ESP_LOGW(TAG, "uart_param_config failed: %s", esp_err_to_name(err)); - this->mark_failed(); - return; - } - #ifdef USE_UART_WAKE_LOOP_ON_RX // Register ISR callback to wake the main loop when UART data arrives. // The callback runs in ISR context and uses vTaskNotifyGiveFromISR() to From 4a23ba7d8a2b28f5245674fc8337227e6f50ed08 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 31 Mar 2026 19:06:48 -0400 Subject: [PATCH 39/72] [mixer] Fix memory leak in mixer task on stop/start cycles (#15185) --- .../mixer/speaker/mixer_speaker.cpp | 274 +++++++++--------- 1 file changed, 137 insertions(+), 137 deletions(-) diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 9d11abb327..0fabc68c70 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -597,173 +597,173 @@ void MixerSpeaker::audio_mixer_task(void *params) { xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STARTING); - std::unique_ptr output_transfer_buffer = audio::AudioSinkTransferBuffer::create( - this_mixer->audio_stream_info_.value().ms_to_bytes(TRANSFER_BUFFER_DURATION_MS)); + { // Ensure C++ objects fall out of scope to ensure proper cleanup before stopping the task + std::unique_ptr output_transfer_buffer = audio::AudioSinkTransferBuffer::create( + this_mixer->audio_stream_info_.value().ms_to_bytes(TRANSFER_BUFFER_DURATION_MS)); - if (output_transfer_buffer == nullptr) { - xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STOPPED | MIXER_TASK_ERR_ESP_NO_MEM); + if (output_transfer_buffer == nullptr) { + xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STOPPED | MIXER_TASK_ERR_ESP_NO_MEM); - vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it - } - - output_transfer_buffer->set_sink(this_mixer->output_speaker_); - - xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_RUNNING); - - bool sent_finished = false; - - // Pre-allocate vectors to avoid heap allocation in the loop (max 8 source speakers per schema) - FixedVector speakers_with_data; - FixedVector> transfer_buffers_with_data; - speakers_with_data.init(this_mixer->source_speakers_.size()); - transfer_buffers_with_data.init(this_mixer->source_speakers_.size()); - - while (true) { - uint32_t event_group_bits = xEventGroupGetBits(this_mixer->event_group_); - if (event_group_bits & MIXER_TASK_COMMAND_STOP) { - break; + vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it } - // Never shift the data in the output transfer buffer to avoid unnecessary, slow data moves - output_transfer_buffer->transfer_data_to_sink(pdMS_TO_TICKS(TASK_DELAY_MS), false); + output_transfer_buffer->set_sink(this_mixer->output_speaker_); - const uint32_t output_frames_free = - this_mixer->audio_stream_info_.value().bytes_to_frames(output_transfer_buffer->free()); + xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_RUNNING); - speakers_with_data.clear(); - transfer_buffers_with_data.clear(); + bool sent_finished = false; - for (auto &speaker : this_mixer->source_speakers_) { - if (speaker->is_running() && !speaker->get_pause_state()) { - // Speaker is running and not paused, so it possibly can provide audio data - std::shared_ptr transfer_buffer = speaker->get_transfer_buffer().lock(); - if (transfer_buffer.use_count() == 0) { - // No transfer buffer allocated, so skip processing this speaker - continue; - } - speaker->process_data_from_source(transfer_buffer, 0); // Transfers and ducks audio from source ring buffers + // Pre-allocate vectors to avoid heap allocation in the loop (max 8 source speakers per schema) + FixedVector speakers_with_data; + FixedVector> transfer_buffers_with_data; + speakers_with_data.init(this_mixer->source_speakers_.size()); + transfer_buffers_with_data.init(this_mixer->source_speakers_.size()); - if (transfer_buffer->available() > 0) { - // Store the locked transfer buffers in their own vector to avoid releasing ownership until after the loop - transfer_buffers_with_data.push_back(transfer_buffer); - speakers_with_data.push_back(speaker); + while (true) { + uint32_t event_group_bits = xEventGroupGetBits(this_mixer->event_group_); + if (event_group_bits & MIXER_TASK_COMMAND_STOP) { + break; + } + + // Never shift the data in the output transfer buffer to avoid unnecessary, slow data moves + output_transfer_buffer->transfer_data_to_sink(pdMS_TO_TICKS(TASK_DELAY_MS), false); + + const uint32_t output_frames_free = + this_mixer->audio_stream_info_.value().bytes_to_frames(output_transfer_buffer->free()); + + speakers_with_data.clear(); + transfer_buffers_with_data.clear(); + + for (auto &speaker : this_mixer->source_speakers_) { + if (speaker->is_running() && !speaker->get_pause_state()) { + // Speaker is running and not paused, so it possibly can provide audio data + std::shared_ptr transfer_buffer = speaker->get_transfer_buffer().lock(); + if (transfer_buffer.use_count() == 0) { + // No transfer buffer allocated, so skip processing this speaker + continue; + } + speaker->process_data_from_source(transfer_buffer, 0); // Transfers and ducks audio from source ring buffers + + if (transfer_buffer->available() > 0) { + // Store the locked transfer buffers in their own vector to avoid releasing ownership until after the loop + transfer_buffers_with_data.push_back(transfer_buffer); + speakers_with_data.push_back(speaker); + } } } - } - if (transfer_buffers_with_data.empty()) { - // No audio available for transferring, block task temporarily - delay(TASK_DELAY_MS); - continue; - } + if (transfer_buffers_with_data.empty()) { + // No audio available for transferring, block task temporarily + delay(TASK_DELAY_MS); + continue; + } - uint32_t frames_to_mix = output_frames_free; + uint32_t frames_to_mix = output_frames_free; - if ((transfer_buffers_with_data.size() == 1) || this_mixer->queue_mode_) { - // Only one speaker has audio data, just copy samples over + if ((transfer_buffers_with_data.size() == 1) || this_mixer->queue_mode_) { + // Only one speaker has audio data, just copy samples over - audio::AudioStreamInfo active_stream_info = speakers_with_data[0]->get_audio_stream_info(); + audio::AudioStreamInfo active_stream_info = speakers_with_data[0]->get_audio_stream_info(); - if (active_stream_info.get_sample_rate() == - this_mixer->output_speaker_->get_audio_stream_info().get_sample_rate()) { - // Speaker's sample rate matches the output speaker's, copy directly + if (active_stream_info.get_sample_rate() == + this_mixer->output_speaker_->get_audio_stream_info().get_sample_rate()) { + // Speaker's sample rate matches the output speaker's, copy directly - const uint32_t frames_available_in_buffer = - active_stream_info.bytes_to_frames(transfer_buffers_with_data[0]->available()); - frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer); - copy_frames(reinterpret_cast(transfer_buffers_with_data[0]->get_buffer_start()), active_stream_info, - reinterpret_cast(output_transfer_buffer->get_buffer_end()), - this_mixer->audio_stream_info_.value(), frames_to_mix); + const uint32_t frames_available_in_buffer = + active_stream_info.bytes_to_frames(transfer_buffers_with_data[0]->available()); + frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer); + copy_frames(reinterpret_cast(transfer_buffers_with_data[0]->get_buffer_start()), + active_stream_info, reinterpret_cast(output_transfer_buffer->get_buffer_end()), + this_mixer->audio_stream_info_.value(), frames_to_mix); - // Set playback delay for newly contributing source - if (!speakers_with_data[0]->has_contributed_.load(std::memory_order_acquire)) { - speakers_with_data[0]->playback_delay_frames_.store( - this_mixer->frames_in_pipeline_.load(std::memory_order_acquire), std::memory_order_release); - speakers_with_data[0]->has_contributed_.store(true, std::memory_order_release); + // Set playback delay for newly contributing source + if (!speakers_with_data[0]->has_contributed_.load(std::memory_order_acquire)) { + speakers_with_data[0]->playback_delay_frames_.store( + this_mixer->frames_in_pipeline_.load(std::memory_order_acquire), std::memory_order_release); + speakers_with_data[0]->has_contributed_.store(true, std::memory_order_release); + } + + // Update source speaker pending frames + speakers_with_data[0]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release); + transfer_buffers_with_data[0]->decrease_buffer_length(active_stream_info.frames_to_bytes(frames_to_mix)); + + // Update output transfer buffer length and pipeline frame count + output_transfer_buffer->increase_buffer_length( + this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix)); + this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release); + } else { + // Speaker's stream info doesn't match the output speaker's, so it's a new source speaker + if (!this_mixer->output_speaker_->is_stopped()) { + if (!sent_finished) { + this_mixer->output_speaker_->finish(); + sent_finished = true; // Avoid repeatedly sending the finish command + } + } else { + // Speaker has finished writing the current audio, update the stream information and restart the speaker + this_mixer->audio_stream_info_ = + audio::AudioStreamInfo(active_stream_info.get_bits_per_sample(), this_mixer->output_channels_, + active_stream_info.get_sample_rate()); + this_mixer->output_speaker_->set_audio_stream_info(this_mixer->audio_stream_info_.value()); + this_mixer->output_speaker_->start(); + // Reset pipeline frame count since we're starting fresh with a new sample rate + this_mixer->frames_in_pipeline_.store(0, std::memory_order_release); + sent_finished = false; + } + } + } else { + // Determine how many frames to mix + for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) { + const uint32_t frames_available_in_buffer = speakers_with_data[i]->get_audio_stream_info().bytes_to_frames( + transfer_buffers_with_data[i]->available()); + frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer); + } + int16_t *primary_buffer = reinterpret_cast(transfer_buffers_with_data[0]->get_buffer_start()); + audio::AudioStreamInfo primary_stream_info = speakers_with_data[0]->get_audio_stream_info(); + + // Mix two streams together + for (size_t i = 1; i < transfer_buffers_with_data.size(); ++i) { + mix_audio_samples(primary_buffer, primary_stream_info, + reinterpret_cast(transfer_buffers_with_data[i]->get_buffer_start()), + speakers_with_data[i]->get_audio_stream_info(), + reinterpret_cast(output_transfer_buffer->get_buffer_end()), + this_mixer->audio_stream_info_.value(), frames_to_mix); + + if (i != transfer_buffers_with_data.size() - 1) { + // Need to mix more streams together, point primary buffer and stream info to the already mixed output + primary_buffer = reinterpret_cast(output_transfer_buffer->get_buffer_end()); + primary_stream_info = this_mixer->audio_stream_info_.value(); + } } - // Update source speaker pending frames - speakers_with_data[0]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release); - transfer_buffers_with_data[0]->decrease_buffer_length(active_stream_info.frames_to_bytes(frames_to_mix)); + // Get current pipeline depth for delay calculation (before incrementing) + uint32_t current_pipeline_frames = this_mixer->frames_in_pipeline_.load(std::memory_order_acquire); - // Update output transfer buffer length and pipeline frame count + // Update source transfer buffer lengths and add new audio durations to the source speaker pending playbacks + for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) { + // Set playback delay for newly contributing sources + if (!speakers_with_data[i]->has_contributed_.load(std::memory_order_acquire)) { + speakers_with_data[i]->playback_delay_frames_.store(current_pipeline_frames, std::memory_order_release); + speakers_with_data[i]->has_contributed_.store(true, std::memory_order_release); + } + + speakers_with_data[i]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release); + transfer_buffers_with_data[i]->decrease_buffer_length( + speakers_with_data[i]->get_audio_stream_info().frames_to_bytes(frames_to_mix)); + } + + // Update output transfer buffer length and pipeline frame count (once, not per source) output_transfer_buffer->increase_buffer_length( this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix)); this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release); - } else { - // Speaker's stream info doesn't match the output speaker's, so it's a new source speaker - if (!this_mixer->output_speaker_->is_stopped()) { - if (!sent_finished) { - this_mixer->output_speaker_->finish(); - sent_finished = true; // Avoid repeatedly sending the finish command - } - } else { - // Speaker has finished writing the current audio, update the stream information and restart the speaker - this_mixer->audio_stream_info_ = - audio::AudioStreamInfo(active_stream_info.get_bits_per_sample(), this_mixer->output_channels_, - active_stream_info.get_sample_rate()); - this_mixer->output_speaker_->set_audio_stream_info(this_mixer->audio_stream_info_.value()); - this_mixer->output_speaker_->start(); - // Reset pipeline frame count since we're starting fresh with a new sample rate - this_mixer->frames_in_pipeline_.store(0, std::memory_order_release); - sent_finished = false; - } } - } else { - // Determine how many frames to mix - for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) { - const uint32_t frames_available_in_buffer = - speakers_with_data[i]->get_audio_stream_info().bytes_to_frames(transfer_buffers_with_data[i]->available()); - frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer); - } - int16_t *primary_buffer = reinterpret_cast(transfer_buffers_with_data[0]->get_buffer_start()); - audio::AudioStreamInfo primary_stream_info = speakers_with_data[0]->get_audio_stream_info(); - - // Mix two streams together - for (size_t i = 1; i < transfer_buffers_with_data.size(); ++i) { - mix_audio_samples(primary_buffer, primary_stream_info, - reinterpret_cast(transfer_buffers_with_data[i]->get_buffer_start()), - speakers_with_data[i]->get_audio_stream_info(), - reinterpret_cast(output_transfer_buffer->get_buffer_end()), - this_mixer->audio_stream_info_.value(), frames_to_mix); - - if (i != transfer_buffers_with_data.size() - 1) { - // Need to mix more streams together, point primary buffer and stream info to the already mixed output - primary_buffer = reinterpret_cast(output_transfer_buffer->get_buffer_end()); - primary_stream_info = this_mixer->audio_stream_info_.value(); - } - } - - // Get current pipeline depth for delay calculation (before incrementing) - uint32_t current_pipeline_frames = this_mixer->frames_in_pipeline_.load(std::memory_order_acquire); - - // Update source transfer buffer lengths and add new audio durations to the source speaker pending playbacks - for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) { - // Set playback delay for newly contributing sources - if (!speakers_with_data[i]->has_contributed_.load(std::memory_order_acquire)) { - speakers_with_data[i]->playback_delay_frames_.store(current_pipeline_frames, std::memory_order_release); - speakers_with_data[i]->has_contributed_.store(true, std::memory_order_release); - } - - speakers_with_data[i]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release); - transfer_buffers_with_data[i]->decrease_buffer_length( - speakers_with_data[i]->get_audio_stream_info().frames_to_bytes(frames_to_mix)); - } - - // Update output transfer buffer length and pipeline frame count (once, not per source) - output_transfer_buffer->increase_buffer_length( - this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix)); - this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release); } - } - xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STOPPING); + xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STOPPING); + } // Reset pipeline frame count since the task is stopping this_mixer->frames_in_pipeline_.store(0, std::memory_order_release); - output_transfer_buffer.reset(); - xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STOPPED); vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it From 954227b2031962cf074615981b471613206be47b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Mar 2026 13:26:26 -1000 Subject: [PATCH 40/72] [esp32_ble_tracker] Restart BLE scan after OTA failure (#15308) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp | 6 ++++++ esphome/components/esp32_ble_tracker/esp32_ble_tracker.h | 3 +++ 2 files changed, 9 insertions(+) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 6dce70f839..f2d60be641 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -88,12 +88,18 @@ void ESP32BLETracker::setup() { #ifdef USE_OTA_STATE_LISTENER void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) { if (state == ota::OTA_STARTED) { + this->scan_continuous_before_ota_ = this->scan_continuous_; this->stop_scan(); #ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT for (auto *client : this->clients_) { client->disconnect(); } #endif + } else if ((state == ota::OTA_ERROR || state == ota::OTA_ABORT) && this->scan_continuous_before_ota_) { + this->scan_continuous_before_ota_ = false; + this->scan_continuous_ = true; + // Do not restart scanning immediately here; allow loop() to + // safely restart scanning once the scanner and all clients are idle. } } #endif diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index ff69a4dcd2..43405b02b7 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -431,6 +431,9 @@ class ESP32BLETracker : public Component, ScannerState scanner_state_{ScannerState::IDLE}; bool scan_continuous_; bool scan_active_; +#ifdef USE_OTA_STATE_LISTENER + bool scan_continuous_before_ota_{false}; +#endif bool ble_was_disabled_{true}; bool raw_advertisements_{false}; bool parse_advertisements_{false}; From e261b5de655dbcda6a1fbda83aec1fbdfd8c5c1c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Mar 2026 08:22:25 -1000 Subject: [PATCH 41/72] [time] Point to valid IANA timezone list on validation failure (#15110) --- esphome/components/time/__init__.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 9821046a73..c31ccbc7ea 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -284,13 +284,23 @@ def validate_tz(value: str) -> str: tzfile = _load_tzdata(value) if tzfile is not None: value = _extract_tz_string(tzfile) + is_iana = True + else: + is_iana = False # Validate that the POSIX TZ string is parseable (skip empty strings) if value: try: parse_posix_tz_python(value) except ValueError as e: - raise cv.Invalid(f"Invalid POSIX timezone string '{value}': {e}") from e + if is_iana: + raise cv.Invalid(f"Invalid POSIX timezone string '{value}': {e}") from e + raise cv.Invalid( + f"Invalid POSIX timezone string '{value}': {e}. " + f"If you meant to use an IANA timezone, check the list of valid " + f"timezones at " + f"https://en.wikipedia.org/wiki/List_of_tz_database_time_zones" + ) from e return value From f7b410fd0c14e0bf00bddab3f03227087957d7f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Mar 2026 08:47:15 -1000 Subject: [PATCH 42/72] [wifi] Fix roaming attempt counter reset on disconnect during scan (#15099) --- esphome/components/wifi/wifi_component.cpp | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 09f883ed61..aa4e691cd0 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -269,11 +269,11 @@ bool CompactString::operator==(const StringRef &other) const { /// │ │ │ /// │ ┌──────────────┼──────────────┐ │ /// │ ↓ ↓ ↓ │ -/// │ scan error no better AP +10 dB better AP │ +/// │ disconnect no better AP +10 dB better AP │ /// │ │ │ │ │ /// │ ↓ ↓ ↓ │ /// │ ┌──────────────────────────────┐ ┌──────────────────────────┐ │ -/// │ │ → IDLE │ │ CONNECTING │ │ +/// │ │ → RECONNECTING │ │ CONNECTING │ │ /// │ │ (counter preserved) │ │ (process_roaming_scan_) │ │ /// │ └──────────────────────────────┘ └────────────┬─────────────┘ │ /// │ │ │ @@ -296,7 +296,7 @@ bool CompactString::operator==(const StringRef &other) const { /// │ Key behaviors: │ /// │ - After 3 checks: attempts >= 3, stop checking │ /// │ - Non-roaming disconnect: clear_roaming_state_() resets counter │ -/// │ - Scan error (SCANNING→IDLE): counter preserved │ +/// │ - Disconnect during scan (SCANNING→RECONNECTING): counter preserved │ /// │ - Roaming success (CONNECTING→IDLE): counter reset (can roam again) │ /// │ - Roaming fail (RECONNECTING→IDLE): counter preserved (ping-pong) │ /// └──────────────────────────────────────────────────────────────────────┘ @@ -2075,9 +2075,10 @@ void WiFiComponent::retry_connect() { ESP_LOGD(TAG, "Roam failed, reconnecting (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS); this->roaming_state_ = RoamingState::RECONNECTING; } else if (this->roaming_state_ == RoamingState::SCANNING) { - // Roam scan failed (e.g., scan error on ESP8266) - go back to idle, keep counter - ESP_LOGD(TAG, "Roam scan failed (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS); - this->roaming_state_ = RoamingState::IDLE; + // Disconnected during roam scan - transition to RECONNECTING so the attempts + // counter is preserved when reconnection succeeds (IDLE would reset it) + ESP_LOGD(TAG, "Disconnected during roam scan (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS); + this->roaming_state_ = RoamingState::RECONNECTING; } else if (this->roaming_state_ == RoamingState::IDLE) { // Not a roaming-triggered reconnect, reset state this->clear_roaming_state_(); From d9788aaefc337faaa20e4cde5681368ae3e18a4f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Mar 2026 13:58:36 -1000 Subject: [PATCH 43/72] [wifi] Reduce ESP8266 roaming scan dwell time to match ESP32 (#15127) --- .../components/wifi/wifi_component_esp8266.cpp | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 5514f1c6be..517b59da37 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -664,11 +664,22 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { config.show_hidden = 1; #if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(2, 4, 0) config.scan_type = passive ? WIFI_SCAN_TYPE_PASSIVE : WIFI_SCAN_TYPE_ACTIVE; + // Use shorter dwell times for roaming scans - we only need to detect strong + // nearby APs, not do a thorough survey. This also reduces off-channel time + // which can cause Beacon Timeout disconnects on some APs. + // Roaming times match the ESP32 IDF scan defaults. + static constexpr uint32_t SCAN_PASSIVE_DEFAULT_MS = 500; + static constexpr uint32_t SCAN_PASSIVE_ROAMING_MS = 300; + static constexpr uint32_t SCAN_ACTIVE_MIN_DEFAULT_MS = 400; + static constexpr uint32_t SCAN_ACTIVE_MAX_DEFAULT_MS = 500; + static constexpr uint32_t SCAN_ACTIVE_MIN_ROAMING_MS = 100; + static constexpr uint32_t SCAN_ACTIVE_MAX_ROAMING_MS = 300; + bool roaming = this->roaming_state_ == RoamingState::SCANNING; if (passive) { - config.scan_time.passive = 500; + config.scan_time.passive = roaming ? SCAN_PASSIVE_ROAMING_MS : SCAN_PASSIVE_DEFAULT_MS; } else { - config.scan_time.active.min = 400; - config.scan_time.active.max = 500; + config.scan_time.active.min = roaming ? SCAN_ACTIVE_MIN_ROAMING_MS : SCAN_ACTIVE_MIN_DEFAULT_MS; + config.scan_time.active.max = roaming ? SCAN_ACTIVE_MAX_ROAMING_MS : SCAN_ACTIVE_MAX_DEFAULT_MS; } #endif bool ret = wifi_station_scan(&config, &WiFiComponent::s_wifi_scan_done_callback); From 2f2c7ac393b23f52e660b928ace0fcd0becf9176 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:04:27 -0400 Subject: [PATCH 44/72] [sx127x] Fix FIFO read corruption (#15114) --- esphome/components/sx127x/sx127x.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/sx127x/sx127x.cpp b/esphome/components/sx127x/sx127x.cpp index 66957a7342..0fddfdccdb 100644 --- a/esphome/components/sx127x/sx127x.cpp +++ b/esphome/components/sx127x/sx127x.cpp @@ -38,14 +38,18 @@ void SX127x::write_register_(uint8_t reg, uint8_t value) { void SX127x::read_fifo_(std::vector &packet) { this->enable(); this->write_byte(REG_FIFO & 0x7F); - this->read_array(packet.data(), packet.size()); + for (auto &byte : packet) { + byte = this->transfer_byte(0x00); + } this->disable(); } void SX127x::write_fifo_(const std::vector &packet) { this->enable(); this->write_byte(REG_FIFO | 0x80); - this->write_array(packet.data(), packet.size()); + for (const auto &byte : packet) { + this->transfer_byte(byte); + } this->disable(); } From cb15e98765e69377bf7369cf95fb21386dd21d57 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Mar 2026 14:03:56 -1000 Subject: [PATCH 45/72] [datetime] Fix state_as_esptime() returning invalid timestamp (#15128) --- .../components/datetime/datetime_entity.cpp | 3 + esphome/core/time.cpp | 2 +- esphome/core/time.h | 18 ++++-- tests/components/time/posix_tz_parser.cpp | 56 ++++++++++++++++++- 4 files changed, 71 insertions(+), 8 deletions(-) diff --git a/esphome/components/datetime/datetime_entity.cpp b/esphome/components/datetime/datetime_entity.cpp index 730abb3ca8..fa50271f04 100644 --- a/esphome/components/datetime/datetime_entity.cpp +++ b/esphome/components/datetime/datetime_entity.cpp @@ -60,6 +60,9 @@ ESPTime DateTimeEntity::state_as_esptime() const { obj.year = this->year_; obj.month = this->month_; obj.day_of_month = this->day_; + obj.day_of_week = 0; + obj.day_of_year = 0; + obj.is_dst = false; obj.hour = this->hour_; obj.minute = this->minute_; obj.second = this->second_; diff --git a/esphome/core/time.cpp b/esphome/core/time.cpp index 6add82e7d1..650c61d37b 100644 --- a/esphome/core/time.cpp +++ b/esphome/core/time.cpp @@ -231,7 +231,7 @@ void ESPTime::increment_day() { void ESPTime::recalc_timestamp_utc(bool use_day_of_year) { time_t res = 0; - if (!this->fields_in_range()) { + if (!this->fields_in_range(false, use_day_of_year)) { this->timestamp = -1; return; } diff --git a/esphome/core/time.h b/esphome/core/time.h index 1716c51ffd..ed47432038 100644 --- a/esphome/core/time.h +++ b/esphome/core/time.h @@ -79,11 +79,19 @@ struct ESPTime { /// Check if this ESPTime is valid (all fields in range and year is greater than or equal to 2019) bool is_valid() const { return this->year >= 2019 && this->fields_in_range(); } - /// Check if all time fields of this ESPTime are in range. - bool fields_in_range() const { - return this->second < 61 && this->minute < 60 && this->hour < 24 && this->day_of_week > 0 && - this->day_of_week < 8 && this->day_of_year > 0 && this->day_of_year < 367 && this->month > 0 && - this->month < 13 && this->day_of_month > 0 && this->day_of_month <= days_in_month(this->month, this->year); + /// Check if time fields are in range. + /// @param check_day_of_week validate day_of_week (not always available when constructing from date/time fields) + /// @param check_day_of_year validate day_of_year (not always available when constructing from date/time fields) + bool fields_in_range(bool check_day_of_week = true, bool check_day_of_year = true) const { + bool valid = this->second < 61 && this->minute < 60 && this->hour < 24 && this->month > 0 && this->month < 13 && + this->day_of_month > 0 && this->day_of_month <= days_in_month(this->month, this->year); + if (check_day_of_week) { + valid = valid && this->day_of_week > 0 && this->day_of_week < 8; + } + if (check_day_of_year) { + valid = valid && this->day_of_year > 0 && this->day_of_year < 367; + } + return valid; } /** Convert a string to ESPTime struct as specified by the format argument. diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index d1747ef5b1..b7cf2a4afa 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -1036,8 +1036,6 @@ static time_t esptime_recalc_local(int year, int month, int day, int hour, int m t.hour = hour; t.minute = min; t.second = sec; - t.day_of_week = 1; // Placeholder for fields_in_range() - t.day_of_year = 1; t.recalc_timestamp_local(); return t.timestamp; } @@ -1187,6 +1185,60 @@ TEST(RecalcTimestampLocal, NonDefaultTransitionTime) { EXPECT_EQ(esp_result, libc_result); } +TEST(RecalcTimestampLocal, MinimalFieldsWithoutDayOfWeekOrYear) { + // Regression test for issue #15115: DateTimeEntity::state_as_esptime() constructs + // an ESPTime with only year/month/day/hour/minute/second set (no day_of_week or + // day_of_year). recalc_timestamp_local() must work without those fields. + const char *tz_str = "CET-1CEST,M3.5.0,M10.5.0"; + setenv("TZ", tz_str, 1); + tzset(); + time::ParsedTimezone tz{}; + ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + set_global_tz(tz); + + // Construct ESPTime with only date/time fields (like state_as_esptime does) + ESPTime t{}; + t.year = 2026; + t.month = 3; + t.day_of_month = 20; + t.hour = 23; + t.minute = 14; + t.second = 55; + // day_of_week and day_of_year are deliberately left as 0 + t.recalc_timestamp_local(); + + // Must NOT return -1 (the bug: fields_in_range() rejected valid times) + EXPECT_NE(t.timestamp, -1); + + // Verify against libc + time_t libc_result = libc_mktime(2026, 3, 20, 23, 14, 55); + EXPECT_EQ(t.timestamp, libc_result); +} + +TEST(RecalcTimestampLocal, MinimalFieldsNoDST) { + // Same test but with a timezone that has no DST + const char *tz_str = "IST-5:30"; + setenv("TZ", tz_str, 1); + tzset(); + time::ParsedTimezone tz{}; + ASSERT_TRUE(parse_posix_tz(tz_str, tz)); + set_global_tz(tz); + + ESPTime t{}; + t.year = 2026; + t.month = 3; + t.day_of_month = 23; + t.hour = 10; + t.minute = 0; + t.second = 0; + t.recalc_timestamp_local(); + + EXPECT_NE(t.timestamp, -1); + + time_t libc_result = libc_mktime(2026, 3, 23, 10, 0, 0); + EXPECT_EQ(t.timestamp, libc_result); +} + TEST(RecalcTimestampLocal, YearBoundaryDST) { // Test southern hemisphere DST across year boundary // Australia/Sydney: DST active from October to April (spans Jan 1) From f5f99071fb51f9cd61ed3bcf2e2f63e3a1555c77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Mar 2026 14:04:17 -1000 Subject: [PATCH 46/72] [wifi] Fix roaming counter reset from delayed disconnect and successful retry (#15126) --- esphome/components/wifi/wifi_component.cpp | 66 +++++++++++++++++----- esphome/components/wifi/wifi_component.h | 6 ++ 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index aa4e691cd0..8656df7f4d 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -287,18 +287,25 @@ bool CompactString::operator==(const StringRef &other) const { /// │ │ (counter reset to 0) │ │ (retry_connect called) │ /// │ └──────────────────────────────────┘ └───────────┬─────────────┘ /// │ │ │ -/// │ ↓ │ -/// │ ┌───────────────────────┐ │ -/// │ │ → IDLE │ │ -/// │ │ (counter preserved!) │ │ -/// │ └───────────────────────┘ │ +/// │ ┌─────────┴─────────┐ │ +/// │ ↓ ↓ │ +/// │ on target BSSID on other AP │ +/// │ │ │ │ +/// │ ↓ ↓ │ +/// │ ┌──────────────────┐ ┌────────────┐│ +/// │ │ → IDLE │ │ → IDLE ││ +/// │ │ (counter reset) │ │ (counter ││ +/// │ │ (roam worked!) │ │ preserved)││ +/// │ └──────────────────┘ └────────────┘│ /// │ │ /// │ Key behaviors: │ /// │ - After 3 checks: attempts >= 3, stop checking │ /// │ - Non-roaming disconnect: clear_roaming_state_() resets counter │ -/// │ - Disconnect during scan (SCANNING→RECONNECTING): counter preserved │ +/// │ - Disconnect during scan (SCANNING→RECONNECTING): counter preserved │ +/// │ - Disconnect after scan (within grace period): counter preserved │ /// │ - Roaming success (CONNECTING→IDLE): counter reset (can roam again) │ -/// │ - Roaming fail (RECONNECTING→IDLE): counter preserved (ping-pong) │ +/// │ - Roaming success via retry (on target BSSID): counter reset │ +/// │ - Roaming fail (RECONNECTING on other AP): counter preserved │ /// └──────────────────────────────────────────────────────────────────────┘ // Use if-chain instead of switch to avoid jump table in RODATA (wastes RAM on ESP8266) @@ -1583,17 +1590,33 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { // Only preserve attempts if reconnecting after a failed roam attempt // This prevents ping-pong between APs when a roam target is unreachable if (this->roaming_state_ == RoamingState::CONNECTING) { - // Successful roam to better AP - reset attempts so we can roam again later + // Successful roam to better AP on first try - reset attempts so we can roam again later ESP_LOGD(TAG, "Roam successful"); this->roaming_attempts_ = 0; } else if (this->roaming_state_ == RoamingState::RECONNECTING) { - // Failed roam, reconnected via normal recovery - keep attempts to prevent ping-pong - ESP_LOGD(TAG, "Reconnected after failed roam (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS); + // Check if we ended up on the roam target despite needing a retry + // (e.g., first connect failed but scan-based retry found and connected to the same better AP) + bssid_t current_bssid = this->wifi_bssid(); + if (this->roaming_target_bssid_ != bssid_t{} && current_bssid == this->roaming_target_bssid_) { + char bssid_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + format_mac_addr_upper(current_bssid.data(), bssid_buf); + ESP_LOGD(TAG, "Roam successful (via retry, attempt %u/%u) to %s", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS, + bssid_buf); + this->roaming_attempts_ = 0; + } else if (this->roaming_target_bssid_ != bssid_t{}) { + // Failed roam to specific target, reconnected to different AP - keep attempts to prevent ping-pong + ESP_LOGD(TAG, "Reconnected after failed roam (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS); + } else { + // Reconnected after scan-induced disconnect (no roam target) - keep attempts + ESP_LOGD(TAG, "Reconnected after roam scan (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS); + } } else { // Normal connection (boot, credentials changed, etc.) this->roaming_attempts_ = 0; } this->roaming_state_ = RoamingState::IDLE; + this->roaming_target_bssid_ = {}; + this->roaming_scan_end_ = 0; // Clear all priority penalties - the next reconnect will happen when an AP disconnects, // which means the landscape has likely changed and previous tracked failures are stale @@ -2080,8 +2103,16 @@ void WiFiComponent::retry_connect() { ESP_LOGD(TAG, "Disconnected during roam scan (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS); this->roaming_state_ = RoamingState::RECONNECTING; } else if (this->roaming_state_ == RoamingState::IDLE) { - // Not a roaming-triggered reconnect, reset state - this->clear_roaming_state_(); + // Check if a roaming scan recently completed - on ESP8266, going off-channel + // during scan can cause a delayed Beacon Timeout 8-20 seconds after scan finishes. + // Transition to RECONNECTING so the attempts counter is preserved on reconnect. + if (this->roaming_scan_end_ != 0 && millis() - this->roaming_scan_end_ < ROAMING_SCAN_GRACE_PERIOD) { + ESP_LOGD(TAG, "Disconnect after roam scan (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS); + this->roaming_state_ = RoamingState::RECONNECTING; + } else { + // Not a roaming-triggered reconnect, reset state + this->clear_roaming_state_(); + } } // RECONNECTING: keep state and counter, still trying to reconnect @@ -2316,6 +2347,8 @@ bool WiFiScanResult::operator==(const WiFiScanResult &rhs) const { return this-> void WiFiComponent::clear_roaming_state_() { this->roaming_attempts_ = 0; this->roaming_last_check_ = 0; + this->roaming_scan_end_ = 0; + this->roaming_target_bssid_ = {}; this->roaming_state_ = RoamingState::IDLE; } @@ -2383,7 +2416,7 @@ void WiFiComponent::check_roaming_(uint32_t now) { // Guard: skip scan if signal is already good (no meaningful improvement possible) int8_t rssi = this->wifi_rssi(); if (rssi > ROAMING_GOOD_RSSI) { - ESP_LOGV(TAG, "Roam check skipped, signal good (%d dBm, attempt %u/%u)", rssi, this->roaming_attempts_, + ESP_LOGD(TAG, "Roam check skipped, signal good (%d dBm, attempt %u/%u)", rssi, this->roaming_attempts_, ROAMING_MAX_ATTEMPTS); return; } @@ -2397,6 +2430,9 @@ void WiFiComponent::process_roaming_scan_() { this->scan_done_ = false; // Default to IDLE - will be set to CONNECTING if we find a better AP this->roaming_state_ = RoamingState::IDLE; + // Record when scan completed so delayed disconnects (e.g., ESP8266 Beacon Timeout) + // can be attributed to the scan and avoid resetting the attempts counter + this->roaming_scan_end_ = millis(); // Get current connection info int8_t current_rssi = this->wifi_rssi(); @@ -2445,10 +2481,12 @@ void WiFiComponent::process_roaming_scan_() { WiFiAP roam_params = *selected; apply_scan_result_to_params(roam_params, *best); - this->release_scan_results_(); // Mark as roaming attempt - affects retry behavior if connection fails this->roaming_state_ = RoamingState::CONNECTING; + this->roaming_target_bssid_ = best->get_bssid(); // Must read before releasing scan results + + this->release_scan_results_(); // Connect directly - wifi_sta_connect_ handles disconnect internally this->start_connecting(roam_params); diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 883cc1344b..27a46a8e03 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -779,6 +779,10 @@ class WiFiComponent : public Component { static constexpr int8_t ROAMING_MIN_IMPROVEMENT = 10; // dB static constexpr int8_t ROAMING_GOOD_RSSI = -49; // Skip scan if signal is excellent static constexpr uint8_t ROAMING_MAX_ATTEMPTS = 3; + // Grace period after roaming scan completes. If WiFi disconnects within this + // window (e.g., ESP8266 Beacon Timeout caused by going off-channel during scan), + // the disconnect is treated as roaming-related and the attempts counter is preserved. + static constexpr uint32_t ROAMING_SCAN_GRACE_PERIOD = 30 * 1000; // 30 seconds // 4-byte members float output_power_{NAN}; @@ -786,6 +790,7 @@ class WiFiComponent : public Component { uint32_t last_connected_{0}; uint32_t reboot_timeout_{}; uint32_t roaming_last_check_{0}; + uint32_t roaming_scan_end_{0}; // Timestamp when last roaming scan completed #ifdef USE_WIFI_AP uint32_t ap_timeout_{}; #endif @@ -810,6 +815,7 @@ class WiFiComponent : public Component { bool error_from_callback_{false}; RetryHiddenMode retry_hidden_mode_{RetryHiddenMode::BLIND_RETRY}; RoamingState roaming_state_{RoamingState::IDLE}; + bssid_t roaming_target_bssid_{}; // BSSID of the AP we're trying to roam to #if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) WiFiPowerSaveMode configured_power_save_{WIFI_POWER_SAVE_NONE}; #endif From 92642df419ad4ca8a7dae98ddb85cef050e8e5c2 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 24 Mar 2026 20:21:04 -0400 Subject: [PATCH 47/72] [wifi] Filter fast_connect by band_mode and use background scan for roaming (#15152) --- esphome/components/wifi/wifi_component.cpp | 8 ++++++++ esphome/components/wifi/wifi_component.h | 2 ++ esphome/components/wifi/wifi_component_esp_idf.cpp | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 8656df7f4d..6163571bc9 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -2229,6 +2229,14 @@ bool WiFiComponent::load_fast_connect_settings_(WiFiAP ¶ms) { params.set_hidden(false); ESP_LOGD(TAG, "Loaded fast_connect settings"); +#if defined(USE_ESP32) && defined(SOC_WIFI_SUPPORT_5G) + if ((this->band_mode_ == WIFI_BAND_MODE_5G_ONLY && fast_connect_save.channel < FIRST_5GHZ_CHANNEL) || + (this->band_mode_ == WIFI_BAND_MODE_2G_ONLY && fast_connect_save.channel >= FIRST_5GHZ_CHANNEL)) { + ESP_LOGW(TAG, "Saved channel %u not allowed by band mode, ignoring fast_connect", fast_connect_save.channel); + this->selected_sta_index_ = -1; + return false; + } +#endif return true; } diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 27a46a8e03..c88fffc512 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -774,6 +774,8 @@ class WiFiComponent : public Component { SemaphoreHandle_t high_performance_semaphore_{nullptr}; #endif + static constexpr uint8_t FIRST_5GHZ_CHANNEL = 36; + // Post-connect roaming constants static constexpr uint32_t ROAMING_CHECK_INTERVAL = 5 * 60 * 1000; // 5 minutes static constexpr int8_t ROAMING_MIN_IMPROVEMENT = 10; // dB diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index eca3f19249..0280becc7e 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -961,6 +961,11 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { config.scan_time.active.min = 100; config.scan_time.active.max = 300; } + // When scanning while connected (roaming), return to home channel between + // each scanned channel to maintain the connection (helps with BLE/WiFi coexistence) + if (this->roaming_state_ == RoamingState::SCANNING) { + config.coex_background_scan = true; + } esp_err_t err = esp_wifi_scan_start(&config, false); if (err != ESP_OK) { From 7b5a4b466a135ab658317e928830a65c05f9d19a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:50:37 -0400 Subject: [PATCH 48/72] [uart] Fix debug callback missing peeked byte and reading past end (#15169) --- esphome/components/uart/uart_component_esp_idf.cpp | 6 ++++-- esphome/components/uart/uart_component_host.cpp | 6 ++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index 8168e49805..82120b1a5f 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -324,6 +324,9 @@ bool IDFUARTComponent::peek_byte(uint8_t *data) { } bool IDFUARTComponent::read_array(uint8_t *data, size_t len) { + if (len == 0) { + return false; + } size_t length_to_read = len; int32_t read_len = 0; if (!this->check_read_timeout_(len)) @@ -331,11 +334,10 @@ bool IDFUARTComponent::read_array(uint8_t *data, size_t len) { if (this->has_peek_) { length_to_read--; *data = this->peek_byte_; - data++; this->has_peek_ = false; } if (length_to_read > 0) - read_len = uart_read_bytes(this->uart_num_, data, length_to_read, 20 / portTICK_PERIOD_MS); + read_len = uart_read_bytes(this->uart_num_, data + (len - length_to_read), length_to_read, 20 / portTICK_PERIOD_MS); #ifdef USE_UART_DEBUGGER for (size_t i = 0; i < len; i++) { this->debug_callback_.call(UART_DIRECTION_RX, data[i]); diff --git a/esphome/components/uart/uart_component_host.cpp b/esphome/components/uart/uart_component_host.cpp index 66026f3ccd..e9c101816e 100644 --- a/esphome/components/uart/uart_component_host.cpp +++ b/esphome/components/uart/uart_component_host.cpp @@ -235,16 +235,14 @@ bool HostUartComponent::read_array(uint8_t *data, size_t len) { } if (!this->check_read_timeout_(len)) return false; - uint8_t *data_ptr = data; size_t length_to_read = len; if (this->has_peek_) { length_to_read--; - *data_ptr = this->peek_byte_; - data_ptr++; + *data = this->peek_byte_; this->has_peek_ = false; } if (length_to_read > 0) { - int sz = ::read(this->file_descriptor_, data_ptr, length_to_read); + int sz = ::read(this->file_descriptor_, data + (len - length_to_read), length_to_read); if (sz == -1) { this->update_error_(strerror(errno)); return false; From 3fd3dcc7e575e52f0924ebdd4fa2fa0356762c0c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:13:24 -0400 Subject: [PATCH 49/72] [sgp4x] Fix NOx index_offset default (should be 1, not 100) (#15212) --- esphome/components/sgp4x/sensor.py | 39 ++++++++++++++++++------------ 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/esphome/components/sgp4x/sensor.py b/esphome/components/sgp4x/sensor.py index ab78ab59d9..8d52ffb4f2 100644 --- a/esphome/components/sgp4x/sensor.py +++ b/esphome/components/sgp4x/sensor.py @@ -44,20 +44,27 @@ def validate_sensors(config): return config -GAS_SENSOR = cv.Schema( - { - cv.Optional(CONF_ALGORITHM_TUNING): cv.Schema( - { - cv.Optional(CONF_INDEX_OFFSET, default=100): cv.int_, - cv.Optional(CONF_LEARNING_TIME_OFFSET_HOURS, default=12): cv.int_, - cv.Optional(CONF_LEARNING_TIME_GAIN_HOURS, default=12): cv.int_, - cv.Optional(CONF_GATING_MAX_DURATION_MINUTES, default=720): cv.int_, - cv.Optional(CONF_STD_INITIAL, default=50): cv.int_, - cv.Optional(CONF_GAIN_FACTOR, default=230): cv.int_, - } - ) - } -) +def _gas_sensor_schema(index_offset_default: int): + return cv.Schema( + { + cv.Optional(CONF_ALGORITHM_TUNING): cv.Schema( + { + cv.Optional( + CONF_INDEX_OFFSET, default=index_offset_default + ): cv.int_, + cv.Optional(CONF_LEARNING_TIME_OFFSET_HOURS, default=12): cv.int_, + cv.Optional(CONF_LEARNING_TIME_GAIN_HOURS, default=12): cv.int_, + cv.Optional(CONF_GATING_MAX_DURATION_MINUTES, default=720): cv.int_, + cv.Optional(CONF_STD_INITIAL, default=50): cv.int_, + cv.Optional(CONF_GAIN_FACTOR, default=230): cv.int_, + } + ) + } + ) + + +VOC_SENSOR = _gas_sensor_schema(100) +NOX_SENSOR = _gas_sensor_schema(1) CONFIG_SCHEMA = cv.All( cv.Schema( @@ -68,13 +75,13 @@ CONFIG_SCHEMA = cv.All( accuracy_decimals=0, device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, - ).extend(GAS_SENSOR), + ).extend(VOC_SENSOR), cv.Optional(CONF_NOX): sensor.sensor_schema( icon=ICON_RADIATOR, accuracy_decimals=0, device_class=DEVICE_CLASS_AQI, state_class=STATE_CLASS_MEASUREMENT, - ).extend(GAS_SENSOR), + ).extend(NOX_SENSOR), cv.Optional(CONF_STORE_BASELINE, default=True): cv.boolean, cv.Optional(CONF_VOC_BASELINE): cv.hex_uint16_t, cv.Optional(CONF_COMPENSATION): cv.Schema( From 3d8a3a91f25cc2e620c4d04e3b5aa9fd1773921b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Mar 2026 15:38:06 -1000 Subject: [PATCH 50/72] [esp32_ble_server] Fix set_value action with static data lists (#15285) --- .../components/esp32_ble_server/ble_server_automations.h | 2 ++ tests/components/esp32_ble_server/common.yaml | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/esphome/components/esp32_ble_server/ble_server_automations.h b/esphome/components/esp32_ble_server/ble_server_automations.h index fe18600280..0bbfdffd5b 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.h +++ b/esphome/components/esp32_ble_server/ble_server_automations.h @@ -70,6 +70,7 @@ template class BLECharacteristicSetValueAction : public Action, buffer) + void set_buffer(std::initializer_list buffer) { this->buffer_ = std::vector(buffer); } void set_buffer(ByteBuffer buffer) { this->set_buffer(buffer.get_data()); } void play(const Ts &...x) override { // If the listener is already set, do nothing @@ -115,6 +116,7 @@ template class BLEDescriptorSetValueAction : public Action, buffer) + void set_buffer(std::initializer_list buffer) { this->buffer_ = std::vector(buffer); } void set_buffer(ByteBuffer buffer) { this->set_buffer(buffer.get_data()); } void play(const Ts &...x) override { this->parent_->set_value(this->buffer_.value(x...)); } diff --git a/tests/components/esp32_ble_server/common.yaml b/tests/components/esp32_ble_server/common.yaml index 7fe0b2eb5f..4e34049038 100644 --- a/tests/components/esp32_ble_server/common.yaml +++ b/tests/components/esp32_ble_server/common.yaml @@ -69,3 +69,11 @@ esp32_ble_server: - ble_server.descriptor.set_value: id: test_change_descriptor value: !lambda return bytebuffer::ByteBuffer::wrap({0x03, 0x04, 0x05}).get_data(); + - ble_server.characteristic.set_value: + id: test_change_characteristic + value: + data: [0xfc, 0xef, 0xfe, 0x86] + - ble_server.descriptor.set_value: + id: test_change_descriptor + value: + data: [0x01, 0x02, 0x03] From d79cf1d7183ed1b72a825a116be798120bbd775e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 Mar 2026 11:57:52 -1000 Subject: [PATCH 51/72] [esp8266] Add enable_scanf_float option (#15284) --- esphome/components/esp8266/__init__.py | 62 +++++++++++++++---- .../components/esp8266/test.esp8266-ard.yaml | 3 + tests/unit_tests/components/test_esp8266.py | 62 +++++++++++++++++++ 3 files changed, 116 insertions(+), 11 deletions(-) create mode 100644 tests/unit_tests/components/test_esp8266.py diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 16043b6d69..2081145096 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -1,5 +1,6 @@ import logging from pathlib import Path +import re import esphome.codegen as cg import esphome.config_validation as cv @@ -18,8 +19,9 @@ from esphome.const import ( PLATFORM_ESP8266, ThreadModel, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority from esphome.helpers import copy_file_if_changed +from esphome.types import ConfigType from .boards import BOARDS, ESP8266_LD_SCRIPTS from .const import ( @@ -40,12 +42,42 @@ from .const import ( ) from .gpio import PinInitialState, add_pin_initial_states_array +CONF_ENABLE_SCANF_FLOAT = "enable_scanf_float" +# Heuristically matches scanf/sscanf calls with float format specifiers. +# Standard scanf float conversions: %f %F %e %E %g %G %a %A +# With optional modifiers: %*f (suppression), %8f (width), %lf %Lf (length) +# Also matches non-standard patterns like %.2f as a heuristic — these are +# invalid in scanf but users may write them by analogy with printf. +# Uses [^;]*? to stay within a single statement, preventing false positives +# from e.g. sscanf(buf, "%d", &x); printf("%f", val); +_SCANF_FLOAT_RE = re.compile(r"scanf\s*\([^;]*?%[*\d.]*[hlL]*[feEgGaAF]") + CODEOWNERS = ["@esphome/core"] _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["preferences"] IS_TARGET_PLATFORM = True +def lambdas_use_scanf_float(config: ConfigType) -> bool: + """Check if any lambda in the config uses scanf with a float format specifier. + + Comments are stripped before matching to avoid false positives from + commented-out code. The cost of a false positive is only ~8KB flash. + """ + stack: list = [config] + while stack: + obj = stack.pop() + if isinstance(obj, Lambda): + src = obj.comment_remover(obj.value) + if _SCANF_FLOAT_RE.search(src): + return True + elif isinstance(obj, dict): + stack.extend(obj.values()) + elif isinstance(obj, list): + stack.extend(obj) + return False + + def set_core_data(config): CORE.data[KEY_ESP8266] = {} CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_ESP8266 @@ -181,6 +213,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ENABLE_SERIAL): cv.boolean, cv.Optional(CONF_ENABLE_SERIAL1): cv.boolean, cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean, + cv.Optional(CONF_ENABLE_SCANF_FLOAT): cv.boolean, } ), set_core_data, @@ -201,16 +234,23 @@ async def to_code(config): cg.add_define("ESPHOME_VARIANT", "ESP8266") cg.add_define(ThreadModel.SINGLE) - cg.add_platformio_option( - "extra_scripts", - [ - "pre:testing_mode.py", - "pre:exclude_updater.py", - "pre:exclude_waveform.py", - "pre:remove_float_scanf.py", - "post:post_build.py", - ], - ) + enable_scanf_float = config.get(CONF_ENABLE_SCANF_FLOAT) + if enable_scanf_float is None and lambdas_use_scanf_float(CORE.config): + enable_scanf_float = True + _LOGGER.warning( + "Lambda uses scanf with a float format specifier; " + "enabling scanf float support (~8KB flash)" + ) + + extra_scripts = [ + "pre:testing_mode.py", + "pre:exclude_updater.py", + "pre:exclude_waveform.py", + ] + if not enable_scanf_float: + extra_scripts.append("pre:remove_float_scanf.py") + extra_scripts.append("post:post_build.py") + cg.add_platformio_option("extra_scripts", extra_scripts) conf = config[CONF_FRAMEWORK] cg.add_platformio_option("framework", "arduino") diff --git a/tests/components/esp8266/test.esp8266-ard.yaml b/tests/components/esp8266/test.esp8266-ard.yaml index c77218f7a3..ba70c1a6a4 100644 --- a/tests/components/esp8266/test.esp8266-ard.yaml +++ b/tests/components/esp8266/test.esp8266-ard.yaml @@ -14,3 +14,6 @@ esphome: assert(x == 95); x = clamp_at_most(x, 40); assert(x == 40); + - lambda: |- + float value = 0.0f; + sscanf("3.14", "%f", &value); diff --git a/tests/unit_tests/components/test_esp8266.py b/tests/unit_tests/components/test_esp8266.py new file mode 100644 index 0000000000..318fd2d889 --- /dev/null +++ b/tests/unit_tests/components/test_esp8266.py @@ -0,0 +1,62 @@ +"""Tests for ESP8266 component.""" + +import pytest + +from esphome.components.esp8266 import lambdas_use_scanf_float +from esphome.core import Lambda +from esphome.types import ConfigType + + +@pytest.mark.parametrize( + ("src", "expected"), + [ + # Basic float formats + ('sscanf(buf, "%f", &v)', True), + ('sscanf(buf, "%F", &v)', True), + ('sscanf(buf, "%e", &v)', True), + ('sscanf(buf, "%E", &v)', True), + ('sscanf(buf, "%g", &v)', True), + ('sscanf(buf, "%G", &v)', True), + ('sscanf(buf, "%a", &v)', True), + ('sscanf(buf, "%A", &v)', True), + # With modifiers + ('sscanf(buf, "%lf", &v)', True), + ('sscanf(buf, "%Lf", &v)', True), + ('sscanf(buf, "%8lf", &v)', True), + ('sscanf(buf, "%*f")', True), + ('sscanf(buf, "%.2f", &v)', True), + # Mixed formats + ('sscanf(buf, "%d,%f", &a, &b)', True), + # fscanf and std::sscanf + ('fscanf(fp, "%f", &v)', True), + ('std::sscanf(buf, "%f", &v)', True), + # Multi-line + ('sscanf(buf,\n"%f", &v)', True), + # No float format + ('sscanf(buf, "%d", &v)', False), + ('sscanf(buf, "%s", s)', False), + # printf not scanf + ('printf("%f", val)', False), + # %f in a different statement after scanf + ('sscanf(buf, "%d", &x); printf("%f", val);', False), + # scanf %f in comment only + ('// sscanf(buf, "%f", &v)\nsscanf(buf, "%d", &x)', False), + ('/* sscanf(buf, "%f") */\nsscanf(buf, "%d", &x)', False), + ], +) +def test_lambdas_use_scanf_float(src: str, expected: bool) -> None: + """Test scanf float detection in lambda source.""" + config: ConfigType = {"test": [Lambda(src)]} + assert lambdas_use_scanf_float(config) is expected + + +def test_lambdas_use_scanf_float_no_lambdas() -> None: + """Test with config containing no lambdas.""" + config: ConfigType = {"key": "value", "list": [1, 2]} + assert lambdas_use_scanf_float(config) is False + + +def test_lambdas_use_scanf_float_nested() -> None: + """Test detection in deeply nested config.""" + config: ConfigType = {"a": {"b": {"c": [Lambda('sscanf(buf, "%f", &v)')]}}} + assert lambdas_use_scanf_float(config) is True From 9cd7c5e700f954272742250467b2ad703d6f5e60 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 30 Mar 2026 13:15:02 -0500 Subject: [PATCH 52/72] [thermostat] Fix stale `max_runtime_exceeded` causing spurious supplemental heating/cooling (#15274) --- .../components/thermostat/thermostat_climate.cpp | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index d52a22f880..eb3e756bc2 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -606,6 +606,16 @@ void ThermostatClimate::switch_to_action_(climate::ClimateAction action, bool pu } void ThermostatClimate::switch_to_supplemental_action_(climate::ClimateAction action) { + // Always cancel max-runtime timers and clear exceeded flags when transitioning to idle/off, + // even if supplemental_action_ is already idle (early-return path). This prevents a stale + // heating_max_runtime_exceeded_ flag from triggering supplemental on the next heating cycle + // when HEATING_MAX_RUN_TIME fires while the main action is already IDLE. + if (action == climate::CLIMATE_ACTION_OFF || action == climate::CLIMATE_ACTION_IDLE) { + this->cancel_timer_(thermostat::THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME); + this->cancel_timer_(thermostat::THERMOSTAT_TIMER_HEATING_MAX_RUN_TIME); + this->cooling_max_runtime_exceeded_ = false; + this->heating_max_runtime_exceeded_ = false; + } // setup_complete_ helps us ensure an action is called immediately after boot if ((action == this->supplemental_action_) && this->setup_complete_) { // already in target mode @@ -975,8 +985,10 @@ void ThermostatClimate::cooling_on_timer_callback_() { void ThermostatClimate::fan_mode_timer_callback_() { ESP_LOGVV(TAG, "fan_mode timer expired"); this->switch_to_fan_mode_(this->fan_mode.value_or(climate::CLIMATE_FAN_ON)); - if (this->supports_fan_only_action_uses_fan_mode_timer_) + if (this->supports_fan_only_action_uses_fan_mode_timer_) { this->switch_to_action_(this->compute_action_()); + this->switch_to_supplemental_action_(this->compute_supplemental_action_()); + } } void ThermostatClimate::fanning_off_timer_callback_() { From 3bf45d8fe026309f8a8e60f49bdb15c564788529 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:22:29 -0400 Subject: [PATCH 53/72] [haier] Fix hOn half-degree temperature setting (#15312) --- esphome/components/haier/hon_climate.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index b8889ef2bd..b027b0f295 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -677,7 +677,6 @@ haier_protocol::HaierMessage HonClimate::get_control_message() { this->quiet_mode_state_ = (SwitchState) ((uint8_t) this->quiet_mode_state_ & 0b01); } out_data->beeper_status = ((!this->get_beeper_state()) || (!has_hvac_settings)) ? 1 : 0; - control_out_buffer[4] = 0; // This byte should be cleared before setting values out_data->display_status = this->get_display_state() ? 1 : 0; this->display_status_ = (SwitchState) ((uint8_t) this->display_status_ & 0b01); out_data->health_mode = this->get_health_mode() ? 1 : 0; From 66a4acafd061b37f71f49cd7c6348df9a7c9eb9e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:01:33 -0400 Subject: [PATCH 54/72] [tormatic] Fix UART stream desync on ESP32 (#15337) --- .../components/tormatic/tormatic_cover.cpp | 67 ++++++++++++++----- esphome/components/tormatic/tormatic_cover.h | 1 + 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/esphome/components/tormatic/tormatic_cover.cpp b/esphome/components/tormatic/tormatic_cover.cpp index 37a269088e..a48dece840 100644 --- a/esphome/components/tormatic/tormatic_cover.cpp +++ b/esphome/components/tormatic/tormatic_cover.cpp @@ -9,6 +9,10 @@ namespace tormatic { static const char *const TAG = "tormatic.cover"; +// Time to poll the UART when flushing after desync. At 9600 baud, a full +// 12-byte message takes ~12.5ms, so 15ms guarantees all bytes have arrived. +static constexpr uint32_t DRAIN_TIMEOUT_MS = 15; + using namespace esphome::cover; void Tormatic::setup() { @@ -255,32 +259,51 @@ void Tormatic::stop_at_target_() { // Read a GateStatus from the unit. The unit only sends messages in response to // status requests or commands, so a message needs to be sent first. optional Tormatic::read_gate_status_() { - if (this->available() < sizeof(MessageHeader)) { + if (!this->pending_hdr_) { + if (this->available() < sizeof(MessageHeader)) { + return {}; + } + + this->pending_hdr_ = this->read_data_(); + if (!this->pending_hdr_) { + return {}; + } + + // Sanity check: valid messages have small payloads (3-4 bytes). A large + // or impossible payload_size means the stream is out of sync (corrupted + // byte, dropped data, etc.). Flush the buffer so we can resync on the + // next request/response cycle. + if (this->pending_hdr_->payload_size() > sizeof(CommandRequestReply)) { + ESP_LOGW(TAG, "Unexpected payload size %" PRIu32 ", flushing rx buffer", this->pending_hdr_->payload_size()); + this->pending_hdr_.reset(); + this->drain_rx_(); + return {}; + } + } + + // Wait for all payload bytes to arrive before processing. + if (this->available() < this->pending_hdr_->payload_size()) { return {}; } - auto o_hdr = this->read_data_(); - if (!o_hdr) { - ESP_LOGE(TAG, "Timeout reading message header"); - return {}; - } - auto hdr = o_hdr.value(); + auto hdr = *this->pending_hdr_; + this->pending_hdr_.reset(); switch (hdr.type) { case STATUS: { if (hdr.payload_size() != sizeof(StatusReply)) { ESP_LOGE(TAG, "Header specifies payload size %d but size of StatusReply is %d", hdr.payload_size(), sizeof(StatusReply)); + this->drain_rx_(hdr.payload_size()); + return {}; } - // Read a StatusReply requested by update(). auto o_status = this->read_data_(); if (!o_status) { return {}; } - auto status = o_status.value(); - return status.state; + return o_status->state; } case COMMAND: @@ -343,16 +366,24 @@ template optional Tormatic::read_data_() { return obj; } -// Drain up to n amount of bytes from the uart rx buffer. +// Drain bytes from the uart rx buffer. When n > 0, drain exactly n bytes +// (caller must ensure they are available). When n == 0, poll for 15ms to +// guarantee a full packet time at 9600 baud has elapsed, consuming any +// bytes still in transit. void Tormatic::drain_rx_(uint16_t n) { uint8_t data; - uint16_t count = 0; - while (this->available()) { - this->read_byte(&data); - count++; - - if (n > 0 && count >= n) { - return; + if (n > 0) { + for (uint16_t i = 0; i < n; i++) { + if (!this->read_byte(&data)) { + return; + } + } + } else { + uint32_t start = millis(); + while (millis() - start < DRAIN_TIMEOUT_MS) { + if (this->available()) { + this->read_byte(&data); + } } } } diff --git a/esphome/components/tormatic/tormatic_cover.h b/esphome/components/tormatic/tormatic_cover.h index 534d4bef14..34483ed6a3 100644 --- a/esphome/components/tormatic/tormatic_cover.h +++ b/esphome/components/tormatic/tormatic_cover.h @@ -43,6 +43,7 @@ class Tormatic : public cover::Cover, public uart::UARTDevice, public PollingCom void handle_gate_status_(GateStatus s); uint32_t seq_tx_{0}; + optional pending_hdr_{}; GateStatus current_status_{PAUSED}; From dc634b8c7b5218b9b5b0a9c561ffa4a80254bb57 Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Wed, 1 Apr 2026 01:04:07 +0200 Subject: [PATCH 55/72] [uart] fix baud rate not applied on `load_settings()` for ESP32 (IDF) (#15341) --- .../uart/uart_component_esp_idf.cpp | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index 82120b1a5f..7d02f54b47 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -147,6 +147,20 @@ void IDFUARTComponent::load_settings(bool dump_config) { return; } + // uart_param_config must be called after uart_driver_install and before any + // other uart_set_*() calls. The driver installation resets the UART peripheral + // registers to their default state, overwriting any previously configured baud + // rate or framing settings. Calling uart_param_config here ensures the requested + // settings are applied after the reset and before pin routing, inversion, and + // threshold configuration. + uart_config_t uart_config = this->get_config_(); + err = uart_param_config(this->uart_num_, &uart_config); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_param_config failed: %s", esp_err_to_name(err)); + this->mark_failed(); + return; + } + int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1; int8_t rx = this->rx_pin_ != nullptr ? this->rx_pin_->get_pin() : -1; int8_t flow_control = this->flow_control_pin_ != nullptr ? this->flow_control_pin_->get_pin() : -1; @@ -214,22 +228,15 @@ void IDFUARTComponent::load_settings(bool dump_config) { return; } + // Per ESP-IDF docs, uart_set_mode() must be called only after uart_driver_install(). auto mode = this->flow_control_pin_ != nullptr ? UART_MODE_RS485_HALF_DUPLEX : UART_MODE_UART; - err = uart_set_mode(this->uart_num_, mode); // per docs, must be called only after uart_driver_install() + err = uart_set_mode(this->uart_num_, mode); if (err != ESP_OK) { ESP_LOGW(TAG, "uart_set_mode failed: %s", esp_err_to_name(err)); this->mark_failed(); return; } - uart_config_t uart_config = this->get_config_(); - err = uart_param_config(this->uart_num_, &uart_config); - if (err != ESP_OK) { - ESP_LOGW(TAG, "uart_param_config failed: %s", esp_err_to_name(err)); - this->mark_failed(); - return; - } - #ifdef USE_UART_WAKE_LOOP_ON_RX // Register ISR callback to wake the main loop when UART data arrives. // The callback runs in ISR context and uses vTaskNotifyGiveFromISR() to From 514c0c8331c6eebc7e116b85c7d8abe2ecc6526b Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 31 Mar 2026 19:06:48 -0400 Subject: [PATCH 56/72] [mixer] Fix memory leak in mixer task on stop/start cycles (#15185) --- .../mixer/speaker/mixer_speaker.cpp | 274 +++++++++--------- 1 file changed, 137 insertions(+), 137 deletions(-) diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 9d11abb327..0fabc68c70 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -597,173 +597,173 @@ void MixerSpeaker::audio_mixer_task(void *params) { xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STARTING); - std::unique_ptr output_transfer_buffer = audio::AudioSinkTransferBuffer::create( - this_mixer->audio_stream_info_.value().ms_to_bytes(TRANSFER_BUFFER_DURATION_MS)); + { // Ensure C++ objects fall out of scope to ensure proper cleanup before stopping the task + std::unique_ptr output_transfer_buffer = audio::AudioSinkTransferBuffer::create( + this_mixer->audio_stream_info_.value().ms_to_bytes(TRANSFER_BUFFER_DURATION_MS)); - if (output_transfer_buffer == nullptr) { - xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STOPPED | MIXER_TASK_ERR_ESP_NO_MEM); + if (output_transfer_buffer == nullptr) { + xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STOPPED | MIXER_TASK_ERR_ESP_NO_MEM); - vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it - } - - output_transfer_buffer->set_sink(this_mixer->output_speaker_); - - xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_RUNNING); - - bool sent_finished = false; - - // Pre-allocate vectors to avoid heap allocation in the loop (max 8 source speakers per schema) - FixedVector speakers_with_data; - FixedVector> transfer_buffers_with_data; - speakers_with_data.init(this_mixer->source_speakers_.size()); - transfer_buffers_with_data.init(this_mixer->source_speakers_.size()); - - while (true) { - uint32_t event_group_bits = xEventGroupGetBits(this_mixer->event_group_); - if (event_group_bits & MIXER_TASK_COMMAND_STOP) { - break; + vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it } - // Never shift the data in the output transfer buffer to avoid unnecessary, slow data moves - output_transfer_buffer->transfer_data_to_sink(pdMS_TO_TICKS(TASK_DELAY_MS), false); + output_transfer_buffer->set_sink(this_mixer->output_speaker_); - const uint32_t output_frames_free = - this_mixer->audio_stream_info_.value().bytes_to_frames(output_transfer_buffer->free()); + xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_RUNNING); - speakers_with_data.clear(); - transfer_buffers_with_data.clear(); + bool sent_finished = false; - for (auto &speaker : this_mixer->source_speakers_) { - if (speaker->is_running() && !speaker->get_pause_state()) { - // Speaker is running and not paused, so it possibly can provide audio data - std::shared_ptr transfer_buffer = speaker->get_transfer_buffer().lock(); - if (transfer_buffer.use_count() == 0) { - // No transfer buffer allocated, so skip processing this speaker - continue; - } - speaker->process_data_from_source(transfer_buffer, 0); // Transfers and ducks audio from source ring buffers + // Pre-allocate vectors to avoid heap allocation in the loop (max 8 source speakers per schema) + FixedVector speakers_with_data; + FixedVector> transfer_buffers_with_data; + speakers_with_data.init(this_mixer->source_speakers_.size()); + transfer_buffers_with_data.init(this_mixer->source_speakers_.size()); - if (transfer_buffer->available() > 0) { - // Store the locked transfer buffers in their own vector to avoid releasing ownership until after the loop - transfer_buffers_with_data.push_back(transfer_buffer); - speakers_with_data.push_back(speaker); + while (true) { + uint32_t event_group_bits = xEventGroupGetBits(this_mixer->event_group_); + if (event_group_bits & MIXER_TASK_COMMAND_STOP) { + break; + } + + // Never shift the data in the output transfer buffer to avoid unnecessary, slow data moves + output_transfer_buffer->transfer_data_to_sink(pdMS_TO_TICKS(TASK_DELAY_MS), false); + + const uint32_t output_frames_free = + this_mixer->audio_stream_info_.value().bytes_to_frames(output_transfer_buffer->free()); + + speakers_with_data.clear(); + transfer_buffers_with_data.clear(); + + for (auto &speaker : this_mixer->source_speakers_) { + if (speaker->is_running() && !speaker->get_pause_state()) { + // Speaker is running and not paused, so it possibly can provide audio data + std::shared_ptr transfer_buffer = speaker->get_transfer_buffer().lock(); + if (transfer_buffer.use_count() == 0) { + // No transfer buffer allocated, so skip processing this speaker + continue; + } + speaker->process_data_from_source(transfer_buffer, 0); // Transfers and ducks audio from source ring buffers + + if (transfer_buffer->available() > 0) { + // Store the locked transfer buffers in their own vector to avoid releasing ownership until after the loop + transfer_buffers_with_data.push_back(transfer_buffer); + speakers_with_data.push_back(speaker); + } } } - } - if (transfer_buffers_with_data.empty()) { - // No audio available for transferring, block task temporarily - delay(TASK_DELAY_MS); - continue; - } + if (transfer_buffers_with_data.empty()) { + // No audio available for transferring, block task temporarily + delay(TASK_DELAY_MS); + continue; + } - uint32_t frames_to_mix = output_frames_free; + uint32_t frames_to_mix = output_frames_free; - if ((transfer_buffers_with_data.size() == 1) || this_mixer->queue_mode_) { - // Only one speaker has audio data, just copy samples over + if ((transfer_buffers_with_data.size() == 1) || this_mixer->queue_mode_) { + // Only one speaker has audio data, just copy samples over - audio::AudioStreamInfo active_stream_info = speakers_with_data[0]->get_audio_stream_info(); + audio::AudioStreamInfo active_stream_info = speakers_with_data[0]->get_audio_stream_info(); - if (active_stream_info.get_sample_rate() == - this_mixer->output_speaker_->get_audio_stream_info().get_sample_rate()) { - // Speaker's sample rate matches the output speaker's, copy directly + if (active_stream_info.get_sample_rate() == + this_mixer->output_speaker_->get_audio_stream_info().get_sample_rate()) { + // Speaker's sample rate matches the output speaker's, copy directly - const uint32_t frames_available_in_buffer = - active_stream_info.bytes_to_frames(transfer_buffers_with_data[0]->available()); - frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer); - copy_frames(reinterpret_cast(transfer_buffers_with_data[0]->get_buffer_start()), active_stream_info, - reinterpret_cast(output_transfer_buffer->get_buffer_end()), - this_mixer->audio_stream_info_.value(), frames_to_mix); + const uint32_t frames_available_in_buffer = + active_stream_info.bytes_to_frames(transfer_buffers_with_data[0]->available()); + frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer); + copy_frames(reinterpret_cast(transfer_buffers_with_data[0]->get_buffer_start()), + active_stream_info, reinterpret_cast(output_transfer_buffer->get_buffer_end()), + this_mixer->audio_stream_info_.value(), frames_to_mix); - // Set playback delay for newly contributing source - if (!speakers_with_data[0]->has_contributed_.load(std::memory_order_acquire)) { - speakers_with_data[0]->playback_delay_frames_.store( - this_mixer->frames_in_pipeline_.load(std::memory_order_acquire), std::memory_order_release); - speakers_with_data[0]->has_contributed_.store(true, std::memory_order_release); + // Set playback delay for newly contributing source + if (!speakers_with_data[0]->has_contributed_.load(std::memory_order_acquire)) { + speakers_with_data[0]->playback_delay_frames_.store( + this_mixer->frames_in_pipeline_.load(std::memory_order_acquire), std::memory_order_release); + speakers_with_data[0]->has_contributed_.store(true, std::memory_order_release); + } + + // Update source speaker pending frames + speakers_with_data[0]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release); + transfer_buffers_with_data[0]->decrease_buffer_length(active_stream_info.frames_to_bytes(frames_to_mix)); + + // Update output transfer buffer length and pipeline frame count + output_transfer_buffer->increase_buffer_length( + this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix)); + this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release); + } else { + // Speaker's stream info doesn't match the output speaker's, so it's a new source speaker + if (!this_mixer->output_speaker_->is_stopped()) { + if (!sent_finished) { + this_mixer->output_speaker_->finish(); + sent_finished = true; // Avoid repeatedly sending the finish command + } + } else { + // Speaker has finished writing the current audio, update the stream information and restart the speaker + this_mixer->audio_stream_info_ = + audio::AudioStreamInfo(active_stream_info.get_bits_per_sample(), this_mixer->output_channels_, + active_stream_info.get_sample_rate()); + this_mixer->output_speaker_->set_audio_stream_info(this_mixer->audio_stream_info_.value()); + this_mixer->output_speaker_->start(); + // Reset pipeline frame count since we're starting fresh with a new sample rate + this_mixer->frames_in_pipeline_.store(0, std::memory_order_release); + sent_finished = false; + } + } + } else { + // Determine how many frames to mix + for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) { + const uint32_t frames_available_in_buffer = speakers_with_data[i]->get_audio_stream_info().bytes_to_frames( + transfer_buffers_with_data[i]->available()); + frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer); + } + int16_t *primary_buffer = reinterpret_cast(transfer_buffers_with_data[0]->get_buffer_start()); + audio::AudioStreamInfo primary_stream_info = speakers_with_data[0]->get_audio_stream_info(); + + // Mix two streams together + for (size_t i = 1; i < transfer_buffers_with_data.size(); ++i) { + mix_audio_samples(primary_buffer, primary_stream_info, + reinterpret_cast(transfer_buffers_with_data[i]->get_buffer_start()), + speakers_with_data[i]->get_audio_stream_info(), + reinterpret_cast(output_transfer_buffer->get_buffer_end()), + this_mixer->audio_stream_info_.value(), frames_to_mix); + + if (i != transfer_buffers_with_data.size() - 1) { + // Need to mix more streams together, point primary buffer and stream info to the already mixed output + primary_buffer = reinterpret_cast(output_transfer_buffer->get_buffer_end()); + primary_stream_info = this_mixer->audio_stream_info_.value(); + } } - // Update source speaker pending frames - speakers_with_data[0]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release); - transfer_buffers_with_data[0]->decrease_buffer_length(active_stream_info.frames_to_bytes(frames_to_mix)); + // Get current pipeline depth for delay calculation (before incrementing) + uint32_t current_pipeline_frames = this_mixer->frames_in_pipeline_.load(std::memory_order_acquire); - // Update output transfer buffer length and pipeline frame count + // Update source transfer buffer lengths and add new audio durations to the source speaker pending playbacks + for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) { + // Set playback delay for newly contributing sources + if (!speakers_with_data[i]->has_contributed_.load(std::memory_order_acquire)) { + speakers_with_data[i]->playback_delay_frames_.store(current_pipeline_frames, std::memory_order_release); + speakers_with_data[i]->has_contributed_.store(true, std::memory_order_release); + } + + speakers_with_data[i]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release); + transfer_buffers_with_data[i]->decrease_buffer_length( + speakers_with_data[i]->get_audio_stream_info().frames_to_bytes(frames_to_mix)); + } + + // Update output transfer buffer length and pipeline frame count (once, not per source) output_transfer_buffer->increase_buffer_length( this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix)); this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release); - } else { - // Speaker's stream info doesn't match the output speaker's, so it's a new source speaker - if (!this_mixer->output_speaker_->is_stopped()) { - if (!sent_finished) { - this_mixer->output_speaker_->finish(); - sent_finished = true; // Avoid repeatedly sending the finish command - } - } else { - // Speaker has finished writing the current audio, update the stream information and restart the speaker - this_mixer->audio_stream_info_ = - audio::AudioStreamInfo(active_stream_info.get_bits_per_sample(), this_mixer->output_channels_, - active_stream_info.get_sample_rate()); - this_mixer->output_speaker_->set_audio_stream_info(this_mixer->audio_stream_info_.value()); - this_mixer->output_speaker_->start(); - // Reset pipeline frame count since we're starting fresh with a new sample rate - this_mixer->frames_in_pipeline_.store(0, std::memory_order_release); - sent_finished = false; - } } - } else { - // Determine how many frames to mix - for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) { - const uint32_t frames_available_in_buffer = - speakers_with_data[i]->get_audio_stream_info().bytes_to_frames(transfer_buffers_with_data[i]->available()); - frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer); - } - int16_t *primary_buffer = reinterpret_cast(transfer_buffers_with_data[0]->get_buffer_start()); - audio::AudioStreamInfo primary_stream_info = speakers_with_data[0]->get_audio_stream_info(); - - // Mix two streams together - for (size_t i = 1; i < transfer_buffers_with_data.size(); ++i) { - mix_audio_samples(primary_buffer, primary_stream_info, - reinterpret_cast(transfer_buffers_with_data[i]->get_buffer_start()), - speakers_with_data[i]->get_audio_stream_info(), - reinterpret_cast(output_transfer_buffer->get_buffer_end()), - this_mixer->audio_stream_info_.value(), frames_to_mix); - - if (i != transfer_buffers_with_data.size() - 1) { - // Need to mix more streams together, point primary buffer and stream info to the already mixed output - primary_buffer = reinterpret_cast(output_transfer_buffer->get_buffer_end()); - primary_stream_info = this_mixer->audio_stream_info_.value(); - } - } - - // Get current pipeline depth for delay calculation (before incrementing) - uint32_t current_pipeline_frames = this_mixer->frames_in_pipeline_.load(std::memory_order_acquire); - - // Update source transfer buffer lengths and add new audio durations to the source speaker pending playbacks - for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) { - // Set playback delay for newly contributing sources - if (!speakers_with_data[i]->has_contributed_.load(std::memory_order_acquire)) { - speakers_with_data[i]->playback_delay_frames_.store(current_pipeline_frames, std::memory_order_release); - speakers_with_data[i]->has_contributed_.store(true, std::memory_order_release); - } - - speakers_with_data[i]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release); - transfer_buffers_with_data[i]->decrease_buffer_length( - speakers_with_data[i]->get_audio_stream_info().frames_to_bytes(frames_to_mix)); - } - - // Update output transfer buffer length and pipeline frame count (once, not per source) - output_transfer_buffer->increase_buffer_length( - this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix)); - this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release); } - } - xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STOPPING); + xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STOPPING); + } // Reset pipeline frame count since the task is stopping this_mixer->frames_in_pipeline_.store(0, std::memory_order_release); - output_transfer_buffer.reset(); - xEventGroupSetBits(this_mixer->event_group_, MIXER_TASK_STATE_STOPPED); vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it From 65051153ac0f7559bd8e271d82fb9685b611164e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Mar 2026 13:26:26 -1000 Subject: [PATCH 57/72] [esp32_ble_tracker] Restart BLE scan after OTA failure (#15308) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp | 6 ++++++ esphome/components/esp32_ble_tracker/esp32_ble_tracker.h | 3 +++ 2 files changed, 9 insertions(+) diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 5a43cf7e49..6a2834a869 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -82,12 +82,18 @@ void ESP32BLETracker::setup() { #ifdef USE_OTA_STATE_LISTENER void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) { if (state == ota::OTA_STARTED) { + this->scan_continuous_before_ota_ = this->scan_continuous_; this->stop_scan(); #ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT for (auto *client : this->clients_) { client->disconnect(); } #endif + } else if ((state == ota::OTA_ERROR || state == ota::OTA_ABORT) && this->scan_continuous_before_ota_) { + this->scan_continuous_before_ota_ = false; + this->scan_continuous_ = true; + // Do not restart scanning immediately here; allow loop() to + // safely restart scanning once the scanner and all clients are idle. } } #endif diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 7f1c2b0f7c..e0e25aca20 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -429,6 +429,9 @@ class ESP32BLETracker : public Component, ScannerState scanner_state_{ScannerState::IDLE}; bool scan_continuous_; bool scan_active_; +#ifdef USE_OTA_STATE_LISTENER + bool scan_continuous_before_ota_{false}; +#endif bool ble_was_disabled_{true}; bool raw_advertisements_{false}; bool parse_advertisements_{false}; From 600ca01fd3904fffd3d6092a45c8a3f6a1899c1b Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:18:24 +1300 Subject: [PATCH 58/72] Bump version to 2026.3.2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index d86894435f..97201d1c44 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.3.1 +PROJECT_NUMBER = 2026.3.2 # 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 52ac7acd22..ebab56193c 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.3.1" +__version__ = "2026.3.2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 8f2cf8b8a75ed710559eff51a37097bc2100959a Mon Sep 17 00:00:00 2001 From: Christian H <28529536+nytaros@users.noreply.github.com> Date: Wed, 1 Apr 2026 03:39:41 +0200 Subject: [PATCH 59/72] [bmp581_base] Add support for BMP585 (#15277) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/bmp581_base/bmp581_base.cpp | 2 +- esphome/components/bmp581_base/bmp581_base.h | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/bmp581_base/bmp581_base.cpp b/esphome/components/bmp581_base/bmp581_base.cpp index c9d250545b..7a627eee03 100644 --- a/esphome/components/bmp581_base/bmp581_base.cpp +++ b/esphome/components/bmp581_base/bmp581_base.cpp @@ -126,7 +126,7 @@ void BMP581Component::setup() { } // verify id - if (chip_id != BMP581_ASIC_ID) { + if (chip_id != BMP581_ASIC_ID && chip_id != BMP585_ASIC_ID) { ESP_LOGE(TAG, "Unknown chip ID"); this->error_code_ = ERROR_WRONG_CHIP_ID; diff --git a/esphome/components/bmp581_base/bmp581_base.h b/esphome/components/bmp581_base/bmp581_base.h index c3920512e0..1a73a91558 100644 --- a/esphome/components/bmp581_base/bmp581_base.h +++ b/esphome/components/bmp581_base/bmp581_base.h @@ -8,7 +8,8 @@ namespace esphome::bmp581_base { static const uint8_t BMP581_ASIC_ID = 0x50; // BMP581's ASIC chip ID (page 51 of datasheet) -static const uint8_t RESET_COMMAND = 0xB6; // Soft reset command +static const uint8_t BMP585_ASIC_ID = 0x51; +static const uint8_t RESET_COMMAND = 0xB6; // Soft reset command // BMP581 Register Addresses enum { From 31a70ab29911d646dc602a0df012d73ba6c85f9b Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 31 Mar 2026 21:44:54 -0400 Subject: [PATCH 60/72] [resampler] Future-proof resampler task to avoid potential memory leaks (#15186) --- .../resampler/speaker/resampler_speaker.cpp | 86 ++++++++++--------- 1 file changed, 44 insertions(+), 42 deletions(-) diff --git a/esphome/components/resampler/speaker/resampler_speaker.cpp b/esphome/components/resampler/speaker/resampler_speaker.cpp index 1303bc459e..b737a2d39a 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.cpp +++ b/esphome/components/resampler/speaker/resampler_speaker.cpp @@ -317,57 +317,59 @@ void ResamplerSpeaker::resample_task(void *params) { xEventGroupSetBits(this_resampler->event_group_, ResamplingEventGroupBits::STATE_STARTING); - std::unique_ptr resampler = - make_unique(this_resampler->audio_stream_info_.ms_to_bytes(TRANSFER_BUFFER_DURATION_MS), - this_resampler->target_stream_info_.ms_to_bytes(TRANSFER_BUFFER_DURATION_MS)); + { // Ensure C++ objects fall out of scope for proper cleanup before stopping the task + std::unique_ptr resampler = make_unique( + this_resampler->audio_stream_info_.ms_to_bytes(TRANSFER_BUFFER_DURATION_MS), + this_resampler->target_stream_info_.ms_to_bytes(TRANSFER_BUFFER_DURATION_MS)); - esp_err_t err = resampler->start(this_resampler->audio_stream_info_, this_resampler->target_stream_info_, - this_resampler->taps_, this_resampler->filters_); + esp_err_t err = resampler->start(this_resampler->audio_stream_info_, this_resampler->target_stream_info_, + this_resampler->taps_, this_resampler->filters_); - if (err == ESP_OK) { - std::shared_ptr temp_ring_buffer = - RingBuffer::create(this_resampler->audio_stream_info_.ms_to_bytes(this_resampler->buffer_duration_ms_)); + if (err == ESP_OK) { + std::shared_ptr temp_ring_buffer = + RingBuffer::create(this_resampler->audio_stream_info_.ms_to_bytes(this_resampler->buffer_duration_ms_)); - if (!temp_ring_buffer) { - err = ESP_ERR_NO_MEM; - } else { - this_resampler->ring_buffer_ = temp_ring_buffer; - resampler->add_source(this_resampler->ring_buffer_); + if (!temp_ring_buffer) { + err = ESP_ERR_NO_MEM; + } else { + this_resampler->ring_buffer_ = temp_ring_buffer; + resampler->add_source(this_resampler->ring_buffer_); - this_resampler->output_speaker_->set_audio_stream_info(this_resampler->target_stream_info_); - resampler->add_sink(this_resampler->output_speaker_); - } - } - - if (err == ESP_OK) { - xEventGroupSetBits(this_resampler->event_group_, ResamplingEventGroupBits::STATE_RUNNING); - } else if (err == ESP_ERR_NO_MEM) { - xEventGroupSetBits(this_resampler->event_group_, ResamplingEventGroupBits::ERR_ESP_NO_MEM); - } else if (err == ESP_ERR_NOT_SUPPORTED) { - xEventGroupSetBits(this_resampler->event_group_, ResamplingEventGroupBits::ERR_ESP_NOT_SUPPORTED); - } - - while (err == ESP_OK) { - uint32_t event_bits = xEventGroupGetBits(this_resampler->event_group_); - - if (event_bits & ResamplingEventGroupBits::TASK_COMMAND_STOP) { - break; + this_resampler->output_speaker_->set_audio_stream_info(this_resampler->target_stream_info_); + resampler->add_sink(this_resampler->output_speaker_); + } } - // Stop gracefully if the decoder is done - int32_t ms_differential = 0; - audio::AudioResamplerState resampler_state = resampler->resample(false, &ms_differential); - - if (resampler_state == audio::AudioResamplerState::FINISHED) { - break; - } else if (resampler_state == audio::AudioResamplerState::FAILED) { - xEventGroupSetBits(this_resampler->event_group_, ResamplingEventGroupBits::ERR_ESP_FAIL); - break; + if (err == ESP_OK) { + xEventGroupSetBits(this_resampler->event_group_, ResamplingEventGroupBits::STATE_RUNNING); + } else if (err == ESP_ERR_NO_MEM) { + xEventGroupSetBits(this_resampler->event_group_, ResamplingEventGroupBits::ERR_ESP_NO_MEM); + } else if (err == ESP_ERR_NOT_SUPPORTED) { + xEventGroupSetBits(this_resampler->event_group_, ResamplingEventGroupBits::ERR_ESP_NOT_SUPPORTED); } + + while (err == ESP_OK) { + uint32_t event_bits = xEventGroupGetBits(this_resampler->event_group_); + + if (event_bits & ResamplingEventGroupBits::TASK_COMMAND_STOP) { + break; + } + + // Stop gracefully if the decoder is done + int32_t ms_differential = 0; + audio::AudioResamplerState resampler_state = resampler->resample(false, &ms_differential); + + if (resampler_state == audio::AudioResamplerState::FINISHED) { + break; + } else if (resampler_state == audio::AudioResamplerState::FAILED) { + xEventGroupSetBits(this_resampler->event_group_, ResamplingEventGroupBits::ERR_ESP_FAIL); + break; + } + } + + xEventGroupSetBits(this_resampler->event_group_, ResamplingEventGroupBits::STATE_STOPPING); } - xEventGroupSetBits(this_resampler->event_group_, ResamplingEventGroupBits::STATE_STOPPING); - resampler.reset(); xEventGroupSetBits(this_resampler->event_group_, ResamplingEventGroupBits::STATE_STOPPED); vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it From 212b3e16880808ab7e3af1b6a5f513a9f622b2fb Mon Sep 17 00:00:00 2001 From: Rene Guca <45061891+rguca@users.noreply.github.com> Date: Wed, 1 Apr 2026 03:59:24 +0200 Subject: [PATCH 61/72] [cover] move time_based_cover to its own subdirectory (#15313) Co-authored-by: Rene --- esphome/components/time_based/__init__.py | 3 +++ esphome/components/time_based/{cover.py => cover/__init__.py} | 3 ++- esphome/components/time_based/{ => cover}/time_based_cover.cpp | 0 esphome/components/time_based/{ => cover}/time_based_cover.h | 0 4 files changed, 5 insertions(+), 1 deletion(-) rename esphome/components/time_based/{cover.py => cover/__init__.py} (97%) rename esphome/components/time_based/{ => cover}/time_based_cover.cpp (100%) rename esphome/components/time_based/{ => cover}/time_based_cover.h (100%) diff --git a/esphome/components/time_based/__init__.py b/esphome/components/time_based/__init__.py index e69de29bb2..ce2f453bda 100644 --- a/esphome/components/time_based/__init__.py +++ b/esphome/components/time_based/__init__.py @@ -0,0 +1,3 @@ +import esphome.codegen as cg + +time_based_ns = cg.esphome_ns.namespace("time_based") diff --git a/esphome/components/time_based/cover.py b/esphome/components/time_based/cover/__init__.py similarity index 97% rename from esphome/components/time_based/cover.py rename to esphome/components/time_based/cover/__init__.py index d14332d453..022b48d249 100644 --- a/esphome/components/time_based/cover.py +++ b/esphome/components/time_based/cover/__init__.py @@ -11,7 +11,8 @@ from esphome.const import ( CONF_STOP_ACTION, ) -time_based_ns = cg.esphome_ns.namespace("time_based") +from .. import time_based_ns + TimeBasedCover = time_based_ns.class_("TimeBasedCover", cover.Cover, cg.Component) CONF_HAS_BUILT_IN_ENDSTOP = "has_built_in_endstop" diff --git a/esphome/components/time_based/time_based_cover.cpp b/esphome/components/time_based/cover/time_based_cover.cpp similarity index 100% rename from esphome/components/time_based/time_based_cover.cpp rename to esphome/components/time_based/cover/time_based_cover.cpp diff --git a/esphome/components/time_based/time_based_cover.h b/esphome/components/time_based/cover/time_based_cover.h similarity index 100% rename from esphome/components/time_based/time_based_cover.h rename to esphome/components/time_based/cover/time_based_cover.h From fbfb5d401f99cf40d60bdced6db36ba00262f27e Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:34:29 +0200 Subject: [PATCH 62/72] [nextion] Fix memory leak in `reset_()` (#15344) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/nextion/nextion.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index bb3e12be50..d141ef7906 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -143,8 +143,17 @@ void Nextion::reset_(bool reset_nextion) { while (this->available()) { // Clear receive buffer this->read_byte(&d); - }; + } + for (auto *entry : this->nextion_queue_) { + if (entry->component != nullptr && entry->component->get_queue_type() == NextionQueueType::NO_RESULT) { + delete entry->component; // NOLINT(cppcoreguidelines-owning-memory) + } + delete entry; // NOLINT(cppcoreguidelines-owning-memory) + } this->nextion_queue_.clear(); + for (auto *entry : this->waveform_queue_) { + delete entry; // NOLINT(cppcoreguidelines-owning-memory) + } this->waveform_queue_.clear(); } From cc8889628010840dcbc9167e07adf6ec342e00b9 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Wed, 1 Apr 2026 17:04:22 +0200 Subject: [PATCH 63/72] [debug] add peripherals status (#12053) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/debug/debug_zephyr.cpp | 47 ++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/esphome/components/debug/debug_zephyr.cpp b/esphome/components/debug/debug_zephyr.cpp index bf87b7ae3d..d1580dae80 100644 --- a/esphome/components/debug/debug_zephyr.cpp +++ b/esphome/components/debug/debug_zephyr.cpp @@ -91,6 +91,49 @@ void DebugComponent::log_partition_info_() { flash_area_foreach(fa_cb, nullptr); } +#ifdef ESPHOME_LOG_HAS_VERBOSE +// Check if an nRF peripheral's ENABLE register indicates it is enabled. +// periph: peripheral register prefix (e.g. USBD, UARTE, SPI) +// reg: register block pointer (e.g. NRF_USBD, NRF_UARTE0) +#define NRF_PERIPH_ENABLED(periph, reg) \ + YESNO(((reg)->ENABLE & periph##_ENABLE_ENABLE_Msk) == (periph##_ENABLE_ENABLE_Enabled << periph##_ENABLE_ENABLE_Pos)) + +static void log_peripherals_info() { + // most peripherals are enabled only when in use so ESP_LOGV is enough + ESP_LOGV(TAG, "Peripherals status:"); + ESP_LOGV(TAG, " USBD: %-3s| UARTE0: %-3s| UARTE1: %-3s| UART0: %-3s", // + NRF_PERIPH_ENABLED(USBD, NRF_USBD), NRF_PERIPH_ENABLED(UARTE, NRF_UARTE0), + NRF_PERIPH_ENABLED(UARTE, NRF_UARTE1), NRF_PERIPH_ENABLED(UART, NRF_UART0)); + ESP_LOGV(TAG, " TWIS0: %-3s| TWIS1: %-3s| TWIM0: %-3s| TWIM1: %-3s", // + NRF_PERIPH_ENABLED(TWIS, NRF_TWIS0), NRF_PERIPH_ENABLED(TWIS, NRF_TWIS1), + NRF_PERIPH_ENABLED(TWIM, NRF_TWIM0), NRF_PERIPH_ENABLED(TWIM, NRF_TWIM1)); + ESP_LOGV(TAG, " TWI0: %-3s| TWI1: %-3s| COMP: %-3s| CCM: %-3s", // + NRF_PERIPH_ENABLED(TWI, NRF_TWI0), NRF_PERIPH_ENABLED(TWI, NRF_TWI1), NRF_PERIPH_ENABLED(COMP, NRF_COMP), + NRF_PERIPH_ENABLED(CCM, NRF_CCM)); + ESP_LOGV(TAG, " PDM: %-3s| SPIS0: %-3s| SPIS1: %-3s| SPIS2: %-3s", // + NRF_PERIPH_ENABLED(PDM, NRF_PDM), NRF_PERIPH_ENABLED(SPIS, NRF_SPIS0), NRF_PERIPH_ENABLED(SPIS, NRF_SPIS1), + NRF_PERIPH_ENABLED(SPIS, NRF_SPIS2)); + ESP_LOGV(TAG, " SPIM0: %-3s| SPIM1: %-3s| SPIM2: %-3s| SPIM3: %-3s", // + NRF_PERIPH_ENABLED(SPIM, NRF_SPIM0), NRF_PERIPH_ENABLED(SPIM, NRF_SPIM1), + NRF_PERIPH_ENABLED(SPIM, NRF_SPIM2), NRF_PERIPH_ENABLED(SPIM, NRF_SPIM3)); + ESP_LOGV(TAG, " SPI0: %-3s| SPI1: %-3s| SPI2: %-3s| SAADC: %-3s", // + NRF_PERIPH_ENABLED(SPI, NRF_SPI0), NRF_PERIPH_ENABLED(SPI, NRF_SPI1), NRF_PERIPH_ENABLED(SPI, NRF_SPI2), + NRF_PERIPH_ENABLED(SAADC, NRF_SAADC)); + ESP_LOGV(TAG, " QSPI: %-3s| QDEC: %-3s| LPCOMP: %-3s| I2S: %-3s", // + NRF_PERIPH_ENABLED(QSPI, NRF_QSPI), NRF_PERIPH_ENABLED(QDEC, NRF_QDEC), + NRF_PERIPH_ENABLED(LPCOMP, NRF_LPCOMP), NRF_PERIPH_ENABLED(I2S, NRF_I2S)); + ESP_LOGV(TAG, " PWM0: %-3s| PWM1: %-3s| PWM2: %-3s| PWM3: %-3s", // + NRF_PERIPH_ENABLED(PWM, NRF_PWM0), NRF_PERIPH_ENABLED(PWM, NRF_PWM1), NRF_PERIPH_ENABLED(PWM, NRF_PWM2), + NRF_PERIPH_ENABLED(PWM, NRF_PWM3)); + ESP_LOGV(TAG, " AAR: %-3s| QSPI deep power-down:%-3s| CRYPTOCELL: %-3s", NRF_PERIPH_ENABLED(AAR, NRF_AAR), + YESNO((NRF_QSPI->IFCONFIG0 & QSPI_IFCONFIG0_DPMENABLE_Msk) == + (QSPI_IFCONFIG0_DPMENABLE_Enable << QSPI_IFCONFIG0_DPMENABLE_Pos)), + YESNO((NRF_CRYPTOCELL->ENABLE & CRYPTOCELL_ENABLE_ENABLE_Msk) == + (CRYPTOCELL_ENABLE_ENABLE_Enabled << CRYPTOCELL_ENABLE_ENABLE_Pos))); +} +#undef NRF_PERIPH_ENABLED +#endif + static const char *regout0_to_str(uint32_t value) { switch (value) { case (UICR_REGOUT0_VOUT_DEFAULT): @@ -354,7 +397,9 @@ size_t DebugComponent::get_device_info_(std::span }; ESP_LOGD(TAG, " NRFFW %s", uicr(NRF_UICR->NRFFW, 13).c_str()); ESP_LOGD(TAG, " NRFHW %s", uicr(NRF_UICR->NRFHW, 12).c_str()); - +#ifdef ESPHOME_LOG_HAS_VERBOSE + log_peripherals_info(); +#endif return pos; } From f33fd047ee5589fa7d21445ed42bb541fe06e92f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Pereira?= Date: Wed, 1 Apr 2026 17:09:22 +0100 Subject: [PATCH 64/72] [hdc2080] Add support for HDC2080 sensor (#9331) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: Big Mike Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- CODEOWNERS | 1 + esphome/components/hdc2080/__init__.py | 1 + esphome/components/hdc2080/hdc2080.cpp | 71 +++++++++++++++++++ esphome/components/hdc2080/hdc2080.h | 24 +++++++ esphome/components/hdc2080/sensor.py | 57 +++++++++++++++ tests/components/hdc2080/common.yaml | 7 ++ tests/components/hdc2080/test.esp32-idf.yaml | 4 ++ .../components/hdc2080/test.esp8266-ard.yaml | 4 ++ tests/components/hdc2080/test.rp2040-ard.yaml | 4 ++ 9 files changed, 173 insertions(+) create mode 100644 esphome/components/hdc2080/__init__.py create mode 100644 esphome/components/hdc2080/hdc2080.cpp create mode 100644 esphome/components/hdc2080/hdc2080.h create mode 100644 esphome/components/hdc2080/sensor.py create mode 100644 tests/components/hdc2080/common.yaml create mode 100644 tests/components/hdc2080/test.esp32-idf.yaml create mode 100644 tests/components/hdc2080/test.esp8266-ard.yaml create mode 100644 tests/components/hdc2080/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 8d297d7b07..03f41618af 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -217,6 +217,7 @@ esphome/components/hbridge/light/* @DotNetDann esphome/components/hbridge/switch/* @dwmw2 esphome/components/hc8/* @omartijn esphome/components/hdc2010/* @optimusprimespace @ssieb +esphome/components/hdc2080/* @G-Pereira @jesserockz esphome/components/hdc302x/* @joshuasing esphome/components/he60r/* @clydebarrow esphome/components/heatpumpir/* @rob-deutsch diff --git a/esphome/components/hdc2080/__init__.py b/esphome/components/hdc2080/__init__.py new file mode 100644 index 0000000000..341ea61048 --- /dev/null +++ b/esphome/components/hdc2080/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@G-Pereira", "@jesserockz"] diff --git a/esphome/components/hdc2080/hdc2080.cpp b/esphome/components/hdc2080/hdc2080.cpp new file mode 100644 index 0000000000..dcb207e099 --- /dev/null +++ b/esphome/components/hdc2080/hdc2080.cpp @@ -0,0 +1,71 @@ +#include "hdc2080.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::hdc2080 { + +static const char *const TAG = "hdc2080"; + +// Register map (Table 8-6) +static constexpr uint8_t REG_TEMPERATURE_LOW = 0x00; // Temperature [7:0] +static constexpr uint8_t REG_TEMPERATURE_HIGH = 0x01; // Temperature [15:8] +static constexpr uint8_t REG_HUMIDITY_LOW = 0x02; // Humidity [7:0] +static constexpr uint8_t REG_HUMIDITY_HIGH = 0x03; // Humidity [15:8] +static constexpr uint8_t REG_RESET_DRDY_INT_CONF = 0x0E; // Soft Reset and Interrupt Configuration +static constexpr uint8_t REG_MEASUREMENT_CONFIGURATION = 0x0F; + +// Measurement register (0x0F) bit fields +static constexpr uint8_t MEAS_TRIG = 0x01; // Bit 0: start measurement +static constexpr uint8_t MEAS_CONF_TEMP = 0x02; // Bits 2:1 = 01: temperature only +static constexpr uint8_t MEAS_CONF_HUM = 0x04; // Bits 2:1 = 10: humidity only + +void HDC2080Component::setup() { + const uint8_t data = 0x00; // automatic measurement mode disabled, heater off + if (this->write_register(REG_RESET_DRDY_INT_CONF, &data, 1) != i2c::ERROR_OK) { + this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + return; + } +} + +void HDC2080Component::dump_config() { + ESP_LOGCONFIG(TAG, "HDC2080:"); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +void HDC2080Component::update() { + uint8_t data = MEAS_TRIG; // 14-bit resolution, measure both, start + if (this->temperature_sensor_ != nullptr && this->humidity_sensor_ == nullptr) { + data = MEAS_TRIG | MEAS_CONF_TEMP; + } else if (this->temperature_sensor_ == nullptr && this->humidity_sensor_ != nullptr) { + data = MEAS_TRIG | MEAS_CONF_HUM; + } + if (this->write_register(REG_MEASUREMENT_CONFIGURATION, &data, 1) != i2c::ERROR_OK) { + this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + return; + } + // wait for conversion to complete 2ms should be enough, more is fine + this->set_timeout(5, [this]() { + uint8_t raw_data[4]; + if (this->read_register(REG_TEMPERATURE_LOW, raw_data, 4) != i2c::ERROR_OK) { + this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + return; + } + this->status_clear_warning(); + if (this->temperature_sensor_ != nullptr) { + float temp = encode_uint16(raw_data[1], raw_data[0]) * (165.0f / 65536.0f) - 40.5f; + this->temperature_sensor_->publish_state(temp); + } + if (this->humidity_sensor_ != nullptr) { + float humidity = encode_uint16(raw_data[3], raw_data[2]) * (100.0f / 65536.0f); + this->humidity_sensor_->publish_state(humidity); + } + }); +} + +} // namespace esphome::hdc2080 diff --git a/esphome/components/hdc2080/hdc2080.h b/esphome/components/hdc2080/hdc2080.h new file mode 100644 index 0000000000..daa10d371d --- /dev/null +++ b/esphome/components/hdc2080/hdc2080.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::hdc2080 { + +class HDC2080Component : public PollingComponent, public i2c::I2CDevice { + public: + void set_temperature(sensor::Sensor *temperature) { this->temperature_sensor_ = temperature; } + void set_humidity(sensor::Sensor *humidity) { this->humidity_sensor_ = humidity; } + + /// Setup the sensor and check for connection. + void setup() override; + void dump_config() override; + void update() override; + + protected: + sensor::Sensor *temperature_sensor_{nullptr}; + sensor::Sensor *humidity_sensor_{nullptr}; +}; + +} // namespace esphome::hdc2080 diff --git a/esphome/components/hdc2080/sensor.py b/esphome/components/hdc2080/sensor.py new file mode 100644 index 0000000000..777fc51cba --- /dev/null +++ b/esphome/components/hdc2080/sensor.py @@ -0,0 +1,57 @@ +import esphome.codegen as cg +from esphome.components import i2c, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_HUMIDITY, + CONF_ID, + CONF_TEMPERATURE, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_PERCENT, +) + +DEPENDENCIES = ["i2c"] + +hdc2080_ns = cg.esphome_ns.namespace("hdc2080") +HDC2080Component = hdc2080_ns.class_( + "HDC2080Component", cg.PollingComponent, i2c.I2CDevice +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(HDC2080Component), + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_HUMIDITY): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x40)) + .add_extra(cv.has_at_least_one_key(CONF_TEMPERATURE, CONF_HUMIDITY)) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + if temperature_config := config.get(CONF_TEMPERATURE): + sens = await sensor.new_sensor(temperature_config) + cg.add(var.set_temperature(sens)) + + if humidity_config := config.get(CONF_HUMIDITY): + sens = await sensor.new_sensor(humidity_config) + cg.add(var.set_humidity(sens)) diff --git a/tests/components/hdc2080/common.yaml b/tests/components/hdc2080/common.yaml new file mode 100644 index 0000000000..cb14cb183b --- /dev/null +++ b/tests/components/hdc2080/common.yaml @@ -0,0 +1,7 @@ +sensor: + - platform: hdc2080 + temperature: + name: Temperature + humidity: + name: Humidity + update_interval: 15s diff --git a/tests/components/hdc2080/test.esp32-idf.yaml b/tests/components/hdc2080/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/hdc2080/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/hdc2080/test.esp8266-ard.yaml b/tests/components/hdc2080/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/hdc2080/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/hdc2080/test.rp2040-ard.yaml b/tests/components/hdc2080/test.rp2040-ard.yaml new file mode 100644 index 0000000000..319a7c71a6 --- /dev/null +++ b/tests/components/hdc2080/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml From ea609d3552fe0685a68a97362abc5658f203837b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 07:09:04 -1000 Subject: [PATCH 65/72] [runtime_stats] Store stats inline on Component to eliminate std::map lookup (#15345) --- .../runtime_stats/runtime_stats.cpp | 76 +++++++++---------- .../components/runtime_stats/runtime_stats.h | 75 +----------------- esphome/core/application.h | 9 +++ esphome/core/component.cpp | 12 +-- esphome/core/component.h | 42 ++++++++++ esphome/core/defines.h | 1 + 6 files changed, 92 insertions(+), 123 deletions(-) diff --git a/esphome/components/runtime_stats/runtime_stats.cpp b/esphome/components/runtime_stats/runtime_stats.cpp index cb28acc96c..06714b5a44 100644 --- a/esphome/components/runtime_stats/runtime_stats.cpp +++ b/esphome/components/runtime_stats/runtime_stats.cpp @@ -2,6 +2,7 @@ #ifdef USE_RUNTIME_STATS +#include "esphome/core/application.h" #include "esphome/core/component.h" #include @@ -13,20 +14,16 @@ RuntimeStatsCollector::RuntimeStatsCollector() : log_interval_(60000), next_log_ global_runtime_stats = this; } -void RuntimeStatsCollector::record_component_time(Component *component, uint32_t duration_us) { - if (component == nullptr) - return; - - // Record stats using component pointer as key - this->component_stats_[component].record_time(duration_us); -} - void RuntimeStatsCollector::log_stats_() { - // First pass: count active components + auto &components = App.components_; + + // Single pass: collect active components into stack buffer + SmallBufferWithHeapFallback<256, Component *> buffer(components.size()); + Component **sorted = buffer.get(); size_t count = 0; - for (const auto &it : this->component_stats_) { - if (it.second.get_period_count() > 0) { - count++; + for (auto *component : components) { + if (component->runtime_stats_.period_count > 0) { + sorted[count++] = component; } } @@ -39,61 +36,58 @@ void RuntimeStatsCollector::log_stats_() { return; } - // Stack buffer sized to actual active count (up to 256 components), heap fallback for larger - SmallBufferWithHeapFallback<256, Component *> buffer(count); - Component **sorted = buffer.get(); - - // Second pass: fill buffer with active components - size_t idx = 0; - for (const auto &it : this->component_stats_) { - if (it.second.get_period_count() > 0) { - sorted[idx++] = it.first; - } - } - // Sort by period runtime (descending) - std::sort(sorted, sorted + count, [this](Component *a, Component *b) { - return this->component_stats_[a].get_period_time_us() > this->component_stats_[b].get_period_time_us(); - }); + std::sort(sorted, sorted + count, compare_period_time); // Log top components by period runtime for (size_t i = 0; i < count; i++) { - const auto &stats = this->component_stats_[sorted[i]]; + const auto &stats = sorted[i]->runtime_stats_; ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", - LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.get_period_count(), - stats.get_period_avg_time_us() / 1000.0f, stats.get_period_max_time_us() / 1000.0f, - stats.get_period_time_us() / 1000.0f); + LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.period_count, + stats.period_count > 0 ? stats.period_time_us / (float) stats.period_count / 1000.0f : 0.0f, + stats.period_max_time_us / 1000.0f, stats.period_time_us / 1000.0f); } // Log total stats since boot (only for active components - idle ones haven't changed) ESP_LOGI(TAG, " Total stats (since boot): %zu active components", count); // Re-sort by total runtime for all-time stats - std::sort(sorted, sorted + count, [this](Component *a, Component *b) { - return this->component_stats_[a].get_total_time_us() > this->component_stats_[b].get_total_time_us(); - }); + std::sort(sorted, sorted + count, compare_total_time); for (size_t i = 0; i < count; i++) { - const auto &stats = this->component_stats_[sorted[i]]; + const auto &stats = sorted[i]->runtime_stats_; ESP_LOGI(TAG, " %s: count=%" PRIu32 ", avg=%.3fms, max=%.2fms, total=%.1fms", - LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.get_total_count(), - stats.get_total_avg_time_us() / 1000.0f, stats.get_total_max_time_us() / 1000.0f, - stats.get_total_time_us() / 1000.0); + LOG_STR_ARG(sorted[i]->get_component_log_str()), stats.total_count, + stats.total_count > 0 ? stats.total_time_us / (float) stats.total_count / 1000.0f : 0.0f, + stats.total_max_time_us / 1000.0f, stats.total_time_us / 1000.0); } + + // Reset period stats + for (auto *component : components) { + component->runtime_stats_.reset_period(); + } +} + +bool RuntimeStatsCollector::compare_period_time(Component *a, Component *b) { + return a->runtime_stats_.period_time_us > b->runtime_stats_.period_time_us; +} + +bool RuntimeStatsCollector::compare_total_time(Component *a, Component *b) { + return a->runtime_stats_.total_time_us > b->runtime_stats_.total_time_us; } void RuntimeStatsCollector::process_pending_stats(uint32_t current_time) { if ((int32_t) (current_time - this->next_log_time_) >= 0) { this->log_stats_(); - this->reset_stats_(); this->next_log_time_ = current_time + this->log_interval_; } } } // namespace runtime_stats -runtime_stats::RuntimeStatsCollector *global_runtime_stats = - nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +runtime_stats::RuntimeStatsCollector + *global_runtime_stats = // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + nullptr; } // namespace esphome diff --git a/esphome/components/runtime_stats/runtime_stats.h b/esphome/components/runtime_stats/runtime_stats.h index 303d895985..3c2c9f78ad 100644 --- a/esphome/components/runtime_stats/runtime_stats.h +++ b/esphome/components/runtime_stats/runtime_stats.h @@ -4,11 +4,8 @@ #ifdef USE_RUNTIME_STATS -#include #include -#include #include "esphome/core/hal.h" -#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome { @@ -19,64 +16,6 @@ namespace runtime_stats { static const char *const TAG = "runtime_stats"; -class ComponentRuntimeStats { - public: - ComponentRuntimeStats() - : period_count_(0), - period_time_us_(0), - period_max_time_us_(0), - total_count_(0), - total_time_us_(0), - total_max_time_us_(0) {} - - void record_time(uint32_t duration_us) { - // Update period counters - this->period_count_++; - this->period_time_us_ += duration_us; - if (duration_us > this->period_max_time_us_) - this->period_max_time_us_ = duration_us; - - // Update total counters (uint64_t to avoid overflow — uint32_t would overflow after ~10 hours) - this->total_count_++; - this->total_time_us_ += duration_us; - if (duration_us > this->total_max_time_us_) - this->total_max_time_us_ = duration_us; - } - - void reset_period_stats() { - this->period_count_ = 0; - this->period_time_us_ = 0; - this->period_max_time_us_ = 0; - } - - // Period stats (reset each logging interval) - uint32_t get_period_count() const { return this->period_count_; } - uint32_t get_period_time_us() const { return this->period_time_us_; } - uint32_t get_period_max_time_us() const { return this->period_max_time_us_; } - float get_period_avg_time_us() const { - return this->period_count_ > 0 ? this->period_time_us_ / static_cast(this->period_count_) : 0.0f; - } - - // Total stats (persistent until reboot, uint64_t to avoid overflow) - uint32_t get_total_count() const { return this->total_count_; } - uint64_t get_total_time_us() const { return this->total_time_us_; } - uint32_t get_total_max_time_us() const { return this->total_max_time_us_; } - float get_total_avg_time_us() const { - return this->total_count_ > 0 ? this->total_time_us_ / static_cast(this->total_count_) : 0.0f; - } - - protected: - // Period stats (reset each logging interval) - uint32_t period_count_; - uint32_t period_time_us_; - uint32_t period_max_time_us_; - - // Total stats (persistent until reboot) - uint32_t total_count_; - uint64_t total_time_us_; - uint32_t total_max_time_us_; -}; - class RuntimeStatsCollector { public: RuntimeStatsCollector(); @@ -87,23 +26,15 @@ class RuntimeStatsCollector { } uint32_t get_log_interval() const { return this->log_interval_; } - void record_component_time(Component *component, uint32_t duration_us); - // Process any pending stats printing (should be called after component loop) void process_pending_stats(uint32_t current_time); protected: void log_stats_(); + // Static comparators — member functions have friend access, lambdas do not + static bool compare_period_time(Component *a, Component *b); + static bool compare_total_time(Component *a, Component *b); - void reset_stats_() { - for (auto &it : this->component_stats_) { - it.second.reset_period_stats(); - } - } - - // Map from component to its stats - // We use Component* as the key since each component is unique - std::map component_stats_; uint32_t log_interval_; uint32_t next_log_time_{0}; }; diff --git a/esphome/core/application.h b/esphome/core/application.h index 06ff30e81f..6cc61bc954 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -130,6 +130,12 @@ bool socket_ready_fd(int fd, bool loop_monitored); // NOLINT(readability-redund #endif } // namespace esphome::socket +#ifdef USE_RUNTIME_STATS +namespace esphome::runtime_stats { +class RuntimeStatsCollector; +} // namespace esphome::runtime_stats +#endif + // Forward declarations for friend access from codegen-generated setup() void setup(); // NOLINT(readability-redundant-declaration) - may be declared in Arduino.h void original_setup(); // NOLINT(readability-redundant-declaration) - used by cpp unit tests @@ -590,6 +596,9 @@ class Application { friend Component; #if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_LWIP_FAST_SELECT) friend bool socket::socket_ready_fd(int fd, bool loop_monitored); +#endif +#ifdef USE_RUNTIME_STATS + friend class runtime_stats::RuntimeStatsCollector; #endif friend void ::setup(); friend void ::original_setup(); diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 00a36fce3d..955596ce95 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -9,9 +9,6 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#ifdef USE_RUNTIME_STATS -#include "esphome/components/runtime_stats/runtime_stats.h" -#endif namespace esphome { @@ -524,13 +521,8 @@ WarnIfComponentBlockingGuard::warn_blocking(Component *component, uint32_t block #ifdef USE_RUNTIME_STATS void WarnIfComponentBlockingGuard::record_runtime_stats_() { - // Use micros() for accurate sub-millisecond timing. millis() has insufficient - // resolution — most components complete in microseconds but millis() only has - // 1ms granularity, so results were essentially random noise. - if (global_runtime_stats != nullptr) { - uint32_t duration_us = micros() - this->started_us_; - global_runtime_stats->record_component_time(this->component_, duration_us); - } + uint32_t duration_us = micros() - this->started_us_; + this->component_->runtime_stats_.record_time(duration_us); } #endif diff --git a/esphome/core/component.h b/esphome/core/component.h index c390a205f0..c5a331ee29 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -20,6 +20,12 @@ namespace esphome { // Forward declaration for LogString struct LogString; +#ifdef USE_RUNTIME_STATS +namespace runtime_stats { +class RuntimeStatsCollector; +} // namespace runtime_stats +#endif + /** Default setup priorities for components of different types. * * Components should return one of these setup priorities in get_setup_priority. @@ -92,6 +98,37 @@ inline constexpr uint8_t WARN_IF_BLOCKING_OVER_CS = 5U; // 50ms in centiseconds /// Weak default returns "" so builds without codegen still link. const LogString *component_source_lookup(uint8_t index); +#ifdef USE_RUNTIME_STATS +/// Inline runtime statistics — eliminates std::map lookup on every loop iteration. +/// Only present when USE_RUNTIME_STATS is defined (profiling builds). +struct ComponentRuntimeStats { + // Period stats (reset each logging interval) + uint32_t period_count{0}; + uint32_t period_time_us{0}; + uint32_t period_max_time_us{0}; + // Total stats (persistent until reboot, uint64_t to avoid overflow) + uint32_t total_count{0}; + uint64_t total_time_us{0}; + uint32_t total_max_time_us{0}; + + void record_time(uint32_t duration_us) { + this->period_count++; + this->period_time_us += duration_us; + if (duration_us > this->period_max_time_us) + this->period_max_time_us = duration_us; + this->total_count++; + this->total_time_us += duration_us; + if (duration_us > this->total_max_time_us) + this->total_max_time_us = duration_us; + } + void reset_period() { + this->period_count = 0; + this->period_time_us = 0; + this->period_max_time_us = 0; + } +}; +#endif + class Component { public: /** Where the component's initialization should happen. @@ -529,6 +566,11 @@ class Component { /// Bits 6-7: Unused - reserved for future expansion uint8_t component_state_{0x00}; volatile bool pending_enable_loop_{false}; ///< ISR-safe flag for enable_loop_soon_any_context +#ifdef USE_RUNTIME_STATS + friend class runtime_stats::RuntimeStatsCollector; + friend class WarnIfComponentBlockingGuard; + ComponentRuntimeStats runtime_stats_; +#endif }; /** This class simplifies creating components that periodically check a state. diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7259167a52..23e65f55bc 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -180,6 +180,7 @@ #define USE_RUNTIME_IMAGE_BMP #define USE_RUNTIME_IMAGE_PNG #define USE_RUNTIME_IMAGE_JPEG +#define USE_RUNTIME_STATS #define USE_OTA #define USE_OTA_PASSWORD #define USE_OTA_STATE_LISTENER From 2e3ea2152d103a21b0cfbe5018bd3a47aabe285a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:13:23 -0400 Subject: [PATCH 66/72] [esp32_camera] Bump esp32-camera to v2.1.6 (#15349) --- esphome/components/camera_encoder/__init__.py | 2 +- esphome/components/esp32_camera/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/camera_encoder/__init__.py b/esphome/components/camera_encoder/__init__.py index a0c59a517a..3bbeae7835 100644 --- a/esphome/components/camera_encoder/__init__.py +++ b/esphome/components/camera_encoder/__init__.py @@ -50,7 +50,7 @@ async def to_code(config: ConfigType) -> None: buffer = cg.new_Pvariable(config[CONF_ENCODER_BUFFER_ID]) cg.add(buffer.set_buffer_size(config[CONF_BUFFER_SIZE])) if config[CONF_TYPE] == ESP32_CAMERA_ENCODER: - add_idf_component(name="espressif/esp32-camera", ref="2.1.5") + add_idf_component(name="espressif/esp32-camera", ref="2.1.6") cg.add_define("USE_ESP32_CAMERA_JPEG_ENCODER") var = cg.new_Pvariable( config[CONF_ID], diff --git a/esphome/components/esp32_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index afab849a7c..66af321e4e 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -400,7 +400,7 @@ async def to_code(config): if config[CONF_JPEG_QUALITY] != 0 and config[CONF_PIXEL_FORMAT] != "JPEG": cg.add_define("USE_ESP32_CAMERA_JPEG_CONVERSION") - add_idf_component(name="espressif/esp32-camera", ref="2.1.5") + add_idf_component(name="espressif/esp32-camera", ref="2.1.6") add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_NEW", True) add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_LEGACY", False) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index c44853969e..462af5d1e7 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -10,7 +10,7 @@ dependencies: espressif/esp-tflite-micro: version: 1.3.3~1 espressif/esp32-camera: - version: 2.1.5 + version: 2.1.6 espressif/mdns: version: 1.10.0 espressif/esp_wifi_remote: From bdce47e764497cd75051f885ffd2916fdf980459 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:39:51 +1000 Subject: [PATCH 67/72] [lvgl] Fixes #4 (#15334) --- esphome/components/lvgl/__init__.py | 22 +++++++++++++++++----- esphome/components/lvgl/lvgl_esphome.h | 8 +++++--- esphome/components/lvgl/schemas.py | 1 + tests/components/lvgl/lvgl-package.yaml | 8 ++++++++ 4 files changed, 31 insertions(+), 8 deletions(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 736fba759f..3b4f150699 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -380,8 +380,10 @@ async def to_code(configs): # This must be done after all widgets are created for comp in helpers.lvgl_components_required: cg.add_define(f"USE_LVGL_{comp.upper()}") - # Currently always need RGB565 for the display buffer, and ARGB8888 is used for layer blending - lv_image_formats = {"RGB565", "ARGB8888"} + for use in helpers.lv_uses: + df.add_define(f"LV_USE_{use.upper()}") + cg.add_define(f"USE_LVGL_{use.upper()}") + if { "transform_rotation", "transform_scale", @@ -389,9 +391,18 @@ async def to_code(configs): "transform_scale_y", } & styles_used: df.add_define("LV_COLOR_SCREEN_TRANSP", "1") - for use in helpers.lv_uses: - df.add_define(f"LV_USE_{use.upper()}") - cg.add_define(f"USE_LVGL_{use.upper()}") + + # Currently always need RGB565 for the display buffer, and ARGB8888 is used for layer blending + lv_image_formats = {"RGB565", "ARGB8888"} + if { + "drop_shadow_color", + "drop_shadow_offset_x", + "drop_shadow_offset_y", + "drop_shadow_opa", + "drop_shadow_quality", + "drop_shadow_radius", + } & styles_used: + lv_image_formats.add("A8") for image_id in lv_images_used: await cg.get_variable(image_id) @@ -410,6 +421,7 @@ async def to_code(configs): lv_image_formats.add("RGB888") for fmt in lv_image_formats: df.add_define(f"LV_DRAW_SW_SUPPORT_{fmt}", "1") + lv_conf_h_file = CORE.relative_src_path(LV_CONF_FILENAME) write_file_if_changed(lv_conf_h_file, generate_lv_conf_h()) cg.add_build_flag("-DLV_CONF_H=1") diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 21d1e0d417..8d139b23cb 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -74,11 +74,13 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) { #if defined(USE_LVGL_IMAGE) && defined(USE_IMAGE) // Shortcut / overload, so that the source of an image can easily be updated // from within a lambda. -inline void lv_image_set_src(lv_obj_t *obj, esphome::image::Image *image) { - lv_image_set_src(obj, image->get_lv_image_dsc()); +inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { lv_image_set_src(obj, image->get_lv_image_dsc()); } + +inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) { + lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector); } -inline void lv_obj_set_style_bg_image_src(lv_obj_t *obj, esphome::image::Image *image, lv_style_selector_t selector) { +inline void lv_obj_set_style_bg_image_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) { lv_obj_set_style_bg_image_src(obj, image->get_lv_image_dsc(), selector); } #endif // USE_LVGL_IMAGE diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 9c9504f05f..4f1473b652 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -168,6 +168,7 @@ BASE_PROPS = { "bg_main_opa": lvalid.opacity, "bg_main_stop": lvalid.stop_value, "bg_opa": lvalid.opacity, + "bitmap_mask_src": lvalid.lv_image, "blend_mode": df.LvConstant( "LV_BLEND_MODE_", "NORMAL", diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index b8c9a1809e..abc66ef587 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -511,6 +511,7 @@ lvgl: image: src: cat_image align: top_left + bitmap_mask_src: alert on_click: - lvgl.widget.focus: spin_up - lvgl.widget.focus: next @@ -1189,6 +1190,13 @@ image: type: BINARY transparency: chroma_key + - id: alert + file: $component_dir/logo-text.svg + type: grayscale + resize: 100x100 + invert_alpha: true + transparency: alpha_channel + color: - id: light_blue hex: "3340FF" From 5cdbbd48873b0cfed261b2c19081fd42e99967a0 Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Wed, 1 Apr 2026 23:48:47 +0200 Subject: [PATCH 68/72] [mitsubishi_cn105] Add climate component for Mitsubishi A/C units with CN105 connector (Part 1) (#15315) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../components/mitsubishi_cn105/__init__.py | 0 .../components/mitsubishi_cn105/climate.py | 41 +++++++++++++++++++ .../mitsubishi_cn105/mitsubishi_cn105.cpp | 7 ++++ .../mitsubishi_cn105/mitsubishi_cn105.h | 19 +++++++++ .../mitsubishi_cn105_climate.cpp | 28 +++++++++++++ .../mitsubishi_cn105_climate.h | 27 ++++++++++++ tests/components/mitsubishi_cn105/common.yaml | 4 ++ .../mitsubishi_cn105/test.esp32-idf.yaml | 4 ++ .../mitsubishi_cn105/test.esp8266-ard.yaml | 4 ++ .../mitsubishi_cn105/test.rp2040-ard.yaml | 4 ++ 11 files changed, 139 insertions(+) create mode 100644 esphome/components/mitsubishi_cn105/__init__.py create mode 100644 esphome/components/mitsubishi_cn105/climate.py create mode 100644 esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp create mode 100644 esphome/components/mitsubishi_cn105/mitsubishi_cn105.h create mode 100644 esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp create mode 100644 esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h create mode 100644 tests/components/mitsubishi_cn105/common.yaml create mode 100644 tests/components/mitsubishi_cn105/test.esp32-idf.yaml create mode 100644 tests/components/mitsubishi_cn105/test.esp8266-ard.yaml create mode 100644 tests/components/mitsubishi_cn105/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 03f41618af..fffe5ce91c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -331,6 +331,7 @@ esphome/components/mipi_dsi/* @clydebarrow esphome/components/mipi_rgb/* @clydebarrow esphome/components/mipi_spi/* @clydebarrow esphome/components/mitsubishi/* @RubyBailey +esphome/components/mitsubishi_cn105/* @crnjan esphome/components/mixer/speaker/* @kahrendt esphome/components/mlx90393/* @functionpointer esphome/components/mlx90614/* @jesserockz diff --git a/esphome/components/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/mitsubishi_cn105/climate.py b/esphome/components/mitsubishi_cn105/climate.py new file mode 100644 index 0000000000..5ea72d4cd2 --- /dev/null +++ b/esphome/components/mitsubishi_cn105/climate.py @@ -0,0 +1,41 @@ +import esphome.codegen as cg +from esphome.components import climate, uart +import esphome.config_validation as cv +from esphome.const import CONF_UPDATE_INTERVAL +from esphome.types import ConfigType + +DEPENDENCIES = ["uart"] +AUTO_LOAD = ["climate"] +CODEOWNERS = ["@crnjan"] + +mitsubishi_ns = cg.esphome_ns.namespace("mitsubishi_cn105") + +MitsubishiCN105Climate = mitsubishi_ns.class_( + "MitsubishiCN105Climate", + climate.Climate, + cg.Component, + uart.UARTDevice, +) + +CONFIG_SCHEMA = ( + climate.climate_schema(MitsubishiCN105Climate) + .extend(uart.UART_DEVICE_SCHEMA) + .extend({cv.Optional(CONF_UPDATE_INTERVAL, default="1s"): cv.update_interval}) +) + +FINAL_VALIDATE_SCHEMA = cv.All( + uart.final_validate_device_schema( + "mitsubishi_cn105", + require_rx=True, + require_tx=True, + data_bits=8, + parity="EVEN", + stop_bits=1, + ) +) + + +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/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp new file mode 100644 index 0000000000..35ab405b2d --- /dev/null +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -0,0 +1,7 @@ +#include "mitsubishi_cn105.h" + +namespace esphome::mitsubishi_cn105 { + +static const char *const TAG = "mitsubishi_cn105.driver"; + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h new file mode 100644 index 0000000000..6018dddbff --- /dev/null +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h @@ -0,0 +1,19 @@ +#pragma once + +#include "esphome/components/uart/uart.h" + +namespace esphome::mitsubishi_cn105 { + +class MitsubishiCN105 { + public: + explicit MitsubishiCN105(uart::UARTDevice &device) : device_(device) {} + + uint32_t get_update_interval() const { return this->update_interval_ms_; } + void set_update_interval(uint32_t interval_ms) { this->update_interval_ms_ = interval_ms; } + + protected: + uart::UARTDevice &device_; + uint32_t update_interval_ms_{1000}; +}; + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp new file mode 100644 index 0000000000..6d50296c8f --- /dev/null +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -0,0 +1,28 @@ +#include "mitsubishi_cn105_climate.h" +#include "esphome/core/log.h" + +namespace esphome::mitsubishi_cn105 { + +static const char *const TAG = "mitsubishi_cn105.climate"; + +void MitsubishiCN105Climate::dump_config() { + LOG_CLIMATE("", "Mitsubishi CN105 Climate", this); + ESP_LOGCONFIG(TAG, + " Update interval: %" PRIu32 " ms\n" + " UART: baud_rate=%" PRIu32 " data_bits=%u parity=%s stop_bits=%u", + this->hp_.get_update_interval(), this->parent_->get_baud_rate(), this->parent_->get_data_bits(), + LOG_STR_ARG(parity_to_str(this->parent_->get_parity())), this->parent_->get_stop_bits()); +} + +void MitsubishiCN105Climate::setup() {} + +void MitsubishiCN105Climate::loop() {} + +climate::ClimateTraits MitsubishiCN105Climate::traits() { + climate::ClimateTraits traits; + return traits; +} + +void MitsubishiCN105Climate::control(const climate::ClimateCall &call) {} + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h new file mode 100644 index 0000000000..08b482025f --- /dev/null +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h @@ -0,0 +1,27 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/climate/climate.h" +#include "esphome/components/uart/uart.h" +#include "mitsubishi_cn105.h" + +namespace esphome::mitsubishi_cn105 { + +class MitsubishiCN105Climate : public climate::Climate, public Component, public uart::UARTDevice { + public: + explicit MitsubishiCN105Climate() : hp_(*this) {} + + void setup() override; + void loop() override; + void dump_config() override; + + climate::ClimateTraits traits() override; + void control(const climate::ClimateCall &call) override; + + void set_update_interval(uint32_t ms) { hp_.set_update_interval(ms); } + + protected: + MitsubishiCN105 hp_; +}; + +} // namespace esphome::mitsubishi_cn105 diff --git a/tests/components/mitsubishi_cn105/common.yaml b/tests/components/mitsubishi_cn105/common.yaml new file mode 100644 index 0000000000..e885ceef81 --- /dev/null +++ b/tests/components/mitsubishi_cn105/common.yaml @@ -0,0 +1,4 @@ +climate: + - platform: mitsubishi_cn105 + name: "AC Test" + uart_id: uart_bus diff --git a/tests/components/mitsubishi_cn105/test.esp32-idf.yaml b/tests/components/mitsubishi_cn105/test.esp32-idf.yaml new file mode 100644 index 0000000000..ac63cf987f --- /dev/null +++ b/tests/components/mitsubishi_cn105/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/mitsubishi_cn105/test.esp8266-ard.yaml b/tests/components/mitsubishi_cn105/test.esp8266-ard.yaml new file mode 100644 index 0000000000..9f2f350b46 --- /dev/null +++ b/tests/components/mitsubishi_cn105/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart_9600_even/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/mitsubishi_cn105/test.rp2040-ard.yaml b/tests/components/mitsubishi_cn105/test.rp2040-ard.yaml new file mode 100644 index 0000000000..4363d6eee8 --- /dev/null +++ b/tests/components/mitsubishi_cn105/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart_9600_even/rp2040-ard.yaml + +<<: !include common.yaml From b5c4449a161c2fc83b4e7b5150b50dcc6ef1ea71 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:11:44 -1000 Subject: [PATCH 69/72] Bump pillow from 12.1.1 to 12.2.0 (#15361) 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 8ad5528c95..dd20600097 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 -pillow==12.1.1 +pillow==12.2.0 resvg-py==0.2.6 freetype-py==2.5.1 jinja2==3.1.6 From eefbb42be477d1ed2e80c251859a4588df61c47b Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:16:56 +1000 Subject: [PATCH 70/72] [lvgl] Add missing event names (#15362) --- esphome/components/lvgl/defines.py | 75 +++++++++++++--- tests/components/lvgl/lvgl-package.yaml | 108 ++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 11 deletions(-) diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index dd51a2f519..500ccb608a 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -251,22 +251,75 @@ LV_FONTS = list(f"montserrat_{s}" for s in range(8, 50, 2)) + [ ] LV_EVENT_MAP = { - "PRESS": "PRESSED", - "SHORT_CLICK": "SHORT_CLICKED", + "ALL_EVENTS": "ALL", + "CANCEL": "CANCEL", + "CHANGE": "VALUE_CHANGED", + "CHILD_CHANGE": "CHILD_CHANGED", + "CHILD_CREATE": "CHILD_CREATED", + "CHILD_DELETE": "CHILD_DELETED", + "CLICK": "CLICKED", + "COLOR_FORMAT_CHANGE": "COLOR_FORMAT_CHANGED", + "COVER_CHECK": "COVER_CHECK", + "CREATE": "CREATE", + "DEFOCUS": "DEFOCUSED", + "DELETE": "DELETE", + "DOUBLE_CLICK": "DOUBLE_CLICKED", + "DRAW_MAIN": "DRAW_MAIN", + "DRAW_MAIN_BEGIN": "DRAW_MAIN_BEGIN", + "DRAW_MAIN_END": "DRAW_MAIN_END", + "DRAW_POST": "DRAW_POST", + "DRAW_POST_BEGIN": "DRAW_POST_BEGIN", + "DRAW_POST_END": "DRAW_POST_END", + "DRAW_TASK_ADD": "DRAW_TASK_ADDED", + "FLUSH_FINISH": "FLUSH_FINISH", + "FLUSH_START": "FLUSH_START", + "FLUSH_WAIT_FINISH": "FLUSH_WAIT_FINISH", + "FLUSH_WAIT_START": "FLUSH_WAIT_START", + "FOCUS": "FOCUSED", + "GESTURE": "GESTURE", + "GET_SELF_SIZE": "GET_SELF_SIZE", + "HIT_TEST": "HIT_TEST", + "HOVER_LEAVE": "HOVER_LEAVE", + "HOVER_OVER": "HOVER_OVER", + "INDEV_RESET": "INDEV_RESET", + "INSERT": "INSERT", + "INVALIDATE_AREA": "INVALIDATE_AREA", + "KEY": "KEY", + "LAYOUT_CHANGE": "LAYOUT_CHANGED", + "LEAVE": "LEAVE", "LONG_PRESS": "LONG_PRESSED", "LONG_PRESS_REPEAT": "LONG_PRESSED_REPEAT", - "CLICK": "CLICKED", + "PRESS": "PRESSED", + "PRESS_LOST": "PRESS_LOST", + "PRESSING": "PRESSING", + "READY": "READY", + "REFRESH": "REFRESH", + "REFR_EXT_DRAW_SIZE": "REFR_EXT_DRAW_SIZE", + "REFR_READY": "REFR_READY", + "REFR_REQUEST": "REFR_REQUEST", + "REFR_START": "REFR_START", "RELEASE": "RELEASED", + "RENDER_READY": "RENDER_READY", + "RENDER_START": "RENDER_START", + "RESOLUTION_CHANGE": "RESOLUTION_CHANGED", + "ROTARY": "ROTARY", + "SCREEN_LOAD": "SCREEN_LOADED", + "SCREEN_LOAD_START": "SCREEN_LOAD_START", + "SCREEN_UNLOAD": "SCREEN_UNLOADED", + "SCREEN_UNLOAD_START": "SCREEN_UNLOAD_START", + "SCROLL": "SCROLL", "SCROLL_BEGIN": "SCROLL_BEGIN", "SCROLL_END": "SCROLL_END", - "SCROLL": "SCROLL", - "FOCUS": "FOCUSED", - "DEFOCUS": "DEFOCUSED", - "READY": "READY", - "CANCEL": "CANCEL", - "ALL_EVENTS": "ALL", - "CHANGE": "VALUE_CHANGED", - "GESTURE": "GESTURE", + "SCROLL_THROW_BEGIN": "SCROLL_THROW_BEGIN", + "SHORT_CLICK": "SHORT_CLICKED", + "SINGLE_CLICK": "SINGLE_CLICKED", + "SIZE_CHANGE": "SIZE_CHANGED", + "STATE_CHANGE": "STATE_CHANGED", + "STYLE_CHANGE": "STYLE_CHANGED", + "TRIPLE_CLICK": "TRIPLE_CLICKED", + "UPDATE_LAYOUT_COMPLETE": "UPDATE_LAYOUT_COMPLETED", + "VSYNC": "VSYNC", + "VSYNC_REQUEST": "VSYNC_REQUEST", } LV_EVENT_TRIGGERS = tuple(f"on_{x.lower()}" for x in LV_EVENT_MAP) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index abc66ef587..3a6af93b64 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -590,6 +590,114 @@ lvgl: logger.log: Button clicked on_long_press_repeat: logger.log: Button clicked + on_pressing: + logger.log: Button pressing + on_press_lost: + logger.log: Button press lost + on_single_click: + logger.log: Button single clicked + on_double_click: + logger.log: Button double clicked + on_triple_click: + logger.log: Button triple clicked + on_scroll_throw_begin: + logger.log: Scroll throw begin + on_gesture: + logger.log: Gesture detected + on_key: + logger.log: Key event + on_rotary: + logger.log: Rotary event + on_leave: + logger.log: Leave event + on_hit_test: + logger.log: Hit test + on_indev_reset: + logger.log: Indev reset + on_hover_over: + logger.log: Hover over + on_hover_leave: + logger.log: Hover leave + on_cover_check: + logger.log: Cover check + on_refr_ext_draw_size: + logger.log: Refr ext draw size + on_draw_main_begin: + logger.log: Draw main begin + on_draw_main: + logger.log: Draw main + on_draw_main_end: + logger.log: Draw main end + on_draw_post_begin: + logger.log: Draw post begin + on_draw_post: + logger.log: Draw post + on_draw_post_end: + logger.log: Draw post end + on_draw_task_add: + logger.log: Draw task add + on_insert: + logger.log: Insert event + on_refresh: + logger.log: Refresh event + on_state_change: + logger.log: State changed + on_create: + logger.log: Create event + on_delete: + logger.log: Delete event + on_child_change: + logger.log: Child changed + on_child_create: + logger.log: Child created + on_child_delete: + logger.log: Child deleted + on_screen_unload_start: + logger.log: Screen unload start + on_screen_load_start: + logger.log: Screen load start + on_screen_load: + logger.log: Screen loaded + on_screen_unload: + logger.log: Screen unloaded + on_size_change: + logger.log: Size changed + on_style_change: + logger.log: Style changed + on_layout_change: + logger.log: Layout changed + on_get_self_size: + logger.log: Get self size + on_invalidate_area: + logger.log: Invalidate area + on_resolution_change: + logger.log: Resolution changed + on_color_format_change: + logger.log: Color format changed + on_refr_request: + logger.log: Refresh request + on_refr_start: + logger.log: Refresh start + on_refr_ready: + logger.log: Refresh ready + on_render_start: + logger.log: Render start + on_render_ready: + logger.log: Render ready + on_flush_start: + logger.log: Flush start + on_flush_finish: + logger.log: Flush finish + on_flush_wait_start: + logger.log: Flush wait start + on_flush_wait_finish: + logger.log: Flush wait finish + on_update_layout_complete: + logger.log: Update layout complete + on_vsync: + logger.log: Vsync + on_vsync_request: + logger.log: Vsync request - led: id: lv_led color: 0x00FF00 From 27c662e73faf6583179084d6a179816751d8d170 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 16:11:50 -1000 Subject: [PATCH 71/72] [bluetooth_proxy] Replace loop() with set_interval for advertisement flushing (#15347) --- .../bluetooth_proxy/bluetooth_proxy.cpp | 48 ++++++------------- .../bluetooth_proxy/bluetooth_proxy.h | 17 +++++-- 2 files changed, 27 insertions(+), 38 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 87206996b2..c69163b1f7 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -30,6 +30,19 @@ void BluetoothProxy::setup() { this->configured_scan_active_ = this->parent_->get_scan_active(); this->parent_->add_scanner_state_listener(this); + + this->set_interval(100, [this]() { + if (api::global_api_server->is_connected() && this->api_connection_ != nullptr) { + this->flush_pending_advertisements_(); + return; + } + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto *connection = this->connections_[i]; + if (connection->get_address() != 0 && !connection->disconnect_pending()) { + connection->disconnect(); + } + } + }); } void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) { @@ -101,25 +114,15 @@ bool BluetoothProxy::parse_devices(const esp32_ble::BLEScanResult *scan_results, // Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) { - this->flush_pending_advertisements(); + this->flush_pending_advertisements_(); } } return true; } -void BluetoothProxy::flush_pending_advertisements() { - if (this->response_.advertisements_len == 0 || !api::global_api_server->is_connected() || - this->api_connection_ == nullptr) - return; - - // Send the message - this->api_connection_->send_message(this->response_); - +void BluetoothProxy::log_advertisement_flush_() { ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len); - - // Reset the length for the next batch - this->response_.advertisements_len = 0; } void BluetoothProxy::dump_config() { @@ -130,27 +133,6 @@ void BluetoothProxy::dump_config() { YESNO(this->active_), this->connection_count_); } -void BluetoothProxy::loop() { - if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { - for (uint8_t i = 0; i < this->connection_count_; i++) { - auto *connection = this->connections_[i]; - if (connection->get_address() != 0 && !connection->disconnect_pending()) { - connection->disconnect(); - } - } - return; - } - - // Flush any pending BLE advertisements that have been accumulated but not yet sent - uint32_t now = App.get_loop_component_start_time(); - - // Flush accumulated advertisements every 100ms - if (now - this->last_advertisement_flush_time_ >= 100) { - this->flush_pending_advertisements(); - this->last_advertisement_flush_time_ = now; - } -} - esp32_ble_tracker::AdvertisementParserType BluetoothProxy::get_advertisement_parser_type() { return esp32_ble_tracker::AdvertisementParserType::RAW_ADVERTISEMENTS; } diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index f1b723e719..6680ab0e84 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -65,8 +65,6 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, bool parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) override; void dump_config() override; void setup() override; - void loop() override; - void flush_pending_advertisements(); esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override; void register_connection(BluetoothConnection *connection) { @@ -150,6 +148,18 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, protected: void send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state); + /// Caller must ensure api_connection_ is non-null and API server is connected. + void flush_pending_advertisements_() { + if (this->response_.advertisements_len == 0) + return; + this->api_connection_->send_message(this->response_); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + this->log_advertisement_flush_(); +#endif + this->response_.advertisements_len = 0; + } + void log_advertisement_flush_(); + BluetoothConnection *get_connection_(uint64_t address, bool reserve); void log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state); void log_connection_info_(BluetoothConnection *connection, const char *message); @@ -166,9 +176,6 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, // BLE advertisement batching api::BluetoothLERawAdvertisementsResponse response_; - // Group 3: 4-byte types - uint32_t last_advertisement_flush_time_{0}; - // Pre-allocated response message - always ready to send api::BluetoothConnectionsFreeResponse connections_free_response_; From bcc7b8f490cae830be31bb3e891c5a39a43fac37 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Apr 2026 16:12:02 -1000 Subject: [PATCH 72/72] [api] Add send_sensor_state benchmarks (#15352) --- esphome/components/api/api_connection.h | 12 ++ .../benchmarks/components/api/bench_helpers.h | 67 ++++++ .../components/api/bench_plaintext_frame.cpp | 58 +----- .../api/bench_send_sensor_state.cpp | 191 ++++++++++++++++++ 4 files changed, 272 insertions(+), 56 deletions(-) create mode 100644 tests/benchmarks/components/api/bench_helpers.h create mode 100644 tests/benchmarks/components/api/bench_send_sensor_state.cpp diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 3d8563b1ae..4ce1335650 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -44,10 +44,22 @@ static constexpr size_t MAX_INITIAL_PER_BATCH = 34; // For clients >= AP static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_PER_BATCH, "MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_PER_BATCH"); +#ifdef USE_BENCHMARK +class APIConnection; +void bench_enable_immediate_send(APIConnection *conn); +void bench_clear_batch(APIConnection *conn); +void bench_process_batch(APIConnection *conn); +#endif + class APIConnection final : public APIServerConnectionBase { public: friend class APIServer; friend class ListEntitiesIterator; +#ifdef USE_BENCHMARK + friend void bench_enable_immediate_send(APIConnection *conn); + friend void bench_clear_batch(APIConnection *conn); + friend void bench_process_batch(APIConnection *conn); +#endif APIConnection(std::unique_ptr socket, APIServer *parent); ~APIConnection(); diff --git a/tests/benchmarks/components/api/bench_helpers.h b/tests/benchmarks/components/api/bench_helpers.h new file mode 100644 index 0000000000..73e51bce3d --- /dev/null +++ b/tests/benchmarks/components/api/bench_helpers.h @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include + +#include "esphome/components/socket/socket.h" + +namespace esphome::api::benchmarks { + +// Helper to drain accumulated data from the read side of a socket +// to prevent the write side from blocking. +inline void drain_socket(int fd) { + char buf[65536]; + while (::read(fd, buf, sizeof(buf)) > 0) { + } +} + +// Create a TCP loopback socket pair. Returns the write-side Socket +// (wrapped for ESPHome) and the raw read-side fd for draining. +// Both ends are non-blocking with 16MB buffers. +inline std::pair, int> create_tcp_loopback() { + // Create a TCP listener on loopback + int listen_fd = ::socket(AF_INET, SOCK_STREAM, 0); + int opt = 1; + ::setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); + + struct sockaddr_in addr {}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; // OS-assigned port + ::bind(listen_fd, reinterpret_cast(&addr), sizeof(addr)); + ::listen(listen_fd, 1); + + // Get the assigned port + socklen_t addr_len = sizeof(addr); + ::getsockname(listen_fd, reinterpret_cast(&addr), &addr_len); + + // Connect from client side + int write_fd = ::socket(AF_INET, SOCK_STREAM, 0); + ::connect(write_fd, reinterpret_cast(&addr), sizeof(addr)); + + // Accept on server side (this is our read fd) + int read_fd = ::accept(listen_fd, nullptr, nullptr); + ::close(listen_fd); + + // Make both ends non-blocking + int flags = ::fcntl(write_fd, F_GETFL, 0); + ::fcntl(write_fd, F_SETFL, flags | O_NONBLOCK); + flags = ::fcntl(read_fd, F_GETFL, 0); + ::fcntl(read_fd, F_SETFL, flags | O_NONBLOCK); + + // Use large socket buffers so benchmarks never hit WOULD_BLOCK + // during a single outer iteration (2000 × ~15B messages = ~30KB). + int bufsize = 16 * 1024 * 1024; + ::setsockopt(write_fd, SOL_SOCKET, SO_SNDBUF, &bufsize, sizeof(bufsize)); + ::setsockopt(read_fd, SOL_SOCKET, SO_RCVBUF, &bufsize, sizeof(bufsize)); + + return {std::make_unique(write_fd), read_fd}; +} + +} // namespace esphome::api::benchmarks diff --git a/tests/benchmarks/components/api/bench_plaintext_frame.cpp b/tests/benchmarks/components/api/bench_plaintext_frame.cpp index 79bffaf953..0caa50c748 100644 --- a/tests/benchmarks/components/api/bench_plaintext_frame.cpp +++ b/tests/benchmarks/components/api/bench_plaintext_frame.cpp @@ -2,12 +2,9 @@ #ifdef USE_API_PLAINTEXT #include -#include -#include -#include -#include #include +#include "bench_helpers.h" #include "esphome/components/api/api_frame_helper_plaintext.h" #include "esphome/components/api/api_pb2.h" #include "esphome/components/api/api_buffer.h" @@ -16,57 +13,12 @@ namespace esphome::api::benchmarks { static constexpr int kInnerIterations = 2000; -// Helper to drain accumulated data from the read side of a socket -// to prevent the write side from blocking. -static void drain_socket(int fd) { - char buf[65536]; - while (::read(fd, buf, sizeof(buf)) > 0) { - } -} - // Helper to create a TCP loopback connection with an APIPlaintextFrameHelper // on the write end. Returns the helper and the read-side fd. -// Uses real TCP sockets so TCP_NODELAY succeeds during init(). static std::pair, int> create_plaintext_helper() { - // Create a TCP listener on loopback - int listen_fd = ::socket(AF_INET, SOCK_STREAM, 0); - int opt = 1; - ::setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); - - struct sockaddr_in addr {}; - addr.sin_family = AF_INET; - addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); - addr.sin_port = 0; // OS-assigned port - ::bind(listen_fd, reinterpret_cast(&addr), sizeof(addr)); - ::listen(listen_fd, 1); - - // Get the assigned port - socklen_t addr_len = sizeof(addr); - ::getsockname(listen_fd, reinterpret_cast(&addr), &addr_len); - - // Connect from client side - int write_fd = ::socket(AF_INET, SOCK_STREAM, 0); - ::connect(write_fd, reinterpret_cast(&addr), sizeof(addr)); - - // Accept on server side (this is our read fd) - int read_fd = ::accept(listen_fd, nullptr, nullptr); - ::close(listen_fd); - - // Make both ends non-blocking - int flags = ::fcntl(write_fd, F_GETFL, 0); - ::fcntl(write_fd, F_SETFL, flags | O_NONBLOCK); - flags = ::fcntl(read_fd, F_GETFL, 0); - ::fcntl(read_fd, F_SETFL, flags | O_NONBLOCK); - - // Increase socket buffer sizes to reduce drain frequency - int bufsize = 1024 * 1024; - ::setsockopt(write_fd, SOL_SOCKET, SO_SNDBUF, &bufsize, sizeof(bufsize)); - ::setsockopt(read_fd, SOL_SOCKET, SO_RCVBUF, &bufsize, sizeof(bufsize)); - - auto sock = std::make_unique(write_fd); + auto [sock, read_fd] = create_tcp_loopback(); auto helper = std::make_unique(std::move(sock)); helper->init(); - return {std::move(helper), read_fd}; } @@ -97,9 +49,6 @@ static void PlaintextFrame_WriteSensorState(benchmark::State &state) { msg.encode(writer); helper->write_protobuf_packet(SensorStateResponse::MESSAGE_TYPE, writer); - - if ((i & 0xFF) == 0) - drain_socket(read_fd); } drain_socket(read_fd); benchmark::DoNotOptimize(helper.get()); @@ -144,9 +93,6 @@ static void PlaintextFrame_WriteBatch5(benchmark::State &state) { } helper->write_protobuf_messages(ProtoWriteBuffer(&buffer, 0), std::span(messages, 5)); - - if ((i & 0xFF) == 0) - drain_socket(read_fd); } drain_socket(read_fd); benchmark::DoNotOptimize(helper.get()); diff --git a/tests/benchmarks/components/api/bench_send_sensor_state.cpp b/tests/benchmarks/components/api/bench_send_sensor_state.cpp new file mode 100644 index 0000000000..815081374a --- /dev/null +++ b/tests/benchmarks/components/api/bench_send_sensor_state.cpp @@ -0,0 +1,191 @@ +#include "esphome/core/defines.h" +#if defined(USE_API_PLAINTEXT) && defined(USE_SENSOR) + +#include +#include + +#include "bench_helpers.h" +#include "esphome/components/api/api_connection.h" +#include "esphome/components/api/api_server.h" +#include "esphome/components/sensor/sensor.h" + +namespace esphome::api { + +// Friend functions declared in APIConnection for benchmark access. +void bench_enable_immediate_send(APIConnection *conn) { conn->flags_.should_try_send_immediately = true; } +void bench_clear_batch(APIConnection *conn) { conn->clear_batch_(); } +void bench_process_batch(APIConnection *conn) { conn->process_batch_(); } + +} // namespace esphome::api + +namespace esphome::api::benchmarks { + +static constexpr int kInnerIterations = 2000; + +// Helper to create a TCP loopback connection with an APIConnection. +// Returns the connection and the read-side fd for draining. +static std::pair, int> create_api_connection() { + auto [sock, read_fd] = create_tcp_loopback(); + auto conn = std::make_unique(std::move(sock), global_api_server); + conn->start(); + return {std::move(conn), read_fd}; +} + +// Test subclass to access protected configure_entity_() for benchmark setup. +class TestSensor : public sensor::Sensor { + public: + void configure(const char *name) { this->configure_entity_(name, 0x12345678, 0); } +}; + +// --- send_sensor_state: immediate send path --- +// Measures: send_message_smart_ → prepare buffer → dispatch_message_ → +// try_send_sensor_state → fill key/device_id + proto encode → frame write → +// TCP send. This is the per-client cost when batch_delay=0 and initial states +// have been sent. + +static void SendSensorState_Immediate(benchmark::State &state) { + auto [conn, read_fd] = create_api_connection(); + bench_enable_immediate_send(conn.get()); + // batch_delay must be 0 for should_send_immediately_ to return true + uint16_t saved_delay = global_api_server->get_batch_delay(); + global_api_server->set_batch_delay(0); + + TestSensor sensor; + sensor.configure("test_sensor"); + sensor.publish_state(23.5f); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->send_sensor_state(&sensor); + } + drain_socket(read_fd); + benchmark::DoNotOptimize(conn.get()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); + + global_api_server->set_batch_delay(saved_delay); + ::close(read_fd); +} +BENCHMARK(SendSensorState_Immediate); + +// --- send_sensor_state: batch path (cold — first call allocates) --- +// Measures: send_message_smart_ → schedule_message_ → deferred batch add. +// Includes one-time vector allocation cost. + +static void SendSensorState_Batch_Cold(benchmark::State &state) { + auto [conn, read_fd] = create_api_connection(); + + TestSensor sensor; + sensor.configure("test_sensor"); + sensor.publish_state(23.5f); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->send_sensor_state(&sensor); + } + benchmark::DoNotOptimize(conn.get()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); + + ::close(read_fd); +} +BENCHMARK(SendSensorState_Batch_Cold); + +// --- send_sensor_state: batch path (warm — buffer already allocated) --- +// Measures steady-state batch cost after the vector has been allocated +// and cleared at least once. This is the typical path during normal +// operation after the first batch has been processed. + +static void SendSensorState_Batch_Warm(benchmark::State &state) { + auto [conn, read_fd] = create_api_connection(); + + TestSensor sensor; + sensor.configure("test_sensor"); + sensor.publish_state(23.5f); + + // Warm up: send once to allocate, then clear to keep capacity + conn->send_sensor_state(&sensor); + bench_clear_batch(conn.get()); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->send_sensor_state(&sensor); + } + benchmark::DoNotOptimize(conn.get()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); + + ::close(read_fd); +} +BENCHMARK(SendSensorState_Batch_Warm); + +// --- process_batch_: single sensor state (encode + frame + write) --- +// Measures the deferred batch processing path: dispatch_message_ → +// try_send_sensor_state → fill + proto encode → send_buffer → frame write. +// This is the cost paid on the next loop() after batching. + +static void ProcessBatch_SingleSensor(benchmark::State &state) { + auto [conn, read_fd] = create_api_connection(); + + TestSensor sensor; + sensor.configure("test_sensor"); + sensor.publish_state(23.5f); + + // Warm up batch vector + conn->send_sensor_state(&sensor); + bench_process_batch(conn.get()); + drain_socket(read_fd); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + conn->send_sensor_state(&sensor); + bench_process_batch(conn.get()); + } + drain_socket(read_fd); + benchmark::DoNotOptimize(conn.get()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); + + ::close(read_fd); +} +BENCHMARK(ProcessBatch_SingleSensor); + +// --- process_batch_: 5 different sensors --- +// Measures batch processing with multiple items queued. +// This exercises the multi-message path in process_batch_. + +static void ProcessBatch_5Sensors(benchmark::State &state) { + auto [conn, read_fd] = create_api_connection(); + + TestSensor sensors[5]; + for (int i = 0; i < 5; i++) { + char name[20]; + snprintf(name, sizeof(name), "sensor_%d", i); + sensors[i].configure(name); + sensors[i].publish_state(23.5f + static_cast(i)); + } + + // Warm up batch vector + for (auto &s : sensors) + conn->send_sensor_state(&s); + bench_process_batch(conn.get()); + drain_socket(read_fd); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + for (auto &s : sensors) + conn->send_sensor_state(&s); + bench_process_batch(conn.get()); + } + drain_socket(read_fd); + benchmark::DoNotOptimize(conn.get()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); + + ::close(read_fd); +} +BENCHMARK(ProcessBatch_5Sensors); + +} // namespace esphome::api::benchmarks + +#endif // USE_API_PLAINTEXT && USE_SENSOR