ESPHome 2026.1.1
Loading...
Searching...
No Matches
wifi_component.cpp
Go to the documentation of this file.
1#include "wifi_component.h"
2#ifdef USE_WIFI
3#include <cassert>
4#include <cinttypes>
5#include <cmath>
6
7#ifdef USE_ESP32
8#if (ESP_IDF_VERSION_MAJOR >= 5 && ESP_IDF_VERSION_MINOR >= 1)
9#include <esp_eap_client.h>
10#else
11#include <esp_wpa2.h>
12#endif
13#endif
14
15#if defined(USE_ESP32)
16#include <esp_wifi.h>
17#endif
18#ifdef USE_ESP8266
19#include <user_interface.h>
20#endif
21
22#include <algorithm>
23#include <utility>
24#include "lwip/dns.h"
25#include "lwip/err.h"
26
28#include "esphome/core/hal.h"
30#include "esphome/core/log.h"
32#include "esphome/core/util.h"
33
34#ifdef USE_CAPTIVE_PORTAL
36#endif
37
38#ifdef USE_IMPROV
40#endif
41
42namespace esphome::wifi {
43
44static const char *const TAG = "wifi";
45
234
235static const LogString *retry_phase_to_log_string(WiFiRetryPhase phase) {
236 switch (phase) {
238 return LOG_STR("INITIAL_CONNECT");
239#ifdef USE_WIFI_FAST_CONNECT
241 return LOG_STR("FAST_CONNECT_CYCLING");
242#endif
244 return LOG_STR("EXPLICIT_HIDDEN");
246 return LOG_STR("SCAN_CONNECTING");
248 return LOG_STR("RETRY_HIDDEN");
250 return LOG_STR("RESTARTING");
251 default:
252 return LOG_STR("UNKNOWN");
253 }
254}
255
257 // If first configured network is marked hidden, we went through EXPLICIT_HIDDEN phase
258 // This means those networks were already tried and should be skipped in RETRY_HIDDEN
259 return !this->sta_.empty() && this->sta_[0].get_hidden();
260}
261
263 // Find the first network that is NOT marked hidden:true
264 // This is where EXPLICIT_HIDDEN phase would have stopped
265 for (size_t i = 0; i < this->sta_.size(); i++) {
266 if (!this->sta_[i].get_hidden()) {
267 return static_cast<int8_t>(i);
268 }
269 }
270 return -1; // All networks are hidden
271}
272
273// 2 attempts per BSSID in SCAN_CONNECTING phase
274// Rationale: This is the ONLY phase where we decrease BSSID priority, so we must be very sure.
275// Auth failures are common immediately after scan due to WiFi stack state transitions.
276// Trying twice filters out false positives and prevents unnecessarily marking a good BSSID as bad.
277// After 2 genuine failures, priority degradation ensures we skip this BSSID on subsequent scans.
278static constexpr uint8_t WIFI_RETRY_COUNT_PER_BSSID = 2;
279
280// 1 attempt per SSID in RETRY_HIDDEN phase
281// Rationale: Try hidden mode once, then rescan to get next best BSSID via priority system
282static constexpr uint8_t WIFI_RETRY_COUNT_PER_SSID = 1;
283
284// 1 attempt per AP in fast_connect mode (INITIAL_CONNECT and FAST_CONNECT_CYCLING_APS)
285// Rationale: Fast connect prioritizes speed - try each AP once to find a working one quickly
286static constexpr uint8_t WIFI_RETRY_COUNT_PER_AP = 1;
287
290static constexpr uint32_t WIFI_COOLDOWN_DURATION_MS = 500;
291
295static constexpr uint32_t WIFI_COOLDOWN_WITH_AP_ACTIVE_MS = 30000;
296
300static constexpr uint32_t WIFI_SCAN_TIMEOUT_MS = 31000;
301
310static constexpr uint32_t WIFI_CONNECT_TIMEOUT_MS = 46000;
311
312static constexpr uint8_t get_max_retries_for_phase(WiFiRetryPhase phase) {
313 switch (phase) {
315#ifdef USE_WIFI_FAST_CONNECT
317#endif
318 // INITIAL_CONNECT and FAST_CONNECT_CYCLING_APS both use 1 attempt per AP (fast_connect mode)
319 return WIFI_RETRY_COUNT_PER_AP;
321 // Explicitly hidden network: 1 attempt (user marked as hidden, try once then scan)
322 return WIFI_RETRY_COUNT_PER_SSID;
324 // Scan-based phase: 2 attempts per BSSID (handles transient auth failures after scan)
325 return WIFI_RETRY_COUNT_PER_BSSID;
327 // Hidden network mode: 1 attempt per SSID
328 return WIFI_RETRY_COUNT_PER_SSID;
329 default:
330 return WIFI_RETRY_COUNT_PER_BSSID;
331 }
332}
333
334static void apply_scan_result_to_params(WiFiAP &params, const WiFiScanResult &scan) {
335 params.set_hidden(false);
336 params.set_ssid(scan.get_ssid());
337 params.set_bssid(scan.get_bssid());
338 params.set_channel(scan.get_channel());
339}
340
342 // Only SCAN_CONNECTING phase needs scan results
344 return false;
345 }
346 // Need scan if we have no results or no matching networks
347 return this->scan_result_.empty() || !this->scan_result_[0].get_matches();
348}
349
350bool WiFiComponent::ssid_was_seen_in_scan_(const std::string &ssid) const {
351 // Check if this SSID is configured as hidden
352 // If explicitly marked hidden, we should always try hidden mode regardless of scan results
353 for (const auto &conf : this->sta_) {
354 if (conf.get_ssid() == ssid && conf.get_hidden()) {
355 return false; // Treat as not seen - force hidden mode attempt
356 }
357 }
358
359 // Otherwise, check if we saw it in scan results
360 for (const auto &scan : this->scan_result_) {
361 if (scan.get_ssid() == ssid) {
362 return true;
363 }
364 }
365 return false;
366}
367
368int8_t WiFiComponent::find_next_hidden_sta_(int8_t start_index) {
369 // Find next SSID to try in RETRY_HIDDEN phase.
370 //
371 // This function operates in two modes based on retry_hidden_mode_:
372 //
373 // 1. SCAN_BASED mode:
374 // After SCAN_CONNECTING phase, only returns networks that were NOT visible
375 // in the scan (truly hidden networks that need probe requests).
376 //
377 // 2. BLIND_RETRY mode:
378 // When captive portal/improv is active, scanning is skipped to avoid
379 // disrupting the AP. In this mode, ALL configured networks are returned
380 // as candidates, cycling through them sequentially. This allows the device
381 // to keep trying all networks while users configure WiFi via captive portal.
382 //
383 // Additionally, if EXPLICIT_HIDDEN phase was executed (first network marked hidden:true),
384 // those networks are skipped here since they were already tried.
385 //
386 bool include_explicit_hidden = !this->went_through_explicit_hidden_phase_();
387 // Start searching from start_index + 1
388 for (size_t i = start_index + 1; i < this->sta_.size(); i++) {
389 const auto &sta = this->sta_[i];
390
391 // Skip networks that were already tried in EXPLICIT_HIDDEN phase
392 // Those are: networks marked hidden:true that appear before the first non-hidden network
393 // If all networks are hidden (first_non_hidden_idx == -1), skip all of them
394 if (!include_explicit_hidden && sta.get_hidden()) {
395 int8_t first_non_hidden_idx = this->find_first_non_hidden_index_();
396 if (first_non_hidden_idx < 0 || static_cast<int8_t>(i) < first_non_hidden_idx) {
397 ESP_LOGD(TAG, "Skipping " LOG_SECRET("'%s'") " (explicit hidden, already tried)", sta.get_ssid().c_str());
398 continue;
399 }
400 }
401
402 // In BLIND_RETRY mode, treat all networks as candidates
403 // In SCAN_BASED mode, only retry networks that weren't seen in the scan
404 if (this->retry_hidden_mode_ == RetryHiddenMode::BLIND_RETRY || !this->ssid_was_seen_in_scan_(sta.get_ssid())) {
405 ESP_LOGD(TAG, "Hidden candidate " LOG_SECRET("'%s'") " at index %d", sta.get_ssid().c_str(), static_cast<int>(i));
406 return static_cast<int8_t>(i);
407 }
408 ESP_LOGD(TAG, "Skipping hidden retry for visible network " LOG_SECRET("'%s'"), sta.get_ssid().c_str());
409 }
410 // No hidden SSIDs found
411 return -1;
412}
413
415 // If first network (highest priority) is explicitly marked hidden, try it first before scanning
416 // This respects user's priority order when they explicitly configure hidden networks
417 if (!this->sta_.empty() && this->sta_[0].get_hidden()) {
418 ESP_LOGI(TAG, "Starting with explicit hidden network (highest priority)");
419 this->selected_sta_index_ = 0;
422 this->start_connecting(params);
423 } else {
424 this->start_scanning();
425 }
426}
427
428#if defined(USE_ESP32) && defined(USE_WIFI_WPA2_EAP) && ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
429static const char *eap_phase2_to_str(esp_eap_ttls_phase2_types type) {
430 switch (type) {
431 case ESP_EAP_TTLS_PHASE2_PAP:
432 return "pap";
433 case ESP_EAP_TTLS_PHASE2_CHAP:
434 return "chap";
435 case ESP_EAP_TTLS_PHASE2_MSCHAP:
436 return "mschap";
437 case ESP_EAP_TTLS_PHASE2_MSCHAPV2:
438 return "mschapv2";
439 case ESP_EAP_TTLS_PHASE2_EAP:
440 return "eap";
441 default:
442 return "unknown";
443 }
444}
445#endif
446
448
450 this->wifi_pre_setup_();
451
452#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
453 // Create semaphore for high-performance mode requests
454 // Start at 0, increment on request, decrement on release
455 this->high_performance_semaphore_ = xSemaphoreCreateCounting(UINT32_MAX, 0);
456 if (this->high_performance_semaphore_ == nullptr) {
457 ESP_LOGE(TAG, "Failed semaphore");
458 }
459
460 // Store the configured power save mode as baseline
462#endif
463
464 if (this->enable_on_boot_) {
465 this->start();
466 } else {
467#ifdef USE_ESP32
468 esp_netif_init();
469#endif
471 }
472}
473
475 ESP_LOGCONFIG(TAG, "Starting");
476 this->last_connected_ = millis();
477
478 uint32_t hash = this->has_sta() ? App.get_config_version_hash() : 88491487UL;
479
481#ifdef USE_WIFI_FAST_CONNECT
483#endif
484
485 SavedWifiSettings save{};
486 if (this->pref_.load(&save)) {
487 ESP_LOGD(TAG, "Loaded settings: %s", save.ssid);
488
489 WiFiAP sta{};
490 sta.set_ssid(save.ssid);
491 sta.set_password(save.password);
492 this->set_sta(sta);
493 }
494
495 if (this->has_sta()) {
496 this->wifi_sta_pre_setup_();
497 if (!std::isnan(this->output_power_) && !this->wifi_apply_output_power_(this->output_power_)) {
498 ESP_LOGV(TAG, "Setting Output Power Option failed");
499 }
500
501#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
502 // Synchronize power_save_ with semaphore state before applying
503 if (this->high_performance_semaphore_ != nullptr) {
504 UBaseType_t semaphore_count = uxSemaphoreGetCount(this->high_performance_semaphore_);
505 if (semaphore_count > 0) {
507 this->is_high_performance_mode_ = true;
508 } else {
510 this->is_high_performance_mode_ = false;
511 }
512 }
513#endif
514 if (!this->wifi_apply_power_save_()) {
515 ESP_LOGV(TAG, "Setting Power Save Option failed");
516 }
517
519#ifdef USE_WIFI_FAST_CONNECT
520 WiFiAP params;
521 bool loaded_fast_connect = this->load_fast_connect_settings_(params);
522 // Fast connect optimization: only use when we have saved BSSID+channel data
523 // Without saved data, try first configured network or use normal flow
524 if (loaded_fast_connect) {
525 ESP_LOGI(TAG, "Starting fast_connect (saved) " LOG_SECRET("'%s'"), params.get_ssid().c_str());
526 this->start_connecting(params);
527 } else if (!this->sta_.empty() && !this->sta_[0].get_hidden()) {
528 // No saved data, but have configured networks - try first non-hidden network
529 ESP_LOGI(TAG, "Starting fast_connect (config) " LOG_SECRET("'%s'"), this->sta_[0].get_ssid().c_str());
530 this->selected_sta_index_ = 0;
531 params = this->build_params_for_current_phase_();
532 this->start_connecting(params);
533 } else {
534 // No saved data and (no networks OR first is hidden) - use normal flow
536 }
537#else
538 // Without fast_connect: go straight to scanning (or hidden mode if all networks are hidden)
540#endif
541#ifdef USE_WIFI_AP
542 } else if (this->has_ap()) {
543 this->setup_ap_config_();
544 if (!std::isnan(this->output_power_) && !this->wifi_apply_output_power_(this->output_power_)) {
545 ESP_LOGV(TAG, "Setting Output Power Option failed");
546 }
547#ifdef USE_CAPTIVE_PORTAL
549 this->wifi_sta_pre_setup_();
550 this->start_scanning();
552 }
553#endif
554#endif // USE_WIFI_AP
555 }
556#ifdef USE_IMPROV
557 if (!this->has_sta() && esp32_improv::global_improv_component != nullptr) {
558 if (this->wifi_mode_(true, {}))
560 }
561#endif
562 this->wifi_apply_hostname_();
563}
564
566 ESP_LOGW(TAG, "Restarting adapter");
567 this->wifi_mode_(false, {});
568 // Clear error flag here because restart_adapter() enters COOLDOWN state,
569 // and check_connecting_finished() is called after cooldown without going
570 // through start_connecting() first. Without this clear, stale errors would
571 // trigger spurious "failed (callback)" logs. The canonical clear location
572 // is in start_connecting(); this is the only exception to that pattern.
573 this->error_from_callback_ = false;
574}
575
577 this->wifi_loop_();
578 const uint32_t now = App.get_loop_component_start_time();
579
580 if (this->has_sta()) {
581 if (this->is_connected() != this->handled_connected_state_) {
582 if (this->handled_connected_state_) {
584 } else {
585 this->connect_trigger_->trigger();
586 }
588 }
589
590 switch (this->state_) {
592 this->status_set_warning(LOG_STR("waiting to reconnect"));
593 // Skip cooldown if new credentials were provided while connecting
594 if (this->skip_cooldown_next_cycle_) {
595 this->skip_cooldown_next_cycle_ = false;
596 this->check_connecting_finished(now);
597 break;
598 }
599 // Use longer cooldown when captive portal/improv is active to avoid disrupting user config
600 bool portal_active = this->is_captive_portal_active_() || this->is_esp32_improv_active_();
601 uint32_t cooldown_duration = portal_active ? WIFI_COOLDOWN_WITH_AP_ACTIVE_MS : WIFI_COOLDOWN_DURATION_MS;
602 if (now - this->action_started_ > cooldown_duration) {
603 // After cooldown we either restarted the adapter because of
604 // a failure, or something tried to connect over and over
605 // so we entered cooldown. In both cases we call
606 // check_connecting_finished to continue the state machine.
607 this->check_connecting_finished(now);
608 }
609 break;
610 }
612 this->status_set_warning(LOG_STR("scanning for networks"));
614 break;
615 }
617 this->status_set_warning(LOG_STR("associating to network"));
618 this->check_connecting_finished(now);
619 break;
620 }
621
623 if (!this->is_connected()) {
624 ESP_LOGW(TAG, "Connection lost; reconnecting");
626 this->retry_connect();
627 } else {
628 this->status_clear_warning();
629 this->last_connected_ = now;
630
631 // Post-connect roaming: check for better AP
632 if (this->post_connect_roaming_) {
634 if (this->scan_done_) {
635 this->process_roaming_scan_();
636 }
637 // else: scan in progress, wait
640 this->check_roaming_(now);
641 }
642 }
643 }
644 break;
645 }
648 break;
650 return;
651 }
652
653#ifdef USE_WIFI_AP
654 if (this->has_ap() && !this->ap_setup_) {
655 if (this->ap_timeout_ != 0 && (now - this->last_connected_ > this->ap_timeout_)) {
656 ESP_LOGI(TAG, "Starting fallback AP");
657 this->setup_ap_config_();
658#ifdef USE_CAPTIVE_PORTAL
661#endif
662 }
663 }
664#endif // USE_WIFI_AP
665
666#ifdef USE_IMPROV
668 !esp32_improv::global_improv_component->should_start()) {
669 if (now - this->last_connected_ > esp32_improv::global_improv_component->get_wifi_timeout()) {
670 if (this->wifi_mode_(true, {}))
672 }
673 }
674
675#endif
676
677 if (!this->has_ap() && this->reboot_timeout_ != 0) {
678 if (now - this->last_connected_ > this->reboot_timeout_) {
679 ESP_LOGE(TAG, "Can't connect; rebooting");
680 App.reboot();
681 }
682 }
683 }
684
685#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
686 // Check if power save mode needs to be updated based on high-performance requests
687 if (this->high_performance_semaphore_ != nullptr) {
688 // Semaphore count directly represents active requests (starts at 0, increments on request)
689 UBaseType_t semaphore_count = uxSemaphoreGetCount(this->high_performance_semaphore_);
690
691 if (semaphore_count > 0 && !this->is_high_performance_mode_) {
692 // Transition to high-performance mode (no power save)
693 ESP_LOGV(TAG, "Switching to high-performance mode (%" PRIu32 " active %s)", (uint32_t) semaphore_count,
694 semaphore_count == 1 ? "request" : "requests");
696 if (this->wifi_apply_power_save_()) {
697 this->is_high_performance_mode_ = true;
698 }
699 } else if (semaphore_count == 0 && this->is_high_performance_mode_) {
700 // Restore to configured power save mode
701 ESP_LOGV(TAG, "Restoring power save mode to configured setting");
703 if (this->wifi_apply_power_save_()) {
704 this->is_high_performance_mode_ = false;
705 }
706 }
707 }
708#endif
709}
710
712
713bool WiFiComponent::has_ap() const { return this->has_ap_; }
714bool WiFiComponent::is_ap_active() const { return this->ap_started_; }
715bool WiFiComponent::has_sta() const { return !this->sta_.empty(); }
716#ifdef USE_WIFI_11KV_SUPPORT
717void WiFiComponent::set_btm(bool btm) { this->btm_ = btm; }
718void WiFiComponent::set_rrm(bool rrm) { this->rrm_ = rrm; }
719#endif
721 if (this->has_sta())
722 return this->wifi_sta_ip_addresses();
723
724#ifdef USE_WIFI_AP
725 if (this->has_ap())
726 return {this->wifi_soft_ap_ip()};
727#endif // USE_WIFI_AP
728
729 return {};
730}
732 if (this->has_sta())
733 return this->wifi_dns_ip_(num);
734 return {};
735}
736// set_use_address() is guaranteed to be called during component setup by Python code generation,
737// so use_address_ will always be valid when get_use_address() is called - no fallback needed.
738const char *WiFiComponent::get_use_address() const { return this->use_address_; }
739void WiFiComponent::set_use_address(const char *use_address) { this->use_address_ = use_address; }
740
741#ifdef USE_WIFI_AP
743 this->wifi_mode_({}, true);
744
745 if (this->ap_setup_)
746 return;
747
748 if (this->ap_.get_ssid().empty()) {
749 std::string name = App.get_name();
750 if (name.length() > 32) {
752 // Keep first 25 chars and last 7 chars (MAC suffix), remove middle
753 name.erase(25, name.length() - 32);
754 } else {
755 name.resize(32);
756 }
757 }
758 this->ap_.set_ssid(name);
759 }
760 this->ap_setup_ = this->wifi_start_ap_(this->ap_);
761
762 char ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
763 ESP_LOGCONFIG(TAG,
764 "Setting up AP:\n"
765 " AP SSID: '%s'\n"
766 " AP Password: '%s'\n"
767 " IP Address: %s",
768 this->ap_.get_ssid().c_str(), this->ap_.get_password().c_str(), this->wifi_soft_ap_ip().str_to(ip_buf));
769
770#ifdef USE_WIFI_MANUAL_IP
771 auto manual_ip = this->ap_.get_manual_ip();
772 if (manual_ip.has_value()) {
773 char static_ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
774 char gateway_buf[network::IP_ADDRESS_BUFFER_SIZE];
775 char subnet_buf[network::IP_ADDRESS_BUFFER_SIZE];
776 ESP_LOGCONFIG(TAG,
777 " AP Static IP: '%s'\n"
778 " AP Gateway: '%s'\n"
779 " AP Subnet: '%s'",
780 manual_ip->static_ip.str_to(static_ip_buf), manual_ip->gateway.str_to(gateway_buf),
781 manual_ip->subnet.str_to(subnet_buf));
782 }
783#endif
784
785 if (!this->has_sta()) {
787 }
788}
789
791 this->ap_ = ap;
792 this->has_ap_ = true;
793}
794#endif // USE_WIFI_AP
795
797 return 10.0f; // before other loop components
798}
799
800void WiFiComponent::init_sta(size_t count) { this->sta_.init(count); }
801void WiFiComponent::add_sta(const WiFiAP &ap) { this->sta_.push_back(ap); }
803 // Clear roaming state - no more configured networks
804 this->clear_roaming_state_();
805 this->sta_.clear();
806 this->selected_sta_index_ = -1;
807}
809 this->clear_sta(); // Also clears roaming state
810 this->init_sta(1);
811 this->add_sta(ap);
812 this->selected_sta_index_ = 0;
813 // When new credentials are set (e.g., from improv), skip cooldown to retry immediately
814 this->skip_cooldown_next_cycle_ = true;
815}
816
818 const WiFiAP *config = this->get_selected_sta_();
819 if (config == nullptr) {
820 ESP_LOGE(TAG, "No valid network config (selected_sta_index_=%d, sta_.size()=%zu)",
821 static_cast<int>(this->selected_sta_index_), this->sta_.size());
822 // Return empty params - caller should handle this gracefully
823 return WiFiAP();
824 }
825
826 WiFiAP params = *config;
827
828 switch (this->retry_phase_) {
830#ifdef USE_WIFI_FAST_CONNECT
832#endif
833 // Fast connect phases: use config-only (no scan results)
834 // BSSID/channel from config if user specified them, otherwise empty
835 break;
836
839 // Hidden network mode: clear BSSID/channel to trigger probe request
840 // (both explicit hidden and retry hidden use same behavior)
841 params.clear_bssid();
842 params.clear_channel();
843 break;
844
846 // Scan-based phase: always use best scan result (index 0 - highest priority after sorting)
847 if (!this->scan_result_.empty()) {
848 apply_scan_result_to_params(params, this->scan_result_[0]);
849 }
850 break;
851
853 // Should not be building params during restart
854 break;
855 }
856
857 return params;
858}
859
861 const WiFiAP *config = this->get_selected_sta_();
862 return config ? *config : WiFiAP{};
863}
864void WiFiComponent::save_wifi_sta(const std::string &ssid, const std::string &password) {
865 SavedWifiSettings save{}; // zero-initialized - all bytes set to \0, guaranteeing null termination
866 strncpy(save.ssid, ssid.c_str(), sizeof(save.ssid) - 1); // max 32 chars, byte 32 remains \0
867 strncpy(save.password, password.c_str(), sizeof(save.password) - 1); // max 64 chars, byte 64 remains \0
868 this->pref_.save(&save);
869 // ensure it's written immediately
871
872 WiFiAP sta{};
873 sta.set_ssid(ssid);
874 sta.set_password(password);
875 this->set_sta(sta);
876
877 // Trigger connection attempt (exits cooldown if needed, no-op if already connecting/connected)
878 this->connect_soon_();
879}
880
882 // Only trigger retry if we're in cooldown - if already connecting/connected, do nothing
884 ESP_LOGD(TAG, "Exiting cooldown early due to new WiFi credentials");
885 this->retry_connect();
886 }
887}
888
890 // Log connection attempt at INFO level with priority
891 char bssid_s[18];
892 int8_t priority = 0;
893
894 if (ap.has_bssid()) {
895 format_mac_addr_upper(ap.get_bssid().data(), bssid_s);
896 priority = this->get_sta_priority(ap.get_bssid());
897 }
898
899 ESP_LOGI(TAG,
900 "Connecting to " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") " (priority %d, attempt %u/%u in phase %s)...",
901 ap.get_ssid().c_str(), ap.has_bssid() ? bssid_s : LOG_STR_LITERAL("any"), priority, this->num_retried_ + 1,
902 get_max_retries_for_phase(this->retry_phase_), LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_)));
903
904#ifdef ESPHOME_LOG_HAS_VERBOSE
905 ESP_LOGV(TAG,
906 "Connection Params:\n"
907 " SSID: '%s'",
908 ap.get_ssid().c_str());
909 if (ap.has_bssid()) {
910 ESP_LOGV(TAG, " BSSID: %s", bssid_s);
911 } else {
912 ESP_LOGV(TAG, " BSSID: Not Set");
913 }
914
915#ifdef USE_WIFI_WPA2_EAP
916 if (ap.get_eap().has_value()) {
917 EAPAuth eap_config = ap.get_eap().value();
918 // clang-format off
919 ESP_LOGV(
920 TAG,
921 " WPA2 Enterprise authentication configured:\n"
922 " Identity: " LOG_SECRET("'%s'") "\n"
923 " Username: " LOG_SECRET("'%s'") "\n"
924 " Password: " LOG_SECRET("'%s'"),
925 eap_config.identity.c_str(), eap_config.username.c_str(), eap_config.password.c_str());
926 // clang-format on
927#if defined(USE_ESP32) && defined(USE_WIFI_WPA2_EAP) && ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
928 ESP_LOGV(TAG, " TTLS Phase 2: " LOG_SECRET("'%s'"), eap_phase2_to_str(eap_config.ttls_phase_2));
929#endif
930 bool ca_cert_present = eap_config.ca_cert != nullptr && strlen(eap_config.ca_cert);
931 bool client_cert_present = eap_config.client_cert != nullptr && strlen(eap_config.client_cert);
932 bool client_key_present = eap_config.client_key != nullptr && strlen(eap_config.client_key);
933 ESP_LOGV(TAG,
934 " CA Cert: %s\n"
935 " Client Cert: %s\n"
936 " Client Key: %s",
937 ca_cert_present ? "present" : "not present", client_cert_present ? "present" : "not present",
938 client_key_present ? "present" : "not present");
939 } else {
940#endif
941 ESP_LOGV(TAG, " Password: " LOG_SECRET("'%s'"), ap.get_password().c_str());
942#ifdef USE_WIFI_WPA2_EAP
943 }
944#endif
945 if (ap.has_channel()) {
946 ESP_LOGV(TAG, " Channel: %u", ap.get_channel());
947 } else {
948 ESP_LOGV(TAG, " Channel not set");
949 }
950#ifdef USE_WIFI_MANUAL_IP
951 if (ap.get_manual_ip().has_value()) {
952 ManualIP m = *ap.get_manual_ip();
953 char static_ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
954 char gateway_buf[network::IP_ADDRESS_BUFFER_SIZE];
955 char subnet_buf[network::IP_ADDRESS_BUFFER_SIZE];
956 char dns1_buf[network::IP_ADDRESS_BUFFER_SIZE];
957 char dns2_buf[network::IP_ADDRESS_BUFFER_SIZE];
958 ESP_LOGV(TAG, " Manual IP: Static IP=%s Gateway=%s Subnet=%s DNS1=%s DNS2=%s", m.static_ip.str_to(static_ip_buf),
959 m.gateway.str_to(gateway_buf), m.subnet.str_to(subnet_buf), m.dns1.str_to(dns1_buf),
960 m.dns2.str_to(dns2_buf));
961 } else
962#endif
963 {
964 ESP_LOGV(TAG, " Using DHCP IP");
965 }
966 ESP_LOGV(TAG, " Hidden: %s", YESNO(ap.get_hidden()));
967#endif
968
969 // Clear any stale error from previous connection attempt.
970 // This is the canonical location for clearing the flag since all connection
971 // attempts go through start_connecting(). The only other clear is in
972 // restart_adapter() which enters COOLDOWN without calling start_connecting().
973 this->error_from_callback_ = false;
974
975 if (!this->wifi_sta_connect_(ap)) {
976 ESP_LOGE(TAG, "wifi_sta_connect_ failed");
977 // Enter cooldown to allow WiFi hardware to stabilize
978 // (immediate failure suggests hardware not ready, different from connection timeout)
980 } else {
982 }
983 this->action_started_ = millis();
984}
985
986const LogString *get_signal_bars(int8_t rssi) {
987 // LOWER ONE QUARTER BLOCK
988 // Unicode: U+2582, UTF-8: E2 96 82
989 // LOWER HALF BLOCK
990 // Unicode: U+2584, UTF-8: E2 96 84
991 // LOWER THREE QUARTERS BLOCK
992 // Unicode: U+2586, UTF-8: E2 96 86
993 // FULL BLOCK
994 // Unicode: U+2588, UTF-8: E2 96 88
995 if (rssi >= -50) {
996 return LOG_STR("\033[0;32m" // green
997 "\xe2\x96\x82"
998 "\xe2\x96\x84"
999 "\xe2\x96\x86"
1000 "\xe2\x96\x88"
1001 "\033[0m");
1002 } else if (rssi >= -65) {
1003 return LOG_STR("\033[0;33m" // yellow
1004 "\xe2\x96\x82"
1005 "\xe2\x96\x84"
1006 "\xe2\x96\x86"
1007 "\033[0;37m"
1008 "\xe2\x96\x88"
1009 "\033[0m");
1010 } else if (rssi >= -85) {
1011 return LOG_STR("\033[0;33m" // yellow
1012 "\xe2\x96\x82"
1013 "\xe2\x96\x84"
1014 "\033[0;37m"
1015 "\xe2\x96\x86"
1016 "\xe2\x96\x88"
1017 "\033[0m");
1018 } else {
1019 return LOG_STR("\033[0;31m" // red
1020 "\xe2\x96\x82"
1021 "\033[0;37m"
1022 "\xe2\x96\x84"
1023 "\xe2\x96\x86"
1024 "\xe2\x96\x88"
1025 "\033[0m");
1026 }
1027}
1028
1030 bssid_t bssid = wifi_bssid();
1031 char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
1032 format_mac_addr_upper(bssid.data(), bssid_s);
1033 // Use stack buffers for IP address formatting to avoid heap allocations
1034 char ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
1035 for (auto &ip : wifi_sta_ip_addresses()) {
1036 if (ip.is_set()) {
1037 ESP_LOGCONFIG(TAG, " IP Address: %s", ip.str_to(ip_buf));
1038 }
1039 }
1040 int8_t rssi = wifi_rssi();
1041 // Use stack buffers for SSID and all IP addresses to avoid heap allocations
1042 char ssid_buf[SSID_BUFFER_SIZE];
1043 char subnet_buf[network::IP_ADDRESS_BUFFER_SIZE];
1044 char gateway_buf[network::IP_ADDRESS_BUFFER_SIZE];
1045 char dns1_buf[network::IP_ADDRESS_BUFFER_SIZE];
1046 char dns2_buf[network::IP_ADDRESS_BUFFER_SIZE];
1047 // clang-format off
1048 ESP_LOGCONFIG(TAG,
1049 " SSID: " LOG_SECRET("'%s'") "\n"
1050 " BSSID: " LOG_SECRET("%s") "\n"
1051 " Hostname: '%s'\n"
1052 " Signal strength: %d dB %s\n"
1053 " Channel: %" PRId32 "\n"
1054 " Subnet: %s\n"
1055 " Gateway: %s\n"
1056 " DNS1: %s\n"
1057 " DNS2: %s",
1058 wifi_ssid_to(ssid_buf), bssid_s, App.get_name().c_str(), rssi, LOG_STR_ARG(get_signal_bars(rssi)),
1059 get_wifi_channel(), wifi_subnet_mask_().str_to(subnet_buf), wifi_gateway_ip_().str_to(gateway_buf),
1060 wifi_dns_ip_(0).str_to(dns1_buf), wifi_dns_ip_(1).str_to(dns2_buf));
1061 // clang-format on
1062#ifdef ESPHOME_LOG_HAS_VERBOSE
1063 if (const WiFiAP *config = this->get_selected_sta_(); config && config->has_bssid()) {
1064 ESP_LOGV(TAG, " Priority: %d", this->get_sta_priority(config->get_bssid()));
1065 }
1066#endif
1067#ifdef USE_WIFI_11KV_SUPPORT
1068 ESP_LOGCONFIG(TAG,
1069 " BTM: %s\n"
1070 " RRM: %s",
1071 this->btm_ ? "enabled" : "disabled", this->rrm_ ? "enabled" : "disabled");
1072#endif
1073}
1074
1077 return;
1078
1079 ESP_LOGD(TAG, "Enabling");
1081 this->start();
1082}
1083
1086 return;
1087
1088 ESP_LOGD(TAG, "Disabling");
1090 this->wifi_disconnect_();
1091 this->wifi_mode_(false, false);
1092}
1093
1095
1097 this->action_started_ = millis();
1098 ESP_LOGD(TAG, "Starting scan");
1099 this->wifi_scan_start_(this->passive_scan_);
1101}
1102
1136[[nodiscard]] inline static bool wifi_scan_result_is_better(const WiFiScanResult &a, const WiFiScanResult &b) {
1137 // Matching networks always come before non-matching
1138 if (a.get_matches() && !b.get_matches())
1139 return true;
1140 if (!a.get_matches() && b.get_matches())
1141 return false;
1142
1143 // Both matching: check priority first (tracks connection failures via priority degradation)
1144 // Priority is decreased when a BSSID fails to connect, so lower priority = previously failed
1145 if (a.get_matches() && b.get_matches() && a.get_priority() != b.get_priority()) {
1146 return a.get_priority() > b.get_priority();
1147 }
1148
1149 // Use RSSI as tiebreaker (for equal-priority matching networks or all non-matching networks)
1150 return a.get_rssi() > b.get_rssi();
1151}
1152
1153// Helper function for insertion sort of WiFi scan results
1154// Using insertion sort instead of std::stable_sort saves flash memory
1155// by avoiding template instantiations (std::rotate, std::stable_sort, lambdas)
1156// IMPORTANT: This sort is stable (preserves relative order of equal elements)
1157template<typename VectorType> static void insertion_sort_scan_results(VectorType &results) {
1158 const size_t size = results.size();
1159 for (size_t i = 1; i < size; i++) {
1160 // Make a copy to avoid issues with move semantics during comparison
1161 WiFiScanResult key = results[i];
1162 int32_t j = i - 1;
1163
1164 // Move elements that are worse than key to the right
1165 // For stability, we only move if key is strictly better than results[j]
1166 while (j >= 0 && wifi_scan_result_is_better(key, results[j])) {
1167 results[j + 1] = results[j];
1168 j--;
1169 }
1170 results[j + 1] = key;
1171 }
1172}
1173
1174// Helper function to log matching scan results - marked noinline to prevent re-inlining into loop
1175//
1176// IMPORTANT: This function deliberately uses a SINGLE log call to minimize blocking.
1177// In environments with many matching networks (e.g., 18+ mesh APs), multiple log calls
1178// per network would block the main loop for an unacceptable duration. Each log call
1179// has overhead from UART transmission, so combining INFO+DEBUG into one line halves
1180// the blocking time. Do NOT split this into separate ESP_LOGI/ESP_LOGD calls.
1181__attribute__((noinline)) static void log_scan_result(const WiFiScanResult &res) {
1182 char bssid_s[18];
1183 auto bssid = res.get_bssid();
1184 format_mac_addr_upper(bssid.data(), bssid_s);
1185
1186#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG
1187 // Single combined log line with all details when DEBUG enabled
1188 ESP_LOGI(TAG, "- '%s' %s" LOG_SECRET("(%s) ") "%s Ch:%2u %3ddB P:%d", res.get_ssid().c_str(),
1189 res.get_is_hidden() ? LOG_STR_LITERAL("(HIDDEN) ") : LOG_STR_LITERAL(""), bssid_s,
1190 LOG_STR_ARG(get_signal_bars(res.get_rssi())), res.get_channel(), res.get_rssi(), res.get_priority());
1191#else
1192 ESP_LOGI(TAG, "- '%s' %s" LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(),
1193 res.get_is_hidden() ? LOG_STR_LITERAL("(HIDDEN) ") : LOG_STR_LITERAL(""), bssid_s,
1194 LOG_STR_ARG(get_signal_bars(res.get_rssi())));
1195#endif
1196}
1197
1198#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
1199// Helper function to log non-matching scan results at verbose level
1200__attribute__((noinline)) static void log_scan_result_non_matching(const WiFiScanResult &res) {
1201 char bssid_s[18];
1202 auto bssid = res.get_bssid();
1203 format_mac_addr_upper(bssid.data(), bssid_s);
1204
1205 ESP_LOGV(TAG, "- " LOG_SECRET("'%s'") " " LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(), bssid_s,
1206 LOG_STR_ARG(get_signal_bars(res.get_rssi())));
1207}
1208#endif
1209
1211 if (!this->scan_done_) {
1212 if (millis() - this->action_started_ > WIFI_SCAN_TIMEOUT_MS) {
1213 ESP_LOGE(TAG, "Scan timeout");
1214 this->retry_connect();
1215 }
1216 return;
1217 }
1218 this->scan_done_ = false;
1220
1221 if (this->scan_result_.empty()) {
1222 ESP_LOGW(TAG, "No networks found");
1223 this->retry_connect();
1224 return;
1225 }
1226
1227 ESP_LOGD(TAG, "Found networks:");
1228 for (auto &res : this->scan_result_) {
1229 for (auto &ap : this->sta_) {
1230 if (res.matches(ap)) {
1231 res.set_matches(true);
1232 // Cache priority lookup - do single search instead of 2 separate searches
1233 const bssid_t &bssid = res.get_bssid();
1234 if (!this->has_sta_priority(bssid)) {
1235 this->set_sta_priority(bssid, ap.get_priority());
1236 }
1237 res.set_priority(this->get_sta_priority(bssid));
1238 break;
1239 }
1240 }
1241 }
1242
1243 // Sort scan results using insertion sort for better memory efficiency
1244 insertion_sort_scan_results(this->scan_result_);
1245
1246 size_t non_matching_count = 0;
1247 for (auto &res : this->scan_result_) {
1248 if (res.get_matches()) {
1249 log_scan_result(res);
1250 } else {
1251#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
1252 log_scan_result_non_matching(res);
1253#else
1254 non_matching_count++;
1255#endif
1256 }
1257 }
1258 if (non_matching_count > 0) {
1259 ESP_LOGD(TAG, "- %zu non-matching (VERBOSE to show)", non_matching_count);
1260 }
1261
1262 // SYNCHRONIZATION POINT: Establish link between scan_result_[0] and selected_sta_index_
1263 // After sorting, scan_result_[0] contains the best network. Now find which sta_[i] config
1264 // matches that network and record it in selected_sta_index_. This keeps the two indices
1265 // synchronized so build_params_for_current_phase_() can safely use both to build connection parameters.
1266 const WiFiScanResult &scan_res = this->scan_result_[0];
1267 bool found_match = false;
1268 if (scan_res.get_matches()) {
1269 for (size_t i = 0; i < this->sta_.size(); i++) {
1270 if (scan_res.matches(this->sta_[i])) {
1271 // Safe cast: sta_.size() limited to MAX_WIFI_NETWORKS (127) in __init__.py validation
1272 // No overflow check needed - YAML validation prevents >127 networks
1273 this->selected_sta_index_ = static_cast<int8_t>(i); // Links scan_result_[0] with sta_[i]
1274 found_match = true;
1275 break;
1276 }
1277 }
1278 }
1279
1280 if (!found_match) {
1281 ESP_LOGW(TAG, "No matching network found");
1282 // No scan results matched our configured networks - transition directly to hidden mode
1283 // Don't call retry_connect() since we never attempted a connection (no BSSID to penalize)
1285 // If no hidden networks to try, skip connection attempt (will be handled on next loop)
1286 if (this->selected_sta_index_ == -1) {
1287 return;
1288 }
1289 // Now start connection attempt in hidden mode
1291 return; // scan started, wait for next loop iteration
1292 }
1293
1294 yield();
1295
1296 WiFiAP params = this->build_params_for_current_phase_();
1297 // Ensure we're in SCAN_CONNECTING phase when connecting with scan results
1298 // (needed when scan was started directly without transition_to_phase_, e.g., initial scan)
1299 this->start_connecting(params);
1300}
1301
1303 char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
1304 ESP_LOGCONFIG(TAG,
1305 "WiFi:\n"
1306 " Local MAC: %s\n"
1307 " Connected: %s",
1308 get_mac_address_pretty_into_buffer(mac_s), YESNO(this->is_connected()));
1309 if (this->is_disabled()) {
1310 ESP_LOGCONFIG(TAG, " Disabled");
1311 return;
1312 }
1313 if (this->is_connected()) {
1314 this->print_connect_params_();
1315 }
1316}
1317
1319 auto status = this->wifi_sta_connect_status_();
1320
1322 char ssid_buf[SSID_BUFFER_SIZE];
1323 if (wifi_ssid_to(ssid_buf)[0] == '\0') {
1324 ESP_LOGW(TAG, "Connection incomplete");
1325 this->retry_connect();
1326 return;
1327 }
1328
1329 ESP_LOGI(TAG, "Connected");
1330 // Warn if we had to retry with hidden network mode for a network that's not marked hidden
1331 // Only warn if we actually connected without scan data (SSID only), not if scan succeeded on retry
1332 if (const WiFiAP *config = this->get_selected_sta_(); this->retry_phase_ == WiFiRetryPhase::RETRY_HIDDEN &&
1333 config && !config->get_hidden() &&
1334 this->scan_result_.empty()) {
1335 ESP_LOGW(TAG, LOG_SECRET("'%s'") " should be marked hidden", config->get_ssid().c_str());
1336 }
1337 // Reset to initial phase on successful connection (don't log transition, just reset state)
1339 this->num_retried_ = 0;
1340 if (this->has_ap()) {
1341#ifdef USE_CAPTIVE_PORTAL
1342 if (this->is_captive_portal_active_()) {
1344 }
1345#endif
1346 ESP_LOGD(TAG, "Disabling AP");
1347 this->wifi_mode_({}, false);
1348 }
1349#ifdef USE_IMPROV
1350 if (this->is_esp32_improv_active_()) {
1352 }
1353#endif
1354
1356 this->num_retried_ = 0;
1357 this->print_connect_params_();
1358
1359 // Reset roaming state on successful connection
1360 this->roaming_last_check_ = now;
1361 // Only preserve attempts if reconnecting after a failed roam attempt
1362 // This prevents ping-pong between APs when a roam target is unreachable
1364 // Successful roam to better AP - reset attempts so we can roam again later
1365 ESP_LOGD(TAG, "Roam successful");
1366 this->roaming_attempts_ = 0;
1367 } else if (this->roaming_state_ == RoamingState::RECONNECTING) {
1368 // Failed roam, reconnected via normal recovery - keep attempts to prevent ping-pong
1369 ESP_LOGD(TAG, "Reconnected after failed roam (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
1370 } else {
1371 // Normal connection (boot, credentials changed, etc.)
1372 this->roaming_attempts_ = 0;
1373 }
1375
1376 // Clear all priority penalties - the next reconnect will happen when an AP disconnects,
1377 // which means the landscape has likely changed and previous tracked failures are stale
1379
1380#ifdef USE_WIFI_FAST_CONNECT
1382#endif
1383
1384 this->release_scan_results_();
1385
1386 return;
1387 }
1388
1389 if (now - this->action_started_ > WIFI_CONNECT_TIMEOUT_MS) {
1390 ESP_LOGW(TAG, "Connection timeout, aborting connection attempt");
1391 this->wifi_disconnect_();
1392 this->retry_connect();
1393 return;
1394 }
1395
1396 if (this->error_from_callback_) {
1397 ESP_LOGW(TAG, "Connecting to network failed (callback)");
1398 this->retry_connect();
1399 return;
1400 }
1401
1403 return;
1404 }
1405
1407 ESP_LOGW(TAG, "Network no longer found");
1408 this->retry_connect();
1409 return;
1410 }
1411
1413 ESP_LOGW(TAG, "Connecting to network failed");
1414 this->retry_connect();
1415 return;
1416 }
1417
1418 ESP_LOGW(TAG, "Unknown connection status %d", (int) status);
1419 this->retry_connect();
1420}
1421
1429 switch (this->retry_phase_) {
1431#ifdef USE_WIFI_FAST_CONNECT
1433 // INITIAL_CONNECT and FAST_CONNECT_CYCLING_APS: no retries, try next AP or fall back to scan
1434 if (this->selected_sta_index_ < static_cast<int8_t>(this->sta_.size()) - 1) {
1435 return WiFiRetryPhase::FAST_CONNECT_CYCLING_APS; // Move to next AP
1436 }
1437#endif
1438 // Check if we should try explicit hidden networks before scanning
1439 // This handles reconnection after connection loss where first network is hidden
1440 if (!this->sta_.empty() && this->sta_[0].get_hidden()) {
1442 }
1443 // No more APs to try, fall back to scan
1445
1447 // Try all explicitly hidden networks before scanning
1448 if (this->num_retried_ + 1 < WIFI_RETRY_COUNT_PER_SSID) {
1449 return WiFiRetryPhase::EXPLICIT_HIDDEN; // Keep retrying same SSID
1450 }
1451
1452 // Exhausted retries on current SSID - check for more explicitly hidden networks
1453 // Stop when we reach a visible network (proceed to scanning)
1454 size_t next_index = this->selected_sta_index_ + 1;
1455 if (next_index < this->sta_.size() && this->sta_[next_index].get_hidden()) {
1456 // Found another explicitly hidden network
1458 }
1459
1460 // No more consecutive explicitly hidden networks
1461 // If ALL networks are hidden, skip scanning and go directly to restart
1462 if (this->find_first_non_hidden_index_() < 0) {
1464 }
1465 // Otherwise proceed to scanning for non-hidden networks
1467 }
1468
1470 // If scan found no networks or no matching networks, skip to hidden network mode
1471 if (this->scan_result_.empty() || !this->scan_result_[0].get_matches()) {
1473 }
1474
1475 if (this->num_retried_ + 1 < WIFI_RETRY_COUNT_PER_BSSID) {
1476 return WiFiRetryPhase::SCAN_CONNECTING; // Keep retrying same BSSID
1477 }
1478
1479 // Exhausted retries on current BSSID (scan_result_[0])
1480 // Its priority has been decreased, so on next scan it will be sorted lower
1481 // and we'll try the next best BSSID.
1482 // Check if there are any potentially hidden networks to try
1483 if (this->find_next_hidden_sta_(-1) >= 0) {
1484 return WiFiRetryPhase::RETRY_HIDDEN; // Found hidden networks to try
1485 }
1486 // No hidden networks - always go through RESTARTING_ADAPTER phase
1487 // This ensures num_retried_ gets reset and a fresh scan is triggered
1488 // The actual adapter restart will be skipped if captive portal/improv is active
1490
1492 // If no hidden SSIDs to try (selected_sta_index_ == -1), skip directly to rescan
1493 if (this->selected_sta_index_ >= 0) {
1494 if (this->num_retried_ + 1 < WIFI_RETRY_COUNT_PER_SSID) {
1495 return WiFiRetryPhase::RETRY_HIDDEN; // Keep retrying same SSID
1496 }
1497
1498 // Exhausted retries on current SSID - check if there are more potentially hidden SSIDs to try
1499 if (this->selected_sta_index_ < static_cast<int8_t>(this->sta_.size()) - 1) {
1500 // Check if find_next_hidden_sta_() would actually find another hidden SSID
1501 // as it might have been seen in the scan results and we want to skip those
1502 // otherwise we will get stuck in RETRY_HIDDEN phase
1503 if (this->find_next_hidden_sta_(this->selected_sta_index_) != -1) {
1504 // More hidden SSIDs available - stay in RETRY_HIDDEN, advance will happen in retry_connect()
1506 }
1507 }
1508 }
1509 // Exhausted all potentially hidden SSIDs - always go through RESTARTING_ADAPTER
1510 // This ensures num_retried_ gets reset and a fresh scan is triggered
1511 // The actual adapter restart will be skipped if captive portal/improv is active
1513
1515 // After restart, go back to explicit hidden if we went through it initially
1518 }
1519 // Skip scanning when captive portal/improv is active to avoid disrupting AP.
1520 //
1521 // WHY SCANNING DISRUPTS AP MODE:
1522 // WiFi scanning requires the radio to leave the AP's channel and hop through
1523 // other channels to listen for beacons. During this time (even for passive scans),
1524 // the AP cannot service connected clients - they experience disconnections or
1525 // timeouts. On ESP32, even passive scans cause brief but noticeable disruptions
1526 // that break captive portal HTTP requests and DNS lookups.
1527 //
1528 // BLIND RETRY MODE:
1529 // When captive portal/improv is active, we use RETRY_HIDDEN as a "try all networks
1530 // blindly" mode. Since retry_hidden_mode_ is set to BLIND_RETRY (in RESTARTING_ADAPTER
1531 // transition), find_next_hidden_sta_() will treat ALL configured networks as
1532 // candidates, cycling through them without requiring scan results.
1533 //
1534 // This allows users to configure WiFi via captive portal while the device keeps
1535 // attempting to connect to all configured networks in sequence.
1536 if (this->is_captive_portal_active_() || this->is_esp32_improv_active_()) {
1538 }
1540 }
1541
1542 // Should never reach here
1544}
1545
1556 WiFiRetryPhase old_phase = this->retry_phase_;
1557
1558 // No-op if staying in same phase
1559 if (old_phase == new_phase) {
1560 return false;
1561 }
1562
1563 ESP_LOGD(TAG, "Retry phase: %s → %s", LOG_STR_ARG(retry_phase_to_log_string(old_phase)),
1564 LOG_STR_ARG(retry_phase_to_log_string(new_phase)));
1565
1566 this->retry_phase_ = new_phase;
1567 this->num_retried_ = 0; // Reset retry counter on phase change
1568
1569 // Phase-specific setup
1570 switch (new_phase) {
1571#ifdef USE_WIFI_FAST_CONNECT
1573 // Move to next configured AP - clear old scan data so new AP is tried with config only
1574 this->selected_sta_index_++;
1575 this->scan_result_.clear();
1576 break;
1577#endif
1578
1580 // Starting explicit hidden phase - reset to first network
1581 this->selected_sta_index_ = 0;
1582 break;
1583
1585 // Transitioning to scan-based connection
1586#ifdef USE_WIFI_FAST_CONNECT
1588 ESP_LOGI(TAG, "Fast connect exhausted, falling back to scan");
1589 }
1590#endif
1591 // Trigger scan if we don't have scan results OR if transitioning from phases that need fresh scan
1592 if (this->scan_result_.empty() || old_phase == WiFiRetryPhase::EXPLICIT_HIDDEN ||
1594 this->selected_sta_index_ = -1; // Will be set after scan completes
1595 this->start_scanning();
1596 return true; // Started scan, wait for completion
1597 }
1598 // Already have scan results - selected_sta_index_ should already be synchronized
1599 // (set in check_scanning_finished() when scan completed)
1600 // No need to reset it here
1601 break;
1602
1604 // Always reset to first candidate when entering this phase.
1605 // This phase can be entered from:
1606 // - SCAN_CONNECTING: normal flow, find_next_hidden_sta_() skips networks visible in scan
1607 // - RESTARTING_ADAPTER: captive portal active, find_next_hidden_sta_() tries ALL networks
1608 //
1609 // The retry_hidden_mode_ controls the behavior:
1610 // - SCAN_BASED: scan_result_ is checked, visible networks are skipped
1611 // - BLIND_RETRY: scan_result_ is ignored, all networks become candidates
1612 // We don't clear scan_result_ here - the mode controls whether it's consulted.
1614
1615 if (this->selected_sta_index_ == -1) {
1616 ESP_LOGD(TAG, "All SSIDs visible or already tried, skipping hidden mode");
1617 }
1618 break;
1619
1621 // Skip actual adapter restart if captive portal/improv is active
1622 // This allows state machine to reset num_retried_ and trigger fresh scan
1623 // without disrupting the captive portal/improv connection
1624 if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_()) {
1625 this->restart_adapter();
1626 } else {
1627 // Even when skipping full restart, disconnect to clear driver state
1628 // Without this, platforms like LibreTiny may think we're still connecting
1629 this->wifi_disconnect_();
1630 }
1631 // Clear scan flag - we're starting a new retry cycle
1632 // This is critical for captive portal/improv flow: when determine_next_phase_()
1633 // returns RETRY_HIDDEN (because scanning is skipped), find_next_hidden_sta_()
1634 // will see BLIND_RETRY mode and treat ALL networks as candidates,
1635 // effectively cycling through all configured networks without scan results.
1637 // Always enter cooldown after restart (or skip-restart) to allow stabilization
1638 // Use extended cooldown when AP is active to avoid constant scanning that blocks DNS
1640 this->action_started_ = millis();
1641 // Return true to indicate we should wait (go to COOLDOWN) instead of immediately connecting
1642 return true;
1643
1644 default:
1645 break;
1646 }
1647
1648 return false; // Did not start scan, can proceed with connection
1649}
1650
1652 if (!this->sta_priorities_.empty()) {
1653 decltype(this->sta_priorities_)().swap(this->sta_priorities_);
1654 }
1655}
1656
1661 if (this->sta_priorities_.empty()) {
1662 return;
1663 }
1664
1665 int8_t first_priority = this->sta_priorities_[0].priority;
1666
1667 // Only clear if all priorities have been decremented to the minimum value
1668 // At this point, all BSSIDs have been equally penalized and priority info is useless
1669 if (first_priority != std::numeric_limits<int8_t>::min()) {
1670 return;
1671 }
1672
1673 for (const auto &pri : this->sta_priorities_) {
1674 if (pri.priority != first_priority) {
1675 return; // Not all same, nothing to do
1676 }
1677 }
1678
1679 // All priorities are at minimum - clear the vector to save memory and reset
1680 ESP_LOGD(TAG, "Clearing BSSID priorities (all at minimum)");
1682}
1683
1703 // Determine which BSSID we tried to connect to
1704 optional<bssid_t> failed_bssid;
1705
1706 if (this->retry_phase_ == WiFiRetryPhase::SCAN_CONNECTING && !this->scan_result_.empty()) {
1707 // Scan-based phase: always use best result (index 0)
1708 failed_bssid = this->scan_result_[0].get_bssid();
1709 } else if (const WiFiAP *config = this->get_selected_sta_(); config && config->has_bssid()) {
1710 // Config has specific BSSID (fast_connect or user-specified)
1711 failed_bssid = config->get_bssid();
1712 }
1713
1714 if (!failed_bssid.has_value()) {
1715 return; // No BSSID to penalize
1716 }
1717
1718 // Get SSID for logging (use pointer to avoid copy)
1719 const std::string *ssid = nullptr;
1720 if (this->retry_phase_ == WiFiRetryPhase::SCAN_CONNECTING && !this->scan_result_.empty()) {
1721 ssid = &this->scan_result_[0].get_ssid();
1722 } else if (const WiFiAP *config = this->get_selected_sta_()) {
1723 ssid = &config->get_ssid();
1724 }
1725
1726 // Only decrease priority on the last attempt for this phase
1727 // This prevents false positives from transient WiFi stack issues
1728 uint8_t max_retries = get_max_retries_for_phase(this->retry_phase_);
1729 bool is_last_attempt = (this->num_retried_ + 1 >= max_retries);
1730
1731 // Decrease priority only on last attempt to avoid false positives from transient failures
1732 int8_t old_priority = this->get_sta_priority(failed_bssid.value());
1733 int8_t new_priority = old_priority;
1734
1735 if (is_last_attempt) {
1736 // Decrease priority, but clamp to int8_t::min to prevent overflow
1737 new_priority =
1738 (old_priority > std::numeric_limits<int8_t>::min()) ? (old_priority - 1) : std::numeric_limits<int8_t>::min();
1739 this->set_sta_priority(failed_bssid.value(), new_priority);
1740 }
1741 char bssid_s[18];
1742 format_mac_addr_upper(failed_bssid.value().data(), bssid_s);
1743 ESP_LOGD(TAG, "Failed " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") ", priority %d → %d",
1744 ssid != nullptr ? ssid->c_str() : "", bssid_s, old_priority, new_priority);
1745
1746 // After adjusting priority, check if all priorities are now at minimum
1747 // If so, clear the vector to save memory and reset for fresh start
1749}
1750
1762 WiFiRetryPhase current_phase = this->retry_phase_;
1763
1764 // Check if we need to advance to next AP/SSID within the same phase
1765#ifdef USE_WIFI_FAST_CONNECT
1766 if (current_phase == WiFiRetryPhase::FAST_CONNECT_CYCLING_APS) {
1767 // Fast connect: always advance to next AP (no retries per AP)
1768 this->selected_sta_index_++;
1769 this->num_retried_ = 0;
1770 ESP_LOGD(TAG, "Next AP in %s", LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_)));
1771 return;
1772 }
1773#endif
1774
1775 if (current_phase == WiFiRetryPhase::EXPLICIT_HIDDEN && this->num_retried_ + 1 >= WIFI_RETRY_COUNT_PER_SSID) {
1776 // Explicit hidden: exhausted retries on current SSID, find next explicitly hidden network
1777 // Stop when we reach a visible network (proceed to scanning)
1778 size_t next_index = this->selected_sta_index_ + 1;
1779 if (next_index < this->sta_.size() && this->sta_[next_index].get_hidden()) {
1780 this->selected_sta_index_ = static_cast<int8_t>(next_index);
1781 this->num_retried_ = 0;
1782 ESP_LOGD(TAG, "Next explicit hidden network at index %d", static_cast<int>(next_index));
1783 return;
1784 }
1785 // No more consecutive explicit hidden networks found - fall through to trigger phase change
1786 }
1787
1788 if (current_phase == WiFiRetryPhase::RETRY_HIDDEN && this->num_retried_ + 1 >= WIFI_RETRY_COUNT_PER_SSID) {
1789 // Hidden mode: exhausted retries on current SSID, find next potentially hidden SSID
1790 // If first network is marked hidden, we went through EXPLICIT_HIDDEN phase
1791 // In that case, skip networks marked hidden:true (already tried)
1792 // Otherwise, include them (they haven't been tried yet)
1793 int8_t next_index = this->find_next_hidden_sta_(this->selected_sta_index_);
1794 if (next_index != -1) {
1795 // Found another potentially hidden SSID
1796 this->selected_sta_index_ = next_index;
1797 this->num_retried_ = 0;
1798 return;
1799 }
1800 // No more potentially hidden SSIDs - set selected_sta_index_ to -1 to trigger phase change
1801 // This ensures determine_next_phase_() will skip the RETRY_HIDDEN logic and transition out
1802 this->selected_sta_index_ = -1;
1803 // Return early - phase change will happen on next wifi_loop() iteration
1804 return;
1805 }
1806
1807 // Don't increment retry counter if we're in a scan phase with no valid targets
1808 if (this->needs_scan_results_()) {
1809 return;
1810 }
1811
1812 // Increment retry counter to try the same target again
1813 this->num_retried_++;
1814 ESP_LOGD(TAG, "Retry attempt %u/%u in phase %s", this->num_retried_ + 1,
1815 get_max_retries_for_phase(this->retry_phase_), LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_)));
1816}
1817
1819 // Handle roaming state transitions - preserve attempts counter to prevent ping-pong
1820 // to unreachable APs after ROAMING_MAX_ATTEMPTS failures
1822 // Roam connection failed - transition to reconnecting
1823 ESP_LOGD(TAG, "Roam failed, reconnecting (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
1825 } else if (this->roaming_state_ == RoamingState::SCANNING) {
1826 // Roam scan failed (e.g., scan error on ESP8266) - go back to idle, keep counter
1827 ESP_LOGD(TAG, "Roam scan failed (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
1829 } else if (this->roaming_state_ == RoamingState::IDLE) {
1830 // Not a roaming-triggered reconnect, reset state
1831 this->clear_roaming_state_();
1832 }
1833 // RECONNECTING: keep state and counter, still trying to reconnect
1834
1836
1837 // Determine next retry phase based on current state
1838 WiFiRetryPhase current_phase = this->retry_phase_;
1839 WiFiRetryPhase next_phase = this->determine_next_phase_();
1840
1841 // Handle phase transitions (transition_to_phase_ handles same-phase no-op internally)
1842 if (this->transition_to_phase_(next_phase)) {
1843 return; // Scan started or adapter restarted (which sets its own state)
1844 }
1845
1846 if (next_phase == current_phase) {
1848 }
1849
1850 yield();
1851 // Check if we have a valid target before building params
1852 // After exhausting all networks in a phase, selected_sta_index_ may be -1
1853 // In that case, skip connection and let next wifi_loop() handle phase transition
1854 if (this->selected_sta_index_ >= 0) {
1855 WiFiAP params = this->build_params_for_current_phase_();
1856 this->start_connecting(params);
1857 }
1858}
1859
1860#ifdef USE_RP2040
1861// RP2040's mDNS library (LEAmDNS) relies on LwipIntf::stateUpCB() to restart
1862// mDNS when the network interface reconnects. However, this callback is disabled
1863// in the arduino-pico framework. As a workaround, we block component setup until
1864// WiFi is connected, ensuring mDNS.begin() is called with an active connection.
1865
1867 if (!this->has_sta() || this->state_ == WIFI_COMPONENT_STATE_DISABLED || this->ap_setup_) {
1868 return true;
1869 }
1870 return this->is_connected();
1871}
1872#endif
1873
1874void WiFiComponent::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; }
1880 this->power_save_ = power_save;
1881#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
1882 this->configured_power_save_ = power_save;
1883#endif
1884}
1885
1886void WiFiComponent::set_passive_scan(bool passive) { this->passive_scan_ = passive; }
1887
1889#ifdef USE_CAPTIVE_PORTAL
1891#else
1892 return false;
1893#endif
1894}
1896#ifdef USE_IMPROV
1898#else
1899 return false;
1900#endif
1901}
1902
1903#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
1905 // Already configured for high performance - request satisfied
1907 return true;
1908 }
1909
1910 // Semaphore initialization failed
1911 if (this->high_performance_semaphore_ == nullptr) {
1912 return false;
1913 }
1914
1915 // Give the semaphore (non-blocking). This increments the count.
1916 return xSemaphoreGive(this->high_performance_semaphore_) == pdTRUE;
1917}
1918
1920 // Already configured for high performance - nothing to release
1922 return true;
1923 }
1924
1925 // Semaphore initialization failed
1926 if (this->high_performance_semaphore_ == nullptr) {
1927 return false;
1928 }
1929
1930 // Take the semaphore (non-blocking). This decrements the count.
1931 return xSemaphoreTake(this->high_performance_semaphore_, 0) == pdTRUE;
1932}
1933#endif // USE_ESP32 && USE_WIFI_RUNTIME_POWER_SAVE
1934
1935#ifdef USE_WIFI_FAST_CONNECT
1937 SavedWifiFastConnectSettings fast_connect_save{};
1938
1939 if (this->fast_connect_pref_.load(&fast_connect_save)) {
1940 // Validate saved AP index
1941 if (fast_connect_save.ap_index < 0 || static_cast<size_t>(fast_connect_save.ap_index) >= this->sta_.size()) {
1942 ESP_LOGW(TAG, "AP index out of bounds");
1943 return false;
1944 }
1945
1946 // Set selected index for future operations (save, retry, etc)
1947 this->selected_sta_index_ = fast_connect_save.ap_index;
1948
1949 // Copy entire config, then override with fast connect data
1950 params = this->sta_[fast_connect_save.ap_index];
1951
1952 // Override with saved BSSID/channel from fast connect (SSID/password/etc already copied from config)
1953 bssid_t bssid{};
1954 std::copy(fast_connect_save.bssid, fast_connect_save.bssid + 6, bssid.begin());
1955 params.set_bssid(bssid);
1956 params.set_channel(fast_connect_save.channel);
1957 // Fast connect uses specific BSSID+channel, not hidden network probe (even if config has hidden: true)
1958 params.set_hidden(false);
1959
1960 ESP_LOGD(TAG, "Loaded fast_connect settings");
1961 return true;
1962 }
1963
1964 return false;
1965}
1966
1968 bssid_t bssid = wifi_bssid();
1969 uint8_t channel = get_wifi_channel();
1970 // selected_sta_index_ is always valid here (called only after successful connection)
1971 // Fallback to 0 is defensive programming for robustness
1972 int8_t ap_index = this->selected_sta_index_ >= 0 ? this->selected_sta_index_ : 0;
1973
1974 // Skip save if settings haven't changed (compare with previously saved settings to reduce flash wear)
1975 SavedWifiFastConnectSettings previous_save{};
1976 if (this->fast_connect_pref_.load(&previous_save) && memcmp(previous_save.bssid, bssid.data(), 6) == 0 &&
1977 previous_save.channel == channel && previous_save.ap_index == ap_index) {
1978 return; // No change, nothing to save
1979 }
1980
1981 SavedWifiFastConnectSettings fast_connect_save{};
1982 memcpy(fast_connect_save.bssid, bssid.data(), 6);
1983 fast_connect_save.channel = channel;
1984 fast_connect_save.ap_index = ap_index;
1985
1986 this->fast_connect_pref_.save(&fast_connect_save);
1987
1988 ESP_LOGD(TAG, "Saved fast_connect settings");
1989}
1990#endif
1991
1992void WiFiAP::set_ssid(const std::string &ssid) { this->ssid_ = ssid; }
1993void WiFiAP::set_bssid(const bssid_t &bssid) { this->bssid_ = bssid; }
1994void WiFiAP::clear_bssid() { this->bssid_ = {}; }
1995void WiFiAP::set_password(const std::string &password) { this->password_ = password; }
1996#ifdef USE_WIFI_WPA2_EAP
1997void WiFiAP::set_eap(optional<EAPAuth> eap_auth) { this->eap_ = std::move(eap_auth); }
1998#endif
1999void WiFiAP::set_channel(uint8_t channel) { this->channel_ = channel; }
2000void WiFiAP::clear_channel() { this->channel_ = 0; }
2001#ifdef USE_WIFI_MANUAL_IP
2002void WiFiAP::set_manual_ip(optional<ManualIP> manual_ip) { this->manual_ip_ = manual_ip; }
2003#endif
2004void WiFiAP::set_hidden(bool hidden) { this->hidden_ = hidden; }
2005const std::string &WiFiAP::get_ssid() const { return this->ssid_; }
2006const bssid_t &WiFiAP::get_bssid() const { return this->bssid_; }
2007bool WiFiAP::has_bssid() const { return this->bssid_ != bssid_t{}; }
2008const std::string &WiFiAP::get_password() const { return this->password_; }
2009#ifdef USE_WIFI_WPA2_EAP
2010const optional<EAPAuth> &WiFiAP::get_eap() const { return this->eap_; }
2011#endif
2012uint8_t WiFiAP::get_channel() const { return this->channel_; }
2013bool WiFiAP::has_channel() const { return this->channel_ != 0; }
2014#ifdef USE_WIFI_MANUAL_IP
2016#endif
2017bool WiFiAP::get_hidden() const { return this->hidden_; }
2018
2019WiFiScanResult::WiFiScanResult(const bssid_t &bssid, std::string ssid, uint8_t channel, int8_t rssi, bool with_auth,
2020 bool is_hidden)
2021 : bssid_(bssid),
2022 channel_(channel),
2023 rssi_(rssi),
2024 ssid_(std::move(ssid)),
2025 with_auth_(with_auth),
2026 is_hidden_(is_hidden) {}
2027bool WiFiScanResult::matches(const WiFiAP &config) const {
2028 if (config.get_hidden()) {
2029 // User configured a hidden network, only match actually hidden networks
2030 // don't match SSID
2031 if (!this->is_hidden_)
2032 return false;
2033 } else if (!config.get_ssid().empty()) {
2034 // check if SSID matches
2035 if (config.get_ssid() != this->ssid_)
2036 return false;
2037 } else {
2038 // network is configured without SSID - match other settings
2039 }
2040 // If BSSID configured, only match for correct BSSIDs
2041 if (config.has_bssid() && config.get_bssid() != this->bssid_)
2042 return false;
2043
2044#ifdef USE_WIFI_WPA2_EAP
2045 // BSSID requires auth but no PSK or EAP credentials given
2046 if (this->with_auth_ && (config.get_password().empty() && !config.get_eap().has_value()))
2047 return false;
2048
2049 // BSSID does not require auth, but PSK or EAP credentials given
2050 if (!this->with_auth_ && (!config.get_password().empty() || config.get_eap().has_value()))
2051 return false;
2052#else
2053 // If PSK given, only match for networks with auth (and vice versa)
2054 if (config.get_password().empty() == this->with_auth_)
2055 return false;
2056#endif
2057
2058 // If channel configured, only match networks on that channel.
2059 if (config.has_channel() && config.get_channel() != this->channel_) {
2060 return false;
2061 }
2062 return true;
2063}
2064bool WiFiScanResult::get_matches() const { return this->matches_; }
2065void WiFiScanResult::set_matches(bool matches) { this->matches_ = matches; }
2066const bssid_t &WiFiScanResult::get_bssid() const { return this->bssid_; }
2067const std::string &WiFiScanResult::get_ssid() const { return this->ssid_; }
2068uint8_t WiFiScanResult::get_channel() const { return this->channel_; }
2069int8_t WiFiScanResult::get_rssi() const { return this->rssi_; }
2070bool WiFiScanResult::get_with_auth() const { return this->with_auth_; }
2071bool WiFiScanResult::get_is_hidden() const { return this->is_hidden_; }
2072
2073bool WiFiScanResult::operator==(const WiFiScanResult &rhs) const { return this->bssid_ == rhs.bssid_; }
2074
2080
2082 if (!this->keep_scan_results_) {
2083#ifdef USE_RP2040
2084 // std::vector - use swap trick since shrink_to_fit is non-binding
2085 decltype(this->scan_result_)().swap(this->scan_result_);
2086#else
2087 // FixedVector::release() frees all memory
2088 this->scan_result_.release();
2089#endif
2090 }
2091}
2092
2094 // Guard: not for hidden networks (may not appear in scan)
2095 const WiFiAP *selected = this->get_selected_sta_();
2096 if (selected == nullptr || selected->get_hidden()) {
2097 this->roaming_attempts_ = ROAMING_MAX_ATTEMPTS; // Stop checking forever
2098 return;
2099 }
2100
2101 this->roaming_last_check_ = now;
2102 this->roaming_attempts_++;
2103
2104 // Guard: skip scan if signal is already good (no meaningful improvement possible)
2105 int8_t rssi = this->wifi_rssi();
2106 if (rssi > ROAMING_GOOD_RSSI) {
2107 ESP_LOGV(TAG, "Roam check skipped, signal good (%d dBm, attempt %u/%u)", rssi, this->roaming_attempts_,
2109 return;
2110 }
2111
2112 ESP_LOGD(TAG, "Roam scan (%d dBm, attempt %u/%u)", rssi, this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
2114 this->wifi_scan_start_(this->passive_scan_);
2115}
2116
2118 this->scan_done_ = false;
2119 // Default to IDLE - will be set to CONNECTING if we find a better AP
2121
2122 // Get current connection info
2123 int8_t current_rssi = this->wifi_rssi();
2124 // Guard: must still be connected (RSSI may have become invalid during scan)
2125 if (current_rssi == WIFI_RSSI_DISCONNECTED) {
2126 this->release_scan_results_();
2127 return;
2128 }
2129
2130 char ssid_buf[SSID_BUFFER_SIZE];
2131 StringRef current_ssid(this->wifi_ssid_to(ssid_buf));
2132 bssid_t current_bssid = this->wifi_bssid();
2133
2134 // Find best candidate: same SSID, different BSSID
2135 const WiFiScanResult *best = nullptr;
2136 char bssid_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
2137
2138 for (const auto &result : this->scan_result_) {
2139 // Must be same SSID, different BSSID
2140 if (current_ssid != result.get_ssid() || result.get_bssid() == current_bssid)
2141 continue;
2142
2143#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
2144 format_mac_addr_upper(result.get_bssid().data(), bssid_buf);
2145 ESP_LOGV(TAG, "Roam candidate %s %d dBm", bssid_buf, result.get_rssi());
2146#endif
2147
2148 // Track the best candidate
2149 if (best == nullptr || result.get_rssi() > best->get_rssi()) {
2150 best = &result;
2151 }
2152 }
2153
2154 // Check if best candidate meets minimum improvement threshold
2155 const WiFiAP *selected = this->get_selected_sta_();
2156 int8_t improvement = (best == nullptr) ? 0 : best->get_rssi() - current_rssi;
2157 if (selected == nullptr || improvement < ROAMING_MIN_IMPROVEMENT) {
2158 ESP_LOGV(TAG, "Roam best %+d dB (need +%d), attempt %u/%u", improvement, ROAMING_MIN_IMPROVEMENT,
2160 this->release_scan_results_();
2161 return;
2162 }
2163
2164 format_mac_addr_upper(best->get_bssid().data(), bssid_buf);
2165 ESP_LOGI(TAG, "Roaming to %s (%+d dB)", bssid_buf, improvement);
2166
2167 WiFiAP roam_params = *selected;
2168 apply_scan_result_to_params(roam_params, *best);
2169 this->release_scan_results_();
2170
2171 // Mark as roaming attempt - affects retry behavior if connection fails
2173
2174 // Connect directly - wifi_sta_connect_ handles disconnect internally
2175 this->start_connecting(roam_params);
2176}
2177
2178WiFiComponent *global_wifi_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
2179
2180} // namespace esphome::wifi
2181#endif
uint8_t m
Definition bl0906.h:1
uint8_t status
Definition bl0942.h:8
bool is_name_add_mac_suffix_enabled() const
const std::string & get_name() const
Get the name of this Application set by pre_setup().
constexpr uint32_t get_config_version_hash()
Get the config hash extended with ESPHome version.
uint32_t IRAM_ATTR HOT get_loop_component_start_time() const
Get the cached time in milliseconds from when the current component started its loop execution.
void status_set_warning(const char *message=nullptr)
void status_clear_warning()
bool save(const T *src)
Definition preferences.h:21
virtual bool sync()=0
Commit pending writes to flash.
virtual ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash)=0
StringRef is a reference to a string owned by something else.
Definition string_ref.h:26
void trigger(const Ts &...x)
Inform the parent automation that the event has triggered.
Definition automation.h:238
bool has_value() const
Definition optional.h:92
value_type const & value() const
Definition optional.h:94
uint8_t get_channel() const
const std::string & get_ssid() const
void set_ssid(const std::string &ssid)
const optional< EAPAuth > & get_eap() const
const std::string & get_password() const
void set_bssid(const bssid_t &bssid)
void set_channel(uint8_t channel)
optional< EAPAuth > eap_
optional< ManualIP > manual_ip_
void set_eap(optional< EAPAuth > eap_auth)
void set_password(const std::string &password)
void set_manual_ip(optional< ManualIP > manual_ip)
const optional< ManualIP > & get_manual_ip() const
void set_hidden(bool hidden)
const bssid_t & get_bssid() const
This component is responsible for managing the ESP WiFi interface.
void add_sta(const WiFiAP &ap)
bool load_fast_connect_settings_(WiFiAP &params)
void set_ap(const WiFiAP &ap)
Setup an Access Point that should be created if no connection to a station can be made.
bool request_high_performance()
Request high-performance mode (no power saving) for improved WiFi latency.
void set_sta(const WiFiAP &ap)
bool has_sta_priority(const bssid_t &bssid)
const WiFiAP * get_selected_sta_() const
int8_t get_sta_priority(const bssid_t bssid)
void log_and_adjust_priority_for_failed_connect_()
Log failed connection and decrease BSSID priority to avoid repeated attempts.
void save_wifi_sta(const std::string &ssid, const std::string &password)
wifi_scan_vector_t< WiFiScanResult > scan_result_
WiFiPowerSaveMode configured_power_save_
void set_sta_priority(const bssid_t bssid, int8_t priority)
void loop() override
Reconnect WiFi if required.
void start_connecting(const WiFiAP &ap)
void advance_to_next_target_or_increment_retry_()
Advance to next target (AP/SSID) within current phase, or increment retry counter Called when staying...
static constexpr uint32_t ROAMING_CHECK_INTERVAL
SemaphoreHandle_t high_performance_semaphore_
network::IPAddress get_dns_address(int num)
WiFiComponent()
Construct a WiFiComponent.
std::vector< WiFiSTAPriority > sta_priorities_
static constexpr int8_t ROAMING_GOOD_RSSI
const char * wifi_ssid_to(std::span< char, SSID_BUFFER_SIZE > buffer)
Write SSID to buffer without heap allocation.
static constexpr uint8_t ROAMING_MAX_ATTEMPTS
void set_passive_scan(bool passive)
void set_power_save_mode(WiFiPowerSaveMode power_save)
int8_t find_next_hidden_sta_(int8_t start_index)
Find next SSID that wasn't in scan results (might be hidden) Returns index of next potentially hidden...
ESPPreferenceObject fast_connect_pref_
void clear_priorities_if_all_min_()
Clear BSSID priority tracking if all priorities are at minimum (saves memory)
WiFiRetryPhase determine_next_phase_()
Determine next retry phase based on current state and failure conditions.
network::IPAddress wifi_dns_ip_(int num)
float get_loop_priority() const override
network::IPAddresses get_ip_addresses()
static constexpr int8_t ROAMING_MIN_IMPROVEMENT
float get_setup_priority() const override
WIFI setup_priority.
FixedVector< WiFiAP > sta_
int8_t find_first_non_hidden_index_() const
Find the index of the first non-hidden network Returns where EXPLICIT_HIDDEN phase would have stopped...
void release_scan_results_()
Free scan results memory unless a component needs them.
bool ssid_was_seen_in_scan_(const std::string &ssid) const
Check if an SSID was seen in the most recent scan results Used to skip hidden mode for SSIDs we know ...
bool needs_scan_results_() const
Check if we need valid scan results for the current phase but don't have any Returns true if the phas...
bool transition_to_phase_(WiFiRetryPhase new_phase)
Transition to a new retry phase with logging Returns true if a scan was started (caller should wait),...
bool release_high_performance()
Release a high-performance mode request.
bool wifi_apply_output_power_(float output_power)
const char * get_use_address() const
WiFiSTAConnectStatus wifi_sta_connect_status_()
bool went_through_explicit_hidden_phase_() const
Check if we went through EXPLICIT_HIDDEN phase (first network is marked hidden) Used in RETRY_HIDDEN ...
bool wifi_mode_(optional< bool > sta, optional< bool > ap)
void set_reboot_timeout(uint32_t reboot_timeout)
network::IPAddresses wifi_sta_ip_addresses()
void check_connecting_finished(uint32_t now)
void start_initial_connection_()
Start initial connection - either scan or connect directly to hidden networks.
void setup() override
Setup WiFi interface.
void clear_all_bssid_priorities_()
Clear all BSSID priority penalties after successful connection (stale after disconnect)
void set_use_address(const char *use_address)
const std::string & get_ssid() const
const bssid_t & get_bssid() const
WiFiScanResult(const bssid_t &bssid, std::string ssid, uint8_t channel, int8_t rssi, bool with_auth, bool is_hidden)
bool matches(const WiFiAP &config) const
bool operator==(const WiFiScanResult &rhs) const
struct @65::@66 __attribute__
uint16_t type
uint8_t priority
CaptivePortal * global_captive_portal
ESP32ImprovComponent * global_improv_component
std::array< IPAddress, 5 > IPAddresses
Definition ip_address.h:180
const char *const TAG
Definition spi.cpp:7
std::array< uint8_t, 6 > bssid_t
const LogString * get_signal_bars(int8_t rssi)
@ BLIND_RETRY
Blind retry mode: scanning disabled (captive portal/improv active), try ALL configured networks seque...
@ SCAN_BASED
Normal mode: scan completed, only try networks NOT visible in scan results (truly hidden networks tha...
WiFiRetryPhase
Tracks the current retry strategy/phase for WiFi connection attempts.
@ RETRY_HIDDEN
Retry networks not found in scan (might be hidden)
@ RESTARTING_ADAPTER
Restarting WiFi adapter to clear stuck state.
@ INITIAL_CONNECT
Initial connection attempt (varies based on fast_connect setting)
@ EXPLICIT_HIDDEN
Explicitly hidden networks (user marked as hidden, try before scanning)
@ FAST_CONNECT_CYCLING_APS
Fast connect mode: cycling through configured APs (config-only, no scan)
@ SCAN_CONNECTING
Scan-based: connecting to best AP from scan results.
WiFiComponent * global_wifi_component
@ SCANNING
Scanning for better AP.
@ CONNECTING
Attempting to connect to better AP found in scan.
@ IDLE
Not roaming, waiting for next check interval.
@ RECONNECTING
Roam connection failed, reconnecting to any available AP.
@ WIFI_COMPONENT_STATE_DISABLED
WiFi is disabled.
@ WIFI_COMPONENT_STATE_AP
WiFi is in AP-only mode and internal AP is already enabled.
@ WIFI_COMPONENT_STATE_STA_CONNECTING
WiFi is in STA(+AP) mode and currently connecting to an AP.
@ WIFI_COMPONENT_STATE_OFF
Nothing has been initialized yet.
@ WIFI_COMPONENT_STATE_STA_SCANNING
WiFi is in STA-only mode and currently scanning for APs.
@ WIFI_COMPONENT_STATE_COOLDOWN
WiFi is in cooldown mode because something went wrong, scanning will begin after a short period of ti...
@ WIFI_COMPONENT_STATE_STA_CONNECTED
WiFi is in STA(+AP) mode and successfully connected.
ESPPreferences * global_preferences
void IRAM_ATTR HOT yield()
Definition core.cpp:24
const char * get_mac_address_pretty_into_buffer(std::span< char, MAC_ADDRESS_PRETTY_BUFFER_SIZE > buf)
Get the device MAC address into the given buffer, in colon-separated uppercase hex notation.
Definition helpers.cpp:737
uint32_t IRAM_ATTR HOT millis()
Definition core.cpp:25
Application App
Global storage of Application pointer - only one Application can exist.
char * format_mac_addr_upper(const uint8_t *mac, char *output)
Format MAC address as XX:XX:XX:XX:XX:XX (uppercase, colon separators)
Definition helpers.h:897
esp_eap_ttls_phase2_types ttls_phase_2
Struct for setting static IPs in WiFiComponent.