A GATT service is a set of attributes that organizes the data and operations of a Bluetooth LE device.
When you connect your smartwatch to your phone, you don’t just see the time. The watch sends your heart rate in real-time, and you can send it configurations or change the watch face from the App. That’s a bidirectional connection based on GATT (Generic Attribute Profile).
In Zephyr, defining this structure is incredibly elegant thanks to its macro system. Let’s build a real example: a Sensor Service that sends temperature data to the phone and allows controlling an LED from the App.
The structure: services and characteristics
We can think of GATT as a database organized into several levels:
Profile: It’s the complete collection (e.g., “Heart Rate Monitor”).
Service: It’s a logical folder (e.g., “Heart Rate Measurement”, “Device Information”).
Characteristic: It’s the file with the data (e.g., “BPM Value”, “Sensor Location”).
Each characteristic has:
- Value: The data itself.
- Properties: Can it be read (
READ)? Can it be written (WRITE)? Does it notify when it changes (NOTIFY)? - Descriptors: Metadata (e.g., the unit of measurement, or the famous CCC to enable notifications).
UUIDs (Identifiers)
Since we are creating our own service (it’s not a standard like the Battery service), we need to generate random 128-bit UUIDs to avoid collisions with anyone else.
You can use an online generator (search for “UUID generator”).
#include <zephyr/kernel.h>
#include <zephyr/bluetooth/bluetooth.h>
#include <zephyr/bluetooth/conn.h>
#include <zephyr/bluetooth/uuid.h>
#include <zephyr/bluetooth/gatt.h>
/* Service UUID: 12345678-1234-5678-1234-56789abcdef0 */
#define MY_SERVICE_UUID_VAL \
BT_UUID_128_ENCODE(0x12345678, 0x1234, 0x5678, 0x1234, 0x56789abcdef0)
/* Temperature Characteristic UUID */
#define MY_CHAR_UUID_VAL \
BT_UUID_128_ENCODE(0x12345678, 0x1234, 0x5678, 0x1234, 0x56789abcdef1)
static struct bt_uuid_128 my_service_uuid = BT_UUID_INIT_128(MY_SERVICE_UUID_VAL);
static struct bt_uuid_128 my_char_uuid = BT_UUID_INIT_128(MY_CHAR_UUID_VAL);Define the service using macros
Zephyr allows declaring this structure statically using the BT_GATT_SERVICE_DEFINE macro.
Let’s define a characteristic that is:
- Readable: The phone can query the value.
- Notifiable: The device pushes the value to the phone when it changes.
/* Variable where we store the actual data */
static uint32_t temperature_value = 0;
/* Read callback: Called when the phone requests to read the data */
static ssize_t read_temperature(struct bt_conn *conn, const struct bt_gatt_attr *attr,
void *buf, uint16_t len, uint16_t offset)
{
/* Send the current value of the variable */
const char *value = (const char *)&temperature_value;
return bt_gatt_attr_read(conn, attr, buf, len, offset, value, sizeof(temperature_value));
}
/* Callback when the notification configuration (CCC) changes */
static void my_ccc_cfg_changed(const struct bt_gatt_attr *attr, uint16_t value)
{
bool notif_enabled = (value == BT_GATT_CCC_NOTIFY);
printk("Notifications %s\n", notif_enabled ? "enabled" : "disabled");
}
/* SERVICE DEFINITION */
BT_GATT_SERVICE_DEFINE(my_sensor_svc,
/* 1. Declare it as a Primary Service */
BT_GATT_PRIMARY_SERVICE(&my_service_uuid.uuid),
/* 2. Define the Characteristic */
/* Parameters: UUID, Properties, Permissions, Read Callback, Write Callback, Initial Value */
BT_GATT_CHARACTERISTIC(&my_char_uuid.uuid,
BT_GATT_CHRC_READ | BT_GATT_CHRC_NOTIFY, /* Properties */
BT_GATT_PERM_READ, /* Permissions (Security) */
read_temperature, NULL, NULL),
/* 3. Define the CCC Descriptor (Client Characteristic Configuration) */
/* MANDATORY if we use NOTIFY or INDICATE */
BT_GATT_CCC(my_ccc_cfg_changed, BT_GATT_PERM_READ | BT_GATT_PERM_WRITE),
);The CCC is essential for notifications. If you set BT_GATT_CHRC_NOTIFY but forget the BT_GATT_CCC macro, the client won’t be able to subscribe to changes.
The main code (main)
Now we need to:
- Initialize Bluetooth.
- Start advertising (as Connectable).
- Simulate temperature changes and Notify.
/* Advertising data (now we include the service UUID so the phone knows what we do) */
static const struct bt_data ad[] = {
BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)),
BT_DATA_BYTES(BT_DATA_UUID128_ALL, MY_SERVICE_UUID_VAL),
};
int main(void)
{
int err;
err = bt_enable(NULL);
if (err) {
printk("Error init (err %d)\n", err);
return err;
}
/* Advertise as CONNECTABLE (BT_LE_ADV_CONN) */
err = bt_le_adv_start(BT_LE_ADV_CONN, ad, ARRAY_SIZE(ad), NULL, 0);
if (err) {
printk("Error advertising (err %d)\n", err);
return err;
}
printk("Ready. Connect with nRF Connect.\n");
while (1) {
k_sleep(K_SECONDS(1));
/* Simulate sensor reading */
temperature_value++;
/* NOTIFY (PUSH) */
/* Find the attribute within the service and send the change */
/* attrs[1] is the declaration and attrs[2] contains the value. */
/* Use bt_gatt_notify(conn, attr, data, len) */
/* If conn is NULL, it notifies ALL connected devices */
bt_gatt_notify(NULL, &my_sensor_svc.attrs[2], &temperature_value,
sizeof(temperature_value));
}
}Test with your phone
- Open nRF Connect.
- Scan and look for your device (you’ll see it advertising a long UUID).
- Tap CONNECT.
- A new tab will open. You’ll see “Generic Access”, “Generic Attribute” and your Unknown Service (with your UUID).
- Tap on the service. You’ll see the characteristic.
- Tap the Download icon (down arrow): you will read the current value.
- Tap the subscribe icon: the value will start updating every second without manual reads.
Receiving data (write)
What if we want to turn on an LED from the phone? We need a characteristic with the WRITE property.
/* Write callback */
static ssize_t write_led(struct bt_conn *conn, const struct bt_gatt_attr *attr,
const void *buf, uint16_t len, uint16_t offset,
uint8_t flags)
{
const uint8_t *val = buf;
if (offset != 0) {
return BT_GATT_ERR(BT_ATT_ERR_INVALID_OFFSET);
}
if (len != 1) {
return BT_GATT_ERR(BT_ATT_ERR_INVALID_ATTRIBUTE_LEN);
}
if (*val == 1) {
printk("LED ON from phone\n");
// gpio_pin_set(...)
} else {
printk("LED OFF from phone\n");
}
return len;
}
/* In the service definition we add: */
BT_GATT_CHARACTERISTIC(&my_led_uuid.uuid,
BT_GATT_CHRC_WRITE,
BT_GATT_PERM_WRITE,
NULL, write_led, NULL),In nRF Connect, you will see an “Up arrow” icon. By tapping it, you can send a byte (01 or 00) and you will see the message in the Zephyr console.