feat(ble): add vibe_indicator device support to OpenCode bridge

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.
This commit is contained in:
jiminxiang
2026-06-18 17:12:36 +08:00
parent de87877f39
commit 114fd4a9bc
7 changed files with 1010 additions and 23 deletions
@@ -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":"<bridge-request-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
(`{ "<directory>": { "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.
@@ -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: `{ "<directory>": { "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<Record<string, Binding>> {
try {
const raw = await readFile(bindingFilePath(), "utf8")
const parsed = JSON.parse(raw) as unknown
if (parsed && typeof parsed === "object") {
const result: Record<string, Binding> = {}
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
// 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<string, unknown>).channel
const pid = (value as Record<string, unknown>).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<string, Binding>): Promise<void> {
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<number | undefined> {
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<ClaimResult> {
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<number | null> {
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<number>()
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<void> {
if (!directory) {
return
}
const bindings = await readBindings()
if (bindings[directory]) {
delete bindings[directory]
await writeBindings(bindings)
}
}
@@ -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<DeviceProbeResult> {
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<string, unknown>).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" }
}
}
@@ -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<void> {
// 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<void> {
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<void> {
await sendControl([
lamp(channel, LIGHT_RED, ACTION_OFF),
lamp(channel, LIGHT_YELLOW, ACTION_OFF),
lamp(channel, LIGHT_GREEN, ACTION_OFF),
])
}
@@ -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"
@@ -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<string>()
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<void>): Promise<void> {
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 {
void runIndicatorLampWork(work).catch(() => {
// Fire-and-forget; failures are already logged by the work itself.
})
}
async function driveIndicatorState(state: IndicatorState): Promise<void> {
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<BLEPluginState> {
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<void> {
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<void> {
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<void> {
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}.`
},
}),
},
}
}
@@ -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") {