mirror of
https://github.com/espressif/esp-idf.git
synced 2026-08-18 06:35:35 +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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user