From 14cc56afc2de15b9524551888cb3630772836ba3 Mon Sep 17 00:00:00 2001 From: rope Date: Mon, 19 Oct 2026 12:04:05 +0100 Subject: [PATCH] quickshell: mouse battery as long-running watcher Co-Authored-By: Claude Opus 5.5 --- settings/quickshell.nix | 108 +++++++++++++++++++++++++++++----------- 1 file changed, 78 insertions(+), 30 deletions(-) diff --git a/settings/quickshell.nix b/settings/quickshell.nix index d79e907..ac133fb 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -293,12 +293,18 @@ in # 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. + # battery at data byte 19 (low 7 bits %, bit 7 charging). The mouse + # also pushes 0xb6 "monitor-base" reports (0xe2: connect, charging, + # pct) unprompted. Protocol lifted from the Keychron Launcher bundle. + # + # Long-running, not one-shot: queries go unanswered for long stretches + # (always after the KM switch re-plugs the receiver), so it keeps the + # device open, takes whichever report turns up, re-finds the node + # after a replug, and caches the last value in $XDG_RUNTIME_DIR. mouseBatteryScript = pkgs.writeScript "mouse-battery" '' #!${pkgs.python3}/bin/python3 - """mouse-battery — prints "" or " charging".""" - import glob, os, select, sys, time + """mouse-battery — streams "" / " charging" lines.""" + import glob, os, select, time def iface(): for h in glob.glob("/sys/class/hidraw/hidraw*"): @@ -311,23 +317,59 @@ in 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) + def reading(pkt): 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) + return pkt[20] & 0x7f, pkt[20] >> 7 + if len(pkt) > 5 and pkt[0] == 0xb6 and pkt[1] == 0xe2 and pkt[3] == 1: + return pkt[5], pkt[4] + return 0, 0 + + cache = os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "mouse-battery") + last = None + def emit(line): + global last + if line == last: + return + last = line + print(line, flush=True) + with open(cache, "w") as f: + f.write(line) + + try: + emit(open(cache).read().strip()) + except OSError: + pass + + while True: + dev = iface() + if not dev: + time.sleep(5) + continue + try: + fd = os.open(dev, os.O_RDWR) + except OSError: + time.sleep(5) + continue + nextq = 0 + try: + while True: + now = time.monotonic() + if now >= nextq: + os.write(fd, bytes([0xb3, 0x06]).ljust(64, b"\0")) + nextq = now + 60 + if not select.select([fd], [], [], nextq - now)[0]: + continue + pkt = os.read(fd, 64) + if not pkt: + raise OSError("receiver gone") + pct, charging = reading(pkt) + if pct: + emit(f"{pct} charging" if charging else str(pct)) + except OSError: + pass # receiver unplugged (KM switch) — find it again + finally: + os.close(fd) + time.sleep(1) ''; weatherFetchScript = pkgs.writeShellScript "weather-fetch" '' loc=$(${pkgs.curl}/bin/curl -sf --max-time 5 https://ipinfo.io/loc 2>/dev/null || true) @@ -5540,9 +5582,10 @@ in ${lib.optionalString (!isMacbook) '' // 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. + // long-running mouse-battery watcher (its receiver + // only answers sporadically), the Nova Pro headset + // via nova-anc, polled while the panel is up. Each + // row hides until its device has reported. Card { // Levels, not the rows' `visible`: that reads back // false while this card is hidden, so it'd latch. @@ -5552,13 +5595,21 @@ in Process { id: mouseBatProc command: [Commands.mouseBattery] - stdout: StdioCollector { - onStreamFinished: { - const f = text.trim().split(" "); + running: true + stdout: SplitParser { + onRead: data => { + const f = data.trim().split(" "); mouseBat.level = parseInt(f[0]) || 0; mouseBat.charging = f[1] === "charging"; } } + onRunningChanged: if (!running) mouseBatRestart.start() + } + + Timer { + id: mouseBatRestart + interval: 5000 + onTriggered: mouseBatProc.running = true } Process { @@ -5578,10 +5629,7 @@ in repeat: true triggeredOnStart: true running: qsDropdown.visible - onTriggered: { - mouseBatProc.running = true; - headsetBatProc.running = true; - } + onTriggered: headsetBatProc.running = true } BatteryRow {