From ff33e362cf4fd5e28986969160bc6abd9c08c9bb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 1 Jan 2026 14:55:16 -1000 Subject: [PATCH 01/12] wifi roam --- esphome/components/wifi/__init__.py | 11 ++ esphome/components/wifi/wifi_component.cpp | 142 ++++++++++++++++++-- esphome/components/wifi/wifi_component.h | 30 ++++- tests/components/wifi/test.esp32-idf.yaml | 1 + tests/components/wifi/test.esp8266-ard.yaml | 1 + 5 files changed, 169 insertions(+), 16 deletions(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 232e8d4f27..824944d4a2 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -64,6 +64,7 @@ _LOGGER = logging.getLogger(__name__) NO_WIFI_VARIANTS = [const.VARIANT_ESP32H2, const.VARIANT_ESP32P4] CONF_SAVE = "save" CONF_MIN_AUTH_MODE = "min_auth_mode" +CONF_POST_CONNECT_ROAMING = "post_connect_roaming" # Maximum number of WiFi networks that can be configured # Limited to 127 because selected_sta_index_ is int8_t in C++ @@ -349,6 +350,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_PASSIVE_SCAN, default=False): cv.boolean, cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean, + cv.Optional(CONF_POST_CONNECT_ROAMING, default=True): cv.boolean, cv.Optional(CONF_ON_CONNECT): automation.validate_automation(single=True), cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation( single=True @@ -491,6 +493,15 @@ async def to_code(config): if not config[CONF_ENABLE_ON_BOOT]: cg.add(var.set_enable_on_boot(False)) + # post_connect_roaming defaults to true in C++ - disable if user disabled it + # or if 802.11k/v is enabled (driver handles roaming natively) + if ( + not config[CONF_POST_CONNECT_ROAMING] + or config.get(CONF_ENABLE_BTM) + or config.get(CONF_ENABLE_RRM) + ): + cg.add(var.set_post_connect_roaming(False)) + if CORE.is_esp8266: cg.add_library("ESP8266WiFi", None) elif CORE.is_rp2040: diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 50c0938cf1..0fa998570b 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -489,7 +489,7 @@ void WiFiComponent::loop() { // Skip cooldown if new credentials were provided while connecting if (this->skip_cooldown_next_cycle_) { this->skip_cooldown_next_cycle_ = false; - this->check_connecting_finished(); + this->check_connecting_finished(now); break; } // Use longer cooldown when captive portal/improv is active to avoid disrupting user config @@ -500,7 +500,7 @@ void WiFiComponent::loop() { // a failure, or something tried to connect over and over // so we entered cooldown. In both cases we call // check_connecting_finished to continue the state machine. - this->check_connecting_finished(); + this->check_connecting_finished(now); } break; } @@ -511,7 +511,7 @@ void WiFiComponent::loop() { } case WIFI_COMPONENT_STATE_STA_CONNECTING: { this->status_set_warning(LOG_STR("associating to network")); - this->check_connecting_finished(); + this->check_connecting_finished(now); break; } @@ -525,6 +525,10 @@ void WiFiComponent::loop() { } else { this->status_clear_warning(); this->last_connected_ = now; + + // Post-connect roaming: check for better AP + this->check_roaming_(now); + this->process_roaming_scan_(now); } break; } @@ -681,8 +685,14 @@ float WiFiComponent::get_loop_priority() const { void WiFiComponent::init_sta(size_t count) { this->sta_.init(count); } void WiFiComponent::add_sta(const WiFiAP &ap) { this->sta_.push_back(ap); } +void WiFiComponent::clear_sta() { + // Clear roaming state - no more configured networks + this->clear_roaming_state_(); + this->sta_.clear(); + this->selected_sta_index_ = -1; +} void WiFiComponent::set_sta(const WiFiAP &ap) { - this->clear_sta(); + this->clear_sta(); // Also clears roaming state this->init_sta(1); this->add_sta(ap); this->selected_sta_index_ = 0; @@ -1163,7 +1173,7 @@ void WiFiComponent::dump_config() { this->print_connect_params_(); } -void WiFiComponent::check_connecting_finished() { +void WiFiComponent::check_connecting_finished(uint32_t now) { auto status = this->wifi_sta_connect_status_(); if (status == WiFiSTAConnectStatus::CONNECTED) { @@ -1209,6 +1219,9 @@ void WiFiComponent::check_connecting_finished() { this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTED; this->num_retried_ = 0; + // Reset roaming timer on successful connection + this->roaming_last_check_ = now; + // Clear priority tracking if all priorities are at minimum this->clear_priorities_if_all_min_(); @@ -1216,16 +1229,11 @@ void WiFiComponent::check_connecting_finished() { this->save_fast_connect_settings_(); #endif - // Free scan results memory unless a component needs them - if (!this->keep_scan_results_) { - this->scan_result_.clear(); - this->scan_result_.shrink_to_fit(); - } + this->release_scan_results_(); return; } - uint32_t now = millis(); if (now - this->action_started_ > WIFI_CONNECT_TIMEOUT_MS) { ESP_LOGW(TAG, "Connection timeout, aborting connection attempt"); this->wifi_disconnect_(); @@ -1632,6 +1640,11 @@ void WiFiComponent::advance_to_next_target_or_increment_retry_() { } void WiFiComponent::retry_connect() { + // Reset roaming state if this wasn't a roaming-initiated disconnect + if (!this->roaming_in_progress_) { + this->clear_roaming_state_(); + } + this->log_and_adjust_priority_for_failed_connect_(); // Determine next retry phase based on current state @@ -1874,6 +1887,113 @@ bool WiFiScanResult::get_is_hidden() const { return this->is_hidden_; } bool WiFiScanResult::operator==(const WiFiScanResult &rhs) const { return this->bssid_ == rhs.bssid_; } +void WiFiComponent::clear_roaming_state_() { + this->roaming_attempts_ = 0; + this->roaming_last_check_ = 0; + this->roaming_scan_active_ = false; +} + +void WiFiComponent::check_roaming_(uint32_t now) { + // Guard: feature enabled + if (!this->post_connect_roaming_) + return; + + // Guard: not for hidden networks (may not appear in scan) + const WiFiAP *selected = this->get_selected_sta_(); + if (selected == nullptr || selected->get_hidden()) + return; + + // Guard: attempt limit + if (this->roaming_attempts_ >= ROAMING_MAX_ATTEMPTS) + return; + + // Guard: scan not already active + if (this->roaming_scan_active_) + return; + + // Guard: interval check + if (now - this->roaming_last_check_ < ROAMING_CHECK_INTERVAL) + return; + + this->roaming_last_check_ = now; + ESP_LOGD(TAG, "Roaming: scanning for better AP (current RSSI %d dBm)", this->wifi_rssi()); + this->roaming_scan_active_ = true; + this->wifi_scan_start_(this->passive_scan_); +} + +void WiFiComponent::process_roaming_scan_(uint32_t now) { + // Not our scan + if (!this->roaming_scan_active_) + return; + + // Scan not done yet + if (!this->scan_done_) + return; + + this->scan_done_ = false; + this->roaming_scan_active_ = false; + + // Get current connection info + bssid_t current_bssid = this->wifi_bssid(); + int8_t current_rssi = this->wifi_rssi(); + std::string current_ssid = this->wifi_ssid(); + + // Find best candidate: same SSID, different BSSID + bssid_t best_bssid{}; + uint8_t best_channel = 0; + int8_t best_rssi = WIFI_RSSI_DISCONNECTED; + + for (const auto &result : this->scan_result_) { + // Must be same SSID as current connection + if (result.get_ssid() != current_ssid) + continue; + + // Must be different BSSID + if (result.get_bssid() == current_bssid) + continue; + + ESP_LOGV(TAG, "Roaming: candidate %s RSSI %d dB", result.get_ssid().c_str(), result.get_rssi()); + + // Track the best candidate + if (result.get_rssi() > best_rssi) { + best_rssi = result.get_rssi(); + best_bssid = result.get_bssid(); + best_channel = result.get_channel(); + } + } + + this->release_scan_results_(); + + // Check if best candidate meets minimum improvement threshold + int8_t improvement = (best_rssi == WIFI_RSSI_DISCONNECTED) ? 0 : best_rssi - current_rssi; + if (improvement < ROAMING_MIN_IMPROVEMENT) { + ESP_LOGD(TAG, "Roaming: best candidate %+d dB (need +%d dB)", improvement, ROAMING_MIN_IMPROVEMENT); + return; + } + + // Found better AP - initiate roam + this->roaming_attempts_++; + + char bssid_s[18]; + format_mac_addr_upper(best_bssid.data(), bssid_s); + ESP_LOGI(TAG, "Roaming: switching to %s (%d dBm, +%d dB improvement)", bssid_s, best_rssi, best_rssi - current_rssi); + + // Create roam parameters from current selected AP with target BSSID/channel + const WiFiAP *selected = this->get_selected_sta_(); + if (selected == nullptr) { + ESP_LOGW(TAG, "Roaming: selected AP is null"); + return; + } + + WiFiAP roam_params = *selected; + roam_params.set_bssid(best_bssid); + roam_params.set_channel(best_channel); + + // Connect directly - wifi_sta_connect_ handles disconnect internally + this->error_from_callback_ = false; + this->start_connecting(roam_params); +} + WiFiComponent *global_wifi_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome::wifi diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index ff2bfe12a4..93d72f601d 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -303,10 +303,7 @@ class WiFiComponent : public Component { WiFiAP get_sta() const; void init_sta(size_t count); void add_sta(const WiFiAP &ap); - void clear_sta() { - this->sta_.clear(); - this->selected_sta_index_ = -1; - } + void clear_sta(); #ifdef USE_WIFI_AP /** Setup an Access Point that should be created if no connection to a station can be made. @@ -330,7 +327,7 @@ class WiFiComponent : public Component { // Backward compatibility overload - ignores 'two' parameter void start_connecting(const WiFiAP &ap, bool /* two */) { this->start_connecting(ap); } - void check_connecting_finished(); + void check_connecting_finished(uint32_t now); void retry_connect(); @@ -420,6 +417,7 @@ class WiFiComponent : public Component { void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } void set_keep_scan_results(bool keep_scan_results) { this->keep_scan_results_ = keep_scan_results; } + void set_post_connect_roaming(bool enabled) { this->post_connect_roaming_ = enabled; } Trigger<> *get_connect_trigger() const { return this->connect_trigger_; }; Trigger<> *get_disconnect_trigger() const { return this->disconnect_trigger_; }; @@ -572,6 +570,19 @@ class WiFiComponent : public Component { void save_fast_connect_settings_(); #endif + // Post-connect roaming methods + void check_roaming_(uint32_t now); + void process_roaming_scan_(uint32_t now); + void clear_roaming_state_(); + + /// Free scan results memory unless a component needs them + void release_scan_results_() { + if (!this->keep_scan_results_) { + this->scan_result_.clear(); + this->scan_result_.shrink_to_fit(); + } + } + #ifdef USE_ESP8266 static void wifi_event_callback(System_Event_t *event); void wifi_scan_done_callback_(void *arg, STATUS status); @@ -614,10 +625,16 @@ class WiFiComponent : public Component { ESPPreferenceObject fast_connect_pref_; #endif + // Post-connect roaming constants + static constexpr uint32_t ROAMING_CHECK_INTERVAL = 90 * 1000; // 90s for testing, 5 min for prod + static constexpr int8_t ROAMING_MIN_IMPROVEMENT = 10; // dB + static constexpr uint8_t ROAMING_MAX_ATTEMPTS = 3; + // Group all 32-bit integers together uint32_t action_started_; uint32_t last_connected_{0}; uint32_t reboot_timeout_{}; + uint32_t roaming_last_check_{0}; #ifdef USE_WIFI_AP uint32_t ap_timeout_{}; #endif @@ -632,6 +649,7 @@ class WiFiComponent : public Component { // Used to access password, manual_ip, priority, EAP settings, and hidden flag // int8_t limits to 127 APs (enforced in __init__.py via MAX_WIFI_NETWORKS) int8_t selected_sta_index_{-1}; + uint8_t roaming_attempts_{0}; #if USE_NETWORK_IPV6 uint8_t num_ipv6_addresses_{0}; @@ -655,6 +673,8 @@ class WiFiComponent : public Component { bool keep_scan_results_{false}; bool did_scan_this_cycle_{false}; bool skip_cooldown_next_cycle_{false}; + bool post_connect_roaming_{true}; // Enabled by default + bool roaming_scan_active_{false}; #if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) WiFiPowerSaveMode configured_power_save_{WIFI_POWER_SAVE_NONE}; bool is_high_performance_mode_{false}; diff --git a/tests/components/wifi/test.esp32-idf.yaml b/tests/components/wifi/test.esp32-idf.yaml index 3e01d7f990..b2b2233ef3 100644 --- a/tests/components/wifi/test.esp32-idf.yaml +++ b/tests/components/wifi/test.esp32-idf.yaml @@ -14,6 +14,7 @@ esphome: wifi: use_psram: true min_auth_mode: WPA + post_connect_roaming: false manual_ip: static_ip: 192.168.1.100 gateway: 192.168.1.1 diff --git a/tests/components/wifi/test.esp8266-ard.yaml b/tests/components/wifi/test.esp8266-ard.yaml index 9cb0e3cf48..709a639ad6 100644 --- a/tests/components/wifi/test.esp8266-ard.yaml +++ b/tests/components/wifi/test.esp8266-ard.yaml @@ -1,5 +1,6 @@ wifi: min_auth_mode: WPA2 + post_connect_roaming: true packages: - !include common.yaml From 1def4df146d4e8be7799fbd3135b4a043600d73e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 1 Jan 2026 14:57:28 -1000 Subject: [PATCH 02/12] wip --- esphome/components/api/api_server.cpp | 32 +++++++++++++++++++-------- esphome/components/api/api_server.h | 1 + 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 7a03d8f8ad..af5ec9314c 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -25,6 +25,10 @@ namespace esphome::api { static const char *const TAG = "api"; +// Grace period before dropping API clients when network disconnects +// Allows for brief disconnections during WiFi roaming +static constexpr uint32_t NETWORK_DISCONNECT_GRACE_MS = 10000; + // APIServer APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -106,8 +110,10 @@ void APIServer::setup() { } #endif - // Initialize last_connected_ for reboot timeout tracking - this->last_connected_ = App.get_loop_component_start_time(); + // Initialize timestamps for timeout tracking + const uint32_t now = App.get_loop_component_start_time(); + this->last_connected_ = now; + this->network_last_connected_ = now; // Set warning status if reboot timeout is enabled if (this->reboot_timeout_ != 0) { this->status_set_warning(); @@ -162,14 +168,22 @@ void APIServer::loop() { // Process clients and remove disconnected ones in a single pass // Check network connectivity once for all clients - if (!network::is_connected()) { - // Network is down - disconnect all clients - for (auto &client : this->clients_) { - client->on_fatal_error(); - ESP_LOGW(TAG, "%s (%s): Network down; disconnect", client->client_info_.name.c_str(), - client->client_info_.peername.c_str()); + const uint32_t now = App.get_loop_component_start_time(); + if (network::is_connected()) { + // Network is up - track this for grace period + this->network_last_connected_ = now; + } else { + // Network is down - check if grace period has expired + // This allows brief disconnections during WiFi roaming without dropping API clients + if (now - this->network_last_connected_ > NETWORK_DISCONNECT_GRACE_MS) { + // Grace period expired - disconnect all clients + for (auto &client : this->clients_) { + client->on_fatal_error(); + ESP_LOGW(TAG, "%s (%s): Network down; disconnect", client->client_info_.name.c_str(), + client->client_info_.peername.c_str()); + } + // Continue to process and clean up the clients below } - // Continue to process and clean up the clients below } size_t client_index = 0; diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 96c56fd08a..ab7040a740 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -253,6 +253,7 @@ class APIServer : public Component, // 4-byte aligned types uint32_t reboot_timeout_{300000}; uint32_t last_connected_{0}; + uint32_t network_last_connected_{0}; // Track when network was last connected (for roaming grace period) // Vectors and strings (12 bytes each on 32-bit) std::vector> clients_; From 8b7bb4ecef020dff968c4372100a73c9d8166301 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 1 Jan 2026 14:59:19 -1000 Subject: [PATCH 03/12] wip --- esphome/components/wifi/wifi_component.cpp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 0fa998570b..3d8f888d53 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1640,10 +1640,8 @@ void WiFiComponent::advance_to_next_target_or_increment_retry_() { } void WiFiComponent::retry_connect() { - // Reset roaming state if this wasn't a roaming-initiated disconnect - if (!this->roaming_in_progress_) { - this->clear_roaming_state_(); - } + // Reset roaming state when entering retry flow + this->clear_roaming_state_(); this->log_and_adjust_priority_for_failed_connect_(); From 291722c50e9b9ace1ce9c5fc86e490efab06ead7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 1 Jan 2026 17:18:21 -1000 Subject: [PATCH 04/12] tweak --- esphome/components/wifi/wifi_component.cpp | 28 ++++++++++++++++------ esphome/components/wifi/wifi_component.h | 8 ++++++- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 3d8f888d53..92bd2d98e4 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1219,11 +1219,14 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTED; this->num_retried_ = 0; - // Reset roaming timer on successful connection + // Reset roaming state on successful connection this->roaming_last_check_ = now; + this->roaming_connect_active_ = false; - // Clear priority tracking if all priorities are at minimum - this->clear_priorities_if_all_min_(); + // Clear all priority penalties - successful connection forgives past failures + if (!this->sta_priorities_.empty()) { + decltype(this->sta_priorities_)().swap(this->sta_priorities_); + } #ifdef USE_WIFI_FAST_CONNECT this->save_fast_connect_settings_(); @@ -1501,8 +1504,7 @@ void WiFiComponent::clear_priorities_if_all_min_() { // All priorities are at minimum - clear the vector to save memory and reset ESP_LOGD(TAG, "Clearing BSSID priorities (all at minimum)"); - this->sta_priorities_.clear(); - this->sta_priorities_.shrink_to_fit(); + decltype(this->sta_priorities_)().swap(this->sta_priorities_); } /// Log failed connection attempt and decrease BSSID priority to avoid repeated failures @@ -1640,8 +1642,16 @@ void WiFiComponent::advance_to_next_target_or_increment_retry_() { } void WiFiComponent::retry_connect() { - // Reset roaming state when entering retry flow - this->clear_roaming_state_(); + // If this was a roaming attempt, preserve roaming_attempts_ count + // (so we stop roaming after ROAMING_MAX_ATTEMPTS failures) + // Otherwise reset all roaming state + if (this->roaming_connect_active_) { + this->roaming_connect_active_ = false; + this->roaming_scan_active_ = false; + // Keep roaming_attempts_ - will prevent further roaming after max failures + } else { + this->clear_roaming_state_(); + } this->log_and_adjust_priority_for_failed_connect_(); @@ -1889,6 +1899,7 @@ void WiFiComponent::clear_roaming_state_() { this->roaming_attempts_ = 0; this->roaming_last_check_ = 0; this->roaming_scan_active_ = false; + this->roaming_connect_active_ = false; } void WiFiComponent::check_roaming_(uint32_t now) { @@ -1987,6 +1998,9 @@ void WiFiComponent::process_roaming_scan_(uint32_t now) { roam_params.set_bssid(best_bssid); roam_params.set_channel(best_channel); + // Mark as roaming attempt - affects retry behavior if connection fails + this->roaming_connect_active_ = true; + // Connect directly - wifi_sta_connect_ handles disconnect internally this->error_from_callback_ = false; this->start_connecting(roam_params); diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 93d72f601d..71b44960ee 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -578,8 +578,13 @@ class WiFiComponent : public Component { /// Free scan results memory unless a component needs them void release_scan_results_() { if (!this->keep_scan_results_) { - this->scan_result_.clear(); +#ifdef USE_RP2040 + // std::vector - use swap trick since shrink_to_fit is non-binding + decltype(this->scan_result_)().swap(this->scan_result_); +#else + // FixedVector::shrink_to_fit() actually frees all memory this->scan_result_.shrink_to_fit(); +#endif } } @@ -675,6 +680,7 @@ class WiFiComponent : public Component { bool skip_cooldown_next_cycle_{false}; bool post_connect_roaming_{true}; // Enabled by default bool roaming_scan_active_{false}; + bool roaming_connect_active_{false}; // True during roaming connection attempt (skip priority decrease on fail) #if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) WiFiPowerSaveMode configured_power_save_{WIFI_POWER_SAVE_NONE}; bool is_high_performance_mode_{false}; From dc07926a9171544f6601d966f0c24f21add51008 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 1 Jan 2026 17:44:39 -1000 Subject: [PATCH 05/12] tweaks --- esphome/components/api/api_server.cpp | 32 ++++++------------- esphome/components/api/api_server.h | 1 - .../wifi/wifi_component_esp8266.cpp | 6 +++- 3 files changed, 14 insertions(+), 25 deletions(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index af5ec9314c..7a03d8f8ad 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -25,10 +25,6 @@ namespace esphome::api { static const char *const TAG = "api"; -// Grace period before dropping API clients when network disconnects -// Allows for brief disconnections during WiFi roaming -static constexpr uint32_t NETWORK_DISCONNECT_GRACE_MS = 10000; - // APIServer APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -110,10 +106,8 @@ void APIServer::setup() { } #endif - // Initialize timestamps for timeout tracking - const uint32_t now = App.get_loop_component_start_time(); - this->last_connected_ = now; - this->network_last_connected_ = now; + // Initialize last_connected_ for reboot timeout tracking + this->last_connected_ = App.get_loop_component_start_time(); // Set warning status if reboot timeout is enabled if (this->reboot_timeout_ != 0) { this->status_set_warning(); @@ -168,22 +162,14 @@ void APIServer::loop() { // Process clients and remove disconnected ones in a single pass // Check network connectivity once for all clients - const uint32_t now = App.get_loop_component_start_time(); - if (network::is_connected()) { - // Network is up - track this for grace period - this->network_last_connected_ = now; - } else { - // Network is down - check if grace period has expired - // This allows brief disconnections during WiFi roaming without dropping API clients - if (now - this->network_last_connected_ > NETWORK_DISCONNECT_GRACE_MS) { - // Grace period expired - disconnect all clients - for (auto &client : this->clients_) { - client->on_fatal_error(); - ESP_LOGW(TAG, "%s (%s): Network down; disconnect", client->client_info_.name.c_str(), - client->client_info_.peername.c_str()); - } - // Continue to process and clean up the clients below + if (!network::is_connected()) { + // Network is down - disconnect all clients + for (auto &client : this->clients_) { + client->on_fatal_error(); + ESP_LOGW(TAG, "%s (%s): Network down; disconnect", client->client_info_.name.c_str(), + client->client_info_.peername.c_str()); } + // Continue to process and clean up the clients below } size_t client_index = 0; diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index ab7040a740..96c56fd08a 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -253,7 +253,6 @@ class APIServer : public Component, // 4-byte aligned types uint32_t reboot_timeout_{300000}; uint32_t last_connected_{0}; - uint32_t network_last_connected_{0}; // Track when network was last connected (for roaming grace period) // Vectors and strings (12 bytes each on 32-bit) std::vector> clients_; diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 1c744648bb..335112a6f9 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -242,7 +242,11 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { if (!this->wifi_mode_(true, {})) return false; - this->wifi_disconnect_(); + // Skip disconnect for roaming - let the SDK handle the transition + // This preserves TCP connections during the brief AP switch + if (!this->roaming_connect_active_) { + this->wifi_disconnect_(); + } struct station_config conf {}; memset(&conf, 0, sizeof(conf)); From ab17775c3ede84f29e1551ff4484baeeb3dfa158 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 1 Jan 2026 17:45:05 -1000 Subject: [PATCH 06/12] tweaks --- esphome/components/wifi/wifi_component.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 92bd2d98e4..54c9e3d90b 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1924,8 +1924,13 @@ void WiFiComponent::check_roaming_(uint32_t now) { if (now - this->roaming_last_check_ < ROAMING_CHECK_INTERVAL) return; + // Guard: must have valid RSSI reading + int8_t current_rssi = this->wifi_rssi(); + if (current_rssi == WIFI_RSSI_DISCONNECTED) + return; + this->roaming_last_check_ = now; - ESP_LOGD(TAG, "Roaming: scanning for better AP (current RSSI %d dBm)", this->wifi_rssi()); + ESP_LOGD(TAG, "Roaming: scanning for better AP (current RSSI %d dBm)", current_rssi); this->roaming_scan_active_ = true; this->wifi_scan_start_(this->passive_scan_); } From dd6ed4aea68cb9a64d818b402f501550dd6e0fe1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 1 Jan 2026 17:48:20 -1000 Subject: [PATCH 07/12] [wifi] Add basic post-connect roaming support for stationary devices --- esphome/components/wifi/wifi_component.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 71b44960ee..cb02394bd6 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -631,8 +631,8 @@ class WiFiComponent : public Component { #endif // Post-connect roaming constants - static constexpr uint32_t ROAMING_CHECK_INTERVAL = 90 * 1000; // 90s for testing, 5 min for prod - static constexpr int8_t ROAMING_MIN_IMPROVEMENT = 10; // dB + static constexpr uint32_t ROAMING_CHECK_INTERVAL = 5 * 60 * 1000; // 5 minutes + static constexpr int8_t ROAMING_MIN_IMPROVEMENT = 10; // dB static constexpr uint8_t ROAMING_MAX_ATTEMPTS = 3; // Group all 32-bit integers together From 0a98f7877cc35b09e276b40b9c6cc006a6dc20fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 1 Jan 2026 22:49:21 -1000 Subject: [PATCH 08/12] tweak --- esphome/components/wifi/wifi_component.cpp | 23 ++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 54c9e3d90b..9b966408a9 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1966,7 +1966,13 @@ void WiFiComponent::process_roaming_scan_(uint32_t now) { if (result.get_bssid() == current_bssid) continue; - ESP_LOGV(TAG, "Roaming: candidate %s RSSI %d dB", result.get_ssid().c_str(), result.get_rssi()); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + { + char bssid_buf[18]; + format_mac_addr_upper(result.get_bssid().data(), bssid_buf); + ESP_LOGV(TAG, "Roaming: candidate %s RSSI %d dB", bssid_buf, result.get_rssi()); + } +#endif // Track the best candidate if (result.get_rssi() > best_rssi) { @@ -1981,23 +1987,20 @@ void WiFiComponent::process_roaming_scan_(uint32_t now) { // Check if best candidate meets minimum improvement threshold int8_t improvement = (best_rssi == WIFI_RSSI_DISCONNECTED) ? 0 : best_rssi - current_rssi; if (improvement < ROAMING_MIN_IMPROVEMENT) { - ESP_LOGD(TAG, "Roaming: best candidate %+d dB (need +%d dB)", improvement, ROAMING_MIN_IMPROVEMENT); + ESP_LOGV(TAG, "Roaming: best candidate %+d dB (need +%d dB)", improvement, ROAMING_MIN_IMPROVEMENT); return; } // Found better AP - initiate roam + const WiFiAP *selected = this->get_selected_sta_(); + if (selected == nullptr) + return; // Defensive: shouldn't happen since clear_sta() clears roaming_scan_active_ + this->roaming_attempts_++; char bssid_s[18]; format_mac_addr_upper(best_bssid.data(), bssid_s); - ESP_LOGI(TAG, "Roaming: switching to %s (%d dBm, +%d dB improvement)", bssid_s, best_rssi, best_rssi - current_rssi); - - // Create roam parameters from current selected AP with target BSSID/channel - const WiFiAP *selected = this->get_selected_sta_(); - if (selected == nullptr) { - ESP_LOGW(TAG, "Roaming: selected AP is null"); - return; - } + ESP_LOGI(TAG, "Roaming to %s (%+d dB)", bssid_s, improvement); WiFiAP roam_params = *selected; roam_params.set_bssid(best_bssid); From 5c890fcfc43b9309d03f2472e2e73e9dd0cddbda Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 2 Jan 2026 09:29:59 -1000 Subject: [PATCH 09/12] add roam diagram --- esphome/components/wifi/wifi_component.cpp | 56 +++++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 9b966408a9..b49fd2d6f4 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -143,6 +143,54 @@ static const char *const TAG = "wifi"; /// - Networks not in scan results → Tried in RETRY_HIDDEN phase /// - Networks visible in scan + not marked hidden → Skipped in RETRY_HIDDEN phase /// - Networks marked 'hidden: true' always use hidden mode, even if broadcasting SSID +/// +/// ┌──────────────────────────────────────────────────────────────────────┐ +/// │ Post-Connect Roaming (for stationary devices) │ +/// ├──────────────────────────────────────────────────────────────────────┤ +/// │ Purpose: Handle AP reboot or power loss scenarios where device │ +/// │ connects to suboptimal AP and never switches back │ +/// │ │ +/// │ ┌─────────────────┐ │ +/// │ │ STA_CONNECTED │ (non-hidden network, roaming enabled, │ +/// │ │ │ not already scanning/roaming) │ +/// │ └────────┬────────┘ │ +/// │ ↓ │ +/// │ ┌─────────────────┐ Every 5 minutes, up to 3 times │ +/// │ │ check_roaming_ │───────────────────────────────────────┐ │ +/// │ └────────┬────────┘ │ │ +/// │ ↓ │ │ +/// │ ┌─────────────────┐ │ │ +/// │ │ Start scan │ (same as normal scan) │ │ +/// │ └────────┬────────┘ │ │ +/// │ ↓ │ │ +/// │ ┌─────────────────────────┐ │ │ +/// │ │ process_roaming_scan_ │ roaming_attempts_++ │ │ +/// │ └────────┬───────────────┘ │ │ +/// │ ↓ │ │ +/// │ ┌─────────────────┐ No ┌───────────────┐ │ │ +/// │ │ +10dB better AP?├────────→│ Stay connected│─────────────┤ │ +/// │ └────────┬────────┘ └───────────────┘ │ │ +/// │ │ Yes │ │ +/// │ ↓ │ │ +/// │ ┌─────────────────┐ │ │ +/// │ │ start_connecting│ (roaming_connect_active_ = true) │ │ +/// │ └────────┬────────┘ │ │ +/// │ ↓ │ │ +/// │ ┌────┴────┐ │ │ +/// │ ↓ ↓ │ │ +/// │ ┌───────┐ ┌───────┐ │ │ +/// │ │SUCCESS│ │FAILED │ │ │ +/// │ └───┬───┘ └───┬───┘ │ │ +/// │ ↓ ↓ │ │ +/// │ Keep counter Keep counter │ │ +/// │ (no reset) retry_connect() │ │ +/// │ │ │ │ │ +/// │ └──────────────┴──────────────────────────────────────┘ │ +/// │ │ +/// │ After 3 scans: roaming_attempts_ >= 3, stop checking │ +/// │ Non-roaming disconnect: clear_roaming_state_() resets counter │ +/// │ Roaming success: counter preserved (prevents ping-pong) │ +/// └──────────────────────────────────────────────────────────────────────┘ static const LogString *retry_phase_to_log_string(WiFiRetryPhase phase) { switch (phase) { @@ -1221,6 +1269,11 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { // Reset roaming state on successful connection this->roaming_last_check_ = now; + // Only reset attempts if this wasn't a roaming-triggered connection + // (prevents ping-pong between APs) + if (!this->roaming_connect_active_) { + this->roaming_attempts_ = 0; + } this->roaming_connect_active_ = false; // Clear all priority penalties - successful connection forgives past failures @@ -1946,6 +1999,7 @@ void WiFiComponent::process_roaming_scan_(uint32_t now) { this->scan_done_ = false; this->roaming_scan_active_ = false; + this->roaming_attempts_++; // Get current connection info bssid_t current_bssid = this->wifi_bssid(); @@ -1996,8 +2050,6 @@ void WiFiComponent::process_roaming_scan_(uint32_t now) { if (selected == nullptr) return; // Defensive: shouldn't happen since clear_sta() clears roaming_scan_active_ - this->roaming_attempts_++; - char bssid_s[18]; format_mac_addr_upper(best_bssid.data(), bssid_s); ESP_LOGI(TAG, "Roaming to %s (%+d dB)", bssid_s, improvement); From d77fc596a9beff2c521a1bc643cc4f355d63553b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 2 Jan 2026 11:37:56 -1000 Subject: [PATCH 10/12] its going to drop anyways --- esphome/components/wifi/wifi_component_esp8266.cpp | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 335112a6f9..1c744648bb 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -242,11 +242,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { if (!this->wifi_mode_(true, {})) return false; - // Skip disconnect for roaming - let the SDK handle the transition - // This preserves TCP connections during the brief AP switch - if (!this->roaming_connect_active_) { - this->wifi_disconnect_(); - } + this->wifi_disconnect_(); struct station_config conf {}; memset(&conf, 0, sizeof(conf)); From 9b02daae2ba741aeb1f62a98ca8c3a7d43373658 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 2 Jan 2026 12:35:05 -1000 Subject: [PATCH 11/12] cleanup per bot --- esphome/components/wifi/wifi_component.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index b49fd2d6f4..0759c751df 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -168,7 +168,7 @@ static const char *const TAG = "wifi"; /// │ └────────┬───────────────┘ │ │ /// │ ↓ │ │ /// │ ┌─────────────────┐ No ┌───────────────┐ │ │ -/// │ │ +10dB better AP?├────────→│ Stay connected│─────────────┤ │ +/// │ │ +10 dB better AP├────────→│ Stay connected│─────────────┤ │ /// │ └────────┬────────┘ └───────────────┘ │ │ /// │ │ Yes │ │ /// │ ↓ │ │ From 5b4bd555dd8b2c1d82bb9b4a69cc0e89df011106 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 2 Jan 2026 12:36:44 -1000 Subject: [PATCH 12/12] cleanup per bot --- esphome/components/wifi/wifi_component.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 0759c751df..2027a5dc55 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -2024,7 +2024,7 @@ void WiFiComponent::process_roaming_scan_(uint32_t now) { { char bssid_buf[18]; format_mac_addr_upper(result.get_bssid().data(), bssid_buf); - ESP_LOGV(TAG, "Roaming: candidate %s RSSI %d dB", bssid_buf, result.get_rssi()); + ESP_LOGV(TAG, "Roaming: candidate %s RSSI %d dBm", bssid_buf, result.get_rssi()); } #endif