05 - BLE GATT Client-Initiated Operations

Author: Tony Fu
Date: 2025/4/6
Device: nRF52840 Dongle
Toolchain: nRF Connect SDK v2.8.0

In the GATT protocol, the server holds the data. The client can request the server to perform operations such as read, write, or write without response — these are known as client-initiated operations. Alternatively, the client can subscribe to notifications or indications, which are server-initiated operations.

This note focuses on client-initiated operations. The server-side implementation will be covered in a later note. We will walk through how to define a custom 128-bit UUID GATT service with both readable and writable characteristics in Zephyr, using the Nordic SDK style.


1. Create a File: my_service.h

This header will define the UUIDs, callback types, and initialization function needed for the custom service.


2. Encode the UUIDs Using BT_UUID_128_ENCODE

Zephyr provides a helper macro to define a 128-bit UUID:

BT_UUID_128_ENCODE(w32, w1, w2, w3, w48)

This macro converts your UUID into little-endian byte order suitable for Zephyr’s internal structures. It's commonly used when defining: - Service UUIDs - Characteristic UUIDs - Advertising UUIDs

Parameters:

Param Size Description
w32 32b First field of the UUID
w1 16b Second field
w2 16b Third field
w3 16b Fourth field
w48 48b Final field (usually vendor part)

Just take your UUID, replace the dashes with commas, and prefix each value with 0x.

Example:

If your 128-bit UUID is:

12345678-9abc-def0-1234-56789abcdef0

You can encode it like:

#define BT_UUID_MY_SERVICE_VAL \
    BT_UUID_128_ENCODE(0x12345678, 0x9abc, 0xdef0, 0x1234, 0x56789abcdef0)

And then define characteristics with similar base UUIDs:

#define BT_UUID_MY_CHAR_READ_VAL \
    BT_UUID_128_ENCODE(0x12345678, 0x9abc, 0xdef0, 0x1234, 0x56789abcdef1)

#define BT_UUID_MY_CHAR_WRITE_VAL \
    BT_UUID_128_ENCODE(0x12345678, 0x9abc, 0xdef0, 0x1234, 0x56789abcdef2)

💡 Naming convention (rule of thumb): - The first few fields can vary by purpose (e.g., 1 service, multiple characteristics). - The final 48 bits are often treated as the vendor-defined base. - No strict rules — just make sure they’re unique.


3. Declare the UUIDs

Encoding a UUID gives you a byte array. To use them in APIs like BT_GATT_PRIMARY_SERVICE() or BT_GATT_CHARACTERISTIC(), you must wrap them with BT_UUID_DECLARE_128():

#define BT_UUID_MY_SERVICE     BT_UUID_DECLARE_128(BT_UUID_MY_SERVICE_VAL)
#define BT_UUID_MY_CHAR_READ   BT_UUID_DECLARE_128(BT_UUID_MY_CHAR_READ_VAL)
#define BT_UUID_MY_CHAR_WRITE  BT_UUID_DECLARE_128(BT_UUID_MY_CHAR_WRITE_VAL)

This declares each UUID as a const struct bt_uuid * that can be used with Zephyr’s GATT API.


4. Create a File: my_service.c

This file implements the callbacks, the internal state, and defines the GATT service.


5. Implement the Read Callback

Include the needed headers:

#include <zephyr/bluetooth/bluetooth.h>
#include <zephyr/bluetooth/gatt.h>

Implement the read callback:

static uint8_t stored_value;

static ssize_t on_read(struct bt_conn *conn, const struct bt_gatt_attr *attr,
                       void *buf, uint16_t len, uint16_t offset)
{
    LOG_INF("Read request received");
    return bt_gatt_attr_read(conn, attr, buf, len, offset, &stored_value, sizeof(stored_value));
}

bt_gatt_attr_read() is a helper that reads from a memory buffer and does bounds checking for you.


6. Implement the Write Callback

static struct my_service_cb service_cb;

static ssize_t on_write(struct bt_conn *conn, const struct bt_gatt_attr *attr,
                        const void *buf, uint16_t len, uint16_t offset, uint8_t flags)
{
    if (offset != 0 || len != 1) {
        return BT_GATT_ERR(BT_ATT_ERR_INVALID_ATTRIBUTE_LEN);
    }

    uint8_t val = *((uint8_t *)buf);
    stored_value = val;

    LOG_INF("New value written: %d", stored_value);

    if (service_cb.on_write) {
        service_cb.on_write(val);
    }

    return len;
}

You can choose to act immediately on the written value, or store it for later use.


7. Define a Callback Struct and Init Function

Define the callback type and storage:

typedef void (*my_write_cb_t)(uint8_t new_value);

struct my_service_cb {
    my_write_cb_t on_write;
};

Then implement a simple init function:

int my_service_init(struct my_service_cb *cb)
{
    if (cb) {
        service_cb = *cb;
    }
    LOG_INF("Custom service initialized");
    return 0;
}

This lets users register custom application logic on writes.


8. Define the GATT Service

Declare your service and characteristics using BT_GATT_SERVICE_DEFINE:

BT_GATT_SERVICE_DEFINE(my_svc,
    BT_GATT_PRIMARY_SERVICE(BT_UUID_MY_SERVICE),

    BT_GATT_CHARACTERISTIC(BT_UUID_MY_CHAR_READ,
                           BT_GATT_CHRC_READ,
                           BT_GATT_PERM_READ,
                           on_read, NULL, &stored_value),

    BT_GATT_CHARACTERISTIC(BT_UUID_MY_CHAR_WRITE,
                           BT_GATT_CHRC_WRITE,
                           BT_GATT_PERM_WRITE,
                           NULL, on_write, NULL)
);

9. Use the Service in main.c

Register your service in main():

#include "my_service.h"

void my_write_handler(uint8_t value)
{
    LOG_INF("Value changed by client to %d", value);
}

void main(void)
{
    bt_enable(NULL);

    struct my_service_cb cb = {
        .on_write = my_write_handler,
    };

    my_service_init(&cb);

    bt_le_adv_start(BT_LE_ADV_CONN, ad, ARRAY_SIZE(ad), sd, ARRAY_SIZE(sd));
}