1
0
mirror of https://github.com/esphome/esphome.git synced 2025-11-18 07:45:56 +00:00

Compare commits

..

24 Commits

Author SHA1 Message Date
J. Nick Koston
1457ef6e0b Merge branch 'integration' into memory_api 2025-11-18 00:15:41 -06:00
J. Nick Koston
9a2536a5f6 Merge branch 'captive_fix' into integration 2025-11-18 00:15:31 -06:00
J. Nick Koston
66fcf364a6 tweak 2025-11-18 00:09:51 -06:00
J. Nick Koston
048533a1fd no delay 2025-11-17 23:45:59 -06:00
J. Nick Koston
3f763b24c5 no delay 2025-11-17 23:45:32 -06:00
J. Nick Koston
efbf696f88 no delay 2025-11-17 23:45:25 -06:00
J. Nick Koston
29ef0a6740 no delay 2025-11-17 23:43:33 -06:00
J. Nick Koston
b9af06f5a4 no delay 2025-11-17 23:35:26 -06:00
J. Nick Koston
3f799a01a2 anotehr thread safety issue 2025-11-17 23:23:49 -06:00
J. Nick Koston
2ee5cc6f22 anotehr thread safety issue 2025-11-17 23:17:36 -06:00
J. Nick Koston
8d7090fcd6 fix thread safety issue 2025-11-17 23:11:55 -06:00
J. Nick Koston
3d83975d46 fix thread safety issue 2025-11-17 23:11:23 -06:00
J. Nick Koston
1815a7cf90 skip scan when ap mode 2025-11-17 23:03:38 -06:00
J. Nick Koston
bfe6fc0dd0 skip scan when ap mode 2025-11-17 23:00:22 -06:00
J. Nick Koston
87ccb777c6 esp32 2025-11-17 22:32:57 -06:00
J. Nick Koston
f0bae783cf cleanup 2025-11-17 22:31:53 -06:00
J. Nick Koston
6f96804a5d cleanup 2025-11-17 22:31:22 -06:00
J. Nick Koston
a81f28a73b fixes 2025-11-17 22:14:56 -06:00
J. Nick Koston
bbfff42f76 fixes 2025-11-17 22:11:27 -06:00
J. Nick Koston
11c8865248 fixes 2025-11-17 21:57:21 -06:00
J. Nick Koston
7f4205b82c reduce 2025-11-17 21:52:12 -06:00
J. Nick Koston
27a068e8b5 reduce 2025-11-17 21:44:18 -06:00
J. Nick Koston
15be275541 tweak 2025-11-17 21:03:35 -06:00
J. Nick Koston
b0560894b7 [wifi] Fix captive portal unusable when WiFi credentials are wrong 2025-11-17 19:10:40 -06:00
8 changed files with 80 additions and 9 deletions

View File

@@ -72,10 +72,15 @@ 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
# - 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(1, "captive_portal")(config)
socket.consume_sockets(4, "captive_portal")(config)
return config

View File

@@ -52,8 +52,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);
wifi::global_wifi_component->start_scanning();
// 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"));
}
@@ -65,6 +65,12 @@ void CaptivePortal::start() {
this->base_->init();
if (!this->initialized_) {
this->base_->add_handler(this);
#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.
this->base_->get_server()->set_lru_purge_enable(true);
#endif
}
network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip();

View File

@@ -40,6 +40,10 @@ class CaptivePortal : public AsyncWebHandler, public Component {
void end() {
this->active_ = false;
this->disable_loop(); // Stop processing DNS requests
#ifdef USE_ESP32
// 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();

View File

@@ -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,6 +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 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 = "",
@@ -242,6 +256,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);
}

View File

@@ -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;

View File

@@ -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,
}
)

View File

@@ -202,7 +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
/// Longer interval gives users time to configure WiFi without constant connection attempts
/// 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) {
@@ -670,6 +671,21 @@ 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;
// Trigger connection attempt (exits cooldown if needed, no-op if already connecting/connected)
this->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");
this->retry_connect();
}
}
void WiFiComponent::start_connecting(const WiFiAP &ap) {
@@ -869,6 +885,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
@@ -1234,9 +1252,18 @@ 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
// 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;
}
// Should never reach here

View File

@@ -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.
@@ -424,6 +425,8 @@ class WiFiComponent : public Component {
return true;
}
void connect_soon_();
void wifi_loop_();
bool wifi_mode_(optional<bool> sta, optional<bool> ap);
bool wifi_sta_pre_setup_();
@@ -529,6 +532,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<>()};