mirror of
https://github.com/esphome/esphome.git
synced 2026-08-28 08:43:27 +00:00
[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:
co-authored by
J. Nick Koston
parent
b99e7f5ae2
commit
cdd892a526
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user