The flashed Stadia controller acts like a standard gamepad, which means not all its buttons are accessible using the Gamepad API. With WebHID, you can now access the missing buttons.
Since Stadia shut down, many feared that the controller would end up as a useless piece of hardware on the landfill. Luckily, the Stadia team has decided to instead open up the Stadia controller by providing a custom firmware that you can flash on your controller by going to the Stadia Bluetooth mode page. This makes your Stadia controller appear as a standard gamepad that you can connect to via USB cable or wirelessly via Bluetooth. Proudly featured on the Project Fugu API Showcase, the Stadia Bluetooth page itself uses WebHID and WebUSB, but this is not the topic of this article. In this post, I want to explain how you can talk to the Stadia controller via WebHID.
The Stadia controller as a standard gamepad
After flashing, the controller appears as a standard gamepad to the operating system. See the following screenshot for a common button and axis arrangement on a standard gamepad. As defined in the Gamepad API spec, standard gamepads have buttons from 0 to 16, so 17 in total (the d-pad counts as four buttons). If you try the Stadia controller on the gamepad tester demo, you will notice that it works like a charm.
However, if you count the buttons on the Stadia controller, there are 19. If you systematically try them one by one in the gamepad tester, you will realize that the Assistant and the Capture buttons don't work. Even if the gamepad buttons
attribute as defined in the Gamepad spec is open-ended, since the Stadia controller appears as a standard gamepad, only buttons 0–16 are mapped. You can still use the other buttons, but most games won't expect them to exist.
WebHID to the rescue
Thanks to the WebHID API, you can talk to the missing buttons 17 and 18. And if you really want to, you can even get data about all the other buttons and axes that are already available via the Gamepad API. The first step is finding out how the Stadia controller reports itself to the operating system. One way to do so is to open the Chrome DevTools Console on any random page, and to request an unfiltered list of devices from the WebHID API. You then manually choose the Stadia controller for further inspection. Get an unfiltered list of devices by simply passing an empty filters
options array.
const [device] = await navigator.hid.requestDevice({filters: []});
In the picker, the penultimate entry looks like the Stadia controller.
After selecting the "Stadia Controller rev. A" device, log the resulting HIDDevice
object to the Console. This reveals the Stadia controller's productId
(37888
, which is 0x9400
in hex) and vendorId
(6353
, which is 0x18d1
in hex). If you look up the vendorID
in the official USB vendor ID table, you will find that 6353
maps to what you would expect: Google Inc.
.
An alternative to the flow described above is navigating to chrome://device-log/
in the URL bar, pressing the Clear button, plugging in your Stadia controller, and then pressing Refresh. This provides you with the same information.
Yet another alternative is using the HID Explorer tool that lets you explore even more details of the HID devices connected to your computer.
Use these two IDs, the vendorId
and the productId
, to refine what's shown in the picker by now correctly filtering for the right WebHID device.
const [stadiaController] = await navigator.hid.requestDevice({filters: [{
vendorId: 6353,
productId: 37888,
}]});
Now the noise from all the unrelated devices is gone, and only the Stadia controller shows up.
Up next, open the HIDDevice
by calling the open()
method.
await stadiaController.open();
Log the HIDDevice
again, and the opened
flag is set to true
.
With the device open, listen for incoming inputreport
events by attaching an event listener.
stadiaController.addEventListener('inputreport', (e) => {
console.log(e);
});
When you press and let go the Assistant button on the controller, two events are logged to the Console. You can think of them as "Assistant button down" and "Assistant button up" events. Apart from the timeStamp
, the two events look indistinguishable at first glance.
The reportId
property of the HIDInputReportEvent
interface returns the one-byte identification prefix for this report, or 0
if the HID interface does not use report IDs. In this case it's 3
. The secret is in the data
property, which is represented as a DataView
of size 10. A DataView
provides a low-level interface for reading and writing multiple number types in a binary ArrayBuffer
. The way to get something more digestible out of this representation is by creating a Uint8Array
out of the ArrayBuffer
, so you can see the individual 8-bit unsigned integers.
const data = new Uint8Array(event.data.buffer);
When you then log the input report event data again, things start to make more sense and the "Assistant button down" and "Assistant button up" events start to become decipherable. The first integer (8
in both events) seems to be related to button presses, and the second integer (2
and 0
) seems to be related to whether the Assistant button is pressed or not.
Press the Capture button instead of the Assistant button, and you will see that the second integer toggles from 1
when the button is pressed to 0
when it's released. This allows you to write a very simple "driver" that enables you to make use of the missing two buttons.
stadia.addEventListener('inputreport', (event) => {
if (!e.reportId === 3) {
return;
}
const data = new Uint8Array(event.data.buffer);
if (data[0] === 8) {
if (data[1] === 1) {
hidButtons[1].classList.add('highlight');
} else if (data[1] === 2) {
hidButtons[0].classList.add('highlight');
} else if (data[1] === 3) {
hidButtons[0].classList.add('highlight');
hidButtons[1].classList.add('highlight');
} else {
hidButtons[0].classList.remove('highlight');
hidButtons[1].classList.remove('highlight');
}
}
});
Using a reverse-engineering approach like this, you can, button by button and axis by axis, figure out how to talk to the Stadia controller with WebHID. Once you get the hang of it, the rest is almost mechanical integer mapping work.
The one thing that's missing now is the smooth connecting experience that the Gamepad API gives you. While for security reasons you always need to go through the initial picker experience once in order to work with a WebHID device like the Stadia controller, for future connections, you can reconnect to known devices. Do that by calling the getDevices()
method.
let stadiaController;
const [device] = await navigator.hid.getDevices();
if (device && device.vendorId === 6353 && device.productId === 37888) {
stadiaController = device;
}
Demo
You can see the Stadia controller controlled jointly by the Gamepad API and the WebHID API in a demo that I've built. Be sure to check out the source code, which builds upon the snippets from this article. For the sake of simplicity, I only display the A, B, X, and Y buttons (controlled by the Gamepad API), and the Assistant and the Capture buttons (controlled by the WebHID API). Below the controller image, you can see the raw WebHID data, so you can get a feeling for all the buttons and axes on the controller.
Conclusions
Thanks to the new firmware, the Stadia controller is now usable as a standard gamepad with 17 buttons, which, in the majority of cases, is more than enough to control common web games. If, for whatever reason, you need data from all 19 buttons on the controller, WebHID allows you to get access to low-level input reports that you can decipher by reverse-engineering them one by one. If you happen to write a complete WebHID driver after reading this article, be sure to contact me, and I'll happily link your project here. Happy WebHIDing!
Acknowledgements
This article was reviewed by François Beaufort.