From f446c0e699fc3e69b2d193f823443aadedde7871 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 12:32:55 -0500 Subject: [PATCH 1/7] [core] Split wake.{h,cpp} into per-platform files under esphome/core/wake/ Move each platform branch out of wake.h/wake.cpp into its own esphome/core/wake/wake_.{h,cpp} pair (wake_freertos for ESP32/LibreTiny, plus wake_esp8266, wake_rp2040, wake_host, and wake_generic for the Zephyr/NRF52 fallback). wake.h becomes a thin dispatcher that defines only the cross-platform wake_request_set/ wake_request_take helpers and then #include's the right platform header based on USE_*. wake.cpp is removed; each platform's g_wake_requested (and g_main_loop_woke where applicable) now lives in the translation unit that actually uses it, guarded by the same USE_* check. Because esphome/core/ source discovery was previously flat, add an opt-in recursive_sources flag to ComponentManifest and enable it only on the core ("esphome") manifest. Subdirectories that are themselves Python subpackages are still skipped so components (which already register platform subpackages independently) are unaffected. --- esphome/core/wake.h | 214 ++---------------- esphome/core/wake/wake_esp8266.cpp | 21 ++ esphome/core/wake/wake_esp8266.h | 47 ++++ esphome/core/wake/wake_freertos.cpp | 33 +++ esphome/core/wake/wake_freertos.h | 60 +++++ esphome/core/wake/wake_generic.cpp | 17 ++ esphome/core/wake/wake_generic.h | 31 +++ esphome/core/{wake.cpp => wake/wake_host.cpp} | 89 +------- esphome/core/wake/wake_host.h | 64 ++++++ esphome/core/wake/wake_rp2040.cpp | 58 +++++ esphome/core/wake/wake_rp2040.h | 31 +++ esphome/loader.py | 50 ++-- 12 files changed, 422 insertions(+), 293 deletions(-) create mode 100644 esphome/core/wake/wake_esp8266.cpp create mode 100644 esphome/core/wake/wake_esp8266.h create mode 100644 esphome/core/wake/wake_freertos.cpp create mode 100644 esphome/core/wake/wake_freertos.h create mode 100644 esphome/core/wake/wake_generic.cpp create mode 100644 esphome/core/wake/wake_generic.h rename esphome/core/{wake.cpp => wake/wake_host.cpp} (74%) create mode 100644 esphome/core/wake/wake_host.h create mode 100644 esphome/core/wake/wake_rp2040.cpp create mode 100644 esphome/core/wake/wake_rp2040.h diff --git a/esphome/core/wake.h b/esphome/core/wake.h index 0cfca94a78..a2f732fcdb 100644 --- a/esphome/core/wake.h +++ b/esphome/core/wake.h @@ -3,6 +3,10 @@ /// @file wake.h /// Platform-specific main loop wake primitives. /// Always available on all platforms — no opt-in needed. +/// +/// The public API for callers lives here; the per-platform implementations +/// live under esphome/core/wake/ and are included at the bottom of this file +/// based on the active USE_* platform define. #include "esphome/core/defines.h" #include "esphome/core/hal.h" @@ -11,21 +15,6 @@ #include #endif -#if defined(USE_ESP32) || defined(USE_LIBRETINY) -#include "esphome/core/main_task.h" -#endif -#ifdef USE_ESP8266 -#include -#elif defined(USE_RP2040) -#include -#include -#endif - -#ifdef USE_HOST -#include -#include -#endif - namespace esphome { // === Wake flag for ESP8266/RP2040 === @@ -67,184 +56,19 @@ __attribute__((always_inline)) inline bool wake_request_take() { } #endif -// === ESP32 / LibreTiny (FreeRTOS) === -#if defined(USE_ESP32) || defined(USE_LIBRETINY) - -/// Wake the main loop from any context (ISR or task). -/// always_inline so callers placed in IRAM keep the whole wake path in IRAM. -__attribute__((always_inline)) inline void wake_main_task_any_context() { - // Set the wake-requested flag BEFORE the task notification so the consumer - // (Application::loop() gate) is guaranteed to see it on its next gate check. - wake_request_set(); - if (in_isr_context()) { - BaseType_t px_higher_priority_task_woken = pdFALSE; - esphome_main_task_notify_from_isr(&px_higher_priority_task_woken); -#ifdef portYIELD_FROM_ISR - portYIELD_FROM_ISR(px_higher_priority_task_woken); -#else - // ARM9 FreeRTOS port (BK72xx) does not define portYIELD_FROM_ISR; the IRQ - // exit sequence performs the context switch if one was requested. - (void) px_higher_priority_task_woken; -#endif - } else { - esphome_main_task_notify(); - } -} - -/// IRAM_ATTR entry points — defined in wake.cpp. -void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); -void wake_loop_any_context(); - -inline void wake_loop_threadsafe() { - wake_request_set(); - esphome_main_task_notify(); -} - -namespace internal { -inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { - // Fast path (with USE_LWIP_FAST_SELECT): FreeRTOS task notifications posted by the lwip - // event_callback wrapper (see lwip_fast_select.c) are the single source of truth for - // socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification - // that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns - // immediately) or wakes a blocked Take directly. Additional wake sources: - // wake_loop_threadsafe() from background tasks, and the ms timeout. - if (ms == 0) [[unlikely]] { - yield(); - return; - } - ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(ms)); -} -} // namespace internal - -// === ESP8266 === -#elif defined(USE_ESP8266) - -/// Inline implementation — IRAM callers inline this directly. -inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() { - // Set the wake-requested flag BEFORE esp_schedule so the consumer is - // guaranteed to see it on its next gate check. - wake_request_set(); - g_main_loop_woke = true; - esp_schedule(); -} - -/// IRAM_ATTR entry point for ISR callers — defined in wake.cpp. -void wake_loop_any_context(); - -/// Non-ISR: always inline. -inline void wake_loop_threadsafe() { wake_loop_impl(); } - -/// ISR-safe: no task_woken arg because ESP8266 has no FreeRTOS. Caller must be IRAM_ATTR. -inline void ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { wake_loop_impl(); } - -namespace internal { -inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { - if (ms == 0) [[unlikely]] { - delay(0); - return; - } - if (g_main_loop_woke) { - g_main_loop_woke = false; - return; - } - esp_delay(ms, []() { return !g_main_loop_woke; }); -} -} // namespace internal - -// === RP2040 === -#elif defined(USE_RP2040) - -inline void wake_loop_any_context() { - // Set the wake-requested flag BEFORE the SEV so the consumer is guaranteed - // to see it on its next gate check. - wake_request_set(); - g_main_loop_woke = true; - __sev(); -} - -inline void wake_loop_threadsafe() { wake_loop_any_context(); } - -/// RP2040 wakeable delay uses file-scope state (alarm callback + flag) — defined in wake.cpp. -namespace internal { -void wakeable_delay(uint32_t ms); -} // namespace internal - -// === Host / Zephyr / other === -#else - -#ifdef USE_HOST -/// Host: wakes select() via UDP loopback socket. Defined in wake.cpp. -void wake_loop_threadsafe(); - -/// Register a socket file descriptor with the host select() loop. Not -/// thread-safe — main loop only. Returns false if fd is invalid or -/// >= FD_SETSIZE. -bool wake_register_fd(int fd); - -/// Unregister a socket file descriptor. Not thread-safe — main loop only. -void wake_unregister_fd(int fd); - -/// One-time setup of the loopback wake socket. Called from Application::setup(). -void wake_setup(); - -// wake_fd_ready() and wake_drain_notifications() are defined inline at the -// bottom of this file — they need internal::g_read_fds / g_wake_socket_fd in -// scope, which depend on USE_HOST-only includes pulled in above. -#else -/// Zephyr is currently the only platform without a wake mechanism. -/// wake_loop_threadsafe() is a no-op and wakeable_delay() falls back to delay(). -/// TODO: implement proper Zephyr wake using k_poll / k_sem or similar. -inline void wake_loop_threadsafe() {} -#endif - -inline void wake_loop_any_context() { wake_loop_threadsafe(); } - -namespace internal { -#ifdef USE_HOST -/// Host wakeable_delay uses select() over the registered fds — defined in wake.cpp. -void wakeable_delay(uint32_t ms); -#else -inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { - if (ms == 0) [[unlikely]] { - yield(); - return; - } - delay(ms); -} -#endif -} // namespace internal - -#endif - -#ifdef USE_HOST -namespace internal { -// File-scope state owned by wake.cpp. Accessed inline by wake_drain_notifications() -// and wake_fd_ready() so the hot path stays in the header. -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -extern int g_wake_socket_fd; -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -extern fd_set g_read_fds; -} // namespace internal - -inline bool ESPHOME_ALWAYS_INLINE wake_fd_ready(int fd) { return FD_ISSET(fd, &internal::g_read_fds); } - -// Small buffer for draining wake notification bytes (1 byte sent per wake). -// Sized to drain multiple notifications per recvfrom() without wasting stack. -inline constexpr size_t WAKE_NOTIFY_DRAIN_BUFFER_SIZE = 16; - -inline void ESPHOME_ALWAYS_INLINE wake_drain_notifications() { - // Called from main loop to drain any pending wake notifications. - // Must check wake_fd_ready() to avoid blocking on empty socket. - if (internal::g_wake_socket_fd >= 0 && wake_fd_ready(internal::g_wake_socket_fd)) { - char buffer[WAKE_NOTIFY_DRAIN_BUFFER_SIZE]; - // Drain all pending notifications with non-blocking reads. Multiple wake events - // may have triggered multiple writes, so drain until EWOULDBLOCK. We control - // both ends of this loopback socket (always 1 byte per wake), so no error - // checking — any error indicates catastrophic system failure. - while (::recvfrom(internal::g_wake_socket_fd, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) { - } - } -} -#endif // USE_HOST - } // namespace esphome + +// Per-platform implementations. Each header re-enters namespace esphome {} and +// guards its body with the matching USE_* check, so only one contributes code +// for the active target. +#if defined(USE_ESP32) || defined(USE_LIBRETINY) +#include "esphome/core/wake/wake_freertos.h" +#elif defined(USE_ESP8266) +#include "esphome/core/wake/wake_esp8266.h" +#elif defined(USE_RP2040) +#include "esphome/core/wake/wake_rp2040.h" +#elif defined(USE_HOST) +#include "esphome/core/wake/wake_host.h" +#else +#include "esphome/core/wake/wake_generic.h" +#endif diff --git a/esphome/core/wake/wake_esp8266.cpp b/esphome/core/wake/wake_esp8266.cpp new file mode 100644 index 0000000000..9ced43c6df --- /dev/null +++ b/esphome/core/wake/wake_esp8266.cpp @@ -0,0 +1,21 @@ +#include "esphome/core/defines.h" + +#ifdef USE_ESP8266 + +#include "esphome/core/hal.h" +#include "esphome/core/wake.h" + +namespace esphome { + +// === Wake-requested flag + main-loop woke flag storage === +// ESP8266 is always ESPHOME_THREAD_SINGLE. +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; +volatile bool g_main_loop_woke = false; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) + +void IRAM_ATTR wake_loop_any_context() { wake_loop_impl(); } + +} // namespace esphome + +#endif // USE_ESP8266 diff --git a/esphome/core/wake/wake_esp8266.h b/esphome/core/wake/wake_esp8266.h new file mode 100644 index 0000000000..80cd61035b --- /dev/null +++ b/esphome/core/wake/wake_esp8266.h @@ -0,0 +1,47 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESP8266 + +#include "esphome/core/hal.h" + +#include + +namespace esphome { + +/// Inline implementation — IRAM callers inline this directly. +inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() { + // Set the wake-requested flag BEFORE esp_schedule so the consumer is + // guaranteed to see it on its next gate check. + wake_request_set(); + g_main_loop_woke = true; + esp_schedule(); +} + +/// IRAM_ATTR entry point for ISR callers — defined in wake_esp8266.cpp. +void wake_loop_any_context(); + +/// Non-ISR: always inline. +inline void wake_loop_threadsafe() { wake_loop_impl(); } + +/// ISR-safe: no task_woken arg because ESP8266 has no FreeRTOS. Caller must be IRAM_ATTR. +inline void ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { wake_loop_impl(); } + +namespace internal { +inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { + if (ms == 0) [[unlikely]] { + delay(0); + return; + } + if (g_main_loop_woke) { + g_main_loop_woke = false; + return; + } + esp_delay(ms, []() { return !g_main_loop_woke; }); +} +} // namespace internal + +} // namespace esphome + +#endif // USE_ESP8266 diff --git a/esphome/core/wake/wake_freertos.cpp b/esphome/core/wake/wake_freertos.cpp new file mode 100644 index 0000000000..0bf700daa8 --- /dev/null +++ b/esphome/core/wake/wake_freertos.cpp @@ -0,0 +1,33 @@ +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + +#include "esphome/core/hal.h" +#include "esphome/core/wake.h" + +namespace esphome { + +// === Wake-requested flag storage === +// ESP32 is always MULTI_ATOMICS; LibreTiny is MULTI_ATOMICS on chips with +// proper atomics (e.g. RTL8720) and MULTI_NO_ATOMICS on others (e.g. BK72XX). +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +std::atomic g_wake_requested{0}; +#else +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; +#endif + +void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken) { + // ISR-safe: set flag before notify so the wake is visible on the next gate + // check. wake_request_set() is just an aligned 8-bit store / atomic store + // and is safe from IRAM. + wake_request_set(); + esphome_main_task_notify_from_isr(px_higher_priority_task_woken); +} + +void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); } + +} // namespace esphome + +#endif // USE_ESP32 || USE_LIBRETINY diff --git a/esphome/core/wake/wake_freertos.h b/esphome/core/wake/wake_freertos.h new file mode 100644 index 0000000000..167a422c61 --- /dev/null +++ b/esphome/core/wake/wake_freertos.h @@ -0,0 +1,60 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + +#include "esphome/core/hal.h" +#include "esphome/core/main_task.h" + +namespace esphome { + +/// Wake the main loop from any context (ISR or task). +/// always_inline so callers placed in IRAM keep the whole wake path in IRAM. +__attribute__((always_inline)) inline void wake_main_task_any_context() { + // Set the wake-requested flag BEFORE the task notification so the consumer + // (Application::loop() gate) is guaranteed to see it on its next gate check. + wake_request_set(); + if (in_isr_context()) { + BaseType_t px_higher_priority_task_woken = pdFALSE; + esphome_main_task_notify_from_isr(&px_higher_priority_task_woken); +#ifdef portYIELD_FROM_ISR + portYIELD_FROM_ISR(px_higher_priority_task_woken); +#else + // ARM9 FreeRTOS port (BK72xx) does not define portYIELD_FROM_ISR; the IRQ + // exit sequence performs the context switch if one was requested. + (void) px_higher_priority_task_woken; +#endif + } else { + esphome_main_task_notify(); + } +} + +/// IRAM_ATTR entry points — defined in wake_freertos.cpp. +void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); +void wake_loop_any_context(); + +inline void wake_loop_threadsafe() { + wake_request_set(); + esphome_main_task_notify(); +} + +namespace internal { +inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { + // Fast path (with USE_LWIP_FAST_SELECT): FreeRTOS task notifications posted by the lwip + // event_callback wrapper (see lwip_fast_select.c) are the single source of truth for + // socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification + // that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns + // immediately) or wakes a blocked Take directly. Additional wake sources: + // wake_loop_threadsafe() from background tasks, and the ms timeout. + if (ms == 0) [[unlikely]] { + yield(); + return; + } + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(ms)); +} +} // namespace internal + +} // namespace esphome + +#endif // USE_ESP32 || USE_LIBRETINY diff --git a/esphome/core/wake/wake_generic.cpp b/esphome/core/wake/wake_generic.cpp new file mode 100644 index 0000000000..40044e4311 --- /dev/null +++ b/esphome/core/wake/wake_generic.cpp @@ -0,0 +1,17 @@ +#include "esphome/core/defines.h" + +#if !defined(USE_ESP32) && !defined(USE_LIBRETINY) && !defined(USE_ESP8266) && !defined(USE_RP2040) && \ + !defined(USE_HOST) + +#include "esphome/core/wake.h" + +namespace esphome { + +// === Wake-requested flag storage === +// Fallback platforms (currently only Zephyr/NRF52) are ESPHOME_THREAD_SINGLE. +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; + +} // namespace esphome + +#endif // fallback guard diff --git a/esphome/core/wake/wake_generic.h b/esphome/core/wake/wake_generic.h new file mode 100644 index 0000000000..85424b6138 --- /dev/null +++ b/esphome/core/wake/wake_generic.h @@ -0,0 +1,31 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if !defined(USE_ESP32) && !defined(USE_LIBRETINY) && !defined(USE_ESP8266) && !defined(USE_RP2040) && \ + !defined(USE_HOST) + +#include "esphome/core/hal.h" + +namespace esphome { + +/// Zephyr is currently the only platform without a wake mechanism. +/// wake_loop_threadsafe() is a no-op and wakeable_delay() falls back to delay(). +/// TODO: implement proper Zephyr wake using k_poll / k_sem or similar. +inline void wake_loop_threadsafe() {} + +inline void wake_loop_any_context() { wake_loop_threadsafe(); } + +namespace internal { +inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { + if (ms == 0) [[unlikely]] { + yield(); + return; + } + delay(ms); +} +} // namespace internal + +} // namespace esphome + +#endif // fallback guard diff --git a/esphome/core/wake.cpp b/esphome/core/wake/wake_host.cpp similarity index 74% rename from esphome/core/wake.cpp rename to esphome/core/wake/wake_host.cpp index cac88ae91e..9d2a650ca2 100644 --- a/esphome/core/wake.cpp +++ b/esphome/core/wake/wake_host.cpp @@ -1,12 +1,11 @@ -#include "esphome/core/wake.h" -#include "esphome/core/hal.h" -#include "esphome/core/log.h" - -#ifdef USE_ESP8266 -#include -#endif +#include "esphome/core/defines.h" #ifdef USE_HOST + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "esphome/core/wake.h" + #include #include #include @@ -15,88 +14,19 @@ #include #include #include -#endif namespace esphome { // === Wake-requested flag storage === -#ifdef ESPHOME_THREAD_MULTI_ATOMICS +// Host is always ESPHOME_THREAD_MULTI_ATOMICS. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) std::atomic g_wake_requested{0}; -#else -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -volatile uint8_t g_wake_requested = 0; -#endif - -// === ESP32 / LibreTiny — IRAM_ATTR entry points === -#if defined(USE_ESP32) || defined(USE_LIBRETINY) -void IRAM_ATTR wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken) { - // ISR-safe: set flag before notify so the wake is visible on the next gate - // check. wake_request_set() is just an aligned 8-bit store / atomic store - // and is safe from IRAM. - wake_request_set(); - esphome_main_task_notify_from_isr(px_higher_priority_task_woken); -} -void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); } -#endif - -// === ESP8266 / RP2040 === -#if defined(USE_ESP8266) || defined(USE_RP2040) -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -volatile bool g_main_loop_woke = false; -#endif - -#ifdef USE_ESP8266 -void IRAM_ATTR wake_loop_any_context() { wake_loop_impl(); } -#endif - -// === RP2040 — wakeable_delay (needs file-scope state for alarm callback) === -#ifdef USE_RP2040 -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -static volatile bool s_delay_expired = false; - -static int64_t alarm_callback_(alarm_id_t id, void *user_data) { - (void) id; - (void) user_data; - s_delay_expired = true; - __sev(); - return 0; -} - -namespace internal { -void wakeable_delay(uint32_t ms) { - if (ms == 0) [[unlikely]] { - yield(); - return; - } - if (g_main_loop_woke) { - g_main_loop_woke = false; - return; - } - s_delay_expired = false; - alarm_id_t alarm = add_alarm_in_ms(ms, alarm_callback_, nullptr, true); - if (alarm <= 0) { - delay(ms); - return; - } - while (!g_main_loop_woke && !s_delay_expired) { - __wfe(); - } - if (!s_delay_expired) - cancel_alarm(alarm); - g_main_loop_woke = false; -} -} // namespace internal -#endif // USE_RP2040 - -// === Host (UDP loopback socket + select() based fd watcher) === -#ifdef USE_HOST static const char *const TAG = "wake"; namespace internal { // File-scope state — referenced inline by wake_drain_notifications() and -// wake_fd_ready() in wake.h, and by the bodies in this file. +// wake_fd_ready() in wake_host.h, and by the bodies in this file. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) int g_wake_socket_fd = -1; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) @@ -271,6 +201,7 @@ void wake_setup() { return; } } -#endif // USE_HOST } // namespace esphome + +#endif // USE_HOST diff --git a/esphome/core/wake/wake_host.h b/esphome/core/wake/wake_host.h new file mode 100644 index 0000000000..9756ed4c39 --- /dev/null +++ b/esphome/core/wake/wake_host.h @@ -0,0 +1,64 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_HOST + +#include "esphome/core/hal.h" + +#include +#include + +namespace esphome { + +/// Host: wakes select() via UDP loopback socket. Defined in wake_host.cpp. +void wake_loop_threadsafe(); + +/// Register a socket file descriptor with the host select() loop. Not +/// thread-safe — main loop only. Returns false if fd is invalid or +/// >= FD_SETSIZE. +bool wake_register_fd(int fd); + +/// Unregister a socket file descriptor. Not thread-safe — main loop only. +void wake_unregister_fd(int fd); + +/// One-time setup of the loopback wake socket. Called from Application::setup(). +void wake_setup(); + +inline void wake_loop_any_context() { wake_loop_threadsafe(); } + +namespace internal { +/// Host wakeable_delay uses select() over the registered fds — defined in wake_host.cpp. +void wakeable_delay(uint32_t ms); + +// File-scope state owned by wake_host.cpp. Accessed inline by +// wake_drain_notifications() and wake_fd_ready() so the hot path stays in the header. +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern int g_wake_socket_fd; +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +extern fd_set g_read_fds; +} // namespace internal + +inline bool ESPHOME_ALWAYS_INLINE wake_fd_ready(int fd) { return FD_ISSET(fd, &internal::g_read_fds); } + +// Small buffer for draining wake notification bytes (1 byte sent per wake). +// Sized to drain multiple notifications per recvfrom() without wasting stack. +inline constexpr size_t WAKE_NOTIFY_DRAIN_BUFFER_SIZE = 16; + +inline void ESPHOME_ALWAYS_INLINE wake_drain_notifications() { + // Called from main loop to drain any pending wake notifications. + // Must check wake_fd_ready() to avoid blocking on empty socket. + if (internal::g_wake_socket_fd >= 0 && wake_fd_ready(internal::g_wake_socket_fd)) { + char buffer[WAKE_NOTIFY_DRAIN_BUFFER_SIZE]; + // Drain all pending notifications with non-blocking reads. Multiple wake events + // may have triggered multiple writes, so drain until EWOULDBLOCK. We control + // both ends of this loopback socket (always 1 byte per wake), so no error + // checking — any error indicates catastrophic system failure. + while (::recvfrom(internal::g_wake_socket_fd, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) { + } + } +} + +} // namespace esphome + +#endif // USE_HOST diff --git a/esphome/core/wake/wake_rp2040.cpp b/esphome/core/wake/wake_rp2040.cpp new file mode 100644 index 0000000000..b18248dbd2 --- /dev/null +++ b/esphome/core/wake/wake_rp2040.cpp @@ -0,0 +1,58 @@ +#include "esphome/core/defines.h" + +#ifdef USE_RP2040 + +#include "esphome/core/hal.h" +#include "esphome/core/wake.h" + +#include +#include + +namespace esphome { + +// === Wake-requested flag + main-loop woke flag storage === +// RP2040 is always ESPHOME_THREAD_SINGLE. +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +volatile uint8_t g_wake_requested = 0; +volatile bool g_main_loop_woke = false; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) + +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +static volatile bool s_delay_expired = false; + +static int64_t alarm_callback_(alarm_id_t id, void *user_data) { + (void) id; + (void) user_data; + s_delay_expired = true; + __sev(); + return 0; +} + +namespace internal { +void wakeable_delay(uint32_t ms) { + if (ms == 0) [[unlikely]] { + yield(); + return; + } + if (g_main_loop_woke) { + g_main_loop_woke = false; + return; + } + s_delay_expired = false; + alarm_id_t alarm = add_alarm_in_ms(ms, alarm_callback_, nullptr, true); + if (alarm <= 0) { + delay(ms); + return; + } + while (!g_main_loop_woke && !s_delay_expired) { + __wfe(); + } + if (!s_delay_expired) + cancel_alarm(alarm); + g_main_loop_woke = false; +} +} // namespace internal + +} // namespace esphome + +#endif // USE_RP2040 diff --git a/esphome/core/wake/wake_rp2040.h b/esphome/core/wake/wake_rp2040.h new file mode 100644 index 0000000000..ea1242f535 --- /dev/null +++ b/esphome/core/wake/wake_rp2040.h @@ -0,0 +1,31 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_RP2040 + +#include "esphome/core/hal.h" + +#include +#include + +namespace esphome { + +inline void wake_loop_any_context() { + // Set the wake-requested flag BEFORE the SEV so the consumer is guaranteed + // to see it on its next gate check. + wake_request_set(); + g_main_loop_woke = true; + __sev(); +} + +inline void wake_loop_threadsafe() { wake_loop_any_context(); } + +/// RP2040 wakeable delay uses file-scope state (alarm callback + flag) — defined in wake_rp2040.cpp. +namespace internal { +void wakeable_delay(uint32_t ms); +} // namespace internal + +} // namespace esphome + +#endif // USE_RP2040 diff --git a/esphome/loader.py b/esphome/loader.py index 68664aaa26..9390b8094b 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -31,8 +31,9 @@ class FileResource: class ComponentManifest: - def __init__(self, module: ModuleType): + def __init__(self, module: ModuleType, recursive_sources: bool = False): self.module = module + self.recursive_sources = recursive_sources @property def package(self) -> str: @@ -108,8 +109,10 @@ class ComponentManifest: def resources(self) -> list[FileResource]: """Return a list of all file resources defined in the package of this component. - This will return all cpp source files that are located in the same folder as the - loaded .py file (does not look through subdirectories) + By default only files directly in the package directory are returned. Manifests + constructed with ``recursive_sources=True`` also descend into non-subpackage + subdirectories (subdirectories without an ``__init__.py``), so core code can + live under ``esphome/core//`` without every component paying the cost. """ ret: list[FileResource] = [] @@ -121,23 +124,30 @@ class ComponentManifest: set(filter_source_files_func()) if filter_source_files_func else set() ) - # Process all resources - for resource in ( - r.name - for r in importlib.resources.files(self.package).iterdir() - if r.is_file() - ): - if Path(resource).suffix not in SOURCE_FILE_EXTENSIONS: - continue - if not importlib.resources.files(self.package).joinpath(resource).is_file(): - # Not a resource = this is a directory (yeah this is confusing) - continue + root = importlib.resources.files(self.package) - # Skip excluded files - if resource in excluded_files: - continue + for child in root.iterdir(): + name = child.name + if child.is_file(): + if Path(name).suffix not in SOURCE_FILE_EXTENSIONS: + continue + if name in excluded_files: + continue + ret.append(FileResource(self.package, name)) + elif self.recursive_sources and child.is_dir() and name != "__pycache__": + # Skip Python subpackages — they load as their own components. + if child.joinpath("__init__.py").is_file(): + continue + for sub in child.iterdir(): + if not sub.is_file(): + continue + if Path(sub.name).suffix not in SOURCE_FILE_EXTENSIONS: + continue + resource = f"{name}/{sub.name}" + if resource in excluded_files: + continue + ret.append(FileResource(self.package, resource)) - ret.append(FileResource(self.package, resource)) return ret @@ -237,7 +247,9 @@ def get_platform(domain: str, platform: str) -> ComponentManifest | None: _COMPONENT_CACHE: dict[str, ComponentManifest] = {} CORE_COMPONENTS_PATH = (Path(__file__).parent / "components").resolve() -_COMPONENT_CACHE["esphome"] = ComponentManifest(esphome.core.config) +_COMPONENT_CACHE["esphome"] = ComponentManifest( + esphome.core.config, recursive_sources=True +) def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> None: From 773e74ba8e0170b59ec7d9b3834b46e8527149dc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 12:35:36 -0500 Subject: [PATCH 2/7] [core] Add loader tests for recursive_sources walk Cover the four paths introduced by the wake split: - default manifests (recursive_sources=False) skip subdirs entirely, - opt-in manifests walk non-subpackage subdirs with /-joined paths, - subdirs containing __init__.py are skipped to avoid double-counting existing component subpackages, and __pycache__ is always skipped, - FILTER_SOURCE_FILES entries accept /-joined subpaths. --- tests/unit_tests/test_loader.py | 164 ++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/tests/unit_tests/test_loader.py b/tests/unit_tests/test_loader.py index a42cc5cca7..3fb0eca4a0 100644 --- a/tests/unit_tests/test_loader.py +++ b/tests/unit_tests/test_loader.py @@ -158,3 +158,167 @@ def test_component_manifest_resources_with_filter_source_files() -> None: # Verify the correct number of resources assert len(resources) == 3 # test.cpp, test.h, common.cpp + + +# --------------------------------------------------------------------------- +# recursive_sources — used only by the core "esphome" manifest so that files +# in esphome/core//*.cpp (e.g. esphome/core/wake/wake_host.cpp) are +# discovered without promoting / to a Python subpackage. +# --------------------------------------------------------------------------- + + +def _mock_file(filename: str) -> MagicMock: + m = MagicMock() + m.name = filename + m.is_file.return_value = True + m.is_dir.return_value = False + return m + + +def _mock_dir(dirname: str, children: list, has_init: bool = False) -> MagicMock: + """Mock a directory entry with an iterdir() and joinpath('__init__.py').""" + d = MagicMock() + d.name = dirname + d.is_file.return_value = False + d.is_dir.return_value = True + d.iterdir.return_value = children + init_marker = MagicMock() + init_marker.is_file.return_value = has_init + d.joinpath.return_value = init_marker + return d + + +def test_component_manifest_resources_non_recursive_skips_subdirs() -> None: + """Default (recursive_sources=False) does not descend into subdirectories.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.components.test_component" + # No FILTER_SOURCE_FILES. + del mock_module.FILTER_SOURCE_FILES + + manifest = ComponentManifest(mock_module) # recursive_sources defaults to False + + top_level = [ + _mock_file("top.cpp"), + _mock_dir("subdir", [_mock_file("nested.cpp")]), + ] + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = top_level + mock_files_func.return_value = pkg + + names = [r.resource for r in manifest.resources] + + assert names == ["top.cpp"] + + +def test_component_manifest_resources_recursive_walks_non_subpackage_subdirs() -> None: + """With recursive_sources=True, a subdir without __init__.py is walked.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.core" + del mock_module.FILTER_SOURCE_FILES + + manifest = ComponentManifest(mock_module, recursive_sources=True) + + wake_dir = _mock_dir( + "wake", + [ + _mock_file("wake_host.cpp"), + _mock_file("wake_host.h"), + _mock_file("README.md"), # wrong suffix, excluded + ], + has_init=False, + ) + top_level = [ + _mock_file("wake.h"), + wake_dir, + ] + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = top_level + mock_files_func.return_value = pkg + + names = sorted(r.resource for r in manifest.resources) + + assert names == ["wake.h", "wake/wake_host.cpp", "wake/wake_host.h"] + + +def test_component_manifest_resources_recursive_skips_subpackages() -> None: + """Subdirectories that ARE Python subpackages (contain __init__.py) are + skipped even with recursive_sources=True — those load as their own + ComponentManifest and would otherwise be double-counted.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.components.haier" + del mock_module.FILTER_SOURCE_FILES + + manifest = ComponentManifest(mock_module, recursive_sources=True) + + button_pkg = _mock_dir( + "button", + [_mock_file("self_cleaning.cpp")], + has_init=True, # Python subpackage — must be skipped. + ) + top_level = [ + _mock_file("haier.cpp"), + button_pkg, + ] + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = top_level + mock_files_func.return_value = pkg + + names = [r.resource for r in manifest.resources] + + assert names == ["haier.cpp"] + + +def test_component_manifest_resources_recursive_skips_pycache() -> None: + """__pycache__ inside a recursive walk must never be descended into.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.core" + del mock_module.FILTER_SOURCE_FILES + + manifest = ComponentManifest(mock_module, recursive_sources=True) + + # __pycache__ is_dir=True but must be skipped without checking __init__.py + # or calling iterdir (would yield compiled artifacts). + pycache = _mock_dir("__pycache__", [_mock_file("wake.cpython-314.pyc")]) + top_level = [ + _mock_file("wake.h"), + pycache, + ] + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = top_level + mock_files_func.return_value = pkg + + names = [r.resource for r in manifest.resources] + + assert names == ["wake.h"] + + +def test_component_manifest_resources_recursive_filter_source_files_supports_subpaths() -> ( + None +): + """FILTER_SOURCE_FILES entries using '/'-joined subpaths exclude files + inside a recursively-walked subdir.""" + mock_module = MagicMock() + mock_module.__package__ = "esphome.core" + mock_module.FILTER_SOURCE_FILES = lambda: ["wake/wake_host.cpp"] + + manifest = ComponentManifest(mock_module, recursive_sources=True) + + wake_dir = _mock_dir( + "wake", + [ + _mock_file("wake_host.cpp"), # excluded + _mock_file("wake_freertos.cpp"), # kept + ], + ) + with patch("importlib.resources.files") as mock_files_func: + pkg = MagicMock() + pkg.iterdir.return_value = [wake_dir] + mock_files_func.return_value = pkg + + names = [r.resource for r in manifest.resources] + + assert names == ["wake/wake_freertos.cpp"] From 881c74febcbe5368554898bfb9b91c7482bc6103 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 12:37:58 -0500 Subject: [PATCH 3/7] [core] Filter wake/wake_*.cpp to only compile on the active platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wake.h's platform #include already ensures only one header contributes code, but all five wake/*.cpp files were still being copied and compiled (as empty TUs) on every target. Extend FILTER_SOURCE_FILES to map each wake_.cpp to the exact set of PlatformFramework values that need it — same pattern ring_buffer.cpp already uses. --- esphome/core/config.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/esphome/core/config.py b/esphome/core/config.py index bf210876df..131c94b7d6 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -786,6 +786,29 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, + # Per-platform wake implementations — wake.h dispatches to exactly one of + # these based on USE_*, so the others can be skipped at the source level + # too. Header files next to each .cpp are always copied (the dispatcher + # #include's them) but compile to empty TUs on the wrong platform anyway. + "wake/wake_freertos.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + PlatformFramework.BK72XX_ARDUINO, + PlatformFramework.RTL87XX_ARDUINO, + PlatformFramework.LN882X_ARDUINO, + }, + "wake/wake_esp8266.cpp": { + PlatformFramework.ESP8266_ARDUINO, + }, + "wake/wake_rp2040.cpp": { + PlatformFramework.RP2040_ARDUINO, + }, + "wake/wake_host.cpp": { + PlatformFramework.HOST_NATIVE, + }, + "wake/wake_generic.cpp": { + PlatformFramework.NRF52_ZEPHYR, + }, # Note: lock_free_queue.h and event_pool.h are header files and don't need to be filtered # as they are only included when needed by the preprocessor } From 522395f93b6accbefb7388a75e216843dcbcb135 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 12:50:23 -0500 Subject: [PATCH 4/7] [core] Match wake storage conditionals across platform TUs ESPHome's defines.h unconditionally #defines every feature flag for static analysis, so clang-tidy sees ESPHOME_THREAD_MULTI_ATOMICS even when building wake_esp8266.cpp / wake_rp2040.cpp / wake_generic.cpp. In that mode wake.h picks the std::atomic extern, and the plain-volatile definitions in those TUs trip a redefinition error. Guard each TU's g_wake_requested with the same ESPHOME_THREAD_MULTI_ATOMICS check wake.h uses. Real builds still pick volatile on every SINGLE platform. --- esphome/core/wake/wake_esp8266.cpp | 8 +++++++- esphome/core/wake/wake_generic.cpp | 12 ++++++++++-- esphome/core/wake/wake_rp2040.cpp | 8 +++++++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/esphome/core/wake/wake_esp8266.cpp b/esphome/core/wake/wake_esp8266.cpp index 9ced43c6df..4c12470148 100644 --- a/esphome/core/wake/wake_esp8266.cpp +++ b/esphome/core/wake/wake_esp8266.cpp @@ -8,9 +8,15 @@ namespace esphome { // === Wake-requested flag + main-loop woke flag storage === -// ESP8266 is always ESPHOME_THREAD_SINGLE. +// ESP8266 is always ESPHOME_THREAD_SINGLE in real builds, but defines.h (used +// for static analysis / IDE) can define ESPHOME_THREAD_MULTI_ATOMICS alongside +// USE_ESP8266, so the storage type here has to match wake.h's conditional. // NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +std::atomic g_wake_requested{0}; +#else volatile uint8_t g_wake_requested = 0; +#endif volatile bool g_main_loop_woke = false; // NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/core/wake/wake_generic.cpp b/esphome/core/wake/wake_generic.cpp index 40044e4311..b90aa593b3 100644 --- a/esphome/core/wake/wake_generic.cpp +++ b/esphome/core/wake/wake_generic.cpp @@ -8,9 +8,17 @@ namespace esphome { // === Wake-requested flag storage === -// Fallback platforms (currently only Zephyr/NRF52) are ESPHOME_THREAD_SINGLE. -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +// Fallback platforms (currently only Zephyr/NRF52) are ESPHOME_THREAD_SINGLE in +// real builds, but defines.h (used for static analysis / IDE) can define +// ESPHOME_THREAD_MULTI_ATOMICS here too, so the storage type has to match +// wake.h's conditional. +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +std::atomic g_wake_requested{0}; +#else volatile uint8_t g_wake_requested = 0; +#endif +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome diff --git a/esphome/core/wake/wake_rp2040.cpp b/esphome/core/wake/wake_rp2040.cpp index b18248dbd2..3fa47d4dcc 100644 --- a/esphome/core/wake/wake_rp2040.cpp +++ b/esphome/core/wake/wake_rp2040.cpp @@ -11,9 +11,15 @@ namespace esphome { // === Wake-requested flag + main-loop woke flag storage === -// RP2040 is always ESPHOME_THREAD_SINGLE. +// RP2040 is always ESPHOME_THREAD_SINGLE in real builds, but defines.h (used +// for static analysis / IDE) can define ESPHOME_THREAD_MULTI_ATOMICS alongside +// USE_RP2040, so the storage type here has to match wake.h's conditional. // NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +std::atomic g_wake_requested{0}; +#else volatile uint8_t g_wake_requested = 0; +#endif volatile bool g_main_loop_woke = false; // NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) From 68442f067484f2364904e3f6c3c4938e151a87de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 12:54:50 -0500 Subject: [PATCH 5/7] [core] Let defines.h pick the right thread model per platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit defines.h previously hardcoded ESPHOME_THREAD_MULTI_ATOMICS regardless of the active USE_, so the clang-tidy envs ended up compiling e.g. wake_esp8266.cpp with USE_ESP8266 + MULTI_ATOMICS both set — a combination that cannot occur in a real build and that mismatched the extern in wake.h. Pick the model that real codegen uses: USE_ESP8266 / USE_RP2040 / USE_NRF52 → SINGLE USE_BK72XX (ARMv5TE, no LDREX/STREX) → MULTI_NO_ATOMICS everything else (ESP32, host, RTL87XX, LN882X) → MULTI_ATOMICS With that, the single-only wake TUs (esp8266, rp2040, generic) can drop the defensive conditional and just define g_wake_requested as plain volatile again. --- esphome/core/defines.h | 15 ++++++++++++++- esphome/core/wake/wake_esp8266.cpp | 8 +------- esphome/core/wake/wake_generic.cpp | 12 ++---------- esphome/core/wake/wake_rp2040.cpp | 8 +------- 4 files changed, 18 insertions(+), 25 deletions(-) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 80247f69da..5a14c47231 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -17,8 +17,21 @@ #define ESPHOME_DEBUG_SCHEDULER #define ESPHOME_DEBUG_API -// Default threading model for static analysis (ESP32 is multi-threaded with atomics) +// Threading model for static analysis. Match what the real codegen picks per +// platform (see esphome/components//__init__.py ThreadModel.*): +// USE_ESP8266 / USE_RP2040 / USE_NRF52 → SINGLE +// USE_BK72XX (ARMv5TE, no LDREX/STREX) → MULTI_NO_ATOMICS +// everything else (ESP32, host, RTL87XX, LN882X) → MULTI_ATOMICS +// Without this the clang-tidy envs end up with USE_ +// + MULTI_ATOMICS simultaneously, a combination that can never occur in a +// real build. +#if defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_NRF52) +#define ESPHOME_THREAD_SINGLE +#elif defined(USE_BK72XX) +#define ESPHOME_THREAD_MULTI_NO_ATOMICS +#else #define ESPHOME_THREAD_MULTI_ATOMICS +#endif // logger #define ESPHOME_LOG_LEVEL ESPHOME_LOG_LEVEL_VERY_VERBOSE diff --git a/esphome/core/wake/wake_esp8266.cpp b/esphome/core/wake/wake_esp8266.cpp index 4c12470148..9ced43c6df 100644 --- a/esphome/core/wake/wake_esp8266.cpp +++ b/esphome/core/wake/wake_esp8266.cpp @@ -8,15 +8,9 @@ namespace esphome { // === Wake-requested flag + main-loop woke flag storage === -// ESP8266 is always ESPHOME_THREAD_SINGLE in real builds, but defines.h (used -// for static analysis / IDE) can define ESPHOME_THREAD_MULTI_ATOMICS alongside -// USE_ESP8266, so the storage type here has to match wake.h's conditional. +// ESP8266 is always ESPHOME_THREAD_SINGLE. // NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) -#ifdef ESPHOME_THREAD_MULTI_ATOMICS -std::atomic g_wake_requested{0}; -#else volatile uint8_t g_wake_requested = 0; -#endif volatile bool g_main_loop_woke = false; // NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/core/wake/wake_generic.cpp b/esphome/core/wake/wake_generic.cpp index b90aa593b3..40044e4311 100644 --- a/esphome/core/wake/wake_generic.cpp +++ b/esphome/core/wake/wake_generic.cpp @@ -8,17 +8,9 @@ namespace esphome { // === Wake-requested flag storage === -// Fallback platforms (currently only Zephyr/NRF52) are ESPHOME_THREAD_SINGLE in -// real builds, but defines.h (used for static analysis / IDE) can define -// ESPHOME_THREAD_MULTI_ATOMICS here too, so the storage type has to match -// wake.h's conditional. -// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) -#ifdef ESPHOME_THREAD_MULTI_ATOMICS -std::atomic g_wake_requested{0}; -#else +// Fallback platforms (currently only Zephyr/NRF52) are ESPHOME_THREAD_SINGLE. +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) volatile uint8_t g_wake_requested = 0; -#endif -// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome diff --git a/esphome/core/wake/wake_rp2040.cpp b/esphome/core/wake/wake_rp2040.cpp index 3fa47d4dcc..b18248dbd2 100644 --- a/esphome/core/wake/wake_rp2040.cpp +++ b/esphome/core/wake/wake_rp2040.cpp @@ -11,15 +11,9 @@ namespace esphome { // === Wake-requested flag + main-loop woke flag storage === -// RP2040 is always ESPHOME_THREAD_SINGLE in real builds, but defines.h (used -// for static analysis / IDE) can define ESPHOME_THREAD_MULTI_ATOMICS alongside -// USE_RP2040, so the storage type here has to match wake.h's conditional. +// RP2040 is always ESPHOME_THREAD_SINGLE. // NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) -#ifdef ESPHOME_THREAD_MULTI_ATOMICS -std::atomic g_wake_requested{0}; -#else volatile uint8_t g_wake_requested = 0; -#endif volatile bool g_main_loop_woke = false; // NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) From 83625d2c0e6b3e2f3df63bb8932f5c4b25e98f23 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 13:06:57 -0500 Subject: [PATCH 6/7] [core] Fix clang-tidy naming in time_64.h SINGLE branch The static class members in Millis64Impl's ESPHOME_THREAD_SINGLE branch kept the trailing-underscore convention used for instance members, but the project's clang-tidy config (readability-identifier-naming. ClassMemberCase = lower_case) wants static class members without the suffix. This violation was latent because defines.h previously hardcoded MULTI_ATOMICS, so the SINGLE branch was never analyzed; now that defines.h picks the model per platform, the ESP8266 tidy env analyzes this branch. Drop the trailing _ from last_millis / millis_major; their types and initial values are unchanged. --- esphome/core/time_64.cpp | 4 ++-- esphome/core/time_64.h | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/esphome/core/time_64.cpp b/esphome/core/time_64.cpp index cf651c3e91..25076228d5 100644 --- a/esphome/core/time_64.cpp +++ b/esphome/core/time_64.cpp @@ -22,8 +22,8 @@ static const char *const TAG = "time_64"; #ifdef ESPHOME_THREAD_SINGLE // Storage for Millis64Impl inline compute() — defined here so all TUs share one copy. -uint32_t Millis64Impl::last_millis_{0}; -uint16_t Millis64Impl::millis_major_{0}; +uint32_t Millis64Impl::last_millis{0}; +uint16_t Millis64Impl::millis_major{0}; #else uint64_t Millis64Impl::compute(uint32_t now) { diff --git a/esphome/core/time_64.h b/esphome/core/time_64.h index 592e645d41..d82373dbfe 100644 --- a/esphome/core/time_64.h +++ b/esphome/core/time_64.h @@ -21,8 +21,8 @@ class Millis64Impl { #ifdef ESPHOME_THREAD_SINGLE // Storage defined in time_64.cpp — declared here so the inline body can access them. - static uint32_t last_millis_; - static uint16_t millis_major_; + static uint32_t last_millis; + static uint16_t millis_major; static inline uint64_t ESPHOME_ALWAYS_INLINE compute(uint32_t now) { // Half the 32-bit range - used to detect rollovers vs normal time progression @@ -30,17 +30,17 @@ class Millis64Impl { // Single-core platforms have no concurrency, so this is a simple implementation // that just tracks 32-bit rollover (every 49.7 days) without any locking or atomics. - uint16_t major = millis_major_; - uint32_t last = last_millis_; + uint16_t major = millis_major; + uint32_t last = last_millis; // Check for rollover if (now < last && (last - now) > HALF_MAX_UINT32) { - millis_major_++; + millis_major++; major++; - last_millis_ = now; + last_millis = now; } else if (now > last) { // Only update if time moved forward - last_millis_ = now; + last_millis = now; } // Combine major (high 32 bits) and now (low 32 bits) into 64-bit time From c30aa4aad4c34df7226d4dc5002e101aeca000c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 04:55:43 -0500 Subject: [PATCH 7/7] [core] wakeable_delay: yield on already-woken fast path (ESP8266, RP2040) When `g_main_loop_woke` is already set on entry to `wakeable_delay()`, both the ESP8266 and RP2040 paths consume the flag and return without yielding. That's safe in isolation, but if a caller loops on `wakeable_delay()` (e.g. `LWIPRawImpl::wait_for_data_()` waiting for SO_RCVTIMEO), and ISR sources (GPIO, timer, WiFi RX on ESP8266; alarm / async on RP2040) keep re-setting the flag between iterations, every iteration takes the fast path and the loop never yields. That can starve the SDK / async context, blocking actual TCP delivery to our socket and causing OTA reads to time out on busy devices even though there is data in flight. The PR that introduced `wait_for_data_()` (#14675) relied on `wakeable_delay()` to yield on every iteration; this restores that property on the fast path. Adds `delay(0)` on ESP8266 and `yield()` on RP2040 to the fast path, matching the yield behaviour those platforms already use for the `ms == 0` poll case. --- esphome/core/wake/wake_esp8266.h | 4 ++++ esphome/core/wake/wake_rp2040.cpp | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/esphome/core/wake/wake_esp8266.h b/esphome/core/wake/wake_esp8266.h index 80cd61035b..7eaaae5293 100644 --- a/esphome/core/wake/wake_esp8266.h +++ b/esphome/core/wake/wake_esp8266.h @@ -36,6 +36,10 @@ inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { } if (g_main_loop_woke) { g_main_loop_woke = false; + // Yield even on the already-woken fast path so callers in tight loops + // (e.g. lwIP raw TCP wait_for_data_) make forward progress when ISRs + // keep re-setting g_main_loop_woke between iterations. + delay(0); return; } esp_delay(ms, []() { return !g_main_loop_woke; }); diff --git a/esphome/core/wake/wake_rp2040.cpp b/esphome/core/wake/wake_rp2040.cpp index b18248dbd2..bdcbb1ad00 100644 --- a/esphome/core/wake/wake_rp2040.cpp +++ b/esphome/core/wake/wake_rp2040.cpp @@ -36,6 +36,10 @@ void wakeable_delay(uint32_t ms) { } if (g_main_loop_woke) { g_main_loop_woke = false; + // Yield even on the already-woken fast path so callers in tight loops + // (e.g. lwIP raw TCP wait_for_data_) make forward progress when async + // wakes keep re-setting g_main_loop_woke between iterations. + yield(); return; } s_delay_expired = false;