/** * Opossum Vibrate Controller (负鼠振动控制器) protocol adapter. * * Unlike Coyote (stim, strength+waveform) or the paw-prints/civet-edging * sensors (pure telemetry), this is a dual-channel vibration device that * both accepts intensity commands (output) AND reports physical button * presses (input). It doesn't fit `WebBluetoothProtocolAdapter` (that * interface's `DeviceCommand`/`DeviceState` are Coyote-strength-shaped with * frequency/waveform concepts this device has no notion of) or * `WebBluetoothSensorAdapter` (this device isn't pure-sensor), so it gets * its own standalone public API instead. * * GATT skeleton is identical to Coyote V3 (service 0x180C, write 0x150A, * notify 0x150B, battery 0x180A/0x1500) — only the opcodes differ. * * Assumption: the community protocol doc's marketing name "Opossum * Vibration Controller 47L127000" suggests a product-name-prefixed BLE * name, but every sibling 47L12x-family device advertises a bare * `47L1XX000`-style model string with no product-name prefix, so the * advertised BLE name is assumed to be just `47L127000` * (`OPOSSUM_DEVICE_NAME_PREFIX`), not literally starting with "Opossum". * * ## Why intensity alone never made the motor spin (fixed here) * * The doc's B3 command only sets a *displayed* "channel strength" (0-200, * same scale as Coyote's strength) — the actual motor drive comes from B0, * a 20-byte packet carrying 4×25ms amplitude samples (0-100) per channel * that **must be re-sent every 100ms** to keep vibrating; the moment the * stream stops, so does the motor. `setIntensity`/`vibrate_start` used to * write B3 once and never touch B0 at all — the device updated its * strength *state* but the motor never actually received a waveform to * play. This adapter now drives an internal 100ms `ProtocolTickLoop` * (started/stopped by `setIntensity`/`adjustIntensity`/`emergencyStop` * whenever either channel's intensity crosses 0) that streams `writeWaveformFrame()` * itself — `vibrate_start`'s external contract (fire-and-forget, no caller * tick loop) is unchanged, it just actually works now. */ import type { Channel, OpossumCommand, OpossumVibrationPatternName } from '@dg-kit/core'; import type { WebBluetoothConnectionContext } from './base.js'; export interface OpossumState { connected: boolean; deviceName?: string; address?: string; battery?: number; intensityA: number; intensityB: number; /** * Named rhythm preset each channel's B0 stream currently follows. * Optional (not part of the pre-1.11.0 shape, and a caller-supplied raw * envelope via `setVibrationPattern(channel, number[])` has no name) — * absent means "unknown/custom", present means the named preset. Set to * 'constant' on connect, so in practice it's always present while * connected unless an advanced caller installed a custom envelope. */ patternA?: OpossumVibrationPatternName; patternB?: OpossumVibrationPatternName; } export declare function createEmptyOpossumState(): OpossumState; export type OpossumButton = 'SEL_1' | 'SEL_2' | 'HOME' | 'Up' | 'Down' | 'Left' | 'Right' | 'B' | 'A' | 'G' | 'D'; export interface OpossumButtonEvent { sequence: number; pressed: Set; } /** * Named vibration-rhythm envelopes, each element a 0-100 amplitude sample * covering 25ms (so a 40-element pattern spans one second), looped forever * while a channel is running. Each B0 tick's actual byte is this envelope * value scaled by the channel's current intensity (0-200 → 0-100 ceiling), * so `pattern` (rhythm/shape) and `intensity` (how strong) compose * independently — switching pattern doesn't change perceived peak strength. * Kept as named presets rather than letting the LLM submit an arbitrary * array: an LLM-generated envelope has no guardrail against e.g. a rapid * 0↔100 alternation that reads as "intense" in the abstract but is * unpleasant/startling on real skin. */ export declare const OPOSSUM_VIBRATION_PATTERNS: Record; type OpossumStateListener = (state: OpossumState) => void; type OpossumButtonListener = (event: OpossumButtonEvent) => void; export declare class OpossumVibrateAdapter { private state; private writeChar; private notifyChar; private readonly stateListeners; private readonly buttonListeners; private readonly tickLoop; private readonly patternCursor; private readonly patterns; private readonly patternState; private readonly burstRestores; onConnected(context: WebBluetoothConnectionContext): Promise; onDisconnected(): Promise; getState(): OpossumState; subscribeButtons(listener: OpossumButtonListener): () => void; onStateChanged(listener: OpossumStateListener): () => void; /** * Single entry point for `OpossumCommand` — the counterpart of * `CoyoteProtocolAdapter.execute()`. Exists so every consumer (DG-Agent, * DG-MCP, ...) shares one command→method mapping instead of each hand- * rolling its own switch, which is what let `vibrateBurst`/ * `vibrateSetPattern` silently no-op in a consumer that predated them. * The `never` check below makes a future `OpossumCommand` variant a * compile error here rather than a silent no-op downstream. */ execute(command: OpossumCommand): Promise; /** * 0xB3 direct intensity: A/B in 0-200, or 'unchanged' to leave that * channel as-is (mapped to the 0xFF sentinel byte). Starts/stops the * internal B0 tick loop so the motor actually follows — see the file * doc comment for why that's necessary. */ setIntensity(channelA: number | 'unchanged', channelB: number | 'unchanged'): Promise; /** * Relative adjust for one channel, e.g. for a `vibrate_adjust` tool call. * Reads the current intensity and writes the new absolute value in one * call so a caller never has to do `getState()` + `setIntensity()` as two * separate steps — doing it as two steps would let two concurrent adjust * calls (an LLM tool call racing a manual UI tweak, say) both read the * same stale value and the second write silently clobber the first's * delta instead of compounding. */ adjustIntensity(channel: Channel, delta: number): Promise; /** * Briefly drive one channel to `intensity`, then restore it after * `durationMs` — the vibrate counterpart of Coyote's `runBurst`, with the * identical stale-restore guard: the restore target is * `min(current, previous)`, so an intervening stop or manual decrease is * never pushed back *up* by a late-firing restore timer. */ vibrateBurst(channel: Channel, intensity: number, durationMs: number): Promise; private cancelBurstRestore; /** * Sets which rhythm envelope a channel's B0 stream follows. Pass a preset * name (see `OPOSSUM_VIBRATION_PATTERNS`) and the name is also surfaced in * `OpossumState.patternA/patternB` so UIs and the LLM status block can show * which rhythm is running; pass a raw envelope array (advanced callers) * and the state field goes blank — "custom", not a known preset. If the * channel is currently running (intensity > 0), the new pattern takes * over from its own beginning on the next tick — changing rhythm restarts * the phase rather than splicing into wherever the old pattern's cursor * happened to be, so the new rhythm is predictable. Does not touch * intensity. */ setVibrationPattern(channel: Channel, pattern: OpossumVibrationPatternName | readonly number[]): void; /** * 0xB0 vibration waveform. This is a single packet builder/writer — the * device expects it re-sent (~every 100ms) to sustain a waveform effect. * `setIntensity`/`adjustIntensity`/`emergencyStop` already drive this * automatically via an internal tick loop; call this directly only for * advanced one-off frames, and never concurrently with a running channel * (intensity > 0) or writes will interleave/race with the internal loop. */ writeWaveformFrame(channelA: [number, number, number, number], channelB: [number, number, number, number]): Promise; /** 0x50 LED color + button-state-reporting toggle. */ setLed(color: number, enableButtonReporting: boolean): Promise; /** * 0xB2 screen display update: fixed 21-byte preamble + current A/B * intensity. The source doc's summary states "23 bytes" total but also * enumerates exactly 21 fixed preamble bytes; 1 (opcode) + 21 + 2 (A/B) * is actually 24. Trusting the literal, unambiguous byte enumeration over * the (likely miscounted) total-length label. * * The doc states the on-device screen only updates when the host * actively pushes this after a B3 change — `setIntensity()` and the B3 * notification handler both call this so the screen and our own state * never drift apart regardless of which side initiated the change. */ updateDisplay(channelA: number, channelB: number): Promise; /** Best-effort safety call: drive both channels to zero, ignore failures. */ emergencyStop(): Promise; private applyIntensity; private resetPatternCursor; private buildQuad; private performTick; /** * Starts the B0 tick loop the moment either channel has intensity > 0, * stops it (and writes one final explicit all-zero B0 frame — the same * "don't just cease ticking, say stop" principle as Coyote's * `emergencyStop`) once both channels are back at 0. */ private syncTickLoop; private readonly handleNotification; private write; private clamp; private emitState; private emitButtonEvent; } export {}; //# sourceMappingURL=opossum.d.ts.map