diff --git a/package-lock.json b/package-lock.json index 5aceaf1..f7adad9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "@timesplinter/pimple": "^2.0.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", - "buttplug": "^3.2.1", + "buttplug": "^5.0.1", "class-transformer": "^0.5.1", "cors": "^2.8.5", "dotenv": "^17.2.0", @@ -3360,19 +3360,15 @@ "license": "MIT" }, "node_modules/buttplug": { - "version": "3.2.2", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/buttplug/-/buttplug-5.0.1.tgz", + "integrity": "sha512-m7Qzoi6TEsr0DkqqXc3Gun/wikWiHziQeXu73Oxzd1ETqj2y1P0VdJ9QxAT811bLcTIYvyf0VkuHlXbEGcEXCw==", "license": "BSD-3-Clause", "dependencies": { - "class-transformer": "^0.5.1", - "eventemitter3": "^5.0.1", - "reflect-metadata": "^0.2.1", - "ws": "^8.16.0" + "eventemitter3": "^5.0.4", + "ws": "^8.20.1" } }, - "node_modules/buttplug/node_modules/reflect-metadata": { - "version": "0.2.2", - "license": "Apache-2.0" - }, "node_modules/bytes": { "version": "3.1.2", "license": "MIT", @@ -4363,7 +4359,9 @@ } }, "node_modules/eventemitter3": { - "version": "5.0.1", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, "node_modules/events": { diff --git a/package.json b/package.json index e912776..2fe569a 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "@timesplinter/pimple": "^2.0.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", - "buttplug": "^3.2.1", + "buttplug": "^5.0.1", "class-transformer": "^0.5.1", "cors": "^2.8.5", "dotenv": "^17.2.0", diff --git a/src/controller/automation/stopScriptController.ts b/src/controller/automation/stopScriptController.ts index 5e88e1c..76749b8 100644 --- a/src/controller/automation/stopScriptController.ts +++ b/src/controller/automation/stopScriptController.ts @@ -11,9 +11,9 @@ export default class StopScriptController implements ControllerInterface this.scriptRuntime = scriptRuntime; } - public execute(req: Request, res: Response): void + public async execute(req: Request, res: Response): Promise { - this.scriptRuntime.stop(); + await this.scriptRuntime.stop(); res.sendStatus(200); } diff --git a/src/device/protocol/buttplugIo/buttplugIoDevice.ts b/src/device/protocol/buttplugIo/buttplugIoDevice.ts index 55e484e..c7066fc 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDevice.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDevice.ts @@ -1,5 +1,5 @@ import { Exclude, Expose } from 'class-transformer'; -import { ActuatorType, ButtplugClientDevice, SensorType } from 'buttplug'; +import { ButtplugClientDevice, DeviceOutput, DeviceOutputCommand, InputCommandType, InputType, OutputType } from 'buttplug'; import Device, { ExtractAttributeValue } from '../../device.js'; import IntRangeDeviceAttribute from '../../attribute/intRangeDeviceAttribute.js'; import BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; @@ -8,9 +8,9 @@ import IntDeviceAttribute from '../../attribute/intDeviceAttribute.js'; import { DeviceAttributeModifier } from '../../attribute/deviceAttribute.js'; import EventEmitter from 'events'; -type ButtplugActuatorTypeKey = `${ActuatorType}-${number}`; -type ButtplugSensorTypeKey = `${SensorType}-${number}`; -export type ButtplugIoDeviceAttributeKey = ButtplugActuatorTypeKey | ButtplugSensorTypeKey; +type ButtplugOutputTypeKey = `${OutputType}-${number}`; +type ButtplugInputTypeKey = `${InputType}-${number}`; +export type ButtplugIoDeviceAttributeKey = ButtplugOutputTypeKey | ButtplugInputTypeKey; export type ButtplugIoDeviceAttributes = Record< ButtplugIoDeviceAttributeKey, @@ -41,9 +41,20 @@ export default class ButtplugIoDevice extends Device } protected override async doRefresh(): Promise { - for (const sensor of this.buttplugClientDevice.messageAttributes.SensorReadCmd ?? []) { - const value = await this.buttplugClientDevice.sensorRead(sensor.Index, sensor.SensorType); - this.attributes[`${sensor.SensorType}-${sensor.Index}`].value = Int.from(value[0]); + for (const [, feature] of this.buttplugClientDevice.features) { + for (const [, input] of feature.inputs) { + if (input.type === InputType.Unknown) { + continue; + } + const attrKey = `${input.type}-${feature.index}` as ButtplugIoDeviceAttributeKey; + if (this.attributes[attrKey] === undefined) { + continue; + } + const response = await feature.runInput(input.type, InputCommandType.Read); + if (response?.value !== undefined) { + this.attributes[attrKey].value = Int.from(response.value); + } + } } } @@ -77,21 +88,38 @@ export default class ButtplugIoDevice extends Device throw new Error(`Unsupported attribute type '${attribute.constructor.name}' for buttplug.io`); } - const [actuatorType, index] = attributeName.split('-'); + const dashIndex = attributeName.lastIndexOf('-'); + const outputType = attributeName.slice(0, dashIndex) as OutputType; + const index = parseInt(attributeName.slice(dashIndex + 1), 10); - await this.send(actuatorType as ActuatorType, parseInt(index, 10), valueToSend); + await this.send(outputType, index, valueToSend); this.attributes[`${attributeName}`].value = value; return value; } - protected async send(command: ActuatorType, index: number, value: number): Promise { - return await this.buttplugClientDevice.scalar({ - 'ActuatorType': command, - 'Scalar': value, - 'Index': index - }); + protected async send(command: OutputType, index: number, value: number): Promise { + const feature = this.buttplugClientDevice.features.get(index); + if (feature === undefined) { + throw new Error(`Feature index ${index} not found`); + } + await feature.runOutput(ButtplugIoDevice.createOutputCommand(command, value)); + } + + private static createOutputCommand(type: OutputType, value: number): DeviceOutputCommand { + switch (type) { + case OutputType.Vibrate: return DeviceOutput.Vibrate.value(value); + case OutputType.Rotate: return DeviceOutput.Rotate.value(value); + case OutputType.Oscillate: return DeviceOutput.Oscillate.value(value); + case OutputType.Constrict: return DeviceOutput.Constrict.value(value); + case OutputType.Inflate: return DeviceOutput.Inflate.value(value); + case OutputType.Temperature: return DeviceOutput.Temperature.value(value); + case OutputType.Led: return DeviceOutput.Led.value(value); + case OutputType.Spray: return DeviceOutput.Spray.value(value); + case OutputType.Position: return DeviceOutput.Position.value(value); + default: throw new Error(`Unsupported output type: ${type}`); + } } public get getButtplugClientDevice(): ButtplugClientDevice diff --git a/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts b/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts index c45eacb..47d2d00 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDeviceFactory.ts @@ -1,16 +1,15 @@ import UuidFactory from '../../../factory/uuidFactory.js'; import Settings from '../../../settings/settings.js'; -import { ButtplugClientDevice } from 'buttplug'; +import { ButtplugClientDevice, ButtplugClientDeviceFeatureValueRange, InputType, OutputType } from 'buttplug'; import ButtplugIoDevice, { ButtplugIoDeviceAttributeKey, ButtplugIoDeviceAttributes } from './buttplugIoDevice.js'; import KnownDevice from '../../../settings/knownDevice.js'; import Logger from '../../../logging/Logger.js'; import { DeviceAttributeModifier } from '../../attribute/deviceAttribute.js'; import IntRangeDeviceAttribute from '../../attribute/intRangeDeviceAttribute.js'; -import BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; import DateFactory from '../../../factory/dateFactory.js'; import { Int } from '../../../util/numbers.js'; -import IntDeviceAttribute from '../../attribute/intDeviceAttribute.js'; import EventEmitterFactory from '../../../factory/eventEmitterFactory.js'; +import BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; export default class ButtplugIoDeviceFactory @@ -60,59 +59,59 @@ export default class ButtplugIoDeviceFactory } private static parseDeviceAttributes(buttplugDevice: ButtplugClientDevice): ButtplugIoDeviceAttributes { - const attributes = {} as ButtplugIoDeviceAttributes; - - for (const item of buttplugDevice.messageAttributes.ScalarCmd ?? []) { - const attrName = `${item.ActuatorType}-${item.Index}` as ButtplugIoDeviceAttributeKey; - - if (item.StepCount > 2) { - attributes[attrName] = IntRangeDeviceAttribute.createInitialized( - attrName, - item.FeatureDescriptor, - DeviceAttributeModifier.writeOnly, - undefined, - Int.ZERO, - Int.from(item.StepCount), - Int.from(1), - Int.ZERO - ); - } else { - attributes[attrName] = BoolDeviceAttribute.createInitialized( - attrName, item.FeatureDescriptor, DeviceAttributeModifier.writeOnly, false - ); + const attributes: ButtplugIoDeviceAttributes = {}; + + for (const [, feature] of buttplugDevice.features) { + for (const [outputType, output] of feature.outputs) { + if (outputType === OutputType.Unknown) { + continue; + } + + const attrName: ButtplugIoDeviceAttributeKey = `${outputType}-${feature.index}`; + + attributes[attrName] = this.createAttribute(attrName, feature.featureDescriptor, output.valueRange, DeviceAttributeModifier.writeOnly); } - } - for (const item of buttplugDevice.messageAttributes.SensorReadCmd ?? []) { - const attrName = `${item.SensorType}-${item.Index}` as ButtplugIoDeviceAttributeKey; - - // A range is defined by two numbers, if there are more or less, let's fallback - // to a normal integer attribute. Not that dramatic for a sensor after all. - if (item.StepRange.length === 2) { - attributes[attrName] = IntRangeDeviceAttribute.createInitialized( - `${item.SensorType}-${item.Index}`, - item.FeatureDescriptor, - DeviceAttributeModifier.readOnly, - undefined, - Int.from(item.StepRange[0]), - Int.from(item.StepRange[1]), - Int.from(1), - Int.ZERO - ); - } else { - attributes[attrName] = IntDeviceAttribute.createInitialized( - `${item.SensorType}-${item.Index}`, - item.FeatureDescriptor, - DeviceAttributeModifier.readOnly, - undefined, - Int.ZERO - ); + for (const [, input] of feature.inputs) { + if (input.type === InputType.Unknown) { + continue; + } + + const attrName: ButtplugIoDeviceAttributeKey = `${input.type}-${feature.index}`; + + attributes[attrName] = this.createAttribute(attrName, feature.featureDescriptor, input.valueRange, DeviceAttributeModifier.readOnly); } } return attributes; } + private static createAttribute( + attrName: ButtplugIoDeviceAttributeKey, + featureDescriptor: string, + valueRange: ButtplugClientDeviceFeatureValueRange, + attributeModifier: DeviceAttributeModifier + ): IntRangeDeviceAttribute|BoolDeviceAttribute { + const [valueRangeMin, valueRangeMax] = valueRange; + + if (valueRangeMin === 0 && valueRangeMax === 1) { + return BoolDeviceAttribute.createInitialized( + attrName, featureDescriptor, DeviceAttributeModifier.writeOnly, false + ); + } + + return IntRangeDeviceAttribute.createInitialized( + attrName, + featureDescriptor || undefined, + attributeModifier, + undefined, + Int.from(valueRangeMin), + Int.from(valueRangeMax), + Int.from(1), + Int.ZERO + ); + } + private createKnownDevice(buttplugDevice: ButtplugClientDevice, provider: string, useDeviceNameAsId: boolean): KnownDevice { // Since we don't get a unique identifier for the Bluetooth device from Intiface, // we need to use the index assigned to the device by Intiface. It's the best we have. diff --git a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts index ac92563..6f26232 100644 --- a/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts +++ b/src/device/protocol/buttplugIo/buttplugIoWebsocketDeviceProvider.ts @@ -5,7 +5,6 @@ import DeviceProvider from '../../provider/deviceProvider.js'; import ButtplugIoDeviceFactory from './buttplugIoDeviceFactory.js'; import Logger from '../../../logging/Logger.js'; import { asyncHandler, setImmediateInterval } from '../../../util/async.js'; -import SlvCtrlPlusButtplugWebsocketClientConnector from './slvCtrlPlusButtplugWebsocketClientConnector.js'; import DeviceManager from '../../deviceManager.js'; import { logError } from '../../../util/error.js'; @@ -43,8 +42,8 @@ export default class ButtplugIoWebsocketDeviceProvider extends DeviceProvider logError(this.logger, `Error in disconnect handler`, e) diff --git a/src/device/protocol/buttplugIo/slvCtrlPlusButtplugWebsocketClientConnector.ts b/src/device/protocol/buttplugIo/slvCtrlPlusButtplugWebsocketClientConnector.ts deleted file mode 100644 index 96c3aba..0000000 --- a/src/device/protocol/buttplugIo/slvCtrlPlusButtplugWebsocketClientConnector.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* eslint-disable @typescript-eslint/no-misused-promises */ -import { ButtplugNodeWebsocketClientConnector } from 'buttplug'; - -// This is needed to make try/catch around connect() work until upgraded to buttplug@4.0.0 -export default class SlvCtrlPlusButtplugWebsocketClientConnector extends ButtplugNodeWebsocketClientConnector { - private _url2; - - public constructor(_url: string) { - super(_url); - this._url2 = _url; - } - - public override connect = async (): Promise => { - return new Promise((resolve, reject) => { - const ws = new (this._websocketConstructor ?? WebSocket)(this._url2); - const onErrorCallback = (event: Event): void => reject(event); - const onCloseCallback = (event: CloseEvent): void => reject(event.reason); - ws.addEventListener('open', async (): Promise => { - this._ws = ws; - try { - await this.initialize(); - this._ws.addEventListener('message', (msg) => { - this.parseIncomingMessage(msg); - }); - this._ws.removeEventListener('close', onCloseCallback); - this._ws.removeEventListener('error', onErrorCallback); - this._ws.addEventListener('close', this.disconnect); - resolve(); - } 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 - // library to state what the problem might be. - - ws.addEventListener('error', onErrorCallback) - ws.addEventListener('close', onCloseCallback); - }); - } -} diff --git a/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts b/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts index 5ab9acd..aa3bfc1 100644 --- a/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts +++ b/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts @@ -1,4 +1,5 @@ -import {ButtplugClientDevice} from "buttplug"; +import {ButtplugClientDevice, DeviceOutputCommand, OutputType} from "buttplug"; +import {Mock} from "@vitest/spy"; import BoolDeviceAttribute from "../../../../../src/device/attribute/boolDeviceAttribute.js"; import IntRangeDeviceAttribute from "../../../../../src/device/attribute/intRangeDeviceAttribute.js"; import ButtplugIoDevice, { @@ -7,13 +8,18 @@ import ButtplugIoDevice, { } from "../../../../../src/device/protocol/buttplugIo/buttplugIoDevice.js"; import {DeviceAttributeModifier} from "../../../../../src/device/attribute/deviceAttribute.js"; import {Int} from "../../../../../src/util/numbers.js"; -import {describe, it, expect} from "vitest"; -import {mock} from "vitest-mock-extended"; +import {describe, it, expect, vi} from "vitest"; import {EventEmitter} from "events"; +type RunOutputFn = (cmd: DeviceOutputCommand) => Promise; + +function makeDeviceStub(entries: Array<[number, { runOutput: Mock }]>): ButtplugClientDevice { + return { features: new Map(entries) } as unknown as ButtplugClientDevice; +} + describe('ButtplugIoDevice', () => { - function createDevice(buttplugDeviceMock: ButtplugClientDevice, attrs: ButtplugIoDeviceAttributes): ButtplugIoDevice + function createDevice(buttplugDevice: ButtplugClientDevice, attrs: ButtplugIoDeviceAttributes): ButtplugIoDevice { return new ButtplugIoDevice( 'device-id', @@ -21,7 +27,7 @@ describe('ButtplugIoDevice', () => { 'device model', 'buttplugIo', new Date(), - buttplugDeviceMock, + buttplugDevice, attrs, new EventEmitter(), ); @@ -30,9 +36,7 @@ describe('ButtplugIoDevice', () => { it('it throws an error if non-existing attribute is set', async () => { // Arrange - const buttplugDeviceMock = mock(); - const device = createDevice(buttplugDeviceMock, {}); - + const device = createDevice(makeDeviceStub([]), {}); const attrName = 'bool' as ButtplugIoDeviceAttributeKey; // Act @@ -45,32 +49,34 @@ describe('ButtplugIoDevice', () => { it('it updates device data and calls buttplugClientDevice on setting boolean attribute', async () => { // Arrange - const buttplugDeviceMock = mock(); - const boolAttrKey = 'bool-1' as ButtplugIoDeviceAttributeKey; + const runOutput = vi.fn().mockResolvedValue(undefined); + const boolAttrKey = `${OutputType.Vibrate}-1` as ButtplugIoDeviceAttributeKey; const boolAttr = BoolDeviceAttribute.create(boolAttrKey, undefined, DeviceAttributeModifier.readWrite); const device = createDevice( - buttplugDeviceMock, + makeDeviceStub([[1, {runOutput}]]), {[boolAttrKey]: boolAttr}, ); // Act await device.setAttribute(boolAttrKey, false); + // Assert expect((await device.getAttribute(boolAttrKey))?.value).toStrictEqual(false); - // Assert - expect(buttplugDeviceMock.scalar).toHaveBeenCalledTimes(1); - expect(buttplugDeviceMock.scalar).toHaveBeenCalledWith({ActuatorType: 'bool', Index: 1, Scalar: 0}); + expect(runOutput).toHaveBeenCalledTimes(1); + const cmd = runOutput.mock.calls[0][0]; + expect(cmd.outputType).toBe(OutputType.Vibrate); + expect(cmd.value).toBe(0); }); it('it updates device data and calls buttplugClientDevice on setting range attribute', async () => { // Arrange - const buttplugDeviceMock = mock(); + const runOutput = vi.fn().mockResolvedValue(undefined); - const rangeAttrName = 'range-2' as ButtplugIoDeviceAttributeKey; + const rangeAttrName = `${OutputType.Vibrate}-2` as ButtplugIoDeviceAttributeKey; const rangeAttr = IntRangeDeviceAttribute.create( rangeAttrName, undefined, @@ -82,7 +88,7 @@ describe('ButtplugIoDevice', () => { ); const device = createDevice( - buttplugDeviceMock, + makeDeviceStub([[2, {runOutput}]]), {[rangeAttrName]: rangeAttr}, ); @@ -91,14 +97,12 @@ describe('ButtplugIoDevice', () => { // Act await device.setAttribute(rangeAttrName, Int.from(newValue)); + // Assert expect((await device.getAttribute(rangeAttrName))?.value).toStrictEqual(newValue); - // Assert - expect(buttplugDeviceMock.scalar).toHaveBeenCalledTimes(1); - expect(buttplugDeviceMock.scalar).toHaveBeenCalledWith({ - ActuatorType: 'range', - Index: 2, - Scalar: newValue/rangeAttr.max - }); + expect(runOutput).toHaveBeenCalledTimes(1); + const cmd = runOutput.mock.calls[0][0]; + expect(cmd.outputType).toBe(OutputType.Vibrate); + expect(cmd.value).toBeCloseTo(newValue / 20); }); });