From bf380037a6dc41b70dda47f769763901f8790c10 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 31 Aug 2026 21:01:03 -0400 Subject: [PATCH] Refresh the watchdog on every authenticated removal --- esphome/components/api/__init__.py | 20 ++++++++++++++++++- .../api/api_outgoing_connection.cpp | 4 +++- esphome/components/api/api_server.cpp | 13 ++++++------ .../api/test_outgoing_connection.py | 20 ++++++++++++++++++- 4 files changed, 48 insertions(+), 9 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 34474220ca..95873ba7d8 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -458,6 +458,22 @@ def _validate_esp8266_action_strings(config: ConfigType) -> ConfigType: return config +def _validate_outgoing_socket_implementation(config: ConfigType) -> ConfigType: + """A raw lwip_tcp socket can be selected explicitly on any platform.""" + if CONF_OUTGOING_CONNECTION not in config: + return config + from esphome.components import socket + + socket_conf = fv.full_config.get().get("socket") or {} + if socket_conf.get(socket.CONF_IMPLEMENTATION) == socket.IMPLEMENTATION_LWIP_TCP: + raise cv.Invalid( + "outgoing_connection is not supported with the lwip_tcp socket " + "implementation because it cannot make outgoing connections", + path=[CONF_OUTGOING_CONNECTION], + ) + return config + + def _validate_outgoing_host_ipv6(config: ConfigType) -> ConfigType: """An IPv6 host silently dials 255.255.255.255 on a build without IPv6.""" if ( @@ -477,7 +493,9 @@ def _validate_outgoing_host_ipv6(config: ConfigType) -> ConfigType: FINAL_VALIDATE_SCHEMA = cv.All( - _validate_esp8266_action_strings, _validate_outgoing_host_ipv6 + _validate_esp8266_action_strings, + _validate_outgoing_socket_implementation, + _validate_outgoing_host_ipv6, ) diff --git a/esphome/components/api/api_outgoing_connection.cpp b/esphome/components/api/api_outgoing_connection.cpp index a2f7e47e35..4a8982d436 100644 --- a/esphome/components/api/api_outgoing_connection.cpp +++ b/esphome/components/api/api_outgoing_connection.cpp @@ -174,7 +174,9 @@ void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) { void OutgoingConnectionManager::handoff_(APIServer *server, uint32_t now) { this->dialed_conn_ = server->add_outgoing_client_(std::move(this->dial_socket_)); if (this->dialed_conn_ == nullptr) { - this->schedule_retry_(now); + // Only preconditions (slot limit, key cleared) refuse the handoff; the + // peer is reachable, so do not escalate the backoff + this->schedule_wait_(now, PRECONDITION_RETRY_MS); return; } // Connected; dialed_conn_ gates further dialing until the session settles diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index cc95966694..99772ee7dc 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -239,14 +239,15 @@ void APIServer::remove_client_(uint8_t client_index) { // Last client disconnected - set warning and start tracking for reboot timeout // (suppressed while provisioning is pending - see loop()). + // Refresh on every authenticated removal, not just the last one, so an + // unauthenticated straggler removed later (e.g. a port scan, or a dial to + // a host that accepts TCP but never speaks the API) cannot discard a + // healthy session's timestamp and trigger a spurious reboot + if (was_authenticated) { + this->last_connected_ = App.get_loop_component_start_time(); + } if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { this->status_set_warning(LOG_STR("waiting for client connection")); - // A session that never authenticated (e.g. a port scan, or a dial to a - // host that accepts TCP but never speaks the API) must not reset the - // reboot watchdog - if (was_authenticated) { - this->last_connected_ = App.get_loop_component_start_time(); - } } #ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER diff --git a/tests/component_tests/api/test_outgoing_connection.py b/tests/component_tests/api/test_outgoing_connection.py index 8936681e5d..cd0cb46a10 100644 --- a/tests/component_tests/api/test_outgoing_connection.py +++ b/tests/component_tests/api/test_outgoing_connection.py @@ -5,7 +5,11 @@ from pathlib import Path import pytest -from esphome.components.api import CONFIG_SCHEMA, _validate_outgoing_host_ipv6 +from esphome.components.api import ( + CONFIG_SCHEMA, + _validate_outgoing_host_ipv6, + _validate_outgoing_socket_implementation, +) from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 import esphome.config_validation as cv from esphome.const import PlatformFramework @@ -68,6 +72,20 @@ def test_outgoing_connection_rejected_on_raw_lwip_platforms( CONFIG_SCHEMA(_api_config({"host": "192.168.1.2"})) +def test_outgoing_connection_rejects_lwip_tcp_socket( + set_core_config: SetCoreConfigCallable, +) -> None: + """An explicit lwip_tcp socket selection is rejected at final validate.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data=ESP32_PLATFORM_DATA, + full_config={"socket": {"implementation": "lwip_tcp"}}, + ) + config = CONFIG_SCHEMA(_api_config({"host": "192.168.1.2"})) + with pytest.raises(cv.Invalid, match="lwip_tcp"): + _validate_outgoing_socket_implementation(config) + + def test_outgoing_connection_rejects_hostnames( set_core_config: SetCoreConfigCallable, ) -> None: