# 使用 ESP-BLE-UART 与 ESP-VoCat 构建 OpenCode 伴侣设备 > [English](OPENCODE_COMPANION.md) ## 介绍 本文档介绍如何使用 ESP-BLE-UART 和 ESP-VoCat 构建一个 OpenCode 的物理伴侣设备。该设备在显示屏上反映当前会话 (Session) 状态,呈现权限请求 (Permission Request) 供用户审批,并通过单键输入将权限决策返回给 OpenCode。BLE UART 作为设备与主机侧编辑器会话之间的传输层。 本教程分为两个部分。第一部分使用 **ESP-BLE-UART 控制台 (Console)** 搭配 `ble_uart_service` 回显服务器 (Echo Server),验证主机是否能够发现、连接 BLE UART 设备并完成数据交换。第二部分引入 `ble_uart_service` 示例固件(运行于 ESP-VoCat 开发板)、**ESP-BLE-UART 守护进程 (Daemon)** 和 **OpenCode 插件 (Plugin)**,使设备能够接收会话状态更新,并将 `once` / `reject` 权限决策返回给 OpenCode。

ESP-VoCat 与 OpenCode 协同工作
ESP-VoCat 与 OpenCode 协同工作

## 学习目标 - 了解 BLE UART 服务及其 GATT 约定 - 掌握构建和烧录 ESP-BLE-UART 回显服务器的方法 - 理解在 BLE UART 上运行的 JSON Lines 协议 - 掌握 ESP-BLE-UART 守护进程和 OpenCode 插件的配置方法 ## 前置条件 - 主机具备可用的蓝牙适配器和扫描/连接权限。 - ESP-IDF 环境已导出。 - 回显服务器冒烟测试可使用 `ble_uart_service` 支持的任意目标芯片 (Target)。 - 完整 OpenCode UI 演示需要以下环境: - [ESP-VoCat](https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32s3/esp-vocat/index.html) 开发板(基于 ESP32-S3),配备圆形触摸显示屏和单键输入。BLE UART 传输层可以复用,但显示、触摸和表情 UI 为该示例的板级特性。该示例维护在 [esp-iot-solution](https://github.com/espressif/esp-iot-solution) 仓库的 `examples/bluetooth/ble_uart_service` 路径下,支持的板型、依赖版本和构建说明请参考示例 README。 - `ble_uart_service` 示例首次编译配置时需要联网下载 `emote_assets.bin`;离线或内网环境下,请将 `EMOTE_ASSETS_BIN` 设置为本地路径以覆盖下载。 - 安装 OpenCode 以运行插件演示。 安装主机侧 ESP-BLE-UART 桥接工具 (Bridge) 依赖: ```bash cd $IDF_PATH . ./export.sh python -m pip install -r tools/ble/ble_uart_bridge/requirements.txt ``` Windows 下请使用 ESP-IDF 根目录中的 `export.bat` 或 `export.ps1`,不要使用 `. ./export.sh`。 ## 第一部分:ESP-BLE-UART 控制台验证 ### BLE UART 简介 Bluetooth LE 协议中并没有传统串口意义上的 UART 外设。BLE UART 服务 (BLE UART Service) 是一种 GATT 约定:一个特征值 (Characteristic) 作为主机写入设备的 RX 通道,另一个特征值作为设备通知 (Notify) 给主机的 TX 通道。`ble_uart_service` 中的回显服务器使用 Nordic UART Service 风格的 UUID,将收到的字节通过 TX Notify 原样发回,适合用于验证主机侧控制台链路。 传输层只负责搬运字节。第一部分中,这些字节为普通回显文本;第二部分中,运行在 ESP-VoCat 上的 `ble_uart_service` 示例固件会在同一条 BLE UART 通道上叠加 JSONL 协议。 ### 构建并烧录 ESP-BLE-UART 回显服务器 ```bash cd $IDF_PATH/examples/bluetooth/ble_uart_service idf.py set-target esp32s3 # 或其他支持的 target idf.py build flash monitor ``` 配对期间请保持串口监视器打开。中央设备 (Central) 提示输入配对密钥 (Passkey) 时,输入固件日志中打印的六位数字即可。固件控制台输出应类似以下日志(地址和设备名后缀会有所不同): ``` I (548) ble_uart: BLE host task started I (548) ble_uart: registered service 0x1800 handle=1 I (548) ble_uart: registered chr 0x2a00 def=2 val=3 I (548) ble_uart: registered chr 0x2a01 def=4 val=5 I (558) ble_uart: registered service 0x1801 handle=6 I (558) ble_uart: registered chr 0x2a05 def=7 val=8 I (568) ble_uart: registered chr 0x2b3a def=10 val=11 I (568) ble_uart: registered chr 0x2b29 def=12 val=13 I (578) ble_uart: registered service 6e400001-b5a3-f393-e0a9-e50e24dcca9e handle=14 I (578) ble_uart: registered chr 6e400002-b5a3-f393-e0a9-e50e24dcca9e def=15 val=16 I (588) ble_uart: registered chr 6e400003-b5a3-f393-e0a9-e50e24dcca9e def=17 val=18 I (608) NimBLE: GAP procedure initiated: stop advertising. I (608) NimBLE: GAP procedure initiated: stop advertising. I (608) ble_uart: addr=74:4d:bd:a9:ed:72 I (608) NimBLE: GAP procedure initiated: advertise; I (618) NimBLE: disc_mode=2 I (618) NimBLE: adv_channel_map=0 own_addr_type=0 adv_filter_policy=0 adv_itvl_min=0 adv_itvl_max=0 I (628) NimBLE: I (628) ble_uart: advertising as 'BleUart-ED72' I (628) main_task: Returned from app_main() ``` `ble_uart: addr=74:4d:bd:a9:ed:72` 这条日志指示了设备的蓝牙 MAC 地址为 `74:4D:BD:A9:ED:72`。设备以最后一行 `ble_uart: advertising as 'BleUart-XXXX'` 中显示的名称广播。 当中央设备发起连接时,固件会输出配对密钥提示。如果系统蓝牙对话框或 `connection-check` 命令要求输入配对密钥,请输入监视器中显示的六位数字: ``` W (19298) ble_uart: +-----------------------------+ W (19298) ble_uart: | BLE PAIRING PASSKEY: | W (19298) ble_uart: | 617138 | W (19298) ble_uart: +-----------------------------+ ``` ### 扫描 BLE UART 设备 打开第二个终端: ```bash cd $IDF_PATH/tools/ble/ble_uart_bridge python main.py list-devices ``` 将输出中的设备标识记为 `DEVICE_ID`。可以通过输出中的 MAC 地址或设备名来判断是否扫描到了目标设备。在 Linux 上,设备 MAC 地址会直接显示: ``` > python main.py list-devices 2026-06-05 11:19:56.728 | INFO | src.core.scanner:scan_devices:42 - Scanning for nearby BLE devices in 5.0s... 2026-06-05 11:19:57.108 | SUCCESS | src.core.scanner:on_detect:39 - Found: 74:4D:BD:A9:ED:72, with name BleUart-ED72, rssi=-46 ``` 此输出中的 MAC 地址 `74:4D:BD:A9:ED:72` 和设备名 `BleUart-ED72` 均与上述固件日志一致。 在 macOS 上,由于系统限制,工具无法显示设备的真实蓝牙 MAC 地址,而是分配一个 CoreBluetooth UUID 作为设备标识。需要通过匹配 `list-devices` 输出中的设备名(此例中为 `BleUart-ED72`)与固件日志中的广播名,找到对应的 UUID: ``` > python main.py list-devices 2026-06-05 11:19:56.728 | INFO | src.core.scanner:scan_devices:42 - Scanning for nearby BLE devices in 5.0s... 2026-06-05 11:19:57.108 | SUCCESS | src.core.scanner:on_detect:39 - Found: 5BA2476C-CDD2-BF3F-F98C-252CFA45F8B5, with name BleUart-ED72, rssi=-46 ``` 此例中的 `5BA2476C-CDD2-BF3F-F98C-252CFA45F8B5` 即 macOS 下用作 `DEVICE_ID` 的 CoreBluetooth UUID。 ### 打开控制台前检查连接 ```bash python main.py connection-check "" ``` 该命令会连接设备、发现 BLE UART 服务和特征值,然后断开。在 Linux 或 Windows 上,将设备 MAC 地址作为 `DEVICE_ID` 传入: ``` > python main.py connection-check 74:4D:BD:A9:ED:72 2026-06-05 12:06:27.252 | INFO | src.core.bridge:connect:139 - Connecting to 74:4D:BD:A9:ED:72... 2026-06-05 12:06:37.460 | SUCCESS | src.core.bridge:connect:206 - Succeeded to connect to 74:4D:BD:A9:ED:72! 2026-06-05 12:06:37.461 | INFO | src.core.bridge:_disconnect_locked:86 - Disconnecting from 74:4D:BD:A9:ED:72... 2026-06-05 12:06:37.461 | INFO | src.core.bridge:_handle_disconnect:120 - Disconnected from 74:4D:BD:A9:ED:72 ``` 在 macOS 上,改用 CoreBluetooth UUID: ``` > python main.py connection-check 5BA2476C-CDD2-BF3F-F98C-252CFA45F8B5 2026-06-05 12:06:27.252 | INFO | src.core.bridge:connect:139 - Connecting to 5BA2476C-CDD2-BF3F-F98C-252CFA45F8B5... 2026-06-05 12:06:37.460 | SUCCESS | src.core.bridge:connect:206 - Succeeded to connect to 5BA2476C-CDD2-BF3F-F98C-252CFA45F8B5! 2026-06-05 12:06:37.461 | INFO | src.core.bridge:_disconnect_locked:86 - Disconnecting from 5BA2476C-CDD2-BF3F-F98C-252CFA45F8B5... 2026-06-05 12:06:37.461 | INFO | src.core.bridge:_handle_disconnect:120 - Disconnected from 5BA2476C-CDD2-BF3F-F98C-252CFA45F8B5 ``` 如果此步骤失败,请先解决扫描、配对、权限或广播问题,再继续使用守护进程或 OpenCode。 ### 打开 ESP-BLE-UART 控制台 ```bash python main.py console "" --terminator lf ``` 在控制台中输入一行短文本并按 Enter: ``` hello from console ``` 预期结果: ``` [INFO] Connected to 68:B6:B3:55:41:76 [TX] hello from console [RX] hello from console ``` - Bluetooth LE 地址因设备而异。 - 控制台中显示输入内容对应的 `[TX]` 行。 - ESP-BLE-UART 示例将相同字节回显,并显示为 `[RX]` 输出。 至此,Bluetooth LE 扫描、连接、主机到设备写入、设备到主机通知 (Notify) 均已验证通过。JSONL 协议(用于 ESP-VoCat 示例)、守护进程和 OpenCode 插件是叠加在该链路之上的应用层,不替代该链路本身。 更多控制台选项(如十六进制模式、带响应写入 (Write with Response)、不同换行符)请参考 [`tools/ble/ble_uart_bridge/docs/Quick-Start-BLE-UART-Console.md`](../../../tools/ble/ble_uart_bridge/docs/Quick-Start-BLE-UART-Console.md)。 ## 第二部分:ESP-VoCat OpenCode 伴侣设备 ### ESP-VoCat 简介 [ESP-VoCat](https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32s3/esp-vocat/index.html) 是基于 ESP32-S3 模组的智能 AI 开发套件,配备圆形触摸显示屏和单键输入。 [esp-iot-solution](https://github.com/espressif/esp-iot-solution) 仓库中包含一个 `ble_uart_service` 示例(位于 `examples/bluetooth/ble_uart_service`),该示例运行在 ESP-VoCat 开发板上。此示例固件将会话状态渲染为表情动画,并将权限请求呈现到屏幕上供用户物理审批。支持的板型、所需组件版本和构建说明请参考 esp-iot-solution 中的示例 README。 ### 选择 JSON Lines 的原因 Bluetooth LE 写入按 ATT MTU 分包,不等同于应用层消息边界。ESP-VoCat 的 OpenCode 流程在 BLE UART 上使用 JSON Lines (JSONL)。选择 JSONL 的原因:日志和控制台中可直接阅读,便于手动测试时输入,固件侧可用 cJSON 解析,且同时支持请求/响应和即发即弃两种消息模式。 ### 架构概述 ESP-BLE-UART 桥接工具和 OpenCode 演示插件已包含在 ESP-IDF 的 master 及 `release/v5.2` 及以上 release 分支中,位于 `tools/ble/ble_uart_bridge/` 目录下。`ble_uart_service` 示例实现设备侧协议,源码位于 [esp-iot-solution](https://github.com/espressif/esp-iot-solution) 仓库。 ```mermaid flowchart LR OC[OpenCode] -->|session.status / permission.asked| Plugin[OpenCode Plugin] Plugin -->|POST /notify| Daemon[ESP-BLE-UART Daemon] Plugin -->|POST /request| Daemon Daemon -->|Bluetooth LE write: NUS RX JSONL| ESP[ble_uart_service 示例] ESP -->|Bluetooth LE notify: NUS TX JSONL| Daemon Daemon -->|HTTP response| Plugin Plugin -->|permission reply| OC ESP --> Display[Emote + Tip Text] ESP --> Key[Single Key: once / reject] ``` 各层均可独立替换: - 控制台验证原始 BLE UART 链路; - 守护进程维持一个 Bluetooth LE 连接并提供本地 HTTP API; - OpenCode 插件将编辑器事件转换为守护进程请求/通知; - `ble_uart_service` 示例在 ESP-VoCat 设备上显示状态和权限提示 (Permission Prompt)。 ### 启动 ESP-BLE-UART 守护进程 首先从 [esp-iot-solution](https://github.com/espressif/esp-iot-solution) 仓库将 `ble_uart_service` 示例烧录到 ESP-VoCat 开发板上,该示例与第一部分的控制台回显服务器为不同应用: ```bash # 如尚未克隆 esp-iot-solution,先执行克隆 git clone https://github.com/espressif/esp-iot-solution.git cd esp-iot-solution/examples/bluetooth/ble_uart_service idf.py set-target esp32s3 idf.py build flash monitor ``` 依赖版本和板级配置详见 esp-iot-solution 中的示例 README。 然后重新扫描设备,并将 ESP-VoCat 的设备标识记为 `VOCAT_DEVICE_ID`: ```bash cd $IDF_PATH/tools/ble/ble_uart_bridge python main.py list-devices python main.py connection-check "" ``` 使用该 ESP-VoCat 设备启动守护进程: ```bash cd $IDF_PATH/tools/ble/ble_uart_bridge python main.py daemon "" --host 127.0.0.1 --port 8888 ``` > **Note:** 守护进程 HTTP API 未内置认证机制。除非自行添加访问控制,否则请保持绑定在 `127.0.0.1`。 在另一个终端检查守护进程状态: ```bash cd $IDF_PATH/tools/ble/ble_uart_bridge python main.py daemon-status ``` ### 在接入 OpenCode 前通过守护进程验证 ESP-VoCat 不要使用通用 `echo` 请求验证 ESP-VoCat,该固件未实现 echo 操作。请使用 [esp-iot-solution 示例中的 json_format.md](https://github.com/espressif/esp-iot-solution/blob/master/examples/bluetooth/ble_uart_service/json_format.md) 中定义的操作。 会话状态 (Session Status) 冒烟测试: ```bash python main.py daemon-notify --op session.status --json '{ "v": 1, "kind": "session.status", "event_id": "evt_manual", "session_id": "ses_manual", "requires_reply": false, "payload": { "type": "busy" } }' python main.py daemon-notify --op session.status --json '{ "v": 1, "kind": "session.status", "event_id": "evt_manual", "session_id": "ses_manual", "requires_reply": false, "payload": { "type": "idle" } }' ``` CLI 将每个 JSON 对象作为守护进程信封 (Envelope) 的 `data` 字段发送,并设置 `op: "session.status"` 和 `id: ""`。固件通过 Bluetooth LE 收到完整 JSONL 信封后,更新显示但不返回响应。 权限请求 (Permission Request) 冒烟测试: ```bash python main.py daemon-send --op permission.request --timeout 35 --json '{ "v": 1, "kind": "permission.request", "event_id": "evt_manual", "session_id": "ses_manual", "permission_id": "perm_manual", "requires_reply": true, "payload": { "id": "perm_manual", "sessionID": "ses_manual", "type": "bash", "title": "Run idf.py build", "metadata": { "command": "idf.py build" } } }' ``` ESP-VoCat 设备应显示一个权限提示: | ESP-VoCat 输入 | 设备响应 | |----------------|---------------------| | 单击 | `decision: "once"` | | 长按 | `decision: "reject"` | | 30 秒无输入 | `decision: "reject"` | 该手动守护进程测试验证的是 OpenCode 插件后续使用的同一条请求/响应链路。 ### 安装 OpenCode 演示插件 OpenCode 演示插件已包含在 ESP-IDF 中,位于 `tools/ble/ble_uart_bridge/demos/opencode`。 项目级安装: ```bash mkdir -p /.opencode/plugins/opencode-ble-uart-bridge cp $IDF_PATH/tools/ble/ble_uart_bridge/demos/opencode/src/*.ts \ /.opencode/plugins/opencode-ble-uart-bridge/ ``` 用户级安装: ```bash mkdir -p ~/.config/opencode/plugins/opencode-ble-uart-bridge cp $IDF_PATH/tools/ble/ble_uart_bridge/demos/opencode/src/*.ts \ ~/.config/opencode/plugins/opencode-ble-uart-bridge/ ``` 然后配置 OpenCode。项目级安装时,将以下内容放入 `/opencode.json`,或合并到已有配置中: ```json { "$schema": "https://opencode.ai/config.json", "plugin": [ ".opencode/plugins/opencode-ble-uart-bridge/opencode-ble-uart-bridge.ts" ], "permission": { "edit": "ask" } } ``` 用户级安装时,将 `plugin` 指向 `~/.config/opencode/plugins/opencode-ble-uart-bridge/` 下的入口文件。如果配置加载器不展开 `~`,请使用绝对路径。 常用插件环境变量: ```bash export OPENCODE_BLE_DAEMON_URL="http://127.0.0.1:8888" export OPENCODE_BLE_DECISION_TIMEOUT_SECONDS=60 export OPENCODE_BLE_DEBUG=1 ``` 修改插件文件、`opencode.json` 或上述环境变量后,需要重启 OpenCode。 ### 运行 OpenCode 演示 1. 保持固件运行,并处于广播或已连接状态。 2. 保持 ESP-BLE-UART 守护进程运行在 `127.0.0.1:8888`。 3. 在已配置插件的项目中启动 OpenCode。 4. 触发一次权限提示,例如当 `permission.edit` 设置为 `ask` 时执行编辑操作。 预期结果: - OpenCode 会话状态以尽力传递 (Best-effort) 方式转发为 `session.status`; - ESP-VoCat 显示 busy/idle/retry 表情; - 权限提示显示到 ESP-VoCat,附带命令、路径、URL 等紧凑元数据 (Metadata); - 单击返回 `once` 给 OpenCode; - 长按或超时返回 `reject`。 如需演示 bash 命令或工具执行权限,请确认 OpenCode 权限配置确实会对该工具类别发起询问;否则建议使用编辑权限 (Edit Permission) 作为触发路径。

ESP-VoCat 请求权限
ESP-VoCat 请求权限

> **Note:** 如需全面了解 Bluetooth Low Energy,请参见 [Bluetooth LE 概览](../../../docs/zh_CN/api-guides/ble/overview.rst)。关于 Bluetooth LE 连接管理和数据交换,请参考 [Bluetooth LE 多连接指南](../../../docs/zh_CN/api-guides/ble/ble-multiconnection-guide.rst)。 ## 协议参考 固件协议详见 `ble_uart_service` 示例中的 `json_format.md`,位于 [esp-iot-solution](https://github.com/espressif/esp-iot-solution) 仓库 (`examples/bluetooth/ble_uart_service/json_format.md`)。外层守护进程信封格式如下: ``` {"v":1,"id":"","op":"","data":{}} ``` - `id` 非空表示请求/响应操作,例如 `permission.request`; - `id` 为空表示即发即弃通知,例如 `session.status` 和 `permission.cancel`; - 设备响应会带回相同的非空 `id`,并返回 `ok/data` 或 `ok:false/error`。 Bluetooth LE 上的 JSONL 权限请求示例: ```json { "v": 1, "id": "perm-001", "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 idf.py build", "metadata": { "command": "idf.py build" } } } } ``` 设备响应示例: ```json { "v": 1, "id": "perm-001", "ok": true, "data": { "decision": "once", "message": "Approved from BLE device" } } ``` `permission.cancel` 用于清理过期的权限提示,不再发送迟到的决策。适用于用户已在 OpenCode 终端界面 (TUI) 中处理了权限请求、但 ESP-VoCat 仍在显示权限提示的情况。 ## 故障排查 - **扫描不到设备:** 确认主机蓝牙权限、固件正在广播、设备距离足够近。先用 `list-devices` 和 `connection-check` 排查。 - **控制台可用但守护进程不可用:** 确认控制台已关闭;该固件同一时间只接受一个 Bluetooth LE 连接。 - **守护进程断开连接:** 守护进程没有后台自动重连循环。当下一次 `/request` 或 `/notify` HTTP 请求到达时,守护进程会尝试按需重连。如果设备连续多次不可达,守护进程将自动退出。可使用 `daemon-status` 查看当前连接状态和重连失败计数。 - **OpenCode 未转发事件:** 确认 `OPENCODE_BLE_DAEMON_URL`,运行 `daemon-status`,修改配置后重启 OpenCode。 - **权限请求超时:** 确认设备收到的是带非空 `id` 的 `permission.request`,没有旧的权限提示仍在等待中,且用户在超时前已按下按键。 - **出现意外拒绝:** 演示采用失败即关闭 (Fail-closed) 设计。Bluetooth LE 链路、守护进程、插件或设备决策处理中任一环节失败,OpenCode 侧都会拒绝而非静默允许。 - **配对失败:** 检查固件日志中打印的配对密钥 (Passkey),确认中央设备端输入的是同一个值。 - **认证不足 (Insufficient Authentication):** 如果连接或特征值访问时报认证错误,请先通过系统蓝牙设置完成设备配对,并输入固件监视器日志中显示的六位数配对密钥。部分桌面 Bluetooth LE 协议栈要求在系统层面完成显式配对后,GATT 操作才能成功。 ## 扩展方向 - 增加更多输入手势,支持 `always`、`edit` 或"本会话拒绝"。 - 为命令/路径/URL 元数据设计更丰富的显示布局。 - 在设备侧增加权限提示超时设置。 - 为低风险命令增加允许列表 (Allowlist)。 - 使用模拟守护进程和模拟固件响应进行集成测试。 - 如需更低开销,将 JSONL 替换为紧凑二进制协议。 ## 总结 本演示中的每一层均保持简单且可独立测试:控制台验证原始 BLE UART 链路,守护进程将一个 Bluetooth LE 连接转换为本地 HTTP 桥接,OpenCode 插件将编辑器事件映射为守护进程请求,ESP-VoCat 提供物理 UI。任意一层均可单独替换而不影响其余部分。