From 900638463a46edc7aecaf8533dea08bb6d19437c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 3 Sep 2026 17:41:48 +0200 Subject: [PATCH] Move the connect poll into the socket component and dedupe target persistence --- esphome/components/api/__init__.py | 8 ++- .../api/api_outgoing_connection.cpp | 56 ++----------------- .../components/api/api_outgoing_connection.h | 19 +++---- esphome/components/const/__init__.py | 1 + esphome/components/socket/__init__.py | 2 + esphome/components/socket/socket.cpp | 44 +++++++++++++++ esphome/components/socket/socket.h | 13 +++++ 7 files changed, 78 insertions(+), 65 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index e9cf0ad459..932cb941cb 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -5,7 +5,7 @@ from typing import Any from esphome import automation from esphome.automation import Condition import esphome.codegen as cg -from esphome.components.const import CONF_DESCRIPTION +from esphome.components.const import CONF_DESCRIPTION, CONF_HOST from esphome.components.logger import request_log_listener # ENCRYPTION_SCHEMA and validate_encryption_key are re-exported for external @@ -132,7 +132,6 @@ SERVICE_ARG_FALLBACK_TYPES: dict[str, MockObj] = { CONF_BATCH_DELAY = "batch_delay" CONF_CUSTOM_SERVICES = "custom_services" CONF_EXAMPLE = "example" -CONF_HOST = "host" CONF_HOMEASSISTANT_SERVICES = "homeassistant_services" CONF_HOMEASSISTANT_STATES = "homeassistant_states" CONF_LISTEN_BACKLOG = "listen_backlog" @@ -480,7 +479,10 @@ def _validate_outgoing_socket_implementation(config: ConfigType) -> ConfigType: 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: + if ( + socket_conf.get(socket.CONF_IMPLEMENTATION) + in socket.IMPLEMENTATIONS_WITHOUT_CONNECT + ): raise cv.Invalid( "outgoing_connection is not supported with the lwip_tcp socket " "implementation because it cannot make outgoing connections", diff --git a/esphome/components/api/api_outgoing_connection.cpp b/esphome/components/api/api_outgoing_connection.cpp index 0970f3a205..7be13a2077 100644 --- a/esphome/components/api/api_outgoing_connection.cpp +++ b/esphome/components/api/api_outgoing_connection.cpp @@ -10,9 +10,6 @@ #include #include -#ifdef USE_SOCKET_IMPL_BSD_SOCKETS -#include -#endif namespace esphome::api { @@ -90,8 +87,7 @@ void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) { // A corrupt remembered value can never become dialable; forget it // (covers an IPv6 literal left by an earlier enable_ipv6 build too) this->saved_ = {}; - this->host_persisted_ = this->target_pref_.save(&this->saved_) && global_preferences->sync(); - if (!this->host_persisted_) { + if (!this->persist_target_()) { ESP_LOGW(TAG, "Failed to clear target"); } #endif @@ -132,58 +128,19 @@ void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) { } this->last_poll_ = now; int err = 0; - switch (poll_connect(*this->dial_socket_, err)) { - case ConnectPollResult::CONNECT_POLL_PENDING: + switch (socket::poll_connect(*this->dial_socket_, err)) { + case socket::ConnectPollResult::CONNECT_POLL_PENDING: break; - case ConnectPollResult::CONNECT_POLL_CONNECTED: + case socket::ConnectPollResult::CONNECT_POLL_CONNECTED: this->handoff_(server, now); break; - case ConnectPollResult::CONNECT_POLL_ERROR: + case socket::ConnectPollResult::CONNECT_POLL_ERROR: ESP_LOGW(TAG, "Connect failed: %d", err); this->schedule_retry_(now); break; } } -ConnectPollResult poll_connect(socket::Socket &sock, int &err_out) { - int fd = sock.get_fd(); - if (fd < 0 || fd >= FD_SETSIZE) { - // FD_SET on either is undefined behavior - err_out = EBADF; - return ConnectPollResult::CONNECT_POLL_ERROR; - } - // Connect completion is a write event; the main loop only selects on reads - fd_set writefds; - FD_ZERO(&writefds); - FD_SET(fd, &writefds); - struct timeval tv = {0, 0}; -#ifdef USE_SOCKET_IMPL_LWIP_SOCKETS - // LWIP_COMPAT_SOCKETS may be off (LibreTiny), so use the lwip symbol directly - int ret = lwip_select(fd + 1, nullptr, &writefds, nullptr, &tv); -#else - // Global-scope select: the entity namespace esphome::select shadows it here - int ret = ::select(fd + 1, nullptr, &writefds, nullptr, &tv); -#endif - if (ret < 0) { - err_out = errno; - return ConnectPollResult::CONNECT_POLL_ERROR; - } - if (ret == 0 || !FD_ISSET(fd, &writefds)) { - return ConnectPollResult::CONNECT_POLL_PENDING; - } - int error = 0; - socklen_t len = sizeof(error); - if (sock.getsockopt(SOL_SOCKET, SO_ERROR, &error, &len) != 0) { - err_out = errno; - return ConnectPollResult::CONNECT_POLL_ERROR; - } - if (error != 0) { - err_out = error; - return ConnectPollResult::CONNECT_POLL_ERROR; - } - return ConnectPollResult::CONNECT_POLL_CONNECTED; -} - 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) { @@ -248,8 +205,7 @@ void OutgoingConnectionManager::on_target_client(APIConnection *conn) { // Use the fresh address this boot even if the flash write fails; a failed // write is retried on the next flagged hello via host_persisted_ this->saved_ = target; - this->host_persisted_ = this->target_pref_.save(&target) && global_preferences->sync(); - if (!this->host_persisted_) { + if (!this->persist_target_()) { ESP_LOGW(TAG, "Failed to save target"); return; } diff --git a/esphome/components/api/api_outgoing_connection.h b/esphome/components/api/api_outgoing_connection.h index a48ab961d8..73b2db44b6 100644 --- a/esphome/components/api/api_outgoing_connection.h +++ b/esphome/components/api/api_outgoing_connection.h @@ -25,18 +25,6 @@ class APIConnection; // target is simply relearned static constexpr size_t SAVED_TARGET_HOST_LEN = socket::SOCKADDR_STR_LEN; -enum class ConnectPollResult : uint8_t { - CONNECT_POLL_PENDING, - CONNECT_POLL_CONNECTED, - CONNECT_POLL_ERROR, -}; - -/// Check a non-blocking connect() for completion without blocking. On -/// CONNECT_POLL_ERROR, err_out holds the socket's SO_ERROR, or errno when the -/// poll itself failed. Lives here for now; a candidate for the socket -/// component (async_tcp has a near-duplicate poll). -ConnectPollResult poll_connect(socket::Socket &sock, int &err_out); - struct SavedOutgoingTarget { // IP as text so the socket component's v4-mapped-IPv6 normalization is // reused on both ends; empty = none remembered @@ -86,6 +74,13 @@ class OutgoingConnectionManager { void schedule_retry_(uint32_t now); // Wait without escalating the backoff (used for unmet preconditions) void schedule_wait_(uint32_t now, uint32_t wait); +#ifndef API_OUTGOING_CONNECTION_HOST + // Write saved_ to flash, tracking success in host_persisted_ + bool persist_target_() { + this->host_persisted_ = this->target_pref_.save(&this->saved_) && global_preferences->sync(); + return this->host_persisted_; + } +#endif const char *target_host_() const { #ifdef API_OUTGOING_CONNECTION_HOST return API_OUTGOING_CONNECTION_HOST; diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index e445a4abde..76392afd8f 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -22,6 +22,7 @@ CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection" CONF_ENABLED = "enabled" CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" +CONF_HOST = "host" CONF_IAQ = "iaq" CONF_IGNORE_NOT_FOUND = "ignore_not_found" CONF_IS_WRGB = "is_wrgb" diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index edebed5d40..04f90ca360 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -15,6 +15,8 @@ CODEOWNERS = ["@esphome/core"] CONF_IMPLEMENTATION = "implementation" IMPLEMENTATION_LWIP_TCP = "lwip_tcp" +# Implementations whose sockets cannot make outgoing connections +IMPLEMENTATIONS_WITHOUT_CONNECT = frozenset({IMPLEMENTATION_LWIP_TCP}) IMPLEMENTATION_LWIP_SOCKETS = "lwip_sockets" IMPLEMENTATION_BSD_SOCKETS = "bsd_sockets" diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index 687160a252..9ed6230c57 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -2,6 +2,9 @@ #if defined(USE_SOCKET_IMPL_LWIP_TCP) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS) #include #include +#ifdef USE_SOCKET_IMPL_BSD_SOCKETS +#include +#endif #include #include "esphome/core/log.h" #include "esphome/core/application.h" @@ -199,6 +202,47 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_ return sizeof(sockaddr_in); } +#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) +ConnectPollResult poll_connect(Socket &sock, int &err_out) { + int fd = sock.get_fd(); + if (fd < 0 || fd >= FD_SETSIZE) { + // FD_SET on either is undefined behavior + err_out = EBADF; + return ConnectPollResult::CONNECT_POLL_ERROR; + } + // Connect completion is a write event; the main loop only selects on reads + fd_set writefds; + FD_ZERO(&writefds); + FD_SET(fd, &writefds); + struct timeval tv = {0, 0}; +#ifdef USE_SOCKET_IMPL_LWIP_SOCKETS + // LWIP_COMPAT_SOCKETS may be off (LibreTiny), so use the lwip symbol directly + int ret = lwip_select(fd + 1, nullptr, &writefds, nullptr, &tv); +#else + // Global-scope select: the entity namespace esphome::select shadows it here + int ret = ::select(fd + 1, nullptr, &writefds, nullptr, &tv); +#endif + if (ret < 0) { + err_out = errno; + return ConnectPollResult::CONNECT_POLL_ERROR; + } + if (ret == 0 || !FD_ISSET(fd, &writefds)) { + return ConnectPollResult::CONNECT_POLL_PENDING; + } + int error = 0; + socklen_t len = sizeof(error); + if (sock.getsockopt(SOL_SOCKET, SO_ERROR, &error, &len) != 0) { + err_out = errno; + return ConnectPollResult::CONNECT_POLL_ERROR; + } + if (error != 0) { + err_out = error; + return ConnectPollResult::CONNECT_POLL_ERROR; + } + return ConnectPollResult::CONNECT_POLL_CONNECTED; +} +#endif + socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port) { #if USE_NETWORK_IPV6 if (addrlen < sizeof(sockaddr_in6)) { diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index eb8870786d..4af35c4f98 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -145,6 +145,19 @@ inline socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const st /// Set a sockaddr to the any address and specified port for the IP version used by socket_ip(). socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port); +#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) +enum class ConnectPollResult : uint8_t { + CONNECT_POLL_PENDING, + CONNECT_POLL_CONNECTED, + CONNECT_POLL_ERROR, +}; + +/// Check a non-blocking connect() for completion without blocking. On +/// CONNECT_POLL_ERROR, err_out holds the socket's SO_ERROR, or errno when the +/// poll itself failed. +ConnectPollResult poll_connect(Socket &sock, int &err_out); +#endif + /// Format sockaddr into caller-provided buffer, returns length written (excluding null) size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::span buf);