diff --git a/esphome/components/ble_client/ble_client_gatt.cpp b/esphome/components/ble_client/ble_client_gatt.cpp index 8da53e0364..c5744b3745 100644 --- a/esphome/components/ble_client/ble_client_gatt.cpp +++ b/esphome/components/ble_client/ble_client_gatt.cpp @@ -42,6 +42,7 @@ bool BLEClient::parse_device(const ble_device_base::ESPBTDevice &device) { return false; // The sighting is the source of truth for the address type. this->address_type_ = device.get_address_type(); + this->address_type_known_ = true; if (!this->enabled || !this->auto_connect_ || this->state_ != State::IDLE) return true; if (this->hold_off_ms_ != 0 && millis() - this->hold_off_start_ < this->hold_off_ms_) @@ -56,6 +57,11 @@ void BLEClient::connect() { // An absent peer can inhibit scanning for the backend's full connect // timeout, so this is worth a breadcrumb - but it is a supported action. ESP_LOGI(TAG, "[%s] Connecting on request", this->address_str_); + if (!this->address_type_known_) { + // Legacy parity: without a sighting the address type defaults to + // public, which never matches a random-static peer. + ESP_LOGW(TAG, "[%s] No sighting yet; assuming a public address type", this->address_str_); + } this->attempt_connect_(); } @@ -81,9 +87,10 @@ void BLEClient::disconnect() { this->cancel_requested_ = true; int err = this->backend_->gatt_disconnect(); if (err != 0) { - // Refused synchronously (states diverged; the backend is already down): - // settle through the normal path, carrying the real code so the log is - // distinguishable from a clean teardown. + // Refused synchronously: the backend and the client disagreed about the + // link state. Worth a warning of its own - the settle below then runs + // the same deliberate-cancel path as a clean teardown. + ESP_LOGW(TAG, "[%s] Disconnect refused, err=%d; settling locally", this->address_str_, err); this->on_connection_state(false, 0, err); } } @@ -149,6 +156,9 @@ void BLEClient::on_service_discovery_done(int error) { auto table = this->backend_->get_service_table(); for (auto *node : this->nodes_) { node->on_connected(table); + if (this->state_ != State::CONNECTED) { + return; // A node tore the link down mid-fan-out; the teardown settled. + } } this->backend_->release_services(); this->consecutive_failures_ = 0; diff --git a/esphome/components/ble_client/ble_client_gatt.h b/esphome/components/ble_client/ble_client_gatt.h index 407692454e..3291a8e2ef 100644 --- a/esphome/components/ble_client/ble_client_gatt.h +++ b/esphome/components/ble_client/ble_client_gatt.h @@ -73,8 +73,8 @@ class BLEClient : public Component, bool connected() const { return this->state_ == State::CONNECTED; } bool idle() const { return this->state_ == State::IDLE; } - /// Action-initiated connect (no sighting needed; uses the last captured or - /// configured address type). No-op unless idle. + /// Action-initiated connect (no sighting needed; uses the last captured + /// address type, public until a sighting arrives). No-op unless idle. void connect(); void disconnect(); @@ -147,6 +147,8 @@ class BLEClient : public Component, // Group 6: 1-byte types State state_{State::IDLE}; uint8_t address_type_{0}; // BLE_ADDR_TYPE_*, captured from the sighting + // Distinguishes a captured public type from the never-sighted default. + bool address_type_known_{false}; bool auto_connect_{true}; // A user-initiated teardown in flight; its failure report is not a // connect failure and must not feed the backoff. diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index 02401d9213..e3e05cc83c 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -186,7 +186,9 @@ def test_esp32_explicit_connections_claim_gatt_slots( # 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 + # Exact match (one entry per slot): catches a missed charge and a + # double charge alike. + assert bluetooth_connection._ledger().consumers == ["bluetooth_proxy"] * 2 def test_hub_source_filter_covers_every_hub_platform() -> None: diff --git a/tests/components/bluetooth_connection/__init__.py b/tests/components/bluetooth_connection/__init__.py index eb6e174c0c..40810951ee 100644 --- a/tests/components/bluetooth_connection/__init__.py +++ b/tests/components/bluetooth_connection/__init__.py @@ -3,8 +3,9 @@ from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: - # close_service_batch compiles only under BLUETOOTH_CONNECTION_HAS_GATT; - # emit the backend define so the host build exercises it. + # close_service_batch compiles only under BLUETOOTH_CONNECTION_SERVES_PROXY + # (= USE_BLE_GATT_CLIENT && USE_BLUETOOTH_PROXY); emit both so the host + # build exercises it. async def to_code_testing(config): # These defines are global to the merged host test binary; safe # because no co-compiled test observes them.