mirror of
https://github.com/esphome/esphome.git
synced 2026-08-24 07:06:20 +00:00
[bluetooth_proxy] Migrate esp32 onto the neutral GATT backend (#18198)
This commit is contained in:
@@ -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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user