2 Commits
Author SHA1 Message Date
valknarandClaude Sonnet 5 f97ed6da04 Fix session timeline query, bump to 0.2.1
CI / Static checks (push) Successful in 1m3s
CI / Build and push image (push) Successful in 1m5s
getSessionTimeline() grouped/ordered by a bare `bucket` identifier,
assuming SQLite would resolve it against the SELECT list's alias -
it doesn't in this generated query, so every /sessions/:id page and
/api/stats/sessions/:id/timeline request failed with "no such column:
bucket". Fixed by reusing the actual bucket expression object in
groupBy/orderBy instead of referencing it by name. Verified against a
seeded session - previously every request, now correct bucketed data.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8WkFF5ppURBAB918593Eb
2026-08-25 17:41:43 +02:00
valknarandClaude Sonnet 5 0bd36b08c9 Upgrade to buttplug v5, add battery level display, bump to 0.2.0
CI / Static checks (push) Successful in 1m0s
CI / Build and push image (push) Successful in 1m3s
buttplug-wasm@3.0.0 still declares buttplug@^4.0.2 as its dependency,
and v5 changed the OutputCmd wire shape (Value: number[] -> number) -
a real protocol difference, not just a type mismatch. Confirmed by
testing against real Lovense hardware that device control still works
in practice, so the bump stands; the risk is documented at the
client.connect() cast in case a future device/build doesn't fare as
well.

Also fixes a Map vs ReadonlyMap mismatch in the version-agnostic
feature-type extraction (v4 returns Map, v5 returns ReadonlyMap) that
surfaced while making this change, and adds a battery indicator to each
connected device's card - reads once on connect, refreshes every 60s,
using device.hasInput("Battery") to detect support.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8WkFF5ppURBAB918593Eb
2026-08-25 17:33:48 +02:00
9 changed files with 112 additions and 17 deletions
+26 -1
View File
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
disconnectAll, disconnectAll,
getBatteryLevel,
getButtplugClientHandle, getButtplugClientHandle,
getDevice, getDevice,
isWebBluetoothSupported, isWebBluetoothSupported,
@@ -42,6 +43,8 @@ export function ButtplugConsole() {
const devices = useButtplugStore((s) => s.devices); const devices = useButtplugStore((s) => s.devices);
const actuatorValues = useButtplugStore((s) => s.actuatorValues); const actuatorValues = useButtplugStore((s) => s.actuatorValues);
const setActuatorValue = useButtplugStore((s) => s.setActuatorValue); const setActuatorValue = useButtplugStore((s) => s.setActuatorValue);
const batteryLevels = useButtplugStore((s) => s.batteryLevels);
const setBatteryLevel = useButtplugStore((s) => s.setBatteryLevel);
const storeError = useButtplugStore((s) => s.error); const storeError = useButtplugStore((s) => s.error);
const [activeSession, setActiveSession] = useState<ActivePlaySession | null>(null); const [activeSession, setActiveSession] = useState<ActivePlaySession | null>(null);
@@ -64,6 +67,21 @@ export function ButtplugConsole() {
}; };
}, []); }, []);
useEffect(() => {
const devicesWithBattery = Object.values(devices).filter((d) => d.hasBattery);
if (devicesWithBattery.length === 0) return;
function refresh() {
for (const device of devicesWithBattery) {
void getBatteryLevel(device.index).then((level) => setBatteryLevel(device.index, level));
}
}
refresh();
const interval = setInterval(refresh, 60_000);
return () => clearInterval(interval);
}, [devices, setBatteryLevel]);
async function ensureRuntime(): Promise<ButtplugRuntime> { async function ensureRuntime(): Promise<ButtplugRuntime> {
if (runtimeRef.current) return runtimeRef.current; if (runtimeRef.current) return runtimeRef.current;
const { runtime } = await getButtplugClientHandle(); const { runtime } = await getButtplugClientHandle();
@@ -153,11 +171,17 @@ export function ButtplugConsole() {
const runtime = await ensureRuntime(); const runtime = await ensureRuntime();
const liveDevice = await getDevice(device.index); const liveDevice = await getDevice(device.index);
const feature = liveDevice && findFeature(liveDevice, featureIndex); const feature = liveDevice && findFeature(liveDevice, featureIndex);
if (!liveDevice || !feature) return; if (!liveDevice || !feature) {
toast.error(`${device.name}: device not found - try reconnecting.`);
return;
}
setTransmittingKey(key); setTransmittingKey(key);
const cmd = buildOutputCommand(runtime, actuator, v); const cmd = buildOutputCommand(runtime, actuator, v);
await feature.runOutput(cmd); await feature.runOutput(cmd);
eventBuffer.record({ deviceIndex: device.index, commandType: actuator.outputType, featureIndex, value: v }); eventBuffer.record({ deviceIndex: device.index, commandType: actuator.outputType, featureIndex, value: v });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
toast.error(`${device.name}: command failed - ${message}`);
} finally { } finally {
setTransmittingKey((k) => (k === key ? null : k)); setTransmittingKey((k) => (k === key ? null : k));
} }
@@ -214,6 +238,7 @@ export function ButtplugConsole() {
<DeviceCard <DeviceCard
key={device.index} key={device.index}
device={device} device={device}
batteryLevel={batteryLevels[device.index] ?? null}
actuatorValues={Object.fromEntries( actuatorValues={Object.fromEntries(
device.actuators.map((a) => [a.featureIndex, actuatorValues[actuatorKey(device.index, a.featureIndex)] ?? 0]), device.actuators.map((a) => [a.featureIndex, actuatorValues[actuatorKey(device.index, a.featureIndex)] ?? 0]),
)} )}
+24 -3
View File
@@ -4,17 +4,30 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ActuatorSlider } from "./ActuatorSlider"; import { ActuatorSlider } from "./ActuatorSlider";
import type { ConnectedDeviceInfo } from "@/lib/buttplug/types"; import type { ConnectedDeviceInfo } from "@/lib/buttplug/types";
import { Battery, BatteryFull, BatteryLow, BatteryMedium, BatteryWarning } from "lucide-react";
interface DeviceCardProps { interface DeviceCardProps {
device: ConnectedDeviceInfo; device: ConnectedDeviceInfo;
batteryLevel: number | null;
actuatorValues: Record<number, number>; actuatorValues: Record<number, number>;
transmittingFeatureIndex: number | null; transmittingFeatureIndex: number | null;
onActuatorChange: (featureIndex: number, value: number) => void; onActuatorChange: (featureIndex: number, value: number) => void;
onStop: () => void; onStop: () => void;
} }
function BatteryIndicator({ level }: { level: number }) {
const Icon = level < 0.15 ? BatteryWarning : level < 0.4 ? BatteryLow : level < 0.8 ? BatteryMedium : BatteryFull;
return (
<span className={`flex items-center gap-1 ${level < 0.15 ? "text-destructive" : "text-muted-foreground"}`}>
<Icon className="size-3.5" />
<span className="bp-readout text-xs">{Math.round(level * 100)}%</span>
</span>
);
}
export function DeviceCard({ export function DeviceCard({
device, device,
batteryLevel,
actuatorValues, actuatorValues,
transmittingFeatureIndex, transmittingFeatureIndex,
onActuatorChange, onActuatorChange,
@@ -24,9 +37,17 @@ export function DeviceCard({
<Card className="bp-glass"> <Card className="bp-glass">
<CardHeader className="flex flex-row items-center justify-between pb-3"> <CardHeader className="flex flex-row items-center justify-between pb-3">
<span className="text-sm font-medium text-foreground">{device.displayName ?? device.name}</span> <span className="text-sm font-medium text-foreground">{device.displayName ?? device.name}</span>
<Button variant="outline" size="sm" onClick={onStop}> <div className="flex items-center gap-3">
Stop {device.hasBattery &&
</Button> (batteryLevel !== null ? (
<BatteryIndicator level={batteryLevel} />
) : (
<Battery className="size-3.5 text-muted-foreground" aria-label="Reading battery level" />
))}
<Button variant="outline" size="sm" onClick={onStop}>
Stop
</Button>
</div>
</CardHeader> </CardHeader>
<div className="bp-hairline mx-4" /> <div className="bp-hairline mx-4" />
<CardContent className="flex flex-col gap-3 pt-3"> <CardContent className="flex flex-col gap-3 pt-3">
+23 -2
View File
@@ -1,4 +1,4 @@
import type { ButtplugClient, ButtplugClientDevice } from "buttplug"; import type { ButtplugClient, ButtplugClientDevice, InputType } from "buttplug";
import { deriveActuators, type ButtplugRuntime } from "./commands"; import { deriveActuators, type ButtplugRuntime } from "./commands";
import { useButtplugStore } from "./store"; import { useButtplugStore } from "./store";
import type { ConnectedDeviceInfo } from "./types"; import type { ConnectedDeviceInfo } from "./types";
@@ -20,6 +20,9 @@ function toDeviceInfo(device: ButtplugClientDevice): ConnectedDeviceInfo {
name: device.name, name: device.name,
displayName: device.displayName, displayName: device.displayName,
actuators: deriveActuators(device), actuators: deriveActuators(device),
// "Battery" is a string enum value - passing the literal avoids needing
// InputType as a runtime import here (see commands.ts for the same trick).
hasBattery: device.hasInput("Battery" as InputType),
}; };
} }
@@ -45,7 +48,14 @@ async function initClient(): Promise<ClientHandle> {
}); });
const connector = new ButtplugWasmClientConnector(); const connector = new ButtplugWasmClientConnector();
await client.connect(connector); // EXPERIMENTAL: buttplug-wasm@3.0.0's embedded server was built against
// buttplug@^4.0.2's wire format; buttplug@5's OutputCmd message shape
// changed (`Value` went from number[] to a bare number), which is a real
// protocol break, not just a type mismatch - the cast below silences that
// signal rather than fixing it. Scanning/pairing may still work since
// DeviceAdded's shape didn't change; device *commands* may be silently
// rejected or mis-parsed server-side. Revert to buttplug@^4.0.2 if so.
await client.connect(connector as unknown as Parameters<typeof client.connect>[0]);
useButtplugStore.getState().setConnected(true); useButtplugStore.getState().setConnected(true);
for (const device of client.devices.values()) { for (const device of client.devices.values()) {
@@ -94,3 +104,14 @@ export async function getDevice(deviceIndex: number): Promise<ButtplugClientDevi
const { client } = await getButtplugClientHandle(); const { client } = await getButtplugClientHandle();
return client.devices.get(deviceIndex); return client.devices.get(deviceIndex);
} }
/** Reads the current battery level (0-1), or null if unsupported/unreadable. */
export async function getBatteryLevel(deviceIndex: number): Promise<number | null> {
const device = await getDevice(deviceIndex);
if (!device || !device.hasInput("Battery" as InputType)) return null;
try {
return await device.battery();
} catch {
return null;
}
}
+4 -2
View File
@@ -2,8 +2,10 @@ import type { ButtplugClientDevice, DeviceOutputCommand, OutputType } from "butt
import type { ActuatorInfo, NormalizedOutputType } from "./types"; import type { ActuatorInfo, NormalizedOutputType } from "./types";
// buttplug@4's barrel doesn't re-export `ButtplugClientDeviceFeature` itself, // buttplug@4's barrel doesn't re-export `ButtplugClientDeviceFeature` itself,
// so its type is recovered from the `features` map it's stored in. // so its type is recovered from the `features` map it's stored in. Matched
type DeviceFeature = ButtplugClientDevice["features"] extends Map<number, infer F> ? F : never; // against ReadonlyMap (not Map) so this works whether `.features` returns a
// mutable Map (v4) or a ReadonlyMap (v5) - Map structurally extends ReadonlyMap.
type DeviceFeature = ButtplugClientDevice["features"] extends ReadonlyMap<number, infer F> ? F : never;
/** /**
* The pieces of the dynamically-imported `buttplug` module namespace this * The pieces of the dynamically-imported `buttplug` module namespace this
+10 -2
View File
@@ -10,12 +10,15 @@ interface ButtplugStoreState {
devices: Record<number, ConnectedDeviceInfo>; devices: Record<number, ConnectedDeviceInfo>;
/** Last-known slider value per `${deviceIndex}:${featureIndex}`, for UI display only. */ /** Last-known slider value per `${deviceIndex}:${featureIndex}`, for UI display only. */
actuatorValues: Record<string, number>; actuatorValues: Record<string, number>;
/** Last-known battery reading (0-1) per device index; absent until first read completes. */
batteryLevels: Record<number, number | null>;
error: string | null; error: string | null;
setConnected: (connected: boolean) => void; setConnected: (connected: boolean) => void;
setScanning: (scanning: boolean) => void; setScanning: (scanning: boolean) => void;
upsertDevice: (device: ConnectedDeviceInfo) => void; upsertDevice: (device: ConnectedDeviceInfo) => void;
removeDevice: (index: number) => void; removeDevice: (index: number) => void;
setActuatorValue: (deviceIndex: number, featureIndex: number, value: number) => void; setActuatorValue: (deviceIndex: number, featureIndex: number, value: number) => void;
setBatteryLevel: (deviceIndex: number, level: number | null) => void;
setError: (message: string | null) => void; setError: (message: string | null) => void;
reset: () => void; reset: () => void;
} }
@@ -25,6 +28,7 @@ export const useButtplugStore = create<ButtplugStoreState>((set) => ({
scanning: false, scanning: false,
devices: {}, devices: {},
actuatorValues: {}, actuatorValues: {},
batteryLevels: {},
error: null, error: null,
setConnected: (connected) => set({ connected }), setConnected: (connected) => set({ connected }),
setScanning: (scanning) => set({ scanning }), setScanning: (scanning) => set({ scanning }),
@@ -33,12 +37,16 @@ export const useButtplugStore = create<ButtplugStoreState>((set) => ({
set((s) => { set((s) => {
const devices = { ...s.devices }; const devices = { ...s.devices };
delete devices[index]; delete devices[index];
return { devices }; const batteryLevels = { ...s.batteryLevels };
delete batteryLevels[index];
return { devices, batteryLevels };
}), }),
setActuatorValue: (deviceIndex, featureIndex, value) => setActuatorValue: (deviceIndex, featureIndex, value) =>
set((s) => ({ set((s) => ({
actuatorValues: { ...s.actuatorValues, [actuatorKey(deviceIndex, featureIndex)]: value }, actuatorValues: { ...s.actuatorValues, [actuatorKey(deviceIndex, featureIndex)]: value },
})), })),
setBatteryLevel: (deviceIndex, level) =>
set((s) => ({ batteryLevels: { ...s.batteryLevels, [deviceIndex]: level } })),
setError: (message) => set({ error: message }), setError: (message) => set({ error: message }),
reset: () => set({ connected: false, scanning: false, devices: {}, actuatorValues: {} }), reset: () => set({ connected: false, scanning: false, devices: {}, actuatorValues: {}, batteryLevels: {} }),
})); }));
+1
View File
@@ -20,6 +20,7 @@ export interface ConnectedDeviceInfo {
name: string; name: string;
displayName?: string; displayName?: string;
actuators: ActuatorInfo[]; actuators: ActuatorInfo[];
hasBattery: boolean;
} }
/** A single dispatched command, timestamped relative to session start. */ /** A single dispatched command, timestamped relative to session start. */
+9 -3
View File
@@ -37,9 +37,15 @@ export async function getSessionsSummary() {
} }
export async function getSessionTimeline(playSessionId: number, bucketMs = 1000) { export async function getSessionTimeline(playSessionId: number, bucketMs = 1000) {
// Reuse the same expression object (not a `sql`bucket`` alias reference)
// in groupBy/orderBy - drizzle doesn't emit a literal `AS bucket` that
// SQLite's GROUP BY/ORDER BY could resolve a bare "bucket" identifier
// against, so referencing it by name causes "no such column: bucket".
const bucket = sql<number>`(${sessionEvents.tsMs} / ${bucketMs}) * ${bucketMs}`;
return db return db
.select({ .select({
bucket: sql<number>`(${sessionEvents.tsMs} / ${bucketMs}) * ${bucketMs}`, bucket,
sessionDeviceId: sessionEvents.sessionDeviceId, sessionDeviceId: sessionEvents.sessionDeviceId,
slotLabel: sessionDevices.slotLabel, slotLabel: sessionDevices.slotLabel,
avgValue: sql<number>`avg(${sessionEvents.value})`, avgValue: sql<number>`avg(${sessionEvents.value})`,
@@ -48,8 +54,8 @@ export async function getSessionTimeline(playSessionId: number, bucketMs = 1000)
.from(sessionEvents) .from(sessionEvents)
.innerJoin(sessionDevices, eq(sessionEvents.sessionDeviceId, sessionDevices.id)) .innerJoin(sessionDevices, eq(sessionEvents.sessionDeviceId, sessionDevices.id))
.where(and(eq(sessionEvents.playSessionId, playSessionId), ne(sessionEvents.commandType, "stop"))) .where(and(eq(sessionEvents.playSessionId, playSessionId), ne(sessionEvents.commandType, "stop")))
.groupBy(sql`bucket`, sessionEvents.sessionDeviceId) .groupBy(bucket, sessionEvents.sessionDeviceId)
.orderBy(sql`bucket`); .orderBy(bucket);
} }
export async function getDeviceUsageStats() { export async function getDeviceUsageStats() {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "sexy", "name": "sexy",
"version": "0.1.1", "version": "0.2.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -12,7 +12,7 @@
}, },
"dependencies": { "dependencies": {
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
"buttplug": "^4.0.2", "buttplug": "^5.0.1",
"buttplug-wasm": "^3.0.0", "buttplug-wasm": "^3.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
+13 -2
View File
@@ -12,8 +12,8 @@ importers:
specifier: ^13.0.3 specifier: ^13.0.3
version: 13.0.3 version: 13.0.3
buttplug: buttplug:
specifier: ^4.0.2 specifier: ^5.0.1
version: 4.0.2 version: 5.0.1
buttplug-wasm: buttplug-wasm:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.0.0 version: 3.0.0
@@ -2292,6 +2292,9 @@ packages:
buttplug@4.0.2: buttplug@4.0.2:
resolution: {integrity: sha512-PciEJEoBkHjeA0UFfdymr5+jHXukzF0T8Pg7TaAxxWFb/0Ynfy1dWaGt1c7O5E1sfJSVyWxrvBQQCSeNWzprbg==} resolution: {integrity: sha512-PciEJEoBkHjeA0UFfdymr5+jHXukzF0T8Pg7TaAxxWFb/0Ynfy1dWaGt1c7O5E1sfJSVyWxrvBQQCSeNWzprbg==}
buttplug@5.0.1:
resolution: {integrity: sha512-m7Qzoi6TEsr0DkqqXc3Gun/wikWiHziQeXu73Oxzd1ETqj2y1P0VdJ9QxAT811bLcTIYvyf0VkuHlXbEGcEXCw==}
bytes@3.1.2: bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -6531,6 +6534,14 @@ snapshots:
- bufferutil - bufferutil
- utf-8-validate - utf-8-validate
buttplug@5.0.1:
dependencies:
eventemitter3: 5.0.4
ws: 8.21.3
transitivePeerDependencies:
- bufferutil
- utf-8-validate
bytes@3.1.2: {} bytes@3.1.2: {}
call-bind-apply-helpers@1.0.2: call-bind-apply-helpers@1.0.2: