esp-iot-solution 中基于 BLE UART 与 ESP-VoCat 的 OpenCode 权限审批设备端实现(ble_uart_service 例程全解析)
【免费下载链接】esp-iot-solutionEspressif IoT Library. IoT Device Drivers, Documentations and Solutions.项目地址: https://gitcode.com/GitHub_Trending/es/esp-iot-solution
导读
examples/bluetooth/ble_uart_service是 esp-iot-solution 仓库中的一个端到端蓝牙例程:它把 PC 端 AI 编程助手 OpenCode 的权限请求(permission.asked)通过 BLE UART 桥接协议转发到 ESP32-S3 设备(ESP-VoCat 开发套件),在圆形触摸屏上以表情动画(Emote)呈现会话状态与权限提示,用户通过电容触摸按键"单击批准 / 长按拒绝"完成远程授权,结果再沿原链路回传。读完本文,你将掌握该例程的硬件要求、构建烧录步骤、BLE UART Bridge + OpenCode 插件全链路联调方法、v1 JSONL 信封协议的完整字段语义,以及协议层与 UI 层的源码级实现原理。
1. 例程定位:一条从 OpenCode 到穿戴设备的完整数据通路
本例程不是泛化的 UART 透传示例,而是ESP-VoCat(ESP32-S3)设备端对 OpenCode BLE UART 桥接的适配实现。其完整数据路径为:
OpenCode -> OpenCode BLE plugin(OpenCode 插件) -> tools/ble/ble_uart_bridge daemon(BLE UART 守护进程) -> BLE UART JSONL(蓝牙传输层) -> 本 ESP32-S3 设备固件设备端固件承担三件事:
- 从 BLE UART 收到 JSONL 消息后,解析 OpenCode
session.status事件,在屏幕上同步显示 busy / idle / retry 状态; - 解析 OpenCode
permission.request事件,展示权限类型、标题与紧凑元数据,等待用户按键决策; - 通过触摸键给出权限结论:单击返回
once、长按返回reject、30 秒无输入也返回reject;同时处理permission.cancel以清理过期的权限提示。
例程还保留了status、name、unpair三个本地维护命令,与 v1 信封协议互不干扰。
2. 支持的硬件:ESP-VoCat 开发套件
该例程专为 ESP-VoCat 智能 AI 开发套件设计,不是通用 ESP32-S3 例程。ESP-VoCat v1.2 的关键特性:
- ESP32-S3-WROOM-1-N16R16VA:2.4 GHz Wi-Fi + Bluetooth 5(LE),16 MB Flash、16 MB PSRAM;
- 1.85 英寸 QSPI 圆形触摸屏(360 × 360):用于显示 OpenCode 会话状态与权限提示;
- 电容触摸焊盘(GPIO IO6 / IO7):作为触摸键,单击 = 批准,长按 = 拒绝;
- 内置 3 W 扬声器与双麦克风阵列:本 BLE UART 例程未使用,保留给其他固件的语音交互;
- USB-C 接口:供电、固件下载与调试。
版本差异:ESP-VoCat v1.0 使用不同模组(ESP32-S3-WROOM-2-N32R16V,32 MB Flash)且只有一个触摸焊盘(仅 IO7)。板级层(
board.c/board.h)同时兼容两个版本。
重要约束:固件依赖
espressif/esp_vocat板级支持包(BSP),无法在其他 ESP32-S3 开发板上直接运行,除非移植板级层board.c/board.h。BSP 通过组件注册清单 main/idf_component.yml 自动拉取。
3. 兼容性与配套环境
- 适配ESP-IDF release/v5.5;
- 复用
$IDF_PATH/examples/bluetooth/common/ble_uart中的 BLE UART 组件,构建跟随当前导出的 ESP-IDF 环境; - GitLab CI 使用
espressif/idf:release-v5.5镜像对esp32s3目标做编译测试。
配套的 OpenCode 主机侧 Demo 位于$IDF_PATH/tools/ble/ble_uart_bridge/demos/opencode(该目录不是固件,而是 OpenCode 插件演示)。
4. 代码布局
main/ ├── app_main.c # NVS、BLE UART、协议层与 Demo 启动 ├── ble_protocol.c # BLE JSONL 协议:权限请求、会话状态、遗留命令 ├── ble_protocol.h ├── board.c # 显示、面板触摸、触摸键、emote player 生命周期 ├── board.h ├── emote_demo.c # 动画、提示文本、按键到权限决策的映射 ├── emote_demo.h └── idf_component.yml顶层 CMakeLists.txt 使用当前导出的IDF_PATH把公共 BLE UART 组件加入构建:
list(APPEND EXTRA_COMPONENT_DIRS "$ENV{IDF_PATH}/examples/bluetooth/common/ble_uart")5. 构建与烧录
cd esp-iot-solution/examples/bluetooth/ble_uart_service . $IDF_PATH/export.sh idf.py set-target esp32s3 idf.py build flash monitor建议使用 ESP-IDFrelease/v5.5环境以保证与 CI 验证版本一致。
首次构建的资源下载:在首次 CMake 配置时,main/CMakeLists.txt 会把emote_assets.bin(表情动画资源包)下载到build/prebuilt/emote_assets.bin,随后通过spiffs_create_partition_assets烧入emote_gen分区。若下载失败,CMake 会直接报FATAL_ERROR终止配置。
分区与工程配置:
- partitions.csv 定义了
emote_genSPIFFS 分区(大小 5500K),另有 nvs(0x6000)、phy_init(0x1000)、factory(2500K); - sdkconfig.defaults 锁定
esp32s3目标,并开启 NimBLE(含安全连接CONFIG_BT_NIMBLE_SM_SC、NVS 持久化CONFIG_BT_NIMBLE_NVS_PERSIST、MTU 247)、八线 PSRAM(80 MHz)、16 MB Flash 与 240 MHz CPU。
默认 BLE 设备名为emote-XXXX,其中XXXX取自 BT MAC 地址后两个字节。该逻辑位于 app_main.c:先用esp_read_mac(mac, ESP_MAC_BT)读取 MAC 并以"emote-%02X%02X"格式化,再尝试从 NVS(命名空间ble_uart、键name)恢复用户自定义名称覆盖默认名。遗留name命令可将自定义 BLE 名称写入 NVS,重启后生效。
6. 与 OpenCode 联调:从安装到触发权限请求
6.1 安装 BLE UART Bridge 依赖
cd $IDF_PATH . ./export.sh cd tools/ble/ble_uart_bridge python -m pip install -r requirements.txt6.2 查找并连接设备
先烧录本例程并确认设备在广播,然后:
cd $IDF_PATH/tools/ble/ble_uart_bridge python main.py list-devices python main.py connection-check <DEVICE_ID> python main.py console <DEVICE_ID> python main.py daemon <DEVICE_ID> --host 127.0.0.1 --port 8888另开一个终端查看 daemon 状态:
cd $IDF_PATH/tools/ble/ble_uart_bridge python main.py daemon-statusOpenCode 插件默认连接的端点:
http://127.0.0.1:8888如需更换端点,在启动 OpenCode 前设置环境变量:
export OPENCODE_BLE_DAEMON_URL="http://127.0.0.1:9999"6.3 安装 OpenCode 插件 Demo
插件位于$IDF_PATH/tools/ble/ble_uart_bridge/demos/opencode。项目级安装示例:
mkdir -p <project>/.opencode/plugins/opencode-ble-uart-bridge cp $IDF_PATH/tools/ble/ble_uart_bridge/demos/opencode/src/*.ts \ <project>/.opencode/plugins/opencode-ble-uart-bridge/合并以下内容到<project>/opencode.json:
{ "plugin": [ ".opencode/plugins/opencode-ble-uart-bridge/opencode-ble-uart-bridge.ts" ], "permission": { "edit": "ask" } }也可以直接使用随附示例配置:$IDF_PATH/tools/ble/ble_uart_bridge/demos/opencode/opencode.json.example。
6.4 触发一次权限请求
启动 OpenCode 后,要求它执行一个需要edit权限的操作。插件通过 daemon 把permission.asked转发到 BLE 设备:
{"v":1,"id":"<bridge-request-id>","op":"permission.request","data":{"v":1,"kind":"permission.request","payload":{"type":"edit","title":"Permission request","metadata":{"path":"..."}}}}设备弹出权限提示后:
单击触摸键,设备回复:
{"v":1,"id":"<bridge-request-id>","ok":true,"data":{"decision":"once","message":"Approved from BLE device"}}长按触摸键,设备回复:
{"v":1,"id":"<bridge-request-id>","ok":true,"data":{"decision":"reject","message":"Rejected from BLE device"}}
插件收到结果后调用 OpenCode 的权限回复 API 完成闭环。
7. v1 JSONL 信封协议详解
协议文件见 json_format.md。双向均为换行分隔的 JSON(每行一个 JSON 对象),实现见 ble_protocol.c 的ble_protocol_handle_line。
7.1 PC/daemon → 设备:权限请求(permission.request)
当 OpenCode 发布permission.asked时,daemon 通过POST /request发送,信封使用op: "permission.request"且bridge request id 非空:
{"v":1,"id":"<bridge-request-id>","op":"permission.request","data":{"v":1,"kind":"permission.request","event_id":"evt_...","session_id":"ses_...","permission_id":"perm_...","requires_reply":true,"payload":{"id":"perm_...","sessionID":"ses_...","type":"bash","title":"Run command","metadata":{"command":"git status"}}}}设备实际使用的字段:
| 路径 | 用途 |
|---|---|
id | 存储并在回复中原样回显 |
data.kind | 必须等于"permission.request",否则回bad_request |
data.payload.type | 显示用(如"bash") |
data.payload.title | 显示用(如"Run command") |
data.payload.metadata | 紧凑元数据;优先取command、path、url,否则回退到第一个字符串子字段 |
硬件映射(ESP-VoCat 触摸键):触摸键使用电容触摸焊盘(v1.0 为 IO7,v1.2 为 IO6 或 IO7):
| 动作 | 发送的决策 |
|---|---|
| 单击 | "once" |
| 长按(第一阈值) | "reject" |
| 30s 超时 | "reject"(而非"timeout") |
"always"未暴露,因为该固件只提供一个触摸键。
7.2 设备 → PC:权限回复(permission reply)
设备以相同的 bridge request id回复:
{"v":1,"id":"<bridge-request-id>","ok":true,"data":{"decision":"once","message":"Approved from BLE device"}}{"v":1,"id":"<bridge-request-id>","ok":true,"data":{"decision":"reject","message":"Rejected from BLE device"}}超时(30 秒无按键):
{"v":1,"id":"<bridge-request-id>","ok":true,"data":{"decision":"reject","message":"Timed out"}}7.3 PC/daemon → 设备:会话状态(session.status,即发即弃)
通过 daemonPOST /notify发送,bridge request id 为空,设备必须不回复:
{"v":1,"id":"","op":"session.status","data":{"v":1,"kind":"session.status","event_id":"evt_...","session_id":"ses_...","requires_reply":false,"payload":{"type":"busy"}}}设备相应更新显示:
"busy"→ 显示 "Working..." 提示"idle"→ 清除提示"retry"→ 显示 "Retry..." 提示
7.4 错误信封
JSON 解析失败(非法 JSON):
{"v":1,"id":"","ok":false,"error":"bad_json"}格式错误或未知请求(id 非空时):
{"v":1,"id":"<request-id>","ok":false,"error":"bad_request"}{"v":1,"id":"<request-id>","ok":false,"error":"unknown_op"}
若id为空且 op 未知,则按即发即弃处理,静默忽略。
7.5 消息映射总览
| OpenCode 事件 | 插件到 daemon | BLEop | 设备行为 |
|---|---|---|---|
session.status | POST /notify | session.status | 更新 busy / idle / retry UI,不回复 |
permission.asked | POST /request | permission.request | 显示提示,等待按键,回复once或reject |
| 会话在 BLE 提示过期时转为 idle | POST /notify | permission.cancel | 清除待处理的权限 UI,不回复 |
单飞行(single-flight)约束:设备同一时刻只允许一个待处理权限请求。重叠请求返回busy错误;主机侧插件在发送前对权限请求做排队。
8. 源码级实现纵深
8.1 协议层(ble_protocol.c)
字节流到行的重组。BLE UART 是字节流,不保证消息边界——central 可能把一个 JSON 对象拆成多次写,也可能合并多个小写。因此解析器持有 FreeRTOS 队列,把原始 RX 字节分块缓存,遇到\n才视为一条记录边界(BLE_PROTOCOL_RX_LINE_MAX2048 字节,超长/损坏的行丢弃直到下一个换行再恢复解析;\r被忽略)。ble_protocol_rx_feed设计为ble_uart_config_t::ble_uart_on_rx回调,运行在 NimBLE host 任务上,只做快速入队,实际解析与 cJSON 分配全部交给ble_protocol_rx_task(栈 6144、优先级 5),避免阻塞 BLE 回调。
v1 信封分发。ble_protocol_dispatch_v1内部维护一张静态分发表:
static const ble_protocol_v1_dispatch_t dispatch_table[] = { { .op = "permission.request", .require_request_id = true, ... }, { .op = "permission.cancel", .require_request_id = false, ... }, { .op = "session.status", .require_request_id = false, ... }, };permission.request强制要求非空 id(缺 id 时回bad_request),permission.cancel与session.status不要求。permission.request处理器会校验data.kind、payload.type/title/metadata的完整性与类型,非法即回bad_request;ble_protocol_format_permission_metadata按command → path → url → 第一个字符串子字段的顺序生成"key: value"形式的紧凑元数据(上限 128 字节)。
权限等待与超时。收到合法权限请求后,创建perm_wait任务(栈 4096、优先级 5),等待s_permission_outcome_queue中的决策(once/reject),30 秒(BLE_PROTOCOL_PERMISSION_WAIT_TICKS)无输入则按安全默认回复"reject"并显示超时表情。任务通过"连接代数(connection generation)"校验自己是否仍是当前连接的当前提示——BLE 断开重连、提示被取消或被新提示替换时,只有当前代的任务允许发回复。
单飞行状态机。s_permission_pending与s_pending_request_id由互斥锁保护;新请求到达时先刷新队列中的陈旧决策,若已有待处理提示则回busy。回复必须回显相同 id,daemon/插件据此把设备决策匹配回原始 HTTP/request。
取消与断开清理。permission.cancel(校验data.kind == "permission.cancel")通过内部"cancel"标记唤醒等待任务,不发出迟到决策;ble_protocol_on_ble_disconnected递增连接代数、向 RX 队列投递零长度复位标记(丢弃半截 JSONL 行)、并取消待处理提示。
会话状态处理。session.status是尽力而为的遥测:更新表情/提示但不产生 ACK;若已有权限提示在显示,busy/retry 不会覆盖它,只有 idle 才先取消提示再更新 UI。
遗留命令。ble_protocol_handle_line在 v1 信封不匹配时回退到{"cmd":...}分支。status返回 ack 与设备状态(ready镜像connected、subscribed、系统运行秒数up与空闲堆heap);name把名称裁剪到 24 字节后经nvs_set_str+nvs_commit持久化并提示reboot_required;unpair调用 NimBLE 的ble_store_clear()清除所有绑定对端。
8.2 UI 层(emote_demo.c)
emote_demo.c是 OpenCode 伴生 Demo 的 UI 适配层,负责把协议事件翻译为板级表现。所有显示操作都不在 BLE/解析回调中直接执行,而是投递到s_msg_queue(队列长度 8),由emote_demo_ui_worker_task(栈 8192、优先级 5)串行执行,保证渲染串行化、BLE 回调低延迟。
表情动画与提示文案的映射(均可通过编译宏覆盖):
| 状态 | 表情名 | 提示文本 |
|---|---|---|
| 权限请求等待 | question_05s(循环) | type: 元数据/标题 |
| 批准(单击) | smile_05s | 清除提示 |
| 拒绝(长按) | cry_10s_10s | 清除提示 |
| 超时(30s 无输入) | sigh_20s_20s(独立于 once/reject) | 清除提示 |
| busy | leisure_05s_(循环) | Working... |
| retry | question_05s(循环) | Retry... |
| idle / 等待连接 | smile_05s(连接时循环,断开时播放一次) | 清除提示 |
按键回调emote_demo_permission_key_event_cb只处理BOARD_TOUCH_SOURCE_KEY源,且仅在ble_protocol_permission_is_pending()为真时生效:BUTTON_SINGLE_CLICK提交once,BUTTON_LONG_PRESS_START提交reject。还有一个ble_link监控任务(300 ms 轮询ble_uart_is_connected())驱动连接/断开时的表情切换。
8.3 板级层(board.c / board.h)
board.h 定义了两类触摸事件源:BOARD_TOUCH_SOURCE_KEY(电容键,code为button_event_t)与BOARD_TOUCH_SOURCE_PANEL(面板触摸,code为gfx_touch_event_type_t)。关键可调参数:
BOARD_TOUCH_PAD_0= 7、BOARD_TOUCH_PAD_1= 6(v1.0 仅 pad 7);BOARD_TOUCH_SLIDER_ENABLED:启用触摸滑条处理(默认 1);BOARD_TOUCH_BUTTON_THRESHOLD:触摸按键相对阈值(默认0.05f),值越小越灵敏,按键不触发或误触发时可调。
board.c 中,按键长按阈值为 1200 ms(BOARD_KEY_LONG_PRESS_MS)、短按 245 ms(BOARD_KEY_SHORT_PRESS_MS),通过iot_button_new_touch_button_device注册BUTTON_PRESS_UP / PRESS_DOWN / SINGLE_CLICK / LONG_PRESS_START事件;board_start依次完成 QSPI 显示初始化(bsp_display_new,30 FPS、双缓冲、DMA)、触摸键初始化、emote_gen_player_init、面板触摸(bsp_touch_new+gfx_touch_add,50 ms 轮询)以及emote_gen_player_mount_assets挂载emote_gen分区的资源。
9. 手动测试(无需启动 OpenCode)
可通过 daemon 的 HTTP API 直接测试设备。
发送会话状态更新:
curl -X POST http://127.0.0.1:8888/notify \ -H 'Content-Type: application/json' \ -d '{"op":"session.status","data":{"v":1,"kind":"session.status","event_id":"manual","session_id":"manual","requires_reply":false,"payload":{"type":"busy"}}}'发送权限请求:
curl -X POST http://127.0.0.1:8888/request \ -H 'Content-Type: application/json' \ -d '{"op":"permission.request","timeout":60,"data":{"v":1,"kind":"permission.request","event_id":"manual","session_id":"manual","permission_id":"manual-perm","requires_reply":true,"payload":{"id":"manual-perm","sessionID":"manual","type":"bash","title":"Run command","metadata":{"command":"git status"}}}}'设备应弹出权限提示;单击或长按触摸键后,curl命令应收到 daemon 的 JSON 响应。
10. 遗留维护命令
以下命令仅用于本地维护,不属于 OpenCode 流程:
| 命令 | 描述 |
|---|---|
{"cmd":"status"} | 查询堆、运行时间与 BLE 连接状态 |
{"cmd":"name","name":"..."} | 把 BLE 名称保存到 NVS;重启生效 |
{"cmd":"unpair"} | 清除所有绑定的对端 |
11. 资源与分区小结
- 首次 CMake 配置时,main/CMakeLists.txt 下载
emote_assets.bin至build/prebuilt/emote_assets.bin; - partitions.csv 定义
emote_genSPIFFS 分区(5500K); - sdkconfig.defaults 面向
esp32s3,启用 NimBLE、PSRAM 与 16 MB Flash。
12. 相关文档
- json_format.md:设备 JSONL 协议(本文第 7 节即其完整展开);该文件同时说明,此前文档记载的
hook_event_name/hookSpecificOutput协议已被上述 v1 信封协议取代; $IDF_PATH/tools/ble/ble_uart_bridge/README.md:BLE UART Bridge 总览;$IDF_PATH/tools/ble/ble_uart_bridge/demos/opencode/README.md:OpenCode 插件侧指南。
【免费下载链接】esp-iot-solutionEspressif IoT Library. IoT Device Drivers, Documentations and Solutions.项目地址: https://gitcode.com/GitHub_Trending/es/esp-iot-solution
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考