diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index b980bab4aa..5f4190a933 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -54,6 +54,10 @@ AUTO_LOAD = ["network"] NO_WIFI_VARIANTS = [const.VARIANT_ESP32H2, const.VARIANT_ESP32P4] CONF_SAVE = "save" +# Maximum number of WiFi networks that can be configured +# Limited to 127 because selected_sta_index_ is int8_t in C++ +MAX_WIFI_NETWORKS = 127 + wifi_ns = cg.esphome_ns.namespace("wifi") EAPAuth = wifi_ns.struct("EAPAuth") ManualIP = wifi_ns.struct("ManualIP") @@ -260,7 +264,9 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(WiFiComponent), - cv.Optional(CONF_NETWORKS): cv.ensure_list(WIFI_NETWORK_STA), + cv.Optional(CONF_NETWORKS): cv.All( + cv.ensure_list(WIFI_NETWORK_STA), cv.Length(max=MAX_WIFI_NETWORKS) + ), cv.Optional(CONF_SSID): cv.ssid, cv.Optional(CONF_PASSWORD): validate_password, cv.Optional(CONF_MANUAL_IP): STA_MANUAL_IP_SCHEMA, diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 51b5756f29..789c22bae1 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1,5 +1,6 @@ #include "wifi_component.h" #ifdef USE_WIFI +#include #include #ifdef USE_ESP32 @@ -109,12 +110,15 @@ void WiFiComponent::start() { } #ifdef USE_WIFI_FAST_CONNECT - this->trying_loaded_ap_ = this->load_fast_connect_settings_(); + WiFiAP params; + this->trying_loaded_ap_ = this->load_fast_connect_settings_(params); if (!this->trying_loaded_ap_) { - this->ap_index_ = 0; - this->selected_ap_ = this->sta_[this->ap_index_]; + // FAST CONNECT FALLBACK: No saved settings available + // Use first config (will use SSID from config) + this->selected_sta_index_ = 0; + params = this->build_wifi_ap_from_selected_(); } - this->start_connecting(this->selected_ap_, false); + this->start_connecting(params, false); #else this->start_scanning(); #endif @@ -169,15 +173,16 @@ void WiFiComponent::loop() { this->status_set_warning(LOG_STR("waiting to reconnect")); if (millis() - this->action_started_ > 5000) { #ifdef USE_WIFI_FAST_CONNECT - // NOTE: This check may not make sense here as it could interfere with AP cycling - if (!this->selected_ap_.get_bssid().has_value()) - this->selected_ap_ = this->sta_[0]; - this->start_connecting(this->selected_ap_, false); + // Safety check: Ensure selected_sta_index_ is valid before retrying + // (should already be set by retry_connect(), but check for robustness) + this->reset_selected_ap_to_first_if_invalid_(); + WiFiAP params = this->build_wifi_ap_from_selected_(); + this->start_connecting(params, false); #else if (this->retry_hidden_) { - if (!this->selected_ap_.get_bssid().has_value()) - this->selected_ap_ = this->sta_[0]; - this->start_connecting(this->selected_ap_, false); + this->reset_selected_ap_to_first_if_invalid_(); + WiFiAP params = this->build_wifi_ap_from_selected_(); + this->start_connecting(params, false); } else { this->start_scanning(); } @@ -336,8 +341,42 @@ void WiFiComponent::set_sta(const WiFiAP &ap) { this->clear_sta(); this->init_sta(1); this->add_sta(ap); + this->selected_sta_index_ = 0; +} + +WiFiAP WiFiComponent::build_wifi_ap_from_selected_() const { + // PRECONDITION: selected_sta_index_ must be valid (ensured by all callers) + const WiFiAP *config = this->get_selected_sta_(); + assert(config != nullptr); + WiFiAP params = *config; + + // SYNCHRONIZATION: selected_sta_index_ and scan_result_[0] are kept in sync after wifi_scan_done(): + // - wifi_scan_done() sorts all scan results by priority/RSSI (best first) + // - It then finds which sta_[i] config matches scan_result_[0] + // - Sets selected_sta_index_ = i to record that matching config + // This sync holds until scan_result_ is cleared (e.g., after connection or in reset_for_next_ap_attempt_()) + if (!this->scan_result_.empty()) { + // Override with scan data - network is visible + const WiFiScanResult &scan = this->scan_result_[0]; + params.set_hidden(false); + params.set_ssid(scan.get_ssid()); + params.set_bssid(scan.get_bssid()); + params.set_channel(scan.get_channel()); + } else if (params.get_hidden()) { + // Hidden network - clear BSSID and channel even if set in config + // There might be multiple hidden networks with same SSID but we can't know which is correct + // Rely on probe-req with just SSID. Empty channel triggers ALL_CHANNEL_SCAN. + params.set_bssid(optional{}); + params.set_channel(optional{}); + } + + return params; +} + +WiFiAP WiFiComponent::get_sta() const { + const WiFiAP *config = this->get_selected_sta_(); + return config ? *config : WiFiAP{}; } -void WiFiComponent::clear_sta() { this->sta_.clear(); } void WiFiComponent::save_wifi_sta(const std::string &ssid, const std::string &password) { SavedWifiSettings save{}; // zero-initialized - all bytes set to \0, guaranteeing null termination strncpy(save.ssid, ssid.c_str(), sizeof(save.ssid) - 1); // max 32 chars, byte 32 remains \0 @@ -485,8 +524,8 @@ void WiFiComponent::print_connect_params_() { LOG_STR_ARG(get_signal_bars(rssi)), get_wifi_channel(), wifi_subnet_mask_().str().c_str(), wifi_gateway_ip_().str().c_str(), wifi_dns_ip_(0).str().c_str(), wifi_dns_ip_(1).str().c_str()); #ifdef ESPHOME_LOG_HAS_VERBOSE - if (this->selected_ap_.get_bssid().has_value()) { - ESP_LOGV(TAG, " Priority: %.1f", this->get_sta_priority(*this->selected_ap_.get_bssid())); + if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_bssid().has_value()) { + ESP_LOGV(TAG, " Priority: %.1f", this->get_sta_priority(*config->get_bssid())); } #endif #ifdef USE_WIFI_11KV_SUPPORT @@ -633,55 +672,38 @@ void WiFiComponent::check_scanning_finished() { log_scan_result(res); } - if (!this->scan_result_[0].get_matches()) { + // SYNCHRONIZATION POINT: Establish link between scan_result_[0] and selected_sta_index_ + // After sorting, scan_result_[0] contains the best network. Now find which sta_[i] config + // matches that network and record it in selected_sta_index_. This keeps the two indices + // synchronized so build_wifi_ap_from_selected_() can safely use both to build connection parameters. + const WiFiScanResult &scan_res = this->scan_result_[0]; + if (!scan_res.get_matches()) { ESP_LOGW(TAG, "No matching network found"); this->retry_connect(); return; } - // Build connection params directly into selected_ap_ to avoid extra copy - const WiFiScanResult &scan_res = this->scan_result_[0]; - WiFiAP &selected = this->selected_ap_; - for (auto &config : this->sta_) { - // search for matching STA config, at least one will match (from checks before) - if (!scan_res.matches(config)) { - continue; + bool found_match = false; + for (size_t i = 0; i < this->sta_.size(); i++) { + if (scan_res.matches(this->sta_[i])) { + // Safe cast: sta_.size() limited to MAX_WIFI_NETWORKS (127) in __init__.py validation + // No overflow check needed - YAML validation prevents >127 networks + this->selected_sta_index_ = static_cast(i); // Links scan_result_[0] with sta_[i] + found_match = true; + break; } + } - if (config.get_hidden()) { - // selected network is hidden, we use the data from the config - selected.set_hidden(true); - selected.set_ssid(config.get_ssid()); - // Clear channel and BSSID for hidden networks - there might be multiple hidden networks - // but we can't know which one is the correct one. Rely on probe-req with just SSID. - selected.set_channel(0); - selected.set_bssid(optional{}); - } else { - // selected network is visible, we use the data from the scan - // limit the connect params to only connect to exactly this network - // (network selection is done during scan phase). - selected.set_hidden(false); - selected.set_ssid(scan_res.get_ssid()); - selected.set_channel(scan_res.get_channel()); - selected.set_bssid(scan_res.get_bssid()); - } - // copy manual IP (if set) - selected.set_manual_ip(config.get_manual_ip()); - -#ifdef USE_WIFI_WPA2_EAP - // copy EAP parameters (if set) - selected.set_eap(config.get_eap()); -#endif - - // copy password (if set) - selected.set_password(config.get_password()); - - break; + if (!found_match) { + ESP_LOGW(TAG, "No matching network found"); + this->retry_connect(); + return; } yield(); - this->start_connecting(this->selected_ap_, false); + WiFiAP params = this->build_wifi_ap_from_selected_(); + this->start_connecting(params, false); } void WiFiComponent::dump_config() { @@ -700,9 +722,12 @@ void WiFiComponent::check_connecting_finished() { } ESP_LOGI(TAG, "Connected"); - // We won't retry hidden networks unless a reconnect fails more than three times again - if (this->retry_hidden_ && !this->selected_ap_.get_hidden()) - ESP_LOGW(TAG, "Network '%s' should be marked as hidden", this->selected_ap_.get_ssid().c_str()); + // Warn if we had to retry with hidden network mode for a network that's not marked hidden + // Only warn if we actually connected without scan data (SSID only), not if scan succeeded on retry + if (const WiFiAP *config = this->get_selected_sta_(); + this->retry_hidden_ && config && !config->get_hidden() && this->scan_result_.empty()) { + ESP_LOGW(TAG, "Network '%s' should be marked as hidden", config->get_ssid().c_str()); + } this->retry_hidden_ = false; this->print_connect_params_(); @@ -725,16 +750,16 @@ void WiFiComponent::check_connecting_finished() { this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTED; this->num_retried_ = 0; +#ifdef USE_WIFI_FAST_CONNECT + this->save_fast_connect_settings_(); +#endif + // Free scan results memory unless a component needs them if (!this->keep_scan_results_) { this->scan_result_.clear(); this->scan_result_.shrink_to_fit(); } -#ifdef USE_WIFI_FAST_CONNECT - this->save_fast_connect_settings_(); -#endif - return; } @@ -772,8 +797,8 @@ void WiFiComponent::check_connecting_finished() { } void WiFiComponent::retry_connect() { - if (this->selected_ap_.get_bssid()) { - auto bssid = *this->selected_ap_.get_bssid(); + if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_bssid()) { + auto bssid = *config->get_bssid(); float priority = this->get_sta_priority(bssid); this->set_sta_priority(bssid, priority - 1.0f); } @@ -782,19 +807,26 @@ void WiFiComponent::retry_connect() { if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_() && (this->num_retried_ > 3 || this->error_from_callback_)) { #ifdef USE_WIFI_FAST_CONNECT + // No empty check needed - YAML validation requires at least one network for fast_connect if (this->trying_loaded_ap_) { this->trying_loaded_ap_ = false; - this->ap_index_ = 0; // Retry from the first configured AP - } else if (this->ap_index_ >= this->sta_.size() - 1) { + this->selected_sta_index_ = 0; // Retry from the first configured AP + this->reset_for_next_ap_attempt_(); + } else if (this->selected_sta_index_ >= static_cast(this->sta_.size()) - 1) { + // Safe cast: sta_.size() limited to MAX_WIFI_NETWORKS (127) in __init__.py validation + // Exhausted all configured APs, restart adapter and cycle back to first + // Restart clears any stuck WiFi driver state + // Each AP is tried with config data only (SSID + optional BSSID/channel if user configured them) + // Typically SSID only, which triggers ESP-IDF internal scanning ESP_LOGW(TAG, "No more APs to try"); - this->ap_index_ = 0; + this->selected_sta_index_ = 0; + this->reset_for_next_ap_attempt_(); this->restart_adapter(); } else { // Try next AP - this->ap_index_++; + this->selected_sta_index_++; + this->reset_for_next_ap_attempt_(); } - this->num_retried_ = 0; - this->selected_ap_ = this->sta_[this->ap_index_]; #else if (this->num_retried_ > 5) { // If retry failed for more than 5 times, let's restart STA @@ -813,7 +845,8 @@ void WiFiComponent::retry_connect() { if (this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTING) { yield(); this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTING_2; - this->start_connecting(this->selected_ap_, true); + WiFiAP params = this->build_wifi_ap_from_selected_(); + this->start_connecting(params, true); return; } @@ -852,16 +885,29 @@ bool WiFiComponent::is_esp32_improv_active_() { } #ifdef USE_WIFI_FAST_CONNECT -bool WiFiComponent::load_fast_connect_settings_() { +bool WiFiComponent::load_fast_connect_settings_(WiFiAP ¶ms) { SavedWifiFastConnectSettings fast_connect_save{}; if (this->fast_connect_pref_.load(&fast_connect_save)) { + // Validate saved AP index + if (fast_connect_save.ap_index < 0 || static_cast(fast_connect_save.ap_index) >= this->sta_.size()) { + ESP_LOGW(TAG, "AP index out of bounds"); + return false; + } + + // Set selected index for future operations (save, retry, etc) + this->selected_sta_index_ = fast_connect_save.ap_index; + + // Copy entire config, then override with fast connect data + params = this->sta_[fast_connect_save.ap_index]; + + // Override with saved BSSID/channel from fast connect (SSID/password/etc already copied from config) bssid_t bssid{}; std::copy(fast_connect_save.bssid, fast_connect_save.bssid + 6, bssid.begin()); - this->ap_index_ = fast_connect_save.ap_index; - this->selected_ap_ = this->sta_[this->ap_index_]; - this->selected_ap_.set_bssid(bssid); - this->selected_ap_.set_channel(fast_connect_save.channel); + params.set_bssid(bssid); + params.set_channel(fast_connect_save.channel); + // Fast connect uses specific BSSID+channel, not hidden network probe (even if config has hidden: true) + params.set_hidden(false); ESP_LOGD(TAG, "Loaded fast_connect settings"); return true; @@ -873,18 +919,25 @@ bool WiFiComponent::load_fast_connect_settings_() { void WiFiComponent::save_fast_connect_settings_() { bssid_t bssid = wifi_bssid(); uint8_t channel = get_wifi_channel(); + // selected_sta_index_ is always valid here (called only after successful connection) + // Fallback to 0 is defensive programming for robustness + int8_t ap_index = this->selected_sta_index_ >= 0 ? this->selected_sta_index_ : 0; - if (bssid != this->selected_ap_.get_bssid() || channel != this->selected_ap_.get_channel()) { - SavedWifiFastConnectSettings fast_connect_save{}; - - memcpy(fast_connect_save.bssid, bssid.data(), 6); - fast_connect_save.channel = channel; - fast_connect_save.ap_index = this->ap_index_; - - this->fast_connect_pref_.save(&fast_connect_save); - - ESP_LOGD(TAG, "Saved fast_connect settings"); + // Skip save if settings haven't changed (compare with previously saved settings to reduce flash wear) + SavedWifiFastConnectSettings previous_save{}; + if (this->fast_connect_pref_.load(&previous_save) && memcmp(previous_save.bssid, bssid.data(), 6) == 0 && + previous_save.channel == channel && previous_save.ap_index == ap_index) { + return; // No change, nothing to save } + + SavedWifiFastConnectSettings fast_connect_save{}; + memcpy(fast_connect_save.bssid, bssid.data(), 6); + fast_connect_save.channel = channel; + fast_connect_save.ap_index = ap_index; + + this->fast_connect_pref_.save(&fast_connect_save); + + ESP_LOGD(TAG, "Saved fast_connect settings"); } #endif diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index ac63e0eb0c..228894076e 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -218,10 +218,14 @@ class WiFiComponent : public Component { WiFiComponent(); void set_sta(const WiFiAP &ap); - WiFiAP get_sta() { return this->selected_ap_; } + // Returns a copy of the currently selected AP configuration + WiFiAP get_sta() const; void init_sta(size_t count); void add_sta(const WiFiAP &ap); - void clear_sta(); + void clear_sta() { + this->sta_.clear(); + this->selected_sta_index_ = -1; + } #ifdef USE_WIFI_AP /** Setup an Access Point that should be created if no connection to a station can be made. @@ -337,6 +341,29 @@ class WiFiComponent : public Component { #endif // USE_WIFI_AP void print_connect_params_(); + WiFiAP build_wifi_ap_from_selected_() const; + + const WiFiAP *get_selected_sta_() const { + if (this->selected_sta_index_ >= 0 && static_cast(this->selected_sta_index_) < this->sta_.size()) { + return &this->sta_[this->selected_sta_index_]; + } + return nullptr; + } + + void reset_selected_ap_to_first_if_invalid_() { + if (this->selected_sta_index_ < 0 || static_cast(this->selected_sta_index_) >= this->sta_.size()) { + this->selected_sta_index_ = this->sta_.empty() ? -1 : 0; + } + } + +#ifdef USE_WIFI_FAST_CONNECT + // Reset state for next fast connect AP attempt + // Clears old scan data so the new AP is tried with config only (SSID without specific BSSID/channel) + void reset_for_next_ap_attempt_() { + this->num_retried_ = 0; + this->scan_result_.clear(); + } +#endif void wifi_loop_(); bool wifi_mode_(optional sta, optional ap); @@ -365,7 +392,7 @@ class WiFiComponent : public Component { bool is_esp32_improv_active_(); #ifdef USE_WIFI_FAST_CONNECT - bool load_fast_connect_settings_(); + bool load_fast_connect_settings_(WiFiAP ¶ms); void save_fast_connect_settings_(); #endif @@ -396,7 +423,6 @@ class WiFiComponent : public Component { FixedVector sta_; std::vector sta_priorities_; wifi_scan_vector_t scan_result_; - WiFiAP selected_ap_; WiFiAP ap_; optional output_power_; ESPPreferenceObject pref_; @@ -414,9 +440,10 @@ class WiFiComponent : public Component { WiFiComponentState state_{WIFI_COMPONENT_STATE_OFF}; WiFiPowerSaveMode power_save_{WIFI_POWER_SAVE_NONE}; uint8_t num_retried_{0}; -#ifdef USE_WIFI_FAST_CONNECT - uint8_t ap_index_{0}; -#endif + // Index into sta_ array for the currently selected AP configuration (-1 = none selected) + // Used to access password, manual_ip, priority, EAP settings, and hidden flag + // int8_t limits to 127 APs (enforced in __init__.py via MAX_WIFI_NETWORKS) + int8_t selected_sta_index_{-1}; #if USE_NETWORK_IPV6 uint8_t num_ipv6_addresses_{0}; #endif /* USE_NETWORK_IPV6 */