ESPHome 2026.10.0
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
40#ifdef USE_IMPROV_BLE
42#endif
43
44#ifdef USE_IMPROV_SERIAL
46#endif
47
48#ifdef USE_PROVISIONING
50#endif
51
52namespace esphome::wifi {
53
54static const char *const TAG = "wifi";
55
56// CompactString implementation
57CompactString::CompactString(const char *str, size_t len) {
58 if (len > MAX_LENGTH) {
59 len = MAX_LENGTH; // Clamp to max valid length
60 }
61
62 this->length_ = len;
63 if (len <= INLINE_CAPACITY) {
64 // Store inline with null terminator
65 this->is_heap_ = 0;
66 if (len > 0) {
67 std::memcpy(this->storage_, str, len);
68 }
69 this->storage_[len] = '\0';
70 } else {
71 // Heap allocate with null terminator
72 this->is_heap_ = 1;
73 char *heap_data = new char[len + 1]; // NOLINT(cppcoreguidelines-owning-memory)
74 std::memcpy(heap_data, str, len);
75 heap_data[len] = '\0';
76 this->set_heap_ptr_(heap_data);
77 }
78}
79
80CompactString::CompactString(const CompactString &other) : CompactString(other.data(), other.size()) {}
81
83 if (this != &other) {
84 this->~CompactString();
85 new (this) CompactString(other);
86 }
87 return *this;
88}
89
90CompactString::CompactString(CompactString &&other) noexcept : length_(other.length_), is_heap_(other.is_heap_) {
91 // Copy full storage (includes null terminator for inline, or pointer for heap)
92 std::memcpy(this->storage_, other.storage_, INLINE_CAPACITY + 1);
93 other.length_ = 0;
94 other.is_heap_ = 0;
95 other.storage_[0] = '\0';
96}
97
99 if (this != &other) {
100 this->~CompactString();
101 new (this) CompactString(std::move(other));
102 }
103 return *this;
104}
105
107 if (this->is_heap_) {
108 delete[] this->get_heap_ptr_(); // NOLINT(cppcoreguidelines-owning-memory)
109 }
110}
111
112bool CompactString::operator==(const CompactString &other) const {
113 return this->size() == other.size() && std::memcmp(this->data(), other.data(), this->size()) == 0;
114}
115bool CompactString::operator==(const StringRef &other) const {
116 return this->size() == other.size() && std::memcmp(this->data(), other.c_str(), this->size()) == 0;
117}
118
314
315#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_INFO
316#ifdef USE_WIFI_PHY_MODE
317// Use if-chain instead of switch to avoid jump table in RODATA (wastes RAM on ESP8266)
318static const LogString *phy_mode_to_log_string(WiFi8266PhyMode mode) {
320 return LOG_STR("11B");
322 return LOG_STR("11G");
324 return LOG_STR("11N");
325 return LOG_STR("Auto");
326}
327#endif
328// Use if-chain instead of switch to avoid jump table in RODATA (wastes RAM on ESP8266)
329static const LogString *retry_phase_to_log_string(WiFiRetryPhase phase) {
331 return LOG_STR("INITIAL_CONNECT");
332#ifdef USE_WIFI_FAST_CONNECT
334 return LOG_STR("FAST_CONNECT_CYCLING");
335#endif
337 return LOG_STR("EXPLICIT_HIDDEN");
339 return LOG_STR("SCAN_CONNECTING");
340 if (phase == WiFiRetryPhase::RETRY_HIDDEN)
341 return LOG_STR("RETRY_HIDDEN");
343 return LOG_STR("RESTARTING");
344 return LOG_STR("UNKNOWN");
345}
346#endif // ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_INFO
347
349 // If first configured network is marked hidden, we went through EXPLICIT_HIDDEN phase
350 // This means those networks were already tried and should be skipped in RETRY_HIDDEN
351 return !this->sta_.empty() && this->sta_[0].get_hidden();
352}
353
355 // Find the first network that is NOT marked hidden:true
356 // This is where EXPLICIT_HIDDEN phase would have stopped
357 for (size_t i = 0; i < this->sta_.size(); i++) {
358 if (!this->sta_[i].get_hidden()) {
359 return static_cast<int8_t>(i);
360 }
361 }
362 return -1; // All networks are hidden
363}
364
365// 2 attempts per BSSID in SCAN_CONNECTING phase
366// Rationale: This is the ONLY phase where we decrease BSSID priority, so we must be very sure.
367// Auth failures are common immediately after scan due to WiFi stack state transitions.
368// Trying twice filters out false positives and prevents unnecessarily marking a good BSSID as bad.
369// After 2 genuine failures, priority degradation ensures we skip this BSSID on subsequent scans.
370static constexpr uint8_t WIFI_RETRY_COUNT_PER_BSSID = 2;
371
372// 1 attempt per SSID in RETRY_HIDDEN phase
373// Rationale: Try hidden mode once, then rescan to get next best BSSID via priority system
374static constexpr uint8_t WIFI_RETRY_COUNT_PER_SSID = 1;
375
376// 1 attempt per AP in fast_connect mode (INITIAL_CONNECT and FAST_CONNECT_CYCLING_APS)
377// Rationale: Fast connect prioritizes speed - try each AP once to find a working one quickly
378static constexpr uint8_t WIFI_RETRY_COUNT_PER_AP = 1;
379
382static constexpr uint32_t WIFI_COOLDOWN_DURATION_MS = 500;
383#ifdef USE_WIFI_AP
385#ifdef USE_WIFI_AP_EXCLUSIVE
386static constexpr bool WIFI_AP_EXCLUSIVE = true;
387#else
388static constexpr bool WIFI_AP_EXCLUSIVE = false;
389#endif
390#endif
391#ifdef USE_WIFI_AP_EXCLUSIVE
396static constexpr uint32_t WIFI_AP_EXCLUSIVE_DWELL_MS = 300000;
399static constexpr uint32_t WIFI_AP_EXCLUSIVE_MAX_DWELL_MS = 3 * WIFI_AP_EXCLUSIVE_DWELL_MS;
400static constexpr uint32_t WIFI_AP_EXCLUSIVE_HANDOVER_MS = 1000;
401static constexpr uint32_t AP_HANDOVER_TIMEOUT_ID = 0;
402#endif
403
407static constexpr uint32_t WIFI_COOLDOWN_WITH_AP_ACTIVE_MS = 30000;
408
412static constexpr uint32_t WIFI_SCAN_TIMEOUT_MS = 31000;
413
422static constexpr uint32_t WIFI_CONNECT_TIMEOUT_MS = 46000;
423
424static constexpr uint8_t get_max_retries_for_phase(WiFiRetryPhase phase) {
425 switch (phase) {
427#ifdef USE_WIFI_FAST_CONNECT
429#endif
430 // INITIAL_CONNECT and FAST_CONNECT_CYCLING_APS both use 1 attempt per AP (fast_connect mode)
431 return WIFI_RETRY_COUNT_PER_AP;
433 // Explicitly hidden network: 1 attempt (user marked as hidden, try once then scan)
434 return WIFI_RETRY_COUNT_PER_SSID;
436 // Scan-based phase: 2 attempts per BSSID (handles transient auth failures after scan)
437 return WIFI_RETRY_COUNT_PER_BSSID;
439 // Hidden network mode: 1 attempt per SSID
440 return WIFI_RETRY_COUNT_PER_SSID;
441 default:
442 return WIFI_RETRY_COUNT_PER_BSSID;
443 }
444}
445
446static void apply_scan_result_to_params(WiFiAP &params, const WiFiScanResult &scan) {
447 params.set_hidden(false);
448 params.set_ssid(scan.get_ssid());
449 params.set_bssid(scan.get_bssid());
450 params.set_channel(scan.get_channel());
451}
452
454 // Only SCAN_CONNECTING phase needs scan results
456 return false;
457 }
458 // Need scan if we have no results or no matching networks
459 return this->scan_result_.empty() || !this->scan_result_[0].get_matches();
460}
461
463 // Check if this SSID is configured as hidden
464 // If explicitly marked hidden, we should always try hidden mode regardless of scan results
465 for (const auto &conf : this->sta_) {
466 if (conf.ssid_ == ssid && conf.get_hidden()) {
467 return false; // Treat as not seen - force hidden mode attempt
468 }
469 }
470
471 // Otherwise, check if we saw it in scan results
472 for (const auto &scan : this->scan_result_) {
473 if (scan.ssid_ == ssid) {
474 return true;
475 }
476 }
477 return false;
478}
479
481 // Components that require full scan results (for example, scan result listeners)
482 // are expected to call request_wifi_scan_results(), which sets keep_scan_results_.
483 if (this->keep_scan_results_) {
484 return true;
485 }
486
487#ifdef USE_CAPTIVE_PORTAL
488 // Captive portal needs full results when active (showing network list to user)
490 return true;
491 }
492#endif
493
494#ifdef USE_IMPROV_SERIAL
495 // Improv serial needs results during provisioning (before connected)
497 return true;
498 }
499#endif
500
501#ifdef USE_IMPROV_BLE
502 // BLE improv also needs results during provisioning
504 return true;
505 }
506#endif
507
508 return false;
509}
510
511bool WiFiComponent::matches_configured_network_(const char *ssid, const uint8_t *bssid) const {
512 // Hidden networks in scan results have empty SSIDs - skip them
513 if (ssid[0] == '\0') {
514 return false;
515 }
516 for (const auto &sta : this->sta_) {
517 // Skip hidden network configs (they don't appear in normal scans)
518 if (sta.get_hidden()) {
519 continue;
520 }
521 // For BSSID-only configs (empty SSID), match by BSSID
522 if (sta.ssid_.empty()) {
523 if (sta.has_bssid() && std::memcmp(sta.get_bssid().data(), bssid, 6) == 0) {
524 return true;
525 }
526 continue;
527 }
528 // Match by SSID
529 if (sta.ssid_ == ssid) {
530 return true;
531 }
532 }
533 return false;
534}
535
537 for (auto &it : this->sta_priorities_) {
538 if (it.bssid == bssid) {
539 it.priority = priority;
540 return;
541 }
542 }
543 this->sta_priorities_.push_back(WiFiSTAPriority{
544 .bssid = bssid,
545 .priority = priority,
546 });
547}
548
549void WiFiComponent::log_discarded_scan_result_(const char *ssid, const uint8_t *bssid, int8_t rssi, uint8_t channel) {
550#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
551 // Skip logging during roaming scans to avoid log buffer overflow
552 // (roaming scans typically find many networks but only care about same-SSID APs)
553 if (this->is_roaming_scan_active()) {
554 return;
555 }
556 char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
557 format_mac_addr_upper(bssid, bssid_s);
558 ESP_LOGV(TAG, "- " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") " %ddB Ch:%u", ssid, bssid_s, rssi, channel);
559#endif
560}
561
562int8_t WiFiComponent::find_next_hidden_sta_(int8_t start_index) {
563 // Find next SSID to try in RETRY_HIDDEN phase.
564 //
565 // This function operates in two modes based on retry_hidden_mode_:
566 //
567 // 1. SCAN_BASED mode:
568 // After SCAN_CONNECTING phase, only returns networks that were NOT visible
569 // in the scan (truly hidden networks that need probe requests).
570 //
571 // 2. BLIND_RETRY mode:
572 // When captive portal/improv is active, scanning is skipped to avoid
573 // disrupting the AP. In this mode, ALL configured networks are returned
574 // as candidates, cycling through them sequentially. This allows the device
575 // to keep trying all networks while users configure WiFi via captive portal.
576 //
577 // Additionally, if EXPLICIT_HIDDEN phase was executed (first network marked hidden:true),
578 // those networks are skipped here since they were already tried.
579 //
580 bool include_explicit_hidden = !this->went_through_explicit_hidden_phase_();
581 // Start searching from start_index + 1
582 for (size_t i = start_index + 1; i < this->sta_.size(); i++) {
583 const auto &sta = this->sta_[i];
584
585 // Skip networks that were already tried in EXPLICIT_HIDDEN phase
586 // Those are: networks marked hidden:true that appear before the first non-hidden network
587 // If all networks are hidden (first_non_hidden_idx == -1), skip all of them
588 if (!include_explicit_hidden && sta.get_hidden()) {
589 int8_t first_non_hidden_idx = this->find_first_non_hidden_index_();
590 if (first_non_hidden_idx < 0 || static_cast<int8_t>(i) < first_non_hidden_idx) {
591 ESP_LOGD(TAG, "Skipping " LOG_SECRET("'%s'") " (explicit hidden, already tried)", sta.ssid_.c_str());
592 continue;
593 }
594 }
595
596 // In BLIND_RETRY mode, treat all networks as candidates
597 // In SCAN_BASED mode, only retry networks that weren't seen in the scan
599 ESP_LOGD(TAG, "Hidden candidate " LOG_SECRET("'%s'") " at index %d", sta.ssid_.c_str(), static_cast<int>(i));
600 return static_cast<int8_t>(i);
601 }
602 ESP_LOGD(TAG, "Skipping hidden retry for visible network " LOG_SECRET("'%s'"), sta.ssid_.c_str());
603 }
604 // No hidden SSIDs found
605 return -1;
606}
607
609 // If first network (highest priority) is explicitly marked hidden, try it first before scanning
610 // This respects user's priority order when they explicitly configure hidden networks
611 if (!this->sta_.empty() && this->sta_[0].get_hidden()) {
612 ESP_LOGI(TAG, "Starting with explicit hidden network (highest priority)");
613 this->selected_sta_index_ = 0;
616 this->start_connecting(params);
617 } else {
618 this->start_scanning();
619 }
620}
621
622#if defined(USE_ESP32) && defined(USE_WIFI_WPA2_EAP) && ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
623static const char *eap_phase2_to_str(esp_eap_ttls_phase2_types type) {
624 switch (type) {
625 case ESP_EAP_TTLS_PHASE2_PAP:
626 return "pap";
627 case ESP_EAP_TTLS_PHASE2_CHAP:
628 return "chap";
629 case ESP_EAP_TTLS_PHASE2_MSCHAP:
630 return "mschap";
631 case ESP_EAP_TTLS_PHASE2_MSCHAPV2:
632 return "mschapv2";
633 case ESP_EAP_TTLS_PHASE2_EAP:
634 return "eap";
635 default:
636 return "unknown";
637 }
638}
639#endif
640
642 this->wifi_pre_setup_();
643
644#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
645 // Create semaphore for high-performance mode requests
646 // Start at 0, increment on request, decrement on release
647 this->high_performance_semaphore_ = xSemaphoreCreateCounting(UINT32_MAX, 0);
648 if (this->high_performance_semaphore_ == nullptr) {
649 ESP_LOGE(TAG, "Failed semaphore");
650 }
651
652 // Store the configured power save mode as baseline
654#endif
655
656#if defined(USE_PROVISIONING) && defined(USE_WIFI_AP)
657 // The access point is a provisioning surface: once the provisioning window has
658 // closed, shut it down (mirrors the teardown done on a successful connection).
659 // The captive portal registers its own closed-callback, and the fallback block
660 // in loop() is gated so neither is started again afterwards.
663 if (this->ap_setup_) {
664 ESP_LOGD(TAG, "Provisioning window closed; disabling AP");
665#ifdef USE_WIFI_AP_EXCLUSIVE
666 // The networks wait while the AP runs on its own; hand them the radio
667 // now rather than at the end of the dwell.
668 if (this->state_ == WIFI_COMPONENT_STATE_AP && this->has_sta()) {
669 this->pause_exclusive_ap_();
670 return;
671 }
672#endif
673 this->wifi_mode_({}, false);
674 }
675 });
676 }
677#endif
678
679 if (this->enable_on_boot_) {
680#ifdef USE_ESP32
681 this->wifi_lazy_init_();
682#endif
683 this->start();
684 } else {
686 }
687}
688
690 ESP_LOGCONFIG(TAG, "Starting");
691 this->last_connected_ = millis();
692
693 uint32_t hash = this->has_sta() ? App.get_config_version_hash() : 88491487UL;
694
696#ifdef USE_WIFI_FAST_CONNECT
697#ifdef USE_WIFI_FAST_CONNECT_IN_FLASH
698 const bool fast_connect_in_flash = true;
699#else
700 const bool fast_connect_in_flash = false;
701#endif
702 this->fast_connect_pref_ =
704#endif
705
706 SavedWifiSettings save{};
707 if (this->pref_.load(&save)) {
708 ESP_LOGD(TAG, "Loaded settings: %s", save.ssid);
709
710 WiFiAP sta{};
711 sta.set_ssid(save.ssid);
712 sta.set_password(save.password);
713 this->set_sta(sta);
714 }
715
716 if (this->has_sta()) {
717 this->wifi_sta_pre_setup_();
718 if (!std::isnan(this->output_power_) && !this->wifi_apply_output_power_(this->output_power_)) {
719 ESP_LOGV(TAG, "Setting Output Power Option failed");
720 }
721
722#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
723 // Synchronize power_save_ with semaphore state before applying
724 if (this->high_performance_semaphore_ != nullptr) {
725 UBaseType_t semaphore_count = uxSemaphoreGetCount(this->high_performance_semaphore_);
726 if (semaphore_count > 0) {
728 this->is_high_performance_mode_ = true;
729 } else {
731 this->is_high_performance_mode_ = false;
732 }
733 }
734#endif
735 if (!this->wifi_apply_power_save_()) {
736 ESP_LOGV(TAG, "Setting Power Save Option failed");
737 }
738
740#ifdef USE_WIFI_FAST_CONNECT
741 WiFiAP params;
742 bool loaded_fast_connect = this->load_fast_connect_settings_(params);
743 // Fast connect optimization: only use when we have saved BSSID+channel data
744 // Without saved data, try first configured network or use normal flow
745 if (loaded_fast_connect) {
746 ESP_LOGI(TAG, "Starting fast_connect (saved) " LOG_SECRET("'%s'"), params.ssid_.c_str());
747 this->start_connecting(params);
748 } else if (!this->sta_.empty() && !this->sta_[0].get_hidden()) {
749 // No saved data, but have configured networks - try first non-hidden network
750 ESP_LOGI(TAG, "Starting fast_connect (config) " LOG_SECRET("'%s'"), this->sta_[0].ssid_.c_str());
751 this->selected_sta_index_ = 0;
752 params = this->build_params_for_current_phase_();
753 this->start_connecting(params);
754 } else {
755 // No saved data and (no networks OR first is hidden) - use normal flow
757 }
758#else
759 // Without fast_connect: go straight to scanning (or hidden mode if all networks are hidden)
761#endif
762#ifdef USE_WIFI_AP
763 } else if (this->has_ap()) {
764 this->setup_ap_config_();
765 if (!std::isnan(this->output_power_) && !this->wifi_apply_output_power_(this->output_power_)) {
766 ESP_LOGV(TAG, "Setting Output Power Option failed");
767 }
768#ifdef USE_CAPTIVE_PORTAL
770 // Where the radio scans alongside the AP, the portal can list networks.
771 if (!WIFI_AP_EXCLUSIVE) {
772 this->wifi_sta_pre_setup_();
773 this->start_scanning();
774 }
776 }
777#endif
778#endif // USE_WIFI_AP
779 }
780#ifdef USE_IMPROV_BLE
781 if (!this->has_sta() && improv_ble::global_improv_component != nullptr) {
782 if (this->wifi_mode_(true, {}))
784 }
785#endif
786 this->wifi_apply_hostname_();
787}
788
790 ESP_LOGW(TAG, "Restarting adapter");
791 this->wifi_mode_(false, {});
792 // Clear error flag here because restart_adapter() enters COOLDOWN state,
793 // and check_connecting_finished() is called after cooldown without going
794 // through start_connecting() first. Without this clear, stale errors would
795 // trigger spurious "failed (callback)" logs. The canonical clear location
796 // is in start_connecting() (ESP8266 also clears after wifi_station_connect()).
797 this->error_from_callback_ = false;
798}
799
801 bool events_processed = this->wifi_loop_();
803 // Connection state can only change when events are processed (ESP-IDF/LibreTiny)
804 // or polled (ESP8266/Pico W). Skip the expensive wifi_sta_connect_status_() call
805 // when no events arrived and we're already in steady state.
806 // Must also run when connected_ is false — after state transitions to STA_CONNECTED,
807 // connected_ won't be set until update_connected_state_() runs.
808 if (events_processed || !this->connected_) {
810 }
811
812 if (this->has_sta()) {
813#if defined(USE_WIFI_CONNECT_TRIGGER) || defined(USE_WIFI_DISCONNECT_TRIGGER)
814 if (this->is_connected() != this->handled_connected_state_) {
815#ifdef USE_WIFI_DISCONNECT_TRIGGER
816 if (this->handled_connected_state_) {
818 }
819#endif
820#ifdef USE_WIFI_CONNECT_TRIGGER
821 if (!this->handled_connected_state_) {
823 }
824#endif
826 }
827#endif // USE_WIFI_CONNECT_TRIGGER || USE_WIFI_DISCONNECT_TRIGGER
828
829 switch (this->state_) {
831 this->status_set_warning(LOG_STR("waiting to reconnect"));
832 // Skip cooldown if new credentials were provided while connecting
833 if (this->skip_cooldown_next_cycle_) {
834 this->skip_cooldown_next_cycle_ = false;
835 this->check_connecting_finished(now);
836 break;
837 }
838 // Use longer cooldown when captive portal/improv is active to avoid disrupting user config
839 bool portal_active = this->is_captive_portal_active_() || this->is_improv_ble_active_();
840 uint32_t cooldown_duration = portal_active ? WIFI_COOLDOWN_WITH_AP_ACTIVE_MS : WIFI_COOLDOWN_DURATION_MS;
841 if (now - this->action_started_ > cooldown_duration) {
842 // After cooldown we either restarted the adapter because of
843 // a failure, or something tried to connect over and over
844 // so we entered cooldown. In both cases we call
845 // check_connecting_finished to continue the state machine.
846 this->check_connecting_finished(now);
847 }
848 break;
849 }
851 this->status_set_warning(LOG_STR("scanning for networks"));
853 break;
854 }
856 this->status_set_warning(LOG_STR("associating to network"));
857 this->check_connecting_finished(now);
858 break;
859 }
860
862 // Use cached connected_ set unconditionally at the top of loop()
863 if (!this->connected_) {
864 ESP_LOGW(TAG, "Connection lost; reconnecting");
866 this->retry_connect();
867 } else {
868 this->status_clear_warning();
869 this->last_connected_ = now;
870
871#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
872 // A driver-initiated roam (e.g. 802.11v BTM) re-associates without the
873 // state machine ever leaving STA_CONNECTED, so the notification the
874 // connected event marked pending would never be flushed by
875 // check_connecting_finished(). Cheap when nothing is pending: the
876 // method returns immediately on a single flag test.
878#endif
879
880 // Post-connect roaming: check for better AP. A scan may have been started by an
881 // explicit force_roam_check() even when post_connect_roaming_ is disabled, so the
882 // scan must always be consumed here to avoid leaving roaming_state_ stuck.
883 if (this->is_roaming_scan_active()) {
884 if (this->scan_done_) {
885 this->process_roaming_scan_();
886 }
887 // else: scan in progress, wait
888 } else if (this->post_connect_roaming_ && this->roaming_state_ == RoamingState::IDLE &&
891 this->check_roaming_(now);
892 }
893 }
894 break;
895 }
897#ifdef USE_WIFI_AP_EXCLUSIVE
898 // The networks are not tried while the AP is up, so it pauses for
899 // them once nobody has used it for a while. Without networks the AP
900 // is all there is and stays.
901 if (this->has_sta() && now - this->ap_exclusive_changed_ > WIFI_AP_EXCLUSIVE_DWELL_MS &&
902 (this->ap_clients_ == 0 || now - this->ap_exclusive_changed_ > WIFI_AP_EXCLUSIVE_MAX_DWELL_MS))
903 this->pause_exclusive_ap_();
904 break;
905#endif
907 break;
909 return;
910 }
911
912#ifdef USE_WIFI_AP
913 bool provisioning_closed = false;
914#ifdef USE_PROVISIONING
915 // Once the provisioning window has closed, don't bring up the fallback AP (or
916 // the captive portal on it) - the device must stay unprovisionable until it is
917 // power-cycled.
918 provisioning_closed =
920#endif
921 if (this->has_ap() && !this->ap_setup_ && !provisioning_closed) {
922 if (this->ap_timeout_ != 0 &&
923 (now - this->last_connected_ > this->ap_timeout_)
924#ifdef USE_WIFI_AP_EXCLUSIVE
925 // After a pause, or a start that failed, the networks get a full
926 // ap_timeout before the AP is tried again.
927 && now - this->ap_exclusive_changed_ > this->ap_timeout_
928#endif
929 ) {
930 ESP_LOGI(TAG, "Starting fallback AP");
931#ifdef USE_WIFI_AP_EXCLUSIVE
932 this->ap_exclusive_changed_ = now;
933#endif
934 this->setup_ap_config_();
935#ifdef USE_CAPTIVE_PORTAL
936 // Where the AP runs on its own, a portal with no AP behind it would
937 // only stretch the cooldowns.
938 if (captive_portal::global_captive_portal != nullptr && (!WIFI_AP_EXCLUSIVE || this->ap_setup_)) {
939 // Reset so we force one full scan after captive portal starts
940 // (previous scans were filtered because captive portal wasn't active yet)
943 }
944#endif
945 }
946 }
947#endif // USE_WIFI_AP
948
949#ifdef USE_IMPROV_BLE
951 !improv_ble::global_improv_component->should_start()) {
952 if (now - this->last_connected_ > improv_ble::global_improv_component->get_wifi_timeout()) {
953 if (this->wifi_mode_(true, {}))
955 }
956 }
957
958#endif
959
960 if (!this->has_ap() && this->reboot_timeout_ != 0) {
961 if (now - this->last_connected_ > this->reboot_timeout_) {
962 bool suppress = false;
963#ifdef USE_PROVISIONING
964 // Don't reboot while a provisioning window is pending (device unprovisioned).
965 // The device is legitimately waiting to be onboarded (Wi-Fi must come up
966 // before the controller can set credentials), and an auto-reboot would reopen
967 // the window without the deliberate power cycle / reset that is meant to be
968 // required. Resumes normal reboot behavior once provisioned.
969 suppress = provisioning::global_provisioning_manager != nullptr &&
971#endif
972 if (!suppress) {
973 ESP_LOGE(TAG, "Can't connect; rebooting");
974 App.reboot();
975 }
976 }
977 }
978 }
979
980#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
981 // Check if power save mode needs to be updated based on high-performance requests
982 if (this->high_performance_semaphore_ != nullptr) {
983 // Semaphore count directly represents active requests (starts at 0, increments on request)
984 UBaseType_t semaphore_count = uxSemaphoreGetCount(this->high_performance_semaphore_);
985
986 if (semaphore_count > 0 && !this->is_high_performance_mode_) {
987 // Transition to high-performance mode (no power save)
988 ESP_LOGV(TAG, "Switching to high-performance mode (%" PRIu32 " active %s)", (uint32_t) semaphore_count,
989 semaphore_count == 1 ? LOG_STR_LITERAL("request") : LOG_STR_LITERAL("requests"));
991 if (this->wifi_apply_power_save_()) {
992 this->is_high_performance_mode_ = true;
993 }
994 } else if (semaphore_count == 0 && this->is_high_performance_mode_) {
995 // Restore to configured power save mode
996 ESP_LOGV(TAG, "Restoring power save mode to configured setting");
998 if (this->wifi_apply_power_save_()) {
999 this->is_high_performance_mode_ = false;
1000 }
1001 }
1002 }
1003#endif
1004}
1005
1007
1009 if (this->has_sta())
1010 return this->wifi_sta_ip_addresses();
1011
1012#ifdef USE_WIFI_AP
1013 if (this->has_ap())
1014 return {this->wifi_soft_ap_ip()};
1015#endif // USE_WIFI_AP
1016
1017 return {};
1018}
1020 if (this->has_sta())
1021 return this->wifi_dns_ip_(num);
1022 return {};
1023}
1024
1025#ifdef USE_WIFI_AP
1027#ifdef USE_WIFI_AP_EXCLUSIVE
1028 // The radio cannot run both, so STA goes off while the AP is up.
1029 if (!this->wifi_mode_(false, true))
1030 return;
1031#else
1032 this->wifi_mode_({}, true);
1033#endif
1034
1035 if (this->ap_setup_)
1036 return;
1037
1038 if (this->ap_.ssid_.empty()) {
1039 // Build AP SSID from app name without heap allocation
1040 // WiFi SSID max is 32 bytes, with MAC suffix we keep first 25 + last 7
1041 static constexpr size_t AP_SSID_MAX_LEN = 32;
1042 static constexpr size_t AP_SSID_PREFIX_LEN = 25;
1043 static constexpr size_t AP_SSID_SUFFIX_LEN = 7;
1044
1045 const auto &app_name = App.get_name();
1046 const char *name_ptr = app_name.c_str();
1047 size_t name_len = app_name.length();
1048
1049 if (name_len <= AP_SSID_MAX_LEN) {
1050 // Name fits, use directly
1051 this->ap_.set_ssid(name_ptr);
1052 } else {
1053 // Name too long, need to truncate into stack buffer
1054 char ssid_buf[AP_SSID_MAX_LEN + 1];
1056 // Keep first 25 chars and last 7 chars (MAC suffix), remove middle
1057 memcpy(ssid_buf, name_ptr, AP_SSID_PREFIX_LEN);
1058 memcpy(ssid_buf + AP_SSID_PREFIX_LEN, name_ptr + name_len - AP_SSID_SUFFIX_LEN, AP_SSID_SUFFIX_LEN);
1059 } else {
1060 memcpy(ssid_buf, name_ptr, AP_SSID_MAX_LEN);
1061 }
1062 ssid_buf[AP_SSID_MAX_LEN] = '\0';
1063 this->ap_.set_ssid(ssid_buf);
1064 }
1065 }
1066 this->ap_setup_ = this->wifi_start_ap_(this->ap_);
1067#ifdef USE_WIFI_AP_EXCLUSIVE
1068 // A failed start must not leave the radio in AP mode, which would refuse
1069 // the STA attempts until the next try.
1070 if (!this->ap_setup_)
1071 this->wifi_mode_({}, false);
1072#endif
1073
1074 char ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
1075 ESP_LOGCONFIG(TAG,
1076 "Setting up AP:\n"
1077 " AP SSID: '%s'\n"
1078 " AP Password: '%s'\n"
1079 " IP Address: %s",
1080 this->ap_.ssid_.c_str(), this->ap_.password_.c_str(), this->wifi_soft_ap_ip().str_to(ip_buf));
1081
1082#ifdef USE_WIFI_MANUAL_IP
1083 auto manual_ip = this->ap_.get_manual_ip();
1084 if (manual_ip.has_value()) {
1085 char static_ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
1086 char gateway_buf[network::IP_ADDRESS_BUFFER_SIZE];
1087 char subnet_buf[network::IP_ADDRESS_BUFFER_SIZE];
1088 ESP_LOGCONFIG(TAG,
1089 " AP Static IP: '%s'\n"
1090 " AP Gateway: '%s'\n"
1091 " AP Subnet: '%s'",
1092 manual_ip->static_ip.str_to(static_ip_buf), manual_ip->gateway.str_to(gateway_buf),
1093 manual_ip->subnet.str_to(subnet_buf));
1094 }
1095#endif
1096
1097 // Where the AP runs on its own, the STA state machine also waits in this
1098 // state until the AP pauses.
1099 if (!this->has_sta() || (WIFI_AP_EXCLUSIVE && this->ap_setup_)) {
1101 }
1102}
1103
1104#ifdef USE_WIFI_AP_EXCLUSIVE
1106 ESP_LOGI(TAG, "Pausing AP to try the networks");
1107 // Scanning and connecting turn STA back on.
1108 this->disable_ap_();
1109 this->ap_setup_ = false;
1110 // Clients drop with the AP without a disconnect event for each.
1111 this->ap_clients_ = 0;
1113 // A fresh connection cycle, as at boot, rather than resuming the one the
1114 // AP cut short.
1116 this->num_retried_ = 0;
1118}
1119#endif
1120
1122#ifdef USE_CAPTIVE_PORTAL
1123 if (this->is_captive_portal_active_()) {
1125 }
1126#endif
1127 ESP_LOGD(TAG, "Disabling AP");
1128 this->wifi_mode_({}, false);
1129}
1130
1132 this->ap_ = ap;
1133 this->has_ap_ = true;
1134}
1135#endif // USE_WIFI_AP
1136
1137void WiFiComponent::init_sta(size_t count) { this->sta_.init(count); }
1138void WiFiComponent::add_sta(const WiFiAP &ap) { this->sta_.push_back(ap); }
1140 // Clear roaming state - no more configured networks
1141 this->clear_roaming_state_();
1142 this->sta_.clear();
1143 this->selected_sta_index_ = -1;
1144}
1146 this->clear_sta(); // Also clears roaming state
1147 this->init_sta(1);
1148 this->add_sta(ap);
1149 this->selected_sta_index_ = 0;
1150 // When new credentials are set (e.g., from improv), skip cooldown to retry immediately
1151 this->skip_cooldown_next_cycle_ = true;
1152}
1153
1155 const WiFiAP *config = this->get_selected_sta_();
1156 if (config == nullptr) {
1157 ESP_LOGE(TAG, "No valid network config (selected_sta_index_=%d, sta_.size()=%zu)",
1158 static_cast<int>(this->selected_sta_index_), this->sta_.size());
1159 // Return empty params - caller should handle this gracefully
1160 return WiFiAP();
1161 }
1162
1163 WiFiAP params = *config;
1164
1165 switch (this->retry_phase_) {
1167#ifdef USE_WIFI_FAST_CONNECT
1169#endif
1170 // Fast connect phases: use config-only (no scan results)
1171 // BSSID/channel from config if user specified them, otherwise empty
1172 break;
1173
1176 // Hidden network mode: clear BSSID/channel to trigger probe request
1177 // (both explicit hidden and retry hidden use same behavior)
1178 params.clear_bssid();
1179 params.clear_channel();
1180 break;
1181
1183 // Scan-based phase: always use best scan result (index 0 - highest priority after sorting)
1184 if (!this->scan_result_.empty()) {
1185 apply_scan_result_to_params(params, this->scan_result_[0]);
1186 }
1187 break;
1188
1190 // Should not be building params during restart
1191 break;
1192 }
1193
1194 return params;
1195}
1196
1198 const WiFiAP *config = this->get_selected_sta_();
1199 return config ? *config : WiFiAP{};
1200}
1201void WiFiComponent::save_wifi_sta(const std::string &ssid, const std::string &password) {
1202 this->save_wifi_sta(ssid.c_str(), password.c_str());
1203}
1204void WiFiComponent::save_wifi_sta(const char *ssid, const char *password) {
1205 SavedWifiSettings save{}; // zero-initialized - all bytes set to \0, guaranteeing null termination
1206 strncpy(save.ssid, ssid, sizeof(save.ssid) - 1); // max 32 chars, byte 32 remains \0
1207 strncpy(save.password, password, sizeof(save.password) - 1); // max 64 chars, byte 64 remains \0
1208 this->pref_.save(&save);
1209 // ensure it's written immediately
1211
1212 WiFiAP sta{};
1213 sta.set_ssid(ssid);
1214 sta.set_password(password);
1215 this->set_sta(sta);
1216
1217 // Trigger connection attempt (exits cooldown if needed, no-op if already connecting/connected)
1218 this->connect_soon_();
1219}
1220
1222#ifdef USE_WIFI_AP_EXCLUSIVE
1223 // New credentials from the portal: the AP pauses for them once its answer
1224 // is out, and the STA state machine picks them up from there.
1225 if (this->ap_setup_) {
1226 this->set_timeout(AP_HANDOVER_TIMEOUT_ID, WIFI_AP_EXCLUSIVE_HANDOVER_MS, [this]() {
1227 if (this->ap_setup_)
1228 this->pause_exclusive_ap_();
1229 });
1230 return;
1231 }
1232#endif
1233 // Only trigger retry if we're in cooldown - if already connecting/connected, do nothing
1235 ESP_LOGD(TAG, "Exiting cooldown early due to new WiFi credentials");
1236 this->retry_connect();
1237 }
1238}
1239
1241 // Log connection attempt at INFO level with priority
1242 char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
1243 int8_t priority = 0;
1244
1245 if (ap.has_bssid()) {
1246 format_mac_addr_upper(ap.get_bssid().data(), bssid_s);
1247 priority = this->get_sta_priority(ap.get_bssid());
1248 }
1249
1250 ESP_LOGI(TAG,
1251 "Connecting to " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") " (priority %d, attempt %u/%u in phase %s)...",
1252 ap.ssid_.c_str(), ap.has_bssid() ? bssid_s : LOG_STR_LITERAL("any"), priority, this->num_retried_ + 1,
1253 get_max_retries_for_phase(this->retry_phase_), LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_)));
1254
1255#ifdef ESPHOME_LOG_HAS_VERBOSE
1256 ESP_LOGV(TAG,
1257 "Connection Params:\n"
1258 " SSID: '%s'",
1259 ap.ssid_.c_str());
1260 if (ap.has_bssid()) {
1261 ESP_LOGV(TAG, " BSSID: %s", bssid_s);
1262 } else {
1263 ESP_LOGV(TAG, " BSSID: Not Set");
1264 }
1265
1266#ifdef USE_WIFI_WPA2_EAP
1267 const auto &eap_opt = ap.get_eap();
1268 if (eap_opt.has_value()) {
1269 const EAPAuth &eap_config = *eap_opt;
1270 // clang-format off
1271 ESP_LOGV(
1272 TAG,
1273 " WPA2 Enterprise authentication configured:\n"
1274 " Identity: " LOG_SECRET("'%s'") "\n"
1275 " Username: " LOG_SECRET("'%s'") "\n"
1276 " Password: " LOG_SECRET("'%s'"),
1277 eap_config.identity.c_str(), eap_config.username.c_str(), eap_config.password.c_str());
1278 // clang-format on
1279#if defined(USE_ESP32) && defined(USE_WIFI_WPA2_EAP) && ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
1280 ESP_LOGV(TAG, " TTLS Phase 2: " LOG_SECRET("'%s'"), eap_phase2_to_str(eap_config.ttls_phase_2));
1281#endif
1282 bool ca_cert_present = eap_config.ca_cert != nullptr && strlen(eap_config.ca_cert);
1283 bool client_cert_present = eap_config.client_cert != nullptr && strlen(eap_config.client_cert);
1284 bool client_key_present = eap_config.client_key != nullptr && strlen(eap_config.client_key);
1285 ESP_LOGV(TAG,
1286 " CA Cert: %s\n"
1287 " Client Cert: %s\n"
1288 " Client Key: %s",
1289 ca_cert_present ? LOG_STR_LITERAL("present") : LOG_STR_LITERAL("not present"),
1290 client_cert_present ? LOG_STR_LITERAL("present") : LOG_STR_LITERAL("not present"),
1291 client_key_present ? LOG_STR_LITERAL("present") : LOG_STR_LITERAL("not present"));
1292 } else {
1293#endif
1294 ESP_LOGV(TAG, " Password: " LOG_SECRET("'%s'"), ap.password_.c_str());
1295#ifdef USE_WIFI_WPA2_EAP
1296 }
1297#endif
1298 if (ap.has_channel()) {
1299 ESP_LOGV(TAG, " Channel: %u", ap.get_channel());
1300 } else {
1301 ESP_LOGV(TAG, " Channel not set");
1302 }
1303#ifdef USE_WIFI_MANUAL_IP
1304 auto manual_ip = ap.get_manual_ip();
1305 if (manual_ip.has_value()) {
1306 ManualIP m = *manual_ip;
1307 char static_ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
1308 char gateway_buf[network::IP_ADDRESS_BUFFER_SIZE];
1309 char subnet_buf[network::IP_ADDRESS_BUFFER_SIZE];
1310 char dns1_buf[network::IP_ADDRESS_BUFFER_SIZE];
1311 char dns2_buf[network::IP_ADDRESS_BUFFER_SIZE];
1312 ESP_LOGV(TAG, " Manual IP: Static IP=%s Gateway=%s Subnet=%s DNS1=%s DNS2=%s", m.static_ip.str_to(static_ip_buf),
1313 m.gateway.str_to(gateway_buf), m.subnet.str_to(subnet_buf), m.dns1.str_to(dns1_buf),
1314 m.dns2.str_to(dns2_buf));
1315 } else
1316#endif
1317 {
1318 ESP_LOGV(TAG, " Using DHCP IP");
1319 }
1320 ESP_LOGV(TAG, " Hidden: %s", YESNO(ap.get_hidden()));
1321#endif
1322
1323 // Clear any stale error from previous connection attempt.
1324 // This is the canonical location for clearing the flag since all connection
1325 // attempts go through start_connecting(). restart_adapter() also clears it, as it
1326 // enters COOLDOWN without calling start_connecting(), and ESP8266 clears it again
1327 // after wifi_station_connect(), whose callbacks fire synchronously.
1328 this->error_from_callback_ = false;
1329
1330 if (!this->wifi_sta_connect_(ap)) {
1331 ESP_LOGE(TAG, "wifi_sta_connect_ failed");
1332 // Enter cooldown to allow WiFi hardware to stabilize
1333 // (immediate failure suggests hardware not ready, different from connection timeout)
1335 } else {
1337 }
1338 this->action_started_ = millis();
1339}
1340
1341const LogString *get_signal_bars(int8_t rssi) {
1342 // LOWER ONE QUARTER BLOCK
1343 // Unicode: U+2582, UTF-8: E2 96 82
1344 // LOWER HALF BLOCK
1345 // Unicode: U+2584, UTF-8: E2 96 84
1346 // LOWER THREE QUARTERS BLOCK
1347 // Unicode: U+2586, UTF-8: E2 96 86
1348 // FULL BLOCK
1349 // Unicode: U+2588, UTF-8: E2 96 88
1350 if (rssi >= -50) {
1351 return LOG_STR("\033[0;32m" // green
1352 "\xe2\x96\x82"
1353 "\xe2\x96\x84"
1354 "\xe2\x96\x86"
1355 "\xe2\x96\x88"
1356 "\033[0m");
1357 } else if (rssi >= -65) {
1358 return LOG_STR("\033[0;33m" // yellow
1359 "\xe2\x96\x82"
1360 "\xe2\x96\x84"
1361 "\xe2\x96\x86"
1362 "\033[0;37m"
1363 "\xe2\x96\x88"
1364 "\033[0m");
1365 } else if (rssi >= -85) {
1366 return LOG_STR("\033[0;33m" // yellow
1367 "\xe2\x96\x82"
1368 "\xe2\x96\x84"
1369 "\033[0;37m"
1370 "\xe2\x96\x86"
1371 "\xe2\x96\x88"
1372 "\033[0m");
1373 } else {
1374 return LOG_STR("\033[0;31m" // red
1375 "\xe2\x96\x82"
1376 "\033[0;37m"
1377 "\xe2\x96\x84"
1378 "\xe2\x96\x86"
1379 "\xe2\x96\x88"
1380 "\033[0m");
1381 }
1382}
1383
1385 bssid_t bssid = wifi_bssid();
1386 char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
1387 format_mac_addr_upper(bssid.data(), bssid_s);
1388 // Use stack buffers for IP address formatting to avoid heap allocations
1389 char ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
1390 for (auto &ip : wifi_sta_ip_addresses()) {
1391 if (ip.is_set()) {
1392 ESP_LOGCONFIG(TAG, " IP Address: %s", ip.str_to(ip_buf));
1393 }
1394 }
1395 int8_t rssi = wifi_rssi();
1396 // Use stack buffers for SSID and all IP addresses to avoid heap allocations
1397 char ssid_buf[SSID_BUFFER_SIZE];
1398 char subnet_buf[network::IP_ADDRESS_BUFFER_SIZE];
1399 char gateway_buf[network::IP_ADDRESS_BUFFER_SIZE];
1400 char dns1_buf[network::IP_ADDRESS_BUFFER_SIZE];
1401 char dns2_buf[network::IP_ADDRESS_BUFFER_SIZE];
1402 // clang-format off
1403 ESP_LOGCONFIG(TAG,
1404 " SSID: " LOG_SECRET("'%s'") "\n"
1405 " BSSID: " LOG_SECRET("%s") "\n"
1406 " Hostname: '%s'\n"
1407 " Signal strength: %d dB %s\n"
1408 " Channel: %" PRId32 "\n"
1409 " Subnet: %s\n"
1410 " Gateway: %s\n"
1411 " DNS1: %s\n"
1412 " DNS2: %s",
1413 wifi_ssid_to(ssid_buf), bssid_s, App.get_name().c_str(), rssi, LOG_STR_ARG(get_signal_bars(rssi)),
1414 get_wifi_channel(), wifi_subnet_mask_().str_to(subnet_buf), wifi_gateway_ip_().str_to(gateway_buf),
1415 wifi_dns_ip_(0).str_to(dns1_buf), wifi_dns_ip_(1).str_to(dns2_buf));
1416 // clang-format on
1417#ifdef ESPHOME_LOG_HAS_VERBOSE
1418 if (const WiFiAP *config = this->get_selected_sta_(); config && config->has_bssid()) {
1419 ESP_LOGV(TAG, " Priority: %d", this->get_sta_priority(config->get_bssid()));
1420 }
1421#endif
1422#ifdef USE_WIFI_11KV_SUPPORT
1423 ESP_LOGCONFIG(TAG,
1424 " BTM: %s\n"
1425 " RRM: %s",
1426 this->btm_ ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"),
1427 this->rrm_ ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"));
1428#endif
1429}
1430
1433 return;
1434
1435 ESP_LOGD(TAG, "Enabling");
1437#ifdef USE_ESP32
1438 // Idempotent — only allocates DMA buffers + netifs on the first call. After this,
1439 // start() can safely run.
1440 this->wifi_lazy_init_();
1441#endif
1442 this->start();
1443}
1444
1447 return;
1448
1449 ESP_LOGD(TAG, "Disabling");
1451 this->wifi_disconnect_();
1452 this->wifi_mode_(false, false);
1453}
1454
1456 this->action_started_ = millis();
1457 ESP_LOGD(TAG, "Starting scan");
1458 this->wifi_scan_start_(this->passive_scan_);
1460}
1461
1495[[nodiscard]] inline static bool wifi_scan_result_is_better(const WiFiScanResult &a, const WiFiScanResult &b) {
1496 // Matching networks always come before non-matching
1497 if (a.get_matches() && !b.get_matches())
1498 return true;
1499 if (!a.get_matches() && b.get_matches())
1500 return false;
1501
1502 // Both matching: check priority first (tracks connection failures via priority degradation)
1503 // Priority is decreased when a BSSID fails to connect, so lower priority = previously failed
1504 if (a.get_matches() && b.get_matches() && a.get_priority() != b.get_priority()) {
1505 return a.get_priority() > b.get_priority();
1506 }
1507
1508 // Use RSSI as tiebreaker (for equal-priority matching networks or all non-matching networks)
1509 return a.get_rssi() > b.get_rssi();
1510}
1511
1512// Helper function for insertion sort of WiFi scan results
1513// Using insertion sort instead of std::stable_sort saves flash memory
1514// by avoiding template instantiations (std::rotate, std::stable_sort, lambdas)
1515// IMPORTANT: This sort is stable (preserves relative order of equal elements)
1516//
1517// Uses raw memcpy instead of copy assignment to avoid CompactString's
1518// destructor/constructor overhead (heap delete[]/new[] for long SSIDs).
1519// Copy assignment calls ~CompactString() then placement-new for every shift,
1520// which means delete[]/new[] per shift for heap-allocated SSIDs. With 70+
1521// networks (e.g., captive portal showing full scan results), this caused
1522// event loop blocking from hundreds of heap operations in a tight loop.
1523//
1524// This is safe because we're permuting elements within the same array —
1525// each slot is overwritten exactly once, so no ownership duplication occurs.
1526// All members of WiFiScanResult are either trivially copyable (bssid, channel,
1527// rssi, priority, flags) or CompactString, which stores either inline data or
1528// a heap pointer — never a self-referential pointer (unlike std::string's SSO
1529// on some implementations). This was not possible before PR#13472 replaced
1530// std::string with CompactString, since std::string's internal layout is
1531// implementation-defined and may use self-referential pointers.
1532//
1533// TODO: If C++ standardizes std::trivially_relocatable, add the assertion for
1534// WiFiScanResult/CompactString here to formally express the memcpy safety guarantee.
1535template<typename VectorType> static void insertion_sort_scan_results(VectorType &results) {
1536 // memcpy-based sort requires no self-referential pointers or virtual dispatch.
1537 // These static_asserts guard the assumptions. If any fire, the memcpy sort
1538 // must be reviewed for safety before updating the expected values.
1539 //
1540 // No vtable pointers (memcpy would corrupt vptr)
1541 static_assert(!std::is_polymorphic<WiFiScanResult>::value, "WiFiScanResult must not have vtable");
1542 static_assert(!std::is_polymorphic<CompactString>::value, "CompactString must not have vtable");
1543 // Standard layout ensures predictable memory layout with no virtual bases
1544 // and no mixed-access-specifier reordering
1545 static_assert(std::is_standard_layout<WiFiScanResult>::value, "WiFiScanResult must be standard layout");
1546 static_assert(std::is_standard_layout<CompactString>::value, "CompactString must be standard layout");
1547 // Size checks catch added/removed fields that may need safety review
1548 static_assert(sizeof(WiFiScanResult) == 32, "WiFiScanResult size changed - verify memcpy sort is still safe");
1549 static_assert(sizeof(CompactString) == 20, "CompactString size changed - verify memcpy sort is still safe");
1550 // Alignment must match for reinterpret_cast of key_buf to be valid
1551 static_assert(alignof(WiFiScanResult) <= alignof(std::max_align_t), "WiFiScanResult alignment exceeds max_align_t");
1552 const size_t size = results.size();
1553 constexpr size_t elem_size = sizeof(WiFiScanResult);
1554 // Suppress warnings for intentional memcpy on non-trivially-copyable type.
1555 // Safety is guaranteed by the static_asserts above and the permutation invariant.
1556 // NOLINTNEXTLINE(bugprone-undefined-memory-manipulation)
1557 auto *memcpy_fn = &memcpy;
1558 for (size_t i = 1; i < size; i++) {
1559 alignas(WiFiScanResult) uint8_t key_buf[elem_size];
1560 memcpy_fn(key_buf, &results[i], elem_size);
1561 const auto &key = *reinterpret_cast<const WiFiScanResult *>(key_buf);
1562 int32_t j = i - 1;
1563
1564 // Move elements that are worse than key to the right
1565 // For stability, we only move if key is strictly better than results[j]
1566 while (j >= 0 && wifi_scan_result_is_better(key, results[j])) {
1567 memcpy_fn(&results[j + 1], &results[j], elem_size);
1568 j--;
1569 }
1570 memcpy_fn(&results[j + 1], key_buf, elem_size);
1571 }
1572}
1573
1574// Helper function to log matching scan results - marked noinline to prevent re-inlining into loop
1575//
1576// IMPORTANT: This function deliberately uses a SINGLE log call to minimize blocking.
1577// In environments with many matching networks (e.g., 18+ mesh APs), multiple log calls
1578// per network would block the main loop for an unacceptable duration. Each log call
1579// has overhead from UART transmission, so combining INFO+DEBUG into one line halves
1580// the blocking time. Do NOT split this into separate ESP_LOGI/ESP_LOGD calls.
1581__attribute__((noinline)) static void log_scan_result(const WiFiScanResult &res) {
1582 char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
1583 auto bssid = res.get_bssid();
1584 format_mac_addr_upper(bssid.data(), bssid_s);
1585
1586#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG
1587 // Single combined log line with all details when DEBUG enabled
1588 ESP_LOGI(TAG, "- '%s' %s" LOG_SECRET("(%s) ") "%s Ch:%2u %3ddB P:%d", res.get_ssid().c_str(),
1589 res.get_is_hidden() ? LOG_STR_LITERAL("(HIDDEN) ") : LOG_STR_LITERAL(""), bssid_s,
1590 LOG_STR_ARG(get_signal_bars(res.get_rssi())), res.get_channel(), res.get_rssi(), res.get_priority());
1591#else
1592 ESP_LOGI(TAG, "- '%s' %s" LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(),
1593 res.get_is_hidden() ? LOG_STR_LITERAL("(HIDDEN) ") : LOG_STR_LITERAL(""), bssid_s,
1594 LOG_STR_ARG(get_signal_bars(res.get_rssi())));
1595#endif
1596}
1597
1599 if (!this->scan_done_) {
1600 if (millis() - this->action_started_ > WIFI_SCAN_TIMEOUT_MS) {
1601 ESP_LOGE(TAG, "Scan timeout");
1602 this->retry_connect();
1603 }
1604 return;
1605 }
1606 this->scan_done_ = false;
1607 // A driver filtered scan saw one SSID; a portal that started during it still needs a full scan
1610
1611 if (this->scan_result_.empty()) {
1612 ESP_LOGW(TAG, "No networks found");
1613 this->retry_connect();
1614 return;
1615 }
1616
1617 ESP_LOGD(TAG, "Found networks:");
1618 {
1619 ScanResultsLock lock(this);
1620 for (auto &res : this->scan_result_) {
1621 for (auto &ap : this->sta_) {
1622 if (res.matches(ap)) {
1623 res.set_matches(true);
1624 // Cache priority lookup - do single search instead of 2 separate searches
1625 const bssid_t &bssid = res.get_bssid();
1626 if (!this->has_sta_priority(bssid)) {
1627 this->set_sta_priority(bssid, ap.get_priority());
1628 }
1629 res.set_priority(this->get_sta_priority(bssid));
1630 break;
1631 }
1632 }
1633 }
1634
1635 // Sort scan results using insertion sort for better memory efficiency
1636 insertion_sort_scan_results(this->scan_result_);
1637 }
1638
1639 // Log matching networks (non-matching already logged at VERBOSE in scan callback)
1640 for (auto &res : this->scan_result_) {
1641 if (res.get_matches()) {
1642 log_scan_result(res);
1643 }
1644 }
1645
1646 // SYNCHRONIZATION POINT: Establish link between scan_result_[0] and selected_sta_index_
1647 // After sorting, scan_result_[0] contains the best network. Now find which sta_[i] config
1648 // matches that network and record it in selected_sta_index_. This keeps the two indices
1649 // synchronized so build_params_for_current_phase_() can safely use both to build connection parameters.
1650 const WiFiScanResult &scan_res = this->scan_result_[0];
1651 bool found_match = false;
1652 if (scan_res.get_matches()) {
1653 for (size_t i = 0; i < this->sta_.size(); i++) {
1654 if (scan_res.matches(this->sta_[i])) {
1655 // Safe cast: sta_.size() limited to MAX_WIFI_NETWORKS (127) in __init__.py validation
1656 // No overflow check needed - YAML validation prevents >127 networks
1657 this->selected_sta_index_ = static_cast<int8_t>(i); // Links scan_result_[0] with sta_[i]
1658 found_match = true;
1659 break;
1660 }
1661 }
1662 }
1663
1664 if (!found_match) {
1665 ESP_LOGW(TAG, "No matching network found");
1666 // No scan results matched our configured networks - transition directly to hidden mode
1667 // Don't call retry_connect() since we never attempted a connection (no BSSID to penalize)
1669 // If no hidden networks to try, skip connection attempt (will be handled on next loop)
1670 if (this->selected_sta_index_ == -1) {
1671 return;
1672 }
1673 // Now start connection attempt in hidden mode
1675 return; // scan started, wait for next loop iteration
1676 }
1677
1678 yield();
1679
1680 WiFiAP params = this->build_params_for_current_phase_();
1681 // Ensure we're in SCAN_CONNECTING phase when connecting with scan results
1682 // (needed when scan was started directly without transition_to_phase_, e.g., initial scan)
1683 this->start_connecting(params);
1684}
1685
1687 char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
1688 ESP_LOGCONFIG(TAG,
1689 "WiFi:\n"
1690 " Local MAC: %s\n"
1691 " Connected: %s",
1692 get_mac_address_pretty_into_buffer(mac_s), YESNO(this->is_connected()));
1693 if (this->is_disabled()) {
1694 ESP_LOGCONFIG(TAG, " Disabled");
1695 return;
1696 }
1697#if defined(USE_ESP32) && defined(SOC_WIFI_SUPPORT_5G)
1698 const char *band_mode_s;
1699 switch (this->band_mode_) {
1700 case WIFI_BAND_MODE_2G_ONLY:
1701 band_mode_s = "2.4GHz";
1702 break;
1703 case WIFI_BAND_MODE_5G_ONLY:
1704 band_mode_s = "5GHz";
1705 break;
1706 case WIFI_BAND_MODE_AUTO:
1707 default:
1708 band_mode_s = "Auto";
1709 break;
1710 }
1711 ESP_LOGCONFIG(TAG, " Band Mode: %s", band_mode_s);
1712#endif
1713#ifdef USE_WIFI_PHY_MODE
1714 ESP_LOGCONFIG(TAG, " PHY Mode: %s", LOG_STR_ARG(phy_mode_to_log_string(this->phy_mode_)));
1715#endif
1716 if (this->is_connected()) {
1717 this->print_connect_params_();
1718 }
1719}
1720
1722 auto status = this->wifi_sta_connect_status_();
1723
1725 char ssid_buf[SSID_BUFFER_SIZE];
1726 if (wifi_ssid_to(ssid_buf)[0] == '\0') {
1727 ESP_LOGW(TAG, "Connection incomplete");
1728 this->retry_connect();
1729 return;
1730 }
1731
1732 ESP_LOGI(TAG, "Connected");
1733 // Warn if we had to retry with hidden network mode for a network that's not marked hidden
1734 // Only warn if we actually connected without scan data (SSID only), not if scan succeeded on retry
1735 if (const WiFiAP *config = this->get_selected_sta_(); this->retry_phase_ == WiFiRetryPhase::RETRY_HIDDEN &&
1736 config && !config->get_hidden() &&
1737 this->scan_result_.empty()) {
1738 ESP_LOGW(TAG, LOG_SECRET("'%s'") " should be marked hidden", config->ssid_.c_str());
1739 }
1740 // Reset to initial phase on successful connection (don't log transition, just reset state)
1742 this->num_retried_ = 0;
1743#ifdef USE_WIFI_AP
1744 if (this->has_ap()) {
1745 this->disable_ap_();
1746 }
1747#endif
1748#ifdef USE_IMPROV_BLE
1749 if (this->is_improv_ble_active_()) {
1751 }
1752#endif
1753
1755 // Refresh is_connected() cache; loop()'s refresh ran before this transition.
1757 this->num_retried_ = 0;
1758 this->print_connect_params_();
1759
1760 // Reset roaming state on successful connection
1761 this->roaming_last_check_ = now;
1762 // Only preserve attempts if reconnecting after a failed roam attempt
1763 // This prevents ping-pong between APs when a roam target is unreachable
1765 // Successful roam to better AP on first try - reset attempts so we can roam again later
1766 ESP_LOGD(TAG, "Roam successful");
1767 this->roaming_attempts_ = 0;
1768 } else if (this->roaming_state_ == RoamingState::RECONNECTING) {
1769 // Check if we ended up on the roam target despite needing a retry
1770 // (e.g., first connect failed but scan-based retry found and connected to the same better AP)
1771 bssid_t current_bssid = this->wifi_bssid();
1772 if (this->roaming_target_bssid_ != bssid_t{} && current_bssid == this->roaming_target_bssid_) {
1773 char bssid_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
1774 format_mac_addr_upper(current_bssid.data(), bssid_buf);
1775 ESP_LOGD(TAG, "Roam successful (via retry, attempt %u/%u) to %s", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS,
1776 bssid_buf);
1777 this->roaming_attempts_ = 0;
1778 } else if (this->roaming_target_bssid_ != bssid_t{}) {
1779 // Failed roam to specific target, reconnected to different AP - keep attempts to prevent ping-pong
1780 ESP_LOGD(TAG, "Reconnected after failed roam (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
1781 } else {
1782 // Reconnected after scan-induced disconnect (no roam target) - keep attempts
1783 ESP_LOGD(TAG, "Reconnected after roam scan (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
1784 }
1785 } else {
1786 // Normal connection (boot, credentials changed, etc.)
1787 this->roaming_attempts_ = 0;
1788 }
1790 this->roaming_target_bssid_ = {};
1791 this->roaming_scan_end_ = 0;
1792
1793 // Clear all priority penalties - the next reconnect will happen when an AP disconnects,
1794 // which means the landscape has likely changed and previous tracked failures are stale
1796
1797#ifdef USE_WIFI_FAST_CONNECT
1799#endif
1800
1801 this->release_scan_results_();
1802
1803#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
1804 // Notify listeners now that state machine has reached STA_CONNECTED
1805 // This ensures wifi.connected condition returns true in listener automations
1807#endif
1808
1809#if defined(USE_ESP8266) && defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP)
1810 // On ESP8266, GOT_IP event may not fire for static IP configurations,
1811 // so notify IP state listeners here as a fallback.
1812 if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) {
1814 }
1815#endif
1816
1817 return;
1818 }
1819
1820 if (now - this->action_started_ > WIFI_CONNECT_TIMEOUT_MS) {
1821 ESP_LOGW(TAG, "Connection timeout, aborting connection attempt");
1822 this->wifi_disconnect_();
1823 this->retry_connect();
1824 return;
1825 }
1826
1827 if (this->error_from_callback_) {
1828 // ESP8266: logging done in callback, listeners deferred via pending_.disconnect
1829 // Other platforms: just log generic failure message
1830#ifndef USE_ESP8266
1831 ESP_LOGW(TAG, "Connecting to network failed (callback)");
1832#endif
1833 this->retry_connect();
1834 return;
1835 }
1836
1838 return;
1839 }
1840
1842 ESP_LOGW(TAG, "Network no longer found");
1843 this->retry_connect();
1844 return;
1845 }
1846
1848 ESP_LOGW(TAG, "Connecting to network failed");
1849 this->retry_connect();
1850 return;
1851 }
1852
1853 ESP_LOGW(TAG, "Unknown connection status %d", (int) status);
1854 this->retry_connect();
1855}
1856
1864 switch (this->retry_phase_) {
1866#ifdef USE_WIFI_FAST_CONNECT
1868 // INITIAL_CONNECT and FAST_CONNECT_CYCLING_APS: no retries, try next AP or fall back to scan
1869 if (this->selected_sta_index_ < static_cast<int8_t>(this->sta_.size()) - 1) {
1870 return WiFiRetryPhase::FAST_CONNECT_CYCLING_APS; // Move to next AP
1871 }
1872#endif
1873 // Check if we should try explicit hidden networks before scanning
1874 // This handles reconnection after connection loss where first network is hidden
1875 if (!this->sta_.empty() && this->sta_[0].get_hidden()) {
1877 }
1878 // No more APs to try, fall back to scan
1880
1882 // Try all explicitly hidden networks before scanning
1883 if (this->num_retried_ + 1 < WIFI_RETRY_COUNT_PER_SSID) {
1884 return WiFiRetryPhase::EXPLICIT_HIDDEN; // Keep retrying same SSID
1885 }
1886
1887 // Exhausted retries on current SSID - check for more explicitly hidden networks
1888 // Stop when we reach a visible network (proceed to scanning)
1889 size_t next_index = this->selected_sta_index_ + 1;
1890 if (next_index < this->sta_.size() && this->sta_[next_index].get_hidden()) {
1891 // Found another explicitly hidden network
1893 }
1894
1895 // No more consecutive explicitly hidden networks
1896 // If ALL networks are hidden, skip scanning and go directly to restart
1897 if (this->find_first_non_hidden_index_() < 0) {
1899 }
1900 // Otherwise proceed to scanning for non-hidden networks
1902 }
1903
1905 // If scan found no networks or no matching networks, skip to hidden network mode
1906 if (this->scan_result_.empty() || !this->scan_result_[0].get_matches()) {
1908 }
1909
1910 if (this->num_retried_ + 1 < WIFI_RETRY_COUNT_PER_BSSID) {
1911 return WiFiRetryPhase::SCAN_CONNECTING; // Keep retrying same BSSID
1912 }
1913
1914 // Exhausted retries on current BSSID (scan_result_[0])
1915 // Its priority has been decreased, so on next scan it will be sorted lower
1916 // and we'll try the next best BSSID.
1917 // Check if there are any potentially hidden networks to try
1918 if (this->find_next_hidden_sta_(-1) >= 0) {
1919 return WiFiRetryPhase::RETRY_HIDDEN; // Found hidden networks to try
1920 }
1921 // No hidden networks - always go through RESTARTING_ADAPTER phase
1922 // This ensures num_retried_ gets reset and a fresh scan is triggered
1923 // The actual adapter restart will be skipped if captive portal/improv is active
1925
1927 // If no hidden SSIDs to try (selected_sta_index_ == -1), skip directly to rescan
1928 if (this->selected_sta_index_ >= 0) {
1929 if (this->num_retried_ + 1 < WIFI_RETRY_COUNT_PER_SSID) {
1930 return WiFiRetryPhase::RETRY_HIDDEN; // Keep retrying same SSID
1931 }
1932
1933 // Exhausted retries on current SSID - check if there are more potentially hidden SSIDs to try
1934 if (this->selected_sta_index_ < static_cast<int8_t>(this->sta_.size()) - 1) {
1935 // Check if find_next_hidden_sta_() would actually find another hidden SSID
1936 // as it might have been seen in the scan results and we want to skip those
1937 // otherwise we will get stuck in RETRY_HIDDEN phase
1938 if (this->find_next_hidden_sta_(this->selected_sta_index_) != -1) {
1939 // More hidden SSIDs available - stay in RETRY_HIDDEN, advance will happen in retry_connect()
1941 }
1942 }
1943 }
1944 // Exhausted all potentially hidden SSIDs - always go through RESTARTING_ADAPTER
1945 // This ensures num_retried_ gets reset and a fresh scan is triggered
1946 // The actual adapter restart will be skipped if captive portal/improv is active
1948
1950 // After restart, go back to explicit hidden if we went through it initially
1953 }
1954 // Skip scanning when captive portal/improv is active to avoid disrupting AP,
1955 // BUT only if we've already completed at least one scan AFTER the portal started.
1956 // When captive portal first starts, scan results may be filtered/stale, so we need
1957 // to do one full scan to populate available networks for the captive portal UI.
1958 //
1959 // WHY SCANNING DISRUPTS AP MODE:
1960 // WiFi scanning requires the radio to leave the AP's channel and hop through
1961 // other channels to listen for beacons. During this time (even for passive scans),
1962 // the AP cannot service connected clients - they experience disconnections or
1963 // timeouts. On ESP32, even passive scans cause brief but noticeable disruptions
1964 // that break captive portal HTTP requests and DNS lookups.
1965 //
1966 // BLIND RETRY MODE:
1967 // When captive portal/improv is active, we use RETRY_HIDDEN as a "try all networks
1968 // blindly" mode. Since retry_hidden_mode_ is set to BLIND_RETRY (in RESTARTING_ADAPTER
1969 // transition), find_next_hidden_sta_() will treat ALL configured networks as
1970 // candidates, cycling through them without requiring scan results.
1971 //
1972 // This allows users to configure WiFi via captive portal while the device keeps
1973 // attempting to connect to all configured networks in sequence.
1974 // Captive portal needs scan results to show available networks.
1975 // If captive portal is active, only skip scanning if we've done a scan after it started.
1976 // If only improv is active (no captive portal), skip scanning since improv doesn't need results.
1977 if (this->is_captive_portal_active_()) {
1980 }
1981 // Need to scan for captive portal
1982 } else if (this->is_improv_ble_active_()) {
1983 // Improv doesn't need scan results
1985 }
1987 }
1988
1989 // Should never reach here
1991}
1992
2003 WiFiRetryPhase old_phase = this->retry_phase_;
2004
2005 // No-op if staying in same phase
2006 if (old_phase == new_phase) {
2007 return false;
2008 }
2009
2010 ESP_LOGD(TAG, "Retry phase: %s → %s", LOG_STR_ARG(retry_phase_to_log_string(old_phase)),
2011 LOG_STR_ARG(retry_phase_to_log_string(new_phase)));
2012
2013 this->retry_phase_ = new_phase;
2014 this->num_retried_ = 0; // Reset retry counter on phase change
2015
2016 // Phase-specific setup
2017 switch (new_phase) {
2018#ifdef USE_WIFI_FAST_CONNECT
2020 // Move to next configured AP - clear old scan data so new AP is tried with config only
2021 this->selected_sta_index_++;
2022 ScanResultsLock lock(this);
2023 this->scan_result_.clear();
2024 break;
2025 }
2026#endif
2027
2029 // Starting explicit hidden phase - reset to first network
2030 this->selected_sta_index_ = 0;
2031 break;
2032
2034 // Transitioning to scan-based connection
2035#ifdef USE_WIFI_FAST_CONNECT
2037 ESP_LOGI(TAG, "Fast connect exhausted, falling back to scan");
2038 }
2039#endif
2040 // Trigger scan if we don't have scan results OR if transitioning from phases that need fresh scan
2041 if (this->scan_result_.empty() || old_phase == WiFiRetryPhase::EXPLICIT_HIDDEN ||
2043 this->selected_sta_index_ = -1; // Will be set after scan completes
2044 this->start_scanning();
2045 return true; // Started scan, wait for completion
2046 }
2047 // Already have scan results - selected_sta_index_ should already be synchronized
2048 // (set in check_scanning_finished() when scan completed)
2049 // No need to reset it here
2050 break;
2051
2053 // Always reset to first candidate when entering this phase.
2054 // This phase can be entered from:
2055 // - SCAN_CONNECTING: normal flow, find_next_hidden_sta_() skips networks visible in scan
2056 // - RESTARTING_ADAPTER: captive portal active, find_next_hidden_sta_() tries ALL networks
2057 //
2058 // The retry_hidden_mode_ controls the behavior:
2059 // - SCAN_BASED: scan_result_ is checked, visible networks are skipped
2060 // - BLIND_RETRY: scan_result_ is ignored, all networks become candidates
2061 // We don't clear scan_result_ here - the mode controls whether it's consulted.
2063
2064 if (this->selected_sta_index_ == -1) {
2065 ESP_LOGD(TAG, "All SSIDs visible or already tried, skipping hidden mode");
2066 }
2067 break;
2068
2070 // Skip actual adapter restart if captive portal/improv is active
2071 // This allows state machine to reset num_retried_ and trigger fresh scan
2072 // without disrupting the captive portal/improv connection
2073 if (!this->is_captive_portal_active_() && !this->is_improv_ble_active_()) {
2074 this->restart_adapter();
2075 } else {
2076 // Even when skipping full restart, disconnect to clear driver state
2077 // Without this, platforms like LibreTiny may think we're still connecting
2078 this->wifi_disconnect_();
2079 }
2080 // Clear scan flag - we're starting a new retry cycle
2081 // This is critical for captive portal/improv flow: when determine_next_phase_()
2082 // returns RETRY_HIDDEN (because scanning is skipped), find_next_hidden_sta_()
2083 // will see BLIND_RETRY mode and treat ALL networks as candidates,
2084 // effectively cycling through all configured networks without scan results.
2086 // Always enter cooldown after restart (or skip-restart) to allow stabilization
2087 // Use extended cooldown when AP is active to avoid constant scanning that blocks DNS
2089 this->action_started_ = millis();
2090 // Return true to indicate we should wait (go to COOLDOWN) instead of immediately connecting
2091 return true;
2092
2093 default:
2094 break;
2095 }
2096
2097 return false; // Did not start scan, can proceed with connection
2098}
2099
2101 if (!this->sta_priorities_.empty()) {
2102 decltype(this->sta_priorities_)().swap(this->sta_priorities_);
2103 }
2104}
2105
2110 if (this->sta_priorities_.empty()) {
2111 return;
2112 }
2113
2114 int8_t first_priority = this->sta_priorities_[0].priority;
2115
2116 // Only clear if all priorities have been decremented to the minimum value
2117 // At this point, all BSSIDs have been equally penalized and priority info is useless
2118 if (first_priority != std::numeric_limits<int8_t>::min()) {
2119 return;
2120 }
2121
2122 for (const auto &pri : this->sta_priorities_) {
2123 if (pri.priority != first_priority) {
2124 return; // Not all same, nothing to do
2125 }
2126 }
2127
2128 // All priorities are at minimum - clear the vector to save memory and reset
2129 ESP_LOGD(TAG, "Clearing BSSID priorities (all at minimum)");
2131}
2132
2152 // Determine which BSSID we tried to connect to
2153 optional<bssid_t> failed_bssid;
2154
2155 if (this->retry_phase_ == WiFiRetryPhase::SCAN_CONNECTING && !this->scan_result_.empty()) {
2156 // Scan-based phase: always use best result (index 0)
2157 failed_bssid = this->scan_result_[0].get_bssid();
2158 } else if (const WiFiAP *config = this->get_selected_sta_(); config && config->has_bssid()) {
2159 // Config has specific BSSID (fast_connect or user-specified)
2160 failed_bssid = config->get_bssid();
2161 }
2162
2163 if (!failed_bssid.has_value()) {
2164 return; // No BSSID to penalize
2165 }
2166
2167 // Get SSID for logging (use pointer to avoid copy)
2168 const char *ssid = nullptr;
2169 if (this->retry_phase_ == WiFiRetryPhase::SCAN_CONNECTING && !this->scan_result_.empty()) {
2170 ssid = this->scan_result_[0].ssid_.c_str();
2171 } else if (const WiFiAP *config = this->get_selected_sta_()) {
2172 ssid = config->ssid_.c_str();
2173 }
2174
2175 // Only decrease priority on the last attempt for this phase
2176 // This prevents false positives from transient WiFi stack issues
2177 uint8_t max_retries = get_max_retries_for_phase(this->retry_phase_);
2178 bool is_last_attempt = (this->num_retried_ + 1 >= max_retries);
2179
2180 // Decrease priority only on last attempt to avoid false positives from transient failures
2181 int8_t old_priority = this->get_sta_priority(failed_bssid.value());
2182 int8_t new_priority = old_priority;
2183
2184 if (is_last_attempt) {
2185 // Decrease priority, but clamp to int8_t::min to prevent overflow
2186 new_priority =
2187 (old_priority > std::numeric_limits<int8_t>::min()) ? (old_priority - 1) : std::numeric_limits<int8_t>::min();
2188 this->set_sta_priority(failed_bssid.value(), new_priority);
2189 }
2190 char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
2191 format_mac_addr_upper(failed_bssid.value().data(), bssid_s);
2192 ESP_LOGD(TAG, "Failed " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") ", priority %d → %d", ssid != nullptr ? ssid : "",
2193 bssid_s, old_priority, new_priority);
2194
2195 // After adjusting priority, check if all priorities are now at minimum
2196 // If so, clear the vector to save memory and reset for fresh start
2198}
2199
2211 WiFiRetryPhase current_phase = this->retry_phase_;
2212
2213 // Check if we need to advance to next AP/SSID within the same phase
2214#ifdef USE_WIFI_FAST_CONNECT
2215 if (current_phase == WiFiRetryPhase::FAST_CONNECT_CYCLING_APS) {
2216 // Fast connect: always advance to next AP (no retries per AP)
2217 this->selected_sta_index_++;
2218 this->num_retried_ = 0;
2219 ESP_LOGD(TAG, "Next AP in %s", LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_)));
2220 return;
2221 }
2222#endif
2223
2224 if (current_phase == WiFiRetryPhase::EXPLICIT_HIDDEN && this->num_retried_ + 1 >= WIFI_RETRY_COUNT_PER_SSID) {
2225 // Explicit hidden: exhausted retries on current SSID, find next explicitly hidden network
2226 // Stop when we reach a visible network (proceed to scanning)
2227 size_t next_index = this->selected_sta_index_ + 1;
2228 if (next_index < this->sta_.size() && this->sta_[next_index].get_hidden()) {
2229 this->selected_sta_index_ = static_cast<int8_t>(next_index);
2230 this->num_retried_ = 0;
2231 ESP_LOGD(TAG, "Next explicit hidden network at index %d", static_cast<int>(next_index));
2232 return;
2233 }
2234 // No more consecutive explicit hidden networks found - fall through to trigger phase change
2235 }
2236
2237 if (current_phase == WiFiRetryPhase::RETRY_HIDDEN && this->num_retried_ + 1 >= WIFI_RETRY_COUNT_PER_SSID) {
2238 // Hidden mode: exhausted retries on current SSID, find next potentially hidden SSID
2239 // If first network is marked hidden, we went through EXPLICIT_HIDDEN phase
2240 // In that case, skip networks marked hidden:true (already tried)
2241 // Otherwise, include them (they haven't been tried yet)
2242 int8_t next_index = this->find_next_hidden_sta_(this->selected_sta_index_);
2243 if (next_index != -1) {
2244 // Found another potentially hidden SSID
2245 this->selected_sta_index_ = next_index;
2246 this->num_retried_ = 0;
2247 return;
2248 }
2249 // No more potentially hidden SSIDs - set selected_sta_index_ to -1 to trigger phase change
2250 // This ensures determine_next_phase_() will skip the RETRY_HIDDEN logic and transition out
2251 this->selected_sta_index_ = -1;
2252 // Return early - phase change will happen on next wifi_loop() iteration
2253 return;
2254 }
2255
2256 // Don't increment retry counter if we're in a scan phase with no valid targets
2257 if (this->needs_scan_results_()) {
2258 return;
2259 }
2260
2261 // Increment retry counter to try the same target again
2262 this->num_retried_++;
2263 ESP_LOGD(TAG, "Retry attempt %u/%u in phase %s", this->num_retried_ + 1,
2264 get_max_retries_for_phase(this->retry_phase_), LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_)));
2265}
2266
2268 // Handle roaming state transitions - preserve attempts counter to prevent ping-pong
2269 // to unreachable APs after ROAMING_MAX_ATTEMPTS failures
2271 // Roam connection failed - transition to reconnecting
2272 ESP_LOGD(TAG, "Roam failed, reconnecting (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
2274 } else if (this->is_roaming_scan_active()) {
2275 // Disconnected during roam scan - transition to RECONNECTING so the attempts
2276 // counter is preserved when reconnection succeeds (IDLE would reset it)
2277 ESP_LOGD(TAG, "Disconnected during roam scan (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
2279 } else if (this->roaming_state_ == RoamingState::IDLE) {
2280 // Check if a roaming scan recently completed - on ESP8266, going off-channel
2281 // during scan can cause a delayed Beacon Timeout 8-20 seconds after scan finishes.
2282 // Transition to RECONNECTING so the attempts counter is preserved on reconnect.
2284 ESP_LOGD(TAG, "Disconnect after roam scan (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
2286 } else {
2287 // Not a roaming-triggered reconnect, reset state
2288 this->clear_roaming_state_();
2289 }
2290 }
2291 // RECONNECTING: keep state and counter, still trying to reconnect
2292
2294
2295 // Determine next retry phase based on current state
2296 WiFiRetryPhase current_phase = this->retry_phase_;
2297 WiFiRetryPhase next_phase = this->determine_next_phase_();
2298
2299 // Handle phase transitions (transition_to_phase_ handles same-phase no-op internally)
2300 if (this->transition_to_phase_(next_phase)) {
2301 return; // Scan started or adapter restarted (which sets its own state)
2302 }
2303
2304 if (next_phase == current_phase) {
2306 }
2307
2308 yield();
2309 // Check if we have a valid target before building params
2310 // After exhausting all networks in a phase, selected_sta_index_ may be -1
2311 // In that case, skip connection and let next wifi_loop() handle phase transition
2312 if (this->selected_sta_index_ >= 0) {
2313 WiFiAP params = this->build_params_for_current_phase_();
2314 this->start_connecting(params);
2315 }
2316}
2317
2319 this->power_save_ = power_save;
2320#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
2321 this->configured_power_save_ = power_save;
2322#endif
2323}
2324
2326#ifdef USE_CAPTIVE_PORTAL
2328#else
2329 return false;
2330#endif
2331}
2333#ifdef USE_IMPROV_BLE
2335#else
2336 return false;
2337#endif
2338}
2339
2340#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
2342 // Already configured for high performance - request satisfied
2344 return true;
2345 }
2346
2347 // Semaphore initialization failed
2348 if (this->high_performance_semaphore_ == nullptr) {
2349 return false;
2350 }
2351
2352 // Give the semaphore (non-blocking). This increments the count.
2353 bool success = xSemaphoreGive(this->high_performance_semaphore_) == pdTRUE;
2354
2355 // Wake the main loop so the switch to high-performance mode is applied on the
2356 // next tick instead of waiting up to loop_interval.
2357 if (success) {
2359 }
2360
2361 return success;
2362}
2363
2365 // Already configured for high performance - nothing to release
2367 return true;
2368 }
2369
2370 // Semaphore initialization failed
2371 if (this->high_performance_semaphore_ == nullptr) {
2372 return false;
2373 }
2374
2375 // Take the semaphore (non-blocking). This decrements the count.
2376 return xSemaphoreTake(this->high_performance_semaphore_, 0) == pdTRUE;
2377}
2378#endif // USE_ESP32 && USE_WIFI_RUNTIME_POWER_SAVE
2379
2380#ifdef USE_WIFI_FAST_CONNECT
2382 SavedWifiFastConnectSettings fast_connect_save{};
2383
2384 if (this->fast_connect_pref_.load(&fast_connect_save)) {
2385 // Validate saved AP index
2386 if (fast_connect_save.ap_index < 0 || static_cast<size_t>(fast_connect_save.ap_index) >= this->sta_.size()) {
2387 ESP_LOGW(TAG, "AP index out of bounds");
2388 return false;
2389 }
2390
2391 // Set selected index for future operations (save, retry, etc)
2392 this->selected_sta_index_ = fast_connect_save.ap_index;
2393
2394 // Copy entire config, then override with fast connect data
2395 params = this->sta_[fast_connect_save.ap_index];
2396
2397 // Override with saved BSSID/channel from fast connect (SSID/password/etc already copied from config)
2398 bssid_t bssid{};
2399 std::copy(fast_connect_save.bssid, fast_connect_save.bssid + 6, bssid.begin());
2400 params.set_bssid(bssid);
2401 params.set_channel(fast_connect_save.channel);
2402 // Fast connect uses specific BSSID+channel, not hidden network probe (even if config has hidden: true)
2403 params.set_hidden(false);
2404
2405 ESP_LOGD(TAG, "Loaded fast_connect settings");
2406#if defined(USE_ESP32) && defined(SOC_WIFI_SUPPORT_5G)
2407 if ((this->band_mode_ == WIFI_BAND_MODE_5G_ONLY && fast_connect_save.channel < FIRST_5GHZ_CHANNEL) ||
2408 (this->band_mode_ == WIFI_BAND_MODE_2G_ONLY && fast_connect_save.channel >= FIRST_5GHZ_CHANNEL)) {
2409 ESP_LOGW(TAG, "Saved channel %u not allowed by band mode, ignoring fast_connect", fast_connect_save.channel);
2410 this->selected_sta_index_ = -1;
2411 return false;
2412 }
2413#endif
2414 return true;
2415 }
2416
2417 return false;
2418}
2419
2420void WiFiComponent::save_fast_connect_settings_(const bssid_t &bssid, uint8_t channel) {
2421 // selected_sta_index_ is always valid here (called only after successful connection)
2422 // Fallback to 0 is defensive programming for robustness
2423 int8_t ap_index = this->selected_sta_index_ >= 0 ? this->selected_sta_index_ : 0;
2424
2425 // Skip save if settings haven't changed (compare with previously saved settings to reduce flash wear)
2426 SavedWifiFastConnectSettings previous_save{};
2427 if (this->fast_connect_pref_.load(&previous_save) && memcmp(previous_save.bssid, bssid.data(), 6) == 0 &&
2428 previous_save.channel == channel && previous_save.ap_index == ap_index) {
2429 return; // No change, nothing to save
2430 }
2431
2432 SavedWifiFastConnectSettings fast_connect_save{};
2433 memcpy(fast_connect_save.bssid, bssid.data(), 6);
2434 fast_connect_save.channel = channel;
2435 fast_connect_save.ap_index = ap_index;
2436
2437 this->fast_connect_pref_.save(&fast_connect_save);
2438
2439 ESP_LOGD(TAG, "Saved fast_connect settings");
2440}
2441#endif
2442
2443WiFiScanResult::WiFiScanResult(const bssid_t &bssid, const char *ssid, size_t ssid_len, uint8_t channel, int8_t rssi,
2444 bool with_auth, bool is_hidden)
2445 : bssid_(bssid),
2446 channel_(channel),
2447 rssi_(rssi),
2448 ssid_(ssid, ssid_len),
2449 with_auth_(with_auth),
2450 is_hidden_(is_hidden) {}
2451bool WiFiScanResult::matches(const WiFiAP &config) const {
2452 if (config.get_hidden()) {
2453 // User configured a hidden network, only match actually hidden networks
2454 // don't match SSID
2455 if (!this->is_hidden_)
2456 return false;
2457 } else if (!config.ssid_.empty()) {
2458 // check if SSID matches
2459 if (this->ssid_ != config.ssid_)
2460 return false;
2461 } else {
2462 // network is configured without SSID - match other settings
2463 }
2464 // If BSSID configured, only match for correct BSSIDs
2465 if (config.has_bssid() && config.get_bssid() != this->bssid_)
2466 return false;
2467
2468#ifdef USE_WIFI_WPA2_EAP
2469 // BSSID requires auth but no PSK or EAP credentials given
2470 if (this->with_auth_ && (config.password_.empty() && !config.get_eap().has_value()))
2471 return false;
2472
2473 // BSSID does not require auth, but PSK or EAP credentials given
2474 if (!this->with_auth_ && (!config.password_.empty() || config.get_eap().has_value()))
2475 return false;
2476#else
2477 // If PSK given, only match for networks with auth (and vice versa)
2478 if (config.password_.empty() == this->with_auth_)
2479 return false;
2480#endif
2481
2482 // If channel configured, only match networks on that channel.
2483 if (config.has_channel() && config.get_channel() != this->channel_) {
2484 return false;
2485 }
2486 return true;
2487}
2488bool WiFiScanResult::operator==(const WiFiScanResult &rhs) const { return this->bssid_ == rhs.bssid_; }
2489
2491 this->roaming_attempts_ = 0;
2492 this->roaming_last_check_ = 0;
2493 this->roaming_scan_end_ = 0;
2494 this->roaming_target_bssid_ = {};
2496}
2497
2498#ifdef USE_ESP32
2499void WiFiComponent::handle_driver_roam_(const bssid_t &bssid, uint8_t channel) {
2500 // A driver-initiated roam (e.g. 802.11v BTM) re-associates without the state
2501 // machine ever leaving STA_CONNECTED, so check_connecting_finished() never runs.
2502 // Redo its post-connect bookkeeping here. roaming_state_ is deliberately left
2503 // untouched so an in-flight roaming scan is not orphaned. The BSSID and
2504 // channel both come from the connected event so the saved pair is consistent:
2505 // the radio may be off-channel during a roaming scan, and a later queued
2506 // event may have moved the driver on again by the time this one is processed.
2508 this->roaming_attempts_ = 0;
2509 this->roaming_scan_end_ = 0;
2511#ifdef USE_WIFI_FAST_CONNECT
2512 this->save_fast_connect_settings_(bssid, channel);
2513#endif
2514}
2515#endif
2516
2518 if (!this->keep_scan_results_) {
2519 ScanResultsLock lock(this);
2520#if defined(USE_RP2)
2521 // std::vector - use swap trick since shrink_to_fit is non-binding
2522 decltype(this->scan_result_)().swap(this->scan_result_);
2523#else
2524 // FixedVector::release() frees all memory
2525 this->scan_result_.release();
2526#endif
2527 }
2528}
2529
2530#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
2532 if (!this->pending_.connect_state)
2533 return;
2534 this->pending_.connect_state = false;
2535 // Get current SSID and BSSID from the WiFi driver
2536 char ssid_buf[SSID_BUFFER_SIZE];
2537 const char *ssid = this->wifi_ssid_to(ssid_buf);
2538 bssid_t bssid = this->wifi_bssid();
2539 for (auto *listener : this->connect_state_listeners_) {
2540 listener->on_wifi_connect_state(StringRef(ssid, strlen(ssid)), bssid);
2541 }
2542}
2543
2545 constexpr uint8_t empty_bssid[6] = {};
2546 for (auto *listener : this->connect_state_listeners_) {
2547 listener->on_wifi_connect_state(StringRef(), empty_bssid);
2548 }
2549}
2550#endif // USE_WIFI_CONNECT_STATE_LISTENERS
2551
2552#ifdef USE_WIFI_IP_STATE_LISTENERS
2554 for (auto *listener : this->ip_state_listeners_) {
2555 listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
2556 }
2557}
2558#endif // USE_WIFI_IP_STATE_LISTENERS
2559
2560#ifdef USE_WIFI_SCAN_RESULTS_LISTENERS
2562 for (auto *listener : this->scan_results_listeners_) {
2563 listener->on_wifi_scan_results(this->scan_result_);
2564 }
2565}
2566#endif // USE_WIFI_SCAN_RESULTS_LISTENERS
2567
2569 if (!this->is_connected() || this->roaming_state_ != RoamingState::IDLE || this->roaming_suppressed_()) {
2570 ESP_LOGD(TAG, "Roam check requested, but not able to check now");
2571 return;
2572 }
2573 // Reset the attempt counter so a prior run of failed roams doesn't block this explicit request
2574 // Note that this re-arms automatic roaming if enabled.
2575 this->roaming_attempts_ = 0;
2576 this->check_roaming_(millis());
2577}
2578
2580 // Guard: not for hidden networks (may not appear in scan)
2581 const WiFiAP *selected = this->get_selected_sta_();
2582 if (selected == nullptr || selected->get_hidden()) {
2583 this->roaming_attempts_ = ROAMING_MAX_ATTEMPTS; // Stop checking forever
2584 return;
2585 }
2586
2587 this->roaming_last_check_ = now;
2588 this->roaming_attempts_++;
2589
2590 // Guard: skip scan if signal is already good (no meaningful improvement possible)
2591 int8_t rssi = this->wifi_rssi();
2592 if (rssi > ROAMING_GOOD_RSSI) {
2593 ESP_LOGD(TAG, "Roam check skipped, signal good (%d dBm, attempt %u/%u)", rssi, this->roaming_attempts_,
2595 return;
2596 }
2597
2598 ESP_LOGD(TAG, "Roam scan (%d dBm, attempt %u/%u)", rssi, this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
2600 if (!this->wifi_scan_start_(this->passive_scan_)) {
2601 // Scan failed to start (e.g. busy) - don't get stuck in SCANNING forever
2602 ESP_LOGD(TAG, "Roam scan failed to start");
2604 }
2605}
2606
2608 this->scan_done_ = false;
2609 // Default to IDLE - will be set to CONNECTING if we find a better AP
2611 // Record when scan completed so delayed disconnects (e.g., ESP8266 Beacon Timeout)
2612 // can be attributed to the scan and avoid resetting the attempts counter
2613 this->roaming_scan_end_ = millis();
2614
2615 // Get current connection info
2616 int8_t current_rssi = this->wifi_rssi();
2617 // Guard: must still be connected (RSSI may have become invalid during scan)
2618 if (current_rssi == WIFI_RSSI_DISCONNECTED) {
2619 this->release_scan_results_();
2620 return;
2621 }
2622
2623 char ssid_buf[SSID_BUFFER_SIZE];
2624 StringRef current_ssid(this->wifi_ssid_to(ssid_buf));
2625 bssid_t current_bssid = this->wifi_bssid();
2626
2627 // Find best candidate: same SSID, different BSSID
2628 const WiFiScanResult *best = nullptr;
2629 char bssid_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
2630
2631 for (const auto &result : this->scan_result_) {
2632 // Must be same SSID, different BSSID
2633 if (result.ssid_ != current_ssid || result.get_bssid() == current_bssid)
2634 continue;
2635
2636#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
2637 format_mac_addr_upper(result.get_bssid().data(), bssid_buf);
2638 ESP_LOGV(TAG, "Roam candidate %s %d dBm", bssid_buf, result.get_rssi());
2639#endif
2640
2641 // Track the best candidate
2642 if (best == nullptr || result.get_rssi() > best->get_rssi()) {
2643 best = &result;
2644 }
2645 }
2646
2647 // Check if best candidate meets minimum improvement threshold
2648 const WiFiAP *selected = this->get_selected_sta_();
2649 int8_t improvement = (best == nullptr) ? 0 : best->get_rssi() - current_rssi;
2650 if (selected == nullptr || improvement < ROAMING_MIN_IMPROVEMENT) {
2651 ESP_LOGV(TAG, "Roam best %+d dB (need +%d), attempt %u/%u", improvement, ROAMING_MIN_IMPROVEMENT,
2653 this->release_scan_results_();
2654 return;
2655 }
2656
2657 format_mac_addr_upper(best->get_bssid().data(), bssid_buf);
2658 ESP_LOGI(TAG, "Roaming to %s (%+d dB)", bssid_buf, improvement);
2659
2660 WiFiAP roam_params = *selected;
2661 apply_scan_result_to_params(roam_params, *best);
2662
2663 // Mark as roaming attempt - affects retry behavior if connection fails
2665 this->roaming_target_bssid_ = best->get_bssid(); // Must read before releasing scan results
2666
2667 this->release_scan_results_();
2668
2669 // Connect directly - wifi_sta_connect_ handles disconnect internally
2670 this->start_connecting(roam_params);
2671}
2672
2673WiFiComponent *global_wifi_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
2674
2675} // namespace esphome::wifi
2676#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.
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:289
StringRef is a reference to a string owned by something else.
Definition string_ref.h:30
constexpr const char * c_str() const
Definition string_ref.h:77
constexpr size_type size() const
Definition string_ref.h:78
void trigger(const Ts &...x) ESPHOME_ALWAYS_INLINE
Inform the parent automation that the event has triggered.
Definition automation.h:528
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 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 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)
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...
static constexpr uint32_t ROAMING_CHECK_INTERVAL
SemaphoreHandle_t high_performance_semaphore_
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)
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_
void disable_ap_()
End the captive portal 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)
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)
uint16_t type
uint8_t priority
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:292
ProvisioningManager * global_provisioning_manager
const char *const TAG
Definition spi.cpp:7
std::array< uint8_t, 6 > bssid_t
const LogString * get_signal_bars(int8_t rssi)
@ BLIND_RETRY
Blind retry mode: scanning disabled (captive portal/improv active), try ALL configured networks seque...
@ SCAN_BASED
Normal mode: scan completed, only try networks NOT visible in scan results (truly hidden networks tha...
WiFiRetryPhase
Tracks the current retry strategy/phase for WiFi connection attempts.
@ RETRY_HIDDEN
Retry networks not found in scan (might be hidden)
@ RESTARTING_ADAPTER
Restarting WiFi adapter to clear stuck state.
@ INITIAL_CONNECT
Initial connection attempt (varies based on fast_connect setting)
@ EXPLICIT_HIDDEN
Explicitly hidden networks (user marked as hidden, try before scanning)
@ FAST_CONNECT_CYCLING_APS
Fast connect mode: cycling through configured APs (config-only, no scan)
@ SCAN_CONNECTING
Scan-based: connecting to best AP from scan results.
WiFiComponent * global_wifi_component
@ SCANNING
Scanning for better AP.
@ CONNECTING
Attempting to connect to better AP found in scan.
@ IDLE
Not roaming, waiting for next check interval.
@ RECONNECTING
Roam connection failed, reconnecting to any available AP.
@ WIFI_COMPONENT_STATE_DISABLED
WiFi is disabled.
@ WIFI_COMPONENT_STATE_AP
WiFi is in AP-only mode and internal AP is already enabled.
@ WIFI_COMPONENT_STATE_STA_CONNECTING
WiFi is in STA(+AP) mode and currently connecting to an AP.
@ WIFI_COMPONENT_STATE_OFF
Nothing has been initialized yet.
@ WIFI_COMPONENT_STATE_STA_SCANNING
WiFi is in STA-only mode and currently scanning for APs.
@ WIFI_COMPONENT_STATE_COOLDOWN
WiFi is in cooldown mode because something went wrong, scanning will begin after a short period of ti...
@ WIFI_COMPONENT_STATE_STA_CONNECTED
WiFi is in STA(+AP) mode and successfully connected.
uint16_t uint16_t size_t elem_size
Definition helpers.cpp:34
const void size_t len
Definition hal.h:64
uint16_t size
Definition helpers.cpp:33
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:801
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:1599
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