Initial implementation of Bluetooth toy control app
Next.js app with a browser-side buttplug/buttplug-wasm control layer (server never touches real-time device commands), SQLite storage via Drizzle, single-secret auth, recordings/replay with device remapping, a usage stats dashboard, Docker deployment, and Gitea CI. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8WkFF5ppURBAB918593Eb
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import type { ButtplugClient, ButtplugClientDevice } from "buttplug";
|
||||
import { deriveActuators, type ButtplugRuntime } from "./commands";
|
||||
import { useButtplugStore } from "./store";
|
||||
import type { ConnectedDeviceInfo } from "./types";
|
||||
|
||||
export function isWebBluetoothSupported(): boolean {
|
||||
return typeof navigator !== "undefined" && "bluetooth" in navigator;
|
||||
}
|
||||
|
||||
interface ClientHandle {
|
||||
client: ButtplugClient;
|
||||
runtime: ButtplugRuntime;
|
||||
}
|
||||
|
||||
let clientPromise: Promise<ClientHandle> | null = null;
|
||||
|
||||
function toDeviceInfo(device: ButtplugClientDevice): ConnectedDeviceInfo {
|
||||
return {
|
||||
index: device.index,
|
||||
name: device.name,
|
||||
displayName: device.displayName,
|
||||
actuators: deriveActuators(device),
|
||||
};
|
||||
}
|
||||
|
||||
async function initClient(): Promise<ClientHandle> {
|
||||
const [{ ButtplugClient, DeviceOutput, OutputType }, { ButtplugWasmClientConnector }] = await Promise.all([
|
||||
import("buttplug"),
|
||||
import("buttplug-wasm"),
|
||||
]);
|
||||
|
||||
const client = new ButtplugClient("sexy");
|
||||
|
||||
client.on("deviceadded", (device: ButtplugClientDevice) => {
|
||||
useButtplugStore.getState().upsertDevice(toDeviceInfo(device));
|
||||
});
|
||||
client.on("deviceremoved", (device: ButtplugClientDevice) => {
|
||||
useButtplugStore.getState().removeDevice(device.index);
|
||||
});
|
||||
client.on("scanningfinished", () => {
|
||||
useButtplugStore.getState().setScanning(false);
|
||||
});
|
||||
client.on("disconnect", () => {
|
||||
useButtplugStore.getState().reset();
|
||||
});
|
||||
|
||||
const connector = new ButtplugWasmClientConnector();
|
||||
await client.connect(connector);
|
||||
|
||||
useButtplugStore.getState().setConnected(true);
|
||||
for (const device of client.devices.values()) {
|
||||
useButtplugStore.getState().upsertDevice(toDeviceInfo(device));
|
||||
}
|
||||
|
||||
return { client, runtime: { DeviceOutput, OutputType } };
|
||||
}
|
||||
|
||||
/** Lazily creates and connects the singleton Buttplug client. Browser-only. */
|
||||
export function getButtplugClientHandle(): Promise<ClientHandle> {
|
||||
if (typeof window === "undefined") {
|
||||
return Promise.reject(new Error("Buttplug client is browser-only"));
|
||||
}
|
||||
if (!clientPromise) {
|
||||
clientPromise = initClient().catch((err) => {
|
||||
clientPromise = null;
|
||||
useButtplugStore.getState().setError(err instanceof Error ? err.message : String(err));
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return clientPromise;
|
||||
}
|
||||
|
||||
export async function startScanning(): Promise<void> {
|
||||
const { client } = await getButtplugClientHandle();
|
||||
useButtplugStore.getState().setScanning(true);
|
||||
await client.startScanning();
|
||||
}
|
||||
|
||||
export async function stopScanning(): Promise<void> {
|
||||
const { client } = await getButtplugClientHandle();
|
||||
await client.stopScanning();
|
||||
useButtplugStore.getState().setScanning(false);
|
||||
}
|
||||
|
||||
export async function disconnectAll(): Promise<void> {
|
||||
if (!clientPromise) return;
|
||||
const { client } = await clientPromise;
|
||||
clientPromise = null;
|
||||
await client.disconnect();
|
||||
useButtplugStore.getState().reset();
|
||||
}
|
||||
|
||||
export async function getDevice(deviceIndex: number): Promise<ButtplugClientDevice | undefined> {
|
||||
const { client } = await getButtplugClientHandle();
|
||||
return client.devices.get(deviceIndex);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { ButtplugClientDevice, DeviceOutputCommand, OutputType } from "buttplug";
|
||||
import type { ActuatorInfo, NormalizedOutputType } from "./types";
|
||||
|
||||
// buttplug@4's barrel doesn't re-export `ButtplugClientDeviceFeature` itself,
|
||||
// so its type is recovered from the `features` map it's stored in.
|
||||
type DeviceFeature = ButtplugClientDevice["features"] extends Map<number, infer F> ? F : never;
|
||||
|
||||
/**
|
||||
* The pieces of the dynamically-imported `buttplug` module namespace this
|
||||
* file needs at runtime. Kept as a parameter (rather than a static import of
|
||||
* `buttplug`'s runtime values) so this module has zero runtime dependency on
|
||||
* the browser-only client library and stays safe to reference from anywhere.
|
||||
*
|
||||
* Pinned to buttplug@4.x's API on purpose: `buttplug-wasm` (the embedded
|
||||
* Web Bluetooth connector) has not been updated for buttplug v5's breaking
|
||||
* OutputCmd wire-format change, so v4 is what's actually wire-compatible
|
||||
* with the WASM embedded server at runtime.
|
||||
*/
|
||||
export interface ButtplugRuntime {
|
||||
OutputType: typeof OutputType;
|
||||
DeviceOutput: {
|
||||
Vibrate: { percent(p: number): DeviceOutputCommand };
|
||||
Rotate: { percent(p: number): DeviceOutputCommand };
|
||||
Position: { percent(p: number): DeviceOutputCommand };
|
||||
HwPositionWithDuration: { percent(p: number, durationMs: number): DeviceOutputCommand };
|
||||
};
|
||||
}
|
||||
|
||||
const NORMALIZED_TO_OUTPUT: Record<NormalizedOutputType, string[]> = {
|
||||
vibrate: ["Vibrate"],
|
||||
rotate: ["Rotate"],
|
||||
// A device may expose plain Position (no duration) or the
|
||||
// duration-bearing HwPositionWithDuration - prefer whichever it reports.
|
||||
linear: ["HwPositionWithDuration", "Position"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the actuator list for a device from its reported feature outputs.
|
||||
* buttplug@4's `ButtplugClientDeviceFeature` only exposes `hasOutput`/
|
||||
* `hasInput`/`runOutput`/`runInput` (no `.index`/`.descriptor` getters, added
|
||||
* later in v5), so featureIndex comes from the `device.features` Map key and
|
||||
* the descriptor is a synthesized label, not the device's own string.
|
||||
*/
|
||||
export function deriveActuators(device: ButtplugClientDevice): ActuatorInfo[] {
|
||||
const actuators: ActuatorInfo[] = [];
|
||||
const countByType: Partial<Record<NormalizedOutputType, number>> = {};
|
||||
|
||||
for (const [featureIndex, feature] of device.features.entries()) {
|
||||
for (const [normalized, candidates] of Object.entries(NORMALIZED_TO_OUTPUT) as [
|
||||
NormalizedOutputType,
|
||||
string[],
|
||||
][]) {
|
||||
const matched = candidates.find((c) => feature.hasOutput(c as OutputType));
|
||||
if (matched) {
|
||||
const n = (countByType[normalized] ?? 0) + 1;
|
||||
countByType[normalized] = n;
|
||||
actuators.push({
|
||||
featureIndex,
|
||||
outputType: normalized,
|
||||
requiresDuration: matched === "HwPositionWithDuration",
|
||||
descriptor: `${normalized[0].toUpperCase()}${normalized.slice(1)} ${n}`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return actuators;
|
||||
}
|
||||
|
||||
export function findFeature(device: ButtplugClientDevice, featureIndex: number): DeviceFeature | undefined {
|
||||
return device.features.get(featureIndex);
|
||||
}
|
||||
|
||||
/** Builds the DeviceOutputCommand for a normalized 0-1 command value. */
|
||||
export function buildOutputCommand(
|
||||
bp: ButtplugRuntime,
|
||||
actuator: ActuatorInfo,
|
||||
value: number,
|
||||
durationMs?: number,
|
||||
): DeviceOutputCommand {
|
||||
const clamped = Math.min(1, Math.max(0, value));
|
||||
switch (actuator.outputType) {
|
||||
case "vibrate":
|
||||
return bp.DeviceOutput.Vibrate.percent(clamped);
|
||||
case "rotate":
|
||||
return bp.DeviceOutput.Rotate.percent(clamped);
|
||||
case "linear":
|
||||
return actuator.requiresDuration
|
||||
? bp.DeviceOutput.HwPositionWithDuration.percent(clamped, durationMs ?? 500)
|
||||
: bp.DeviceOutput.Position.percent(clamped);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { RecordingDeviceSlot } from "@/lib/db/schema";
|
||||
import type { ConnectedDeviceInfo } from "./types";
|
||||
|
||||
export interface DeviceRemapEntry {
|
||||
slotLabel: string;
|
||||
recordedBleName: string;
|
||||
matchedDeviceIndex: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort name match from a recording's saved device slots to the
|
||||
* currently-connected devices. Web Bluetooth exposes no stable hardware id
|
||||
* across sessions, so this is inherently approximate: two devices sharing an
|
||||
* identical advertised name are indistinguishable and must be disambiguated
|
||||
* manually in the remap UI - this isn't a bug, it's a hard BLE limitation.
|
||||
*/
|
||||
export function autoMapDeviceSlots(
|
||||
slots: RecordingDeviceSlot[],
|
||||
connectedDevices: ConnectedDeviceInfo[],
|
||||
): DeviceRemapEntry[] {
|
||||
const usedIndexes = new Set<number>();
|
||||
|
||||
return slots.map((slot) => {
|
||||
const normalizedSlotName = slot.recordedBleName.trim().toLowerCase();
|
||||
|
||||
const exact = connectedDevices.find(
|
||||
(d) => !usedIndexes.has(d.index) && d.name.trim().toLowerCase() === normalizedSlotName,
|
||||
);
|
||||
const partial =
|
||||
exact ??
|
||||
connectedDevices.find(
|
||||
(d) =>
|
||||
!usedIndexes.has(d.index) &&
|
||||
(d.name.toLowerCase().includes(normalizedSlotName) ||
|
||||
normalizedSlotName.includes(d.name.toLowerCase())),
|
||||
);
|
||||
|
||||
if (partial) usedIndexes.add(partial.index);
|
||||
|
||||
return {
|
||||
slotLabel: slot.slotLabel,
|
||||
recordedBleName: slot.recordedBleName,
|
||||
matchedDeviceIndex: partial?.index ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { CommandEvent } from "./types";
|
||||
|
||||
const FLUSH_INTERVAL_MS = 4000;
|
||||
|
||||
interface ApiEvent {
|
||||
sessionDeviceId: number;
|
||||
tsMs: number;
|
||||
commandType: CommandEvent["commandType"];
|
||||
featureIndex: number;
|
||||
value: number;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buffers every dispatched command (live or replay) and flushes it in
|
||||
* batches to the play-session's events endpoint, so a dragged slider never
|
||||
* fires one HTTP request per tick. Every command is always recorded here
|
||||
* regardless of whether the session is later saved as a named recording -
|
||||
* "recording" is a save decision made after the fact, not a separate
|
||||
* capture pipeline (see lib/db/queries/recordings.ts).
|
||||
*/
|
||||
class EventBuffer {
|
||||
private buffer: CommandEvent[] = [];
|
||||
private playSessionId: number | null = null;
|
||||
private sessionStartedAt = 0;
|
||||
private sessionDeviceIdByDeviceIndex = new Map<number, number>();
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
start(playSessionId: number, sessionStartedAt: number): void {
|
||||
this.playSessionId = playSessionId;
|
||||
this.sessionStartedAt = sessionStartedAt;
|
||||
this.sessionDeviceIdByDeviceIndex = new Map();
|
||||
this.buffer = [];
|
||||
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = setInterval(() => void this.flush(), FLUSH_INTERVAL_MS);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("beforeunload", this.flushBeacon);
|
||||
document.addEventListener("visibilitychange", this.onVisibilityChange);
|
||||
}
|
||||
}
|
||||
|
||||
registerSessionDevice(deviceIndex: number, sessionDeviceId: number): void {
|
||||
this.sessionDeviceIdByDeviceIndex.set(deviceIndex, sessionDeviceId);
|
||||
}
|
||||
|
||||
record(event: Omit<CommandEvent, "tsMs"> & { tsMs?: number }): void {
|
||||
if (this.playSessionId === null) return;
|
||||
const tsMs = event.tsMs ?? Date.now() - this.sessionStartedAt;
|
||||
this.buffer.push({ ...event, tsMs });
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
if (this.buffer.length === 0 || this.playSessionId === null) return;
|
||||
const events = this.drain();
|
||||
try {
|
||||
await fetch(`/api/play-sessions/${this.playSessionId}/events`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ events }),
|
||||
keepalive: true,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort telemetry - dropping a batch on network hiccup is
|
||||
// preferable to blocking or crashing live device control.
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
void this.flush();
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
this.playSessionId = null;
|
||||
if (typeof window !== "undefined") {
|
||||
window.removeEventListener("beforeunload", this.flushBeacon);
|
||||
document.removeEventListener("visibilitychange", this.onVisibilityChange);
|
||||
}
|
||||
}
|
||||
|
||||
private onVisibilityChange = (): void => {
|
||||
if (document.visibilityState === "hidden") this.flushBeacon();
|
||||
};
|
||||
|
||||
private flushBeacon = (): void => {
|
||||
if (this.buffer.length === 0 || this.playSessionId === null || typeof navigator === "undefined") return;
|
||||
const events = this.drain();
|
||||
navigator.sendBeacon(
|
||||
`/api/play-sessions/${this.playSessionId}/events`,
|
||||
new Blob([JSON.stringify({ events })], { type: "application/json" }),
|
||||
);
|
||||
};
|
||||
|
||||
private drain(): ApiEvent[] {
|
||||
const events = this.buffer;
|
||||
this.buffer = [];
|
||||
return events
|
||||
.map((e): ApiEvent | null => {
|
||||
const sessionDeviceId = this.sessionDeviceIdByDeviceIndex.get(e.deviceIndex);
|
||||
if (sessionDeviceId === undefined) return null;
|
||||
return {
|
||||
sessionDeviceId,
|
||||
tsMs: e.tsMs,
|
||||
commandType: e.commandType,
|
||||
featureIndex: e.featureIndex,
|
||||
value: e.value,
|
||||
durationMs: e.durationMs,
|
||||
};
|
||||
})
|
||||
.filter((e): e is ApiEvent => e !== null);
|
||||
}
|
||||
}
|
||||
|
||||
export const eventBuffer = new EventBuffer();
|
||||
@@ -0,0 +1,136 @@
|
||||
import { getDevice } from "./client";
|
||||
import { buildOutputCommand, findFeature, type ButtplugRuntime } from "./commands";
|
||||
import { eventBuffer } from "./event-buffer";
|
||||
import type { ActuatorInfo, CommandEvent } from "./types";
|
||||
|
||||
export interface RecordingEventRow {
|
||||
tsMs: number;
|
||||
commandType: CommandEvent["commandType"];
|
||||
featureIndex: number;
|
||||
value: number;
|
||||
durationMs: number | null;
|
||||
sessionDeviceId: number;
|
||||
}
|
||||
|
||||
export interface PlayerOptions {
|
||||
events: RecordingEventRow[];
|
||||
/** Recording's session_device_id -> currently-connected device index, from the remap step. */
|
||||
sessionDeviceIdToDeviceIndex: Map<number, number>;
|
||||
actuatorsByDeviceIndex: Map<number, ActuatorInfo[]>;
|
||||
runtime: ButtplugRuntime;
|
||||
speed?: number;
|
||||
onProgress?: (elapsedMs: number, durationMs: number) => void;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays a recording's events against the currently-connected devices,
|
||||
* using performance.now()-relative scheduling (not wall-clock Date.now())
|
||||
* so long sessions don't accumulate drift from setTimeout jitter.
|
||||
*/
|
||||
export class RecordingPlayer {
|
||||
private readonly durationMs: number;
|
||||
private startedAtPerf = 0;
|
||||
private pausedAtMs = 0;
|
||||
private timers: ReturnType<typeof setTimeout>[] = [];
|
||||
private progressTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private playing = false;
|
||||
|
||||
constructor(private readonly options: PlayerOptions) {
|
||||
this.durationMs = options.events.at(-1)?.tsMs ?? 0;
|
||||
}
|
||||
|
||||
get isPlaying(): boolean {
|
||||
return this.playing;
|
||||
}
|
||||
|
||||
play(): void {
|
||||
if (this.playing) return;
|
||||
const speed = this.options.speed ?? 1;
|
||||
this.playing = true;
|
||||
this.startedAtPerf = performance.now() - this.pausedAtMs / speed;
|
||||
this.scheduleFrom(this.pausedAtMs);
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
if (!this.playing) return;
|
||||
const speed = this.options.speed ?? 1;
|
||||
this.pausedAtMs = (performance.now() - this.startedAtPerf) * speed;
|
||||
this.playing = false;
|
||||
this.clearTimers();
|
||||
}
|
||||
|
||||
seek(ms: number): void {
|
||||
const wasPlaying = this.playing;
|
||||
this.clearTimers();
|
||||
this.playing = false;
|
||||
this.pausedAtMs = Math.max(0, Math.min(ms, this.durationMs));
|
||||
if (wasPlaying) this.play();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.playing = false;
|
||||
this.pausedAtMs = 0;
|
||||
this.clearTimers();
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
this.timers.forEach(clearTimeout);
|
||||
this.timers = [];
|
||||
if (this.progressTimer) clearInterval(this.progressTimer);
|
||||
this.progressTimer = null;
|
||||
}
|
||||
|
||||
private scheduleFrom(fromMs: number): void {
|
||||
const speed = this.options.speed ?? 1;
|
||||
|
||||
for (const event of this.options.events.filter((e) => e.tsMs >= fromMs)) {
|
||||
const delay = (event.tsMs - fromMs) / speed;
|
||||
this.timers.push(setTimeout(() => void this.dispatch(event), delay));
|
||||
}
|
||||
|
||||
this.timers.push(
|
||||
setTimeout(() => {
|
||||
this.playing = false;
|
||||
this.clearTimers();
|
||||
this.options.onComplete?.();
|
||||
}, (this.durationMs - fromMs) / speed),
|
||||
);
|
||||
|
||||
this.progressTimer = setInterval(() => {
|
||||
if (!this.playing) return;
|
||||
const elapsed = Math.min((performance.now() - this.startedAtPerf) * speed, this.durationMs);
|
||||
this.options.onProgress?.(elapsed, this.durationMs);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
private async dispatch(event: RecordingEventRow): Promise<void> {
|
||||
const deviceIndex = this.options.sessionDeviceIdToDeviceIndex.get(event.sessionDeviceId);
|
||||
if (deviceIndex === undefined) return;
|
||||
|
||||
const device = await getDevice(deviceIndex);
|
||||
if (!device) return;
|
||||
|
||||
eventBuffer.record({
|
||||
deviceIndex,
|
||||
commandType: event.commandType,
|
||||
featureIndex: event.featureIndex,
|
||||
value: event.value,
|
||||
durationMs: event.durationMs ?? undefined,
|
||||
});
|
||||
|
||||
if (event.commandType === "stop") {
|
||||
await device.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
const actuator = this.options.actuatorsByDeviceIndex
|
||||
.get(deviceIndex)
|
||||
?.find((a) => a.featureIndex === event.featureIndex);
|
||||
const feature = findFeature(device, event.featureIndex);
|
||||
if (!actuator || !feature) return;
|
||||
|
||||
const cmd = buildOutputCommand(this.options.runtime, actuator, event.value, event.durationMs ?? undefined);
|
||||
await feature.runOutput(cmd);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { create } from "zustand";
|
||||
import type { ConnectedDeviceInfo } from "./types";
|
||||
|
||||
export const actuatorKey = (deviceIndex: number, featureIndex: number): string =>
|
||||
`${deviceIndex}:${featureIndex}`;
|
||||
|
||||
interface ButtplugStoreState {
|
||||
connected: boolean;
|
||||
scanning: boolean;
|
||||
devices: Record<number, ConnectedDeviceInfo>;
|
||||
/** Last-known slider value per `${deviceIndex}:${featureIndex}`, for UI display only. */
|
||||
actuatorValues: Record<string, number>;
|
||||
error: string | null;
|
||||
setConnected: (connected: boolean) => void;
|
||||
setScanning: (scanning: boolean) => void;
|
||||
upsertDevice: (device: ConnectedDeviceInfo) => void;
|
||||
removeDevice: (index: number) => void;
|
||||
setActuatorValue: (deviceIndex: number, featureIndex: number, value: number) => void;
|
||||
setError: (message: string | null) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useButtplugStore = create<ButtplugStoreState>((set) => ({
|
||||
connected: false,
|
||||
scanning: false,
|
||||
devices: {},
|
||||
actuatorValues: {},
|
||||
error: null,
|
||||
setConnected: (connected) => set({ connected }),
|
||||
setScanning: (scanning) => set({ scanning }),
|
||||
upsertDevice: (device) => set((s) => ({ devices: { ...s.devices, [device.index]: device } })),
|
||||
removeDevice: (index) =>
|
||||
set((s) => {
|
||||
const devices = { ...s.devices };
|
||||
delete devices[index];
|
||||
return { devices };
|
||||
}),
|
||||
setActuatorValue: (deviceIndex, featureIndex, value) =>
|
||||
set((s) => ({
|
||||
actuatorValues: { ...s.actuatorValues, [actuatorKey(deviceIndex, featureIndex)]: value },
|
||||
})),
|
||||
setError: (message) => set({ error: message }),
|
||||
reset: () => set({ connected: false, scanning: false, devices: {}, actuatorValues: {} }),
|
||||
}));
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Per-actuator leading+trailing debounce so a dragged slider doesn't fire a
|
||||
* device command on every pointermove - devices don't need mouse-move-rate
|
||||
* updates, and most BLE stacks choke on a command flood.
|
||||
*/
|
||||
const THROTTLE_MS = 75;
|
||||
|
||||
interface ThrottleEntry {
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
pendingValue: number | null;
|
||||
pendingDuration: number | undefined;
|
||||
lastSentAt: number;
|
||||
}
|
||||
|
||||
const entries = new Map<string, ThrottleEntry>();
|
||||
|
||||
export function throttledSend(
|
||||
key: string,
|
||||
send: (value: number, durationMs?: number) => void | Promise<void>,
|
||||
value: number,
|
||||
durationMs?: number,
|
||||
): void {
|
||||
let entry = entries.get(key);
|
||||
if (!entry) {
|
||||
entry = { timer: null, pendingValue: null, pendingDuration: undefined, lastSentAt: 0 };
|
||||
entries.set(key, entry);
|
||||
}
|
||||
|
||||
entry.pendingValue = value;
|
||||
entry.pendingDuration = durationMs;
|
||||
|
||||
const flush = (): void => {
|
||||
const v = entry!.pendingValue;
|
||||
entry!.pendingValue = null;
|
||||
entry!.timer = null;
|
||||
entry!.lastSentAt = Date.now();
|
||||
if (v !== null) void send(v, entry!.pendingDuration);
|
||||
};
|
||||
|
||||
const elapsed = Date.now() - entry.lastSentAt;
|
||||
if (elapsed >= THROTTLE_MS) {
|
||||
if (entry.timer) clearTimeout(entry.timer);
|
||||
flush();
|
||||
} else if (!entry.timer) {
|
||||
entry.timer = setTimeout(flush, THROTTLE_MS - elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearThrottle(key: string): void {
|
||||
const entry = entries.get(key);
|
||||
if (entry?.timer) clearTimeout(entry.timer);
|
||||
entries.delete(key);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Normalized, app-level types for the Buttplug integration. Kept separate
|
||||
* from `buttplug`'s own types so the rest of the app (UI, API payloads, DB
|
||||
* rows) never has to import the client library directly.
|
||||
*/
|
||||
|
||||
export type NormalizedOutputType = "vibrate" | "rotate" | "linear";
|
||||
export type CommandType = NormalizedOutputType | "stop";
|
||||
|
||||
export interface ActuatorInfo {
|
||||
featureIndex: number;
|
||||
outputType: NormalizedOutputType;
|
||||
/** True for outputs (e.g. HwPositionWithDuration) that require a move duration. */
|
||||
requiresDuration: boolean;
|
||||
descriptor: string;
|
||||
}
|
||||
|
||||
export interface ConnectedDeviceInfo {
|
||||
index: number;
|
||||
name: string;
|
||||
displayName?: string;
|
||||
actuators: ActuatorInfo[];
|
||||
}
|
||||
|
||||
/** A single dispatched command, timestamped relative to session start. */
|
||||
export interface CommandEvent {
|
||||
tsMs: number;
|
||||
deviceIndex: number;
|
||||
commandType: CommandType;
|
||||
featureIndex: number;
|
||||
value: number;
|
||||
durationMs?: number;
|
||||
}
|
||||
Reference in New Issue
Block a user