diff --git a/esphome/components/ble_client/__init__.py b/esphome/components/ble_client/__init__.py index b679dece9a..9e90d3c236 100644 --- a/esphome/components/ble_client/__init__.py +++ b/esphome/components/ble_client/__init__.py @@ -12,7 +12,10 @@ from esphome.components.ble_device_base import ( as_reversed_hex_array, bt_uuid, ) -from esphome.config_helpers import filter_source_files_from_platform +from esphome.config_helpers import ( + filter_source_files_from_platform, + frameworks_for_platforms, +) import esphome.config_validation as cv from esphome.const import ( CONF_CHARACTERISTIC_UUID, @@ -59,12 +62,9 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, # Every framework of every non-esp32 registry platform: a platform # that validates the neutral arm must also compile the neutral engine. - "ble_client_gatt.cpp": { - pf - for pf in PlatformFramework - if pf.value[0] in bluetooth_connection.GATT_CLIENT_PLATFORMS - and pf.value[0] != PLATFORM_ESP32 - }, + "ble_client_gatt.cpp": frameworks_for_platforms( + set(bluetooth_connection.GATT_CLIENT_PLATFORMS) - {PLATFORM_ESP32} + ), } ) diff --git a/esphome/components/ble_client/ble_client_gatt.cpp b/esphome/components/ble_client/ble_client_gatt.cpp index a0409b1b8a..8da53e0364 100644 --- a/esphome/components/ble_client/ble_client_gatt.cpp +++ b/esphome/components/ble_client/ble_client_gatt.cpp @@ -28,8 +28,13 @@ void BLEClient::set_enabled(bool enabled) { this->enabled = enabled; if (!enabled) { this->disconnect(); + return; } - // Enabling does not connect: the next sighting does (legacy parity). + // A re-enable is an explicit "try again": clear the backoff so the next + // sighting connects promptly. Enabling does not itself connect (legacy + // parity). + this->consecutive_failures_ = 0; + this->hold_off_ms_ = 0; } bool BLEClient::parse_device(const ble_device_base::ESPBTDevice &device) { diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index bd22e13bba..341705f41f 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -99,6 +99,12 @@ def _esp32_config_schema() -> cv.All: raise cv.Invalid( "Connections can only be used if the proxy is set to active" ) + # Explicit entries claim slots like the generated ones; dev + # historically skipped this, letting an explicit-connections + # config evade the controller budget. + bluetooth_connection.consume_gatt_slot( + "bluetooth_proxy", len(config[CONF_CONNECTIONS]) + )(config) elif config[CONF_ACTIVE]: connection_slots: int = config[CONF_CONNECTION_SLOTS] bluetooth_connection.consume_gatt_slot("bluetooth_proxy", connection_slots)( diff --git a/tests/component_tests/bluetooth_connection/test_slot_ledger.py b/tests/component_tests/bluetooth_connection/test_slot_ledger.py index 3ba3daeec0..d722a4e165 100644 --- a/tests/component_tests/bluetooth_connection/test_slot_ledger.py +++ b/tests/component_tests/bluetooth_connection/test_slot_ledger.py @@ -9,31 +9,29 @@ from esphome.components import ( bluetooth_connection, bluetooth_proxy, ) -from esphome.const import ( - CONF_MAC_ADDRESS, - KEY_CORE, - KEY_TARGET_PLATFORM, - PLATFORM_RP2, - PlatformFramework, -) +from esphome.const import CONF_MAC_ADDRESS, PlatformFramework from esphome.core import CORE from ..types import SetCoreConfigCallable -def test_gatt_slot_ledger_rejects_overcommit_on_rp2() -> None: +def test_gatt_slot_ledger_rejects_overcommit_on_rp2( + set_core_config: SetCoreConfigCallable, +) -> None: # The cap logic in isolation: two hand charges must trip it. - CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = PLATFORM_RP2 + set_core_config(PlatformFramework.RP2_ARDUINO) bluetooth_connection.consume_gatt_slot("bluetooth_proxy")({}) bluetooth_connection.consume_gatt_slot("ble_client")({}) with pytest.raises(cv.Invalid, match="supports at most 1 GATT client"): bluetooth_connection.FINAL_VALIDATE_SCHEMA({}) -def test_gatt_slot_ledger_skipped_in_testing_mode() -> None: +def test_gatt_slot_ledger_skipped_in_testing_mode( + set_core_config: SetCoreConfigCallable, +) -> None: # Grouped component builds merge fixtures past the cap; the check defers # to testing mode like esp32_ble.validate_connection_slots. - CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = PLATFORM_RP2 + set_core_config(PlatformFramework.RP2_ARDUINO) bluetooth_connection.consume_gatt_slot("bluetooth_proxy")({}) bluetooth_connection.consume_gatt_slot("ble_client")({}) CORE.testing_mode = True diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index 8e8b49b691..02401d9213 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -179,6 +179,16 @@ def test_rp2_rejects_esp32_only_keys_by_name( bluetooth_proxy.CONFIG_SCHEMA({"connections": [{}]}) +def test_esp32_explicit_connections_claim_gatt_slots( + set_core_config: SetCoreConfigCallable, +) -> None: + # Explicit `connections:` entries must charge the slot ledger like the + # generated ones; dev historically let them evade the budget. + set_core_config(PlatformFramework.ESP32_IDF) + bluetooth_proxy.CONFIG_SCHEMA({"active": True, "connections": [{}, {}]}) + assert "bluetooth_proxy" in bluetooth_connection._ledger().consumers + + def test_hub_source_filter_covers_every_hub_platform() -> None: # bluetooth_connection cannot import this module to derive the hub.cpp # framework set, so pin it here: a platform admitted to the proxy but