One control panel for SteelSeries peripherals on Linux.
Three good open-source backends already cover disjoint pieces of the hardware, but they do not talk to each other and share no idea of a "profile". Periphub is the layer above them: a per-key effect engine, profiles that span every device at once, and automatic switching — driven by a single background service that owns the hardware.
| Layer | Backend | What it actually gives you |
|---|---|---|
| Lighting | OpenRGB (SDK over TCP) | per-key control — 111 LEDs on an Apex Pro, Direct mode, 6×22 matrix |
| Headset | HeadsetControl | sidetone, battery, earcup lighting, auto power-off, 10-band equaliser |
| Mouse | ratbagd / libratbag | DPI and polling rate — for models that library knows |
Not affiliated with SteelSeries and not a SteelSeries Engine clone. It is a front end for other people's backends, plus the parts none of them provide.
- 17 per-key lighting effects (plus
off) rendered on the CPU and pushed in one SDK packet — including effects no backend offers: a ripple spreading from the pressed key, a typing heatmap, a system monitor across the function row, a clock, a battery gauge. - Profiles that cover every layer at once — one file sets lighting, sidetone, equaliser and DPI together.
- Automatic profile switching when a matching program starts.
- Low-battery notifications for the headset, with configurable thresholds.
- One owner of the hardware. The service holds every device; the window
and the CLI are clients over a
AF_UNIXsocket, so two writers can never fight over a device. - Honest diagnostics —
periphub doctorsays what is missing and why.
Periphub separates four things that are easy to confuse, and shows all four:
| Meaning | |
|---|---|
| detected | the device is on the USB bus — read from sysfs, so it stays visible even when a backend is down |
| connected | there is a usable link. For a wireless base station this is not the same as detected: the dock is plugged in while the headset is switched off |
| support level | FULL / PARTIAL / DETECTED_ONLY / UNAVAILABLE |
| capabilities | the individual functions that can actually be driven |
Verified on real hardware:
| Device | USB id | Support | Notes |
|---|---|---|---|
| SteelSeries Apex Pro | 1038:1610 |
FULL |
111 LEDs, full-size ANSI layout, reactive effects |
| SteelSeries Arctis Nova Pro Wireless | 1038:12e0 |
FULL |
sidetone, battery, lighting, power, equaliser |
| SteelSeries Rival 5 | 1038:183c |
DETECTED_ONLY |
works as a pointing device; no advanced control (see Limitations) |
Any other SteelSeries device is detected, classified by its USB interface
classes and listed as DETECTED_ONLY rather than hidden or reported as an
error. Devices from other vendors are ignored.
periphub devicesFedora:
sudo dnf install openrgb headsetcontrol libratbag-ratbagd python3-pyside6On other distributions install the equivalents: OpenRGB, HeadsetControl,
libratbag/ratbagd, PySide6 and python3-evdev (needed for reactive effects).
Nothing else is required — there is no build step and no third-party Python
dependency beyond PySide6.
The udev rules shipped by headsetcontrol and openrgb-udev-rules give access
without root. After installing them, re-plug the devices or:
sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=hidraw --action=changeRead docs/OPENRGB.md before the first run. Without a
detector whitelist, OpenRGB can hang on unrelated motherboard RGB controllers,
and the service will wait for it.
git clone https://github.com/Filipluke/periphub.git ~/Projects/periphubmkdir -p ~/.local/bin && ln -sf ~/Projects/periphub/scripts/periphub ~/.local/bin/periphub && ln -sf ~/Projects/periphub/scripts/periphub-gui ~/.local/bin/periphub-guiCheck the environment before starting anything:
periphub doctorRun the service in the foreground once to see what it does:
periphub daemonThen install it as a user service, started with the graphical session:
mkdir -p ~/.config/systemd/user && cp ~/Projects/periphub/systemd/periphub.service ~/.config/systemd/user/ && systemctl --user daemon-reload && systemctl --user enable --now periphubThe unit expects the checkout in ~/Projects/periphub — it sets PYTHONPATH
there and refuses to start if the directory is missing. If you cloned somewhere
else, edit the two %h/Projects/periphub paths in your copy of the unit.
Open the window:
periphub guiClosing the window does not stop hardware control. Effects, profiles and automatic switching keep running, because the service owns them. The window itself hides to the system tray — click the tray icon to bring it back, or use Quit in its menu to close the window for good. Starting Periphub again while it is in the tray raises the existing window instead of opening a second one.
To keep the tray icon ready after every login, start it hidden:
periphub gui --trayTo actually stop hardware control:
systemctl --user stop periphubThe .desktop file declares Icon=periphub, so install the icon together with
the entry — otherwise the menu shows an empty frame.
install -Dm644 ~/Projects/periphub/assets/periphub.svg ~/.local/share/icons/hicolor/scalable/apps/periphub.svgdesktop-file-install --dir="$HOME/.local/share/applications" ~/Projects/periphub/periphub.desktop && update-desktop-database "$HOME/.local/share/applications" && gtk-update-icon-cache -f -t ~/.local/share/icons/hicolor 2>/dev/null; kbuildsycoca6 2>/dev/nullperiphub statusperiphub devicesperiphub effectsperiphub effect reactive_ripple --color FF3000 --reactive-color FFFFFF --speed 1.4periphub headset --sidetone 40 --eq-profile action --inactive-time 30periphub profile list && periphub profile apply gamingperiphub profile create tournament --effect gaming --color 00FF80 --processes "cs2,steam_app_"periphub auto offThe window, the command line and the service all ship in English and Polish. A fresh installation starts in English; the system locale is deliberately not used to guess — running a Polish desktop does not mean you want a Polish interface. Pick the language in About Periphub; the service stores it, the command line reads the same setting, and the window applies it after it is reopened.
Default profile names are language-neutral identifiers (default, gaming,
work, monitor, night) because they are used in commands and file names;
only their descriptions are translated.
Translations are flat JSON catalogues, one file per language, with no build step:
periphub/locale/en.json
periphub/locale/pl.json
Adding a language means copying en.json, translating the values, adding the
code to LANGUAGES in periphub/i18n.py and — if the language needs it — a
plural rule next to the existing ones. python3 tools/test_i18n.py then checks
catalogue parity, missing keys and placeholder mismatches for you. Plural forms are handled by a rule per
language rather than by hand-written conditionals, so 1 LED / 2 LEDs and
1 dioda / 2 diody / 111 diod both come out right. A missing key falls back to
English and then to the key itself, so a partial translation degrades quietly
instead of showing blanks.
Technical identifiers — effect names, IPC operations, backend and brand names — are never translated.
A dark, single-accent theme. Navigation is a left sidebar, not tabs.
| Screen | What it shows |
|---|---|
| Devices | a card per detected device, the active configuration, and system load used by informational effects |
| Lighting | live keyboard visualisation and an effect editor whose form is built from the effect |
| Headset | battery, sidetone, power, earcup lighting, equaliser as a curve |
| Mouse | separates "works as a pointing device" from "advanced settings unavailable" |
| Profiles | profile manager — no raw JSON in the normal workflow |
| Rules | which program triggers which profile, and whether it matches right now |
| Status | layers as status rows, performance metrics, diagnostics to the clipboard |
Theme rule: no component sets its own colours through setStyleSheet.
Variants are declared as Qt properties (role, variant, tone) and the look
lives in one stylesheet in theme.py. The only exceptions are the elements
whose colour is the content: the colour swatch and the keyboard preview.
OpenRGB reports LED positions in a 6×22 matrix and their names, but not key
widths — in the matrix, Space takes one cell just like a letter.
keycaps.py adds geometry only (a name → row, x, width, height table in 1u
units), matched against the LED names the device actually reports. The backend
stays untouched.
Layout confirmed by measurement: full-size ANSI Apex Pro, 104 keys.
Every effect class declares params — the parameters it really reads. The form
is built from that list, so there is no speed slider on a static effect and no
colour picker on the system monitor.
Ten draggable points on a dB grid. Bands are numbered 1–10, not labelled with
frequencies — HeadsetControl does not expose the centre frequencies anywhere:
not in -o json, not in its README, not in the strings of the binary. The band
count and range come from the tool's own validation messages
(Device only supports 10 bands., bands ranging from -10 to 10), so they are
measured rather than assumed.
The band order is an assumption: the graphic-equaliser convention, band 1 lowest. Checking it by ear takes half a minute — play something with obvious bass and run:
periphub headset --eq-curve "10,10,0,0,0,0,0,0,-10,-10"Bassy and dull means the order is right; thin and glassy means it is reversed,
and reversing the lists in EQ_PRESETS (periphub/backends/headset.py) is the
whole fix.
periphub headset --eq-profile flat| Preset | For |
|---|---|
flat |
reference, equaliser off |
neutral |
everyday listening, gentle cleanup |
fps |
shooters: trimmed low end, localisation band pushed |
action |
vehicles and explosions: impact plus brightened detail |
balanced |
general purpose: full low end, clean mids, open top |
music |
gentle V, vocals stay in place |
movie |
cinematic low end, clearer dialogue |
voice |
calls and meetings |
bass |
strong low end without muddying the mids |
Profiles carry a preset, so switching a profile also changes the sound.
| Name | Needs | Description |
|---|---|---|
static |
— | one colour |
breathing |
— | fade in and out |
rainbow |
— | whole keyboard through the spectrum |
rainbow_wave |
key map | spectrum travelling across the matrix |
wave |
key map | brightness wave in one colour |
gradient |
key map | static blend between two colours |
rainbow_wave_reactive |
key map + keys | default — rainbow plus a white ripple from the pressed key |
starlight |
— | random keys flare and fade |
fire |
key map | warm noise rising from the bottom row |
matrix_rain |
key map | falling columns |
reactive_key |
keys | the pressed key flares |
reactive_ripple |
key map + keys | ring spreading from the pressed key |
heatmap |
keys | typing heatmap |
system_monitor |
key map | CPU on F1–F12, GPU on the number row, RAM on the numpad, battery on the arrows |
battery |
— | whole keyboard as a battery gauge |
gaming |
— | WASD and action keys lit |
clock |
— | hours and minutes on the keys |
off |
— | dark |
Effects that react to keystrokes need read access to keyboard input events; spatial effects need a key map from the device. Both are reported per effect in the interface rather than failing silently.
┌──────────────┐
periphub gui ───▶│ │──▶ OpenRGB SDK (TCP 127.0.0.1:6742)
│ SERVICE │──▶ headsetcontrol (process, JSON)
periphub cli ───▶│ AF_UNIX IPC │──▶ ratbagctl / ratbagd (D-Bus)
│ │──▶ evdev (key events)
└──────────────┘──▶ sysfs (device discovery)
The service is the only owner of the hardware. Threads: render, headset, mouse,
appwatch, IPC. The socket lives in $XDG_RUNTIME_DIR/periphub.sock, mode
0600. Clients never touch a device directly, which is what keeps a closed
window from stopping the lighting.
The OpenRGB SDK client is written from scratch on the standard library
(periphub/backends/openrgb_sdk.py): the openrgb CLI runs a full detection
pass on every invocation and cannot address individual LEDs at animation rate.
Protocol 4 requires parsing zone segments; without that the buffer cursor
drifts and the LED list comes out empty.
Device discovery reads sysfs rather than asking the backends, because a
backend answers "what can I drive", not "what is plugged in". USB interfaces
are deduplicated by construction: interface directories (1-9:1.0) have no
idVendor file, so filtering on it yields exactly one entry per physical
device — a keyboard reporting five HID interfaces is one card, not five.
periphub/
daemon.py the service: threads, IPC operations, hardware ownership
discovery.py physical device detection from sysfs
effects.py per-key effect engine
config.py settings and profiles
i18n.py language runtime
locale/ translation catalogues
backends/ OpenRGB SDK client, HeadsetControl, libratbag
gui/ PySide6 window (theme, widgets, keycaps, equaliser, pages)
tools/ QA and test scripts, no runtime dependency
python3 tools/test_discovery.pyTwelve device-discovery cases run against fake sysfs trees — no hardware and
no service required, including cases that are hard to stage on a desk: an
unknown model, a switched-off headset, a backend that is down.
- SteelSeries Rival 5 (
1038:183c) has no advanced support. It is in neither the libratbag 0.18 database (ratbagctl list→ No devices available) nor the OpenRGB detectors. Periphub deliberately does not ship a guessed.devicefile — guessing a protocol means sending random HID reports to hardware. The mouse works normally as a pointing device and the interface says exactly that instead of pretending. - Rapid Trigger / OmniPoint on the Apex Pro is not available. The adjustable actuation point uses a proprietary SteelSeries protocol that none of the installed backends implement. Settings written once from Engine on Windows stay in the keyboard's memory and remain active under Linux.
- Headset battery readings are intermittently wrong. Measured: the first
call can return
Wrong id for device status packetwhile the next one reports the level immediately. Every read is therefore retried (headset_battery_retries). - ChatMix, voice prompts and microphone control appear in
headsetcontrol --help-all, but the Arctis Nova Pro Wireless does not report them among its capabilities — the interface greys those controls out instead of sending commands into the void. - HeadsetControl cannot read the equaliser curve back from the device. The
last curve applied is remembered in
~/.config/periphub/config.jsonso the window has something to show. - Per-application profiles match on processes, not on the focused window. Reading the active window on KDE/Wayland needs a KWin script.
- The service and the client can disagree on language. Each process reads the setting on start, so text produced by the service (device reasons, backend errors) follows the service's language until it is restarted, even if a client was started with another one. On a normal installation both are the same.
~/.config/periphub/config.json settings
~/.config/periphub/profiles/*.json profiles
~/.local/state/periphub/periphub.log log
$XDG_RUNTIME_DIR/periphub.sock IPC socket
MIT — see LICENSE.



