gaming: drop arctis-sound-manager, ANC via direct HID script

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-07-28 19:35:25 +01:00
parent 027858ff1e
commit ee86678cb5
4 changed files with 77 additions and 63 deletions

23
flake.lock generated
View file

@ -1,27 +1,5 @@
{
"nodes": {
"arctis-sound-manager": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"dir": "nix",
"lastModified": 1785229953,
"narHash": "sha256-5oezrSj8OxNeMUN0IxjdKh4t2D9jn1BOainmBRNeXgE=",
"owner": "loteran",
"repo": "Arctis-Sound-Manager",
"rev": "aaa05fd62b7ac037d59802ce9c46b69c5bc84a33",
"type": "github"
},
"original": {
"dir": "nix",
"owner": "loteran",
"repo": "Arctis-Sound-Manager",
"type": "github"
}
},
"base16": {
"inputs": {
"fromYaml": "fromYaml"
@ -394,7 +372,6 @@
},
"root": {
"inputs": {
"arctis-sound-manager": "arctis-sound-manager",
"home-manager": "home-manager",
"nix-cachyos-kernel": "nix-cachyos-kernel",
"nixpkgs": "nixpkgs_2",

View file

@ -20,11 +20,6 @@
proton-cachyos-nix.url = "github:powerofthe69/proton-cachyos-nix";
arctis-sound-manager = {
url = "github:loteran/Arctis-Sound-Manager?dir=nix";
inputs.nixpkgs.follows = "nixpkgs";
};
stylix.url = "github:nix-community/stylix/release-26.05";
};
outputs =
@ -54,7 +49,7 @@
in
{
nixosConfigurations = {
FredOS-Gaming = mkHost "FredOS-Gaming" [ inputs.arctis-sound-manager.nixosModules.default ];
FredOS-Gaming = mkHost "FredOS-Gaming" [];
FredOS-Mediaserver = mkHost "FredOS-Mediaserver" [];
FredOS-Macbook = mkHost "FredOS-Macbook" [];
};

View file

@ -24,9 +24,14 @@
programs.geary.enable = true;
# SteelSeries Nova Pro control (ANC switching via quickshell talks to its
# asm-daemon over D-Bus)
services.arctis-sound-manager.enable = true;
# Nova Pro Wireless base station: open up its vendor HID interface so
# quickshell's ANC switcher can write to it directly (nova-anc script in
# settings/quickshell.nix). Single-user desktop, 0666 is fine.
services.udev.extraRules = ''
KERNEL=="hidraw*", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="12e0", MODE="0666"
KERNEL=="hidraw*", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="12e5", MODE="0666"
KERNEL=="hidraw*", ATTRS{idVendor}=="1038", ATTRS{idProduct}=="225d", MODE="0666"
'';
# Force heavy builds off the gaming PC onto the media server so nix updates
# never compete with games for CPU/RAM. Pairs with the distributed build

View file

@ -120,6 +120,58 @@ in
'';
# 7-day forecast JSON from Open-Meteo (no API key). Location is
# auto-detected by IP via ipinfo.io, falling back to London.
# Nova Pro Wireless ANC, no daemon: talk straight to the base station's
# vendor HID interface (iface 4). Report id 0x06 leads every 64-byte
# packet; opcode 0xbd sets ANC (0 off / 1 transparent / 2 on), 0xb0
# requests full status (anc @ 0x0a, power @ 0x0f, 0x08 = on-head).
# Protocol lifted from Arctis-Sound-Manager's nova_pro_wireless.yaml.
novaAncScript = pkgs.writeScript "nova-anc" ''
#!${pkgs.python3}/bin/python3
"""nova-anc [off|transparent|on] no arg prints current mode."""
import glob, os, select, sys, time
MODES = {"off": 0, "transparent": 1, "on": 2}
NAMES = {0: "off", 1: "transparent", 2: "on"}
PIDS = {0x12e0, 0x12e5, 0x225d}
def dongle():
for h in glob.glob("/sys/class/hidraw/hidraw*"):
try:
uevent = open(h + "/device/uevent").read()
iface = open(h + "/device/../bInterfaceNumber").read().strip()
except OSError:
continue
for line in uevent.splitlines():
if line.startswith("HID_ID="):
_, vid, pid = line.split("=")[1].split(":")
if int(vid, 16) == 0x1038 and int(pid, 16) in PIDS and iface == "04":
return "/dev/" + os.path.basename(h)
return None
dev = dongle()
if not dev:
sys.exit(1)
fd = os.open(dev, os.O_RDWR)
if len(sys.argv) > 1:
os.write(fd, bytes([0x06, 0xbd, MODES[sys.argv[1]]]).ljust(64, b"\0"))
print(sys.argv[1])
sys.exit(0)
os.write(fd, bytes([0x06, 0xb0]).ljust(64, b"\0"))
deadline = time.time() + 1.0
while (left := deadline - time.time()) > 0:
if not select.select([fd], [], [], left)[0]:
break
pkt = os.read(fd, 64)
# Skip unsolicited 0x07xx event packets until the status reply
if len(pkt) > 0x0f and pkt[0] == 0x06 and pkt[1] == 0xb0:
if pkt[0x0f] != 0x08:
break # headset off / in cradle -> report nothing
print(NAMES.get(pkt[0x0a], "off"))
sys.exit(0)
sys.exit(1)
'';
weatherFetchScript = pkgs.writeShellScript "weather-fetch" ''
loc=$(${pkgs.curl}/bin/curl -sf --max-time 5 https://ipinfo.io/loc 2>/dev/null || true)
case "$loc" in
@ -418,7 +470,7 @@ in
readonly property string systemctl: "${pkgs.systemd}/bin/systemctl"
readonly property string weatherFetch: "${weatherFetchScript}"
readonly property string ssh: "${pkgs.openssh}/bin/ssh"
readonly property string gdbus: "${pkgs.glib}/bin/gdbus"
readonly property string novaAnc: "${novaAncScript}"
// Unprivileged ping works via ICMP dgram sockets NixOS sets
// net.ipv4.ping_group_range for everyone (no wrapper exists).
readonly property string ping: "${pkgs.iputils}/bin/ping"
@ -2934,51 +2986,36 @@ in
}
${lib.optionalString (!isMacbook) ''
// Nova Pro noise cancelling talks to asm-daemon
// (Arctis Sound Manager) over the session bus.
// SetSetting("noise_cancelling", "0|1|2"), status read
// back as off/transparent/on. Card hides when the
// daemon or headset is gone.
// Nova Pro noise cancelling writes straight to the
// base station's HID interface via the nova-anc
// script (no daemon). Card hides when the dongle or
// headset is gone.
Card {
id: ancCard
visible: ancMode !== ""
width: parent.width
property string ancMode: ""
function setMode(key, value) {
function setMode(key) {
if (key === ancMode) return;
ancMode = key; // optimistic; next poll corrects if the device disagrees
ancSet.mode = value;
ancSet.mode = key;
ancSet.running = true;
}
Process {
id: ancSet
property string mode: "0"
command: [Commands.gdbus, "call", "--session",
"--dest", "name.giacomofurlan.ArctisManager.Next",
"--object-path", "/name/giacomofurlan/ArctisManager/Next/Settings",
"--method", "name.giacomofurlan.ArctisManager.Next.Settings.SetSetting",
"noise_cancelling", mode]
property string mode: "off"
command: [Commands.novaAnc, mode]
}
Process {
id: ancStatus
command: [Commands.gdbus, "call", "--session",
"--dest", "name.giacomofurlan.ArctisManager.Next",
"--object-path", "/name/giacomofurlan/ArctisManager/Next/Status",
"--method", "name.giacomofurlan.ArctisManager.Next.Status.GetStatus"]
command: [Commands.novaAnc]
stdout: StdioCollector {
onStreamFinished: {
// gdbus prints a GVariant tuple: ('{"headset": ...}',)
const a = text.indexOf("('");
const b = text.lastIndexOf("',");
if (a < 0 || b <= a) { ancCard.ancMode = ""; return; }
try {
const st = JSON.parse(text.slice(a + 2, b));
const nc = st.headset ? st.headset.noise_cancelling : undefined;
ancCard.ancMode = (nc && nc.value) ? nc.value : "";
} catch (e) { ancCard.ancMode = ""; }
const m = text.trim();
ancCard.ancMode = (m === "off" || m === "transparent" || m === "on") ? m : "";
}
}
}
@ -3010,9 +3047,9 @@ in
Repeater {
model: [
{ key: "off", label: "Off", val: "0" },
{ key: "transparent", label: "Transparent", val: "1" },
{ key: "on", label: "ANC", val: "2" }
{ key: "off", label: "Off" },
{ key: "transparent", label: "Transparent" },
{ key: "on", label: "ANC" }
]
Rectangle {
@ -3035,7 +3072,7 @@ in
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: ancCard.setMode(modelData.key, modelData.val)
onClicked: ancCard.setMode(modelData.key)
}
}
}