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

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

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

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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "buttplug_wasm" name = "buttplug_wasm"
version = "9.0.9" version = "10.0.0"
authors = ["Nonpolynomial Labs, LLC <kyle@nonpolynomial.com>"] authors = ["Nonpolynomial Labs, LLC <kyle@nonpolynomial.com>"]
description = "WASM Interop for the Buttplug Intimate Hardware Control Library" description = "WASM Interop for the Buttplug Intimate Hardware Control Library"
license = "BSD-3-Clause" license = "BSD-3-Clause"
@@ -16,9 +16,9 @@ name = "buttplug_wasm"
path = "src/lib.rs" path = "src/lib.rs"
[dependencies] [dependencies]
buttplug = { version = "9.0.9", default-features = false, features = ["wasm"] } buttplug_core = { git = "https://github.com/valknarthing/buttplug.git", default-features = false, features = ["wasm"] }
# buttplug = { path = "../../../buttplug/buttplug", default-features = false, features = ["wasm"] } buttplug_server = { git = "https://github.com/valknarthing/buttplug.git", default-features = false, features = ["wasm"] }
# buttplug_derive = { path = "../buttplug_derive" } buttplug_server_device_config = { git = "https://github.com/valknarthing/buttplug.git" }
js-sys = "0.3.80" js-sys = "0.3.80"
tracing-wasm = "0.2.1" tracing-wasm = "0.2.1"
log-panics = { version = "2.1.0", features = ["with-backtrace"] } 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" "build:wasm": "wasm-pack build --out-dir wasm --out-name index --target bundler --release"
}, },
"dependencies": { "dependencies": {
"class-transformer": "^0.5.1",
"eventemitter3": "^5.0.1", "eventemitter3": "^5.0.1",
"reflect-metadata": "^0.2.2",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"vite": "^7.1.4", "vite": "^7.1.4",
"vite-plugin-wasm": "3.5.0", "vite-plugin-wasm": "3.5.0",

View File

@@ -6,20 +6,20 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved. * @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/ */
"use strict"; 'use strict';
import { IButtplugClientConnector } from "./IButtplugClientConnector"; import { IButtplugClientConnector } from './IButtplugClientConnector';
import { ButtplugMessage } from "../core/Messages"; import { ButtplugMessage } from '../core/Messages';
import { ButtplugBrowserWebsocketConnector } from "../utils/ButtplugBrowserWebsocketConnector"; import { ButtplugBrowserWebsocketConnector } from '../utils/ButtplugBrowserWebsocketConnector';
export class ButtplugBrowserWebsocketClientConnector export class ButtplugBrowserWebsocketClientConnector
extends ButtplugBrowserWebsocketConnector extends ButtplugBrowserWebsocketConnector
implements IButtplugClientConnector implements IButtplugClientConnector
{ {
public send = (msg: ButtplugMessage): void => { public send = (msg: ButtplugMessage): void => {
if (!this.Connected) { if (!this.Connected) {
throw new Error("ButtplugClient not connected"); throw new Error('ButtplugClient not connected');
} }
this.sendMessage(msg); 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 as Messages.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,11 +6,11 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved. * @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/ */
import { ButtplugError } from "../core/Exceptions"; import { ButtplugError } from '../core/Exceptions';
import * as Messages from "../core/Messages"; import * as Messages from '../core/Messages';
export class ButtplugClientConnectorException extends ButtplugError { export class ButtplugClientConnectorException extends ButtplugError {
public constructor(message: string) { public constructor(message: string) {
super(message, Messages.ErrorClass.ERROR_UNKNOWN); super(message, Messages.ErrorClass.ERROR_UNKNOWN);
} }
} }

View File

@@ -6,396 +6,160 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved. * @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/ */
"use strict"; 'use strict';
import * as Messages from "../core/Messages"; import * as Messages from '../core/Messages';
import { import {
ButtplugDeviceError, ButtplugDeviceError,
ButtplugError, ButtplugError,
ButtplugMessageError, ButtplugMessageError,
} from "../core/Exceptions"; } from '../core/Exceptions';
import { EventEmitter } from "eventemitter3"; import { EventEmitter } from 'eventemitter3';
import { getMessageClassFromMessage } from "../core/MessageUtils"; import { ButtplugClientDeviceFeature } from './ButtplugClientDeviceFeature';
import { DeviceOutputCommand } from './ButtplugClientDeviceCommand';
/** /**
* Represents an abstract device, capable of taking certain kinds of messages. * Represents an abstract device, capable of taking certain kinds of messages.
*/ */
export class ButtplugClientDevice extends EventEmitter { export class ButtplugClientDevice extends EventEmitter {
/**
* Return the name of the device.
*/
public get name(): string {
return this._deviceInfo.DeviceName;
}
/** private _features: Map<number, ButtplugClientDeviceFeature>;
* Return the user set name of the device.
*/
public get displayName(): string | undefined {
return this._deviceInfo.DeviceDisplayName;
}
/** /**
* Return the index of the device. * Return the name of the device.
*/ */
public get index(): number { public get name(): string {
return this._deviceInfo.DeviceIndex; return this._deviceInfo.DeviceName;
} }
/** /**
* Return the index of the device. * Return the user set name of the device.
*/ */
public get messageTimingGap(): number | undefined { public get displayName(): string | undefined {
return this._deviceInfo.DeviceMessageTimingGap; return this._deviceInfo.DeviceDisplayName;
} }
/** /**
* Return a list of message types the device accepts. * Return the index of the device.
*/ */
public get messageAttributes(): Messages.MessageAttributes { public get index(): number {
return this._deviceInfo.DeviceMessages; return this._deviceInfo.DeviceIndex;
} }
public static fromMsg( /**
msg: Messages.DeviceInfo, * Return the index of the device.
sendClosure: ( */
device: ButtplugClientDevice, public get messageTimingGap(): number | undefined {
msg: Messages.ButtplugDeviceMessage, return this._deviceInfo.DeviceMessageTimingGap;
) => Promise<Messages.ButtplugMessage>, }
): ButtplugClientDevice {
return new ButtplugClientDevice(msg, sendClosure);
}
// Map of messages and their attributes (feature count, etc...) public get features(): Map<number, ButtplugClientDeviceFeature> {
private allowedMsgs: Map<string, Messages.MessageAttributes> = new Map< return this._features;
string, }
Messages.MessageAttributes
>();
/** public static fromMsg(
* @param _index Index of the device, as created by the device manager. msg: Messages.DeviceInfo,
* @param _name Name of the device. sendClosure: (
* @param allowedMsgs Buttplug messages the device can receive. msg: Messages.ButtplugMessage
*/ ) => Promise<Messages.ButtplugMessage>
constructor( ): ButtplugClientDevice {
private _deviceInfo: Messages.DeviceInfo, return new ButtplugClientDevice(msg, sendClosure);
private _sendClosure: ( }
device: ButtplugClientDevice,
msg: Messages.ButtplugDeviceMessage,
) => Promise<Messages.ButtplugMessage>,
) {
super();
_deviceInfo.DeviceMessages.update();
}
public async send( /**
msg: Messages.ButtplugDeviceMessage, * @param _index Index of the device, as created by the device manager.
): Promise<Messages.ButtplugMessage> { * @param _name Name of the device.
// Assume we're getting the closure from ButtplugClient, which does all of * @param allowedMsgs Buttplug messages the device can receive.
// the index/existence/connection/message checks for us. */
return await this._sendClosure(this, msg); private constructor(
} private _deviceInfo: Messages.DeviceInfo,
private _sendClosure: (
msg: Messages.ButtplugMessage
) => Promise<Messages.ButtplugMessage>
) {
super();
this._features = new Map(Object.entries(_deviceInfo.DeviceFeatures).map(([index, v]) => [parseInt(index), new ButtplugClientDeviceFeature(_deviceInfo.DeviceIndex, _deviceInfo.DeviceName, v, _sendClosure)]));
}
public async sendExpectOk( public async send(
msg: Messages.ButtplugDeviceMessage, msg: Messages.ButtplugMessage
): Promise<void> { ): Promise<Messages.ButtplugMessage> {
const response = await this.send(msg); // Assume we're getting the closure from ButtplugClient, which does all of
switch (getMessageClassFromMessage(response)) { // the index/existence/connection/message checks for us.
case Messages.Ok: return await this._sendClosure(msg);
return; }
case Messages.Error:
throw ButtplugError.FromError(response as Messages.Error);
default:
throw new ButtplugMessageError(
`Message type ${response.constructor} not handled by SendMsgExpectOk`,
);
}
}
public async scalar( protected sendMsgExpectOk = async (
scalar: Messages.ScalarSubcommand | Messages.ScalarSubcommand[], msg: Messages.ButtplugMessage
): Promise<void> { ): Promise<void> => {
if (Array.isArray(scalar)) { const response = await this.send(msg);
await this.sendExpectOk(new Messages.ScalarCmd(scalar, this.index)); if (response.Ok !== undefined) {
} else { return;
await this.sendExpectOk(new Messages.ScalarCmd([scalar], this.index)); } 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`
);
*/
}
};
private async scalarCommandBuilder( protected isOutputValid(featureIndex: number, type: Messages.OutputType) {
speed: number | number[], if (!this._deviceInfo.DeviceFeatures.hasOwnProperty(featureIndex.toString())) {
actuator: Messages.ActuatorType, throw new ButtplugDeviceError(`Feature index ${featureIndex} does not exist for device ${this.name}`);
) { }
const scalarAttrs = this.messageAttributes.ScalarCmd?.filter( if (this._deviceInfo.DeviceFeatures[featureIndex.toString()].Outputs !== undefined && !this._deviceInfo.DeviceFeatures[featureIndex.toString()].Outputs.hasOwnProperty(type)) {
(x) => x.ActuatorType === actuator, throw new ButtplugDeviceError(`Feature index ${featureIndex} does not support type ${type} for device ${this.name}`);
); }
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 get vibrateAttributes(): Messages.GenericDeviceMessageAttributes[] { public hasOutput(type: Messages.OutputType): boolean {
return ( return this._features.values().filter((f) => f.hasOutput(type)).toArray().length > 0;
this.messageAttributes.ScalarCmd?.filter( }
(x) => x.ActuatorType === Messages.ActuatorType.Vibrate,
) ?? []
);
}
public async vibrate(speed: number | number[]): Promise<void> { public hasInput(type: Messages.InputType): boolean {
await this.scalarCommandBuilder(speed, Messages.ActuatorType.Vibrate); return this._features.values().filter((f) => f.hasInput(type)).toArray().length > 0;
} }
public get oscillateAttributes(): Messages.GenericDeviceMessageAttributes[] { public async runOutput(cmd: DeviceOutputCommand): Promise<void> {
return ( let p: Promise<void>[] = [];
this.messageAttributes.ScalarCmd?.filter( for (let f of this._features.values()) {
(x) => x.ActuatorType === Messages.ActuatorType.Oscillate, if (f.hasOutput(cmd.outputType)) {
) ?? [] p.push(f.runOutput(cmd));
); }
} }
if (p.length == 0) {
return Promise.reject(`No features with output type ${cmd.outputType}`);
}
await Promise.all(p);
}
public async oscillate(speed: number | number[]): Promise<void> { public async stop(): Promise<void> {
await this.scalarCommandBuilder(speed, Messages.ActuatorType.Oscillate); await this.sendMsgExpectOk({StopCmd: { Id: 1, DeviceIndex: this.index, FeatureIndex: undefined, Inputs: true, Outputs: true}});
} }
public get rotateAttributes(): Messages.GenericDeviceMessageAttributes[] { public async battery(): Promise<number> {
return this.messageAttributes.RotateCmd ?? []; 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 async rotate( public emitDisconnected() {
values: number | [number, boolean][], this.emit('deviceremoved');
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 get hasBattery(): boolean {
const batteryAttrs = this.messageAttributes.SensorReadCmd?.filter(
(x) => x.SensorType === Messages.SensorType.Battery,
);
return batteryAttrs !== undefined && batteryAttrs.length > 0;
}
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),
);
}
public async stop(): Promise<void> {
await this.sendExpectOk(new Messages.StopDeviceCmd(this.index));
}
public emitDisconnected() {
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.FeatureDescriptor;
}
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,12 +6,12 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved. * @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/ */
"use strict"; 'use strict';
import { ButtplugBrowserWebsocketClientConnector } from "./ButtplugBrowserWebsocketClientConnector"; import { ButtplugBrowserWebsocketClientConnector } from './ButtplugBrowserWebsocketClientConnector';
import { WebSocket as NodeWebSocket } from "ws"; import { WebSocket as NodeWebSocket } from 'ws';
export class ButtplugNodeWebsocketClientConnector extends ButtplugBrowserWebsocketClientConnector { export class ButtplugNodeWebsocketClientConnector extends ButtplugBrowserWebsocketClientConnector {
protected _websocketConstructor = protected _websocketConstructor =
NodeWebSocket as unknown as typeof WebSocket; NodeWebSocket as unknown as typeof WebSocket;
} }

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,13 +6,13 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved. * @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/ */
import { ButtplugMessage } from "../core/Messages"; import { ButtplugMessage } from '../core/Messages';
import { EventEmitter } from "eventemitter3"; import { EventEmitter } from 'eventemitter3';
export interface IButtplugClientConnector extends EventEmitter { export interface IButtplugClientConnector extends EventEmitter {
connect: () => Promise<void>; connect: () => Promise<void>;
disconnect: () => Promise<void>; disconnect: () => Promise<void>;
initialize: () => Promise<void>; initialize: () => Promise<void>;
send: (msg: ButtplugMessage) => void; send: (msg: ButtplugMessage) => void;
readonly Connected: boolean; readonly Connected: boolean;
} }

View File

@@ -6,96 +6,102 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved. * @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/ */
import * as Messages from "./Messages"; import * as Messages from './Messages';
import { ButtplugLogger } from "./Logging"; import { ButtplugLogger } from './Logging';
export class ButtplugError extends Error { export class ButtplugError extends Error {
public get ErrorClass(): Messages.ErrorClass { public get ErrorClass(): Messages.ErrorClass {
return this.errorClass; return this.errorClass;
} }
public get InnerError(): Error | undefined { public get InnerError(): Error | undefined {
return this.innerError; return this.innerError;
} }
public get Id(): number | undefined { public get Id(): number | undefined {
return this.messageId; return this.messageId;
} }
public get ErrorMessage(): Messages.ButtplugMessage { 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>( public static LogAndError<T extends ButtplugError>(
constructor: new (str: string, num: number) => T, constructor: new (str: string, num: number) => T,
logger: ButtplugLogger, logger: ButtplugLogger,
message: string, message: string,
id: number = Messages.SYSTEM_MESSAGE_ID, id: number = Messages.SYSTEM_MESSAGE_ID
): T { ): T {
logger.Error(message); logger.Error(message);
return new constructor(message, id); return new constructor(message, id);
} }
public static FromError(error: Messages.Error) { public static FromError(error: Messages.Error) {
switch (error.ErrorCode) { switch (error.ErrorCode) {
case Messages.ErrorClass.ERROR_DEVICE: case Messages.ErrorClass.ERROR_DEVICE:
return new ButtplugDeviceError(error.ErrorMessage, error.Id); return new ButtplugDeviceError(error.ErrorMessage, error.Id);
case Messages.ErrorClass.ERROR_INIT: case Messages.ErrorClass.ERROR_INIT:
return new ButtplugInitError(error.ErrorMessage, error.Id); return new ButtplugInitError(error.ErrorMessage, error.Id);
case Messages.ErrorClass.ERROR_UNKNOWN: case Messages.ErrorClass.ERROR_UNKNOWN:
return new ButtplugUnknownError(error.ErrorMessage, error.Id); return new ButtplugUnknownError(error.ErrorMessage, error.Id);
case Messages.ErrorClass.ERROR_PING: case Messages.ErrorClass.ERROR_PING:
return new ButtplugPingError(error.ErrorMessage, error.Id); return new ButtplugPingError(error.ErrorMessage, error.Id);
case Messages.ErrorClass.ERROR_MSG: case Messages.ErrorClass.ERROR_MSG:
return new ButtplugMessageError(error.ErrorMessage, error.Id); return new ButtplugMessageError(error.ErrorMessage, error.Id);
default: default:
throw new Error(`Message type ${error.ErrorCode} not handled`); throw new Error(`Message type ${error.ErrorCode} not handled`);
} }
} }
public errorClass: Messages.ErrorClass = Messages.ErrorClass.ERROR_UNKNOWN; public errorClass: Messages.ErrorClass = Messages.ErrorClass.ERROR_UNKNOWN;
public innerError: Error | undefined; public innerError: Error | undefined;
public messageId: number | undefined; public messageId: number | undefined;
protected constructor( protected constructor(
message: string, message: string,
errorClass: Messages.ErrorClass, errorClass: Messages.ErrorClass,
id: number = Messages.SYSTEM_MESSAGE_ID, id: number = Messages.SYSTEM_MESSAGE_ID,
inner?: Error, inner?: Error
) { ) {
super(message); super(message);
this.errorClass = errorClass; this.errorClass = errorClass;
this.innerError = inner; this.innerError = inner;
this.messageId = id; this.messageId = id;
} }
} }
export class ButtplugInitError extends ButtplugError { export class ButtplugInitError extends ButtplugError {
public constructor(message: string, id: number = Messages.SYSTEM_MESSAGE_ID) { public constructor(message: string, id: number = Messages.SYSTEM_MESSAGE_ID) {
super(message, Messages.ErrorClass.ERROR_INIT, id); super(message, Messages.ErrorClass.ERROR_INIT, id);
} }
} }
export class ButtplugDeviceError extends ButtplugError { export class ButtplugDeviceError extends ButtplugError {
public constructor(message: string, id: number = Messages.SYSTEM_MESSAGE_ID) { public constructor(message: string, id: number = Messages.SYSTEM_MESSAGE_ID) {
super(message, Messages.ErrorClass.ERROR_DEVICE, id); super(message, Messages.ErrorClass.ERROR_DEVICE, id);
} }
} }
export class ButtplugMessageError extends ButtplugError { export class ButtplugMessageError extends ButtplugError {
public constructor(message: string, id: number = Messages.SYSTEM_MESSAGE_ID) { public constructor(message: string, id: number = Messages.SYSTEM_MESSAGE_ID) {
super(message, Messages.ErrorClass.ERROR_MSG, id); super(message, Messages.ErrorClass.ERROR_MSG, id);
} }
} }
export class ButtplugPingError extends ButtplugError { export class ButtplugPingError extends ButtplugError {
public constructor(message: string, id: number = Messages.SYSTEM_MESSAGE_ID) { public constructor(message: string, id: number = Messages.SYSTEM_MESSAGE_ID) {
super(message, Messages.ErrorClass.ERROR_PING, id); super(message, Messages.ErrorClass.ERROR_PING, id);
} }
} }
export class ButtplugUnknownError extends ButtplugError { export class ButtplugUnknownError extends ButtplugError {
public constructor(message: string, id: number = Messages.SYSTEM_MESSAGE_ID) { public constructor(message: string, id: number = Messages.SYSTEM_MESSAGE_ID) {
super(message, Messages.ErrorClass.ERROR_UNKNOWN, id); super(message, Messages.ErrorClass.ERROR_UNKNOWN, id);
} }
} }

View File

@@ -6,71 +6,73 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved. * @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/ */
import { EventEmitter } from "eventemitter3"; import { EventEmitter } from 'eventemitter3';
export enum ButtplugLogLevel { export enum ButtplugLogLevel {
Off, Off,
Error, Error,
Warn, Warn,
Info, Info,
Debug, Debug,
Trace, Trace,
} }
/** /**
* Representation of log messages for the internal logging utility. * Representation of log messages for the internal logging utility.
*/ */
export class LogMessage { export class LogMessage {
/** Timestamp for the log message */ /** Timestamp for the log message */
private timestamp: string; private timestamp: string;
/** Log Message */ /** Log Message */
private logMessage: string; private logMessage: string;
/** Log Level */ /** Log Level */
private logLevel: ButtplugLogLevel; private logLevel: ButtplugLogLevel;
/** /**
* @param logMessage Log message. * @param logMessage Log message.
* @param logLevel: Log severity level. * @param logLevel: Log severity level.
*/ */
public constructor(logMessage: string, logLevel: ButtplugLogLevel) { public constructor(logMessage: string, logLevel: ButtplugLogLevel) {
const a = new Date(); const a = new Date();
const hour = a.getHours(); const hour = a.getHours();
const min = a.getMinutes(); const min = a.getMinutes();
const sec = a.getSeconds(); const sec = a.getSeconds();
this.timestamp = `${hour}:${min}:${sec}`; this.timestamp = `${hour}:${min}:${sec}`;
this.logMessage = logMessage; this.logMessage = logMessage;
this.logLevel = logLevel; this.logLevel = logLevel;
} }
/** /**
* Returns the log message. * Returns the log message.
*/ */
public get Message() { public get Message() {
return this.logMessage; return this.logMessage;
} }
/** /**
* Returns the log message level. * Returns the log message level.
*/ */
public get LogLevel() { public get LogLevel() {
return this.logLevel; return this.logLevel;
} }
/** /**
* Returns the log message timestamp. * Returns the log message timestamp.
*/ */
public get Timestamp() { public get Timestamp() {
return this.timestamp; return this.timestamp;
} }
/** /**
* Returns a formatted string with timestamp, level, and message. * Returns a formatted string with timestamp, level, and message.
*/ */
public get FormattedMessage() { public get FormattedMessage() {
return `${ButtplugLogLevel[this.logLevel]} : ${this.timestamp} : ${this.logMessage}`; return `${ButtplugLogLevel[this.logLevel]} : ${this.timestamp} : ${
} this.logMessage
}`;
}
} }
/** /**
@@ -79,117 +81,117 @@ export class LogMessage {
* basically), and allows message logging throughout the module. * basically), and allows message logging throughout the module.
*/ */
export class ButtplugLogger extends EventEmitter { export class ButtplugLogger extends EventEmitter {
/** Singleton instance for the logger */ /** Singleton instance for the logger */
protected static sLogger: ButtplugLogger | undefined = undefined; protected static sLogger: ButtplugLogger | undefined = undefined;
/** Sets maximum log level to log to console */ /** Sets maximum log level to log to console */
protected maximumConsoleLogLevel: ButtplugLogLevel = ButtplugLogLevel.Off; protected maximumConsoleLogLevel: ButtplugLogLevel = ButtplugLogLevel.Off;
/** Sets maximum log level for all log messages */ /** Sets maximum log level for all log messages */
protected maximumEventLogLevel: ButtplugLogLevel = ButtplugLogLevel.Off; protected maximumEventLogLevel: ButtplugLogLevel = ButtplugLogLevel.Off;
/** /**
* Returns the stored static instance of the logger, creating one if it * Returns the stored static instance of the logger, creating one if it
* doesn't currently exist. * doesn't currently exist.
*/ */
public static get Logger(): ButtplugLogger { public static get Logger(): ButtplugLogger {
if (ButtplugLogger.sLogger === undefined) { if (ButtplugLogger.sLogger === undefined) {
ButtplugLogger.sLogger = new ButtplugLogger(); ButtplugLogger.sLogger = new ButtplugLogger();
} }
return this.sLogger!; return this.sLogger!;
} }
/** /**
* Constructor. Can only be called internally since we regulate ButtplugLogger * Constructor. Can only be called internally since we regulate ButtplugLogger
* ownership. * ownership.
*/ */
protected constructor() { protected constructor() {
super(); super();
} }
/** /**
* Set the maximum log level to output to console. * Set the maximum log level to output to console.
*/ */
public get MaximumConsoleLogLevel() { public get MaximumConsoleLogLevel() {
return this.maximumConsoleLogLevel; return this.maximumConsoleLogLevel;
} }
/** /**
* Get the maximum log level to output to console. * Get the maximum log level to output to console.
*/ */
public set MaximumConsoleLogLevel(buttplugLogLevel: ButtplugLogLevel) { public set MaximumConsoleLogLevel(buttplugLogLevel: ButtplugLogLevel) {
this.maximumConsoleLogLevel = buttplugLogLevel; this.maximumConsoleLogLevel = buttplugLogLevel;
} }
/** /**
* Set the global maximum log level * Set the global maximum log level
*/ */
public get MaximumEventLogLevel() { public get MaximumEventLogLevel() {
return this.maximumEventLogLevel; return this.maximumEventLogLevel;
} }
/** /**
* Get the global maximum log level * Get the global maximum log level
*/ */
public set MaximumEventLogLevel(logLevel: ButtplugLogLevel) { public set MaximumEventLogLevel(logLevel: ButtplugLogLevel) {
this.maximumEventLogLevel = logLevel; this.maximumEventLogLevel = logLevel;
} }
/** /**
* Log new message at Error level. * Log new message at Error level.
*/ */
public Error(msg: string) { public Error(msg: string) {
this.AddLogMessage(msg, ButtplugLogLevel.Error); this.AddLogMessage(msg, ButtplugLogLevel.Error);
} }
/** /**
* Log new message at Warn level. * Log new message at Warn level.
*/ */
public Warn(msg: string) { public Warn(msg: string) {
this.AddLogMessage(msg, ButtplugLogLevel.Warn); this.AddLogMessage(msg, ButtplugLogLevel.Warn);
} }
/** /**
* Log new message at Info level. * Log new message at Info level.
*/ */
public Info(msg: string) { public Info(msg: string) {
this.AddLogMessage(msg, ButtplugLogLevel.Info); this.AddLogMessage(msg, ButtplugLogLevel.Info);
} }
/** /**
* Log new message at Debug level. * Log new message at Debug level.
*/ */
public Debug(msg: string) { public Debug(msg: string) {
this.AddLogMessage(msg, ButtplugLogLevel.Debug); this.AddLogMessage(msg, ButtplugLogLevel.Debug);
} }
/** /**
* Log new message at Trace level. * Log new message at Trace level.
*/ */
public Trace(msg: string) { public Trace(msg: string) {
this.AddLogMessage(msg, ButtplugLogLevel.Trace); this.AddLogMessage(msg, ButtplugLogLevel.Trace);
} }
/** /**
* Checks to see if message should be logged, and if so, adds message to the * Checks to see if message should be logged, and if so, adds message to the
* log buffer. May also print message and emit event. * log buffer. May also print message and emit event.
*/ */
protected AddLogMessage(msg: string, level: ButtplugLogLevel) { protected AddLogMessage(msg: string, level: ButtplugLogLevel) {
// If nothing wants the log message we have, ignore it. // If nothing wants the log message we have, ignore it.
if ( if (
level > this.maximumEventLogLevel && level > this.maximumEventLogLevel &&
level > this.maximumConsoleLogLevel level > this.maximumConsoleLogLevel
) { ) {
return; return;
} }
const logMsg = new LogMessage(msg, level); const logMsg = new LogMessage(msg, level);
// Clients and console logging may have different needs. For instance, it // Clients and console logging may have different needs. For instance, it
// could be that the client requests trace level, while all we want in the // could be that the client requests trace level, while all we want in the
// console is info level. This makes sure the client can't also spam the // console is info level. This makes sure the client can't also spam the
// console. // console.
if (level <= this.maximumConsoleLogLevel) { if (level <= this.maximumConsoleLogLevel) {
console.log(logMsg.FormattedMessage); console.log(logMsg.FormattedMessage);
} }
if (level <= this.maximumEventLogLevel) { 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,485 +7,203 @@
*/ */
// tslint:disable:max-classes-per-file // tslint:disable:max-classes-per-file
"use strict"; 'use strict';
import { instanceToPlain, Type } from "class-transformer"; import { ButtplugMessageError } from './Exceptions';
import "reflect-metadata";
export const SYSTEM_MESSAGE_ID = 0; export const SYSTEM_MESSAGE_ID = 0;
export const DEFAULT_MESSAGE_ID = 1; export const DEFAULT_MESSAGE_ID = 1;
export const MAX_ID = 4294967295; 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 { // Base message interfaces
public ScalarCmd?: Array<GenericDeviceMessageAttributes>; export interface ButtplugMessage {
public RotateCmd?: Array<GenericDeviceMessageAttributes>; Ok?: Ok;
public LinearCmd?: Array<GenericDeviceMessageAttributes>; Ping?: Ping;
public RawReadCmd?: RawDeviceMessageAttributes; Error?: Error;
public RawWriteCmd?: RawDeviceMessageAttributes; RequestServerInfo?: RequestServerInfo;
public RawSubscribeCmd?: RawDeviceMessageAttributes; ServerInfo?: ServerInfo;
public SensorReadCmd?: Array<SensorDeviceMessageAttributes>; RequestDeviceList?: RequestDeviceList;
public SensorSubscribeCmd?: Array<SensorDeviceMessageAttributes>; StartScanning?: StartScanning;
public StopDeviceCmd: {}; StopScanning?: StopScanning;
ScanningFinished?: ScanningFinished;
constructor(data: Partial<MessageAttributes>) { StopCmd?: StopCmd;
Object.assign(this, data); InputCmd?: InputCmd;
} InputReading?: InputReading;
OutputCmd?: OutputCmd;
public update() { DeviceList?: DeviceList;
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 enum ActuatorType { export function msgId(msg: ButtplugMessage): number {
Unknown = "Unknown", for (let [_, entry] of Object.entries(msg)) {
Vibrate = "Vibrate", if (entry != undefined) {
Rotate = "Rotate", return entry.Id;
Oscillate = "Oscillate", }
Constrict = "Constrict", }
Inflate = "Inflate", throw new ButtplugMessageError(`Message ${msg} does not have an ID.`);
Position = "Position",
} }
export enum SensorType { export function setMsgId(msg: ButtplugMessage, id: number) {
Unknown = "Unknown", for (let [_, entry] of Object.entries(msg)) {
Battery = "Battery", if (entry != undefined) {
RSSI = "RSSI", entry.Id = id;
Button = "Button", return;
Pressure = "Pressure", }
// Temperature, }
// Accelerometer, throw new ButtplugMessageError(`Message ${msg} does not have an ID.`);
// Gyro,
} }
export class GenericDeviceMessageAttributes { export interface Ok {
public FeatureDescriptor: string; Id: number | undefined;
public ActuatorType: ActuatorType;
public StepCount: number;
public Index = 0;
constructor(data: Partial<GenericDeviceMessageAttributes>) {
Object.assign(this, data);
}
} }
export class RawDeviceMessageAttributes { export interface Ping {
constructor(public Endpoints: Array<string>) {} Id: number | undefined;
}
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 enum ErrorClass { export enum ErrorClass {
ERROR_UNKNOWN, ERROR_UNKNOWN,
ERROR_INIT, ERROR_INIT,
ERROR_PING, ERROR_PING,
ERROR_MSG, ERROR_MSG,
ERROR_DEVICE, ERROR_DEVICE,
} }
export class Error extends ButtplugMessage { export interface Error {
static Name = "Error"; ErrorMessage: string;
ErrorCode: ErrorClass;
constructor( Id: number | undefined;
public ErrorMessage: string,
public ErrorCode: ErrorClass = ErrorClass.ERROR_UNKNOWN,
public Id: number = DEFAULT_MESSAGE_ID,
) {
super(Id);
}
get Schemversion() {
return 0;
}
} }
export class DeviceInfo { export interface RequestDeviceList {
public DeviceIndex: number; Id: number | undefined;
public DeviceName: string;
@Type(() => MessageAttributes)
public DeviceMessages: MessageAttributes;
public DeviceDisplayName?: string;
public DeviceMessageTimingGap?: number;
constructor(data: Partial<DeviceInfo>) {
Object.assign(this, data);
}
} }
export class DeviceList extends ButtplugMessage { export interface StartScanning {
static Name = "DeviceList"; Id: number | undefined;
@Type(() => DeviceInfo)
public Devices: DeviceInfo[];
public Id: number;
constructor(devices: DeviceInfo[], id: number = DEFAULT_MESSAGE_ID) {
super(id);
this.Devices = devices;
this.Id = id;
}
public update() {
for (const device of this.Devices) {
device.DeviceMessages.update();
}
}
} }
export class DeviceAdded extends ButtplugSystemMessage { export interface StopScanning {
static Name = "DeviceAdded"; Id: number | undefined;
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);
}
public update() {
this.DeviceMessages.update();
}
} }
export class DeviceRemoved extends ButtplugSystemMessage { export interface StopAllDevices {
static Name = "DeviceRemoved"; Id: number | undefined;
constructor(public DeviceIndex: number) {
super();
}
} }
export class RequestDeviceList extends ButtplugMessage { export interface ScanningFinished {
static Name = "RequestDeviceList"; Id: number | undefined;
constructor(public Id: number = DEFAULT_MESSAGE_ID) {
super(Id);
}
} }
export class StartScanning extends ButtplugMessage { export interface RequestServerInfo {
static Name = "StartScanning"; ClientName: string;
ProtocolVersionMajor: number;
constructor(public Id: number = DEFAULT_MESSAGE_ID) { ProtocolVersionMinor: number;
super(Id); Id: number | undefined;
}
} }
export class StopScanning extends ButtplugMessage { export interface ServerInfo {
static Name = "StopScanning"; MaxPingTime: number;
ServerName: string;
constructor(public Id: number = DEFAULT_MESSAGE_ID) { ProtocolVersionMajor: number;
super(Id); ProtocolVersionMinor: number;
} Id: number | undefined;
} }
export class ScanningFinished extends ButtplugSystemMessage { export interface DeviceFeature {
static Name = "ScanningFinished"; FeatureDescriptor: string;
Output: { [key: string]: DeviceFeatureOutput };
constructor() { Input: { [key: string]: DeviceFeatureInput };
super(); FeatureIndex: number;
}
} }
export class RequestServerInfo extends ButtplugMessage { export interface DeviceInfo {
static Name = "RequestServerInfo"; DeviceIndex: number;
DeviceName: string;
constructor( DeviceFeatures: { [key: number]: DeviceFeature };
public ClientName: string, DeviceDisplayName?: string;
public MessageVersion: number = 0, DeviceMessageTimingGap?: number;
public Id: number = DEFAULT_MESSAGE_ID,
) {
super(Id);
}
} }
export class ServerInfo extends ButtplugSystemMessage { export interface DeviceList {
static Name = "ServerInfo"; Devices: { [key: number]: DeviceInfo };
Id: number | undefined;
constructor(
public MessageVersion: number,
public MaxPingTime: number,
public ServerName: string,
public Id: number = DEFAULT_MESSAGE_ID,
) {
super();
}
} }
export class StopDeviceCmd extends ButtplugDeviceMessage { export enum OutputType {
static Name = "StopDeviceCmd"; Unknown = 'Unknown',
Vibrate = 'Vibrate',
constructor( Rotate = 'Rotate',
public DeviceIndex: number = -1, Oscillate = 'Oscillate',
public Id: number = DEFAULT_MESSAGE_ID, Constrict = 'Constrict',
) { Inflate = 'Inflate',
super(DeviceIndex, Id); Position = 'Position',
} HwPositionWithDuration = 'HwPositionWithDuration',
Temperature = 'Temperature',
Spray = 'Spray',
Led = 'Led',
} }
export class StopAllDevices extends ButtplugMessage { export enum InputType {
static Name = "StopAllDevices"; Unknown = 'Unknown',
Battery = 'Battery',
constructor(public Id: number = DEFAULT_MESSAGE_ID) { RSSI = 'RSSI',
super(Id); Button = 'Button',
} Pressure = 'Pressure',
// Temperature,
// Accelerometer,
// Gyro,
} }
export class GenericMessageSubcommand { export enum InputCommandType {
protected constructor(public Index: number) {} Read = 'Read',
Subscribe = 'Subscribe',
Unsubscribe = 'Unsubscribe',
} }
export class ScalarSubcommand extends GenericMessageSubcommand { export interface DeviceFeatureInput {
constructor( Value: number[];
Index: number, Command: InputCommandType[];
public Scalar: number,
public ActuatorType: ActuatorType,
) {
super(Index);
}
} }
export class ScalarCmd extends ButtplugDeviceMessage { export interface DeviceFeatureOutput {
static Name = "ScalarCmd"; Value: number;
Duration?: number;
constructor(
public Scalars: ScalarSubcommand[],
public DeviceIndex: number = -1,
public Id: number = DEFAULT_MESSAGE_ID,
) {
super(DeviceIndex, Id);
}
} }
export class RotateSubcommand extends GenericMessageSubcommand { export interface OutputCmd {
constructor( DeviceIndex: number;
Index: number, FeatureIndex: number;
public Speed: number, Command: { [key: string]: DeviceFeatureOutput };
public Clockwise: boolean, Id: number | undefined;
) {
super(Index);
}
} }
export class RotateCmd extends ButtplugDeviceMessage { // Device Input Commands
static Name = "RotateCmd";
public static Create( export interface InputCmd {
deviceIndex: number, DeviceIndex: number;
commands: [number, boolean][], FeatureIndex: number;
): RotateCmd { Type: InputType;
const cmdList: RotateSubcommand[] = new Array<RotateSubcommand>(); Command: InputCommandType;
Id: number | undefined;
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 { export interface InputValue {
constructor( Value: number;
Index: number,
public Position: number,
public Duration: number,
) {
super(Index);
}
} }
export class LinearCmd extends ButtplugDeviceMessage { export interface InputReading {
static Name = "LinearCmd"; DeviceIndex: number;
FeatureIndex: number;
public static Create( Reading: { [key: string]: InputValue };
deviceIndex: number, Id: number | undefined;
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 { export interface StopCmd {
static Name = "SensorReadCmd"; Id: number | undefined;
DeviceIndex: number | undefined;
constructor( FeatureIndex: number | undefined;
public DeviceIndex: number, Inputs: boolean | undefined;
public SensorIndex: number, Outputs: boolean | undefined;
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);
}
} }

View File

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

View File

@@ -8,12 +8,16 @@ mod webbluetooth;
use js_sys; use js_sys;
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use crate::webbluetooth::{WebBluetoothCommunicationManagerBuilder}; use crate::webbluetooth::{WebBluetoothCommunicationManagerBuilder};
use buttplug::{ use buttplug_core::{
core::message::{ButtplugServerMessageCurrent,serializer::vec_to_protocol_json}, message::{ButtplugServerMessageCurrent, BUTTPLUG_CURRENT_API_MAJOR_VERSION, serializer::{ButtplugSerializedMessage, ButtplugMessageSerializer}},
server::{ButtplugServerBuilder,ButtplugServerDowngradeWrapper,device::{ServerDeviceManagerBuilder,configuration::{DeviceConfigurationManager}}}, util::async_manager,
util::async_manager, core::message::{BUTTPLUG_CURRENT_MESSAGE_SPEC_VERSION, ButtplugServerMessageVariant, serializer::{ButtplugSerializedMessage, ButtplugMessageSerializer, ButtplugServerJSONSerializer}},
util::device_configuration::load_protocol_configs
}; };
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 FFICallback = js_sys::Function;
type FFICallbackContext = u32; type FFICallbackContext = u32;
@@ -33,16 +37,17 @@ use wasm_bindgen::prelude::*;
use std::sync::Arc; use std::sync::Arc;
use js_sys::Uint8Array; use js_sys::Uint8Array;
pub type ButtplugWASMServer = Arc<ButtplugServerDowngradeWrapper>; pub type ButtplugWASMServer = Arc<ButtplugServer>;
pub fn send_server_message( pub fn send_server_message(
message: &ButtplugServerMessageCurrent, message: &ButtplugServerMessageCurrent,
callback: &FFICallback, callback: &FFICallback,
) { ) {
let msg_array = [message.clone()]; let serializer = ButtplugServerJSONSerializer::default();
let json_msg = vec_to_protocol_json(&msg_array); serializer.force_message_version(&BUTTPLUG_CURRENT_API_MAJOR_VERSION);
let buf = json_msg.as_bytes(); 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 this = JsValue::null();
let uint8buf = unsafe { Uint8Array::new(&Uint8Array::view(buf)) }; let uint8buf = unsafe { Uint8Array::new(&Uint8Array::view(buf)) };
callback.call1(&this, &JsValue::from(uint8buf)); callback.call1(&this, &JsValue::from(uint8buf));
@@ -50,10 +55,9 @@ pub fn send_server_message(
} }
#[no_mangle] #[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) load_protocol_configs(&None, &None, false)
.expect("If this fails, the whole library goes with it.") .expect("If this fails, the whole library goes with it.")
.allow_raw_messages(allow_raw_messages)
.finish() .finish()
.expect("If this fails, the whole library goes with it.") .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); let mut sdm = ServerDeviceManagerBuilder::new(dcm);
sdm.comm_manager(WebBluetoothCommunicationManagerBuilder::default()); sdm.comm_manager(WebBluetoothCommunicationManagerBuilder::default());
let builder = ButtplugServerBuilder::new(sdm.finish().unwrap()); let builder = ButtplugServerBuilder::new(sdm.finish().unwrap());
let server = builder.finish().unwrap(); let server = Arc::new(builder.finish().unwrap());
let wrapper = Arc::new(ButtplugServerDowngradeWrapper::new(server)); let event_stream = server.server_version_event_stream();
let event_stream = wrapper.server_version_event_stream();
let callback = callback.clone(); let callback = callback.clone();
async_manager::spawn(async move { async_manager::spawn(async move {
pin_mut!(event_stream); pin_mut!(event_stream);
while let Some(message) = event_stream.next().await { 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] #[no_mangle]
@@ -106,15 +109,18 @@ pub fn buttplug_client_send_json_message(
}; };
let callback = callback.clone(); let callback = callback.clone();
let serializer = ButtplugServerJSONSerializer::default(); 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(); let input_msg = serializer.deserialize(&ButtplugSerializedMessage::Text(std::str::from_utf8(buf).unwrap().to_owned())).unwrap();
async_manager::spawn(async move { async_manager::spawn(async move {
let msg = input_msg[0].clone(); let msg = input_msg[0].clone();
let response = server.parse_message(msg).await.unwrap(); let response = server.parse_message(msg).await.unwrap();
if let ButtplugServerMessageVariant::V3(response) = response { let json_msg = serializer.serialize(&[response]);
send_server_message(&response, &callback); 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,86 +6,83 @@
* @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved. * @copyright Copyright (c) Nonpolynomial Labs LLC. All rights reserved.
*/ */
"use strict"; 'use strict';
import { EventEmitter } from "eventemitter3"; import { EventEmitter } from 'eventemitter3';
import { ButtplugMessage } from "../core/Messages"; import { ButtplugMessage } from '../core/Messages';
import { fromJSON } from "../core/MessageUtils";
export class ButtplugBrowserWebsocketConnector extends EventEmitter { export class ButtplugBrowserWebsocketConnector extends EventEmitter {
protected _ws: WebSocket | undefined; protected _ws: WebSocket | undefined;
protected _websocketConstructor: typeof WebSocket | null = null; protected _websocketConstructor: typeof WebSocket | null = null;
public constructor(private _url: string) { public constructor(private _url: string) {
super(); super();
} }
public get Connected(): boolean { public get Connected(): boolean {
return this._ws !== undefined; return this._ws !== undefined;
} }
public connect = async (): Promise<void> => { public connect = async (): Promise<void> => {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const ws = new (this._websocketConstructor ?? WebSocket)(this._url); const ws = new (this._websocketConstructor ?? WebSocket)(this._url);
const onErrorCallback = (event: Event) => { const onErrorCallback = (event: Event) => {reject(event)}
reject(event); const onCloseCallback = (event: CloseEvent) => reject(event.reason)
}; ws.addEventListener('open', async () => {
const onCloseCallback = (event: CloseEvent) => reject(event.reason); this._ws = ws;
ws.addEventListener("open", async () => { try {
this._ws = ws; await this.initialize();
try { this._ws.addEventListener('message', (msg) => {
await this.initialize(); this.parseIncomingMessage(msg);
this._ws.addEventListener("message", (msg) => { });
this.parseIncomingMessage(msg); this._ws.removeEventListener('close', onCloseCallback);
}); this._ws.removeEventListener('error', onErrorCallback);
this._ws.removeEventListener("close", onCloseCallback); this._ws.addEventListener('close', this.disconnect);
this._ws.removeEventListener("error", onErrorCallback); resolve();
this._ws.addEventListener("close", this.disconnect); } catch (e) {
resolve(); reject(e);
} catch (e) { }
reject(e); });
} // In websockets, our error rarely tells us much, as for security reasons
}); // browsers usually only throw Error Code 1006. It's up to those using this
// In websockets, our error rarely tells us much, as for security reasons // library to state what the problem might be.
// 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('error', onErrorCallback)
ws.addEventListener("close", onCloseCallback); ws.addEventListener('close', onCloseCallback);
}); });
}; };
public disconnect = async (): Promise<void> => { public disconnect = async (): Promise<void> => {
if (!this.Connected) { if (!this.Connected) {
return; return;
} }
this._ws!.close(); this._ws!.close();
this._ws = undefined; this._ws = undefined;
this.emit("disconnect"); this.emit('disconnect');
}; };
public sendMessage(msg: ButtplugMessage) { public sendMessage(msg: ButtplugMessage) {
if (!this.Connected) { 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> => { public initialize = async (): Promise<void> => {
return Promise.resolve(); return Promise.resolve();
}; };
protected parseIncomingMessage(event: MessageEvent) { protected parseIncomingMessage(event: MessageEvent) {
if (typeof event.data === "string") { if (typeof event.data === 'string') {
const msgs = fromJSON(event.data); const msgs: ButtplugMessage[] = JSON.parse(event.data);
this.emit("message", msgs); this.emit('message', msgs);
} else if (event.data instanceof Blob) { } else if (event.data instanceof Blob) {
// No-op, we only use text message types. // No-op, we only use text message types.
} }
} }
protected onReaderLoad(event: Event) { protected onReaderLoad(event: Event) {
const msgs = fromJSON((event.target as FileReader).result); const msgs: ButtplugMessage[] = JSON.parse((event.target as FileReader).result as string);
this.emit("message", msgs); this.emit('message', msgs);
} }
} }

View File

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

View File

@@ -1,25 +1,17 @@
use async_trait::async_trait; use async_trait::async_trait;
use buttplug::{ use buttplug_core::errors::ButtplugDeviceError;
core::{ use buttplug_server_device_config::{BluetoothLESpecifier, Endpoint, ProtocolCommunicationSpecifier};
errors::ButtplugDeviceError, use buttplug_server::device::hardware::{
message::Endpoint, Hardware,
}, HardwareConnector,
server::device::{ HardwareEvent,
configuration::{BluetoothLESpecifier, ProtocolCommunicationSpecifier}, HardwareInternal,
hardware::{ HardwareReadCmd,
Hardware, HardwareReading,
HardwareConnector, HardwareSpecializer,
HardwareEvent, HardwareSubscribeCmd,
HardwareInternal, HardwareUnsubscribeCmd,
HardwareReadCmd, HardwareWriteCmd,
HardwareReading,
HardwareSpecializer,
HardwareSubscribeCmd,
HardwareUnsubscribeCmd,
HardwareWriteCmd,
},
},
util::future::{ButtplugFuture, ButtplugFutureStateShared},
}; };
use futures::future::{self, BoxFuture}; use futures::future::{self, BoxFuture};
use js_sys::{DataView, Uint8Array}; use js_sys::{DataView, Uint8Array};
@@ -28,7 +20,7 @@ use std::{
convert::TryFrom, convert::TryFrom,
fmt::{self, Debug}, fmt::{self, Debug},
}; };
use tokio::sync::{broadcast, mpsc}; use tokio::sync::{broadcast, mpsc, oneshot};
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
use wasm_bindgen_futures::{spawn_local, JsFuture}; use wasm_bindgen_futures::{spawn_local, JsFuture};
@@ -41,9 +33,6 @@ use web_sys::{
MessageEvent, MessageEvent,
}; };
type WebBluetoothResultFuture = ButtplugFuture<Result<(), ButtplugDeviceError>>;
type WebBluetoothReadResultFuture = ButtplugFuture<Result<HardwareReading, ButtplugDeviceError>>;
struct BluetoothDeviceWrapper { struct BluetoothDeviceWrapper {
pub device: BluetoothDevice pub device: BluetoothDevice
} }
@@ -179,7 +168,7 @@ impl HardwareSpecializer for WebBluetoothHardwareSpecializer {
receiver, receiver,
command_sender, command_sender,
)); ));
Ok(Hardware::new(&name, &address, &[], device_impl)) Ok(Hardware::new(&name, &address, &[], &None, false, device_impl))
} }
WebBluetoothEvent::Disconnected => Err( WebBluetoothEvent::Disconnected => Err(
ButtplugDeviceError::DeviceCommunicationError( ButtplugDeviceError::DeviceCommunicationError(
@@ -202,19 +191,19 @@ pub enum WebBluetoothEvent {
pub enum WebBluetoothDeviceCommand { pub enum WebBluetoothDeviceCommand {
Write( Write(
HardwareWriteCmd, HardwareWriteCmd,
ButtplugFutureStateShared<Result<(), ButtplugDeviceError>>, oneshot::Sender<Result<(), ButtplugDeviceError>>,
), ),
Read( Read(
HardwareReadCmd, HardwareReadCmd,
ButtplugFutureStateShared<Result<HardwareReading, ButtplugDeviceError>>, oneshot::Sender<Result<HardwareReading, ButtplugDeviceError>>,
), ),
Subscribe( Subscribe(
HardwareSubscribeCmd, HardwareSubscribeCmd,
ButtplugFutureStateShared<Result<(), ButtplugDeviceError>>, oneshot::Sender<Result<(), ButtplugDeviceError>>,
), ),
Unsubscribe( Unsubscribe(
HardwareUnsubscribeCmd, HardwareUnsubscribeCmd,
ButtplugFutureStateShared<Result<(), ButtplugDeviceError>>, oneshot::Sender<Result<(), ButtplugDeviceError>>,
), ),
} }
@@ -287,7 +276,7 @@ async fn run_webbluetooth_loop(
.await; .await;
while let Some(msg) = device_command_receiver.recv().await { while let Some(msg) = device_command_receiver.recv().await {
match msg { match msg {
WebBluetoothDeviceCommand::Write(write_cmd, waker) => { WebBluetoothDeviceCommand::Write(write_cmd, sender) => {
debug!("Writing to endpoint {:?}", write_cmd.endpoint()); debug!("Writing to endpoint {:?}", write_cmd.endpoint());
let chr = char_map.get(&write_cmd.endpoint()).unwrap().clone(); let chr = char_map.get(&write_cmd.endpoint()).unwrap().clone();
spawn_local(async move { spawn_local(async move {
@@ -295,10 +284,10 @@ async fn run_webbluetooth_loop(
JsFuture::from(chr.write_value_with_u8_array(&uint8buf).unwrap()) JsFuture::from(chr.write_value_with_u8_array(&uint8buf).unwrap())
.await .await
.unwrap(); .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()); debug!("Writing to endpoint {:?}", read_cmd.endpoint());
let chr = char_map.get(&read_cmd.endpoint()).unwrap().clone(); let chr = char_map.get(&read_cmd.endpoint()).unwrap().clone();
spawn_local(async move { spawn_local(async move {
@@ -307,10 +296,10 @@ async fn run_webbluetooth_loop(
let mut body = vec![0; data_view.byte_length() as usize]; let mut body = vec![0; data_view.byte_length() as usize];
Uint8Array::new(&data_view).copy_to(&mut body[..]); Uint8Array::new(&data_view).copy_to(&mut body[..]);
let reading = HardwareReading::new(read_cmd.endpoint(), &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()); debug!("Subscribing to endpoint {:?}", subscribe_cmd.endpoint());
let chr = char_map.get(&subscribe_cmd.endpoint()).unwrap().clone(); let chr = char_map.get(&subscribe_cmd.endpoint()).unwrap().clone();
let ep = subscribe_cmd.endpoint(); let ep = subscribe_cmd.endpoint();
@@ -335,10 +324,10 @@ async fn run_webbluetooth_loop(
spawn_local(async move { spawn_local(async move {
JsFuture::from(chr.start_notifications()).await.unwrap(); JsFuture::from(chr.start_notifications()).await.unwrap();
debug!("Endpoint subscribed"); 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!"); debug!("run_webbluetooth_loop exited!");
@@ -388,12 +377,13 @@ impl HardwareInternal for WebBluetoothHardware {
let sender = self.device_command_sender.clone(); let sender = self.device_command_sender.clone();
let msg = msg.clone(); let msg = msg.clone();
Box::pin(async move { Box::pin(async move {
let fut = WebBluetoothReadResultFuture::default(); let (tx, rx) = oneshot::channel();
let waker = fut.get_state_clone(); let _ = sender
sender .send(WebBluetoothDeviceCommand::Read(msg, tx))
.send(WebBluetoothDeviceCommand::Read(msg, waker))
.await; .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 sender = self.device_command_sender.clone();
let msg = msg.clone(); let msg = msg.clone();
Box::pin(async move { Box::pin(async move {
let fut = WebBluetoothResultFuture::default(); let (tx, rx) = oneshot::channel();
let waker = fut.get_state_clone(); let _ = sender
sender .send(WebBluetoothDeviceCommand::Write(msg, tx))
.send(WebBluetoothDeviceCommand::Write(msg.clone(), waker))
.await; .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 sender = self.device_command_sender.clone();
let msg = msg.clone(); let msg = msg.clone();
Box::pin(async move { Box::pin(async move {
let fut = WebBluetoothResultFuture::default(); let (tx, rx) = oneshot::channel();
let waker = fut.get_state_clone(); let _ = sender
sender .send(WebBluetoothDeviceCommand::Subscribe(msg, tx))
.send(WebBluetoothDeviceCommand::Subscribe(msg.clone(), waker))
.await; .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 super::webbluetooth_hardware::WebBluetoothHardwareConnector;
use buttplug::{ use buttplug_core::ButtplugResultFuture;
core::ButtplugResultFuture, use buttplug_server_device_config::ProtocolCommunicationSpecifier;
server::device::{ use buttplug_server::device::hardware::communication::{
configuration::{ProtocolCommunicationSpecifier}, HardwareCommunicationManager, HardwareCommunicationManagerBuilder,
hardware::communication::{ HardwareCommunicationManagerEvent,
HardwareCommunicationManager, HardwareCommunicationManagerBuilder,
HardwareCommunicationManagerEvent,
},
}
}; };
use futures::future; use futures::future;
use js_sys::Array; use js_sys::Array;
@@ -69,8 +65,8 @@ impl HardwareCommunicationManager for WebBluetoothCommunicationManager {
let options = web_sys::RequestDeviceOptions::new(); let options = web_sys::RequestDeviceOptions::new();
let filters = Array::new(); let filters = Array::new();
let optional_services = Array::new(); let optional_services = Array::new();
for vals in config_manager.protocol_device_configurations().iter() { for vals in config_manager.base_communication_specifiers().iter() {
for config in vals.1 { for config in vals.1.iter() {
if let ProtocolCommunicationSpecifier::BluetoothLE(btle) = &config { if let ProtocolCommunicationSpecifier::BluetoothLE(btle) = &config {
for name in btle.names() { for name in btle.names() {
let filter = web_sys::BluetoothLeScanFilterInit::new(); let filter = web_sys::BluetoothLeScanFilterInit::new();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

16
pnpm-lock.yaml generated
View File

@@ -23,15 +23,9 @@ importers:
packages/buttplug: packages/buttplug:
dependencies: dependencies:
class-transformer:
specifier: ^0.5.1
version: 0.5.1
eventemitter3: eventemitter3:
specifier: ^5.0.1 specifier: ^5.0.1
version: 5.0.1 version: 5.0.1
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
typescript: typescript:
specifier: ^5.9.2 specifier: ^5.9.2
version: 5.9.3 version: 5.9.3
@@ -1650,9 +1644,6 @@ packages:
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
class-transformer@0.5.1:
resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==}
cli-color@2.0.4: cli-color@2.0.4:
resolution: {integrity: sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA==} resolution: {integrity: sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA==}
engines: {node: '>=0.10'} engines: {node: '>=0.10'}
@@ -2857,9 +2848,6 @@ packages:
resolution: {integrity: sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w==} resolution: {integrity: sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w==}
engines: {node: '>=6'} engines: {node: '>=6'}
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
relative-time-format@1.1.11: relative-time-format@1.1.11:
resolution: {integrity: sha512-TH+oV/w77hjaB9xCzoFYJ/Icmr/12+02IAoCI/YGS2UBTbjCbBjHGEBxGnVy4EJvOR1qadGzyFRI6hGaJJG93Q==} resolution: {integrity: sha512-TH+oV/w77hjaB9xCzoFYJ/Icmr/12+02IAoCI/YGS2UBTbjCbBjHGEBxGnVy4EJvOR1qadGzyFRI6hGaJJG93Q==}
@@ -4710,8 +4698,6 @@ snapshots:
chownr@2.0.0: {} chownr@2.0.0: {}
class-transformer@0.5.1: {}
cli-color@2.0.4: cli-color@2.0.4:
dependencies: dependencies:
d: 1.0.2 d: 1.0.2
@@ -5897,8 +5883,6 @@ snapshots:
reduce-flatten@2.0.0: {} reduce-flatten@2.0.0: {}
reflect-metadata@0.2.2: {}
relative-time-format@1.1.11: {} relative-time-format@1.1.11: {}
resolve-from@4.0.0: {} resolve-from@4.0.0: {}