ESPHome 2026.10.0-dev
Loading...
Searching...
No Matches
esp32_improv_component.cpp
Go to the documentation of this file.
2
3#include <array>
4
10#include "esphome/core/log.h"
11
12#ifdef USE_PROVISIONING
14#endif
15
16#ifdef USE_ESP32
17
18namespace esphome::esp32_improv {
19
20using namespace bytebuffer;
21
22static const char *const TAG = "esp32_improv.component";
23static constexpr size_t IMPROV_MAX_LOG_BYTES = 128;
24static constexpr char ESPHOME_MY_LINK[] = "https://my.home-assistant.io/redirect/config_flow_start?domain=esphome";
25// command + data length + trailing byte
26static constexpr size_t RPC_RESPONSE_OVERHEAD = 3;
27// Reserves the ESPHOME_MY_LINK entry; a maximal next URL displaces only the
28// lower value web server URL
29static constexpr size_t MAX_NEXT_URL_LEN =
30 improv::RPC_RESPONSE_MAX_SIZE - RPC_RESPONSE_OVERHEAD - 1 - sizeof(ESPHOME_MY_LINK);
31static constexpr uint16_t STOP_ADVERTISING_DELAY =
32 10000; // Delay (ms) before stopping service to allow BLE clients to read the final state
33static constexpr uint16_t NAME_ADVERTISING_INTERVAL = 60000; // Advertise name every 60 seconds
34static constexpr uint16_t NAME_ADVERTISING_DURATION = 1000; // Advertise name for 1 second
35
36// Improv service data constants
37static constexpr uint8_t IMPROV_SERVICE_DATA_SIZE = 8;
38static constexpr uint8_t IMPROV_PROTOCOL_ID_1 = 0x77; // 'P' << 1 | 'R' >> 7
39static constexpr uint8_t IMPROV_PROTOCOL_ID_2 = 0x46; // 'I' << 1 | 'M' >> 7
40
42
44#ifdef USE_BINARY_SENSOR
45 if (this->authorizer_ != nullptr) {
46 this->authorizer_->add_on_state_callback([this](bool state) {
47 if (state) {
48 this->authorized_start_ = millis();
49 this->identify_start_ = 0;
50 }
51 });
52 }
53#endif
54 global_ble_server->on_disconnect([this](uint16_t conn_id) { this->set_error_(improv::ERROR_NONE); });
55
56#ifdef USE_PROVISIONING
59 ESP_LOGD(TAG, "Provisioning window closed; stopping Improv");
60 this->stop();
61 });
62 }
63#endif
64
65 // Start with loop disabled - will be enabled by start() when needed
66 this->disable_loop();
67}
68
72 BLEDescriptor *status_descriptor = new BLE2902();
73 this->status_->add_descriptor(status_descriptor);
74
77 BLEDescriptor *error_descriptor = new BLE2902();
78 this->error_->add_descriptor(error_descriptor);
79
80 this->rpc_ = this->service_->create_characteristic(improv::RPC_COMMAND_UUID, BLECharacteristic::PROPERTY_WRITE);
81 this->rpc_->on_write([this](std::span<const uint8_t> data, uint16_t id) {
82 if (!data.empty()) {
83 this->incoming_data_.insert(this->incoming_data_.end(), data.begin(), data.end());
84 }
85 });
86 BLEDescriptor *rpc_descriptor = new BLE2902();
87 this->rpc_->add_descriptor(rpc_descriptor);
88
91 BLEDescriptor *rpc_response_descriptor = new BLE2902();
92 this->rpc_response_->add_descriptor(rpc_response_descriptor);
93
94 this->capabilities_ =
95 this->service_->create_characteristic(improv::CAPABILITIES_UUID, BLECharacteristic::PROPERTY_READ);
96 BLEDescriptor *capabilities_descriptor = new BLE2902();
97 this->capabilities_->add_descriptor(capabilities_descriptor);
98 uint8_t capabilities = 0x00;
99#ifdef USE_OUTPUT
100 if (this->status_indicator_ != nullptr)
101 capabilities |= improv::CAPABILITY_IDENTIFY;
102#endif
103 this->capabilities_->set_value(ByteBuffer::wrap(capabilities));
104 this->setup_complete_ = true;
105}
106
109 if (this->state_ != improv::STATE_STOPPED) {
110 this->state_ = improv::STATE_STOPPED;
111#ifdef USE_ESP32_IMPROV_STATE_CALLBACK
112 this->state_callback_.call(this->state_, this->error_state_);
113#endif
114 }
115 this->release_advertising_();
116 this->incoming_data_.clear();
117 return;
118 }
119 if (this->service_ == nullptr) {
120 // Setup the service
121 ESP_LOGD(TAG, "Creating Improv service");
122 this->service_ = global_ble_server->create_service(ESPBTUUID::from_raw(improv::SERVICE_UUID), true);
123 this->setup_characteristics();
124 }
125
126 if (!this->incoming_data_.empty())
129
130 // Check if we need to update advertising type
131 if (this->state_ != improv::STATE_STOPPED && this->state_ != improv::STATE_PROVISIONED) {
133 }
134
135 switch (this->state_) {
136 case improv::STATE_STOPPED:
137 this->set_status_indicator_state_(false);
138
139 if (this->should_start_ && this->setup_complete_) {
140 if (this->service_->is_created()) {
141 this->service_->start();
142 } else if (this->service_->is_running()) {
143 // Start by advertising the device name first BEFORE setting any state
144 ESP_LOGV(TAG, "Starting with device name advertising");
145 this->advertising_device_name_ = true;
147 // Set the payload before requesting, so advertising starts exactly once
148 esp32_ble::global_ble->advertising_set_service_data_and_name(std::span<const uint8_t>{}, true);
149 this->request_advertising_();
150
151 // Set initial state based on whether we have an authorizer
152 this->set_state_(this->get_initial_state_(), false);
153 this->set_error_(improv::ERROR_NONE);
154 this->should_start_ = false; // Clear flag after starting
155 ESP_LOGD(TAG, "Service started!");
156 }
157 }
158 break;
159 case improv::STATE_AWAITING_AUTHORIZATION: {
160#ifdef USE_BINARY_SENSOR
161 if (this->authorizer_ == nullptr ||
162 (this->authorized_start_ != 0 && ((now - this->authorized_start_) < this->authorized_duration_))) {
163 this->set_state_(improv::STATE_AUTHORIZED);
164 } else {
165 if (!this->check_identify_())
166 this->set_status_indicator_state_(true);
167 }
168#else
169 this->set_state_(improv::STATE_AUTHORIZED);
170#endif
172 break;
173 }
174 case improv::STATE_AUTHORIZED: {
175#ifdef USE_BINARY_SENSOR
176 if (this->authorizer_ != nullptr && now - this->authorized_start_ > this->authorized_duration_) {
177 ESP_LOGD(TAG, "Authorization timeout");
178 this->set_state_(improv::STATE_AWAITING_AUTHORIZATION);
179 return;
180 }
181#endif
182 if (!this->check_identify_()) {
183 this->set_status_indicator_state_((now % 1000) < 500);
184 }
186 break;
187 }
188 case improv::STATE_PROVISIONING: {
189 this->set_status_indicator_state_((now % 200) < 100);
191 break;
192 }
193 case improv::STATE_PROVISIONED: {
194 this->incoming_data_.clear();
195 this->set_status_indicator_state_(false);
196 // Provisioning complete, no further loop execution needed
197 this->disable_loop();
198 break;
199 }
200 }
201}
202
204#ifdef USE_OUTPUT
205 if (this->status_indicator_ == nullptr)
206 return;
207 if (this->status_indicator_state_ == state)
208 return;
210 if (state) {
211 this->status_indicator_->turn_on();
212 } else {
214 }
215#endif
216}
217
218#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG
220 switch (state) {
221 case improv::STATE_STOPPED:
222 return "STOPPED";
223 case improv::STATE_AWAITING_AUTHORIZATION:
224 return "AWAITING_AUTHORIZATION";
225 case improv::STATE_AUTHORIZED:
226 return "AUTHORIZED";
227 case improv::STATE_PROVISIONING:
228 return "PROVISIONING";
229 case improv::STATE_PROVISIONED:
230 return "PROVISIONED";
231 default:
232 return "UNKNOWN";
233 }
234}
235#endif
236
238 uint32_t now = millis();
239
240 bool identify = this->identify_start_ != 0 && now - this->identify_start_ <= this->identify_duration_;
241
242 if (identify) {
243 uint32_t time = now % 1000;
244 this->set_status_indicator_state_(time < 600 && time % 200 < 100);
245 }
246 return identify;
247}
248
249void ESP32ImprovComponent::set_state_(improv::State state, bool update_advertising) {
250 // Skip if state hasn't changed
251 if (this->state_ == state) {
252 return;
253 }
254
255#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG
256 ESP_LOGD(TAG, "State transition: %s (0x%02X) -> %s (0x%02X)", this->state_to_string_(this->state_), this->state_,
257 this->state_to_string_(state), state);
258#endif
259 this->state_ = state;
260 if (this->status_ != nullptr && (this->status_->get_value().empty() || this->status_->get_value()[0] != state)) {
261 this->status_->set_value(ByteBuffer::wrap(static_cast<uint8_t>(state)));
262 if (state != improv::STATE_STOPPED)
263 this->status_->notify();
264 }
265 // Only advertise valid Improv states (0x01-0x04).
266 // STATE_STOPPED (0x00) is internal only and not part of the Improv spec.
267 // Advertising 0x00 causes undefined behavior in some clients and makes them
268 // repeatedly connect trying to determine the actual state.
269 if (state != improv::STATE_STOPPED && update_advertising) {
270 // State change always overrides name advertising and resets the timer
271 this->advertising_device_name_ = false;
272 // Reset the timer so we wait another 60 seconds before advertising name
274 // Advertise the new state via service data
276 }
277#ifdef USE_ESP32_IMPROV_STATE_CALLBACK
278 this->state_callback_.call(this->state_, this->error_state_);
279#endif
280}
281
282void ESP32ImprovComponent::set_error_(improv::Error error) {
283 if (error != improv::ERROR_NONE) {
284 ESP_LOGE(TAG, "Error: %d", error);
285 }
286 // The error_ characteristic is initialized in setup_characteristics() which is called
287 // from the loop, while the BLE disconnect callback is registered in setup().
288 // error_ can be nullptr if:
289 // 1. A client connects/disconnects before setup_characteristics() is called
290 // 2. The device is already provisioned so the service never starts (should_start_ is false)
291 if (this->error_ != nullptr && (this->error_->get_value().empty() || this->error_->get_value()[0] != error)) {
292 this->error_->set_value(ByteBuffer::wrap(static_cast<uint8_t>(error)));
293 if (this->state_ != improv::STATE_STOPPED)
294 this->error_->notify();
295 }
296}
297
298void ESP32ImprovComponent::send_response_(std::span<const uint8_t> response) {
299 // The BLE characteristic owns its value, so one exact-size copy is required here
300 this->rpc_response_->set_value(std::vector<uint8_t>(response.begin(), response.end()));
301 if (this->state_ != improv::STATE_STOPPED)
302 this->rpc_response_->notify();
303}
304
306 if (this->should_start_ || this->state_ != improv::STATE_STOPPED)
307 return;
308
309#ifdef USE_PROVISIONING
310 // Don't (re)start advertising once the provisioning window has closed - e.g. when
311 // wifi tries to restart Improv after the window expired at runtime.
313 ESP_LOGD(TAG, "Provisioning window closed; not starting Improv");
314 return;
315 }
316#endif
317
318 ESP_LOGD(TAG, "Setting Improv to start");
319 this->should_start_ = true;
320 this->enable_loop();
321}
322
324 this->should_start_ = false;
325 // Wait before stopping the service to ensure all BLE clients see the state change.
326 // This prevents clients from repeatedly reconnecting and wasting resources by allowing
327 // them to observe that the device is provisioned before the service disappears.
328 this->set_timeout("end-service", STOP_ADVERTISING_DELAY, [this] {
329 if (this->state_ == improv::STATE_STOPPED || this->service_ == nullptr)
330 return;
331 // Release first so removing the service UUID does not restart advertising on the way out
332 this->release_advertising_();
333 this->service_->stop();
334 this->set_state_(improv::STATE_STOPPED);
335 });
336}
337
339
341 ESP_LOGCONFIG(TAG, "ESP32 Improv:");
342#ifdef USE_BINARY_SENSOR
343 LOG_BINARY_SENSOR(" ", "Authorizer", this->authorizer_);
344#endif
345#ifdef USE_OUTPUT
346 ESP_LOGCONFIG(TAG, " Status Indicator: '%s'", YESNO(this->status_indicator_ != nullptr));
347#endif
348}
349
351 if (this->incoming_data_.size() < 3)
352 return;
353 uint8_t length = this->incoming_data_[1];
354
355#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
356 char hex_buf[format_hex_pretty_size(IMPROV_MAX_LOG_BYTES)];
357 ESP_LOGV(TAG, "Processing bytes - %s",
358 format_hex_pretty_to(hex_buf, this->incoming_data_.data(), this->incoming_data_.size()));
359#endif
360 if (this->incoming_data_.size() - 3 == length) {
361 this->set_error_(improv::ERROR_NONE);
362 improv::ImprovCommand command = improv::parse_improv_data(this->incoming_data_);
363 switch (command.command) {
364 case improv::BAD_CHECKSUM:
365 ESP_LOGW(TAG, "Error decoding Improv payload");
366 this->set_error_(improv::ERROR_INVALID_RPC);
367 this->incoming_data_.clear();
368 break;
369 case improv::WIFI_SETTINGS: {
370 if (this->state_ != improv::STATE_AUTHORIZED) {
371 ESP_LOGW(TAG, "Settings received, but not authorized");
372 this->set_error_(improv::ERROR_NOT_AUTHORIZED);
373 this->incoming_data_.clear();
374 return;
375 }
376#ifdef USE_PROVISIONING
379 ESP_LOGW(TAG, "Provisioning window closed; refusing settings");
380 this->set_error_(improv::ERROR_NOT_AUTHORIZED);
381 this->incoming_data_.clear();
382 return;
383 }
384#endif
385 if (wifi::global_wifi_component->is_disabled()) {
386 // Wi-Fi is disabled, so we can't provision. Respond immediately
387 // instead of letting the client wait out its provisioning timeout.
388 ESP_LOGW(TAG, "Wi-Fi is disabled; cannot provision");
389 this->set_error_(improv::ERROR_UNABLE_TO_CONNECT);
390 this->incoming_data_.clear();
391 return;
392 }
393 wifi::WiFiAP sta{};
394 sta.set_ssid(command.ssid.c_str());
395 sta.set_password(command.password.c_str());
396 this->connecting_sta_ = sta;
397
400 this->set_state_(improv::STATE_PROVISIONING);
401 ESP_LOGD(TAG, "Received Improv Wi-Fi settings ssid=%s, password=" LOG_SECRET("%s"), command.ssid.c_str(),
402 command.password.c_str());
403
404 this->set_timeout("wifi-connect-timeout", 30000, [this]() { this->on_wifi_connect_timeout_(); });
405 this->incoming_data_.clear();
406 break;
407 }
408 case improv::IDENTIFY:
409 this->incoming_data_.clear();
410 this->identify_start_ = millis();
411 break;
412 default:
413 ESP_LOGW(TAG, "Unknown Improv payload");
414 this->set_error_(improv::ERROR_UNKNOWN_RPC);
415 this->incoming_data_.clear();
416 }
417 } else if (this->incoming_data_.size() - 2 > length) {
418 ESP_LOGV(TAG, "Too much data received or data malformed; resetting buffer");
419 this->incoming_data_.clear();
420 } else {
421 ESP_LOGV(TAG, "Waiting for split data packets");
422 }
423}
424
426 this->set_error_(improv::ERROR_UNABLE_TO_CONNECT);
427 this->set_state_(improv::STATE_AUTHORIZED);
428#ifdef USE_BINARY_SENSOR
429 if (this->authorizer_ != nullptr)
430 this->authorized_start_ = millis();
431#endif
432 ESP_LOGW(TAG, "Timed out while connecting to Wi-Fi network");
434}
435
437 if (!wifi::global_wifi_component->is_connected()) {
438 return;
439 }
440
441 if (this->state_ == improv::STATE_PROVISIONING) {
442 wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(), this->connecting_sta_.get_password());
443 this->connecting_sta_ = {};
444 this->cancel_timeout("wifi-connect-timeout");
445
446 // Build the URL list directly into a stack buffer with no heap allocation
447 std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
448 improv::RpcResponseBuilder builder(buf, improv::WIFI_SETTINGS);
449
450#ifdef USE_ESP32_IMPROV_NEXT_URL
451 // Add next_url if configured (should be first per Improv BLE spec)
452 this->add_next_url_(builder, MAX_NEXT_URL_LEN);
453#endif
454
455 // Add default URLs for backward compatibility; MAX_NEXT_URL_LEN reserves this
456 // entry's space, so it always fits
457 builder.add_string(ESPHOME_MY_LINK, sizeof(ESPHOME_MY_LINK) - 1);
458#ifdef USE_WEBSERVER
459 for (auto &ip : wifi::global_wifi_component->wifi_sta_ip_addresses()) {
460 if (ip.is_ip4()) {
461 char ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
462 ip.str_to(ip_buf);
463 // "http://" (7) + IP (40) + ":" (1) + port (5) + null (1) = 54
464 char webserver_url[7 + network::IP_ADDRESS_BUFFER_SIZE + 1 + 5 + 1];
465 size_t len =
466 buf_append_printf(webserver_url, sizeof(webserver_url), 0, "http://%s:%u", ip_buf, USE_WEBSERVER_PORT);
467 if (!builder.add_string(webserver_url, len)) {
468 ESP_LOGW(TAG, "Response full; URL dropped");
469 }
470 break;
471 }
472 }
473#endif
474 this->send_response_(builder.finish());
475 } else if (this->is_active() && this->state_ != improv::STATE_PROVISIONED) {
476 ESP_LOGD(TAG, "WiFi provisioned externally");
477 }
478
479 this->set_state_(improv::STATE_PROVISIONED);
480 this->stop();
481}
482
484 uint8_t service_data[IMPROV_SERVICE_DATA_SIZE] = {};
485 service_data[0] = IMPROV_PROTOCOL_ID_1; // PR
486 service_data[1] = IMPROV_PROTOCOL_ID_2; // IM
487 service_data[2] = static_cast<uint8_t>(this->state_);
488
489 uint8_t capabilities = 0x00;
490#ifdef USE_OUTPUT
491 if (this->status_indicator_ != nullptr)
492 capabilities |= improv::CAPABILITY_IDENTIFY;
493#endif
494
495 service_data[3] = capabilities;
496 // service_data[4-7] are already 0 (Reserved)
497
498 // Atomically set service data and disable name in advertising
499 esp32_ble::global_ble->advertising_set_service_data_and_name(std::span<const uint8_t>(service_data), false);
500}
501
504
505 // If we're advertising the device name and it's been more than NAME_ADVERTISING_DURATION, switch back to service data
506 if (this->advertising_device_name_) {
507 if (now - this->last_name_adv_time_ >= NAME_ADVERTISING_DURATION) {
508 ESP_LOGV(TAG, "Switching back to service data advertising");
509 this->advertising_device_name_ = false;
510 // Restore service data advertising
512 }
513 return;
514 }
515
516 // Check if it's time to advertise the device name (every NAME_ADVERTISING_INTERVAL)
517 if (now - this->last_name_adv_time_ >= NAME_ADVERTISING_INTERVAL) {
518 ESP_LOGV(TAG, "Switching to device name advertising");
519 this->advertising_device_name_ = true;
520 this->last_name_adv_time_ = now;
521
522 // Atomically clear service data and enable name in advertising data
523 esp32_ble::global_ble->advertising_set_service_data_and_name(std::span<const uint8_t>{}, true);
524 }
525}
526
533
540
542#ifdef USE_BINARY_SENSOR
543 // If we have an authorizer, start in awaiting authorization state
544 return this->authorizer_ == nullptr ? improv::STATE_AUTHORIZED : improv::STATE_AWAITING_AUTHORIZATION;
545#else
546 // No binary_sensor support = no authorizer possible, start as authorized
547 return improv::STATE_AUTHORIZED;
548#endif
549}
550
551ESP32ImprovComponent *global_improv_component = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
552
553} // namespace esphome::esp32_improv
554
555#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_start()
Request advertising on behalf of a component.
Definition ble.cpp:101
void advertising_set_service_data_and_name(std::span< const uint8_t > data, bool include_name)
Definition ble.cpp:137
void advertising_stop()
Release a request made with advertising_start(); advertising stops at the last release.
Definition ble.cpp:107
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:70
BLECharacteristic * create_characteristic(const std::string &uuid, esp_gatt_char_prop_t properties)
CallbackManager< void(improv::State, improv::Error)> state_callback_
void send_response_(std::span< const uint8_t > response)
void set_state_(improv::State state, bool update_advertising=true)
const char * state_to_string_(improv::State state)
void add_next_url_(improv::RpcResponseBuilder &builder, size_t max_len)
Append the formatted next_url to the RPC response, warning if it does not fit.
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:816
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:425
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:1438
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