From 427b83dc0a52f2d2f3d2a205088aa51ad79301b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 19 Aug 2026 13:07:19 -0500 Subject: [PATCH] [web_server] Move the AP only warning into web_server and emit the script tag once --- esphome/components/web_server/__init__.py | 58 +++++++++---- esphome/components/wifi/__init__.py | 23 +---- .../unit_tests/components/test_web_server.py | 84 ++++++++++++++++--- tests/unit_tests/components/test_wifi.py | 33 -------- 4 files changed, 118 insertions(+), 80 deletions(-) diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 8ab7be057f..bfa3cac5a1 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -11,6 +11,7 @@ from esphome.components.logger import request_log_listener from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID import esphome.config_validation as cv from esphome.const import ( + CONF_AP, CONF_AUTH, CONF_COMPRESSION, CONF_CSS_INCLUDE, @@ -23,6 +24,7 @@ from esphome.const import ( CONF_LOCAL, CONF_LOG, CONF_NAME, + CONF_NETWORKS, CONF_OTA, CONF_PASSWORD, CONF_PORT, @@ -31,6 +33,7 @@ from esphome.const import ( CONF_VERSION, CONF_WEB_SERVER, CONF_WEB_SERVER_ID, + CONF_WIFI, PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, @@ -203,7 +206,33 @@ def _final_validate_sorting(config: ConfigType) -> None: ) -FINAL_VALIDATE_SCHEMA = _final_validate_sorting +def _final_validate_ap_only_hosted_ui(config: ConfigType) -> None: + # AP only: the device is reachable only through its own AP, where browsers usually + # have no internet to download the hosted interface. Warn unless it is served locally. + if config.get(CONF_LOCAL) or not config[CONF_JS_URL]: + return + full_config = fv.full_config.get() + wifi_conf = full_config.get(CONF_WIFI) + ap_only = ( + wifi_conf is not None + and CONF_AP in wifi_conf + and not wifi_conf.get(CONF_NETWORKS) + ) + if not ap_only or "captive_portal" in full_config: + return + _LOGGER.warning( + "WiFi is AP only and web_server loads its interface from the internet, which " + "clients of the AP usually cannot reach, so the page stays blank. " + "Set 'local: true' under 'web_server:' or add 'captive_portal:'." + ) + + +def _final_validate(config: ConfigType) -> None: + _final_validate_sorting(config) + _final_validate_ap_only_hosted_ui(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate def _consume_web_server_sockets(config: ConfigType) -> ConfigType: @@ -344,19 +373,18 @@ def build_index_html(config: ConfigType, offline_hint: bool = True) -> str: if js_include: html += "" html += "" - js_url = config[CONF_JS_URL] - if js_url and offline_hint: - # The interface is downloaded from the internet. Show a hint instead of a blank - # page when the browser cannot reach it, which is common in WiFi AP mode. - # Kept short: this string lives in flash on every build without captive_portal. - hint = ( - "Could not download the web interface. This browser needs internet access, " - "or set local: true under web_server: in the YAML and reinstall so the " - "device serves it." - ) - html += f'' - elif js_url: - html += f'' + if js_url := config[CONF_JS_URL]: + onerror = "" + if offline_hint: + # The interface is downloaded from the internet. Show a hint instead of a + # blank page when the browser cannot reach it, which is common in WiFi AP + # mode. Kept short: it lives in flash on every build without captive_portal. + onerror = ( + " onerror=\"document.body.innerText='Could not download the web interface. " + "This browser needs internet access, or set local: true under web_server: " + "in the YAML.'\"" + ) + html += f'' html += "" return html @@ -397,7 +425,7 @@ async def to_code(config): # With captive_portal the AP serves its own local page, so the offline hint is # only useful without it. index_html = build_index_html( - config, offline_hint="captive_portal" not in CORE.config + config, offline_hint=not CORE.has_at_least_one_component("captive_portal") ) add_resource_as_progmem("INDEX_HTML", index_html, compress=False) else: diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 21162d4d59..4bb6629da1 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -41,7 +41,6 @@ from esphome.const import ( CONF_ID, CONF_IDENTITY, CONF_KEY, - CONF_LOCAL, CONF_MANUAL_IP, CONF_NETWORKS, CONF_ON_CONNECT, @@ -344,38 +343,24 @@ def _apply_min_auth_mode_default(config): return config -def final_validate(config: ConfigType) -> None: - has_sta = bool(config.get(CONF_NETWORKS)) +def final_validate(config): + has_sta = bool(config.get(CONF_NETWORKS, True)) has_ap = CONF_AP in config full_config = fv.full_config.get() has_improv = "esp32_improv" in full_config has_improv_serial = "improv_serial" in full_config has_captive_portal = "captive_portal" in full_config - web_server = full_config.get("web_server") + has_web_server = "web_server" in full_config if not (has_sta or has_ap or has_improv or has_improv_serial): raise cv.Invalid( "Please specify at least an SSID or an Access Point to create." ) - if has_ap and not has_captive_portal and web_server is None: + if has_ap and not has_captive_portal and not has_web_server: _LOGGER.warning( "WiFi AP is configured but neither captive_portal nor web_server is enabled. " "The AP will not be usable for configuration or monitoring. " "Add 'captive_portal:' or 'web_server:' to your configuration." ) - elif ( - has_ap - and not has_sta - and not has_captive_portal - and web_server is not None - and not web_server.get(CONF_LOCAL) - ): - # AP only: the device is reachable only through its own AP, where browsers - # usually have no internet to download the hosted web_server interface. - _LOGGER.warning( - "WiFi is AP only and web_server loads its interface from the internet, which " - "clients of the AP usually cannot reach, so the page stays blank. " - "Set 'local: true' under 'web_server:' or add 'captive_portal:'." - ) def _consume_wifi_sockets(config: ConfigType) -> ConfigType: diff --git a/tests/unit_tests/components/test_web_server.py b/tests/unit_tests/components/test_web_server.py index 0de1b8b9ed..dc0fb03d21 100644 --- a/tests/unit_tests/components/test_web_server.py +++ b/tests/unit_tests/components/test_web_server.py @@ -1,31 +1,89 @@ -"""Tests for the web_server component index page builder.""" +"""Tests for the web_server component index page builder and validation.""" -from esphome.components.web_server import build_index_html -from esphome.const import CONF_CSS_URL, CONF_JS_URL +import logging + +import pytest + +from esphome.components.web_server import ( + _final_validate_ap_only_hosted_ui, + build_index_html, +) +from esphome.const import ( + CONF_AP, + CONF_CSS_URL, + CONF_JS_URL, + CONF_LOCAL, + CONF_NETWORKS, + CONF_SSID, + CONF_WIFI, +) +import esphome.final_validate as fv + +JS_URL = "https://oi.esphome.io/v2/www.js" def test_build_index_html_has_offline_hint() -> None: """The hosted script tag shows a hint when the browser cannot download it.""" - html = build_index_html( - {CONF_JS_URL: "https://oi.esphome.io/v2/www.js", CONF_CSS_URL: ""} - ) - assert '' in html + html = build_index_html({CONF_JS_URL: JS_URL, CONF_CSS_URL: ""}, offline_hint=False) + assert f'' in html assert "onerror" not in html def test_build_index_html_without_js_url() -> None: """No hosted script and no hint when js_url is empty.""" html = build_index_html({CONF_JS_URL: "", CONF_CSS_URL: ""}) - assert "onerror" not in html assert " None: + """AP only configs with a hosted page get a hint to use local: true.""" + token = fv.full_config.set({"web_server": web_server_config, **full_config}) + try: + with caplog.at_level(logging.WARNING): + _final_validate_ap_only_hosted_ui(web_server_config) + finally: + fv.full_config.reset(token) + assert ("AP only" in caplog.text) is expect_warning diff --git a/tests/unit_tests/components/test_wifi.py b/tests/unit_tests/components/test_wifi.py index b625aafaf7..3899b3d854 100644 --- a/tests/unit_tests/components/test_wifi.py +++ b/tests/unit_tests/components/test_wifi.py @@ -1,13 +1,10 @@ """Tests for WiFi component public helpers.""" -import logging - import pytest from esphome.components.esp32 import const from esphome.components.wifi import ( check_placeholder_credentials, - final_validate, has_native_wifi, variant_has_wifi, ) @@ -20,7 +17,6 @@ from esphome.const import ( Platform, ) from esphome.core import EsphomeError, Lambda -import esphome.final_validate as fv @pytest.mark.parametrize( @@ -201,32 +197,3 @@ def test_check_placeholder_credentials_skips_template_ssid() -> None: """A templated (Lambda) SSID is not a string and is skipped.""" config = _wifi_config(networks=[{CONF_SSID: Lambda('return "x";')}]) assert check_placeholder_credentials(config) is None - - -@pytest.mark.parametrize( - ("wifi_config", "full_config", "expect_warning"), - [ - # AP only with a hosted web_server page: warn. - ({CONF_AP: {}}, {"web_server": {}}, True), - # local: true serves the page from the device. - ({CONF_AP: {}}, {"web_server": {"local": True}}, False), - # captive_portal serves its own local page. - ({CONF_AP: {}}, {"web_server": {}, "captive_portal": {}}, False), - # STA with AP fallback: the AP is rarely used, stay quiet. - ({CONF_AP: {}, CONF_NETWORKS: [{CONF_SSID: "x"}]}, {"web_server": {}}, False), - ], -) -def test_final_validate_ap_only_hosted_web_server_warning( - wifi_config: dict, - full_config: dict, - expect_warning: bool, - caplog: pytest.LogCaptureFixture, -) -> None: - """AP only configs with a hosted web_server page get a hint to use local: true.""" - token = fv.full_config.set({"wifi": wifi_config, **full_config}) - try: - with caplog.at_level(logging.WARNING): - final_validate(wifi_config) - finally: - fv.full_config.reset(token) - assert ("AP only" in caplog.text) is expect_warning