[web_server] Move the AP only warning into web_server and emit the script tag once

This commit is contained in:
J. Nick Koston
2026-08-19 13:07:19 -05:00
parent deb641d413
commit 427b83dc0a
4 changed files with 118 additions and 80 deletions
+43 -15
View File
@@ -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 += "<script type=module src=/0.js></script>"
html += "<esp-app></esp-app>"
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'<script src="{js_url}" onerror="document.body.innerText=\'{hint}\'"></script>'
elif js_url:
html += f'<script src="{js_url}"></script>'
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'<script src="{js_url}"{onerror}></script>'
html += "</body></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:
+4 -19
View File
@@ -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:
+71 -13
View File
@@ -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 '<script src="https://oi.esphome.io/v2/www.js" onerror="' in html
html = build_index_html({CONF_JS_URL: JS_URL, CONF_CSS_URL: ""})
assert f'<script src="{JS_URL}" onerror="' in html
assert "document.body.innerText='Could not download the web interface." in html
assert "local: true" in html
def test_build_index_html_hint_disabled() -> None:
"""The plain script tag is kept when the hint is not wanted (captive_portal)."""
html = build_index_html(
{CONF_JS_URL: "https://oi.esphome.io/v2/www.js", CONF_CSS_URL: ""},
offline_hint=False,
)
assert '<script src="https://oi.esphome.io/v2/www.js"></script>' in html
html = build_index_html({CONF_JS_URL: JS_URL, CONF_CSS_URL: ""}, offline_hint=False)
assert f'<script src="{JS_URL}"></script>' 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 "<script" not in html
@pytest.mark.parametrize(
("web_server_config", "full_config", "expect_warning"),
[
# AP only with a hosted page: warn.
({CONF_JS_URL: JS_URL}, {CONF_WIFI: {CONF_AP: {}}}, True),
# local: true serves the page from the device.
({CONF_JS_URL: JS_URL, CONF_LOCAL: True}, {CONF_WIFI: {CONF_AP: {}}}, False),
# No hosted script at all.
({CONF_JS_URL: ""}, {CONF_WIFI: {CONF_AP: {}}}, False),
# captive_portal serves its own local page.
(
{CONF_JS_URL: JS_URL},
{CONF_WIFI: {CONF_AP: {}}, "captive_portal": {}},
False,
),
# STA with AP fallback: the AP is rarely used, stay quiet.
(
{CONF_JS_URL: JS_URL},
{CONF_WIFI: {CONF_AP: {}, CONF_NETWORKS: [{CONF_SSID: "x"}]}},
False,
),
# No AP, or no wifi at all (ethernet).
(
{CONF_JS_URL: JS_URL},
{CONF_WIFI: {CONF_NETWORKS: [{CONF_SSID: "x"}]}},
False,
),
({CONF_JS_URL: JS_URL}, {}, False),
],
)
def test_final_validate_ap_only_hosted_ui_warning(
web_server_config: dict,
full_config: dict,
expect_warning: bool,
caplog: pytest.LogCaptureFixture,
) -> 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
-33
View File
@@ -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