Files
sexy.pivoine.art/packages/buttplug/src/client/ButtplugClient.ts
Sebastian Krüger 6ea4ed1933
All checks were successful
Build and Push Docker Image to Gitea / build-and-push (push) Successful in 7m30s
feat: upgrade buttplug package to protocol v4 and WASM v10
Upgrade the buttplug TypeScript client from class-based v3 protocol to
interface-based v4 protocol, and the Rust/WASM server from the monolithic
buttplug 9.0.9 crate to the split buttplug_core/buttplug_server/
buttplug_server_device_config 10.0.0 crates.

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 14:46:47 +01:00

243 lines
7.9 KiB
TypeScript

/*!
* 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);
};
}