ESPHome 2026.11.0-dev
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#include <type_traits>
7
8#ifdef USE_ESP32
9#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0)
10#include <esp_eap_client.h>
11#else
12#include <esp_wpa2.h>
13#endif
14#endif
15
16#if defined(USE_ESP32)
17#include <esp_wifi.h>
18#endif
19#ifdef USE_ESP8266
20#include <user_interface.h>
21#endif
22
23#include <algorithm>
24#include <new>
25#include <utility>
26#include "lwip/dns.h"
27#include "lwip/err.h"
28
30#include "esphome/core/hal.h"
32#include "esphome/core/log.h"
34#include "esphome/core/util.h"
35
36#ifdef USE_CAPTIVE_PORTAL
38#endif
39#ifdef USE_WEBSERVER_CAPTIVE
41#endif
42
43#ifdef USE_IMPROV_BLE
45#endif
46
47#ifdef USE_IMPROV_SERIAL
49#endif
50
51#ifdef USE_PROVISIONING
53#endif
54
55namespace esphome::wifi {
56
57ESPHOME_LOG_TAG(TAG, "wifi");
58
59// CompactString implementation
60CompactString::CompactString(const char *str, size_t len) {
61 if (len > MAX_LENGTH) {
62 len = MAX_LENGTH; // Clamp to max valid length
63 }
64
65 this->length_ = len;
66 if (len <= INLINE_CAPACITY) {
67 // Store inline with null terminator
68 this->is_heap_ = 0;
69 if (len > 0) {
70 std::memcpy(this->storage_, str, len);
71 }
72 this->storage_[len] = '\0';
73 } else {
74 // Heap allocate with null terminator
75 this->is_heap_ = 1;
76 char *heap_data = new char[len + 1]; // NOLINT(cppcoreguidelines-owning-memory)
77 std::memcpy(heap_data, str, len);
78 heap_data[len] = '\0';
79 this->set_heap_ptr_(heap_data);
80 }
81}
82
83CompactString::CompactString(const CompactString &other) : CompactString(other.data(), other.size()) {}
84
86 if (this != &other) {
87 this->~CompactString();
88 new (this) CompactString(other);
89 }
90 return *this;
91}
92
93CompactString::CompactString(CompactString &&other) noexcept : length_(other.length_), is_heap_(other.is_heap_) {
94 // Copy full storage (includes null terminator for inline, or pointer for heap). The bytes past the
95 // terminator are never read, and a fixed-size copy is 8 bytes of flash smaller than a sized one.
96 // NOLINTNEXTLINE(clang-analyzer-unix.cstring.UninitializedRead)
97 std::memcpy(this->storage_, other.storage_, INLINE_CAPACITY + 1);
98 other.length_ = 0;
99 other.is_heap_ = 0;
100 other.storage_[0] = '\0';
101}
102
104 if (this != &other) {
105 this->~CompactString();
106 new (this) CompactString(std::move(other));
107 }
108 return *this;
109}
110
112 if (this->is_heap_) {
113 delete[] this->get_heap_ptr_(); // NOLINT(cppcoreguidelines-owning-memory)
114 }
115}
116
117bool CompactString::operator==(const CompactString &other) const {
118 return this->size() == other.size() && std::memcmp(this->data(), other.data(), this->size()) == 0;
119}
120bool CompactString::operator==(const StringRef &other) const {
121 return this->size() == other.size() && std::memcmp(this->data(), other.c_str(), this->size()) == 0;
122}
123
319
320#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_INFO
321#ifdef USE_WIFI_PHY_MODE
322// Use if-chain instead of switch to avoid jump table in RODATA (wastes RAM on ESP8266)
323static const LogString *phy_mode_to_log_string(WiFi8266PhyMode mode) {
325 return LOG_STR("11B");
327 return LOG_STR("11G");
329 return LOG_STR("11N");
330 return LOG_STR("Auto");
331}
332#endif
333// Use if-chain instead of switch to avoid jump table in RODATA (wastes RAM on ESP8266)
334static const LogString *retry_phase_to_log_string(WiFiRetryPhase phase) {
336 return LOG_STR("INITIAL_CONNECT");
337#ifdef USE_WIFI_FAST_CONNECT
339 return LOG_STR("FAST_CONNECT_CYCLING");
340#endif
342 return LOG_STR("EXPLICIT_HIDDEN");
344 return LOG_STR("SCAN_CONNECTING");
345 if (phase == WiFiRetryPhase::RETRY_HIDDEN)
346 return LOG_STR("RETRY_HIDDEN");
348 return LOG_STR("RESTARTING");
349 return LOG_STR("UNKNOWN");
350}
351#endif // ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_INFO
352
354 // If first configured network is marked hidden, we went through EXPLICIT_HIDDEN phase
355 // This means those networks were already tried and should be skipped in RETRY_HIDDEN
356 return !this->sta_.empty() && this->sta_[0].get_hidden();
357}
358
360 // Find the first network that is NOT marked hidden:true
361 // This is where EXPLICIT_HIDDEN phase would have stopped
362 for (size_t i = 0; i < this->sta_.size(); i++) {
363 if (!this->sta_[i].get_hidden()) {
364 return static_cast<int8_t>(i);
365 }
366 }
367 return -1; // All networks are hidden
368}
369
370// 2 attempts per BSSID in SCAN_CONNECTING phase
371// Rationale: This is the ONLY phase where we decrease BSSID priority, so we must be very sure.
372// Auth failures are common immediately after scan due to WiFi stack state transitions.
373// Trying twice filters out false positives and prevents unnecessarily marking a good BSSID as bad.
374// After 2 genuine failures, priority degradation ensures we skip this BSSID on subsequent scans.
375static constexpr uint8_t WIFI_RETRY_COUNT_PER_BSSID = 2;
376
377// 1 attempt per SSID in RETRY_HIDDEN phase
378// Rationale: Try hidden mode once, then rescan to get next best BSSID via priority system
379static constexpr uint8_t WIFI_RETRY_COUNT_PER_SSID = 1;
380
381// 1 attempt per AP in fast_connect mode (INITIAL_CONNECT and FAST_CONNECT_CYCLING_APS)
382// Rationale: Fast connect prioritizes speed - try each AP once to find a working one quickly
383static constexpr uint8_t WIFI_RETRY_COUNT_PER_AP = 1;
384
387static constexpr uint32_t WIFI_COOLDOWN_DURATION_MS = 500;
388#ifdef USE_WIFI_AP
390#ifdef USE_WIFI_AP_EXCLUSIVE
391static constexpr bool WIFI_AP_EXCLUSIVE = true;
392#else
393static constexpr bool WIFI_AP_EXCLUSIVE = false;
394#endif
395#endif
396#ifdef USE_WIFI_AP_EXCLUSIVE
401static constexpr uint32_t WIFI_AP_EXCLUSIVE_DWELL_MS = 300000;
404static constexpr uint32_t WIFI_AP_EXCLUSIVE_MAX_DWELL_MS = 3 * WIFI_AP_EXCLUSIVE_DWELL_MS;
405static constexpr uint32_t WIFI_AP_EXCLUSIVE_HANDOVER_MS = 1000;
406#endif
407
411static constexpr uint32_t WIFI_COOLDOWN_WITH_AP_ACTIVE_MS = 30000;
412
416static constexpr uint32_t WIFI_SCAN_TIMEOUT_MS = 31000;
417
426static constexpr uint32_t WIFI_CONNECT_TIMEOUT_MS = 46000;
427
428static constexpr uint8_t get_max_retries_for_phase(WiFiRetryPhase phase) {
429 switch (phase) {
431#ifdef USE_WIFI_FAST_CONNECT
433#endif
434 // INITIAL_CONNECT and FAST_CONNECT_CYCLING_APS both use 1 attempt per AP (fast_connect mode)
435 return WIFI_RETRY_COUNT_PER_AP;
437 // Explicitly hidden network: 1 attempt (user marked as hidden, try once then scan)
438 return WIFI_RETRY_COUNT_PER_SSID;
440 // Scan-based phase: 2 attempts per BSSID (handles transient auth failures after scan)
441 return WIFI_RETRY_COUNT_PER_BSSID;
443 // Hidden network mode: 1 attempt per SSID
444 return WIFI_RETRY_COUNT_PER_SSID;
445 default:
446 return WIFI_RETRY_COUNT_PER_BSSID;
447 }
448}
449
450static void apply_scan_result_to_params(WiFiAP &params, const WiFiScanResult &scan) {
451 params.set_hidden(false);
452 params.set_ssid(scan.get_ssid());
453 params.set_bssid(scan.get_bssid());
454 params.set_channel(scan.get_channel());
455}
456
458 // Only SCAN_CONNECTING phase needs scan results
460 return false;
461 }
462 // Need scan if we have no results or no matching networks
463 return this->scan_result_.empty() || !this->scan_result_[0].get_matches();
464}
465
467 // Check if this SSID is configured as hidden
468 // If explicitly marked hidden, we should always try hidden mode regardless of scan results
469 for (const auto &conf : this->sta_) {
470 if (conf.ssid_ == ssid && conf.get_hidden()) {
471 return false; // Treat as not seen - force hidden mode attempt
472 }
473 }
474
475 // Otherwise, check if we saw it in scan results
476 for (const auto &scan : this->scan_result_) {
477 if (scan.ssid_ == ssid) {
478 return true;
479 }
480 }
481 return false;
482}
483
485 // Components that require full scan results (for example, scan result listeners)
486 // are expected to call request_wifi_scan_results(), which sets keep_scan_results_.
487 if (this->keep_scan_results_) {
488 return true;
489 }
490
491#ifdef USE_CAPTIVE_PORTAL
492 // Captive portal needs full results when active (showing network list to user)
494 return true;
495 }
496#endif
497
498#ifdef USE_IMPROV_SERIAL
499 // Improv serial needs results during provisioning (before connected)
501 return true;
502 }
503#endif
504
505#ifdef USE_IMPROV_BLE
506 // BLE improv also needs results during provisioning
508 return true;
509 }
510#endif
511
512 return false;
513}
514
515bool WiFiComponent::matches_configured_network_(const char *ssid, const uint8_t *bssid) const {
516 // Hidden networks in scan results have empty SSIDs - skip them
517 if (ssid[0] == '\0') {
518 return false;
519 }
520 for (const auto &sta : this->sta_) {
521 // Skip hidden network configs (they don't appear in normal scans)
522 if (sta.get_hidden()) {
523 continue;
524 }
525 // For BSSID-only configs (empty SSID), match by BSSID
526 if (sta.ssid_.empty()) {
527 if (sta.has_bssid() && std::memcmp(sta.get_bssid().data(), bssid, 6) == 0) {
528 return true;
529 }
530 continue;
531 }
532 // Match by SSID
533 if (sta.ssid_ == ssid) {
534 return true;
535 }
536 }
537 return false;
538}
539
541 for (auto &it : this->sta_priorities_) {
542 if (it.bssid == bssid) {
543 it.priority = priority;
544 return;
545 }
546 }
547 this->sta_priorities_.push_back(WiFiSTAPriority{
548 .bssid = bssid,
549 .priority = priority,
550 });
551}
552
553void WiFiComponent::log_discarded_scan_result_(const char *ssid, const uint8_t *bssid, int8_t rssi, uint8_t channel) {
554#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
555 // Skip logging during roaming scans to avoid log buffer overflow
556 // (roaming scans typically find many networks but only care about same-SSID APs)
557 if (this->is_roaming_scan_active()) {
558 return;
559 }
560 char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
561 format_mac_addr_upper(bssid, bssid_s);
562 ESP_LOGV(TAG, "- " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") " %ddB Ch:%u", ssid, bssid_s, rssi, channel);
563#endif
564}
565
566int8_t WiFiComponent::find_next_hidden_sta_(int8_t start_index) {
567 // Find next SSID to try in RETRY_HIDDEN phase.
568 //
569 // This function operates in two modes based on retry_hidden_mode_:
570 //
571 // 1. SCAN_BASED mode:
572 // After SCAN_CONNECTING phase, only returns networks that were NOT visible
573 // in the scan (truly hidden networks that need probe requests).
574 //
575 // 2. BLIND_RETRY mode:
576 // When captive portal/improv is active, scanning is skipped to avoid
577 // disrupting the AP. In this mode, ALL configured networks are returned
578 // as candidates, cycling through them sequentially. This allows the device
579 // to keep trying all networks while users configure WiFi via captive portal.
580 //
581 // Additionally, if EXPLICIT_HIDDEN phase was executed (first network marked hidden:true),
582 // those networks are skipped here since they were already tried.
583 //
584 bool include_explicit_hidden = !this->went_through_explicit_hidden_phase_();
585 // Start searching from start_index + 1
586 for (size_t i = start_index + 1; i < this->sta_.size(); i++) {
587 const auto &sta = this->sta_[i];
588
589 // Skip networks that were already tried in EXPLICIT_HIDDEN phase
590 // Those are: networks marked hidden:true that appear before the first non-hidden network
591 // If all networks are hidden (first_non_hidden_idx == -1), skip all of them
592 if (!include_explicit_hidden && sta.get_hidden()) {
593 int8_t first_non_hidden_idx = this->find_first_non_hidden_index_();
594 if (first_non_hidden_idx < 0 || static_cast<int8_t>(i) < first_non_hidden_idx) {
595 ESP_LOGD(TAG, "Skipping " LOG_SECRET("'%s'") " (explicit hidden, already tried)", sta.ssid_.c_str());
596 continue;
597 }
598 }
599
600 // In BLIND_RETRY mode, treat all networks as candidates
601 // In SCAN_BASED mode, only retry networks that weren't seen in the scan
603 ESP_LOGD(TAG, "Hidden candidate " LOG_SECRET("'%s'") " at index %d", sta.ssid_.c_str(), static_cast<int>(i));
604 return static_cast<int8_t>(i);
605 }
606 ESP_LOGD(TAG, "Skipping hidden retry for visible network " LOG_SECRET("'%s'"), sta.ssid_.c_str());
607 }
608 // No hidden SSIDs found
609 return -1;
610}
611
613 // If first network (highest priority) is explicitly marked hidden, try it first before scanning
614 // This respects user's priority order when they explicitly configure hidden networks
615 if (!this->sta_.empty() && this->sta_[0].get_hidden()) {
616 ESP_LOGI(TAG, "Starting with explicit hidden network (highest priority)");
617 this->selected_sta_index_ = 0;
620 this->start_connecting(params);
621 } else {
622 this->start_scanning();
623 }
624}
625
626#if defined(USE_ESP32) && defined(USE_WIFI_WPA2_EAP) && ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
627static const char *eap_phase2_to_str(esp_eap_ttls_phase2_types type) {
628 switch (type) {
629 case ESP_EAP_TTLS_PHASE2_PAP:
630 return "pap";
631 case ESP_EAP_TTLS_PHASE2_CHAP:
632 return "chap";
633 case ESP_EAP_TTLS_PHASE2_MSCHAP:
634 return "mschap";
635 case ESP_EAP_TTLS_PHASE2_MSCHAPV2:
636 return "mschapv2";
637 case ESP_EAP_TTLS_PHASE2_EAP:
638 return "eap";
639 default:
640 return "unknown";
641 }
642}
643#endif
644
646 this->wifi_pre_setup_();
647
648#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
649 // Create semaphore for high-performance mode requests
650 // Start at 0, increment on request, decrement on release
651 this->high_performance_semaphore_ = xSemaphoreCreateCounting(UINT32_MAX, 0);
652 if (this->high_performance_semaphore_ == nullptr) {
653 ESP_LOGE(TAG, "Failed semaphore");
654 }
655
656 // Store the configured power save mode as baseline
658#endif
659
660#if defined(USE_PROVISIONING) && (defined(USE_WIFI_DPP) || (defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)))
661 // The access point, the portals on it and the DPP listen are provisioning surfaces: once the
662 // window has closed, end them (mirrors the teardown on a successful connection). The blocks in
663 // loop() that start them are gated so none of them is started again afterwards. A bridge AP
664 // carries no portal and stays up.
667#ifdef USE_WIFI_DPP
668 this->stop_dpp_listen_();
669#endif
670#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)
671 this->end_ap_portal_(); // no-op when nothing is active, so not tied to ap_setup_
672 if (this->ap_setup_) {
673 ESP_LOGD(TAG, "Provisioning window closed; disabling AP");
674#ifdef USE_WIFI_AP_EXCLUSIVE
675 // The networks wait while the AP runs on its own; hand them the radio
676 // now rather than at the end of the dwell.
677 if (this->state_ == WIFI_COMPONENT_STATE_AP && this->has_sta()) {
678 this->pause_exclusive_ap_();
679 return;
680 }
681#endif
682 this->disable_ap_();
683 }
684#endif // USE_WIFI_AP
685 });
686 }
687#endif
688
689 if (this->enable_on_boot_) {
690#ifdef USE_ESP32
691 this->wifi_lazy_init_();
692#endif
693 this->start();
694 } else {
696 }
697}
698
700 ESP_LOGCONFIG(TAG, "Starting");
701 // The loop's time, not millis(): a wifi.enable from a trigger in this pass must not stamp a time after now
703
704 uint32_t hash = this->has_sta() ? App.get_config_version_hash() : 88491487UL;
705
707#ifdef USE_WIFI_FAST_CONNECT
708#ifdef USE_WIFI_FAST_CONNECT_IN_FLASH
709 const bool fast_connect_in_flash = true;
710#else
711 const bool fast_connect_in_flash = false;
712#endif
713 this->fast_connect_pref_ =
715#endif
716#ifdef USE_WIFI_LEASE_CACHE
717#ifdef USE_WIFI_LEASE_CACHE_IN_FLASH
718 const bool lease_cache_in_flash = true;
719#else
720 const bool lease_cache_in_flash = false;
721#endif
722 this->lease_cache_pref_ =
723 global_preferences->make_preference<wifi::SavedWifiLeaseSettings>(hash + 2, lease_cache_in_flash);
724#endif
725
726 SavedWifiSettings save{};
727 if (this->pref_.load(&save)) {
728 ESP_LOGD(TAG, "Loaded settings: %s", save.ssid);
729
730 WiFiAP sta{};
731 sta.set_ssid(save.ssid);
732 sta.set_password(save.password);
733 this->set_sta(sta);
734 }
735#ifdef USE_WIFI_LEASE_CACHE
736 // After the saved credentials: set_sta() clears the cache, and the lease belongs to that same network
737 this->load_lease_settings_();
738#endif
739
740 if (this->has_sta()) {
741 this->wifi_sta_pre_setup_();
742 if (!std::isnan(this->output_power_) && !this->wifi_apply_output_power_(this->output_power_)) {
743 ESP_LOGV(TAG, "Setting Output Power Option failed");
744 }
745
746#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
747 // Synchronize power_save_ with semaphore state before applying
748 if (this->high_performance_semaphore_ != nullptr) {
749 UBaseType_t semaphore_count = uxSemaphoreGetCount(this->high_performance_semaphore_);
750 if (semaphore_count > 0) {
752 this->is_high_performance_mode_ = true;
753 } else {
755 this->is_high_performance_mode_ = false;
756 }
757 }
758#endif
759 if (!this->wifi_apply_power_save_()) {
760 ESP_LOGV(TAG, "Setting Power Save Option failed");
761 }
762
764#ifdef USE_WIFI_FAST_CONNECT
765 WiFiAP params;
766 bool loaded_fast_connect = this->load_fast_connect_settings_(params);
767 // Fast connect optimization: only use when we have saved BSSID+channel data
768 // Without saved data, try first configured network or use normal flow
769 if (loaded_fast_connect) {
770 ESP_LOGI(TAG, "Starting fast_connect (saved) " LOG_SECRET("'%s'"), params.ssid_.c_str());
771 this->start_connecting(params);
772 } else if (!this->sta_.empty() && !this->sta_[0].get_hidden()) {
773 // No saved data, but have configured networks - try first non-hidden network
774 ESP_LOGI(TAG, "Starting fast_connect (config) " LOG_SECRET("'%s'"), this->sta_[0].ssid_.c_str());
775 this->selected_sta_index_ = 0;
776 params = this->build_params_for_current_phase_();
777 this->start_connecting(params);
778 } else {
779 // No saved data and (no networks OR first is hidden) - use normal flow
781 }
782#else
783 // Without fast_connect: go straight to scanning (or hidden mode if all networks are hidden)
785#endif
786#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)
787 } else if (this->has_ap()) { // a bridge waits in loop() for the upstream's resolver
788 this->setup_ap_config_();
789#ifdef USE_CAPTIVE_PORTAL
790 // Where the radio scans alongside the AP, the portal can list networks.
791 if (captive_portal::global_captive_portal != nullptr && !WIFI_AP_EXCLUSIVE) {
792 this->wifi_sta_pre_setup_();
793 this->start_scanning();
794 }
795#endif
796 this->start_ap_portal_();
797#endif // USE_WIFI_AP && !USE_WIFI_AP_WISP
798 }
799#ifdef USE_WIFI_DPP
800 // Without networks there is nothing to try first, so listen for a configurator right away
801 if (!this->has_sta() && this->is_dpp_active_() && this->wifi_mode_(true, {}))
802 this->start_dpp_listen_(millis());
803#endif
804#ifdef USE_IMPROV_BLE
805 if (!this->has_sta() && improv_ble::global_improv_component != nullptr) {
806 if (this->wifi_mode_(true, {}))
808 }
809#endif
810 this->wifi_apply_hostname_();
811}
812
814 ESP_LOGW(TAG, "Restarting adapter");
815 this->wifi_mode_(false, {});
816 // Clear error flag here because restart_adapter() enters COOLDOWN state,
817 // and check_connecting_finished() is called after cooldown without going
818 // through start_connecting() first. Without this clear, stale errors would
819 // trigger spurious "failed (callback)" logs. The canonical clear location
820 // is in start_connecting() (ESP8266 also clears after wifi_station_connect()).
821 this->error_from_callback_ = false;
822}
823
825 bool events_processed = this->wifi_loop_();
827 // Connection state can only change when events are processed (ESP-IDF/LibreTiny)
828 // or polled (ESP8266/Pico W). Skip the expensive wifi_sta_connect_status_() call
829 // when no events arrived and we're already in steady state.
830 // Must also run when connected_ is false — after state transitions to STA_CONNECTED,
831 // connected_ won't be set until update_connected_state_() runs.
832 if (events_processed || !this->connected_) {
834 }
835
836 if (this->has_sta()) {
837#if defined(USE_WIFI_CONNECT_TRIGGER) || defined(USE_WIFI_DISCONNECT_TRIGGER)
838 if (this->is_connected() != this->handled_connected_state_) {
839#ifdef USE_WIFI_DISCONNECT_TRIGGER
840 if (this->handled_connected_state_) {
842 }
843#endif
844#ifdef USE_WIFI_CONNECT_TRIGGER
845 if (!this->handled_connected_state_) {
847 }
848#endif
850 }
851#endif // USE_WIFI_CONNECT_TRIGGER || USE_WIFI_DISCONNECT_TRIGGER
852
853 switch (this->state_) {
855 this->status_set_warning(LOG_STR("waiting to reconnect"));
856 // Credentials not tried yet: retry whatever the driver reports. The flag outlives an adapter restart,
857 // which only lands back here, and start_connecting() clears it once they are tried
858 if (this->skip_cooldown_next_cycle_) {
859 ESP_LOGD(TAG, "Exiting cooldown early due to new WiFi credentials");
860 this->retry_connect();
861 break;
862 }
863 // Use longer cooldown when a portal/improv is active to avoid disrupting a user on the AP
864 bool portal_active = this->is_provisioning_active_();
865 uint32_t cooldown_duration = portal_active ? WIFI_COOLDOWN_WITH_AP_ACTIVE_MS : WIFI_COOLDOWN_DURATION_MS;
866 if (now - this->action_started_ > cooldown_duration) {
867 // After cooldown we either restarted the adapter because of
868 // a failure, or something tried to connect over and over
869 // so we entered cooldown. In both cases we call
870 // check_connecting_finished to continue the state machine.
871 this->check_connecting_finished(now);
872 }
873 break;
874 }
876 this->status_set_warning(LOG_STR("scanning for networks"));
878 break;
879 }
881 this->status_set_warning(LOG_STR("associating to network"));
882 this->check_connecting_finished(now);
883 break;
884 }
885
887 // Use cached connected_ set unconditionally at the top of loop()
888 if (!this->connected_) {
889 ESP_LOGW(TAG, "Connection lost; reconnecting");
891 this->retry_connect();
892 } else {
893 this->status_clear_warning();
894 this->set_last_connected_(now);
895
896#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
897 // A driver-initiated roam (e.g. 802.11v BTM) re-associates without the
898 // state machine ever leaving STA_CONNECTED, so the notification the
899 // connected event marked pending would never be flushed by
900 // check_connecting_finished(). Cheap when nothing is pending: the
901 // method returns immediately on a single flag test.
903#endif
904
905 // Post-connect roaming: check for better AP. A scan may have been started by an
906 // explicit force_roam_check() even when post_connect_roaming_ is disabled, so the
907 // scan must always be consumed here to avoid leaving roaming_state_ stuck.
908 if (this->is_roaming_scan_active()) {
909 if (this->scan_done_) {
910 this->process_roaming_scan_();
911 }
912 // else: scan in progress, wait
913 } else if (this->post_connect_roaming_ && this->roaming_state_ == RoamingState::IDLE &&
916 this->check_roaming_(now);
917 }
918 }
919 break;
920 }
922#ifdef USE_WIFI_AP_EXCLUSIVE
923 // The networks are not tried while the AP is up, so it pauses for
924 // them once nobody has used it for a while. Without networks the AP
925 // is all there is and stays.
926 if (this->has_sta() && now - this->ap_changed_ > WIFI_AP_EXCLUSIVE_DWELL_MS &&
927 (this->ap_clients_ == 0 || now - this->ap_changed_ > WIFI_AP_EXCLUSIVE_MAX_DWELL_MS))
928 this->pause_exclusive_ap_();
929 break;
930#endif
932 break;
934 return;
935 }
936
937#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP)
938 // Once the provisioning window has closed, don't bring up the fallback AP (or
939 // the captive portal on it) - the device must stay unprovisionable until it is
940 // power-cycled.
941 if (this->has_ap() && !this->ap_setup_ && !this->is_provisioning_closed_()) {
942 const uint32_t since_connected = now - this->last_connected_;
943 if (this->ap_timeout_ != 0 &&
944#ifdef USE_WIFI_AP_EXCLUSIVE
945 // After a pause, or a start that failed, the networks get a full ap_timeout before the AP is tried again
946 since_connected > this->ap_timeout_ && now - this->ap_changed_ > this->ap_timeout_
947#else
948 // Tried in whole ap_timeout slots after the last connection; after UINT8_MAX slots it is no longer tried
949 this->ap_start_failures_ != UINT8_MAX && since_connected / this->ap_timeout_ > this->ap_start_failures_
950#endif
951 ) {
952 ESP_LOGI(TAG, "Starting fallback AP");
953#ifdef USE_WIFI_AP_EXCLUSIVE
954 this->ap_changed_ = now;
955#endif
956 this->setup_ap_config_();
957#ifndef USE_WIFI_AP_EXCLUSIVE
958 if (!this->ap_setup_) {
959 // Skip the next slot too, so at least a full ap_timeout passes wherever in its slot this try ran
960 uint32_t slot = since_connected / this->ap_timeout_ + 1;
961 this->ap_start_failures_ = slot < UINT8_MAX ? slot : UINT8_MAX;
962 }
963#endif
964 this->start_ap_portal_();
965 }
966 }
967#endif // USE_WIFI_AP && !USE_WIFI_AP_WISP
968
969#ifdef USE_IMPROV_BLE
971 !improv_ble::global_improv_component->should_start()) {
972 if (now - this->last_connected_ > improv_ble::global_improv_component->get_wifi_timeout()) {
973 if (this->wifi_mode_(true, {}))
975 }
976 }
977#endif
978
979 if (this->reboot_timeout_ != 0 && now - this->last_connected_ > this->reboot_timeout_) {
980 bool suppress = false;
981#if defined(USE_WIFI_AP) && (defined(USE_RTL87XX) || defined(USE_LN882X))
982 // The Realtek driver reports no stations and the Lightning one is unverified, so a running fallback AP
983 // holds the reboot there as it always did
984 suppress = this->ap_setup_;
985#elif defined(USE_WIFI_AP)
986 // A station on the fallback AP is using the device
987 suppress = this->ap_setup_ && this->ap_clients_ != 0;
988#endif
989#ifdef USE_PROVISIONING
990 // Don't reboot while a provisioning window is pending (device unprovisioned).
991 // The device is legitimately waiting to be onboarded (Wi-Fi must come up
992 // before the controller can set credentials), and an auto-reboot would reopen
993 // the window without the deliberate power cycle / reset that is meant to be
994 // required. Resumes normal reboot behavior once provisioned.
995 suppress |= provisioning::global_provisioning_manager != nullptr &&
997#endif
998 if (!suppress) {
999 ESP_LOGE(TAG, "Can't connect; rebooting");
1000 App.reboot();
1001 }
1002 }
1003 }
1004
1005#ifdef USE_WIFI_AP_WISP
1006 // The bridge comes up once it has a resolver to offer; a start that failed is tried again after ap_timeout
1007 if (!this->ap_setup_ && this->state_ != WIFI_COMPONENT_STATE_DISABLED && this->ap_dns_known_() &&
1008 (this->ap_changed_ == 0 || now - this->ap_changed_ > this->ap_timeout_)) {
1009 ESP_LOGI(TAG, "Starting AP");
1010 this->ap_changed_ = now;
1011 this->setup_ap_config_();
1012 }
1013#endif
1014
1015#ifdef USE_WIFI_DPP
1016 // Without networks no cooldown starts the listen, so a failed or abandoned enrollment is listened for again
1017 // here, at most once per timeout
1018 if (!this->has_sta() && !this->dpp_listening_ && this->state_ != WIFI_COMPONENT_STATE_DISABLED &&
1019 now - this->dpp_listen_started_ > this->dpp_timeout_ && this->is_dpp_active_() && this->wifi_mode_(true, {})) {
1020 this->start_dpp_listen_(now);
1021 }
1022#endif
1023
1024#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
1025 // Check if power save mode needs to be updated based on high-performance requests
1026 if (this->high_performance_semaphore_ != nullptr) {
1027 // Semaphore count directly represents active requests (starts at 0, increments on request)
1028 UBaseType_t semaphore_count = uxSemaphoreGetCount(this->high_performance_semaphore_);
1029
1030 if (semaphore_count > 0 && !this->is_high_performance_mode_) {
1031 // Transition to high-performance mode (no power save)
1032 ESP_LOGV(TAG, "Switching to high-performance mode (%" PRIu32 " active %s)", (uint32_t) semaphore_count,
1033 semaphore_count == 1 ? LOG_STR_LITERAL("request") : LOG_STR_LITERAL("requests"));
1035 if (this->wifi_apply_power_save_()) {
1036 this->is_high_performance_mode_ = true;
1037 }
1038 } else if (semaphore_count == 0 && this->is_high_performance_mode_) {
1039 // Restore to configured power save mode
1040 ESP_LOGV(TAG, "Restoring power save mode to configured setting");
1041 this->power_save_ = this->configured_power_save_;
1042 if (this->wifi_apply_power_save_()) {
1043 this->is_high_performance_mode_ = false;
1044 }
1045 }
1046 }
1047#endif
1048}
1049
1051
1053 if (this->has_sta())
1054 return this->wifi_sta_ip_addresses();
1055
1056#ifdef USE_WIFI_AP
1057 if (this->has_ap())
1058 return {this->wifi_soft_ap_ip()};
1059#endif // USE_WIFI_AP
1060
1061 return {};
1062}
1064 if (this->has_sta())
1065 return this->wifi_dns_ip_(num);
1066 return {};
1067}
1068
1069#ifdef USE_WIFI_AP
1071#ifdef USE_WIFI_AP_EXCLUSIVE
1072 // The radio cannot run both, so STA goes off while the AP is up.
1073 if (!this->wifi_mode_(false, true))
1074 return;
1075#else
1076 this->wifi_mode_({}, true);
1077#endif
1078
1079 if (this->ap_setup_)
1080 return;
1081
1082 if (this->ap_.ssid_.empty()) {
1083 // Build AP SSID from app name without heap allocation; WiFi SSID max is 32 bytes
1084 static constexpr size_t AP_SSID_MAX_LEN = 32;
1085
1086 const auto &app_name = App.get_name();
1087 if (app_name.length() <= AP_SSID_MAX_LEN) {
1088 // Name fits, use directly
1089 this->ap_.set_ssid(app_name.c_str());
1090 } else {
1091 char ssid_buf[AP_SSID_MAX_LEN + 1];
1092 truncate_name_to(ssid_buf, sizeof(ssid_buf), app_name.c_str(), app_name.length(),
1093 App.is_name_add_mac_suffix_enabled() ? MAC_ADDRESS_SUFFIX_WITH_SEPARATOR_LEN : 0);
1094 this->ap_.set_ssid(ssid_buf);
1095 }
1096 }
1097 // Reset before the AP exists: afterwards only the platform's event path writes the counter
1098 this->ap_clients_ = 0;
1099 this->ap_setup_ = this->wifi_start_ap_(this->ap_);
1100 // The station path applied it in start(); without a station the radio only exists from here
1101 if (this->ap_setup_ && !this->has_sta() && !std::isnan(this->output_power_) &&
1103 ESP_LOGV(TAG, "Setting Output Power Option failed");
1104 }
1105#if defined(USE_WIFI_AP_EXCLUSIVE) || defined(USE_WIFI_AP_WISP)
1106 // A failed start must not leave the radio in AP mode: on an exclusive radio it would refuse
1107 // the STA attempts until the next try, and a bridge would take clients it cannot serve.
1108 if (!this->ap_setup_)
1109 this->wifi_mode_({}, false);
1110#endif
1111
1112 char ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
1113 ESP_LOGCONFIG(TAG,
1114 "Setting up AP:\n"
1115 " AP SSID: '%s'\n"
1116 " AP Password: '%s'\n"
1117 " IP Address: %s",
1118 this->ap_.ssid_.c_str(), this->ap_.password_.c_str(), this->wifi_soft_ap_ip().str_to(ip_buf));
1119
1120#ifdef USE_WIFI_MANUAL_IP
1121 auto manual_ip = this->ap_.get_manual_ip();
1122 if (manual_ip.has_value()) {
1123 char static_ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
1124 char gateway_buf[network::IP_ADDRESS_BUFFER_SIZE];
1125 char subnet_buf[network::IP_ADDRESS_BUFFER_SIZE];
1126 ESP_LOGCONFIG(TAG,
1127 " AP Static IP: '%s'\n"
1128 " AP Gateway: '%s'\n"
1129 " AP Subnet: '%s'",
1130 manual_ip->static_ip.str_to(static_ip_buf), manual_ip->gateway.str_to(gateway_buf),
1131 manual_ip->subnet.str_to(subnet_buf));
1132 }
1133#endif
1134
1135 // Where the AP runs on its own, the STA state machine also waits in this
1136 // state until the AP pauses.
1137 if (!this->has_sta() || (WIFI_AP_EXCLUSIVE && this->ap_setup_)) {
1139 }
1140}
1141
1142#ifdef USE_WIFI_AP_EXCLUSIVE
1144 ESP_LOGI(TAG, "Pausing AP to try the networks");
1145 // Scanning and connecting turn STA back on.
1146 this->disable_ap_();
1148 // A fresh connection cycle, as at boot, rather than resuming the one the
1149 // AP cut short.
1151 this->num_retried_ = 0;
1153}
1154#endif
1155
1156#ifdef USE_WIFI_AP_WISP
1158 this->ap_setup_ = false;
1159 this->ap_clients_ = 0; // dropped with the AP, without a disconnect event for each
1160 this->wifi_mode_({}, false);
1161}
1162#endif
1163
1165 this->end_ap_portal_();
1166 ESP_LOGD(TAG, "Disabling AP");
1167 this->wifi_mode_({}, false);
1168 // The fallback block may bring the AP back after a later disconnect; its clients dropped with it without
1169 // a disconnect event for each
1170 this->ap_setup_ = false;
1171 this->ap_clients_ = 0;
1172}
1173
1175 this->ap_ = ap;
1176 this->has_ap_ = true;
1177}
1178#endif // USE_WIFI_AP
1179
1180void WiFiComponent::init_sta(size_t count) { this->sta_.init(count); }
1181void WiFiComponent::add_sta(const WiFiAP &ap) { this->sta_.push_back(ap); }
1183 // Clear roaming state - no more configured networks
1184 this->clear_roaming_state_();
1185 this->sta_.clear();
1186 this->selected_sta_index_ = -1;
1187}
1189 this->clear_sta(); // Also clears roaming state
1190 this->init_sta(1);
1191 this->add_sta(ap);
1192 this->selected_sta_index_ = 0;
1193 // New credentials end the next cooldown early; start_connecting() clears this once they are tried
1194 this->skip_cooldown_next_cycle_ = true;
1195#ifdef USE_WIFI_LEASE_CACHE
1196 // Index 0 is a different network now, so its cached lease must not be re-applied
1197 this->clear_lease_settings_();
1198#endif
1199}
1200
1202 const WiFiAP *config = this->get_selected_sta_();
1203 if (config == nullptr) {
1204 ESP_LOGE(TAG, "No valid network config (selected_sta_index_=%d, sta_.size()=%zu)",
1205 static_cast<int>(this->selected_sta_index_), this->sta_.size());
1206 // Return empty params - caller should handle this gracefully
1207 return WiFiAP();
1208 }
1209
1210 WiFiAP params = *config;
1211
1212 switch (this->retry_phase_) {
1214#ifdef USE_WIFI_FAST_CONNECT
1216#endif
1217 // Fast connect phases: use config-only (no scan results)
1218 // BSSID/channel from config if user specified them, otherwise empty
1219 break;
1220
1223 // Hidden network mode: clear BSSID/channel to trigger probe request
1224 // (both explicit hidden and retry hidden use same behavior)
1225 params.clear_bssid();
1226 params.clear_channel();
1227 break;
1228
1230 // Scan-based phase: always use best scan result (index 0 - highest priority after sorting)
1231 if (!this->scan_result_.empty()) {
1232 apply_scan_result_to_params(params, this->scan_result_[0]);
1233 }
1234 break;
1235
1237 // Should not be building params during restart
1238 break;
1239 }
1240
1241 return params;
1242}
1243
1245 const WiFiAP *config = this->get_selected_sta_();
1246 return config ? *config : WiFiAP{};
1247}
1248void WiFiComponent::save_wifi_sta(const std::string &ssid, const std::string &password) {
1249 this->save_wifi_sta(ssid.c_str(), password.c_str());
1250}
1251void WiFiComponent::save_wifi_sta(const char *ssid, const char *password) {
1252 SavedWifiSettings save{}; // zero-initialized - all bytes set to \0, guaranteeing null termination
1253 strncpy(save.ssid, ssid, sizeof(save.ssid) - 1); // max 32 chars, byte 32 remains \0
1254 strncpy(save.password, password, sizeof(save.password) - 1); // max 64 chars, byte 64 remains \0
1255 this->pref_.save(&save);
1256 // ensure it's written immediately
1258
1259 WiFiAP sta{};
1260 sta.set_ssid(ssid);
1261 sta.set_password(password);
1262 // No retry here: a captive portal save runs in lwIP's context on the ESP8266, where retry_connect() yields and
1263 // panics. set_sta() flags the credentials and loop() tries them
1264 this->set_sta(sta);
1265#ifdef USE_WIFI_AP_EXCLUSIVE
1266 // The AP pauses for the new credentials once its answer is out, and the STA state machine picks them up
1267 if (this->ap_setup_) {
1268 this->set_timeout(AP_HANDOVER_TIMEOUT_ID, WIFI_AP_EXCLUSIVE_HANDOVER_MS, [this]() {
1269 if (this->ap_setup_)
1270 this->pause_exclusive_ap_();
1271 });
1272 }
1273#endif
1274}
1275
1277 // Log connection attempt at INFO level with priority
1278 char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
1279 int8_t priority = 0;
1280
1281 if (ap.has_bssid()) {
1282 format_mac_addr_upper(ap.get_bssid().data(), bssid_s);
1283 priority = this->get_sta_priority(ap.get_bssid());
1284 }
1285
1286 ESP_LOGI(TAG,
1287 "Connecting to " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") " (priority %d, attempt %u/%u in phase %s)...",
1288 ap.ssid_.c_str(), ap.has_bssid() ? bssid_s : LOG_STR_LITERAL("any"), priority, this->num_retried_ + 1,
1289 get_max_retries_for_phase(this->retry_phase_), LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_)));
1290
1291#ifdef ESPHOME_LOG_HAS_VERBOSE
1292 ESP_LOGV(TAG,
1293 "Connection Params:\n"
1294 " SSID: '%s'",
1295 ap.ssid_.c_str());
1296 if (ap.has_bssid()) {
1297 ESP_LOGV(TAG, " BSSID: %s", bssid_s);
1298 } else {
1299 ESP_LOGV(TAG, " BSSID: Not Set");
1300 }
1301
1302#ifdef USE_WIFI_WPA2_EAP
1303 const auto &eap_opt = ap.get_eap();
1304 if (eap_opt.has_value()) {
1305 const EAPAuth &eap_config = *eap_opt;
1306 // clang-format off
1307 ESP_LOGV(
1308 TAG,
1309 " WPA2 Enterprise authentication configured:\n"
1310 " Identity: " LOG_SECRET("'%s'") "\n"
1311 " Username: " LOG_SECRET("'%s'") "\n"
1312 " Password: " LOG_SECRET("'%s'"),
1313 eap_config.identity.c_str(), eap_config.username.c_str(), eap_config.password.c_str());
1314 // clang-format on
1315#if defined(USE_ESP32) && defined(USE_WIFI_WPA2_EAP) && ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
1316 ESP_LOGV(TAG, " TTLS Phase 2: " LOG_SECRET("'%s'"), eap_phase2_to_str(eap_config.ttls_phase_2));
1317#endif
1318 bool ca_cert_present = eap_config.ca_cert != nullptr && strlen(eap_config.ca_cert);
1319 bool client_cert_present = eap_config.client_cert != nullptr && strlen(eap_config.client_cert);
1320 bool client_key_present = eap_config.client_key != nullptr && strlen(eap_config.client_key);
1321 ESP_LOGV(TAG,
1322 " CA Cert: %s\n"
1323 " Client Cert: %s\n"
1324 " Client Key: %s",
1325 ca_cert_present ? LOG_STR_LITERAL("present") : LOG_STR_LITERAL("not present"),
1326 client_cert_present ? LOG_STR_LITERAL("present") : LOG_STR_LITERAL("not present"),
1327 client_key_present ? LOG_STR_LITERAL("present") : LOG_STR_LITERAL("not present"));
1328 } else {
1329#endif
1330 ESP_LOGV(TAG, " Password: " LOG_SECRET("'%s'"), ap.password_.c_str());
1331#ifdef USE_WIFI_WPA2_EAP
1332 }
1333#endif
1334 if (ap.has_channel()) {
1335 ESP_LOGV(TAG, " Channel: %u", ap.get_channel());
1336 } else {
1337 ESP_LOGV(TAG, " Channel not set");
1338 }
1339#ifdef USE_WIFI_MANUAL_IP
1340 auto manual_ip = ap.get_manual_ip();
1341 if (manual_ip.has_value()) {
1342 ManualIP m = *manual_ip;
1343 char static_ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
1344 char gateway_buf[network::IP_ADDRESS_BUFFER_SIZE];
1345 char subnet_buf[network::IP_ADDRESS_BUFFER_SIZE];
1346 char dns1_buf[network::IP_ADDRESS_BUFFER_SIZE];
1347 char dns2_buf[network::IP_ADDRESS_BUFFER_SIZE];
1348 ESP_LOGV(TAG, " Manual IP: Static IP=%s Gateway=%s Subnet=%s DNS1=%s DNS2=%s", m.static_ip.str_to(static_ip_buf),
1349 m.gateway.str_to(gateway_buf), m.subnet.str_to(subnet_buf), m.dns1.str_to(dns1_buf),
1350 m.dns2.str_to(dns2_buf));
1351 } else
1352#endif
1353 {
1354 ESP_LOGV(TAG, " Using DHCP IP");
1355 }
1356 ESP_LOGV(TAG, " Hidden: %s", YESNO(ap.get_hidden()));
1357#endif
1358
1359 // Clear any stale error from previous connection attempt.
1360 // This is the canonical location for clearing the flag since all connection
1361 // attempts go through start_connecting(). restart_adapter() also clears it, as it
1362 // enters COOLDOWN without calling start_connecting(), and ESP8266 clears it again
1363 // after wifi_station_connect(), whose callbacks fire synchronously.
1364 this->error_from_callback_ = false;
1365 // Every attempt passes through here, so the credentials set_sta() flagged are tried now
1366 this->skip_cooldown_next_cycle_ = false;
1367
1368 if (!this->wifi_sta_connect_(ap)) {
1369 ESP_LOGE(TAG, "wifi_sta_connect_ failed");
1370 // Enter cooldown to allow WiFi hardware to stabilize
1371 // (immediate failure suggests hardware not ready, different from connection timeout)
1372 this->start_cooldown_();
1373 return;
1374 }
1376 this->action_started_ = millis();
1377}
1378
1381 this->action_started_ = millis();
1382#ifdef USE_WIFI_DPP
1383 // The cooldown is the configurator's window; the attempt that ends it stops the listen again
1384 if (!this->dpp_listening_ && this->is_dpp_active_() && this->wifi_mode_(true, {}))
1386#endif
1387}
1388
1389const LogString *get_signal_bars(int8_t rssi) {
1390 // LOWER ONE QUARTER BLOCK
1391 // Unicode: U+2582, UTF-8: E2 96 82
1392 // LOWER HALF BLOCK
1393 // Unicode: U+2584, UTF-8: E2 96 84
1394 // LOWER THREE QUARTERS BLOCK
1395 // Unicode: U+2586, UTF-8: E2 96 86
1396 // FULL BLOCK
1397 // Unicode: U+2588, UTF-8: E2 96 88
1398 if (rssi >= -50) {
1399 return LOG_STR("\033[0;32m" // green
1400 "\xe2\x96\x82"
1401 "\xe2\x96\x84"
1402 "\xe2\x96\x86"
1403 "\xe2\x96\x88"
1404 "\033[0m");
1405 } else if (rssi >= -65) {
1406 return LOG_STR("\033[0;33m" // yellow
1407 "\xe2\x96\x82"
1408 "\xe2\x96\x84"
1409 "\xe2\x96\x86"
1410 "\033[0;37m"
1411 "\xe2\x96\x88"
1412 "\033[0m");
1413 } else if (rssi >= -85) {
1414 return LOG_STR("\033[0;33m" // yellow
1415 "\xe2\x96\x82"
1416 "\xe2\x96\x84"
1417 "\033[0;37m"
1418 "\xe2\x96\x86"
1419 "\xe2\x96\x88"
1420 "\033[0m");
1421 } else {
1422 return LOG_STR("\033[0;31m" // red
1423 "\xe2\x96\x82"
1424 "\033[0;37m"
1425 "\xe2\x96\x84"
1426 "\xe2\x96\x86"
1427 "\xe2\x96\x88"
1428 "\033[0m");
1429 }
1430}
1431
1433 bssid_t bssid = wifi_bssid();
1434 char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
1435 format_mac_addr_upper(bssid.data(), bssid_s);
1436 // Use stack buffers for IP address formatting to avoid heap allocations
1437 char ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
1438 for (auto &ip : wifi_sta_ip_addresses()) {
1439 if (ip.is_set()) {
1440 ESP_LOGCONFIG(TAG, " IP Address: %s", ip.str_to(ip_buf));
1441 }
1442 }
1443 int8_t rssi = wifi_rssi();
1444 // Use stack buffers for SSID and all IP addresses to avoid heap allocations
1445 char ssid_buf[SSID_BUFFER_SIZE];
1446 char subnet_buf[network::IP_ADDRESS_BUFFER_SIZE];
1447 char gateway_buf[network::IP_ADDRESS_BUFFER_SIZE];
1448 char dns1_buf[network::IP_ADDRESS_BUFFER_SIZE];
1449 char dns2_buf[network::IP_ADDRESS_BUFFER_SIZE];
1450 // clang-format off
1451 ESP_LOGCONFIG(TAG,
1452 " SSID: " LOG_SECRET("'%s'") "\n"
1453 " BSSID: " LOG_SECRET("%s") "\n"
1454 " Hostname: '%s'\n"
1455 " Signal strength: %d dB %s\n"
1456 " Channel: %" PRId32 "\n"
1457 " Subnet: %s\n"
1458 " Gateway: %s\n"
1459 " DNS1: %s\n"
1460 " DNS2: %s",
1461 wifi_ssid_to(ssid_buf), bssid_s, App.get_name().c_str(), rssi, LOG_STR_ARG(get_signal_bars(rssi)),
1462 get_wifi_channel(), wifi_subnet_mask_().str_to(subnet_buf), wifi_gateway_ip_().str_to(gateway_buf),
1463 wifi_dns_ip_(0).str_to(dns1_buf), wifi_dns_ip_(1).str_to(dns2_buf));
1464 // clang-format on
1465#ifdef ESPHOME_LOG_HAS_VERBOSE
1466 if (const WiFiAP *config = this->get_selected_sta_(); config && config->has_bssid()) {
1467 ESP_LOGV(TAG, " Priority: %d", this->get_sta_priority(config->get_bssid()));
1468 }
1469#endif
1470#ifdef USE_WIFI_11KV_SUPPORT
1471 ESP_LOGCONFIG(TAG,
1472 " BTM: %s\n"
1473 " RRM: %s",
1474 this->btm_ ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"),
1475 this->rrm_ ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"));
1476#endif
1477}
1478
1481 return;
1482
1483 ESP_LOGD(TAG, "Enabling");
1485#ifdef USE_ESP32
1486 // Idempotent — only allocates DMA buffers + netifs on the first call. After this,
1487 // start() can safely run.
1488 this->wifi_lazy_init_();
1489#endif
1490 this->start();
1491}
1492
1495 return;
1496
1497 ESP_LOGD(TAG, "Disabling");
1499#ifdef USE_WIFI_LEASE_CACHE
1500 this->cancel_timeout(LEASE_RENEW_TIMEOUT_ID);
1501#endif
1502#ifdef USE_WIFI_DPP
1503 this->stop_dpp_listen_();
1504#endif
1505 this->wifi_disconnect_();
1506 this->wifi_mode_(false, false);
1507#ifdef USE_WIFI_AP
1508 // The AP went down with the radio; enable() has to configure it, and its portal, again
1509 this->end_ap_portal_();
1510 this->ap_setup_ = false;
1511 this->ap_clients_ = 0;
1512#endif
1513#ifdef USE_WIFI_AP_WISP
1514 this->ap_changed_ = 0; // at once, not after ap_timeout
1515#endif
1516}
1517
1519 this->action_started_ = millis();
1520 ESP_LOGD(TAG, "Starting scan");
1521 this->wifi_scan_start_(this->passive_scan_);
1523}
1524
1558[[nodiscard]] inline static bool wifi_scan_result_is_better(const WiFiScanResult &a, const WiFiScanResult &b) {
1559 // Matching networks always come before non-matching
1560 if (a.get_matches() && !b.get_matches())
1561 return true;
1562 if (!a.get_matches() && b.get_matches())
1563 return false;
1564
1565 // Both matching: check priority first (tracks connection failures via priority degradation)
1566 // Priority is decreased when a BSSID fails to connect, so lower priority = previously failed
1567 if (a.get_matches() && b.get_matches() && a.get_priority() != b.get_priority()) {
1568 return a.get_priority() > b.get_priority();
1569 }
1570
1571 // Use RSSI as tiebreaker (for equal-priority matching networks or all non-matching networks)
1572 return a.get_rssi() > b.get_rssi();
1573}
1574
1575// Helper function for insertion sort of WiFi scan results
1576// Using insertion sort instead of std::stable_sort saves flash memory
1577// by avoiding template instantiations (std::rotate, std::stable_sort, lambdas)
1578// IMPORTANT: This sort is stable (preserves relative order of equal elements)
1579//
1580// Uses raw memcpy instead of copy assignment to avoid CompactString's
1581// destructor/constructor overhead (heap delete[]/new[] for long SSIDs).
1582// Copy assignment calls ~CompactString() then placement-new for every shift,
1583// which means delete[]/new[] per shift for heap-allocated SSIDs. With 70+
1584// networks (e.g., captive portal showing full scan results), this caused
1585// event loop blocking from hundreds of heap operations in a tight loop.
1586//
1587// This is safe because we're permuting elements within the same array —
1588// each slot is overwritten exactly once, so no ownership duplication occurs.
1589// All members of WiFiScanResult are either trivially copyable (bssid, channel,
1590// rssi, priority, flags) or CompactString, which stores either inline data or
1591// a heap pointer — never a self-referential pointer (unlike std::string's SSO
1592// on some implementations). This was not possible before PR#13472 replaced
1593// std::string with CompactString, since std::string's internal layout is
1594// implementation-defined and may use self-referential pointers.
1595//
1596// TODO: If C++ standardizes std::trivially_relocatable, add the assertion for
1597// WiFiScanResult/CompactString here to formally express the memcpy safety guarantee.
1598template<typename VectorType> static void insertion_sort_scan_results(VectorType &results) {
1599 // memcpy-based sort requires no self-referential pointers or virtual dispatch.
1600 // These static_asserts guard the assumptions. If any fire, the memcpy sort
1601 // must be reviewed for safety before updating the expected values.
1602 //
1603 // No vtable pointers (memcpy would corrupt vptr)
1604 static_assert(!std::is_polymorphic<WiFiScanResult>::value, "WiFiScanResult must not have vtable");
1605 static_assert(!std::is_polymorphic<CompactString>::value, "CompactString must not have vtable");
1606 // Standard layout ensures predictable memory layout with no virtual bases
1607 // and no mixed-access-specifier reordering
1608 static_assert(std::is_standard_layout<WiFiScanResult>::value, "WiFiScanResult must be standard layout");
1609 static_assert(std::is_standard_layout<CompactString>::value, "CompactString must be standard layout");
1610 // Size checks catch added/removed fields that may need safety review
1611 static_assert(sizeof(WiFiScanResult) == 32, "WiFiScanResult size changed - verify memcpy sort is still safe");
1612 static_assert(sizeof(CompactString) == 20, "CompactString size changed - verify memcpy sort is still safe");
1613 // Alignment must match for reinterpret_cast of key_buf to be valid
1614 static_assert(alignof(WiFiScanResult) <= alignof(std::max_align_t), "WiFiScanResult alignment exceeds max_align_t");
1615 const size_t size = results.size();
1616 constexpr size_t elem_size = sizeof(WiFiScanResult);
1617 // Suppress warnings for intentional memcpy on non-trivially-copyable type.
1618 // Safety is guaranteed by the static_asserts above and the permutation invariant.
1619 // NOLINTNEXTLINE(bugprone-undefined-memory-manipulation)
1620 auto *memcpy_fn = &memcpy;
1621 for (size_t i = 1; i < size; i++) {
1622 alignas(WiFiScanResult) uint8_t key_buf[elem_size];
1623 memcpy_fn(key_buf, &results[i], elem_size);
1624 const auto &key = *reinterpret_cast<const WiFiScanResult *>(key_buf);
1625 int32_t j = i - 1;
1626
1627 // Move elements that are worse than key to the right
1628 // For stability, we only move if key is strictly better than results[j]
1629 while (j >= 0 && wifi_scan_result_is_better(key, results[j])) {
1630 memcpy_fn(&results[j + 1], &results[j], elem_size);
1631 j--;
1632 }
1633 memcpy_fn(&results[j + 1], key_buf, elem_size);
1634 }
1635}
1636
1637// Helper function to log matching scan results - marked noinline to prevent re-inlining into loop
1638//
1639// IMPORTANT: This function deliberately uses a SINGLE log call to minimize blocking.
1640// In environments with many matching networks (e.g., 18+ mesh APs), multiple log calls
1641// per network would block the main loop for an unacceptable duration. Each log call
1642// has overhead from UART transmission, so combining INFO+DEBUG into one line halves
1643// the blocking time. Do NOT split this into separate ESP_LOGI/ESP_LOGD calls.
1644__attribute__((noinline)) static void log_scan_result(const WiFiScanResult &res) {
1645 char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
1646 auto bssid = res.get_bssid();
1647 format_mac_addr_upper(bssid.data(), bssid_s);
1648
1649#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG
1650 // Single combined log line with all details when DEBUG enabled
1651 ESP_LOGI(TAG, "- '%s' %s" LOG_SECRET("(%s) ") "%s Ch:%2u %3ddB P:%d", res.get_ssid().c_str(),
1652 res.get_is_hidden() ? LOG_STR_LITERAL("(HIDDEN) ") : LOG_STR_LITERAL(""), bssid_s,
1653 LOG_STR_ARG(get_signal_bars(res.get_rssi())), res.get_channel(), res.get_rssi(), res.get_priority());
1654#else
1655 ESP_LOGI(TAG, "- '%s' %s" LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(),
1656 res.get_is_hidden() ? LOG_STR_LITERAL("(HIDDEN) ") : LOG_STR_LITERAL(""), bssid_s,
1657 LOG_STR_ARG(get_signal_bars(res.get_rssi())));
1658#endif
1659}
1660
1662 if (!this->scan_done_) {
1663 if (millis() - this->action_started_ > WIFI_SCAN_TIMEOUT_MS) {
1664 ESP_LOGE(TAG, "Scan timeout");
1665 this->retry_connect();
1666 }
1667 return;
1668 }
1669 this->scan_done_ = false;
1670 // A driver filtered scan saw one SSID; a portal that started during it still needs a full scan
1673
1674 if (this->scan_result_.empty()) {
1675 ESP_LOGW(TAG, "No networks found");
1676 this->retry_connect();
1677 return;
1678 }
1679
1680 ESP_LOGD(TAG, "Found networks:");
1681 {
1682 ScanResultsLock lock(this);
1683 for (auto &res : this->scan_result_) {
1684 for (auto &ap : this->sta_) {
1685 if (res.matches(ap)) {
1686 res.set_matches(true);
1687 // Cache priority lookup - do single search instead of 2 separate searches
1688 const bssid_t &bssid = res.get_bssid();
1689 if (!this->has_sta_priority(bssid)) {
1690 this->set_sta_priority(bssid, ap.get_priority());
1691 }
1692 res.set_priority(this->get_sta_priority(bssid));
1693 break;
1694 }
1695 }
1696 }
1697
1698 // Sort scan results using insertion sort for better memory efficiency
1699 insertion_sort_scan_results(this->scan_result_);
1700 }
1701
1702 // Log matching networks (non-matching already logged at VERBOSE in scan callback)
1703 for (auto &res : this->scan_result_) {
1704 if (res.get_matches()) {
1705 log_scan_result(res);
1706 }
1707 }
1708
1709 // SYNCHRONIZATION POINT: Establish link between scan_result_[0] and selected_sta_index_
1710 // After sorting, scan_result_[0] contains the best network. Now find which sta_[i] config
1711 // matches that network and record it in selected_sta_index_. This keeps the two indices
1712 // synchronized so build_params_for_current_phase_() can safely use both to build connection parameters.
1713 const WiFiScanResult &scan_res = this->scan_result_[0];
1714 bool found_match = false;
1715 if (scan_res.get_matches()) {
1716 for (size_t i = 0; i < this->sta_.size(); i++) {
1717 if (scan_res.matches(this->sta_[i])) {
1718 // Safe cast: sta_.size() limited to MAX_WIFI_NETWORKS (127) in __init__.py validation
1719 // No overflow check needed - YAML validation prevents >127 networks
1720 this->selected_sta_index_ = static_cast<int8_t>(i); // Links scan_result_[0] with sta_[i]
1721 found_match = true;
1722 break;
1723 }
1724 }
1725 }
1726
1727 if (!found_match) {
1728 ESP_LOGW(TAG, "No matching network found");
1729 // No scan results matched our configured networks - transition directly to hidden mode
1730 // Don't call retry_connect() since we never attempted a connection (no BSSID to penalize)
1732 // If no hidden networks to try, skip connection attempt (will be handled on next loop)
1733 if (this->selected_sta_index_ == -1) {
1734 return;
1735 }
1736 // Now start connection attempt in hidden mode
1738 return; // scan started, wait for next loop iteration
1739 }
1740
1741 yield();
1742
1743 WiFiAP params = this->build_params_for_current_phase_();
1744 // Ensure we're in SCAN_CONNECTING phase when connecting with scan results
1745 // (needed when scan was started directly without transition_to_phase_, e.g., initial scan)
1746 this->start_connecting(params);
1747}
1748
1750 char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
1751 ESP_LOGCONFIG(TAG,
1752 "WiFi:\n"
1753 " Local MAC: %s\n"
1754 " Connected: %s",
1755 get_mac_address_pretty_into_buffer(mac_s), YESNO(this->is_connected()));
1756 if (this->is_disabled()) {
1757 ESP_LOGCONFIG(TAG, " Disabled");
1758 return;
1759 }
1760#ifdef USE_WIFI_DPP
1761 ESP_LOGCONFIG(TAG,
1762 " DPP channels: %s\n"
1763 " DPP timeout: %" PRIu32 " ms",
1764 this->dpp_channels_, this->dpp_timeout_);
1765#endif
1766#if defined(USE_ESP32) && defined(SOC_WIFI_SUPPORT_5G)
1767 const char *band_mode_s;
1768 switch (this->band_mode_) {
1769 case WIFI_BAND_MODE_2G_ONLY:
1770 band_mode_s = "2.4GHz";
1771 break;
1772 case WIFI_BAND_MODE_5G_ONLY:
1773 band_mode_s = "5GHz";
1774 break;
1775 case WIFI_BAND_MODE_AUTO:
1776 default:
1777 band_mode_s = "Auto";
1778 break;
1779 }
1780 ESP_LOGCONFIG(TAG, " Band Mode: %s", band_mode_s);
1781#endif
1782#ifdef USE_WIFI_PHY_MODE
1783 ESP_LOGCONFIG(TAG, " PHY Mode: %s", LOG_STR_ARG(phy_mode_to_log_string(this->phy_mode_)));
1784#endif
1785 if (this->is_connected()) {
1786 this->print_connect_params_();
1787 }
1788}
1789
1791 auto status = this->wifi_sta_connect_status_();
1792
1794 char ssid_buf[SSID_BUFFER_SIZE];
1795 if (wifi_ssid_to(ssid_buf)[0] == '\0') {
1796 ESP_LOGW(TAG, "Connection incomplete");
1797 this->retry_connect();
1798 return;
1799 }
1800
1801 ESP_LOGI(TAG, "Connected");
1802 // Warn if we had to retry with hidden network mode for a network that's not marked hidden
1803 // Only warn if we actually connected without scan data (SSID only), not if scan succeeded on retry
1804 if (const WiFiAP *config = this->get_selected_sta_(); this->retry_phase_ == WiFiRetryPhase::RETRY_HIDDEN &&
1805 config && !config->get_hidden() &&
1806 this->scan_result_.empty()) {
1807 ESP_LOGW(TAG, LOG_SECRET("'%s'") " should be marked hidden", config->ssid_.c_str());
1808 }
1809 // Reset to initial phase on successful connection (don't log transition, just reset state)
1811 this->num_retried_ = 0;
1812#if defined(USE_WIFI_AP) && !defined(USE_WIFI_AP_WISP) // the bridge AP stays up for its clients
1813 if (this->has_ap()) {
1814 this->disable_ap_();
1815 }
1816#endif
1817#ifdef USE_WIFI_DPP
1818 this->stop_dpp_listen_();
1819#endif
1820#ifdef USE_IMPROV_BLE
1821 if (this->is_improv_ble_active_()) {
1823 }
1824#endif
1825
1826 // The fallback and reboot checks later in this pass must see the connection, not the old timestamp
1827 this->set_last_connected_(now);
1829 // Refresh is_connected() cache; loop()'s refresh ran before this transition.
1831 this->num_retried_ = 0;
1832 this->print_connect_params_();
1833
1834 // Reset roaming state on successful connection
1835 this->roaming_last_check_ = now;
1836 // Only preserve attempts if reconnecting after a failed roam attempt
1837 // This prevents ping-pong between APs when a roam target is unreachable
1839 // Successful roam to better AP on first try - reset attempts so we can roam again later
1840 ESP_LOGD(TAG, "Roam successful");
1841 this->roaming_attempts_ = 0;
1842 } else if (this->roaming_state_ == RoamingState::RECONNECTING) {
1843 // Check if we ended up on the roam target despite needing a retry
1844 // (e.g., first connect failed but scan-based retry found and connected to the same better AP)
1845 bssid_t current_bssid = this->wifi_bssid();
1846 if (this->roaming_target_bssid_ != bssid_t{} && current_bssid == this->roaming_target_bssid_) {
1847 char bssid_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
1848 format_mac_addr_upper(current_bssid.data(), bssid_buf);
1849 ESP_LOGD(TAG, "Roam successful (via retry, attempt %u/%u) to %s", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS,
1850 bssid_buf);
1851 this->roaming_attempts_ = 0;
1852 } else if (this->roaming_target_bssid_ != bssid_t{}) {
1853 // Failed roam to specific target, reconnected to different AP - keep attempts to prevent ping-pong
1854 ESP_LOGD(TAG, "Reconnected after failed roam (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
1855 } else {
1856 // Reconnected after scan-induced disconnect (no roam target) - keep attempts
1857 ESP_LOGD(TAG, "Reconnected after roam scan (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
1858 }
1859 } else {
1860 // Normal connection (boot, credentials changed, etc.)
1861 this->roaming_attempts_ = 0;
1862 }
1864 this->roaming_target_bssid_ = {};
1865 this->roaming_scan_end_ = 0;
1866
1867 // Clear all priority penalties - the next reconnect will happen when an AP disconnects,
1868 // which means the landscape has likely changed and previous tracked failures are stale
1870
1871#ifdef USE_WIFI_FAST_CONNECT
1873#endif
1874
1875 this->release_scan_results_();
1876
1877#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
1878 // Notify listeners now that state machine has reached STA_CONNECTED
1879 // This ensures wifi.connected condition returns true in listener automations
1881#endif
1882
1883#if defined(USE_ESP8266) && defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP)
1884 // On ESP8266, GOT_IP event may not fire for static IP configurations,
1885 // so notify IP state listeners here as a fallback.
1886 if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) {
1888 }
1889#endif
1890
1891 return;
1892 }
1893
1894 if (now - this->action_started_ > WIFI_CONNECT_TIMEOUT_MS) {
1895 ESP_LOGW(TAG, "Connection timeout, aborting connection attempt");
1896 this->wifi_disconnect_();
1897 this->retry_connect();
1898 return;
1899 }
1900
1901 if (this->error_from_callback_) {
1902 // ESP8266: logging done in callback, listeners deferred via pending_.disconnect
1903 // Other platforms: just log generic failure message
1904#ifndef USE_ESP8266
1905 ESP_LOGW(TAG, "Connecting to network failed (callback)");
1906#endif
1907 this->retry_connect();
1908 return;
1909 }
1910
1912 return;
1913 }
1914
1916 ESP_LOGW(TAG, "Network no longer found");
1917 this->retry_connect();
1918 return;
1919 }
1920
1922 ESP_LOGW(TAG, "Connecting to network failed");
1923 this->retry_connect();
1924 return;
1925 }
1926
1927 ESP_LOGW(TAG, "Unknown connection status %d", (int) status);
1928 this->retry_connect();
1929}
1930
1938 switch (this->retry_phase_) {
1940#ifdef USE_WIFI_FAST_CONNECT
1942 // INITIAL_CONNECT and FAST_CONNECT_CYCLING_APS: no retries, try next AP or fall back to scan
1943 if (this->selected_sta_index_ < static_cast<int8_t>(this->sta_.size()) - 1) {
1944 return WiFiRetryPhase::FAST_CONNECT_CYCLING_APS; // Move to next AP
1945 }
1946#endif
1947 // Check if we should try explicit hidden networks before scanning
1948 // This handles reconnection after connection loss where first network is hidden
1949 if (!this->sta_.empty() && this->sta_[0].get_hidden()) {
1951 }
1952 // No more APs to try, fall back to scan
1954
1956 // Try all explicitly hidden networks before scanning
1957 if (this->num_retried_ + 1 < WIFI_RETRY_COUNT_PER_SSID) {
1958 return WiFiRetryPhase::EXPLICIT_HIDDEN; // Keep retrying same SSID
1959 }
1960
1961 // Exhausted retries on current SSID - check for more explicitly hidden networks
1962 // Stop when we reach a visible network (proceed to scanning)
1963 size_t next_index = this->selected_sta_index_ + 1;
1964 if (next_index < this->sta_.size() && this->sta_[next_index].get_hidden()) {
1965 // Found another explicitly hidden network
1967 }
1968
1969 // No more consecutive explicitly hidden networks
1970 // If ALL networks are hidden, skip scanning and go directly to restart
1971 if (this->find_first_non_hidden_index_() < 0) {
1973 }
1974 // Otherwise proceed to scanning for non-hidden networks
1976 }
1977
1979 // If scan found no networks or no matching networks, skip to hidden network mode
1980 if (this->scan_result_.empty() || !this->scan_result_[0].get_matches()) {
1982 }
1983
1984 if (this->num_retried_ + 1 < WIFI_RETRY_COUNT_PER_BSSID) {
1985 return WiFiRetryPhase::SCAN_CONNECTING; // Keep retrying same BSSID
1986 }
1987
1988 // Exhausted retries on current BSSID (scan_result_[0])
1989 // Its priority has been decreased, so on next scan it will be sorted lower
1990 // and we'll try the next best BSSID.
1991 // Check if there are any potentially hidden networks to try
1992 if (this->find_next_hidden_sta_(-1) >= 0) {
1993 return WiFiRetryPhase::RETRY_HIDDEN; // Found hidden networks to try
1994 }
1995 // No hidden networks - always go through RESTARTING_ADAPTER phase
1996 // This ensures num_retried_ gets reset and a fresh scan is triggered
1997 // The actual adapter restart will be skipped if DPP/captive portal/improv is active
1999
2001 // If no hidden SSIDs to try (selected_sta_index_ == -1), skip directly to rescan
2002 if (this->selected_sta_index_ >= 0) {
2003 if (this->num_retried_ + 1 < WIFI_RETRY_COUNT_PER_SSID) {
2004 return WiFiRetryPhase::RETRY_HIDDEN; // Keep retrying same SSID
2005 }
2006
2007 // Exhausted retries on current SSID - check if there are more potentially hidden SSIDs to try
2008 if (this->selected_sta_index_ < static_cast<int8_t>(this->sta_.size()) - 1) {
2009 // Check if find_next_hidden_sta_() would actually find another hidden SSID
2010 // as it might have been seen in the scan results and we want to skip those
2011 // otherwise we will get stuck in RETRY_HIDDEN phase
2012 if (this->find_next_hidden_sta_(this->selected_sta_index_) != -1) {
2013 // More hidden SSIDs available - stay in RETRY_HIDDEN, advance will happen in retry_connect()
2015 }
2016 }
2017 }
2018 // Exhausted all potentially hidden SSIDs - always go through RESTARTING_ADAPTER
2019 // This ensures num_retried_ gets reset and a fresh scan is triggered
2020 // The actual adapter restart will be skipped if DPP/captive portal/improv is active
2022
2024 // After restart, go back to explicit hidden if we went through it initially
2027 }
2028 // Skip scanning when captive portal/improv is active to avoid disrupting AP,
2029 // BUT only if we've already completed at least one scan AFTER the portal started.
2030 // When captive portal first starts, scan results may be filtered/stale, so we need
2031 // to do one full scan to populate available networks for the captive portal UI.
2032 //
2033 // WHY SCANNING DISRUPTS AP MODE:
2034 // WiFi scanning requires the radio to leave the AP's channel and hop through
2035 // other channels to listen for beacons. During this time (even for passive scans),
2036 // the AP cannot service connected clients - they experience disconnections or
2037 // timeouts. On ESP32, even passive scans cause brief but noticeable disruptions
2038 // that break captive portal HTTP requests and DNS lookups.
2039 //
2040 // BLIND RETRY MODE:
2041 // When captive portal/improv is active, we use RETRY_HIDDEN as a "try all networks
2042 // blindly" mode. Since retry_hidden_mode_ is set to BLIND_RETRY (in RESTARTING_ADAPTER
2043 // transition), find_next_hidden_sta_() will treat ALL configured networks as
2044 // candidates, cycling through them without requiring scan results.
2045 //
2046 // This allows users to configure WiFi via captive portal while the device keeps
2047 // attempting to connect to all configured networks in sequence.
2048 // Captive portal needs scan results to show available networks.
2049 // If captive portal is active, only skip scanning if we've done a scan after it started.
2050 // If only DPP or improv is active (no captive portal), skip scanning since neither needs results.
2051 if (this->is_captive_portal_active_()) {
2054 }
2055 // Need to scan for captive portal
2056 } else if (this->is_improv_ble_active_() || this->is_dpp_active_()) {
2057 // Improv and DPP don't need scan results
2059 }
2061 }
2062
2063 // Should never reach here
2065}
2066
2077 WiFiRetryPhase old_phase = this->retry_phase_;
2078
2079 // No-op if staying in same phase
2080 if (old_phase == new_phase) {
2081 return false;
2082 }
2083
2084 ESP_LOGD(TAG, "Retry phase: %s → %s", LOG_STR_ARG(retry_phase_to_log_string(old_phase)),
2085 LOG_STR_ARG(retry_phase_to_log_string(new_phase)));
2086
2087 this->retry_phase_ = new_phase;
2088 this->num_retried_ = 0; // Reset retry counter on phase change
2089
2090 // Phase-specific setup
2091 switch (new_phase) {
2092#ifdef USE_WIFI_FAST_CONNECT
2094 // Move to next configured AP - clear old scan data so new AP is tried with config only
2095 this->selected_sta_index_++;
2096 ScanResultsLock lock(this);
2097 this->scan_result_.clear();
2098 break;
2099 }
2100#endif
2101
2103 // Starting explicit hidden phase - reset to first network
2104 this->selected_sta_index_ = 0;
2105 break;
2106
2108 // Transitioning to scan-based connection
2109#ifdef USE_WIFI_FAST_CONNECT
2111 ESP_LOGI(TAG, "Fast connect exhausted, falling back to scan");
2112 }
2113#endif
2114 // Trigger scan if we don't have scan results OR if transitioning from phases that need fresh scan
2115 if (this->scan_result_.empty() || old_phase == WiFiRetryPhase::EXPLICIT_HIDDEN ||
2117 this->selected_sta_index_ = -1; // Will be set after scan completes
2118 this->start_scanning();
2119 return true; // Started scan, wait for completion
2120 }
2121 // Already have scan results - selected_sta_index_ should already be synchronized
2122 // (set in check_scanning_finished() when scan completed)
2123 // No need to reset it here
2124 break;
2125
2127 // Always reset to first candidate when entering this phase.
2128 // This phase can be entered from:
2129 // - SCAN_CONNECTING: normal flow, find_next_hidden_sta_() skips networks visible in scan
2130 // - RESTARTING_ADAPTER: captive portal active, find_next_hidden_sta_() tries ALL networks
2131 //
2132 // The retry_hidden_mode_ controls the behavior:
2133 // - SCAN_BASED: scan_result_ is checked, visible networks are skipped
2134 // - BLIND_RETRY: scan_result_ is ignored, all networks become candidates
2135 // We don't clear scan_result_ here - the mode controls whether it's consulted.
2137
2138 if (this->selected_sta_index_ == -1) {
2139 ESP_LOGD(TAG, "All SSIDs visible or already tried, skipping hidden mode");
2140 }
2141 break;
2142
2144 // Skip actual adapter restart if a portal/improv is active
2145 // This allows state machine to reset num_retried_ and trigger fresh scan
2146 // without disrupting the portal/improv connection
2147 if (!this->is_provisioning_active_()) {
2148 this->restart_adapter();
2149 } else {
2150 // Even when skipping full restart, disconnect to clear driver state
2151 // Without this, platforms like LibreTiny may think we're still connecting
2152 this->wifi_disconnect_();
2153 }
2154 // Clear scan flag - we're starting a new retry cycle
2155 // This is critical for DPP/captive portal/improv flow: when determine_next_phase_()
2156 // returns RETRY_HIDDEN (because scanning is skipped), find_next_hidden_sta_()
2157 // will see BLIND_RETRY mode and treat ALL networks as candidates,
2158 // effectively cycling through all configured networks without scan results.
2160 // Always enter cooldown after restart (or skip-restart) to allow stabilization
2161 // Use extended cooldown when AP is active to avoid constant scanning that blocks DNS
2162 this->start_cooldown_();
2163 // Return true to indicate we should wait (go to COOLDOWN) instead of immediately connecting
2164 return true;
2165
2166 default:
2167 break;
2168 }
2169
2170 return false; // Did not start scan, can proceed with connection
2171}
2172
2174 if (!this->sta_priorities_.empty()) {
2175 decltype(this->sta_priorities_)().swap(this->sta_priorities_);
2176 }
2177}
2178
2183 if (this->sta_priorities_.empty()) {
2184 return;
2185 }
2186
2187 int8_t first_priority = this->sta_priorities_[0].priority;
2188
2189 // Only clear if all priorities have been decremented to the minimum value
2190 // At this point, all BSSIDs have been equally penalized and priority info is useless
2191 if (first_priority != std::numeric_limits<int8_t>::min()) {
2192 return;
2193 }
2194
2195 for (const auto &pri : this->sta_priorities_) {
2196 if (pri.priority != first_priority) {
2197 return; // Not all same, nothing to do
2198 }
2199 }
2200
2201 // All priorities are at minimum - clear the vector to save memory and reset
2202 ESP_LOGD(TAG, "Clearing BSSID priorities (all at minimum)");
2204}
2205
2225 // Determine which BSSID we tried to connect to
2226 optional<bssid_t> failed_bssid;
2227
2228 if (this->retry_phase_ == WiFiRetryPhase::SCAN_CONNECTING && !this->scan_result_.empty()) {
2229 // Scan-based phase: always use best result (index 0)
2230 failed_bssid = this->scan_result_[0].get_bssid();
2231 } else if (const WiFiAP *config = this->get_selected_sta_(); config && config->has_bssid()) {
2232 // Config has specific BSSID (fast_connect or user-specified)
2233 failed_bssid = config->get_bssid();
2234 }
2235
2236 if (!failed_bssid.has_value()) {
2237 return; // No BSSID to penalize
2238 }
2239
2240 // Get SSID for logging (use pointer to avoid copy)
2241 const char *ssid = nullptr;
2242 if (this->retry_phase_ == WiFiRetryPhase::SCAN_CONNECTING && !this->scan_result_.empty()) {
2243 ssid = this->scan_result_[0].ssid_.c_str();
2244 } else if (const WiFiAP *config = this->get_selected_sta_()) {
2245 ssid = config->ssid_.c_str();
2246 }
2247
2248 // Only decrease priority on the last attempt for this phase
2249 // This prevents false positives from transient WiFi stack issues
2250 uint8_t max_retries = get_max_retries_for_phase(this->retry_phase_);
2251 bool is_last_attempt = (this->num_retried_ + 1 >= max_retries);
2252
2253 // Decrease priority only on last attempt to avoid false positives from transient failures
2254 int8_t old_priority = this->get_sta_priority(failed_bssid.value());
2255 int8_t new_priority = old_priority;
2256
2257 if (is_last_attempt) {
2258 // Decrease priority, but clamp to int8_t::min to prevent overflow
2259 new_priority =
2260 (old_priority > std::numeric_limits<int8_t>::min()) ? (old_priority - 1) : std::numeric_limits<int8_t>::min();
2261 this->set_sta_priority(failed_bssid.value(), new_priority);
2262 }
2263 char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
2264 format_mac_addr_upper(failed_bssid.value().data(), bssid_s);
2265 ESP_LOGD(TAG, "Failed " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") ", priority %d → %d", ssid != nullptr ? ssid : "",
2266 bssid_s, old_priority, new_priority);
2267
2268 // After adjusting priority, check if all priorities are now at minimum
2269 // If so, clear the vector to save memory and reset for fresh start
2271}
2272
2284 WiFiRetryPhase current_phase = this->retry_phase_;
2285
2286 // Check if we need to advance to next AP/SSID within the same phase
2287#ifdef USE_WIFI_FAST_CONNECT
2288 if (current_phase == WiFiRetryPhase::FAST_CONNECT_CYCLING_APS) {
2289 // Fast connect: always advance to next AP (no retries per AP)
2290 this->selected_sta_index_++;
2291 this->num_retried_ = 0;
2292 ESP_LOGD(TAG, "Next AP in %s", LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_)));
2293 return;
2294 }
2295#endif
2296
2297 if (current_phase == WiFiRetryPhase::EXPLICIT_HIDDEN && this->num_retried_ + 1 >= WIFI_RETRY_COUNT_PER_SSID) {
2298 // Explicit hidden: exhausted retries on current SSID, find next explicitly hidden network
2299 // Stop when we reach a visible network (proceed to scanning)
2300 size_t next_index = this->selected_sta_index_ + 1;
2301 if (next_index < this->sta_.size() && this->sta_[next_index].get_hidden()) {
2302 this->selected_sta_index_ = static_cast<int8_t>(next_index);
2303 this->num_retried_ = 0;
2304 ESP_LOGD(TAG, "Next explicit hidden network at index %d", static_cast<int>(next_index));
2305 return;
2306 }
2307 // No more consecutive explicit hidden networks found - fall through to trigger phase change
2308 }
2309
2310 if (current_phase == WiFiRetryPhase::RETRY_HIDDEN && this->num_retried_ + 1 >= WIFI_RETRY_COUNT_PER_SSID) {
2311 // Hidden mode: exhausted retries on current SSID, find next potentially hidden SSID
2312 // If first network is marked hidden, we went through EXPLICIT_HIDDEN phase
2313 // In that case, skip networks marked hidden:true (already tried)
2314 // Otherwise, include them (they haven't been tried yet)
2315 int8_t next_index = this->find_next_hidden_sta_(this->selected_sta_index_);
2316 if (next_index != -1) {
2317 // Found another potentially hidden SSID
2318 this->selected_sta_index_ = next_index;
2319 this->num_retried_ = 0;
2320 return;
2321 }
2322 // No more potentially hidden SSIDs - set selected_sta_index_ to -1 to trigger phase change
2323 // This ensures determine_next_phase_() will skip the RETRY_HIDDEN logic and transition out
2324 this->selected_sta_index_ = -1;
2325 // Return early - phase change will happen on next wifi_loop() iteration
2326 return;
2327 }
2328
2329 // Don't increment retry counter if we're in a scan phase with no valid targets
2330 if (this->needs_scan_results_()) {
2331 return;
2332 }
2333
2334 // Increment retry counter to try the same target again
2335 this->num_retried_++;
2336 ESP_LOGD(TAG, "Retry attempt %u/%u in phase %s", this->num_retried_ + 1,
2337 get_max_retries_for_phase(this->retry_phase_), LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_)));
2338}
2339
2341 // Handle roaming state transitions - preserve attempts counter to prevent ping-pong
2342 // to unreachable APs after ROAMING_MAX_ATTEMPTS failures
2344 // Roam connection failed - transition to reconnecting
2345 ESP_LOGD(TAG, "Roam failed, reconnecting (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
2347 } else if (this->is_roaming_scan_active()) {
2348 // Disconnected during roam scan - transition to RECONNECTING so the attempts
2349 // counter is preserved when reconnection succeeds (IDLE would reset it)
2350 ESP_LOGD(TAG, "Disconnected during roam scan (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
2352 } else if (this->roaming_state_ == RoamingState::IDLE) {
2353 // Check if a roaming scan recently completed - on ESP8266, going off-channel
2354 // during scan can cause a delayed Beacon Timeout 8-20 seconds after scan finishes.
2355 // Transition to RECONNECTING so the attempts counter is preserved on reconnect.
2357 ESP_LOGD(TAG, "Disconnect after roam scan (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
2359 } else {
2360 // Not a roaming-triggered reconnect, reset state
2361 this->clear_roaming_state_();
2362 }
2363 }
2364 // RECONNECTING: keep state and counter, still trying to reconnect
2365
2367
2368 // Determine next retry phase based on current state
2369 WiFiRetryPhase current_phase = this->retry_phase_;
2370 WiFiRetryPhase next_phase = this->determine_next_phase_();
2371
2372 // Handle phase transitions (transition_to_phase_ handles same-phase no-op internally)
2373 if (this->transition_to_phase_(next_phase)) {
2374 return; // Scan started or adapter restarted (which sets its own state)
2375 }
2376
2377 if (next_phase == current_phase) {
2379 }
2380
2381 yield();
2382 // Check if we have a valid target before building params
2383 // After exhausting all networks in a phase, selected_sta_index_ may be -1
2384 // In that case, skip connection and let next wifi_loop() handle phase transition
2385 if (this->selected_sta_index_ >= 0) {
2386 WiFiAP params = this->build_params_for_current_phase_();
2387 this->start_connecting(params);
2388 }
2389}
2390
2392 this->power_save_ = power_save;
2393#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
2394 this->configured_power_save_ = power_save;
2395#endif
2396}
2397
2398#ifdef USE_WIFI_DPP
2403
2405 if (!this->dpp_listening_)
2406 return;
2407 this->wifi_dpp_stop_listen_();
2408 this->dpp_listening_ = false;
2409}
2410#endif
2411
2413#ifdef USE_WIFI_DPP
2414 // A configurator is waited for once the networks have had their timeout, or from boot without any; the retry
2415 // machine then treats DPP like a portal (blind retries, a long cooldown) whether the supplicant is listening at
2416 // this moment or not
2417 return !this->is_provisioning_closed_() &&
2419#else
2420 return false;
2421#endif
2422}
2423
2425#ifdef USE_CAPTIVE_PORTAL
2427#else
2428 return false;
2429#endif
2430}
2431
2433#ifdef USE_WEBSERVER_CAPTIVE
2434 if (web_server::global_web_server->is_captive())
2435 return true;
2436#endif
2437 return this->is_captive_portal_active_();
2438}
2439
2440// global_web_server needs no null check: codegen always instantiates WebServer when
2441// USE_WEBSERVER_CAPTIVE is defined, and the constructor assigns the global.
2443 // Only behind an AP that came up; a fallback AP (networks and an ap_timeout) is retried by loop()
2444 if (!this->ap_setup_)
2445 return;
2446#ifdef USE_CAPTIVE_PORTAL
2447 // Force one full scan now that the portal lists networks (earlier scans were filtered)
2451#endif
2452#ifdef USE_WEBSERVER_CAPTIVE
2454#endif
2455}
2456
2458#ifdef USE_CAPTIVE_PORTAL
2459 if (this->is_captive_portal_active_())
2461#endif
2462#ifdef USE_WEBSERVER_CAPTIVE
2464#endif
2465}
2467#ifdef USE_IMPROV_BLE
2469#else
2470 return false;
2471#endif
2472}
2473
2477
2479#ifdef USE_PROVISIONING
2480 // The device stays unprovisionable until it is power-cycled
2482#else
2483 return false;
2484#endif
2485}
2486
2487#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
2489 // Already configured for high performance - request satisfied
2491 return true;
2492 }
2493
2494 // Semaphore initialization failed
2495 if (this->high_performance_semaphore_ == nullptr) {
2496 return false;
2497 }
2498
2499 // Give the semaphore (non-blocking). This increments the count.
2500 bool success = xSemaphoreGive(this->high_performance_semaphore_) == pdTRUE;
2501
2502 // Wake the main loop so the switch to high-performance mode is applied on the
2503 // next tick instead of waiting up to loop_interval.
2504 if (success) {
2506 }
2507
2508 return success;
2509}
2510
2512 // Already configured for high performance - nothing to release
2514 return true;
2515 }
2516
2517 // Semaphore initialization failed
2518 if (this->high_performance_semaphore_ == nullptr) {
2519 return false;
2520 }
2521
2522 // Take the semaphore (non-blocking). This decrements the count.
2523 return xSemaphoreTake(this->high_performance_semaphore_, 0) == pdTRUE;
2524}
2525#endif // USE_ESP32 && USE_WIFI_RUNTIME_POWER_SAVE
2526
2527#ifdef USE_WIFI_FAST_CONNECT
2529 SavedWifiFastConnectSettings fast_connect_save{};
2530
2531 if (this->fast_connect_pref_.load(&fast_connect_save)) {
2532 // Validate saved AP index
2533 if (fast_connect_save.ap_index < 0 || static_cast<size_t>(fast_connect_save.ap_index) >= this->sta_.size()) {
2534 ESP_LOGW(TAG, "AP index out of bounds");
2535 return false;
2536 }
2537
2538 // Set selected index for future operations (save, retry, etc)
2539 this->selected_sta_index_ = fast_connect_save.ap_index;
2540
2541 // Copy entire config, then override with fast connect data
2542 params = this->sta_[fast_connect_save.ap_index];
2543
2544 // Override with saved BSSID/channel from fast connect (SSID/password/etc already copied from config)
2545 bssid_t bssid{};
2546 std::copy(fast_connect_save.bssid, fast_connect_save.bssid + 6, bssid.begin());
2547 params.set_bssid(bssid);
2548 params.set_channel(fast_connect_save.channel);
2549 // Fast connect uses specific BSSID+channel, not hidden network probe (even if config has hidden: true)
2550 params.set_hidden(false);
2551
2552 ESP_LOGD(TAG, "Loaded fast_connect settings");
2553#if defined(USE_ESP32) && defined(SOC_WIFI_SUPPORT_5G)
2554 if ((this->band_mode_ == WIFI_BAND_MODE_5G_ONLY && fast_connect_save.channel < FIRST_5GHZ_CHANNEL) ||
2555 (this->band_mode_ == WIFI_BAND_MODE_2G_ONLY && fast_connect_save.channel >= FIRST_5GHZ_CHANNEL)) {
2556 ESP_LOGW(TAG, "Saved channel %u not allowed by band mode, ignoring fast_connect", fast_connect_save.channel);
2557 this->selected_sta_index_ = -1;
2558 return false;
2559 }
2560#endif
2561 return true;
2562 }
2563
2564 return false;
2565}
2566
2567void WiFiComponent::save_fast_connect_settings_(const bssid_t &bssid, uint8_t channel) {
2568 // selected_sta_index_ is always valid here (called only after successful connection)
2569 // Fallback to 0 is defensive programming for robustness
2570 int8_t ap_index = this->selected_sta_index_ >= 0 ? this->selected_sta_index_ : 0;
2571
2572 // Skip save if settings haven't changed (compare with previously saved settings to reduce flash wear)
2573 SavedWifiFastConnectSettings previous_save{};
2574 if (this->fast_connect_pref_.load(&previous_save) && memcmp(previous_save.bssid, bssid.data(), 6) == 0 &&
2575 previous_save.channel == channel && previous_save.ap_index == ap_index) {
2576 return; // No change, nothing to save
2577 }
2578
2579 SavedWifiFastConnectSettings fast_connect_save{};
2580 memcpy(fast_connect_save.bssid, bssid.data(), 6);
2581 fast_connect_save.channel = channel;
2582 fast_connect_save.ap_index = ap_index;
2583
2584 this->fast_connect_pref_.save(&fast_connect_save);
2585
2586 ESP_LOGD(TAG, "Saved fast_connect settings");
2587}
2588#endif
2589
2590#ifdef USE_WIFI_LEASE_CACHE
2592 if (!this->lease_cache_pref_.load(&this->cached_lease_) || this->cached_lease_.version != SAVED_WIFI_LEASE_VERSION) {
2593 this->cached_lease_ = {};
2594 return;
2595 }
2596 const uint8_t *ip = reinterpret_cast<const uint8_t *>(&this->cached_lease_.ip); // network byte order
2597 ESP_LOGD(TAG, "Loaded cached lease %u.%u.%u.%u (network %d, lease %us)", ip[0], ip[1], ip[2], ip[3],
2598 this->cached_lease_.ap_index, this->cached_lease_.lease_secs);
2599}
2600
2602 if (this->cached_lease_.version == 0)
2603 return;
2604 this->cached_lease_ = {};
2605 this->lease_cache_pref_.save(&this->cached_lease_);
2606 ESP_LOGD(TAG, "Lease cache: cleared");
2607}
2608#endif
2609
2610WiFiScanResult::WiFiScanResult(const bssid_t &bssid, const char *ssid, size_t ssid_len, uint8_t channel, int8_t rssi,
2611 bool with_auth, bool is_hidden)
2612 : bssid_(bssid),
2613 channel_(channel),
2614 rssi_(rssi),
2615 ssid_(ssid, ssid_len),
2616 with_auth_(with_auth),
2617 is_hidden_(is_hidden) {}
2618bool WiFiScanResult::matches(const WiFiAP &config) const {
2619 if (config.get_hidden()) {
2620 // User configured a hidden network, only match actually hidden networks
2621 // don't match SSID
2622 if (!this->is_hidden_)
2623 return false;
2624 } else if (!config.ssid_.empty()) {
2625 // check if SSID matches
2626 if (this->ssid_ != config.ssid_)
2627 return false;
2628 } else {
2629 // network is configured without SSID - match other settings
2630 }
2631 // If BSSID configured, only match for correct BSSIDs
2632 if (config.has_bssid() && config.get_bssid() != this->bssid_)
2633 return false;
2634
2635#ifdef USE_WIFI_WPA2_EAP
2636 // BSSID requires auth but no PSK or EAP credentials given
2637 if (this->with_auth_ && (config.password_.empty() && !config.get_eap().has_value()))
2638 return false;
2639
2640 // BSSID does not require auth, but PSK or EAP credentials given
2641 if (!this->with_auth_ && (!config.password_.empty() || config.get_eap().has_value()))
2642 return false;
2643#else
2644 // If PSK given, only match for networks with auth (and vice versa)
2645 if (config.password_.empty() == this->with_auth_)
2646 return false;
2647#endif
2648
2649 // If channel configured, only match networks on that channel.
2650 if (config.has_channel() && config.get_channel() != this->channel_) {
2651 return false;
2652 }
2653 return true;
2654}
2655bool WiFiScanResult::operator==(const WiFiScanResult &rhs) const { return this->bssid_ == rhs.bssid_; }
2656
2658 this->roaming_attempts_ = 0;
2659 this->roaming_last_check_ = 0;
2660 this->roaming_scan_end_ = 0;
2661 this->roaming_target_bssid_ = {};
2663}
2664
2665#ifdef USE_ESP32
2666void WiFiComponent::handle_driver_roam_(const bssid_t &bssid, uint8_t channel) {
2667 // A driver-initiated roam (e.g. 802.11v BTM) re-associates without the state
2668 // machine ever leaving STA_CONNECTED, so check_connecting_finished() never runs.
2669 // Redo its post-connect bookkeeping here. roaming_state_ is deliberately left
2670 // untouched so an in-flight roaming scan is not orphaned. The BSSID and
2671 // channel both come from the connected event so the saved pair is consistent:
2672 // the radio may be off-channel during a roaming scan, and a later queued
2673 // event may have moved the driver on again by the time this one is processed.
2675 this->roaming_attempts_ = 0;
2676 this->roaming_scan_end_ = 0;
2678#ifdef USE_WIFI_FAST_CONNECT
2679 this->save_fast_connect_settings_(bssid, channel);
2680#endif
2681}
2682#endif
2683
2685 if (!this->keep_scan_results_) {
2686 ScanResultsLock lock(this);
2687#if defined(USE_RP2)
2688 // std::vector - use swap trick since shrink_to_fit is non-binding
2689 decltype(this->scan_result_)().swap(this->scan_result_);
2690#else
2691 // FixedVector::release() frees all memory
2692 this->scan_result_.release();
2693#endif
2694 }
2695}
2696
2697#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
2699 if (!this->pending_.connect_state)
2700 return;
2701 this->pending_.connect_state = false;
2702 // Get current SSID and BSSID from the WiFi driver
2703 char ssid_buf[SSID_BUFFER_SIZE];
2704 const char *ssid = this->wifi_ssid_to(ssid_buf);
2705 bssid_t bssid = this->wifi_bssid();
2706 for (auto *listener : this->connect_state_listeners_) {
2707 listener->on_wifi_connect_state(StringRef(ssid, strlen(ssid)), bssid);
2708 }
2709}
2710
2712 constexpr uint8_t empty_bssid[6] = {};
2713 for (auto *listener : this->connect_state_listeners_) {
2714 listener->on_wifi_connect_state(StringRef(), empty_bssid);
2715 }
2716}
2717#endif // USE_WIFI_CONNECT_STATE_LISTENERS
2718
2719#ifdef USE_WIFI_IP_STATE_LISTENERS
2721 for (auto *listener : this->ip_state_listeners_) {
2722 listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
2723 }
2724}
2725#endif // USE_WIFI_IP_STATE_LISTENERS
2726
2727#ifdef USE_WIFI_SCAN_RESULTS_LISTENERS
2729 for (auto *listener : this->scan_results_listeners_) {
2730 listener->on_wifi_scan_results(this->scan_result_);
2731 }
2732}
2733#endif // USE_WIFI_SCAN_RESULTS_LISTENERS
2734
2736 if (!this->is_connected() || this->roaming_state_ != RoamingState::IDLE || this->roaming_suppressed_()) {
2737 ESP_LOGD(TAG, "Roam check requested, but not able to check now");
2738 return;
2739 }
2740 // Reset the attempt counter so a prior run of failed roams doesn't block this explicit request
2741 // Note that this re-arms automatic roaming if enabled.
2742 this->roaming_attempts_ = 0;
2743 this->check_roaming_(millis());
2744}
2745
2747 // Guard: not for hidden networks (may not appear in scan)
2748 const WiFiAP *selected = this->get_selected_sta_();
2749 if (selected == nullptr || selected->get_hidden()) {
2750 this->roaming_attempts_ = ROAMING_MAX_ATTEMPTS; // Stop checking forever
2751 return;
2752 }
2753
2754 this->roaming_last_check_ = now;
2755 this->roaming_attempts_++;
2756
2757 // Guard: skip scan if signal is already good (no meaningful improvement possible)
2758 int8_t rssi = this->wifi_rssi();
2759 if (rssi > ROAMING_GOOD_RSSI) {
2760 ESP_LOGD(TAG, "Roam check skipped, signal good (%d dBm, attempt %u/%u)", rssi, this->roaming_attempts_,
2762 return;
2763 }
2764
2765 ESP_LOGD(TAG, "Roam scan (%d dBm, attempt %u/%u)", rssi, this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
2767 if (!this->wifi_scan_start_(this->passive_scan_)) {
2768 // Scan failed to start (e.g. busy) - don't get stuck in SCANNING forever
2769 ESP_LOGD(TAG, "Roam scan failed to start");
2771 }
2772}
2773
2775 this->scan_done_ = false;
2776 // Default to IDLE - will be set to CONNECTING if we find a better AP
2778 // Record when scan completed so delayed disconnects (e.g., ESP8266 Beacon Timeout)
2779 // can be attributed to the scan and avoid resetting the attempts counter
2780 this->roaming_scan_end_ = millis();
2781
2782 // Get current connection info
2783 int8_t current_rssi = this->wifi_rssi();
2784 // Guard: must still be connected (RSSI may have become invalid during scan)
2785 if (current_rssi == WIFI_RSSI_DISCONNECTED) {
2786 this->release_scan_results_();
2787 return;
2788 }
2789
2790 char ssid_buf[SSID_BUFFER_SIZE];
2791 StringRef current_ssid(this->wifi_ssid_to(ssid_buf));
2792 bssid_t current_bssid = this->wifi_bssid();
2793
2794 // Find best candidate: same SSID, different BSSID
2795 const WiFiScanResult *best = nullptr;
2796 char bssid_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
2797
2798 for (const auto &result : this->scan_result_) {
2799 // Must be same SSID, different BSSID
2800 if (result.ssid_ != current_ssid || result.get_bssid() == current_bssid)
2801 continue;
2802
2803#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
2804 format_mac_addr_upper(result.get_bssid().data(), bssid_buf);
2805 ESP_LOGV(TAG, "Roam candidate %s %d dBm", bssid_buf, result.get_rssi());
2806#endif
2807
2808 // Track the best candidate
2809 if (best == nullptr || result.get_rssi() > best->get_rssi()) {
2810 best = &result;
2811 }
2812 }
2813
2814 // Check if best candidate meets minimum improvement threshold
2815 const WiFiAP *selected = this->get_selected_sta_();
2816 int8_t improvement = (best == nullptr) ? 0 : best->get_rssi() - current_rssi;
2817 if (selected == nullptr || improvement < ROAMING_MIN_IMPROVEMENT) {
2818 ESP_LOGV(TAG, "Roam best %+d dB (need +%d), attempt %u/%u", improvement, ROAMING_MIN_IMPROVEMENT,
2820 this->release_scan_results_();
2821 return;
2822 }
2823
2824 format_mac_addr_upper(best->get_bssid().data(), bssid_buf);
2825 ESP_LOGI(TAG, "Roaming to %s (%+d dB)", bssid_buf, improvement);
2826
2827 WiFiAP roam_params = *selected;
2828 apply_scan_result_to_params(roam_params, *best);
2829
2830 // Mark as roaming attempt - affects retry behavior if connection fails
2832 this->roaming_target_bssid_ = best->get_bssid(); // Must read before releasing scan results
2833
2834 this->release_scan_results_();
2835
2836 // Connect directly - wifi_sta_connect_ handles disconnect internally
2837 this->start_connecting(roam_params);
2838}
2839
2840WiFiComponent *global_wifi_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
2841
2842} // namespace esphome::wifi
2843#endif
BedjetMode mode
BedJet operating mode.
uint8_t m
Definition bl0906.h:1
uint8_t status
Definition bl0942.h:8
const StringRef & get_name() const
Get the name of this Application set by pre_setup().
void wake_loop_threadsafe()
Wake the main event loop from another thread or callback.
bool is_name_add_mac_suffix_enabled() const
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.
bool cancel_timeout(const char *name)
Cancel a timeout function.
void set_timeout(const char *name, uint32_t timeout, std::function< void()> &&f)
Set a timeout function with a const char* name.
Definition component.cpp:96
void status_clear_warning()
Definition component.h:296
StringRef is a reference to a string owned by something else.
Definition string_ref.h:31
constexpr const char * c_str() const
Definition string_ref.h:78
constexpr size_type size() const
Definition string_ref.h:79
void trigger(const Ts &...x) ESPHOME_ALWAYS_INLINE
Inform the parent automation that the event has triggered.
Definition automation.h:556
void start_captive()
AP mode: DNS answers every name with the AP address and unknown URLs redirect to the interface.
20-byte string: 18 chars inline + null, heap for longer.
const char * data() const
CompactString & operator=(const CompactString &other)
bool operator==(const CompactString &other) const
static constexpr uint8_t INLINE_CAPACITY
const char * c_str() const
char storage_[INLINE_CAPACITY+1]
static constexpr uint8_t MAX_LENGTH
Guards WiFiComponent::scan_result_.
uint8_t get_channel() const
void set_ssid(const std::string &ssid)
const optional< EAPAuth > & get_eap() const
void set_bssid(const bssid_t &bssid)
void set_channel(uint8_t channel)
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 notify_scan_results_listeners_()
Notify scan results listeners with current scan results.
void fail_bridge_()
Take the bridge down so loop() runs its setup again after ap_timeout.
bool is_provisioning_closed_()
The provisioning window has closed, so no new provisioning surface may start.
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 roaming_suppressed_() const
Returns true if a component has requested that roaming scans be suppressed (e.g. during audio playbac...
bool has_sta_priority(const bssid_t &bssid)
const WiFiAP * get_selected_sta_() const
WiFiSTAConnectStatus wifi_sta_connect_status_() const
int8_t get_sta_priority(const bssid_t bssid)
void set_last_connected_(uint32_t now)
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)
void notify_connect_state_listeners_()
Notify connect state listeners (called after state machine reaches STA_CONNECTED)
SavedWifiLeaseSettings cached_lease_
wifi_scan_vector_t< WiFiScanResult > scan_result_
void save_fast_connect_settings_(const bssid_t &bssid, uint8_t channel)
WiFiPowerSaveMode configured_power_save_
void set_sta_priority(bssid_t bssid, int8_t priority)
StaticVector< WiFiScanResultsListener *, ESPHOME_WIFI_SCAN_RESULTS_LISTENERS > scan_results_listeners_
void loop() override
Reconnect WiFi if required.
void notify_ip_state_listeners_()
Notify IP state listeners with current addresses.
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...
ESPPreferenceObject lease_cache_pref_
static constexpr uint32_t ROAMING_CHECK_INTERVAL
SemaphoreHandle_t high_performance_semaphore_
bool ap_dns_known_()
Whether there is an IPv4 resolver to offer AP clients: a configured one, or the station's.
void start_cooldown_()
Wait before the next attempt; while a configurator is waited for, the wait is its listen window.
network::IPAddress get_dns_address(int num)
void pause_exclusive_ap_()
Drop the fallback AP so the networks can be tried; it comes back after ap_timeout.
WiFiComponent()
Construct a WiFiComponent.
std::vector< WiFiSTAPriority > sta_priorities_
static constexpr int8_t ROAMING_GOOD_RSSI
void notify_disconnect_state_listeners_()
Notify connect state listeners of disconnection.
StaticVector< WiFiConnectStateListener *, ESPHOME_WIFI_CONNECT_STATE_LISTENERS > connect_state_listeners_
void log_discarded_scan_result_(const char *ssid, const uint8_t *bssid, int8_t rssi, uint8_t channel)
Log a discarded scan result at VERBOSE level (skipped during roaming scans to avoid log overflow)
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_power_save_mode(WiFiPowerSaveMode power_save)
bool is_roaming_scan_active() const
True while a post-connect roaming scan holds the radio off-channel.
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)
bool is_ap_portal_active_()
captive_portal or the web_server AP mode is serving a user on the access point
static constexpr uint32_t ROAMING_SCAN_GRACE_PERIOD
WiFiRetryPhase determine_next_phase_()
Determine next retry phase based on current state and failure conditions.
network::IPAddress wifi_dns_ip_(int num)
network::IPAddresses get_ip_addresses()
static constexpr int8_t ROAMING_MIN_IMPROVEMENT
bool matches_configured_network_(const char *ssid, const uint8_t *bssid) const
Check if network matches any configured network (for scan result filtering) Matches by SSID when conf...
struct esphome::wifi::WiFiComponent::@194 pending_
bool is_provisioning_active_()
A portal, improv or DPP is waiting for credentials, so the retry machine must not disturb the radio.
void disable_ap_()
End the portals and turn the AP off.
StaticVector< WiFiIPStateListener *, ESPHOME_WIFI_IP_STATE_LISTENERS > ip_state_listeners_
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 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 needs_full_scan_results_() const
Check if full scan results are needed (captive portal active, improv, listeners)
void start_dpp_listen_(uint32_t now)
static constexpr uint8_t FIRST_5GHZ_CHANNEL
bool release_high_performance()
Release a high-performance mode request.
void force_roam_check()
Force an immediate post-connect roaming check, bypassing the periodic interval and the per-connection...
bool wifi_apply_output_power_(float output_power)
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)
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.
bool ssid_was_seen_in_scan_(const CompactString &ssid) const
Check if an SSID was seen in the most recent scan results Used to skip hidden mode for SSIDs we know ...
void handle_driver_roam_(const bssid_t &bssid, uint8_t channel)
Redo post-connect bookkeeping after a driver-initiated roam (e.g.
void setup() override
Setup WiFi interface.
void clear_all_bssid_priorities_()
Clear all BSSID priority penalties after successful connection (stale after disconnect)
WiFiScanResult(const bssid_t &bssid, const char *ssid, size_t ssid_len, uint8_t channel, int8_t rssi, bool with_auth, bool is_hidden)
const bssid_t & get_bssid() const
bool matches(const WiFiAP &config) const
bool operator==(const WiFiScanResult &rhs) const
struct @66::@67 __attribute__
Wake the main loop task from an ISR. ISR-safe.
Definition main_task.h:45
void yield(void)
uint8_t priority
uint8_t type
const char * key
unsigned count
Definition gif.cpp:51
CaptivePortal * global_captive_portal
ImprovBLEComponent * global_improv_component
ImprovSerialComponent * global_improv_serial_component
std::array< IPAddress, 5 > IPAddresses
Definition ip_address.h:369
ProvisioningManager * global_provisioning_manager
WebServer * global_web_server
std::array< uint8_t, 6 > bssid_t
ESPHOME_LOG_TAG(TAG, "wifi")
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.
size_t truncate_name_to(char *buffer, size_t buffer_size, const char *name, size_t name_len, size_t keep_suffix_len)
Copy a name into a buffer, cutting it when it does not fit.
Definition helpers.cpp:297
uint16_t uint16_t size_t elem_size
Definition helpers.cpp:35
const void size_t len
Definition hal.h:68
uint16_t size
Definition helpers.cpp:34
ESPPreferences * global_preferences
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:854
uint32_t IRAM_ATTR HOT millis()
Definition hal.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:1676
static void uint32_t
ESPPreferenceObject make_preference(size_t, uint32_t, bool)
Definition preferences.h:24
bool sync()
Commit pending writes to flash.
Definition preferences.h:33
esp_eap_ttls_phase2_types ttls_phase_2
Struct for setting static IPs in WiFiComponent.
SemaphoreHandle_t lock