Address review findings and advertise support in device info

This commit is contained in:
J. Nick Koston
2026-08-31 16:44:49 -04:00
parent 4e0cda0287
commit fc671d38e5
11 changed files with 92 additions and 11 deletions
+4
View File
@@ -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 ====================
@@ -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;
@@ -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<SavedOutgoingTarget>(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 {
@@ -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<socket::Socket> 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};
+6
View File
@@ -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;
}
+4 -1
View File
@@ -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;
+3
View File
@@ -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();
}
+9 -1
View File
@@ -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<socket::Socket> sock) {
APIConnection *APIServer::add_outgoing_client_(std::unique_ptr<socket::Socket> 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) {
+2 -1
View File
@@ -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<socket::Socket> sock);
// Returns the new connection, or nullptr (socket dropped) when at the limit
APIConnection *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
+2
View File
@@ -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
@@ -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