How to Implement BLE GATT Services from Scratch

You can read the Bluetooth specification cover to cover and still have no idea where to start writing code. The official docs give you 3,000 pages of protocol theory. Stack Overflow gives you fragments that worked on someone else’s chip three years ago. The ESP-IDF examples compile, but they’re 800 lines of abstraction designed to demonstrate everything at once rather than teach you anything.
Here’s the problem: GATT isn’t complicated, but everyone explains it like it is. You need about 150 lines of code to implement a working service. This guide gives you exactly that: a complete Battery Service implementation you can flash to an ESP32 in under an hour. You’ll understand every line.
We’re using ESP-IDF v5.x (not Arduino) because it gives you direct control over the Bluetooth stack. If you have an ESP32 dev board, you can follow along in real time.
GATT Architecture in 60 Seconds
Forget the specification’s language for a moment. If you’ve built REST APIs, you already understand GATT’s structure:
Profile (your API)
└── Service (an endpoint, like /battery)
└── Characteristic (a data field, like "level": 87)
└── Descriptor (metadata, like "notify me when this changes")That’s it. A GATT server exposes services. Services contain characteristics. Characteristics hold your actual data and define what clients can do with it: read, write, or subscribe to notifications.
Every service and characteristic needs a UUID. The Bluetooth SIG pre-defines 16-bit UUIDs for common functions: 0x180F is Battery Service, 0x2A19 is Battery Level. For proprietary data, you generate a 128-bit UUID.
The ATT protocol handles the actual data transfer underneath GATT, but you rarely interact with it directly. Your job is defining the structure and handling events when clients interact with it.
Setting Up Your ESP32 Environment
Prerequisites: ESP-IDF v5.x installed with a working idf.py build workflow. If you’re not there yet, follow Espressif’s official setup guide. No point duplicating it here.
Enable Bluetooth in your project:
idf.py menuconfigNavigate to Component config → Bluetooth and enable:
- Bluetooth
- Bluedroid (the ESP32’s Bluetooth stack)
- GATT Server
Your main/CMakeLists.txt needs:
idf_component_register(SRCS "main.c"
INCLUDE_DIRS "."
REQUIRES bt nvs_flash)Defining Your GATT Service Structure
Here’s the complete attribute table for a Battery Service. This is the core of your implementation:
#include "esp_gatts_api.h"
#include "esp_bt_defs.h"
#define GATTS_NUM_HANDLES 4
enum {
IDX_SVC,
IDX_CHAR_BATT_LEVEL,
IDX_CHAR_BATT_LEVEL_VAL,
IDX_CHAR_BATT_LEVEL_CCCD,
};
static const uint16_t primary_service_uuid = ESP_GATT_UUID_PRI_SERVICE;
static const uint16_t char_decl_uuid = ESP_GATT_UUID_CHAR_DECLARE;
static const uint16_t cccd_uuid = ESP_GATT_UUID_CHAR_CLIENT_CONFIG;
static const uint16_t batt_svc_uuid = 0x180F;
static const uint16_t batt_level_uuid = 0x2A19;
static const uint8_t char_prop_read_notify = ESP_GATT_CHAR_PROP_BIT_READ | ESP_GATT_CHAR_PROP_BIT_NOTIFY;
static uint8_t battery_level = 87;
static uint8_t cccd_value[2] = {0x00, 0x00};
static const esp_gatts_attr_db_t battery_gatt_db[GATTS_NUM_HANDLES] = {
[IDX_SVC] = {{ESP_GATT_AUTO_RSP},
{ESP_UUID_LEN_16, (uint8_t *)&primary_service_uuid, ESP_GATT_PERM_READ,
sizeof(uint16_t), sizeof(batt_svc_uuid), (uint8_t *)&batt_svc_uuid}},
[IDX_CHAR_BATT_LEVEL] = {{ESP_GATT_AUTO_RSP},
{ESP_UUID_LEN_16, (uint8_t *)&char_decl_uuid, ESP_GATT_PERM_READ,
sizeof(uint8_t), sizeof(uint8_t), (uint8_t *)&char_prop_read_notify}},
[IDX_CHAR_BATT_LEVEL_VAL] = {{ESP_GATT_AUTO_RSP},
{ESP_UUID_LEN_16, (uint8_t *)&batt_level_uuid, ESP_GATT_PERM_READ,
sizeof(uint8_t), sizeof(uint8_t), &battery_level}},
[IDX_CHAR_BATT_LEVEL_CCCD] = {{ESP_GATT_AUTO_RSP},
{ESP_UUID_LEN_16, (uint8_t *)&cccd_uuid, ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE,
sizeof(uint16_t), sizeof(cccd_value), cccd_value}},
};The order matters. Each service starts with a service declaration, followed by characteristic declarations, their values, and any descriptors. The CCCD (Client Characteristic Configuration Descriptor) at the end lets clients enable notifications. Forget it, and your notify-capable characteristic silently won’t notify.
ESP_GATT_AUTO_RSP tells the stack to handle read responses automatically using the values you provide. For dynamic data, you’ll switch to manual responses later.
Registering and Initializing the GATT Service
Initialization follows a strict sequence. Here’s the complete startup code:
#include "nvs_flash.h"
#include "esp_bt.h"
#include "esp_bt_main.h"
#include "esp_gap_ble_api.h"
#include "esp_gatts_api.h"
#include "esp_log.h"
#define TAG "BATT_SVC"
#define APP_ID 0
static uint16_t gatt_handles[GATTS_NUM_HANDLES];
static uint16_t conn_id;
static esp_gatt_if_t gatt_if;
void app_main(void) {
esp_err_t ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
nvs_flash_erase();
nvs_flash_init();
}
esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT();
esp_bt_controller_init(&bt_cfg);
esp_bt_controller_enable(ESP_BT_MODE_BLE);
esp_bluedroid_init();
esp_bluedroid_enable();
esp_ble_gatts_register_callback(gatts_event_handler);
esp_ble_gap_register_callback(gap_event_handler);
esp_ble_gatts_app_register(APP_ID);
}You store gatt_handles after service creation. These are the runtime identifiers for each attribute. You’ll need them to send notifications and respond to events.
Handling GATT Events
The event handler is where your service comes alive. Here’s the implementation:
static void gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if,
esp_ble_gatts_cb_param_t *param) {
switch (event) {
case ESP_GATTS_REG_EVT:
gatt_if = gatts_if;
esp_ble_gatts_create_attr_tab(battery_gatt_db, gatts_if,
GATTS_NUM_HANDLES, 0);
// Configure and start advertising here
break;
case ESP_GATTS_CREAT_ATTR_TAB_EVT:
if (param->add_attr_tab.status == ESP_GATT_OK) {
memcpy(gatt_handles, param->add_attr_tab.handles,
sizeof(gatt_handles));
esp_ble_gatts_start_service(gatt_handles[IDX_SVC]);
}
break;
case ESP_GATTS_CONNECT_EVT:
conn_id = param->connect.conn_id;
ESP_LOGI(TAG, "Client connected");
break;
case ESP_GATTS_DISCONNECT_EVT:
ESP_LOGI(TAG, "Client disconnected");
esp_ble_gap_start_advertising(&adv_params); // restart advertising
break;
case ESP_GATTS_WRITE_EVT:
if (param->write.handle == gatt_handles[IDX_CHAR_BATT_LEVEL_CCCD]) {
bool notifications_enabled = (param->write.value[0] == 0x01);
ESP_LOGI(TAG, "Notifications %s",
notifications_enabled ? "enabled" : "disabled");
}
break;
default:
break;
}
}The ESP_GATTS_WRITE_EVT fires when a client writes to the CCCD. A value of 0x01 0x00 means “enable notifications.” Your application logic should track this state per connection. Don’t spam notifications to clients that haven’t subscribed.
For manual read responses (when ESP_GATT_AUTO_RSP isn’t set), you’d handle ESP_GATTS_READ_EVT:
case ESP_GATTS_READ_EVT:
if (param->read.need_rsp) {
esp_gatt_rsp_t rsp = {0};
rsp.attr_value.handle = param->read.handle;
rsp.attr_value.len = 1;
rsp.attr_value.value[0] = get_current_battery_level();
esp_ble_gatts_send_response(gatts_if, param->read.conn_id,
param->read.trans_id, ESP_GATT_OK, &rsp);
}
break;Always check need_rsp before responding. Long reads and certain stack operations don’t expect responses. Sending one anyway causes protocol errors.
Sending Notifications
When your battery level changes, push updates to subscribed clients:
void send_battery_notification(uint8_t level) {
if (cccd_value[0] != 0x01) {
return; // client hasn't enabled notifications
}
esp_ble_gatts_send_indicate(gatt_if, conn_id,
gatt_handles[IDX_CHAR_BATT_LEVEL_VAL],
sizeof(level), &level, false);
}The final false parameter means “notification” (no acknowledgment expected). Set it to true for an indication, which requires the client to confirm receipt. Notifications are faster; indications are reliable. For battery levels, notifications make sense.
Testing with nRF Connect
Download nRF Connect (free, iOS and Android) and verify your implementation:
- Scan — Your device appears with its advertising name
- Connect — Tap the device entry
- Explore — You should see “Battery Service” with UUID
0x180F - Read — Tap the Battery Level characteristic; it should show your value (87%)
- Subscribe — Tap the notification icon; the CCCD write triggers your event handler
If the service doesn’t appear, verify your advertising data includes the service UUID. Check your GAP advertising configuration. That’s a separate topic covered in our BLE advertising deep dive.
Next Steps: Extending Your Implementation
You have a working GATT service. The pattern repeats for any service you build:
- Define attributes in a table
- Register the table with the stack
- Handle events for reads, writes, and connections
- Send notifications when data changes
Try adding a second characteristic to your Battery Service, perhaps a “charging state” boolean. Or implement a custom service with your own 128-bit UUID. The structure is identical; only the UUIDs and data types change.
For production deployments, you’ll want to add security (pairing and bonding), handle multiple simultaneous connections, and implement proper power management. But the GATT foundation you just built stays the same.
The best way to learn is to break things. Change the permissions, remove the CCCD, send notifications without checking subscription state. See what errors the stack gives you. Those error messages will make a lot more sense now that you understand what’s supposed to happen.
Hubble Network’s satellites receive standard BLE GATT advertisements directly from space—no protocol changes needed. See how it works →