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