[api] Add outgoing connections so the device can dial Home Assistant

This commit is contained in:
J. Nick Koston
2026-08-31 14:06:05 -05:00
parent 813c000684
commit 676eac7686
17 changed files with 756 additions and 12 deletions
+59
View File
@@ -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",
}
)
@@ -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());
+16
View File
@@ -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<APINoiseFrameHelper *>(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_();
@@ -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;
@@ -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
};
@@ -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 <cerrno>
#include <cstring>
namespace esphome::api {
static const char *const TAG = "api.outgoing";
void OutgoingConnectionManager::setup() {
this->target_pref_ = global_preferences->make_preference<SavedOutgoingTarget>(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
@@ -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 <memory>
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<socket::Socket> 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
+43 -9
View File
@@ -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<socket::Socket> 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;
+17
View File
@@ -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<socket::Socket> 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)
+1
View File
@@ -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
@@ -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"}))
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,13 @@
esphome:
name: outgoing-conn-test
host:
logger:
api:
encryption:
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
outgoing_connection:
port: OUTGOING_PORT
delay: 1s
@@ -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()