/* * 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 #include #include #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 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; } } /* ------------------------------------------------------------------ */ /* State query */ /* ------------------------------------------------------------------ */ bool bt_ble_is_enabled(void) { return s_ble.enabled; } /* ------------------------------------------------------------------ */ /* 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); }