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