1
0
Fork 0

Switch from pyusb to hidapi, update readme

This commit is contained in:
Ricardo 2025-07-10 13:42:42 +02:00
parent 3747fef22e
commit 57fdea9442
4 changed files with 190 additions and 190 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
__pycache__/
env/

133
README.md
View file

@ -1,14 +1,5 @@
# Arctis Nova Pro Wireless ChatMix on Linux
### In this document
1. [About this project](#about-this-project)
2. [Disclaimer](#disclaimer)
3. [My Findings](#my-findings)
4. [Usage](#usage)
5. [What's Next](#whats-next)
6. [License](#license)
## About this project
Some SteelSeries headsets have a feature called ChatMix where you can easily adjust game and chat audio volume on the headphones or dongle.
@ -17,11 +8,11 @@ In previous SteelSeries headsets ChatMix was always a hardware feature. It worke
In newer generations of their headsets however, in particular the Arctis Nova Pro Wireless, this feature was taken out of the hardware itself, and made into a feature of their audio software called Sonar.
Sonar of course only works on Windows (and requires a SteelSeries account!), so everyone using other platforms were shit out of luck, even though these headphones are far from cheap.
Sonar of course only works on Windows and requires a SteelSeries account.
Even though it is now a software feature, the hardware can still control it, but only when Sonar activated this feature on the base station. You can toggle between normal volume controls and ChatMix by pressing down on the volume dial on either the headset or base station.
I used ChatMix a lot on my previous SteelSeries headset, and I want to be able to use it on this one, so I started looking into how I can control 2 virtual PipeWire sinks with the ChatMix controls. This project is the result.
I wanted to be able to use ChatMix on linux, so I reverse engineered the communication between Sonar and the base station to control 2 virtual PipeWire sinks.
## Disclaimer
@ -29,76 +20,44 @@ THIS PROJECT HAS NO ASSOCIATION TO STEELSERIES, NOR IS IT IN ANY WAY SUPPORTED B
I AM NOT RESPONSIBLE FOR BRICKED/BROKEN DEVICES NOR DO I GUARANTEE IT WILL WORK FOR YOU.
USING ANYTHING IN THIS PROJECT *MIGHT* VOID YOUR WARRANTY AND IS AT YOUR OWN RISK
I started this project knowing nothing about USB (I still don't), so I might be overcomplicating everything. The scripts I created were quickly thrown together and don't really integrate with anything. I might try to improve this later, but I also might not, as it does work for me in it's current state.
## My findings
I started by installing SteelSeries GG and Sonar in a Windows 11 VM and passing through the base station USB device. On the (Linux) host I used Wireshark to see what happened when I enabled Sonar.
This device uses USB HID events to both configure and receive data from the base station. I identified which ones enabled what features and tried activating these on Linux using the /dev/hidraw* device. See `commands.sh` for more information.
I am on MCU firmware version `01.29.27` and DSP firmware version `00.03.82`.
### Protocol description
Again, I don't really know anything about USB, so I could be wrong about a lot of this, but this is the protocol as I understand it
See `nova.py` for a commented example implementation.
The controls and data output are on USB Interface 4 (`bInterfaceNumber=4`). This interface has 2 endpoint, 1 for sending data (`0x04`) and 1 for receiving data (`0x84`).
The HID Data messages are structured as follows:
- The first byte decides wether we are sending or receiving. `0x6` means we sent it, `0x7` means we received it from the base station
- The second byte specifies the parameter, eg. `73` (`0x49`) to enable ChatMix
- The next bytes contain the value for that parameter, some parameters use 1 byte, some use more.
- Because the message should be 64 bytes long, the unused bytes should all be `0`
These are the parameters I have found:
<br>
*(There are quite a few more parameters, like sidetone and other settings, I just haven't documented those yet.)*
| Option | Description | Parameters | Range | Notes |
| ------ | ----------- | ---------- | ----- | ----- |
| 73 | ChatMix State | - Boolean: State | 0-1 |
| 141 | Sonar Icon State | - Boolean: State | 0-1 | I think this only toggles the icon, but that could be wrong
| 37 | Volume Attenuation | - Integer: Attenuation | 0-56 | 0=max<br>56=mute |
| 69 | ChatMix Controls | - Integer: Game Volume<br>- Integer: Chat Volume | 0-100 |
| 46 | EQ Preset | - Integer: Preset | 0-18 | Preset 4 is the custom EQ profile
| 49 | Custom EQ Controls | - Integer: EQ Bar<br>- Integer: Value | 0-40 | On the base station the value ranges from -20 to 20
USING ANYTHING IN THIS PROJECT _MIGHT_ VOID YOUR WARRANTY AND IS AT YOUR OWN RISK.
## Usage
For this project I created a simple Python program to both enable the controls and use them to control 2 virtual sound devices.
### Dependencies
- Python 3 (I used 3.12, it may or may not work on older versions)
- PyUSB
- Python 3
- python-hidapi
- PipeWire
- pactl
On Fedora (assuming PipeWire is already setup) these can be installed with:
On Fedora these can be installed with:
```
sudo dnf install pulseaudio-utils python3 python3-pyusb
sudo dnf install pulseaudio-utils python3 python3-hidapi
```
On Debian based systems (like Ubuntu) these can be installed with:
On Debian based systems (like Ubuntu or Pop!_OS) these can be installed with:
```
sudo apt install pulseaudio-utils python3 python3-usb
sudo apt install pulseaudio-utils python3 python3-hid
```
### Install
Clone this repo and cd into it
```
git clone https://git.dymstro.nl/Dymstro/nova-chatmix-linux.git
cd nova-chatmix-linux
```
To be able to run the script as a non-root user, some udev rules need to be applied. This will allow regular users to access the base station USB device. It also starts the script when it gets plugged in (only when the systemd service is set up).
To be able to run the script as a non-root user, some udev rules need to be applied. This will allow regular users to access the base station USB device. It also starts the script when it gets plugged in (only when the systemd service is also set up).
Copy `50-nova-pro-wireless.rules` to `/etc/udev/rules.d` and reload udev rules:
```
sudo cp 50-nova-pro-wireless.rules /etc/udev/rules.d/
@ -107,6 +66,7 @@ sudo udevadm trigger
```
If you want to run this script on startup you can add and enable the systemd service
```
## The systemd service expects the script in .local/bin
# Create the folder if it doesn't exist
@ -126,8 +86,9 @@ systemctl --user enable nova-chatmix --now
### Run
You can now run the python script to use ChatMix.
You can now run the python script to use ChatMix.
This will create 2 virtual sound devices:
- NovaGame for game/general audio
- NovaChat for chat audio
@ -140,37 +101,41 @@ This command does not generate any output, but the Sonar icon should now be visi
To use ChatMix select NovaGame as your main audio output, and select NovaChat as the output in your voice chat software of choice.
ChatMix should now work. You can toggle between volume and ChatMix by pressing the dial.
### Use as a library
## Details
You should also be able to use nova.py as a library.
I started by installing SteelSeries GG and Sonar in a Windows 11 VM and passing through the base station USB device. On the Linux host I used Wireshark to see what happened when I enabled Sonar.
This device uses USB HID events to both configure and receive data from the base station. I identified which ones enabled what features and tried activating these on Linux using the /dev/hidraw\* device. See `commands.sh` for more information.
I am on MCU firmware version `01.29.27` and DSP firmware version `00.03.82`.
### Protocol description
See `nova.py` for a commented example implementation.
The controls and data output are on USB Interface 4 (`bInterfaceNumber=4`). This interface has 2 endpoint, 1 for sending data (`0x04`) and 1 for receiving data (`0x84`).
The HID Data messages are structured as follows:
- The first byte decides wether we are sending or receiving. `0x6` means we sent it, `0x7` means we received it from the base station
- The second byte specifies the parameter, eg. `73` (`0x49`) to enable ChatMix
- The next bytes contain the value for that parameter, some parameters use 1 byte, some use more.
- Because the message should be 64 bytes long, the unused bytes should all be `0`
These are the parameters I have found:
<br>
Example:
_(There are quite a few more parameters, like sidetone and other settings, I just haven't documented those yet.)_
```
from nova import NovaProWireless
nova = NovaProWireless()
nova.enable_sonar_icon()
nova.enable_chatmix()
# Output all received messages
nova.print_output(True)
```
## What's Next
Currently none of this is very polished, it should work, but that's about it. ~~I would like to make this a bit more integrated, so that it just works without having to run the python script every time.~~ I added a systemd service to automate starting the script.
There are also some other projects that try to make headset features work on linux, for example [HeadsetControl](https://github.com/Sapd/HeadsetControl). I might want to try and get some of this implemented in there.
I also want to figure out if this could be something that happens by default in PipeWire (or somewhere else). So that users of the headset don't need anything beside what already comes with their distribution.
While I'd like to do these things, I don't yet know if I will. Even in the current state of the project, it does work for me, so while I'd like for this to be a temporary solution, it might just stay like this.
If anyone else wants to work on these things, please do, I'd love to see it.
| Option | Description | Parameters | Range | Notes |
| ------ | ------------------ | ------------------------------------------------ | ----- | ----------------------------------------------------------- |
| 73 | ChatMix State | - Boolean: State | 0-1 |
| 141 | Sonar Icon State | - Boolean: State | 0-1 | I think this only toggles the icon, but that could be wrong |
| 37 | Volume Attenuation | - Integer: Attenuation | 0-56 | 0=max<br>56=mute |
| 69 | ChatMix Controls | - Integer: Game Volume<br>- Integer: Chat Volume | 0-100 |
| 46 | EQ Preset | - Integer: Preset | 0-18 | Preset 4 is the custom EQ profile |
| 49 | Custom EQ Controls | - Integer: EQ Bar<br>- Integer: Value | 0-40 | On the base station the value ranges from -20 to 20 |
## License

View file

@ -1,13 +1,13 @@
[Unit]
Description=This will enable ChatMix for the Steelseries Arctis Nova Pro Wireless
Description=Enable ChatMix for the Steelseries Arctis Nova Pro Wireless
After=pipewire.service pipewire-pulse.service
Wants=network-online.target
[Service]
Restart=no
Type=simple
ExecStartPre=/bin/sleep 1
ExecStart=/usr/bin/python3 %h/.local/bin/nova.py
ExecStart=%h/.local/bin/nova.py
Restart=on-failure
[Install]
WantedBy=default.target

239
nova.py
View file

@ -2,9 +2,52 @@
# Licensed under the 0BSD
from signal import SIGINT, SIGTERM, signal
from subprocess import Popen, check_output
from signal import signal, SIGINT, SIGTERM
from usb.core import find, USBTimeoutError, USBError
from hid import device
from hid import enumerate as hidenumerate
CMD_PACTL = "pactl"
CMD_PWLOOPBACK = "pw-loopback"
class ChatMix:
# Create virtual pipewire sinks
def __init__(self, output_sink: str, main_sink: str, chat_sink: str):
self.main_sink = main_sink
self.chat_sink = chat_sink
self.main_sink_process = self._create_virtual_sink(main_sink, output_sink)
self.chat_sink_process = self._create_virtual_sink(chat_sink, output_sink)
def set_main_volume(self, volume: int):
self._set_volume(self.main_sink, volume)
def set_chat_volume(self, volume: int):
self._set_volume(self.chat_sink, volume)
def set_volumes(self, main_volume: int, chat_volume: int):
self.set_main_volume(main_volume)
self.set_chat_volume(chat_volume)
def close(self):
self.main_sink_process.terminate()
self.chat_sink_process.terminate()
def _create_virtual_sink(self, name: str, output_sink: str) -> Popen:
return Popen(
[
CMD_PWLOOPBACK,
"-P",
output_sink,
"--capture-props=media.class=Audio/Sink",
"-n",
name,
]
)
def _set_volume(self, sink: str, volume: int):
Popen([CMD_PACTL, "set-sink-volume", f"input.{sink}", f"{volume}%"])
class NovaProWireless:
@ -15,11 +58,11 @@ class NovaProWireless:
# bInterfaceNumber
INTERFACE = 0x4
# bEndpointAddress
ENDPOINT_TX = 0x4 # EP 4 OUT
ENDPOINT_RX = 0x84 # EP 4 IN
# HID Message length
MSGLEN = 63
MSGLEN = 64 # Total USB packet is 128 bytes, data is last 64 bytes.
# Message read timeout
READ_TIMEOUT = 1000
# First byte controls data direction.
TX = 0x6 # To base station.
@ -28,29 +71,25 @@ class NovaProWireless:
# Second Byte
# This is a very limited list of options, you can control way more. I just haven't implemented those options (yet)
## As far as I know, this only controls the icon.
OPT_SONAR_ICON = 141
## Enabling this options enables the ability to switch between volume and ChatMix.
OPT_CHATMIX_ENABLE = 73
OPT_SONAR_ICON = 0x8D
## Enabling this option enables the ability to switch between volume and ChatMix.
OPT_CHATMIX_ENABLE = 0x49
## Volume controls, 1 byte
OPT_VOLUME = 37
OPT_VOLUME = 0x25
## ChatMix controls, 2 bytes show and control game and chat volume.
OPT_CHATMIX = 69
OPT_CHATMIX = 0x45
## EQ controls, 2 bytes show and control which band and what value.
OPT_EQ = 49
OPT_EQ = 0x31
## EQ preset controls, 1 byte sets and shows enabled preset. Preset 4 is the custom preset required for OPT_EQ.
OPT_EQ_PRESET = 46
OPT_EQ_PRESET = 0x2E
# PipeWire Names
## This is automatically detected, can be set manually by overriding this variable
PW_ORIGINAL_SINK = None
## String used to automatically select output sink
PW_OUTPUT_SINK_AUTODETECT = "SteelSeries_Arctis_Nova_Pro_Wireless"
## Names of virtual sound devices
PW_GAME_SINK = "NovaGame"
PW_CHAT_SINK = "NovaChat"
# PipeWire virtual sink processes
PW_LOOPBACK_GAME_PROCESS = None
PW_LOOPBACK_CHAT_PROCESS = None
# Keeps track of enabled features for when close() is called
CHATMIX_CONTROLS_ENABLED = False
SONAR_ICON_ENABLED = False
@ -58,129 +97,105 @@ class NovaProWireless:
# Stops processes when program exits
CLOSE = False
# Selects correct device, and makes sure we can control it
def __init__(self):
self.dev = find(idVendor=self.VID, idProduct=self.PID)
if self.dev is None:
raise ValueError("Device not found")
if self.dev.is_kernel_driver_active(self.INTERFACE):
self.dev.detach_kernel_driver(self.INTERFACE)
# Device not found error string
ERR_NOTFOUND = "Device not found"
# Takes a tuple of ints and turns it into bytes with the correct length padded with zeroes
def _create_msgdata(self, data: tuple[int]) -> bytes:
return bytes(data).ljust(self.MSGLEN, b"0")
# Selects correct device, and makes sure we can control it
def __init__(self, output_sink=None):
# Find HID device path
devpath = None
for hiddev in hidenumerate(self.VID, self.PID):
if hiddev["interface_number"] == self.INTERFACE:
devpath = hiddev["path"]
break
if not devpath:
raise DeviceNotFoundException
# Try to automatically detect output sink, this is skipped if output_sink is given
if not output_sink:
sinks = (
check_output([CMD_PACTL, "list", "sinks", "short"]).decode().split("\n")
)
for sink in sinks[:-1]:
sink_name = sink.split("\t")[1]
if self.PW_OUTPUT_SINK_AUTODETECT in sink_name:
output_sink = sink_name
self.dev = device()
self.dev.open_path(devpath)
self.dev.set_nonblocking(True)
self.output_sink = output_sink
# Enables/Disables chatmix controls
def set_chatmix_controls(self, state: bool):
assert self.dev, self.ERR_NOTFOUND
self.dev.write(
self.ENDPOINT_TX,
self._create_msgdata((self.TX, self.OPT_CHATMIX_ENABLE, int(state))),
)
self.CHATMIX_CONTROLS_ENABLED = state
# Enables/Disables Sonar Icon
def set_sonar_icon(self, state: bool):
assert self.dev, self.ERR_NOTFOUND
self.dev.write(
self.ENDPOINT_TX,
self._create_msgdata((self.TX, self.OPT_SONAR_ICON, int(state))),
)
self.SONAR_ICON_ENABLED = state
# Sets Volume
def set_volume(self, attenuation: int):
assert self.dev, self.ERR_NOTFOUND
self.dev.write(
self.ENDPOINT_TX,
self._create_msgdata((self.TX, self.OPT_VOLUME, attenuation)),
)
# Sets EQ preset
def set_eq_preset(self, preset: int):
assert self.dev, self.ERR_NOTFOUND
self.dev.write(
self.ENDPOINT_TX,
self._create_msgdata((self.TX, self.OPT_EQ_PRESET, preset)),
)
# Checks available sinks and select headset
def _detect_original_sink(self):
# If sink is set manually, skip auto detect
if self.PW_ORIGINAL_SINK:
return
sinks = check_output(["pactl", "list", "sinks", "short"]).decode().split("\n")
for sink in sinks:
print(sink)
name = sink.split("\t")[1]
if "SteelSeries_Arctis_Nova_Pro_Wireless" in name:
self.PW_ORIGINAL_SINK = name
break
# Creates virtual pipewire loopback sinks, and redirects them to the real headset sink
def _start_virtual_sinks(self):
self._detect_original_sink()
cmd = [
"pw-loopback",
"-P",
self.PW_ORIGINAL_SINK,
"--capture-props=media.class=Audio/Sink",
"-n",
]
self.PW_LOOPBACK_GAME_PROCESS = Popen(cmd + [self.PW_GAME_SINK])
self.PW_LOOPBACK_CHAT_PROCESS = Popen(cmd + [self.PW_CHAT_SINK])
def _remove_virtual_sinks(self):
self.PW_LOOPBACK_GAME_PROCESS.terminate()
self.PW_LOOPBACK_CHAT_PROCESS.terminate()
# ChatMix implementation
# Continuously read from base station and ignore everything but ChatMix messages (OPT_CHATMIX)
# The .read method times out and returns an error. This error is catched and basically ignored. Timeout can be configured, but not turned off (I think).
def chatmix(self):
self._start_virtual_sinks()
def chatmix_volume_control(self, chatmix: ChatMix):
assert self.dev, self.ERR_NOTFOUND
while not self.CLOSE:
try:
msg = self.dev.read(self.ENDPOINT_RX, self.MSGLEN)
if msg[1] != self.OPT_CHATMIX:
msg = self.dev.read(self.MSGLEN, self.READ_TIMEOUT)
if not msg or msg[1] is not self.OPT_CHATMIX:
continue
# 4th and 5th byte contain ChatMix data
gamevol = msg[2]
chatvol = msg[3]
# Set Volume using PulseAudio tools. Can be done with pure pipewire tools, but I didn't feel like it
cmd = ["pactl", "set-sink-volume"]
# Actually change volume. Everytime you turn the dial, both volumes are set to the correct level
Popen(cmd + [f"input.{self.PW_GAME_SINK}", f"{gamevol}%"])
Popen(cmd + [f"input.{self.PW_CHAT_SINK}", f"{chatvol}%"])
# Ignore timeout.
except USBTimeoutError:
continue
except USBError:
print("Device was probably disconnected, exiting..")
chatmix.set_volumes(gamevol, chatvol)
except OSError:
print("Device was probably disconnected, exiting.")
self.CLOSE = True
self._remove_virtual_sinks()
# Remove virtual sinks on exit
self._remove_virtual_sinks()
chatmix.close()
# Prints output from base station. `debug` argument enables raw output.
def print_output(self, debug: bool = False):
assert self.dev
while not self.CLOSE:
try:
msg = self.dev.read(self.ENDPOINT_RX, self.MSGLEN)
if debug:
print(msg)
match msg[1]:
case self.OPT_VOLUME:
print(f"Volume: -{msg[2]}")
case self.OPT_CHATMIX:
print(f"Game Volume: {msg[2]} - Chat Volume: {msg[3]}")
case self.OPT_EQ:
print(f"EQ: Bar: {msg[2]} - Value: {(msg[3] - 20) / 2}")
case self.OPT_EQ_PRESET:
print(f"EQ Preset: {msg[2]}")
case _:
print("Unknown Message")
except USBTimeoutError:
continue
msg = self.dev.read(self.MSGLEN, self.READ_TIMEOUT)
if debug:
print(msg)
match msg[1]:
case self.OPT_VOLUME:
print(f"Volume: -{msg[2]}")
case self.OPT_CHATMIX:
print(f"Game Volume: {msg[2]} - Chat Volume: {msg[3]}")
case self.OPT_EQ:
print(f"EQ: Bar: {msg[2]} - Value: {(msg[3] - 20) / 2}")
case self.OPT_EQ_PRESET:
print(f"EQ Preset: {msg[2]}")
case _:
print("Unknown Message")
# Terminates processes and disables features
def close(self, signum, frame):
@ -190,14 +205,32 @@ class NovaProWireless:
if self.SONAR_ICON_ENABLED:
self.set_sonar_icon(False)
# Takes a tuple of ints and turns it into bytes with the correct length padded with zeroes
def _create_msgdata(self, data: tuple[int, ...]) -> bytes:
return bytes(data).ljust(self.MSGLEN, b"\0")
class DeviceNotFoundException(Exception):
pass
# When run directly, just start the ChatMix implementation. (And activate the icon, just for fun)
if __name__ == "__main__":
nova = NovaProWireless()
nova.set_sonar_icon(True)
nova.set_chatmix_controls(True)
try:
nova = NovaProWireless()
nova.set_sonar_icon(state=True)
nova.set_chatmix_controls(state=True)
signal(SIGINT, nova.close)
signal(SIGTERM, nova.close)
signal(SIGINT, nova.close)
signal(SIGTERM, nova.close)
nova.chatmix()
assert nova.output_sink, "Output sink not set"
chatmix = ChatMix(
output_sink=nova.output_sink,
main_sink=nova.PW_GAME_SINK,
chat_sink=nova.PW_CHAT_SINK,
)
nova.chatmix_volume_control(chatmix=chatmix)
except DeviceNotFoundException:
print("Device not found, exiting.")