From 23e5cdb30e197d57541be752642524e4b70a05c2 Mon Sep 17 00:00:00 2001
From: "J. Nick Koston" <nick@koston.org>
Date: Sun, 6 Apr 2025 14:48:12 -1000
Subject: [PATCH] Rework max connections for BLE to avoid exceeding the hard
 limit (#8303)

---
 esphome/components/ble_client/__init__.py     |   5 +-
 .../components/bluetooth_proxy/__init__.py    |  53 +++--
 .../components/esp32_ble_tracker/__init__.py  | 186 +++++++++++++-----
 tests/components/bluetooth_proxy/common.yaml  |   8 +
 .../bluetooth_proxy/test.esp32-ard.yaml       |   8 +
 .../bluetooth_proxy/test.esp32-c3-ard.yaml    |   8 +
 .../bluetooth_proxy/test.esp32-c3-idf.yaml    |   8 +
 .../bluetooth_proxy/test.esp32-idf.yaml       |   8 +
 .../esp32_ble_tracker/test.esp32-ard.yaml     |   3 +
 .../esp32_ble_tracker/test.esp32-c3-ard.yaml  |   3 +
 .../esp32_ble_tracker/test.esp32-c3-idf.yaml  |   3 +
 .../esp32_ble_tracker/test.esp32-idf.yaml     |   3 +
 12 files changed, 224 insertions(+), 72 deletions(-)
 create mode 100644 tests/components/bluetooth_proxy/common.yaml
 create mode 100644 tests/components/bluetooth_proxy/test.esp32-ard.yaml
 create mode 100644 tests/components/bluetooth_proxy/test.esp32-c3-ard.yaml
 create mode 100644 tests/components/bluetooth_proxy/test.esp32-c3-idf.yaml
 create mode 100644 tests/components/bluetooth_proxy/test.esp32-idf.yaml

diff --git a/esphome/components/ble_client/__init__.py b/esphome/components/ble_client/__init__.py
index bc7d517695..37f8ea32b3 100644
--- a/esphome/components/ble_client/__init__.py
+++ b/esphome/components/ble_client/__init__.py
@@ -67,7 +67,7 @@ CONF_AUTO_CONNECT = "auto_connect"
 
 MULTI_CONF = True
 
-CONFIG_SCHEMA = (
+CONFIG_SCHEMA = cv.All(
     cv.Schema(
         {
             cv.GenerateID(): cv.declare_id(BLEClient),
@@ -114,7 +114,8 @@ CONFIG_SCHEMA = (
         }
     )
     .extend(cv.COMPONENT_SCHEMA)
-    .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
+    .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA),
+    esp32_ble_tracker.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 edfea50473..04ac9116c7 100644
--- a/esphome/components/bluetooth_proxy/__init__.py
+++ b/esphome/components/bluetooth_proxy/__init__.py
@@ -8,9 +8,10 @@ AUTO_LOAD = ["esp32_ble_client", "esp32_ble_tracker"]
 DEPENDENCIES = ["api", "esp32"]
 CODEOWNERS = ["@jesserockz"]
 
+CONF_CONNECTION_SLOTS = "connection_slots"
 CONF_CACHE_SERVICES = "cache_services"
 CONF_CONNECTIONS = "connections"
-MAX_CONNECTIONS = 3
+DEFAULT_CONNECTION_SLOTS = 3
 
 bluetooth_proxy_ns = cg.esphome_ns.namespace("bluetooth_proxy")
 
@@ -35,28 +36,42 @@ def validate_connections(config):
                 "Connections can only be used if the proxy is set to active"
             )
     elif config[CONF_ACTIVE]:
-        conf = config.copy()
-        conf[CONF_CONNECTIONS] = [CONNECTION_SCHEMA({}) for _ in range(MAX_CONNECTIONS)]
-        return conf
+        connection_slots: int = config[CONF_CONNECTION_SLOTS]
+        esp32_ble_tracker.consume_connection_slots(connection_slots, "bluetooth_proxy")(
+            config
+        )
+        return {
+            **config,
+            CONF_CONNECTIONS: [CONNECTION_SCHEMA({}) for _ in range(connection_slots)],
+        }
     return config
 
 
 CONFIG_SCHEMA = cv.All(
-    cv.Schema(
-        {
-            cv.GenerateID(): cv.declare_id(BluetoothProxy),
-            cv.Optional(CONF_ACTIVE, default=False): cv.boolean,
-            cv.SplitDefault(CONF_CACHE_SERVICES, esp32_idf=True): cv.All(
-                cv.only_with_esp_idf, cv.boolean
-            ),
-            cv.Optional(CONF_CONNECTIONS): cv.All(
-                cv.ensure_list(CONNECTION_SCHEMA),
-                cv.Length(min=1, max=MAX_CONNECTIONS),
-            ),
-        }
-    )
-    .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
-    .extend(cv.COMPONENT_SCHEMA),
+    (
+        cv.Schema(
+            {
+                cv.GenerateID(): cv.declare_id(BluetoothProxy),
+                cv.Optional(CONF_ACTIVE, default=False): cv.boolean,
+                cv.SplitDefault(CONF_CACHE_SERVICES, esp32_idf=True): cv.All(
+                    cv.only_with_esp_idf, cv.boolean
+                ),
+                cv.Optional(
+                    CONF_CONNECTION_SLOTS,
+                    default=DEFAULT_CONNECTION_SLOTS,
+                ): cv.All(
+                    cv.positive_int,
+                    cv.Range(min=1, max=esp32_ble_tracker.max_connections()),
+                ),
+                cv.Optional(CONF_CONNECTIONS): cv.All(
+                    cv.ensure_list(CONNECTION_SCHEMA),
+                    cv.Length(min=1, max=esp32_ble_tracker.max_connections()),
+                ),
+            }
+        )
+        .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
+        .extend(cv.COMPONENT_SCHEMA)
+    ),
     validate_connections,
 )
 
diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py
index 425ce54b9f..68be2cbbe9 100644
--- a/esphome/components/esp32_ble_tracker/__init__.py
+++ b/esphome/components/esp32_ble_tracker/__init__.py
@@ -1,3 +1,9 @@
+from __future__ import annotations
+
+from collections.abc import MutableMapping
+import logging
+from typing import Any, Callable
+
 from esphome import automation
 import esphome.codegen as cg
 from esphome.components import esp32_ble
@@ -29,11 +35,21 @@ from esphome.core import CORE
 AUTO_LOAD = ["esp32_ble"]
 DEPENDENCIES = ["esp32"]
 
+KEY_ESP32_BLE_TRACKER = "esp32_ble_tracker"
+KEY_USED_CONNECTION_SLOTS = "used_connection_slots"
+
+CONF_MAX_CONNECTIONS = "max_connections"
 CONF_ESP32_BLE_ID = "esp32_ble_id"
 CONF_SCAN_PARAMETERS = "scan_parameters"
 CONF_WINDOW = "window"
 CONF_CONTINUOUS = "continuous"
 CONF_ON_SCAN_END = "on_scan_end"
+
+DEFAULT_MAX_CONNECTIONS = 3
+IDF_MAX_CONNECTIONS = 9
+
+_LOGGER = logging.getLogger(__name__)
+
 esp32_ble_tracker_ns = cg.esphome_ns.namespace("esp32_ble_tracker")
 ESP32BLETracker = esp32_ble_tracker_ns.class_(
     "ESP32BLETracker",
@@ -112,61 +128,126 @@ def as_reversed_hex_array(value):
     )
 
 
-CONFIG_SCHEMA = cv.Schema(
-    {
-        cv.GenerateID(): cv.declare_id(ESP32BLETracker),
-        cv.GenerateID(esp32_ble.CONF_BLE_ID): cv.use_id(esp32_ble.ESP32BLE),
-        cv.Optional(CONF_SCAN_PARAMETERS, default={}): cv.All(
-            cv.Schema(
+def max_connections() -> int:
+    return IDF_MAX_CONNECTIONS if CORE.using_esp_idf else DEFAULT_MAX_CONNECTIONS
+
+
+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(
+        {
+            cv.GenerateID(): cv.declare_id(ESP32BLETracker),
+            cv.GenerateID(esp32_ble.CONF_BLE_ID): cv.use_id(esp32_ble.ESP32BLE),
+            cv.Optional(CONF_MAX_CONNECTIONS, default=DEFAULT_MAX_CONNECTIONS): cv.All(
+                cv.positive_int, cv.Range(min=0, max=max_connections())
+            ),
+            cv.Optional(CONF_SCAN_PARAMETERS, default={}): cv.All(
+                cv.Schema(
+                    {
+                        cv.Optional(
+                            CONF_DURATION, default="5min"
+                        ): cv.positive_time_period_seconds,
+                        cv.Optional(
+                            CONF_INTERVAL, default="320ms"
+                        ): cv.positive_time_period_milliseconds,
+                        cv.Optional(
+                            CONF_WINDOW, default="30ms"
+                        ): cv.positive_time_period_milliseconds,
+                        cv.Optional(CONF_ACTIVE, default=True): cv.boolean,
+                        cv.Optional(CONF_CONTINUOUS, default=True): cv.boolean,
+                    }
+                ),
+                validate_scan_parameters,
+            ),
+            cv.Optional(CONF_ON_BLE_ADVERTISE): automation.validate_automation(
                 {
-                    cv.Optional(
-                        CONF_DURATION, default="5min"
-                    ): cv.positive_time_period_seconds,
-                    cv.Optional(
-                        CONF_INTERVAL, default="320ms"
-                    ): cv.positive_time_period_milliseconds,
-                    cv.Optional(
-                        CONF_WINDOW, default="30ms"
-                    ): cv.positive_time_period_milliseconds,
-                    cv.Optional(CONF_ACTIVE, default=True): cv.boolean,
-                    cv.Optional(CONF_CONTINUOUS, default=True): cv.boolean,
+                    cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
+                        ESPBTAdvertiseTrigger
+                    ),
+                    cv.Optional(CONF_MAC_ADDRESS): cv.ensure_list(cv.mac_address),
                 }
             ),
-            validate_scan_parameters,
-        ),
-        cv.Optional(CONF_ON_BLE_ADVERTISE): automation.validate_automation(
-            {
-                cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ESPBTAdvertiseTrigger),
-                cv.Optional(CONF_MAC_ADDRESS): cv.ensure_list(cv.mac_address),
-            }
-        ),
-        cv.Optional(CONF_ON_BLE_SERVICE_DATA_ADVERTISE): automation.validate_automation(
-            {
-                cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
-                    BLEServiceDataAdvertiseTrigger
-                ),
-                cv.Optional(CONF_MAC_ADDRESS): cv.mac_address,
-                cv.Required(CONF_SERVICE_UUID): bt_uuid,
-            }
-        ),
-        cv.Optional(
-            CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE
-        ): automation.validate_automation(
-            {
-                cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
-                    BLEManufacturerDataAdvertiseTrigger
-                ),
-                cv.Optional(CONF_MAC_ADDRESS): cv.mac_address,
-                cv.Required(CONF_MANUFACTURER_ID): bt_uuid,
-            }
-        ),
-        cv.Optional(CONF_ON_SCAN_END): automation.validate_automation(
-            {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(BLEEndOfScanTrigger)}
-        ),
-    }
-).extend(cv.COMPONENT_SCHEMA)
+            cv.Optional(
+                CONF_ON_BLE_SERVICE_DATA_ADVERTISE
+            ): automation.validate_automation(
+                {
+                    cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
+                        BLEServiceDataAdvertiseTrigger
+                    ),
+                    cv.Optional(CONF_MAC_ADDRESS): cv.mac_address,
+                    cv.Required(CONF_SERVICE_UUID): bt_uuid,
+                }
+            ),
+            cv.Optional(
+                CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE
+            ): automation.validate_automation(
+                {
+                    cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
+                        BLEManufacturerDataAdvertiseTrigger
+                    ),
+                    cv.Optional(CONF_MAC_ADDRESS): cv.mac_address,
+                    cv.Required(CONF_MANUFACTURER_ID): bt_uuid,
+                }
+            ),
+            cv.Optional(CONF_ON_SCAN_END): automation.validate_automation(
+                {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(BLEEndOfScanTrigger)}
+            ),
+        }
+    ).extend(cv.COMPONENT_SCHEMA),
+)
 
-FINAL_VALIDATE_SCHEMA = esp32_ble.validate_variant
+
+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)
+    hard_limit = max_connections()
+
+    if used_slots < hard_limit:
+        _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] < hard_limit:
+        msg += f" or increase {CONF_MAX_CONNECTIONS}` to {used_slots}"
+    msg += f" to stay under the {hard_limit} connection slot(s) limit."
+    raise cv.Invalid(msg)
+
+
+FINAL_VALIDATE_SCHEMA = cv.All(
+    validate_remaining_connections, esp32_ble.validate_variant
+)
 
 ESP_BLE_DEVICE_SCHEMA = cv.Schema(
     {
@@ -238,6 +319,9 @@ async def to_code(config):
         else:
             add_idf_sdkconfig_option("CONFIG_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]
+        )
         # CONFIG_BT_GATTC_NOTIF_REG_MAX controls the number of
         # max notifications in 5.x, setting CONFIG_BT_ACL_CONNECTIONS
         # is enough in 4.x
diff --git a/tests/components/bluetooth_proxy/common.yaml b/tests/components/bluetooth_proxy/common.yaml
new file mode 100644
index 0000000000..5e84f4a678
--- /dev/null
+++ b/tests/components/bluetooth_proxy/common.yaml
@@ -0,0 +1,8 @@
+wifi:
+  ssid: MySSID
+  password: password1
+
+ota:
+  - platform: esphome
+
+api:
diff --git a/tests/components/bluetooth_proxy/test.esp32-ard.yaml b/tests/components/bluetooth_proxy/test.esp32-ard.yaml
new file mode 100644
index 0000000000..bf01b65b6f
--- /dev/null
+++ b/tests/components/bluetooth_proxy/test.esp32-ard.yaml
@@ -0,0 +1,8 @@
+<<: !include common.yaml
+
+esp32_ble_tracker:
+  max_connections: 3
+
+bluetooth_proxy:
+  active: true
+  connection_slots: 2
diff --git a/tests/components/bluetooth_proxy/test.esp32-c3-ard.yaml b/tests/components/bluetooth_proxy/test.esp32-c3-ard.yaml
new file mode 100644
index 0000000000..bf01b65b6f
--- /dev/null
+++ b/tests/components/bluetooth_proxy/test.esp32-c3-ard.yaml
@@ -0,0 +1,8 @@
+<<: !include common.yaml
+
+esp32_ble_tracker:
+  max_connections: 3
+
+bluetooth_proxy:
+  active: true
+  connection_slots: 2
diff --git a/tests/components/bluetooth_proxy/test.esp32-c3-idf.yaml b/tests/components/bluetooth_proxy/test.esp32-c3-idf.yaml
new file mode 100644
index 0000000000..6c27bd35d0
--- /dev/null
+++ b/tests/components/bluetooth_proxy/test.esp32-c3-idf.yaml
@@ -0,0 +1,8 @@
+<<: !include common.yaml
+
+esp32_ble_tracker:
+  max_connections: 9
+
+bluetooth_proxy:
+  active: true
+  connection_slots: 9
diff --git a/tests/components/bluetooth_proxy/test.esp32-idf.yaml b/tests/components/bluetooth_proxy/test.esp32-idf.yaml
new file mode 100644
index 0000000000..6c27bd35d0
--- /dev/null
+++ b/tests/components/bluetooth_proxy/test.esp32-idf.yaml
@@ -0,0 +1,8 @@
+<<: !include common.yaml
+
+esp32_ble_tracker:
+  max_connections: 9
+
+bluetooth_proxy:
+  active: true
+  connection_slots: 9
diff --git a/tests/components/esp32_ble_tracker/test.esp32-ard.yaml b/tests/components/esp32_ble_tracker/test.esp32-ard.yaml
index dade44d145..070fffd68b 100644
--- a/tests/components/esp32_ble_tracker/test.esp32-ard.yaml
+++ b/tests/components/esp32_ble_tracker/test.esp32-ard.yaml
@@ -1 +1,4 @@
 <<: !include common.yaml
+
+esp32_ble_tracker:
+  max_connections: 3
diff --git a/tests/components/esp32_ble_tracker/test.esp32-c3-ard.yaml b/tests/components/esp32_ble_tracker/test.esp32-c3-ard.yaml
index dade44d145..070fffd68b 100644
--- a/tests/components/esp32_ble_tracker/test.esp32-c3-ard.yaml
+++ b/tests/components/esp32_ble_tracker/test.esp32-c3-ard.yaml
@@ -1 +1,4 @@
 <<: !include common.yaml
+
+esp32_ble_tracker:
+  max_connections: 3
diff --git a/tests/components/esp32_ble_tracker/test.esp32-c3-idf.yaml b/tests/components/esp32_ble_tracker/test.esp32-c3-idf.yaml
index dade44d145..5e09f5020e 100644
--- a/tests/components/esp32_ble_tracker/test.esp32-c3-idf.yaml
+++ b/tests/components/esp32_ble_tracker/test.esp32-c3-idf.yaml
@@ -1 +1,4 @@
 <<: !include common.yaml
+
+esp32_ble_tracker:
+  max_connections: 9
diff --git a/tests/components/esp32_ble_tracker/test.esp32-idf.yaml b/tests/components/esp32_ble_tracker/test.esp32-idf.yaml
index dade44d145..5e09f5020e 100644
--- a/tests/components/esp32_ble_tracker/test.esp32-idf.yaml
+++ b/tests/components/esp32_ble_tracker/test.esp32-idf.yaml
@@ -1 +1,4 @@
 <<: !include common.yaml
+
+esp32_ble_tracker:
+  max_connections: 9