docs(ble): add ESP-BLE-UART companion guide

Move the OpenCode companion guide into the ble_uart_service example.

Add English and Chinese Markdown guides with image assets.

Keep ESP-BLE-UART naming consistent across the example and bridge tooling.


(cherry picked from commit 926111e721)

Co-authored-by: Zhou Xiao <zhouxiao@espressif.com>
This commit is contained in:
Zhou Xiao
2026-06-08 14:14:49 +08:00
parent c3640229af
commit 6766a9ba67
30 changed files with 1072 additions and 112 deletions
@@ -1,4 +1,4 @@
# BLE UART Bridge Demo - OpenCode Integration
# ESP-BLE-UART Bridge Demo - OpenCode Integration
This demo sketches how to bridge OpenCode events to a BLE device through
`tools/ble/ble_uart_bridge`.
@@ -12,7 +12,7 @@ device decisions, and daemon-side protocol handling for their own products.
- [Goal](#goal)
- [Quick Start](#quick-start)
- [How it relates to BLE UART Bridge](#how-it-relates-to-ble-uart-bridge)
- [How it relates to ESP-BLE-UART Bridge](#how-it-relates-to-esp-ble-uart-bridge)
- [Daemon JSONL protocol summary](#daemon-jsonl-protocol-summary)
- [Files](#files)
- [Demo and customization notes](#demo-and-customization-notes)
@@ -40,7 +40,7 @@ Use an OpenCode plugin to:
flowchart LR
OC[OpenCode] -->|session.status| Plugin[OpenCode BLE plugin]
OC -->|permission.asked| Plugin
Plugin -->|POST /notify| Daemon[ble_uart_bridge daemon]
Plugin -->|POST /notify| Daemon[ESP-BLE-UART Daemon]
Plugin -->|POST /request| Daemon
Daemon -->|BLE UART JSONL| Device[BLE device UI]
Device -->|once / reject| Daemon
@@ -52,10 +52,13 @@ flowchart LR
1. Prepare a BLE device firmware example.
The intended firmware companion is an `esp-vocat` example for the MiaoBan
(喵伴) device, planned for the `esp-iot-solution` repository. Until that
example is available, use any device that implements the default BLE UART-over-GATT UUIDs and
the JSONL request/response envelope described in
The intended firmware companion is the `esp-vocat` example for the MiaoBan
(喵伴) device, available in the
[esp-iot-solution](https://github.com/espressif/esp-iot-solution) repository
at `examples/bluetooth/ble_uart_service`. See the example README for
supported boards, dependency versions, and build instructions. Alternatively,
use any device that implements the default BLE UART-over-GATT UUIDs and the
JSONL request/response envelope described in
[Firmware protocol reference](#firmware-protocol-reference).
2. Install the bridge dependencies:
@@ -64,7 +67,7 @@ flowchart LR
python -m pip install -r tools/ble/ble_uart_bridge/requirements.txt
```
3. Start the BLE UART daemon:
3. Start the ESP-BLE-UART Daemon:
```bash
python tools/ble/ble_uart_bridge/main.py list-devices
@@ -152,14 +155,15 @@ flowchart LR
should receive a `permission.request` JSONL message and return `once` or
`reject`.
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.
The `esp-vocat` example is available in the
[esp-iot-solution](https://github.com/espressif/esp-iot-solution) repository at
`examples/bluetooth/ble_uart_service`. See the example README for build/flash
commands, dependency versions, and MiaoBan-specific button and display behavior.
## How it relates to BLE UART Bridge
## How it relates to ESP-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
to the ESP-BLE-UART Daemon, and the Daemon keeps the BLE connection open for
the plugin:
- `POST /notify` sends fire-and-forget events, such as session status updates.
@@ -170,8 +174,8 @@ the plugin:
For the daemon itself, see:
- [BLE UART Bridge README](../../README.md)
- [BLE UART Daemon Quick Start](../../docs/Quick-Start-BLE-UART-Daemon.md)
- [ESP-BLE-UART Bridge README](../../README.md)
- [ESP-BLE-UART Daemon Quick Start](../../docs/Quick-Start-BLE-UART-Daemon.md)
### Daemon JSONL protocol summary
@@ -201,7 +205,7 @@ are documented below in [Firmware protocol reference](#firmware-protocol-referen
## Files
- `src/opencode-ble-uart-bridge.ts` — OpenCode plugin entry point using `/notify` for status and `/request` for permission decisions.
- `src/*.ts` helper modules — typed, commented demo code for payloads, BLE daemon transport, OpenCode replies, and permission queue handling.
- `src/*.ts` helper modules — typed, commented demo code for payloads, ESP-BLE-UART 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
@@ -224,7 +228,7 @@ permission requests can be approved once with `once` or denied with `reject`.
## Environment variables
- `OPENCODE_BLE_DAEMON_URL`: BLE daemon base URL. Defaults to
- `OPENCODE_BLE_DAEMON_URL`: ESP-BLE-UART Daemon base URL. Defaults to
`http://127.0.0.1:8888`.
- `OPENCODE_BLE_DECISION_TIMEOUT_SECONDS`: permission decision timeout in
seconds. Defaults to `60`; set it to a positive number.
@@ -232,9 +236,9 @@ permission requests can be approved once with `once` or denied with `reject`.
## Current assumptions
- The BLE daemon endpoint is configured by `OPENCODE_BLE_DAEMON_URL`, defaulting
- The ESP-BLE-UART Daemon endpoint is configured by `OPENCODE_BLE_DAEMON_URL`, defaulting
to `http://127.0.0.1:8888`.
- The BLE daemon supports both `POST /notify` and `POST /request`.
- The ESP-BLE-UART Daemon supports both `POST /notify` and `POST /request`.
- The BLE device implements the default BLE UART-over-GATT UUID layout.
- The BLE device understands JSON messages described in
[Firmware protocol reference](#firmware-protocol-reference).
@@ -247,7 +251,7 @@ permission requests can be approved once with `once` or denied with `reject`.
- The plugin checks daemon `/status` to maintain a connected, degraded, or
disabled BLE forwarding state. State changes are reported with OpenCode TUI
notifications when `client.tui.showToast` is available.
- If BLE forwarding is disabled or the BLE daemon cannot return a permission
- If BLE forwarding is disabled or the ESP-BLE-UART Daemon cannot return a permission
decision, the plugin replies `reject`.
## Message routing
@@ -259,7 +263,7 @@ permission requests can be approved once with `once` or denied with `reject`.
## Firmware protocol reference
The BLE UART Bridge daemon wraps plugin messages into JSONL over BLE UART. For
The ESP-BLE-UART Daemon wraps plugin messages into JSONL over BLE UART. For
request/response RPC, `POST /request` sends a non-empty bridge request ID:
```json
@@ -410,4 +414,4 @@ and truncated before crossing BLE.
## Open items
- Add an integration test with a mocked BLE daemon.
- Add an integration test with a mocked ESP-BLE-UART Daemon.
@@ -7,7 +7,7 @@ import { isPermissionDecision } from "./opencode-permission-reply"
import type { BridgeResponse, DaemonResponse, DaemonStatus } from "./types"
/**
* Check whether the local BLE daemon is reachable.
* Check whether the local ESP-BLE-UART Daemon is reachable.
*
* This probe is intentionally silent: OpenCode loads plugins during startup, so
* a missing optional daemon must not print fetch errors into the OpenCode UI.
@@ -25,13 +25,13 @@ export async function isDaemonAvailable(): Promise<boolean> {
export async function getDaemonStatus(): Promise<DaemonStatus> {
const response = await fetch(`${BLE_DAEMON_URL}/status`)
if (!response.ok) {
throw new Error(`BLE daemon status failed: HTTP ${response.status}`)
throw new Error(`ESP-BLE-UART Daemon status check failed: HTTP ${response.status}`)
}
return (await response.json()) as DaemonStatus
}
/**
* Normalize the BLE daemon's response envelope into a permission response.
* Normalize the ESP-BLE-UART Daemon's response envelope into a permission response.
*
* The daemon supports both nested `data`/`response` envelopes and a direct
* top-level decision. Keeping that tolerance here prevents transport quirks
@@ -61,11 +61,11 @@ function parseBridgeResponse(body: DaemonResponse): BridgeResponse {
return body as BridgeResponse
}
throw new Error(`BLE daemon returned an invalid response payload: ${JSON.stringify(body)}`)
throw new Error(`ESP-BLE-UART Daemon returned an invalid response payload: ${JSON.stringify(body)}`)
}
/**
* Send a one-way notification to the BLE daemon.
* Send a one-way notification to the ESP-BLE-UART Daemon.
*
* Use this for events such as session status updates or permission cancellation,
* where the BLE device should update its UI but OpenCode is not waiting for a
@@ -79,12 +79,12 @@ export async function notifyBLE(op: string, data: unknown): Promise<void> {
})
if (!response.ok) {
throw new Error(`BLE daemon notify failed: HTTP ${response.status}`)
throw new Error(`ESP-BLE-UART Daemon notify failed: HTTP ${response.status}`)
}
}
/**
* Send a request to the BLE daemon and wait for a structured response.
* Send a request to the ESP-BLE-UART Daemon and wait for a structured response.
*
* Permission prompts use this path because OpenCode cannot continue until the
* BLE device returns a decision or the request times out.
@@ -101,7 +101,7 @@ export async function sendRequestToBLE(op: string, data: unknown, timeoutSeconds
})
if (!response.ok) {
throw new Error(`BLE daemon request failed: HTTP ${response.status}`)
throw new Error(`ESP-BLE-UART Daemon request failed: HTTP ${response.status}`)
}
return parseBridgeResponse((await response.json()) as DaemonResponse)
@@ -31,7 +31,7 @@ export const METADATA_DISPLAY_KEYS = ["command", "path", "url"] as const
/** Enables verbose local console logging when OPENCODE_BLE_DEBUG=1. */
export const DEBUG = process.env.OPENCODE_BLE_DEBUG === "1"
/** HTTP base URL of the local BLE daemon that bridges OpenCode to the BLE device. */
/** HTTP base URL of the local ESP-BLE-UART Daemon that bridges OpenCode to the BLE device. */
export const BLE_DAEMON_URL = process.env.OPENCODE_BLE_DAEMON_URL ?? "http://127.0.0.1:8888"
const DEFAULT_DECISION_TIMEOUT_SECONDS = 60
@@ -39,12 +39,12 @@ function stateMessage(state: BLEPluginState, status?: DaemonStatus): string {
status?.reconnect_failures !== undefined && status.max_reconnect_failures !== undefined
? ` (${status.reconnect_failures}/${status.max_reconnect_failures} reconnect failures)`
: ""
return `BLE UART daemon is reachable, but the device is disconnected${attempts}. The next BLE send will try to reconnect.`
return `ESP-BLE-UART Daemon is reachable, but the device is disconnected${attempts}. The next BLE send will try to reconnect.`
}
if (status?.daemon_state === "exiting") {
return "BLE UART daemon is exiting after repeated reconnect failures. BLE forwarding is disabled."
return "ESP-BLE-UART Daemon is exiting after repeated reconnect failures. BLE forwarding is disabled."
}
return "BLE UART daemon is unreachable. BLE forwarding is disabled until the daemon is available."
return "ESP-BLE-UART Daemon is unreachable. BLE forwarding is disabled until the daemon is available."
}
async function notifyStateChange(
@@ -54,7 +54,7 @@ async function notifyStateChange(
): Promise<void> {
const variant = state === "connected" ? "success" : state === "degraded" ? "warning" : "error"
const message = stateMessage(state, status)
await showToastBestEffort(client, variant, "OpenCode BLE UART Bridge", message)
await showToastBestEffort(client, variant, "OpenCode ESP-BLE-UART Bridge", message)
await appLogBestEffort(client, variant === "error" ? "error" : variant === "warning" ? "warn" : "info", message, {
state,
status,
@@ -95,7 +95,7 @@ export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directo
bleState = "disabled"
if (shouldNotify) {
await notifyStateChange(openCodeClient, "disabled")
await appLogBestEffort(openCodeClient, "warn", "BLE UART daemon status check failed", { error: String(error) })
await appLogBestEffort(openCodeClient, "warn", "ESP-BLE-UART Daemon status check failed", { error: String(error) })
}
}
return bleState
@@ -124,7 +124,7 @@ export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directo
// Forwarding session status is best-effort background work. Do not
// await this async IIFE from the OpenCode event callback; otherwise a
// slow or unavailable BLE daemon could block OpenCode's own event loop.
// slow or unavailable ESP-BLE-UART Daemon could block OpenCode's own event loop.
void (async () => {
try {
const previousState = bleState
@@ -139,7 +139,7 @@ export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directo
await showToastBestEffort(
openCodeClient,
"success",
"OpenCode BLE UART Bridge",
"OpenCode ESP-BLE-UART Bridge",
"BLE UART device is connected for this OpenCode session.",
)
}
@@ -149,8 +149,8 @@ export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directo
if (
// If the session became idle while a BLE permission prompt is
// active, mark that prompt as externally resolved and ask the BLE
// daemon to dismiss it. This prevents an old prompt from being
// active, mark that prompt as externally resolved and ask the
// ESP-BLE-UART Daemon to dismiss it. This prevents an old prompt from being
// answered after OpenCode no longer needs the decision. Only idle
// triggers this cancellation: busy/retry are normal activity
// transitions and should not dismiss an actively displayed prompt.
@@ -174,7 +174,7 @@ export async function replyToOpenCodePermission(
throw new Error("OpenCode client does not expose a permission reply API")
}
/** Runtime guard for permission decisions parsed from BLE daemon JSON. */
/** Runtime guard for permission decisions parsed from ESP-BLE-UART Daemon JSON. */
export function isPermissionDecision(value: unknown): value is PermissionDecision {
return value === "once" || value === "reject"
}
@@ -9,7 +9,7 @@ import {
} from "./config"
import type { PermissionEventProperties } from "./types"
/** Create a unique event ID for messages sent to the BLE daemon. */
/** Create a unique event ID for messages sent to the ESP-BLE-UART Daemon. */
function eventID() {
return crypto.randomUUID()
}
@@ -116,7 +116,7 @@ export function permissionRequestID(permission: PermissionEventProperties): stri
}
/**
* Build the protocol message sent to the BLE daemon whenever OpenCode's session
* Build the protocol message sent to the ESP-BLE-UART Daemon whenever OpenCode's session
* state changes. This is a one-way notification, so the BLE device can update
* its UI but is not expected to send a reply.
*/
@@ -135,7 +135,7 @@ export function buildSessionStatusPayload(
}
/**
* Tell the BLE daemon to dismiss any permission prompt for this session.
* Tell the ESP-BLE-UART Daemon to dismiss any permission prompt for this session.
*
* This is used when OpenCode has already moved on, for example after the
* session becomes idle before the BLE device returns a decision.
@@ -156,7 +156,7 @@ export function buildPermissionCancelPayload(sessionID: string) {
/**
* Build the BLE permission prompt payload from an OpenCode permission event.
*
* This is the main protocol boundary between OpenCode and the BLE daemon. The
* This is the main protocol boundary between OpenCode and the ESP-BLE-UART Daemon. The
* outer fields describe routing and reply behavior; the nested `payload` fields
* are intentionally small and display-oriented for the device UI.
*/
@@ -174,7 +174,7 @@ async function handlePermissionQueueItem(item: PermissionQueueItem): Promise<Per
await showToastBestEffort(
item.client,
"error",
"OpenCode BLE UART Bridge",
"OpenCode ESP-BLE-UART Bridge",
"Failed to get a BLE permission decision. The request was rejected.",
)
} finally {
@@ -15,7 +15,7 @@ export type PermissionDecision = "once" | "reject"
*
* Several ID field names are optional because OpenCode permission events have
* changed across API versions. The bridge normalizes them before talking to the
* BLE daemon or replying back to OpenCode.
* ESP-BLE-UART Daemon or replying back to OpenCode.
*/
export type PermissionEventProperties = {
id?: string
@@ -32,7 +32,7 @@ export type PermissionEventProperties = {
}
/**
* Normalized response expected from the BLE daemon after a permission request.
* Normalized response expected from the ESP-BLE-UART Daemon after a permission request.
*
* `decision` is optional at the type level because the daemon response is parsed
* from JSON. Runtime code validates it before using it as a permission reply.
@@ -44,7 +44,7 @@ export type BridgeResponse = {
}
/**
* Envelope formats the BLE daemon may return.
* Envelope formats the ESP-BLE-UART Daemon may return.
*
* The daemon can wrap the actual payload in either `data` or `response`, and it
* may encode that payload as an object or a JSON string. Keeping this type loose
@@ -55,7 +55,7 @@ export type DaemonResponse = {
response?: unknown
}
/** Status payload returned by the BLE UART daemon `/status` endpoint. */
/** Status payload returned by the ESP-BLE-UART Daemon `/status` endpoint. */
export type DaemonStatus = {
device_id?: string
connection_state?: "DISCONNECTED" | "CONNECTING" | "CONNECTED" | string