diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index e80f9e669f..599422a46b 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -6,6 +6,9 @@ #include "esphome/core/string_ref.h" #include "esphome/components/wifi/scan_list.h" #include "esphome/components/wifi/wifi_component.h" +#ifdef USE_PROVISIONING +#include "esphome/components/provisioning/provisioning.h" +#endif #include "captive_index.h" namespace esphome::captive_portal { @@ -78,6 +81,20 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { void CaptivePortal::setup() { // Disable loop by default - will be enabled when captive portal starts this->disable_loop(); +#ifdef USE_PROVISIONING + // The captive portal is a provisioning surface: once the provisioning window + // has closed, stop serving it. WiFi's own closed-callback shuts down the + // access point the portal runs on, and the gated fallback in WiFiComponent's + // loop() ensures neither is started again afterwards. + if (provisioning::global_provisioning_manager != nullptr) { + provisioning::global_provisioning_manager->add_on_closed_callback([this]() { + if (this->active_) { + ESP_LOGD(TAG, "Provisioning window closed; stopping captive portal"); + this->end(); + } + }); + } +#endif } void CaptivePortal::start() { this->base_->init(); diff --git a/esphome/components/provisioning/__init__.py b/esphome/components/provisioning/__init__.py index 9462bbb3b7..63faaf8eae 100644 --- a/esphome/components/provisioning/__init__.py +++ b/esphome/components/provisioning/__init__.py @@ -23,6 +23,8 @@ class ProvisioningData: sources: set[str] = field(default_factory=set) # Names of source components that have their credentials set in the config. hardcoded_credentials: set[str] = field(default_factory=set) + # True when WiFi is configured with an access point but no station credentials. + ap_without_sta: bool = False def _get_data() -> ProvisioningData: @@ -56,6 +58,17 @@ def report_hardcoded_credentials(name: str) -> None: _get_data().hardcoded_credentials.add(name) +def report_ap_without_sta() -> None: + """Record that WiFi runs an access point with no station credentials. + + The access point (and captive portal) shut down when the provisioning window + closes. On a device where that access point is the only network connection, + closing the window makes the device unreachable until it is power-cycled, so + `provisioning:` warns about this combination. + """ + _get_data().ap_without_sta = True + + CONFIG_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(ProvisioningManager), @@ -89,6 +102,13 @@ def _final_validate(config: ConfigType) -> None: "hardcoding them makes the window pointless.", ", ".join(sorted(data.hardcoded_credentials)), ) + if data.ap_without_sta: + _LOGGER.warning( + "'provisioning' is configured with a WiFi access point and no station " + "credentials. The access point shuts down when the provisioning window " + "closes, so if it is the device's only network connection, the device " + "will be unreachable until it is power-cycled." + ) FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 4bb6629da1..b8c6d774ac 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -445,10 +445,15 @@ def _report_provisioning_credentials(config): about this, since a device that uses a provisioning window should get its credentials on first connection instead. """ - if config.get(CONF_NETWORKS): - from esphome.components import provisioning + from esphome.components import provisioning + if config.get(CONF_NETWORKS): provisioning.report_hardcoded_credentials("wifi") + elif CONF_AP in config: + # An access point with no station credentials: the AP shuts down when the + # provisioning window closes, so `provisioning:` warns that the device may + # become unreachable until power-cycled. + provisioning.report_ap_without_sta() return config diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index d82929e5cb..82755f39f7 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -633,6 +633,21 @@ void WiFiComponent::setup() { this->configured_power_save_ = this->power_save_; #endif +#if defined(USE_PROVISIONING) && defined(USE_WIFI_AP) + // The access point is a provisioning surface: once the provisioning window has + // closed, shut it down (mirrors the teardown done on a successful connection). + // The captive portal registers its own closed-callback, and the fallback block + // in loop() is gated so neither is started again afterwards. + if (provisioning::global_provisioning_manager != nullptr) { + provisioning::global_provisioning_manager->add_on_closed_callback([this]() { + if (this->ap_setup_) { + ESP_LOGD(TAG, "Provisioning window closed; disabling AP"); + this->wifi_mode_({}, false); + } + }); + } +#endif + if (this->enable_on_boot_) { #ifdef USE_ESP32 this->wifi_lazy_init_(); @@ -854,7 +869,15 @@ void WiFiComponent::loop() { } #ifdef USE_WIFI_AP - if (this->has_ap() && !this->ap_setup_) { + bool provisioning_closed = false; +#ifdef USE_PROVISIONING + // Once the provisioning window has closed, don't bring up the fallback AP (or + // the captive portal on it) - the device must stay unprovisionable until it is + // power-cycled. + provisioning_closed = + provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed(); +#endif + if (this->has_ap() && !this->ap_setup_ && !provisioning_closed) { if (this->ap_timeout_ != 0 && (now - this->last_connected_ > this->ap_timeout_)) { ESP_LOGI(TAG, "Starting fallback AP"); this->setup_ap_config_(); diff --git a/tests/component_tests/provisioning/test_provisioning.py b/tests/component_tests/provisioning/test_provisioning.py index d3a3771bbc..83c31aeca6 100644 --- a/tests/component_tests/provisioning/test_provisioning.py +++ b/tests/component_tests/provisioning/test_provisioning.py @@ -11,6 +11,7 @@ from esphome.components.provisioning import ( CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA, register_source, + report_ap_without_sta, report_hardcoded_credentials, ) from esphome.const import CONF_TIMEOUT, PlatformFramework @@ -66,6 +67,32 @@ def test_provisioning_no_warning_without_hardcoded_credentials( assert "credentials" not in caplog.text +def test_provisioning_warns_on_ap_without_sta( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """An access point with no station credentials triggers a reachability warning.""" + set_core_config(PlatformFramework.ESP32_IDF) + register_source("network") + report_ap_without_sta() + with caplog.at_level(logging.WARNING): + FINAL_VALIDATE_SCHEMA({}) + assert "access point" in caplog.text + assert "unreachable" in caplog.text + + +def test_provisioning_no_warning_without_ap( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, +) -> None: + """No reachability warning when no AP-without-station setup is reported.""" + set_core_config(PlatformFramework.ESP32_IDF) + register_source("network") + with caplog.at_level(logging.WARNING): + FINAL_VALIDATE_SCHEMA({}) + assert "access point" not in caplog.text + + def test_provisioning_rejects_zero_timeout( set_core_config: SetCoreConfigCallable, ) -> None: diff --git a/tests/components/provisioning/test.esp32-idf.yaml b/tests/components/provisioning/test.esp32-idf.yaml index 24168881fc..baa3aa8f68 100644 --- a/tests/components/provisioning/test.esp32-idf.yaml +++ b/tests/components/provisioning/test.esp32-idf.yaml @@ -1,6 +1,7 @@ # Exercises the provisioning window: api registers as a provisioning source -# (encryption enabled, no key), the on_timeout automation, and the wifi + -# esp32_improv cross-component guards. improv_serial is intentionally NOT gated. +# (encryption enabled, no key), the on_timeout automation, and the wifi (AP + +# captive portal) and esp32_improv cross-component guards. improv_serial is +# intentionally NOT gated. provisioning: timeout: 1min on_timeout: @@ -13,6 +14,10 @@ api: wifi: ssid: MySSID password: password1 + ap: + ssid: MyAP + +captive_portal: improv_serial: diff --git a/tests/components/provisioning/test.esp8266-ard.yaml b/tests/components/provisioning/test.esp8266-ard.yaml index 4188c00bef..2666477658 100644 --- a/tests/components/provisioning/test.esp8266-ard.yaml +++ b/tests/components/provisioning/test.esp8266-ard.yaml @@ -1,5 +1,6 @@ # Provisioning window on ESP8266 (no BLE Improv): api as a provisioning source -# and the wifi reboot guard. improv_serial is present and intentionally NOT gated. +# and the wifi (AP + captive portal) guards. improv_serial is present and +# intentionally NOT gated. provisioning: timeout: 1min on_timeout: @@ -12,5 +13,9 @@ api: wifi: ssid: MySSID password: password1 + ap: + ssid: MyAP + +captive_portal: improv_serial: