diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index 5f4d04eb44..a83648979c 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -149,9 +149,10 @@ def require_wake_loop_threadsafe() -> None: ): CORE.data[KEY_WAKE_LOOP_THREADSAFE_REQUIRED] = True cg.add_define("USE_WAKE_LOOP_THREADSAFE") - 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). + if not CORE.is_esp32 and not CORE.is_libretiny: + # Only platforms without fast select need a UDP socket for wake + # notifications. ESP32 and LibreTiny use FreeRTOS task notifications + # instead (no socket needed). consume_sockets(1, "socket.wake_loop_threadsafe", SocketType.UDP)({}) @@ -187,6 +188,10 @@ async def to_code(config): elif impl == IMPLEMENTATION_BSD_SOCKETS: cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") cg.add_define("USE_SOCKET_SELECT_SUPPORT") + # ESP32 and LibreTiny both have LwIP >= 2.1.3 with lwip_socket_dbg_get_socket() + # and FreeRTOS task notifications — enable fast select to bypass lwip_select() + if CORE.is_esp32 or CORE.is_libretiny: + cg.add_define("USE_LWIP_FAST_SELECT") def FILTER_SOURCE_FILES() -> list[str]: diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 57dd5cb8d7..6aafcdde4d 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -9,10 +9,17 @@ #endif #ifdef USE_ESP32 #include +#endif +#ifdef USE_LWIP_FAST_SELECT #include "esphome/core/lwip_fast_select.h" +#ifdef USE_ESP32 #include #include +#else +#include +#include #endif +#endif // USE_LWIP_FAST_SELECT #include "esphome/core/version.h" #include "esphome/core/hal.h" #include @@ -164,14 +171,14 @@ void Application::setup() { clear_setup_priority_overrides(); #endif -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_ESP32) +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_LWIP_FAST_SELECT) // 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. + // The fast path (rcvevent reads + ulTaskNotifyTake) is used unconditionally + // when USE_LWIP_FAST_SELECT is enabled (ESP32 and LibreTiny). 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) +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_LWIP_FAST_SELECT) + // Set up wake socket for waking main loop from tasks (platforms without fast select only) this->setup_wake_loop_threadsafe_(); #endif @@ -602,7 +609,7 @@ bool Application::register_socket_fd(int fd) { #endif this->socket_fds_.push_back(fd); -#ifdef USE_ESP32 +#ifdef USE_LWIP_FAST_SELECT // Hook the socket's netconn callback for instant wake on receive events esphome_lwip_hook_socket(fd); #else @@ -631,7 +638,7 @@ void Application::unregister_socket_fd(int fd) { if (i < this->socket_fds_.size() - 1) this->socket_fds_[i] = this->socket_fds_.back(); this->socket_fds_.pop_back(); -#ifndef USE_ESP32 +#ifndef USE_LWIP_FAST_SELECT this->socket_fds_changed_ = true; // Only recalculate max_fd if we removed the current max if (fd == this->max_fd_) { @@ -650,8 +657,8 @@ void Application::unregister_socket_fd(int fd) { 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. -#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). +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_LWIP_FAST_SELECT) + // Fast path (ESP32/LibreTiny): reads rcvevent directly via lwip_socket_dbg_get_socket(). // Safe because this runs on the main loop which owns socket lifetime (create, read, close). if (delay_ms == 0) [[unlikely]] { yield(); @@ -676,9 +683,8 @@ void Application::yield_with_select_(uint32_t delay_ms) { 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. + // Fallback select() path (host platform and any future platforms without fast select). + // ESP32 and LibreTiny are excluded by the #if above — they use the fast path. if (!this->socket_fds_.empty()) [[likely]] { // Update fd_set if socket list has changed if (this->socket_fds_changed_) [[unlikely]] { @@ -742,12 +748,12 @@ alignas(Application) char app_storage[sizeof(Application)] asm("_ZN7esphome3AppE #if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) -#ifdef USE_ESP32 +#ifdef USE_LWIP_FAST_SELECT 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 +#else // !USE_LWIP_FAST_SELECT void Application::setup_wake_loop_threadsafe_() { // Create UDP socket for wake notifications @@ -815,7 +821,7 @@ void Application::wake_loop_threadsafe() { lwip_send(this->wake_socket_fd_, &dummy, 1, 0); } } -#endif // USE_ESP32 +#endif // USE_LWIP_FAST_SELECT #endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) diff --git a/esphome/core/application.h b/esphome/core/application.h index e30ec7879e..87a74e8bfd 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -24,7 +24,7 @@ #endif #ifdef USE_SOCKET_SELECT_SUPPORT -#ifdef USE_ESP32 +#ifdef USE_LWIP_FAST_SELECT #include "esphome/core/lwip_fast_select.h" #else #include @@ -631,7 +631,7 @@ class Application { /// 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 +#ifdef USE_LWIP_FAST_SELECT 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_); } @@ -663,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) && !defined(USE_ESP32) +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_LWIP_FAST_SELECT) 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 @@ -702,7 +702,7 @@ class Application { FixedVector looping_components_{}; #ifdef USE_SOCKET_SELECT_SUPPORT std::vector socket_fds_; // Vector of all monitored socket file descriptors -#if defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) +#if defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_LWIP_FAST_SELECT) int wake_socket_fd_{-1}; // Shared wake notification socket for waking main loop from tasks #endif #endif @@ -718,7 +718,7 @@ class Application { uint32_t setup_heap_stats_baseline_{0}; #endif -#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_ESP32) +#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_LWIP_FAST_SELECT) int max_fd_{-1}; // Highest file descriptor number for select() #endif @@ -734,12 +734,12 @@ class Application { bool in_loop_{false}; volatile bool has_pending_enable_loop_requests_{false}; -#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_ESP32) +#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_LWIP_FAST_SELECT) bool socket_fds_changed_{false}; // Flag to rebuild base_read_fds_ when socket_fds_ changes #endif -#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_ESP32) - // Variable-sized members (not needed on ESP32 — is_socket_ready_ reads rcvevent directly) +#if defined(USE_SOCKET_SELECT_SUPPORT) && !defined(USE_LWIP_FAST_SELECT) + // Variable-sized members (not needed with fast select — 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 #endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 2ed42e58a5..8e1c6e9e7c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -225,6 +225,7 @@ #define USE_SENDSPIN_PORT 8928 // NOLINT #define USE_SOCKET_IMPL_BSD_SOCKETS #define USE_SOCKET_SELECT_SUPPORT +#define USE_LWIP_FAST_SELECT #define USE_WAKE_LOOP_THREADSAFE #define USE_SPEAKER #define USE_SPI @@ -333,6 +334,7 @@ #define USE_CAPTIVE_PORTAL #define USE_SOCKET_IMPL_LWIP_SOCKETS #define USE_SOCKET_SELECT_SUPPORT +#define USE_LWIP_FAST_SELECT #define USE_WEBSERVER #define USE_WEBSERVER_AUTH #define USE_WEBSERVER_PORT 80 // NOLINT diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 4ccfed3f62..b6a4ac2961 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -1,11 +1,11 @@ -// Fast socket monitoring for ESP32 (ESP-IDF LwIP) +// Fast socket monitoring for ESP32 and LibreTiny (LwIP >= 2.1.3) // 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 +// 1. lwip/priv/sockets_priv.h conflicts with C++ compilation units // 2. The netconn callback is a C function pointer // -// defines.h is force-included by the build system (-include flag), providing USE_ESP32 etc. +// defines.h is force-included by the build system (-include flag), providing USE_LWIP_FAST_SELECT etc. // // Thread safety analysis // ====================== @@ -81,20 +81,21 @@ // 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. +// Safe: 32-bit aligned pointer writes are atomic on Xtensa, RISC-V (ESP32), +// and ARM Cortex-M (LibreTiny). 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. +// Safe: SYS_ARCH_UNPROTECT releases a FreeRTOS mutex (ESP32) or resumes the +// scheduler (LibreTiny), both providing a memory barrier. The volatile cast +// prevents the compiler from caching the read. Aligned 16-bit reads are +// single-instruction loads on Xtensa (L16SI), RISC-V (LH), and ARM Cortex-M +// (LDRH), which cannot produce torn values. On single-core chips (LibreTiny, +// ESP32-C3/C6/H2) cross-core visibility is not an issue. // // FreeRTOS task notification value: // Written by TCP/IP thread (xTaskNotifyGive in callback) and background tasks @@ -103,20 +104,30 @@ // critical sections). Multiple concurrent xTaskNotifyGive calls are safe — // the notification count simply increments. -#ifdef USE_ESP32 +// USE_ESP32 and USE_LIBRETINY are build flags (-D), always available to .c files. +// USE_LWIP_FAST_SELECT is in the generated defines.h (force-included for .cpp but +// may not reach .c files on all build systems), so we use platform flags here. +#if defined(USE_ESP32) || defined(USE_LIBRETINY) // LwIP headers must come first — they define netconn_callback, struct lwip_sock, etc. #include #include +// FreeRTOS include paths differ: ESP-IDF uses freertos/ prefix, LibreTiny does not +#ifdef USE_ESP32 #include #include +#else +#include +#include +#endif #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. +// On ESP32 (Xtensa/RISC-V) and LibreTiny (ARM Cortex-M), 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) @@ -126,7 +137,7 @@ _Static_assert(sizeof(netconn_callback) <= 4, "netconn_callback must be <= 4 byt // 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. +// Struct member alignment — natural alignment guarantees atomicity on Xtensa/RISC-V/ARM. // 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"); @@ -149,6 +160,9 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt 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. + // NETCONN_EVT_ERROR is deliberately omitted: LwIP signals errors via RCVPLUS + // (rcvevent++ with a NULL pbuf or error in recvmbox), so error conditions + // already wake the main loop through the RCVPLUS path. if (evt == NETCONN_EVT_RCVPLUS) { TaskHandle_t task = s_main_loop_task; if (task != NULL) { @@ -180,9 +194,9 @@ bool esphome_lwip_socket_has_data(int fd) { 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. + // FreeRTOS mutex (ESP32) or resumes the scheduler (LibreTiny), ensuring the value + // is visible. Aligned 16-bit reads are single-instruction loads (L16SI/LH/LDRH) on + // Xtensa/RISC-V/ARM and cannot produce torn values. return *(volatile s16_t *) &sock->rcvevent > 0; } @@ -197,7 +211,7 @@ void esphome_lwip_hook_socket(int fd) { s_original_callback = sock->conn->callback; } - // Replace with our wrapper. Atomic on ESP32 (32-bit aligned pointer write). + // Replace with our wrapper. Atomic on all supported platforms (32-bit aligned pointer write). // TCP/IP thread sees either old or new pointer — both are valid. sock->conn->callback = esphome_socket_event_callback; } @@ -210,4 +224,4 @@ void esphome_lwip_wake_main_loop(void) { } } -#endif // USE_ESP32 +#endif // defined(USE_ESP32) || defined(USE_LIBRETINY) diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index 73a89fdc3d..b08c946212 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -1,6 +1,6 @@ #pragma once -// Fast socket monitoring for ESP32 (ESP-IDF LwIP) +// Fast socket monitoring for ESP32 and LibreTiny (LwIP >= 2.1.3) // Replaces lwip_select() with direct rcvevent reads and FreeRTOS task notifications. #include diff --git a/tests/components/socket/test.bk72xx-ard.yaml b/tests/components/socket/test.bk72xx-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/socket/test.bk72xx-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/socket/test.ln882x-ard.yaml b/tests/components/socket/test.ln882x-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/socket/test.ln882x-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/socket/test.rtl87xx-ard.yaml b/tests/components/socket/test.rtl87xx-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/socket/test.rtl87xx-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/socket/test_wake_loop_threadsafe.py b/tests/components/socket/test_wake_loop_threadsafe.py index 28b4ee564f..7121fe7007 100644 --- a/tests/components/socket/test_wake_loop_threadsafe.py +++ b/tests/components/socket/test_wake_loop_threadsafe.py @@ -2,8 +2,11 @@ from esphome.components import socket from esphome.const import ( KEY_CORE, KEY_TARGET_PLATFORM, + PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, + PLATFORM_LN882X, + PLATFORM_RTL87XX, ) from esphome.core import CORE @@ -114,3 +117,48 @@ def test_require_wake_loop_threadsafe__non_esp32_consumes_udp_socket() -> None: # Verify UDP socket was consumed udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {}) assert udp_consumers.get("socket.wake_loop_threadsafe") == 1 + + +def test_require_wake_loop_threadsafe__bk72xx_no_udp_socket() -> None: + """Test that BK72xx (LibreTiny) uses task notifications instead of UDP socket.""" + _setup_platform(PLATFORM_BK72XX) + 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 (LibreTiny 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__rtl87xx_no_udp_socket() -> None: + """Test that RTL87xx (LibreTiny) uses task notifications instead of UDP socket.""" + _setup_platform(PLATFORM_RTL87XX) + 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 (LibreTiny 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__ln882x_no_udp_socket() -> None: + """Test that LN882H (LibreTiny) uses task notifications instead of UDP socket.""" + _setup_platform(PLATFORM_LN882X) + 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 (LibreTiny uses FreeRTOS task notifications) + udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {}) + assert "socket.wake_loop_threadsafe" not in udp_consumers