A reverse-engineered, from-scratch Linux driver and daemon for the Ragnaros USB control deck (Redragon SS552, USB 0200:3001) - a Linux-native alternative to the vendor's Windows software.
The daemon renders icons and animated GIFs onto the deck's LCD keys and touch strip, and runs a configurable command on every input event.
- 10 LCD keys (2x5 grid, 112x112 px each) with static icons or animated GIFs
- 4 rotary knobs with rotate (CW/CCW) and press actions, with detent debouncing
- 4-segment LCD touch strip (704x124 combined): taps and swipes are real inputs, and it shows now playing, a system dashboard, desktop notifications or idle animations (generated art by default, opt-in GIF themes)
- Key layers - a second set of keys over the same profile, on a knob press
- Hot profile switching from a key, a swipe or the CLI
ragnarosctl- a control socket for scripts, shortcuts and CI- Desktop aware - toasts on the strip, and the deck goes dark when the session locks
- YAML keymap profiles mapping any input to a command or a deck verb
- Runs as a systemd user service
The vendor protocol was reverse-engineered from the Windows SDLibrary1.dll - no vendor SDK is used.
It turned out to be the Mirabox / Ajazz "Stream Dock" protocol (CRT\0\0 commands, ACK input
reports), the 1024-byte "v3" variant also used by the Mirabox N4 / Ajazz AKP05; see
mirajazz and opendeck-akp05,
which lists the SS552.
ragnaros/daemon.py ──> protocol.py ──(ctypes / libusb-1.0)──> Ragnaros deck
│ JPEG frames in 1024-byte chunks to display slots
├── renderer.py ── PIL: icons / GIFs -> JPEG
├── inputmap.py ── HID input reports -> key / knob / strip events
└── profiles/*.yaml ── events -> commands
- Output: images sent as JPEG payloads over the interrupt OUT endpoint
0x03(1024-byte packets) with aCRTcommand header; keys map to display slots 5-14, strip segments to 0-3 (content rotated 180°) - Input: reports polled from EP
0x82, decoded intokey,knob,knob_press,strip_touch, andstrip_swipeevents
Requires Linux with libusb-1.0, udev and systemd. Every route needs one reboot
after the first install, so usbhid loads with the deck's quirk and lets libusb claim it.
Every v* tag builds ragnaros_<version>-1_all.deb (ragnaros_0.1.1-1_all.deb for v0.1.1)
and attaches it to the releases page
(packaging/deb/build-deb.sh builds it locally from a wheel, with nfpm):
sudo apt install ./ragnaros_*_all.deb # udev rules, usbhid quirk, user units
sudo reboot
systemctl --user enable --now ragnarosddebian/ is a policy-compliant source package (pybuild, DEP-5 copyright, man pages,
opt-in user units) for a Debian upload. It builds from a +ds repack of the release
tarball without the app-logo key icons, which aren't DFSG-free; see
debian/README.source. The release .deb above is still the nfpm one.
The PKGBUILD lives in packaging/aur/ and builds from GitHub's tarball of the release tag:
cd packaging/aur && makepkg -si # or, once it is on the AUR: yay -S ragnaros
sudo mkinitcpio -P && sudo reboot # if your initramfs carries usbhid
systemctl --user enable --now ragnarosdIf usbhid is built into your kernel, add usbhid.quirks=0x0200:0x3001:0x00000004 to the
kernel command line instead (ragnaros install-system --print tells you which case you are in).
pipx install git+https://github.com/ValentinTorassa/VT-Ragnaros # or `pipx install .` in a clone
ragnaros install-system # udev rules + usbhid quirk into /etc, with sudo (--print to review first)
sudo reboot
ragnaros install-service # ~/.config/systemd/user/ragnarosd.service for this install
systemctl --user start ragnarosdAll three install the same commands:
| command | what it does |
|---|---|
ragnarosd |
the daemon (run by the ragnarosd user unit) |
ragnarosctl |
drive the running daemon (see Control CLI) |
ragnaros |
install-system, install-service, paths, diagnose (see Reporting a new device) |
ragnaros-fetch-gifs |
opt-in anime GIF themes for the strip (see GIF themes) |
ragnaros-claude-hook |
Claude Code hook, and review / label / eval for its optional Jev log (see Notices that wait for you) |
ragnaros-strip-gif |
convert any GIF to the 704x124 full-strip format |
ragnaros-preview-strip |
render strip layouts to a PNG without the deck |
ragnaros-usage |
summarize the usage log |
git clone https://github.com/ValentinTorassa/VT-Ragnaros
cd VT-Ragnaros
./install.shThe installer:
- Symlinks
daemon/,profiles/,assets/andtools/into~/.config/ragnaros, so edits in the checkout are live - Installs udev rules (uaccess +
plugdevgroup) and ausbhidkernel quirk for the device - requires sudo and one reboot - Installs and enables the
ragnarosd.servicesystemd user unit - Installs Python dependencies (
pillow,pyyaml) if missing
Then start it with systemctl --user start ragnarosd, and check it anytime with ./install.sh doctor.
Profiles and images are looked up in your config first, then in the data directory, then in
the ones shipped with ragnaros; ragnaros paths prints the actual directories.
| path | |
|---|---|
| your profiles / icons | ~/.config/ragnaros/profiles/, ~/.config/ragnaros/assets/ |
| downloaded GIF themes | ~/.local/share/ragnaros/gifs/ |
| state, usage log, album art | ~/.local/state/ragnaros/ |
| Claude hook settings (optional) | ~/.config/ragnaros/claude-hook.yaml |
A profile in ~/.config/ragnaros/profiles/ with the same name as a bundled one replaces it.
Profiles live in profiles/ as YAML files. Bundled: streaming (default), work, ai-tools, stream (OBS) and system - a dashboard tab whose strip stays on the metrics and whose first four keys pick which cards it shows.
Each profile maps 10 keys and 4 knobs to actions, plus optional idle strip GIFs:
name: streaming
strip:
segments: # optional: one GIF per panel, from any assets directory
- gifs/segments/mine.gif
- strip/default_1.gif
keys:
"0":
icon: icons/brave.png
action: gtk-launch com.brave.Browser
"6":
icon: icons/mic_mute.png
action: wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle
"9":
icon: icons/work.png
profile: work # switch to another profile
knobs:
"0":
rotate:
cw: wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+
ccw: wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-
press: wpctl set-mute @DEFAULT_AUDIO_SINK@ toggleKeys accept action (a command or a deck verb), profile (hot switch),
layer (momentary while held) or layer_toggle. The deck verbs are pomodoro,
dashboard[:cards], obs:*, profile:<name> and layer:<name>.
Commands run without a shell: the line is split the way a shell splits words
(quotes work) and started directly, so pipes, &&, redirections and $VARS are
passed through as plain arguments. Wrap those in sh -c '...'. A ~ at the start
of an argument, or right after --option=, expands to your home directory.
action: dashboard:cpu,ram,temp,gpu pins that set of cards on the strip; pressing
the same key again puts the strip back. strip.pinned: dashboard does it for a
whole profile, which is what system.yaml uses.
Priority, top to bottom: a transient overlay (knob feedback, a toast), then a
pinned dashboard, then now playing, then the idle face. After ~10 s of inactivity
the idle face alternates between the segment GIFs and the dashboard every 60 s
(strip.idle_seconds). A profile with strip.pinned: dashboard never rotates.
The segment GIFs come from the profile's strip.segments, else from your
GIF theme, else from the generated idle art that ships with ragnaros
(assets/strip/, made by tools/gen_assets.py --strip).
strip:
segments: [gifs/segments/mine.gif, ...] # one GIF per 176x124 panel
gif: gifs/wide.gif # or one 704x124 GIF across the strip
pinned: dashboard # this profile owns the strip
idle: [gifs, dashboard] # idle rotation (default: both)
idle_seconds: 60 # how long each face holds the strip
dashboard: [clock, cpu, ram, temp] # gpu, disk, net, battery, uptime
players:
ignore: [chromium, brave] # keep browser tabs off the strip
touch:
"3": dashboard # per-panel tap actions
swipe:
left: profile:work
right: profile:streamingWithout a touch mapping, tapping the last panel pins or unpins the dashboard
and, while music plays, the first three panels are play/pause, previous and next
- they sit right under the art, title and artist cards. A swipe cycles profiles.
The repository ships no third-party GIFs. ragnaros-fetch-gifs (in a checkout:
python3 tools/fetch_gifs.py) downloads an anime theme from Tenor search results into
~/.local/share/ragnaros/gifs/ on your machine and writes the rotation to gifs/theme.yaml
there; every profile without its own strip.segments picks it up within seconds.
ragnaros-fetch-gifs --theme jjk # or evangelion; fetches 3 new GIFs
ragnaros-fetch-gifs # 3 more for the current theme
ragnaros-fetch-gifs --off # back to the bundled idle art
systemctl --user enable --now ragnaros-gif-refresh.timer # weekly refreshtheme.yaml can also pin a rotation per profile (profiles: {system: [...]}) and keep
two anchors per theme on the end panels. The GIFs belong to their owners; they stay in
your data directory.
A layer lays a second set of keys over the profile; keys it does not define fall
through to the base. The bundled profiles put a media layer on knob 2's press.
layers:
media:
keys:
"0": {icon: icons/media_prev.png, action: playerctl previous}
"9": {icon: icons/layers.png, layer_toggle: media} # back to baseDesktop notifications are read off the session bus with dbus-monitor and shown
as a 4 s card. Mute them per profile:
notifications:
enabled: true
seconds: 4
ignore: [Spotify, Thunderbird]ragnarosctl talks to the running daemon over a unix socket
($XDG_RUNTIME_DIR/ragnaros.sock, mode 0600), so shortcuts, scripts and CI can
drive the deck:
ragnarosctl status # profile, layer, what the strip shows
ragnarosctl profile work # or: next, prev, list
ragnarosctl layer media # or: base, list
ragnarosctl dashboard toggle # or: on, off, "cpu,ram,temp,gpu"
ragnarosctl brightness 40
ragnarosctl reset-display # firmware sleep/wake and full repaint
ragnarosctl toast "Build passed" "12 tests, 4.2s" --app=ci --seconds=6
ragnarosctl key 6 # run a key's action without touching it
ragnarosctl alert "Claude terminó" "VT-Ragnaros" # holds until you press a key
ragnarosctl alert --clear
ragnarosctl watch [--grab] # stream every input as JSONAdd --json for machine-readable replies.
A toast is gone in four seconds; an alert owns the strip until someone presses a key on the deck, and the last card counts how long it has been waiting. That is the shape of "come back and look at this".
The bundled tools/claude_hook.py is a Claude Code Stop hook: when a session
finishes, the deck shows the project and the last thing Claude said for eight
seconds, then hands the strip back to the GIFs or the dashboard. Set
RAGNAROS_CLAUDE_HOOK_MODE=alert to make it an alert that waits instead, and
RAGNAROS_CLAUDE_HOOK_SECONDS to change how long the toast stays.
{ "hooks": { "Stop": [ { "hooks": [ {
"type": "command",
"command": "ragnaros-claude-hook"
} ] } ] } }From a checkout without installing, use python3 ~/path/to/VT-Ragnaros/tools/claude_hook.py.
It exits 0 whatever happens, so a stopped daemon or an unplugged deck never
breaks the session. Registered as a Notification hook instead, it raises the
notice when Claude is waiting for a permission answer.
A stop can mean three things: Claude waits for you (a question, a permission, a choice), it finished, or it got stuck (an error it could not get past, a missing credential, it gave up). Only the text says which, so the hook can ask Jev (TypeSafe AI, through Vercel AI Gateway), a small decision model that answers closed questions with probabilities and never writes text. With Jev in charge, waiting and stuck hold the strip as an alert and a finished session gets the usual toast.
Rules come first and Jev only gets the doubtful case:
| stop | class | Jev asked? |
|---|---|---|
Notification for a permission, an idle prompt or a question dialog |
waiting | no |
StopFailure (the turn ended on an API error) |
stuck | no |
Stop with background tasks or session crons still in flight |
as today | no |
a session under a folder in jev_skip |
as today | never |
any other Stop |
Jev's pick | yes |
Settings live in ~/.config/ragnaros/claude-hook.yaml (read on every stop: no restart):
jev: shadow # off (default) | shadow | on
# jev_provider: vercel # vercel (default, Vercel AI Gateway) | openrouter
jev_skip: # folders whose sessions never reach Jev, matched against the
- ~/work/client # session's cwd and the folder it started in (symlinks resolved)
# jev_question: en-1 # the question set: en-1 (default) or es-1, the same in Spanish
# jev_timeout: 1.5 # seconds, capped at 1.5
# jev_min: 0.5 # in `on`, Jev's pick needs at least this probability- off: nothing is called, nothing is logged; the hook behaves exactly as before.
- shadow: the notice is today's, and a detached worker asks Jev and logs what it would have picked next to what the hook did.
- on: the worker shows Jev's pick when it reaches
jev_min, and today's notice when it does not, or when anything fails (no key, timeout, HTTP error, odd reply).
RAGNAROS_JEV=off|shadow|on overrides the mode, RAGNAROS_JEV_PROVIDER the provider,
RAGNAROS_JEV_MODEL the pinned model (typesafe-ai/jev-1.14.0 on the gateway,
typesafe/jev-1.14 on OpenRouter), and RAGNAROS_JEV_SKIP (a
:-separated list) adds folders to the skip-list. A config file that does not parse
turns Jev off, since its skip-list may be all that keeps a folder at home.
Key. A Vercel AI Gateway key in $AI_GATEWAY_API_KEY, or in
~/.config/ragnaros/jev.key ($RAGNAROS_JEV_KEY_FILE), which must be a plain file only
you can read: chmod 600. The gateway is asked to route only to TypeSafe. With
jev_provider: openrouter the variable is $OPENROUTER_API_KEY and the key file holds
that key instead. The key never reaches the logs.
What leaves the machine. Only Claude's last message, cut to its first 300 and last 1,200 characters, with anything shaped like a token, key or password masked; its length; and the stop reason and last tool when the transcript has them. Never the project, the path or the session id. The message is sent as data, and the questions tell Jev to ignore any instructions inside it.
Speed. The hook itself only adds the time to start the worker (about 10 ms); the
worker runs in its own session with no shared output, so Claude never waits for it.
The whole decision has a hard 1.5 s wall clock. In on mode the notice comes from the
worker, so it can show up to that much later.
The log. Every decision is one JSON line in ~/.local/state/ragnaros/claude-jev.jsonl
($RAGNAROS_JEV_LOG), mode 600: time, id, session, project, the model, the question set,
the provider, the hook's class and signal, Jev's class, probabilities, signal, latency,
cost and input tokens (when the gateway reports them), what was actually shown, any
error, and the excerpt Jev read. To turn it into labels:
ragnaros-claude-hook review # where Jev and the hook disagree, newest first
ragnaros-claude-hook label 8b2119ff11 waiting
ragnaros-claude-hook eval # the hook vs Jev (and `on` at each jev_min) on the labelsFrom a checkout, python3 tools/claude_hook.py review works the same. Labels go to
claude-jev-labels.jsonl next to the log; nothing in these commands calls Jev.
ragnarosctl watch keeps its connection open and prints one JSON line per
input, so a long-running script can react to the deck instead of launching
one-shot commands:
ragnarosctl watch --json | while read -r event; do
case "$(jq -r '.type + ":" + (.index|tostring)' <<<"$event")" in
key:4) ./deploy.sh ;;
strip_swipe:1) ./next-take.sh ;;
esac
done--grab additionally suppresses the profile's own actions while the script is
connected, so the deck becomes a blank input device; the grab is released the
moment the client disconnects. Profile switches, layer changes and alerts are
published on the same stream.
Ragnaros drives one deck, the SS552 (0200:3001). Other Redragon decks share its USB
vendor ID 0200 (which lsusb labels "TP-Link") and may or may not speak the same
protocol. If you have one, two things show whether it can be added.
1. A device report. ragnaros diagnose prints a Markdown report on every vendor-0200
device plugged in: IDs and strings, interfaces and endpoints, the HID report descriptors,
the hidraw and USB device nodes with their permissions, which udev rules and usbhid quirks
cover it, and the ragnaros, kernel and distribution versions. It only reads sysfs and the
permissions in /dev: the deck is never opened, so it needs no root and is safe while the
daemon runs. It leaves out the serial number, the hostname and user names.
ragnaros diagnose > report.md # --device VID:PID reports any other devicePaste report.md into an issue, with how many keys, knobs and screens the deck has. What
to try next goes to the terminal, not to the file.
2. An experimental test. ragnarosd --experimental-pid 0x2000 (or
RAGNAROS_EXPERIMENTAL_PID=0x2000) also opens that product ID and drives it with the SS552
protocol. It is off unless you ask for it, it is marked experimental in the daemon's log and
in ragnarosctl status, and it logs every raw input report the deck sends. Nothing about
it is known to work.
The udev rules only give your user the SS552, so hand yourself the deck's USB node (the
device node line of the report) until you unplug it, instead of widening the rules:
sudo chown "$USER" /dev/bus/usb/005/002 # the device node from the report
systemctl --user stop ragnarosd # if it runs
ragnarosd --experimental-pid 0x2000 # stays in the foreground; Ctrl+C stops it
ragnarosctl watch --grab # in a second terminal: press every key, turn every knob--grab keeps the profile's actions from running while you press things. Report whether
icons show up and on which keys (a photo helps), and paste the output of both terminals.
Unplugging the deck undoes the chown. From a git checkout the commands are
python3 -m ragnaros.cli diagnose, python3 daemon/ragnarosd.py and tools/ragnarosctl.
The user-facing tools are installed as commands (table above). The checkout also keeps:
tools/gen_assets.py- generates the 112x112 key icons (Font Awesome glyphs + app icons, including animated tiles) and, with--strip, the default strip idle arttools/strip_order.py- paints 1 2 3 4 on the panels to confirm their physical ordertools/probe_modes.py- walks the firmware's undocumented MOD command, dumping the feature and input reports at each step. Findings so far: modes 0-9 change no reported state and never break input, and every feature report (0-3) and input report 1 return the same firmware string,V3.SS_552.02.009- a version identifier, not device statetools/sniff.py,handshake.py,probe*.py,wake.py,listen.py,paint*.py- reverse-engineering and protocol discovery utilitiestools/ragnarosctl,tools/claude_hook.py,tools/fetch_gifs.py,daemon/ragnarosd.py- entry points that run the commands straight from the checkout (whatinstall.shwires up)
- Python 3.9+ with
pillowandpyyaml libusb-1.0(loaded via ctypes, no Python binding needed)- DejaVu fonts (
fonts-dejavu-core/ttf-dejavu) for the strip text - Linux: udev rules, usbhid quirk, systemd user units
playerctl,wpctl,brightnessctlfor the bundled actionsdbus-monitor(Debian:dbus-bin) for toasts and lock dimming - optional
MIT - see LICENSE.
To cite this software, use the metadata in CITATION.cff.