mirror of
https://github.com/esphome/esphome.git
synced 2026-09-11 15:27:33 +00:00
[api] Add outgoing connections so the device can dial Home Assistant
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user