From fa1ed741fc0a41e970165e05ea145d29c8c79be8 Mon Sep 17 00:00:00 2001 From: rope Date: Mon, 19 Oct 2026 09:52:06 +0100 Subject: [PATCH] quickshell: headset battery next to mouse Co-Authored-By: Claude Opus 5.5 --- settings/quickshell.nix | 157 ++++++++++++++++++++++++++-------------- 1 file changed, 101 insertions(+), 56 deletions(-) diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 0c14177..d79e907 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -233,9 +233,12 @@ in # 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. + # Battery is 0x06 in the same reply (0-8 scale); power 0x01 = off, + # 0x02 = cable charging (HeadsetControl's nova_pro_wireless driver). novaAncScript = pkgs.writeScript "nova-anc" '' #!${pkgs.python3}/bin/python3 - """nova-anc [off|transparent|on] — no arg prints current mode.""" + """nova-anc [off|transparent|on] — no arg prints current mode. + nova-anc battery — prints "" or " charging".""" import glob, os, select, sys, time MODES = {"off": 0, "transparent": 1, "on": 2} @@ -261,7 +264,8 @@ in sys.exit(1) fd = os.open(dev, os.O_RDWR) - if len(sys.argv) > 1: + battery = sys.argv[1:] == ["battery"] + if len(sys.argv) > 1 and not battery: os.write(fd, bytes([0x06, 0xbd, MODES[sys.argv[1]]]).ljust(64, b"\0")) print(sys.argv[1]) sys.exit(0) @@ -274,6 +278,12 @@ in 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 battery: + if pkt[0x0f] == 0x01: + break # headset off + pct = round(pkt[0x06] * 100 / 8) + print(f"{pct} charging" if pkt[0x0f] == 0x02 else pct) + sys.exit(0) if pkt[0x0f] != 0x08: break # headset off / in cradle -> report nothing print(NAMES.get(pkt[0x0a], "off")) @@ -1505,6 +1515,60 @@ in } } + // ── BatteryRow: icon + label on the left, battery glyph and + // percentage on the right, tinted by level. Hidden while + // `level` is 0 (device off or unreadable). + component BatteryRow: Item { + id: _br + property string icon + property string label + property int level: 0 + property bool charging: false + readonly property color tint: charging ? Theme.accent + : level <= 15 ? Theme.err + : level <= 30 ? Theme.warn + : Theme.base05 + visible: level > 0 + height: 20 + + Row { + spacing: 6 + anchors.verticalCenter: parent.verticalCenter + SIcon { + anchors.verticalCenter: parent.verticalCenter + text: _br.icon + font.pixelSize: 15 + } + SText { + anchors.verticalCenter: parent.verticalCenter + text: _br.label + font.weight: Font.Medium + } + } + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + SIcon { + anchors.verticalCenter: parent.verticalCenter + text: _br.charging ? "battery-charging" + : _br.level >= 75 ? "battery-full" + : _br.level >= 50 ? "battery-medium" + : _br.level >= 20 ? "battery-low" + : "battery" + color: _br.tint + font.pixelSize: 15 + } + SText { + anchors.verticalCenter: parent.verticalCenter + text: _br.level + "%" + color: _br.tint + font.pixelSize: 11 + } + } + } + // ── PillSlider: rounded track + fill + knob with a full-height // invisible hit area. `value` is 0..1; `moved(v)` fires on drag. // Knob diameter derives from the track, so the small app-stream @@ -5475,30 +5539,36 @@ in spacing: 8 ${lib.optionalString (!isMacbook) '' - // Wireless mouse battery, read off the Keychron - // receiver by the mouse-battery script. Polls only - // while the panel is up; hidden while the mouse is - // off/asleep or the receiver is unplugged. + // Peripheral batteries: the Keychron mouse via the + // mouse-battery script, the Nova Pro headset via + // nova-anc. Polls only while the panel is up; each + // row hides while its device is off/unplugged. Card { - id: mouseBatCard - visible: level > 0 + // Levels, not the rows' `visible`: that reads back + // false while this card is hidden, so it'd latch. + visible: mouseBat.level > 0 || headsetBat.level > 0 width: parent.width - property int level: 0 - property bool charging: false - property color tint: charging ? Theme.accent - : level <= 15 ? Theme.err - : level <= 30 ? Theme.warn - : Theme.base05 - Process { id: mouseBatProc command: [Commands.mouseBattery] stdout: StdioCollector { onStreamFinished: { const f = text.trim().split(" "); - mouseBatCard.level = parseInt(f[0]) || 0; - mouseBatCard.charging = f[1] === "charging"; + mouseBat.level = parseInt(f[0]) || 0; + mouseBat.charging = f[1] === "charging"; + } + } + } + + Process { + id: headsetBatProc + command: [Commands.novaAnc, "battery"] + stdout: StdioCollector { + onStreamFinished: { + const f = text.trim().split(" "); + headsetBat.level = parseInt(f[0]) || 0; + headsetBat.charging = f[1] === "charging"; } } } @@ -5508,49 +5578,24 @@ in repeat: true triggeredOnStart: true running: qsDropdown.visible - onTriggered: mouseBatProc.running = true + onTriggered: { + mouseBatProc.running = true; + headsetBatProc.running = true; + } } - Item { + BatteryRow { + id: mouseBat width: parent.width - height: 20 + icon: "mouse" + label: "Mouse" + } - Row { - spacing: 6 - anchors.verticalCenter: parent.verticalCenter - SIcon { - anchors.verticalCenter: parent.verticalCenter - text: "mouse" - font.pixelSize: 15 - } - SText { - anchors.verticalCenter: parent.verticalCenter - text: "Mouse" - font.weight: Font.Medium - } - } - - Row { - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - spacing: 4 - SIcon { - anchors.verticalCenter: parent.verticalCenter - text: mouseBatCard.charging ? "battery-charging" - : mouseBatCard.level >= 75 ? "battery-full" - : mouseBatCard.level >= 50 ? "battery-medium" - : mouseBatCard.level >= 20 ? "battery-low" - : "battery" - color: mouseBatCard.tint - font.pixelSize: 15 - } - SText { - anchors.verticalCenter: parent.verticalCenter - text: mouseBatCard.level + "%" - color: mouseBatCard.tint - font.pixelSize: 11 - } - } + BatteryRow { + id: headsetBat + width: parent.width + icon: "headphones" + label: "Headset" } } ''}