BLE Labs
Prerequisites: You have built and flashed the starter BLE project that advertises a single read-only characteristic (
0xFF01) on service0x00FF.
Hardware: ESP32-C6 dev-board · USB cable · nRF Connect (phone) or any BLE scanner
Framework: ESP-IDF with Bluedroid BLE stack
The Starter Code at a Glance
Before diving in, make sure you understand the moving parts of the base project. The file main.c does the following in order:
- Initialises NVS, the BT controller and Bluedroid — boilerplate that rarely changes.
- Registers callbacks —
gap_event_handler(advertising) andgatts_event_handler(GATT server events). - On
ESP_GATTS_REG_EVT— sets the device name, configures advertising data, and creates the GATT service. - On
ESP_GATTS_CREATE_EVT— starts the service and adds one characteristic (UUID0xFF01) that a phone can read. - On disconnect — restarts advertising so a new client can connect.
The characteristic returns the static string "Hello from ESP32-C6". A client can connect and read it, but cannot write anything back.
Lab 1 — Writable Characteristic + Serial Log Output
Objective
By the end of this lab you will:
- Add a write property to the existing characteristic so a phone can send data to the ESP32.
- Handle the
ESP_GATTS_WRITE_EVTevent inside the GATT server callback. - Print the received bytes to the serial monitor with
ESP_LOGI.
Step 1 — Allow the Characteristic to Be Written
Every BLE characteristic carries a set of properties — small flags that tell a client what operations are permitted. The most common ones are:
| Flag | Meaning |
|---|---|
ESP_GATT_CHAR_PROP_BIT_READ |
Client can read the value |
ESP_GATT_CHAR_PROP_BIT_WRITE |
Client can write with acknowledgement |
ESP_GATT_CHAR_PROP_BIT_NOTIFY |
Server can push updates to the client |
In the starter code the property variable only has READ:
What to change
Open main.c and find the line above (near the top, around line 24). Replace it with:
static esp_gatt_char_prop_t char_property = ESP_GATT_CHAR_PROP_BIT_READ
| ESP_GATT_CHAR_PROP_BIT_WRITE;
This combines both flags using the bitwise OR operator |. Now the characteristic advertises itself as readable and writable.
Step 2 — Grant Write Permission
Properties tell the client what it can do; permissions tell the GATT stack what to actually allow. If you set the property to "writable" but the permission stays "read-only", the BLE stack will reject incoming write requests.
Where to change it
Inside gatts_profile_event_handler, find the ESP_GATTS_CREATE_EVT case. Locate the call to esp_ble_gatts_add_char(...). The third argument is the permission mask:
/* BEFORE */
esp_ble_gatts_add_char(service_handle,
&char_uuid,
ESP_GATT_PERM_READ, // <-- only read
char_property,
&char_val,
&char_control);
Change it to include write permission:
/* AFTER */
esp_ble_gatts_add_char(service_handle,
&char_uuid,
ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE, // read + write
char_property,
&char_val,
&char_control);
Step 3 — Disable Auto-Response for Writes
When auto-response (ESP_GATT_AUTO_RSP) is active, the BLE stack handles read/write requests by itself — it reads from or writes to an internal attribute buffer and immediately sends a response to the client. This is fine for reads, but for writes you usually want to see the data first, log it, validate it, or act on it before the stack replies.
By switching to ESP_GATT_RSP_BY_APP, you take control. You will need to send the response yourself inside the write event handler — but you gain full visibility of every byte the client sends.
Where to change it
Still inside ESP_GATTS_CREATE_EVT, look at the esp_attr_control_t struct just above the add_char call:
Change it to:
Important: Because we turned off auto-response, we must now reply manually in the write handler (next step). If you forget, the client will time out waiting for a response.
Step 4 — Handle the Write Event
Every time a connected client writes to a characteristic, the GATT stack fires ESP_GATTS_WRITE_EVT. The event parameter (param->write) contains:
handle— which attribute was written to.value/len— the raw bytes and their length.need_rsp— whether the client expects an acknowledgement.
We will:
- Print the received data as a string.
- Send back a success response so the client knows the write went through.
Where to add it
Inside gatts_profile_event_handler, add a new case block after the existing ESP_GATTS_ADD_CHAR_EVT case. Place it before the ESP_GATTS_CONNECT_EVT case:
case ESP_GATTS_WRITE_EVT:
ESP_LOGI(TAG, "Write event received, handle = %d", param->write.handle);
if (param->write.len > 0) {
/* Null-terminate so we can safely print as a C string */
char buf[param->write.len + 1];
memcpy(buf, param->write.value, param->write.len);
buf[param->write.len] = '\0';
ESP_LOGI(TAG, "Data received: %s", buf);
}
/* Send a success response if the client is waiting for one */
if (param->write.need_rsp) {
esp_ble_gatts_send_response(gatts_if,
param->write.conn_id,
param->write.trans_id,
ESP_GATT_OK,
NULL);
}
break;
Line-by-line breakdown:
- We log the attribute handle so you can confirm which characteristic was written to (useful later when you have several).
- We copy the raw bytes into a local buffer and add a
'\0'terminator soESP_LOGIcan print it safely. - We call
esp_ble_gatts_send_response(...)withESP_GATT_OKto tell the client "write accepted". If we skip this whenneed_rspis true, the BLE connection stalls.
Step 5 — Build, Flash and Test
- Build and flash as usual:
- Open nRF Connect on your phone, scan, and connect to
ESP32C6_BLE_DEMO. - Find service
0x00FF→ characteristic0xFF01. - Tap the write icon (pencil or up-arrow, depending on app version).
- Type any text — for example
Hola BLE— and send. - In the serial monitor you should see:
Congratulations — your ESP32 can now receive data over Bluetooth!
Lab 1 — Full Final Code
#include <stdio.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "nvs_flash.h"
#include "esp_bt.h"
#include "esp_gap_ble_api.h"
#include "esp_gatts_api.h"
#include "esp_bt_main.h"
#include "esp_gatt_common_api.h"
static const char *TAG = "BLE_DEMO";
#define DEVICE_NAME "ESP32C6_BLE_DEMO"
#define GATTS_SERVICE_UUID_TEST 0x00FF
#define GATTS_CHAR_UUID_TEST 0xFF01
#define GATTS_NUM_HANDLE_TEST 4
#define PROFILE_NUM 1
#define PROFILE_APP_IDX 0
#define ESP_APP_ID 0x55
static const char char_value[] = "Hello from ESP32-C6";
static uint16_t service_handle;
/* ── CHANGED: added WRITE property ── */
static esp_gatt_char_prop_t char_property = ESP_GATT_CHAR_PROP_BIT_READ
| ESP_GATT_CHAR_PROP_BIT_WRITE;
static uint16_t char_handle;
static esp_gatt_if_t gatts_if_for_profile = 0;
static esp_ble_adv_data_t adv_data = {
.set_scan_rsp = false,
.include_name = true,
.include_txpower = false,
.min_interval = 0x20,
.max_interval = 0x40,
.appearance = 0x00,
.manufacturer_len = 0,
.p_manufacturer_data = NULL,
.service_data_len = 0,
.p_service_data = NULL,
.service_uuid_len = 0,
.p_service_uuid = NULL,
.flag = ESP_BLE_ADV_FLAG_GEN_DISC | ESP_BLE_ADV_FLAG_BREDR_NOT_SPT,
};
static esp_ble_adv_params_t adv_params = {
.adv_int_min = 0x20,
.adv_int_max = 0x40,
.adv_type = ADV_TYPE_IND,
.own_addr_type = BLE_ADDR_TYPE_PUBLIC,
.channel_map = ADV_CHNL_ALL,
.adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY,
};
/* ====================== GAP callback ====================== */
static void gap_event_handler(esp_gap_ble_cb_event_t event,
esp_ble_gap_cb_param_t *param)
{
switch (event) {
case ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT:
esp_ble_gap_start_advertising(&adv_params);
break;
case ESP_GAP_BLE_ADV_START_COMPLETE_EVT:
if (param->adv_start_cmpl.status == ESP_BT_STATUS_SUCCESS) {
ESP_LOGI(TAG, "Advertising started successfully");
} else {
ESP_LOGE(TAG, "Advertising start failed");
}
break;
default:
break;
}
}
/* ====================== GATTS profile callback ====================== */
static void gatts_profile_event_handler(esp_gatts_cb_event_t event,
esp_gatt_if_t gatts_if,
esp_ble_gatts_cb_param_t *param)
{
switch (event) {
/* ---------- Registration ---------- */
case ESP_GATTS_REG_EVT:
ESP_LOGI(TAG, "GATT server registered");
gatts_if_for_profile = gatts_if;
esp_ble_gap_set_device_name(DEVICE_NAME);
esp_ble_gap_config_adv_data(&adv_data);
esp_gatt_srvc_id_t service_id = {
.is_primary = true,
.id.inst_id = 0x00,
.id.uuid.len = ESP_UUID_LEN_16,
.id.uuid.uuid.uuid16 = GATTS_SERVICE_UUID_TEST,
};
esp_ble_gatts_create_service(gatts_if, &service_id, GATTS_NUM_HANDLE_TEST);
break;
/* ---------- Service created ---------- */
case ESP_GATTS_CREATE_EVT:
ESP_LOGI(TAG, "Service created");
service_handle = param->create.service_handle;
esp_ble_gatts_start_service(service_handle);
esp_bt_uuid_t char_uuid = {
.len = ESP_UUID_LEN_16,
.uuid.uuid16 = GATTS_CHAR_UUID_TEST,
};
esp_attr_value_t char_val = {
.attr_max_len = sizeof(char_value),
.attr_len = strlen(char_value),
.attr_value = (uint8_t *)char_value,
};
/* ── CHANGED: manual response so we see writes ── */
esp_attr_control_t char_control = {
.auto_rsp = ESP_GATT_RSP_BY_APP,
};
/* ── CHANGED: added WRITE permission ── */
esp_ble_gatts_add_char(service_handle,
&char_uuid,
ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE,
char_property,
&char_val,
&char_control);
break;
/* ---------- Characteristic added ---------- */
case ESP_GATTS_ADD_CHAR_EVT:
ESP_LOGI(TAG, "Characteristic added");
char_handle = param->add_char.attr_handle;
break;
/* ── NEW: handle incoming writes ── */
case ESP_GATTS_WRITE_EVT:
ESP_LOGI(TAG, "Write event received, handle = %d", param->write.handle);
if (param->write.len > 0) {
char buf[param->write.len + 1];
memcpy(buf, param->write.value, param->write.len);
buf[param->write.len] = '\0';
ESP_LOGI(TAG, "Data received: %s", buf);
}
if (param->write.need_rsp) {
esp_ble_gatts_send_response(gatts_if,
param->write.conn_id,
param->write.trans_id,
ESP_GATT_OK,
NULL);
}
break;
/* ---------- Connection / Disconnection ---------- */
case ESP_GATTS_CONNECT_EVT:
ESP_LOGI(TAG, "Central connected");
break;
case ESP_GATTS_DISCONNECT_EVT:
ESP_LOGI(TAG, "Central disconnected, restarting advertising");
esp_ble_gap_start_advertising(&adv_params);
break;
default:
break;
}
}
/* ====================== GATTS dispatcher ====================== */
static void gatts_event_handler(esp_gatts_cb_event_t event,
esp_gatt_if_t gatts_if,
esp_ble_gatts_cb_param_t *param)
{
if (event == ESP_GATTS_REG_EVT) {
if (param->reg.status == ESP_GATT_OK) {
gatts_if_for_profile = gatts_if;
} else {
ESP_LOGE(TAG, "GATT app register failed: %d", param->reg.status);
return;
}
}
gatts_profile_event_handler(event, gatts_if, param);
}
/* ====================== Entry point ====================== */
void app_main(void)
{
esp_err_t ret;
ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES ||
ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
ret = nvs_flash_init();
}
ESP_ERROR_CHECK(ret);
ESP_ERROR_CHECK(esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT));
esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_bt_controller_init(&bt_cfg));
ESP_ERROR_CHECK(esp_bt_controller_enable(ESP_BT_MODE_BLE));
ESP_ERROR_CHECK(esp_bluedroid_init());
ESP_ERROR_CHECK(esp_bluedroid_enable());
ESP_ERROR_CHECK(esp_ble_gap_register_callback(gap_event_handler));
ESP_ERROR_CHECK(esp_ble_gatts_register_callback(gatts_event_handler));
ESP_ERROR_CHECK(esp_ble_gatts_app_register(ESP_APP_ID));
ESP_ERROR_CHECK(esp_ble_gatt_set_local_mtu(500));
ESP_LOGI(TAG, "BLE initialization complete");
}
Exercise 1.1 — Echo the Write Back on Read
Goal: Whatever the client writes is stored and returned on the next read, replacing the original "Hello from ESP32-C6".
Steps:
- Declare a global
charbuffer (e.g. 128 bytes) and a length variable at the top ofmain.c. Initialise the buffer with the original default string so reads work even before the first write. - In your
ESP_GATTS_WRITE_EVThandler, after printing the received data, copy the incoming bytes and their length into your global buffer. - Add a new
case ESP_GATTS_READ_EVT:ingatts_profile_event_handler. Inside it, build anesp_gatt_rsp_t, fill itsattr_valuefields with the contents of your global buffer, and callesp_ble_gatts_send_responsewithESP_GATT_OK. - Build, flash, and test: connect with nRF Connect, read the characteristic (you should see the default string), write
"Ping", then read again — the response should now be"Ping".
Exercise 1.2 — LED Control via BLE
Goal: Send "ON" or "OFF" from nRF Connect to toggle a GPIO and log the LED state.
Steps:
- Include the GPIO driver header (
driver/gpio.h) at the top ofmain.c. - Define a macro for the LED pin number. Check your board's schematic for the on-board LED or choose any free GPIO.
- In
app_main, before the BLE initialisation block, reset the pin, set its direction to output, and set its initial level to 0 (off). - Inside the
ESP_GATTS_WRITE_EVThandler, after building the null-terminated buffer, compare the string to"ON"and"OFF"usingstrcmp. Callgpio_set_levelwith 1 or 0 accordingly, and log the current state. For any other string, log a warning with the unrecognised command. - Build, flash, and test: write
"ON"— the LED lights up and the log confirms it. Write"OFF"— the LED turns off. Write anything else — the log prints an unknown-command warning.
Exercise 1.3 — Write-Length Guard
Goal: Reject writes longer than 64 bytes with an appropriate BLE error code.
Steps:
- At the very beginning of your
ESP_GATTS_WRITE_EVThandler, before any data processing, check whetherparam->write.lenexceeds 64. - If it does, log a warning with the rejected length, send a response using
ESP_GATT_INVALID_ATTR_LENas the status (instead ofESP_GATT_OK), andbreakout of the case immediately so the data is never copied or printed. - Make sure this length check sits above all the existing write-handling code so that oversized writes are caught first.
- Build, flash, and test: write a short string — it should succeed as before. Write a string longer than 64 characters — nRF Connect should display an error, and the serial monitor should show only the rejection warning with no data printout.
---
Lab 2 — Multiple Services + Multiple Characteristics
Objective
By the end of this lab you will:
- Create two separate GATT services, each with its own UUID.
- Add multiple characteristics to each service.
- Route write events to the correct handler by checking the attribute handle.
We will build a device that exposes:
| Service | UUID | Characteristic | UUID | Properties |
|---|---|---|---|---|
| Device Info | 0xAA00 |
Firmware Version (read) | 0xAA01 |
READ |
| Device Name (read + write) | 0xAA02 |
READ · WRITE | ||
| Sensor | 0xBB00 |
Temperature (read) | 0xBB01 |
READ |
| Command (write) | 0xBB02 |
WRITE |
Concept — How ESP-IDF Manages Multiple Services
In the Bluedroid stack each service goes through the same lifecycle: create → start → add characteristics. The trick is that you cannot create the second service until the first one has finished being set up. The event flow looks like this:
REG_EVT → create service A
CREATE_EVT (A) → start A, add char A1
ADD_CHAR_EVT (A1) → add char A2
ADD_CHAR_EVT (A2) → create service B ← chain to next service
CREATE_EVT (B) → start B, add char B1
ADD_CHAR_EVT (B1) → add char B2
ADD_CHAR_EVT (B2) → done!
We will use a simple state counter to keep track of where we are in this chain.
Step 1 — Define UUIDs and Handles
Where to change it
Replace the old UUID defines at the top of main.c with the full set of new UUIDs. Also increase the handle count and add storage variables for every handle.
Remove these old defines:
#define GATTS_SERVICE_UUID_TEST 0x00FF
#define GATTS_CHAR_UUID_TEST 0xFF01
#define GATTS_NUM_HANDLE_TEST 4
Add the following in their place:
/* ── Service A: Device Info ── */
#define SVC_A_UUID 0xAA00
#define CHAR_A1_UUID 0xAA01 /* Firmware Version (R) */
#define CHAR_A2_UUID 0xAA02 /* Device Name (R/W) */
#define SVC_A_NUM_HANDLES 6 /* 1 svc + 2 chars × (decl + value) + margin */
/* ── Service B: Sensor ── */
#define SVC_B_UUID 0xBB00
#define CHAR_B1_UUID 0xBB01 /* Temperature (R) */
#define CHAR_B2_UUID 0xBB02 /* Command (W) */
#define SVC_B_NUM_HANDLES 6
Why 6 handles? Each characteristic needs at least two GATT attribute handles (one for the declaration, one for the value). A service itself uses one handle. With two characteristics:
1 + 2×2 = 5. We round up to 6 for safety.
Now replace the old handle variables:
/* Remove these old variables */
// static uint16_t service_handle;
// static esp_gatt_char_prop_t char_property = ...;
// static uint16_t char_handle;
/* Add these new ones */
static uint16_t svc_a_handle;
static uint16_t char_a1_handle;
static uint16_t char_a2_handle;
static uint16_t svc_b_handle;
static uint16_t char_b1_handle;
static uint16_t char_b2_handle;
/* State counter to chain service/char creation */
static int setup_step = 0;
Also replace the old char_value string with initial values for each characteristic:
static const char firmware_version[] = "1.0.0";
static char device_name[32] = "ESP32C6_BLE_DEMO";
static char temperature[8] = "25.0";
Step 2 — Helper Functions to Reduce Repetition
Creating services and characteristics involves the same boilerplate every time. Let's write two small helpers that keep the event handler readable.
Add these above gatts_profile_event_handler:
/* ---------- helper: create a 16-bit service ---------- */
static void create_service(esp_gatt_if_t gif, uint16_t uuid, uint16_t num_handles)
{
esp_gatt_srvc_id_t id = {
.is_primary = true,
.id.inst_id = 0x00,
.id.uuid.len = ESP_UUID_LEN_16,
.id.uuid.uuid.uuid16 = uuid,
};
esp_ble_gatts_create_service(gif, &id, num_handles);
}
/* ---------- helper: add a 16-bit characteristic ---------- */
static void add_char(uint16_t svc_handle, uint16_t uuid,
esp_gatt_perm_t perm, esp_gatt_char_prop_t prop,
uint8_t *value, uint16_t val_len, uint16_t max_len,
bool auto_rsp)
{
esp_bt_uuid_t cuuid = {
.len = ESP_UUID_LEN_16,
.uuid.uuid16 = uuid,
};
esp_attr_value_t val = {
.attr_max_len = max_len,
.attr_len = val_len,
.attr_value = value,
};
esp_attr_control_t ctrl = {
.auto_rsp = auto_rsp ? ESP_GATT_AUTO_RSP : ESP_GATT_RSP_BY_APP,
};
esp_ble_gatts_add_char(svc_handle, &cuuid, perm, prop, &val, &ctrl);
}
Step 3 — Rewrite the GATTS Profile Handler
This is the core of the lab. Replace the entire gatts_profile_event_handler function with the version below. Read the comments carefully — the setup_step counter is what chains one creation event into the next.
static void gatts_profile_event_handler(esp_gatts_cb_event_t event,
esp_gatt_if_t gatts_if,
esp_ble_gatts_cb_param_t *param)
{
switch (event) {
/* ========== 1. Registration: kick off the chain ========== */
case ESP_GATTS_REG_EVT:
ESP_LOGI(TAG, "GATT server registered");
gatts_if_for_profile = gatts_if;
esp_ble_gap_set_device_name(DEVICE_NAME);
esp_ble_gap_config_adv_data(&adv_data);
/* Start the chain: create Service A */
create_service(gatts_if, SVC_A_UUID, SVC_A_NUM_HANDLES);
break;
/* ========== 2. Service created ========== */
case ESP_GATTS_CREATE_EVT: {
uint16_t handle = param->create.service_handle;
esp_ble_gatts_start_service(handle);
if (param->create.service_id.id.uuid.uuid.uuid16 == SVC_A_UUID) {
ESP_LOGI(TAG, "Service A (Device Info) created");
svc_a_handle = handle;
/* Add first char of Service A: Firmware Version (read-only, auto-rsp) */
add_char(svc_a_handle, CHAR_A1_UUID,
ESP_GATT_PERM_READ,
ESP_GATT_CHAR_PROP_BIT_READ,
(uint8_t *)firmware_version, strlen(firmware_version),
sizeof(firmware_version), true);
} else if (param->create.service_id.id.uuid.uuid.uuid16 == SVC_B_UUID) {
ESP_LOGI(TAG, "Service B (Sensor) created");
svc_b_handle = handle;
/* Add first char of Service B: Temperature (read-only, auto-rsp) */
add_char(svc_b_handle, CHAR_B1_UUID,
ESP_GATT_PERM_READ,
ESP_GATT_CHAR_PROP_BIT_READ,
(uint8_t *)temperature, strlen(temperature),
sizeof(temperature), true);
}
break;
}
/* ========== 3. Characteristic added — chain to next ========== */
case ESP_GATTS_ADD_CHAR_EVT: {
setup_step++;
ESP_LOGI(TAG, "Characteristic added (step %d), handle = %d",
setup_step, param->add_char.attr_handle);
switch (setup_step) {
case 1:
/* A1 done → save handle, add A2 (Device Name, R/W, manual rsp) */
char_a1_handle = param->add_char.attr_handle;
add_char(svc_a_handle, CHAR_A2_UUID,
ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE,
ESP_GATT_CHAR_PROP_BIT_READ | ESP_GATT_CHAR_PROP_BIT_WRITE,
(uint8_t *)device_name, strlen(device_name),
sizeof(device_name), false);
break;
case 2:
/* A2 done → save handle, create Service B */
char_a2_handle = param->add_char.attr_handle;
create_service(gatts_if_for_profile, SVC_B_UUID, SVC_B_NUM_HANDLES);
break;
case 3:
/* B1 done → save handle, add B2 (Command, write-only, manual rsp) */
char_b1_handle = param->add_char.attr_handle;
add_char(svc_b_handle, CHAR_B2_UUID,
ESP_GATT_PERM_WRITE,
ESP_GATT_CHAR_PROP_BIT_WRITE,
NULL, 0, 64, false);
break;
case 4:
/* B2 done → all set up! */
char_b2_handle = param->add_char.attr_handle;
ESP_LOGI(TAG, "All services and characteristics ready");
break;
}
break;
}
/* ========== 4. Handle writes ========== */
case ESP_GATTS_WRITE_EVT: {
uint16_t handle = param->write.handle;
/* Null-terminate the incoming data for safe printing */
char buf[param->write.len + 1];
memcpy(buf, param->write.value, param->write.len);
buf[param->write.len] = '\0';
if (handle == char_a2_handle) {
/* Device Name characteristic written */
strncpy(device_name, buf, sizeof(device_name) - 1);
ESP_LOGI(TAG, "[Device Info] Name updated to: %s", device_name);
} else if (handle == char_b2_handle) {
/* Command characteristic written */
ESP_LOGI(TAG, "[Sensor] Command received: %s", buf);
} else {
ESP_LOGW(TAG, "Write to unknown handle %d", handle);
}
if (param->write.need_rsp) {
esp_ble_gatts_send_response(gatts_if,
param->write.conn_id,
param->write.trans_id,
ESP_GATT_OK, NULL);
}
break;
}
/* ========== 5. Read requests (manual response for non-auto chars) ========== */
case ESP_GATTS_READ_EVT: {
if (param->read.handle == char_a2_handle) {
esp_gatt_rsp_t rsp = {0};
rsp.attr_value.handle = param->read.handle;
rsp.attr_value.len = strlen(device_name);
memcpy(rsp.attr_value.value, device_name, rsp.attr_value.len);
esp_ble_gatts_send_response(gatts_if,
param->read.conn_id,
param->read.trans_id,
ESP_GATT_OK, &rsp);
}
break;
}
/* ========== 6. Connection / Disconnection ========== */
case ESP_GATTS_CONNECT_EVT:
ESP_LOGI(TAG, "Central connected");
break;
case ESP_GATTS_DISCONNECT_EVT:
ESP_LOGI(TAG, "Central disconnected, restarting advertising");
esp_ble_gap_start_advertising(&adv_params);
break;
default:
break;
}
}
Key points to notice
ESP_GATTS_CREATE_EVTchecks which service UUID triggered the event. This is how we know whether to add Service A's or Service B's characteristics.ESP_GATTS_ADD_CHAR_EVTusessetup_stepas a simple state machine. Each step saves the handle of the characteristic that was just created and then kicks off the next creation.ESP_GATTS_WRITE_EVTcomparesparam->write.handleagainst our saved handles to know which characteristic the client wrote to.ESP_GATTS_READ_EVTis only needed for characteristics whereauto_rspis false. Firmware Version and Temperature useauto_rsp = true, so the stack handles their reads automatically.
Step 4 — Build, Flash and Test
idf.py build flash monitor- In nRF Connect, connect to the device. You should now see two services:
0xAA00with two characteristics (0xAA01and0xAA02)0xBB00with two characteristics (0xBB01and0xBB02)- Read
0xAA01— you should get1.0.0. - Write
MyNewNameto0xAA02, then read it back — it should returnMyNewName. - Write
GET_TEMPto0xBB02and check the serial monitor for the log.
Lab 2 — Full Final Code
#include <stdio.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "nvs_flash.h"
#include "esp_bt.h"
#include "esp_gap_ble_api.h"
#include "esp_gatts_api.h"
#include "esp_bt_main.h"
#include "esp_gatt_common_api.h"
static const char *TAG = "BLE_DEMO";
#define DEVICE_NAME "ESP32C6_BLE_DEMO"
#define PROFILE_NUM 1
#define PROFILE_APP_IDX 0
#define ESP_APP_ID 0x55
/* ── Service A: Device Info ── */
#define SVC_A_UUID 0xAA00
#define CHAR_A1_UUID 0xAA01
#define CHAR_A2_UUID 0xAA02
#define SVC_A_NUM_HANDLES 6
/* ── Service B: Sensor ── */
#define SVC_B_UUID 0xBB00
#define CHAR_B1_UUID 0xBB01
#define CHAR_B2_UUID 0xBB02
#define SVC_B_NUM_HANDLES 6
/* ── Default values ── */
static const char firmware_version[] = "1.0.0";
static char device_name[32] = "ESP32C6_BLE_DEMO";
static char temperature[8] = "25.0";
/* ── Handles ── */
static uint16_t svc_a_handle;
static uint16_t char_a1_handle;
static uint16_t char_a2_handle;
static uint16_t svc_b_handle;
static uint16_t char_b1_handle;
static uint16_t char_b2_handle;
static int setup_step = 0;
static esp_gatt_if_t gatts_if_for_profile = 0;
/* ── Advertising ── */
static esp_ble_adv_data_t adv_data = {
.set_scan_rsp = false,
.include_name = true,
.include_txpower = false,
.min_interval = 0x20,
.max_interval = 0x40,
.appearance = 0x00,
.manufacturer_len = 0,
.p_manufacturer_data = NULL,
.service_data_len = 0,
.p_service_data = NULL,
.service_uuid_len = 0,
.p_service_uuid = NULL,
.flag = ESP_BLE_ADV_FLAG_GEN_DISC | ESP_BLE_ADV_FLAG_BREDR_NOT_SPT,
};
static esp_ble_adv_params_t adv_params = {
.adv_int_min = 0x20,
.adv_int_max = 0x40,
.adv_type = ADV_TYPE_IND,
.own_addr_type = BLE_ADDR_TYPE_PUBLIC,
.channel_map = ADV_CHNL_ALL,
.adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY,
};
/* ====================== GAP callback ====================== */
static void gap_event_handler(esp_gap_ble_cb_event_t event,
esp_ble_gap_cb_param_t *param)
{
switch (event) {
case ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT:
esp_ble_gap_start_advertising(&adv_params);
break;
case ESP_GAP_BLE_ADV_START_COMPLETE_EVT:
if (param->adv_start_cmpl.status == ESP_BT_STATUS_SUCCESS) {
ESP_LOGI(TAG, "Advertising started successfully");
} else {
ESP_LOGE(TAG, "Advertising start failed");
}
break;
default:
break;
}
}
/* ====================== Helpers ====================== */
static void create_service(esp_gatt_if_t gif, uint16_t uuid, uint16_t num_handles)
{
esp_gatt_srvc_id_t id = {
.is_primary = true,
.id.inst_id = 0x00,
.id.uuid.len = ESP_UUID_LEN_16,
.id.uuid.uuid.uuid16 = uuid,
};
esp_ble_gatts_create_service(gif, &id, num_handles);
}
static void add_char(uint16_t svc_handle, uint16_t uuid,
esp_gatt_perm_t perm, esp_gatt_char_prop_t prop,
uint8_t *value, uint16_t val_len, uint16_t max_len,
bool auto_rsp)
{
esp_bt_uuid_t cuuid = {
.len = ESP_UUID_LEN_16,
.uuid.uuid16 = uuid,
};
esp_attr_value_t val = {
.attr_max_len = max_len,
.attr_len = val_len,
.attr_value = value,
};
esp_attr_control_t ctrl = {
.auto_rsp = auto_rsp ? ESP_GATT_AUTO_RSP : ESP_GATT_RSP_BY_APP,
};
esp_ble_gatts_add_char(svc_handle, &cuuid, perm, prop, &val, &ctrl);
}
/* ====================== GATTS profile callback ====================== */
static void gatts_profile_event_handler(esp_gatts_cb_event_t event,
esp_gatt_if_t gatts_if,
esp_ble_gatts_cb_param_t *param)
{
switch (event) {
/* ---------- Registration ---------- */
case ESP_GATTS_REG_EVT:
ESP_LOGI(TAG, "GATT server registered");
gatts_if_for_profile = gatts_if;
esp_ble_gap_set_device_name(DEVICE_NAME);
esp_ble_gap_config_adv_data(&adv_data);
create_service(gatts_if, SVC_A_UUID, SVC_A_NUM_HANDLES);
break;
/* ---------- Service created ---------- */
case ESP_GATTS_CREATE_EVT: {
uint16_t handle = param->create.service_handle;
esp_ble_gatts_start_service(handle);
if (param->create.service_id.id.uuid.uuid.uuid16 == SVC_A_UUID) {
ESP_LOGI(TAG, "Service A (Device Info) created");
svc_a_handle = handle;
add_char(svc_a_handle, CHAR_A1_UUID,
ESP_GATT_PERM_READ,
ESP_GATT_CHAR_PROP_BIT_READ,
(uint8_t *)firmware_version, strlen(firmware_version),
sizeof(firmware_version), true);
} else if (param->create.service_id.id.uuid.uuid.uuid16 == SVC_B_UUID) {
ESP_LOGI(TAG, "Service B (Sensor) created");
svc_b_handle = handle;
add_char(svc_b_handle, CHAR_B1_UUID,
ESP_GATT_PERM_READ,
ESP_GATT_CHAR_PROP_BIT_READ,
(uint8_t *)temperature, strlen(temperature),
sizeof(temperature), true);
}
break;
}
/* ---------- Characteristic added — chain to next ---------- */
case ESP_GATTS_ADD_CHAR_EVT: {
setup_step++;
ESP_LOGI(TAG, "Characteristic added (step %d), handle = %d",
setup_step, param->add_char.attr_handle);
switch (setup_step) {
case 1:
char_a1_handle = param->add_char.attr_handle;
add_char(svc_a_handle, CHAR_A2_UUID,
ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE,
ESP_GATT_CHAR_PROP_BIT_READ | ESP_GATT_CHAR_PROP_BIT_WRITE,
(uint8_t *)device_name, strlen(device_name),
sizeof(device_name), false);
break;
case 2:
char_a2_handle = param->add_char.attr_handle;
create_service(gatts_if_for_profile, SVC_B_UUID, SVC_B_NUM_HANDLES);
break;
case 3:
char_b1_handle = param->add_char.attr_handle;
add_char(svc_b_handle, CHAR_B2_UUID,
ESP_GATT_PERM_WRITE,
ESP_GATT_CHAR_PROP_BIT_WRITE,
NULL, 0, 64, false);
break;
case 4:
char_b2_handle = param->add_char.attr_handle;
ESP_LOGI(TAG, "All services and characteristics ready");
break;
}
break;
}
/* ---------- Write events ---------- */
case ESP_GATTS_WRITE_EVT: {
uint16_t handle = param->write.handle;
char buf[param->write.len + 1];
memcpy(buf, param->write.value, param->write.len);
buf[param->write.len] = '\0';
if (handle == char_a2_handle) {
strncpy(device_name, buf, sizeof(device_name) - 1);
ESP_LOGI(TAG, "[Device Info] Name updated to: %s", device_name);
} else if (handle == char_b2_handle) {
ESP_LOGI(TAG, "[Sensor] Command received: %s", buf);
} else {
ESP_LOGW(TAG, "Write to unknown handle %d", handle);
}
if (param->write.need_rsp) {
esp_ble_gatts_send_response(gatts_if,
param->write.conn_id,
param->write.trans_id,
ESP_GATT_OK, NULL);
}
break;
}
/* ---------- Read events (manual response chars only) ---------- */
case ESP_GATTS_READ_EVT: {
if (param->read.handle == char_a2_handle) {
esp_gatt_rsp_t rsp = {0};
rsp.attr_value.handle = param->read.handle;
rsp.attr_value.len = strlen(device_name);
memcpy(rsp.attr_value.value, device_name, rsp.attr_value.len);
esp_ble_gatts_send_response(gatts_if,
param->read.conn_id,
param->read.trans_id,
ESP_GATT_OK, &rsp);
}
break;
}
/* ---------- Connection / Disconnection ---------- */
case ESP_GATTS_CONNECT_EVT:
ESP_LOGI(TAG, "Central connected");
break;
case ESP_GATTS_DISCONNECT_EVT:
ESP_LOGI(TAG, "Central disconnected, restarting advertising");
esp_ble_gap_start_advertising(&adv_params);
break;
default:
break;
}
}
/* ====================== GATTS dispatcher ====================== */
static void gatts_event_handler(esp_gatts_cb_event_t event,
esp_gatt_if_t gatts_if,
esp_ble_gatts_cb_param_t *param)
{
if (event == ESP_GATTS_REG_EVT) {
if (param->reg.status == ESP_GATT_OK) {
gatts_if_for_profile = gatts_if;
} else {
ESP_LOGE(TAG, "GATT app register failed: %d", param->reg.status);
return;
}
}
gatts_profile_event_handler(event, gatts_if, param);
}
/* ====================== Entry point ====================== */
void app_main(void)
{
esp_err_t ret;
ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES ||
ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
ret = nvs_flash_init();
}
ESP_ERROR_CHECK(ret);
ESP_ERROR_CHECK(esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT));
esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_bt_controller_init(&bt_cfg));
ESP_ERROR_CHECK(esp_bt_controller_enable(ESP_BT_MODE_BLE));
ESP_ERROR_CHECK(esp_bluedroid_init());
ESP_ERROR_CHECK(esp_bluedroid_enable());
ESP_ERROR_CHECK(esp_ble_gap_register_callback(gap_event_handler));
ESP_ERROR_CHECK(esp_ble_gatts_register_callback(gatts_event_handler));
ESP_ERROR_CHECK(esp_ble_gatts_app_register(ESP_APP_ID));
ESP_ERROR_CHECK(esp_ble_gatt_set_local_mtu(500));
ESP_LOGI(TAG, "BLE initialization complete");
}
Lab 2 — Exercises
Exercise 2.1 — Add a Third Service
Goal: Add a new service 0xCC00 with a single read/write characteristic 0xCC01 that stores a 4-character PIN code (default "0000").
Steps:
- Define the new service UUID (
0xCC00), characteristic UUID (0xCC01), and handle count at the top of the file, following the same pattern as Services A and B. - Declare global variables for the new service handle, characteristic handle, and a
chararray to hold the PIN (initialised to"0000"). - In
ESP_GATTS_ADD_CHAR_EVT, extend thesetup_stepchain: after the last step of Service B finishes (currently step 4), callcreate_servicefor Service C instead of printing "all ready". - Add a new branch in
ESP_GATTS_CREATE_EVTthat checks for your new service UUID, saves its handle, starts it, and adds the PIN characteristic with read and write permissions and manual response. - Add a new step in the
setup_stepswitch that saves the PIN characteristic handle and prints the "all ready" message. - In
ESP_GATTS_WRITE_EVT, add a branch that checks for the new characteristic handle and copies the incoming value into your PIN buffer. - In
ESP_GATTS_READ_EVT, add a branch that responds with the current PIN value. - Build, flash, and verify with nRF Connect that all three services appear and that you can read and write the PIN.
Exercise 2.2 — Notify on Temperature Change
Goal: When the Command characteristic receives "UPDATE", simulate a temperature change and push a notification to the client.
Steps:
- Add
ESP_GATT_CHAR_PROP_BIT_NOTIFYto the property flags of the Temperature characteristic (0xBB01) in itsadd_charcall. - After adding the Temperature characteristic, add a Client Characteristic Configuration Descriptor (CCCD) to it using
esp_ble_gatts_add_char_descrwith UUID0x2902and read/write permissions. You will need to handleESP_GATTS_ADD_CHAR_DESCR_EVTand save the descriptor handle. - In
ESP_GATTS_WRITE_EVT, add a check for writes to the CCCD handle — the client writes a 2-byte value to enable or disable notifications. Store this flag in a global variable. - Still in
ESP_GATTS_WRITE_EVT, when the Command characteristic (0xBB02) receives the string"UPDATE", increment the temperature value (e.g. by 0.5), format it into the temperature buffer, and — if notifications are enabled — callesp_ble_gatts_send_indicatewithneed_confirm = falseto push the new value. - Adjust the
setup_stepchain to account for the extra descriptor-creation event between characteristic additions. - Build, flash, and test: enable notifications on
0xBB01in nRF Connect, then write"UPDATE"to0xBB02— the temperature value should appear automatically on the client without a manual read.
Exercise 2.3 — Service UUID in Advertising Data
Goal: Include both service UUIDs in the advertising packet so scanners can filter by service before connecting.
Steps:
- Create a static
uint8_tarray containing both 16-bit UUIDs in little-endian byte order (low byte first). For0xAA00and0xBB00this means four bytes total. - In the
adv_datastruct, setp_service_uuidto point to your array andservice_uuid_lento the array's size. - Build, flash, and scan with nRF Connect. Verify that the advertising data now lists both service UUIDs in the scan result, even before connecting.
- Try filtering the scan in nRF Connect by one of the UUIDs — only your device should appear.
Exercise 2.4 — Refactor with a Profile Table
Goal: Replace the hard-coded setup_step state machine with a data-driven profile table that scales to any number of services and characteristics.
Steps:
- Define a struct type that describes a single characteristic: its UUID, permissions, properties, default value pointer, max length, and whether it uses auto-response. Add a field to store the assigned handle after creation.
- Define a second struct type that describes a service: its UUID, handle count, a pointer to an array of characteristic structs, and the number of characteristics. Add a field for the service handle.
- Create a static array of service structs that fully describes Services A and B (and C, if you completed Exercise 2.1).
- Rewrite
ESP_GATTS_CREATE_EVTto look up the current service in the table by UUID and calladd_charfor its first characteristic. - Rewrite
ESP_GATTS_ADD_CHAR_EVTto walk through the table: save the handle into the current characteristic's struct, then either add the next characteristic in the same service or create the next service. - Rewrite the
ESP_GATTS_WRITE_EVTandESP_GATTS_READ_EVThandlers to loop through the table and match by handle instead of using a chain ofif/elsecomparisons. - Study Espressif's official
gatts_table_creat_demoexample for inspiration — it uses a similar table-driven approach. - Build, flash, and confirm that behaviour is identical to the hard-coded version.