Added systemd user service and made script exit more gracefully
This commit is contained in:
parent
05146bf16e
commit
50255ba5df
3 changed files with 111 additions and 20 deletions
49
README.md
49
README.md
|
@ -78,10 +78,17 @@ For this project I created a simple Python program to both enable the controls a
|
||||||
- PipeWire
|
- PipeWire
|
||||||
- pactl
|
- pactl
|
||||||
|
|
||||||
On Fedora 39 (assuming PipeWire is already setup) these can be installed with:
|
On Fedora (assuming PipeWire is already setup) these can be installed with:
|
||||||
```
|
```
|
||||||
sudo dnf install pulseaudio-utils python3 python3-pyusb
|
sudo dnf install pulseaudio-utils python3 python3-pyusb
|
||||||
```
|
```
|
||||||
|
### 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.
|
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.
|
||||||
|
|
||||||
|
@ -93,12 +100,50 @@ sudo udevadm control --reload-rules
|
||||||
sudo udevadm trigger
|
sudo udevadm trigger
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Check if your audio device matches the one on line 49 of `nova.py`
|
||||||
|
```
|
||||||
|
pactl list sinks short
|
||||||
|
# The output should look something like this:
|
||||||
|
# 47 alsa_output.pci-0000_0c_00.4.iec958-stereo PipeWire s32le 2ch 48000Hz SUSPENDED
|
||||||
|
# 77 alsa_output.pci-0000_0a_00.1.hdmi-stereo PipeWire s32le 2ch 48000Hz SUSPENDED
|
||||||
|
# 92 alsa_output.usb-SteelSeries_Arctis_Nova_Pro_Wireless-00.iec958-stereo PipeWire s24le 2ch 48000Hz RUNNING
|
||||||
|
```
|
||||||
|
|
||||||
|
In `nova.py`:
|
||||||
|
```
|
||||||
|
## Lines 48-50
|
||||||
|
# PW_ORIGINAL_SINK = (
|
||||||
|
# "alsa_output.usb-SteelSeries_Arctis_Nova_Pro_Wireless-00.7.iec958-stereo" # Edit this line if needed
|
||||||
|
# )
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
mkdir -p ~/.local/bin
|
||||||
|
# Copy the script to the expected location
|
||||||
|
cp -i nova.py ~/.local/bin
|
||||||
|
|
||||||
|
# Create systemd user unit folder if it doesn't exist
|
||||||
|
mkdir -p ~/.config/systemd/user
|
||||||
|
# Install the service file
|
||||||
|
cp nova-chatmix.service ~/.config/systemd/user/
|
||||||
|
# Reload systemd configuration
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
# Enable and start the service
|
||||||
|
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:
|
This will create 2 virtual sound devices:
|
||||||
- NovaGame for game/general audio
|
- NovaGame for game/general audio
|
||||||
- NovaChat for chat audio
|
- NovaChat for chat audio
|
||||||
|
|
||||||
```
|
```
|
||||||
|
# You do not need to run this if you installed the systemd unit!
|
||||||
python nova.py
|
python nova.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -128,7 +173,7 @@ nova.print_output(True)
|
||||||
|
|
||||||
## What's Next
|
## 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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|
12
nova-chatmix.service
Normal file
12
nova-chatmix.service
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
[Unit]
|
||||||
|
Description=This will enable ChatMix for the Steelseries Arctis Nova Pro Wireless
|
||||||
|
After=pipewire.service pipewire-pulse.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Restart=no
|
||||||
|
Type=simple
|
||||||
|
ExecStart=%h/.local/bin/nova.py
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
70
nova.py
Normal file → Executable file
70
nova.py
Normal file → Executable file
|
@ -1,8 +1,10 @@
|
||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
|
|
||||||
# Licensed under the EUPL
|
# Licensed under the 0BSD
|
||||||
|
|
||||||
|
from subprocess import Popen
|
||||||
|
from signal import signal, SIGINT, SIGTERM
|
||||||
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from usb.core import find, USBTimeoutError
|
from usb.core import find, USBTimeoutError
|
||||||
|
|
||||||
|
@ -50,6 +52,18 @@ class NovaProWireless:
|
||||||
PW_GAME_SINK = "NovaGame"
|
PW_GAME_SINK = "NovaGame"
|
||||||
PW_CHAT_SINK = "NovaChat"
|
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
|
||||||
|
CHATMIX_ENABLED = False
|
||||||
|
|
||||||
|
# Stops processes when program exits
|
||||||
|
CLOSE = False
|
||||||
|
|
||||||
# Selects correct device, and makes sure we can control it
|
# Selects correct device, and makes sure we can control it
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.dev = find(idVendor=self.VID, idProduct=self.PID)
|
self.dev = find(idVendor=self.VID, idProduct=self.PID)
|
||||||
|
@ -62,18 +76,21 @@ class NovaProWireless:
|
||||||
def _create_msgdata(self, data: tuple[int]) -> bytes:
|
def _create_msgdata(self, data: tuple[int]) -> bytes:
|
||||||
return bytes(data).ljust(self.MSGLEN, b"0")
|
return bytes(data).ljust(self.MSGLEN, b"0")
|
||||||
|
|
||||||
# Enables chatmix
|
# Enables/Disables chatmix controls
|
||||||
def enable_chatmix(self):
|
def set_chatmix_controls(self, state: bool):
|
||||||
self.dev.write(
|
self.dev.write(
|
||||||
self.ENDPOINT_TX,
|
self.ENDPOINT_TX,
|
||||||
self._create_msgdata((self.TX, self.OPT_CHATMIX_ENABLE, 1)),
|
self._create_msgdata((self.TX, self.OPT_CHATMIX_ENABLE, int(state))),
|
||||||
)
|
)
|
||||||
|
self.CHATMIX_CONTROLS_ENABLED = state
|
||||||
|
|
||||||
# Enables Sonar Icon
|
# Enables/Disables Sonar Icon
|
||||||
def enable_sonar_icon(self):
|
def set_sonar_icon(self, state: bool):
|
||||||
self.dev.write(
|
self.dev.write(
|
||||||
self.ENDPOINT_TX, self._create_msgdata((self.TX, self.OPT_SONAR_ICON, 1))
|
self.ENDPOINT_TX,
|
||||||
|
self._create_msgdata((self.TX, self.OPT_SONAR_ICON, int(state))),
|
||||||
)
|
)
|
||||||
|
self.SONAR_ICON_ENABLED = state
|
||||||
|
|
||||||
# Sets Volume
|
# Sets Volume
|
||||||
def set_volume(self, attenuation: int):
|
def set_volume(self, attenuation: int):
|
||||||
|
@ -90,7 +107,7 @@ class NovaProWireless:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create virtual pipewire loopback sinks, and redirect them to the real headset sink
|
# Create virtual pipewire loopback sinks, and redirect them to the real headset sink
|
||||||
def _enable_virtual_sinks(self):
|
def _start_virtual_sinks(self):
|
||||||
cmd = [
|
cmd = [
|
||||||
"pw-loopback",
|
"pw-loopback",
|
||||||
"-P",
|
"-P",
|
||||||
|
@ -98,15 +115,16 @@ class NovaProWireless:
|
||||||
"--capture-props=media.class=Audio/Sink",
|
"--capture-props=media.class=Audio/Sink",
|
||||||
"-n",
|
"-n",
|
||||||
]
|
]
|
||||||
subprocess.Popen(cmd + [self.PW_GAME_SINK])
|
self.PW_LOOPBACK_GAME_PROCESS = Popen(cmd + [self.PW_GAME_SINK])
|
||||||
subprocess.Popen(cmd + [self.PW_CHAT_SINK])
|
self.PW_LOOPBACK_CHAT_PROCESS = Popen(cmd + [self.PW_CHAT_SINK])
|
||||||
|
|
||||||
# ChatMix implementation
|
# ChatMix implementation
|
||||||
# Continuously read from base station and ignore everything but ChatMix messages (OPT_CHATMIX)
|
# 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).
|
# 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):
|
def chatmix(self):
|
||||||
self._enable_virtual_sinks()
|
self._start_virtual_sinks()
|
||||||
while True:
|
self.CHATMIX_ENABLED = True
|
||||||
|
while not self.CLOSE:
|
||||||
try:
|
try:
|
||||||
msg = self.dev.read(self.ENDPOINT_RX, self.MSGLEN)
|
msg = self.dev.read(self.ENDPOINT_RX, self.MSGLEN)
|
||||||
if msg[1] != self.OPT_CHATMIX:
|
if msg[1] != self.OPT_CHATMIX:
|
||||||
|
@ -120,15 +138,15 @@ class NovaProWireless:
|
||||||
cmd = ["pactl", "set-sink-volume"]
|
cmd = ["pactl", "set-sink-volume"]
|
||||||
|
|
||||||
# Actually change volume. Everytime you turn the dial, both volumes are set to the correct level
|
# Actually change volume. Everytime you turn the dial, both volumes are set to the correct level
|
||||||
subprocess.Popen(cmd + [f"input.{self.PW_GAME_SINK}", f"{gamevol}%"])
|
Popen(cmd + [f"input.{self.PW_GAME_SINK}", f"{gamevol}%"])
|
||||||
subprocess.Popen(cmd + [f"input.{self.PW_CHAT_SINK}", f"{chatvol}%"])
|
Popen(cmd + [f"input.{self.PW_CHAT_SINK}", f"{chatvol}%"])
|
||||||
# Ignore timeout.
|
# Ignore timeout.
|
||||||
except USBTimeoutError:
|
except USBTimeoutError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Prints output from base station. `debug` argument enables raw output.
|
# Prints output from base station. `debug` argument enables raw output.
|
||||||
def print_output(self, debug: bool = False):
|
def print_output(self, debug: bool = False):
|
||||||
while True:
|
while not self.CLOSE:
|
||||||
try:
|
try:
|
||||||
msg = self.dev.read(self.ENDPOINT_RX, self.MSGLEN)
|
msg = self.dev.read(self.ENDPOINT_RX, self.MSGLEN)
|
||||||
if debug:
|
if debug:
|
||||||
|
@ -147,10 +165,26 @@ class NovaProWireless:
|
||||||
except USBTimeoutError:
|
except USBTimeoutError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Terminates processes and disables features
|
||||||
|
def close(self, signum, frame):
|
||||||
|
self.CLOSE = True
|
||||||
|
if self.CHATMIX_CONTROLS_ENABLED:
|
||||||
|
self.set_chatmix_controls(False)
|
||||||
|
if self.SONAR_ICON_ENABLED:
|
||||||
|
print("test")
|
||||||
|
self.set_sonar_icon(False)
|
||||||
|
if self.CHATMIX_ENABLED:
|
||||||
|
self.PW_LOOPBACK_GAME_PROCESS.terminate()
|
||||||
|
self.PW_LOOPBACK_CHAT_PROCESS.terminate()
|
||||||
|
|
||||||
|
|
||||||
# When run directly, just start the ChatMix implementation. (And activate the icon, just for fun)
|
# When run directly, just start the ChatMix implementation. (And activate the icon, just for fun)
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
nova = NovaProWireless()
|
nova = NovaProWireless()
|
||||||
nova.enable_sonar_icon()
|
nova.set_sonar_icon(True)
|
||||||
nova.enable_chatmix()
|
nova.set_chatmix_controls(True)
|
||||||
|
|
||||||
|
signal(SIGINT, nova.close)
|
||||||
|
signal(SIGTERM, nova.close)
|
||||||
|
|
||||||
nova.chatmix()
|
nova.chatmix()
|
||||||
|
|
Loading…
Reference in a new issue