[web_server] Limit captive mode to port 80 and hint at the manual URL otherwise

This commit is contained in:
J. Nick Koston
2026-08-19 15:22:04 -05:00
parent 676ecae469
commit 50f50aa7e0
2 changed files with 37 additions and 3 deletions
+16 -1
View File
@@ -363,10 +363,13 @@ def serve_local(config: ConfigType, wifi_config: ConfigType | None) -> bool:
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."""
an access point, unless captive_portal (which owns that role) is configured. Only on
port 80: the OS captive portal probes and the DHCP portal URI always use port 80, so
a portal on another port could never be discovered."""
wifi_config = full_config.get(CONF_WIFI)
return (
"captive_portal" not in full_config
and config[CONF_PORT] == 80
and wifi_config is not None
and CONF_AP in wifi_config
and serve_local(config, wifi_config)
@@ -391,6 +394,18 @@ def _final_validate_ap_mode(config: ConfigType) -> None:
"blank. Remove 'local: false', or migrate off version 1, so the interface "
"is embedded in the firmware."
)
elif (
wifi_is_ap_only(wifi_config)
and serve_local(config, wifi_config)
and config[CONF_PORT] != 80
):
_LOGGER.warning(
"WiFi is AP only and web_server uses port %d. The interface cannot open "
"automatically on the access point (captive portal detection only works on "
"port 80); open http://192.168.4.1:%d/ manually, or remove 'port:' to use 80.",
config[CONF_PORT],
config[CONF_PORT],
)
def _final_validate(config: ConfigType) -> None:
+21 -2
View File
@@ -13,6 +13,7 @@ from esphome.const import (
CONF_AP,
CONF_LOCAL,
CONF_NETWORKS,
CONF_PORT,
CONF_SSID,
CONF_VERSION,
CONF_WIFI,
@@ -52,6 +53,8 @@ def test_serve_local(
[
# AP only: local is implied, web_server is the captive portal.
({CONF_VERSION: 2}, {CONF_WIFI: AP_ONLY}, True),
# Captive portal probes only work on port 80.
({CONF_VERSION: 2, CONF_PORT: 8080}, {CONF_WIFI: AP_ONLY}, False),
# AP fallback needs an explicit local: true to be captive.
({CONF_VERSION: 2}, {CONF_WIFI: AP_FALLBACK}, False),
({CONF_VERSION: 2, CONF_LOCAL: True}, {CONF_WIFI: AP_FALLBACK}, True),
@@ -67,6 +70,7 @@ def test_serve_local(
def test_serve_captive(
web_server_config: dict, full_config: dict, expected: bool
) -> None:
web_server_config.setdefault(CONF_PORT, 80)
assert serve_captive(web_server_config, full_config) is expected
@@ -74,9 +78,9 @@ def test_serve_captive(
("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),
({CONF_VERSION: 2, CONF_PORT: 80, CONF_LOCAL: False}, True),
# Default: embedded and captive, nothing to warn about.
({CONF_VERSION: 2}, False),
({CONF_VERSION: 2, CONF_PORT: 80}, False),
],
)
def test_final_validate_ap_mode_warns_for_hosted_page(
@@ -89,3 +93,18 @@ def test_final_validate_ap_mode_warns_for_hosted_page(
finally:
fv.full_config.reset(token)
assert ("stays blank" in caplog.text) is expect_warning
def test_final_validate_ap_mode_warns_for_non_default_port(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Captive portal detection needs port 80; other ports get a hint, not captive mode."""
config = {CONF_VERSION: 2, CONF_PORT: 8080}
token = fv.full_config.set({"web_server": config, CONF_WIFI: AP_ONLY})
try:
with caplog.at_level(logging.WARNING):
_final_validate_ap_mode(config)
finally:
fv.full_config.reset(token)
assert "cannot open automatically" in caplog.text
assert "http://192.168.4.1:8080/" in caplog.text