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/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
- /** Buffered battery reading, sampled on a slow cadence */
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
- /** Unique identifier for the connected device (path or fingerprint) */
128
- public get deviceId(): string | undefined {
129
- return this.hid.provider.deviceId;
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
- /** Hardware serial number of the connected device, if available */
133
- public get serialNumber(): string | undefined {
134
- return this.hid.provider.serialNumber;
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
- /** Refresh connection state */
235
- let lastConnected = false;
236
- setInterval(() => {
237
- const {
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 && !lastConnected) {
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
- lastConnected = connected;
250
- if (!connected) this.hid.provider.connect();
251
- }, 200);
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(this.playerLeds.bitmask, this.playerLeds.brightness);
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]);
@@ -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
+
@@ -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[],