Merge branch 'esp32-gatt-backend' into neutral-ble-client

# Conflicts:
#	esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h
#	esphome/components/bluetooth_proxy/__init__.py
This commit is contained in:
J. Nick Koston
2026-08-09 09:37:59 -05:00
9 changed files with 61 additions and 87 deletions
@@ -163,8 +163,8 @@ _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 connection slot. Called by bluetooth_connection.new_gatt_backend()
once per backend instance."""
cg.add_define("USE_BLE_GATT_CLIENT")
_request_gatt_connection_slot()
@@ -22,6 +22,9 @@ static constexpr int GATT_ERR_NO_MEMORY = -2;
/// 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
// 0). FAST covers connection setup and service discovery; MEDIUM is the
@@ -17,8 +17,6 @@ from esphome.const import PLATFORM_ESP32, PLATFORM_RP2, PlatformFramework
from esphome.core import CORE
from esphome.types import ConfigType
DOMAIN = "bluetooth_connection"
def AUTO_LOAD() -> list[str]:
"""ble_device_base plus the platform BLE stack the build's backend
@@ -40,10 +38,12 @@ bluetooth_connection_ns = cg.esphome_ns.namespace("bluetooth_connection")
# arduino-pico's prebuilt BTstack is compiled with MAX_NR_GATT_CLIENTS 1;
# raising this needs an upstream change (the layer itself supports N).
DOMAIN = "bluetooth_connection"
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 backend codegen classes.
@@ -73,9 +73,9 @@ def _rp2_schema_fragment() -> cv.Schema:
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's
# tracker-facing shim registers as a raw client.
await esp32_ble_tracker.register_raw_client(backend.tracker_client(), config)
# 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:
@@ -17,8 +17,6 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <esp_gatt_common_api.h>
#include <cstring>
namespace esphome::bluetooth_connection {
@@ -35,21 +33,12 @@ using esp32_ble_tracker::ClientState;
using esp32_ble_tracker::ConnectionType;
static constexpr uint16_t UNSET_CONN_ID = 0xFFFF;
// Wire default before any MTU exchange (Bluetooth spec ATT_MTU minimum).
static constexpr uint16_t DEFAULT_ATT_MTU = 23;
// Bounds one characteristic's descriptor walk against a stack that never
// reports end-of-range.
static constexpr uint16_t MAX_DESCRIPTORS_PER_CHARACTERISTIC = 64;
// ---- tracker surface ----
bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) {
return this->handle_gattc_event_(event, gattc_if, param);
}
void BluedroidGattClient::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
this->handle_gap_event_(event, param);
}
void BluedroidGattClient::connect() { this->tracker_connect_(); }
void BluedroidGattClient::disconnect() { this->gatt_disconnect(); }
@@ -63,10 +52,10 @@ void BluedroidGattClient::setup() {
void BluedroidGattClient::loop() {
if (!esp32_ble::global_ble->is_active()) {
// Stack down: re-register the app on the next enable.
this->set_state_(ClientState::INIT);
this->set_state(ClientState::INIT);
return;
}
auto st = this->state_();
auto st = this->state();
if (st == ClientState::INIT) {
auto ret = esp_ble_gattc_app_register(this->app_id);
if (ret) {
@@ -74,7 +63,7 @@ void BluedroidGattClient::loop() {
this->mark_failed();
}
// Do not wait for REG_EVT; a dropped event must not wedge the slot.
this->set_state_(ClientState::IDLE);
this->set_state(ClientState::IDLE);
} else if (st == ClientState::IDLE) {
// The loop only drives the bootstrap and the disconnect safety timeout.
this->disable_loop();
@@ -85,7 +74,7 @@ void BluedroidGattClient::loop() {
// lost CLOSE/DISCONNECT would otherwise leak the table and the cache.
this->release_services();
this->set_idle_();
this->report_connection_state_(false, 0, ESP_GATT_CONN_TIMEOUT);
this->listener_->on_connection_state(false, 0, ESP_GATT_CONN_TIMEOUT);
}
}
@@ -101,7 +90,7 @@ void BluedroidGattClient::dump_config() {
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) {
if (this->state() != ClientState::IDLE) {
ESP_LOGW(TAG, "[%d] Connect rejected, slot busy", this->connection_index_);
return ESP_GATT_BUSY;
}
@@ -109,12 +98,12 @@ int BluedroidGattClient::connect(uint64_t address, uint8_t addr_type) {
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);
this->set_state(ClientState::DISCOVERED);
return 0;
}
void BluedroidGattClient::tracker_connect_() {
auto st = this->state_();
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;
@@ -127,7 +116,7 @@ void BluedroidGattClient::tracker_connect_() {
this->services_released_ = false;
this->seen_mtu_ = false;
this->enable_loop();
this->set_state_(ClientState::CONNECTING);
this->set_state(ClientState::CONNECTING);
if (this->connection_type_ == ConnectionType::V3_WITHOUT_CACHE) {
// Fast params for the discovery phase; stepped down at SEARCH_CMPL.
esp_ble_gap_set_prefer_conn_params(this->remote_bda_, FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL, 0,
@@ -141,13 +130,13 @@ void BluedroidGattClient::tracker_connect_() {
if (ret) {
this->log_gattc_warning_("esp_ble_gattc_open", ret);
// CONNECT_EVT never fired, so conn_id_ is legitimately unset: plain IDLE.
this->set_state_(ClientState::IDLE);
this->report_connection_state_(false, 0, ret);
this->set_state(ClientState::IDLE);
this->listener_->on_connection_state(false, 0, ret);
}
}
int BluedroidGattClient::gatt_disconnect() {
auto st = this->state_();
auto st = this->state();
if (st == ClientState::DISCONNECTING) {
return 0;
}
@@ -158,7 +147,7 @@ int BluedroidGattClient::gatt_disconnect() {
}
if (st == ClientState::DISCOVERED) {
// Parked for the tracker promote loop, never opened.
this->set_state_(ClientState::IDLE);
this->set_state(ClientState::IDLE);
return ble_device_base::GATT_ERR_NOT_CONNECTED;
}
if (st == ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) {
@@ -429,21 +418,17 @@ bool BluedroidGattClient::check_addr_(const esp_bd_addr_t &addr) const {
}
void BluedroidGattClient::set_idle_() {
this->set_state_(ClientState::IDLE);
this->set_state(ClientState::IDLE);
this->conn_id_ = UNSET_CONN_ID;
}
void BluedroidGattClient::set_disconnecting_() {
this->disconnecting_started_ = millis();
this->set_state_(ClientState::DISCONNECTING);
this->set_state(ClientState::DISCONNECTING);
// The loop may be disabled while idle; the safety timeout needs it.
this->enable_loop();
}
void BluedroidGattClient::report_connection_state_(bool connected, uint16_t mtu, int error) {
this->listener_->on_connection_state(connected, mtu, error);
}
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}};
@@ -646,7 +631,7 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) {
// ---- events ----
void BluedroidGattClient::handle_open_evt_(esp_ble_gattc_cb_param_t *param) {
auto st = this->state_();
auto st = this->state();
if (st == ClientState::IDLE) {
// IDF can deliver OPEN_EVT after esp_ble_gattc_open already returned an
// error and the slot went IDLE; do not resurrect it.
@@ -660,7 +645,7 @@ void BluedroidGattClient::handle_open_evt_(esp_ble_gattc_cb_param_t *param) {
this->log_gattc_warning_("Connection open", param->open.status);
// Never established, CLOSE_EVT may not follow.
this->set_idle_();
this->report_connection_state_(false, 0, param->open.status);
this->listener_->on_connection_state(false, 0, param->open.status);
return;
}
if (this->disconnect_pending()) {
@@ -668,17 +653,17 @@ void BluedroidGattClient::handle_open_evt_(esp_ble_gattc_cb_param_t *param) {
this->unconditional_disconnect_();
return;
}
this->set_state_(ClientState::CONNECTED);
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);
this->set_state(ClientState::ESTABLISHED);
// No discovery phase: report immediately; the MTU report below is
// suppressed by seen_mtu_ (HA tolerates a post-connect MTU of 23 here,
// matching the previous esp32 behavior).
this->seen_mtu_ = true;
// Cached path never exchanged an MTU; HA has always seen the default.
this->report_connection_state_(true, DEFAULT_ATT_MTU, 0);
if (this->state_() != ClientState::DISCONNECTING) {
this->listener_->on_connection_state(true, ble_device_base::DEFAULT_ATT_MTU, 0);
if (this->state() != ClientState::DISCONNECTING) {
// Settled; set_disconnecting_() re-enables the loop for the net.
this->disable_loop();
}
@@ -686,12 +671,12 @@ void BluedroidGattClient::handle_open_evt_(esp_ble_gattc_cb_param_t *param) {
}
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) {
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) {
if (this->state() == ClientState::IDLE) {
// Active close delivers CLOSE_EVT first; never walk back to DISCONNECTING.
return;
}
@@ -703,7 +688,7 @@ void BluedroidGattClient::handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param
this->set_disconnecting_();
}
bool BluedroidGattClient::handle_gattc_event_(esp_gattc_cb_event_t event, esp_gatt_if_t esp_gattc_if,
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;
@@ -747,8 +732,8 @@ bool BluedroidGattClient::handle_gattc_event_(esp_gattc_cb_event_t event, esp_ga
if (!this->seen_mtu_) {
this->seen_mtu_ = true;
// The connected report waited for the MTU; forwarded, not stored.
this->report_connection_state_(true,
param->cfg_mtu.status == ESP_GATT_OK ? param->cfg_mtu.mtu : DEFAULT_ATT_MTU, 0);
this->listener_->on_connection_state(
true, param->cfg_mtu.status == ESP_GATT_OK ? param->cfg_mtu.mtu : ble_device_base::DEFAULT_ATT_MTU, 0);
}
break;
}
@@ -765,16 +750,16 @@ bool BluedroidGattClient::handle_gattc_event_(esp_gattc_cb_event_t event, esp_ga
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->report_connection_state_(false, 0, param->close.reason);
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_);
this->set_state_(ClientState::ESTABLISHED);
this->set_state(ClientState::ESTABLISHED);
this->handle_search_cmpl_();
if (this->state_() != ClientState::DISCONNECTING) {
if (this->state() != ClientState::DISCONNECTING) {
// Settled; a failed count started a teardown that needs the loop.
this->disable_loop();
}
@@ -821,7 +806,7 @@ bool BluedroidGattClient::handle_gattc_event_(esp_gattc_cb_event_t event, esp_ga
return true;
}
void BluedroidGattClient::handle_gap_event_(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
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))
@@ -2,10 +2,8 @@
// 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 and the real esp_ble_gattc_open happens in
// the tracker-invoked shim connect(). The shim exists because the tracker's
// ESPBTClient::disconnect() returns void while the contract's returns int -
// one class cannot carry both.
// parks the address in DISCOVERED; the real esp_ble_gattc_open happens in
// the tracker-invoked connect() override.
#pragma once
@@ -24,7 +22,6 @@
namespace esphome::bluetooth_connection {
class BluedroidGattClient;
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
class BluetoothConnection;
#endif
@@ -43,7 +40,6 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
// 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 *tracker_client() { return this; }
// ---- esp32_ble_tracker::ESPBTClient ----
bool gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
@@ -67,7 +63,8 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
int pair();
int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout);
// On-demand table for direct consumers; the proxy streams instead, so the
// materializer compiles only under USE_BLE_GATT_SERVICE_TABLE.
// materializer compiles only under USE_BLE_GATT_SERVICE_TABLE (emitted by
// direct-consumer codegen, never by the proxy).
#ifdef USE_BLE_GATT_SERVICE_TABLE
ble_device_base::GattServiceTable get_service_table();
#else
@@ -85,19 +82,14 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public
void set_connection_type(ble_device_base::ConnectionType ct) { this->connection_type_ = ct; }
protected:
esp32_ble_tracker::ClientState state_() const { return this->state(); }
void set_state_(esp32_ble_tracker::ClientState st) { this->set_state(st); }
bool check_addr_(const esp_bd_addr_t &addr) const;
void tracker_connect_();
bool handle_gattc_event_(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param);
void handle_gap_event_(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param);
void handle_open_evt_(esp_ble_gattc_cb_param_t *param);
void handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param);
void handle_search_cmpl_();
void unconditional_disconnect_();
void set_idle_();
void set_disconnecting_();
void report_connection_state_(bool connected, uint16_t mtu, int error);
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);
@@ -148,8 +148,7 @@ 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) {
@@ -337,8 +336,8 @@ 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
// 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) {
@@ -43,7 +43,7 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
/// 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.
/// through the same reset path a failed open takes.
void initiate_connection(uint8_t address_type) {
this->remote_addr_type_ = address_type;
this->start_connect_();
@@ -126,7 +126,7 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
// 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};
@@ -92,8 +92,7 @@ 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 deferred-disconnect state (disconnect is one call) and no
// connection-type branching on this backend.
// No connection-type branching on this backend.
void set_connection_type(ble_device_base::ConnectionType ct) {}
void release_services();
@@ -175,7 +174,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};
+9 -13
View File
@@ -52,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"]
@@ -203,7 +204,12 @@ def _rp2_config_schema() -> cv.All:
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()."""
for connection_conf in config.get(CONF_CONNECTIONS, []):
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, service_table=False
)
@@ -366,10 +372,6 @@ 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)
await _connections_to_code(var, config)
if config.get(CONF_CACHE_SERVICES):
@@ -384,12 +386,6 @@ 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)
await _connections_to_code(var, config)