diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 3568318dad..3b2f87e2bb 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -1,3 +1,4 @@ +import ipaddress import logging import re from typing import Any @@ -24,6 +25,7 @@ from esphome.const import ( CONF_CAPTURE_RESPONSE, CONF_DATA, CONF_DATA_TEMPLATE, + CONF_DELAY, CONF_ENCRYPTION, CONF_EVENT, CONF_ID, @@ -129,10 +131,12 @@ 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" CONF_MAX_SEND_QUEUE = "max_send_queue" +CONF_OUTGOING_CONNECTION = "outgoing_connection" CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only" @@ -284,9 +288,54 @@ 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_ip_literal(value: Any) -> str: + value = cv.string_strict(value) + try: + ipaddress.ip_address(value) + except ValueError as err: + raise cv.Invalid( + f"outgoing_connection host must be an IP address, got {value!r}" + ) from err + return value + + +def _validate_outgoing_connection_platform(value: ConfigType) -> ConfigType: + 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" + ) + return value + + +def _validate_outgoing_connection(config: ConfigType) -> ConfigType: + if CONF_OUTGOING_CONNECTION in config and CONF_ENCRYPTION not in config: + raise cv.Invalid( + "outgoing_connection requires 'encryption' so the peer is verified by key", + path=[CONF_OUTGOING_CONNECTION], + ) + return config + + +OUTGOING_CONNECTION_SCHEMA = cv.All( + cv.Schema( + { + cv.Optional(CONF_HOST): _validate_ip_literal, + cv.Optional(CONF_PORT, default=6054): cv.port, + cv.Optional( + CONF_DELAY, default="60s" + ): cv.positive_time_period_milliseconds, + } + ), + _validate_outgoing_connection_platform, +) + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -311,6 +360,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="100ms"): cv.All( cv.positive_time_period_milliseconds, cv.Range(max=cv.TimePeriod(milliseconds=65535)), @@ -367,6 +417,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, ) @@ -606,6 +657,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(var.set_outgoing_connection_host(host)) + cg.add(var.set_outgoing_connection_port(outgoing[CONF_PORT])) + cg.add(var.set_outgoing_connection_delay(outgoing[CONF_DELAY])) + cg.add_define("USE_API") cg.add_global(api_ns.using) @@ -992,6 +1050,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_connection.cpp b/esphome/components/api/api_connection.cpp index bc088ca473..b587ecde51 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1786,6 +1786,10 @@ void APIConnection::complete_authentication_() { #endif } +#ifdef USE_API_OUTGOING_CONNECTION +void APIConnection::notify_state_subscription_() { this->parent_->on_client_state_subscription(this); } +#endif + bool APIConnection::send_hello_response_(const HelloRequest &msg) { // Copy client name with truncation if needed (set_client_name handles truncation) this->helper_->set_client_name(msg.client_info.c_str(), msg.client_info.size()); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index a4c49dccf4..af20a6a860 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -275,6 +275,9 @@ class APIConnection final : public APIServerConnectionBase { void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); } void on_subscribe_states_request() { this->flags_.state_subscription = true; +#ifdef USE_API_OUTGOING_CONNECTION + this->notify_state_subscription_(); +#endif // Start initial state iterator only if no iterator is active // If list_entities is running, we'll start initial_state when it completes if (this->active_iterator_ == ActiveIterator::NONE) { @@ -375,9 +378,22 @@ class APIConnection final : public APIServerConnectionBase { return this->helper_->get_peername_to(buf); } +#ifdef USE_API_OUTGOING_CONNECTION + /// Outgoing connection: the noise helper sends its server hello + /// first 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. + /// Must be called before start(). + void mark_outgoing() { static_cast(this->helper_.get())->set_server_hello_first(); } +#endif + protected: bool try_to_clear_buffer_slow_(bool log_out_of_space); +#ifdef USE_API_OUTGOING_CONNECTION + // Out of line: forwards to the server, which is an incomplete type here + void notify_state_subscription_(); +#endif + // Helper function to handle authentication completion void complete_authentication_(); diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 138dbdddba..8324b6266d 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -79,6 +79,13 @@ APIError APINoiseFrameHelper::init() { #endif state_ = State::CLIENT_HELLO; +#ifdef USE_API_OUTGOING_CONNECTION + if (this->server_hello_first_) { + // Outgoing connection: the peer needs our name and MAC to pick + // the matching key before it can send its PSK-mixed handshake message. + return this->send_server_hello_frame_(); + } +#endif return APIError::OK; } #ifdef USE_API_PLAINTEXT @@ -285,11 +292,20 @@ APIError APINoiseFrameHelper::state_action_client_hello_() { std::memcpy(this->prologue_.data() + old_size + 2, this->rx_buf_.data(), rx_size); } +#ifdef USE_API_OUTGOING_CONNECTION + if (this->server_hello_first_) { + // Server hello already went out in init(); go straight to the handshake. + aerr = init_handshake_(); + if (aerr != APIError::OK) + return aerr; + state_ = State::HANDSHAKE; + return APIError::OK; + } +#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); @@ -313,7 +329,10 @@ 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; diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index 366751738e..73b19bfb3b 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 + // Outgoing connection: send the server hello immediately in init() + // so the peer can identify this device and select the matching key before it + // sends the PSK-mixed first handshake message. Must be called before init(). + void set_server_hello_first() { this->server_hello_first_ = true; } #endif APIError loop() override; APIError read_packet(ReadPacketBuffer *buffer) override; @@ -39,6 +45,7 @@ class APINoiseFrameHelper final : public APIFrameHelper { APIError state_action_(); APIError state_action_client_hello_(); APIError state_action_server_hello_(); + APIError send_server_hello_frame_(); APIError state_action_handshake_(); APIError state_action_handshake_read_(); APIError state_action_handshake_write_(); @@ -69,6 +76,9 @@ class APINoiseFrameHelper final : public APIFrameHelper { // Note: Maximum message size is UINT16_MAX (65535), with a limit of 128 bytes during handshake phase uint8_t rx_header_buf_[noise::FRAME_HEADER_SIZE]; uint8_t rx_header_buf_len_ = 0; +#ifdef USE_API_OUTGOING_CONNECTION + bool server_hello_first_{false}; +#endif // 4 bytes total, no padding }; diff --git a/esphome/components/api/api_outgoing_connection.cpp b/esphome/components/api/api_outgoing_connection.cpp new file mode 100644 index 0000000000..7500dbeb39 --- /dev/null +++ b/esphome/components/api/api_outgoing_connection.cpp @@ -0,0 +1,182 @@ +#include "api_outgoing_connection.h" +#if defined(USE_API) && defined(USE_API_OUTGOING_CONNECTION) + +#include "api_frame_helper.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 + +namespace esphome::api { + +static const char *const TAG = "api.outgoing"; + +void OutgoingConnectionManager::setup() { + this->target_pref_ = global_preferences->make_preference(629847102UL, true); + if (this->target_pref_.load(&this->saved_)) { + // Defend against a corrupt or truncated preference blob + this->saved_.host[socket::SOCKADDR_STR_LEN - 1] = '\0'; + } else { + this->saved_.host[0] = '\0'; + } +} + +void OutgoingConnectionManager::loop(APIServer *server) { + const uint32_t now = App.get_loop_component_start_time(); + const bool has_subscriber = server->is_connected_with_state_subscription(); + if (has_subscriber) { + if (this->state_ != DialState::DIAL_STATE_IDLE) { + this->abort_dial_(); + this->state_ = DialState::DIAL_STATE_IDLE; + this->backoff_ = BACKOFF_MIN_MS; + } + return; + } + switch (this->state_) { + case DialState::DIAL_STATE_IDLE: + this->state_ = DialState::DIAL_STATE_WAITING; + this->state_ts_ = now; + break; + case DialState::DIAL_STATE_WAITING: + if (now - this->state_ts_ >= this->delay_) { + this->try_dial_(server, now); + } + break; + case DialState::DIAL_STATE_CONNECTING: + this->poll_connect_(server, now); + break; + case DialState::DIAL_STATE_COOLDOWN: + if (now - this->state_ts_ >= this->cooldown_wait_) { + this->try_dial_(server, now); + } + break; + } +} + +void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) { + const char *host = this->target_host_(); + if (host == nullptr || !network::is_connected() || server->api_connection_count_ >= MAX_API_CONNECTIONS || + !server->noise_ctx_.has_psk()) { + this->enter_cooldown_(now); + return; + } + struct sockaddr_storage addr {}; + socklen_t addr_len = socket::set_sockaddr((struct sockaddr *) &addr, sizeof(addr), host, this->port_); + if (addr_len == 0) { + ESP_LOGW(TAG, "Invalid outgoing connection target %s", host); + this->enter_cooldown_(now); + 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) { + this->abort_dial_(); + this->enter_cooldown_(now); + return; + } + ESP_LOGD(TAG, "Dialing %s:%u", host, this->port_); + int err = this->dial_socket_->connect((struct sockaddr *) &addr, addr_len); + if (err == 0) { + // Immediate success (possible for localhost) + server->add_outgoing_client_(std::move(this->dial_socket_)); + this->enter_cooldown_(now); + return; + } + if (errno != EINPROGRESS) { + ESP_LOGW(TAG, "Outgoing connect failed: errno %d", errno); + this->abort_dial_(); + this->enter_cooldown_(now); + return; + } + this->state_ = DialState::DIAL_STATE_CONNECTING; + this->state_ts_ = now; +} + +void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) { + if (now - this->state_ts_ >= CONNECT_TIMEOUT_MS) { + ESP_LOGW(TAG, "Outgoing connect timeout"); + this->abort_dial_(); + this->enter_cooldown_(now); + return; + } + int fd = this->dial_socket_->get_fd(); + if (fd < 0) { + this->abort_dial_(); + this->enter_cooldown_(now); + return; + } + // Connect completion is a write event; the main loop select() only watches + // read readiness, so poll it here with a zero timeout. + fd_set writefds; + FD_ZERO(&writefds); + FD_SET(fd, &writefds); + struct timeval tv = {0, 0}; + int ret = select(fd + 1, nullptr, &writefds, nullptr, &tv); + if (ret <= 0 || !FD_ISSET(fd, &writefds)) { + return; // still in progress + } + int error = 0; + socklen_t len = sizeof(error); + if (this->dial_socket_->getsockopt(SOL_SOCKET, SO_ERROR, &error, &len) != 0 || error != 0) { + ESP_LOGW(TAG, "Outgoing connect failed: %d", error); + this->abort_dial_(); + this->enter_cooldown_(now); + return; + } + server->add_outgoing_client_(std::move(this->dial_socket_)); + // Stay in cooldown until the peer proves itself by subscribing to states; + // loop() flips back to idle and resets the backoff when that happens. + this->enter_cooldown_(now); +} + +void OutgoingConnectionManager::enter_cooldown_(uint32_t now) { + this->state_ = DialState::DIAL_STATE_COOLDOWN; + this->state_ts_ = now; + // +/-20% jitter so a fleet of devices does not retry one server in lockstep + const uint32_t jitter_span = this->backoff_ / 5; + this->cooldown_wait_ = this->backoff_ - jitter_span + (random_uint32() % (2 * jitter_span + 1)); + this->backoff_ = std::min(this->backoff_ * 2, BACKOFF_MAX_MS); +} + +void OutgoingConnectionManager::on_state_subscription(const char *client_name, APIFrameHelper *helper) { + // A state subscriber is connected; any dial in flight is now pointless. + this->abort_dial_(); + this->state_ = DialState::DIAL_STATE_IDLE; + this->backoff_ = BACKOFF_MIN_MS; + if (strncmp(client_name, "Home Assistant", 14) != 0) { + return; + } + struct sockaddr_storage peer {}; + socklen_t peer_len = sizeof(peer); + if (helper->getpeername((struct sockaddr *) &peer, &peer_len) != 0) { + return; + } + SavedOutgoingTarget target{}; + if (socket::format_sockaddr_to((struct sockaddr *) &peer, peer_len, target.host) == 0) { + return; + } + if (strcmp(target.host, this->saved_.host) == 0) { + return; // unchanged; avoid flash wear + } + this->saved_ = target; + if (!this->target_pref_.save(&this->saved_) || !global_preferences->sync()) { + ESP_LOGW(TAG, "Failed to save outgoing connection target"); + return; + } + ESP_LOGD(TAG, "Saved %s as outgoing connection target", this->saved_.host); +} + +void OutgoingConnectionManager::dump_config() const { + ESP_LOGCONFIG(TAG, " Outgoing connection port: %u", this->port_); + if (this->configured_host_ != nullptr) { + ESP_LOGCONFIG(TAG, " Outgoing connection host: %s", this->configured_host_); + } else if (this->saved_.host[0] != '\0') { + ESP_LOGCONFIG(TAG, " Outgoing connection host: %s (remembered)", this->saved_.host); + } +} + +} // 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..7ec6e24bc9 --- /dev/null +++ b/esphome/components/api/api_outgoing_connection.h @@ -0,0 +1,81 @@ +#pragma once + +#include "esphome/core/defines.h" +#if defined(USE_API) && defined(USE_API_OUTGOING_CONNECTION) + +#ifdef USE_SOCKET_IMPL_LWIP_TCP +#error "api outgoing_connection needs a socket implementation that can make outgoing connections" +#endif + +#include "esphome/components/socket/socket.h" +#include "esphome/core/preferences.h" + +#include + +namespace esphome::api { + +class APIServer; +class APIFrameHelper; + +struct SavedOutgoingTarget { + // Null-terminated IP string; empty when no peer has been remembered yet. + // Stored as text so the platform-specific v4-mapped-IPv6 normalization in + // socket::format_sockaddr_to()/set_sockaddr() is reused on both ends. + char host[socket::SOCKADDR_STR_LEN]; +} PACKED; // NOLINT + +/// Dials out to Home Assistant when no state-subscribed client is connected. +/// The TCP direction flips but the protocol roles do not: this device stays the +/// Noise responder, so both sides still verify each other by the shared key. +/// The target is either a host from YAML or the last persisted Home Assistant +/// peer address; the listening socket keeps accepting inbound clients the +/// whole time. +class OutgoingConnectionManager { + public: + void setup(); + void loop(APIServer *server); + /// Called when a client subscribes to states. Home Assistant peers are + /// persisted as the dial-back target; any subscriber cancels dialing. + void on_state_subscription(const char *client_name, APIFrameHelper *helper); + void on_shutdown() { this->abort_dial_(); } + void set_target_host(const char *host) { this->configured_host_ = host; } + void set_port(uint16_t port) { this->port_ = port; } + void set_delay(uint32_t delay) { this->delay_ = delay; } + void dump_config() const; + + protected: + enum class DialState : uint8_t { + DIAL_STATE_IDLE, + DIAL_STATE_WAITING, + DIAL_STATE_CONNECTING, + DIAL_STATE_COOLDOWN, + }; + + static constexpr uint32_t BACKOFF_MIN_MS = 5000; + static constexpr uint32_t BACKOFF_MAX_MS = 300000; + static constexpr uint32_t CONNECT_TIMEOUT_MS = 10000; + + void try_dial_(APIServer *server, uint32_t now); + void poll_connect_(APIServer *server, uint32_t now); + void abort_dial_() { this->dial_socket_.reset(); } + void enter_cooldown_(uint32_t now); + const char *target_host_() const { + if (this->configured_host_ != nullptr) + return this->configured_host_; + return this->saved_.host[0] != '\0' ? this->saved_.host : nullptr; + } + + std::unique_ptr dial_socket_; + const char *configured_host_{nullptr}; + ESPPreferenceObject target_pref_; + SavedOutgoingTarget saved_{}; + uint32_t delay_{60000}; + uint32_t backoff_{BACKOFF_MIN_MS}; + uint32_t cooldown_wait_{0}; + uint32_t state_ts_{0}; + uint16_t port_{6054}; + DialState state_{DialState::DIAL_STATE_IDLE}; +}; + +} // namespace esphome::api +#endif // USE_API && USE_API_OUTGOING_CONNECTION diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 751f2e4c3b..575c20a578 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -135,6 +135,9 @@ 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 } void APIServer::loop() { @@ -143,6 +146,12 @@ void APIServer::loop() { this->accept_new_connections_(); } +#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). @@ -254,18 +263,37 @@ 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_(new APIConnection(std::move(sock), this)); } } +void APIServer::add_client_(APIConnection *conn) { + 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(); + } +} + +#ifdef USE_API_OUTGOING_CONNECTION +void APIServer::add_outgoing_client_(std::unique_ptr sock) { + char peername[socket::SOCKADDR_STR_LEN]; + sock->getpeername_to(peername); + ESP_LOGD(TAG, "Outgoing connection to %s", peername); + + auto *conn = new APIConnection(std::move(sock), this); + conn->mark_outgoing(); + this->add_client_(conn); +} + +void APIServer::on_client_state_subscription(APIConnection *conn) { + this->outgoing_conn_.on_state_subscription(conn->get_name(), conn->helper_.get()); +} +#endif + void APIServer::dump_config() { char addr_buf[network::USE_ADDRESS_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, @@ -282,6 +310,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) {} @@ -685,6 +716,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 072a583901..e985a3beb2 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" @@ -81,6 +82,13 @@ class APIServer final : public Component, void set_noise_psk(noise::psk_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 + void set_outgoing_connection_host(const char *host) { this->outgoing_conn_.set_target_host(host); } + void set_outgoing_connection_port(uint16_t port) { this->outgoing_conn_.set_port(port); } + void set_outgoing_connection_delay(uint32_t delay) { this->outgoing_conn_.set_delay(delay); } + // Called by APIConnection when a client subscribes to states + void on_client_state_subscription(APIConnection *conn); +#endif void handle_disconnect(APIConnection *conn); #ifdef USE_BINARY_SENSOR @@ -258,6 +266,12 @@ class APIServer final : public Component, protected: // Accept incoming socket connections. Only called when socket has pending connections. void __attribute__((noinline)) accept_new_connections_(); + // Insert a constructed connection into the client slots and start it. + void add_client_(APIConnection *conn); +#ifdef USE_API_OUTGOING_CONNECTION + void add_outgoing_client_(std::unique_ptr sock); + 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); @@ -360,6 +374,9 @@ class APIServer final : public Component, noise::NoiseContext noise_ctx_; 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 1f5a10d47d..c740cd1d2d 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -214,6 +214,7 @@ #define USE_API_HOMEASSISTANT_SERVICES #define USE_API_HOMEASSISTANT_STATES #define USE_API_NOISE +#define USE_API_OUTGOING_CONNECTION #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..d694ede168 --- /dev/null +++ b/tests/component_tests/api/test_outgoing_connection.py @@ -0,0 +1,73 @@ +"""Tests for the api outgoing_connection option.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.api import CONFIG_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.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_setters( + generate_main: Callable[[str | Path], str], +) -> None: + """A valid config emits the setters with defaults applied.""" + main_cpp = generate_main("tests/component_tests/api/test_outgoing_connection.yaml") + + assert 'set_outgoing_connection_host("192.168.1.2")' in main_cpp + assert "set_outgoing_connection_port(6054)" in main_cpp + assert "set_outgoing_connection_delay(60000)" in main_cpp + + +def test_outgoing_connection_defaults( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA) + config = CONFIG_SCHEMA(_api_config({"host": "192.168.1.2"})) + outgoing = config["outgoing_connection"] + assert outgoing["port"] == 6054 + assert outgoing["delay"].total_milliseconds == 60000 + + +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_rejected_on_raw_lwip_platforms( + set_core_config: SetCoreConfigCallable, + platform_framework: PlatformFramework, +) -> 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_hostnames( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA) + with pytest.raises(cv.Invalid, match="must be an IP address"): + CONFIG_SCHEMA(_api_config({"host": "homeassistant.local"})) 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.esp32-idf.yaml b/tests/components/api/test-outgoing-connection.esp32-idf.yaml new file mode 100644 index 0000000000..bf806229ed --- /dev/null +++ b/tests/components/api/test-outgoing-connection.esp32-idf.yaml @@ -0,0 +1,17 @@ +packages: + common: !include common-base.yaml + +wifi: + ssid: MySSID + password: password1 + +# Outgoing connection: the device dials Home Assistant when no client with a +# state subscription is connected. Requires encryption so the peer is +# verified by key. +api: + encryption: + key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU= + outgoing_connection: + host: 192.168.1.2 + port: 6054 + delay: 60s 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..0f2e0d6276 --- /dev/null +++ b/tests/components/api/test-outgoing-connection.host.yaml @@ -0,0 +1,11 @@ +packages: + common: !include common-base.yaml + +network: + +# No host set: the device dials the last remembered Home Assistant address +api: + encryption: + key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU= + outgoing_connection: + delay: 30s 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..374be6ed21 --- /dev/null +++ b/tests/integration/test_api_outgoing_connection.py @@ -0,0 +1,176 @@ +"""Integration tests for the api outgoing_connection option. + +The device dials out to the test's listener when no client with a state +subscription 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. +""" + +from __future__ import annotations + +import asyncio +import socket +from typing import Any + +from aioesphomeapi import APIClient, api_pb2 +import pytest + +from .raw_api_client import MESSAGE_TYPE_OF +from .types import APIClientConnectedFactory, RunCompiledFunction + +KEY = "bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=" +DEVICE_NAME = "outgoing-conn-test" +HA_CLIENT_INFO = "Home Assistant 2026.8.0" + + +@pytest.fixture(autouse=True) +def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + """Keep host preferences per-test so every run starts with no saved peer.""" + monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs")) + + +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 + ) + + +async def _serve_home_assistant( + listener: socket.socket, *, subscribe_states: bool = False +) -> None: + """Accept one dial-in from the device and run the client side over it.""" + # Lazy import per the module's own contract (pulls in the noise stack) + from aioesphomeapi.noise import NoiseHandshake + + 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: + # On an outgoing connection the device announces itself first so the + # peer can pick the matching key before its PSK-mixed first message. + server_hello = await _read_frame(reader) + 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}" + + # Normal NNpsk0 handshake: client hello plus PSK-mixed message one, + # then the device's response completes it and proves the key matches. + handshake = NoiseHandshake(KEY, b"NoiseAPIInit\x00\x00") + writer.write(b"\x01\x00\x00" + _frame(b"\x00" + handshake.write_message())) + await writer.drain() + 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 | None) -> Any: + msg_type = MESSAGE_TYPE_OF[type(msg)] + payload = msg.SerializeToString() + 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() + if response_cls is None: + return None + 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 + + await transact( + api_pb2.HelloRequest(client_info=HA_CLIENT_INFO), api_pb2.HelloResponse + ) + device_info = await transact( + api_pb2.DeviceInfoRequest(), api_pb2.DeviceInfoResponse + ) + assert device_info.name == DEVICE_NAME + + if subscribe_states: + await transact(api_pb2.SubscribeStatesRequest(), None) + # No entities are configured, so there is nothing to wait for; + # give the device a moment to process the subscription. + await asyncio.sleep(0.5) + 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, subscribe_states=True) + finally: + listener.close() + + +@pytest.mark.asyncio +async def test_api_outgoing_connection_remembered( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """No host configured: the device remembers the Home Assistant client that + connected inbound and dials that address after a restart.""" + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + # Bound but not yet listening: dial attempts in the first phase are + # refused, exercising the retry path without queueing stale connections. + 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), + api_client_connected(noise_psk=KEY, client_info=HA_CLIENT_INFO) as client, + ): + client: APIClient + device_info = await client.device_info() + assert device_info.name == DEVICE_NAME + # Subscribing to states marks this client as Home Assistant; + # the device persists the peer address for dial-back. + client.subscribe_states(lambda state: None) + await asyncio.sleep(1.0) + + # 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()