A layer-compositing RGB lighting daemon for Redragon keyboards, written in C++.
Effects are stacked like layers in an image editor, grouped into profiles, and switched automatically by whichever window has focus. It runs as a background daemon and is driven at runtime through a control socket, so lighting can be bound to a key, changed from a script, or wired into CI.
sinodragon --daemon # run it
sinoctl profile magma # switch profiles
sinoctl brightness 40 # dim it
sinoctl game tetris start # play something
sinoctl state build fail # turn the F row red from a CI script- Install
- Running it
- Commands
- Configuration
- Effects
- Games
- System-state layers
- Temporary profiles
- Shell completion
- Architecture
- Packet format
Dependencies:
- A C++17 compiler and CMake ≥ 3.16
hidapi(found via its CMake package or via pkg-config)libevdev— reactive effects, the shortcut overlay and the gamestomlpluspluslibX11— optional, only for the X11 window backend
# Arch
sudo pacman -S hidapi libevdev tomlplusplus cmake
# Debian / Ubuntu
sudo apt install libhidapi-dev libevdev-dev libtomlplusplus-dev cmakeBuild and install:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
sudo cmake --install buildThat installs sinodragon, sinoctl, the udev rules and a systemd user unit.
The daemon needs to open the keyboard's hidraw node, and — for reactive
effects, the shortcut overlay and the games — to read /dev/input/event*.
Both are root-only by default:
sudo cp packaging/70-sinodragon.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules && sudo udevadm triggerThe rules use TAG+="uaccess", which grants access to the locally logged-in
user. That is deliberately not "add yourself to the input group" — group
membership would let every process you run read your keystrokes.
If your keyboard is not a 258a device, change the vendor id in the rules
file; lsusb will tell you what it is.
mkdir -p ~/.config/systemd/user
cp packaging/sinodragon.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now sinodragonsinodragon [options] [config.toml]
-c, --config <path> Config file to load
-d, --daemon Run without the interactive prompt
-p, --preview Draw frames in the terminal instead of sending them
to the keyboard (implies --daemon)
-s, --socket <path> Control socket to listen on
--no-socket Do not listen for control commands
--lock <path> Single-instance lock file (default: socket path + .lock)
--no-lock Allow more than one instance (not recommended)
-h, --help Show this help
-v, --version Show the version
With no config argument it looks for $XDG_CONFIG_HOME/sinodragon/config.toml,
then ~/.config/sinodragon/config.toml, then ./configs/config.toml.
Only one daemon runs at a time: a second one refuses to start (exit code 3)
rather than fighting the first over the keyboard. The lock is released
automatically when the daemon exits, crash included. To drive two keyboards
with two daemons, give each its own --socket (which gives each its own lock);
--no-lock disables the check entirely.
--preview renders each frame as coloured blocks laid out in the physical key
grid. It decodes the same bytes that would go to the device, so what you see
includes master brightness — useful for building effects with no keyboard
attached.
SIGINT and SIGTERM blank the keyboard and exit cleanly rather than leaving the last frame frozen on it.
The same commands work at the interactive prompt and through sinoctl.
| Command | Description |
|---|---|
status |
Device, transport, active profile, brightness, running game |
list |
Presets, and which are currently drawn |
profiles |
Configured profiles |
profile <name> |
Activate a profile |
profile <name> for <30s|5m|1h> |
Hold a profile, then go back to the one the current window asks for |
brightness [0-100] |
Get or set master brightness |
frame <ms> |
Animation frame interval |
set <index> <key> <value> |
Change a preset parameter live |
toggle <index> |
Toggle one preset on or off |
game list |
Configured games |
game <name> <start|stop> |
Run a game; game stop stops whichever is running |
metric <name> <0..1> |
Feed a value to a system_meter layer |
state <name> <value> |
Set a status_light state |
pomodoro <start|pause|reset|skip|status> |
Drive a pomodoro layer |
complete <profiles|games|commands> |
Names for shell completion |
reload |
Re-read the config in place |
watch <on|off> |
Watch the config file for changes |
quit |
Shut the daemon down |
sinoctl exits non-zero when the daemon rejects a command, so scripts can
branch on it:
sinoctl profile "$1" || notify-send "no such profile: $1"The socket lives at $XDG_RUNTIME_DIR/sinodragon.sock, mode 0600.
# Hyprland
bind = SUPER, F1, exec, sinoctl profile coding
bind = SUPER, F2, exec, sinoctl brightness 20One TOML file. See configs/config.toml for a full working example.
| Key | Default | Meaning |
|---|---|---|
name |
"Unknown Device" |
Display name |
vendor_id, product_id |
0 |
USB ids |
packet_header |
[] |
Bytes prepended to every report |
packet_length |
0 |
Total report size |
layout |
— | CSV of key labels, relative to the config file |
keycodes |
— | CSV of evdev names; without it reactive effects and games are inert |
transport |
"hidapi" |
hidapi, preview or logging |
frame_interval_ms |
33 |
Animation tick |
brightness |
100 |
Master brightness, 0–100 |
config_watch_mode |
false |
Watch the config file from startup |
preview_transpose |
auto | Override the preview's grid orientation |
| Key | Default | Meaning |
|---|---|---|
enabled |
false |
Enables window watching and the shortcut overlay |
window_source |
"auto" |
auto, hyprland, sway, x11 or none |
events_socket |
auto | Override the compositor socket path |
shortcuts_overlay_effect |
— | Inline effect drawn while a modifier is held |
Named sets of key labels, referenced by a layer's zones.
[zones]
function = ["F1", "F2", "F3", "F4", "F5", "F6"]
wasd = ["W", "A", "S", "D"]Labels must match the layout CSV exactly.
A profile is an ordered stack of layers, drawn bottom to top.
[[profiles.coding.layers]]
type = "static_color"
color = "#FFFF40"
[[profiles.coding.layers]]
type = "rainbow_wave"
speed = 0.8
zones = ["function"] # restrict to a zone
blend = "screen" # normal | add | multiply | screen
opacity = 0.6 # 0.0 - 1.0typenames the effect; everything else is passed to it as a parameter.zonesandkeysrestrict which keys the layer touches. With neither, it covers the whole board and paints over everything below it.blendandopacitycontrol how it combines with the layers below. The default — opaquenormal— simply overwrites, which is the historical behaviour.
[apps]
default_profile = "liquid_n"
default_shortcut = "default"
[apps.mappings]
Code = "coding"
kitty = "maze"
[apps.mappings.zen]
profile = "food"
shortcut = "zen"
# Title rules are checked before class mappings; first match wins.
[[apps.title_rules]]
contains = "youtube" # case-insensitive substring
class = "firefox" # optional, restricts the rule
profile = "video"[shortcuts.kitty]
color = "#ff5500"
ctrl = ["C", "D", "L", "Z"]
ctrl_shift = ["C", "V", "T", "W"]Modifier tokens joined with _, any order and case: ctrl, shift, alt,
super/win/meta. The overlay engages on an exact match, so ctrl and
ctrl_shift are separate entries.
reload, or config_watch_mode = true, re-reads the config without dropping
the device handle or restarting the watchers. The active profile is kept if it
still exists. A config that fails to parse is rejected and the running one is
kept, so saving a half-written file will not black out your keyboard. Changing
the [device] section needs a new handle, so that one restarts the runtime.
| Effect | Notes |
|---|---|
static_color |
color |
key_map |
Per-label colours (key.<Label>), background |
rainbow_wave |
speed, scale, saturation, value, tint, tint_mix |
star_matrix |
star, background, density, speed |
liquid_plasma |
Sine interference. colors, scale, wave_complexity, mix_mode |
smoke |
fBm noise with wind. octaves, persistence, lacunarity, drift_x/y, contrast |
reaction_diffusion |
Gray-Scott. feed, kill, du, dv, steps, zoom |
doom_fire |
cooling, spark_chance, spark_intensity, palette |
reactive_ripple |
Rings from keystrokes. wave_speed, decay_time, thickness |
space_colonization |
Growing roots. attractors, kill_dist, segment_len, lifespan |
typing_heatmap |
Where you type. half_life, gain, spread, palette |
matrix_rain |
Falling code. direction, speed, density, tail |
lightning |
Forked bolts from keystrokes. branch_chance, max_length, ambient_interval |
fireworks |
Shells launched by keystrokes. sparks, gravity, spark_life, palette |
pomodoro |
Work/break timer as a bar — see below |
system_meter |
A value as a bar — see below |
status_light |
An externally driven state — see below |
liquid_plasma, smoke, reaction_diffusion and space_colonization accept
reactive = true and a family of reactive_* parameters that warp the field
around recent keystrokes. configs/config_preset_info.md has the per-parameter
detail.
Games take the keyboard over while they run and hand it back on stop.
sinoctl game list
sinoctl game tetris start
sinoctl game stopDeclare one as a layer in its own profile to make it available:
[[profiles.tetris_game.layers]]
type = "tetris"
step_interval = 0.4| Game | Controls |
|---|---|
snake |
Arrows steer; Enter/Space restarts after a crash |
tetris |
Up/Down move, Space rotates, Left hard-drops |
pong |
Two players: left uses W/S, right uses Up/Down. First to win_score (7) wins. opponent = "ai" for solo |
life |
Press any key to toggle the cell under it |
connect4 |
Two players; 1-7 drop a disc, Enter resets |
breakout |
Left/Right move the paddle, Space serves |
flappy |
Any key flaps |
simon |
Repeat the sequence on the A/S/D/F pads |
reaction |
Hit the key that lights, as fast as you can |
Tetris runs sideways: the board is six rows tall and sixteen wide, far too short for pieces to fall down it, so gravity runs along the long axis and a full column is what clears. Connect Four gets the opposite treatment — its grid is 7x6, which fits as-is, so it is centred with a margin either side rather than stretched.
Games need the keycodes CSV — that is how they read input.
Layers driven by data instead of time.
[[profiles.sysmon.layers]]
type = "system_meter"
metric = "cpu" # cpu | memory | load | battery | <custom>
bar_keys = ["F1", "F2", "F3", "F4", "F5", "F6"]
blend = "add"The bar fills bar_keys in the order listed. Meters paint their non-bar keys
black, so stack several with blend = "add" — otherwise each erases the one
below it.
Anything outside the daemon can drive a layer:
[[profiles.buildstatus.layers]]
type = "status_light"
signal = "build"
keys = ["F1", "F2", "F3", "F4"]
ok_timeout = 20.0sinoctl state build busy # amber sweep
make && sinoctl state build ok || sinoctl state build fail
sinoctl metric deploy 0.6 # feeds a system_meter with metric = "deploy"ok fades out after ok_timeout so a green build does not stay lit all day.
The pomodoro timer works the same way — a layer you leave in a profile and drive from outside:
[[profiles.focus.layers]]
type = "pomodoro"
work_minutes = 25.0
short_break_minutes = 5.0
bar_keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"]sinoctl pomodoro start # red bar draining across the number row
sinoctl pomodoro status # work 12m41s remaining, round 2/4
sinoctl pomodoro skipprofile <name> for <duration> holds a profile for a while and then goes back:
sinoctl profile focus for 25mWhile the hold is up, switching windows does not change the lighting — it only
changes where the hold reverts to, so when it expires you get the profile for
whatever window you are actually looking at. 30s, 5m and 1h all work, and
a bare number means seconds. profile <name> with no duration cancels a hold.
Completions for bash, zsh and fish live in packaging/completions/. They ask
the running daemon for the real names, so a profile you added to your config
shows up without regenerating anything:
# bash
sudo install -m644 packaging/completions/sinoctl.bash \
/usr/share/bash-completion/completions/sinoctl
# zsh
sudo install -m644 packaging/completions/_sinoctl /usr/share/zsh/site-functions/_sinoctl
# fish
install -m644 packaging/completions/sinoctl.fish \
~/.config/fish/completions/sinoctl.fishWith no daemon running they fall back to the static command list.
Runtimeowns the model, the transport, the engine and the single render thread, and is the only place commands are dispatched from. Every frontend — the interactive CLI, the control socket, the window watchers — goes through it, so there is one lock protecting the engine and one thread touching the device. Lock order is documented inruntime.hpp: the loop mutex may be taken before the engine mutex, never the reverse.EffectEnginecomposites layers into a frame: render each layer into a scratch buffer, then blend it through the layer's key mask, opacity and blend mode. The render thread parks on a condition variable when nothing is animating, so a static profile costs no frames per second.LightingPresetis the effect interface. Add one by subclassing it, implementingrender, and registering it inbuildRegistry()inmain.cpp.GamePresetextends it for effects that take the keyboard over.DeviceTransportabstracts the device:hidapifor real hardware,previewfor the terminal,loggingfor hex dumps. The hidapi transport reconnects on its own — three consecutive write failures close the handle and it re-enumerates on a backoff, so an unplug or a suspend/resume cycle recovers without a restart.WindowSourceabstracts focus tracking, with Hyprland, sway/i3 and X11 backends chosen automatically.SystemStateis the shared, cached source of/procreadings and of values pushed in over the socket.
On Xorg they work through the X11 backend. On Wayland neither exposes a stable
active-window interface without a shell extension or a KWin script, so there is
no honest backend to ship. Drive sinoctl profile from your own key bindings
instead.
A 382-byte feature report: a 4-byte header, then RGB triplets for 96 keys, then
zero padding, sent with hid_send_feature_report.
vendor_id = 0x258A
product_id = 0x0049
packet_header = [0x08, 0x0A, 0x7A, 0x01]
packet_length = 382
The layout CSV lists keys in packet order, which on this keyboard runs down the physical columns — 16 rows of 6 entries, where each CSV row is one physical column of the board:
Esc, Backtick, Tab, Caps, Shift, Ctrl
F1, 1, Q, A, Z, Win
F2, 2, W, S, X, Alt
...
Del, Home, End, PgUp, PgDn, Right
A key's colour starts at byte 4 + index * 3, where
index = csv_row * 6 + csv_column. NAN marks a position with no physical
key; it still occupies an index and is always sent as 00 00 00.
The preview and the games both transpose this automatically, so "up" in a game looks like up on the keyboard.
- @Evan (Lead Developer)