[esphome.ota] Disable loop while idle, wake on listening-socket activity

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.
This commit is contained in:
J. Nick Koston
2026-04-10 13:24:32 -10:00
parent 40081e5ae7
commit 5b84ad5926
5 changed files with 69 additions and 6 deletions
+19 -6
View File
@@ -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_() {
@@ -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 <coredecls.h> // 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;
}
+13
View File
@@ -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();
+17
View File
@@ -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
+11
View File
@@ -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
}
}