Ryan Malloy 6398a5223a ESP32 Bluetooth test harness MCP server
UART-controlled ESP32 peripheral for automated E2E Bluetooth testing.
Dual-mode (Classic BT + BLE) via Bluedroid on original ESP32.

Firmware (ESP-IDF v5.x, 2511 lines C):
- NDJSON protocol over UART1 (115200 baud)
- System commands: ping, reset, get_info, get_status
- Classic BT: GAP, SPP, all 4 SSP pairing modes
- BLE: GATTS, advertising, GATT service/characteristic management
- 6 device personas: headset, speaker, keyboard, sensor, phone, bare
- Event reporter: thread-safe async event queue to host

Python MCP server (FastMCP, 1626 lines):
- Async serial client with command/response correlation
- Event queue with wait_for pattern matching
- Tools: connection, configure, classic, ble, persona, events
- MCP resources: esp32://status, esp32://events, esp32://personas

Tests: 74 unit tests passing, 5 integration test stubs (skip without hardware)
2026-02-02 15:12:28 -07:00

904 lines
29 KiB
C

/*
* bt_ble.c -- BLE peripheral using Bluedroid GATTS (ESP-IDF v5.x).
*
* Provides GATT server with up to 8 services, each with up to 8
* characteristics. Commands arrive as NDJSON from the host over UART;
* events (connect, read, write, subscribe) are pushed back asynchronously
* via the event_reporter queue.
*/
#include "bt_ble.h"
#include "protocol.h"
#include "uart_handler.h"
#include "event_reporter.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"
#include "esp_log.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#define TAG "ble"
#define MAX_SERVICES 8
#define MAX_CHARS_PER_SVC 8
#define MAX_CHAR_VALUE_LEN 512
#define GATTS_APP_ID 0
/* ------------------------------------------------------------------ */
/* Data structures */
/* ------------------------------------------------------------------ */
typedef struct {
uint16_t handle;
uint8_t uuid[16];
uint8_t value[MAX_CHAR_VALUE_LEN];
uint16_t value_len;
uint8_t properties;
uint16_t cccd_handle;
bool notifications_enabled;
} char_entry_t;
typedef struct {
uint16_t handle;
uint8_t uuid[16];
bool primary;
int num_chars;
char_entry_t chars[MAX_CHARS_PER_SVC];
} service_entry_t;
static struct {
bool enabled;
bool advertising;
int num_services;
service_entry_t services[MAX_SERVICES];
esp_gatt_if_t gatts_if;
uint16_t conn_id;
bool connected;
} s_ble;
/*
* Async response plumbing: GATTS create/add_char events need to reply to the
* command that triggered them. Commands are processed sequentially on the
* main UART task, so a single pending slot is safe.
*/
static char s_pending_cmd_id[64];
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
static int parse_uuid(const char *uuid_str, uint8_t *uuid_out)
{
if (!uuid_str) return -1;
/* Strip hyphens into a 32-char hex string */
char hex[33];
int h = 0;
for (int i = 0; uuid_str[i] && h < 32; i++) {
if (uuid_str[i] != '-') {
hex[h++] = uuid_str[i];
}
}
hex[h] = '\0';
if (h != 32) {
ESP_LOGE(TAG, "UUID must be 32 hex digits (got %d)", h);
return -1;
}
/* Parse 16 bytes, store in little-endian order for ESP BLE stack */
for (int i = 0; i < 16; i++) {
unsigned int byte;
if (sscanf(hex + (15 - i) * 2, "%2x", &byte) != 1) return -1;
uuid_out[i] = (uint8_t)byte;
}
return 0;
}
static int hex_to_bytes(const char *hex, uint8_t *out, int max_len)
{
if (!hex) return 0;
int len = (int)strlen(hex) / 2;
if (len > max_len) len = max_len;
for (int i = 0; i < len; i++) {
unsigned int b;
if (sscanf(hex + i * 2, "%2x", &b) != 1) return i;
out[i] = (uint8_t)b;
}
return len;
}
static void bytes_to_hex(const uint8_t *data, int len, char *out)
{
for (int i = 0; i < len; i++) {
sprintf(out + i * 2, "%02x", data[i]);
}
out[len * 2] = '\0';
}
static uint8_t properties_from_json(cJSON *arr)
{
uint8_t props = 0;
if (!cJSON_IsArray(arr)) return props;
cJSON *item;
cJSON_ArrayForEach(item, arr) {
if (!cJSON_IsString(item)) continue;
const char *s = item->valuestring;
if (strcmp(s, "read") == 0) props |= ESP_GATT_CHAR_PROP_BIT_READ;
else if (strcmp(s, "write") == 0) props |= ESP_GATT_CHAR_PROP_BIT_WRITE;
else if (strcmp(s, "write_nr") == 0) props |= ESP_GATT_CHAR_PROP_BIT_WRITE_NR;
else if (strcmp(s, "notify") == 0) props |= ESP_GATT_CHAR_PROP_BIT_NOTIFY;
else if (strcmp(s, "indicate") == 0) props |= ESP_GATT_CHAR_PROP_BIT_INDICATE;
}
return props;
}
static service_entry_t *find_service_by_handle(uint16_t handle)
{
for (int i = 0; i < s_ble.num_services; i++) {
if (s_ble.services[i].handle == handle)
return &s_ble.services[i];
}
return NULL;
}
static char_entry_t *find_char_by_handle(uint16_t handle)
{
for (int i = 0; i < s_ble.num_services; i++) {
service_entry_t *svc = &s_ble.services[i];
for (int j = 0; j < svc->num_chars; j++) {
if (svc->chars[j].handle == handle)
return &svc->chars[j];
}
}
return NULL;
}
static char_entry_t *find_char_by_cccd(uint16_t cccd_handle)
{
for (int i = 0; i < s_ble.num_services; i++) {
service_entry_t *svc = &s_ble.services[i];
for (int j = 0; j < svc->num_chars; j++) {
if (svc->chars[j].cccd_handle == cccd_handle)
return &svc->chars[j];
}
}
return NULL;
}
static void send_pending_response(const char *status, cJSON *data)
{
if (s_pending_cmd_id[0] == '\0') return;
uart_send_response(s_pending_cmd_id, status, data);
s_pending_cmd_id[0] = '\0';
}
static void set_pending(const char *id)
{
strncpy(s_pending_cmd_id, id, sizeof(s_pending_cmd_id) - 1);
s_pending_cmd_id[sizeof(s_pending_cmd_id) - 1] = '\0';
}
/* BDA to string */
static void bda_to_str(const uint8_t *bda, char *out)
{
sprintf(out, "%02X:%02X:%02X:%02X:%02X:%02X",
bda[0], bda[1], bda[2], bda[3], bda[4], bda[5]);
}
/* ------------------------------------------------------------------ */
/* Advertising params */
/* ------------------------------------------------------------------ */
static esp_ble_adv_params_t s_adv_params = {
.adv_int_min = 0x20, /* 20 ms */
.adv_int_max = 0x40, /* 40 ms */
.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 event handler */
/* ------------------------------------------------------------------ */
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(&s_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");
s_ble.advertising = true;
} else {
ESP_LOGE(TAG, "advertising start failed: %d",
param->adv_start_cmpl.status);
}
break;
case ESP_GAP_BLE_ADV_STOP_COMPLETE_EVT:
ESP_LOGI(TAG, "advertising stopped");
s_ble.advertising = false;
break;
default:
break;
}
}
/* ------------------------------------------------------------------ */
/* GATTS event handler */
/* ------------------------------------------------------------------ */
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:
if (param->reg.status == ESP_GATT_OK) {
s_ble.gatts_if = gatts_if;
ESP_LOGI(TAG, "GATTS app registered, if=%d", gatts_if);
} else {
ESP_LOGE(TAG, "GATTS register failed: %d", param->reg.status);
}
break;
case ESP_GATTS_CREATE_EVT: {
uint16_t svc_handle = param->create.service_handle;
ESP_LOGI(TAG, "service created, handle=%d", svc_handle);
/* Store handle in the most recently added service entry */
if (s_ble.num_services > 0) {
service_entry_t *svc = &s_ble.services[s_ble.num_services - 1];
svc->handle = svc_handle;
}
esp_ble_gatts_start_service(svc_handle);
cJSON *data = cJSON_CreateObject();
cJSON_AddNumberToObject(data, "service_handle", svc_handle);
send_pending_response(STATUS_OK, data);
break;
}
case ESP_GATTS_ADD_CHAR_EVT: {
if (param->add_char.status != ESP_GATT_OK) {
ESP_LOGE(TAG, "add char failed: %d", param->add_char.status);
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", "add_char_failed");
send_pending_response(STATUS_ERROR, err);
break;
}
uint16_t char_handle = param->add_char.attr_handle;
ESP_LOGI(TAG, "characteristic added, handle=%d", char_handle);
/* Find the service and update the most recently added char */
service_entry_t *svc = find_service_by_handle(param->add_char.service_handle);
if (svc && svc->num_chars > 0) {
char_entry_t *ch = &svc->chars[svc->num_chars - 1];
ch->handle = char_handle;
}
cJSON *data = cJSON_CreateObject();
cJSON_AddNumberToObject(data, "char_handle", char_handle);
send_pending_response(STATUS_OK, data);
break;
}
case ESP_GATTS_ADD_CHAR_DESCR_EVT: {
if (param->add_char_descr.status != ESP_GATT_OK) {
ESP_LOGE(TAG, "add descriptor failed: %d", param->add_char_descr.status);
break;
}
/* Store CCCD handle on the owning characteristic */
uint16_t descr_handle = param->add_char_descr.attr_handle;
service_entry_t *svc = find_service_by_handle(param->add_char_descr.service_handle);
if (svc && svc->num_chars > 0) {
svc->chars[svc->num_chars - 1].cccd_handle = descr_handle;
}
ESP_LOGI(TAG, "CCCD descriptor added, handle=%d", descr_handle);
break;
}
case ESP_GATTS_READ_EVT: {
char addr_str[18];
bda_to_str(param->read.bda, addr_str);
event_report_gatt_read(param->read.handle, addr_str);
char_entry_t *ch = find_char_by_handle(param->read.handle);
esp_gatt_rsp_t rsp;
memset(&rsp, 0, sizeof(rsp));
rsp.attr_value.handle = param->read.handle;
if (ch) {
uint16_t send_len = ch->value_len;
if (send_len > ESP_GATT_MAX_ATTR_LEN)
send_len = ESP_GATT_MAX_ATTR_LEN;
rsp.attr_value.len = send_len;
memcpy(rsp.attr_value.value, ch->value, send_len);
}
esp_ble_gatts_send_response(gatts_if, param->read.conn_id,
param->read.trans_id,
ESP_GATT_OK, &rsp);
break;
}
case ESP_GATTS_WRITE_EVT: {
char addr_str[18];
bda_to_str(param->write.bda, addr_str);
/* CCCD write -- subscription toggle */
char_entry_t *cccd_ch = find_char_by_cccd(param->write.handle);
if (cccd_ch) {
bool subscribed = false;
if (param->write.len == 2) {
uint16_t val = param->write.value[0] | (param->write.value[1] << 8);
subscribed = (val != 0);
}
cccd_ch->notifications_enabled = subscribed;
event_report_gatt_subscribe(cccd_ch->handle, subscribed);
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;
}
/* Normal characteristic write */
char_entry_t *ch = find_char_by_handle(param->write.handle);
if (ch && param->write.len <= MAX_CHAR_VALUE_LEN) {
memcpy(ch->value, param->write.value, param->write.len);
ch->value_len = param->write.len;
}
event_report_gatt_write(param->write.handle, addr_str,
param->write.value, param->write.len);
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;
}
case ESP_GATTS_CONNECT_EVT: {
s_ble.conn_id = param->connect.conn_id;
s_ble.connected = true;
char addr_str[18];
bda_to_str(param->connect.remote_bda, addr_str);
ESP_LOGI(TAG, "connected: %s conn_id=%d", addr_str, param->connect.conn_id);
event_report_connect(addr_str, "ble");
break;
}
case ESP_GATTS_DISCONNECT_EVT: {
s_ble.connected = false;
char addr_str[18];
bda_to_str(param->disconnect.remote_bda, addr_str);
ESP_LOGI(TAG, "disconnected: %s reason=0x%x", addr_str, param->disconnect.reason);
event_report_disconnect(addr_str, "ble");
/* Restart advertising if we were advertising before the connection */
if (s_ble.advertising) {
esp_ble_gap_start_advertising(&s_adv_params);
}
break;
}
case ESP_GATTS_START_EVT:
ESP_LOGI(TAG, "service started, handle=%d status=%d",
param->start.service_handle, param->start.status);
break;
case ESP_GATTS_STOP_EVT:
ESP_LOGI(TAG, "service stopped, handle=%d", param->stop.service_handle);
break;
case ESP_GATTS_DELETE_EVT:
ESP_LOGI(TAG, "service deleted, handle=%d", param->del.service_handle);
break;
default:
break;
}
}
/* ------------------------------------------------------------------ */
/* Command handlers */
/* ------------------------------------------------------------------ */
void cmd_ble_enable(const char *id, cJSON *params)
{
(void)params;
if (s_ble.enabled) {
uart_send_response(id, STATUS_OK, NULL);
return;
}
/* Release classic BT memory if we only need BLE */
esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT();
esp_err_t ret = esp_bt_controller_init(&bt_cfg);
if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) {
ESP_LOGE(TAG, "controller init failed: %s", esp_err_to_name(ret));
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", esp_err_to_name(ret));
uart_send_response(id, STATUS_ERROR, err);
return;
}
ret = esp_bt_controller_enable(ESP_BT_MODE_BLE);
if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) {
ESP_LOGE(TAG, "controller enable failed: %s", esp_err_to_name(ret));
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", esp_err_to_name(ret));
uart_send_response(id, STATUS_ERROR, err);
return;
}
ret = esp_bluedroid_init();
if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) {
ESP_LOGE(TAG, "bluedroid init failed: %s", esp_err_to_name(ret));
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", esp_err_to_name(ret));
uart_send_response(id, STATUS_ERROR, err);
return;
}
ret = esp_bluedroid_enable();
if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) {
ESP_LOGE(TAG, "bluedroid enable failed: %s", esp_err_to_name(ret));
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", esp_err_to_name(ret));
uart_send_response(id, STATUS_ERROR, err);
return;
}
esp_ble_gap_register_callback(gap_event_handler);
esp_ble_gatts_register_callback(gatts_event_handler);
ret = esp_ble_gatts_app_register(GATTS_APP_ID);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "GATTS app register failed: %s", esp_err_to_name(ret));
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", esp_err_to_name(ret));
uart_send_response(id, STATUS_ERROR, err);
return;
}
esp_ble_gatt_set_local_mtu(517);
s_ble.enabled = true;
ESP_LOGI(TAG, "BLE enabled");
uart_send_response(id, STATUS_OK, NULL);
}
void cmd_ble_disable(const char *id, cJSON *params)
{
(void)params;
if (!s_ble.enabled) {
uart_send_response(id, STATUS_OK, NULL);
return;
}
if (s_ble.advertising) {
esp_ble_gap_stop_advertising();
s_ble.advertising = false;
}
esp_ble_gatts_app_unregister(s_ble.gatts_if);
esp_bluedroid_disable();
esp_bluedroid_deinit();
esp_bt_controller_disable();
esp_bt_controller_deinit();
s_ble.enabled = false;
s_ble.gatts_if = 0;
ESP_LOGI(TAG, "BLE disabled");
uart_send_response(id, STATUS_OK, NULL);
}
void cmd_ble_advertise(const char *id, cJSON *params)
{
if (!s_ble.enabled) {
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", "ble_not_enabled");
uart_send_response(id, STATUS_ERROR, err);
return;
}
bool enable = true;
cJSON *j_enable = cJSON_GetObjectItem(params, "enable");
if (cJSON_IsBool(j_enable)) {
enable = cJSON_IsTrue(j_enable);
}
cJSON *j_interval = cJSON_GetObjectItem(params, "interval_ms");
if (cJSON_IsNumber(j_interval)) {
/* Convert ms to 0.625ms units */
uint16_t raw = (uint16_t)(j_interval->valuedouble / 0.625);
if (raw < 0x20) raw = 0x20;
if (raw > 0x4000) raw = 0x4000;
s_adv_params.adv_int_min = raw;
s_adv_params.adv_int_max = raw;
}
if (enable) {
esp_err_t ret = esp_ble_gap_start_advertising(&s_adv_params);
if (ret != ESP_OK) {
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", esp_err_to_name(ret));
uart_send_response(id, STATUS_ERROR, err);
return;
}
s_ble.advertising = true;
} else {
esp_ble_gap_stop_advertising();
s_ble.advertising = false;
}
uart_send_response(id, STATUS_OK, NULL);
}
void cmd_ble_set_adv_data(const char *id, cJSON *params)
{
if (!s_ble.enabled) {
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", "ble_not_enabled");
uart_send_response(id, STATUS_ERROR, err);
return;
}
cJSON *j_name = cJSON_GetObjectItem(params, "name");
if (cJSON_IsString(j_name)) {
esp_ble_gap_set_device_name(j_name->valuestring);
}
esp_ble_adv_data_t adv_data = {
.set_scan_rsp = false,
.include_name = true,
.include_txpower = true,
.min_interval = 0x0006,
.max_interval = 0x0010,
.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),
};
/* Service UUIDs */
uint8_t svc_uuid_buf[16 * 8]; /* up to 8 UUIDs */
int svc_uuid_len = 0;
cJSON *j_uuids = cJSON_GetObjectItem(params, "service_uuids");
if (cJSON_IsArray(j_uuids)) {
cJSON *j_uuid;
cJSON_ArrayForEach(j_uuid, j_uuids) {
if (cJSON_IsString(j_uuid) && svc_uuid_len < (int)sizeof(svc_uuid_buf) - 16) {
if (parse_uuid(j_uuid->valuestring, svc_uuid_buf + svc_uuid_len) == 0) {
svc_uuid_len += 16;
}
}
}
}
if (svc_uuid_len > 0) {
adv_data.service_uuid_len = svc_uuid_len;
adv_data.p_service_uuid = svc_uuid_buf;
}
/* Manufacturer data */
uint8_t mfr_buf[32];
int mfr_len = 0;
cJSON *j_mfr = cJSON_GetObjectItem(params, "manufacturer_data");
if (cJSON_IsString(j_mfr)) {
mfr_len = hex_to_bytes(j_mfr->valuestring, mfr_buf, sizeof(mfr_buf));
}
if (mfr_len > 0) {
adv_data.manufacturer_len = mfr_len;
adv_data.p_manufacturer_data = mfr_buf;
}
esp_err_t ret = esp_ble_gap_config_adv_data(&adv_data);
if (ret != ESP_OK) {
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", esp_err_to_name(ret));
uart_send_response(id, STATUS_ERROR, err);
return;
}
uart_send_response(id, STATUS_OK, NULL);
}
void cmd_gatt_add_service(const char *id, cJSON *params)
{
if (!s_ble.enabled) {
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", "ble_not_enabled");
uart_send_response(id, STATUS_ERROR, err);
return;
}
if (s_ble.num_services >= MAX_SERVICES) {
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", "max_services_reached");
uart_send_response(id, STATUS_ERROR, err);
return;
}
cJSON *j_uuid = cJSON_GetObjectItem(params, "uuid");
if (!cJSON_IsString(j_uuid)) {
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", "missing uuid");
uart_send_response(id, STATUS_ERROR, err);
return;
}
bool primary = true;
cJSON *j_primary = cJSON_GetObjectItem(params, "primary");
if (cJSON_IsBool(j_primary)) {
primary = cJSON_IsTrue(j_primary);
}
/* Prepare the service entry (handle filled in by CREATE_EVT) */
service_entry_t *svc = &s_ble.services[s_ble.num_services];
memset(svc, 0, sizeof(*svc));
svc->primary = primary;
if (parse_uuid(j_uuid->valuestring, svc->uuid) != 0) {
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", "invalid uuid");
uart_send_response(id, STATUS_ERROR, err);
return;
}
s_ble.num_services++;
/* Build service ID for ESP API */
esp_gatt_srvc_id_t srvc_id;
memset(&srvc_id, 0, sizeof(srvc_id));
srvc_id.is_primary = primary;
srvc_id.id.inst_id = 0;
srvc_id.id.uuid.len = ESP_UUID_LEN_128;
memcpy(srvc_id.id.uuid.uuid.uuid128, svc->uuid, 16);
/* num_handle: 1 (svc) + per char: 1 (char decl) + 1 (char val) + 1 (CCCD) = 3 each, + 1 spare */
uint16_t num_handle = 1 + MAX_CHARS_PER_SVC * 3 + 1;
set_pending(id);
esp_err_t ret = esp_ble_gatts_create_service(s_ble.gatts_if, &srvc_id, num_handle);
if (ret != ESP_OK) {
s_ble.num_services--;
s_pending_cmd_id[0] = '\0';
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", esp_err_to_name(ret));
uart_send_response(id, STATUS_ERROR, err);
}
}
void cmd_gatt_add_characteristic(const char *id, cJSON *params)
{
if (!s_ble.enabled) {
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", "ble_not_enabled");
uart_send_response(id, STATUS_ERROR, err);
return;
}
cJSON *j_svc_handle = cJSON_GetObjectItem(params, "service_handle");
cJSON *j_uuid = cJSON_GetObjectItem(params, "uuid");
cJSON *j_props = cJSON_GetObjectItem(params, "properties");
if (!cJSON_IsNumber(j_svc_handle) || !cJSON_IsString(j_uuid)) {
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", "missing service_handle or uuid");
uart_send_response(id, STATUS_ERROR, err);
return;
}
uint16_t svc_handle = (uint16_t)j_svc_handle->valuedouble;
service_entry_t *svc = find_service_by_handle(svc_handle);
if (!svc) {
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", "unknown service_handle");
uart_send_response(id, STATUS_ERROR, err);
return;
}
if (svc->num_chars >= MAX_CHARS_PER_SVC) {
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", "max_chars_reached");
uart_send_response(id, STATUS_ERROR, err);
return;
}
/* Prepare char entry (handle filled in by ADD_CHAR_EVT) */
char_entry_t *ch = &svc->chars[svc->num_chars];
memset(ch, 0, sizeof(*ch));
if (parse_uuid(j_uuid->valuestring, ch->uuid) != 0) {
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", "invalid uuid");
uart_send_response(id, STATUS_ERROR, err);
return;
}
ch->properties = properties_from_json(j_props);
/* Initial value */
cJSON *j_value = cJSON_GetObjectItem(params, "value");
if (cJSON_IsString(j_value)) {
ch->value_len = hex_to_bytes(j_value->valuestring,
ch->value, MAX_CHAR_VALUE_LEN);
}
svc->num_chars++;
/* Build UUID */
esp_bt_uuid_t char_uuid;
char_uuid.len = ESP_UUID_LEN_128;
memcpy(char_uuid.uuid.uuid128, ch->uuid, 16);
/* Permission flags derived from properties */
esp_gatt_perm_t perm = 0;
if (ch->properties & ESP_GATT_CHAR_PROP_BIT_READ)
perm |= ESP_GATT_PERM_READ;
if (ch->properties & (ESP_GATT_CHAR_PROP_BIT_WRITE | ESP_GATT_CHAR_PROP_BIT_WRITE_NR))
perm |= ESP_GATT_PERM_WRITE;
/* Auto-add CCCD if notify or indicate */
esp_attr_control_t auto_rsp = { .auto_rsp = ESP_GATT_RSP_BY_APP };
set_pending(id);
esp_err_t ret = esp_ble_gatts_add_char(svc_handle, &char_uuid,
perm, ch->properties,
NULL, &auto_rsp);
if (ret != ESP_OK) {
svc->num_chars--;
s_pending_cmd_id[0] = '\0';
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", esp_err_to_name(ret));
uart_send_response(id, STATUS_ERROR, err);
return;
}
/* If the char supports notify/indicate, add a CCCD descriptor */
if (ch->properties & (ESP_GATT_CHAR_PROP_BIT_NOTIFY | ESP_GATT_CHAR_PROP_BIT_INDICATE)) {
esp_bt_uuid_t cccd_uuid;
cccd_uuid.len = ESP_UUID_LEN_16;
cccd_uuid.uuid.uuid16 = ESP_GATT_UUID_CHAR_CLIENT_CONFIG;
esp_ble_gatts_add_char_descr(svc_handle, &cccd_uuid,
ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE,
NULL, NULL);
}
}
void cmd_gatt_set_value(const char *id, cJSON *params)
{
cJSON *j_handle = cJSON_GetObjectItem(params, "char_handle");
cJSON *j_value = cJSON_GetObjectItem(params, "value");
if (!cJSON_IsNumber(j_handle) || !cJSON_IsString(j_value)) {
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", "missing char_handle or value");
uart_send_response(id, STATUS_ERROR, err);
return;
}
uint16_t handle = (uint16_t)j_handle->valuedouble;
char_entry_t *ch = find_char_by_handle(handle);
if (!ch) {
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", "unknown char_handle");
uart_send_response(id, STATUS_ERROR, err);
return;
}
ch->value_len = hex_to_bytes(j_value->valuestring, ch->value, MAX_CHAR_VALUE_LEN);
esp_ble_gatts_set_attr_value(handle, ch->value_len, ch->value);
uart_send_response(id, STATUS_OK, NULL);
}
void cmd_gatt_notify(const char *id, cJSON *params)
{
if (!s_ble.connected) {
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", "not_connected");
uart_send_response(id, STATUS_ERROR, err);
return;
}
cJSON *j_handle = cJSON_GetObjectItem(params, "char_handle");
if (!cJSON_IsNumber(j_handle)) {
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", "missing char_handle");
uart_send_response(id, STATUS_ERROR, err);
return;
}
uint16_t handle = (uint16_t)j_handle->valuedouble;
char_entry_t *ch = find_char_by_handle(handle);
if (!ch) {
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", "unknown char_handle");
uart_send_response(id, STATUS_ERROR, err);
return;
}
if (!ch->notifications_enabled) {
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", "notifications_not_enabled");
uart_send_response(id, STATUS_ERROR, err);
return;
}
bool need_confirm = (ch->properties & ESP_GATT_CHAR_PROP_BIT_INDICATE) != 0;
esp_err_t ret = esp_ble_gatts_send_indicate(s_ble.gatts_if, s_ble.conn_id,
handle, ch->value_len,
ch->value, need_confirm);
if (ret != ESP_OK) {
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "error", esp_err_to_name(ret));
uart_send_response(id, STATUS_ERROR, err);
return;
}
uart_send_response(id, STATUS_OK, NULL);
}
void cmd_gatt_clear(const char *id, cJSON *params)
{
(void)params;
/* Stop and delete all services */
for (int i = 0; i < s_ble.num_services; i++) {
service_entry_t *svc = &s_ble.services[i];
if (svc->handle != 0) {
esp_ble_gatts_stop_service(svc->handle);
esp_ble_gatts_delete_service(svc->handle);
}
}
memset(s_ble.services, 0, sizeof(s_ble.services));
s_ble.num_services = 0;
ESP_LOGI(TAG, "GATT services cleared");
uart_send_response(id, STATUS_OK, NULL);
}
/* ------------------------------------------------------------------ */
/* Init */
/* ------------------------------------------------------------------ */
void bt_ble_init(void)
{
memset(&s_ble, 0, sizeof(s_ble));
s_pending_cmd_id[0] = '\0';
ESP_LOGI(TAG, "BLE subsystem ready (max %d services, %d chars each)",
MAX_SERVICES, MAX_CHARS_PER_SVC);
}