Skip to content

Commit 86908ab

Browse files
committed
Added support for Arctis Nova 7 gen2 + Added opcode filtering
- Reworked how it discovers devices a bit so it's a little more scalable if we add support for other devices in the future - Added opcode filtering on first word so other messages aren't interpreted as chatmix - Added debug logging for chatmix slider - Misc cleanup for rules and service registration
1 parent 9b4bf96 commit 86908ab

3 files changed

Lines changed: 75 additions & 44 deletions

File tree

Arctis_7_Plus_ChatMix.py

Lines changed: 72 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,29 @@
2727
import re
2828
import usb.core
2929

30+
# First byte of the chatmix interrupt
31+
CHATMIX_OPCODE = 0x45
32+
33+
supported_devices = [
34+
{
35+
"name": "Arctis 7P",
36+
"idVendor": 0x1038,
37+
"idProduct": 0x220e,
38+
"hidIndex": 7
39+
},
40+
{
41+
"name": "Nova 7 WOW Edition",
42+
"idVendor": 0x1038,
43+
"idProduct": 0x227a,
44+
"hidIndex": 7
45+
},
46+
{
47+
"name": "Arctis Nova 7 Gen 2",
48+
"idVendor": 0x1038,
49+
"idProduct": 0x227e,
50+
"hidIndex": 8
51+
}
52+
]
3053

3154
class Arctis7PlusChatMix:
3255
def __init__(self):
@@ -37,30 +60,12 @@ def __init__(self):
3760
self.log = self._init_log()
3861
self.log.info("Initializing ac7pcm...")
3962

40-
# identify the arctis 7+ device
41-
try:
42-
# Support both Arctis 7P (0x220e) and Nova 7 WOW Edition (0x227a)
43-
self.dev = usb.core.find(idVendor=0x1038, idProduct=0x220e) or \
44-
usb.core.find(idVendor=0x1038, idProduct=0x227a)
45-
except Exception as e:
46-
self.log.error("""Failed to identify the Arctis 7+ device.
47-
Please ensure it is connected.\n
48-
Please note: This program only supports the '7+' or the Nova 7 WoW Edition models""")
49-
self.die_gracefully(trigger ="Couldn't find arctis7 model")
50-
51-
# select its interface and USB endpoint, and capture the endpoint address
52-
try:
53-
# interface index 7 of the Arctis 7+ is the USB HID for the ChatMix dial;
54-
# its actual interface number on the device itself is 5.
55-
self.interface = self.dev[0].interfaces()[7]
56-
self.interface_num = self.interface.bInterfaceNumber
57-
self.endpoint = self.interface.endpoints()[0]
58-
self.addr = self.endpoint.bEndpointAddress
63+
# get the default sink id from pactl
64+
self.system_default_sink = os.popen("pactl get-default-sink").read().strip()
65+
self.log.info(f"default sink identified as {self.system_default_sink}")
5966

60-
except Exception as e:
61-
self.log.error("""Failure to identify relevant
62-
USB device's interface or endpoint. Shutting down...""")
63-
self.die_gracefully(exc=True, trigger ="identification of USB endpoint")
67+
# Find a supported arctis device
68+
self._bind_device()
6469

6570
# detach if the device is active
6671
if self.dev.is_kernel_driver_active(self.interface_num):
@@ -78,15 +83,45 @@ def _init_log(self):
7883
log.addHandler(stdout_handler)
7984
return (log)
8085

86+
87+
def _bind_device(self):
88+
# Match on any devices which we have explicitly listed
89+
def is_supported(dev):
90+
return any(device_info["idVendor"] == dev.idVendor and device_info["idProduct"] == dev.idProduct for device_info in supported_devices)
91+
92+
# TODO: multi-device support... for now we just bind to the first supported device we find
93+
matched_device = usb.core.find(find_all=False, custom_match=is_supported)
94+
95+
if not matched_device:
96+
all_device_names = ", ".join([device_info["name"] for device_info in supported_devices])
97+
self.log.error(f"""Failed to identify the Arctis device.
98+
Please ensure it is connected and supported.\n
99+
Supported models: {all_device_names}.""")
100+
self.die_gracefully(trigger="Couldn't find supported arctis model")
101+
return
102+
103+
self.dev = matched_device
104+
device_info = next(d for d in supported_devices if d["idProduct"] == self.dev.idProduct)
105+
self.log.info(f"Found supported device: {device_info['name']} ({hex(device_info['idVendor'])}:{hex(device_info['idProduct'])})")
106+
107+
# select its interface and USB endpoint, and capture the endpoint address
108+
try:
109+
self.interface = self.dev[0].interfaces()[device_info["hidIndex"]]
110+
self.interface_num = self.interface.bInterfaceNumber
111+
self.endpoint = self.interface.endpoints()[0]
112+
self.addr = self.endpoint.bEndpointAddress
113+
114+
except Exception as e:
115+
self.log.error("""Failure to identify relevant
116+
USB device's interface or endpoint. Shutting down...""")
117+
self.die_gracefully(exc=True, trigger ="identification of USB endpoint")
118+
119+
81120
def _init_VAC(self):
82121
"""Get name of default sink, establish virtual sink
83122
and pipe its output to the default sink
84123
"""
85124

86-
# get the default sink id from pactl
87-
self.system_default_sink = os.popen("pactl get-default-sink").read().strip()
88-
self.log.info(f"default sink identified as {self.system_default_sink}")
89-
90125
# attempt to identify an Arctis sink via pactl
91126
try:
92127
pactl_short_sinks = os.popen("pactl list short sinks").readlines()
@@ -184,15 +219,18 @@ def start_modulator_signal(self):
184219
while True:
185220
try:
186221
# read the input of the USB signal. Signal is sent in 64-bit interrupt packets.
222+
# read_input[0] is the opcode. We only care about chatmix (0x45)
187223
# read_input[1] returns value to use for default device volume
188224
# read_input[2] returns the value to use for virtual device volume
189225
read_input = self.dev.read(self.addr, 64)
190-
default_device_volume = "{}%".format(read_input[1])
191-
virtual_device_volume = "{}%".format(read_input[2])
192-
193-
# os.system calls to issue the commands directly to pactl
194-
os.system(f'pactl set-sink-volume Arctis_Game {default_device_volume}')
195-
os.system(f'pactl set-sink-volume Arctis_Chat {virtual_device_volume}')
226+
if read_input[0] == CHATMIX_OPCODE:
227+
default_device_volume = "{}%".format(read_input[1])
228+
virtual_device_volume = "{}%".format(read_input[2])
229+
230+
# os.system calls to issue the commands directly to pactl
231+
os.system(f'pactl set-sink-volume Arctis_Game {default_device_volume}')
232+
os.system(f'pactl set-sink-volume Arctis_Chat {virtual_device_volume}')
233+
self.log.debug(f"Game Volume: {default_device_volume} | Chat Volume: {virtual_device_volume}")
196234
except usb.core.USBTimeoutError:
197235
pass
198236
except usb.core.USBError:
@@ -223,7 +261,7 @@ def die_gracefully(self, sink_creation_fail=False, trigger=None):
223261
sys.exit(1)
224262
else:
225263
self.log.info("-"*45)
226-
self.log.info("Artcis 7+ ChatMix shut down gracefully... Bye Bye!")
264+
self.log.info("Arctis 7+ ChatMix shut down gracefully... Bye Bye!")
227265
self.log.info("-"*45)
228266
sys.exit(0)
229267

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1 @@
1-
# TODO: use envsubst to fill in the appropriate $USER in install.sh
2-
SUBSYSTEM=="usb", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="220e", OWNER="${USER}", GROUP="${USER}", MODE="0664"
3-
SUBSYSTEM=="usb", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="227a", OWNER="${USER}", GROUP="${USER}", MODE="0664"
4-
5-
ACTION=="add", SUBSYSTEM=="usb", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="220e", TAG+="systemd", ENV{SYSTEMD_ALIAS}="/dev/arctis7"
6-
ACTION=="add", SUBSYSTEM=="usb", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="227a", TAG+="systemd", ENV{SYSTEMD_ALIAS}="/dev/arctis7"
7-
ACTION=="remove", SUBSYSTEM=="usb", ENV{PRODUCT}=="1038/220e/*", TAG+="systemd"
1+
SUBSYSTEMS=="usb", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="220e|227a|227e", TAG+="uaccess", ENV{SYSTEMD_USER_WANTS}="arctis7pcm.service"

system-config/arctis7pcm.service

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
[Unit]
22
Description=Arctis 7+ ChatMix
3-
Requisite=dev-arctis7.device
4-
After=dev-arctis7.device
3+
After=pipewire.service pipewire-pulse.service
54
StartLimitIntervalSec=1m
65
StartLimitBurst=5
76

@@ -12,4 +11,4 @@ Restart=on-failure
1211
RestartSec=1
1312

1413
[Install]
15-
WantedBy=dev-arctis7.device
14+
WantedBy=default.target

0 commit comments

Comments
 (0)