mirror of
https://github.com/espressif/esp-idf.git
synced 2026-06-05 04:36:33 +03:00
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:
@@ -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)
|
||||
|
||||
413
tools/ble/ble_uart_bridge/demos/opencode/README.md
Normal file
413
tools/ble/ble_uart_bridge/demos/opencode/README.md
Normal 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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
48
tools/ble/ble_uart_bridge/demos/opencode/src/config.ts
Normal file
48
tools/ble/ble_uart_bridge/demos/opencode/src/config.ts
Normal 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"
|
||||
95
tools/ble/ble_uart_bridge/demos/opencode/src/logging.ts
Normal file
95
tools/ble/ble_uart_bridge/demos/opencode/src/logging.ts
Normal 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) })
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
276
tools/ble/ble_uart_bridge/demos/opencode/src/permission-queue.ts
Normal file
276
tools/ble/ble_uart_bridge/demos/opencode/src/permission-queue.ts
Normal 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"
|
||||
}
|
||||
142
tools/ble/ble_uart_bridge/demos/opencode/src/types.ts
Normal file
142
tools/ble/ble_uart_bridge/demos/opencode/src/types.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user