Skip to content

Commit bc5b8a5

Browse files
fix: add vendor/product gamepad ID matching
Add a fallback lookup that normalizes Gamepad API IDs into vendor/product pairs so controllers can still be identified when browsers expose different product names. This covers Chromium and Firefox ID formats, adds an explicit VID/PID mapping for the generic controller, and expands tests for the new lookup paths while preserving existing XInput label behavior.
1 parent 81447b5 commit bc5b8a5

3 files changed

Lines changed: 142 additions & 3 deletions

File tree

src/js/gamepad-helper.js

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22
* Gamepad Helper Module
33
* This module provides a set of utilities for working with gamepads in web applications.
44
*/
5+
6+
/**
7+
* Controller identity metadata used by browser ID lookups.
8+
* @typedef {Object} GamepadIdentityMapping
9+
* @property {string} name - Human-readable controller name
10+
* @property {string[]} gamepad_api_ids - Known complete Gamepad API IDs
11+
* @property {string[]} [vendor_product_ids] - Additional normalized `vendor:product` IDs
12+
* @property {string} type - Controller type
13+
*/
514
class GamepadHelper {
615
constructor() {
716
/**
@@ -22,7 +31,7 @@ class GamepadHelper {
2231
/**
2332
* Exact Gamepad Mappings
2433
* This object maps specific gamepad API IDs to controller types and names.
25-
* @type {Object.<number, {name: string, gamepad_api_ids: string[], type: string}>}
34+
* @type {Object.<number, GamepadIdentityMapping>}
2635
*/
2736
this.exactGamepadMappings = {
2837
0: {
@@ -35,6 +44,7 @@ class GamepadHelper {
3544
"Core (Plus) Wired Controller (Vendor: 20d6 Product: a711)",
3645
"Wireless Controller Extended Gamepad",
3746
],
47+
vendor_product_ids: ["1209:0001"],
3848
type: this.CONTROLLER_TYPES.STANDARD,
3949
},
4050
1: {
@@ -117,12 +127,27 @@ class GamepadHelper {
117127
/**
118128
* Exact ID Lookup
119129
* This object maps gamepad API IDs to their respective controller mappings.
120-
* @type {Object.<string, {name: string, gamepad_api_ids: string[], type: string}>}
130+
* @type {Object.<string, GamepadIdentityMapping>}
121131
*/
122132
this.exactIdLookup = {};
133+
134+
/**
135+
* Vendor/product lookup used when browser-specific product names differ.
136+
* @type {Object.<string, GamepadIdentityMapping>}
137+
*/
138+
this.vendorProductLookup = {};
123139
Object.values(this.exactGamepadMappings).forEach(mapping => {
124140
mapping.gamepad_api_ids.forEach(id => {
125141
this.exactIdLookup[id] = mapping;
142+
143+
const vendorProductId = this.extractVendorProductId(id);
144+
if (vendorProductId) {
145+
this.vendorProductLookup[vendorProductId] = mapping;
146+
}
147+
});
148+
149+
mapping.vendor_product_ids?.forEach(vendorProductId => {
150+
this.vendorProductLookup[vendorProductId] = mapping;
126151
});
127152
});
128153

@@ -351,6 +376,29 @@ class GamepadHelper {
351376
return !!navigator.getGamepads;
352377
}
353378

379+
/**
380+
* Extract a normalized vendor/product identifier from a browser Gamepad API ID.
381+
* @param {string|null} gamepadId - The ID of the gamepad as given by the Gamepad API
382+
* @returns {string|null} A lower-case, zero-padded `vendor:product` identifier
383+
*/
384+
extractVendorProductId(gamepadId) {
385+
if (!gamepadId) {
386+
return null;
387+
}
388+
389+
const chromiumMatch = /Vendor:\s*([\da-f]{1,4})\s+Product:\s*([\da-f]{1,4})/i.exec(gamepadId);
390+
const firefoxMatch = /^([\da-f]{1,4})-([\da-f]{1,4})-/i.exec(gamepadId);
391+
const match = chromiumMatch || firefoxMatch;
392+
393+
if (!match) {
394+
return null;
395+
}
396+
397+
const vendorId = match[1].padStart(4, '0').toLowerCase();
398+
const productId = match[2].padStart(4, '0').toLowerCase();
399+
return `${vendorId}:${productId}`;
400+
}
401+
354402
/**
355403
* Get gamepad information based on the gamepad ID
356404
* @param {string|null} gamepadId - The ID of the gamepad as given by the Gamepad API
@@ -373,6 +421,15 @@ class GamepadHelper {
373421
};
374422
}
375423

424+
const vendorProductId = this.extractVendorProductId(gamepadId);
425+
const vendorProductMatch = vendorProductId ? this.vendorProductLookup[vendorProductId] : null;
426+
if (vendorProductMatch) {
427+
return {
428+
type: vendorProductMatch.type,
429+
name: vendorProductMatch.name
430+
};
431+
}
432+
376433
return {
377434
type: this.CONTROLLER_TYPES.STANDARD,
378435
name: 'Generic Controller'

tests/gamepad-helper.test.js

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ describe('GamepadHelper', () => {
5151
expect(helper.exactIdLookup['Pro Controller (STANDARD GAMEPAD Vendor: 057e Product: 2009)'].type).toBe(helper.CONTROLLER_TYPES.SWITCH);
5252
});
5353

54+
test('initializes vendorProductLookup correctly', () => {
55+
expect(helper.vendorProductLookup['054c:05c4'].name).toBe('Sony DualShock (PS4)');
56+
expect(helper.vendorProductLookup['054c:0ce6'].name).toBe('Sony DualSense (PS5)');
57+
expect(helper.vendorProductLookup['057e:2009'].name).toBe('Nintendo Switch Pro Controller');
58+
expect(helper.vendorProductLookup['1209:0001'].name).toBe('Generic Gamepad');
59+
});
60+
5461
test('initializes controller mappings correctly', () => {
5562
expect(helper.controllerMappings).toBeDefined();
5663
expect(helper.controllerMappings[helper.CONTROLLER_TYPES.XBOX].buttonMap[0]).toBe('A');
@@ -87,6 +94,81 @@ describe('GamepadHelper', () => {
8794
});
8895
});
8996

97+
test('preserves browser-provided XInput labels', () => {
98+
expect(helper.getGamepadInfo('xinput')).toEqual({
99+
type: helper.CONTROLLER_TYPES.XBOX,
100+
name: 'Xbox'
101+
});
102+
expect(helper.getGamepadInfo('Xbox 360 Controller (XInput STANDARD GAMEPAD)')).toEqual({
103+
type: helper.CONTROLLER_TYPES.XBOX,
104+
name: 'Xbox 360'
105+
});
106+
});
107+
108+
test.each([
109+
{
110+
browserController: 'Firefox DualShock 4',
111+
gamepadId: '054c-05c4-HID VHF Driver',
112+
type: 'playstation',
113+
name: 'Sony DualShock (PS4)'
114+
},
115+
{
116+
browserController: 'Firefox DualSense',
117+
gamepadId: '054c-0ce6-HID VHF Driver',
118+
type: 'playstation',
119+
name: 'Sony DualSense (PS5)'
120+
},
121+
{
122+
browserController: 'Firefox Switch Pro',
123+
gamepadId: '057e-2009-HID VHF Driver',
124+
type: 'switch',
125+
name: 'Nintendo Switch Pro Controller'
126+
},
127+
{
128+
browserController: 'Firefox Generic',
129+
gamepadId: '1209-0001-HID VHF Driver',
130+
type: 'standard',
131+
name: 'Generic Gamepad'
132+
},
133+
{
134+
browserController: 'Chrome DualShock 4',
135+
gamepadId: 'HID VHF Driver (STANDARD GAMEPAD Vendor: 054c Product: 05c4)',
136+
type: 'playstation',
137+
name: 'Sony DualShock (PS4)'
138+
},
139+
{
140+
browserController: 'Chrome DualSense',
141+
gamepadId: 'HID VHF Driver (STANDARD GAMEPAD Vendor: 054c Product: 0ce6)',
142+
type: 'playstation',
143+
name: 'Sony DualSense (PS5)'
144+
},
145+
{
146+
browserController: 'Chrome Switch Pro',
147+
gamepadId: 'HID VHF Driver (STANDARD GAMEPAD Vendor: 057e Product: 2009)',
148+
type: 'switch',
149+
name: 'Nintendo Switch Pro Controller'
150+
},
151+
{
152+
browserController: 'Chrome Generic',
153+
gamepadId: 'HID VHF Driver (Vendor: 1209 Product: 0001)',
154+
type: 'standard',
155+
name: 'Generic Gamepad'
156+
},
157+
])('returns a VID/PID match for $browserController', ({ gamepadId, type, name }) => {
158+
expect(helper.getGamepadInfo(gamepadId)).toEqual({ type, name });
159+
});
160+
161+
test('normalizes shortened and uppercase VID/PID values', () => {
162+
expect(helper.getGamepadInfo('54C-5C4-HID VHF Driver')).toEqual({
163+
type: helper.CONTROLLER_TYPES.PLAYSTATION,
164+
name: 'Sony DualShock (PS4)'
165+
});
166+
});
167+
168+
test('does not extract a VID/PID from a missing ID', () => {
169+
expect(helper.extractVendorProductId(null)).toBeNull();
170+
});
171+
90172
test('returns generic info for unknown gamepadId', () => {
91173
const result = helper.getGamepadInfo('unknown-controller-id');
92174
expect(result).toEqual({

webpack.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ let config = {
3030
// Put the Codecov webpack plugin after all other plugins
3131
codecovWebpackPlugin({
3232
enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined,
33-
bundleName: "shared-web",
33+
bundleName: "gamepad-helper",
3434
uploadToken: process.env.CODECOV_TOKEN,
3535
}),
3636
],

0 commit comments

Comments
 (0)