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
|
@@ -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
|
+
}
|
package/src/hid/hid_provider.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|