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/dualsense.ts
CHANGED
|
@@ -23,6 +23,10 @@ import {
|
|
|
23
23
|
InputId,
|
|
24
24
|
ChargeStatus,
|
|
25
25
|
PulseOptions,
|
|
26
|
+
FirmwareInfo,
|
|
27
|
+
FactoryInfo,
|
|
28
|
+
DualsenseColor,
|
|
29
|
+
DualsenseColorMap,
|
|
26
30
|
} from "./hid";
|
|
27
31
|
import { Intensity } from "./math";
|
|
28
32
|
|
|
@@ -96,23 +100,48 @@ export class Dualsense extends Input<Dualsense> {
|
|
|
96
100
|
public readonly accelerometer: Accelerometer;
|
|
97
101
|
/** Battery level and charging status */
|
|
98
102
|
public readonly battery: Battery;
|
|
103
|
+
/** Whether a microphone is connected (e.g. headset mic or USB mic) */
|
|
104
|
+
public readonly microphone: Momentary;
|
|
105
|
+
/** Whether headphones are connected to the controller's 3.5mm jack */
|
|
106
|
+
public readonly headphone: Momentary;
|
|
99
107
|
/** The RGB light bar at the top of the controller */
|
|
100
108
|
public readonly lightbar = new Lightbar();
|
|
101
109
|
/** The 5 white player indicator LEDs */
|
|
102
110
|
public readonly playerLeds = new PlayerLeds();
|
|
103
111
|
|
|
104
|
-
/**
|
|
112
|
+
/**
|
|
113
|
+
* Buffered battery reading, sampled on a slow cadence
|
|
114
|
+
* Battery readings are prone to flip-flopping, so we buffer them
|
|
115
|
+
*/
|
|
105
116
|
private readonly pendingBattery = {
|
|
106
117
|
peakLevel: 0,
|
|
107
118
|
status: ChargeStatus.Discharging as ChargeStatus,
|
|
108
119
|
};
|
|
109
120
|
|
|
110
|
-
/** Represents the underlying HID device. Provides input events */
|
|
121
|
+
/** Represents the underlying HID device. Provides input events. */
|
|
111
122
|
public readonly hid: DualsenseHID;
|
|
112
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Firmware and hardware information.
|
|
126
|
+
* Contains sensible defaults until the device reports its actual values.
|
|
127
|
+
*/
|
|
128
|
+
public get firmwareInfo(): FirmwareInfo {
|
|
129
|
+
return this.hid.firmwareInfo;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Factory information (serial number, body color, board revision).
|
|
134
|
+
* Contains sensible defaults until the device reports its actual values.
|
|
135
|
+
* On Linux over Bluetooth the defaults persist (kernel limitation).
|
|
136
|
+
*/
|
|
137
|
+
public get factoryInfo(): FactoryInfo {
|
|
138
|
+
return this.hid.factoryInfo;
|
|
139
|
+
}
|
|
140
|
+
|
|
113
141
|
/** A virtual button representing whether or not a controller is connected */
|
|
114
142
|
public readonly connection: Momentary;
|
|
115
143
|
|
|
144
|
+
/** True if any input at all is active or changing */
|
|
116
145
|
public get active(): boolean {
|
|
117
146
|
return Object.values(this).some(
|
|
118
147
|
(input) => input !== this && input instanceof Input && input.active,
|
|
@@ -124,14 +153,16 @@ export class Dualsense extends Input<Dualsense> {
|
|
|
124
153
|
return this.hid.wireless;
|
|
125
154
|
}
|
|
126
155
|
|
|
127
|
-
/**
|
|
128
|
-
public get
|
|
129
|
-
|
|
156
|
+
/** Body color of the controller */
|
|
157
|
+
public get color(): DualsenseColor {
|
|
158
|
+
const { colorCode } = this.hid.factoryInfo;
|
|
159
|
+
if (colorCode in DualsenseColorMap) return DualsenseColorMap[colorCode];
|
|
160
|
+
return DualsenseColor.Unknown;
|
|
130
161
|
}
|
|
131
162
|
|
|
132
|
-
/**
|
|
133
|
-
public get serialNumber(): string
|
|
134
|
-
return this.hid.
|
|
163
|
+
/** Factory-stamped serial number of the controller */
|
|
164
|
+
public get serialNumber(): string {
|
|
165
|
+
return this.hid.factoryInfo.serialNumber;
|
|
135
166
|
}
|
|
136
167
|
|
|
137
168
|
constructor(params: DualsenseParams = {}) {
|
|
@@ -147,6 +178,8 @@ export class Dualsense extends Input<Dualsense> {
|
|
|
147
178
|
name: "Mute",
|
|
148
179
|
...(params.mute ?? {}),
|
|
149
180
|
});
|
|
181
|
+
this.microphone = new Momentary({ icon: "🎤", name: "Microphone" });
|
|
182
|
+
this.headphone = new Momentary({ icon: "🎧", name: "Headphone" });
|
|
150
183
|
this.options = new Momentary({
|
|
151
184
|
icon: "⋯",
|
|
152
185
|
name: "Options",
|
|
@@ -221,6 +254,10 @@ export class Dualsense extends Input<Dualsense> {
|
|
|
221
254
|
});
|
|
222
255
|
|
|
223
256
|
this.connection[InputSet](false);
|
|
257
|
+
// If a HID instance was supplied externally (e.g. by DualsenseManager),
|
|
258
|
+
// the owner is responsible for driving discovery + reconnection. Otherwise
|
|
259
|
+
// construct a default platform provider and run our own discovery loop.
|
|
260
|
+
const externallyManaged = params.hid != null;
|
|
224
261
|
this.hid = params.hid ?? new DualsenseHID(new PlatformHIDProvider());
|
|
225
262
|
this.hid.register((state: DualsenseHIDState) => {
|
|
226
263
|
this.processHID(state);
|
|
@@ -231,24 +268,32 @@ export class Dualsense extends Input<Dualsense> {
|
|
|
231
268
|
const lightbarMemo = { key: "" };
|
|
232
269
|
const playerLedsMemo = { key: "" };
|
|
233
270
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
provider: { connected },
|
|
239
|
-
} = this.hid;
|
|
240
|
-
|
|
271
|
+
// Mirror transport-level connect/disconnect into the connection Momentary,
|
|
272
|
+
// and invalidate output memos on rising-edge connect so the output loop
|
|
273
|
+
// re-pushes desired state to the new device.
|
|
274
|
+
this.hid.onConnectionChange((connected) => {
|
|
241
275
|
this.connection[InputSet](connected);
|
|
242
|
-
if (connected
|
|
243
|
-
// Invalidate memos so the output loop restores desired state on reconnect.
|
|
276
|
+
if (connected) {
|
|
244
277
|
triggerFeedbackMemo.left = "";
|
|
245
278
|
triggerFeedbackMemo.right = "";
|
|
246
279
|
lightbarMemo.key = "";
|
|
247
280
|
playerLedsMemo.key = "";
|
|
248
281
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
282
|
+
});
|
|
283
|
+
// Seed the initial state in case the provider was already attached.
|
|
284
|
+
this.connection[InputSet](this.hid.provider.connected);
|
|
285
|
+
|
|
286
|
+
// Standalone mode: poll for devices and reconnect on drop. In managed
|
|
287
|
+
// mode the manager owns this and we must NOT race with it.
|
|
288
|
+
if (!externallyManaged) {
|
|
289
|
+
setInterval(() => {
|
|
290
|
+
if (!this.hid.provider.connected) {
|
|
291
|
+
void Promise.resolve(this.hid.provider.connect()).catch(() => {
|
|
292
|
+
/* surfaced via onError */
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}, 200);
|
|
296
|
+
}
|
|
252
297
|
|
|
253
298
|
/** Refresh battery state on a slow cadence to filter transient glitches */
|
|
254
299
|
setInterval(() => {
|
|
@@ -308,13 +353,16 @@ export class Dualsense extends Input<Dualsense> {
|
|
|
308
353
|
|
|
309
354
|
const playerLedsKey = this.playerLeds.toKey();
|
|
310
355
|
if (playerLedsKey !== playerLedsMemo.key) {
|
|
311
|
-
this.hid.setPlayerLeds(
|
|
356
|
+
this.hid.setPlayerLeds(
|
|
357
|
+
this.playerLeds.bitmask,
|
|
358
|
+
this.playerLeds.brightness,
|
|
359
|
+
);
|
|
312
360
|
playerLedsMemo.key = playerLedsKey;
|
|
313
361
|
}
|
|
314
|
-
|
|
315
362
|
}, 1000 / 30);
|
|
316
363
|
}
|
|
317
364
|
|
|
365
|
+
/** Average rumble strength across both halves of the controller. */
|
|
318
366
|
private get rumbleIntensity(): number {
|
|
319
367
|
return (this.left.rumble() + this.right.rumble()) / 2;
|
|
320
368
|
}
|
|
@@ -332,7 +380,7 @@ export class Dualsense extends Input<Dualsense> {
|
|
|
332
380
|
return this.rumbleIntensity;
|
|
333
381
|
}
|
|
334
382
|
|
|
335
|
-
/** Distributes HID event values to the controller's inputs */
|
|
383
|
+
/** Distributes HID event values to the controller's public inputs. */
|
|
336
384
|
private processHID(state: DualsenseHIDState): void {
|
|
337
385
|
this.ps[InputSet](state[InputId.Playstation]);
|
|
338
386
|
this.options[InputSet](state[InputId.Options]);
|
|
@@ -340,6 +388,8 @@ export class Dualsense extends Input<Dualsense> {
|
|
|
340
388
|
|
|
341
389
|
this.mute[InputSet](state[InputId.Mute]);
|
|
342
390
|
this.mute.status[InputSet](state[InputId.MuteLed]);
|
|
391
|
+
this.microphone[InputSet](state[InputId.Microphone]);
|
|
392
|
+
this.headphone[InputSet](state[InputId.Headphone]);
|
|
343
393
|
|
|
344
394
|
this.triangle[InputSet](state[InputId.Triangle]);
|
|
345
395
|
this.circle[InputSet](state[InputId.Circle]);
|
package/src/hid/bt_checksum.ts
CHANGED
|
@@ -56,3 +56,42 @@ export function computeBluetoothReportChecksum(buffer: Uint8Array): number {
|
|
|
56
56
|
return result >>> 0;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/** Standard CRC-32 lookup table (polynomial 0xEDB88320) */
|
|
60
|
+
const crc32Table: number[] = (() => {
|
|
61
|
+
const table: number[] = [];
|
|
62
|
+
for (let n = 0; n < 256; n++) {
|
|
63
|
+
let c = n;
|
|
64
|
+
for (let k = 0; k < 8; k++) {
|
|
65
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
66
|
+
}
|
|
67
|
+
table[n] = c >>> 0;
|
|
68
|
+
}
|
|
69
|
+
return table;
|
|
70
|
+
})();
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Compute CRC-32 for a Bluetooth feature report.
|
|
74
|
+
* The CRC covers the HID transaction header (0x53), the report ID,
|
|
75
|
+
* and all payload bytes except the last 4 (which hold the CRC itself).
|
|
76
|
+
* Uses standard CRC-32 with final inversion.
|
|
77
|
+
*/
|
|
78
|
+
export function computeFeatureReportChecksum(
|
|
79
|
+
reportId: number,
|
|
80
|
+
buffer: Uint8Array,
|
|
81
|
+
): number {
|
|
82
|
+
let crc = 0xffffffff >>> 0;
|
|
83
|
+
|
|
84
|
+
// Feed prefix bytes: 0x53 (SET_REPORT feature) + report ID
|
|
85
|
+
crc = (crc >>> 8) ^ crc32Table[(crc ^ 0x53) & 0xff];
|
|
86
|
+
crc = (crc >>> 8) ^ crc32Table[(crc ^ reportId) & 0xff];
|
|
87
|
+
|
|
88
|
+
// Feed payload data (exclude last 4 bytes reserved for CRC)
|
|
89
|
+
const dataLen = buffer.length - 4;
|
|
90
|
+
for (let i = 0; i < dataLen; i++) {
|
|
91
|
+
crc = (crc >>> 8) ^ crc32Table[(crc ^ buffer[i]) & 0xff];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Final XOR (standard CRC-32 finalization)
|
|
95
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
96
|
+
}
|
|
97
|
+
|
package/src/hid/dualsense_hid.ts
CHANGED
|
@@ -5,9 +5,14 @@ import {
|
|
|
5
5
|
DefaultDualsenseHIDState,
|
|
6
6
|
} from "./hid_provider";
|
|
7
7
|
import { computeBluetoothReportChecksum } from "./bt_checksum";
|
|
8
|
+
import { FirmwareInfo, DefaultFirmwareInfo, readFirmwareInfo } from "./firmware_info";
|
|
9
|
+
import { FactoryInfo, DefaultFactoryInfo, readFactoryInfo } from "./factory_info";
|
|
10
|
+
import { readMacAddress } from "./pairing_info";
|
|
8
11
|
|
|
9
12
|
export type HIDCallback = (state: DualsenseHIDState) => void;
|
|
10
13
|
export type ErrorCallback = (error: Error) => void;
|
|
14
|
+
export type ReadyCallback = () => void;
|
|
15
|
+
export type ConnectionCallback = (connected: boolean) => void;
|
|
11
16
|
|
|
12
17
|
const SCOPE_A = 1;
|
|
13
18
|
const SCOPE_B = 2;
|
|
@@ -28,10 +33,26 @@ export class DualsenseHID {
|
|
|
28
33
|
private readonly subscribers = new Set<HIDCallback>();
|
|
29
34
|
/** Subscribers waiting for error updates */
|
|
30
35
|
private readonly errorSubscribers = new Set<ErrorCallback>();
|
|
36
|
+
/** Subscribers waiting for firmware/factory info to become available */
|
|
37
|
+
private readonly readySubscribers = new Set<ReadyCallback>();
|
|
38
|
+
/** Subscribers tracking transport-level connect/disconnect events */
|
|
39
|
+
private readonly connectionSubscribers = new Set<ConnectionCallback>();
|
|
40
|
+
/** True once firmware/factory info has been loaded for the current connection */
|
|
41
|
+
private identityResolved = false;
|
|
42
|
+
/** Pending retry timer for identity loading after a transient failure */
|
|
43
|
+
private identityRetryTimer?: ReturnType<typeof setTimeout>;
|
|
44
|
+
/** Number of identity-load attempts made for the current connection */
|
|
45
|
+
private identityRetryCount = 0;
|
|
31
46
|
/** Queue of pending HID commands */
|
|
32
47
|
private pendingCommands: CommandEvent[] = [];
|
|
33
48
|
/** Most recent HID state of the device */
|
|
34
49
|
public state: DualsenseHIDState = { ...DefaultDualsenseHIDState };
|
|
50
|
+
/** Firmware and hardware information, populated after connection */
|
|
51
|
+
public firmwareInfo: FirmwareInfo = DefaultFirmwareInfo;
|
|
52
|
+
/** Factory information (serial, color, board revision), populated after connection */
|
|
53
|
+
public factoryInfo: FactoryInfo = DefaultFactoryInfo;
|
|
54
|
+
/** Bluetooth MAC address from Feature Report 0x09, populated after connection */
|
|
55
|
+
public macAddress?: string;
|
|
35
56
|
|
|
36
57
|
constructor(
|
|
37
58
|
readonly provider: HIDProvider,
|
|
@@ -39,6 +60,24 @@ export class DualsenseHID {
|
|
|
39
60
|
) {
|
|
40
61
|
provider.onData = this.set.bind(this);
|
|
41
62
|
provider.onError = this.handleError.bind(this);
|
|
63
|
+
provider.onConnect = () => {
|
|
64
|
+
// Keep cached firmware/factory info from the prior session so that
|
|
65
|
+
// consumers see identity details immediately on a reconnection
|
|
66
|
+
// event. The background loadIdentity() call will verify and refresh
|
|
67
|
+
// the cache — if the hardware identity turns out different (e.g. a
|
|
68
|
+
// different controller grabbed the same slot), the fields get
|
|
69
|
+
// overwritten then.
|
|
70
|
+
this.firmwareFetch = undefined;
|
|
71
|
+
this.factoryFetch = undefined;
|
|
72
|
+
this.identityResolved = false;
|
|
73
|
+
this.cancelIdentityRetry();
|
|
74
|
+
this.connectionSubscribers.forEach((cb) => cb(true));
|
|
75
|
+
void this.loadIdentity();
|
|
76
|
+
};
|
|
77
|
+
provider.onDisconnect = () => {
|
|
78
|
+
this.cancelIdentityRetry();
|
|
79
|
+
this.connectionSubscribers.forEach((cb) => cb(false));
|
|
80
|
+
};
|
|
42
81
|
|
|
43
82
|
setInterval(() => {
|
|
44
83
|
if (this.pendingCommands.length > 0) {
|
|
@@ -79,6 +118,56 @@ export class DualsenseHID {
|
|
|
79
118
|
if (type === "error") this.errorSubscribers.add(callback);
|
|
80
119
|
}
|
|
81
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Subscribe to notification when firmware/factory info finishes loading
|
|
123
|
+
* after a connect. Fires once per connection — either when identity has
|
|
124
|
+
* been resolved, or when we've given up retrying. If identity is already
|
|
125
|
+
* resolved at the time of subscription, the callback fires synchronously.
|
|
126
|
+
*/
|
|
127
|
+
public onReady(callback: ReadyCallback): () => void {
|
|
128
|
+
if (this.identityResolved) {
|
|
129
|
+
callback();
|
|
130
|
+
return () => {};
|
|
131
|
+
}
|
|
132
|
+
this.readySubscribers.add(callback);
|
|
133
|
+
return () => this.readySubscribers.delete(callback);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** True if firmware/factory info has been loaded (or given up on) for the current connection */
|
|
137
|
+
public get ready(): boolean {
|
|
138
|
+
return this.identityResolved;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Subscribe to transport-level connect/disconnect events. Useful for
|
|
143
|
+
* mirroring connection state into an Input without polling. Returns
|
|
144
|
+
* an unsubscribe function.
|
|
145
|
+
*/
|
|
146
|
+
public onConnectionChange(callback: ConnectionCallback): () => void {
|
|
147
|
+
this.connectionSubscribers.add(callback);
|
|
148
|
+
return () => this.connectionSubscribers.delete(callback);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Stable hardware identity for this controller, derived from the most
|
|
153
|
+
* trustworthy info available. Prefers the Bluetooth MAC address (from
|
|
154
|
+
* Feature Report 0x09, works on every transport and platform), then
|
|
155
|
+
* falls back to the factory serial, then firmware deviceInfo.
|
|
156
|
+
* Returns undefined until identity info has been read.
|
|
157
|
+
*/
|
|
158
|
+
public get identity(): string | undefined {
|
|
159
|
+
if (this.macAddress) {
|
|
160
|
+
return `mac:${this.macAddress}`;
|
|
161
|
+
}
|
|
162
|
+
if (this.factoryInfo.serialNumber !== DefaultFactoryInfo.serialNumber) {
|
|
163
|
+
return `serial:${this.factoryInfo.serialNumber}`;
|
|
164
|
+
}
|
|
165
|
+
if (this.firmwareInfo.deviceInfo !== DefaultFirmwareInfo.deviceInfo) {
|
|
166
|
+
return `device:${this.firmwareInfo.deviceInfo}`;
|
|
167
|
+
}
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
82
171
|
/** Update the HID state and pass it along to all state subscribers */
|
|
83
172
|
private set(state: DualsenseHIDState): void {
|
|
84
173
|
this.state = state;
|
|
@@ -90,6 +179,147 @@ export class DualsenseHID {
|
|
|
90
179
|
this.errorSubscribers.forEach((callback) => callback(error));
|
|
91
180
|
}
|
|
92
181
|
|
|
182
|
+
/** Maximum identity-load retry attempts per connection */
|
|
183
|
+
private static readonly IDENTITY_MAX_ATTEMPTS = 5;
|
|
184
|
+
/** Backoff schedule (ms) for identity-load retries */
|
|
185
|
+
private static readonly IDENTITY_BACKOFF_MS = [500, 1500, 3000, 5000];
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Attempt to read firmware + factory info for the current connection,
|
|
189
|
+
* with retry on failure. Marks identity as resolved on success or after
|
|
190
|
+
* exhausting all attempts (so consumers don't wait forever).
|
|
191
|
+
*
|
|
192
|
+
* If cached firmware/factory info exists from a prior session, identity
|
|
193
|
+
* is resolved immediately (so the connection event has full details),
|
|
194
|
+
* then a background verification re-reads the device to confirm.
|
|
195
|
+
*/
|
|
196
|
+
private async loadIdentity(): Promise<void> {
|
|
197
|
+
if (!this.provider.connected) return;
|
|
198
|
+
|
|
199
|
+
// Fast path: if we already have cached identity from a prior session,
|
|
200
|
+
// mark resolved immediately so consumers see it on the connection event.
|
|
201
|
+
// Then continue to the verification read below.
|
|
202
|
+
const hadCachedIdentity = this.identity !== undefined;
|
|
203
|
+
if (hadCachedIdentity) {
|
|
204
|
+
this.markIdentityResolved();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
this.identityRetryCount += 1;
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
// Read MAC address first — simple feature report, no firmware gate.
|
|
211
|
+
const mac = await readMacAddress(this.provider);
|
|
212
|
+
if (mac) this.macAddress = mac;
|
|
213
|
+
|
|
214
|
+
// Always read fresh from the device (bypass the idempotency cache).
|
|
215
|
+
const fw = await readFirmwareInfo(this.provider);
|
|
216
|
+
if (fw) {
|
|
217
|
+
this.firmwareInfo = fw;
|
|
218
|
+
this.firmwareFetch = Promise.resolve(fw);
|
|
219
|
+
|
|
220
|
+
const fi = await readFactoryInfo(
|
|
221
|
+
this.provider,
|
|
222
|
+
fw.hardwareInfo,
|
|
223
|
+
fw.mainFirmwareVersionRaw,
|
|
224
|
+
);
|
|
225
|
+
this.factoryInfo = fi ?? DefaultFactoryInfo;
|
|
226
|
+
this.factoryFetch = Promise.resolve(this.factoryInfo);
|
|
227
|
+
|
|
228
|
+
if (!hadCachedIdentity) {
|
|
229
|
+
this.markIdentityResolved();
|
|
230
|
+
}
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
} catch {
|
|
234
|
+
// Treat throws the same as undefined — fall through to retry logic.
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Failure — clear in-flight promises so the next attempt can retry.
|
|
238
|
+
this.firmwareFetch = undefined;
|
|
239
|
+
this.factoryFetch = undefined;
|
|
240
|
+
|
|
241
|
+
if (
|
|
242
|
+
this.identityRetryCount >= DualsenseHID.IDENTITY_MAX_ATTEMPTS ||
|
|
243
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
244
|
+
!this.provider.connected
|
|
245
|
+
) {
|
|
246
|
+
this.markIdentityResolved();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const delay =
|
|
251
|
+
DualsenseHID.IDENTITY_BACKOFF_MS[
|
|
252
|
+
Math.min(
|
|
253
|
+
this.identityRetryCount - 1,
|
|
254
|
+
DualsenseHID.IDENTITY_BACKOFF_MS.length - 1,
|
|
255
|
+
)
|
|
256
|
+
];
|
|
257
|
+
this.identityRetryTimer = setTimeout(() => {
|
|
258
|
+
this.identityRetryTimer = undefined;
|
|
259
|
+
void this.loadIdentity();
|
|
260
|
+
}, delay);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Mark identity loading as complete and notify subscribers */
|
|
264
|
+
private markIdentityResolved(): void {
|
|
265
|
+
if (this.identityResolved) return;
|
|
266
|
+
this.identityResolved = true;
|
|
267
|
+
this.identityRetryCount = 0;
|
|
268
|
+
const callbacks = Array.from(this.readySubscribers);
|
|
269
|
+
this.readySubscribers.clear();
|
|
270
|
+
callbacks.forEach((cb) => cb());
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Cancel any pending identity-load retry */
|
|
274
|
+
private cancelIdentityRetry(): void {
|
|
275
|
+
if (this.identityRetryTimer) {
|
|
276
|
+
clearTimeout(this.identityRetryTimer);
|
|
277
|
+
this.identityRetryTimer = undefined;
|
|
278
|
+
}
|
|
279
|
+
this.identityRetryCount = 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** In-flight firmware info fetch, deduped across callers within a connection */
|
|
283
|
+
private firmwareFetch?: Promise<FirmwareInfo>;
|
|
284
|
+
/** In-flight factory info fetch, deduped across callers within a connection */
|
|
285
|
+
private factoryFetch?: Promise<FactoryInfo>;
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Read firmware info from the controller (Feature Report 0x20).
|
|
289
|
+
* Idempotent: returns the cached value if already fetched, or the
|
|
290
|
+
* in-flight promise if a fetch is already underway.
|
|
291
|
+
*/
|
|
292
|
+
public fetchFirmwareInfo(): Promise<FirmwareInfo> {
|
|
293
|
+
if (this.firmwareInfo !== DefaultFirmwareInfo) return Promise.resolve(this.firmwareInfo);
|
|
294
|
+
if (this.firmwareFetch) return this.firmwareFetch;
|
|
295
|
+
this.firmwareFetch = readFirmwareInfo(this.provider).then((info) => {
|
|
296
|
+
this.firmwareInfo = info ?? DefaultFirmwareInfo;
|
|
297
|
+
return this.firmwareInfo;
|
|
298
|
+
});
|
|
299
|
+
return this.firmwareFetch;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Read factory info (serial number, body color, board revision) from the controller.
|
|
304
|
+
* Requires firmware info to be fetched first for feature gating.
|
|
305
|
+
* Idempotent across the lifetime of a single connection.
|
|
306
|
+
*/
|
|
307
|
+
public fetchFactoryInfo(): Promise<FactoryInfo> {
|
|
308
|
+
if (this.factoryInfo !== DefaultFactoryInfo) return Promise.resolve(this.factoryInfo);
|
|
309
|
+
if (this.factoryFetch) return this.factoryFetch;
|
|
310
|
+
if (this.firmwareInfo === DefaultFirmwareInfo) return Promise.resolve(DefaultFactoryInfo);
|
|
311
|
+
const fwInfo = this.firmwareInfo;
|
|
312
|
+
this.factoryFetch = readFactoryInfo(
|
|
313
|
+
this.provider,
|
|
314
|
+
fwInfo.hardwareInfo,
|
|
315
|
+
fwInfo.mainFirmwareVersionRaw,
|
|
316
|
+
).then((info) => {
|
|
317
|
+
this.factoryInfo = info ?? DefaultFactoryInfo;
|
|
318
|
+
return this.factoryInfo;
|
|
319
|
+
});
|
|
320
|
+
return this.factoryFetch;
|
|
321
|
+
}
|
|
322
|
+
|
|
93
323
|
/** Condense all pending commands into one HID feature report */
|
|
94
324
|
private static buildFeatureReport(
|
|
95
325
|
events: CommandEvent[],
|