diff --git a/tools/ble/ble_uart_bridge/README.md b/tools/ble/ble_uart_bridge/README.md index ad8c7e87b47..1c9a8fe587c 100644 --- a/tools/ble/ble_uart_bridge/README.md +++ b/tools/ble/ble_uart_bridge/README.md @@ -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) diff --git a/tools/ble/ble_uart_bridge/demos/opencode/README.md b/tools/ble/ble_uart_bridge/demos/opencode/README.md new file mode 100644 index 00000000000..caf758a1b96 --- /dev/null +++ b/tools/ble/ble_uart_bridge/demos/opencode/README.md @@ -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 "" --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 /.opencode/plugins/opencode-ble-uart-bridge + cp tools/ble/ble_uart_bridge/demos/opencode/src/*.ts /.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": [ + "/.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 + `/.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":"","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":"","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":"","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":"","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": "", + "ok": true, + "data": { + "decision": "once", + "message": "Approved from BLE device" + } +} +``` + +Device error response in the daemon JSONL envelope: + +```json +{ + "v": 1, + "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. diff --git a/tools/ble/ble_uart_bridge/demos/opencode/opencode.json.example b/tools/ble/ble_uart_bridge/demos/opencode/opencode.json.example new file mode 100644 index 00000000000..06b371baa11 --- /dev/null +++ b/tools/ble/ble_uart_bridge/demos/opencode/opencode.json.example @@ -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" + } +} diff --git a/tools/ble/ble_uart_bridge/demos/opencode/src/ble-daemon-client.ts b/tools/ble/ble_uart_bridge/demos/opencode/src/ble-daemon-client.ts new file mode 100644 index 00000000000..1a70326c2c6 --- /dev/null +++ b/tools/ble/ble_uart_bridge/demos/opencode/src/ble-daemon-client.ts @@ -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 { + 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 { + 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 { + 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 { + 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) +} diff --git a/tools/ble/ble_uart_bridge/demos/opencode/src/config.ts b/tools/ble/ble_uart_bridge/demos/opencode/src/config.ts new file mode 100644 index 00000000000..92b67734c12 --- /dev/null +++ b/tools/ble/ble_uart_bridge/demos/opencode/src/config.ts @@ -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" diff --git a/tools/ble/ble_uart_bridge/demos/opencode/src/logging.ts b/tools/ble/ble_uart_bridge/demos/opencode/src/logging.ts new file mode 100644 index 00000000000..17246e444bc --- /dev/null +++ b/tools/ble/ble_uart_bridge/demos/opencode/src/logging.ts @@ -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, +) { + 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, +) { + try { + await appLog(client, level, message, extra) + } catch (error) { + debugLog(`failed to write app log: ${message}`, { error: String(error) }) + } +} diff --git a/tools/ble/ble_uart_bridge/demos/opencode/src/opencode-ble-uart-bridge.ts b/tools/ble/ble_uart_bridge/demos/opencode/src/opencode-ble-uart-bridge.ts new file mode 100644 index 00000000000..9c7e14694cd --- /dev/null +++ b/tools/ble/ble_uart_bridge/demos/opencode/src/opencode-ble-uart-bridge.ts @@ -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 { + 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() + + async function refreshBLEState(notifyConnected: boolean): Promise { + 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 + } + } + }, + } +} diff --git a/tools/ble/ble_uart_bridge/demos/opencode/src/opencode-permission-reply.ts b/tools/ble/ble_uart_bridge/demos/opencode/src/opencode-permission-reply.ts new file mode 100644 index 00000000000..8d4fe00c37c --- /dev/null +++ b/tools/ble/ble_uart_bridge/demos/opencode/src/opencode-permission-reply.ts @@ -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 { + 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 { + 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" +} diff --git a/tools/ble/ble_uart_bridge/demos/opencode/src/permission-payload.ts b/tools/ble/ble_uart_bridge/demos/opencode/src/permission-payload.ts new file mode 100644 index 00000000000..9507de4cc22 --- /dev/null +++ b/tools/ble/ble_uart_bridge/demos/opencode/src/permission-payload.ts @@ -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): 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 | undefined): Record { + 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 { + 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), + }, + } +} diff --git a/tools/ble/ble_uart_bridge/demos/opencode/src/permission-queue.ts b/tools/ble/ble_uart_bridge/demos/opencode/src/permission-queue.ts new file mode 100644 index 00000000000..e3ef49b254a --- /dev/null +++ b/tools/ble/ble_uart_bridge/demos/opencode/src/permission-queue.ts @@ -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>() + +/** + * 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() + +/** 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() + 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 { + 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) + } 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 { + 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" +} diff --git a/tools/ble/ble_uart_bridge/demos/opencode/src/types.ts b/tools/ble/ble_uart_bridge/demos/opencode/src/types.ts new file mode 100644 index 00000000000..b6ca0ae9ca6 --- /dev/null +++ b/tools/ble/ble_uart_bridge/demos/opencode/src/types.ts @@ -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 + patterns?: string[] + tool_name?: string + tool_input?: Record +} + +/** + * 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 + }) => Promise + } + app?: { + log?: (input: { + body: { + service: string + level: "debug" | "info" | "warn" | "error" + message: string + extra?: Record + } + }) => Promise + } + tui?: { + showToast?: (input: { + body: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } + }) => Promise + } + permission?: { + reply?: (input: { + requestID: string + reply?: "once" | "always" | "reject" + message?: string + directory?: string + workspace?: string + }) => Promise + respond?: (input: { + sessionID: string + permissionID: string + response?: "once" | "always" | "reject" + directory?: string + workspace?: string + }) => Promise + } + postSessionIdPermissionsPermissionId?: (input: { + path: { id: string; permissionID: string } + body: { response: PermissionDecision } + }) => Promise +} + +/** + * 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 +} diff --git a/tools/ble/ble_uart_bridge/docs/Quick-Start-BLE-UART-Daemon.md b/tools/ble/ble_uart_bridge/docs/Quick-Start-BLE-UART-Daemon.md index 74852758f6d..fa0dd1553ec 100644 --- a/tools/ble/ble_uart_bridge/docs/Quick-Start-BLE-UART-Daemon.md +++ b/tools/ble/ble_uart_bridge/docs/Quick-Start-BLE-UART-Daemon.md @@ -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 diff --git a/tools/ble/ble_uart_bridge/src/core/bridge.py b/tools/ble/ble_uart_bridge/src/core/bridge.py index 2424404237a..070009736bd 100644 --- a/tools/ble/ble_uart_bridge/src/core/bridge.py +++ b/tools/ble/ble_uart_bridge/src/core/bridge.py @@ -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: diff --git a/tools/ble/ble_uart_bridge/src/daemon/api.py b/tools/ble/ble_uart_bridge/src/daemon/api.py index a2e5f2ea15e..54af7d882da 100644 --- a/tools/ble/ble_uart_bridge/src/daemon/api.py +++ b/tools/ble/ble_uart_bridge/src/daemon/api.py @@ -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: diff --git a/tools/ble/ble_uart_bridge/src/daemon/server.py b/tools/ble/ble_uart_bridge/src/daemon/server.py index b4bb915c038..3f4235ead86 100644 --- a/tools/ble/ble_uart_bridge/src/daemon/server.py +++ b/tools/ble/ble_uart_bridge/src/daemon/server.py @@ -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}