mirror of
https://github.com/esphome/esphome.git
synced 2026-02-25 21:43:14 -07:00
Merge branch 'ultra_low_latancy_select_esp32' into integration
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
183
esphome/core/lwip_fast_select.c
Normal file
183
esphome/core/lwip_fast_select.c
Normal 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
|
||||
33
esphome/core/lwip_fast_select.h
Normal file
33
esphome/core/lwip_fast_select.h
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user