From bd3f8e006c40dfc5e64dc37f3f90e9bc2c4ec5ac Mon Sep 17 00:00:00 2001 From: whitty Date: Sat, 28 Feb 2026 03:02:29 +1100 Subject: [PATCH 1/2] [esp32_ble] allow setting of min/max key_size and auth_req_mode (#7138) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/esp32_ble/__init__.py | 52 +++++++++++++ esphome/components/esp32_ble/ble.cpp | 74 ++++++++++++++++++- esphome/components/esp32_ble/ble.h | 26 +++++++ esphome/core/defines.h | 1 + ...nded-auth-req-params-single.esp32-idf.yaml | 6 ++ ...st-extended-auth-req-params.esp32-idf.yaml | 5 ++ 6 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 tests/components/esp32_ble/test-extended-auth-req-params-single.esp32-idf.yaml create mode 100644 tests/components/esp32_ble/test-extended-auth-req-params.esp32-idf.yaml diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index c0e2f78bde..8b368afc2e 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -21,6 +21,7 @@ from esphome.const import ( ) from esphome.core import CORE, CoroPriority, TimePeriod, coroutine_with_priority import esphome.final_validate as fv +from esphome.types import ConfigType DEPENDENCIES = ["esp32"] CODEOWNERS = ["@jesserockz", "@Rapsssito", "@bdraco"] @@ -188,6 +189,9 @@ def register_bt_logger(*loggers: BTLoggers) -> None: CONF_BLE_ID = "ble_id" CONF_IO_CAPABILITY = "io_capability" +CONF_AUTH_REQ_MODE = "auth_req_mode" +CONF_MAX_KEY_SIZE = "max_key_size" +CONF_MIN_KEY_SIZE = "min_key_size" CONF_ADVERTISING = "advertising" CONF_ADVERTISING_CYCLE_TIME = "advertising_cycle_time" CONF_DISABLE_BT_LOGS = "disable_bt_logs" @@ -238,6 +242,18 @@ IO_CAPABILITY = { "display_yes_no": IoCapability.IO_CAP_IO, } +AuthReqMode = esp32_ble_ns.enum("AuthReqMode") +AUTH_REQ_MODE = { + "no_bond": AuthReqMode.AUTH_REQ_NO_BOND, + "bond": AuthReqMode.AUTH_REQ_BOND, + "mitm": AuthReqMode.AUTH_REQ_MITM, + "bond_mitm": AuthReqMode.AUTH_REQ_BOND_MITM, + "sc_only": AuthReqMode.AUTH_REQ_SC_ONLY, + "sc_bond": AuthReqMode.AUTH_REQ_SC_BOND, + "sc_mitm": AuthReqMode.AUTH_REQ_SC_MITM, + "sc_mitm_bond": AuthReqMode.AUTH_REQ_SC_MITM_BOND, +} + esp_power_level_t = cg.global_ns.enum("esp_power_level_t") TX_POWER_LEVELS = { @@ -258,6 +274,10 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(CONF_IO_CAPABILITY, default="none"): cv.enum( IO_CAPABILITY, lower=True ), + # note: no defaults so we can action them not being present + cv.Optional(CONF_AUTH_REQ_MODE): cv.enum(AUTH_REQ_MODE, lower=True), + cv.Optional(CONF_MAX_KEY_SIZE): cv.int_range(min=7, max=16), + cv.Optional(CONF_MIN_KEY_SIZE): cv.int_range(min=7, max=16), cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean, cv.Optional(CONF_ADVERTISING, default=False): cv.boolean, cv.Optional( @@ -279,6 +299,23 @@ CONFIG_SCHEMA = cv.Schema( ).extend(cv.COMPONENT_SCHEMA) +def _validate_key_sizes(config: ConfigType) -> ConfigType: + if ( + CONF_MIN_KEY_SIZE in config + and CONF_MAX_KEY_SIZE in config + and config[CONF_MIN_KEY_SIZE] > config[CONF_MAX_KEY_SIZE] + ): + raise cv.Invalid( + f"min_key_size ({config[CONF_MIN_KEY_SIZE]}) must be " + f"less than or equal to " + f"max_key_size ({config[CONF_MAX_KEY_SIZE]})" + ) + return config + + +CONFIG_SCHEMA = cv.All(CONFIG_SCHEMA, _validate_key_sizes) + + bt_uuid16_format = "XXXX" bt_uuid32_format = "XXXXXXXX" bt_uuid128_format = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" @@ -487,6 +524,21 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT])) cg.add(var.set_io_capability(config[CONF_IO_CAPABILITY])) + + if ( + CONF_AUTH_REQ_MODE in config + or CONF_MAX_KEY_SIZE in config + or CONF_MIN_KEY_SIZE in config + ): + cg.add_define("ESPHOME_ESP32_BLE_EXTENDED_AUTH_PARAMS", None) + + if CONF_AUTH_REQ_MODE in config: + cg.add(var.set_auth_req(config[CONF_AUTH_REQ_MODE])) + if CONF_MAX_KEY_SIZE in config: + cg.add(var.set_max_key_size(config[CONF_MAX_KEY_SIZE])) + if CONF_MIN_KEY_SIZE in config: + cg.add(var.set_min_key_size(config[CONF_MIN_KEY_SIZE])) + cg.add(var.set_advertising_cycle_time(config[CONF_ADVERTISING_CYCLE_TIME])) if (name := config.get(CONF_NAME)) is not None: cg.add(var.set_name(name)) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index acbe9d88fc..9d26018800 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -296,12 +296,39 @@ bool ESP32BLE::ble_setup_() { return false; } - err = esp_ble_gap_set_security_param(ESP_BLE_SM_IOCAP_MODE, &(this->io_cap_), sizeof(uint8_t)); + err = esp_ble_gap_set_security_param(ESP_BLE_SM_IOCAP_MODE, &(this->io_cap_), sizeof(esp_ble_io_cap_t)); if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_ble_gap_set_security_param failed: %d", err); + ESP_LOGE(TAG, "esp_ble_gap_set_security_param iocap_mode failed: %d", err); return false; } +#ifdef ESPHOME_ESP32_BLE_EXTENDED_AUTH_PARAMS + if (this->max_key_size_) { + err = esp_ble_gap_set_security_param(ESP_BLE_SM_MAX_KEY_SIZE, &(this->max_key_size_), sizeof(uint8_t)); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ble_gap_set_security_param max_key_size failed: %d", err); + return false; + } + } + + if (this->min_key_size_) { + err = esp_ble_gap_set_security_param(ESP_BLE_SM_MIN_KEY_SIZE, &(this->min_key_size_), sizeof(uint8_t)); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ble_gap_set_security_param min_key_size failed: %d", err); + return false; + } + } + + if (this->auth_req_mode_) { + err = esp_ble_gap_set_security_param(ESP_BLE_SM_AUTHEN_REQ_MODE, &(this->auth_req_mode_.value()), + sizeof(esp_ble_auth_req_t)); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ble_gap_set_security_param authen_req_mode failed: %d", err); + return false; + } + } +#endif // ESPHOME_ESP32_BLE_EXTENDED_AUTH_PARAMS + // BLE takes some time to be fully set up, 200ms should be more than enough delay(200); // NOLINT @@ -645,6 +672,7 @@ void ESP32BLE::dump_config() { io_capability_s = "invalid"; break; } + char mac_s[18]; format_mac_addr_upper(mac_address, mac_s); ESP_LOGCONFIG(TAG, @@ -652,6 +680,48 @@ void ESP32BLE::dump_config() { " MAC address: %s\n" " IO Capability: %s", mac_s, io_capability_s); + +#ifdef ESPHOME_ESP32_BLE_EXTENDED_AUTH_PARAMS + const char *auth_req_mode_s = ""; + if (this->auth_req_mode_) { + switch (this->auth_req_mode_.value()) { + case AUTH_REQ_NO_BOND: + auth_req_mode_s = "no_bond"; + break; + case AUTH_REQ_BOND: + auth_req_mode_s = "bond"; + break; + case AUTH_REQ_MITM: + auth_req_mode_s = "mitm"; + break; + case AUTH_REQ_BOND_MITM: + auth_req_mode_s = "bond_mitm"; + break; + case AUTH_REQ_SC_ONLY: + auth_req_mode_s = "sc_only"; + break; + case AUTH_REQ_SC_BOND: + auth_req_mode_s = "sc_bond"; + break; + case AUTH_REQ_SC_MITM: + auth_req_mode_s = "sc_mitm"; + break; + case AUTH_REQ_SC_MITM_BOND: + auth_req_mode_s = "sc_mitm_bond"; + break; + } + } + + ESP_LOGCONFIG(TAG, " Auth Req Mode: %s", auth_req_mode_s); + if (this->max_key_size_ && this->min_key_size_) { + ESP_LOGCONFIG(TAG, " Key Size: %u - %u", this->min_key_size_, this->max_key_size_); + } else if (this->max_key_size_) { + ESP_LOGCONFIG(TAG, " Key Size: - %u", this->max_key_size_); + } else if (this->min_key_size_) { + ESP_LOGCONFIG(TAG, " Key Size: %u - ", this->min_key_size_); + } +#endif // ESPHOME_ESP32_BLE_EXTENDED_AUTH_PARAMS + } else { ESP_LOGCONFIG(TAG, "Bluetooth stack is not enabled"); } diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index f1ab81b6dc..2ce17e97be 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -52,6 +52,19 @@ enum IoCapability { IO_CAP_KBDISP = ESP_IO_CAP_KBDISP, }; +#ifdef ESPHOME_ESP32_BLE_EXTENDED_AUTH_PARAMS +enum AuthReqMode { + AUTH_REQ_NO_BOND = ESP_LE_AUTH_NO_BOND, + AUTH_REQ_BOND = ESP_LE_AUTH_BOND, + AUTH_REQ_MITM = ESP_LE_AUTH_REQ_MITM, + AUTH_REQ_BOND_MITM = ESP_LE_AUTH_REQ_BOND_MITM, + AUTH_REQ_SC_ONLY = ESP_LE_AUTH_REQ_SC_ONLY, + AUTH_REQ_SC_BOND = ESP_LE_AUTH_REQ_SC_BOND, + AUTH_REQ_SC_MITM = ESP_LE_AUTH_REQ_SC_MITM, + AUTH_REQ_SC_MITM_BOND = ESP_LE_AUTH_REQ_SC_MITM_BOND, +}; +#endif + enum BLEComponentState : uint8_t { /** Nothing has been initialized yet. */ BLE_COMPONENT_STATE_OFF = 0, @@ -100,6 +113,12 @@ class ESP32BLE : public Component { public: void set_io_capability(IoCapability io_capability) { this->io_cap_ = (esp_ble_io_cap_t) io_capability; } +#ifdef ESPHOME_ESP32_BLE_EXTENDED_AUTH_PARAMS + void set_max_key_size(uint8_t key_size) { this->max_key_size_ = key_size; } + void set_min_key_size(uint8_t key_size) { this->min_key_size_ = key_size; } + void set_auth_req(AuthReqMode req) { this->auth_req_mode_ = (esp_ble_auth_req_t) req; } +#endif + void set_advertising_cycle_time(uint32_t advertising_cycle_time) { this->advertising_cycle_time_ = advertising_cycle_time; } @@ -209,6 +228,13 @@ class ESP32BLE : public Component { // 1-byte aligned members (grouped together to minimize padding) BLEComponentState state_{BLE_COMPONENT_STATE_OFF}; // 1 byte (uint8_t enum) bool enable_on_boot_{}; // 1 byte + +#ifdef ESPHOME_ESP32_BLE_EXTENDED_AUTH_PARAMS + optional auth_req_mode_; + + uint8_t max_key_size_{0}; // range is 7..16, 0 is unset + uint8_t min_key_size_{0}; // range is 7..16, 0 is unset +#endif }; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 673aa246fe..9b55ae93b1 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -209,6 +209,7 @@ #define ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT 1 #define ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT 1 #define ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT 2 +#define ESPHOME_ESP32_BLE_EXTENDED_AUTH_PARAMS #define ESPHOME_LOOP_TASK_STACK_SIZE 8192 #define USE_ESP32_CAMERA_JPEG_ENCODER #define USE_HTTP_REQUEST_RESPONSE diff --git a/tests/components/esp32_ble/test-extended-auth-req-params-single.esp32-idf.yaml b/tests/components/esp32_ble/test-extended-auth-req-params-single.esp32-idf.yaml new file mode 100644 index 0000000000..6e191c132f --- /dev/null +++ b/tests/components/esp32_ble/test-extended-auth-req-params-single.esp32-idf.yaml @@ -0,0 +1,6 @@ +esp32_ble: + io_capability: keyboard_display + # Explicitly not setting some parameters to test ifdef selection + # max_key_size: 16 + # min_key_size: 7 + auth_req_mode: sc_mitm_bond diff --git a/tests/components/esp32_ble/test-extended-auth-req-params.esp32-idf.yaml b/tests/components/esp32_ble/test-extended-auth-req-params.esp32-idf.yaml new file mode 100644 index 0000000000..f05b9bac96 --- /dev/null +++ b/tests/components/esp32_ble/test-extended-auth-req-params.esp32-idf.yaml @@ -0,0 +1,5 @@ +esp32_ble: + io_capability: keyboard_display + max_key_size: 16 + min_key_size: 7 + auth_req_mode: sc_mitm_bond From 0f7ac1726d9fea8cdb7ff481fbfcaa8b4b49620e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 Feb 2026 09:03:37 -0700 Subject: [PATCH 2/2] [core] Extend fast select optimization to LibreTiny platforms (#14254) Co-authored-by: Claude Opus 4.6 --- esphome/components/socket/__init__.py | 11 ++-- esphome/core/application.cpp | 43 ++++++++------- esphome/core/application.h | 27 +++++----- esphome/core/defines.h | 2 + esphome/core/lwip_fast_select.c | 54 +++++++++++-------- esphome/core/lwip_fast_select.h | 2 +- .../socket/test_wake_loop_threadsafe.py | 23 +++++--- 7 files changed, 100 insertions(+), 62 deletions(-) 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 a9753da1b5..3bd4c1c670 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 @@ -147,14 +154,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 @@ -532,7 +539,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) && !defined(USE_ESP32) +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_LWIP_FAST_SELECT) // Drain wake notifications first to clear socket for next wake this->drain_wake_notifications_(); #endif @@ -585,7 +592,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 @@ -609,12 +616,13 @@ void Application::unregister_socket_fd(int fd) { continue; // 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. + // No need to unhook the netconn callback on fast select platforms — 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 +#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_) { @@ -633,8 +641,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(); @@ -659,9 +667,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]] { @@ -725,12 +732,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 @@ -798,7 +805,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 f5df5e7bdf..0cc29af8e7 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 @@ -511,10 +511,11 @@ class Application { #ifdef USE_SOCKET_SELECT_SUPPORT /// Fast path for Socket::ready() via friendship - skips negative fd check. - /// 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 + /// Main loop only — with USE_LWIP_FAST_SELECT, 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_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_); } @@ -546,7 +547,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 @@ -576,7 +577,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 @@ -589,7 +590,7 @@ class Application { uint32_t last_loop_{0}; uint32_t loop_component_start_time_{0}; -#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 @@ -605,12 +606,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 @@ -699,7 +700,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) && !defined(USE_ESP32) +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_LWIP_FAST_SELECT) // Inline implementations for hot-path functions // drain_wake_notifications_() is called on every loop iteration @@ -721,6 +722,6 @@ inline void Application::drain_wake_notifications_() { } } } -#endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) +#endif // defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_LWIP_FAST_SELECT) } // namespace esphome diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 9b55ae93b1..5c982c94b1 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -222,6 +222,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 @@ -330,6 +331,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 70a6482d48..88cf23b67e 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -1,11 +1,12 @@ -// 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. +// USE_ESP32 and USE_LIBRETINY platform flags (-D) control compilation of this file. +// See the guard at the bottom of the header comment for details. // // Thread safety analysis // ====================== @@ -81,20 +82,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 +105,30 @@ // critical sections). Multiple concurrent xTaskNotifyGive calls are safe — // the notification count simply increments. -#ifdef USE_ESP32 +// USE_ESP32 and USE_LIBRETINY are compiler -D flags, so they are always visible in this .c file. +// Feature macros like USE_LWIP_FAST_SELECT may come from generated headers that are not included here, +// so this implementation is enabled based on platform flags instead of USE_LWIP_FAST_SELECT. +#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 +138,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"); @@ -183,9 +195,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; } @@ -200,7 +212,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; } @@ -213,4 +225,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_wake_loop_threadsafe.py b/tests/components/socket/test_wake_loop_threadsafe.py index 28b4ee564f..0434b3e1b5 100644 --- a/tests/components/socket/test_wake_loop_threadsafe.py +++ b/tests/components/socket/test_wake_loop_threadsafe.py @@ -1,9 +1,14 @@ +import pytest + 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 @@ -90,9 +95,15 @@ def test_require_wake_loop_threadsafe__no_networking_does_not_consume_socket() - 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) +@pytest.mark.parametrize( + "platform", + [PLATFORM_ESP32, PLATFORM_BK72XX, PLATFORM_RTL87XX, PLATFORM_LN882X], +) +def test_require_wake_loop_threadsafe__fast_select_no_udp_socket( + platform: str, +) -> None: + """Test that fast select platforms use task notifications instead of UDP socket.""" + _setup_platform(platform) CORE.config = {"wifi": True} socket.require_wake_loop_threadsafe() @@ -100,13 +111,13 @@ def test_require_wake_loop_threadsafe__esp32_no_udp_socket() -> None: 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) + # Verify no UDP socket was consumed (fast select platforms use 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.""" +def test_require_wake_loop_threadsafe__non_fast_select_consumes_udp_socket() -> None: + """Test that platforms without fast select consume a UDP socket for wake notifications.""" _setup_platform(PLATFORM_ESP8266) CORE.config = {"wifi": True} socket.require_wake_loop_threadsafe()