
Simplifying GATT Client Implementations
If you’re looking to implement a standard GATT (Generic Attribute Profile) Service Client, your starting point will be the corresponding Bluetooth GATT Service Profile specification provided by the Bluetooth SIG. This document outlines how a GATT Client interacts with a GATT Server, but only those steps after the service has been discovered, characteristics have been queried, and all allowed notifications and indications have been subscribed to. While there is no mystery to this setup process — it follows a well-defined sequence of queries and responses — its implementation is repetitive and time-consuming. This repetitiveness became painfully obvious during our work on the LE Audio profile, as we were developing a large number of GATT Clients one after another.
To streamline this effort, we introduced a GATT Service Client component that encapsulates the entire discover-and-subscribe workflow. The component removes the overhead of boilerplate setup code, so that developers can now dive straight into implementing the core logic defined by the GATT Service Profile specification.
In the following sections, we briefly introduce the core concepts of GATT, outline the setup process, and conclude with a comparison between a GATT Client that handles setup manually and one that uses the new component.
GATT Data Structures
The GATT profile structures data into Characteristics with a declared set of allowed operations and access permissions. These Characteristics are further organized into standardized entities called Services, each one performing a specific function. For example, the main purpose of the Battery Service is to enable connected devices to report battery level information.
GATT Services that define the main functionality of a device are known as Primary Services. Services can also include others, known as Included (Secondary) Services. Officially adopted GATT Services and Characteristics are identified by unique 16-bit UUIDs, whereas a custom made Services must use 128-bit UUIDs. A complete list of officially adopted GATT Services is available on the Bluetooth Developer Portal.
As an example, the Scan Parameters Service (UUID: 0x1813) is used to optimize the way a Central device (like a smartphone or tablet) scans for advertising packets from a Peripheral device (like a BLE sensor, wearable, or HID device). It allows the Peripheral device to suggest preferred scanning parameters by means of two Characteristics:
- Scan Interval Window (0x2A31) – mandatory Characteristic comprising two 16-bit values: LE_Scan_Interval and LE_Scan_Window. It stores the Client’s scan parameters and is only writable by the Client.
- Scan Refresh (0x2A31) – optional Characteristic used to notify the Client that the Server requires an update of the Scan Interval Window value.
GATT Device Roles
Two connected Bluetooth LE devices exchange data by taking on two GATT roles: one acts as the GATT Server, and the other as the GATT Client.
The GATT Server’s role is to store Characteristics and make them available through Services. The GATT Server can notify the client when the value of a Characteristic changes and it responds to the Client’s read and write requests.
The GATT Client’s role is to discover available Services and Characteristics on the GATT Server. Depending on the Service configuration, it can read or write values of a Characteristic, or subscribe to receive updates when those values change.
GATT Client Discovery Workflow
Before a GATT Client can perform operations on Characteristics or process responses from the GATT Server, it must first complete a sequence of GATT queries, typically including:
- Discover services
- Query characteristics and their subscription properties
- Subscribe to notifications or indications, if allowed
GATT queries in most Bluetooth stacks, including BTstack, are asynchronous. Therefore, a state machine is required to manage the sequence of operations. One part of this state machine is responsible for issuing GATT queries – one at a time. The other part handles the GATT Server responses. For example, a “discover all primary services with UUID X” query may result in multiple GATT events, one for each matching service, followed by a final “service query complete” event indicating that all results have been received. These two parts are illustrated below in pseudocode.
Send GATT Query
---------------
switch (STATE):
case W2_QUERY_SERVICES:
- Set state to W4_SERVICES_RESULT and wait for SERVICE_QUERY_RESULT GATT event
- Execute query to search for all services with the given UUID
case W2_QUERY_CHARACTERISTICS:
- Set state to W4_QUERY_CHARACTERISTICS_RESULT
- Execute query to get all characteristics of the current service
case W2_SUBSCRIBE:
- Set state to W4_SUBSCRIPTION
- Subscribe to notifications or indications
Handle GATT Response
--------------------
switch (STATE):
case W4_SERVICES_RESULT:
switch (GATT_EVENT):
case SERVICE_QUERY_RESULT:
- A matching service was found
- Store the service in the client’s service list
case SERVICE_QUERY_COMPLETE:
- Set state to W2_QUERY_CHARACTERISTICS
case W4_QUERY_CHARACTERISTICS_RESULT:
switch (GATT_EVENT):
case CHARACTERISTIC_QUERY_RESULT:
- A characteristic for a given service was found
- Store the characteristic and its properties in the service’s characteristic list
case CHARACTERISTIC_QUERY_COMPLETE:
- All characteristics for the current service have been reported
- Choose the first characteristic that supports notification or indication
- If such characteristic exists set state to W2_SUBSCRIBE, otherwise to CONNECTED
case W4_SUBSCRIPTION:
switch (GATT_EVENT):
case CHARACTERISTIC_QUERY_COMPLETE:
- Characteristic was subscribed
- Choose the next characteristic that supports notification or indication
- If such characteristic exists set state to W2_SUBSCRIBE, otherwise to CONNECTED
While not conceptually complex, implementing the described discovery workflow can be tedious and time-consuming, diverting attention from the core development of the GATT Client. To simplify this process, we introduced the GATT Service Client component (ble/gatt_service_client.h) that encapsulates all discovery steps – from querying services to subscribing to all allowed Characteristics. Let’s see how it it is used…
GATT Service Client Component Setup
To setup a GATT Service Client component for a custom service, we need to initialize both the GATT Client as well as the GATT Service Client and then register an instance of the new GATT Service Client. This usually happens before the Bluetooth stack is started:
- gatt_client_init – function to initialize the GATT Client,
- gatt_service_client_init – function to initialize the GATT Service Client component, and
- gatt_service_client_register_client_with_uuid(16|128)s – function to register the new GATT Service Client instance with the list of Characteristic UUIDs (16-bit or 128-bit respectively). The connection and notification callback is used to receive GATT Service Client connected and disconnected events, as well as notifications and indications.
The following code snippet shows the setup code of the GATT Streamer Client (example/le_streamer_client.c), which we rewrote as our first experiment. The GATT Streamer provides a custom GATT Service created to test the maximal GATT throughput – its core functionality is to stream data over GATT as fast as possible using write without response operations and notifications on the RX Characteristic.
GATT Service Client Setup Code
-------------------------------
# define NUM_CHARACTERISTICS 1
// LE Streamer GATT Service Client instance
static gatt_service_client_t le_streamer_client;
// 128-bit (custom) LE Streamer Service UUID
static uuid128_t LE_STREAMER_SERVICE_UUID = {
0x00, 0x00, 0xFF, 0x10, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0x80, 0x5F, 0x9B, 0x34, 0xFB
};
// 128-bit (custom) LE Streamer Service Characteristics UUIDs
static enum {
CHARACTERISTIC_INDEX_RX = 0
} le_streamer_service_characteristic_index_t;
static const uuid128_t le_streamer_service_uuid128s[] = {
{0x00, 0x00, 0xFF, 0x11, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0x80, 0x5F, 0x9B, 0x34, 0xFB}
};
/*
* Callback to receive GATT Service Client events:
* connected, disconnected, notifications and indications
*/
static void gatt_service_client_handler(uint8_t packet_type, uint16_t channel,
uint8_t *packet, uint16_t size);
static void setup(void){
...
gatt_client_init();
gatt_service_client_init();
gatt_service_client_register_client_uuid128(&le_streamer_client, &gatt_service_client_handler,
le_streamer_service_uuid128s, NUM_CHARACTERISTICS);
...
}
GATT Service Client Component Connect
After the setup is performed and a Bluetooth HCI connection between two devices has been established, we can trigger the GATT Service discovery workflow by calling a single function gatt_service_client_connect_primary_service_with_uuid(16|128). This function requires the connection handle, the GATT Service UUID as well as storage for the information on the characteristics that will be discovered during the discover-and-subscribe phase. The following code snippet shows how to place a call to this function directly after the HCI connection has been established. The value ERROR_CODE_SUCCESS of the returned status indicates that the service discovery workflow has started. The end of the discovery is marked by the GATTSERVICE_SUBEVENT_CLIENT_CONNECTED event in the gatt_service_client_handler.
GATT Service Client Connect
---------------------------
static hci_con_handle_t connection_handle;
static gatt_service_client_connection_t le_streamer_connection;
static gatt_service_client_characteristic_t le_streamer_characteristics_storage[NUM_CHARACTERISTICS];
/*
* Callback to receive GATT Service Client events
*/
static void gatt_service_client_handler(uint8_t packet_type, uint16_t channel, uint8_t *packet, uint16_t size){
if (packet_type != HCI_EVENT_PACKET) return;
switch(hci_event_packet_get_type(packet){
case HCI_EVENT_GATTSERVICE_META:
switch (hci_event_gattservice_meta_get_subevent_code(packet)) {
case GATTSERVICE_SUBEVENT_CLIENT_CONNECTED:
...
break;
case GATTSERVICE_SUBEVENT_CLIENT_DISCONNECTED:
...
break;
default:
break;
}
break;
case GATT_EVENT_NOTIFICATION:
...
break;
case GATT_EVENT_INDICATION:
...
break;
default:
break;
}
}
static void hci_event_handler(uint8_t packet_type, uint16_t channel, uint8_t *packet, uint16_t size){
if (hci_event_packet_get_type(packet) == HCI_EVENT_META_GAP) {
if (hci_event_gap_meta_get_subevent_code(packet) != GAP_SUBEVENT_LE_CONNECTION_COMPLETE){
uint8_t status = gatt_service_client_connect_primary_service_with_uuid128(
connection_handle,
&le_streamer_client,
&le_streamer_connection,
&LE_STREAMER_SERVICE_UUID,
le_streamer_characteristics_storage,
NUM_CHARACTERISTICS);
...
}
}
...
}
static void setup(void){
...
hci_event_callback_registration.callback = &hci_event_handler;
hci_add_event_handler(&hci_event_callback_registration);
...
}
GATT Service Client Queries
If the status of the received GATTSERVICE_SUBEVENT_CLIENT_CONNECTED event is ERROR_CODE_SUCCESS, we can start scheduling service specific GATT Queries if needed. The complete list of supported GATT queries is defined in GATT Client header file (ble/gatt_client.h). From this point on a query will most likely be either read, write or write without response on a specific characteristic of the GATT Service. These queries use a characteristic’s attribute value handle to identify the characteristic. The attribute value handle can be accessed using the gatt_service_client_characteristic_value_handle_for_index function. The index parameter for this function matches the order of the UUIDs stored in the UUID array.
The following code snippet shows how to perform write without response.
GATT Service Client Query
-------------------------
static uint8_t data[200];
static uint16_t data_len;
static void le_streamer_write_without_response(gatt_service_client_connection_t * connection){
uint8_t status = gatt_client_write_value_of_characteristic_without_response(
connection_handle,
gatt_service_client_characteristic_value_handle_for_index(connection, CHARACTERISTIC_INDEX_RX),
data_len, data);
...
}
GATT Service Client Code Reduction
By using the new component to automate the discovery steps of a single characteristic, and by adding some missing consistency checks, we were able to reduce the LE Streamer Service Client code by about 100 lines from the original 542 lines.
Next, we applied the same approach to the standard Scan Parameter Service Client (ble/gatt-service/scan_parameters_service_client.c), which queries and subscribes to two defined Characteristics. In this case, the code was reduced by 278 lines from the original 516 lines.
These results demonstrate code reduction especially for relatively simple services. In addition, the component handles both 16-bit and 128-bit UUIDs, making it equally suitable for developing standard Bluetooth services and custom ones.