mirror of
https://github.com/esphome/esphome.git
synced 2026-08-17 02:47:52 +08:00
[rp2040_ble][bluetooth_connection] 3 connection slots on rp2 with esp32 parity (#18247)
This commit is contained in:
@@ -9,6 +9,7 @@ from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import rp2040_ble
|
||||
from esphome.config_helpers import (
|
||||
filter_source_files_from_platform,
|
||||
frameworks_for_platforms,
|
||||
@@ -36,9 +37,12 @@ CODEOWNERS = ["@bdraco", "@jesserockz"]
|
||||
|
||||
bluetooth_connection_ns = cg.esphome_ns.namespace("bluetooth_connection")
|
||||
|
||||
# arduino-pico's prebuilt BTstack is compiled with MAX_NR_GATT_CLIENTS 1;
|
||||
# raising this needs an upstream change (the layer itself supports N).
|
||||
RP2_MAX_CONNECTIONS = 1
|
||||
# arduino-pico's prebuilt BTstack is compiled with MAX_NR_GATT_CLIENTS 1 and
|
||||
# MAX_NR_HCI_CONNECTIONS 2; for more than one backend, rp2040_ble's
|
||||
# btstack_memory.cpp replaces those pools via linker --wrap (requested by
|
||||
# _rp2_register), sized from ESPHOME_BLE_GATT_CLIENT_COUNT. The cap itself
|
||||
# belongs to the platform stack that owns the pools.
|
||||
RP2_MAX_CONNECTIONS = rp2040_ble.MAX_CONNECTIONS
|
||||
|
||||
# Slot limits for the hub platforms running the connection-capable proxy;
|
||||
# the backend registry itself is _PLATFORM_BACKENDS below.
|
||||
@@ -53,6 +57,19 @@ BluedroidGattClient = bluetooth_connection_ns.class_(
|
||||
|
||||
CONF_BACKEND_ID = "backend_id"
|
||||
|
||||
DOMAIN = "bluetooth_connection"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ConnectionData:
|
||||
rp2_backend_count: int = 0
|
||||
|
||||
|
||||
def _get_data() -> _ConnectionData:
|
||||
if DOMAIN not in CORE.data:
|
||||
CORE.data[DOMAIN] = _ConnectionData()
|
||||
return CORE.data[DOMAIN]
|
||||
|
||||
|
||||
def _esp32_schema_fragment() -> cv.Schema:
|
||||
from esphome.components import esp32_ble_tracker
|
||||
@@ -61,8 +78,6 @@ def _esp32_schema_fragment() -> cv.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)}
|
||||
)
|
||||
@@ -77,15 +92,29 @@ async def _esp32_register(backend: cg.MockObj, config: ConfigType) -> None:
|
||||
|
||||
|
||||
async def _rp2_register(backend: cg.MockObj, config: ConfigType) -> None:
|
||||
from esphome.components import rp2040_ble
|
||||
from esphome.components import ota
|
||||
|
||||
# The backend drops its link when an OTA starts (esp32 tracker parity).
|
||||
ota.request_ota_state_listeners()
|
||||
# More than one backend outgrows the prebuilt BTstack pools: swap them for
|
||||
# the ESPHOME_BLE_GATT_CLIENT_COUNT-sized ones in rp2040_ble's
|
||||
# btstack_memory.cpp. Keyed to backend registrations (the same event that
|
||||
# grows the count that sizes the pools), so single-backend builds emit no
|
||||
# flags and stay byte-identical to previous releases.
|
||||
data = _get_data()
|
||||
data.rp2_backend_count += 1
|
||||
if data.rp2_backend_count == 2:
|
||||
rp2040_ble.add_btstack_pool_overrides()
|
||||
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."""
|
||||
"""One platform's backend: codegen class, extra schema keys, and stack
|
||||
registration. The esp32 fragments import their stack lazily because those
|
||||
imports register esp32-only automations as a side effect; rp2040_ble is
|
||||
side-effect-free, so it is imported at module scope (the cap constant
|
||||
needs it there anyway)."""
|
||||
|
||||
backend_class: cg.MockObjClass
|
||||
schema_fragment: Callable[[], cv.Schema]
|
||||
|
||||
@@ -103,10 +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. 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.
|
||||
// shared steady-state parameters. Both backends already open cached
|
||||
// connections with these values (esp32 prefer-params, rp2 initiating
|
||||
// params), so this request is normally redundant - kept as a backstop
|
||||
// in case the initial parameters were negotiated away.
|
||||
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,
|
||||
|
||||
@@ -77,8 +77,9 @@ class BluetoothConnection final : public ble_device_base::GattClientListener {
|
||||
bool connected() const { return this->state_ == ClientState::ESTABLISHED; }
|
||||
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.
|
||||
// Both backends branch on the type before connecting (bluedroid picks
|
||||
// prefer-params and the with-cache report at OPEN_EVT; rp2 picks the
|
||||
// initiating parameters), so this must be set before the connect starts.
|
||||
this->backend_->set_connection_type(ct);
|
||||
}
|
||||
// Latched at discovery completion rather than read from the backend table:
|
||||
|
||||
@@ -26,6 +26,13 @@ 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;
|
||||
// Budget after a cancel is in flight: its completion normally lands within
|
||||
// tens of ms, and while the engine waits it pins the stack-wide connect slot,
|
||||
// so a lost completion must cost seconds, not another full connect budget.
|
||||
static constexpr uint32_t CONNECT_CANCEL_TIMEOUT_MS = 2000;
|
||||
// Pending engines re-attempt gap_connect on this cadence instead of every
|
||||
// loop pass: the DISALLOWED path (teardown overlap) takes BluetoothLock.
|
||||
static constexpr uint32_t CONNECT_RETRY_INTERVAL_MS = 50;
|
||||
// Can-send windows normally open within a connection interval (tens of ms).
|
||||
static constexpr uint32_t WRITE_NO_RSP_TIMEOUT_MS = 500;
|
||||
|
||||
@@ -54,6 +61,7 @@ RP2GattClient *RP2GattClient::instances[ESPHOME_BLE_GATT_CLIENT_COUNT] = {};
|
||||
uint8_t RP2GattClient::instance_count = 0;
|
||||
btstack_packet_callback_registration_t RP2GattClient::hci_event_registration = {};
|
||||
btstack_packet_callback_registration_t RP2GattClient::sm_event_registration = {};
|
||||
RP2GattClient *RP2GattClient::connect_owner = nullptr;
|
||||
// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
static ESPBTUUID uuid_from_btstack(uint16_t uuid16, const uint8_t uuid128[16]) {
|
||||
@@ -84,6 +92,7 @@ void RP2GattClient::setup() {
|
||||
// One locked section: the slot store lands before the count bump, and a
|
||||
// live HCI handler (N > 1 builds) cannot read a half-written registry.
|
||||
BluetoothLock lock;
|
||||
this->engine_index_ = instance_count;
|
||||
instances[instance_count] = this;
|
||||
instance_count++;
|
||||
// One HCI event handler for all engine instances (BTstack supports
|
||||
@@ -96,9 +105,24 @@ void RP2GattClient::setup() {
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
ota::get_global_ota_callback()->add_global_state_listener(this);
|
||||
#endif
|
||||
|
||||
this->disable_loop();
|
||||
}
|
||||
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
void RP2GattClient::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) {
|
||||
// esp32 parity (its tracker disconnects every client at OTA start): free
|
||||
// the shared radio for the transfer. No restore needed; the client
|
||||
// reconnects, and on success the device reboots anyway.
|
||||
if (state == ota::OTA_STARTED && this->state_ != EngineState::IDLE) {
|
||||
this->gatt_disconnect();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
float RP2GattClient::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; }
|
||||
|
||||
void RP2GattClient::dump_config() { ESP_LOGCONFIG(TAG, "RP2 GATT client (BTstack)"); }
|
||||
@@ -124,34 +148,56 @@ void RP2GattClient::hci_packet_handler(uint8_t type, uint16_t channel, uint8_t *
|
||||
if (hci_event_gap_meta_get_subevent_code(packet) != GAP_SUBEVENT_LE_CONNECTION_COMPLETE) {
|
||||
break;
|
||||
}
|
||||
bd_addr_t peer;
|
||||
gap_subevent_le_connection_complete_get_peer_address(packet, peer);
|
||||
uint8_t status = gap_subevent_le_connection_complete_get_status(packet);
|
||||
hci_con_handle_t con_handle = gap_subevent_le_connection_complete_get_connection_handle(packet);
|
||||
// Route to the engine that is waiting for this peer.
|
||||
for (uint8_t i = 0; i < instance_count; i++) {
|
||||
RP2GattClient *inst = instances[i];
|
||||
if (inst->state_ == EngineState::CONNECTING && memcmp(inst->peer_addr_, peer, sizeof(bd_addr_t)) == 0) {
|
||||
inst->enqueue_event_irq_(RP2GattEvent::CONNECTED, status, con_handle);
|
||||
break;
|
||||
bd_addr_t peer;
|
||||
gap_subevent_le_connection_complete_get_peer_address(packet, peer);
|
||||
// Route by ownership, not address: gap_connect refuses a new
|
||||
// create-connection until the previous completion is processed, so the
|
||||
// event belongs to the owner by construction. Cancel completions carry
|
||||
// a zeroed peer address on this controller, so an address match would
|
||||
// drop them and pin the owner until its backstop.
|
||||
RP2GattClient *inst = connect_owner;
|
||||
static constexpr bd_addr_t ZERO_ADDR = {};
|
||||
if (inst != nullptr && memcmp(peer, ZERO_ADDR, sizeof(bd_addr_t)) != 0 &&
|
||||
memcmp(inst->peer_addr_, peer, sizeof(bd_addr_t)) != 0) {
|
||||
// Addressed completion for a peer the owner is not connecting to: a
|
||||
// success delayed past a cancel and an ownership handoff (the cancel
|
||||
// idles the stack's request immediately) must not stamp the old
|
||||
// procedure's link onto the new owner. Zero-address (cancel)
|
||||
// completions need no such guard: BTstack only emits them while its
|
||||
// request state is idle, and a new owner re-arms that state when it
|
||||
// claims the token, so a stale cancel completion is swallowed by the
|
||||
// stack, never re-attributed. A successful stale link still needs
|
||||
// disposal (same hazard as the unowned branch below).
|
||||
if (status == 0) {
|
||||
gap_disconnect(con_handle);
|
||||
}
|
||||
break;
|
||||
}
|
||||
connect_owner = nullptr;
|
||||
if (inst == nullptr) {
|
||||
if (status == 0) {
|
||||
// Nobody owns this late link (the owner escalated first): tear it
|
||||
// down here or the hci_connection_t leaks and the peer answers
|
||||
// DISALLOWED until reboot.
|
||||
gap_disconnect(con_handle);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (status == 0) {
|
||||
// Stamp the handle here in the BTstack context: a disconnection
|
||||
// racing the queued CONNECTED event arrives in this same context
|
||||
// and must route by handle (it carries no address).
|
||||
inst->con_handle_ = con_handle;
|
||||
}
|
||||
inst->enqueue_event_irq_(RP2GattEvent::CONNECTED, status, con_handle);
|
||||
break;
|
||||
}
|
||||
case HCI_EVENT_DISCONNECTION_COMPLETE: {
|
||||
hci_con_handle_t con_handle = hci_event_disconnection_complete_get_connection_handle(packet);
|
||||
RP2GattClient *inst = instance_for_con_handle(con_handle);
|
||||
if (inst == nullptr && instance_count == 1) {
|
||||
// The main loop may not have recorded the handle yet (the CONNECTED
|
||||
// event is still queued); with a single engine the connecting
|
||||
// instance is unambiguous, so route there to close the
|
||||
// accept-then-drop window. With multiple engines the event has no
|
||||
// address to match on, so it must be dropped instead of guessed.
|
||||
RP2GattClient *candidate = instances[0];
|
||||
if (candidate->con_handle_ == HCI_CON_HANDLE_INVALID && candidate->state_ != EngineState::IDLE) {
|
||||
inst = candidate;
|
||||
}
|
||||
}
|
||||
// Routable even against a still-queued CONNECTED event: the handle is
|
||||
// stamped in this context at connection-complete time.
|
||||
RP2GattClient *inst = instance_for_con_handle(hci_event_disconnection_complete_get_connection_handle(packet));
|
||||
if (inst != nullptr) {
|
||||
inst->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, hci_event_disconnection_complete_get_reason(packet), 0);
|
||||
}
|
||||
@@ -393,44 +439,73 @@ void RP2GattClient::loop() {
|
||||
if (dropped > 0) {
|
||||
// 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);
|
||||
ESP_LOGE(TAG, "[%u] Dropped %u GATT control events, disconnecting", this->engine_index_, dropped);
|
||||
this->gatt_disconnect();
|
||||
}
|
||||
uint16_t notify_dropped = this->notify_queue_.get_and_reset_dropped_count();
|
||||
if (notify_dropped > 0) {
|
||||
ESP_LOGW(TAG, "Dropped %u GATT notifications (queue full)", notify_dropped);
|
||||
ESP_LOGW(TAG, "[%u] Dropped %u GATT notifications (queue full)", this->engine_index_, notify_dropped);
|
||||
}
|
||||
|
||||
if (this->state_ == EngineState::CONNECTING || this->state_ == EngineState::MTU_EXCHANGE) {
|
||||
if (this->state_ == EngineState::CONNECT_PENDING) {
|
||||
uint32_t now = millis();
|
||||
if (now - this->connect_started_ > CONNECT_TIMEOUT_MS) {
|
||||
ESP_LOGW(TAG, "Connect timeout");
|
||||
if (this->state_ == EngineState::CONNECTING && this->con_handle_ == HCI_CON_HANDLE_INVALID) {
|
||||
if (!this->connect_cancel_attempted_) {
|
||||
this->connect_cancel_attempted_ = true;
|
||||
BluetoothLock lock;
|
||||
// Never reached the radio; nothing stack-side to cancel.
|
||||
ESP_LOGW(TAG, "[%u] Connect timeout (queued)", this->engine_index_);
|
||||
this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT);
|
||||
} else if (now - this->connect_retry_ms_ >= CONNECT_RETRY_INTERVAL_MS) {
|
||||
this->connect_retry_ms_ = now;
|
||||
if (int err = this->try_gap_connect_(); err != 0) {
|
||||
this->fail_connection_(static_cast<uint8_t>(err));
|
||||
}
|
||||
}
|
||||
} else if (this->state_ == EngineState::CONNECTING || this->state_ == EngineState::MTU_EXCHANGE) {
|
||||
uint32_t now = millis();
|
||||
bool cancel_in_flight = this->state_ == EngineState::CONNECTING && this->con_handle_ == HCI_CON_HANDLE_INVALID &&
|
||||
this->connect_cancel_attempted_;
|
||||
uint32_t budget = cancel_in_flight ? CONNECT_CANCEL_TIMEOUT_MS : CONNECT_TIMEOUT_MS;
|
||||
if (now - this->connect_started_ > budget) {
|
||||
ESP_LOGW(TAG, "[%u] Connect timeout", this->engine_index_);
|
||||
bool link_up = this->state_ != EngineState::CONNECTING;
|
||||
bool cancel_sent = false;
|
||||
if (!link_up) {
|
||||
BluetoothLock lock;
|
||||
// Handle check under the lock: a success completion can stamp it in
|
||||
// the BTstack context right up to this point, and escalating past a
|
||||
// live link would orphan it (the queued CONNECTED event is dropped
|
||||
// by the state guard once fail_connection_ runs).
|
||||
link_up = this->con_handle_ != HCI_CON_HANDLE_INVALID;
|
||||
if (!link_up && connect_owner == this) {
|
||||
// gap_connect_cancel is stack-global; only the engine whose
|
||||
// create-connection is in flight may issue it. First timeout:
|
||||
// cancel and give the completion a grace period. Second: the
|
||||
// completion was lost, re-issue the cancel in case the procedure
|
||||
// still runs (a no-op on an idle stack), then escalate.
|
||||
gap_connect_cancel();
|
||||
// The cancel produces a connection-complete event with a failure
|
||||
// status, which drives the normal failure path; restart the timer
|
||||
// so a lost event escalates below instead of wedging here.
|
||||
this->connect_started_ = now;
|
||||
} else {
|
||||
// The cancel's completion never arrived: reclaim the slot and the
|
||||
// scan rather than cancelling forever.
|
||||
this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT);
|
||||
cancel_sent = !this->connect_cancel_attempted_;
|
||||
}
|
||||
} else {
|
||||
// The link is up (MTU exchange stalled): tear it down properly so the
|
||||
// controller frees its side; the DISCONNECTING safety net below
|
||||
// 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->connect_cancel_attempted_ = true;
|
||||
}
|
||||
if (link_up) {
|
||||
// The link is up (stamped mid-timeout or MTU exchange stalled): tear
|
||||
// it down properly so the controller frees its side; the
|
||||
// DISCONNECTING safety net below reclaims state if the disconnection
|
||||
// event is lost. Dropping engine state without gap_disconnect would
|
||||
// leak the live link and this engine's GATT slot for the rest of the
|
||||
// boot.
|
||||
this->gatt_disconnect();
|
||||
} else if (cancel_sent) {
|
||||
// The cancel produces a connection-complete event with a failure
|
||||
// status, which drives the normal failure path; restart the timer so
|
||||
// a lost event escalates on the short cancel budget.
|
||||
this->connect_started_ = now;
|
||||
} else {
|
||||
this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT);
|
||||
}
|
||||
}
|
||||
} else if (this->state_ == EngineState::DISCONNECTING) {
|
||||
if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) {
|
||||
ESP_LOGW(TAG, "Disconnect timeout, forcing idle");
|
||||
ESP_LOGW(TAG, "[%u] Disconnect timeout, forcing idle", this->engine_index_);
|
||||
this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT);
|
||||
}
|
||||
} else if (this->state_ == EngineState::READY && this->op_type_ == OpType::WRITE_CHAR_NO_RSP &&
|
||||
@@ -446,7 +521,7 @@ void RP2GattClient::loop() {
|
||||
}
|
||||
}
|
||||
if (timed_out) {
|
||||
ESP_LOGW(TAG, "Deferred write timeout, handle=0x%04x", this->op_handle_);
|
||||
ESP_LOGW(TAG, "[%u] Deferred write timeout, handle=0x%04x", this->engine_index_, this->op_handle_);
|
||||
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_() &&
|
||||
@@ -467,7 +542,7 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) {
|
||||
case RP2GattEvent::MTU_EXCHANGED:
|
||||
if (this->state_ == EngineState::MTU_EXCHANGE) {
|
||||
this->mtu_ = event.value;
|
||||
ESP_LOGD(TAG, "MTU %u", this->mtu_);
|
||||
ESP_LOGD(TAG, "[%u] MTU %u", this->engine_index_, this->mtu_);
|
||||
this->state_ = EngineState::READY;
|
||||
// Scanning resumes and runs alongside the established connection.
|
||||
this->release_scan_inhibit_();
|
||||
@@ -515,7 +590,7 @@ void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) {
|
||||
return;
|
||||
}
|
||||
if (status != 0) {
|
||||
ESP_LOGW(TAG, "Connect failed, status=0x%02x", status);
|
||||
ESP_LOGW(TAG, "[%u] Connect failed, status=0x%02x", this->engine_index_, status);
|
||||
this->fail_connection_(status);
|
||||
return;
|
||||
}
|
||||
@@ -539,7 +614,7 @@ void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) {
|
||||
}
|
||||
this->con_handle_ = con_handle;
|
||||
this->state_ = EngineState::MTU_EXCHANGE;
|
||||
ESP_LOGD(TAG, "Link up, handle=0x%04x, negotiating MTU", con_handle);
|
||||
ESP_LOGD(TAG, "[%u] Link up, handle=0x%04x, negotiating MTU", this->engine_index_, con_handle);
|
||||
BluetoothLock lock;
|
||||
// One wildcard listener covers notifications/indications for every
|
||||
// characteristic on this connection; the CCCD writes come from the API
|
||||
@@ -564,6 +639,24 @@ void RP2GattClient::release_scan_inhibit_() {
|
||||
}
|
||||
|
||||
void RP2GattClient::fail_connection_(uint8_t reason) {
|
||||
{
|
||||
// Timeout escalation can fire with the completion event lost; release the
|
||||
// stack-wide connect slot so pending engines can proceed. Until the old
|
||||
// completion is processed, gap_connect answers any peer with DISALLOWED
|
||||
// (the request-level guard in hci.c); a cancel idles that request
|
||||
// immediately, and a late addressed completion from the old procedure is
|
||||
// then dropped by the owner-peer cross-check in the handler.
|
||||
BluetoothLock lock;
|
||||
if (connect_owner == this) {
|
||||
connect_owner = nullptr;
|
||||
}
|
||||
if (this->state_ == EngineState::CONNECTING && this->con_handle_ != HCI_CON_HANDLE_INVALID) {
|
||||
// A success completion stamped the handle between the escalation
|
||||
// decision and this lock: tear the link down before cleanup wipes the
|
||||
// handle, or it leaks its pool block for the rest of the boot.
|
||||
gap_disconnect(this->con_handle_);
|
||||
}
|
||||
}
|
||||
this->cleanup_link_state_();
|
||||
this->release_scan_inhibit_();
|
||||
this->state_ = EngineState::IDLE;
|
||||
@@ -577,14 +670,19 @@ void RP2GattClient::cleanup_link_state_() {
|
||||
while ((stale = this->notify_queue_.pop()) != nullptr) {
|
||||
this->notify_pool_.release(stale);
|
||||
}
|
||||
// The wildcard listener is registered on the normal connect path right
|
||||
// after con_handle_ is recorded; the cancel branch tears down before
|
||||
// registering, where stop_listening on an unregistered entry is a no-op.
|
||||
if (this->con_handle_ != HCI_CON_HANDLE_INVALID) {
|
||||
// con_handle_ may be stamped in the BTstack context before the main loop
|
||||
// registers the listener, so a valid handle does not imply a registration;
|
||||
// stop_listening on an unregistered entry is a benign no-op. One lock
|
||||
// scope around check and reset so an IRQ stamp cannot land in between
|
||||
// (unreachable today — ownership is released before cleanup — but the
|
||||
// invariant lives three functions away).
|
||||
{
|
||||
BluetoothLock lock;
|
||||
gatt_client_stop_listening_for_characteristic_value_updates(&this->notification_registration_);
|
||||
if (this->con_handle_ != HCI_CON_HANDLE_INVALID) {
|
||||
gatt_client_stop_listening_for_characteristic_value_updates(&this->notification_registration_);
|
||||
}
|
||||
this->con_handle_ = HCI_CON_HANDLE_INVALID;
|
||||
}
|
||||
this->con_handle_ = HCI_CON_HANDLE_INVALID;
|
||||
this->notify_subscription_count_ = 0;
|
||||
this->cancel_requested_ = false;
|
||||
this->op_type_ = OpType::NONE;
|
||||
@@ -596,7 +694,7 @@ void RP2GattClient::handle_disconnected_(uint8_t reason) {
|
||||
if (this->state_ == EngineState::IDLE) {
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "Disconnected, reason=0x%02x", reason);
|
||||
ESP_LOGD(TAG, "[%u] Disconnected, reason=0x%02x", this->engine_index_, reason);
|
||||
this->fail_connection_(reason);
|
||||
}
|
||||
|
||||
@@ -654,7 +752,7 @@ int RP2GattClient::discover_services() {
|
||||
RAMAllocator<ServiceArena> allocator(RAMAllocator<ServiceArena>::ALLOC_INTERNAL);
|
||||
this->arena_ = allocator.allocate(1);
|
||||
if (this->arena_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Service table allocation failed");
|
||||
ESP_LOGE(TAG, "[%u] Service table allocation failed", this->engine_index_);
|
||||
return ble_device_base::GATT_ERR_NO_MEMORY;
|
||||
}
|
||||
new (this->arena_) ServiceArena();
|
||||
@@ -760,8 +858,8 @@ void RP2GattClient::advance_discovery_(uint8_t att_status) {
|
||||
|
||||
void RP2GattClient::finish_discovery_(int error) {
|
||||
this->discovery_phase_ = DiscoveryPhase::NONE;
|
||||
ESP_LOGD(TAG, "Discovery done (err=%d): %u services, %u characteristics, %u descriptors", error, this->service_count_,
|
||||
this->char_count_, this->desc_count_);
|
||||
ESP_LOGD(TAG, "[%u] Discovery done (err=%d): %u services, %u characteristics, %u descriptors", this->engine_index_,
|
||||
error, this->service_count_, this->char_count_, this->desc_count_);
|
||||
if (error == 0 && this->truncated_) {
|
||||
// A partial table must not stream: V3 clients cache the database
|
||||
// permanently, so an incomplete one would be wrong forever.
|
||||
@@ -839,22 +937,68 @@ int RP2GattClient::connect(uint64_t address, uint8_t addr_type) {
|
||||
this->parent_->inhibit_scan();
|
||||
this->connect_cancel_attempted_ = false;
|
||||
this->cancel_requested_ = false;
|
||||
// Bounds the queued wait; restarted when gap_connect is accepted so the
|
||||
// radio attempt gets its full budget (HA's own ~20 s timeout arbitrates the
|
||||
// sum via a disconnect request).
|
||||
this->connect_started_ = millis();
|
||||
if (int err = this->try_gap_connect_(); err != 0) {
|
||||
this->release_scan_inhibit_();
|
||||
return err;
|
||||
}
|
||||
this->enable_loop();
|
||||
return 0;
|
||||
}
|
||||
|
||||
// One outgoing LE create-connection exists stack-wide: issue it if no other
|
||||
// engine owns it, otherwise park in CONNECT_PENDING for loop() to retry.
|
||||
// Returns nonzero only for hard failures (state untouched; caller cleans up).
|
||||
int RP2GattClient::try_gap_connect_() {
|
||||
// Unlocked peek: single core, aligned pointer; a stale value costs one loop
|
||||
// pass and the locked re-check below is authoritative. Keeps the per-loop
|
||||
// pending retry from taking BluetoothLock just to find the radio busy.
|
||||
if (connect_owner != nullptr) {
|
||||
this->state_ = EngineState::CONNECT_PENDING;
|
||||
return 0;
|
||||
}
|
||||
uint8_t status;
|
||||
{
|
||||
BluetoothLock lock;
|
||||
gap_set_connection_parameters(CONN_SCAN_INTERVAL, CONN_SCAN_WINDOW, FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL,
|
||||
0, FAST_CONN_TIMEOUT, CONN_CE_MIN, CONN_CE_MAX);
|
||||
status = gap_connect(this->peer_addr_, this->peer_addr_type_);
|
||||
if (connect_owner != nullptr) {
|
||||
status = ERROR_CODE_COMMAND_DISALLOWED;
|
||||
} else {
|
||||
// esp32 parity: cached connections come up at MEDIUM already (nothing
|
||||
// consumes the fast interval without a discovery phase), so there is no
|
||||
// post-connect update procedure to race or silently lose; sustained
|
||||
// FAST intervals also starve WiFi on the shared CYW43 radio.
|
||||
// Without-cache runs FAST for discovery and steps down in
|
||||
// finish_discovery_.
|
||||
bool cached = this->connection_type_ == ble_device_base::ConnectionType::V3_WITH_CACHE;
|
||||
gap_set_connection_parameters(CONN_SCAN_INTERVAL, CONN_SCAN_WINDOW,
|
||||
cached ? MEDIUM_MIN_CONN_INTERVAL : FAST_MIN_CONN_INTERVAL,
|
||||
cached ? MEDIUM_MAX_CONN_INTERVAL : FAST_MAX_CONN_INTERVAL, 0,
|
||||
cached ? MEDIUM_CONN_TIMEOUT : FAST_CONN_TIMEOUT, CONN_CE_MIN, CONN_CE_MAX);
|
||||
status = gap_connect(this->peer_addr_, this->peer_addr_type_);
|
||||
if (status == 0) {
|
||||
connect_owner = this;
|
||||
// Still under the lock: a synthesized failure completion can fire in
|
||||
// the BTstack context the instant it releases, and completion routing
|
||||
// requires CONNECTING — set after the fact, the event is discarded
|
||||
// and the engine burns its whole budget waiting for it.
|
||||
this->state_ = EngineState::CONNECTING;
|
||||
this->connect_started_ = millis();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (status != 0) {
|
||||
ESP_LOGW(TAG, "gap_connect failed, status=0x%02x", status);
|
||||
this->release_scan_inhibit_();
|
||||
return status;
|
||||
if (status == 0) {
|
||||
return 0;
|
||||
}
|
||||
this->state_ = EngineState::CONNECTING;
|
||||
this->connect_started_ = millis();
|
||||
this->enable_loop();
|
||||
return 0;
|
||||
if (status == ERROR_CODE_COMMAND_DISALLOWED) {
|
||||
// Radio busy with another engine's connect; resolved from loop().
|
||||
this->state_ = EngineState::CONNECT_PENDING;
|
||||
return 0;
|
||||
}
|
||||
ESP_LOGW(TAG, "[%u] gap_connect failed, status=0x%02x", this->engine_index_, status);
|
||||
return status;
|
||||
}
|
||||
|
||||
int RP2GattClient::gatt_disconnect() {
|
||||
@@ -863,6 +1007,10 @@ int RP2GattClient::gatt_disconnect() {
|
||||
return GATT_ERR_NOT_CONNECTED;
|
||||
case EngineState::DISCONNECTING:
|
||||
return 0; // already on its way down
|
||||
case EngineState::CONNECT_PENDING:
|
||||
// Nothing issued stack-side; the invalid handle takes the refused
|
||||
// path below without touching the stack.
|
||||
break;
|
||||
case EngineState::CONNECTING: {
|
||||
if (this->con_handle_ == HCI_CON_HANDLE_INVALID) {
|
||||
// The cancel can lose the race against a successful connection
|
||||
@@ -871,9 +1019,18 @@ int RP2GattClient::gatt_disconnect() {
|
||||
// attempt, so a lost completion escalates on the next timeout tick.
|
||||
this->cancel_requested_ = true;
|
||||
this->connect_cancel_attempted_ = true;
|
||||
// Grace period for the cancel completion: the client's disconnect
|
||||
// often lands right at the engine's own deadline, and without the
|
||||
// restart the loop timeout fires first and reports before the
|
||||
// completion can finish the teardown cleanly.
|
||||
this->connect_started_ = millis();
|
||||
BluetoothLock lock;
|
||||
gap_connect_cancel();
|
||||
// Completion arrives as a failed connection-complete event.
|
||||
// Owner: the cancel completes as a failed connection-complete. Not
|
||||
// the owner (completion already resolved in the BTstack context): the
|
||||
// queued event drives the same teardown, nothing to cancel.
|
||||
if (connect_owner == this) {
|
||||
gap_connect_cancel();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
@@ -881,20 +1038,23 @@ int RP2GattClient::gatt_disconnect() {
|
||||
default:
|
||||
break;
|
||||
}
|
||||
uint8_t status;
|
||||
{
|
||||
BluetoothLock lock;
|
||||
status = gap_disconnect(this->con_handle_);
|
||||
}
|
||||
if (status != 0) {
|
||||
// Refused (handle already gone): complete via the event queue so the
|
||||
// listener cannot re-enter disconnect() mid-call. BluetoothLock stops
|
||||
// the IRQ producer, so this main-loop push is SPSC-safe.
|
||||
ESP_LOGW(TAG, "gap_disconnect failed, status=0x%02x", status);
|
||||
uint8_t status = ERROR_CODE_UNKNOWN_CONNECTION_IDENTIFIER;
|
||||
if (this->con_handle_ != HCI_CON_HANDLE_INVALID) {
|
||||
{
|
||||
BluetoothLock lock;
|
||||
this->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, HCI_REASON_CONNECTION_TIMEOUT, 0);
|
||||
status = gap_disconnect(this->con_handle_);
|
||||
}
|
||||
if (status != 0) {
|
||||
ESP_LOGW(TAG, "[%u] gap_disconnect failed, status=0x%02x", this->engine_index_, status);
|
||||
}
|
||||
}
|
||||
if (status != 0) {
|
||||
// Refused (handle already gone) or never issued (CONNECT_PENDING):
|
||||
// complete via the event queue so the listener cannot re-enter
|
||||
// disconnect mid-call. BluetoothLock stops the IRQ producer, so this
|
||||
// main-loop push is SPSC-safe.
|
||||
BluetoothLock lock;
|
||||
this->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, HCI_REASON_CONNECTION_TIMEOUT, 0);
|
||||
}
|
||||
this->state_ = EngineState::DISCONNECTING;
|
||||
this->disconnecting_started_ = millis();
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/lock_free_queue.h"
|
||||
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
#include "esphome/components/ota/ota_backend.h"
|
||||
#endif
|
||||
|
||||
#include <btstack.h>
|
||||
|
||||
#include <array>
|
||||
@@ -71,7 +75,13 @@ static constexpr uint8_t RP2_GATT_EVENT_QUEUE_SIZE = 8;
|
||||
// full 512 B ATT payload, so depth buys burst tolerance at ~516 B per slot.
|
||||
static constexpr uint8_t RP2_GATT_NOTIFY_QUEUE_SIZE = 4;
|
||||
|
||||
class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040BLE> {
|
||||
class RP2GattClient final : public Component,
|
||||
public Parented<rp2040_ble::RP2040BLE>
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
,
|
||||
public ota::OTAGlobalStateListener
|
||||
#endif
|
||||
{
|
||||
public:
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
@@ -95,18 +105,26 @@ 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) {}
|
||||
// Cached connections initiate at MEDIUM parameters (esp32 parity); FAST is
|
||||
// reserved for the discovery phase of uncached connects.
|
||||
void set_connection_type(ble_device_base::ConnectionType ct) { this->connection_type_ = ct; }
|
||||
void release_services();
|
||||
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
// Drop the connection while an OTA runs (esp32 parity): an active link
|
||||
// competes with the transfer for the shared radio.
|
||||
void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
// Link/engine state. Discovery and GATT ops have their own cursors below —
|
||||
// the link stays READY while they run.
|
||||
enum class EngineState : uint8_t {
|
||||
IDLE,
|
||||
CONNECTING, // gap_connect issued, waiting for connection complete
|
||||
MTU_EXCHANGE, // link up, waiting for GATT_EVENT_MTU
|
||||
READY, // on_connection_state(true) delivered
|
||||
CONNECT_PENDING, // queued: another engine owns the stack-wide create-connection
|
||||
CONNECTING, // gap_connect issued, waiting for connection complete
|
||||
MTU_EXCHANGE, // link up, waiting for GATT_EVENT_MTU
|
||||
READY, // on_connection_state(true) delivered
|
||||
DISCONNECTING,
|
||||
};
|
||||
|
||||
@@ -143,6 +161,7 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
|
||||
int issue_descriptor_query_(uint16_t char_index);
|
||||
void finish_discovery_(int error);
|
||||
void fail_connection_(uint8_t reason);
|
||||
int try_gap_connect_();
|
||||
void cleanup_link_state_();
|
||||
bool notify_subscribed_(uint16_t handle) const;
|
||||
static void can_write_no_rsp_trampoline(void *context);
|
||||
@@ -171,8 +190,12 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
|
||||
|
||||
// Group 3: 4-byte types
|
||||
uint32_t connect_started_{0};
|
||||
uint32_t connect_retry_ms_{0}; // last CONNECT_PENDING gap_connect attempt
|
||||
uint32_t disconnecting_started_{0};
|
||||
uint32_t write_no_rsp_started_{0};
|
||||
// Unscoped C enum, so int-sized: lives with the 4-byte members to keep the
|
||||
// padding at the tail.
|
||||
bd_addr_type_t peer_addr_type_{BD_ADDR_TYPE_LE_PUBLIC};
|
||||
|
||||
// Group 4: 2-byte types (table counters written from the handler during
|
||||
// discovery, read from the main loop after the phase's QUERY_COMPLETE)
|
||||
@@ -191,8 +214,9 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
|
||||
// listener's deliveries on this list (esp32 parity for enable=false).
|
||||
std::array<uint16_t, RP2_GATT_MAX_NOTIFY_SUBSCRIPTIONS> notify_subscriptions_{};
|
||||
uint8_t notify_subscription_count_{0};
|
||||
bd_addr_t peer_addr_{}; // MSB-first, as gap_connect expects
|
||||
bd_addr_type_t peer_addr_type_{BD_ADDR_TYPE_LE_PUBLIC};
|
||||
uint8_t engine_index_{0}; // position in instances[]; tags log lines per slot
|
||||
bd_addr_t peer_addr_{}; // MSB-first, as gap_connect expects
|
||||
ble_device_base::ConnectionType connection_type_{ble_device_base::ConnectionType::V3_WITHOUT_CACHE};
|
||||
EngineState state_{EngineState::IDLE};
|
||||
DiscoveryPhase discovery_phase_{DiscoveryPhase::NONE};
|
||||
OpType op_type_{OpType::NONE};
|
||||
@@ -214,6 +238,12 @@ class RP2GattClient final : public Component, public Parented<rp2040_ble::RP2040
|
||||
static btstack_packet_callback_registration_t hci_event_registration;
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static btstack_packet_callback_registration_t sm_event_registration;
|
||||
// The engine whose gap_connect is in flight: BTstack allows one outgoing LE
|
||||
// create-connection stack-wide, and gap_connect_cancel is global, so only
|
||||
// the owner may cancel. Written under BluetoothLock from the main loop,
|
||||
// cleared in the BTstack context when the procedure resolves.
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static RP2GattClient *connect_owner;
|
||||
};
|
||||
|
||||
} // namespace esphome::bluetooth_connection
|
||||
|
||||
@@ -151,20 +151,23 @@ def _validate_no_active(config: ConfigType) -> ConfigType:
|
||||
@functools.cache
|
||||
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."""
|
||||
GATT client backend in bluetooth_connection. Multi-slot builds replace the
|
||||
prebuilt library's one-client BTstack pools via linker --wrap, owned by
|
||||
rp2040_ble and requested when a second backend registers."""
|
||||
connection_schema = bluetooth_connection.hub_connection_schema(PLATFORM_RP2)
|
||||
|
||||
def populate_connections(config: ConfigType) -> ConfigType:
|
||||
from esphome.components import rp2040_ble
|
||||
|
||||
# One wrapper + backend pair per slot, declared during validation so
|
||||
# their ids exist for codegen (the esp32 arm's `connections` pattern).
|
||||
if not config[CONF_ACTIVE]:
|
||||
return config
|
||||
connection_slots: int = config[CONF_CONNECTION_SLOTS]
|
||||
rp2040_ble.consume_connection_slots(connection_slots, "bluetooth_proxy")(config)
|
||||
return {
|
||||
**config,
|
||||
CONF_CONNECTIONS: [
|
||||
connection_schema({}) for _ in range(config[CONF_CONNECTION_SLOTS])
|
||||
],
|
||||
CONF_CONNECTIONS: [connection_schema({}) for _ in range(connection_slots)],
|
||||
}
|
||||
|
||||
max_conn = bluetooth_connection.HUB_MAX_CONNECTIONS[PLATFORM_RP2]
|
||||
@@ -182,8 +185,8 @@ def _rp2_config_schema() -> cv.All:
|
||||
min=1,
|
||||
max=max_conn,
|
||||
msg=f"rp2 supports at most {max_conn} connection slot(s); "
|
||||
"the framework's BTstack library is built with "
|
||||
f"MAX_NR_GATT_CLIENTS {max_conn}",
|
||||
"the BTstack pool overrides in rp2040_ble are sized "
|
||||
f"for {max_conn}",
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from collections.abc import Callable, MutableMapping
|
||||
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["rp2"]
|
||||
@@ -8,6 +11,15 @@ CODEOWNERS = ["@bdraco"]
|
||||
|
||||
CONF_RP2040_BLE_ID = "rp2040_ble_id"
|
||||
|
||||
KEY_RP2040_BLE = "rp2040_ble"
|
||||
KEY_USED_CONNECTION_SLOTS = "used_connection_slots"
|
||||
|
||||
# Hard platform cap on concurrent GATT connections: the BTstack pool overrides
|
||||
# in btstack_memory.cpp are sized from ESPHOME_BLE_GATT_CLIENT_COUNT with this
|
||||
# as the ceiling. 3 matches the esp32 default and stays within the
|
||||
# controller's resources (MAX_NR_CONTROLLER_ACL_BUFFERS 3).
|
||||
MAX_CONNECTIONS = 3
|
||||
|
||||
rp2040_ble_ns = cg.esphome_ns.namespace("rp2040_ble")
|
||||
RP2040BLE = rp2040_ble_ns.class_("RP2040BLE", cg.Component)
|
||||
|
||||
@@ -30,13 +42,67 @@ def _validate_board(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _validate_board
|
||||
def consume_connection_slots(
|
||||
value: int, consumer: str
|
||||
) -> Callable[[MutableMapping], MutableMapping]:
|
||||
"""Reserve BLE connection slots for a component (the esp32_ble pattern);
|
||||
the total is checked against MAX_CONNECTIONS in final validation."""
|
||||
|
||||
def _consume_connection_slots(config: MutableMapping) -> MutableMapping:
|
||||
data: dict = CORE.data.setdefault(KEY_RP2040_BLE, {})
|
||||
slots: list[str] = data.setdefault(KEY_USED_CONNECTION_SLOTS, [])
|
||||
slots.extend([consumer] * value)
|
||||
return config
|
||||
|
||||
return _consume_connection_slots
|
||||
|
||||
|
||||
def validate_connection_slots() -> None:
|
||||
"""Fail when consumers claimed more slots than the platform cap."""
|
||||
# Skip in testing mode to allow component grouping (esp32_ble parity).
|
||||
if CORE.testing_mode:
|
||||
return
|
||||
used = CORE.data.get(KEY_RP2040_BLE, {}).get(KEY_USED_CONNECTION_SLOTS, [])
|
||||
if len(used) > MAX_CONNECTIONS:
|
||||
raise cv.Invalid(
|
||||
f"BLE components require {len(used)} connection slots but the "
|
||||
f"rp2 maximum is {MAX_CONNECTIONS}. "
|
||||
f"Components: {', '.join(used)}"
|
||||
)
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> ConfigType:
|
||||
_validate_board(config)
|
||||
validate_connection_slots()
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
# Once per registered scan listener; sizes the controller's StaticVector
|
||||
# listener storage.
|
||||
request_scan_listener_slot = cg.slot_counter("RP2040_BLE_SCAN_LISTENER_COUNT")
|
||||
|
||||
# The four btstack_memory accessors whose static pools are baked into the
|
||||
# prebuilt liblwip-bt.a; every internal use crosses an object boundary in the
|
||||
# archive, so --wrap intercepts them all (see btstack_memory.cpp).
|
||||
_BTSTACK_POOL_SYMBOLS = (
|
||||
"btstack_memory_gatt_client_get",
|
||||
"btstack_memory_gatt_client_free",
|
||||
"btstack_memory_hci_connection_get",
|
||||
"btstack_memory_hci_connection_free",
|
||||
)
|
||||
|
||||
|
||||
def add_btstack_pool_overrides() -> None:
|
||||
"""Emit the --wrap flags that swap the prebuilt BTstack pools for the
|
||||
ESPHOME_BLE_GATT_CLIENT_COUNT-sized ones in btstack_memory.cpp. Called by
|
||||
bluetooth_connection when a second GATT backend registers; idempotent
|
||||
(build flags are a set)."""
|
||||
for symbol in _BTSTACK_POOL_SYMBOLS:
|
||||
cg.add_build_flag(f"-Wl,--wrap={symbol}")
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Replaces the gatt_client / hci_connection static pools baked into
|
||||
// arduino-pico's prebuilt liblwip-bt.a (built with MAX_NR_GATT_CLIENTS 1,
|
||||
// MAX_NR_HCI_CONNECTIONS 2) with pools sized from ESPHOME_BLE_GATT_CLIENT_COUNT.
|
||||
// add_btstack_pool_overrides() in this component's codegen emits the matching
|
||||
// -Wl,--wrap flags, requested by bluetooth_connection when more than one GATT
|
||||
// backend registers; single-backend builds emit no flags and this file
|
||||
// compiles to nothing, leaving the prebuilt pools in charge. Layout safety:
|
||||
// the framework defines ENABLE_CLASSIC / ENABLE_BLE for every user TU
|
||||
// whenever PIO_FRAMEWORK_ARDUINO_ENABLE_BLUETOOTH is set (this component
|
||||
// always sets it), so sizeof() here matches the archive.
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) && (ESPHOME_BLE_GATT_CLIENT_COUNT > 1)
|
||||
|
||||
#include <btstack.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::rp2040_ble {
|
||||
namespace {
|
||||
|
||||
// Pinned against arduino-pico 6.0.0's prebuilt archives: a framework bump (or
|
||||
// a changed ENABLE_* macro) shifting the struct layout must fail the build
|
||||
// here, not overrun the pool blocks at runtime. Sizes differ per core
|
||||
// architecture (measured from each archive's own storage symbols). GCC only:
|
||||
// the clang-tidy frontend lays these structs out differently, and the guard
|
||||
// targets the real link.
|
||||
#ifndef __clang__
|
||||
#ifdef __riscv
|
||||
static_assert(sizeof(gatt_client_t) == 140 && sizeof(hci_connection_t) == 3740, "BTstack layout changed");
|
||||
#else
|
||||
static_assert(sizeof(gatt_client_t) == 128 && sizeof(hci_connection_t) == 3688, "BTstack layout changed");
|
||||
#endif
|
||||
#endif // __clang__
|
||||
|
||||
// One gatt_client_t per configured connection slot. An hci_connection_t is
|
||||
// held from gap_connect() to DISCONNECTION_COMPLETE (scanning holds none);
|
||||
// +1 mirrors the prebuilt library's own headroom (2 connections for 1 GATT
|
||||
// client) so a teardown/re-connect overlap can never starve a slot.
|
||||
constexpr int HCI_CONNECTION_POOL_SIZE = ESPHOME_BLE_GATT_CLIENT_COUNT + 1;
|
||||
|
||||
// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables,cert-err58-cpp)
|
||||
gatt_client_t gatt_client_storage[ESPHOME_BLE_GATT_CLIENT_COUNT];
|
||||
btstack_memory_pool_t gatt_client_pool;
|
||||
hci_connection_t hci_connection_storage[HCI_CONNECTION_POOL_SIZE];
|
||||
btstack_memory_pool_t hci_connection_pool;
|
||||
|
||||
// Static init: pool_create only links a free list through its own storage,
|
||||
// and BTstack first allocates long after static construction.
|
||||
struct PoolInit {
|
||||
PoolInit() {
|
||||
btstack_memory_pool_create(&gatt_client_pool, gatt_client_storage, ESPHOME_BLE_GATT_CLIENT_COUNT,
|
||||
sizeof(gatt_client_t));
|
||||
btstack_memory_pool_create(&hci_connection_pool, hci_connection_storage, HCI_CONNECTION_POOL_SIZE,
|
||||
sizeof(hci_connection_t));
|
||||
}
|
||||
} pool_init;
|
||||
// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables,cert-err58-cpp)
|
||||
|
||||
} // namespace
|
||||
|
||||
// Exact semantics of btstack_memory.c's static-pool arm: zeroed block on
|
||||
// success, NULL when exhausted; free returns the block to the pool. The
|
||||
// prebuilt pools stay resident in .bss (~7.4 KB, kept live by
|
||||
// btstack_memory_init in the archive) — dead weight here, not a leak.
|
||||
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
|
||||
extern "C" gatt_client_t *__real_btstack_memory_gatt_client_get(void);
|
||||
extern "C" void __real_btstack_memory_gatt_client_free(gatt_client_t *gatt_client);
|
||||
extern "C" hci_connection_t *__real_btstack_memory_hci_connection_get(void);
|
||||
extern "C" void __real_btstack_memory_hci_connection_free(hci_connection_t *hci_connection);
|
||||
|
||||
namespace {
|
||||
// Fails the link if the corresponding --wrap flag is missing: __real_* only
|
||||
// exists while --wrap is in effect, and each wrap function anchors its own
|
||||
// symbol so dropping any single flag fails loudly. A code reference is used
|
||||
// because the framework links with --gc-sections, which discards an
|
||||
// unreferenced data anchor regardless of [[gnu::used]] (and this toolchain
|
||||
// does not emit SHF_GNU_RETAIN for [[gnu::retain]]).
|
||||
template<typename T> void anchor_wrap(T *symbol) { asm volatile("" ::"r"(symbol)); }
|
||||
} // namespace
|
||||
|
||||
extern "C" {
|
||||
|
||||
gatt_client_t *__wrap_btstack_memory_gatt_client_get(void) {
|
||||
anchor_wrap(&__real_btstack_memory_gatt_client_get);
|
||||
void *buffer = btstack_memory_pool_get(&gatt_client_pool);
|
||||
if (buffer != nullptr) {
|
||||
memset(buffer, 0, sizeof(gatt_client_t));
|
||||
}
|
||||
return static_cast<gatt_client_t *>(buffer);
|
||||
}
|
||||
|
||||
void __wrap_btstack_memory_gatt_client_free(gatt_client_t *gatt_client) {
|
||||
anchor_wrap(&__real_btstack_memory_gatt_client_free);
|
||||
btstack_memory_pool_free(&gatt_client_pool, gatt_client);
|
||||
}
|
||||
|
||||
hci_connection_t *__wrap_btstack_memory_hci_connection_get(void) {
|
||||
anchor_wrap(&__real_btstack_memory_hci_connection_get);
|
||||
void *buffer = btstack_memory_pool_get(&hci_connection_pool);
|
||||
if (buffer != nullptr) {
|
||||
memset(buffer, 0, sizeof(hci_connection_t));
|
||||
}
|
||||
return static_cast<hci_connection_t *>(buffer);
|
||||
}
|
||||
|
||||
void __wrap_btstack_memory_hci_connection_free(hci_connection_t *hci_connection) {
|
||||
anchor_wrap(&__real_btstack_memory_hci_connection_free);
|
||||
btstack_memory_pool_free(&hci_connection_pool, hci_connection);
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
|
||||
|
||||
} // namespace esphome::rp2040_ble
|
||||
|
||||
#endif // USE_RP2040_BLE && USE_BLE_GATT_CLIENT && ESPHOME_BLE_GATT_CLIENT_COUNT > 1
|
||||
@@ -262,13 +262,13 @@
|
||||
#define USE_BLUETOOTH_PROXY
|
||||
// Mirror the codegen values per platform: _to_code_esp32() emits the connection
|
||||
// count (default 3) and the scanner-state push slot, _to_code_ble_hub() emits
|
||||
// the slot count (1 on rp2, 0 on advertisement-only hubs) — so static analysis
|
||||
// the slot count (3 on rp2, 0 on advertisement-only hubs) — so static analysis
|
||||
// checks the same instantiations a real build produces.
|
||||
#ifdef USE_ESP32
|
||||
#define USE_BLE_SCANNER_STATE_CALLBACK
|
||||
#define BLUETOOTH_PROXY_MAX_CONNECTIONS 3
|
||||
#elif defined(USE_RP2)
|
||||
#define BLUETOOTH_PROXY_MAX_CONNECTIONS 1
|
||||
#define BLUETOOTH_PROXY_MAX_CONNECTIONS 3
|
||||
#else
|
||||
#define BLUETOOTH_PROXY_MAX_CONNECTIONS 0
|
||||
#endif
|
||||
@@ -482,7 +482,7 @@
|
||||
#define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1
|
||||
#define USE_BLE_SCAN_RESPONSE_MERGER
|
||||
#define USE_BLE_GATT_CLIENT
|
||||
#define ESPHOME_BLE_GATT_CLIENT_COUNT 1
|
||||
#define ESPHOME_BLE_GATT_CLIENT_COUNT 3
|
||||
#define USE_RP2040_VARIANT_RP2040
|
||||
#define USE_SPI
|
||||
#ifndef USE_ETHERNET
|
||||
|
||||
@@ -142,8 +142,8 @@ def test_rp2_defaults_to_the_full_proxy(
|
||||
_register_tracker(PLATFORM_RP2)
|
||||
validated = bluetooth_proxy.CONFIG_SCHEMA({})
|
||||
assert validated[CONF_ACTIVE] is True
|
||||
assert validated[bluetooth_proxy.CONF_CONNECTION_SLOTS] == 1
|
||||
assert len(validated[bluetooth_proxy.CONF_CONNECTIONS]) == 1
|
||||
assert validated[bluetooth_proxy.CONF_CONNECTION_SLOTS] == 3
|
||||
assert len(validated[bluetooth_proxy.CONF_CONNECTIONS]) == 3
|
||||
|
||||
|
||||
def test_rp2_accepts_explicit_passive(
|
||||
@@ -159,11 +159,15 @@ def test_rp2_accepts_explicit_passive(
|
||||
def test_rp2_rejects_slots_beyond_the_btstack_limit(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
# The prebuilt BTstack library allows exactly one GATT client connection.
|
||||
# The BTstack pool overrides are sized for RP2_MAX_CONNECTIONS slots.
|
||||
set_core_config(PlatformFramework.RP2_ARDUINO)
|
||||
_register_tracker(PLATFORM_RP2)
|
||||
with pytest.raises(cv.Invalid, match="at most 1 connection slot"):
|
||||
bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 2})
|
||||
with pytest.raises(cv.Invalid, match="at most 3 connection slot"):
|
||||
bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 4})
|
||||
# Fewer slots than the cap stay accepted (the prebuilt single-client pool
|
||||
# path for 1, the wrap path for 2).
|
||||
validated = bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 1})
|
||||
assert len(validated[bluetooth_proxy.CONF_CONNECTIONS]) == 1
|
||||
# Values past even the loosest platform cap stop at the outer walkable
|
||||
# schema, which stays bounded for range walkers (device-builder sync);
|
||||
# in-range values get the platform message above.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
esphome:
|
||||
name: poolwrap-rp2-default
|
||||
|
||||
rp2:
|
||||
board: rpipicow
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
api:
|
||||
|
||||
rp2_ble_tracker:
|
||||
|
||||
bluetooth_proxy:
|
||||
@@ -0,0 +1,16 @@
|
||||
esphome:
|
||||
name: poolwrap-rp2-single
|
||||
|
||||
rp2:
|
||||
board: rpipicow
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
api:
|
||||
|
||||
rp2_ble_tracker:
|
||||
|
||||
bluetooth_proxy:
|
||||
connection_slots: 1
|
||||
@@ -0,0 +1,16 @@
|
||||
esphome:
|
||||
name: poolwrap-rp2-two
|
||||
|
||||
rp2:
|
||||
board: rpipicow
|
||||
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
api:
|
||||
|
||||
rp2_ble_tracker:
|
||||
|
||||
bluetooth_proxy:
|
||||
connection_slots: 2
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Connection-slot accounting: consumers claim against MAX_CONNECTIONS and
|
||||
final validation rejects over-subscription with the consumer list."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components import rp2040_ble
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
def test_proxy_claims_its_slots_through_the_shared_accounting(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
# A default (3-slot) proxy build records one claim per slot, attributed
|
||||
# to the consumer, and passes final validation.
|
||||
generate_main(component_config_path("rp2_proxy_default.yaml"))
|
||||
used = CORE.data[rp2040_ble.KEY_RP2040_BLE][rp2040_ble.KEY_USED_CONNECTION_SLOTS]
|
||||
assert used == ["bluetooth_proxy"] * 3
|
||||
|
||||
|
||||
def test_oversubscription_is_rejected_with_the_consumer_list() -> None:
|
||||
# No YAML shape reaches this today (the proxy schema caps at the same
|
||||
# limit); the guard exists for a second consumer such as ble_client.
|
||||
rp2040_ble.consume_connection_slots(3, "bluetooth_proxy")({})
|
||||
rp2040_ble.consume_connection_slots(1, "ble_client")({})
|
||||
with pytest.raises(
|
||||
cv.Invalid,
|
||||
match=r"4 connection slots.*maximum is 3.*bluetooth_proxy.*ble_client",
|
||||
):
|
||||
rp2040_ble.validate_connection_slots()
|
||||
|
||||
|
||||
def test_at_cap_passes() -> None:
|
||||
rp2040_ble.consume_connection_slots(3, "bluetooth_proxy")({})
|
||||
rp2040_ble.validate_connection_slots()
|
||||
@@ -0,0 +1,52 @@
|
||||
"""The rp2 BTstack pool overrides: multi-slot builds emit the --wrap flags
|
||||
that swap the prebuilt single-client pools for the codegen-sized ones;
|
||||
single-slot builds emit none and stay byte-identical to previous releases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.core import CORE
|
||||
|
||||
from ..helpers import get_define_value
|
||||
|
||||
# Spelled out rather than derived from rp2040_ble's symbol tuple, so a typo
|
||||
# in the component's list fails here instead of mirroring into the test.
|
||||
WRAP_FLAGS = (
|
||||
"-Wl,--wrap=btstack_memory_gatt_client_get",
|
||||
"-Wl,--wrap=btstack_memory_gatt_client_free",
|
||||
"-Wl,--wrap=btstack_memory_hci_connection_get",
|
||||
"-Wl,--wrap=btstack_memory_hci_connection_free",
|
||||
)
|
||||
|
||||
|
||||
def test_default_slots_emit_the_pool_wrap(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
generate_main(component_config_path("rp2_proxy_default.yaml"))
|
||||
assert all(flag in CORE.build_flags for flag in WRAP_FLAGS)
|
||||
assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "3"
|
||||
assert get_define_value("BLUETOOTH_PROXY_MAX_CONNECTIONS") == "3"
|
||||
|
||||
|
||||
def test_two_slots_emit_the_pool_wrap(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
# Two slots: the wrap pools are smaller than the cap, sized from the count.
|
||||
generate_main(component_config_path("rp2_proxy_two_slots.yaml"))
|
||||
assert all(flag in CORE.build_flags for flag in WRAP_FLAGS)
|
||||
assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "2"
|
||||
assert get_define_value("BLUETOOTH_PROXY_MAX_CONNECTIONS") == "2"
|
||||
|
||||
|
||||
def test_single_slot_keeps_the_prebuilt_pools(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
generate_main(component_config_path("rp2_proxy_single_slot.yaml"))
|
||||
assert not any(flag in CORE.build_flags for flag in WRAP_FLAGS)
|
||||
assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "1"
|
||||
assert get_define_value("BLUETOOTH_PROXY_MAX_CONNECTIONS") == "1"
|
||||
@@ -6,6 +6,7 @@ packages:
|
||||
|
||||
rp2_ble_tracker:
|
||||
|
||||
# Two slots: the one shape where the wrap pools are smaller than the cap.
|
||||
bluetooth_proxy:
|
||||
active: true
|
||||
connection_slots: 1
|
||||
connection_slots: 2
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Full proxy on the rp2 BLE hub: active defaults to true here (esp32 parity),
|
||||
# so this compiles the BTstack GATT client backend and one connection slot.
|
||||
# so this compiles the BTstack GATT client backend with the default three
|
||||
# connection slots, exercising the rp2040_ble/btstack_memory.cpp pool --wrap
|
||||
# link.
|
||||
# No explicit ble_hub_id: the generated binding resolves the single declared
|
||||
# hub, and an inline id here would collide with rp2_ble_tracker's own fixture
|
||||
# once CI merges both components into one grouped rp2040-ard build (grouped
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Pico 2 W build of the full proxy: links the rp2350 framework archive, so
|
||||
# the pool --wrap overrides and their per-architecture layout asserts are
|
||||
# exercised for this chip too (see test.rp2040-ard.yaml for the slot shape).
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
|
||||
rp2_ble_tracker:
|
||||
|
||||
bluetooth_proxy:
|
||||
@@ -2,8 +2,10 @@ esphome:
|
||||
name: componenttestrp2040pico2ard
|
||||
friendly_name: $component_name
|
||||
|
||||
# rpipico2w: superset of rpipico2 with the CYW43 radio, so wireless
|
||||
# components (wifi, BLE) can share this target too.
|
||||
rp2:
|
||||
board: rpipico2
|
||||
board: rpipico2w
|
||||
|
||||
logger:
|
||||
level: VERY_VERBOSE
|
||||
|
||||
Reference in New Issue
Block a user