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 2d635d893f..6654474329 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -28,6 +28,7 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/string_ref.h" #include "esphome/core/util.h" #ifdef USE_CAPTIVE_PORTAL @@ -143,6 +144,56 @@ 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 │ +/// │ │ +/// │ Loop call site: roaming enabled && attempts < 3 && 5 min elapsed │ +/// │ ↓ │ +/// │ ┌─────────────────┐ Hidden? ┌──────────────────────────┐ │ +/// │ │ check_roaming_ ├───────────→│ attempts = MAX, stop │ │ +/// │ └────────┬────────┘ └──────────────────────────┘ │ +/// │ ↓ │ +/// │ attempts++, update last_check │ +/// │ ↓ │ +/// │ RSSI > -49 dBm? ────Yes────→ Skip scan (excellent signal)─┐ │ +/// │ ↓ No │ │ +/// │ ┌─────────────────┐ │ │ +/// │ │ Start scan │ │ │ +/// │ └────────┬────────┘ │ │ +/// │ ↓ │ │ +/// │ ┌────────────────────────┐ │ │ +/// │ │ process_roaming_scan_ │ │ │ +/// │ └────────┬───────────────┘ │ │ +/// │ ↓ │ │ +/// │ ┌─────────────────┐ No ┌───────────────┐ │ │ +/// │ │ +10 dB better AP├────────→│ Stay connected│───────────────┤ │ +/// │ └────────┬────────┘ └───────────────┘ │ │ +/// │ │ Yes │ │ +/// │ ↓ │ │ +/// │ ┌─────────────────┐ │ │ +/// │ │ start_connecting│ (roaming_connect_active_ = true) │ │ +/// │ └────────┬────────┘ │ │ +/// │ ↓ │ │ +/// │ ┌────┴────┐ │ │ +/// │ ↓ ↓ │ │ +/// │ ┌───────┐ ┌───────┐ │ │ +/// │ │SUCCESS│ │FAILED │ │ │ +/// │ └───┬───┘ └───┬───┘ │ │ +/// │ ↓ ↓ │ │ +/// │ Keep counter retry_connect() → normal reconnect flow │ │ +/// │ (no reset) (keeps counter, handles retries) │ │ +/// │ │ │ │ │ +/// │ └──────────────┴────────────────────────────────────────┘ │ +/// │ │ +/// │ After 3 checks: attempts >= 3, stop checking │ +/// │ Non-roaming disconnect: clear_roaming_state_() resets counter │ +/// │ Roaming success: counter preserved (prevents ping-pong) │ +/// │ Roaming fail: normal flow handles reconnection, counter preserved │ +/// └──────────────────────────────────────────────────────────────────────┘ static const LogString *retry_phase_to_log_string(WiFiRetryPhase phase) { switch (phase) { @@ -484,7 +535,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 @@ -495,7 +546,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; } @@ -506,7 +557,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; } @@ -520,6 +571,19 @@ void WiFiComponent::loop() { } else { this->status_clear_warning(); this->last_connected_ = now; + + // Post-connect roaming: check for better AP + if (this->post_connect_roaming_) { + if (this->roaming_scan_active_) { + if (this->scan_done_) { + this->process_roaming_scan_(); + } + // else: scan in progress, wait + } else if (this->roaming_attempts_ < ROAMING_MAX_ATTEMPTS && + now - this->roaming_last_check_ >= ROAMING_CHECK_INTERVAL) { + this->check_roaming_(now); + } + } } break; } @@ -679,8 +743,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; @@ -1184,7 +1254,7 @@ void WiFiComponent::dump_config() { } } -void WiFiComponent::check_connecting_finished() { +void WiFiComponent::check_connecting_finished(uint32_t now) { auto status = this->wifi_sta_connect_status_(); if (status == WiFiSTAConnectStatus::CONNECTED) { @@ -1230,23 +1300,28 @@ void WiFiComponent::check_connecting_finished() { this->num_retried_ = 0; this->print_connect_params_(); - // Clear priority tracking if all priorities are at minimum - this->clear_priorities_if_all_min_(); + // 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 - the next reconnect will happen when an AP disconnects, + // which means the landscape has likely changed and previous tracked failures are stale + this->clear_all_bssid_priorities_(); #ifdef USE_WIFI_FAST_CONNECT 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_(); @@ -1490,9 +1565,15 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { return false; // Did not start scan, can proceed with connection } +void WiFiComponent::clear_all_bssid_priorities_() { + if (!this->sta_priorities_.empty()) { + decltype(this->sta_priorities_)().swap(this->sta_priorities_); + } +} + /// Clear BSSID priority tracking if all priorities are at minimum (saves memory) /// At minimum priority, all BSSIDs are equally bad, so priority tracking is useless -/// Called after successful connection or after failed connection attempts +/// Called after failed connection attempts void WiFiComponent::clear_priorities_if_all_min_() { if (this->sta_priorities_.empty()) { return; @@ -1514,8 +1595,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(); + this->clear_all_bssid_priorities_(); } /// Log failed connection attempt and decrease BSSID priority to avoid repeated failures @@ -1653,6 +1733,17 @@ void WiFiComponent::advance_to_next_target_or_increment_retry_() { } void WiFiComponent::retry_connect() { + // 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_(); // Determine next retry phase based on current state @@ -1895,6 +1986,106 @@ 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; + this->roaming_connect_active_ = false; +} + +void WiFiComponent::release_scan_results_() { + if (!this->keep_scan_results_) { +#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 + } +} + +void WiFiComponent::check_roaming_(uint32_t now) { + // Guard: not for hidden networks (may not appear in scan) + const WiFiAP *selected = this->get_selected_sta_(); + if (selected == nullptr || selected->get_hidden()) { + this->roaming_attempts_ = ROAMING_MAX_ATTEMPTS; // Stop checking forever + return; + } + + this->roaming_last_check_ = now; + this->roaming_attempts_++; + + // Guard: skip scan if signal is already good (no meaningful improvement possible) + int8_t rssi = this->wifi_rssi(); + if (rssi > ROAMING_GOOD_RSSI) + return; + + ESP_LOGD(TAG, "Roam scan (%d dBm)", rssi); + this->roaming_scan_active_ = true; + this->wifi_scan_start_(this->passive_scan_); +} + +void WiFiComponent::process_roaming_scan_() { + this->scan_done_ = false; + this->roaming_scan_active_ = false; + + // Get current connection info + int8_t current_rssi = this->wifi_rssi(); + // Guard: must still be connected (RSSI may have become invalid during scan) + if (current_rssi == WIFI_RSSI_DISCONNECTED) { + this->release_scan_results_(); + return; + } + + char ssid_buf[SSID_BUFFER_SIZE]; + StringRef current_ssid(this->wifi_ssid_to(ssid_buf)); + bssid_t current_bssid = this->wifi_bssid(); + + // Find best candidate: same SSID, different BSSID + const WiFiScanResult *best = nullptr; + char bssid_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + + for (const auto &result : this->scan_result_) { + // Must be same SSID, different BSSID + if (current_ssid != result.get_ssid() || result.get_bssid() == current_bssid) + continue; + +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + format_mac_addr_upper(result.get_bssid().data(), bssid_buf); + ESP_LOGV(TAG, "Roam candidate %s %d dBm", bssid_buf, result.get_rssi()); +#endif + + // Track the best candidate + if (best == nullptr || result.get_rssi() > best->get_rssi()) { + best = &result; + } + } + + // Check if best candidate meets minimum improvement threshold + const WiFiAP *selected = this->get_selected_sta_(); + int8_t improvement = (best == nullptr) ? 0 : best->get_rssi() - current_rssi; + if (selected == nullptr || improvement < ROAMING_MIN_IMPROVEMENT) { + ESP_LOGV(TAG, "Roam best %+d dB (need +%d)", improvement, ROAMING_MIN_IMPROVEMENT); + this->release_scan_results_(); + return; + } + + format_mac_addr_upper(best->get_bssid().data(), bssid_buf); + ESP_LOGI(TAG, "Roaming to %s (%+d dB)", bssid_buf, improvement); + + WiFiAP roam_params = *selected; + apply_scan_result_to_params(roam_params, *best); + this->release_scan_results_(); + + // 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); +} + 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 1906b672b8..09af384725 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -301,10 +301,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. @@ -328,7 +325,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(); @@ -418,6 +415,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_; }; @@ -507,6 +505,8 @@ class WiFiComponent : public Component { int8_t find_next_hidden_sta_(int8_t start_index); /// Log failed connection and decrease BSSID priority to avoid repeated attempts void log_and_adjust_priority_for_failed_connect_(); + /// Clear all BSSID priority penalties after successful connection (stale after disconnect) + void clear_all_bssid_priorities_(); /// Clear BSSID priority tracking if all priorities are at minimum (saves memory) void clear_priorities_if_all_min_(); /// Advance to next target (AP/SSID) within current phase, or increment retry counter @@ -570,6 +570,14 @@ class WiFiComponent : public Component { void save_fast_connect_settings_(); #endif + // Post-connect roaming methods + void check_roaming_(uint32_t now); + void process_roaming_scan_(); + void clear_roaming_state_(); + + /// Free scan results memory unless a component needs them + void release_scan_results_(); + #ifdef USE_ESP8266 static void wifi_event_callback(System_Event_t *event); void wifi_scan_done_callback_(void *arg, STATUS status); @@ -609,10 +617,17 @@ class WiFiComponent : public Component { ESPPreferenceObject fast_connect_pref_; #endif + // Post-connect roaming constants + static constexpr uint32_t ROAMING_CHECK_INTERVAL = 5 * 60 * 1000; // 5 minutes + static constexpr int8_t ROAMING_MIN_IMPROVEMENT = 10; // dB + static constexpr int8_t ROAMING_GOOD_RSSI = -49; // Skip scan if signal is excellent + 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 @@ -627,6 +642,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}; @@ -650,6 +666,9 @@ 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}; + bool roaming_connect_active_{false}; // True during roaming connection attempt (preserves roaming_attempts_) #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