quickshell: mouse battery as long-running watcher
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
662e4c242f
commit
14cc56afc2
1 changed files with 78 additions and 30 deletions
|
|
@ -293,12 +293,18 @@ in
|
||||||
# Keychron mouse battery, straight off the vendor HID interface
|
# Keychron mouse battery, straight off the vendor HID interface
|
||||||
# (usage page 0xffc1) of the Ultra-Link receiver or a wired mouse.
|
# (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
|
# Report 0xb3 with cmd 0x06 asks for base info; the 0xb4 reply has
|
||||||
# battery at data byte 19 (low 7 bits %, bit 7 charging).
|
# battery at data byte 19 (low 7 bits %, bit 7 charging). The mouse
|
||||||
# Protocol lifted from the Keychron Launcher's WebHID bundle.
|
# 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" ''
|
mouseBatteryScript = pkgs.writeScript "mouse-battery" ''
|
||||||
#!${pkgs.python3}/bin/python3
|
#!${pkgs.python3}/bin/python3
|
||||||
"""mouse-battery — prints "<pct>" or "<pct> charging"."""
|
"""mouse-battery — streams "<pct>" / "<pct> charging" lines."""
|
||||||
import glob, os, select, sys, time
|
import glob, os, select, time
|
||||||
|
|
||||||
def iface():
|
def iface():
|
||||||
for h in glob.glob("/sys/class/hidraw/hidraw*"):
|
for h in glob.glob("/sys/class/hidraw/hidraw*"):
|
||||||
|
|
@ -311,23 +317,59 @@ in
|
||||||
return "/dev/" + os.path.basename(h)
|
return "/dev/" + os.path.basename(h)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
dev = iface()
|
def reading(pkt):
|
||||||
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:
|
if len(pkt) > 20 and pkt[0] == 0xb4 and pkt[1] == 0x06:
|
||||||
pct = pkt[20] & 0x7f
|
return pkt[20] & 0x7f, pkt[20] >> 7
|
||||||
if pct == 0:
|
if len(pkt) > 5 and pkt[0] == 0xb6 and pkt[1] == 0xe2 and pkt[3] == 1:
|
||||||
break # mouse off / asleep
|
return pkt[5], pkt[4]
|
||||||
print(f"{pct} charging" if pkt[20] & 0x80 else pct)
|
return 0, 0
|
||||||
sys.exit(0)
|
|
||||||
sys.exit(1)
|
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" ''
|
weatherFetchScript = pkgs.writeShellScript "weather-fetch" ''
|
||||||
loc=$(${pkgs.curl}/bin/curl -sf --max-time 5 https://ipinfo.io/loc 2>/dev/null || true)
|
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) ''
|
${lib.optionalString (!isMacbook) ''
|
||||||
// Peripheral batteries: the Keychron mouse via the
|
// Peripheral batteries: the Keychron mouse via the
|
||||||
// mouse-battery script, the Nova Pro headset via
|
// long-running mouse-battery watcher (its receiver
|
||||||
// nova-anc. Polls only while the panel is up; each
|
// only answers sporadically), the Nova Pro headset
|
||||||
// row hides while its device is off/unplugged.
|
// via nova-anc, polled while the panel is up. Each
|
||||||
|
// row hides until its device has reported.
|
||||||
Card {
|
Card {
|
||||||
// Levels, not the rows' `visible`: that reads back
|
// Levels, not the rows' `visible`: that reads back
|
||||||
// false while this card is hidden, so it'd latch.
|
// false while this card is hidden, so it'd latch.
|
||||||
|
|
@ -5552,13 +5595,21 @@ in
|
||||||
Process {
|
Process {
|
||||||
id: mouseBatProc
|
id: mouseBatProc
|
||||||
command: [Commands.mouseBattery]
|
command: [Commands.mouseBattery]
|
||||||
stdout: StdioCollector {
|
running: true
|
||||||
onStreamFinished: {
|
stdout: SplitParser {
|
||||||
const f = text.trim().split(" ");
|
onRead: data => {
|
||||||
|
const f = data.trim().split(" ");
|
||||||
mouseBat.level = parseInt(f[0]) || 0;
|
mouseBat.level = parseInt(f[0]) || 0;
|
||||||
mouseBat.charging = f[1] === "charging";
|
mouseBat.charging = f[1] === "charging";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
onRunningChanged: if (!running) mouseBatRestart.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: mouseBatRestart
|
||||||
|
interval: 5000
|
||||||
|
onTriggered: mouseBatProc.running = true
|
||||||
}
|
}
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
|
|
@ -5578,10 +5629,7 @@ in
|
||||||
repeat: true
|
repeat: true
|
||||||
triggeredOnStart: true
|
triggeredOnStart: true
|
||||||
running: qsDropdown.visible
|
running: qsDropdown.visible
|
||||||
onTriggered: {
|
onTriggered: headsetBatProc.running = true
|
||||||
mouseBatProc.running = true;
|
|
||||||
headsetBatProc.running = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
BatteryRow {
|
BatteryRow {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue