ESPHome 2026.11.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
19ESPHOME_LOG_TAG(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 uint8_t checksum = sum_bytes(this->tx_header_, header_checksum_len);
99 if (there_is_data) {
101 }
102 this->tx_header_[TX_CHECKSUM_IDX] = checksum;
103
104#ifdef USE_IMPROV_SERIAL_UART
105 this->uart_->write_array(this->tx_header_, header_tx_len);
106 if (there_is_data) {
107 this->uart_->write_array(data, size);
108 this->uart_->write_array(&this->tx_header_[TX_CHECKSUM_IDX], 2); // Footer: checksum and newline
109 }
110#elif defined(USE_ESP32)
111 switch (this->uart_selection_) {
114#if defined(USE_ESP32_VARIANT_ESP32)
116#endif
117 uart_write_bytes(this->uart_num_, this->tx_header_, header_tx_len);
118 if (there_is_data) {
119 uart_write_bytes(this->uart_num_, data, size);
120 uart_write_bytes(this->uart_num_, &this->tx_header_[TX_CHECKSUM_IDX], 2); // Footer: checksum and newline
121 }
122 break;
123#if defined(USE_LOGGER_USB_CDC) && defined(CONFIG_ESP_CONSOLE_USB_CDC)
125 esp_usb_console_write_buf((const char *) this->tx_header_, header_tx_len);
126 if (there_is_data) {
127 esp_usb_console_write_buf((const char *) data, size);
128 esp_usb_console_write_buf((const char *) &this->tx_header_[TX_CHECKSUM_IDX],
129 2); // Footer: checksum and newline
130 }
131 break;
132#endif
133#ifdef USE_LOGGER_USB_SERIAL_JTAG
135 usb_serial_jtag_write_bytes((const char *) this->tx_header_, header_tx_len, 20 / portTICK_PERIOD_MS);
136 if (there_is_data) {
137 usb_serial_jtag_write_bytes((const char *) data, size, 20 / portTICK_PERIOD_MS);
138 usb_serial_jtag_write_bytes((const char *) &this->tx_header_[TX_CHECKSUM_IDX], 2,
139 20 / portTICK_PERIOD_MS); // Footer: checksum and newline
140 }
141 break;
142#endif
143 default:
144 break;
145 }
146#elif defined(USE_ARDUINO)
147 this->hw_serial_->write(this->tx_header_, header_tx_len);
148 if (there_is_data) {
149 this->hw_serial_->write(data, size);
150 this->hw_serial_->write(&this->tx_header_[TX_CHECKSUM_IDX], 2); // Footer: checksum and newline
151 }
152#endif
153}
154
155#ifdef USE_WEBSERVER
156void ImprovSerialComponent::add_webserver_urls_(improv::RpcResponseBuilder &builder, [[maybe_unused]] bool wifi_first) {
157 // The webserver listens on every interface, so advertise each one that has a usable IPv4.
158 // network::get_ip_addresses() can't be used here: it returns only the highest-priority
159 // interface's addresses, which are all-unset (0.0.0.0) when e.g. Ethernet has no link while
160 // the device is online via Wi-Fi, and 0.0.0.0 must not become the advertised URL. OpenThread
161 // is omitted: it only ever has IPv6 addresses, which cannot form an IPv4 http:// URL.
162 const auto append_urls = [&builder](const network::IPAddresses &addresses) {
163 for (const auto &ip : addresses) {
164 if (!ip.is_ip4() || !ip.is_set())
165 continue;
166 char ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
167 ip.str_to(ip_buf);
168 // "http://" (7) + IP (40) + ":" (1) + port (5) + null (1) = 54
169 char webserver_url[7 + network::IP_ADDRESS_BUFFER_SIZE + 1 + 5 + 1];
170 // buf_append_printf keeps the format string in flash on ESP8266
171 size_t len =
172 buf_append_printf(webserver_url, sizeof(webserver_url), 0, "http://%s:%u", ip_buf, USE_WEBSERVER_PORT);
173 if (!builder.add_string(webserver_url, len)) {
174 ESP_LOGW(TAG, "Response full; URL dropped");
175 }
176 }
177 };
178#ifdef USE_WIFI
179 // Clients redirect to the first URL, so the interface the client just configured has to lead:
180 // another interface's address can be on a subnet that client cannot reach.
181 const auto append_wifi_urls = [&append_urls]() {
182 if (wifi::global_wifi_component != nullptr)
183 append_urls(wifi::global_wifi_component->get_ip_addresses());
184 };
185 if (wifi_first)
186 append_wifi_urls();
187#endif
188#ifdef USE_ETHERNET
189 if (ethernet::global_eth_component != nullptr)
190 append_urls(ethernet::global_eth_component->get_ip_addresses());
191#endif
192#ifdef USE_MODEM
193 if (modem::global_modem_component != nullptr)
194 append_urls(modem::global_modem_component->get_ip_addresses());
195#endif
196#ifdef USE_WIFI
197 if (!wifi_first)
198 append_wifi_urls();
199#endif
200}
201#endif // USE_WEBSERVER
202
204 std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
205 improv::RpcResponseBuilder builder(buf, command);
206#ifdef USE_IMPROV_NEXT_URL
207 this->add_next_url_(builder, MAX_NEXT_URL_LEN);
208#endif
209#ifdef USE_WEBSERVER
210 // This response only ever answers Wi-Fi provisioning, so lead with the Wi-Fi URL as it did
211 // before other interfaces were reported.
212 this->add_webserver_urls_(builder, /*wifi_first=*/true);
213#endif
214 this->send_response_(builder.finish(false));
215}
216
218// Entry cost per field is sizeof(lit): a length byte plus the string
219#ifdef ESPHOME_PROJECT_NAME
220 static constexpr size_t INFO_ENTRIES_LEN =
221 sizeof(ESPHOME_PROJECT_NAME) + sizeof(ESPHOME_PROJECT_VERSION) + sizeof(ESPHOME_VARIANT);
222#else
223 static constexpr size_t INFO_ENTRIES_LEN = sizeof("ESPHome") + sizeof(ESPHOME_VERSION) + sizeof(ESPHOME_VARIANT);
224#endif
225 static_assert(INFO_ENTRIES_LEN < MAX_SERIAL_PAYLOAD,
226 "esphome project name and version too long for the improv_serial device info frame");
227 std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
228 improv::RpcResponseBuilder builder(buf, improv::GET_DEVICE_INFO);
229#ifdef USE_ESP8266
230 // Keep each literal in flash and copy it through an exact size stack buffer,
231 // so a long project name or version can never be truncated
232#define IMPROV_ADD_INFO(lit) \
233 do { \
234 static const char progmem_str[] PROGMEM = lit; \
235 char tmp[sizeof(lit)]; \
236 progmem_memcpy(tmp, progmem_str, sizeof(lit)); \
237 builder.add_string(tmp, sizeof(lit) - 1); \
238 } while (0)
239#else
240 // Literals are directly flash mapped on all other platforms
241#define IMPROV_ADD_INFO(lit) builder.add_string(lit, sizeof(lit) - 1)
242#endif
243#ifdef ESPHOME_PROJECT_NAME
244 IMPROV_ADD_INFO(ESPHOME_PROJECT_NAME);
245 IMPROV_ADD_INFO(ESPHOME_PROJECT_VERSION);
246#else
247 IMPROV_ADD_INFO("ESPHome");
248 IMPROV_ADD_INFO(ESPHOME_VERSION);
249#endif
250 IMPROV_ADD_INFO(ESPHOME_VARIANT);
251#undef IMPROV_ADD_INFO
252 // Only the device name length is unknown at compile time
253 const auto &name = App.get_name();
254 if (INFO_ENTRIES_LEN + 1 + name.size() <= MAX_SERIAL_PAYLOAD) {
255 builder.add_string(name.c_str(), name.size());
256 } else {
257 ESP_LOGW(TAG, "Response full; device name dropped");
258 }
259 this->send_response_(builder.finish(false));
260}
261
263 size_t at = this->rx_buffer_.size();
264 this->rx_buffer_.push_back(byte);
265 ESP_LOGV(TAG, "Byte: 0x%02X", byte);
266 const uint8_t *raw = &this->rx_buffer_[0];
267
268 return improv::parse_improv_serial_byte(
269 at, byte, raw, [this](improv::ImprovCommand command) -> bool { return this->parse_improv_payload_(command); },
270 [this](improv::Error error) -> void {
271 ESP_LOGW(TAG, "Error decoding payload");
272 this->set_error_(error);
273 });
274}
275
276bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command) {
277 switch (command.command) {
278 case improv::WIFI_SETTINGS: {
279#ifdef USE_WIFI
280 if (wifi::global_wifi_component == nullptr || wifi::global_wifi_component->is_disabled()) {
281 // Wi-Fi is disabled, so we can't provision. Respond immediately
282 // instead of letting the client wait out its provisioning timeout.
283 ESP_LOGW(TAG, "Wi-Fi is disabled; cannot provision");
284 this->set_error_(improv::ERROR_UNABLE_TO_CONNECT);
285 return true;
286 }
287 wifi::WiFiAP sta{};
288 sta.set_ssid(command.ssid.c_str());
289 sta.set_password(command.password.c_str());
290 this->connecting_sta_ = sta;
291
292 // Sampled before start_connecting(): the old connection drops asynchronously after it.
293 const bool switching = wifi::global_wifi_component->is_connected();
296 this->set_state_(improv::STATE_PROVISIONING);
297 ESP_LOGD(TAG, "Received settings: SSID=%s, password=" LOG_SECRET("%s"), command.ssid.c_str(),
298 command.password.c_str());
299
300 this->set_timeout(WIFI_CONNECT_TIMEOUT_ID, switching ? WIFI_SWITCH_TIMEOUT_MS : WIFI_CONNECT_TIMEOUT_MS,
301 [this]() { this->on_wifi_connect_timeout_(); });
302#else
303 // No Wi-Fi support compiled in; there is nothing to provision.
304 ESP_LOGW(TAG, "Wi-Fi not supported; cannot provision");
305 this->set_error_(improv::ERROR_UNABLE_TO_CONNECT);
306#endif
307 return true;
308 }
309 case improv::GET_CURRENT_STATE: {
310 // This state machine tracks Wi-Fi provisioning only. When Wi-Fi is disabled or not
311 // compiled in, provisioning is unavailable -> report STOPPED so the client doesn't
312 // offer a Wi-Fi form. General connectivity (e.g. Ethernet) is reported separately
313 // via GET_NETWORK_STATE.
314#ifdef USE_WIFI
315 if (wifi::global_wifi_component == nullptr || wifi::global_wifi_component->is_disabled()) {
316 // Reported transiently without disturbing our internal provisioning state machine,
317 // so a later `wifi.enable` still reports the correct state.
318 this->send_current_state_(improv::STATE_STOPPED);
319 return true;
320 }
321 this->set_state_(this->state_);
322 if (this->state_ == improv::STATE_PROVISIONED) {
323 this->send_settings_response_(improv::GET_CURRENT_STATE);
324 }
325#else
326 this->send_current_state_(improv::STATE_STOPPED);
327#endif
328 return true;
329 }
330 case improv::GET_DEVICE_INFO: {
331 this->send_version_info_();
332 return true;
333 }
334 case improv::GET_WIFI_NETWORKS: {
335 // Declared out here because the terminating empty response is sent with or without Wi-Fi
336 std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
337#ifdef USE_WIFI
338 const auto &results = wifi::global_wifi_component->get_scan_result();
339 for (const auto &scan : results) {
340 bool with_auth = false;
341 if (!wifi::should_show_scan_entry(results, scan, with_auth))
342 continue;
343 // Send each ssid separately to avoid overflowing the buffer
344 char rssi_buf[5]; // int8_t: -128 to 127, max 4 chars + null
345 char *rssi_end = int8_to_str(rssi_buf, scan.get_rssi());
346 *rssi_end = '\0';
347 improv::RpcResponseBuilder builder(buf, improv::GET_WIFI_NETWORKS);
348 // SSID(32) + RSSI(4) + YESNO(3) entries always fit the payload
349 const auto &ssid = scan.get_ssid();
350 builder.add_string(ssid.c_str(), ssid.size());
351 builder.add_string(rssi_buf, rssi_end - rssi_buf);
352 builder.add_string(YESNO(with_auth));
353 this->send_response_(builder.finish(false));
354 }
355#endif // USE_WIFI
356 // Send empty response to signify the end of the list.
357 improv::RpcResponseBuilder builder(buf, improv::GET_WIFI_NETWORKS);
358 this->send_response_(builder.finish(false));
359 return true;
360 }
361 case improv::GET_NETWORK_STATE: {
362 // Reports general device connectivity and which network interfaces are present, decoupled
363 // from the Wi-Fi-only provisioning state machine. data[0] is a decimal flags byte;
364 // when online, the reachable device URL(s) follow.
365 uint8_t flags = 0;
367 flags |= improv::NETWORK_IS_ONLINE;
368#ifdef USE_WIFI
369 flags |= improv::NETWORK_SUPPORTS_WIFI;
370#endif
371#ifdef USE_ETHERNET
372 flags |= improv::NETWORK_SUPPORTS_ETHERNET;
373#endif
374#ifdef USE_OPENTHREAD
375 flags |= improv::NETWORK_SUPPORTS_THREAD;
376#endif
377#ifdef USE_MODEM
378 flags |= improv::NETWORK_SUPPORTS_MODEM;
379#endif
380 std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
381 improv::RpcResponseBuilder builder(buf, improv::GET_NETWORK_STATE);
382 // Every flag bit fits int8_t's positive range, so int8_to_str renders the byte
383 static_assert(improv::NETWORK_SUPPORTS_MODEM <= 0x7F, "network flags no longer fit int8_to_str");
384 char flags_buf[4]; // uint8_t: max "255" + null
385 char *flags_end = int8_to_str(flags_buf, static_cast<int8_t>(flags));
386 builder.add_string(flags_buf, flags_end - flags_buf);
387#ifdef USE_WEBSERVER
388 // Not tied to one interface, so follow the configured priority the way
389 // network::get_ip_addresses() does: a wifi-first network priority list leads with Wi-Fi.
390 if (flags & improv::NETWORK_IS_ONLINE) {
391#if defined(USE_NETWORK_PRIMARY_INTERFACE_WIFI) && defined(USE_WIFI)
392 this->add_webserver_urls_(builder, /*wifi_first=*/true);
393#else
394 this->add_webserver_urls_(builder, /*wifi_first=*/false);
395#endif
396 }
397#endif
398 this->send_response_(builder.finish(false));
399 return true;
400 }
401 default: {
402 ESP_LOGW(TAG, "Unknown payload");
403 this->set_error_(improv::ERROR_UNKNOWN_RPC);
404 return false;
405 }
406 }
407}
408
410 this->state_ = state;
411 this->send_current_state_(state);
412}
413
415 this->tx_header_[TX_TYPE_IDX] = TYPE_CURRENT_STATE;
416 this->tx_header_[TX_DATA_IDX] = state;
417 this->write_data_();
418}
419
420void ImprovSerialComponent::set_error_(improv::Error error) {
421 this->tx_header_[TX_TYPE_IDX] = TYPE_ERROR_STATE;
422 this->tx_header_[TX_DATA_IDX] = error;
423 this->write_data_();
424}
425
426void ImprovSerialComponent::send_response_(std::span<const uint8_t> response) {
427 // The serial frame length field is a single byte
428 if (response.size() > MAX_SERIAL_RESPONSE) {
429 ESP_LOGE(TAG, "Response too long");
430 // Fail fast instead of leaving the client to wait out its timeout
431 this->set_error_(improv::ERROR_UNKNOWN);
432 return;
433 }
434 this->tx_header_[TX_TYPE_IDX] = TYPE_RPC_RESPONSE;
435 this->write_data_(response.data(), response.size());
436}
437
438#ifdef USE_WIFI
440 this->set_error_(improv::ERROR_UNABLE_TO_CONNECT);
441 this->set_state_(improv::STATE_AUTHORIZED);
442 ESP_LOGW(TAG, "Timed out while connecting to Wi-Fi network");
444}
445#endif
446
447ImprovSerialComponent *global_improv_serial_component = // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
448 nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
449
450} // namespace esphome::improv_serial
451
452#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:78
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
ESPHOME_LOG_TAG(TAG, "improv_serial")
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:276
ModemComponent * global_modem_component
std::array< IPAddress, 5 > IPAddresses
Definition ip_address.h:369
ESPHOME_ALWAYS_INLINE bool is_connected()
Return whether the node is connected to the network (through wifi, eth, ...)
Definition util.h:36
bool should_show_scan_entry(const Results &results, const Entry &scan, bool &with_auth)
Definition scan_list.h:11
WiFiComponent * global_wifi_component
constexpr uint8_t sum_bytes(const uint8_t *data, size_t len, uint8_t sum=0)
Add up the bytes of data with size len, truncated to 8 bits: the checksum many serial and IR protocol...
Definition helpers.h:894
const void size_t len
Definition hal.h:68
uint16_t size
Definition helpers.cpp:34
char * int8_to_str(char *buf, int8_t val)
Write int8 value to buffer without modulo operations.
Definition helpers.h:1482
Application App
Global storage of Application pointer - only one Application can exist.
static void uint32_t