From 0e04b230fe92fd4350b2d6db14fe5504831a61fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 6 Oct 2026 22:22:14 -0500 Subject: [PATCH] [api] Add outgoing connections so the device can dial Home Assistant (or anything that implements native API) (#18919) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/api/__init__.py | 104 +++++- esphome/components/api/api.proto | 9 + esphome/components/api/api_connection.cpp | 16 + esphome/components/api/api_connection.h | 20 ++ esphome/components/api/api_frame_helper.h | 3 +- .../components/api/api_frame_helper_noise.cpp | 32 +- .../components/api/api_frame_helper_noise.h | 8 + .../api/api_outgoing_connection.cpp | 318 ++++++++++++++++++ .../components/api/api_outgoing_connection.h | 120 +++++++ esphome/components/api/api_pb2.cpp | 11 + esphome/components/api/api_pb2.h | 10 +- esphome/components/api/api_pb2_dump.cpp | 6 + esphome/components/api/api_server.cpp | 163 ++++++--- esphome/components/api/api_server.h | 24 ++ esphome/core/defines.h | 3 + .../api/test_outgoing_connection.py | 155 +++++++++ .../api/test_outgoing_connection.yaml | 17 + .../test-outgoing-connection.bk72xx-ard.yaml | 11 + .../test-outgoing-connection.esp32-idf.yaml | 14 + .../test-outgoing-connection.esp8266-ard.yaml | 11 + .../api/test-outgoing-connection.host.yaml | 9 + .../test-outgoing-connection.rp2040-ard.yaml | 11 + .../fixtures/api_outgoing_connection.yaml | 14 + .../api_outgoing_connection_remembered.yaml | 13 + .../test_api_outgoing_connection.py | 175 ++++++++++ 25 files changed, 1217 insertions(+), 60 deletions(-) create mode 100644 esphome/components/api/api_outgoing_connection.cpp create mode 100644 esphome/components/api/api_outgoing_connection.h create mode 100644 tests/component_tests/api/test_outgoing_connection.py create mode 100644 tests/component_tests/api/test_outgoing_connection.yaml create mode 100644 tests/components/api/test-outgoing-connection.bk72xx-ard.yaml create mode 100644 tests/components/api/test-outgoing-connection.esp32-idf.yaml create mode 100644 tests/components/api/test-outgoing-connection.esp8266-ard.yaml create mode 100644 tests/components/api/test-outgoing-connection.host.yaml create mode 100644 tests/components/api/test-outgoing-connection.rp2040-ard.yaml create mode 100644 tests/integration/fixtures/api_outgoing_connection.yaml create mode 100644 tests/integration/fixtures/api_outgoing_connection_remembered.yaml create mode 100644 tests/integration/test_api_outgoing_connection.py diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 764a3d0f1e..9b65f052cc 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -1,3 +1,4 @@ +from ipaddress import IPv4Address, IPv6Address import logging import re from typing import Any @@ -5,7 +6,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 @@ -26,6 +27,8 @@ from esphome.const import ( CONF_CAPTURE_RESPONSE, CONF_DATA, CONF_DATA_TEMPLATE, + CONF_DELAY, + CONF_ENABLE_IPV6, CONF_ENCRYPTION, CONF_EVENT, CONF_ID, @@ -49,7 +52,9 @@ from esphome.const import ( ) from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority from esphome.cpp_generator import MockObj, TemplateArgsType +import esphome.final_validate as fv from esphome.helpers import fnv1_hash +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigFragmentType, ConfigType # Compat alias: downstream consumers (e.g. device-builder) referenced the @@ -135,6 +140,7 @@ CONF_HOMEASSISTANT_SERVICES = "homeassistant_services" CONF_HOMEASSISTANT_STATES = "homeassistant_states" CONF_LISTEN_BACKLOG = "listen_backlog" CONF_MAX_SEND_QUEUE = "max_send_queue" +CONF_OUTGOING_CONNECTION = "outgoing_connection" CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only" # Schema defaults that also match the C++ initializers in api_server.h; codegen @@ -293,9 +299,72 @@ def _consume_api_sockets(config: ConfigType) -> ConfigType: # (not max_connections, which is the upper limit rarely reached) socket.consume_sockets(3, "api")(config) socket.consume_sockets(1, "api", socket.SocketType.TCP_LISTEN)(config) + if CONF_OUTGOING_CONNECTION in config: + socket.consume_sockets(1, "api_outgoing_connection")(config) return config +def _validate_outgoing_connection(config: ConfigType) -> ConfigType: + if (outgoing := config.get(CONF_OUTGOING_CONNECTION)) is None: + return config + if CONF_ENCRYPTION not in config: + raise cv.Invalid( + "outgoing_connection requires 'encryption' so the peer is verified by key", + path=[CONF_OUTGOING_CONNECTION], + ) + # A device with no client reboots once reboot_timeout passes, so a delay that + # reaches it would reboot the device before it ever dials + reboot_timeout = config[CONF_REBOOT_TIMEOUT] + delay = outgoing[CONF_DELAY] + if reboot_timeout.total_milliseconds and delay >= reboot_timeout: + raise cv.Invalid( + f"delay must be shorter than reboot_timeout ({reboot_timeout}), " + "otherwise the device reboots before it dials", + path=[CONF_OUTGOING_CONNECTION, CONF_DELAY], + ) + return config + + +def _validate_outgoing_host(value: str) -> IPv4Address | IPv6Address: + """Only accept an address the device itself can parse. + + Python accepts a scope id, which neither `inet_pton` nor lwIP's `inet6_aton` + takes, and a v4-mapped address is dialed as plain IPv4, needing no IPv6 build. + """ + address = cv.ipaddress(value) + if isinstance(address, IPv6Address): + if address.scope_id is not None: + raise cv.Invalid( + f"{value} carries a scope id, which the device cannot parse; " + "give the address without the '%' part" + ) + if (mapped := address.ipv4_mapped) is not None: + return mapped + return address + + +_OUTGOING_CONNECTION_SCHEMA = cv.Schema( + { + cv.Optional(CONF_HOST): _validate_outgoing_host, + cv.Optional(CONF_PORT, default=6054): cv.port, + # Bounded against reboot_timeout in _validate_outgoing_connection + cv.Optional(CONF_DELAY, default="60s"): cv.positive_time_period_milliseconds, + } +) + + +@schema_extractor("schema") +def _outgoing_connection_schema(config: ConfigType | None) -> ConfigType: + # A bare `outgoing_connection:` block is valid; without a host the device + # dials the remembered last dial-back client + if config is SCHEMA_EXTRACT: + # Let the language-schema dumper walk host, port and delay + return _OUTGOING_CONNECTION_SCHEMA + if config is None: + config = {} + return _OUTGOING_CONNECTION_SCHEMA(config) + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -320,6 +389,7 @@ CONFIG_SCHEMA = cv.All( ): ACTIONS_SCHEMA, cv.Exclusive(CONF_ACTIONS, group_of_exclusion=CONF_ACTIONS): ACTIONS_SCHEMA, cv.Optional(CONF_ENCRYPTION): encryption_schema, + cv.Optional(CONF_OUTGOING_CONNECTION): _outgoing_connection_schema, cv.Optional(CONF_BATCH_DELAY, default=DEFAULT_BATCH_DELAY): cv.All( cv.positive_time_period_milliseconds, cv.Range(max=cv.TimePeriod(milliseconds=65535)), @@ -375,6 +445,7 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), cv.rename_key(CONF_SERVICES, CONF_ACTIONS), + _validate_outgoing_connection, _consume_api_sockets, _register_provisioning_source, ) @@ -431,7 +502,28 @@ def _validate_esp8266_action_strings(config: ConfigType) -> ConfigType: return config -FINAL_VALIDATE_SCHEMA = _validate_esp8266_action_strings +def _validate_outgoing_host_ipv6(config: ConfigType) -> ConfigType: + """An IPv6 host can never be parsed, so never dialed, without IPv6.""" + if ( + (outgoing := config.get(CONF_OUTGOING_CONNECTION)) is None + or (host := outgoing.get(CONF_HOST)) is None + or host.version != 6 + ): + return config + network_conf = fv.full_config.get().get("network") or {} + if not network_conf.get(CONF_ENABLE_IPV6): + raise cv.Invalid( + "outgoing_connection host is an IPv6 address but IPv6 is not " + "enabled; set 'network: enable_ipv6: true'", + path=[CONF_OUTGOING_CONNECTION, CONF_HOST], + ) + return config + + +FINAL_VALIDATE_SCHEMA = cv.All( + _validate_esp8266_action_strings, + _validate_outgoing_host_ipv6, +) def _add_action_strings( @@ -621,6 +713,13 @@ async def to_code(config: ConfigType) -> None: else: cg.add_define("USE_API_PLAINTEXT") + if (outgoing := config.get(CONF_OUTGOING_CONNECTION)) is not None: + cg.add_define("USE_API_OUTGOING_CONNECTION") + if (host := outgoing.get(CONF_HOST)) is not None: + cg.add_define("API_OUTGOING_CONNECTION_HOST", str(host)) + cg.add_define("API_OUTGOING_CONNECTION_PORT", outgoing[CONF_PORT]) + cg.add_define("API_OUTGOING_CONNECTION_DELAY", outgoing[CONF_DELAY]) + cg.add_define("USE_API") cg.add_global(api_ns.using) @@ -1007,6 +1106,7 @@ _define_filter = filter_source_files_from_defines( "user_services.cpp": "USE_API_USER_DEFINED_ACTIONS", "api_frame_helper_noise.cpp": "USE_API_NOISE", "api_frame_helper_plaintext.cpp": "USE_API_PLAINTEXT", + "api_outgoing_connection.cpp": "USE_API_OUTGOING_CONNECTION", } ) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index a03bf5fc1a..ed7b8e515c 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -113,6 +113,11 @@ message HelloRequest { string client_info = 1; uint32 api_version_major = 2; uint32 api_version_minor = 3; + + // Set by clients that can accept connections the device opens to them + // (see api: outgoing_connection:). The device remembers this client's + // address as the target to dial when no such client is connected. + bool outgoing_connection_target = 4 [(field_ifdef) = "USE_API_OUTGOING_CONNECTION"]; } // Confirmation of successful connection request. @@ -332,6 +337,10 @@ message DeviceInfoResponse { // all-zeros PSK, so the api encryption key can be provisioned without being // sent in plaintext (protects against passive sniffing, not active MITM) bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"]; + + // Device is built with the api outgoing_connection option and can open + // the TCP connection to a dial-back target itself + bool api_outgoing_connection_supported = 27 [(field_ifdef) = "USE_API_OUTGOING_CONNECTION"]; } // ==================== DEVICE CAPABILITIES ==================== diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index e50bf2f722..0a2fe13e6a 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1839,6 +1839,19 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { // Auto-authenticate - password auth was removed in ESPHome 2026.1.0 this->complete_authentication_(); +#ifdef USE_API_OUTGOING_CONNECTION + // With a PSK set only key-verified transports reach hello: plaintext and + // zero-PSK are rejected, and pre-activation sessions are force-closed + if (msg.outgoing_connection_target && !this->flags_.outgoing_connection_target) { + if (this->parent_->get_noise_ctx().has_psk()) { + this->flags_.outgoing_connection_target = true; + this->parent_->on_outgoing_target_client(this); + } else { + this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Dial-back target refused; no key active")); + } + } +#endif + return this->send_message(resp); } @@ -1961,6 +1974,9 @@ bool APIConnection::send_device_info_response_() { // one) so this advertisement survives the plaintext removal in 2027.2.0. resp.api_encryption_provisionable = !this->parent_->get_noise_ctx().has_psk(); #endif +#ifdef USE_API_OUTGOING_CONNECTION + resp.api_outgoing_connection_supported = true; +#endif #endif #ifdef USE_DEVICES size_t device_index = 0; diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 0b8a1dc2b2..dacca57369 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -378,6 +378,23 @@ class APIConnection final : public APIServerConnectionBase { return this->helper_->get_peername_to(buf); } +#ifdef USE_API_OUTGOING_CONNECTION + /// Get the peer address itself, for remembering a dial-back target + int getpeername(struct sockaddr *addr, socklen_t *addrlen) const { return this->helper_->getpeername(addr, addrlen); } + /// Outgoing connection: send our server hello immediately so the peer can + /// pick the matching key. Outgoing connections are only dialed when a PSK + /// is set, so the helper is always the noise helper. Call after start(). + void mark_outgoing() { + if (this->flags_.remove) { + return; // start() failed; the connection is already being torn down + } + APIError err = static_cast(this->helper_.get())->send_server_hello_first(); + if (err != APIError::OK) { + this->fatal_error_with_log_(LOG_STR("Server hello failed"), err); + } + } +#endif + protected: bool try_to_clear_buffer_slow_(bool log_out_of_space); @@ -734,6 +751,9 @@ class APIConnection final : public APIServerConnectionBase { uint8_t batch_first_message : 1; // For batch buffer allocation uint8_t should_try_send_immediately : 1; // True after initial states are sent uint8_t may_have_remaining_data : 1; // Read loop hit limit, retry without ready check +#ifdef USE_API_OUTGOING_CONNECTION + uint8_t outgoing_connection_target : 1; // Client declared itself a dial-back target in its hello +#endif #ifdef HAS_PROTO_MESSAGE_DUMP uint8_t log_only_mode : 1; #endif diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index a68a0ad0d8..96c302745c 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -285,7 +285,8 @@ class APIFrameHelper { DATA = 5, CLOSED = 6, FAILED = 7, - EXPLICIT_REJECT = 8, // Noise only + EXPLICIT_REJECT = 8, // Noise only + CLIENT_HELLO_OUTGOING = 9, // Noise only: like CLIENT_HELLO but the server hello already went out (outgoing conn) }; // Fast inline state check for read_packet/write_protobuf_messages hot path. diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index e726a7c967..21ef085c53 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -70,6 +70,13 @@ APIError APINoiseFrameHelper::init() { state_ = State::CLIENT_HELLO; return APIError::OK; } +#ifdef USE_API_OUTGOING_CONNECTION +APIError APINoiseFrameHelper::send_server_hello_first() { + // The peer needs our name and MAC to pick the key before its first message + this->state_ = State::CLIENT_HELLO_OUTGOING; + return this->send_server_hello_frame_(); +} +#endif #ifdef USE_API_PLAINTEXT APIError APINoiseFrameHelper::init_from_handoff(const uint8_t *header, uint8_t header_len) { APIError err = this->init(); @@ -242,6 +249,9 @@ APIError APINoiseFrameHelper::state_action_() { HELPER_LOG("Bad state for method: %d", (int) this->state_); return APIError::BAD_STATE; case State::CLIENT_HELLO: +#ifdef USE_API_OUTGOING_CONNECTION + case State::CLIENT_HELLO_OUTGOING: +#endif return this->state_action_client_hello_(); case State::SERVER_HELLO: return this->state_action_server_hello_(); @@ -274,11 +284,16 @@ APIError APINoiseFrameHelper::state_action_client_hello_() { std::memcpy(dst + 2, this->rx_buf_.data(), rx_size); } +#ifdef USE_API_OUTGOING_CONNECTION + if (this->state_ == State::CLIENT_HELLO_OUTGOING) { + // Server hello already went out at handoff + return this->start_handshake_(); + } +#endif state_ = State::SERVER_HELLO; return APIError::OK; } -APIError APINoiseFrameHelper::state_action_server_hello_() { - // send server hello +APIError APINoiseFrameHelper::send_server_hello_frame_() { const auto &name = App.get_name(); char mac[MAC_ADDRESS_BUFFER_SIZE]; get_mac_address_into_buffer(mac); @@ -302,15 +317,18 @@ APIError APINoiseFrameHelper::state_action_server_hello_() { // node mac, terminated by null byte std::memcpy(msg + mac_offset, mac, MAC_ADDRESS_BUFFER_SIZE); - APIError aerr = write_frame_(msg, total_size); + return write_frame_(msg, total_size); +} +APIError APINoiseFrameHelper::state_action_server_hello_() { + APIError aerr = this->send_server_hello_frame_(); if (aerr != APIError::OK) return aerr; - - // start handshake - aerr = init_handshake_(); + return this->start_handshake_(); +} +APIError APINoiseFrameHelper::start_handshake_() { + APIError aerr = init_handshake_(); if (aerr != APIError::OK) return aerr; - state_ = State::HANDSHAKE; return APIError::OK; } diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index 366751738e..a807d6e5da 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -28,6 +28,12 @@ class APINoiseFrameHelper final : public APIFrameHelper { // Seeds the already-read header bytes and pumps the handshake state machine // until it would block. APIError init_from_handoff(const uint8_t *header, uint8_t header_len); +#endif +#ifdef USE_API_OUTGOING_CONNECTION + // Send the server hello immediately so the peer can pick the key before + // its PSK-mixed message. Call after init(); the mode is tracked in state_ + // so the helper does not grow. + APIError send_server_hello_first(); #endif APIError loop() override; APIError read_packet(ReadPacketBuffer *buffer) override; @@ -39,6 +45,8 @@ class APINoiseFrameHelper final : public APIFrameHelper { APIError state_action_(); APIError state_action_client_hello_(); APIError state_action_server_hello_(); + APIError send_server_hello_frame_(); + APIError start_handshake_(); APIError state_action_handshake_(); APIError state_action_handshake_read_(); APIError state_action_handshake_write_(); diff --git a/esphome/components/api/api_outgoing_connection.cpp b/esphome/components/api/api_outgoing_connection.cpp new file mode 100644 index 0000000000..c86107cef0 --- /dev/null +++ b/esphome/components/api/api_outgoing_connection.cpp @@ -0,0 +1,318 @@ +#include "api_outgoing_connection.h" +#if defined(USE_API) && defined(USE_API_OUTGOING_CONNECTION) + +#include "api_connection.h" +#include "api_server.h" +#include "esphome/components/network/util.h" +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include +#include +#include +#include + +namespace esphome::api { + +static const char *const TAG = "api.outgoing"; + +#ifndef API_OUTGOING_CONNECTION_HOST +static constexpr uint32_t OUTGOING_TARGET_PREF_HASH = 629847102UL; +#endif + +#ifndef API_OUTGOING_CONNECTION_HOST +// Read the connection's peer address into target; false when unavailable or +// of a family this build cannot dial +static bool peer_to_target(APIConnection *conn, SavedOutgoingTarget &target) { + // Zeroed because the raw lwIP getpeername() leaves sin6_scope_id untouched + struct sockaddr_storage peer = {}; + socklen_t peer_len = sizeof(peer); + if (conn->getpeername((struct sockaddr *) &peer, &peer_len) != 0) { + return false; + } + const sa_family_t family = ((struct sockaddr *) &peer)->sa_family; +#if USE_NETWORK_IPV6 + if (family == AF_INET6) { + const auto *addr6 = reinterpret_cast(&peer); + const auto *bytes = reinterpret_cast(&addr6->sin6_addr); + uint32_t prefix[3]; + memcpy(prefix, bytes, sizeof(prefix)); + // A dual-stack listener reports an IPv4 peer as ::ffff:a.b.c.d + if (prefix[0] == 0 && prefix[1] == 0 && prefix[2] == htonl(0xFFFFUL)) { + target.family = AF_INET; + memcpy(target.addr, bytes + sizeof(prefix), sizeof(struct in_addr)); + return true; + } + // A link-local target is only reachable through the interface it came in + // on. Device platforms number interfaces from one; a host build can hand + // out an index too large to store, and a truncated one dials the wrong + // interface, so that target is not remembered at all. + if (addr6->sin6_scope_id > UINT8_MAX) { + return false; + } + target.family = AF_INET6; + memcpy(target.addr, bytes, sizeof(target.addr)); + target.scope_id = static_cast(addr6->sin6_scope_id); + return true; + } +#endif + if (family != AF_INET) { + return false; + } + const auto *addr4 = reinterpret_cast(&peer); + target.family = AF_INET; + memcpy(target.addr, &addr4->sin_addr, sizeof(addr4->sin_addr)); + return true; +} +#endif + +socklen_t OutgoingConnectionManager::target_sockaddr_(struct sockaddr_storage *addr) const { +#ifdef API_OUTGOING_CONNECTION_HOST + // Validation only lets through a literal both inet_pton and inet6_aton + // accept, so this cannot fail + return socket::set_sockaddr((struct sockaddr *) addr, sizeof(*addr), API_OUTGOING_CONNECTION_HOST, + API_OUTGOING_CONNECTION_PORT); +#else +#if USE_NETWORK_IPV6 + if (this->saved_.family == AF_INET6) { + auto *addr6 = reinterpret_cast(addr); + memset(addr6, 0, sizeof(*addr6)); + addr6->sin6_family = AF_INET6; + addr6->sin6_port = htons(API_OUTGOING_CONNECTION_PORT); + memcpy(&addr6->sin6_addr, this->saved_.addr, sizeof(this->saved_.addr)); + addr6->sin6_scope_id = this->saved_.scope_id; + return sizeof(*addr6); + } +#endif + if (this->saved_.family != AF_INET) { + return 0; + } + auto *addr4 = reinterpret_cast(addr); + memset(addr4, 0, sizeof(*addr4)); + addr4->sin_family = AF_INET; + addr4->sin_port = htons(API_OUTGOING_CONNECTION_PORT); + memcpy(&addr4->sin_addr, this->saved_.addr, sizeof(addr4->sin_addr)); + return sizeof(*addr4); +#endif +} + +#ifndef API_OUTGOING_CONNECTION_HOST +void OutgoingConnectionManager::format_target_(std::span buf) const { + struct sockaddr_storage addr; + socklen_t addr_len = this->target_sockaddr_(&addr); + if (addr_len == 0) { + buf[0] = '\0'; + return; + } + // Clears buf itself if it cannot format the address + socket::format_sockaddr_to((struct sockaddr *) &addr, addr_len, buf); +} +#endif + +void OutgoingConnectionManager::setup() { +#ifndef API_OUTGOING_CONNECTION_HOST + this->target_pref_ = global_preferences->make_preference(OUTGOING_TARGET_PREF_HASH, true); + struct sockaddr_storage addr; + // dump_config() prints whichever target this leaves in place + if (this->target_pref_.load(&this->saved_) && this->target_sockaddr_(&addr) != 0) { + this->host_persisted_ = true; + } else { + // Never saved, failed its size or CRC check, or holds an unknown family + this->saved_ = {}; + } +#endif +} + +void OutgoingConnectionManager::loop(APIServer *server) { + if (server->has_outgoing_target_client_()) { + return; // on_target_client() already reset the dial state + } + if (this->dialed_conn_ != nullptr) { + // A live dialed session (flagged or not, e.g. a host: peer) is the + // target; a silent one dies on the handshake timeout + return; + } + const uint32_t now = App.get_loop_component_start_time(); + switch (this->state_) { + case DialState::DIAL_STATE_IDLE: + // Target went away; give it the configured delay to reconnect first + this->schedule_wait_(now, IDLE_WAIT_MS); + break; + case DialState::DIAL_STATE_WAITING: + if (now - this->state_ts_ >= this->wait_) { + this->try_dial_(server, now); + } + break; + case DialState::DIAL_STATE_CONNECTING: + this->poll_connect_(server, now); + break; + } +} + +void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) { + if (!network::is_connected()) { + // Flips within seconds of boot; recheck fast so a deep sleep wake + // window is not spent waiting + this->schedule_wait_(now, NETWORK_RETRY_MS); + return; + } + struct sockaddr_storage addr; + socklen_t addr_len = this->target_sockaddr_(&addr); + const bool at_limit = server->at_client_limit_(); + // No target is the steady state until a dial-back client has ever connected + if (addr_len == 0 || at_limit || !server->noise_ctx_.has_psk()) { + // Repeats for as long as the reason holds, so keep it out of debug logs + ESP_LOGV(TAG, "Not dialing: %s", + addr_len == 0 ? LOG_STR_LITERAL("no target") + : (at_limit ? LOG_STR_LITERAL("max connections") : LOG_STR_LITERAL("no key"))); + // Not a dial failure; retry without escalating the backoff + this->schedule_wait_(now, PRECONDITION_RETRY_MS); + return; + } + this->dial_socket_ = socket::socket_loop_monitored(((struct sockaddr *) &addr)->sa_family, SOCK_STREAM, IPPROTO_TCP); + if (!this->dial_socket_ || this->dial_socket_->setblocking(false) != 0) { + ESP_LOGW(TAG, "Socket %s failed: errno %d", + this->dial_socket_ ? LOG_STR_LITERAL("setblocking") : LOG_STR_LITERAL("create"), errno); + this->schedule_retry_(now); + return; + } +#ifdef API_OUTGOING_CONNECTION_HOST + ESP_LOGD(TAG, "Dialing " API_OUTGOING_CONNECTION_HOST ":%u", API_OUTGOING_CONNECTION_PORT); +#else + char host[socket::SOCKADDR_STR_LEN]; + socket::format_sockaddr_to((struct sockaddr *) &addr, addr_len, host); + ESP_LOGD(TAG, "Dialing %s:%u", host, API_OUTGOING_CONNECTION_PORT); +#endif + int err = this->dial_socket_->connect((struct sockaddr *) &addr, addr_len); + if (err == 0) { + // Immediate success (possible for localhost) + this->handoff_(server, now); + return; + } + if (errno != EINPROGRESS) { + ESP_LOGW(TAG, "Connect failed: %d", errno); + this->schedule_retry_(now); + return; + } + this->state_ = DialState::DIAL_STATE_CONNECTING; + this->state_ts_ = now; + this->last_poll_ = now; +} + +void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) { + if (now - this->state_ts_ >= CONNECT_TIMEOUT_MS) { + ESP_LOGW(TAG, "Connect timeout"); + this->schedule_retry_(now); + return; + } + if (now - this->last_poll_ < CONNECT_POLL_INTERVAL_MS) { + return; + } + this->last_poll_ = now; + int err = 0; + switch (socket::poll_connect(*this->dial_socket_, err)) { + case socket::ConnectPollResult::CONNECT_POLL_RESULT_PENDING: + break; + case socket::ConnectPollResult::CONNECT_POLL_RESULT_CONNECTED: + this->handoff_(server, now); + break; + case socket::ConnectPollResult::CONNECT_POLL_RESULT_ERROR: + ESP_LOGW(TAG, "Connect failed: %d", err); + this->schedule_retry_(now); + break; + } +} + +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) { + // 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 + this->state_ = DialState::DIAL_STATE_IDLE; +} + +void OutgoingConnectionManager::schedule_wait_(uint32_t now, uint32_t wait) { + this->dial_socket_.reset(); // no-op when the socket was handed off + this->state_ = DialState::DIAL_STATE_WAITING; + this->state_ts_ = now; + this->wait_ = wait; +} + +void OutgoingConnectionManager::schedule_retry_(uint32_t now) { + // +/-20% jitter so a fleet of devices does not retry one server in lockstep + const uint32_t jitter_span = this->backoff_ / 5; + this->schedule_wait_(now, this->backoff_ - jitter_span + (random_uint32() % (2 * jitter_span + 1))); + this->backoff_ = std::min(this->backoff_ * 2, BACKOFF_MAX_MS); +} + +void OutgoingConnectionManager::on_client_removed(APIConnection *conn, bool was_authenticated) { + if (conn != this->dialed_conn_) { + return; + } + this->dialed_conn_ = nullptr; + 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_(App.get_loop_component_start_time()); + } +} + +void OutgoingConnectionManager::on_target_client(APIConnection *conn) { + // The target is connected; stop any dial in flight and reset the backoff. + // A dialed connection stays tracked unless it is this one: an inbound + // target must not orphan a still-open dial. + this->dial_socket_.reset(); + if (conn == this->dialed_conn_) { + this->dialed_conn_ = nullptr; + } + this->state_ = DialState::DIAL_STATE_IDLE; + this->backoff_ = BACKOFF_MIN_MS; +#ifndef API_OUTGOING_CONNECTION_HOST + SavedOutgoingTarget target{}; + if (!peer_to_target(conn, target)) { + ESP_LOGW(TAG, "Not remembering this target; its address cannot be dialed"); + return; + } + if (this->host_persisted_ && memcmp(&target, &this->saved_, sizeof(target)) == 0) { + return; // unchanged and already on flash; avoid flash wear + } + // 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; + if (!this->persist_target_()) { + ESP_LOGW(TAG, "Failed to save target"); + return; + } + char host[socket::SOCKADDR_STR_LEN]; + this->format_target_(host); + ESP_LOGD(TAG, "Remembered %s as the dial target", host); +#endif +} + +void OutgoingConnectionManager::dump_config() const { + // The boot delay differs from delay: on deep sleep builds, so print the + // value that actually applies + ESP_LOGCONFIG(TAG, + " Outgoing connection port: %u\n" + " Outgoing connection boot delay: %" PRIu32 "ms", + API_OUTGOING_CONNECTION_PORT, BOOT_WAIT_MS); + // Both forms keep their text out of RAM on ESP8266: in the format string, + // or through LOG_STR_LITERAL +#ifdef API_OUTGOING_CONNECTION_HOST + ESP_LOGCONFIG(TAG, " Outgoing connection host: " API_OUTGOING_CONNECTION_HOST); +#else + char buf[socket::SOCKADDR_STR_LEN]; + this->format_target_(buf); + ESP_LOGCONFIG(TAG, " Outgoing connection host: %s", buf[0] == '\0' ? LOG_STR_LITERAL("none remembered yet") : buf); +#endif +} + +} // namespace esphome::api +#endif // USE_API && USE_API_OUTGOING_CONNECTION diff --git a/esphome/components/api/api_outgoing_connection.h b/esphome/components/api/api_outgoing_connection.h new file mode 100644 index 0000000000..16d50c3425 --- /dev/null +++ b/esphome/components/api/api_outgoing_connection.h @@ -0,0 +1,120 @@ +#pragma once + +#include "esphome/core/defines.h" +#if defined(USE_API) && defined(USE_API_OUTGOING_CONNECTION) + +#ifndef USE_API_NOISE +#error "api outgoing_connection needs noise encryption so the peer is verified by key" +#endif + +#include "esphome/components/socket/socket.h" +#include "esphome/core/preferences.h" + +#include + +namespace esphome::api { + +class APIServer; +class APIConnection; + +// Room for an IPv6 address in every build, so a remembered IPv4 target is +// still dialed after enable_ipv6 is turned on. A size that followed the build +// would also shift every preference registered after this one on ESP8266, +// where slots are positional. An IPv6 target on a build without IPv6 is +// dropped by target_sockaddr_() and relearned. +// Bytes in an IPv6 address +static constexpr size_t TARGET_ADDR_LEN = 16; + +struct SavedOutgoingTarget { + // 0 when none is remembered, else AF_INET or AF_INET6 + uint8_t family; + // Network order, IPv4 in the first four bytes and the rest zero + uint8_t addr[TARGET_ADDR_LEN]; + // Interface a link-local IPv6 target is reachable on, 0 when it needs none. + // Free in flash: the record still rounds up to the same five words. + uint8_t scope_id; +} PACKED; // NOLINT + +/// Dials out when no dial-back target client is connected. Only the TCP +/// direction flips: the device stays the Noise responder, so both sides +/// still verify by key. Targets the YAML host or the last remembered client. +class OutgoingConnectionManager { + public: + void setup(); + void loop(APIServer *server); + /// A key-verified client declared itself a dial-back target; last one wins + void on_target_client(APIConnection *conn); + /// Clears the dialed-connection gate; dying unauthenticated escalates the backoff + void on_client_removed(APIConnection *conn, bool was_authenticated); + void on_shutdown() { this->dial_socket_.reset(); } + void dump_config() const; + + protected: + enum class DialState : uint8_t { + DIAL_STATE_IDLE, + DIAL_STATE_WAITING, + DIAL_STATE_CONNECTING, + }; + + static constexpr uint32_t BACKOFF_MIN_MS = 5000; + static constexpr uint32_t BACKOFF_MAX_MS = 300000; + static constexpr uint32_t CONNECT_TIMEOUT_MS = 10000; + static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 250; + static constexpr uint32_t NETWORK_RETRY_MS = 500; + static constexpr uint32_t PRECONDITION_RETRY_MS = 5000; + // A deep sleep wake window is too short to spend on the delay, so those + // builds dial out as soon as the target is gone +#ifdef USE_DEEP_SLEEP + static constexpr uint32_t BOOT_WAIT_MS = 0; + static constexpr uint32_t IDLE_WAIT_MS = BACKOFF_MIN_MS; +#else + static constexpr uint32_t BOOT_WAIT_MS = API_OUTGOING_CONNECTION_DELAY; + static constexpr uint32_t IDLE_WAIT_MS = API_OUTGOING_CONNECTION_DELAY; +#endif + + void try_dial_(APIServer *server, uint32_t now); + void poll_connect_(APIServer *server, uint32_t now); + // Hand the connected socket to the server and gate on the new connection + void handoff_(APIServer *server, uint32_t now); + // Close any half-open dial and wait a jittered backoff before retrying + void schedule_retry_(uint32_t now); + // Wait without escalating the backoff (used for unmet preconditions) + void schedule_wait_(uint32_t now, uint32_t wait); + /// Fill addr with the target and return its length, or 0 when there is none + socklen_t target_sockaddr_(struct sockaddr_storage *addr) const; +#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_; + } + /// Format the remembered target for a log line; empty when there is none + void format_target_(std::span buf) const; +#endif + + // Pointers first (4 bytes each on 32-bit) + std::unique_ptr dial_socket_; + // Compared only, never dereferenced + APIConnection *dialed_conn_{nullptr}; +#ifndef API_OUTGOING_CONNECTION_HOST + ESPPreferenceObject target_pref_; +#endif + + // 4-byte types + uint32_t backoff_{BACKOFF_MIN_MS}; + uint32_t wait_{BOOT_WAIT_MS}; + uint32_t state_ts_{0}; + uint32_t last_poll_{0}; + + // Byte-aligned types last +#ifndef API_OUTGOING_CONNECTION_HOST + SavedOutgoingTarget saved_{}; + // False while saved_ holds a value the flash write failed for; retried on + // the next flagged hello + bool host_persisted_{false}; +#endif + DialState state_{DialState::DIAL_STATE_WAITING}; +}; + +} // namespace esphome::api +#endif // USE_API && USE_API_OUTGOING_CONNECTION diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 1d1f594aa7..fe8992337d 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -20,6 +20,11 @@ void HelloRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, p case proto_tag(3, WIRE_TYPE_VARINT): msg.api_version_minor = value.as_varint(); break; +#ifdef USE_API_OUTGOING_CONNECTION + case proto_tag(4, WIRE_TYPE_VARINT): + msg.outgoing_connection_target = value.as_bool(); + break; +#endif } } uint8_t *HelloResponse::encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) { @@ -176,6 +181,9 @@ uint8_t *DeviceInfoResponse::encode_msg(const void *self, ProtoWriteBuffer &buff #endif #ifdef USE_API_NOISE pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.api_encryption_provisionable); +#endif +#ifdef USE_API_OUTGOING_CONNECTION + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 27, msg.api_outgoing_connection_supported); #endif return pos; } @@ -242,6 +250,9 @@ uint32_t DeviceInfoResponse::calc_size_msg(const void *self) { #endif #ifdef USE_API_NOISE size += ProtoSize::calc_bool(2, msg.api_encryption_provisionable); +#endif +#ifdef USE_API_OUTGOING_CONNECTION + size += ProtoSize::calc_bool(2, msg.api_outgoing_connection_supported); #endif return size; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index b4e3d4c55b..d94cd709d8 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -417,13 +417,16 @@ class CommandProtoMessage : public ProtoDecodableMessage { class HelloRequest final : public ProtoDecodableMessage { public: static constexpr uint16_t MESSAGE_TYPE = 1; - static constexpr uint8_t ESTIMATED_SIZE = 17; + static constexpr uint8_t ESTIMATED_SIZE = 19; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("hello_request"); } #endif StringRef client_info{}; uint32_t api_version_major{0}; uint32_t api_version_minor{0}; +#ifdef USE_API_OUTGOING_CONNECTION + bool outgoing_connection_target{false}; +#endif void decode(const uint8_t *buffer, size_t length) { ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); } @@ -579,7 +582,7 @@ class SerialProxyInfo final : public ProtoMessage { class DeviceInfoResponse final : public ProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 10; - static constexpr uint16_t ESTIMATED_SIZE = 312; + static constexpr uint16_t ESTIMATED_SIZE = 315; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("device_info_response"); } #endif @@ -637,6 +640,9 @@ class DeviceInfoResponse final : public ProtoMessage { #endif #ifdef USE_API_NOISE bool api_encryption_provisionable{false}; +#endif +#ifdef USE_API_OUTGOING_CONNECTION + bool api_outgoing_connection_supported{false}; #endif static uint8_t *encode_msg(const void *self, ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index d854a0731b..b884507618 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -897,6 +897,9 @@ const char *HelloRequest::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("client_info"), this->client_info); dump_field(out, ESPHOME_PSTR("api_version_major"), this->api_version_major); dump_field(out, ESPHOME_PSTR("api_version_minor"), this->api_version_minor); +#ifdef USE_API_OUTGOING_CONNECTION + dump_field(out, ESPHOME_PSTR("outgoing_connection_target"), this->outgoing_connection_target); +#endif return out.c_str(); } const char *HelloResponse::dump_to(DumpBuffer &out) const { @@ -1020,6 +1023,9 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const { #endif #ifdef USE_API_NOISE dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable); +#endif +#ifdef USE_API_OUTGOING_CONNECTION + dump_field(out, ESPHOME_PSTR("api_outgoing_connection_supported"), this->api_outgoing_connection_supported); #endif return out.c_str(); } diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index da9ca8bc79..600ff2a2fe 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -56,7 +56,12 @@ 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 } void APIServer::setup() { @@ -72,43 +77,6 @@ void APIServer::setup() { #endif #endif - this->socket_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections - if (this->socket_ == nullptr) { - this->socket_failed_(LOG_STR("creation")); - return; - } - int enable = 1; - int err = this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); - if (err != 0) { - ESP_LOGW(TAG, "Socket reuseaddr: errno %d", errno); - // we can still continue - } - err = this->socket_->setblocking(false); - if (err != 0) { - this->socket_failed_(LOG_STR("nonblocking")); - return; - } - - struct sockaddr_storage server; - - socklen_t sl = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->port_); - if (sl == 0) { - this->socket_failed_(LOG_STR("set sockaddr")); - return; - } - - err = this->socket_->bind((struct sockaddr *) &server, sl); - if (err != 0) { - this->socket_failed_(LOG_STR("bind")); - return; - } - - err = this->socket_->listen(this->listen_backlog_); - if (err != 0) { - this->socket_failed_(LOG_STR("listen")); - return; - } - #ifdef USE_LOGGER if (logger::global_logger != nullptr) { logger::global_logger->add_log_callback( @@ -154,6 +122,47 @@ void APIServer::setup() { if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { this->status_set_warning(LOG_STR("waiting for client connection")); } +#ifdef USE_API_OUTGOING_CONNECTION + this->outgoing_conn_.setup(); +#endif + + // Listener last: on failure socket_failed_() returns early, and an + // outgoing_connection build keeps dialing out without one + this->socket_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections + if (this->socket_ == nullptr) { + this->socket_failed_(LOG_STR("creation")); + return; + } + int enable = 1; + int err = this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); + if (err != 0) { + ESP_LOGW(TAG, "Socket reuseaddr: errno %d", errno); + // we can still continue + } + err = this->socket_->setblocking(false); + if (err != 0) { + this->socket_failed_(LOG_STR("nonblocking")); + return; + } + + struct sockaddr_storage server; + + socklen_t sl = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->port_); + if (sl == 0) { + this->socket_failed_(LOG_STR("set sockaddr")); + return; + } + + err = this->socket_->bind((struct sockaddr *) &server, sl); + if (err != 0) { + this->socket_failed_(LOG_STR("bind")); + return; + } + + err = this->socket_->listen(this->listen_backlog_); + if (err != 0) { + this->socket_failed_(LOG_STR("listen")); + } } void APIServer::loop() { @@ -169,6 +178,12 @@ void APIServer::loop() { } #endif +#ifdef USE_API_OUTGOING_CONNECTION + if (!this->shutting_down_) { + this->outgoing_conn_.loop(this); + } +#endif + if (this->api_connection_count_ == 0) { // Check reboot timeout - done in loop to avoid scheduler heap churn // (cancelled scheduler items sit in heap memory until their scheduled time). @@ -241,6 +256,15 @@ void APIServer::remove_client_(uint8_t client_index) { std::string client_peername(client->get_peername_to(peername_buf)); #endif + // Read before the swap-and-reset below destroys the connection + const bool was_authenticated = client->is_authenticated(); +#ifdef USE_API_OUTGOING_CONNECTION + if (client->flags_.outgoing_connection_target) { + this->outgoing_target_count_--; + } + this->outgoing_conn_.on_client_removed(client.get(), was_authenticated); +#endif + // Close socket now (was deferred from on_fatal_error to allow getpeername) client->helper_->close(); @@ -259,9 +283,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")); - this->last_connected_ = App.get_loop_component_start_time(); } #ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER @@ -283,7 +313,7 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() { sock->getpeername_to(peername); // Check if we're at the connection limit - if (this->api_connection_count_ >= MAX_API_CONNECTIONS) { + if (this->at_client_limit_()) { ESP_LOGW(TAG, "Max connections (%d), rejecting %s", MAX_API_CONNECTIONS, peername); // Immediately close - socket destructor will handle cleanup sock.reset(); @@ -292,18 +322,47 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() { ESP_LOGD(TAG, "Accept %s", peername); - auto *conn = new APIConnection(std::move(sock), this); - this->clients_[this->api_connection_count_++].reset(conn); - conn->start(); - - // First client connected - clear warning and update timestamp - if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { - this->status_clear_warning(); - this->last_connected_ = App.get_loop_component_start_time(); - } + this->add_client_(std::move(sock)); } } +APIConnection *APIServer::add_client_(std::unique_ptr sock) { + auto *conn = new APIConnection(std::move(sock), this); // NOLINT(cppcoreguidelines-owning-memory) + this->clients_[this->api_connection_count_++].reset(conn); + conn->start(); + + // First client connected - clear warning. The reboot watchdog timestamp is + // refreshed when an authenticated client is removed (see remove_client_), + // never on bare TCP connects. + if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { + this->status_clear_warning(); + } + return conn; +} + +#ifdef USE_API_OUTGOING_CONNECTION +APIConnection *APIServer::add_outgoing_client_(std::unique_ptr sock) { + // Re-check at the handoff: inbound clients may have taken the last slot and + // the PSK may have been cleared since the dial started (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 ? LOG_STR_LITERAL("max connections") : LOG_STR_LITERAL("no key")); + return nullptr; + } + auto *conn = this->add_client_(std::move(sock)); + // After start(): sends our server hello first so the peer can pick the key + conn->mark_outgoing(); + return conn; +} + +void APIServer::on_outgoing_target_client(APIConnection *conn) { + this->outgoing_target_count_++; + this->outgoing_conn_.on_target_client(conn); +} +#endif + void APIServer::dump_config() { char addr_buf[network::USE_ADDRESS_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, @@ -320,6 +379,9 @@ void APIServer::dump_config() { #else ESP_LOGCONFIG(TAG, " Noise encryption: NO"); #endif +#ifdef USE_API_OUTGOING_CONNECTION + this->outgoing_conn_.dump_config(); +#endif } void APIServer::handle_disconnect(APIConnection *conn) {} @@ -616,6 +678,8 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString if (!c->send_message(req)) { API_LOG_MSG_DROPPED(TAG, "Disconnect request"); } + // Force it: a session from before the key was active must not survive + c->flags_.next_close = true; } }); } @@ -717,6 +781,9 @@ void APIServer::on_shutdown() { // Close the listening socket to prevent new connections this->destroy_socket_(); +#ifdef USE_API_OUTGOING_CONNECTION + this->outgoing_conn_.on_shutdown(); +#endif // Change batch delay to 5ms for quick flushing during shutdown this->batch_delay_ = 5; diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 491ab38342..fc1ca7df13 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -11,6 +11,7 @@ #endif #include "api_pb2.h" #include "api_pb2_service.h" +#include "api_outgoing_connection.h" #include "esphome/components/socket/socket.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" @@ -90,6 +91,10 @@ class APIServer final : public Component void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); } noise::NoiseContext &get_noise_ctx() { return this->noise_ctx_; } #endif // USE_API_NOISE +#ifdef USE_API_OUTGOING_CONNECTION + // Called by APIConnection when a client declares itself a dial-back target in its hello + void on_outgoing_target_client(APIConnection *conn); +#endif void handle_disconnect(APIConnection *conn); #ifdef USE_BINARY_SENSOR @@ -267,6 +272,16 @@ class APIServer final : public Component protected: // Accept incoming socket connections. Only called when socket has pending connections. void __attribute__((noinline)) accept_new_connections_(); + /// Takes the socket into a new connection and starts it; callers must have + /// checked at_client_limit_() first + APIConnection *add_client_(std::unique_ptr sock); + bool at_client_limit_() const { return this->api_connection_count_ >= MAX_API_CONNECTIONS; } +#ifdef USE_API_OUTGOING_CONNECTION + // Returns the new connection, or nullptr (socket dropped) when at the limit + APIConnection *add_outgoing_client_(std::unique_ptr sock); + bool has_outgoing_target_client_() const { return this->outgoing_target_count_ != 0; } + friend class OutgoingConnectionManager; +#endif // Remove a disconnected client by index. Swaps with the last populated slot and resets it. void __attribute__((noinline)) remove_client_(uint8_t client_index); @@ -307,6 +322,8 @@ class APIServer final : public Component delete this->socket_; this->socket_ = nullptr; } + /// Log the failure, drop the listen socket, and mark the component failed + /// unless this build can still dial out void socket_failed_(const LogString *msg); // Pointers and pointer-like types first (4 bytes each) socket::ListenSocket *socket_{nullptr}; @@ -360,6 +377,10 @@ class APIServer final : public Component uint8_t listen_backlog_{4}; // Keep in sync with DEFAULT_LISTEN_BACKLOG in __init__.py bool shutting_down_ = false; uint8_t api_connection_count_{0}; +#ifdef USE_API_OUTGOING_CONNECTION + // Connected clients whose hello declared them a dial-back target + uint8_t outgoing_target_count_{0}; +#endif #if defined(USE_PROVISIONING) && defined(USE_API_NOISE) // Index assigned by the provisioning manager for reporting this transport's state. uint8_t provisioning_source_{0}; @@ -375,6 +396,9 @@ class APIServer final : public Component #endif ESPPreferenceObject noise_pref_; #endif // USE_API_NOISE +#ifdef USE_API_OUTGOING_CONNECTION + OutgoingConnectionManager outgoing_conn_; +#endif }; extern APIServer *global_api_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 1d35b2a67b..8b3319190d 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -266,6 +266,9 @@ #define USE_API_HOMEASSISTANT_SERVICES #define USE_API_HOMEASSISTANT_STATES #define USE_API_NOISE +#define USE_API_OUTGOING_CONNECTION +#define API_OUTGOING_CONNECTION_PORT 6054 +#define API_OUTGOING_CONNECTION_DELAY 60000 #define USE_API_VARINT64 #define USE_API_PLAINTEXT #define USE_API_USER_DEFINED_ACTIONS diff --git a/tests/component_tests/api/test_outgoing_connection.py b/tests/component_tests/api/test_outgoing_connection.py new file mode 100644 index 0000000000..b329e0814e --- /dev/null +++ b/tests/component_tests/api/test_outgoing_connection.py @@ -0,0 +1,155 @@ +"""Tests for the api outgoing_connection option.""" + +from collections.abc import Callable +from ipaddress import IPv4Address +from pathlib import Path + +import pytest + +from esphome.components.api import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 +import esphome.config_validation as cv +from esphome.const import PlatformFramework +from esphome.core import CORE +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + +KEY = "bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=" +ESP32_PLATFORM_DATA = {KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32} + + +def _api_config(outgoing: ConfigType, *, encryption: bool = True) -> ConfigType: + config: ConfigType = {"outgoing_connection": outgoing} + if encryption: + config["encryption"] = {"key": KEY} + return config + + +def test_outgoing_connection_generates_defines( + generate_main: Callable[[str | Path], str], +) -> None: + """A valid config emits the compile-time defines with defaults applied.""" + generate_main("tests/component_tests/api/test_outgoing_connection.yaml") + + defines = {define.name: define.value for define in CORE.defines} + assert "USE_API_OUTGOING_CONNECTION" in defines + assert str(defines["API_OUTGOING_CONNECTION_HOST"]) == '"192.168.1.2"' + assert str(defines["API_OUTGOING_CONNECTION_PORT"]) == "6054" + assert str(defines["API_OUTGOING_CONNECTION_DELAY"]) == "60000" + + +def test_outgoing_connection_bare_block( + set_core_config: SetCoreConfigCallable, +) -> None: + """A bare outgoing_connection: block is valid; the device dials the + remembered last dial-back client.""" + set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA) + config = CONFIG_SCHEMA(_api_config(None)) + outgoing = config["outgoing_connection"] + assert "host" not in outgoing + assert outgoing["port"] == 6054 + + +@pytest.mark.parametrize( + ("delay", "reboot_timeout"), + [("16min", None), ("30s", "30s"), ("31s", "30s")], +) +def test_outgoing_connection_delay_bounded( + set_core_config: SetCoreConfigCallable, + delay: str, + reboot_timeout: str | None, +) -> None: + """A delay the reboot watchdog would cut short is rejected.""" + set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA) + config = _api_config({"delay": delay}) + if reboot_timeout is not None: + config["reboot_timeout"] = reboot_timeout + with pytest.raises(cv.Invalid, match="shorter than reboot_timeout"): + CONFIG_SCHEMA(config) + + +@pytest.mark.parametrize( + ("delay", "reboot_timeout"), + [("20min", "1h"), ("29s", "30s"), ("30min", "0s")], +) +def test_outgoing_connection_delay_within_timeout( + set_core_config: SetCoreConfigCallable, + delay: str, + reboot_timeout: str, +) -> None: + """The bound follows the configured timeout, and a disabled one has none.""" + set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA) + config = _api_config({"delay": delay}) | {"reboot_timeout": reboot_timeout} + assert CONFIG_SCHEMA(config)["outgoing_connection"]["delay"] == cv.time_period( + delay + ) + + +def test_outgoing_connection_requires_encryption( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA) + with pytest.raises(cv.Invalid, match="requires 'encryption'"): + CONFIG_SCHEMA(_api_config({"host": "192.168.1.2"}, encryption=False)) + + +@pytest.mark.parametrize( + "platform_framework", + [PlatformFramework.ESP8266_ARDUINO, PlatformFramework.RP2040_ARDUINO], +) +def test_outgoing_connection_accepts_raw_lwip( + set_core_config: SetCoreConfigCallable, + platform_framework: PlatformFramework, +) -> None: + """The raw lwip_tcp socket these platforms default to can dial out.""" + set_core_config(platform_framework) + config = CONFIG_SCHEMA(_api_config({"host": "192.168.1.2"})) + assert FINAL_VALIDATE_SCHEMA(config) is not None + + +def test_outgoing_connection_rejects_hostnames( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA) + with pytest.raises(cv.Invalid, match="not a valid IP address"): + CONFIG_SCHEMA(_api_config({"host": "homeassistant.local"})) + + +def test_outgoing_connection_rejects_scope_id( + set_core_config: SetCoreConfigCallable, +) -> None: + """A scope id passes Python's parser but not the device's.""" + set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA) + with pytest.raises(cv.Invalid, match="scope id"): + CONFIG_SCHEMA(_api_config({"host": "fe80::1%eth0"})) + + +def test_outgoing_connection_folds_v4_mapped_host( + set_core_config: SetCoreConfigCallable, +) -> None: + """A v4-mapped host becomes plain IPv4, so it needs no IPv6 build.""" + set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA) + config = CONFIG_SCHEMA(_api_config({"host": "::ffff:192.168.1.2"})) + assert config["outgoing_connection"]["host"] == IPv4Address("192.168.1.2") + assert FINAL_VALIDATE_SCHEMA(config) is not None + + +def test_outgoing_connection_ipv6_host_requires_ipv6( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA) + config = CONFIG_SCHEMA(_api_config({"host": "fd00::1"})) + with pytest.raises(cv.Invalid, match="IPv6 is not"): + FINAL_VALIDATE_SCHEMA(config) + + +def test_outgoing_connection_ipv6_host_passes_with_ipv6_enabled( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data=ESP32_PLATFORM_DATA, + full_config={"network": {"enable_ipv6": True}}, + ) + config = CONFIG_SCHEMA(_api_config({"host": "fd00::1"})) + assert FINAL_VALIDATE_SCHEMA(config) is not None diff --git a/tests/component_tests/api/test_outgoing_connection.yaml b/tests/component_tests/api/test_outgoing_connection.yaml new file mode 100644 index 0000000000..0d64968bc6 --- /dev/null +++ b/tests/component_tests/api/test_outgoing_connection.yaml @@ -0,0 +1,17 @@ +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: SomeNetwork + password: SomePassword + +logger: + +api: + encryption: + key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU= + outgoing_connection: + host: 192.168.1.2 diff --git a/tests/components/api/test-outgoing-connection.bk72xx-ard.yaml b/tests/components/api/test-outgoing-connection.bk72xx-ard.yaml new file mode 100644 index 0000000000..97648ec6d2 --- /dev/null +++ b/tests/components/api/test-outgoing-connection.bk72xx-ard.yaml @@ -0,0 +1,11 @@ +packages: + common: !include common.yaml + +wifi: + ssid: MySSID + password: password1 + +# Outgoing connection on the lwip_sockets implementation used by LibreTiny +api: + outgoing_connection: + host: 192.168.1.2 diff --git a/tests/components/api/test-outgoing-connection.esp32-idf.yaml b/tests/components/api/test-outgoing-connection.esp32-idf.yaml new file mode 100644 index 0000000000..04013a8986 --- /dev/null +++ b/tests/components/api/test-outgoing-connection.esp32-idf.yaml @@ -0,0 +1,14 @@ +packages: + common: !include common.yaml + +wifi: + ssid: MySSID + password: password1 + +# Outgoing connection: the device dials out when no dial-back client is +# connected. Requires encryption so the peer is verified by key. +api: + outgoing_connection: + host: 192.168.1.2 + port: 6054 + delay: 60s diff --git a/tests/components/api/test-outgoing-connection.esp8266-ard.yaml b/tests/components/api/test-outgoing-connection.esp8266-ard.yaml new file mode 100644 index 0000000000..0cf4b0b4b4 --- /dev/null +++ b/tests/components/api/test-outgoing-connection.esp8266-ard.yaml @@ -0,0 +1,11 @@ +packages: + common: !include common.yaml + +wifi: + ssid: MySSID + password: password1 + +# Outgoing connection on the raw lwip_tcp implementation used by ESP8266 and RP2040 +api: + outgoing_connection: + host: 192.168.1.2 diff --git a/tests/components/api/test-outgoing-connection.host.yaml b/tests/components/api/test-outgoing-connection.host.yaml new file mode 100644 index 0000000000..deb45b99f9 --- /dev/null +++ b/tests/components/api/test-outgoing-connection.host.yaml @@ -0,0 +1,9 @@ +packages: + common: !include common.yaml + +network: + +# No host set: the device dials the last remembered Home Assistant address +api: + outgoing_connection: + delay: 30s diff --git a/tests/components/api/test-outgoing-connection.rp2040-ard.yaml b/tests/components/api/test-outgoing-connection.rp2040-ard.yaml new file mode 100644 index 0000000000..0cf4b0b4b4 --- /dev/null +++ b/tests/components/api/test-outgoing-connection.rp2040-ard.yaml @@ -0,0 +1,11 @@ +packages: + common: !include common.yaml + +wifi: + ssid: MySSID + password: password1 + +# Outgoing connection on the raw lwip_tcp implementation used by ESP8266 and RP2040 +api: + outgoing_connection: + host: 192.168.1.2 diff --git a/tests/integration/fixtures/api_outgoing_connection.yaml b/tests/integration/fixtures/api_outgoing_connection.yaml new file mode 100644 index 0000000000..b8bc7c5bd8 --- /dev/null +++ b/tests/integration/fixtures/api_outgoing_connection.yaml @@ -0,0 +1,14 @@ +esphome: + name: outgoing-conn-test + +host: + +logger: + +api: + encryption: + key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU= + outgoing_connection: + host: 127.0.0.1 + port: OUTGOING_PORT + delay: 1s diff --git a/tests/integration/fixtures/api_outgoing_connection_remembered.yaml b/tests/integration/fixtures/api_outgoing_connection_remembered.yaml new file mode 100644 index 0000000000..4cff031fd3 --- /dev/null +++ b/tests/integration/fixtures/api_outgoing_connection_remembered.yaml @@ -0,0 +1,13 @@ +esphome: + name: outgoing-conn-test + +host: + +logger: + +api: + encryption: + key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU= + outgoing_connection: + port: OUTGOING_PORT + delay: 1s diff --git a/tests/integration/test_api_outgoing_connection.py b/tests/integration/test_api_outgoing_connection.py new file mode 100644 index 0000000000..8490f302d0 --- /dev/null +++ b/tests/integration/test_api_outgoing_connection.py @@ -0,0 +1,175 @@ +"""Integration tests for the api outgoing_connection option. + +The device dials out to the test's listener when no dial-back target client is +connected. The listener plays the Home Assistant side over the accepted socket +using aioesphomeapi's sans-IO Noise handshake: the device sends its server +hello first so the listener could pick the right key, and the NNpsk0 handshake +then verifies both sides. Protocol roles stay unchanged, so the client speaks +exactly the same frames as over a normal connection. A client becomes the +remembered dial-back target by setting the outgoing_connection_target flag in +its hello. +""" + +from __future__ import annotations + +import asyncio +import socket +from typing import Any + +from aioesphomeapi import api_pb2 +import pytest + +from .raw_api_client import MESSAGE_TYPE_OF +from .types import RunCompiledFunction + +KEY = "bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=" +DEVICE_NAME = "outgoing-conn-test" +HA_CLIENT_INFO = "Home Assistant 2026.8.0" +# HelloRequest field 4 (outgoing_connection_target) as raw protobuf bytes; the +# installed aioesphomeapi's api_pb2 predates the field, so append it manually. +HELLO_TARGET_FLAG = b"\x20\x01" + + +def _frame(payload: bytes) -> bytes: + return bytes((0x01, len(payload) >> 8, len(payload) & 0xFF)) + payload + + +async def _read_frame(reader: asyncio.StreamReader, timeout: float = 10.0) -> bytes: + header = await asyncio.wait_for(reader.readexactly(3), timeout) + assert header[0] == 0x01, f"Bad frame indicator: {header[0]}" + return await asyncio.wait_for( + reader.readexactly((header[1] << 8) | header[2]), timeout + ) + + +def _check_server_hello(server_hello: bytes) -> None: + assert server_hello[0] == 0x01, "Bad chosen proto in server hello" + name, mac, _rest = server_hello[1:].split(b"\x00", 2) + assert name.decode() == DEVICE_NAME + assert len(mac) == 12, f"Expected bare MAC, got {mac!r}" + + +async def _run_ha_session( + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + *, + device_dialed_out: bool, +) -> None: + """Handshake and exchange the usual first messages as Home Assistant would.""" + # Lazy import per the module's own contract (pulls in the noise stack) + from aioesphomeapi.noise import NoiseHandshake + + if device_dialed_out: + # On an outgoing connection the device announces itself first so the + # peer can pick the matching key before its PSK-mixed first message. + _check_server_hello(await _read_frame(reader)) + + handshake = NoiseHandshake(KEY, b"NoiseAPIInit\x00\x00") + writer.write(b"\x01\x00\x00" + _frame(b"\x00" + handshake.write_message())) + await writer.drain() + + if not device_dialed_out: + _check_server_hello(await _read_frame(reader)) + + reply = await _read_frame(reader) + assert reply[0] == 0, f"Handshake rejected: {reply[1:].decode(errors='replace')}" + handshake.read_message(reply[1:]) + encrypt_cipher, decrypt_cipher = handshake.get_ciphers() + + async def transact(msg: Any, response_cls: Any, extra_payload: bytes = b"") -> Any: + msg_type = MESSAGE_TYPE_OF[type(msg)] + payload = msg.SerializeToString() + extra_payload + plaintext = ( + bytes( + (msg_type >> 8, msg_type & 0xFF, len(payload) >> 8, len(payload) & 0xFF) + ) + + payload + ) + writer.write(_frame(encrypt_cipher.encrypt(plaintext))) + await writer.drain() + want = MESSAGE_TYPE_OF[response_cls] + while True: + plain = decrypt_cipher.decrypt(await _read_frame(reader)) + if ((plain[0] << 8) | plain[1]) == want: + response = response_cls() + response.ParseFromString(bytes(plain[4:])) + return response + + # Declare this client a dial-back target in the hello + await transact( + api_pb2.HelloRequest(client_info=HA_CLIENT_INFO), + api_pb2.HelloResponse, + extra_payload=HELLO_TARGET_FLAG, + ) + device_info = await transact( + api_pb2.DeviceInfoRequest(), api_pb2.DeviceInfoResponse + ) + assert device_info.name == DEVICE_NAME + + +async def _serve_home_assistant(listener: socket.socket) -> None: + """Accept one dial-in from the device and run the client side over it.""" + loop = asyncio.get_running_loop() + conn, _ = await asyncio.wait_for(loop.sock_accept(listener), timeout=30) + reader, writer = await asyncio.open_connection(sock=conn) + try: + await _run_ha_session(reader, writer, device_dialed_out=True) + finally: + writer.close() + + +@pytest.mark.asyncio +async def test_api_outgoing_connection( + yaml_config: str, + run_compiled: RunCompiledFunction, +) -> None: + """With a configured host the device dials out and speaks the normal API.""" + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.bind(("127.0.0.1", 0)) + listener.listen(2) + listener.setblocking(False) + port = listener.getsockname()[1] + + try: + yaml = yaml_config.replace("OUTGOING_PORT", str(port)) + async with run_compiled(yaml): + await _serve_home_assistant(listener) + finally: + listener.close() + + +@pytest.mark.asyncio +async def test_api_outgoing_connection_remembered( + yaml_config: str, + run_compiled: RunCompiledFunction, + unused_tcp_port: int, +) -> None: + """No host configured: the device remembers the client whose hello carried + the dial-back flag and dials that address after a restart.""" + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + # Bound but not yet listening so first-phase dials cannot queue stale + # connections; whether the device attempts any dial before the restart + # is timing dependent and not asserted here. + listener.bind(("127.0.0.1", 0)) + port = listener.getsockname()[1] + + try: + yaml = yaml_config.replace("OUTGOING_PORT", str(port)) + + async with run_compiled(yaml): + # Connect inbound with the dial-back flag; the device persists the + # peer address during the hello. + reader, writer = await asyncio.open_connection("127.0.0.1", unused_tcp_port) + try: + await _run_ha_session(reader, writer, device_dialed_out=False) + finally: + writer.close() + + # Restart with the same preferences: the device now dials the + # remembered address on its own. + listener.listen(2) + listener.setblocking(False) + async with run_compiled(yaml): + await _serve_home_assistant(listener) + finally: + listener.close()