diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index 910f823b1f..cdacb8fe67 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -74,17 +74,8 @@ def _final_validate(config: ConfigType) -> None: "Add 'ap:' to your WiFi configuration to enable the captive portal." ) - # Register socket needs for DNS server and additional HTTP connections - # - 1 UDP socket for DNS server - # - 3 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. # The listening socket is registered by web_server_base (shared HTTP server). - from esphome.components import socket - - socket.consume_sockets(3, "captive_portal")(config) - socket.consume_sockets(1, "captive_portal", socket.SocketType.UDP)(config) + web_server_base.consume_captive_dns_sockets(config, "captive_portal") FINAL_VALIDATE_SCHEMA = _final_validate @@ -104,5 +95,4 @@ async def to_code(config): if config[CONF_COMPRESSION] == "gzip": cg.add_define("USE_CAPTIVE_PORTAL_GZIP") - if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2): - cg.add_library("DNSServer", None) + web_server_base.add_captive_dns_library() diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 704a61d4de..fce11594bf 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -82,17 +82,7 @@ void CaptivePortal::start() { this->base_->add_handler_without_auth(this); } - network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); - -#if defined(USE_ESP32) - // Create DNS server instance for ESP-IDF - this->dns_server_ = make_unique(); - this->dns_server_->start(ip); -#elif defined(USE_ARDUINO) - this->dns_server_ = make_unique(); - this->dns_server_->setErrorReplyCode(DNSReplyCode::NoError); - this->dns_server_->start(53, ESPHOME_F("*"), ip); -#endif + this->dns_.start(wifi::global_wifi_component->wifi_soft_ap_ip()); this->initialized_ = true; this->active_ = true; diff --git a/esphome/components/captive_portal/captive_portal.h b/esphome/components/captive_portal/captive_portal.h index 67a38bd31a..013ba25b5f 100644 --- a/esphome/components/captive_portal/captive_portal.h +++ b/esphome/components/captive_portal/captive_portal.h @@ -1,39 +1,20 @@ #pragma once #include "esphome/core/defines.h" #ifdef USE_CAPTIVE_PORTAL -#include -#if defined(USE_ESP32) -#include "esphome/components/web_server_base/dns_server_esp32_idf.h" -#elif defined(USE_ARDUINO) -#include -#endif #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" #include "esphome/components/web_server_base/web_server_base.h" +#include "esphome/components/web_server_base/captive_dns.h" namespace esphome::captive_portal { -#if defined(USE_ESP32) -using web_server_base::DNSServer; -#endif - class CaptivePortal final : public AsyncWebHandler, public Component { public: CaptivePortal(web_server_base::WebServerBase *base); void setup() override; void dump_config() override; - void loop() override { -#if defined(USE_ESP32) - if (this->dns_server_ != nullptr) { - this->dns_server_->process_next_request(); - } -#elif defined(USE_ARDUINO) - if (this->dns_server_ != nullptr) { - this->dns_server_->processNextRequest(); - } -#endif - } + void loop() override { this->dns_.loop(); } float get_setup_priority() const override; void start(); bool is_active() const { return this->active_; } @@ -41,10 +22,7 @@ class CaptivePortal final : public AsyncWebHandler, public Component { this->active_ = false; this->disable_loop(); // Stop processing DNS requests this->base_->deinit(); - if (this->dns_server_ != nullptr) { - this->dns_server_->stop(); - this->dns_server_ = nullptr; - } + this->dns_.stop(); } bool canHandle(AsyncWebServerRequest *request) const override { @@ -64,9 +42,7 @@ class CaptivePortal final : public AsyncWebHandler, public Component { web_server_base::WebServerBase *base_; bool initialized_{false}; bool active_{false}; -#if defined(USE_ARDUINO) || defined(USE_ESP32) - std::unique_ptr dns_server_{nullptr}; -#endif + web_server_base::CaptiveDNS dns_; }; extern CaptivePortal *global_captive_portal; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 75e89c4fa8..ec1719fa21 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -48,10 +48,12 @@ from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) -def AUTO_LOAD() -> list[str]: +def AUTO_LOAD(config: ConfigType) -> list[str]: auto_load = ["json", "web_server_base"] - if CORE.is_esp32: - # The AP mode DNS server (web_server_base/dns_server_esp32_idf) uses socket + # The AP mode DNS server (web_server_base/dns_server_esp32_idf) uses socket; only + # configs with a WiFi access point can end up in AP mode. + wifi = CORE.raw_config.get(CONF_WIFI) if CORE.raw_config is not None else None + if CORE.is_esp32 and (not isinstance(wifi, dict) or CONF_AP in wifi): auto_load.append("socket") return auto_load @@ -339,51 +341,42 @@ async def add_entity_config(entity, config): ) -def wifi_has_ap(wifi_config: ConfigType | None) -> bool: - return wifi_config is not None and CONF_AP in wifi_config - - def wifi_is_ap_only(wifi_config: ConfigType | None) -> bool: - """Return True when WiFi has an access point but no network to join, so the device - is only ever reached through its own AP.""" - return wifi_has_ap(wifi_config) and not wifi_config.get(CONF_NETWORKS) + """AP only: an access point and no network to join, so the device is only ever reached + through its own AP.""" + return ( + wifi_config is not None + and CONF_AP in wifi_config + and not wifi_config.get(CONF_NETWORKS) + ) def serve_local(config: ConfigType, wifi_config: ConfigType | None) -> bool: - """Return True when the web interface is embedded in the firmware instead of - loaded from oi.esphome.io. An explicit ``local:`` wins. Otherwise it is embedded for AP - only WiFi, since browsers on the AP usually have no internet and the hosted page would - stay blank. Version 1 has no local mode.""" + """Embed the interface unless ``local:`` says otherwise; AP only WiFi has no internet + for the hosted page. Version 1 has no local mode.""" if (local := config.get(CONF_LOCAL)) is not None: return local return config[CONF_VERSION] != 1 and wifi_is_ap_only(wifi_config) -def serve_captive( - config: ConfigType, wifi_config: ConfigType | None, has_captive_portal: bool -) -> bool: - """Return True when web_server runs its own captive portal (DNS server plus the - interface for every URL) while the WiFi access point is up: the interface must be - embedded, an AP must exist, and captive_portal (which owns that role when present) - must not be configured.""" +def serve_captive(config: ConfigType, full_config: ConfigType) -> bool: + """web_server runs its own captive portal while the AP is up: embedded interface plus + an access point, unless captive_portal (which owns that role) is configured.""" + wifi_config = full_config.get(CONF_WIFI) return ( - serve_local(config, wifi_config) - and wifi_has_ap(wifi_config) - and not has_captive_portal + "captive_portal" not in full_config + and wifi_config is not None + and CONF_AP in wifi_config + and serve_local(config, wifi_config) ) def _final_validate_ap_mode(config: ConfigType) -> None: full_config = fv.full_config.get() wifi_config = full_config.get(CONF_WIFI) - if serve_captive(config, wifi_config, "captive_portal" in full_config): - # Sockets for the DNS server and the OS captive portal probes, like captive_portal. - from esphome.components import socket - - socket.consume_sockets(3, "web_server")(config) - socket.consume_sockets(1, "web_server", socket.SocketType.UDP)(config) - return - if wifi_is_ap_only(wifi_config) and not serve_local(config, wifi_config): + if serve_captive(config, full_config): + web_server_base.consume_captive_dns_sockets(config, "web_server") + elif wifi_is_ap_only(wifi_config) and not serve_local(config, wifi_config): _LOGGER.warning( "WiFi is AP only and web_server has local: false, so the web interface is " "loaded from the internet, which browsers on the access point usually cannot " @@ -392,6 +385,7 @@ def _final_validate_ap_mode(config: ConfigType) -> None: def _final_validate(config: ConfigType) -> None: + # Called one after the other rather than via cv.All: these return None. _final_validate_sorting(config) _final_validate_ap_mode(config) @@ -499,16 +493,12 @@ async def to_code(config): with path.open(encoding="utf-8") as js_file: add_resource_as_progmem("JS_INCLUDE", js_file.read()) cg.add(var.set_include_internal(config[CONF_INCLUDE_INTERNAL])) - wifi_config = CORE.config.get(CONF_WIFI) - if serve_local(config, wifi_config): + if serve_local(config, CORE.config.get(CONF_WIFI)): cg.add_define("USE_WEBSERVER_LOCAL") - if serve_captive( - config, wifi_config, CORE.has_at_least_one_component("captive_portal") - ): - # AP mode: DNS server plus catch-all page so phones open the interface by themselves + if serve_captive(config, CORE.config): + # AP mode: DNS server plus redirect of unknown URLs so phones open the interface cg.add_define("USE_WEBSERVER_CAPTIVE") - if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2): - cg.add_library("DNSServer", None) + web_server_base.add_captive_dns_library() if config[CONF_COMPRESSION] == "gzip": cg.add_define("USE_WEBSERVER_GZIP") diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 7df3832779..661253c9b9 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -344,9 +344,13 @@ void DeferredUpdateEventSourceList::on_client_disconnect_(DeferredUpdateEventSou } #endif +#ifdef USE_WEBSERVER_CAPTIVE WebServer *global_web_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) { global_web_server = this; } +#else +WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) {} +#endif #ifdef USE_WEBSERVER_CSS_INCLUDE void WebServer::set_css_include(const char *css_include) { this->css_include_ = css_include; } @@ -392,6 +396,12 @@ void WebServer::setup() { this->base_->add_handler(&this->events_); #endif this->base_->add_handler(this); +#ifdef USE_WEBSERVER_CAPTIVE + // Not-found fallback (outside the auth middleware): the OS captive portal probes hit + // arbitrary URLs and must get the redirect without credentials. + this->base_->get_server()->onNotFound( + [](AsyncWebServerRequest *request) { global_web_server->handle_not_found_(request); }); +#endif // OTA is now handled by the web_server OTA platform @@ -407,51 +417,48 @@ void WebServer::setup() { }); } void WebServer::loop() { - bool busy = this->events_.loop(); + bool has_clients = this->events_.loop(); #ifdef USE_WEBSERVER_CAPTIVE - if (this->captive_) { -#if defined(USE_ESP32) - this->dns_server_->process_next_request(); -#elif defined(USE_ARDUINO) - this->dns_server_->processNextRequest(); -#endif - busy = true; + if (this->dns_.is_running()) { + this->dns_.loop(); + return; } #endif - // No SSE clients connected (and no captive DNS to serve); stop looping until a new - // client connects via enable_loop_soon_any_context(). This is safe because: + // No SSE clients connected; stop looping until a new client connects via + // enable_loop_soon_any_context(). This is safe because: // - set_interval/set_timeout/defer run via the Scheduler, independent of loop() // - deferrable_send_state early-outs when no clients are connected // - try_send_nodefer (log, ping) iterates sessions which are empty // - REST API handlers use defer() which runs via the Scheduler - if (!busy) + if (!has_clients) this->disable_loop(); } #ifdef USE_WEBSERVER_CAPTIVE void WebServer::start_captive() { - if (this->captive_) + if (this->dns_.is_running()) return; network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); - this->dns_server_ = make_unique(); -#if defined(USE_ESP32) - this->dns_server_->start(ip); -#elif defined(USE_ARDUINO) - this->dns_server_->setErrorReplyCode(DNSReplyCode::NoError); - this->dns_server_->start(53, ESPHOME_F("*"), ip); -#endif - this->captive_ = true; + this->dns_.start(ip); this->enable_loop(); char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; ESP_LOGI(TAG, "AP mode: serving the web interface as captive portal at http://%s/", ip.str_to(ip_buf)); } -void WebServer::end_captive() { - if (!this->captive_) +void WebServer::end_captive() { this->dns_.stop(); } + +void WebServer::handle_not_found_(AsyncWebServerRequest *request) { + // OS captive portal probe (or any other unknown page) while the AP is up: send the browser + // to the real page. A redirect rather than the page itself, because the interface resolves + // its /events and REST paths relative to the page URL. + if (this->dns_.is_running() && request->method() == HTTP_GET) { + char location[7 + network::IP_ADDRESS_BUFFER_SIZE]; + size_t pos = buf_append_str(location, sizeof(location), 0, "http://"); + wifi::global_wifi_component->wifi_soft_ap_ip().str_to(location + pos); + request->redirect(location); return; - this->captive_ = false; - this->dns_server_->stop(); - this->dns_server_ = nullptr; + } + request->send(404); } #endif @@ -2379,13 +2386,6 @@ bool WebServer::canHandle(AsyncWebServerRequest *request) const { #endif const auto method = request->method(); -#ifdef USE_WEBSERVER_CAPTIVE - // AP mode: answer every GET; unknown URLs redirect to the index page, so the OS captive - // portal check (generate_204, hotspot-detect.html, ...) opens the interface. - if (this->captive_ && method == HTTP_GET) - return true; -#endif - // Static URL checks - use ESPHOME_F to keep strings in flash on ESP8266 if (url == ESPHOME_F("/")) return true; @@ -2691,18 +2691,6 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { } #endif else { -#ifdef USE_WEBSERVER_CAPTIVE - if (this->captive_ && request->method() == HTTP_GET) { - // OS captive portal probe (or any other unknown page): send the browser to the real - // page. A redirect rather than the page itself, because the interface resolves its - // /events and REST paths relative to the page URL. - char location[7 + network::IP_ADDRESS_BUFFER_SIZE]; - memcpy(location, "http://", 7); // NOLINT(bugprone-not-null-terminated-result) - str_to null-terminates - wifi::global_wifi_component->wifi_soft_ap_ip().str_to(location + 7); - request->redirect(location); - return; - } -#endif // No matching handler found - send 404 ESP_LOGV(TAG, "Request for unknown URL: %s", url.c_str()); request->send(404, ESPHOME_F("text/plain"), ESPHOME_F("Not Found")); diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index d52a1880b7..72df86fef0 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -5,11 +5,7 @@ #include "esphome/components/json/json_util.h" #include "esphome/components/web_server_base/web_server_base.h" #ifdef USE_WEBSERVER_CAPTIVE -#if defined(USE_ESP32) -#include "esphome/components/web_server_base/dns_server_esp32_idf.h" -#elif defined(USE_ARDUINO) -#include -#endif +#include "esphome/components/web_server_base/captive_dns.h" #endif #ifdef USE_WEBSERVER #include "esphome/core/component.h" @@ -21,7 +17,6 @@ #include #include -#include #include #include #include @@ -44,10 +39,6 @@ extern const size_t ESPHOME_WEBSERVER_JS_INCLUDE_SIZE; namespace esphome::web_server { -#if defined(USE_WEBSERVER_CAPTIVE) && defined(USE_ESP32) -using web_server_base::DNSServer; -#endif - // Type for parameter names that can be stored in flash on ESP8266 #ifdef USE_ESP8266 using ParamNameType = const __FlashStringHelper *; @@ -289,13 +280,14 @@ class WebServer final : public Controller, public Component, public AsyncWebHand void handle_index_request(AsyncWebServerRequest *request); #ifdef USE_WEBSERVER_CAPTIVE - /** AP mode: run a DNS server that answers every name with the AP address and serve the - * interface for any unknown URL, so a phone joining the AP opens it through the OS captive - * portal check. Started and ended by the wifi component with the access point. + /** AP mode: run a DNS server that answers every name with the AP address and redirect any + * unknown URL to the interface, so a phone joining the AP opens it through the OS captive + * portal check. Started and ended by the wifi component with the access point; start may + * run before setup(), so it touches nothing but the DNS server. */ void start_captive(); void end_captive(); - bool is_captive() const { return this->captive_; } + bool is_captive() const { return this->dns_.is_running(); } #endif /// Return the webserver configuration as JSON. @@ -620,8 +612,8 @@ class WebServer final : public Controller, public Component, public AsyncWebHand DeferredUpdateEventSourceList events_; #endif #ifdef USE_WEBSERVER_CAPTIVE - std::unique_ptr dns_server_; - bool captive_{false}; + void handle_not_found_(AsyncWebServerRequest *request); + web_server_base::CaptiveDNS dns_; #endif #if USE_WEBSERVER_VERSION == 1 @@ -722,7 +714,9 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif }; +#ifdef USE_WEBSERVER_CAPTIVE extern WebServer *global_web_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#endif } // namespace esphome::web_server #endif diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index 010eedd9d1..3d0308c3df 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -7,6 +7,7 @@ from esphome.const import CONF_ID, PlatformFramework from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority from esphome.helpers import copy_file_if_changed +from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network"] @@ -26,6 +27,22 @@ WebServerBase = web_server_base_ns.class_("WebServerBase") CONF_WEB_SERVER_BASE_ID = "web_server_base_id" +def consume_captive_dns_sockets(config: ConfigType, name: str) -> None: + """Register the sockets a captive portal needs on top of the shared HTTP server: + 1 UDP socket for the DNS server and 3 TCP sockets for the OS captive portal probes, + which make several requests that linger in TIME_WAIT.""" + from esphome.components import socket + + socket.consume_sockets(3, name)(config) + socket.consume_sockets(1, name, socket.SocketType.UDP)(config) + + +def add_captive_dns_library() -> None: + """Pull in the Arduino DNSServer library used by CaptiveDNS off ESP32.""" + if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2): + cg.add_library("DNSServer", None) + + def _consume_web_server_base_sockets(config): """Register the shared listening socket for the HTTP server. diff --git a/esphome/components/web_server_base/captive_dns.h b/esphome/components/web_server_base/captive_dns.h new file mode 100644 index 0000000000..592e5369b2 --- /dev/null +++ b/esphome/components/web_server_base/captive_dns.h @@ -0,0 +1,58 @@ +#pragma once +#include "esphome/core/defines.h" +// DNS server that answers every name with the access point address, so a phone joining the +// AP runs its captive portal check against the device. Shared by captive_portal and the +// web_server AP mode; hides the ESP32 (own implementation) vs Arduino (DNSServer library) split. +#if defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE) +#include + +#include "esphome/components/network/ip_address.h" +#include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" +#if defined(USE_ESP32) +#include "dns_server_esp32_idf.h" +#elif defined(USE_ARDUINO) +#include +#endif + +namespace esphome::web_server_base { + +// The server object only exists while running, so an idle owner (AP not up) pays one pointer. +class CaptiveDNS { + public: + void start(const network::IPAddress &ip) { + if (this->dns_server_ != nullptr) + return; + this->dns_server_ = make_unique(); +#if defined(USE_ESP32) + this->dns_server_->start(ip); +#elif defined(USE_ARDUINO) + this->dns_server_->setErrorReplyCode(DNSReplyCode::NoError); + this->dns_server_->start(53, ESPHOME_F("*"), ip); +#endif + } + void stop() { + if (this->dns_server_ == nullptr) + return; + this->dns_server_->stop(); + this->dns_server_ = nullptr; + } + /// Answer one pending query; call from the owner's loop() while running. + void loop() { + if (this->dns_server_ == nullptr) + return; +#if defined(USE_ESP32) + this->dns_server_->process_next_request(); +#elif defined(USE_ARDUINO) + this->dns_server_->processNextRequest(); +#endif + } + bool is_running() const { return this->dns_server_ != nullptr; } + + protected: + // ESP32: web_server_base::DNSServer from dns_server_esp32_idf.h; Arduino: the library class. + std::unique_ptr dns_server_; +}; + +} // namespace esphome::web_server_base +#endif // USE_CAPTIVE_PORTAL || USE_WEBSERVER_CAPTIVE diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 993fb6c035..e0d4c2b5fe 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -325,9 +325,9 @@ StringRef AsyncWebServerRequest::url_to(std::span buffer) co return StringRef(buffer.data(), decoded_len); } -void AsyncWebServerRequest::redirect(const std::string &url) { +void AsyncWebServerRequest::redirect(const char *url) { httpd_resp_set_status(*this, "302 Found"); - httpd_resp_set_hdr(*this, "Location", url.c_str()); + httpd_resp_set_hdr(*this, "Location", url); httpd_resp_set_hdr(*this, "Connection", "close"); httpd_resp_send(*this, nullptr, 0); } diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index baa55898bb..052591c062 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -132,7 +132,8 @@ class AsyncWebServerRequest { void requestAuthentication() const; #endif - void redirect(const std::string &url); + void redirect(const char *url); + void redirect(const std::string &url) { this->redirect(url.c_str()); } inline void ESPHOME_ALWAYS_INLINE send(AsyncWebServerResponse *response) { httpd_resp_send(*this, response->get_content_data(), response->get_content_size()); diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index f2e7a8f3f9..4f523167e0 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -731,12 +731,9 @@ void WiFiComponent::start() { if (captive_portal::global_captive_portal != nullptr) { this->wifi_sta_pre_setup_(); this->start_scanning(); - captive_portal::global_captive_portal->start(); } #endif -#ifdef USE_WEBSERVER_CAPTIVE - web_server::global_web_server->start_captive(); -#endif + this->start_ap_portal_(); #endif // USE_WIFI_AP } #ifdef USE_IMPROV @@ -797,8 +794,8 @@ void WiFiComponent::loop() { this->check_connecting_finished(now); break; } - // 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_(); + // Use longer cooldown when a portal/improv is active to avoid disrupting a user on the AP + bool portal_active = this->is_ap_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 @@ -867,16 +864,11 @@ void WiFiComponent::loop() { ESP_LOGI(TAG, "Starting fallback AP"); this->setup_ap_config_(); #ifdef USE_CAPTIVE_PORTAL - if (captive_portal::global_captive_portal != nullptr) { - // Reset so we force one full scan after captive portal starts - // (previous scans were filtered because captive portal wasn't active yet) - this->has_completed_scan_after_captive_portal_start_ = false; - captive_portal::global_captive_portal->start(); - } -#endif -#ifdef USE_WEBSERVER_CAPTIVE - web_server::global_web_server->start_captive(); + // Reset so we force one full scan after captive portal starts + // (previous scans were filtered because captive portal wasn't active yet) + this->has_completed_scan_after_captive_portal_start_ = false; #endif + this->start_ap_portal_(); } } #endif // USE_WIFI_AP @@ -1627,14 +1619,7 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT; this->num_retried_ = 0; if (this->has_ap()) { -#ifdef USE_CAPTIVE_PORTAL - if (this->is_captive_portal_active_()) { - captive_portal::global_captive_portal->end(); - } -#endif -#ifdef USE_WEBSERVER_CAPTIVE - web_server::global_web_server->end_captive(); -#endif + this->end_ap_portal_(); ESP_LOGD(TAG, "Disabling AP"); this->wifi_mode_({}, false); } @@ -1960,10 +1945,10 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { break; case WiFiRetryPhase::RESTARTING_ADAPTER: - // Skip actual adapter restart if captive portal/improv is active + // Skip actual adapter restart if a portal/improv is active // This allows state machine to reset num_retried_ and trigger fresh scan - // without disrupting the captive portal/improv connection - if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_()) { + // without disrupting the portal/improv connection + if (!this->is_ap_portal_active_() && !this->is_esp32_improv_active_()) { this->restart_adapter(); } else { // Even when skipping full restart, disconnect to clear driver state @@ -2225,6 +2210,36 @@ bool WiFiComponent::is_captive_portal_active_() { return false; #endif } + +bool WiFiComponent::is_ap_portal_active_() { +#ifdef USE_WEBSERVER_CAPTIVE + if (web_server::global_web_server->is_captive()) + return true; +#endif + return this->is_captive_portal_active_(); +} + +#ifdef USE_WIFI_AP +void WiFiComponent::start_ap_portal_() { +#ifdef USE_CAPTIVE_PORTAL + if (captive_portal::global_captive_portal != nullptr) + captive_portal::global_captive_portal->start(); +#endif +#ifdef USE_WEBSERVER_CAPTIVE + web_server::global_web_server->start_captive(); +#endif +} + +void WiFiComponent::end_ap_portal_() { +#ifdef USE_CAPTIVE_PORTAL + if (this->is_captive_portal_active_()) + captive_portal::global_captive_portal->end(); +#endif +#ifdef USE_WEBSERVER_CAPTIVE + web_server::global_web_server->end_captive(); +#endif +} +#endif // USE_WIFI_AP bool WiFiComponent::is_esp32_improv_active_() { #ifdef USE_IMPROV return esp32_improv::global_improv_component != nullptr && esp32_improv::global_improv_component->is_active(); diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index ea043fd5c6..cea53f2136 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -789,6 +789,12 @@ class WiFiComponent final : public Component { network::IPAddress wifi_dns_ip_(int num); bool is_captive_portal_active_(); + /// captive_portal or the web_server AP mode is serving a user on the access point + bool is_ap_portal_active_(); +#ifdef USE_WIFI_AP + void start_ap_portal_(); + void end_ap_portal_(); +#endif bool is_esp32_improv_active_(); #ifdef USE_WIFI_FAST_CONNECT diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 245390b097..57e7398018 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -1128,10 +1128,15 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional &manual_ip) { return false; } -#if defined(USE_CAPTIVE_PORTAL) && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 0) - // Configure DHCP Option 114 (Captive Portal URI) if captive portal is enabled - // This provides a standards-compliant way for clients to discover the captive portal - if (captive_portal::global_captive_portal != nullptr) { +#if (defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE)) && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 0) + // Configure DHCP Option 114 (Captive Portal URI) if captive portal or the web_server AP + // mode is enabled. This provides a standards-compliant way for clients to discover the portal +#ifdef USE_CAPTIVE_PORTAL + const bool has_portal = captive_portal::global_captive_portal != nullptr; +#else + const bool has_portal = true; +#endif + if (has_portal) { // Buffer must be static - dhcps_set_option_info stores pointer, doesn't copy static char captive_portal_uri[24]; // "http://" (7) + IPv4 max (15) + null memcpy(captive_portal_uri, "http://", 7); // NOLINT(bugprone-not-null-terminated-result) - str_to null-terminates diff --git a/tests/unit_tests/components/test_web_server.py b/tests/unit_tests/components/test_web_server.py index 5b4b8deb92..5b9541c0aa 100644 --- a/tests/unit_tests/components/test_web_server.py +++ b/tests/unit_tests/components/test_web_server.py @@ -8,7 +8,6 @@ from esphome.components.web_server import ( _final_validate_ap_mode, serve_captive, serve_local, - wifi_is_ap_only, ) from esphome.const import ( CONF_AP, @@ -25,14 +24,6 @@ AP_FALLBACK = {CONF_AP: {}, CONF_NETWORKS: [{CONF_SSID: "x"}]} STA_ONLY = {CONF_NETWORKS: [{CONF_SSID: "x"}]} -@pytest.mark.parametrize( - ("wifi_config", "expected"), - [(AP_ONLY, True), (AP_FALLBACK, False), (STA_ONLY, False), (None, False)], -) -def test_wifi_is_ap_only(wifi_config: dict | None, expected: bool) -> None: - assert wifi_is_ap_only(wifi_config) is expected - - @pytest.mark.parametrize( ("web_server_config", "wifi_config", "expected"), [ @@ -57,45 +48,44 @@ def test_serve_local( @pytest.mark.parametrize( - ("web_server_config", "wifi_config", "has_captive_portal", "expected"), + ("web_server_config", "full_config", "expected"), [ # AP only: local is implied, web_server is the captive portal. - ({CONF_VERSION: 2}, AP_ONLY, False, True), + ({CONF_VERSION: 2}, {CONF_WIFI: AP_ONLY}, True), # AP fallback needs an explicit local: true to be captive. - ({CONF_VERSION: 2}, AP_FALLBACK, False, False), - ({CONF_VERSION: 2, CONF_LOCAL: True}, AP_FALLBACK, False, True), + ({CONF_VERSION: 2}, {CONF_WIFI: AP_FALLBACK}, False), + ({CONF_VERSION: 2, CONF_LOCAL: True}, {CONF_WIFI: AP_FALLBACK}, True), # captive_portal owns the role when configured. - ({CONF_VERSION: 2}, AP_ONLY, True, False), - # No AP, hosted page, or version 1: never captive. - ({CONF_VERSION: 2, CONF_LOCAL: True}, STA_ONLY, False, False), - ({CONF_VERSION: 2, CONF_LOCAL: False}, AP_ONLY, False, False), - ({CONF_VERSION: 1}, AP_ONLY, False, False), + ({CONF_VERSION: 2}, {CONF_WIFI: AP_ONLY, "captive_portal": {}}, False), + # No AP, no wifi, hosted page, or version 1: never captive. + ({CONF_VERSION: 2, CONF_LOCAL: True}, {CONF_WIFI: STA_ONLY}, False), + ({CONF_VERSION: 2, CONF_LOCAL: True}, {}, False), + ({CONF_VERSION: 2, CONF_LOCAL: False}, {CONF_WIFI: AP_ONLY}, False), + ({CONF_VERSION: 1}, {CONF_WIFI: AP_ONLY}, False), ], ) def test_serve_captive( - web_server_config: dict, - wifi_config: dict | None, - has_captive_portal: bool, - expected: bool, + web_server_config: dict, full_config: dict, expected: bool ) -> None: - assert serve_captive(web_server_config, wifi_config, has_captive_portal) is expected + assert serve_captive(web_server_config, full_config) is expected -def test_final_validate_ap_mode_warns_for_hosted_page( - caplog: pytest.LogCaptureFixture, -) -> None: - """AP only with an explicit local: false gets a warning; AP only default does not.""" - for web_server_config, expect_warning in ( +@pytest.mark.parametrize( + ("web_server_config", "expect_warning"), + [ + # Explicit local: false on an AP only device: the hosted page will stay blank. ({CONF_VERSION: 2, CONF_LOCAL: False}, True), + # Default: embedded and captive, nothing to warn about. ({CONF_VERSION: 2}, False), - ): - caplog.clear() - token = fv.full_config.set( - {"web_server": web_server_config, CONF_WIFI: AP_ONLY} - ) - try: - with caplog.at_level(logging.WARNING): - _final_validate_ap_mode(web_server_config) - finally: - fv.full_config.reset(token) - assert ("stays blank" in caplog.text) is expect_warning + ], +) +def test_final_validate_ap_mode_warns_for_hosted_page( + web_server_config: dict, expect_warning: bool, caplog: pytest.LogCaptureFixture +) -> None: + token = fv.full_config.set({"web_server": web_server_config, CONF_WIFI: AP_ONLY}) + try: + with caplog.at_level(logging.WARNING): + _final_validate_ap_mode(web_server_config) + finally: + fv.full_config.reset(token) + assert ("stays blank" in caplog.text) is expect_warning