@zendrex/buttplug.js 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -32,60 +32,24 @@ var index_exports = {};
32
32
  __export(index_exports, {
33
33
  ButtplugClient: () => ButtplugClient,
34
34
  ButtplugError: () => ButtplugError,
35
- ClientMessageSchema: () => ClientMessageSchema,
36
35
  ConnectionError: () => ConnectionError,
37
- DEFAULT_CLIENT_NAME: () => DEFAULT_CLIENT_NAME,
38
- DEFAULT_PING_INTERVAL: () => DEFAULT_PING_INTERVAL,
39
- DEFAULT_REQUEST_TIMEOUT: () => DEFAULT_REQUEST_TIMEOUT,
40
36
  Device: () => Device,
41
37
  DeviceError: () => DeviceError,
42
- DeviceFeaturesSchema: () => DeviceFeaturesSchema,
43
38
  EASING_FUNCTIONS: () => EASING_FUNCTIONS,
44
39
  EASING_VALUES: () => EASING_VALUES,
45
40
  ErrorCode: () => ErrorCode,
46
- FeatureValueSchema: () => FeatureValueSchema,
47
41
  HandshakeError: () => HandshakeError,
48
- HwPositionOutputDataSchema: () => HwPositionOutputDataSchema,
49
42
  INPUT_TYPES: () => INPUT_TYPES,
50
- INPUT_TYPE_VALUES: () => INPUT_TYPE_VALUES,
51
- InputCommandTypeSchema: () => InputCommandTypeSchema,
52
- InputDataSchema: () => InputDataSchema,
53
- InputFeatureSchema: () => InputFeatureSchema,
54
- InputReadingSchema: () => InputReadingSchema,
55
- InputTypeSchema: () => InputTypeSchema,
56
- KeyframeSchema: () => KeyframeSchema,
57
- MAX_MESSAGE_ID: () => MAX_MESSAGE_ID,
58
43
  OUTPUT_TYPES: () => OUTPUT_TYPES,
59
- OUTPUT_TYPE_VALUES: () => OUTPUT_TYPE_VALUES,
60
- OutputCommandSchema: () => OutputCommandSchema,
61
- OutputFeatureSchema: () => OutputFeatureSchema,
62
- OutputTypeSchema: () => OutputTypeSchema,
63
44
  PRESETS: () => PRESETS,
64
45
  PRESET_NAMES: () => PRESET_NAMES,
65
- PROTOCOL_VERSION_MAJOR: () => PROTOCOL_VERSION_MAJOR,
66
- PROTOCOL_VERSION_MINOR: () => PROTOCOL_VERSION_MINOR,
67
- PatternDescriptorSchema: () => PatternDescriptorSchema,
68
46
  PatternEngine: () => PatternEngine,
69
- PingManager: () => PingManager,
70
- PositionValueSchema: () => PositionValueSchema,
71
47
  ProtocolError: () => ProtocolError,
72
- ReconnectDefaults: () => ReconnectDefaults,
73
- ReconnectHandler: () => ReconnectHandler,
74
- RotateWithDirectionOutputDataSchema: () => RotateWithDirectionOutputDataSchema,
75
- RotationValueSchema: () => RotationValueSchema,
76
- SensorValueSchema: () => SensorValueSchema,
77
- ServerInfoSchema: () => ServerInfoSchema,
78
- ServerMessageSchema: () => ServerMessageSchema,
79
- SignedScalarOutputDataSchema: () => SignedScalarOutputDataSchema,
80
48
  TimeoutError: () => TimeoutError,
81
- UnsignedScalarOutputDataSchema: () => UnsignedScalarOutputDataSchema,
82
- WebSocketTransport: () => WebSocketTransport,
83
49
  consoleLogger: () => consoleLogger,
84
50
  formatError: () => formatError,
85
- getLogger: () => getLogger,
86
51
  getPresetInfo: () => getPresetInfo,
87
- noopLogger: () => noopLogger,
88
- runWithLogger: () => runWithLogger
52
+ noopLogger: () => noopLogger
89
53
  });
90
54
  module.exports = __toCommonJS(index_exports);
91
55
 
@@ -127,92 +91,6 @@ function createLogger(prefix) {
127
91
  }
128
92
  var consoleLogger = createLogger("buttplug");
129
93
 
130
- // src/lib/context.ts
131
- var currentLogger;
132
- function getLogger() {
133
- return currentLogger ?? noopLogger;
134
- }
135
- function runWithLogger(logger, fn) {
136
- const prev = currentLogger;
137
- currentLogger = logger;
138
- try {
139
- return fn();
140
- } finally {
141
- currentLogger = prev;
142
- }
143
- }
144
-
145
- // src/core/utils.ts
146
- function raceTimeout(promise, ms) {
147
- return Promise.race([
148
- promise,
149
- new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), ms))
150
- ]);
151
- }
152
- function outputFeaturesEqual(a, b) {
153
- const sortedA = [...a].sort((x, y) => x.index - y.index);
154
- const sortedB = [...b].sort((x, y) => x.index - y.index);
155
- for (const [i, ao] of sortedA.entries()) {
156
- const bo = sortedB[i];
157
- if (!bo || ao.type !== bo.type || ao.index !== bo.index || ao.description !== bo.description || ao.range[0] !== bo.range[0] || ao.range[1] !== bo.range[1] || ao.durationRange?.[0] !== bo.durationRange?.[0] || ao.durationRange?.[1] !== bo.durationRange?.[1]) {
158
- return false;
159
- }
160
- }
161
- return true;
162
- }
163
- function inputFeaturesEqual(a, b) {
164
- const sortedA = [...a].sort((x, y) => x.index - y.index);
165
- const sortedB = [...b].sort((x, y) => x.index - y.index);
166
- for (const [i, ai] of sortedA.entries()) {
167
- const bi = sortedB[i];
168
- if (!bi || ai.type !== bi.type || ai.index !== bi.index || ai.description !== bi.description || ai.canRead !== bi.canRead || ai.canSubscribe !== bi.canSubscribe || ai.range[0] !== bi.range[0] || ai.range[1] !== bi.range[1]) {
169
- return false;
170
- }
171
- }
172
- return true;
173
- }
174
- function featuresEqual(a, b) {
175
- if (a.outputs.length !== b.outputs.length || a.inputs.length !== b.inputs.length) {
176
- return false;
177
- }
178
- return outputFeaturesEqual(a.outputs, b.outputs) && inputFeaturesEqual(a.inputs, b.inputs);
179
- }
180
-
181
- // src/core/device-reconciler.ts
182
- function reconcileDevices(options) {
183
- const { currentDevices, incomingRaw, createDevice, callbacks } = options;
184
- const logger = getLogger();
185
- const incomingIndices = new Set(incomingRaw.map((d) => d.DeviceIndex));
186
- const currentIndices = new Set(currentDevices.keys());
187
- for (const index of currentIndices) {
188
- if (!incomingIndices.has(index)) {
189
- const device = currentDevices.get(index);
190
- if (device) {
191
- logger.debug(`Device removed: ${device.name} (index ${index})`);
192
- currentDevices.delete(index);
193
- callbacks.onRemoved(device);
194
- }
195
- }
196
- }
197
- for (const rawDevice of incomingRaw) {
198
- if (currentIndices.has(rawDevice.DeviceIndex)) {
199
- const existingDevice = currentDevices.get(rawDevice.DeviceIndex);
200
- const newDevice = createDevice(rawDevice);
201
- if (existingDevice && !featuresEqual(existingDevice.features, newDevice.features)) {
202
- currentDevices.set(rawDevice.DeviceIndex, newDevice);
203
- logger.debug(`Device updated: ${newDevice.name} (index ${newDevice.index})`);
204
- callbacks.onUpdated(newDevice, existingDevice);
205
- }
206
- } else {
207
- const device = createDevice(rawDevice);
208
- currentDevices.set(rawDevice.DeviceIndex, device);
209
- logger.debug(`Device added: ${device.name} (index ${device.index})`);
210
- callbacks.onAdded(device);
211
- }
212
- }
213
- callbacks.onList(Array.from(currentDevices.values()));
214
- }
215
-
216
94
  // src/lib/errors.ts
217
95
  var ErrorCode = {
218
96
  UNKNOWN: 0,
@@ -286,12 +164,82 @@ function formatError(err) {
286
164
  return err instanceof Error ? err.message : String(err);
287
165
  }
288
166
 
167
+ // src/core/utils.ts
168
+ function raceTimeout(promise, ms) {
169
+ return Promise.race([
170
+ promise,
171
+ new Promise((_, reject) => setTimeout(() => reject(new TimeoutError("raceTimeout", ms)), ms))
172
+ ]);
173
+ }
174
+ function outputFeaturesEqual(a, b) {
175
+ const sortedA = [...a].sort((x, y) => x.index - y.index);
176
+ const sortedB = [...b].sort((x, y) => x.index - y.index);
177
+ for (const [i, ao] of sortedA.entries()) {
178
+ const bo = sortedB[i];
179
+ 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]) {
180
+ return false;
181
+ }
182
+ }
183
+ return true;
184
+ }
185
+ function inputFeaturesEqual(a, b) {
186
+ const sortedA = [...a].sort((x, y) => x.index - y.index);
187
+ const sortedB = [...b].sort((x, y) => x.index - y.index);
188
+ for (const [i, ai] of sortedA.entries()) {
189
+ const bi = sortedB[i];
190
+ 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]) {
191
+ return false;
192
+ }
193
+ }
194
+ return true;
195
+ }
196
+ function featuresEqual(a, b) {
197
+ if (a.outputs.length !== b.outputs.length || a.inputs.length !== b.inputs.length) {
198
+ return false;
199
+ }
200
+ return outputFeaturesEqual(a.outputs, b.outputs) && inputFeaturesEqual(a.inputs, b.inputs);
201
+ }
202
+
203
+ // src/core/device-reconciler.ts
204
+ function reconcileDevices(options) {
205
+ const { currentDevices, incomingRaw, createDevice, callbacks } = options;
206
+ const logger = options.logger ?? noopLogger;
207
+ const incomingIndices = new Set(incomingRaw.map((d) => d.DeviceIndex));
208
+ const currentIndices = new Set(currentDevices.keys());
209
+ for (const index of currentIndices) {
210
+ if (!incomingIndices.has(index)) {
211
+ const device = currentDevices.get(index);
212
+ if (device) {
213
+ logger.debug(`Device removed: ${device.name} (index ${index})`);
214
+ currentDevices.delete(index);
215
+ callbacks.onRemoved(device);
216
+ }
217
+ }
218
+ }
219
+ for (const rawDevice of incomingRaw) {
220
+ if (currentIndices.has(rawDevice.DeviceIndex)) {
221
+ const existingDevice = currentDevices.get(rawDevice.DeviceIndex);
222
+ const newDevice = createDevice(rawDevice);
223
+ if (existingDevice && !featuresEqual(existingDevice.features, newDevice.features)) {
224
+ currentDevices.set(rawDevice.DeviceIndex, newDevice);
225
+ logger.debug(`Device updated: ${newDevice.name} (index ${newDevice.index})`);
226
+ callbacks.onUpdated(newDevice, existingDevice);
227
+ }
228
+ } else {
229
+ const device = createDevice(rawDevice);
230
+ currentDevices.set(rawDevice.DeviceIndex, device);
231
+ logger.debug(`Device added: ${device.name} (index ${device.index})`);
232
+ callbacks.onAdded(device);
233
+ }
234
+ }
235
+ callbacks.onList(Array.from(currentDevices.values()));
236
+ }
237
+
289
238
  // src/protocol/constants.ts
290
239
  var PROTOCOL_VERSION_MAJOR = 4;
291
240
  var PROTOCOL_VERSION_MINOR = 0;
292
241
  var DEFAULT_CLIENT_NAME = "buttplug.js";
293
242
  var DEFAULT_REQUEST_TIMEOUT = 1e4;
294
- var DEFAULT_PING_INTERVAL = 1e3;
295
243
  var MAX_MESSAGE_ID = 4294967295;
296
244
 
297
245
  // src/protocol/messages.ts
@@ -332,7 +280,7 @@ function createDisconnect(id) {
332
280
  }
333
281
  function createStopCmd(id, options) {
334
282
  if (options?.featureIndex !== void 0 && options.deviceIndex === void 0) {
335
- throw new Error("StopCmd: featureIndex requires deviceIndex to be set");
283
+ throw new ProtocolError(ErrorCode.MESSAGE, "StopCmd: featureIndex requires deviceIndex to be set");
336
284
  }
337
285
  return {
338
286
  StopCmd: {
@@ -344,9 +292,6 @@ function createStopCmd(id, options) {
344
292
  }
345
293
  };
346
294
  }
347
- function serializeMessage(message) {
348
- return JSON.stringify([message]);
349
- }
350
295
  function serializeMessages(messages) {
351
296
  return JSON.stringify(messages);
352
297
  }
@@ -649,11 +594,7 @@ var MessageRouter = class {
649
594
  this.#onInputReading = options.onInputReading;
650
595
  this.#onError = options.onError;
651
596
  }
652
- /**
653
- * Returns the next message ID, wrapping around at {@link MAX_MESSAGE_ID}.
654
- *
655
- * @returns A monotonically increasing ID for use in outgoing messages
656
- */
597
+ /** Returns the next auto-incrementing message ID. */
657
598
  nextId() {
658
599
  this.#messageId = this.#messageId % MAX_MESSAGE_ID + 1;
659
600
  return this.#messageId;
@@ -681,6 +622,13 @@ var MessageRouter = class {
681
622
  this.#pending.delete(id);
682
623
  reject(new TimeoutError(`Request (ID ${id})`, this.#timeout));
683
624
  }, this.#timeout);
625
+ const existing = this.#pending.get(id);
626
+ if (existing) {
627
+ if (existing.timeout !== null) {
628
+ clearTimeout(existing.timeout);
629
+ }
630
+ existing.reject(new ProtocolError(ErrorCode.MESSAGE, `Request ${id} superseded by new request`));
631
+ }
684
632
  this.#pending.set(id, {
685
633
  resolve,
686
634
  reject: (err) => {
@@ -866,6 +814,7 @@ var SensorHandler = class {
866
814
  * @param key - Composite sensor key from {@link sensorKey}
867
815
  * @param callback - Function invoked with the sensor value on each reading
868
816
  * @param info - Device index, feature index, and input type for the sensor
817
+ * @throws {Error} if a subscription already exists for the given key
869
818
  */
870
819
  register(key, callback, info) {
871
820
  if (this.#subscriptions.has(key)) {
@@ -969,11 +918,7 @@ var SensorHandler = class {
969
918
  function validateRange(value, range) {
970
919
  const [min, max] = range;
971
920
  const rounded = Math.round(value);
972
- const clamped = Math.max(min, Math.min(max, rounded));
973
- if (rounded !== clamped) {
974
- getLogger().debug(`Value ${value} clamped to ${clamped} (range [${min}, ${max}])`);
975
- }
976
- return clamped;
921
+ return Math.max(min, Math.min(max, rounded));
977
922
  }
978
923
 
979
924
  // src/builders/commands.ts
@@ -1006,12 +951,9 @@ function buildPositionMessages(options) {
1006
951
  throw new DeviceError(deviceIndex, "Values array must not be empty");
1007
952
  }
1008
953
  for (const p of position) {
1009
- const feature = features[p.index];
954
+ const feature = features.find((f) => f.index === p.index);
1010
955
  if (!feature) {
1011
- throw new DeviceError(
1012
- deviceIndex,
1013
- `Invalid position index ${p.index} (device has ${features.length} position feature(s))`
1014
- );
956
+ throw new DeviceError(deviceIndex, `Position feature index ${p.index} not found on device`);
1015
957
  }
1016
958
  messages.push(
1017
959
  buildPositionMessage({
@@ -1039,12 +981,9 @@ function buildRotateMessages(options) {
1039
981
  throw new DeviceError(deviceIndex, "Values array must not be empty");
1040
982
  }
1041
983
  for (const r of speed) {
1042
- const feature = features[r.index];
984
+ const feature = features.find((f) => f.index === r.index);
1043
985
  if (!feature) {
1044
- throw new DeviceError(
1045
- deviceIndex,
1046
- `Invalid rotation index ${r.index} (device has ${features.length} rotation feature(s))`
1047
- );
986
+ throw new DeviceError(deviceIndex, `Rotation feature index ${r.index} not found on device`);
1048
987
  }
1049
988
  const validatedValue = validateRange(r.speed, feature.range);
1050
989
  const command = rotationType === "RotateWithDirection" ? { RotateWithDirection: { Value: validatedValue, Clockwise: r.clockwise } } : { Rotate: { Value: validatedValue } };
@@ -1083,12 +1022,9 @@ function buildScalarOutputMessages(options) {
1083
1022
  }
1084
1023
  const messages2 = [];
1085
1024
  for (const entry of values) {
1086
- const feature = features[entry.index];
1025
+ const feature = features.find((f) => f.index === entry.index);
1087
1026
  if (!feature) {
1088
- throw new DeviceError(
1089
- deviceIndex,
1090
- `Invalid ${errorLabel} index ${entry.index} (device has ${features.length} ${errorLabel} feature(s))`
1091
- );
1027
+ throw new DeviceError(deviceIndex, `${errorLabel} feature index ${entry.index} not found on device`);
1092
1028
  }
1093
1029
  const validatedValue = validateRange(entry.value, feature.range);
1094
1030
  const id = client.nextId();
@@ -1172,11 +1108,10 @@ function getInputIndex(features) {
1172
1108
  }
1173
1109
  var KNOWN_OUTPUT_KEYS = new Set(OUTPUT_TYPES);
1174
1110
  var KNOWN_INPUT_KEYS = new Set(INPUT_TYPES);
1175
- function collectOutputs(feature) {
1111
+ function collectOutputs(feature, logger) {
1176
1112
  if (!feature.Output) {
1177
1113
  return [];
1178
1114
  }
1179
- const logger = getLogger();
1180
1115
  const results = [];
1181
1116
  for (const key of Object.keys(feature.Output)) {
1182
1117
  if (!KNOWN_OUTPUT_KEYS.has(key)) {
@@ -1191,11 +1126,10 @@ function collectOutputs(feature) {
1191
1126
  }
1192
1127
  return results;
1193
1128
  }
1194
- function collectInputs(feature) {
1129
+ function collectInputs(feature, logger) {
1195
1130
  if (!feature.Input) {
1196
1131
  return [];
1197
1132
  }
1198
- const logger = getLogger();
1199
1133
  const results = [];
1200
1134
  for (const key of Object.keys(feature.Input)) {
1201
1135
  if (!KNOWN_INPUT_KEYS.has(key)) {
@@ -1210,16 +1144,16 @@ function collectInputs(feature) {
1210
1144
  }
1211
1145
  return results;
1212
1146
  }
1213
- function parseFeatures(raw) {
1147
+ function parseFeatures(raw, logger = noopLogger) {
1214
1148
  const outputs = [];
1215
1149
  const inputs = [];
1216
1150
  const features = raw.DeviceFeatures ?? {};
1217
1151
  const sortedFeatures = Object.values(features).sort((a, b) => a.FeatureIndex - b.FeatureIndex);
1218
1152
  for (const feature of sortedFeatures) {
1219
- for (const output of collectOutputs(feature)) {
1153
+ for (const output of collectOutputs(feature, logger)) {
1220
1154
  outputs.push(output);
1221
1155
  }
1222
- for (const input of collectInputs(feature)) {
1156
+ for (const input of collectInputs(feature, logger)) {
1223
1157
  inputs.push(input);
1224
1158
  }
1225
1159
  }
@@ -1272,8 +1206,8 @@ var Device = class {
1272
1206
  constructor(options) {
1273
1207
  this.#client = options.client;
1274
1208
  this.#raw = options.raw;
1275
- this.#logger = (options.logger ?? getLogger()).child("device");
1276
- this.#features = parseFeatures(options.raw);
1209
+ this.#logger = (options.logger ?? noopLogger).child("device");
1210
+ this.#features = parseFeatures(options.raw, this.#logger);
1277
1211
  }
1278
1212
  /**
1279
1213
  * Sets vibration intensity on all or individual motors.
@@ -1496,6 +1430,7 @@ var Device = class {
1496
1430
  *
1497
1431
  * @param type - The sensor type to unsubscribe from
1498
1432
  * @param sensorIndex - Index of the sensor if the device has multiple of the same type
1433
+ * @throws {DeviceError} if the sensor does not exist at the given index
1499
1434
  */
1500
1435
  async unsubscribe(type, sensorIndex = 0) {
1501
1436
  const features = getInputsByType(this.#features, type);
@@ -1912,6 +1847,8 @@ var PingManager = class {
1912
1847
  #pingTimer = null;
1913
1848
  /** Tracks whether a ping request is currently awaiting a response. */
1914
1849
  #pingInFlight = false;
1850
+ /** Whether the manager is in a stopped state. */
1851
+ #stopped = true;
1915
1852
  /** Maximum time in ms the server allows between pings. */
1916
1853
  #maxPingTime = 0;
1917
1854
  constructor(options) {
@@ -1939,6 +1876,7 @@ var PingManager = class {
1939
1876
  this.#cancelPing(new TimeoutError("Ping", 0));
1940
1877
  }
1941
1878
  this.stop();
1879
+ this.#stopped = false;
1942
1880
  this.#maxPingTime = maxPingTime;
1943
1881
  if (!this.#autoPing || maxPingTime <= 0) {
1944
1882
  return;
@@ -1961,6 +1899,7 @@ var PingManager = class {
1961
1899
  }
1962
1900
  /** Stops the ping timer and resets in-flight state. */
1963
1901
  stop() {
1902
+ this.#stopped = true;
1964
1903
  if (this.#pingTimer !== null) {
1965
1904
  clearInterval(this.#pingTimer);
1966
1905
  this.#pingTimer = null;
@@ -1970,14 +1909,23 @@ var PingManager = class {
1970
1909
  }
1971
1910
  /** Sends a single ping and handles timeout or failure. */
1972
1911
  async #doPing() {
1912
+ if (this.#stopped) {
1913
+ return;
1914
+ }
1973
1915
  this.#logger.debug("Sending ping");
1974
1916
  const maxPingTime = this.#maxPingTime || DEFAULT_PING_TIMEOUT_MS;
1975
1917
  const timer = setTimeout(() => {
1918
+ if (this.#stopped) {
1919
+ return;
1920
+ }
1976
1921
  this.#cancelPing(new TimeoutError("Ping", maxPingTime));
1977
1922
  }, maxPingTime);
1978
1923
  try {
1979
1924
  await this.#sendPing();
1980
1925
  } catch (err) {
1926
+ if (this.#stopped) {
1927
+ return;
1928
+ }
1981
1929
  const isTimeout = err instanceof TimeoutError;
1982
1930
  this.#logger.error(`Ping failed: ${formatError(err)}`);
1983
1931
  this.#onError(err instanceof Error ? err : new Error(String(err)));
@@ -2071,7 +2019,12 @@ var ReconnectHandler = class {
2071
2019
  /** Safely invokes a user callback, catching and logging any errors. */
2072
2020
  #safeCallback(name, fn) {
2073
2021
  try {
2074
- fn();
2022
+ const result = fn();
2023
+ if (result instanceof Promise) {
2024
+ result.catch((err) => {
2025
+ this.#logger.error(`Error in async ${name} callback: ${formatError(err)}`);
2026
+ });
2027
+ }
2075
2028
  } catch (err) {
2076
2029
  this.#logger.error(`Error in ${name} callback: ${formatError(err)}`);
2077
2030
  }
@@ -2146,6 +2099,7 @@ var ButtplugClient = class extends import_emittery.default {
2146
2099
  #serverInfo = null;
2147
2100
  #connectPromise = null;
2148
2101
  #isHandshaking = false;
2102
+ #disconnecting = false;
2149
2103
  constructor(url, options = {}) {
2150
2104
  super();
2151
2105
  this.#url = url;
@@ -2157,9 +2111,10 @@ var ButtplugClient = class extends import_emittery.default {
2157
2111
  this.#messageRouter.handleMessage(data);
2158
2112
  });
2159
2113
  this.#transport.on("close", (_code, reason) => {
2160
- this.#safetyStop();
2161
2114
  this.#pingManager.stop();
2162
- this.emit("disconnected", { reason });
2115
+ if (!this.#disconnecting) {
2116
+ this.emit("disconnected", { reason });
2117
+ }
2163
2118
  });
2164
2119
  this.#transport.on("error", (error) => {
2165
2120
  this.emit("error", { error });
@@ -2168,20 +2123,18 @@ var ButtplugClient = class extends import_emittery.default {
2168
2123
  send: (data) => this.#transport.send(data),
2169
2124
  timeout: options.requestTimeout,
2170
2125
  logger: this.#baseLogger,
2171
- onDeviceList: (devices) => runWithLogger(
2172
- this.#logger,
2173
- () => reconcileDevices({
2174
- currentDevices: this.#devices,
2175
- incomingRaw: devices,
2176
- createDevice: (raw) => new Device({ client: this, raw }),
2177
- callbacks: {
2178
- onAdded: (d) => this.emit("deviceAdded", { device: d }),
2179
- onRemoved: (d) => this.emit("deviceRemoved", { device: d }),
2180
- onUpdated: (d, old) => this.emit("deviceUpdated", { device: d, previousDevice: old }),
2181
- onList: (list) => this.emit("deviceList", { devices: list })
2182
- }
2183
- })
2184
- ),
2126
+ onDeviceList: (devices) => reconcileDevices({
2127
+ currentDevices: this.#devices,
2128
+ incomingRaw: devices,
2129
+ createDevice: (raw) => new Device({ client: this, raw, logger: this.#baseLogger }),
2130
+ logger: this.#logger,
2131
+ callbacks: {
2132
+ onAdded: (d) => this.emit("deviceAdded", { device: d }),
2133
+ onRemoved: (d) => this.emit("deviceRemoved", { device: d }),
2134
+ onUpdated: (d, old) => this.emit("deviceUpdated", { device: d, previousDevice: old }),
2135
+ onList: (list) => this.emit("deviceList", { devices: list })
2136
+ }
2137
+ }),
2185
2138
  onScanningFinished: () => {
2186
2139
  this.#scanning = false;
2187
2140
  this.emit("scanningFinished", void 0);
@@ -2282,34 +2235,45 @@ var ButtplugClient = class extends import_emittery.default {
2282
2235
  * @param reason - Optional human-readable reason for the disconnection
2283
2236
  */
2284
2237
  async disconnect(reason) {
2285
- if (this.#reconnectHandler?.active) {
2286
- this.#reconnectHandler.cancel();
2238
+ this.#disconnecting = true;
2239
+ try {
2240
+ const disconnectReason = reason ?? "Client disconnected";
2241
+ let emitted = false;
2242
+ if (this.#reconnectHandler?.active) {
2243
+ this.#reconnectHandler.cancel();
2244
+ this.#pingManager.stop();
2245
+ this.emit("disconnected", { reason: disconnectReason });
2246
+ emitted = true;
2247
+ }
2248
+ if (!this.connected) {
2249
+ return;
2250
+ }
2251
+ this.#logger.info(`Disconnecting${reason ? `: ${reason}` : ""}`);
2287
2252
  this.#pingManager.stop();
2288
- this.emit("disconnected", { reason: reason ?? "Client disconnected" });
2289
- }
2290
- if (!this.connected) {
2291
- return;
2292
- }
2293
- this.#logger.info(`Disconnecting${reason ? `: ${reason}` : ""}`);
2294
- this.#pingManager.stop();
2295
- this.#reconnectHandler?.cancel();
2296
- if (this.#serverInfo !== null && !this.#isHandshaking) {
2297
- try {
2298
- await raceTimeout(this.stopAll(), STOP_DEVICES_TIMEOUT_MS);
2299
- } catch {
2300
- this.#logger.warn("Stop all devices timed out during disconnect");
2253
+ this.#reconnectHandler?.cancel();
2254
+ if (this.#serverInfo !== null && !this.#isHandshaking) {
2255
+ try {
2256
+ await raceTimeout(this.stopAll(), STOP_DEVICES_TIMEOUT_MS);
2257
+ } catch {
2258
+ this.#logger.warn("Stop all devices timed out during disconnect");
2259
+ }
2260
+ try {
2261
+ await raceTimeout(
2262
+ this.#messageRouter.send(createDisconnect(this.#messageRouter.nextId())),
2263
+ DISCONNECT_TIMEOUT_MS
2264
+ );
2265
+ } catch {
2266
+ this.#logger.warn("Disconnect message failed or timed out");
2267
+ }
2301
2268
  }
2302
- try {
2303
- await raceTimeout(
2304
- this.#messageRouter.send(createDisconnect(this.#messageRouter.nextId())),
2305
- DISCONNECT_TIMEOUT_MS
2306
- );
2307
- } catch {
2308
- this.#logger.warn("Disconnect message failed or timed out");
2269
+ this.#messageRouter.cancelAll(new ConnectionError("Client disconnected"));
2270
+ await this.#transport.disconnect();
2271
+ if (!emitted) {
2272
+ this.emit("disconnected", { reason: disconnectReason });
2309
2273
  }
2274
+ } finally {
2275
+ this.#disconnecting = false;
2310
2276
  }
2311
- this.#messageRouter.cancelAll(new ConnectionError("Client disconnected"));
2312
- await this.#transport.disconnect();
2313
2277
  }
2314
2278
  /**
2315
2279
  * Begins scanning for devices on the server.
@@ -2354,20 +2318,18 @@ var ButtplugClient = class extends import_emittery.default {
2354
2318
  for (const response of responses) {
2355
2319
  if (isDeviceList(response)) {
2356
2320
  const deviceList = getDeviceList(response);
2357
- runWithLogger(
2358
- this.#logger,
2359
- () => reconcileDevices({
2360
- currentDevices: this.#devices,
2361
- incomingRaw: Object.values(deviceList.Devices),
2362
- createDevice: (raw) => new Device({ client: this, raw }),
2363
- callbacks: {
2364
- onAdded: (d) => this.emit("deviceAdded", { device: d }),
2365
- onRemoved: (d) => this.emit("deviceRemoved", { device: d }),
2366
- onUpdated: (d, old) => this.emit("deviceUpdated", { device: d, previousDevice: old }),
2367
- onList: (list) => this.emit("deviceList", { devices: list })
2368
- }
2369
- })
2370
- );
2321
+ reconcileDevices({
2322
+ currentDevices: this.#devices,
2323
+ incomingRaw: Object.values(deviceList.Devices),
2324
+ createDevice: (raw) => new Device({ client: this, raw, logger: this.#baseLogger }),
2325
+ logger: this.#logger,
2326
+ callbacks: {
2327
+ onAdded: (d) => this.emit("deviceAdded", { device: d }),
2328
+ onRemoved: (d) => this.emit("deviceRemoved", { device: d }),
2329
+ onUpdated: (d, old) => this.emit("deviceUpdated", { device: d, previousDevice: old }),
2330
+ onList: (list) => this.emit("deviceList", { devices: list })
2331
+ }
2332
+ });
2371
2333
  }
2372
2334
  }
2373
2335
  }
@@ -2433,16 +2395,18 @@ var ButtplugClient = class extends import_emittery.default {
2433
2395
  get devices() {
2434
2396
  return Array.from(this.#devices.values());
2435
2397
  }
2436
- /** Best-effort StopCmd bypassing connection checks for safety-critical scenarios. */
2437
- #safetyStop() {
2438
- try {
2439
- if (this.#transport.state === "connected") {
2440
- const msg = createStopCmd(this.#messageRouter.nextId());
2441
- this.#transport.send(serializeMessage(msg));
2442
- }
2443
- } catch {
2444
- this.#logger.warn("Safety stop failed \u2014 transport may already be closed");
2445
- }
2398
+ /**
2399
+ * Disposes the client, clearing all event listeners and internal state.
2400
+ *
2401
+ * Callers should {@link disconnect} first if still connected.
2402
+ * Subsequent usage of the client after disposal is undefined behavior.
2403
+ */
2404
+ dispose() {
2405
+ this.clearListeners();
2406
+ this.#pingManager.stop();
2407
+ this.#sensorHandler.clear();
2408
+ this.#reconnectHandler?.cancel();
2409
+ this.#devices.clear();
2446
2410
  }
2447
2411
  /**
2448
2412
  * Validates connection state before performing an action.
@@ -2502,59 +2466,6 @@ var ButtplugClient = class extends import_emittery.default {
2502
2466
  }
2503
2467
  };
2504
2468
 
2505
- // src/patterns/easing.ts
2506
- var clamp = (t) => Math.min(1, Math.max(0, t));
2507
- var linear = (t) => clamp(t);
2508
- var easeIn = (t) => clamp(t) ** 3;
2509
- var easeOut = (t) => 1 - (1 - clamp(t)) ** 3;
2510
- var easeInOut = (t) => {
2511
- const c = clamp(t);
2512
- return c < 0.5 ? 4 * c ** 3 : 1 - (-2 * c + 2) ** 3 / 2;
2513
- };
2514
- var step = (t) => clamp(t) < 1 ? 0 : 1;
2515
- var EASING_FUNCTIONS = {
2516
- linear,
2517
- easeIn,
2518
- easeOut,
2519
- easeInOut,
2520
- step
2521
- };
2522
- var ease = (t, easing) => {
2523
- const fn = EASING_FUNCTIONS[easing];
2524
- return fn ? fn(t) : clamp(t);
2525
- };
2526
-
2527
- // src/patterns/types.ts
2528
- var import_zod2 = require("zod");
2529
- var EASING_VALUES = ["linear", "easeIn", "easeOut", "easeInOut", "step"];
2530
- var EasingSchema = import_zod2.z.enum(EASING_VALUES);
2531
- var KeyframeSchema = import_zod2.z.object({
2532
- value: import_zod2.z.number().min(0).max(1),
2533
- duration: import_zod2.z.number().int().nonnegative(),
2534
- easing: EasingSchema.optional()
2535
- });
2536
- var TrackSchema = import_zod2.z.object({
2537
- featureIndex: import_zod2.z.number().int().nonnegative(),
2538
- keyframes: import_zod2.z.array(KeyframeSchema).min(1),
2539
- clockwise: import_zod2.z.boolean().optional(),
2540
- outputType: OutputTypeSchema.optional()
2541
- });
2542
- var PRESET_NAMES = ["pulse", "wave", "ramp_up", "ramp_down", "heartbeat", "surge", "stroke"];
2543
- var PresetPatternSchema = import_zod2.z.object({
2544
- type: import_zod2.z.literal("preset"),
2545
- preset: import_zod2.z.enum(PRESET_NAMES),
2546
- intensity: import_zod2.z.number().min(0).max(1).optional(),
2547
- speed: import_zod2.z.number().min(0.25).max(4).optional(),
2548
- loop: import_zod2.z.union([import_zod2.z.boolean(), import_zod2.z.number().int().positive()]).optional()
2549
- });
2550
- var CustomPatternSchema = import_zod2.z.object({
2551
- type: import_zod2.z.literal("custom"),
2552
- tracks: import_zod2.z.array(TrackSchema).min(1),
2553
- intensity: import_zod2.z.number().min(0).max(1).optional(),
2554
- loop: import_zod2.z.union([import_zod2.z.boolean(), import_zod2.z.number().int().positive()]).optional()
2555
- });
2556
- var PatternDescriptorSchema = import_zod2.z.discriminatedUnion("type", [PresetPatternSchema, CustomPatternSchema]);
2557
-
2558
2469
  // src/patterns/presets.ts
2559
2470
  var MOTOR_OUTPUT_TYPES = ["Vibrate", "Rotate", "RotateWithDirection", "Oscillate", "Constrict"];
2560
2471
  var POSITION_OUTPUT_TYPES = ["Position", "HwPositionWithDuration"];
@@ -2662,6 +2573,28 @@ function getPresetInfo() {
2662
2573
  }));
2663
2574
  }
2664
2575
 
2576
+ // src/patterns/easing.ts
2577
+ var clamp = (t) => Math.min(1, Math.max(0, t));
2578
+ var linear = (t) => clamp(t);
2579
+ var easeIn = (t) => clamp(t) ** 3;
2580
+ var easeOut = (t) => 1 - (1 - clamp(t)) ** 3;
2581
+ var easeInOut = (t) => {
2582
+ const c = clamp(t);
2583
+ return c < 0.5 ? 4 * c ** 3 : 1 - (-2 * c + 2) ** 3 / 2;
2584
+ };
2585
+ var step = (t) => clamp(t) < 1 ? 0 : 1;
2586
+ var EASING_FUNCTIONS = {
2587
+ linear,
2588
+ easeIn,
2589
+ easeOut,
2590
+ easeInOut,
2591
+ step
2592
+ };
2593
+ var ease = (t, easing) => {
2594
+ const fn = EASING_FUNCTIONS[easing];
2595
+ return fn ? fn(t) : clamp(t);
2596
+ };
2597
+
2665
2598
  // src/patterns/scheduler.ts
2666
2599
  function interpolateKeyframes(keyframes, elapsed) {
2667
2600
  let accumulated = 0;
@@ -2702,8 +2635,14 @@ function buildScalarCommand(track, value) {
2702
2635
  return { Constrict: { Value: value } };
2703
2636
  case "Position":
2704
2637
  return { Position: { Value: value } };
2638
+ case "Spray":
2639
+ return { Spray: { Value: value } };
2640
+ case "Temperature":
2641
+ return { Temperature: { Value: value } };
2642
+ case "Led":
2643
+ return { Led: { Value: value } };
2705
2644
  default:
2706
- return { Vibrate: { Value: value } };
2645
+ throw new DeviceError(0, `Unsupported output type in pattern: ${track.outputType}`);
2707
2646
  }
2708
2647
  }
2709
2648
  function getCycleDuration(tracks) {
@@ -2727,7 +2666,10 @@ function evaluateScalarTrack(state, track, elapsed, device, buildCommand, onErro
2727
2666
  return;
2728
2667
  }
2729
2668
  const command = buildCommand(track, mapped);
2730
- device.output({ featureIndex, command }).catch((err) => onError(state, err));
2669
+ device.output({ featureIndex, command }).catch((err) => {
2670
+ state.lastSentValues.delete(featureIndex);
2671
+ onError(state, err);
2672
+ });
2731
2673
  state.lastSentValues.set(featureIndex, mapped);
2732
2674
  }
2733
2675
  function evaluateHwPositionTrack(state, track, elapsed, device, onError) {
@@ -2854,6 +2796,37 @@ function resolveCustomTracks(device, descriptor) {
2854
2796
  return tracks;
2855
2797
  }
2856
2798
 
2799
+ // src/patterns/types.ts
2800
+ var import_zod2 = require("zod");
2801
+ var EASING_VALUES = ["linear", "easeIn", "easeOut", "easeInOut", "step"];
2802
+ var EasingSchema = import_zod2.z.enum(EASING_VALUES);
2803
+ var KeyframeSchema = import_zod2.z.object({
2804
+ value: import_zod2.z.number().min(0).max(1),
2805
+ duration: import_zod2.z.number().int().nonnegative(),
2806
+ easing: EasingSchema.optional()
2807
+ });
2808
+ var TrackSchema = import_zod2.z.object({
2809
+ featureIndex: import_zod2.z.number().int().nonnegative(),
2810
+ keyframes: import_zod2.z.array(KeyframeSchema).min(1),
2811
+ clockwise: import_zod2.z.boolean().optional(),
2812
+ outputType: OutputTypeSchema.optional()
2813
+ });
2814
+ var PRESET_NAMES = ["pulse", "wave", "ramp_up", "ramp_down", "heartbeat", "surge", "stroke"];
2815
+ var PresetPatternSchema = import_zod2.z.object({
2816
+ type: import_zod2.z.literal("preset"),
2817
+ preset: import_zod2.z.enum(PRESET_NAMES),
2818
+ intensity: import_zod2.z.number().min(0).max(1).optional(),
2819
+ speed: import_zod2.z.number().min(0.25).max(4).optional(),
2820
+ loop: import_zod2.z.union([import_zod2.z.boolean(), import_zod2.z.number().int().positive()]).optional()
2821
+ });
2822
+ var CustomPatternSchema = import_zod2.z.object({
2823
+ type: import_zod2.z.literal("custom"),
2824
+ tracks: import_zod2.z.array(TrackSchema).min(1),
2825
+ intensity: import_zod2.z.number().min(0).max(1).optional(),
2826
+ loop: import_zod2.z.union([import_zod2.z.boolean(), import_zod2.z.number().int().positive()]).optional()
2827
+ });
2828
+ var PatternDescriptorSchema = import_zod2.z.discriminatedUnion("type", [PresetPatternSchema, CustomPatternSchema]);
2829
+
2857
2830
  // src/patterns/engine.ts
2858
2831
  var DEFAULT_TIMEOUT_MS = 18e5;
2859
2832
  var MIN_TICK_INTERVAL_MS = 50;
@@ -3019,7 +2992,8 @@ var PatternEngine = class {
3019
2992
  if (state.stopped) {
3020
2993
  return;
3021
2994
  }
3022
- const elapsed = performance.now() - state.startedAt;
2995
+ const now = performance.now();
2996
+ const elapsed = now - state.startedAt;
3023
2997
  const cycleDuration = getCycleDuration(state.tracks);
3024
2998
  const cycleElapsed = cycleDuration > 0 && elapsed >= cycleDuration ? cycleDuration : elapsed;
3025
2999
  const onError = (s, err) => this.#handleOutputError(s, err);
@@ -3043,9 +3017,9 @@ var PatternEngine = class {
3043
3017
  return;
3044
3018
  }
3045
3019
  }
3046
- const drift = performance.now() - state.expectedTickTime;
3020
+ const drift = now - state.expectedTickTime;
3047
3021
  const nextDelay = Math.max(0, state.tickInterval - drift);
3048
- state.expectedTickTime = performance.now() + nextDelay;
3022
+ state.expectedTickTime = now + nextDelay;
3049
3023
  state.timerId = setTimeout(() => this.#tick(state, device), nextDelay);
3050
3024
  }
3051
3025
  /** Builds a {@link PatternDescriptor} from the shorthand pattern argument and options. */
@@ -3101,12 +3075,7 @@ var PatternEngine = class {
3101
3075
  const device = this.#client.getDevice(state.deviceIndex);
3102
3076
  if (device) {
3103
3077
  for (const track of state.tracks) {
3104
- if (track.outputType === "Position" || track.outputType === "HwPositionWithDuration") {
3105
- device.stop({ featureIndex: track.featureIndex }).catch(noop);
3106
- } else {
3107
- const command = buildScalarCommand(track, track.range[0]);
3108
- device.output({ featureIndex: track.featureIndex, command }).catch(noop);
3109
- }
3078
+ device.stop({ featureIndex: track.featureIndex }).catch(noop);
3110
3079
  }
3111
3080
  }
3112
3081
  if (complete) {
@@ -3119,58 +3088,22 @@ var PatternEngine = class {
3119
3088
  0 && (module.exports = {
3120
3089
  ButtplugClient,
3121
3090
  ButtplugError,
3122
- ClientMessageSchema,
3123
3091
  ConnectionError,
3124
- DEFAULT_CLIENT_NAME,
3125
- DEFAULT_PING_INTERVAL,
3126
- DEFAULT_REQUEST_TIMEOUT,
3127
3092
  Device,
3128
3093
  DeviceError,
3129
- DeviceFeaturesSchema,
3130
3094
  EASING_FUNCTIONS,
3131
3095
  EASING_VALUES,
3132
3096
  ErrorCode,
3133
- FeatureValueSchema,
3134
3097
  HandshakeError,
3135
- HwPositionOutputDataSchema,
3136
3098
  INPUT_TYPES,
3137
- INPUT_TYPE_VALUES,
3138
- InputCommandTypeSchema,
3139
- InputDataSchema,
3140
- InputFeatureSchema,
3141
- InputReadingSchema,
3142
- InputTypeSchema,
3143
- KeyframeSchema,
3144
- MAX_MESSAGE_ID,
3145
3099
  OUTPUT_TYPES,
3146
- OUTPUT_TYPE_VALUES,
3147
- OutputCommandSchema,
3148
- OutputFeatureSchema,
3149
- OutputTypeSchema,
3150
3100
  PRESETS,
3151
3101
  PRESET_NAMES,
3152
- PROTOCOL_VERSION_MAJOR,
3153
- PROTOCOL_VERSION_MINOR,
3154
- PatternDescriptorSchema,
3155
3102
  PatternEngine,
3156
- PingManager,
3157
- PositionValueSchema,
3158
3103
  ProtocolError,
3159
- ReconnectDefaults,
3160
- ReconnectHandler,
3161
- RotateWithDirectionOutputDataSchema,
3162
- RotationValueSchema,
3163
- SensorValueSchema,
3164
- ServerInfoSchema,
3165
- ServerMessageSchema,
3166
- SignedScalarOutputDataSchema,
3167
3104
  TimeoutError,
3168
- UnsignedScalarOutputDataSchema,
3169
- WebSocketTransport,
3170
3105
  consoleLogger,
3171
3106
  formatError,
3172
- getLogger,
3173
3107
  getPresetInfo,
3174
- noopLogger,
3175
- runWithLogger
3108
+ noopLogger
3176
3109
  });