Merge branch 'feat/ble-uart-bridge-opencode-demo' into 'master'

feat: BLE UART Bridge OpenCode Demo

See merge request espressif/esp-idf!48042
This commit is contained in:
Island
2026-05-08 14:02:42 +08:00
15 changed files with 1807 additions and 15 deletions

View File

@@ -16,6 +16,7 @@ BLE UART Bridge is a host-side utility for talking to ESP-IDF applications that
- [Core](#core)
- [Console](#console)
- [Daemon](#daemon)
- [Demos](#demos) - example integrations built on the daemon
- [Choosing Core, Console, or Daemon](#choosing-core-console-or-daemon)
- [Profile compatibility](#profile-compatibility)
- [Dependencies](#dependencies)
@@ -162,6 +163,8 @@ tools/ble/ble_uart_bridge/
│ ├── Quick-Start-BLE-UART-Daemon.md
│ ├── Profile-Compatibility.md
│ └── PORTING.md
├── demos/
│ └── opencode/
└── src/
├── core/
├── console/
@@ -219,12 +222,24 @@ Main responsibilities:
- Expose local HTTP endpoints for status, request/response, and fire-and-forget calls.
- Encode requests as newline-delimited JSON messages over BLE UART.
- Correlate device responses by request ID.
- Attempt to reconnect on demand before sending after a BLE disconnect.
- Provide a small JSONL RPC-style envelope as an example protocol.
The daemon protocol is intentionally small. It is not a full RPC framework. It demonstrates a portable pattern that users can copy into firmware or extend in their own application protocol.
By default, the daemon binds to `127.0.0.1`. Keep it on a loopback address unless you add your own network access control, because the daemon exposes unauthenticated HTTP endpoints that can send data to the BLE device.
## Demos
The `demos/` directory contains example integrations that build on BLE UART
Bridge components.
- [BLE UART Bridge Demo - OpenCode Integration](demos/opencode/README.md) shows
how an OpenCode plugin can forward session status and permission requests to a
BLE device through the daemon. It also includes a firmware-side protocol
reference for devices, such as the planned `esp-vocat` / MiaoBan (喵伴)
example in `esp-iot-solution`.
## Choosing Core, Console, or Daemon
| Component | Best for | Interface |
@@ -259,7 +274,7 @@ The tool depends on:
- Custom GATT UUIDs require constructing `BLEUARTProfile` in Python code.
- Daemon mode is single-flight for request/response calls: it processes one `/request` at a time. `/notify` sends without waiting for a device response.
- Daemon mode does not automatically reconnect after a BLE disconnect; restart the daemon after the device starts advertising again.
- Daemon startup requires the initial BLE connection to succeed. After a later BLE disconnect, `/request` or `/notify` attempts one reconnect before sending and returns HTTP 503 if the device is still unavailable. After three consecutive reconnect failures, the daemon exits.
- Daemon `/request` and `/notify` limit `op` to 64 characters and JSON-encoded `data` to 4096 bytes.
- The JSONL RPC protocol is a demonstration envelope, not a complete RPC framework.
- Unmatched device messages are logged as unsolicited messages and are not exposed as a streaming API.
@@ -269,5 +284,6 @@ The tool depends on:
- [Quick-Start-BLE-UART-Console.md](docs/Quick-Start-BLE-UART-Console.md)
- [Quick-Start-BLE-UART-Daemon.md](docs/Quick-Start-BLE-UART-Daemon.md)
- [BLE UART Bridge Demo - OpenCode Integration](demos/opencode/README.md)
- [Profile-Compatibility.md](docs/Profile-Compatibility.md)
- [PORTING.md](docs/PORTING.md)

View File

@@ -0,0 +1,413 @@
# 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](#goal)
- [Quick Start](#quick-start)
- [How it relates to BLE UART Bridge](#how-it-relates-to-ble-uart-bridge)
- [Daemon JSONL protocol summary](#daemon-jsonl-protocol-summary)
- [Files](#files)
- [Demo and customization notes](#demo-and-customization-notes)
- [Environment variables](#environment-variables)
- [Current assumptions](#current-assumptions)
- [Message routing](#message-routing)
- [Firmware protocol reference](#firmware-protocol-reference)
- [Plugin message: session status](#plugin-message-session-status)
- [Plugin message: permission cancel](#plugin-message-permission-cancel)
- [Plugin message: permission request](#plugin-message-permission-request)
- [Firmware UI sketch](#firmware-ui-sketch)
- [Safety defaults](#safety-defaults)
- [Open items](#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` / `reject` decisions from the device;
- reply to OpenCode permission requests through the OpenCode SDK client.
```mermaid
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
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 Nordic UART Service and
the JSONL request/response envelope described in
[Firmware protocol reference](#firmware-protocol-reference).
2. Install the bridge dependencies:
```bash
python -m pip install -r tools/ble/ble_uart_bridge/requirements.txt
```
3. Start the BLE UART daemon:
```bash
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 8888
```
The plugin connects to `http://127.0.0.1:8888` by default. If the daemon uses
another endpoint, inject it with `OPENCODE_BLE_DAEMON_URL` before starting
OpenCode:
```bash
export OPENCODE_BLE_DAEMON_URL="http://127.0.0.1:9999"
```
4. 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:
```bash
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:
```bash
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/
```
5. Merge the relevant parts of `opencode.json.example` into your `opencode.json`.
For OpenCode plugin loading details, see the official
[OpenCode plugin documentation](https://opencode.ai/docs/en/plugins/).
Project-level `opencode.json` example:
```json
{
"plugin": [
".opencode/plugins/opencode-ble-uart-bridge/opencode-ble-uart-bridge.ts"
],
"permission": {
"edit": "ask"
}
}
```
User-level `~/.config/opencode/opencode.json` example:
```json
{
"plugin": [
"<home-path>/.config/opencode/plugins/opencode-ble-uart-bridge/opencode-ble-uart-bridge.ts"
],
"permission": {
"edit": "ask"
}
}
```
- `plugin` tells 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,
so `opencode.json.example` points 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 the `edit` tool.
Those permission prompts are what this plugin forwards to the BLE device as
`permission.request` messages.
6. Start OpenCode after the daemon is running.
OpenCode loads plugins during startup. This demo checks `GET /status` while
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
`/status` becomes reachable again, the plugin updates its state and can resume
forwarding.
To verify the path, trigger an `edit` permission request. The BLE device
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.
## 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 /notify` sends fire-and-forget events, such as session status updates.
- `POST /request` sends request/response messages, such as permission prompts
that must wait for a device decision.
- `GET /status` can be used by tools to inspect daemon health and connection
state.
For the daemon itself, see:
- [BLE UART Bridge README](../../README.md)
- [BLE UART Daemon Quick Start](../../docs/Quick-Start-BLE-UART-Daemon.md)
### 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:
```json
{"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`:
```json
{"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](#firmware-protocol-reference).
## 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.
- `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.ts` if the BLE device needs a different display
model for OpenCode permissions.
- Change the [Firmware protocol reference](#firmware-protocol-reference) and the
firmware parser together if you add more message kinds, decision types,
buttons, or display states.
- Set `OPENCODE_BLE_DAEMON_URL` if the daemon runs on a different local
host/port.
- Keep local security requirements in mind. The daemon defaults to
`127.0.0.1:8888` and 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 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.
- `OPENCODE_BLE_DEBUG=1`: enables verbose local plugin logs.
## Current assumptions
- The BLE 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 BLE device implements Nordic UART Service.
- The BLE device understands JSON messages described in
[Firmware protocol reference](#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` / `metadata` fields 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 `/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
decision, the plugin replies `reject`.
## Message routing
- `session.status` uses `POST /notify` because it is telemetry and does not require a device response.
- `permission.request` uses `POST /request` because OpenCode must wait for the device's `once` / `reject` decision.
- `permission.cancel` uses `POST /notify` because 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:
```json
{"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`:
```json
{"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:
```json
{"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: ""`.
```json
{
"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: ""`.
```json
{
"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.
```json
{
"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:
```json
{
"v": 1,
"id": "<bridge-request-id>",
"ok": true,
"data": {
"decision": "once",
"message": "Approved from BLE device"
}
}
```
Device error response in the daemon JSONL envelope:
```json
{
"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:
- `once`
- `reject`
### Firmware UI sketch
For `session.status`:
- show `busy`, `idle`, or `retry`.
For `permission.request`:
- show permission type/title;
- show compact metadata such as command/path/url;
- expose two actions on the current single-key device: `Once` and `Reject`.
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.

View File

@@ -0,0 +1,9 @@
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
".opencode/plugins/opencode-ble-uart-bridge/opencode-ble-uart-bridge.ts"
],
"permission": {
"edit": "ask"
}
}

View File

@@ -0,0 +1,108 @@
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
// SPDX-License-Identifier: Apache-2.0
import { BLE_DAEMON_URL } from "./config"
import { debugLog } from "./logging"
import { isPermissionDecision } from "./opencode-permission-reply"
import type { BridgeResponse, DaemonResponse, DaemonStatus } from "./types"
/**
* Check whether the local BLE 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.
*/
export async function isDaemonAvailable(): Promise<boolean> {
try {
await getDaemonStatus()
return true
} catch {
return false
}
}
/** Return the daemon status payload or throw if the daemon is unreachable. */
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}`)
}
return (await response.json()) as DaemonStatus
}
/**
* Normalize the BLE 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
* from leaking into the permission queue code.
*/
function parseBridgeResponse(body: DaemonResponse): BridgeResponse {
debugLog("daemon raw response", body)
// Try nested data/response fields first (daemon envelope)
const payload = body.data ?? body.response
if (payload !== undefined) {
if (typeof payload === "string") {
return JSON.parse(payload) as BridgeResponse
}
if (payload && typeof payload === "object") {
return payload as BridgeResponse
}
}
// Fallback: daemon may have returned the decision at the top level
if (
body &&
typeof body === "object" &&
"decision" in body &&
isPermissionDecision((body as BridgeResponse).decision)
) {
return body as BridgeResponse
}
throw new Error(`BLE daemon returned an invalid response payload: ${JSON.stringify(body)}`)
}
/**
* Send a one-way notification to the BLE 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
* user decision.
*/
export async function notifyBLE(op: string, data: unknown): Promise<void> {
const response = await fetch(`${BLE_DAEMON_URL}/notify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ op, data }),
})
if (!response.ok) {
throw new Error(`BLE daemon notify failed: HTTP ${response.status}`)
}
}
/**
* Send a request to the BLE 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.
*/
export async function sendRequestToBLE(op: string, data: unknown, timeoutSeconds: number): Promise<BridgeResponse> {
const response = await fetch(`${BLE_DAEMON_URL}/request`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
op,
data,
timeout: timeoutSeconds,
}),
})
if (!response.ok) {
throw new Error(`BLE daemon request failed: HTTP ${response.status}`)
}
return parseBridgeResponse((await response.json()) as DaemonResponse)
}

View File

@@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
// SPDX-License-Identifier: Apache-2.0
/** Maximum number of characters sent for one metadata value shown on the BLE device. */
export const MAX_METADATA_VALUE_CHARS = 512
/** Fallback display type when OpenCode does not provide a specific permission type. */
export const DEFAULT_PERMISSION_TYPE = "unknown"
/** Fallback title shown on the BLE device when the permission event has no title. */
export const DEFAULT_PERMISSION_TITLE = "Permission request"
/**
* Default rejection message sent back to OpenCode when the BLE path rejects or fails.
*
* Keep this message non-empty: OpenCode treats a bare reject as a hard
* PermissionRejectedError that can block the current assistant turn. A reject
* with feedback behaves like a corrective denial and lets later serial tool
* requests continue.
*/
export const DEFAULT_REJECT_MESSAGE = "Rejected from BLE device"
/**
* Metadata keys that are most useful on a small BLE screen.
*
* The bridge shows at most one metadata entry, so these keys are prioritized
* before falling back to the first available string metadata value.
*/
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. */
export const BLE_DAEMON_URL = process.env.OPENCODE_BLE_DAEMON_URL ?? "http://127.0.0.1:8888"
const DEFAULT_DECISION_TIMEOUT_SECONDS = 60
function parseDecisionTimeoutSeconds(value: string | undefined): number {
const parsed = Number(value ?? String(DEFAULT_DECISION_TIMEOUT_SECONDS))
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_DECISION_TIMEOUT_SECONDS
}
/** Maximum time to wait for a user decision from the BLE device. */
export const DECISION_TIMEOUT_SECONDS = parseDecisionTimeoutSeconds(process.env.OPENCODE_BLE_DECISION_TIMEOUT_SECONDS)
/** Message used when later prompts are skipped after an earlier same-session reject. */
export const CONCURRENT_REJECT_MESSAGE = "Another concurrent permission request was rejected"

View File

@@ -0,0 +1,95 @@
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
// SPDX-License-Identifier: Apache-2.0
import { DEBUG } from "./config"
import type { OpenCodePermissionClient } from "./types"
/**
* Write local debug logs only when explicitly enabled.
*
* Demo plugins often need extra observability while being developed, but they
* should stay quiet by default when users copy them into normal OpenCode use.
*/
export function debugLog(message: string, data?: unknown) {
if (!DEBUG) {
return
}
if (data === undefined) {
console.info(`[opencode-ble] ${message}`)
return
}
console.info(`[opencode-ble] ${message}`, data)
}
/**
* Prefer OpenCode's application log API. Fall back to debug-only local logs so
* daemon connection failures do not pollute the OpenCode TUI.
*
* The partial client type means a copied demo can still run against OpenCode
* versions that do not expose every helper method used by newer SDKs.
*/
export async function appLog(
client: OpenCodePermissionClient,
level: "debug" | "info" | "warn" | "error",
message: string,
extra?: Record<string, unknown>,
) {
if (typeof client.app?.log === "function") {
await client.app.log({
body: {
service: "opencode-ble",
level,
message,
extra,
},
})
return
}
if (level === "error") {
debugLog(`error: ${message}`, extra)
} else if (level === "warn") {
debugLog(`warn: ${message}`, extra)
} else {
debugLog(message, extra)
}
}
/** Show an OpenCode TUI toast when that API is available. */
export async function showToastBestEffort(
client: OpenCodePermissionClient,
variant: "info" | "success" | "warning" | "error",
title: string,
message: string,
) {
try {
await client.tui?.showToast?.({
body: {
title,
message,
variant,
duration: 5000,
},
})
} catch (error) {
debugLog("failed to show TUI toast", { error: String(error), title, message, variant })
}
}
/**
* Best-effort logging wrapper used on error paths.
*
* Permission handling should not fail just because the logging endpoint is
* unavailable, so this helper catches log write errors and reports them locally.
*/
export async function appLogBestEffort(
client: OpenCodePermissionClient,
level: "debug" | "info" | "warn" | "error",
message: string,
extra?: Record<string, unknown>,
) {
try {
await appLog(client, level, message, extra)
} catch (error) {
debugLog(`failed to write app log: ${message}`, { error: String(error) })
}
}

View File

@@ -0,0 +1,235 @@
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
// SPDX-License-Identifier: Apache-2.0
import type { Plugin } from "@opencode-ai/plugin"
import { getDaemonStatus, notifyBLE } from "./ble-daemon-client"
import { DEFAULT_REJECT_MESSAGE } from "./config"
import { appLog, appLogBestEffort, debugLog, showToastBestEffort } from "./logging"
import { replyToOpenCodePermission } from "./opencode-permission-reply"
import { buildPermissionCancelPayload, buildSessionStatusPayload } from "./permission-payload"
import {
enqueuePermissionRequest,
markActiveBLEPermissionsExternallyResolved,
statusShouldCancelPendingPermission,
} from "./permission-queue"
import type { DaemonStatus, OpenCodePermissionClient, PermissionEventProperties } from "./types"
export { notifyBLE, sendRequestToBLE } from "./ble-daemon-client"
export { buildPermissionPayload } from "./permission-payload"
type BLEPluginState = "unknown" | "connected" | "degraded" | "disabled"
function stateFromStatus(status: DaemonStatus): BLEPluginState {
if (status.daemon_state === "exiting") {
return "disabled"
}
if (status.is_connected === true) {
return "connected"
}
return "degraded"
}
function stateMessage(state: BLEPluginState, status?: DaemonStatus): string {
if (state === "connected") {
return `BLE UART device connected${status?.device_id ? `: ${status.device_id}` : ""}`
}
if (state === "degraded") {
const attempts =
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.`
}
if (status?.daemon_state === "exiting") {
return "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."
}
async function notifyStateChange(
client: OpenCodePermissionClient,
state: BLEPluginState,
status?: DaemonStatus,
): 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 appLogBestEffort(client, variant === "error" ? "error" : variant === "warning" ? "warn" : "info", message, {
state,
status,
})
}
/**
* OpenCode plugin entry point for the BLE device bridge demo.
*
* OpenCode calls this exported function once when loading the plugin. The
* returned object registers an `event` callback, and OpenCode calls that
* callback for session and permission events.
*/
export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directory }) => {
const openCodeClient = client as OpenCodePermissionClient
let bleState: BLEPluginState = "unknown"
let bleStateRefreshGeneration = 0
const connectedSessionNotifications = new Set<string>()
async function refreshBLEState(notifyConnected: boolean): Promise<BLEPluginState> {
const generation = ++bleStateRefreshGeneration
try {
const status = await getDaemonStatus()
if (generation !== bleStateRefreshGeneration) {
return bleState
}
const nextState = stateFromStatus(status)
const shouldNotify = nextState !== bleState && (nextState !== "connected" || notifyConnected)
bleState = nextState
if (shouldNotify) {
await notifyStateChange(openCodeClient, nextState, status)
}
} catch (error) {
if (generation !== bleStateRefreshGeneration) {
return bleState
}
const shouldNotify = bleState !== "disabled"
bleState = "disabled"
if (shouldNotify) {
await notifyStateChange(openCodeClient, "disabled")
await appLogBestEffort(openCodeClient, "warn", "BLE UART daemon status check failed", { error: String(error) })
}
}
return bleState
}
await refreshBLEState(false)
return {
/**
* Handle OpenCode events that are relevant to the BLE device.
*
* This demo listens for two event families:
* - `session.status`: mirror OpenCode activity on the BLE device.
* - `permission.asked`: ask the BLE device user to approve or reject.
*/
event: async ({ event }) => {
if (event.type === "session.status") {
// OpenCode emits this event whenever a session changes state, such as
// becoming busy, retrying, or going idle. The plugin forwards this
// state to the BLE device so the device can mirror OpenCode's current
// activity.
const properties = event.properties as {
sessionID: string
status: { type: "idle" | "busy" | "retry"; attempt?: number; message?: string; next?: number }
}
// 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.
void (async () => {
try {
const previousState = bleState
const state = await refreshBLEState(true)
if (
properties.status.type === "busy" &&
state === "connected" &&
previousState === "connected" &&
!connectedSessionNotifications.has(properties.sessionID)
) {
connectedSessionNotifications.add(properties.sessionID)
await showToastBestEffort(
openCodeClient,
"success",
"OpenCode BLE UART Bridge",
"BLE UART device is connected for this OpenCode session.",
)
}
if (state === "disabled") {
return
}
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
// 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.
statusShouldCancelPendingPermission(properties.status) &&
markActiveBLEPermissionsExternallyResolved(properties.sessionID)
) {
try {
// OpenCode does not emit a permission-specific cancellation event
// when a user answers the same prompt in the TUI. This best-effort
// notification tells the daemon the BLE prompt is stale before the
// following idle status also clears the device UI.
await notifyBLE("permission.cancel", buildPermissionCancelPayload(properties.sessionID))
} catch (error) {
await appLogBestEffort(openCodeClient, "warn", "Failed to cancel stale BLE permission on device", {
error: String(error),
})
await refreshBLEState(false)
}
}
try {
// Always forward the latest session status, even if there was no
// stale permission prompt to cancel. This keeps the BLE device's
// display synchronized with OpenCode.
await notifyBLE("session.status", buildSessionStatusPayload(properties.sessionID, properties.status))
} catch (error) {
await appLogBestEffort(openCodeClient, "warn", "Failed to forward session status to BLE device", {
error: String(error),
})
await refreshBLEState(false)
}
} catch (error) {
await appLogBestEffort(openCodeClient, "warn", "session.status handler failed", {
error: String(error),
})
debugLog("session.status handler failed", { error: String(error) })
}
})()
}
if (event.type === "permission.asked") {
const permission = event.properties as PermissionEventProperties
const permissionSummary = {
keys: Object.keys(permission),
id: permission.id,
requestID: permission.requestID,
permissionID: permission.permissionID,
sessionID: permission.sessionID,
permission: permission.permission,
type: permission.type,
title: permission.title,
}
debugLog("received permission.asked", permissionSummary)
await appLogBestEffort(openCodeClient, "info", "received permission.asked", permissionSummary)
try {
const state = await refreshBLEState(true)
if (state === "disabled") {
await replyToOpenCodePermission(
openCodeClient,
permission,
"reject",
DEFAULT_REJECT_MESSAGE,
serverUrl,
directory,
)
return
}
// Permission handling is awaited because OpenCode needs an explicit
// reply before it can continue the tool or command that requested
// permission. The queue itself serializes BLE prompts.
await enqueuePermissionRequest(openCodeClient, permission, serverUrl, directory)
} catch (error) {
await appLog(openCodeClient, "error", "Failed to reply to OpenCode permission request", {
error: String(error),
})
throw error
}
}
},
}
}

View File

@@ -0,0 +1,180 @@
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
// SPDX-License-Identifier: Apache-2.0
import { debugLog } from "./logging"
import { permissionRequestID } from "./permission-payload"
import type { OpenCodePermissionClient, PermissionDecision, PermissionEventProperties } from "./types"
/** Extract SDK-style errors from methods that return error objects instead of throwing. */
function sdkResultError(result: unknown): unknown {
if (result && typeof result === "object" && "error" in result) {
return (result as { error?: unknown }).error
}
return undefined
}
/** Build Basic Auth headers for raw OpenCode server fallback calls when configured. */
function serverAuthHeaders(): Record<string, string> {
const password = process.env.OPENCODE_SERVER_PASSWORD
if (!password) {
return {}
}
const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode"
return {
Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`,
}
}
/**
* Headers required by raw OpenCode HTTP calls.
*
* The `x-opencode-directory` header tells OpenCode which workspace directory the
* request belongs to, matching how the SDK normally scopes permission replies.
*/
function opencodeRequestHeaders(directory: string): Record<string, string> {
return {
"Content-Type": "application/json",
"x-opencode-directory": encodeURIComponent(directory),
...serverAuthHeaders(),
}
}
/**
* Reply to an OpenCode permission request using the best API available.
*
* The preferred path is the modern `permission.reply` API. The remaining paths
* keep this demo useful across OpenCode versions and environments where only a
* raw client, raw server URL, older v1 helper, or deprecated v2 helper exists.
*/
export async function replyToOpenCodePermission(
client: OpenCodePermissionClient,
permission: PermissionEventProperties,
decision: PermissionDecision,
message?: string,
serverUrl?: URL,
directory?: string,
) {
const requestID = permissionRequestID(permission)
// Probe reply APIs in compatibility order:
// - modern v2 SDK: client.permission.reply()
// - plugin-internal client: client._client.post()
// - raw HTTP fallback: fetch(serverUrl /permission/:id/reply)
// - v1 SDK helper: postSessionIdPermissionsPermissionId()
// - deprecated v2 SDK: client.permission.respond()
debugLog("replying to OpenCode permission", {
requestID,
sessionID: permission.sessionID,
decision,
message,
hasServerUrl: serverUrl !== undefined,
hasDirectory: directory !== undefined,
hasInternalPost: typeof client._client?.post === "function",
hasV2Reply: typeof client.permission?.reply === "function",
hasV2Respond: typeof client.permission?.respond === "function",
hasV1Respond: typeof client.postSessionIdPermissionsPermissionId === "function",
})
if (typeof client.permission?.reply === "function") {
debugLog("using OpenCode v2 permission.reply API")
const result = await client.permission.reply({
requestID,
reply: decision,
message,
})
const error = sdkResultError(result)
if (error !== undefined) {
throw new Error(`OpenCode permission response failed: ${String(error)}`)
}
debugLog("OpenCode permission response completed", result)
return
}
if (typeof client._client?.post === "function") {
// In TUI/plugin runtime, serverUrl can be a phantom URL: a plain
// fetch(serverUrl) may fail or hit the wrong OpenCode instance. The
// injected HeyAPI client uses OpenCode's in-process app.fetch() binding,
// so this is the reliable fallback when the public reply helper is absent.
debugLog("using OpenCode internal raw v2 permission.reply endpoint")
const result = await client._client.post({
url: `/permission/${encodeURIComponent(requestID)}/reply`,
body: {
reply: decision,
message,
},
headers: { "Content-Type": "application/json" },
})
const error = sdkResultError(result)
if (error !== undefined) {
throw new Error(`OpenCode permission response failed: ${String(error)}`)
}
debugLog("OpenCode permission response completed", result)
return
}
if (serverUrl !== undefined && directory !== undefined) {
debugLog("using OpenCode raw v2 permission.reply endpoint")
try {
const response = await fetch(new URL(`/permission/${encodeURIComponent(requestID)}/reply`, serverUrl), {
method: "POST",
headers: opencodeRequestHeaders(directory),
body: JSON.stringify({
reply: decision,
message,
}),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${await response.text()}`)
}
try {
debugLog("OpenCode permission response completed", await response.json())
} catch {
debugLog("OpenCode permission response completed (non-JSON body)")
}
return
} catch (error) {
debugLog("OpenCode raw v2 permission.reply failed, trying fallback", { error: String(error) })
}
}
if (typeof client.postSessionIdPermissionsPermissionId === "function") {
debugLog("using OpenCode v1 permission respond API")
const result = await client.postSessionIdPermissionsPermissionId({
path: {
id: permission.sessionID,
permissionID: requestID,
},
body: {
response: decision,
},
})
const error = sdkResultError(result)
if (error !== undefined) {
throw new Error(`OpenCode permission response failed: ${String(error)}`)
}
debugLog("OpenCode permission response completed", result)
return
}
if (typeof client.permission?.respond === "function") {
debugLog("using OpenCode v2 deprecated permission.respond API")
const result = await client.permission.respond({
sessionID: permission.sessionID,
permissionID: requestID,
response: decision,
})
const error = sdkResultError(result)
if (error !== undefined) {
throw new Error(`OpenCode permission response failed: ${String(error)}`)
}
debugLog("OpenCode permission response completed", result)
return
}
throw new Error("OpenCode client does not expose a permission reply API")
}
/** Runtime guard for permission decisions parsed from BLE daemon JSON. */
export function isPermissionDecision(value: unknown): value is PermissionDecision {
return value === "once" || value === "reject"
}

View File

@@ -0,0 +1,180 @@
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
// SPDX-License-Identifier: Apache-2.0
import {
DEFAULT_PERMISSION_TITLE,
DEFAULT_PERMISSION_TYPE,
MAX_METADATA_VALUE_CHARS,
METADATA_DISPLAY_KEYS,
} from "./config"
import type { PermissionEventProperties } from "./types"
/** Create a unique event ID for messages sent to the BLE daemon. */
function eventID() {
return crypto.randomUUID()
}
/** Keep one displayed metadata value small enough for constrained BLE device UIs. */
function truncateForBLE(value: string): string {
return value.length > MAX_METADATA_VALUE_CHARS ? value.slice(0, MAX_METADATA_VALUE_CHARS) : value
}
/** Return the first non-empty string from several OpenCode event fields. */
function firstNonEmptyString(...values: unknown[]): string | undefined {
for (const value of values) {
if (typeof value === "string" && value.length > 0) {
return value
}
}
return undefined
}
/** Check whether compacted display metadata has at least one useful entry. */
function hasMetadata(metadata: Record<string, string>): boolean {
return Object.keys(metadata).length > 0
}
/**
* Pick one BLE-friendly metadata entry from a larger OpenCode metadata object.
*
* A permission event can include many nested fields, but a BLE device usually
* has limited display space. This helper prefers human-relevant keys and avoids
* sending large or non-string values to the daemon.
*/
function compactMetadataForBLE(metadata: Record<string, unknown> | undefined): Record<string, string> {
if (!metadata) {
return {}
}
for (const key of METADATA_DISPLAY_KEYS) {
const value = metadata[key]
if (typeof value === "string" && value.length > 0) {
return { [key]: truncateForBLE(value) }
}
}
for (const [key, value] of Object.entries(metadata)) {
if (typeof value === "string" && value.length > 0) {
return { [key]: truncateForBLE(value) }
}
}
return {}
}
/** Convert OpenCode permission fields into a short type label for the BLE UI. */
function displayTypeForBLE(permission: PermissionEventProperties): string {
return firstNonEmptyString(permission.type, permission.permission, permission.tool_name) ?? DEFAULT_PERMISSION_TYPE
}
/** Convert OpenCode permission fields into the main prompt title shown on BLE. */
function displayTitleForBLE(permission: PermissionEventProperties): string {
return (
firstNonEmptyString(permission.title, permission.tool_input?.description, permission.permission, permission.tool_name) ??
DEFAULT_PERMISSION_TITLE
)
}
/**
* Choose the single most useful metadata field for the BLE permission prompt.
*
* The order is: explicit permission metadata, tool input metadata, then the
* first command pattern. This keeps the device prompt concise while still
* showing the user why OpenCode is asking for permission.
*/
function displayMetadataForBLE(permission: PermissionEventProperties): Record<string, string> {
const metadata = compactMetadataForBLE(permission.metadata)
if (hasMetadata(metadata)) {
return metadata
}
const toolInput = compactMetadataForBLE(permission.tool_input)
if (hasMetadata(toolInput)) {
return toolInput
}
const pattern = permission.patterns?.find((value) => typeof value === "string" && value.length > 0)
if (pattern) {
return { command: truncateForBLE(pattern) }
}
return {}
}
/**
* Normalize the permission ID across OpenCode API versions.
*
* Different OpenCode events may call the same concept `id`, `requestID`, or
* `permissionID`. Reply code and BLE payloads should use one normalized value.
*/
export function permissionRequestID(permission: PermissionEventProperties): string {
const id = permission.id ?? permission.requestID ?? permission.permissionID
if (typeof id === "string" && id.length > 0) {
return id
}
throw new Error("OpenCode permission event is missing id/requestID/permissionID")
}
/**
* Build the protocol message sent to the BLE 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.
*/
export function buildSessionStatusPayload(
sessionID: string,
status: { type: "idle" | "busy" | "retry"; attempt?: number; message?: string; next?: number },
) {
return {
v: 1,
kind: "session.status",
event_id: eventID(),
session_id: sessionID,
requires_reply: false,
payload: status,
}
}
/**
* Tell the BLE 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.
*/
export function buildPermissionCancelPayload(sessionID: string) {
return {
v: 1,
kind: "permission.cancel",
event_id: eventID(),
session_id: sessionID,
requires_reply: false,
payload: {
reason: "opencode_state_changed",
},
}
}
/**
* Build the BLE permission prompt payload from an OpenCode permission event.
*
* This is the main protocol boundary between OpenCode and the BLE daemon. The
* outer fields describe routing and reply behavior; the nested `payload` fields
* are intentionally small and display-oriented for the device UI.
*/
export function buildPermissionPayload(permission: PermissionEventProperties, id = eventID()) {
const requestID = permissionRequestID(permission)
return {
v: 1,
kind: "permission.request",
event_id: id,
session_id: permission.sessionID,
permission_id: requestID,
requires_reply: true,
payload: {
id: requestID,
sessionID: permission.sessionID,
type: displayTypeForBLE(permission),
title: displayTitleForBLE(permission),
metadata: displayMetadataForBLE(permission),
},
}
}

View File

@@ -0,0 +1,276 @@
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
// SPDX-License-Identifier: Apache-2.0
import { CONCURRENT_REJECT_MESSAGE, DECISION_TIMEOUT_SECONDS, DEFAULT_REJECT_MESSAGE } from "./config"
import { sendRequestToBLE } from "./ble-daemon-client"
import { appLogBestEffort, debugLog, showToastBestEffort } from "./logging"
import { isPermissionDecision, replyToOpenCodePermission } from "./opencode-permission-reply"
import { buildPermissionPayload, permissionRequestID } from "./permission-payload"
import type { OpenCodePermissionClient, PermissionDecision, PermissionEventProperties, PermissionQueueItem } from "./types"
/**
* Queue permission prompts so the BLE device only asks one question at a time.
*
* This keeps the device UI simple and avoids racing multiple decisions for the
* same OpenCode session. It also mirrors OpenCode's behavior of cancelling
* other same-session pending permissions after one pending request is rejected.
*/
const permissionQueue: PermissionQueueItem[] = []
/** Prevents multiple asynchronous queue drainers from processing the same queue. */
let permissionQueueRunning = false
/**
* Tracks permission IDs currently waiting for a BLE device decision per session.
*
* The session.status handler uses this map to detect stale prompts when OpenCode
* becomes idle before the BLE device replies.
*/
const activeBLEPermissionsBySession = new Map<string, Set<string>>()
/**
* Permission IDs that OpenCode resolved outside the BLE decision path.
*
* This can happen when a user answers the same permission directly in the TUI
* while the BLE device is still showing the prompt. If the BLE device replies
* later, the queue ignores that reply instead of double-answering an already
* finished OpenCode request.
*/
const externallyResolvedPermissionIDs = new Set<string>()
/** Return a non-empty string or a fallback message for OpenCode replies. */
function nonEmptyString(value: unknown, fallback: string): string {
return typeof value === "string" && value.length > 0 ? value : fallback
}
/** Mark a permission as actively displayed or pending on the BLE device. */
function beginActiveBLEPermission(permission: PermissionEventProperties) {
const requestID = permissionRequestID(permission)
const ids = activeBLEPermissionsBySession.get(permission.sessionID) ?? new Set<string>()
ids.add(requestID)
activeBLEPermissionsBySession.set(permission.sessionID, ids)
}
/** Remove a permission from the active BLE prompt tracking map. */
function endActiveBLEPermission(permission: PermissionEventProperties) {
const requestID = permissionRequestID(permission)
const ids = activeBLEPermissionsBySession.get(permission.sessionID)
if (ids === undefined) {
return
}
ids.delete(requestID)
if (ids.size === 0) {
activeBLEPermissionsBySession.delete(permission.sessionID)
}
}
/**
* Mark active prompts for a session as resolved by OpenCode state changes.
*
* The session.status handler calls this when a session becomes idle. Returning
* `true` tells the caller there was a prompt worth cancelling on the BLE device.
*/
export function markActiveBLEPermissionsExternallyResolved(sessionID: string): boolean {
const ids = activeBLEPermissionsBySession.get(sessionID)
if (ids === undefined || ids.size === 0) {
return false
}
for (const id of ids) {
externallyResolvedPermissionIDs.add(id)
}
return true
}
/**
* Skip queued prompts for a session after one prompt is rejected.
*
* OpenCode cancels other pending permission requests in the same session when
* one pending request is rejected. Because this demo serializes prompts before
* they reach BLE, later same-session items may still be waiting in this FIFO.
* Marking them as skipped rejects them back to OpenCode without showing stale
* prompts on the device during the next OpenCode turn.
*/
function markQueuedPermissionsSkipped(sessionID: string) {
for (const item of permissionQueue) {
if (item.permission.sessionID === sessionID && item.skipBLE !== true) {
item.skipBLE = true
item.skipMessage = CONCURRENT_REJECT_MESSAGE
}
}
}
/**
* Process one queued permission request from BLE prompt through OpenCode reply.
*
* The function defaults to rejection on timeout or daemon failure. That fail-safe
* behavior is important for a permission bridge: losing contact with the remote
* device should not grant access.
*/
async function handlePermissionQueueItem(item: PermissionQueueItem): Promise<PermissionDecision | undefined> {
const requestID = permissionRequestID(item.permission)
let decision: PermissionDecision = "reject"
let decisionMessage: string | undefined
if (item.skipBLE === true) {
decisionMessage = nonEmptyString(item.skipMessage, DEFAULT_REJECT_MESSAGE)
debugLog("skipping BLE permission request after concurrent reject", {
requestID,
sessionID: item.permission.sessionID,
message: decisionMessage,
})
await appLogBestEffort(item.client, "warn", "skipping BLE permission request after concurrent reject", {
requestID,
sessionID: item.permission.sessionID,
message: decisionMessage,
})
} else {
try {
beginActiveBLEPermission(item.permission)
const result = await sendRequestToBLE(
"permission.request",
buildPermissionPayload(item.permission),
DECISION_TIMEOUT_SECONDS,
)
if (externallyResolvedPermissionIDs.has(requestID)) {
markQueuedPermissionsSkipped(item.permission.sessionID)
debugLog("BLE permission response ignored after OpenCode state changed", {
requestID,
sessionID: item.permission.sessionID,
result,
})
return undefined
}
if (!isPermissionDecision(result.decision)) {
throw new Error(`Invalid BLE permission decision: ${String(result.decision)}`)
}
decision = result.decision
if (decision === "reject") {
// Always include a reject message. A bare OpenCode reject is treated as
// PermissionRejectedError and can block the current turn; feedback makes
// it a corrective denial so later serial tool calls can continue.
decisionMessage = nonEmptyString(result.message, DEFAULT_REJECT_MESSAGE)
}
debugLog("BLE permission decision received", result)
await appLogBestEffort(item.client, "info", "BLE permission decision received", result as Record<string, unknown>)
} catch (error) {
if (externallyResolvedPermissionIDs.has(requestID)) {
markQueuedPermissionsSkipped(item.permission.sessionID)
debugLog("BLE permission failure ignored after OpenCode state changed", {
requestID,
sessionID: item.permission.sessionID,
error: String(error),
})
return undefined
}
// Daemon failures are fail-closed, but still include feedback for the same
// reason as device-side rejects: avoid turning a denial into a hard turn
// blocker when OpenCode can continue with later serial requests.
decisionMessage = DEFAULT_REJECT_MESSAGE
await appLogBestEffort(item.client, "warn", "Failed to get BLE permission decision, rejecting request", {
error: String(error),
})
await showToastBestEffort(
item.client,
"error",
"OpenCode BLE UART Bridge",
"Failed to get a BLE permission decision. The request was rejected.",
)
} finally {
endActiveBLEPermission(item.permission)
externallyResolvedPermissionIDs.delete(requestID)
}
}
if (decision === "reject") {
// After one reject, keep queued same-session prompts away from BLE. This
// matches OpenCode's concurrent-cancel semantics and prevents the device
// from showing permission requests that OpenCode has already invalidated.
markQueuedPermissionsSkipped(item.permission.sessionID)
}
try {
await replyToOpenCodePermission(
item.client,
item.permission,
decision,
decisionMessage,
item.serverUrl,
item.directory,
)
} catch (error) {
if (item.skipBLE === true) {
await appLogBestEffort(item.client, "warn", "Skipped queued permission was already resolved by OpenCode", {
requestID,
sessionID: item.permission.sessionID,
error: String(error),
})
return decision
}
throw error
}
return decision
}
/**
* Drain queued permission requests sequentially.
*
* A new drainer starts only when one is not already running. If new items arrive
* just as the loop exits, the `finally` block starts another pass.
*/
async function drainPermissionQueue() {
if (permissionQueueRunning) {
return
}
permissionQueueRunning = true
try {
while (permissionQueue.length > 0) {
const item = permissionQueue.shift()
if (item === undefined) {
continue
}
try {
await handlePermissionQueueItem(item)
item.resolve()
} catch (error) {
item.reject(error)
}
}
} finally {
permissionQueueRunning = false
if (permissionQueue.length > 0) {
void drainPermissionQueue()
}
}
}
/**
* Add a permission request to the serialized BLE decision queue.
*
* The returned Promise resolves only after the queue item has either replied to
* OpenCode or decided that OpenCode already resolved the request elsewhere.
*/
export function enqueuePermissionRequest(
client: OpenCodePermissionClient,
permission: PermissionEventProperties,
serverUrl?: URL,
directory?: string,
): Promise<void> {
return new Promise((resolve, reject) => {
permissionQueue.push({ client, permission, serverUrl, directory, resolve, reject })
void drainPermissionQueue()
})
}
/**
* Decide whether a session status update should cancel pending BLE prompts.
*
* An idle session means OpenCode is no longer actively waiting for the operation
* that originally caused the permission prompt. Any BLE prompt still shown for
* that session is therefore considered stale.
*/
export function statusShouldCancelPendingPermission(status: { type: "idle" | "busy" | "retry" }): boolean {
return status.type === "idle"
}

View File

@@ -0,0 +1,142 @@
// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
// SPDX-License-Identifier: Apache-2.0
/**
* The permission decisions this BLE demo accepts from the device.
*
* OpenCode may support additional replies such as "always", but this demo only
* exposes "once" and "reject" to keep the BLE device UI simple and to avoid
* accidentally granting broad long-lived permissions from a small remote UI.
*/
export type PermissionDecision = "once" | "reject"
/**
* Shape of the permission event fields this plugin reads from OpenCode.
*
* 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.
*/
export type PermissionEventProperties = {
id?: string
requestID?: string
permissionID?: string
sessionID: string
permission?: string
type?: string
title?: string
metadata?: Record<string, unknown>
patterns?: string[]
tool_name?: string
tool_input?: Record<string, unknown>
}
/**
* Normalized response expected from the BLE 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.
*/
export type BridgeResponse = {
decision?: PermissionDecision
message?: string
ok?: boolean
}
/**
* Envelope formats the BLE 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
* lets the parser accept all supported daemon versions in one place.
*/
export type DaemonResponse = {
data?: unknown
response?: unknown
}
/** Status payload returned by the BLE UART daemon `/status` endpoint. */
export type DaemonStatus = {
device_id?: string
connection_state?: "DISCONNECTED" | "CONNECTING" | "CONNECTED" | string
is_connected?: boolean
pending_requests?: number
reconnect_failures?: number
max_reconnect_failures?: number
daemon_state?: "running" | "exiting" | string
}
/**
* Partial OpenCode client surface used by this demo.
*
* The plugin intentionally models only the methods it calls instead of importing
* every generated SDK type. This makes the demo easier to read and allows it to
* support multiple OpenCode API versions and fallback paths.
*/
export type OpenCodePermissionClient = {
_client?: {
post?: (input: {
url: string
body: { reply: PermissionDecision; message?: string }
headers?: Record<string, string>
}) => Promise<unknown>
}
app?: {
log?: (input: {
body: {
service: string
level: "debug" | "info" | "warn" | "error"
message: string
extra?: Record<string, unknown>
}
}) => Promise<unknown>
}
tui?: {
showToast?: (input: {
body: {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
duration?: number
}
}) => Promise<unknown>
}
permission?: {
reply?: (input: {
requestID: string
reply?: "once" | "always" | "reject"
message?: string
directory?: string
workspace?: string
}) => Promise<unknown>
respond?: (input: {
sessionID: string
permissionID: string
response?: "once" | "always" | "reject"
directory?: string
workspace?: string
}) => Promise<unknown>
}
postSessionIdPermissionsPermissionId?: (input: {
path: { id: string; permissionID: string }
body: { response: PermissionDecision }
}) => Promise<unknown>
}
/**
* Internal unit of work for the permission queue.
*
* Each item connects one OpenCode permission event to one BLE device decision,
* plus the Promise callbacks that let the plugin's event handler wait until the
* reply has been sent back to OpenCode.
*/
export type PermissionQueueItem = {
client: OpenCodePermissionClient
permission: PermissionEventProperties
serverUrl?: URL
directory?: string
skipBLE?: boolean
skipMessage?: string
resolve: () => void
reject: (error: unknown) => void
}

View File

@@ -159,6 +159,9 @@ Response fields:
| `single_flight` | Whether the daemon serializes requests |
| `max_request_data_bytes` | Maximum JSON-encoded `data` size accepted by `/request` and `/notify` |
| `protocol` | Wire protocol name and version |
| `reconnect_failures` | Consecutive BLE transport failures, including reconnect and write failures |
| `max_reconnect_failures` | Maximum consecutive BLE transport failures before the daemon exits |
| `daemon_state` | Daemon lifecycle state, such as `running` or `exiting` |
### `POST /request`
@@ -207,8 +210,8 @@ HTTP error behavior:
| HTTP status | Meaning |
| --- | --- |
| `413` | Request data exceeds the daemon payload limit |
| `500` | Failed to send data to the BLE device |
| `502` | Device returned a protocol error or invalid response |
| `503` | BLE device is disconnected and the reconnect attempt failed, or the BLE write failed |
| `504` | Timed out waiting for the device response |
### `POST /notify`
@@ -257,7 +260,7 @@ HTTP error behavior:
| HTTP status | Meaning |
| --- | --- |
| `413` | Request data exceeds the daemon payload limit |
| `500` | Failed to send data to the BLE device |
| `503` | BLE device is disconnected and the reconnect attempt failed, or the BLE write failed |
## BLE JSONL RPC protocol
@@ -394,7 +397,24 @@ This keeps the firmware-side example simple because the device only needs to han
## Disconnect behavior
The daemon does not automatically reconnect after the BLE link is disconnected. If the device disconnects, stop and restart the daemon after the device starts advertising again.
The daemon attempts an on-demand reconnect before each `/request` or `/notify`
when it detects that the BLE link is disconnected. If reconnect succeeds, the
HTTP call continues without restarting the daemon. If reconnect fails, the call
returns HTTP `503`.
The daemon does not run a background reconnect loop, so `GET /status` may show
`DISCONNECTED` until the next `/request` or `/notify` triggers a reconnect
attempt. Daemon startup still requires the initial BLE connection to succeed.
The daemon records consecutive BLE transport failures, including failed
on-demand reconnects and failed writes after a stale connection is detected. A
successful reconnect or write clears the counter. After three consecutive BLE
transport failures, the daemon returns HTTP `503` for the triggering call and
then exits.
If the BLE link drops after a `/request` has already been written to the device,
the daemon does not replay the request. The HTTP call may time out with `504`
while the daemon waits for a response that never arrives.
## Troubleshooting
@@ -404,7 +424,18 @@ The daemon does not automatically reconnect after the BLE link is disconnected.
- Confirm the device response contains the same `id` as the request.
- Confirm the firmware handles the requested `op`.
- Increase `--timeout` if the operation is slow.
- Restart the daemon if the BLE link was disconnected.
- If the BLE link was disconnected, make sure the device is advertising again;
the next `/request` or `/notify` will attempt to reconnect.
### Daemon returns HTTP 503
- The BLE device is disconnected or not advertising.
- The daemon tried to reconnect before sending the request or notification, but
the reconnect attempt failed.
- Restore the BLE device and retry the same command; the daemon does not replay
failed requests automatically.
- After three consecutive BLE transport failures, the daemon exits. Restart it
after the BLE device is advertising again.
### Daemon returns HTTP 502

View File

@@ -34,12 +34,7 @@ class BLEUARTBridge:
self._profile = profile or BLEUARTProfile()
self._conn_state = ConnectionState.DISCONNECTED
self._connection_timeout = connection_timeout
self._client = BleakClient(
self._device_id,
disconnected_callback=self._handle_disconnect,
services=[self._profile.service_uuid],
timeout=connection_timeout,
)
self._client = self._create_client()
self._tx_char: BleakGATTCharacteristic | None = None
self._rx_char: BleakGATTCharacteristic | None = None
self._disconnected_event = asyncio.Event()
@@ -60,6 +55,14 @@ class BLEUARTBridge:
def is_connected(self) -> bool:
return self._conn_state is ConnectionState.CONNECTED and self._client.is_connected
def _create_client(self) -> BleakClient:
return BleakClient(
self._device_id,
disconnected_callback=self._handle_disconnect,
services=[self._profile.service_uuid],
timeout=self._connection_timeout,
)
def reset(self) -> None:
self._conn_state = ConnectionState.DISCONNECTED
self._tx_char = None
@@ -108,7 +111,10 @@ class BLEUARTBridge:
async with self._state_lock:
await self._cleanup_connection_locked()
def _handle_disconnect(self, _: BleakClient) -> None:
def _handle_disconnect(self, client: BleakClient) -> None:
if client is not self._client:
logger.debug(f'Ignoring stale disconnect callback from {self._device_id}')
return
logger.info(f'Disconnected from {self._device_id}')
self.reset()
self._disconnected_event.set()
@@ -130,6 +136,7 @@ class BLEUARTBridge:
# Update connection state
logger.info(f'Connecting to {self._device_id}...')
self._conn_state = ConnectionState.CONNECTING
self._client = self._create_client()
await self._client.connect()
if not self._client.is_connected:

View File

@@ -52,7 +52,9 @@ def _request_json(
def run_daemon(device_id: str, host: str, port: int) -> None:
daemon_app.state.device_id = device_id
uvicorn.run(daemon_app, host=host, port=port)
server = uvicorn.Server(uvicorn.Config(daemon_app, host=host, port=port))
daemon_app.state.uvicorn_server = server
server.run()
def run_daemon_status(host: str = '127.0.0.1', port: int = 8888) -> None:

View File

@@ -20,6 +20,8 @@ from .models import MAX_REQUEST_DATA_BYTES
from .models import BLEUARTNotifyPayload
from .models import BLEUARTRequestPayload
MAX_RECONNECT_FAILURES = 3
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
@@ -31,6 +33,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
loop = asyncio.get_running_loop()
app.state.rx_buffer = bytearray()
app.state.pending_requests = {}
app.state.reconnect_failures = 0
app.state.max_reconnect_failures = MAX_RECONNECT_FAILURES
app.state.daemon_state = 'running'
def _handle_rx_data(data: bytes) -> None:
for message in drain_jsonl_messages(app.state.rx_buffer, data):
@@ -64,6 +69,40 @@ def _request_data_size(data: object) -> int:
return len(json.dumps(data).encode())
def _record_ble_transport_success() -> None:
app.state.reconnect_failures = 0
def _request_daemon_exit() -> None:
app.state.daemon_state = 'exiting'
logger.error('Maximum BLE UART transport failures reached; stopping daemon')
server = getattr(app.state, 'uvicorn_server', None)
if server is None:
logger.warning('Uvicorn server handle is unavailable; daemon exit cannot be requested')
return
server.should_exit = True
def _record_ble_transport_failure(reason: str) -> None:
app.state.reconnect_failures += 1
logger.warning(
f'BLE UART transport failure: {reason} ({app.state.reconnect_failures}/{app.state.max_reconnect_failures})'
)
if app.state.reconnect_failures >= app.state.max_reconnect_failures:
_request_daemon_exit()
async def _ensure_connected(bridge: BLEUARTBridge) -> None:
if bridge.is_connected:
return
logger.info('BLE UART device is disconnected; attempting to reconnect...')
if not await bridge.connect():
_record_ble_transport_failure('reconnect failed')
raise HTTPException(status_code=503, detail='BLE device is disconnected and reconnect failed')
_record_ble_transport_success()
@app.get('/status')
async def status() -> dict:
bridge: BLEUARTBridge | None = getattr(app.state, 'bridge', None)
@@ -77,6 +116,9 @@ async def status() -> dict:
'single_flight': True,
'max_request_data_bytes': MAX_REQUEST_DATA_BYTES,
'protocol': f'esp-jsonl-rpc-lite-v{PROTOCOL_VERSION}',
'reconnect_failures': getattr(app.state, 'reconnect_failures', 0),
'max_reconnect_failures': getattr(app.state, 'max_reconnect_failures', MAX_RECONNECT_FAILURES),
'daemon_state': getattr(app.state, 'daemon_state', 'running'),
}
@@ -87,6 +129,8 @@ async def request(payload: BLEUARTRequestPayload) -> dict:
# Request with coroutine lock
async with app.state.request_lock:
await _ensure_connected(app.state.bridge)
request_id = uuid4().hex
response_future = asyncio.get_running_loop().create_future()
app.state.pending_requests[request_id] = response_future
@@ -98,7 +142,9 @@ async def request(payload: BLEUARTRequestPayload) -> dict:
with_response=True,
)
if not success:
raise HTTPException(status_code=500, detail='Failed to send data to device')
_record_ble_transport_failure('request write failed')
raise HTTPException(status_code=503, detail='Failed to send data to device')
_record_ble_transport_success()
# Wait for BLE device to respond
try:
@@ -118,11 +164,15 @@ async def notify(payload: BLEUARTNotifyPayload) -> dict:
if _request_data_size(payload.data) > MAX_REQUEST_DATA_BYTES:
raise HTTPException(status_code=413, detail=f'Request data exceeds {MAX_REQUEST_DATA_BYTES} bytes')
await _ensure_connected(app.state.bridge)
success = await app.state.bridge.send(
encode_jsonl_request('', payload.op, payload.data),
with_response=False,
)
if not success:
raise HTTPException(status_code=500, detail='Failed to send data to device')
_record_ble_transport_failure('notification write failed')
raise HTTPException(status_code=503, detail='Failed to send data to device')
_record_ble_transport_success()
return {'ok': True}