Skip to content

BLE Labs

Prerequisites: You have built and flashed the starter BLE project that advertises a single read-only characteristic (0xFF01) on service 0x00FF.
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:

  1. Initialises NVS, the BT controller and Bluedroid — boilerplate that rarely changes.
  2. Registers callbacksgap_event_handler (advertising) and gatts_event_handler (GATT server events).
  3. On ESP_GATTS_REG_EVT — sets the device name, configures advertising data, and creates the GATT service.
  4. On ESP_GATTS_CREATE_EVT — starts the service and adds one characteristic (UUID 0xFF01) that a phone can read.
  5. 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_EVT event 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:

static esp_gatt_char_prop_t char_property = ESP_GATT_CHAR_PROP_BIT_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:

/* BEFORE */
esp_attr_control_t char_control = {
    .auto_rsp = ESP_GATT_AUTO_RSP,
};

Change it to:

/* AFTER */
esp_attr_control_t char_control = {
    .auto_rsp = ESP_GATT_RSP_BY_APP,
};

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:

  1. Print the received data as a string.
  2. 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 so ESP_LOGI can print it safely.
  • We call esp_ble_gatts_send_response(...) with ESP_GATT_OK to tell the client "write accepted". If we skip this when need_rsp is true, the BLE connection stalls.

Step 5 — Build, Flash and Test

  1. Build and flash as usual:
idf.py build flash monitor
  1. Open nRF Connect on your phone, scan, and connect to ESP32C6_BLE_DEMO.
  2. Find service 0x00FF → characteristic 0xFF01.
  3. Tap the write icon (pencil or up-arrow, depending on app version).
  4. Type any text — for example Hola BLE — and send.
  5. In the serial monitor you should see:
I (12345) BLE_DEMO: Write event received, handle = 42
I (12345) BLE_DEMO: Data received: Hola BLE

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:

  1. Declare a global char buffer (e.g. 128 bytes) and a length variable at the top of main.c. Initialise the buffer with the original default string so reads work even before the first write.
  2. In your ESP_GATTS_WRITE_EVT handler, after printing the received data, copy the incoming bytes and their length into your global buffer.
  3. Add a new case ESP_GATTS_READ_EVT: in gatts_profile_event_handler. Inside it, build an esp_gatt_rsp_t, fill its attr_value fields with the contents of your global buffer, and call esp_ble_gatts_send_response with ESP_GATT_OK.
  4. 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:

  1. Include the GPIO driver header (driver/gpio.h) at the top of main.c.
  2. Define a macro for the LED pin number. Check your board's schematic for the on-board LED or choose any free GPIO.
  3. In app_main, before the BLE initialisation block, reset the pin, set its direction to output, and set its initial level to 0 (off).
  4. Inside the ESP_GATTS_WRITE_EVT handler, after building the null-terminated buffer, compare the string to "ON" and "OFF" using strcmp. Call gpio_set_level with 1 or 0 accordingly, and log the current state. For any other string, log a warning with the unrecognised command.
  5. 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:

  1. At the very beginning of your ESP_GATTS_WRITE_EVT handler, before any data processing, check whether param->write.len exceeds 64.
  2. If it does, log a warning with the rejected length, send a response using ESP_GATT_INVALID_ATTR_LEN as the status (instead of ESP_GATT_OK), and break out of the case immediately so the data is never copied or printed.
  3. Make sure this length check sits above all the existing write-handling code so that oversized writes are caught first.
  4. 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_EVT checks 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_EVT uses setup_step as 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_EVT compares param->write.handle against our saved handles to know which characteristic the client wrote to.
  • ESP_GATTS_READ_EVT is only needed for characteristics where auto_rsp is false. Firmware Version and Temperature use auto_rsp = true, so the stack handles their reads automatically.

Step 4 — Build, Flash and Test

  1. idf.py build flash monitor
  2. In nRF Connect, connect to the device. You should now see two services:
  3. 0xAA00 with two characteristics (0xAA01 and 0xAA02)
  4. 0xBB00 with two characteristics (0xBB01 and 0xBB02)
  5. Read 0xAA01 — you should get 1.0.0.
  6. Write MyNewName to 0xAA02, then read it back — it should return MyNewName.
  7. Write GET_TEMP to 0xBB02 and 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:

  1. 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.
  2. Declare global variables for the new service handle, characteristic handle, and a char array to hold the PIN (initialised to "0000").
  3. In ESP_GATTS_ADD_CHAR_EVT, extend the setup_step chain: after the last step of Service B finishes (currently step 4), call create_service for Service C instead of printing "all ready".
  4. Add a new branch in ESP_GATTS_CREATE_EVT that checks for your new service UUID, saves its handle, starts it, and adds the PIN characteristic with read and write permissions and manual response.
  5. Add a new step in the setup_step switch that saves the PIN characteristic handle and prints the "all ready" message.
  6. In ESP_GATTS_WRITE_EVT, add a branch that checks for the new characteristic handle and copies the incoming value into your PIN buffer.
  7. In ESP_GATTS_READ_EVT, add a branch that responds with the current PIN value.
  8. 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:

  1. Add ESP_GATT_CHAR_PROP_BIT_NOTIFY to the property flags of the Temperature characteristic (0xBB01) in its add_char call.
  2. After adding the Temperature characteristic, add a Client Characteristic Configuration Descriptor (CCCD) to it using esp_ble_gatts_add_char_descr with UUID 0x2902 and read/write permissions. You will need to handle ESP_GATTS_ADD_CHAR_DESCR_EVT and save the descriptor handle.
  3. 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.
  4. 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 — call esp_ble_gatts_send_indicate with need_confirm = false to push the new value.
  5. Adjust the setup_step chain to account for the extra descriptor-creation event between characteristic additions.
  6. Build, flash, and test: enable notifications on 0xBB01 in nRF Connect, then write "UPDATE" to 0xBB02 — 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:

  1. Create a static uint8_t array containing both 16-bit UUIDs in little-endian byte order (low byte first). For 0xAA00 and 0xBB00 this means four bytes total.
  2. In the adv_data struct, set p_service_uuid to point to your array and service_uuid_len to the array's size.
  3. Build, flash, and scan with nRF Connect. Verify that the advertising data now lists both service UUIDs in the scan result, even before connecting.
  4. 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:

  1. 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.
  2. 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.
  3. Create a static array of service structs that fully describes Services A and B (and C, if you completed Exercise 2.1).
  4. Rewrite ESP_GATTS_CREATE_EVT to look up the current service in the table by UUID and call add_char for its first characteristic.
  5. Rewrite ESP_GATTS_ADD_CHAR_EVT to 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.
  6. Rewrite the ESP_GATTS_WRITE_EVT and ESP_GATTS_READ_EVT handlers to loop through the table and match by handle instead of using a chain of if/else comparisons.
  7. Study Espressif's official gatts_table_creat_demo example for inspiration — it uses a similar table-driven approach.
  8. Build, flash, and confirm that behaviour is identical to the hard-coded version.