From 89c43b49c2e3852271edae9fc47726cc43e94028 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Feb 2026 10:40:33 -1000 Subject: [PATCH 1/3] [uart] Replace wake-on-RX task+queue with direct ISR callback Use the ESP-IDF UART driver's uart_set_select_notif_callback() to wake the main loop directly from the UART ISR via vTaskNotifyGiveFromISR(), eliminating the FreeRTOS trampoline task and event queue that were previously needed when wake_loop_threadsafe() used a UDP loopback socket. With fast lwip select, the main loop sleeps on ulTaskNotifyTake(), so the ISR-safe vTaskNotifyGiveFromISR() can wake it directly. Saves ~2.6KB RAM per UART instance with wake-on-RX enabled: - 2,240 bytes FreeRTOS task stack - ~340 bytes event queue (20 entries + control block) - 8 bytes QueueHandle_t + TaskHandle_t fields Also adds Application::wake_loop_isrsafe() as the ISR-safe counterpart to wake_loop_threadsafe(), backed by esphome_lwip_wake_main_loop_from_isr(). --- .../uart/uart_component_esp_idf.cpp | 101 +++--------------- .../components/uart/uart_component_esp_idf.h | 22 ++-- esphome/core/application.h | 10 ++ esphome/core/lwip_fast_select.c | 8 ++ esphome/core/lwip_fast_select.h | 5 + 5 files changed, 47 insertions(+), 99 deletions(-) diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index 8699d37d7a..394c33d032 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -2,7 +2,6 @@ #include "uart_component_esp_idf.h" #include -#include "esphome/core/application.h" #include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -10,6 +9,10 @@ #include "driver/gpio.h" #include "soc/gpio_num.h" +#ifdef USE_UART_WAKE_LOOP_ON_RX +#include "esphome/core/application.h" +#endif + #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" #endif @@ -107,12 +110,6 @@ void IDFUARTComponent::load_settings(bool dump_config) { esp_err_t err; if (uart_is_driver_installed(this->uart_num_)) { -#ifdef USE_UART_WAKE_LOOP_ON_RX - if (this->rx_event_task_handle_ != nullptr) { - vTaskDelete(this->rx_event_task_handle_); - this->rx_event_task_handle_ = nullptr; - } -#endif err = uart_driver_delete(this->uart_num_); if (err != ESP_OK) { ESP_LOGW(TAG, "uart_driver_delete failed: %s", esp_err_to_name(err)); @@ -120,20 +117,13 @@ void IDFUARTComponent::load_settings(bool dump_config) { return; } } -#ifdef USE_UART_WAKE_LOOP_ON_RX - constexpr int event_queue_size = 20; - QueueHandle_t *event_queue_ptr = &this->uart_event_queue_; -#else - constexpr int event_queue_size = 0; - QueueHandle_t *event_queue_ptr = nullptr; -#endif err = uart_driver_install(this->uart_num_, // UART number this->rx_buffer_size_, // RX ring buffer size 0, // TX ring buffer size. If zero, driver will not use a TX buffer and TX function will // block task until all data has been sent out - event_queue_size, // event queue size/depth - event_queue_ptr, // event queue - 0 // Flags used to allocate the interrupt + 0, // event queue size/depth + nullptr, // event queue + 0 // Flags used to allocate the interrupt ); if (err != ESP_OK) { ESP_LOGW(TAG, "uart_driver_install failed: %s", esp_err_to_name(err)); @@ -213,8 +203,10 @@ void IDFUARTComponent::load_settings(bool dump_config) { } #ifdef USE_UART_WAKE_LOOP_ON_RX - // Start the RX event task to enable low-latency data notifications - this->start_rx_event_task_(); + // Register ISR callback to wake the main loop when UART data arrives. + // The callback runs in ISR context and uses vTaskNotifyGiveFromISR() to + // wake the main loop task directly — no queue or FreeRTOS task needed. + uart_set_select_notif_callback(this->uart_num_, IDFUARTComponent::uart_rx_isr_callback_); #endif // USE_UART_WAKE_LOOP_ON_RX if (dump_config) { @@ -345,71 +337,12 @@ void IDFUARTComponent::flush() { void IDFUARTComponent::check_logger_conflict() {} #ifdef USE_UART_WAKE_LOOP_ON_RX -void IDFUARTComponent::start_rx_event_task_() { - // Create FreeRTOS task to monitor UART events - BaseType_t result = xTaskCreate(rx_event_task_func, // Task function - "uart_rx_evt", // Task name (max 16 chars) - 2240, // Stack size in bytes (~2.2KB); increase if needed for logging - this, // Task parameter (this pointer) - tskIDLE_PRIORITY + 1, // Priority (low, just above idle) - &this->rx_event_task_handle_ // Task handle - ); - - if (result != pdPASS) { - ESP_LOGE(TAG, "Failed to create RX event task"); - return; - } - - ESP_LOGV(TAG, "RX event task started"); -} - -// FreeRTOS task that relays UART ISR events to the main loop. -// This task exists because wake_loop_threadsafe() is not ISR-safe (it uses a -// UDP loopback socket), so we need a task as an ISR-to-main-loop trampoline. -// IMPORTANT: This task must NOT call any UART wrapper methods (read_array, -// write_array, peek_byte, etc.) or touch has_peek_/peek_byte_ — all reading -// is done by the main loop. This task only reads from the event queue and -// calls App.wake_loop_threadsafe(). -void IDFUARTComponent::rx_event_task_func(void *param) { - auto *self = static_cast(param); - uart_event_t event; - - ESP_LOGV(TAG, "RX event task running"); - - // Run forever - task lifecycle matches component lifecycle - while (true) { - // Wait for UART events (blocks efficiently) - if (xQueueReceive(self->uart_event_queue_, &event, portMAX_DELAY) == pdTRUE) { - switch (event.type) { - case UART_DATA: - // Data available in UART RX buffer - wake the main loop - ESP_LOGVV(TAG, "Data event: %d bytes", event.size); -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) - App.wake_loop_threadsafe(); -#endif - break; - - case UART_FIFO_OVF: - case UART_BUFFER_FULL: - // Don't call uart_flush_input() here — this task does not own the read side. - // ESP-IDF examples flush on overflow because the same task handles both events - // and reads, so flush and read are serialized. Here, reads happen on the main - // loop, so flushing from this task races with read_array() and can destroy data - // mid-read. The driver self-heals without an explicit flush: uart_read_bytes() - // calls uart_check_buf_full() after each chunk, which moves stashed FIFO bytes - // into the ring buffer and re-enables RX interrupts once space is freed. - ESP_LOGW(TAG, "FIFO overflow or ring buffer full"); -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) - App.wake_loop_threadsafe(); -#endif - break; - - default: - // Ignore other event types - ESP_LOGVV(TAG, "Event type: %d", event.type); - break; - } - } +// ISR callback invoked by the ESP-IDF UART driver when data arrives. +// Wakes the main loop directly via vTaskNotifyGiveFromISR() — no queue or task needed. +void IRAM_ATTR IDFUARTComponent::uart_rx_isr_callback_(uart_port_t uart_num, uart_select_notif_t uart_select_notif, + BaseType_t *task_woken) { + if (uart_select_notif == UART_SELECT_READ_NOTIF) { + Application::wake_loop_isrsafe(task_woken); } } #endif // USE_UART_WAKE_LOOP_ON_RX diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index 1517eab509..ce8f4736d4 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -3,6 +3,9 @@ #ifdef USE_ESP32 #include +#ifdef USE_UART_WAKE_LOOP_ON_RX +#include +#endif #include "esphome/core/component.h" #include "uart_component.h" @@ -12,9 +15,7 @@ namespace esphome::uart { /// /// Thread safety: All public methods must only be called from the main loop. /// The ESP-IDF UART driver API does not guarantee thread safety, and ESPHome's -/// peek byte state (has_peek_/peek_byte_) is not synchronized. The rx_event_task -/// (when enabled) must not call any of these methods — it communicates with the -/// main loop exclusively via App.wake_loop_threadsafe(). +/// peek byte state (has_peek_/peek_byte_) is not synchronized. class IDFUARTComponent : public UARTComponent, public Component { public: void setup() override; @@ -33,9 +34,6 @@ class IDFUARTComponent : public UARTComponent, public Component { void flush() override; uint8_t get_hw_serial_number() { return this->uart_num_; } -#ifdef USE_UART_WAKE_LOOP_ON_RX - QueueHandle_t *get_uart_event_queue() { return &this->uart_event_queue_; } -#endif /** * Load the UART with the current settings. @@ -61,15 +59,9 @@ class IDFUARTComponent : public UARTComponent, public Component { uint8_t peek_byte_; #ifdef USE_UART_WAKE_LOOP_ON_RX - // RX notification support — runs on a separate FreeRTOS task. - // IMPORTANT: rx_event_task_func must NOT call any UART wrapper methods (read_array, - // write_array, etc.) or touch has_peek_/peek_byte_. It must only read from the - // event queue and call App.wake_loop_threadsafe(). - void start_rx_event_task_(); - static void rx_event_task_func(void *param); - - QueueHandle_t uart_event_queue_; - TaskHandle_t rx_event_task_handle_{nullptr}; + // ISR callback for UART RX data notification — wakes the main loop directly. + static void uart_rx_isr_callback_(uart_port_t uart_num, uart_select_notif_t uart_select_notif, + BaseType_t *task_woken); #endif // USE_UART_WAKE_LOOP_ON_RX }; diff --git a/esphome/core/application.h b/esphome/core/application.h index ba3ce13291..525ec264d8 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -500,6 +500,16 @@ class Application { /// On other platforms: uses UDP loopback socket void wake_loop_threadsafe(); #endif + +#if defined(USE_WAKE_LOOP_THREADSAFE) && defined(USE_LWIP_FAST_SELECT) + /// Wake the main event loop from an ISR. + /// Uses vTaskNotifyGiveFromISR() — <1 us, ISR-safe. + /// Only available on platforms with fast select (ESP32, LibreTiny). + /// @param pxHigherPriorityTaskWoken Set to pdTRUE if a context switch is needed. + static void IRAM_ATTR wake_loop_isrsafe(int *pxHigherPriorityTaskWoken) { + esphome_lwip_wake_main_loop_from_isr(pxHigherPriorityTaskWoken); + } +#endif #endif protected: diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 88cf23b67e..7c29ebc192 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -225,4 +225,12 @@ void esphome_lwip_wake_main_loop(void) { } } +// Wake the main loop from an ISR. ISR-safe variant. +void IRAM_ATTR esphome_lwip_wake_main_loop_from_isr(int *pxHigherPriorityTaskWoken) { + TaskHandle_t task = s_main_loop_task; + if (task != NULL) { + vTaskNotifyGiveFromISR(task, (BaseType_t *) pxHigherPriorityTaskWoken); + } +} + #endif // defined(USE_ESP32) || defined(USE_LIBRETINY) diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index b08c946212..fcf7ec805a 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -28,6 +28,11 @@ void esphome_lwip_hook_socket(int fd); /// NOT ISR-safe — must only be called from task context. void esphome_lwip_wake_main_loop(void); +/// Wake the main loop task from an ISR — costs <1 us. +/// ISR-safe variant using vTaskNotifyGiveFromISR(). +/// @param pxHigherPriorityTaskWoken Set to pdTRUE if a context switch is needed. +void esphome_lwip_wake_main_loop_from_isr(int *pxHigherPriorityTaskWoken); + #ifdef __cplusplus } #endif From 3c5d877d9619c141f81b5508c3c07a043df84a7a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Feb 2026 10:53:52 -1000 Subject: [PATCH 2/3] Fix clang-tidy naming: snake_case params, no trailing underscore on static method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pxHigherPriorityTaskWoken → px_higher_priority_task_woken - uart_rx_isr_callback_ → uart_rx_isr_callback --- esphome/components/uart/uart_component_esp_idf.cpp | 6 +++--- esphome/components/uart/uart_component_esp_idf.h | 3 +-- esphome/core/application.h | 6 +++--- esphome/core/lwip_fast_select.c | 4 ++-- esphome/core/lwip_fast_select.h | 4 ++-- 5 files changed, 11 insertions(+), 12 deletions(-) diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index 394c33d032..631db1e5c7 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -206,7 +206,7 @@ void IDFUARTComponent::load_settings(bool dump_config) { // Register ISR callback to wake the main loop when UART data arrives. // The callback runs in ISR context and uses vTaskNotifyGiveFromISR() to // wake the main loop task directly — no queue or FreeRTOS task needed. - uart_set_select_notif_callback(this->uart_num_, IDFUARTComponent::uart_rx_isr_callback_); + uart_set_select_notif_callback(this->uart_num_, IDFUARTComponent::uart_rx_isr_callback); #endif // USE_UART_WAKE_LOOP_ON_RX if (dump_config) { @@ -339,8 +339,8 @@ void IDFUARTComponent::check_logger_conflict() {} #ifdef USE_UART_WAKE_LOOP_ON_RX // ISR callback invoked by the ESP-IDF UART driver when data arrives. // Wakes the main loop directly via vTaskNotifyGiveFromISR() — no queue or task needed. -void IRAM_ATTR IDFUARTComponent::uart_rx_isr_callback_(uart_port_t uart_num, uart_select_notif_t uart_select_notif, - BaseType_t *task_woken) { +void IRAM_ATTR IDFUARTComponent::uart_rx_isr_callback(uart_port_t uart_num, uart_select_notif_t uart_select_notif, + BaseType_t *task_woken) { if (uart_select_notif == UART_SELECT_READ_NOTIF) { Application::wake_loop_isrsafe(task_woken); } diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index ce8f4736d4..7bb25f8dff 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -60,8 +60,7 @@ class IDFUARTComponent : public UARTComponent, public Component { #ifdef USE_UART_WAKE_LOOP_ON_RX // ISR callback for UART RX data notification — wakes the main loop directly. - static void uart_rx_isr_callback_(uart_port_t uart_num, uart_select_notif_t uart_select_notif, - BaseType_t *task_woken); + static void uart_rx_isr_callback(uart_port_t uart_num, uart_select_notif_t uart_select_notif, BaseType_t *task_woken); #endif // USE_UART_WAKE_LOOP_ON_RX }; diff --git a/esphome/core/application.h b/esphome/core/application.h index 525ec264d8..13e0f63885 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -505,9 +505,9 @@ class Application { /// Wake the main event loop from an ISR. /// Uses vTaskNotifyGiveFromISR() — <1 us, ISR-safe. /// Only available on platforms with fast select (ESP32, LibreTiny). - /// @param pxHigherPriorityTaskWoken Set to pdTRUE if a context switch is needed. - static void IRAM_ATTR wake_loop_isrsafe(int *pxHigherPriorityTaskWoken) { - esphome_lwip_wake_main_loop_from_isr(pxHigherPriorityTaskWoken); + /// @param px_higher_priority_task_woken Set to pdTRUE if a context switch is needed. + static void IRAM_ATTR wake_loop_isrsafe(int *px_higher_priority_task_woken) { + esphome_lwip_wake_main_loop_from_isr(px_higher_priority_task_woken); } #endif #endif diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 7c29ebc192..4e7ab07125 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -226,10 +226,10 @@ void esphome_lwip_wake_main_loop(void) { } // Wake the main loop from an ISR. ISR-safe variant. -void IRAM_ATTR esphome_lwip_wake_main_loop_from_isr(int *pxHigherPriorityTaskWoken) { +void IRAM_ATTR esphome_lwip_wake_main_loop_from_isr(int *px_higher_priority_task_woken) { TaskHandle_t task = s_main_loop_task; if (task != NULL) { - vTaskNotifyGiveFromISR(task, (BaseType_t *) pxHigherPriorityTaskWoken); + vTaskNotifyGiveFromISR(task, (BaseType_t *) px_higher_priority_task_woken); } } diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index fcf7ec805a..b7a70a8f9f 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -30,8 +30,8 @@ void esphome_lwip_wake_main_loop(void); /// Wake the main loop task from an ISR — costs <1 us. /// ISR-safe variant using vTaskNotifyGiveFromISR(). -/// @param pxHigherPriorityTaskWoken Set to pdTRUE if a context switch is needed. -void esphome_lwip_wake_main_loop_from_isr(int *pxHigherPriorityTaskWoken); +/// @param px_higher_priority_task_woken Set to pdTRUE if a context switch is needed. +void esphome_lwip_wake_main_loop_from_isr(int *px_higher_priority_task_woken); #ifdef __cplusplus } From 46bff3f3eb52b18da6d93c0e3e5dcd0dd3462901 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Feb 2026 10:48:33 -1000 Subject: [PATCH 3/3] [core] Wake main loop from ISR in enable_loop_soon_any_context() enable_loop_soon_any_context() sets volatile flags but does not wake the main loop from ulTaskNotifyTake() sleep. This means components using ISR-driven state changes (e.g. GPIO binary sensors) wait up to ~16ms for the select timeout before their loop runs. Add Application::wake_loop_isrsafe(nullptr) to immediately wake the main loop when called from ISR context on platforms with fast select. Also relax the wake_loop_isrsafe() guard from requiring both USE_WAKE_LOOP_THREADSAFE and USE_LWIP_FAST_SELECT to just USE_LWIP_FAST_SELECT, since the ISR wake path uses vTaskNotifyGiveFromISR directly and does not depend on the UDP socket mechanism. --- esphome/core/application.h | 2 +- esphome/core/component.cpp | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 13e0f63885..f3c09591ac 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -501,7 +501,7 @@ class Application { void wake_loop_threadsafe(); #endif -#if defined(USE_WAKE_LOOP_THREADSAFE) && defined(USE_LWIP_FAST_SELECT) +#ifdef USE_LWIP_FAST_SELECT /// Wake the main event loop from an ISR. /// Uses vTaskNotifyGiveFromISR() — <1 us, ISR-safe. /// Only available on platforms with fast select (ESP32, LibreTiny). diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 1fd621ea83..36eef1e6c8 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -323,6 +323,13 @@ void IRAM_ATTR HOT Component::enable_loop_soon_any_context() { // 8. Race condition with main loop is handled by clearing flag before processing this->pending_enable_loop_ = true; App.has_pending_enable_loop_requests_ = true; +#ifdef USE_LWIP_FAST_SELECT + // Wake the main loop if sleeping in ulTaskNotifyTake(). Without this, + // the main loop would not wake until the select timeout expires (~16ms). + // vTaskNotifyGiveFromISR(NULL) is safe here — it skips the yield flag + // and the context switch happens at the next scheduler tick (<1ms). + Application::wake_loop_isrsafe(nullptr); +#endif } void Component::reset_to_construction_state() { if ((this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_FAILED) {