Skip to content

Repository files navigation

English · Русский · Español

hs80-max-tray

A system tray monitor and lighting console for the Corsair HS80 MAX Wireless headset on Linux.

Tray menu Lighting
The tray menu The Lighting tab

Every picture of the interface, and what each one shows, is in SCREENSHOTS.md.

Why this exists

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.

Scope

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.

How it is put together

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.

Where the battery shows up

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.

What it shows

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.

What it controls

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.

Installing

From the package

make package
sudo dpkg -i build-package/hs80-max-tray_*.deb

The 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.rules

Removing 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.

From source

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.bus

A 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.

Settings

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.

Language

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

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

Building and testing

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 over

No 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.

Adding another device

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:

  1. capture and verify its VID/PID, HID interface, usage page and protocol;
  2. add a profile only after testing it on real hardware;
  3. reuse the Bragi module when the protocol is compatible, or add another driver for older generations;
  4. add packet-level tests and record in docs/PROTOCOL.md which properties were confirmed on hardware.

Documents

  • 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.

Limitations

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.

Ours

  • 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 receivers 0x0A73 and 0x0A6B speak 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.

Not ours

  • 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_TYPE is honoured only for a UPS behind hiddev, so the shipped udev rule reaches the same result through SOUND_INITIALIZED and SOUND_FORM_FACTOR on 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.

License

MIT. See LICENSE.

About

Linux tray monitor and lighting control for the Corsair HS80 MAX Wireless headset

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages