Select the dial-back target with an explicit hello flag instead of guessing from client info

This commit is contained in:
J. Nick Koston
2026-08-31 15:40:15 -04:00
parent 676eac7686
commit 4e0cda0287
17 changed files with 248 additions and 232 deletions
+4 -16
View File
@@ -1,4 +1,3 @@
import ipaddress
import logging
import re
from typing import Any
@@ -293,17 +292,6 @@ def _consume_api_sockets(config: ConfigType) -> ConfigType:
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(
@@ -325,7 +313,7 @@ def _validate_outgoing_connection(config: ConfigType) -> ConfigType:
OUTGOING_CONNECTION_SCHEMA = cv.All(
cv.Schema(
{
cv.Optional(CONF_HOST): _validate_ip_literal,
cv.Optional(CONF_HOST): cv.ipaddress,
cv.Optional(CONF_PORT, default=6054): cv.port,
cv.Optional(
CONF_DELAY, default="60s"
@@ -660,9 +648,9 @@ async def to_code(config: ConfigType) -> None:
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("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)
+5
View File
@@ -112,6 +112,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.
+11 -4
View File
@@ -1786,10 +1786,6 @@ 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());
@@ -1826,6 +1822,17 @@ 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
// Only honor the flag once a real key is active: with a PSK set, plaintext
// and the all-zeros provisioning PSK are rejected at the transport, so a
// client reaching this point has proven possession of the key.
if (msg.outgoing_connection_target && !this->flags_.outgoing_connection_target &&
this->parent_->get_noise_ctx().has_psk()) {
this->flags_.outgoing_connection_target = true;
this->parent_->on_outgoing_target_client(this);
}
#endif
return this->send_message(resp);
}
+3 -8
View File
@@ -275,9 +275,6 @@ 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) {
@@ -389,11 +386,6 @@ class APIConnection final : public APIServerConnectionBase {
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_();
@@ -761,6 +753,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
+3 -2
View File
@@ -18,7 +18,7 @@
namespace esphome::api {
// uncomment to log raw packets
//#define HELPER_LOG_PACKETS
// #define HELPER_LOG_PACKETS
// Maximum message size limits to prevent OOM on constrained devices
// Handshake messages are limited to a small size for security
@@ -282,7 +282,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.
@@ -61,6 +61,14 @@ static constexpr size_t API_MAX_LOG_BYTES = 168;
/// Initialize the frame helper, returns OK if successful.
APIError APINoiseFrameHelper::init() {
#ifdef USE_API_OUTGOING_CONNECTION
// set_server_hello_first() marks the outgoing mode in state_; restore the
// state init_common_() expects before running it.
const bool outgoing = this->state_ == State::CLIENT_HELLO_OUTGOING;
if (outgoing) {
this->state_ = State::INITIALIZE;
}
#endif
APIError err = init_common_();
if (err != APIError::OK) {
return err;
@@ -80,9 +88,10 @@ APIError APINoiseFrameHelper::init() {
state_ = State::CLIENT_HELLO;
#ifdef USE_API_OUTGOING_CONNECTION
if (this->server_hello_first_) {
if (outgoing) {
// Outgoing connection: the peer needs our name and MAC to pick
// the matching key before it can send its PSK-mixed handshake message.
state_ = State::CLIENT_HELLO_OUTGOING;
return this->send_server_hello_frame_();
}
#endif
@@ -260,6 +269,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_();
@@ -293,7 +305,7 @@ APIError APINoiseFrameHelper::state_action_client_hello_() {
}
#ifdef USE_API_OUTGOING_CONNECTION
if (this->server_hello_first_) {
if (this->state_ == State::CLIENT_HELLO_OUTGOING) {
// Server hello already went out in init(); go straight to the handshake.
aerr = init_handshake_();
if (aerr != APIError::OK)
@@ -30,10 +30,11 @@ class APINoiseFrameHelper final : public APIFrameHelper {
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; }
// Outgoing connection: init() sends the server hello immediately 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();
// stored in state_ so the helper does not grow.
void set_server_hello_first() { this->state_ = State::CLIENT_HELLO_OUTGOING; }
#endif
APIError loop() override;
APIError read_packet(ReadPacketBuffer *buffer) override;
@@ -76,9 +77,6 @@ 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
};
@@ -1,7 +1,7 @@
#include "api_outgoing_connection.h"
#if defined(USE_API) && defined(USE_API_OUTGOING_CONNECTION)
#include "api_frame_helper.h"
#include "api_connection.h"
#include "api_server.h"
#include "esphome/components/network/util.h"
#include "esphome/core/application.h"
@@ -26,86 +26,81 @@ void OutgoingConnectionManager::setup() {
}
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;
}
if (server->has_outgoing_target_client_()) {
// on_target_client() already reset the dial state when this client's
// hello arrived; nothing to do while it stays connected.
return;
}
const uint32_t now = App.get_loop_component_start_time();
switch (this->state_) {
case DialState::DIAL_STATE_IDLE:
// The target client just went away; give it the configured delay to
// reconnect on its own before dialing.
this->state_ = DialState::DIAL_STATE_WAITING;
this->state_ts_ = now;
this->wait_ = API_OUTGOING_CONNECTION_DELAY;
break;
case DialState::DIAL_STATE_WAITING:
if (now - this->state_ts_ >= this->delay_) {
if (now - this->state_ts_ >= this->wait_) {
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);
if (host == nullptr || !network::is_connected() || server->at_client_limit_() || !server->noise_ctx_.has_psk()) {
this->schedule_retry_(now);
return;
}
struct sockaddr_storage addr {};
socklen_t addr_len = socket::set_sockaddr((struct sockaddr *) &addr, sizeof(addr), host, this->port_);
struct sockaddr_storage addr;
socklen_t addr_len =
socket::set_sockaddr((struct sockaddr *) &addr, sizeof(addr), host, API_OUTGOING_CONNECTION_PORT);
if (addr_len == 0) {
ESP_LOGW(TAG, "Invalid outgoing connection target %s", host);
this->enter_cooldown_(now);
ESP_LOGW(TAG, "Invalid target %s", host);
this->schedule_retry_(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);
this->schedule_retry_(now);
return;
}
ESP_LOGD(TAG, "Dialing %s:%u", host, this->port_);
ESP_LOGD(TAG, "Dialing %s:%u", host, API_OUTGOING_CONNECTION_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);
this->schedule_retry_(now);
return;
}
if (errno != EINPROGRESS) {
ESP_LOGW(TAG, "Outgoing connect failed: errno %d", errno);
this->abort_dial_();
this->enter_cooldown_(now);
ESP_LOGW(TAG, "Connect failed: errno %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, "Outgoing connect timeout");
this->abort_dial_();
this->enter_cooldown_(now);
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 fd = this->dial_socket_->get_fd();
if (fd < 0) {
this->abort_dial_();
this->enter_cooldown_(now);
this->schedule_retry_(now);
return;
}
// Connect completion is a write event; the main loop select() only watches
@@ -121,61 +116,53 @@ void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) {
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);
ESP_LOGW(TAG, "Connect failed: %d", error);
this->schedule_retry_(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);
// Stay in a retry wait until the peer proves itself by sending a flagged
// hello; on_target_client() then resets to idle and clears the backoff.
this->schedule_retry_(now);
}
void OutgoingConnectionManager::enter_cooldown_(uint32_t now) {
this->state_ = DialState::DIAL_STATE_COOLDOWN;
void OutgoingConnectionManager::schedule_retry_(uint32_t now) {
this->dial_socket_.reset(); // no-op when the socket was handed off
this->state_ = DialState::DIAL_STATE_WAITING;
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->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_();
void OutgoingConnectionManager::on_target_client(APIConnection *conn) {
// The target is connected; stop any dial in flight and reset the backoff.
this->dial_socket_.reset();
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
conn->get_peername_to(target.host);
if (target.host[0] == '\0' || strcmp(target.host, this->saved_.host) == 0) {
return; // unknown peer or 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");
ESP_LOGW(TAG, "Failed to save 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 port: %u", API_OUTGOING_CONNECTION_PORT);
#ifdef API_OUTGOING_CONNECTION_HOST
ESP_LOGCONFIG(TAG, " Outgoing connection host: %s", API_OUTGOING_CONNECTION_HOST);
#else
if (this->saved_.host[0] != '\0') {
ESP_LOGCONFIG(TAG, " Outgoing connection host: %s (remembered)", this->saved_.host);
}
#endif
}
} // namespace esphome::api
@@ -15,32 +15,29 @@
namespace esphome::api {
class APIServer;
class APIFrameHelper;
class APIConnection;
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.
// the socket component 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.
/// Dials out to Home Assistant when no dial-back target 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 persisted address of the
/// last client whose hello declared it a dial-back target; 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; }
/// Called when a key-verified client declares itself a dial-back target;
/// the last such client wins as the remembered address.
void on_target_client(APIConnection *conn);
void on_shutdown() { this->dial_socket_.reset(); }
void dump_config() const;
protected:
@@ -48,32 +45,32 @@ class OutgoingConnectionManager {
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;
static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 250;
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);
// Close any half-open dial and wait a jittered backoff before retrying
void schedule_retry_(uint32_t now);
const char *target_host_() const {
if (this->configured_host_ != nullptr)
return this->configured_host_;
#ifdef API_OUTGOING_CONNECTION_HOST
return API_OUTGOING_CONNECTION_HOST;
#else
return this->saved_.host[0] != '\0' ? this->saved_.host : nullptr;
#endif
}
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 wait_{0};
uint32_t state_ts_{0};
uint16_t port_{6054};
uint32_t last_poll_{0};
DialState state_{DialState::DIAL_STATE_IDLE};
};
+5
View File
@@ -15,6 +15,11 @@ bool HelloRequest::decode_varint(uint32_t field_id, proto_varint_value_t value)
case 3:
this->api_version_minor = value;
break;
#ifdef USE_API_OUTGOING_CONNECTION
case 4:
this->outgoing_connection_target = value != 0;
break;
#endif
default:
return false;
}
+4 -1
View File
@@ -412,13 +412,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
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
+3
View File
@@ -885,6 +885,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 {
+10 -7
View File
@@ -212,6 +212,12 @@ void APIServer::remove_client_(uint8_t client_index) {
std::string client_peername(client->get_peername_to(peername_buf));
#endif
#ifdef USE_API_OUTGOING_CONNECTION
if (client->flags_.outgoing_connection_target) {
this->outgoing_target_count_--;
}
#endif
// Close socket now (was deferred from on_fatal_error to allow getpeername)
client->helper_->close();
@@ -254,7 +260,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();
@@ -280,17 +286,14 @@ void APIServer::add_client_(APIConnection *conn) {
#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());
void APIServer::on_outgoing_target_client(APIConnection *conn) {
this->outgoing_target_count_++;
this->outgoing_conn_.on_target_client(conn);
}
#endif
+8 -5
View File
@@ -83,11 +83,8 @@ class APIServer final : public Component,
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);
// 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);
@@ -268,8 +265,10 @@ class APIServer final : public Component,
void __attribute__((noinline)) accept_new_connections_();
// Insert a constructed connection into the client slots and start it.
void add_client_(APIConnection *conn);
bool at_client_limit_() const { return this->api_connection_count_ >= MAX_API_CONNECTIONS; }
#ifdef USE_API_OUTGOING_CONNECTION
void add_outgoing_client_(std::unique_ptr<socket::Socket> 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.
@@ -365,6 +364,10 @@ class APIServer final : public Component,
uint8_t listen_backlog_{4};
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};
+2
View File
@@ -215,6 +215,8 @@
#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
@@ -9,6 +9,7 @@ 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.core import CORE
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
@@ -23,15 +24,17 @@ def _api_config(outgoing: ConfigType, *, encryption: bool = True) -> ConfigType:
return config
def test_outgoing_connection_generates_setters(
def test_outgoing_connection_generates_defines(
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")
"""A valid config emits the compile-time defines with defaults applied."""
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
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_defaults(
@@ -69,5 +72,5 @@ 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"):
with pytest.raises(cv.Invalid, match="not a valid IP address"):
CONFIG_SCHEMA(_api_config({"host": "homeassistant.local"}))
@@ -1,11 +1,13 @@
"""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.
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
@@ -14,15 +16,18 @@ import asyncio
import socket
from typing import Any
from aioesphomeapi import APIClient, api_pb2
from aioesphomeapi import api_pb2
import pytest
from .raw_api_client import MESSAGE_TYPE_OF
from .types import APIClientConnectedFactory, RunCompiledFunction
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"
@pytest.fixture(autouse=True)
@@ -43,76 +48,78 @@ async def _read_frame(reader: asyncio.StreamReader, timeout: float = 10.0) -> by
)
async def _serve_home_assistant(
listener: socket.socket, *, subscribe_states: bool = False
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:
"""Accept one dial-in from the device and run the client side over it."""
"""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:
# 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)
await _run_ha_session(reader, writer, device_dialed_out=True)
finally:
writer.close()
@@ -132,7 +139,7 @@ async def test_api_outgoing_connection(
try:
yaml = yaml_config.replace("OUTGOING_PORT", str(port))
async with run_compiled(yaml):
await _serve_home_assistant(listener, subscribe_states=True)
await _serve_home_assistant(listener)
finally:
listener.close()
@@ -141,10 +148,10 @@ async def test_api_outgoing_connection(
async def test_api_outgoing_connection_remembered(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
unused_tcp_port: int,
) -> None:
"""No host configured: the device remembers the Home Assistant client that
connected inbound and dials that address after a restart."""
"""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: dial attempts in the first phase are
# refused, exercising the retry path without queueing stale connections.
@@ -154,17 +161,14 @@ async def test_api_outgoing_connection_remembered(
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)
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.