valknarandClaude Sonnet 5 27d0db0f72
CI / Build and push image (push) Successful in 1m11s
CI / Static checks (push) Successful in 1m3s
Bump to 0.8.2
Devices table's editable name field now matches the session
title/description underline style app-wide (hover/focus turn primary,
primary caret, default text color), and Devices moved before Stats in
the nav order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-31 19:43:53 +02:00
2026-08-31 19:43:53 +02:00

SEXY

A self-hosted web console for scanning, connecting to, and controlling Bluetooth LE sex toys over the Buttplug protocol - the same open-source protocol behind Intiface Central and most other legitimate Bluetooth toy-control apps. Every live control session is recorded automatically, and any completed session can be replayed later directly, against a (possibly different) set of connected devices - no separate "save as recording" step. Track usage in a stats dashboard. All gated behind a single shared secret, all data kept in a local SQLite file.

Architecture, in words

Web Bluetooth (navigator.bluetooth) only exists inside a browser, in a secure context (HTTPS or localhost), and requires a user gesture to start a device scan. A Docker container has no access to host Bluetooth hardware at all. So:

  • The actual Buttplug client and device connector run entirely in your browser, using buttplug-wasm as an embedded connector - a full Buttplug server compiled to WebAssembly, talking to devices directly over Web Bluetooth. There's no separate Intiface Engine or Buttplug Server process to run.
  • The Next.js server is never in the real-time device-control loop. It only handles the login gate, persists session telemetry to SQLite, serves the stats aggregation endpoints, and serves a session's events back for replay - all over plain HTTPS/JSON, batched every few seconds, not per slider tick.
 Browser                              Server (Next.js)
 ┌─────────────────────────┐          ┌───────────────────────┐
 │ buttplug-wasm (embedded)│          │ auth / API routes      │
 │        ↕ Web Bluetooth  │  HTTPS   │ sessions               │
 │  BLE devices            │ ───────► │ stats aggregation      │
 │  live control / replay   │  (batch) │ SQLite (better-sqlite3)│
 └─────────────────────────┘          └───────────────────────┘

Browser support (read this first)

  • Works: Chrome or Edge on desktop, Chrome on Android, over HTTPS (or localhost in development).
  • Does not work, at all, on any browser: iOS and macOS Safari. Every browser on iOS is WebKit under the hood by Apple's policy, and WebKit has no Web Bluetooth implementation. This is a platform limitation, not a bug in this app - there's no workaround short of Apple shipping Web Bluetooth support.
  • The app itself detects 'bluetooth' in navigator and shows a clear message instead of a broken UI on unsupported browsers.

Features

  • Scan for and connect to multiple Bluetooth LE devices at once
  • Live per-actuator control (vibrate / rotate / linear) with a responsive slider UI
  • Start/end sessions; name one so it's easy to find later
  • Replay any completed session directly against a different set of connected devices, with a device-remap step (BLE exposes no stable device id across sessions, so the session's devices are matched to live ones by name, with manual override when needed) - replaying doesn't create a new session or duplicate events, it just bumps the source session's play count
  • Stats dashboard: session summaries, per-session intensity timelines, per-device usage, replay counts
  • Single shared-secret login, no user accounts
  • SQLite storage, Docker Compose deployment, Gitea Actions CI

Tech stack

Next.js (App Router) · Tailwind CSS v4 · shadcn/ui · buttplug + buttplug-wasm · Drizzle ORM + better-sqlite3 · zustand · jose · pnpm

Local development

Requires Node 22+, pnpm, and a Chromium-based browser (Chrome or Edge) for actually exercising the Bluetooth parts - http://localhost:3000 counts as a secure context, so no HTTPS setup is needed in dev.

pnpm install
cp .env.example .env        # fill in ACCESS_PASSWORD and AUTH_SECRET
pnpm db:generate             # only needed after changing lib/db/schema.ts
pnpm dev

Migrations run automatically against DATABASE_PATH on server boot (see instrumentation.ts) - no manual migrate step needed for a normal pnpm dev / pnpm start. pnpm db:migrate exists for applying migrations out-of-band if you ever need to.

Environment variables

Variable Required Description
ACCESS_PASSWORD yes The shared secret typed into the login form. Compared in constant time.
AUTH_SECRET yes Signing key for the session JWT. Must differ from ACCESS_PASSWORD - keep both secret; a leaked signing key lets an attacker forge sessions.
DATABASE_PATH no Path to the SQLite file. Default ./data/app.db.
LOG_LEVEL no debug | info | warn | error. Defaults to debug in dev, info in production.

Compose-only (read by docker-compose.yml for ${...} substitution, not by the app itself): TRAEFIK_HOST, NETWORK_NAME.

Database

Schema lives in lib/db/schema.ts, migrations in drizzle/ (generate new ones with pnpm db:generate after a schema change, and commit the generated SQL). Every live command is always persisted to session_events for stats, regardless of whether the session is ever replayed - replaying reads those same events back directly, it doesn't create a new session or duplicate them, it just bumps the source session's playCount/lastPlayedAt.

Renaming a table or column makes drizzle-kit generate prompt interactively ("is this a rename?"), which needs a real TTY and won't work non-interactively (e.g. from an agent or CI). In that case, hand-author the migration SQL and its drizzle/meta/*_snapshot.json instead, then confirm pnpm db:generate reports no further changes against the updated schema.

Docker deployment

mkdir -p data && chown 10001:10001 data   # match the container's non-root uid/gid
cp .env.example .env                       # fill in real secrets + TRAEFIK_HOST/NETWORK_NAME
docker compose up -d --build

HTTPS in front of this container is not optional. Web Bluetooth refuses to run outside a secure context, so if the app isn't served over HTTPS (via Traefik, as the included labels assume, or another reverse proxy terminating TLS), the Control and Replay pages simply won't be able to use Bluetooth at all.

The SQLite file lives at ./data/app.db on the host (bind-mounted, read-write) - everything else in the container filesystem is read-only. Back up by copying that one file; there's no other state.

CI/CD

.gitea/workflows/ci.yml runs lint + build on every push/PR, and on a pushed tag also builds and pushes the Docker image to this repo's path on the dev.pivoine.art Gitea registry, authenticating with a PACKAGE_TOKEN repo secret.

Usage guide

  1. Log in with the shared secret.
  2. On Control, scan for devices and connect. Move the sliders to control actuators live.
  3. Click Start session before you begin; End session when done, and optionally name it so it's easy to find later.
  4. On Sessions, click replay on any completed session, connect the devices you want to use, match them to the session's original device slots, and play back.
  5. Stats shows aggregated usage across sessions and devices.

Security & privacy notes

  • This is a single shared-secret gate, not multi-user authentication - anyone with the secret has full access. Treat it like a shared house key; restricting network access (VPN, IP allowlist, Traefik middleware) in addition to the app secret is recommended for anything beyond trusted personal use.
  • All data (session history, device names) stays in your own SQLite file. Nothing is sent anywhere except directly between your browser and the devices it connects to, and between your browser and this server.
  • There's no login rate-limiting in this version - acceptable behind a private deployment, but worth knowing.

Known limitations

  • iOS/Safari cannot use Web Bluetooth at all (see above) - not fixable from this app.
  • BLE device identity isn't stable across browser sessions; the device-remap step mitigates but can't fully solve matching two identically-named devices.
  • Concurrent BLE connection limits are set by the OS/Bluetooth adapter, not this app.
  • Replay timing uses performance.now()-relative scheduling to avoid wall-clock drift, but individual command dispatch still has a few-to-tens-of-milliseconds of jitter.

License

MIT, see LICENSE.

S
Description
A self-hosted web console for scanning, connecting to, and controlling Bluetooth LE sex toys over the Buttplug protocol.
Readme MIT
361 KiB
Languages
TypeScript 94.4%
CSS 4.3%
Dockerfile 0.9%
JavaScript 0.4%