@zendrex/buttplug.js 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/dist/index.cjs +3176 -0
- package/dist/index.d.cts +1752 -0
- package/dist/index.d.ts +1752 -0
- package/dist/index.js +3084 -0
- package/package.json +55 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,3176 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
ButtplugClient: () => ButtplugClient,
|
|
34
|
+
ButtplugError: () => ButtplugError,
|
|
35
|
+
ClientMessageSchema: () => ClientMessageSchema,
|
|
36
|
+
ConnectionError: () => ConnectionError,
|
|
37
|
+
DEFAULT_CLIENT_NAME: () => DEFAULT_CLIENT_NAME,
|
|
38
|
+
DEFAULT_PING_INTERVAL: () => DEFAULT_PING_INTERVAL,
|
|
39
|
+
DEFAULT_REQUEST_TIMEOUT: () => DEFAULT_REQUEST_TIMEOUT,
|
|
40
|
+
Device: () => Device,
|
|
41
|
+
DeviceError: () => DeviceError,
|
|
42
|
+
DeviceFeaturesSchema: () => DeviceFeaturesSchema,
|
|
43
|
+
EASING_FUNCTIONS: () => EASING_FUNCTIONS,
|
|
44
|
+
EASING_VALUES: () => EASING_VALUES,
|
|
45
|
+
ErrorCode: () => ErrorCode,
|
|
46
|
+
FeatureValueSchema: () => FeatureValueSchema,
|
|
47
|
+
HandshakeError: () => HandshakeError,
|
|
48
|
+
HwPositionOutputDataSchema: () => HwPositionOutputDataSchema,
|
|
49
|
+
INPUT_TYPES: () => INPUT_TYPES,
|
|
50
|
+
INPUT_TYPE_VALUES: () => INPUT_TYPE_VALUES,
|
|
51
|
+
InputCommandTypeSchema: () => InputCommandTypeSchema,
|
|
52
|
+
InputDataSchema: () => InputDataSchema,
|
|
53
|
+
InputFeatureSchema: () => InputFeatureSchema,
|
|
54
|
+
InputReadingSchema: () => InputReadingSchema,
|
|
55
|
+
InputTypeSchema: () => InputTypeSchema,
|
|
56
|
+
KeyframeSchema: () => KeyframeSchema,
|
|
57
|
+
MAX_MESSAGE_ID: () => MAX_MESSAGE_ID,
|
|
58
|
+
OUTPUT_TYPES: () => OUTPUT_TYPES,
|
|
59
|
+
OUTPUT_TYPE_VALUES: () => OUTPUT_TYPE_VALUES,
|
|
60
|
+
OutputCommandSchema: () => OutputCommandSchema,
|
|
61
|
+
OutputFeatureSchema: () => OutputFeatureSchema,
|
|
62
|
+
OutputTypeSchema: () => OutputTypeSchema,
|
|
63
|
+
PRESETS: () => PRESETS,
|
|
64
|
+
PRESET_NAMES: () => PRESET_NAMES,
|
|
65
|
+
PROTOCOL_VERSION_MAJOR: () => PROTOCOL_VERSION_MAJOR,
|
|
66
|
+
PROTOCOL_VERSION_MINOR: () => PROTOCOL_VERSION_MINOR,
|
|
67
|
+
PatternDescriptorSchema: () => PatternDescriptorSchema,
|
|
68
|
+
PatternEngine: () => PatternEngine,
|
|
69
|
+
PingManager: () => PingManager,
|
|
70
|
+
PositionValueSchema: () => PositionValueSchema,
|
|
71
|
+
ProtocolError: () => ProtocolError,
|
|
72
|
+
ReconnectDefaults: () => ReconnectDefaults,
|
|
73
|
+
ReconnectHandler: () => ReconnectHandler,
|
|
74
|
+
RotateWithDirectionOutputDataSchema: () => RotateWithDirectionOutputDataSchema,
|
|
75
|
+
RotationValueSchema: () => RotationValueSchema,
|
|
76
|
+
SensorValueSchema: () => SensorValueSchema,
|
|
77
|
+
ServerInfoSchema: () => ServerInfoSchema,
|
|
78
|
+
ServerMessageSchema: () => ServerMessageSchema,
|
|
79
|
+
SignedScalarOutputDataSchema: () => SignedScalarOutputDataSchema,
|
|
80
|
+
TimeoutError: () => TimeoutError,
|
|
81
|
+
UnsignedScalarOutputDataSchema: () => UnsignedScalarOutputDataSchema,
|
|
82
|
+
WebSocketTransport: () => WebSocketTransport,
|
|
83
|
+
consoleLogger: () => consoleLogger,
|
|
84
|
+
formatError: () => formatError,
|
|
85
|
+
getLogger: () => getLogger,
|
|
86
|
+
getPresetInfo: () => getPresetInfo,
|
|
87
|
+
noopLogger: () => noopLogger,
|
|
88
|
+
runWithLogger: () => runWithLogger
|
|
89
|
+
});
|
|
90
|
+
module.exports = __toCommonJS(index_exports);
|
|
91
|
+
|
|
92
|
+
// src/client.ts
|
|
93
|
+
var import_emittery = __toESM(require("emittery"), 1);
|
|
94
|
+
|
|
95
|
+
// src/lib/logger.ts
|
|
96
|
+
var noopLogger = {
|
|
97
|
+
debug() {
|
|
98
|
+
},
|
|
99
|
+
info() {
|
|
100
|
+
},
|
|
101
|
+
warn() {
|
|
102
|
+
},
|
|
103
|
+
error() {
|
|
104
|
+
},
|
|
105
|
+
child() {
|
|
106
|
+
return noopLogger;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
function createLogger(prefix) {
|
|
110
|
+
return {
|
|
111
|
+
debug(message) {
|
|
112
|
+
console.debug(`[${prefix}] ${message}`);
|
|
113
|
+
},
|
|
114
|
+
info(message) {
|
|
115
|
+
console.info(`[${prefix}] ${message}`);
|
|
116
|
+
},
|
|
117
|
+
warn(message) {
|
|
118
|
+
console.warn(`[${prefix}] ${message}`);
|
|
119
|
+
},
|
|
120
|
+
error(message) {
|
|
121
|
+
console.error(`[${prefix}] ${message}`);
|
|
122
|
+
},
|
|
123
|
+
child(childPrefix) {
|
|
124
|
+
return createLogger(`${prefix}:${childPrefix}`);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
var consoleLogger = createLogger("buttplug");
|
|
129
|
+
|
|
130
|
+
// src/lib/context.ts
|
|
131
|
+
var currentLogger;
|
|
132
|
+
function getLogger() {
|
|
133
|
+
return currentLogger ?? noopLogger;
|
|
134
|
+
}
|
|
135
|
+
function runWithLogger(logger, fn) {
|
|
136
|
+
const prev = currentLogger;
|
|
137
|
+
currentLogger = logger;
|
|
138
|
+
try {
|
|
139
|
+
return fn();
|
|
140
|
+
} finally {
|
|
141
|
+
currentLogger = prev;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// src/core/utils.ts
|
|
146
|
+
function raceTimeout(promise, ms) {
|
|
147
|
+
return Promise.race([
|
|
148
|
+
promise,
|
|
149
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), ms))
|
|
150
|
+
]);
|
|
151
|
+
}
|
|
152
|
+
function outputFeaturesEqual(a, b) {
|
|
153
|
+
const sortedA = [...a].sort((x, y) => x.index - y.index);
|
|
154
|
+
const sortedB = [...b].sort((x, y) => x.index - y.index);
|
|
155
|
+
for (const [i, ao] of sortedA.entries()) {
|
|
156
|
+
const bo = sortedB[i];
|
|
157
|
+
if (!bo || ao.type !== bo.type || ao.index !== bo.index || ao.description !== bo.description || ao.range[0] !== bo.range[0] || ao.range[1] !== bo.range[1] || ao.durationRange?.[0] !== bo.durationRange?.[0] || ao.durationRange?.[1] !== bo.durationRange?.[1]) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
function inputFeaturesEqual(a, b) {
|
|
164
|
+
const sortedA = [...a].sort((x, y) => x.index - y.index);
|
|
165
|
+
const sortedB = [...b].sort((x, y) => x.index - y.index);
|
|
166
|
+
for (const [i, ai] of sortedA.entries()) {
|
|
167
|
+
const bi = sortedB[i];
|
|
168
|
+
if (!bi || ai.type !== bi.type || ai.index !== bi.index || ai.description !== bi.description || ai.canRead !== bi.canRead || ai.canSubscribe !== bi.canSubscribe || ai.range[0] !== bi.range[0] || ai.range[1] !== bi.range[1]) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
function featuresEqual(a, b) {
|
|
175
|
+
if (a.outputs.length !== b.outputs.length || a.inputs.length !== b.inputs.length) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
return outputFeaturesEqual(a.outputs, b.outputs) && inputFeaturesEqual(a.inputs, b.inputs);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// src/core/device-reconciler.ts
|
|
182
|
+
function reconcileDevices(options) {
|
|
183
|
+
const { currentDevices, incomingRaw, createDevice, callbacks } = options;
|
|
184
|
+
const logger = getLogger();
|
|
185
|
+
const incomingIndices = new Set(incomingRaw.map((d) => d.DeviceIndex));
|
|
186
|
+
const currentIndices = new Set(currentDevices.keys());
|
|
187
|
+
for (const index of currentIndices) {
|
|
188
|
+
if (!incomingIndices.has(index)) {
|
|
189
|
+
const device = currentDevices.get(index);
|
|
190
|
+
if (device) {
|
|
191
|
+
logger.debug(`Device removed: ${device.name} (index ${index})`);
|
|
192
|
+
currentDevices.delete(index);
|
|
193
|
+
callbacks.onRemoved(device);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
for (const rawDevice of incomingRaw) {
|
|
198
|
+
if (currentIndices.has(rawDevice.DeviceIndex)) {
|
|
199
|
+
const existingDevice = currentDevices.get(rawDevice.DeviceIndex);
|
|
200
|
+
const newDevice = createDevice(rawDevice);
|
|
201
|
+
if (existingDevice && !featuresEqual(existingDevice.features, newDevice.features)) {
|
|
202
|
+
currentDevices.set(rawDevice.DeviceIndex, newDevice);
|
|
203
|
+
logger.debug(`Device updated: ${newDevice.name} (index ${newDevice.index})`);
|
|
204
|
+
callbacks.onUpdated(newDevice, existingDevice);
|
|
205
|
+
}
|
|
206
|
+
} else {
|
|
207
|
+
const device = createDevice(rawDevice);
|
|
208
|
+
currentDevices.set(rawDevice.DeviceIndex, device);
|
|
209
|
+
logger.debug(`Device added: ${device.name} (index ${device.index})`);
|
|
210
|
+
callbacks.onAdded(device);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
callbacks.onList(Array.from(currentDevices.values()));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// src/lib/errors.ts
|
|
217
|
+
var ErrorCode = {
|
|
218
|
+
UNKNOWN: 0,
|
|
219
|
+
INIT: 1,
|
|
220
|
+
PING: 2,
|
|
221
|
+
MESSAGE: 3,
|
|
222
|
+
DEVICE: 4
|
|
223
|
+
};
|
|
224
|
+
var ButtplugError = class extends Error {
|
|
225
|
+
name = "ButtplugError";
|
|
226
|
+
/**
|
|
227
|
+
* @param message - Human-readable error description
|
|
228
|
+
* @param cause - The underlying error that caused this failure
|
|
229
|
+
*/
|
|
230
|
+
constructor(message, cause) {
|
|
231
|
+
super(message, { cause });
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
var ConnectionError = class extends ButtplugError {
|
|
235
|
+
name = "ConnectionError";
|
|
236
|
+
};
|
|
237
|
+
var HandshakeError = class extends ButtplugError {
|
|
238
|
+
name = "HandshakeError";
|
|
239
|
+
};
|
|
240
|
+
var ProtocolError = class extends ButtplugError {
|
|
241
|
+
name = "ProtocolError";
|
|
242
|
+
/** The protocol {@link ErrorCode} returned by the server. */
|
|
243
|
+
code;
|
|
244
|
+
/**
|
|
245
|
+
* @param code - The protocol error code
|
|
246
|
+
* @param message - Human-readable error description
|
|
247
|
+
* @param cause - The underlying error that caused this failure
|
|
248
|
+
*/
|
|
249
|
+
constructor(code, message, cause) {
|
|
250
|
+
super(message, cause);
|
|
251
|
+
this.code = code;
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
var DeviceError = class extends ButtplugError {
|
|
255
|
+
name = "DeviceError";
|
|
256
|
+
/** Index of the device that triggered the error. */
|
|
257
|
+
deviceIndex;
|
|
258
|
+
/**
|
|
259
|
+
* @param deviceIndex - Index of the device that triggered the error
|
|
260
|
+
* @param message - Human-readable error description
|
|
261
|
+
* @param cause - The underlying error that caused this failure
|
|
262
|
+
*/
|
|
263
|
+
constructor(deviceIndex, message, cause) {
|
|
264
|
+
super(message, cause);
|
|
265
|
+
this.deviceIndex = deviceIndex;
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
var TimeoutError = class extends ButtplugError {
|
|
269
|
+
name = "TimeoutError";
|
|
270
|
+
/** Name of the operation that timed out. */
|
|
271
|
+
operation;
|
|
272
|
+
/** Duration in milliseconds before the timeout triggered. */
|
|
273
|
+
timeoutMs;
|
|
274
|
+
/**
|
|
275
|
+
* @param operation - Name of the operation that timed out
|
|
276
|
+
* @param timeoutMs - Duration in milliseconds before the timeout triggered
|
|
277
|
+
* @param cause - The underlying error that caused this failure
|
|
278
|
+
*/
|
|
279
|
+
constructor(operation, timeoutMs, cause) {
|
|
280
|
+
super(`${operation} timed out after ${timeoutMs}ms`, cause);
|
|
281
|
+
this.operation = operation;
|
|
282
|
+
this.timeoutMs = timeoutMs;
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
function formatError(err) {
|
|
286
|
+
return err instanceof Error ? err.message : String(err);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// src/protocol/constants.ts
|
|
290
|
+
var PROTOCOL_VERSION_MAJOR = 4;
|
|
291
|
+
var PROTOCOL_VERSION_MINOR = 0;
|
|
292
|
+
var DEFAULT_CLIENT_NAME = "buttplug.js";
|
|
293
|
+
var DEFAULT_REQUEST_TIMEOUT = 1e4;
|
|
294
|
+
var DEFAULT_PING_INTERVAL = 1e3;
|
|
295
|
+
var MAX_MESSAGE_ID = 4294967295;
|
|
296
|
+
|
|
297
|
+
// src/protocol/messages.ts
|
|
298
|
+
function createRequestServerInfo(id, clientName) {
|
|
299
|
+
return {
|
|
300
|
+
RequestServerInfo: {
|
|
301
|
+
Id: id,
|
|
302
|
+
ClientName: clientName,
|
|
303
|
+
ProtocolVersionMajor: PROTOCOL_VERSION_MAJOR,
|
|
304
|
+
ProtocolVersionMinor: PROTOCOL_VERSION_MINOR
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
function createStartScanning(id) {
|
|
309
|
+
return {
|
|
310
|
+
StartScanning: { Id: id }
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
function createStopScanning(id) {
|
|
314
|
+
return {
|
|
315
|
+
StopScanning: { Id: id }
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
function createRequestDeviceList(id) {
|
|
319
|
+
return {
|
|
320
|
+
RequestDeviceList: { Id: id }
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
function createPing(id) {
|
|
324
|
+
return {
|
|
325
|
+
Ping: { Id: id }
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
function createDisconnect(id) {
|
|
329
|
+
return {
|
|
330
|
+
Disconnect: { Id: id }
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
function createStopCmd(id, options) {
|
|
334
|
+
if (options?.featureIndex !== void 0 && options.deviceIndex === void 0) {
|
|
335
|
+
throw new Error("StopCmd: featureIndex requires deviceIndex to be set");
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
StopCmd: {
|
|
339
|
+
Id: id,
|
|
340
|
+
...options?.deviceIndex !== void 0 && { DeviceIndex: options.deviceIndex },
|
|
341
|
+
...options?.featureIndex !== void 0 && { FeatureIndex: options.featureIndex },
|
|
342
|
+
...options?.inputs !== void 0 && { Inputs: options.inputs },
|
|
343
|
+
...options?.outputs !== void 0 && { Outputs: options.outputs }
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
function serializeMessage(message) {
|
|
348
|
+
return JSON.stringify([message]);
|
|
349
|
+
}
|
|
350
|
+
function serializeMessages(messages) {
|
|
351
|
+
return JSON.stringify(messages);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// src/protocol/schema.ts
|
|
355
|
+
var import_zod = require("zod");
|
|
356
|
+
var OUTPUT_TYPE_VALUES = [
|
|
357
|
+
"Vibrate",
|
|
358
|
+
"Rotate",
|
|
359
|
+
"RotateWithDirection",
|
|
360
|
+
"Oscillate",
|
|
361
|
+
"Constrict",
|
|
362
|
+
"Spray",
|
|
363
|
+
"Temperature",
|
|
364
|
+
"Led",
|
|
365
|
+
"Position",
|
|
366
|
+
"HwPositionWithDuration"
|
|
367
|
+
];
|
|
368
|
+
var OutputTypeSchema = import_zod.z.enum(OUTPUT_TYPE_VALUES);
|
|
369
|
+
var INPUT_TYPE_VALUES = ["Battery", "RSSI", "Pressure", "Button", "Position"];
|
|
370
|
+
var InputTypeSchema = import_zod.z.enum(INPUT_TYPE_VALUES);
|
|
371
|
+
var InputCommandTypeSchema = import_zod.z.enum(["Read", "Subscribe", "Unsubscribe"]);
|
|
372
|
+
var BaseMessageSchema = import_zod.z.object({
|
|
373
|
+
Id: import_zod.z.number().int().min(0).max(MAX_MESSAGE_ID)
|
|
374
|
+
});
|
|
375
|
+
var RequestServerInfoSchema = BaseMessageSchema.extend({
|
|
376
|
+
ClientName: import_zod.z.string().min(1),
|
|
377
|
+
ProtocolVersionMajor: import_zod.z.number().int(),
|
|
378
|
+
ProtocolVersionMinor: import_zod.z.number().int()
|
|
379
|
+
});
|
|
380
|
+
var StartScanningSchema = BaseMessageSchema;
|
|
381
|
+
var StopScanningSchema = BaseMessageSchema;
|
|
382
|
+
var RequestDeviceListSchema = BaseMessageSchema;
|
|
383
|
+
var PingSchema = BaseMessageSchema;
|
|
384
|
+
var DisconnectSchema = BaseMessageSchema;
|
|
385
|
+
var StopCmdSchema = BaseMessageSchema.extend({
|
|
386
|
+
DeviceIndex: import_zod.z.number().int().optional(),
|
|
387
|
+
FeatureIndex: import_zod.z.number().int().optional(),
|
|
388
|
+
Inputs: import_zod.z.boolean().optional(),
|
|
389
|
+
Outputs: import_zod.z.boolean().optional()
|
|
390
|
+
});
|
|
391
|
+
var UnsignedScalarOutputDataSchema = import_zod.z.object({
|
|
392
|
+
Value: import_zod.z.number().int().nonnegative()
|
|
393
|
+
});
|
|
394
|
+
var SignedScalarOutputDataSchema = import_zod.z.object({
|
|
395
|
+
Value: import_zod.z.number().int()
|
|
396
|
+
});
|
|
397
|
+
var RotateWithDirectionOutputDataSchema = import_zod.z.object({
|
|
398
|
+
Value: import_zod.z.number().int().nonnegative(),
|
|
399
|
+
Clockwise: import_zod.z.boolean()
|
|
400
|
+
});
|
|
401
|
+
var HwPositionOutputDataSchema = import_zod.z.object({
|
|
402
|
+
Position: import_zod.z.number().int().nonnegative(),
|
|
403
|
+
Duration: import_zod.z.number().int().nonnegative()
|
|
404
|
+
});
|
|
405
|
+
var OutputCommandSchema = import_zod.z.union([
|
|
406
|
+
import_zod.z.strictObject({ Vibrate: UnsignedScalarOutputDataSchema }),
|
|
407
|
+
import_zod.z.strictObject({ Rotate: UnsignedScalarOutputDataSchema }),
|
|
408
|
+
import_zod.z.strictObject({ RotateWithDirection: RotateWithDirectionOutputDataSchema }),
|
|
409
|
+
import_zod.z.strictObject({ Oscillate: UnsignedScalarOutputDataSchema }),
|
|
410
|
+
import_zod.z.strictObject({ Constrict: UnsignedScalarOutputDataSchema }),
|
|
411
|
+
import_zod.z.strictObject({ Spray: UnsignedScalarOutputDataSchema }),
|
|
412
|
+
import_zod.z.strictObject({ Temperature: SignedScalarOutputDataSchema }),
|
|
413
|
+
import_zod.z.strictObject({ Led: UnsignedScalarOutputDataSchema }),
|
|
414
|
+
import_zod.z.strictObject({ Position: UnsignedScalarOutputDataSchema }),
|
|
415
|
+
import_zod.z.strictObject({ HwPositionWithDuration: HwPositionOutputDataSchema })
|
|
416
|
+
]);
|
|
417
|
+
var OutputCmdSchema = BaseMessageSchema.extend({
|
|
418
|
+
DeviceIndex: import_zod.z.number().int(),
|
|
419
|
+
FeatureIndex: import_zod.z.number().int(),
|
|
420
|
+
Command: OutputCommandSchema
|
|
421
|
+
});
|
|
422
|
+
var InputCmdSchema = BaseMessageSchema.extend({
|
|
423
|
+
DeviceIndex: import_zod.z.number().int(),
|
|
424
|
+
FeatureIndex: import_zod.z.number().int(),
|
|
425
|
+
Type: InputTypeSchema,
|
|
426
|
+
Command: InputCommandTypeSchema
|
|
427
|
+
});
|
|
428
|
+
var ClientMessageSchema = import_zod.z.union([
|
|
429
|
+
import_zod.z.strictObject({ RequestServerInfo: RequestServerInfoSchema }),
|
|
430
|
+
import_zod.z.strictObject({ StartScanning: StartScanningSchema }),
|
|
431
|
+
import_zod.z.strictObject({ StopScanning: StopScanningSchema }),
|
|
432
|
+
import_zod.z.strictObject({ RequestDeviceList: RequestDeviceListSchema }),
|
|
433
|
+
import_zod.z.strictObject({ Ping: PingSchema }),
|
|
434
|
+
import_zod.z.strictObject({ Disconnect: DisconnectSchema }),
|
|
435
|
+
import_zod.z.strictObject({ StopCmd: StopCmdSchema }),
|
|
436
|
+
import_zod.z.strictObject({ OutputCmd: OutputCmdSchema }),
|
|
437
|
+
import_zod.z.strictObject({ InputCmd: InputCmdSchema })
|
|
438
|
+
]);
|
|
439
|
+
var ServerInfoSchema = BaseMessageSchema.extend({
|
|
440
|
+
ServerName: import_zod.z.string().nullish(),
|
|
441
|
+
ProtocolVersionMajor: import_zod.z.number().int(),
|
|
442
|
+
ProtocolVersionMinor: import_zod.z.number().int(),
|
|
443
|
+
MaxPingTime: import_zod.z.number().int()
|
|
444
|
+
});
|
|
445
|
+
var OkSchema = BaseMessageSchema;
|
|
446
|
+
var ErrorMsgSchema = BaseMessageSchema.extend({
|
|
447
|
+
ErrorCode: import_zod.z.nativeEnum(ErrorCode),
|
|
448
|
+
ErrorMessage: import_zod.z.string()
|
|
449
|
+
});
|
|
450
|
+
var SensorValueSchema = import_zod.z.object({ Value: import_zod.z.number().int() });
|
|
451
|
+
var InputDataSchema = import_zod.z.union([
|
|
452
|
+
import_zod.z.strictObject({ Battery: SensorValueSchema }),
|
|
453
|
+
import_zod.z.strictObject({ RSSI: SensorValueSchema }),
|
|
454
|
+
import_zod.z.strictObject({ Pressure: SensorValueSchema }),
|
|
455
|
+
import_zod.z.strictObject({ Button: SensorValueSchema }),
|
|
456
|
+
import_zod.z.strictObject({ Position: SensorValueSchema })
|
|
457
|
+
]);
|
|
458
|
+
var InputReadingSchema = BaseMessageSchema.extend({
|
|
459
|
+
DeviceIndex: import_zod.z.number().int(),
|
|
460
|
+
FeatureIndex: import_zod.z.number().int(),
|
|
461
|
+
Reading: InputDataSchema
|
|
462
|
+
});
|
|
463
|
+
var RawFeatureOutputSchema = import_zod.z.object({
|
|
464
|
+
Value: import_zod.z.tuple([import_zod.z.number().int(), import_zod.z.number().int()]),
|
|
465
|
+
Duration: import_zod.z.tuple([import_zod.z.number().int().nonnegative(), import_zod.z.number().int().nonnegative()]).optional()
|
|
466
|
+
});
|
|
467
|
+
var RawFeatureInputSchema = import_zod.z.object({
|
|
468
|
+
Command: import_zod.z.array(InputCommandTypeSchema),
|
|
469
|
+
Value: import_zod.z.array(import_zod.z.tuple([import_zod.z.number().int(), import_zod.z.number().int()]))
|
|
470
|
+
});
|
|
471
|
+
var RawDeviceFeatureSchema = import_zod.z.object({
|
|
472
|
+
FeatureIndex: import_zod.z.number().int(),
|
|
473
|
+
FeatureDescription: import_zod.z.string(),
|
|
474
|
+
Output: import_zod.z.record(import_zod.z.string(), RawFeatureOutputSchema).nullish(),
|
|
475
|
+
Input: import_zod.z.record(import_zod.z.string(), RawFeatureInputSchema).nullish()
|
|
476
|
+
});
|
|
477
|
+
var RawDeviceSchema = import_zod.z.object({
|
|
478
|
+
DeviceName: import_zod.z.string(),
|
|
479
|
+
DeviceIndex: import_zod.z.number().int(),
|
|
480
|
+
DeviceMessageTimingGap: import_zod.z.number().int(),
|
|
481
|
+
DeviceDisplayName: import_zod.z.string().nullish(),
|
|
482
|
+
DeviceFeatures: import_zod.z.record(import_zod.z.string(), RawDeviceFeatureSchema)
|
|
483
|
+
});
|
|
484
|
+
var DeviceListSchema = BaseMessageSchema.extend({
|
|
485
|
+
Devices: import_zod.z.record(import_zod.z.string(), RawDeviceSchema)
|
|
486
|
+
});
|
|
487
|
+
var ScanningFinishedSchema = BaseMessageSchema;
|
|
488
|
+
var ServerMessageSchema = import_zod.z.union([
|
|
489
|
+
import_zod.z.strictObject({ ServerInfo: ServerInfoSchema }),
|
|
490
|
+
import_zod.z.strictObject({ Ok: OkSchema }),
|
|
491
|
+
import_zod.z.strictObject({ Error: ErrorMsgSchema }),
|
|
492
|
+
import_zod.z.strictObject({ DeviceList: DeviceListSchema }),
|
|
493
|
+
import_zod.z.strictObject({ ScanningFinished: ScanningFinishedSchema }),
|
|
494
|
+
import_zod.z.strictObject({ InputReading: InputReadingSchema })
|
|
495
|
+
]);
|
|
496
|
+
var OutputFeatureSchema = import_zod.z.object({
|
|
497
|
+
type: OutputTypeSchema,
|
|
498
|
+
index: import_zod.z.number().int(),
|
|
499
|
+
description: import_zod.z.string(),
|
|
500
|
+
range: import_zod.z.tuple([import_zod.z.number().int(), import_zod.z.number().int()]),
|
|
501
|
+
durationRange: import_zod.z.tuple([import_zod.z.number().int(), import_zod.z.number().int()]).optional()
|
|
502
|
+
});
|
|
503
|
+
var InputFeatureSchema = import_zod.z.object({
|
|
504
|
+
type: InputTypeSchema,
|
|
505
|
+
index: import_zod.z.number().int(),
|
|
506
|
+
description: import_zod.z.string(),
|
|
507
|
+
range: import_zod.z.tuple([import_zod.z.number().int(), import_zod.z.number().int()]),
|
|
508
|
+
canRead: import_zod.z.boolean(),
|
|
509
|
+
canSubscribe: import_zod.z.boolean()
|
|
510
|
+
});
|
|
511
|
+
var DeviceFeaturesSchema = import_zod.z.object({
|
|
512
|
+
outputs: import_zod.z.array(OutputFeatureSchema),
|
|
513
|
+
inputs: import_zod.z.array(InputFeatureSchema)
|
|
514
|
+
});
|
|
515
|
+
var FeatureValueSchema = import_zod.z.object({
|
|
516
|
+
index: import_zod.z.number().int(),
|
|
517
|
+
value: import_zod.z.number().int()
|
|
518
|
+
});
|
|
519
|
+
var RotationValueSchema = import_zod.z.object({
|
|
520
|
+
index: import_zod.z.number().int(),
|
|
521
|
+
speed: import_zod.z.number().int(),
|
|
522
|
+
clockwise: import_zod.z.boolean()
|
|
523
|
+
});
|
|
524
|
+
var PositionValueSchema = import_zod.z.object({
|
|
525
|
+
index: import_zod.z.number().int(),
|
|
526
|
+
position: import_zod.z.number().int(),
|
|
527
|
+
duration: import_zod.z.number().int()
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// src/protocol/parser.ts
|
|
531
|
+
function parseServerMessages(raw, logger = noopLogger) {
|
|
532
|
+
const parsed = JSON.parse(raw);
|
|
533
|
+
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
534
|
+
throw new Error("Invalid server message: expected non-empty array");
|
|
535
|
+
}
|
|
536
|
+
const messages = [];
|
|
537
|
+
for (const element of parsed) {
|
|
538
|
+
if (typeof element !== "object" || element === null) {
|
|
539
|
+
throw new Error("Invalid server message: expected object");
|
|
540
|
+
}
|
|
541
|
+
const keys = Object.keys(element);
|
|
542
|
+
if (keys.length !== 1) {
|
|
543
|
+
throw new Error(`Invalid server message: expected exactly one key, got ${keys.length}`);
|
|
544
|
+
}
|
|
545
|
+
const result = ServerMessageSchema.safeParse(element);
|
|
546
|
+
if (result.success) {
|
|
547
|
+
messages.push(result.data);
|
|
548
|
+
} else {
|
|
549
|
+
logger.warn(`Unknown server message type: ${keys[0]}`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return messages;
|
|
553
|
+
}
|
|
554
|
+
function getMessageType(message) {
|
|
555
|
+
const keys = Object.keys(message);
|
|
556
|
+
if (keys.length !== 1) {
|
|
557
|
+
throw new Error("Invalid message: expected exactly one key");
|
|
558
|
+
}
|
|
559
|
+
return keys[0];
|
|
560
|
+
}
|
|
561
|
+
function extractId(message) {
|
|
562
|
+
const type = getMessageType(message);
|
|
563
|
+
const inner = message[type];
|
|
564
|
+
if (typeof inner?.Id !== "number") {
|
|
565
|
+
throw new Error(`Message type "${type}" has no valid Id field`);
|
|
566
|
+
}
|
|
567
|
+
return inner.Id;
|
|
568
|
+
}
|
|
569
|
+
function isServerInfo(message) {
|
|
570
|
+
return "ServerInfo" in message;
|
|
571
|
+
}
|
|
572
|
+
function isOk(message) {
|
|
573
|
+
return "Ok" in message;
|
|
574
|
+
}
|
|
575
|
+
function isError(message) {
|
|
576
|
+
return "Error" in message;
|
|
577
|
+
}
|
|
578
|
+
function isDeviceList(message) {
|
|
579
|
+
return "DeviceList" in message;
|
|
580
|
+
}
|
|
581
|
+
function isScanningFinished(message) {
|
|
582
|
+
return "ScanningFinished" in message;
|
|
583
|
+
}
|
|
584
|
+
function isInputReading(message) {
|
|
585
|
+
return "InputReading" in message;
|
|
586
|
+
}
|
|
587
|
+
function getServerInfo(message) {
|
|
588
|
+
return message.ServerInfo;
|
|
589
|
+
}
|
|
590
|
+
function getError(message) {
|
|
591
|
+
return message.Error;
|
|
592
|
+
}
|
|
593
|
+
function getDeviceList(message) {
|
|
594
|
+
return message.DeviceList;
|
|
595
|
+
}
|
|
596
|
+
function getInputReading(message) {
|
|
597
|
+
return message.InputReading;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// src/core/handshake.ts
|
|
601
|
+
async function performHandshake(options) {
|
|
602
|
+
const { router, clientName, pingManager, logger = noopLogger } = options;
|
|
603
|
+
let response;
|
|
604
|
+
try {
|
|
605
|
+
const responses = await router.send(createRequestServerInfo(router.nextId(), clientName));
|
|
606
|
+
response = responses[0];
|
|
607
|
+
} catch (err) {
|
|
608
|
+
throw new HandshakeError(
|
|
609
|
+
`Handshake failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
610
|
+
err instanceof Error ? err : void 0
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
if (!isServerInfo(response)) {
|
|
614
|
+
throw new HandshakeError("Handshake failed: unexpected response type");
|
|
615
|
+
}
|
|
616
|
+
const serverInfo = getServerInfo(response);
|
|
617
|
+
if (serverInfo.ProtocolVersionMajor !== PROTOCOL_VERSION_MAJOR) {
|
|
618
|
+
throw new HandshakeError(
|
|
619
|
+
`Server protocol version ${serverInfo.ProtocolVersionMajor} is incompatible (client requires ${PROTOCOL_VERSION_MAJOR})`
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
const negotiatedMinor = Math.min(PROTOCOL_VERSION_MINOR, serverInfo.ProtocolVersionMinor);
|
|
623
|
+
logger.info(`Protocol version negotiated: ${PROTOCOL_VERSION_MAJOR}.${negotiatedMinor}`);
|
|
624
|
+
pingManager.start(serverInfo.MaxPingTime);
|
|
625
|
+
return serverInfo;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// src/core/message-router.ts
|
|
629
|
+
var MessageRouter = class {
|
|
630
|
+
/** In-flight requests awaiting server responses, keyed by message ID. */
|
|
631
|
+
#pending = /* @__PURE__ */ new Map();
|
|
632
|
+
#send;
|
|
633
|
+
#timeout;
|
|
634
|
+
#logger;
|
|
635
|
+
#onDeviceList;
|
|
636
|
+
#onScanningFinished;
|
|
637
|
+
#onInputReading;
|
|
638
|
+
#onError;
|
|
639
|
+
#messageId = 0;
|
|
640
|
+
/**
|
|
641
|
+
* @param options - Router configuration including transport function and event callbacks
|
|
642
|
+
*/
|
|
643
|
+
constructor(options) {
|
|
644
|
+
this.#send = options.send;
|
|
645
|
+
this.#timeout = options.timeout ?? DEFAULT_REQUEST_TIMEOUT;
|
|
646
|
+
this.#logger = (options.logger ?? noopLogger).child("router");
|
|
647
|
+
this.#onDeviceList = options.onDeviceList;
|
|
648
|
+
this.#onScanningFinished = options.onScanningFinished;
|
|
649
|
+
this.#onInputReading = options.onInputReading;
|
|
650
|
+
this.#onError = options.onError;
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Returns the next message ID, wrapping around at {@link MAX_MESSAGE_ID}.
|
|
654
|
+
*
|
|
655
|
+
* @returns A monotonically increasing ID for use in outgoing messages
|
|
656
|
+
*/
|
|
657
|
+
nextId() {
|
|
658
|
+
this.#messageId = this.#messageId % MAX_MESSAGE_ID + 1;
|
|
659
|
+
return this.#messageId;
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Sends one or more client messages and returns promises for their responses.
|
|
663
|
+
*
|
|
664
|
+
* Each message is tracked by its ID with an automatic timeout. If the transport
|
|
665
|
+
* function throws, all pending requests from this batch are cleaned up.
|
|
666
|
+
*
|
|
667
|
+
* @param input - A single message or array of messages to send
|
|
668
|
+
* @returns A promise resolving to an array of server responses, one per input message
|
|
669
|
+
* @throws {TimeoutError} if a response is not received within the configured timeout
|
|
670
|
+
* @throws {ProtocolError} if a message has an invalid structure
|
|
671
|
+
*/
|
|
672
|
+
send(input) {
|
|
673
|
+
const messages = Array.isArray(input) ? input : [input];
|
|
674
|
+
const serialized = serializeMessages(messages);
|
|
675
|
+
const label = messages.length === 1 ? "message" : `batch (${messages.length})`;
|
|
676
|
+
this.#logger.debug(`Sending ${label}: ${serialized}`);
|
|
677
|
+
const promises = messages.map((message) => {
|
|
678
|
+
const id = this.#extractMessageId(message);
|
|
679
|
+
return new Promise((resolve, reject) => {
|
|
680
|
+
const timeoutHandle = setTimeout(() => {
|
|
681
|
+
this.#pending.delete(id);
|
|
682
|
+
reject(new TimeoutError(`Request (ID ${id})`, this.#timeout));
|
|
683
|
+
}, this.#timeout);
|
|
684
|
+
this.#pending.set(id, {
|
|
685
|
+
resolve,
|
|
686
|
+
reject: (err) => {
|
|
687
|
+
clearTimeout(timeoutHandle);
|
|
688
|
+
reject(err);
|
|
689
|
+
},
|
|
690
|
+
timeout: timeoutHandle
|
|
691
|
+
});
|
|
692
|
+
});
|
|
693
|
+
});
|
|
694
|
+
try {
|
|
695
|
+
this.#send(serialized);
|
|
696
|
+
} catch (err) {
|
|
697
|
+
const ids = messages.map((m) => this.#extractMessageId(m));
|
|
698
|
+
for (const id of ids) {
|
|
699
|
+
const pending = this.#pending.get(id);
|
|
700
|
+
if (pending?.timeout) {
|
|
701
|
+
clearTimeout(pending.timeout);
|
|
702
|
+
}
|
|
703
|
+
this.#pending.delete(id);
|
|
704
|
+
}
|
|
705
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
706
|
+
}
|
|
707
|
+
return Promise.all(promises);
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Processes a raw incoming message string from the server.
|
|
711
|
+
*
|
|
712
|
+
* Parses the JSON, then routes each message to its pending request or to
|
|
713
|
+
* the appropriate event callback.
|
|
714
|
+
*
|
|
715
|
+
* @param raw - The raw JSON string received from the server
|
|
716
|
+
*/
|
|
717
|
+
handleMessage(raw) {
|
|
718
|
+
this.#logger.debug(`Received message: ${raw}`);
|
|
719
|
+
let messages;
|
|
720
|
+
try {
|
|
721
|
+
messages = parseServerMessages(raw, this.#logger);
|
|
722
|
+
} catch (err) {
|
|
723
|
+
this.#logger.error(`Failed to parse message: ${formatError(err)}`);
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
for (const message of messages) {
|
|
727
|
+
this.#processMessage(message);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Cancels a single pending request by ID, rejecting its promise with the given error.
|
|
732
|
+
*
|
|
733
|
+
* @param id - The message ID of the request to cancel
|
|
734
|
+
* @param error - The error to reject the pending promise with
|
|
735
|
+
*/
|
|
736
|
+
cancelPending(id, error) {
|
|
737
|
+
const pending = this.#pending.get(id);
|
|
738
|
+
if (pending) {
|
|
739
|
+
if (pending.timeout !== null) {
|
|
740
|
+
clearTimeout(pending.timeout);
|
|
741
|
+
}
|
|
742
|
+
this.#pending.delete(id);
|
|
743
|
+
pending.reject(error);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Cancels all pending requests, rejecting each with the given error.
|
|
748
|
+
*
|
|
749
|
+
* @param error - The error to reject all pending promises with
|
|
750
|
+
*/
|
|
751
|
+
cancelAll(error) {
|
|
752
|
+
const entries = Array.from(this.#pending.values());
|
|
753
|
+
this.#pending.clear();
|
|
754
|
+
for (const pending of entries) {
|
|
755
|
+
if (pending.timeout !== null) {
|
|
756
|
+
clearTimeout(pending.timeout);
|
|
757
|
+
}
|
|
758
|
+
pending.reject(error);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
/** Resets the message ID counter — required after reconnect to avoid collision with old IDs. */
|
|
762
|
+
resetId() {
|
|
763
|
+
this.#messageId = 0;
|
|
764
|
+
}
|
|
765
|
+
/** Number of in-flight requests currently awaiting responses. */
|
|
766
|
+
get pendingCount() {
|
|
767
|
+
return this.#pending.size;
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Routes a parsed message to its pending request or to the event handler.
|
|
771
|
+
* Messages with ID 0 or unmatched IDs are treated as unsolicited events.
|
|
772
|
+
*/
|
|
773
|
+
#processMessage(message) {
|
|
774
|
+
const id = extractId(message);
|
|
775
|
+
if (id === 0) {
|
|
776
|
+
this.#routeEvent(message);
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
const pending = this.#pending.get(id);
|
|
780
|
+
if (!pending) {
|
|
781
|
+
this.#routeEvent(message);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
if (pending.timeout !== null) {
|
|
785
|
+
clearTimeout(pending.timeout);
|
|
786
|
+
}
|
|
787
|
+
this.#pending.delete(id);
|
|
788
|
+
if (isOk(message) || isServerInfo(message) || isInputReading(message)) {
|
|
789
|
+
pending.resolve(message);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
if (isError(message)) {
|
|
793
|
+
const error = getError(message);
|
|
794
|
+
pending.reject(new ProtocolError(error.ErrorCode, error.ErrorMessage));
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
if (isDeviceList(message)) {
|
|
798
|
+
pending.resolve(message);
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
this.#logger.warn(`Unexpected response type for pending request ${id}`);
|
|
802
|
+
pending.resolve(message);
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Dispatches an unsolicited server event to the appropriate callback.
|
|
806
|
+
* Logs a warning if the message type has no registered handler.
|
|
807
|
+
*/
|
|
808
|
+
#routeEvent(message) {
|
|
809
|
+
if (isDeviceList(message)) {
|
|
810
|
+
const deviceList = getDeviceList(message);
|
|
811
|
+
this.#onDeviceList?.(Object.values(deviceList.Devices));
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (isScanningFinished(message)) {
|
|
815
|
+
this.#onScanningFinished?.();
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
if (isInputReading(message)) {
|
|
819
|
+
const reading = getInputReading(message);
|
|
820
|
+
this.#onInputReading?.(reading);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (isError(message)) {
|
|
824
|
+
const error = getError(message);
|
|
825
|
+
this.#onError?.(error);
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
this.#logger.warn(`Unexpected message type: ${JSON.stringify(message)}`);
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Extracts the numeric message ID from a client message envelope.
|
|
832
|
+
* @throws {ProtocolError} if the message is malformed or missing an ID
|
|
833
|
+
*/
|
|
834
|
+
#extractMessageId(message) {
|
|
835
|
+
const keys = Object.keys(message);
|
|
836
|
+
if (keys.length !== 1) {
|
|
837
|
+
throw new ProtocolError(ErrorCode.MESSAGE, "Invalid message: expected exactly one key");
|
|
838
|
+
}
|
|
839
|
+
const inner = message[keys[0]];
|
|
840
|
+
if (typeof inner.Id !== "number") {
|
|
841
|
+
throw new ProtocolError(ErrorCode.MESSAGE, "Invalid message: missing or non-numeric Id field");
|
|
842
|
+
}
|
|
843
|
+
return inner.Id;
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
// src/protocol/types.ts
|
|
848
|
+
function sensorKey(deviceIndex, featureIndex, type) {
|
|
849
|
+
return `${deviceIndex}-${featureIndex}-${type}`;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// src/core/sensor-handler.ts
|
|
853
|
+
var SensorHandler = class {
|
|
854
|
+
/** Active subscriptions keyed by composite sensor key. */
|
|
855
|
+
#subscriptions = /* @__PURE__ */ new Map();
|
|
856
|
+
#logger;
|
|
857
|
+
/**
|
|
858
|
+
* @param logger - Logger instance for subscription lifecycle events
|
|
859
|
+
*/
|
|
860
|
+
constructor(logger) {
|
|
861
|
+
this.#logger = logger.child("sensor");
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Registers a callback for a specific sensor on a device.
|
|
865
|
+
*
|
|
866
|
+
* @param key - Composite sensor key from {@link sensorKey}
|
|
867
|
+
* @param callback - Function invoked with the sensor value on each reading
|
|
868
|
+
* @param info - Device index, feature index, and input type for the sensor
|
|
869
|
+
*/
|
|
870
|
+
register(key, callback, info) {
|
|
871
|
+
if (this.#subscriptions.has(key)) {
|
|
872
|
+
throw new Error(`Sensor subscription already exists: ${key}. Unsubscribe before re-subscribing.`);
|
|
873
|
+
}
|
|
874
|
+
this.#subscriptions.set(key, { callback, ...info });
|
|
875
|
+
this.#logger.debug(`Registered sensor subscription: ${key}`);
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Removes a sensor subscription by key.
|
|
879
|
+
*
|
|
880
|
+
* @param key - Composite sensor key to unregister
|
|
881
|
+
*/
|
|
882
|
+
unregister(key) {
|
|
883
|
+
this.#subscriptions.delete(key);
|
|
884
|
+
this.#logger.debug(`Unregistered sensor subscription: ${key}`);
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Routes an incoming sensor reading to a matching subscription callback,
|
|
888
|
+
* or falls back to the provided emit function if no subscription matches.
|
|
889
|
+
*
|
|
890
|
+
* @param reading - The input reading from the server
|
|
891
|
+
* @param emit - Fallback emitter for unmatched readings
|
|
892
|
+
*/
|
|
893
|
+
handleReading(reading, emit) {
|
|
894
|
+
const readingData = reading.Reading;
|
|
895
|
+
const readingKey = Object.keys(readingData)[0];
|
|
896
|
+
if (!(readingKey && readingKey in readingData)) {
|
|
897
|
+
emit(reading);
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
const type = readingKey;
|
|
901
|
+
const subKey = sensorKey(reading.DeviceIndex, reading.FeatureIndex, type);
|
|
902
|
+
const sub = this.#subscriptions.get(subKey);
|
|
903
|
+
if (sub) {
|
|
904
|
+
const wrapper = readingData[type];
|
|
905
|
+
if (wrapper !== void 0) {
|
|
906
|
+
sub.callback(wrapper.Value);
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
emit(reading);
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Sends unsubscribe commands for all subscriptions on a device, then cleans up locally.
|
|
914
|
+
*
|
|
915
|
+
* If the client is disconnected, skips sending commands and only removes local state.
|
|
916
|
+
*
|
|
917
|
+
* @param options - Device index, router for sending commands, and connection status
|
|
918
|
+
*/
|
|
919
|
+
unsubscribeDevice(options) {
|
|
920
|
+
const { deviceIndex, router, connected } = options;
|
|
921
|
+
if (!connected) {
|
|
922
|
+
this.cleanupDevice(deviceIndex);
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
try {
|
|
926
|
+
for (const [, sub] of this.#subscriptions) {
|
|
927
|
+
if (sub.deviceIndex === deviceIndex) {
|
|
928
|
+
const id = router.nextId();
|
|
929
|
+
router.send({
|
|
930
|
+
InputCmd: {
|
|
931
|
+
Id: id,
|
|
932
|
+
DeviceIndex: sub.deviceIndex,
|
|
933
|
+
FeatureIndex: sub.featureIndex,
|
|
934
|
+
Type: sub.type,
|
|
935
|
+
Command: "Unsubscribe"
|
|
936
|
+
}
|
|
937
|
+
}).catch(() => {
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
} finally {
|
|
942
|
+
this.cleanupDevice(deviceIndex);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Removes all local subscriptions for a device without sending unsubscribe commands.
|
|
947
|
+
*
|
|
948
|
+
* @param deviceIndex - Server-assigned index of the device to clean up
|
|
949
|
+
*/
|
|
950
|
+
cleanupDevice(deviceIndex) {
|
|
951
|
+
const keysToDelete = [];
|
|
952
|
+
for (const [key, sub] of this.#subscriptions) {
|
|
953
|
+
if (sub.deviceIndex === deviceIndex) {
|
|
954
|
+
keysToDelete.push(key);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
for (const key of keysToDelete) {
|
|
958
|
+
this.#subscriptions.delete(key);
|
|
959
|
+
this.#logger.debug(`Cleaned up subscription on device removal: ${key}`);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
/** Removes all sensor subscriptions — used during client shutdown. */
|
|
963
|
+
clear() {
|
|
964
|
+
this.#subscriptions.clear();
|
|
965
|
+
}
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
// src/builders/validation.ts
|
|
969
|
+
function validateRange(value, range) {
|
|
970
|
+
const [min, max] = range;
|
|
971
|
+
const rounded = Math.round(value);
|
|
972
|
+
const clamped = Math.max(min, Math.min(max, rounded));
|
|
973
|
+
if (rounded !== clamped) {
|
|
974
|
+
getLogger().debug(`Value ${value} clamped to ${clamped} (range [${min}, ${max}])`);
|
|
975
|
+
}
|
|
976
|
+
return clamped;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// src/builders/commands.ts
|
|
980
|
+
function buildPositionMessage(options) {
|
|
981
|
+
const { client, deviceIndex, positionType, feature, position, duration } = options;
|
|
982
|
+
if (positionType === "Position" && duration !== 0) {
|
|
983
|
+
throw new DeviceError(
|
|
984
|
+
deviceIndex,
|
|
985
|
+
`Position output type does not support duration (got ${duration}ms). Use HwPositionWithDuration for timed movements.`
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
const validatedValue = validateRange(position, feature.range);
|
|
989
|
+
const validatedDuration = feature.durationRange ? validateRange(duration, feature.durationRange) : duration;
|
|
990
|
+
const command = positionType === "HwPositionWithDuration" ? { HwPositionWithDuration: { Position: validatedValue, Duration: validatedDuration } } : { Position: { Value: validatedValue } };
|
|
991
|
+
const id = client.nextId();
|
|
992
|
+
return {
|
|
993
|
+
OutputCmd: {
|
|
994
|
+
Id: id,
|
|
995
|
+
DeviceIndex: deviceIndex,
|
|
996
|
+
FeatureIndex: feature.index,
|
|
997
|
+
Command: command
|
|
998
|
+
}
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
function buildPositionMessages(options) {
|
|
1002
|
+
const { client, deviceIndex, positionType, features, position, duration } = options;
|
|
1003
|
+
const messages = [];
|
|
1004
|
+
if (Array.isArray(position)) {
|
|
1005
|
+
if (position.length === 0) {
|
|
1006
|
+
throw new DeviceError(deviceIndex, "Values array must not be empty");
|
|
1007
|
+
}
|
|
1008
|
+
for (const p of position) {
|
|
1009
|
+
const feature = features[p.index];
|
|
1010
|
+
if (!feature) {
|
|
1011
|
+
throw new DeviceError(
|
|
1012
|
+
deviceIndex,
|
|
1013
|
+
`Invalid position index ${p.index} (device has ${features.length} position feature(s))`
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
messages.push(
|
|
1017
|
+
buildPositionMessage({
|
|
1018
|
+
client,
|
|
1019
|
+
deviceIndex,
|
|
1020
|
+
positionType,
|
|
1021
|
+
feature,
|
|
1022
|
+
position: p.position,
|
|
1023
|
+
duration: p.duration
|
|
1024
|
+
})
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
} else {
|
|
1028
|
+
for (const feature of features) {
|
|
1029
|
+
messages.push(buildPositionMessage({ client, deviceIndex, positionType, feature, position, duration }));
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
return messages;
|
|
1033
|
+
}
|
|
1034
|
+
function buildRotateMessages(options) {
|
|
1035
|
+
const { client, deviceIndex, features, rotationType, speed, clockwise } = options;
|
|
1036
|
+
const messages = [];
|
|
1037
|
+
if (Array.isArray(speed)) {
|
|
1038
|
+
if (speed.length === 0) {
|
|
1039
|
+
throw new DeviceError(deviceIndex, "Values array must not be empty");
|
|
1040
|
+
}
|
|
1041
|
+
for (const r of speed) {
|
|
1042
|
+
const feature = features[r.index];
|
|
1043
|
+
if (!feature) {
|
|
1044
|
+
throw new DeviceError(
|
|
1045
|
+
deviceIndex,
|
|
1046
|
+
`Invalid rotation index ${r.index} (device has ${features.length} rotation feature(s))`
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
const validatedValue = validateRange(r.speed, feature.range);
|
|
1050
|
+
const command = rotationType === "RotateWithDirection" ? { RotateWithDirection: { Value: validatedValue, Clockwise: r.clockwise } } : { Rotate: { Value: validatedValue } };
|
|
1051
|
+
const id = client.nextId();
|
|
1052
|
+
messages.push({
|
|
1053
|
+
OutputCmd: {
|
|
1054
|
+
Id: id,
|
|
1055
|
+
DeviceIndex: deviceIndex,
|
|
1056
|
+
FeatureIndex: feature.index,
|
|
1057
|
+
Command: command
|
|
1058
|
+
}
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
} else {
|
|
1062
|
+
for (const feature of features) {
|
|
1063
|
+
const validatedValue = validateRange(speed, feature.range);
|
|
1064
|
+
const command = rotationType === "RotateWithDirection" ? { RotateWithDirection: { Value: validatedValue, Clockwise: clockwise } } : { Rotate: { Value: validatedValue } };
|
|
1065
|
+
const id = client.nextId();
|
|
1066
|
+
messages.push({
|
|
1067
|
+
OutputCmd: {
|
|
1068
|
+
Id: id,
|
|
1069
|
+
DeviceIndex: deviceIndex,
|
|
1070
|
+
FeatureIndex: feature.index,
|
|
1071
|
+
Command: command
|
|
1072
|
+
}
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
return messages;
|
|
1077
|
+
}
|
|
1078
|
+
function buildScalarOutputMessages(options) {
|
|
1079
|
+
const { client, deviceIndex, type, features, values, errorLabel } = options;
|
|
1080
|
+
if (Array.isArray(values)) {
|
|
1081
|
+
if (values.length === 0) {
|
|
1082
|
+
throw new DeviceError(deviceIndex, "Values array must not be empty");
|
|
1083
|
+
}
|
|
1084
|
+
const messages2 = [];
|
|
1085
|
+
for (const entry of values) {
|
|
1086
|
+
const feature = features[entry.index];
|
|
1087
|
+
if (!feature) {
|
|
1088
|
+
throw new DeviceError(
|
|
1089
|
+
deviceIndex,
|
|
1090
|
+
`Invalid ${errorLabel} index ${entry.index} (device has ${features.length} ${errorLabel} feature(s))`
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
const validatedValue = validateRange(entry.value, feature.range);
|
|
1094
|
+
const id = client.nextId();
|
|
1095
|
+
messages2.push({
|
|
1096
|
+
OutputCmd: {
|
|
1097
|
+
Id: id,
|
|
1098
|
+
DeviceIndex: deviceIndex,
|
|
1099
|
+
FeatureIndex: feature.index,
|
|
1100
|
+
Command: { [type]: { Value: validatedValue } }
|
|
1101
|
+
}
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
return messages2;
|
|
1105
|
+
}
|
|
1106
|
+
const messages = [];
|
|
1107
|
+
for (const feature of features) {
|
|
1108
|
+
const validatedValue = validateRange(values, feature.range);
|
|
1109
|
+
const id = client.nextId();
|
|
1110
|
+
messages.push({
|
|
1111
|
+
OutputCmd: {
|
|
1112
|
+
Id: id,
|
|
1113
|
+
DeviceIndex: deviceIndex,
|
|
1114
|
+
FeatureIndex: feature.index,
|
|
1115
|
+
Command: { [type]: { Value: validatedValue } }
|
|
1116
|
+
}
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
return messages;
|
|
1120
|
+
}
|
|
1121
|
+
async function sendMessages(client, messages) {
|
|
1122
|
+
if (messages.length === 0) {
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
await client.send(messages);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// src/builders/features.ts
|
|
1129
|
+
var OUTPUT_TYPES = OUTPUT_TYPE_VALUES;
|
|
1130
|
+
var INPUT_TYPES = INPUT_TYPE_VALUES;
|
|
1131
|
+
var outputIndex = /* @__PURE__ */ new WeakMap();
|
|
1132
|
+
var inputIndex = /* @__PURE__ */ new WeakMap();
|
|
1133
|
+
function buildOutputIndex(features) {
|
|
1134
|
+
const map = /* @__PURE__ */ new Map();
|
|
1135
|
+
for (const f of features.outputs) {
|
|
1136
|
+
const list = map.get(f.type);
|
|
1137
|
+
if (list) {
|
|
1138
|
+
list.push(f);
|
|
1139
|
+
} else {
|
|
1140
|
+
map.set(f.type, [f]);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
return map;
|
|
1144
|
+
}
|
|
1145
|
+
function buildInputIndex(features) {
|
|
1146
|
+
const map = /* @__PURE__ */ new Map();
|
|
1147
|
+
for (const f of features.inputs) {
|
|
1148
|
+
const list = map.get(f.type);
|
|
1149
|
+
if (list) {
|
|
1150
|
+
list.push(f);
|
|
1151
|
+
} else {
|
|
1152
|
+
map.set(f.type, [f]);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
return map;
|
|
1156
|
+
}
|
|
1157
|
+
function getOutputIndex(features) {
|
|
1158
|
+
let idx = outputIndex.get(features);
|
|
1159
|
+
if (!idx) {
|
|
1160
|
+
idx = buildOutputIndex(features);
|
|
1161
|
+
outputIndex.set(features, idx);
|
|
1162
|
+
}
|
|
1163
|
+
return idx;
|
|
1164
|
+
}
|
|
1165
|
+
function getInputIndex(features) {
|
|
1166
|
+
let idx = inputIndex.get(features);
|
|
1167
|
+
if (!idx) {
|
|
1168
|
+
idx = buildInputIndex(features);
|
|
1169
|
+
inputIndex.set(features, idx);
|
|
1170
|
+
}
|
|
1171
|
+
return idx;
|
|
1172
|
+
}
|
|
1173
|
+
var KNOWN_OUTPUT_KEYS = new Set(OUTPUT_TYPES);
|
|
1174
|
+
var KNOWN_INPUT_KEYS = new Set(INPUT_TYPES);
|
|
1175
|
+
function collectOutputs(feature) {
|
|
1176
|
+
if (!feature.Output) {
|
|
1177
|
+
return [];
|
|
1178
|
+
}
|
|
1179
|
+
const logger = getLogger();
|
|
1180
|
+
const results = [];
|
|
1181
|
+
for (const key of Object.keys(feature.Output)) {
|
|
1182
|
+
if (!KNOWN_OUTPUT_KEYS.has(key)) {
|
|
1183
|
+
logger.warn(`Unknown output type "${key}" at feature index ${feature.FeatureIndex}, skipping`);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
for (const outputType of OUTPUT_TYPES) {
|
|
1187
|
+
const outputConfig = feature.Output[outputType];
|
|
1188
|
+
if (outputConfig) {
|
|
1189
|
+
results.push(parseOutputFeature(outputType, feature.FeatureIndex, feature, outputConfig));
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
return results;
|
|
1193
|
+
}
|
|
1194
|
+
function collectInputs(feature) {
|
|
1195
|
+
if (!feature.Input) {
|
|
1196
|
+
return [];
|
|
1197
|
+
}
|
|
1198
|
+
const logger = getLogger();
|
|
1199
|
+
const results = [];
|
|
1200
|
+
for (const key of Object.keys(feature.Input)) {
|
|
1201
|
+
if (!KNOWN_INPUT_KEYS.has(key)) {
|
|
1202
|
+
logger.warn(`Unknown input type "${key}" at feature index ${feature.FeatureIndex}, skipping`);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
for (const inputType of INPUT_TYPES) {
|
|
1206
|
+
const inputConfig = feature.Input[inputType];
|
|
1207
|
+
if (inputConfig) {
|
|
1208
|
+
results.push(parseInputFeature(inputType, feature.FeatureIndex, feature, inputConfig));
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
return results;
|
|
1212
|
+
}
|
|
1213
|
+
function parseFeatures(raw) {
|
|
1214
|
+
const outputs = [];
|
|
1215
|
+
const inputs = [];
|
|
1216
|
+
const features = raw.DeviceFeatures ?? {};
|
|
1217
|
+
const sortedFeatures = Object.values(features).sort((a, b) => a.FeatureIndex - b.FeatureIndex);
|
|
1218
|
+
for (const feature of sortedFeatures) {
|
|
1219
|
+
for (const output of collectOutputs(feature)) {
|
|
1220
|
+
outputs.push(output);
|
|
1221
|
+
}
|
|
1222
|
+
for (const input of collectInputs(feature)) {
|
|
1223
|
+
inputs.push(input);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
const result = { outputs, inputs };
|
|
1227
|
+
outputIndex.set(result, buildOutputIndex(result));
|
|
1228
|
+
inputIndex.set(result, buildInputIndex(result));
|
|
1229
|
+
return result;
|
|
1230
|
+
}
|
|
1231
|
+
function parseOutputFeature(type, index, feature, output) {
|
|
1232
|
+
return {
|
|
1233
|
+
type,
|
|
1234
|
+
index,
|
|
1235
|
+
description: feature.FeatureDescription,
|
|
1236
|
+
range: output.Value,
|
|
1237
|
+
durationRange: output.Duration
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
function parseInputFeature(type, index, feature, input) {
|
|
1241
|
+
const canRead = input.Command.includes("Read");
|
|
1242
|
+
const canSubscribe = input.Command.includes("Subscribe");
|
|
1243
|
+
const range = input.Value[0] ?? [0, 0];
|
|
1244
|
+
return {
|
|
1245
|
+
type,
|
|
1246
|
+
index,
|
|
1247
|
+
description: feature.FeatureDescription,
|
|
1248
|
+
range,
|
|
1249
|
+
canRead,
|
|
1250
|
+
canSubscribe
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
function hasOutputType(features, type) {
|
|
1254
|
+
const idx = getOutputIndex(features);
|
|
1255
|
+
const list = idx.get(type);
|
|
1256
|
+
return list !== void 0 && list.length > 0;
|
|
1257
|
+
}
|
|
1258
|
+
function getOutputsByType(features, type) {
|
|
1259
|
+
return getOutputIndex(features).get(type) ?? [];
|
|
1260
|
+
}
|
|
1261
|
+
function getInputsByType(features, type) {
|
|
1262
|
+
return getInputIndex(features).get(type) ?? [];
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// src/device.ts
|
|
1266
|
+
var Device = class {
|
|
1267
|
+
#client;
|
|
1268
|
+
#raw;
|
|
1269
|
+
#features;
|
|
1270
|
+
#logger;
|
|
1271
|
+
#lastCommandTime = 0;
|
|
1272
|
+
constructor(options) {
|
|
1273
|
+
this.#client = options.client;
|
|
1274
|
+
this.#raw = options.raw;
|
|
1275
|
+
this.#logger = (options.logger ?? getLogger()).child("device");
|
|
1276
|
+
this.#features = parseFeatures(options.raw);
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* Sets vibration intensity on all or individual motors.
|
|
1280
|
+
*
|
|
1281
|
+
* @param intensity - A single value for all motors, or per-motor {@link FeatureValue} entries
|
|
1282
|
+
* @throws DeviceError if the device does not support vibration
|
|
1283
|
+
*/
|
|
1284
|
+
async vibrate(intensity) {
|
|
1285
|
+
await this.#sendScalarOutput({ type: "Vibrate", errorLabel: "vibration", values: intensity });
|
|
1286
|
+
}
|
|
1287
|
+
/**
|
|
1288
|
+
* Sets oscillation speed on all or individual motors.
|
|
1289
|
+
*
|
|
1290
|
+
* @param speed - A single value for all motors, or per-motor {@link FeatureValue} entries
|
|
1291
|
+
* @throws DeviceError if the device does not support oscillation
|
|
1292
|
+
*/
|
|
1293
|
+
async oscillate(speed) {
|
|
1294
|
+
await this.#sendScalarOutput({ type: "Oscillate", errorLabel: "oscillation", values: speed });
|
|
1295
|
+
}
|
|
1296
|
+
/**
|
|
1297
|
+
* Sets constriction pressure on all or individual actuators.
|
|
1298
|
+
*
|
|
1299
|
+
* @param value - A single value for all actuators, or per-actuator {@link FeatureValue} entries
|
|
1300
|
+
* @throws DeviceError if the device does not support constriction
|
|
1301
|
+
*/
|
|
1302
|
+
async constrict(value) {
|
|
1303
|
+
await this.#sendScalarOutput({ type: "Constrict", errorLabel: "constriction", values: value });
|
|
1304
|
+
}
|
|
1305
|
+
/**
|
|
1306
|
+
* Controls spray output on all or individual actuators.
|
|
1307
|
+
*
|
|
1308
|
+
* @param value - A single value for all actuators, or per-actuator {@link FeatureValue} entries
|
|
1309
|
+
* @throws DeviceError if the device does not support spraying
|
|
1310
|
+
*/
|
|
1311
|
+
async spray(value) {
|
|
1312
|
+
await this.#sendScalarOutput({ type: "Spray", errorLabel: "spraying", values: value });
|
|
1313
|
+
}
|
|
1314
|
+
/**
|
|
1315
|
+
* Sets temperature on all or individual actuators.
|
|
1316
|
+
*
|
|
1317
|
+
* @param value - A single value for all actuators, or per-actuator {@link FeatureValue} entries
|
|
1318
|
+
* @throws DeviceError if the device does not support temperature control
|
|
1319
|
+
*/
|
|
1320
|
+
async temperature(value) {
|
|
1321
|
+
await this.#sendScalarOutput({ type: "Temperature", errorLabel: "temperature control", values: value });
|
|
1322
|
+
}
|
|
1323
|
+
/**
|
|
1324
|
+
* Controls LED brightness on all or individual actuators.
|
|
1325
|
+
*
|
|
1326
|
+
* @param value - A single value for all actuators, or per-actuator {@link FeatureValue} entries
|
|
1327
|
+
* @throws DeviceError if the device does not support LED control
|
|
1328
|
+
*/
|
|
1329
|
+
async led(value) {
|
|
1330
|
+
await this.#sendScalarOutput({ type: "Led", errorLabel: "LED control", values: value });
|
|
1331
|
+
}
|
|
1332
|
+
async rotate(speed, options) {
|
|
1333
|
+
this.#checkTimingGap();
|
|
1334
|
+
if (!this.canRotate) {
|
|
1335
|
+
throw new DeviceError(this.index, "Device does not support rotation");
|
|
1336
|
+
}
|
|
1337
|
+
const rotationType = hasOutputType(this.#features, "RotateWithDirection") ? "RotateWithDirection" : "Rotate";
|
|
1338
|
+
const features = getOutputsByType(this.#features, rotationType);
|
|
1339
|
+
const clockwise = options?.clockwise ?? true;
|
|
1340
|
+
const messages = buildRotateMessages({
|
|
1341
|
+
client: this.#client,
|
|
1342
|
+
deviceIndex: this.index,
|
|
1343
|
+
features,
|
|
1344
|
+
rotationType,
|
|
1345
|
+
speed,
|
|
1346
|
+
clockwise
|
|
1347
|
+
});
|
|
1348
|
+
this.#logger.debug(`Rotate command: ${messages.length} motor(s) on device ${this.name}`);
|
|
1349
|
+
await sendMessages(this.#client, messages);
|
|
1350
|
+
}
|
|
1351
|
+
async position(position, options) {
|
|
1352
|
+
this.#checkTimingGap();
|
|
1353
|
+
if (!this.canPosition) {
|
|
1354
|
+
throw new DeviceError(this.index, "Device does not support position control");
|
|
1355
|
+
}
|
|
1356
|
+
if (typeof position === "number" && options?.duration === void 0) {
|
|
1357
|
+
throw new DeviceError(this.index, "Duration is required when using a uniform position value");
|
|
1358
|
+
}
|
|
1359
|
+
const positionType = hasOutputType(this.#features, "HwPositionWithDuration") ? "HwPositionWithDuration" : "Position";
|
|
1360
|
+
const features = getOutputsByType(this.#features, positionType);
|
|
1361
|
+
const duration = typeof position === "number" ? options?.duration ?? 0 : 0;
|
|
1362
|
+
const messages = buildPositionMessages({
|
|
1363
|
+
client: this.#client,
|
|
1364
|
+
deviceIndex: this.index,
|
|
1365
|
+
positionType,
|
|
1366
|
+
features,
|
|
1367
|
+
position,
|
|
1368
|
+
duration
|
|
1369
|
+
});
|
|
1370
|
+
this.#logger.debug(`Position command: ${messages.length} axis/axes on device ${this.name}`);
|
|
1371
|
+
await sendMessages(this.#client, messages);
|
|
1372
|
+
}
|
|
1373
|
+
/**
|
|
1374
|
+
* Stops activity on this device.
|
|
1375
|
+
*
|
|
1376
|
+
* Can target a specific feature index or filter by input/output type.
|
|
1377
|
+
* Without options, stops all features.
|
|
1378
|
+
*
|
|
1379
|
+
* @param options - Optional filters for which features to stop
|
|
1380
|
+
* @throws DeviceError if the specified feature index does not exist
|
|
1381
|
+
*/
|
|
1382
|
+
async stop(options) {
|
|
1383
|
+
this.#checkTimingGap();
|
|
1384
|
+
if (options?.featureIndex !== void 0) {
|
|
1385
|
+
const isOutput = this.#features.outputs.some((f) => f.index === options.featureIndex);
|
|
1386
|
+
const isInput = this.#features.inputs.some((f) => f.index === options.featureIndex);
|
|
1387
|
+
if (!(isOutput || isInput)) {
|
|
1388
|
+
throw new DeviceError(this.index, `No feature at index ${options.featureIndex}`);
|
|
1389
|
+
}
|
|
1390
|
+
if (isOutput && !isInput && options.outputs === false) {
|
|
1391
|
+
throw new DeviceError(
|
|
1392
|
+
this.index,
|
|
1393
|
+
`Feature at index ${options.featureIndex} is output-only, but outputs filter is false`
|
|
1394
|
+
);
|
|
1395
|
+
}
|
|
1396
|
+
if (isInput && !isOutput && options.inputs === false) {
|
|
1397
|
+
throw new DeviceError(
|
|
1398
|
+
this.index,
|
|
1399
|
+
`Feature at index ${options.featureIndex} is input-only, but inputs filter is false`
|
|
1400
|
+
);
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
this.#logger.debug(`Stop command on device ${this.name} (index ${this.index})`);
|
|
1404
|
+
const id = this.#client.nextId();
|
|
1405
|
+
await this.#client.send({
|
|
1406
|
+
StopCmd: {
|
|
1407
|
+
Id: id,
|
|
1408
|
+
DeviceIndex: this.index,
|
|
1409
|
+
...options?.featureIndex !== void 0 && { FeatureIndex: options.featureIndex },
|
|
1410
|
+
...options?.inputs !== void 0 && { Inputs: options.inputs },
|
|
1411
|
+
...options?.outputs !== void 0 && { Outputs: options.outputs }
|
|
1412
|
+
}
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
/**
|
|
1416
|
+
* Sends a raw output command to a specific feature.
|
|
1417
|
+
*
|
|
1418
|
+
* Values are validated against the feature's declared range and clamped if out of bounds.
|
|
1419
|
+
*
|
|
1420
|
+
* @param options - The feature index and output command payload
|
|
1421
|
+
* @throws DeviceError if no matching output feature exists at the given index
|
|
1422
|
+
*/
|
|
1423
|
+
async output(options) {
|
|
1424
|
+
this.#checkTimingGap();
|
|
1425
|
+
const { featureIndex, command } = options;
|
|
1426
|
+
const commandType = Object.keys(command)[0];
|
|
1427
|
+
const feature = this.#features.outputs.find((f) => f.index === featureIndex && f.type === commandType);
|
|
1428
|
+
if (!feature) {
|
|
1429
|
+
throw new DeviceError(this.index, `No "${commandType}" output feature at index ${featureIndex}`);
|
|
1430
|
+
}
|
|
1431
|
+
const commandData = Object.values(command)[0];
|
|
1432
|
+
if (commandType === "HwPositionWithDuration") {
|
|
1433
|
+
const data = commandData;
|
|
1434
|
+
data.Position = validateRange(data.Position, feature.range);
|
|
1435
|
+
} else {
|
|
1436
|
+
const data = commandData;
|
|
1437
|
+
data.Value = validateRange(data.Value, feature.range);
|
|
1438
|
+
}
|
|
1439
|
+
const validatedCommand = command;
|
|
1440
|
+
this.#logger.debug(`Output command: ${commandType} on device ${this.name} feature ${featureIndex}`);
|
|
1441
|
+
const id = this.#client.nextId();
|
|
1442
|
+
await this.#client.send({
|
|
1443
|
+
OutputCmd: {
|
|
1444
|
+
Id: id,
|
|
1445
|
+
DeviceIndex: this.index,
|
|
1446
|
+
FeatureIndex: featureIndex,
|
|
1447
|
+
Command: validatedCommand
|
|
1448
|
+
}
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
/**
|
|
1452
|
+
* Performs a one-shot read of a sensor value.
|
|
1453
|
+
*
|
|
1454
|
+
* @param type - The sensor type to read (e.g. `"Battery"`, `"RSSI"`)
|
|
1455
|
+
* @param sensorIndex - Index of the sensor if the device has multiple of the same type
|
|
1456
|
+
* @returns The numeric sensor value
|
|
1457
|
+
* @throws DeviceError if the sensor does not exist or does not support reading
|
|
1458
|
+
*/
|
|
1459
|
+
async readSensor(type, sensorIndex = 0) {
|
|
1460
|
+
const feature = this.#requireSensor({ type, sensorIndex, capability: "canRead" });
|
|
1461
|
+
const response = await this.#sendInputCmd({ featureIndex: feature.index, type, command: "Read" });
|
|
1462
|
+
if ("InputReading" in response) {
|
|
1463
|
+
const reading = response.InputReading.Reading;
|
|
1464
|
+
const wrapper = type in reading ? reading[type] : void 0;
|
|
1465
|
+
if (wrapper !== void 0) {
|
|
1466
|
+
return wrapper.Value;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
throw new DeviceError(this.index, `Failed to read ${type} sensor: unexpected response`);
|
|
1470
|
+
}
|
|
1471
|
+
/**
|
|
1472
|
+
* Subscribes to continuous sensor readings.
|
|
1473
|
+
*
|
|
1474
|
+
* @param type - The sensor type to subscribe to
|
|
1475
|
+
* @param callback - Invoked each time a new reading arrives
|
|
1476
|
+
* @param sensorIndex - Index of the sensor if the device has multiple of the same type
|
|
1477
|
+
* @returns An async unsubscribe function that stops the subscription
|
|
1478
|
+
* @throws DeviceError if the sensor does not exist or does not support subscriptions
|
|
1479
|
+
*/
|
|
1480
|
+
async subscribeSensor(type, callback, sensorIndex = 0) {
|
|
1481
|
+
const feature = this.#requireSensor({ type, sensorIndex, capability: "canSubscribe" });
|
|
1482
|
+
const subscriptionKey = sensorKey(this.index, feature.index, type);
|
|
1483
|
+
await this.#sendInputCmd({ featureIndex: feature.index, type, command: "Subscribe" });
|
|
1484
|
+
this.#client.registerSensorSubscription(subscriptionKey, callback, {
|
|
1485
|
+
deviceIndex: this.index,
|
|
1486
|
+
featureIndex: feature.index,
|
|
1487
|
+
type
|
|
1488
|
+
});
|
|
1489
|
+
return async () => {
|
|
1490
|
+
this.#client.unregisterSensorSubscription(subscriptionKey);
|
|
1491
|
+
await this.#sendInputCmd({ featureIndex: feature.index, type, command: "Unsubscribe" });
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
/**
|
|
1495
|
+
* Explicitly unsubscribes from a sensor subscription by type and sensor index.
|
|
1496
|
+
*
|
|
1497
|
+
* @param type - The sensor type to unsubscribe from
|
|
1498
|
+
* @param sensorIndex - Index of the sensor if the device has multiple of the same type
|
|
1499
|
+
*/
|
|
1500
|
+
async unsubscribe(type, sensorIndex = 0) {
|
|
1501
|
+
const features = getInputsByType(this.#features, type);
|
|
1502
|
+
const feature = features[sensorIndex];
|
|
1503
|
+
if (!feature) {
|
|
1504
|
+
throw new DeviceError(this.index, `Device does not have ${type} sensor at index ${sensorIndex}`);
|
|
1505
|
+
}
|
|
1506
|
+
const subscriptionKey = sensorKey(this.index, feature.index, type);
|
|
1507
|
+
this.#client.unregisterSensorSubscription(subscriptionKey);
|
|
1508
|
+
await this.#sendInputCmd({ featureIndex: feature.index, type, command: "Unsubscribe" });
|
|
1509
|
+
}
|
|
1510
|
+
/**
|
|
1511
|
+
* Checks whether this device supports a given output type.
|
|
1512
|
+
*
|
|
1513
|
+
* @param type - The output type to check
|
|
1514
|
+
* @returns `true` if at least one feature supports the output type
|
|
1515
|
+
*/
|
|
1516
|
+
canOutput(type) {
|
|
1517
|
+
return hasOutputType(this.#features, type);
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* Checks whether this device can perform a one-shot read of a given sensor type.
|
|
1521
|
+
*
|
|
1522
|
+
* @param type - The input type to check
|
|
1523
|
+
* @returns `true` if at least one matching sensor supports reading
|
|
1524
|
+
*/
|
|
1525
|
+
canRead(type) {
|
|
1526
|
+
return getInputsByType(this.#features, type).some((f) => f.canRead);
|
|
1527
|
+
}
|
|
1528
|
+
/**
|
|
1529
|
+
* Checks whether this device supports subscriptions for a given sensor type.
|
|
1530
|
+
*
|
|
1531
|
+
* @param type - The input type to check
|
|
1532
|
+
* @returns `true` if at least one matching sensor supports subscriptions
|
|
1533
|
+
*/
|
|
1534
|
+
canSubscribe(type) {
|
|
1535
|
+
return getInputsByType(this.#features, type).some((f) => f.canSubscribe);
|
|
1536
|
+
}
|
|
1537
|
+
/** Server-assigned device index. */
|
|
1538
|
+
get index() {
|
|
1539
|
+
return this.#raw.DeviceIndex;
|
|
1540
|
+
}
|
|
1541
|
+
/** Internal device name from firmware. */
|
|
1542
|
+
get name() {
|
|
1543
|
+
return this.#raw.DeviceName;
|
|
1544
|
+
}
|
|
1545
|
+
/** User-facing display name, or `null` if the server did not provide one. */
|
|
1546
|
+
get displayName() {
|
|
1547
|
+
return this.#raw.DeviceDisplayName ?? null;
|
|
1548
|
+
}
|
|
1549
|
+
/** Minimum interval in milliseconds between messages recommended by the server. */
|
|
1550
|
+
get messageTimingGap() {
|
|
1551
|
+
return this.#raw.DeviceMessageTimingGap;
|
|
1552
|
+
}
|
|
1553
|
+
/** Parsed input and output feature descriptors for this device. */
|
|
1554
|
+
get features() {
|
|
1555
|
+
return this.#features;
|
|
1556
|
+
}
|
|
1557
|
+
/** Whether this device supports any form of rotation output. */
|
|
1558
|
+
get canRotate() {
|
|
1559
|
+
return this.canOutput("Rotate") || this.canOutput("RotateWithDirection");
|
|
1560
|
+
}
|
|
1561
|
+
/** Whether this device supports any form of position output. */
|
|
1562
|
+
get canPosition() {
|
|
1563
|
+
return this.canOutput("Position") || this.canOutput("HwPositionWithDuration");
|
|
1564
|
+
}
|
|
1565
|
+
/** Warns when commands are sent faster than the device's timing gap. */
|
|
1566
|
+
#checkTimingGap() {
|
|
1567
|
+
const gap = this.#raw.DeviceMessageTimingGap;
|
|
1568
|
+
if (gap <= 0) {
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
const now = Date.now();
|
|
1572
|
+
const elapsed = now - this.#lastCommandTime;
|
|
1573
|
+
if (this.#lastCommandTime > 0 && elapsed < gap) {
|
|
1574
|
+
this.#logger.warn(
|
|
1575
|
+
`Command sent ${elapsed}ms after previous (timing gap is ${gap}ms) \u2014 server may drop this command`
|
|
1576
|
+
);
|
|
1577
|
+
}
|
|
1578
|
+
this.#lastCommandTime = now;
|
|
1579
|
+
}
|
|
1580
|
+
/**
|
|
1581
|
+
* Validates sensor existence and capability.
|
|
1582
|
+
*
|
|
1583
|
+
* @throws DeviceError if sensor doesn't exist or lacks the capability
|
|
1584
|
+
*/
|
|
1585
|
+
#requireSensor(params) {
|
|
1586
|
+
const { type, sensorIndex, capability } = params;
|
|
1587
|
+
const features = getInputsByType(this.#features, type);
|
|
1588
|
+
const feature = features[sensorIndex];
|
|
1589
|
+
if (!feature) {
|
|
1590
|
+
throw new DeviceError(this.index, `Device does not have ${type} sensor at index ${sensorIndex}`);
|
|
1591
|
+
}
|
|
1592
|
+
const label = capability === "canRead" ? "reading" : "subscriptions";
|
|
1593
|
+
if (!feature[capability]) {
|
|
1594
|
+
throw new DeviceError(this.index, `${type} sensor at index ${sensorIndex} does not support ${label}`);
|
|
1595
|
+
}
|
|
1596
|
+
return feature;
|
|
1597
|
+
}
|
|
1598
|
+
/** Executes InputCmd and returns the server's first response. */
|
|
1599
|
+
async #sendInputCmd(params) {
|
|
1600
|
+
const { featureIndex, type, command } = params;
|
|
1601
|
+
const id = this.#client.nextId();
|
|
1602
|
+
const responses = await this.#client.send({
|
|
1603
|
+
InputCmd: { Id: id, DeviceIndex: this.index, FeatureIndex: featureIndex, Type: type, Command: command }
|
|
1604
|
+
});
|
|
1605
|
+
return responses[0];
|
|
1606
|
+
}
|
|
1607
|
+
/**
|
|
1608
|
+
* Sends scalar output commands after validating feature support.
|
|
1609
|
+
*
|
|
1610
|
+
* @throws DeviceError if the output type is not supported
|
|
1611
|
+
*/
|
|
1612
|
+
async #sendScalarOutput(params) {
|
|
1613
|
+
this.#checkTimingGap();
|
|
1614
|
+
const { type, errorLabel, values } = params;
|
|
1615
|
+
if (!this.canOutput(type)) {
|
|
1616
|
+
throw new DeviceError(this.index, `Device does not support ${errorLabel}`);
|
|
1617
|
+
}
|
|
1618
|
+
const features = getOutputsByType(this.#features, type);
|
|
1619
|
+
const messages = buildScalarOutputMessages({
|
|
1620
|
+
client: this.#client,
|
|
1621
|
+
deviceIndex: this.index,
|
|
1622
|
+
type,
|
|
1623
|
+
features,
|
|
1624
|
+
values,
|
|
1625
|
+
errorLabel
|
|
1626
|
+
});
|
|
1627
|
+
this.#logger.debug(`${type} command: ${messages.length} actuator(s) on device ${this.name}`);
|
|
1628
|
+
await sendMessages(this.#client, messages);
|
|
1629
|
+
}
|
|
1630
|
+
};
|
|
1631
|
+
|
|
1632
|
+
// src/transport/connection.ts
|
|
1633
|
+
var WebSocketTransport = class {
|
|
1634
|
+
/** Logger for connection diagnostics. */
|
|
1635
|
+
#logger;
|
|
1636
|
+
/** Map of event names to sets of handler callbacks. */
|
|
1637
|
+
#listeners = /* @__PURE__ */ new Map();
|
|
1638
|
+
/** Active WebSocket instance, null when disconnected. */
|
|
1639
|
+
#ws = null;
|
|
1640
|
+
/** Current connection lifecycle state. */
|
|
1641
|
+
#state = "disconnected";
|
|
1642
|
+
/** Tracks in-flight connection attempt to deduplicate concurrent connect calls. */
|
|
1643
|
+
#connectPromise = null;
|
|
1644
|
+
/** Set by disconnect() when a connect() is in flight to signal early teardown. */
|
|
1645
|
+
#disconnectRequested = false;
|
|
1646
|
+
/** Stored handler references for cleanup. */
|
|
1647
|
+
#handleMessage = null;
|
|
1648
|
+
#handleClose = null;
|
|
1649
|
+
#handleError = null;
|
|
1650
|
+
constructor(options = {}) {
|
|
1651
|
+
this.#logger = (options.logger ?? noopLogger).child("ws-transport");
|
|
1652
|
+
}
|
|
1653
|
+
/**
|
|
1654
|
+
* Opens a WebSocket connection to the given URL.
|
|
1655
|
+
*
|
|
1656
|
+
* Returns immediately if already connected. Deduplicates concurrent
|
|
1657
|
+
* connect calls by returning the same in-flight promise.
|
|
1658
|
+
*
|
|
1659
|
+
* @param url - The WebSocket endpoint to connect to
|
|
1660
|
+
* @throws {ConnectionError} if the connection fails or is closed during handshake
|
|
1661
|
+
*/
|
|
1662
|
+
connect(url) {
|
|
1663
|
+
if (this.#state === "connected") {
|
|
1664
|
+
return Promise.resolve();
|
|
1665
|
+
}
|
|
1666
|
+
if (this.#connectPromise) {
|
|
1667
|
+
return this.#connectPromise;
|
|
1668
|
+
}
|
|
1669
|
+
this.#state = "connecting";
|
|
1670
|
+
this.#disconnectRequested = false;
|
|
1671
|
+
this.#logger.debug(`Opening WebSocket connection to ${url}`);
|
|
1672
|
+
this.#connectPromise = new Promise((resolve, reject) => {
|
|
1673
|
+
try {
|
|
1674
|
+
this.#ws = new WebSocket(url);
|
|
1675
|
+
} catch (error) {
|
|
1676
|
+
this.#state = "disconnected";
|
|
1677
|
+
reject(
|
|
1678
|
+
new ConnectionError(
|
|
1679
|
+
`Failed to create WebSocket: ${formatError(error)}`,
|
|
1680
|
+
error instanceof Error ? error : void 0
|
|
1681
|
+
)
|
|
1682
|
+
);
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
const handleOpen = () => {
|
|
1686
|
+
cleanup();
|
|
1687
|
+
if (this.#disconnectRequested) {
|
|
1688
|
+
this.#disconnectRequested = false;
|
|
1689
|
+
this.#cleanup();
|
|
1690
|
+
resolve();
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
this.#state = "connected";
|
|
1694
|
+
this.#attachHandlers();
|
|
1695
|
+
this.#logger.info("WebSocket connected");
|
|
1696
|
+
this.#emit("open");
|
|
1697
|
+
resolve();
|
|
1698
|
+
};
|
|
1699
|
+
const handleError = (event) => {
|
|
1700
|
+
cleanup();
|
|
1701
|
+
this.#logger.error(`WebSocket error during connect: ${event.type}`);
|
|
1702
|
+
const error = new ConnectionError(`WebSocket error: ${event.type}`);
|
|
1703
|
+
this.#state = "disconnected";
|
|
1704
|
+
this.#ws = null;
|
|
1705
|
+
this.#emit("error", error);
|
|
1706
|
+
reject(error);
|
|
1707
|
+
};
|
|
1708
|
+
const handleClose = (event) => {
|
|
1709
|
+
cleanup();
|
|
1710
|
+
this.#state = "disconnected";
|
|
1711
|
+
this.#ws = null;
|
|
1712
|
+
const reason = event.reason || `Code: ${event.code}`;
|
|
1713
|
+
this.#logger.info(`WebSocket closed during connect (code: ${event.code})`);
|
|
1714
|
+
this.#emit("close", event.code, reason);
|
|
1715
|
+
reject(new ConnectionError(`WebSocket closed during connect: ${reason}`));
|
|
1716
|
+
};
|
|
1717
|
+
const cleanup = () => {
|
|
1718
|
+
if (this.#ws) {
|
|
1719
|
+
this.#ws.removeEventListener("open", handleOpen);
|
|
1720
|
+
this.#ws.removeEventListener("error", handleError);
|
|
1721
|
+
this.#ws.removeEventListener("close", handleClose);
|
|
1722
|
+
}
|
|
1723
|
+
};
|
|
1724
|
+
this.#ws.addEventListener("open", handleOpen);
|
|
1725
|
+
this.#ws.addEventListener("error", handleError);
|
|
1726
|
+
this.#ws.addEventListener("close", handleClose);
|
|
1727
|
+
}).finally(() => {
|
|
1728
|
+
this.#connectPromise = null;
|
|
1729
|
+
});
|
|
1730
|
+
return this.#connectPromise;
|
|
1731
|
+
}
|
|
1732
|
+
/**
|
|
1733
|
+
* Closes the active WebSocket connection.
|
|
1734
|
+
*
|
|
1735
|
+
* No-ops if already disconnected. Waits for the close handshake to complete
|
|
1736
|
+
* if the socket is currently open or closing.
|
|
1737
|
+
*/
|
|
1738
|
+
disconnect() {
|
|
1739
|
+
if (this.#state === "disconnected") {
|
|
1740
|
+
return Promise.resolve();
|
|
1741
|
+
}
|
|
1742
|
+
if (this.#connectPromise) {
|
|
1743
|
+
this.#disconnectRequested = true;
|
|
1744
|
+
}
|
|
1745
|
+
this.#logger.info("Disconnecting WebSocket");
|
|
1746
|
+
if (!this.#ws || this.#ws.readyState === WebSocket.CLOSED) {
|
|
1747
|
+
this.#cleanup();
|
|
1748
|
+
return Promise.resolve();
|
|
1749
|
+
}
|
|
1750
|
+
return new Promise((resolve) => {
|
|
1751
|
+
const ws = this.#ws;
|
|
1752
|
+
if (ws?.readyState === WebSocket.CLOSING) {
|
|
1753
|
+
const onClose2 = () => {
|
|
1754
|
+
ws.removeEventListener("close", onClose2);
|
|
1755
|
+
this.#cleanup();
|
|
1756
|
+
resolve();
|
|
1757
|
+
};
|
|
1758
|
+
ws.addEventListener("close", onClose2);
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
if (!ws) {
|
|
1762
|
+
this.#cleanup();
|
|
1763
|
+
resolve();
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
const onClose = () => {
|
|
1767
|
+
ws.removeEventListener("close", onClose);
|
|
1768
|
+
this.#cleanup();
|
|
1769
|
+
resolve();
|
|
1770
|
+
};
|
|
1771
|
+
ws.addEventListener("close", onClose);
|
|
1772
|
+
ws.close(1e3, "Client disconnect");
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
/**
|
|
1776
|
+
* Sends a text message over the active WebSocket.
|
|
1777
|
+
*
|
|
1778
|
+
* @param data - The string payload to send
|
|
1779
|
+
* @throws {ConnectionError} if the WebSocket is not in the OPEN state or send fails
|
|
1780
|
+
*/
|
|
1781
|
+
send(data) {
|
|
1782
|
+
if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) {
|
|
1783
|
+
throw new ConnectionError("Cannot send: WebSocket is not connected");
|
|
1784
|
+
}
|
|
1785
|
+
try {
|
|
1786
|
+
this.#ws.send(data);
|
|
1787
|
+
} catch (error) {
|
|
1788
|
+
throw new ConnectionError(
|
|
1789
|
+
`Failed to send data: ${formatError(error)}`,
|
|
1790
|
+
error instanceof Error ? error : void 0
|
|
1791
|
+
);
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
/**
|
|
1795
|
+
* Subscribes a handler for the given transport event.
|
|
1796
|
+
*
|
|
1797
|
+
* @param event - The event to listen for
|
|
1798
|
+
* @param handler - The callback to invoke when the event fires
|
|
1799
|
+
*/
|
|
1800
|
+
on(event, handler) {
|
|
1801
|
+
let handlers = this.#listeners.get(event);
|
|
1802
|
+
if (!handlers) {
|
|
1803
|
+
handlers = /* @__PURE__ */ new Set();
|
|
1804
|
+
this.#listeners.set(event, handlers);
|
|
1805
|
+
}
|
|
1806
|
+
handlers.add(handler);
|
|
1807
|
+
}
|
|
1808
|
+
/**
|
|
1809
|
+
* Removes a previously registered handler for the given event.
|
|
1810
|
+
*
|
|
1811
|
+
* @param event - The event to stop listening for
|
|
1812
|
+
* @param handler - The callback to remove
|
|
1813
|
+
*/
|
|
1814
|
+
off(event, handler) {
|
|
1815
|
+
const handlers = this.#listeners.get(event);
|
|
1816
|
+
if (handlers) {
|
|
1817
|
+
handlers.delete(handler);
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
/** Current connection lifecycle state. */
|
|
1821
|
+
get state() {
|
|
1822
|
+
return this.#state;
|
|
1823
|
+
}
|
|
1824
|
+
/** Dispatches an event to all registered handlers for that event name. */
|
|
1825
|
+
#emit(event, ...args) {
|
|
1826
|
+
const handlers = this.#listeners.get(event);
|
|
1827
|
+
if (!handlers) {
|
|
1828
|
+
return;
|
|
1829
|
+
}
|
|
1830
|
+
for (const handler of handlers) {
|
|
1831
|
+
try {
|
|
1832
|
+
handler(...args);
|
|
1833
|
+
} catch (err) {
|
|
1834
|
+
this.#logger.error(`Error in ${event} handler: ${formatError(err)}`);
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
/** Wires up message, close, and error listeners on the active WebSocket. */
|
|
1839
|
+
#attachHandlers() {
|
|
1840
|
+
const ws = this.#ws;
|
|
1841
|
+
if (!ws) {
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
this.#handleMessage = (event) => {
|
|
1845
|
+
if (typeof event.data === "string") {
|
|
1846
|
+
this.#emit("message", event.data);
|
|
1847
|
+
}
|
|
1848
|
+
};
|
|
1849
|
+
this.#handleClose = (event) => {
|
|
1850
|
+
this.#removeHandlers();
|
|
1851
|
+
this.#state = "disconnected";
|
|
1852
|
+
this.#ws = null;
|
|
1853
|
+
const reason = event.reason || `Code: ${event.code}`;
|
|
1854
|
+
this.#logger.info(`WebSocket closed (code: ${event.code}, reason: ${reason})`);
|
|
1855
|
+
this.#emit("close", event.code, reason);
|
|
1856
|
+
};
|
|
1857
|
+
this.#handleError = (event) => {
|
|
1858
|
+
this.#logger.error(`WebSocket error: ${event.type}`);
|
|
1859
|
+
this.#emit("error", new ConnectionError(`WebSocket error: ${event.type}`));
|
|
1860
|
+
};
|
|
1861
|
+
ws.addEventListener("message", this.#handleMessage);
|
|
1862
|
+
ws.addEventListener("close", this.#handleClose);
|
|
1863
|
+
ws.addEventListener("error", this.#handleError);
|
|
1864
|
+
}
|
|
1865
|
+
/** Removes message/close/error listeners from the active WebSocket. */
|
|
1866
|
+
#removeHandlers() {
|
|
1867
|
+
const ws = this.#ws;
|
|
1868
|
+
if (!ws) {
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
1871
|
+
if (this.#handleMessage) {
|
|
1872
|
+
ws.removeEventListener("message", this.#handleMessage);
|
|
1873
|
+
this.#handleMessage = null;
|
|
1874
|
+
}
|
|
1875
|
+
if (this.#handleClose) {
|
|
1876
|
+
ws.removeEventListener("close", this.#handleClose);
|
|
1877
|
+
this.#handleClose = null;
|
|
1878
|
+
}
|
|
1879
|
+
if (this.#handleError) {
|
|
1880
|
+
ws.removeEventListener("error", this.#handleError);
|
|
1881
|
+
this.#handleError = null;
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
/** Removes listeners, nulls the socket reference, and resets state to disconnected. */
|
|
1885
|
+
#cleanup() {
|
|
1886
|
+
this.#removeHandlers();
|
|
1887
|
+
this.#ws = null;
|
|
1888
|
+
this.#state = "disconnected";
|
|
1889
|
+
this.#logger.debug("WebSocket cleaned up");
|
|
1890
|
+
}
|
|
1891
|
+
};
|
|
1892
|
+
|
|
1893
|
+
// src/transport/ping.ts
|
|
1894
|
+
var MIN_PING_INTERVAL_MS = 100;
|
|
1895
|
+
var DEFAULT_PING_TIMEOUT_MS = 5e3;
|
|
1896
|
+
var PingManager = class {
|
|
1897
|
+
/** Sends a protocol-level ping and resolves when the pong arrives. */
|
|
1898
|
+
#sendPing;
|
|
1899
|
+
/** Cancels an in-flight ping with the given error (e.g. on timeout). */
|
|
1900
|
+
#cancelPing;
|
|
1901
|
+
/** Logger for ping diagnostics. */
|
|
1902
|
+
#logger;
|
|
1903
|
+
/** Whether automatic periodic pings are enabled. */
|
|
1904
|
+
#autoPing;
|
|
1905
|
+
/** Callback invoked when a ping fails with a non-timeout error. */
|
|
1906
|
+
#onError;
|
|
1907
|
+
/** Callback to initiate disconnect when a ping times out. */
|
|
1908
|
+
#onDisconnect;
|
|
1909
|
+
/** Returns whether the transport is currently connected. */
|
|
1910
|
+
#isConnected;
|
|
1911
|
+
/** Interval timer that triggers periodic ping attempts. */
|
|
1912
|
+
#pingTimer = null;
|
|
1913
|
+
/** Tracks whether a ping request is currently awaiting a response. */
|
|
1914
|
+
#pingInFlight = false;
|
|
1915
|
+
/** Maximum time in ms the server allows between pings. */
|
|
1916
|
+
#maxPingTime = 0;
|
|
1917
|
+
constructor(options) {
|
|
1918
|
+
this.#sendPing = options.sendPing;
|
|
1919
|
+
this.#cancelPing = options.cancelPing;
|
|
1920
|
+
this.#logger = (options.logger ?? noopLogger).child("ping");
|
|
1921
|
+
this.#autoPing = options.autoPing ?? true;
|
|
1922
|
+
this.#onError = options.onError;
|
|
1923
|
+
this.#onDisconnect = options.onDisconnect;
|
|
1924
|
+
this.#isConnected = options.isConnected;
|
|
1925
|
+
}
|
|
1926
|
+
/**
|
|
1927
|
+
* Starts the periodic ping timer.
|
|
1928
|
+
*
|
|
1929
|
+
* The ping interval is 60% of `maxPingTime`, clamped to a minimum of 100ms.
|
|
1930
|
+
* Stops any previously running timer before starting a new one.
|
|
1931
|
+
*
|
|
1932
|
+
* **Important**: Callers must call {@link PingManager.stop} when the transport
|
|
1933
|
+
* disconnects to prevent pings from being sent to a closed connection.
|
|
1934
|
+
*
|
|
1935
|
+
* @param maxPingTime - Maximum time in ms the server allows between pings
|
|
1936
|
+
*/
|
|
1937
|
+
start(maxPingTime) {
|
|
1938
|
+
if (this.#pingInFlight) {
|
|
1939
|
+
this.#cancelPing(new TimeoutError("Ping", 0));
|
|
1940
|
+
}
|
|
1941
|
+
this.stop();
|
|
1942
|
+
this.#maxPingTime = maxPingTime;
|
|
1943
|
+
if (!this.#autoPing || maxPingTime <= 0) {
|
|
1944
|
+
return;
|
|
1945
|
+
}
|
|
1946
|
+
const pingInterval = Math.max(Math.floor(maxPingTime * 0.6), MIN_PING_INTERVAL_MS);
|
|
1947
|
+
this.#logger.debug(`Starting ping timer with interval ${pingInterval}ms`);
|
|
1948
|
+
this.#pingTimer = setInterval(() => {
|
|
1949
|
+
if (!this.#isConnected()) {
|
|
1950
|
+
return;
|
|
1951
|
+
}
|
|
1952
|
+
if (this.#pingInFlight) {
|
|
1953
|
+
this.#logger.warn("Skipping ping: previous ping still in flight");
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1956
|
+
this.#pingInFlight = true;
|
|
1957
|
+
this.#doPing().finally(() => {
|
|
1958
|
+
this.#pingInFlight = false;
|
|
1959
|
+
});
|
|
1960
|
+
}, pingInterval);
|
|
1961
|
+
}
|
|
1962
|
+
/** Stops the ping timer and resets in-flight state. */
|
|
1963
|
+
stop() {
|
|
1964
|
+
if (this.#pingTimer !== null) {
|
|
1965
|
+
clearInterval(this.#pingTimer);
|
|
1966
|
+
this.#pingTimer = null;
|
|
1967
|
+
this.#pingInFlight = false;
|
|
1968
|
+
this.#logger.debug("Stopped ping timer");
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
/** Sends a single ping and handles timeout or failure. */
|
|
1972
|
+
async #doPing() {
|
|
1973
|
+
this.#logger.debug("Sending ping");
|
|
1974
|
+
const maxPingTime = this.#maxPingTime || DEFAULT_PING_TIMEOUT_MS;
|
|
1975
|
+
const timer = setTimeout(() => {
|
|
1976
|
+
this.#cancelPing(new TimeoutError("Ping", maxPingTime));
|
|
1977
|
+
}, maxPingTime);
|
|
1978
|
+
try {
|
|
1979
|
+
await this.#sendPing();
|
|
1980
|
+
} catch (err) {
|
|
1981
|
+
const isTimeout = err instanceof TimeoutError;
|
|
1982
|
+
this.#logger.error(`Ping failed: ${formatError(err)}`);
|
|
1983
|
+
this.#onError(err instanceof Error ? err : new Error(String(err)));
|
|
1984
|
+
if (isTimeout && this.#isConnected()) {
|
|
1985
|
+
await this.#onDisconnect("Ping response timeout");
|
|
1986
|
+
} else if (!isTimeout) {
|
|
1987
|
+
this.#logger.warn("Ping failed with non-timeout error, not disconnecting");
|
|
1988
|
+
}
|
|
1989
|
+
} finally {
|
|
1990
|
+
clearTimeout(timer);
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
};
|
|
1994
|
+
|
|
1995
|
+
// src/transport/constants.ts
|
|
1996
|
+
var ReconnectDefaults = {
|
|
1997
|
+
DELAY: 1e3,
|
|
1998
|
+
MAX_DELAY: 3e4,
|
|
1999
|
+
MAX_ATTEMPTS: 10
|
|
2000
|
+
};
|
|
2001
|
+
|
|
2002
|
+
// src/transport/reconnect.ts
|
|
2003
|
+
var MAX_BACKOFF_EXPONENT = 30;
|
|
2004
|
+
var ReconnectHandler = class {
|
|
2005
|
+
/** The WebSocket endpoint URL to reconnect to. */
|
|
2006
|
+
#url;
|
|
2007
|
+
/** The transport instance to reconnect. */
|
|
2008
|
+
#transport;
|
|
2009
|
+
/** Base delay in ms before the first reconnect attempt. */
|
|
2010
|
+
#reconnectDelay;
|
|
2011
|
+
/** Upper bound in ms for exponential backoff. */
|
|
2012
|
+
#maxReconnectDelay;
|
|
2013
|
+
/** Maximum number of reconnect attempts before giving up. */
|
|
2014
|
+
#maxReconnectAttempts;
|
|
2015
|
+
/** Logger for reconnection diagnostics. */
|
|
2016
|
+
#logger;
|
|
2017
|
+
/** Callback invoked before each reconnect attempt. */
|
|
2018
|
+
#onReconnecting;
|
|
2019
|
+
/** Callback invoked when reconnection succeeds. */
|
|
2020
|
+
#onReconnected;
|
|
2021
|
+
/** Callback invoked when all reconnect attempts are exhausted. */
|
|
2022
|
+
#onFailed;
|
|
2023
|
+
/** Current reconnect attempt number, incremented before each attempt. */
|
|
2024
|
+
#reconnectAttempt = 0;
|
|
2025
|
+
/** Timer that schedules the next reconnect attempt. */
|
|
2026
|
+
#reconnectTimer = null;
|
|
2027
|
+
/** Whether a reconnection sequence is currently active. */
|
|
2028
|
+
#reconnecting = false;
|
|
2029
|
+
/** Whether the reconnection sequence was explicitly cancelled. */
|
|
2030
|
+
#cancelled = false;
|
|
2031
|
+
constructor(options) {
|
|
2032
|
+
this.#url = options.url;
|
|
2033
|
+
this.#transport = options.transport;
|
|
2034
|
+
this.#reconnectDelay = options.reconnectDelay ?? ReconnectDefaults.DELAY;
|
|
2035
|
+
this.#maxReconnectDelay = options.maxReconnectDelay ?? ReconnectDefaults.MAX_DELAY;
|
|
2036
|
+
this.#maxReconnectAttempts = options.maxReconnectAttempts ?? ReconnectDefaults.MAX_ATTEMPTS;
|
|
2037
|
+
this.#logger = (options.logger ?? noopLogger).child("reconnect");
|
|
2038
|
+
this.#onReconnecting = options.onReconnecting;
|
|
2039
|
+
this.#onReconnected = options.onReconnected;
|
|
2040
|
+
this.#onFailed = options.onFailed;
|
|
2041
|
+
}
|
|
2042
|
+
/**
|
|
2043
|
+
* Begins the reconnection sequence.
|
|
2044
|
+
*
|
|
2045
|
+
* No-ops if a reconnection is already in progress.
|
|
2046
|
+
*/
|
|
2047
|
+
start() {
|
|
2048
|
+
if (this.#reconnecting) {
|
|
2049
|
+
return;
|
|
2050
|
+
}
|
|
2051
|
+
this.#logger.info("Starting reconnection sequence");
|
|
2052
|
+
this.#reconnecting = true;
|
|
2053
|
+
this.#cancelled = false;
|
|
2054
|
+
this.#attemptReconnect();
|
|
2055
|
+
}
|
|
2056
|
+
/** Cancels the reconnection sequence and clears any pending timers. */
|
|
2057
|
+
cancel() {
|
|
2058
|
+
this.#logger.debug("Reconnect cancelled");
|
|
2059
|
+
this.#cancelled = true;
|
|
2060
|
+
if (this.#reconnectTimer) {
|
|
2061
|
+
clearTimeout(this.#reconnectTimer);
|
|
2062
|
+
this.#reconnectTimer = null;
|
|
2063
|
+
}
|
|
2064
|
+
this.#reconnecting = false;
|
|
2065
|
+
this.#reconnectAttempt = 0;
|
|
2066
|
+
}
|
|
2067
|
+
/** Whether a reconnection sequence is currently in progress. */
|
|
2068
|
+
get active() {
|
|
2069
|
+
return this.#reconnecting;
|
|
2070
|
+
}
|
|
2071
|
+
/** Safely invokes a user callback, catching and logging any errors. */
|
|
2072
|
+
#safeCallback(name, fn) {
|
|
2073
|
+
try {
|
|
2074
|
+
fn();
|
|
2075
|
+
} catch (err) {
|
|
2076
|
+
this.#logger.error(`Error in ${name} callback: ${formatError(err)}`);
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
/** Schedules the next reconnect attempt with exponential backoff. */
|
|
2080
|
+
#attemptReconnect() {
|
|
2081
|
+
if (this.#cancelled || !this.#reconnecting) {
|
|
2082
|
+
return;
|
|
2083
|
+
}
|
|
2084
|
+
this.#reconnectAttempt++;
|
|
2085
|
+
if (this.#reconnectAttempt > this.#maxReconnectAttempts) {
|
|
2086
|
+
const reason = `Failed to reconnect after ${this.#maxReconnectAttempts} attempts`;
|
|
2087
|
+
this.#logger.error(reason);
|
|
2088
|
+
this.#reconnecting = false;
|
|
2089
|
+
if (this.#onFailed) {
|
|
2090
|
+
this.#safeCallback("onFailed", () => this.#onFailed?.(reason));
|
|
2091
|
+
}
|
|
2092
|
+
return;
|
|
2093
|
+
}
|
|
2094
|
+
if (this.#onReconnecting) {
|
|
2095
|
+
this.#safeCallback("onReconnecting", () => this.#onReconnecting?.(this.#reconnectAttempt));
|
|
2096
|
+
}
|
|
2097
|
+
const exponent = Math.min(this.#reconnectAttempt - 1, MAX_BACKOFF_EXPONENT);
|
|
2098
|
+
const delay = Math.min(this.#reconnectDelay * 2 ** exponent, this.#maxReconnectDelay);
|
|
2099
|
+
this.#logger.info(
|
|
2100
|
+
`Reconnect attempt ${this.#reconnectAttempt}/${this.#maxReconnectAttempts} (delay: ${delay}ms)`
|
|
2101
|
+
);
|
|
2102
|
+
this.#reconnectTimer = setTimeout(async () => {
|
|
2103
|
+
if (this.#cancelled || !this.#reconnecting) {
|
|
2104
|
+
return;
|
|
2105
|
+
}
|
|
2106
|
+
try {
|
|
2107
|
+
if (this.#transport.state !== "disconnected") {
|
|
2108
|
+
await this.#transport.disconnect();
|
|
2109
|
+
}
|
|
2110
|
+
await this.#transport.connect(this.#url);
|
|
2111
|
+
if (this.#cancelled) {
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
this.#reconnecting = false;
|
|
2115
|
+
this.#reconnectAttempt = 0;
|
|
2116
|
+
this.#logger.info("Reconnection successful");
|
|
2117
|
+
if (this.#onReconnected) {
|
|
2118
|
+
this.#safeCallback("onReconnected", () => this.#onReconnected?.());
|
|
2119
|
+
}
|
|
2120
|
+
} catch (err) {
|
|
2121
|
+
if (this.#cancelled) {
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
this.#logger.debug(`Reconnect attempt ${this.#reconnectAttempt} failed: ${formatError(err)}`);
|
|
2125
|
+
this.#attemptReconnect();
|
|
2126
|
+
}
|
|
2127
|
+
}, delay);
|
|
2128
|
+
}
|
|
2129
|
+
};
|
|
2130
|
+
|
|
2131
|
+
// src/client.ts
|
|
2132
|
+
var STOP_DEVICES_TIMEOUT_MS = 2e3;
|
|
2133
|
+
var DISCONNECT_TIMEOUT_MS = 3e3;
|
|
2134
|
+
var ButtplugClient = class extends import_emittery.default {
|
|
2135
|
+
#url;
|
|
2136
|
+
#clientName;
|
|
2137
|
+
#baseLogger;
|
|
2138
|
+
#logger;
|
|
2139
|
+
#transport;
|
|
2140
|
+
#messageRouter;
|
|
2141
|
+
#pingManager;
|
|
2142
|
+
#sensorHandler;
|
|
2143
|
+
#reconnectHandler;
|
|
2144
|
+
#devices = /* @__PURE__ */ new Map();
|
|
2145
|
+
#scanning = false;
|
|
2146
|
+
#serverInfo = null;
|
|
2147
|
+
#connectPromise = null;
|
|
2148
|
+
#isHandshaking = false;
|
|
2149
|
+
constructor(url, options = {}) {
|
|
2150
|
+
super();
|
|
2151
|
+
this.#url = url;
|
|
2152
|
+
this.#clientName = options.clientName ?? DEFAULT_CLIENT_NAME;
|
|
2153
|
+
this.#baseLogger = options.logger ?? noopLogger;
|
|
2154
|
+
this.#logger = this.#baseLogger.child("client");
|
|
2155
|
+
this.#transport = new WebSocketTransport({ logger: this.#baseLogger });
|
|
2156
|
+
this.#transport.on("message", (data) => {
|
|
2157
|
+
this.#messageRouter.handleMessage(data);
|
|
2158
|
+
});
|
|
2159
|
+
this.#transport.on("close", (_code, reason) => {
|
|
2160
|
+
this.#safetyStop();
|
|
2161
|
+
this.#pingManager.stop();
|
|
2162
|
+
this.emit("disconnected", { reason });
|
|
2163
|
+
});
|
|
2164
|
+
this.#transport.on("error", (error) => {
|
|
2165
|
+
this.emit("error", { error });
|
|
2166
|
+
});
|
|
2167
|
+
const routerOpts = {
|
|
2168
|
+
send: (data) => this.#transport.send(data),
|
|
2169
|
+
timeout: options.requestTimeout,
|
|
2170
|
+
logger: this.#baseLogger,
|
|
2171
|
+
onDeviceList: (devices) => runWithLogger(
|
|
2172
|
+
this.#logger,
|
|
2173
|
+
() => reconcileDevices({
|
|
2174
|
+
currentDevices: this.#devices,
|
|
2175
|
+
incomingRaw: devices,
|
|
2176
|
+
createDevice: (raw) => new Device({ client: this, raw }),
|
|
2177
|
+
callbacks: {
|
|
2178
|
+
onAdded: (d) => this.emit("deviceAdded", { device: d }),
|
|
2179
|
+
onRemoved: (d) => this.emit("deviceRemoved", { device: d }),
|
|
2180
|
+
onUpdated: (d, old) => this.emit("deviceUpdated", { device: d, previousDevice: old }),
|
|
2181
|
+
onList: (list) => this.emit("deviceList", { devices: list })
|
|
2182
|
+
}
|
|
2183
|
+
})
|
|
2184
|
+
),
|
|
2185
|
+
onScanningFinished: () => {
|
|
2186
|
+
this.#scanning = false;
|
|
2187
|
+
this.emit("scanningFinished", void 0);
|
|
2188
|
+
},
|
|
2189
|
+
onInputReading: (reading) => {
|
|
2190
|
+
this.#sensorHandler.handleReading(reading, (r) => this.emit("inputReading", { reading: r }));
|
|
2191
|
+
},
|
|
2192
|
+
onError: (error) => {
|
|
2193
|
+
this.#logger.warn(`System error from server: [${error.ErrorCode}] ${error.ErrorMessage}`);
|
|
2194
|
+
this.emit("error", { error: new ProtocolError(error.ErrorCode, error.ErrorMessage) });
|
|
2195
|
+
if (error.ErrorCode === ErrorCode.PING) {
|
|
2196
|
+
this.#logger.error("Server ping timeout \u2014 server will halt devices and disconnect");
|
|
2197
|
+
this.disconnect("Server ping timeout");
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
};
|
|
2201
|
+
this.#messageRouter = new MessageRouter(routerOpts);
|
|
2202
|
+
this.#pingManager = new PingManager({
|
|
2203
|
+
sendPing: async () => {
|
|
2204
|
+
await this.#messageRouter.send(createPing(this.#messageRouter.nextId()));
|
|
2205
|
+
},
|
|
2206
|
+
cancelPing: (error) => this.#messageRouter.cancelAll(error),
|
|
2207
|
+
logger: this.#baseLogger,
|
|
2208
|
+
autoPing: options.autoPing ?? true,
|
|
2209
|
+
onError: (error) => this.emit("error", { error }),
|
|
2210
|
+
onDisconnect: (reason) => this.disconnect(reason),
|
|
2211
|
+
isConnected: () => this.connected
|
|
2212
|
+
});
|
|
2213
|
+
this.#sensorHandler = new SensorHandler(this.#baseLogger);
|
|
2214
|
+
if (options.autoReconnect) {
|
|
2215
|
+
this.#reconnectHandler = new ReconnectHandler({
|
|
2216
|
+
url: this.#url,
|
|
2217
|
+
transport: this.#transport,
|
|
2218
|
+
reconnectDelay: options.reconnectDelay,
|
|
2219
|
+
maxReconnectDelay: options.maxReconnectDelay,
|
|
2220
|
+
maxReconnectAttempts: options.maxReconnectAttempts,
|
|
2221
|
+
logger: this.#baseLogger,
|
|
2222
|
+
onReconnecting: (attempt) => {
|
|
2223
|
+
this.#pingManager.stop();
|
|
2224
|
+
this.emit("reconnecting", { attempt });
|
|
2225
|
+
},
|
|
2226
|
+
onReconnected: () => this.#handleReconnected(),
|
|
2227
|
+
onFailed: (reason) => {
|
|
2228
|
+
this.#logger.error(`Reconnection failed: ${reason}`);
|
|
2229
|
+
this.emit("error", { error: new ConnectionError(reason) });
|
|
2230
|
+
}
|
|
2231
|
+
});
|
|
2232
|
+
} else {
|
|
2233
|
+
this.#reconnectHandler = null;
|
|
2234
|
+
}
|
|
2235
|
+
this.on("disconnected", () => {
|
|
2236
|
+
this.#scanning = false;
|
|
2237
|
+
this.#serverInfo = null;
|
|
2238
|
+
this.#sensorHandler.clear();
|
|
2239
|
+
for (const device of this.#devices.values()) {
|
|
2240
|
+
this.emit("deviceRemoved", { device });
|
|
2241
|
+
}
|
|
2242
|
+
this.#devices.clear();
|
|
2243
|
+
if (this.#reconnectHandler) {
|
|
2244
|
+
this.#reconnectHandler.start();
|
|
2245
|
+
}
|
|
2246
|
+
});
|
|
2247
|
+
this.on("deviceRemoved", ({ device }) => {
|
|
2248
|
+
this.#sensorHandler.unsubscribeDevice({
|
|
2249
|
+
deviceIndex: device.index,
|
|
2250
|
+
router: this.#messageRouter,
|
|
2251
|
+
connected: this.#serverInfo !== null && this.connected
|
|
2252
|
+
});
|
|
2253
|
+
});
|
|
2254
|
+
}
|
|
2255
|
+
/**
|
|
2256
|
+
* Opens a WebSocket connection and performs the Buttplug handshake.
|
|
2257
|
+
*
|
|
2258
|
+
* @throws ConnectionError if the transport fails to connect
|
|
2259
|
+
* @throws HandshakeError if the server rejects the handshake
|
|
2260
|
+
*/
|
|
2261
|
+
async connect() {
|
|
2262
|
+
if (this.connected && this.#serverInfo) {
|
|
2263
|
+
return;
|
|
2264
|
+
}
|
|
2265
|
+
if (this.#connectPromise) {
|
|
2266
|
+
return this.#connectPromise;
|
|
2267
|
+
}
|
|
2268
|
+
this.#connectPromise = this.#performConnect();
|
|
2269
|
+
try {
|
|
2270
|
+
await this.#connectPromise;
|
|
2271
|
+
} finally {
|
|
2272
|
+
this.#connectPromise = null;
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
/**
|
|
2276
|
+
* Gracefully disconnects from the server.
|
|
2277
|
+
*
|
|
2278
|
+
* Stops all devices, sends a protocol-level disconnect message, then closes
|
|
2279
|
+
* the WebSocket. Both stop and disconnect steps are time-bounded so the
|
|
2280
|
+
* method does not hang indefinitely.
|
|
2281
|
+
*
|
|
2282
|
+
* @param reason - Optional human-readable reason for the disconnection
|
|
2283
|
+
*/
|
|
2284
|
+
async disconnect(reason) {
|
|
2285
|
+
if (this.#reconnectHandler?.active) {
|
|
2286
|
+
this.#reconnectHandler.cancel();
|
|
2287
|
+
this.#pingManager.stop();
|
|
2288
|
+
this.emit("disconnected", { reason: reason ?? "Client disconnected" });
|
|
2289
|
+
}
|
|
2290
|
+
if (!this.connected) {
|
|
2291
|
+
return;
|
|
2292
|
+
}
|
|
2293
|
+
this.#logger.info(`Disconnecting${reason ? `: ${reason}` : ""}`);
|
|
2294
|
+
this.#pingManager.stop();
|
|
2295
|
+
this.#reconnectHandler?.cancel();
|
|
2296
|
+
if (this.#serverInfo !== null && !this.#isHandshaking) {
|
|
2297
|
+
try {
|
|
2298
|
+
await raceTimeout(this.stopAll(), STOP_DEVICES_TIMEOUT_MS);
|
|
2299
|
+
} catch {
|
|
2300
|
+
this.#logger.warn("Stop all devices timed out during disconnect");
|
|
2301
|
+
}
|
|
2302
|
+
try {
|
|
2303
|
+
await raceTimeout(
|
|
2304
|
+
this.#messageRouter.send(createDisconnect(this.#messageRouter.nextId())),
|
|
2305
|
+
DISCONNECT_TIMEOUT_MS
|
|
2306
|
+
);
|
|
2307
|
+
} catch {
|
|
2308
|
+
this.#logger.warn("Disconnect message failed or timed out");
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
this.#messageRouter.cancelAll(new ConnectionError("Client disconnected"));
|
|
2312
|
+
await this.#transport.disconnect();
|
|
2313
|
+
}
|
|
2314
|
+
/**
|
|
2315
|
+
* Begins scanning for devices on the server.
|
|
2316
|
+
*
|
|
2317
|
+
* @throws ConnectionError if the client is not connected
|
|
2318
|
+
*/
|
|
2319
|
+
async startScanning() {
|
|
2320
|
+
this.#requireConnection("start scanning");
|
|
2321
|
+
await this.#messageRouter.send(createStartScanning(this.#messageRouter.nextId()));
|
|
2322
|
+
this.#scanning = true;
|
|
2323
|
+
}
|
|
2324
|
+
/**
|
|
2325
|
+
* Stops an active device scan on the server.
|
|
2326
|
+
*
|
|
2327
|
+
* @throws ConnectionError if the client is not connected
|
|
2328
|
+
*/
|
|
2329
|
+
async stopScanning() {
|
|
2330
|
+
this.#requireConnection("stop scanning");
|
|
2331
|
+
await this.#messageRouter.send(createStopScanning(this.#messageRouter.nextId()));
|
|
2332
|
+
this.#scanning = false;
|
|
2333
|
+
}
|
|
2334
|
+
/**
|
|
2335
|
+
* Sends a global stop command to halt all devices on the server.
|
|
2336
|
+
*
|
|
2337
|
+
* @throws ConnectionError if the client is not connected
|
|
2338
|
+
*/
|
|
2339
|
+
async stopAll() {
|
|
2340
|
+
this.#requireConnection("stop devices");
|
|
2341
|
+
await this.#messageRouter.send(createStopCmd(this.#messageRouter.nextId()));
|
|
2342
|
+
}
|
|
2343
|
+
/**
|
|
2344
|
+
* Requests the current device list from the server.
|
|
2345
|
+
*
|
|
2346
|
+
* The response triggers device reconciliation and emits
|
|
2347
|
+
* `deviceAdded`, `deviceRemoved`, `deviceUpdated`, and `deviceList` events.
|
|
2348
|
+
*
|
|
2349
|
+
* @throws ConnectionError if the client is not connected
|
|
2350
|
+
*/
|
|
2351
|
+
async requestDeviceList() {
|
|
2352
|
+
this.#requireConnection("request device list");
|
|
2353
|
+
const responses = await this.#messageRouter.send(createRequestDeviceList(this.#messageRouter.nextId()));
|
|
2354
|
+
for (const response of responses) {
|
|
2355
|
+
if (isDeviceList(response)) {
|
|
2356
|
+
const deviceList = getDeviceList(response);
|
|
2357
|
+
runWithLogger(
|
|
2358
|
+
this.#logger,
|
|
2359
|
+
() => reconcileDevices({
|
|
2360
|
+
currentDevices: this.#devices,
|
|
2361
|
+
incomingRaw: Object.values(deviceList.Devices),
|
|
2362
|
+
createDevice: (raw) => new Device({ client: this, raw }),
|
|
2363
|
+
callbacks: {
|
|
2364
|
+
onAdded: (d) => this.emit("deviceAdded", { device: d }),
|
|
2365
|
+
onRemoved: (d) => this.emit("deviceRemoved", { device: d }),
|
|
2366
|
+
onUpdated: (d, old) => this.emit("deviceUpdated", { device: d, previousDevice: old }),
|
|
2367
|
+
onList: (list) => this.emit("deviceList", { devices: list })
|
|
2368
|
+
}
|
|
2369
|
+
})
|
|
2370
|
+
);
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
/**
|
|
2375
|
+
* Sends one or more raw protocol messages to the server.
|
|
2376
|
+
*
|
|
2377
|
+
* @param messages - A single message or array of messages to send
|
|
2378
|
+
* @returns Server response messages
|
|
2379
|
+
* @throws ConnectionError if the client is not connected
|
|
2380
|
+
*/
|
|
2381
|
+
async send(messages) {
|
|
2382
|
+
this.#requireConnection("send message");
|
|
2383
|
+
return await this.#messageRouter.send(messages);
|
|
2384
|
+
}
|
|
2385
|
+
/**
|
|
2386
|
+
* Returns the next monotonically increasing message ID.
|
|
2387
|
+
*
|
|
2388
|
+
* @returns A unique message ID for the next outgoing message
|
|
2389
|
+
*/
|
|
2390
|
+
nextId() {
|
|
2391
|
+
return this.#messageRouter.nextId();
|
|
2392
|
+
}
|
|
2393
|
+
/**
|
|
2394
|
+
* Registers a callback for incoming sensor readings.
|
|
2395
|
+
*
|
|
2396
|
+
* @param key - Unique subscription key (typically from `sensorKey()`)
|
|
2397
|
+
* @param callback - Function invoked when a matching reading arrives
|
|
2398
|
+
* @param info - Device, feature, and input type identifying the subscription
|
|
2399
|
+
*/
|
|
2400
|
+
registerSensorSubscription(key, callback, info) {
|
|
2401
|
+
this.#sensorHandler.register(key, callback, info);
|
|
2402
|
+
}
|
|
2403
|
+
/**
|
|
2404
|
+
* Removes a previously registered sensor subscription.
|
|
2405
|
+
*
|
|
2406
|
+
* @param key - The subscription key to remove
|
|
2407
|
+
*/
|
|
2408
|
+
unregisterSensorSubscription(key) {
|
|
2409
|
+
this.#sensorHandler.unregister(key);
|
|
2410
|
+
}
|
|
2411
|
+
/**
|
|
2412
|
+
* Retrieves a device by its server-assigned index.
|
|
2413
|
+
*
|
|
2414
|
+
* @param index - The device index
|
|
2415
|
+
* @returns The {@link Device} instance, or `undefined` if not found
|
|
2416
|
+
*/
|
|
2417
|
+
getDevice(index) {
|
|
2418
|
+
return this.#devices.get(index);
|
|
2419
|
+
}
|
|
2420
|
+
/** Whether the WebSocket transport is currently connected. */
|
|
2421
|
+
get connected() {
|
|
2422
|
+
return this.#transport.state === "connected";
|
|
2423
|
+
}
|
|
2424
|
+
/** Whether a device scan is currently in progress. */
|
|
2425
|
+
get scanning() {
|
|
2426
|
+
return this.#scanning;
|
|
2427
|
+
}
|
|
2428
|
+
/** Server information received during handshake, or `null` if not connected. */
|
|
2429
|
+
get serverInfo() {
|
|
2430
|
+
return this.#serverInfo;
|
|
2431
|
+
}
|
|
2432
|
+
/** Snapshot of all currently known {@link Device} instances. */
|
|
2433
|
+
get devices() {
|
|
2434
|
+
return Array.from(this.#devices.values());
|
|
2435
|
+
}
|
|
2436
|
+
/** Best-effort StopCmd bypassing connection checks for safety-critical scenarios. */
|
|
2437
|
+
#safetyStop() {
|
|
2438
|
+
try {
|
|
2439
|
+
if (this.#transport.state === "connected") {
|
|
2440
|
+
const msg = createStopCmd(this.#messageRouter.nextId());
|
|
2441
|
+
this.#transport.send(serializeMessage(msg));
|
|
2442
|
+
}
|
|
2443
|
+
} catch {
|
|
2444
|
+
this.#logger.warn("Safety stop failed \u2014 transport may already be closed");
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
/**
|
|
2448
|
+
* Validates connection state before performing an action.
|
|
2449
|
+
*
|
|
2450
|
+
* @throws ConnectionError if not connected
|
|
2451
|
+
*/
|
|
2452
|
+
#requireConnection(action) {
|
|
2453
|
+
if (!this.connected) {
|
|
2454
|
+
throw new ConnectionError(`Cannot ${action}: not connected`);
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
/** Executes the transport connection and protocol handshake. */
|
|
2458
|
+
async #performConnect() {
|
|
2459
|
+
this.#logger.info(`Connecting to ${this.#url}`);
|
|
2460
|
+
await this.#transport.connect(this.#url);
|
|
2461
|
+
this.#isHandshaking = true;
|
|
2462
|
+
try {
|
|
2463
|
+
this.#serverInfo = await performHandshake({
|
|
2464
|
+
router: this.#messageRouter,
|
|
2465
|
+
clientName: this.#clientName,
|
|
2466
|
+
pingManager: this.#pingManager,
|
|
2467
|
+
logger: this.#logger
|
|
2468
|
+
});
|
|
2469
|
+
} finally {
|
|
2470
|
+
this.#isHandshaking = false;
|
|
2471
|
+
}
|
|
2472
|
+
this.#logger.info(`Connected to server: ${this.#serverInfo?.ServerName ?? "unknown"}`);
|
|
2473
|
+
this.emit("connected", void 0);
|
|
2474
|
+
}
|
|
2475
|
+
/**
|
|
2476
|
+
* Re-handshakes and reconciles device state after reconnection.
|
|
2477
|
+
*
|
|
2478
|
+
* Resets all client state, performs a new handshake, and requests the device list.
|
|
2479
|
+
* Emits an error and disconnects if the handshake fails.
|
|
2480
|
+
*/
|
|
2481
|
+
async #handleReconnected() {
|
|
2482
|
+
this.#logger.info("Reconnected, performing handshake");
|
|
2483
|
+
this.#messageRouter.cancelAll(new ConnectionError("Reconnecting"));
|
|
2484
|
+
this.#messageRouter.resetId();
|
|
2485
|
+
this.#serverInfo = null;
|
|
2486
|
+
this.#scanning = false;
|
|
2487
|
+
this.#sensorHandler.clear();
|
|
2488
|
+
try {
|
|
2489
|
+
this.#serverInfo = await performHandshake({
|
|
2490
|
+
router: this.#messageRouter,
|
|
2491
|
+
clientName: this.#clientName,
|
|
2492
|
+
pingManager: this.#pingManager,
|
|
2493
|
+
logger: this.#logger
|
|
2494
|
+
});
|
|
2495
|
+
this.emit("reconnected", void 0);
|
|
2496
|
+
await this.requestDeviceList();
|
|
2497
|
+
} catch (err) {
|
|
2498
|
+
this.#logger.error(`Handshake failed after reconnect: ${formatError(err)}`);
|
|
2499
|
+
this.emit("error", { error: err instanceof Error ? err : new Error(String(err)) });
|
|
2500
|
+
await this.disconnect("Handshake failed after reconnect");
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
};
|
|
2504
|
+
|
|
2505
|
+
// src/patterns/easing.ts
|
|
2506
|
+
var clamp = (t) => Math.min(1, Math.max(0, t));
|
|
2507
|
+
var linear = (t) => clamp(t);
|
|
2508
|
+
var easeIn = (t) => clamp(t) ** 3;
|
|
2509
|
+
var easeOut = (t) => 1 - (1 - clamp(t)) ** 3;
|
|
2510
|
+
var easeInOut = (t) => {
|
|
2511
|
+
const c = clamp(t);
|
|
2512
|
+
return c < 0.5 ? 4 * c ** 3 : 1 - (-2 * c + 2) ** 3 / 2;
|
|
2513
|
+
};
|
|
2514
|
+
var step = (t) => clamp(t) < 1 ? 0 : 1;
|
|
2515
|
+
var EASING_FUNCTIONS = {
|
|
2516
|
+
linear,
|
|
2517
|
+
easeIn,
|
|
2518
|
+
easeOut,
|
|
2519
|
+
easeInOut,
|
|
2520
|
+
step
|
|
2521
|
+
};
|
|
2522
|
+
var ease = (t, easing) => {
|
|
2523
|
+
const fn = EASING_FUNCTIONS[easing];
|
|
2524
|
+
return fn ? fn(t) : clamp(t);
|
|
2525
|
+
};
|
|
2526
|
+
|
|
2527
|
+
// src/patterns/types.ts
|
|
2528
|
+
var import_zod2 = require("zod");
|
|
2529
|
+
var EASING_VALUES = ["linear", "easeIn", "easeOut", "easeInOut", "step"];
|
|
2530
|
+
var EasingSchema = import_zod2.z.enum(EASING_VALUES);
|
|
2531
|
+
var KeyframeSchema = import_zod2.z.object({
|
|
2532
|
+
value: import_zod2.z.number().min(0).max(1),
|
|
2533
|
+
duration: import_zod2.z.number().int().nonnegative(),
|
|
2534
|
+
easing: EasingSchema.optional()
|
|
2535
|
+
});
|
|
2536
|
+
var TrackSchema = import_zod2.z.object({
|
|
2537
|
+
featureIndex: import_zod2.z.number().int().nonnegative(),
|
|
2538
|
+
keyframes: import_zod2.z.array(KeyframeSchema).min(1),
|
|
2539
|
+
clockwise: import_zod2.z.boolean().optional(),
|
|
2540
|
+
outputType: OutputTypeSchema.optional()
|
|
2541
|
+
});
|
|
2542
|
+
var PRESET_NAMES = ["pulse", "wave", "ramp_up", "ramp_down", "heartbeat", "surge", "stroke"];
|
|
2543
|
+
var PresetPatternSchema = import_zod2.z.object({
|
|
2544
|
+
type: import_zod2.z.literal("preset"),
|
|
2545
|
+
preset: import_zod2.z.enum(PRESET_NAMES),
|
|
2546
|
+
intensity: import_zod2.z.number().min(0).max(1).optional(),
|
|
2547
|
+
speed: import_zod2.z.number().min(0.25).max(4).optional(),
|
|
2548
|
+
loop: import_zod2.z.union([import_zod2.z.boolean(), import_zod2.z.number().int().positive()]).optional()
|
|
2549
|
+
});
|
|
2550
|
+
var CustomPatternSchema = import_zod2.z.object({
|
|
2551
|
+
type: import_zod2.z.literal("custom"),
|
|
2552
|
+
tracks: import_zod2.z.array(TrackSchema).min(1),
|
|
2553
|
+
intensity: import_zod2.z.number().min(0).max(1).optional(),
|
|
2554
|
+
loop: import_zod2.z.union([import_zod2.z.boolean(), import_zod2.z.number().int().positive()]).optional()
|
|
2555
|
+
});
|
|
2556
|
+
var PatternDescriptorSchema = import_zod2.z.discriminatedUnion("type", [PresetPatternSchema, CustomPatternSchema]);
|
|
2557
|
+
|
|
2558
|
+
// src/patterns/presets.ts
|
|
2559
|
+
var MOTOR_OUTPUT_TYPES = ["Vibrate", "Rotate", "RotateWithDirection", "Oscillate", "Constrict"];
|
|
2560
|
+
var POSITION_OUTPUT_TYPES = ["Position", "HwPositionWithDuration"];
|
|
2561
|
+
var PRESETS = {
|
|
2562
|
+
pulse: {
|
|
2563
|
+
description: "Square wave on/off",
|
|
2564
|
+
outputTypes: MOTOR_OUTPUT_TYPES,
|
|
2565
|
+
tracks: [
|
|
2566
|
+
[
|
|
2567
|
+
{ value: 0, duration: 0 },
|
|
2568
|
+
{ value: 1, duration: 0 },
|
|
2569
|
+
{ value: 1, duration: 500 },
|
|
2570
|
+
{ value: 0, duration: 0 },
|
|
2571
|
+
{ value: 0, duration: 500 }
|
|
2572
|
+
]
|
|
2573
|
+
],
|
|
2574
|
+
loop: true
|
|
2575
|
+
},
|
|
2576
|
+
wave: {
|
|
2577
|
+
description: "Smooth sine wave oscillation",
|
|
2578
|
+
outputTypes: MOTOR_OUTPUT_TYPES,
|
|
2579
|
+
tracks: [
|
|
2580
|
+
[
|
|
2581
|
+
{ value: 0, duration: 0 },
|
|
2582
|
+
{ value: 0.5, duration: 500, easing: "easeInOut" },
|
|
2583
|
+
{ value: 1, duration: 500, easing: "easeInOut" },
|
|
2584
|
+
{ value: 0.5, duration: 500, easing: "easeInOut" },
|
|
2585
|
+
{ value: 0, duration: 500, easing: "easeInOut" }
|
|
2586
|
+
]
|
|
2587
|
+
],
|
|
2588
|
+
loop: true
|
|
2589
|
+
},
|
|
2590
|
+
ramp_up: {
|
|
2591
|
+
description: "Gradual increase to maximum",
|
|
2592
|
+
outputTypes: MOTOR_OUTPUT_TYPES,
|
|
2593
|
+
tracks: [
|
|
2594
|
+
[
|
|
2595
|
+
{ value: 0, duration: 0 },
|
|
2596
|
+
{ value: 1, duration: 3e3, easing: "easeIn" }
|
|
2597
|
+
]
|
|
2598
|
+
],
|
|
2599
|
+
loop: false
|
|
2600
|
+
},
|
|
2601
|
+
ramp_down: {
|
|
2602
|
+
description: "Gradual decrease to zero",
|
|
2603
|
+
outputTypes: MOTOR_OUTPUT_TYPES,
|
|
2604
|
+
tracks: [
|
|
2605
|
+
[
|
|
2606
|
+
{ value: 1, duration: 0 },
|
|
2607
|
+
{ value: 0, duration: 3e3, easing: "easeOut" }
|
|
2608
|
+
]
|
|
2609
|
+
],
|
|
2610
|
+
loop: false
|
|
2611
|
+
},
|
|
2612
|
+
heartbeat: {
|
|
2613
|
+
description: "Ba-bump heartbeat rhythm",
|
|
2614
|
+
outputTypes: MOTOR_OUTPUT_TYPES,
|
|
2615
|
+
tracks: [
|
|
2616
|
+
[
|
|
2617
|
+
{ value: 0, duration: 0 },
|
|
2618
|
+
{ value: 1, duration: 0 },
|
|
2619
|
+
{ value: 1, duration: 100 },
|
|
2620
|
+
{ value: 0.3, duration: 50 },
|
|
2621
|
+
{ value: 0.8, duration: 0 },
|
|
2622
|
+
{ value: 0.8, duration: 100 },
|
|
2623
|
+
{ value: 0, duration: 0 },
|
|
2624
|
+
{ value: 0, duration: 750 }
|
|
2625
|
+
]
|
|
2626
|
+
],
|
|
2627
|
+
loop: true
|
|
2628
|
+
},
|
|
2629
|
+
surge: {
|
|
2630
|
+
description: "Build to peak then release",
|
|
2631
|
+
outputTypes: MOTOR_OUTPUT_TYPES,
|
|
2632
|
+
tracks: [
|
|
2633
|
+
[
|
|
2634
|
+
{ value: 0.1, duration: 0 },
|
|
2635
|
+
{ value: 0.7, duration: 2e3, easing: "easeIn" },
|
|
2636
|
+
{ value: 1, duration: 500 },
|
|
2637
|
+
{ value: 1, duration: 1e3 },
|
|
2638
|
+
{ value: 0.1, duration: 1500, easing: "easeOut" }
|
|
2639
|
+
]
|
|
2640
|
+
],
|
|
2641
|
+
loop: false
|
|
2642
|
+
},
|
|
2643
|
+
stroke: {
|
|
2644
|
+
description: "Full-range position strokes",
|
|
2645
|
+
outputTypes: POSITION_OUTPUT_TYPES,
|
|
2646
|
+
tracks: [
|
|
2647
|
+
[
|
|
2648
|
+
{ value: 0, duration: 0, easing: "easeInOut" },
|
|
2649
|
+
{ value: 1, duration: 1e3, easing: "easeInOut" },
|
|
2650
|
+
{ value: 0, duration: 1e3, easing: "easeInOut" }
|
|
2651
|
+
]
|
|
2652
|
+
],
|
|
2653
|
+
loop: true
|
|
2654
|
+
}
|
|
2655
|
+
};
|
|
2656
|
+
function getPresetInfo() {
|
|
2657
|
+
return Object.entries(PRESETS).map(([name, def]) => ({
|
|
2658
|
+
name,
|
|
2659
|
+
description: def.description,
|
|
2660
|
+
compatibleOutputTypes: def.outputTypes,
|
|
2661
|
+
defaultLoop: def.loop
|
|
2662
|
+
}));
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
// src/patterns/scheduler.ts
|
|
2666
|
+
function interpolateKeyframes(keyframes, elapsed) {
|
|
2667
|
+
let accumulated = 0;
|
|
2668
|
+
const first = keyframes[0];
|
|
2669
|
+
if (!first) {
|
|
2670
|
+
return 0;
|
|
2671
|
+
}
|
|
2672
|
+
let value = first.value;
|
|
2673
|
+
for (const kf of keyframes) {
|
|
2674
|
+
if (kf.duration === 0) {
|
|
2675
|
+
if (elapsed >= accumulated) {
|
|
2676
|
+
value = kf.value;
|
|
2677
|
+
}
|
|
2678
|
+
continue;
|
|
2679
|
+
}
|
|
2680
|
+
const prevValue = value;
|
|
2681
|
+
if (elapsed < accumulated + kf.duration) {
|
|
2682
|
+
const t = (elapsed - accumulated) / kf.duration;
|
|
2683
|
+
const result = prevValue + (kf.value - prevValue) * ease(t, kf.easing);
|
|
2684
|
+
return Math.max(0, Math.min(1, result));
|
|
2685
|
+
}
|
|
2686
|
+
accumulated += kf.duration;
|
|
2687
|
+
value = kf.value;
|
|
2688
|
+
}
|
|
2689
|
+
return Math.max(0, Math.min(1, value));
|
|
2690
|
+
}
|
|
2691
|
+
function buildScalarCommand(track, value) {
|
|
2692
|
+
switch (track.outputType) {
|
|
2693
|
+
case "Vibrate":
|
|
2694
|
+
return { Vibrate: { Value: value } };
|
|
2695
|
+
case "Rotate":
|
|
2696
|
+
return { Rotate: { Value: value } };
|
|
2697
|
+
case "RotateWithDirection":
|
|
2698
|
+
return { RotateWithDirection: { Value: value, Clockwise: track.clockwise } };
|
|
2699
|
+
case "Oscillate":
|
|
2700
|
+
return { Oscillate: { Value: value } };
|
|
2701
|
+
case "Constrict":
|
|
2702
|
+
return { Constrict: { Value: value } };
|
|
2703
|
+
case "Position":
|
|
2704
|
+
return { Position: { Value: value } };
|
|
2705
|
+
default:
|
|
2706
|
+
return { Vibrate: { Value: value } };
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
function getCycleDuration(tracks) {
|
|
2710
|
+
let max = 0;
|
|
2711
|
+
for (const track of tracks) {
|
|
2712
|
+
let total = 0;
|
|
2713
|
+
for (const kf of track.keyframes) {
|
|
2714
|
+
total += kf.duration;
|
|
2715
|
+
}
|
|
2716
|
+
if (total > max) {
|
|
2717
|
+
max = total;
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
return max;
|
|
2721
|
+
}
|
|
2722
|
+
function evaluateScalarTrack(state, track, elapsed, device, buildCommand, onError) {
|
|
2723
|
+
const { keyframes, featureIndex, range } = track;
|
|
2724
|
+
const value = interpolateKeyframes(keyframes, elapsed);
|
|
2725
|
+
const mapped = Math.round(range[0] + value * (range[1] - range[0]));
|
|
2726
|
+
if (state.lastSentValues.get(featureIndex) === mapped) {
|
|
2727
|
+
return;
|
|
2728
|
+
}
|
|
2729
|
+
const command = buildCommand(track, mapped);
|
|
2730
|
+
device.output({ featureIndex, command }).catch((err) => onError(state, err));
|
|
2731
|
+
state.lastSentValues.set(featureIndex, mapped);
|
|
2732
|
+
}
|
|
2733
|
+
function evaluateHwPositionTrack(state, track, elapsed, device, onError) {
|
|
2734
|
+
const { keyframes, featureIndex, range, durationRange } = track;
|
|
2735
|
+
let accumulated = 0;
|
|
2736
|
+
let activeIndex = 0;
|
|
2737
|
+
for (const [i, kf2] of keyframes.entries()) {
|
|
2738
|
+
if (kf2.duration === 0 && elapsed >= accumulated) {
|
|
2739
|
+
if (state.lastSentKeyframeIndex.get(featureIndex) !== i) {
|
|
2740
|
+
const mappedValue2 = Math.round(range[0] + kf2.value * (range[1] - range[0]));
|
|
2741
|
+
const command2 = {
|
|
2742
|
+
HwPositionWithDuration: { Position: mappedValue2, Duration: 0 }
|
|
2743
|
+
};
|
|
2744
|
+
device.output({ featureIndex, command: command2 }).catch((err) => onError(state, err));
|
|
2745
|
+
state.lastSentKeyframeIndex.set(featureIndex, i);
|
|
2746
|
+
}
|
|
2747
|
+
activeIndex = i;
|
|
2748
|
+
continue;
|
|
2749
|
+
}
|
|
2750
|
+
if (elapsed < accumulated + kf2.duration) {
|
|
2751
|
+
activeIndex = i;
|
|
2752
|
+
break;
|
|
2753
|
+
}
|
|
2754
|
+
accumulated += kf2.duration;
|
|
2755
|
+
activeIndex = i;
|
|
2756
|
+
}
|
|
2757
|
+
const kf = keyframes[activeIndex];
|
|
2758
|
+
if (!kf || kf.duration === 0) {
|
|
2759
|
+
return;
|
|
2760
|
+
}
|
|
2761
|
+
if (state.lastSentKeyframeIndex.get(featureIndex) === activeIndex) {
|
|
2762
|
+
return;
|
|
2763
|
+
}
|
|
2764
|
+
const mappedValue = Math.round(range[0] + kf.value * (range[1] - range[0]));
|
|
2765
|
+
let duration = kf.duration;
|
|
2766
|
+
if (durationRange) {
|
|
2767
|
+
duration = Math.max(durationRange[0], Math.min(durationRange[1], duration));
|
|
2768
|
+
}
|
|
2769
|
+
const command = {
|
|
2770
|
+
HwPositionWithDuration: { Position: mappedValue, Duration: duration }
|
|
2771
|
+
};
|
|
2772
|
+
device.output({ featureIndex, command }).catch((err) => onError(state, err));
|
|
2773
|
+
state.lastSentKeyframeIndex.set(featureIndex, activeIndex);
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
// src/patterns/track-resolver.ts
|
|
2777
|
+
function resolveTracks(device, descriptor, featureIndex) {
|
|
2778
|
+
if (descriptor.type === "preset") {
|
|
2779
|
+
return resolvePresetTracks(device, descriptor, featureIndex);
|
|
2780
|
+
}
|
|
2781
|
+
return resolveCustomTracks(device, descriptor);
|
|
2782
|
+
}
|
|
2783
|
+
function resolvePresetTracks(device, descriptor, featureIndex) {
|
|
2784
|
+
const preset = PRESETS[descriptor.preset];
|
|
2785
|
+
if (!preset) {
|
|
2786
|
+
throw new DeviceError(device.index, `Unknown preset: ${descriptor.preset}`);
|
|
2787
|
+
}
|
|
2788
|
+
const intensity = descriptor.intensity ?? 1;
|
|
2789
|
+
const speed = descriptor.speed ?? 1;
|
|
2790
|
+
const matchingFeatures = [];
|
|
2791
|
+
for (const outputType of preset.outputTypes) {
|
|
2792
|
+
const features = getOutputsByType(device.features, outputType);
|
|
2793
|
+
for (const feature of features) {
|
|
2794
|
+
if (featureIndex === void 0 || feature.index === featureIndex) {
|
|
2795
|
+
matchingFeatures.push({ feature, outputType });
|
|
2796
|
+
}
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
if (featureIndex !== void 0 && matchingFeatures.length === 0) {
|
|
2800
|
+
throw new DeviceError(
|
|
2801
|
+
device.index,
|
|
2802
|
+
`Feature at index ${featureIndex} is not compatible with preset "${descriptor.preset}"`
|
|
2803
|
+
);
|
|
2804
|
+
}
|
|
2805
|
+
const tracks = [];
|
|
2806
|
+
for (const [i, match] of matchingFeatures.entries()) {
|
|
2807
|
+
const presetTrack = preset.tracks[i % preset.tracks.length];
|
|
2808
|
+
if (!presetTrack) {
|
|
2809
|
+
continue;
|
|
2810
|
+
}
|
|
2811
|
+
const keyframes = presetTrack.map((kf) => ({
|
|
2812
|
+
value: kf.value * intensity,
|
|
2813
|
+
duration: speed > 0 ? kf.duration / speed : kf.duration,
|
|
2814
|
+
easing: kf.easing ?? "linear"
|
|
2815
|
+
}));
|
|
2816
|
+
tracks.push({
|
|
2817
|
+
featureIndex: match.feature.index,
|
|
2818
|
+
outputType: match.outputType,
|
|
2819
|
+
keyframes,
|
|
2820
|
+
range: match.feature.range,
|
|
2821
|
+
durationRange: match.feature.durationRange,
|
|
2822
|
+
clockwise: true
|
|
2823
|
+
});
|
|
2824
|
+
}
|
|
2825
|
+
return tracks;
|
|
2826
|
+
}
|
|
2827
|
+
function resolveCustomTracks(device, descriptor) {
|
|
2828
|
+
const intensity = descriptor.intensity ?? 1;
|
|
2829
|
+
const tracks = [];
|
|
2830
|
+
for (const track of descriptor.tracks) {
|
|
2831
|
+
const feature = track.outputType ? device.features.outputs.find(
|
|
2832
|
+
(f) => f.index === track.featureIndex && f.type === track.outputType
|
|
2833
|
+
) : device.features.outputs.find((f) => f.index === track.featureIndex);
|
|
2834
|
+
if (!feature) {
|
|
2835
|
+
throw new DeviceError(
|
|
2836
|
+
device.index,
|
|
2837
|
+
`No output feature at index ${track.featureIndex}${track.outputType ? ` with type "${track.outputType}"` : ""}`
|
|
2838
|
+
);
|
|
2839
|
+
}
|
|
2840
|
+
const keyframes = track.keyframes.map((kf) => ({
|
|
2841
|
+
value: kf.value * intensity,
|
|
2842
|
+
duration: kf.duration,
|
|
2843
|
+
easing: kf.easing ?? "linear"
|
|
2844
|
+
}));
|
|
2845
|
+
tracks.push({
|
|
2846
|
+
featureIndex: feature.index,
|
|
2847
|
+
outputType: feature.type,
|
|
2848
|
+
keyframes,
|
|
2849
|
+
range: feature.range,
|
|
2850
|
+
durationRange: feature.durationRange,
|
|
2851
|
+
clockwise: track.clockwise ?? true
|
|
2852
|
+
});
|
|
2853
|
+
}
|
|
2854
|
+
return tracks;
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
// src/patterns/engine.ts
|
|
2858
|
+
var DEFAULT_TIMEOUT_MS = 18e5;
|
|
2859
|
+
var MIN_TICK_INTERVAL_MS = 50;
|
|
2860
|
+
var noop = () => {
|
|
2861
|
+
};
|
|
2862
|
+
var PatternEngine = class {
|
|
2863
|
+
/** Client interface for device access and event subscription. */
|
|
2864
|
+
#client;
|
|
2865
|
+
/** Active pattern states keyed by pattern ID. */
|
|
2866
|
+
#patterns = /* @__PURE__ */ new Map();
|
|
2867
|
+
/** Default safety timeout in milliseconds. */
|
|
2868
|
+
#defaultTimeout;
|
|
2869
|
+
/** Unsubscribe function for the client disconnect event. */
|
|
2870
|
+
#unsubDisconnect;
|
|
2871
|
+
/** Unsubscribe function for the device removed event. */
|
|
2872
|
+
#unsubDeviceRemoved;
|
|
2873
|
+
/** Whether this engine has been disposed. */
|
|
2874
|
+
#disposed = false;
|
|
2875
|
+
/**
|
|
2876
|
+
* @param client - Client providing device access and event hooks
|
|
2877
|
+
* @param options - Optional configuration for default timeout behavior
|
|
2878
|
+
*/
|
|
2879
|
+
constructor(client, options) {
|
|
2880
|
+
this.#client = client;
|
|
2881
|
+
this.#defaultTimeout = options?.defaultTimeout ?? DEFAULT_TIMEOUT_MS;
|
|
2882
|
+
this.#unsubDisconnect = client.on("disconnected", () => {
|
|
2883
|
+
this.#stopMatchingPatterns("disconnect");
|
|
2884
|
+
});
|
|
2885
|
+
this.#unsubDeviceRemoved = client.on("deviceRemoved", ({ device }) => {
|
|
2886
|
+
this.#stopMatchingPatterns("deviceRemoved", device.index);
|
|
2887
|
+
});
|
|
2888
|
+
}
|
|
2889
|
+
// biome-ignore lint/suspicious/useAwait: async API contract per spec — errors become rejected promises
|
|
2890
|
+
async play(device, pattern, options) {
|
|
2891
|
+
const deviceIndex = typeof device === "number" ? device : device.index;
|
|
2892
|
+
if (this.#disposed) {
|
|
2893
|
+
throw new DeviceError(deviceIndex, "PatternEngine has been disposed");
|
|
2894
|
+
}
|
|
2895
|
+
const descriptor = this.#buildDescriptor(pattern, options);
|
|
2896
|
+
const parsed = PatternDescriptorSchema.parse(descriptor);
|
|
2897
|
+
const resolvedDevice = typeof device === "number" ? this.#client.getDevice(device) : device;
|
|
2898
|
+
if (!resolvedDevice) {
|
|
2899
|
+
throw new DeviceError(deviceIndex, `Device at index ${deviceIndex} not found`);
|
|
2900
|
+
}
|
|
2901
|
+
const tracks = resolveTracks(resolvedDevice, parsed, options?.featureIndex);
|
|
2902
|
+
if (tracks.length === 0) {
|
|
2903
|
+
throw new DeviceError(deviceIndex, "No compatible features found on device");
|
|
2904
|
+
}
|
|
2905
|
+
for (const s of this.#patterns.values()) {
|
|
2906
|
+
if (s.deviceIndex === deviceIndex) {
|
|
2907
|
+
this.#stopPatternInternal(s, "manual");
|
|
2908
|
+
}
|
|
2909
|
+
}
|
|
2910
|
+
const loop = parsed.type === "preset" ? parsed.loop ?? PRESETS[parsed.preset]?.loop ?? false : parsed.loop ?? false;
|
|
2911
|
+
let remainingLoops;
|
|
2912
|
+
if (loop === true) {
|
|
2913
|
+
remainingLoops = Number.POSITIVE_INFINITY;
|
|
2914
|
+
} else if (typeof loop === "number") {
|
|
2915
|
+
remainingLoops = loop;
|
|
2916
|
+
} else {
|
|
2917
|
+
remainingLoops = 1;
|
|
2918
|
+
}
|
|
2919
|
+
const id = crypto.randomUUID();
|
|
2920
|
+
const tickInterval = Math.max(resolvedDevice.messageTimingGap, MIN_TICK_INTERVAL_MS);
|
|
2921
|
+
const now = performance.now();
|
|
2922
|
+
const state = {
|
|
2923
|
+
id,
|
|
2924
|
+
deviceIndex,
|
|
2925
|
+
descriptor: parsed,
|
|
2926
|
+
tracks,
|
|
2927
|
+
loop,
|
|
2928
|
+
remainingLoops,
|
|
2929
|
+
startedAt: now,
|
|
2930
|
+
stopped: false,
|
|
2931
|
+
timerId: null,
|
|
2932
|
+
safetyTimerId: null,
|
|
2933
|
+
tickInterval,
|
|
2934
|
+
expectedTickTime: now,
|
|
2935
|
+
lastSentValues: /* @__PURE__ */ new Map(),
|
|
2936
|
+
lastSentKeyframeIndex: /* @__PURE__ */ new Map(),
|
|
2937
|
+
options: options ?? {}
|
|
2938
|
+
};
|
|
2939
|
+
this.#patterns.set(id, state);
|
|
2940
|
+
const timeout = options?.timeout ?? this.#defaultTimeout;
|
|
2941
|
+
if (timeout > 0) {
|
|
2942
|
+
state.safetyTimerId = setTimeout(() => this.#stopPatternInternal(state, "timeout"), timeout);
|
|
2943
|
+
}
|
|
2944
|
+
state.timerId = setTimeout(() => this.#tick(state, resolvedDevice), 0);
|
|
2945
|
+
return id;
|
|
2946
|
+
}
|
|
2947
|
+
/**
|
|
2948
|
+
* Stops a specific pattern by its ID.
|
|
2949
|
+
*
|
|
2950
|
+
* No-op if the pattern ID is not found (already stopped or never started).
|
|
2951
|
+
*
|
|
2952
|
+
* @param patternId - The pattern instance ID returned by {@link play}
|
|
2953
|
+
*/
|
|
2954
|
+
// biome-ignore lint/suspicious/useAwait: async API contract per spec
|
|
2955
|
+
async stop(patternId) {
|
|
2956
|
+
const state = this.#patterns.get(patternId);
|
|
2957
|
+
if (!state) {
|
|
2958
|
+
return;
|
|
2959
|
+
}
|
|
2960
|
+
this.#stopPatternInternal(state, "manual");
|
|
2961
|
+
}
|
|
2962
|
+
/**
|
|
2963
|
+
* Stops all active patterns.
|
|
2964
|
+
*
|
|
2965
|
+
* @returns Number of patterns that were stopped
|
|
2966
|
+
*/
|
|
2967
|
+
stopAll() {
|
|
2968
|
+
return this.#stopMatchingPatterns("manual");
|
|
2969
|
+
}
|
|
2970
|
+
/**
|
|
2971
|
+
* Stops all active patterns targeting a specific device.
|
|
2972
|
+
*
|
|
2973
|
+
* @param deviceIndex - The device index to stop patterns for
|
|
2974
|
+
* @returns Number of patterns that were stopped
|
|
2975
|
+
*/
|
|
2976
|
+
stopByDevice(deviceIndex) {
|
|
2977
|
+
return this.#stopMatchingPatterns("manual", deviceIndex);
|
|
2978
|
+
}
|
|
2979
|
+
/**
|
|
2980
|
+
* Returns a snapshot of all active patterns.
|
|
2981
|
+
*
|
|
2982
|
+
* @returns Array of {@link PatternInfo} snapshots
|
|
2983
|
+
*/
|
|
2984
|
+
list() {
|
|
2985
|
+
const now = performance.now();
|
|
2986
|
+
return [...this.#patterns.values()].map((state) => ({
|
|
2987
|
+
id: state.id,
|
|
2988
|
+
deviceIndex: state.deviceIndex,
|
|
2989
|
+
featureIndices: state.tracks.map((t) => t.featureIndex),
|
|
2990
|
+
descriptor: state.descriptor,
|
|
2991
|
+
startedAt: state.startedAt,
|
|
2992
|
+
elapsed: now - state.startedAt
|
|
2993
|
+
}));
|
|
2994
|
+
}
|
|
2995
|
+
/**
|
|
2996
|
+
* Returns metadata for all available built-in presets.
|
|
2997
|
+
*
|
|
2998
|
+
* @returns Array of {@link PresetInfo} descriptors
|
|
2999
|
+
*/
|
|
3000
|
+
listPresets() {
|
|
3001
|
+
return getPresetInfo();
|
|
3002
|
+
}
|
|
3003
|
+
/**
|
|
3004
|
+
* Disposes the engine, stopping all patterns and unsubscribing from client events.
|
|
3005
|
+
*
|
|
3006
|
+
* Subsequent calls to {@link play} will throw. Idempotent.
|
|
3007
|
+
*/
|
|
3008
|
+
dispose() {
|
|
3009
|
+
if (this.#disposed) {
|
|
3010
|
+
return;
|
|
3011
|
+
}
|
|
3012
|
+
this.#disposed = true;
|
|
3013
|
+
this.#unsubDisconnect();
|
|
3014
|
+
this.#unsubDeviceRemoved();
|
|
3015
|
+
this.#stopMatchingPatterns("manual");
|
|
3016
|
+
}
|
|
3017
|
+
/** Evaluates all tracks at the current time and schedules the next tick. */
|
|
3018
|
+
#tick(state, device) {
|
|
3019
|
+
if (state.stopped) {
|
|
3020
|
+
return;
|
|
3021
|
+
}
|
|
3022
|
+
const elapsed = performance.now() - state.startedAt;
|
|
3023
|
+
const cycleDuration = getCycleDuration(state.tracks);
|
|
3024
|
+
const cycleElapsed = cycleDuration > 0 && elapsed >= cycleDuration ? cycleDuration : elapsed;
|
|
3025
|
+
const onError = (s, err) => this.#handleOutputError(s, err);
|
|
3026
|
+
for (const track of state.tracks) {
|
|
3027
|
+
if (track.outputType === "HwPositionWithDuration") {
|
|
3028
|
+
evaluateHwPositionTrack(state, track, cycleElapsed, device, onError);
|
|
3029
|
+
} else {
|
|
3030
|
+
evaluateScalarTrack(state, track, cycleElapsed, device, buildScalarCommand, onError);
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
if (cycleDuration > 0 && elapsed >= cycleDuration) {
|
|
3034
|
+
if (state.remainingLoops === Number.POSITIVE_INFINITY) {
|
|
3035
|
+
state.startedAt += cycleDuration;
|
|
3036
|
+
state.lastSentKeyframeIndex.clear();
|
|
3037
|
+
} else if (state.remainingLoops > 1) {
|
|
3038
|
+
state.remainingLoops--;
|
|
3039
|
+
state.startedAt += cycleDuration;
|
|
3040
|
+
state.lastSentKeyframeIndex.clear();
|
|
3041
|
+
} else {
|
|
3042
|
+
this.#stopPatternInternal(state, "complete", true);
|
|
3043
|
+
return;
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
const drift = performance.now() - state.expectedTickTime;
|
|
3047
|
+
const nextDelay = Math.max(0, state.tickInterval - drift);
|
|
3048
|
+
state.expectedTickTime = performance.now() + nextDelay;
|
|
3049
|
+
state.timerId = setTimeout(() => this.#tick(state, device), nextDelay);
|
|
3050
|
+
}
|
|
3051
|
+
/** Builds a {@link PatternDescriptor} from the shorthand pattern argument and options. */
|
|
3052
|
+
#buildDescriptor(pattern, options) {
|
|
3053
|
+
if (typeof pattern === "string") {
|
|
3054
|
+
return {
|
|
3055
|
+
type: "preset",
|
|
3056
|
+
preset: pattern,
|
|
3057
|
+
intensity: options?.intensity,
|
|
3058
|
+
speed: options?.speed,
|
|
3059
|
+
loop: options?.loop
|
|
3060
|
+
};
|
|
3061
|
+
}
|
|
3062
|
+
if (Array.isArray(pattern)) {
|
|
3063
|
+
return {
|
|
3064
|
+
type: "custom",
|
|
3065
|
+
tracks: pattern,
|
|
3066
|
+
intensity: options?.intensity,
|
|
3067
|
+
loop: options?.loop
|
|
3068
|
+
};
|
|
3069
|
+
}
|
|
3070
|
+
return pattern;
|
|
3071
|
+
}
|
|
3072
|
+
/** Stops the pattern on device or protocol errors; ignores transient failures. */
|
|
3073
|
+
#handleOutputError(state, err) {
|
|
3074
|
+
if (err instanceof DeviceError || err instanceof ProtocolError) {
|
|
3075
|
+
this.#stopPatternInternal(state, "error");
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
/** Stops all patterns, optionally filtered by device index. */
|
|
3079
|
+
#stopMatchingPatterns(reason, deviceIndex) {
|
|
3080
|
+
const patterns = deviceIndex !== void 0 ? [...this.#patterns.values()].filter((s) => s.deviceIndex === deviceIndex) : [...this.#patterns.values()];
|
|
3081
|
+
for (const state of patterns) {
|
|
3082
|
+
this.#stopPatternInternal(state, reason);
|
|
3083
|
+
}
|
|
3084
|
+
return patterns.length;
|
|
3085
|
+
}
|
|
3086
|
+
/** Stops a pattern, clears its timers, sends zero-value stop commands, and fires callbacks. */
|
|
3087
|
+
#stopPatternInternal(state, reason, complete = false) {
|
|
3088
|
+
if (state.stopped) {
|
|
3089
|
+
return;
|
|
3090
|
+
}
|
|
3091
|
+
state.stopped = true;
|
|
3092
|
+
if (state.timerId !== null) {
|
|
3093
|
+
clearTimeout(state.timerId);
|
|
3094
|
+
state.timerId = null;
|
|
3095
|
+
}
|
|
3096
|
+
if (state.safetyTimerId !== null) {
|
|
3097
|
+
clearTimeout(state.safetyTimerId);
|
|
3098
|
+
state.safetyTimerId = null;
|
|
3099
|
+
}
|
|
3100
|
+
this.#patterns.delete(state.id);
|
|
3101
|
+
const device = this.#client.getDevice(state.deviceIndex);
|
|
3102
|
+
if (device) {
|
|
3103
|
+
for (const track of state.tracks) {
|
|
3104
|
+
if (track.outputType === "Position" || track.outputType === "HwPositionWithDuration") {
|
|
3105
|
+
device.stop({ featureIndex: track.featureIndex }).catch(noop);
|
|
3106
|
+
} else {
|
|
3107
|
+
const command = buildScalarCommand(track, track.range[0]);
|
|
3108
|
+
device.output({ featureIndex: track.featureIndex, command }).catch(noop);
|
|
3109
|
+
}
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
if (complete) {
|
|
3113
|
+
state.options.onComplete?.(state.id);
|
|
3114
|
+
}
|
|
3115
|
+
state.options.onStop?.(state.id, reason);
|
|
3116
|
+
}
|
|
3117
|
+
};
|
|
3118
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
3119
|
+
0 && (module.exports = {
|
|
3120
|
+
ButtplugClient,
|
|
3121
|
+
ButtplugError,
|
|
3122
|
+
ClientMessageSchema,
|
|
3123
|
+
ConnectionError,
|
|
3124
|
+
DEFAULT_CLIENT_NAME,
|
|
3125
|
+
DEFAULT_PING_INTERVAL,
|
|
3126
|
+
DEFAULT_REQUEST_TIMEOUT,
|
|
3127
|
+
Device,
|
|
3128
|
+
DeviceError,
|
|
3129
|
+
DeviceFeaturesSchema,
|
|
3130
|
+
EASING_FUNCTIONS,
|
|
3131
|
+
EASING_VALUES,
|
|
3132
|
+
ErrorCode,
|
|
3133
|
+
FeatureValueSchema,
|
|
3134
|
+
HandshakeError,
|
|
3135
|
+
HwPositionOutputDataSchema,
|
|
3136
|
+
INPUT_TYPES,
|
|
3137
|
+
INPUT_TYPE_VALUES,
|
|
3138
|
+
InputCommandTypeSchema,
|
|
3139
|
+
InputDataSchema,
|
|
3140
|
+
InputFeatureSchema,
|
|
3141
|
+
InputReadingSchema,
|
|
3142
|
+
InputTypeSchema,
|
|
3143
|
+
KeyframeSchema,
|
|
3144
|
+
MAX_MESSAGE_ID,
|
|
3145
|
+
OUTPUT_TYPES,
|
|
3146
|
+
OUTPUT_TYPE_VALUES,
|
|
3147
|
+
OutputCommandSchema,
|
|
3148
|
+
OutputFeatureSchema,
|
|
3149
|
+
OutputTypeSchema,
|
|
3150
|
+
PRESETS,
|
|
3151
|
+
PRESET_NAMES,
|
|
3152
|
+
PROTOCOL_VERSION_MAJOR,
|
|
3153
|
+
PROTOCOL_VERSION_MINOR,
|
|
3154
|
+
PatternDescriptorSchema,
|
|
3155
|
+
PatternEngine,
|
|
3156
|
+
PingManager,
|
|
3157
|
+
PositionValueSchema,
|
|
3158
|
+
ProtocolError,
|
|
3159
|
+
ReconnectDefaults,
|
|
3160
|
+
ReconnectHandler,
|
|
3161
|
+
RotateWithDirectionOutputDataSchema,
|
|
3162
|
+
RotationValueSchema,
|
|
3163
|
+
SensorValueSchema,
|
|
3164
|
+
ServerInfoSchema,
|
|
3165
|
+
ServerMessageSchema,
|
|
3166
|
+
SignedScalarOutputDataSchema,
|
|
3167
|
+
TimeoutError,
|
|
3168
|
+
UnsignedScalarOutputDataSchema,
|
|
3169
|
+
WebSocketTransport,
|
|
3170
|
+
consoleLogger,
|
|
3171
|
+
formatError,
|
|
3172
|
+
getLogger,
|
|
3173
|
+
getPresetInfo,
|
|
3174
|
+
noopLogger,
|
|
3175
|
+
runWithLogger
|
|
3176
|
+
});
|