ive-connect
Version:
A universal haptic device control library for interactive experiences
300 lines (299 loc) • 10.9 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ButtplugApi = void 0;
/**
* Buttplug API — high-level device management over Buttplug v4 protocol
*/
const events_1 = require("../../core/events");
const types_1 = require("./types");
const buttplug_ws_1 = require("./buttplug-ws");
class ButtplugApi extends events_1.EventEmitter {
constructor(clientName = 'IVE-Connect') {
super();
this.devices = new Map();
this.devicePreferences = new Map();
this.features = new Map();
this.isScanning = false;
this.connectionState = types_1.ButtplugConnectionState.DISCONNECTED;
this.clientName = clientName;
this.ws = new buttplug_ws_1.ButtplugWs((type, payload) => this.onMessage(type, payload));
}
getConnectionState() {
return this.connectionState;
}
getConnectedUrl() {
return this.connectedUrl;
}
getDevices() {
return Array.from(this.devices.values());
}
getIsScanning() {
return this.isScanning;
}
getDevicePreferences() {
return this.devicePreferences;
}
setDevicePreference(index, pref) {
this.devicePreferences.set(index, pref);
this.emit('devicePreferenceChanged', {
deviceIndex: index,
preference: pref,
});
}
// ── Connection ──────────────────────────────────────────────────
async connect(type, serverUrl) {
if (this.connectionState !== types_1.ButtplugConnectionState.DISCONNECTED) {
await this.disconnect();
}
if (type !== types_1.ButtplugConnectionType.WEBSOCKET || !serverUrl) {
this.emit('error', 'WebSocket URL required');
return false;
}
try {
this.connectionState = types_1.ButtplugConnectionState.CONNECTING;
this.emit('connectionStateChanged', this.connectionState);
await this.ws.open(serverUrl, this.clientName);
this.connectedUrl = serverUrl;
this.connectionState = types_1.ButtplugConnectionState.CONNECTED;
this.emit('connectionStateChanged', this.connectionState);
return true;
}
catch (e) {
console.error('Buttplug connect error:', e);
this.cleanup();
this.emit('error', e instanceof Error ? e.message : String(e));
return false;
}
}
async disconnect() {
this.ws.close();
this.cleanup();
return true;
}
// ── Scanning ────────────────────────────────────────────────────
async startScanning() {
if (!this.ws.connected)
return false;
try {
this.isScanning = true;
this.emit('scanningChanged', true);
await this.ws.send('StartScanning', {});
return true;
}
catch (e) {
this.isScanning = false;
this.emit('scanningChanged', false);
this.emit('error', e instanceof Error ? e.message : String(e));
return false;
}
}
async stopScanning() {
if (!this.ws.connected)
return false;
try {
await this.ws.send('StopScanning', {});
return true;
}
catch (_a) {
this.isScanning = false;
this.emit('scanningChanged', false);
return false;
}
}
// ── Device commands ─────────────────────────────────────────────
async vibrateDevice(index, speed) {
return this.outputCmd(index, 'Vibrate', speed);
}
async linearDevice(index, position, duration) {
const feat = this.findFeature(index, 'HwPositionWithDuration');
if (!feat)
return false;
try {
const value = Math.ceil(feat.maxSteps * Math.min(1, Math.max(0, position)));
await this.ws.send('OutputCmd', {
DeviceIndex: index,
FeatureIndex: feat.featureIndex,
Command: {
HwPositionWithDuration: {
Value: value,
Duration: Math.round(duration),
},
},
});
return true;
}
catch (_a) {
return false;
}
}
async rotateDevice(index, speed, _clockwise) {
return this.outputCmd(index, 'Rotate', speed);
}
async oscillateDevice(index, speed, _frequency) {
return this.outputCmd(index, 'Oscillate', speed);
}
async stopDevice(index) {
const d = this.devices.get(index);
if (!d)
return false;
try {
if (d.canVibrate)
await this.outputCmd(index, 'Vibrate', 0.01);
else if (d.canRotate)
await this.outputCmd(index, 'Rotate', 0.01);
else if (d.canLinear)
await this.linearDevice(index, 0.01, 500);
await this.delay(100);
await this.ws.send('StopCmd', {
DeviceIndex: index,
FeatureIndex: undefined,
Inputs: true,
Outputs: true,
});
return true;
}
catch (_a) {
return false;
}
}
async stopAllDevices() {
if (!this.ws.connected)
return false;
try {
for (const d of this.devices.values()) {
try {
if (d.canVibrate)
await this.outputCmd(d.index, 'Vibrate', 0.01);
else if (d.canRotate)
await this.outputCmd(d.index, 'Rotate', 0.01);
else if (d.canLinear)
await this.linearDevice(d.index, 0.01, 500);
}
catch (_a) { }
}
await this.delay(100);
await this.ws.send('StopCmd', {
DeviceIndex: undefined,
FeatureIndex: undefined,
Inputs: true,
Outputs: true,
});
return true;
}
catch (_b) {
return false;
}
}
// ── Internals ───────────────────────────────────────────────────
onMessage(type, payload) {
switch (type) {
case 'DeviceAdded':
this.addDevice(payload);
break;
case 'DeviceRemoved':
this.removeDevice(payload.DeviceIndex);
break;
case 'DeviceList': {
// DeviceList.Devices is an object keyed by index
const incoming = payload.Devices || {};
// Add new devices
for (const dev of Object.values(incoming)) {
this.addDevice(dev);
}
// Remove devices no longer in the list
for (const index of this.devices.keys()) {
if (!incoming.hasOwnProperty(index.toString())) {
this.removeDevice(index);
}
}
break;
}
case 'ScanningFinished':
this.isScanning = false;
this.emit('scanningChanged', false);
break;
}
}
addDevice(dev) {
var _a, _b, _c;
// DeviceFeatures is an object keyed by feature index
const rawFeatures = dev.DeviceFeatures || {};
const parsed = [];
for (const [idx, feat] of Object.entries(rawFeatures)) {
const output = feat
.Output;
if (!output)
continue;
for (const [outputType, outputDef] of Object.entries(output)) {
parsed.push({
featureIndex: parseInt(idx),
type: outputType,
maxSteps: (_c = (_b = (_a = outputDef.Value) === null || _a === void 0 ? void 0 : _a[1]) !== null && _b !== void 0 ? _b : outputDef.Value) !== null && _c !== void 0 ? _c : 100,
});
}
}
this.features.set(dev.DeviceIndex, parsed);
const has = (t) => parsed.some((f) => f.type === t);
const info = {
index: dev.DeviceIndex,
name: dev.DeviceDisplayName || dev.DeviceName,
canVibrate: has('Vibrate'),
canLinear: has('Position') || has('HwPositionWithDuration'),
canRotate: has('Rotate'),
canOscillate: has('Oscillate'),
};
this.devices.set(dev.DeviceIndex, info);
if (!this.devicePreferences.has(dev.DeviceIndex)) {
this.devicePreferences.set(dev.DeviceIndex, {
enabled: true,
useVibrate: info.canVibrate,
useRotate: info.canRotate,
useLinear: info.canLinear,
useOscillate: info.canOscillate,
});
}
this.emit('deviceAdded', info);
}
removeDevice(index) {
const info = this.devices.get(index);
if (!info)
return;
this.devices.delete(index);
this.features.delete(index);
this.emit('deviceRemoved', info);
}
findFeature(deviceIndex, type) {
var _a;
return (_a = this.features.get(deviceIndex)) === null || _a === void 0 ? void 0 : _a.find((f) => f.type === type);
}
async outputCmd(deviceIndex, outputType, percent) {
const feat = this.findFeature(deviceIndex, outputType);
if (!feat)
return false;
try {
const value = Math.ceil(feat.maxSteps * Math.min(1, Math.max(0, percent)));
await this.ws.send('OutputCmd', {
DeviceIndex: deviceIndex,
FeatureIndex: feat.featureIndex,
Command: { [outputType]: { Value: value } },
});
return true;
}
catch (_a) {
return false;
}
}
cleanup() {
this.devices.clear();
this.features.clear();
this.isScanning = false;
this.connectionState = types_1.ButtplugConnectionState.DISCONNECTED;
this.connectedUrl = undefined;
this.emit('connectionStateChanged', this.connectionState);
this.emit('scanningChanged', false);
}
delay(ms) {
return new Promise((r) => setTimeout(r, ms));
}
}
exports.ButtplugApi = ButtplugApi;