Dial immediately when no client has ever connected and polish review nits

This commit is contained in:
J. Nick Koston
2026-08-31 17:36:57 -04:00
parent 5fbe09b68c
commit 79325fe59a
6 changed files with 50 additions and 13 deletions
+23 -1
View File
@@ -25,6 +25,7 @@ from esphome.const import (
CONF_DATA,
CONF_DATA_TEMPLATE,
CONF_DELAY,
CONF_ENABLE_IPV6,
CONF_ENCRYPTION,
CONF_EVENT,
CONF_ID,
@@ -48,6 +49,7 @@ from esphome.const import (
)
from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority
from esphome.cpp_generator import MockObj, TemplateArgsType
import esphome.final_validate as fv
from esphome.helpers import fnv1_hash
from esphome.types import ConfigFragmentType, ConfigType
@@ -462,7 +464,27 @@ def _validate_esp8266_action_strings(config: ConfigType) -> ConfigType:
return config
FINAL_VALIDATE_SCHEMA = _validate_esp8266_action_strings
def _validate_outgoing_host_ipv6(config: ConfigType) -> ConfigType:
"""An IPv6 host silently dials 255.255.255.255 on a build without IPv6."""
if (
(outgoing := config.get(CONF_OUTGOING_CONNECTION)) is None
or (host := outgoing.get(CONF_HOST)) is None
or host.version != 6
):
return config
network_conf = fv.full_config.get().get("network") or {}
if not network_conf.get(CONF_ENABLE_IPV6):
raise cv.Invalid(
"outgoing_connection host is an IPv6 address but IPv6 is not "
"enabled; set 'network: enable_ipv6: true'",
path=[CONF_OUTGOING_CONNECTION, CONF_HOST],
)
return config
FINAL_VALIDATE_SCHEMA = cv.All(
_validate_esp8266_action_strings, _validate_outgoing_host_ipv6
)
def _add_action_strings(
@@ -45,8 +45,9 @@ void OutgoingConnectionManager::loop(APIServer *server) {
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.
// The connected target went away; give it the configured delay to
// reconnect on its own before dialing. (Boot skips this state so a
// device whose client never reached it dials right away.)
this->state_ = DialState::DIAL_STATE_WAITING;
this->state_ts_ = now;
this->wait_ = API_OUTGOING_CONNECTION_DELAY;
@@ -117,6 +118,7 @@ void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) {
this->last_poll_ = now;
int fd = this->dial_socket_->get_fd();
if (fd < 0 || fd >= FD_SETSIZE) {
ESP_LOGW(TAG, "Bad fd for connect poll: %d", fd);
this->schedule_retry_(now);
return;
}
@@ -205,6 +207,8 @@ void OutgoingConnectionManager::dump_config() const {
#else
if (this->saved_.host[0] != '\0') {
ESP_LOGCONFIG(TAG, " Outgoing connection host: %s (remembered)", this->saved_.host);
} else {
ESP_LOGCONFIG(TAG, " Outgoing connection host: none remembered yet");
}
#endif
}
@@ -6,6 +6,9 @@
#ifdef USE_SOCKET_IMPL_LWIP_TCP
#error "api outgoing_connection needs a socket implementation that can make outgoing connections"
#endif
#ifndef USE_API_NOISE
#error "api outgoing_connection needs noise encryption so the peer is verified by key"
#endif
#include "esphome/components/socket/socket.h"
#include "esphome/core/preferences.h"
@@ -41,7 +44,10 @@ class OutgoingConnectionManager {
/// re-dials. A dialed connection dying without ever sending the flagged
/// hello is the unproven-peer case, so the backoff escalates here.
void on_client_removed(APIConnection *conn);
void on_shutdown() { this->dial_socket_.reset(); }
void on_shutdown() {
this->dial_socket_.reset();
this->state_ = DialState::DIAL_STATE_IDLE;
}
void dump_config() const;
protected:
@@ -88,7 +94,10 @@ class OutgoingConnectionManager {
uint32_t wait_{0};
uint32_t state_ts_{0};
uint32_t last_poll_{0};
DialState state_{DialState::DIAL_STATE_IDLE};
// Boot starts in WAITING with wait_ 0: when no dial-back client has ever
// connected (a device its client could never reach), dial immediately.
// The configured delay only applies after a connected target goes away.
DialState state_{DialState::DIAL_STATE_WAITING};
};
} // namespace esphome::api
+5 -3
View File
@@ -288,9 +288,11 @@ void APIServer::add_client_(APIConnection *conn) {
#ifdef USE_API_OUTGOING_CONNECTION
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);
// flight; re-check at the handoff so add_client_ cannot write past clients_.
// The PSK can also have been cleared mid-dial, and mark_outgoing() requires
// the noise helper the constructor only picks while a PSK is set.
if (this->at_client_limit_() || !this->noise_ctx_.has_psk()) {
ESP_LOGW(TAG, "Dropping outgoing connection (%s)", this->at_client_limit_() ? "max connections" : "no key active");
return nullptr;
}
auto *conn = new APIConnection(std::move(sock), this);
@@ -5,9 +5,8 @@ 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.
# Outgoing connection: the device dials out when no dial-back client is
# connected. Requires encryption so the peer is verified by key.
api:
encryption:
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
@@ -153,8 +153,9 @@ async def test_api_outgoing_connection_remembered(
"""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.
# Bound but not yet listening so first-phase dials cannot queue stale
# connections; whether the device attempts any dial before the restart
# is timing dependent and not asserted here.
listener.bind(("127.0.0.1", 0))
port = listener.getsockname()[1]