[provisioning] Add provisioning window (#17152)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Keith Burzinski
2026-07-08 01:01:20 -05:00
committed by GitHub
parent 93bc02b308
commit 9c40ed5d71
24 changed files with 724 additions and 49 deletions
+1
View File
@@ -404,6 +404,7 @@ esphome/components/pn7160_i2c/* @jesserockz @kbx81
esphome/components/pn7160_spi/* @jesserockz @kbx81
esphome/components/power_supply/* @esphome/core
esphome/components/preferences/* @esphome/core
esphome/components/provisioning/* @esphome/core
esphome/components/psram/* @esphome/core
esphome/components/pulse_meter/* @cstaahl @stevebaxter @TrentHouliston
esphome/components/pvvx_mithermometer/* @pasiz
+18
View File
@@ -112,6 +112,23 @@ CONF_MAX_SEND_QUEUE = "max_send_queue"
CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only"
def _register_provisioning_source(config: ConfigType) -> ConfigType:
"""Register the API as a provisioning source when encryption is enabled.
With no ``key`` the device boots unprovisioned and is set up on first
connection; a YAML ``key`` means it is born provisioned. Either way the API
drives the provisioning manager, so it counts as a source for `provisioning:`.
A hardcoded ``key`` is reported so `provisioning:` can warn about it.
"""
if (encryption := config.get(CONF_ENCRYPTION)) is not None:
from esphome.components import provisioning
provisioning.register_source("api")
if CONF_KEY in encryption:
provisioning.report_hardcoded_credentials("api")
return config
def validate_encryption_key(value):
value = cv.string_strict(value)
try:
@@ -337,6 +354,7 @@ CONFIG_SCHEMA = cv.All(
).extend(cv.COMPONENT_SCHEMA),
cv.rename_key(CONF_SERVICES, CONF_ACTIONS),
_consume_api_sockets,
_register_provisioning_source,
)
+14
View File
@@ -158,6 +158,16 @@ message AuthenticationResponse {
bool invalid_password = 1;
}
// Reason a party is requesting the connection be closed.
enum DisconnectReason {
// No specific reason / not provided (default for older peers).
DISCONNECT_REASON_UNSPECIFIED = 0;
// The device's provisioning window has expired. The device must be reset
// (power-cycled) to reopen the provisioning window before it will accept a
// connection again.
DISCONNECT_REASON_PROVISIONING_CLOSED = 1;
}
// Request to close the connection.
// Can be sent by both the client and server
message DisconnectRequest {
@@ -166,6 +176,10 @@ message DisconnectRequest {
option (no_delay) = true;
// Do not close the connection before the acknowledgement arrives
// Optional reason the connection is being closed. Older peers that do not
// send this field will report DISCONNECT_REASON_UNSPECIFIED (0).
DisconnectReason reason = 1;
}
message DisconnectResponse {
+27 -1
View File
@@ -25,6 +25,9 @@
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "esphome/core/version.h"
#ifdef USE_PROVISIONING
#include "esphome/components/provisioning/provisioning.h"
#endif
#ifdef USE_DEEP_SLEEP
#include "esphome/components/deep_sleep/deep_sleep_component.h"
@@ -1724,6 +1727,19 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
resp.server_info = ESPHOME_VERSION_REF;
resp.name = StringRef(App.get_name());
#ifdef USE_PROVISIONING
if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) {
// The provisioning window has closed without the device being provisioned.
// Acknowledge the hello so the client can read the server name, then request
// disconnect with the reason. Authentication is intentionally not completed.
this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Provisioning closed; rejecting connection"));
this->send_message(resp);
DisconnectRequest req;
req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED;
return this->send_message(req);
}
#endif
// Auto-authenticate - password auth was removed in ESPHome 2026.1.0
this->complete_authentication_();
@@ -1874,7 +1890,8 @@ void APIConnection::on_hello_request(const HelloRequest &msg) {
this->on_fatal_error();
}
}
void APIConnection::on_disconnect_request() {
void APIConnection::on_disconnect_request(const DisconnectRequest & /*msg*/) {
// The reason is informational when a client disconnects us; we always ack and close.
if (!this->send_disconnect_response_()) {
this->on_fatal_error();
}
@@ -2002,6 +2019,15 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio
NoiseEncryptionSetKeyResponse resp;
resp.success = false;
#ifdef USE_PROVISIONING
// Refuse to set a key once the provisioning window has closed (defense in depth;
// such connections are already rejected at hello).
if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) {
ESP_LOGW(TAG, "Provisioning closed; rejecting key set");
return this->send_message(resp);
}
#endif
psk_t psk{};
if (msg.key_len == 0) {
if (this->parent_->clear_noise_psk(true)) {
+1 -1
View File
@@ -259,7 +259,7 @@ class APIConnection final : public APIServerConnectionBase {
void on_get_time_response(const GetTimeResponse &value);
#endif
void on_hello_request(const HelloRequest &msg);
void on_disconnect_request();
void on_disconnect_request(const DisconnectRequest &msg);
void on_ping_request();
void on_device_info_request();
void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); }
+20
View File
@@ -47,6 +47,26 @@ uint32_t HelloResponse::calculate_size() const {
size += 2 + this->name.size();
return size;
}
bool DisconnectRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
switch (field_id) {
case 1:
this->reason = static_cast<enums::DisconnectReason>(value);
break;
default:
return false;
}
return true;
}
uint8_t *DisconnectRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(this->reason));
return pos;
}
uint32_t DisconnectRequest::calculate_size() const {
uint32_t size = 0;
size += this->reason ? 2 : 0;
return size;
}
#ifdef USE_AREAS
uint8_t *AreaInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
+10 -2
View File
@@ -11,6 +11,10 @@ namespace esphome::api {
namespace enums {
enum DisconnectReason : uint32_t {
DISCONNECT_REASON_UNSPECIFIED = 0,
DISCONNECT_REASON_PROVISIONING_CLOSED = 1,
};
enum SerialProxyPortType : uint32_t {
SERIAL_PROXY_PORT_TYPE_TTL = 0,
SERIAL_PROXY_PORT_TYPE_RS232 = 1,
@@ -427,18 +431,22 @@ class HelloResponse final : public ProtoMessage {
protected:
};
class DisconnectRequest final : public ProtoMessage {
class DisconnectRequest final : public ProtoDecodableMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 5;
static constexpr uint8_t ESTIMATED_SIZE = 0;
static constexpr uint8_t ESTIMATED_SIZE = 2;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("disconnect_request"); }
#endif
enums::DisconnectReason reason{};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;
};
class DisconnectResponse final : public ProtoMessage {
public:
+12 -1
View File
@@ -125,6 +125,16 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint
}
#pragma GCC diagnostic pop
template<> const char *proto_enum_to_string<enums::DisconnectReason>(enums::DisconnectReason value) {
switch (value) {
case enums::DISCONNECT_REASON_UNSPECIFIED:
return ESPHOME_PSTR("DISCONNECT_REASON_UNSPECIFIED");
case enums::DISCONNECT_REASON_PROVISIONING_CLOSED:
return ESPHOME_PSTR("DISCONNECT_REASON_PROVISIONING_CLOSED");
default:
return ESPHOME_PSTR("UNKNOWN");
}
}
template<> const char *proto_enum_to_string<enums::SerialProxyPortType>(enums::SerialProxyPortType value) {
switch (value) {
case enums::SERIAL_PROXY_PORT_TYPE_TTL:
@@ -864,7 +874,8 @@ const char *HelloResponse::dump_to(DumpBuffer &out) const {
return out.c_str();
}
const char *DisconnectRequest::dump_to(DumpBuffer &out) const {
out.append_p(ESPHOME_PSTR("DisconnectRequest {}"));
MessageDumpHelper helper(out, ESPHOME_PSTR("DisconnectRequest"));
dump_field(out, ESPHOME_PSTR("reason"), static_cast<enums::DisconnectReason>(this->reason));
return out.c_str();
}
const char *DisconnectResponse::dump_to(DumpBuffer &out) const {
+4 -2
View File
@@ -51,10 +51,12 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
case DisconnectRequest::MESSAGE_TYPE: {
DisconnectRequest msg;
msg.decode(msg_data, msg_size);
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_disconnect_request"));
this->log_receive_message_(LOG_STR("on_disconnect_request"), msg);
#endif
this->on_disconnect_request();
this->on_disconnect_request(msg);
break;
}
case DisconnectResponse::MESSAGE_TYPE: {
+1 -1
View File
@@ -21,7 +21,7 @@ class APIServerConnectionBase {
void on_hello_request(const HelloRequest &value){};
void on_disconnect_request(){};
void on_disconnect_request(const DisconnectRequest &value){};
void on_disconnect_response(){};
void on_ping_request(){};
void on_ping_response(){};
+51 -10
View File
@@ -107,8 +107,30 @@ void APIServer::setup() {
// Initialize last_connected_ for reboot timeout tracking
this->last_connected_ = App.get_loop_component_start_time();
// Set warning status if reboot timeout is enabled
if (this->reboot_timeout_ != 0) {
#if defined(USE_PROVISIONING) && defined(USE_API_NOISE)
// Register with the provisioning manager (provisioning:) as a source and
// report our current state (provisioned == an encryption key is set). When the
// window closes, disconnect any client still attempting to provision so it learns
// the reason. The manager owns the timeout, window state and on_timeout automation.
if (provisioning::global_provisioning_manager != nullptr) {
this->provisioning_source_ = provisioning::global_provisioning_manager->register_source();
provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_,
this->noise_ctx_.has_psk());
provisioning::global_provisioning_manager->add_on_closed_callback([this]() {
for (auto &c : this->active_clients()) {
DisconnectRequest req;
req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED;
// Best-effort: if the send buffer is full the reason is dropped, but the
// client still learns the window is closed when it reconnects (rejected at
// hello) or via the socket close.
c->send_message(req);
}
});
}
#endif
// Set warning status if reboot timeout is enabled (suppressed while provisioning
// is pending so the device waits to be onboarded instead of rebooting).
if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
this->status_set_warning(LOG_STR("waiting for client connection"));
}
}
@@ -121,8 +143,10 @@ void APIServer::loop() {
if (this->api_connection_count_ == 0) {
// Check reboot timeout - done in loop to avoid scheduler heap churn
// (cancelled scheduler items sit in heap memory until their scheduled time)
if (this->reboot_timeout_ != 0) {
// (cancelled scheduler items sit in heap memory until their scheduled time).
// Suppressed while a provisioning window is pending so the device waits to be
// onboarded / reset instead of rebooting itself; resumes once provisioned.
if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
const uint32_t now = App.get_loop_component_start_time();
if (now - this->last_connected_ > this->reboot_timeout_) {
ESP_LOGE(TAG, "No clients; rebooting");
@@ -194,7 +218,8 @@ void APIServer::remove_client_(uint8_t client_index) {
this->clients_[last_index].reset();
// Last client disconnected - set warning and start tracking for reboot timeout
if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0) {
// (suppressed while provisioning is pending - see loop()).
if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
this->status_set_warning(LOG_STR("waiting for client connection"));
this->last_connected_ = App.get_loop_component_start_time();
}
@@ -232,7 +257,7 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
conn->start();
// First client connected - clear warning and update timestamp
if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0) {
if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
this->status_clear_warning();
this->last_connected_ = App.get_loop_component_start_time();
}
@@ -572,8 +597,16 @@ bool APIServer::save_noise_psk(psk_t psk, bool make_active) {
}
SavedNoisePsk new_saved_psk{psk};
return this->update_noise_psk_(new_saved_psk, LOG_STR("Noise PSK saved"), LOG_STR("Failed to save Noise PSK"),
make_active);
bool result = this->update_noise_psk_(new_saved_psk, LOG_STR("Noise PSK saved"), LOG_STR("Failed to save Noise PSK"),
make_active);
#ifdef USE_PROVISIONING
// The device now has a key; report provisioned so the provisioning window is
// satisfied and the reboot timeout resumes normal operation.
if (result && provisioning::global_provisioning_manager != nullptr) {
provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_, true);
}
#endif
return result;
#endif
}
bool APIServer::clear_noise_psk(bool make_active) {
@@ -584,8 +617,16 @@ bool APIServer::clear_noise_psk(bool make_active) {
return false;
#else
SavedNoisePsk empty_psk{};
return this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"),
make_active);
bool result = this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"),
make_active);
#ifdef USE_PROVISIONING
// The key was cleared; report unprovisioned so a subsequent reboot reopens the
// provisioning window.
if (result && provisioning::global_provisioning_manager != nullptr) {
provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_, false);
}
#endif
return result;
#endif
}
#endif
+20 -1
View File
@@ -14,6 +14,9 @@
#include "esphome/core/controller.h"
#include "esphome/core/log.h"
#include "esphome/core/string_ref.h"
#ifdef USE_PROVISIONING
#include "esphome/components/provisioning/provisioning.h"
#endif
#ifdef USE_LOGGER
#include "esphome/components/logger/logger.h"
#endif
@@ -255,6 +258,19 @@ class APIServer final : public Component,
// Remove a disconnected client by index. Swaps with the last populated slot and resets it.
void __attribute__((noinline)) remove_client_(uint8_t client_index);
#ifdef USE_PROVISIONING
// True while a configured provisioning window is still pending (the device is
// unprovisioned). Suppresses the reboot timeout and its warning so the device is
// not auto-rebooted while waiting to be provisioned. False when no provisioning
// window is configured.
bool provisioning_pending_() const {
return provisioning::global_provisioning_manager != nullptr &&
provisioning::global_provisioning_manager->window_pending();
}
#else
bool provisioning_pending_() const { return false; }
#endif
#ifdef USE_API_NOISE
bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg,
bool make_active);
@@ -332,7 +348,10 @@ class APIServer final : public Component,
uint8_t listen_backlog_{4};
bool shutting_down_ = false;
uint8_t api_connection_count_{0};
// 7 bytes used, 1 byte padding
#if defined(USE_PROVISIONING) && defined(USE_API_NOISE)
// Index assigned by the provisioning manager for reporting this transport's state.
uint8_t provisioning_source_{0};
#endif
#ifdef USE_API_NOISE
APINoiseContext noise_ctx_;
@@ -7,6 +7,10 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#ifdef USE_PROVISIONING
#include "esphome/components/provisioning/provisioning.h"
#endif
#ifdef USE_ESP32
namespace esphome::esp32_improv {
@@ -41,6 +45,15 @@ void ESP32ImprovComponent::setup() {
#endif
global_ble_server->on_disconnect([this](uint16_t conn_id) { this->set_error_(improv::ERROR_NONE); });
#ifdef USE_PROVISIONING
if (provisioning::global_provisioning_manager != nullptr) {
provisioning::global_provisioning_manager->add_on_closed_callback([this]() {
ESP_LOGD(TAG, "Provisioning window closed; stopping Improv");
this->stop();
});
}
#endif
// Start with loop disabled - will be enabled by start() when needed
this->disable_loop();
}
@@ -282,6 +295,15 @@ void ESP32ImprovComponent::start() {
if (this->should_start_ || this->state_ != improv::STATE_STOPPED)
return;
#ifdef USE_PROVISIONING
// Don't (re)start advertising once the provisioning window has closed - e.g. when
// wifi tries to restart Improv after the window expired at runtime.
if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) {
ESP_LOGD(TAG, "Provisioning window closed; not starting Improv");
return;
}
#endif
ESP_LOGD(TAG, "Setting Improv to start");
this->should_start_ = true;
this->enable_loop();
@@ -338,6 +360,15 @@ void ESP32ImprovComponent::process_incoming_data_() {
this->incoming_data_.clear();
return;
}
#ifdef USE_PROVISIONING
if (provisioning::global_provisioning_manager != nullptr &&
provisioning::global_provisioning_manager->closed()) {
ESP_LOGW(TAG, "Provisioning window closed; refusing settings");
this->set_error_(improv::ERROR_NOT_AUTHORIZED);
this->incoming_data_.clear();
return;
}
#endif
if (wifi::global_wifi_component->is_disabled()) {
// Wi-Fi is disabled, so we can't provision. Respond immediately
// instead of letting the client wait out its provisioning timeout.
+47 -28
View File
@@ -25,6 +25,20 @@ NetworkComponent = network_ns.class_("NetworkComponent", cg.Component)
IPAddress = network_ns.class_("IPAddress")
def _register_provisioning_source(config: ConfigType) -> ConfigType:
"""Register network connectivity as a provisioning source.
The network component is auto-loaded whenever an interface (wifi, ethernet, ...)
is configured, so a device with connectivity always has this source: it is
considered provisioned once it has connected via any interface, and
`provisioning:` is valid without another source.
"""
from esphome.components import provisioning
provisioning.register_source("network")
return config
def ip_address_literal(ip: str | int | None) -> cg.MockObj:
"""Generate an IPAddress with compile-time initialization instead of runtime parsing.
@@ -128,36 +142,41 @@ def validate_ipv6(value: bool) -> bool:
return value
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(NetworkComponent),
cv.SplitDefault(
CONF_ENABLE_IPV6,
bk72xx=False,
esp32=False,
esp8266=False,
host=False,
rp2=False,
nrf52=True,
): cv.All(
cv.boolean,
cv.Any(
cv.require_framework_version(
bk72xx_arduino=cv.Version(1, 7, 0),
esp_idf=cv.Version(0, 0, 0),
esp32_arduino=cv.Version(0, 0, 0),
esp8266_arduino=cv.Version(0, 0, 0),
host=cv.Version(0, 0, 0),
rp2_arduino=cv.Version(0, 0, 0),
nrf52_zephyr=cv.Version(0, 0, 0),
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(NetworkComponent),
cv.SplitDefault(
CONF_ENABLE_IPV6,
bk72xx=False,
esp32=False,
esp8266=False,
host=False,
rp2=False,
nrf52=True,
): cv.All(
cv.boolean,
cv.Any(
cv.require_framework_version(
bk72xx_arduino=cv.Version(1, 7, 0),
esp_idf=cv.Version(0, 0, 0),
esp32_arduino=cv.Version(0, 0, 0),
esp8266_arduino=cv.Version(0, 0, 0),
host=cv.Version(0, 0, 0),
rp2_arduino=cv.Version(0, 0, 0),
nrf52_zephyr=cv.Version(0, 0, 0),
),
cv.boolean_false,
),
cv.boolean_false,
validate_ipv6,
),
validate_ipv6,
),
cv.Optional(CONF_MIN_IPV6_ADDR_COUNT, default=0): cv.positive_int,
cv.Optional(CONF_ENABLE_HIGH_PERFORMANCE): cv.All(cv.boolean, cv.only_on_esp32),
}
cv.Optional(CONF_MIN_IPV6_ADDR_COUNT, default=0): cv.positive_int,
cv.Optional(CONF_ENABLE_HIGH_PERFORMANCE): cv.All(
cv.boolean, cv.only_on_esp32
),
}
),
_register_provisioning_source,
)
+104
View File
@@ -0,0 +1,104 @@
from dataclasses import dataclass, field
import logging
from esphome import automation
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_ON_TIMEOUT, CONF_TIMEOUT
from esphome.core import CORE
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/core"]
DOMAIN = "provisioning"
_LOGGER = logging.getLogger(__name__)
provisioning_ns = cg.esphome_ns.namespace("provisioning")
ProvisioningManager = provisioning_ns.class_("ProvisioningManager", cg.Component)
@dataclass
class ProvisioningData:
# Names of the components that registered as a provisioning source this run.
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)
def _get_data() -> ProvisioningData:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = ProvisioningData()
return CORE.data[DOMAIN]
def register_source(name: str) -> None:
"""Record that ``name`` is a provisioning source for this configuration.
A provisioning-capable component (a transport that boots unprovisioned and is
set up by the controller on first connection, or a network interface that
provisions once connected) calls this while its own config is being processed,
typically from a schema validator. `provisioning:` then confirms at least one
source is present without inspecting the full config or knowing about any
specific component. State lives in CORE.data, which is cleared between runs.
"""
_get_data().sources.add(name)
def report_hardcoded_credentials(name: str) -> None:
"""Record that source component ``name`` has its credentials set in the config.
A source component calls this from its own validator when it finds baked-in
credentials (a WiFi SSID/password, an API encryption key, ...). `provisioning:`
warns about these, since a device that ships with credentials does not need a
provisioning window. The warning is emitted here, by `provisioning:`, so the
source components stay unaware of it.
"""
_get_data().hardcoded_credentials.add(name)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(ProvisioningManager),
cv.Required(CONF_TIMEOUT): cv.All(
cv.positive_not_null_time_period, cv.positive_time_period_milliseconds
),
cv.Optional(CONF_ON_TIMEOUT): automation.validate_automation(single=True),
}
).extend(cv.COMPONENT_SCHEMA)
def _final_validate(config: ConfigType) -> ConfigType:
"""Validate the provisioning setup once every component has been processed.
Sources register during their own config validation, so by final validation
both the source set and the hardcoded-credentials set are complete.
"""
data = _get_data()
if not data.sources:
raise cv.Invalid(
"'provisioning' requires at least one provisioning-capable component: "
"configure a network interface such as 'wifi:' or 'ethernet:', or enable "
"'api:' with 'encryption:' and no 'key:' so the device boots "
"unprovisioned and is configured on first connection."
)
if data.hardcoded_credentials:
_LOGGER.warning(
"'provisioning' is configured, but credentials are set in the "
"configuration for: %s. A device that uses a provisioning window should "
"ship without credentials so they are set on first connection; "
"hardcoding them makes the window pointless.",
", ".join(sorted(data.hardcoded_credentials)),
)
return config
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_PROVISIONING")
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add(var.set_timeout(config[CONF_TIMEOUT]))
if on_timeout := config.get(CONF_ON_TIMEOUT):
await automation.build_automation(var.get_timeout_trigger(), [], on_timeout)
@@ -0,0 +1,92 @@
#include "esphome/components/provisioning/provisioning.h"
#ifdef USE_PROVISIONING
#include "esphome/core/application.h"
#include "esphome/core/log.h"
#ifdef USE_NETWORK
#include "esphome/components/network/util.h"
#endif
#include <cinttypes>
namespace esphome::provisioning {
static const char *const TAG = "provisioning";
ProvisioningManager *global_provisioning_manager = // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
nullptr;
ProvisioningManager::ProvisioningManager() {
global_provisioning_manager = this;
#ifdef USE_NETWORK
// Network connectivity is a built-in provisioning source. Registered here rather
// than from a source's setup() because connectivity is universal, not a pluggable
// transport; loop() latches it provisioned once the device has connected.
this->network_source_ = this->register_source();
#endif
}
uint8_t ProvisioningManager::register_source() {
if (this->source_count_ >= MAX_SOURCES) {
// Defensive: only a handful of sources exist in practice. Fail loudly rather
// than shifting past the mask width (undefined behavior). The returned index is
// ignored by set_source_provisioned()'s bounds check.
ESP_LOGE(TAG, "Too many provisioning sources (max %u)", MAX_SOURCES);
return this->source_count_;
}
uint8_t source = this->source_count_++;
this->registered_mask_ |= (1UL << source);
return source;
}
void ProvisioningManager::loop() {
// Sources register during their own setup() (at various priorities), and this
// loop() also runs while waiting on a slow component during setup. Evaluating the
// provisioning state before every source has registered could conclude
// "provisioned" prematurely and disable_loop() for good, defeating the window --
// so do nothing until all setup() calls are done.
if (!App.is_setup_complete())
return;
#ifdef USE_NETWORK
// Latch the built-in connectivity source once the device has been reachable via
// any interface. network::is_connected() aggregates wifi/ethernet/modem/... (OR
// across interfaces), and a disabled interface never connects so it never
// contributes. Latched: a later link drop does not un-provision -- the RAM-only
// window still reopens only on reboot.
if ((this->provisioned_mask_ & (1UL << this->network_source_)) == 0 && network::is_connected())
this->set_source_provisioned(this->network_source_, true);
#endif
// The window is resolved once the device is provisioned or the window has closed;
// there is nothing left to track, so stop running entirely. Config validation
// guarantees at least one source, so is_provisioned() is never vacuously true here.
if (this->closed_ || this->is_provisioned()) {
this->disable_loop();
return;
}
// The window timer runs from boot (millis since boot). The closed state is not
// persisted, so a reboot reopens the window.
if (this->timeout_ != 0 && App.get_loop_component_start_time() > this->timeout_) {
this->close_window_();
}
}
void ProvisioningManager::close_window_() {
this->closed_ = true;
ESP_LOGW(TAG, "Window expired; cycle power to reopen window");
// Notify internal consumers first (transports disconnect clients, Improv stops),
// then fire the user-facing automation.
this->closed_callback_.call();
this->timeout_trigger_.trigger();
}
void ProvisioningManager::dump_config() {
ESP_LOGCONFIG(TAG,
"Provisioning:\n"
" Timeout: %" PRIu32 "ms\n"
" Provisioned: %s",
this->timeout_, YESNO(this->is_provisioned()));
}
} // namespace esphome::provisioning
#endif // USE_PROVISIONING
@@ -0,0 +1,96 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_PROVISIONING
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include <cstdint>
namespace esphome::provisioning {
// Central provisioning-window manager (EN18031). A device that ships unprovisioned
// (secure transports enabled with no credentials, configured by the controller on
// first connection) opens a provisioning window at boot. Each transport that needs
// provisioning registers as a "source" and reports its state; the device is
// considered provisioned once every registered source is provisioned.
//
// Network connectivity is a built-in source: a device with a network interface but
// no other provisioning-capable component (no api encryption, etc.) is still
// considered provisioned once it has connected via any interface -- so an
// Improv-only device reports its state correctly.
//
// If the window times out while still unprovisioned it closes: the closed state is
// RAM-only (a power cycle / reset reopens it) and the `on_timeout` automation fires.
// Components query window_pending()/closed() to suppress reboot timeouts and refuse
// further provisioning. This manager owns no transport knowledge; transports
// (api, and later mqtt/wireguard/...) drive it through the source API.
class ProvisioningManager : public Component {
public:
// Maximum number of provisioning sources, limited by the width of the state masks.
static constexpr uint8_t MAX_SOURCES = 32;
ProvisioningManager();
void loop() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::BEFORE_CONNECTION; }
void set_timeout(uint32_t timeout) { this->timeout_ = timeout; }
// Register a provisioning source. Returns a bit index the source uses to report
// its state via set_source_provisioned(). Call once, from the source's setup().
uint8_t register_source();
// Report whether the given source currently holds valid credentials.
void set_source_provisioned(uint8_t source, bool provisioned) {
if (source >= MAX_SOURCES)
return;
if (provisioned) {
this->provisioned_mask_ |= (1UL << source);
} else {
this->provisioned_mask_ &= ~(1UL << source);
}
}
// True once every registered source is provisioned. Config validation guarantees
// at least one source, and the built-in connectivity source registers in the
// constructor, so registered_mask_ is never zero in practice.
bool is_provisioned() const { return (this->provisioned_mask_ & this->registered_mask_) == this->registered_mask_; }
// True while provisioning is still pending: the device is unprovisioned, whether
// the window is still open or has already closed. Reboot timeouts are suppressed
// while this holds so the device never auto-reboots (and silently reopens the
// window) while unprovisioned.
bool window_pending() const { return !this->is_provisioned(); }
// True once the window has expired without the device being provisioned.
bool closed() const { return this->closed_; }
// Register a callback fired once when the window closes (runtime expiry). Used
// internally by transports/Improv to stop accepting provisioning. The user-facing
// on_timeout automation is wired to get_timeout_trigger() instead.
template<typename F> void add_on_closed_callback(F &&callback) {
this->closed_callback_.add(std::forward<F>(callback));
}
Trigger<> *get_timeout_trigger() { return &this->timeout_trigger_; }
protected:
void close_window_();
Trigger<> timeout_trigger_;
LazyCallbackManager<void()> closed_callback_;
uint32_t timeout_{0};
uint32_t registered_mask_{0};
uint32_t provisioned_mask_{0};
uint8_t source_count_{0};
bool closed_{false};
#ifdef USE_NETWORK
// Built-in connectivity source (see loop()): registered in the constructor and
// latched provisioned once the device has connected via any network interface.
uint8_t network_source_{0};
#endif
};
extern ProvisioningManager *global_provisioning_manager; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
} // namespace esphome::provisioning
#endif // USE_PROVISIONING
+16
View File
@@ -436,6 +436,21 @@ def _validate(config):
return config
def _report_provisioning_credentials(config):
"""Report baked-in STA credentials to the provisioning component (if used).
`_validate` has already folded any ``ssid``/``password`` into ``networks``, so a
non-empty list means credentials are set in the config. `provisioning:` warns
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
provisioning.report_hardcoded_credentials("wifi")
return config
CONF_PASSIVE_SCAN = "passive_scan"
FAST_CONNECT_SCHEMA = cv.Schema(
@@ -517,6 +532,7 @@ CONFIG_SCHEMA = cv.All(
),
_apply_min_auth_mode_default,
_validate,
_report_provisioning_credentials,
)
+18 -2
View File
@@ -45,6 +45,10 @@
#include "esphome/components/improv_serial/improv_serial_component.h"
#endif
#ifdef USE_PROVISIONING
#include "esphome/components/provisioning/provisioning.h"
#endif
namespace esphome::wifi {
static const char *const TAG = "wifi";
@@ -872,8 +876,20 @@ void WiFiComponent::loop() {
if (!this->has_ap() && this->reboot_timeout_ != 0) {
if (now - this->last_connected_ > this->reboot_timeout_) {
ESP_LOGE(TAG, "Can't connect; rebooting");
App.reboot();
bool suppress = false;
#ifdef USE_PROVISIONING
// Don't reboot while a provisioning window is pending (device unprovisioned).
// The device is legitimately waiting to be onboarded (Wi-Fi must come up
// before the controller can set credentials), and an auto-reboot would reopen
// the window without the deliberate power cycle / reset that is meant to be
// required. Resumes normal reboot behavior once provisioned.
suppress = provisioning::global_provisioning_manager != nullptr &&
provisioning::global_provisioning_manager->window_pending();
#endif
if (!suppress) {
ESP_LOGE(TAG, "Can't connect; rebooting");
App.reboot();
}
}
}
}
+1
View File
@@ -153,6 +153,7 @@
#define USE_OUTPUT_FLOAT_POWER_SCALING
#define USE_POWER_SUPPLY
#define USE_PREFERENCES_SYNC_EVERY_LOOP
#define USE_PROVISIONING
#define USE_QR_CODE
#define USE_SAFE_MODE_CALLBACK
#define ESPHOME_SAFE_MODE_CALLBACK_COUNT 1
@@ -0,0 +1,84 @@
"""Tests for the provisioning component config validation."""
from __future__ import annotations
import logging
import pytest
from esphome import config_validation as cv
from esphome.components.provisioning import (
CONFIG_SCHEMA,
FINAL_VALIDATE_SCHEMA,
register_source,
report_hardcoded_credentials,
)
from esphome.const import CONF_TIMEOUT, PlatformFramework
from tests.component_tests.types import SetCoreConfigCallable
def test_provisioning_requires_a_source(
set_core_config: SetCoreConfigCallable,
) -> None:
"""Provisioning with no registered source is a config error.
Sources register themselves during their own config validation; with none
registered the window could never resolve, so validation fails.
"""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid, match="provisioning-capable component"):
FINAL_VALIDATE_SCHEMA({})
def test_provisioning_accepts_a_registered_source(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A component that registered as a provisioning source satisfies validation."""
set_core_config(PlatformFramework.ESP32_IDF)
register_source("network")
# Should not raise.
assert FINAL_VALIDATE_SCHEMA({}) == {}
def test_provisioning_warns_on_hardcoded_credentials(
set_core_config: SetCoreConfigCallable,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A source with credentials set in the config triggers a warning."""
set_core_config(PlatformFramework.ESP32_IDF)
register_source("network")
report_hardcoded_credentials("wifi")
with caplog.at_level(logging.WARNING):
assert FINAL_VALIDATE_SCHEMA({}) == {}
assert "wifi" in caplog.text
assert "credentials" in caplog.text
def test_provisioning_no_warning_without_hardcoded_credentials(
set_core_config: SetCoreConfigCallable,
caplog: pytest.LogCaptureFixture,
) -> None:
"""No credentials warning when no source reports hardcoded credentials."""
set_core_config(PlatformFramework.ESP32_IDF)
register_source("network")
with caplog.at_level(logging.WARNING):
assert FINAL_VALIDATE_SCHEMA({}) == {}
assert "credentials" not in caplog.text
def test_provisioning_rejects_zero_timeout(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A zero timeout would leave the window open forever, so it is rejected."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid):
CONFIG_SCHEMA({CONF_TIMEOUT: "0s"})
def test_provisioning_accepts_positive_timeout(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A positive timeout is accepted."""
set_core_config(PlatformFramework.ESP32_IDF)
config = CONFIG_SCHEMA({CONF_TIMEOUT: "5min"})
assert config[CONF_TIMEOUT].total_milliseconds == 300000
@@ -0,0 +1,25 @@
# 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.
provisioning:
timeout: 1min
on_timeout:
then:
- logger.log: "Provisioning window expired"
api:
encryption:
wifi:
ssid: MySSID
password: password1
improv_serial:
binary_sensor:
- platform: gpio
pin: 0
id: io0_button
esp32_improv:
authorizer: io0_button
@@ -0,0 +1,16 @@
# 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.
provisioning:
timeout: 1min
on_timeout:
then:
- logger.log: "Provisioning window expired"
api:
encryption:
wifi:
ssid: MySSID
password: password1
improv_serial:
@@ -0,0 +1,15 @@
# A device provisioned over the network (wifi / Improv) with no api: network
# connectivity alone satisfies provisioning, so `provisioning:` is valid without an
# api encryption source. Config-only -- exercises the network provisioning-source
# validation path (the Improv-only case from the review).
provisioning:
timeout: 1min
on_timeout:
then:
- logger.log: "Provisioning window expired"
wifi:
ssid: MySSID
password: password1
improv_serial: