ESPHome 2026.6.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_ESP32
11
12namespace esphome::esp32_improv {
13
14using namespace bytebuffer;
15
16static const char *const TAG = "esp32_improv.component";
17static constexpr size_t IMPROV_MAX_LOG_BYTES = 128;
18static const char *const ESPHOME_MY_LINK = "https://my.home-assistant.io/redirect/config_flow_start?domain=esphome";
19static constexpr uint16_t STOP_ADVERTISING_DELAY =
20 10000; // Delay (ms) before stopping service to allow BLE clients to read the final state
21static constexpr uint16_t NAME_ADVERTISING_INTERVAL = 60000; // Advertise name every 60 seconds
22static constexpr uint16_t NAME_ADVERTISING_DURATION = 1000; // Advertise name for 1 second
23
24// Improv service data constants
25static constexpr uint8_t IMPROV_SERVICE_DATA_SIZE = 8;
26static constexpr uint8_t IMPROV_PROTOCOL_ID_1 = 0x77; // 'P' << 1 | 'R' >> 7
27static constexpr uint8_t IMPROV_PROTOCOL_ID_2 = 0x46; // 'I' << 1 | 'M' >> 7
28
30
32#ifdef USE_BINARY_SENSOR
33 if (this->authorizer_ != nullptr) {
34 this->authorizer_->add_on_state_callback([this](bool state) {
35 if (state) {
36 this->authorized_start_ = millis();
37 this->identify_start_ = 0;
38 }
39 });
40 }
41#endif
42 global_ble_server->on_disconnect([this](uint16_t conn_id) { this->set_error_(improv::ERROR_NONE); });
43
44 // Start with loop disabled - will be enabled by start() when needed
45 this->disable_loop();
46}
47
51 BLEDescriptor *status_descriptor = new BLE2902();
52 this->status_->add_descriptor(status_descriptor);
53
56 BLEDescriptor *error_descriptor = new BLE2902();
57 this->error_->add_descriptor(error_descriptor);
58
59 this->rpc_ = this->service_->create_characteristic(improv::RPC_COMMAND_UUID, BLECharacteristic::PROPERTY_WRITE);
60 this->rpc_->on_write([this](std::span<const uint8_t> data, uint16_t id) {
61 if (!data.empty()) {
62 this->incoming_data_.insert(this->incoming_data_.end(), data.begin(), data.end());
63 }
64 });
65 BLEDescriptor *rpc_descriptor = new BLE2902();
66 this->rpc_->add_descriptor(rpc_descriptor);
67
70 BLEDescriptor *rpc_response_descriptor = new BLE2902();
71 this->rpc_response_->add_descriptor(rpc_response_descriptor);
72
73 this->capabilities_ =
74 this->service_->create_characteristic(improv::CAPABILITIES_UUID, BLECharacteristic::PROPERTY_READ);
75 BLEDescriptor *capabilities_descriptor = new BLE2902();
76 this->capabilities_->add_descriptor(capabilities_descriptor);
77 uint8_t capabilities = 0x00;
78#ifdef USE_OUTPUT
79 if (this->status_indicator_ != nullptr)
80 capabilities |= improv::CAPABILITY_IDENTIFY;
81#endif
82 this->capabilities_->set_value(ByteBuffer::wrap(capabilities));
83 this->setup_complete_ = true;
84}
85
88 if (this->state_ != improv::STATE_STOPPED) {
89 this->state_ = improv::STATE_STOPPED;
90#ifdef USE_ESP32_IMPROV_STATE_CALLBACK
91 this->state_callback_.call(this->state_, this->error_state_);
92#endif
93 }
94 this->incoming_data_.clear();
95 return;
96 }
97 if (this->service_ == nullptr) {
98 // Setup the service
99 ESP_LOGD(TAG, "Creating Improv service");
100 this->service_ = global_ble_server->create_service(ESPBTUUID::from_raw(improv::SERVICE_UUID), true);
101 this->setup_characteristics();
102 }
103
104 if (!this->incoming_data_.empty())
107
108 // Check if we need to update advertising type
109 if (this->state_ != improv::STATE_STOPPED && this->state_ != improv::STATE_PROVISIONED) {
111 }
112
113 switch (this->state_) {
114 case improv::STATE_STOPPED:
115 this->set_status_indicator_state_(false);
116
117 if (this->should_start_ && this->setup_complete_) {
118 if (this->service_->is_created()) {
119 this->service_->start();
120 } else if (this->service_->is_running()) {
121 // Start by advertising the device name first BEFORE setting any state
122 ESP_LOGV(TAG, "Starting with device name advertising");
123 this->advertising_device_name_ = true;
125 esp32_ble::global_ble->advertising_set_service_data_and_name(std::span<const uint8_t>{}, true);
127
128 // Set initial state based on whether we have an authorizer
129 this->set_state_(this->get_initial_state_(), false);
130 this->set_error_(improv::ERROR_NONE);
131 this->should_start_ = false; // Clear flag after starting
132 ESP_LOGD(TAG, "Service started!");
133 }
134 }
135 break;
136 case improv::STATE_AWAITING_AUTHORIZATION: {
137#ifdef USE_BINARY_SENSOR
138 if (this->authorizer_ == nullptr ||
139 (this->authorized_start_ != 0 && ((now - this->authorized_start_) < this->authorized_duration_))) {
140 this->set_state_(improv::STATE_AUTHORIZED);
141 } else {
142 if (!this->check_identify_())
143 this->set_status_indicator_state_(true);
144 }
145#else
146 this->set_state_(improv::STATE_AUTHORIZED);
147#endif
149 break;
150 }
151 case improv::STATE_AUTHORIZED: {
152#ifdef USE_BINARY_SENSOR
153 if (this->authorizer_ != nullptr && now - this->authorized_start_ > this->authorized_duration_) {
154 ESP_LOGD(TAG, "Authorization timeout");
155 this->set_state_(improv::STATE_AWAITING_AUTHORIZATION);
156 return;
157 }
158#endif
159 if (!this->check_identify_()) {
160 this->set_status_indicator_state_((now % 1000) < 500);
161 }
163 break;
164 }
165 case improv::STATE_PROVISIONING: {
166 this->set_status_indicator_state_((now % 200) < 100);
168 break;
169 }
170 case improv::STATE_PROVISIONED: {
171 this->incoming_data_.clear();
172 this->set_status_indicator_state_(false);
173 // Provisioning complete, no further loop execution needed
174 this->disable_loop();
175 break;
176 }
177 }
178}
179
181#ifdef USE_OUTPUT
182 if (this->status_indicator_ == nullptr)
183 return;
184 if (this->status_indicator_state_ == state)
185 return;
187 if (state) {
188 this->status_indicator_->turn_on();
189 } else {
191 }
192#endif
193}
194
195#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG
197 switch (state) {
198 case improv::STATE_STOPPED:
199 return "STOPPED";
200 case improv::STATE_AWAITING_AUTHORIZATION:
201 return "AWAITING_AUTHORIZATION";
202 case improv::STATE_AUTHORIZED:
203 return "AUTHORIZED";
204 case improv::STATE_PROVISIONING:
205 return "PROVISIONING";
206 case improv::STATE_PROVISIONED:
207 return "PROVISIONED";
208 default:
209 return "UNKNOWN";
210 }
211}
212#endif
213
215 uint32_t now = millis();
216
217 bool identify = this->identify_start_ != 0 && now - this->identify_start_ <= this->identify_duration_;
218
219 if (identify) {
220 uint32_t time = now % 1000;
221 this->set_status_indicator_state_(time < 600 && time % 200 < 100);
222 }
223 return identify;
224}
225
226void ESP32ImprovComponent::set_state_(improv::State state, bool update_advertising) {
227 // Skip if state hasn't changed
228 if (this->state_ == state) {
229 return;
230 }
231
232#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG
233 ESP_LOGD(TAG, "State transition: %s (0x%02X) -> %s (0x%02X)", this->state_to_string_(this->state_), this->state_,
234 this->state_to_string_(state), state);
235#endif
236 this->state_ = state;
237 if (this->status_ != nullptr && (this->status_->get_value().empty() || this->status_->get_value()[0] != state)) {
238 this->status_->set_value(ByteBuffer::wrap(static_cast<uint8_t>(state)));
239 if (state != improv::STATE_STOPPED)
240 this->status_->notify();
241 }
242 // Only advertise valid Improv states (0x01-0x04).
243 // STATE_STOPPED (0x00) is internal only and not part of the Improv spec.
244 // Advertising 0x00 causes undefined behavior in some clients and makes them
245 // repeatedly connect trying to determine the actual state.
246 if (state != improv::STATE_STOPPED && update_advertising) {
247 // State change always overrides name advertising and resets the timer
248 this->advertising_device_name_ = false;
249 // Reset the timer so we wait another 60 seconds before advertising name
251 // Advertise the new state via service data
253 }
254#ifdef USE_ESP32_IMPROV_STATE_CALLBACK
255 this->state_callback_.call(this->state_, this->error_state_);
256#endif
257}
258
259void ESP32ImprovComponent::set_error_(improv::Error error) {
260 if (error != improv::ERROR_NONE) {
261 ESP_LOGE(TAG, "Error: %d", error);
262 }
263 // The error_ characteristic is initialized in setup_characteristics() which is called
264 // from the loop, while the BLE disconnect callback is registered in setup().
265 // error_ can be nullptr if:
266 // 1. A client connects/disconnects before setup_characteristics() is called
267 // 2. The device is already provisioned so the service never starts (should_start_ is false)
268 if (this->error_ != nullptr && (this->error_->get_value().empty() || this->error_->get_value()[0] != error)) {
269 this->error_->set_value(ByteBuffer::wrap(static_cast<uint8_t>(error)));
270 if (this->state_ != improv::STATE_STOPPED)
271 this->error_->notify();
272 }
273}
274
275void ESP32ImprovComponent::send_response_(std::vector<uint8_t> &&response) {
276 this->rpc_response_->set_value(std::move(response));
277 if (this->state_ != improv::STATE_STOPPED)
278 this->rpc_response_->notify();
279}
280
282 if (this->should_start_ || this->state_ != improv::STATE_STOPPED)
283 return;
284
285 ESP_LOGD(TAG, "Setting Improv to start");
286 this->should_start_ = true;
287 this->enable_loop();
288}
289
291 this->should_start_ = false;
292 // Wait before stopping the service to ensure all BLE clients see the state change.
293 // This prevents clients from repeatedly reconnecting and wasting resources by allowing
294 // them to observe that the device is provisioned before the service disappears.
295 this->set_timeout("end-service", STOP_ADVERTISING_DELAY, [this] {
296 if (this->state_ == improv::STATE_STOPPED || this->service_ == nullptr)
297 return;
298 this->service_->stop();
299 this->set_state_(improv::STATE_STOPPED);
300 });
301}
302
304
306 ESP_LOGCONFIG(TAG, "ESP32 Improv:");
307#ifdef USE_BINARY_SENSOR
308 LOG_BINARY_SENSOR(" ", "Authorizer", this->authorizer_);
309#endif
310#ifdef USE_OUTPUT
311 ESP_LOGCONFIG(TAG, " Status Indicator: '%s'", YESNO(this->status_indicator_ != nullptr));
312#endif
313}
314
316 if (this->incoming_data_.size() < 3)
317 return;
318 uint8_t length = this->incoming_data_[1];
319
320#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
321 char hex_buf[format_hex_pretty_size(IMPROV_MAX_LOG_BYTES)];
322 ESP_LOGV(TAG, "Processing bytes - %s",
323 format_hex_pretty_to(hex_buf, this->incoming_data_.data(), this->incoming_data_.size()));
324#endif
325 if (this->incoming_data_.size() - 3 == length) {
326 this->set_error_(improv::ERROR_NONE);
327 improv::ImprovCommand command = improv::parse_improv_data(this->incoming_data_);
328 switch (command.command) {
329 case improv::BAD_CHECKSUM:
330 ESP_LOGW(TAG, "Error decoding Improv payload");
331 this->set_error_(improv::ERROR_INVALID_RPC);
332 this->incoming_data_.clear();
333 break;
334 case improv::WIFI_SETTINGS: {
335 if (this->state_ != improv::STATE_AUTHORIZED) {
336 ESP_LOGW(TAG, "Settings received, but not authorized");
337 this->set_error_(improv::ERROR_NOT_AUTHORIZED);
338 this->incoming_data_.clear();
339 return;
340 }
341 wifi::WiFiAP sta{};
342 sta.set_ssid(command.ssid.c_str());
343 sta.set_password(command.password.c_str());
344 this->connecting_sta_ = sta;
345
348 this->set_state_(improv::STATE_PROVISIONING);
349 ESP_LOGD(TAG, "Received Improv Wi-Fi settings ssid=%s, password=" LOG_SECRET("%s"), command.ssid.c_str(),
350 command.password.c_str());
351
352 this->set_timeout("wifi-connect-timeout", 30000, [this]() { this->on_wifi_connect_timeout_(); });
353 this->incoming_data_.clear();
354 break;
355 }
356 case improv::IDENTIFY:
357 this->incoming_data_.clear();
358 this->identify_start_ = millis();
359 break;
360 default:
361 ESP_LOGW(TAG, "Unknown Improv payload");
362 this->set_error_(improv::ERROR_UNKNOWN_RPC);
363 this->incoming_data_.clear();
364 }
365 } else if (this->incoming_data_.size() - 2 > length) {
366 ESP_LOGV(TAG, "Too much data received or data malformed; resetting buffer");
367 this->incoming_data_.clear();
368 } else {
369 ESP_LOGV(TAG, "Waiting for split data packets");
370 }
371}
372
374 this->set_error_(improv::ERROR_UNABLE_TO_CONNECT);
375 this->set_state_(improv::STATE_AUTHORIZED);
376#ifdef USE_BINARY_SENSOR
377 if (this->authorizer_ != nullptr)
378 this->authorized_start_ = millis();
379#endif
380 ESP_LOGW(TAG, "Timed out while connecting to Wi-Fi network");
382}
383
385 if (!wifi::global_wifi_component->is_connected()) {
386 return;
387 }
388
389 if (this->state_ == improv::STATE_PROVISIONING) {
390 wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(), this->connecting_sta_.get_password());
391 this->connecting_sta_ = {};
392 this->cancel_timeout("wifi-connect-timeout");
393
394 // Build URL list with minimal allocations
395 // Maximum 3 URLs: custom next_url + ESPHOME_MY_LINK + webserver URL
396 std::string url_strings[3];
397 size_t url_count = 0;
398
399#ifdef USE_ESP32_IMPROV_NEXT_URL
400 // Add next_url if configured (should be first per Improv BLE spec)
401 {
402 char url_buffer[384];
403 size_t len = this->get_formatted_next_url_(url_buffer, sizeof(url_buffer));
404 if (len > 0) {
405 url_strings[url_count++] = std::string(url_buffer, len);
406 }
407 }
408#endif
409
410 // Add default URLs for backward compatibility
411 url_strings[url_count++] = ESPHOME_MY_LINK;
412#ifdef USE_WEBSERVER
413 for (auto &ip : wifi::global_wifi_component->wifi_sta_ip_addresses()) {
414 if (ip.is_ip4()) {
415 // "http://" (7) + IPv4 max (15) + ":" (1) + port max (5) + null = 29
416 char url_buffer[32];
417 memcpy(url_buffer, "http://", 7); // NOLINT(bugprone-not-null-terminated-result) - str_to null-terminates
418 ip.str_to(url_buffer + 7);
419 size_t len = strlen(url_buffer);
420 snprintf(url_buffer + len, sizeof(url_buffer) - len, ":%d", USE_WEBSERVER_PORT);
421 url_strings[url_count++] = url_buffer;
422 break;
423 }
424 }
425#endif
426 this->send_response_(improv::build_rpc_response(improv::WIFI_SETTINGS,
427 std::vector<std::string>(url_strings, url_strings + url_count)));
428 } else if (this->is_active() && this->state_ != improv::STATE_PROVISIONED) {
429 ESP_LOGD(TAG, "WiFi provisioned externally");
430 }
431
432 this->set_state_(improv::STATE_PROVISIONED);
433 this->stop();
434}
435
437 uint8_t service_data[IMPROV_SERVICE_DATA_SIZE] = {};
438 service_data[0] = IMPROV_PROTOCOL_ID_1; // PR
439 service_data[1] = IMPROV_PROTOCOL_ID_2; // IM
440 service_data[2] = static_cast<uint8_t>(this->state_);
441
442 uint8_t capabilities = 0x00;
443#ifdef USE_OUTPUT
444 if (this->status_indicator_ != nullptr)
445 capabilities |= improv::CAPABILITY_IDENTIFY;
446#endif
447
448 service_data[3] = capabilities;
449 // service_data[4-7] are already 0 (Reserved)
450
451 // Atomically set service data and disable name in advertising
452 esp32_ble::global_ble->advertising_set_service_data_and_name(std::span<const uint8_t>(service_data), false);
453}
454
457
458 // If we're advertising the device name and it's been more than NAME_ADVERTISING_DURATION, switch back to service data
459 if (this->advertising_device_name_) {
460 if (now - this->last_name_adv_time_ >= NAME_ADVERTISING_DURATION) {
461 ESP_LOGV(TAG, "Switching back to service data advertising");
462 this->advertising_device_name_ = false;
463 // Restore service data advertising
465 }
466 return;
467 }
468
469 // Check if it's time to advertise the device name (every NAME_ADVERTISING_INTERVAL)
470 if (now - this->last_name_adv_time_ >= NAME_ADVERTISING_INTERVAL) {
471 ESP_LOGV(TAG, "Switching to device name advertising");
472 this->advertising_device_name_ = true;
473 this->last_name_adv_time_ = now;
474
475 // Atomically clear service data and enable name in advertising data
476 esp32_ble::global_ble->advertising_set_service_data_and_name(std::span<const uint8_t>{}, true);
477 }
478}
479
481#ifdef USE_BINARY_SENSOR
482 // If we have an authorizer, start in awaiting authorization state
483 return this->authorizer_ == nullptr ? improv::STATE_AUTHORIZED : improv::STATE_AWAITING_AUTHORIZATION;
484#else
485 // No binary_sensor support = no authorizer possible, start as authorized
486 return improv::STATE_AUTHORIZED;
487#endif
488}
489
490ESP32ImprovComponent *global_improv_component = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
491
492} // namespace esphome::esp32_improv
493
494#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.
ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") void set_timeout(const std voi set_timeout)(const char *name, uint32_t timeout, std::function< void()> &&f)
Set a timeout function with a unique name.
Definition component.h:493
void enable_loop()
Enable this component's loop.
Definition component.h:246
void disable_loop()
Disable this component's loop.
ESPDEPRECATED("Use const char* or uint32_t overload instead. Removed in 2026.7.0", "2026.1.0") bool cancel_timeout(const std boo cancel_timeout)(const char *name)
Cancel a timeout function.
Definition component.h:515
void add_on_state_callback(F &&callback)
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:104
static ESPBTUUID from_raw(const uint8_t *data)
Definition ble_uuid.cpp:29
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:718
ESP32ImprovComponent * global_improv_component
constexpr float AFTER_BLUETOOTH
Definition component.h:47
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:1386
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