Minimal ESP32 NimBLE example of executing custom commands via BLE

In our previous example Minimal ESP32 NimBLE example of executing custom commands via BLE we showed how to create a custom BLE service and characteristic using NimBLE on the ESP32. This example builds upon that by demonstrating how to execute custom commands via BLE.

How custom commands work

BLE doesn’t really know about commands per-se, so it’s just a custom characteristic that we can write to.

In this example, we will use a single characteristic which you can write a string to. If the string is RESTART, the ESP32 will reboot.

Note that besides the CustomBLE.cpp (with only a few changes compared to Minimal ESP32 NimBLE example of executing custom commands via BLE), all the code is the same as in the previous example and duplicate code is omitted here.

CustomBLE.cpp

#include "CustomBLE.hpp"

#include "esp_log.h"
#include "esp_system.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h"
#include "host/ble_hs.h"
#include "host/ble_uuid.h"
#include "host/ble_gatt.h"
#include "services/gap/ble_svc_gap.h"
#include "services/gatt/ble_svc_gatt.h"

#include <string>
#include <cstring>

static const char *TAG = "CustomBLE";

// Custom service and characteristic UUIDs (randomly generated 128-bit UUIDs)
static const ble_uuid128_t custom_service_uuid = 
    BLE_UUID128_INIT(0xF0, 0xDE, 0xBC, 0x9A, 0x78, 0x56, 0x34, 0x12,
                     0xF0, 0xDE, 0xBC, 0x9A, 0x78, 0x56, 0x34, 0x12);

static const ble_uuid128_t command_char_uuid = 
    BLE_UUID128_INIT(0x98, 0xBA, 0xDC, 0xFE, 0x21, 0x43, 0x65, 0x87,
                     0x98, 0xBA, 0xDC, 0xFE, 0x21, 0x43, 0x65, 0x87);

static uint16_t command_char_handle;
static uint16_t conn_handle = BLE_HS_CONN_HANDLE_NONE;

// Command constants
#define RESTART_COMMAND "RESTART"

// GAP event listener structure
static struct ble_gap_event_listener gap_event_listener;

// GATT characteristic access function for commands
static int command_char_access(uint16_t conn_handle, uint16_t attr_handle,
                               struct ble_gatt_access_ctxt *ctxt, void *arg) {
    int rc;
    
    switch (ctxt->op) {
        case BLE_GATT_ACCESS_OP_READ_CHR: {
            ESP_LOGI(TAG, "Command characteristic read - returning status");
            const char* status_msg = "Command interface ready";
            rc = os_mbuf_append(ctxt->om, status_msg, strlen(status_msg));
            return rc == 0 ? 0 : BLE_ATT_ERR_INSUFFICIENT_RES;
        }
        case BLE_GATT_ACCESS_OP_WRITE_CHR: {
            ESP_LOGI(TAG, "Command characteristic write received");
            uint16_t om_len = OS_MBUF_PKTLEN(ctxt->om);
            if (om_len > 0) {
                char buffer[om_len + 1];
                rc = ble_hs_mbuf_to_flat(ctxt->om, buffer, sizeof(buffer) - 1, NULL);
                if (rc == 0) {
                    buffer[om_len] = '\0';
                    ESP_LOGI(TAG, "Received command: %s", buffer);
                    // Process the command
                    if (strcmp(buffer, RESTART_COMMAND) == 0) {
                        ESP_LOGI(TAG, "Restart command received - restarting ESP32...");
                        // Add a small delay to allow the BLE response to be sent
                        vTaskDelay(pdMS_TO_TICKS(100));
                        esp_restart();
                    } else {
                        ESP_LOGW(TAG, "Unknown command: %s", buffer);
                    }
                }
            }
            return 0;
        }
        default: {
            return BLE_ATT_ERR_UNLIKELY;
        }
    }
}

// GATT service definition
static const struct ble_gatt_svc_def custom_gatt_svcs[] = {
    {
        BLE_GATT_SVC_TYPE_PRIMARY,
        &custom_service_uuid.u,
        NULL, // includes
        (struct ble_gatt_chr_def[]) {
            {
                &command_char_uuid.u,
                command_char_access,
                NULL, // arg
                NULL, // descriptors
                BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_WRITE,
                0, // min_key_size
                &command_char_handle,
                NULL, // cpfd
            },
            { 
                NULL, // uuid - end of characteristics
            }
        },
    },
    { 
        0, // type - end of services
    }
};

// GAP event handler
static int custom_gap_event(struct ble_gap_event *event, void *arg) {
    switch (event->type) {
        case BLE_GAP_EVENT_CONNECT:
            ESP_LOGI(TAG, "Connection %s; status=%d",
                    event->connect.status == 0 ? "established" : "failed",
                    event->connect.status);
            if (event->connect.status == 0) {
                conn_handle = event->connect.conn_handle;
            }
            break;
            
        case BLE_GAP_EVENT_DISCONNECT:
            ESP_LOGI(TAG, "Disconnect; reason=%d", event->disconnect.reason);
            conn_handle = BLE_HS_CONN_HANDLE_NONE;
            break;
            
        default:
            break;
    }
    return 0;
}

void InitCustomBLE() {
    int rc;

    // Initialize GATT services
    rc = ble_gatts_count_cfg(custom_gatt_svcs);
    if (rc != 0) {
        ESP_LOGE(TAG, "Failed to count GATT services: %d", rc);
        return;
    }

    rc = ble_gatts_add_svcs(custom_gatt_svcs);
    if (rc != 0) {
        ESP_LOGE(TAG, "Failed to add GATT services: %d", rc);
        return;
    }

    ESP_LOGI(TAG, "Custom NimBLE command service initialized");
}

void ExecuteBLECommand(const std::string& command) {
    ESP_LOGI(TAG, "Executing BLE command: %s", command.c_str());
    
    if (command == RESTART_COMMAND) {
        ESP_LOGI(TAG, "Restart command executed - restarting ESP32...");
        vTaskDelay(pdMS_TO_TICKS(100));
        esp_restart();
    } else {
        ESP_LOGW(TAG, "Unknown command: %s", command.c_str());
    }
}

std::string GetCommandStatus() {
    return "Command interface ready. Supported commands: " RESTART_COMMAND;
}

void SetCustomBLEGapHandler() {
    // Set up the GAP event listener
    gap_event_listener.fn = custom_gap_event;
    gap_event_listener.arg = NULL;
    ble_gap_event_listener_register(&gap_event_listener, custom_gap_event, NULL);
}

How to test

Use our script from Python script to write BLE characteristics using Bleak to write the command RESTART to the characteristic.

Note that since the ESP32 will reboot immediately, the write will fail, but the command will still be executed.

python write_ble_characteristic.py 24:EC:4A:76:00:32 87654321-fedc-ba98-8765-4321fedcba98 RESTART

Example output

Using MAC address: 24:EC:4A:76:00:32
Characteristic UUID: 87654321-fedc-ba98-8765-4321fedcba98
Value: RESTART (utf-8)

Attempting to connect to 24:EC:4A:76:00:32 ...
Successfully connected to 24:EC:4A:76:00:32
Connected at: 2025-08-04 03:07:02
Writing to characteristic 87654321-fedc-ba98-8765-4321fedcba98 ...
Failed to write value: [org.bluez.Error.Failed] Operation failed with ATT error: 0x0e (Unlikely Error)

Write operation failed.