From 2001b912807e72df923eb7c6eccb15c41c6f0e65 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 11 Apr 2026 08:57:39 +0000 Subject: [PATCH 01/10] Bump resvg-py from 0.3.0 to 0.3.1 (#15640) 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 12e7658e43..361a628919 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 pillow==12.2.0 -resvg-py==0.3.0 +resvg-py==0.3.1 freetype-py==2.5.1 jinja2==3.1.6 bleak==2.1.1 From c2af4874f952d3685c33f42e4bda7156497d2075 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 11 Apr 2026 08:58:20 +0000 Subject: [PATCH 02/10] Bump aioesphomeapi from 44.13.2 to 44.13.3 (#15641) 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 361a628919..1466eccc9b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.19 esptool==5.2.0 click==8.3.2 esphome-dashboard==20260408.1 -aioesphomeapi==44.13.2 +aioesphomeapi==44.13.3 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 59d0f9dcbd78cf62e828b577175eb54a00f272f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 23:44:35 -1000 Subject: [PATCH 03/10] [status_led] Disable loop when idle and switch blink timing to deadline-based --- esphome/components/status_led/status_led.cpp | 33 +++++++++++--- esphome/components/status_led/status_led.h | 2 + esphome/core/application.cpp | 45 +++++++++----------- 3 files changed, 48 insertions(+), 32 deletions(-) diff --git a/esphome/components/status_led/status_led.cpp b/esphome/components/status_led/status_led.cpp index a792110eeb..87035f6284 100644 --- a/esphome/components/status_led/status_led.cpp +++ b/esphome/components/status_led/status_led.cpp @@ -7,6 +7,11 @@ namespace status_led { static const char *const TAG = "status_led"; +static constexpr uint32_t ERROR_ON_MS = 150; +static constexpr uint32_t ERROR_OFF_MS = 100; +static constexpr uint32_t WARNING_ON_MS = 250; +static constexpr uint32_t WARNING_OFF_MS = 1250; + StatusLED *global_status_led = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) StatusLED::StatusLED(GPIOPin *pin) : pin_(pin) { global_status_led = this; } @@ -19,13 +24,29 @@ void StatusLED::dump_config() { LOG_PIN(" Pin: ", this->pin_); } void StatusLED::loop() { - if ((App.get_app_state() & STATUS_LED_ERROR) != 0u) { - this->pin_->digital_write(millis() % 250u < 150u); - } else if ((App.get_app_state() & STATUS_LED_WARNING) != 0u) { - this->pin_->digital_write(millis() % 1500u < 250u); - } else { - this->pin_->digital_write(false); + const uint32_t app_state = App.get_app_state(); + const bool error = (app_state & STATUS_LED_ERROR) != 0u; + const bool warning = (app_state & STATUS_LED_WARNING) != 0u; + if (!error && !warning) { + if (this->led_on_) { + this->led_on_ = false; + this->pin_->digital_write(false); + } + this->disable_loop(); + return; } + const uint32_t now = App.get_loop_component_start_time(); + if ((int32_t) (now - this->next_toggle_) < 0) + return; + this->led_on_ = !this->led_on_; + uint32_t delay; + if (error) { + delay = this->led_on_ ? ERROR_ON_MS : ERROR_OFF_MS; + } else { + delay = this->led_on_ ? WARNING_ON_MS : WARNING_OFF_MS; + } + this->next_toggle_ = now + delay; + this->pin_->digital_write(this->led_on_); } float StatusLED::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/status_led/status_led.h b/esphome/components/status_led/status_led.h index a4b5db93d7..16b6dcf736 100644 --- a/esphome/components/status_led/status_led.h +++ b/esphome/components/status_led/status_led.h @@ -17,6 +17,8 @@ class StatusLED : public Component { protected: GPIOPin *pin_; + uint32_t next_toggle_{0}; + bool led_on_{false}; }; extern StatusLED *global_status_led; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index cd75859880..d83b6bf2be 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -206,7 +206,14 @@ void HOT Application::feed_wdt(uint32_t time) { last_feed = now; #ifdef USE_STATUS_LED if (status_led::global_status_led != nullptr) { - status_led::global_status_led->call(); + auto *sl = status_led::global_status_led; + // If an error or warning bit is set while status_led's loop is disabled, re-enable it + // so it starts blinking again. disable_loop() is called from loop() itself when idle. + if ((sl->get_component_state() & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE && + (this->app_state_ & STATUS_LED_MASK) != 0) { + sl->enable_loop(); + } + sl->call(); } #endif } @@ -449,32 +456,12 @@ void Application::enable_pending_loops_() { } } -#ifdef USE_LWIP_FAST_SELECT -bool Application::register_socket(struct lwip_sock *sock) { - // It modifies monitored_sockets_ without locking — must only be called from the main loop. - if (sock == nullptr) - return false; - esphome_lwip_hook_socket(sock); - this->monitored_sockets_.push_back(sock); - return true; -} +#if defined(USE_OTA) && defined(USE_LWIP_FAST_SELECT) +// C trampoline called from lwip_fast_select.c when the listener filter matches. +extern "C" void esphome_wake_ota_component_any_context() { App.wake_ota_component_any_context(); } +#endif -void Application::unregister_socket(struct lwip_sock *sock) { - // It modifies monitored_sockets_ without locking — must only be called from the main loop. - for (size_t i = 0; i < this->monitored_sockets_.size(); i++) { - if (this->monitored_sockets_[i] != sock) - continue; - - // Swap with last element and pop - O(1) removal since order doesn't matter. - // No need to unhook the netconn callback — all LwIP sockets share the same - // static event_callback, and the socket will be closed by the caller. - if (i < this->monitored_sockets_.size() - 1) - this->monitored_sockets_[i] = this->monitored_sockets_.back(); - this->monitored_sockets_.pop_back(); - return; - } -} -#elif defined(USE_HOST) +#ifdef USE_HOST bool Application::register_socket_fd(int fd) { // WARNING: This function is NOT thread-safe and must only be called from the main loop // It modifies socket_fds_ and related variables without locking @@ -554,6 +541,12 @@ void Application::yield_with_select_(uint32_t delay_ms) { // ret > 0: socket(s) have data ready - normal and expected // ret == 0: timeout occurred - normal and expected if (ret >= 0) [[likely]] { +#ifdef USE_OTA + // No-op today — host has no esphome OTA platform, so ota_wake_component_ is null. + if (ret > 0) { + this->wake_ota_component_any_context(); + } +#endif // Yield if zero timeout since select(0) only polls without yielding if (delay_ms == 0) [[unlikely]] { yield(); From af7fdc98879778e4f81c36a216c8711e75d4fa0d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 23:49:56 -1000 Subject: [PATCH 04/10] [status_led] Simplify loop body, rely on disable_loop for idle optimization --- esphome/components/status_led/status_led.cpp | 30 ++++++-------------- esphome/components/status_led/status_led.h | 2 -- 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/esphome/components/status_led/status_led.cpp b/esphome/components/status_led/status_led.cpp index 87035f6284..90216d244d 100644 --- a/esphome/components/status_led/status_led.cpp +++ b/esphome/components/status_led/status_led.cpp @@ -7,10 +7,10 @@ namespace status_led { static const char *const TAG = "status_led"; +static constexpr uint32_t ERROR_PERIOD_MS = 250; static constexpr uint32_t ERROR_ON_MS = 150; -static constexpr uint32_t ERROR_OFF_MS = 100; +static constexpr uint32_t WARNING_PERIOD_MS = 1500; static constexpr uint32_t WARNING_ON_MS = 250; -static constexpr uint32_t WARNING_OFF_MS = 1250; StatusLED *global_status_led = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -25,28 +25,14 @@ void StatusLED::dump_config() { } void StatusLED::loop() { const uint32_t app_state = App.get_app_state(); - const bool error = (app_state & STATUS_LED_ERROR) != 0u; - const bool warning = (app_state & STATUS_LED_WARNING) != 0u; - if (!error && !warning) { - if (this->led_on_) { - this->led_on_ = false; - this->pin_->digital_write(false); - } - this->disable_loop(); - return; - } - const uint32_t now = App.get_loop_component_start_time(); - if ((int32_t) (now - this->next_toggle_) < 0) - return; - this->led_on_ = !this->led_on_; - uint32_t delay; - if (error) { - delay = this->led_on_ ? ERROR_ON_MS : ERROR_OFF_MS; + if ((app_state & STATUS_LED_ERROR) != 0u) { + this->pin_->digital_write(App.get_loop_component_start_time() % ERROR_PERIOD_MS < ERROR_ON_MS); + } else if ((app_state & STATUS_LED_WARNING) != 0u) { + this->pin_->digital_write(App.get_loop_component_start_time() % WARNING_PERIOD_MS < WARNING_ON_MS); } else { - delay = this->led_on_ ? WARNING_ON_MS : WARNING_OFF_MS; + this->pin_->digital_write(false); + this->disable_loop(); } - this->next_toggle_ = now + delay; - this->pin_->digital_write(this->led_on_); } float StatusLED::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/status_led/status_led.h b/esphome/components/status_led/status_led.h index 16b6dcf736..a4b5db93d7 100644 --- a/esphome/components/status_led/status_led.h +++ b/esphome/components/status_led/status_led.h @@ -17,8 +17,6 @@ class StatusLED : public Component { protected: GPIOPin *pin_; - uint32_t next_toggle_{0}; - bool led_on_{false}; }; extern StatusLED *global_status_led; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) From c9a68baa69c417da7c40d1cb7fe24d60d821a46f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 23:58:36 -1000 Subject: [PATCH 05/10] [status_led] Remove unrelated changes from application.cpp --- esphome/core/application.cpp | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index d83b6bf2be..1766f35dd6 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -456,12 +456,32 @@ void Application::enable_pending_loops_() { } } -#if defined(USE_OTA) && defined(USE_LWIP_FAST_SELECT) -// C trampoline called from lwip_fast_select.c when the listener filter matches. -extern "C" void esphome_wake_ota_component_any_context() { App.wake_ota_component_any_context(); } -#endif +#ifdef USE_LWIP_FAST_SELECT +bool Application::register_socket(struct lwip_sock *sock) { + // It modifies monitored_sockets_ without locking — must only be called from the main loop. + if (sock == nullptr) + return false; + esphome_lwip_hook_socket(sock); + this->monitored_sockets_.push_back(sock); + return true; +} -#ifdef USE_HOST +void Application::unregister_socket(struct lwip_sock *sock) { + // It modifies monitored_sockets_ without locking — must only be called from the main loop. + for (size_t i = 0; i < this->monitored_sockets_.size(); i++) { + if (this->monitored_sockets_[i] != sock) + continue; + + // Swap with last element and pop - O(1) removal since order doesn't matter. + // No need to unhook the netconn callback — all LwIP sockets share the same + // static event_callback, and the socket will be closed by the caller. + if (i < this->monitored_sockets_.size() - 1) + this->monitored_sockets_[i] = this->monitored_sockets_.back(); + this->monitored_sockets_.pop_back(); + return; + } +} +#elif defined(USE_HOST) bool Application::register_socket_fd(int fd) { // WARNING: This function is NOT thread-safe and must only be called from the main loop // It modifies socket_fds_ and related variables without locking @@ -541,12 +561,6 @@ void Application::yield_with_select_(uint32_t delay_ms) { // ret > 0: socket(s) have data ready - normal and expected // ret == 0: timeout occurred - normal and expected if (ret >= 0) [[likely]] { -#ifdef USE_OTA - // No-op today — host has no esphome OTA platform, so ota_wake_component_ is null. - if (ret > 0) { - this->wake_ota_component_any_context(); - } -#endif // Yield if zero timeout since select(0) only polls without yielding if (delay_ms == 0) [[unlikely]] { yield(); From 0f80c16507904c9f3caa0abb7818dbe958e13f85 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 00:03:55 -1000 Subject: [PATCH 06/10] [status_led] Skip feed_wdt dispatch entirely when idle --- esphome/core/application.cpp | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 1766f35dd6..03091a4141 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -207,13 +207,16 @@ void HOT Application::feed_wdt(uint32_t time) { #ifdef USE_STATUS_LED if (status_led::global_status_led != nullptr) { auto *sl = status_led::global_status_led; - // If an error or warning bit is set while status_led's loop is disabled, re-enable it - // so it starts blinking again. disable_loop() is called from loop() itself when idle. - if ((sl->get_component_state() & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE && - (this->app_state_ & STATUS_LED_MASK) != 0) { - sl->enable_loop(); + if ((sl->get_component_state() & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE) { + // Loop was disabled by status_led itself when it went idle. Only re-enable (and + // dispatch) if an error or warning bit has been set since; otherwise skip entirely. + if ((this->app_state_ & STATUS_LED_MASK) != 0) { + sl->enable_loop(); + sl->call(); + } + } else { + sl->call(); } - sl->call(); } #endif } From fcd1aa5a40e3f01736ec7bc8ba2d23e4345458d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 00:05:57 -1000 Subject: [PATCH 07/10] [status_led] Hoist state read and dispatch loop() directly from feed_wdt --- esphome/core/application.cpp | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 03091a4141..4d1a48da4c 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -207,16 +207,17 @@ void HOT Application::feed_wdt(uint32_t time) { #ifdef USE_STATUS_LED if (status_led::global_status_led != nullptr) { auto *sl = status_led::global_status_led; - if ((sl->get_component_state() & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE) { - // Loop was disabled by status_led itself when it went idle. Only re-enable (and - // dispatch) if an error or warning bit has been set since; otherwise skip entirely. - if ((this->app_state_ & STATUS_LED_MASK) != 0) { - sl->enable_loop(); - sl->call(); - } - } else { - sl->call(); + uint8_t sl_state = sl->get_component_state() & COMPONENT_STATE_MASK; + if (sl_state == COMPONENT_STATE_LOOP) { + sl->loop(); + } else if (sl_state == COMPONENT_STATE_LOOP_DONE && (this->app_state_ & STATUS_LED_MASK) != 0) { + // status_led disabled its own loop when it went idle; an error or warning bit has + // been set since, so re-enable and dispatch it. Safe to call loop() directly: + // LOOP_DONE means setup has already run. + sl->enable_loop(); + sl->loop(); } + // CONSTRUCTION/SETUP/FAILED: not our job — App::setup() drives the lifecycle. } #endif } From f6311c38466025df93dfb2a6300f9b7a1435f71e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 00:07:22 -1000 Subject: [PATCH 08/10] [status_led] Use millis() to survive blocking waits via feed_wdt --- esphome/components/status_led/status_led.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/status_led/status_led.cpp b/esphome/components/status_led/status_led.cpp index 90216d244d..48762a7333 100644 --- a/esphome/components/status_led/status_led.cpp +++ b/esphome/components/status_led/status_led.cpp @@ -25,10 +25,14 @@ void StatusLED::dump_config() { } void StatusLED::loop() { const uint32_t app_state = App.get_app_state(); + // Use millis() rather than App.get_loop_component_start_time() because this loop is also + // dispatched from Application::feed_wdt() during long blocking operations, where the cached + // per-component timestamp doesn't advance and would freeze the blink pattern. + const uint32_t now = millis(); if ((app_state & STATUS_LED_ERROR) != 0u) { - this->pin_->digital_write(App.get_loop_component_start_time() % ERROR_PERIOD_MS < ERROR_ON_MS); + this->pin_->digital_write(now % ERROR_PERIOD_MS < ERROR_ON_MS); } else if ((app_state & STATUS_LED_WARNING) != 0u) { - this->pin_->digital_write(App.get_loop_component_start_time() % WARNING_PERIOD_MS < WARNING_ON_MS); + this->pin_->digital_write(now % WARNING_PERIOD_MS < WARNING_ON_MS); } else { this->pin_->digital_write(false); this->disable_loop(); From 56029457f48fba67f544d72faf4bed9e4f5c8f01 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 00:13:05 -1000 Subject: [PATCH 09/10] [status_led] Restructure feed_wdt to tail-call status_led loop() --- esphome/core/application.cpp | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 4d1a48da4c..5576d22bfe 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -208,16 +208,18 @@ void HOT Application::feed_wdt(uint32_t time) { if (status_led::global_status_led != nullptr) { auto *sl = status_led::global_status_led; uint8_t sl_state = sl->get_component_state() & COMPONENT_STATE_MASK; - if (sl_state == COMPONENT_STATE_LOOP) { - sl->loop(); - } else if (sl_state == COMPONENT_STATE_LOOP_DONE && (this->app_state_ & STATUS_LED_MASK) != 0) { - // status_led disabled its own loop when it went idle; an error or warning bit has - // been set since, so re-enable and dispatch it. Safe to call loop() directly: - // LOOP_DONE means setup has already run. + if (sl_state == COMPONENT_STATE_LOOP_DONE) { + // status_led disabled its own loop when it went idle. Only re-dispatch if an error + // or warning bit has been set since. Safe to call loop() directly here: LOOP_DONE + // means setup has already run. + if ((this->app_state_ & STATUS_LED_MASK) == 0) + return; sl->enable_loop(); - sl->loop(); + } else if (sl_state != COMPONENT_STATE_LOOP) { + // CONSTRUCTION/SETUP/FAILED: not our job — App::setup() drives the lifecycle. + return; } - // CONSTRUCTION/SETUP/FAILED: not our job — App::setup() drives the lifecycle. + sl->loop(); } #endif } From 7eb56e1cf6911cae93ac21c5fc21916fdd52d985 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Apr 2026 00:14:40 -1000 Subject: [PATCH 10/10] [status_led] Clarify feed_wdt LOOP_DONE comment --- esphome/core/application.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 5576d22bfe..63c9d65b3f 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -209,9 +209,10 @@ void HOT Application::feed_wdt(uint32_t time) { auto *sl = status_led::global_status_led; uint8_t sl_state = sl->get_component_state() & COMPONENT_STATE_MASK; if (sl_state == COMPONENT_STATE_LOOP_DONE) { - // status_led disabled its own loop when it went idle. Only re-dispatch if an error - // or warning bit has been set since. Safe to call loop() directly here: LOOP_DONE - // means setup has already run. + // status_led only transitions to LOOP_DONE from inside its own loop() (after the + // first idle-path dispatch), so its pin is already initialized by pre_setup() and + // its setup() has already run. Re-dispatch only if an error or warning bit has been + // set since; otherwise skip entirely. if ((this->app_state_ & STATUS_LED_MASK) == 0) return; sl->enable_loop();