ESPHome 2026.8.0-dev
Loading...
Searching...
No Matches
esp32_improv_component.cpp
Go to the documentation of this file.
2
8#include "esphome/core/log.h"
9
10#ifdef USE_PROVISIONING
12#endif
13
14#ifdef USE_ESP32
15
16namespace esphome::esp32_improv {
17
18using namespace bytebuffer;
19
20static const char *const TAG = "esp32_improv.component";
21static constexpr size_t IMPROV_MAX_LOG_BYTES = 128;
22static const char *const ESPHOME_MY_LINK = "https://my.home-assistant.io/redirect/config_flow_start?domain=esphome";
23static constexpr uint16_t STOP_ADVERTISING_DELAY =
24 10000; // Delay (ms) before stopping service to allow BLE clients to read the final state
25static constexpr uint16_t NAME_ADVERTISING_INTERVAL = 60000; // Advertise name every 60 seconds
26static constexpr uint16_t NAME_ADVERTISING_DURATION = 1000; // Advertise name for 1 second
27
28// Improv service data constants
29static constexpr uint8_t IMPROV_SERVICE_DATA_SIZE = 8;
30static constexpr uint8_t IMPROV_PROTOCOL_ID_1 = 0x77; // 'P' << 1 | 'R' >> 7
31static constexpr uint8_t IMPROV_PROTOCOL_ID_2 = 0x46; // 'I' << 1 | 'M' >> 7
32
34
36#ifdef USE_BINARY_SENSOR
37 if (this->authorizer_ != nullptr) {
38 this->authorizer_->add_on_state_callback([this](bool state) {
39 if (state) {
40 this->authorized_start_ = millis();
41 this->identify_start_ = 0;
42 }
43 });
44 }
45#endif
46 global_ble_server->on_disconnect([this](uint16_t conn_id) { this->set_error_(improv::ERROR_NONE); });
47
48#ifdef USE_PROVISIONING
51 ESP_LOGD(TAG, "Provisioning window closed; stopping Improv");
52 this->stop();
53 });
54 }
55#endif
56
57 // Start with loop disabled - will be enabled by start() when needed
58 this->disable_loop();
59}
60
64 BLEDescriptor *status_descriptor = new BLE2902();
65 this->status_->add_descriptor(status_descriptor);
66
69 BLEDescriptor *error_descriptor = new BLE2902();
70 this->error_->add_descriptor(error_descriptor);
71
72 this->rpc_ = this->service_->create_characteristic(improv::RPC_COMMAND_UUID, BLECharacteristic::PROPERTY_WRITE);
73 this->rpc_->on_write([this](std::span<const uint8_t> data, uint16_t id) {
74 if (!data.empty()) {
75 this->incoming_data_.insert(this->incoming_data_.end(), data.begin(), data.end());
76 }
77 });
78 BLEDescriptor *rpc_descriptor = new BLE2902();
79 this->rpc_->add_descriptor(rpc_descriptor);
80
83 BLEDescriptor *rpc_response_descriptor = new BLE2902();
84 this->rpc_response_->add_descriptor(rpc_response_descriptor);
85
86 this->capabilities_ =
87 this->service_->create_characteristic(improv::CAPABILITIES_UUID, BLECharacteristic::PROPERTY_READ);
88 BLEDescriptor *capabilities_descriptor = new BLE2902();
89 this->capabilities_->add_descriptor(capabilities_descriptor);
90 uint8_t capabilities = 0x00;
91#ifdef USE_OUTPUT
92 if (this->status_indicator_ != nullptr)
93 capabilities |= improv::CAPABILITY_IDENTIFY;
94#endif
95 this->capabilities_->set_value(ByteBuffer::wrap(capabilities));
96 this->setup_complete_ = true;
97}
98
101 if (this->state_ != improv::STATE_STOPPED) {
102 this->state_ = improv::STATE_STOPPED;
103#ifdef USE_ESP32_IMPROV_STATE_CALLBACK
104 this->state_callback_.call(this->state_, this->error_state_);
105#endif
106 }
107 this->incoming_data_.clear();
108 return;
109 }
110 if (this->service_ == nullptr) {
111 // Setup the service
112 ESP_LOGD(TAG, "Creating Improv service");
113 this->service_ = global_ble_server->create_service(ESPBTUUID::from_raw(improv::SERVICE_UUID), true);
114 this->setup_characteristics();
115 }
116
117 if (!this->incoming_data_.empty())
120
121 // Check if we need to update advertising type
122 if (this->state_ != improv::STATE_STOPPED && this->state_ != improv::STATE_PROVISIONED) {
124 }
125
126 switch (this->state_) {
127 case improv::STATE_STOPPED:
128 this->set_status_indicator_state_(false);
129
130 if (this->should_start_ && this->setup_complete_) {
131 if (this->service_->is_created()) {
132 this->service_->start();
133 } else if (this->service_->is_running()) {
134 // Start by advertising the device name first BEFORE setting any state
135 ESP_LOGV(TAG, "Starting with device name advertising");
136 this->advertising_device_name_ = true;
138 esp32_ble::global_ble->advertising_set_service_data_and_name(std::span<const uint8_t>{}, true);
140
141 // Set initial state based on whether we have an authorizer
142 this->set_state_(this->get_initial_state_(), false);
143 this->set_error_(improv::ERROR_NONE);
144 this->should_start_ = false; // Clear flag after starting
145 ESP_LOGD(TAG, "Service started!");
146 }
147 }
148 break;
149 case improv::STATE_AWAITING_AUTHORIZATION: {
150#ifdef USE_BINARY_SENSOR
151 if (this->authorizer_ == nullptr ||
152 (this->authorized_start_ != 0 && ((now - this->authorized_start_) < this->authorized_duration_))) {
153 this->set_state_(improv::STATE_AUTHORIZED);
154 } else {
155 if (!this->check_identify_())
156 this->set_status_indicator_state_(true);
157 }
158#else
159 this->set_state_(improv::STATE_AUTHORIZED);
160#endif
162 break;
163 }
164 case improv::STATE_AUTHORIZED: {
165#ifdef USE_BINARY_SENSOR
166 if (this->authorizer_ != nullptr && now - this->authorized_start_ > this->authorized_duration_) {
167 ESP_LOGD(TAG, "Authorization timeout");
168 this->set_state_(improv::STATE_AWAITING_AUTHORIZATION);
169 return;
170 }
171#endif
172 if (!this->check_identify_()) {
173 this->set_status_indicator_state_((now % 1000) < 500);
174 }
176 break;
177 }
178 case improv::STATE_PROVISIONING: {
179 this->set_status_indicator_state_((now % 200) < 100);
181 break;
182 }
183 case improv::STATE_PROVISIONED: {
184 this->incoming_data_.clear();
185 this->set_status_indicator_state_(false);
186 // Provisioning complete, no further loop execution needed
187 this->disable_loop();
188 break;
189 }
190 }
191}
192
194#ifdef USE_OUTPUT
195 if (this->status_indicator_ == nullptr)
196 return;
197 if (this->status_indicator_state_ == state)
198 return;
200 if (state) {
201 this->status_indicator_->turn_on();
202 } else {
204 }
205#endif
206}
207
208#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG
210 switch (state) {
211 case improv::STATE_STOPPED:
212 return "STOPPED";
213 case improv::STATE_AWAITING_AUTHORIZATION:
214 return "AWAITING_AUTHORIZATION";
215 case improv::STATE_AUTHORIZED:
216 return "AUTHORIZED";
217 case improv::STATE_PROVISIONING:
218 return "PROVISIONING";
219 case improv::STATE_PROVISIONED:
220 return "PROVISIONED";
221 default:
222 return "UNKNOWN";
223 }
224}
225#endif
226
228 uint32_t now = millis();
229
230 bool identify = this->identify_start_ != 0 && now - this->identify_start_ <= this->identify_duration_;
231
232 if (identify) {
233 uint32_t time = now % 1000;
234 this->set_status_indicator_state_(time < 600 && time % 200 < 100);
235 }
236 return identify;
237}
238
239void ESP32ImprovComponent::set_state_(improv::State state, bool update_advertising) {
240 // Skip if state hasn't changed
241 if (this->state_ == state) {
242 return;
243 }
244
245#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG
246 ESP_LOGD(TAG, "State transition: %s (0x%02X) -> %s (0x%02X)", this->state_to_string_(this->state_), this->state_,
247 this->state_to_string_(state), state);
248#endif
249 this->state_ = state;
250 if (this->status_ != nullptr && (this->status_->get_value().empty() || this->status_->get_value()[0] != state)) {
251 this->status_->set_value(ByteBuffer::wrap(static_cast<uint8_t>(state)));
252 if (state != improv::STATE_STOPPED)
253 this->status_->notify();
254 }
255 // Only advertise valid Improv states (0x01-0x04).
256 // STATE_STOPPED (0x00) is internal only and not part of the Improv spec.
257 // Advertising 0x00 causes undefined behavior in some clients and makes them
258 // repeatedly connect trying to determine the actual state.
259 if (state != improv::STATE_STOPPED && update_advertising) {
260 // State change always overrides name advertising and resets the timer
261 this->advertising_device_name_ = false;
262 // Reset the timer so we wait another 60 seconds before advertising name
264 // Advertise the new state via service data
266 }
267#ifdef USE_ESP32_IMPROV_STATE_CALLBACK
268 this->state_callback_.call(this->state_, this->error_state_);
269#endif
270}
271
272void ESP32ImprovComponent::set_error_(improv::Error error) {
273 if (error != improv::ERROR_NONE) {
274 ESP_LOGE(TAG, "Error: %d", error);
275 }
276 // The error_ characteristic is initialized in setup_characteristics() which is called
277 // from the loop, while the BLE disconnect callback is registered in setup().
278 // error_ can be nullptr if:
279 // 1. A client connects/disconnects before setup_characteristics() is called
280 // 2. The device is already provisioned so the service never starts (should_start_ is false)
281 if (this->error_ != nullptr && (this->error_->get_value().empty() || this->error_->get_value()[0] != error)) {
282 this->error_->set_value(ByteBuffer::wrap(static_cast<uint8_t>(error)));
283 if (this->state_ != improv::STATE_STOPPED)
284 this->error_->notify();
285 }
286}
287
288void ESP32ImprovComponent::send_response_(std::vector<uint8_t> &&response) {
289 this->rpc_response_->set_value(std::move(response));
290 if (this->state_ != improv::STATE_STOPPED)
291 this->rpc_response_->notify();
292}
293
295 if (this->should_start_ || this->state_ != improv::STATE_STOPPED)
296 return;
297
298#ifdef USE_PROVISIONING
299 // Don't (re)start advertising once the provisioning window has closed - e.g. when
300 // wifi tries to restart Improv after the window expired at runtime.
302 ESP_LOGD(TAG, "Provisioning window closed; not starting Improv");
303 return;
304 }
305#endif
306
307 ESP_LOGD(TAG, "Setting Improv to start");
308 this->should_start_ = true;
309 this->enable_loop();
310}
311
313 this->should_start_ = false;
314 // Wait before stopping the service to ensure all BLE clients see the state change.
315 // This prevents clients from repeatedly reconnecting and wasting resources by allowing
316 // them to observe that the device is provisioned before the service disappears.
317 this->set_timeout("end-service", STOP_ADVERTISING_DELAY, [this] {
318 if (this->state_ == improv::STATE_STOPPED || this->service_ == nullptr)
319 return;
320 this->service_->stop();
321 this->set_state_(improv::STATE_STOPPED);
322 });
323}
324
326
328 ESP_LOGCONFIG(TAG, "ESP32 Improv:");
329#ifdef USE_BINARY_SENSOR
330 LOG_BINARY_SENSOR(" ", "Authorizer", this->authorizer_);
331#endif
332#ifdef USE_OUTPUT
333 ESP_LOGCONFIG(TAG, " Status Indicator: '%s'", YESNO(this->status_indicator_ != nullptr));
334#endif
335}
336
338 if (this->incoming_data_.size() < 3)
339 return;
340 uint8_t length = this->incoming_data_[1];
341
342#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
343 char hex_buf[format_hex_pretty_size(IMPROV_MAX_LOG_BYTES)];
344 ESP_LOGV(TAG, "Processing bytes - %s",
345 format_hex_pretty_to(hex_buf, this->incoming_data_.data(), this->incoming_data_.size()));
346#endif
347 if (this->incoming_data_.size() - 3 == length) {
348 this->set_error_(improv::ERROR_NONE);
349 improv::ImprovCommand command = improv::parse_improv_data(this->incoming_data_);
350 switch (command.command) {
351 case improv::BAD_CHECKSUM:
352 ESP_LOGW(TAG, "Error decoding Improv payload");
353 this->set_error_(improv::ERROR_INVALID_RPC);
354 this->incoming_data_.clear();
355 break;
356 case improv::WIFI_SETTINGS: {
357 if (this->state_ != improv::STATE_AUTHORIZED) {
358 ESP_LOGW(TAG, "Settings received, but not authorized");
359 this->set_error_(improv::ERROR_NOT_AUTHORIZED);
360 this->incoming_data_.clear();
361 return;
362 }
363#ifdef USE_PROVISIONING
366 ESP_LOGW(TAG, "Provisioning window closed; refusing settings");
367 this->set_error_(improv::ERROR_NOT_AUTHORIZED);
368 this->incoming_data_.clear();
369 return;
370 }
371#endif
372 if (wifi::global_wifi_component->is_disabled()) {
373 // Wi-Fi is disabled, so we can't provision. Respond immediately
374 // instead of letting the client wait out its provisioning timeout.
375 ESP_LOGW(TAG, "Wi-Fi is disabled; cannot provision");
376 this->set_error_(improv::ERROR_UNABLE_TO_CONNECT);
377 this->incoming_data_.clear();
378 return;
379 }
380 wifi::WiFiAP sta{};
381 sta.set_ssid(command.ssid.c_str());
382 sta.set_password(command.password.c_str());
383 this->connecting_sta_ = sta;
384
387 this->set_state_(improv::STATE_PROVISIONING);
388 ESP_LOGD(TAG, "Received Improv Wi-Fi settings ssid=%s, password=" LOG_SECRET("%s"), command.ssid.c_str(),
389 command.password.c_str());
390
391 this->set_timeout("wifi-connect-timeout", 30000, [this]() { this->on_wifi_connect_timeout_(); });
392 this->incoming_data_.clear();
393 break;
394 }
395 case improv::IDENTIFY:
396 this->incoming_data_.clear();
397 this->identify_start_ = millis();
398 break;
399 default:
400 ESP_LOGW(TAG, "Unknown Improv payload");
401 this->set_error_(improv::ERROR_UNKNOWN_RPC);
402 this->incoming_data_.clear();
403 }
404 } else if (this->incoming_data_.size() - 2 > length) {
405 ESP_LOGV(TAG, "Too much data received or data malformed; resetting buffer");
406 this->incoming_data_.clear();
407 } else {
408 ESP_LOGV(TAG, "Waiting for split data packets");
409 }
410}
411
413 this->set_error_(improv::ERROR_UNABLE_TO_CONNECT);
414 this->set_state_(improv::STATE_AUTHORIZED);
415#ifdef USE_BINARY_SENSOR
416 if (this->authorizer_ != nullptr)
417 this->authorized_start_ = millis();
418#endif
419 ESP_LOGW(TAG, "Timed out while connecting to Wi-Fi network");
421}
422
424 if (!wifi::global_wifi_component->is_connected()) {
425 return;
426 }
427
428 if (this->state_ == improv::STATE_PROVISIONING) {
429 wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(), this->connecting_sta_.get_password());
430 this->connecting_sta_ = {};
431 this->cancel_timeout("wifi-connect-timeout");
432
433 // Build URL list with minimal allocations
434 // Maximum 3 URLs: custom next_url + ESPHOME_MY_LINK + webserver URL
435 std::string url_strings[3];
436 size_t url_count = 0;
437
438#ifdef USE_ESP32_IMPROV_NEXT_URL
439 // Add next_url if configured (should be first per Improv BLE spec)
440 {
441 char url_buffer[384];
442 size_t len = this->get_formatted_next_url_(url_buffer, sizeof(url_buffer));
443 if (len > 0) {
444 url_strings[url_count++] = std::string(url_buffer, len);
445 }
446 }
447#endif
448
449 // Add default URLs for backward compatibility
450 url_strings[url_count++] = ESPHOME_MY_LINK;
451#ifdef USE_WEBSERVER
452 for (auto &ip : wifi::global_wifi_component->wifi_sta_ip_addresses()) {
453 if (ip.is_ip4()) {
454 // "http://" (7) + IPv4 max (15) + ":" (1) + port max (5) + null = 29
455 char url_buffer[32];
456 memcpy(url_buffer, "http://", 7); // NOLINT(bugprone-not-null-terminated-result) - str_to null-terminates
457 ip.str_to(url_buffer + 7);
458 size_t len = strlen(url_buffer);
459 snprintf(url_buffer + len, sizeof(url_buffer) - len, ":%d", USE_WEBSERVER_PORT);
460 url_strings[url_count++] = url_buffer;
461 break;
462 }
463 }
464#endif
465 this->send_response_(improv::build_rpc_response(improv::WIFI_SETTINGS,
466 std::vector<std::string>(url_strings, url_strings + url_count)));
467 } else if (this->is_active() && this->state_ != improv::STATE_PROVISIONED) {
468 ESP_LOGD(TAG, "WiFi provisioned externally");
469 }
470
471 this->set_state_(improv::STATE_PROVISIONED);
472 this->stop();
473}
474
476 uint8_t service_data[IMPROV_SERVICE_DATA_SIZE] = {};
477 service_data[0] = IMPROV_PROTOCOL_ID_1; // PR
478 service_data[1] = IMPROV_PROTOCOL_ID_2; // IM
479 service_data[2] = static_cast<uint8_t>(this->state_);
480
481 uint8_t capabilities = 0x00;
482#ifdef USE_OUTPUT
483 if (this->status_indicator_ != nullptr)
484 capabilities |= improv::CAPABILITY_IDENTIFY;
485#endif
486
487 service_data[3] = capabilities;
488 // service_data[4-7] are already 0 (Reserved)
489
490 // Atomically set service data and disable name in advertising
491 esp32_ble::global_ble->advertising_set_service_data_and_name(std::span<const uint8_t>(service_data), false);
492}
493
496
497 // If we're advertising the device name and it's been more than NAME_ADVERTISING_DURATION, switch back to service data
498 if (this->advertising_device_name_) {
499 if (now - this->last_name_adv_time_ >= NAME_ADVERTISING_DURATION) {
500 ESP_LOGV(TAG, "Switching back to service data advertising");
501 this->advertising_device_name_ = false;
502 // Restore service data advertising
504 }
505 return;
506 }
507
508 // Check if it's time to advertise the device name (every NAME_ADVERTISING_INTERVAL)
509 if (now - this->last_name_adv_time_ >= NAME_ADVERTISING_INTERVAL) {
510 ESP_LOGV(TAG, "Switching to device name advertising");
511 this->advertising_device_name_ = true;
512 this->last_name_adv_time_ = now;
513
514 // Atomically clear service data and enable name in advertising data
515 esp32_ble::global_ble->advertising_set_service_data_and_name(std::span<const uint8_t>{}, true);
516 }
517}
518
520#ifdef USE_BINARY_SENSOR
521 // If we have an authorizer, start in awaiting authorization state
522 return this->authorizer_ == nullptr ? improv::STATE_AUTHORIZED : improv::STATE_AWAITING_AUTHORIZATION;
523#else
524 // No binary_sensor support = no authorizer possible, start as authorized
525 return improv::STATE_AUTHORIZED;
526#endif
527}
528
529ESP32ImprovComponent *global_improv_component = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
530
531} // namespace esphome::esp32_improv
532
533#endif
uint32_t IRAM_ATTR HOT get_loop_component_start_time() const
Get the cached time in milliseconds from when the current component started its loop execution.
bool cancel_timeout(const char *name)
Cancel a timeout function.
void enable_loop()
Enable this component's loop.
Definition component.h:246
void set_timeout(const char *name, uint32_t timeout, std::function< void()> &&f)
Set a timeout function with a const char* name.
Definition component.cpp:96
void disable_loop()
Disable this component's loop.
void add_on_state_callback(F &&callback)
static ESPBTUUID from_raw(const uint8_t *data)
Construct from raw 16-byte little-endian UUID.
static ByteBuffer wrap(T value, Endian endianness=LITTLE)
Definition bytebuffer.h:155
void advertising_set_service_data_and_name(std::span< const uint8_t > data, bool include_name)
Definition ble.cpp:120
void on_write(std::function< void(std::span< const uint8_t >, uint16_t)> &&callback)
void add_descriptor(BLEDescriptor *descriptor)
ESPHOME_ALWAYS_INLINE bool is_running()
Definition ble_server.h:34
BLEService * create_service(ESPBTUUID uuid, bool advertise=false, uint16_t num_handles=15)
void on_disconnect(std::function< void(uint16_t)> &&callback)
Definition ble_server.h:63
BLECharacteristic * create_characteristic(const std::string &uuid, esp_gatt_char_prop_t properties)
void send_response_(std::vector< uint8_t > &&response)
CallbackManager< void(improv::State, improv::Error)> state_callback_
void set_state_(improv::State state, bool update_advertising=true)
const char * state_to_string_(improv::State state)
size_t get_formatted_next_url_(char *buffer, size_t buffer_size)
Format next_url_ into buffer, replacing placeholders. Returns length written.
virtual void turn_off()
Disable this binary output.
virtual void turn_on()
Enable this binary output.
StringRef get_ssid() const
void set_ssid(const std::string &ssid)
void set_sta(const WiFiAP &ap)
void save_wifi_sta(const std::string &ssid, const std::string &password)
void start_connecting(const WiFiAP &ap)
bool state
Definition fan.h:2
ESP32BLE * global_ble
Definition ble.cpp:761
ESP32ImprovComponent * global_improv_component
ProvisioningManager * global_provisioning_manager
constexpr float AFTER_BLUETOOTH
Definition component.h:49
WiFiComponent * global_wifi_component
const void size_t len
Definition hal.h:64
char * format_hex_pretty_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length, char separator)
Format byte array as uppercase hex to buffer (base implementation).
Definition helpers.cpp:340
constexpr size_t format_hex_pretty_size(size_t byte_count)
Calculate buffer size needed for format_hex_pretty_to with separator: "XX:XX:...:XX\0".
Definition helpers.h:1400
uint32_t IRAM_ATTR HOT millis()
Definition hal.cpp:28
Application App
Global storage of Application pointer - only one Application can exist.
static void uint32_t
uint16_t length
Definition tt21100.cpp:0