diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 1999b88ef4..408bde14ac 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -99,15 +99,23 @@ void ESPHomeOTAComponent::dump_config() { void ESPHomeOTAComponent::loop() { // Self-disabling idle loop. On the first tick after setup() (and after every session - // cleanup and every false wake), if there's no client and the listener has nothing - // queued, we disable ourselves and go back to sleep. Socket-wake paths (LwIP fast - // select, raw TCP accept, host select) mark us pending-enable via - // App.wake_ota_component_any_context() when a monitored socket signals activity, and - // enable_pending_loops_() reactivates us. + // ends), if there's no client and the listener has nothing queued, we disable + // ourselves and go back to sleep. Wake paths (LwIP fast select with listener filter, + // raw TCP accept_fn_, host select) mark us pending-enable via + // App.wake_ota_component_any_context() when an incoming connection arrives on the OTA + // listen socket; enable_pending_loops_() reactivates us. // - // False wakes from unrelated monitored sockets are expected — the event callbacks - // fire on every RCVPLUS across all monitored sockets, not just OTA's listener — and - // they land here with no pending work. + // On fast-select platforms (ESP32 / LibreTiny), the callback is filtered to OTA's + // listener netconn, so unrelated monitored sockets (API client data, mDNS, etc.) do + // NOT wake OTA through that path. Raw TCP (ESP8266 / RP2040) is inherently filtered: + // accept_fn_ is registered per listener pcb and only fires on completed handshakes + // to that specific listener. So in normal steady state this loop() runs exactly + // once per real incoming OTA connection. + // + // We can still land here with no pending work in a few narrow cases — a second + // connection queued on the listener while an OTA session was active, a listener + // filter that hasn't been installed yet, or the host select path (no filter) — which + // is why the idle-check and self-disable are retained as a safety net. // // cleanup_connection_() deliberately does NOT call disable_loop() — letting loop() // run one more iteration after a session ends guarantees we re-read server_->ready() diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index 9e1438ecdd..2572a9103d 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -53,12 +53,16 @@ static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) { /// The sock pointer must have been obtained from esphome_lwip_get_sock(). void esphome_lwip_hook_socket(struct lwip_sock *sock); -/// Filter the inline OTA wake hook in the fast-select callback so it only fires for -/// RCVPLUS events on this specific listener's netconn. Without this, every monitored +/// Install an OTA listener netconn as the wake-filter target for the fast-select +/// callback. After this is called, the OTA wake hook only fires for RCVPLUS events +/// whose `conn` argument matches this listener's netconn — i.e. only on actual +/// incoming connections to the OTA listen socket. Without this, every monitored /// socket's RCVPLUS (API client data, web server, mDNS, etc.) would mark OTA /// pending-enable and force its loop to run a wake-up tick only to re-disable itself. /// Captured at OTA setup(); stays pointing at the listener for the device's lifetime. -/// Pass NULL to clear the filter (wake fires on every RCVPLUS, pre-filter behavior). +/// Passing NULL disables OTA wake notifications entirely (no RCVPLUS event will match +/// a null listener), which is the correct behavior before the listener is installed +/// and after it's torn down. void esphome_fast_select_set_ota_listener_sock(struct lwip_sock *sock); /// Set or clear TCP_NODELAY on a socket's tcp_pcb directly.