From b0560894b78c9650e5da58fe3df1ae2ef61f7267 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Nov 2025 19:10:10 -0600 Subject: [PATCH 01/22] [wifi] Fix captive portal unusable when WiFi credentials are wrong --- esphome/components/captive_portal/__init__.py | 5 +++++ esphome/components/wifi/wifi_component.cpp | 19 +++++++++++++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index 9bd3ef8a05..f9bf93bee8 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -72,6 +72,11 @@ def _final_validate(config: ConfigType) -> ConfigType: "Add 'ap:' to your WiFi configuration to enable the captive portal." ) + # Register socket needs for DNS server (1 UDP socket) + from esphome.components import socket + + socket.consume_sockets(1, "captive_portal")(config) + return config diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 51a5a47323..d33d80c364 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -199,7 +199,11 @@ static constexpr uint8_t WIFI_RETRY_COUNT_PER_AP = 1; /// Cooldown duration in milliseconds after adapter restart or repeated failures /// Allows WiFi hardware to stabilize before next connection attempt -static constexpr uint32_t WIFI_COOLDOWN_DURATION_MS = 1000; +static constexpr uint32_t WIFI_COOLDOWN_DURATION_MS = 500; + +/// Cooldown duration when fallback AP is active and captive portal may be running +/// Longer interval prevents scanning from disrupting AP connections and blocking captive portal +static constexpr uint32_t WIFI_COOLDOWN_WITH_AP_ACTIVE_MS = 30000; static constexpr uint8_t get_max_retries_for_phase(WiFiRetryPhase phase) { switch (phase) { @@ -417,10 +421,6 @@ void WiFiComponent::start() { void WiFiComponent::restart_adapter() { ESP_LOGW(TAG, "Restarting adapter"); this->wifi_mode_(false, {}); - // Enter cooldown state to allow WiFi hardware to stabilize after restart - // Don't set retry_phase_ or num_retried_ here - state machine handles transitions - this->state_ = WIFI_COMPONENT_STATE_COOLDOWN; - this->action_started_ = millis(); this->error_from_callback_ = false; } @@ -441,7 +441,10 @@ void WiFiComponent::loop() { switch (this->state_) { case WIFI_COMPONENT_STATE_COOLDOWN: { this->status_set_warning(LOG_STR("waiting to reconnect")); - if (now - this->action_started_ > WIFI_COOLDOWN_DURATION_MS) { + // Use longer cooldown when captive portal/improv is active to avoid disrupting user config + bool portal_active = this->is_captive_portal_active_() || this->is_esp32_improv_active_(); + uint32_t cooldown_duration = portal_active ? WIFI_COOLDOWN_WITH_AP_ACTIVE_MS : WIFI_COOLDOWN_DURATION_MS; + if (now - this->action_started_ > cooldown_duration) { // After cooldown we either restarted the adapter because of // a failure, or something tried to connect over and over // so we entered cooldown. In both cases we call @@ -1319,6 +1322,10 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_()) { this->restart_adapter(); } + // Always enter cooldown after restart (or skip-restart) to allow stabilization + // Use extended cooldown when AP is active to avoid constant scanning that blocks DNS + this->state_ = WIFI_COMPONENT_STATE_COOLDOWN; + this->action_started_ = millis(); // Return true to indicate we should wait (go to COOLDOWN) instead of immediately connecting return true; From 15be275541ea525b6f2590f1951d5670206dc097 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Nov 2025 21:03:35 -0600 Subject: [PATCH 02/22] tweak --- esphome/components/captive_portal/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index f9bf93bee8..c730624b49 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -72,10 +72,14 @@ def _final_validate(config: ConfigType) -> ConfigType: "Add 'ap:' to your WiFi configuration to enable the captive portal." ) - # Register socket needs for DNS server (1 UDP socket) + # Register socket needs for DNS server and additional HTTP connections + # - 1 UDP socket for DNS server + # - 2 additional TCP sockets for captive portal detection probes + configuration requests + # (OS captive portal detection makes multiple probe requests that stay in TIME_WAIT, + # need headroom for actual user configuration requests) from esphome.components import socket - socket.consume_sockets(1, "captive_portal")(config) + socket.consume_sockets(3, "captive_portal")(config) return config From 27a068e8b5b825255b293d43d068d4b8ddf17a23 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Nov 2025 21:44:18 -0600 Subject: [PATCH 03/22] reduce --- esphome/components/captive_portal/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index c730624b49..5f7ae32570 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -74,12 +74,12 @@ def _final_validate(config: ConfigType) -> ConfigType: # Register socket needs for DNS server and additional HTTP connections # - 1 UDP socket for DNS server - # - 2 additional TCP sockets for captive portal detection probes + configuration requests + # - 3 additional TCP sockets for captive portal detection probes + configuration requests # (OS captive portal detection makes multiple probe requests that stay in TIME_WAIT, # need headroom for actual user configuration requests) from esphome.components import socket - socket.consume_sockets(3, "captive_portal")(config) + socket.consume_sockets(4, "captive_portal")(config) return config From 7f4205b82ca566dfc670062682556c584a490306 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Nov 2025 21:52:12 -0600 Subject: [PATCH 04/22] reduce --- esphome/components/captive_portal/captive_portal.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 30438747f2..e9ca7a8e14 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -52,7 +52,13 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { ESP_LOGI(TAG, " Password=" LOG_SECRET("'%s'"), psk.c_str()); wifi::global_wifi_component->save_wifi_sta(ssid, psk); wifi::global_wifi_component->start_scanning(); - request->redirect(ESPHOME_F("/?save")); + + // Add Connection: close header to ensure socket is released immediately + // Without this, sockets can stay in TIME_WAIT and exhaust the socket pool + auto *response = request->beginResponse(302, ESPHOME_F("text/plain"), ESPHOME_F("")); + response->addHeader(ESPHOME_F("Location"), ESPHOME_F("/?save")); + response->addHeader(ESPHOME_F("Connection"), ESPHOME_F("close")); + request->send(response); } void CaptivePortal::setup() { From 11c8865248506e6c0b2ab55920a6cfd426ac0d85 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Nov 2025 21:57:21 -0600 Subject: [PATCH 05/22] fixes --- esphome/components/captive_portal/captive_portal.cpp | 8 +------- esphome/components/web_server_idf/web_server_idf.cpp | 1 + 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index e9ca7a8e14..30438747f2 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -52,13 +52,7 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { ESP_LOGI(TAG, " Password=" LOG_SECRET("'%s'"), psk.c_str()); wifi::global_wifi_component->save_wifi_sta(ssid, psk); wifi::global_wifi_component->start_scanning(); - - // Add Connection: close header to ensure socket is released immediately - // Without this, sockets can stay in TIME_WAIT and exhaust the socket pool - auto *response = request->beginResponse(302, ESPHOME_F("text/plain"), ESPHOME_F("")); - response->addHeader(ESPHOME_F("Location"), ESPHOME_F("/?save")); - response->addHeader(ESPHOME_F("Connection"), ESPHOME_F("close")); - request->send(response); + request->redirect(ESPHOME_F("/?save")); } void CaptivePortal::setup() { diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index ce91569de2..b99e4aa402 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -242,6 +242,7 @@ void AsyncWebServerRequest::send(int code, const char *content_type, const char void AsyncWebServerRequest::redirect(const std::string &url) { httpd_resp_set_status(*this, "302 Found"); httpd_resp_set_hdr(*this, "Location", url.c_str()); + httpd_resp_set_hdr(*this, "Connection", "close"); httpd_resp_send(*this, nullptr, 0); } From bbfff42f765f9bc7f43ec1f66f4cc6ca2a2ad832 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Nov 2025 22:11:27 -0600 Subject: [PATCH 06/22] fixes --- esphome/components/web_server_idf/web_server_idf.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index b99e4aa402..08528f12ec 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -101,6 +101,9 @@ void AsyncWebServer::begin() { httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.server_port = this->port_; config.uri_match_fn = [](const char * /*unused*/, const char * /*unused*/, size_t /*unused*/) { return true; }; + // Enable LRU purging to close oldest idle connections when socket limit is reached + // This prevents socket exhaustion when multiple clients connect (e.g., captive portal probes) + config.lru_purge_enable = true; if (httpd_start(&this->server_, &config) == ESP_OK) { const httpd_uri_t handler_get = { .uri = "", From a81f28a73b299f674e256cb33a730b01356f85ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Nov 2025 22:14:56 -0600 Subject: [PATCH 07/22] fixes --- .../captive_portal/captive_portal.cpp | 6 ++++++ .../components/captive_portal/captive_portal.h | 4 ++++ .../web_server_idf/web_server_idf.cpp | 17 ++++++++++++++--- .../components/web_server_idf/web_server_idf.h | 4 ++++ 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 30438747f2..c2d3f63837 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -63,6 +63,12 @@ void CaptivePortal::start() { this->base_->init(); if (!this->initialized_) { this->base_->add_handler(this); +#ifdef USE_ESP_IDF + // Enable LRU socket purging to handle captive portal detection probe bursts + // OS captive portal detection makes many simultaneous HTTP requests which can + // exhaust sockets. LRU purging automatically closes oldest idle connections. + this->base_->get_server()->set_lru_purge_enable(true); +#endif } network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); diff --git a/esphome/components/captive_portal/captive_portal.h b/esphome/components/captive_portal/captive_portal.h index f48c286f0c..e98a2b6511 100644 --- a/esphome/components/captive_portal/captive_portal.h +++ b/esphome/components/captive_portal/captive_portal.h @@ -40,6 +40,10 @@ class CaptivePortal : public AsyncWebHandler, public Component { void end() { this->active_ = false; this->disable_loop(); // Stop processing DNS requests +#ifdef USE_ESP_IDF + // Disable LRU socket purging now that captive portal is done + this->base_->get_server()->set_lru_purge_enable(false); +#endif this->base_->deinit(); if (this->dns_server_ != nullptr) { this->dns_server_->stop(); diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 08528f12ec..f5a66f6bd9 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -94,6 +94,18 @@ void AsyncWebServer::end() { } } +void AsyncWebServer::set_lru_purge_enable(bool enable) { + if (this->lru_purge_enable_ == enable) { + return; // No change needed + } + this->lru_purge_enable_ = enable; + // If server is already running, restart it with new config + if (this->server_) { + this->end(); + this->begin(); + } +} + void AsyncWebServer::begin() { if (this->server_) { this->end(); @@ -101,9 +113,8 @@ void AsyncWebServer::begin() { httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.server_port = this->port_; config.uri_match_fn = [](const char * /*unused*/, const char * /*unused*/, size_t /*unused*/) { return true; }; - // Enable LRU purging to close oldest idle connections when socket limit is reached - // This prevents socket exhaustion when multiple clients connect (e.g., captive portal probes) - config.lru_purge_enable = true; + // Enable LRU purging if requested (e.g., by captive portal to handle probe bursts) + config.lru_purge_enable = this->lru_purge_enable_; if (httpd_start(&this->server_, &config) == ESP_OK) { const httpd_uri_t handler_get = { .uri = "", diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 5ec6fec009..b9f690b462 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -199,9 +199,13 @@ class AsyncWebServer { return *handler; } + void set_lru_purge_enable(bool enable); + httpd_handle_t get_server() { return this->server_; } + protected: uint16_t port_{}; httpd_handle_t server_{}; + bool lru_purge_enable_{false}; static esp_err_t request_handler(httpd_req_t *r); static esp_err_t request_post_handler(httpd_req_t *r); esp_err_t request_handler_(AsyncWebServerRequest *request) const; From 6f96804a5dd8a1154f4bd2c74ce6b952469f53cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Nov 2025 22:31:22 -0600 Subject: [PATCH 08/22] cleanup --- esphome/components/captive_portal/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index 5f7ae32570..2c788c4b39 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -74,9 +74,9 @@ def _final_validate(config: ConfigType) -> ConfigType: # Register socket needs for DNS server and additional HTTP connections # - 1 UDP socket for DNS server - # - 3 additional TCP sockets for captive portal detection probes + configuration requests - # (OS captive portal detection makes multiple probe requests that stay in TIME_WAIT, - # need headroom for actual user configuration requests) + # - 3 additional TCP sockets for captive portal HTTP connections + # OS captive portal detection makes multiple simultaneous probe requests. + # LRU purging will reclaim idle sockets, but we need enough for the initial burst. from esphome.components import socket socket.consume_sockets(4, "captive_portal")(config) From f0bae783cfe2e69d9151fc1e65611338b53bc3cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Nov 2025 22:31:53 -0600 Subject: [PATCH 09/22] cleanup --- esphome/components/captive_portal/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index 2c788c4b39..25d0a22083 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -74,9 +74,10 @@ def _final_validate(config: ConfigType) -> ConfigType: # Register socket needs for DNS server and additional HTTP connections # - 1 UDP socket for DNS server - # - 3 additional TCP sockets for captive portal HTTP connections - # OS captive portal detection makes multiple simultaneous probe requests. - # LRU purging will reclaim idle sockets, but we need enough for the initial burst. + # - 3 additional TCP sockets for captive portal detection probes + configuration requests + # OS captive portal detection makes multiple probe requests that stay in TIME_WAIT. + # Need headroom for actual user configuration requests. + # LRU purging will reclaim idle sockets to prevent exhaustion from repeated attempts. from esphome.components import socket socket.consume_sockets(4, "captive_portal")(config) From 87ccb777c6a2052e4a311f9a93e673626275a9ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Nov 2025 22:32:57 -0600 Subject: [PATCH 10/22] esp32 --- esphome/components/captive_portal/captive_portal.cpp | 2 +- esphome/components/captive_portal/captive_portal.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index c2d3f63837..0ad06d49ad 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -63,7 +63,7 @@ void CaptivePortal::start() { this->base_->init(); if (!this->initialized_) { this->base_->add_handler(this); -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 // Enable LRU socket purging to handle captive portal detection probe bursts // OS captive portal detection makes many simultaneous HTTP requests which can // exhaust sockets. LRU purging automatically closes oldest idle connections. diff --git a/esphome/components/captive_portal/captive_portal.h b/esphome/components/captive_portal/captive_portal.h index e98a2b6511..ae9b9dfba0 100644 --- a/esphome/components/captive_portal/captive_portal.h +++ b/esphome/components/captive_portal/captive_portal.h @@ -40,7 +40,7 @@ class CaptivePortal : public AsyncWebHandler, public Component { void end() { this->active_ = false; this->disable_loop(); // Stop processing DNS requests -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 // Disable LRU socket purging now that captive portal is done this->base_->get_server()->set_lru_purge_enable(false); #endif From bfe6fc0dd0fe69004cacc7e3722e76fa87ec299f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Nov 2025 23:00:22 -0600 Subject: [PATCH 11/22] skip scan when ap mode --- esphome/components/wifi/wifi_component.cpp | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index d33d80c364..a31f10f8cd 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1232,9 +1232,16 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() { return WiFiRetryPhase::RESTARTING_ADAPTER; case WiFiRetryPhase::RESTARTING_ADAPTER: - // After restart, go back to explicit hidden if we went through it initially, otherwise scan - return this->went_through_explicit_hidden_phase_() ? WiFiRetryPhase::EXPLICIT_HIDDEN - : WiFiRetryPhase::SCAN_CONNECTING; + // After restart, go back to explicit hidden if we went through it initially + if (this->went_through_explicit_hidden_phase_()) { + return WiFiRetryPhase::EXPLICIT_HIDDEN; + } + // Skip scanning when captive portal/improv is active to avoid disrupting AP + // Even passive scans can cause brief AP disconnections on ESP32 + if (this->is_captive_portal_active_() || this->is_esp32_improv_active_()) { + return WiFiRetryPhase::RETRY_HIDDEN; + } + return WiFiRetryPhase::SCAN_CONNECTING; } // Should never reach here From 1815a7cf90c7b6570dd11812b4e18e452a5e8511 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Nov 2025 23:03:38 -0600 Subject: [PATCH 12/22] skip scan when ap mode --- esphome/components/wifi/wifi_component.cpp | 10 +++++++++- esphome/components/wifi/wifi_component.h | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index a31f10f8cd..f9043058fd 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -669,6 +669,10 @@ void WiFiComponent::save_wifi_sta(const std::string &ssid, const std::string &pa sta.set_ssid(ssid); sta.set_password(password); this->set_sta(sta); + + // Force scan on next attempt even if captive portal is still active + // This ensures new credentials are tried with proper BSSID selection after provisioning + this->force_scan_after_provision_ = true; } void WiFiComponent::start_connecting(const WiFiAP &ap) { @@ -867,6 +871,8 @@ void WiFiComponent::start_scanning() { ESP_LOGD(TAG, "Starting scan"); this->wifi_scan_start_(this->passive_scan_); this->state_ = WIFI_COMPONENT_STATE_STA_SCANNING; + // Clear the force scan flag after starting the scan + this->force_scan_after_provision_ = false; } /// Comparator for WiFi scan result sorting - determines which network should be tried first @@ -1238,7 +1244,9 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() { } // Skip scanning when captive portal/improv is active to avoid disrupting AP // Even passive scans can cause brief AP disconnections on ESP32 - if (this->is_captive_portal_active_() || this->is_esp32_improv_active_()) { + // UNLESS new credentials were just provisioned - then we need to scan + if ((this->is_captive_portal_active_() || this->is_esp32_improv_active_()) && + !this->force_scan_after_provision_) { return WiFiRetryPhase::RETRY_HIDDEN; } return WiFiRetryPhase::SCAN_CONNECTING; diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 2fd7fa6cd4..1430794112 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -529,6 +529,7 @@ class WiFiComponent : public Component { bool enable_on_boot_; bool got_ipv4_address_{false}; bool keep_scan_results_{false}; + bool force_scan_after_provision_{false}; // Pointers at the end (naturally aligned) Trigger<> *connect_trigger_{new Trigger<>()}; From 3d83975d460878a1425b432110702109bc77f03a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Nov 2025 23:11:23 -0600 Subject: [PATCH 13/22] fix thread safety issue --- esphome/components/captive_portal/captive_portal.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 0ad06d49ad..2c0ef86fa9 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -51,7 +51,8 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { ESP_LOGI(TAG, " SSID='%s'", ssid.c_str()); ESP_LOGI(TAG, " Password=" LOG_SECRET("'%s'"), psk.c_str()); wifi::global_wifi_component->save_wifi_sta(ssid, psk); - wifi::global_wifi_component->start_scanning(); + // Don't call start_scanning() from HTTP thread - WiFi operations must happen on main loop + // The force_scan_after_provision_ flag will trigger scan on next retry cycle request->redirect(ESPHOME_F("/?save")); } From 8d7090fcd6e0f0bcaa473497275583088a283bb8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Nov 2025 23:11:55 -0600 Subject: [PATCH 14/22] fix thread safety issue --- esphome/components/wifi/wifi_component.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index f9043058fd..981aa0b74f 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -202,8 +202,8 @@ static constexpr uint8_t WIFI_RETRY_COUNT_PER_AP = 1; static constexpr uint32_t WIFI_COOLDOWN_DURATION_MS = 500; /// Cooldown duration when fallback AP is active and captive portal may be running -/// Longer interval prevents scanning from disrupting AP connections and blocking captive portal -static constexpr uint32_t WIFI_COOLDOWN_WITH_AP_ACTIVE_MS = 30000; +/// Longer interval gives users time to configure WiFi without constant connection attempts +static constexpr uint32_t WIFI_COOLDOWN_WITH_AP_ACTIVE_MS = 5000; static constexpr uint8_t get_max_retries_for_phase(WiFiRetryPhase phase) { switch (phase) { From 2ee5cc6f2243b1a8f59dccbe3131bfacfecb3a75 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Nov 2025 23:17:36 -0600 Subject: [PATCH 15/22] anotehr thread safety issue --- esphome/components/captive_portal/captive_portal.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 2c0ef86fa9..459ac557c8 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -50,9 +50,8 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { ESP_LOGI(TAG, "Requested WiFi Settings Change:"); ESP_LOGI(TAG, " SSID='%s'", ssid.c_str()); ESP_LOGI(TAG, " Password=" LOG_SECRET("'%s'"), psk.c_str()); - wifi::global_wifi_component->save_wifi_sta(ssid, psk); - // Don't call start_scanning() from HTTP thread - WiFi operations must happen on main loop - // The force_scan_after_provision_ flag will trigger scan on next retry cycle + // Defer save to main loop thread to avoid NVS operations from HTTP thread + this->defer([ssid, psk]() { wifi::global_wifi_component->save_wifi_sta(ssid, psk); }); request->redirect(ESPHOME_F("/?save")); } From 3f799a01a25711eab9d12afc593d6241da9a6eea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Nov 2025 23:23:49 -0600 Subject: [PATCH 16/22] anotehr thread safety issue --- esphome/components/wifi/wifi_component.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 981aa0b74f..fa2ca92e55 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -203,7 +203,8 @@ static constexpr uint32_t WIFI_COOLDOWN_DURATION_MS = 500; /// Cooldown duration when fallback AP is active and captive portal may be running /// Longer interval gives users time to configure WiFi without constant connection attempts -static constexpr uint32_t WIFI_COOLDOWN_WITH_AP_ACTIVE_MS = 5000; +/// While connecting, WiFi can't beacon the AP properly, so needs longer cooldown +static constexpr uint32_t WIFI_COOLDOWN_WITH_AP_ACTIVE_MS = 30000; static constexpr uint8_t get_max_retries_for_phase(WiFiRetryPhase phase) { switch (phase) { From b9af06f5a42742a65ce707ab85a107f2e67cd2d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Nov 2025 23:35:26 -0600 Subject: [PATCH 17/22] no delay --- esphome/components/captive_portal/captive_portal.cpp | 6 +++++- esphome/components/esp32_improv/esp32_improv_component.cpp | 2 ++ .../components/improv_serial/improv_serial_component.cpp | 2 ++ esphome/components/wifi/wifi_component.h | 1 + 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 459ac557c8..a377abe380 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -51,7 +51,11 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { ESP_LOGI(TAG, " SSID='%s'", ssid.c_str()); ESP_LOGI(TAG, " Password=" LOG_SECRET("'%s'"), psk.c_str()); // Defer save to main loop thread to avoid NVS operations from HTTP thread - this->defer([ssid, psk]() { wifi::global_wifi_component->save_wifi_sta(ssid, psk); }); + this->defer([ssid, psk]() { + wifi::global_wifi_component->save_wifi_sta(ssid, psk); + // Trigger immediate retry to attempt connection with new credentials + wifi::global_wifi_component->retry_connect(); + }); request->redirect(ESPHOME_F("/?save")); } diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index 398b1d4251..f4c19b04d4 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -381,6 +381,8 @@ void ESP32ImprovComponent::check_wifi_connection_() { if (this->state_ == improv::STATE_PROVISIONING) { wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(), this->connecting_sta_.get_password()); + // Trigger immediate retry to attempt connection with new credentials + wifi::global_wifi_component->retry_connect(); this->connecting_sta_ = {}; this->cancel_timeout("wifi-connect-timeout"); diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 70260eeab3..52e223670d 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -50,6 +50,8 @@ void ImprovSerialComponent::loop() { if (wifi::global_wifi_component->is_connected()) { wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(), this->connecting_sta_.get_password()); + // Trigger immediate retry to attempt connection with new credentials + wifi::global_wifi_component->retry_connect(); this->connecting_sta_ = {}; this->cancel_timeout("wifi-connect-timeout"); this->set_state_(improv::STATE_PROVISIONED); diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 1430794112..270404dff5 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -291,6 +291,7 @@ class WiFiComponent : public Component { void set_passive_scan(bool passive); void save_wifi_sta(const std::string &ssid, const std::string &password); + // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) /// Setup WiFi interface. From 29ef0a67405395522af039f84c3d87ed045b587c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Nov 2025 23:43:33 -0600 Subject: [PATCH 18/22] no delay --- esphome/components/captive_portal/captive_portal.cpp | 4 ++-- .../components/esp32_improv/esp32_improv_component.cpp | 4 ++-- .../components/improv_serial/improv_serial_component.cpp | 4 ++-- esphome/components/wifi/wifi_component.cpp | 8 ++++++++ esphome/components/wifi/wifi_component.h | 3 +++ 5 files changed, 17 insertions(+), 6 deletions(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index a377abe380..b70fa4d0ca 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -53,8 +53,8 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { // Defer save to main loop thread to avoid NVS operations from HTTP thread this->defer([ssid, psk]() { wifi::global_wifi_component->save_wifi_sta(ssid, psk); - // Trigger immediate retry to attempt connection with new credentials - wifi::global_wifi_component->retry_connect(); + // Trigger connection attempt (exits cooldown if needed) + wifi::global_wifi_component->connect_soon(); }); request->redirect(ESPHOME_F("/?save")); } diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index f4c19b04d4..c456ad7d33 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -381,8 +381,8 @@ void ESP32ImprovComponent::check_wifi_connection_() { if (this->state_ == improv::STATE_PROVISIONING) { wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(), this->connecting_sta_.get_password()); - // Trigger immediate retry to attempt connection with new credentials - wifi::global_wifi_component->retry_connect(); + // Trigger connection attempt (exits cooldown if needed, no-op if already connected) + wifi::global_wifi_component->connect_soon(); this->connecting_sta_ = {}; this->cancel_timeout("wifi-connect-timeout"); diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 52e223670d..51f8c8b839 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -50,8 +50,8 @@ void ImprovSerialComponent::loop() { if (wifi::global_wifi_component->is_connected()) { wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(), this->connecting_sta_.get_password()); - // Trigger immediate retry to attempt connection with new credentials - wifi::global_wifi_component->retry_connect(); + // Trigger connection attempt (exits cooldown if needed, no-op if already connected) + wifi::global_wifi_component->connect_soon(); this->connecting_sta_ = {}; this->cancel_timeout("wifi-connect-timeout"); this->set_state_(improv::STATE_PROVISIONED); diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index fa2ca92e55..401c60267e 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -676,6 +676,14 @@ void WiFiComponent::save_wifi_sta(const std::string &ssid, const std::string &pa this->force_scan_after_provision_ = true; } +void WiFiComponent::connect_soon() { + // Only trigger retry if we're in cooldown - if already connecting/connected, do nothing + if (this->state_ == WIFI_COMPONENT_STATE_COOLDOWN) { + ESP_LOGD(TAG, "Exiting cooldown early due to new WiFi credentials"); + this->retry_connect(); + } +} + void WiFiComponent::start_connecting(const WiFiAP &ap) { // Log connection attempt at INFO level with priority char bssid_s[18]; diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 270404dff5..c014fc6343 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -292,6 +292,9 @@ class WiFiComponent : public Component { void save_wifi_sta(const std::string &ssid, const std::string &password); + /// Trigger connection attempt soon (exits cooldown if needed, otherwise no-op if already connecting/connected) + void connect_soon(); + // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) /// Setup WiFi interface. From efbf696f8874bd09ea782d6fcd5832fe0fb3e42c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Nov 2025 23:45:25 -0600 Subject: [PATCH 19/22] no delay --- esphome/components/captive_portal/captive_portal.cpp | 6 +----- esphome/components/esp32_improv/esp32_improv_component.cpp | 2 -- .../components/improv_serial/improv_serial_component.cpp | 2 -- esphome/components/wifi/wifi_component.cpp | 3 +++ 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index b70fa4d0ca..459ac557c8 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -51,11 +51,7 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { ESP_LOGI(TAG, " SSID='%s'", ssid.c_str()); ESP_LOGI(TAG, " Password=" LOG_SECRET("'%s'"), psk.c_str()); // Defer save to main loop thread to avoid NVS operations from HTTP thread - this->defer([ssid, psk]() { - wifi::global_wifi_component->save_wifi_sta(ssid, psk); - // Trigger connection attempt (exits cooldown if needed) - wifi::global_wifi_component->connect_soon(); - }); + this->defer([ssid, psk]() { wifi::global_wifi_component->save_wifi_sta(ssid, psk); }); request->redirect(ESPHOME_F("/?save")); } diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index c456ad7d33..398b1d4251 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -381,8 +381,6 @@ void ESP32ImprovComponent::check_wifi_connection_() { if (this->state_ == improv::STATE_PROVISIONING) { wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(), this->connecting_sta_.get_password()); - // Trigger connection attempt (exits cooldown if needed, no-op if already connected) - wifi::global_wifi_component->connect_soon(); this->connecting_sta_ = {}; this->cancel_timeout("wifi-connect-timeout"); diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 51f8c8b839..70260eeab3 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -50,8 +50,6 @@ void ImprovSerialComponent::loop() { if (wifi::global_wifi_component->is_connected()) { wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(), this->connecting_sta_.get_password()); - // Trigger connection attempt (exits cooldown if needed, no-op if already connected) - wifi::global_wifi_component->connect_soon(); this->connecting_sta_ = {}; this->cancel_timeout("wifi-connect-timeout"); this->set_state_(improv::STATE_PROVISIONED); diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 401c60267e..5adc0c0489 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -674,6 +674,9 @@ void WiFiComponent::save_wifi_sta(const std::string &ssid, const std::string &pa // Force scan on next attempt even if captive portal is still active // This ensures new credentials are tried with proper BSSID selection after provisioning this->force_scan_after_provision_ = true; + + // Trigger connection attempt (exits cooldown if needed, no-op if already connecting/connected) + this->connect_soon(); } void WiFiComponent::connect_soon() { From 3f763b24c55ec740f71bd569349f5ba00f74d499 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Nov 2025 23:45:32 -0600 Subject: [PATCH 20/22] no delay --- esphome/components/wifi/wifi_component.h | 3 --- 1 file changed, 3 deletions(-) diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index c014fc6343..270404dff5 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -292,9 +292,6 @@ class WiFiComponent : public Component { void save_wifi_sta(const std::string &ssid, const std::string &password); - /// Trigger connection attempt soon (exits cooldown if needed, otherwise no-op if already connecting/connected) - void connect_soon(); - // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) /// Setup WiFi interface. From 048533a1fda385c5d52a52db9af9931e9c78977c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 17 Nov 2025 23:45:59 -0600 Subject: [PATCH 21/22] no delay --- esphome/components/wifi/wifi_component.cpp | 4 ++-- esphome/components/wifi/wifi_component.h | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 5adc0c0489..c7c87c2343 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -676,10 +676,10 @@ void WiFiComponent::save_wifi_sta(const std::string &ssid, const std::string &pa this->force_scan_after_provision_ = true; // Trigger connection attempt (exits cooldown if needed, no-op if already connecting/connected) - this->connect_soon(); + this->connect_soon_(); } -void WiFiComponent::connect_soon() { +void WiFiComponent::connect_soon_() { // Only trigger retry if we're in cooldown - if already connecting/connected, do nothing if (this->state_ == WIFI_COMPONENT_STATE_COOLDOWN) { ESP_LOGD(TAG, "Exiting cooldown early due to new WiFi credentials"); diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 270404dff5..f5d21af99d 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -425,6 +425,8 @@ class WiFiComponent : public Component { return true; } + void connect_soon_(); + void wifi_loop_(); bool wifi_mode_(optional sta, optional ap); bool wifi_sta_pre_setup_(); From 66fcf364a6b661bf1e3eddc9595fcf0d4689f179 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Nov 2025 00:09:51 -0600 Subject: [PATCH 22/22] tweak --- esphome/components/wifi/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 11bd7798e2..5336a43ca2 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -69,6 +69,12 @@ CONF_MIN_AUTH_MODE = "min_auth_mode" # Limited to 127 because selected_sta_index_ is int8_t in C++ MAX_WIFI_NETWORKS = 127 +# Default AP timeout - allows sufficient time to try all BSSIDs during initial connection +# After AP starts, WiFi scanning is skipped to avoid disrupting the AP, so we only +# get best-effort connection attempts. Longer timeout ensures we exhaust all options +# before falling back to AP mode. +DEFAULT_AP_TIMEOUT = "2min" + wifi_ns = cg.esphome_ns.namespace("wifi") EAPAuth = wifi_ns.struct("EAPAuth") ManualIP = wifi_ns.struct("ManualIP") @@ -177,7 +183,7 @@ CONF_AP_TIMEOUT = "ap_timeout" WIFI_NETWORK_AP = WIFI_NETWORK_BASE.extend( { cv.Optional( - CONF_AP_TIMEOUT, default="1min" + CONF_AP_TIMEOUT, default=DEFAULT_AP_TIMEOUT ): cv.positive_time_period_milliseconds, } )