For the last year and a bit, I’ve been using a SteelSeries Arctis Pro Wireless Headset for gaming and talking to friends. It’s a fine headset, but because there’s an always-on receiver there’s no way to detect if the headset is turned on or not from the desktop.
Whenever I start using the headset, I set my desktop’s sound to go to the headset, and then when I stop using the headset I set it to go back to speakers. It doesn’t take more than a second, but some days I might put the headset on a dozen times as I’m on calls, or if it’s noisy outside, etc. That means it’s probably worth at least a few hours of my time trying to automate it.
At first, I hoped I’d be able to tell from the state of the USB device whether there was a headset connected but nothing at all changed when flipping it on and off. Then I went hunting for existing open source tools that might work with it and found that while people have reverse engineered many of the older Arctis headsets, no one has done the same for the Pro Wireless. I finished off with a search to see if anyone had documented the wire protocol even if there was no nice open source software to go with it; I came up short there, too. Looks like I’d have to do it myself.
Capturing data with WireShark
The headset exposes a Human Interface Device (HID), and the wire protocols for earlier versions of the Arctis series looked to be very simple messages passed over the HID connection. It should therefore be fairly easy to use WireShark to capture the data being sent and received by the official SteelSeries application1.
After setting up WireShark and making it record only the headset’s HID connection, it quickly became apparent that the software was sending three different requests each second, over and over again:
The packets we’re interested in are the
SET_REPORT Request frames sent from the host to the
device. Wireshark understands the HID protocol, so it nicely shows us the raw HID data; in the screenshot this is
0x40AA (ignoring the trailing zero bytes). The other requests sent immediately after have
0x42AA payloads — clearly the first byte is indicating which piece of data
is being requested.
The responses to these requests come back in an
URB_INTERRUPT in frame:
So the answer to our first request appears to be
0x04. The second response is
and the third is
0x04 again. The first thing I tried doing was popping the spare battery out of the
charger. The response to the
0x42AA request changed from
that’s certainly clear enough! After a bit of waiting around, the battery in my headset dropped from full on the
display to three bars, and at that point the
0x40AA response dropped from
0x03. The display on the receiver shows battery state as four bars, and it appears the wire protocol
directly corresponds to that particular representation.
That left the
0x41AA request as an unknown. I tried everything I could think of, but it
stubbornly kept returning
0x0402. I enlisted a friend who has the same headset to run some hacky Go
code and report his values, and he also got a
0x0402 response. As I explained that I couldn’t figure
out what these values are, he reported back that turning his headset off made the response change to
0x0202. In all my testing, I’d forgotten to try turning the headset off! That’s the one thing I was
actually trying to detect, as well. Thanks, Simon, for helping me get past that bit of stupidity!
I still don’t know what the second byte of the response is, or whether there are other values than
0x04 for on and
0x02 for off, but I’m happy enough to label it as “device status” and
Exploring other features
The software allows you to tweak a bunch of different settings:
I went through each one and fiddled with all the values, recording the requests in WireShark as I did so. It
turns out the protocol is very simplistic - the wire protocol directly corresponds to the UI elements in the
software (or on the receiver, if you navigate through its menus). For example, the software allows you to set the
“Headset auto shutoff” value in increments of 10 minutes. You might usually expect this to be converted to
seconds or something similar before being passed to the device, but on the wire it’s actually sent as
0x00 for off,
0x01 for 10 minutes, up through to
0x0C for the maximum of
All the dropdown options seem to function this way — if you pick the 6th option then the request payload will
0x06 byte. The sliders have fixed positions they snap to and function similarly: for the two
brightness sliders pictured they snap to 11 positions and on the wire these range from
0x00 for off
0x0A for the maximum.
At this point I was well past my goal of being able to detect whether the headset was on or off, and I was now just trying to see if I could figure out enough of the protocol that I could reimplement the control software on Linux if I ever wanted to. Just as I was about to close the app, something caught my eye: there are integrations with games and other software that can display information on the receiver’s OLED display!
There’s an official API for this, but it involves sending JSON to a webserver which runs as part of their app2. That doesn’t feel very nice, and definitely won’t work for me on Linux, so I started a new WireShark session and took some captures while I spoke to myself on Discord.
The frames sent to the device whenever Discord showed a notification had a 1060 byte payload. The display on the receiver is 140 pixels wide3 and each pixel can only seem to be on or off, so I figured each bit in the payload corresponded to one bit of the output. I exported the data to binary in the hope that I could visually see what was going on - in theory if I line wrap the data at 140 characters it should look vaguely like the final output. Unfortunately, it did not. There was roughly the right number of high bits, but no amount of fiddling with them in a text editor could give me a coherent picture.
Instead, I wrote some code to write different values to the device. Starting with a payload of all zeroes and gradually increasing a bit at a time every second. After the first few seconds, I saw a line of pixels being drawn downwards on the left of the screen; had I just got the axes the wrong way around? After the first eight pixels lit up, though, they jumped over to the next column. The actual addressing scheme looks something like this:
So the first 140 bytes given the pixels for the first 8 rows, the next 140 bytes fill in the 8 rows below that, and so on and so forth. Armed with this information I wrote a simple program to read an image and output it to the display:
At this point I was thinking about trying to get Doom rendering on the screen, but I couldn’t find anything nicely hackable that would let me grab the output and pass it on to the receiver. Instead, I decided to try a GIF decoder and before very long had a nice little animated display:
All the HID messages have a single byte that determines the message type, then an
0xAA byte, then
any payload required by the command. These are the ones I’ve figured out:
||Request firmware version(?)||None.|
||Equalizer preset||ID of the equalizer preset to use.|
||Set headset timeout||Timeout, as a number of 10 minutes.
||Mic mute LED brightness||
||Request headset battery||None.|
||Request device status||None.|
||Request receiver battery||None.|
||Surround sound mode||
||Auto-mute game audio during calls||
||Equalizer||(Not yet decoded)|
||Render image||Pixel array as described above.|
Some of these I’ve not dug too much into because they didn’t seem very interesting. If you have an Arctis Pro Wireless and figure anything more out, let me know, and I’ll update the list.
If you just want to check device state like I originally did, I’ve contributed support for this headset to the excellent HeadsetControl project. It should be in the next release.
Unfortunately (as you’d expect) their application only runs on Windows, so this process involved an annoying amount of rebooting to Windows, fleeing back to Linux, and then realising I hadn’t actually recorded enough to figure it out and repeating. ↩︎
You can also send lisp to the webserver, and it will execute it. No comment. ↩︎
I know this not because it’s mentioned in the technical specs (it’s not), but because I took a photo and counted them out one by one. ↩︎