[esphome][ota] Disable loop while idle, wake on listening-socket activity (#15636)

This commit is contained in:
J. Nick Koston
2026-04-14 08:20:14 -10:00
committed by GitHub
parent 9f5ed938e5
commit 79cee864cb
6 changed files with 180 additions and 6 deletions
@@ -78,6 +78,14 @@ def ota_esphome_final_validate(config):
else:
new_ota_conf.append(ota_conf)
if len(merged_ota_esphome_configs_by_port) > 1:
raise cv.Invalid(
f"Only a single port is supported for '{CONF_OTA}' "
f"'{CONF_PLATFORM}: {CONF_ESPHOME}'. Got ports "
f"{sorted(merged_ota_esphome_configs_by_port.keys())}. Consolidate "
f"onto a single port; configs sharing a port are merged automatically."
)
new_ota_conf.extend(merged_ota_esphome_configs_by_port.values())
full_conf[CONF_OTA] = new_ota_conf
@@ -147,6 +155,8 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_auth_password(config[CONF_PASSWORD]))
cg.add_define("USE_OTA_PASSWORD")
cg.add_define("USE_OTA_VERSION", config[CONF_VERSION])
# Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it.
cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME")
await cg.register_component(var, config)
await ota_to_code(var, config)
+33 -6
View File
@@ -15,6 +15,9 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/util.h"
#ifdef USE_LWIP_FAST_SELECT
#include "esphome/core/lwip_fast_select.h"
#endif
#include <cerrno>
#include <cstdio>
@@ -28,6 +31,17 @@ static constexpr size_t OTA_BUFFER_SIZE = 1024; // buffer size
static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer
// Single-instance pointer — multi-port configs are rejected in final_validate.
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
static ESPHomeOTAComponent *global_esphome_ota_component = nullptr;
// Called from any context (LwIP TCP/IP task, RP2040 user-IRQ).
extern "C" void esphome_wake_ota_component_any_context() {
if (global_esphome_ota_component != nullptr) {
global_esphome_ota_component->enable_loop_soon_any_context();
}
}
void ESPHomeOTAComponent::setup() {
this->server_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections
if (this->server_ == nullptr) {
@@ -65,6 +79,14 @@ void ESPHomeOTAComponent::setup() {
this->server_failed_(LOG_STR("listen"));
return;
}
// loop() self-disables on its first idle tick; no explicit disable_loop() needed here.
global_esphome_ota_component = this;
#ifdef USE_LWIP_FAST_SELECT
// Filter fast-select wakes to this listener only. If the sock lookup returns nullptr,
// no wakes fire and loop() falls back to the self-disable safety net.
esphome_fast_select_set_ota_listener_sock(esphome_lwip_get_sock(this->server_->get_fd()));
#endif
}
void ESPHomeOTAComponent::dump_config() {
@@ -81,13 +103,15 @@ 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_();
// Self-disabling idle loop. Runs when a wake path marks us pending-enable (fast-select
// listener filter, raw-TCP accept_fn_, or host select), finds no work, and goes back
// to sleep. cleanup_connection_() deliberately leaves the loop enabled for one more
// iteration so a connection queued mid-session is still caught here.
if (this->client_ == nullptr && !this->server_->ready()) {
this->disable_loop();
return;
}
this->handle_handshake_();
}
static const uint8_t FEATURE_SUPPORTS_COMPRESSION = 0x01;
@@ -566,6 +590,9 @@ void ESPHomeOTAComponent::cleanup_connection_() {
#ifdef USE_OTA_PASSWORD
this->cleanup_auth_();
#endif
// Intentionally no disable_loop() — letting loop() run one more iteration catches
// any connection that queued on the listener mid-session (otherwise the wake flag,
// set while we were in LOOP state, would be lost to enable_pending_loops_()).
}
void ESPHomeOTAComponent::yield_and_feed_watchdog_() {
@@ -11,6 +11,10 @@
#include "esphome/core/wake.h"
#include "esphome/core/log.h"
#ifdef USE_OTA_PLATFORM_ESPHOME
extern "C" void esphome_wake_ota_component_any_context();
#endif
#ifdef USE_ESP8266
#include <coredecls.h> // For esp_schedule()
#elif defined(USE_RP2040)
@@ -854,6 +858,10 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) {
tcp_err(newpcb, LWIPRawListenImpl::s_queued_err_fn);
tcp_recv(newpcb, LWIPRawListenImpl::s_queued_recv_fn);
LWIP_LOG("Accepted connection, queue size: %d", this->accepted_socket_count_);
#ifdef USE_OTA_PLATFORM_ESPHOME
// Must run before wake_loop_any_context() so flags are visible when the main task wakes.
esphome_wake_ota_component_any_context();
#endif
// Wake the main loop immediately so it can accept the new connection.
esphome::wake_loop_any_context();
return ERR_OK;
+18
View File
@@ -157,6 +157,17 @@ _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_PLATFORM_ESPHOME
static struct netconn *s_ota_listener_conn = NULL;
extern void esphome_wake_ota_component_any_context(void);
void esphome_fast_select_set_ota_listener_sock(struct lwip_sock *sock) {
s_ota_listener_conn = (sock != NULL) ? sock->conn : NULL;
}
#else
void esphome_fast_select_set_ota_listener_sock(struct lwip_sock *sock) { (void) sock; }
#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) {
@@ -171,6 +182,13 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt
// (rcvevent++ with a NULL pbuf or error in recvmbox), so error conditions
// already wake the main loop through the RCVPLUS path.
if (evt == NETCONN_EVT_RCVPLUS) {
#ifdef USE_OTA_PLATFORM_ESPHOME
// Mark OTA pending-enable only for events on its listen socket. MUST happen
// before xTaskNotifyGive so the flags are visible when the main task wakes.
if (conn == s_ota_listener_conn) {
esphome_wake_ota_component_any_context();
}
#endif
TaskHandle_t task = esphome_main_task_handle;
if (task != NULL) {
xTaskNotifyGive(task);
+6
View File
@@ -53,6 +53,12 @@ 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);
/// Set the listener netconn that the fast-select callback filters OTA wakes against.
/// After this is called, the OTA wake hook only fires for RCVPLUS events whose `conn`
/// matches this listener. Passing NULL disables OTA wakes (no event matches a NULL
/// listener) — correct behavior before install and after teardown.
void esphome_fast_select_set_ota_listener_sock(struct lwip_sock *sock);
/// Set or clear TCP_NODELAY on a socket's tcp_pcb directly.
/// Must be called with the TCPIP core lock held (LwIPLock in C++).
/// This bypasses lwip_setsockopt() overhead (socket lookups, switch cascade,
@@ -0,0 +1,105 @@
"""Tests for the esphome OTA platform final_validate logic."""
from __future__ import annotations
import logging
from typing import Any
import pytest
from esphome import config_validation as cv
from esphome.components.esphome.ota import ota_esphome_final_validate
from esphome.const import (
CONF_ESPHOME,
CONF_ID,
CONF_OTA,
CONF_PASSWORD,
CONF_PLATFORM,
CONF_PORT,
CONF_VERSION,
)
from esphome.core import ID
import esphome.final_validate as fv
def _make_ota_config(port: int = 3232, **kwargs: Any) -> dict[str, Any]:
config: dict[str, Any] = {
CONF_PLATFORM: CONF_ESPHOME,
CONF_ID: ID(f"ota_esphome_{port}", is_manual=False),
CONF_VERSION: 2,
CONF_PORT: port,
}
config.update(kwargs)
return config
def test_single_esphome_ota_instance_accepted() -> None:
"""A single ESPHome OTA config passes final_validate untouched."""
full_conf = {CONF_OTA: [_make_ota_config(port=3232)]}
token = fv.full_config.set(full_conf)
try:
ota_esphome_final_validate({})
updated = fv.full_config.get()
assert len(updated[CONF_OTA]) == 1
assert updated[CONF_OTA][0][CONF_PORT] == 3232
finally:
fv.full_config.reset(token)
def test_same_port_configs_merge(caplog: pytest.LogCaptureFixture) -> None:
"""Two ESPHome OTA configs on the same port merge into one instance."""
full_conf = {
CONF_OTA: [
_make_ota_config(port=3232, **{CONF_PASSWORD: "pw"}),
_make_ota_config(port=3232),
]
}
token = fv.full_config.set(full_conf)
try:
with caplog.at_level(logging.WARNING):
ota_esphome_final_validate({})
updated = fv.full_config.get()
assert len(updated[CONF_OTA]) == 1
assert updated[CONF_OTA][0][CONF_PORT] == 3232
assert any("Found and merged" in record.message for record in caplog.records), (
"Expected merge warning not found in log"
)
finally:
fv.full_config.reset(token)
def test_multiple_ports_rejected() -> None:
"""Two ESPHome OTA configs on different ports raise cv.Invalid."""
full_conf = {
CONF_OTA: [
_make_ota_config(port=3232),
_make_ota_config(port=3233),
]
}
token = fv.full_config.set(full_conf)
try:
with pytest.raises(
cv.Invalid,
match=r"Only a single port is supported for 'ota' 'platform: esphome'",
):
ota_esphome_final_validate({})
finally:
fv.full_config.reset(token)
def test_non_esphome_ota_unaffected() -> None:
"""Non-esphome OTA platforms are not subject to the single-instance rule."""
full_conf = {
CONF_OTA: [
_make_ota_config(port=3232),
{CONF_PLATFORM: "web_server", CONF_ID: ID("ota_ws", is_manual=False)},
{CONF_PLATFORM: "http_request", CONF_ID: ID("ota_hr", is_manual=False)},
]
}
token = fv.full_config.set(full_conf)
try:
ota_esphome_final_validate({})
updated = fv.full_config.get()
assert len(updated[CONF_OTA]) == 3
finally:
fv.full_config.reset(token)