[provisioning] Shut down the Wi-Fi AP and captive portal when the window closes (#17466)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Keith Burzinski
2026-08-27 15:16:25 +12:00
committed by GitHub
co-authored by J. Nick Koston
parent b99e7f5ae2
commit cdd892a526
7 changed files with 108 additions and 6 deletions
@@ -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();
@@ -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
+7 -2
View File
@@ -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
+24 -1
View File
@@ -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_();
@@ -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:
@@ -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:
@@ -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: