diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 2710239dd4..dfdc02158a 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -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: diff --git a/tests/unit_tests/components/test_web_server.py b/tests/unit_tests/components/test_web_server.py index 5b9541c0aa..e0ce9a6cc9 100644 --- a/tests/unit_tests/components/test_web_server.py +++ b/tests/unit_tests/components/test_web_server.py @@ -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