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,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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user