diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 286c55fbdc..74feec7915 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -408,9 +408,28 @@ def _final_validate_ap_mode(config: ConfigType) -> None: "web_server will act as a captive portal while the %saccess point is active.", "" if ap_only else "fallback ", ) - if not ap_only: - return - if not local: + # An embedded interface on an AP (AP only, or a fallback with local: true) that is not + # on port 80 cannot be a captive portal; say so, unless captive_portal has that role. + if ( + local + and config[CONF_PORT] != 80 + and wifi_config is not None + and CONF_AP in wifi_config + and "captive_portal" not in full_config + ): + 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]) + _LOGGER.warning( + "web_server uses port %d, so the interface cannot open automatically on the " + "%saccess point (captive portal detection only works on port 80); open " + "http://%s:%d/ manually, or remove 'port:' to use 80.", + config[CONF_PORT], + "" if ap_only else "fallback ", + ap_ip, + config[CONF_PORT], + ) + if ap_only and not local: _LOGGER.warning( "WiFi is AP only and the web_server interface is loaded from the internet, " "which browsers on the access point usually cannot reach; the page stays " @@ -419,18 +438,6 @@ def _final_validate_ap_mode(config: ConfigType) -> None: if config.get(CONF_LOCAL) is False else "Migrate to version 2 or 3", ) - elif config[CONF_PORT] != 80: - 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]) - _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://%s:%d/ manually, or remove 'port:' to use 80.", - config[CONF_PORT], - ap_ip, - config[CONF_PORT], - ) def _final_validate(config: ConfigType) -> None: diff --git a/esphome/components/web_server_base/captive_dns.h b/esphome/components/web_server_base/captive_dns.h index c1d1cb0795..0d027d3c40 100644 --- a/esphome/components/web_server_base/captive_dns.h +++ b/esphome/components/web_server_base/captive_dns.h @@ -20,7 +20,7 @@ namespace esphome::web_server_base { // The server object only exists while running, so an idle owner (AP not up) pays one pointer. class CaptiveDNS { public: - /// Returns false when the DNS server could not start; nothing is kept, so a later start retries. + /// Returns false when the DNS server could not start; nothing is kept, so is_running() stays false. bool start(const network::IPAddress &ip) { if (this->dns_server_ != nullptr) return true; diff --git a/tests/unit_tests/components/test_web_server.py b/tests/unit_tests/components/test_web_server.py index efa79d5707..3015527c18 100644 --- a/tests/unit_tests/components/test_web_server.py +++ b/tests/unit_tests/components/test_web_server.py @@ -95,18 +95,40 @@ def test_final_validate_ap_mode_warns_for_hosted_page( assert ("stays blank" in caplog.text) is expect_warning +@pytest.mark.parametrize( + ("web_server_config", "wifi_config", "expected"), + [ + # AP only: local is implied, so the port alone blocks captive mode. + ({CONF_VERSION: 2, CONF_PORT: 8080}, AP_ONLY, "on the access point"), + # Fallback AP with local: true opted into captive mode; say why it is skipped. + ( + {CONF_VERSION: 2, CONF_PORT: 8080, CONF_LOCAL: True}, + AP_FALLBACK, + "on the fallback access point", + ), + # Fallback AP without local: hosted page, nothing was promised. + ({CONF_VERSION: 2, CONF_PORT: 8080}, AP_FALLBACK, None), + ], +) def test_final_validate_ap_mode_warns_for_non_default_port( + web_server_config: dict, + wifi_config: dict, + expected: str | None, 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}) + token = fv.full_config.set( + {"web_server": web_server_config, CONF_WIFI: wifi_config} + ) try: with caplog.at_level(logging.WARNING): - _final_validate_ap_mode(config) + _final_validate_ap_mode(web_server_config) finally: fv.full_config.reset(token) - assert "cannot open automatically" in caplog.text + if expected is None: + assert "cannot open automatically" not in caplog.text + return + assert expected in caplog.text assert "http://192.168.4.1:8080/" in caplog.text