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