diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index 8e5274f58f..910f823b1f 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -3,7 +3,6 @@ import logging import esphome.codegen as cg from esphome.components import web_server_base, wifi from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID -from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( CONF_AP, @@ -15,7 +14,6 @@ from esphome.const import ( PLATFORM_LN882X, PLATFORM_RP2, PLATFORM_RTL87XX, - PlatformFramework, ) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority @@ -108,14 +106,3 @@ async def to_code(config): if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2): cg.add_library("DNSServer", None) - - -# Only compile the ESP-IDF DNS server when using ESP-IDF framework -FILTER_SOURCE_FILES = filter_source_files_from_platform( - { - "dns_server_esp32_idf.cpp": { - PlatformFramework.ESP32_ARDUINO, - PlatformFramework.ESP32_IDF, - }, - } -) diff --git a/esphome/components/captive_portal/captive_portal.h b/esphome/components/captive_portal/captive_portal.h index b47af9d978..67a38bd31a 100644 --- a/esphome/components/captive_portal/captive_portal.h +++ b/esphome/components/captive_portal/captive_portal.h @@ -3,7 +3,7 @@ #ifdef USE_CAPTIVE_PORTAL #include #if defined(USE_ESP32) -#include "dns_server_esp32_idf.h" +#include "esphome/components/web_server_base/dns_server_esp32_idf.h" #elif defined(USE_ARDUINO) #include #endif @@ -14,6 +14,10 @@ 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); diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 4315155035..75e89c4fa8 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -23,13 +23,11 @@ from esphome.const import ( CONF_JS_URL, CONF_LOCAL, CONF_LOG, - CONF_MANUAL_IP, CONF_NAME, CONF_NETWORKS, CONF_OTA, CONF_PASSWORD, CONF_PORT, - CONF_STATIC_IP, CONF_TYPE, CONF_USERNAME, CONF_VERSION, @@ -49,7 +47,14 @@ from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) -AUTO_LOAD = ["json", "web_server_base"] + +def AUTO_LOAD() -> 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 + auto_load.append("socket") + return auto_load + AUTH_TYPE_BASIC = "basic" AUTH_TYPE_DIGEST = "digest" @@ -334,56 +339,61 @@ 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_config is not None - and CONF_AP in wifi_config - and not wifi_config.get(CONF_NETWORKS) - ) + return wifi_has_ap(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 + 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.""" if (local := config.get(CONF_LOCAL)) is not None: return local return config[CONF_VERSION] != 1 and wifi_is_ap_only(wifi_config) -def _final_validate_ap_only(config: ConfigType) -> None: +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.""" + return ( + serve_local(config, wifi_config) + and wifi_has_ap(wifi_config) + and not has_captive_portal + ) + + +def _final_validate_ap_mode(config: ConfigType) -> None: full_config = fv.full_config.get() wifi_config = full_config.get(CONF_WIFI) - if not wifi_is_ap_only(wifi_config): + 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 - ap_ip = "192.168.4.1" - if (manual_ip := wifi_config[CONF_AP].get(CONF_MANUAL_IP)) is not None: - ap_ip = str(manual_ip[CONF_STATIC_IP]) - captive = ( - "" if "captive_portal" in full_config else " (the AP is not a captive portal)" - ) - if serve_local(config, wifi_config): - how = "The web interface is embedded in the firmware (local: true)" - else: - how = ( - "With local: false the web interface is loaded from the internet, which " - "browsers on the AP usually cannot reach, so the page stays blank" + if 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." ) - _LOGGER.warning( - "WiFi is AP only, so web_server is reachable only through the access point: " - "open http://%s/ manually after joining it%s. %s.", - ap_ip, - captive, - how, - ) def _final_validate(config: ConfigType) -> None: _final_validate_sorting(config) - _final_validate_ap_only(config) + _final_validate_ap_mode(config) FINAL_VALIDATE_SCHEMA = _final_validate @@ -489,8 +499,16 @@ 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])) - if serve_local(config, CORE.config.get(CONF_WIFI)): + wifi_config = CORE.config.get(CONF_WIFI) + if serve_local(config, wifi_config): 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 + 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) 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 9e50b7a394..7df3832779 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -44,6 +44,10 @@ #include "esphome/components/radio_frequency/radio_frequency.h" #endif +#ifdef USE_WEBSERVER_CAPTIVE +#include "esphome/components/wifi/wifi_component.h" +#endif + #ifdef USE_WEBSERVER_LOCAL #if USE_WEBSERVER_VERSION == 2 #include "server_index_v2.h" @@ -340,7 +344,9 @@ void DeferredUpdateEventSourceList::on_client_disconnect_(DeferredUpdateEventSou } #endif -WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) {} +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; } #ifdef USE_WEBSERVER_CSS_INCLUDE void WebServer::set_css_include(const char *css_include) { this->css_include_ = css_include; } @@ -401,16 +407,54 @@ void WebServer::setup() { }); } void WebServer::loop() { - // No SSE clients connected; stop looping until a new client connects via - // enable_loop_soon_any_context(). This is safe because: + bool busy = 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; + } +#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: // - 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 (!this->events_.loop()) + if (!busy) this->disable_loop(); } +#ifdef USE_WEBSERVER_CAPTIVE +void WebServer::start_captive() { + if (this->captive_) + 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->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_) + return; + this->captive_ = false; + this->dns_server_->stop(); + this->dns_server_ = nullptr; +} +#endif + #ifdef USE_LOGGER void WebServer::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { (void) level; @@ -2335,6 +2379,13 @@ 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; @@ -2640,6 +2691,18 @@ 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 0fbe4ec551..d52a1880b7 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -4,6 +4,13 @@ #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 +#endif #ifdef USE_WEBSERVER #include "esphome/core/component.h" #include "esphome/core/controller.h" @@ -14,6 +21,7 @@ #include #include +#include #include #include #include @@ -36,6 +44,10 @@ 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 *; @@ -276,6 +288,16 @@ class WebServer final : public Controller, public Component, public AsyncWebHand /// Handle an index request under '/'. 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. + */ + void start_captive(); + void end_captive(); + bool is_captive() const { return this->captive_; } +#endif + /// Return the webserver configuration as JSON. json::SerializationBuffer<> get_config_json(); @@ -597,6 +619,10 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #elif USE_ARDUINO DeferredUpdateEventSourceList events_; #endif +#ifdef USE_WEBSERVER_CAPTIVE + std::unique_ptr dns_server_; + bool captive_{false}; +#endif #if USE_WEBSERVER_VERSION == 1 const char *css_url_{nullptr}; @@ -696,5 +722,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #endif }; +extern WebServer *global_web_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + } // namespace esphome::web_server #endif diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index fc575d1c06..010eedd9d1 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -1,8 +1,9 @@ from pathlib import Path import esphome.codegen as cg +from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv -from esphome.const import CONF_ID +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 @@ -80,3 +81,15 @@ async def to_code(config): cg.add_platformio_option("extra_scripts", ["pre:fix_rp2040_hash.py"]) # https://github.com/ESP32Async/ESPAsyncWebServer/blob/main/library.json cg.add_library("ESP32Async/ESPAsyncWebServer", "3.9.6") + + +# The DNS server used for captive portals on ESP32; other platforms use the Arduino +# DNSServer library. Its source is also guarded by USE_CAPTIVE_PORTAL / USE_WEBSERVER_CAPTIVE. +FILTER_SOURCE_FILES = filter_source_files_from_platform( + { + "dns_server_esp32_idf.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, + } +) diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.cpp b/esphome/components/web_server_base/dns_server_esp32_idf.cpp similarity index 95% rename from esphome/components/captive_portal/dns_server_esp32_idf.cpp rename to esphome/components/web_server_base/dns_server_esp32_idf.cpp index 56ad9f7176..34c1af375e 100644 --- a/esphome/components/captive_portal/dns_server_esp32_idf.cpp +++ b/esphome/components/web_server_base/dns_server_esp32_idf.cpp @@ -1,5 +1,5 @@ #include "dns_server_esp32_idf.h" -#ifdef USE_ESP32 +#if defined(USE_ESP32) && (defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE)) #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -7,9 +7,9 @@ #include #include -namespace esphome::captive_portal { +namespace esphome::web_server_base { -static const char *const TAG = "captive_portal.dns"; +static const char *const TAG = "web_server_base.dns"; // DNS constants static constexpr uint16_t DNS_PORT = 53; @@ -202,6 +202,6 @@ void DNSServer::process_next_request() { } } -} // namespace esphome::captive_portal +} // namespace esphome::web_server_base -#endif // USE_ESP32 +#endif // USE_ESP32 && (USE_CAPTIVE_PORTAL || USE_WEBSERVER_CAPTIVE) diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.h b/esphome/components/web_server_base/dns_server_esp32_idf.h similarity index 58% rename from esphome/components/captive_portal/dns_server_esp32_idf.h rename to esphome/components/web_server_base/dns_server_esp32_idf.h index b30856c204..8136187387 100644 --- a/esphome/components/captive_portal/dns_server_esp32_idf.h +++ b/esphome/components/web_server_base/dns_server_esp32_idf.h @@ -1,11 +1,15 @@ #pragma once -#ifdef USE_ESP32 +#include "esphome/core/defines.h" +// Small DNS server that answers every query with the access point address, so a +// phone joining the AP opens the captive portal or web_server page on its own. +// Shared by captive_portal and the web_server AP mode. +#if defined(USE_ESP32) && (defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE)) #include "esphome/core/helpers.h" #include "esphome/components/network/ip_address.h" #include "esphome/components/socket/socket.h" -namespace esphome::captive_portal { +namespace esphome::web_server_base { class DNSServer { public: @@ -27,6 +31,6 @@ class DNSServer { uint8_t buffer_[DNS_BUFFER_SIZE]; }; -} // namespace esphome::captive_portal +} // namespace esphome::web_server_base -#endif // USE_ESP32 +#endif // USE_ESP32 && (USE_CAPTIVE_PORTAL || USE_WEBSERVER_CAPTIVE) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 127eb50df1..f2e7a8f3f9 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -36,6 +36,9 @@ #ifdef USE_CAPTIVE_PORTAL #include "esphome/components/captive_portal/captive_portal.h" #endif +#ifdef USE_WEBSERVER_CAPTIVE +#include "esphome/components/web_server/web_server.h" +#endif #ifdef USE_IMPROV #include "esphome/components/esp32_improv/esp32_improv_component.h" @@ -731,6 +734,9 @@ void WiFiComponent::start() { captive_portal::global_captive_portal->start(); } #endif +#ifdef USE_WEBSERVER_CAPTIVE + web_server::global_web_server->start_captive(); +#endif #endif // USE_WIFI_AP } #ifdef USE_IMPROV @@ -867,6 +873,9 @@ void WiFiComponent::loop() { 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(); #endif } } @@ -1622,6 +1631,9 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { 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 ESP_LOGD(TAG, "Disabling AP"); this->wifi_mode_({}, false); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index bb4960aec7..df6916aa9e 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -353,6 +353,7 @@ #define USE_WEBSERVER #define USE_WEBSERVER_AUTH #define USE_WEBSERVER_AUTH_DIGEST +#define USE_WEBSERVER_CAPTIVE #define USE_WEBSERVER_OTA #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_WEBSERVER_GZIP @@ -466,6 +467,7 @@ #define USE_WEBSERVER #define USE_WEBSERVER_AUTH #define USE_WEBSERVER_AUTH_DIGEST +#define USE_WEBSERVER_CAPTIVE #define USE_WEBSERVER_PORT 80 // NOLINT #endif @@ -523,6 +525,7 @@ #define USE_WEBSERVER #define USE_WEBSERVER_AUTH #define USE_WEBSERVER_AUTH_DIGEST +#define USE_WEBSERVER_CAPTIVE #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_ESPHOME_TASK_LOG_BUFFER #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 diff --git a/tests/components/web_server/test-ap-mode.esp32-idf.yaml b/tests/components/web_server/test-ap-mode.esp32-idf.yaml new file mode 100644 index 0000000000..dc522e3178 --- /dev/null +++ b/tests/components/web_server/test-ap-mode.esp32-idf.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.esp8266-ard.yaml b/tests/components/web_server/test-ap-mode.esp8266-ard.yaml new file mode 100644 index 0000000000..dc522e3178 --- /dev/null +++ b/tests/components/web_server/test-ap-mode.esp8266-ard.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/unit_tests/components/test_web_server.py b/tests/unit_tests/components/test_web_server.py index f73f52fd64..5b4b8deb92 100644 --- a/tests/unit_tests/components/test_web_server.py +++ b/tests/unit_tests/components/test_web_server.py @@ -1,21 +1,20 @@ -"""Tests for web_server component helpers.""" +"""Tests for the web_server AP mode helpers.""" import logging import pytest from esphome.components.web_server import ( - _final_validate_ap_only, + _final_validate_ap_mode, + serve_captive, serve_local, wifi_is_ap_only, ) from esphome.const import ( CONF_AP, CONF_LOCAL, - CONF_MANUAL_IP, CONF_NETWORKS, CONF_SSID, - CONF_STATIC_IP, CONF_VERSION, CONF_WIFI, ) @@ -58,52 +57,45 @@ def test_serve_local( @pytest.mark.parametrize( - ("web_server_config", "full_config", "expected"), + ("web_server_config", "wifi_config", "has_captive_portal", "expected"), [ - ({CONF_VERSION: 2}, {CONF_WIFI: AP_ONLY}, "http://192.168.4.1/"), - ( - {CONF_VERSION: 2}, - {CONF_WIFI: {CONF_AP: {CONF_MANUAL_IP: {CONF_STATIC_IP: "10.0.0.1"}}}}, - "http://10.0.0.1/", - ), - ({CONF_VERSION: 2}, {CONF_WIFI: AP_ONLY}, "not a captive portal"), - ({CONF_VERSION: 2}, {CONF_WIFI: AP_ONLY}, "embedded in the firmware"), - ({CONF_VERSION: 2, CONF_LOCAL: False}, {CONF_WIFI: AP_ONLY}, "stays blank"), - ({CONF_VERSION: 2}, {CONF_WIFI: AP_FALLBACK}, None), - ({CONF_VERSION: 2}, {CONF_WIFI: STA_ONLY}, None), - ({CONF_VERSION: 2}, {}, None), + # AP only: local is implied, web_server is the captive portal. + ({CONF_VERSION: 2}, AP_ONLY, False, 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), + # 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), ], ) -def test_final_validate_ap_only_warning( +def test_serve_captive( web_server_config: dict, - full_config: dict, - expected: str | None, - caplog: pytest.LogCaptureFixture, + wifi_config: dict | None, + has_captive_portal: bool, + expected: bool, ) -> None: - """AP only WiFi with web_server warns and names the AP address; others stay quiet.""" - token = fv.full_config.set({"web_server": web_server_config, **full_config}) - try: - with caplog.at_level(logging.WARNING): - _final_validate_ap_only(web_server_config) - finally: - fv.full_config.reset(token) - if expected is None: - assert "AP only" not in caplog.text - else: - assert expected in caplog.text + assert serve_captive(web_server_config, wifi_config, has_captive_portal) is expected -def test_final_validate_ap_only_with_captive_portal( +def test_final_validate_ap_mode_warns_for_hosted_page( caplog: pytest.LogCaptureFixture, ) -> None: - """With captive_portal the AP is captive, so that clause is left out.""" - token = fv.full_config.set( - {"web_server": {CONF_VERSION: 2}, CONF_WIFI: AP_ONLY, "captive_portal": {}} - ) - try: - with caplog.at_level(logging.WARNING): - _final_validate_ap_only({CONF_VERSION: 2}) - finally: - fv.full_config.reset(token) - assert "AP only" in caplog.text - assert "captive portal" not in caplog.text + """AP only with an explicit local: false gets a warning; AP only default does not.""" + for web_server_config, expect_warning in ( + ({CONF_VERSION: 2, CONF_LOCAL: False}, True), + ({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