feat(tools): improve ble uart reconnect ux

(cherry picked from commit 956299d988)

Co-authored-by: Zhou Xiao <zhouxiao@espressif.com>
This commit is contained in:
Zhou Xiao
2026-05-09 10:52:01 +08:00
parent e8f9f530b5
commit ecd106dfb7
11 changed files with 296 additions and 40 deletions

View File

@@ -222,6 +222,7 @@ 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.
@@ -273,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.

View File

@@ -141,10 +141,12 @@ flowchart LR
6. Start OpenCode after the daemon is running.
OpenCode loads plugins during startup. This demo checks `GET /status` once
while loading; if the BLE daemon is not reachable, the plugin registers no
event handler for that OpenCode process. Start or restart OpenCode after the
daemon is available.
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
@@ -242,7 +244,11 @@ permission requests can be approved once with `once` or denied with `reject`.
- 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.
- If the BLE daemon cannot return a permission decision, the plugin replies `reject`.
- The plugin checks daemon `/status` to maintain a connected, degraded, or
disabled BLE forwarding state. State changes are reported with OpenCode TUI
notifications when `client.tui.showToast` is available.
- If BLE forwarding is disabled or the BLE daemon cannot return a permission
decision, the plugin replies `reject`.
## Message routing
@@ -404,5 +410,4 @@ and truncated before crossing BLE.
## Open items
- Improve plugin startup UX when the daemon health/status check fails.
- Add an integration test with a mocked BLE daemon.

View File

@@ -4,7 +4,7 @@
import { BLE_DAEMON_URL } from "./config"
import { debugLog } from "./logging"
import { isPermissionDecision } from "./opencode-permission-reply"
import type { BridgeResponse, DaemonResponse } from "./types"
import type { BridgeResponse, DaemonResponse, DaemonStatus } from "./types"
/**
* Check whether the local BLE daemon is reachable.
@@ -14,13 +14,22 @@ import type { BridgeResponse, DaemonResponse } from "./types"
*/
export async function isDaemonAvailable(): Promise<boolean> {
try {
const response = await fetch(`${BLE_DAEMON_URL}/status`)
return response.ok
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.
*

View File

@@ -22,7 +22,8 @@ export function debugLog(message: string, data?: unknown) {
}
/**
* Prefer OpenCode's application log API, with console fallback for older clients.
* 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.
@@ -45,14 +46,35 @@ export async function appLog(
return
}
if (level === "error") {
console.error(`[opencode-ble] ${message}`, extra)
debugLog(`error: ${message}`, extra)
} else if (level === "warn") {
console.warn(`[opencode-ble] ${message}`, extra)
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.
*
@@ -68,6 +90,6 @@ export async function appLogBestEffort(
try {
await appLog(client, level, message, extra)
} catch (error) {
console.warn(`[opencode-ble] failed to write app log: ${message}`, error)
debugLog(`failed to write app log: ${message}`, { error: String(error) })
}
}

View File

@@ -3,19 +3,64 @@
import type { Plugin } from "@opencode-ai/plugin"
import { isDaemonAvailable, notifyBLE } from "./ble-daemon-client"
import { appLog, appLogBestEffort, debugLog } from "./logging"
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 { OpenCodePermissionClient, PermissionEventProperties } from "./types"
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.
*
@@ -24,10 +69,30 @@ export { buildPermissionPayload } from "./permission-payload"
* callback for session and permission events.
*/
export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directory }) => {
if (!(await isDaemonAvailable())) {
return {}
const openCodeClient = client as OpenCodePermissionClient
let bleState: BLEPluginState = "unknown"
const connectedSessionNotifications = new Set<string>()
async function refreshBLEState(notifyConnected: boolean): Promise<BLEPluginState> {
try {
const status = await getDaemonStatus()
const nextState = stateFromStatus(status)
if (nextState !== bleState && (nextState !== "connected" || notifyConnected)) {
await notifyStateChange(openCodeClient, nextState, status)
}
bleState = nextState
} catch (error) {
if (bleState !== "disabled") {
await notifyStateChange(openCodeClient, "disabled")
await appLogBestEffort(openCodeClient, "warn", "BLE UART daemon status check failed", { error: String(error) })
}
bleState = "disabled"
}
return bleState
}
await refreshBLEState(false)
return {
/**
* Handle OpenCode events that are relevant to the BLE device.
@@ -51,6 +116,26 @@ export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directo
// 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 () => {
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
@@ -68,7 +153,10 @@ export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directo
// following idle status also clears the device UI.
await notifyBLE("permission.cancel", buildPermissionCancelPayload(properties.sessionID))
} catch (error) {
console.warn("Failed to cancel stale BLE permission on device", error)
await appLogBestEffort(openCodeClient, "warn", "Failed to cancel stale BLE permission on device", {
error: String(error),
})
await refreshBLEState(false)
}
}
@@ -78,7 +166,10 @@ export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directo
// display synchronized with OpenCode.
await notifyBLE("session.status", buildSessionStatusPayload(properties.sessionID, properties.status))
} catch (error) {
console.warn("Failed to forward session status to BLE device", error)
await appLogBestEffort(openCodeClient, "warn", "Failed to forward session status to BLE device", {
error: String(error),
})
await refreshBLEState(false)
}
})()
}
@@ -96,15 +187,27 @@ export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directo
title: permission.title,
}
debugLog("received permission.asked", permissionSummary)
await appLogBestEffort(client as OpenCodePermissionClient, "info", "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(client as OpenCodePermissionClient, permission, serverUrl, directory)
await enqueuePermissionRequest(openCodeClient, permission, serverUrl, directory)
} catch (error) {
await appLog(client as OpenCodePermissionClient, "error", "Failed to reply to OpenCode permission request", {
await appLog(openCodeClient, "error", "Failed to reply to OpenCode permission request", {
error: String(error),
})
throw error

View File

@@ -3,7 +3,7 @@
import { CONCURRENT_REJECT_MESSAGE, DECISION_TIMEOUT_SECONDS, DEFAULT_REJECT_MESSAGE } from "./config"
import { sendRequestToBLE } from "./ble-daemon-client"
import { appLogBestEffort, debugLog } from "./logging"
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"
@@ -168,10 +168,15 @@ async function handlePermissionQueueItem(item: PermissionQueueItem): Promise<Per
// 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
console.warn("Failed to get BLE permission decision, rejecting request", error)
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)

View File

@@ -55,6 +55,17 @@ export type DaemonResponse = {
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.
*
@@ -80,6 +91,16 @@ export type OpenCodePermissionClient = {
}
}) => Promise<unknown>
}
tui?: {
showToast?: (input: {
body: {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
duration?: number
}
}) => Promise<unknown>
}
permission?: {
reply?: (input: {
requestID: string

View File

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

View File

@@ -33,12 +33,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()
@@ -59,6 +54,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
@@ -107,7 +110,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()
@@ -129,6 +135,7 @@ class BLEUARTBridge:
# Update connection state
logger.info(f'Connecting to {self._device_id}...')
self._conn_state = ConnectionState.CONNECTING
self._client = self._create_client()
await self._client.connect()
if not self._client.is_connected:

View File

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

View File

@@ -22,6 +22,8 @@ from .models import BLEUARTNotifyPayload
from .models import BLEUARTRequestPayload
from .models import MAX_REQUEST_DATA_BYTES
MAX_RECONNECT_FAILURES = 3
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
@@ -33,6 +35,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):
@@ -66,6 +71,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: Optional[BLEUARTBridge] = getattr(app.state, 'bridge', None)
@@ -79,6 +118,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'),
}
@@ -89,6 +131,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
@@ -100,7 +144,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:
@@ -120,11 +166,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}