BLE UART Bridge Demo - OpenCode Integration
This demo sketches how to bridge OpenCode events to a BLE device through
tools/ble/ble_uart_bridge.
It is intentionally small and example-oriented. Both this OpenCode plugin and
the ble_uart_bridge daemon protocol are demos that show one possible local IPC
pattern; developers are encouraged to customize the plugin payloads, firmware UI,
device decisions, and daemon-side protocol handling for their own products.
Table of contents
- Goal
- Quick Start
- How it relates to BLE UART Bridge
- Files
- Demo and customization notes
- Environment variables
- Current assumptions
- Message routing
- Firmware protocol reference
- Open items
Goal
Use an OpenCode plugin to:
- forward session status events to a BLE device;
- forward permission requests to a BLE device;
- receive
once/rejectdecisions from the device; - reply to OpenCode permission requests through the OpenCode SDK client.
flowchart LR
OC[OpenCode] -->|session.status| Plugin[OpenCode BLE plugin]
OC -->|permission.asked| Plugin
Plugin -->|POST /notify| Daemon[ble_uart_bridge daemon]
Plugin -->|POST /request| Daemon
Daemon -->|BLE NUS JSONL| Device[BLE device UI]
Device -->|once / reject| Daemon
Daemon -->|HTTP response| Plugin
Plugin -->|SDK permission reply| OC
Quick Start
-
Prepare a BLE device firmware example.
The intended firmware companion is an
esp-vocatexample for the MiaoBan (喵伴) device, planned for theesp-iot-solutionrepository. Until that example is available, use any device that implements Nordic UART Service and the JSONL request/response envelope described in Firmware protocol reference. -
Install the bridge dependencies:
python -m pip install -r tools/ble/ble_uart_bridge/requirements.txt -
Start the BLE UART daemon:
python tools/ble/ble_uart_bridge/main.py list-devices python tools/ble/ble_uart_bridge/main.py daemon "<device_id>" --host 127.0.0.1 --port 8888The plugin connects to
http://127.0.0.1:8888by default. If the daemon uses another endpoint, inject it withOPENCODE_BLE_DAEMON_URLbefore starting OpenCode:export OPENCODE_BLE_DAEMON_URL="http://127.0.0.1:9999" -
Copy or symlink the plugin into an OpenCode plugin directory, keeping the TypeScript files together in one subdirectory.
Project-level install, for one project only:
mkdir -p <proj-path>/.opencode/plugins/opencode-ble-uart-bridge cp tools/ble/ble_uart_bridge/demos/opencode/src/*.ts <proj-path>/.opencode/plugins/opencode-ble-uart-bridge/User-level install, for all projects that use this OpenCode user config:
mkdir -p ~/.config/opencode/plugins/opencode-ble-uart-bridge cp tools/ble/ble_uart_bridge/demos/opencode/src/*.ts ~/.config/opencode/plugins/opencode-ble-uart-bridge/ -
Merge the relevant parts of
opencode.json.exampleinto youropencode.json.For OpenCode plugin loading details, see the official OpenCode plugin documentation.
Project-level
opencode.jsonexample:{ "plugin": [ ".opencode/plugins/opencode-ble-uart-bridge/opencode-ble-uart-bridge.ts" ], "permission": { "edit": "ask" } }User-level
~/.config/opencode/opencode.jsonexample:{ "plugin": [ "<home-path>/.config/opencode/plugins/opencode-ble-uart-bridge/opencode-ble-uart-bridge.ts" ], "permission": { "edit": "ask" } }plugintells OpenCode which plugin module to load when the session starts. The official docs describe local plugin auto-loading from<proj-path>/.opencode/plugins/and~/.config/opencode/plugins/. This demo keeps the entry file and helper modules together in one subdirectory, soopencode.json.examplepoints directly to the entry TypeScript file. For user-level installs, point this entry to the installed file under~/.config/opencode/plugins/opencode-ble-uart-bridge/; use an absolute home path if your OpenCode config loader does not expand~.permission.edit: "ask"makes OpenCode ask before using theedittool. Those permission prompts are what this plugin forwards to the BLE device aspermission.requestmessages.
-
Start OpenCode after the daemon is running.
OpenCode loads plugins during startup. This demo checks
GET /statuswhile loading and during relevant session events. If the daemon is unreachable, the plugin stays loaded but marks BLE forwarding as disabled and shows an OpenCode TUI notification instead of printing connection errors into the TUI log. When/statusbecomes reachable again, the plugin updates its state and can resume forwarding.To verify the path, trigger an
editpermission request. The BLE device should receive apermission.requestJSONL message and returnonceorreject.
After the esp-vocat example is published in esp-iot-solution, this section
should be updated with the exact example path, build/flash commands, and any
MiaoBan-specific button or display behavior.
How it relates to BLE UART Bridge
The OpenCode plugin does not talk to BLE directly. It sends local HTTP requests to the BLE UART Bridge daemon, and the daemon keeps the BLE connection open for the plugin:
POST /notifysends fire-and-forget events, such as session status updates.POST /requestsends request/response messages, such as permission prompts that must wait for a device decision.GET /statuscan be used by tools to inspect daemon health and connection state.
For the daemon itself, see:
Daemon JSONL protocol summary
The daemon forwards HTTP messages over BLE UART as newline-delimited JSON
(JSONL). Every BLE message is one JSON object followed by a final \n. The
example daemon protocol is named esp-jsonl-rpc-lite-v1.
Host-to-device messages use a small envelope:
{"v":1,"id":"<bridge-request-id>","op":"permission.request","data":{"v":1,"kind":"permission.request"}}
Device-to-host responses echo the same id and return either data or an
error:
{"v":1,"id":"<bridge-request-id>","ok":true,"data":{"decision":"once"}}
The daemon envelope is only a demonstration protocol, not a complete RPC
framework. It is designed to be easy to inspect, easy to parse with firmware
JSON libraries such as cJSON, and easy to replace with an application-specific
protocol when needed. The OpenCode-specific payloads carried in the data field
are documented below in Firmware protocol reference.
Files
src/opencode-ble-uart-bridge.ts— OpenCode plugin entry point using/notifyfor status and/requestfor permission decisions.src/*.tshelper modules — typed, commented demo code for payloads, BLE daemon transport, OpenCode replies, and permission queue handling.opencode.json.example— example OpenCode config to load the plugin and ask for permissions.
Demo and customization notes
This directory is meant to be copied, modified, and used as a starting point:
- Change
src/permission-payload.tsif the BLE device needs a different display model for OpenCode permissions. - Change the Firmware protocol reference and the firmware parser together if you add more message kinds, decision types, buttons, or display states.
- Set
OPENCODE_BLE_DAEMON_URLif the daemon runs on a different local host/port. - Keep local security requirements in mind. The daemon defaults to
127.0.0.1:8888and exposes unauthenticated local HTTP endpoints; do not bind it to a public interface without adding your own access control.
The current demo intentionally keeps the BLE device decision model simple:
permission requests can be approved once with once or denied with reject.
Environment variables
OPENCODE_BLE_DAEMON_URL: BLE daemon base URL. Defaults tohttp://127.0.0.1:8888.OPENCODE_BLE_DECISION_TIMEOUT_SECONDS: permission decision timeout in seconds. Defaults to60; set it to a positive number.OPENCODE_BLE_DEBUG=1: enables verbose local plugin logs.
Current assumptions
- The BLE daemon endpoint is configured by
OPENCODE_BLE_DAEMON_URL, defaulting tohttp://127.0.0.1:8888. - The BLE daemon supports both
POST /notifyandPOST /request. - The BLE device implements Nordic UART Service.
- The BLE device understands JSON messages described in Firmware protocol reference.
- Permission decisions from the current single-key device are:
once,reject. - Permission requests are queued so the BLE device only displays one active prompt at a time.
- The plugin fills missing permission
type/title/metadatafields before sending to the device. - Permission metadata sent to BLE is compacted to one display field (
command,path,url, or first string field) and truncated. - Session status forwarding is best-effort and should not block OpenCode.
- The plugin checks daemon
/statusto maintain a connected, degraded, or disabled BLE forwarding state. State changes are reported with OpenCode TUI notifications whenclient.tui.showToastis available. - If BLE forwarding is disabled or the BLE daemon cannot return a permission
decision, the plugin replies
reject.
Message routing
session.statususesPOST /notifybecause it is telemetry and does not require a device response.permission.requestusesPOST /requestbecause OpenCode must wait for the device'sonce/rejectdecision.permission.cancelusesPOST /notifybecause it only tells the device to clear a pending permission UI.- The plugin sends structured JSON objects as daemon
data; it does not double-encode plugin payloads as JSON strings.
Firmware protocol reference
The BLE UART Bridge daemon wraps plugin messages into JSONL over BLE UART. For
request/response RPC, POST /request sends a non-empty bridge request ID:
{"v":1,"id":"<bridge-request-id>","op":"permission.request","data":{"v":1,"kind":"permission.request"}}
The BLE device must reply with the same bridge request ID. Successful responses
can return structured JSON in data:
{"v":1,"id":"<bridge-request-id>","ok":true,"data":{"decision":"once"}}
For fire-and-forget telemetry, POST /notify sends an empty bridge request ID.
The BLE device should process the message and must not reply:
{"v":1,"id":"","op":"session.status","data":{"v":1,"kind":"session.status"}}
Both directions are newline-delimited JSON. Plugin payloads are sent as
structured JSON objects in the daemon data field, not as JSON-encoded strings.
Plugin message: session status
Sent through daemon POST /notify when OpenCode publishes session.status.
The daemon envelope uses op: "session.status" and id: "".
{
"v": 1,
"kind": "session.status",
"event_id": "evt_...",
"session_id": "ses_...",
"requires_reply": false,
"payload": {
"type": "busy"
}
}
Device response: none.
Plugin message: permission cancel
Sent through daemon POST /notify when OpenCode reaches session.status: idle
while a BLE permission request is still pending on the device. This covers cases
where the same permission was answered from the OpenCode TUI instead of the BLE
device. The daemon envelope uses op: "permission.cancel" and id: "".
{
"v": 1,
"kind": "permission.cancel",
"event_id": "evt_...",
"session_id": "ses_...",
"requires_reply": false,
"payload": {
"reason": "opencode_state_changed"
}
}
Device response: none. The device should clear any pending permission UI and must not emit a later reply for the cancelled request.
Plugin message: permission request
Sent through daemon POST /request when OpenCode publishes permission.asked.
The daemon envelope uses op: "permission.request" and a non-empty request ID.
{
"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"
}
}
}
Device response in the daemon JSONL envelope:
{
"v": 1,
"id": "<bridge-request-id>",
"ok": true,
"data": {
"decision": "once",
"message": "Approved from BLE device"
}
}
Device error response in the daemon JSONL envelope:
{
"v": 1,
"id": "<bridge-request-id>",
"ok": false,
"error": "device rejected permission"
}
The daemon turns this into an HTTP error for /request; the plugin fails closed
and replies reject to OpenCode.
Valid decisions:
oncereject
Firmware UI sketch
For session.status:
- show
busy,idle, orretry.
For permission.request:
- show permission type/title;
- show compact metadata such as command/path/url;
- expose two actions on the current single-key device:
OnceandReject.
The OpenCode plugin normalizes optional permission fields before forwarding to
the device: missing type becomes unknown, missing title becomes
Permission request, and missing/non-string metadata becomes {}. Metadata is
compacted to one display field (command, path, url, or first string field)
and truncated before crossing BLE.
Safety defaults
- If the device UI times out, return
reject. - If JSON parsing fails, return an error response.
- Keep displayed metadata short to avoid leaking large prompts or secrets.
Open items
- Add an integration test with a mocked BLE daemon.