diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index fa66448867..ae03003713 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -163,8 +163,9 @@ _request_gatt_connection_slot = cg.slot_counter(GATT_CLIENT_COUNT_DEFINE) def request_gatt_client() -> None: """Compile in the neutral GATT client contract (ble_gatt_client.h) and - claim one connection slot. Called by bluetooth_proxy once per connection - it instantiates on a hub platform.""" + claim one compiled-in client slot (sizes ESPHOME_BLE_GATT_CLIENT_COUNT; + distinct from the proxy's validated connection budget). Called by + bluetooth_connection.new_gatt_backend() once per backend instance.""" cg.add_define("USE_BLE_GATT_CLIENT") _request_gatt_connection_slot() diff --git a/esphome/components/ble_device_base/ble_client_state.h b/esphome/components/ble_device_base/ble_client_state.h index b0c91397fc..92754b70b4 100644 --- a/esphome/components/ble_device_base/ble_client_state.h +++ b/esphome/components/ble_device_base/ble_client_state.h @@ -17,6 +17,16 @@ namespace esphome::ble_device_base { /// client backend. static constexpr int GATT_ERR_NOT_CONNECTED = -1; static constexpr int GATT_ERR_NO_MEMORY = -2; +/// ATT "Unlikely Error" (spec 0x0E): a client-side internal inconsistency, +/// e.g. a service table failing its own bounds checks. +static constexpr int GATT_ERR_UNLIKELY = 0x0E; + +/// Safety net shared by every GATT backend: force IDLE when the stack never +/// delivers its disconnect completion. +static constexpr uint32_t GATT_DISCONNECT_TIMEOUT_MS = 10000; + +/// ATT MTU before negotiation completes (Bluetooth spec default). +static constexpr uint16_t DEFAULT_ATT_MTU = 23; // Preferred connection parameters shared by every platform's GATT client so // the backends cannot drift (units: interval 1.25 ms, timeout 10 ms; latency diff --git a/esphome/components/ble_device_base/ble_gatt_client.h b/esphome/components/ble_device_base/ble_gatt_client.h index 74548f578f..b95fb6878a 100644 --- a/esphome/components/ble_device_base/ble_gatt_client.h +++ b/esphome/components/ble_device_base/ble_gatt_client.h @@ -5,10 +5,11 @@ // Exactly one GATT backend exists per build, so BLEGattConnection is a // compile-time alias (bluetooth_connection_gatt_backend.h), not an abstract // interface. -// The hub BluetoothConnection wrapper drives it and receives completions -// through its event-sink methods, which the backend calls directly. All sink -// calls are delivered on the ESPHome main loop; borrowed data pointers are -// valid only for the duration of the call. +// A consumer - the hub wrapper streaming the raw database, or a direct +// consumer owning a dedicated backend and resolving handles by UUID - +// drives it and receives completions through the GattClientListener +// interface. All listener calls are delivered on the ESPHome main loop; +// borrowed data pointers are valid only for the duration of the call. // // Error domain (plain int, forwarded to the API without translation): // 0 success @@ -78,27 +79,55 @@ struct GattServiceTable { uint16_t descriptor_count{0}; }; +/// The event surface a backend delivers completions through - the one place +/// with genuine runtime polymorphism (several consumer types, one non-virtual +/// backend). Methods default to no-ops; consumers override what they consume. +/// No destructor: components are never destroyed. +/// on_connection_state carries the negotiated MTU and an HCI status/reason. +/// Codegen wires the listener before setup(), so backends skip null checks. +class GattClientListener { + public: + virtual void on_connection_state(bool connected, uint16_t mtu, int error) {} + virtual void on_service_discovery_done(int error) {} + virtual void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {} + virtual void on_write_result(uint16_t handle, int error) {} + virtual void on_notify_state(uint16_t handle, bool enabled, int error) {} + virtual void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {} + virtual void on_pairing_result(int status) {} +}; + // The BLEGattConnection op surface, asserted where the alias binds // (bluetooth_connection_gatt_backend.h). Operations return 0 when accepted (completion arrives -// through the sink) or a synchronous error (busy, not connected, stack +// through the listener) or a synchronous error (busy, not connected, stack // rejection); one operation may be outstanding at a time. Semantics beyond // the signatures: // - connect: addr_type is a BLE_ADDR_TYPE_* constant (ble_device.h). -// - disconnect: also cancels a connect in progress. +// - gatt_disconnect: also cancels a connect in progress (named to coexist +// with a platform stack's own void disconnect() on one backend class). +// Nonzero means nothing to tear down and no completion will follow; an +// accepted teardown (0) always reaches a terminal on_connection_state. +// - cancel_gatt_disconnect: true cancels a scheduled teardown that has not +// started closing - the in-flight connect resumes and completes normally. +// False once the teardown owns the link (or nothing was scheduled). // - notify_characteristic: local registration only; the CCCD write is the // API client's responsibility (a plain write_descriptor). // - get_service_table/release_services: backend-owned transient storage, -// released after streaming (release is idempotent). -// - completions: connect and disconnect land in on_connection_state, +// released after streaming (release is idempotent). A backend may +// additionally provide its own service streamer (stream_service_batch on +// the concrete type, detected by the consumer at compile time) for +// arbitrary-size databases; the table then materializes only for consumers +// that ask for it. +// - completions: connect and gatt_disconnect land in on_connection_state, // discover_services in on_service_discovery_done, pair in // on_pairing_result, reads in on_read_result, notify_characteristic in -// on_notify_state, characteristic writes with response and descriptor -// writes in on_write_result. -template -concept BLEGattConnectionContract = requires(T conn, Sink *sink, const uint8_t *data) { - conn.set_listener(sink); +// on_notify_state, characteristic writes (with and without response) and +// descriptor writes in on_write_result. +template +concept BLEGattConnectionContract = requires(T conn, GattClientListener *listener, const uint8_t *data) { + conn.set_listener(listener); { conn.connect(uint64_t{}, uint8_t{}) } -> std::same_as; - { conn.disconnect() } -> std::same_as; + { conn.gatt_disconnect() } -> std::same_as; + { conn.cancel_gatt_disconnect() } -> std::same_as; { conn.discover_services() } -> std::same_as; { conn.read_characteristic(uint16_t{}) } -> std::same_as; { conn.write_characteristic(uint16_t{}, data, uint16_t{}, true) } -> std::same_as; @@ -109,22 +138,9 @@ concept BLEGattConnectionContract = requires(T conn, Sink *sink, const uint8_t * { conn.update_connection_params(uint16_t{}, uint16_t{}, uint16_t{}, uint16_t{}) } -> std::same_as; { conn.get_service_table() } -> std::same_as; { conn.release_services() } -> std::same_as; -}; - -// The event sink the backend calls directly (the hub BluetoothConnection -// wrapper), asserted where the wrapper is defined: on_connection_state -// carries the negotiated MTU and an HCI status/disconnect reason. The -// requirements check call validity, not exact parameter types; keep sink -// parameters at the documented widths (uint16_t handles and lengths). -template -concept GattClientEventSinkContract = requires(S sink, const uint8_t *data) { - { sink.on_connection_state(true, uint16_t{}, int{}) } -> std::same_as; - { sink.on_service_discovery_done(int{}) } -> std::same_as; - { sink.on_read_result(uint16_t{}, data, uint16_t{}, int{}) } -> std::same_as; - { sink.on_write_result(uint16_t{}, int{}) } -> std::same_as; - { sink.on_notify_state(uint16_t{}, true, int{}) } -> std::same_as; - { sink.on_notify_data(uint16_t{}, data, uint16_t{}) } -> std::same_as; - { sink.on_pairing_result(int{}) } -> std::same_as; + // Connection-type hint for backends that tune parameters by it; others + // carry an inline no-op. + { conn.set_connection_type(ConnectionType{}) } -> std::same_as; }; } // namespace esphome::ble_device_base diff --git a/esphome/components/bluetooth_connection/__init__.py b/esphome/components/bluetooth_connection/__init__.py index 1dc1969a6a..8c218c0954 100644 --- a/esphome/components/bluetooth_connection/__init__.py +++ b/esphome/components/bluetooth_connection/__init__.py @@ -1,23 +1,34 @@ -"""Per-platform GATT connection backends the Bluetooth proxy drives. +"""Per-platform GATT connection backends and the helpers to embed one. -Backends: esp32 Bluedroid, rp2 BTstack. Auto-loaded by bluetooth_proxy, no -user-facing configuration; the proxy's codegen declares and registers the -connection instances. +Backends: esp32 Bluedroid, rp2 BTstack. No user-facing configuration; the +Bluetooth proxy's codegen declares and registers the backend instances +through gatt_client_schema()/hub_connection_schema() + new_gatt_backend(). """ -import functools +from collections.abc import Awaitable, Callable +from dataclasses import dataclass import esphome.codegen as cg -from esphome.config_helpers import filter_source_files_from_platform -from esphome.const import PLATFORM_RP2, PlatformFramework +from esphome.config_helpers import ( + filter_source_files_from_platform, + frameworks_for_platforms, +) +import esphome.config_validation as cv +from esphome.const import PLATFORM_ESP32, PLATFORM_RP2, PlatformFramework from esphome.core import CORE +from esphome.types import ConfigType def AUTO_LOAD() -> list[str]: - """The esp32 connection header includes esp32_ble_client, so the closure - must be self-satisfying; no target platform (tooling) gets the union.""" - if CORE.is_esp32 or CORE.target_platform is None: - return ["ble_device_base", "esp32_ble_client"] + """ble_device_base plus the platform BLE stack the build's backend + registers with (the Bluedroid header includes the tracker's), so + consumers need not know. The platform-less arm serves manifest tooling.""" + if CORE.is_esp32: + return ["ble_device_base", "esp32_ble_tracker"] + if CORE.is_rp2: + return ["ble_device_base", "rp2040_ble"] + if CORE.target_platform is None: + return ["ble_device_base", "esp32_ble_tracker", "rp2040_ble"] return ["ble_device_base"] @@ -29,39 +40,134 @@ bluetooth_connection_ns = cg.esphome_ns.namespace("bluetooth_connection") # raising this needs an upstream change (the layer itself supports N). RP2_MAX_CONNECTIONS = 1 -# Hub platforms with a GATT backend, mapped to their slot limit — the single -# registry of which hub platforms run the connection-capable proxy. +# Slot limits for the hub platforms running the connection-capable proxy; +# the backend registry itself is _PLATFORM_BACKENDS below. HUB_MAX_CONNECTIONS: dict[str, int] = {PLATFORM_RP2: RP2_MAX_CONNECTIONS} -# The hub-platform wrapper and the rp2 BTstack backend codegen classes. +# The hub-platform wrapper and the backend codegen classes. HubBluetoothConnection = bluetooth_connection_ns.class_("BluetoothConnection") RP2GattClient = bluetooth_connection_ns.class_("RP2GattClient", cg.Component) +BluedroidGattClient = bluetooth_connection_ns.class_( + "BluedroidGattClient", cg.Component +) + +CONF_BACKEND_ID = "backend_id" -@functools.cache -def esp32_connection_class() -> cg.MockObjClass: - """Lazy: importing esp32_ble_client registers esp32-only automations as - an import side effect, which must not leak into other platforms.""" - from esphome.components import esp32_ble_client +def _esp32_schema_fragment() -> cv.Schema: + from esphome.components import esp32_ble_tracker - return bluetooth_connection_ns.class_( - "BluetoothConnection", esp32_ble_client.BLEClientBase + return esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA + + +def _rp2_schema_fragment() -> cv.Schema: + from esphome.components import rp2040_ble + + return cv.Schema( + {cv.GenerateID(rp2040_ble.CONF_RP2040_BLE_ID): cv.use_id(rp2040_ble.RP2040BLE)} ) -FILTER_SOURCE_FILES = filter_source_files_from_platform( - { - "bluetooth_connection_esp32.cpp": { - PlatformFramework.ESP32_ARDUINO, - PlatformFramework.ESP32_IDF, - }, - # Every hub platform the proxy admits (the file compiles empty where - # USE_BLE_GATT_CLIENT is not defined), so a platform gaining a backend - # cannot hit a missing-symbol trap here. - "bluetooth_connection_hub.cpp": { - PlatformFramework.RP2_ARDUINO, - PlatformFramework.LN882X_ARDUINO, - }, - "bluetooth_connection_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, - } -) +async def _esp32_register(backend: cg.MockObj, config: ConfigType) -> None: + from esphome.components import esp32_ble_tracker + + # The tracker's promote loop owns connect timing; the backend registers + # as a raw client (it is the tracker's ESPBTClient). + await esp32_ble_tracker.register_raw_client(backend, config) + + +async def _rp2_register(backend: cg.MockObj, config: ConfigType) -> None: + from esphome.components import rp2040_ble + + await cg.register_parented(backend, config[rp2040_ble.CONF_RP2040_BLE_ID]) + + +@dataclass(frozen=True) +class _PlatformBackend: + """One platform's backend: codegen class, extra schema keys (lazy so the + platform stack is only imported when targeted), and stack registration.""" + + backend_class: cg.MockObjClass + schema_fragment: Callable[[], cv.Schema] + register: Callable[[cg.MockObj, ConfigType], Awaitable[None]] + + +# The single registry of platforms with a GATT client backend; a platform +# missing here fails loudly everywhere instead of falling into another +# platform's arm. +_PLATFORM_BACKENDS: dict[str, _PlatformBackend] = { + PLATFORM_ESP32: _PlatformBackend( + BluedroidGattClient, _esp32_schema_fragment, _esp32_register + ), + PLATFORM_RP2: _PlatformBackend(RP2GattClient, _rp2_schema_fragment, _rp2_register), +} + + +def _backend_entry(platform: str | None = None) -> _PlatformBackend: + key = platform if platform is not None else CORE.target_platform + if (entry := _PLATFORM_BACKENDS.get(key)) is None: + raise cv.Invalid(f"no GATT client backend is registered for {key}") + return entry + + +def gatt_client_schema(platform: str | None = None) -> cv.Schema: + """Schema fragment for one GATT backend instance: its generated id plus + the platform-stack reference new_gatt_backend() resolves. + + Defaults to the platform being validated; pass `platform` explicitly when + building a schema outside validation (the language-schema dumper calls + per-platform builders under arbitrary CORE platforms). + """ + entry = _backend_entry(platform) + return entry.schema_fragment().extend( + {cv.GenerateID(CONF_BACKEND_ID): cv.declare_id(entry.backend_class)} + ) + + +def hub_connection_schema(platform: str | None = None) -> cv.Schema: + """Per-slot schema for the proxy's connection wrappers: the wrapper id on + top of the backend fragment, plus the component keys (setup_priority and + friends now apply to the backend, the slot's real Component). Same + platform rules as gatt_client_schema().""" + return ( + gatt_client_schema(platform) + .extend({cv.GenerateID(): cv.declare_id(HubBluetoothConnection)}) + .extend(cv.COMPONENT_SCHEMA) + ) + + +async def new_gatt_backend(config: ConfigType) -> cg.MockObj: + """Instantiate the backend declared by gatt_client_schema() and register + it with its platform stack. The connection slot is claimed at validation + (the proxy's slot validators), not here. + """ + from esphome.components import ble_device_base + + ble_device_base.request_gatt_client() + backend = cg.new_Pvariable(config[CONF_BACKEND_ID]) + # The backend is the slot's real Component: component keys from the + # connection entry (setup_priority, ...) apply to it. Consumers whose own + # schema carries keys that register_component would misapply to the + # backend (e.g. a polling interval) must not put them in this config. + await cg.register_component(backend, config) + await _backend_entry().register(backend, config) + return backend + + +# Named so tests can pin the hub entry against bluetooth_proxy's platform +# list (this module cannot import bluetooth_proxy to derive it). +SOURCE_FILE_FRAMEWORKS: dict[str, set[PlatformFramework]] = { + "bluetooth_connection_bluedroid.cpp": frameworks_for_platforms([PLATFORM_ESP32]), + # Every hub platform the proxy admits (the file compiles empty where + # USE_BLE_GATT_CLIENT is not defined), so a platform gaining a backend + # cannot hit a missing-symbol trap here. + "bluetooth_connection_hub.cpp": { + PlatformFramework.RP2_ARDUINO, + PlatformFramework.LN882X_ARDUINO, + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, + "bluetooth_connection_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, +} + +FILTER_SOURCE_FILES = filter_source_files_from_platform(SOURCE_FILE_FRAMEWORKS) diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.cpp b/esphome/components/bluetooth_connection/bluetooth_connection.cpp index 57833edbd2..94bb119c84 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection.cpp @@ -1,5 +1,10 @@ #include "bluetooth_connection.h" +#ifdef USE_ESP32 +#include +#include +#endif + #ifdef BLUETOOTH_CONNECTION_HAS_GATT #include "esphome/components/api/api_pb2.h" @@ -40,3 +45,25 @@ BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size } // namespace esphome::bluetooth_connection #endif // BLUETOOTH_CONNECTION_HAS_GATT + +#ifdef USE_ESP32 +namespace esphome::bluetooth_connection { + +// Address-scoped Bluedroid maintenance shared by every esp32 proxy build, +// including advertisement-only ones where no GATT backend (and none of the +// gated surface above) is compiled - so this block sits outside that gate. + +conn_err_t unpair_device(uint64_t address) { + esp_bd_addr_t bda; + ble_device_base::uint64_to_mac_msb_first(address, bda); + return esp_ble_remove_bond_device(bda); +} + +conn_err_t clear_gatt_cache(uint64_t address) { + esp_bd_addr_t bda; + ble_device_base::uint64_to_mac_msb_first(address, bda); + return esp_ble_gattc_cache_clean(bda); +} + +} // namespace esphome::bluetooth_connection +#endif // USE_ESP32 diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h index 2125d5b34f..5052e7eca1 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -16,10 +16,15 @@ #include #endif -// A GATT connection backend exists in this build: esp32 (Bluedroid) or a hub -// platform with the neutral GATT client compiled in. Single-sourced here so -// the proxy and this component cannot drift. -#if defined(USE_ESP32) || defined(USE_BLE_GATT_CLIENT) +// The connection-aware API request handlers are compiled: a GATT backend is +// wired by codegen (one slot per connection). This is the single spelling of +// that predicate - the hub wrapper and the API request handlers gate on it. +// The wrapper serves the proxy's API surface, so it compiles only when a +// backend AND the proxy are present; advertisement-only and backend-only +// builds get the clean-error handlers instead. Address-scoped maintenance +// (unpair, cache clear) still works there through the per-platform free +// functions below. +#if defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUETOOTH_PROXY) #define BLUETOOTH_CONNECTION_HAS_GATT #endif diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp new file mode 100644 index 0000000000..f24d261c57 --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -0,0 +1,772 @@ +#include "bluetooth_connection_bluedroid.h" + +#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT) + +// The in-place streamer serves the proxy's service-discovery API; backend-only +// builds compile without the proxy headers or the streamer. +#ifdef USE_BLUETOOTH_PROXY +#include "bluetooth_connection.h" +#include "bluetooth_connection_hub.h" + +#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" +#endif + +#include "esphome/components/ble_device_base/ble_client_state.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::bluetooth_connection { + +static const char *const TAG = "bluetooth_connection.bluedroid"; + +using ble_device_base::FAST_CONN_TIMEOUT; +using ble_device_base::FAST_MAX_CONN_INTERVAL; +using ble_device_base::FAST_MIN_CONN_INTERVAL; +using ble_device_base::MEDIUM_CONN_TIMEOUT; +using ble_device_base::MEDIUM_MAX_CONN_INTERVAL; +using ble_device_base::MEDIUM_MIN_CONN_INTERVAL; +using esp32_ble_tracker::ClientState; +using esp32_ble_tracker::ConnectionType; + +// ---- tracker surface ---- + +void BluedroidGattClient::connect() { this->tracker_connect_(); } +void BluedroidGattClient::disconnect() { this->gatt_disconnect(); } + +// ---- component ---- + +void BluedroidGattClient::setup() { + static uint8_t connection_index = 0; + this->connection_index_ = connection_index++; +} + +void BluedroidGattClient::loop() { + if (!esp32_ble::global_ble->is_active()) { + // Stack down: no CLOSE_EVT will come. Settle a live link so the consumer + // frees its slot, then re-register the app on the next enable. + auto down_st = this->state(); + if (down_st != ClientState::IDLE && down_st != ClientState::INIT) { + this->release_services(); + this->set_idle_(); + this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED); + } + this->set_state(ClientState::INIT); + return; + } + auto st = this->state(); + if (st == ClientState::INIT) { + // Parity with BLEClientBase: a failed registration marks the slot + // failed and idles it without retry. + auto ret = esp_ble_gattc_app_register(this->app_id); + if (ret) { + ESP_LOGE(TAG, "gattc app register failed: app_id=%d code=%d", this->app_id, ret); + this->mark_failed(); + } + // Do not wait for REG_EVT; a dropped event must not wedge the slot. + this->set_idle_(); + } else if (st == ClientState::DISCONNECTING || this->disconnect_pending()) { + // The one teardown safety net: a lost CLOSE_EVT, or a scheduled + // teardown whose OPEN_EVT never arrives. + if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) { + ESP_LOGE(TAG, "[%d] Timeout waiting for teardown, forcing IDLE", this->connection_index_); + // Release before idling: a lost completion must not leak the cache. + this->release_services(); + this->set_idle_(); // also clears want_disconnect_ + this->listener_->on_connection_state(false, 0, ESP_GATT_CONN_TIMEOUT); + } + } else { + // The loop stays on while a link exists (stack-down watch, pre-started + // search flush); it settles only back at IDLE. + this->deliver_pending_search_(); + if (this->state() == ClientState::IDLE) { + this->disable_loop(); + } + } +} + +void BluedroidGattClient::dump_config() { + ESP_LOGCONFIG(TAG, "Bluedroid GATT client %d", this->connection_index_); + if (this->is_failed()) { + ESP_LOGE(TAG, " Registration failed; if the error was ESP_GATT_NO_RESOURCES, reduce the connection slots"); + } +} + +// ---- contract ops ---- + +int BluedroidGattClient::connect(uint64_t address, uint8_t addr_type) { + // Only from idle: clobbering DISCONNECTING would open a new link the + // stale CLOSE_EVT then tears down. + if (this->state() != ClientState::IDLE) { + ESP_LOGW(TAG, "[%d] Connect rejected, slot busy", this->connection_index_); + return ESP_GATT_BUSY; + } + ble_device_base::uint64_to_mac_msb_first(address, this->remote_bda_); + this->remote_addr_type_ = addr_type; + // Hand the request to the tracker's promote loop: it stops the scan, raises + // coex, and calls tracker_connect_() - the tracker owns connect timing here. + this->set_state(ClientState::DISCOVERED); + return 0; +} + +void BluedroidGattClient::tracker_connect_() { + auto st = this->state(); + if (st == ClientState::CONNECTING || st == ClientState::CONNECTED || st == ClientState::ESTABLISHED) { + ESP_LOGW(TAG, "[%d] Connection already in progress", this->connection_index_); + return; + } + if (st == ClientState::DISCONNECTING) { + ESP_LOGW(TAG, "[%d] Cannot connect, still waiting for CLOSE_EVT", this->connection_index_); + return; + } + ESP_LOGI(TAG, "[%d] 0x%02x Connecting", this->connection_index_, this->remote_addr_type_); + // Per-attempt latches; the search machine is reset by set_idle_(), the + // one door back to IDLE. + this->services_released_ = false; + this->seen_mtu_ = false; + this->mtu_failed_ = false; + this->enable_loop(); + this->set_state(ClientState::CONNECTING); + if (this->connection_type_ == ConnectionType::V3_WITHOUT_CACHE) { + // Fast params for the discovery phase; stepped down at SEARCH_CMPL. + this->check_and_log_error_("esp_ble_gap_set_prefer_conn_params", + esp_ble_gap_set_prefer_conn_params(this->remote_bda_, FAST_MIN_CONN_INTERVAL, + FAST_MAX_CONN_INTERVAL, 0, FAST_CONN_TIMEOUT)); + } else { + this->check_and_log_error_("esp_ble_gap_set_prefer_conn_params", + esp_ble_gap_set_prefer_conn_params(this->remote_bda_, MEDIUM_MIN_CONN_INTERVAL, + MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT)); + } + auto ret = esp_ble_gattc_open(this->gattc_if_, this->remote_bda_, + static_cast(this->remote_addr_type_), true); + if (ret) { + this->log_gattc_warning_("esp_ble_gattc_open", ret); + // CONNECT_EVT never fired; nothing to close. + this->set_idle_(); + this->listener_->on_connection_state(false, 0, ret); + } +} + +int BluedroidGattClient::gatt_disconnect() { + auto st = this->state(); + if (st == ClientState::DISCONNECTING) { + return 0; + } + // Nothing was opened, so no completion event will follow: report + // not-connected and the hub frees the slot at once (rp2 convention). + if (st == ClientState::IDLE) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + if (st == ClientState::DISCOVERED) { + // Parked for the tracker promote loop, never opened. + this->set_idle_(); + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + if (st == ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) { + ESP_LOGD(TAG, "[%d] Disconnect scheduled", this->connection_index_); + this->want_disconnect_ = true; + // Arm the safety window: a lost OPEN_EVT must not leak the teardown. + this->disconnecting_started_ = millis(); + this->enable_loop(); + return 0; + } + this->unconditional_disconnect_(); + return 0; +} + +void BluedroidGattClient::unconditional_disconnect_() { + ESP_LOGI(TAG, "[%d] Disconnecting (conn_id: %d)", this->connection_index_, this->conn_id_); + if (this->conn_id_ == UNSET_CONN_ID) { + // Terminal state now rather than leaning on the scheduled-teardown timer. + ESP_LOGE(TAG, "[%d] conn id unset, cannot disconnect", this->connection_index_); + this->release_services(); + this->set_idle_(); + this->listener_->on_connection_state(false, 0, ble_device_base::GATT_ERR_NOT_CONNECTED); + return; + } + auto err = esp_ble_gattc_close(this->gattc_if_, this->conn_id_); + if (err != ESP_OK) { + // The stack is now in an indeterminate state for this link. + ESP_LOGE(TAG, "[%d] esp_ble_gattc_close error: %d", this->connection_index_, err); + } + this->set_disconnecting_(); +} + +bool BluedroidGattClient::cancel_gatt_disconnect() { + // Only a scheduled teardown (want_disconnect_ latched while the open is + // still in flight) is cancellable; once closing started the terminal + // report settles the race. + if (this->state() != ClientState::CONNECTING || !this->disconnect_pending()) { + return false; + } + this->want_disconnect_ = false; + return true; +} + +int BluedroidGattClient::discover_services() { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + switch (this->search_state_) { + case SearchState::PRESTARTED: + // The pending SEARCH_CMPL reports once it lands. + this->search_state_ = SearchState::CLAIMED; + return 0; + case SearchState::PRESTART_DONE: + // Already landed: the flush after the connected report delivers + // (loop() covers a claim made outside that event drain). + this->search_state_ = SearchState::REPORT_PENDING; + this->enable_loop(); + return 0; + case SearchState::CLAIMED: + case SearchState::REPORT_PENDING: + return 0; // One completion is already owed to this claimant. + case SearchState::NONE: + break; + } + int err = this->check_and_log_error_("esp_ble_gattc_search_service", + esp_ble_gattc_search_service(this->gattc_if_, this->conn_id_, nullptr)); + if (err == 0) { + this->search_state_ = SearchState::CLAIMED; + } + return err; +} + +int BluedroidGattClient::read_characteristic(uint16_t handle) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + return this->check_and_log_error_("esp_ble_gattc_read_char", esp_ble_gattc_read_char(this->gattc_if_, this->conn_id_, + handle, ESP_GATT_AUTH_REQ_NONE)); +} + +int BluedroidGattClient::write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + // The BTC layer copies the payload immediately, so the const_cast is safe. + return this->check_and_log_error_( + "esp_ble_gattc_write_char", + esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, len, const_cast(data), + response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, + ESP_GATT_AUTH_REQ_NONE)); +} + +int BluedroidGattClient::read_descriptor(uint16_t handle) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + return this->check_and_log_error_( + "esp_ble_gattc_read_char_descr", + esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE)); +} + +int BluedroidGattClient::write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + return this->check_and_log_error_( + "esp_ble_gattc_write_char_descr", + esp_ble_gattc_write_char_descr(this->gattc_if_, this->conn_id_, handle, len, const_cast(data), + ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE)); +} + +int BluedroidGattClient::notify_characteristic(uint16_t handle, bool enable) { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + // Local registration only; the CCCD write is the API client's responsibility. + if (enable) { + return this->check_and_log_error_("esp_ble_gattc_register_for_notify", + esp_ble_gattc_register_for_notify(this->gattc_if_, this->remote_bda_, handle)); + } + return this->check_and_log_error_("esp_ble_gattc_unregister_for_notify", + esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle)); +} + +int BluedroidGattClient::pair() { + if (this->conn_id_ == UNSET_CONN_ID) { + return ble_device_base::GATT_ERR_NOT_CONNECTED; + } + return esp_ble_set_encryption(this->remote_bda_, ESP_BLE_SEC_ENCRYPT); +} + +int BluedroidGattClient::update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout) { + return this->update_conn_params_(min_interval, max_interval, latency, timeout, "custom"); +} + +void BluedroidGattClient::release_services() { + this->service_total_ = 0; + // Always set: terminates any in-flight stream on every cache config. + this->services_released_ = true; +#ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH + // A failed clean leaves a stale database the next connection could serve + // as authoritative. A disabled stack invalidates its own cache; skip the + // meaningless call instead of warning on every OTA/ble.disable teardown. + if (esp32_ble::global_ble->is_active()) { + this->check_and_log_error_("esp_ble_gattc_cache_clean", esp_ble_gattc_cache_clean(this->remote_bda_)); + } +#endif +} + +// ---- internals ---- + +bool BluedroidGattClient::check_addr_(const esp_bd_addr_t &addr) const { + return memcmp(addr, this->remote_bda_, sizeof(esp_bd_addr_t)) == 0; +} + +void BluedroidGattClient::set_idle_() { + this->set_state(ClientState::IDLE); + this->conn_id_ = UNSET_CONN_ID; + this->search_state_ = SearchState::NONE; + this->search_status_ = 0; +} + +void BluedroidGattClient::set_disconnecting_() { + this->disconnecting_started_ = millis(); + this->set_state(ClientState::DISCONNECTING); + // The loop may be disabled while idle; the safety timeout needs it. + this->enable_loop(); +} + +esp_err_t BluedroidGattClient::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout, const char *param_type) { + esp_ble_conn_update_params_t conn_params = {{0}}; + memcpy(conn_params.bda, this->remote_bda_, sizeof(esp_bd_addr_t)); + conn_params.min_int = min_interval; + conn_params.max_int = max_interval; + conn_params.latency = latency; + conn_params.timeout = timeout; + ESP_LOGD(TAG, "[%d] %s conn params", this->connection_index_, param_type); + return this->check_and_log_error_("esp_ble_gap_update_conn_params", esp_ble_gap_update_conn_params(&conn_params)); +} + +int BluedroidGattClient::check_and_log_error_(const char *operation, esp_err_t err) { + if (err != ESP_OK) { + this->log_gattc_warning_(operation, err); + } + return err; +} + +void BluedroidGattClient::log_gattc_warning_(const char *operation, int code) { + ESP_LOGW(TAG, "[%d] %s failed, status=%d", this->connection_index_, operation, code); +} + +// ---- service streaming ---- + +int BluedroidGattClient::handle_search_cmpl_(esp_gatt_status_t status) { + // Step down from the fast discovery params. + this->update_conn_params_(MEDIUM_MIN_CONN_INTERVAL, MEDIUM_MAX_CONN_INTERVAL, 0, MEDIUM_CONN_TIMEOUT, "medium"); + if (status != ESP_GATT_OK) { + // A failed discovery reads as a clean zero from the count calls below; + // honoring the event status stops it becoming an authoritative empty + // list. + return status; + } + uint16_t primary = 0; + uint16_t secondary = 0; + auto primary_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_PRIMARY_SERVICE, + 0x0001, 0xFFFF, 0, &primary); + auto secondary_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_SECONDARY_SERVICE, + 0x0001, 0xFFFF, 0, &secondary); + if (primary_status != ESP_GATT_OK || secondary_status != ESP_GATT_OK) { + // A failed count must not become an authoritative empty database. + auto count_status = primary_status != ESP_GATT_OK ? primary_status : secondary_status; + this->log_gattc_warning_("esp_ble_gattc_get_attr_count", count_status); + return count_status; + } + this->service_total_ = primary + secondary; + return 0; +} + +// Reports a completed search once claimed; delivery consumes the state so +// a re-discovery issues a real search. +void BluedroidGattClient::deliver_pending_search_() { + if (this->search_state_ != SearchState::REPORT_PENDING) + return; + this->search_state_ = SearchState::NONE; + this->listener_->on_service_discovery_done(this->search_status_); +} + +#ifdef USE_BLUETOOTH_PROXY +// The wrapper's compile-time streamer detection must keep finding this +// method; a signature drift would silently fall back to the table streamer, +// which proxy builds compile without a materializer. +static_assert(requires(BluedroidGattClient c, BluetoothConnection &conn) { c.stream_service_batch(conn); }); + +void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) { + if (this->services_released_) { + // Released under the stream: park without services-done so a partial + // list is never cached as authoritative (the client retries after its + // GetServices timeout). + ESP_LOGW(TAG, "[%d] [%s] Services released mid-stream, parking", conn.connection_index_, conn.address_str_); + conn.send_service_ = DONE_SENDING_SERVICES; + return; + } + if (conn.send_service_ >= this->service_total_) { + conn.send_service_ = DONE_SENDING_SERVICES; + conn.proxy_->send_gatt_services_done(conn.address_); + this->release_services(); + return; + } + + // The subscriber vanished mid-stream: park the cursor at done WITHOUT + // sending services-done (a resubscribing client gets silence and its 30 s + // timeout, never an authoritative partial list). + auto *api_conn = conn.proxy_->get_api_connection(); + if (api_conn == nullptr) { + ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", conn.connection_index_, conn.address_str_); + conn.send_service_ = DONE_SENDING_SERVICES; + this->release_services(); + return; + } + + bool use_efficient_uuids = conn.proxy_->client_supports_efficient_uuids(); + api::BluetoothGATTGetServicesResponse resp; + resp.address = conn.address_; + size_t current_size = resp.calculate_size(); + int16_t batch_start = conn.send_service_; + + while (conn.send_service_ < this->service_total_) { + esp_gattc_service_elem_t service_result; + uint16_t svc_count = 1; + esp_gatt_status_t svc_status = esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, &service_result, + &svc_count, conn.send_service_); + if (svc_status != ESP_GATT_OK || svc_count == 0) { + ESP_LOGE(TAG, "[%d] [%s] Service walk failed (service %d), aborting stream", conn.connection_index_, + conn.address_str_, conn.send_service_); + conn.abort_service_stream(svc_status != ESP_GATT_OK ? svc_status : ESP_GATT_NOT_FOUND); + return; + } + uint16_t total_char_count = 0; + auto char_count_status = + esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC, + service_result.start_handle, service_result.end_handle, 0, &total_char_count); + if (char_count_status != ESP_GATT_OK) { + this->log_gattc_warning_("esp_ble_gattc_get_attr_count", char_count_status); + conn.abort_service_stream(char_count_status); + return; + } + + // If this service likely won't fit, send the current batch first. + size_t estimated_size = estimate_service_size(total_char_count, use_efficient_uuids); + if (!resp.services.empty() && current_size + estimated_size > MAX_PACKET_SIZE) { + break; + } + + resp.services.emplace_back(); + auto &service_resp = resp.services.back(); + fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, + ble_device_base::ESPBTUUID::from_uuid(service_result.uuid), use_efficient_uuids); + service_resp.handle = service_result.start_handle; + + if (total_char_count > 0) { + service_resp.characteristics.init(total_char_count); + uint16_t char_offset = 0; + esp_gattc_char_elem_t char_result; + // Bounded by the count query: a misbehaving peripheral can make the + // enumeration return more entries than it reported. + while (char_offset < total_char_count) { + uint16_t cc = 1; + auto char_status = esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, + service_result.end_handle, &char_result, &cc, char_offset); + if (char_status != ESP_GATT_OK || cc == 0) { + // An early terminator contradicts the count from the same cache; + // never stream a silently truncated list. + this->log_gattc_warning_("esp_ble_gattc_get_all_char", char_status); + conn.abort_service_stream(char_status != ESP_GATT_OK ? char_status : ESP_GATT_NOT_FOUND); + return; + } + service_resp.characteristics.emplace_back(); + auto &characteristic_resp = service_resp.characteristics.back(); + fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, + ble_device_base::ESPBTUUID::from_uuid(char_result.uuid), use_efficient_uuids); + characteristic_resp.handle = char_result.char_handle; + characteristic_resp.properties = char_result.properties; + + uint16_t total_desc_count = 0; + auto desc_count_status = esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, + 0, 0, char_result.char_handle, &total_desc_count); + if (desc_count_status != ESP_GATT_OK) { + // Abort rather than stream the characteristic descriptor-less: a + // missing CCCD in a cached database breaks notifications for good. + this->log_gattc_warning_("esp_ble_gattc_get_attr_count", desc_count_status); + conn.abort_service_stream(desc_count_status); + return; + } + if (total_desc_count > 0) { + characteristic_resp.descriptors.init(total_desc_count); + uint16_t desc_offset = 0; + esp_gattc_descr_elem_t desc_result; + while (desc_offset < total_desc_count) { + uint16_t dc = 1; + auto desc_status = esp_ble_gattc_get_all_descr(this->gattc_if_, this->conn_id_, char_result.char_handle, + &desc_result, &dc, desc_offset); + if (desc_status != ESP_GATT_OK || dc == 0) { + this->log_gattc_warning_("esp_ble_gattc_get_all_descr", desc_status); + conn.abort_service_stream(desc_status != ESP_GATT_OK ? desc_status : ESP_GATT_NOT_FOUND); + return; + } + characteristic_resp.descriptors.emplace_back(); + auto &descriptor_resp = characteristic_resp.descriptors.back(); + fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, + ble_device_base::ESPBTUUID::from_uuid(desc_result.uuid), use_efficient_uuids); + descriptor_resp.handle = desc_result.handle; + desc_offset++; + } + } + char_offset++; + } + } + + if (close_service_batch(resp, current_size, conn.send_service_, conn.connection_index_, conn.address_str_) != + BatchClose::CONTINUE) { + break; + } + } + + // On a failed send, rewind the cursor so the batch is retried instead of + // silently skipped. + if (!api_conn->send_message(resp)) { + ESP_LOGW(TAG, "[%d] [%s] Failed to send service batch, retrying", conn.connection_index_, conn.address_str_); + conn.send_service_ = batch_start; + } +} +#endif // USE_BLUETOOTH_PROXY + +// ---- events ---- + +void BluedroidGattClient::handle_open_evt_(esp_ble_gattc_cb_param_t *param) { + auto st = this->state(); + if (st == ClientState::IDLE) { + // Late OPEN_EVT after the slot went IDLE (open-error race, or the + // teardown net gave up): close a won link, never resurrect the slot. + ESP_LOGD(TAG, "[%d] OPEN_EVT in IDLE state (status=%d)", this->connection_index_, param->open.status); + if (param->open.status == ESP_GATT_OK || param->open.status == ESP_GATT_ALREADY_OPEN) { + // A failed close here leaks a live link nothing tracks; make it heard. + this->check_and_log_error_("esp_ble_gattc_close", esp_ble_gattc_close(this->gattc_if_, param->open.conn_id)); + } + return; + } + if (st != ClientState::CONNECTING) { + ESP_LOGE(TAG, "[%d] OPEN_EVT in unexpected state", this->connection_index_); + } + if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { + this->log_gattc_warning_("Connection open", param->open.status); + // Never established, CLOSE_EVT may not follow. + this->set_idle_(); + this->listener_->on_connection_state(false, 0, param->open.status); + return; + } + if (this->disconnect_pending()) { + // Open resolved with a teardown scheduled: close now (conn_id_ stays set + // so CLOSE_EVT still matches). + this->unconditional_disconnect_(); + return; + } + this->set_state(ClientState::CONNECTED); + ESP_LOGI(TAG, "[%d] Connection open", this->connection_index_); + if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) { + this->set_state(ClientState::ESTABLISHED); + // No discovery phase: report immediately with the default MTU. The + // cached path never waits for (or reports) the exchange - seen_mtu_ + // suppresses the CFG_MTU report, matching the previous esp32 behavior. + this->seen_mtu_ = true; + this->listener_->on_connection_state(true, ble_device_base::DEFAULT_ATT_MTU, 0); + } else { + // Discovery-bound connection: start the search now so it overlaps the + // MTU exchange. On a refusal fall back to the serialized path - the + // consumer's own discover_services() call retries the real search. + if (this->check_and_log_error_("esp_ble_gattc_search_service", + esp_ble_gattc_search_service(this->gattc_if_, param->open.conn_id, nullptr)) == 0) { + this->search_state_ = SearchState::PRESTARTED; + } + if (this->mtu_failed_ && !this->seen_mtu_) { + // Refused MTU request: report with the default so the consumer + // proceeds. + this->seen_mtu_ = true; + this->listener_->on_connection_state(true, ble_device_base::DEFAULT_ATT_MTU, 0); + this->deliver_pending_search_(); + } + } +} + +void BluedroidGattClient::handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param) { + if (param->disconnect.reason == ESP_GATT_CONN_TERMINATE_PEER_USER && this->state() == ClientState::CONNECTED) { + ESP_LOGW(TAG, "[%d] Remote closed during discovery", this->connection_index_); + } else { + ESP_LOGD(TAG, "[%d] DISCONNECT_EVT reason=0x%02x", this->connection_index_, param->disconnect.reason); + } + if (this->state() == ClientState::IDLE) { + // Active close delivers CLOSE_EVT first; never walk back to DISCONNECTING. + return; + } + // Passive disconnect: wait for CLOSE_EVT before going IDLE (reconnecting + // earlier makes the controller reject with 133 or assert) and before + // reporting - the wrapper frees the slot on the report, and a freed slot + // invites a reconnect into the still-closing link. + this->release_services(); + this->set_disconnecting_(); +} + +bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if, + esp_ble_gattc_cb_param_t *param) { + if (event == ESP_GATTC_REG_EVT && this->app_id != param->reg.app_id) + return false; + if (event != ESP_GATTC_REG_EVT && esp_gattc_if != ESP_GATT_IF_NONE && esp_gattc_if != this->gattc_if_) + return false; + + switch (event) { + case ESP_GATTC_REG_EVT: { + if (param->reg.status == ESP_GATT_OK) { + this->gattc_if_ = esp_gattc_if; + } else { + ESP_LOGE(TAG, "[%d] gattc app registration failed, status=%d", this->connection_index_, param->reg.status); + this->mark_failed(); + } + break; + } + case ESP_GATTC_CONNECT_EVT: { + if (!this->check_addr_(param->connect.remote_bda)) + return false; + this->conn_id_ = param->connect.conn_id; + // MTU request here rather than OPEN_EVT, matching the IDF examples. + auto ret = esp_ble_gattc_send_mtu_req(this->gattc_if_, param->connect.conn_id); + if (ret) { + this->log_gattc_warning_("esp_ble_gattc_send_mtu_req", ret); + // No CFG_MTU_EVT will follow; OPEN_EVT reports with the default. + this->mtu_failed_ = true; + } + break; + } + case ESP_GATTC_OPEN_EVT: { + if (!this->check_addr_(param->open.remote_bda)) + return false; + this->handle_open_evt_(param); + break; + } + case ESP_GATTC_CFG_MTU_EVT: { + if (this->conn_id_ != param->cfg_mtu.conn_id) + return false; + if (param->cfg_mtu.status != ESP_GATT_OK) { + // Warn only; a disconnect will follow if the link is dead. + this->log_gattc_warning_("MTU exchange", param->cfg_mtu.status); + } + if (!this->seen_mtu_ && !this->disconnect_pending() && this->state() != ClientState::DISCONNECTING) { + // Teardown owns the link: suppress the connected report here like + // OPEN_EVT and SEARCH_CMPL do; the terminal report settles it. + this->seen_mtu_ = true; + // The connected report waited for the MTU; forwarded, not stored. + this->listener_->on_connection_state( + true, param->cfg_mtu.status == ESP_GATT_OK ? param->cfg_mtu.mtu : ble_device_base::DEFAULT_ATT_MTU, 0); + // The consumer requests discovery from inside that report; when the + // pre-started search already finished, complete it in the same drain. + this->deliver_pending_search_(); + } + break; + } + case ESP_GATTC_DISCONNECT_EVT: { + if (!this->check_addr_(param->disconnect.remote_bda)) + return false; + this->handle_disconnect_evt_(param); + break; + } + case ESP_GATTC_CLOSE_EVT: { + if (this->conn_id_ != param->close.conn_id) + return false; + this->release_services(); + this->set_idle_(); + // The one connected=false report: the wrapper frees the slot on it, + // so it must not fire before the controller finished closing. + this->listener_->on_connection_state(false, 0, param->close.reason); + break; + } + case ESP_GATTC_SEARCH_CMPL_EVT: { + if (this->conn_id_ != param->search_cmpl.conn_id) + return false; + ESP_LOGI(TAG, "[%d] Service discovery complete", this->connection_index_); + if (this->state() == ClientState::DISCONNECTING) { + // Teardown owns the link; the result is never delivered, skip the + // work. + break; + } + this->search_status_ = this->handle_search_cmpl_(static_cast(param->search_cmpl.status)); + this->search_state_ = + this->search_state_ == SearchState::CLAIMED ? SearchState::REPORT_PENDING : SearchState::PRESTART_DONE; + this->set_state(ClientState::ESTABLISHED); + this->deliver_pending_search_(); + break; + } + case ESP_GATTC_READ_CHAR_EVT: + case ESP_GATTC_READ_DESCR_EVT: { + if (this->conn_id_ != param->read.conn_id) + return false; + bool ok = param->read.status == ESP_GATT_OK; + this->listener_->on_read_result(param->read.handle, ok ? param->read.value : nullptr, + ok ? param->read.value_len : 0, ok ? 0 : param->read.status); + break; + } + case ESP_GATTC_WRITE_CHAR_EVT: + case ESP_GATTC_WRITE_DESCR_EVT: { + if (this->conn_id_ != param->write.conn_id) + return false; + this->listener_->on_write_result(param->write.handle, + param->write.status == ESP_GATT_OK ? 0 : param->write.status); + break; + } + case ESP_GATTC_REG_FOR_NOTIFY_EVT: { + this->listener_->on_notify_state(param->reg_for_notify.handle, true, + param->reg_for_notify.status == ESP_GATT_OK ? 0 : param->reg_for_notify.status); + break; + } + case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: { + this->listener_->on_notify_state( + param->unreg_for_notify.handle, false, + param->unreg_for_notify.status == ESP_GATT_OK ? 0 : param->unreg_for_notify.status); + break; + } + case ESP_GATTC_NOTIFY_EVT: { + if (this->conn_id_ != param->notify.conn_id) + return false; + ESP_LOGV(TAG, "[%d] NOTIFY_EVT handle=0x%2X", this->connection_index_, param->notify.handle); + this->listener_->on_notify_data(param->notify.handle, param->notify.value, param->notify.value_len); + break; + } + default: + break; + } + return true; +} + +void BluedroidGattClient::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { + switch (event) { + case ESP_GAP_BLE_SEC_REQ_EVT: { + if (!this->check_addr_(param->ble_security.auth_cmpl.bd_addr)) + break; + // Always accept; a refused response means no AUTH_CMPL, so answer the + // pairing request with the failure. + int sec_err = this->check_and_log_error_("esp_ble_gap_security_rsp", + esp_ble_gap_security_rsp(param->ble_security.ble_req.bd_addr, true)); + if (sec_err != 0) { + this->listener_->on_pairing_result(sec_err); + } + break; + } + case ESP_GAP_BLE_AUTH_CMPL_EVT: { + if (!this->check_addr_(param->ble_security.auth_cmpl.bd_addr)) + break; + this->listener_->on_pairing_result( + param->ble_security.auth_cmpl.success ? 0 : param->ble_security.auth_cmpl.fail_reason); + break; + } + default: + break; + } +} + +} // namespace esphome::bluetooth_connection + +#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h new file mode 100644 index 0000000000..19b89ea5cd --- /dev/null +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -0,0 +1,142 @@ +// Bluedroid (esp32) GATT client backend: the esp32 arm of the +// ble_device_base::BLEGattConnection alias for the hub BluetoothConnection +// wrapper. Not a BLEClientBase: the tracker's promote loop owns +// scan-stop/coex/one-connect-at-a-time, so the contract's connect() only +// parks the address in DISCOVERED; the real esp_ble_gattc_open happens in +// the tracker-invoked connect() override. + +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32_BLE) && defined(USE_BLE_GATT_CLIENT) + +#include "esphome/components/ble_device_base/ble_gatt_client.h" +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/core/component.h" + +#include +#include + +namespace esphome::bluetooth_connection { + +#ifdef USE_BLUETOOTH_PROXY +class BluetoothConnection; +#endif + +// One class carries both halves: the tracker's ESPBTClient surface (its +// promote loop owns scan-stop/coex/one-connect-at-a-time and calls the +// virtual connect()/disconnect()) and the neutral contract ops. The +// contract's teardown op is named gatt_disconnect() because the tracker's +// void disconnect() cannot overload with an int-returning twin. +class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public Component { + public: + static constexpr uint16_t UNSET_CONN_ID = 0xFFFF; + + // Lifecycle of one connection attempt's service search. + enum class SearchState : uint8_t { + NONE, // no search this attempt + PRESTARTED, // issued at OPEN_EVT, no claimant yet + PRESTART_DONE, // completed with search_status_ latched, no claimant yet + CLAIMED, // in flight with a claimant (pre-started or direct) + REPORT_PENDING // completed and claimed: deliver on the next flush + }; + + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; } + + // Wired by codegen before setup and invariant for the device lifetime. + void set_listener(ble_device_base::GattClientListener *listener) { this->listener_ = listener; } + + // ---- esp32_ble_tracker::ESPBTClient ---- + bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t *param) override; + void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; + void connect() override; + void disconnect() override; + bool wants_parsed_advertisements() override { return false; } + void on_scan_end() override {} + bool parse_device(const ble_device_base::ESPBTDevice &device) override { return false; } + + // ---- ble_device_base::BLEGattConnection contract ---- + int connect(uint64_t address, uint8_t addr_type); + int gatt_disconnect(); + bool cancel_gatt_disconnect(); + int discover_services(); + int read_characteristic(uint16_t handle); + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response); + int read_descriptor(uint16_t handle); + int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len); + int notify_characteristic(uint16_t handle, bool enable); + int pair(); + int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout); + // Contract stub: the proxy streams in place; the on-demand materializer + // for direct consumers lands with #18205. NOTE: a direct consumer reaching + // this stub gets an empty table indistinguishable from a service-less + // peer - do not ship one against this backend before the materializer. + ble_device_base::GattServiceTable get_service_table() { return {}; } + void release_services(); + +#ifdef USE_BLUETOOTH_PROXY + /// In-place service streamer (the proxy wrapper detects and prefers it): + /// builds one api response batch directly from Bluedroid's cached database, + /// so the streaming peak is the response itself - the old esp32 model. + void stream_service_batch(BluetoothConnection &conn); +#endif + + void set_connection_type(ble_device_base::ConnectionType ct) { this->connection_type_ = ct; } + + protected: + bool check_addr_(const esp_bd_addr_t &addr) const; + void tracker_connect_(); + void handle_open_evt_(esp_ble_gattc_cb_param_t *param); + void handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param); + int handle_search_cmpl_(esp_gatt_status_t status); + void deliver_pending_search_(); + void unconditional_disconnect_(); + void set_idle_(); + void set_disconnecting_(); + esp_err_t update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, + const char *param_type); + int check_and_log_error_(const char *operation, esp_err_t err); + void log_gattc_warning_(const char *operation, int code); + + // Group 1: pointers / composed objects + ble_device_base::GattClientListener *listener_{nullptr}; + // Group 2: 4-byte types + uint32_t disconnecting_started_{0}; + + // Group 3: arrays + esp_bd_addr_t remote_bda_{}; + + // Group 4: 2-byte types + uint16_t conn_id_{UNSET_CONN_ID}; + uint16_t service_total_{0}; + + // Group 5: 1-byte types + esp_gatt_if_t gattc_if_{ESP_GATT_IF_NONE}; // uint8_t width keeps the object at 48 bytes + // Stored narrow (the enum is 4 bytes); widened at the esp_ble_gattc_open call. + uint8_t remote_addr_type_{0}; + esp32_ble_tracker::ConnectionType connection_type_{esp32_ble_tracker::ConnectionType::V3_WITHOUT_CACHE}; + uint8_t connection_index_{0}; + // Terminates an in-flight stream (never send a partial list as authoritative) + // and marks a cleaned cache unsafe to walk (Bluedroid asserts). + bool services_released_ : 1 {false}; + // The connected report waits for the MTU exchange; OPEN_EVT alone would + // hand HA the default 23. + bool seen_mtu_ : 1 {false}; + // The MTU request was refused at CONNECT_EVT; OPEN_EVT reports instead. + bool mtu_failed_ : 1 {false}; + // Search issued at OPEN_EVT overlaps the MTU exchange; discover_services() + // completes from it. Reset by set_idle_(). + static_assert(static_cast(SearchState::REPORT_PENDING) < (1 << 4), "search_state_ bitfield too narrow"); + SearchState search_state_ : 4 {SearchState::NONE}; + // esp_gatt_status_t of the completed search, held until claimed. + uint8_t search_status_{0}; +}; + +} // namespace esphome::bluetooth_connection + +#endif // USE_ESP32_BLE && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp deleted file mode 100644 index f5c59ca43a..0000000000 --- a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.cpp +++ /dev/null @@ -1,484 +0,0 @@ -#include "bluetooth_connection_esp32.h" - -#include "esphome/components/api/api_pb2.h" -#include "esphome/core/helpers.h" -#include "esphome/core/log.h" - -#ifdef USE_ESP32 - -#include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" - -namespace esphome::bluetooth_connection { - -namespace espbt = esphome::esp32_ble_tracker; - -using ble_device_base::ESPBTUUID; - -static const char *const TAG = "bluetooth_connection"; - -conn_err_t unpair_device(uint64_t address) { - esp_bd_addr_t bd_addr; - ble_device_base::uint64_to_mac_msb_first(address, bd_addr); - return esp_ble_remove_bond_device(bd_addr); -} - -conn_err_t clear_gatt_cache(uint64_t address) { - esp_bd_addr_t bd_addr; - ble_device_base::uint64_to_mac_msb_first(address, bd_addr); - return esp_ble_gattc_cache_clean(bd_addr); -} - -void BluetoothConnection::dump_config() { - ESP_LOGCONFIG(TAG, "BLE Connection:"); - BLEClientBase::dump_config(); -} - -void BluetoothConnection::set_address(uint64_t address) { - // Keep the proxy's pre-allocated connections-free message in step - this->proxy_->update_address_slot_(this->address_, address); - // Call parent implementation to actually set the address - BLEClientBase::set_address(address); -} - -void BluetoothConnection::loop() { - BLEClientBase::loop(); - - // Early return if no active connection - if (this->address_ == 0) { - return; - } - - // Handle service discovery if in valid range - if (this->send_service_ >= 0 && this->send_service_ <= this->service_count_) { - this->send_service_for_discovery_(); - } - - // Check if we should disable the loop - // - For V3_WITH_CACHE: Services are never sent, disable after INIT state - // - For V3_WITHOUT_CACHE: Disable only after service discovery is complete - // (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent) - // Never disable while DISCONNECTING — BLEClientBase::loop() needs to keep running so the - // 10s safety timeout can force IDLE if CLOSE_EVT is never delivered. - if (this->state() != espbt::ClientState::INIT && this->state() != espbt::ClientState::DISCONNECTING && - (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE || - this->send_service_ == DONE_SENDING_SERVICES)) { - this->disable_loop(); - } -} - -void BluetoothConnection::on_disconnect_complete(esp_err_t reason) { - // Called from both the CLOSE_EVT handler and the DISCONNECTING safety timeout in the - // base class. Free the proxy slot, notify the API client, and reset send_service_. - // address_ may already be 0 if reset_connection_ ran earlier on this teardown. - if (this->address_ == 0) { - return; - } - ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, reason); - this->reset_connection_(reason); -} - -void BluetoothConnection::reset_connection_(esp_err_t reason) { this->proxy_->reset_connection_slot_(this, reason); } - -void BluetoothConnection::send_service_for_discovery_() { - if (this->send_service_ >= this->service_count_) { - this->send_service_ = DONE_SENDING_SERVICES; - this->proxy_->send_gatt_services_done(this->address_); - this->release_services(); - return; - } - - // Early return if no API connection - auto *api_conn = this->proxy_->get_api_connection(); - if (api_conn == nullptr) { - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - - // Check if client supports efficient UUIDs - bool use_efficient_uuids = this->proxy_->client_supports_efficient_uuids(); - - // Prepare response - api::BluetoothGATTGetServicesResponse resp; - resp.address = this->address_; - - // Dynamic batching based on actual size - // Keep running total of actual message size - size_t current_size = resp.calculate_size(); - int16_t batch_start = this->send_service_; - - while (this->send_service_ < this->service_count_) { - esp_gattc_service_elem_t service_result; - uint16_t service_count = 1; - esp_gatt_status_t service_status = esp_ble_gattc_get_service(this->gattc_if_, this->conn_id_, nullptr, - &service_result, &service_count, this->send_service_); - - if (service_status != ESP_GATT_OK || service_count == 0) { - ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_service %s, status=%d, service_count=%d, offset=%d", - this->connection_index_, this->address_str(), service_status != ESP_GATT_OK ? "error" : "missing", - service_status, service_count, this->send_service_); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - - // Get the number of characteristics BEFORE adding to response - uint16_t total_char_count = 0; - esp_gatt_status_t char_count_status = - esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_CHARACTERISTIC, - service_result.start_handle, service_result.end_handle, 0, &total_char_count); - - if (char_count_status != ESP_GATT_OK) { - this->log_connection_error_("esp_ble_gattc_get_attr_count", char_count_status); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - - // If this service likely won't fit, send current batch (unless it's the first) - size_t estimated_size = estimate_service_size(total_char_count, use_efficient_uuids); - if (!resp.services.empty() && (current_size + estimated_size > MAX_PACKET_SIZE)) { - // This service likely won't fit, send current batch - break; - } - - // Now add the service since we know it will likely fit - resp.services.emplace_back(); - auto &service_resp = resp.services.back(); - - fill_gatt_uuid(service_resp.uuid, service_resp.short_uuid, ESPBTUUID::from_uuid(service_result.uuid), - use_efficient_uuids); - - service_resp.handle = service_result.start_handle; - - if (total_char_count > 0) { - // Initialize FixedVector with exact count and process characteristics - service_resp.characteristics.init(total_char_count); - uint16_t char_offset = 0; - esp_gattc_char_elem_t char_result; - // Bound by total_char_count: the vector is sized for it, and a malicious peripheral - // can make enumeration return more entries than the count query reported - while (char_offset < total_char_count) { // characteristics - uint16_t char_count = 1; - esp_gatt_status_t char_status = - esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle, - service_result.end_handle, &char_result, &char_count, char_offset); - if (char_status == ESP_GATT_INVALID_OFFSET || char_status == ESP_GATT_NOT_FOUND) { - break; - } - if (char_status != ESP_GATT_OK) { - this->log_connection_error_("esp_ble_gattc_get_all_char", char_status); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - if (char_count == 0) { - break; - } - - service_resp.characteristics.emplace_back(); - auto &characteristic_resp = service_resp.characteristics.back(); - fill_gatt_uuid(characteristic_resp.uuid, characteristic_resp.short_uuid, ESPBTUUID::from_uuid(char_result.uuid), - use_efficient_uuids); - characteristic_resp.handle = char_result.char_handle; - characteristic_resp.properties = char_result.properties; - char_offset++; - - // Get the number of descriptors directly with one call - uint16_t total_desc_count = 0; - esp_gatt_status_t desc_count_status = esp_ble_gattc_get_attr_count( - this->gattc_if_, this->conn_id_, ESP_GATT_DB_DESCRIPTOR, 0, 0, char_result.char_handle, &total_desc_count); - - if (desc_count_status != ESP_GATT_OK) { - this->log_connection_error_("esp_ble_gattc_get_attr_count", desc_count_status); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - if (total_desc_count == 0) { - continue; - } - - // Initialize FixedVector with exact count and process descriptors - characteristic_resp.descriptors.init(total_desc_count); - uint16_t desc_offset = 0; - esp_gattc_descr_elem_t desc_result; - while (desc_offset < total_desc_count) { // descriptors - uint16_t desc_count = 1; - esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr( - this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset); - if (desc_status == ESP_GATT_INVALID_OFFSET || desc_status == ESP_GATT_NOT_FOUND) { - break; - } - if (desc_status != ESP_GATT_OK) { - this->log_connection_error_("esp_ble_gattc_get_all_descr", desc_status); - this->send_service_ = DONE_SENDING_SERVICES; - return; - } - if (desc_count == 0) { - break; // No more descriptors - } - - characteristic_resp.descriptors.emplace_back(); - auto &descriptor_resp = characteristic_resp.descriptors.back(); - fill_gatt_uuid(descriptor_resp.uuid, descriptor_resp.short_uuid, ESPBTUUID::from_uuid(desc_result.uuid), - use_efficient_uuids); - descriptor_resp.handle = desc_result.handle; - desc_offset++; - } - } - } // end if (total_char_count > 0) - - if (close_service_batch(resp, current_size, this->send_service_, this->connection_index_, this->address_str()) != - BatchClose::CONTINUE) { - break; - } - } - - // Send the message with dynamically batched services; on a failed send, - // rewind the cursor so the batch is retried instead of silently skipped. - if (!api_conn->send_message(resp)) { - ESP_LOGW(TAG, "[%d] [%s] Failed to send service batch, retrying", this->connection_index_, this->address_str_); - this->send_service_ = batch_start; - } -} - -void BluetoothConnection::log_connection_error_(const char *operation, esp_gatt_status_t status) { - ESP_LOGE(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str(), operation, status); -} - -void BluetoothConnection::log_connection_warning_(const char *operation, esp_err_t err) { - ESP_LOGW(TAG, "[%d] [%s] %s failed, err=%d", this->connection_index_, this->address_str(), operation, err); -} - -void BluetoothConnection::log_gatt_not_connected_(const char *action, const char *type) { - ESP_LOGW(TAG, "[%d] [%s] Cannot %s GATT %s, not connected.", this->connection_index_, this->address_str(), action, - type); -} - -void BluetoothConnection::log_gatt_operation_error_(const char *operation, uint16_t handle, esp_gatt_status_t status) { - ESP_LOGW(TAG, "[%d] [%s] Error %s for handle 0x%2X, status=%d", this->connection_index_, this->address_str(), - operation, handle, status); -} - -esp_err_t BluetoothConnection::check_and_log_error_(const char *operation, esp_err_t err) { - if (err != ESP_OK) { - this->log_connection_warning_(operation, err); - return err; - } - return ESP_OK; -} - -bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, - esp_ble_gattc_cb_param_t *param) { - if (!BLEClientBase::gattc_event_handler(event, gattc_if, param)) - return false; - - switch (event) { - case ESP_GATTC_DISCONNECT_EVT: { - // Don't reset connection yet - wait for CLOSE_EVT to ensure controller has freed resources - // This prevents race condition where we mark slot as free before controller cleanup is complete - ESP_LOGD(TAG, "[%d] [%s] Disconnect, reason=0x%02x", this->connection_index_, this->address_str_, - param->disconnect.reason); - // Send disconnection notification but don't free the slot yet - this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason); - break; - } - case ESP_GATTC_OPEN_EVT: { - if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { - this->reset_connection_(param->open.status); - } else if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { - this->proxy_->send_device_connection(this->address_, true, this->mtu_); - this->proxy_->send_connections_free(); - } - this->seen_mtu_or_services_ = false; - break; - } - case ESP_GATTC_CFG_MTU_EVT: - case ESP_GATTC_SEARCH_CMPL_EVT: { - if (!this->seen_mtu_or_services_) { - // We don't know if we will get the MTU or the services first, so - // only send the device connection true if we have already received - // the services. - this->seen_mtu_or_services_ = true; - break; - } - this->proxy_->send_device_connection(this->address_, true, this->mtu_); - this->proxy_->send_connections_free(); - break; - } - case ESP_GATTC_READ_DESCR_EVT: - case ESP_GATTC_READ_CHAR_EVT: { - if (param->read.status != ESP_GATT_OK) { - this->log_gatt_operation_error_("reading char/descriptor", param->read.handle, param->read.status); - this->proxy_->send_gatt_error(this->address_, param->read.handle, param->read.status); - break; - } - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - break; - api::BluetoothGATTReadResponse resp; - resp.address = this->address_; - resp.handle = param->read.handle; - resp.set_data(param->read.value, param->read.value_len); - api_connection->send_message(resp); - break; - } - case ESP_GATTC_WRITE_CHAR_EVT: - case ESP_GATTC_WRITE_DESCR_EVT: { - if (param->write.status != ESP_GATT_OK) { - this->log_gatt_operation_error_("writing char/descriptor", param->write.handle, param->write.status); - this->proxy_->send_gatt_error(this->address_, param->write.handle, param->write.status); - break; - } - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - break; - api::BluetoothGATTWriteResponse resp; - resp.address = this->address_; - resp.handle = param->write.handle; - api_connection->send_message(resp); - break; - } - case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: { - if (param->unreg_for_notify.status != ESP_GATT_OK) { - this->log_gatt_operation_error_("unregistering notifications", param->unreg_for_notify.handle, - param->unreg_for_notify.status); - this->proxy_->send_gatt_error(this->address_, param->unreg_for_notify.handle, param->unreg_for_notify.status); - break; - } - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - break; - api::BluetoothGATTNotifyResponse resp; - resp.address = this->address_; - resp.handle = param->unreg_for_notify.handle; - api_connection->send_message(resp); - break; - } - case ESP_GATTC_REG_FOR_NOTIFY_EVT: { - if (param->reg_for_notify.status != ESP_GATT_OK) { - this->log_gatt_operation_error_("registering notifications", param->reg_for_notify.handle, - param->reg_for_notify.status); - this->proxy_->send_gatt_error(this->address_, param->reg_for_notify.handle, param->reg_for_notify.status); - break; - } - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - break; - api::BluetoothGATTNotifyResponse resp; - resp.address = this->address_; - resp.handle = param->reg_for_notify.handle; - api_connection->send_message(resp); - break; - } - case ESP_GATTC_NOTIFY_EVT: { - ESP_LOGV(TAG, "[%d] [%s] ESP_GATTC_NOTIFY_EVT: handle=0x%2X", this->connection_index_, this->address_str_, - param->notify.handle); - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - break; - api::BluetoothGATTNotifyDataResponse resp; - resp.address = this->address_; - resp.handle = param->notify.handle; - resp.set_data(param->notify.value, param->notify.value_len); - api_connection->send_message(resp); - break; - } - default: - break; - } - return true; -} - -void BluetoothConnection::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { - BLEClientBase::gap_event_handler(event, param); - - switch (event) { - case ESP_GAP_BLE_AUTH_CMPL_EVT: - if (memcmp(param->ble_security.auth_cmpl.bd_addr, this->remote_bda_, 6) != 0) - break; - if (param->ble_security.auth_cmpl.success) { - this->proxy_->send_device_pairing(this->address_, true); - } else { - this->proxy_->send_device_pairing(this->address_, false, param->ble_security.auth_cmpl.fail_reason); - } - break; - default: - break; - } -} - -esp_err_t BluetoothConnection::read_characteristic(uint16_t handle) { - if (!this->connected()) { - this->log_gatt_not_connected_("read", "characteristic"); - return GATT_NOT_CONNECTED; - } - - ESP_LOGV(TAG, "[%d] [%s] Reading GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); - - esp_err_t err = esp_ble_gattc_read_char(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE); - return this->check_and_log_error_("esp_ble_gattc_read_char", err); -} - -esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8_t *data, size_t length, - bool response) { - if (!this->connected()) { - this->log_gatt_not_connected_("write", "characteristic"); - return GATT_NOT_CONNECTED; - } - ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); - - // ESP-IDF's API requires a non-const uint8_t* but it doesn't modify the data - // The BTC layer immediately copies the data to its own buffer (see btc_gattc.c) - // const_cast is safe here and was previously hidden by a C-style cast - esp_err_t err = - esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, length, const_cast(data), - response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - return this->check_and_log_error_("esp_ble_gattc_write_char", err); -} - -esp_err_t BluetoothConnection::read_descriptor(uint16_t handle) { - if (!this->connected()) { - this->log_gatt_not_connected_("read", "descriptor"); - return GATT_NOT_CONNECTED; - } - ESP_LOGV(TAG, "[%d] [%s] Reading GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); - - esp_err_t err = esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE); - return this->check_and_log_error_("esp_ble_gattc_read_char_descr", err); -} - -esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response) { - if (!this->connected()) { - this->log_gatt_not_connected_("write", "descriptor"); - return GATT_NOT_CONNECTED; - } - ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); - - // ESP-IDF's API requires a non-const uint8_t* but it doesn't modify the data - // The BTC layer immediately copies the data to its own buffer (see btc_gattc.c) - // const_cast is safe here and was previously hidden by a C-style cast - esp_err_t err = esp_ble_gattc_write_char_descr( - this->gattc_if_, this->conn_id_, handle, length, const_cast(data), - response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); - return this->check_and_log_error_("esp_ble_gattc_write_char_descr", err); -} - -esp_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enable) { - if (!this->connected()) { - this->log_gatt_not_connected_("notify", "characteristic"); - return GATT_NOT_CONNECTED; - } - - if (enable) { - ESP_LOGV(TAG, "[%d] [%s] Registering for GATT characteristic notifications handle %d", this->connection_index_, - this->address_str_, handle); - esp_err_t err = esp_ble_gattc_register_for_notify(this->gattc_if_, this->remote_bda_, handle); - return this->check_and_log_error_("esp_ble_gattc_register_for_notify", err); - } - - ESP_LOGV(TAG, "[%d] [%s] Unregistering for GATT characteristic notifications handle %d", this->connection_index_, - this->address_str_, handle); - esp_err_t err = esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle); - return this->check_and_log_error_("esp_ble_gattc_unregister_for_notify", err); -} - -} // namespace esphome::bluetooth_connection - -#endif // USE_ESP32 diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h b/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h deleted file mode 100644 index fb60d93e9c..0000000000 --- a/esphome/components/bluetooth_connection/bluetooth_connection_esp32.h +++ /dev/null @@ -1,76 +0,0 @@ -#pragma once - -#include "esphome/core/defines.h" - -#ifdef USE_ESP32 - -#include "esphome/components/esp32_ble_client/ble_client_base.h" - -#include "bluetooth_connection.h" - -namespace esphome::bluetooth_proxy { -class BluetoothProxy; -} // namespace esphome::bluetooth_proxy - -namespace esphome::bluetooth_connection { - -class BluetoothConnection final : public esp32_ble_client::BLEClientBase { - public: - void dump_config() override; - void loop() override; - bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, - esp_ble_gattc_cb_param_t *param) override; - void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; - // The proxy's connections never consume parsed ESPBTDevice objects. - bool wants_parsed_advertisements() override { return false; } - - esp_err_t read_characteristic(uint16_t handle); - esp_err_t write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response); - esp_err_t read_descriptor(uint16_t handle); - esp_err_t write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool response); - - esp_err_t notify_characteristic(uint16_t handle, bool enable); - - esp_err_t update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout) { - return this->update_conn_params_(min_interval, max_interval, latency, timeout, "custom"); - } - - bool has_gatt_services() const { return this->service_count_ != 0; } - - /// Start connecting: record the API address type and hand the client to the - /// tracker's promote loop (it pauses the scan and opens the connection). - void initiate_connection(uint8_t address_type) { - this->set_remote_addr_type(static_cast(address_type)); - this->set_state(esp32_ble_tracker::ClientState::DISCOVERED); - } - - void set_address(uint64_t address) override; - - protected: - friend class bluetooth_proxy::BluetoothProxy; - - void on_disconnect_complete(esp_err_t reason) override; - - void send_service_for_discovery_(); - void reset_connection_(esp_err_t reason); - void log_connection_error_(const char *operation, esp_gatt_status_t status); - void log_connection_warning_(const char *operation, esp_err_t err); - void log_gatt_not_connected_(const char *action, const char *type); - void log_gatt_operation_error_(const char *operation, uint16_t handle, esp_gatt_status_t status); - esp_err_t check_and_log_error_(const char *operation, esp_err_t err); - - // Memory optimized layout for 32-bit systems - // Group 1: Pointers (4 bytes each, naturally aligned) - bluetooth_proxy::BluetoothProxy *proxy_; - - // Group 2: 2-byte types - int16_t send_service_{INIT_SENDING_SERVICES}; // see bluetooth_connection.h cursor states - - // Group 3: 1-byte types - bool seen_mtu_or_services_{false}; - // 1 byte used, 1 byte padding -}; - -} // namespace esphome::bluetooth_connection - -#endif // USE_ESP32 diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h b/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h index d8792b88c1..3c982d81ae 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h @@ -15,19 +15,21 @@ #if defined(USE_RP2040_BLE) #include "bluetooth_connection_rp2.h" #define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::RP2GattClient +#elif defined(USE_ESP32_BLE) +#include "bluetooth_connection_bluedroid.h" +#define ESPHOME_BLE_GATT_CONNECTION_TYPE bluetooth_connection::BluedroidGattClient #elif defined(USE_BLE_GATT_CLIENT_STUB_BACKEND) // Emitted only by the host unit-test manifest: the tests compile the hub // wrapper standalone, so bind a do-nothing backend. Every other backend-less // build hits the #error below. namespace esphome::bluetooth_connection { -class BluetoothConnection; - class StubGattBackend { public: - void set_listener(BluetoothConnection *listener) {} + void set_listener(ble_device_base::GattClientListener *listener) {} int connect(uint64_t address, uint8_t addr_type) { return ble_device_base::GATT_ERR_NOT_CONNECTED; } - int disconnect() { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + int gatt_disconnect() { return ble_device_base::GATT_ERR_NOT_CONNECTED; } + bool cancel_gatt_disconnect() { return false; } int discover_services() { return ble_device_base::GATT_ERR_NOT_CONNECTED; } int read_characteristic(uint16_t handle) { return ble_device_base::GATT_ERR_NOT_CONNECTED; } int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { @@ -43,6 +45,7 @@ class StubGattBackend { return ble_device_base::GATT_ERR_NOT_CONNECTED; } ble_device_base::GattServiceTable get_service_table() { return {}; } + void set_connection_type(ble_device_base::ConnectionType ct) {} void release_services() {} }; @@ -55,7 +58,7 @@ class StubGattBackend { namespace esphome::ble_device_base { using BLEGattConnection = ESPHOME_BLE_GATT_CONNECTION_TYPE; -static_assert(BLEGattConnectionContract, +static_assert(BLEGattConnectionContract, "The build's GATT backend is missing part of the BLEGattConnection surface (ble_gatt_client.h)"); #undef ESPHOME_BLE_GATT_CONNECTION_TYPE diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index ec03f18e1d..b913bb9a55 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -1,7 +1,7 @@ -// Hub-platform connection wrapper (USE_RP2 hub builds today). +// The proxy's per-slot connection wrapper, shared by every platform. #include "bluetooth_connection_hub.h" -#if !defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) +#ifdef BLUETOOTH_CONNECTION_HAS_GATT #include "esphome/components/api/api_pb2.h" #include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" @@ -26,11 +26,11 @@ void BluetoothConnection::set_address(uint64_t address) { format_mac_addr_upper(mac, this->address_str_); } -void BluetoothConnection::start_connect_() { - // No connect timeout here (esp32 parity): the client's own timeout or - // the api-gone sweep drives disconnect(). +void BluetoothConnection::initiate_connection(uint8_t address_type) { + // No connect timeout here: the API client's own timeout or the api-gone + // sweep drives disconnect(). this->state_ = ClientState::CONNECTING; - int err = this->backend_->connect(this->address_, this->remote_addr_type_); + int err = this->backend_->connect(this->address_, address_type); if (err != 0) { ESP_LOGW(TAG, "[%d] [%s] connect failed, err=%d", this->connection_index_, this->address_str_, err); this->reset_connection_(err); @@ -38,40 +38,21 @@ void BluetoothConnection::start_connect_() { } void BluetoothConnection::disconnect() { - // Idempotent like the esp32 class: the proxy's teardown loop calls this - // every 100 ms while the API subscriber is gone, and a repeat call must not - // reach the backend (whose busy error would free the slot mid-teardown). + // Idempotent: the proxy's teardown loop calls this every 100 ms while the + // API subscriber is gone, and a repeat call reaching the backend would + // re-arm its teardown timer so the safety timeout never fires. if (this->state_ == ClientState::IDLE || this->state_ == ClientState::DISCONNECTING) { return; } - int err = this->backend_->disconnect(); - if (err == GATT_NOT_CONNECTED) { - // Backend already idle: free the slot so the client is not stuck. - ESP_LOGW(TAG, "[%d] [%s] disconnect while backend idle", this->connection_index_, this->address_str_); + int err = this->backend_->gatt_disconnect(); + if (err != 0) { + // Nonzero means nothing to tear down (both backends): free the slot. + // Accepted teardowns always reach a terminal report. + ESP_LOGW(TAG, "[%d] [%s] disconnect while backend idle, err=%d", this->connection_index_, this->address_str_, err); this->reset_connection_(err); return; } - if (err != 0) { - // Transient refusal: stay DISCONNECTING and let the safety timeout - // arbitrate rather than freeing a slot whose teardown is unresolved. - // Latch the refusal unless a GATT cause is already recorded (first wins). - ESP_LOGW(TAG, "[%d] [%s] disconnect failed, err=%d", this->connection_index_, this->address_str_, err); - if (this->pending_error_ == 0) { - this->pending_error_ = err; - } - } this->state_ = ClientState::DISCONNECTING; - this->disconnecting_started_ = millis(); -} - -void BluetoothConnection::check_disconnect_timeout_() { - // Safety net mirroring the esp32 base class: if the backend's disconnect - // completion is lost, force the slot free instead of leaking it. - static constexpr uint32_t DISCONNECT_TIMEOUT_MS = 10000; - if (this->state_ == ClientState::DISCONNECTING && millis() - this->disconnecting_started_ > DISCONNECT_TIMEOUT_MS) { - ESP_LOGW(TAG, "[%d] [%s] Disconnect timeout, freeing slot", this->connection_index_, this->address_str_); - this->reset_connection_(GATT_NOT_CONNECTED); - } } void BluetoothConnection::on_pairing_result(int status) { @@ -96,32 +77,24 @@ void BluetoothConnection::reset_connection_(conn_err_t reason) { this->proxy_->reset_connection_slot_(this, reason); } -// ---- backend event sink ---- +// ---- backend event listener ---- void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int error) { if (connected && this->address_ == 0) { // Late completion for a slot that was already freed: nothing to report, // and the api-gone sweep or a new reservation owns the slot now. - int err = this->backend_->disconnect(); - if (err != 0 && err != GATT_NOT_CONNECTED) { - // Log only: re-arming a freed slot could clobber a new reservation. - ESP_LOGW(TAG, "[%d] freed-slot disconnect refused, err=%d", this->connection_index_, err); - } + // Return ignored: nonzero just means the backend was already idle, and + // re-arming a freed slot could clobber a new reservation. + this->backend_->gatt_disconnect(); return; } if (connected && this->state_ == ClientState::DISCONNECTING) { // The link came up after a disconnect request won the race; finish the // teardown instead of reporting a connection the client no longer wants. - int err = this->backend_->disconnect(); - // Fresh teardown attempt: give it the full safety window. - this->disconnecting_started_ = millis(); - if (err == GATT_NOT_CONNECTED) { + int err = this->backend_->gatt_disconnect(); + if (err != 0) { // Nothing left to tear down after all. this->reset_connection_(err); - } else if (err != 0) { - // Transient refusal while the link is up: keep DISCONNECTING and let - // the safety timeout arbitrate (same policy as disconnect()). - ESP_LOGW(TAG, "[%d] [%s] teardown disconnect failed, err=%d", this->connection_index_, this->address_str_, err); } return; } @@ -130,7 +103,10 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) { // The API client has the services cached; never discover them. No // discovery phase needs the fast interval, so settle straight into the - // shared steady-state parameters (same lifecycle place as esp32). + // shared steady-state parameters. On esp32 the backend already set the + // same values as prefer-params before opening, so this request is + // usually redundant there - kept because rp2 has no prefer-params and + // the explicit update is its only path to the steady-state interval. this->state_ = ClientState::ESTABLISHED; int param_err = this->backend_->update_connection_params(ble_device_base::MEDIUM_MIN_CONN_INTERVAL, ble_device_base::MEDIUM_MAX_CONN_INTERVAL, 0, @@ -145,14 +121,13 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int return; } // V3_WITHOUT_CACHE: discover services first — the connected response is - // sent when discovery completes, mirroring the esp32 flow (MTU + services - // before the response). + // sent when discovery completes (MTU + services before the response). this->state_ = ClientState::CONNECTED; int err = this->backend_->discover_services(); if (err != 0) { ESP_LOGW(TAG, "[%d] [%s] discover_services failed, err=%d", this->connection_index_, this->address_str_, err); // Latch the real cause for the disconnect report. - this->pending_error_ = err; + this->latch_pending_error_(err); this->disconnect(); } return; @@ -171,7 +146,7 @@ void BluetoothConnection::on_service_discovery_done(int error) { ESP_LOGW(TAG, "[%d] [%s] Service discovery failed, err=%d", this->connection_index_, this->address_str_, error); // Carry the GATT error into the disconnection report so the client sees // the real cause instead of a generic HCI reason. - this->pending_error_ = error; + this->latch_pending_error_(error); this->disconnect(); return; } @@ -334,9 +309,9 @@ void BluetoothConnection::send_service_for_discovery_() { } // The subscriber vanished mid-stream: park the cursor at done WITHOUT - // sending services-done (esp32 parity — a resubscribing client gets - // silence and its 30 s timeout, never an authoritative partial list) and - // free the table; the api-gone sweep tears the connection down anyway. + // sending services-done (a resubscribing client gets silence and its 30 s + // timeout, never an authoritative partial list) and free the table; the + // api-gone sweep tears the connection down anyway. auto *api_conn = this->proxy_->get_api_connection(); if (api_conn == nullptr) { ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", this->connection_index_, @@ -380,8 +355,7 @@ void BluetoothConnection::send_service_for_discovery_() { if (char_count != 0 && service.first_characteristic + char_count > table.characteristic_count) { ESP_LOGE(TAG, "[%d] [%s] Characteristic range out of bounds (service %d), aborting stream", this->connection_index_, this->address_str_, this->send_service_); - this->send_service_ = DONE_SENDING_SERVICES; - this->disconnect(); + this->abort_service_stream(ble_device_base::GATT_ERR_UNLIKELY); return; } if (char_count > 0) { @@ -397,8 +371,7 @@ void BluetoothConnection::send_service_for_discovery_() { if (desc_count != 0 && chr.first_descriptor + desc_count > table.descriptor_count) { ESP_LOGE(TAG, "[%d] [%s] Descriptor range out of bounds (service %d), aborting stream", this->connection_index_, this->address_str_, this->send_service_); - this->send_service_ = DONE_SENDING_SERVICES; - this->disconnect(); + this->abort_service_stream(ble_device_base::GATT_ERR_UNLIKELY); return; } if (desc_count == 0) { @@ -433,4 +406,4 @@ void BluetoothConnection::send_service_for_discovery_() { } // namespace esphome::bluetooth_connection -#endif // !USE_ESP32 && USE_BLE_GATT_CLIENT +#endif // BLUETOOTH_CONNECTION_HAS_GATT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index e79ee9e7a8..82d9ae7db4 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -1,17 +1,17 @@ -// Hub-platform BluetoothConnection: drives the build's GATT backend (the +// BluetoothConnection: drives the build's GATT backend (the // ble_device_base::BLEGattConnection alias) and translates its events into -// the same API messages the esp32 class emits. -// Presents the identical method surface, so the proxy's GATT dispatch -// compiles against either class unchanged. +// the proxy's API messages. One wrapper for every platform; per-backend +// differences live behind the alias and the streamer cut-through. #pragma once -#include "esphome/core/defines.h" - -#if !defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) - #include "bluetooth_connection.h" +// The wrapper exists to serve the proxy's API surface; direct consumers +// drive the backend themselves, so backend-only builds compile this header +// empty. +#ifdef BLUETOOTH_CONNECTION_HAS_GATT + #include "esphome/components/ble_device_base/ble_client_state.h" #include "bluetooth_connection_gatt_backend.h" #include "esphome/core/helpers.h" @@ -25,7 +25,7 @@ namespace esphome::bluetooth_connection { using ClientState = ble_device_base::ClientState; using ConnectionType = ble_device_base::ConnectionType; -class BluetoothConnection final { +class BluetoothConnection final : public ble_device_base::GattClientListener { public: /// Wire the platform backend. Called from codegen before setup. void set_backend(ble_device_base::BLEGattConnection *backend) { @@ -33,7 +33,7 @@ class BluetoothConnection final { backend->set_listener(this); } - // ---- proxy dispatch surface (mirrors the esp32 class) ---- + // ---- proxy dispatch surface ---- conn_err_t read_characteristic(uint16_t handle); conn_err_t write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response); conn_err_t read_descriptor(uint16_t handle); @@ -41,21 +41,31 @@ class BluetoothConnection final { conn_err_t notify_characteristic(uint16_t handle, bool enable); conn_err_t update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout); - /// Start connecting: record the API address type (BLE_ADDR_TYPE_* code - /// space) and open the connection through the backend. Failures report - /// through the same reset path a failed open takes on esp32. - void initiate_connection(uint8_t address_type) { - this->remote_addr_type_ = address_type; - this->start_connect_(); + /// Streamer abort: latch the GATT cause, park the cursor, tear down. + void abort_service_stream(conn_err_t err) { + this->latch_pending_error_(err); + this->send_service_ = DONE_SENDING_SERVICES; + this->disconnect(); } + + /// Start connecting with the API address type (BLE_ADDR_TYPE_* code + /// space). Failures report through the same reset path a failed open + /// takes. + void initiate_connection(uint8_t address_type); void disconnect(); + /// A connect request racing a scheduled teardown: true when the backend + /// had not started closing - the in-flight open resumes and reports + /// connected. False once the teardown owns the link. + bool cancel_teardown() { + if (this->state_ == ClientState::DISCONNECTING && this->backend_->cancel_gatt_disconnect()) { + this->state_ = ClientState::CONNECTING; + return true; + } + return false; + } bool is_paired() const { return this->paired_; } void set_unpaired() { this->paired_ = false; } conn_err_t pair() { return this->backend_->pair(); } - // A backend disconnect() is a single call that also cancels an in-progress - // connect; there is no deferred-disconnect state to track. - bool disconnect_pending() const { return false; } - void cancel_pending_disconnect() {} void set_address(uint64_t address); uint64_t get_address() const { return this->address_; } @@ -65,39 +75,58 @@ class BluetoothConnection final { ClientState state() const { return this->state_; } void set_state(ClientState st) { this->state_ = st; } bool connected() const { return this->state_ == ClientState::ESTABLISHED; } - void set_connection_type(ConnectionType ct) { this->connection_type_ = ct; } + void set_connection_type(ConnectionType ct) { + this->connection_type_ = ct; + // The bluedroid backend branches on the type itself (prefer-params and + // the with-cache report at OPEN_EVT); the others ignore it. + this->backend_->set_connection_type(ct); + } // Latched at discovery completion rather than read from the backend table: // streaming frees the table, and this must stay true for the connection's - // lifetime (esp32 parity — a repeat GetServices is silently ignored there, - // never answered with an authoritative empty database). + // lifetime (a repeat GetServices is silently ignored, never answered with + // an authoritative empty database). bool has_gatt_services() const { return this->services_discovered_; } - /// Stream any pending service-discovery batch and police the disconnect - /// safety timeout. Called from the proxy's loop — hub connections have no - /// Component loop of their own (the esp32 class streams from its own - /// loop() and has the same 10 s safety net in its base class). + /// Stream any pending service-discovery batch (proxy loop; the backend + /// owns the disconnect safety timer). void process_pending_services() { if (this->send_service_ >= 0) { - this->send_service_for_discovery_(); + this->stream_pending_(this->backend_); } - this->check_disconnect_timeout_(); } - // ---- backend event sink (called directly by the backend, main loop) ---- - void on_connection_state(bool connected, uint16_t mtu, int error); - void on_service_discovery_done(int error); - void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error); - void on_write_result(uint16_t handle, int error); - void on_notify_state(uint16_t handle, bool enabled, int error); - void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len); - void on_pairing_result(int status); + // ---- backend event listener (called directly by the backend, main loop) ---- + void on_connection_state(bool connected, uint16_t mtu, int error) override; + void on_service_discovery_done(int error) override; + void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) override; + void on_write_result(uint16_t handle, int error) override; + void on_notify_state(uint16_t handle, bool enabled, int error) override; + void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override; + void on_pairing_result(int status) override; protected: friend class bluetooth_proxy::BluetoothProxy; + // The Bluedroid backend streams services in place from its stack cache. + friend class BluedroidGattClient; - void start_connect_(); + /// First cause wins: a later, less specific error must not overwrite it. + void latch_pending_error_(conn_err_t err) { + if (this->pending_error_ == 0) { + this->pending_error_ = err; + } + } + // A backend providing its own streamer (see the contract doc) builds the + // response in place from its stack cache; the rest use the table streamer. + // Template so the discarded branch is not odr-checked against backends + // that lack the method. + template void stream_pending_(Backend *backend) { + if constexpr (requires { backend->stream_service_batch(*this); }) { + backend->stream_service_batch(*this); + } else { + this->send_service_for_discovery_(); + } + } void send_service_for_discovery_(); - void check_disconnect_timeout_(); void reset_connection_(conn_err_t reason); conn_err_t check_connected_op_(const char *action, const char *type) const; void log_gatt_operation_error_(const char *operation, uint16_t handle, int status); @@ -109,28 +138,26 @@ class BluetoothConnection final { // Group 2: 2-byte types int16_t send_service_{INIT_SENDING_SERVICES}; - uint16_t mtu_{23}; + uint16_t mtu_{ble_device_base::DEFAULT_ATT_MTU}; // Group 3: 8-byte and 4-byte types uint64_t address_{0}; - uint32_t disconnecting_started_{0}; conn_err_t pending_error_{0}; // Group 4: Arrays char address_str_[MAC_ADDRESS_PRETTY_BUFFER_SIZE]{}; - // Group 5: 1-byte types - ClientState state_{ClientState::IDLE}; - bool paired_{false}; - ConnectionType connection_type_{ConnectionType::V1}; - uint8_t remote_addr_type_{0}; - uint8_t connection_index_{0}; - bool services_discovered_{false}; + // Group 5: bit-packed tail; within 2 bytes the 8-aligned object stays 48. + static_assert(static_cast(ClientState::ESTABLISHED) < (1 << 3), "state_ bitfield too narrow"); + static_assert(static_cast(ConnectionType::V3_WITHOUT_CACHE) < (1 << 2), + "connection_type_ bitfield too narrow"); + ClientState state_ : 3 {ClientState::IDLE}; + bool paired_ : 1 {false}; + ConnectionType connection_type_ : 2 {ConnectionType::V1}; + uint8_t connection_index_ : 4 {0}; + bool services_discovered_ : 1 {false}; }; -static_assert(ble_device_base::GattClientEventSinkContract, - "The hub wrapper is missing part of the event-sink surface (ble_gatt_client.h)"); - } // namespace esphome::bluetooth_connection -#endif // !USE_ESP32 && USE_BLE_GATT_CLIENT +#endif // BLUETOOTH_CONNECTION_HAS_GATT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index dc730659f5..dc77d448a5 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -1,6 +1,5 @@ #include "bluetooth_connection_rp2.h" -#include "bluetooth_connection_hub.h" #include "bluetooth_connection.h" #if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) @@ -26,7 +25,6 @@ using ble_device_base::GATT_ERR_NO_MEMORY; // and keeps the scan inhibited, so the engine cancels after 20 s. The // disconnect timeout mirrors the esp32 CLOSE_EVT safety net. static constexpr uint32_t CONNECT_TIMEOUT_MS = 20000; -static constexpr uint32_t DISCONNECT_TIMEOUT_MS = 10000; // Can-send windows normally open within a connection interval (tens of ms). static constexpr uint32_t WRITE_NO_RSP_TIMEOUT_MS = 500; @@ -384,7 +382,7 @@ void RP2GattClient::loop() { RP2GattNotifyEvent *notify; while ((notify = this->notify_queue_.pop()) != nullptr) { - if (this->listener_ != nullptr && this->notify_subscribed_(notify->handle)) { + if (this->notify_subscribed_(notify->handle)) { this->listener_->on_notify_data(notify->handle, notify->data, notify->len); } this->notify_pool_.release(notify); @@ -395,7 +393,7 @@ void RP2GattClient::loop() { // Control events must not be lost; the connection state is no longer // trustworthy — recover with a forced teardown. ESP_LOGE(TAG, "Dropped %u GATT control events, disconnecting", dropped); - this->disconnect(); + this->gatt_disconnect(); } uint16_t notify_dropped = this->notify_queue_.get_and_reset_dropped_count(); if (notify_dropped > 0) { @@ -426,11 +424,11 @@ void RP2GattClient::loop() { // reclaims state if the disconnection event is lost. Dropping engine // state without gap_disconnect would leak the live link and the // single GATT slot for the rest of the boot. - this->disconnect(); + this->gatt_disconnect(); } } } else if (this->state_ == EngineState::DISCONNECTING) { - if (millis() - this->disconnecting_started_ > DISCONNECT_TIMEOUT_MS) { + if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) { ESP_LOGW(TAG, "Disconnect timeout, forcing idle"); this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT); } @@ -448,9 +446,7 @@ void RP2GattClient::loop() { } if (timed_out) { ESP_LOGW(TAG, "Deferred write timeout, handle=0x%04x", this->op_handle_); - if (this->listener_ != nullptr) { - this->listener_->on_write_result(this->op_handle_, GATT_CLIENT_BUSY); - } + this->listener_->on_write_result(this->op_handle_, GATT_CLIENT_BUSY); } } else if (this->state_ == EngineState::IDLE || (this->state_ == EngineState::READY && !this->op_in_flight_() && this->event_queue_.empty() && this->notify_queue_.empty())) { @@ -474,9 +470,7 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) { this->state_ = EngineState::READY; // Scanning resumes and runs alongside the established connection. this->release_scan_inhibit_(); - if (this->listener_ != nullptr) { - this->listener_->on_connection_state(true, this->mtu_, 0); - } + this->listener_->on_connection_state(true, this->mtu_, 0); } break; case RP2GattEvent::QUERY_COMPLETE: @@ -486,9 +480,7 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) { this->finish_write_no_rsp_(event.status); break; case RP2GattEvent::PAIRING_RESULT: - if (this->listener_ != nullptr) { - this->listener_->on_pairing_result(event.status); - } + this->listener_->on_pairing_result(event.status); break; } } @@ -514,9 +506,7 @@ void RP2GattClient::finish_write_no_rsp_(uint8_t status) { return; } this->op_type_ = OpType::NONE; - if (this->listener_ != nullptr) { - this->listener_->on_write_result(this->op_handle_, status); - } + this->listener_->on_write_result(this->op_handle_, status); } void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) { @@ -576,9 +566,7 @@ void RP2GattClient::fail_connection_(uint8_t reason) { this->cleanup_link_state_(); this->release_scan_inhibit_(); this->state_ = EngineState::IDLE; - if (this->listener_ != nullptr) { - this->listener_->on_connection_state(false, 0, reason); - } + this->listener_->on_connection_state(false, 0, reason); } void RP2GattClient::cleanup_link_state_() { @@ -621,9 +609,6 @@ void RP2GattClient::handle_query_complete_(uint8_t att_status) { if (this->op_type_ != OpType::NONE && this->op_type_ != OpType::WRITE_CHAR_NO_RSP) { OpType op = this->op_type_; this->op_type_ = OpType::NONE; - if (this->listener_ == nullptr) { - return; - } switch (op) { case OpType::READ_CHAR: case OpType::READ_DESC: @@ -796,9 +781,7 @@ void RP2GattClient::finish_discovery_(int error) { if (error != 0) { this->release_services(); } - if (this->listener_ != nullptr) { - this->listener_->on_service_discovery_done(error); - } + this->listener_->on_service_discovery_done(error); } ble_device_base::GattServiceTable RP2GattClient::get_service_table() { @@ -873,7 +856,7 @@ int RP2GattClient::connect(uint64_t address, uint8_t addr_type) { return 0; } -int RP2GattClient::disconnect() { +int RP2GattClient::gatt_disconnect() { switch (this->state_) { case EngineState::IDLE: return GATT_ERR_NOT_CONNECTED; @@ -990,7 +973,7 @@ int RP2GattClient::write_characteristic(uint16_t handle, const uint8_t *data, ui return 0; } } - if (status == 0 && this->listener_ != nullptr) { + if (status == 0) { this->listener_->on_write_result(handle, 0); } return status; @@ -1092,9 +1075,7 @@ int RP2GattClient::notify_characteristic(uint16_t handle, bool enable) { } } } - if (this->listener_ != nullptr) { - this->listener_->on_notify_state(handle, enable, 0); - } + this->listener_->on_notify_state(handle, enable, 0); return 0; } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h index d5bf76e6ee..df43ebd66d 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h @@ -26,8 +26,6 @@ namespace esphome::bluetooth_connection { -class BluetoothConnection; - // Caps for the transient service table. Sized generously for real devices // (typical peripherals expose < 8 services / < 30 characteristics); a peer // exceeding a cap fails discovery with INSUFFICIENT_RESOURCES rather than @@ -80,11 +78,14 @@ class RP2GattClient final : public Component, public Parentedlistener_ = listener; } + void set_listener(ble_device_base::GattClientListener *listener) { this->listener_ = listener; } // ---- ble_device_base::BLEGattConnection contract ---- int connect(uint64_t address, uint8_t addr_type); - int disconnect(); + int gatt_disconnect(); + // Teardown starts inside gatt_disconnect() on this backend; nothing is + // ever scheduled, so there is nothing to cancel. + bool cancel_gatt_disconnect() { return false; } int discover_services(); int read_characteristic(uint16_t handle); int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response); @@ -94,6 +95,8 @@ class RP2GattClient final : public Component, public Parented event_queue_; esphome::EventPool event_pool_; @@ -174,7 +177,7 @@ class RP2GattClient final : public Component, public Parented list[str]: @@ -27,7 +33,7 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]: target platform set, so it takes one of the concrete branches. """ if CORE.is_esp32: - return ["bluetooth_connection", "esp32_ble_client", "esp32_ble_tracker"] + return ["bluetooth_connection", "esp32_ble_tracker"] if CORE.target_platform in _HUB_PLATFORMS: return ["ble_device_base", "bluetooth_connection"] # No target platform, or one this component does not support: tooling @@ -36,7 +42,6 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]: return [ "ble_device_base", "bluetooth_connection", - "esp32_ble_client", "esp32_ble_tracker", ] @@ -47,8 +52,9 @@ def AUTO_LOAD(config: ConfigType | None = None) -> list[str]: # Assistant) assumes an ESPHome proxy can scan actively, so a passive-only # proxy would be misdriven — bk72xx follows once the API carries a feature # flag clients can trust (FEATURE_ACTIVE_SCAN + a version flag, separate PRs). -# Coupled to bluetooth_connection: platforms with a GATT backend are also -# listed in its HUB_MAX_CONNECTIONS and its FILTER_SOURCE_FILES hub entry. +# Coupled to bluetooth_connection: platforms here are also listed in its +# _PLATFORM_BACKENDS registry, HUB_MAX_CONNECTIONS, and FILTER_SOURCE_FILES +# hub entry. _HUB_PLATFORMS = (PLATFORM_LN882X, PLATFORM_RP2) DEPENDENCIES = ["api"] @@ -59,7 +65,6 @@ _LOGGER = logging.getLogger(__name__) CONF_CONNECTION_SLOTS = "connection_slots" CONF_CACHE_SERVICES = "cache_services" CONF_CONNECTIONS = "connections" -CONF_BACKEND_ID = "backend_id" DEFAULT_CONNECTION_SLOTS = 3 bluetooth_proxy_ns = cg.esphome_ns.namespace("bluetooth_proxy") @@ -86,12 +91,7 @@ def _esp32_config_schema() -> cv.All: f"update _IDF_MAX_CONNECTIONS in bluetooth_proxy/__init__.py" ) - BluetoothConnection = bluetooth_connection.esp32_connection_class() - CONNECTION_SCHEMA = esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA.extend( - { - cv.GenerateID(): cv.declare_id(BluetoothConnection), - } - ).extend(cv.COMPONENT_SCHEMA) + CONNECTION_SCHEMA = bluetooth_connection.hub_connection_schema(PLATFORM_ESP32) def validate_connections(config): if CONF_CONNECTIONS in config: @@ -154,16 +154,7 @@ def _rp2_config_schema() -> cv.All: """Full proxy on the rp2 BLE hub: active connections through the BTstack GATT client backend in bluetooth_connection. The slot limit comes from the prebuilt BTstack library (one connection today); the code is built for N.""" - from esphome.components import rp2040_ble - - connection_schema = cv.Schema( - { - cv.GenerateID(): cv.declare_id(bluetooth_connection.HubBluetoothConnection), - cv.GenerateID(CONF_BACKEND_ID): cv.declare_id( - bluetooth_connection.RP2GattClient - ), - } - ) + connection_schema = bluetooth_connection.hub_connection_schema(PLATFORM_RP2) def populate_connections(config: ConfigType) -> ConfigType: # One wrapper + backend pair per slot, declared during validation so @@ -182,11 +173,6 @@ def _rp2_config_schema() -> cv.All: cv.Schema( { **_COMMON_SCHEMA_KEYS, - # The GATT backend drives the controller directly (connect, GATT - # ops), not through the tracker hub. - cv.GenerateID(rp2040_ble.CONF_RP2040_BLE_ID): cv.use_id( - rp2040_ble.RP2040BLE - ), cv.Optional(CONF_ACTIVE, default=True): cv.boolean, cv.Optional( CONF_CONNECTION_SLOTS, @@ -212,25 +198,25 @@ def _rp2_config_schema() -> cv.All: return cv.All(schema, populate_connections) -async def _rp2_connections_to_code(var: cg.MockObj, config: ConfigType) -> None: - from esphome.components import rp2040_ble - - # One wrapper + backend pair per slot (the esp32 arm's pattern). - for connection_conf in config[CONF_CONNECTIONS]: - ble_device_base.request_gatt_client() - backend = cg.new_Pvariable(connection_conf[CONF_BACKEND_ID]) - await cg.register_component(backend, connection_conf) - await cg.register_parented(backend, config[rp2040_ble.CONF_RP2040_BLE_ID]) +async def _connections_to_code(var: cg.MockObj, config: ConfigType) -> None: + """One wrapper + backend pair per slot; the platform-specific backend + registration lives in bluetooth_connection.new_gatt_backend().""" + connections = config.get(CONF_CONNECTIONS, []) + # The api component sizes BluetoothConnectionsFreeResponse.allocated with + # this define whenever a proxy is present (zero on advertisement-only + # hubs); sized here so it can never diverge from the loop below. + cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", len(connections)) + for connection_conf in connections: + backend = await bluetooth_connection.new_gatt_backend(connection_conf) connection = cg.new_Pvariable(connection_conf[CONF_ID]) cg.add(connection.set_backend(backend)) cg.add(var.register_connection(connection)) -# Per-platform schema builders and connection codegen; every key of -# bluetooth_connection.HUB_MAX_CONNECTIONS needs an entry in both (pinned by -# tests/component_tests/bluetooth_proxy/). +# Per-platform schema builders; every key of +# bluetooth_connection.HUB_MAX_CONNECTIONS needs an entry here (pinned by +# tests/component_tests/bluetooth_proxy/). Connection codegen is shared. _GATT_HUB_SCHEMAS = {PLATFORM_RP2: _rp2_config_schema} -_GATT_HUB_TO_CODE = {PLATFORM_RP2: _rp2_connections_to_code} # Keys every platform arm declares identically; each arm spreads this dict so @@ -381,15 +367,7 @@ async def _to_code_esp32(config: ConfigType) -> None: # registration into the proxy; the other hubs are polled instead. cg.add_define("USE_BLE_SCANNER_STATE_CALLBACK") - # Define max connections for protobuf fixed array - connection_count = len(config.get(CONF_CONNECTIONS, [])) - cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", connection_count) - - for connection_conf in config.get(CONF_CONNECTIONS, []): - connection_var = cg.new_Pvariable(connection_conf[CONF_ID]) - await cg.register_component(connection_var, connection_conf) - cg.add(var.register_connection(connection_var)) - await esp32_ble_tracker.register_raw_client(connection_var, connection_conf) + await _connections_to_code(var, config) if config.get(CONF_CACHE_SERVICES): add_idf_sdkconfig_option("CONFIG_BT_GATTC_CACHE_NVS_FLASH", True) @@ -403,16 +381,7 @@ async def _to_code_ble_hub(config: ConfigType) -> None: hub = await cg.get_variable(config[ble_device_base.CONF_BLE_HUB_ID]) cg.add(var.set_ble_hub(hub)) - # The api component sizes BluetoothConnectionsFreeResponse.allocated with - # this define whenever a proxy is present. Zero on advertisement-only hubs. - # Sized from the instantiated connections so the define can never diverge - # from the loop below (the define sizes fixed storage in the proxy). - slots = len(config.get(CONF_CONNECTIONS, ())) - cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", slots) - if not slots: - return - - await _GATT_HUB_TO_CODE[CORE.target_platform](var, config) + await _connections_to_code(var, config) async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 88d8cc1885..0a16567549 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -132,13 +132,6 @@ void BluetoothProxy::log_advertisement_flush_() { } void BluetoothProxy::dump_config() { -#ifdef USE_ESP32 - ESP_LOGCONFIG(TAG, - "Bluetooth Proxy:\n" - " Active: %s\n" - " Connections: %d", - YESNO(this->active_), this->connection_count_); -#else // Print configured facts. dump_config runs right after setup, before the // radio is up, so live scan state would always read "stopped" here — the // loop's BluetoothScannerStateResponse carries the changing value instead. @@ -162,32 +155,8 @@ void BluetoothProxy::dump_config() { " Adapter MAC: %s", scan_mode, mac_out); #endif -#endif } -#ifdef USE_ESP32 - -void BluetoothProxy::loop() { - // Run advertisement flush / connection cleanup every 100ms - uint32_t now = App.get_loop_component_start_time(); - if (now - this->last_advertisement_flush_time_ < 100) - return; - this->last_advertisement_flush_time_ = now; - - if (api::global_api_server->is_connected() && this->api_connection_ != nullptr) { - this->flush_pending_advertisements_(); - return; - } - for (uint8_t i = 0; i < this->connection_count_; i++) { - auto *connection = this->connections_[i]; - if (connection->get_address() != 0 && !connection->disconnect_pending()) { - connection->disconnect(); - } - } -} - -#endif // USE_ESP32 - #ifdef BLUETOOTH_CONNECTION_HAS_GATT // maybe_unused: in a passive proxy (active: false) MAX is 0, the body is removed, and connection is unused. @@ -200,11 +169,8 @@ void BluetoothProxy::register_connection([[maybe_unused]] BluetoothConnection *c ESP_LOGE(TAG, "Connection registry full, dropping registration"); return; } -#ifndef USE_ESP32 - // esp32 assigns connection_index_ in BLEClientBase::setup(); the hub - // class has no Component lifecycle, so the index is assigned here. + // The hub wrapper has no Component lifecycle, so the index is assigned here. connection->connection_index_ = this->connection_count_; -#endif this->connections_[this->connection_count_++] = connection; connection->proxy_ = this; #endif @@ -274,16 +240,13 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest this->send_device_connection(msg.address, true); this->send_connections_free(); return; - } else if (connection->state() == ClientState::CONNECTING) { - if (connection->disconnect_pending()) { - ESP_LOGW(TAG, "[%d] [%s] Connection request while pending disconnect, cancelling pending disconnect", - connection->get_connection_index(), connection->address_str()); - connection->cancel_pending_disconnect(); - return; - } - this->log_connection_request_ignored_(connection, connection->state()); + } else if (connection->state() == ClientState::DISCONNECTING && connection->cancel_teardown()) { + ESP_LOGW(TAG, "[%d] [%s] Connection request while pending disconnect, cancelling pending disconnect", + connection->get_connection_index(), connection->address_str()); return; } else if (connection->state() != ClientState::INIT) { + // Covers CONNECTING too: a repeat request during a connect attempt is + // ignored the same way. this->log_connection_request_ignored_(connection, connection->state()); return; } @@ -315,7 +278,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest break; } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR: { - // Both connection classes expose the same pairing surface; success is + // The connection wrapper exposes the pairing surface; success is // reported when the platform's pairing completion arrives. auto *connection = this->get_connection_(msg.address, false); if (connection != nullptr) { @@ -486,11 +449,33 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { #else // !USE_ESP32 +void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { + if (this->hub_->scan_active() != active) { + ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive"); + if (!this->hub_->request_scan_mode(active)) { + // Passive-only controller asked for active scanning; the state report + // below carries the real, unchanged mode so the subscriber does not + // assume the change happened. + ESP_LOGW(TAG, "Scanner mode %s not supported by this tracker", active ? "active" : "passive"); + } + } +#ifndef USE_BLE_SCANNER_STATE_CALLBACK + if (this->api_connection_ != nullptr) { + // Reports the mode change; the sender also refreshes last_scan_running_, so + // a failed restart (scan_running_ dropped by the tracker) is not reported + // again by loop() on the next tick. A push hub reports the restart's + // transitions (mode rides along) instead. + this->send_polled_scanner_state_(); + } +#endif +} + +#endif // USE_ESP32 + void BluetoothProxy::loop() { #ifdef BLUETOOTH_CONNECTION_HAS_GATT - // Stream pending service-discovery batches every iteration (esp32 parity: - // its connections stream from their own per-iteration Component loop). - // send_service_for_discovery_() handles a vanished API connection itself. + // Stream pending service-discovery batches every iteration; the streamer + // handles a vanished API connection itself. for (uint8_t i = 0; i < this->connection_count_; i++) { this->connections_[i]->process_pending_services(); } @@ -502,10 +487,19 @@ void BluetoothProxy::loop() { return; this->last_advertisement_flush_time_ = now; + if (this->connections_free_pending_ && this->api_connection_ != nullptr) { + // Resend a dropped slot-state update, paced by the 100 ms gate so the + // retry does not hammer the congestion it exists to survive; the + // advertisement-only arm answers DISCONNECT requests with this message + // too, so the drain compiles on every proxy build. + this->connections_free_pending_ = false; + this->send_connections_free(this->api_connection_); + } + if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { #ifdef BLUETOOTH_CONNECTION_HAS_GATT // The API subscriber is gone: tear down any connections it left behind - // (disconnect() on an already-disconnecting backend is a no-op). + // (disconnect() on an already-disconnecting slot is a no-op). for (uint8_t i = 0; i < this->connection_count_; i++) { auto *connection = this->connections_[i]; if (connection->get_address() != 0) { @@ -550,12 +544,18 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR: this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED); break; - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: - this->send_device_unpairing(msg.address, false, GATT_NOT_CONNECTED); + case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: { + // Address-scoped maintenance needs no connection slot: real on esp32 + // (Bluedroid bond table), the stub elsewhere keeps the old error reply. + conn_err_t ret = bluetooth_connection::unpair_device(msg.address); + this->send_device_unpairing(msg.address, ret == CONN_OK, ret); break; - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE: - this->send_device_clear_cache(msg.address, false, GATT_NOT_CONNECTED); + } + case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE: { + conn_err_t ret = bluetooth_connection::clear_gatt_cache(msg.address); + this->send_device_clear_cache(msg.address, ret == CONN_OK, ret); break; + } } } @@ -595,29 +595,6 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn #endif // !BLUETOOTH_CONNECTION_HAS_GATT -void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { - if (this->hub_->scan_active() != active) { - ESP_LOGD(TAG, "Setting scanner mode to %s", active ? "active" : "passive"); - if (!this->hub_->request_scan_mode(active)) { - // Passive-only controller asked for active scanning; the state report - // below carries the real, unchanged mode so the subscriber does not - // assume the change happened. - ESP_LOGW(TAG, "Scanner mode %s not supported by this tracker", active ? "active" : "passive"); - } - } -#ifndef USE_BLE_SCANNER_STATE_CALLBACK - if (this->api_connection_ != nullptr) { - // Reports the mode change; the sender also refreshes last_scan_running_, so - // a failed restart (scan_running_ dropped by the tracker) is not reported - // again by loop() on the next tick. A push hub reports the restart's - // transitions (mode rides along) instead. - this->send_polled_scanner_state_(); - } -#endif -} - -#endif // USE_ESP32 - void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) { if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) { // A previous subscriber still holds the slot. This is almost always a stale @@ -631,6 +608,8 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection api_connection->get_peername_to(new_peername), this->api_connection_->get_name(), this->api_connection_->get_peername_to(old_peername)); } + // A stale retry latch belongs to the previous subscriber's session. + this->connections_free_pending_ = false; this->api_connection_ = api_connection; #ifdef USE_BLE_SCANNER_STATE_CALLBACK // get_scanner_state() is part of the push-hub surface (see BLEHubContract). @@ -646,6 +625,7 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti return; } this->api_connection_ = nullptr; + this->connections_free_pending_ = false; } void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) { @@ -656,6 +636,8 @@ void BluetoothProxy::send_device_connection(uint64_t address, bool connected, ui call.connected = connected; call.mtu = mtu; call.error = error; + // Fire and forget: a drop is covered by the client's own timeouts and the + // retried connections-free state. this->api_connection_->send_message(call); } void BluetoothProxy::send_connections_free() { @@ -665,7 +647,13 @@ void BluetoothProxy::send_connections_free() { } void BluetoothProxy::send_connections_free(api::APIConnection *api_connection) { - api_connection->send_message(this->connections_free_response_); + // Latch only for the current subscriber: loop() resends to api_connection_. + if (!api_connection->send_message(this->connections_free_response_) && api_connection == this->api_connection_) { + // V like the api layer's own buffer-full log: a D would ride the same + // full connection. + ESP_LOGV(TAG, "Connections-free update deferred, TCP buffer full"); + this->connections_free_pending_ = true; + } } void BluetoothProxy::send_gatt_services_done(uint64_t address) { diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index d7150617d3..26f99fcca2 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -5,8 +5,6 @@ #ifdef USE_BLUETOOTH_PROXY #include -#include -#include #include "esphome/components/api/api_connection.h" #include "esphome/components/api/api_pb2.h" @@ -17,11 +15,7 @@ #include "esphome/components/ble_device_base/ble_hub_impl.h" -#ifdef USE_ESP32 -#include "esphome/components/bluetooth_connection/bluetooth_connection_esp32.h" -#elif defined(USE_BLE_GATT_CLIENT) #include "esphome/components/bluetooth_connection/bluetooth_connection_hub.h" -#endif namespace esphome::bluetooth_proxy { @@ -29,7 +23,6 @@ namespace esphome::bluetooth_proxy { // re-exported here so the proxy code reads unqualified. using bluetooth_connection::CONN_OK; using bluetooth_connection::conn_err_t; -using bluetooth_connection::DONE_SENDING_SERVICES; using bluetooth_connection::GATT_NOT_CONNECTED; using bluetooth_connection::INIT_SENDING_SERVICES; @@ -261,6 +254,10 @@ class BluetoothProxy final : public Component { // Group 4: 1-byte types grouped together bool active_; + // A dropped send (full TCP buffer) would leave the API client with a stale + // slot state forever; the cached response is current by construction, so + // retrying it from loop() is an idempotent resync. + bool connections_free_pending_{false}; uint8_t connection_count_{0}; bool configured_scan_active_{false}; // Configured scan mode from YAML #ifndef USE_BLE_SCANNER_STATE_CALLBACK diff --git a/esphome/config_helpers.py b/esphome/config_helpers.py index c0a3b99968..c82c2b3dbe 100644 --- a/esphome/config_helpers.py +++ b/esphome/config_helpers.py @@ -1,4 +1,4 @@ -from collections.abc import Callable +from collections.abc import Callable, Collection from esphome.const import ( CONF_LEVEL, @@ -98,6 +98,19 @@ def merge_config(old, new): return new +def frameworks_for_platforms(platforms: Collection[str]) -> set[PlatformFramework]: + """All PlatformFramework members whose platform is in `platforms`. + + For FILTER_SOURCE_FILES maps that must stay in sync with a platform + registry: deriving the framework set here means a platform added to the + registry cannot validate and then fail at link on a filtered-out file. + """ + known = {pf.value[0].value for pf in PlatformFramework} + if unknown := set(platforms) - known: + raise ValueError(f"unknown platform(s): {sorted(unknown)}") + return {pf for pf in PlatformFramework if pf.value[0].value in platforms} + + def filter_source_files_from_platform( files_map: dict[str, set[PlatformFramework]], ) -> Callable[[], list[str]]: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 7be217383e..bfb019d7ae 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -306,6 +306,8 @@ #define USE_ESP32_BLE_SERVER_ON_CONNECT #define USE_ESP32_BLE_SERVER_ON_DISCONNECT #define USE_ESP32_BLE_TRACKER +#define USE_BLE_GATT_CLIENT +#define ESPHOME_BLE_GATT_CLIENT_COUNT 1 #define ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT 1 #define ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT 1 #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 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 e784c9871e..86ee53fe8a 100644 --- a/tests/component_tests/ble_device_base/test_slot_counter.py +++ b/tests/component_tests/ble_device_base/test_slot_counter.py @@ -109,6 +109,8 @@ def test_esp32_bluetooth_proxy_requests_client_slots_only( generate_main(component_config_path("esp32_bluetooth_proxy.yaml")) assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") is None assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") == "3" + # One neutral GATT backend slot per connection (the hub-model flip). + assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "3" def test_counts_reset_between_compiles( diff --git a/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py b/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py index 32e5daf4bb..765b2e48d4 100644 --- a/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py +++ b/tests/component_tests/bluetooth_proxy/test_outer_schema_mirror.py @@ -15,6 +15,13 @@ import voluptuous as vol from esphome import config_validation as cv from esphome.components.bluetooth_proxy import CONFIG_SCHEMA, _esp32_config_schema + +def _esp32_schema_keys() -> dict[str, object]: + # The builder names its platform explicitly, so no CORE state is needed + # (this also mirrors how the language-schema dumper calls it). + return _keys(_schema_of(_esp32_config_schema())) + + # esp32-schema keys with no place in the outer schema: COMPONENT_SCHEMA # plumbing (derived, so a future core key does not fail this component's test), # generated IDs (not user-walkable options), and connections (must validate @@ -38,7 +45,7 @@ def _keys(schema: vol.Schema) -> dict[str, object]: def test_outer_scalar_keys_exist_in_esp32_schema() -> None: outer = _keys(_schema_of(CONFIG_SCHEMA)) - esp32 = _keys(_schema_of(_esp32_config_schema())) + esp32 = _esp32_schema_keys() missing = set(outer) - set(esp32) assert not missing, ( f"outer CONFIG_SCHEMA declares {sorted(missing)} which the esp32 schema " @@ -51,7 +58,7 @@ def test_esp32_scalars_all_walkable() -> None: """Every non-generated esp32 scalar option must appear in the outer schema (connections is deliberately excluded — it must validate exactly once).""" outer = _keys(_schema_of(CONFIG_SCHEMA)) - esp32 = _keys(_schema_of(_esp32_config_schema())) + esp32 = _esp32_schema_keys() scalar = { name for name, key in esp32.items() diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index 55e6fe2ca7..a47dfd53fa 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -9,11 +9,13 @@ import pytest from esphome import config_validation as cv from esphome.components import ble_device_base, bluetooth_connection, bluetooth_proxy +from esphome.config_helpers import frameworks_for_platforms from esphome.const import ( CONF_ACTIVE, KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, + PLATFORM_ESP32, PLATFORM_LN882X, PLATFORM_RP2, PlatformFramework, @@ -177,28 +179,49 @@ def test_rp2_rejects_esp32_only_keys_by_name( bluetooth_proxy.CONFIG_SCHEMA({"connections": [{}]}) +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 + # missing from the filter would validate, then fail at link. + expected = frameworks_for_platforms( + [*bluetooth_proxy._HUB_PLATFORMS, PLATFORM_ESP32] + ) + hub_frameworks = bluetooth_connection.SOURCE_FILE_FRAMEWORKS[ + "bluetooth_connection_hub.cpp" + ] + assert expected == hub_frameworks + + def test_bluetooth_connection_auto_load_covers_its_includes() -> None: - # The esp32 connection header includes esp32_ble_client; the auto load - # must satisfy that closure itself (regression: it once relied on the - # consumer's auto loads). + # The backend registers with its platform BLE stack (and the Bluedroid + # header includes the tracker's), so that closure lives here and + # consumers stay platform-blind; the platform-less arm is the union for + # manifest-resolving tooling. _set_platform("esp32") - assert "esp32_ble_client" in bluetooth_connection.AUTO_LOAD() + assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "esp32_ble_tracker"] _set_platform("rp2") + assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "rp2040_ble"] + _set_platform("ln882x") assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base"] - # No target platform (tooling resolving the manifest): the union, so - # dependency closures stay complete for build_codeowners and friends. _set_platform(None) - assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "esp32_ble_client"] + assert bluetooth_connection.AUTO_LOAD() == [ + "ble_device_base", + "esp32_ble_tracker", + "rp2040_ble", + ] def test_every_registered_hub_platform_has_a_schema_arm() -> None: - # A platform added to HUB_MAX_CONNECTIONS without a schema builder, - # codegen arm, or _HUB_PLATFORMS entry would only fail when a config for - # it is validated (or not even then); pin all three couplings here. + # A platform added to HUB_MAX_CONNECTIONS without a schema builder or + # _HUB_PLATFORMS entry would only fail when a config for it is validated + # (or not even then); pin both couplings here. Connection codegen is + # shared (bluetooth_connection.new_gatt_backend), so it needs no arm. registered = set(bluetooth_connection.HUB_MAX_CONNECTIONS) assert registered <= set(bluetooth_proxy._GATT_HUB_SCHEMAS) - assert registered <= set(bluetooth_proxy._GATT_HUB_TO_CODE) assert registered <= set(bluetooth_proxy._HUB_PLATFORMS) + # Hub platforms must also be in the backend registry the shared codegen + # helpers dispatch on. + assert registered <= set(bluetooth_connection._PLATFORM_BACKENDS) # The outer walkable schema's bound must stay the loosest platform cap. assert ( max(bluetooth_connection.HUB_MAX_CONNECTIONS.values()) @@ -220,9 +243,14 @@ def test_defines_h_mirrors_the_rp2_slot_cap() -> None: assert int(match.group(1)) == cap, ( f"defines.h rp2 arm carries {match.group(1)}, expected {cap}" ) - # The static-analysis client count scales with the same cap. - match = re.search(r"#define ESPHOME_BLE_GATT_CLIENT_COUNT (\d+)", defines) - assert match is not None, "ESPHOME_BLE_GATT_CLIENT_COUNT missing from defines.h" + # The static-analysis client count scales with the same cap. Scoped to + # the USE_RP2 block: the esp32 arm carries its own count. + rp2_block = re.search(r"#ifdef USE_RP2\n((?:#define [^\n]*\n)+)", defines) + assert rp2_block is not None, "no USE_RP2 platform block in defines.h" + match = re.search( + r"#define ESPHOME_BLE_GATT_CLIENT_COUNT (\d+)", rp2_block.group(1) + ) + assert match is not None, "ESPHOME_BLE_GATT_CLIENT_COUNT missing from rp2 block" assert int(match.group(1)) == cap, ( - f"ESPHOME_BLE_GATT_CLIENT_COUNT is {match.group(1)}, expected {cap}" + f"rp2 ESPHOME_BLE_GATT_CLIENT_COUNT is {match.group(1)}, expected {cap}" ) diff --git a/tests/components/ble_device_base/test_gatt_client_contract.cpp b/tests/components/ble_device_base/test_gatt_client_contract.cpp index 25b6cbf002..89eb56642e 100644 --- a/tests/components/ble_device_base/test_gatt_client_contract.cpp +++ b/tests/components/ble_device_base/test_gatt_client_contract.cpp @@ -2,7 +2,7 @@ // configured; this TU pins it on the host so the header cannot rot unseen. // The contract is a concept (BLEGattConnection is a per-platform alias), so // the minimal backend here proves the concept stays satisfiable and routes -// events through the duck-typed sink the way a real backend does. +// events through the GattClientListener interface the way a real backend does. #define USE_BLE_GATT_CLIENT #include "esphome/components/ble_device_base/ble_gatt_client.h" @@ -11,37 +11,37 @@ namespace esphome::ble_device_base::testing { -struct RecordingSink { - void on_connection_state(bool connected, uint16_t mtu, int error) { this->connected_ = connected; } - void on_service_discovery_done(int error) { this->discovery_error_ = error; } - void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {} - void on_write_result(uint16_t handle, int error) {} - void on_notify_state(uint16_t handle, bool enabled, int error) {} - void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {} - void on_pairing_result(int status) {} +// Overrides only what it records; the interface's defaults cover the rest. +class RecordingListener : public GattClientListener { + public: + void on_connection_state(bool connected, uint16_t mtu, int error) override { this->connected_ = connected; } + void on_service_discovery_done(int error) override { this->discovery_error_ = error; } + void on_write_result(uint16_t handle, int error) override { this->write_handle_ = handle; } + bool connected_{false}; int discovery_error_{0}; + uint16_t write_handle_{0}; }; -static_assert(GattClientEventSinkContract, "the recording sink must cover the full event-sink surface"); - class MinimalConnection { public: - void set_listener(RecordingSink *listener) { this->listener_ = listener; } + void set_listener(GattClientListener *listener) { this->listener_ = listener; } int connect(uint64_t address, uint8_t addr_type) { - if (this->listener_ != nullptr) - this->listener_->on_connection_state(true, 517, 0); + this->listener_->on_connection_state(true, 517, 0); return 0; } - int disconnect() { return 0; } + bool cancel_gatt_disconnect() { return false; } + int gatt_disconnect() { return 0; } int discover_services() { - if (this->listener_ != nullptr) - this->listener_->on_service_discovery_done(0); + this->listener_->on_service_discovery_done(0); return 0; } int read_characteristic(uint16_t handle) { return GATT_ERR_NOT_CONNECTED; } - int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { return 0; } + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) { + this->listener_->on_write_result(handle, 0); + return 0; + } int read_descriptor(uint16_t handle) { return 0; } int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) { return 0; } int notify_characteristic(uint16_t handle, bool enable) { return 0; } @@ -51,23 +51,26 @@ class MinimalConnection { } GattServiceTable get_service_table() { return {}; } void release_services() {} + void set_connection_type(ConnectionType ct) {} protected: - RecordingSink *listener_{nullptr}; + GattClientListener *listener_{nullptr}; }; -static_assert(BLEGattConnectionContract, +static_assert(BLEGattConnectionContract, "a minimal backend must satisfy the contract the alias asserts"); TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) { MinimalConnection connection; - RecordingSink listener; + RecordingListener listener; connection.set_listener(&listener); EXPECT_EQ(connection.connect(0xAABBCCDDEEFFULL, 0), 0); EXPECT_TRUE(listener.connected_); EXPECT_EQ(connection.discover_services(), 0); EXPECT_EQ(listener.discovery_error_, 0); EXPECT_EQ(connection.read_characteristic(1), GATT_ERR_NOT_CONNECTED); + EXPECT_EQ(connection.write_characteristic(7, nullptr, 0, true), 0); + EXPECT_EQ(listener.write_handle_, 7); // A default table is empty and safe to walk. GattServiceTable table = connection.get_service_table(); diff --git a/tests/components/bluetooth_proxy/test-passive.esp32-c6-idf.yaml b/tests/components/bluetooth_proxy/test-passive.esp32-c6-idf.yaml new file mode 100644 index 0000000000..b3445f16c8 --- /dev/null +++ b/tests/components/bluetooth_proxy/test-passive.esp32-c6-idf.yaml @@ -0,0 +1,12 @@ +# Advertisement-only proxy on esp32 by explicit choice: no GATT backend is +# compiled (USE_BLE_GATT_CLIENT unset), which pins the HAS_GATT gating and the +# address-scoped maintenance path that a connections build never exercises. +# Under batch grouping the active default build is what runs; the standalone +# compile of this fixture is what exercises the passive gating. +packages: + common: !include common.yaml + +esp32_ble_tracker: + +bluetooth_proxy: + active: false diff --git a/tests/unit_tests/test_config_helpers.py b/tests/unit_tests/test_config_helpers.py index 1c850e3759..88913c0f23 100644 --- a/tests/unit_tests/test_config_helpers.py +++ b/tests/unit_tests/test_config_helpers.py @@ -3,7 +3,13 @@ from collections.abc import Callable from unittest.mock import patch -from esphome.config_helpers import filter_source_files_from_platform, get_logger_level +import pytest + +from esphome.config_helpers import ( + filter_source_files_from_platform, + frameworks_for_platforms, + get_logger_level, +) from esphome.const import ( CONF_LEVEL, CONF_LOGGER, @@ -133,3 +139,12 @@ def test_get_logger_level() -> None: mock_config = {CONF_LOGGER: {}} with patch("esphome.config_helpers.CORE.config", mock_config): assert get_logger_level() == "DEBUG" + + +def test_frameworks_for_platforms_derives_and_rejects_unknown() -> None: + assert frameworks_for_platforms(["esp32"]) == { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + } + with pytest.raises(ValueError, match="unknown platform"): + frameworks_for_platforms(["esp32", "not_a_platform"])