From cca4777f6458c1bf41e2cdd79f91aafe699e9c08 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Feb 2026 17:51:01 -0600 Subject: [PATCH 01/12] [web_server_idf] Pass std::function by rvalue reference (#14262) --- esphome/components/web_server_idf/multipart.h | 4 ++-- esphome/components/web_server_idf/web_server_idf.h | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/esphome/components/web_server_idf/multipart.h b/esphome/components/web_server_idf/multipart.h index cb1e0ecd1d..8b94b0491e 100644 --- a/esphome/components/web_server_idf/multipart.h +++ b/esphome/components/web_server_idf/multipart.h @@ -33,8 +33,8 @@ class MultipartReader { ~MultipartReader(); // Set callbacks for handling data - void set_data_callback(DataCallback callback) { data_callback_ = std::move(callback); } - void set_part_complete_callback(PartCompleteCallback callback) { part_complete_callback_ = std::move(callback); } + void set_data_callback(DataCallback &&callback) { data_callback_ = std::move(callback); } + void set_part_complete_callback(PartCompleteCallback &&callback) { part_complete_callback_ = std::move(callback); } // Parse incoming data size_t parse(const char *data, size_t len); diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 76ddfa35fd..9d81d89ec7 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -204,7 +204,7 @@ class AsyncWebServer { ~AsyncWebServer() { this->end(); } // NOLINTNEXTLINE(readability-identifier-naming) - void onNotFound(std::function fn) { on_not_found_ = std::move(fn); } + void onNotFound(std::function &&fn) { on_not_found_ = std::move(fn); } void begin(); void end(); @@ -327,7 +327,7 @@ class AsyncEventSource : public AsyncWebHandler { // NOLINTNEXTLINE(readability-identifier-naming) void handleRequest(AsyncWebServerRequest *request) override; // NOLINTNEXTLINE(readability-identifier-naming) - void onConnect(connect_handler_t cb) { this->on_connect_ = std::move(cb); } + void onConnect(connect_handler_t &&cb) { this->on_connect_ = std::move(cb); } void try_send_nodefer(const char *message, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator); From 4dc6b12ec51d9e8610ca5be0fc77ebf9c3f39da3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Feb 2026 17:56:43 -0600 Subject: [PATCH 02/12] [api] Pass std::function by rvalue reference in state subscriptions (#14261) --- esphome/components/api/api_server.cpp | 20 ++++++++++---------- esphome/components/api/api_server.h | 20 ++++++++++---------- esphome/components/api/user_services.h | 2 +- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 5b096788f5..0352d7347b 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -433,8 +433,8 @@ void APIServer::handle_action_response(uint32_t call_id, bool success, StringRef #ifdef USE_API_HOMEASSISTANT_STATES // Helper to add subscription (reduces duplication) -void APIServer::add_state_subscription_(const char *entity_id, const char *attribute, std::function f, - bool once) { +void APIServer::add_state_subscription_(const char *entity_id, const char *attribute, + std::function &&f, bool once) { this->state_subs_.push_back(HomeAssistantStateSubscription{ .entity_id = entity_id, .attribute = attribute, .callback = std::move(f), .once = once, // entity_id_dynamic_storage and attribute_dynamic_storage remain nullptr (no heap allocation) @@ -443,7 +443,7 @@ void APIServer::add_state_subscription_(const char *entity_id, const char *attri // Helper to add subscription with heap-allocated strings (reduces duplication) void APIServer::add_state_subscription_(std::string entity_id, optional attribute, - std::function f, bool once) { + std::function &&f, bool once) { HomeAssistantStateSubscription sub; // Allocate heap storage for the strings sub.entity_id_dynamic_storage = std::make_unique(std::move(entity_id)); @@ -463,29 +463,29 @@ void APIServer::add_state_subscription_(std::string entity_id, optional f) { + std::function &&f) { this->add_state_subscription_(entity_id, attribute, std::move(f), false); } void APIServer::get_home_assistant_state(const char *entity_id, const char *attribute, - std::function f) { + std::function &&f) { this->add_state_subscription_(entity_id, attribute, std::move(f), true); } // std::string overload with StringRef callback (zero-allocation callback) void APIServer::subscribe_home_assistant_state(std::string entity_id, optional attribute, - std::function f) { + std::function &&f) { this->add_state_subscription_(std::move(entity_id), std::move(attribute), std::move(f), false); } void APIServer::get_home_assistant_state(std::string entity_id, optional attribute, - std::function f) { + std::function &&f) { this->add_state_subscription_(std::move(entity_id), std::move(attribute), std::move(f), true); } // Legacy helper: wraps std::string callback and delegates to StringRef version void APIServer::add_state_subscription_(std::string entity_id, optional attribute, - std::function f, bool once) { + std::function &&f, bool once) { // Wrap callback to convert StringRef -> std::string, then delegate this->add_state_subscription_(std::move(entity_id), std::move(attribute), std::function([f = std::move(f)](StringRef state) { f(state.str()); }), @@ -494,12 +494,12 @@ void APIServer::add_state_subscription_(std::string entity_id, optional attribute, - std::function f) { + std::function &&f) { this->add_state_subscription_(std::move(entity_id), std::move(attribute), std::move(f), false); } void APIServer::get_home_assistant_state(std::string entity_id, optional attribute, - std::function f) { + std::function &&f) { this->add_state_subscription_(std::move(entity_id), std::move(attribute), std::move(f), true); } diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index fed29016b3..3abf68358c 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -201,20 +201,20 @@ class APIServer : public Component, }; // New const char* overload (for internal components - zero allocation) - void subscribe_home_assistant_state(const char *entity_id, const char *attribute, std::function f); - void get_home_assistant_state(const char *entity_id, const char *attribute, std::function f); + void subscribe_home_assistant_state(const char *entity_id, const char *attribute, std::function &&f); + void get_home_assistant_state(const char *entity_id, const char *attribute, std::function &&f); // std::string overload with StringRef callback (for custom_api_device.h with zero-allocation callback) void subscribe_home_assistant_state(std::string entity_id, optional attribute, - std::function f); + std::function &&f); void get_home_assistant_state(std::string entity_id, optional attribute, - std::function f); + std::function &&f); // Legacy std::string overload (for custom_api_device.h - converts StringRef to std::string for callback) void subscribe_home_assistant_state(std::string entity_id, optional attribute, - std::function f); + std::function &&f); void get_home_assistant_state(std::string entity_id, optional attribute, - std::function f); + std::function &&f); const std::vector &get_state_subs() const; #endif @@ -241,13 +241,13 @@ class APIServer : public Component, #endif // USE_API_NOISE #ifdef USE_API_HOMEASSISTANT_STATES // Helper methods to reduce code duplication - void add_state_subscription_(const char *entity_id, const char *attribute, std::function f, - bool once); - void add_state_subscription_(std::string entity_id, optional attribute, std::function f, + void add_state_subscription_(const char *entity_id, const char *attribute, std::function &&f, bool once); + void add_state_subscription_(std::string entity_id, optional attribute, + std::function &&f, bool once); // Legacy helper: wraps std::string callback and delegates to StringRef version void add_state_subscription_(std::string entity_id, optional attribute, - std::function f, bool once); + std::function &&f, bool once); #endif // USE_API_HOMEASSISTANT_STATES // No explicit close() needed — listen sockets have no active connections on // failure/shutdown. Destructor handles fd cleanup (close or abort per platform). diff --git a/esphome/components/api/user_services.h b/esphome/components/api/user_services.h index 0fc529108c..d1b8a6ef0d 100644 --- a/esphome/components/api/user_services.h +++ b/esphome/components/api/user_services.h @@ -230,7 +230,7 @@ template class APIRespondAction : public Action { void set_is_optional_mode(bool is_optional) { this->is_optional_mode_ = is_optional; } #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON - void set_data(std::function func) { + void set_data(std::function &&func) { this->json_builder_ = std::move(func); this->has_data_ = true; } From 08dc487b5b668f1dbf4ec6573aceca2de0d1fdd8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Feb 2026 18:08:07 -0600 Subject: [PATCH 03/12] [core] Pass std::function by rvalue reference in scheduler (#14260) --- esphome/core/scheduler.cpp | 15 ++++++++------- esphome/core/scheduler.h | 18 +++++++++--------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index e4e0751e10..674d70abcf 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -135,7 +135,7 @@ bool Scheduler::is_retry_cancelled_locked_(Component *component, NameType name_t // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, const char *static_name, uint32_t hash_or_id, uint32_t delay, - std::function func, bool is_retry, bool skip_cancel) { + std::function &&func, bool is_retry, bool skip_cancel) { if (delay == SCHEDULER_DONT_RUN) { // Still need to cancel existing timer if we have a name/id if (!skip_cancel) { @@ -216,17 +216,18 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type target->push_back(std::move(item)); } -void HOT Scheduler::set_timeout(Component *component, const char *name, uint32_t timeout, std::function func) { +void HOT Scheduler::set_timeout(Component *component, const char *name, uint32_t timeout, + std::function &&func) { this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::STATIC_STRING, name, 0, timeout, std::move(func)); } void HOT Scheduler::set_timeout(Component *component, const std::string &name, uint32_t timeout, - std::function func) { + std::function &&func) { this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), timeout, std::move(func)); } -void HOT Scheduler::set_timeout(Component *component, uint32_t id, uint32_t timeout, std::function func) { +void HOT Scheduler::set_timeout(Component *component, uint32_t id, uint32_t timeout, std::function &&func) { this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::NUMERIC_ID, nullptr, id, timeout, std::move(func)); } @@ -240,17 +241,17 @@ bool HOT Scheduler::cancel_timeout(Component *component, uint32_t id) { return this->cancel_item_(component, NameType::NUMERIC_ID, nullptr, id, SchedulerItem::TIMEOUT); } void HOT Scheduler::set_interval(Component *component, const std::string &name, uint32_t interval, - std::function func) { + std::function &&func) { this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), interval, std::move(func)); } void HOT Scheduler::set_interval(Component *component, const char *name, uint32_t interval, - std::function func) { + std::function &&func) { this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::STATIC_STRING, name, 0, interval, std::move(func)); } -void HOT Scheduler::set_interval(Component *component, uint32_t id, uint32_t interval, std::function func) { +void HOT Scheduler::set_interval(Component *component, uint32_t id, uint32_t interval, std::function &&func) { this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::NUMERIC_ID, nullptr, id, interval, std::move(func)); } diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index ed457b87f6..b6a8336606 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -33,7 +33,7 @@ class Scheduler { // std::string overload - deprecated, use const char* or uint32_t instead // Remove before 2026.7.0 ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - void set_timeout(Component *component, const std::string &name, uint32_t timeout, std::function func); + void set_timeout(Component *component, const std::string &name, uint32_t timeout, std::function &&func); /** Set a timeout with a const char* name. * @@ -43,11 +43,11 @@ class Scheduler { * - A static const char* variable * - A pointer with lifetime >= the scheduled task */ - void set_timeout(Component *component, const char *name, uint32_t timeout, std::function func); + void set_timeout(Component *component, const char *name, uint32_t timeout, std::function &&func); /// Set a timeout with a numeric ID (zero heap allocation) - void set_timeout(Component *component, uint32_t id, uint32_t timeout, std::function func); + void set_timeout(Component *component, uint32_t id, uint32_t timeout, std::function &&func); /// Set a timeout with an internal scheduler ID (separate namespace from component NUMERIC_ID) - void set_timeout(Component *component, InternalSchedulerID id, uint32_t timeout, std::function func) { + void set_timeout(Component *component, InternalSchedulerID id, uint32_t timeout, std::function &&func) { this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::NUMERIC_ID_INTERNAL, nullptr, static_cast(id), timeout, std::move(func)); } @@ -62,7 +62,7 @@ class Scheduler { } ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") - void set_interval(Component *component, const std::string &name, uint32_t interval, std::function func); + void set_interval(Component *component, const std::string &name, uint32_t interval, std::function &&func); /** Set an interval with a const char* name. * @@ -72,11 +72,11 @@ class Scheduler { * - A static const char* variable * - A pointer with lifetime >= the scheduled task */ - void set_interval(Component *component, const char *name, uint32_t interval, std::function func); + void set_interval(Component *component, const char *name, uint32_t interval, std::function &&func); /// Set an interval with a numeric ID (zero heap allocation) - void set_interval(Component *component, uint32_t id, uint32_t interval, std::function func); + void set_interval(Component *component, uint32_t id, uint32_t interval, std::function &&func); /// Set an interval with an internal scheduler ID (separate namespace from component NUMERIC_ID) - void set_interval(Component *component, InternalSchedulerID id, uint32_t interval, std::function func) { + void set_interval(Component *component, InternalSchedulerID id, uint32_t interval, std::function &&func) { this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::NUMERIC_ID_INTERNAL, nullptr, static_cast(id), interval, std::move(func)); } @@ -255,7 +255,7 @@ class Scheduler { // Common implementation for both timeout and interval // name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id void set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, const char *static_name, - uint32_t hash_or_id, uint32_t delay, std::function func, bool is_retry = false, + uint32_t hash_or_id, uint32_t delay, std::function &&func, bool is_retry = false, bool skip_cancel = false); // Common implementation for retry - Remove before 2026.8.0 From 2ff876c629a3181e1907fc52594d1257f5165139 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Feb 2026 18:18:44 -0600 Subject: [PATCH 04/12] [core] Use custom deleter for SchedulerItem unique_ptr to prevent destructor inlining (#14258) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/core/scheduler.cpp | 22 ++++++++++------- esphome/core/scheduler.h | 48 ++++++++++++++++++++++++-------------- 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 674d70abcf..d9c66b2000 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -33,6 +33,11 @@ static constexpr uint32_t HALF_MAX_UINT32 = std::numeric_limits::max() // max delay to start an interval sequence static constexpr uint32_t MAX_INTERVAL_DELAY = 5000; +// Prevent inlining of SchedulerItem deletion. On BK7231N (Thumb-1), GCC inlines +// ~unique_ptr (~30 bytes each) at every destruction site. Defining +// the deleter in the .cpp file ensures a single copy of the destructor + operator delete. +void Scheduler::SchedulerItemDeleter::operator()(SchedulerItem *ptr) const noexcept { delete ptr; } + #if defined(ESPHOME_LOG_HAS_VERBOSE) || defined(ESPHOME_DEBUG_SCHEDULER) // Helper struct for formatting scheduler item names consistently in logs // Uses a stack buffer to avoid heap allocation @@ -468,7 +473,7 @@ void HOT Scheduler::call(uint32_t now) { if (now_64 - last_print > 2000) { last_print = now_64; - std::vector> old_items; + std::vector old_items; #ifdef ESPHOME_THREAD_MULTI_ATOMICS const auto last_dbg = this->last_millis_.load(std::memory_order_relaxed); const auto major_dbg = this->millis_major_.load(std::memory_order_relaxed); @@ -481,7 +486,7 @@ void HOT Scheduler::call(uint32_t now) { // Cleanup before debug output this->cleanup_(); while (!this->items_.empty()) { - std::unique_ptr item; + SchedulerItemPtr item; { LockGuard guard{this->lock_}; item = this->pop_raw_locked_(); @@ -642,7 +647,7 @@ size_t HOT Scheduler::cleanup_() { } return this->items_.size(); } -std::unique_ptr HOT Scheduler::pop_raw_locked_() { +Scheduler::SchedulerItemPtr HOT Scheduler::pop_raw_locked_() { std::pop_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); // Move the item out before popping - this is the item that was at the front of the heap @@ -865,8 +870,7 @@ uint64_t Scheduler::millis_64_(uint32_t now) { #endif } -bool HOT Scheduler::SchedulerItem::cmp(const std::unique_ptr &a, - const std::unique_ptr &b) { +bool HOT Scheduler::SchedulerItem::cmp(const SchedulerItemPtr &a, const SchedulerItemPtr &b) { // High bits are almost always equal (change only on 32-bit rollover ~49 days) // Optimize for common case: check low bits first when high bits are equal return (a->next_execution_high_ == b->next_execution_high_) ? (a->next_execution_low_ > b->next_execution_low_) @@ -877,7 +881,7 @@ bool HOT Scheduler::SchedulerItem::cmp(const std::unique_ptr &a, // IMPORTANT: Caller must hold the scheduler lock before calling this function. // This protects scheduler_item_pool_ from concurrent access by other threads // that may be acquiring items from the pool in set_timer_common_(). -void Scheduler::recycle_item_main_loop_(std::unique_ptr item) { +void Scheduler::recycle_item_main_loop_(SchedulerItemPtr item) { if (!item) return; @@ -920,8 +924,8 @@ void Scheduler::debug_log_timer_(const SchedulerItem *item, NameType name_type, // Helper to get or create a scheduler item from the pool // IMPORTANT: Caller must hold the scheduler lock before calling this function. -std::unique_ptr Scheduler::get_item_from_pool_locked_() { - std::unique_ptr item; +Scheduler::SchedulerItemPtr Scheduler::get_item_from_pool_locked_() { + SchedulerItemPtr item; if (!this->scheduler_item_pool_.empty()) { item = std::move(this->scheduler_item_pool_.back()); this->scheduler_item_pool_.pop_back(); @@ -929,7 +933,7 @@ std::unique_ptr Scheduler::get_item_from_pool_locked_( ESP_LOGD(TAG, "Reused item from pool (pool size now: %zu)", this->scheduler_item_pool_.size()); #endif } else { - item = make_unique(); + item = SchedulerItemPtr(new SchedulerItem()); #ifdef ESPHOME_DEBUG_SCHEDULER ESP_LOGD(TAG, "Allocated new item (pool empty)"); #endif diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index b6a8336606..840ee7159a 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -142,6 +142,19 @@ class Scheduler { }; protected: + struct SchedulerItem; + + // Custom deleter for SchedulerItem unique_ptr that prevents the compiler from + // inlining the destructor at every destruction site. On BK7231N (Thumb-1), GCC + // inlines ~unique_ptr (~30 bytes: null check + ~std::function + + // operator delete) at every destruction site, while ESP32/ESP8266/RTL8720CF outline + // it into a single helper. This noinline deleter ensures only one copy exists. + // operator() is defined in scheduler.cpp to prevent inlining. + struct SchedulerItemDeleter { + void operator()(SchedulerItem *ptr) const noexcept; + }; + using SchedulerItemPtr = std::unique_ptr; + struct SchedulerItem { // Ordered by size to minimize padding Component *component; @@ -233,7 +246,7 @@ class Scheduler { name_type_ = type; } - static bool cmp(const std::unique_ptr &a, const std::unique_ptr &b); + static bool cmp(const SchedulerItemPtr &a, const SchedulerItemPtr &b); // Note: We use 48 bits total (32 + 16), stored in a 64-bit value for API compatibility. // The upper 16 bits of the 64-bit value are always zero, which is fine since @@ -276,10 +289,10 @@ class Scheduler { size_t cleanup_(); // Remove and return the front item from the heap // IMPORTANT: Caller must hold the scheduler lock before calling this function. - std::unique_ptr pop_raw_locked_(); + SchedulerItemPtr pop_raw_locked_(); // Get or create a scheduler item from the pool // IMPORTANT: Caller must hold the scheduler lock before calling this function. - std::unique_ptr get_item_from_pool_locked_(); + SchedulerItemPtr get_item_from_pool_locked_(); private: // Helper to cancel items - must be called with lock held @@ -303,9 +316,9 @@ class Scheduler { // Helper function to check if item matches criteria for cancellation // name_type determines matching: STATIC_STRING uses static_name, others use hash_or_id // IMPORTANT: Must be called with scheduler lock held - inline bool HOT matches_item_locked_(const std::unique_ptr &item, Component *component, - NameType name_type, const char *static_name, uint32_t hash_or_id, - SchedulerItem::Type type, bool match_retry, bool skip_removed = true) const { + inline bool HOT matches_item_locked_(const SchedulerItemPtr &item, Component *component, NameType name_type, + const char *static_name, uint32_t hash_or_id, SchedulerItem::Type type, + bool match_retry, bool skip_removed = true) const { // THREAD SAFETY: Check for nullptr first to prevent LoadProhibited crashes. On multi-threaded // platforms, items can be moved out of defer_queue_ during processing, leaving nullptr entries. // PR #11305 added nullptr checks in callers (mark_matching_items_removed_locked_()), but this check @@ -340,7 +353,7 @@ class Scheduler { // IMPORTANT: Only call from main loop context! Recycling clears the callback, // so calling from another thread while the callback is executing causes use-after-free. // IMPORTANT: Caller must hold the scheduler lock before calling this function. - void recycle_item_main_loop_(std::unique_ptr item); + void recycle_item_main_loop_(SchedulerItemPtr item); // Helper to perform full cleanup when too many items are cancelled void full_cleanup_removed_items_(); @@ -396,7 +409,7 @@ class Scheduler { // Merge lock acquisitions: instead of separate locks for move-out and recycle (2N+1 total), // recycle each item after re-acquiring the lock for the next iteration (N+1 total). // The lock is held across: recycle → loop condition → move-out, then released for execution. - std::unique_ptr item; + SchedulerItemPtr item; this->lock_.lock(); while (this->defer_queue_front_ < defer_queue_end) { @@ -496,9 +509,10 @@ class Scheduler { // name_type determines matching: STATIC_STRING uses static_name, others use hash_or_id // Returns the number of items marked for removal // IMPORTANT: Must be called with scheduler lock held - __attribute__((noinline)) size_t mark_matching_items_removed_locked_( - std::vector> &container, Component *component, NameType name_type, - const char *static_name, uint32_t hash_or_id, SchedulerItem::Type type, bool match_retry) { + __attribute__((noinline)) size_t mark_matching_items_removed_locked_(std::vector &container, + Component *component, NameType name_type, + const char *static_name, uint32_t hash_or_id, + SchedulerItem::Type type, bool match_retry) { size_t count = 0; for (auto &item : container) { // Skip nullptr items (can happen in defer_queue_ when items are being processed) @@ -514,15 +528,15 @@ class Scheduler { } Mutex lock_; - std::vector> items_; - std::vector> to_add_; + std::vector items_; + std::vector to_add_; #ifndef ESPHOME_THREAD_SINGLE // Single-core platforms don't need the defer queue and save ~32 bytes of RAM // Using std::vector instead of std::deque avoids 512-byte chunked allocations // Index tracking avoids O(n) erase() calls when draining the queue each loop - std::vector> defer_queue_; // FIFO queue for defer() calls - size_t defer_queue_front_{0}; // Index of first valid item in defer_queue_ (tracks consumed items) -#endif /* ESPHOME_THREAD_SINGLE */ + std::vector defer_queue_; // FIFO queue for defer() calls + size_t defer_queue_front_{0}; // Index of first valid item in defer_queue_ (tracks consumed items) +#endif /* ESPHOME_THREAD_SINGLE */ uint32_t to_remove_{0}; // Memory pool for recycling SchedulerItem objects to reduce heap churn. @@ -533,7 +547,7 @@ class Scheduler { // - The pool significantly reduces heap fragmentation which is critical because heap allocation/deallocation // can stall the entire system, causing timing issues and dropped events for any components that need // to synchronize between tasks (see https://github.com/esphome/backlog/issues/52) - std::vector> scheduler_item_pool_; + std::vector scheduler_item_pool_; #ifdef ESPHOME_THREAD_MULTI_ATOMICS /* From 3460a8c9225f504fdfdd9166d603fe2c3fb41474 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Feb 2026 18:44:50 -0600 Subject: [PATCH 05/12] [dlms_meter/kamstrup_kmp] Replace powf with pow10_int (#14125) Co-authored-by: Claude Opus 4.6 --- esphome/components/dlms_meter/dlms_meter.cpp | 4 +--- esphome/components/kamstrup_kmp/kamstrup_kmp.cpp | 6 +++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/esphome/components/dlms_meter/dlms_meter.cpp b/esphome/components/dlms_meter/dlms_meter.cpp index 11d05b3a08..bd2150e8dd 100644 --- a/esphome/components/dlms_meter/dlms_meter.cpp +++ b/esphome/components/dlms_meter/dlms_meter.cpp @@ -1,7 +1,5 @@ #include "dlms_meter.h" -#include - #if defined(USE_ESP8266_FRAMEWORK_ARDUINO) #include #elif defined(USE_ESP32) @@ -410,7 +408,7 @@ void DlmsMeterComponent::decode_obis_(uint8_t *plaintext, uint16_t message_lengt if (current_position + 1 < message_length) { int8_t scaler = static_cast(plaintext[current_position + 1]); if (scaler != 0) { - value *= powf(10.0f, scaler); + value *= pow10_int(scaler); } } diff --git a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp index 534939f9af..00c65a1937 100644 --- a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp +++ b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp @@ -222,11 +222,11 @@ void KamstrupKMPComponent::parse_command_message_(uint16_t command, const uint8_ } // Calculate exponent - float exponent = msg[6] & 0x3F; + int8_t exp_val = msg[6] & 0x3F; if (msg[6] & 0x40) { - exponent = -exponent; + exp_val = -exp_val; } - exponent = powf(10, exponent); + float exponent = pow10_int(exp_val); if (msg[6] & 0x80) { exponent = -exponent; } From 905e81330ee3af27a82f2f6cf3675497bd6fcc70 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:28:19 +1300 Subject: [PATCH 06/12] Don't get stuck forever on a failed component can_proceed (#14267) --- esphome/core/application.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 1cb7dc0075..6a7683a987 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -134,7 +134,7 @@ void Application::setup() { this->after_loop_tasks_(); this->app_state_ = new_app_state; yield(); - } while (!component->can_proceed()); + } while (!component->can_proceed() && !component->is_failed()); } ESP_LOGI(TAG, "setup() finished successfully!"); From 1dac501b049ffea5a1767df5dfbd429d768c0af2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Feb 2026 21:39:51 -0600 Subject: [PATCH 07/12] [light] Add additional light effect test cases (#14266) --- tests/components/light/common.yaml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 55525fc67f..e5fab62a79 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -71,6 +71,32 @@ esphome: - light.control: id: test_monochromatic_light state: on + # Test static effect name resolution at codegen time + - light.turn_on: + id: test_monochromatic_light + effect: Strobe + - light.turn_on: + id: test_monochromatic_light + effect: none + # Test resolving a different effect on the same light + - light.control: + id: test_monochromatic_light + effect: My Flicker + # Test effect: None (capitalized) + - light.control: + id: test_monochromatic_light + effect: None + # Test effect lambda with no args (on_boot has empty Ts...) + - light.turn_on: + id: test_monochromatic_light + effect: !lambda 'return "Strobe";' + # Test effect lambda with non-empty args (repeat passes uint32_t iteration) + - repeat: + count: 3 + then: + - light.turn_on: + id: test_monochromatic_light + effect: !lambda 'return iteration > 1 ? "Strobe" : "none";' - light.dim_relative: id: test_monochromatic_light relative_brightness: 5% From 2e705a919fc8a4e9990e4fc5870e4677a05f6e46 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:26:00 -0500 Subject: [PATCH 08/12] [pid] Fix deadband threshold conversion for Fahrenheit (#14268) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- esphome/components/pid/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/pid/climate.py b/esphome/components/pid/climate.py index 5919d2cac8..5fa3166f9d 100644 --- a/esphome/components/pid/climate.py +++ b/esphome/components/pid/climate.py @@ -50,8 +50,8 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_HEAT_OUTPUT): cv.use_id(output.FloatOutput), cv.Optional(CONF_DEADBAND_PARAMETERS): cv.Schema( { - cv.Required(CONF_THRESHOLD_HIGH): cv.temperature, - cv.Required(CONF_THRESHOLD_LOW): cv.temperature, + cv.Required(CONF_THRESHOLD_HIGH): cv.temperature_delta, + cv.Required(CONF_THRESHOLD_LOW): cv.temperature_delta, cv.Optional(CONF_KP_MULTIPLIER, default=0.1): cv.float_, cv.Optional(CONF_KI_MULTIPLIER, default=0.0): cv.float_, cv.Optional(CONF_KD_MULTIPLIER, default=0.0): cv.float_, From b134c4679ca5f609633a2b97681a41867e62c12d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Feb 2026 22:33:57 -0600 Subject: [PATCH 09/12] [light] Replace std::lerp with lightweight lerp_fast in LightColorValues::lerp (#14238) --- .../components/light/light_color_values.cpp | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/esphome/components/light/light_color_values.cpp b/esphome/components/light/light_color_values.cpp index 2f22bb3c68..e54cc0a12a 100644 --- a/esphome/components/light/light_color_values.cpp +++ b/esphome/components/light/light_color_values.cpp @@ -1,27 +1,30 @@ #include "light_color_values.h" -#include - namespace esphome::light { +// Lightweight lerp: a + t * (b - a). +// Avoids std::lerp's NaN/infinity handling which Clang doesn't optimize out, +// adding ~200 bytes per call. Safe because all values are finite floats. +static float __attribute__((noinline)) lerp_fast(float a, float b, float t) { return a + t * (b - a); } + LightColorValues LightColorValues::lerp(const LightColorValues &start, const LightColorValues &end, float completion) { // Directly interpolate the raw values to avoid getter/setter overhead. // This is safe because: - // - All LightColorValues have their values clamped when set via the setters - // - std::lerp guarantees output is in the same range as inputs + // - All LightColorValues except color_temperature_ have their values clamped when set via the setters + // - lerp_fast output stays in range when inputs are in range and 0 <= completion <= 1 // - Therefore the output doesn't need clamping, so we can skip the setters LightColorValues v; v.color_mode_ = end.color_mode_; - v.state_ = std::lerp(start.state_, end.state_, completion); - v.brightness_ = std::lerp(start.brightness_, end.brightness_, completion); - v.color_brightness_ = std::lerp(start.color_brightness_, end.color_brightness_, completion); - v.red_ = std::lerp(start.red_, end.red_, completion); - v.green_ = std::lerp(start.green_, end.green_, completion); - v.blue_ = std::lerp(start.blue_, end.blue_, completion); - v.white_ = std::lerp(start.white_, end.white_, completion); - v.color_temperature_ = std::lerp(start.color_temperature_, end.color_temperature_, completion); - v.cold_white_ = std::lerp(start.cold_white_, end.cold_white_, completion); - v.warm_white_ = std::lerp(start.warm_white_, end.warm_white_, completion); + v.state_ = lerp_fast(start.state_, end.state_, completion); + v.brightness_ = lerp_fast(start.brightness_, end.brightness_, completion); + v.color_brightness_ = lerp_fast(start.color_brightness_, end.color_brightness_, completion); + v.red_ = lerp_fast(start.red_, end.red_, completion); + v.green_ = lerp_fast(start.green_, end.green_, completion); + v.blue_ = lerp_fast(start.blue_, end.blue_, completion); + v.white_ = lerp_fast(start.white_, end.white_, completion); + v.color_temperature_ = lerp_fast(start.color_temperature_, end.color_temperature_, completion); + v.cold_white_ = lerp_fast(start.cold_white_, end.cold_white_, completion); + v.warm_white_ = lerp_fast(start.warm_white_, end.warm_white_, completion); return v; } From bb05cfb7119102b877ad583d9d9091ff57663a44 Mon Sep 17 00:00:00 2001 From: Big Mike Date: Wed, 25 Feb 2026 06:34:58 -0600 Subject: [PATCH 10/12] [sensirion_common] Move sen5x's sensirion_convert_to_string_in_place() function to sensirion_common (#14269) --- esphome/components/sen5x/sen5x.cpp | 9 --------- esphome/components/sensirion_common/i2c_sensirion.h | 11 +++++++++++ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/esphome/components/sen5x/sen5x.cpp b/esphome/components/sen5x/sen5x.cpp index fc4932d867..09dda8bca4 100644 --- a/esphome/components/sen5x/sen5x.cpp +++ b/esphome/components/sen5x/sen5x.cpp @@ -56,15 +56,6 @@ static const LogString *rht_accel_mode_to_string(RhtAccelerationMode mode) { } } -// This function performs an in-place conversion of the provided buffer -// from uint16_t values to big endianness -static inline const char *sensirion_convert_to_string_in_place(uint16_t *array, size_t length) { - for (size_t i = 0; i < length; i++) { - array[i] = convert_big_endian(array[i]); - } - return reinterpret_cast(array); -} - void SEN5XComponent::setup() { // the sensor needs 1000 ms to enter the idle state this->set_timeout(1000, [this]() { diff --git a/esphome/components/sensirion_common/i2c_sensirion.h b/esphome/components/sensirion_common/i2c_sensirion.h index f3eb3761f6..3c2c14ccb8 100644 --- a/esphome/components/sensirion_common/i2c_sensirion.h +++ b/esphome/components/sensirion_common/i2c_sensirion.h @@ -21,6 +21,17 @@ class SensirionI2CDevice : public i2c::I2CDevice { public: enum CommandLen : uint8_t { ADDR_8_BIT = 1, ADDR_16_BIT = 2 }; + /** + * This function performs an in-place conversion of the provided buffer + * from uint16_t values to big endianness. Useful for Sensirion strings in SEN5X and SEN6X + */ + static inline const char *sensirion_convert_to_string_in_place(uint16_t *array, size_t length) { + for (size_t i = 0; i < length; i++) { + array[i] = convert_big_endian(array[i]); + } + return reinterpret_cast(array); + } + /** Read data words from I2C device. * handles CRC check used by Sensirion sensors * @param data pointer to raw result From 228874a52b268070199dc37f4622cca1361e729b Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:45:50 -0500 Subject: [PATCH 11/12] [config] Improve dimensions validation and fix online_image resize aspect ratio (#14274) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- esphome/components/runtime_image/runtime_image.cpp | 9 +++++++++ esphome/config_validation.py | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index 603ea76f01..5a4a2ea7d6 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -2,6 +2,7 @@ #include "image_decoder.h" #include "esphome/core/log.h" #include "esphome/core/helpers.h" +#include #include #ifdef USE_RUNTIME_IMAGE_BMP @@ -43,6 +44,14 @@ int RuntimeImage::resize(int width, int height) { int target_width = this->fixed_width_ ? this->fixed_width_ : width; int target_height = this->fixed_height_ ? this->fixed_height_ : height; + // When both fixed dimensions are set, scale uniformly to preserve aspect ratio + if (this->fixed_width_ && this->fixed_height_ && width > 0 && height > 0) { + float scale = + std::min(static_cast(this->fixed_width_) / width, static_cast(this->fixed_height_) / height); + target_width = static_cast(width * scale); + target_height = static_cast(height * scale); + } + size_t result = this->resize_buffer_(target_width, target_height); if (result > 0 && this->progressive_display_) { // Update display dimensions for progressive display diff --git a/esphome/config_validation.py b/esphome/config_validation.py index ef1c66a20e..3b0e4da298 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1640,7 +1640,10 @@ def dimensions(value): if width <= 0 or height <= 0: raise Invalid("Width and height must at least be 1") return [width, height] - value = string(value) + if not isinstance(value, str): + raise Invalid( + "Dimensions must be a string (WIDTHxHEIGHT). Got a number instead, try quoting the value." + ) match = re.match(r"\s*([0-9]+)\s*[xX]\s*([0-9]+)\s*", value) if not match: raise Invalid( From 1beeb9ab5c34b1b28ba3c8dc8e78cc12be511dc2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Feb 2026 09:54:32 -0600 Subject: [PATCH 12/12] [web_server] Fix uptime display overflow after ~24.8 days (#13739) --- esphome/components/web_server/web_server.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 682008c40e..e2f9c21331 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -385,6 +385,7 @@ json::SerializationBuffer<> WebServer::get_config_json() { #endif root[ESPHOME_F("log")] = this->expose_log_; root[ESPHOME_F("lang")] = "en"; + root[ESPHOME_F("uptime")] = static_cast(App.scheduler.millis_64() / 1000); return builder.serialize(); } @@ -411,7 +412,12 @@ void WebServer::setup() { // doesn't need defer functionality - if the queue is full, the client JS knows it's alive because it's clearly // getting a lot of events - this->set_interval(10000, [this]() { this->events_.try_send_nodefer("", "ping", millis(), 30000); }); + this->set_interval(10000, [this]() { + char buf[32]; + auto uptime = static_cast(App.scheduler.millis_64() / 1000); + buf_append_printf(buf, sizeof(buf), 0, "{\"uptime\":%u}", uptime); + this->events_.try_send_nodefer(buf, "ping", millis(), 30000); + }); } void WebServer::loop() { this->events_.loop(); }