ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ESP32 蓝牙 GATT Client 实战:主机/从机双角色对接 Dialog NUS 通讯配置与验证

ESP32 蓝牙 GATT Client 实战:主机/从机双角色对接 Dialog NUS 通讯配置与验证 1. ESP32 做 GATT Client 对接 Dialog NUS 的真实场景如果你手里同时有 ESP32 和一颗跑 Dialog SDK 的从机DA14531/DA14585 这类想让 ESP32 当主机去连它、读写 NUS 风格的自定义服务那你大概率会踩到我这几天踩的坑ESP32 扫描到了、连上了、服务也搜到了结果一写特征值就报错串口里刷出一行write char failed, error status ...从机那边却安安静静什么都不回。这个场景的核心其实就三件事ESP32 作为 GATT Client 完成服务发现、拿到正确的特征值句柄、然后按从机声明的权限去读写。Dialog 侧的 NUS 服务通常用 128 位 UUIDTX/RX 两个特征值分别负责从机发数据和主机发数据中间还夹着一个 CCCD0x2902用来使能 notify。只要权限位没配对或者句柄拿错了写操作就会被 ATT 层直接拒掉。下面我按「先复现错误 → 定位权限 → 改从机 → 验证收发」的顺序走一遍代码都是可以直接抄进工程的。适合已经跑通官方 gattc_demo、但卡在自定义 NUS 服务对接的嵌入式同学。2. TaoToken 前置把模型对话和接入文档放在手边调蓝牙这种协议栈问题最烦的是报错码含义记不清、UUID 拼错一位就要重烧。我习惯把几个常用入口固定到浏览器书签查状态码、对 UUID、看接入示例都快。TaoToken 的模型对话入口适合拿来快速问「ESP_GATT_* 这个错误码是什么意思」「ATT 权限位怎么组合」接入文档里也有 GATT 相关的配置说明。地址如下模型对话问协议细节、错误码https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentmodel-chat接入文档对照 API 与配置https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentdocAPI Keys需要自己写脚本批量验证时https://taotoken.net/api注意这些只是辅助查资料和验证思路的工具蓝牙链路的调试还是得靠串口日志和逻辑分析仪别指望工具替你连上设备。3. 可复制配置ESP32 GATT Client 初始化与 NUS 特征值骨架3.1 服务与特征值 UUID 定义Dialog 侧 NUS 服务用的是 128 位 UUIDESP32 这边必须逐字节对齐。注意esp_bt_uuid_t里 uuid128 是小端存放的也就是从机广播里 UUID 的低字节在前。我踩过的坑就是把顺序写反结果search_service一直搜不到。// Dialog NUS 服务 UUID按小端排列 #define REMOTE_SERVICE_UUID {0x9E,0xCA,0xDC,0x24,0x0E,0xE5,0xA9,0xE0, \ 0x93,0xF3,0xA3,0xB5,0x01,0x00,0x40,0x6E} // 从机 TX 特征值从机 notify 给主机 #define REMOTE_NOTIFY_CHAR_UUID {0x9E,0xCA,0xDC,0x24,0x0E,0xE5,0xA9,0xE0, \ 0x93,0xF3,0xA3,0xB5,0x03,0x00,0x40,0x6E} static esp_bt_uuid_t remote_filter_service_uuid { .len ESP_UUID_LEN_128, .uuid {.uuid128 REMOTE_SERVICE_UUID,}, }; static esp_bt_uuid_t remote_filter_char_uuid { .len ESP_UUID_LEN_128, .uuid {.uuid128 REMOTE_NOTIFY_CHAR_UUID,}, }; static esp_bt_uuid_t notify_descr_uuid { .len ESP_UUID_LEN_16, .uuid {.uuid16 0x2902,}, // CCCD使能 notify 用 };3.2 扫描参数与连接触发扫描参数不用太激进interval 0x50、window 0x30 足够稳定。关键是扫描结果里按设备名匹配匹配上就停扫描并发起连接。static esp_ble_scan_params_t ble_scan_params { .scan_type BLE_SCAN_TYPE_ACTIVE, .own_addr_type BLE_ADDR_TYPE_PUBLIC, .scan_filter_policy BLE_SCAN_FILTER_ALLOW_ALL, .scan_interval 0x50, .scan_window 0x30, .scan_duplicate BLE_SCAN_DUPLICATE_DISABLE }; static const char remote_device_name[] GKOSON; // 改成你的从机广播名在ESP_GAP_BLE_SCAN_RESULT_EVT里解析广播名命中就esp_ble_gap_stop_scanning()然后esp_ble_gattc_open()。这一步官方 demo 已经写得很完整直接沿用即可。3.3 服务发现与特征值句柄获取连接成功后走ESP_GATTC_SEARCH_CMPL_EVT先按服务 UUID 搜服务再在服务范围内按特征值 UUID 拿句柄。这里有个细节esp_ble_gattc_get_char_by_uuid返回的char_elem_result[0].char_handle才是后面写数据要用的句柄。case ESP_GATTC_SEARCH_CMPL_EVT: if (p_data-search_cmpl.status ! ESP_GATT_OK) { ESP_LOGE(GATTC_TAG, search service failed, status %x, p_data-search_cmpl.status); break; } if (get_server) { uint16_t count 0; esp_gatt_status_t status esp_ble_gattc_get_attr_count( gattc_if, p_data-search_cmpl.conn_id, ESP_GATT_DB_CHARACTERISTIC, gl_profile_tab[PROFILE_A_APP_ID].service_start_handle, gl_profile_tab[PROFILE_A_APP_ID].service_end_handle, INVALID_HANDLE, count); if (status ! ESP_GATT_OK) { ESP_LOGE(GATTC_TAG, get_attr_count error); } if (count 0) { char_elem_result malloc(sizeof(esp_gattc_char_elem_t) * count); status esp_ble_gattc_get_char_by_uuid( gattc_if, p_data-search_cmpl.conn_id, gl_profile_tab[PROFILE_A_APP_ID].service_start_handle, gl_profile_tab[PROFILE_A_APP_ID].service_end_handle, remote_filter_char_uuid, char_elem_result, count); if (count 0 (char_elem_result[0].properties ESP_GATT_CHAR_PROP_BIT_NOTIFY)) { gl_profile_tab[PROFILE_A_APP_ID].char_handle char_elem_result[0].char_handle; esp_ble_gattc_register_for_notify( gattc_if, gl_profile_tab[PROFILE_A_APP_ID].remote_bda, char_elem_result[0].char_handle); } free(char_elem_result); } } break;3.4 使能 notify 并写 CCCDESP_GATTC_REG_FOR_NOTIFY_EVT里找到 0x2902 描述符写0x0001使能 notify。这一步写完会触发ESP_GATTC_WRITE_DESCR_EVT我就是在那个事件里顺手发了一包测试数据结果撞上了权限问题。case ESP_GATTC_REG_FOR_NOTIFY_EVT: { if (p_data-reg_for_notify.status ! ESP_GATT_OK) { ESP_LOGE(GATTC_TAG, REG FOR NOTIFY failed: %d, p_data-reg_for_notify.status); break; } uint16_t count 0; uint16_t notify_en 1; esp_ble_gattc_get_attr_count( gattc_if, gl_profile_tab[PROFILE_A_APP_ID].conn_id, ESP_GATT_DB_DESCRIPTOR, gl_profile_tab[PROFILE_A_APP_ID].service_start_handle, gl_profile_tab[PROFILE_A_APP_ID].service_end_handle, gl_profile_tab[PROFILE_A_APP_ID].char_handle, count); if (count 0) { descr_elem_result malloc(sizeof(esp_gattc_descr_elem_t) * count); esp_ble_gattc_get_descr_by_char_handle( gattc_if, gl_profile_tab[PROFILE_A_APP_ID].conn_id, p_data-reg_for_notify.handle, notify_descr_uuid, descr_elem_result, count); if (count 0 descr_elem_result[0].uuid.uuid.uuid16 ESP_GATT_UUID_CHAR_CLIENT_CONFIG) { esp_ble_gattc_write_char_descr( gattc_if, gl_profile_tab[PROFILE_A_APP_ID].conn_id, descr_elem_result[0].handle, sizeof(notify_en), (uint8_t *)notify_en, ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE); } free(descr_elem_result); } break; }4. 验证请求与成功结果从写失败到双向收发4.1 复现写失败使能 notify 成功后我在ESP_GATTC_WRITE_DESCR_EVT里直接发了一包 35 字节的测试数据case ESP_GATTC_WRITE_DESCR_EVT: if (p_data-write.status ! ESP_GATT_OK) { ESP_LOGE(GATTC_TAG, write descr failed, status %x, p_data-write.status); break; } uint8_t write_char_data[35]; for (int i 0; i sizeof(write_char_data); i) { write_char_data[i] i % 256; } esp_ble_gattc_write_char( gattc_if, gl_profile_tab[PROFILE_A_APP_ID].conn_id, gl_profile_tab[PROFILE_A_APP_ID].char_handle, sizeof(write_char_data), write_char_data, ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE); break;串口立刻报E (12345) GATTC_DEMO: acked ----311--write char failed, error status 3status 3对应ESP_GATT_WRITE_NOT_PERMIT也就是从机侧这个特征值根本没开写权限。问题不在 ESP32在 Dialog 的属性表。4.2 修改 Dialog 从机权限Dialog SDK 里属性权限在user_custs1_def.c的custs1_att_db数组里定义。找到 TX_CTRL 那一行把权限位补全// 修改前只有读和 notify [SVC1_IDX_TX_CTRL_VAL] { SVC1_TX_CTRL_UUID_128, ATT_UUID_128_LEN, PERM(RD, ENABLE) | PERM(NTF, ENABLE), DEF_SVC1_TX_CTRL_CHAR_LEN, 0, NULL }, // 修改后补上写权限 [SVC1_IDX_TX_CTRL_VAL] { SVC1_TX_CTRL_UUID_128, ATT_UUID_128_LEN, PERM(WR, ENABLE) | PERM(WRITE_REQ, ENABLE) | PERM(RD, ENABLE) | PERM(NTF, ENABLE), DEF_SVC1_TX_CTRL_CHAR_LEN, 0, NULL },PERM(WR, ENABLE)允许 Write Command无响应写PERM(WRITE_REQ, ENABLE)允许 Write Request带响应写。ESP32 这边用的是ESP_GATT_WRITE_TYPE_RSP所以两个都得开。改完重新编译烧录从机再跑 ESP32write char success就出来了。4.3 从机侧接收 ESP32 写过来的数据从机收到写请求会进user_catch_rest_hndl的CUSTS1_VAL_WRITE_IND分支。默认 SDK 只处理了 RX_CONTROL_VAL 和几个 NTF_CFGTX_CTRL_VAL 的写事件没接。加上一个 case 就能看到 ESP32 发来的内容case SVC1_IDX_TX_CTRL_VAL: showarry(esp com, (uint8_t *)msg_param-value, msg_param-length, msg_param-length); break;showarry是 Dialog 工程里现成的十六进制打印函数串口会打出00-01-02-...这样的序列正好对应 ESP32 发的i % 256。4.4 ESP32 收到 notify 后回写从机使能 notify 后会周期性推数据ESP32 在ESP_GATTC_NOTIFY_EVT里收到。想回写就在这个事件里直接调esp_ble_gattc_write_charcase ESP_GATTC_NOTIFY_EVT: if (p_data-notify.is_notify) { ESP_LOGI(GATTC_TAG, receive notify value:); } else { ESP_LOGI(GATTC_TAG, receive indicate value:); } esp_log_buffer_hex(GATTC_TAG, p_data-notify.value, p_data-notify.value_len); uint8_t write_char_data_test[] {HAHA}; esp_ble_gattc_write_char( gattc_if, gl_profile_tab[PROFILE_A_APP_ID].conn_id, gl_profile_tab[PROFILE_A_APP_ID].char_handle, sizeof(write_char_data_test), write_char_data_test, ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE); break;从机串口会打出HAHA的十六进制说明双向链路通了。4.5 关于「以前有函数对应现在没有」的疑问官方 demo 里每个事件都有独立的 case 分支看起来「一个动作对应一个函数」。但实际业务里写操作往往是在某个事件回调里顺手触发的比如在WRITE_DESCR_EVT里写特征值、在NOTIFY_EVT里回写。这不是 bug是事件驱动模型的正常用法。如果你想要「函数对应」的清晰结构可以自己包一层static void gattc_send_data(uint8_t *data, uint16_t len) { esp_ble_gattc_write_char( gl_profile_tab[PROFILE_A_APP_ID].gattc_if, gl_profile_tab[PROFILE_A_APP_ID].conn_id, gl_profile_tab[PROFILE_A_APP_ID].char_handle, len, data, ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE); }然后在任何事件里调gattc_send_data()逻辑就清爽了。5. 本篇常见错误排查5.1 write char failed, error status 3这是最常见的。3是ESP_GATT_WRITE_NOT_PERMIT从机属性表没开写权限。去 Dialog 的custs1_att_db里找对应特征值补PERM(WR, ENABLE) | PERM(WRITE_REQ, ENABLE)。注意 Write Command 和 Write Request 是两个不同的权限位ESP32 用ESP_GATT_WRITE_TYPE_RSP时走的是 Write Request。5.2 搜不到服务get_server 一直是 false先确认 UUID 字节序。Dialog 广播里的 128 位 UUID 和 ESP32esp_bt_uuid_t.uuid128的存放顺序是反的必须按小端逐字节填。其次确认esp_ble_gattc_search_service传的是remote_filter_service_uuid而不是 NULL传 NULL 会搜所有服务但后面按 UUID 过滤时容易拿错句柄。5.3 使能 notify 后收不到数据检查 CCCD 写的是不是0x0001。有些从机要求写0x0001才推 notify写0x0002是 indicate。另外确认从机侧user_svc1_tx_ntf_cfg_ind_handler里收到非零值后有没有真正触发发送逻辑Dialog 的 NUS 通常在这里把状态机切到 STATE_SEND。5.4 写数据长度超过 MTU 被截断ESP32 默认 MTU 是 23有效载荷 20 字节。我在app_main里设了esp_ble_gatt_set_local_mtu(500)但实际协商后的 MTU 要看ESP_GATTC_CFG_MTU_EVT里打印的值。如果从机不支持大 MTU写超过 20 字节会被截断或报错。稳妥做法是每次写之前检查p_data-cfg_mtu.mtu - 3。5.5 从机一直 ack 停不下来使能 notify 后从机如果进入循环发送状态ESP32 这边会一直收 notify。如果不想收可以在收到第一包后写 CCCD 为0x0000关掉 notify或者在从机侧的状态机里加超时退出。Dialog 的STATE_WAIT_ACK就是干这个的count到 5 就回STATE_HISTORY。6. 继续把链路跑通接入文档与 Coding Plan蓝牙 GATT 调试的坑基本集中在权限位、UUID 字节序、句柄获取这三块。把从机属性表的写权限打开、确认 UUID 小端对齐、用esp_ble_gattc_get_char_by_uuid拿准句柄剩下的就是事件回调里组织收发逻辑。如果你在写 ESP32 侧的 GATT Client 时想对照更完整的接入示例或者需要批量验证不同 UUID 组合可以看接入文档和 API Keys接入文档https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentdocAPI Keyshttps://taotoken.net/api长期做嵌入式编码和 Agent 联调的话Coding Plan 入口在这里https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentcoding-plan我实测下来Dialog 侧改完权限、ESP32 侧把写操作包成独立函数之后整个 NUS 收发链路就稳了。串口里write char success和从机的esp com十六进制打印同时出现的那一刻基本就说明主机从机对接完成了。
返回列表