mirror of
https://github.com/esphome/esphome.git
synced 2026-10-02 17:30:22 +00:00
Combined-stack simplify: loop discipline, gated materializer, registry, peer keys
This commit is contained in:
@@ -5,8 +5,9 @@
|
||||
// Exactly one GATT backend exists per build, so BLEGattConnection is a
|
||||
// compile-time alias (bluetooth_connection_gatt_backend.h), not an abstract
|
||||
// interface.
|
||||
// A consumer (the hub BluetoothConnection wrapper, or a component owning a
|
||||
// dedicated backend instance such as radon_eye_rd200) drives it and receives
|
||||
// A consumer - a streaming consumer that forwards the raw database (the hub
|
||||
// BluetoothConnection wrapper) or a direct consumer owning a dedicated
|
||||
// backend and resolving handles by UUID - drives it and receives
|
||||
// completions through a GattEventSink — a pointer-sized-entry function table
|
||||
// rather than a concrete consumer type, because one build can hold several
|
||||
// consumer types while the backend stays a single non-virtual class. All sink
|
||||
@@ -197,20 +198,18 @@ concept BLEGattConnectionContract = requires(T conn, GattEventSink sink, const u
|
||||
{ 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>;
|
||||
// Deferred-disconnect visibility and the connection-type hint; backends
|
||||
// without the underlying state carry inline no-ops.
|
||||
{ conn.disconnect_pending() } -> std::same_as<bool>;
|
||||
{ conn.cancel_pending_disconnect() } -> 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>;
|
||||
};
|
||||
|
||||
// ---- service table lookup helpers ----
|
||||
//
|
||||
// Neutral, bounds-checked walks over a materialized GattServiceTable for
|
||||
// consumers that resolve a known device's handles by UUID (the proxy streams
|
||||
// the whole table to HA instead and never needs these). Linear search: the
|
||||
// table exists only between discovery and release_services(), for one small
|
||||
// known device.
|
||||
// direct consumers that resolve a known device's handles by UUID (streaming
|
||||
// consumers forward the raw database and never need these). Linear search:
|
||||
// the table exists only between discovery and release_services(), for one
|
||||
// small known device.
|
||||
|
||||
/// Client Characteristic Configuration descriptor UUID (Bluetooth spec).
|
||||
static constexpr uint16_t CCCD_UUID = 0x2902;
|
||||
|
||||
@@ -2,19 +2,25 @@
|
||||
|
||||
Backends: esp32 Bluedroid, rp2 BTstack. No user-facing configuration; a
|
||||
consumer's codegen declares and registers the backend instances — the
|
||||
Bluetooth proxy through its per-slot connection wrappers, and components
|
||||
owning a dedicated backend (e.g. radon_eye_rd200) through
|
||||
gatt_client_schema() + new_gatt_backend().
|
||||
Bluetooth proxy through its per-slot connection wrappers (a streaming
|
||||
consumer), and direct consumers owning a dedicated backend through
|
||||
gatt_client_config_schema() + new_gatt_backend().
|
||||
"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.config_helpers import filter_source_files_from_platform
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import PLATFORM_ESP32, PLATFORM_RP2, PlatformFramework
|
||||
from esphome.const import (
|
||||
CONF_MAC_ADDRESS,
|
||||
PLATFORM_ESP32,
|
||||
PLATFORM_RP2,
|
||||
PlatformFramework,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DOMAIN = "bluetooth_connection"
|
||||
@@ -45,12 +51,7 @@ RP2_MAX_CONNECTIONS = 1
|
||||
# registry of which hub platforms run the connection-capable proxy.
|
||||
HUB_MAX_CONNECTIONS: dict[str, int] = {PLATFORM_RP2: RP2_MAX_CONNECTIONS}
|
||||
|
||||
# Every platform with a GATT backend; gates dedicated-backend consumers.
|
||||
# Derived from the hub registry so a platform gaining a backend is admitted
|
||||
# everywhere at once (esp32 is the non-hub arm).
|
||||
GATT_CLIENT_PLATFORMS = [PLATFORM_ESP32, *HUB_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_(
|
||||
@@ -58,6 +59,70 @@ BluedroidGattClient = bluetooth_connection_ns.class_(
|
||||
)
|
||||
|
||||
CONF_BACKEND_ID = "backend_id"
|
||||
CONF_ADDRESS_TYPE = "address_type"
|
||||
|
||||
# BLE_ADDR_TYPE_* code space shared with the API and the backends.
|
||||
ADDRESS_TYPES = {"public": 0, "random": 1}
|
||||
|
||||
|
||||
def _esp32_schema_fragment() -> cv.Schema:
|
||||
from esphome.components import esp32_ble_tracker
|
||||
|
||||
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)}
|
||||
)
|
||||
|
||||
|
||||
async def _esp32_register(backend: cg.MockObj, config: ConfigType) -> None:
|
||||
from esphome.components import esp32_ble_tracker
|
||||
|
||||
# The tracker's promote loop owns connect timing; the backend's
|
||||
# tracker-facing shim registers as a raw client.
|
||||
await esp32_ble_tracker.register_raw_client(backend.tracker_client(), config)
|
||||
|
||||
|
||||
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),
|
||||
}
|
||||
|
||||
# Gates dedicated-backend consumers (cv.only_on).
|
||||
GATT_CLIENT_PLATFORMS = list(_PLATFORM_BACKENDS)
|
||||
|
||||
|
||||
def _backend_entry() -> _PlatformBackend:
|
||||
if (entry := _PLATFORM_BACKENDS.get(CORE.target_platform)) is None:
|
||||
raise cv.Invalid(
|
||||
f"no GATT client backend is registered for {CORE.target_platform}"
|
||||
)
|
||||
return entry
|
||||
|
||||
|
||||
def gatt_client_schema() -> cv.Schema:
|
||||
@@ -66,21 +131,9 @@ def gatt_client_schema() -> cv.Schema:
|
||||
dispatch happens at call time, so call this from inside a validator or a
|
||||
per-platform schema builder, never at module import.
|
||||
"""
|
||||
if CORE.is_esp32:
|
||||
from esphome.components import esp32_ble_tracker
|
||||
|
||||
return esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA.extend(
|
||||
{cv.GenerateID(CONF_BACKEND_ID): cv.declare_id(BluedroidGattClient)}
|
||||
)
|
||||
from esphome.components import rp2040_ble
|
||||
|
||||
return cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_BACKEND_ID): cv.declare_id(RP2GattClient),
|
||||
cv.GenerateID(rp2040_ble.CONF_RP2040_BLE_ID): cv.use_id(
|
||||
rp2040_ble.RP2040BLE
|
||||
),
|
||||
}
|
||||
entry = _backend_entry()
|
||||
return entry.schema_fragment().extend(
|
||||
{cv.GenerateID(CONF_BACKEND_ID): cv.declare_id(entry.backend_class)}
|
||||
)
|
||||
|
||||
|
||||
@@ -106,17 +159,18 @@ def _ledger() -> _SlotLedger:
|
||||
return CORE.data[DOMAIN]
|
||||
|
||||
|
||||
def consume_gatt_slot(consumer: str):
|
||||
"""Validator claiming one GATT connection slot: the neutral ledger feeds
|
||||
the platform cap check in FINAL_VALIDATE_SCHEMA, and esp32 additionally
|
||||
charges the controller's connection budget."""
|
||||
def consume_gatt_slot(consumer: str, count: int = 1):
|
||||
"""Validator claiming GATT connection slots — the one spelling for every
|
||||
claimant (the proxy per configured slot, dedicated backends once). The
|
||||
neutral ledger feeds the platform cap check in FINAL_VALIDATE_SCHEMA;
|
||||
esp32 additionally charges the controller's connection budget."""
|
||||
|
||||
def validator(config: ConfigType) -> ConfigType:
|
||||
_ledger().consumers.append(consumer)
|
||||
_ledger().consumers.extend([consumer] * count)
|
||||
if CORE.is_esp32:
|
||||
from esphome.components import esp32_ble
|
||||
|
||||
esp32_ble.consume_connection_slots(1, consumer)(config)
|
||||
esp32_ble.consume_connection_slots(count, consumer)(config)
|
||||
return config
|
||||
|
||||
return validator
|
||||
@@ -140,48 +194,59 @@ def _validate_slot_totals(config: ConfigType) -> ConfigType:
|
||||
FINAL_VALIDATE_SCHEMA = _validate_slot_totals
|
||||
|
||||
|
||||
# The peer keys every dedicated-backend consumer shares: one target device.
|
||||
_PEER_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_MAC_ADDRESS): cv.mac_address,
|
||||
cv.Optional(CONF_ADDRESS_TYPE, default="public"): cv.enum(
|
||||
ADDRESS_TYPES, lower=True
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def gatt_client_config_schema(base_schema: cv.Schema, consumer: str):
|
||||
"""Wrap a dedicated-backend consumer's schema so the consumer stays
|
||||
platform-blind: gates on the platforms with a backend, folds in
|
||||
gatt_client_schema(), and claims the connection slot.
|
||||
`consumer` names the component in slot-exhaustion errors."""
|
||||
gatt_client_schema() plus the peer keys (mac_address, address_type),
|
||||
and claims the connection slot. `consumer` names the component in
|
||||
slot-exhaustion errors."""
|
||||
|
||||
@schema_extractor("schema")
|
||||
def apply(config: ConfigType) -> ConfigType:
|
||||
if config is SCHEMA_EXTRACT:
|
||||
# The language-schema dumper runs without a platform; expose the
|
||||
# consumer's own keys. Checked before the platform gate so the
|
||||
# dumper is not rejected by only_on.
|
||||
return base_schema
|
||||
# consumer's keys plus the platform-free peer keys.
|
||||
return base_schema.extend(_PEER_SCHEMA)
|
||||
cv.only_on(GATT_CLIENT_PLATFORMS)(config)
|
||||
config = base_schema.extend(gatt_client_schema())(config)
|
||||
schema = base_schema.extend(_PEER_SCHEMA).extend(gatt_client_schema())
|
||||
config = schema(config)
|
||||
return consume_gatt_slot(consumer)(config)
|
||||
|
||||
return apply
|
||||
|
||||
|
||||
async def new_gatt_backend(config: ConfigType) -> cg.MockObj:
|
||||
"""Instantiate the backend declared by gatt_client_schema(), register it
|
||||
with its platform stack, and claim one neutral GATT client slot.
|
||||
async def new_gatt_backend(
|
||||
config: ConfigType, *, service_table: bool = True
|
||||
) -> cg.MockObj:
|
||||
"""Instantiate the backend declared by gatt_client_schema() and register
|
||||
it with its platform stack. The connection slot is claimed at validation
|
||||
(gatt_client_config_schema / the proxy's slot validators), not here.
|
||||
|
||||
On esp32 the tracker's promote loop owns connect timing, so the backend's
|
||||
tracker-facing shim registers as a raw client; on rp2 the backend parents
|
||||
on the BTstack controller.
|
||||
service_table compiles the on-demand service-table materializer into the
|
||||
backend; direct consumers need it, the streaming proxy does not, so
|
||||
proxy-only builds keep the smaller footprint.
|
||||
"""
|
||||
from esphome.components import ble_device_base
|
||||
|
||||
ble_device_base.request_gatt_client()
|
||||
if service_table:
|
||||
cg.add_define("USE_BLE_GATT_SERVICE_TABLE")
|
||||
backend = cg.new_Pvariable(config[CONF_BACKEND_ID])
|
||||
# The backend has no user-facing component options; an empty config keeps
|
||||
# the consumer's own keys (update_interval, ...) off it.
|
||||
await cg.register_component(backend, {})
|
||||
if CORE.is_esp32:
|
||||
from esphome.components import esp32_ble_tracker
|
||||
|
||||
await esp32_ble_tracker.register_raw_client(backend.tracker_client(), config)
|
||||
else:
|
||||
from esphome.components import rp2040_ble
|
||||
|
||||
await cg.register_parented(backend, config[rp2040_ble.CONF_RP2040_BLE_ID])
|
||||
await _backend_entry().register(backend, config)
|
||||
return backend
|
||||
|
||||
|
||||
@@ -191,10 +256,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
|
||||
PlatformFramework.ESP32_ARDUINO,
|
||||
PlatformFramework.ESP32_IDF,
|
||||
},
|
||||
"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.
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
#include "bluetooth_connection.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#include <esp_bt_device.h>
|
||||
#include <esp_gattc_api.h>
|
||||
#endif
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
|
||||
#include "esphome/components/api/api_pb2.h"
|
||||
@@ -37,6 +42,23 @@ BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size
|
||||
return BatchClose::CONTINUE;
|
||||
}
|
||||
|
||||
#ifdef USE_ESP32
|
||||
// Address-scoped Bluedroid maintenance shared by every esp32 proxy build,
|
||||
// including advertisement-only ones where no GATT backend is compiled.
|
||||
|
||||
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);
|
||||
}
|
||||
#endif // USE_ESP32
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
|
||||
#endif // BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
|
||||
@@ -234,7 +234,9 @@ int BluedroidGattClient::update_connection_params(uint16_t min_interval, uint16_
|
||||
|
||||
void BluedroidGattClient::release_services() {
|
||||
this->service_total_ = 0;
|
||||
#ifdef USE_BLE_GATT_SERVICE_TABLE
|
||||
this->free_service_table_();
|
||||
#endif
|
||||
#ifndef CONFIG_BT_GATTC_CACHE_NVS_FLASH
|
||||
// Only the cache clean makes the stack's database unsafe to walk.
|
||||
this->services_released_ = true;
|
||||
@@ -242,6 +244,7 @@ void BluedroidGattClient::release_services() {
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef USE_BLE_GATT_SERVICE_TABLE
|
||||
ble_device_base::GattServiceTable BluedroidGattClient::get_service_table() {
|
||||
if (this->table_storage_ == nullptr &&
|
||||
(this->services_released_ || this->service_total_ == 0 || !this->build_service_table_())) {
|
||||
@@ -413,6 +416,7 @@ bool BluedroidGattClient::build_service_table_() {
|
||||
this->table_desc_total_ = desc_total;
|
||||
return true;
|
||||
}
|
||||
#endif // USE_BLE_GATT_SERVICE_TABLE
|
||||
|
||||
// ---- internals ----
|
||||
|
||||
@@ -657,6 +661,9 @@ void BluedroidGattClient::handle_open_evt_(esp_ble_gattc_cb_param_t *param) {
|
||||
// matching the previous esp32 behavior).
|
||||
this->seen_mtu_ = true;
|
||||
this->report_connection_state_(true, 0);
|
||||
// Settled: only the disconnect safety net needs the loop, and
|
||||
// set_disconnecting_() re-enables it.
|
||||
this->disable_loop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -750,6 +757,8 @@ bool BluedroidGattClient::handle_gattc_event_(esp_gattc_cb_event_t event, esp_ga
|
||||
ESP_LOGI(TAG, "[%d] Service discovery complete", this->connection_index_);
|
||||
this->set_state_(ClientState::ESTABLISHED);
|
||||
this->handle_search_cmpl_();
|
||||
// Settled (see the V3_WITH_CACHE arm in handle_open_evt_).
|
||||
this->disable_loop();
|
||||
break;
|
||||
}
|
||||
case ESP_GATTC_READ_CHAR_EVT:
|
||||
|
||||
@@ -72,11 +72,16 @@ class BluedroidGattClient final : public Component {
|
||||
int pair();
|
||||
int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout);
|
||||
// Materialized on demand from Bluedroid's cached database for direct
|
||||
// consumers that resolve handles by UUID (e.g. radon_eye_rd200). The proxy
|
||||
// wrapper never calls this - it streams through stream_service_batch - so
|
||||
// proxy peak RAM is unchanged; a direct consumer's peak is bounded by its
|
||||
// one known device's table.
|
||||
// consumers that resolve handles by UUID. The streaming consumer (the
|
||||
// proxy wrapper) never calls this - it uses stream_service_batch - so the
|
||||
// materializer only compiles when codegen declares a direct consumer
|
||||
// (USE_BLE_GATT_SERVICE_TABLE) and proxy-only builds keep the old
|
||||
// footprint; a direct consumer's peak is bounded by its one known device.
|
||||
#ifdef USE_BLE_GATT_SERVICE_TABLE
|
||||
ble_device_base::GattServiceTable get_service_table();
|
||||
#else
|
||||
ble_device_base::GattServiceTable get_service_table() { return {}; }
|
||||
#endif
|
||||
void release_services();
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
@@ -86,9 +91,7 @@ class BluedroidGattClient final : public Component {
|
||||
void stream_service_batch(BluetoothConnection &conn);
|
||||
#endif
|
||||
|
||||
void set_connection_type(esp32_ble_tracker::ConnectionType ct) { this->connection_type_ = ct; }
|
||||
bool disconnect_pending() const { return this->shim_.disconnect_pending(); }
|
||||
void cancel_pending_disconnect() { this->shim_.cancel_pending_disconnect(); }
|
||||
void set_connection_type(ble_device_base::ConnectionType ct) { this->connection_type_ = ct; }
|
||||
|
||||
protected:
|
||||
friend class BluedroidTrackerShim;
|
||||
@@ -110,20 +113,24 @@ class BluedroidGattClient final : public Component {
|
||||
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);
|
||||
#ifdef USE_BLE_GATT_SERVICE_TABLE
|
||||
template<typename ServiceFn, typename CharFn, typename DescFn>
|
||||
bool walk_database_(ServiceFn &&on_service, CharFn &&on_char, DescFn &&on_desc);
|
||||
bool build_service_table_();
|
||||
void free_service_table_();
|
||||
ble_device_base::GattServiceTable table_view_() const;
|
||||
#endif
|
||||
|
||||
// Group 1: pointers / composed objects
|
||||
BluedroidTrackerShim shim_{this};
|
||||
ble_device_base::GattEventSink sink_;
|
||||
#ifdef USE_BLE_GATT_SERVICE_TABLE
|
||||
// One exact-size block carved into the table's three arrays; owned here,
|
||||
// freed by release_services(). Null when no table is materialized. The
|
||||
// GattServiceTable view is rebuilt from this pointer and the counts on
|
||||
// each (cold) get_service_table() call instead of being cached.
|
||||
uint8_t *table_storage_{nullptr};
|
||||
#endif
|
||||
// Group 2: 4-byte types
|
||||
int gattc_if_{ESP_GATT_IF_NONE};
|
||||
uint32_t disconnecting_started_{0};
|
||||
@@ -135,9 +142,11 @@ class BluedroidGattClient final : public Component {
|
||||
uint16_t conn_id_{0xFFFF};
|
||||
uint16_t mtu_{23};
|
||||
uint16_t service_total_{0};
|
||||
#ifdef USE_BLE_GATT_SERVICE_TABLE
|
||||
// Filled element counts of the materialized table (0 when none).
|
||||
uint16_t table_char_total_{0};
|
||||
uint16_t table_desc_total_{0};
|
||||
#endif
|
||||
|
||||
// Group 5: 1-byte types
|
||||
// Stored narrow (the enum is 4 bytes); widened at the esp_ble_gattc_open call.
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
// Address-scoped Bluedroid maintenance shared by every esp32 proxy build,
|
||||
// including advertisement-only ones where no GATT backend is compiled.
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "bluetooth_connection.h"
|
||||
|
||||
#include <esp_bt_device.h>
|
||||
#include <esp_gattc_api.h>
|
||||
|
||||
namespace esphome::bluetooth_connection {
|
||||
|
||||
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
|
||||
@@ -44,8 +44,6 @@ class StubGattBackend {
|
||||
return ble_device_base::GATT_ERR_NOT_CONNECTED;
|
||||
}
|
||||
ble_device_base::GattServiceTable get_service_table() { return {}; }
|
||||
bool disconnect_pending() const { return false; }
|
||||
void cancel_pending_disconnect() {}
|
||||
void set_connection_type(ble_device_base::ConnectionType ct) {}
|
||||
void release_services() {}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 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"
|
||||
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
@@ -27,7 +27,7 @@ void BluetoothConnection::set_address(uint64_t address) {
|
||||
}
|
||||
|
||||
void BluetoothConnection::start_connect_() {
|
||||
// No connect timeout here (esp32 parity): the client's own timeout or
|
||||
// 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_);
|
||||
@@ -38,7 +38,7 @@ void BluetoothConnection::start_connect_() {
|
||||
}
|
||||
|
||||
void BluetoothConnection::disconnect() {
|
||||
// Idempotent like the esp32 class: the proxy's teardown loop calls this
|
||||
// Idempotent: 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).
|
||||
if (this->state_ == ClientState::IDLE || this->state_ == ClientState::DISCONNECTING) {
|
||||
@@ -65,10 +65,10 @@ void BluetoothConnection::disconnect() {
|
||||
}
|
||||
|
||||
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.
|
||||
if (this->state_ == ClientState::DISCONNECTING &&
|
||||
millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) {
|
||||
// Safety net: if the backend's disconnect completion is lost (or a refusal
|
||||
// left the teardown unresolved), force the slot free instead of leaking it.
|
||||
// The caller already gates on DISCONNECTING.
|
||||
if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) {
|
||||
ESP_LOGW(TAG, "[%d] [%s] Disconnect timeout, freeing slot", this->connection_index_, this->address_str_);
|
||||
this->reset_connection_(GATT_NOT_CONNECTED);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
|
||||
#include "bluetooth_connection.h"
|
||||
|
||||
// The wrapper exists to serve the proxy's API surface; dedicated-backend
|
||||
// consumers (radon_eye_rd200) drive the backend directly, so backend-only
|
||||
// builds compile this header empty.
|
||||
// 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_SERVES_PROXY
|
||||
|
||||
#include "esphome/components/ble_device_base/ble_client_state.h"
|
||||
@@ -33,7 +33,7 @@ class BluetoothConnection final {
|
||||
backend->set_sink(ble_device_base::make_gatt_sink(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);
|
||||
@@ -52,8 +52,6 @@ class BluetoothConnection final {
|
||||
bool is_paired() const { return this->paired_; }
|
||||
void set_unpaired() { this->paired_ = false; }
|
||||
conn_err_t pair() { return this->backend_->pair(); }
|
||||
bool disconnect_pending() const { return this->backend_->disconnect_pending(); }
|
||||
void cancel_pending_disconnect() { this->backend_->cancel_pending_disconnect(); }
|
||||
|
||||
void set_address(uint64_t address);
|
||||
uint64_t get_address() const { return this->address_; }
|
||||
@@ -71,8 +69,8 @@ class BluetoothConnection final {
|
||||
}
|
||||
// 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
|
||||
|
||||
@@ -25,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;
|
||||
|
||||
@@ -429,7 +428,7 @@ void RP2GattClient::loop() {
|
||||
}
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
|
||||
@@ -92,10 +92,7 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
|
||||
int pair();
|
||||
int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout);
|
||||
ble_device_base::GattServiceTable get_service_table();
|
||||
// No deferred-disconnect state (disconnect is one call) and no
|
||||
// connection-type branching on this backend.
|
||||
bool disconnect_pending() const { return false; }
|
||||
void cancel_pending_disconnect() {}
|
||||
// No connection-type branching on this backend.
|
||||
void set_connection_type(ble_device_base::ConnectionType ct) {}
|
||||
void release_services();
|
||||
|
||||
|
||||
@@ -9,12 +9,12 @@ 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]:
|
||||
@@ -94,7 +94,7 @@ def _esp32_config_schema() -> cv.All:
|
||||
)
|
||||
elif config[CONF_ACTIVE]:
|
||||
connection_slots: int = config[CONF_CONNECTION_SLOTS]
|
||||
esp32_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(
|
||||
bluetooth_connection.consume_gatt_slot("bluetooth_proxy", connection_slots)(
|
||||
config
|
||||
)
|
||||
|
||||
@@ -154,8 +154,9 @@ def _rp2_config_schema() -> cv.All:
|
||||
# their ids exist for codegen (the esp32 arm's `connections` pattern).
|
||||
if not config[CONF_ACTIVE]:
|
||||
return config
|
||||
for _ in range(config[CONF_CONNECTION_SLOTS]):
|
||||
bluetooth_connection.consume_gatt_slot("bluetooth_proxy")(config)
|
||||
bluetooth_connection.consume_gatt_slot(
|
||||
"bluetooth_proxy", config[CONF_CONNECTION_SLOTS]
|
||||
)(config)
|
||||
return {
|
||||
**config,
|
||||
CONF_CONNECTIONS: [
|
||||
@@ -197,7 +198,9 @@ async def _connections_to_code(var: cg.MockObj, config: ConfigType) -> None:
|
||||
"""One wrapper + backend pair per slot; the platform-specific backend
|
||||
registration lives in bluetooth_connection.new_gatt_backend()."""
|
||||
for connection_conf in config.get(CONF_CONNECTIONS, []):
|
||||
backend = await bluetooth_connection.new_gatt_backend(connection_conf)
|
||||
backend = await bluetooth_connection.new_gatt_backend(
|
||||
connection_conf, service_table=False
|
||||
)
|
||||
connection = cg.new_Pvariable(connection_conf[CONF_ID])
|
||||
cg.add(connection.set_backend(backend))
|
||||
cg.add(var.register_connection(connection))
|
||||
@@ -381,9 +384,6 @@ async def _to_code_ble_hub(config: ConfigType) -> None:
|
||||
# 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 _connections_to_code(var, config)
|
||||
|
||||
|
||||
|
||||
@@ -241,12 +241,6 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
|
||||
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());
|
||||
return;
|
||||
} else if (connection->state() != ClientState::INIT) {
|
||||
@@ -281,7 +275,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) {
|
||||
@@ -493,10 +487,10 @@ void BluetoothProxy::loop() {
|
||||
if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) {
|
||||
#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY
|
||||
// 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 && !connection->disconnect_pending()) {
|
||||
if (connection->get_address() != 0) {
|
||||
connection->disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,7 @@
|
||||
|
||||
#include "esphome/components/ble_device_base/ble_hub_impl.h"
|
||||
|
||||
#ifdef USE_BLE_GATT_CLIENT
|
||||
#include "esphome/components/bluetooth_connection/bluetooth_connection_hub.h"
|
||||
#endif
|
||||
|
||||
namespace esphome::bluetooth_proxy {
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ static const uint16_t MESSAGE_MIN_LEN_V2 = 68;
|
||||
void RadonEyeRD200::update() {
|
||||
// The backends refuse a connect on a non-idle slot, so a poll landing
|
||||
// mid-sequence just logs and retries next interval.
|
||||
int err = this->backend_->connect(this->address_, ble_device_base::BLE_ADDR_TYPE_PUBLIC);
|
||||
int err = this->backend_->connect(this->address_, this->address_type_);
|
||||
if (err != 0) {
|
||||
ESP_LOGW(TAG, "Connection in progress (err=%d)", err);
|
||||
return;
|
||||
|
||||
@@ -25,7 +25,8 @@ namespace esphome::radon_eye_rd200 {
|
||||
|
||||
class RadonEyeRD200 final : public PollingComponent {
|
||||
public:
|
||||
RadonEyeRD200(ble_device_base::BLEGattConnection *backend, uint64_t address) : backend_(backend), address_(address) {
|
||||
RadonEyeRD200(ble_device_base::BLEGattConnection *backend, uint64_t address, uint8_t address_type)
|
||||
: backend_(backend), address_(address), address_type_(address_type) {
|
||||
backend->set_sink(ble_device_base::make_gatt_sink(this));
|
||||
}
|
||||
|
||||
@@ -64,6 +65,7 @@ class RadonEyeRD200 final : public PollingComponent {
|
||||
|
||||
// Group 4: 1-byte types
|
||||
uint8_t write_command_{0};
|
||||
uint8_t address_type_;
|
||||
};
|
||||
|
||||
} // namespace esphome::radon_eye_rd200
|
||||
|
||||
@@ -10,6 +10,8 @@ from esphome.const import (
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
UNIT_BECQUEREL_PER_CUBIC_METER,
|
||||
)
|
||||
|
||||
# mac_address and address_type come from gatt_client_config_schema.
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["bluetooth_connection"]
|
||||
@@ -28,7 +30,6 @@ CONFIG_SCHEMA = bluetooth_connection.gatt_client_config_schema(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(RadonEyeRD200),
|
||||
cv.Required(CONF_MAC_ADDRESS): cv.mac_address,
|
||||
cv.Optional(CONF_RADON): _SENSOR_SCHEMA,
|
||||
cv.Optional(CONF_RADON_LONG_TERM): _SENSOR_SCHEMA,
|
||||
}
|
||||
@@ -39,7 +40,12 @@ CONFIG_SCHEMA = bluetooth_connection.gatt_client_config_schema(
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
backend = await bluetooth_connection.new_gatt_backend(config)
|
||||
var = cg.new_Pvariable(config[CONF_ID], backend, config[CONF_MAC_ADDRESS].as_hex)
|
||||
var = cg.new_Pvariable(
|
||||
config[CONF_ID],
|
||||
backend,
|
||||
config[CONF_MAC_ADDRESS].as_hex,
|
||||
config[bluetooth_connection.CONF_ADDRESS_TYPE],
|
||||
)
|
||||
await cg.register_component(var, config)
|
||||
|
||||
if (radon := config.get(CONF_RADON)) is not None:
|
||||
|
||||
@@ -307,6 +307,7 @@
|
||||
#define USE_ESP32_BLE_SERVER_ON_DISCONNECT
|
||||
#define USE_ESP32_BLE_TRACKER
|
||||
#define USE_BLE_GATT_CLIENT
|
||||
#define USE_BLE_GATT_SERVICE_TABLE
|
||||
#define ESPHOME_BLE_GATT_CLIENT_COUNT 1
|
||||
#define ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT 1
|
||||
#define ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT 1
|
||||
|
||||
@@ -203,6 +203,9 @@ def test_every_registered_hub_platform_has_a_schema_arm() -> None:
|
||||
registered = set(bluetooth_connection.HUB_MAX_CONNECTIONS)
|
||||
assert registered <= set(bluetooth_proxy._GATT_HUB_SCHEMAS)
|
||||
assert registered <= set(bluetooth_proxy._HUB_PLATFORMS)
|
||||
# Hub platforms must also be in the backend registry the shared consumer
|
||||
# helpers dispatch on.
|
||||
assert registered <= set(bluetooth_connection.GATT_CLIENT_PLATFORMS)
|
||||
# The outer walkable schema's bound must stay the loosest platform cap.
|
||||
assert (
|
||||
max(bluetooth_connection.HUB_MAX_CONNECTIONS.values())
|
||||
|
||||
@@ -53,8 +53,6 @@ class MinimalConnection {
|
||||
}
|
||||
GattServiceTable get_service_table() { return {}; }
|
||||
void release_services() {}
|
||||
bool disconnect_pending() const { return false; }
|
||||
void cancel_pending_disconnect() {}
|
||||
void set_connection_type(ConnectionType ct) {}
|
||||
|
||||
protected:
|
||||
@@ -109,7 +107,8 @@ TEST_F(ServiceTableLookup, FindsServicesAndCharacteristicsByUuid) {
|
||||
EXPECT_EQ(service->start_handle, 0x0010);
|
||||
EXPECT_EQ(find_service(this->table_, ESPBTUUID::from_uint16(0xFFFF)), nullptr);
|
||||
|
||||
const GattCharacteristic *characteristic = find_characteristic(this->table_, *service, ESPBTUUID::from_uint16(0x1525));
|
||||
const GattCharacteristic *characteristic =
|
||||
find_characteristic(this->table_, *service, ESPBTUUID::from_uint16(0x1525));
|
||||
ASSERT_NE(characteristic, nullptr);
|
||||
EXPECT_EQ(characteristic->value_handle, 0x0012);
|
||||
// The lookup is scoped to the service: 0x2A00 lives in the other service.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# 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
|
||||
# compiled (USE_BLE_GATT_CLIENT unset), which pins the SERVES_PROXY gating and the
|
||||
# address-scoped maintenance path that a connections build never exercises.
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
|
||||
Reference in New Issue
Block a user