#include "wifi_component.h" #ifdef USE_WIFI #include #include #include #include #ifdef USE_ESP32 #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0) #include #else #include #endif #endif #if defined(USE_ESP32) #include #endif #ifdef USE_ESP8266 #include #endif #include #include #include #include "lwip/dns.h" #include "lwip/err.h" #include "esphome/core/application.h" #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 #include "esphome/components/captive_portal/captive_portal.h" #endif #ifdef USE_IMPROV #include "esphome/components/esp32_improv/esp32_improv_component.h" #endif #ifdef USE_IMPROV_SERIAL #include "esphome/components/improv_serial/improv_serial_component.h" #endif namespace esphome::wifi { static const char *const TAG = "wifi"; // CompactString implementation CompactString::CompactString(const char *str, size_t len) { if (len > MAX_LENGTH) { len = MAX_LENGTH; // Clamp to max valid length } this->length_ = len; if (len <= INLINE_CAPACITY) { // Store inline with null terminator this->is_heap_ = 0; if (len > 0) { std::memcpy(this->storage_, str, len); } this->storage_[len] = '\0'; } else { // Heap allocate with null terminator this->is_heap_ = 1; char *heap_data = new char[len + 1]; // NOLINT(cppcoreguidelines-owning-memory) std::memcpy(heap_data, str, len); heap_data[len] = '\0'; this->set_heap_ptr_(heap_data); } } CompactString::CompactString(const CompactString &other) : CompactString(other.data(), other.size()) {} CompactString &CompactString::operator=(const CompactString &other) { if (this != &other) { this->~CompactString(); new (this) CompactString(other); } return *this; } CompactString::CompactString(CompactString &&other) noexcept : length_(other.length_), is_heap_(other.is_heap_) { // Copy full storage (includes null terminator for inline, or pointer for heap) std::memcpy(this->storage_, other.storage_, INLINE_CAPACITY + 1); other.length_ = 0; other.is_heap_ = 0; other.storage_[0] = '\0'; } CompactString &CompactString::operator=(CompactString &&other) noexcept { if (this != &other) { this->~CompactString(); new (this) CompactString(std::move(other)); } return *this; } CompactString::~CompactString() { if (this->is_heap_) { delete[] this->get_heap_ptr_(); // NOLINT(cppcoreguidelines-owning-memory) } } bool CompactString::operator==(const CompactString &other) const { return this->size() == other.size() && std::memcmp(this->data(), other.data(), this->size()) == 0; } bool CompactString::operator==(const StringRef &other) const { return this->size() == other.size() && std::memcmp(this->data(), other.c_str(), this->size()) == 0; } /// WiFi Retry Logic - Priority-Based BSSID Selection /// /// The WiFi component uses a state machine with priority degradation to handle connection failures /// and automatically cycle through different BSSIDs in mesh networks or multiple configured networks. /// /// Normal Connection Flow (SCAN_BASED): /// ┌──────────────────────────────────────────────────────────────────────┐ /// │ Fast Connect Path (Optional) │ /// ├──────────────────────────────────────────────────────────────────────┤ /// │ Entered if: configuration has 'fast_connect: true' │ /// │ Optimization to skip scanning when possible: │ /// │ │ /// │ 1. INITIAL_CONNECT → Try one of: │ /// │ a) Saved BSSID+channel (from previous boot) │ /// │ b) First configured non-hidden network (any BSSID) │ /// │ ↓ │ /// │ [FAILED] → Check if more configured networks available │ /// │ ↓ │ /// │ 2. FAST_CONNECT_CYCLING_APS → Try remaining configured networks │ /// │ (1 attempt each, any BSSID) │ /// │ ↓ │ /// │ [All Failed] → Fall through to explicit hidden or scanning │ /// │ │ /// │ Note: Fast connect data saved from previous successful connection │ /// └──────────────────────────────────────────────────────────────────────┘ /// ↓ /// ┌──────────────────────────────────────────────────────────────────────┐ /// │ Explicit Hidden Networks Path (Optional) │ /// ├──────────────────────────────────────────────────────────────────────┤ /// │ Entered if: first configured network has 'hidden: true' │ /// │ │ /// │ 1. EXPLICIT_HIDDEN → Try consecutive hidden networks (1 attempt) │ /// │ Stop when visible network reached │ /// │ ↓ │ /// │ Example: Hidden1, Hidden2, Visible1, Hidden3, Visible2 │ /// │ Try: Hidden1, Hidden2 (stop at Visible1) │ /// │ ↓ │ /// │ [All Failed] → Fall back to scan-based connection │ /// │ │ /// │ Note: Fast connect saves BSSID after first successful connection, │ /// │ so subsequent boots use fast path instead of hidden mode │ /// └──────────────────────────────────────────────────────────────────────┘ /// ↓ /// ┌──────────────────────────────────────────────────────────────────────┐ /// │ Scan-Based Connection Path │ /// ├──────────────────────────────────────────────────────────────────────┤ /// │ │ /// │ 1. SCAN → Sort by priority (highest first), then RSSI │ /// │ ┌─────────────────────────────────────────────────┐ │ /// │ │ scan_result_[0] = Best BSSID (highest priority) │ │ /// │ │ scan_result_[1] = Second best │ │ /// │ │ scan_result_[2] = Third best │ │ /// │ └─────────────────────────────────────────────────┘ │ /// │ ↓ │ /// │ 2. SCAN_CONNECTING → Try scan_result_[0] (2 attempts) │ /// │ (Visible1, Visible2 from example above) │ /// │ ↓ │ /// │ 3. FAILED → Decrease priority: 0.0 → -1.0 → -2.0 │ /// │ (stored in persistent sta_priorities_) │ /// │ ↓ │ /// │ 4. Check for hidden networks: │ /// │ - If found → RETRY_HIDDEN (try SSIDs not in scan, 1 attempt) │ /// │ Skip hidden networks before first visible one │ /// │ (Skip Hidden1/Hidden2, try Hidden3 from example) │ /// │ - If none → Skip RETRY_HIDDEN, go to step 5 │ /// │ ↓ │ /// │ 5. FAILED → RESTARTING_ADAPTER │ /// │ - Normal: restart adapter, clear state │ /// │ - AP/improv active: skip restart, just disconnect │ /// │ ↓ │ /// │ 6. Loop back to start: │ /// │ - If first network is hidden → EXPLICIT_HIDDEN (retry cycle) │ /// │ - If AP/improv active → RETRY_HIDDEN (blind retry, see below) │ /// │ - Otherwise → SCAN_CONNECTING (rescan) │ /// │ ↓ │ /// │ 7. RESCAN → Apply stored priorities, sort again │ /// │ ┌─────────────────────────────────────────────────┐ │ /// │ │ scan_result_[0] = BSSID B (priority 0.0) ← NEW │ │ /// │ │ scan_result_[1] = BSSID C (priority 0.0) │ │ /// │ │ scan_result_[2] = BSSID A (priority -2.0) ← OLD │ │ /// │ └─────────────────────────────────────────────────┘ │ /// │ ↓ │ /// │ 8. SCAN_CONNECTING → Try scan_result_[0] (next best) │ /// │ │ /// │ Key: Priority system cycles through BSSIDs ACROSS scan cycles │ /// │ Full retry cycle: EXPLICIT_HIDDEN → SCAN → RETRY_HIDDEN │ /// │ Always try best available BSSID (scan_result_[0]) │ /// └──────────────────────────────────────────────────────────────────────┘ /// /// Retry Phases: /// - INITIAL_CONNECT: Try saved BSSID+channel (fast_connect), or fall back to normal flow /// - FAST_CONNECT_CYCLING_APS: Cycle through remaining configured networks (1 attempt each, fast_connect only) /// - EXPLICIT_HIDDEN: Try consecutive networks marked hidden:true before scanning (1 attempt per SSID) /// - SCAN_CONNECTING: Connect using scan results (2 attempts per BSSID) /// - RETRY_HIDDEN: Behavior controlled by RetryHiddenMode: /// * SCAN_BASED: Try networks not found in scan (truly hidden, 1 attempt per SSID) /// * BLIND_RETRY: Cycle through ALL networks when scanning disabled (AP active) /// - RESTARTING_ADAPTER: Restart WiFi adapter to clear stuck state (restart skipped if AP active) /// /// Hidden Network Handling: /// - Networks marked 'hidden: true' before first non-hidden → Tried in EXPLICIT_HIDDEN phase /// - Networks marked 'hidden: true' after first non-hidden → Tried in RETRY_HIDDEN phase /// - After successful connection, fast_connect saves BSSID → subsequent boots use fast path /// - 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 /// /// ┌──────────────────────────────────────────────────────────────────────┐ /// │ Captive Portal / Improv Mode (AP active, scanning disabled) │ /// ├──────────────────────────────────────────────────────────────────────┤ /// │ When captive_portal or esp32_improv is active, WiFi scanning is │ /// │ disabled because it disrupts AP clients (radio leaves AP channel │ /// │ to hop through other channels, causing client disconnections). │ /// │ │ /// │ Flow with RetryHiddenMode::BLIND_RETRY: │ /// │ │ /// │ 1. RESTARTING_ADAPTER → In this mode, skip adapter restart and │ /// │ just disconnect (normal mode restarts the adapter) │ /// │ - Sets retry_hidden_mode_ = BLIND_RETRY │ /// │ - Enter extended cooldown (30s vs normal 500ms) │ /// │ ↓ │ /// │ 2. determine_next_phase_() returns RETRY_HIDDEN (skips scanning) │ /// │ ↓ │ /// │ 3. RETRY_HIDDEN with BLIND_RETRY mode: │ /// │ - find_next_hidden_sta_() ignores scan_result_ │ /// │ - ALL configured networks become candidates │ /// │ - Cycles through networks: Net1 → Net2 → Net3 → ... │ /// │ ↓ │ /// │ 4. After exhausting all networks → Back to RESTARTING_ADAPTER │ /// │ - Loop continues until connection succeeds or user configures │ /// │ new credentials via captive portal │ /// │ │ /// │ The 30s cooldown gives users time to interact with captive portal │ /// │ without constant connection attempts disrupting the AP. │ /// └──────────────────────────────────────────────────────────────────────┘ /// /// ┌──────────────────────────────────────────────────────────────────────┐ /// │ Post-Connect Roaming (for stationary devices) │ /// ├──────────────────────────────────────────────────────────────────────┤ /// │ Purpose: Handle AP reboot or power loss scenarios where device │ /// │ connects to suboptimal AP and never switches back │ /// │ │ /// │ State Machine (RoamingState): │ /// │ │ /// │ ┌─────────────────────────────────────────────────────────────┐ │ /// │ │ IDLE │ │ /// │ │ (waiting for 5 min timer, attempts < 3) │ │ /// │ └─────────────────────────┬───────────────────────────────────┘ │ /// │ │ 5 min elapsed, RSSI < -49 dBm │ /// │ ↓ │ /// │ ┌─────────────────────────────────────────────────────────────┐ │ /// │ │ SCANNING │ │ /// │ │ (attempts++ in check_roaming_ before entering this state) │ │ /// │ └─────────────────────────┬───────────────────────────────────┘ │ /// │ │ │ /// │ ┌──────────────┼──────────────┐ │ /// │ ↓ ↓ ↓ │ /// │ disconnect no better AP +10 dB better AP │ /// │ │ │ │ │ /// │ ↓ ↓ ↓ │ /// │ ┌──────────────────────────────┐ ┌──────────────────────────┐ │ /// │ │ → RECONNECTING │ │ CONNECTING │ │ /// │ │ (counter preserved) │ │ (process_roaming_scan_) │ │ /// │ └──────────────────────────────┘ └────────────┬─────────────┘ │ /// │ │ │ /// │ ┌───────────────────┴───────────────┐ │ /// │ ↓ ↓ │ /// │ SUCCESS FAILED │ /// │ │ │ │ /// │ ↓ ↓ │ /// │ ┌──────────────────────────────────┐ ┌─────────────────────────┐ /// │ │ → IDLE │ │ RECONNECTING │ /// │ │ (counter reset to 0) │ │ (retry_connect called) │ /// │ └──────────────────────────────────┘ └───────────┬─────────────┘ /// │ │ │ /// │ ┌─────────┴─────────┐ │ /// │ ↓ ↓ │ /// │ on target BSSID on other AP │ /// │ │ │ │ /// │ ↓ ↓ │ /// │ ┌──────────────────┐ ┌────────────┐│ /// │ │ → IDLE │ │ → IDLE ││ /// │ │ (counter reset) │ │ (counter ││ /// │ │ (roam worked!) │ │ preserved)││ /// │ └──────────────────┘ └────────────┘│ /// │ │ /// │ Key behaviors: │ /// │ - After 3 checks: attempts >= 3, stop checking │ /// │ - Non-roaming disconnect: clear_roaming_state_() resets counter │ /// │ - Disconnect during scan (SCANNING→RECONNECTING): counter preserved │ /// │ - Disconnect after scan (within grace period): counter preserved │ /// │ - Roaming success (CONNECTING→IDLE): counter reset (can roam again) │ /// │ - Roaming success via retry (on target BSSID): counter reset │ /// │ - Roaming fail (RECONNECTING on other AP): counter preserved │ /// └──────────────────────────────────────────────────────────────────────┘ #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_INFO // Use if-chain instead of switch to avoid jump table in RODATA (wastes RAM on ESP8266) static const LogString *retry_phase_to_log_string(WiFiRetryPhase phase) { if (phase == WiFiRetryPhase::INITIAL_CONNECT) return LOG_STR("INITIAL_CONNECT"); #ifdef USE_WIFI_FAST_CONNECT if (phase == WiFiRetryPhase::FAST_CONNECT_CYCLING_APS) return LOG_STR("FAST_CONNECT_CYCLING"); #endif if (phase == WiFiRetryPhase::EXPLICIT_HIDDEN) return LOG_STR("EXPLICIT_HIDDEN"); if (phase == WiFiRetryPhase::SCAN_CONNECTING) return LOG_STR("SCAN_CONNECTING"); if (phase == WiFiRetryPhase::RETRY_HIDDEN) return LOG_STR("RETRY_HIDDEN"); if (phase == WiFiRetryPhase::RESTARTING_ADAPTER) return LOG_STR("RESTARTING"); return LOG_STR("UNKNOWN"); } #endif // ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_INFO bool WiFiComponent::went_through_explicit_hidden_phase_() const { // If first configured network is marked hidden, we went through EXPLICIT_HIDDEN phase // This means those networks were already tried and should be skipped in RETRY_HIDDEN return !this->sta_.empty() && this->sta_[0].get_hidden(); } int8_t WiFiComponent::find_first_non_hidden_index_() const { // Find the first network that is NOT marked hidden:true // This is where EXPLICIT_HIDDEN phase would have stopped for (size_t i = 0; i < this->sta_.size(); i++) { if (!this->sta_[i].get_hidden()) { return static_cast(i); } } return -1; // All networks are hidden } // 2 attempts per BSSID in SCAN_CONNECTING phase // Rationale: This is the ONLY phase where we decrease BSSID priority, so we must be very sure. // Auth failures are common immediately after scan due to WiFi stack state transitions. // Trying twice filters out false positives and prevents unnecessarily marking a good BSSID as bad. // After 2 genuine failures, priority degradation ensures we skip this BSSID on subsequent scans. static constexpr uint8_t WIFI_RETRY_COUNT_PER_BSSID = 2; // 1 attempt per SSID in RETRY_HIDDEN phase // Rationale: Try hidden mode once, then rescan to get next best BSSID via priority system static constexpr uint8_t WIFI_RETRY_COUNT_PER_SSID = 1; // 1 attempt per AP in fast_connect mode (INITIAL_CONNECT and FAST_CONNECT_CYCLING_APS) // Rationale: Fast connect prioritizes speed - try each AP once to find a working one quickly static constexpr uint8_t WIFI_RETRY_COUNT_PER_AP = 1; /// Cooldown duration in milliseconds after adapter restart or repeated failures /// Allows WiFi hardware to stabilize before next connection attempt static constexpr uint32_t WIFI_COOLDOWN_DURATION_MS = 500; /// Cooldown duration when fallback AP is active and captive portal may be running /// Longer interval gives users time to configure WiFi without constant connection attempts /// While connecting, WiFi can't beacon the AP properly, so needs longer cooldown static constexpr uint32_t WIFI_COOLDOWN_WITH_AP_ACTIVE_MS = 30000; /// Timeout for WiFi scan operations /// This is a fallback in case we don't receive a scan done callback from the WiFi driver. /// Normal scans complete via callback; this only triggers if something goes wrong. static constexpr uint32_t WIFI_SCAN_TIMEOUT_MS = 31000; /// Timeout for WiFi connection attempts /// This is a fallback in case we don't receive connection success/failure callbacks. /// Some platforms (especially LibreTiny/Beken) can take 30-60 seconds to connect, /// particularly with fast_connect enabled where no prior scan provides channel info. /// Do not lower this value - connection failures are detected via callbacks, not timeout. /// If this timeout fires prematurely while a connection is still in progress, it causes /// cascading failures: the subsequent scan will also fail because the WiFi driver is /// still busy with the previous connection attempt. static constexpr uint32_t WIFI_CONNECT_TIMEOUT_MS = 46000; static constexpr uint8_t get_max_retries_for_phase(WiFiRetryPhase phase) { switch (phase) { case WiFiRetryPhase::INITIAL_CONNECT: #ifdef USE_WIFI_FAST_CONNECT case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS: #endif // INITIAL_CONNECT and FAST_CONNECT_CYCLING_APS both use 1 attempt per AP (fast_connect mode) return WIFI_RETRY_COUNT_PER_AP; case WiFiRetryPhase::EXPLICIT_HIDDEN: // Explicitly hidden network: 1 attempt (user marked as hidden, try once then scan) return WIFI_RETRY_COUNT_PER_SSID; case WiFiRetryPhase::SCAN_CONNECTING: // Scan-based phase: 2 attempts per BSSID (handles transient auth failures after scan) return WIFI_RETRY_COUNT_PER_BSSID; case WiFiRetryPhase::RETRY_HIDDEN: // Hidden network mode: 1 attempt per SSID return WIFI_RETRY_COUNT_PER_SSID; default: return WIFI_RETRY_COUNT_PER_BSSID; } } static void apply_scan_result_to_params(WiFiAP ¶ms, const WiFiScanResult &scan) { params.set_hidden(false); params.set_ssid(scan.get_ssid()); params.set_bssid(scan.get_bssid()); params.set_channel(scan.get_channel()); } bool WiFiComponent::needs_scan_results_() const { // Only SCAN_CONNECTING phase needs scan results if (this->retry_phase_ != WiFiRetryPhase::SCAN_CONNECTING) { return false; } // Need scan if we have no results or no matching networks return this->scan_result_.empty() || !this->scan_result_[0].get_matches(); } bool WiFiComponent::ssid_was_seen_in_scan_(const CompactString &ssid) const { // Check if this SSID is configured as hidden // If explicitly marked hidden, we should always try hidden mode regardless of scan results for (const auto &conf : this->sta_) { if (conf.ssid_ == ssid && conf.get_hidden()) { return false; // Treat as not seen - force hidden mode attempt } } // Otherwise, check if we saw it in scan results for (const auto &scan : this->scan_result_) { if (scan.ssid_ == ssid) { return true; } } return false; } bool WiFiComponent::needs_full_scan_results_() const { // Components that require full scan results (for example, scan result listeners) // are expected to call request_wifi_scan_results(), which sets keep_scan_results_. if (this->keep_scan_results_) { return true; } #ifdef USE_CAPTIVE_PORTAL // Captive portal needs full results when active (showing network list to user) if (captive_portal::global_captive_portal != nullptr && captive_portal::global_captive_portal->is_active()) { return true; } #endif #ifdef USE_IMPROV_SERIAL // Improv serial needs results during provisioning (before connected) if (improv_serial::global_improv_serial_component != nullptr && !this->is_connected()) { return true; } #endif #ifdef USE_IMPROV // BLE improv also needs results during provisioning if (esp32_improv::global_improv_component != nullptr && esp32_improv::global_improv_component->is_active()) { return true; } #endif return false; } bool WiFiComponent::matches_configured_network_(const char *ssid, const uint8_t *bssid) const { // Hidden networks in scan results have empty SSIDs - skip them if (ssid[0] == '\0') { return false; } for (const auto &sta : this->sta_) { // Skip hidden network configs (they don't appear in normal scans) if (sta.get_hidden()) { continue; } // For BSSID-only configs (empty SSID), match by BSSID if (sta.ssid_.empty()) { if (sta.has_bssid() && std::memcmp(sta.get_bssid().data(), bssid, 6) == 0) { return true; } continue; } // Match by SSID if (sta.ssid_ == ssid) { return true; } } return false; } void __attribute__((flatten)) WiFiComponent::set_sta_priority(bssid_t bssid, int8_t priority) { for (auto &it : this->sta_priorities_) { if (it.bssid == bssid) { it.priority = priority; return; } } this->sta_priorities_.push_back(WiFiSTAPriority{ .bssid = bssid, .priority = priority, }); } void WiFiComponent::log_discarded_scan_result_(const char *ssid, const uint8_t *bssid, int8_t rssi, uint8_t channel) { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE // Skip logging during roaming scans to avoid log buffer overflow // (roaming scans typically find many networks but only care about same-SSID APs) if (this->roaming_state_ == RoamingState::SCANNING) { return; } char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(bssid, bssid_s); ESP_LOGV(TAG, "- " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") " %ddB Ch:%u", ssid, bssid_s, rssi, channel); #endif } int8_t WiFiComponent::find_next_hidden_sta_(int8_t start_index) { // Find next SSID to try in RETRY_HIDDEN phase. // // This function operates in two modes based on retry_hidden_mode_: // // 1. SCAN_BASED mode: // After SCAN_CONNECTING phase, only returns networks that were NOT visible // in the scan (truly hidden networks that need probe requests). // // 2. BLIND_RETRY mode: // When captive portal/improv is active, scanning is skipped to avoid // disrupting the AP. In this mode, ALL configured networks are returned // as candidates, cycling through them sequentially. This allows the device // to keep trying all networks while users configure WiFi via captive portal. // // Additionally, if EXPLICIT_HIDDEN phase was executed (first network marked hidden:true), // those networks are skipped here since they were already tried. // bool include_explicit_hidden = !this->went_through_explicit_hidden_phase_(); // Start searching from start_index + 1 for (size_t i = start_index + 1; i < this->sta_.size(); i++) { const auto &sta = this->sta_[i]; // Skip networks that were already tried in EXPLICIT_HIDDEN phase // Those are: networks marked hidden:true that appear before the first non-hidden network // If all networks are hidden (first_non_hidden_idx == -1), skip all of them if (!include_explicit_hidden && sta.get_hidden()) { int8_t first_non_hidden_idx = this->find_first_non_hidden_index_(); if (first_non_hidden_idx < 0 || static_cast(i) < first_non_hidden_idx) { ESP_LOGD(TAG, "Skipping " LOG_SECRET("'%s'") " (explicit hidden, already tried)", sta.ssid_.c_str()); continue; } } // In BLIND_RETRY mode, treat all networks as candidates // In SCAN_BASED mode, only retry networks that weren't seen in the scan if (this->retry_hidden_mode_ == RetryHiddenMode::BLIND_RETRY || !this->ssid_was_seen_in_scan_(sta.ssid_)) { ESP_LOGD(TAG, "Hidden candidate " LOG_SECRET("'%s'") " at index %d", sta.ssid_.c_str(), static_cast(i)); return static_cast(i); } ESP_LOGD(TAG, "Skipping hidden retry for visible network " LOG_SECRET("'%s'"), sta.ssid_.c_str()); } // No hidden SSIDs found return -1; } void WiFiComponent::start_initial_connection_() { // If first network (highest priority) is explicitly marked hidden, try it first before scanning // This respects user's priority order when they explicitly configure hidden networks if (!this->sta_.empty() && this->sta_[0].get_hidden()) { ESP_LOGI(TAG, "Starting with explicit hidden network (highest priority)"); this->selected_sta_index_ = 0; this->retry_phase_ = WiFiRetryPhase::EXPLICIT_HIDDEN; WiFiAP params = this->build_params_for_current_phase_(); this->start_connecting(params); } else { this->start_scanning(); } } #if defined(USE_ESP32) && defined(USE_WIFI_WPA2_EAP) && ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE static const char *eap_phase2_to_str(esp_eap_ttls_phase2_types type) { switch (type) { case ESP_EAP_TTLS_PHASE2_PAP: return "pap"; case ESP_EAP_TTLS_PHASE2_CHAP: return "chap"; case ESP_EAP_TTLS_PHASE2_MSCHAP: return "mschap"; case ESP_EAP_TTLS_PHASE2_MSCHAPV2: return "mschapv2"; case ESP_EAP_TTLS_PHASE2_EAP: return "eap"; default: return "unknown"; } } #endif float WiFiComponent::get_setup_priority() const { return setup_priority::WIFI; } void WiFiComponent::setup() { this->wifi_pre_setup_(); #if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) // Create semaphore for high-performance mode requests // Start at 0, increment on request, decrement on release this->high_performance_semaphore_ = xSemaphoreCreateCounting(UINT32_MAX, 0); if (this->high_performance_semaphore_ == nullptr) { ESP_LOGE(TAG, "Failed semaphore"); } // Store the configured power save mode as baseline this->configured_power_save_ = this->power_save_; #endif if (this->enable_on_boot_) { this->start(); } else { #ifdef USE_ESP32 esp_netif_init(); #endif this->state_ = WIFI_COMPONENT_STATE_DISABLED; } } void WiFiComponent::start() { ESP_LOGCONFIG(TAG, "Starting"); this->last_connected_ = millis(); uint32_t hash = this->has_sta() ? App.get_config_version_hash() : 88491487UL; this->pref_ = global_preferences->make_preference(hash, true); #ifdef USE_WIFI_FAST_CONNECT this->fast_connect_pref_ = global_preferences->make_preference(hash + 1, false); #endif SavedWifiSettings save{}; if (this->pref_.load(&save)) { ESP_LOGD(TAG, "Loaded settings: %s", save.ssid); WiFiAP sta{}; sta.set_ssid(save.ssid); sta.set_password(save.password); this->set_sta(sta); } if (this->has_sta()) { this->wifi_sta_pre_setup_(); if (!std::isnan(this->output_power_) && !this->wifi_apply_output_power_(this->output_power_)) { ESP_LOGV(TAG, "Setting Output Power Option failed"); } #if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) // Synchronize power_save_ with semaphore state before applying if (this->high_performance_semaphore_ != nullptr) { UBaseType_t semaphore_count = uxSemaphoreGetCount(this->high_performance_semaphore_); if (semaphore_count > 0) { this->power_save_ = WIFI_POWER_SAVE_NONE; this->is_high_performance_mode_ = true; } else { this->power_save_ = this->configured_power_save_; this->is_high_performance_mode_ = false; } } #endif if (!this->wifi_apply_power_save_()) { ESP_LOGV(TAG, "Setting Power Save Option failed"); } this->transition_to_phase_(WiFiRetryPhase::INITIAL_CONNECT); #ifdef USE_WIFI_FAST_CONNECT WiFiAP params; bool loaded_fast_connect = this->load_fast_connect_settings_(params); // Fast connect optimization: only use when we have saved BSSID+channel data // Without saved data, try first configured network or use normal flow if (loaded_fast_connect) { ESP_LOGI(TAG, "Starting fast_connect (saved) " LOG_SECRET("'%s'"), params.ssid_.c_str()); this->start_connecting(params); } else if (!this->sta_.empty() && !this->sta_[0].get_hidden()) { // No saved data, but have configured networks - try first non-hidden network ESP_LOGI(TAG, "Starting fast_connect (config) " LOG_SECRET("'%s'"), this->sta_[0].ssid_.c_str()); this->selected_sta_index_ = 0; params = this->build_params_for_current_phase_(); this->start_connecting(params); } else { // No saved data and (no networks OR first is hidden) - use normal flow this->start_initial_connection_(); } #else // Without fast_connect: go straight to scanning (or hidden mode if all networks are hidden) this->start_initial_connection_(); #endif #ifdef USE_WIFI_AP } else if (this->has_ap()) { this->setup_ap_config_(); if (!std::isnan(this->output_power_) && !this->wifi_apply_output_power_(this->output_power_)) { ESP_LOGV(TAG, "Setting Output Power Option failed"); } #ifdef USE_CAPTIVE_PORTAL if (captive_portal::global_captive_portal != nullptr) { this->wifi_sta_pre_setup_(); this->start_scanning(); captive_portal::global_captive_portal->start(); } #endif #endif // USE_WIFI_AP } #ifdef USE_IMPROV if (!this->has_sta() && esp32_improv::global_improv_component != nullptr) { if (this->wifi_mode_(true, {})) esp32_improv::global_improv_component->start(); } #endif this->wifi_apply_hostname_(); } void WiFiComponent::restart_adapter() { ESP_LOGW(TAG, "Restarting adapter"); this->wifi_mode_(false, {}); // Clear error flag here because restart_adapter() enters COOLDOWN state, // and check_connecting_finished() is called after cooldown without going // through start_connecting() first. Without this clear, stale errors would // trigger spurious "failed (callback)" logs. The canonical clear location // is in start_connecting(); this is the only exception to that pattern. this->error_from_callback_ = false; } void WiFiComponent::loop() { bool events_processed = this->wifi_loop_(); const uint32_t now = App.get_loop_component_start_time(); // Connection state can only change when events are processed (ESP-IDF/LibreTiny) // or polled (ESP8266/Pico W). Skip the expensive wifi_sta_connect_status_() call // when no events arrived and we're already in steady state. // Must also run when connected_ is false — after state transitions to STA_CONNECTED, // connected_ won't be set until update_connected_state_() runs. if (events_processed || !this->connected_) { this->update_connected_state_(); } if (this->has_sta()) { #if defined(USE_WIFI_CONNECT_TRIGGER) || defined(USE_WIFI_DISCONNECT_TRIGGER) if (this->is_connected() != this->handled_connected_state_) { #ifdef USE_WIFI_DISCONNECT_TRIGGER if (this->handled_connected_state_) { this->disconnect_trigger_.trigger(); } #endif #ifdef USE_WIFI_CONNECT_TRIGGER if (!this->handled_connected_state_) { this->connect_trigger_.trigger(); } #endif this->handled_connected_state_ = this->is_connected(); } #endif // USE_WIFI_CONNECT_TRIGGER || USE_WIFI_DISCONNECT_TRIGGER switch (this->state_) { case WIFI_COMPONENT_STATE_COOLDOWN: { this->status_set_warning(LOG_STR("waiting to reconnect")); // 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(now); break; } // Use longer cooldown when captive portal/improv is active to avoid disrupting user config bool portal_active = this->is_captive_portal_active_() || this->is_esp32_improv_active_(); uint32_t cooldown_duration = portal_active ? WIFI_COOLDOWN_WITH_AP_ACTIVE_MS : WIFI_COOLDOWN_DURATION_MS; if (now - this->action_started_ > cooldown_duration) { // After cooldown we either restarted the adapter because of // 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(now); } break; } case WIFI_COMPONENT_STATE_STA_SCANNING: { this->status_set_warning(LOG_STR("scanning for networks")); this->check_scanning_finished(); break; } case WIFI_COMPONENT_STATE_STA_CONNECTING: { this->status_set_warning(LOG_STR("associating to network")); this->check_connecting_finished(now); break; } case WIFI_COMPONENT_STATE_STA_CONNECTED: { // Use cached connected_ set unconditionally at the top of loop() if (!this->connected_) { ESP_LOGW(TAG, "Connection lost; reconnecting"); this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTING; this->retry_connect(); } else { this->status_clear_warning(); this->last_connected_ = now; // Post-connect roaming: check for better AP if (this->post_connect_roaming_) { if (this->roaming_state_ == RoamingState::SCANNING) { if (this->scan_done_) { this->process_roaming_scan_(); } // else: scan in progress, wait } 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); } } } break; } case WIFI_COMPONENT_STATE_OFF: case WIFI_COMPONENT_STATE_AP: break; case WIFI_COMPONENT_STATE_DISABLED: return; } #ifdef USE_WIFI_AP if (this->has_ap() && !this->ap_setup_) { if (this->ap_timeout_ != 0 && (now - this->last_connected_ > this->ap_timeout_)) { ESP_LOGI(TAG, "Starting fallback AP"); this->setup_ap_config_(); #ifdef USE_CAPTIVE_PORTAL if (captive_portal::global_captive_portal != nullptr) { // Reset so we force one full scan after captive portal starts // (previous scans were filtered because captive portal wasn't active yet) this->has_completed_scan_after_captive_portal_start_ = false; captive_portal::global_captive_portal->start(); } #endif } } #endif // USE_WIFI_AP #ifdef USE_IMPROV if (esp32_improv::global_improv_component != nullptr && !esp32_improv::global_improv_component->is_active() && !esp32_improv::global_improv_component->should_start()) { if (now - this->last_connected_ > esp32_improv::global_improv_component->get_wifi_timeout()) { if (this->wifi_mode_(true, {})) esp32_improv::global_improv_component->start(); } } #endif if (!this->has_ap() && this->reboot_timeout_ != 0) { if (now - this->last_connected_ > this->reboot_timeout_) { ESP_LOGE(TAG, "Can't connect; rebooting"); App.reboot(); } } } #if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) // Check if power save mode needs to be updated based on high-performance requests if (this->high_performance_semaphore_ != nullptr) { // Semaphore count directly represents active requests (starts at 0, increments on request) UBaseType_t semaphore_count = uxSemaphoreGetCount(this->high_performance_semaphore_); if (semaphore_count > 0 && !this->is_high_performance_mode_) { // Transition to high-performance mode (no power save) ESP_LOGV(TAG, "Switching to high-performance mode (%" PRIu32 " active %s)", (uint32_t) semaphore_count, semaphore_count == 1 ? "request" : "requests"); this->power_save_ = WIFI_POWER_SAVE_NONE; if (this->wifi_apply_power_save_()) { this->is_high_performance_mode_ = true; } } else if (semaphore_count == 0 && this->is_high_performance_mode_) { // Restore to configured power save mode ESP_LOGV(TAG, "Restoring power save mode to configured setting"); this->power_save_ = this->configured_power_save_; if (this->wifi_apply_power_save_()) { this->is_high_performance_mode_ = false; } } } #endif } WiFiComponent::WiFiComponent() { global_wifi_component = this; } #ifdef USE_WIFI_11KV_SUPPORT void WiFiComponent::set_btm(bool btm) { this->btm_ = btm; } void WiFiComponent::set_rrm(bool rrm) { this->rrm_ = rrm; } #endif network::IPAddresses WiFiComponent::get_ip_addresses() { if (this->has_sta()) return this->wifi_sta_ip_addresses(); #ifdef USE_WIFI_AP if (this->has_ap()) return {this->wifi_soft_ap_ip()}; #endif // USE_WIFI_AP return {}; } network::IPAddress WiFiComponent::get_dns_address(int num) { if (this->has_sta()) return this->wifi_dns_ip_(num); return {}; } #ifdef USE_WIFI_AP void WiFiComponent::setup_ap_config_() { this->wifi_mode_({}, true); if (this->ap_setup_) return; if (this->ap_.ssid_.empty()) { // Build AP SSID from app name without heap allocation // WiFi SSID max is 32 bytes, with MAC suffix we keep first 25 + last 7 static constexpr size_t AP_SSID_MAX_LEN = 32; static constexpr size_t AP_SSID_PREFIX_LEN = 25; static constexpr size_t AP_SSID_SUFFIX_LEN = 7; const auto &app_name = App.get_name(); const char *name_ptr = app_name.c_str(); size_t name_len = app_name.length(); if (name_len <= AP_SSID_MAX_LEN) { // Name fits, use directly this->ap_.set_ssid(name_ptr); } else { // Name too long, need to truncate into stack buffer char ssid_buf[AP_SSID_MAX_LEN + 1]; if (App.is_name_add_mac_suffix_enabled()) { // Keep first 25 chars and last 7 chars (MAC suffix), remove middle memcpy(ssid_buf, name_ptr, AP_SSID_PREFIX_LEN); memcpy(ssid_buf + AP_SSID_PREFIX_LEN, name_ptr + name_len - AP_SSID_SUFFIX_LEN, AP_SSID_SUFFIX_LEN); } else { memcpy(ssid_buf, name_ptr, AP_SSID_MAX_LEN); } ssid_buf[AP_SSID_MAX_LEN] = '\0'; this->ap_.set_ssid(ssid_buf); } } this->ap_setup_ = this->wifi_start_ap_(this->ap_); char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, "Setting up AP:\n" " AP SSID: '%s'\n" " AP Password: '%s'\n" " IP Address: %s", this->ap_.ssid_.c_str(), this->ap_.password_.c_str(), this->wifi_soft_ap_ip().str_to(ip_buf)); #ifdef USE_WIFI_MANUAL_IP auto manual_ip = this->ap_.get_manual_ip(); if (manual_ip.has_value()) { char static_ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; char gateway_buf[network::IP_ADDRESS_BUFFER_SIZE]; char subnet_buf[network::IP_ADDRESS_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, " AP Static IP: '%s'\n" " AP Gateway: '%s'\n" " AP Subnet: '%s'", manual_ip->static_ip.str_to(static_ip_buf), manual_ip->gateway.str_to(gateway_buf), manual_ip->subnet.str_to(subnet_buf)); } #endif if (!this->has_sta()) { this->state_ = WIFI_COMPONENT_STATE_AP; } } void WiFiComponent::set_ap(const WiFiAP &ap) { this->ap_ = ap; this->has_ap_ = true; } #endif // USE_WIFI_AP 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(); // Also clears roaming state this->init_sta(1); this->add_sta(ap); this->selected_sta_index_ = 0; // When new credentials are set (e.g., from improv), skip cooldown to retry immediately this->skip_cooldown_next_cycle_ = true; } WiFiAP WiFiComponent::build_params_for_current_phase_() { const WiFiAP *config = this->get_selected_sta_(); if (config == nullptr) { ESP_LOGE(TAG, "No valid network config (selected_sta_index_=%d, sta_.size()=%zu)", static_cast(this->selected_sta_index_), this->sta_.size()); // Return empty params - caller should handle this gracefully return WiFiAP(); } WiFiAP params = *config; switch (this->retry_phase_) { case WiFiRetryPhase::INITIAL_CONNECT: #ifdef USE_WIFI_FAST_CONNECT case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS: #endif // Fast connect phases: use config-only (no scan results) // BSSID/channel from config if user specified them, otherwise empty break; case WiFiRetryPhase::EXPLICIT_HIDDEN: case WiFiRetryPhase::RETRY_HIDDEN: // Hidden network mode: clear BSSID/channel to trigger probe request // (both explicit hidden and retry hidden use same behavior) params.clear_bssid(); params.clear_channel(); break; case WiFiRetryPhase::SCAN_CONNECTING: // Scan-based phase: always use best scan result (index 0 - highest priority after sorting) if (!this->scan_result_.empty()) { apply_scan_result_to_params(params, this->scan_result_[0]); } break; case WiFiRetryPhase::RESTARTING_ADAPTER: // Should not be building params during restart break; } return params; } WiFiAP WiFiComponent::get_sta() const { const WiFiAP *config = this->get_selected_sta_(); return config ? *config : WiFiAP{}; } void WiFiComponent::save_wifi_sta(const std::string &ssid, const std::string &password) { this->save_wifi_sta(ssid.c_str(), password.c_str()); } void WiFiComponent::save_wifi_sta(const char *ssid, const char *password) { SavedWifiSettings save{}; // zero-initialized - all bytes set to \0, guaranteeing null termination strncpy(save.ssid, ssid, sizeof(save.ssid) - 1); // max 32 chars, byte 32 remains \0 strncpy(save.password, password, sizeof(save.password) - 1); // max 64 chars, byte 64 remains \0 this->pref_.save(&save); // ensure it's written immediately global_preferences->sync(); WiFiAP sta{}; sta.set_ssid(ssid); sta.set_password(password); this->set_sta(sta); // Trigger connection attempt (exits cooldown if needed, no-op if already connecting/connected) this->connect_soon_(); } void WiFiComponent::connect_soon_() { // Only trigger retry if we're in cooldown - if already connecting/connected, do nothing if (this->state_ == WIFI_COMPONENT_STATE_COOLDOWN) { ESP_LOGD(TAG, "Exiting cooldown early due to new WiFi credentials"); this->retry_connect(); } } void WiFiComponent::start_connecting(const WiFiAP &ap) { // Log connection attempt at INFO level with priority char bssid_s[18]; int8_t priority = 0; if (ap.has_bssid()) { format_mac_addr_upper(ap.get_bssid().data(), bssid_s); priority = this->get_sta_priority(ap.get_bssid()); } ESP_LOGI(TAG, "Connecting to " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") " (priority %d, attempt %u/%u in phase %s)...", ap.ssid_.c_str(), ap.has_bssid() ? bssid_s : LOG_STR_LITERAL("any"), priority, this->num_retried_ + 1, get_max_retries_for_phase(this->retry_phase_), LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_))); #ifdef ESPHOME_LOG_HAS_VERBOSE ESP_LOGV(TAG, "Connection Params:\n" " SSID: '%s'", ap.ssid_.c_str()); if (ap.has_bssid()) { ESP_LOGV(TAG, " BSSID: %s", bssid_s); } else { ESP_LOGV(TAG, " BSSID: Not Set"); } #ifdef USE_WIFI_WPA2_EAP auto eap_opt = ap.get_eap(); if (eap_opt.has_value()) { EAPAuth eap_config = *eap_opt; // clang-format off ESP_LOGV( TAG, " WPA2 Enterprise authentication configured:\n" " Identity: " LOG_SECRET("'%s'") "\n" " Username: " LOG_SECRET("'%s'") "\n" " Password: " LOG_SECRET("'%s'"), eap_config.identity.c_str(), eap_config.username.c_str(), eap_config.password.c_str()); // clang-format on #if defined(USE_ESP32) && defined(USE_WIFI_WPA2_EAP) && ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE ESP_LOGV(TAG, " TTLS Phase 2: " LOG_SECRET("'%s'"), eap_phase2_to_str(eap_config.ttls_phase_2)); #endif bool ca_cert_present = eap_config.ca_cert != nullptr && strlen(eap_config.ca_cert); bool client_cert_present = eap_config.client_cert != nullptr && strlen(eap_config.client_cert); bool client_key_present = eap_config.client_key != nullptr && strlen(eap_config.client_key); ESP_LOGV(TAG, " CA Cert: %s\n" " Client Cert: %s\n" " Client Key: %s", ca_cert_present ? "present" : "not present", client_cert_present ? "present" : "not present", client_key_present ? "present" : "not present"); } else { #endif ESP_LOGV(TAG, " Password: " LOG_SECRET("'%s'"), ap.password_.c_str()); #ifdef USE_WIFI_WPA2_EAP } #endif if (ap.has_channel()) { ESP_LOGV(TAG, " Channel: %u", ap.get_channel()); } else { ESP_LOGV(TAG, " Channel not set"); } #ifdef USE_WIFI_MANUAL_IP auto manual_ip = ap.get_manual_ip(); if (manual_ip.has_value()) { ManualIP m = *manual_ip; char static_ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; char gateway_buf[network::IP_ADDRESS_BUFFER_SIZE]; char subnet_buf[network::IP_ADDRESS_BUFFER_SIZE]; char dns1_buf[network::IP_ADDRESS_BUFFER_SIZE]; char dns2_buf[network::IP_ADDRESS_BUFFER_SIZE]; ESP_LOGV(TAG, " Manual IP: Static IP=%s Gateway=%s Subnet=%s DNS1=%s DNS2=%s", m.static_ip.str_to(static_ip_buf), m.gateway.str_to(gateway_buf), m.subnet.str_to(subnet_buf), m.dns1.str_to(dns1_buf), m.dns2.str_to(dns2_buf)); } else #endif { ESP_LOGV(TAG, " Using DHCP IP"); } ESP_LOGV(TAG, " Hidden: %s", YESNO(ap.get_hidden())); #endif // Clear any stale error from previous connection attempt. // This is the canonical location for clearing the flag since all connection // attempts go through start_connecting(). The only other clear is in // restart_adapter() which enters COOLDOWN without calling start_connecting(). this->error_from_callback_ = false; if (!this->wifi_sta_connect_(ap)) { ESP_LOGE(TAG, "wifi_sta_connect_ failed"); // Enter cooldown to allow WiFi hardware to stabilize // (immediate failure suggests hardware not ready, different from connection timeout) this->state_ = WIFI_COMPONENT_STATE_COOLDOWN; } else { this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTING; } this->action_started_ = millis(); } const LogString *get_signal_bars(int8_t rssi) { // LOWER ONE QUARTER BLOCK // Unicode: U+2582, UTF-8: E2 96 82 // LOWER HALF BLOCK // Unicode: U+2584, UTF-8: E2 96 84 // LOWER THREE QUARTERS BLOCK // Unicode: U+2586, UTF-8: E2 96 86 // FULL BLOCK // Unicode: U+2588, UTF-8: E2 96 88 if (rssi >= -50) { return LOG_STR("\033[0;32m" // green "\xe2\x96\x82" "\xe2\x96\x84" "\xe2\x96\x86" "\xe2\x96\x88" "\033[0m"); } else if (rssi >= -65) { return LOG_STR("\033[0;33m" // yellow "\xe2\x96\x82" "\xe2\x96\x84" "\xe2\x96\x86" "\033[0;37m" "\xe2\x96\x88" "\033[0m"); } else if (rssi >= -85) { return LOG_STR("\033[0;33m" // yellow "\xe2\x96\x82" "\xe2\x96\x84" "\033[0;37m" "\xe2\x96\x86" "\xe2\x96\x88" "\033[0m"); } else { return LOG_STR("\033[0;31m" // red "\xe2\x96\x82" "\033[0;37m" "\xe2\x96\x84" "\xe2\x96\x86" "\xe2\x96\x88" "\033[0m"); } } void WiFiComponent::print_connect_params_() { bssid_t bssid = wifi_bssid(); char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(bssid.data(), bssid_s); // Use stack buffers for IP address formatting to avoid heap allocations char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; for (auto &ip : wifi_sta_ip_addresses()) { if (ip.is_set()) { ESP_LOGCONFIG(TAG, " IP Address: %s", ip.str_to(ip_buf)); } } int8_t rssi = wifi_rssi(); // Use stack buffers for SSID and all IP addresses to avoid heap allocations char ssid_buf[SSID_BUFFER_SIZE]; char subnet_buf[network::IP_ADDRESS_BUFFER_SIZE]; char gateway_buf[network::IP_ADDRESS_BUFFER_SIZE]; char dns1_buf[network::IP_ADDRESS_BUFFER_SIZE]; char dns2_buf[network::IP_ADDRESS_BUFFER_SIZE]; // clang-format off ESP_LOGCONFIG(TAG, " SSID: " LOG_SECRET("'%s'") "\n" " BSSID: " LOG_SECRET("%s") "\n" " Hostname: '%s'\n" " Signal strength: %d dB %s\n" " Channel: %" PRId32 "\n" " Subnet: %s\n" " Gateway: %s\n" " DNS1: %s\n" " DNS2: %s", wifi_ssid_to(ssid_buf), bssid_s, App.get_name().c_str(), rssi, LOG_STR_ARG(get_signal_bars(rssi)), get_wifi_channel(), wifi_subnet_mask_().str_to(subnet_buf), wifi_gateway_ip_().str_to(gateway_buf), wifi_dns_ip_(0).str_to(dns1_buf), wifi_dns_ip_(1).str_to(dns2_buf)); // clang-format on #ifdef ESPHOME_LOG_HAS_VERBOSE if (const WiFiAP *config = this->get_selected_sta_(); config && config->has_bssid()) { ESP_LOGV(TAG, " Priority: %d", this->get_sta_priority(config->get_bssid())); } #endif #ifdef USE_WIFI_11KV_SUPPORT ESP_LOGCONFIG(TAG, " BTM: %s\n" " RRM: %s", this->btm_ ? "enabled" : "disabled", this->rrm_ ? "enabled" : "disabled"); #endif } void WiFiComponent::enable() { if (this->state_ != WIFI_COMPONENT_STATE_DISABLED) return; ESP_LOGD(TAG, "Enabling"); this->state_ = WIFI_COMPONENT_STATE_OFF; this->start(); } void WiFiComponent::disable() { if (this->state_ == WIFI_COMPONENT_STATE_DISABLED) return; ESP_LOGD(TAG, "Disabling"); this->state_ = WIFI_COMPONENT_STATE_DISABLED; this->wifi_disconnect_(); this->wifi_mode_(false, false); } bool WiFiComponent::is_disabled() { return this->state_ == WIFI_COMPONENT_STATE_DISABLED; } void WiFiComponent::start_scanning() { this->action_started_ = millis(); ESP_LOGD(TAG, "Starting scan"); this->wifi_scan_start_(this->passive_scan_); this->state_ = WIFI_COMPONENT_STATE_STA_SCANNING; } /// Comparator for WiFi scan result sorting - determines which network should be tried first /// Returns true if 'a' should be placed before 'b' in the sorted order (a is "better" than b) /// /// Sorting logic (in priority order): /// 1. Matching networks always ranked before non-matching networks /// 2. For matching networks: Priority first (CRITICAL - tracks failure history) /// 3. RSSI as tiebreaker for equal priority or non-matching networks /// /// WHY PRIORITY MUST BE CHECKED FIRST: /// The priority field tracks connection failure history via priority degradation: /// - Initial priority: 0.0 (from config or default) /// - Each connection failure: priority -= 1.0 (becomes -1.0, -2.0, -3.0, etc.) /// - Failed BSSIDs sorted lower → naturally try different BSSID on next scan /// /// This enables automatic BSSID cycling for various real-world failure scenarios: /// - Crashed/hung AP (visible but not responding) /// - Misconfigured mesh node (accepts auth but no DHCP/routing) /// - Capacity limits (AP refuses new clients) /// - Rogue AP (same SSID, wrong password or malicious) /// - Intermittent hardware issues (flaky radio, overheating) /// /// Example mesh network: 3 APs with same SSID "home", all at priority 0.0 initially /// - Try strongest BSSID A (sorted by RSSI) → fails → priority A becomes -1.0 /// - Next scan: BSSID B and C (priority 0.0) sorted BEFORE A (priority -1.0) /// - Try next strongest BSSID B → succeeds or fails and gets deprioritized /// - System naturally cycles through all BSSIDs via priority degradation /// - Eventually finds working AP or tries all options before restarting adapter /// /// If we checked RSSI first (Bug in PR #9963): /// - Same failed BSSID would keep being selected if it has strongest signal /// - Device stuck connecting to crashed AP with -30dBm while working AP at -50dBm ignored /// - Priority degradation would be useless /// - Mesh networks would never recover from single AP failure [[nodiscard]] inline static bool wifi_scan_result_is_better(const WiFiScanResult &a, const WiFiScanResult &b) { // Matching networks always come before non-matching if (a.get_matches() && !b.get_matches()) return true; if (!a.get_matches() && b.get_matches()) return false; // Both matching: check priority first (tracks connection failures via priority degradation) // Priority is decreased when a BSSID fails to connect, so lower priority = previously failed if (a.get_matches() && b.get_matches() && a.get_priority() != b.get_priority()) { return a.get_priority() > b.get_priority(); } // Use RSSI as tiebreaker (for equal-priority matching networks or all non-matching networks) return a.get_rssi() > b.get_rssi(); } // Helper function for insertion sort of WiFi scan results // Using insertion sort instead of std::stable_sort saves flash memory // by avoiding template instantiations (std::rotate, std::stable_sort, lambdas) // IMPORTANT: This sort is stable (preserves relative order of equal elements) // // Uses raw memcpy instead of copy assignment to avoid CompactString's // destructor/constructor overhead (heap delete[]/new[] for long SSIDs). // Copy assignment calls ~CompactString() then placement-new for every shift, // which means delete[]/new[] per shift for heap-allocated SSIDs. With 70+ // networks (e.g., captive portal showing full scan results), this caused // event loop blocking from hundreds of heap operations in a tight loop. // // This is safe because we're permuting elements within the same array — // each slot is overwritten exactly once, so no ownership duplication occurs. // All members of WiFiScanResult are either trivially copyable (bssid, channel, // rssi, priority, flags) or CompactString, which stores either inline data or // a heap pointer — never a self-referential pointer (unlike std::string's SSO // on some implementations). This was not possible before PR#13472 replaced // std::string with CompactString, since std::string's internal layout is // implementation-defined and may use self-referential pointers. // // TODO: If C++ standardizes std::trivially_relocatable, add the assertion for // WiFiScanResult/CompactString here to formally express the memcpy safety guarantee. template static void insertion_sort_scan_results(VectorType &results) { // memcpy-based sort requires no self-referential pointers or virtual dispatch. // These static_asserts guard the assumptions. If any fire, the memcpy sort // must be reviewed for safety before updating the expected values. // // No vtable pointers (memcpy would corrupt vptr) static_assert(!std::is_polymorphic::value, "WiFiScanResult must not have vtable"); static_assert(!std::is_polymorphic::value, "CompactString must not have vtable"); // Standard layout ensures predictable memory layout with no virtual bases // and no mixed-access-specifier reordering static_assert(std::is_standard_layout::value, "WiFiScanResult must be standard layout"); static_assert(std::is_standard_layout::value, "CompactString must be standard layout"); // Size checks catch added/removed fields that may need safety review static_assert(sizeof(WiFiScanResult) == 32, "WiFiScanResult size changed - verify memcpy sort is still safe"); static_assert(sizeof(CompactString) == 20, "CompactString size changed - verify memcpy sort is still safe"); // Alignment must match for reinterpret_cast of key_buf to be valid static_assert(alignof(WiFiScanResult) <= alignof(std::max_align_t), "WiFiScanResult alignment exceeds max_align_t"); const size_t size = results.size(); constexpr size_t elem_size = sizeof(WiFiScanResult); // Suppress warnings for intentional memcpy on non-trivially-copyable type. // Safety is guaranteed by the static_asserts above and the permutation invariant. // NOLINTNEXTLINE(bugprone-undefined-memory-manipulation) auto *memcpy_fn = &memcpy; for (size_t i = 1; i < size; i++) { alignas(WiFiScanResult) uint8_t key_buf[elem_size]; memcpy_fn(key_buf, &results[i], elem_size); const auto &key = *reinterpret_cast(key_buf); int32_t j = i - 1; // Move elements that are worse than key to the right // For stability, we only move if key is strictly better than results[j] while (j >= 0 && wifi_scan_result_is_better(key, results[j])) { memcpy_fn(&results[j + 1], &results[j], elem_size); j--; } memcpy_fn(&results[j + 1], key_buf, elem_size); } } // Helper function to log matching scan results - marked noinline to prevent re-inlining into loop // // IMPORTANT: This function deliberately uses a SINGLE log call to minimize blocking. // In environments with many matching networks (e.g., 18+ mesh APs), multiple log calls // per network would block the main loop for an unacceptable duration. Each log call // has overhead from UART transmission, so combining INFO+DEBUG into one line halves // the blocking time. Do NOT split this into separate ESP_LOGI/ESP_LOGD calls. __attribute__((noinline)) static void log_scan_result(const WiFiScanResult &res) { char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; auto bssid = res.get_bssid(); format_mac_addr_upper(bssid.data(), bssid_s); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG // Single combined log line with all details when DEBUG enabled ESP_LOGI(TAG, "- '%s' %s" LOG_SECRET("(%s) ") "%s Ch:%2u %3ddB P:%d", res.get_ssid().c_str(), res.get_is_hidden() ? LOG_STR_LITERAL("(HIDDEN) ") : LOG_STR_LITERAL(""), bssid_s, LOG_STR_ARG(get_signal_bars(res.get_rssi())), res.get_channel(), res.get_rssi(), res.get_priority()); #else ESP_LOGI(TAG, "- '%s' %s" LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(), res.get_is_hidden() ? LOG_STR_LITERAL("(HIDDEN) ") : LOG_STR_LITERAL(""), bssid_s, LOG_STR_ARG(get_signal_bars(res.get_rssi()))); #endif } void WiFiComponent::check_scanning_finished() { if (!this->scan_done_) { if (millis() - this->action_started_ > WIFI_SCAN_TIMEOUT_MS) { ESP_LOGE(TAG, "Scan timeout"); this->retry_connect(); } return; } this->scan_done_ = false; this->has_completed_scan_after_captive_portal_start_ = true; // Track that we've done a scan since captive portal started this->retry_hidden_mode_ = RetryHiddenMode::SCAN_BASED; if (this->scan_result_.empty()) { ESP_LOGW(TAG, "No networks found"); this->retry_connect(); return; } ESP_LOGD(TAG, "Found networks:"); for (auto &res : this->scan_result_) { for (auto &ap : this->sta_) { if (res.matches(ap)) { res.set_matches(true); // Cache priority lookup - do single search instead of 2 separate searches const bssid_t &bssid = res.get_bssid(); if (!this->has_sta_priority(bssid)) { this->set_sta_priority(bssid, ap.get_priority()); } res.set_priority(this->get_sta_priority(bssid)); break; } } } // Sort scan results using insertion sort for better memory efficiency insertion_sort_scan_results(this->scan_result_); // Log matching networks (non-matching already logged at VERBOSE in scan callback) for (auto &res : this->scan_result_) { if (res.get_matches()) { log_scan_result(res); } } // SYNCHRONIZATION POINT: Establish link between scan_result_[0] and selected_sta_index_ // After sorting, scan_result_[0] contains the best network. Now find which sta_[i] config // matches that network and record it in selected_sta_index_. This keeps the two indices // synchronized so build_params_for_current_phase_() can safely use both to build connection parameters. const WiFiScanResult &scan_res = this->scan_result_[0]; bool found_match = false; if (scan_res.get_matches()) { for (size_t i = 0; i < this->sta_.size(); i++) { if (scan_res.matches(this->sta_[i])) { // Safe cast: sta_.size() limited to MAX_WIFI_NETWORKS (127) in __init__.py validation // No overflow check needed - YAML validation prevents >127 networks this->selected_sta_index_ = static_cast(i); // Links scan_result_[0] with sta_[i] found_match = true; break; } } } if (!found_match) { ESP_LOGW(TAG, "No matching network found"); // No scan results matched our configured networks - transition directly to hidden mode // Don't call retry_connect() since we never attempted a connection (no BSSID to penalize) this->transition_to_phase_(WiFiRetryPhase::RETRY_HIDDEN); // If no hidden networks to try, skip connection attempt (will be handled on next loop) if (this->selected_sta_index_ == -1) { return; } // Now start connection attempt in hidden mode } else if (this->transition_to_phase_(WiFiRetryPhase::SCAN_CONNECTING)) { return; // scan started, wait for next loop iteration } yield(); WiFiAP params = this->build_params_for_current_phase_(); // Ensure we're in SCAN_CONNECTING phase when connecting with scan results // (needed when scan was started directly without transition_to_phase_, e.g., initial scan) this->start_connecting(params); } void WiFiComponent::dump_config() { char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, "WiFi:\n" " Local MAC: %s\n" " Connected: %s", get_mac_address_pretty_into_buffer(mac_s), YESNO(this->is_connected())); if (this->is_disabled()) { ESP_LOGCONFIG(TAG, " Disabled"); return; } #if defined(USE_ESP32) && defined(SOC_WIFI_SUPPORT_5G) const char *band_mode_s; switch (this->band_mode_) { case WIFI_BAND_MODE_2G_ONLY: band_mode_s = "2.4GHz"; break; case WIFI_BAND_MODE_5G_ONLY: band_mode_s = "5GHz"; break; case WIFI_BAND_MODE_AUTO: default: band_mode_s = "Auto"; break; } ESP_LOGCONFIG(TAG, " Band Mode: %s", band_mode_s); #endif if (this->is_connected()) { this->print_connect_params_(); } } void WiFiComponent::check_connecting_finished(uint32_t now) { auto status = this->wifi_sta_connect_status_(); if (status == WiFiSTAConnectStatus::CONNECTED) { char ssid_buf[SSID_BUFFER_SIZE]; if (wifi_ssid_to(ssid_buf)[0] == '\0') { ESP_LOGW(TAG, "Connection incomplete"); this->retry_connect(); return; } ESP_LOGI(TAG, "Connected"); // Warn if we had to retry with hidden network mode for a network that's not marked hidden // Only warn if we actually connected without scan data (SSID only), not if scan succeeded on retry if (const WiFiAP *config = this->get_selected_sta_(); this->retry_phase_ == WiFiRetryPhase::RETRY_HIDDEN && config && !config->get_hidden() && this->scan_result_.empty()) { ESP_LOGW(TAG, LOG_SECRET("'%s'") " should be marked hidden", config->ssid_.c_str()); } // Reset to initial phase on successful connection (don't log transition, just reset state) this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT; this->num_retried_ = 0; if (this->has_ap()) { #ifdef USE_CAPTIVE_PORTAL if (this->is_captive_portal_active_()) { captive_portal::global_captive_portal->end(); } #endif ESP_LOGD(TAG, "Disabling AP"); this->wifi_mode_({}, false); } #ifdef USE_IMPROV if (this->is_esp32_improv_active_()) { esp32_improv::global_improv_component->stop(); } #endif this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTED; this->num_retried_ = 0; this->print_connect_params_(); // Reset roaming state on successful connection this->roaming_last_check_ = now; // 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 on first try - reset attempts so we can roam again later ESP_LOGD(TAG, "Roam successful"); this->roaming_attempts_ = 0; } else if (this->roaming_state_ == RoamingState::RECONNECTING) { // Check if we ended up on the roam target despite needing a retry // (e.g., first connect failed but scan-based retry found and connected to the same better AP) bssid_t current_bssid = this->wifi_bssid(); if (this->roaming_target_bssid_ != bssid_t{} && current_bssid == this->roaming_target_bssid_) { char bssid_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(current_bssid.data(), bssid_buf); ESP_LOGD(TAG, "Roam successful (via retry, attempt %u/%u) to %s", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS, bssid_buf); this->roaming_attempts_ = 0; } else if (this->roaming_target_bssid_ != bssid_t{}) { // Failed roam to specific target, reconnected to different AP - keep attempts to prevent ping-pong ESP_LOGD(TAG, "Reconnected after failed roam (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS); } else { // Reconnected after scan-induced disconnect (no roam target) - keep attempts ESP_LOGD(TAG, "Reconnected after roam scan (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS); } } else { // Normal connection (boot, credentials changed, etc.) this->roaming_attempts_ = 0; } this->roaming_state_ = RoamingState::IDLE; this->roaming_target_bssid_ = {}; this->roaming_scan_end_ = 0; // 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 this->release_scan_results_(); #ifdef USE_WIFI_CONNECT_STATE_LISTENERS // Notify listeners now that state machine has reached STA_CONNECTED // This ensures wifi.connected condition returns true in listener automations this->notify_connect_state_listeners_(); #endif #if defined(USE_ESP8266) && defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP) // On ESP8266, GOT_IP event may not fire for static IP configurations, // so notify IP state listeners here as a fallback. if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) { this->notify_ip_state_listeners_(); } #endif return; } if (now - this->action_started_ > WIFI_CONNECT_TIMEOUT_MS) { ESP_LOGW(TAG, "Connection timeout, aborting connection attempt"); this->wifi_disconnect_(); this->retry_connect(); return; } if (this->error_from_callback_) { // ESP8266: logging done in callback, listeners deferred via pending_.disconnect // Other platforms: just log generic failure message #ifndef USE_ESP8266 ESP_LOGW(TAG, "Connecting to network failed (callback)"); #endif this->retry_connect(); return; } if (status == WiFiSTAConnectStatus::CONNECTING) { return; } if (status == WiFiSTAConnectStatus::ERROR_NETWORK_NOT_FOUND) { ESP_LOGW(TAG, "Network no longer found"); this->retry_connect(); return; } if (status == WiFiSTAConnectStatus::ERROR_CONNECT_FAILED) { ESP_LOGW(TAG, "Connecting to network failed"); this->retry_connect(); return; } ESP_LOGW(TAG, "Unknown connection status %d", (int) status); this->retry_connect(); } /// Determine the next retry phase based on current state and failure conditions /// This function examines the current retry phase, number of retries, and failure reasons /// to decide what phase to move to next. It does not modify any state - it only returns /// the recommended next phase. /// /// @return The next WiFiRetryPhase to transition to (may be same as current phase if should retry) WiFiRetryPhase WiFiComponent::determine_next_phase_() { switch (this->retry_phase_) { case WiFiRetryPhase::INITIAL_CONNECT: #ifdef USE_WIFI_FAST_CONNECT case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS: // INITIAL_CONNECT and FAST_CONNECT_CYCLING_APS: no retries, try next AP or fall back to scan if (this->selected_sta_index_ < static_cast(this->sta_.size()) - 1) { return WiFiRetryPhase::FAST_CONNECT_CYCLING_APS; // Move to next AP } #endif // Check if we should try explicit hidden networks before scanning // This handles reconnection after connection loss where first network is hidden if (!this->sta_.empty() && this->sta_[0].get_hidden()) { return WiFiRetryPhase::EXPLICIT_HIDDEN; } // No more APs to try, fall back to scan return WiFiRetryPhase::SCAN_CONNECTING; case WiFiRetryPhase::EXPLICIT_HIDDEN: { // Try all explicitly hidden networks before scanning if (this->num_retried_ + 1 < WIFI_RETRY_COUNT_PER_SSID) { return WiFiRetryPhase::EXPLICIT_HIDDEN; // Keep retrying same SSID } // Exhausted retries on current SSID - check for more explicitly hidden networks // Stop when we reach a visible network (proceed to scanning) size_t next_index = this->selected_sta_index_ + 1; if (next_index < this->sta_.size() && this->sta_[next_index].get_hidden()) { // Found another explicitly hidden network return WiFiRetryPhase::EXPLICIT_HIDDEN; } // No more consecutive explicitly hidden networks // If ALL networks are hidden, skip scanning and go directly to restart if (this->find_first_non_hidden_index_() < 0) { return WiFiRetryPhase::RESTARTING_ADAPTER; } // Otherwise proceed to scanning for non-hidden networks return WiFiRetryPhase::SCAN_CONNECTING; } case WiFiRetryPhase::SCAN_CONNECTING: // If scan found no networks or no matching networks, skip to hidden network mode if (this->scan_result_.empty() || !this->scan_result_[0].get_matches()) { return WiFiRetryPhase::RETRY_HIDDEN; } if (this->num_retried_ + 1 < WIFI_RETRY_COUNT_PER_BSSID) { return WiFiRetryPhase::SCAN_CONNECTING; // Keep retrying same BSSID } // Exhausted retries on current BSSID (scan_result_[0]) // Its priority has been decreased, so on next scan it will be sorted lower // and we'll try the next best BSSID. // Check if there are any potentially hidden networks to try if (this->find_next_hidden_sta_(-1) >= 0) { return WiFiRetryPhase::RETRY_HIDDEN; // Found hidden networks to try } // No hidden networks - always go through RESTARTING_ADAPTER phase // This ensures num_retried_ gets reset and a fresh scan is triggered // The actual adapter restart will be skipped if captive portal/improv is active return WiFiRetryPhase::RESTARTING_ADAPTER; case WiFiRetryPhase::RETRY_HIDDEN: // If no hidden SSIDs to try (selected_sta_index_ == -1), skip directly to rescan if (this->selected_sta_index_ >= 0) { if (this->num_retried_ + 1 < WIFI_RETRY_COUNT_PER_SSID) { return WiFiRetryPhase::RETRY_HIDDEN; // Keep retrying same SSID } // Exhausted retries on current SSID - check if there are more potentially hidden SSIDs to try if (this->selected_sta_index_ < static_cast(this->sta_.size()) - 1) { // Check if find_next_hidden_sta_() would actually find another hidden SSID // as it might have been seen in the scan results and we want to skip those // otherwise we will get stuck in RETRY_HIDDEN phase if (this->find_next_hidden_sta_(this->selected_sta_index_) != -1) { // More hidden SSIDs available - stay in RETRY_HIDDEN, advance will happen in retry_connect() return WiFiRetryPhase::RETRY_HIDDEN; } } } // Exhausted all potentially hidden SSIDs - always go through RESTARTING_ADAPTER // This ensures num_retried_ gets reset and a fresh scan is triggered // The actual adapter restart will be skipped if captive portal/improv is active return WiFiRetryPhase::RESTARTING_ADAPTER; case WiFiRetryPhase::RESTARTING_ADAPTER: // After restart, go back to explicit hidden if we went through it initially if (this->went_through_explicit_hidden_phase_()) { return WiFiRetryPhase::EXPLICIT_HIDDEN; } // Skip scanning when captive portal/improv is active to avoid disrupting AP, // BUT only if we've already completed at least one scan AFTER the portal started. // When captive portal first starts, scan results may be filtered/stale, so we need // to do one full scan to populate available networks for the captive portal UI. // // WHY SCANNING DISRUPTS AP MODE: // WiFi scanning requires the radio to leave the AP's channel and hop through // other channels to listen for beacons. During this time (even for passive scans), // the AP cannot service connected clients - they experience disconnections or // timeouts. On ESP32, even passive scans cause brief but noticeable disruptions // that break captive portal HTTP requests and DNS lookups. // // BLIND RETRY MODE: // When captive portal/improv is active, we use RETRY_HIDDEN as a "try all networks // blindly" mode. Since retry_hidden_mode_ is set to BLIND_RETRY (in RESTARTING_ADAPTER // transition), find_next_hidden_sta_() will treat ALL configured networks as // candidates, cycling through them without requiring scan results. // // This allows users to configure WiFi via captive portal while the device keeps // attempting to connect to all configured networks in sequence. // Captive portal needs scan results to show available networks. // If captive portal is active, only skip scanning if we've done a scan after it started. // If only improv is active (no captive portal), skip scanning since improv doesn't need results. if (this->is_captive_portal_active_()) { if (this->has_completed_scan_after_captive_portal_start_) { return WiFiRetryPhase::RETRY_HIDDEN; } // Need to scan for captive portal } else if (this->is_esp32_improv_active_()) { // Improv doesn't need scan results return WiFiRetryPhase::RETRY_HIDDEN; } return WiFiRetryPhase::SCAN_CONNECTING; } // Should never reach here return WiFiRetryPhase::SCAN_CONNECTING; } /// Transition from current retry phase to a new phase with logging and phase-specific setup /// This function handles the actual state change, including: /// - Logging the phase transition /// - Resetting the retry counter /// - Performing phase-specific initialization (e.g., advancing AP index, starting scans) /// /// @param new_phase The phase we're transitioning TO /// @return true if connection attempt should be skipped (scan started or no networks to try) /// false if caller can proceed with connection attempt bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { WiFiRetryPhase old_phase = this->retry_phase_; // No-op if staying in same phase if (old_phase == new_phase) { return false; } ESP_LOGD(TAG, "Retry phase: %s → %s", LOG_STR_ARG(retry_phase_to_log_string(old_phase)), LOG_STR_ARG(retry_phase_to_log_string(new_phase))); this->retry_phase_ = new_phase; this->num_retried_ = 0; // Reset retry counter on phase change // Phase-specific setup switch (new_phase) { #ifdef USE_WIFI_FAST_CONNECT case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS: // Move to next configured AP - clear old scan data so new AP is tried with config only this->selected_sta_index_++; this->scan_result_.clear(); break; #endif case WiFiRetryPhase::EXPLICIT_HIDDEN: // Starting explicit hidden phase - reset to first network this->selected_sta_index_ = 0; break; case WiFiRetryPhase::SCAN_CONNECTING: // Transitioning to scan-based connection #ifdef USE_WIFI_FAST_CONNECT if (old_phase == WiFiRetryPhase::FAST_CONNECT_CYCLING_APS) { ESP_LOGI(TAG, "Fast connect exhausted, falling back to scan"); } #endif // Trigger scan if we don't have scan results OR if transitioning from phases that need fresh scan if (this->scan_result_.empty() || old_phase == WiFiRetryPhase::EXPLICIT_HIDDEN || old_phase == WiFiRetryPhase::RETRY_HIDDEN || old_phase == WiFiRetryPhase::RESTARTING_ADAPTER) { this->selected_sta_index_ = -1; // Will be set after scan completes this->start_scanning(); return true; // Started scan, wait for completion } // Already have scan results - selected_sta_index_ should already be synchronized // (set in check_scanning_finished() when scan completed) // No need to reset it here break; case WiFiRetryPhase::RETRY_HIDDEN: // Always reset to first candidate when entering this phase. // This phase can be entered from: // - SCAN_CONNECTING: normal flow, find_next_hidden_sta_() skips networks visible in scan // - RESTARTING_ADAPTER: captive portal active, find_next_hidden_sta_() tries ALL networks // // The retry_hidden_mode_ controls the behavior: // - SCAN_BASED: scan_result_ is checked, visible networks are skipped // - BLIND_RETRY: scan_result_ is ignored, all networks become candidates // We don't clear scan_result_ here - the mode controls whether it's consulted. this->selected_sta_index_ = this->find_next_hidden_sta_(-1); if (this->selected_sta_index_ == -1) { ESP_LOGD(TAG, "All SSIDs visible or already tried, skipping hidden mode"); } break; case WiFiRetryPhase::RESTARTING_ADAPTER: // Skip actual adapter restart if captive portal/improv is active // This allows state machine to reset num_retried_ and trigger fresh scan // without disrupting the captive portal/improv connection if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_()) { this->restart_adapter(); } else { // Even when skipping full restart, disconnect to clear driver state // Without this, platforms like LibreTiny may think we're still connecting this->wifi_disconnect_(); } // Clear scan flag - we're starting a new retry cycle // This is critical for captive portal/improv flow: when determine_next_phase_() // returns RETRY_HIDDEN (because scanning is skipped), find_next_hidden_sta_() // will see BLIND_RETRY mode and treat ALL networks as candidates, // effectively cycling through all configured networks without scan results. this->retry_hidden_mode_ = RetryHiddenMode::BLIND_RETRY; // Always enter cooldown after restart (or skip-restart) to allow stabilization // Use extended cooldown when AP is active to avoid constant scanning that blocks DNS this->state_ = WIFI_COMPONENT_STATE_COOLDOWN; this->action_started_ = millis(); // Return true to indicate we should wait (go to COOLDOWN) instead of immediately connecting return true; default: break; } 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 failed connection attempts void WiFiComponent::clear_priorities_if_all_min_() { if (this->sta_priorities_.empty()) { return; } int8_t first_priority = this->sta_priorities_[0].priority; // Only clear if all priorities have been decremented to the minimum value // At this point, all BSSIDs have been equally penalized and priority info is useless if (first_priority != std::numeric_limits::min()) { return; } for (const auto &pri : this->sta_priorities_) { if (pri.priority != first_priority) { return; // Not all same, nothing to do } } // All priorities are at minimum - clear the vector to save memory and reset ESP_LOGD(TAG, "Clearing BSSID priorities (all at minimum)"); this->clear_all_bssid_priorities_(); } /// Log failed connection attempt and decrease BSSID priority to avoid repeated failures /// This function identifies which BSSID was attempted (from scan results or config), /// decreases its priority by 1.0 to discourage future attempts, and logs the change. /// /// The priority degradation system ensures that failed BSSIDs are automatically sorted /// lower in subsequent scans, naturally cycling through different APs without explicit /// BSSID tracking within a scan cycle. /// /// Priority sources: /// - SCAN_CONNECTING phase: Uses BSSID from scan_result_[0] (best match after sorting) /// - Other phases: Uses BSSID from config if explicitly specified by user or fast_connect /// /// If no BSSID is available (SSID-only connection), priority adjustment is skipped. /// /// IMPORTANT: Priority is only decreased on the LAST attempt for a BSSID in SCAN_CONNECTING phase. /// This prevents false positives from transient WiFi stack state issues after scanning. /// Single failures don't necessarily mean the AP is bad - two genuine failures provide /// higher confidence before degrading priority and skipping the BSSID in future scans. void WiFiComponent::log_and_adjust_priority_for_failed_connect_() { // Determine which BSSID we tried to connect to optional failed_bssid; if (this->retry_phase_ == WiFiRetryPhase::SCAN_CONNECTING && !this->scan_result_.empty()) { // Scan-based phase: always use best result (index 0) failed_bssid = this->scan_result_[0].get_bssid(); } else if (const WiFiAP *config = this->get_selected_sta_(); config && config->has_bssid()) { // Config has specific BSSID (fast_connect or user-specified) failed_bssid = config->get_bssid(); } if (!failed_bssid.has_value()) { return; // No BSSID to penalize } // Get SSID for logging (use pointer to avoid copy) const char *ssid = nullptr; if (this->retry_phase_ == WiFiRetryPhase::SCAN_CONNECTING && !this->scan_result_.empty()) { ssid = this->scan_result_[0].ssid_.c_str(); } else if (const WiFiAP *config = this->get_selected_sta_()) { ssid = config->ssid_.c_str(); } // Only decrease priority on the last attempt for this phase // This prevents false positives from transient WiFi stack issues uint8_t max_retries = get_max_retries_for_phase(this->retry_phase_); bool is_last_attempt = (this->num_retried_ + 1 >= max_retries); // Decrease priority only on last attempt to avoid false positives from transient failures int8_t old_priority = this->get_sta_priority(failed_bssid.value()); int8_t new_priority = old_priority; if (is_last_attempt) { // Decrease priority, but clamp to int8_t::min to prevent overflow new_priority = (old_priority > std::numeric_limits::min()) ? (old_priority - 1) : std::numeric_limits::min(); this->set_sta_priority(failed_bssid.value(), new_priority); } char bssid_s[18]; format_mac_addr_upper(failed_bssid.value().data(), bssid_s); ESP_LOGD(TAG, "Failed " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") ", priority %d → %d", ssid != nullptr ? ssid : "", bssid_s, old_priority, new_priority); // After adjusting priority, check if all priorities are now at minimum // If so, clear the vector to save memory and reset for fresh start this->clear_priorities_if_all_min_(); } /// Handle target advancement or retry counter increment when staying in the same phase /// This function is called when a connection attempt fails and determine_next_phase_() indicates /// we should stay in the current phase. It decides whether to: /// - Advance to the next target (AP in fast_connect, SSID in hidden mode) /// - Or increment the retry counter to try the same target again /// /// Phase-specific behavior: /// - FAST_CONNECT_CYCLING_APS: Always advance to next AP (no retries per AP) /// - RETRY_HIDDEN: Advance to next SSID after exhausting retries on current SSID /// - Other phases: Increment retry counter (will retry same target) void WiFiComponent::advance_to_next_target_or_increment_retry_() { WiFiRetryPhase current_phase = this->retry_phase_; // Check if we need to advance to next AP/SSID within the same phase #ifdef USE_WIFI_FAST_CONNECT if (current_phase == WiFiRetryPhase::FAST_CONNECT_CYCLING_APS) { // Fast connect: always advance to next AP (no retries per AP) this->selected_sta_index_++; this->num_retried_ = 0; ESP_LOGD(TAG, "Next AP in %s", LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_))); return; } #endif if (current_phase == WiFiRetryPhase::EXPLICIT_HIDDEN && this->num_retried_ + 1 >= WIFI_RETRY_COUNT_PER_SSID) { // Explicit hidden: exhausted retries on current SSID, find next explicitly hidden network // Stop when we reach a visible network (proceed to scanning) size_t next_index = this->selected_sta_index_ + 1; if (next_index < this->sta_.size() && this->sta_[next_index].get_hidden()) { this->selected_sta_index_ = static_cast(next_index); this->num_retried_ = 0; ESP_LOGD(TAG, "Next explicit hidden network at index %d", static_cast(next_index)); return; } // No more consecutive explicit hidden networks found - fall through to trigger phase change } if (current_phase == WiFiRetryPhase::RETRY_HIDDEN && this->num_retried_ + 1 >= WIFI_RETRY_COUNT_PER_SSID) { // Hidden mode: exhausted retries on current SSID, find next potentially hidden SSID // If first network is marked hidden, we went through EXPLICIT_HIDDEN phase // In that case, skip networks marked hidden:true (already tried) // Otherwise, include them (they haven't been tried yet) int8_t next_index = this->find_next_hidden_sta_(this->selected_sta_index_); if (next_index != -1) { // Found another potentially hidden SSID this->selected_sta_index_ = next_index; this->num_retried_ = 0; return; } // No more potentially hidden SSIDs - set selected_sta_index_ to -1 to trigger phase change // This ensures determine_next_phase_() will skip the RETRY_HIDDEN logic and transition out this->selected_sta_index_ = -1; // Return early - phase change will happen on next wifi_loop() iteration return; } // Don't increment retry counter if we're in a scan phase with no valid targets if (this->needs_scan_results_()) { return; } // Increment retry counter to try the same target again this->num_retried_++; ESP_LOGD(TAG, "Retry attempt %u/%u in phase %s", this->num_retried_ + 1, get_max_retries_for_phase(this->retry_phase_), LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_))); } void WiFiComponent::retry_connect() { // 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) { // Disconnected during roam scan - transition to RECONNECTING so the attempts // counter is preserved when reconnection succeeds (IDLE would reset it) ESP_LOGD(TAG, "Disconnected during roam scan (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS); this->roaming_state_ = RoamingState::RECONNECTING; } else if (this->roaming_state_ == RoamingState::IDLE) { // Check if a roaming scan recently completed - on ESP8266, going off-channel // during scan can cause a delayed Beacon Timeout 8-20 seconds after scan finishes. // Transition to RECONNECTING so the attempts counter is preserved on reconnect. if (this->roaming_scan_end_ != 0 && millis() - this->roaming_scan_end_ < ROAMING_SCAN_GRACE_PERIOD) { ESP_LOGD(TAG, "Disconnect after roam scan (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS); this->roaming_state_ = RoamingState::RECONNECTING; } else { // 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_(); // Determine next retry phase based on current state WiFiRetryPhase current_phase = this->retry_phase_; WiFiRetryPhase next_phase = this->determine_next_phase_(); // Handle phase transitions (transition_to_phase_ handles same-phase no-op internally) if (this->transition_to_phase_(next_phase)) { return; // Scan started or adapter restarted (which sets its own state) } if (next_phase == current_phase) { this->advance_to_next_target_or_increment_retry_(); } yield(); // Check if we have a valid target before building params // After exhausting all networks in a phase, selected_sta_index_ may be -1 // In that case, skip connection and let next wifi_loop() handle phase transition if (this->selected_sta_index_ >= 0) { WiFiAP params = this->build_params_for_current_phase_(); this->start_connecting(params); } } void WiFiComponent::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } void WiFiComponent::set_power_save_mode(WiFiPowerSaveMode power_save) { this->power_save_ = power_save; #if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) this->configured_power_save_ = power_save; #endif } void WiFiComponent::set_passive_scan(bool passive) { this->passive_scan_ = passive; } bool WiFiComponent::is_captive_portal_active_() { #ifdef USE_CAPTIVE_PORTAL return captive_portal::global_captive_portal != nullptr && captive_portal::global_captive_portal->is_active(); #else return false; #endif } bool WiFiComponent::is_esp32_improv_active_() { #ifdef USE_IMPROV return esp32_improv::global_improv_component != nullptr && esp32_improv::global_improv_component->is_active(); #else return false; #endif } #if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) bool WiFiComponent::request_high_performance() { // Already configured for high performance - request satisfied if (this->configured_power_save_ == WIFI_POWER_SAVE_NONE) { return true; } // Semaphore initialization failed if (this->high_performance_semaphore_ == nullptr) { return false; } // Give the semaphore (non-blocking). This increments the count. return xSemaphoreGive(this->high_performance_semaphore_) == pdTRUE; } bool WiFiComponent::release_high_performance() { // Already configured for high performance - nothing to release if (this->configured_power_save_ == WIFI_POWER_SAVE_NONE) { return true; } // Semaphore initialization failed if (this->high_performance_semaphore_ == nullptr) { return false; } // Take the semaphore (non-blocking). This decrements the count. return xSemaphoreTake(this->high_performance_semaphore_, 0) == pdTRUE; } #endif // USE_ESP32 && USE_WIFI_RUNTIME_POWER_SAVE #ifdef USE_WIFI_FAST_CONNECT bool WiFiComponent::load_fast_connect_settings_(WiFiAP ¶ms) { SavedWifiFastConnectSettings fast_connect_save{}; if (this->fast_connect_pref_.load(&fast_connect_save)) { // Validate saved AP index if (fast_connect_save.ap_index < 0 || static_cast(fast_connect_save.ap_index) >= this->sta_.size()) { ESP_LOGW(TAG, "AP index out of bounds"); return false; } // Set selected index for future operations (save, retry, etc) this->selected_sta_index_ = fast_connect_save.ap_index; // Copy entire config, then override with fast connect data params = this->sta_[fast_connect_save.ap_index]; // Override with saved BSSID/channel from fast connect (SSID/password/etc already copied from config) bssid_t bssid{}; std::copy(fast_connect_save.bssid, fast_connect_save.bssid + 6, bssid.begin()); params.set_bssid(bssid); params.set_channel(fast_connect_save.channel); // Fast connect uses specific BSSID+channel, not hidden network probe (even if config has hidden: true) params.set_hidden(false); ESP_LOGD(TAG, "Loaded fast_connect settings"); #if defined(USE_ESP32) && defined(SOC_WIFI_SUPPORT_5G) if ((this->band_mode_ == WIFI_BAND_MODE_5G_ONLY && fast_connect_save.channel < FIRST_5GHZ_CHANNEL) || (this->band_mode_ == WIFI_BAND_MODE_2G_ONLY && fast_connect_save.channel >= FIRST_5GHZ_CHANNEL)) { ESP_LOGW(TAG, "Saved channel %u not allowed by band mode, ignoring fast_connect", fast_connect_save.channel); this->selected_sta_index_ = -1; return false; } #endif return true; } return false; } void WiFiComponent::save_fast_connect_settings_() { bssid_t bssid = wifi_bssid(); uint8_t channel = get_wifi_channel(); // selected_sta_index_ is always valid here (called only after successful connection) // Fallback to 0 is defensive programming for robustness int8_t ap_index = this->selected_sta_index_ >= 0 ? this->selected_sta_index_ : 0; // Skip save if settings haven't changed (compare with previously saved settings to reduce flash wear) SavedWifiFastConnectSettings previous_save{}; if (this->fast_connect_pref_.load(&previous_save) && memcmp(previous_save.bssid, bssid.data(), 6) == 0 && previous_save.channel == channel && previous_save.ap_index == ap_index) { return; // No change, nothing to save } SavedWifiFastConnectSettings fast_connect_save{}; memcpy(fast_connect_save.bssid, bssid.data(), 6); fast_connect_save.channel = channel; fast_connect_save.ap_index = ap_index; this->fast_connect_pref_.save(&fast_connect_save); ESP_LOGD(TAG, "Saved fast_connect settings"); } #endif void WiFiAP::set_ssid(const std::string &ssid) { this->ssid_ = CompactString(ssid.c_str(), ssid.size()); } void WiFiAP::set_ssid(const char *ssid) { this->ssid_ = CompactString(ssid, strlen(ssid)); } void WiFiAP::set_bssid(const bssid_t &bssid) { this->bssid_ = bssid; } void WiFiAP::clear_bssid() { this->bssid_ = {}; } void WiFiAP::set_password(const std::string &password) { this->password_ = CompactString(password.c_str(), password.size()); } void WiFiAP::set_password(const char *password) { this->password_ = CompactString(password, strlen(password)); } #ifdef USE_WIFI_WPA2_EAP void WiFiAP::set_eap(optional eap_auth) { this->eap_ = std::move(eap_auth); } #endif void WiFiAP::set_channel(uint8_t channel) { this->channel_ = channel; } void WiFiAP::clear_channel() { this->channel_ = 0; } #ifdef USE_WIFI_MANUAL_IP void WiFiAP::set_manual_ip(optional manual_ip) { this->manual_ip_ = manual_ip; } #endif void WiFiAP::set_hidden(bool hidden) { this->hidden_ = hidden; } const bssid_t &WiFiAP::get_bssid() const { return this->bssid_; } bool WiFiAP::has_bssid() const { return this->bssid_ != bssid_t{}; } #ifdef USE_WIFI_WPA2_EAP const optional &WiFiAP::get_eap() const { return this->eap_; } #endif #ifdef USE_WIFI_MANUAL_IP const optional &WiFiAP::get_manual_ip() const { return this->manual_ip_; } #endif bool WiFiAP::get_hidden() const { return this->hidden_; } WiFiScanResult::WiFiScanResult(const bssid_t &bssid, const char *ssid, size_t ssid_len, uint8_t channel, int8_t rssi, bool with_auth, bool is_hidden) : bssid_(bssid), channel_(channel), rssi_(rssi), ssid_(ssid, ssid_len), with_auth_(with_auth), is_hidden_(is_hidden) {} bool WiFiScanResult::matches(const WiFiAP &config) const { if (config.get_hidden()) { // User configured a hidden network, only match actually hidden networks // don't match SSID if (!this->is_hidden_) return false; } else if (!config.ssid_.empty()) { // check if SSID matches if (this->ssid_ != config.ssid_) return false; } else { // network is configured without SSID - match other settings } // If BSSID configured, only match for correct BSSIDs if (config.has_bssid() && config.get_bssid() != this->bssid_) return false; #ifdef USE_WIFI_WPA2_EAP // BSSID requires auth but no PSK or EAP credentials given if (this->with_auth_ && (config.password_.empty() && !config.get_eap().has_value())) return false; // BSSID does not require auth, but PSK or EAP credentials given if (!this->with_auth_ && (!config.password_.empty() || config.get_eap().has_value())) return false; #else // If PSK given, only match for networks with auth (and vice versa) if (config.password_.empty() == this->with_auth_) return false; #endif // If channel configured, only match networks on that channel. if (config.has_channel() && config.get_channel() != this->channel_) { return false; } return true; } bool WiFiScanResult::get_matches() const { return this->matches_; } void WiFiScanResult::set_matches(bool matches) { this->matches_ = matches; } const bssid_t &WiFiScanResult::get_bssid() const { return this->bssid_; } uint8_t WiFiScanResult::get_channel() const { return this->channel_; } int8_t WiFiScanResult::get_rssi() const { return this->rssi_; } bool WiFiScanResult::get_with_auth() const { return this->with_auth_; } 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_end_ = 0; this->roaming_target_bssid_ = {}; this->roaming_state_ = RoamingState::IDLE; } void WiFiComponent::release_scan_results_() { if (!this->keep_scan_results_) { #if defined(USE_RP2040) || defined(USE_ESP32) // std::vector - use swap trick since shrink_to_fit is non-binding decltype(this->scan_result_)().swap(this->scan_result_); #else // FixedVector::release() frees all memory this->scan_result_.release(); #endif } } #ifdef USE_WIFI_CONNECT_STATE_LISTENERS void WiFiComponent::notify_connect_state_listeners_() { if (!this->pending_.connect_state) return; this->pending_.connect_state = false; // Get current SSID and BSSID from the WiFi driver char ssid_buf[SSID_BUFFER_SIZE]; const char *ssid = this->wifi_ssid_to(ssid_buf); bssid_t bssid = this->wifi_bssid(); for (auto *listener : this->connect_state_listeners_) { listener->on_wifi_connect_state(StringRef(ssid, strlen(ssid)), bssid); } } void WiFiComponent::notify_disconnect_state_listeners_() { constexpr uint8_t empty_bssid[6] = {}; for (auto *listener : this->connect_state_listeners_) { listener->on_wifi_connect_state(StringRef(), empty_bssid); } } #endif // USE_WIFI_CONNECT_STATE_LISTENERS #ifdef USE_WIFI_IP_STATE_LISTENERS void WiFiComponent::notify_ip_state_listeners_() { for (auto *listener : this->ip_state_listeners_) { listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); } } #endif // USE_WIFI_IP_STATE_LISTENERS #ifdef USE_WIFI_SCAN_RESULTS_LISTENERS void WiFiComponent::notify_scan_results_listeners_() { for (auto *listener : this->scan_results_listeners_) { listener->on_wifi_scan_results(this->scan_result_); } } #endif // USE_WIFI_SCAN_RESULTS_LISTENERS 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) { ESP_LOGD(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, 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; // Default to IDLE - will be set to CONNECTING if we find a better AP this->roaming_state_ = RoamingState::IDLE; // Record when scan completed so delayed disconnects (e.g., ESP8266 Beacon Timeout) // can be attributed to the scan and avoid resetting the attempts counter this->roaming_scan_end_ = millis(); // 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 (result.ssid_ != current_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), attempt %u/%u", improvement, ROAMING_MIN_IMPROVEMENT, this->roaming_attempts_, ROAMING_MAX_ATTEMPTS); 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); // Mark as roaming attempt - affects retry behavior if connection fails this->roaming_state_ = RoamingState::CONNECTING; this->roaming_target_bssid_ = best->get_bssid(); // Must read before releasing scan results this->release_scan_results_(); // Connect directly - wifi_sta_connect_ handles disconnect internally this->start_connecting(roam_params); } WiFiComponent *global_wifi_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome::wifi #endif