diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 4bb6629da1..21162d4d59 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -41,6 +41,7 @@ from esphome.const import ( CONF_ID, CONF_IDENTITY, CONF_KEY, + CONF_LOCAL, CONF_MANUAL_IP, CONF_NETWORKS, CONF_ON_CONNECT, @@ -343,24 +344,38 @@ def _apply_min_auth_mode_default(config): return config -def final_validate(config): - has_sta = bool(config.get(CONF_NETWORKS, True)) +def final_validate(config: ConfigType) -> None: + has_sta = bool(config.get(CONF_NETWORKS)) 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 - has_web_server = "web_server" in full_config + web_server = full_config.get("web_server") 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 not has_web_server: + if has_ap and not has_captive_portal and web_server is None: _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_wifi.py b/tests/unit_tests/components/test_wifi.py index 3899b3d854..b625aafaf7 100644 --- a/tests/unit_tests/components/test_wifi.py +++ b/tests/unit_tests/components/test_wifi.py @@ -1,10 +1,13 @@ """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, ) @@ -17,6 +20,7 @@ from esphome.const import ( Platform, ) from esphome.core import EsphomeError, Lambda +import esphome.final_validate as fv @pytest.mark.parametrize( @@ -197,3 +201,32 @@ 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