ESPHome 2026.2.3
Loading...
Searching...
No Matches
wifi_component.cpp
Go to the documentation of this file.
1#include "wifi_component.h"
2#ifdef USE_WIFI
3#include <cassert>
4#include <cinttypes>
5#include <cmath>
6
7#ifdef USE_ESP32
8#if (ESP_IDF_VERSION_MAJOR >= 5 && ESP_IDF_VERSION_MINOR >= 1)
9#include <esp_eap_client.h>
10#else
11#include <esp_wpa2.h>
12#endif
13#endif
14
15#if defined(USE_ESP32)
16#include <esp_wifi.h>
17#endif
18#ifdef USE_ESP8266
19#include <user_interface.h>
20#endif
21
22#include <algorithm>
23#include <new>
24#include <utility>
25#include "lwip/dns.h"
26#include "lwip/err.h"
27
29#include "esphome/core/hal.h"
31#include "esphome/core/log.h"
33#include "esphome/core/util.h"
34
35#ifdef USE_CAPTIVE_PORTAL
37#endif
38
39#ifdef USE_IMPROV
41#endif
42
43#ifdef USE_IMPROV_SERIAL
45#endif
46
47namespace esphome::wifi {
48
49static const char *const TAG = "wifi";
50
51// CompactString implementation
52CompactString::CompactString(const char *str, size_t len) {
53 if (len > MAX_LENGTH) {
54 len = MAX_LENGTH; // Clamp to max valid length
55 }
56
57 this->length_ = len;
58 if (len <= INLINE_CAPACITY) {
59 // Store inline with null terminator
60 this->is_heap_ = 0;
61 if (len > 0) {
62 std::memcpy(this->storage_, str, len);
63 }
64 this->storage_[len] = '\0';
65 } else {
66 // Heap allocate with null terminator
67 this->is_heap_ = 1;
68 char *heap_data = new char[len + 1]; // NOLINT(cppcoreguidelines-owning-memory)
69 std::memcpy(heap_data, str, len);
70 heap_data[len] = '\0';
71 this->set_heap_ptr_(heap_data);
72 }
73}
74
75CompactString::CompactString(const CompactString &other) : CompactString(other.data(), other.size()) {}
76
78 if (this != &other) {
79 this->~CompactString();
80 new (this) CompactString(other);
81 }
82 return *this;
83}
84
85CompactString::CompactString(CompactString &&other) noexcept : length_(other.length_), is_heap_(other.is_heap_) {
86 // Copy full storage (includes null terminator for inline, or pointer for heap)
87 std::memcpy(this->storage_, other.storage_, INLINE_CAPACITY + 1);
88 other.length_ = 0;
89 other.is_heap_ = 0;
90 other.storage_[0] = '\0';
91}
92
94 if (this != &other) {
95 this->~CompactString();
96 new (this) CompactString(std::move(other));
97 }
98 return *this;
99}
100
102 if (this->is_heap_) {
103 delete[] this->get_heap_ptr_(); // NOLINT(cppcoreguidelines-owning-memory)
104 }
105}
106
107bool CompactString::operator==(const CompactString &other) const {
108 return this->size() == other.size() && std::memcmp(this->data(), other.data(), this->size()) == 0;
109}
110bool CompactString::operator==(const StringRef &other) const {
111 return this->size() == other.size() && std::memcmp(this->data(), other.c_str(), this->size()) == 0;
112}
113
302
303// Use if-chain instead of switch to avoid jump table in RODATA (wastes RAM on ESP8266)
304static const LogString *retry_phase_to_log_string(WiFiRetryPhase phase) {
306 return LOG_STR("INITIAL_CONNECT");
307#ifdef USE_WIFI_FAST_CONNECT
309 return LOG_STR("FAST_CONNECT_CYCLING");
310#endif
312 return LOG_STR("EXPLICIT_HIDDEN");
314 return LOG_STR("SCAN_CONNECTING");
315 if (phase == WiFiRetryPhase::RETRY_HIDDEN)
316 return LOG_STR("RETRY_HIDDEN");
318 return LOG_STR("RESTARTING");
319 return LOG_STR("UNKNOWN");
320}
321
323 // If first configured network is marked hidden, we went through EXPLICIT_HIDDEN phase
324 // This means those networks were already tried and should be skipped in RETRY_HIDDEN
325 return !this->sta_.empty() && this->sta_[0].get_hidden();
326}
327
329 // Find the first network that is NOT marked hidden:true
330 // This is where EXPLICIT_HIDDEN phase would have stopped
331 for (size_t i = 0; i < this->sta_.size(); i++) {
332 if (!this->sta_[i].get_hidden()) {
333 return static_cast<int8_t>(i);
334 }
335 }
336 return -1; // All networks are hidden
337}
338
339// 2 attempts per BSSID in SCAN_CONNECTING phase
340// Rationale: This is the ONLY phase where we decrease BSSID priority, so we must be very sure.
341// Auth failures are common immediately after scan due to WiFi stack state transitions.
342// Trying twice filters out false positives and prevents unnecessarily marking a good BSSID as bad.
343// After 2 genuine failures, priority degradation ensures we skip this BSSID on subsequent scans.
344static constexpr uint8_t WIFI_RETRY_COUNT_PER_BSSID = 2;
345
346// 1 attempt per SSID in RETRY_HIDDEN phase
347// Rationale: Try hidden mode once, then rescan to get next best BSSID via priority system
348static constexpr uint8_t WIFI_RETRY_COUNT_PER_SSID = 1;
349
350// 1 attempt per AP in fast_connect mode (INITIAL_CONNECT and FAST_CONNECT_CYCLING_APS)
351// Rationale: Fast connect prioritizes speed - try each AP once to find a working one quickly
352static constexpr uint8_t WIFI_RETRY_COUNT_PER_AP = 1;
353
356static constexpr uint32_t WIFI_COOLDOWN_DURATION_MS = 500;
357
361static constexpr uint32_t WIFI_COOLDOWN_WITH_AP_ACTIVE_MS = 30000;
362
366static constexpr uint32_t WIFI_SCAN_TIMEOUT_MS = 31000;
367
376static constexpr uint32_t WIFI_CONNECT_TIMEOUT_MS = 46000;
377
378static constexpr uint8_t get_max_retries_for_phase(WiFiRetryPhase phase) {
379 switch (phase) {
381#ifdef USE_WIFI_FAST_CONNECT
383#endif
384 // INITIAL_CONNECT and FAST_CONNECT_CYCLING_APS both use 1 attempt per AP (fast_connect mode)
385 return WIFI_RETRY_COUNT_PER_AP;
387 // Explicitly hidden network: 1 attempt (user marked as hidden, try once then scan)
388 return WIFI_RETRY_COUNT_PER_SSID;
390 // Scan-based phase: 2 attempts per BSSID (handles transient auth failures after scan)
391 return WIFI_RETRY_COUNT_PER_BSSID;
393 // Hidden network mode: 1 attempt per SSID
394 return WIFI_RETRY_COUNT_PER_SSID;
395 default:
396 return WIFI_RETRY_COUNT_PER_BSSID;
397 }
398}
399
400static void apply_scan_result_to_params(WiFiAP &params, const WiFiScanResult &scan) {
401 params.set_hidden(false);
402 params.set_ssid(scan.get_ssid());
403 params.set_bssid(scan.get_bssid());
404 params.set_channel(scan.get_channel());
405}
406
408 // Only SCAN_CONNECTING phase needs scan results
410 return false;
411 }
412 // Need scan if we have no results or no matching networks
413 return this->scan_result_.empty() || !this->scan_result_[0].get_matches();
414}
415
417 // Check if this SSID is configured as hidden
418 // If explicitly marked hidden, we should always try hidden mode regardless of scan results
419 for (const auto &conf : this->sta_) {
420 if (conf.ssid_ == ssid && conf.get_hidden()) {
421 return false; // Treat as not seen - force hidden mode attempt
422 }
423 }
424
425 // Otherwise, check if we saw it in scan results
426 for (const auto &scan : this->scan_result_) {
427 if (scan.ssid_ == ssid) {
428 return true;
429 }
430 }
431 return false;
432}
433
435 // Components that require full scan results (for example, scan result listeners)
436 // are expected to call request_wifi_scan_results(), which sets keep_scan_results_.
437 if (this->keep_scan_results_) {
438 return true;
439 }
440
441#ifdef USE_CAPTIVE_PORTAL
442 // Captive portal needs full results when active (showing network list to user)
444 return true;
445 }
446#endif
447
448#ifdef USE_IMPROV_SERIAL
449 // Improv serial needs results during provisioning (before connected)
451 return true;
452 }
453#endif
454
455#ifdef USE_IMPROV
456 // BLE improv also needs results during provisioning
458 return true;
459 }
460#endif
461
462 return false;
463}
464
465bool WiFiComponent::matches_configured_network_(const char *ssid, const uint8_t *bssid) const {
466 // Hidden networks in scan results have empty SSIDs - skip them
467 if (ssid[0] == '\0') {
468 return false;
469 }
470 for (const auto &sta : this->sta_) {
471 // Skip hidden network configs (they don't appear in normal scans)
472 if (sta.get_hidden()) {
473 continue;
474 }
475 // For BSSID-only configs (empty SSID), match by BSSID
476 if (sta.ssid_.empty()) {
477 if (sta.has_bssid() && std::memcmp(sta.get_bssid().data(), bssid, 6) == 0) {
478 return true;
479 }
480 continue;
481 }
482 // Match by SSID
483 if (sta.ssid_ == ssid) {
484 return true;
485 }
486 }
487 return false;
488}
489
490void WiFiComponent::log_discarded_scan_result_(const char *ssid, const uint8_t *bssid, int8_t rssi, uint8_t channel) {
491#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
492 // Skip logging during roaming scans to avoid log buffer overflow
493 // (roaming scans typically find many networks but only care about same-SSID APs)
495 return;
496 }
497 char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
498 format_mac_addr_upper(bssid, bssid_s);
499 ESP_LOGV(TAG, "- " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") " %ddB Ch:%u", ssid, bssid_s, rssi, channel);
500#endif
501}
502
503int8_t WiFiComponent::find_next_hidden_sta_(int8_t start_index) {
504 // Find next SSID to try in RETRY_HIDDEN phase.
505 //
506 // This function operates in two modes based on retry_hidden_mode_:
507 //
508 // 1. SCAN_BASED mode:
509 // After SCAN_CONNECTING phase, only returns networks that were NOT visible
510 // in the scan (truly hidden networks that need probe requests).
511 //
512 // 2. BLIND_RETRY mode:
513 // When captive portal/improv is active, scanning is skipped to avoid
514 // disrupting the AP. In this mode, ALL configured networks are returned
515 // as candidates, cycling through them sequentially. This allows the device
516 // to keep trying all networks while users configure WiFi via captive portal.
517 //
518 // Additionally, if EXPLICIT_HIDDEN phase was executed (first network marked hidden:true),
519 // those networks are skipped here since they were already tried.
520 //
521 bool include_explicit_hidden = !this->went_through_explicit_hidden_phase_();
522 // Start searching from start_index + 1
523 for (size_t i = start_index + 1; i < this->sta_.size(); i++) {
524 const auto &sta = this->sta_[i];
525
526 // Skip networks that were already tried in EXPLICIT_HIDDEN phase
527 // Those are: networks marked hidden:true that appear before the first non-hidden network
528 // If all networks are hidden (first_non_hidden_idx == -1), skip all of them
529 if (!include_explicit_hidden && sta.get_hidden()) {
530 int8_t first_non_hidden_idx = this->find_first_non_hidden_index_();
531 if (first_non_hidden_idx < 0 || static_cast<int8_t>(i) < first_non_hidden_idx) {
532 ESP_LOGD(TAG, "Skipping " LOG_SECRET("'%s'") " (explicit hidden, already tried)", sta.ssid_.c_str());
533 continue;
534 }
535 }
536
537 // In BLIND_RETRY mode, treat all networks as candidates
538 // In SCAN_BASED mode, only retry networks that weren't seen in the scan
540 ESP_LOGD(TAG, "Hidden candidate " LOG_SECRET("'%s'") " at index %d", sta.ssid_.c_str(), static_cast<int>(i));
541 return static_cast<int8_t>(i);
542 }
543 ESP_LOGD(TAG, "Skipping hidden retry for visible network " LOG_SECRET("'%s'"), sta.ssid_.c_str());
544 }
545 // No hidden SSIDs found
546 return -1;
547}
548
550 // If first network (highest priority) is explicitly marked hidden, try it first before scanning
551 // This respects user's priority order when they explicitly configure hidden networks
552 if (!this->sta_.empty() && this->sta_[0].get_hidden()) {
553 ESP_LOGI(TAG, "Starting with explicit hidden network (highest priority)");
554 this->selected_sta_index_ = 0;
557 this->start_connecting(params);
558 } else {
559 this->start_scanning();
560 }
561}
562
563#if defined(USE_ESP32) && defined(USE_WIFI_WPA2_EAP) && ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
564static const char *eap_phase2_to_str(esp_eap_ttls_phase2_types type) {
565 switch (type) {
566 case ESP_EAP_TTLS_PHASE2_PAP:
567 return "pap";
568 case ESP_EAP_TTLS_PHASE2_CHAP:
569 return "chap";
570 case ESP_EAP_TTLS_PHASE2_MSCHAP:
571 return "mschap";
572 case ESP_EAP_TTLS_PHASE2_MSCHAPV2:
573 return "mschapv2";
574 case ESP_EAP_TTLS_PHASE2_EAP:
575 return "eap";
576 default:
577 return "unknown";
578 }
579}
580#endif
581
583
585 this->wifi_pre_setup_();
586
587#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
588 // Create semaphore for high-performance mode requests
589 // Start at 0, increment on request, decrement on release
590 this->high_performance_semaphore_ = xSemaphoreCreateCounting(UINT32_MAX, 0);
591 if (this->high_performance_semaphore_ == nullptr) {
592 ESP_LOGE(TAG, "Failed semaphore");
593 }
594
595 // Store the configured power save mode as baseline
597#endif
598
599 if (this->enable_on_boot_) {
600 this->start();
601 } else {
602#ifdef USE_ESP32
603 esp_netif_init();
604#endif
606 }
607}
608
610 ESP_LOGCONFIG(TAG, "Starting");
611 this->last_connected_ = millis();
612
613 uint32_t hash = this->has_sta() ? App.get_config_version_hash() : 88491487UL;
614
616#ifdef USE_WIFI_FAST_CONNECT
618#endif
619
620 SavedWifiSettings save{};
621 if (this->pref_.load(&save)) {
622 ESP_LOGD(TAG, "Loaded settings: %s", save.ssid);
623
624 WiFiAP sta{};
625 sta.set_ssid(save.ssid);
626 sta.set_password(save.password);
627 this->set_sta(sta);
628 }
629
630 if (this->has_sta()) {
631 this->wifi_sta_pre_setup_();
632 if (!std::isnan(this->output_power_) && !this->wifi_apply_output_power_(this->output_power_)) {
633 ESP_LOGV(TAG, "Setting Output Power Option failed");
634 }
635
636#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
637 // Synchronize power_save_ with semaphore state before applying
638 if (this->high_performance_semaphore_ != nullptr) {
639 UBaseType_t semaphore_count = uxSemaphoreGetCount(this->high_performance_semaphore_);
640 if (semaphore_count > 0) {
642 this->is_high_performance_mode_ = true;
643 } else {
645 this->is_high_performance_mode_ = false;
646 }
647 }
648#endif
649 if (!this->wifi_apply_power_save_()) {
650 ESP_LOGV(TAG, "Setting Power Save Option failed");
651 }
652
654#ifdef USE_WIFI_FAST_CONNECT
655 WiFiAP params;
656 bool loaded_fast_connect = this->load_fast_connect_settings_(params);
657 // Fast connect optimization: only use when we have saved BSSID+channel data
658 // Without saved data, try first configured network or use normal flow
659 if (loaded_fast_connect) {
660 ESP_LOGI(TAG, "Starting fast_connect (saved) " LOG_SECRET("'%s'"), params.ssid_.c_str());
661 this->start_connecting(params);
662 } else if (!this->sta_.empty() && !this->sta_[0].get_hidden()) {
663 // No saved data, but have configured networks - try first non-hidden network
664 ESP_LOGI(TAG, "Starting fast_connect (config) " LOG_SECRET("'%s'"), this->sta_[0].ssid_.c_str());
665 this->selected_sta_index_ = 0;
666 params = this->build_params_for_current_phase_();
667 this->start_connecting(params);
668 } else {
669 // No saved data and (no networks OR first is hidden) - use normal flow
671 }
672#else
673 // Without fast_connect: go straight to scanning (or hidden mode if all networks are hidden)
675#endif
676#ifdef USE_WIFI_AP
677 } else if (this->has_ap()) {
678 this->setup_ap_config_();
679 if (!std::isnan(this->output_power_) && !this->wifi_apply_output_power_(this->output_power_)) {
680 ESP_LOGV(TAG, "Setting Output Power Option failed");
681 }
682#ifdef USE_CAPTIVE_PORTAL
684 this->wifi_sta_pre_setup_();
685 this->start_scanning();
687 }
688#endif
689#endif // USE_WIFI_AP
690 }
691#ifdef USE_IMPROV
692 if (!this->has_sta() && esp32_improv::global_improv_component != nullptr) {
693 if (this->wifi_mode_(true, {}))
695 }
696#endif
697 this->wifi_apply_hostname_();
698}
699
701 ESP_LOGW(TAG, "Restarting adapter");
702 this->wifi_mode_(false, {});
703 // Clear error flag here because restart_adapter() enters COOLDOWN state,
704 // and check_connecting_finished() is called after cooldown without going
705 // through start_connecting() first. Without this clear, stale errors would
706 // trigger spurious "failed (callback)" logs. The canonical clear location
707 // is in start_connecting(); this is the only exception to that pattern.
708 this->error_from_callback_ = false;
709}
710
712 this->wifi_loop_();
713 const uint32_t now = App.get_loop_component_start_time();
714
715 if (this->has_sta()) {
716#if defined(USE_WIFI_CONNECT_TRIGGER) || defined(USE_WIFI_DISCONNECT_TRIGGER)
717 if (this->is_connected() != this->handled_connected_state_) {
718#ifdef USE_WIFI_DISCONNECT_TRIGGER
719 if (this->handled_connected_state_) {
721 }
722#endif
723#ifdef USE_WIFI_CONNECT_TRIGGER
724 if (!this->handled_connected_state_) {
726 }
727#endif
729 }
730#endif // USE_WIFI_CONNECT_TRIGGER || USE_WIFI_DISCONNECT_TRIGGER
731
732 switch (this->state_) {
734 this->status_set_warning(LOG_STR("waiting to reconnect"));
735 // Skip cooldown if new credentials were provided while connecting
736 if (this->skip_cooldown_next_cycle_) {
737 this->skip_cooldown_next_cycle_ = false;
738 this->check_connecting_finished(now);
739 break;
740 }
741 // Use longer cooldown when captive portal/improv is active to avoid disrupting user config
742 bool portal_active = this->is_captive_portal_active_() || this->is_esp32_improv_active_();
743 uint32_t cooldown_duration = portal_active ? WIFI_COOLDOWN_WITH_AP_ACTIVE_MS : WIFI_COOLDOWN_DURATION_MS;
744 if (now - this->action_started_ > cooldown_duration) {
745 // After cooldown we either restarted the adapter because of
746 // a failure, or something tried to connect over and over
747 // so we entered cooldown. In both cases we call
748 // check_connecting_finished to continue the state machine.
749 this->check_connecting_finished(now);
750 }
751 break;
752 }
754 this->status_set_warning(LOG_STR("scanning for networks"));
756 break;
757 }
759 this->status_set_warning(LOG_STR("associating to network"));
760 this->check_connecting_finished(now);
761 break;
762 }
763
765 if (!this->is_connected()) {
766 ESP_LOGW(TAG, "Connection lost; reconnecting");
768 this->retry_connect();
769 } else {
770 this->status_clear_warning();
771 this->last_connected_ = now;
772
773 // Post-connect roaming: check for better AP
774 if (this->post_connect_roaming_) {
776 if (this->scan_done_) {
777 this->process_roaming_scan_();
778 }
779 // else: scan in progress, wait
782 this->check_roaming_(now);
783 }
784 }
785 }
786 break;
787 }
790 break;
792 return;
793 }
794
795#ifdef USE_WIFI_AP
796 if (this->has_ap() && !this->ap_setup_) {
797 if (this->ap_timeout_ != 0 && (now - this->last_connected_ > this->ap_timeout_)) {
798 ESP_LOGI(TAG, "Starting fallback AP");
799 this->setup_ap_config_();
800#ifdef USE_CAPTIVE_PORTAL
802 // Reset so we force one full scan after captive portal starts
803 // (previous scans were filtered because captive portal wasn't active yet)
806 }
807#endif
808 }
809 }
810#endif // USE_WIFI_AP
811
812#ifdef USE_IMPROV
814 !esp32_improv::global_improv_component->should_start()) {
815 if (now - this->last_connected_ > esp32_improv::global_improv_component->get_wifi_timeout()) {
816 if (this->wifi_mode_(true, {}))
818 }
819 }
820
821#endif
822
823 if (!this->has_ap() && this->reboot_timeout_ != 0) {
824 if (now - this->last_connected_ > this->reboot_timeout_) {
825 ESP_LOGE(TAG, "Can't connect; rebooting");
826 App.reboot();
827 }
828 }
829 }
830
831#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
832 // Check if power save mode needs to be updated based on high-performance requests
833 if (this->high_performance_semaphore_ != nullptr) {
834 // Semaphore count directly represents active requests (starts at 0, increments on request)
835 UBaseType_t semaphore_count = uxSemaphoreGetCount(this->high_performance_semaphore_);
836
837 if (semaphore_count > 0 && !this->is_high_performance_mode_) {
838 // Transition to high-performance mode (no power save)
839 ESP_LOGV(TAG, "Switching to high-performance mode (%" PRIu32 " active %s)", (uint32_t) semaphore_count,
840 semaphore_count == 1 ? "request" : "requests");
842 if (this->wifi_apply_power_save_()) {
843 this->is_high_performance_mode_ = true;
844 }
845 } else if (semaphore_count == 0 && this->is_high_performance_mode_) {
846 // Restore to configured power save mode
847 ESP_LOGV(TAG, "Restoring power save mode to configured setting");
849 if (this->wifi_apply_power_save_()) {
850 this->is_high_performance_mode_ = false;
851 }
852 }
853 }
854#endif
855}
856
858
859bool WiFiComponent::has_ap() const { return this->has_ap_; }
860bool WiFiComponent::is_ap_active() const { return this->ap_started_; }
861bool WiFiComponent::has_sta() const { return !this->sta_.empty(); }
862#ifdef USE_WIFI_11KV_SUPPORT
863void WiFiComponent::set_btm(bool btm) { this->btm_ = btm; }
864void WiFiComponent::set_rrm(bool rrm) { this->rrm_ = rrm; }
865#endif
867 if (this->has_sta())
868 return this->wifi_sta_ip_addresses();
869
870#ifdef USE_WIFI_AP
871 if (this->has_ap())
872 return {this->wifi_soft_ap_ip()};
873#endif // USE_WIFI_AP
874
875 return {};
876}
878 if (this->has_sta())
879 return this->wifi_dns_ip_(num);
880 return {};
881}
882// set_use_address() is guaranteed to be called during component setup by Python code generation,
883// so use_address_ will always be valid when get_use_address() is called - no fallback needed.
884const char *WiFiComponent::get_use_address() const { return this->use_address_; }
885void WiFiComponent::set_use_address(const char *use_address) { this->use_address_ = use_address; }
886
887#ifdef USE_WIFI_AP
889 this->wifi_mode_({}, true);
890
891 if (this->ap_setup_)
892 return;
893
894 if (this->ap_.ssid_.empty()) {
895 // Build AP SSID from app name without heap allocation
896 // WiFi SSID max is 32 bytes, with MAC suffix we keep first 25 + last 7
897 static constexpr size_t AP_SSID_MAX_LEN = 32;
898 static constexpr size_t AP_SSID_PREFIX_LEN = 25;
899 static constexpr size_t AP_SSID_SUFFIX_LEN = 7;
900
901 const std::string &app_name = App.get_name();
902 const char *name_ptr = app_name.c_str();
903 size_t name_len = app_name.length();
904
905 if (name_len <= AP_SSID_MAX_LEN) {
906 // Name fits, use directly
907 this->ap_.set_ssid(name_ptr);
908 } else {
909 // Name too long, need to truncate into stack buffer
910 char ssid_buf[AP_SSID_MAX_LEN + 1];
912 // Keep first 25 chars and last 7 chars (MAC suffix), remove middle
913 memcpy(ssid_buf, name_ptr, AP_SSID_PREFIX_LEN);
914 memcpy(ssid_buf + AP_SSID_PREFIX_LEN, name_ptr + name_len - AP_SSID_SUFFIX_LEN, AP_SSID_SUFFIX_LEN);
915 } else {
916 memcpy(ssid_buf, name_ptr, AP_SSID_MAX_LEN);
917 }
918 ssid_buf[AP_SSID_MAX_LEN] = '\0';
919 this->ap_.set_ssid(ssid_buf);
920 }
921 }
922 this->ap_setup_ = this->wifi_start_ap_(this->ap_);
923
924 char ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
925 ESP_LOGCONFIG(TAG,
926 "Setting up AP:\n"
927 " AP SSID: '%s'\n"
928 " AP Password: '%s'\n"
929 " IP Address: %s",
930 this->ap_.ssid_.c_str(), this->ap_.password_.c_str(), this->wifi_soft_ap_ip().str_to(ip_buf));
931
932#ifdef USE_WIFI_MANUAL_IP
933 auto manual_ip = this->ap_.get_manual_ip();
934 if (manual_ip.has_value()) {
935 char static_ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
936 char gateway_buf[network::IP_ADDRESS_BUFFER_SIZE];
937 char subnet_buf[network::IP_ADDRESS_BUFFER_SIZE];
938 ESP_LOGCONFIG(TAG,
939 " AP Static IP: '%s'\n"
940 " AP Gateway: '%s'\n"
941 " AP Subnet: '%s'",
942 manual_ip->static_ip.str_to(static_ip_buf), manual_ip->gateway.str_to(gateway_buf),
943 manual_ip->subnet.str_to(subnet_buf));
944 }
945#endif
946
947 if (!this->has_sta()) {
949 }
950}
951
953 this->ap_ = ap;
954 this->has_ap_ = true;
955}
956#endif // USE_WIFI_AP
957
959 return 10.0f; // before other loop components
960}
961
962void WiFiComponent::init_sta(size_t count) { this->sta_.init(count); }
963void WiFiComponent::add_sta(const WiFiAP &ap) { this->sta_.push_back(ap); }
965 // Clear roaming state - no more configured networks
966 this->clear_roaming_state_();
967 this->sta_.clear();
968 this->selected_sta_index_ = -1;
969}
971 this->clear_sta(); // Also clears roaming state
972 this->init_sta(1);
973 this->add_sta(ap);
974 this->selected_sta_index_ = 0;
975 // When new credentials are set (e.g., from improv), skip cooldown to retry immediately
976 this->skip_cooldown_next_cycle_ = true;
977}
978
980 const WiFiAP *config = this->get_selected_sta_();
981 if (config == nullptr) {
982 ESP_LOGE(TAG, "No valid network config (selected_sta_index_=%d, sta_.size()=%zu)",
983 static_cast<int>(this->selected_sta_index_), this->sta_.size());
984 // Return empty params - caller should handle this gracefully
985 return WiFiAP();
986 }
987
988 WiFiAP params = *config;
989
990 switch (this->retry_phase_) {
992#ifdef USE_WIFI_FAST_CONNECT
994#endif
995 // Fast connect phases: use config-only (no scan results)
996 // BSSID/channel from config if user specified them, otherwise empty
997 break;
998
1001 // Hidden network mode: clear BSSID/channel to trigger probe request
1002 // (both explicit hidden and retry hidden use same behavior)
1003 params.clear_bssid();
1004 params.clear_channel();
1005 break;
1006
1008 // Scan-based phase: always use best scan result (index 0 - highest priority after sorting)
1009 if (!this->scan_result_.empty()) {
1010 apply_scan_result_to_params(params, this->scan_result_[0]);
1011 }
1012 break;
1013
1015 // Should not be building params during restart
1016 break;
1017 }
1018
1019 return params;
1020}
1021
1023 const WiFiAP *config = this->get_selected_sta_();
1024 return config ? *config : WiFiAP{};
1025}
1026void WiFiComponent::save_wifi_sta(const std::string &ssid, const std::string &password) {
1027 this->save_wifi_sta(ssid.c_str(), password.c_str());
1028}
1029void WiFiComponent::save_wifi_sta(const char *ssid, const char *password) {
1030 SavedWifiSettings save{}; // zero-initialized - all bytes set to \0, guaranteeing null termination
1031 strncpy(save.ssid, ssid, sizeof(save.ssid) - 1); // max 32 chars, byte 32 remains \0
1032 strncpy(save.password, password, sizeof(save.password) - 1); // max 64 chars, byte 64 remains \0
1033 this->pref_.save(&save);
1034 // ensure it's written immediately
1036
1037 WiFiAP sta{};
1038 sta.set_ssid(ssid);
1039 sta.set_password(password);
1040 this->set_sta(sta);
1041
1042 // Trigger connection attempt (exits cooldown if needed, no-op if already connecting/connected)
1043 this->connect_soon_();
1044}
1045
1047 // Only trigger retry if we're in cooldown - if already connecting/connected, do nothing
1049 ESP_LOGD(TAG, "Exiting cooldown early due to new WiFi credentials");
1050 this->retry_connect();
1051 }
1052}
1053
1055 // Log connection attempt at INFO level with priority
1056 char bssid_s[18];
1057 int8_t priority = 0;
1058
1059 if (ap.has_bssid()) {
1060 format_mac_addr_upper(ap.get_bssid().data(), bssid_s);
1061 priority = this->get_sta_priority(ap.get_bssid());
1062 }
1063
1064 ESP_LOGI(TAG,
1065 "Connecting to " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") " (priority %d, attempt %u/%u in phase %s)...",
1066 ap.ssid_.c_str(), ap.has_bssid() ? bssid_s : LOG_STR_LITERAL("any"), priority, this->num_retried_ + 1,
1067 get_max_retries_for_phase(this->retry_phase_), LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_)));
1068
1069#ifdef ESPHOME_LOG_HAS_VERBOSE
1070 ESP_LOGV(TAG,
1071 "Connection Params:\n"
1072 " SSID: '%s'",
1073 ap.ssid_.c_str());
1074 if (ap.has_bssid()) {
1075 ESP_LOGV(TAG, " BSSID: %s", bssid_s);
1076 } else {
1077 ESP_LOGV(TAG, " BSSID: Not Set");
1078 }
1079
1080#ifdef USE_WIFI_WPA2_EAP
1081 if (ap.get_eap().has_value()) {
1082 EAPAuth eap_config = ap.get_eap().value();
1083 // clang-format off
1084 ESP_LOGV(
1085 TAG,
1086 " WPA2 Enterprise authentication configured:\n"
1087 " Identity: " LOG_SECRET("'%s'") "\n"
1088 " Username: " LOG_SECRET("'%s'") "\n"
1089 " Password: " LOG_SECRET("'%s'"),
1090 eap_config.identity.c_str(), eap_config.username.c_str(), eap_config.password.c_str());
1091 // clang-format on
1092#if defined(USE_ESP32) && defined(USE_WIFI_WPA2_EAP) && ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
1093 ESP_LOGV(TAG, " TTLS Phase 2: " LOG_SECRET("'%s'"), eap_phase2_to_str(eap_config.ttls_phase_2));
1094#endif
1095 bool ca_cert_present = eap_config.ca_cert != nullptr && strlen(eap_config.ca_cert);
1096 bool client_cert_present = eap_config.client_cert != nullptr && strlen(eap_config.client_cert);
1097 bool client_key_present = eap_config.client_key != nullptr && strlen(eap_config.client_key);
1098 ESP_LOGV(TAG,
1099 " CA Cert: %s\n"
1100 " Client Cert: %s\n"
1101 " Client Key: %s",
1102 ca_cert_present ? "present" : "not present", client_cert_present ? "present" : "not present",
1103 client_key_present ? "present" : "not present");
1104 } else {
1105#endif
1106 ESP_LOGV(TAG, " Password: " LOG_SECRET("'%s'"), ap.password_.c_str());
1107#ifdef USE_WIFI_WPA2_EAP
1108 }
1109#endif
1110 if (ap.has_channel()) {
1111 ESP_LOGV(TAG, " Channel: %u", ap.get_channel());
1112 } else {
1113 ESP_LOGV(TAG, " Channel not set");
1114 }
1115#ifdef USE_WIFI_MANUAL_IP
1116 if (ap.get_manual_ip().has_value()) {
1117 ManualIP m = *ap.get_manual_ip();
1118 char static_ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
1119 char gateway_buf[network::IP_ADDRESS_BUFFER_SIZE];
1120 char subnet_buf[network::IP_ADDRESS_BUFFER_SIZE];
1121 char dns1_buf[network::IP_ADDRESS_BUFFER_SIZE];
1122 char dns2_buf[network::IP_ADDRESS_BUFFER_SIZE];
1123 ESP_LOGV(TAG, " Manual IP: Static IP=%s Gateway=%s Subnet=%s DNS1=%s DNS2=%s", m.static_ip.str_to(static_ip_buf),
1124 m.gateway.str_to(gateway_buf), m.subnet.str_to(subnet_buf), m.dns1.str_to(dns1_buf),
1125 m.dns2.str_to(dns2_buf));
1126 } else
1127#endif
1128 {
1129 ESP_LOGV(TAG, " Using DHCP IP");
1130 }
1131 ESP_LOGV(TAG, " Hidden: %s", YESNO(ap.get_hidden()));
1132#endif
1133
1134 // Clear any stale error from previous connection attempt.
1135 // This is the canonical location for clearing the flag since all connection
1136 // attempts go through start_connecting(). The only other clear is in
1137 // restart_adapter() which enters COOLDOWN without calling start_connecting().
1138 this->error_from_callback_ = false;
1139
1140 if (!this->wifi_sta_connect_(ap)) {
1141 ESP_LOGE(TAG, "wifi_sta_connect_ failed");
1142 // Enter cooldown to allow WiFi hardware to stabilize
1143 // (immediate failure suggests hardware not ready, different from connection timeout)
1145 } else {
1147 }
1148 this->action_started_ = millis();
1149}
1150
1151const LogString *get_signal_bars(int8_t rssi) {
1152 // LOWER ONE QUARTER BLOCK
1153 // Unicode: U+2582, UTF-8: E2 96 82
1154 // LOWER HALF BLOCK
1155 // Unicode: U+2584, UTF-8: E2 96 84
1156 // LOWER THREE QUARTERS BLOCK
1157 // Unicode: U+2586, UTF-8: E2 96 86
1158 // FULL BLOCK
1159 // Unicode: U+2588, UTF-8: E2 96 88
1160 if (rssi >= -50) {
1161 return LOG_STR("\033[0;32m" // green
1162 "\xe2\x96\x82"
1163 "\xe2\x96\x84"
1164 "\xe2\x96\x86"
1165 "\xe2\x96\x88"
1166 "\033[0m");
1167 } else if (rssi >= -65) {
1168 return LOG_STR("\033[0;33m" // yellow
1169 "\xe2\x96\x82"
1170 "\xe2\x96\x84"
1171 "\xe2\x96\x86"
1172 "\033[0;37m"
1173 "\xe2\x96\x88"
1174 "\033[0m");
1175 } else if (rssi >= -85) {
1176 return LOG_STR("\033[0;33m" // yellow
1177 "\xe2\x96\x82"
1178 "\xe2\x96\x84"
1179 "\033[0;37m"
1180 "\xe2\x96\x86"
1181 "\xe2\x96\x88"
1182 "\033[0m");
1183 } else {
1184 return LOG_STR("\033[0;31m" // red
1185 "\xe2\x96\x82"
1186 "\033[0;37m"
1187 "\xe2\x96\x84"
1188 "\xe2\x96\x86"
1189 "\xe2\x96\x88"
1190 "\033[0m");
1191 }
1192}
1193
1195 bssid_t bssid = wifi_bssid();
1196 char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
1197 format_mac_addr_upper(bssid.data(), bssid_s);
1198 // Use stack buffers for IP address formatting to avoid heap allocations
1199 char ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
1200 for (auto &ip : wifi_sta_ip_addresses()) {
1201 if (ip.is_set()) {
1202 ESP_LOGCONFIG(TAG, " IP Address: %s", ip.str_to(ip_buf));
1203 }
1204 }
1205 int8_t rssi = wifi_rssi();
1206 // Use stack buffers for SSID and all IP addresses to avoid heap allocations
1207 char ssid_buf[SSID_BUFFER_SIZE];
1208 char subnet_buf[network::IP_ADDRESS_BUFFER_SIZE];
1209 char gateway_buf[network::IP_ADDRESS_BUFFER_SIZE];
1210 char dns1_buf[network::IP_ADDRESS_BUFFER_SIZE];
1211 char dns2_buf[network::IP_ADDRESS_BUFFER_SIZE];
1212 // clang-format off
1213 ESP_LOGCONFIG(TAG,
1214 " SSID: " LOG_SECRET("'%s'") "\n"
1215 " BSSID: " LOG_SECRET("%s") "\n"
1216 " Hostname: '%s'\n"
1217 " Signal strength: %d dB %s\n"
1218 " Channel: %" PRId32 "\n"
1219 " Subnet: %s\n"
1220 " Gateway: %s\n"
1221 " DNS1: %s\n"
1222 " DNS2: %s",
1223 wifi_ssid_to(ssid_buf), bssid_s, App.get_name().c_str(), rssi, LOG_STR_ARG(get_signal_bars(rssi)),
1224 get_wifi_channel(), wifi_subnet_mask_().str_to(subnet_buf), wifi_gateway_ip_().str_to(gateway_buf),
1225 wifi_dns_ip_(0).str_to(dns1_buf), wifi_dns_ip_(1).str_to(dns2_buf));
1226 // clang-format on
1227#ifdef ESPHOME_LOG_HAS_VERBOSE
1228 if (const WiFiAP *config = this->get_selected_sta_(); config && config->has_bssid()) {
1229 ESP_LOGV(TAG, " Priority: %d", this->get_sta_priority(config->get_bssid()));
1230 }
1231#endif
1232#ifdef USE_WIFI_11KV_SUPPORT
1233 ESP_LOGCONFIG(TAG,
1234 " BTM: %s\n"
1235 " RRM: %s",
1236 this->btm_ ? "enabled" : "disabled", this->rrm_ ? "enabled" : "disabled");
1237#endif
1238}
1239
1242 return;
1243
1244 ESP_LOGD(TAG, "Enabling");
1246 this->start();
1247}
1248
1251 return;
1252
1253 ESP_LOGD(TAG, "Disabling");
1255 this->wifi_disconnect_();
1256 this->wifi_mode_(false, false);
1257}
1258
1260
1262 this->action_started_ = millis();
1263 ESP_LOGD(TAG, "Starting scan");
1264 this->wifi_scan_start_(this->passive_scan_);
1266}
1267
1301[[nodiscard]] inline static bool wifi_scan_result_is_better(const WiFiScanResult &a, const WiFiScanResult &b) {
1302 // Matching networks always come before non-matching
1303 if (a.get_matches() && !b.get_matches())
1304 return true;
1305 if (!a.get_matches() && b.get_matches())
1306 return false;
1307
1308 // Both matching: check priority first (tracks connection failures via priority degradation)
1309 // Priority is decreased when a BSSID fails to connect, so lower priority = previously failed
1310 if (a.get_matches() && b.get_matches() && a.get_priority() != b.get_priority()) {
1311 return a.get_priority() > b.get_priority();
1312 }
1313
1314 // Use RSSI as tiebreaker (for equal-priority matching networks or all non-matching networks)
1315 return a.get_rssi() > b.get_rssi();
1316}
1317
1318// Helper function for insertion sort of WiFi scan results
1319// Using insertion sort instead of std::stable_sort saves flash memory
1320// by avoiding template instantiations (std::rotate, std::stable_sort, lambdas)
1321// IMPORTANT: This sort is stable (preserves relative order of equal elements)
1322template<typename VectorType> static void insertion_sort_scan_results(VectorType &results) {
1323 const size_t size = results.size();
1324 for (size_t i = 1; i < size; i++) {
1325 // Make a copy to avoid issues with move semantics during comparison
1326 WiFiScanResult key = results[i];
1327 int32_t j = i - 1;
1328
1329 // Move elements that are worse than key to the right
1330 // For stability, we only move if key is strictly better than results[j]
1331 while (j >= 0 && wifi_scan_result_is_better(key, results[j])) {
1332 results[j + 1] = results[j];
1333 j--;
1334 }
1335 results[j + 1] = key;
1336 }
1337}
1338
1339// Helper function to log matching scan results - marked noinline to prevent re-inlining into loop
1340//
1341// IMPORTANT: This function deliberately uses a SINGLE log call to minimize blocking.
1342// In environments with many matching networks (e.g., 18+ mesh APs), multiple log calls
1343// per network would block the main loop for an unacceptable duration. Each log call
1344// has overhead from UART transmission, so combining INFO+DEBUG into one line halves
1345// the blocking time. Do NOT split this into separate ESP_LOGI/ESP_LOGD calls.
1346__attribute__((noinline)) static void log_scan_result(const WiFiScanResult &res) {
1347 char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
1348 auto bssid = res.get_bssid();
1349 format_mac_addr_upper(bssid.data(), bssid_s);
1350
1351#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG
1352 // Single combined log line with all details when DEBUG enabled
1353 ESP_LOGI(TAG, "- '%s' %s" LOG_SECRET("(%s) ") "%s Ch:%2u %3ddB P:%d", res.get_ssid().c_str(),
1354 res.get_is_hidden() ? LOG_STR_LITERAL("(HIDDEN) ") : LOG_STR_LITERAL(""), bssid_s,
1355 LOG_STR_ARG(get_signal_bars(res.get_rssi())), res.get_channel(), res.get_rssi(), res.get_priority());
1356#else
1357 ESP_LOGI(TAG, "- '%s' %s" LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(),
1358 res.get_is_hidden() ? LOG_STR_LITERAL("(HIDDEN) ") : LOG_STR_LITERAL(""), bssid_s,
1359 LOG_STR_ARG(get_signal_bars(res.get_rssi())));
1360#endif
1361}
1362
1364 if (!this->scan_done_) {
1365 if (millis() - this->action_started_ > WIFI_SCAN_TIMEOUT_MS) {
1366 ESP_LOGE(TAG, "Scan timeout");
1367 this->retry_connect();
1368 }
1369 return;
1370 }
1371 this->scan_done_ = false;
1373 true; // Track that we've done a scan since captive portal started
1375
1376 if (this->scan_result_.empty()) {
1377 ESP_LOGW(TAG, "No networks found");
1378 this->retry_connect();
1379 return;
1380 }
1381
1382 ESP_LOGD(TAG, "Found networks:");
1383 for (auto &res : this->scan_result_) {
1384 for (auto &ap : this->sta_) {
1385 if (res.matches(ap)) {
1386 res.set_matches(true);
1387 // Cache priority lookup - do single search instead of 2 separate searches
1388 const bssid_t &bssid = res.get_bssid();
1389 if (!this->has_sta_priority(bssid)) {
1390 this->set_sta_priority(bssid, ap.get_priority());
1391 }
1392 res.set_priority(this->get_sta_priority(bssid));
1393 break;
1394 }
1395 }
1396 }
1397
1398 // Sort scan results using insertion sort for better memory efficiency
1399 insertion_sort_scan_results(this->scan_result_);
1400
1401 // Log matching networks (non-matching already logged at VERBOSE in scan callback)
1402 for (auto &res : this->scan_result_) {
1403 if (res.get_matches()) {
1404 log_scan_result(res);
1405 }
1406 }
1407
1408 // SYNCHRONIZATION POINT: Establish link between scan_result_[0] and selected_sta_index_
1409 // After sorting, scan_result_[0] contains the best network. Now find which sta_[i] config
1410 // matches that network and record it in selected_sta_index_. This keeps the two indices
1411 // synchronized so build_params_for_current_phase_() can safely use both to build connection parameters.
1412 const WiFiScanResult &scan_res = this->scan_result_[0];
1413 bool found_match = false;
1414 if (scan_res.get_matches()) {
1415 for (size_t i = 0; i < this->sta_.size(); i++) {
1416 if (scan_res.matches(this->sta_[i])) {
1417 // Safe cast: sta_.size() limited to MAX_WIFI_NETWORKS (127) in __init__.py validation
1418 // No overflow check needed - YAML validation prevents >127 networks
1419 this->selected_sta_index_ = static_cast<int8_t>(i); // Links scan_result_[0] with sta_[i]
1420 found_match = true;
1421 break;
1422 }
1423 }
1424 }
1425
1426 if (!found_match) {
1427 ESP_LOGW(TAG, "No matching network found");
1428 // No scan results matched our configured networks - transition directly to hidden mode
1429 // Don't call retry_connect() since we never attempted a connection (no BSSID to penalize)
1431 // If no hidden networks to try, skip connection attempt (will be handled on next loop)
1432 if (this->selected_sta_index_ == -1) {
1433 return;
1434 }
1435 // Now start connection attempt in hidden mode
1437 return; // scan started, wait for next loop iteration
1438 }
1439
1440 yield();
1441
1442 WiFiAP params = this->build_params_for_current_phase_();
1443 // Ensure we're in SCAN_CONNECTING phase when connecting with scan results
1444 // (needed when scan was started directly without transition_to_phase_, e.g., initial scan)
1445 this->start_connecting(params);
1446}
1447
1449 char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
1450 ESP_LOGCONFIG(TAG,
1451 "WiFi:\n"
1452 " Local MAC: %s\n"
1453 " Connected: %s",
1454 get_mac_address_pretty_into_buffer(mac_s), YESNO(this->is_connected()));
1455 if (this->is_disabled()) {
1456 ESP_LOGCONFIG(TAG, " Disabled");
1457 return;
1458 }
1459 if (this->is_connected()) {
1460 this->print_connect_params_();
1461 }
1462}
1463
1465 auto status = this->wifi_sta_connect_status_();
1466
1468 char ssid_buf[SSID_BUFFER_SIZE];
1469 if (wifi_ssid_to(ssid_buf)[0] == '\0') {
1470 ESP_LOGW(TAG, "Connection incomplete");
1471 this->retry_connect();
1472 return;
1473 }
1474
1475 ESP_LOGI(TAG, "Connected");
1476 // Warn if we had to retry with hidden network mode for a network that's not marked hidden
1477 // Only warn if we actually connected without scan data (SSID only), not if scan succeeded on retry
1478 if (const WiFiAP *config = this->get_selected_sta_(); this->retry_phase_ == WiFiRetryPhase::RETRY_HIDDEN &&
1479 config && !config->get_hidden() &&
1480 this->scan_result_.empty()) {
1481 ESP_LOGW(TAG, LOG_SECRET("'%s'") " should be marked hidden", config->ssid_.c_str());
1482 }
1483 // Reset to initial phase on successful connection (don't log transition, just reset state)
1485 this->num_retried_ = 0;
1486 if (this->has_ap()) {
1487#ifdef USE_CAPTIVE_PORTAL
1488 if (this->is_captive_portal_active_()) {
1490 }
1491#endif
1492 ESP_LOGD(TAG, "Disabling AP");
1493 this->wifi_mode_({}, false);
1494 }
1495#ifdef USE_IMPROV
1496 if (this->is_esp32_improv_active_()) {
1498 }
1499#endif
1500
1502 this->num_retried_ = 0;
1503 this->print_connect_params_();
1504
1505 // Reset roaming state on successful connection
1506 this->roaming_last_check_ = now;
1507 // Only preserve attempts if reconnecting after a failed roam attempt
1508 // This prevents ping-pong between APs when a roam target is unreachable
1510 // Successful roam to better AP - reset attempts so we can roam again later
1511 ESP_LOGD(TAG, "Roam successful");
1512 this->roaming_attempts_ = 0;
1513 } else if (this->roaming_state_ == RoamingState::RECONNECTING) {
1514 // Failed roam, reconnected via normal recovery - keep attempts to prevent ping-pong
1515 ESP_LOGD(TAG, "Reconnected after failed roam (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
1516 } else {
1517 // Normal connection (boot, credentials changed, etc.)
1518 this->roaming_attempts_ = 0;
1519 }
1521
1522 // Clear all priority penalties - the next reconnect will happen when an AP disconnects,
1523 // which means the landscape has likely changed and previous tracked failures are stale
1525
1526#ifdef USE_WIFI_FAST_CONNECT
1528#endif
1529
1530 this->release_scan_results_();
1531
1532#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
1533 // Notify listeners now that state machine has reached STA_CONNECTED
1534 // This ensures wifi.connected condition returns true in listener automations
1536#endif
1537
1538#if defined(USE_ESP8266) && defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP)
1539 // On ESP8266, GOT_IP event may not fire for static IP configurations,
1540 // so notify IP state listeners here as a fallback.
1541 if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) {
1543 }
1544#endif
1545
1546 return;
1547 }
1548
1549 if (now - this->action_started_ > WIFI_CONNECT_TIMEOUT_MS) {
1550 ESP_LOGW(TAG, "Connection timeout, aborting connection attempt");
1551 this->wifi_disconnect_();
1552 this->retry_connect();
1553 return;
1554 }
1555
1556 if (this->error_from_callback_) {
1557 // ESP8266: logging done in callback, listeners deferred via pending_.disconnect
1558 // Other platforms: just log generic failure message
1559#ifndef USE_ESP8266
1560 ESP_LOGW(TAG, "Connecting to network failed (callback)");
1561#endif
1562 this->retry_connect();
1563 return;
1564 }
1565
1567 return;
1568 }
1569
1571 ESP_LOGW(TAG, "Network no longer found");
1572 this->retry_connect();
1573 return;
1574 }
1575
1577 ESP_LOGW(TAG, "Connecting to network failed");
1578 this->retry_connect();
1579 return;
1580 }
1581
1582 ESP_LOGW(TAG, "Unknown connection status %d", (int) status);
1583 this->retry_connect();
1584}
1585
1593 switch (this->retry_phase_) {
1595#ifdef USE_WIFI_FAST_CONNECT
1597 // INITIAL_CONNECT and FAST_CONNECT_CYCLING_APS: no retries, try next AP or fall back to scan
1598 if (this->selected_sta_index_ < static_cast<int8_t>(this->sta_.size()) - 1) {
1599 return WiFiRetryPhase::FAST_CONNECT_CYCLING_APS; // Move to next AP
1600 }
1601#endif
1602 // Check if we should try explicit hidden networks before scanning
1603 // This handles reconnection after connection loss where first network is hidden
1604 if (!this->sta_.empty() && this->sta_[0].get_hidden()) {
1606 }
1607 // No more APs to try, fall back to scan
1609
1611 // Try all explicitly hidden networks before scanning
1612 if (this->num_retried_ + 1 < WIFI_RETRY_COUNT_PER_SSID) {
1613 return WiFiRetryPhase::EXPLICIT_HIDDEN; // Keep retrying same SSID
1614 }
1615
1616 // Exhausted retries on current SSID - check for more explicitly hidden networks
1617 // Stop when we reach a visible network (proceed to scanning)
1618 size_t next_index = this->selected_sta_index_ + 1;
1619 if (next_index < this->sta_.size() && this->sta_[next_index].get_hidden()) {
1620 // Found another explicitly hidden network
1622 }
1623
1624 // No more consecutive explicitly hidden networks
1625 // If ALL networks are hidden, skip scanning and go directly to restart
1626 if (this->find_first_non_hidden_index_() < 0) {
1628 }
1629 // Otherwise proceed to scanning for non-hidden networks
1631 }
1632
1634 // If scan found no networks or no matching networks, skip to hidden network mode
1635 if (this->scan_result_.empty() || !this->scan_result_[0].get_matches()) {
1637 }
1638
1639 if (this->num_retried_ + 1 < WIFI_RETRY_COUNT_PER_BSSID) {
1640 return WiFiRetryPhase::SCAN_CONNECTING; // Keep retrying same BSSID
1641 }
1642
1643 // Exhausted retries on current BSSID (scan_result_[0])
1644 // Its priority has been decreased, so on next scan it will be sorted lower
1645 // and we'll try the next best BSSID.
1646 // Check if there are any potentially hidden networks to try
1647 if (this->find_next_hidden_sta_(-1) >= 0) {
1648 return WiFiRetryPhase::RETRY_HIDDEN; // Found hidden networks to try
1649 }
1650 // No hidden networks - always go through RESTARTING_ADAPTER phase
1651 // This ensures num_retried_ gets reset and a fresh scan is triggered
1652 // The actual adapter restart will be skipped if captive portal/improv is active
1654
1656 // If no hidden SSIDs to try (selected_sta_index_ == -1), skip directly to rescan
1657 if (this->selected_sta_index_ >= 0) {
1658 if (this->num_retried_ + 1 < WIFI_RETRY_COUNT_PER_SSID) {
1659 return WiFiRetryPhase::RETRY_HIDDEN; // Keep retrying same SSID
1660 }
1661
1662 // Exhausted retries on current SSID - check if there are more potentially hidden SSIDs to try
1663 if (this->selected_sta_index_ < static_cast<int8_t>(this->sta_.size()) - 1) {
1664 // Check if find_next_hidden_sta_() would actually find another hidden SSID
1665 // as it might have been seen in the scan results and we want to skip those
1666 // otherwise we will get stuck in RETRY_HIDDEN phase
1667 if (this->find_next_hidden_sta_(this->selected_sta_index_) != -1) {
1668 // More hidden SSIDs available - stay in RETRY_HIDDEN, advance will happen in retry_connect()
1670 }
1671 }
1672 }
1673 // Exhausted all potentially hidden SSIDs - always go through RESTARTING_ADAPTER
1674 // This ensures num_retried_ gets reset and a fresh scan is triggered
1675 // The actual adapter restart will be skipped if captive portal/improv is active
1677
1679 // After restart, go back to explicit hidden if we went through it initially
1682 }
1683 // Skip scanning when captive portal/improv is active to avoid disrupting AP,
1684 // BUT only if we've already completed at least one scan AFTER the portal started.
1685 // When captive portal first starts, scan results may be filtered/stale, so we need
1686 // to do one full scan to populate available networks for the captive portal UI.
1687 //
1688 // WHY SCANNING DISRUPTS AP MODE:
1689 // WiFi scanning requires the radio to leave the AP's channel and hop through
1690 // other channels to listen for beacons. During this time (even for passive scans),
1691 // the AP cannot service connected clients - they experience disconnections or
1692 // timeouts. On ESP32, even passive scans cause brief but noticeable disruptions
1693 // that break captive portal HTTP requests and DNS lookups.
1694 //
1695 // BLIND RETRY MODE:
1696 // When captive portal/improv is active, we use RETRY_HIDDEN as a "try all networks
1697 // blindly" mode. Since retry_hidden_mode_ is set to BLIND_RETRY (in RESTARTING_ADAPTER
1698 // transition), find_next_hidden_sta_() will treat ALL configured networks as
1699 // candidates, cycling through them without requiring scan results.
1700 //
1701 // This allows users to configure WiFi via captive portal while the device keeps
1702 // attempting to connect to all configured networks in sequence.
1703 // Captive portal needs scan results to show available networks.
1704 // If captive portal is active, only skip scanning if we've done a scan after it started.
1705 // If only improv is active (no captive portal), skip scanning since improv doesn't need results.
1706 if (this->is_captive_portal_active_()) {
1709 }
1710 // Need to scan for captive portal
1711 } else if (this->is_esp32_improv_active_()) {
1712 // Improv doesn't need scan results
1714 }
1716 }
1717
1718 // Should never reach here
1720}
1721
1732 WiFiRetryPhase old_phase = this->retry_phase_;
1733
1734 // No-op if staying in same phase
1735 if (old_phase == new_phase) {
1736 return false;
1737 }
1738
1739 ESP_LOGD(TAG, "Retry phase: %s → %s", LOG_STR_ARG(retry_phase_to_log_string(old_phase)),
1740 LOG_STR_ARG(retry_phase_to_log_string(new_phase)));
1741
1742 this->retry_phase_ = new_phase;
1743 this->num_retried_ = 0; // Reset retry counter on phase change
1744
1745 // Phase-specific setup
1746 switch (new_phase) {
1747#ifdef USE_WIFI_FAST_CONNECT
1749 // Move to next configured AP - clear old scan data so new AP is tried with config only
1750 this->selected_sta_index_++;
1751 this->scan_result_.clear();
1752 break;
1753#endif
1754
1756 // Starting explicit hidden phase - reset to first network
1757 this->selected_sta_index_ = 0;
1758 break;
1759
1761 // Transitioning to scan-based connection
1762#ifdef USE_WIFI_FAST_CONNECT
1764 ESP_LOGI(TAG, "Fast connect exhausted, falling back to scan");
1765 }
1766#endif
1767 // Trigger scan if we don't have scan results OR if transitioning from phases that need fresh scan
1768 if (this->scan_result_.empty() || old_phase == WiFiRetryPhase::EXPLICIT_HIDDEN ||
1770 this->selected_sta_index_ = -1; // Will be set after scan completes
1771 this->start_scanning();
1772 return true; // Started scan, wait for completion
1773 }
1774 // Already have scan results - selected_sta_index_ should already be synchronized
1775 // (set in check_scanning_finished() when scan completed)
1776 // No need to reset it here
1777 break;
1778
1780 // Always reset to first candidate when entering this phase.
1781 // This phase can be entered from:
1782 // - SCAN_CONNECTING: normal flow, find_next_hidden_sta_() skips networks visible in scan
1783 // - RESTARTING_ADAPTER: captive portal active, find_next_hidden_sta_() tries ALL networks
1784 //
1785 // The retry_hidden_mode_ controls the behavior:
1786 // - SCAN_BASED: scan_result_ is checked, visible networks are skipped
1787 // - BLIND_RETRY: scan_result_ is ignored, all networks become candidates
1788 // We don't clear scan_result_ here - the mode controls whether it's consulted.
1790
1791 if (this->selected_sta_index_ == -1) {
1792 ESP_LOGD(TAG, "All SSIDs visible or already tried, skipping hidden mode");
1793 }
1794 break;
1795
1797 // Skip actual adapter restart if captive portal/improv is active
1798 // This allows state machine to reset num_retried_ and trigger fresh scan
1799 // without disrupting the captive portal/improv connection
1800 if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_()) {
1801 this->restart_adapter();
1802 } else {
1803 // Even when skipping full restart, disconnect to clear driver state
1804 // Without this, platforms like LibreTiny may think we're still connecting
1805 this->wifi_disconnect_();
1806 }
1807 // Clear scan flag - we're starting a new retry cycle
1808 // This is critical for captive portal/improv flow: when determine_next_phase_()
1809 // returns RETRY_HIDDEN (because scanning is skipped), find_next_hidden_sta_()
1810 // will see BLIND_RETRY mode and treat ALL networks as candidates,
1811 // effectively cycling through all configured networks without scan results.
1813 // Always enter cooldown after restart (or skip-restart) to allow stabilization
1814 // Use extended cooldown when AP is active to avoid constant scanning that blocks DNS
1816 this->action_started_ = millis();
1817 // Return true to indicate we should wait (go to COOLDOWN) instead of immediately connecting
1818 return true;
1819
1820 default:
1821 break;
1822 }
1823
1824 return false; // Did not start scan, can proceed with connection
1825}
1826
1828 if (!this->sta_priorities_.empty()) {
1829 decltype(this->sta_priorities_)().swap(this->sta_priorities_);
1830 }
1831}
1832
1837 if (this->sta_priorities_.empty()) {
1838 return;
1839 }
1840
1841 int8_t first_priority = this->sta_priorities_[0].priority;
1842
1843 // Only clear if all priorities have been decremented to the minimum value
1844 // At this point, all BSSIDs have been equally penalized and priority info is useless
1845 if (first_priority != std::numeric_limits<int8_t>::min()) {
1846 return;
1847 }
1848
1849 for (const auto &pri : this->sta_priorities_) {
1850 if (pri.priority != first_priority) {
1851 return; // Not all same, nothing to do
1852 }
1853 }
1854
1855 // All priorities are at minimum - clear the vector to save memory and reset
1856 ESP_LOGD(TAG, "Clearing BSSID priorities (all at minimum)");
1858}
1859
1879 // Determine which BSSID we tried to connect to
1880 optional<bssid_t> failed_bssid;
1881
1882 if (this->retry_phase_ == WiFiRetryPhase::SCAN_CONNECTING && !this->scan_result_.empty()) {
1883 // Scan-based phase: always use best result (index 0)
1884 failed_bssid = this->scan_result_[0].get_bssid();
1885 } else if (const WiFiAP *config = this->get_selected_sta_(); config && config->has_bssid()) {
1886 // Config has specific BSSID (fast_connect or user-specified)
1887 failed_bssid = config->get_bssid();
1888 }
1889
1890 if (!failed_bssid.has_value()) {
1891 return; // No BSSID to penalize
1892 }
1893
1894 // Get SSID for logging (use pointer to avoid copy)
1895 const char *ssid = nullptr;
1896 if (this->retry_phase_ == WiFiRetryPhase::SCAN_CONNECTING && !this->scan_result_.empty()) {
1897 ssid = this->scan_result_[0].ssid_.c_str();
1898 } else if (const WiFiAP *config = this->get_selected_sta_()) {
1899 ssid = config->ssid_.c_str();
1900 }
1901
1902 // Only decrease priority on the last attempt for this phase
1903 // This prevents false positives from transient WiFi stack issues
1904 uint8_t max_retries = get_max_retries_for_phase(this->retry_phase_);
1905 bool is_last_attempt = (this->num_retried_ + 1 >= max_retries);
1906
1907 // Decrease priority only on last attempt to avoid false positives from transient failures
1908 int8_t old_priority = this->get_sta_priority(failed_bssid.value());
1909 int8_t new_priority = old_priority;
1910
1911 if (is_last_attempt) {
1912 // Decrease priority, but clamp to int8_t::min to prevent overflow
1913 new_priority =
1914 (old_priority > std::numeric_limits<int8_t>::min()) ? (old_priority - 1) : std::numeric_limits<int8_t>::min();
1915 this->set_sta_priority(failed_bssid.value(), new_priority);
1916 }
1917 char bssid_s[18];
1918 format_mac_addr_upper(failed_bssid.value().data(), bssid_s);
1919 ESP_LOGD(TAG, "Failed " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") ", priority %d → %d", ssid != nullptr ? ssid : "",
1920 bssid_s, old_priority, new_priority);
1921
1922 // After adjusting priority, check if all priorities are now at minimum
1923 // If so, clear the vector to save memory and reset for fresh start
1925}
1926
1938 WiFiRetryPhase current_phase = this->retry_phase_;
1939
1940 // Check if we need to advance to next AP/SSID within the same phase
1941#ifdef USE_WIFI_FAST_CONNECT
1942 if (current_phase == WiFiRetryPhase::FAST_CONNECT_CYCLING_APS) {
1943 // Fast connect: always advance to next AP (no retries per AP)
1944 this->selected_sta_index_++;
1945 this->num_retried_ = 0;
1946 ESP_LOGD(TAG, "Next AP in %s", LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_)));
1947 return;
1948 }
1949#endif
1950
1951 if (current_phase == WiFiRetryPhase::EXPLICIT_HIDDEN && this->num_retried_ + 1 >= WIFI_RETRY_COUNT_PER_SSID) {
1952 // Explicit hidden: exhausted retries on current SSID, find next explicitly hidden network
1953 // Stop when we reach a visible network (proceed to scanning)
1954 size_t next_index = this->selected_sta_index_ + 1;
1955 if (next_index < this->sta_.size() && this->sta_[next_index].get_hidden()) {
1956 this->selected_sta_index_ = static_cast<int8_t>(next_index);
1957 this->num_retried_ = 0;
1958 ESP_LOGD(TAG, "Next explicit hidden network at index %d", static_cast<int>(next_index));
1959 return;
1960 }
1961 // No more consecutive explicit hidden networks found - fall through to trigger phase change
1962 }
1963
1964 if (current_phase == WiFiRetryPhase::RETRY_HIDDEN && this->num_retried_ + 1 >= WIFI_RETRY_COUNT_PER_SSID) {
1965 // Hidden mode: exhausted retries on current SSID, find next potentially hidden SSID
1966 // If first network is marked hidden, we went through EXPLICIT_HIDDEN phase
1967 // In that case, skip networks marked hidden:true (already tried)
1968 // Otherwise, include them (they haven't been tried yet)
1969 int8_t next_index = this->find_next_hidden_sta_(this->selected_sta_index_);
1970 if (next_index != -1) {
1971 // Found another potentially hidden SSID
1972 this->selected_sta_index_ = next_index;
1973 this->num_retried_ = 0;
1974 return;
1975 }
1976 // No more potentially hidden SSIDs - set selected_sta_index_ to -1 to trigger phase change
1977 // This ensures determine_next_phase_() will skip the RETRY_HIDDEN logic and transition out
1978 this->selected_sta_index_ = -1;
1979 // Return early - phase change will happen on next wifi_loop() iteration
1980 return;
1981 }
1982
1983 // Don't increment retry counter if we're in a scan phase with no valid targets
1984 if (this->needs_scan_results_()) {
1985 return;
1986 }
1987
1988 // Increment retry counter to try the same target again
1989 this->num_retried_++;
1990 ESP_LOGD(TAG, "Retry attempt %u/%u in phase %s", this->num_retried_ + 1,
1991 get_max_retries_for_phase(this->retry_phase_), LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_)));
1992}
1993
1995 // Handle roaming state transitions - preserve attempts counter to prevent ping-pong
1996 // to unreachable APs after ROAMING_MAX_ATTEMPTS failures
1998 // Roam connection failed - transition to reconnecting
1999 ESP_LOGD(TAG, "Roam failed, reconnecting (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
2001 } else if (this->roaming_state_ == RoamingState::SCANNING) {
2002 // Roam scan failed (e.g., scan error on ESP8266) - go back to idle, keep counter
2003 ESP_LOGD(TAG, "Roam scan failed (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
2005 } else if (this->roaming_state_ == RoamingState::IDLE) {
2006 // Not a roaming-triggered reconnect, reset state
2007 this->clear_roaming_state_();
2008 }
2009 // RECONNECTING: keep state and counter, still trying to reconnect
2010
2012
2013 // Determine next retry phase based on current state
2014 WiFiRetryPhase current_phase = this->retry_phase_;
2015 WiFiRetryPhase next_phase = this->determine_next_phase_();
2016
2017 // Handle phase transitions (transition_to_phase_ handles same-phase no-op internally)
2018 if (this->transition_to_phase_(next_phase)) {
2019 return; // Scan started or adapter restarted (which sets its own state)
2020 }
2021
2022 if (next_phase == current_phase) {
2024 }
2025
2026 yield();
2027 // Check if we have a valid target before building params
2028 // After exhausting all networks in a phase, selected_sta_index_ may be -1
2029 // In that case, skip connection and let next wifi_loop() handle phase transition
2030 if (this->selected_sta_index_ >= 0) {
2031 WiFiAP params = this->build_params_for_current_phase_();
2032 this->start_connecting(params);
2033 }
2034}
2035
2036#ifdef USE_RP2040
2037// RP2040's mDNS library (LEAmDNS) relies on LwipIntf::stateUpCB() to restart
2038// mDNS when the network interface reconnects. However, this callback is disabled
2039// in the arduino-pico framework. As a workaround, we block component setup until
2040// WiFi is connected, ensuring mDNS.begin() is called with an active connection.
2041
2043 if (!this->has_sta() || this->state_ == WIFI_COMPONENT_STATE_DISABLED || this->ap_setup_) {
2044 return true;
2045 }
2046 return this->is_connected();
2047}
2048#endif
2049
2050void WiFiComponent::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; }
2056 this->power_save_ = power_save;
2057#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
2058 this->configured_power_save_ = power_save;
2059#endif
2060}
2061
2062void WiFiComponent::set_passive_scan(bool passive) { this->passive_scan_ = passive; }
2063
2065#ifdef USE_CAPTIVE_PORTAL
2067#else
2068 return false;
2069#endif
2070}
2072#ifdef USE_IMPROV
2074#else
2075 return false;
2076#endif
2077}
2078
2079#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
2081 // Already configured for high performance - request satisfied
2083 return true;
2084 }
2085
2086 // Semaphore initialization failed
2087 if (this->high_performance_semaphore_ == nullptr) {
2088 return false;
2089 }
2090
2091 // Give the semaphore (non-blocking). This increments the count.
2092 return xSemaphoreGive(this->high_performance_semaphore_) == pdTRUE;
2093}
2094
2096 // Already configured for high performance - nothing to release
2098 return true;
2099 }
2100
2101 // Semaphore initialization failed
2102 if (this->high_performance_semaphore_ == nullptr) {
2103 return false;
2104 }
2105
2106 // Take the semaphore (non-blocking). This decrements the count.
2107 return xSemaphoreTake(this->high_performance_semaphore_, 0) == pdTRUE;
2108}
2109#endif // USE_ESP32 && USE_WIFI_RUNTIME_POWER_SAVE
2110
2111#ifdef USE_WIFI_FAST_CONNECT
2113 SavedWifiFastConnectSettings fast_connect_save{};
2114
2115 if (this->fast_connect_pref_.load(&fast_connect_save)) {
2116 // Validate saved AP index
2117 if (fast_connect_save.ap_index < 0 || static_cast<size_t>(fast_connect_save.ap_index) >= this->sta_.size()) {
2118 ESP_LOGW(TAG, "AP index out of bounds");
2119 return false;
2120 }
2121
2122 // Set selected index for future operations (save, retry, etc)
2123 this->selected_sta_index_ = fast_connect_save.ap_index;
2124
2125 // Copy entire config, then override with fast connect data
2126 params = this->sta_[fast_connect_save.ap_index];
2127
2128 // Override with saved BSSID/channel from fast connect (SSID/password/etc already copied from config)
2129 bssid_t bssid{};
2130 std::copy(fast_connect_save.bssid, fast_connect_save.bssid + 6, bssid.begin());
2131 params.set_bssid(bssid);
2132 params.set_channel(fast_connect_save.channel);
2133 // Fast connect uses specific BSSID+channel, not hidden network probe (even if config has hidden: true)
2134 params.set_hidden(false);
2135
2136 ESP_LOGD(TAG, "Loaded fast_connect settings");
2137 return true;
2138 }
2139
2140 return false;
2141}
2142
2144 bssid_t bssid = wifi_bssid();
2145 uint8_t channel = get_wifi_channel();
2146 // selected_sta_index_ is always valid here (called only after successful connection)
2147 // Fallback to 0 is defensive programming for robustness
2148 int8_t ap_index = this->selected_sta_index_ >= 0 ? this->selected_sta_index_ : 0;
2149
2150 // Skip save if settings haven't changed (compare with previously saved settings to reduce flash wear)
2151 SavedWifiFastConnectSettings previous_save{};
2152 if (this->fast_connect_pref_.load(&previous_save) && memcmp(previous_save.bssid, bssid.data(), 6) == 0 &&
2153 previous_save.channel == channel && previous_save.ap_index == ap_index) {
2154 return; // No change, nothing to save
2155 }
2156
2157 SavedWifiFastConnectSettings fast_connect_save{};
2158 memcpy(fast_connect_save.bssid, bssid.data(), 6);
2159 fast_connect_save.channel = channel;
2160 fast_connect_save.ap_index = ap_index;
2161
2162 this->fast_connect_pref_.save(&fast_connect_save);
2163
2164 ESP_LOGD(TAG, "Saved fast_connect settings");
2165}
2166#endif
2167
2168void WiFiAP::set_ssid(const std::string &ssid) { this->ssid_ = CompactString(ssid.c_str(), ssid.size()); }
2169void WiFiAP::set_ssid(const char *ssid) { this->ssid_ = CompactString(ssid, strlen(ssid)); }
2170void WiFiAP::set_bssid(const bssid_t &bssid) { this->bssid_ = bssid; }
2171void WiFiAP::clear_bssid() { this->bssid_ = {}; }
2172void WiFiAP::set_password(const std::string &password) {
2173 this->password_ = CompactString(password.c_str(), password.size());
2174}
2175void WiFiAP::set_password(const char *password) { this->password_ = CompactString(password, strlen(password)); }
2176#ifdef USE_WIFI_WPA2_EAP
2177void WiFiAP::set_eap(optional<EAPAuth> eap_auth) { this->eap_ = std::move(eap_auth); }
2178#endif
2179void WiFiAP::set_channel(uint8_t channel) { this->channel_ = channel; }
2180void WiFiAP::clear_channel() { this->channel_ = 0; }
2181#ifdef USE_WIFI_MANUAL_IP
2182void WiFiAP::set_manual_ip(optional<ManualIP> manual_ip) { this->manual_ip_ = manual_ip; }
2183#endif
2184void WiFiAP::set_hidden(bool hidden) { this->hidden_ = hidden; }
2185const bssid_t &WiFiAP::get_bssid() const { return this->bssid_; }
2186bool WiFiAP::has_bssid() const { return this->bssid_ != bssid_t{}; }
2187#ifdef USE_WIFI_WPA2_EAP
2188const optional<EAPAuth> &WiFiAP::get_eap() const { return this->eap_; }
2189#endif
2190uint8_t WiFiAP::get_channel() const { return this->channel_; }
2191bool WiFiAP::has_channel() const { return this->channel_ != 0; }
2192#ifdef USE_WIFI_MANUAL_IP
2194#endif
2195bool WiFiAP::get_hidden() const { return this->hidden_; }
2196
2197WiFiScanResult::WiFiScanResult(const bssid_t &bssid, const char *ssid, size_t ssid_len, uint8_t channel, int8_t rssi,
2198 bool with_auth, bool is_hidden)
2199 : bssid_(bssid),
2200 channel_(channel),
2201 rssi_(rssi),
2202 ssid_(ssid, ssid_len),
2203 with_auth_(with_auth),
2204 is_hidden_(is_hidden) {}
2205bool WiFiScanResult::matches(const WiFiAP &config) const {
2206 if (config.get_hidden()) {
2207 // User configured a hidden network, only match actually hidden networks
2208 // don't match SSID
2209 if (!this->is_hidden_)
2210 return false;
2211 } else if (!config.ssid_.empty()) {
2212 // check if SSID matches
2213 if (this->ssid_ != config.ssid_)
2214 return false;
2215 } else {
2216 // network is configured without SSID - match other settings
2217 }
2218 // If BSSID configured, only match for correct BSSIDs
2219 if (config.has_bssid() && config.get_bssid() != this->bssid_)
2220 return false;
2221
2222#ifdef USE_WIFI_WPA2_EAP
2223 // BSSID requires auth but no PSK or EAP credentials given
2224 if (this->with_auth_ && (config.password_.empty() && !config.get_eap().has_value()))
2225 return false;
2226
2227 // BSSID does not require auth, but PSK or EAP credentials given
2228 if (!this->with_auth_ && (!config.password_.empty() || config.get_eap().has_value()))
2229 return false;
2230#else
2231 // If PSK given, only match for networks with auth (and vice versa)
2232 if (config.password_.empty() == this->with_auth_)
2233 return false;
2234#endif
2235
2236 // If channel configured, only match networks on that channel.
2237 if (config.has_channel() && config.get_channel() != this->channel_) {
2238 return false;
2239 }
2240 return true;
2241}
2242bool WiFiScanResult::get_matches() const { return this->matches_; }
2243void WiFiScanResult::set_matches(bool matches) { this->matches_ = matches; }
2244const bssid_t &WiFiScanResult::get_bssid() const { return this->bssid_; }
2245uint8_t WiFiScanResult::get_channel() const { return this->channel_; }
2246int8_t WiFiScanResult::get_rssi() const { return this->rssi_; }
2247bool WiFiScanResult::get_with_auth() const { return this->with_auth_; }
2248bool WiFiScanResult::get_is_hidden() const { return this->is_hidden_; }
2249
2250bool WiFiScanResult::operator==(const WiFiScanResult &rhs) const { return this->bssid_ == rhs.bssid_; }
2251
2257
2259 if (!this->keep_scan_results_) {
2260#if defined(USE_RP2040) || defined(USE_ESP32)
2261 // std::vector - use swap trick since shrink_to_fit is non-binding
2262 decltype(this->scan_result_)().swap(this->scan_result_);
2263#else
2264 // FixedVector::release() frees all memory
2265 this->scan_result_.release();
2266#endif
2267 }
2268}
2269
2270#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
2272 if (!this->pending_.connect_state)
2273 return;
2274 this->pending_.connect_state = false;
2275 // Get current SSID and BSSID from the WiFi driver
2276 char ssid_buf[SSID_BUFFER_SIZE];
2277 const char *ssid = this->wifi_ssid_to(ssid_buf);
2278 bssid_t bssid = this->wifi_bssid();
2279 for (auto *listener : this->connect_state_listeners_) {
2280 listener->on_wifi_connect_state(StringRef(ssid, strlen(ssid)), bssid);
2281 }
2282}
2283
2285 constexpr uint8_t empty_bssid[6] = {};
2286 for (auto *listener : this->connect_state_listeners_) {
2287 listener->on_wifi_connect_state(StringRef(), empty_bssid);
2288 }
2289}
2290#endif // USE_WIFI_CONNECT_STATE_LISTENERS
2291
2292#ifdef USE_WIFI_IP_STATE_LISTENERS
2294 for (auto *listener : this->ip_state_listeners_) {
2295 listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
2296 }
2297}
2298#endif // USE_WIFI_IP_STATE_LISTENERS
2299
2300#ifdef USE_WIFI_SCAN_RESULTS_LISTENERS
2302 for (auto *listener : this->scan_results_listeners_) {
2303 listener->on_wifi_scan_results(this->scan_result_);
2304 }
2305}
2306#endif // USE_WIFI_SCAN_RESULTS_LISTENERS
2307
2309 // Guard: not for hidden networks (may not appear in scan)
2310 const WiFiAP *selected = this->get_selected_sta_();
2311 if (selected == nullptr || selected->get_hidden()) {
2312 this->roaming_attempts_ = ROAMING_MAX_ATTEMPTS; // Stop checking forever
2313 return;
2314 }
2315
2316 this->roaming_last_check_ = now;
2317 this->roaming_attempts_++;
2318
2319 // Guard: skip scan if signal is already good (no meaningful improvement possible)
2320 int8_t rssi = this->wifi_rssi();
2321 if (rssi > ROAMING_GOOD_RSSI) {
2322 ESP_LOGV(TAG, "Roam check skipped, signal good (%d dBm, attempt %u/%u)", rssi, this->roaming_attempts_,
2324 return;
2325 }
2326
2327 ESP_LOGD(TAG, "Roam scan (%d dBm, attempt %u/%u)", rssi, this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
2329 this->wifi_scan_start_(this->passive_scan_);
2330}
2331
2333 this->scan_done_ = false;
2334 // Default to IDLE - will be set to CONNECTING if we find a better AP
2336
2337 // Get current connection info
2338 int8_t current_rssi = this->wifi_rssi();
2339 // Guard: must still be connected (RSSI may have become invalid during scan)
2340 if (current_rssi == WIFI_RSSI_DISCONNECTED) {
2341 this->release_scan_results_();
2342 return;
2343 }
2344
2345 char ssid_buf[SSID_BUFFER_SIZE];
2346 StringRef current_ssid(this->wifi_ssid_to(ssid_buf));
2347 bssid_t current_bssid = this->wifi_bssid();
2348
2349 // Find best candidate: same SSID, different BSSID
2350 const WiFiScanResult *best = nullptr;
2351 char bssid_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
2352
2353 for (const auto &result : this->scan_result_) {
2354 // Must be same SSID, different BSSID
2355 if (result.ssid_ != current_ssid || result.get_bssid() == current_bssid)
2356 continue;
2357
2358#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
2359 format_mac_addr_upper(result.get_bssid().data(), bssid_buf);
2360 ESP_LOGV(TAG, "Roam candidate %s %d dBm", bssid_buf, result.get_rssi());
2361#endif
2362
2363 // Track the best candidate
2364 if (best == nullptr || result.get_rssi() > best->get_rssi()) {
2365 best = &result;
2366 }
2367 }
2368
2369 // Check if best candidate meets minimum improvement threshold
2370 const WiFiAP *selected = this->get_selected_sta_();
2371 int8_t improvement = (best == nullptr) ? 0 : best->get_rssi() - current_rssi;
2372 if (selected == nullptr || improvement < ROAMING_MIN_IMPROVEMENT) {
2373 ESP_LOGV(TAG, "Roam best %+d dB (need +%d), attempt %u/%u", improvement, ROAMING_MIN_IMPROVEMENT,
2375 this->release_scan_results_();
2376 return;
2377 }
2378
2379 format_mac_addr_upper(best->get_bssid().data(), bssid_buf);
2380 ESP_LOGI(TAG, "Roaming to %s (%+d dB)", bssid_buf, improvement);
2381
2382 WiFiAP roam_params = *selected;
2383 apply_scan_result_to_params(roam_params, *best);
2384 this->release_scan_results_();
2385
2386 // Mark as roaming attempt - affects retry behavior if connection fails
2388
2389 // Connect directly - wifi_sta_connect_ handles disconnect internally
2390 this->start_connecting(roam_params);
2391}
2392
2393WiFiComponent *global_wifi_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
2394
2395} // namespace esphome::wifi
2396#endif
uint8_t m
Definition bl0906.h:1
uint8_t status
Definition bl0942.h:8
bool is_name_add_mac_suffix_enabled() const
const std::string & get_name() const
Get the name of this Application set by pre_setup().
constexpr uint32_t get_config_version_hash()
Get the config hash extended with ESPHome version.
uint32_t IRAM_ATTR HOT get_loop_component_start_time() const
Get the cached time in milliseconds from when the current component started its loop execution.
void status_set_warning(const char *message=nullptr)
void status_clear_warning()
bool save(const T *src)
Definition preferences.h:21
virtual bool sync()=0
Commit pending writes to flash.
virtual ESPPreferenceObject make_preference(size_t length, uint32_t type, bool in_flash)=0
StringRef is a reference to a string owned by something else.
Definition string_ref.h:26
constexpr const char * c_str() const
Definition string_ref.h:73
constexpr size_type size() const
Definition string_ref.h:74
void trigger(const Ts &...x)
Inform the parent automation that the event has triggered.
Definition automation.h:279
bool has_value() const
Definition optional.h:92
value_type const & value() const
Definition optional.h:94
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
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)
optional< EAPAuth > eap_
optional< ManualIP > manual_ip_
void set_eap(optional< EAPAuth > eap_auth)
void set_password(const std::string &password)
void set_manual_ip(optional< ManualIP > manual_ip)
const optional< ManualIP > & get_manual_ip() const
void set_hidden(bool hidden)
const bssid_t & get_bssid() const
This component is responsible for managing the ESP WiFi interface.
void 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 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)
struct esphome::wifi::WiFiComponent::@175 pending_
wifi_scan_vector_t< WiFiScanResult > scan_result_
WiFiPowerSaveMode configured_power_save_
void set_sta_priority(const 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)
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_passive_scan(bool passive)
void set_power_save_mode(WiFiPowerSaveMode power_save)
int8_t find_next_hidden_sta_(int8_t start_index)
Find next SSID that wasn't in scan results (might be hidden) Returns index of next potentially hidden...
ESPPreferenceObject fast_connect_pref_
void clear_priorities_if_all_min_()
Clear BSSID priority tracking if all priorities are at minimum (saves memory)
WiFiRetryPhase determine_next_phase_()
Determine next retry phase based on current state and failure conditions.
network::IPAddress wifi_dns_ip_(int num)
float get_loop_priority() const override
network::IPAddresses get_ip_addresses()
static constexpr int8_t ROAMING_MIN_IMPROVEMENT
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...
float get_setup_priority() const override
WIFI setup_priority.
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)
bool release_high_performance()
Release a high-performance mode request.
bool wifi_apply_output_power_(float output_power)
const char * get_use_address() const
bool went_through_explicit_hidden_phase_() const
Check if we went through EXPLICIT_HIDDEN phase (first network is marked hidden) Used in RETRY_HIDDEN ...
bool wifi_mode_(optional< bool > sta, optional< bool > ap)
void set_reboot_timeout(uint32_t reboot_timeout)
network::IPAddresses wifi_sta_ip_addresses()
void check_connecting_finished(uint32_t now)
void start_initial_connection_()
Start initial connection - either scan or connect directly to hidden networks.
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 setup() override
Setup WiFi interface.
void clear_all_bssid_priorities_()
Clear all BSSID priority penalties after successful connection (stale after disconnect)
void set_use_address(const char *use_address)
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 @65::@66 __attribute__
uint16_t type
uint8_t priority
CaptivePortal * global_captive_portal
ESP32ImprovComponent * global_improv_component
ImprovSerialComponent * global_improv_serial_component
std::array< IPAddress, 5 > IPAddresses
Definition ip_address.h:188
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.
std::string size_t len
Definition helpers.h:692
size_t size
Definition helpers.h:729
ESPPreferences * global_preferences
void IRAM_ATTR HOT yield()
Definition core.cpp:24
const char * get_mac_address_pretty_into_buffer(std::span< char, MAC_ADDRESS_PRETTY_BUFFER_SIZE > buf)
Get the device MAC address into the given buffer, in colon-separated uppercase hex notation.
Definition helpers.cpp:819
uint32_t IRAM_ATTR HOT millis()
Definition core.cpp:25
Application App
Global storage of Application pointer - only one Application can exist.
char * format_mac_addr_upper(const uint8_t *mac, char *output)
Format MAC address as XX:XX:XX:XX:XX:XX (uppercase, colon separators)
Definition helpers.h:1045
esp_eap_ttls_phase2_types ttls_phase_2
Struct for setting static IPs in WiFiComponent.