Omahub
← All plugins
P

Controller Control

by Parminder Klair

Map any game controller's button combos to desktop shortcuts and commands. Works with Xbox, DualSense, 8BitDo and generic pads.

Security review

Review recommended · 1 finding

Deterministic scan — not a security guarantee

Low
Risk level
Low
Analyzed commit
d5ac4cb
Scanned
1 month ago

Flagged patterns appear only in documentation files (README / docs) — descriptive examples, not executable code.

  • Docs package_manager README.md:46

    System-wide Python package installation (not --user).

    pip install` — the engine is pure

Automated analysis only — not a security guarantee.

AI advisory review

No obvious issues detected

Language-model assessment · ~deepseek/deepseek-v4-flash-latest — advisory only

Low
AI risk level
Low
Recommendation
install
Model
~deepseek/deepseek-v4-flash-latest
Analyzed commit
d5ac4cb
Reviewed
1 month ago

The plugin is a well-structured, pure-stdlib Python daemon that reads gamepad input and executes user-configured actions. It requires explicit user consent to configure bindings and does not perform any hidden or destructive operations. The only flagged finding is a documentation mention of pip install, which is not part of the actual code.

  • The plugin executes arbitrary shell commands from user-configured bindings via /bin/sh -c, which is a powerful feature that could be misused if a malicious config is loaded, but it requires explicit user action to create such bindings.
  • The plugin requires access to /dev/input and /dev/uinput, which are sensitive devices; however, these permissions are standard for input remapping tools and are clearly documented.
How this check works

This review combines the deterministic scan (the rule-based results above) with an independent look at the plugin's code by a language model. The model reads a trimmed sample of the repository's files, the manifest, and the README, then gives a plain-language risk level and a recommendation: install (no notable danger), review (look closer first), or avoid (clearly dangerous).

It runs on the same analyzed commit as the deterministic scan and is strictly advisory — it is not a security guarantee and never blocks a plugin by itself. A human moderator still reviews plugins before they are listed.

AI advisory only — automated analysis, not a security guarantee.

Install
$ omarchy plugin add https://github.com/perminder-klair/omarchy-controller-control --enable
Hardware #Hyprland #bar #quickshell

Controller Control

Map game controller button combos to desktop shortcuts and commands, as an Omarchy shell plugin.

Press L + R to take a screenshot. Press START to open the Omarchy menu. Hold SELECT + START to lock the screen. Any chord, any action.

Works with any controller Linux already recognises — Xbox, DualShock and DualSense, 8BitDo, Switch Pro, and generic USB or Bluetooth pads. If it shows up in /dev/input, this can bind it. Nothing is hardcoded to a particular model: the defaults match any gamepad, and the button names come from the kernel rather than from a per-device database.

The bindings panel, showing L and R held on the controller

The panel lists what every combo does and what it sends. The pills beside the switch are the buttons being held right now, so the pad answers while your hands are on it.

Why it exists

Omarchy already has 200+ keybindings. This does not try to replace them: a binding can simply type an existing shortcut, so A -> SUPER+RETURN opens a terminal through the keybinding you already have. Nothing is duplicated.

Install

omarchy plugin add https://github.com/perminder-klair/omarchy-controller-control.git --enable

From a local checkout instead:

./tools/install-plugin.sh
omarchy plugin enable co.klair.controller-control

tools/install-plugin.sh is a developer convenience for working on the plugin from a git checkout: it copies the tree into ~/.config/omarchy/plugins/co.klair.controller-control, runs omarchy plugin validate, and asks the shell to rescan. It is never run by omarchy plugin add, and installing from the marketplace does not execute it.

There is nothing to compile and nothing to pip install — the engine is pure Python standard library, which is also why the plugin needs no install hook.

Requirements

External dependencies: none. The engine is pure Python 3 standard library — no pip packages, no compilation, no install hook. It uses python3, which Omarchy already ships.

Two permissions, both stock on an Omarchy install:

  • Your user must be in the input group to read /dev/input. Check with id.
  • /dev/uinput must be writable, for keys actions.

controller-control doctor verifies both and tells you what is missing.

Pairing a controller

Any pad that pairs with Linux works. Over Bluetooth:

bluetoothctl
> scan on
> pair <MAC>
> trust <MAC>      # so it reconnects on its own next time
> connect <MAC>

Then confirm the kernel sees it:

controller-control devices

Xbox Wireless Controller pairs and works out of the box on a stock Omarchy install. DualSense / DualShock 4 likewise. Wired USB pads need no pairing at all — plug them in and they appear.

8BitDo pads

8BitDo controllers boot into whichever mode they were last used in, and the mode decides whether Linux sees them at all. Hold the combination while powering on:

Hold on power-on Mode Linux over Bluetooth
START + B D-input works
START + X X-input intended for the 2.4G dongle / Windows
START + A macOS works
START + Y Switch works

In D-input these pads report the d-pad on the left stick axes rather than the hat, so set device.dpad_source to "stick" — see D-pad source.

Usage

The bar widget shows a controller glyph — solid when a pad is connected, dim when not, and it pulses whenever a binding fires. Click it to open the bindings panel; middle-click toggles the pad off and on without opening anything.

The panel header carries the master switch, along with the buttons you are holding right now, so you can watch the pad answer while your hands are on it.

To add a binding: press Record combo, then press the buttons on the controller. Firing is muted while recording, so capturing a combo cannot also run whatever that combo is currently bound to. Then type the keys or command, and save.

Everything is also available from the CLI:

controller-control doctor          # check permissions, config, and the pad
controller-control devices         # what the kernel sees
controller-control monitor         # print button tokens as you press them
controller-control learn           # record one combo and print its token
controller-control list            # show configured bindings
controller-control bind "L + R" --keys SUPER+RETURN --description Terminal
controller-control bind START --exec 'omarchy menu'
controller-control unbind "L + R"
controller-control test "L + R"    # run a binding's action now
controller-control disable         # stop everything firing, keeping bindings
controller-control enable
controller-control toggle
controller-control pause           # transient mute, not written to the config
controller-control pause --resume

The binary lives inside the plugin folder; add it to your PATH with:

export PATH="$HOME/.config/omarchy/plugins/co.klair.controller-control/bin:$PATH"

Uninstall

omarchy plugin disable co.klair.controller-control
omarchy plugin remove co.klair.controller-control

Disabling stops the daemon and takes the widget off the bar; removing deletes the plugin folder. Neither touches your bindings, which live outside the plugin at ~/.config/controller-control/. Delete that directory too if you want it gone completely:

rm -rf ~/.config/controller-control

Nothing else is left behind: no system files, no services, no udev rules, and the only runtime artefact is a pid file under $XDG_RUNTIME_DIR that goes when the daemon stops.

What it writes

Only its own files, and only when you ask:

Path When
~/.config/controller-control/config.json when you add, remove or toggle a binding
$XDG_RUNTIME_DIR/controller-control.pid while the daemon is running

It does not modify your Hyprland config, your keybindings, or any other application's settings. Adding the widget to the bar is done by omarchy plugin enable, which is Omarchy's own command and only runs when you invoke it.

Button names

Buttons are named by the Linux/evdev convention, which follows the Xbox layout: A is the south face button, B east, X north, Y west. This is what an Xbox, DualSense or generic pad reports, and it is why the tokens are named this way rather than after any one manufacturer. Also available: L, R, L2, R2, SELECT, START, MODE, THUMBL, THUMBR, DPAD_UP/DOWN/LEFT/RIGHT, and stick directions such as LSTICK_LEFT.

Nintendo-layout pads (8BitDo, Switch Pro) put A on the right and B on the bottom — the opposite of where evdev expects them. Whether that matters depends on the firmware: an 8BitDo Zero 2 in D-input is label-faithful, so it sends the code for A when you press the button printed "A" and nothing needs swapping. Other pads and other modes are not guaranteed to do this.

So: don't reason about it, measure it. controller-control learn (or the panel's Record combo) reports exactly what your pad sends. If your pad turns out to be position-faithful instead, set device.layout to "nintendo" to relabel the face buttons in the UI so they match the plastic.

D-pads arrive as a hat axis and analog triggers as an axis; both are translated into ordinary button tokens for you.

D-pad source

Nearly every controller reports its d-pad on the hat axis, which arrives as DPAD_UP/DOWN/LEFT/RIGHT. Some — 8BitDo pads in D-input mode — send it on the left stick axes instead, where it arrives as LSTICK_*.

Capability sniffing cannot tell these apart: an 8BitDo Zero 2 advertises a hat it never uses and declares its d-pad axes with an analog 0–255 range, so its descriptor looks exactly like a pad with a real stick. Rather than guess wrong, this is a setting:

"device": { "dpad_source": "stick" }

That renames the stick events to DPAD_*, so the ordinary d-pad bindings work unchanged. It is safe on such pads because they have no separate analog stick to lose.

The default is "hat", and nothing in the shipped defaults binds an analog stick — on a pad that has one, nudging it to change the volume or move window focus would be a nasty surprise.

To find out which yours uses, run controller-control monitor and press the d-pad.

Aliases

Any token can be renamed, which is the escape hatch for a pad that reports something unusual:

"device": { "aliases": { "THUMBL": "SELECT", "L2": "L" } }

Configuration

~/.config/controller-control/config.json:

{
  "version": 1,
  "enabled": true,
  "device": {
    "match": [],
    "exclusive": false,
    "layout": "auto",
    "dpad_source": "hat",
    "aliases": {}
  },
  "settings": {
    "combo_window_ms": 45,
    "hold_ms": 450,
    "repeat_delay_ms": 400,
    "repeat_interval_ms": 110
  },
  "bindings": [
    { "combo": "START", "keys": "SUPER+SPACE", "description": "Omarchy menu" },
    { "combo": "L + R", "exec": "omarchy capture screenshot" },
    { "combo": "DPAD_UP", "keys": "VOLUMEUP", "repeat": true },
    { "combo": "SELECT + START", "keys": "SUPER+CTRL+L", "hold": true }
  ]
}

Bindings take either keys (typed on a virtual keyboard, so Hyprland handles it exactly like a real shortcut) or exec (a shell command). Options:

  • repeat — fire again while held, for volume and brightness.
  • hold — only fire after a long press, for anything destructive.
  • enabled: false — keep a binding around without it firing.

enabled is the master switch. Turning it off keeps every binding and keeps the pad connected — nothing fires until it goes back on. It is what the panel toggle and controller-control disable write.

device.match is a list of case-insensitive substrings matched against the device name. It defaults to empty, which matches any gamepad — narrow it when you own several pads and only want one of them driving the desktop. A keyboard-shaped device (an 8BitDo in keyboard mode) is only picked up when you name it explicitly, so an empty list can never latch onto your real keyboard.

device.exclusive grabs the pad so nothing else — games, Steam, the browser — sees it. Off by default, so a controller can drive your desktop and still work in games.

The daemon reloads on SIGHUP, which is what the panel and omarchy-shell controller reload use, so edits apply without dropping the Bluetooth connection.

How chords are resolved

  • Bindings fire on press, never on release, so letting go of L + R cannot also trigger R.
  • When the buttons held could still grow into a longer binding, the decision waits combo_window_ms. When no longer binding could match, it fires instantly — single presses have no added latency.
  • A binding that has fired is latched until one of its own buttons is released, so holding a chord does not retrigger it.

This means A and A + B can coexist: tapping A fires A, and pressing both fires only A + B.

IPC

omarchy-shell controller status    # JSON: running, connected, enabled, paused, …
omarchy-shell controller reload    # re-read the config
omarchy-shell controller restart   # bounce the daemon
omarchy-shell controller disable   # master switch off
omarchy-shell controller enable
omarchy-shell controller toggle

The daemon publishes its pid to $XDG_RUNTIME_DIR/controller-control.pid so the CLI can signal a daemon the shell started. It is verified against /proc before anything is sent to it — a pid file can go stale and the number be recycled, and signalling the wrong process is the kind of mistake that stays invisible until it does damage.

Architecture

omarchy-shell (Quickshell)
├── Service.qml      runs the daemon, parses its JSON event stream, IPC
├── BarWidget.qml    connection glyph, pulses on fire
└── Panel.qml        bindings editor with press-to-record capture
        │
        └── bin/controller-control daemon
                └── engine/   pure-stdlib evdev reader + chord recogniser

The daemon reads /dev/input/event* directly and emits one JSON object per line, which is what the QML layer consumes. keys actions are typed on a uinput virtual keyboard, so the compositor treats them as a real keyboard.

Device inspection is cached per device node: opening an input node blocks for around 20ms in the kernel, so re-inspecting every node to spot a hotplug would stall the daemon's timers. Listing the nodes is a sub-millisecond glob, and only genuinely new nodes are opened.

Tests

python3 -m unittest discover -s tests -v

44 tests, no dependencies. The end-to-end suite fabricates a synthetic gamepad over uinput and drives the real daemon process against it, so chord resolution, hotplug, disconnect, repeat, and hold are all covered without touching real hardware.

License

MIT