Turns a cheap USB macropad into a programmable control surface on Omarchy.
Built for a 12-key, 2-knob macropad (USB 1189:8840) — sold under the Sikai
name, and by plenty of sellers unbranded as a "12 key macro keyboard with two
knobs". The device identity is in the config, so any pad that reports plain
keycodes will work. The pad is matched on any bus: plugged in over USB, or paired over
Bluetooth, where it shows up as MINI_KEYBOARD with a borrowed Apple id
(05ac:022c). Both can be live at once; each is grabbed.
These pads advertise three hardware layers. On Linux the layers are a fiction: every layer sends the same keycodes, and pressing the layer button sends nothing at all — verified by reading the raw HID reports on both the standard and the vendor-specific interface, where the vendor channel stayed completely silent. Only the manufacturer's Windows tool can write different keycodes into layers 2 and 3.
So the layers here are ours. The daemon holds the layer, the bar widget shows it, and any control can be made the switch. Name each layer after an app — "Resolve", "Signal", "Blender" — and the same twelve keys become a different set of keys for each.
The keys also send plain letters a–l, which cannot be used as global
hotkeys without breaking typing. The daemon therefore grabs the pad
exclusively, so its keys stop reaching anything else.
Two commands. The second one is the whole setup.
omarchy plugin add https://github.com/mfilm77/omarchy-keypad --enable --yes
~/.config/omarchy/plugins/io.github.mfilm77.keypad/bin/keypad-setupkeypad-setup creates your bindings file, installs and starts the background
service, and — after printing them — runs the three steps that need root. It
asks for sudo once. It never overwrites a bindings file, a service file or
a udev rule you already have: anything already in place is reported and left
alone, so running it twice is safe.
Then press a key on the pad.
keypad-setup --checklists every prerequisite and what to do about each one:
[ ok ] udev rule (lets you read the pad without being root)
[ ok ] uinput (how the pad's shortcuts are typed)
[ ok ] your bindings file
[ ok ] the background service that reads the pad
[ ok ] the keypad itself
found at /dev/input/event23
The panel shows the same thing in plain words when something is missing, so a pad that is not working tells you why rather than leaving you to guess.
Reading a keyboard is a privileged act. The udev rule grants access to this
one pad rather than adding you to the input group, which would mean read
access to every keyboard on the machine — a keylogger for anything running as
you. The same rule grants /dev/uinput, which is where shortcut bindings are
typed from, and the uinput module has to be loaded for that node to exist.
Those three steps are printed in full before they run, and they run in a single
sudo invocation you can see. Nothing is escalated silently. If you would
rather do it yourself, keypad-setup --check names each file, and the commands
are:
sudo install -m 644 share/70-omarchy-keypad.rules /etc/udev/rules.d/70-omarchy-keypad.rules
printf 'uinput\n' | sudo tee /etc/modules-load.d/omarchy-keypad.conf
sudo modprobe uinput && sudo udevadm control --reload && sudo udevadm triggerA rule installed while the pad is already connected reaches it on the next
re-trigger, which keypad-setup does for you. If the pad still is not read,
switch it off and on once so it re-attaches under the new rule, then
keypad-setup --check.
Omarchy 4 (Quattro) with the Quickshell-based shell. No pip packages — the daemon reads evdev
directly from the Python standard library. It shells out to hyprctl (window/workspace actions and
the recording submap), systemctl --user (its own unit), qs (the shell IPC) and, only if present,
bluetoothctl for the Bluetooth battery reading. The uinput kernel module is needed for shortcut
bindings.
systemctl --user disable --now omarchy-keypad.service
rm -f ~/.config/systemd/user/omarchy-keypad.service && systemctl --user daemon-reload
omarchy plugin remove io.github.mfilm77.keypad
sudo rm -f /etc/udev/rules.d/70-omarchy-keypad.rules /etc/modules-load.d/omarchy-keypad.conf
sudo udevadm control --reload && sudo udevadm triggerYour bindings are left alone at ~/.config/omarchy-keypad/config.json — delete that directory too if
you want them gone. Nothing outside these paths is touched, and no existing configuration is
overwritten at install: the udev rule and the systemd unit are new files under this plugin's own
name, and the recording submap is registered at runtime rather than written into your Hyprland config.
Click the bar widget. Click a key or a knob in the picture, give it a name, and choose what it does:
- Run a command — any shell command, e.g.
hyprctl dispatch 'hl.dsp.focus({ workspace = "3" })'(Hyprland 0.56+ takes Lua; older setups usehyprctl dispatch workspace 3). Pick from the Preset list to fill the field with one of the commands the plugin ships — menus, capture, sound, windows, workspaces, apps, system — or type your own. A preset fills the field in; it does not lock it. - Press a shortcut — click the box, press the chord on your keyboard, and that is what the key will press. Recorded, not typed, so there is nothing to spell.
- Open an app — search the same launcher entries
SUPER + SPACEoffers.
A recorded shortcut can be tapped or held. Tap is the default and is
right for nearly everything. Hold keeps the chord down for exactly as long as
the pad key is down, which is what anything that watches key-down and key-up
separately needs — push-to-talk dictation being the usual one. (For dictation
specifically, the command voxtype record toggle is often nicer: press to
start, press to stop, and your hand is free in between.)
Save. Changes are live — the daemon rereads on SIGHUP and never drops the
grab, so no keypress escapes as a stray letter mid-edit.
share/config.default.json ships three worked layers, so the pad does something
useful the moment it is installed. Rebind anything you don't want.
| Layer 1 — Main | Layer 2 — System | Layer 3 — Media | |
|---|---|---|---|
| Keys 1–4 | Workspaces 1–4 | Gaps · Transparency · Fullscreen · Layout | Play/pause · Prev · Next · Spotify |
| Keys 5–8 | Terminal · Browser · Files · Screenshot | Record screen · Scan QR · Grab text · Keybindings | Copy · Paste · Undo · Redo |
| Keys 9–12 | Emoji · Clipboard · Omarchy menu · Screensaver | Share · Input · Edit config · About | — |
| Knob 1 | Volume, press to pause all | Brightness, press to mute | Volume, press to pause all |
| Knob 2 | Press for the next layer | Press for the next layer | Press to go back to Main |
Pressing knob 1 pauses, it does not mute. Muting leaves the video running
and the podcast talking to nobody; keypad-media-pause-all stops the playback
itself — whichever player is actually making the sound, be it a YouTube tab, a
video player, or the Plex Music plugin. Press it again and exactly those players
resume, not every idle tab that happens to still be open. It speaks MPRIS over
the session bus with busctl, so it needs nothing installed, and it does
nothing quietly when nothing is playing. Mute is still there whenever you want
it — on layer 2, and in the editor's Sound presets.
Layers 1 and 2 are all Omarchy commands. Layer 3 shows the other binding type:
Copy, Paste, Undo and Redo are recorded shortcuts, pressed on the
virtual keyboard rather than shelled out.
To rename the layer you are on, type in the Rename field next to the tabs.
Hyprland runs its binds before a key reaches any window, and a consumed key
never arrives at all — press SUPER+1 in a plain recorder and it would see SUPER
go down, then the workspace change. While recording, the panel therefore enters
a Hyprland submap that has no useful binds (bin/keypad-record-mode), so every
chord falls through to it. The submap is registered at runtime the first time
it is needed; nothing is added to your Hyprland config. It is left again the
moment a chord lands, on Escape, on close, or after 20 seconds regardless.
The daemon creates a virtual keyboard through uinput and presses the recorded
keys on it. The compositor sees a real keyboard with your own keymap, so binds
written by keycode (Omarchy binds workspaces as code:10…) fire exactly as they
do from the physical keyboard, and app shortcuts arrive as ordinary key events.
That is why uinput rather than wtype: a wtype keymap carries its own
keycodes and misses every code: bind.
This plugin exists because the hardware got in the way. The pad is fine, but its layers are a fiction on Linux, its keys type letters into whatever has focus, and the only tool that can change any of that runs on Windows. All of that is worked around here rather than fixed.
It could be fixed. If enough people want one, I would look at making a keypad properly — designed for this instead of adapted to it: different colours, different knobs, and a key layout chosen rather than inherited.
Nothing is for sale and there is nothing to pre-order. This is a straight question about whether the interest exists, and the answer decides whether it goes any further.
If you would want one, say so here — Would you want a keypad built properly for this? — what you would use it for, how many keys and knobs you would actually want, and what it should look like. A 👍 counts if you have nothing to add. Numbers are what decide it.
| Path | What |
|---|---|
bin/keypadd.py |
Reads the pad, holds the layer, runs commands, presses shortcuts |
bin/keypad-record-mode |
Enters/leaves the Hyprland submap used while recording |
bin/keypad-media-pause-all |
Pauses every playing MPRIS player, and resumes those same ones |
Service.qml |
Owns the config and the running state |
Panel.qml |
The editor |
PadGraphic.qml / KnobGraphic.qml |
The drawing of the pad |
BarWidget.qml |
Current layer in the bar |
share/presets.json |
The ready-made commands the editor offers |
~/.config/omarchy-keypad/config.json |
Your bindings |
A binding in config.json is one of:
{ "type": "command", "label": "Workspace 3", "run": "hyprctl dispatch 'hl.dsp.focus({ workspace = \"3\" })'" }
{ "type": "shortcut", "label": "Undo", "keys": "CTRL + Z", "codes": [29, 44] }
{ "type": "hold", "label": "Talk", "keys": "F9", "codes": [67] }
{ "type": "layer", "label": "Next layer" }
{ "type": "layer", "label": "Resolve keys", "to": 1 }devices lists what to grab; each entry is any subset of bus (usb or
bluetooth), vendor, product, name, uniq (the Bluetooth address):
"devices": [
{ "bus": "usb", "vendor": "1189", "product": "8840" },
{ "bus": "bluetooth", "name": "MINI_KEYBOARD", "vendor": "05ac", "product": "022c" }
]codes are Linux evdev keycodes, pressed in that order and released in
reverse. to on a layer binding jumps to that layer (0-based) instead of
cycling.
