@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/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
+ });