feat: upgrade buttplug package to protocol v4 and WASM v10
All checks were successful
Build and Push Docker Image to Gitea / build-and-push (push) Successful in 7m30s

Upgrade the buttplug TypeScript client from class-based v3 protocol to
interface-based v4 protocol, and the Rust/WASM server from the monolithic
buttplug 9.0.9 crate to the split buttplug_core/buttplug_server/
buttplug_server_device_config 10.0.0 crates.

TypeScript changes:
- Messages are now plain interfaces with msgId()/setMsgId() helpers
- ActuatorType → OutputType, SensorType → InputType
- ScalarCmd/RotateCmd/LinearCmd → OutputCmd, SensorReadCmd → InputCmd
- Client.ts → ButtplugClient.ts, new DeviceCommand/DeviceFeature files
- Devices getter returns Map instead of array
- Removed class-transformer/reflect-metadata dependencies

Rust/WASM changes:
- Split imports across buttplug_core, buttplug_server, buttplug_server_device_config
- Removed ButtplugServerDowngradeWrapper (use ButtplugServer directly)
- Replaced ButtplugFuture/ButtplugFutureStateShared with tokio::sync::oneshot
- Updated Hardware::new for new 6-arg signature
- Uses git fork (valknarthing/buttplug) to fix missing wasm deps in buttplug_core

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-06 14:46:47 +01:00
parent fed2dd65e5
commit 6ea4ed1933
31 changed files with 1763 additions and 2441 deletions

View File

@@ -5,7 +5,6 @@ import { Label } from "$lib/components/ui/label";
import { Card, CardContent, CardHeader } from "$lib/components/ui/card";
import type { BluetoothDevice } from "$lib/types";
import { _ } from "svelte-i18n";
import { ActuatorType } from "@sexy.pivoine.art/buttplug";
interface Props {
device: BluetoothDevice;
@@ -16,7 +15,7 @@ interface Props {
let { device, onChange, onStop }: Props = $props();
function getBatteryColor(level: number) {
if (!device.info.hasBattery) {
if (!device.hasBattery) {
return "text-gray-400";
}
if (level > 60) return "text-green-400";
@@ -25,7 +24,7 @@ function getBatteryColor(level: number) {
}
function getBatteryBgColor(level: number) {
if (!device.info.hasBattery) {
if (!device.hasBattery) {
return "bg-gray-400/20";
}
if (level > 60) return "bg-green-400/20";
@@ -34,17 +33,13 @@ function getBatteryBgColor(level: number) {
}
function getScalarAnimations() {
const cmds: [{ ActuatorType: typeof ActuatorType }] =
device.info.messageAttributes.ScalarCmd;
return cmds
.filter((_, i: number) => !!device.actuatorValues[i])
.map(({ ActuatorType }) => `animate-${ActuatorType.toLowerCase()}`);
return device.actuators
.filter((a) => a.value > 0)
.map((a) => `animate-${a.outputType.toLowerCase()}`);
}
function isActive() {
const cmds: [{ ActuatorType: typeof ActuatorType }] =
device.info.messageAttributes.ScalarCmd;
return cmds.some((_, i: number) => !!device.actuatorValues[i]);
return device.actuators.some((a) => a.value > 0);
}
</script>
@@ -119,7 +114,7 @@ function isActive() {
></span>
<span class="text-sm text-muted-foreground">{$_("device_card.battery")}</span>
</div>
{#if device.info.hasBattery}
{#if device.hasBattery}
<span class="text-sm font-medium {getBatteryColor(device.batteryLevel)}">
{device.batteryLevel}%
</span>
@@ -144,19 +139,19 @@ function isActive() {
</div> -->
<!-- Action Button -->
{#each device.info.messageAttributes.ScalarCmd as scalarCmd}
{#each device.actuators as actuator, idx}
<div class="space-y-2">
<Label for={`device-${device.info.index}-${scalarCmd.Index}`}
<Label for={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
>{$_(
`device_card.actuator_types.${scalarCmd.ActuatorType.toLowerCase()}`,
`device_card.actuator_types.${actuator.outputType.toLowerCase()}`,
)}</Label
>
<Slider
id={`device-${device.info.index}-${scalarCmd.Index}`}
id={`device-${device.info.index}-${actuator.featureIndex}-${actuator.outputType}`}
type="single"
value={device.actuatorValues[scalarCmd.Index]}
onValueChange={(val) => onChange(scalarCmd.Index, val)}
max={scalarCmd.StepCount}
value={actuator.value}
onValueChange={(val) => onChange(idx, val)}
max={actuator.maxSteps}
step={1}
/>
</div>

View File

@@ -108,12 +108,20 @@ export interface Stats {
viewers_count: number;
}
export interface DeviceActuator {
featureIndex: number;
outputType: string;
maxSteps: number;
descriptor: string;
value: number;
}
export interface BluetoothDevice {
id: string;
name: string;
actuatorValues: number[];
sensorValues: number[];
actuators: DeviceActuator[];
batteryLevel: number;
hasBattery: boolean;
isConnected: boolean;
lastSeen: Date;
info: ButtplugClientDevice;

View File

@@ -3,18 +3,13 @@ import { _ } from "svelte-i18n";
import Meta from "$lib/components/meta/meta.svelte";
import {
ButtplugClient,
ButtplugMessage,
ButtplugWasmClientConnector,
DeviceList,
SensorReadCmd,
StopDeviceCmd,
SensorReading,
ScalarCmd,
ScalarSubcommand,
ButtplugDeviceMessage,
ButtplugClientDevice,
SensorType,
OutputType,
InputType,
DeviceOutputValueConstructor,
} from "@sexy.pivoine.art/buttplug";
import type { ButtplugMessage } from "@sexy.pivoine.art/buttplug";
import Button from "$lib/components/ui/button/button.svelte";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
@@ -50,11 +45,12 @@ async function init() {
// await ButtplugWasmClientConnector.activateLogging("info");
await client.connect(connector);
client.on("deviceadded", onDeviceAdded);
client.on("deviceremoved", (msg: ButtplugDeviceMessage) =>
devices.splice(msg.DeviceIndex, 1),
);
client.on("deviceremoved", (dev: ButtplugClientDevice) => {
const idx = devices.findIndex((d) => d.info.index === dev.index);
if (idx !== -1) devices.splice(idx, 1);
});
client.on("scanningfinished", () => (scanning = false));
connector.on("message", handleMessages);
client.on("inputreading", handleInputReading);
connected = client.connected;
}
@@ -63,65 +59,48 @@ async function startScanning() {
scanning = true;
}
async function onDeviceAdded(
msg: ButtplugDeviceMessage,
dev: ButtplugClientDevice,
) {
async function onDeviceAdded(dev: ButtplugClientDevice) {
const device = convertDevice(dev);
devices.push(device);
const cmds = device.info.messageAttributes.SensorReadCmd;
cmds?.forEach(async (cmd) => {
await client.sendDeviceMessage(
{ index: device.info.index },
new SensorReadCmd(device.info.index, cmd.Index, cmd.SensorType),
);
});
}
async function handleMessages(messages: ButtplugMessage[]) {
messages.forEach(async (msg) => {
await handleMessage(msg);
});
}
async function handleMessage(msg: ButtplugMessage) {
if (msg instanceof SensorReading) {
const device = devices[msg.DeviceIndex];
if (msg.SensorType === SensorType.Battery) {
device.batteryLevel = msg.Data[0];
// Try to read battery level
if (device.hasBattery) {
try {
device.batteryLevel = await dev.battery();
} catch (e) {
console.warn(`Failed to read battery for ${dev.name}:`, e);
}
device.sensorValues[msg.Index] = msg.Data[0];
device.lastSeen = new Date();
} else if (msg instanceof DeviceList) {
devices = client.devices.map(convertDevice);
}
}
function handleInputReading(msg: ButtplugMessage) {
if (msg.InputReading === undefined) return;
const reading = msg.InputReading;
const device = devices.find((d) => d.info.index === reading.DeviceIndex);
if (!device) return;
if (reading.Reading[InputType.Battery] !== undefined) {
device.batteryLevel = reading.Reading[InputType.Battery].Value;
}
device.lastSeen = new Date();
}
async function handleChange(
device: BluetoothDevice,
scalarIndex: number,
actuatorIdx: number,
value: number,
) {
const vibrateCmd = device.info.messageAttributes.ScalarCmd[scalarIndex];
await client.sendDeviceMessage(
{ index: device.info.index },
new ScalarCmd(
[
new ScalarSubcommand(
vibrateCmd.Index,
(device.actuatorValues[scalarIndex] = value),
vibrateCmd.ActuatorType,
),
],
device.info.index,
),
);
const actuator = device.actuators[actuatorIdx];
const feature = device.info.features.get(actuator.featureIndex);
if (!feature) return;
actuator.value = value;
const outputType = actuator.outputType as OutputType;
await feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(value));
// Capture event if recording
if (isRecording && recordingStartTime) {
captureEvent(device, scalarIndex, value);
captureEvent(device, actuatorIdx, value);
}
}
@@ -145,44 +124,51 @@ function stopRecording() {
function captureEvent(
device: BluetoothDevice,
scalarIndex: number,
actuatorIdx: number,
value: number,
) {
if (!recordingStartTime) return;
const timestamp = performance.now() - recordingStartTime;
const scalarCmd = device.info.messageAttributes.ScalarCmd[scalarIndex];
const actuator = device.actuators[actuatorIdx];
recordedEvents.push({
timestamp,
deviceIndex: device.info.index,
deviceName: device.name,
actuatorIndex: scalarIndex,
actuatorType: scalarCmd.ActuatorType,
value: (value / scalarCmd.StepCount) * 100, // Normalize to 0-100
actuatorIndex: actuatorIdx,
actuatorType: actuator.outputType,
value: (value / actuator.maxSteps) * 100, // Normalize to 0-100
});
}
async function handleStop(device: BluetoothDevice) {
await client.sendDeviceMessage(
{ index: device.info.index },
new StopDeviceCmd(device.info.index),
);
device.actuatorValues = device.info.messageAttributes.ScalarCmd.map(() => 0);
await device.info.stop();
device.actuators.forEach((a) => (a.value = 0));
}
function convertDevice(device: ButtplugClientDevice): BluetoothDevice {
console.log(device);
const actuators: import("$lib/types").DeviceActuator[] = [];
for (const [, feature] of device.features) {
for (const outputType of feature.outputTypes) {
actuators.push({
featureIndex: feature.featureIndex,
outputType,
maxSteps: feature.outputMaxValue(outputType),
descriptor: feature.featureDescriptor,
value: 0,
});
}
}
return {
id: device.index as string,
name: device.name as string,
id: String(device.index),
name: device.name,
actuators,
batteryLevel: 0,
hasBattery: device.hasInput(InputType.Battery),
isConnected: true,
lastSeen: new Date(),
sensorValues: device.messageAttributes.SensorReadCmd
? device.messageAttributes.SensorReadCmd.map(() => 0)
: [],
actuatorValues: device.messageAttributes.ScalarCmd.map(() => 0),
info: device,
};
}
@@ -195,7 +181,7 @@ async function handleSaveRecording(data: {
const deviceInfo: DeviceInfo[] = devices.map((d) => ({
name: d.name,
index: d.info.index,
capabilities: d.info.messageAttributes.ScalarCmd.map((cmd) => cmd.ActuatorType),
capabilities: d.actuators.map((a) => a.outputType),
}));
try {
@@ -345,37 +331,26 @@ function executeEvent(event: RecordedEvent) {
}
// Find matching actuator by type
const scalarCmd = device.info.messageAttributes.ScalarCmd.find(
cmd => cmd.ActuatorType === event.actuatorType
const actuator = device.actuators.find(
(a) => a.outputType === event.actuatorType,
);
if (!scalarCmd) {
if (!actuator) {
console.warn(`Actuator type ${event.actuatorType} not found on ${device.name}`);
return;
}
// Convert normalized value (0-100) back to device scale
const deviceValue = (event.value / 100) * scalarCmd.StepCount;
const deviceValue = Math.round((event.value / 100) * actuator.maxSteps);
// Send command to device
client.sendDeviceMessage(
{ index: device.info.index },
new ScalarCmd(
[
new ScalarSubcommand(
scalarCmd.Index,
deviceValue,
scalarCmd.ActuatorType,
),
],
device.info.index,
),
);
// Send command to device via feature
const feature = device.info.features.get(actuator.featureIndex);
if (feature) {
const outputType = actuator.outputType as OutputType;
feature.runOutput(new DeviceOutputValueConstructor(outputType).steps(deviceValue));
}
// Update UI
const scalarIndex = device.info.messageAttributes.ScalarCmd.indexOf(scalarCmd);
if (scalarIndex !== -1) {
device.actuatorValues[scalarIndex] = deviceValue;
}
actuator.value = deviceValue;
}
function seek(percentage: number) {
@@ -618,7 +593,7 @@ onMount(() => {
deviceInfo={devices.map((d) => ({
name: d.name,
index: d.info.index,
capabilities: d.info.messageAttributes.ScalarCmd.map((cmd) => cmd.ActuatorType),
capabilities: d.actuators.map((a) => a.outputType),
}))}
duration={recordingDuration}
onSave={handleSaveRecording}

View File

@@ -19,8 +19,8 @@ let mappings = $state<Map<string, BluetoothDevice>>(new Map());
// Check if a connected device is compatible with a recorded device
function isCompatible(recordedDevice: DeviceInfo, connectedDevice: BluetoothDevice): boolean {
const connectedActuators = connectedDevice.info.messageAttributes.ScalarCmd.map(
cmd => cmd.ActuatorType
const connectedActuators = connectedDevice.actuators.map(
(a) => a.outputType,
);
// Check if all required actuator types from recording exist on connected device

View File

@@ -8,6 +8,14 @@ export default defineConfig({
resolve: {
alias: { $lib: path.resolve("./src/lib"), "@": path.resolve("./src/lib") },
},
ssr: {
noExternal: ["@sexy.pivoine.art/buttplug"],
},
build: {
rollupOptions: {
external: [/\/wasm\/index\.js/],
},
},
server: {
port: 3000,
proxy: {