From 114fd4a9bc9d2db4c0535bbac27154f43a5acd49 Mon Sep 17 00:00:00 2001 From: jiminxiang Date: Thu, 18 Jun 2026 11:04:41 +0800 Subject: [PATCH] feat(ble): add vibe_indicator device support to OpenCode bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect and drive the display-only ble_uart_vibe_indicator sample alongside the interactive MiaoBan companion device, and let multiple OpenCode instances each bind their own indicator channel. - plugin: after connect, probe the device over the daemon's generic /request path and classify it as vibe_indicator / generic / unknown — a vibe_indicator answers the indicator_count capability query, a 502 marks a generic device, and a transport failure stays unknown for retry on a later refresh. The daemon stays a generic transport and is unchanged; detection is a demo concern. - plugin: route by device_type. The vibe_indicator mirrors OpenCode activity as four lamp states on its bound channel — executing (green blink), success (green solid), waiting-for-user (yellow solid, on permission prompts, decision left to the TUI), and error (red solid, on session.error and the following idle). Other devices keep the existing session.status / permission round-trip. - plugin: add indicator_bind_channel / indicator_unbind_channel / indicator_show_binding tools. Each channel has at most one live owner: binding a channel owned by another running instance fails (force to take over), and stale claims are reclaimed via process-liveness checks. The per-directory binding is persisted and re-claimed across restarts (OPENCODE_BLE_BINDING_FILE). - docs: document device detection, lamp effects, and channel binding. --- .../ble_uart_bridge/demos/opencode/README.md | 153 +++++- .../demos/opencode/src/binding-store.ts | 183 +++++++ .../demos/opencode/src/device-detection.ts | 82 +++ .../demos/opencode/src/indicator-control.ts | 100 ++++ .../demos/opencode/src/logging.ts | 2 +- .../opencode/src/opencode-ble-uart-bridge.ts | 487 +++++++++++++++++- .../opencode/src/opencode-permission-reply.ts | 26 +- 7 files changed, 1010 insertions(+), 23 deletions(-) create mode 100644 tools/ble/ble_uart_bridge/demos/opencode/src/binding-store.ts create mode 100644 tools/ble/ble_uart_bridge/demos/opencode/src/device-detection.ts create mode 100644 tools/ble/ble_uart_bridge/demos/opencode/src/indicator-control.ts diff --git a/tools/ble/ble_uart_bridge/demos/opencode/README.md b/tools/ble/ble_uart_bridge/demos/opencode/README.md index 4e1dec7956d..474723215a7 100644 --- a/tools/ble/ble_uart_bridge/demos/opencode/README.md +++ b/tools/ble/ble_uart_bridge/demos/opencode/README.md @@ -19,6 +19,10 @@ device decisions, and daemon-side protocol handling for their own products. - [Environment variables](#environment-variables) - [Current assumptions](#current-assumptions) - [Message routing](#message-routing) +- [Indicator device support (vibe_indicator)](#indicator-device-support-vibe_indicator) + - [Device-type detection](#device-type-detection) + - [Lamp effects](#lamp-effects) + - [Binding a channel (multiple instances, one device)](#binding-a-channel-multiple-instances-one-device) - [Firmware protocol reference](#firmware-protocol-reference) - [Plugin message: session status](#plugin-message-session-status) - [Plugin message: permission cancel](#plugin-message-permission-cancel) @@ -211,6 +215,8 @@ are documented below in [Firmware protocol reference](#firmware-protocol-referen ## Files - `src/opencode-ble-uart-bridge.ts` — OpenCode plugin entry point using `/notify` for status and `/request` for permission decisions. +- `src/indicator-control.ts` — lamp mapping and control commands for the `ble_uart_vibe_indicator` sample device (see [Indicator device support](#indicator-device-support-vibe_indicator)). +- `src/binding-store.ts` — persists the per-directory indicator channel binding so it survives an OpenCode restart. - `src/*.ts` helper modules — typed, commented demo code for payloads, ESP-BLE-UART Daemon transport, OpenCode replies, and permission queue handling. - `opencode.json.example` — example OpenCode config to load the plugin and ask for permissions. @@ -238,7 +244,9 @@ permission requests can be approved once with `once` or denied with `reject`. `http://127.0.0.1:8888`. - `OPENCODE_BLE_DECISION_TIMEOUT_SECONDS`: permission decision timeout in seconds. Defaults to `60`; set it to a positive number. -- `OPENCODE_BLE_DEBUG=1`: enables verbose local plugin logs. +- `OPENCODE_BLE_BINDING_FILE`: path to the indicator channel binding file. + Defaults to `~/.ble_uart_bridge/indicator-bindings.json`. See + [Binding a channel](#binding-a-channel-multiple-instances-one-device). ## Current assumptions @@ -267,6 +275,140 @@ permission requests can be approved once with `once` or denied with `reject`. - `permission.cancel` uses `POST /notify` because it only tells the device to clear a pending permission UI. - The plugin sends structured JSON objects as daemon `data`; it does not double-encode plugin payloads as JSON strings. +## Indicator device support (vibe_indicator) + +Besides the interactive MiaoBan (喵伴) companion device, this plugin can also +drive the display-only `ble_uart_vibe_indicator` sample (a signal-light board). +The two devices speak different application protocols over the same BLE UART +transport, so the plugin detects which one is connected and routes messages +accordingly. + +### Device-type detection + +Device-type detection is an application concern, so it lives in the plugin, not +in the generic transport daemon. The first time the plugin sees a connected +device, it probes it via `POST /request` with the indicator capability query +(`{"cmd":"query","type":"indicator_count"}`) and classifies the reply: + +- `vibe_indicator` — the device returned a well-formed `{"count": N}` with + `N >= 1`; this is the signal-light board and `N` is the number of lamp groups + (channels) it exposes. +- `generic` — the device answered but rejected the indicator probe (daemon + `502`), so it is not an indicator (for example, the MiaoBan companion device). +- `unknown` — the probe could not be completed (write failed, timed out, or the + daemon was unreachable). The plugin retries on a later status refresh while the + device stays connected. + +The probe runs at most once per **connection session** while the device stays +connected (an inconclusive `unknown` result is retried on later status refreshes). +When the device disconnects, detection state is cleared so a reconnect or a +different device is probed again. The plugin switches behavior on the detected +type: + +| OpenCode event | `vibe_indicator` | `generic` | `unknown` (probe pending) | +|---|---|---|---| +| `session.status` busy / retry | green blink (executing) | forwarded as `session.status` | deferred until classified | +| `session.status` idle | green solid (success), or red solid if the session just errored | forwarded as `session.status` | deferred until classified | +| `session.error` | red solid (error exit) | not forwarded | deferred until classified | +| `permission.asked` | yellow solid (waiting); decision in the OpenCode TUI | full BLE round-trip (`once` / `reject`) | OpenCode TUI only (no BLE round-trip) | + +The indicator protocol has no way to return a decision, so the plugin never +waits on the indicator for a permission answer — it only shows "waiting for user +feedback" on the lamps and lets you answer in the OpenCode TUI. + +All indicator commands use `POST /request` (which carries a non-empty `id`); the +`/notify` path is not used for indicators because its empty `id` is rejected by +the firmware as `id_not_specified`. + +The lamp mapping (lamp colors, blink actions, and the protocol field values) +lives in `src/indicator-control.ts` — edit it there if your board wires the +lamps differently. + +### Lamp effects + +Each indicator channel has three lamps — red (`light_id` 0), yellow (1), and +green (2). The firmware supports four actions per lamp: off (`light_action` 0), +on (1), slow blink (2, ~1 Hz), and fast blink (3, ~3 Hz). The plugin always +drives all three lamps of the bound channel together, so the previous effect is +cleared on every update. + +The plugin maps OpenCode activity to four high-level states (see +`IndicatorState` in `src/indicator-control.ts`): + +| State | Meaning | Red (0) | Yellow (1) | Green (2) | Triggered by | +|---|---|---|---|---|---| +| `executing` | running | off | off | slow blink | `session.status` = `busy` / `retry` | +| `success` | finished without error | off | off | on | `session.status` = `idle` (no error); `indicator_bind_channel` confirm | +| `waiting` | waiting for user feedback | off | on | off | `permission.asked` pending | +| `error` | errored out | on | off | off | `session.error` (and the following `idle` stays red) | + +Notes: + +- `idle` alone cannot tell success from failure, so the plugin tracks + `session.error`: after an error the lamp stays red (`error`) through the + following `idle`, and only returns to green once new work starts (`busy`). +- Each state is sent as one `control` command whose `payload` lists all three + lamp updates, for example `executing` (green blink) on channel 0: + + ```json + {"v":1,"id":"","op":"command","data":{"cmd":"control","payload":[ + {"indicator_id":0,"light_id":0,"light_action":0}, + {"indicator_id":0,"light_id":1,"light_action":0}, + {"indicator_id":0,"light_id":2,"light_action":2} + ]}} + ``` + + `indicator_id` is the [bound channel](#binding-a-channel-multiple-instances-one-device). + +### Binding a channel (multiple instances, one device) + +When several independent OpenCode instances share one indicator device, each +instance needs its own lamp group (channel) so their lamps do not collide. The +plugin assigns channels automatically and exposes tools to override the choice +(invoked by the assistant in natural language). + +**Automatic assignment on startup.** Once the plugin learns the device is an +indicator and how many channels it exposes, it resolves a channel in this order: + +1. If this project directory already has a saved binding, it re-claims that + channel. If a *live* instance has meanwhile taken the channel, the binding is + **not** silently moved — the plugin reports the conflict and leaves this + instance unbound, so you can decide where it goes. +2. Otherwise it auto-selects and claims the lowest-numbered **free** channel. +3. If every channel is already owned by a live instance, the instance stays + **unbound** ("dangling"): it warns and drives no lamps until a channel frees + up and you bind it. + +While unbound, lamp updates are skipped — the instance simply does not light +anything. + +**Tools (manual override):** + +- `indicator_bind_channel(channel)` — bind this OpenCode instance to a specific + channel (`0` .. `indicator_count - 1`). The chosen channel briefly lights green + to confirm. Trigger it with, for example, *"bind the indicator to channel 1"*. +- `indicator_unbind_channel()` — release this instance's channel so another + instance can take it. This instance becomes unbound and drives no lamps until + you bind a channel again. +- `indicator_show_binding()` — report the current channel (or `none` when + unbound) and the device's channel count. + +**One channel, one live owner.** A channel can be owned by only one running +instance. Binding a channel that another *live* instance already owns fails with +an error naming the conflicting instance — pick a free channel instead (there is +no force-takeover). When an instance exits, its claim becomes stale and is +reclaimed automatically: the channel is then treated as free by the next +auto-selection or bind (ownership is tracked by process id). Rebinding to a +different channel, or `indicator_unbind`, frees the previously held one. + +The binding is persisted per project directory and re-claimed automatically when +an instance restarts, so a directory keeps the same channel across restarts. +Bindings are stored as a small JSON map +(`{ "": { "channel": N, "pid": P } }`) at +`~/.ble_uart_bridge/indicator-bindings.json`; override the path with +`OPENCODE_BLE_BINDING_FILE`. Two instances opened in the *same* directory share +one binding (they key off the directory). + ## Firmware protocol reference The ESP-BLE-UART Daemon wraps plugin messages into JSONL over BLE UART. For @@ -453,15 +595,6 @@ followed the Quick Start steps: Then restart OpenCode. The daemon URL can be customized with the `OPENCODE_BLE_DAEMON_URL` environment variable. -4. **Enable debug logging.** Set `OPENCODE_BLE_DEBUG=1` before starting - OpenCode to see verbose plugin logs in the terminal where OpenCode was - launched: - - ```bash - export OPENCODE_BLE_DEBUG=1 - opencode - ``` - ## Open items - Add an integration test with a mocked ESP-BLE-UART Daemon. diff --git a/tools/ble/ble_uart_bridge/demos/opencode/src/binding-store.ts b/tools/ble/ble_uart_bridge/demos/opencode/src/binding-store.ts new file mode 100644 index 00000000000..725b099349f --- /dev/null +++ b/tools/ble/ble_uart_bridge/demos/opencode/src/binding-store.ts @@ -0,0 +1,183 @@ +// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD +// SPDX-License-Identifier: Apache-2.0 + +import { mkdir, readFile, rename, writeFile } from "node:fs/promises" +import { homedir } from "node:os" +import { dirname, join } from "node:path" + +/** + * Persistent, exclusive store for indicator channel bindings, keyed by project + * directory. + * + * Each independent OpenCode instance runs in a project directory, so storing + * `directory -> { channel, pid }` lets a manual binding survive a restart while + * also enforcing that a channel has at most one live owner. The file is a small + * JSON object: `{ "": { "channel": N, "pid": P }, ... }`. + * + * Ownership is resolved with a liveness check: a claim whose owning process is + * gone is treated as stale and reclaimed automatically, so closing an instance + * frees its channel. Writes go through a temp file + rename so a partial write + * can never corrupt the shared file. Writes happen on startup auto-selection and + * on explicit bind/unbind; cross-instance write contention is low, and a + * concurrent claim race is possible but acceptable for this demo. + */ + +type Binding = { channel: number; pid: number } + +const DEFAULT_BINDING_FILE = join(homedir(), ".ble_uart_bridge", "indicator-bindings.json") + +/** Location of the bindings file; override with `OPENCODE_BLE_BINDING_FILE`. */ +function bindingFilePath(): string { + return process.env.OPENCODE_BLE_BINDING_FILE ?? DEFAULT_BINDING_FILE +} + +/** Whether a process id belongs to a live process on this machine. */ +function isProcessAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) { + return false + } + try { + // Signal 0 performs existence/permission checking without sending a signal. + process.kill(pid, 0) + return true + } catch (error) { + // EPERM means the process exists but is owned by another user. + return (error as { code?: string }).code === "EPERM" + } +} + +async function readBindings(): Promise> { + try { + const raw = await readFile(bindingFilePath(), "utf8") + const parsed = JSON.parse(raw) as unknown + if (parsed && typeof parsed === "object") { + const result: Record = {} + for (const [key, value] of Object.entries(parsed as Record)) { + // Current format: { channel, pid }. Tolerate the legacy format where the + // value was a bare channel number (treated as having no live owner). + if (typeof value === "number" && Number.isInteger(value) && value >= 0) { + result[key] = { channel: value, pid: 0 } + } else if (value && typeof value === "object") { + const channel = (value as Record).channel + const pid = (value as Record).pid + if (typeof channel === "number" && Number.isInteger(channel) && channel >= 0) { + result[key] = { channel, pid: typeof pid === "number" ? pid : 0 } + } + } + } + return result + } + } catch { + // Missing file or invalid JSON: treat as an empty store. + } + return {} +} + +async function writeBindings(bindings: Record): Promise { + const file = bindingFilePath() + const tmp = `${file}.${process.pid}.tmp` + await mkdir(dirname(file), { recursive: true }) + await writeFile(tmp, `${JSON.stringify(bindings, null, 2)}\n`, "utf8") + await rename(tmp, file) +} + +export type ClaimResult = + | { ok: true; channel: number } + | { ok: false; conflictDirectory: string; conflictPid: number } + +/** Return the channel saved for a directory, or undefined if none is stored. */ +export async function loadChannelForDirectory(directory: string | undefined): Promise { + if (!directory) { + return undefined + } + const bindings = await readBindings() + return bindings[directory]?.channel +} + +/** + * Claim a channel for a directory, enforcing one live owner per channel. + * + * Fails if another directory currently owns the channel and its owning process + * is still alive: a channel held by a running instance cannot be taken over. + * Stale claims (owner process gone) are reclaimed automatically. On success the + * directory's own previous claim (if any, on a different channel) is replaced, + * freeing that channel. + */ +export async function claimChannelForDirectory( + directory: string | undefined, + channel: number, +): Promise { + if (!directory) { + return { ok: true, channel } + } + const bindings = await readBindings() + + for (const [otherDir, binding] of Object.entries(bindings)) { + if (otherDir === directory || binding.channel !== channel) { + continue + } + if (isProcessAlive(binding.pid)) { + return { ok: false, conflictDirectory: otherDir, conflictPid: binding.pid } + } + // Stale claim (owner process gone): release the other directory's claim. + delete bindings[otherDir] + } + + bindings[directory] = { channel, pid: process.pid } + await writeBindings(bindings) + return { ok: true, channel } +} + +/** + * Auto-select and claim the lowest-numbered free channel for a directory. + * + * A channel is "free" when no *other* directory with a live owning process holds + * it (channels held by dead processes are reclaimable, hence free). Returns the + * claimed channel, or `null` when every channel in `0..count-1` is occupied by a + * live instance — the caller should then treat the instance as unbound + * ("dangling"). `claimChannelForDirectory` re-reads the store before writing, so + * a channel lost to a concurrent claim is skipped and the next one is tried. + */ +export async function pickAndClaimFreeChannel( + directory: string | undefined, + count: number, +): Promise { + if (count <= 0) { + return null + } + if (!directory) { + // No persistence without a directory key; default to the first channel. + return 0 + } + const bindings = await readBindings() + const taken = new Set() + for (const [otherDir, binding] of Object.entries(bindings)) { + if (otherDir !== directory && isProcessAlive(binding.pid)) { + taken.add(binding.channel) + } + } + for (let channel = 0; channel < count; channel++) { + if (taken.has(channel)) { + continue + } + const result = await claimChannelForDirectory(directory, channel) + if (result.ok) { + return channel + } + // Lost a concurrent race for this channel; try the next one. + taken.add(channel) + } + return null +} + +/** Release any channel claimed by a directory (best-effort). */ +export async function releaseChannelForDirectory(directory: string | undefined): Promise { + if (!directory) { + return + } + const bindings = await readBindings() + if (bindings[directory]) { + delete bindings[directory] + await writeBindings(bindings) + } +} diff --git a/tools/ble/ble_uart_bridge/demos/opencode/src/device-detection.ts b/tools/ble/ble_uart_bridge/demos/opencode/src/device-detection.ts new file mode 100644 index 00000000000..8eee87b8324 --- /dev/null +++ b/tools/ble/ble_uart_bridge/demos/opencode/src/device-detection.ts @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD +// SPDX-License-Identifier: Apache-2.0 + +import { BLE_DAEMON_URL } from "./config" +import type { DaemonResponse } from "./types" + +/** + * Application-type detection for the connected ESP-BLE-UART device. + * + * The transport layer (NUS-over-BLE JSONL) is identical for every ESP-BLE-UART + * device, so neither the daemon nor this plugin can tell a `vibe_indicator` + * apart from any other device by connection alone. This detection is a demo + * concern — the daemon is a generic transport — so the probe lives here in the + * plugin: we send the `vibe_indicator` capability query and classify the device + * from its reply. + * + * - A well-formed `{"count": N}` reply with `N >= 1` identifies a `vibe_indicator`. + * - A protocol or application rejection (daemon HTTP 502) identifies a generic + * ESP-BLE-UART device that does not understand the indicator protocol. + * - A write failure, timeout, or unreachable daemon leaves the type `unknown`, + * so the caller can retry on a later refresh. + */ + +export type DeviceType = "vibe_indicator" | "generic" | "unknown" + +export type DeviceProbeResult = { + deviceType: DeviceType + /** Number of indicator channels, only present for a `vibe_indicator`. */ + indicatorCount?: number +} + +/** JSONL envelope `op` and capability query the indicator firmware answers. */ +const PROBE_OP = "command" +const PROBE_DATA = { cmd: "query", type: "indicator_count" } +const PROBE_TIMEOUT_SECONDS = 5 + +/** + * Probe the connected device and classify its application type. + * + * This goes straight to the daemon's `/request` path (rather than + * `sendRequestToBLE`) because it needs the raw device payload (`{count}`) and + * the daemon's HTTP status to distinguish a rejection (generic device) from a + * transport failure (unknown), neither of which the permission-oriented response + * parser preserves. + */ +export async function probeDeviceType(): Promise { + let response: Response + try { + response = await fetch(`${BLE_DAEMON_URL}/request`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ op: PROBE_OP, data: PROBE_DATA, timeout: PROBE_TIMEOUT_SECONDS }), + }) + } catch { + // Daemon unreachable: cannot determine the type. + return { deviceType: "unknown" } + } + + if (!response.ok) { + // 502: the device rejected the indicator probe (protocol/application error) + // → a generic ESP-BLE-UART device. + // 503 (write failed) / 504 (timeout) / anything else: transport could not + // deliver the probe, so the type stays unknown and is retried later. + return { deviceType: response.status === 502 ? "generic" : "unknown" } + } + + try { + const body = (await response.json()) as DaemonResponse + const payload = body.data + const count = + payload && typeof payload === "object" ? (payload as Record).count : undefined + // Reject booleans (typeof boolean !== "number") and non-integers, matching the + // firmware's integer indicator count. Require at least one channel. + if (typeof count === "number" && Number.isInteger(count) && count >= 1) { + return { deviceType: "vibe_indicator", indicatorCount: count } + } + // Answered the probe without a usable indicator count: treat as a generic device. + return { deviceType: "generic" } + } catch { + return { deviceType: "unknown" } + } +} diff --git a/tools/ble/ble_uart_bridge/demos/opencode/src/indicator-control.ts b/tools/ble/ble_uart_bridge/demos/opencode/src/indicator-control.ts new file mode 100644 index 00000000000..9a5833b9e93 --- /dev/null +++ b/tools/ble/ble_uart_bridge/demos/opencode/src/indicator-control.ts @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD +// SPDX-License-Identifier: Apache-2.0 + +import { sendRequestToBLE } from "./ble-daemon-client" + +/** + * Signal-light control for the `ble_uart_vibe_indicator` sample device. + * + * Unlike the interactive companion device, the vibe_indicator is display-only: + * its protocol (see the sample's `json_format.md`) exposes only `query` and + * `control` commands and has no way to return a permission decision. This module + * maps OpenCode activity onto the device's lamps. + * + * Every command must travel over the daemon's `/request` path + * (`sendRequestToBLE`), never `/notify`: the notify path sends an empty `id`, + * which the indicator firmware rejects with `id_not_specified`. + */ + +/** JSONL envelope `op` the indicator firmware accepts. */ +const INDICATOR_OP = "command" + +/** Lamp ids within an indicator group: red / yellow / green GPIO. */ +const LIGHT_RED = 0 +const LIGHT_YELLOW = 1 +const LIGHT_GREEN = 2 + +// Lamp actions defined by the sample protocol: 0 off, 1 on, 2 slow blink +// (~1 Hz), 3 fast blink (~3 Hz). Only the actions used by the state mapping +// below are bound to names. +const ACTION_OFF = 0 +const ACTION_ON = 1 +const ACTION_SLOW_BLINK = 2 + +/** Indicator control commands echo quickly; keep the request timeout short. */ +const INDICATOR_REQUEST_TIMEOUT_SECONDS = 5 + +type LampCommand = { indicator_id: number; light_id: number; light_action: number } + +/** + * High-level indicator states mirrored on one channel's lamps: + * + * - `executing` → green blink (work in progress) + * - `success` → green solid (finished without error) + * - `waiting` → yellow solid (waiting for user feedback, e.g. a permission) + * - `error` → red solid (the session errored out) + */ +export type IndicatorState = "executing" | "success" | "waiting" | "error" + +function lamp(channel: number, lightId: number, lightAction: number): LampCommand { + return { indicator_id: channel, light_id: lightId, light_action: lightAction } +} + +/** + * Map a high-level indicator state onto the three lamps (red/yellow/green) of + * one channel. Every state drives all three lamps so the previous one is always + * cleared. + */ +function lampCommandsForState(channel: number, state: IndicatorState): LampCommand[] { + switch (state) { + case "executing": + // Green blink — work in progress. + return [lamp(channel, LIGHT_RED, ACTION_OFF), lamp(channel, LIGHT_YELLOW, ACTION_OFF), lamp(channel, LIGHT_GREEN, ACTION_SLOW_BLINK)] + case "waiting": + // Yellow solid — waiting for user feedback. + return [lamp(channel, LIGHT_RED, ACTION_OFF), lamp(channel, LIGHT_YELLOW, ACTION_ON), lamp(channel, LIGHT_GREEN, ACTION_OFF)] + case "error": + // Red solid — the session errored out. + return [lamp(channel, LIGHT_RED, ACTION_ON), lamp(channel, LIGHT_YELLOW, ACTION_OFF), lamp(channel, LIGHT_GREEN, ACTION_OFF)] + case "success": + default: + // Green solid — finished without error. + return [lamp(channel, LIGHT_RED, ACTION_OFF), lamp(channel, LIGHT_YELLOW, ACTION_OFF), lamp(channel, LIGHT_GREEN, ACTION_ON)] + } +} + +async function sendControl(payload: LampCommand[]): Promise { + // The device echoes the payload on success; the response is intentionally + // ignored. Errors propagate so callers can keep this best-effort. + await sendRequestToBLE(INDICATOR_OP, { cmd: "control", payload }, INDICATOR_REQUEST_TIMEOUT_SECONDS) +} + +/** Mirror a high-level indicator state onto one channel's lamps. */ +export async function sendIndicatorState(channel: number, state: IndicatorState): Promise { + await sendControl(lampCommandsForState(channel, state)) +} + +/** + * Turn off all three lamps (red/yellow/green) of one channel. + * + * Used when an instance stops driving a channel — on unbind, or when rebinding + * to a different channel — so the channel it left behind does not keep a stale + * lamp lit (e.g. a green blink from the previous binding). + */ +export async function clearIndicatorChannel(channel: number): Promise { + await sendControl([ + lamp(channel, LIGHT_RED, ACTION_OFF), + lamp(channel, LIGHT_YELLOW, ACTION_OFF), + lamp(channel, LIGHT_GREEN, ACTION_OFF), + ]) +} 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 23f2ca2f1cc..16c14dce90b 100644 --- a/tools/ble/ble_uart_bridge/demos/opencode/src/logging.ts +++ b/tools/ble/ble_uart_bridge/demos/opencode/src/logging.ts @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2026 Esposif Systems (Shanghai) CO LTD +// SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD // SPDX-License-Identifier: Apache-2.0 import type { OpenCodePermissionClient } from "./types" 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 4f33854ac3c..7d817ead056 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 @@ -1,10 +1,18 @@ // SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD // SPDX-License-Identifier: Apache-2.0 -import type { Plugin } from "@opencode-ai/plugin" +import { type Plugin, tool } from "@opencode-ai/plugin" import { getDaemonStatus, notifyBLE } from "./ble-daemon-client" +import { + claimChannelForDirectory, + loadChannelForDirectory, + pickAndClaimFreeChannel, + releaseChannelForDirectory, +} from "./binding-store" import { DEFAULT_REJECT_MESSAGE } from "./config" +import { type DeviceType, probeDeviceType } from "./device-detection" +import { clearIndicatorChannel, sendIndicatorState, type IndicatorState } from "./indicator-control" import { appLog, appLogBestEffort, showToastBestEffort } from "./logging" import { replyToOpenCodePermission } from "./opencode-permission-reply" import { @@ -21,6 +29,18 @@ import type { DaemonStatus, OpenCodePermissionClient, RawPermissionEvent } from type BLEPluginState = "unknown" | "connected" | "degraded" | "disabled" +/** + * Number of consecutive inconclusive ("unknown") device-type probes tolerated + * for one connection before the device is assumed to be a generic companion + * device. The probe classifies a device as generic only when its firmware + * actively rejects the indicator query (daemon HTTP 502). A companion device + * whose firmware silently ignores the query times out (HTTP 504) and would + * otherwise stay "pending" forever — permanently suppressing session-status + * forwarding and the BLE permission round-trip. Falling back to generic after a + * bounded number of retries re-enables those paths. + */ +const MAX_DEVICE_TYPE_PROBE_ATTEMPTS = 3 + function stateFromStatus(status: DaemonStatus): BLEPluginState { if (status.daemon_state === "exiting") { return "disabled" @@ -65,9 +85,110 @@ async function notifyStateChange( export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directory }) => { const openCodeClient = client as OpenCodePermissionClient let bleState: BLEPluginState = "unknown" + // Device application type, detected by probing the firmware (see + // ./device-detection). A "vibe_indicator" is a display-only device that + // mirrors OpenCode activity on lamps and cannot return a permission decision; + // everything else is treated as the interactive companion device that + // understands the full permission protocol. + let bleDeviceType: DeviceType = "unknown" + // Number of indicator channels (groups) the device exposes, from the probe. + let indicatorCount: number | undefined + // Whether the device application type has been conclusively detected for the + // current connection session, so the probe runs at most once while connected + // (cleared on disconnect so a new or reconnected device is probed again). An + // inconclusive "unknown" result leaves this false so a later refresh retries + // while still connected. + let deviceTypeProbed = false + // Guard against overlapping refreshes probing the device at the same time. + let deviceTypeProbing = false + // Consecutive inconclusive probes for the current connection. Drives the + // generic fallback after MAX_DEVICE_TYPE_PROBE_ATTEMPTS; reset on disconnect. + let deviceTypeProbeAttempts = 0 + // Indicator channel this OpenCode instance drives, or null when no channel is + // bound ("dangling": every channel is taken by another live instance, so lamp + // updates are skipped until a channel is bound). Resolved once the device type + // and channel count are known — a saved binding is re-claimed, otherwise the + // lowest free channel is auto-selected. Each independent instance therefore + // gets its own channel without fighting over one. + let instanceChannel: number | null = null + // Whether channel resolution has run for the connected indicator device, so it + // happens at most once and an explicit bind/unbind is not overridden. + let channelResolved = false + // Whether the current activity errored. Set on `session.error`, so the + // following `idle` shows red (error exit) instead of green (success); cleared + // when new work starts (`busy`). + let indicatorErrorActive = false + // Latest lamp state to apply once a channel is bound (covers auto-bind I/O race). + let pendingIndicatorState: IndicatorState | null = null + let indicatorLampChain = Promise.resolve() let bleStateRefreshGeneration = 0 const connectedSessionNotifications = new Set() + function isIndicatorDevice(): boolean { + return bleDeviceType === "vibe_indicator" + } + + function isDeviceTypePending(): boolean { + return bleDeviceType === "unknown" && !deviceTypeProbed + } + + // Run lamp work serialized on the single indicator chain and return a promise + // that resolves when *this* work item completes. All lamp writes — event + // driven (session.status / session.error / permission) and tool/lifecycle + // driven (bind / unbind / dispose) — must go through here so they apply in a + // deterministic order and never race to leave the lamp in the wrong state. + function runIndicatorLampWork(work: () => Promise): Promise { + const result = indicatorLampChain.then(work) + // Keep the queue alive after a failed update so later work still runs. + indicatorLampChain = result.then( + () => {}, + () => {}, + ) + return result + } + + function enqueueIndicatorLampWork(work: () => Promise): void { + void runIndicatorLampWork(work).catch(() => { + // Fire-and-forget; failures are already logged by the work itself. + }) + } + + async function driveIndicatorState(state: IndicatorState): Promise { + if (instanceChannel === null) { + pendingIndicatorState = state + return + } + try { + await sendIndicatorState(instanceChannel, state) + } catch (error) { + await appLogBestEffort(openCodeClient, "warn", "Failed to update indicator lamps", { + error: String(error), + }) + await refreshBLEState(false) + } + } + + // Flush the latest pending lamp state once a channel is bound. Enqueued on the + // shared chain so it cannot overtake or be overtaken by other lamp work. + function replayPendingIndicatorState(): void { + enqueueIndicatorLampWork(async () => { + if (instanceChannel === null || pendingIndicatorState === null) { + return + } + const state = pendingIndicatorState + pendingIndicatorState = null + try { + await sendIndicatorState(instanceChannel, state) + } catch (error) { + pendingIndicatorState = state + await appLogBestEffort(openCodeClient, "warn", "Failed to replay pending indicator state", { + error: String(error), + }) + await refreshBLEState(false) + } + }) + } + async function refreshBLEState(notifyConnected: boolean): Promise { const generation = ++bleStateRefreshGeneration try { @@ -77,7 +198,27 @@ export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directo } const nextState = stateFromStatus(status) const shouldNotify = nextState !== bleState && (nextState !== "connected" || notifyConnected) + if (bleState === "connected" && nextState !== "connected") { + deviceTypeProbed = false + deviceTypeProbeAttempts = 0 + bleDeviceType = "unknown" + indicatorCount = undefined + channelResolved = false + indicatorErrorActive = false + instanceChannel = null + pendingIndicatorState = null + } bleState = nextState + // Detect the device application type the first time we observe a connected + // device, before notifying or driving lamps, so callers that await this + // refresh (event handlers, indicator tools) see the resolved type instead + // of racing the probe. Awaited here is safe: the init call site wraps this + // refresh in `void`, so plugin init stays non-blocking, and a connected + // device answers the probe quickly. deviceTypeProbed makes a conclusive + // result stick while an inconclusive one is retried on a later refresh. + if (nextState === "connected" && !deviceTypeProbed) { + await detectDeviceType() + } if (shouldNotify) { await notifyStateChange(openCodeClient, nextState, status) } @@ -95,6 +236,117 @@ export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directo return bleState } + // Probe the connected device to classify its application type (see + // ./device-detection), then — for a vibe_indicator — resolve which channel + // this instance drives. Detection is a demo concern, so it lives in the + // plugin rather than the generic transport daemon. An inconclusive result + // (write failed / timed out / daemon unreachable) leaves deviceTypeProbed + // false so a later refresh retries while the device stays connected. A + // concurrent in-flight guard keeps overlapping refreshes from probing twice. + async function detectDeviceType(): Promise { + if (deviceTypeProbed || deviceTypeProbing) { + return + } + // Snapshot the refresh generation so a probe result from a connection that + // has since dropped (or been replaced by a reconnect) is discarded instead + // of being written onto the new/disconnected state. The probe itself can + // take seconds, during which another refresh may reset the device state. + const generation = bleStateRefreshGeneration + deviceTypeProbing = true + try { + const result = await probeDeviceType() + if (generation !== bleStateRefreshGeneration || bleState !== "connected") { + // The connection changed under us while probing; drop the stale result. + return + } + if (result.deviceType === "unknown") { + deviceTypeProbeAttempts += 1 + if (deviceTypeProbeAttempts < MAX_DEVICE_TYPE_PROBE_ATTEMPTS) { + // Inconclusive; leave unprobed so a later refresh retries. + return + } + // Retries exhausted: assume a generic device that does not speak the + // indicator protocol, so the companion permission/status path is + // re-enabled instead of staying pending forever. + deviceTypeProbed = true + bleDeviceType = "generic" + indicatorCount = undefined + await appLogBestEffort( + openCodeClient, + "warn", + `Device type probe inconclusive after ${deviceTypeProbeAttempts} attempts; treating device as generic.`, + {}, + ) + return + } + deviceTypeProbed = true + bleDeviceType = result.deviceType + indicatorCount = result.indicatorCount + if (isIndicatorDevice() && !channelResolved && indicatorCount !== undefined) { + await resolveInstanceChannel() + } + } finally { + deviceTypeProbing = false + } + } + + // Resolve which indicator channel this instance drives, once the device type + // and channel count are known (triggered from refreshBLEState). Order: + // 1. Re-claim the saved binding for this directory if it is still free. + // If a live instance has taken it, report the conflict and stay unbound + // (no silent re-selection — the user decides via indicator_bind_channel). + // 2. Otherwise auto-select and claim the lowest free channel. + // 3. If every channel is taken, stay unbound ("dangling") and warn. + // Runs at most once per connected indicator device; never blocks init. + async function resolveInstanceChannel(): Promise { + const count = indicatorCount + if (channelResolved || !isIndicatorDevice() || count === undefined) { + return + } + channelResolved = true + try { + const saved = await loadChannelForDirectory(directory) + if (saved !== undefined) { + const result = await claimChannelForDirectory(directory, saved) + if (result.ok) { + instanceChannel = saved + replayPendingIndicatorState() + return + } + instanceChannel = null + await reportDanglingChannel( + `Saved indicator channel ${saved} is in use by another running OpenCode instance ` + + `(pid ${result.conflictPid}). This instance is unbound; bind a free channel with the ` + + `indicator_bind_channel tool.`, + ) + return + } + const picked = await pickAndClaimFreeChannel(directory, count) + if (picked !== null) { + instanceChannel = picked + await appLogBestEffort(openCodeClient, "info", `Auto-bound to indicator channel ${picked} (of ${count}).`, {}) + replayPendingIndicatorState() + return + } + instanceChannel = null + await reportDanglingChannel( + `All ${count} indicator channel(s) are in use by other running OpenCode instances. ` + + `This instance is unbound; free a channel or bind one explicitly with indicator_bind_channel.`, + ) + } catch (error) { + // File I/O failure: leave the instance unbound rather than risk a collision. + instanceChannel = null + await appLogBestEffort(openCodeClient, "warn", "Failed to resolve indicator channel binding", { + error: String(error), + }) + } + } + + async function reportDanglingChannel(message: string): Promise { + await showToastBestEffort(openCodeClient, "warning", "OpenCode ESP-BLE-UART Bridge", message) + await appLogBestEffort(openCodeClient, "warn", message, {}) + } + // Fire-and-forget the initial BLE state check. Per OpenCode issue #4140, // awaiting client methods (app.log, tui.showToast) during plugin init can // hang OpenCode if the server isn't fully ready. The state will be @@ -104,6 +356,34 @@ export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directo }) return { + /** + * Turn off this instance's indicator lamps when the plugin is disposed + * (OpenCode shutting down, or reloading the plugin), so an exited instance + * does not leave its channel's lamp lit. Best-effort: if the daemon or device + * is already gone the call simply fails and is ignored. + * + * Only this instance's own channel is cleared, not every channel, so other + * OpenCode instances sharing the device keep their lamps. The persisted + * channel binding is intentionally left in place: this directory re-claims the + * same channel on the next start, and the now-dead pid lets another instance + * reclaim it if needed. + */ + dispose: async () => { + if (instanceChannel === null || !isIndicatorDevice()) { + return + } + const channel = instanceChannel + // Serialized on the shared chain so the final clear is not overtaken by a + // lamp update still queued from a last-moment event. + await runIndicatorLampWork(async () => { + try { + await clearIndicatorChannel(channel) + } catch { + // Best-effort; nothing else to do while shutting down. + } + }) + }, + /** * Handle OpenCode events that are relevant to the BLE device. * @@ -152,6 +432,34 @@ export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directo return } + // See the permission handler: only treat a pending probe as a reason to + // skip while the device is connected. While disconnected (degraded) the + // type stays pending until it reconnects, and forwarding the session + // status below is the BLE send that triggers the daemon's reconnect. + if (state === "connected" && isDeviceTypePending()) { + return + } + + if (isIndicatorDevice()) { + // Display-only device: map the session status onto the lamps. + // - busy/retry → executing (green blink); new work clears any + // previous error. + // - idle → success (green solid), unless this activity errored, in + // which case keep error (red solid) until the next busy. + const statusType = properties.status.type + enqueueIndicatorLampWork(async () => { + let indicatorState: IndicatorState + if (statusType === "busy" || statusType === "retry") { + indicatorErrorActive = false + indicatorState = "executing" + } else { + indicatorState = indicatorErrorActive ? "error" : "success" + } + await driveIndicatorState(indicatorState) + }) + return + } + if ( // If the session became idle while a BLE permission prompt is // active, mark that prompt as externally resolved and ask the @@ -195,6 +503,27 @@ export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directo })() } + if (eventType === "session.error") { + // Set synchronously so a concurrent session.status idle sees the error + // before lamp work is enqueued. + indicatorErrorActive = true + void (async () => { + try { + const state = await refreshBLEState(true) + if (state === "disabled" || !isIndicatorDevice()) { + return + } + enqueueIndicatorLampWork(async () => { + await driveIndicatorState("error") + }) + } catch (error) { + await appLogBestEffort(openCodeClient, "warn", "session.error handler failed", { + error: String(error), + }) + } + })() + } + // OpenCode's internal event system uses "permission.asked"; the SDK // event system uses "permission.updated". Accept both for compatibility // across OpenCode versions. @@ -203,6 +532,30 @@ export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directo try { const state = await refreshBLEState(true) + if (isIndicatorDevice()) { + // The indicator cannot return a decision. Show "waiting for user + // feedback" (yellow solid, best-effort, non-blocking) and let the + // user answer in the OpenCode TUI. Do not reply to OpenCode here. + // Enqueued on the shared lamp chain so it cannot race a concurrent + // session.status update and leave the lamp in the wrong state. + if (state !== "disabled" && instanceChannel !== null) { + enqueueIndicatorLampWork(async () => { + await driveIndicatorState("waiting") + }) + } + return + } + if (state === "connected" && isDeviceTypePending()) { + // Probe inconclusive on a *connected* device: do not route to the + // generic BLE permission path (the device may be a vibe_indicator). + // Answer in the OpenCode TUI. Gate this on "connected" only: when the + // device is disconnected (degraded) the type is reset to pending and is + // not re-probed until it reconnects, so falling through to + // enqueuePermissionRequest below is what triggers the daemon's + // reconnect-on-send. Blocking here would strand the user on a manual + // reconnect. + return + } if (state === "disabled") { await replyToOpenCodePermission( openCodeClient, @@ -226,5 +579,137 @@ export const BLEDeviceBridgePlugin: Plugin = async ({ client, serverUrl, directo } } }, + + /** + * User-invokable tools for binding this OpenCode instance to an indicator + * channel. When several independent OpenCode instances share one indicator + * device, each instance can claim its own channel so their lamps do not + * collide. Ask the assistant, e.g. "bind the indicator to channel 1". + */ + tool: { + indicator_bind_channel: tool({ + description: + "Bind this OpenCode instance's vibe_indicator lamps to a specific channel (indicator group, 0-based). " + + "Use when multiple OpenCode instances share one indicator device and each should drive its own channel. " + + "A channel can only be owned by one live instance; binding a channel already owned by another running " + + "instance fails (pick a free channel instead).", + args: { + channel: tool.schema.number(), + }, + async execute(args) { + await refreshBLEState(false) + if (!isIndicatorDevice()) { + return "No vibe_indicator device is connected; channel binding only applies to indicator devices." + } + const count = indicatorCount + const channel = Math.trunc(args.channel) + // Validate the target channel BEFORE touching any state. If the channel + // count is not known yet, or the channel is out of range, refuse without + // moving the binding: otherwise an invalid channel would overwrite the + // current (valid) binding in the store and dark its real lamp, leaving + // this instance pointed at a non-existent channel. Refusing here keeps + // the existing binding fully intact — i.e. it falls back to it. + if (count === undefined) { + return "Indicator channel count is not known yet; please try binding again in a moment." + } + if (channel < 0 || channel >= count) { + const current = instanceChannel === null ? "none (unbound)" : `channel ${instanceChannel}` + return `Invalid channel ${channel}. Valid range is 0..${count - 1}. Keeping the current binding (${current}).` + } + // An explicit, valid bind is the user's decision; stop auto-resolution + // from overriding it later. + channelResolved = true + // Claim the channel exclusively (persisted). A channel owned by another + // live instance cannot be taken over. + let claimed = true + try { + const result = await claimChannelForDirectory(directory, channel) + if (!result.ok) { + return ( + `Channel ${channel} is already bound by another running OpenCode instance ` + + `(pid ${result.conflictPid}, directory ${result.conflictDirectory}). ` + + `Pick a free channel.` + ) + } + } catch { + // Filesystem error: keep the binding in memory only (no exclusivity + // guarantee and it will reset on restart). + claimed = false + } + const previousChannel = instanceChannel + instanceChannel = channel + // Serialize the rebind lamp work on the shared chain so it cannot race + // a concurrent event-driven update. Moving off a different channel: + // turn its lamps off so the channel we left behind does not keep a + // stale lamp lit (e.g. the previous channel's green blink). Then a + // best-effort visual confirmation: light the chosen channel green. + await runIndicatorLampWork(async () => { + if (previousChannel !== null && previousChannel !== channel) { + try { + await clearIndicatorChannel(previousChannel) + } catch { + // Ignore; the new channel binding below still applies. + } + } + try { + await sendIndicatorState(channel, "success") + } catch { + // Ignore; the binding still takes effect for later status updates. + } + }) + replayPendingIndicatorState() + const note = claimed ? "" : " (could not be saved to disk; it will reset on restart)" + return `Bound this OpenCode instance to indicator channel ${channel} (of ${count})${note}.` + }, + }), + indicator_unbind_channel: tool({ + description: + "Release this OpenCode instance's indicator channel binding so another instance can claim it. " + + "This instance becomes unbound and stops driving any lamps until a channel is bound again.", + args: {}, + async execute() { + try { + await releaseChannelForDirectory(directory) + } catch { + // Best-effort; the in-memory reset below still happens. + } + // Turn off the lamps we were driving so an unbound instance does not + // leave a stale lamp lit on the channel it just released. Serialized + // on the shared chain so it cannot race a concurrent lamp update. + const releasedChannel = instanceChannel + if (releasedChannel !== null) { + await runIndicatorLampWork(async () => { + try { + await clearIndicatorChannel(releasedChannel) + } catch { + // Best-effort; the in-memory reset below still happens. + } + }) + } + // Stay unbound (do not auto-rebind) until the user binds again. + instanceChannel = null + pendingIndicatorState = null + channelResolved = true + return "Released the channel binding; this instance is now unbound and will not drive any indicator lamps until you bind a channel." + }, + }), + indicator_show_binding: tool({ + description: + "Show which vibe_indicator channel this OpenCode instance is currently bound to, and how many channels the device exposes.", + args: {}, + async execute() { + await refreshBLEState(false) + const bound = instanceChannel === null ? "none (unbound)" : String(instanceChannel) + if (!isIndicatorDevice()) { + return `Bound channel: ${bound}. No vibe_indicator device is currently connected.` + } + const range = indicatorCount !== undefined ? `. Device channels: 0..${indicatorCount - 1}` : "" + if (instanceChannel === null) { + return `Bound channel: none (unbound/dangling)${range}.` + } + return `Bound channel: ${instanceChannel}${range}.` + }, + }), + }, } } diff --git a/tools/ble/ble_uart_bridge/demos/opencode/src/opencode-permission-reply.ts b/tools/ble/ble_uart_bridge/demos/opencode/src/opencode-permission-reply.ts index 4b00e06bb24..e980cd6f2dc 100644 --- a/tools/ble/ble_uart_bridge/demos/opencode/src/opencode-permission-reply.ts +++ b/tools/ble/ble_uart_bridge/demos/opencode/src/opencode-permission-reply.ts @@ -96,18 +96,22 @@ export async function replyToOpenCodePermission( } if (serverUrl !== undefined && directory !== undefined) { - const response = await fetch(new URL(`/permission/${encodeURIComponent(requestID)}/reply`, serverUrl), { - method: "POST", - headers: opencodeRequestHeaders(directory), - body: JSON.stringify({ - reply: decision, - message, - }), - }) - if (!response.ok) { - throw new Error(`HTTP ${response.status} ${await response.text()}`) + try { + const response = await fetch(new URL(`/permission/${encodeURIComponent(requestID)}/reply`, serverUrl), { + method: "POST", + headers: opencodeRequestHeaders(directory), + body: JSON.stringify({ + reply: decision, + message, + }), + }) + if (!response.ok) { + throw new Error(`HTTP ${response.status} ${await response.text()}`) + } + return + } catch { + // fall through to v1 / deprecated v2 probes } - return } if (typeof client.postSessionIdPermissionsPermissionId === "function") {