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.
- package/LINUX_HID.md +85 -0
- package/README.md +83 -12
- package/dist/dualsense.d.ts +28 -8
- package/dist/dualsense.d.ts.map +1 -1
- package/dist/dualsense.js +57 -17
- package/dist/dualsense.js.map +1 -1
- package/dist/hid/bt_checksum.d.ts +7 -0
- package/dist/hid/bt_checksum.d.ts.map +1 -1
- package/dist/hid/bt_checksum.js +33 -1
- package/dist/hid/bt_checksum.js.map +1 -1
- package/dist/hid/dualsense_hid.d.ts +77 -0
- package/dist/hid/dualsense_hid.d.ts.map +1 -1
- package/dist/hid/dualsense_hid.js +193 -0
- package/dist/hid/dualsense_hid.js.map +1 -1
- package/dist/hid/factory_info.d.ts +53 -0
- package/dist/hid/factory_info.d.ts.map +1 -0
- package/dist/hid/factory_info.js +166 -0
- package/dist/hid/factory_info.js.map +1 -0
- package/dist/hid/firmware_info.d.ts +46 -0
- package/dist/hid/firmware_info.d.ts.map +1 -0
- package/dist/hid/firmware_info.js +109 -0
- package/dist/hid/firmware_info.js.map +1 -0
- package/dist/hid/hid_provider.d.ts +12 -0
- package/dist/hid/hid_provider.d.ts.map +1 -1
- package/dist/hid/hid_provider.js +13 -0
- package/dist/hid/hid_provider.js.map +1 -1
- package/dist/hid/index.d.ts +3 -0
- package/dist/hid/index.d.ts.map +1 -1
- package/dist/hid/index.js +3 -0
- package/dist/hid/index.js.map +1 -1
- package/dist/hid/node_hid_provider.d.ts +2 -0
- package/dist/hid/node_hid_provider.d.ts.map +1 -1
- package/dist/hid/node_hid_provider.js +14 -0
- package/dist/hid/node_hid_provider.js.map +1 -1
- package/dist/hid/pairing_info.d.ts +9 -0
- package/dist/hid/pairing_info.d.ts.map +1 -0
- package/dist/hid/pairing_info.js +33 -0
- package/dist/hid/pairing_info.js.map +1 -0
- package/dist/hid/web_hid_provider.d.ts +14 -0
- package/dist/hid/web_hid_provider.d.ts.map +1 -1
- package/dist/hid/web_hid_provider.js +79 -8
- package/dist/hid/web_hid_provider.js.map +1 -1
- package/dist/id.d.ts +4 -0
- package/dist/id.d.ts.map +1 -1
- package/dist/manager.d.ts +57 -4
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +248 -66
- package/dist/manager.js.map +1 -1
- package/nodehid_example/debug.ts +43 -13
- package/nodehid_example/single.ts +29 -0
- package/package.json +1 -1
- package/src/dualsense.ts +73 -23
- package/src/hid/bt_checksum.ts +39 -0
- package/src/hid/dualsense_hid.ts +230 -0
- package/src/hid/factory_info.ts +206 -0
- package/src/hid/firmware_info.ts +157 -0
- package/src/hid/hid_provider.ts +22 -0
- package/src/hid/index.ts +3 -0
- package/src/hid/node_hid_provider.ts +14 -0
- package/src/hid/pairing_info.ts +33 -0
- package/src/hid/web_hid_provider.ts +87 -8
- package/src/id.ts +5 -0
- package/src/manager.ts +285 -71
- package/webhid_example/build/asset-manifest.json +3 -3
- package/webhid_example/build/index.html +1 -1
- package/webhid_example/build/static/js/main.1c1a2c23.js +3 -0
- package/webhid_example/build/static/js/main.1c1a2c23.js.map +1 -0
- package/webhid_example/src/App.tsx +7 -1
- package/webhid_example/src/hud/AudioIndicator.tsx +116 -0
- package/webhid_example/src/hud/BatteryIndicator.tsx +4 -2
- package/webhid_example/src/hud/ColorIndicator.tsx +72 -0
- package/webhid_example/src/hud/ControllerConnection.tsx +29 -2
- package/webhid_example/src/hud/Debugger.tsx +31 -1
- package/webhid_example/src/hud/LightbarFadeButtons.tsx +2 -2
- package/webhid_example/src/hud/MuteLedControls.tsx +3 -2
- package/webhid_example/src/hud/index.tsx +2 -0
- package/webhid_example/build/static/js/main.2ac31d24.js +0 -3
- package/webhid_example/build/static/js/main.2ac31d24.js.map +0 -1
- /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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
/**
|
|
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 = {
|
|
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 —
|
|
307
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
//
|
|
397
|
-
for
|
|
398
|
-
|
|
399
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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>
|