diff --git a/esphome/components/ble_client/ble_client.cpp b/esphome/components/ble_client/ble_client.cpp index e00e0fd0b1..daaf65a54d 100644 --- a/esphome/components/ble_client/ble_client.cpp +++ b/esphome/components/ble_client/ble_client.cpp @@ -251,6 +251,9 @@ bool BLEClient::handle_gatt_search_cmpl_(esp_gatt_status_t status) { // and keep the link. Gatt nodes catch the next connection. ESP_LOGW(TAG, "[%s] Service table build failed; gatt nodes skip this connection", this->address_str()); this->status_set_warning(LOG_STR("gatt nodes inactive: service table build failed")); + // A peer whose database never walks must not retry the build at + // advertisement cadence once the legacy nodes drop the link. + this->gatt_backoff_.register_failure(this->address_str()); } else { this->gatt_connected_ = true; auto view = table.view(); diff --git a/esphome/components/ble_client/ble_client_gatt.cpp b/esphome/components/ble_client/ble_client_gatt.cpp index e23190a9ff..63d6d87cdb 100644 --- a/esphome/components/ble_client/ble_client_gatt.cpp +++ b/esphome/components/ble_client/ble_client_gatt.cpp @@ -141,6 +141,12 @@ void BLEClient::on_connection_state(bool connected, uint16_t mtu, int error) { } void BLEClient::on_service_discovery_done(int error) { + if (this->state_ != State::DISCOVERING || this->cancel_requested_) { + // Raced a teardown; the terminal report settles the link. + ESP_LOGD(TAG, "[%s] Discovery completed during teardown, ignoring", this->address_str_); + this->backend_->release_services(); + return; + } if (error != 0) { ESP_LOGW(TAG, "[%s] Service discovery failed, status=%d", this->address_str_, error); this->backoff_.register_failure(this->address_str_); diff --git a/esphome/components/ble_client/ble_write_action.h b/esphome/components/ble_client/ble_write_action.h index 5a5d030e29..2e156927ad 100644 --- a/esphome/components/ble_client/ble_write_action.h +++ b/esphome/components/ble_client/ble_write_action.h @@ -97,6 +97,10 @@ template class BLEClientWriteAction final : public Action } void on_connected(const ble_device_base::GattServiceTable &table) override { + // Resolution comes from this table only; a re-discovery must not keep a + // stale handle. + this->resolved_ = false; + this->char_handle_ = 0; const auto *service = ble_device_base::find_service(table, this->service_uuid_); const auto *chr = service == nullptr ? nullptr : ble_device_base::find_characteristic(table, *service, this->char_uuid_); diff --git a/esphome/components/bluetooth_connection/__init__.py b/esphome/components/bluetooth_connection/__init__.py index d52badb623..c63e2feade 100644 --- a/esphome/components/bluetooth_connection/__init__.py +++ b/esphome/components/bluetooth_connection/__init__.py @@ -183,40 +183,48 @@ def hub_connection_schema(platform: str | None = None) -> cv.Schema: ) +def _charge_esp32_budget(count: int, consumer: str, config: ConfigType) -> None: + from esphome.components import esp32_ble + + esp32_ble.consume_connection_slots(count, consumer)(config) + + +def _charge_rp2_budget(count: int, consumer: str, config: ConfigType) -> None: + rp2040_ble.consume_connection_slots(count, consumer)(config) + + +# Platforms whose BLE stack owns its own connection budget: the claim is +# charged there and that stack's final validation is the one place an +# overcommit is reported (never two messages for one misconfiguration). One +# mapping drives both, so a platform can never be skipped without a charge. +_STACK_BUDGETS: dict[str, Callable[[int, str, ConfigType], None]] = { + PLATFORM_ESP32: _charge_esp32_budget, + PLATFORM_RP2: _charge_rp2_budget, +} + + def consume_gatt_slot( consumer: str, count: int = 1 ) -> Callable[[ConfigType], ConfigType]: """Validator claiming GATT connection slots - the one spelling for every - claimant. Platforms whose BLE stack owns a connection budget (esp32, rp2) - are charged there and their stack's final validation reports an - overcommit; the neutral ledger covers any future backend platform without - one (the cap check in FINAL_VALIDATE_SCHEMA).""" + claimant. The neutral ledger covers backend platforms without a stack + budget (the cap check in FINAL_VALIDATE_SCHEMA).""" def validator(config: ConfigType) -> ConfigType: _get_data().slot_consumers.extend([consumer] * count) - if CORE.is_esp32: - from esphome.components import esp32_ble - - esp32_ble.consume_connection_slots(count, consumer)(config) - elif CORE.target_platform == PLATFORM_RP2: - rp2040_ble.consume_connection_slots(count, consumer)(config) + if (charge := _STACK_BUDGETS.get(CORE.target_platform)) is not None: + charge(count, consumer, config) return config return validator -# Platforms whose BLE stack owns its own connection budget: consume_gatt_slot -# charges it there, and the stack's final validation is the one place an -# overcommit is reported (never two messages for one misconfiguration). -_STACK_BUDGET_PLATFORMS = {PLATFORM_ESP32, PLATFORM_RP2} - - def _validate_slot_totals(config: ConfigType) -> ConfigType: # Skipped in testing mode so grouped component builds can co-exist # (mirrors esp32_ble.validate_connection_slots). if CORE.testing_mode: return config - if CORE.target_platform in _STACK_BUDGET_PLATFORMS: + if CORE.target_platform in _STACK_BUDGETS: return config if (cap := HUB_MAX_CONNECTIONS.get(CORE.target_platform)) is None: # Any backend platform without a stack budget must carry a cap here diff --git a/tests/component_tests/bluetooth_connection/test_slot_ledger.py b/tests/component_tests/bluetooth_connection/test_slot_ledger.py index 209c920ac7..382ae2c78a 100644 --- a/tests/component_tests/bluetooth_connection/test_slot_ledger.py +++ b/tests/component_tests/bluetooth_connection/test_slot_ledger.py @@ -71,7 +71,7 @@ def test_neutral_cap_check_guards_future_hub_platforms( # Both current platforms defer to their stack budgets; pin the message # and boundary of the branch a future budget-less hub platform takes. set_core_config(PlatformFramework.RP2_ARDUINO) - monkeypatch.setattr(bluetooth_connection, "_STACK_BUDGET_PLATFORMS", set()) + monkeypatch.setattr(bluetooth_connection, "_STACK_BUDGETS", {}) bluetooth_connection.consume_gatt_slot("bluetooth_proxy", 3)({}) bluetooth_connection.FINAL_VALIDATE_SCHEMA({}) bluetooth_connection.consume_gatt_slot("ble_client")({})