Data Exchange
This document is the fourth tutorial in the Getting Started series on Bluetooth Low Energy (Bluetooth LE), aiming to provide a brief overview of the data exchange process within Bluetooth LE connections. Subsequently, this tutorial introduces the code implementation of a GATT server, using the NimBLE_GATT_Server example based on the NimBLE host layer stack.
Learning Objectives
Understand the data structure details of characteristic data and services
Learn about different data access operations in GATT
Learn about the code structure of the NimBLE_GATT_Server example
GATT Data Characteristics and Services
GATT services are the infrastructure for data exchange between two devices in a Bluetooth LE connection, with the minimum data unit being an attribute. In the section on Data Representation and Exchange, we briefly introduced the attributes at the ATT layer and the characteristic data, services, and specifications at the GATT layer. Below are details regarding the attribute-based data structure.
Attributes
An attribute consists of the following four parts:
No. |
Name |
Description |
---|---|---|
1 |
Handle |
A 16-bit unsigned integer representing the index of the attribute in the attribute table |
2 |
Type |
ATT attributes use UUID (Universally Unique Identifier) to differentiate types |
3 |
Access Permission |
Indicates whether encryption/authorization is needed; whether it is readable or writable |
4 |
Value |
Actual user data or metadata of another attribute |
There are two types of UUIDs in Bluetooth LE:
16-bit UUIDs defined by SIG
128-bit UUIDs customized by manufacturers
Common characteristic and service UUIDs are provided in SIG's Assigned Numbers standard document, such as:
Category |
Type Name |
UUID |
---|---|---|
Service |
Blood Pressure Service |
0x1810 |
Service |
Common Audio Service |
0x1853 |
Characteristic Data |
Age |
0x2A80 |
Characteristic Data |
Appearance |
0x2A01 |
In fact, the definitions of these services and characteristic data are also provided by the SIG. For example, the value of the Heart Rate Measurement must include a flag field and a heart rate measurement field, and may include fields such as energy expended, RR-interval, and transmission interval, among others. Therefore, these definitions from SIG allow Bluetooth LE devices from different manufacturers to recognize each other's services or characteristic data, enabling cross-manufacturer communication.
Manufacturers' customized 128-bit UUIDs are used for proprietary services or data characteristics, such as the UUID for the LED characteristic in this example: 0x00001525-1212-EFDE-1523-785FEABCD123.
Characteristic Data
A characteristic data item typically consists of the following attributes:
No. |
Type |
Function |
Notes |
---|---|---|---|
1 |
Characteristic Declaration |
Contains properties, handle, and UUID info for the characteristic value |
UUID is 0x2803, read-only |
2 |
Characteristic Value |
user data |
UUID identifies the characteristic type |
3 |
Characteristic Descriptor |
Additional description for the characteristic data |
Optional attribute |
Relationship between Characteristic Declaration and Characteristic Value
Using the Heart Rate Measurement as an example, the relationship between the characteristic declaration and characteristic value is illustrated as follows:
The table below is an attribute table, containing two attributes of the Heart Rate Measurement characteristic. Let's first look at the attribute with handle 0. Its UUID is 0x2803, and the access permission is read-only, indicating that this is a characteristic declaration attribute. The attribute value shows that the read/write property is read-only, and the handle points to 1, indicating that the attribute with handle 1 is the value attribute for this characteristic. The UUID is 0x2A37, meaning that this characteristic type is Heart Rate Measurement.
Now, let's examine the attribute with handle 1. Its UUID is 0x2A37, and the access permission is also read-only, corresponding directly with the characteristic declaration attribute. The value of this attribute consists of flag bits and measurement values, which complies with the SIG specification for Heart Rate Measurement characteristic data.
Handle |
UUID |
Permissions |
Value |
Attribute Type |
---|---|---|---|---|
0 |
0x2803 |
Read-only |
Properties = Read-only |
Characteristic Declaration |
Handle = 1 |
||||
UUID = 0x2A37 |
||||
1 |
0x2A37 |
Read-only |
Flags |
Characteristic Value |
Measurement value |
Characteristic Descriptors
Characteristic descriptors provide supplementary information about characteristic data. The most common is the Client Characteristic Configuration Descriptor (CCCD). When a characteristic supports server-initiated data operations (notifications or indications), CCCD must be used to describe the relevant information. This is a read-write attribute that allows the GATT client to inform the server whether notifications or indications should be enabled. Writing to this value is also referred to as subscribing or unsubscribing.
The UUID for CCCD is 0x2902, and its attribute value contains only 2 bits of information. The first bit indicates whether notifications are enabled, and the second bit indicates whether indications are enabled. By adding the CCCD to the attribute table and providing indication access permissions for the Heart Rate Measurement characteristic data, we obtain the complete form of the Heart Rate Measurement characteristic data in the attribute table as follows:
Handle |
UUID |
Permissions |
Value |
Attribute Type |
---|---|---|---|---|
0 |
0x2803 |
Read-only |
Properties = Read/Indicate |
Characteristic Declaration |
Handle = 1 |
||||
UUID = 0x2A37 |
||||
1 |
0x2A37 |
Read/Indicate |
Flags |
Characteristic Value |
Measurement value |
||||
2 |
0x2902 |
Read/Write |
Notification status |
Characteristic Descriptor |
Indication status |
Services
The data structure of a service can be broadly divided into two parts:
No. |
Name |
---|---|
1 |
Service Declaration Attribute |
2 |
Characteristic Definition Attributes |
The three characteristic data attributes mentioned in the Characteristic Data belong to characteristic definition attributes. In essence, the data structure of a service consists of several characteristic data attributes along with a service declaration attribute.
The UUID for the service declaration attribute is 0x2800, which is read-only and holds the UUID identifying the service type. For example, the UUID for the Heart Rate Service is 0x180D, so its service declaration attribute can be represented as follows:
Handle |
UUID |
Permissions |
Value |
Attribute Type |
---|---|---|---|---|
0 |
0x2800 |
Read-only |
0x180D |
Service Declaration |
Attribute Example
The following is an example of a possible attribute table for a GATT server, using the NimBLE_GATT_Server as an illustration. The example includes two services: the Heart Rate Service and the Automation IO Service. The former contains a Heart Rate Measurement characteristic, while the latter includes an LED characteristic. The complete attribute table for the GATT server is as follows:
Handle |
UUID |
Permissions |
Value |
Attribute Type |
---|---|---|---|---|
0 |
0x2800 |
Read-only |
UUID = 0x180D |
Service Declaration |
1 |
0x2803 |
Read-only |
Properties = Read/Indicate |
Characteristic Declaration |
Handle = 2 |
||||
UUID = 0x2A37 |
||||
2 |
0x2A37 |
Read/Indicate |
Flags |
Characteristic Value |
Measurement value |
||||
3 |
0x2902 |
Read/Write |
Notification status |
Characteristic Descriptor |
Indication status |
||||
4 |
0x2800 |
Read-only |
UUID = 0x1815 |
Service Declaration |
5 |
0x2803 |
Read-only |
Properties = Write-only |
Characteristic Declaration |
Handle = 6 |
||||
UUID = 0x00001525-1212-EFDE-1523-785FEABCD123 |
||||
6 |
0x00001525-1212-EFDE- 1523-785FE ABCD123 |
Write-only |
LED status |
Characteristic Value |
When a GATT client first establishes communication with a GATT server, it pulls metadata from the server's attribute table to discover the available services and characteristics. This process is known as Service Discovery.
GATT Data Operations
Data operations refer to accessing characteristic data on a GATT server, which can be mainly categorized into two types:
Client-initiated operations
Server-initiated operations
Client-initiated Operations
Client-initiated operations include the following three types:
- Read
A straightforward operation to pull the current value of a specific characteristic from the GATT server.
- Write
Standard write operations require confirmation from the GATT server upon receiving the client's write request and data.
- Write without response
This is another form of write operation that does not require server acknowledgment.
Server-Initiated Operations
Server-initiated operations are divided into two types:
- Notify
A GATT server actively pushes data to the client without requiring a confirmation response.
- Indicate
Similar to notifications, but this requires confirmation from the client, which makes indication slower than notification.
Although both notifications and indications are initiated by the server, the prerequisite for these operations is that the client has enabled notifications or indications. Therefore, the data exchange process in GATT essentially begins with a client request for data.
Hands-On Practice
Having grasped the relevant knowledge of GATT data exchange, let’s combine the NimBLE_GATT_Server example code to learn how to build a simple GATT server using the NimBLE protocol stack and put our knowledge into practice.
Prerequisites
An ESP32-C2 development board
ESP-IDF development environment
The nRF Connect for Mobile application installed on your phone
If you have not completed the ESP-IDF development environment setup, please refer to IDF Get Started.
Try It Out
Please refer to BLE Introduction Try It Out 。
Code Explanation
Project Structure Overview
The root directory structure of NimBLE_GATT_Server is identical to that of NimBLE_Connection. Additionally, the main folder includes source code related to the GATT service and simulated heart rate generation.
Program Behavior Overview
The program behavior of this example is largely consistent with that of NimBLE_Connection, with the difference being that this example adds GATT services and handles access to GATT characteristic data through corresponding callback functions.
Entry Function
Based on NimBLE_Connection, a process to initialize the GATT service by calling the gatt_svc_init function has been added. Moreover, in addition to the NimBLE thread, a new heart_rate_task thread has been introduced, responsible for the random generation of simulated heart rate measurement data and indication handling. Relevant code is as follows:
static void heart_rate_task(void *param) {
/* Task entry log */
ESP_LOGI(TAG, "heart rate task has been started!");
/* Loop forever */
while (1) {
/* Update heart rate value every 1 second */
update_heart_rate();
ESP_LOGI(TAG, "heart rate updated to %d", get_heart_rate());
/* Send heart rate indication if enabled */
send_heart_rate_indication();
/* Sleep */
vTaskDelay(HEART_RATE_TASK_PERIOD);
}
/* Clean up at exit */
vTaskDelete(NULL);
}
void app_main(void) {
...
xTaskCreate(heart_rate_task, "Heart Rate", 4*1024, NULL, 5, NULL);
return;
}
The heart_rate_task thread runs at a frequency of 1 Hz, as HEART_RATE_TASK_PERIOD is defined as 1000 ms. Each time it executes, the thread calls the update_heart_rate function to randomly generate a new heart rate measurement and then calls send_heart_rate_indication to handle the indication operation.
GATT Service Initialization
In the gatt_svc.c file, there is a GATT service initialization function as follows:
int gatt_svc_init(void) {
/* Local variables */
int rc;
/* 1. GATT service initialization */
ble_svc_gatt_init();
/* 2. Update GATT services counter */
rc = ble_gatts_count_cfg(gatt_svr_svcs);
if (rc != 0) {
return rc;
}
/* 3. Add GATT services */
rc = ble_gatts_add_svcs(gatt_svr_svcs);
if (rc != 0) {
return rc;
}
return 0;
}
This function first calls the ble_svc_gatt_init API to initialize the GATT Service. It's important to note that this GATT Service is a special service with the UUID 0x1801, which is used by the GATT server to notify clients when services change (i.e., when GATT services are added or removed). In such cases, the client will re-execute the service discovery process to update its service information.
Next, the function calls ble_gatts_count_cfg and ble_gatts_add_svcs APIs to add the services and characteristic data defined in the gatt_svr_svcs service table to the GATT server.
GATT Service Table
The gatt_svr_svcs service table is a crucial data structure in this example, defining all services and characteristic data used. The relevant code is as follows:
/* Heart rate service */
static const ble_uuid16_t heart_rate_svc_uuid = BLE_UUID16_INIT(0x180D);
...
static uint16_t heart_rate_chr_val_handle;
static const ble_uuid16_t heart_rate_chr_uuid = BLE_UUID16_INIT(0x2A37);
static uint16_t heart_rate_chr_conn_handle = 0;
...
/* Automation IO service */
static const ble_uuid16_t auto_io_svc_uuid = BLE_UUID16_INIT(0x1815);
static uint16_t led_chr_val_handle;
static const ble_uuid128_t led_chr_uuid =
BLE_UUID128_INIT(0x23, 0xd1, 0xbc, 0xea, 0x5f, 0x78, 0x23, 0x15, 0xde, 0xef,
0x12, 0x12, 0x25, 0x15, 0x00, 0x00);
/* GATT services table */
static const struct ble_gatt_svc_def gatt_svr_svcs[] = {
/* Heart rate service */
{.type = BLE_GATT_SVC_TYPE_PRIMARY,
.uuid = &heart_rate_svc_uuid.u,
.characteristics =
(struct ble_gatt_chr_def[]){
{/* Heart rate characteristic */
.uuid = &heart_rate_chr_uuid.u,
.access_cb = heart_rate_chr_access,
.flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_INDICATE,
.val_handle = &heart_rate_chr_val_handle},
{
0, /* No more characteristics in this service. */
}}},
/* Automation IO service */
{
.type = BLE_GATT_SVC_TYPE_PRIMARY,
.uuid = &auto_io_svc_uuid.u,
.characteristics =
(struct ble_gatt_chr_def[]){/* LED characteristic */
{.uuid = &led_chr_uuid.u,
.access_cb = led_chr_access,
.flags = BLE_GATT_CHR_F_WRITE,
.val_handle = &led_chr_val_handle},
{0}},
},
{
0, /* No more services. */
},
};
The macros BLE_UUID16_INIT and BLE_UUID128_INIT provided by the NimBLE protocol stack allow for convenient conversion of 16-bit and 128-bit UUIDs from raw data into ble_uuid16_t and ble_uuid128_t type variables.
The gatt_svr_svcs is an array of structures of type ble_gatt_svc_def. The ble_gatt_svc_def structure defines a service, with key fields being type, uuid, and characteristics. The type field indicates whether the service is primary or secondary, with all services in this example being primary. The uuid field represents the UUID of the service. The characteristics field is an array of ble_gatt_chr_def structures that stores the characteristics associated with the service.
The ble_gatt_chr_def structure defines the characteristics, with key fields being uuid, access_cb, flags, and val_handle. The uuid field is the UUID of the characteristic. The access_cb field points to the access callback function for that characteristic. The flags field indicates the access permissions for the characteristic data. The val_handle field points to the variable handle address for the characteristic value.
It's important to note that when the BLE_GATT_CHR_F_INDICATE flag is set for a characteristic, the NimBLE protocol stack automatically adds the CCCD, so there's no need to manually add the descriptor.
Based on variable naming, it's clear that gatt_svr_svcs implements all property definitions in the attribute table. Additionally, access to the Heart Rate Measurement characteristic is managed through the heart_rate_chr_access callback function, while access to the LED characteristic is managed through the led_chr_access callback function.
Characteristic Data Access Management
LED Access Management
Access to the LED characteristic data is managed through the led_chr_access callback function, with the relevant code as follows:
static int led_chr_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg) {
/* Local variables */
int rc;
/* Handle access events */
/* Note: LED characteristic is write only */
switch (ctxt->op) {
/* Write characteristic event */
case BLE_GATT_ACCESS_OP_WRITE_CHR:
/* Verify connection handle */
if (conn_handle != BLE_HS_CONN_HANDLE_NONE) {
ESP_LOGI(TAG, "characteristic write; conn_handle=%d attr_handle=%d",
conn_handle, attr_handle);
} else {
ESP_LOGI(TAG,
"characteristic write by nimble stack; attr_handle=%d",
attr_handle);
}
/* Verify attribute handle */
if (attr_handle == led_chr_val_handle) {
/* Verify access buffer length */
if (ctxt->om->om_len == 1) {
/* Turn the LED on or off according to the operation bit */
if (ctxt->om->om_data[0]) {
led_on();
ESP_LOGI(TAG, "led turned on!");
} else {
led_off();
ESP_LOGI(TAG, "led turned off!");
}
} else {
goto error;
}
return rc;
}
goto error;
/* Unknown event */
default:
goto error;
}
error:
ESP_LOGE(TAG,
"unexpected access operation to led characteristic, opcode: %d",
ctxt->op);
return BLE_ATT_ERR_UNLIKELY;
}
When the GATT client initiates access to the LED characteristic data, the NimBLE protocol stack will call the led_chr_access callback function, passing in the handle information and access context. The op field of ble_gatt_access_ctxt is used to identify different access events. Since the LED is a write-only characteristic, we only handle the BLE_GATT_ACCESS_OP_WRITE_CHR event.
In this processing branch, we first validate the attribute handle to ensure that the client is accessing the LED characteristic. Then, based on the om field of ble_gatt_access_ctxt, we verify the length of the access data. Finally, we check if the data in om_data is equal to 1 to either turn the LED on or off.
If any other access events occur, they are considered unexpected, and we proceed to the error branch to return.
Heart Rate Measurement Read Access Management
The heart rate measurement is a readable and indicative characteristic. The read access initiated by the client for heart rate measurement values is managed by the heart_rate_chr_access callback function, with the relevant code as follows:
static int heart_rate_chr_access(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg) {
/* Local variables */
int rc;
/* Handle access events */
/* Note: Heart rate characteristic is read only */
switch (ctxt->op) {
/* Read characteristic event */
case BLE_GATT_ACCESS_OP_READ_CHR:
/* Verify connection handle */
if (conn_handle != BLE_HS_CONN_HANDLE_NONE) {
ESP_LOGI(TAG, "characteristic read; conn_handle=%d attr_handle=%d",
conn_handle, attr_handle);
} else {
ESP_LOGI(TAG, "characteristic read by nimble stack; attr_handle=%d",
attr_handle);
}
/* Verify attribute handle */
if (attr_handle == heart_rate_chr_val_handle) {
/* Update access buffer value */
heart_rate_chr_val[1] = get_heart_rate();
rc = os_mbuf_append(ctxt->om, &heart_rate_chr_val,
sizeof(heart_rate_chr_val));
return rc == 0 ? 0 : BLE_ATT_ERR_INSUFFICIENT_RES;
}
goto error;
/* Unknown event */
default:
goto error;
}
error:
ESP_LOGE(
TAG,
"unexpected access operation to heart rate characteristic, opcode: %d",
ctxt->op);
return BLE_ATT_ERR_UNLIKELY;
}
Similar to the LED access management, we use the op field of the ble_gatt_access_ctxt access context to determine the access event, handling the BLE_GATT_ACCESS_OP_READ_CHR event.
In the handling branch, we first validate the attribute handle to confirm that the client is accessing the heart rate measurement attribute. Then, we call the get_heart_rate function to retrieve the latest heart rate measurement, storing it in the measurement area of the heart_rate_chr_val array. Finally, we copy the data from heart_rate_chr_val into the om field of the ble_gatt_access_ctxt access context. The NimBLE protocol stack will send the data in this field to the client after the current callback function ends, thus achieving read access to the Heart Rate Measurement characteristic value.
Heart Rate Measurement Indication
When the client enables indications for heart rate measurements, the processing flow is a bit more complicated. First, enabling or disabling the heart rate measurement indications is a subscription or unsubscription event at the GAP layer, so we need to add a handling branch for subscription events in the gap_event_handler callback function, as follows:
static int gap_event_handler(struct ble_gap_event *event, void *arg) {
...
/* Subscribe event */
case BLE_GAP_EVENT_SUBSCRIBE:
/* Print subscription info to log */
ESP_LOGI(TAG,
"subscribe event; conn_handle=%d attr_handle=%d "
"reason=%d prevn=%d curn=%d previ=%d curi=%d",
event->subscribe.conn_handle, event->subscribe.attr_handle,
event->subscribe.reason, event->subscribe.prev_notify,
event->subscribe.cur_notify, event->subscribe.prev_indicate,
event->subscribe.cur_indicate);
/* GATT subscribe event callback */
gatt_svr_subscribe_cb(event);
return rc;
}
The subscription event is represented by BLE_GAP_EVENT_SUBSCRIBE. In this handling branch, we do not process the subscription event directly; instead, we call the gatt_svr_subscribe_cb callback function to handle the subscription event. This reflects the layered design philosophy of software, as the subscription event affects the GATT server's behavior in sending characteristic data and is not directly related to the GAP layer. Thus, it should be passed to the GATT layer for processing.
Next, let's take a look at the operations performed in the gatt_svr_subscribe_cb callback function.
void gatt_svr_subscribe_cb(struct ble_gap_event *event) {
/* Check connection handle */
if (event->subscribe.conn_handle != BLE_HS_CONN_HANDLE_NONE) {
ESP_LOGI(TAG, "subscribe event; conn_handle=%d attr_handle=%d",
event->subscribe.conn_handle, event->subscribe.attr_handle);
} else {
ESP_LOGI(TAG, "subscribe by nimble stack; attr_handle=%d",
event->subscribe.attr_handle);
}
/* Check attribute handle */
if (event->subscribe.attr_handle == heart_rate_chr_val_handle) {
/* Update heart rate subscription status */
heart_rate_chr_conn_handle = event->subscribe.conn_handle;
heart_rate_chr_conn_handle_inited = true;
heart_rate_ind_status = event->subscribe.cur_indicate;
}
}
In this example, the callback handling is quite simple: it checks whether the attribute handle in the subscription event corresponds to the heart rate measurement attribute handle. If it does, it saves the corresponding connection handle and updates the indication status requested by the client.
As mentioned in Entry Function, the send_heart_rate_indication function is called by the heart_rate_task thread at a frequency of 1 Hz. The implementation of this function is as follows:
void send_heart_rate_indication(void) {
if (heart_rate_ind_status && heart_rate_chr_conn_handle_inited) {
ble_gatts_indicate(heart_rate_chr_conn_handle,
heart_rate_chr_val_handle);
ESP_LOGI(TAG, "heart rate indication sent!");
}
}
The ble_gatts_indicate function is an API provided by the NimBLE protocol stack for sending indications. This means that when the indication status for the heart rate measurement is true and the corresponding connection handle is available, calling the send_heart_rate_indication function will send the heart rate measurement to the GATT client.
To summarize, when a GATT client subscribes to heart rate measurements, the gap_event_handler receives the subscription event and passes it to the gatt_svr_subscribe_cb callback function, which updates the subscription status for heart rate measurements. In the heart_rate_task thread, it checks the subscription status every second; if the status is true, it sends the heart rate measurement to the client.
Summary
Through this tutorial, you have learned how to create GATT services and their corresponding characteristic data using a service table, and you mastered the management of access to GATT characteristic data, including read, write, and subscription operations. You can now build more complex GATT service applications based on the NimBLE_GATT_Server example.