mirror of
https://github.com/esphome/esphome.git
synced 2026-07-10 08:55:36 +00:00
[provisioning] Add provisioning window (#17152)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(){};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
Reference in New Issue
Block a user