diff --git a/esphome/components/ble_client/automation.cpp b/esphome/components/ble_client/automation.cpp index cd2802f617..49f72d095b 100644 --- a/esphome/components/ble_client/automation.cpp +++ b/esphome/components/ble_client/automation.cpp @@ -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 diff --git a/esphome/components/ble_client/automation_gatt.h b/esphome/components/ble_client/automation_gatt.h new file mode 100644 index 0000000000..d7fefa373d --- /dev/null +++ b/esphome/components/ble_client/automation_gatt.h @@ -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 +#include +#include + +#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 class BLEClientWriteAction final : public Action, 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 (*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 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 (*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 var_{}; + uint16_t char_handle_{}; + bool write_response_{false}; + bool resolved_{false}; +}; + +template class BLEClientConnectAction final : public Action { + 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 var_{}; +}; + +template class BLEClientDisconnectAction final : public Action { + 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 var_{}; +}; + +} // namespace esphome::ble_client + +#endif // USE_BLE_GATT_CLIENT && !USE_ESP32 diff --git a/esphome/components/ble_client/ble_client.h b/esphome/components/ble_client/ble_client.h index f20df31816..c8494cc82c 100644 --- a/esphome/components/ble_client/ble_client.h +++ b/esphome/components/ble_client/ble_client.h @@ -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 #include #include diff --git a/esphome/components/ble_client/ble_client_gatt.cpp b/esphome/components/ble_client/ble_client_gatt.cpp new file mode 100644 index 0000000000..7415b829ae --- /dev/null +++ b/esphome/components/ble_client/ble_client_gatt.cpp @@ -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 diff --git a/esphome/components/ble_client/ble_client_gatt.h b/esphome/components/ble_client/ble_client_gatt.h new file mode 100644 index 0000000000..16bfb7a814 --- /dev/null +++ b/esphome/components/ble_client/ble_client_gatt.h @@ -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 +#include +#include +#include + +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 &&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 void add_on_connect_callback(F &&callback) { + this->connect_callbacks_.add(std::forward(callback)); + } + template void add_on_disconnect_callback(F &&callback) { + this->disconnect_callbacks_.add(std::forward(callback)); + } + // Fired when a connect attempt dies before being established; the user + // on_disconnect trigger deliberately does NOT fire here (legacy parity). + template void add_on_connect_failed_callback(F &&callback) { + this->connect_failed_callbacks_.add(std::forward(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 nodes_; // filled during setup, never after + + // Group 2: 8-byte types + uint64_t address_{0}; + + // Group 3: callback managers (pointer-sized when empty) + LazyCallbackManager connect_callbacks_; + LazyCallbackManager disconnect_callbacks_; + LazyCallbackManager 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 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 diff --git a/esphome/components/ble_device_base/ble_gatt_client.h b/esphome/components/ble_device_base/ble_gatt_client.h index 6208831fd4..4d0e535cbc 100644 --- a/esphome/components/ble_device_base/ble_gatt_client.h +++ b/esphome/components/ble_device_base/ble_gatt_client.h @@ -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)