mirror of
https://github.com/espressif/esp-idf.git
synced 2026-10-01 18:50:34 +03:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user