From 5b84ad592671cf12242794474e45a2395f46b67f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 13:24:32 -1000 Subject: [PATCH] [esphome.ota] Disable loop while idle, wake on listening-socket activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ESPHomeOTAComponent::loop() previously ran every main-loop tick just to check `client_ != nullptr || server_->ready()` — a wasted dispatch on every device, since OTA is idle the vast majority of the time. OTA now disables its own loop after setup() and after cleanup_connection_(). A single 4-byte Component* slot in Application (only compiled in under USE_OTA) lets the existing socket-wake paths call enable_loop_soon_any_context() on the registered OTA component: - ESP32 / LibreTiny (lwip fast select): hooked in esphome_socket_event_callback on NETCONN_EVT_RCVPLUS. - ESP8266 / RP2040 (raw TCP): hooked in LWIPRawListenImpl::accept_fn_ right after the existing wake_loop_any_context() call. - Host (select fallback): called after select() returns ready in Application::yield_with_select_. False wakes (e.g. an API-socket event firing the fast-select callback) land in ESPHomeOTAComponent::loop(), which re-disables itself immediately when idle. Net cost is still far below running every tick. This is deliberately an OTA-only hook: OTA is the only component that benefits, and a single global slot avoids adding per-socket Component* storage, wake-callback lists, or any new API churn to the socket layer. --- .../components/esphome/ota/ota_esphome.cpp | 25 ++++++++++++++----- .../components/socket/lwip_raw_tcp_impl.cpp | 9 +++++++ esphome/core/application.cpp | 13 ++++++++++ esphome/core/application.h | 17 +++++++++++++ esphome/core/lwip_fast_select.c | 11 ++++++++ 5 files changed, 69 insertions(+), 6 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index af9b8ee19a1..ba526cc2b78 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -65,6 +65,12 @@ void ESPHomeOTAComponent::setup() { this->server_failed_(LOG_STR("listen")); return; } + + // Disable loop() while idle. Socket wake paths (LwIP fast select, raw TCP accept + // callback, host select) call App.wake_ota_component_any_context() which re-enables + // this component's loop when an incoming connection arrives. + App.set_ota_wake_component(this); + this->disable_loop(); } void ESPHomeOTAComponent::dump_config() { @@ -81,13 +87,18 @@ void ESPHomeOTAComponent::dump_config() { } void ESPHomeOTAComponent::loop() { - // Skip handle_handshake_() call if no client connected and no incoming connections - // This optimization reduces idle loop overhead when OTA is not active - // Note: No need to check server_ for null as the component is marked failed in setup() - // if server_ creation fails - if (this->client_ != nullptr || this->server_->ready()) { - this->handle_handshake_(); + // loop() is disabled while idle (see setup() / cleanup_connection_()). Socket-wake + // paths (LwIP fast select, raw TCP accept, host select) call + // App.wake_ota_component_any_context() to re-enable this loop when a monitored + // socket signals activity. False wakes (e.g. an API-socket event) land here with + // no pending work — in that case we disable the loop again and go back to sleep. + // Note: No need to check server_ for null as the component is marked failed in + // setup() if server_ creation fails. + if (this->client_ == nullptr && !this->server_->ready()) { + this->disable_loop(); + return; } + this->handle_handshake_(); } static const uint8_t FEATURE_SUPPORTS_COMPRESSION = 0x01; @@ -566,6 +577,8 @@ void ESPHomeOTAComponent::cleanup_connection_() { #ifdef USE_OTA_PASSWORD this->cleanup_auth_(); #endif + // Back to idle — sleep until the next incoming connection wakes us. + this->disable_loop(); } void ESPHomeOTAComponent::yield_and_feed_watchdog_() { diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 86131d3ddb9..823637e0f73 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -10,6 +10,9 @@ #include "esphome/core/helpers.h" #include "esphome/core/wake.h" #include "esphome/core/log.h" +#ifdef USE_OTA +#include "esphome/core/application.h" +#endif #ifdef USE_ESP8266 #include // For esp_schedule() @@ -856,6 +859,12 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { LWIP_LOG("Accepted connection, queue size: %d", this->accepted_socket_count_); // Wake the main loop immediately so it can accept the new connection. esphome::wake_loop_any_context(); +#ifdef USE_OTA + // Re-enable the OTA component loop if it disabled itself while idle. + // enable_loop_soon_any_context() is IRAM/IRQ-safe, which is required on RP2040 + // where this callback runs in a low-priority user IRQ context. + esphome::App.wake_ota_component_any_context(); +#endif return ERR_OK; } diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index cd758598801..45bd2350f7c 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -449,6 +449,12 @@ void Application::enable_pending_loops_() { } } +#if defined(USE_OTA) && defined(USE_LWIP_FAST_SELECT) +// Called from the LwIP TCP/IP task via esphome_socket_event_callback() on NETCONN_EVT_RCVPLUS. +// enable_loop_soon_any_context() is task-safe and IRAM-resident. +extern "C" void IRAM_ATTR 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. @@ -554,6 +560,13 @@ 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 + // A socket is ready; re-enable the OTA component loop if it disabled itself while idle. + // The wake is a no-op if OTA didn't register or is already active. + 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(); diff --git a/esphome/core/application.h b/esphome/core/application.h index 6b2969b4907..63a951b6814 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -559,6 +559,20 @@ class Application { /// Wake from any context (ISR, thread, callback). static void IRAM_ATTR wake_loop_any_context() { esphome::wake_loop_any_context(); } +#ifdef USE_OTA + /// Register the OTA component so socket-wake paths can enable its loop when + /// a new connection arrives on the listening socket. OTA disables its own + /// loop while idle to avoid per-tick dispatch overhead. + void set_ota_wake_component(Component *component) { this->ota_wake_component_ = component; } + /// Wake the registered OTA component (if any) from any context. Safe to call + /// from the LwIP TCP/IP task and other callback contexts. + void IRAM_ATTR wake_ota_component_any_context() { + if (this->ota_wake_component_ != nullptr) { + this->ota_wake_component_->enable_loop_soon_any_context(); + } + } +#endif + protected: friend Component; #ifdef USE_HOST @@ -634,6 +648,9 @@ class Application { // Pointer-sized members first Component *current_component_{nullptr}; +#ifdef USE_OTA + Component *ota_wake_component_{nullptr}; // Set by ESPHomeOTAComponent to receive socket-wake notifications +#endif // std::vector (3 pointers each: begin, end, capacity) // Partitioned vector design for looping components diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index bb3acbafcb1..80fe933941f 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -157,6 +157,13 @@ _Static_assert(offsetof(struct lwip_sock, rcvevent) == ESPHOME_LWIP_SOCK_RCVEVEN // Saved original event_callback pointer — written once in first hook_socket(), read from TCP/IP task. static netconn_callback s_original_callback = NULL; +#ifdef USE_OTA +// Extern wake hook for the OTA component (implemented in application.cpp). Called from the +// TCP/IP task so the OTA component's disabled loop can be re-enabled when a new connection +// arrives on its listening socket. Safe from task context via enable_loop_soon_any_context(). +extern void esphome_wake_ota_component_any_context(void); +#endif + // Wrapper callback: calls original event_callback + notifies main loop task. // Called from LwIP's TCP/IP thread when socket events occur (task context, not ISR). static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt evt, u16_t len) { @@ -175,6 +182,10 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt if (task != NULL) { xTaskNotifyGive(task); } +#ifdef USE_OTA + // Re-enable the OTA component loop if it disabled itself while idle. + esphome_wake_ota_component_any_context(); +#endif } }