From 66eb10cc55b6fc7c33d453bbb67ce432b87d90f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Nov 2025 14:52:45 -0500 Subject: [PATCH 01/10] fix ble latency --- esphome/components/esp32_ble/__init__.py | 7 ++ esphome/components/esp32_ble/ble.cpp | 115 +++++++++++++++++++++++ esphome/components/esp32_ble/ble.h | 14 +++ 3 files changed, 136 insertions(+) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 411c2add71..beb6fd70da 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -7,6 +7,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg +from esphome.components import socket from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_variant import esphome.config_validation as cv from esphome.const import ( @@ -481,6 +482,12 @@ async def to_code(config): cg.add(var.set_name(name)) await cg.register_component(var, config) + # BLE uses 1 UDP socket for event notification to wake up main loop from select() + # This enables low-latency (~12μs) BLE event processing instead of waiting for + # select() timeout (0-16ms). The socket is created in ble_setup_() and used to + # wake lwip_select() when BLE events arrive from the BLE thread. + socket.consume_sockets(1, "esp32_ble")(config) + # Define max connections for use in C++ code (e.g., ble_server.h) max_connections = config.get(CONF_MAX_CONNECTIONS, DEFAULT_MAX_CONNECTIONS) cg.add_define("USE_ESP32_BLE_MAX_CONNECTIONS", max_connections) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 5bbd5fe9ed..8730b894ea 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -27,6 +27,10 @@ extern "C" { #include #endif +#ifdef USE_SOCKET_SELECT_SUPPORT +#include +#endif + namespace esphome::esp32_ble { static const char *const TAG = "esp32_ble"; @@ -273,10 +277,21 @@ bool ESP32BLE::ble_setup_() { // BLE takes some time to be fully set up, 200ms should be more than enough delay(200); // NOLINT + // Set up notification socket to wake main loop for BLE events + // This enables low-latency (~12μs) event processing instead of waiting for select() timeout +#ifdef USE_SOCKET_SELECT_SUPPORT + this->setup_event_notification_(); +#endif + return true; } bool ESP32BLE::ble_dismantle_() { + // Clean up notification socket first before dismantling BLE stack +#ifdef USE_SOCKET_SELECT_SUPPORT + this->cleanup_event_notification_(); +#endif + esp_err_t err = esp_bluedroid_disable(); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_bluedroid_disable failed: %d", err); @@ -374,6 +389,12 @@ void ESP32BLE::loop() { break; } +#ifdef USE_SOCKET_SELECT_SUPPORT + // Drain any notification socket events first + // This clears the socket so it doesn't stay "ready" in subsequent select() calls + this->drain_event_notifications_(); +#endif + BLEEvent *ble_event = this->ble_events_.pop(); while (ble_event != nullptr) { switch (ble_event->type_) { @@ -531,6 +552,12 @@ template void enqueue_ble_event(Args... args) { // Push the event to the queue global_ble->ble_events_.push(event); // Push always succeeds because we're the only producer and the pool ensures we never exceed queue size + + // Wake up main loop to process event immediately + // This is thread-safe - notify_main_loop_() uses lwip_sendto which is thread-safe +#ifdef USE_SOCKET_SELECT_SUPPORT + global_ble->notify_main_loop_(); +#endif } // Explicit template instantiations for the friend function @@ -630,6 +657,94 @@ void ESP32BLE::dump_config() { } } +#ifdef USE_SOCKET_SELECT_SUPPORT +void ESP32BLE::setup_event_notification_() { + // Guard against multiple calls (reentrant safety for ble.enable automation) + if (this->notify_fd_ >= 0) { + return; // Already set up + } + + // Create UDP socket for event notifications + this->notify_fd_ = lwip_socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (this->notify_fd_ < 0) { + ESP_LOGW(TAG, "Event socket create failed: %d", errno); + return; + } + + // Bind to loopback with auto-assigned port + struct sockaddr_in addr = {}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = lwip_htonl(INADDR_LOOPBACK); + addr.sin_port = 0; // Auto-assign port + + if (lwip_bind(this->notify_fd_, (struct sockaddr *) &addr, sizeof(addr)) < 0) { + ESP_LOGW(TAG, "Event socket bind failed: %d", errno); + lwip_close(this->notify_fd_); + this->notify_fd_ = -1; + return; + } + + // Get the assigned port for sendto() + socklen_t len = sizeof(this->notify_addr_); + if (lwip_getsockname(this->notify_fd_, (struct sockaddr *) &this->notify_addr_, &len) < 0) { + ESP_LOGW(TAG, "Event socket address failed: %d", errno); + lwip_close(this->notify_fd_); + this->notify_fd_ = -1; + return; + } + + // Set non-blocking mode + int flags = lwip_fcntl(this->notify_fd_, F_GETFL, 0); + lwip_fcntl(this->notify_fd_, F_SETFL, flags | O_NONBLOCK); + + // Register with application's select() loop + if (!App.register_socket_fd(this->notify_fd_)) { + ESP_LOGW(TAG, "Event socket register failed"); + lwip_close(this->notify_fd_); + this->notify_fd_ = -1; + return; + } + + ESP_LOGD(TAG, "Event socket ready"); +} + +void ESP32BLE::cleanup_event_notification_() { + // Guard against multiple calls (reentrant safety for ble.disable automation) + if (this->notify_fd_ < 0) { + return; // Already cleaned up + } + + App.unregister_socket_fd(this->notify_fd_); + lwip_close(this->notify_fd_); + this->notify_fd_ = -1; + ESP_LOGD(TAG, "Event socket closed"); +} + +void ESP32BLE::notify_main_loop_() { + // Called from BLE thread context when events are queued + // Wakes up lwip_select() in main loop by writing to loopback socket + if (this->notify_fd_ >= 0) { + const char dummy = 1; + // Non-blocking sendto - if it fails (unlikely), select() will wake on timeout anyway + // This is safe to call from BLE thread - sendto() is thread-safe in lwip + lwip_sendto(this->notify_fd_, &dummy, 1, 0, (struct sockaddr *) &this->notify_addr_, sizeof(this->notify_addr_)); + } +} + +void ESP32BLE::drain_event_notifications_() { + // Called from main loop to drain any pending notifications + // Must check is_socket_ready() to avoid blocking on empty socket + if (this->notify_fd_ >= 0 && App.is_socket_ready(this->notify_fd_)) { + char buffer[64]; + // Drain all pending notifications with non-blocking reads + // Multiple BLE events may have triggered multiple writes, so drain until EWOULDBLOCK + while (lwip_recvfrom(this->notify_fd_, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) { + // Just draining, no action needed + } + } +} +#endif // USE_SOCKET_SELECT_SUPPORT + uint64_t ble_addr_to_uint64(const esp_bd_addr_t address) { uint64_t u = 0; u |= uint64_t(address[0] & 0xFF) << 40; diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index dc973f0e82..e03d7f4f03 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -162,6 +162,13 @@ class ESP32BLE : public Component { void advertising_init_(); #endif +#ifdef USE_SOCKET_SELECT_SUPPORT + void setup_event_notification_(); // Create notification socket + void cleanup_event_notification_(); // Close and unregister socket + void notify_main_loop_(); // Wake up select() from BLE thread + void drain_event_notifications_(); // Read pending notifications in main loop +#endif + private: template friend void enqueue_ble_event(Args... args); @@ -196,6 +203,13 @@ class ESP32BLE : public Component { esp_ble_io_cap_t io_cap_{ESP_IO_CAP_NONE}; // 4 bytes (enum) uint32_t advertising_cycle_time_{}; // 4 bytes +#ifdef USE_SOCKET_SELECT_SUPPORT + // Event notification socket for waking up main loop from BLE thread + // Uses UDP loopback to wake lwip_select() with ~12μs latency vs 0-16ms timeout + struct sockaddr_in notify_addr_ {}; // 16 bytes (sockaddr_in structure) + int notify_fd_{-1}; // 4 bytes (file descriptor) +#endif + // 2-byte aligned members uint16_t appearance_{0}; // 2 bytes From 9c5dbd18c24b2757826f26ed1994b7f2263d0f8d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Nov 2025 14:53:12 -0500 Subject: [PATCH 02/10] fix ble latency --- esphome/components/esp32_ble/ble.cpp | 5 ----- 1 file changed, 5 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 8730b894ea..d73a54a973 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -659,11 +659,6 @@ void ESP32BLE::dump_config() { #ifdef USE_SOCKET_SELECT_SUPPORT void ESP32BLE::setup_event_notification_() { - // Guard against multiple calls (reentrant safety for ble.enable automation) - if (this->notify_fd_ >= 0) { - return; // Already set up - } - // Create UDP socket for event notifications this->notify_fd_ = lwip_socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if (this->notify_fd_ < 0) { From a29f209b46aebfd898b8c6809fabf3bf7818edd9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Nov 2025 14:53:34 -0500 Subject: [PATCH 03/10] fix ble latency --- esphome/components/esp32_ble/ble.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index d73a54a973..bdc0837a47 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -704,9 +704,8 @@ void ESP32BLE::setup_event_notification_() { } void ESP32BLE::cleanup_event_notification_() { - // Guard against multiple calls (reentrant safety for ble.disable automation) if (this->notify_fd_ < 0) { - return; // Already cleaned up + return; } App.unregister_socket_fd(this->notify_fd_); From f6a5a30dc283627e7655de037132b2d3f5dbf2b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Nov 2025 14:55:37 -0500 Subject: [PATCH 04/10] fix ble latency --- esphome/components/esp32_ble/ble.cpp | 33 +++------------------ esphome/components/esp32_ble/ble.h | 43 +++++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 33 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index bdc0837a47..cadb2cbc2a 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -704,39 +704,14 @@ void ESP32BLE::setup_event_notification_() { } void ESP32BLE::cleanup_event_notification_() { - if (this->notify_fd_ < 0) { - return; - } - - App.unregister_socket_fd(this->notify_fd_); - lwip_close(this->notify_fd_); - this->notify_fd_ = -1; - ESP_LOGD(TAG, "Event socket closed"); -} - -void ESP32BLE::notify_main_loop_() { - // Called from BLE thread context when events are queued - // Wakes up lwip_select() in main loop by writing to loopback socket if (this->notify_fd_ >= 0) { - const char dummy = 1; - // Non-blocking sendto - if it fails (unlikely), select() will wake on timeout anyway - // This is safe to call from BLE thread - sendto() is thread-safe in lwip - lwip_sendto(this->notify_fd_, &dummy, 1, 0, (struct sockaddr *) &this->notify_addr_, sizeof(this->notify_addr_)); + App.unregister_socket_fd(this->notify_fd_); + lwip_close(this->notify_fd_); + this->notify_fd_ = -1; + ESP_LOGD(TAG, "Event socket closed"); } } -void ESP32BLE::drain_event_notifications_() { - // Called from main loop to drain any pending notifications - // Must check is_socket_ready() to avoid blocking on empty socket - if (this->notify_fd_ >= 0 && App.is_socket_ready(this->notify_fd_)) { - char buffer[64]; - // Drain all pending notifications with non-blocking reads - // Multiple BLE events may have triggered multiple writes, so drain until EWOULDBLOCK - while (lwip_recvfrom(this->notify_fd_, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) { - // Just draining, no action needed - } - } -} #endif // USE_SOCKET_SELECT_SUPPORT uint64_t ble_addr_to_uint64(const esp_bd_addr_t address) { diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index e03d7f4f03..32f62baf88 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -25,6 +25,10 @@ #include #include +#ifdef USE_SOCKET_SELECT_SUPPORT +#include +#endif + namespace esphome::esp32_ble { // Maximum size of the BLE event queue @@ -163,10 +167,10 @@ class ESP32BLE : public Component { #endif #ifdef USE_SOCKET_SELECT_SUPPORT - void setup_event_notification_(); // Create notification socket - void cleanup_event_notification_(); // Close and unregister socket - void notify_main_loop_(); // Wake up select() from BLE thread - void drain_event_notifications_(); // Read pending notifications in main loop + void setup_event_notification_(); // Create notification socket + void cleanup_event_notification_(); // Close and unregister socket + inline void notify_main_loop_(); // Wake up select() from BLE thread (hot path - inlined) + inline void drain_event_notifications_(); // Read pending notifications in main loop (hot path - inlined) #endif private: @@ -221,6 +225,37 @@ class ESP32BLE : public Component { // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) extern ESP32BLE *global_ble; +#ifdef USE_SOCKET_SELECT_SUPPORT +// Inline implementations for hot-path functions +// These are called from BLE thread (notify) and main loop (drain) on every event + +inline void ESP32BLE::notify_main_loop_() { + // Called from BLE thread context when events are queued + // Wakes up lwip_select() in main loop by writing to loopback socket + if (this->notify_fd_ >= 0) { + const char dummy = 1; + // Non-blocking sendto - if it fails (unlikely), select() will wake on timeout anyway + // This is safe to call from BLE thread - sendto() is thread-safe in lwip + lwip_sendto(this->notify_fd_, &dummy, 1, 0, (struct sockaddr *) &this->notify_addr_, sizeof(this->notify_addr_)); + } +} + +inline void ESP32BLE::drain_event_notifications_() { + // Called from main loop to drain any pending notifications + // Must check is_socket_ready() to avoid blocking on empty socket + // Requires App to be defined - include esphome/core/application.h in .cpp files that use this + extern esphome::Application App; + if (this->notify_fd_ >= 0 && App.is_socket_ready(this->notify_fd_)) { + char buffer[16]; + // Drain all pending notifications with non-blocking reads + // Multiple BLE events may have triggered multiple writes, so drain until EWOULDBLOCK + while (lwip_recvfrom(this->notify_fd_, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) { + // Just draining, no action needed + } + } +} +#endif // USE_SOCKET_SELECT_SUPPORT + template class BLEEnabledCondition : public Condition { public: bool check(Ts... x) override { return global_ble->is_active(); } From ff2e2bed666c582e5b3ff356b413224f36d4a987 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Nov 2025 14:56:11 -0500 Subject: [PATCH 05/10] fix ble latency --- esphome/components/esp32_ble/ble.h | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 32f62baf88..b62109bff5 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -229,6 +229,10 @@ extern ESP32BLE *global_ble; // Inline implementations for hot-path functions // These are called from BLE thread (notify) and main loop (drain) on every event +// Small buffer for draining notification bytes (1 byte sent per BLE event) +// Size allows draining multiple notifications per recvfrom() without wasting stack +static constexpr size_t BLE_EVENT_NOTIFY_DRAIN_BUFFER_SIZE = 16; + inline void ESP32BLE::notify_main_loop_() { // Called from BLE thread context when events are queued // Wakes up lwip_select() in main loop by writing to loopback socket @@ -246,7 +250,7 @@ inline void ESP32BLE::drain_event_notifications_() { // Requires App to be defined - include esphome/core/application.h in .cpp files that use this extern esphome::Application App; if (this->notify_fd_ >= 0 && App.is_socket_ready(this->notify_fd_)) { - char buffer[16]; + char buffer[BLE_EVENT_NOTIFY_DRAIN_BUFFER_SIZE]; // Drain all pending notifications with non-blocking reads // Multiple BLE events may have triggered multiple writes, so drain until EWOULDBLOCK while (lwip_recvfrom(this->notify_fd_, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) { From 69af4cddb5f6d1b315d76eb75444174cc819799d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Nov 2025 14:58:24 -0500 Subject: [PATCH 06/10] fix ble latency --- esphome/components/esp32_ble/ble.cpp | 17 ++++++++++++++--- esphome/components/esp32_ble/ble.h | 15 ++++++++------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index cadb2cbc2a..7298dc9621 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -679,15 +679,26 @@ void ESP32BLE::setup_event_notification_() { return; } - // Get the assigned port for sendto() - socklen_t len = sizeof(this->notify_addr_); - if (lwip_getsockname(this->notify_fd_, (struct sockaddr *) &this->notify_addr_, &len) < 0) { + // Get the assigned address and connect to it + // Connecting a UDP socket allows using send() instead of sendto() for better performance + struct sockaddr_in notify_addr; + socklen_t len = sizeof(notify_addr); + if (lwip_getsockname(this->notify_fd_, (struct sockaddr *) ¬ify_addr, &len) < 0) { ESP_LOGW(TAG, "Event socket address failed: %d", errno); lwip_close(this->notify_fd_); this->notify_fd_ = -1; return; } + // Connect to self (loopback) - allows using send() instead of sendto() + // After connect(), no need to store notify_addr - the socket remembers it + if (lwip_connect(this->notify_fd_, (struct sockaddr *) ¬ify_addr, sizeof(notify_addr)) < 0) { + ESP_LOGW(TAG, "Event socket connect failed: %d", errno); + lwip_close(this->notify_fd_); + this->notify_fd_ = -1; + return; + } + // Set non-blocking mode int flags = lwip_fcntl(this->notify_fd_, F_GETFL, 0); lwip_fcntl(this->notify_fd_, F_SETFL, flags | O_NONBLOCK); diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index b62109bff5..93c03063c6 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -209,9 +209,9 @@ class ESP32BLE : public Component { #ifdef USE_SOCKET_SELECT_SUPPORT // Event notification socket for waking up main loop from BLE thread - // Uses UDP loopback to wake lwip_select() with ~12μs latency vs 0-16ms timeout - struct sockaddr_in notify_addr_ {}; // 16 bytes (sockaddr_in structure) - int notify_fd_{-1}; // 4 bytes (file descriptor) + // Uses connected UDP loopback socket to wake lwip_select() with ~12μs latency vs 0-16ms timeout + // Socket is connected during setup, allowing use of send() instead of sendto() for efficiency + int notify_fd_{-1}; // 4 bytes (file descriptor) #endif // 2-byte aligned members @@ -235,12 +235,13 @@ static constexpr size_t BLE_EVENT_NOTIFY_DRAIN_BUFFER_SIZE = 16; inline void ESP32BLE::notify_main_loop_() { // Called from BLE thread context when events are queued - // Wakes up lwip_select() in main loop by writing to loopback socket + // Wakes up lwip_select() in main loop by writing to connected loopback socket if (this->notify_fd_ >= 0) { const char dummy = 1; - // Non-blocking sendto - if it fails (unlikely), select() will wake on timeout anyway - // This is safe to call from BLE thread - sendto() is thread-safe in lwip - lwip_sendto(this->notify_fd_, &dummy, 1, 0, (struct sockaddr *) &this->notify_addr_, sizeof(this->notify_addr_)); + // Non-blocking send - if it fails (unlikely), select() will wake on timeout anyway + // This is safe to call from BLE thread - send() is thread-safe in lwip + // Socket is already connected to loopback address, so send() is faster than sendto() + lwip_send(this->notify_fd_, &dummy, 1, 0); } } From 32ea82060d5e134577bbfaf26531b885dc096c75 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Nov 2025 15:02:26 -0500 Subject: [PATCH 07/10] fix ble latency --- esphome/components/esp32_ble/ble.cpp | 13 +++++++++++++ esphome/components/esp32_ble/ble.h | 12 +++++------- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 7298dc9621..797dbcc2bf 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -723,6 +723,19 @@ void ESP32BLE::cleanup_event_notification_() { } } +void ESP32BLE::drain_event_notifications_() { + // Called from main loop to drain any pending notifications + // Must check is_socket_ready() to avoid blocking on empty socket + if (this->notify_fd_ >= 0 && App.is_socket_ready(this->notify_fd_)) { + char buffer[BLE_EVENT_NOTIFY_DRAIN_BUFFER_SIZE]; + // Drain all pending notifications with non-blocking reads + // Multiple BLE events may have triggered multiple writes, so drain until EWOULDBLOCK + while (lwip_recvfrom(this->notify_fd_, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) { + // Just draining, no action needed + } + } +} + #endif // USE_SOCKET_SELECT_SUPPORT uint64_t ble_addr_to_uint64(const esp_bd_addr_t address) { diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 93c03063c6..a91d8756a8 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -167,10 +167,10 @@ class ESP32BLE : public Component { #endif #ifdef USE_SOCKET_SELECT_SUPPORT - void setup_event_notification_(); // Create notification socket - void cleanup_event_notification_(); // Close and unregister socket - inline void notify_main_loop_(); // Wake up select() from BLE thread (hot path - inlined) - inline void drain_event_notifications_(); // Read pending notifications in main loop (hot path - inlined) + void setup_event_notification_(); // Create notification socket + void cleanup_event_notification_(); // Close and unregister socket + inline void notify_main_loop_(); // Wake up select() from BLE thread (hot path - inlined) + void drain_event_notifications_(); // Read pending notifications in main loop #endif private: @@ -248,9 +248,7 @@ inline void ESP32BLE::notify_main_loop_() { inline void ESP32BLE::drain_event_notifications_() { // Called from main loop to drain any pending notifications // Must check is_socket_ready() to avoid blocking on empty socket - // Requires App to be defined - include esphome/core/application.h in .cpp files that use this - extern esphome::Application App; - if (this->notify_fd_ >= 0 && App.is_socket_ready(this->notify_fd_)) { + if (this->notify_fd_ >= 0 && esphome::App.is_socket_ready(this->notify_fd_)) { char buffer[BLE_EVENT_NOTIFY_DRAIN_BUFFER_SIZE]; // Drain all pending notifications with non-blocking reads // Multiple BLE events may have triggered multiple writes, so drain until EWOULDBLOCK From b80f40676a82e88f124abf6289117f7fc478c8a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Nov 2025 15:02:51 -0500 Subject: [PATCH 08/10] fix ble latency --- esphome/components/esp32_ble/ble.h | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index a91d8756a8..facb0e5853 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -244,19 +244,6 @@ inline void ESP32BLE::notify_main_loop_() { lwip_send(this->notify_fd_, &dummy, 1, 0); } } - -inline void ESP32BLE::drain_event_notifications_() { - // Called from main loop to drain any pending notifications - // Must check is_socket_ready() to avoid blocking on empty socket - if (this->notify_fd_ >= 0 && esphome::App.is_socket_ready(this->notify_fd_)) { - char buffer[BLE_EVENT_NOTIFY_DRAIN_BUFFER_SIZE]; - // Drain all pending notifications with non-blocking reads - // Multiple BLE events may have triggered multiple writes, so drain until EWOULDBLOCK - while (lwip_recvfrom(this->notify_fd_, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) { - // Just draining, no action needed - } - } -} #endif // USE_SOCKET_SELECT_SUPPORT template class BLEEnabledCondition : public Condition { From bb2418a53f1e44de7e731add6d23bf264fe96c89 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Nov 2025 15:13:30 -0500 Subject: [PATCH 09/10] fix --- esphome/components/esp32_ble/ble.cpp | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 797dbcc2bf..2fcc9270cd 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -552,12 +552,6 @@ template void enqueue_ble_event(Args... args) { // Push the event to the queue global_ble->ble_events_.push(event); // Push always succeeds because we're the only producer and the pool ensures we never exceed queue size - - // Wake up main loop to process event immediately - // This is thread-safe - notify_main_loop_() uses lwip_sendto which is thread-safe -#ifdef USE_SOCKET_SELECT_SUPPORT - global_ble->notify_main_loop_(); -#endif } // Explicit template instantiations for the friend function @@ -611,6 +605,10 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa void ESP32BLE::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, esp_ble_gatts_cb_param_t *param) { enqueue_ble_event(event, gatts_if, param); + // Wake up main loop to process GATT event immediately +#ifdef USE_SOCKET_SELECT_SUPPORT + global_ble->notify_main_loop_(); +#endif } #endif @@ -618,6 +616,10 @@ void ESP32BLE::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gat void ESP32BLE::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) { enqueue_ble_event(event, gattc_if, param); + // Wake up main loop to process GATT event immediately +#ifdef USE_SOCKET_SELECT_SUPPORT + global_ble->notify_main_loop_(); +#endif } #endif From 604508e3d8fd9e006eba464096139762fc5e8358 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Nov 2025 15:23:35 -0500 Subject: [PATCH 10/10] fix --- esphome/components/esp32_ble/__init__.py | 2 ++ esphome/components/esp32_ble/ble.cpp | 4 +++- esphome/components/esp32_ble/ble.h | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index beb6fd70da..1ae8df6f5e 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -486,6 +486,8 @@ async def to_code(config): # This enables low-latency (~12μs) BLE event processing instead of waiting for # select() timeout (0-16ms). The socket is created in ble_setup_() and used to # wake lwip_select() when BLE events arrive from the BLE thread. + # Note: Called during config generation, socket is created at runtime. In practice, + # always used since esp32_ble only runs on ESP32 which always has USE_SOCKET_SELECT_SUPPORT. socket.consume_sockets(1, "esp32_ble")(config) # Define max connections for use in C++ code (e.g., ble_server.h) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 2fcc9270cd..9cb482bcbb 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -732,8 +732,10 @@ void ESP32BLE::drain_event_notifications_() { char buffer[BLE_EVENT_NOTIFY_DRAIN_BUFFER_SIZE]; // Drain all pending notifications with non-blocking reads // Multiple BLE events may have triggered multiple writes, so drain until EWOULDBLOCK + // We control both ends of this loopback socket (always write 1 byte per event), + // so no error checking needed - any errors indicate catastrophic system failure while (lwip_recvfrom(this->notify_fd_, buffer, sizeof(buffer), 0, nullptr, nullptr) > 0) { - // Just draining, no action needed + // Just draining, no action needed - actual BLE events are already queued } } } diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index facb0e5853..7c3195db6d 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -239,6 +239,8 @@ inline void ESP32BLE::notify_main_loop_() { if (this->notify_fd_ >= 0) { const char dummy = 1; // Non-blocking send - if it fails (unlikely), select() will wake on timeout anyway + // No error checking needed: we control both ends of this loopback socket, and the + // BLE event is already queued. Notification is best-effort to reduce latency. // This is safe to call from BLE thread - send() is thread-safe in lwip // Socket is already connected to loopback address, so send() is faster than sendto() lwip_send(this->notify_fd_, &dummy, 1, 0);