Compare commits

...

4 Commits

Author SHA1 Message Date
e744d1e40f fix: clean up stale sorter entries and fix battery reactivity
All checks were successful
Build and Push Docker Image to Gitea / build-and-push (push) Successful in 5m44s
ButtplugMessageSorter never deleted entries from _waitingMsgs after
resolving, causing unsolicited DeviceList messages (with reused Ids)
to be swallowed. Also fix battery level not updating in UI by accessing
the device through the Svelte $state proxy array.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 13:07:13 +01:00
82be8b8859 fix: resolve device listing bug after buttplug v10 upgrade
Unwrap DeviceList wrapper message before passing to parseDeviceList(),
and rename FeatureDescriptor to FeatureDescription to match Rust v10 serde output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 12:11:53 +01:00
27d86fff8b fix: vite wasm rollup
All checks were successful
Build and Push Docker Image to Gitea / build-and-push (push) Successful in 5m33s
2026-02-06 15:08:32 +01:00
6ea4ed1933 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>
2026-02-06 14:46:47 +01:00
32 changed files with 1767 additions and 2444 deletions

View File

@@ -17,8 +17,10 @@
"packageManager": "pnpm@10.19.0",
"pnpm": {
"onlyBuiltDependencies": [
"es5-ext",
"esbuild",
"svelte-preprocess",
"vue-demi"
"wasm-pack"
],
"ignoredBuiltDependencies": [
"@tailwindcss/oxide",

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "buttplug_wasm"
version = "9.0.9"
version = "10.0.0"
authors = ["Nonpolynomial Labs, LLC <kyle@nonpolynomial.com>"]
description = "WASM Interop for the Buttplug Intimate Hardware Control Library"
license = "BSD-3-Clause"
@@ -16,9 +16,9 @@ name = "buttplug_wasm"
path = "src/lib.rs"
[dependencies]
buttplug = { version = "9.0.9", default-features = false, features = ["wasm"] }
# buttplug = { path = "../../../buttplug/buttplug", default-features = false, features = ["wasm"] }
# buttplug_derive = { path = "../buttplug_derive" }
buttplug_core = { git = "https://github.com/valknarthing/buttplug.git", default-features = false, features = ["wasm"] }
buttplug_server = { git = "https://github.com/valknarthing/buttplug.git", default-features = false, features = ["wasm"] }
buttplug_server_device_config = { git = "https://github.com/valknarthing/buttplug.git" }
js-sys = "0.3.80"
tracing-wasm = "0.2.1"
log-panics = { version = "2.1.0", features = ["with-backtrace"] }

View File

@@ -13,9 +13,7 @@
"build:wasm": "wasm-pack build --out-dir wasm --out-name index --target bundler --release"
},
"dependencies": {
"class-transformer": "^0.5.1",
"eventemitter3": "^5.0.1",
"reflect-metadata": "^0.2.2",
"typescript": "^5.9.2",
"vite": "^7.1.4",
"vite-plugin-wasm": "3.5.0",

View File

@@ -6,11 +6,11 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/
"use strict";
'use strict';
import { IButtplugClientConnector } from "./IButtplugClientConnector";
import { ButtplugMessage } from "../core/Messages";
import { ButtplugBrowserWebsocketConnector } from "../utils/ButtplugBrowserWebsocketConnector";
import { IButtplugClientConnector } from './IButtplugClientConnector';
import { ButtplugMessage } from '../core/Messages';
import { ButtplugBrowserWebsocketConnector } from '../utils/ButtplugBrowserWebsocketConnector';
export class ButtplugBrowserWebsocketClientConnector
extends ButtplugBrowserWebsocketConnector
@@ -18,7 +18,7 @@ export class ButtplugBrowserWebsocketClientConnector
{
public send = (msg: ButtplugMessage): void => {
if (!this.Connected) {
throw new Error("ButtplugClient not connected");
throw new Error('ButtplugClient not connected');
}
this.sendMessage(msg);
};

View File

@@ -0,0 +1,242 @@
/*!
* Buttplug JS Source Code File - Visit https://buttplug.io for more info about
* the project. Licensed under the BSD 3-Clause license. See LICENSE file in the
* project root for full license information.
*
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/
'use strict';
import { ButtplugLogger } from '../core/Logging';
import { EventEmitter } from 'eventemitter3';
import { ButtplugClientDevice } from './ButtplugClientDevice';
import { IButtplugClientConnector } from './IButtplugClientConnector';
import { ButtplugMessageSorter } from '../utils/ButtplugMessageSorter';
import * as Messages from '../core/Messages';
import {
ButtplugError,
ButtplugInitError,
ButtplugMessageError,
} from '../core/Exceptions';
import { ButtplugClientConnectorException } from './ButtplugClientConnectorException';
export class ButtplugClient extends EventEmitter {
protected _pingTimer: NodeJS.Timeout | null = null;
protected _connector: IButtplugClientConnector | null = null;
protected _devices: Map<number, ButtplugClientDevice> = new Map();
protected _clientName: string;
protected _logger = ButtplugLogger.Logger;
protected _isScanning = false;
private _sorter: ButtplugMessageSorter = new ButtplugMessageSorter(true);
constructor(clientName = 'Generic Buttplug Client') {
super();
this._clientName = clientName;
this._logger.Debug(`ButtplugClient: Client ${clientName} created.`);
}
public get connected(): boolean {
return this._connector !== null && this._connector.Connected;
}
public get devices(): Map<number, ButtplugClientDevice> {
// While this function doesn't actually send a message, if we don't have a
// connector, we shouldn't have devices.
this.checkConnector();
return this._devices;
}
public get isScanning(): boolean {
return this._isScanning;
}
public connect = async (connector: IButtplugClientConnector) => {
this._logger.Info(
`ButtplugClient: Connecting using ${connector.constructor.name}`
);
await connector.connect();
this._connector = connector;
this._connector.addListener('message', this.parseMessages);
this._connector.addListener('disconnect', this.disconnectHandler);
await this.initializeConnection();
};
public disconnect = async () => {
this._logger.Debug('ButtplugClient: Disconnect called');
this._devices.clear();
this.checkConnector();
await this.shutdownConnection();
await this._connector!.disconnect();
};
public startScanning = async () => {
this._logger.Debug('ButtplugClient: StartScanning called');
this._isScanning = true;
await this.sendMsgExpectOk({ StartScanning: { Id: 1 } });
};
public stopScanning = async () => {
this._logger.Debug('ButtplugClient: StopScanning called');
this._isScanning = false;
await this.sendMsgExpectOk({ StopScanning: { Id: 1 } });
};
public stopAllDevices = async () => {
this._logger.Debug('ButtplugClient: StopAllDevices');
await this.sendMsgExpectOk({ StopCmd: { Id: 1, DeviceIndex: undefined, FeatureIndex: undefined, Inputs: true, Outputs: true } });
};
protected disconnectHandler = () => {
this._logger.Info('ButtplugClient: Disconnect event receieved.');
this.emit('disconnect');
};
protected parseMessages = (msgs: Messages.ButtplugMessage[]) => {
const leftoverMsgs = this._sorter.ParseIncomingMessages(msgs);
for (const x of leftoverMsgs) {
if (x.DeviceList !== undefined) {
this.parseDeviceList(x.DeviceList!);
break;
} else if (x.ScanningFinished !== undefined) {
this._isScanning = false;
this.emit('scanningfinished', x);
} else if (x.InputReading !== undefined) {
// TODO this should be emitted from the device or feature, not the client
this.emit('inputreading', x);
} else {
console.log(`Unhandled message: ${x}`);
}
}
};
protected initializeConnection = async (): Promise<boolean> => {
this.checkConnector();
const msg = await this.sendMessage(
{
RequestServerInfo: {
ClientName: this._clientName,
Id: 1,
ProtocolVersionMajor: Messages.MESSAGE_SPEC_VERSION_MAJOR,
ProtocolVersionMinor: Messages.MESSAGE_SPEC_VERSION_MINOR
}
}
);
if (msg.ServerInfo !== undefined) {
const serverinfo = msg as Messages.ServerInfo;
this._logger.Info(
`ButtplugClient: Connected to Server ${serverinfo.ServerName}`
);
// TODO: maybe store server name, do something with message template version?
const ping = serverinfo.MaxPingTime;
// If the server version is lower than the client version, the server will disconnect here.
if (ping > 0) {
/*
this._pingTimer = setInterval(async () => {
// If we've disconnected, stop trying to ping the server.
if (!this.Connected) {
await this.ShutdownConnection();
return;
}
await this.SendMessage(new Messages.Ping());
} , Math.round(ping / 2));
*/
}
await this.requestDeviceList();
return true;
} else if (msg.Error !== undefined) {
// Disconnect and throw an exception with the error message we got back.
// This will usually only error out if we have a version mismatch that the
// server has detected.
await this._connector!.disconnect();
const err = msg.Error as Messages.Error;
throw ButtplugError.LogAndError(
ButtplugInitError,
this._logger,
`Cannot connect to server. ${err.ErrorMessage}`
);
}
return false;
}
private parseDeviceList = (list: Messages.DeviceList) => {
for (let [_, d] of Object.entries(list.Devices)) {
if (!this._devices.has(d.DeviceIndex)) {
const device = ButtplugClientDevice.fromMsg(
d,
this.sendMessageClosure
);
this._logger.Debug(`ButtplugClient: Adding Device: ${device}`);
this._devices.set(d.DeviceIndex, device);
this.emit('deviceadded', device);
} else {
this._logger.Debug(`ButtplugClient: Device already added: ${d}`);
}
}
for (let [index, device] of this._devices.entries()) {
if (!list.Devices.hasOwnProperty(index.toString())) {
this._devices.delete(index);
this.emit('deviceremoved', device);
}
}
}
protected requestDeviceList = async () => {
this.checkConnector();
this._logger.Debug('ButtplugClient: ReceiveDeviceList called');
const response = (await this.sendMessage(
{
RequestDeviceList: { Id: 1 }
}
));
this.parseDeviceList(response.DeviceList!);
};
protected shutdownConnection = async () => {
await this.stopAllDevices();
if (this._pingTimer !== null) {
clearInterval(this._pingTimer);
this._pingTimer = null;
}
};
protected async sendMessage(
msg: Messages.ButtplugMessage
): Promise<Messages.ButtplugMessage> {
this.checkConnector();
const p = this._sorter.PrepareOutgoingMessage(msg);
await this._connector!.send(msg);
return await p;
}
protected checkConnector() {
if (!this.connected) {
throw new ButtplugClientConnectorException(
'ButtplugClient not connected'
);
}
}
protected sendMsgExpectOk = async (
msg: Messages.ButtplugMessage
): Promise<void> => {
const response = await this.sendMessage(msg);
if (response.Ok !== undefined) {
return;
} else if (response.Error !== undefined) {
throw ButtplugError.FromError(response as Messages.Error);
} else {
throw ButtplugError.LogAndError(
ButtplugMessageError,
this._logger,
`Message ${response} not handled by SendMsgExpectOk`
);
}
};
protected sendMessageClosure = async (
msg: Messages.ButtplugMessage
): Promise<Messages.ButtplugMessage> => {
return await this.sendMessage(msg);
};
}

View File

@@ -6,8 +6,8 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/
import { ButtplugError } from "../core/Exceptions";
import * as Messages from "../core/Messages";
import { ButtplugError } from '../core/Exceptions';
import * as Messages from '../core/Messages';
export class ButtplugClientConnectorException extends ButtplugError {
public constructor(message: string) {

View File

@@ -6,20 +6,24 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/
"use strict";
import * as Messages from "../core/Messages";
'use strict';
import * as Messages from '../core/Messages';
import {
ButtplugDeviceError,
ButtplugError,
ButtplugMessageError,
} from "../core/Exceptions";
import { EventEmitter } from "eventemitter3";
import { getMessageClassFromMessage } from "../core/MessageUtils";
} from '../core/Exceptions';
import { EventEmitter } from 'eventemitter3';
import { ButtplugClientDeviceFeature } from './ButtplugClientDeviceFeature';
import { DeviceOutputCommand } from './ButtplugClientDeviceCommand';
/**
* Represents an abstract device, capable of taking certain kinds of messages.
*/
export class ButtplugClientDevice extends EventEmitter {
private _features: Map<number, ButtplugClientDeviceFeature>;
/**
* Return the name of the device.
*/
@@ -48,354 +52,114 @@ export class ButtplugClientDevice extends EventEmitter {
return this._deviceInfo.DeviceMessageTimingGap;
}
/**
* Return a list of message types the device accepts.
*/
public get messageAttributes(): Messages.MessageAttributes {
return this._deviceInfo.DeviceMessages;
public get features(): Map<number, ButtplugClientDeviceFeature> {
return this._features;
}
public static fromMsg(
msg: Messages.DeviceInfo,
sendClosure: (
device: ButtplugClientDevice,
msg: Messages.ButtplugDeviceMessage,
) => Promise<Messages.ButtplugMessage>,
msg: Messages.ButtplugMessage
) => Promise<Messages.ButtplugMessage>
): ButtplugClientDevice {
return new ButtplugClientDevice(msg, sendClosure);
}
// Map of messages and their attributes (feature count, etc...)
private allowedMsgs: Map<string, Messages.MessageAttributes> = new Map<
string,
Messages.MessageAttributes
>();
/**
* @param _index Index of the device, as created by the device manager.
* @param _name Name of the device.
* @param allowedMsgs Buttplug messages the device can receive.
*/
constructor(
private constructor(
private _deviceInfo: Messages.DeviceInfo,
private _sendClosure: (
device: ButtplugClientDevice,
msg: Messages.ButtplugDeviceMessage,
) => Promise<Messages.ButtplugMessage>,
msg: Messages.ButtplugMessage
) => Promise<Messages.ButtplugMessage>
) {
super();
_deviceInfo.DeviceMessages.update();
this._features = new Map(Object.entries(_deviceInfo.DeviceFeatures).map(([index, v]) => [parseInt(index), new ButtplugClientDeviceFeature(_deviceInfo.DeviceIndex, _deviceInfo.DeviceName, v, _sendClosure)]));
}
public async send(
msg: Messages.ButtplugDeviceMessage,
msg: Messages.ButtplugMessage
): Promise<Messages.ButtplugMessage> {
// Assume we're getting the closure from ButtplugClient, which does all of
// the index/existence/connection/message checks for us.
return await this._sendClosure(this, msg);
return await this._sendClosure(msg);
}
public async sendExpectOk(
msg: Messages.ButtplugDeviceMessage,
): Promise<void> {
protected sendMsgExpectOk = async (
msg: Messages.ButtplugMessage
): Promise<void> => {
const response = await this.send(msg);
switch (getMessageClassFromMessage(response)) {
case Messages.Ok:
if (response.Ok !== undefined) {
return;
case Messages.Error:
} else if (response.Error !== undefined) {
throw ButtplugError.FromError(response as Messages.Error);
default:
throw new ButtplugMessageError(
`Message type ${response.constructor} not handled by SendMsgExpectOk`,
);
}
}
public async scalar(
scalar: Messages.ScalarSubcommand | Messages.ScalarSubcommand[],
): Promise<void> {
if (Array.isArray(scalar)) {
await this.sendExpectOk(new Messages.ScalarCmd(scalar, this.index));
} else {
await this.sendExpectOk(new Messages.ScalarCmd([scalar], this.index));
/*
throw ButtplugError.LogAndError(
ButtplugMessageError,
this._logger,
`Message ${response} not handled by SendMsgExpectOk`
);
*/
}
};
protected isOutputValid(featureIndex: number, type: Messages.OutputType) {
if (!this._deviceInfo.DeviceFeatures.hasOwnProperty(featureIndex.toString())) {
throw new ButtplugDeviceError(`Feature index ${featureIndex} does not exist for device ${this.name}`);
}
if (this._deviceInfo.DeviceFeatures[featureIndex.toString()].Outputs !== undefined && !this._deviceInfo.DeviceFeatures[featureIndex.toString()].Outputs.hasOwnProperty(type)) {
throw new ButtplugDeviceError(`Feature index ${featureIndex} does not support type ${type} for device ${this.name}`);
}
}
private async scalarCommandBuilder(
speed: number | number[],
actuator: Messages.ActuatorType,
) {
const scalarAttrs = this.messageAttributes.ScalarCmd?.filter(
(x) => x.ActuatorType === actuator,
);
if (!scalarAttrs || scalarAttrs.length === 0) {
throw new ButtplugDeviceError(
`Device ${this.name} has no ${actuator} capabilities`,
);
}
const cmds: Messages.ScalarSubcommand[] = [];
if (typeof speed === "number") {
scalarAttrs.forEach((x) =>
cmds.push(new Messages.ScalarSubcommand(x.Index, speed, actuator)),
);
} else if (Array.isArray(speed)) {
if (speed.length > scalarAttrs.length) {
throw new ButtplugDeviceError(
`${speed.length} commands send to a device with ${scalarAttrs.length} vibrators`,
);
}
scalarAttrs.forEach((x, i) => {
cmds.push(new Messages.ScalarSubcommand(x.Index, speed[i], actuator));
});
} else {
throw new ButtplugDeviceError(
`${actuator} can only take numbers or arrays of numbers.`,
);
}
await this.scalar(cmds);
public hasOutput(type: Messages.OutputType): boolean {
return this._features.values().filter((f) => f.hasOutput(type)).toArray().length > 0;
}
public get vibrateAttributes(): Messages.GenericDeviceMessageAttributes[] {
return (
this.messageAttributes.ScalarCmd?.filter(
(x) => x.ActuatorType === Messages.ActuatorType.Vibrate,
) ?? []
);
public hasInput(type: Messages.InputType): boolean {
return this._features.values().filter((f) => f.hasInput(type)).toArray().length > 0;
}
public async vibrate(speed: number | number[]): Promise<void> {
await this.scalarCommandBuilder(speed, Messages.ActuatorType.Vibrate);
}
public get oscillateAttributes(): Messages.GenericDeviceMessageAttributes[] {
return (
this.messageAttributes.ScalarCmd?.filter(
(x) => x.ActuatorType === Messages.ActuatorType.Oscillate,
) ?? []
);
}
public async oscillate(speed: number | number[]): Promise<void> {
await this.scalarCommandBuilder(speed, Messages.ActuatorType.Oscillate);
}
public get rotateAttributes(): Messages.GenericDeviceMessageAttributes[] {
return this.messageAttributes.RotateCmd ?? [];
}
public async rotate(
values: number | [number, boolean][],
clockwise?: boolean,
): Promise<void> {
const rotateAttrs = this.messageAttributes.RotateCmd;
if (!rotateAttrs || rotateAttrs.length === 0) {
throw new ButtplugDeviceError(
`Device ${this.name} has no Rotate capabilities`,
);
}
let msg: Messages.RotateCmd;
if (typeof values === "number") {
msg = Messages.RotateCmd.Create(
this.index,
new Array(rotateAttrs.length).fill([values, clockwise]),
);
} else if (Array.isArray(values)) {
msg = Messages.RotateCmd.Create(this.index, values);
} else {
throw new ButtplugDeviceError(
"SendRotateCmd can only take a number and boolean, or an array of number/boolean tuples",
);
}
await this.sendExpectOk(msg);
}
public get linearAttributes(): Messages.GenericDeviceMessageAttributes[] {
return this.messageAttributes.LinearCmd ?? [];
}
public async linear(
values: number | [number, number][],
duration?: number,
): Promise<void> {
const linearAttrs = this.messageAttributes.LinearCmd;
if (!linearAttrs || linearAttrs.length === 0) {
throw new ButtplugDeviceError(
`Device ${this.name} has no Linear capabilities`,
);
}
let msg: Messages.LinearCmd;
if (typeof values === "number") {
msg = Messages.LinearCmd.Create(
this.index,
new Array(linearAttrs.length).fill([values, duration]),
);
} else if (Array.isArray(values)) {
msg = Messages.LinearCmd.Create(this.index, values);
} else {
throw new ButtplugDeviceError(
"SendLinearCmd can only take a number and number, or an array of number/number tuples",
);
}
await this.sendExpectOk(msg);
}
public async sensorRead(
sensorIndex: number,
sensorType: Messages.SensorType,
): Promise<number[]> {
const response = await this.send(
new Messages.SensorReadCmd(this.index, sensorIndex, sensorType),
);
switch (getMessageClassFromMessage(response)) {
case Messages.SensorReading:
return (response as Messages.SensorReading).Data;
case Messages.Error:
throw ButtplugError.FromError(response as Messages.Error);
default:
throw new ButtplugMessageError(
`Message type ${response.constructor} not handled by sensorRead`,
);
public async runOutput(cmd: DeviceOutputCommand): Promise<void> {
let p: Promise<void>[] = [];
for (let f of this._features.values()) {
if (f.hasOutput(cmd.outputType)) {
p.push(f.runOutput(cmd));
}
}
public get hasBattery(): boolean {
const batteryAttrs = this.messageAttributes.SensorReadCmd?.filter(
(x) => x.SensorType === Messages.SensorType.Battery,
);
return batteryAttrs !== undefined && batteryAttrs.length > 0;
if (p.length == 0) {
return Promise.reject(`No features with output type ${cmd.outputType}`);
}
public async battery(): Promise<number> {
if (!this.hasBattery) {
throw new ButtplugDeviceError(
`Device ${this.name} has no Battery capabilities`,
);
}
const batteryAttrs = this.messageAttributes.SensorReadCmd?.filter(
(x) => x.SensorType === Messages.SensorType.Battery,
);
// Find the battery sensor, we'll need its index.
const result = await this.sensorRead(
batteryAttrs![0].Index,
Messages.SensorType.Battery,
);
return result[0] / 100.0;
}
public get hasRssi(): boolean {
const rssiAttrs = this.messageAttributes.SensorReadCmd?.filter(
(x) => x.SensorType === Messages.SensorType.RSSI,
);
return rssiAttrs !== undefined && rssiAttrs.length === 0;
}
public async rssi(): Promise<number> {
if (!this.hasRssi) {
throw new ButtplugDeviceError(
`Device ${this.name} has no RSSI capabilities`,
);
}
const rssiAttrs = this.messageAttributes.SensorReadCmd?.filter(
(x) => x.SensorType === Messages.SensorType.RSSI,
);
// Find the battery sensor, we'll need its index.
const result = await this.sensorRead(
rssiAttrs![0].Index,
Messages.SensorType.RSSI,
);
return result[0];
}
public async rawRead(
endpoint: string,
expectedLength: number,
timeout: number,
): Promise<Uint8Array> {
if (!this.messageAttributes.RawReadCmd) {
throw new ButtplugDeviceError(
`Device ${this.name} has no raw read capabilities`,
);
}
if (this.messageAttributes.RawReadCmd.Endpoints.indexOf(endpoint) === -1) {
throw new ButtplugDeviceError(
`Device ${this.name} has no raw readable endpoint ${endpoint}`,
);
}
const response = await this.send(
new Messages.RawReadCmd(this.index, endpoint, expectedLength, timeout),
);
switch (getMessageClassFromMessage(response)) {
case Messages.RawReading:
return new Uint8Array((response as Messages.RawReading).Data);
case Messages.Error:
throw ButtplugError.FromError(response as Messages.Error);
default:
throw new ButtplugMessageError(
`Message type ${response.constructor} not handled by rawRead`,
);
}
}
public async rawWrite(
endpoint: string,
data: Uint8Array,
writeWithResponse: boolean,
): Promise<void> {
if (!this.messageAttributes.RawWriteCmd) {
throw new ButtplugDeviceError(
`Device ${this.name} has no raw write capabilities`,
);
}
if (this.messageAttributes.RawWriteCmd.Endpoints.indexOf(endpoint) === -1) {
throw new ButtplugDeviceError(
`Device ${this.name} has no raw writable endpoint ${endpoint}`,
);
}
await this.sendExpectOk(
new Messages.RawWriteCmd(this.index, endpoint, data, writeWithResponse),
);
}
public async rawSubscribe(endpoint: string): Promise<void> {
if (!this.messageAttributes.RawSubscribeCmd) {
throw new ButtplugDeviceError(
`Device ${this.name} has no raw subscribe capabilities`,
);
}
if (
this.messageAttributes.RawSubscribeCmd.Endpoints.indexOf(endpoint) === -1
) {
throw new ButtplugDeviceError(
`Device ${this.name} has no raw subscribable endpoint ${endpoint}`,
);
}
await this.sendExpectOk(new Messages.RawSubscribeCmd(this.index, endpoint));
}
public async rawUnsubscribe(endpoint: string): Promise<void> {
// This reuses raw subscribe's info.
if (!this.messageAttributes.RawSubscribeCmd) {
throw new ButtplugDeviceError(
`Device ${this.name} has no raw unsubscribe capabilities`,
);
}
if (
this.messageAttributes.RawSubscribeCmd.Endpoints.indexOf(endpoint) === -1
) {
throw new ButtplugDeviceError(
`Device ${this.name} has no raw unsubscribable endpoint ${endpoint}`,
);
}
await this.sendExpectOk(
new Messages.RawUnsubscribeCmd(this.index, endpoint),
);
await Promise.all(p);
}
public async stop(): Promise<void> {
await this.sendExpectOk(new Messages.StopDeviceCmd(this.index));
await this.sendMsgExpectOk({StopCmd: { Id: 1, DeviceIndex: this.index, FeatureIndex: undefined, Inputs: true, Outputs: true}});
}
public async battery(): Promise<number> {
let p: Promise<void>[] = [];
for (let f of this._features.values()) {
if (f.hasInput(Messages.InputType.Battery)) {
// Right now, we only have one battery per device, so assume the first one we find is it.
let response = await f.runInput(Messages.InputType.Battery, Messages.InputCommandType.Read);
if (response === undefined) {
throw new ButtplugMessageError("Got incorrect message back.");
}
if (response.Reading[Messages.InputType.Battery] === undefined) {
throw new ButtplugMessageError("Got reading with no Battery info.");
}
return response.Reading[Messages.InputType.Battery].Value;
}
}
throw new ButtplugDeviceError(`No battery present on this device.`);
}
public emitDisconnected() {
this.emit("deviceremoved");
this.emit('deviceremoved');
}
}

View File

@@ -0,0 +1,111 @@
import { ButtplugDeviceError } from "../core/Exceptions";
import { OutputType } from "../core/Messages";
class PercentOrSteps {
private _percent: number | undefined;
private _steps: number | undefined;
public get percent() {
return this._percent;
}
public get steps() {
return this._steps;
}
public static createSteps(s: number): PercentOrSteps {
let v = new PercentOrSteps;
v._steps = s;
return v;
}
public static createPercent(p: number): PercentOrSteps {
if (p < 0 || p > 1.0) {
throw new ButtplugDeviceError(`Percent value ${p} is not in the range 0.0 <= x <= 1.0`);
}
let v = new PercentOrSteps;
v._percent = p;
return v;
}
}
export class DeviceOutputCommand {
public constructor(
private _outputType: OutputType,
private _value: PercentOrSteps,
private _duration?: number,
)
{}
public get outputType() {
return this._outputType;
}
public get value() {
return this._value;
}
public get duration() {
return this._duration;
}
}
export class DeviceOutputValueConstructor {
public constructor(
private _outputType: OutputType)
{}
public steps(steps: number): DeviceOutputCommand {
return new DeviceOutputCommand(this._outputType, PercentOrSteps.createSteps(steps), undefined);
}
public percent(percent: number): DeviceOutputCommand {
return new DeviceOutputCommand(this._outputType, PercentOrSteps.createPercent(percent), undefined);
}
}
export class DeviceOutputPositionWithDurationConstructor {
public steps(steps: number, duration: number): DeviceOutputCommand {
return new DeviceOutputCommand(OutputType.Position, PercentOrSteps.createSteps(steps), duration);
}
public percent(percent: number, duration: number): DeviceOutputCommand {
return new DeviceOutputCommand(OutputType.HwPositionWithDuration, PercentOrSteps.createPercent(percent), duration);
}
}
export class DeviceOutput {
private constructor() {}
public static get Vibrate() {
return new DeviceOutputValueConstructor(OutputType.Vibrate);
}
public static get Rotate() {
return new DeviceOutputValueConstructor(OutputType.Rotate);
}
public static get Oscillate() {
return new DeviceOutputValueConstructor(OutputType.Oscillate);
}
public static get Constrict() {
return new DeviceOutputValueConstructor(OutputType.Constrict);
}
public static get Inflate() {
return new DeviceOutputValueConstructor(OutputType.Inflate);
}
public static get Temperature() {
return new DeviceOutputValueConstructor(OutputType.Temperature);
}
public static get Led() {
return new DeviceOutputValueConstructor(OutputType.Led);
}
public static get Spray() {
return new DeviceOutputValueConstructor(OutputType.Spray);
}
public static get Position() {
return new DeviceOutputValueConstructor(OutputType.Position);
}
public static get PositionWithDuration() {
return new DeviceOutputPositionWithDurationConstructor();
}
}

View File

@@ -0,0 +1,168 @@
import { ButtplugDeviceError, ButtplugError, ButtplugMessageError } from "../core/Exceptions";
import * as Messages from "../core/Messages";
import { DeviceOutputCommand } from "./ButtplugClientDeviceCommand";
export class ButtplugClientDeviceFeature {
constructor(
private _deviceIndex: number,
private _deviceName: string,
private _feature: Messages.DeviceFeature,
private _sendClosure: (
msg: Messages.ButtplugMessage
) => Promise<Messages.ButtplugMessage>) {
}
protected send = async (msg: Messages.ButtplugMessage): Promise<Messages.ButtplugMessage> => {
return await this._sendClosure(msg);
}
protected sendMsgExpectOk = async (
msg: Messages.ButtplugMessage
): Promise<void> => {
const response = await this.send(msg);
if (response.Ok !== undefined) {
return;
} else if (response.Error !== undefined) {
throw ButtplugError.FromError(response as Messages.Error);
} else {
throw new ButtplugMessageError("Expected Ok or Error, and didn't get either!");
}
};
protected isOutputValid(type: Messages.OutputType) {
if (this._feature.Output !== undefined && !this._feature.Output.hasOwnProperty(type)) {
throw new ButtplugDeviceError(`Feature index ${this._feature.FeatureIndex} does not support type ${type} for device ${this._deviceName}`);
}
}
protected isInputValid(type: Messages.InputType) {
if (this._feature.Input !== undefined && !this._feature.Input.hasOwnProperty(type)) {
throw new ButtplugDeviceError(`Feature index ${this._feature.FeatureIndex} does not support type ${type} for device ${this._deviceName}`);
}
}
protected async sendOutputCmd(command: DeviceOutputCommand): Promise<void> {
// Make sure the requested feature is valid
this.isOutputValid(command.outputType);
if (command.value === undefined) {
throw new ButtplugDeviceError(`${command.outputType} requires value defined`);
}
let type = command.outputType;
let duration: undefined | number = undefined;
if (type == Messages.OutputType.HwPositionWithDuration) {
if (command.duration === undefined) {
throw new ButtplugDeviceError("PositionWithDuration requires duration defined");
}
duration = command.duration;
}
let value: number;
let p = command.value;
if (p.percent === undefined) {
// TODO Check step limits here
value = command.value.steps!;
} else {
value = Math.ceil(this._feature.Output[type]!.Value![1] * p.percent);
}
let newCommand: Messages.DeviceFeatureOutput = { Value: value, Duration: duration };
let outCommand = {};
outCommand[type.toString()] = newCommand;
let cmd: Messages.ButtplugMessage = {
OutputCmd: {
Id: 1,
DeviceIndex: this._deviceIndex,
FeatureIndex: this._feature.FeatureIndex,
Command: outCommand
}
};
await this.sendMsgExpectOk(cmd);
}
public get featureDescriptor(): string {
return this._feature.FeatureDescription;
}
public get featureIndex(): number {
return this._feature.FeatureIndex;
}
public get outputTypes(): Messages.OutputType[] {
if (this._feature.Output === undefined) return [];
return Object.keys(this._feature.Output) as Messages.OutputType[];
}
public get inputTypes(): Messages.InputType[] {
if (this._feature.Input === undefined) return [];
return Object.keys(this._feature.Input) as Messages.InputType[];
}
public outputMaxValue(type: Messages.OutputType): number {
if (this._feature.Output === undefined || this._feature.Output[type] === undefined) {
return 0;
}
const val = this._feature.Output[type]!.Value;
// Value can arrive as number[] [min, max] from server or as number
if (Array.isArray(val)) {
return val[val.length - 1];
}
return val as number;
}
public hasOutput(type: Messages.OutputType): boolean {
if (this._feature.Output !== undefined) {
return this._feature.Output.hasOwnProperty(type.toString());
}
return false;
}
public hasInput(type: Messages.InputType): boolean {
if (this._feature.Input !== undefined) {
return this._feature.Input.hasOwnProperty(type.toString());
}
return false;
}
public async runOutput(cmd: DeviceOutputCommand): Promise<void> {
if (this._feature.Output !== undefined && this._feature.Output.hasOwnProperty(cmd.outputType.toString())) {
return this.sendOutputCmd(cmd);
}
throw new ButtplugDeviceError(`Output type ${cmd.outputType} not supported by feature.`);
}
public async runInput(inputType: Messages.InputType, inputCommand: Messages.InputCommandType): Promise<Messages.InputReading | undefined> {
// Make sure the requested feature is valid
this.isInputValid(inputType);
let inputAttributes = this._feature.Input[inputType];
console.log(this._feature.Input);
if ((inputCommand === Messages.InputCommandType.Unsubscribe && !inputAttributes.Command.includes(Messages.InputCommandType.Subscribe)) && !inputAttributes.Command.includes(inputCommand)) {
throw new ButtplugDeviceError(`${inputType} does not support command ${inputCommand}`);
}
let cmd: Messages.ButtplugMessage = {
InputCmd: {
Id: 1,
DeviceIndex: this._deviceIndex,
FeatureIndex: this._feature.FeatureIndex,
Type: inputType,
Command: inputCommand,
}
};
if (inputCommand == Messages.InputCommandType.Read) {
const response = await this.send(cmd);
if (response.InputReading !== undefined) {
return response.InputReading;
} else if (response.Error !== undefined) {
throw ButtplugError.FromError(response as Messages.Error);
} else {
throw new ButtplugMessageError("Expected InputReading or Error, and didn't get either!");
}
} else {
console.log(`Sending subscribe message: ${JSON.stringify(cmd)}`);
await this.sendMsgExpectOk(cmd);
console.log("Got back ok?");
}
}
}

View File

@@ -6,10 +6,10 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/
"use strict";
'use strict';
import { ButtplugBrowserWebsocketClientConnector } from "./ButtplugBrowserWebsocketClientConnector";
import { WebSocket as NodeWebSocket } from "ws";
import { ButtplugBrowserWebsocketClientConnector } from './ButtplugBrowserWebsocketClientConnector';
import { WebSocket as NodeWebSocket } from 'ws';
export class ButtplugNodeWebsocketClientConnector extends ButtplugBrowserWebsocketClientConnector {
protected _websocketConstructor =

View File

@@ -1,276 +0,0 @@
/*!
* Buttplug JS Source Code File - Visit https://buttplug.io for more info about
* the project. Licensed under the BSD 3-Clause license. See LICENSE file in the
* project root for full license information.
*
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/
"use strict";
import { ButtplugLogger } from "../core/Logging";
import { EventEmitter } from "eventemitter3";
import { ButtplugClientDevice } from "./ButtplugClientDevice";
import { IButtplugClientConnector } from "./IButtplugClientConnector";
import { ButtplugMessageSorter } from "../utils/ButtplugMessageSorter";
import * as Messages from "../core/Messages";
import {
ButtplugDeviceError,
ButtplugError,
ButtplugInitError,
ButtplugMessageError,
} from "../core/Exceptions";
import { ButtplugClientConnectorException } from "./ButtplugClientConnectorException";
import { getMessageClassFromMessage } from "../core/MessageUtils";
export class ButtplugClient extends EventEmitter {
protected _pingTimer: NodeJS.Timeout | null = null;
protected _connector: IButtplugClientConnector | null = null;
protected _devices: Map<number, ButtplugClientDevice> = new Map();
protected _clientName: string;
protected _logger = ButtplugLogger.Logger;
protected _isScanning = false;
private _sorter: ButtplugMessageSorter = new ButtplugMessageSorter(true);
constructor(clientName = "Generic Buttplug Client") {
super();
this._clientName = clientName;
this._logger.Debug(`ButtplugClient: Client ${clientName} created.`);
}
public get connected(): boolean {
return this._connector !== null && this._connector.Connected;
}
public get devices(): ButtplugClientDevice[] {
// While this function doesn't actually send a message, if we don't have a
// connector, we shouldn't have devices.
this.checkConnector();
const devices: ButtplugClientDevice[] = [];
this._devices.forEach((d) => {
devices.push(d);
});
return devices;
}
public get isScanning(): boolean {
return this._isScanning;
}
public connect = async (connector: IButtplugClientConnector) => {
this._logger.Info(
`ButtplugClient: Connecting using ${connector.constructor.name}`,
);
await connector.connect();
this._connector = connector;
this._connector.addListener("message", this.parseMessages);
this._connector.addListener("disconnect", this.disconnectHandler);
await this.initializeConnection();
};
public disconnect = async () => {
this._logger.Debug("ButtplugClient: Disconnect called");
this.checkConnector();
await this.shutdownConnection();
await this._connector!.disconnect();
};
public startScanning = async () => {
this._logger.Debug("ButtplugClient: StartScanning called");
this._isScanning = true;
await this.sendMsgExpectOk(new Messages.StartScanning());
};
public stopScanning = async () => {
this._logger.Debug("ButtplugClient: StopScanning called");
this._isScanning = false;
await this.sendMsgExpectOk(new Messages.StopScanning());
};
public stopAllDevices = async () => {
this._logger.Debug("ButtplugClient: StopAllDevices");
await this.sendMsgExpectOk(new Messages.StopAllDevices());
};
private async sendDeviceMessage(
device: ButtplugClientDevice,
deviceMsg: Messages.ButtplugDeviceMessage,
): Promise<Messages.ButtplugMessage> {
this.checkConnector();
const dev = this._devices.get(device.index);
if (dev === undefined) {
throw ButtplugError.LogAndError(
ButtplugDeviceError,
this._logger,
`Device ${device.index} not available.`,
);
}
deviceMsg.DeviceIndex = device.index;
return await this.sendMessage(deviceMsg);
}
protected disconnectHandler = () => {
this._logger.Info("ButtplugClient: Disconnect event receieved.");
this.emit("disconnect");
};
protected parseMessages = (msgs: Messages.ButtplugMessage[]) => {
const leftoverMsgs = this._sorter.ParseIncomingMessages(msgs);
for (const x of leftoverMsgs) {
switch (getMessageClassFromMessage(x)) {
case Messages.DeviceAdded: {
const addedMsg = x as Messages.DeviceAdded;
const addedDevice = ButtplugClientDevice.fromMsg(
addedMsg,
this.sendDeviceMessageClosure,
);
this._devices.set(addedMsg.DeviceIndex, addedDevice);
this.emit("deviceadded", addedMsg, addedDevice);
break;
}
case Messages.DeviceRemoved: {
const removedMsg = x as Messages.DeviceRemoved;
if (this._devices.has(removedMsg.DeviceIndex)) {
const removedDevice = this._devices.get(removedMsg.DeviceIndex);
removedDevice?.emitDisconnected();
this._devices.delete(removedMsg.DeviceIndex);
this.emit("deviceremoved", removedMsg, removedDevice);
}
break;
}
case Messages.ScanningFinished:
this._isScanning = false;
this.emit("scanningfinished", x);
break;
}
}
};
protected initializeConnection = async (): Promise<boolean> => {
this.checkConnector();
const msg = await this.sendMessage(
new Messages.RequestServerInfo(
this._clientName,
Messages.MESSAGE_SPEC_VERSION,
),
);
switch (getMessageClassFromMessage(msg)) {
case Messages.ServerInfo: {
const serverinfo = msg as Messages.ServerInfo;
this._logger.Info(
`ButtplugClient: Connected to Server ${serverinfo.ServerName}`,
);
// TODO: maybe store server name, do something with message template version?
const ping = serverinfo.MaxPingTime;
if (serverinfo.MessageVersion < Messages.MESSAGE_SPEC_VERSION) {
// Disconnect and throw an exception explaining the version mismatch problem.
await this._connector!.disconnect();
throw ButtplugError.LogAndError(
ButtplugInitError,
this._logger,
`Server protocol version ${serverinfo.MessageVersion} is older than client protocol version ${Messages.MESSAGE_SPEC_VERSION}. Please update server.`,
);
}
if (ping > 0) {
/*
this._pingTimer = setInterval(async () => {
// If we've disconnected, stop trying to ping the server.
if (!this.Connected) {
await this.ShutdownConnection();
return;
}
await this.SendMessage(new Messages.Ping());
} , Math.round(ping / 2));
*/
}
await this.requestDeviceList();
return true;
}
case Messages.Error: {
// Disconnect and throw an exception with the error message we got back.
// This will usually only error out if we have a version mismatch that the
// server has detected.
await this._connector!.disconnect();
const err = msg as Messages.Error;
throw ButtplugError.LogAndError(
ButtplugInitError,
this._logger,
`Cannot connect to server. ${err.ErrorMessage}`,
);
}
}
return false;
};
protected requestDeviceList = async () => {
this.checkConnector();
this._logger.Debug("ButtplugClient: ReceiveDeviceList called");
const deviceList = (await this.sendMessage(
new Messages.RequestDeviceList(),
)) as Messages.DeviceList;
deviceList.Devices.forEach((d) => {
if (!this._devices.has(d.DeviceIndex)) {
const device = ButtplugClientDevice.fromMsg(
d,
this.sendDeviceMessageClosure,
);
this._logger.Debug(`ButtplugClient: Adding Device: ${device}`);
this._devices.set(d.DeviceIndex, device);
this.emit("deviceadded", device);
} else {
this._logger.Debug(`ButtplugClient: Device already added: ${d}`);
}
});
};
protected shutdownConnection = async () => {
await this.stopAllDevices();
if (this._pingTimer !== null) {
clearInterval(this._pingTimer);
this._pingTimer = null;
}
};
protected async sendMessage(
msg: Messages.ButtplugMessage,
): Promise<Messages.ButtplugMessage> {
this.checkConnector();
const p = this._sorter.PrepareOutgoingMessage(msg);
await this._connector!.send(msg);
return await p;
}
protected checkConnector() {
if (!this.connected) {
throw new ButtplugClientConnectorException(
"ButtplugClient not connected",
);
}
}
protected sendMsgExpectOk = async (
msg: Messages.ButtplugMessage,
): Promise<void> => {
const response = await this.sendMessage(msg);
switch (getMessageClassFromMessage(response)) {
case Messages.Ok:
return;
case Messages.Error:
throw ButtplugError.FromError(response as Messages.Error);
default:
throw ButtplugError.LogAndError(
ButtplugMessageError,
this._logger,
`Message type ${getMessageClassFromMessage(response)!.constructor} not handled by SendMsgExpectOk`,
);
}
};
protected sendDeviceMessageClosure = async (
device: ButtplugClientDevice,
msg: Messages.ButtplugDeviceMessage,
): Promise<Messages.ButtplugMessage> => {
return await this.sendDeviceMessage(device, msg);
};
}

View File

@@ -6,8 +6,8 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/
import { ButtplugMessage } from "../core/Messages";
import { EventEmitter } from "eventemitter3";
import { ButtplugMessage } from '../core/Messages';
import { EventEmitter } from 'eventemitter3';
export interface IButtplugClientConnector extends EventEmitter {
connect: () => Promise<void>;

View File

@@ -6,8 +6,8 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/
import * as Messages from "./Messages";
import { ButtplugLogger } from "./Logging";
import * as Messages from './Messages';
import { ButtplugLogger } from './Logging';
export class ButtplugError extends Error {
public get ErrorClass(): Messages.ErrorClass {
@@ -23,14 +23,20 @@ export class ButtplugError extends Error {
}
public get ErrorMessage(): Messages.ButtplugMessage {
return new Messages.Error(this.message, this.ErrorClass, this.Id);
return {
Error: {
Id: this.Id,
ErrorCode: this.ErrorClass,
ErrorMessage: this.message
}
}
}
public static LogAndError<T extends ButtplugError>(
constructor: new (str: string, num: number) => T,
logger: ButtplugLogger,
message: string,
id: number = Messages.SYSTEM_MESSAGE_ID,
id: number = Messages.SYSTEM_MESSAGE_ID
): T {
logger.Error(message);
return new constructor(message, id);
@@ -61,7 +67,7 @@ export class ButtplugError extends Error {
message: string,
errorClass: Messages.ErrorClass,
id: number = Messages.SYSTEM_MESSAGE_ID,
inner?: Error,
inner?: Error
) {
super(message);
this.errorClass = errorClass;

View File

@@ -6,7 +6,7 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/
import { EventEmitter } from "eventemitter3";
import { EventEmitter } from 'eventemitter3';
export enum ButtplugLogLevel {
Off,
@@ -69,7 +69,9 @@ export class LogMessage {
* Returns a formatted string with timestamp, level, and message.
*/
public get FormattedMessage() {
return `${ButtplugLogLevel[this.logLevel]} : ${this.timestamp} : ${this.logMessage}`;
return `${ButtplugLogLevel[this.logLevel]} : ${this.timestamp} : ${
this.logMessage
}`;
}
}
@@ -189,7 +191,7 @@ export class ButtplugLogger extends EventEmitter {
console.log(logMsg.FormattedMessage);
}
if (level <= this.maximumEventLogLevel) {
this.emit("log", logMsg);
this.emit('log', logMsg);
}
}
}

View File

@@ -1,48 +0,0 @@
/*!
* Buttplug JS Source Code File - Visit https://buttplug.io for more info about
* the project. Licensed under the BSD 3-Clause license. See LICENSE file in the
* project root for full license information.
*
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/
"use strict";
import { plainToInstance } from "class-transformer";
import * as Messages from "./Messages";
function getMessageClass(
type: string,
): (new (...args: unknown[]) => Messages.ButtplugMessage) | null {
for (const value of Object.values(Messages)) {
if (typeof value === "function" && "Name" in value && value.Name === type) {
return value;
}
}
return null;
}
export function getMessageClassFromMessage(
msg: Messages.ButtplugMessage,
): (new (...args: unknown[]) => Messages.ButtplugMessage) | null {
// Making the bold assumption all message classes have the Name static. Should define a
// requirement for this in the abstract class.
return getMessageClass(Object.getPrototypeOf(msg).constructor.Name);
}
export function fromJSON(str): Messages.ButtplugMessage[] {
const msgarray: object[] = JSON.parse(str);
const msgs: Messages.ButtplugMessage[] = [];
for (const x of Array.from(msgarray)) {
const type = Object.getOwnPropertyNames(x)[0];
const cls = getMessageClass(type);
if (cls) {
const msg = plainToInstance<Messages.ButtplugMessage, unknown>(
cls,
x[type],
);
msg.update();
msgs.push(msg);
}
}
return msgs;
}

View File

@@ -7,136 +7,59 @@
*/
// tslint:disable:max-classes-per-file
"use strict";
'use strict';
import { instanceToPlain, Type } from "class-transformer";
import "reflect-metadata";
import { ButtplugMessageError } from './Exceptions';
export const SYSTEM_MESSAGE_ID = 0;
export const DEFAULT_MESSAGE_ID = 1;
export const MAX_ID = 4294967295;
export const MESSAGE_SPEC_VERSION = 3;
export const MESSAGE_SPEC_VERSION_MAJOR = 4;
export const MESSAGE_SPEC_VERSION_MINOR = 0;
export class MessageAttributes {
public ScalarCmd?: Array<GenericDeviceMessageAttributes>;
public RotateCmd?: Array<GenericDeviceMessageAttributes>;
public LinearCmd?: Array<GenericDeviceMessageAttributes>;
public RawReadCmd?: RawDeviceMessageAttributes;
public RawWriteCmd?: RawDeviceMessageAttributes;
public RawSubscribeCmd?: RawDeviceMessageAttributes;
public SensorReadCmd?: Array<SensorDeviceMessageAttributes>;
public SensorSubscribeCmd?: Array<SensorDeviceMessageAttributes>;
public StopDeviceCmd: {};
constructor(data: Partial<MessageAttributes>) {
Object.assign(this, data);
// Base message interfaces
export interface ButtplugMessage {
Ok?: Ok;
Ping?: Ping;
Error?: Error;
RequestServerInfo?: RequestServerInfo;
ServerInfo?: ServerInfo;
RequestDeviceList?: RequestDeviceList;
StartScanning?: StartScanning;
StopScanning?: StopScanning;
ScanningFinished?: ScanningFinished;
StopCmd?: StopCmd;
InputCmd?: InputCmd;
InputReading?: InputReading;
OutputCmd?: OutputCmd;
DeviceList?: DeviceList;
}
public update() {
this.ScalarCmd?.forEach((x, i) => (x.Index = i));
this.RotateCmd?.forEach((x, i) => (x.Index = i));
this.LinearCmd?.forEach((x, i) => (x.Index = i));
this.SensorReadCmd?.forEach((x, i) => (x.Index = i));
this.SensorSubscribeCmd?.forEach((x, i) => (x.Index = i));
export function msgId(msg: ButtplugMessage): number {
for (let [_, entry] of Object.entries(msg)) {
if (entry != undefined) {
return entry.Id;
}
}
throw new ButtplugMessageError(`Message ${msg} does not have an ID.`);
}
export enum ActuatorType {
Unknown = "Unknown",
Vibrate = "Vibrate",
Rotate = "Rotate",
Oscillate = "Oscillate",
Constrict = "Constrict",
Inflate = "Inflate",
Position = "Position",
export function setMsgId(msg: ButtplugMessage, id: number) {
for (let [_, entry] of Object.entries(msg)) {
if (entry != undefined) {
entry.Id = id;
return;
}
}
throw new ButtplugMessageError(`Message ${msg} does not have an ID.`);
}
export enum SensorType {
Unknown = "Unknown",
Battery = "Battery",
RSSI = "RSSI",
Button = "Button",
Pressure = "Pressure",
// Temperature,
// Accelerometer,
// Gyro,
export interface Ok {
Id: number | undefined;
}
export class GenericDeviceMessageAttributes {
public FeatureDescriptor: string;
public ActuatorType: ActuatorType;
public StepCount: number;
public Index = 0;
constructor(data: Partial<GenericDeviceMessageAttributes>) {
Object.assign(this, data);
}
}
export class RawDeviceMessageAttributes {
constructor(public Endpoints: Array<string>) {}
}
export class SensorDeviceMessageAttributes {
public FeatureDescriptor: string;
public SensorType: SensorType;
public StepRange: Array<number>;
public Index = 0;
constructor(data: Partial<GenericDeviceMessageAttributes>) {
Object.assign(this, data);
}
}
export abstract class ButtplugMessage {
constructor(public Id: number) {}
// tslint:disable-next-line:ban-types
public get Type(): Function {
return this.constructor;
}
public toJSON(): string {
return JSON.stringify(this.toProtocolFormat());
}
public toProtocolFormat(): object {
const jsonObj = {};
jsonObj[(this.constructor as unknown as { Name: string }).Name] =
instanceToPlain(this);
return jsonObj;
}
public update() {}
}
export abstract class ButtplugDeviceMessage extends ButtplugMessage {
constructor(
public DeviceIndex: number,
public Id: number,
) {
super(Id);
}
}
export abstract class ButtplugSystemMessage extends ButtplugMessage {
constructor(public Id: number = SYSTEM_MESSAGE_ID) {
super(Id);
}
}
export class Ok extends ButtplugSystemMessage {
static Name = "Ok";
constructor(public Id: number = DEFAULT_MESSAGE_ID) {
super(Id);
}
}
export class Ping extends ButtplugMessage {
static Name = "Ping";
constructor(public Id: number = DEFAULT_MESSAGE_ID) {
super(Id);
}
export interface Ping {
Id: number | undefined;
}
export enum ErrorClass {
@@ -147,345 +70,140 @@ export enum ErrorClass {
ERROR_DEVICE,
}
export class Error extends ButtplugMessage {
static Name = "Error";
constructor(
public ErrorMessage: string,
public ErrorCode: ErrorClass = ErrorClass.ERROR_UNKNOWN,
public Id: number = DEFAULT_MESSAGE_ID,
) {
super(Id);
export interface Error {
ErrorMessage: string;
ErrorCode: ErrorClass;
Id: number | undefined;
}
get Schemversion() {
return 0;
}
export interface RequestDeviceList {
Id: number | undefined;
}
export class DeviceInfo {
public DeviceIndex: number;
public DeviceName: string;
@Type(() => MessageAttributes)
public DeviceMessages: MessageAttributes;
public DeviceDisplayName?: string;
public DeviceMessageTimingGap?: number;
constructor(data: Partial<DeviceInfo>) {
Object.assign(this, data);
}
export interface StartScanning {
Id: number | undefined;
}
export class DeviceList extends ButtplugMessage {
static Name = "DeviceList";
@Type(() => DeviceInfo)
public Devices: DeviceInfo[];
public Id: number;
constructor(devices: DeviceInfo[], id: number = DEFAULT_MESSAGE_ID) {
super(id);
this.Devices = devices;
this.Id = id;
export interface StopScanning {
Id: number | undefined;
}
public update() {
for (const device of this.Devices) {
device.DeviceMessages.update();
}
}
export interface StopAllDevices {
Id: number | undefined;
}
export class DeviceAdded extends ButtplugSystemMessage {
static Name = "DeviceAdded";
public DeviceIndex: number;
public DeviceName: string;
@Type(() => MessageAttributes)
public DeviceMessages: MessageAttributes;
public DeviceDisplayName?: string;
public DeviceMessageTimingGap?: number;
constructor(data: Partial<DeviceAdded>) {
super();
Object.assign(this, data);
export interface ScanningFinished {
Id: number | undefined;
}
public update() {
this.DeviceMessages.update();
}
export interface RequestServerInfo {
ClientName: string;
ProtocolVersionMajor: number;
ProtocolVersionMinor: number;
Id: number | undefined;
}
export class DeviceRemoved extends ButtplugSystemMessage {
static Name = "DeviceRemoved";
constructor(public DeviceIndex: number) {
super();
}
export interface ServerInfo {
MaxPingTime: number;
ServerName: string;
ProtocolVersionMajor: number;
ProtocolVersionMinor: number;
Id: number | undefined;
}
export class RequestDeviceList extends ButtplugMessage {
static Name = "RequestDeviceList";
constructor(public Id: number = DEFAULT_MESSAGE_ID) {
super(Id);
}
export interface DeviceFeature {
FeatureDescription: string;
Output: { [key: string]: DeviceFeatureOutput };
Input: { [key: string]: DeviceFeatureInput };
FeatureIndex: number;
}
export class StartScanning extends ButtplugMessage {
static Name = "StartScanning";
constructor(public Id: number = DEFAULT_MESSAGE_ID) {
super(Id);
}
export interface DeviceInfo {
DeviceIndex: number;
DeviceName: string;
DeviceFeatures: { [key: number]: DeviceFeature };
DeviceDisplayName?: string;
DeviceMessageTimingGap?: number;
}
export class StopScanning extends ButtplugMessage {
static Name = "StopScanning";
constructor(public Id: number = DEFAULT_MESSAGE_ID) {
super(Id);
}
export interface DeviceList {
Devices: { [key: number]: DeviceInfo };
Id: number | undefined;
}
export class ScanningFinished extends ButtplugSystemMessage {
static Name = "ScanningFinished";
constructor() {
super();
}
export enum OutputType {
Unknown = 'Unknown',
Vibrate = 'Vibrate',
Rotate = 'Rotate',
Oscillate = 'Oscillate',
Constrict = 'Constrict',
Inflate = 'Inflate',
Position = 'Position',
HwPositionWithDuration = 'HwPositionWithDuration',
Temperature = 'Temperature',
Spray = 'Spray',
Led = 'Led',
}
export class RequestServerInfo extends ButtplugMessage {
static Name = "RequestServerInfo";
constructor(
public ClientName: string,
public MessageVersion: number = 0,
public Id: number = DEFAULT_MESSAGE_ID,
) {
super(Id);
}
export enum InputType {
Unknown = 'Unknown',
Battery = 'Battery',
RSSI = 'RSSI',
Button = 'Button',
Pressure = 'Pressure',
// Temperature,
// Accelerometer,
// Gyro,
}
export class ServerInfo extends ButtplugSystemMessage {
static Name = "ServerInfo";
constructor(
public MessageVersion: number,
public MaxPingTime: number,
public ServerName: string,
public Id: number = DEFAULT_MESSAGE_ID,
) {
super();
}
export enum InputCommandType {
Read = 'Read',
Subscribe = 'Subscribe',
Unsubscribe = 'Unsubscribe',
}
export class StopDeviceCmd extends ButtplugDeviceMessage {
static Name = "StopDeviceCmd";
constructor(
public DeviceIndex: number = -1,
public Id: number = DEFAULT_MESSAGE_ID,
) {
super(DeviceIndex, Id);
}
export interface DeviceFeatureInput {
Value: number[];
Command: InputCommandType[];
}
export class StopAllDevices extends ButtplugMessage {
static Name = "StopAllDevices";
constructor(public Id: number = DEFAULT_MESSAGE_ID) {
super(Id);
}
export interface DeviceFeatureOutput {
Value: number;
Duration?: number;
}
export class GenericMessageSubcommand {
protected constructor(public Index: number) {}
export interface OutputCmd {
DeviceIndex: number;
FeatureIndex: number;
Command: { [key: string]: DeviceFeatureOutput };
Id: number | undefined;
}
export class ScalarSubcommand extends GenericMessageSubcommand {
constructor(
Index: number,
public Scalar: number,
public ActuatorType: ActuatorType,
) {
super(Index);
}
// Device Input Commands
export interface InputCmd {
DeviceIndex: number;
FeatureIndex: number;
Type: InputType;
Command: InputCommandType;
Id: number | undefined;
}
export class ScalarCmd extends ButtplugDeviceMessage {
static Name = "ScalarCmd";
constructor(
public Scalars: ScalarSubcommand[],
public DeviceIndex: number = -1,
public Id: number = DEFAULT_MESSAGE_ID,
) {
super(DeviceIndex, Id);
}
export interface InputValue {
Value: number;
}
export class RotateSubcommand extends GenericMessageSubcommand {
constructor(
Index: number,
public Speed: number,
public Clockwise: boolean,
) {
super(Index);
}
export interface InputReading {
DeviceIndex: number;
FeatureIndex: number;
Reading: { [key: string]: InputValue };
Id: number | undefined;
}
export class RotateCmd extends ButtplugDeviceMessage {
static Name = "RotateCmd";
public static Create(
deviceIndex: number,
commands: [number, boolean][],
): RotateCmd {
const cmdList: RotateSubcommand[] = new Array<RotateSubcommand>();
let i = 0;
for (const [speed, clockwise] of commands) {
cmdList.push(new RotateSubcommand(i, speed, clockwise));
++i;
}
return new RotateCmd(cmdList, deviceIndex);
}
constructor(
public Rotations: RotateSubcommand[],
public DeviceIndex: number = -1,
public Id: number = DEFAULT_MESSAGE_ID,
) {
super(DeviceIndex, Id);
}
}
export class VectorSubcommand extends GenericMessageSubcommand {
constructor(
Index: number,
public Position: number,
public Duration: number,
) {
super(Index);
}
}
export class LinearCmd extends ButtplugDeviceMessage {
static Name = "LinearCmd";
public static Create(
deviceIndex: number,
commands: [number, number][],
): LinearCmd {
const cmdList: VectorSubcommand[] = new Array<VectorSubcommand>();
let i = 0;
for (const cmd of commands) {
cmdList.push(new VectorSubcommand(i, cmd[0], cmd[1]));
++i;
}
return new LinearCmd(cmdList, deviceIndex);
}
constructor(
public Vectors: VectorSubcommand[],
public DeviceIndex: number = -1,
public Id: number = DEFAULT_MESSAGE_ID,
) {
super(DeviceIndex, Id);
}
}
export class SensorReadCmd extends ButtplugDeviceMessage {
static Name = "SensorReadCmd";
constructor(
public DeviceIndex: number,
public SensorIndex: number,
public SensorType: SensorType,
public Id: number = DEFAULT_MESSAGE_ID,
) {
super(DeviceIndex, Id);
}
}
export class SensorReading extends ButtplugDeviceMessage {
static Name = "SensorReading";
constructor(
public DeviceIndex: number,
public SensorIndex: number,
public SensorType: SensorType,
public Data: number[],
public Id: number = DEFAULT_MESSAGE_ID,
) {
super(DeviceIndex, Id);
}
}
export class RawReadCmd extends ButtplugDeviceMessage {
static Name = "RawReadCmd";
constructor(
public DeviceIndex: number,
public Endpoint: string,
public ExpectedLength: number,
public Timeout: number,
public Id: number = DEFAULT_MESSAGE_ID,
) {
super(DeviceIndex, Id);
}
}
export class RawWriteCmd extends ButtplugDeviceMessage {
static Name = "RawWriteCmd";
constructor(
public DeviceIndex: number,
public Endpoint: string,
public Data: Uint8Array,
public WriteWithResponse: boolean,
public Id: number = DEFAULT_MESSAGE_ID,
) {
super(DeviceIndex, Id);
}
}
export class RawSubscribeCmd extends ButtplugDeviceMessage {
static Name = "RawSubscribeCmd";
constructor(
public DeviceIndex: number,
public Endpoint: string,
public Id: number = DEFAULT_MESSAGE_ID,
) {
super(DeviceIndex, Id);
}
}
export class RawUnsubscribeCmd extends ButtplugDeviceMessage {
static Name = "RawUnsubscribeCmd";
constructor(
public DeviceIndex: number,
public Endpoint: string,
public Id: number = DEFAULT_MESSAGE_ID,
) {
super(DeviceIndex, Id);
}
}
export class RawReading extends ButtplugDeviceMessage {
static Name = "RawReading";
constructor(
public DeviceIndex: number,
public Endpoint: string,
public Data: number[],
public Id: number = DEFAULT_MESSAGE_ID,
) {
super(DeviceIndex, Id);
}
export interface StopCmd {
Id: number | undefined;
DeviceIndex: number | undefined;
FeatureIndex: number | undefined;
Inputs: boolean | undefined;
Outputs: boolean | undefined;
}

View File

@@ -1,19 +1,27 @@
import { ButtplugMessage } from "./core/Messages";
import { IButtplugClientConnector } from "./client/IButtplugClientConnector";
import { fromJSON } from "./core/MessageUtils";
import { EventEmitter } from "eventemitter3";
/*!
* Buttplug JS Source Code File - Visit https://buttplug.io for more info about
* the project. Licensed under the BSD 3-Clause license. See LICENSE file in the
* project root for full license information.
*
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/
export * from "./client/Client";
export * from "./client/ButtplugClientDevice";
export * from "./client/ButtplugBrowserWebsocketClientConnector";
export * from "./client/ButtplugNodeWebsocketClientConnector";
export * from "./client/ButtplugClientConnectorException";
export * from "./utils/ButtplugMessageSorter";
export * from "./client/IButtplugClientConnector";
export * from "./core/Messages";
export * from "./core/MessageUtils";
export * from "./core/Logging";
export * from "./core/Exceptions";
import { ButtplugMessage } from './core/Messages';
import { IButtplugClientConnector } from './client/IButtplugClientConnector';
import { EventEmitter } from 'eventemitter3';
export * from './client/ButtplugClient';
export * from './client/ButtplugClientDevice';
export * from './client/ButtplugBrowserWebsocketClientConnector';
export * from './client/ButtplugNodeWebsocketClientConnector';
export * from './client/ButtplugClientConnectorException';
export * from './utils/ButtplugMessageSorter';
export * from './client/ButtplugClientDeviceCommand';
export * from './client/ButtplugClientDeviceFeature';
export * from './client/IButtplugClientConnector';
export * from './core/Messages';
export * from './core/Logging';
export * from './core/Exceptions';
export class ButtplugWasmClientConnector
extends EventEmitter
@@ -36,18 +44,18 @@ export class ButtplugWasmClientConnector
private static maybeLoadWasm = async () => {
if (ButtplugWasmClientConnector.wasmInstance == undefined) {
ButtplugWasmClientConnector.wasmInstance = await import(
"../wasm/index.js"
'../wasm/index.js'
);
}
};
public static activateLogging = async (logLevel: string = "debug") => {
public static activateLogging = async (logLevel: string = 'debug') => {
await ButtplugWasmClientConnector.maybeLoadWasm();
if (this._loggingActivated) {
console.log("Logging already activated, ignoring.");
console.log('Logging already activated, ignoring.');
return;
}
console.log("Turning on logging.");
console.log('Turning on logging.');
ButtplugWasmClientConnector.wasmInstance.buttplug_activate_env_logger(
logLevel,
);
@@ -57,7 +65,6 @@ export class ButtplugWasmClientConnector
public connect = async (): Promise<void> => {
await ButtplugWasmClientConnector.maybeLoadWasm();
//ButtplugWasmClientConnector.wasmInstance.buttplug_activate_env_logger('debug');
this.client =
ButtplugWasmClientConnector.wasmInstance.buttplug_create_embedded_wasm_server(
(msgs) => {
@@ -73,7 +80,7 @@ export class ButtplugWasmClientConnector
public send = (msg: ButtplugMessage): void => {
ButtplugWasmClientConnector.wasmInstance.buttplug_client_send_json_message(
this.client,
new TextEncoder().encode("[" + msg.toJSON() + "]"),
new TextEncoder().encode('[' + JSON.stringify(msg) + ']'),
(output) => {
this.emitMessage(output);
},
@@ -82,7 +89,7 @@ export class ButtplugWasmClientConnector
private emitMessage = (msg: Uint8Array) => {
const str = new TextDecoder().decode(msg);
// This needs to use buttplug-js's fromJSON, otherwise we won't resolve the message name correctly.
this.emit("message", fromJSON(str));
const msgs: ButtplugMessage[] = JSON.parse(str);
this.emit('message', msgs);
};
}

View File

@@ -8,12 +8,16 @@ mod webbluetooth;
use js_sys;
use tokio_stream::StreamExt;
use crate::webbluetooth::{WebBluetoothCommunicationManagerBuilder};
use buttplug::{
core::message::{ButtplugServerMessageCurrent,serializer::vec_to_protocol_json},
server::{ButtplugServerBuilder,ButtplugServerDowngradeWrapper,device::{ServerDeviceManagerBuilder,configuration::{DeviceConfigurationManager}}},
util::async_manager, core::message::{BUTTPLUG_CURRENT_MESSAGE_SPEC_VERSION, ButtplugServerMessageVariant, serializer::{ButtplugSerializedMessage, ButtplugMessageSerializer, ButtplugServerJSONSerializer}},
util::device_configuration::load_protocol_configs
use buttplug_core::{
message::{ButtplugServerMessageCurrent, BUTTPLUG_CURRENT_API_MAJOR_VERSION, serializer::{ButtplugSerializedMessage, ButtplugMessageSerializer}},
util::async_manager,
};
use buttplug_server::{
ButtplugServerBuilder, ButtplugServer,
device::ServerDeviceManagerBuilder,
message::{ButtplugServerMessageVariant, serializer::ButtplugServerJSONSerializer},
};
use buttplug_server_device_config::{DeviceConfigurationManager, load_protocol_configs};
type FFICallback = js_sys::Function;
type FFICallbackContext = u32;
@@ -33,16 +37,17 @@ use wasm_bindgen::prelude::*;
use std::sync::Arc;
use js_sys::Uint8Array;
pub type ButtplugWASMServer = Arc<ButtplugServerDowngradeWrapper>;
pub type ButtplugWASMServer = Arc<ButtplugServer>;
pub fn send_server_message(
message: &ButtplugServerMessageCurrent,
callback: &FFICallback,
) {
let msg_array = [message.clone()];
let json_msg = vec_to_protocol_json(&msg_array);
let buf = json_msg.as_bytes();
{
let serializer = ButtplugServerJSONSerializer::default();
serializer.force_message_version(&BUTTPLUG_CURRENT_API_MAJOR_VERSION);
let json_msg = serializer.serialize(&[ButtplugServerMessageVariant::V4(message.clone())]);
if let ButtplugSerializedMessage::Text(json) = json_msg {
let buf = json.as_bytes();
let this = JsValue::null();
let uint8buf = unsafe { Uint8Array::new(&Uint8Array::view(buf)) };
callback.call1(&this, &JsValue::from(uint8buf));
@@ -50,10 +55,9 @@ pub fn send_server_message(
}
#[no_mangle]
pub fn create_test_dcm(allow_raw_messages: bool) -> DeviceConfigurationManager {
pub fn create_test_dcm(_allow_raw_messages: bool) -> DeviceConfigurationManager {
load_protocol_configs(&None, &None, false)
.expect("If this fails, the whole library goes with it.")
.allow_raw_messages(allow_raw_messages)
.finish()
.expect("If this fails, the whole library goes with it.")
}
@@ -68,18 +72,17 @@ pub fn buttplug_create_embedded_wasm_server(
let mut sdm = ServerDeviceManagerBuilder::new(dcm);
sdm.comm_manager(WebBluetoothCommunicationManagerBuilder::default());
let builder = ButtplugServerBuilder::new(sdm.finish().unwrap());
let server = builder.finish().unwrap();
let wrapper = Arc::new(ButtplugServerDowngradeWrapper::new(server));
let event_stream = wrapper.server_version_event_stream();
let server = Arc::new(builder.finish().unwrap());
let event_stream = server.server_version_event_stream();
let callback = callback.clone();
async_manager::spawn(async move {
pin_mut!(event_stream);
while let Some(message) = event_stream.next().await {
send_server_message(&ButtplugServerMessageCurrent::try_from(message).unwrap(), &callback);
send_server_message(&message, &callback);
}
});
Box::into_raw(Box::new(wrapper))
Box::into_raw(Box::new(server))
}
#[no_mangle]
@@ -106,15 +109,18 @@ pub fn buttplug_client_send_json_message(
};
let callback = callback.clone();
let serializer = ButtplugServerJSONSerializer::default();
serializer.force_message_version(&BUTTPLUG_CURRENT_MESSAGE_SPEC_VERSION);
serializer.force_message_version(&BUTTPLUG_CURRENT_API_MAJOR_VERSION);
let input_msg = serializer.deserialize(&ButtplugSerializedMessage::Text(std::str::from_utf8(buf).unwrap().to_owned())).unwrap();
async_manager::spawn(async move {
let msg = input_msg[0].clone();
let response = server.parse_message(msg).await.unwrap();
if let ButtplugServerMessageVariant::V3(response) = response {
send_server_message(&response, &callback);
let json_msg = serializer.serialize(&[response]);
if let ButtplugSerializedMessage::Text(json) = json_msg {
let buf = json.as_bytes();
let this = JsValue::null();
let uint8buf = unsafe { Uint8Array::new(&Uint8Array::view(buf)) };
callback.call1(&this, &JsValue::from(uint8buf));
}
});
}

View File

@@ -6,11 +6,10 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/
"use strict";
'use strict';
import { EventEmitter } from "eventemitter3";
import { ButtplugMessage } from "../core/Messages";
import { fromJSON } from "../core/MessageUtils";
import { EventEmitter } from 'eventemitter3';
import { ButtplugMessage } from '../core/Messages';
export class ButtplugBrowserWebsocketConnector extends EventEmitter {
protected _ws: WebSocket | undefined;
@@ -27,20 +26,18 @@ export class ButtplugBrowserWebsocketConnector extends EventEmitter {
public connect = async (): Promise<void> => {
return new Promise<void>((resolve, reject) => {
const ws = new (this._websocketConstructor ?? WebSocket)(this._url);
const onErrorCallback = (event: Event) => {
reject(event);
};
const onCloseCallback = (event: CloseEvent) => reject(event.reason);
ws.addEventListener("open", async () => {
const onErrorCallback = (event: Event) => {reject(event)}
const onCloseCallback = (event: CloseEvent) => reject(event.reason)
ws.addEventListener('open', async () => {
this._ws = ws;
try {
await this.initialize();
this._ws.addEventListener("message", (msg) => {
this._ws.addEventListener('message', (msg) => {
this.parseIncomingMessage(msg);
});
this._ws.removeEventListener("close", onCloseCallback);
this._ws.removeEventListener("error", onErrorCallback);
this._ws.addEventListener("close", this.disconnect);
this._ws.removeEventListener('close', onCloseCallback);
this._ws.removeEventListener('error', onErrorCallback);
this._ws.addEventListener('close', this.disconnect);
resolve();
} catch (e) {
reject(e);
@@ -50,8 +47,8 @@ export class ButtplugBrowserWebsocketConnector extends EventEmitter {
// browsers usually only throw Error Code 1006. It's up to those using this
// library to state what the problem might be.
ws.addEventListener("error", onErrorCallback);
ws.addEventListener("close", onCloseCallback);
ws.addEventListener('error', onErrorCallback)
ws.addEventListener('close', onCloseCallback);
});
};
@@ -61,14 +58,14 @@ export class ButtplugBrowserWebsocketConnector extends EventEmitter {
}
this._ws!.close();
this._ws = undefined;
this.emit("disconnect");
this.emit('disconnect');
};
public sendMessage(msg: ButtplugMessage) {
if (!this.Connected) {
throw new Error("ButtplugBrowserWebsocketConnector not connected");
throw new Error('ButtplugBrowserWebsocketConnector not connected');
}
this._ws!.send("[" + msg.toJSON() + "]");
this._ws!.send('[' + JSON.stringify(msg) + ']');
}
public initialize = async (): Promise<void> => {
@@ -76,16 +73,16 @@ export class ButtplugBrowserWebsocketConnector extends EventEmitter {
};
protected parseIncomingMessage(event: MessageEvent) {
if (typeof event.data === "string") {
const msgs = fromJSON(event.data);
this.emit("message", msgs);
if (typeof event.data === 'string') {
const msgs: ButtplugMessage[] = JSON.parse(event.data);
this.emit('message', msgs);
} else if (event.data instanceof Blob) {
// No-op, we only use text message types.
}
}
protected onReaderLoad(event: Event) {
const msgs = fromJSON((event.target as FileReader).result);
this.emit("message", msgs);
const msgs: ButtplugMessage[] = JSON.parse((event.target as FileReader).result as string);
this.emit('message', msgs);
}
}

View File

@@ -6,8 +6,8 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/
import * as Messages from "../core/Messages";
import { ButtplugError } from "../core/Exceptions";
import * as Messages from '../core/Messages';
import { ButtplugError } from '../core/Exceptions';
export class ButtplugMessageSorter {
protected _counter = 1;
@@ -22,10 +22,10 @@ export class ButtplugMessageSorter {
// them while waiting for them to return across the line.
// tslint:disable:promise-function-async
public PrepareOutgoingMessage(
msg: Messages.ButtplugMessage,
msg: Messages.ButtplugMessage
): Promise<Messages.ButtplugMessage> {
if (this._useCounter) {
msg.Id = this._counter;
Messages.setMsgId(msg, this._counter);
// Always increment last, otherwise we might lose sync
this._counter += 1;
}
@@ -35,23 +35,25 @@ export class ButtplugMessageSorter {
(resolve, reject) => {
res = resolve;
rej = reject;
},
}
);
this._waitingMsgs.set(msg.Id, [res, rej]);
this._waitingMsgs.set(Messages.msgId(msg), [res, rej]);
return msgPromise;
}
public ParseIncomingMessages(
msgs: Messages.ButtplugMessage[],
msgs: Messages.ButtplugMessage[]
): Messages.ButtplugMessage[] {
const noMatch: Messages.ButtplugMessage[] = [];
for (const x of msgs) {
if (x.Id !== Messages.SYSTEM_MESSAGE_ID && this._waitingMsgs.has(x.Id)) {
const [res, rej] = this._waitingMsgs.get(x.Id)!;
let id = Messages.msgId(x);
if (id !== Messages.SYSTEM_MESSAGE_ID && this._waitingMsgs.has(id)) {
const [res, rej] = this._waitingMsgs.get(id)!;
this._waitingMsgs.delete(id);
// If we've gotten back an error, reject the related promise using a
// ButtplugException derived type.
if (x.Type === Messages.Error) {
rej(ButtplugError.FromError(x as Messages.Error));
if (x.Error !== undefined) {
rej(ButtplugError.FromError(x.Error!));
continue;
}
res(x);

View File

@@ -1,12 +1,7 @@
use async_trait::async_trait;
use buttplug::{
core::{
errors::ButtplugDeviceError,
message::Endpoint,
},
server::device::{
configuration::{BluetoothLESpecifier, ProtocolCommunicationSpecifier},
hardware::{
use buttplug_core::errors::ButtplugDeviceError;
use buttplug_server_device_config::{BluetoothLESpecifier, Endpoint, ProtocolCommunicationSpecifier};
use buttplug_server::device::hardware::{
Hardware,
HardwareConnector,
HardwareEvent,
@@ -17,9 +12,6 @@ use buttplug::{
HardwareSubscribeCmd,
HardwareUnsubscribeCmd,
HardwareWriteCmd,
},
},
util::future::{ButtplugFuture, ButtplugFutureStateShared},
};
use futures::future::{self, BoxFuture};
use js_sys::{DataView, Uint8Array};
@@ -28,7 +20,7 @@ use std::{
convert::TryFrom,
fmt::{self, Debug},
};
use tokio::sync::{broadcast, mpsc};
use tokio::sync::{broadcast, mpsc, oneshot};
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::{spawn_local, JsFuture};
@@ -41,9 +33,6 @@ use web_sys::{
MessageEvent,
};
type WebBluetoothResultFuture = ButtplugFuture<Result<(), ButtplugDeviceError>>;
type WebBluetoothReadResultFuture = ButtplugFuture<Result<HardwareReading, ButtplugDeviceError>>;
struct BluetoothDeviceWrapper {
pub device: BluetoothDevice
}
@@ -179,7 +168,7 @@ impl HardwareSpecializer for WebBluetoothHardwareSpecializer {
receiver,
command_sender,
));
Ok(Hardware::new(&name, &address, &[], device_impl))
Ok(Hardware::new(&name, &address, &[], &None, false, device_impl))
}
WebBluetoothEvent::Disconnected => Err(
ButtplugDeviceError::DeviceCommunicationError(
@@ -202,19 +191,19 @@ pub enum WebBluetoothEvent {
pub enum WebBluetoothDeviceCommand {
Write(
HardwareWriteCmd,
ButtplugFutureStateShared<Result<(), ButtplugDeviceError>>,
oneshot::Sender<Result<(), ButtplugDeviceError>>,
),
Read(
HardwareReadCmd,
ButtplugFutureStateShared<Result<HardwareReading, ButtplugDeviceError>>,
oneshot::Sender<Result<HardwareReading, ButtplugDeviceError>>,
),
Subscribe(
HardwareSubscribeCmd,
ButtplugFutureStateShared<Result<(), ButtplugDeviceError>>,
oneshot::Sender<Result<(), ButtplugDeviceError>>,
),
Unsubscribe(
HardwareUnsubscribeCmd,
ButtplugFutureStateShared<Result<(), ButtplugDeviceError>>,
oneshot::Sender<Result<(), ButtplugDeviceError>>,
),
}
@@ -287,7 +276,7 @@ async fn run_webbluetooth_loop(
.await;
while let Some(msg) = device_command_receiver.recv().await {
match msg {
WebBluetoothDeviceCommand::Write(write_cmd, waker) => {
WebBluetoothDeviceCommand::Write(write_cmd, sender) => {
debug!("Writing to endpoint {:?}", write_cmd.endpoint());
let chr = char_map.get(&write_cmd.endpoint()).unwrap().clone();
spawn_local(async move {
@@ -295,10 +284,10 @@ async fn run_webbluetooth_loop(
JsFuture::from(chr.write_value_with_u8_array(&uint8buf).unwrap())
.await
.unwrap();
waker.set_reply(Ok(()));
let _ = sender.send(Ok(()));
});
}
WebBluetoothDeviceCommand::Read(read_cmd, waker) => {
WebBluetoothDeviceCommand::Read(read_cmd, sender) => {
debug!("Writing to endpoint {:?}", read_cmd.endpoint());
let chr = char_map.get(&read_cmd.endpoint()).unwrap().clone();
spawn_local(async move {
@@ -307,10 +296,10 @@ async fn run_webbluetooth_loop(
let mut body = vec![0; data_view.byte_length() as usize];
Uint8Array::new(&data_view).copy_to(&mut body[..]);
let reading = HardwareReading::new(read_cmd.endpoint(), &body);
waker.set_reply(Ok(reading));
let _ = sender.send(Ok(reading));
});
}
WebBluetoothDeviceCommand::Subscribe(subscribe_cmd, waker) => {
WebBluetoothDeviceCommand::Subscribe(subscribe_cmd, sender) => {
debug!("Subscribing to endpoint {:?}", subscribe_cmd.endpoint());
let chr = char_map.get(&subscribe_cmd.endpoint()).unwrap().clone();
let ep = subscribe_cmd.endpoint();
@@ -335,10 +324,10 @@ async fn run_webbluetooth_loop(
spawn_local(async move {
JsFuture::from(chr.start_notifications()).await.unwrap();
debug!("Endpoint subscribed");
waker.set_reply(Ok(()));
let _ = sender.send(Ok(()));
});
}
WebBluetoothDeviceCommand::Unsubscribe(_unsubscribe_cmd, _waker) => {}
WebBluetoothDeviceCommand::Unsubscribe(_unsubscribe_cmd, _sender) => {}
}
}
debug!("run_webbluetooth_loop exited!");
@@ -388,12 +377,13 @@ impl HardwareInternal for WebBluetoothHardware {
let sender = self.device_command_sender.clone();
let msg = msg.clone();
Box::pin(async move {
let fut = WebBluetoothReadResultFuture::default();
let waker = fut.get_state_clone();
sender
.send(WebBluetoothDeviceCommand::Read(msg, waker))
let (tx, rx) = oneshot::channel();
let _ = sender
.send(WebBluetoothDeviceCommand::Read(msg, tx))
.await;
fut.await
rx.await.unwrap_or(Err(ButtplugDeviceError::DeviceCommunicationError(
"Device command channel closed".to_string(),
)))
})
}
@@ -401,12 +391,13 @@ impl HardwareInternal for WebBluetoothHardware {
let sender = self.device_command_sender.clone();
let msg = msg.clone();
Box::pin(async move {
let fut = WebBluetoothResultFuture::default();
let waker = fut.get_state_clone();
sender
.send(WebBluetoothDeviceCommand::Write(msg.clone(), waker))
let (tx, rx) = oneshot::channel();
let _ = sender
.send(WebBluetoothDeviceCommand::Write(msg, tx))
.await;
fut.await
rx.await.unwrap_or(Err(ButtplugDeviceError::DeviceCommunicationError(
"Device command channel closed".to_string(),
)))
})
}
@@ -414,12 +405,13 @@ impl HardwareInternal for WebBluetoothHardware {
let sender = self.device_command_sender.clone();
let msg = msg.clone();
Box::pin(async move {
let fut = WebBluetoothResultFuture::default();
let waker = fut.get_state_clone();
sender
.send(WebBluetoothDeviceCommand::Subscribe(msg.clone(), waker))
let (tx, rx) = oneshot::channel();
let _ = sender
.send(WebBluetoothDeviceCommand::Subscribe(msg, tx))
.await;
fut.await
rx.await.unwrap_or(Err(ButtplugDeviceError::DeviceCommunicationError(
"Device command channel closed".to_string(),
)))
})
}

View File

@@ -1,14 +1,10 @@
use super::webbluetooth_hardware::WebBluetoothHardwareConnector;
use buttplug::{
core::ButtplugResultFuture,
server::device::{
configuration::{ProtocolCommunicationSpecifier},
hardware::communication::{
use buttplug_core::ButtplugResultFuture;
use buttplug_server_device_config::ProtocolCommunicationSpecifier;
use buttplug_server::device::hardware::communication::{
HardwareCommunicationManager, HardwareCommunicationManagerBuilder,
HardwareCommunicationManagerEvent,
},
}
};
use futures::future;
use js_sys::Array;
@@ -69,8 +65,8 @@ impl HardwareCommunicationManager for WebBluetoothCommunicationManager {
let options = web_sys::RequestDeviceOptions::new();
let filters = Array::new();
let optional_services = Array::new();
for vals in config_manager.protocol_device_configurations().iter() {
for config in vals.1 {
for vals in config_manager.base_communication_specifiers().iter() {
for config in vals.1.iter() {
if let ProtocolCommunicationSpecifier::BluetoothLE(btle) = &config {
for name in btle.names() {
let filter = web_sys::BluetoothLeScanFilterInit::new();

View File

@@ -5,9 +5,7 @@
"outDir": "dist",
"moduleResolution": "bundler",
"esModuleInterop": true,
"skipLibCheck": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true
"skipLibCheck": true
},
"include": ["src"]
}

View File

@@ -14,5 +14,8 @@ export default defineConfig({
minify: false, // for demo purposes
target: "esnext", // this is important as well
outDir: "dist",
rollupOptions: {
external: [/\.\/wasm\//, /\.\.\/wasm\//],
},
},
});

View File

@@ -36,7 +36,8 @@
"tailwindcss": "^4.0.0",
"tw-animate-css": "^1.3.8",
"typescript": "^5.9.2",
"vite": "^7.1.4"
"vite": "^7.1.4",
"vite-plugin-wasm": "3.5.0"
},
"dependencies": {
"@directus/sdk": "^20.0.3",

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,49 @@ 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),
);
});
// Try to read battery level — access through the reactive array so Svelte detects the mutation
const idx = devices.length - 1;
if (device.hasBattery) {
try {
devices[idx].batteryLevel = await dev.battery();
} catch (e) {
console.warn(`Failed to read battery for ${dev.name}:`, e);
}
}
}
async function handleMessages(messages: ButtplugMessage[]) {
messages.forEach(async (msg) => {
await handleMessage(msg);
});
}
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;
async function handleMessage(msg: ButtplugMessage) {
if (msg instanceof SensorReading) {
const device = devices[msg.DeviceIndex];
if (msg.SensorType === SensorType.Battery) {
device.batteryLevel = msg.Data[0];
if (reading.Reading[InputType.Battery] !== undefined) {
device.batteryLevel = reading.Reading[InputType.Battery].Value;
}
device.sensorValues[msg.Index] = msg.Data[0];
device.lastSeen = new Date();
} else if (msg instanceof DeviceList) {
devices = client.devices.map(convertDevice);
}
}
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 +125,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 +182,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 +332,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 +594,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

@@ -2,9 +2,10 @@ import path from "path";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
import { sveltekit } from "@sveltejs/kit/vite";
import wasm from 'vite-plugin-wasm';
export default defineConfig({
plugins: [sveltekit(), tailwindcss()],
plugins: [sveltekit(), tailwindcss(), wasm()],
resolve: {
alias: { $lib: path.resolve("./src/lib"), "@": path.resolve("./src/lib") },
},

23
pnpm-lock.yaml generated
View File

@@ -23,15 +23,9 @@ importers:
packages/buttplug:
dependencies:
class-transformer:
specifier: ^0.5.1
version: 0.5.1
eventemitter3:
specifier: ^5.0.1
version: 5.0.1
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
typescript:
specifier: ^5.9.2
version: 5.9.3
@@ -145,6 +139,9 @@ importers:
vite:
specifier: ^7.1.4
version: 7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)
vite-plugin-wasm:
specifier: 3.5.0
version: 3.5.0(vite@7.1.12(@types/node@24.9.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0))
packages:
@@ -1650,9 +1647,6 @@ packages:
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
engines: {node: '>=10'}
class-transformer@0.5.1:
resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==}
cli-color@2.0.4:
resolution: {integrity: sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA==}
engines: {node: '>=0.10'}
@@ -2099,11 +2093,12 @@ packages:
glob@11.0.3:
resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==}
engines: {node: 20 || >=22}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
globals@15.15.0:
resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==}
@@ -2857,9 +2852,6 @@ packages:
resolution: {integrity: sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w==}
engines: {node: '>=6'}
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
relative-time-format@1.1.11:
resolution: {integrity: sha512-TH+oV/w77hjaB9xCzoFYJ/Icmr/12+02IAoCI/YGS2UBTbjCbBjHGEBxGnVy4EJvOR1qadGzyFRI6hGaJJG93Q==}
@@ -3124,6 +3116,7 @@ packages:
tar@6.2.1:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
tarn@3.0.2:
resolution: {integrity: sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==}
@@ -4710,8 +4703,6 @@ snapshots:
chownr@2.0.0: {}
class-transformer@0.5.1: {}
cli-color@2.0.4:
dependencies:
d: 1.0.2
@@ -5897,8 +5888,6 @@ snapshots:
reduce-flatten@2.0.0: {}
reflect-metadata@0.2.2: {}
relative-time-format@1.1.11: {}
resolve-from@4.0.0: {}