From 86908ab098c7cb247fe2f09ee60673e218b0cd2f Mon Sep 17 00:00:00 2001 From: tdhowe <4553348+tdhowe@users.noreply.github.com> Date: Mon, 2 Mar 2026 22:19:03 -0800 Subject: [PATCH 1/2] 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 --- Arctis_7_Plus_ChatMix.py | 106 +++++++++++++------ system-config/91-steelseries-arctis-7p.rules | 8 +- system-config/arctis7pcm.service | 5 +- 3 files changed, 75 insertions(+), 44 deletions(-) diff --git a/Arctis_7_Plus_ChatMix.py b/Arctis_7_Plus_ChatMix.py index ac40f78..9eaf8b6 100644 --- a/Arctis_7_Plus_ChatMix.py +++ b/Arctis_7_Plus_ChatMix.py @@ -27,6 +27,29 @@ import re import usb.core +# First byte of the chatmix interrupt +CHATMIX_OPCODE = 0x45 + +supported_devices = [ + { + "name": "Arctis 7P", + "idVendor": 0x1038, + "idProduct": 0x220e, + "hidIndex": 7 + }, + { + "name": "Nova 7 WOW Edition", + "idVendor": 0x1038, + "idProduct": 0x227a, + "hidIndex": 7 + }, + { + "name": "Arctis Nova 7 Gen 2", + "idVendor": 0x1038, + "idProduct": 0x227e, + "hidIndex": 8 + } +] class Arctis7PlusChatMix: def __init__(self): @@ -37,30 +60,12 @@ def __init__(self): self.log = self._init_log() self.log.info("Initializing ac7pcm...") - # identify the arctis 7+ device - try: - # Support both Arctis 7P (0x220e) and Nova 7 WOW Edition (0x227a) - self.dev = usb.core.find(idVendor=0x1038, idProduct=0x220e) or \ - usb.core.find(idVendor=0x1038, idProduct=0x227a) - except Exception as e: - self.log.error("""Failed to identify the Arctis 7+ device. - Please ensure it is connected.\n - Please note: This program only supports the '7+' or the Nova 7 WoW Edition models""") - self.die_gracefully(trigger ="Couldn't find arctis7 model") - - # select its interface and USB endpoint, and capture the endpoint address - try: - # interface index 7 of the Arctis 7+ is the USB HID for the ChatMix dial; - # its actual interface number on the device itself is 5. - self.interface = self.dev[0].interfaces()[7] - self.interface_num = self.interface.bInterfaceNumber - self.endpoint = self.interface.endpoints()[0] - self.addr = self.endpoint.bEndpointAddress + # get the default sink id from pactl + self.system_default_sink = os.popen("pactl get-default-sink").read().strip() + self.log.info(f"default sink identified as {self.system_default_sink}") - except Exception as e: - self.log.error("""Failure to identify relevant - USB device's interface or endpoint. Shutting down...""") - self.die_gracefully(exc=True, trigger ="identification of USB endpoint") + # Find a supported arctis device + self._bind_device() # detach if the device is active if self.dev.is_kernel_driver_active(self.interface_num): @@ -78,15 +83,45 @@ def _init_log(self): log.addHandler(stdout_handler) return (log) + + def _bind_device(self): + # Match on any devices which we have explicitly listed + def is_supported(dev): + return any(device_info["idVendor"] == dev.idVendor and device_info["idProduct"] == dev.idProduct for device_info in supported_devices) + + # TODO: multi-device support... for now we just bind to the first supported device we find + matched_device = usb.core.find(find_all=False, custom_match=is_supported) + + if not matched_device: + all_device_names = ", ".join([device_info["name"] for device_info in supported_devices]) + self.log.error(f"""Failed to identify the Arctis device. + Please ensure it is connected and supported.\n + Supported models: {all_device_names}.""") + self.die_gracefully(trigger="Couldn't find supported arctis model") + return + + self.dev = matched_device + device_info = next(d for d in supported_devices if d["idProduct"] == self.dev.idProduct) + self.log.info(f"Found supported device: {device_info['name']} ({hex(device_info['idVendor'])}:{hex(device_info['idProduct'])})") + + # select its interface and USB endpoint, and capture the endpoint address + try: + self.interface = self.dev[0].interfaces()[device_info["hidIndex"]] + self.interface_num = self.interface.bInterfaceNumber + self.endpoint = self.interface.endpoints()[0] + self.addr = self.endpoint.bEndpointAddress + + except Exception as e: + self.log.error("""Failure to identify relevant + USB device's interface or endpoint. Shutting down...""") + self.die_gracefully(exc=True, trigger ="identification of USB endpoint") + + def _init_VAC(self): """Get name of default sink, establish virtual sink and pipe its output to the default sink """ - # get the default sink id from pactl - self.system_default_sink = os.popen("pactl get-default-sink").read().strip() - self.log.info(f"default sink identified as {self.system_default_sink}") - # attempt to identify an Arctis sink via pactl try: pactl_short_sinks = os.popen("pactl list short sinks").readlines() @@ -184,15 +219,18 @@ def start_modulator_signal(self): while True: try: # read the input of the USB signal. Signal is sent in 64-bit interrupt packets. + # read_input[0] is the opcode. We only care about chatmix (0x45) # read_input[1] returns value to use for default device volume # read_input[2] returns the value to use for virtual device volume read_input = self.dev.read(self.addr, 64) - default_device_volume = "{}%".format(read_input[1]) - virtual_device_volume = "{}%".format(read_input[2]) - - # os.system calls to issue the commands directly to pactl - os.system(f'pactl set-sink-volume Arctis_Game {default_device_volume}') - os.system(f'pactl set-sink-volume Arctis_Chat {virtual_device_volume}') + if read_input[0] == CHATMIX_OPCODE: + default_device_volume = "{}%".format(read_input[1]) + virtual_device_volume = "{}%".format(read_input[2]) + + # os.system calls to issue the commands directly to pactl + os.system(f'pactl set-sink-volume Arctis_Game {default_device_volume}') + os.system(f'pactl set-sink-volume Arctis_Chat {virtual_device_volume}') + self.log.debug(f"Game Volume: {default_device_volume} | Chat Volume: {virtual_device_volume}") except usb.core.USBTimeoutError: pass except usb.core.USBError: @@ -223,7 +261,7 @@ def die_gracefully(self, sink_creation_fail=False, trigger=None): sys.exit(1) else: self.log.info("-"*45) - self.log.info("Artcis 7+ ChatMix shut down gracefully... Bye Bye!") + self.log.info("Arctis 7+ ChatMix shut down gracefully... Bye Bye!") self.log.info("-"*45) sys.exit(0) diff --git a/system-config/91-steelseries-arctis-7p.rules b/system-config/91-steelseries-arctis-7p.rules index 40f7c1f..67befe7 100644 --- a/system-config/91-steelseries-arctis-7p.rules +++ b/system-config/91-steelseries-arctis-7p.rules @@ -1,7 +1 @@ -# TODO: use envsubst to fill in the appropriate $USER in install.sh -SUBSYSTEM=="usb", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="220e", OWNER="${USER}", GROUP="${USER}", MODE="0664" -SUBSYSTEM=="usb", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="227a", OWNER="${USER}", GROUP="${USER}", MODE="0664" - -ACTION=="add", SUBSYSTEM=="usb", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="220e", TAG+="systemd", ENV{SYSTEMD_ALIAS}="/dev/arctis7" -ACTION=="add", SUBSYSTEM=="usb", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="227a", TAG+="systemd", ENV{SYSTEMD_ALIAS}="/dev/arctis7" -ACTION=="remove", SUBSYSTEM=="usb", ENV{PRODUCT}=="1038/220e/*", TAG+="systemd" +SUBSYSTEMS=="usb", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="220e|227a|227e", TAG+="uaccess", ENV{SYSTEMD_USER_WANTS}="arctis7pcm.service" \ No newline at end of file diff --git a/system-config/arctis7pcm.service b/system-config/arctis7pcm.service index 23a907c..8fe91f2 100644 --- a/system-config/arctis7pcm.service +++ b/system-config/arctis7pcm.service @@ -1,7 +1,6 @@ [Unit] Description=Arctis 7+ ChatMix -Requisite=dev-arctis7.device -After=dev-arctis7.device +After=pipewire.service pipewire-pulse.service StartLimitIntervalSec=1m StartLimitBurst=5 @@ -12,4 +11,4 @@ Restart=on-failure RestartSec=1 [Install] -WantedBy=dev-arctis7.device +WantedBy=default.target From d5d9b47e036341aa114061c3b65e41891cfb82d9 Mon Sep 17 00:00:00 2001 From: tdhowe <4553348+tdhowe@users.noreply.github.com> Date: Mon, 2 Mar 2026 22:25:50 -0800 Subject: [PATCH 2/2] Fix rules --- system-config/91-steelseries-arctis-7p.rules | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/system-config/91-steelseries-arctis-7p.rules b/system-config/91-steelseries-arctis-7p.rules index 67befe7..15c1d18 100644 --- a/system-config/91-steelseries-arctis-7p.rules +++ b/system-config/91-steelseries-arctis-7p.rules @@ -1 +1,8 @@ -SUBSYSTEMS=="usb", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="220e|227a|227e", TAG+="uaccess", ENV{SYSTEMD_USER_WANTS}="arctis7pcm.service" \ No newline at end of file +SUBSYSTEM=="usb", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="220e", OWNER="${USER}", GROUP="${USER}", MODE="0664" +SUBSYSTEM=="usb", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="227a", OWNER="${USER}", GROUP="${USER}", MODE="0664" +SUBSYSTEM=="usb", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="227e", OWNER="${USER}", GROUP="${USER}", MODE="0664" + +ACTION=="add", SUBSYSTEM=="usb", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="220e", TAG+="systemd", ENV{SYSTEMD_ALIAS}="/dev/arctis7" +ACTION=="add", SUBSYSTEM=="usb", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="227a", TAG+="systemd", ENV{SYSTEMD_ALIAS}="/dev/arctis7" +ACTION=="add", SUBSYSTEM=="usb", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="227e", TAG+="systemd", ENV{SYSTEMD_ALIAS}="/dev/arctis7" +ACTION=="remove", SUBSYSTEM=="usb", ENV{PRODUCT}=="1038/220e/*", TAG+="systemd" \ No newline at end of file