A system tray monitor and lighting console for the Corsair HS80 MAX Wireless headset on Linux.
| Tray menu | Lighting |
|---|---|
![]() |
![]() |
Every picture of the interface, and what each one shows, is in
SCREENSHOTS.md.
The headset reports its battery level, its charging state and its microphone mute over the USB receiver, and it will take colours for its two LED zones — all of it through Corsair's Bragi HID protocol, and none of it through anything that runs on Linux. iCUE is Windows-only, and Bluetooth is not involved: the receiver is the device.
So this reads those properties and puts them in the panel, drives the lighting from the same connection, and publishes the charge as an ordinary battery device so the desktop's own power settings show the headset alongside every other battery on the machine.
Built and tested on one unit:
| Receiver | 1b1c:0a97, HID interface 4, usage page 0xff42 |
| Protocol | Corsair Bragi over the USB receiver |
| Desktop | Any tray host that speaks StatusNotifierItem |
| Battery elsewhere | Anything that reads UPower |
Older HS80 receivers — PIDs 0x0A73 and 0x0A6B — are deliberately not
supported. Their protocol differs and no matching hardware was available;
registering a profile that was never exercised produces confident wrong
behaviour rather than graceful failure.
Two processes. hs80maxd is a system service and the only owner of the
receiver: it reads the headset, drives the lighting, and publishes the battery.
hs80maxtray is the panel icon and the settings window, and it is a client
of that service.
The split is not decoration. Publishing the battery means creating a virtual HID
device through /dev/uhid, which is the right to create any HID device — a
keyboard included — and must not belong to a session process. And the lighting
has to keep working when no tray is running at all: a tray that fails to come up
with the session used to take the lighting down with it.
The tray is therefore optional. Without it the battery and the lighting still work; what disappears is the icon, the tooltip and the settings window. With it and without the daemon, the menu says the daemon is not answering and the tray keeps trying to reach it.
They meet on the system bus. The daemon answers as
io.github.svesh87.HS80Max at /io/github/svesh87/HS80Max: every reading is a
property, changes arrive as PropertiesChanged, and the tray reads everything
once and then listens. Changing the lighting is a method call, and that one is
checked against the polkit action
io.github.svesh87.HS80Max.set-lighting — allowed for whoever is logged in at
the machine, refused for an inactive or remote session, and never a password
prompt. Nobody has to be added to a group.
The daemon publishes the charge the only way a desktop can be told about it: as
an entry under /sys/class/power_supply, which the kernel creates for a HID
device whose descriptor carries a battery field. UPower picks it up from there,
and a shipped udev rule tells UPower the device is a headset rather than letting
it guess.
What that looks like in practice depends on the desktop:
- GNOME and KDE show peripheral batteries in the power menu, so the headset appears there next to a wireless mouse.
- COSMIC shows it in Settings → Power & Battery, under Connected devices, as a headset with its charge. Its panel applet does not list peripheral batteries at all — not this one and not a Bluetooth mouse either — so in the panel the charge stays with this application's own tray icon.
- Anything else that reads UPower sees it as an ordinary device battery.
The charge is remembered across restarts of the daemon, and the entry stays when the headset is switched off: a headset that is off has not gone flat, and a device that comes and goes is one the panel loses track of.
The tray menu and the tooltip carry connection state, battery percentage, charging state and microphone mute. The menu duplicates the tooltip on purpose: some tray hosts show no tooltip at all. Protocol mode, raw brightness, device IDs and firmware version are diagnostics and live under Advanced.
When the headset is offline those lines disappear rather than showing dashes. There is nothing to report, and a row of unknowns reads as a fault in the application.
Battery, power and microphone changes arrive as device notifications, so they appear without waiting. A full refresh runs every 30 seconds, and on demand from the menu or from the button on the Headset tab of the settings window.
When the daemon is not answering the menu says so, and every reading disappears rather than showing the last value: an old number with no way to tell how old is worse than none.
The panel icon is ours rather than a glyph from the icon theme, because no theme has
one for this headset's charge. It ships as a set of stencils under
hicolor/scalable/status, and the tray publishes the name of the one that matches the
reading. A panel that resolves such a name — they end in -symbolic — takes the shape
out of the file and paints it in its own colour, which is the only way one icon is
right on a dark panel and a light dock at the same time. On this desktop the status
area is drawn in both at once, with backgrounds that are set separately, so no colour
of ours could be correct in both.
Under a stencil the colour is the panel's, so the charge is a count rather than a hue: a headband over up to four bars, one per twenty-five percent, taken to the nearest step, so ninety-four percent is four bars out of four. A bolt between the band and the bars means charging.
| State | What is drawn |
|---|---|
| The headset answered | the bars for its charge |
| Charging | the same, with a bolt |
| The headset is off, or the receiver is out | the bars for the charge the daemon last saw, drawn as outlines |
| Something answered without a level | a dash |
| Nothing is known at all | a cross |
A remembered charge outranks an unplugged receiver: the headset had that charge, and saying so is more use than drawing a cross. The cross is for having nothing to say — the daemon is not answering, or there is neither a reading nor a memory of one.
For a panel that cannot paint an icon itself, the tray sends a picture along with the name: the same reading as a battery under a headband, with the charge as a colour — green above 60%, amber from 20 to 60, red below, grey when it is only remembered — on a plate of its own, so it owes nothing to the panel behind it. Which of the two a panel gets is a switch on the Application tab.
The icons that carry no state — the menu entries, the window's own icon — are still
symbolic names from the theme, so they look like the rest of the desktop. The
application has an icon of its own too, a headset on a plate, installed under
hicolor so every theme inherits it.
Left-clicking the tray icon opens the settings window, and hides it again when it is already open — behind another window included, which is the cost of a second click that works at all. The same window is on the menu as Settings..., for desktops that do not forward the primary tray activation.
Five tabs, each built from groups with headings, and a line at the bottom of the window saying whether the daemon and the headset are answering. Every control has a tooltip: half of them need explaining, and the explanation belongs beside the control rather than in this file.
Headset. What the device is reporting, as the tray menu says it: charge, power, microphone, render mode, brightness, USB identifiers and the firmware version the headset claims. Nothing to set here. A headset that is switched off shows the charge it was last seen with, dated in words, and the rest reads Unknown rather than pretending to know.
Lighting. Control the lighting comes first, and it is off to begin with: the headset lights itself and keeps its own behaviour, including the tone it plays on muting. Switched on, the rest of the tab comes alive — logo colour, brightness on a 0-1000 scale, and separate colours for an active and a muted microphone. Every change applies at once, there is nothing to confirm, and switching the lighting back to the headset hands the render mode over on the spot.
Off, the whole tab is greyed out rather than hidden, and each control says why when hovered: outside software render mode the headset refuses a brightness change and ignores a colour while reporting success, so a live control there would be a control that lies.
The battery on the LEDs. Either zone, or both, can show the battery level instead of a configured colour. Ten fixed colours, one per ten percent, and the colour changes only when the level crosses a boundary:
| Battery | Colour |
|---|---|
| 0-9% | #FF0000 |
| 10-19% | #FF3300 |
| 20-29% | #FF6600 |
| 30-39% | #FF9900 |
| 40-49% | #FFCC00 |
| 50-59% | #FFFF00 |
| 60-69% | #CCFF00 |
| 70-79% | #99FF00 |
| 80-89% | #4DFF00 |
| 90-100% | #00FF00 |
A zone driven by the battery stops showing what it showed before: the microphone LED cannot indicate mute and battery at once. The colour controls that target overrides are greyed out rather than hidden, and say why when hovered — the setting stays visible along with the colour it will go back to, and the window does not change size when the target does. Brightness is never overridden, and a logo-only target leaves the microphone colours alone. Switching the target off puts every saved colour back in force.
Notifications. Connect and disconnect, charging started and stopped, the configured low-battery threshold, and a charging battery reaching 99.5%. Each category can be switched off, and only the low battery is on to begin with: it is the one that matters while you are looking somewhere else, and the other three announce something you have just done with your own hands. Nothing is announced while the initial state is being established: the first reading is not a transition.
Keeping it switched on. The headset switches itself off after about ten minutes with no incoming audio, and only its own button brings it back — on a call where you talk into the microphone and nothing comes out of the earcups, that happens regularly. The Timer tab plays a short sound into it every few minutes instead, which is the one thing besides charging that the headset counts. Start begins a hold, Stop ends one — a button rather than a checkbox, because this is something that runs and ends rather than a setting that is on.
The hold has a length, 15 to 120 minutes in quarter hours, and ends itself; it is never restored at the next start. A hold nobody remembers to end is a headset that never switches off and is empty in the morning. The line under the controls says how many minutes are left.
Both the sound and the interval are chosen there. The names come from the desktop's
sound theme, so a file of your own dropped into
~/.local/share/sounds/freedesktop/stereo/ appears in the list; the last entry is a
generated 19 kHz tone, which holds the headset just as well and is heard as a faint
crackle rather than a beep. Picking a sound plays it, on both tabs that offer one: a
sound cannot be chosen from its name. Nothing longer than five minutes is offered — that is what was measured to be enough, and an interval that loses
the headset would defeat the point. A regular sound also tells you how the time is
going, which is the other half of why it is a choice.
Nothing is played into a headset that is away or on charge: away, the sound would go to whatever output the audio server picks instead, and on charge it does not switch off at all. The countdown keeps running through both.
A sound on low battery. A checkbox beside Low battery on the Notifications tab, with its own choice of sound next to it — a warning and a tick are not the same thing, so they are chosen separately. It is the notification that matters while you are looking somewhere else, and it is played into the headset and nowhere else.
Sound needs the tray. The audio server belongs to the session and the daemon is a system service without one, so both of these are the tray's: no tray, no hold and no sound. The battery and the lighting do not depend on it.
Autostart. Whether the tray starts with the session, on the Application tab. The desktop entry is the state — there is no setting behind the switch, which is why something switched off stays off.
The panel icon. Whether the panel paints our shape in its own colour or draws our own picture, on the Application tab. Letting the panel paint it is the default and the only arrangement that is right on every panel; our own picture is for a panel that cannot do it, and it carries the charge as a colour rather than as a count of bars.
Window colours. Following the desktop, or a light or dark palette of our own, on the same tab. Following the desktop means following it while the window is open, which matters because a desktop switches its theme on its own at sunrise and sunset. This is the one setting the window keeps for itself, in the person's own configuration; everything about the headset belongs to the daemon.
The package installs no autostart entry. Whether the tray joins a session is
each person's own decision, and a system-wide entry would make it for every
account on the machine while switching it off could only ever be personal. The
switch writes $XDG_CONFIG_HOME/autostart/hs80-max-tray.desktop, pointing at
the binary that is running.
Started that way, the tray waits for the panel rather than giving up: at login the StatusNotifierItem host is routinely a second or two behind. It keeps looking for a minute and then exits with a message.
make package
sudo dpkg -i build-package/hs80-max-tray_*.debThe package installs both binaries, the systemd unit, a udev rule, a desktop entry, the bus policy and the polkit action. It enables and starts the daemon and adds nothing to anybody's session.
Nothing else has to be set up. Earlier versions created an hs80max group and
asked for an account to be added to it; the group is neither created nor used any
more, and an existing one is left alone — sudo delgroup --system hs80max
removes it once nothing else needs it.
Run the tray as the desktop user, not through sudo: a root process is not
the active session, and the polkit action allows the active session. The daemon needs no
udev rule to reach the receiver, because it runs as root — and no rule hands the
device to anything else. Earlier versions installed one that set mode 0666 on
the receiver, giving every process on the machine write access to the headset;
if a copy was ever placed by hand, remove it:
sudo rm -f /etc/udev/rules.d/99-corsair-hid.rulesRemoving the package stops and disables the daemon, and the daemon hands the
headset back its own render mode on the way out. A purge also removes
/var/lib/hs80max, which is where its settings and the last known charge live.
sudo apt install build-essential cmake pkg-config qt6-base-dev \
qt6-multimedia-dev libhidapi-dev
make
dbus-daemon --session --address=unix:path=/tmp/hs80max.bus --print-address \
--nofork &
sudo ./build/hs80maxd --bus-address unix:path=/tmp/hs80max.bus &
./build/hs80maxtray --bus-address unix:path=/tmp/hs80max.busA bus of its own, because the system bus refuses the name until the package's
policy file is in place: owning a name there is granted by
/usr/share/dbus-1/system.d/io.github.svesh87.HS80Max.conf and nothing else. On
a private bus that file is not consulted, and neither is polkit — an authority
that nobody holds means every lighting change is refused, so lighting from a
build tree is exercised by the test suite rather than by hand.
Run from a build tree the daemon still needs root, for /dev/uhid and for the
receiver. --no-battery-device skips the virtual battery, which is what to use
when only the lighting is of interest.
sound-theme-freedesktop is what the ping and the low-battery sound are taken from.
Without it the list of sounds is short or empty, and the generated tone is what is left.
pulseaudio-utils is needed only for --device, which switches the default
audio sink when the headset connects.
KDE Frameworks and libkf6statusnotifieritem-dev are not required: the
application uses Qt's portable QSystemTrayIcon.
Split between the machine and the person, because the two have different owners.
The lighting belongs to the daemon, which has to know it before any tray connects and has to keep applying it when none does:
/var/lib/hs80max/settings.conf
Notifications and the choice of window colours belong to whoever is logged in. The autostart switch is here in name only: its state is the desktop entry, not a value in this file.
~/.config/hs80-max-tray/hs80-max-tray.ini
The settings window shows both and never writes the lighting itself: it sends the change to the daemon, which is the single writer of that file.
On the first run the daemon reads the headset's current LED brightness before creating the profile, so nothing jumps. If that value cannot be read, brightness starts at 500. Properties that cannot be read take documented defaults: lighting left to the headset, white logo, white active microphone, red muted microphone, battery lighting off, the low-battery notification on and the other three off, low-battery threshold 20%. Reset to Defaults restores exactly those values, whatever the hardware brightness happens to be. It leaves autostart alone: that is not a stored setting, and a reset button has no business adding a process to a session.
The window and the tray menu come up in the language of the session, in English,
Russian or Spanish. Nothing selects it inside the application: the language is
whatever LANG and LC_ALL say, and a language with no catalogue installed shows
the interface in English.
The catalogues are files rather than something compiled into the binary:
/usr/share/hs80-max-tray/translations/hs80maxtray_ru.qm
HS80_TRANSLATIONS_DIR points the search somewhere else, which is how the suite
runs against freshly built catalogues and how the pictures in the documentation
are taken in English on a machine that is not. HS80_ICONS_DIR does the same for the
panel icons, which are looked up by name in the icon theme: without it a binary run
from a build tree would find whatever copy is installed.
The command line, the daemon's log and everything it answers on the bus stay English in every session. They are quoted in documentation and searched for by their exact wording, and a translated error message is one nobody can look up.
Usage: hs80maxtray [options]
Tray status monitor for Corsair HS80 MAX Wireless
Options:
-h, --help Show this help message
-d, --device NAME Switch to this PipeWire/PulseAudio sink
-v, --verbose Print diagnostics
--bus-address A Bus to look for the hs80maxd daemon on, instead of the system bus
--screenshot DIR Write the documentation's pictures here and exit
Sink names come from pactl list short sinks. Switching the sink is the tray's
job rather than the daemon's: pactl talks to the sound server of a logged-in
person, which a system service has none of.
Usage: hs80maxd [options]
--help this text
--verbose log every HID report
--bus-address <addr> bus to answer on, instead of the system bus
--settings <path> lighting settings file
--state <path> last known battery file
--no-battery-device do not publish the battery through /dev/uhid
--quit-after-ms <ms> exit after this long, for tests
make # build
make test # the full suite
make coverage # the suite plus a coverage report
make lint # static checks
make package # build the deb and inspect it
make icons # regenerate the panel icon set from the code that draws it
make lupdate # put new interface strings into the .ts catalogues
make gates # everything that has to be green before handing work overNo test needs a receiver, a headset, or root. The unit tests exercise pure logic;
the cases that need a device drive the real daemon against a fake hidapi loaded
with LD_PRELOAD, and the tray against a daemon of its own; and the virtual
battery device is exercised against a socket pair, because /dev/uhid is
root-only and a test that needed root would not be a test. Line coverage of
production code is held at 80% or above.
USB identity and transport selection live in the DeviceProfile registry
(src/device_profile.h, src/device_profile.cpp), while packet encoding and
parsing live in bragi_protocol. The tray consumes a common status model and
does not depend on a receiver PID.
To add a device:
- capture and verify its VID/PID, HID interface, usage page and protocol;
- add a profile only after testing it on real hardware;
- reuse the Bragi module when the protocol is compatible, or add another driver for older generations;
- add packet-level tests and record in
docs/PROTOCOL.mdwhich properties were confirmed on hardware.
docs/DESIGN.md— the decisions that shape the program, and what each one costs.docs/PROTOCOL.md— the wire protocol, and what was confirmed on a real device.docs/SCREENSHOTS.md— the interface, and how the pictures are generated.AGENTS.md— the rules this repository is developed under.
Every one of them has a Russian and a Spanish copy beside it, <NAME>.ru.md and
<NAME>.es.md. The agent rules come in English and Russian only.
What this program cannot do, and where the reason lies. The ones that are somebody else's to fix are filed, with a link to the report.
- Software render mode suspends the headset's native microphone tone while the daemon is managing the lighting: custom colours and that tone are the same resource. This is why the lighting is left to the headset by default — switching Control the lighting off, or stopping the daemon, restores hardware mode and the tone with it.
- The hold and the sound need the tray running. The audio server belongs to the session, and a system service has none. A daemon could not play into it even if it wanted to, so a machine without a tray keeps its battery readings and its lighting and loses both of these.
- Without the system bus the tray has no way to reach the daemon, and says so in its menu. The battery and the lighting are unaffected: the daemon owns the receiver either way, and it claims its name as soon as a bus answers.
- Changing the lighting needs a local, active session. That is what the polkit action allows, and an inactive or remote session is refused rather than asked for a password. Over SSH the readings are still there; the colours are not.
- Only one receiver is supported,
1b1c:0a97. The older HS80 receivers0x0A73and0x0A6Bspeak a different protocol and no matching hardware was available; a profile that was never exercised would produce confident wrong behaviour rather than graceful failure. - Low-battery warnings come from this application's own notifications. A desktop's own warnings are about the machine's battery, not about a peripheral's.
- A desktop without a StatusNotifierItem host has nowhere to put the icon, and needs a tray extension. The battery and the lighting do not depend on it: those belong to the daemon.
- COSMIC's panel applet does not list peripheral batteries at all — not this headset and not a Bluetooth mouse either — so in the panel the charge stays with this application's own icon. Asked for upstream: cosmic-applets#1523.
- COSMIC's Settings page updates the percentage only when the charging state changes, because it subscribes to a narrower set of UPower signals than the charge needs. Reported with the cause: cosmic-settings#1995.
- A device UPower classifies after announcing it never reaches that list at all. This is why the headset can be absent from Settings until UPower is restarted, and why the daemon re-announces its virtual battery: cosmic-settings#2142.
- UPower has no way to be told what kind of device a battery belongs to.
UPOWER_BATTERY_TYPEis honoured only for a UPS behind hiddev, so the shipped udev rule reaches the same result throughSOUND_INITIALIZEDandSOUND_FORM_FACTORon the sibling input node. Asked for upstream: upower#353; if it lands, the rule becomes one honest line. - UPower's guess at a device's kind sticks to the first answer, which is how an ordinary mouse ends up announced as a keyboard. It does not affect this headset, and it explains what a panel full of misnamed peripherals is: upower#352.
MIT. See LICENSE.

