From 676ecae4698214f422ccd6ad3659a3c7aac14f74 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 19 Aug 2026 14:40:35 -0500 Subject: [PATCH] [wifi][web_server] Guard AP portal helpers with USE_WIFI_AP and apply review cleanups --- esphome/components/captive_portal/__init__.py | 1 - esphome/components/web_server/__init__.py | 13 +++++++--- esphome/components/web_server/web_server.cpp | 26 +++++++++---------- esphome/components/web_server/web_server.h | 5 ++-- esphome/components/wifi/wifi_component.cpp | 6 +++++ esphome/components/wifi/wifi_component.h | 2 ++ .../wifi/wifi_component_esp_idf.cpp | 7 ++--- .../components/web_server/common-ap-mode.yaml | 9 +++++++ .../web_server/test-ap-mode.esp32-idf.yaml | 11 ++------ .../web_server/test-ap-mode.esp8266-ard.yaml | 11 ++------ 10 files changed, 51 insertions(+), 40 deletions(-) create mode 100644 tests/components/web_server/common-ap-mode.yaml diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index cdacb8fe67..353e510cab 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -74,7 +74,6 @@ def _final_validate(config: ConfigType) -> None: "Add 'ap:' to your WiFi configuration to enable the captive portal." ) - # The listening socket is registered by web_server_base (shared HTTP server). web_server_base.consume_captive_dns_sockets(config, "captive_portal") diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 009e064f3b..2710239dd4 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -378,11 +378,18 @@ def _final_validate_ap_mode(config: ConfigType) -> None: wifi_config = full_config.get(CONF_WIFI) if serve_captive(config, full_config): web_server_base.consume_captive_dns_sockets(config, "web_server") + if CONF_LOCAL not in config: + _LOGGER.info( + "WiFi is AP only: embedding the web interface in the firmware " + "(local: true) and serving it as a captive portal on the access point. " + "Set 'local: false' to load it from the internet instead." + ) 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 " - "reach; the page stays blank. Remove 'local: false' to embed it in the firmware." + "WiFi is AP only and the web_server interface is loaded from the internet, " + "which browsers on the access point usually cannot reach; the page stays " + "blank. Remove 'local: false', or migrate off version 1, so the interface " + "is embedded in the firmware." ) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 661253c9b9..0c004715bc 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -346,12 +346,14 @@ void DeferredUpdateEventSourceList::on_client_disconnect_(DeferredUpdateEventSou #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 +WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) { +#ifdef USE_WEBSERVER_CAPTIVE + global_web_server = this; +#endif +} + #ifdef USE_WEBSERVER_CSS_INCLUDE void WebServer::set_css_include(const char *css_include) { this->css_include_ = css_include; } #endif @@ -399,8 +401,7 @@ void WebServer::setup() { #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); }); + this->base_->get_server()->onNotFound([this](AsyncWebServerRequest *request) { this->handle_not_found_(request); }); #endif // OTA is now handled by the web_server OTA platform @@ -417,25 +418,24 @@ void WebServer::setup() { }); } void WebServer::loop() { - bool has_clients = this->events_.loop(); + bool keep_looping = this->events_.loop(); #ifdef USE_WEBSERVER_CAPTIVE - if (this->dns_.is_running()) { - this->dns_.loop(); - return; - } + this->dns_.loop(); + keep_looping |= this->dns_.is_running(); #endif - // No SSE clients connected; stop looping until a new client connects via + // 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: // - 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 (!has_clients) + if (!keep_looping) this->disable_loop(); } #ifdef USE_WEBSERVER_CAPTIVE void WebServer::start_captive() { + // CaptiveDNS::start() no-ops too; this guard just avoids repeating the log and enable_loop if (this->dns_.is_running()) return; network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 72df86fef0..08a140a49e 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -282,8 +282,9 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #ifdef USE_WEBSERVER_CAPTIVE /** 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. + * portal check. Started and ended by the wifi component with the access point. start may run + * before setup() (wifi sets up first): safe because enable_loop() is a no-op before setup; + * nothing but the DNS server may be touched, in particular not base_ or the handlers. */ void start_captive(); void end_captive(); diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 38268bdf60..029f241d01 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1619,7 +1619,9 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT; this->num_retried_ = 0; if (this->has_ap()) { +#ifdef USE_WIFI_AP this->end_ap_portal_(); +#endif ESP_LOGD(TAG, "Disabling AP"); this->wifi_mode_({}, false); } @@ -2219,6 +2221,9 @@ bool WiFiComponent::is_ap_portal_active_() { return this->is_captive_portal_active_(); } +#ifdef USE_WIFI_AP +// global_web_server needs no null check: codegen always instantiates WebServer when +// USE_WEBSERVER_CAPTIVE is defined, and the constructor assigns the global. void WiFiComponent::start_ap_portal_() { #ifdef USE_CAPTIVE_PORTAL if (captive_portal::global_captive_portal != nullptr) @@ -2238,6 +2243,7 @@ void WiFiComponent::end_ap_portal_() { 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 a7adce1a0d..cea53f2136 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -791,8 +791,10 @@ class WiFiComponent final : public Component { 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 57e7398018..92d1951345 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -1131,10 +1131,11 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional &manual_ip) { #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 +#ifdef USE_WEBSERVER_CAPTIVE + // web_server AP mode always serves the portal when compiled in const bool has_portal = true; +#else + const bool has_portal = captive_portal::global_captive_portal != nullptr; #endif if (has_portal) { // Buffer must be static - dhcps_set_option_info stores pointer, doesn't copy diff --git a/tests/components/web_server/common-ap-mode.yaml b/tests/components/web_server/common-ap-mode.yaml new file mode 100644 index 0000000000..dc522e3178 --- /dev/null +++ b/tests/components/web_server/common-ap-mode.yaml @@ -0,0 +1,9 @@ +# AP mode: with a WiFi access point and the interface embedded in the firmware, web_server +# runs its own captive portal (DNS server, unknown URLs redirect to the page). +wifi: + ap: + ssid: "ESPHome-Test" + password: "Test1234!" + +web_server: + local: true diff --git a/tests/components/web_server/test-ap-mode.esp32-idf.yaml b/tests/components/web_server/test-ap-mode.esp32-idf.yaml index dc522e3178..077a7da009 100644 --- a/tests/components/web_server/test-ap-mode.esp32-idf.yaml +++ b/tests/components/web_server/test-ap-mode.esp32-idf.yaml @@ -1,9 +1,2 @@ -# AP mode: with a WiFi access point and the interface embedded in the firmware, web_server -# runs its own captive portal (DNS server, unknown URLs redirect to the page). -wifi: - ap: - ssid: "ESPHome-Test" - password: "Test1234!" - -web_server: - local: true +packages: + web_server: !include common-ap-mode.yaml diff --git a/tests/components/web_server/test-ap-mode.esp8266-ard.yaml b/tests/components/web_server/test-ap-mode.esp8266-ard.yaml index dc522e3178..077a7da009 100644 --- a/tests/components/web_server/test-ap-mode.esp8266-ard.yaml +++ b/tests/components/web_server/test-ap-mode.esp8266-ard.yaml @@ -1,9 +1,2 @@ -# AP mode: with a WiFi access point and the interface embedded in the firmware, web_server -# runs its own captive portal (DNS server, unknown URLs redirect to the page). -wifi: - ap: - ssid: "ESPHome-Test" - password: "Test1234!" - -web_server: - local: true +packages: + web_server: !include common-ap-mode.yaml