From eb9befde4defdac72ffbae3b906be18c3ef44a4d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 3 Oct 2025 18:07:10 -0500 Subject: [PATCH] merge --- esphome/components/ble_client/__init__.py | 2 +- .../components/bluetooth_proxy/__init__.py | 8 +- esphome/components/esp32_ble/__init__.py | 111 +++++++++++++++++- .../esp32_ble_server/ble_characteristic.cpp | 6 +- .../esp32_ble_server/ble_server.cpp | 28 ++++- .../components/esp32_ble_server/ble_server.h | 14 ++- .../components/esp32_ble_tracker/__init__.py | 78 +++--------- esphome/core/defines.h | 1 + 8 files changed, 170 insertions(+), 78 deletions(-) diff --git a/esphome/components/ble_client/__init__.py b/esphome/components/ble_client/__init__.py index 5f4ea8afd1..768a345213 100644 --- a/esphome/components/ble_client/__init__.py +++ b/esphome/components/ble_client/__init__.py @@ -116,7 +116,7 @@ CONFIG_SCHEMA = cv.All( ) .extend(cv.COMPONENT_SCHEMA) .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA), - esp32_ble_tracker.consume_connection_slots(1, "ble_client"), + esp32_ble.consume_connection_slots(1, "ble_client"), ) CONF_BLE_CLIENT_ID = "ble_client_id" diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 42a88f1421..ad7528c156 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -42,9 +42,7 @@ def validate_connections(config): ) elif config[CONF_ACTIVE]: connection_slots: int = config[CONF_CONNECTION_SLOTS] - esp32_ble_tracker.consume_connection_slots(connection_slots, "bluetooth_proxy")( - config - ) + esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(config) return { **config, @@ -65,11 +63,11 @@ CONFIG_SCHEMA = cv.All( default=DEFAULT_CONNECTION_SLOTS, ): cv.All( cv.positive_int, - cv.Range(min=1, max=esp32_ble_tracker.IDF_MAX_CONNECTIONS), + cv.Range(min=1, max=esp32_ble.IDF_MAX_CONNECTIONS), ), cv.Optional(CONF_CONNECTIONS): cv.All( cv.ensure_list(CONNECTION_SCHEMA), - cv.Length(min=1, max=esp32_ble_tracker.IDF_MAX_CONNECTIONS), + cv.Length(min=1, max=esp32_ble.IDF_MAX_CONNECTIONS), ), } ) diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 0501d1c5ef..15afb22ab8 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -1,5 +1,8 @@ +from collections.abc import Callable, MutableMapping from enum import Enum +import logging import re +from typing import Any from esphome import automation import esphome.codegen as cg @@ -9,16 +12,19 @@ from esphome.const import ( CONF_ENABLE_ON_BOOT, CONF_ESPHOME, CONF_ID, + CONF_MAX_CONNECTIONS, CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, ) -from esphome.core import TimePeriod +from esphome.core import CORE, TimePeriod import esphome.final_validate as fv DEPENDENCIES = ["esp32"] CODEOWNERS = ["@jesserockz", "@Rapsssito", "@bdraco"] DOMAIN = "esp32_ble" +_LOGGER = logging.getLogger(__name__) + class BTLoggers(Enum): """Bluetooth logger categories available in ESP-IDF. @@ -127,6 +133,28 @@ CONF_DISABLE_BT_LOGS = "disable_bt_logs" CONF_CONNECTION_TIMEOUT = "connection_timeout" CONF_MAX_NOTIFICATIONS = "max_notifications" +# BLE connection limits +# ESP-IDF CONFIG_BT_ACL_CONNECTIONS has range 1-9, default 4 +# Total instances: 10 (ADV + SCAN + connections) +# - ADV only: up to 9 connections +# - SCAN only: up to 9 connections +# - ADV + SCAN: up to 8 connections +DEFAULT_MAX_CONNECTIONS = 3 +IDF_MAX_CONNECTIONS = 9 + +# Connection slot tracking keys +KEY_ESP32_BLE = "esp32_ble" +KEY_USED_CONNECTION_SLOTS = "used_connection_slots" + +# Export for use by other components (bluetooth_proxy, etc.) +__all__ = [ + "DEFAULT_MAX_CONNECTIONS", + "IDF_MAX_CONNECTIONS", + "KEY_ESP32_BLE", + "KEY_USED_CONNECTION_SLOTS", + "consume_connection_slots", +] + NO_BLUETOOTH_VARIANTS = [const.VARIANT_ESP32S2] esp32_ble_ns = cg.esphome_ns.namespace("esp32_ble") @@ -183,6 +211,9 @@ CONFIG_SCHEMA = cv.Schema( cv.positive_int, cv.Range(min=1, max=64), ), + cv.Optional(CONF_MAX_CONNECTIONS, default=DEFAULT_MAX_CONNECTIONS): cv.All( + cv.positive_int, cv.Range(min=1, max=IDF_MAX_CONNECTIONS) + ), } ).extend(cv.COMPONENT_SCHEMA) @@ -230,6 +261,56 @@ def validate_variant(_): raise cv.Invalid(f"{variant} does not support Bluetooth") +def consume_connection_slots( + value: int, consumer: str +) -> Callable[[MutableMapping], MutableMapping]: + """Reserve BLE connection slots for a component. + + Args: + value: Number of connection slots to reserve + consumer: Name of the component consuming the slots + + Returns: + A validator function that records the slot usage + """ + + def _consume_connection_slots(config: MutableMapping) -> MutableMapping: + data: dict[str, Any] = CORE.data.setdefault(KEY_ESP32_BLE, {}) + slots: list[str] = data.setdefault(KEY_USED_CONNECTION_SLOTS, []) + slots.extend([consumer] * value) + return config + + return _consume_connection_slots + + +def validate_connection_slots(max_connections: int) -> None: + """Validate that BLE connection slots don't exceed the configured maximum.""" + ble_data = CORE.data.get(KEY_ESP32_BLE, {}) + used_slots = ble_data.get(KEY_USED_CONNECTION_SLOTS, []) + num_used = len(used_slots) + + if num_used <= max_connections: + return + + slot_users = ", ".join(used_slots) + + if num_used > IDF_MAX_CONNECTIONS: + raise cv.Invalid( + f"BLE components require {num_used} connection slots but maximum is {IDF_MAX_CONNECTIONS}. " + f"Reduce the number of BLE clients. Components: {slot_users}" + ) + + _LOGGER.warning( + "BLE components require %d connection slot(s) but only %d configured. " + "Please set 'max_connections: %d' in the 'esp32_ble' component. " + "Components: %s", + num_used, + max_connections, + num_used, + slot_users, + ) + + def final_validation(config): validate_variant(config) if (name := config.get(CONF_NAME)) is not None: @@ -245,6 +326,10 @@ def final_validation(config): # Set GATT Client/Server sdkconfig options based on which components are loaded full_config = fv.full_config.get() + # Validate connection slots usage + max_connections = config.get(CONF_MAX_CONNECTIONS, DEFAULT_MAX_CONNECTIONS) + validate_connection_slots(max_connections) + # Check if BLE Server is needed has_ble_server = "esp32_ble_server" in full_config add_idf_sdkconfig_option("CONFIG_BT_GATTS_ENABLE", has_ble_server) @@ -255,6 +340,26 @@ def final_validation(config): ) add_idf_sdkconfig_option("CONFIG_BT_GATTC_ENABLE", has_ble_client) + # Handle max_connections: check for deprecated location in esp32_ble_tracker + max_connections = config.get(CONF_MAX_CONNECTIONS, DEFAULT_MAX_CONNECTIONS) + + # Use value from tracker if esp32_ble doesn't have it explicitly set (backward compat) + if "esp32_ble_tracker" in full_config: + tracker_config = full_config["esp32_ble_tracker"] + if "max_connections" in tracker_config and CONF_MAX_CONNECTIONS not in config: + max_connections = tracker_config["max_connections"] + + # Set CONFIG_BT_ACL_CONNECTIONS to the maximum connections needed + 1 for ADV/SCAN + # This is the Bluedroid host stack total instance limit (range 1-9, default 4) + # Total instances = ADV/SCAN (1) + connection slots (max_connections) + # Shared between client (tracker/ble_client) and server + add_idf_sdkconfig_option("CONFIG_BT_ACL_CONNECTIONS", max_connections + 1) + + # Set controller-specific max connections for ESP32 (classic) + # CONFIG_BTDM_CTRL_BLE_MAX_CONN is ESP32-specific controller limit (just connections, not ADV/SCAN) + # For newer chips (C3/S3/etc), different configs are used automatically + add_idf_sdkconfig_option("CONFIG_BTDM_CTRL_BLE_MAX_CONN", max_connections) + return config @@ -270,6 +375,10 @@ async def to_code(config): cg.add(var.set_name(name)) await cg.register_component(var, 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) + add_idf_sdkconfig_option("CONFIG_BT_ENABLED", True) add_idf_sdkconfig_option("CONFIG_BT_BLE_42_FEATURES_SUPPORTED", True) diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index d485d9fe2d..c632165fb7 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -49,7 +49,11 @@ void BLECharacteristic::notify() { this->service_->get_server()->get_connected_client_count() == 0) return; - for (auto &client : this->service_->get_server()->get_clients()) { + const uint16_t *clients = this->service_->get_server()->get_clients(); + uint8_t client_count = this->service_->get_server()->get_client_count(); + + for (uint8_t i = 0; i < client_count; i++) { + uint16_t client = clients[i]; size_t length = this->value_.size(); // Find the client in the list of clients to notify auto *entry = this->find_client_in_notify_list_(client); diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index 942be7e597..a95f37a48b 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -185,9 +185,35 @@ void BLEServer::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t ga } } +int8_t BLEServer::find_client_index_(uint16_t conn_id) const { + for (uint8_t i = 0; i < this->client_count_; i++) { + if (this->clients_[i] == conn_id) + return i; + } + return -1; +} + +void BLEServer::add_client_(uint16_t conn_id) { + // Check if already in list + if (this->find_client_index_(conn_id) >= 0) + return; + // Add if there's space + if (this->client_count_ < USE_ESP32_BLE_MAX_CONNECTIONS) { + this->clients_[this->client_count_++] = conn_id; + } +} + +void BLEServer::remove_client_(uint16_t conn_id) { + int8_t index = this->find_client_index_(conn_id); + if (index >= 0) { + // Replace with last element and decrement count + this->clients_[index] = this->clients_[--this->client_count_]; + } +} + void BLEServer::ble_before_disabled_event_handler() { // Delete all clients - this->clients_.clear(); + this->client_count_ = 0; // Delete all services for (auto &entry : this->services_) { entry.service->do_delete(); diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index 48005b1346..6fa86dd67f 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -12,7 +12,6 @@ #include #include #include -#include #include #ifdef USE_ESP32 @@ -47,8 +46,9 @@ class BLEServer : public Component, public GATTsEventHandler, public BLEStatusEv void set_device_information_service(BLEService *service) { this->device_information_service_ = service; } esp_gatt_if_t get_gatts_if() { return this->gatts_if_; } - uint32_t get_connected_client_count() { return this->clients_.size(); } - const std::unordered_set &get_clients() { return this->clients_; } + uint32_t get_connected_client_count() { return this->client_count_; } + const uint16_t *get_clients() const { return this->clients_; } + uint8_t get_client_count() const { return this->client_count_; } void gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, esp_ble_gatts_cb_param_t *param) override; @@ -82,8 +82,9 @@ class BLEServer : public Component, public GATTsEventHandler, public BLEStatusEv void restart_advertising_(); - void add_client_(uint16_t conn_id) { this->clients_.insert(conn_id); } - void remove_client_(uint16_t conn_id) { this->clients_.erase(conn_id); } + int8_t find_client_index_(uint16_t conn_id) const; + void add_client_(uint16_t conn_id); + void remove_client_(uint16_t conn_id); void dispatch_callbacks_(CallbackType type, uint16_t conn_id); std::vector callbacks_; @@ -92,7 +93,8 @@ class BLEServer : public Component, public GATTsEventHandler, public BLEStatusEv esp_gatt_if_t gatts_if_{0}; bool registered_{false}; - std::unordered_set clients_; + uint16_t clients_[USE_ESP32_BLE_MAX_CONNECTIONS]{}; + uint8_t client_count_{0}; std::vector services_{}; std::vector services_to_start_{}; BLEService *device_information_service_{}; diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 8ebee6b0b1..37c1afa789 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -1,14 +1,14 @@ from __future__ import annotations -from collections.abc import Callable, MutableMapping import logging -from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components import esp32_ble from esphome.components.esp32 import add_idf_sdkconfig_option from esphome.components.esp32_ble import ( + DEFAULT_MAX_CONNECTIONS, + IDF_MAX_CONNECTIONS, BTLoggers, bt_uuid, bt_uuid16_format, @@ -39,18 +39,12 @@ AUTO_LOAD = ["esp32_ble"] DEPENDENCIES = ["esp32"] CODEOWNERS = ["@bdraco"] -KEY_ESP32_BLE_TRACKER = "esp32_ble_tracker" -KEY_USED_CONNECTION_SLOTS = "used_connection_slots" - CONF_ESP32_BLE_ID = "esp32_ble_id" CONF_SCAN_PARAMETERS = "scan_parameters" CONF_WINDOW = "window" CONF_ON_SCAN_END = "on_scan_end" CONF_SOFTWARE_COEXISTENCE = "software_coexistence" -DEFAULT_MAX_CONNECTIONS = 3 -IDF_MAX_CONNECTIONS = 9 - _LOGGER = logging.getLogger(__name__) @@ -128,6 +122,15 @@ def validate_scan_parameters(config): return config +def validate_max_connections_deprecated(config: ConfigType) -> ConfigType: + if CONF_MAX_CONNECTIONS in config: + _LOGGER.warning( + "The 'max_connections' option in 'esp32_ble_tracker' is deprecated. " + "Please move it to the 'esp32_ble' component instead." + ) + return config + + def as_hex(value): return cg.RawExpression(f"0x{value}ULL") @@ -150,18 +153,6 @@ def as_reversed_hex_array(value): ) -def consume_connection_slots( - value: int, consumer: str -) -> Callable[[MutableMapping], MutableMapping]: - def _consume_connection_slots(config: MutableMapping) -> MutableMapping: - data: dict[str, Any] = CORE.data.setdefault(KEY_ESP32_BLE_TRACKER, {}) - slots: list[str] = data.setdefault(KEY_USED_CONNECTION_SLOTS, []) - slots.extend([consumer] * value) - return config - - return _consume_connection_slots - - CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -224,48 +215,11 @@ CONFIG_SCHEMA = cv.All( cv.OnlyWith(CONF_SOFTWARE_COEXISTENCE, "wifi", default=True): bool, } ).extend(cv.COMPONENT_SCHEMA), + validate_max_connections_deprecated, ) -def validate_remaining_connections(config): - data: dict[str, Any] = CORE.data.get(KEY_ESP32_BLE_TRACKER, {}) - slots: list[str] = data.get(KEY_USED_CONNECTION_SLOTS, []) - used_slots = len(slots) - if used_slots <= config[CONF_MAX_CONNECTIONS]: - return config - slot_users = ", ".join(slots) - - if used_slots < IDF_MAX_CONNECTIONS: - _LOGGER.warning( - "esp32_ble_tracker exceeded `%s`: components attempted to consume %d " - "connection slot(s) out of available configured maximum %d connection " - "slot(s); The system automatically increased `%s` to %d to match the " - "number of used connection slot(s) by components: %s.", - CONF_MAX_CONNECTIONS, - used_slots, - config[CONF_MAX_CONNECTIONS], - CONF_MAX_CONNECTIONS, - used_slots, - slot_users, - ) - config[CONF_MAX_CONNECTIONS] = used_slots - return config - - msg = ( - f"esp32_ble_tracker exceeded `{CONF_MAX_CONNECTIONS}`: " - f"components attempted to consume {used_slots} connection slot(s) " - f"out of available configured maximum {config[CONF_MAX_CONNECTIONS]} " - f"connection slot(s); Decrease the number of BLE clients ({slot_users})" - ) - if config[CONF_MAX_CONNECTIONS] < IDF_MAX_CONNECTIONS: - msg += f" or increase {CONF_MAX_CONNECTIONS}` to {used_slots}" - msg += f" to stay under the {IDF_MAX_CONNECTIONS} connection slot(s) limit." - raise cv.Invalid(msg) - - -FINAL_VALIDATE_SCHEMA = cv.All( - validate_remaining_connections, esp32_ble.validate_variant -) +FINAL_VALIDATE_SCHEMA = esp32_ble.validate_variant ESP_BLE_DEVICE_SCHEMA = cv.Schema( { @@ -345,10 +299,8 @@ async def to_code(config): # Match arduino CONFIG_BTU_TASK_STACK_SIZE # https://github.com/espressif/arduino-esp32/blob/fd72cf46ad6fc1a6de99c1d83ba8eba17d80a4ee/tools/sdk/esp32/sdkconfig#L1866 add_idf_sdkconfig_option("CONFIG_BT_BTU_TASK_STACK_SIZE", 8192) - add_idf_sdkconfig_option("CONFIG_BT_ACL_CONNECTIONS", 9) - add_idf_sdkconfig_option( - "CONFIG_BTDM_CTRL_BLE_MAX_CONN", config[CONF_MAX_CONNECTIONS] - ) + # Note: CONFIG_BT_ACL_CONNECTIONS and CONFIG_BTDM_CTRL_BLE_MAX_CONN are now + # configured in esp32_ble component based on max_connections setting cg.add_define("USE_OTA_STATE_CALLBACK") # To be notified when an OTA update starts cg.add_define("USE_ESP32_BLE_CLIENT") diff --git a/esphome/core/defines.h b/esphome/core/defines.h index d560007e71..468e9af5fb 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -159,6 +159,7 @@ #define BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE 16 #define USE_CAPTIVE_PORTAL #define USE_ESP32_BLE +#define USE_ESP32_BLE_MAX_CONNECTIONS 3 #define USE_ESP32_BLE_CLIENT #define USE_ESP32_BLE_DEVICE #define USE_ESP32_BLE_SERVER