From e18ee80a1aafd5761d1e91471d6e768eb6f47de3 Mon Sep 17 00:00:00 2001 From: rope Date: Mon, 19 Oct 2026 09:32:46 +0100 Subject: [PATCH] quickshell: keychron mouse battery in bar Co-Authored-By: Claude Opus 5.5 --- hosts/FredOS-Gaming.nix | 3 ++ settings/quickshell.nix | 99 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/hosts/FredOS-Gaming.nix b/hosts/FredOS-Gaming.nix index d7a11e8..fbef2a3 100644 --- a/hosts/FredOS-Gaming.nix +++ b/hosts/FredOS-Gaming.nix @@ -36,10 +36,13 @@ # 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. + # Keychron (receiver + wired mouse) likewise for the bar's mouse-battery + # readout; also lets the Keychron Launcher's WebHID reach it. 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" + KERNEL=="hidraw*", ATTRS{idVendor}=="3434", MODE="0666" ''; # Force heavy builds off the gaming PC onto the media server so nix updates diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 1ffbf1d..4741fdb 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -280,6 +280,45 @@ in sys.exit(0) sys.exit(1) ''; + # Keychron mouse battery, straight off the vendor HID interface + # (usage page 0xffc1) of the Ultra-Link receiver or a wired mouse. + # Report 0xb3 with cmd 0x06 asks for base info; the 0xb4 reply has + # battery at data byte 19 (low 7 bits %, bit 7 charging). + # Protocol lifted from the Keychron Launcher's WebHID bundle. + mouseBatteryScript = pkgs.writeScript "mouse-battery" '' + #!${pkgs.python3}/bin/python3 + """mouse-battery — prints "" or " charging".""" + import glob, os, select, sys, time + + def iface(): + for h in glob.glob("/sys/class/hidraw/hidraw*"): + try: + uevent = open(h + "/device/uevent").read() + desc = open(h + "/device/report_descriptor", "rb").read(3) + except OSError: + continue + if ":00003434:" in uevent and desc == b"\x06\xc1\xff": + return "/dev/" + os.path.basename(h) + return None + + dev = iface() + if not dev: + sys.exit(1) + fd = os.open(dev, os.O_RDWR) + os.write(fd, bytes([0xb3, 0x06]).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) + if len(pkt) > 20 and pkt[0] == 0xb4 and pkt[1] == 0x06: + pct = pkt[20] & 0x7f + if pct == 0: + break # mouse off / asleep + print(f"{pct} charging" if pkt[20] & 0x80 else pct) + 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 @@ -880,6 +919,7 @@ in readonly property string weatherFetch: "${weatherFetchScript}" readonly property string ssh: "${pkgs.openssh}/bin/ssh" readonly property string novaAnc: "${novaAncScript}" + readonly property string mouseBattery: "${mouseBatteryScript}" // 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" @@ -4065,6 +4105,65 @@ in } ''} + ${lib.optionalString (!isMacbook) '' + // Wireless mouse battery — polled off the Keychron + // receiver via the mouse-battery script. Hidden while the + // mouse is off/asleep or the receiver is unplugged. + Item { + id: mouseBatWidget + visible: level > 0 + width: Theme.barWidth + height: mouseBatCol.height + + 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(" "); + mouseBatWidget.level = parseInt(f[0]) || 0; + mouseBatWidget.charging = f[1] === "charging"; + } + } + } + + Timer { + interval: 60000 + repeat: true + running: true + triggeredOnStart: true + onTriggered: mouseBatProc.running = true + } + + Column { + id: mouseBatCol + anchors.horizontalCenter: parent.horizontalCenter + spacing: 0 + + SIcon { + anchors.horizontalCenter: parent.horizontalCenter + text: "mouse" + color: mouseBatWidget.tint + font.pixelSize: 16 + } + + SText { + anchors.horizontalCenter: parent.horizontalCenter + text: mouseBatWidget.level + color: mouseBatWidget.tint + font.pixelSize: 10 + } + } + } + ''} + // Quick settings — present on every host, so the shared // controls sit in the same place on all of them. Item {