Merge branch 'neutral-ble-client' into radon-eye-single-node

This commit is contained in:
J. Nick Koston
2026-08-12 16:33:21 -05:00
10 changed files with 111 additions and 85 deletions
+6 -4
View File
@@ -338,13 +338,13 @@ async def register_ble_node(var, config):
def _request_gatt_node_build() -> None:
"""Node storage plus, on esp32, the bridge and shared-materializer
defines one neutral node needs compiled in."""
"""Node storage and the one define meaning "the neutral node surface is
compiled in", plus the esp32 bridge/materializer defines."""
_request_node_slot()
cg.add_define("USE_BLE_CLIENT_GATT_NODES")
if CORE.is_esp32:
# Deliberately not ble_device_base.request_gatt_client(): that would
# claim a phantom backend slot on combined proxy builds.
cg.add_define("USE_BLE_CLIENT_GATT_NODES")
cg.add_define("USE_BLE_GATT_CLIENT")
cg.add_define("USE_BLE_GATT_SERVICE_TABLE")
@@ -538,7 +538,9 @@ _request_node_slot = cg.slot_counter("ESPHOME_BLE_CLIENT_MAX_NODES")
async def _to_code_gatt(config: ConfigType) -> cg.MockObj:
_request_node_slot()
# The engine always carries the node surface (the client itself owns the
# baseline slot).
_request_gatt_node_build()
backend = await bluetooth_connection.new_gatt_backend(config)
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -2,13 +2,8 @@
#ifdef USE_ESP32
#include <utility>
#include <vector>
#include "esphome/core/automation.h"
#include "esphome/components/ble_client/ble_client.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::ble_client {
@@ -12,12 +12,9 @@
#if defined(USE_BLE_GATT_CLIENT) && !defined(USE_ESP32)
#include <tuple>
#include <vector>
#include "ble_client_gatt.h"
#include "esphome/core/automation.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::ble_client {
+59 -48
View File
@@ -9,6 +9,7 @@
#ifdef USE_BLE_CLIENT_GATT_NODES
#include "esphome/components/bluetooth_connection/bluetooth_connection.h"
#include "esphome/components/bluetooth_connection/gatt_service_table_bluedroid.h"
#endif
namespace esphome::ble_client {
@@ -60,7 +61,8 @@ bool BLEClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t es
int err = param->reg_for_notify.status == ESP_GATT_OK ? 0 : param->reg_for_notify.status;
for (auto *node : this->gatt_nodes_)
node->on_notify_state(param->reg_for_notify.handle, true, err);
// A retiring last registration must still release the cache.
// A retiring last registration must still release the cache (the counter
// was cleared above, before the node dispatch).
this->maybe_release_services_();
return true;
}
@@ -68,18 +70,20 @@ bool BLEClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t es
if (!BLEClientBase::gattc_event_handler(event, esp_gattc_if, param))
return false;
for (auto *node : this->nodes_)
node->gattc_event_handler(event, esp_gattc_if, param);
#ifdef USE_BLE_CLIENT_GATT_NODES
// Before the legacy fan-out so gatt nodes resolve their handles before any
// legacy trigger (e.g. on_connect at SEARCH_CMPL) fires - the neutral
// engine's order.
this->dispatch_gatt_event_(event, param);
#endif
for (auto *node : this->nodes_)
node->gattc_event_handler(event, esp_gattc_if, param);
this->maybe_release_services_();
return true;
}
void BLEClient::maybe_release_services_() {
// The release frees the GATT cache that BLEClientBase's CCCD lookup still needs.
// The last REG_FOR_NOTIFY event clears the counter before node dispatch, so the release still runs here.
if (!this->services_.empty() && !this->notify_registration_pending() && this->all_nodes_established_()) {
this->release_services();
ESP_LOGD(TAG, "All clients established, services released");
@@ -102,14 +106,10 @@ void BLEClient::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_p
void BLEClient::set_state(espbt::ClientState state) {
BLEClientBase::set_state(state);
// ESTABLISHED never flows through here (the base sets it internally); gatt
// nodes (members of nodes_ too) are promoted after the on_connected fan-out.
for (auto &node : nodes_)
node->node_state = state;
#ifdef USE_BLE_CLIENT_GATT_NODES
// ESTABLISHED never flows through here (the base sets it internally);
// gatt nodes are promoted explicitly after the on_connected fan-out.
for (auto *node : this->gatt_nodes_)
node->node_state = state;
#endif
}
bool BLEClient::all_nodes_established_() {
@@ -119,19 +119,12 @@ bool BLEClient::all_nodes_established_() {
if (node->node_state != espbt::ClientState::ESTABLISHED)
return false;
}
#ifdef USE_BLE_CLIENT_GATT_NODES
for (auto *node : this->gatt_nodes_) {
if (node->node_state != espbt::ClientState::ESTABLISHED)
return false;
}
#endif
return true;
}
#ifdef USE_BLE_CLIENT_GATT_NODES
void BLEClient::register_gatt_node(BLEClientNode *node) {
node->set_ble_client_parent(this);
if (this->gatt_nodes_.size() == ESPHOME_BLE_CLIENT_MAX_NODES) {
// push_back past capacity is a silent no-op; an undersized slot count
// must be loud at boot, not an unresolvable node at runtime.
@@ -139,22 +132,21 @@ void BLEClient::register_gatt_node(BLEClientNode *node) {
return;
}
this->gatt_nodes_.push_back(node);
// Membership in nodes_ covers the shared state bookkeeping (node_state,
// release condition); gatt_nodes_ is the neutral fan-out subset.
this->register_ble_node(node);
}
bool BLEClient::take_pending_gatt_reg_(uint16_t handle) {
bool found = false;
// Erase every match: a stale duplicate must never swallow a later legacy
// registration of the same handle.
auto &regs = this->pending_gatt_regs_;
for (auto it = regs.begin(); it != regs.end();) {
if (*it == handle) {
it = regs.erase(it);
found = true;
} else {
++it;
// No duplicates possible: notify_characteristic() refuses a re-push while
// one is pending, so a single swap-with-last removal suffices.
for (uint8_t i = 0; i < this->pending_gatt_reg_count_; i++) {
if (this->pending_gatt_regs_[i] == handle) {
this->pending_gatt_regs_[i] = this->pending_gatt_regs_[--this->pending_gatt_reg_count_];
return true;
}
}
return found;
return false;
}
void BLEClient::dispatch_gatt_event_(esp_gattc_cb_event_t event, esp_ble_gattc_cb_param_t *param) {
@@ -199,18 +191,14 @@ void BLEClient::dispatch_gatt_event_(esp_gattc_cb_event_t event, esp_ble_gattc_c
void BLEClient::handle_gatt_search_cmpl_(esp_gatt_status_t status) {
// The base establishes without reading the search status; the neutral
// contract treats a failed or empty discovery as a failed connection.
uint16_t primary = 0;
uint16_t secondary = 0;
bool counted = status == ESP_GATT_OK &&
esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_PRIMARY_SERVICE, 0x0001,
0xFFFF, 0, &primary) == ESP_GATT_OK &&
esp_ble_gattc_get_attr_count(this->gattc_if_, this->conn_id_, ESP_GATT_DB_SECONDARY_SERVICE, 0x0001,
0xFFFF, 0, &secondary) == ESP_GATT_OK;
uint16_t service_total = 0;
bool counted = status == ESP_GATT_OK && bluetooth_connection::BluedroidServiceTable::count_services(
this->gattc_if_, this->conn_id_, &service_total);
// Stack-owned: freed on scope exit; nodes copy their handles during
// on_connected() per the borrowed-table contract.
bluetooth_connection::BluedroidServiceTable table;
if (!counted || primary + secondary == 0 ||
!table.build(this->gattc_if_, this->conn_id_, primary + secondary, this->connection_index_)) {
if (!counted || service_total == 0 ||
!table.build(this->gattc_if_, this->conn_id_, service_total, this->connection_index_)) {
ESP_LOGW(TAG, "[%s] Service table is empty; treating as failed discovery", this->address_str());
this->disconnect();
return;
@@ -233,7 +221,7 @@ void BLEClient::handle_gatt_search_cmpl_(esp_gatt_status_t status) {
}
void BLEClient::on_disconnect_complete(esp_err_t reason) {
this->pending_gatt_regs_.clear();
this->pending_gatt_reg_count_ = 0;
if (!this->gatt_connected_)
return; // Never-established links report nothing (neutral parity).
this->gatt_connected_ = false;
@@ -241,45 +229,68 @@ void BLEClient::on_disconnect_complete(esp_err_t reason) {
node->on_disconnected();
}
int BLEClient::check_gatt_op_(const char *operation, esp_err_t err) {
if (err != ESP_OK)
this->log_gattc_warning_(operation, err);
return err;
}
int BLEClient::write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) {
if (this->conn_id_ == UNSET_CONN_ID)
return ble_device_base::GATT_ERR_NOT_CONNECTED;
return esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, len, const_cast<uint8_t *>(data),
response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP,
ESP_GATT_AUTH_REQ_NONE);
return this->check_gatt_op_(
"esp_ble_gattc_write_char",
esp_ble_gattc_write_char(this->gattc_if_, this->conn_id_, handle, len, const_cast<uint8_t *>(data),
response ? ESP_GATT_WRITE_TYPE_RSP : ESP_GATT_WRITE_TYPE_NO_RSP,
ESP_GATT_AUTH_REQ_NONE));
}
int BLEClient::read_characteristic(uint16_t handle) {
if (this->conn_id_ == UNSET_CONN_ID)
return ble_device_base::GATT_ERR_NOT_CONNECTED;
return esp_ble_gattc_read_char(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE);
return this->check_gatt_op_("esp_ble_gattc_read_char",
esp_ble_gattc_read_char(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE));
}
int BLEClient::read_descriptor(uint16_t handle) {
if (this->conn_id_ == UNSET_CONN_ID)
return ble_device_base::GATT_ERR_NOT_CONNECTED;
return esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE);
return this->check_gatt_op_(
"esp_ble_gattc_read_char_descr",
esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE));
}
int BLEClient::write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) {
if (this->conn_id_ == UNSET_CONN_ID)
return ble_device_base::GATT_ERR_NOT_CONNECTED;
return esp_ble_gattc_write_char_descr(this->gattc_if_, this->conn_id_, handle, len, const_cast<uint8_t *>(data),
ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE);
return this->check_gatt_op_(
"esp_ble_gattc_write_char_descr",
esp_ble_gattc_write_char_descr(this->gattc_if_, this->conn_id_, handle, len, const_cast<uint8_t *>(data),
ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE));
}
int BLEClient::notify_characteristic(uint16_t handle, bool enable) {
if (this->conn_id_ == UNSET_CONN_ID)
return ble_device_base::GATT_ERR_NOT_CONNECTED;
if (enable) {
for (uint8_t i = 0; i < this->pending_gatt_reg_count_; i++) {
if (this->pending_gatt_regs_[i] == handle) {
ESP_LOGW(TAG, "[%s] Notify registration already pending for handle 0x%04x", this->address_str(), handle);
return ESP_OK;
}
}
if (this->pending_gatt_reg_count_ == ESPHOME_BLE_CLIENT_MAX_NODES) {
// Registering without a pending entry would let the base's automatic
// CCCD write through; refuse loudly instead.
ESP_LOGE(TAG, "[%s] Too many pending notify registrations", this->address_str());
return ble_device_base::GATT_ERR_NO_MEMORY;
}
// Reuses the base helper so its pending count holds the service-release
// until the registration completes; the completion is intercepted before
// the base's automatic CCCD write.
if (this->take_pending_gatt_reg_(handle))
ESP_LOGW(TAG, "[%s] Duplicate notify registration for handle 0x%04x", this->address_str(), handle);
esp_err_t err = this->register_for_notify(handle);
if (err == ESP_OK)
this->pending_gatt_regs_.push_back(handle);
this->pending_gatt_regs_[this->pending_gatt_reg_count_++] = handle;
return err;
}
return esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle);
+6 -6
View File
@@ -10,10 +10,6 @@
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#ifdef USE_BLE_CLIENT_GATT_NODES
#include "esphome/components/bluetooth_connection/gatt_service_table_bluedroid.h"
#endif
#include <esp_bt_defs.h>
#include <esp_gap_ble_api.h>
#include <esp_gatt_common_api.h>
@@ -65,6 +61,7 @@ class BLEClient final : public BLEClientBase {
/// Local registration only; per the neutral contract the CCCD write is the
/// node's job (the legacy auto-CCCD is suppressed for these handles).
int notify_characteristic(uint16_t handle, bool enable);
// pair() comes from BLEClientBase, matching the neutral engine's.
int unpair();
#endif
@@ -72,6 +69,7 @@ class BLEClient final : public BLEClientBase {
bool all_nodes_established_();
void maybe_release_services_();
#ifdef USE_BLE_CLIENT_GATT_NODES
int check_gatt_op_(const char *operation, esp_err_t err);
void dispatch_gatt_event_(esp_gattc_cb_event_t event, esp_ble_gattc_cb_param_t *param);
void handle_gatt_search_cmpl_(esp_gatt_status_t status);
bool take_pending_gatt_reg_(uint16_t handle);
@@ -83,8 +81,10 @@ class BLEClient final : public BLEClientBase {
// Nodes on the neutral surface; fed the translated callbacks and
// auto-established after the on_connected fan-out.
StaticVector<BLEClientNode *, ESPHOME_BLE_CLIENT_MAX_NODES> gatt_nodes_;
// Bridge-initiated notify registrations awaiting REG_FOR_NOTIFY_EVT.
std::vector<uint16_t> pending_gatt_regs_;
// Bridge-initiated notify registrations awaiting REG_FOR_NOTIFY_EVT;
// bounded by the node count (one in-flight registration per node).
uint16_t pending_gatt_regs_[ESPHOME_BLE_CLIENT_MAX_NODES];
uint8_t pending_gatt_reg_count_{0};
// on_connected fan-out started; on_disconnected is owed at teardown.
bool gatt_connected_{false};
#endif
@@ -24,7 +24,7 @@ class BLEClient;
class BLEClientNode {
public:
#if defined(USE_BLE_CLIENT_GATT_NODES) || (defined(USE_BLE_GATT_CLIENT) && !defined(USE_ESP32))
#ifdef USE_BLE_CLIENT_GATT_NODES
// Neutral surface, delivered by both engines. The table is borrowed
// backend storage, valid only for the duration of on_connected(): copy the
// handles you need. All nodes see all completions/notifications; filter by
@@ -6,14 +6,14 @@
#include "esphome/core/defines.h"
#if defined(USE_BLE_CLIENT_GATT_NODES) || (defined(USE_BLE_GATT_CLIENT) && !defined(USE_ESP32))
#ifdef USE_BLE_CLIENT_GATT_NODES
#include <tuple>
#include <vector>
// One of the two resolves per build; both define class Automation's TAG box.
#include "automation.h"
#include "automation_gatt.h"
// One of the two engine headers resolves per build.
#include "ble_client.h"
#include "ble_client_gatt.h"
#include "ble_client_node.h"
#include "esphome/core/automation.h"
#include "esphome/core/helpers.h"
@@ -21,6 +21,8 @@
namespace esphome::ble_client {
static const char *const BLE_WRITE_TAG = "ble_client.automation";
// Maximum bytes to log in hex format for BLE writes (many logging buffers are 256 chars)
static constexpr size_t BLE_WRITE_MAX_LOG_BYTES = 64;
@@ -75,20 +77,20 @@ template<typename... Ts> class BLEClientWriteAction final : public Action<Ts...>
// handle is armed before the backend is touched.
bool write(const uint8_t *data, size_t len) {
if (!this->ble_client_->connected()) {
esph_log_w(Automation::TAG, "Cannot write to BLE characteristic - not connected");
esph_log_w(BLE_WRITE_TAG, "Cannot write to BLE characteristic - not connected");
return false;
}
if (!this->resolved_) {
esph_log_w(Automation::TAG, "Cannot write to BLE characteristic - characteristic was not resolved");
esph_log_w(BLE_WRITE_TAG, "Cannot write to BLE characteristic - characteristic was not resolved");
return false;
}
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE
char hex_buf[format_hex_pretty_size(BLE_WRITE_MAX_LOG_BYTES)];
esph_log_vv(Automation::TAG, "Will write %d bytes: %s", len, format_hex_pretty_to(hex_buf, data, len));
esph_log_vv(BLE_WRITE_TAG, "Will write %d bytes: %s", len, format_hex_pretty_to(hex_buf, data, len));
#endif
int err = this->ble_client_->write_characteristic(this->char_handle_, data, len, this->write_response_);
if (err != 0) {
esph_log_e(Automation::TAG, "Error writing to characteristic: %d!", err);
esph_log_e(BLE_WRITE_TAG, "Error writing to characteristic: %d!", err);
return false;
}
return true;
@@ -101,7 +103,7 @@ template<typename... Ts> class BLEClientWriteAction final : public Action<Ts...>
if (chr == nullptr) {
char char_buf[ble_device_base::UUID_STR_LEN];
char service_buf[ble_device_base::UUID_STR_LEN];
esph_log_w("ble_write_action", "Characteristic %s was not found in service %s", this->char_uuid_.to_str(char_buf),
esph_log_w(BLE_WRITE_TAG, "Characteristic %s was not found in service %s", this->char_uuid_.to_str(char_buf),
this->service_uuid_.to_str(service_buf));
return;
}
@@ -111,13 +113,13 @@ template<typename... Ts> class BLEClientWriteAction final : public Action<Ts...>
this->write_response_ = false;
} else {
char char_buf[ble_device_base::UUID_STR_LEN];
esph_log_e(Automation::TAG, "Characteristic %s does not allow writing", this->char_uuid_.to_str(char_buf));
esph_log_e(BLE_WRITE_TAG, "Characteristic %s does not allow writing", this->char_uuid_.to_str(char_buf));
return;
}
this->char_handle_ = chr->value_handle;
this->resolved_ = true;
char char_buf[ble_device_base::UUID_STR_LEN];
esph_log_d(Automation::TAG, "Found characteristic %s on device %s", this->char_uuid_.to_str(char_buf),
esph_log_d(BLE_WRITE_TAG, "Found characteristic %s on device %s", this->char_uuid_.to_str(char_buf),
this->ble_client_->address_str());
}
@@ -135,13 +137,13 @@ template<typename... Ts> class BLEClientWriteAction final : public Action<Ts...>
if (!this->resolved_ || handle != this->char_handle_) {
// A parked chain waiting on a completion that never matches would
// otherwise stall silently until disconnect.
esph_log_d(Automation::TAG, "Write result for handle 0x%04x ignored, waiting on 0x%04x", handle,
esph_log_d(BLE_WRITE_TAG, "Write result for handle 0x%04x ignored, waiting on 0x%04x", handle,
this->char_handle_);
return;
}
if (error != 0) {
// Continue the chain (legacy parity) but leave a breadcrumb.
esph_log_w(Automation::TAG, "Write completed with status %d", error);
esph_log_w(BLE_WRITE_TAG, "Write completed with status %d", error);
}
this->ble_client_->run_later([this]() { this->play_next_tuple_(this->var_); });
}
@@ -163,4 +165,4 @@ template<typename... Ts> class BLEClientWriteAction final : public Action<Ts...>
} // namespace esphome::ble_client
#endif // USE_BLE_CLIENT_GATT_NODES || (USE_BLE_GATT_CLIENT && !USE_ESP32)
#endif // USE_BLE_CLIENT_GATT_NODES
@@ -76,6 +76,20 @@ bool BluedroidServiceTable::walk_(ServiceFn &&on_service, CharFn &&on_char, Desc
return true;
}
bool BluedroidServiceTable::count_services(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t *total) {
uint16_t primary = 0;
uint16_t secondary = 0;
if (esp_ble_gattc_get_attr_count(gattc_if, conn_id, ESP_GATT_DB_PRIMARY_SERVICE, 0x0001, 0xFFFF, 0, &primary) !=
ESP_GATT_OK ||
esp_ble_gattc_get_attr_count(gattc_if, conn_id, ESP_GATT_DB_SECONDARY_SERVICE, 0x0001, 0xFFFF, 0, &secondary) !=
ESP_GATT_OK) {
// A failed count must not read as an authoritative empty database.
return false;
}
*total = primary + secondary;
return true;
}
bool BluedroidServiceTable::build(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t service_total, uint8_t log_index) {
this->free();
this->gattc_if_ = gattc_if;
@@ -19,10 +19,13 @@ class BluedroidServiceTable {
public:
~BluedroidServiceTable() { this->free(); }
/// Two-pass build from the stack's cached database. service_total MUST be
/// the esp_ble_gattc_get_attr_count(PRIMARY)+(SECONDARY) total, never the
/// SEARCH_RES event count. log_index labels warnings. Frees any previous
/// table first; on failure the table is left empty.
/// The service count build() requires: the stack's PRIMARY+SECONDARY
/// attribute totals, never the SEARCH_RES event count.
static bool count_services(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t *total);
/// Two-pass build from the stack's cached database (service_total from
/// count_services()). log_index labels warnings. Frees any previous table
/// first; on failure the table is left empty.
bool build(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t service_total, uint8_t log_index);
// The view is carved from the storage block and the counts on each call
@@ -45,6 +48,7 @@ class BluedroidServiceTable {
RAMAllocator<uint8_t> allocator(RAMAllocator<uint8_t>::ALLOC_INTERNAL);
allocator.deallocate(this->storage_, 0);
this->storage_ = nullptr;
this->service_total_ = 0;
this->char_total_ = 0;
this->desc_total_ = 0;
}
+1
View File
@@ -488,6 +488,7 @@
#define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1
#define USE_BLE_SCAN_RESPONSE_MERGER
#define USE_BLE_GATT_CLIENT
#define USE_BLE_CLIENT_GATT_NODES
#define ESPHOME_BLE_GATT_CLIENT_COUNT 3
#define ESPHOME_BLE_CLIENT_MAX_NODES 1
#define USE_RP2040_VARIANT_RP2040