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
@@ -0,0 +1,206 @@
1
+ import { HIDProvider } from "./hid_provider";
2
+
3
+ /** Known DualSense body colors */
4
+ export enum DualsenseColor {
5
+ Unknown = "Unknown",
6
+ White = "White",
7
+ MidnightBlack = "Midnight Black",
8
+ CosmicRed = "Cosmic Red",
9
+ NovaPink = "Nova Pink",
10
+ GalacticPurple = "Galactic Purple",
11
+ StarlightBlue = "Starlight Blue",
12
+ GreyCamouflage = "Grey Camouflage",
13
+ VolcanicRed = "Volcanic Red",
14
+ SterlingSilver = "Sterling Silver",
15
+ CobaltBlue = "Cobalt Blue",
16
+ ChromaTeal = "Chroma Teal",
17
+ ChromaIndigo = "Chroma Indigo",
18
+ ChromaPearl = "Chroma Pearl",
19
+ Anniversary30th = "30th Anniversary",
20
+ GodOfWarRagnarok = "God of War Ragnarok",
21
+ SpiderMan2 = "Spider-Man 2",
22
+ AstroBot = "Astro Bot",
23
+ Fortnite = "Fortnite",
24
+ TheLastOfUs = "The Last of Us",
25
+ IconBlueLimitedEdition = "Icon Blue Limited Edition",
26
+ GenshinImpact = "Genshin Impact",
27
+ }
28
+
29
+ /** Known DualSense body colors, keyed by the 2-char code from the serial number */
30
+ export const DualsenseColorMap: Record<string, DualsenseColor> = {
31
+ "00": DualsenseColor.White,
32
+ "01": DualsenseColor.MidnightBlack,
33
+ "02": DualsenseColor.CosmicRed,
34
+ "03": DualsenseColor.NovaPink,
35
+ "04": DualsenseColor.GalacticPurple,
36
+ "05": DualsenseColor.StarlightBlue,
37
+ "06": DualsenseColor.GreyCamouflage,
38
+ "07": DualsenseColor.VolcanicRed,
39
+ "08": DualsenseColor.SterlingSilver,
40
+ "09": DualsenseColor.CobaltBlue,
41
+ "10": DualsenseColor.ChromaTeal,
42
+ "11": DualsenseColor.ChromaIndigo,
43
+ "12": DualsenseColor.ChromaPearl,
44
+ "30": DualsenseColor.Anniversary30th,
45
+ Z1: DualsenseColor.GodOfWarRagnarok,
46
+ Z2: DualsenseColor.SpiderMan2,
47
+ Z3: DualsenseColor.AstroBot,
48
+ Z4: DualsenseColor.Fortnite,
49
+ Z6: DualsenseColor.TheLastOfUs,
50
+ ZB: DualsenseColor.IconBlueLimitedEdition,
51
+ ZE: DualsenseColor.GenshinImpact,
52
+ };
53
+
54
+ /** Board revision names, keyed by the character at serial position 1 */
55
+ const BoardRevisionMap: Record<string, string> = {
56
+ "1": "BDM-010",
57
+ "2": "BDM-020",
58
+ "3": "BDM-030",
59
+ "4": "BDM-040",
60
+ "5": "BDM-050",
61
+ };
62
+
63
+ /** Factory information derived from the controller's serial number */
64
+ export interface FactoryInfo {
65
+ /** Raw serial number string */
66
+ serialNumber: string;
67
+ /** Controller body color name (e.g. "Cosmic Red") */
68
+ colorName: string;
69
+ /** Raw 2-character color code from the serial number */
70
+ colorCode: string;
71
+ /** Board revision (e.g. "BDM-030") */
72
+ boardRevision: string;
73
+ }
74
+
75
+ /** Default FactoryInfo used when the test command protocol is unavailable (e.g. Linux Bluetooth via node-hid) */
76
+ export const DefaultFactoryInfo: FactoryInfo = {
77
+ serialNumber: "unknown",
78
+ colorName: "unknown",
79
+ colorCode: "??",
80
+ boardRevision: "unknown",
81
+ };
82
+
83
+ /** Feature report IDs for the test command protocol */
84
+ const SEND_REPORT_ID = 0x80;
85
+ const RECV_REPORT_ID = 0x81;
86
+ /** Report size for test command feature reports (report ID + 63 bytes payload) */
87
+ const REPORT_SIZE = 64;
88
+
89
+ /** Test command device/action IDs */
90
+ const DEVICE_SYSTEM = 0x01;
91
+ const ACTION_READ_SERIAL = 0x13;
92
+
93
+ /** Test command response status values */
94
+ const STATUS_COMPLETE = 0x02;
95
+
96
+ /**
97
+ * Send a test command via Feature Report 0x80 and poll 0x81 for the response.
98
+ * Returns the result data bytes (after the header), or undefined on failure.
99
+ */
100
+ async function sendTestCommand(
101
+ provider: HIDProvider,
102
+ deviceId: number,
103
+ actionId: number,
104
+ maxAttempts: number = 20,
105
+ ): Promise<Uint8Array | undefined> {
106
+ // Build the send report: report ID at byte 0, then payload.
107
+ // The provider handles platform differences (WebHID strips byte 0, adds CRC for BT).
108
+ const sendBuf = new Uint8Array(REPORT_SIZE).fill(0);
109
+ sendBuf[0] = SEND_REPORT_ID;
110
+ sendBuf[1] = deviceId;
111
+ sendBuf[2] = actionId;
112
+
113
+ await provider.sendFeatureReport(SEND_REPORT_ID, sendBuf);
114
+
115
+ // Poll for response
116
+ for (let i = 0; i < maxAttempts; i++) {
117
+ await sleep(50);
118
+
119
+ const response = await provider.readFeatureReport(
120
+ RECV_REPORT_ID,
121
+ REPORT_SIZE,
122
+ );
123
+ if (response.length === 0) continue;
124
+
125
+ // Response layout: [reportId, deviceId, actionId, status, ...data]
126
+ // Note: node-hid includes the report ID at byte 0; WebHID may not.
127
+ // Find the actual start based on whether byte 0 is the report ID.
128
+ const offset = response[0] === RECV_REPORT_ID ? 1 : 0;
129
+ const respDevice = response[offset];
130
+ const respAction = response[offset + 1];
131
+ const respStatus = response[offset + 2];
132
+
133
+ if (respDevice !== deviceId || respAction !== actionId) continue;
134
+ if (respStatus === STATUS_COMPLETE) {
135
+ return response.slice(offset + 3);
136
+ }
137
+ }
138
+
139
+ return undefined;
140
+ }
141
+
142
+ /** Decode a byte array as ASCII, stopping at null terminator */
143
+ function decodeAscii(data: Uint8Array, offset: number, length: number): string {
144
+ let str = "";
145
+ for (let i = 0; i < length; i++) {
146
+ const byte = data[offset + i];
147
+ if (byte === 0) break;
148
+ str += String.fromCharCode(byte);
149
+ }
150
+ return str;
151
+ }
152
+
153
+ function sleep(ms: number): Promise<void> {
154
+ return new Promise((resolve) => setTimeout(resolve, ms));
155
+ }
156
+
157
+ /**
158
+ * Read factory info (serial number, body color, board revision) from a connected controller.
159
+ *
160
+ * Requires firmware support: hardwareInfo >= 777 and mainFirmwareVersion >= 65655.
161
+ * Use the values from FirmwareInfo (Feature Report 0x20) to check this gate.
162
+ *
163
+ * @param provider The HID provider for the connected controller
164
+ * @param hardwareInfo Hardware info word from FirmwareInfo
165
+ * @param mainFwVersionRaw Raw uint32 main firmware version from FirmwareInfo
166
+ */
167
+ export async function readFactoryInfo(
168
+ provider: HIDProvider,
169
+ hardwareInfo: number,
170
+ mainFwVersionRaw: number,
171
+ ): Promise<FactoryInfo | undefined> {
172
+ // Firmware gate check
173
+ if ((hardwareInfo & 0xffff) < 777 || mainFwVersionRaw < 65655) {
174
+ return undefined;
175
+ }
176
+
177
+ try {
178
+ const result = await sendTestCommand(
179
+ provider,
180
+ DEVICE_SYSTEM,
181
+ ACTION_READ_SERIAL,
182
+ );
183
+ if (!result) return undefined;
184
+
185
+ const serialNumber = decodeAscii(result, 0, 32);
186
+ if (serialNumber.length < 6) return undefined;
187
+
188
+ const colorCode = serialNumber.slice(4, 6);
189
+ const revisionChar = serialNumber.slice(1, 2);
190
+ const colorName =
191
+ colorCode in DualsenseColorMap ? DualsenseColorMap[colorCode] : colorCode;
192
+ const boardRevision =
193
+ revisionChar in BoardRevisionMap
194
+ ? BoardRevisionMap[revisionChar]
195
+ : "unknown";
196
+
197
+ return {
198
+ serialNumber,
199
+ colorName,
200
+ colorCode,
201
+ boardRevision,
202
+ };
203
+ } catch {
204
+ return undefined;
205
+ }
206
+ }
@@ -0,0 +1,157 @@
1
+ import { HIDProvider } from "./hid_provider";
2
+
3
+ /** Parsed firmware version in major.minor.patch format */
4
+ export interface FirmwareVersion {
5
+ major: number;
6
+ minor: number;
7
+ patch: number;
8
+ }
9
+
10
+ /** Firmware and hardware information from Feature Report 0x20 */
11
+ export interface FirmwareInfo {
12
+ /** Firmware build date (e.g. "Apr 14 2023") */
13
+ buildDate: string;
14
+ /** Firmware build time (e.g. "12:34:56") */
15
+ buildTime: string;
16
+ /** Firmware type (2 or 3 indicates production firmware) */
17
+ firmwareType: number;
18
+ /** Software series identifier */
19
+ softwareSeries: number;
20
+ /** Hardware info word (lower 16 bits used for feature gating) */
21
+ hardwareInfo: number;
22
+ /** Main firmware version */
23
+ mainFirmwareVersion: FirmwareVersion;
24
+ /** Raw uint32 main firmware version (used for feature gating) */
25
+ mainFirmwareVersionRaw: number;
26
+ /** Device info (raw hex string) */
27
+ deviceInfo: string;
28
+ /** Update version (formatted as HH.LL hex) */
29
+ updateVersion: string;
30
+ /** Update image info */
31
+ updateImageInfo: number;
32
+ /** SBL (second bootloader) firmware version */
33
+ sblFirmwareVersion: FirmwareVersion;
34
+ /** DSP firmware version (formatted as HHHH_LLLL hex) */
35
+ dspFirmwareVersion: string;
36
+ /** Spider DSP firmware version */
37
+ spiderDspFirmwareVersion: FirmwareVersion;
38
+ }
39
+
40
+ /** A zeroed firmware version */
41
+ const UnknownVersion: FirmwareVersion = { major: 0, minor: 0, patch: 0 };
42
+
43
+ /** Default FirmwareInfo used when Feature Report 0x20 could not be read */
44
+ export const DefaultFirmwareInfo: FirmwareInfo = {
45
+ buildDate: "unknown",
46
+ buildTime: "unknown",
47
+ firmwareType: 0,
48
+ softwareSeries: 0,
49
+ hardwareInfo: 0,
50
+ mainFirmwareVersion: UnknownVersion,
51
+ mainFirmwareVersionRaw: 0,
52
+ deviceInfo: "unknown",
53
+ updateVersion: "00.00",
54
+ updateImageInfo: 0,
55
+ sblFirmwareVersion: UnknownVersion,
56
+ dspFirmwareVersion: "0000_0000",
57
+ spiderDspFirmwareVersion: UnknownVersion,
58
+ };
59
+
60
+ /** Format a FirmwareVersion as "major.minor.patch" */
61
+ export function formatFirmwareVersion(v: FirmwareVersion): string {
62
+ return `${v.major}.${v.minor}.${v.patch}`;
63
+ }
64
+
65
+ /** Feature report ID for firmware information */
66
+ const REPORT_ID = 0x20;
67
+ /** Expected report length (report ID + 63 bytes of data) */
68
+ const REPORT_LENGTH = 64;
69
+
70
+ /** Parse a uint32 version into major.minor.patch */
71
+ function parseVersion(ver: number): FirmwareVersion {
72
+ return {
73
+ major: (ver >>> 24) & 0xff,
74
+ minor: (ver >>> 16) & 0xff,
75
+ patch: ver & 0xffff,
76
+ };
77
+ }
78
+
79
+ /** Format a uint16 as HH.LL hex */
80
+ function formatUpdateVersion(ver: number): string {
81
+ const hi = ((ver >>> 8) & 0xff).toString(16).padStart(2, "0");
82
+ const lo = (ver & 0xff).toString(16).padStart(2, "0");
83
+ return `${hi}.${lo}`;
84
+ }
85
+
86
+ /** Format a uint32 as HHHH_LLLL hex */
87
+ function formatDspVersion(ver: number): string {
88
+ const hi = ((ver >>> 16) & 0xffff).toString(16).padStart(4, "0");
89
+ const lo = (ver & 0xffff).toString(16).padStart(4, "0");
90
+ return `${hi}_${lo}`;
91
+ }
92
+
93
+ /** Read a null-terminated ASCII string from a buffer */
94
+ function readString(data: Uint8Array, offset: number, length: number): string {
95
+ let str = "";
96
+ for (let i = 0; i < length; i++) {
97
+ const byte = data[offset + i];
98
+ if (byte === 0) break;
99
+ str += String.fromCharCode(byte);
100
+ }
101
+ return str;
102
+ }
103
+
104
+ /** Read a little-endian uint16 from a buffer */
105
+ function readUint16LE(data: Uint8Array, offset: number): number {
106
+ return data[offset] | (data[offset + 1] << 8);
107
+ }
108
+
109
+ /** Read a little-endian uint32 from a buffer */
110
+ function readUint32LE(data: Uint8Array, offset: number): number {
111
+ return (
112
+ data[offset] |
113
+ (data[offset + 1] << 8) |
114
+ (data[offset + 2] << 16) |
115
+ ((data[offset + 3] << 24) >>> 0)
116
+ );
117
+ }
118
+
119
+ /** Convert bytes to a hex string */
120
+ function toHex(data: Uint8Array, offset: number, length: number): string {
121
+ return Array.from(data.slice(offset, offset + length))
122
+ .map((b) => b.toString(16).padStart(2, "0"))
123
+ .join("");
124
+ }
125
+
126
+ /**
127
+ * Read and parse Feature Report 0x20 from a connected controller.
128
+ * Returns undefined if the report cannot be read.
129
+ */
130
+ export async function readFirmwareInfo(
131
+ provider: HIDProvider,
132
+ ): Promise<FirmwareInfo | undefined> {
133
+ try {
134
+ const data = await provider.readFeatureReport(REPORT_ID, REPORT_LENGTH);
135
+
136
+ // Offsets are from byte 1 (after the report ID byte)
137
+ const base = 1;
138
+
139
+ return {
140
+ buildDate: readString(data, base, 11),
141
+ buildTime: readString(data, base + 11, 8),
142
+ firmwareType: readUint16LE(data, base + 19),
143
+ softwareSeries: readUint16LE(data, base + 21),
144
+ hardwareInfo: readUint32LE(data, base + 23),
145
+ mainFirmwareVersion: parseVersion(readUint32LE(data, base + 27)),
146
+ mainFirmwareVersionRaw: readUint32LE(data, base + 27),
147
+ deviceInfo: toHex(data, base + 31, 12),
148
+ updateVersion: formatUpdateVersion(readUint16LE(data, base + 43)),
149
+ updateImageInfo: data[base + 45],
150
+ sblFirmwareVersion: parseVersion(readUint32LE(data, base + 47)),
151
+ dspFirmwareVersion: formatDspVersion(readUint32LE(data, base + 51)),
152
+ spiderDspFirmwareVersion: parseVersion(readUint32LE(data, base + 55)),
153
+ };
154
+ } catch {
155
+ return undefined;
156
+ }
157
+ }
@@ -76,6 +76,8 @@ export interface DualsenseHIDState {
76
76
  [InputId.BatteryLevel]: number;
77
77
  [InputId.BatteryStatus]: ChargeStatus;
78
78
  [InputId.MuteLed]: boolean;
79
+ [InputId.Microphone]: boolean;
80
+ [InputId.Headphone]: boolean;
79
81
  }
80
82
 
81
83
  /** Default values for all inputs */
@@ -124,6 +126,8 @@ export const DefaultDualsenseHIDState: DualsenseHIDState = {
124
126
  [InputId.BatteryLevel]: 0,
125
127
  [InputId.BatteryStatus]: ChargeStatus.Discharging,
126
128
  [InputId.MuteLed]: false,
129
+ [InputId.Microphone]: false,
130
+ [InputId.Headphone]: false,
127
131
  };
128
132
 
129
133
  /** Information about an available Dualsense device */
@@ -156,6 +160,12 @@ export abstract class HIDProvider {
156
160
  /** Callback to use for Error events */
157
161
  public onError: (error: Error) => void = () => {};
158
162
 
163
+ /** Callback fired the moment a device is fully attached and ready for I/O */
164
+ public onConnect: () => void = () => {};
165
+
166
+ /** Callback fired the moment a device detaches (cleanly or via error) */
167
+ public onDisconnect: () => void = () => {};
168
+
159
169
  /** Unique identifier for the connected device (path or serial) */
160
170
  public deviceId?: string;
161
171
 
@@ -186,6 +196,12 @@ export abstract class HIDProvider {
186
196
  /** Write to the HID device */
187
197
  abstract write(data: Uint8Array): Promise<void>;
188
198
 
199
+ /** Read a feature report from the device */
200
+ abstract readFeatureReport(reportId: number, length?: number): Promise<Uint8Array>;
201
+
202
+ /** Send a feature report to the device */
203
+ abstract sendFeatureReport(reportId: number, data: Uint8Array): Promise<void>;
204
+
189
205
  /** If true, gyroscope, touchpad, accelerometer are disabled */
190
206
  public limited?: boolean;
191
207
 
@@ -217,6 +233,7 @@ export abstract class HIDProvider {
217
233
  * Reset the HIDProvider state when the device is disconnected
218
234
  */
219
235
  protected reset(): void {
236
+ const wasAttached = this.device !== undefined;
220
237
  if (this.deviceId) {
221
238
  HIDProvider.claimedDevices.delete(this.deviceId);
222
239
  }
@@ -227,6 +244,7 @@ export abstract class HIDProvider {
227
244
  this.deviceId = undefined;
228
245
  this.serialNumber = undefined;
229
246
  this.onData(DefaultDualsenseHIDState);
247
+ if (wasAttached) this.onDisconnect();
230
248
  }
231
249
 
232
250
  /**
@@ -342,6 +360,8 @@ export abstract class HIDProvider {
342
360
  [InputId.TouchY1]: mapAxis(buffer.readUint16LE(40) >> 4, 1080),
343
361
  [InputId.Status]: (buffer.readUint8(55) & 8) > 0,
344
362
  [InputId.MuteLed]: (buffer.readUint8(55) & 4) > 0,
363
+ [InputId.Microphone]: (buffer.readUint8(55) & 2) > 0,
364
+ [InputId.Headphone]: (buffer.readUint8(55) & 1) > 0,
345
365
  [InputId.BatteryLevel]: mapBatteryLevel(buffer.readUint8(54)),
346
366
  [InputId.BatteryStatus]: (buffer.readUint8(54) >> 4) as ChargeStatus,
347
367
  };
@@ -416,6 +436,8 @@ export abstract class HIDProvider {
416
436
  // 12 reserved bytes
417
437
  [InputId.Status]: (buffer.readUint8(54) & 8) > 0,
418
438
  [InputId.MuteLed]: (buffer.readUint8(54) & 4) > 0,
439
+ [InputId.Microphone]: (buffer.readUint8(54) & 2) > 0,
440
+ [InputId.Headphone]: (buffer.readUint8(54) & 1) > 0,
419
441
  [InputId.BatteryLevel]: mapBatteryLevel(buffer.readUint8(53)),
420
442
  [InputId.BatteryStatus]: (buffer.readUint8(53) >> 4) as ChargeStatus,
421
443
  };
package/src/hid/index.ts CHANGED
@@ -7,7 +7,10 @@ export * from "./bt_checksum";
7
7
  export * from "./byte_array";
8
8
  export * from "./command";
9
9
  export * from "./dualsense_hid";
10
+ export * from "./factory_info";
11
+ export * from "./firmware_info";
10
12
  export * from "./hid_provider";
11
13
  export * from "./node_hid_provider";
14
+ export * from "./pairing_info";
12
15
  export * from "./platform_hid_provider";
13
16
  export * from "./web_hid_provider";
@@ -133,6 +133,7 @@ export class NodeHIDProvider extends HIDProvider {
133
133
  });
134
134
 
135
135
  this.device = device;
136
+ this.onConnect();
136
137
  } catch (err) {
137
138
  this.onError(
138
139
  err instanceof Error ? err : new Error(String(err))
@@ -148,6 +149,19 @@ export class NodeHIDProvider extends HIDProvider {
148
149
  return Promise.resolve();
149
150
  }
150
151
 
152
+ readFeatureReport(reportId: number, length: number): Promise<Uint8Array> {
153
+ if (!this.device) return Promise.reject(new Error("No device connected"));
154
+ const buf = this.device.getFeatureReport(reportId, length);
155
+ return Promise.resolve(new Uint8Array(buf));
156
+ }
157
+
158
+ sendFeatureReport(_reportId: number, data: Uint8Array): Promise<void> {
159
+ if (!this.device) return Promise.resolve();
160
+ // node-hid sendFeatureReport expects the report ID as the first byte of the buffer
161
+ this.device.sendFeatureReport(Array.from(data));
162
+ return Promise.resolve();
163
+ }
164
+
151
165
  get connected(): boolean {
152
166
  return this.device !== undefined;
153
167
  }
@@ -0,0 +1,33 @@
1
+ import { HIDProvider } from "./hid_provider";
2
+
3
+ /** Feature report ID for pairing info */
4
+ const REPORT_ID = 0x09;
5
+ /** Expected report length (report ID + 19 bytes) */
6
+ const REPORT_LENGTH = 20;
7
+
8
+ /**
9
+ * Read the controller's Bluetooth MAC address from Feature Report 0x09.
10
+ * Works over both USB and Bluetooth on all platforms.
11
+ * Returns the MAC as a colon-separated hex string (e.g. "AA:BB:CC:DD:EE:FF"),
12
+ * or undefined if the report cannot be read.
13
+ */
14
+ export async function readMacAddress(
15
+ provider: HIDProvider,
16
+ ): Promise<string | undefined> {
17
+ try {
18
+ const data = await provider.readFeatureReport(REPORT_ID, REPORT_LENGTH);
19
+
20
+ // Bytes 1–6 contain the MAC in little-endian order
21
+ const mac: string[] = [];
22
+ for (let i = 6; i >= 1; i--) {
23
+ mac.push(data[i].toString(16).padStart(2, "0"));
24
+ }
25
+
26
+ const result = mac.join(":");
27
+ // Reject all-zero MACs (no pairing info available)
28
+ if (result === "00:00:00:00:00:00") return undefined;
29
+ return result;
30
+ } catch {
31
+ return undefined;
32
+ }
33
+ }
@@ -1,5 +1,6 @@
1
1
  import { ByteArray } from "./byte_array";
2
2
  import { HIDProvider, DualsenseHIDState } from "./hid_provider";
3
+ import { computeFeatureReportChecksum } from "./bt_checksum";
3
4
 
4
5
  export interface WebHIDProviderOptions {
5
6
  /** Attach to this specific HIDDevice instead of discovering one */
@@ -25,7 +26,8 @@ export class WebHIDProvider extends HIDProvider {
25
26
 
26
27
  navigator.hid.addEventListener("disconnect", ({ device }) => {
27
28
  if (device === this.device) {
28
- this.device = undefined;
29
+ // Let disconnect() → reset() handle nulling this.device so that
30
+ // reset() can detect the device was attached and fire onDisconnect.
29
31
  this.disconnect();
30
32
  }
31
33
  });
@@ -93,16 +95,12 @@ export class WebHIDProvider extends HIDProvider {
93
95
 
94
96
  attach(device: HIDDevice): void {
95
97
  const key = WebHIDProvider.deviceKey(device);
96
- if (HIDProvider.claimedDevices.has(key)) {
97
- return; // Already claimed by another instance
98
- }
99
98
 
100
- device
101
- .open()
99
+ const openPromise = device.opened ? Promise.resolve() : device.open();
100
+ openPromise
102
101
  .then(() => {
103
102
  this.device = device;
104
103
  this.deviceId = key;
105
- HIDProvider.claimedDevices.add(key);
106
104
  this.detectConnectionType();
107
105
 
108
106
  // Enable accelerometer, gyro, touchpad
@@ -114,6 +112,7 @@ export class WebHIDProvider extends HIDProvider {
114
112
  this.buffer = data;
115
113
  this.onData(this.process({ reportId, buffer: data }));
116
114
  });
115
+ this.onConnect();
117
116
  })
118
117
  .catch((err: Error) => {
119
118
  this.onError(err);
@@ -121,6 +120,29 @@ export class WebHIDProvider extends HIDProvider {
121
120
  });
122
121
  }
123
122
 
123
+ /**
124
+ * Detach the current HIDDevice (if any) and attach a different one in place.
125
+ * Used by the manager to transplant a freshly-discovered device into an
126
+ * existing slot's provider after identity matching, so the consumer's
127
+ * Dualsense reference survives reconnection.
128
+ *
129
+ * The new device must already be open (or openable) — we close the old one,
130
+ * release its claim, and run the standard attach() flow on the new one.
131
+ */
132
+ replaceDevice(device: HIDDevice): void {
133
+ // Tear down the existing device without firing the disconnect cascade
134
+ // (we don't want subscribers to see a disconnect/reconnect blip).
135
+ if (this.device) {
136
+ const old = this.device;
137
+ const oldKey = this.deviceId;
138
+ this.device = undefined;
139
+ if (oldKey) HIDProvider.claimedDevices.delete(oldKey);
140
+ // Best-effort close; failures are non-fatal.
141
+ old.close().catch(() => {});
142
+ }
143
+ this.attach(device);
144
+ }
145
+
124
146
  /**
125
147
  * You need to get HID device permissions from an interactive
126
148
  * component, like a button. This returns a callback for triggering
@@ -199,12 +221,69 @@ export class WebHIDProvider extends HIDProvider {
199
221
 
200
222
  disconnect(): void {
201
223
  if (this.device) {
202
- this.device.close().finally(() => this.reset());
224
+ const dev = this.device;
225
+ // Reset synchronously so claimedDevices is freed immediately —
226
+ // otherwise a rapid disconnect/reconnect can race: the browser's
227
+ // connect event arrives before close() resolves, and attach() sees
228
+ // the key still claimed and silently bails out.
229
+ this.reset();
230
+ dev.close().catch(() => {});
203
231
  } else {
204
232
  this.reset();
205
233
  }
206
234
  }
207
235
 
236
+ async readFeatureReport(reportId: number): Promise<Uint8Array> {
237
+ if (!this.device) throw new Error("No device connected");
238
+ const view = await this.device.receiveFeatureReport(reportId);
239
+ return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
240
+ }
241
+
242
+ async sendFeatureReport(reportId: number, data: Uint8Array): Promise<void> {
243
+ if (!this.device) return;
244
+
245
+ // WebHID sendFeatureReport takes the report ID separately.
246
+ // data[0] is the report ID (for node-hid compat); strip it for WebHID.
247
+ const rawPayload = data.slice(1);
248
+
249
+ // Pad to the expected payload length from the HID descriptor
250
+ const expectedLength = this.getFeatureReportLength(reportId);
251
+ const payload = expectedLength > 0 && rawPayload.length < expectedLength
252
+ ? new Uint8Array(expectedLength)
253
+ : new Uint8Array(rawPayload);
254
+
255
+ if (expectedLength > rawPayload.length) {
256
+ payload.set(rawPayload);
257
+ }
258
+
259
+ // Bluetooth requires CRC-32 in the last 4 bytes of the payload
260
+ if (this.wireless) {
261
+ const crc = computeFeatureReportChecksum(reportId, payload);
262
+ const off = payload.length - 4;
263
+ payload[off] = crc & 0xff;
264
+ payload[off + 1] = (crc >>> 8) & 0xff;
265
+ payload[off + 2] = (crc >>> 16) & 0xff;
266
+ payload[off + 3] = (crc >>> 24) & 0xff;
267
+ }
268
+
269
+ await this.device.sendFeatureReport(reportId, payload);
270
+ }
271
+
272
+ /** Query the HID descriptor for the expected payload length of a feature report */
273
+ private getFeatureReportLength(reportId: number): number {
274
+ if (!this.device) return 0;
275
+ for (const c of this.device.collections) {
276
+ const report = (c.featureReports ?? []).find((r) => r.reportId === reportId);
277
+ if (report) {
278
+ return (report.items ?? []).reduce(
279
+ (sum, item) => sum + Math.ceil(((item.reportSize ?? 0) * (item.reportCount ?? 0)) / 8),
280
+ 0,
281
+ );
282
+ }
283
+ }
284
+ return 0;
285
+ }
286
+
208
287
  async write(data: Uint8Array): Promise<void> {
209
288
  if (!this.device) return;
210
289
  const reportId = data[0];
package/src/id.ts CHANGED
@@ -58,6 +58,11 @@ export const enum InputId {
58
58
  BatteryStatus = "BatteryStatus",
59
59
  MuteLed = "MuteLed",
60
60
 
61
+ /** Whether a microphone is connected to the controller */
62
+ Microphone = "Microphone",
63
+ /** Whether headphones are connected to the controller */
64
+ Headphone = "Headphone",
65
+
61
66
  /** For placeholder inputs */
62
67
  Unknown = "Unknown",
63
68
  }