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
+37
View File
@@ -0,0 +1,37 @@
import { SignJWT, jwtVerify } from "jose";
import { getEnv } from "@/lib/env";
export const SESSION_COOKIE_NAME = "bp_session";
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // 30 days
function signingKey(): Uint8Array {
return new TextEncoder().encode(getEnv().AUTH_SECRET);
}
export async function createSessionToken(): Promise<string> {
return new SignJWT({})
.setProtectedHeader({ alg: "HS256" })
.setSubject("app")
.setIssuedAt()
.setExpirationTime(`${SESSION_MAX_AGE_SECONDS}s`)
.sign(signingKey());
}
export async function verifySessionToken(token: string | undefined | null): Promise<boolean> {
if (!token) return false;
try {
await jwtVerify(token, signingKey());
return true;
} catch {
return false;
}
}
export const sessionCookieOptions = {
name: SESSION_COOKIE_NAME,
httpOnly: true,
secure: true,
sameSite: "lax" as const,
path: "/",
maxAge: SESSION_MAX_AGE_SECONDS,
};
+14
View File
@@ -0,0 +1,14 @@
import { timingSafeEqual } from "node:crypto";
/**
* Constant-time string comparison. `timingSafeEqual` throws on mismatched
* buffer lengths, which would itself leak length via which branch throws -
* so a length mismatch is treated as a plain (also constant-time-irrelevant,
* since it never reaches the byte comparison) false rather than propagating.
*/
export function timingSafeStringEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}
+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;
}
+43
View File
@@ -0,0 +1,43 @@
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { getEnv } from "@/lib/env";
import * as schema from "./schema";
const globalForDb = globalThis as unknown as { __bpSqlite?: Database.Database };
function openDatabase(): Database.Database {
if (globalForDb.__bpSqlite) return globalForDb.__bpSqlite;
const path = getEnv().DATABASE_PATH;
mkdirSync(dirname(path), { recursive: true });
const instance = new Database(path);
instance.pragma("journal_mode = WAL");
instance.pragma("foreign_keys = ON");
// Reused across hot-reloads in dev so we don't leak file handles / open a
// fresh WAL-mode connection on every request.
globalForDb.__bpSqlite = instance;
return instance;
}
// Lazy by design: `next build` imports this module's whole graph while
// collecting route metadata, even for force-dynamic pages that are never
// actually rendered at build time. Opening the DB (and requiring env vars)
// as a module-level side effect would make `pnpm build` fail in CI, where
// no secrets are configured. The proxy defers both until first real query.
export const sqlite = new Proxy({} as Database.Database, {
get(_target, prop) {
// drizzle-orm's `isConfig()` probes `.constructor.name` on its first
// argument to tell a client instance from a plain config object - that
// introspection must not itself trigger opening the real connection.
if (prop === "constructor") return Database;
const instance = openDatabase();
const value = Reflect.get(instance, prop, instance);
return typeof value === "function" ? value.bind(instance) : value;
},
}) as Database.Database;
export const db = drizzle(sqlite, { schema });
+10
View File
@@ -0,0 +1,10 @@
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { db } from "./client";
import { createLogger } from "@/lib/logger";
const log = createLogger("db:migrate");
export function runMigrations(): void {
migrate(db, { migrationsFolder: "./drizzle" });
log.info("migrations applied");
}
+52
View File
@@ -0,0 +1,52 @@
import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { devices, type DeviceCapabilities } from "@/lib/db/schema";
export async function listDevices() {
return db.select().from(devices).orderBy(devices.lastConnectedAt);
}
export async function getDevice(id: number) {
const [row] = await db.select().from(devices).where(eq(devices.id, id));
return row;
}
/** Upserts by advertised BLE name - the only stable-ish identity Web Bluetooth exposes. */
export async function upsertDeviceByBleName(input: {
bleName: string;
deviceClass?: string | null;
capabilities?: DeviceCapabilities;
}): Promise<typeof devices.$inferSelect> {
const now = Date.now();
const [existing] = await db.select().from(devices).where(eq(devices.bleName, input.bleName));
if (existing) {
const [updated] = await db
.update(devices)
.set({
lastConnectedAt: now,
deviceClass: input.deviceClass ?? existing.deviceClass,
capabilities: input.capabilities ?? existing.capabilities,
})
.where(eq(devices.id, existing.id))
.returning();
return updated;
}
const [created] = await db
.insert(devices)
.values({
bleName: input.bleName,
deviceClass: input.deviceClass,
capabilities: input.capabilities,
createdAt: now,
lastConnectedAt: now,
})
.returning();
return created;
}
export async function renameDevice(id: number, displayName: string) {
const [updated] = await db.update(devices).set({ displayName }).where(eq(devices.id, id)).returning();
return updated;
}
+112
View File
@@ -0,0 +1,112 @@
import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { playSessions, sessionDevices, devices } from "@/lib/db/schema";
import { upsertDeviceByBleName } from "./devices";
import { incrementPlayCount } from "./recordings";
import type { DeviceCapabilities } from "@/lib/db/schema";
export interface StartSessionDeviceInput {
slotLabel: string;
bleName: string;
deviceClass?: string | null;
capabilities?: DeviceCapabilities;
}
export interface StartSessionInput {
kind: "live" | "replay";
replayedRecordingId?: number;
name?: string;
devices: StartSessionDeviceInput[];
}
export async function startPlaySession(input: StartSessionInput) {
const now = Date.now();
const [session] = await db
.insert(playSessions)
.values({
kind: input.kind,
replayedRecordingId: input.replayedRecordingId,
name: input.name,
status: "active",
startedAt: now,
})
.returning();
const sessionDeviceRows = [];
for (const d of input.devices) {
const device = await upsertDeviceByBleName({
bleName: d.bleName,
deviceClass: d.deviceClass,
capabilities: d.capabilities,
});
const [sessionDevice] = await db
.insert(sessionDevices)
.values({
playSessionId: session.id,
deviceId: device.id,
slotLabel: d.slotLabel,
connectedAt: now,
})
.returning();
sessionDeviceRows.push(sessionDevice);
}
if (input.kind === "replay" && input.replayedRecordingId) {
await incrementPlayCount(input.replayedRecordingId, now);
}
return { session, sessionDevices: sessionDeviceRows };
}
export async function endPlaySession(
id: number,
input: { status: "completed" | "aborted" },
) {
const [existing] = await db.select().from(playSessions).where(eq(playSessions.id, id));
if (!existing) return undefined;
const endedAt = Date.now();
const durationMs = endedAt - existing.startedAt;
await db
.update(sessionDevices)
.set({ disconnectedAt: endedAt })
.where(eq(sessionDevices.playSessionId, id));
const [updated] = await db
.update(playSessions)
.set({ endedAt, durationMs, status: input.status })
.where(eq(playSessions.id, id))
.returning();
return updated;
}
export async function listPlaySessions() {
return db.select().from(playSessions).orderBy(playSessions.startedAt);
}
export async function getPlaySessionDetail(id: number) {
const [session] = await db.select().from(playSessions).where(eq(playSessions.id, id));
if (!session) return undefined;
const sessionDeviceRows = await db
.select({
id: sessionDevices.id,
slotLabel: sessionDevices.slotLabel,
connectedAt: sessionDevices.connectedAt,
disconnectedAt: sessionDevices.disconnectedAt,
deviceId: devices.id,
deviceDisplayName: devices.displayName,
deviceBleName: devices.bleName,
})
.from(sessionDevices)
.innerJoin(devices, eq(sessionDevices.deviceId, devices.id))
.where(eq(sessionDevices.playSessionId, id));
return { session, devices: sessionDeviceRows };
}
export async function deletePlaySession(id: number) {
await db.delete(playSessions).where(eq(playSessions.id, id));
}
+86
View File
@@ -0,0 +1,86 @@
import { desc, eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { recordings, sessionDevices, devices, playSessions, type RecordingDeviceSlot } from "@/lib/db/schema";
import { getEventsForSession } from "./session-events";
/**
* A recording is a thin pointer over an already-captured play_session, not a
* duplicated event pipeline: every live command is always persisted to
* session_events (stats need it regardless of whether the user saves a
* recording), so "saving a recording" just snapshots the session's device
* slots and points a new row at it. This keeps the replay-loader query and
* the stats-timeline query reading the exact same rows, with zero risk of
* live/recorded data drifting apart.
*/
export async function createRecording(input: {
sourcePlaySessionId: number;
name: string;
description?: string;
}) {
const [session] = await db.select().from(playSessions).where(eq(playSessions.id, input.sourcePlaySessionId));
if (!session) throw new Error("source play session not found");
const sessionDeviceRows = await db
.select({
sessionDeviceId: sessionDevices.id,
slotLabel: sessionDevices.slotLabel,
bleName: devices.bleName,
deviceClass: devices.deviceClass,
capabilities: devices.capabilities,
})
.from(sessionDevices)
.innerJoin(devices, eq(sessionDevices.deviceId, devices.id))
.where(eq(sessionDevices.playSessionId, input.sourcePlaySessionId));
const deviceSlots: RecordingDeviceSlot[] = sessionDeviceRows.map((row) => ({
slotLabel: row.slotLabel,
recordedBleName: row.bleName,
deviceClass: row.deviceClass,
capabilities: row.capabilities,
sourceSessionDeviceId: row.sessionDeviceId,
}));
const [recording] = await db
.insert(recordings)
.values({
sourcePlaySessionId: input.sourcePlaySessionId,
name: input.name,
description: input.description,
createdAt: Date.now(),
durationMs: session.durationMs ?? 0,
deviceSlots,
playCount: 0,
})
.returning();
return recording;
}
export async function listRecordings() {
return db.select().from(recordings).orderBy(desc(recordings.createdAt));
}
export async function getRecording(id: number) {
const [recording] = await db.select().from(recordings).where(eq(recordings.id, id));
if (!recording) return undefined;
const events = await getEventsForSession(recording.sourcePlaySessionId);
return { recording, events };
}
export async function renameRecording(id: number, input: { name?: string; description?: string }) {
const [updated] = await db.update(recordings).set(input).where(eq(recordings.id, id)).returning();
return updated;
}
export async function deleteRecording(id: number) {
await db.delete(recordings).where(eq(recordings.id, id));
}
export async function incrementPlayCount(id: number, playedAt: number): Promise<void> {
const [existing] = await db.select().from(recordings).where(eq(recordings.id, id));
if (!existing) return;
await db
.update(recordings)
.set({ playCount: existing.playCount + 1, lastPlayedAt: playedAt })
.where(eq(recordings.id, id));
}
+42
View File
@@ -0,0 +1,42 @@
import { asc, eq } from "drizzle-orm";
import { db, sqlite } from "@/lib/db/client";
import { sessionEvents } from "@/lib/db/schema";
export interface IncomingEvent {
sessionDeviceId: number;
tsMs: number;
commandType: "vibrate" | "rotate" | "linear" | "stop";
featureIndex: number;
value: number;
durationMs?: number;
}
export async function insertEvents(playSessionId: number, events: IncomingEvent[]): Promise<void> {
if (events.length === 0) return;
const insertMany = sqlite.transaction((rows: IncomingEvent[]) => {
for (const row of rows) {
db.insert(sessionEvents)
.values({
playSessionId,
sessionDeviceId: row.sessionDeviceId,
tsMs: row.tsMs,
commandType: row.commandType,
featureIndex: row.featureIndex,
value: row.value,
durationMs: row.durationMs,
})
.run();
}
});
insertMany(events);
}
export async function getEventsForSession(playSessionId: number) {
return db
.select()
.from(sessionEvents)
.where(eq(sessionEvents.playSessionId, playSessionId))
.orderBy(asc(sessionEvents.tsMs));
}
+103
View File
@@ -0,0 +1,103 @@
import { sql, eq, and, ne } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { playSessions, sessionDevices, sessionEvents, devices, recordings } from "@/lib/db/schema";
export async function getSessionsSummary() {
const [totals] = await db
.select({
count: sql<number>`count(*)`,
totalDurationMs: sql<number>`coalesce(sum(${playSessions.durationMs}), 0)`,
avgDurationMs: sql<number>`coalesce(avg(${playSessions.durationMs}), 0)`,
})
.from(playSessions)
.where(eq(playSessions.status, "completed"));
const byKind = await db
.select({
kind: playSessions.kind,
count: sql<number>`count(*)`,
totalDurationMs: sql<number>`coalesce(sum(${playSessions.durationMs}), 0)`,
})
.from(playSessions)
.where(eq(playSessions.status, "completed"))
.groupBy(playSessions.kind);
const durationPerDevice = await db
.select({
deviceId: devices.id,
displayName: devices.displayName,
bleName: devices.bleName,
totalActiveMs: sql<number>`coalesce(sum(coalesce(${sessionDevices.disconnectedAt}, ${sessionDevices.connectedAt}) - ${sessionDevices.connectedAt}), 0)`,
})
.from(sessionDevices)
.innerJoin(devices, eq(sessionDevices.deviceId, devices.id))
.groupBy(devices.id);
return { ...totals, byKind, durationPerDevice };
}
export async function getSessionTimeline(playSessionId: number, bucketMs = 1000) {
return db
.select({
bucket: sql<number>`(${sessionEvents.tsMs} / ${bucketMs}) * ${bucketMs}`,
sessionDeviceId: sessionEvents.sessionDeviceId,
slotLabel: sessionDevices.slotLabel,
avgValue: sql<number>`avg(${sessionEvents.value})`,
maxValue: sql<number>`max(${sessionEvents.value})`,
})
.from(sessionEvents)
.innerJoin(sessionDevices, eq(sessionEvents.sessionDeviceId, sessionDevices.id))
.where(and(eq(sessionEvents.playSessionId, playSessionId), ne(sessionEvents.commandType, "stop")))
.groupBy(sql`bucket`, sessionEvents.sessionDeviceId)
.orderBy(sql`bucket`);
}
export async function getDeviceUsageStats() {
return db
.select({
deviceId: devices.id,
displayName: devices.displayName,
bleName: devices.bleName,
sessionCount: sql<number>`count(distinct ${sessionDevices.playSessionId})`,
totalActiveMs: sql<number>`coalesce(sum(coalesce(${sessionDevices.disconnectedAt}, ${sessionDevices.connectedAt}) - ${sessionDevices.connectedAt}), 0)`,
lastUsedAt: sql<number | null>`max(${sessionDevices.connectedAt})`,
})
.from(devices)
.leftJoin(sessionDevices, eq(sessionDevices.deviceId, devices.id))
.groupBy(devices.id);
}
export async function getDeviceCommandCounts() {
return db
.select({
deviceId: devices.id,
commandCount: sql<number>`count(${sessionEvents.id})`,
})
.from(devices)
.leftJoin(sessionDevices, eq(sessionDevices.deviceId, devices.id))
.leftJoin(sessionEvents, eq(sessionEvents.sessionDeviceId, sessionDevices.id))
.groupBy(devices.id);
}
export async function getRecordingLibraryStats() {
const [totals] = await db
.select({
count: sql<number>`count(*)`,
avgDurationMs: sql<number>`coalesce(avg(${recordings.durationMs}), 0)`,
totalPlayCount: sql<number>`coalesce(sum(${recordings.playCount}), 0)`,
})
.from(recordings);
const list = await db
.select({
id: recordings.id,
name: recordings.name,
durationMs: recordings.durationMs,
playCount: recordings.playCount,
lastPlayedAt: recordings.lastPlayedAt,
})
.from(recordings)
.orderBy(sql`${recordings.playCount} desc`);
return { ...totals, list };
}
+104
View File
@@ -0,0 +1,104 @@
import { sqliteTable, text, integer, real, index, type AnySQLiteColumn } from "drizzle-orm/sqlite-core";
export interface DeviceCapabilities {
/** Buttplug OutputType strings (Vibrate/Rotate/Position/...) present on any feature. */
outputs: string[];
featureCount: number;
}
// No `users` table - the app is gated by a single ACCESS_PASSWORD env var
// (see lib/auth/session.ts), not a persisted account.
export const devices = sqliteTable("devices", {
id: integer("id").primaryKey({ autoIncrement: true }),
displayName: text("display_name"),
// Web Bluetooth exposes no stable hardware id, only the advertised name -
// this is the best identity key available across browser sessions.
bleName: text("ble_name").notNull(),
deviceClass: text("device_class"),
capabilities: text("capabilities", { mode: "json" }).$type<DeviceCapabilities>(),
createdAt: integer("created_at").notNull(),
lastConnectedAt: integer("last_connected_at"),
});
export const playSessions = sqliteTable("play_sessions", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name"),
kind: text("kind", { enum: ["live", "replay"] }).notNull(),
replayedRecordingId: integer("replayed_recording_id").references((): AnySQLiteColumn => recordings.id, {
onDelete: "set null",
}),
status: text("status", { enum: ["active", "completed", "aborted"] }).notNull().default("active"),
startedAt: integer("started_at").notNull(),
endedAt: integer("ended_at"),
durationMs: integer("duration_ms"),
notes: text("notes"),
});
export const sessionDevices = sqliteTable(
"session_devices",
{
id: integer("id").primaryKey({ autoIncrement: true }),
playSessionId: integer("play_session_id")
.notNull()
.references(() => playSessions.id, { onDelete: "cascade" }),
deviceId: integer("device_id")
.notNull()
.references(() => devices.id, { onDelete: "restrict" }),
slotLabel: text("slot_label").notNull(),
connectedAt: integer("connected_at").notNull(),
disconnectedAt: integer("disconnected_at"),
},
(table) => [index("session_devices_play_session_id_idx").on(table.playSessionId)],
);
export const sessionEvents = sqliteTable(
"session_events",
{
id: integer("id").primaryKey({ autoIncrement: true }),
playSessionId: integer("play_session_id")
.notNull()
.references(() => playSessions.id, { onDelete: "cascade" }),
sessionDeviceId: integer("session_device_id")
.notNull()
.references(() => sessionDevices.id, { onDelete: "cascade" }),
// Relative to the session's startedAt, not wall-clock - keeps timeline
// math and replay scheduling drift-free and reproducible.
tsMs: integer("ts_ms").notNull(),
commandType: text("command_type", { enum: ["vibrate", "rotate", "linear", "stop"] }).notNull(),
featureIndex: integer("feature_index").notNull(),
value: real("value").notNull(),
durationMs: integer("duration_ms"),
rawPayload: text("raw_payload", { mode: "json" }),
},
(table) => [
index("session_events_session_ts_idx").on(table.playSessionId, table.tsMs),
index("session_events_session_device_idx").on(table.sessionDeviceId),
],
);
export interface RecordingDeviceSlot {
slotLabel: string;
recordedBleName: string;
deviceClass: string | null;
capabilities: DeviceCapabilities | null;
/** The original session_devices.id this slot was captured from - lets the
* replay UI map a chosen live device back to this slot's session_events. */
sourceSessionDeviceId: number;
}
export const recordings = sqliteTable("recordings", {
id: integer("id").primaryKey({ autoIncrement: true }),
// A recording is a thin pointer over an already-captured play_session, not
// a duplicated event pipeline - see lib/db/queries/recordings.ts for why.
sourcePlaySessionId: integer("source_play_session_id")
.notNull()
.references(() => playSessions.id, { onDelete: "restrict" }),
name: text("name").notNull(),
description: text("description"),
createdAt: integer("created_at").notNull(),
durationMs: integer("duration_ms").notNull(),
deviceSlots: text("device_slots", { mode: "json" }).$type<RecordingDeviceSlot[]>().notNull(),
playCount: integer("play_count").notNull().default(0),
lastPlayedAt: integer("last_played_at"),
});
+25
View File
@@ -0,0 +1,25 @@
import { z } from "zod";
// ACCESS_PASSWORD gates the login form; AUTH_SECRET signs the session JWT.
// These are deliberately separate - reusing the login password as the
// signing key would let a JWT-signing weakness leak the login secret itself.
const envSchema = z.object({
ACCESS_PASSWORD: z.string().min(1, "ACCESS_PASSWORD must be set"),
AUTH_SECRET: z.string().min(16, "AUTH_SECRET must be at least 16 characters"),
DATABASE_PATH: z.string().min(1).default("./data/app.db"),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).optional(),
});
export type Env = z.infer<typeof envSchema>;
let cached: Env | undefined;
export function getEnv(): Env {
if (cached) return cached;
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
throw new Error(`Invalid environment configuration: ${parsed.error.message}`);
}
cached = parsed.data;
return cached;
}
+47
View File
@@ -0,0 +1,47 @@
type LogLevel = "debug" | "info" | "warn" | "error";
type LogFields = Record<string, unknown>;
const LEVEL_ORDER: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };
function resolveMinLevel(): LogLevel {
const configured = process.env.LOG_LEVEL?.toLowerCase();
if (configured === "debug" || configured === "info" || configured === "warn" || configured === "error") {
return configured;
}
return process.env.NODE_ENV === "production" ? "info" : "debug";
}
const MIN_LEVEL = resolveMinLevel();
function formatFields(fields?: LogFields): string {
if (!fields) return "";
const parts = Object.entries(fields)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => `${key}=${typeof value === "string" ? value : JSON.stringify(value)}`);
return parts.length > 0 ? ` ${parts.join(" ")}` : "";
}
function write(level: LogLevel, scope: string, message: string, fields?: LogFields): void {
if (LEVEL_ORDER[level] < LEVEL_ORDER[MIN_LEVEL]) return;
const line = `${new Date().toISOString()} ${level.toUpperCase().padEnd(5)} [${scope}] ${message}${formatFields(fields)}`;
if (level === "error") console.error(line);
else if (level === "warn") console.warn(line);
else console.log(line);
}
export interface Logger {
debug(message: string, fields?: LogFields): void;
info(message: string, fields?: LogFields): void;
warn(message: string, fields?: LogFields): void;
error(message: string, fields?: LogFields): void;
}
/** Scoped logger. `scope` is the bracketed tag, e.g. createLogger("api") -> "[api]". */
export function createLogger(scope: string): Logger {
return {
debug: (message, fields) => write("debug", scope, message, fields),
info: (message, fields) => write("info", scope, message, fields),
warn: (message, fields) => write("warn", scope, message, fields),
error: (message, fields) => write("error", scope, message, fields),
};
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}