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 67bb668151..e1a8e21076 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 ┌───────────────┐ │ │ +/// │ │ +10 dB 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) { @@ -489,7 +537,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 +548,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 +559,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 +573,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; } @@ -684,8 +736,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; @@ -1172,7 +1230,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) { @@ -1219,23 +1277,29 @@ void WiFiComponent::check_connecting_finished() { this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTED; this->num_retried_ = 0; - // 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 - 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_(); #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_(); @@ -1503,8 +1567,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 @@ -1643,6 +1706,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 @@ -1885,6 +1959,124 @@ 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::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; + + // 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)", current_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; + this->roaming_attempts_++; + + // 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; + +#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 dBm", bssid_buf, result.get_rssi()); + } +#endif + + // 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_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_ + + char bssid_s[18]; + format_mac_addr_upper(best_bssid.data(), bssid_s); + ESP_LOGI(TAG, "Roaming to %s (%+d dB)", bssid_s, improvement); + + WiFiAP roam_params = *selected; + 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); +} + 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 5bf1f444e8..3808fbeb4e 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -297,10 +297,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. @@ -324,7 +321,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(); @@ -414,6 +411,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_; }; @@ -566,6 +564,24 @@ 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_) { +#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 + } + } + #ifdef USE_ESP8266 static void wifi_event_callback(System_Event_t *event); void wifi_scan_done_callback_(void *arg, STATUS status); @@ -604,10 +620,16 @@ 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 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 @@ -622,6 +644,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}; @@ -645,6 +668,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 (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}; 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