Recordings were just a thin named pointer over an already-captured session's events, so the whole separate feature (recordings table, API routes, pages, UI) is gone: any completed session can now be named and replayed directly. Replaying no longer creates a session or duplicates events of its own - it just bumps the source session's playCount/lastPlayedAt. Also renames play_sessions/playSession(s) to sessions/session(s) throughout the schema, queries, API routes, and UI for consistency, and updates the README to match the new flow. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
155 lines
8.6 KiB
Markdown
155 lines
8.6 KiB
Markdown
# SEXY
|
|
|
|
A self-hosted web console for scanning, connecting to, and controlling Bluetooth LE sex toys over the
|
|
[Buttplug](https://buttplug.io) 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.
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
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](./LICENSE).
|