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
+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"),
});