fix(ble): fixed edge case issues from ai reviewer

(cherry picked from commit fa8ca07e58)

Co-authored-by: Zhou Xiao <zhouxiao@espressif.com>
This commit is contained in:
Zhou Xiao
2026-05-09 10:52:10 +08:00
parent 6484f83ff3
commit c3af0670da
2 changed files with 73 additions and 49 deletions
@@ -34,8 +34,15 @@ export const DEBUG = process.env.OPENCODE_BLE_DEBUG === "1"
/** HTTP base URL of the local BLE daemon that bridges OpenCode to the BLE device. */ /** 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" 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. */ /** Maximum time to wait for a user decision from the BLE device. */
export const DECISION_TIMEOUT_SECONDS = Number(process.env.OPENCODE_BLE_DECISION_TIMEOUT_SECONDS ?? "60") 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. */ /** Message used when later prompts are skipped after an earlier same-session reject. */
export const CONCURRENT_REJECT_MESSAGE = "Another concurrent permission request was rejected" export const CONCURRENT_REJECT_MESSAGE = "Another concurrent permission request was rejected"
@@ -71,22 +71,32 @@ async function notifyStateChange(
export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directory }) => { export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directory }) => {
const openCodeClient = client as OpenCodePermissionClient const openCodeClient = client as OpenCodePermissionClient
let bleState: BLEPluginState = "unknown" let bleState: BLEPluginState = "unknown"
let bleStateRefreshGeneration = 0
const connectedSessionNotifications = new Set<string>() const connectedSessionNotifications = new Set<string>()
async function refreshBLEState(notifyConnected: boolean): Promise<BLEPluginState> { async function refreshBLEState(notifyConnected: boolean): Promise<BLEPluginState> {
const generation = ++bleStateRefreshGeneration
try { try {
const status = await getDaemonStatus() const status = await getDaemonStatus()
if (generation !== bleStateRefreshGeneration) {
return bleState
}
const nextState = stateFromStatus(status) const nextState = stateFromStatus(status)
if (nextState !== bleState && (nextState !== "connected" || notifyConnected)) { const shouldNotify = nextState !== bleState && (nextState !== "connected" || notifyConnected)
bleState = nextState
if (shouldNotify) {
await notifyStateChange(openCodeClient, nextState, status) await notifyStateChange(openCodeClient, nextState, status)
} }
bleState = nextState
} catch (error) { } catch (error) {
if (bleState !== "disabled") { if (generation !== bleStateRefreshGeneration) {
return bleState
}
const shouldNotify = bleState !== "disabled"
bleState = "disabled"
if (shouldNotify) {
await notifyStateChange(openCodeClient, "disabled") await notifyStateChange(openCodeClient, "disabled")
await appLogBestEffort(openCodeClient, "warn", "BLE UART daemon status check failed", { error: String(error) }) await appLogBestEffort(openCodeClient, "warn", "BLE UART daemon status check failed", { error: String(error) })
} }
bleState = "disabled"
} }
return bleState return bleState
} }
@@ -116,60 +126,67 @@ export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directo
// await this async IIFE from the OpenCode event callback; otherwise a // await this async IIFE from the OpenCode event callback; otherwise a
// slow or unavailable BLE daemon could block OpenCode's own event loop. // slow or unavailable BLE daemon could block OpenCode's own event loop.
void (async () => { void (async () => {
const previousState = bleState try {
const state = await refreshBLEState(true) const previousState = bleState
if ( const state = await refreshBLEState(true)
properties.status.type === "busy" && if (
state === "connected" && properties.status.type === "busy" &&
previousState === "connected" && state === "connected" &&
!connectedSessionNotifications.has(properties.sessionID) previousState === "connected" &&
) { !connectedSessionNotifications.has(properties.sessionID)
connectedSessionNotifications.add(properties.sessionID) ) {
await showToastBestEffort( connectedSessionNotifications.add(properties.sessionID)
openCodeClient, await showToastBestEffort(
"success", openCodeClient,
"OpenCode BLE UART Bridge", "success",
"BLE UART device is connected for this OpenCode session.", "OpenCode BLE UART Bridge",
) "BLE UART device is connected for this OpenCode session.",
} )
if (state === "disabled") { }
return 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)
}
}
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 { try {
// OpenCode does not emit a permission-specific cancellation event // Always forward the latest session status, even if there was no
// when a user answers the same prompt in the TUI. This best-effort // stale permission prompt to cancel. This keeps the BLE device's
// notification tells the daemon the BLE prompt is stale before the // display synchronized with OpenCode.
// following idle status also clears the device UI. await notifyBLE("session.status", buildSessionStatusPayload(properties.sessionID, properties.status))
await notifyBLE("permission.cancel", buildPermissionCancelPayload(properties.sessionID))
} catch (error) { } catch (error) {
await appLogBestEffort(openCodeClient, "warn", "Failed to cancel stale BLE permission on device", { await appLogBestEffort(openCodeClient, "warn", "Failed to forward session status to BLE device", {
error: String(error), error: String(error),
}) })
await refreshBLEState(false) 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) { } catch (error) {
await appLogBestEffort(openCodeClient, "warn", "Failed to forward session status to BLE device", { await appLogBestEffort(openCodeClient, "warn", "session.status handler failed", {
error: String(error), error: String(error),
}) })
await refreshBLEState(false) debugLog("session.status handler failed", { error: String(error) })
} }
})() })()
} }