Combined-stack simplify: loop discipline, gated materializer, registry, peer keys

This commit is contained in:
J. Nick Koston
2026-08-09 00:16:15 -05:00
parent ac6d2fe322
commit 2724ec3c30
21 changed files with 223 additions and 157 deletions
@@ -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();
+13 -13
View File
@@ -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
+8 -2
View File
@@ -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:
+1
View File
@@ -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