quickshell: headset battery next to mouse

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-19 09:52:06 +01:00
parent 434db62cd6
commit fa1ed741fc

View file

@ -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 "<pct>" or "<pct> 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"
}
}
''}