1
0
mirror of https://github.com/esphome/esphome.git synced 2026-02-08 00:31:58 +00:00

[wifi] Fix infinite roaming when best-signal AP is crashed/broken (#13071)

This commit is contained in:
J. Nick Koston
2026-01-08 11:42:06 -10:00
committed by GitHub
parent 325c938074
commit 52459d1bc7
2 changed files with 96 additions and 65 deletions

View File

@@ -151,48 +151,51 @@ static const char *const TAG = "wifi";
/// │ 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) │ │
/// │ │ │ │ │
/// │ └──────────────┴────────────────────────────────────────┘ │
/// │ State Machine (RoamingState):
/// │ │
/// │ 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
/// │ ┌─────────────────────────────────────────────────────────────┐
/// │ │ IDLE │
/// │ │ (waiting for 5 min timer, attempts < 3)
/// │ └─────────────────────────┬───────────────────────────────────┘
/// │ │ 5 min elapsed, RSSI < -49 dBm │
/// │ ↓ │
/// │ ┌─────────────────────────────────────────────────────────────┐ │
/// │ │ SCANNING │ │
/// │ │ (attempts++ in check_roaming_ before entering this state) │ │
/// │ └─────────────────────────┬───────────────────────────────────┘ │
/// │ │ │
/// │ ┌──────────────┼──────────────┐ │
/// │ ↓ ↓ ↓ │
/// │ scan error no better AP +10 dB better AP │
/// │ │ │ │ │
/// │ ↓ ↓ ↓ │
/// │ ┌──────────────────────────────┐ ┌──────────────────────────┐ │
/// │ │ → IDLE │ │ CONNECTING │ │
/// │ │ (counter preserved) │ │ (process_roaming_scan_) │ │
/// │ └──────────────────────────────┘ └────────────┬─────────────┘ │
/// │ │ │
/// │ ┌───────────────────┴───────────────┐ │
/// │ ↓ ↓ │
/// │ SUCCESS FAILED │
/// │ │ │ │
/// │ ↓ ↓ │
/// │ ┌──────────────────────────────────┐ ┌─────────────────────────┐
/// │ │ → IDLE │ │ RECONNECTING │
/// │ │ (counter reset to 0) │ │ (retry_connect called) │
/// │ └──────────────────────────────────┘ └───────────┬─────────────┘
/// │ │ │
/// │ ↓ │
/// │ ┌───────────────────────┐ │
/// │ │ → IDLE │ │
/// │ │ (counter preserved!) │ │
/// │ └───────────────────────┘ │
/// │ │
/// │ Key behaviors: │
/// │ - After 3 checks: attempts >= 3, stop checking │
/// │ - Non-roaming disconnect: clear_roaming_state_() resets counter │
/// │ - Scan error (SCANNING→IDLE): counter preserved │
/// │ - Roaming success (CONNECTING→IDLE): counter reset (can roam again) │
/// │ - Roaming fail (RECONNECTING→IDLE): counter preserved (ping-pong) │
/// └──────────────────────────────────────────────────────────────────────┘
static const LogString *retry_phase_to_log_string(WiFiRetryPhase phase) {
@@ -574,12 +577,12 @@ void WiFiComponent::loop() {
// Post-connect roaming: check for better AP
if (this->post_connect_roaming_) {
if (this->roaming_scan_active_) {
if (this->roaming_state_ == RoamingState::SCANNING) {
if (this->scan_done_) {
this->process_roaming_scan_();
}
// else: scan in progress, wait
} else if (this->roaming_attempts_ < ROAMING_MAX_ATTEMPTS &&
} else if (this->roaming_state_ == RoamingState::IDLE && this->roaming_attempts_ < ROAMING_MAX_ATTEMPTS &&
now - this->roaming_last_check_ >= ROAMING_CHECK_INTERVAL) {
this->check_roaming_(now);
}
@@ -1302,12 +1305,20 @@ 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_) {
// Only preserve attempts if reconnecting after a failed roam attempt
// This prevents ping-pong between APs when a roam target is unreachable
if (this->roaming_state_ == RoamingState::CONNECTING) {
// Successful roam to better AP - reset attempts so we can roam again later
ESP_LOGD(TAG, "Roam successful");
this->roaming_attempts_ = 0;
} else if (this->roaming_state_ == RoamingState::RECONNECTING) {
// Failed roam, reconnected via normal recovery - keep attempts to prevent ping-pong
ESP_LOGD(TAG, "Reconnected after failed roam (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
} else {
// Normal connection (boot, credentials changed, etc.)
this->roaming_attempts_ = 0;
}
this->roaming_connect_active_ = false;
this->roaming_state_ = RoamingState::IDLE;
// 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
@@ -1733,16 +1744,21 @@ 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 {
// Handle roaming state transitions - preserve attempts counter to prevent ping-pong
// to unreachable APs after ROAMING_MAX_ATTEMPTS failures
if (this->roaming_state_ == RoamingState::CONNECTING) {
// Roam connection failed - transition to reconnecting
ESP_LOGD(TAG, "Roam failed, reconnecting (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
this->roaming_state_ = RoamingState::RECONNECTING;
} else if (this->roaming_state_ == RoamingState::SCANNING) {
// Roam scan failed (e.g., scan error on ESP8266) - go back to idle, keep counter
ESP_LOGD(TAG, "Roam scan failed (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
this->roaming_state_ = RoamingState::IDLE;
} else if (this->roaming_state_ == RoamingState::IDLE) {
// Not a roaming-triggered reconnect, reset state
this->clear_roaming_state_();
}
// RECONNECTING: keep state and counter, still trying to reconnect
this->log_and_adjust_priority_for_failed_connect_();
@@ -1989,8 +2005,7 @@ bool WiFiScanResult::operator==(const WiFiScanResult &rhs) const { return this->
void WiFiComponent::clear_roaming_state_() {
this->roaming_attempts_ = 0;
this->roaming_last_check_ = 0;
this->roaming_scan_active_ = false;
this->roaming_connect_active_ = false;
this->roaming_state_ = RoamingState::IDLE;
}
void WiFiComponent::release_scan_results_() {
@@ -2018,17 +2033,21 @@ void WiFiComponent::check_roaming_(uint32_t now) {
// Guard: skip scan if signal is already good (no meaningful improvement possible)
int8_t rssi = this->wifi_rssi();
if (rssi > ROAMING_GOOD_RSSI)
if (rssi > ROAMING_GOOD_RSSI) {
ESP_LOGV(TAG, "Roam check skipped, signal good (%d dBm, attempt %u/%u)", rssi, this->roaming_attempts_,
ROAMING_MAX_ATTEMPTS);
return;
}
ESP_LOGD(TAG, "Roam scan (%d dBm)", rssi);
this->roaming_scan_active_ = true;
ESP_LOGD(TAG, "Roam scan (%d dBm, attempt %u/%u)", rssi, this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
this->roaming_state_ = RoamingState::SCANNING;
this->wifi_scan_start_(this->passive_scan_);
}
void WiFiComponent::process_roaming_scan_() {
this->scan_done_ = false;
this->roaming_scan_active_ = false;
// Default to IDLE - will be set to CONNECTING if we find a better AP
this->roaming_state_ = RoamingState::IDLE;
// Get current connection info
int8_t current_rssi = this->wifi_rssi();
@@ -2066,7 +2085,8 @@ void WiFiComponent::process_roaming_scan_() {
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);
ESP_LOGV(TAG, "Roam best %+d dB (need +%d), attempt %u/%u", improvement, ROAMING_MIN_IMPROVEMENT,
this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
this->release_scan_results_();
return;
}
@@ -2079,7 +2099,7 @@ void WiFiComponent::process_roaming_scan_() {
this->release_scan_results_();
// Mark as roaming attempt - affects retry behavior if connection fails
this->roaming_connect_active_ = true;
this->roaming_state_ = RoamingState::CONNECTING;
// Connect directly - wifi_sta_connect_ handles disconnect internally
this->error_from_callback_ = false;

View File

@@ -112,6 +112,18 @@ enum class WiFiRetryPhase : uint8_t {
RESTARTING_ADAPTER,
};
/// Tracks post-connect roaming state machine
enum class RoamingState : uint8_t {
/// Not roaming, waiting for next check interval
IDLE,
/// Scanning for better AP
SCANNING,
/// Attempting to connect to better AP found in scan
CONNECTING,
/// Roam connection failed, reconnecting to any available AP
RECONNECTING,
};
/// Struct for setting static IPs in WiFiComponent.
struct ManualIP {
network::IPAddress static_ip;
@@ -667,8 +679,7 @@ class WiFiComponent : public Component {
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_)
RoamingState roaming_state_{RoamingState::IDLE};
#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
WiFiPowerSaveMode configured_power_save_{WIFI_POWER_SAVE_NONE};
bool is_high_performance_mode_{false};