dualsense-ts 6.2.0 → 6.4.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.
Files changed (79) hide show
  1. package/LINUX_HID.md +85 -0
  2. package/README.md +83 -12
  3. package/dist/dualsense.d.ts +28 -8
  4. package/dist/dualsense.d.ts.map +1 -1
  5. package/dist/dualsense.js +57 -17
  6. package/dist/dualsense.js.map +1 -1
  7. package/dist/hid/bt_checksum.d.ts +7 -0
  8. package/dist/hid/bt_checksum.d.ts.map +1 -1
  9. package/dist/hid/bt_checksum.js +33 -1
  10. package/dist/hid/bt_checksum.js.map +1 -1
  11. package/dist/hid/dualsense_hid.d.ts +77 -0
  12. package/dist/hid/dualsense_hid.d.ts.map +1 -1
  13. package/dist/hid/dualsense_hid.js +193 -0
  14. package/dist/hid/dualsense_hid.js.map +1 -1
  15. package/dist/hid/factory_info.d.ts +53 -0
  16. package/dist/hid/factory_info.d.ts.map +1 -0
  17. package/dist/hid/factory_info.js +166 -0
  18. package/dist/hid/factory_info.js.map +1 -0
  19. package/dist/hid/firmware_info.d.ts +46 -0
  20. package/dist/hid/firmware_info.d.ts.map +1 -0
  21. package/dist/hid/firmware_info.js +109 -0
  22. package/dist/hid/firmware_info.js.map +1 -0
  23. package/dist/hid/hid_provider.d.ts +12 -0
  24. package/dist/hid/hid_provider.d.ts.map +1 -1
  25. package/dist/hid/hid_provider.js +13 -0
  26. package/dist/hid/hid_provider.js.map +1 -1
  27. package/dist/hid/index.d.ts +3 -0
  28. package/dist/hid/index.d.ts.map +1 -1
  29. package/dist/hid/index.js +3 -0
  30. package/dist/hid/index.js.map +1 -1
  31. package/dist/hid/node_hid_provider.d.ts +2 -0
  32. package/dist/hid/node_hid_provider.d.ts.map +1 -1
  33. package/dist/hid/node_hid_provider.js +14 -0
  34. package/dist/hid/node_hid_provider.js.map +1 -1
  35. package/dist/hid/pairing_info.d.ts +9 -0
  36. package/dist/hid/pairing_info.d.ts.map +1 -0
  37. package/dist/hid/pairing_info.js +33 -0
  38. package/dist/hid/pairing_info.js.map +1 -0
  39. package/dist/hid/web_hid_provider.d.ts +14 -0
  40. package/dist/hid/web_hid_provider.d.ts.map +1 -1
  41. package/dist/hid/web_hid_provider.js +79 -8
  42. package/dist/hid/web_hid_provider.js.map +1 -1
  43. package/dist/id.d.ts +4 -0
  44. package/dist/id.d.ts.map +1 -1
  45. package/dist/manager.d.ts +57 -4
  46. package/dist/manager.d.ts.map +1 -1
  47. package/dist/manager.js +248 -66
  48. package/dist/manager.js.map +1 -1
  49. package/nodehid_example/debug.ts +43 -13
  50. package/nodehid_example/single.ts +29 -0
  51. package/package.json +1 -1
  52. package/src/dualsense.ts +73 -23
  53. package/src/hid/bt_checksum.ts +39 -0
  54. package/src/hid/dualsense_hid.ts +230 -0
  55. package/src/hid/factory_info.ts +206 -0
  56. package/src/hid/firmware_info.ts +157 -0
  57. package/src/hid/hid_provider.ts +22 -0
  58. package/src/hid/index.ts +3 -0
  59. package/src/hid/node_hid_provider.ts +14 -0
  60. package/src/hid/pairing_info.ts +33 -0
  61. package/src/hid/web_hid_provider.ts +87 -8
  62. package/src/id.ts +5 -0
  63. package/src/manager.ts +285 -71
  64. package/webhid_example/build/asset-manifest.json +3 -3
  65. package/webhid_example/build/index.html +1 -1
  66. package/webhid_example/build/static/js/main.1c1a2c23.js +3 -0
  67. package/webhid_example/build/static/js/main.1c1a2c23.js.map +1 -0
  68. package/webhid_example/src/App.tsx +7 -1
  69. package/webhid_example/src/hud/AudioIndicator.tsx +116 -0
  70. package/webhid_example/src/hud/BatteryIndicator.tsx +4 -2
  71. package/webhid_example/src/hud/ColorIndicator.tsx +72 -0
  72. package/webhid_example/src/hud/ControllerConnection.tsx +29 -2
  73. package/webhid_example/src/hud/Debugger.tsx +31 -1
  74. package/webhid_example/src/hud/LightbarFadeButtons.tsx +2 -2
  75. package/webhid_example/src/hud/MuteLedControls.tsx +3 -2
  76. package/webhid_example/src/hud/index.tsx +2 -0
  77. package/webhid_example/build/static/js/main.2ac31d24.js +0 -3
  78. package/webhid_example/build/static/js/main.2ac31d24.js.map +0 -1
  79. /package/webhid_example/build/static/js/{main.2ac31d24.js.LICENSE.txt → main.1c1a2c23.js.LICENSE.txt} +0 -0
package/src/manager.ts CHANGED
@@ -10,12 +10,28 @@ import { WebHIDProvider } from "./hid/web_hid_provider";
10
10
  interface ControllerSlot {
11
11
  /** The Dualsense instance for this slot */
12
12
  controller: Dualsense;
13
- /** Serial number for reconnection matching (Node.js only) */
13
+ /**
14
+ * Stable hardware identity, derived from firmware info once available.
15
+ * Format: "serial:..." (factory serial) or "device:..." (firmware deviceInfo blob).
16
+ * Preferred over node-hid's serial number, which is unreliable on Linux Bluetooth.
17
+ */
18
+ identity?: string;
19
+ /** Best-effort serial number from node-hid (may be wrong/missing) */
14
20
  serial?: string;
15
- /** Current device path */
21
+ /** Current device path (Node.js) */
16
22
  path?: string;
17
23
  /** Slot index (stable, never reused until released) */
18
24
  index: number;
25
+ /** Set after we've subscribed to onReady, to avoid double-wiring */
26
+ readyHooked?: boolean;
27
+ /**
28
+ * True until firmware/factory info has loaded for the first time.
29
+ * Provisional slots are hidden from public state (`controllers`,
30
+ * `count`, `state.players`) so consumers don't see a connect that
31
+ * might immediately get merged into an existing slot via identity
32
+ * matching. Made non-provisional in `handleSlotReady`.
33
+ */
34
+ provisional?: boolean;
19
35
  }
20
36
 
21
37
  /** The state exposed by a DualsenseManager via the Input system */
@@ -100,9 +116,12 @@ export class DualsenseManager extends Input<DualsenseManagerState> {
100
116
  /** All controller slots, indexed by slot number */
101
117
  private readonly slots: ControllerSlot[] = [];
102
118
 
103
- /** Map from serial number to slot index, for reconnection matching */
119
+ /** Map from node-hid serial to slot index — best-effort, used as a fallback */
104
120
  private readonly serialToSlot = new Map<string, number>();
105
121
 
122
+ /** Map from canonical hardware identity to slot index — preferred when available */
123
+ private readonly identityToSlot = new Map<string, number>();
124
+
106
125
  /** Discovery polling timer (Node.js only) */
107
126
  private discoveryTimer?: ReturnType<typeof setInterval>;
108
127
 
@@ -129,24 +148,45 @@ export class DualsenseManager extends Input<DualsenseManagerState> {
129
148
  return this.state.active > 0;
130
149
  }
131
150
 
132
- /** All managed controller instances (including disconnected ones awaiting reconnection) */
151
+ /**
152
+ * All managed controller instances (including disconnected ones awaiting
153
+ * reconnection). Excludes provisional slots whose identity is still being
154
+ * resolved — those become visible only after firmware info loads, to
155
+ * avoid surfacing controllers that may be merged into an existing slot.
156
+ */
133
157
  public get controllers(): readonly Dualsense[] {
134
- return this.slots.map((s) => s.controller);
158
+ return this.publicSlots().map((s) => s.controller);
135
159
  }
136
160
 
137
161
  /** Number of managed controllers (including disconnected ones awaiting reconnection) */
138
162
  public get count(): number {
139
- return this.slots.length;
163
+ return this.publicSlots().length;
140
164
  }
141
165
 
142
166
  /** Get a controller by slot index */
143
167
  public get(index: number): Dualsense | undefined {
144
- return this.slots[index]?.controller;
168
+ const slot = this.slots[index] as ControllerSlot | undefined;
169
+ if (!slot || slot.provisional) return undefined;
170
+ return slot.controller;
145
171
  }
146
172
 
147
173
  /** Iterate over all managed controllers */
148
174
  [Symbol.iterator](): IterableIterator<Dualsense> {
149
- return this.slots.map((s) => s.controller).values();
175
+ return this.publicSlots().map((s) => s.controller).values();
176
+ }
177
+
178
+ /** Slots that are visible to the consumer (i.e. identity has been resolved) */
179
+ private publicSlots(): ControllerSlot[] {
180
+ return this.slots.filter((s) => !s.provisional);
181
+ }
182
+
183
+ /**
184
+ * True while at least one controller has been discovered but is still
185
+ * waiting for firmware info to load. Useful for showing a "connecting"
186
+ * state in the UI without surfacing the unresolved slot itself.
187
+ */
188
+ public get pending(): boolean {
189
+ return this.slots.some((s) => s.provisional);
150
190
  }
151
191
 
152
192
  /**
@@ -181,7 +221,10 @@ export class DualsenseManager extends Input<DualsenseManagerState> {
181
221
  // Disconnect the HID provider and release the claimed device
182
222
  slot.controller.hid.provider.disconnect();
183
223
 
184
- // Remove serial mapping
224
+ // Remove identity / serial mappings
225
+ if (slot.identity) {
226
+ this.identityToSlot.delete(slot.identity);
227
+ }
185
228
  if (slot.serial) {
186
229
  this.serialToSlot.delete(slot.serial);
187
230
  }
@@ -189,10 +232,13 @@ export class DualsenseManager extends Input<DualsenseManagerState> {
189
232
  // Remove the slot (shift remaining slots down)
190
233
  this.slots.splice(index, 1);
191
234
 
192
- // Re-index serial mappings after splice
235
+ // Re-index identity / serial mappings after splice
193
236
  for (let i = index; i < this.slots.length; i++) {
194
237
  const s = this.slots[i];
195
238
  s.index = i;
239
+ if (s.identity) {
240
+ this.identityToSlot.set(s.identity, i);
241
+ }
196
242
  if (s.serial) {
197
243
  this.serialToSlot.set(s.serial, i);
198
244
  }
@@ -249,64 +295,80 @@ export class DualsenseManager extends Input<DualsenseManagerState> {
249
295
 
250
296
  // --- Private ---
251
297
 
298
+ /** Previous state snapshot, for deduplication */
299
+ private lastActive = 0;
300
+ private lastPlayerCount = 0;
301
+ /** Fingerprint of the last published player set (slot indices + connected flags) */
302
+ private lastPlayerKey = "";
303
+
252
304
  /** Build a new state snapshot and push it through InputSet */
253
305
  private updateState(): void {
254
306
  const players = new Map<number, Dualsense>();
307
+ let activeCount = 0;
308
+ let key = "";
255
309
  for (const slot of this.slots) {
310
+ if (slot.provisional) continue;
256
311
  players.set(slot.index, slot.controller);
312
+ const connected = slot.controller.connection.active;
313
+ if (connected) activeCount += 1;
314
+ key += `${slot.index}:${connected ? "1" : "0"},`;
257
315
  }
258
316
 
259
- const activeCount = this.slots.filter(
260
- (s) => s.controller.connection.active
261
- ).length;
317
+ // Suppress no-op publishes (e.g. provisional slots churning without
318
+ // changing the visible state) to avoid noisy change events.
319
+ if (
320
+ activeCount === this.lastActive &&
321
+ players.size === this.lastPlayerCount &&
322
+ key === this.lastPlayerKey
323
+ ) {
324
+ return;
325
+ }
326
+ this.lastActive = activeCount;
327
+ this.lastPlayerCount = players.size;
328
+ this.lastPlayerKey = key;
262
329
 
263
- // Always creates a new object so BasicComparator detects the change
264
330
  this[InputSet]({ active: activeCount, players });
265
331
  }
266
332
 
267
- /** Create a Dualsense instance and register it in a slot */
333
+ /**
334
+ * Create a Dualsense instance and register it in a (provisional) slot.
335
+ * The caller is responsible for opening the device on the provider — the
336
+ * manager treats this as the *only* path that opens new devices, so
337
+ * identity matching can run before the slot becomes visible.
338
+ *
339
+ * Note: identity is the sole reconnection key. We do NOT key on node-hid's
340
+ * serialNumber because it's frequently missing or wrong (especially over
341
+ * Bluetooth on Linux). Path is tracked only so we can re-target the same
342
+ * device on transplant.
343
+ */
268
344
  private createSlot(
269
345
  provider: HIDProvider,
270
346
  serial?: string,
271
347
  path?: string
272
348
  ): ControllerSlot {
273
- // Check if this serial already has a slot (reconnection)
274
- if (serial) {
275
- const existingIndex = this.serialToSlot.get(serial);
276
- if (existingIndex !== undefined) {
277
- const existingSlot =
278
- this.slots[existingIndex] as ControllerSlot | undefined;
279
- if (existingSlot && !existingSlot.controller.connection.active) {
280
- // Reconnect to existing slot: update the provider target
281
- if (provider instanceof NodeHIDProvider) {
282
- const existingProvider = existingSlot.controller.hid
283
- .provider as NodeHIDProvider;
284
- existingProvider.targetPath = path;
285
- existingProvider.targetSerial = serial;
286
- }
287
- existingSlot.path = path;
288
- // Release the new provider since we're reusing the existing one
289
- provider.disconnect();
290
- return existingSlot;
291
- }
292
- }
293
- }
294
-
295
349
  const hid = new DualsenseHID(provider);
296
350
  const controller = new Dualsense({ hid });
297
351
  const index = this.slots.length;
298
352
 
299
- const slot: ControllerSlot = { controller, serial, path, index };
353
+ const slot: ControllerSlot = {
354
+ controller,
355
+ serial,
356
+ path,
357
+ index,
358
+ // Hide from public state until firmware info has been read and any
359
+ // identity-based merge has had a chance to run.
360
+ provisional: true,
361
+ };
300
362
  this.slots.push(slot);
301
363
 
302
364
  if (serial) {
303
365
  this.serialToSlot.set(serial, index);
304
366
  }
305
367
 
306
- // Assign player LEDs — set immediately and re-apply on every connect,
307
- // since the controller may not be connected yet at slot creation time.
368
+ // Assign player LEDs — skip for provisional slots (they may get
369
+ // transplanted to a different index). Re-apply on every connect.
308
370
  const applyPlayerLeds = () => {
309
- if (this.autoAssignPlayerLeds) {
371
+ if (this.autoAssignPlayerLeds && !slot.provisional) {
310
372
  controller.playerLeds.set(this.playerPatterns[slot.index] ?? 0);
311
373
  }
312
374
  };
@@ -320,11 +382,159 @@ export class DualsenseManager extends Input<DualsenseManagerState> {
320
382
  this.updateState();
321
383
  });
322
384
 
385
+ // Hook firmware-info readiness so we can perform identity-based slot
386
+ // matching once the controller's hardware identity is known. This is
387
+ // far more reliable than node-hid's serial number, which is
388
+ // frequently missing or wrong (especially over Bluetooth on Linux).
389
+ if (!slot.readyHooked) {
390
+ slot.readyHooked = true;
391
+ hid.onReady(() => this.handleSlotReady(slot));
392
+ }
393
+
323
394
  this.updateState();
324
395
 
325
396
  return slot;
326
397
  }
327
398
 
399
+ /**
400
+ * Called when a slot's HID layer has finished reading firmware/factory info.
401
+ * If the resolved identity matches a *different* (disconnected) slot, the
402
+ * underlying device is transplanted into that slot's existing provider so
403
+ * the consumer's Dualsense reference is preserved across reconnect.
404
+ */
405
+ private handleSlotReady(slot: ControllerSlot): void {
406
+ const identity = slot.controller.hid.identity;
407
+
408
+ // No identity at all (firmware read failed completely after retries) —
409
+ // promote the slot anyway so the consumer can still use it. We just
410
+ // won't be able to merge it on reconnect.
411
+ if (!identity) {
412
+ this.promoteSlot(slot);
413
+ return;
414
+ }
415
+
416
+ const existingIndex = this.identityToSlot.get(identity);
417
+
418
+ // First time we've seen this identity — claim it for this slot.
419
+ if (existingIndex === undefined) {
420
+ slot.identity = identity;
421
+ this.identityToSlot.set(identity, slot.index);
422
+ this.promoteSlot(slot);
423
+ return;
424
+ }
425
+
426
+ // We already have a slot for this identity — make sure it's not just us.
427
+ if (existingIndex === slot.index) {
428
+ this.promoteSlot(slot);
429
+ return;
430
+ }
431
+
432
+ const existingSlot = this.slots[existingIndex] as
433
+ | ControllerSlot
434
+ | undefined;
435
+ if (!existingSlot) {
436
+ // Stale mapping — overwrite.
437
+ slot.identity = identity;
438
+ this.identityToSlot.set(identity, slot.index);
439
+ this.promoteSlot(slot);
440
+ return;
441
+ }
442
+
443
+ // If the existing slot is currently connected, both slots map to the
444
+ // same hardware (shouldn't normally happen). Prefer the older slot
445
+ // and drop the new one without ever publishing it.
446
+ if (existingSlot.controller.connection.active) {
447
+ this.dropSlot(slot);
448
+ return;
449
+ }
450
+
451
+ // Existing slot is disconnected — transplant the new device into it.
452
+ // The new (provisional) slot is dropped before any state is published,
453
+ // so the consumer only ever sees the original slot reconnect in place.
454
+ this.transplant(slot, existingSlot);
455
+ }
456
+
457
+ /** Mark a slot as visible to consumers and publish state */
458
+ private promoteSlot(slot: ControllerSlot): void {
459
+ if (!slot.provisional) return;
460
+ slot.provisional = false;
461
+ if (this.autoAssignPlayerLeds) {
462
+ slot.controller.playerLeds.set(this.playerPatterns[slot.index] ?? 0);
463
+ }
464
+ this.updateState();
465
+ }
466
+
467
+ /**
468
+ * Move the device handle from `from` into `into`'s existing provider so
469
+ * the existing Dualsense instance reconnects in place. Then remove `from`.
470
+ */
471
+ private transplant(from: ControllerSlot, into: ControllerSlot): void {
472
+ const fromProvider = from.controller.hid.provider;
473
+ const intoProvider = into.controller.hid.provider;
474
+
475
+ if (
476
+ fromProvider instanceof WebHIDProvider &&
477
+ intoProvider instanceof WebHIDProvider &&
478
+ fromProvider.device
479
+ ) {
480
+ // Move the open HIDDevice handle from the source provider to the
481
+ // destination. We can't close + reopen here — that would race with
482
+ // the destination's attach() call. Instead we abandon the source
483
+ // provider in place (its slot is about to be dropped) and let the
484
+ // destination take over the same handle. The source's input listener
485
+ // will be GC'd along with the source provider once nothing else
486
+ // references the dropped slot's Dualsense.
487
+ const device = fromProvider.device;
488
+ fromProvider.device = undefined;
489
+ if (fromProvider.deviceId) {
490
+ HIDProvider.claimedDevices.delete(fromProvider.deviceId);
491
+ fromProvider.deviceId = undefined;
492
+ }
493
+ intoProvider.replaceDevice(device);
494
+ } else if (
495
+ fromProvider instanceof NodeHIDProvider &&
496
+ intoProvider instanceof NodeHIDProvider
497
+ ) {
498
+ // node-hid HID handles can't be moved between providers, so we close
499
+ // the source (releasing its path claim) and re-open the same path on
500
+ // the destination provider — preserving the existing Dualsense
501
+ // instance and its subscribers.
502
+ const newPath = from.path;
503
+ const newSerial = from.serial;
504
+ fromProvider.disconnect();
505
+ intoProvider.targetPath = newPath;
506
+ intoProvider.targetSerial = newSerial;
507
+ into.path = newPath;
508
+ into.serial = newSerial;
509
+ void Promise.resolve(intoProvider.connect()).catch(() => {
510
+ /* errors surface via provider.onError */
511
+ });
512
+ }
513
+
514
+ this.dropSlot(from);
515
+ }
516
+
517
+ /** Remove a slot without firing the player-LED reshuffle */
518
+ private dropSlot(slot: ControllerSlot): void {
519
+ const idx = this.slots.indexOf(slot);
520
+ if (idx === -1) return;
521
+
522
+ if (slot.identity) this.identityToSlot.delete(slot.identity);
523
+ if (slot.serial) this.serialToSlot.delete(slot.serial);
524
+
525
+ this.slots.splice(idx, 1);
526
+
527
+ // Re-index the trailing slots
528
+ for (let i = idx; i < this.slots.length; i++) {
529
+ const s = this.slots[i];
530
+ s.index = i;
531
+ if (s.identity) this.identityToSlot.set(s.identity, i);
532
+ if (s.serial) this.serialToSlot.set(s.serial, i);
533
+ }
534
+
535
+ this.updateState();
536
+ }
537
+
328
538
  // --- Node.js discovery ---
329
539
 
330
540
  private startNodeDiscovery(intervalMs: number): void {
@@ -345,33 +555,27 @@ export class DualsenseManager extends Input<DualsenseManagerState> {
345
555
  this.discoveryTimer = setInterval(() => void poll(), intervalMs);
346
556
  }
347
557
 
348
- /** Handle a newly discovered device from enumeration */
558
+ /**
559
+ * Handle a newly discovered device from enumeration. Opens the device on
560
+ * a fresh provider, which adds it to `claimedDevices` so subsequent polls
561
+ * skip it. Identity matching (and any merge into a disconnected slot)
562
+ * happens later, once firmware info has been read.
563
+ */
349
564
  private processDiscoveredDevice(device: DualsenseDeviceInfo): void {
350
565
  if (HIDProvider.claimedDevices.has(device.path)) return;
351
566
 
352
- // Check if this serial matches a disconnected slot
353
- if (device.serialNumber !== undefined) {
354
- const existingSlotIndex = this.serialToSlot.get(device.serialNumber);
355
- if (existingSlotIndex !== undefined) {
356
- const slot = this.slots[existingSlotIndex];
357
- if (!slot.controller.connection.active) {
358
- // Update the existing provider to reconnect via this path
359
- const provider = slot.controller.hid.provider as NodeHIDProvider;
360
- provider.targetPath = device.path;
361
- provider.targetSerial = device.serialNumber;
362
- slot.path = device.path;
363
- // The Dualsense's internal 200ms loop will call provider.connect()
364
- return;
365
- }
366
- }
367
- }
368
-
369
- // New device: create a provider and slot
370
567
  const provider = new NodeHIDProvider({
371
568
  devicePath: device.path,
372
569
  serialNumber: device.serialNumber,
373
570
  });
374
571
  this.createSlot(provider, device.serialNumber, device.path);
572
+ // Drive the connection. The Dualsense instance no longer polls in
573
+ // managed mode, so the manager owns this. claimedDevices is added
574
+ // synchronously inside connect() on success, preventing duplicate
575
+ // discovery on the next poll tick.
576
+ void Promise.resolve(provider.connect()).catch(() => {
577
+ /* errors surface via provider.onError */
578
+ });
375
579
  }
376
580
 
377
581
  // --- WebHID discovery ---
@@ -383,21 +587,31 @@ export class DualsenseManager extends Input<DualsenseManagerState> {
383
587
  this.addWebDevice(device);
384
588
  });
385
589
 
386
- // Enumerate already-permitted devices
387
- void WebHIDProvider.enumerate().then((devices) => {
388
- for (const device of devices) {
389
- this.addWebDevice(device);
390
- }
391
- });
590
+ // Poll for permitted devices. The WebHID connect event only fires
591
+ // for newly-permitted devices, not for already-permitted devices
592
+ // that physically reconnect. Periodic enumeration catches those.
593
+ const poll = () => {
594
+ void WebHIDProvider.enumerate().then((devices) => {
595
+ for (const device of devices) {
596
+ this.addWebDevice(device);
597
+ }
598
+ });
599
+ };
600
+ poll();
601
+ this.discoveryTimer = setInterval(poll, 2000);
392
602
  }
393
603
  }
394
604
 
605
+ /** HIDDevice objects we've already handed to a provider */
606
+ private readonly knownWebDevices = new WeakSet<HIDDevice>();
607
+
395
608
  private addWebDevice(device: HIDDevice): void {
396
- // Check if any existing slot's provider already has this device
397
- for (const slot of this.slots) {
398
- const provider = slot.controller.hid.provider as WebHIDProvider;
399
- if (provider.device === device) return; // Already managed
400
- }
609
+ // WeakSet tracks object identity enumerate() returns the same objects
610
+ // for still-connected devices, so this deduplicates across polls.
611
+ // On reconnect, the browser provides a fresh HIDDevice object, so it
612
+ // passes this check and creates a new provisional slot.
613
+ if (this.knownWebDevices.has(device)) return;
614
+ this.knownWebDevices.add(device);
401
615
 
402
616
  const provider = new WebHIDProvider({ device });
403
617
  this.createSlot(provider, undefined, undefined);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "files": {
3
3
  "main.css": "/dualsense-ts/static/css/main.9393bbb9.css",
4
- "main.js": "/dualsense-ts/static/js/main.2ac31d24.js",
4
+ "main.js": "/dualsense-ts/static/js/main.1c1a2c23.js",
5
5
  "blueprint-icons-all-paths-loader.js": "/dualsense-ts/static/js/blueprint-icons-all-paths-loader.53883a20.chunk.js",
6
6
  "blueprint-icons-split-paths-by-size-loader.js": "/dualsense-ts/static/js/blueprint-icons-split-paths-by-size-loader.6d8a31ce.chunk.js",
7
7
  "static/js/787.b12b0301.chunk.js": "/dualsense-ts/static/js/787.b12b0301.chunk.js",
@@ -18,7 +18,7 @@
18
18
  "static/media/blueprint-icons-16.woff2?": "/dualsense-ts/static/media/blueprint-icons-16.582f87288ff1d1f0dfae.woff2",
19
19
  "index.html": "/dualsense-ts/index.html",
20
20
  "main.9393bbb9.css.map": "/dualsense-ts/static/css/main.9393bbb9.css.map",
21
- "main.2ac31d24.js.map": "/dualsense-ts/static/js/main.2ac31d24.js.map",
21
+ "main.1c1a2c23.js.map": "/dualsense-ts/static/js/main.1c1a2c23.js.map",
22
22
  "blueprint-icons-all-paths-loader.53883a20.chunk.js.map": "/dualsense-ts/static/js/blueprint-icons-all-paths-loader.53883a20.chunk.js.map",
23
23
  "blueprint-icons-split-paths-by-size-loader.6d8a31ce.chunk.js.map": "/dualsense-ts/static/js/blueprint-icons-split-paths-by-size-loader.6d8a31ce.chunk.js.map",
24
24
  "787.b12b0301.chunk.js.map": "/dualsense-ts/static/js/787.b12b0301.chunk.js.map",
@@ -28,6 +28,6 @@
28
28
  },
29
29
  "entrypoints": [
30
30
  "static/css/main.9393bbb9.css",
31
- "static/js/main.2ac31d24.js"
31
+ "static/js/main.1c1a2c23.js"
32
32
  ]
33
33
  }
@@ -1 +1 @@
1
- <!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/dualsense-ts/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#1a1a2e"/><title>dualsense-ts — PS5 DualSense Controller for TypeScript</title><meta name="description" content="Interactive WebHID demo for dualsense-ts. Connect a PS5 DualSense controller to your browser and control rumble, trigger effects, lightbar, player LEDs, and more."/><meta name="keywords" content="dualsense, ps5, controller, webhid, typescript, gamepad, haptics, trigger effects, lightbar, rumble"/><meta name="author" content="nsfm"/><meta name="robots" content="index, follow"/><meta property="og:type" content="website"/><meta property="og:title" content="dualsense-ts — PS5 DualSense Controller for TypeScript"/><meta property="og:description" content="Connect a PS5 DualSense controller to your browser. Control rumble, adaptive triggers, lightbar, player LEDs, and read every input in real time."/><meta property="og:url" content="https://nsfm.github.io/dualsense-ts/"/><meta property="og:site_name" content="dualsense-ts"/><meta name="twitter:card" content="summary"/><meta name="twitter:title" content="dualsense-ts — PS5 DualSense Controller for TypeScript"/><meta name="twitter:description" content="Interactive WebHID demo. Connect a DualSense controller to your browser and control every feature in real time."/><link rel="canonical" href="https://nsfm.github.io/dualsense-ts/"/><link rel="manifest" href="/dualsense-ts/manifest.json"/><script type="text/javascript">!function(n){if("/"===n.search[1]){var a=n.search.slice(1).split("&").map((function(n){return n.replace(/~and~/g,"&")})).join("?");window.history.replaceState(null,null,n.pathname.slice(0,-1)+a+n.hash)}}(window.location)</script><script defer="defer" src="/dualsense-ts/static/js/main.2ac31d24.js"></script><link href="/dualsense-ts/static/css/main.9393bbb9.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
1
+ <!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/dualsense-ts/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#1a1a2e"/><title>dualsense-ts — PS5 DualSense Controller for TypeScript</title><meta name="description" content="Interactive WebHID demo for dualsense-ts. Connect a PS5 DualSense controller to your browser and control rumble, trigger effects, lightbar, player LEDs, and more."/><meta name="keywords" content="dualsense, ps5, controller, webhid, typescript, gamepad, haptics, trigger effects, lightbar, rumble"/><meta name="author" content="nsfm"/><meta name="robots" content="index, follow"/><meta property="og:type" content="website"/><meta property="og:title" content="dualsense-ts — PS5 DualSense Controller for TypeScript"/><meta property="og:description" content="Connect a PS5 DualSense controller to your browser. Control rumble, adaptive triggers, lightbar, player LEDs, and read every input in real time."/><meta property="og:url" content="https://nsfm.github.io/dualsense-ts/"/><meta property="og:site_name" content="dualsense-ts"/><meta name="twitter:card" content="summary"/><meta name="twitter:title" content="dualsense-ts — PS5 DualSense Controller for TypeScript"/><meta name="twitter:description" content="Interactive WebHID demo. Connect a DualSense controller to your browser and control every feature in real time."/><link rel="canonical" href="https://nsfm.github.io/dualsense-ts/"/><link rel="manifest" href="/dualsense-ts/manifest.json"/><script type="text/javascript">!function(n){if("/"===n.search[1]){var a=n.search.slice(1).split("&").map((function(n){return n.replace(/~and~/g,"&")})).join("?");window.history.replaceState(null,null,n.pathname.slice(0,-1)+a+n.hash)}}(window.location)</script><script defer="defer" src="/dualsense-ts/static/js/main.1c1a2c23.js"></script><link href="/dualsense-ts/static/css/main.9393bbb9.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>