From 634515ecc56c3ac2e583f2c8558f9f73e4df72eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 6 Aug 2026 21:45:57 -0500 Subject: [PATCH] [ble_device_base] Add platform-neutral GATT client contract (#18128) --- .../components/ble_device_base/__init__.py | 15 ++ .../ble_device_base/ble_client_state.cpp | 26 ++++ .../ble_device_base/ble_client_state.h | 53 +++++++ .../components/ble_device_base/ble_device.cpp | 13 +- .../components/ble_device_base/ble_device.h | 21 ++- .../ble_device_base/ble_gatt_client.h | 140 ++++++++++++++++++ esphome/components/ble_device_base/ble_hub.h | 5 +- esphome/components/ble_scanner/ble_scanner.h | 2 +- .../bluetooth_proxy/bluetooth_proxy.h | 4 +- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 21 --- .../esp32_ble_tracker/esp32_ble_tracker.h | 37 +---- .../thermopro_ble/thermopro_ble.cpp | 2 +- .../components/thermopro_ble/thermopro_ble.h | 2 +- esphome/core/defines.h | 2 + .../ble_device_base/test_address.cpp | 14 ++ .../ble_device_base/test_adv_name.cpp | 91 ++++++++++++ .../test_gatt_client_contract.cpp | 66 +++++++++ 17 files changed, 450 insertions(+), 64 deletions(-) create mode 100644 esphome/components/ble_device_base/ble_client_state.cpp create mode 100644 esphome/components/ble_device_base/ble_client_state.h create mode 100644 esphome/components/ble_device_base/ble_gatt_client.h create mode 100644 tests/components/ble_device_base/test_adv_name.cpp create mode 100644 tests/components/ble_device_base/test_gatt_client_contract.cpp diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index ad162c616d2..a52286f4566 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -150,6 +150,21 @@ def request_irk_support() -> None: cg.add_define("USE_BLE_DEVICE_IRK") +# Number of GATT client connection slots in this build; sizes the platform +# backend's connection storage. +GATT_CLIENT_COUNT_DEFINE = "ESPHOME_BLE_GATT_CLIENT_COUNT" + +_request_gatt_connection_slot = cg.slot_counter(GATT_CLIENT_COUNT_DEFINE) + + +def request_gatt_client() -> None: + """Compile in the neutral GATT client contract (ble_gatt_client.h) and + claim one connection slot. Called by bluetooth_proxy once per connection + it instantiates on a hub platform.""" + cg.add_define("USE_BLE_GATT_CLIENT") + _request_gatt_connection_slot() + + _request_listener_slot = cg.slot_counter(LISTENER_COUNT_DEFINE) diff --git a/esphome/components/ble_device_base/ble_client_state.cpp b/esphome/components/ble_device_base/ble_client_state.cpp new file mode 100644 index 00000000000..55817024b52 --- /dev/null +++ b/esphome/components/ble_device_base/ble_client_state.cpp @@ -0,0 +1,26 @@ +#include "ble_client_state.h" + +namespace esphome::ble_device_base { + +const char *client_state_to_string(ClientState state) { + switch (state) { + case ClientState::INIT: + return "INIT"; + case ClientState::DISCONNECTING: + return "DISCONNECTING"; + case ClientState::IDLE: + return "IDLE"; + case ClientState::DISCOVERED: + return "DISCOVERED"; + case ClientState::CONNECTING: + return "CONNECTING"; + case ClientState::CONNECTED: + return "CONNECTED"; + case ClientState::ESTABLISHED: + return "ESTABLISHED"; + default: + return "UNKNOWN"; + } +} + +} // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_client_state.h b/esphome/components/ble_device_base/ble_client_state.h new file mode 100644 index 00000000000..58f7d84fad9 --- /dev/null +++ b/esphome/components/ble_device_base/ble_client_state.h @@ -0,0 +1,53 @@ +// ble_client_state.h +// +// Platform-neutral GATT client connection state types, shared by every +// platform's GATT client implementation (esp32_ble_client, bluetooth_connection +// backends). Moved here from esp32_ble_tracker, which re-exports them under its +// own namespace for backward compatibility. + +#pragma once + +#include + +namespace esphome::ble_device_base { + +/// ESPHome-private errors for the API's plain-int error fields, outside the +/// ATT code range so they cannot be mistaken for spec errors. -1 is +/// understood by API clients as "not connected". Shared by every GATT +/// client backend. +static constexpr int GATT_ERR_NOT_CONNECTED = -1; +static constexpr int GATT_ERR_NO_MEMORY = -2; + +enum class ClientState : uint8_t { + // Connection is allocated + INIT, + // Client is disconnecting + DISCONNECTING, + // Connection is idle, no device detected. + IDLE, + // Device advertisement found. + DISCOVERED, + // Connection in progress. + CONNECTING, + // Initial connection established. + CONNECTED, + // The client and sub-clients have completed setup. + ESTABLISHED, +}; + +// Helper function to convert ClientState to string +const char *client_state_to_string(ClientState state); + +enum class ConnectionType : uint8_t { + // The default connection type, we hold all the services in ram + // for the duration of the connection. + V1, + // The client has a cache of the services and mtu so we should not + // fetch them again + V3_WITH_CACHE, + // The client does not need the services and mtu once we send them + // so we should wipe them from memory as soon as we send them + V3_WITHOUT_CACHE +}; + +} // namespace esphome::ble_device_base diff --git a/esphome/components/ble_device_base/ble_device.cpp b/esphome/components/ble_device_base/ble_device.cpp index c2a43ee7e94..fc5bf5c1e01 100644 --- a/esphome/components/ble_device_base/ble_device.cpp +++ b/esphome/components/ble_device_base/ble_device.cpp @@ -349,7 +349,8 @@ void ESPBTDevice::from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_ty this->address_[i] = mac[5 - i]; this->address_type_ = addr_type; this->rssi_ = rssi; - this->name_.clear(); + this->name_len_ = 0; + this->name_[0] = '\0'; this->service_uuids_.clear(); this->manufacturer_datas_.clear(); this->service_datas_.clear(); @@ -365,7 +366,7 @@ void ESPBTDevice::from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_ty " Address: %s (%s)\n" " RSSI: %d\n" " Name: '%s'", - this->address_str_to(addr_buf), this->address_type_str(), this->rssi_, this->name_.c_str()); + this->address_str_to(addr_buf), this->address_type_str(), this->rssi_, this->name_); for (auto &it : this->tx_powers_) { ESP_LOGVV(TAG, " TX Power: %d", it); } @@ -477,8 +478,12 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) { // Keep the longest name seen — a merged adv + scan-response frame may carry both the // shortened and the complete name, and the shortened form must never replace the // complete one (same rule as esp32_ble_tracker's parse_adv_). - if (ad_data_len > this->name_.length()) - this->name_.assign(reinterpret_cast(ad_data), ad_data_len); + if (ad_data_len > this->name_len_) { + uint8_t name_len = ad_data_len > MAX_ADV_NAME_LEN ? MAX_ADV_NAME_LEN : static_cast(ad_data_len); + memcpy(this->name_, ad_data, name_len); + this->name_[name_len] = '\0'; + this->name_len_ = name_len; + } break; case 0x0A: // TX Power Level diff --git a/esphome/components/ble_device_base/ble_device.h b/esphome/components/ble_device_base/ble_device.h index 0f40c51b299..fba1fe23476 100644 --- a/esphome/components/ble_device_base/ble_device.h +++ b/esphome/components/ble_device_base/ble_device.h @@ -14,6 +14,7 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" #include #include @@ -166,6 +167,13 @@ inline uint64_t mac_lsb_first_to_uint64(const uint8_t *mac) { return addr; } +/// Unpack a uint64 BLE address into printable (MSB-first) byte order — +/// the order bd_addr_t / esp_bd_addr_t style APIs expect. +inline void uint64_to_mac_msb_first(uint64_t address, uint8_t out[6]) { + for (int i = 0; i < 6; i++) + out[i] = (address >> ((5 - i) * 8)) & 0xFF; +} + // --------------------------------------------------------------------------- // ESPBTDevice — parsed BLE advertisement // --------------------------------------------------------------------------- @@ -211,7 +219,9 @@ class ESPBTDevice { const char *address_type_str() const; int get_rssi() const { return rssi_; } - const std::string &get_name() const { return name_; } + /// Advertised name as a view into the fixed buffer (always NUL-terminated, + /// so c_str() is safe); converts implicitly to std::string where needed. + StringRef get_name() const { return StringRef(this->name_, this->name_len_); } const std::vector &get_service_uuids() const { return service_uuids_; } const std::vector &get_manufacturer_datas() const { return manufacturer_datas_; } @@ -230,10 +240,17 @@ class ESPBTDevice { protected: void parse_adv_(const uint8_t *payload, uint16_t len); + // Max name bytes in a legacy advertisement AD element (31-byte PDU minus + // the 2-byte element header); every in-tree tracker scans legacy PDUs only. + static constexpr uint8_t MAX_ADV_NAME_LEN = 29; + uint8_t address_[6]{0}; uint8_t address_type_{0}; int rssi_{0}; - std::string name_{}; + // Fixed buffer instead of std::string: no per-advertisement heap churn on + // the scan path, and no libstdc++ string/exception machinery in the image. + char name_[MAX_ADV_NAME_LEN + 1]{}; + uint8_t name_len_{0}; std::vector service_uuids_{}; std::vector manufacturer_datas_{}; std::vector service_datas_{}; diff --git a/esphome/components/ble_device_base/ble_gatt_client.h b/esphome/components/ble_device_base/ble_gatt_client.h new file mode 100644 index 00000000000..ef28f7672ac --- /dev/null +++ b/esphome/components/ble_device_base/ble_gatt_client.h @@ -0,0 +1,140 @@ +// ble_gatt_client.h +// +// Platform-neutral GATT client connection contract. +// +// A platform's GATT client backend (bluetooth_connection/esp32, +// bluetooth_connection/rp2) implements BLEGattConnection; consumers +// (bluetooth_proxy) drive it through this interface and receive +// completions through GattClientEventListener. All listener callbacks are +// delivered on the ESPHome main loop; borrowed data pointers are valid only +// for the duration of the call. +// +// Error domain (plain int, forwarded to the API without translation): +// 0 success +// 1..0x11 ATT error codes (Bluetooth spec; BTstack and Bluedroid agree) +// GATT_ERR_NOT_CONNECTED (-1) no connection to the peer (on esp32 a raw +// ESP_FAIL from the stack shares this value; both read as a +// failed, unusable connection on the client side) +// GATT_ERR_NO_MEMORY (-2) backend storage exhausted +// anything else: platform stack error/status code, surfaced opaquely. +// Connection events carry HCI status/disconnect reason codes (same code +// space on every controller). + +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_BLE_GATT_CLIENT + +#include "ble_client_state.h" +#include "ble_device.h" + +#include + +namespace esphome::ble_device_base { + +// Materialized GATT database of a connected peer, discovered by the backend +// and streamed to the API by the consumer. Flat arrays with index ranges +// (not pointers): a service owns characteristics +// [first_characteristic, first_characteristic + characteristic_count) and a +// characteristic owns descriptors [first_descriptor, ...) — discovery is +// depth-first, so the ranges are naturally contiguous. +struct GattDescriptor { + ESPBTUUID uuid; + uint16_t handle; +}; + +struct GattCharacteristic { + ESPBTUUID uuid; + uint16_t value_handle; + // Needed to rebuild the stack's characteristic object for CCCD operations. + uint16_t end_handle; + uint8_t properties; // Bluetooth spec property bitfield + uint16_t first_descriptor; + uint16_t descriptor_count; +}; + +struct GattService { + ESPBTUUID uuid; + uint16_t start_handle; + uint16_t end_handle; + uint16_t first_characteristic; + uint16_t characteristic_count; +}; + +/// Borrowed view of the backend-owned service table. Valid from a successful +/// on_service_discovery_done() until release_services(). Characteristics and +/// descriptors are reached through the per-service/per-characteristic index +/// ranges; the array totals let a consumer bounds-check those ranges instead +/// of trusting the backend's discovery bookkeeping blindly. +struct GattServiceTable { + const GattService *services{nullptr}; + const GattCharacteristic *characteristics{nullptr}; + const GattDescriptor *descriptors{nullptr}; + uint16_t service_count{0}; + uint16_t characteristic_count{0}; + uint16_t descriptor_count{0}; +}; + +/// Completion/event sink for a GATT connection. Implemented by the consumer +/// (bluetooth_proxy's connection wrapper). Every callback runs on the main loop. +class GattClientEventListener { + public: + virtual ~GattClientEventListener() = default; + + /// Connected (with negotiated MTU) or disconnected/connect-failed + /// (error = HCI status or disconnect reason). + virtual void on_connection_state(bool connected, uint16_t mtu, int error) = 0; + /// Service discovery finished; on success the service table is populated. + virtual void on_service_discovery_done(int error) = 0; + /// Characteristic or descriptor read finished. data/len valid during the call. + virtual void on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) = 0; + /// Characteristic write-with-response or descriptor write finished. + virtual void on_write_result(uint16_t handle, int error) = 0; + /// Notification/indication registration state changed. + virtual void on_notify_state(uint16_t handle, bool enabled, int error) = 0; + /// Notification/indication data from the peer. data/len valid during the call. + virtual void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) = 0; +}; + +/// One GATT client connection slot. Operations return 0 when accepted +/// (completion arrives via the listener) or a synchronous error code +/// (busy, not connected, stack rejection). One operation may be outstanding +/// at a time; callers see a synchronous error otherwise. +class BLEGattConnection { + public: + virtual ~BLEGattConnection() = default; + + void set_listener(GattClientEventListener *listener) { this->listener_ = listener; } + + /// Start connecting to a peer. addr_type is a BLE_ADDR_TYPE_* constant + /// (ble_device.h). Completion: on_connection_state(). + virtual int connect(uint64_t address, uint8_t addr_type) = 0; + /// Disconnect (or cancel a connect in progress). Completion: on_connection_state(). + virtual int disconnect() = 0; + /// Discover the peer's services/characteristics/descriptors into the + /// service table. Completion: on_service_discovery_done(). + virtual int discover_services() = 0; + virtual int read_characteristic(uint16_t handle) = 0; + virtual int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) = 0; + virtual int read_descriptor(uint16_t handle) = 0; + virtual int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) = 0; + /// Enable/disable delivery of on_notify_data() for a characteristic value + /// handle. Local registration only — the CCCD write is the API client's + /// responsibility (it arrives as a plain write_descriptor). + virtual int notify_characteristic(uint16_t handle, bool enable) = 0; + virtual int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout) = 0; + + /// Backend-owned service table (see GattServiceTable lifetime). + virtual GattServiceTable get_service_table() = 0; + /// Free the transient service table storage. Call after streaming. + virtual void release_services() = 0; + + protected: + GattClientEventListener *listener_{nullptr}; +}; + +} // namespace esphome::ble_device_base + +#endif // USE_BLE_GATT_CLIENT diff --git a/esphome/components/ble_device_base/ble_hub.h b/esphome/components/ble_device_base/ble_hub.h index 454478e0eb7..3870e9833e1 100644 --- a/esphome/components/ble_device_base/ble_hub.h +++ b/esphome/components/ble_device_base/ble_hub.h @@ -56,8 +56,9 @@ struct HubCapabilities { /// frame. When false, consumers relying on scan-response fields (e.g. names) /// may only see them where the receiver merges per address (Home Assistant does). bool merges_scan_response; - /// GATT client connections are available (today: esp32 only, but a chip SDK - /// gaining GATT support only has to flip this bit). + /// GATT client connections are available: the platform has a + /// bluetooth_connection backend implementing ble_device_base::BLEGattConnection + /// (ble_gatt_client.h). Today: esp32; rp2 follows with its BTstack backend. bool gatt; /// request_scan_mode() is honored at runtime. Distinct from active_scan: /// a passive-only controller (bk72xx) can never switch, and a hub may diff --git a/esphome/components/ble_scanner/ble_scanner.h b/esphome/components/ble_scanner/ble_scanner.h index 106171d38f6..b4e4488646d 100644 --- a/esphome/components/ble_scanner/ble_scanner.h +++ b/esphome/components/ble_scanner/ble_scanner.h @@ -23,7 +23,7 @@ class BLEScanner final : public text_sensor::TextSensor, // Escape special characters in the device name for valid JSON. Control characters stay in the \u00XX form this // sensor has always published. char escaped_name[128]; - json_escape_into_buffer(escaped_name, StringRef(device.get_name()), /*short_control_escapes=*/false); + json_escape_into_buffer(escaped_name, device.get_name(), /*short_control_escapes=*/false); char buf[256]; snprintf(buf, sizeof(buf), "{\"timestamp\":%" PRId64 ",\"address\":\"%s\",\"rssi\":%d,\"name\":\"%s\"}", diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 54236dbc674..fd1f1839c9b 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -13,6 +13,8 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/components/ble_device_base/ble_client_state.h" + #ifdef USE_ESP32 #include "esphome/components/esp32_ble_client/ble_client_base.h" #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" @@ -41,7 +43,7 @@ using proxy_err_t = int; static constexpr proxy_err_t PROXY_OK = 0; #endif -static constexpr proxy_err_t ESP_GATT_NOT_CONNECTED = -1; +static constexpr proxy_err_t ESP_GATT_NOT_CONNECTED = ble_device_base::GATT_ERR_NOT_CONNECTED; static constexpr int DONE_SENDING_SERVICES = -2; static constexpr int INIT_SENDING_SERVICES = -3; diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index bae4e1634d9..8418fc3fec7 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -36,27 +36,6 @@ static const char *const TAG = "esp32_ble_tracker"; ESP32BLETracker *global_esp32_ble_tracker = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -const char *client_state_to_string(ClientState state) { - switch (state) { - case ClientState::INIT: - return "INIT"; - case ClientState::DISCONNECTING: - return "DISCONNECTING"; - case ClientState::IDLE: - return "IDLE"; - case ClientState::DISCOVERED: - return "DISCOVERED"; - case ClientState::CONNECTING: - return "CONNECTING"; - case ClientState::CONNECTED: - return "CONNECTED"; - case ClientState::ESTABLISHED: - return "ESTABLISHED"; - default: - return "UNKNOWN"; - } -} - float ESP32BLETracker::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } void ESP32BLETracker::setup() { diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index fe2a5d85994..88642fff6b7 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -18,6 +18,7 @@ #include #include +#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_hub.h" #include "esphome/components/esp32_ble/ble.h" @@ -88,22 +89,11 @@ struct ClientStateCounts { bool operator!=(const ClientStateCounts &other) const { return !(*this == other); } }; -enum class ClientState : uint8_t { - // Connection is allocated - INIT, - // Client is disconnecting - DISCONNECTING, - // Connection is idle, no device detected. - IDLE, - // Device advertisement found. - DISCOVERED, - // Connection in progress. - CONNECTING, - // Initial connection established. - CONNECTED, - // The client and sub-clients have completed setup. - ESTABLISHED, -}; +// The client connection state types are owned by the platform-neutral +// ble_device_base layer; re-exported here for backward compatibility. +using ClientState = ble_device_base::ClientState; +using ConnectionType = ble_device_base::ConnectionType; +using ble_device_base::client_state_to_string; enum class ScannerState { // Scanner is idle, init state @@ -128,21 +118,6 @@ class BLEScannerStateListener { virtual void on_scanner_state(ScannerState state) = 0; }; -// Helper function to convert ClientState to string -const char *client_state_to_string(ClientState state); - -enum class ConnectionType : uint8_t { - // The default connection type, we hold all the services in ram - // for the duration of the connection. - V1, - // The client has a cache of the services and mtu so we should not - // fetch them again - V3_WITH_CACHE, - // The client does not need the services and mtu once we send them - // so we should wipe them from memory as soon as we send them - V3_WITHOUT_CACHE -}; - /// Base class for BLE GATT clients that connect to remote devices. /// /// State Change Tracking Design: diff --git a/esphome/components/thermopro_ble/thermopro_ble.cpp b/esphome/components/thermopro_ble/thermopro_ble.cpp index 2a950d36645..72e398f7741 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.cpp +++ b/esphome/components/thermopro_ble/thermopro_ble.cpp @@ -92,7 +92,7 @@ bool ThermoProBLE::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { return success; } -void ThermoProBLE::update_device_type_(const std::string &device_name) { +void ThermoProBLE::update_device_type_(StringRef device_name) { // check for changed device name (should only happen on initial call) if (this->device_name_ == device_name) { return; diff --git a/esphome/components/thermopro_ble/thermopro_ble.h b/esphome/components/thermopro_ble/thermopro_ble.h index 2d7523e07af..ca04fbea39b 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.h +++ b/esphome/components/thermopro_ble/thermopro_ble.h @@ -41,7 +41,7 @@ class ThermoProBLE final : public Component, public esp32_ble_tracker::ESPBTDevi sensor::Sensor *humidity_{nullptr}; sensor::Sensor *battery_level_{nullptr}; - void update_device_type_(const std::string &device_name); + void update_device_type_(StringRef device_name); }; } // namespace esphome::thermopro_ble diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 75a92f8fced..63b9c879182 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -464,6 +464,8 @@ #define USE_RP2040_BLE #define RP2040_BLE_SCAN_LISTENER_COUNT 1 #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 +#define USE_BLE_GATT_CLIENT +#define ESPHOME_BLE_GATT_CLIENT_COUNT 1 #define USE_RP2040_VARIANT_RP2040 #define USE_SPI #ifndef USE_ETHERNET diff --git a/tests/components/ble_device_base/test_address.cpp b/tests/components/ble_device_base/test_address.cpp index f1eebcbfc1f..7ff318b66bb 100644 --- a/tests/components/ble_device_base/test_address.cpp +++ b/tests/components/ble_device_base/test_address.cpp @@ -50,4 +50,18 @@ TEST(BleDeviceAddress, MacLsbFirstToUint64AgreesWithParsedDevice) { EXPECT_EQ(mac_lsb_first_to_uint64(MAC_LSB_FIRST), device.address_uint64()); } +// uint64_to_mac_msb_first() is the inverse: unpacking the wire value yields +// printable (MSB-first) order, and round-tripping through the LSB-first +// packer restores the original value. +TEST(BleDeviceAddress, Uint64ToMacMsbFirstRoundTrip) { + uint8_t msb_first[6]; + uint64_to_mac_msb_first(0xAABBCCDDEEFFULL, msb_first); + EXPECT_EQ(msb_first[0], 0xaa); + EXPECT_EQ(msb_first[5], 0xff); + uint8_t lsb_first[6]; + for (int i = 0; i < 6; i++) + lsb_first[i] = msb_first[5 - i]; + EXPECT_EQ(mac_lsb_first_to_uint64(lsb_first), 0xAABBCCDDEEFFULL); +} + } // namespace esphome::ble_device_base::testing diff --git a/tests/components/ble_device_base/test_adv_name.cpp b/tests/components/ble_device_base/test_adv_name.cpp new file mode 100644 index 00000000000..44fb3525ea1 --- /dev/null +++ b/tests/components/ble_device_base/test_adv_name.cpp @@ -0,0 +1,91 @@ +#include "esphome/components/ble_device_base/ble_device.h" + +#include + +#include +#include +#include + +namespace esphome::ble_device_base { +namespace { + +// AD types under test +constexpr uint8_t AD_SHORT_NAME = 0x08; +constexpr uint8_t AD_COMPLETE_NAME = 0x09; + +void append_name(std::vector &adv, uint8_t ad_type, const char *name) { + size_t len = strlen(name); + adv.push_back(static_cast(len + 1)); + adv.push_back(ad_type); + adv.insert(adv.end(), name, name + len); +} + +ESPBTDevice device_from(const std::vector &adv) { + const uint8_t mac[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66}; + ESPBTDevice device; + device.from_scan_result(mac, -59, 0, adv.data(), static_cast(adv.size())); + return device; +} + +} // namespace + +TEST(BleAdvName, ParsesACompleteName) { + std::vector adv; + append_name(adv, AD_COMPLETE_NAME, "TP96"); + ESPBTDevice device = device_from(adv); + EXPECT_EQ(device.get_name(), "TP96"); + // The backing buffer is NUL-terminated so c_str() is usable directly. + EXPECT_STREQ(device.get_name().c_str(), "TP96"); +} + +TEST(BleAdvName, LongestNameWinsShortenedThenComplete) { + // A merged adv + scan-response frame can carry both forms; the shortened + // one must never replace the complete one. + std::vector adv; + append_name(adv, AD_SHORT_NAME, "Radon"); + append_name(adv, AD_COMPLETE_NAME, "RadonEye"); + EXPECT_EQ(device_from(adv).get_name(), "RadonEye"); +} + +TEST(BleAdvName, LongestNameWinsCompleteThenShortened) { + std::vector adv; + append_name(adv, AD_COMPLETE_NAME, "RadonEye"); + append_name(adv, AD_SHORT_NAME, "Radon"); + EXPECT_EQ(device_from(adv).get_name(), "RadonEye"); +} + +TEST(BleAdvName, MaxLengthNameFitsAndTerminates) { + // 29 bytes is the largest name a legacy AD element can carry and exactly + // fills the fixed buffer. + std::string max_name(29, 'a'); + std::vector adv; + append_name(adv, AD_COMPLETE_NAME, max_name.c_str()); + ESPBTDevice device = device_from(adv); + EXPECT_EQ(device.get_name().size(), 29u); + EXPECT_EQ(device.get_name(), max_name); + EXPECT_STREQ(device.get_name().c_str(), max_name.c_str()); +} + +TEST(BleAdvName, ReparseResetsThePreviousName) { + const uint8_t mac[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66}; + std::vector first; + append_name(first, AD_COMPLETE_NAME, "RadonEye"); + std::vector second; + append_name(second, AD_COMPLETE_NAME, "TP96"); + + ESPBTDevice device; + device.from_scan_result(mac, -59, 0, first.data(), static_cast(first.size())); + ASSERT_EQ(device.get_name(), "RadonEye"); + // A shorter name from a fresh report must fully replace the longer one: + // the longest-name rule applies within one report, not across reports. + device.from_scan_result(mac, -59, 0, second.data(), static_cast(second.size())); + EXPECT_EQ(device.get_name(), "TP96"); + EXPECT_STREQ(device.get_name().c_str(), "TP96"); +} + +TEST(BleAdvName, NoNamePresentIsEmpty) { + std::vector adv = {0x02, 0x0A, 0x00}; // TX power only + EXPECT_TRUE(device_from(adv).get_name().empty()); +} + +} // namespace esphome::ble_device_base diff --git a/tests/components/ble_device_base/test_gatt_client_contract.cpp b/tests/components/ble_device_base/test_gatt_client_contract.cpp new file mode 100644 index 00000000000..fb743b26994 --- /dev/null +++ b/tests/components/ble_device_base/test_gatt_client_contract.cpp @@ -0,0 +1,66 @@ +// The GATT client contract compiles in no real build until a hub backend is +// configured; this TU pins it on the host so the header cannot rot unseen. +#define USE_BLE_GATT_CLIENT + +#include "esphome/components/ble_device_base/ble_gatt_client.h" + +#include + +namespace esphome::ble_device_base::testing { + +class RecordingListener : public GattClientEventListener { + public: + void on_connection_state(bool connected, uint16_t mtu, int error) override { this->connected_ = connected; } + void on_service_discovery_done(int error) override { this->discovery_error_ = error; } + 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_state(uint16_t handle, bool enabled, int error) override {} + void on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) override {} + bool connected_{false}; + int discovery_error_{0}; +}; + +class MinimalConnection : public BLEGattConnection { + public: + int connect(uint64_t address, uint8_t addr_type) override { + if (this->listener_ != nullptr) + this->listener_->on_connection_state(true, 517, 0); + return 0; + } + int disconnect() override { return 0; } + int discover_services() override { + if (this->listener_ != nullptr) + this->listener_->on_service_discovery_done(0); + return 0; + } + int read_characteristic(uint16_t handle) override { return GATT_ERR_NOT_CONNECTED; } + int write_characteristic(uint16_t handle, const uint8_t *data, uint16_t len, bool response) override { return 0; } + int read_descriptor(uint16_t handle) override { return 0; } + int write_descriptor(uint16_t handle, const uint8_t *data, uint16_t len) override { return 0; } + int notify_characteristic(uint16_t handle, bool enable) override { return 0; } + int update_connection_params(uint16_t min_interval, uint16_t max_interval, uint16_t latency, + uint16_t timeout) override { + return 0; + } + GattServiceTable get_service_table() override { return {}; } + void release_services() override {} +}; + +TEST(BleGattClientContract, MinimalImplementerCompilesAndRoutesEvents) { + MinimalConnection connection; + RecordingListener listener; + connection.set_listener(&listener); + EXPECT_EQ(connection.connect(0xAABBCCDDEEFFULL, 0), 0); + EXPECT_TRUE(listener.connected_); + EXPECT_EQ(connection.discover_services(), 0); + EXPECT_EQ(listener.discovery_error_, 0); + EXPECT_EQ(connection.read_characteristic(1), GATT_ERR_NOT_CONNECTED); + + // A default table is empty and safe to walk. + GattServiceTable table = connection.get_service_table(); + EXPECT_EQ(table.service_count, 0); + EXPECT_EQ(table.characteristic_count, 0); + EXPECT_EQ(table.descriptor_count, 0); +} + +} // namespace esphome::ble_device_base::testing