Neutral ble_client engine on the GATT contract

This commit is contained in:
J. Nick Koston
2026-08-09 01:45:11 -05:00
parent 269d52ac2e
commit fa18f5a0ad
6 changed files with 632 additions and 4 deletions
+6 -2
View File
@@ -1,11 +1,15 @@
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#include "automation.h"
#elif defined(USE_BLE_GATT_CLIENT)
#include "automation_gatt.h"
#endif
#if defined(USE_ESP32) || defined(USE_BLE_GATT_CLIENT)
namespace esphome::ble_client {
const char *const Automation::TAG = "ble_client.automation";
} // namespace esphome::ble_client
#endif
@@ -0,0 +1,244 @@
// Neutral twins of the shared ble_client automations. Class names, namespace,
// and codegen-visible signatures are IDENTICAL to automation.h so generated
// main.cpp compiles against whichever engine the build gates in; only the
// internals differ (client callbacks and the neutral node interface instead
// of raw gattc events). The Bluedroid-security automations (passkey, numeric
// comparison, remove bond) have no neutral equivalent and stay esp32-only.
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_BLE_GATT_CLIENT) && !defined(USE_ESP32)
#include <tuple>
#include <utility>
#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 {
// placeholder class for static TAG (shared with automation.cpp).
class Automation {
public:
static const char *const TAG;
};
class BLEClientConnectTrigger final : public Trigger<> {
public:
explicit BLEClientConnectTrigger(BLEClient *parent) {
parent->add_on_connect_callback([this]() { this->trigger(); });
}
};
class BLEClientDisconnectTrigger final : public Trigger<> {
public:
explicit BLEClientDisconnectTrigger(BLEClient *parent) {
// Fires only after a completed connection (never for failed attempts),
// matching the legacy CLOSE_EVT semantics.
parent->add_on_disconnect_callback([this]() { this->trigger(); });
}
};
template<typename... Ts> class BLEClientWriteAction final : public Action<Ts...>, public BLEClientNode {
public:
BLEClientWriteAction(BLEClient *ble_client) {
ble_client->register_ble_node(this);
ble_client_ = ble_client;
}
void set_service_uuid16(uint16_t uuid) { this->service_uuid_ = ble_device_base::ESPBTUUID::from_uint16(uuid); }
void set_service_uuid32(uint32_t uuid) { this->service_uuid_ = ble_device_base::ESPBTUUID::from_uint32(uuid); }
void set_service_uuid128(uint8_t *uuid) { this->service_uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); }
void set_char_uuid16(uint16_t uuid) { this->char_uuid_ = ble_device_base::ESPBTUUID::from_uint16(uuid); }
void set_char_uuid32(uint32_t uuid) { this->char_uuid_ = ble_device_base::ESPBTUUID::from_uint32(uuid); }
void set_char_uuid128(uint8_t *uuid) { this->char_uuid_ = ble_device_base::ESPBTUUID::from_raw(uuid); }
void set_value_template(std::vector<uint8_t> (*func)(Ts...)) {
this->value_.func = func;
this->len_ = -1; // Sentinel value indicates template mode
}
// Store pointer to static data in flash (no RAM copy)
void set_value_simple(const uint8_t *data, size_t len) {
this->value_.data = data;
this->len_ = len; // Length >= 0 indicates static mode
}
void play(const Ts &...x) override {}
void play_complex(const Ts &...x) override {
this->num_running_++;
this->var_ = std::make_tuple(x...);
bool result;
if (this->len_ >= 0) {
result = this->write(this->value_.data, this->len_);
} else {
std::vector<uint8_t> value = this->value_.func(x...);
result = this->write(value.data(), value.size());
}
// on write failure, continue the automation chain rather than stopping so
// that e.g. disconnect can work.
if (!result)
this->play_next_(x...);
}
// Initiate the write; the completion arrives in on_write_result. The
// response-less path can complete synchronously inside the call, so the
// handle is armed before the backend is touched.
bool write(const uint8_t *data, size_t len) {
if (!this->resolved_ || !this->ble_client_->connected()) {
esph_log_w(Automation::TAG, "Cannot write to BLE characteristic - not connected");
return false;
}
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);
return false;
}
return true;
}
void on_connected(const ble_device_base::GattServiceTable &table) override {
const auto *service = ble_device_base::find_service(table, this->service_uuid_);
const auto *chr =
service == nullptr ? nullptr : ble_device_base::find_characteristic(table, *service, this->char_uuid_);
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),
this->service_uuid_.to_str(service_buf));
return;
}
if (chr->properties & ble_device_base::GATT_CHAR_PROP_WRITE) {
this->write_response_ = true;
} else if (chr->properties & ble_device_base::GATT_CHAR_PROP_WRITE_NO_RSP) {
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));
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),
this->ble_client_->address_str());
}
void on_disconnected() override {
this->resolved_ = false;
if (this->num_running_ != 0)
this->stop_complex();
}
void on_write_result(uint16_t handle, int error) override {
if (handle == this->char_handle_ && this->num_running_ != 0) {
this->ble_client_->run_later([this]() { this->play_next_tuple_(this->var_); });
}
}
private:
BLEClient *ble_client_;
ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length
union Value {
std::vector<uint8_t> (*func)(Ts...); // Function pointer (stateless lambdas)
const uint8_t *data; // Pointer to static data in flash
} value_;
ble_device_base::ESPBTUUID service_uuid_;
ble_device_base::ESPBTUUID char_uuid_;
std::tuple<Ts...> var_{};
uint16_t char_handle_{};
bool write_response_{false};
bool resolved_{false};
};
template<typename... Ts> class BLEClientConnectAction final : public Action<Ts...> {
public:
BLEClientConnectAction(BLEClient *ble_client) {
ble_client_ = ble_client;
ble_client->add_on_connect_callback([this]() {
if (this->num_running_ != 0)
this->play_next_tuple_(this->var_);
});
// A connect attempt that dies (or a later disconnect) terminates the
// chain, mirroring the legacy DISCONNECT_EVT handling.
ble_client->add_on_connect_failed_callback([this]() {
if (this->num_running_ != 0)
this->stop_complex();
});
ble_client->add_on_disconnect_callback([this]() {
if (this->num_running_ != 0)
this->stop_complex();
});
}
// not used since we override play_complex_
void play(const Ts &...x) override {}
void play_complex(const Ts &...x) override {
// it makes no sense to have multiple instances of this running at the
// same time; cancel a re-trigger while still running.
if (this->num_running_ != 0) {
this->stop_complex();
return;
}
this->num_running_++;
if (this->ble_client_->connected()) {
this->play_next_(x...);
} else {
this->var_ = std::make_tuple(x...);
// No-op while already connecting; the callback resolves the wait.
this->ble_client_->connect();
}
}
private:
BLEClient *ble_client_;
std::tuple<Ts...> var_{};
};
template<typename... Ts> class BLEClientDisconnectAction final : public Action<Ts...> {
public:
BLEClientDisconnectAction(BLEClient *ble_client) {
ble_client_ = ble_client;
// Both terminal outcomes resolve the wait: a completed teardown and a
// connect attempt that died on the way down.
ble_client->add_on_disconnect_callback([this]() {
if (this->num_running_ != 0)
this->play_next_tuple_(this->var_);
});
ble_client->add_on_connect_failed_callback([this]() {
if (this->num_running_ != 0)
this->play_next_tuple_(this->var_);
});
}
// not used since we override play_complex_
void play(const Ts &...x) override {}
void play_complex(const Ts &...x) override {
this->num_running_++;
if (this->ble_client_->idle()) {
this->play_next_(x...);
} else {
this->var_ = std::make_tuple(x...);
this->ble_client_->disconnect();
}
}
private:
BLEClient *ble_client_;
std::tuple<Ts...> var_{};
};
} // namespace esphome::ble_client
#endif // USE_BLE_GATT_CLIENT && !USE_ESP32
+4 -2
View File
@@ -1,12 +1,14 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#include "esphome/components/esp32_ble_client/ble_client_base.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#ifdef USE_ESP32
#include <esp_bt_defs.h>
#include <esp_gap_ble_api.h>
#include <esp_gatt_common_api.h>
@@ -0,0 +1,196 @@
#include "ble_client_gatt.h"
#if defined(USE_BLE_GATT_CLIENT) && !defined(USE_ESP32)
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace esphome::ble_client {
static const char *const TAG = "ble_client";
// Hold-off step per consecutive failure; capped so a flapping peer retries
// within a minute at worst.
static const uint32_t FAILURE_HOLD_OFF_STEP_MS = 10000;
static const uint8_t FAILURE_HOLD_OFF_MAX_STEPS = 6;
void BLEClient::set_address(uint64_t address) {
this->address_ = address;
uint8_t mac[6];
ble_device_base::uint64_to_mac_msb_first(address, mac);
format_mac_addr_upper(mac, this->address_str_);
}
void BLEClient::set_enabled(bool enabled) {
if (enabled == this->enabled)
return;
ESP_LOGI(TAG, "[%s] %s", this->address_str_, enabled ? "Enabled" : "Disabled");
this->enabled = enabled;
if (!enabled) {
this->disconnect();
}
// Enabling does not connect: the next sighting does (legacy parity).
}
bool BLEClient::parse_device(const ble_device_base::ESPBTDevice &device) {
if (device.address_uint64() != this->address_)
return false;
// The sighting is the source of truth for the address type.
this->address_type_ = device.get_address_type();
if (!this->enabled || !this->auto_connect_ || this->state_ != State::IDLE)
return true;
if (this->hold_off_until_ != 0 && millis() < this->hold_off_until_)
return true;
this->attempt_connect_(false);
return true;
}
void BLEClient::connect() {
if (this->state_ != State::IDLE)
return;
// Without a fresh sighting each attempt can inhibit scanning for the
// backend's full connect timeout (20 s on rp2) if the peer is absent.
ESP_LOGW(TAG, "[%s] Connecting without a recent advertisement", this->address_str_);
this->attempt_connect_(true);
}
void BLEClient::attempt_connect_(bool from_action) {
int err = this->backend_->connect(this->address_, this->address_type_);
if (err != 0) {
// A refused connect never produces a callback; stay idle and let the
// next sighting (or action) retry.
ESP_LOGW(TAG, "[%s] Connect refused, err=%d", this->address_str_, err);
return;
}
ESP_LOGD(TAG, "[%s] Connecting", this->address_str_);
this->state_ = State::CONNECTING;
}
void BLEClient::disconnect() {
if (this->state_ == State::IDLE)
return;
this->backend_->disconnect();
}
void BLEClient::request_notify(uint16_t char_handle, uint16_t cccd_handle, uint8_t properties) {
if (this->cccd_count_ >= BLE_CLIENT_MAX_NOTIFY_REQUESTS) {
ESP_LOGW(TAG, "[%s] Too many notify requests, dropping handle 0x%04x", this->address_str_, char_handle);
return;
}
// Local registration is free (no operation slot); the CCCD write is
// serialized through the client's queue because the backend accepts one
// outstanding operation at a time.
this->backend_->notify_characteristic(char_handle, true);
uint8_t value = (properties & ble_device_base::GATT_CHAR_PROP_NOTIFY) ? 1 : 2;
this->pending_cccd_[this->cccd_count_++] = {cccd_handle, value};
}
void BLEClient::issue_next_cccd_() {
while (this->cccd_head_ < this->cccd_count_) {
const auto &pending = this->pending_cccd_[this->cccd_head_];
const uint8_t value[2] = {pending.value, 0x00};
if (this->backend_->write_descriptor(pending.handle, value, sizeof(value)) == 0) {
return; // advance on the matching on_write_result
}
ESP_LOGW(TAG, "[%s] CCCD write failed for handle 0x%04x", this->address_str_, pending.handle);
this->cccd_head_++;
}
}
void BLEClient::register_failure_() {
if (this->consecutive_failures_ < FAILURE_HOLD_OFF_MAX_STEPS)
this->consecutive_failures_++;
this->hold_off_until_ = millis() + this->consecutive_failures_ * FAILURE_HOLD_OFF_STEP_MS;
ESP_LOGW(TAG, "[%s] Holding off reconnect for %u s", this->address_str_,
this->consecutive_failures_ * (FAILURE_HOLD_OFF_STEP_MS / 1000));
}
void BLEClient::on_connection_state(bool connected, uint16_t mtu, int error) {
if (connected) {
this->state_ = State::DISCOVERING;
if (this->backend_->discover_services() != 0) {
// Synchronous refusal: no discovery completion will follow.
this->backend_->disconnect();
}
return;
}
bool was_connected = this->state_ == State::CONNECTED;
this->state_ = State::IDLE;
this->cccd_head_ = this->cccd_count_ = 0;
if (was_connected) {
ESP_LOGI(TAG, "[%s] Disconnected, status=%d", this->address_str_, error);
for (auto *node : this->nodes_) {
node->on_disconnected();
}
// Continuations leave the backend's event-drain stack first.
this->defer([this]() { this->disconnect_callbacks_.call(); });
} else {
ESP_LOGW(TAG, "[%s] Connect failed, status=%d", this->address_str_, error);
this->register_failure_();
this->defer([this]() { this->connect_failed_callbacks_.call(); });
}
}
void BLEClient::on_service_discovery_done(int error) {
if (error != 0) {
ESP_LOGW(TAG, "[%s] Service discovery failed, status=%d", this->address_str_, error);
this->register_failure_();
this->backend_->disconnect();
return;
}
auto table = this->backend_->get_service_table();
for (auto *node : this->nodes_) {
node->on_connected(table);
}
this->backend_->release_services();
this->state_ = State::CONNECTED;
this->consecutive_failures_ = 0;
this->hold_off_until_ = 0;
ESP_LOGI(TAG, "[%s] Connected", this->address_str_);
this->defer([this]() { this->connect_callbacks_.call(); });
this->issue_next_cccd_();
}
void BLEClient::on_write_result(uint16_t handle, int error) {
// The client's CCCD queue claims its own completion before the node
// fan-out sees it.
if (this->cccd_head_ < this->cccd_count_ && this->pending_cccd_[this->cccd_head_].handle == handle) {
if (error != 0) {
ESP_LOGW(TAG, "[%s] CCCD write error %d on handle 0x%04x", this->address_str_, error, handle);
}
this->cccd_head_++;
this->issue_next_cccd_();
return;
}
for (auto *node : this->nodes_) {
node->on_write_result(handle, error);
}
}
void BLEClient::on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {
for (auto *node : this->nodes_) {
node->on_read_result(handle, data, len, error);
}
}
void BLEClient::on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) {
// Every node sees every notification and filters by handle (legacy parity).
for (auto *node : this->nodes_) {
node->on_notify(handle, data, len);
}
}
void BLEClient::dump_config() {
ESP_LOGCONFIG(TAG,
"BLE Client:\n"
" Address: %s\n"
" Auto connect: %s",
this->address_str_, YESNO(this->auto_connect_));
if (this->enabled && this->state_ == State::IDLE) {
ESP_LOGCONFIG(TAG, " Waiting for an advertisement from the device");
}
}
} // namespace esphome::ble_client
#endif // USE_BLE_GATT_CLIENT && !USE_ESP32
@@ -0,0 +1,174 @@
// Platform-neutral ble_client engine on the ble_device_base GATT contract.
//
// Compiled on every platform with a GATT backend except esp32, which keeps
// the legacy BLEClientBase engine (ble_client.h) until its raw-gattc node
// family migrates - the exclusive gates make the same class names resolve to
// exactly one definition per build, so codegen is shared.
//
// Connects are sighting-gated like the legacy engine: the client is a parsed
// advertisement listener, captures the peer's address type from the sighting,
// and asks the backend to connect only when enabled and idle.
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_BLE_GATT_CLIENT) && !defined(USE_ESP32)
#include "esphome/components/ble_device_base/ble_client_state.h"
#include "esphome/components/ble_device_base/ble_device.h"
#include "esphome/components/ble_device_base/ble_gatt_client.h"
#include "esphome/components/bluetooth_connection/bluetooth_connection_gatt_backend.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include <array>
#include <cstdint>
#include <functional>
#include <vector>
namespace esphome::ble_client {
class BLEClient;
// Concurrent notify subscriptions per client (matches the smallest backend's
// own cap; a node requesting more logs and is dropped).
static constexpr uint8_t BLE_CLIENT_MAX_NOTIFY_REQUESTS = 16;
/// The neutral node interface. Nodes resolve their handles from the service
/// table during on_connected() and MUST copy what they need: the table is
/// borrowed backend storage, valid only for the duration of that call.
class BLEClientNode {
public:
virtual void on_connected(const ble_device_base::GattServiceTable &table) {}
virtual void on_disconnected() {}
virtual void on_notify(uint16_t handle, const uint8_t *data, uint16_t len) {}
virtual void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) {}
virtual void on_write_result(uint16_t handle, int error) {}
BLEClient *parent() const { return this->parent_; }
void set_ble_client_parent(BLEClient *parent) { this->parent_ = parent; }
protected:
BLEClient *parent_{nullptr};
};
class BLEClient : public Component,
public ble_device_base::ESPBTDeviceListener,
public ble_device_base::GattClientListener {
public:
void setup() override { this->enabled = true; }
void dump_config() override;
// Public field for legacy parity (the switch platform republishes it).
bool enabled{true};
void set_backend(ble_device_base::BLEGattConnection *backend) {
this->backend_ = backend;
backend->set_listener(this);
}
void set_address(uint64_t address);
void set_auto_connect(bool auto_connect) { this->auto_connect_ = auto_connect; }
void set_enabled(bool enabled);
const char *address_str() const { return this->address_str_; }
void register_ble_node(BLEClientNode *node) {
node->set_ble_client_parent(this);
this->nodes_.push_back(node);
}
bool connected() const { return this->state_ == State::CONNECTED; }
bool idle() const { return this->state_ == State::IDLE; }
/// Action-initiated connect (no sighting needed; uses the last captured or
/// configured address type). No-op unless idle.
void connect();
void disconnect();
/// Legacy-named deferral used by the automation twins: neutral listener
/// callbacks run inside the backend's event drain, so automation chain
/// continuations must leave that stack first.
void run_later(std::function<void()> &&f) { this->defer(std::move(f)); } // NOLINT
/// Called by nodes during on_connected(): registers the notification with
/// the backend and queues the CCCD write (the backend accepts one
/// outstanding operation, so the client serializes them).
void request_notify(uint16_t char_handle, uint16_t cccd_handle, uint8_t properties);
// Backend ops for nodes and actions.
int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) {
return this->backend_->write_characteristic(handle, data, len, response);
}
int read_characteristic(uint16_t handle) { return this->backend_->read_characteristic(handle); }
int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) {
return this->backend_->write_descriptor(handle, data, len);
}
// Automation callback registration.
template<typename F> void add_on_connect_callback(F &&callback) {
this->connect_callbacks_.add(std::forward<F>(callback));
}
template<typename F> void add_on_disconnect_callback(F &&callback) {
this->disconnect_callbacks_.add(std::forward<F>(callback));
}
// Fired when a connect attempt dies before being established; the user
// on_disconnect trigger deliberately does NOT fire here (legacy parity).
template<typename F> void add_on_connect_failed_callback(F &&callback) {
this->connect_failed_callbacks_.add(std::forward<F>(callback));
}
// ---- ble_device_base::ESPBTDeviceListener ----
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
// ---- ble_device_base::GattClientListener ----
void on_connection_state(bool connected, uint16_t mtu, int error) override;
void on_service_discovery_done(int error) override;
void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) override;
void on_write_result(uint16_t handle, int error) override;
void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override;
protected:
enum class State : uint8_t { IDLE, CONNECTING, DISCOVERING, CONNECTED };
struct PendingCccd {
uint16_t handle;
uint8_t value; // 1 = notify, 2 = indicate
};
void attempt_connect_(bool from_action);
void issue_next_cccd_();
void register_failure_();
// Group 1: pointers / containers
ble_device_base::BLEGattConnection *backend_{nullptr};
std::vector<BLEClientNode *> nodes_; // filled during setup, never after
// Group 2: 8-byte types
uint64_t address_{0};
// Group 3: callback managers (pointer-sized when empty)
LazyCallbackManager<void()> connect_callbacks_;
LazyCallbackManager<void()> disconnect_callbacks_;
LazyCallbackManager<void()> connect_failed_callbacks_;
// Group 4: 4-byte types
// Backoff after repeated failures so an undiscoverable database or a
// dead peer cannot produce a battery-draining connect loop.
uint32_t hold_off_until_{0};
// Group 5: arrays
char address_str_[MAC_ADDRESS_PRETTY_BUFFER_SIZE]{};
std::array<PendingCccd, BLE_CLIENT_MAX_NOTIFY_REQUESTS> pending_cccd_{};
// Group 6: 1-byte types
State state_{State::IDLE};
uint8_t address_type_{0}; // BLE_ADDR_TYPE_*, captured from the sighting
bool auto_connect_{true};
uint8_t cccd_head_{0};
uint8_t cccd_count_{0};
uint8_t consecutive_failures_{0};
};
} // namespace esphome::ble_client
#endif // USE_BLE_GATT_CLIENT && !USE_ESP32
@@ -155,6 +155,14 @@ concept BLEGattConnectionContract = requires(T conn, GattClientListener *listene
/// Client Characteristic Configuration descriptor UUID (Bluetooth spec).
static constexpr uint16_t CCCD_UUID = 0x2902;
// Characteristic property bits (the Bluetooth-spec declaration byte carried
// in GattCharacteristic::properties; the ESP-IDF macros for these do not
// exist on the other platforms).
static constexpr uint8_t GATT_CHAR_PROP_WRITE_NO_RSP = 0x04;
static constexpr uint8_t GATT_CHAR_PROP_WRITE = 0x08;
static constexpr uint8_t GATT_CHAR_PROP_NOTIFY = 0x10;
static constexpr uint8_t GATT_CHAR_PROP_INDICATE = 0x20;
inline const GattService *find_service(const GattServiceTable &table, const ESPBTUUID &uuid) {
for (uint16_t i = 0; i < table.service_count; i++) {
if (table.services[i].uuid == uuid)