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:07 +08:00
parent d2de518955
commit e10999a791
11 changed files with 296 additions and 40 deletions
@@ -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.
*
@@ -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) })
}
}
@@ -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
@@ -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)
@@ -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