mirror of
https://github.com/espressif/esp-idf.git
synced 2026-10-01 18:50:34 +03:00
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:
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user