Creating a battery indicator app for my SteelSeries Arctis headset

Published 14.04.2024 • Last modified 13.05.2024

Recently, the cushions of my old headset, the HyperX Cloud Stinger, were breaking apart pretty bad. I had been using them for 5+ years at that point, so it was time for an upgrade. I bought a new wireless gaming headset, the SteelSeries Arctis Nova 7. So far, it has been great. I was expecting to feel a noticeable delay in the audio since they are wireless, but I am happy to report that with the 2.4 Ghz wireless adapter there is practically no delay! They have about 38 hours of battery life too. One small issue is that the only way to see how much battery life is left is to look at the color of the LED on the headset, or use the SteelSeries GG application, which has a small battery percentage indicator in the “Engine” tab.

Screenshot of SteelSeries GG application, with Arctis Nova 7 showing battery as 100% remaining
The SteelSeries GG app shows the battery percentage as 50% or 75% – not very accurate

I wasn’t satisfied with opening the SteelSeries app every time I wanted to check the battery status. I wanted to have the battery percentage icon show up on the taskbar, so that I could quickly check the battery level. The application would need to somehow get the battery from the device.

Initially, I had no idea how to read things like the battery level from a device. I wasn’t even sure whether it was possible at all. Thankfully, other people had already done it: YourM4jesty/ArctisBatteryPercentage and richrace/arctis-usb-finder. Reading the code of these projects and trying it out, I was able to write a program to read the battery level from my Arctis headset. It worked, but left me confused about how it really worked, and I couldn’t find out how they managed to find the magic bytes to make it work. To understand these things, I had to dig deeper.

Part 1: Spying on your headphones with WireShark #

HID (Human Interface Devices) is a specification that allows USB (and Bluetooth) devices to communicate in a standardized way. Because of HID, plugging in any keyboard / mouse just works without needing to install drivers for everything. It’s a pretty low-level protocol, where the communcation happens through sending messages that consist of raw bytes. Most programs that interact with HID are written my the manufactures of said device. Usually the bytes map to a common C struct that is shared in the device and driver code. Unfortunately, we don’t have access to this struct, only the raw bytes, so it will look almost like random garbage to us. Reverse engineering is the practice of finding meaningful information in that garbage.

Since my end goal is to be able to get the battery level from my HID device, I first need to figure out how exactly the SteelSeries GG app communicates with the device. There is a great program, Wireshark, which allows one to read USB traffic captured by another program called USBPcap. I installed both programs on my Windows machine and started to work on reverse-engineering the SteelSeries GG app.

I ran USBCap for ~20 seconds, turning the headset on and off and making sure that the battery status updated in the SteelSeries GG app. After opening the dump file (which I saved as 1.pcap) in Wireshark, I was bombarded with over 10 000 packets! How could I possibly find the ones I was looking for? As it turns out, I can filter just the pseudo-IP address of the device by reading the “GET DESCRIPTOR” responses. In my case, the device is at 1.1.x. I can use the disply filter usb.device_address == 1 to filter the packets.

The ‘idVendor’ field in the descriptor response has a value of 0x1038 in hexadecimal. There’s also ‘idProduct’ with a value of 0x2202. These values will become useful later.

The packets with the type “URB_ISOCHRONOUS” are used mostly by webcams or microphones (like the on my Arctis). I removed them with the following display filter: usb.device_address == 1 and not usb.transfer_type == "URB_ISOCHRONOUS". After that, it was only down to 78 packets! By looking at the remaining packets, I was able to figure out the process.

Querying the battery happens in two steps:

  1. The SteelSeries GG program writes an HID “Output report” (with report ID of 0) to the device, containing just the byte b0 as data:

  2. The SteelSeries GG program requests an HID “Input report” from the device (which shows up as URB_INTERRUPT in in WireShark). It contains the same byte 0xb0 along with other data, the first 3 of which are [0x03, 0x04, 0x03]. The second byte has been observed to be the battery status and the third to be the charging status. This meant that at the time of capture, the battery was at 100% and discharging, which I was able to confirm.

Basically, we have to somehow replicate sending the Input report and reading the data.

Step 2: Reading the battery status #

Now that we understand the basics, we can try talking to the device. There are libraries to interact with HID in almost any language, but I chose to use Rust and the hidapi crate, which is a wrapper for the signal11/hidapi C library.

> cargo new arctis_battery_icon
> cargo add hidapi
Adding hidapi v2.6.1 to dependencies.

Before writing or reading from a device, we first have to connect to it. Connecting to a device via HID requires two parts: a vendor ID and a product ID. In the first step, we found that for my headset, the Vendor ID is 0x1038 and the product ID is 0x2202. The vendor ID is actually the same for all SteelSeries devices. Here is a list of all vendor IDs. We can use the vendor ID to find all connected SteelSeries devices.

In src/main.rs:

fn main() {
	let mut api = hidapi::HidApi::new_without_enumerate().unwrap();
	// find all devices with vendor ID 0x1038 (SteelSeries)
	// product ID is set to 0 for no filter
	api.add_devices(0x1038, 0).expect("Failed to scan devices");

	for device in api.device_list() {
		if device.product_id() == 0x2202 && device.interface_number() == 3 {
			let headset: HidDevice = device.open_device(&api).unwrap();
		}
	}
}

Here we enumerate all devices with the 0x1038 vendor ID (with product ID set to 0), and iterate through them, looking for the right product ID. Notice that we also check the interface number: my headset has multiple interfaces, which show up as different devices. Only one of these actually responds with the information we want. If you don’t know the interface number, you can try looping through all of them until you get the right response.

Communicating with the device #

Now that we have a connection to the right device, we can try using it to read the battery status:

...

headset.write(&[0x00, 0xb0]).unwrap();

let mut buf = [0u8; 4];

let num_read = headset.read_timeout(&mut buf, 100).unwrap();

if num_read == 0 {
	println!("Timeout!");
} else {
	println!("Output: {:x?}", &buf); // print in hexadecimal format
}

First, we first write an “output report” to the device containing the bytes [0x0, 0xb0]. The first byte is the “report number”, we use the default report type of 0. The other byte (0xb0) was a magic byte we found in the WireShark part.

> cargo run -q
Output: [b0, 3, 4, 3]

In the output, the first byte is equal to 0xb0, which we just wrote to it. I don’t know what the second byte is, but the last 2 bytes are the battery and charging status. Unfortunately, the headset only sends the battery status in the range [0-4], with 4 being 100% charge. This means that a battery state of 2 could mean anything from 49% to 26%. The charging status will be 0 when not connected, 1 when charging and 3 when discharging.

With this knowledge: we can print some information about the battery status:

...

let battery = (buf[2] as f32 / 4.0) * 100.0;
println!("Battery: {battery}%");

let charging_status = match buf[3] {
	1 => "Charging",
	3 => "Discharging",
	_ => "Disconnected",
};
println!("Charging status: {charging_status}");
Battery: 100%
Charging status: Discharging

And that’s how you read the battery state from an Arctis Nova 7 headset! This method should work for all Arctis headphones, but other models will have different product IDs, interface numbers and message bytes.

Next, I used the tray-icon crate made by Tauri developers to make an icon on the taskbar / system tray, which shows the battery status:

Screenshot showing a battery icon on the Windows taskbar with the text “Arctis Nova 7: 50% - Connected”

I also added support for all known Arctis headphones using arctis-usb-finder’s device list. If you want to take a closer look at the code, It’s up on GitHub. It only supports Windows for now, although I imagine that supporting Linux would also be possible, since both hidapi and tray-icon are cross platform.

For the binary distribution method, I decided on a simple GitHub Actions workflow to build and package the executable into a zip archive along with a PowerShell installation script. PowerShell is actually surprisingly fun to write, as long as you have the PowerShell extension installed in VS Code. The script simply copies the executable into a new directory in %localAppData% and makes a shortcut in the Start Menu startup directory1.

It’s definitely not the best solution, but it should work fine. One problem I found is that Windows Defender thinks that my executable is actually a trojan called “Wacatac.B!ml” and removes it, if I try to download the release from GitHub. It seems that I’m not the only Rustacean with this problem. I submitted the release zip file to Microsoft but the submission is still pending after more than a week.

I might submit the program to the winget repository in the future. I would need to create a proper installer for it first though. The WiX Toolset seems to be the best option to create a simple MSI installer.


  1. %appData%\Microsoft\Windows\Start Menu\\Programs\\Startup\\ ↩︎