Merge branch 'ultra_low_latancy_select_esp32' into integration

This commit is contained in:
J. Nick Koston
2026-02-23 22:16:37 -06:00
10 changed files with 370 additions and 62 deletions

View File

@@ -37,8 +37,7 @@ using ip4_addr_t = in_addr;
#include <esp_netif.h>
#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<IPAddress, 5>;
} // namespace network
} // namespace esphome
} // namespace esphome::network
#endif

View File

@@ -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

View File

@@ -4,8 +4,7 @@
#include <string>
#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

View File

@@ -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(

View File

@@ -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

View File

@@ -9,6 +9,9 @@
#endif
#ifdef USE_ESP32
#include <esp_chip_info.h>
#include "esphome/core/lwip_fast_select.h"
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#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<Application>::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<char, BUILD_TIME_STR_SIZE> buffer) {

View File

@@ -24,10 +24,14 @@
#endif
#ifdef USE_SOCKET_SELECT_SUPPORT
#ifdef USE_ESP32
#include "esphome/core/lwip_fast_select.h"
#else
#include <sys/select.h>
#ifdef USE_WAKE_LOOP_THREADSAFE
#include <lwip/sockets.h>
#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<Component *> looping_components_{};
#ifdef USE_SOCKET_SELECT_SUPPORT
std::vector<int> 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

View File

@@ -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 <lwip/api.h>
#include <lwip/priv/sockets_priv.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include "esphome/core/lwip_fast_select.h"
#include <stddef.h>
// 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

View File

@@ -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 <stdbool.h>
#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

View File

@@ -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