[bluetooth_proxy] Migrate esp32 onto the neutral GATT backend (#18198)

This commit is contained in:
J. Nick Koston
2026-08-10 10:47:56 -05:00
committed by GitHub
parent c8c929d487
commit f3d1fc0d64
26 changed files with 1518 additions and 976 deletions
@@ -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()
@@ -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
@@ -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<typename T, typename Sink>
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<typename T>
concept BLEGattConnectionContract = requires(T conn, GattClientListener *listener, const uint8_t *data) {
conn.set_listener(listener);
{ conn.connect(uint64_t{}, uint8_t{}) } -> std::same_as<int>;
{ conn.disconnect() } -> std::same_as<int>;
{ conn.gatt_disconnect() } -> std::same_as<int>;
{ conn.cancel_gatt_disconnect() } -> std::same_as<bool>;
{ conn.discover_services() } -> std::same_as<int>;
{ conn.read_characteristic(uint16_t{}) } -> std::same_as<int>;
{ conn.write_characteristic(uint16_t{}, data, uint16_t{}, true) } -> std::same_as<int>;
@@ -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<int>;
{ conn.get_service_table() } -> std::same_as<GattServiceTable>;
{ conn.release_services() } -> std::same_as<void>;
};
// 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<typename S>
concept GattClientEventSinkContract = requires(S sink, const uint8_t *data) {
{ sink.on_connection_state(true, uint16_t{}, int{}) } -> std::same_as<void>;
{ sink.on_service_discovery_done(int{}) } -> std::same_as<void>;
{ sink.on_read_result(uint16_t{}, data, uint16_t{}, int{}) } -> std::same_as<void>;
{ sink.on_write_result(uint16_t{}, int{}) } -> std::same_as<void>;
{ sink.on_notify_state(uint16_t{}, true, int{}) } -> std::same_as<void>;
{ sink.on_notify_data(uint16_t{}, data, uint16_t{}) } -> std::same_as<void>;
{ sink.on_pairing_result(int{}) } -> std::same_as<void>;
// Connection-type hint for backends that tune parameters by it; others
// carry an inline no-op.
{ conn.set_connection_type(ConnectionType{}) } -> std::same_as<void>;
};
} // namespace esphome::ble_device_base
@@ -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)
@@ -1,5 +1,10 @@
#include "bluetooth_connection.h"
#ifdef USE_ESP32
#include <esp_gap_ble_api.h>
#include <esp_gattc_api.h>
#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
@@ -16,10 +16,15 @@
#include <esp_err.h>
#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
@@ -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 <cstring>
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<esp_ble_addr_type_t>(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<uint8_t *>(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<uint8_t *>(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<esp_gatt_status_t>(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
@@ -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 <esp_gap_ble_api.h>
#include <esp_gattc_api.h>
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<uint8_t>(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
@@ -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<uint8_t *>(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<uint8_t *>(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
@@ -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<esp_ble_addr_type_t>(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
@@ -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<BLEGattConnection, bluetooth_connection::BluetoothConnection>,
static_assert(BLEGattConnectionContract<BLEGattConnection>,
"The build's GATT backend is missing part of the BLEGattConnection surface (ble_gatt_client.h)");
#undef ESPHOME_BLE_GATT_CONNECTION_TYPE
@@ -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
@@ -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<typename Backend> 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<uint8_t>(ClientState::ESTABLISHED) < (1 << 3), "state_ bitfield too narrow");
static_assert(static_cast<uint8_t>(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<BluetoothConnection>,
"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
@@ -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;
}
@@ -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 Parented<rp2040_ble::RP2040
void dump_config() override;
float get_setup_priority() const override;
void set_listener(BluetoothConnection *listener) { this->listener_ = 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<rp2040_ble::RP2040
int pair();
int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout);
ble_device_base::GattServiceTable get_service_table();
// No connection-type branching on this backend.
void set_connection_type(ble_device_base::ConnectionType ct) {}
void release_services();
protected:
@@ -150,7 +153,7 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
}
// Group 1: containers / large storage
BluetoothConnection *listener_{nullptr};
ble_device_base::GattClientListener *listener_{nullptr};
ServiceArena *arena_{nullptr};
esphome::LockFreeQueue<RP2GattEvent, RP2_GATT_EVENT_QUEUE_SIZE> event_queue_;
esphome::EventPool<RP2GattEvent, RP2_GATT_EVENT_QUEUE_SIZE - 1> event_pool_;
@@ -174,7 +177,7 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
// Group 4: 2-byte types (table counters written from the handler during
// discovery, read from the main loop after the phase's QUERY_COMPLETE)
hci_con_handle_t con_handle_{HCI_CON_HANDLE_INVALID};
uint16_t mtu_{23};
uint16_t mtu_{ble_device_base::DEFAULT_ATT_MTU};
uint16_t op_handle_{0};
uint16_t op_len_{0};
uint16_t service_count_{0};
+34 -65
View File
@@ -4,17 +4,23 @@ import logging
import esphome.codegen as cg
from esphome.components import ble_device_base, bluetooth_connection
import esphome.config_validation as cv
from esphome.const import CONF_ACTIVE, CONF_ID, PLATFORM_LN882X, PLATFORM_RP2
from esphome.const import (
CONF_ACTIVE,
CONF_ID,
PLATFORM_ESP32,
PLATFORM_LN882X,
PLATFORM_RP2,
)
from esphome.core import CORE
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType
# The esp32 BLE stack (esp32_ble, esp32_ble_client, esp32_ble_tracker) is
# imported lazily inside _esp32_config_schema()/_to_code_esp32(): importing
# those modules registers esp32-only automations (ble.enable, ble.disable, ...)
# as a side effect, and a module-scope import would leak them into every
# platform's registry the moment a config declares `bluetooth_proxy:` —
# degrading "Unable to find action" config errors into C++ compile failures.
# The esp32 BLE stack (esp32_ble, esp32_ble_tracker) is imported lazily
# inside _esp32_config_schema()/_to_code_esp32(): importing those modules
# registers esp32-only automations (ble.enable, ble.disable, ...) as a side
# effect, and a module-scope import would leak them into every platform's
# registry the moment a config declares `bluetooth_proxy:` — degrading
# "Unable to find action" config errors into C++ compile failures.
def AUTO_LOAD(config: ConfigType | None = None) -> 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:
@@ -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) {
@@ -5,8 +5,6 @@
#ifdef USE_BLUETOOTH_PROXY
#include <array>
#include <map>
#include <vector>
#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
+14 -1
View File
@@ -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]]:
+2
View File
@@ -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
@@ -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(
@@ -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()
@@ -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}"
)
@@ -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<RecordingSink>, "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<MinimalConnection, RecordingSink>,
static_assert(BLEGattConnectionContract<MinimalConnection>,
"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();
@@ -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
+16 -1
View File
@@ -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"])