diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 6a0bd61138..034fa7c410 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -296,15 +296,6 @@ def _consume_api_sockets(config: ConfigType) -> ConfigType: def _validate_outgoing_connection(config: ConfigType) -> ConfigType: if CONF_OUTGOING_CONNECTION not in config: return config - # Platform default check here for a friendly early error; an explicit - # lwip_tcp selection on other platforms is caught against the resolved - # implementation in _validate_outgoing_socket_implementation - if CORE.is_esp8266 or CORE.is_rp2: - raise cv.Invalid( - "outgoing_connection is not supported on this platform because its " - "socket layer cannot make outgoing connections", - path=[CONF_OUTGOING_CONNECTION], - ) if CONF_ENCRYPTION not in config: raise cv.Invalid( "outgoing_connection requires 'encryption' so the peer is verified by key", @@ -474,7 +465,11 @@ def _validate_esp8266_action_strings(config: ConfigType) -> ConfigType: def _validate_outgoing_socket_implementation(config: ConfigType) -> ConfigType: - """A raw lwip_tcp socket can be selected explicitly on any platform.""" + """Reject the raw lwip_tcp socket, the only option on ESP8266 and RP2040. + + Checked against the resolved implementation so an explicit selection on + another platform is caught the same way as the platform default. + """ if CONF_OUTGOING_CONNECTION not in config: return config from esphome.components import socket @@ -485,7 +480,8 @@ def _validate_outgoing_socket_implementation(config: ConfigType) -> ConfigType: ) in socket.IMPLEMENTATIONS_WITHOUT_CONNECT: raise cv.Invalid( f"outgoing_connection is not supported with the {impl} socket " - "implementation because it cannot make outgoing connections", + "implementation (the only one on ESP8266 and RP2040) because it " + "cannot make outgoing connections", path=[CONF_OUTGOING_CONNECTION], ) return config diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 88a738a490..97db9adfb6 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -18,7 +18,7 @@ namespace esphome::api { // uncomment to log raw packets -// #define HELPER_LOG_PACKETS +//#define HELPER_LOG_PACKETS // Maximum message size limits to prevent OOM on constrained devices // Handshake messages are limited to a small size for security diff --git a/esphome/components/api/api_outgoing_connection.cpp b/esphome/components/api/api_outgoing_connection.cpp index 34966a9b44..374735ad46 100644 --- a/esphome/components/api/api_outgoing_connection.cpp +++ b/esphome/components/api/api_outgoing_connection.cpp @@ -178,13 +178,12 @@ void OutgoingConnectionManager::on_client_removed(APIConnection *conn, bool was_ return; } this->dialed_conn_ = nullptr; - const uint32_t now = App.get_loop_component_start_time(); if (was_authenticated) { // A working peer (e.g. a host: target that never sends the flag) // disconnected normally; state is IDLE, so loop() applies the delay this->backoff_ = BACKOFF_MIN_MS; } else { - this->schedule_retry_(now); + this->schedule_retry_(App.get_loop_component_start_time()); } } diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 228b9b8fbf..ab99e2f18f 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -34,12 +34,6 @@ APIServer::APIServer() { global_api_server = this; } void APIServer::socket_failed_(const LogString *msg) { ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno); this->destroy_socket_(); -#ifdef USE_API_OUTGOING_CONNECTION - // Dial-out needs no listener; degrade instead of stopping the component - this->status_set_error(LOG_STR("listen socket failed")); -#else - this->mark_failed(); -#endif } bool APIServer::create_listen_socket_() { @@ -98,14 +92,15 @@ void APIServer::setup() { #endif #endif -#ifdef USE_API_OUTGOING_CONNECTION - // A dead listener degrades to an error status; dial-out still runs - this->create_listen_socket_(); -#else if (!this->create_listen_socket_()) { +#ifdef USE_API_OUTGOING_CONNECTION + // Dial-out needs no listener; degrade instead of stopping the component + this->status_set_error(LOG_STR("listen socket failed")); +#else + this->mark_failed(); return; - } #endif + } #ifdef USE_LOGGER if (logger::global_logger != nullptr) { @@ -309,7 +304,8 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() { bool APIServer::add_client_(APIConnection *conn) { if (this->at_client_limit_()) { - // Callers check first; enforce the array bound where the write happens + // The accept path checks first to skip the allocation; the outgoing + // handoff relies on this check ESP_LOGW(TAG, "Max connections (%d), dropping client", MAX_API_CONNECTIONS); delete conn; return false; @@ -328,11 +324,11 @@ bool APIServer::add_client_(APIConnection *conn) { #ifdef USE_API_OUTGOING_CONNECTION APIConnection *APIServer::add_outgoing_client_(std::unique_ptr sock) { - // Re-check at the handoff: inbound clients may have filled the slots and - // the PSK may have been cleared (mark_outgoing() needs the noise helper) - const bool at_limit = this->at_client_limit_(); - if (at_limit || !this->noise_ctx_.has_psk()) { - ESP_LOGW(TAG, "Dropping outgoing connection (%s)", at_limit ? "max connections" : "no key"); + // Re-check at the handoff: the PSK may have been cleared since the dial + // started (mark_outgoing() needs the noise helper); add_client_ re-checks + // the slot limit + if (!this->noise_ctx_.has_psk()) { + ESP_LOGW(TAG, "Dropping outgoing connection (no key)"); return nullptr; } auto *conn = new APIConnection(std::move(sock), this); diff --git a/tests/component_tests/api/test_outgoing_connection.py b/tests/component_tests/api/test_outgoing_connection.py index 14e10fcbe0..bb09910d89 100644 --- a/tests/component_tests/api/test_outgoing_connection.py +++ b/tests/component_tests/api/test_outgoing_connection.py @@ -81,25 +81,24 @@ def test_outgoing_connection_requires_encryption( @pytest.mark.parametrize( - "platform_framework", - [PlatformFramework.ESP8266_ARDUINO, PlatformFramework.RP2040_ARDUINO], + ("platform_framework", "platform_data"), + [ + # The platform default on these two + (PlatformFramework.ESP8266_ARDUINO, None), + (PlatformFramework.RP2040_ARDUINO, None), + # An explicit selection elsewhere + (PlatformFramework.ESP32_IDF, ESP32_PLATFORM_DATA), + ], ) -def test_outgoing_connection_rejected_on_raw_lwip_platforms( +def test_outgoing_connection_rejects_lwip_tcp( set_core_config: SetCoreConfigCallable, platform_framework: PlatformFramework, + platform_data: ConfigType | None, ) -> None: - set_core_config(platform_framework) - with pytest.raises(cv.Invalid, match="not supported on this platform"): - CONFIG_SCHEMA(_api_config({"host": "192.168.1.2"})) - - -def test_outgoing_connection_rejects_lwip_tcp_selected_on_esp32( - set_core_config: SetCoreConfigCallable, -) -> None: - """An explicit lwip_tcp selection is caught at final validate.""" + """The resolved lwip_tcp socket is rejected at final validate.""" set_core_config( - PlatformFramework.ESP32_IDF, - platform_data=ESP32_PLATFORM_DATA, + platform_framework, + platform_data=platform_data, full_config={"socket": {"implementation": "lwip_tcp"}}, ) config = CONFIG_SCHEMA(_api_config({"host": "192.168.1.2"}))