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