From fc671d38e5fea00124fd5972fa02206749a44cbc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 31 Aug 2026 16:44:49 -0400 Subject: [PATCH] Address review findings and advertise support in device info --- esphome/components/api/api.proto | 4 +++ esphome/components/api/api_connection.cpp | 3 ++ .../api/api_outgoing_connection.cpp | 35 ++++++++++++++----- .../components/api/api_outgoing_connection.h | 19 ++++++++++ esphome/components/api/api_pb2.cpp | 6 ++++ esphome/components/api/api_pb2.h | 5 ++- esphome/components/api/api_pb2_dump.cpp | 3 ++ esphome/components/api/api_server.cpp | 10 +++++- esphome/components/api/api_server.h | 3 +- esphome/core/defines.h | 2 ++ .../test-outgoing-connection.bk72xx-ard.yaml | 13 +++++++ 11 files changed, 92 insertions(+), 11 deletions(-) create mode 100644 tests/components/api/test-outgoing-connection.bk72xx-ard.yaml diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 0f77ced142..0cf536b07a 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -336,6 +336,10 @@ message DeviceInfoResponse { // all-zeros PSK, so the api encryption key can be provisioned without being // sent in plaintext (protects against passive sniffing, not active MITM) bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"]; + + // Device is built with the api outgoing_connection option and can open + // the TCP connection to a dial-back target itself + bool api_outgoing_connection_supported = 27 [(field_ifdef) = "USE_API_OUTGOING_CONNECTION"]; } // ==================== DEVICE CAPABILITIES ==================== diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 10559db850..cf573b5dff 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1955,6 +1955,9 @@ bool APIConnection::send_device_info_response_() { // one) so this advertisement survives the plaintext removal in 2027.2.0. resp.api_encryption_provisionable = !this->parent_->get_noise_ctx().has_psk(); #endif +#ifdef USE_API_OUTGOING_CONNECTION + resp.api_outgoing_connection_supported = true; +#endif #endif #ifdef USE_DEVICES size_t device_index = 0; diff --git a/esphome/components/api/api_outgoing_connection.cpp b/esphome/components/api/api_outgoing_connection.cpp index 7f2aabd482..d3487e5bfe 100644 --- a/esphome/components/api/api_outgoing_connection.cpp +++ b/esphome/components/api/api_outgoing_connection.cpp @@ -16,6 +16,7 @@ namespace esphome::api { static const char *const TAG = "api.outgoing"; void OutgoingConnectionManager::setup() { +#ifndef API_OUTGOING_CONNECTION_HOST this->target_pref_ = global_preferences->make_preference(629847102UL, true); if (this->target_pref_.load(&this->saved_)) { // Defend against a corrupt or truncated preference blob @@ -23,6 +24,7 @@ void OutgoingConnectionManager::setup() { } else { this->saved_.host[0] = '\0'; } +#endif } void OutgoingConnectionManager::loop(APIServer *server) { @@ -31,6 +33,12 @@ void OutgoingConnectionManager::loop(APIServer *server) { // hello arrived; nothing to do while it stays connected. return; } + if (this->dialed_conn_ != nullptr) { + // A dialed connection is still open but its peer has not sent a flagged + // hello (yet); dialing again would only burn connection slots. The + // connection's own timeouts remove it eventually if the peer is silent. + return; + } const uint32_t now = App.get_loop_component_start_time(); switch (this->state_) { case DialState::DIAL_STATE_IDLE: @@ -54,7 +62,8 @@ void OutgoingConnectionManager::loop(APIServer *server) { void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) { const char *host = this->target_host_(); if (host == nullptr || !network::is_connected() || server->at_client_limit_() || !server->noise_ctx_.has_psk()) { - this->schedule_retry_(now); + // Not a dial failure; retry soon without escalating the backoff + this->schedule_wait_(now, PRECONDITION_RETRY_MS); return; } struct sockaddr_storage addr; @@ -74,7 +83,7 @@ void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) { 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->dialed_conn_ = server->add_outgoing_client_(std::move(this->dial_socket_)); this->schedule_retry_(now); return; } @@ -99,7 +108,7 @@ void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) { } this->last_poll_ = now; int fd = this->dial_socket_->get_fd(); - if (fd < 0) { + if (fd < 0 || fd >= FD_SETSIZE) { this->schedule_retry_(now); return; } @@ -109,8 +118,14 @@ void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) { 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)) { + // Global-scope select: the entity namespace esphome::select shadows it here + int ret = ::select(fd + 1, nullptr, &writefds, nullptr, &tv); + if (ret < 0) { + ESP_LOGW(TAG, "Connect poll failed: errno %d", errno); + this->schedule_retry_(now); + return; + } + if (ret == 0 || !FD_ISSET(fd, &writefds)) { return; // still in progress } int error = 0; @@ -120,7 +135,7 @@ void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) { this->schedule_retry_(now); return; } - server->add_outgoing_client_(std::move(this->dial_socket_)); + this->dialed_conn_ = server->add_outgoing_client_(std::move(this->dial_socket_)); // 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); @@ -139,19 +154,23 @@ void OutgoingConnectionManager::schedule_retry_(uint32_t now) { 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->dialed_conn_ = nullptr; this->state_ = DialState::DIAL_STATE_IDLE; this->backoff_ = BACKOFF_MIN_MS; +#ifndef API_OUTGOING_CONNECTION_HOST SavedOutgoingTarget target{}; 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()) { + if (!this->target_pref_.save(&target) || !global_preferences->sync()) { + // Keep the old value so the save is retried on the next flagged hello ESP_LOGW(TAG, "Failed to save target"); return; } + this->saved_ = target; ESP_LOGD(TAG, "Saved %s as outgoing connection target", this->saved_.host); +#endif } void OutgoingConnectionManager::dump_config() const { diff --git a/esphome/components/api/api_outgoing_connection.h b/esphome/components/api/api_outgoing_connection.h index 50d15fc54b..1fc2c54c65 100644 --- a/esphome/components/api/api_outgoing_connection.h +++ b/esphome/components/api/api_outgoing_connection.h @@ -37,6 +37,12 @@ class OutgoingConnectionManager { /// 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); + /// Called for every removed connection so a dialed one stops gating re-dials + void on_client_removed(APIConnection *conn) { + if (conn == this->dialed_conn_) { + this->dialed_conn_ = nullptr; + } + } void on_shutdown() { this->dial_socket_.reset(); } void dump_config() const; @@ -56,6 +62,13 @@ class OutgoingConnectionManager { void poll_connect_(APIServer *server, uint32_t now); // Close any half-open dial and wait a jittered backoff before retrying void schedule_retry_(uint32_t now); + // Wait without escalating the backoff (used for unmet preconditions) + void schedule_wait_(uint32_t now, uint32_t wait) { + this->dial_socket_.reset(); + this->state_ = DialState::DIAL_STATE_WAITING; + this->state_ts_ = now; + this->wait_ = wait; + } const char *target_host_() const { #ifdef API_OUTGOING_CONNECTION_HOST return API_OUTGOING_CONNECTION_HOST; @@ -63,10 +76,16 @@ class OutgoingConnectionManager { return this->saved_.host[0] != '\0' ? this->saved_.host : nullptr; #endif } + static constexpr uint32_t PRECONDITION_RETRY_MS = 5000; std::unique_ptr dial_socket_; + // Connection created by the last successful dial; compared, never + // dereferenced. Cleared by on_client_removed()/on_target_client(). + APIConnection *dialed_conn_{nullptr}; +#ifndef API_OUTGOING_CONNECTION_HOST ESPPreferenceObject target_pref_; SavedOutgoingTarget saved_{}; +#endif uint32_t backoff_{BACKOFF_MIN_MS}; uint32_t wait_{0}; uint32_t state_ts_{0}; diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index ee28bbc6dc..ec81e471e8 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -180,6 +180,9 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_ #endif #ifdef USE_API_NOISE ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, this->api_encryption_provisionable); +#endif +#ifdef USE_API_OUTGOING_CONNECTION + ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 27, this->api_outgoing_connection_supported); #endif return pos; } @@ -245,6 +248,9 @@ uint32_t DeviceInfoResponse::calculate_size() const { #endif #ifdef USE_API_NOISE size += ProtoSize::calc_bool(2, this->api_encryption_provisionable); +#endif +#ifdef USE_API_OUTGOING_CONNECTION + size += ProtoSize::calc_bool(2, this->api_outgoing_connection_supported); #endif return size; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 0d305c4400..c33f9db67e 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -552,7 +552,7 @@ class SerialProxyInfo final : public ProtoMessage { class DeviceInfoResponse final : public ProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 10; - static constexpr uint16_t ESTIMATED_SIZE = 312; + static constexpr uint16_t ESTIMATED_SIZE = 315; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("device_info_response"); } #endif @@ -610,6 +610,9 @@ class DeviceInfoResponse final : public ProtoMessage { #endif #ifdef USE_API_NOISE bool api_encryption_provisionable{false}; +#endif +#ifdef USE_API_OUTGOING_CONNECTION + bool api_outgoing_connection_supported{false}; #endif uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; uint32_t calculate_size() const; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index f5f3de3917..9eb4e34b89 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1011,6 +1011,9 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const { #endif #ifdef USE_API_NOISE dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable); +#endif +#ifdef USE_API_OUTGOING_CONNECTION + dump_field(out, ESPHOME_PSTR("api_outgoing_connection_supported"), this->api_outgoing_connection_supported); #endif return out.c_str(); } diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 26b5ce330f..cec27639d3 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -216,6 +216,7 @@ void APIServer::remove_client_(uint8_t client_index) { if (client->flags_.outgoing_connection_target) { this->outgoing_target_count_--; } + this->outgoing_conn_.on_client_removed(client.get()); #endif // Close socket now (was deferred from on_fatal_error to allow getpeername) @@ -285,10 +286,17 @@ void APIServer::add_client_(APIConnection *conn) { } #ifdef USE_API_OUTGOING_CONNECTION -void APIServer::add_outgoing_client_(std::unique_ptr sock) { +APIConnection *APIServer::add_outgoing_client_(std::unique_ptr sock) { + // Inbound clients may have taken the remaining slots while the dial was in + // flight; re-check at the handoff so add_client_ cannot write past clients_ + if (this->at_client_limit_()) { + ESP_LOGW(TAG, "Max connections (%d), dropping outgoing connection", MAX_API_CONNECTIONS); + return nullptr; + } auto *conn = new APIConnection(std::move(sock), this); conn->mark_outgoing(); this->add_client_(conn); + return conn; } void APIServer::on_outgoing_target_client(APIConnection *conn) { diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 664ec19a14..e61645758c 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -267,7 +267,8 @@ class APIServer final : public Component, 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 sock); + // Returns the new connection, or nullptr (socket dropped) when at the limit + APIConnection *add_outgoing_client_(std::unique_ptr sock); bool has_outgoing_target_client_() const { return this->outgoing_target_count_ != 0; } friend class OutgoingConnectionManager; #endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 302245ea0e..9968c60e34 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -214,9 +214,11 @@ #define USE_API_HOMEASSISTANT_SERVICES #define USE_API_HOMEASSISTANT_STATES #define USE_API_NOISE +#if !defined(USE_ESP8266) && !defined(USE_RP2) // raw-lwip sockets cannot make outgoing connections #define USE_API_OUTGOING_CONNECTION #define API_OUTGOING_CONNECTION_PORT 6054 #define API_OUTGOING_CONNECTION_DELAY 60000 +#endif #define USE_API_VARINT64 #define USE_API_PLAINTEXT #define USE_API_USER_DEFINED_ACTIONS diff --git a/tests/components/api/test-outgoing-connection.bk72xx-ard.yaml b/tests/components/api/test-outgoing-connection.bk72xx-ard.yaml new file mode 100644 index 0000000000..6de64e58f0 --- /dev/null +++ b/tests/components/api/test-outgoing-connection.bk72xx-ard.yaml @@ -0,0 +1,13 @@ +packages: + common: !include common-base.yaml + +wifi: + ssid: MySSID + password: password1 + +# Outgoing connection on the lwip_sockets implementation used by LibreTiny +api: + encryption: + key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU= + outgoing_connection: + host: 192.168.1.2