Makes a wireless gaming headset show up in KDE's Power & Battery applet, next to the mouse and the keyboard. The battery level comes from HeadsetControl, or — for the Audeze Maxwell — from a native reader that is far more reliable.
No tray icon, no applet to install: the headset becomes a real UPower device, so
anything that reads UPower — Plasma, GNOME, upower -d, a status bar — sees it.
UPower does not accept battery devices over D-Bus; it only reports what the
kernel publishes under /sys/class/power_supply. So instead of talking to
UPower, this daemon talks to the kernel:
headsetcontrol -b -o json /dev/uhid /sys/class/power_supply
┌───────────────────────┐ poll ┌──────────────────────┐ ┌─────────────────────────┐
│ Audeze Maxwell 73 % │ ───────▶ │ headset-battery- │──▶ │ hid-headset-3329-4b18- │
│ (vendor HID report) │ 60 s │ indicator │ │ battery (capacity=73) │
└───────────────────────┘ └──────────────────────┘ └────────────┬────────────┘
creates a virtual HID │ udev
device whose descriptor ▼
declares a battery ┌───────────────┐
│ UPower │
└───────┬───────┘
▼
KDE Power & Battery
The virtual device's report descriptor declares Battery Strength (Generic
Device Controls page, usage 0x20) and Charging (Battery System page, usage
0x44). That is all drivers/hid/hid-input.c needs to register a
power_supply object with scope=Device, which UPower exposes as a peripheral
battery.
Three details are worth knowing, all checked against the kernel source:
- The descriptor's top-level collection must be an input application
(
IS_INPUT_APPLICATION). With a vendor-defined collection,hidinput_connect()returns before looking at any field, and the device gets a hidraw node and nothing else — no battery. It is declared as Consumer Control, which is what the real dongle declares too. - A HID device that declares only a battery is torn down again —
hidinput_connect()bails out with "No inputs registered, leaving" and removes the battery with it. The descriptor therefore ends with a single vendor-defined input bit, which the kernel maps toBTN_MISC. That bit is never set, and it is outside every range systemd'sinput_idbuiltin looks at, so udev does not tag the node as a keyboard or a pointer and UPower keeps reporting a plain battery rather than mislabelling the headset. - The device is created on the virtual bus (
BUS_VIRTUAL) with the real vendor and product IDs. Device-specific kernel HID drivers all match on a physical bus, so onlyhid-genericbinds to it.
UPower types a HID battery after its sibling nodes: a mouse if one of them is
tagged ID_INPUT_MOUSE, and so on. There is no input class for headsets; the
only way to the headset kind is a sibling carrying SOUND_INITIALIZED=1 and
SOUND_FORM_FACTOR=headset, the properties systemd puts on sound cards. UPower
accepts an input node as that sibling, and the virtual device has one, so the
generated udev rule tags it. Without the rule everything still works, but the
desktop draws a generic battery - which reads as a laptop battery.
--backend auto (the default) uses the native reader when it recognises the
hardware and falls back to HeadsetControl otherwise. --backend native and
--backend headsetcontrol force one or the other.
| Backend | Hardware | Notes |
|---|---|---|
native |
Audeze Maxwell (3329:4b18, 3329:4b19) |
Listens to the dongle; reacts within a second to the headset coming or going. HeadsetControl does not need to be installed. |
headsetcontrol |
everything HeadsetControl supports | Shells out to headsetcontrol --battery --output json. |
HeadsetControl reads the Maxwell's battery unreliably — it reports
BATTERY_UNAVAILABLE while music is playing on the headset, and the odd 0%
or 44% in between. Its driver replays a twenty-packet sequence and looks for
the battery answer in the buffer of a different request, one frame later, so
a dongle that is a few milliseconds late is read as "unavailable". It also
matches d6 0c 00 00 anywhere in the frame, which hits the dongle's
acknowledgement as well as its answer.
The dongle's input report is really a stream of small messages,
05 <type> <len> 00 <payload>:
05 5b 03 00 d6 0c 00 acknowledgement of request d6 0c
05 5d 05 00 d6 0c 00 00 5b answer: 0x5b = 91 %
The native reader matches whole message headers - the answer (type 5d), not
the acknowledgement that echoes the same command - and rejects any level above
100. Measured side by side on a Maxwell Xbox dongle: 60 reads out of 60, against
intermittent failures and 2.7 s per read for HeadsetControl. It does not poll
the headset, though; see below.
Two more things the frames taught us. The report is a buffer the dongle fills from the start, and its second byte counts the bytes written since it was last fetched; everything past that is left over from earlier exchanges, old answers included. Reading without that count returns a stale level for ever once the headset is switched off.
And charging is not in there at all: every register the dongle answers a read
for was compared plugged and unplugged, and only the level moved. What changes
is the USB bus - on a cable to the computer the headset enumerates as a device
of its own (3329:4b1e, Audeze Maxwell XBOX Headset), and that presence is
what gets reported as charging. A headset charging from a wall adapter is
therefore invisible, and keeps reading as discharging.
The answer is only available through a GET_REPORT control transfer
(HIDIOCGINPUT); the dongle never pushes it on the interrupt endpoint, so a
plain read() on the hidraw node sees nothing.
- Linux with
CONFIG_UHIDandCONFIG_HID_BATTERY_STRENGTH(Fedora, Arch, Ubuntu and friends all ship both). - An Audeze Maxwell, or HeadsetControl
4.x on
PATHwith a supported headset that has thebatterycapability. - UPower (any desktop that shows peripheral batteries).
- Rust 1.85 or newer to build.
cargo build --release
sudo ./install.shinstall.sh installs the binary to /usr/local/bin, creates the
headset-battery system group, generates the udev rule for the headsets it
currently detects, installs the systemd unit and starts it.
Remove everything with sudo ./install.sh --uninstall.
Then check the result:
systemctl status headset-battery-indicator.service
upower -d | grep -B2 -A8 -i headsetheadset-battery-indicator [OPTIONS] [COMMAND]
Commands:
run Run the daemon (default)
status Print what HeadsetControl currently reports, then exit
udev-rules Print a udev rule granting a group access to the detected headsets
Options:
--backend <BACKEND> auto, native or headsetcontrol [default: auto]
--headsetcontrol <PATH> Path to the headsetcontrol binary [default: headsetcontrol]
--timeout <SECONDS> How long to wait for headsetcontrol [default: 10]
-i, --interval <SECONDS> Delay between two readings through headsetcontrol [default: 60]
--native-interval <SECONDS>
How often the native reader asks a linked headset for its level [default: 60]
--offline-grace <SECONDS>
How long a detected but silent headset keeps its entry [default: 10]
--missing-grace <SECONDS>
How long an undetected headset keeps its entry [default: 30]
--uhid <PATH> Path of the uhid character device [default: /dev/uhid]
-v, --verbose... -v for debug, -vv for trace
RUST_LOG is honoured too, if you want finer filtering than -v.
With the native reader the entry follows the headset to the second, because the dongle says when the headset comes and goes and the daemon listens for it:
| Situation | What happens |
|---|---|
| Headset switched off | The dongle announces it. The entry is withdrawn once the announcement has held for 3 s, so a link that drops for a second while a freshly powered headset settles does not make it blink. |
| Headset switched on | The dongle announces it and volunteers the level: the entry is back within about a second. |
| Dongle unplugged, or a reader failing outright | Withdrawn after --missing-grace (30 s). A single warning per failure streak. |
| Headset silent without the dongle saying why | Withdrawn after --offline-grace (10 s), counted from the first lost reading so that a few retries fit in. |
| Service stopped or restarted | Every virtual battery is destroyed, so no stale entry is left behind. |
Through HeadsetControl there are no announcements: the headset is polled every
--interval (60 s), a reading that goes unanswered is retried every 3 s, and
the graces above apply.
It fetches the dongle's report once a second - a control transfer to the dongle,
nothing goes over the air - and only asks for the level while the headset is
linked (every --native-interval, 60 s), plus at most three times per opened
node to learn where things stand.
That restraint is not politeness. An earlier version asked every ten seconds whether or not the headset was there. After an hour with the headset off - some 320 unanswered requests - the dongle's command channel was dead: audio still worked, but it answered no request at all, not even those addressed to the dongle itself, and only unplugging it brought it back. Requests for an absent headset most likely pile up in the dongle; whatever the cause, not sending them is the cure. As a second line of defence the reader stops asking when a headset that is announced as linked leaves three requests unanswered, and says so in the journal.
The dongle also re-enumerates on USB a second or two after every link change, so its hidraw node vanishes and comes back. The daemon reopens it, carries what it knew across, and does not fall back to HeadsetControl for that gap.
Wireless dongles hand out the occasional bogus frame. An Audeze Maxwell will
answer 0% or 44% between two 92% readings, and a spurious 0% is enough
to make the desktop announce a critical battery.
So a reading more than 15 points away from the last published level is held back, and only published if the next poll confirms it (within 5 points). No headset moves that far in one interval, so such a jump is either noise — which never reaches UPower — or a real change, such as a machine coming back from a night of sleep, which costs one extra poll before it shows up.
Small moves are logged at debug level rather than info, so a headset hovering
between 91% and 92% does not fill the journal; journalctl -u headset-battery-indicator -f with -v in ExecStart= shows everything.
/dev/uhid is what this daemon needs, and a process holding it can create
arbitrary input devices — including a keyboard. The unit is built so that
nobody is granted access to the node:
- the node keeps its
root:root 0600permissions — no udev rule widens it; - systemd opens it (
OpenFile=/dev/uhid:uhid) and passes the descriptor to the service, which adopts it through thesd_listen_fds()protocol; - the daemon runs as a
DynamicUser=, with an empty capability bounding set, no network, a read-only view of the filesystem, andDevicePolicy=closedexcept for hidraw; - the only other device it reaches is the headset's own
hidrawnode, through theheadset-batterygroup set by the generated udev rule.
The daemon denies unsafe code outside one documented spot, the HIDIOCGINPUT
ioctl of the native reader. Everything to do with /dev/uhid - the wire format,
adopting the descriptor systemd passes, checking that it really is the uhid
device before writing into it, marking it close-on-exec so the headsetcontrol
child never inherits it - lives in
uhid-battery, shared with
razerd.
cargo test # unit + CLI tests, no hardware needed
cargo clippy --all-targets -- -D warnings
cargo fmt --all --checkThe CLI tests drive the binary against a stub headsetcontrol, so they run
anywhere. What talks to the real kernel - creating a virtual battery and reading
it back from sysfs - is tested in
uhid-battery, as root.
CI runs the test suite on stable and on the MSRV, verifies the systemd unit with
systemd-analyze, and the lint workflow covers rustfmt, clippy, rustdoc,
shellcheck and cargo audit.
main only takes signed commits that went through a pull request, so the
release workflow does not write to it. Bump version in Cargo.toml (and
Cargo.lock) in a pull request; once merged, run Actions → Release. It tests,
builds, tags v<version> and publishes the GitHub release with the binary
attached - and refuses to run if that tag already exists.
Nothing shows up in the applet. Check that the kernel created the power
supply: ls /sys/class/power_supply/ should contain hid-headset-<vid>-<pid>-battery
(recent kernels append the report ID: …-battery-1) while the headset is on. If it is there but UPower does not list it, restart
upower.service.
could not run headsetcontrol. The binary is not on the daemon's PATH;
point at it explicitly with --headsetcontrol /usr/local/bin/headsetcontrol in
the unit's ExecStart=.
The journal says no supported headset found. The daemon is
running but cannot see the dongle, which is a different problem from a headset
that is merely switched off — that one is reported as "connected but not
answering battery queries". Either the dongle is unplugged, or the udev rule
does not cover it and the service's unprivileged user cannot open its hidraw
node. Regenerate the rule while the dongle is plugged in:
headset-battery-indicator udev-rules | sudo tee /etc/udev/rules.d/70-headset-battery-indicator.rules
sudo udevadm control --reload && sudo udevadm trigger --subsystem-match=hidrawout of inherited /dev/uhid descriptors. You have more than one headset;
add an OpenFile=/dev/uhid:uhid2 line to the unit — the daemon takes every
descriptor whose name starts with uhid.
- HeadsetControl does the hard part: speaking each vendor's protocol.
- ruflas/headset-battery-indicator for the idea of a small dedicated indicator.
MIT — see LICENSE.