ESPHome 2026.10.2
Loading...
Searching...
No Matches
improv_serial_component.cpp
Go to the documentation of this file.
2#ifdef USE_IMPROV_SERIAL
5#include "esphome/core/hal.h"
6#include "esphome/core/log.h"
8
11#ifdef USE_WIFI
13#endif
14
15#include <array>
16
18
19static const char *const TAG = "improv_serial";
20
21static constexpr uint32_t WIFI_CONNECT_TIMEOUT_ID = 0;
22
25#ifdef USE_IMPROV_SERIAL_UART
26 // Transport is a dedicated UART bus set via set_uart() in generated code
27#elif defined(USE_ESP32)
30#elif defined(USE_ARDUINO)
32#endif
33
34 // The Improv state machine tracks Wi-Fi provisioning only. General device
35 // connectivity (e.g. Ethernet) is reported separately via GET_NETWORK_STATE.
36#ifdef USE_WIFI
37 if (wifi::global_wifi_component != nullptr && wifi::global_wifi_component->has_sta()) {
38 this->state_ = improv::STATE_PROVISIONED;
39 } else if (wifi::global_wifi_component != nullptr && !wifi::global_wifi_component->is_disabled()) {
40 // Respect Wi-Fi's disabled state; forcing a scan while disabled throws
41 // the wifi component into an invalid state from which it cannot recover.
43 }
44#endif
45}
46
49 if (this->last_read_byte_ && (now - this->last_read_byte_ > IMPROV_SERIAL_TIMEOUT)) {
50 this->last_read_byte_ = 0;
51 this->rx_buffer_.clear();
52 ESP_LOGV(TAG, "Timeout");
53 }
54
55 while (true) {
56 auto byte = this->read_byte_();
57 if (!byte.has_value())
58 break;
59 if (this->parse_improv_serial_byte_(byte.value())) {
60 this->last_read_byte_ = now;
61 } else {
62 this->last_read_byte_ = 0;
63 this->rx_buffer_.clear();
64 }
65 }
66
67#ifdef USE_WIFI
68 if (this->state_ == improv::STATE_PROVISIONING && wifi::global_wifi_component != nullptr &&
69 wifi::global_wifi_component->is_connected()) {
70 // Being connected is not enough: re-provisioning a device that is already online leaves the
71 // prior network up until it drops, so check that the joined network is the requested one
72 // before reporting success. Same test as the wifi.connect action.
73 char ssid_buf[wifi::SSID_BUFFER_SIZE];
74 if (strcmp(wifi::global_wifi_component->wifi_ssid_to(ssid_buf), this->connecting_sta_.get_ssid().c_str()) == 0) {
76 this->connecting_sta_.get_password());
77 this->connecting_sta_ = {};
78 this->cancel_timeout(WIFI_CONNECT_TIMEOUT_ID);
79 this->set_state_(improv::STATE_PROVISIONED);
80
81 this->send_settings_response_(improv::WIFI_SETTINGS);
82 }
83 }
84#endif
85}
86
87void ImprovSerialComponent::dump_config() { ESP_LOGCONFIG(TAG, "Improv Serial:"); }
88
89void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size) {
90 // First, set length field
91 this->tx_header_[TX_LENGTH_IDX] = this->tx_header_[TX_TYPE_IDX] == TYPE_RPC_RESPONSE ? size : 1;
92
93 const bool there_is_data = data != nullptr && size > 0;
94 // If there_is_data, checksum must not include our optional data byte
95 const uint8_t header_checksum_len = there_is_data ? TX_BUFFER_SIZE - 3 : TX_BUFFER_SIZE - 2;
96 // Only transmit the full buffer length if there is no data (only state/error byte is provided in this case)
97 const uint8_t header_tx_len = there_is_data ? TX_BUFFER_SIZE - 3 : TX_BUFFER_SIZE;
98 // Calculate checksum for message
99 uint8_t checksum = 0;
100 for (uint8_t i = 0; i < header_checksum_len; i++) {
101 checksum += this->tx_header_[i];
102 }
103 if (there_is_data) {
104 // Include data in checksum
105 for (size_t i = 0; i < size; i++) {
106 checksum += data[i];
107 }
108 }
109 this->tx_header_[TX_CHECKSUM_IDX] = checksum;
110
111#ifdef USE_IMPROV_SERIAL_UART
112 this->uart_->write_array(this->tx_header_, header_tx_len);
113 if (there_is_data) {
114 this->uart_->write_array(data, size);
115 this->uart_->write_array(&this->tx_header_[TX_CHECKSUM_IDX], 2); // Footer: checksum and newline
116 }
117#elif defined(USE_ESP32)
118 switch (this->uart_selection_) {
121#if defined(USE_ESP32_VARIANT_ESP32)
123#endif
124 uart_write_bytes(this->uart_num_, this->tx_header_, header_tx_len);
125 if (there_is_data) {
126 uart_write_bytes(this->uart_num_, data, size);
127 uart_write_bytes(this->uart_num_, &this->tx_header_[TX_CHECKSUM_IDX], 2); // Footer: checksum and newline
128 }
129 break;
130#if defined(USE_LOGGER_USB_CDC) && defined(CONFIG_ESP_CONSOLE_USB_CDC)
132 esp_usb_console_write_buf((const char *) this->tx_header_, header_tx_len);
133 if (there_is_data) {
134 esp_usb_console_write_buf((const char *) data, size);
135 esp_usb_console_write_buf((const char *) &this->tx_header_[TX_CHECKSUM_IDX],
136 2); // Footer: checksum and newline
137 }
138 break;
139#endif
140#ifdef USE_LOGGER_USB_SERIAL_JTAG
142 usb_serial_jtag_write_bytes((const char *) this->tx_header_, header_tx_len, 20 / portTICK_PERIOD_MS);
143 if (there_is_data) {
144 usb_serial_jtag_write_bytes((const char *) data, size, 20 / portTICK_PERIOD_MS);
145 usb_serial_jtag_write_bytes((const char *) &this->tx_header_[TX_CHECKSUM_IDX], 2,
146 20 / portTICK_PERIOD_MS); // Footer: checksum and newline
147 }
148 break;
149#endif
150 default:
151 break;
152 }
153#elif defined(USE_ARDUINO)
154 this->hw_serial_->write(this->tx_header_, header_tx_len);
155 if (there_is_data) {
156 this->hw_serial_->write(data, size);
157 this->hw_serial_->write(&this->tx_header_[TX_CHECKSUM_IDX], 2); // Footer: checksum and newline
158 }
159#endif
160}
161
162#ifdef USE_WEBSERVER
163void ImprovSerialComponent::add_webserver_urls_(improv::RpcResponseBuilder &builder, [[maybe_unused]] bool wifi_first) {
164 // The webserver listens on every interface, so advertise each one that has a usable IPv4.
165 // network::get_ip_addresses() can't be used here: it returns only the highest-priority
166 // interface's addresses, which are all-unset (0.0.0.0) when e.g. Ethernet has no link while
167 // the device is online via Wi-Fi, and 0.0.0.0 must not become the advertised URL. OpenThread
168 // is omitted: it only ever has IPv6 addresses, which cannot form an IPv4 http:// URL.
169 const auto append_urls = [&builder](const network::IPAddresses &addresses) {
170 for (const auto &ip : addresses) {
171 if (!ip.is_ip4() || !ip.is_set())
172 continue;
173 char ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
174 ip.str_to(ip_buf);
175 // "http://" (7) + IP (40) + ":" (1) + port (5) + null (1) = 54
176 char webserver_url[7 + network::IP_ADDRESS_BUFFER_SIZE + 1 + 5 + 1];
177 // buf_append_printf keeps the format string in flash on ESP8266
178 size_t len =
179 buf_append_printf(webserver_url, sizeof(webserver_url), 0, "http://%s:%u", ip_buf, USE_WEBSERVER_PORT);
180 if (!builder.add_string(webserver_url, len)) {
181 ESP_LOGW(TAG, "Response full; URL dropped");
182 }
183 }
184 };
185#ifdef USE_WIFI
186 // Clients redirect to the first URL, so the interface the client just configured has to lead:
187 // another interface's address can be on a subnet that client cannot reach.
188 const auto append_wifi_urls = [&append_urls]() {
189 if (wifi::global_wifi_component != nullptr)
190 append_urls(wifi::global_wifi_component->get_ip_addresses());
191 };
192 if (wifi_first)
193 append_wifi_urls();
194#endif
195#ifdef USE_ETHERNET
196 if (ethernet::global_eth_component != nullptr)
197 append_urls(ethernet::global_eth_component->get_ip_addresses());
198#endif
199#ifdef USE_MODEM
200 if (modem::global_modem_component != nullptr)
201 append_urls(modem::global_modem_component->get_ip_addresses());
202#endif
203#ifdef USE_WIFI
204 if (!wifi_first)
205 append_wifi_urls();
206#endif
207}
208#endif // USE_WEBSERVER
209
211 std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
212 improv::RpcResponseBuilder builder(buf, command);
213#ifdef USE_IMPROV_NEXT_URL
214 this->add_next_url_(builder, MAX_NEXT_URL_LEN);
215#endif
216#ifdef USE_WEBSERVER
217 // This response only ever answers Wi-Fi provisioning, so lead with the Wi-Fi URL as it did
218 // before other interfaces were reported.
219 this->add_webserver_urls_(builder, /*wifi_first=*/true);
220#endif
221 this->send_response_(builder.finish(false));
222}
223
225// Entry cost per field is sizeof(lit): a length byte plus the string
226#ifdef ESPHOME_PROJECT_NAME
227 static constexpr size_t INFO_ENTRIES_LEN =
228 sizeof(ESPHOME_PROJECT_NAME) + sizeof(ESPHOME_PROJECT_VERSION) + sizeof(ESPHOME_VARIANT);
229#else
230 static constexpr size_t INFO_ENTRIES_LEN = sizeof("ESPHome") + sizeof(ESPHOME_VERSION) + sizeof(ESPHOME_VARIANT);
231#endif
232 static_assert(INFO_ENTRIES_LEN < MAX_SERIAL_PAYLOAD,
233 "esphome project name and version too long for the improv_serial device info frame");
234 std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
235 improv::RpcResponseBuilder builder(buf, improv::GET_DEVICE_INFO);
236#ifdef USE_ESP8266
237 // Keep each literal in flash and copy it through an exact size stack buffer,
238 // so a long project name or version can never be truncated
239#define IMPROV_ADD_INFO(lit) \
240 do { \
241 static const char progmem_str[] PROGMEM = lit; \
242 char tmp[sizeof(lit)]; \
243 progmem_memcpy(tmp, progmem_str, sizeof(lit)); \
244 builder.add_string(tmp, sizeof(lit) - 1); \
245 } while (0)
246#else
247 // Literals are directly flash mapped on all other platforms
248#define IMPROV_ADD_INFO(lit) builder.add_string(lit, sizeof(lit) - 1)
249#endif
250#ifdef ESPHOME_PROJECT_NAME
251 IMPROV_ADD_INFO(ESPHOME_PROJECT_NAME);
252 IMPROV_ADD_INFO(ESPHOME_PROJECT_VERSION);
253#else
254 IMPROV_ADD_INFO("ESPHome");
255 IMPROV_ADD_INFO(ESPHOME_VERSION);
256#endif
257 IMPROV_ADD_INFO(ESPHOME_VARIANT);
258#undef IMPROV_ADD_INFO
259 // Only the device name length is unknown at compile time
260 const auto &name = App.get_name();
261 if (INFO_ENTRIES_LEN + 1 + name.size() <= MAX_SERIAL_PAYLOAD) {
262 builder.add_string(name.c_str(), name.size());
263 } else {
264 ESP_LOGW(TAG, "Response full; device name dropped");
265 }
266 this->send_response_(builder.finish(false));
267}
268
270 size_t at = this->rx_buffer_.size();
271 this->rx_buffer_.push_back(byte);
272 ESP_LOGV(TAG, "Byte: 0x%02X", byte);
273 const uint8_t *raw = &this->rx_buffer_[0];
274
275 return improv::parse_improv_serial_byte(
276 at, byte, raw, [this](improv::ImprovCommand command) -> bool { return this->parse_improv_payload_(command); },
277 [this](improv::Error error) -> void {
278 ESP_LOGW(TAG, "Error decoding payload");
279 this->set_error_(error);
280 });
281}
282
283bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command) {
284 switch (command.command) {
285 case improv::WIFI_SETTINGS: {
286#ifdef USE_WIFI
287 if (wifi::global_wifi_component == nullptr || wifi::global_wifi_component->is_disabled()) {
288 // Wi-Fi is disabled, so we can't provision. Respond immediately
289 // instead of letting the client wait out its provisioning timeout.
290 ESP_LOGW(TAG, "Wi-Fi is disabled; cannot provision");
291 this->set_error_(improv::ERROR_UNABLE_TO_CONNECT);
292 return true;
293 }
294 wifi::WiFiAP sta{};
295 sta.set_ssid(command.ssid.c_str());
296 sta.set_password(command.password.c_str());
297 this->connecting_sta_ = sta;
298
299 // Sampled before start_connecting(): the old connection drops asynchronously after it.
300 const bool switching = wifi::global_wifi_component->is_connected();
303 this->set_state_(improv::STATE_PROVISIONING);
304 ESP_LOGD(TAG, "Received settings: SSID=%s, password=" LOG_SECRET("%s"), command.ssid.c_str(),
305 command.password.c_str());
306
307 this->set_timeout(WIFI_CONNECT_TIMEOUT_ID, switching ? WIFI_SWITCH_TIMEOUT_MS : WIFI_CONNECT_TIMEOUT_MS,
308 [this]() { this->on_wifi_connect_timeout_(); });
309#else
310 // No Wi-Fi support compiled in; there is nothing to provision.
311 ESP_LOGW(TAG, "Wi-Fi not supported; cannot provision");
312 this->set_error_(improv::ERROR_UNABLE_TO_CONNECT);
313#endif
314 return true;
315 }
316 case improv::GET_CURRENT_STATE: {
317 // This state machine tracks Wi-Fi provisioning only. When Wi-Fi is disabled or not
318 // compiled in, provisioning is unavailable -> report STOPPED so the client doesn't
319 // offer a Wi-Fi form. General connectivity (e.g. Ethernet) is reported separately
320 // via GET_NETWORK_STATE.
321#ifdef USE_WIFI
322 if (wifi::global_wifi_component == nullptr || wifi::global_wifi_component->is_disabled()) {
323 // Reported transiently without disturbing our internal provisioning state machine,
324 // so a later `wifi.enable` still reports the correct state.
325 this->send_current_state_(improv::STATE_STOPPED);
326 return true;
327 }
328 this->set_state_(this->state_);
329 if (this->state_ == improv::STATE_PROVISIONED) {
330 this->send_settings_response_(improv::GET_CURRENT_STATE);
331 }
332#else
333 this->send_current_state_(improv::STATE_STOPPED);
334#endif
335 return true;
336 }
337 case improv::GET_DEVICE_INFO: {
338 this->send_version_info_();
339 return true;
340 }
341 case improv::GET_WIFI_NETWORKS: {
342 // Declared out here because the terminating empty response is sent with or without Wi-Fi
343 std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
344#ifdef USE_WIFI
345 const auto &results = wifi::global_wifi_component->get_scan_result();
346 for (const auto &scan : results) {
347 bool with_auth = false;
348 if (!wifi::should_show_scan_entry(results, scan, with_auth))
349 continue;
350 // Send each ssid separately to avoid overflowing the buffer
351 char rssi_buf[5]; // int8_t: -128 to 127, max 4 chars + null
352 char *rssi_end = int8_to_str(rssi_buf, scan.get_rssi());
353 *rssi_end = '\0';
354 improv::RpcResponseBuilder builder(buf, improv::GET_WIFI_NETWORKS);
355 // SSID(32) + RSSI(4) + YESNO(3) entries always fit the payload
356 const auto &ssid = scan.get_ssid();
357 builder.add_string(ssid.c_str(), ssid.size());
358 builder.add_string(rssi_buf, rssi_end - rssi_buf);
359 builder.add_string(YESNO(with_auth));
360 this->send_response_(builder.finish(false));
361 }
362#endif // USE_WIFI
363 // Send empty response to signify the end of the list.
364 improv::RpcResponseBuilder builder(buf, improv::GET_WIFI_NETWORKS);
365 this->send_response_(builder.finish(false));
366 return true;
367 }
368 case improv::GET_NETWORK_STATE: {
369 // Reports general device connectivity and which network interfaces are present, decoupled
370 // from the Wi-Fi-only provisioning state machine. data[0] is a decimal flags byte;
371 // when online, the reachable device URL(s) follow.
372 uint8_t flags = 0;
374 flags |= improv::NETWORK_IS_ONLINE;
375#ifdef USE_WIFI
376 flags |= improv::NETWORK_SUPPORTS_WIFI;
377#endif
378#ifdef USE_ETHERNET
379 flags |= improv::NETWORK_SUPPORTS_ETHERNET;
380#endif
381#ifdef USE_OPENTHREAD
382 flags |= improv::NETWORK_SUPPORTS_THREAD;
383#endif
384#ifdef USE_MODEM
385 flags |= improv::NETWORK_SUPPORTS_MODEM;
386#endif
387 std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
388 improv::RpcResponseBuilder builder(buf, improv::GET_NETWORK_STATE);
389 // Every flag bit fits int8_t's positive range, so int8_to_str renders the byte
390 static_assert(improv::NETWORK_SUPPORTS_MODEM <= 0x7F, "network flags no longer fit int8_to_str");
391 char flags_buf[4]; // uint8_t: max "255" + null
392 char *flags_end = int8_to_str(flags_buf, static_cast<int8_t>(flags));
393 builder.add_string(flags_buf, flags_end - flags_buf);
394#ifdef USE_WEBSERVER
395 // Not tied to one interface, so follow the configured priority the way
396 // network::get_ip_addresses() does: a wifi-first network priority list leads with Wi-Fi.
397 if (flags & improv::NETWORK_IS_ONLINE) {
398#if defined(USE_NETWORK_PRIMARY_INTERFACE_WIFI) && defined(USE_WIFI)
399 this->add_webserver_urls_(builder, /*wifi_first=*/true);
400#else
401 this->add_webserver_urls_(builder, /*wifi_first=*/false);
402#endif
403 }
404#endif
405 this->send_response_(builder.finish(false));
406 return true;
407 }
408 default: {
409 ESP_LOGW(TAG, "Unknown payload");
410 this->set_error_(improv::ERROR_UNKNOWN_RPC);
411 return false;
412 }
413 }
414}
415
417 this->state_ = state;
418 this->send_current_state_(state);
419}
420
422 this->tx_header_[TX_TYPE_IDX] = TYPE_CURRENT_STATE;
423 this->tx_header_[TX_DATA_IDX] = state;
424 this->write_data_();
425}
426
427void ImprovSerialComponent::set_error_(improv::Error error) {
428 this->tx_header_[TX_TYPE_IDX] = TYPE_ERROR_STATE;
429 this->tx_header_[TX_DATA_IDX] = error;
430 this->write_data_();
431}
432
433void ImprovSerialComponent::send_response_(std::span<const uint8_t> response) {
434 // The serial frame length field is a single byte
435 if (response.size() > MAX_SERIAL_RESPONSE) {
436 ESP_LOGE(TAG, "Response too long");
437 // Fail fast instead of leaving the client to wait out its timeout
438 this->set_error_(improv::ERROR_UNKNOWN);
439 return;
440 }
441 this->tx_header_[TX_TYPE_IDX] = TYPE_RPC_RESPONSE;
442 this->write_data_(response.data(), response.size());
443}
444
445#ifdef USE_WIFI
447 this->set_error_(improv::ERROR_UNABLE_TO_CONNECT);
448 this->set_state_(improv::STATE_AUTHORIZED);
449 ESP_LOGW(TAG, "Timed out while connecting to Wi-Fi network");
451}
452#endif
453
454ImprovSerialComponent *global_improv_serial_component = // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
455 nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
456
457} // namespace esphome::improv_serial
458
459#endif
uint8_t checksum
Definition bl0906.h:3
uint8_t raw[35]
Definition bl0939.h:0
const StringRef & get_name() const
Get the name of this Application set by pre_setup().
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 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
constexpr const char * c_str() const
Definition string_ref.h:77
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 write_data_(const uint8_t *data=nullptr, size_t size=0)
void send_response_(std::span< const uint8_t > response)
ESPHOME_ALWAYS_INLINE optional< uint8_t > read_byte_()
void add_webserver_urls_(improv::RpcResponseBuilder &builder, bool wifi_first)
Append one web server URL per interface that has a usable IPv4.
bool parse_improv_payload_(improv::ImprovCommand &command)
Stream * get_hw_serial() const
Definition logger.h:159
UARTSelection get_uart() const
Get the UART used by the logger.
Definition logger.h:171
uart_port_t get_uart_num() const
Definition logger.h:162
void write_array(const std::vector< uint8_t > &data)
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)
const wifi_scan_vector_t< WiFiScanResult > & get_scan_result() const
Main-loop callers may read this directly.
uint16_t flags
bool state
Definition fan.h:2
EthernetComponent * global_eth_component
ImprovSerialComponent * global_improv_serial_component
@ UART_SELECTION_UART2
Definition logger.h:118
@ UART_SELECTION_USB_SERIAL_JTAG
Definition logger.h:124
@ UART_SELECTION_USB_CDC
Definition logger.h:121
@ UART_SELECTION_UART0
Definition logger.h:112
@ UART_SELECTION_UART1
Definition logger.h:116
Logger * global_logger
Definition logger.cpp:274
std::array< IPAddress, 5 > IPAddresses
Definition ip_address.h:292
ESPHOME_ALWAYS_INLINE bool is_connected()
Return whether the node is connected to the network (through wifi, eth, ...)
Definition util.h:28
bool should_show_scan_entry(const Results &results, const Entry &scan, bool &with_auth)
Definition scan_list.h:11
WiFiComponent * global_wifi_component
const void size_t len
Definition hal.h:64
uint16_t size
Definition helpers.cpp:33
char * int8_to_str(char *buf, int8_t val)
Write int8 value to buffer without modulo operations.
Definition helpers.h:1405
Application App
Global storage of Application pointer - only one Application can exist.
static void uint32_t