diff --git a/tools/ble/ble_uart_bridge/README.md b/tools/ble/ble_uart_bridge/README.md index 40a83171923..1c9a8fe587c 100644 --- a/tools/ble/ble_uart_bridge/README.md +++ b/tools/ble/ble_uart_bridge/README.md @@ -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. diff --git a/tools/ble/ble_uart_bridge/demos/opencode/README.md b/tools/ble/ble_uart_bridge/demos/opencode/README.md index c05646663f0..caf758a1b96 100644 --- a/tools/ble/ble_uart_bridge/demos/opencode/README.md +++ b/tools/ble/ble_uart_bridge/demos/opencode/README.md @@ -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. diff --git a/tools/ble/ble_uart_bridge/demos/opencode/src/ble-daemon-client.ts b/tools/ble/ble_uart_bridge/demos/opencode/src/ble-daemon-client.ts index 0452b5d99af..1a70326c2c6 100644 --- a/tools/ble/ble_uart_bridge/demos/opencode/src/ble-daemon-client.ts +++ b/tools/ble/ble_uart_bridge/demos/opencode/src/ble-daemon-client.ts @@ -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 { 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 { + 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. * diff --git a/tools/ble/ble_uart_bridge/demos/opencode/src/logging.ts b/tools/ble/ble_uart_bridge/demos/opencode/src/logging.ts index e33558a161b..17246e444bc 100644 --- a/tools/ble/ble_uart_bridge/demos/opencode/src/logging.ts +++ b/tools/ble/ble_uart_bridge/demos/opencode/src/logging.ts @@ -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) }) } } diff --git a/tools/ble/ble_uart_bridge/demos/opencode/src/opencode-ble-uart-bridge.ts b/tools/ble/ble_uart_bridge/demos/opencode/src/opencode-ble-uart-bridge.ts index d4a9f97dbdc..2d4cfca767d 100644 --- a/tools/ble/ble_uart_bridge/demos/opencode/src/opencode-ble-uart-bridge.ts +++ b/tools/ble/ble_uart_bridge/demos/opencode/src/opencode-ble-uart-bridge.ts @@ -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 { + 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() + + async function refreshBLEState(notifyConnected: boolean): Promise { + 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 diff --git a/tools/ble/ble_uart_bridge/demos/opencode/src/permission-queue.ts b/tools/ble/ble_uart_bridge/demos/opencode/src/permission-queue.ts index 444ef20fb3a..e3ef49b254a 100644 --- a/tools/ble/ble_uart_bridge/demos/opencode/src/permission-queue.ts +++ b/tools/ble/ble_uart_bridge/demos/opencode/src/permission-queue.ts @@ -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 Promise } + tui?: { + showToast?: (input: { + body: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } + }) => Promise + } permission?: { reply?: (input: { requestID: string diff --git a/tools/ble/ble_uart_bridge/docs/Quick-Start-BLE-UART-Daemon.md b/tools/ble/ble_uart_bridge/docs/Quick-Start-BLE-UART-Daemon.md index 74852758f6d..fa0dd1553ec 100644 --- a/tools/ble/ble_uart_bridge/docs/Quick-Start-BLE-UART-Daemon.md +++ b/tools/ble/ble_uart_bridge/docs/Quick-Start-BLE-UART-Daemon.md @@ -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 diff --git a/tools/ble/ble_uart_bridge/src/core/bridge.py b/tools/ble/ble_uart_bridge/src/core/bridge.py index 340e25d974c..07cbd07bc6a 100644 --- a/tools/ble/ble_uart_bridge/src/core/bridge.py +++ b/tools/ble/ble_uart_bridge/src/core/bridge.py @@ -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: diff --git a/tools/ble/ble_uart_bridge/src/daemon/api.py b/tools/ble/ble_uart_bridge/src/daemon/api.py index 7b107e6adae..b3894f16cd2 100644 --- a/tools/ble/ble_uart_bridge/src/daemon/api.py +++ b/tools/ble/ble_uart_bridge/src/daemon/api.py @@ -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: diff --git a/tools/ble/ble_uart_bridge/src/daemon/server.py b/tools/ble/ble_uart_bridge/src/daemon/server.py index 47b9bc0f036..b3b4e720700 100644 --- a/tools/ble/ble_uart_bridge/src/daemon/server.py +++ b/tools/ble/ble_uart_bridge/src/daemon/server.py @@ -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}