Initial implementation of Bluetooth toy control app
CI / Static checks (push) Successful in 1m27s
CI / Build and push image (push) Skipped

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:
2026-08-25 07:51:38 +02:00
co-authored by Claude Sonnet 5
commit 1119c8eea0
112 changed files with 15522 additions and 0 deletions
+96
View File
@@ -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);
}
+92
View File
@@ -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);
}
}
+46
View File
@@ -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,
};
});
}
+114
View File
@@ -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();
+136
View File
@@ -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);
}
}
+44
View File
@@ -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: {} }),
}));
+53
View File
@@ -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);
}
+33
View File
@@ -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;
}