diff --git a/flake.lock b/flake.lock index 1dafe8a..4ab7717 100644 --- a/flake.lock +++ b/flake.lock @@ -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", diff --git a/flake.nix b/flake.nix index ab400ff..ce78b5e 100644 --- a/flake.nix +++ b/flake.nix @@ -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" []; }; diff --git a/hosts/FredOS-Gaming.nix b/hosts/FredOS-Gaming.nix index b22e5f0..6ed6adf 100644 --- a/hosts/FredOS-Gaming.nix +++ b/hosts/FredOS-Gaming.nix @@ -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 diff --git a/settings/quickshell.nix b/settings/quickshell.nix index f2833da..3d9695e 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -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) } } }