Chapter 59: BLE GATT Client Implementation

Chapter Objectives

By the end of this chapter, you will be able to:

  • Understand the role and responsibilities of a GATT Client in BLE.
  • Implement BLE scanning to discover nearby peripheral devices using ESP-IDF.
  • Establish a connection to a target BLE peripheral.
  • Perform service discovery to find services offered by a connected peripheral.
  • Discover characteristics and descriptors within a specific service.
  • Read values from and write values to characteristics and descriptors.
  • Enable and handle notifications and indications from a GATT server.
  • Manage attribute handles obtained during the discovery process.
  • Use ESP-IDF GATT Client APIs (esp_gattc_api.h) effectively.

Introduction

In the preceding chapters, we’ve explored the architecture of BLE and learned how to create a GATT Server, allowing an ESP32 to offer data and services. Now, we turn our attention to the other crucial role in BLE communication: the GATT Client. A GATT Client is a device that discovers, connects to, and consumes the data and services offered by a GATT Server.

Many ESP32 applications require interaction with external BLE peripherals – reading data from sensors (like heart rate monitors, temperature sensors), controlling actuators (like smart locks, lights), or exchanging information with other BLE-enabled devices. To achieve this, the ESP32 must act as a GATT Client.

This chapter will provide a comprehensive guide to implementing GATT client functionality on an ESP32 using ESP-IDF v5.x. We will cover the entire lifecycle: scanning for devices, establishing connections, discovering the server’s capabilities (services, characteristics, descriptors), and interacting with those attributes by reading, writing, and receiving notifications.

Theory

Recall that a BLE device acting as a Central typically takes on the role of a GATT Client. The GATT Client initiates communication with a Peripheral (GATT Server) and requests operations on the server’s attributes.

GATT Client Operational Flow

The typical sequence of operations for a GATT Client is as follows:

  1. Initialization: Initialize the Bluetooth controller and host stack (Bluedroid). Register GAP and GATTC event handlers and application profiles.
  2. Scanning (GAP operation):
    • Configure scan parameters (scan interval, window, scan type – passive/active).
    • Start scanning for advertising peripherals.
    • Process scan results received in the GAP callback (ESP_GAP_BLE_SCAN_RESULT_EVT). This includes device address, advertising data (name, service UUIDs), and RSSI.
    • Select a target peripheral based on its advertising data.
  3. Connecting (GAP operation):
    • Stop scanning (usually).
    • Initiate a connection to the target peripheral using esp_ble_gattc_open(). This function takes the remote device address and address type.
    • Await connection confirmation via ESP_GATTC_OPEN_EVT or ESP_GATTC_CONNECT_EVT in the GATTC callback.
  4. MTU Exchange (Optional but Recommended):
    • After connection, the client can request a larger ATT MTU (Maximum Transmission Unit) using esp_ble_gattc_send_mtu_req(). The default is 23 bytes. A larger MTU allows more data per packet.
    • The server responds, and the negotiated MTU is reported via ESP_GATTC_CFG_MTU_EVT.
  5. Service Discovery:
    • Once connected, the client needs to discover what services the server offers.
    • Use esp_ble_gattc_search_service() to discover all primary services or a specific primary service by UUID.
    • Service discovery results are received via ESP_GATTC_SEARCH_RES_EVT (one event per discovered service, providing service UUID, start handle, and end handle).
    • ESP_GATTC_SEARCH_CMPL_EVT indicates the completion of the service search.
  6. Characteristic Discovery:
    • For each interesting service discovered, the client discovers its characteristics.
    • Use esp_ble_gattc_get_all_char() or esp_ble_gattc_get_char_by_uuid() within the service’s handle range.
    • Characteristic information (UUID, properties, value handle) is received via ESP_GATTC_GET_CHAR_EVT. Multiple events may arrive if multiple characteristics are found.
  7. Descriptor Discovery:
    • For each interesting characteristic, the client may discover its descriptors (e.g., CCCD, User Description).
    • Use esp_ble_gattc_get_all_descr() or esp_ble_gattc_get_descr_by_uuid() within the characteristic’s handle range (typically from its value handle to the service end handle or next characteristic handle).
    • Descriptor information (UUID, handle) is received via ESP_GATTC_GET_DESCR_EVT.
  8. Interacting with Characteristics/Descriptors:
    • Reading:
      • Use esp_ble_gattc_read_char() or esp_ble_gattc_read_char_descr() specifying the attribute handle.
      • The read value is received in ESP_GATTC_READ_CHAR_EVT or ESP_GATTC_READ_DESCR_EVT.
    • Writing:
      • Use esp_ble_gattc_write_char() or esp_ble_gattc_write_char_descr(). Specify the handle, value, length, and write type (with/without response).
      • Confirmation for writes with response comes via ESP_GATTC_WRITE_CHAR_EVT or ESP_GATTC_WRITE_DESCR_EVT.
    • Enabling Notifications/Indications:
      • Discover the CCCD handle for the target characteristic.
      • Write 0x0001 (for notifications) or 0x0002 (for indications) to the CCCD handle using esp_ble_gattc_write_char_descr().
      • Register interest in receiving these updates using esp_ble_gattc_register_for_notify() with the characteristic’s value handle.
    • Handling Notifications/Indications:
      • Incoming notifications/indications are received via ESP_GATTC_NOTIFY_EVT. The event parameters include the characteristic handle, value, and length.
  9. Disconnecting:
    • Use esp_ble_gattc_close() to terminate the connection.
    • Confirmation via ESP_GATTC_CLOSE_EVT.
%%{ init: { 'theme': 'base', 'themeVariables': { 'fontFamily': 'Open Sans' } } }%%
graph TD
    A["Start: Init BLE Stack & Register Callbacks<br><span class='func-small'>esp_bluedroid_init(), esp_ble_gap_register_callback(),<br>esp_ble_gattc_register_callback(), esp_ble_gattc_app_register()</span>"] --> B(Scanning for Peripherals);
    B -- Found Target Device --> C(Stop Scan & Initiate Connection);
    C -- <span class='func-small'>esp_ble_gattc_open(target_bda)</span> --> D{Connection Event?<br><span class='event-small'>ESP_GATTC_OPEN_EVT</span>};
    D -- Success --> E(Optional: MTU Exchange);
    D -- Failure --> B;
    E -- <span class='func-small'>esp_ble_gattc_send_mtu_req()</span> --> F{MTU Configured?<br><span class='event-small'>ESP_GATTC_CFG_MTU_EVT</span>};
    F --> G(Discover Services);
    G -- <span class='func-small'>esp_ble_gattc_search_service()</span> --> H{Services Found?<br><span class='event-small'>ESP_GATTC_SEARCH_RES_EVT</span><br><span class='event-small'>ESP_GATTC_SEARCH_CMPL_EVT</span>};
    H -- Yes --> I(For Each Service: Discover Characteristics);
    I -- <span class='func-small'>esp_ble_gattc_get_all_char()</span> --> J{Characteristics Found?<br><span class='event-small'>ESP_GATTC_GET_CHAR_EVT</span>};
    J -- Yes --> K(For Each Char: Discover Descriptors);
    K -- <span class='func-small'>esp_ble_gattc_get_all_descr()</span> --> L{Descriptors Found?<br><span class='event-small'>ESP_GATTC_GET_DESCR_EVT</span>};
    L -- Yes / No more Descriptors --> M(Store All Handles);
    M --> N{Interact with Attributes};
    N -- Read --> O_READ["Read Char/Descr<br><span class='func-small'>esp_ble_gattc_read_char()</span><br>Event: <span class='event-small'>ESP_GATTC_READ_CHAR_EVT</span>"];
    N -- Write --> O_WRITE["Write Char/Descr<br><span class='func-small'>esp_ble_gattc_write_char()</span><br>Event: <span class='event-small'>ESP_GATTC_WRITE_CHAR_EVT</span>"];
    N -- Notify/Indicate --> O_NOTIFY["Enable Notifications/Indications<br>(Write CCCD, <span class='func-small'>register_for_notify()</span>)<br>Event: <span class='event-small'>ESP_GATTC_NOTIFY_EVT</span>"];
    O_READ --> N;
    O_WRITE --> N;
    O_NOTIFY --> N;
    N -- Done Interacting / Error --> P(Disconnect);
    P -- <span class='func-small'>esp_ble_gattc_close()</span> --> Q{Disconnected?<br><span class='event-small'>ESP_GATTC_CLOSE_EVT</span>};
    Q --> R[End / Cleanup];
    H -- No / Error --> P;
    J -- No / Error --> P;
    K -- No / Error --> P;

    classDef func-small fill:#fff,stroke:#333,color:#333,fontSize:10px;
    classDef event-small fill:#fff,stroke:#333,color:#333,fontSize:10px,fontStyle:italic;

    classDef startNode fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px,color:#5B21B6;
    classDef processNode fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF;
    classDef decisionNode fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E;
    classDef ioNode fill:#D1FAE5,stroke:#059669,stroke-width:1px,color:#065F46;
    classDef endNode fill:#FEE2E2,stroke:#DC2626,stroke-width:1px,color:#991B1B;

    class A,R startNode;
    class B,C,E,G,I,K,M,P processNode;
    class D,F,H,J,L,N,Q decisionNode;
    class O_READ,O_WRITE,O_NOTIFY ioNode;

BLE Device Scanning Process:

%%{ init: { 'theme': 'base', 'themeVariables': { 'fontFamily': 'Open Sans' } } }%%
graph TD
    A[Start: Init BLE Stack & Register GAP Callback] --> B(Set Scan Parameters);
    B -- <span class='func-small'>esp_ble_gap_set_scan_params()</span> --> C{Scan Params Set?<br><span class='event-small'>ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT</span>};
    C -- Success --> D(Start Scanning);
    D -- <span class='func-small'>esp_ble_gap_start_scanning(duration)</span> --> E{Scan Started?<br><span class='event-small'>ESP_GAP_BLE_SCAN_START_COMPLETE_EVT</span>};
    E -- Success --> F{Scanning Loop...};
    F -- Advertising Packet Received --> G{Scan Result Event<br><span class='event-small'>ESP_GAP_BLE_SCAN_RESULT_EVT</span>};
    G --> H(Process Scan Result: <br>BDA, RSSI, Adv Data, Name?);
    H --> I{Target Device Found?};
    I -- Yes --> J(Stop Scanning);
    I -- No --> F;
    F -- Scan Duration Elapsed / Timeout --> J;
    J -- <span class='func-small'>esp_ble_gap_stop_scanning()</span> --> K{Scan Stopped?<br><span class='event-small'>ESP_GAP_BLE_SCAN_STOP_COMPLETE_EVT</span>};
    K -- Success --> L[End Scan Phase / Proceed to Connect];
    C -- Failure --> M[Error Handling];
    E -- Failure --> M;
    K -- Failure --> M;

    classDef func-small fill:#fff,stroke:#333,color:#333,fontSize:10px;
    classDef event-small fill:#fff,stroke:#333,color:#333,fontSize:10px,fontStyle:italic;

    classDef startNode fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px,color:#5B21B6;
    classDef processNode fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF;
    classDef decisionNode fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E;
    classDef endNode fill:#D1FAE5,stroke:#059669,stroke-width:2px,color:#065F46;
    classDef errorNode fill:#FEE2E2,stroke:#DC2626,stroke-width:1px,color:#991B1B;
    
    class A startNode;
    class B,D,F,H,J processNode;
    class C,E,G,I,K decisionNode;
    class L endNode;
    class M errorNode;

BLE Connection Process:

%%{ init: { 'theme': 'base', 'themeVariables': { 'fontFamily': 'Open Sans' } } }%%
graph TD
    A["Start: Target BDA Known, <br>GATTC App Registered (<span class='code-small'>client_gattc_if</span> valid)"] --> B(Initiate Connection);
    B -- <span class='func-small'>esp_ble_gattc_open(client_gattc_if, remote_bda, ...)</span> --> C{GATT Connection Open Event?<br><span class='event-small'>ESP_GATTC_OPEN_EVT</span>};
    C -- Status OK --> D(Connection Successful!);
    D --> E["Store: <span class='code-small'>conn_id</span>, <span class='code-small'>remote_bda</span>, <span class='code-small'>gattc_if</span><br>Server MTU: <span class='code-small'>param->open.mtu</span>"];
    E --> F{Optional: MTU Exchange?};
    F -- Yes --> G["Send MTU Request<br><span class='func-small'>esp_ble_gattc_send_mtu_req(conn_id)</span>"];
    G --> H{MTU Configured?<br><span class='event-small'>ESP_GATTC_CFG_MTU_EVT</span>};
    H -- Status OK --> I["Store Negotiated MTU<br><span class='code-small'>param->cfg_mtu.mtu</span>"];
    F -- No --> J[Proceed to Service Discovery];
    I --> J;
    C -- Status Not OK (Failure) --> K["Handle Connection Error<br>(e.g., Log, Retry, Rescan)"];
    H -- Status Not OK --> L[MTU Exchange Failed, Use Server MTU];
    L --> J;

    classDef code-small fill:#fff,stroke:#333,color:#333,fontSize:10px;
    classDef func-small fill:#fff,stroke:#333,color:#333,fontSize:10px;
    classDef event-small fill:#fff,stroke:#333,color:#333,fontSize:10px,fontStyle:italic;

    classDef startNode fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px,color:#5B21B6;
    classDef processNode fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF;
    classDef decisionNode fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E;
    classDef successNode fill:#D1FAE5,stroke:#059669,stroke-width:1px,color:#065F46;
    classDef errorNode fill:#FEE2E2,stroke:#DC2626,stroke-width:1px,color:#991B1B;

    class A startNode;
    class B,E,G,I,L processNode;
    class C,F,H decisionNode;
    class D,J successNode;
    class K errorNode;

ESP-IDF GATT Client APIs (esp_gattc_api.h)

The ESP-IDF provides a comprehensive set of APIs for GATT client operations. These primarily reside in esp_gattc_api.h.

  • Callback Registration:
    • esp_ble_gattc_register_callback(esp_gattc_cb_t callback): Registers a global callback function to handle all GATTC events. This is the central point for asynchronous event processing.
  • Application Profile Registration:
    • esp_ble_gattc_app_register(uint16_t app_id): Registers a client application profile. Each profile gets a unique gattc_if (GATT client interface ID) delivered in ESP_GATTC_REG_EVT. Multiple client profiles can exist.
  • Connection Management:
    • esp_ble_gattc_open(esp_gatt_if_t gattc_if, esp_bd_addr_t remote_bda, esp_ble_addr_type_t remote_addr_type, bool is_direct): Initiates a connection.
    • esp_ble_gattc_close(esp_gatt_if_t gattc_if, uint16_t conn_id): Closes a connection.
  • MTU Configuration:
    • esp_ble_gatt_set_local_mtu(uint16_t mtu): Sets the client’s desired MTU size before connection.
    • esp_ble_gattc_send_mtu_req(esp_gatt_if_t gattc_if, uint16_t conn_id): Sends an MTU exchange request after connection. (Often, setting local MTU is sufficient, and the exchange happens automatically).
  • Discovery Functions:
    • esp_ble_gattc_search_service(esp_gatt_if_t gattc_if, uint16_t conn_id, esp_bt_uuid_t *filter_srvc_uuid): Searches for services. filter_srvc_uuid can be NULL to find all primary services.
    • esp_ble_gattc_get_all_char(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t start_handle, uint16_t end_handle): Gets all characteristics within a service’s handle range.
    • esp_ble_gattc_get_char_by_uuid(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t start_handle, uint16_t end_handle, esp_bt_uuid_t char_uuid, esp_gattc_char_filter_t *filter): Gets characteristics matching a UUID.
    • esp_ble_gattc_get_all_descr(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t char_handle, uint16_t end_handle): Gets all descriptors for a characteristic. char_handle here is the characteristic value handle.
    • esp_ble_gattc_get_descr_by_uuid(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t char_handle, esp_bt_uuid_t descr_uuid): Gets a descriptor by UUID.
  • Attribute Read/Write:
    • esp_ble_gattc_read_char(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t handle, esp_gatt_auth_req_t auth_req): Reads a characteristic value.
    • esp_ble_gattc_read_char_descr(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t handle, esp_gatt_auth_req_t auth_req): Reads a descriptor value.
    • esp_ble_gattc_write_char(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t handle, uint16_t value_len, uint8_t *value, esp_gatt_write_type_t write_type, esp_gatt_auth_req_t auth_req): Writes to a characteristic.
    • esp_ble_gattc_write_char_descr(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t handle, uint16_t value_len, uint8_t *value, esp_gatt_write_type_t write_type, esp_gatt_auth_req_t auth_req): Writes to a descriptor.
  • Notifications/Indications:
    • esp_ble_gattc_register_for_notify(esp_gatt_if_t gattc_if, esp_bd_addr_t server_bda, uint16_t handle): Registers to receive notifications/indications for a characteristic handle.
    • esp_ble_gattc_unregister_for_notify(esp_gatt_if_t gattc_if, esp_bd_addr_t server_bda, uint16_t handle): Unregisters.
API Function Purpose Key Triggered/Related Event(s) in GATTC Callback
esp_ble_gattc_register_callback(cb) Registers the master GATTC callback function. (N/A – This sets up the handler for all other events)
esp_ble_gattc_app_register(app_id) Registers a GATT client application profile. ESP_GATTC_REG_EVT
esp_ble_gattc_open(if, bda, type, direct) Initiates a connection to a remote peripheral. ESP_GATTC_OPEN_EVT
esp_ble_gattc_close(if, conn_id) Closes an existing connection. ESP_GATTC_CLOSE_EVT
esp_ble_gattc_send_mtu_req(if, conn_id) Requests an ATT MTU exchange with the server. ESP_GATTC_CFG_MTU_EVT
esp_ble_gattc_search_service(if, conn_id, filter_uuid) Discovers services on the connected server. ESP_GATTC_SEARCH_RES_EVT (per service), ESP_GATTC_SEARCH_CMPL_EVT (completion)
esp_ble_gattc_get_all_char(if, conn_id, start_h, end_h) Discovers all characteristics within a service’s handle range. ESP_GATTC_GET_CHAR_EVT (per characteristic/completion)
esp_ble_gattc_get_char_by_uuid(…) Discovers characteristics matching a specific UUID. ESP_GATTC_GET_CHAR_EVT
esp_ble_gattc_get_all_descr(if, conn_id, char_h, end_h) Discovers all descriptors for a characteristic. ESP_GATTC_GET_DESCR_EVT (per descriptor/completion)
esp_ble_gattc_read_char(if, conn_id, handle, auth) Reads a characteristic’s value. ESP_GATTC_READ_CHAR_EVT
esp_ble_gattc_read_char_descr(…) Reads a descriptor’s value. ESP_GATTC_READ_DESCR_EVT
esp_ble_gattc_write_char(if, conn_id, handle, len, val, type, auth) Writes to a characteristic’s value. ESP_GATTC_WRITE_CHAR_EVT (for write with response)
esp_ble_gattc_write_char_descr(…) Writes to a descriptor’s value (e.g., CCCD). ESP_GATTC_WRITE_DESCR_EVT (for write with response)
esp_ble_gattc_register_for_notify(if, bda, handle) Registers to receive notifications/indications for a characteristic. ESP_GATTC_REG_FOR_NOTIFY_EVT, then ESP_GATTC_NOTIFY_EVT for incoming data.
esp_ble_gattc_unregister_for_notify(…) Unregisters from receiving notifications/indications. ESP_GATTC_UNREG_FOR_NOTIFY_EVT

Managing Discovered Attribute Handles

A crucial part of GATT client implementation is storing the attribute handles (service start/end handles, characteristic value handles, descriptor handles) discovered from the server. These handles are needed for subsequent read, write, and notification operations. You’ll typically use a data structure (an array of structs, a linked list, etc.) to store this information, often associating it with the connection ID (conn_id) and GATT interface (gattc_if).

GATTC Event Handling

The GATTC callback function is central to the client’s operation. It must handle a variety of events:

Event Name (in esp_gattc_cb_event_t) Description & Significance Key Parameters in esp_ble_gattc_cb_param_t union
ESP_GATTC_REG_EVT Client application profile registered. Store gattc_if. reg.status, reg.app_id, reg.gattc_if
ESP_GATTC_OPEN_EVT Connection attempt completed (success or failure). If success, store conn_id, remote_bda. Initiate MTU exchange or service discovery. open.status, open.conn_id, open.gattc_if, open.remote_bda, open.mtu (initial MTU)
ESP_GATTC_CONNECT_EVT Physical link established (often precedes ESP_GATTC_OPEN_EVT for GATT-level connection). connect.conn_id, connect.gattc_if, connect.remote_bda
ESP_GATTC_DISCONNECT_EVT Physical link disconnected. disconnect.conn_id, disconnect.gattc_if, disconnect.remote_bda, disconnect.reason
ESP_GATTC_CLOSE_EVT GATT connection closed. Clean up resources. close.status, close.conn_id, close.remote_bda
ESP_GATTC_CFG_MTU_EVT MTU exchange procedure completed. Store negotiated MTU. cfg_mtu.status, cfg_mtu.conn_id, cfg_mtu.mtu
ESP_GATTC_SEARCH_RES_EVT A service is found during discovery. Store service UUID, start/end handles. search_res.conn_id, search_res.srvc_id, search_res.start_handle, search_res.end_handle
ESP_GATTC_SEARCH_CMPL_EVT Service discovery process is complete. Initiate characteristic discovery. search_cmpl.status, search_cmpl.conn_id
ESP_GATTC_GET_CHAR_EVT A characteristic is found (or discovery for a service completes). Store char UUID, value handle, properties. Initiate descriptor discovery. get_char.status, get_char.conn_id, get_char.srvc_id, get_char.char_id, get_char.char_handle, get_char.char_prop
ESP_GATTC_GET_DESCR_EVT A descriptor is found (or discovery for a char completes). Store descr UUID and handle. get_descr.status, get_descr.conn_id, get_descr.srvc_id, get_descr.char_id, get_descr.descr_id, get_descr.handle
ESP_GATTC_READ_CHAR_EVT Result of a characteristic read operation. Process received value. read.status, read.conn_id, read.handle, read.value, read.value_len
ESP_GATTC_READ_DESCR_EVT Result of a descriptor read operation. read.status, read.conn_id, read.handle (descriptor handle), read.value, read.value_len
ESP_GATTC_WRITE_CHAR_EVT Confirmation of a characteristic write operation (if write with response). write.status, write.conn_id, write.handle
ESP_GATTC_WRITE_DESCR_EVT Confirmation of a descriptor write operation (e.g., to CCCD). write.status, write.conn_id, write.handle
ESP_GATTC_NOTIFY_EVT A notification or indication is received from the server. Process the value. notify.conn_id, notify.remote_bda, notify.handle (char value handle), notify.value_len, notify.value, notify.is_notify (true for notify, false for indicate)
ESP_GATTC_REG_FOR_NOTIFY_EVT Confirmation that registration for notifications was successful. reg_for_notify.status, reg_for_notify.handle

A state machine is often useful within the client application to manage the sequential nature of discovery and interaction.

Practical Examples

Prerequisites:

  • An ESP32 board (ESP32, ESP32-C3, ESP32-S3, ESP32-C6, ESP32-H2. Not ESP32-S2).
  • VS Code with the Espressif IDF Extension.
  • A BLE peripheral device to connect to. This could be:
    • Another ESP32 running the GATT server code from Chapter 57.
    • A standard BLE device (e.g., a fitness tracker, heart rate monitor).
    • A smartphone app acting as a BLE peripheral (e.g., “nRF Connect for Mobile” can simulate peripherals).

Example 1: Scanning for BLE Peripherals

This example demonstrates how to initialize BLE and scan for nearby advertising devices.

1. Project Setup:

  • Create a new ESP-IDF project.
  • Run idf.py menuconfig:
    • Component config -> Bluetooth -> Enable [*] Bluetooth.
    • Component config -> Bluetooth -> Bluetooth Host -> Select Bluedroid.
    • Component config -> Bluetooth -> Bluetooth controller -> Controller Mode -> BLE Only.
    • Save and exit.

2. Code (main/gatt_client_main.c):

C
#include <stdio.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_system.h"
#include "esp_log.h"
#include "nvs_flash.h"
#include "esp_bt.h"
#include "esp_gap_ble_api.h"
#include "esp_gattc_api.h"
#include "esp_bt_main.h"
#include "esp_gatt_common_api.h"

static const char* GATTC_TAG = "GATT_CLIENT_SCAN_EXAMPLE";

// Scan parameters
static esp_ble_scan_params_t ble_scan_params = {
    .scan_type              = BLE_SCAN_TYPE_ACTIVE, // Active scan
    .own_addr_type          = BLE_ADDR_TYPE_PUBLIC,
    .scan_filter_policy     = BLE_SCAN_FILTER_ALLOW_ALL,
    .scan_interval          = 0x50, // N * 0.625ms
    .scan_window            = 0x30, // N * 0.625ms
    .scan_duplicate         = BLE_SCAN_DUPLICATE_DISABLE // Process all advertising packets
};

// GAP event handler
static void esp_gap_cb(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
    uint8_t *adv_name = NULL;
    uint8_t adv_name_len = 0;
    switch (event) {
    case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT: {
        ESP_LOGI(GATTC_TAG, "Scan parameters set, starting scan...");
        // The duration of the scan, 0 for continuous scan
        uint32_t duration = 30; // Scan for 30 seconds
        esp_ble_gap_start_scanning(duration);
        break;
    }
    case ESP_GAP_BLE_SCAN_START_COMPLETE_EVT:
        if (param->scan_start_cmpl.status != ESP_BT_STATUS_SUCCESS) {
            ESP_LOGE(GATTC_TAG, "Scan start failed, error status = %x", param->scan_start_cmpl.status);
        } else {
            ESP_LOGI(GATTC_TAG, "Scan started successfully");
        }
        break;
    case ESP_GAP_BLE_SCAN_RESULT_EVT: {
        esp_ble_gap_cb_param_t *scan_result = (esp_ble_gap_cb_param_t *)param;
        switch (scan_result->scan_rst.search_evt) {
        case ESP_GAP_SEARCH_INQ_RES_EVT: // Inquiry result for advertisements
            ESP_LOGI(GATTC_TAG, "Device found: Addr %02x:%02x:%02x:%02x:%02x:%02x, RSSI: %d",
                     scan_result->scan_rst.bda[0], scan_result->scan_rst.bda[1],
                     scan_result->scan_rst.bda[2], scan_result->scan_rst.bda[3],
                     scan_result->scan_rst.bda[4], scan_result->scan_rst.bda[5],
                     scan_result->scan_rst.rssi);

            // Try to get device name from advertising data
            adv_name = esp_ble_resolve_adv_data(scan_result->scan_rst.ble_adv,
                                                ESP_BLE_AD_TYPE_NAME_CMPL, &adv_name_len);
            if (adv_name != NULL) {
                ESP_LOGI(GATTC_TAG, "Device Name: %.*s", adv_name_len, adv_name);
            } else {
                 adv_name = esp_ble_resolve_adv_data(scan_result->scan_rst.ble_adv,
                                                ESP_BLE_AD_TYPE_NAME_SHORT, &adv_name_len);
                if (adv_name != NULL) {
                    ESP_LOGI(GATTC_TAG, "Short Device Name: %.*s", adv_name_len, adv_name);
                } else {
                    ESP_LOGI(GATTC_TAG, "No device name found in advertisement");
                }
            }
            // Here you could decide to connect to this device based on its address or name
            // For example: if (memcmp(scan_result->scan_rst.bda, target_bda, ESP_BD_ADDR_LEN) == 0) { esp_ble_gattc_open(...); }
            break;
        case ESP_GAP_SEARCH_INQ_CMPL_EVT: // Inquiry complete
            ESP_LOGI(GATTC_TAG, "Scan completed");
            // You might restart scanning or proceed with a connection if a target was found
            break;
        default:
            break;
        }
        break;
    }
    case ESP_GAP_BLE_SCAN_STOP_COMPLETE_EVT:
        if (param->scan_stop_cmpl.status != ESP_BT_STATUS_SUCCESS){
            ESP_LOGE(GATTC_TAG, "Scan stop failed, error status = %x", param->scan_stop_cmpl.status);
        } else {
            ESP_LOGI(GATTC_TAG, "Scan stopped successfully");
        }
        break;
    default:
        break;
    }
}

// GATTC event handler (minimal for this example)
static void esp_gattc_cb(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) {
    ESP_LOGI(GATTC_TAG, "GATTC EVT %d, gattc_if %d", event, gattc_if);
    // Further GATTC event handling will be added in subsequent examples
}


void app_main(void) {
    esp_err_t ret;

    ret = nvs_flash_init();
    if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
        ESP_ERROR_CHECK(nvs_flash_erase());
        ret = nvs_flash_init();
    }
    ESP_ERROR_CHECK(ret);

    ESP_ERROR_CHECK(esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT));

    esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT();
    ret = esp_bt_controller_init(&bt_cfg);
    if (ret) {
        ESP_LOGE(GATTC_TAG, "%s initialize controller failed: %s", __func__, esp_err_to_name(ret));
        return;
    }
    ret = esp_bt_controller_enable(ESP_BT_MODE_BLE);
    if (ret) {
        ESP_LOGE(GATTC_TAG, "%s enable controller failed: %s", __func__, esp_err_to_name(ret));
        return;
    }
    ret = esp_bluedroid_init();
    if (ret) {
        ESP_LOGE(GATTC_TAG, "%s init bluetooth failed: %s", __func__, esp_err_to_name(ret));
        return;
    }
    ret = esp_bluedroid_enable();
    if (ret) {
        ESP_LOGE(GATTC_TAG, "%s enable bluetooth failed: %s", __func__, esp_err_to_name(ret));
        return;
    }

    // Register GAP callback
    ret = esp_ble_gap_register_callback(esp_gap_cb);
    if (ret) {
        ESP_LOGE(GATTC_TAG, "gap register error, error code = %x", ret);
        return;
    }
    // Register GATTC callback
    ret = esp_ble_gattc_register_callback(esp_gattc_cb);
    if (ret) {
        ESP_LOGE(GATTC_TAG, "gattc register error, error code = %x", ret);
        return;
    }
    // Register GATTC application profile (app_id can be any value)
    ret = esp_ble_gattc_app_register(0);
     if (ret) {
        ESP_LOGE(GATTC_TAG, "gattc app register error, error code = %x", ret);
        return;
    }
    
    // Set scan parameters
    ret = esp_ble_gap_set_scan_params(&ble_scan_params);
    if (ret) {
        ESP_LOGE(GATTC_TAG, "set scan params error, error code = %x", ret);
    }
    ESP_LOGI(GATTC_TAG, "GATT Client Scan Example Initialized");
}

3. Build, Flash, and Monitor:

  • Connect your ESP32 board.
  • Run idf.py build.
  • Run idf.py flash monitor.

4. Observe Output:

  • The serial monitor will show logs from GATTC_TAG.
  • After “Scan parameters set, starting scan…”, you should see “Device found…” messages for nearby BLE peripherals, along with their MAC addresses, RSSI, and names (if available in the advertisement).
  • The scan will run for 30 seconds (as set by duration) and then stop.

Example 2: Connecting and Discovering Services

This example builds upon Example 1 to connect to a specific device (you’ll need to hardcode its MAC address or modify the logic to select one from scan results) and then discover its services.

Conceptual Additions to esp_gattc_cb for Example 2 & 3:

C
// ... (previous includes and definitions) ...

// Store remote BDA of the device to connect to
static esp_bd_addr_t remote_bda_to_connect = {0xXX, 0xXX, 0xXX, 0xXX, 0xXX, 0xXX}; // TODO: Replace with actual MAC
static bool connect_to_device = false; // Set to true when target device is found

// GATTC Profile ID and GATTC Interface
static uint16_t gattc_profile_app_id = 0; // Matches app_register
static esp_gatt_if_t client_gattc_if = ESP_GATT_IF_NONE;
static uint16_t current_conn_id = 0xFFFF;

// State for discovery
typedef enum {
    CLIENT_IDLE,
    CLIENT_CONNECTING,
    CLIENT_CONNECTED,
    CLIENT_DISCOVERING_SERVICES,
    CLIENT_DISCOVERING_CHARS,
    CLIENT_DISCOVERING_DESCRS,
    CLIENT_READY_TO_INTERACT
} client_state_t;
static client_state_t g_client_state = CLIENT_IDLE;

// Placeholder for storing discovered service info
#define MAX_SERVICES 10
typedef struct {
    esp_bt_uuid_t uuid;
    uint16_t start_handle;
    uint16_t end_handle;
    // You'd add storage for characteristics and descriptors here too
} discovered_service_t;
static discovered_service_t g_services[MAX_SERVICES];
static uint8_t g_service_count = 0;
static uint8_t g_current_service_idx_for_char_discovery = 0;


// Modified GAP callback to initiate connection
static void esp_gap_cb(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) {
    // ... (scan result handling from Example 1) ...
    case ESP_GAP_BLE_SCAN_RESULT_EVT: {
        esp_ble_gap_cb_param_t *scan_result = (esp_ble_gap_cb_param_t *)param;
        if (scan_result->scan_rst.search_evt == ESP_GAP_SEARCH_INQ_RES_EVT) {
            ESP_LOGI(GATTC_TAG, "Device found: Addr %02x:%02x:%02x:%02x:%02x:%02x", /* ... */);
            // Example: Connect if MAC address matches
            if (memcmp(scan_result->scan_rst.bda, remote_bda_to_connect, ESP_BD_ADDR_LEN) == 0) {
                ESP_LOGI(GATTC_TAG, "Target device found. Stopping scan and attempting to connect.");
                esp_ble_gap_stop_scanning();
                connect_to_device = true; // Flag to connect after scan stops
                // Actual connect call will be in ESP_GAP_BLE_SCAN_STOP_COMPLETE_EVT
                // or directly here if you ensure gattc_if is valid
            }
        }
        break;
    }
     case ESP_GAP_BLE_SCAN_STOP_COMPLETE_EVT:
        // ...
        if (connect_to_device && client_gattc_if != ESP_GATT_IF_NONE) {
            ESP_LOGI(GATTC_TAG, "Connecting to remote device...");
            esp_ble_gattc_open(client_gattc_if, remote_bda_to_connect, BLE_ADDR_TYPE_PUBLIC, true);
            g_client_state = CLIENT_CONNECTING;
            connect_to_device = false; // Reset flag
        }
        break;
    // ...
}


// Modified GATTC callback for connection and discovery
static void esp_gattc_cb(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) {
    ESP_LOGI(GATTC_TAG, "GATTC EVT %d, gattc_if %d, status %d", event, gattc_if, param->reg.status);

    switch (event) {
    case ESP_GATTC_REG_EVT:
        if (param->reg.status == ESP_GATT_OK) {
            ESP_LOGI(GATTC_TAG, "GATTC app registered successfully, app_id %d, gattc_if %d", param->reg.app_id, gattc_if);
            client_gattc_if = gattc_if; // Store the interface
        } else {
            ESP_LOGE(GATTC_TAG, "GATTC app registration failed");
        }
        break;

    case ESP_GATTC_OPEN_EVT:
        if (param->open.status == ESP_GATT_OK) {
            ESP_LOGI(GATTC_TAG, "Connected successfully to %02x:%02x:%02x:%02x:%02x:%02x, conn_id %d",
                     param->open.remote_bda[0], param->open.remote_bda[1], param->open.remote_bda[2],
                     param->open.remote_bda[3], param->open.remote_bda[4], param->open.remote_bda[5],
                     param->open.conn_id);
            current_conn_id = param->open.conn_id;
            g_client_state = CLIENT_CONNECTED;
            // Optional: Configure MTU
            // esp_ble_gattc_send_mtu_req(gattc_if, param->open.conn_id);
            
            // Start service discovery
            ESP_LOGI(GATTC_TAG, "Starting service discovery...");
            esp_ble_gattc_search_service(gattc_if, param->open.conn_id, NULL); // NULL for all primary services
            g_client_state = CLIENT_DISCOVERING_SERVICES;
            g_service_count = 0;
        } else {
            ESP_LOGE(GATTC_TAG, "Connection failed, status %d", param->open.status);
            g_client_state = CLIENT_IDLE;
            // Optionally, restart scanning
            // esp_ble_gap_start_scanning(0); 
        }
        break;
    
    case ESP_GATTC_CFG_MTU_EVT:
        if (param->cfg_mtu.status == ESP_GATT_OK) {
            ESP_LOGI(GATTC_TAG, "MTU configured to %d", param->cfg_mtu.mtu);
        } else {
            ESP_LOGE(GATTC_TAG, "MTU configuration failed, status %d", param->cfg_mtu.status);
        }
        break;

    case ESP_GATTC_SEARCH_RES_EVT: { // Service found
        ESP_LOGI(GATTC_TAG, "Service found: UUID %04x, start_handle %d, end_handle %d",
                 param->search_res.srvc_id.uuid.uuid.uuid16, // Assuming 16-bit UUID for simplicity
                 param->search_res.start_handle, param->search_res.end_handle);
        if (g_service_count < MAX_SERVICES) {
            g_services[g_service_count].uuid = param->search_res.srvc_id.uuid;
            g_services[g_service_count].start_handle = param->search_res.start_handle;
            g_services[g_service_count].end_handle = param->search_res.end_handle;
            g_service_count++;
        }
        break;
    }
    case ESP_GATTC_SEARCH_CMPL_EVT: // Service discovery complete
        ESP_LOGI(GATTC_TAG, "Service discovery complete, found %d services.", g_service_count);
        if (param->search_cmpl.status == ESP_GATT_OK) {
            if (g_service_count > 0) {
                // Start characteristic discovery for the first service
                g_current_service_idx_for_char_discovery = 0;
                ESP_LOGI(GATTC_TAG, "Discovering characteristics for service UUID %04x", g_services[0].uuid.uuid.uuid16);
                esp_ble_gattc_get_all_char(gattc_if, current_conn_id,
                                           g_services[0].start_handle,
                                           g_services[0].end_handle);
                g_client_state = CLIENT_DISCOVERING_CHARS;
            } else {
                ESP_LOGI(GATTC_TAG, "No services found.");
                g_client_state = CLIENT_READY_TO_INTERACT; // Or disconnect
            }
        } else {
            ESP_LOGE(GATTC_TAG, "Service discovery failed, status %d", param->search_cmpl.status);
            g_client_state = CLIENT_IDLE;
        }
        break;

    case ESP_GATTC_GET_CHAR_EVT: // Characteristic found
        if (param->get_char.status == ESP_GATT_OK) {
            ESP_LOGI(GATTC_TAG, "Characteristic found: UUID %04x, handle %d, props %02x",
                     param->get_char.char_id.uuid.uuid.uuid16, // Assuming 16-bit
                     param->get_char.char_handle,
                     param->get_char.char_prop);
            // TODO: Store characteristic info (handle, UUID, properties)
            // TODO: If this is the last char for current service, move to next service or desc discovery
            // For simplicity, we are not doing full recursive discovery here.
            // You would typically discover descriptors for this characteristic now.
            // esp_ble_gattc_get_all_descr(gattc_if, current_conn_id, param->get_char.char_handle, g_services[g_current_service_idx_for_char_discovery].end_handle);
            // g_client_state = CLIENT_DISCOVERING_DESCRS;

            // Example: If it's a known characteristic, try to read it
            // if (param->get_char.char_id.uuid.uuid.uuid16 == ESP_GATT_UUID_MANU_NAME) {
            //     esp_ble_gattc_read_char(gattc_if, current_conn_id, param->get_char.char_handle, ESP_GATT_AUTH_REQ_NONE);
            // }
        } else {
             ESP_LOGE(GATTC_TAG, "Get characteristic failed for service %d, status %d", g_current_service_idx_for_char_discovery, param->get_char.status);
        }
        // Logic to move to next characteristic or next service for discovery
        // This part needs careful state management. If all chars for current service are done:
        // g_current_service_idx_for_char_discovery++;
        // if (g_current_service_idx_for_char_discovery < g_service_count) { ... start char discovery for next service ...} 
        // else { g_client_state = CLIENT_READY_TO_INTERACT; }
        break;

    // TODO: Add handlers for ESP_GATTC_GET_DESCR_EVT, ESP_GATTC_READ_CHAR_EVT,
    // ESP_GATTC_WRITE_CHAR_EVT, ESP_GATTC_NOTIFY_EVT, ESP_GATTC_DISCONNECT_EVT etc.

    default:
        break;
    }
}

Note: The above code for connection and discovery is significantly simplified. A robust implementation requires careful state management to proceed through service, characteristic, and descriptor discovery sequentially, and to store all discovered attribute handles. Refer to the gattc_demo or ble_spp_client examples in ESP-IDF for more complete implementations.

Example 3: Reading a Characteristic and Handling Notifications

This would further extend Example 2.

  • Reading: After discovering a characteristic (e.g., “Manufacturer Name String” UUID 0x2A29), call esp_ble_gattc_read_char() using its handle. The result comes in ESP_GATTC_READ_CHAR_EVT.
  • Notifications:
    1. Discover a notifiable characteristic and its CCCD (UUID 0x2902).
    2. Write 0x0001 to the CCCD handle using esp_ble_gattc_write_char_descr().
    3. On successful write (ESP_GATTC_WRITE_DESCR_EVT), call esp_ble_gattc_register_for_notify() with the characteristic’s value handle.
    4. Handle incoming data in ESP_GATTC_NOTIFY_EVT.

Variant Notes

  • ESP32-S2: This variant does not have Bluetooth hardware and cannot operate as a BLE GATT client.
  • ESP32, ESP32-C3, ESP32-S3, ESP32-C6, ESP32-H2: All these variants provide full support for BLE GATT client functionality. The ESP-IDF GATTC APIs are consistent across these chips.
  • Impact of BLE 5.x Features (from a Client Perspective):
    • Larger ATT_MTU: As a client, your ESP32 can request a larger MTU from the server using esp_ble_gattc_send_mtu_req() (or by setting local MTU with esp_ble_gatt_set_local_mtu() which often triggers the exchange). This allows receiving larger characteristic values in a single operation.
    • LE 2M PHY / Coded PHY: If both the ESP32 client and the peripheral server support these PHYs, the connection can achieve higher throughput (2M PHY) or longer range (Coded PHY). The client doesn’t usually explicitly select the PHY during connection for GATT operations; the stack often handles negotiation or uses defaults. PHY preferences can be set for scanning and connection initiation.
    • Scanning for Extended Advertisements: If peripherals use BLE 5.0 extended advertising, the client needs to be configured to scan for these to discover them.

The core GATTC operations (connect, discover, read, write, notify) are fundamentally the same. BLE 5.x features enhance the underlying link capabilities.

Common Mistakes & Troubleshooting Tips

Mistake / Issue (Client Focus) Symptom(s) Fix / Best Practice
Incorrect State Management During Discovery API calls fail (e.g., ESP_GATT_ILLEGAL_PARAMETER), discovery gets stuck, app crashes. Calling GATTC functions in wrong order/context. Implement a clear state machine. E.g., only start char discovery in ESP_GATTC_SEARCH_CMPL_EVT. Store handles properly.
Not Handling All Necessary GATTC Events App doesn’t proceed to next step, doesn’t clean up on disconnect, misses crucial info. Ensure GATTC callback covers all relevant events for your workflow. Log unhandled events during development.
Mismanaging Attribute Handles Operations target wrong attribute, fail with ESP_GATT_INVALID_HANDLE, data misinterpreted. Carefully store handles from _GET_CHAR_EVT, _GET_DESCR_EVT. Map handles to services/chars correctly.
Issues with Notifications/Indications ESP_GATTC_NOTIFY_EVT never triggered despite server sending. 1. Discover CCCD handle.
2. Write 0x0001 (notify) or 0x0002 (indicate) to CCCD.
3. Wait for ESP_GATTC_WRITE_DESCR_EVT.
4. Call esp_ble_gattc_register_for_notify() with char value handle.
Connection Failures / Unexpected Disconnects esp_ble_gattc_open() fails, or connection drops. Check remote BDA & address type. Ensure peripheral is connectable. Log disconnect reasons from ESP_GATTC_DISCONNECT_EVT. Check supervision timeout.
Forgetting to Register GATTC App Profile GATTC API calls fail early, gattc_if is invalid. Call esp_ble_gattc_app_register() and wait for ESP_GATTC_REG_EVT to get a valid gattc_if before other GATTC operations.
Blocking in Callbacks BLE stack becomes unresponsive, other events missed, watchdog timers may trigger. Keep GATTC (and GAP) callback functions non-blocking. Offload lengthy operations to separate tasks using queues or event groups.

Exercises

  1. Targeted Device Connection:
    • Modify Example 1 (scanning) to automatically connect to a device that advertises a specific name (e.g., “ESP32_GATT_SERVER” from Chapter 57).
    • Once connected, log a success message and then disconnect.
  2. Read All Characteristics of a Service:
    • Connect to a device known to have the “Device Information Service” (UUID 0x180A).
    • Discover this service.
    • Discover all characteristics within this service.
    • For each discovered characteristic, attempt to read its value and log the UUID and the read value (if successful and readable).
  3. Write to a Writable Characteristic:
    • Set up the GATT server from Chapter 57 (or a similar server) that has a writable characteristic (e.g., the “LED State” from Chapter 57’s exercises).
    • Implement a GATT client that connects to this server, discovers the specific writable characteristic by its UUID.
    • Write a new value to the characteristic (e.g., toggle the LED state).
    • Verify on the server side (via logs or actual LED) that the write was successful.
  4. Full Interaction with Custom Sensor:
    • Assume you have a BLE peripheral with the “Custom Environment Sensing Service” designed in Chapter 58 (Example 1), which has a notifiable “Temperature Measurement” characteristic and a readable “Humidity Measurement” characteristic.
    • Write an ESP32 GATT client application that:
      1. Scans and connects to this specific peripheral (identify it by name or advertised service UUID).
      2. Discovers the “Custom Environment Service.”
      3. Discovers the “Temperature Measurement” and “Humidity Measurement” characteristics.
      4. Reads the “Humidity Measurement” value and prints it.
      5. Enables notifications for the “Temperature Measurement” characteristic.
      6. Prints any received temperature notifications to the console for 30 seconds.
      7. Disables notifications and disconnects.

Summary

  • A GATT Client discovers and interacts with services and characteristics offered by a GATT Server.
  • The process involves scanning, connecting, discovering services, characteristics, and descriptors, and then performing operations like read, write, and subscribe/handle notifications.
  • ESP-IDF provides esp_gattc_api.h for all GATTC operations, which are event-driven via a registered callback.
  • Proper state management and careful handling of attribute handles are crucial for successful GATT client implementation.
  • Key GATTC events include ESP_GATTC_OPEN_EVT, ESP_GATTC_SEARCH_RES_EVT, ESP_GATTC_SEARCH_CMPL_EVT, ESP_GATTC_GET_CHAR_EVT, ESP_GATTC_READ_CHAR_EVT, ESP_GATTC_WRITE_CHAR_EVT, and ESP_GATTC_NOTIFY_EVT.
  • To receive notifications/indications, the client must write to the server’s CCCD for the specific characteristic and then register for notifications locally.

Further Reading

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top