BLE: A Deep Dive into GATT
In the previous article , we explored the basics of Bluetooth Low Energy (BLE). In this article, we will take a closer look at BLE GATT (Generic Attribute Profile) — unpacking what it is and how it works.
BLE and GATT
BLE is a wireless technology designed for short-range communication between devices while keeping power consumption to a minimum. Hence, it is commonly used in the Internet of Things (IoT) landscape. It does not only manages how data is organized and exchanged between devices, but also provides a standardized and power efficient way for BLE devices to communicate.
GATT, short for Generic Attribute Profile, is the protocol that defines the structure and behavior for communication between BLE-enabled devices. It extends the Attribute Protocol (ATT) by defining several key concepts:
- Characteristics - Individual data elements. This is where the actual data is stored.
- Services - Collections of related characteristics that group data into logical entities.
- Profile - High level definition of how a device should behave to enable a specific application. It contains one or more services.

A profile sits on top of the hierarchy. It contains a pre-defined collection of services. Profiles can be either pre-defined by the Bluetooth SIG ( Profiles Overview) or by the peripheral designers. One example is the Heart Rate Profile, which combines the mandatory Heart Rate Service and the optional Device Information Service.
Services are used to break data up into logical entities (characteristics). A service can be identified by either a 16-bit UUID (officially adopted BLE services) or a 128-bit UUID (for custom services). If we look at the official Heart Rate Service, we can see that the service has one mandatory characteristic (i.e., Heart Rate Measurement) and two optional ones (i.e., Body Sensor Location and Heart Rate Control Point). Each characteristics then contains a single data point.
GATT Operations
As mentioned before, data is stored within characteristics. Once the data has been structured, clients interact with the data through several operations:
- Read .
The client requests to read the value of a Characteristic from the server.
- Write .
The client can also send data to the server to update the value of a characteristic. There are two variants. The first one is a write that requires an acknowledgment from the server and the second one is a write command without a response/ acknowledgement.
- Notify .
The server can be configured to automatically send updates of a characteristic’s value to the client whenever the value changes. To configure the server, the client needs to send a subscription request.
- Indicate .
This is similar to notify; the difference is that the client must send an acknowledgement back to the server to confirm it received the data.
Example using NimBLE
NimBLE is a lightweight, open-source Bluetooth Low Energy (BLE) stack designed especially for resource-constrained devices. It has been integrated into ESP-IDF framework for ESP32 devices. The main advantages of NimBLE are its modular architecture and small memory footprint. This makes NimBLE an excellent choice for building BLE applications.
Write and Read Operations
In NimBLE, all GATT services and characteristics can be defined in the gatt_svr_svcs_service table. For each characteristics, we can specific where it supports read, write, notify, and indicate operations. We also need to assign callback in the access_cb variable and the characteristic handle in the val_handle field. Below is an example showing a Heart Rate service definition:
/* Heart rate service UUID */
static const ble_uuid16_t heart_rate_svc_uuid = BLE_UUID16_INIT(0x180D);
/* Characteristics Handle */
static uint16_t heart_rate_chr_val_handle;
static uint16_t body_sensor_loc_chr_val_handle;
/* Callback function */
static int gatt_svr_chr_access_heart_rate(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg);
/* GATT services table */
static const struct ble_gatt_svc_def gatt_svr_svcs[] = {
{ /* Service: Heart-rate */
.type = BLE_GATT_SVC_TYPE_PRIMARY,
.uuid = &heart_rate_svc_uuid.u,
.characteristics = (struct ble_gatt_chr_def[]){
{
/* Characteristic: Heart-rate measurement */
.uuid = BLE_UUID16_DECLARE(GATT_HRS_MEASUREMENT_UUID),
.access_cb = gatt_svr_chr_access_heart_rate,
.val_handle = &heart_rate_chr_val_handle,
.flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_NOTIFY,
},
{
/* Characteristic: Body sensor location */
.uuid = BLE_UUID16_DECLARE(GATT_HRS_BODY_SENSOR_LOC_UUID),
.access_cb = gatt_svr_chr_access_heart_rate,
.val_handle = &body_sensor_loc_chr_val_handle,
.flags = BLE_GATT_CHR_F_READ,
},
{
0, /* No more characteristics in this service */
},
}
},
{
0, /* No more services */
},
};
The accompanying callback function handles events. In this example, it handles only read operations because the characteristic only supports read operations.
static int gatt_svr_chr_access_heart_rate(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)
{
/* 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;
/* Write characteristic event */
case BLE_GATT_ACCESS_OP_WRITE_CHR:
/* Unknown event */
default:
goto error;
}
error:
ESP_LOGE(tag,
"unexpected access operation to led characteristic, opcode: %d",
ctxt->op);
return BLE_ATT_ERR_UNLIKELY;
}
Notification and Indication
The NimBLE library provides APIs to handle notification and indication operations.
int ble_gatts_notify_custom(uint16_t conn_handle, uint16_t chr_val_handle,
struct os_mbuf *txom);
int ble_gatts_indicate_custom(uint16_t conn_handle, uint16_t chr_val_handle,
struct os_mbuf *txom);
These operations will be performed only if the client specifies that it would like to subscribe to any changes. The subscription event will be handled at the GAP layer. This can be done by handling the BLE_GAP_EVENT_SUBSCRIBE event.
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 */
/* 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;
}
}
This handle logs subscription events and updates internal state, ensuring the server sends notifications or indications only when appropriate.
Final Thoughts
BLE GATT’s structured approach—organizing data into characteristics, services, and profiles—is foundational for efficient and interoperable BLE communication in IoT devices. Whether you’re building wearable health monitors, smart home gadgets, or industrial sensors, understanding and leveraging GATT operations can greatly enhance your application’s performance and reliability.
NimBLE, with its lightweight design and efficient API set, stands out as a powerful tool for implementing these concepts on resource-constrained devices like the ESP32. Its modular architecture ensures that you can easily scale, customize, and optimize your BLE applications as your requirements evolve.
References
[2] Adafruit GATT