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
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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user