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