diff --git a/esphome/components/network/ip_address.h b/esphome/components/network/ip_address.h index b2a2c563e2..c0e7b2886c 100644 --- a/esphome/components/network/ip_address.h +++ b/esphome/components/network/ip_address.h @@ -37,8 +37,7 @@ using ip4_addr_t = in_addr; #include #endif -namespace esphome { -namespace network { +namespace esphome::network { /// Buffer size for IP address string (IPv6 max: 39 chars + null) static constexpr size_t IP_ADDRESS_BUFFER_SIZE = 40; @@ -187,6 +186,5 @@ struct IPAddress { using IPAddresses = std::array; -} // namespace network -} // namespace esphome +} // namespace esphome::network #endif diff --git a/esphome/components/network/util.cpp b/esphome/components/network/util.cpp index 5e741fd244..e397d77077 100644 --- a/esphome/components/network/util.cpp +++ b/esphome/components/network/util.cpp @@ -17,8 +17,7 @@ #include "esphome/components/modem/modem_component.h" #endif -namespace esphome { -namespace network { +namespace esphome::network { // The order of the components is important: WiFi should come after any possible main interfaces (it may be used as // an AP that use a previous interface for NAT). @@ -109,6 +108,5 @@ const char *get_use_address() { #endif } -} // namespace network -} // namespace esphome +} // namespace esphome::network #endif diff --git a/esphome/components/network/util.h b/esphome/components/network/util.h index 3dc12232aa..ae949ab0a8 100644 --- a/esphome/components/network/util.h +++ b/esphome/components/network/util.h @@ -4,8 +4,7 @@ #include #include "ip_address.h" -namespace esphome { -namespace network { +namespace esphome::network { /// Return whether the node is connected to the network (through wifi, eth, ...) bool is_connected(); @@ -15,6 +14,5 @@ bool is_disabled(); const char *get_use_address(); IPAddresses get_ip_addresses(); -} // namespace network -} // namespace esphome +} // namespace esphome::network #endif diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index d82f0c7aba..5f4d04eb44 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -134,6 +134,8 @@ def require_wake_loop_threadsafe() -> None: IMPORTANT: This is for background thread context only, NOT ISR context. Socket operations are not safe to call from ISR handlers. + On ESP32, FreeRTOS task notifications are used instead (no socket needed). + Example: from esphome.components import socket @@ -147,8 +149,10 @@ def require_wake_loop_threadsafe() -> None: ): CORE.data[KEY_WAKE_LOOP_THREADSAFE_REQUIRED] = True cg.add_define("USE_WAKE_LOOP_THREADSAFE") - # Consume 1 socket for the shared wake notification socket - consume_sockets(1, "socket.wake_loop_threadsafe", SocketType.UDP)({}) + if not CORE.is_esp32: + # Only non-ESP32 platforms need a UDP socket for wake notifications. + # ESP32 uses FreeRTOS task notifications instead (no socket needed). + consume_sockets(1, "socket.wake_loop_threadsafe", SocketType.UDP)({}) CONFIG_SCHEMA = cv.Schema( diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index c0098d689a..a771e2fe1a 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -71,7 +71,7 @@ class Socket { int get_fd() const { return -1; } #endif - /// Check if socket has data ready to read + /// Check if socket has data ready to read. Must only be called from the main loop thread. /// For select()-based sockets: non-virtual, checks Application's select() results /// For LWIP raw TCP sockets: virtual, checks internal buffer state #ifdef USE_SOCKET_SELECT_SUPPORT diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 0a703da579..57dd5cb8d7 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -9,6 +9,9 @@ #endif #ifdef USE_ESP32 #include +#include "esphome/core/lwip_fast_select.h" +#include +#include #endif #include "esphome/core/version.h" #include "esphome/core/hal.h" @@ -161,8 +164,14 @@ void Application::setup() { clear_setup_priority_overrides(); #endif -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) - // Set up wake socket for waking main loop from tasks +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_ESP32) + // Initialize fast select: saves main loop task handle for xTaskNotifyGive wake. + // Always init on ESP32 — the fast path (rcvevent reads + ulTaskNotifyTake) is used + // unconditionally when USE_SOCKET_SELECT_SUPPORT is enabled. + esphome_lwip_fast_select_init(); +#endif +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) + // Set up wake socket for waking main loop from tasks (non-ESP32 only) this->setup_wake_loop_threadsafe_(); #endif @@ -540,7 +549,7 @@ void Application::enable_pending_loops_() { } void Application::before_loop_tasks_(uint32_t loop_start_time) { -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) // Drain wake notifications first to clear socket for next wake this->drain_wake_notifications_(); #endif @@ -593,11 +602,15 @@ bool Application::register_socket_fd(int fd) { #endif this->socket_fds_.push_back(fd); +#ifdef USE_ESP32 + // Hook the socket's netconn callback for instant wake on receive events + esphome_lwip_hook_socket(fd); +#else this->socket_fds_changed_ = true; - if (fd > this->max_fd_) { this->max_fd_ = fd; } +#endif return true; } @@ -612,12 +625,14 @@ void Application::unregister_socket_fd(int fd) { if (this->socket_fds_[i] != fd) continue; - // Swap with last element and pop - O(1) removal since order doesn't matter + // Swap with last element and pop - O(1) removal since order doesn't matter. + // No need to unhook the netconn callback on ESP32 — all LwIP sockets share + // the same static event_callback, and the socket will be closed by the caller. if (i < this->socket_fds_.size() - 1) this->socket_fds_[i] = this->socket_fds_.back(); this->socket_fds_.pop_back(); +#ifndef USE_ESP32 this->socket_fds_changed_ = true; - // Only recalculate max_fd if we removed the current max if (fd == this->max_fd_) { this->max_fd_ = -1; @@ -626,6 +641,7 @@ void Application::unregister_socket_fd(int fd) { this->max_fd_ = sock_fd; } } +#endif return; } } @@ -633,16 +649,41 @@ void Application::unregister_socket_fd(int fd) { #endif void Application::yield_with_select_(uint32_t delay_ms) { - // Delay while monitoring sockets. When delay_ms is 0, always yield() to ensure other tasks run - // since select() with 0 timeout only polls without yielding. -#ifdef USE_SOCKET_SELECT_SUPPORT - if (!this->socket_fds_.empty()) { + // Delay while monitoring sockets. When delay_ms is 0, always yield() to ensure other tasks run. +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_ESP32) + // ESP32 fast path: reads rcvevent directly via lwip_socket_dbg_get_socket() (~215 ns per socket). + // Safe because this runs on the main loop which owns socket lifetime (create, read, close). + if (delay_ms == 0) [[unlikely]] { + yield(); + return; + } + + // Check if any socket already has pending data before sleeping. + // If a socket still has unread data (rcvevent > 0) but the task notification was already + // consumed, ulTaskNotifyTake would block until timeout — adding up to delay_ms latency. + // This scan preserves select() semantics: return immediately when any fd is ready. + for (int fd : this->socket_fds_) { + if (esphome_lwip_socket_has_data(fd)) { + yield(); + return; + } + } + + // Sleep with instant wake via FreeRTOS task notification. + // Woken by: callback wrapper (socket data arrives), wake_loop_threadsafe() (other tasks), or timeout. + // Without USE_WAKE_LOOP_THREADSAFE, only hooked socket callbacks wake the task — + // background tasks won't call wake, so this degrades to a pure timeout (same as old select path). + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(delay_ms)); + +#elif defined(USE_SOCKET_SELECT_SUPPORT) + // Non-ESP32 select() path (LibreTiny bk72xx/rtl87xx, host platform). + // ESP32 is excluded by the #if above — both BSD_SOCKETS and LWIP_SOCKETS on ESP32 + // use LwIP under the hood, so the fast path handles all ESP32 socket implementations. + if (!this->socket_fds_.empty()) [[likely]] { // Update fd_set if socket list has changed - if (this->socket_fds_changed_) { + if (this->socket_fds_changed_) [[unlikely]] { FD_ZERO(&this->base_read_fds_); - // fd bounds are already validated in register_socket_fd() or guaranteed by platform design: - // - ESP32: LwIP guarantees fd < FD_SETSIZE by design (LWIP_SOCKET_OFFSET = FD_SETSIZE - CONFIG_LWIP_MAX_SOCKETS) - // - Other platforms: register_socket_fd() validates fd < FD_SETSIZE + // fd bounds are validated in register_socket_fd() for (int fd : this->socket_fds_) { FD_SET(fd, &this->base_read_fds_); } @@ -658,7 +699,7 @@ void Application::yield_with_select_(uint32_t delay_ms) { tv.tv_usec = (delay_ms - tv.tv_sec * 1000) * 1000; // Call select with timeout -#if defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || (defined(USE_ESP32) && defined(USE_SOCKET_IMPL_BSD_SOCKETS)) +#ifdef USE_SOCKET_IMPL_LWIP_SOCKETS int ret = lwip_select(this->max_fd_ + 1, &this->read_fds_, nullptr, nullptr, &tv); #else int ret = ::select(this->max_fd_ + 1, &this->read_fds_, nullptr, nullptr, &tv); @@ -668,19 +709,18 @@ void Application::yield_with_select_(uint32_t delay_ms) { // ret < 0: error (except EINTR which is normal) // ret > 0: socket(s) have data ready - normal and expected // ret == 0: timeout occurred - normal and expected - if (ret < 0 && errno != EINTR) { - // Actual error - log and fall back to delay - ESP_LOGW(TAG, "select() failed with errno %d", errno); - delay(delay_ms); + if (ret >= 0 || errno == EINTR) [[likely]] { + // Yield if zero timeout since select(0) only polls without yielding + if (delay_ms == 0) [[unlikely]] { + yield(); + } + return; } - // When delay_ms is 0, we need to yield since select(0) doesn't yield - if (delay_ms == 0) { - yield(); - } - } else { - // No sockets registered, use regular delay - delay(delay_ms); + // select() error - log and fall through to delay() + ESP_LOGW(TAG, "select() failed with errno %d", errno); } + // No sockets registered or select() failed - use regular delay + delay(delay_ms); #elif defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP) // No select support but can wake on socket activity via esp_schedule() socket::socket_delay(delay_ms); @@ -701,6 +741,14 @@ static_assert(std::is_default_constructible::value, "Application mu alignas(Application) char app_storage[sizeof(Application)] asm("_ZN7esphome3AppE"); #if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) + +#ifdef USE_ESP32 +void Application::wake_loop_threadsafe() { + // Direct FreeRTOS task notification — <1 us, task context only (NOT ISR-safe) + esphome_lwip_wake_main_loop(); +} +#else // !USE_ESP32 + void Application::setup_wake_loop_threadsafe_() { // Create UDP socket for wake notifications this->wake_socket_fd_ = lwip_socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); @@ -767,6 +815,8 @@ void Application::wake_loop_threadsafe() { lwip_send(this->wake_socket_fd_, &dummy, 1, 0); } } +#endif // USE_ESP32 + #endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) void Application::get_build_time_string(std::span buffer) { diff --git a/esphome/core/application.h b/esphome/core/application.h index 401c3f7256..e30ec7879e 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -24,10 +24,14 @@ #endif #ifdef USE_SOCKET_SELECT_SUPPORT +#ifdef USE_ESP32 +#include "esphome/core/lwip_fast_select.h" +#else #include #ifdef USE_WAKE_LOOP_THREADSAFE #include #endif +#endif #endif // USE_SOCKET_SELECT_SUPPORT #ifdef USE_BINARY_SENSOR @@ -608,15 +612,12 @@ class Application { /// @return true if registration was successful, false if fd exceeds limits bool register_socket_fd(int fd); void unregister_socket_fd(int fd); - /// Check if there's data available on a socket without blocking - /// This function is thread-safe for reading, but should be called after select() has run - /// The read_fds_ is only modified by select() in the main loop - bool is_socket_ready(int fd) const { return fd >= 0 && this->is_socket_ready_(fd); } #ifdef USE_WAKE_LOOP_THREADSAFE - /// Wake the main event loop from a FreeRTOS task - /// Thread-safe, can be called from task context to immediately wake select() - /// IMPORTANT: NOT safe to call from ISR context (socket operations not ISR-safe) + /// Wake the main event loop from another FreeRTOS task. + /// Thread-safe, but must only be called from task context (NOT ISR-safe). + /// On ESP32: uses xTaskNotifyGive (<1 us) + /// On other platforms: uses UDP loopback socket void wake_loop_threadsafe(); #endif #endif @@ -627,10 +628,14 @@ class Application { #ifdef USE_SOCKET_SELECT_SUPPORT /// Fast path for Socket::ready() via friendship - skips negative fd check. - /// Safe because: fd was validated in register_socket_fd() at registration time, - /// and Socket::ready() only calls this when loop_monitored_ is true (registration succeeded). - /// FD_ISSET may include its own upper bounds check depending on platform. + /// Main loop only — on ESP32, reads rcvevent via lwip_socket_dbg_get_socket() + /// which has no refcount; safe only because the main loop owns socket lifetime + /// (creates, reads, and closes sockets on the same thread). +#ifdef USE_ESP32 + bool is_socket_ready_(int fd) const { return esphome_lwip_socket_has_data(fd); } +#else bool is_socket_ready_(int fd) const { return FD_ISSET(fd, &this->read_fds_); } +#endif #endif void register_component_(Component *comp); @@ -658,7 +663,7 @@ class Application { /// Perform a delay while also monitoring socket file descriptors for readiness void yield_with_select_(uint32_t delay_ms); -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) void setup_wake_loop_threadsafe_(); // Create wake notification socket inline void drain_wake_notifications_(); // Read pending wake notifications in main loop (hot path - inlined) #endif @@ -697,7 +702,7 @@ class Application { FixedVector looping_components_{}; #ifdef USE_SOCKET_SELECT_SUPPORT std::vector socket_fds_; // Vector of all monitored socket file descriptors -#ifdef USE_WAKE_LOOP_THREADSAFE +#if defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) int wake_socket_fd_{-1}; // Shared wake notification socket for waking main loop from tasks #endif #endif @@ -713,7 +718,7 @@ class Application { uint32_t setup_heap_stats_baseline_{0}; #endif -#ifdef USE_SOCKET_SELECT_SUPPORT +#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_ESP32) int max_fd_{-1}; // Highest file descriptor number for select() #endif @@ -729,14 +734,14 @@ class Application { bool in_loop_{false}; volatile bool has_pending_enable_loop_requests_{false}; -#ifdef USE_SOCKET_SELECT_SUPPORT +#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_ESP32) bool socket_fds_changed_{false}; // Flag to rebuild base_read_fds_ when socket_fds_ changes #endif -#ifdef USE_SOCKET_SELECT_SUPPORT - // Variable-sized members +#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_ESP32) + // Variable-sized members (not needed on ESP32 — is_socket_ready_ reads rcvevent directly) + fd_set read_fds_{}; // Working fd_set: populated by select() fd_set base_read_fds_{}; // Cached fd_set rebuilt only when socket_fds_ changes - fd_set read_fds_{}; // Working fd_set for select(), copied from base_read_fds_ #endif // StaticVectors (largest members - contain actual array data inline) @@ -823,7 +828,7 @@ class Application { /// Global storage of Application pointer - only one Application can exist. extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) // Inline implementations for hot-path functions // drain_wake_notifications_() is called on every loop iteration @@ -833,8 +838,8 @@ static constexpr size_t WAKE_NOTIFY_DRAIN_BUFFER_SIZE = 16; inline void Application::drain_wake_notifications_() { // Called from main loop to drain any pending wake notifications - // Must check is_socket_ready() to avoid blocking on empty socket - if (this->wake_socket_fd_ >= 0 && this->is_socket_ready(this->wake_socket_fd_)) { + // Must check is_socket_ready_() to avoid blocking on empty socket + if (this->wake_socket_fd_ >= 0 && this->is_socket_ready_(this->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 @@ -845,6 +850,6 @@ inline void Application::drain_wake_notifications_() { } } } -#endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) +#endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) } // namespace esphome diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c new file mode 100644 index 0000000000..caee6cffce --- /dev/null +++ b/esphome/core/lwip_fast_select.c @@ -0,0 +1,183 @@ +// Fast socket monitoring for ESP32 (ESP-IDF LwIP) +// Replaces lwip_select() with direct rcvevent reads and FreeRTOS task notifications. +// +// This must be a .c file (not .cpp) because: +// 1. lwip/priv/sockets_priv.h conflicts with C++ compilation units that include bootloader headers +// 2. The netconn callback is a C function pointer +// +// defines.h is force-included by the build system (-include flag), providing USE_ESP32 etc. +// +// Thread safety analysis +// ====================== +// Three threads interact with this code: +// 1. Main loop task — calls init, has_data, hook +// 2. LwIP TCP/IP task — calls event_callback (reads s_original_callback; writes rcvevent +// via the original callback under SYS_ARCH_PROTECT/UNPROTECT mutex) +// 3. Background tasks — call wake_main_loop +// +// LwIP source references (ESP-IDF v5.5.2, commit 30aaf64524): +// sockets.c: https://github.com/espressif/esp-idf/blob/30aaf64524/components/lwip/lwip/src/api/sockets.c +// - event_callback (static, same for all sockets): L327 +// - DEFAULT_SOCKET_EVENTCB = event_callback: L328 +// - tryget_socket_unconn_nouse (direct array lookup): L450 +// - lwip_socket_dbg_get_socket (thin wrapper): L461 +// - All socket types use DEFAULT_SOCKET_EVENTCB: L1741, L1748, L1759 +// - event_callback definition: L2538 +// - SYS_ARCH_PROTECT before rcvevent switch: L2578 +// - sock->rcvevent++ (NETCONN_EVT_RCVPLUS case): L2582 +// - SYS_ARCH_UNPROTECT after switch: L2615 +// sys.h: https://github.com/espressif/esp-idf/blob/30aaf64524/components/lwip/lwip/src/include/lwip/sys.h +// - SYS_ARCH_PROTECT calls sys_arch_protect(): L495 +// - SYS_ARCH_UNPROTECT calls sys_arch_unprotect(): L506 +// (ESP-IDF implements sys_arch_protect/unprotect as FreeRTOS mutex lock/unlock) +// +// Shared state and safety rationale: +// +// s_main_loop_task (TaskHandle_t, 4 bytes): +// Written once by main loop in init(). Read by TCP/IP thread (in callback) +// and background tasks (in wake). +// Safe: write-once-then-read pattern. Socket hooks may run before init(), +// but the NULL check on s_main_loop_task in the callback provides correct +// degraded behavior — notifications are simply skipped until init() completes. +// +// s_original_callback (netconn_callback, 4-byte function pointer): +// Written by main loop in hook_socket() (only when NULL — set once). +// Read by TCP/IP thread in esphome_socket_event_callback(). +// Safe: set-once pattern. The first hook_socket() captures the original callback. +// All subsequent hooks see it already set and skip the write. The TCP/IP thread +// only reads this after the callback pointer has been swapped (which happens after +// the write), so it always sees the initialized value. +// +// sock->conn->callback (netconn_callback, 4-byte function pointer): +// Written by main loop in hook_socket(). Never restored — all LwIP sockets share +// the same static event_callback (DEFAULT_SOCKET_EVENTCB), so the wrapper stays permanently. +// Read by TCP/IP thread when invoking the callback. +// Safe: 32-bit aligned pointer writes are atomic on Xtensa and RISC-V (ESP32). +// The TCP/IP thread will see either the old or new pointer atomically — never a +// torn value. Both the wrapper and original callbacks are valid at all times +// (the wrapper itself calls the original), so either value is correct. +// +// sock->rcvevent (s16_t, 2 bytes): +// Written by TCP/IP thread in event_callback under SYS_ARCH_PROTECT. +// Read by main loop in has_data() via volatile cast. +// Safe: SYS_ARCH_UNPROTECT releases a FreeRTOS mutex, which internally +// uses a critical section with memory barrier (rsync on dual-core Xtensa; on +// single-core builds the spinlock is compiled out, but cross-core visibility is +// not an issue). The volatile cast prevents the compiler +// from caching the read. Aligned 16-bit reads are single-instruction loads on +// Xtensa (L16SI) and RISC-V (LH), which cannot produce torn values. +// +// FreeRTOS task notification value: +// Written by TCP/IP thread (xTaskNotifyGive in callback) and background tasks +// (xTaskNotifyGive in wake_main_loop). Read by main loop (ulTaskNotifyTake). +// Safe: FreeRTOS notification APIs are thread-safe by design (use internal +// critical sections). Multiple concurrent xTaskNotifyGive calls are safe — +// the notification count simply increments. + +#ifdef USE_ESP32 + +// LwIP headers must come first — they define netconn_callback, struct lwip_sock, etc. +#include +#include +#include +#include + +#include "esphome/core/lwip_fast_select.h" + +#include + +// Compile-time verification of thread safety assumptions. +// On ESP32 (Xtensa/RISC-V), naturally-aligned reads/writes up to 32 bits are atomic. +// These asserts ensure our cross-thread shared state meets those requirements. + +// Pointer types must fit in a single 32-bit store (atomic write) +_Static_assert(sizeof(TaskHandle_t) <= 4, "TaskHandle_t must be <= 4 bytes for atomic access"); +_Static_assert(sizeof(netconn_callback) <= 4, "netconn_callback must be <= 4 bytes for atomic access"); + +// rcvevent must fit in a single atomic read +_Static_assert(sizeof(((struct lwip_sock *) 0)->rcvevent) <= 4, "rcvevent must be <= 4 bytes for atomic access"); + +// Struct member alignment — natural alignment guarantees atomicity on Xtensa/RISC-V. +// Misaligned access would not be atomic even if the size is <= 4 bytes. +_Static_assert(offsetof(struct netconn, callback) % sizeof(netconn_callback) == 0, + "netconn.callback must be naturally aligned for atomic access"); +_Static_assert(offsetof(struct lwip_sock, rcvevent) % sizeof(((struct lwip_sock *) 0)->rcvevent) == 0, + "lwip_sock.rcvevent must be naturally aligned for atomic access"); + +// Task handle for the main loop — written once in init(), read from TCP/IP and background tasks. +static TaskHandle_t s_main_loop_task = NULL; + +// Saved original event_callback pointer — written once in first hook_socket(), read from TCP/IP task. +static netconn_callback s_original_callback = NULL; + +// Wrapper callback: calls original event_callback + notifies main loop task. +// Called from LwIP's TCP/IP thread when socket events occur (task context, not ISR). +static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt evt, u16_t len) { + // Call original LwIP event_callback first — updates rcvevent/sendevent/errevent, + // signals any select() waiters. This preserves all LwIP behavior. + // s_original_callback is always valid here: hook_socket() sets it before swapping + // the callback pointer, so this wrapper cannot run until it's initialized. + s_original_callback(conn, evt, len); + // Wake the main loop task if sleeping in ulTaskNotifyTake(). + // Only notify on receive events to avoid spurious wakeups from send-ready events. + if (evt == NETCONN_EVT_RCVPLUS) { + TaskHandle_t task = s_main_loop_task; + if (task != NULL) { + xTaskNotifyGive(task); + } + } +} + +void esphome_lwip_fast_select_init(void) { s_main_loop_task = xTaskGetCurrentTaskHandle(); } + +// lwip_socket_dbg_get_socket() is a thin wrapper around the static +// tryget_socket_unconn_nouse() — a direct array lookup without the refcount +// that get_socket()/done_socket() uses. This is safe because the caller owns +// the socket lifetime: both has_data() and socket close happen on the main +// loop thread, so the sockets[] entry cannot be freed while we read it. +// If lwip_socket_dbg_get_socket() were ever removed, we could fall back to lwip_select(). +// Returns the sock only if both the sock and its netconn are valid, NULL otherwise. +static inline struct lwip_sock *get_sock(int fd) { + struct lwip_sock *sock = lwip_socket_dbg_get_socket(fd); + if (sock == NULL || sock->conn == NULL) + return NULL; + return sock; +} + +bool esphome_lwip_socket_has_data(int fd) { + struct lwip_sock *sock = get_sock(fd); + if (sock == NULL) + return false; + // volatile prevents the compiler from caching/reordering this cross-thread read. + // The write side (TCP/IP thread) commits via SYS_ARCH_UNPROTECT which releases a + // FreeRTOS mutex with a memory barrier (rsync on Xtensa), ensuring the value is + // visible. Aligned 16-bit reads are single-instruction loads (L16SI/LH) on + // Xtensa/RISC-V and cannot produce torn values. + return *(volatile s16_t *) &sock->rcvevent > 0; +} + +void esphome_lwip_hook_socket(int fd) { + struct lwip_sock *sock = get_sock(fd); + if (sock == NULL) + return; + + // Save original callback once — all LwIP sockets share the same static event_callback + // (DEFAULT_SOCKET_EVENTCB in sockets.c, used for SOCK_RAW, SOCK_DGRAM, and SOCK_STREAM). + if (s_original_callback == NULL) { + s_original_callback = sock->conn->callback; + } + + // Replace with our wrapper. Atomic on ESP32 (32-bit aligned pointer write). + // TCP/IP thread sees either old or new pointer — both are valid. + sock->conn->callback = esphome_socket_event_callback; +} + +// Wake the main loop from another FreeRTOS task. NOT ISR-safe. +void esphome_lwip_wake_main_loop(void) { + TaskHandle_t task = s_main_loop_task; + if (task != NULL) { + xTaskNotifyGive(task); + } +} + +#endif // USE_ESP32 diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h new file mode 100644 index 0000000000..73a89fdc3d --- /dev/null +++ b/esphome/core/lwip_fast_select.h @@ -0,0 +1,33 @@ +#pragma once + +// Fast socket monitoring for ESP32 (ESP-IDF LwIP) +// Replaces lwip_select() with direct rcvevent reads and FreeRTOS task notifications. + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/// Initialize fast select — must be called from the main loop task during setup(). +/// Saves the current task handle for xTaskNotifyGive() wake notifications. +void esphome_lwip_fast_select_init(void); + +/// Check if a LwIP socket has data ready via direct rcvevent read (~215 ns per socket). +/// Uses lwip_socket_dbg_get_socket() — a direct array lookup without the refcount that +/// get_socket()/done_socket() uses. Safe because the caller owns the socket lifetime: +/// both has_data reads and socket close/unregister happen on the main loop thread. +bool esphome_lwip_socket_has_data(int fd); + +/// Hook a socket's netconn callback to notify the main loop task on receive events. +/// Wraps the original event_callback with one that also calls xTaskNotifyGive(). +/// Must be called from the main loop after socket creation. +void esphome_lwip_hook_socket(int fd); + +/// Wake the main loop task from another FreeRTOS task — costs <1 us. +/// NOT ISR-safe — must only be called from task context. +void esphome_lwip_wake_main_loop(void); + +#ifdef __cplusplus +} +#endif diff --git a/tests/components/socket/test_wake_loop_threadsafe.py b/tests/components/socket/test_wake_loop_threadsafe.py index a40b6068a8..28b4ee564f 100644 --- a/tests/components/socket/test_wake_loop_threadsafe.py +++ b/tests/components/socket/test_wake_loop_threadsafe.py @@ -1,9 +1,21 @@ from esphome.components import socket +from esphome.const import ( + KEY_CORE, + KEY_TARGET_PLATFORM, + PLATFORM_ESP32, + PLATFORM_ESP8266, +) from esphome.core import CORE +def _setup_platform(platform=PLATFORM_ESP8266) -> None: + """Set up CORE.data with a platform for testing.""" + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} + + def test_require_wake_loop_threadsafe__first_call() -> None: """Test that first call sets up define and consumes socket.""" + _setup_platform() CORE.config = {"wifi": True} socket.require_wake_loop_threadsafe() @@ -32,6 +44,7 @@ def test_require_wake_loop_threadsafe__idempotent() -> None: def test_require_wake_loop_threadsafe__multiple_calls() -> None: """Test that multiple calls only set up once.""" + _setup_platform() # Call three times CORE.config = {"openthread": True} socket.require_wake_loop_threadsafe() @@ -75,3 +88,29 @@ def test_require_wake_loop_threadsafe__no_networking_does_not_consume_socket() - udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {}) assert "socket.wake_loop_threadsafe" not in udp_consumers assert udp_consumers == initial_udp + + +def test_require_wake_loop_threadsafe__esp32_no_udp_socket() -> None: + """Test that ESP32 uses task notifications instead of UDP socket.""" + _setup_platform(PLATFORM_ESP32) + CORE.config = {"wifi": True} + socket.require_wake_loop_threadsafe() + + # Verify the define was added + assert CORE.data[socket.KEY_WAKE_LOOP_THREADSAFE_REQUIRED] is True + assert any(d.name == "USE_WAKE_LOOP_THREADSAFE" for d in CORE.defines) + + # Verify no UDP socket was consumed (ESP32 uses FreeRTOS task notifications) + udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {}) + assert "socket.wake_loop_threadsafe" not in udp_consumers + + +def test_require_wake_loop_threadsafe__non_esp32_consumes_udp_socket() -> None: + """Test that non-ESP32 platforms consume a UDP socket for wake notifications.""" + _setup_platform(PLATFORM_ESP8266) + CORE.config = {"wifi": True} + socket.require_wake_loop_threadsafe() + + # Verify UDP socket was consumed + udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {}) + assert udp_consumers.get("socket.wake_loop_threadsafe") == 1