From ad18bbc6445eb035d9d9e7cdc3a5d01fad786333 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Aug 2026 19:54:07 -0500 Subject: [PATCH] [esp32_ble] Migrate the BLE count machinery to the shared slot counter factory (#18059) --- .../components/bluetooth_proxy/__init__.py | 1 + .../bluetooth_proxy/bluetooth_proxy.cpp | 2 - esphome/components/esp32_ble/__init__.py | 83 ++++++------------- .../components/esp32_ble_tracker/__init__.py | 67 +++++++-------- .../components/esp32_ble_tracker/automation.h | 2 + .../esp32_ble_tracker/esp32_ble_tracker.cpp | 2 + .../esp32_ble_tracker/esp32_ble_tracker.h | 13 ++- esphome/core/defines.h | 1 + .../config/esp32_bluetooth_proxy.yaml | 16 ++++ .../config/esp32_tracker_only.yaml | 9 ++ .../ble_device_base/test_slot_counter.py | 52 ++++++++++++ 11 files changed, 149 insertions(+), 99 deletions(-) create mode 100644 tests/component_tests/ble_device_base/config/esp32_bluetooth_proxy.yaml create mode 100644 tests/component_tests/ble_device_base/config/esp32_tracker_only.yaml diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index ad7528c156..4792736dd9 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -87,6 +87,7 @@ async def to_code(config): cg.add(var.set_active(config[CONF_ACTIVE])) await esp32_ble_tracker.register_raw_ble_device(var, config) + await esp32_ble_tracker.register_scanner_state_listener(var, config) # Define max connections for protobuf fixed array connection_count = len(config.get(CONF_CONNECTIONS, [])) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index f1a30cdfa2..dbe9c1e30d 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -29,8 +29,6 @@ void BluetoothProxy::setup() { // Capture the configured scan mode from YAML before any API changes this->configured_scan_active_ = this->parent_->get_scan_active(); - - this->parent_->add_scanner_state_listener(this); } void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) { diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 72cb10caac..935d8b1b7e 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -1,5 +1,4 @@ from collections.abc import Callable, MutableMapping -from dataclasses import dataclass from enum import Enum import logging from typing import Any @@ -32,7 +31,7 @@ from esphome.const import ( CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, ) -from esphome.core import CORE, CoroPriority, TimePeriod, coroutine_with_priority +from esphome.core import CORE, TimePeriod import esphome.final_validate as fv from esphome.types import ConfigType @@ -134,18 +133,21 @@ def _get_required_loggers() -> set[BTLoggers]: return CORE.data.setdefault(ESP32_BLE_REQUIRED_LOGGERS_KEY, set()) -# Dataclass for handler registration counts -@dataclass -class HandlerCounts: - gap_event: int = 0 - gap_scan_event: int = 0 - gattc_event: int = 0 - gatts_event: int = 0 - ble_status_event: int = 0 - - -# Track handler registration counts for StaticVector sizing -_handler_counts = HandlerCounts() +# Handler slot counters sizing the StaticCallbackManager storage in ble.h; +# one request per register_* call below. +_request_gap_event_slot = cg.slot_counter("ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT") +_request_gap_scan_event_slot = cg.slot_counter( + "ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT" +) +_request_gattc_event_slot = cg.slot_counter( + "ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT" +) +_request_gatts_event_slot = cg.slot_counter( + "ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT" +) +_request_ble_status_event_slot = cg.slot_counter( + "ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT" +) def _add_callback( @@ -171,8 +173,8 @@ def _add_callback( def register_gap_event_handler(parent_var: cg.MockObj, handler_var: cg.MockObj) -> None: - """Register a GAP event handler and track the count.""" - _handler_counts.gap_event += 1 + """Register a GAP event handler and request a handler slot.""" + _request_gap_event_slot() _add_callback( parent_var, "add_gap_event_callback", @@ -185,8 +187,8 @@ def register_gap_event_handler(parent_var: cg.MockObj, handler_var: cg.MockObj) def register_gap_scan_event_handler( parent_var: cg.MockObj, handler_var: cg.MockObj ) -> None: - """Register a GAP scan event handler and track the count.""" - _handler_counts.gap_scan_event += 1 + """Register a GAP scan event handler and request a handler slot.""" + _request_gap_scan_event_slot() _add_callback( parent_var, "add_gap_scan_event_callback", @@ -199,8 +201,8 @@ def register_gap_scan_event_handler( def register_gattc_event_handler( parent_var: cg.MockObj, handler_var: cg.MockObj ) -> None: - """Register a GATTc event handler and track the count.""" - _handler_counts.gattc_event += 1 + """Register a GATTc event handler and request a handler slot.""" + _request_gattc_event_slot() _add_callback( parent_var, "add_gattc_event_callback", @@ -213,8 +215,8 @@ def register_gattc_event_handler( def register_gatts_event_handler( parent_var: cg.MockObj, handler_var: cg.MockObj ) -> None: - """Register a GATTs event handler and track the count.""" - _handler_counts.gatts_event += 1 + """Register a GATTs event handler and request a handler slot.""" + _request_gatts_event_slot() _add_callback( parent_var, "add_gatts_event_callback", @@ -227,8 +229,8 @@ def register_gatts_event_handler( def register_ble_status_event_handler( parent_var: cg.MockObj, handler_var: cg.MockObj ) -> None: - """Register a BLE status event handler and track the count.""" - _handler_counts.ble_status_event += 1 + """Register a BLE status event handler and request a handler slot.""" + _request_ble_status_event_slot() _add_callback( parent_var, "add_ble_status_event_callback", @@ -518,36 +520,6 @@ def final_validation(config): FINAL_VALIDATE_SCHEMA = final_validation -# This needs to be run as a job with CoroPriority.FINAL priority so that all components have -# a chance to register their handlers before the counts are added to defines. -@coroutine_with_priority(CoroPriority.FINAL) -async def _add_ble_handler_defines(): - # Add defines for StaticVector sizing based on handler registration counts - # Only define if count > 0 to avoid allocating unnecessary memory - if _handler_counts.gap_event > 0: - cg.add_define( - "ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT", _handler_counts.gap_event - ) - if _handler_counts.gap_scan_event > 0: - cg.add_define( - "ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT", - _handler_counts.gap_scan_event, - ) - if _handler_counts.gattc_event > 0: - cg.add_define( - "ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT", _handler_counts.gattc_event - ) - if _handler_counts.gatts_event > 0: - cg.add_define( - "ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT", _handler_counts.gatts_event - ) - if _handler_counts.ble_status_event > 0: - cg.add_define( - "ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT", - _handler_counts.ble_status_event, - ) - - async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT])) @@ -633,9 +605,6 @@ async def to_code(config): cg.add_define("USE_ESP32_BLE_ADVERTISING") cg.add_define("USE_ESP32_BLE_UUID") - # Schedule the handler defines to be added after all components register - CORE.add_job(_add_ble_handler_defines) - @automation.register_condition("ble.enabled", BLEEnabledCondition, cv.Schema({})) async def ble_enabled_to_code(config, condition_id, template_arg, args): diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 1e0716cb20..102e59bfb2 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -1,6 +1,5 @@ from __future__ import annotations -from dataclasses import dataclass import logging from esphome import automation @@ -56,16 +55,8 @@ class BLEFeatures(StrEnum): ESP_BT_DEVICE = "ESP_BT_DEVICE" -# Dataclass for registration counts -@dataclass -class RegistrationCounts: - listeners: int = 0 - clients: int = 0 - - -# CORE.data keys for state management +# CORE.data key for state management ESP32_BLE_TRACKER_REQUIRED_FEATURES_KEY = "esp32_ble_tracker_required_features" -ESP32_BLE_TRACKER_REGISTRATION_COUNTS_KEY = "esp32_ble_tracker_registration_counts" def _get_required_features() -> set[BLEFeatures]: @@ -73,11 +64,13 @@ def _get_required_features() -> set[BLEFeatures]: return CORE.data.setdefault(ESP32_BLE_TRACKER_REQUIRED_FEATURES_KEY, set()) -def _get_registration_counts() -> RegistrationCounts: - """Get the registration counts from CORE.data.""" - return CORE.data.setdefault( - ESP32_BLE_TRACKER_REGISTRATION_COUNTS_KEY, RegistrationCounts() - ) +# Slot counters sizing the tracker's StaticVector storage; one request per +# registered listener, client, or scanner state listener. +_request_listener_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") +_request_client_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") +_request_scanner_state_listener_slot = cg.slot_counter( + "ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT" +) def register_ble_features(features: set[BLEFeatures]) -> None: @@ -239,17 +232,15 @@ async def to_code(config): ): register_ble_features({BLEFeatures.ESP_BT_DEVICE}) - registration_counts = _get_registration_counts() - for conf in config.get(CONF_ON_BLE_ADVERTISE, []): - registration_counts.listeners += 1 + _request_listener_slot() trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) if CONF_MAC_ADDRESS in conf: addr_list = [it.as_hex for it in conf[CONF_MAC_ADDRESS]] cg.add(trigger.set_addresses(addr_list)) await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf) for conf in config.get(CONF_ON_BLE_SERVICE_DATA_ADVERTISE, []): - registration_counts.listeners += 1 + _request_listener_slot() trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) if len(conf[CONF_SERVICE_UUID]) == len(bt_uuid16_format): cg.add(trigger.set_service_uuid16(as_hex(conf[CONF_SERVICE_UUID]))) @@ -262,7 +253,7 @@ async def to_code(config): cg.add(trigger.set_address(conf[CONF_MAC_ADDRESS].as_hex)) await automation.build_automation(trigger, [(adv_data_t_const_ref, "x")], conf) for conf in config.get(CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE, []): - registration_counts.listeners += 1 + _request_listener_slot() trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) if len(conf[CONF_MANUFACTURER_ID]) == len(bt_uuid16_format): cg.add(trigger.set_manufacturer_uuid16(as_hex(conf[CONF_MANUFACTURER_ID]))) @@ -275,7 +266,7 @@ async def to_code(config): cg.add(trigger.set_address(conf[CONF_MAC_ADDRESS].as_hex)) await automation.build_automation(trigger, [(adv_data_t_const_ref, "x")], conf) for conf in config.get(CONF_ON_SCAN_END, []): - registration_counts.listeners += 1 + _request_listener_slot() trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) @@ -316,18 +307,6 @@ async def _add_ble_features(): cg.add_define("USE_ESP32_BLE_DEVICE") cg.add_define("USE_ESP32_BLE_UUID") - # Add defines for StaticVector sizing based on registration counts - # Only define if count > 0 to avoid allocating unnecessary memory - registration_counts = _get_registration_counts() - if registration_counts.listeners > 0: - cg.add_define( - "ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT", registration_counts.listeners - ) - if registration_counts.clients > 0: - cg.add_define( - "ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT", registration_counts.clients - ) - ESP32_BLE_START_SCAN_ACTION_SCHEMA = cv.Schema( { @@ -380,7 +359,7 @@ async def register_ble_device( var: cg.SafeExpType, config: ConfigType ) -> cg.SafeExpType: register_ble_features({BLEFeatures.ESP_BT_DEVICE}) - _get_registration_counts().listeners += 1 + _request_listener_slot() paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) cg.add(paren.register_listener(var)) return var @@ -388,7 +367,7 @@ async def register_ble_device( async def register_client(var: cg.SafeExpType, config: ConfigType) -> cg.SafeExpType: register_ble_features({BLEFeatures.ESP_BT_DEVICE}) - _get_registration_counts().clients += 1 + _request_client_slot() paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) cg.add(paren.register_client(var)) return var @@ -402,7 +381,7 @@ async def register_raw_ble_device( This does NOT register the ESP_BT_DEVICE feature, meaning ESPBTDevice will not be compiled in if this is the only registration method used. """ - _get_registration_counts().listeners += 1 + _request_listener_slot() paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) cg.add(paren.register_listener(var)) return var @@ -416,7 +395,21 @@ async def register_raw_client( This does NOT register the ESP_BT_DEVICE feature, meaning ESPBTDevice will not be compiled in if this is the only registration method used. """ - _get_registration_counts().clients += 1 + _request_client_slot() paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) cg.add(paren.register_client(var)) return var + + +async def register_scanner_state_listener( + var: cg.SafeExpType, config: ConfigType +) -> cg.SafeExpType: + """Register a listener for scanner state changes. + + The slot request here is what sizes the tracker's listener storage; a + build with no registrations compiles the storage out entirely. + """ + _request_scanner_state_listener_slot() + paren = await cg.get_variable(config[CONF_ESP32_BLE_ID]) + cg.add(paren.add_scanner_state_listener(var)) + return var diff --git a/esphome/components/esp32_ble_tracker/automation.h b/esphome/components/esp32_ble_tracker/automation.h index b653325f56..541b63b2fd 100644 --- a/esphome/components/esp32_ble_tracker/automation.h +++ b/esphome/components/esp32_ble_tracker/automation.h @@ -3,6 +3,8 @@ #include "esphome/core/automation.h" #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include + #ifdef USE_ESP32 namespace esphome::esp32_ble_tracker { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 0c1a98c4be..372af5e89a 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -443,9 +443,11 @@ void ESP32BLETracker::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_i void ESP32BLETracker::set_scanner_state_(ScannerState state) { this->scanner_state_ = state; this->state_version_++; +#ifdef ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT for (auto *listener : this->scanner_state_listeners_) { listener->on_scanner_state(state); } +#endif } void ESP32BLETracker::dump_config() { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index b0357289f1..b2db092eb0 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -8,7 +8,6 @@ #include #include #include -#include #ifdef USE_ESP32 @@ -265,10 +264,15 @@ class ESP32BLETracker final : public Component, void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override; #endif - /// Add a listener for scanner state changes +#ifdef ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT + /// Add a listener for scanner state changes. Only compiled when a consumer + /// requested a slot in codegen: register through + /// esp32_ble_tracker.register_scanner_state_listener() in your component's + /// to_code, which requests the slot and emits this call. void add_scanner_state_listener(BLEScannerStateListener *listener) { this->scanner_state_listeners_.push_back(listener); } +#endif ScannerState get_scanner_state() const { return this->scanner_state_; } protected: @@ -335,7 +339,10 @@ class ESP32BLETracker final : public Component, #ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT StaticVector clients_; #endif - std::vector scanner_state_listeners_; +#ifdef ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT + StaticVector + scanner_state_listeners_; +#endif // Parsed listeners registered through the neutral BLEHub contract (migrated // sensors); dispatched alongside listeners_. #ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 231ba50dd7..731e6188df 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -278,6 +278,7 @@ #define USE_ESP32_BLE_SERVER_ON_DISCONNECT #define ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT 1 #define ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT 1 +#define ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT 1 #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 #define ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT 2 #define ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT 1 diff --git a/tests/component_tests/ble_device_base/config/esp32_bluetooth_proxy.yaml b/tests/component_tests/ble_device_base/config/esp32_bluetooth_proxy.yaml new file mode 100644 index 0000000000..7500f2133b --- /dev/null +++ b/tests/component_tests/ble_device_base/config/esp32_bluetooth_proxy.yaml @@ -0,0 +1,16 @@ +esphome: + name: slotcount-esp32-proxy + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: MySSID + password: password1 + +api: + +bluetooth_proxy: + active: true diff --git a/tests/component_tests/ble_device_base/config/esp32_tracker_only.yaml b/tests/component_tests/ble_device_base/config/esp32_tracker_only.yaml new file mode 100644 index 0000000000..46a76cfec8 --- /dev/null +++ b/tests/component_tests/ble_device_base/config/esp32_tracker_only.yaml @@ -0,0 +1,9 @@ +esphome: + name: slotcount-esp32-tracker + +esp32: + board: esp32dev + framework: + type: esp-idf + +esp32_ble_tracker: diff --git a/tests/component_tests/ble_device_base/test_slot_counter.py b/tests/component_tests/ble_device_base/test_slot_counter.py index b36a5f1a8a..0fa5577a0b 100644 --- a/tests/component_tests/ble_device_base/test_slot_counter.py +++ b/tests/component_tests/ble_device_base/test_slot_counter.py @@ -81,3 +81,55 @@ def test_neutral_listener_count_emitted_when_requested() -> None: ) CORE.flush_tasks() assert get_define_value(ble_device_base.LISTENER_COUNT_DEFINE) == "1" + + +def test_esp32_tracker_handler_counts( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A bare tracker registers its four esp32_ble handlers and nothing else.""" + generate_main(component_config_path("esp32_tracker_only.yaml")) + assert get_define_value("ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT") == "1" + assert get_define_value("ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT") == "1" + assert get_define_value("ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT") == "1" + assert get_define_value("ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT") == "1" + assert get_define_value("ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT") is None + # No consumer subscribed to scanner state, so the storage compiles out. + assert ( + get_define_value("ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT") + is None + ) + assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") is None + assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") is None + + +def test_esp32_bluetooth_proxy_requests_scanner_state_slot( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The proxy requests one scanner state slot, one raw listener slot and a + client slot per connection (three by default with active: true).""" + generate_main(component_config_path("esp32_bluetooth_proxy.yaml")) + assert ( + get_define_value("ESPHOME_ESP32_BLE_TRACKER_SCANNER_STATE_LISTENER_COUNT") + == "1" + ) + assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") == "1" + assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") == "3" + + +def test_counts_reset_between_compiles( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A second compile in the same process starts from zero. + + The module level counters this change removes leaked across compiles in a + long lived host process (dashboard, device-builder), growing the handler + counts by one per compile and oversizing the StaticCallbackManager storage. + """ + generate_main(component_config_path("esp32_tracker_only.yaml")) + assert get_define_value("ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT") == "1" + CORE.reset() + generate_main(component_config_path("esp32_tracker_only.yaml")) + assert get_define_value("ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT") == "1"