Skip to content

About

Controller support plugin for Omarchy

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Controller Support for Omarchy

Drive your Omarchy desktop from a game controller. Buttons become keys, window management, and commands — configured in Lua, the same way ~/.config/hypr is.

Tested against a Sony DualSense (PS5) and a Microsoft Xbox Series controller.

Runs with no elevated privileges: no sudo, no udev rule, no group change, no uinput. Reading a controller is already permitted by systemd, and everything it does to your desktop goes through Hyprland.

Status: Phase 3. Installable with omarchy plugin add, supervised by the shell, with radial menus, a bar widget, dictation and pointer tuning. See Roadmap.

Documentation

File For
README.md Using it — install, configuration, the full ctrl API
ARCHITECTURE.md How it works. Start here to read the source
HARDWARE.md What was measured on real controllers, and the traps
ROADMAP.md What is done, what is left, what was decided against
CLAUDE.md Working agreement, and Omarchy plugin conventions

Contents

What works today

  • Reading any gamepad the kernel exposes, hot-plugged or not
  • Positional button names, so one config drives both pads
  • Modifier layers — hold r1, get a different set of bindings
  • Activators — tap, long press, double tap, release
  • Leader sequences — tap the guide button, then a face button
  • Radial menus on a held button, nested, with labels on screen
  • Simultaneous chords, off unless you declare one
  • Per-app bindings, layered over your base set
  • Sending keys, running Hyprland dispatchers, running commands, arbitrary Lua
  • Config hot reload, with rollback when the config is broken
  • Installs as an Omarchy plugin, supervised by the shell and restarted if it dies
  • A bar widget showing the connected pad and the layer being held
  • Dictation through Omarchy's voxtype, and pointer tuning through hl.device

Requirements

Needs Why
Hyprland 0.56+ The embedded Lua VM and its IPC surface, which is how this reaches the desktop
luajit The daemon. Already an Omarchy dependency
A controller A DualSense needs no driver. An Xbox pad over Bluetooth wants xpadneo

Nothing else is installed and nothing outside your home directory is written.

Install

omarchy plugin add https://github.com/bruce-forte/omarchy-controller-support.git --enable

That clones it into ~/.config/omarchy/plugins/, validates the manifest, enables it, and puts the widget on your bar. Then restart the shell so the service loads:

omarchy restart shell

On first run the daemon writes a starter config to ~/.config/omarchy/controller/ and picks up any connected pad. Press ✕ (or A) and the focused window should receive Return.

The shell owns the daemon from then on — it starts with the shell, stops with it, and is restarted if it ever dies. There is no systemd unit to enable.

Running it without installing

The daemon is a plain executable and runs fine from a checkout, which is the easier way to work on it:

git clone https://github.com/bruce-forte/omarchy-controller-support
cd omarchy-controller-support
./bin/omarchy-controllerd

Only one daemon may run at a time, so disable the plugin (or stop its daemon) before running your own — otherwise the second one refuses to start.

./bin/omarchy-controllerd            # run in the foreground
./bin/omarchy-controllerd --status   # is one running?
./bin/omarchy-controllerd --stop     # stop it
./bin/omarchy-controllerd --help

Set the log level with OMARCHY_CONTROLLER_LOG — debug, info (the default), warn, error. At debug every binding is logged as it fires, which is the fastest way to tell "the binding did not match" from "the binding matched and its target ignored it":

controllerd: debug fire r1 + south [tap] -> Go to app

A second daemon refuses to start rather than opening the pad twice and firing every binding twice. The running pid is recorded at $XDG_RUNTIME_DIR/omarchy-controllerd.pid, and a stale pidfile is ignored — the process behind it is checked before it is believed.

The bar widget

Shows the connected controller, and — the useful half — the layer you are currently holding. Holding a modifier silently changes what every button does, and until the Phase 3 on-screen HUD this is the smallest answer to "which layer am I in".

Shows Means
󰊤 A controller is connected, base layer
󰊤 r1 You are holding r1
󰊤 l1+r1 Both held
󰊤 ! The config failed to load; your previous bindings are still live
hidden No controller connected

The tooltip names the pad and the number of bindings loaded. To keep the icon visible with nothing connected, set showWhenDisconnected on the widget's entry in ~/.config/omarchy/shell.json:

{ "id": "io.github.bruce-forte.controller-support", "showWhenDisconnected": true }

Controlling it from a terminal

The plugin registers an IPC target under its id, so it can be driven from a shell — or by a script, or by an agent:

omarchy-shell io.github.bruce-forte.controller-support status    # pads, mode, layer, bindings, as JSON
omarchy-shell io.github.bruce-forte.controller-support running   # is the daemon up?
omarchy-shell io.github.bruce-forte.controller-support reload    # re-read the config
omarchy-shell io.github.bruce-forte.controller-support restart   # bounce the daemon

Every Omarchy plugin does this, and the whole set is discoverable:

qs ipc -n -p "$OMARCHY_PATH/shell" show

That prints each target with its function signatures, which is how the first-party plugins are driven too (omarchy-shell omarchy.audio toggle, omarchy-shell omarchy.monitor brightness 40).

reload works by touching the config file the daemon already watches, so the reload it triggers is exactly the one a save triggers — rollback included.

Pairing a controller

Omarchy has no menu entry for Bluetooth pairing. Use the Bluetooth widget in the bar (omarchy.bluetooth); it scans automatically and lists devices under discovered, where Enter connects.

Pad Pairing mode
DualSense Start with the controller off. Hold Create (left of the touchpad) and PS together for 3–5 s, until the light bar flashes blue
Xbox Series Press Xbox to power on, then hold the small Pair button on the top edge next to the USB-C port for ~3 s, until the logo flashes rapidly

A DualSense also works over USB, which needs no pairing at all. Over Bluetooth the microphone uses the plugin's experimental raw-HID bridge rather than a standard Bluetooth audio profile.

Configuration

Where config lives

~/.config/omarchy/controller/, laid out like ~/.config/hypr:

File Holds
controller.lua Entry point. Pulls in Omarchy's defaults, then yours
config.lua Timings, and which buttons are modifiers
bindings.lua Your bindings
apps/ Per-app profiles

Saving any of them reloads the daemon immediately.

Load order

The daemon puts these on package.path, mirroring what Omarchy's own bootstrap.lua does for Hyprland:

~/.config/omarchy/?.lua      →  require("controller.bindings")
<plugin>/?.lua               →  require("default.controller.bindings")
<plugin>/lua/?.lua           →  the daemon's own modules

So controller.* is yours and default.controller.* is ours, and your config decides how much of ours to take:

-- ~/.config/omarchy/controller/controller.lua
require("default.controller.controller")   -- Omarchy's defaults
require("controller.config")               -- then yours, layered on top
require("controller.bindings")

Drop the defaults wholesale with either flag, set before the require:

omarchy_controller_default_bindings = false   -- no default bindings
omarchy_controller_default_apps = false       -- no default app profiles

Or surgically, with ctrl.unbind. If ~/.config/omarchy/controller/controller.lua does not exist at all, the daemon loads its own defaults directly, so a pad still works.

Button names

Buttons are named by position, not by the label printed on your pad, because the same physical button is PlayStation's ✕ and Xbox's A. Naming the position means one config drives both.

Canonical Aliases
south cross, a
east circle, b
west square, x
north triangle, y
l1 r1 lb, rb
l2 r2 lt, rt
l3 r3 lstick, rstick
dpad_up dpad_down dpad_left dpad_right up, down, left, right
select create, share, back, view
start options, menu
guide ps, xbox, home, mode

Names are case-insensitive.

⚠️ x and y are the Xbox labels. Xbox X sits west, not south. If you mean the PlayStation ✕ button, write cross or south. This is the one genuinely ambiguous pair — PlayStation's official names are shapes, so cross/circle/square/triangle never collide with a/b/x/y. The daemon warns once per run when it sees x or y, rather than silently doing something surprising.

Bindings and layers

ctrl.bind("south", "Confirm", ctrl.key("Return"))
ctrl.bind("r1 + south", "Go to app", ctrl.exec("omarchy-menu toggle apps"))
ctrl.unbind("select")

Anything before the + is a modifier: a button that selects a layer and never fires an action of its own.

That purity is the whole point. Because a modifier can never be an action, there is nothing to disambiguate — no timer, no window, no guessing whether you meant to tap or hold, and no latency. The cost is spending a few buttons, which a pad can afford.

The defaults are l1 r1 l2 r2. Change them with:

ctrl.modifiers({ "l1", "r1", "l2", "r2" })

A button not bound in the active layer falls back to the base layer, so holding a modifier never makes the rest of the pad go dead.

The guide button is deliberately not a modifier — it is the escape hatch, and it works as a sequence leader instead, so nothing has to be held.

Modifiers combine. Holding l1 and r1 together selects the l1+r1 layer, named in sorted order so "l1 + r1 + south" and "r1 + l1 + south" are the same binding.

Activators

ctrl.bind("start", "Power menu", ctrl.exec("omarchy-menu toggle system"),
  { activator = "long" })

tap (the default), long, double, release. A button may carry several.

You only pay for what you bind. A button carrying nothing but a tap fires the instant it goes down:

Bound on a button Fires Added latency
tap only on press none
tap + long on release, or at the long threshold none on the long
tap + double after the double window closes ~200 ms on a single tap
named in a chord after the settle window ~50 ms on that button

So a config with no double-taps and no chords adds no latency anywhere, and adding one double-tap slows down only the button carrying it. This is worth knowing before reaching for double on a button you press constantly.

release fires on release in addition to whatever else the button does.

Sequences

Tap the leader, release it, then press the follower. Nothing is held.

ctrl.sequence("guide", "north", "Capture", ctrl.exec("omarchy-menu toggle capture"))

The leader stays armed for sequence ms (800 by default), then disarms. If the follower has no sequence bound, the leader disarms and that press is handled normally.

A sequence leader is exclusive: while it is armed it consumes the next press.

Chords

Two or more buttons pressed together.

ctrl.chord({ "l3", "r3" }, "Lock screen", ctrl.exec("omarchy-lock-screen"))

Off unless you declare one, because every button named in a chord must wait out the settle window on every press to see whether a partner is coming. Chord members need no binding of their own — a chord can be built from two otherwise unused buttons.

Releasing a chord member before the settle window closes cancels the chord and handles the press as a lone button.

Per-app bindings

ctrl.app({ class = "^firefox$" }, function()
  ctrl.bind("r1 + dpad_left", "Back", ctrl.key("ALT + Left"))
  ctrl.bind("r1 + dpad_right", "Forward", ctrl.key("ALT + Right"))
end)

App bindings layer over the base set rather than replacing it, so a profile states only what differs and your navigation and modifier layers keep working. For a clean slate:

ctrl.app({ class = "^mpv$", replace = true }, function() ... end)

class and title are Lua patterns, not globs. Focus changes arrive on Hyprland's event socket, so this costs no polling.

Title matters more than it looks: a TUI is not a window. nvim, lazygit and btop run inside your terminal, so the window class is the terminal's and class alone cannot tell them apart. nvim sets the terminal title, which is what makes a TUI profile possible:

ctrl.app({ class = "^Alacritty$", title = "nvim" }, function()
  ctrl.bind("west", "Jump back", ctrl.key("CTRL + o"))
end)

The alternative — walking /proc for the foreground process under the window's pid — is the only fully correct approach, and it was rejected: tmux and terminal splits break it anyway, so it buys correctness that cannot actually be cashed.

Radial menus

Press a button and a wheel appears. Flick the left stick to highlight, press ✕ to run.

ctrl.radial("guide", {
  { "Apps",    ctrl.exec("omarchy-menu toggle apps") },
  { "Capture", ctrl.exec("omarchy-menu toggle capture") },
  { "Style", {
    { "Theme",      ctrl.exec("omarchy-menu toggle theme") },
    { "Background", ctrl.exec("omarchy-menu toggle background") },
  } },
  { "Menu",    ctrl.exec("omarchy-menu toggle root") },
})

This is the answer to a pad having too few buttons. A chord solves the same problem by being memorised; a radial solves it by putting the labels on screen — and because a novice reading labels and an expert flicking blind perform the same motion, one turns into the other without relearning anything.

Action Does
The bound button Opens the wheel — and closes it again
Flick the stick onto a leaf Highlights it, and it stays highlighted
Flick onto a branch (›) Walks into it
✕ Runs the highlighted leaf
○ Back one level; closes the wheel at the top

The menu is modal: it stays up until something closes it, and while it is up every other button is swallowed, so nothing underneath fires.

Three things about it are deliberate, and each fixes a way this can be annoying:

  • The highlight is sticky. A stick springs back to centre on its own, so a highlight that lived only while a direction was held would force one thumb onto the stick and another onto the button at the same time. Flick, let go, then press.
  • The left stick by default, so the thumb that aims is not the thumb that presses. Override with { stick = "right" }.
  • Moving can never run anything. Branches are walked into and leaves are only highlighted, so the stick explores freely and ✕ is the only thing that acts.

After walking into a branch, the stick is still deflected — and in the submenu that direction may be another branch. So descending re-arms only once the stick returns near the centre: flick, descend, relax, flick again. ○ disarms for the same reason, so backing out does not fall straight back in.

Items may be written positionally or by name; a bare list of items is a submenu:

{ "Apps", ctrl.exec("...") }                  -- an action
{ "Power", { ... } }                          -- a submenu
{ label = "Apps", action = ctrl.exec("...") } -- the same, named

Options: stick ("left", the default, or "right"), deadzone (default 0.4), activate (default south), back (default east). Menus may nest four deep.

A button bound to a radial cannot also be a modifier, since holding it would select a layer instead; the daemon says so rather than silently preferring one. activate and back must differ from each other and from the opening button.

Omarchy binds a radial to guide by default — the escape hatch, reachable from anywhere.

Timings

All milliseconds.

ctrl.timings({
  long = 300,      -- hold this long for a long press
  double = 200,    -- window for the second press of a double tap
  sequence = 800,  -- how long a leader stays armed
  chord = 50,      -- how long to wait for a chord partner
})

Dictation

Delegated to voxtype, Omarchy's own whisper.cpp dictation tool, rather than reimplemented — it already owns the model, the audio device and the bar indicator.

ctrl.bind("l1 + start", "Toggle dictation", ctrl.dictation("toggle"))

toggle, start or stop. The daemon models this explicitly: it starts in control, enters dictating when recording starts, and returns to control when recording stops or fails. passthrough is the third state reserved for a future activation binding; no binding enters it yet.

Bound by default on l1 + start, but only when voxtype is installed — the shipped default is guarded with ctrl.has("voxtype"), the same way Omarchy guards its own voxtype keybindings.

Over USB, or with any ordinary system microphone, this remains a thin wrapper around voxtype. For an original DualSense (054c:0ce6) over Bluetooth, the daemon instead:

  1. transactionally grabs the pad, Motion Sensors and Touchpad evdev siblings so userspace cannot consume microphone bytes as phantom controls;
  2. enables the proprietary microphone stream over hidraw;
  3. decodes its 48 kHz mono Opus frames and publishes omarchy_dualsense_mic through PipeWire;
  4. re-asserts hardware unmute ten times a second, because hid-playstation reads a byte of every Opus frame as the microphone-mute button and would otherwise latch the microphone off mid-sentence. The pad's mute LED strobes while this is happening, which is the healthy state -- a solid light is the one that means the microphone is muted;
  5. selects that temporary source, starts voxtype, then restores everything on stop, disconnect or daemon shutdown.

Wireless use requires libopus, pipewire-pulse/pactl, and hidraw access. On Arch the last part is normally supplied by sudo pacman -S steam-devices. Keep voxtype's [audio] device = "default", because the source is selected transactionally rather than written into its config.

While wireless dictation is active, other applications temporarily lose the physical gamepad and its sibling input devices; controllerd still reads the clean raw reports, so the dictation-stop binding continues to work. Removing that limitation needs the kernel-side report filter described in HARDWARE.md.

The clean-room transport and lifecycle are fixture-tested. Microphone activation, decoded audio and the unmute counter-measure were also verified on real Bluetooth hardware. If a session ever does record silence anyway, two seconds of it raises a notification rather than producing an empty transcription. Xbox controller microphones remain unsupported on Linux.

Normal stop, disconnect and SIGTERM paths disable the microphone before releasing the evdev grab. If that disable write fails while the controller is still connected, controllerd deliberately stays alive and retries once per second; disconnecting the controller is the safe escape. SIGKILL cannot run that policy, so reconnect the controller before giving it to a game after a forced kill.

Pointer tuning

Mouse control needs nothing from this plugin — see Mouse control — because both pads already present a real pointer device that libinput drives. They are ordinary Hyprland input devices, so they are configured, not synthesised:

ctrl.device({
  name = "dualsense-wireless-controller-touchpad",
  sensitivity = 0.2,
  natural_scroll = true,
})

Find the name with hyprctl devices. Fields are passed to Hyprland's hl.device untouched, so anything it accepts on a device works here. Applied when the config loads, so it re-applies on every reload.

API reference

Everything below is a global ctrl.* function, available in any config file. This is the controller's equivalent of the hl object in ~/.config/hypr — except hl is defined by Hyprland inside its own Lua VM, and ctrl is defined by this daemon.

Declaring bindings

Call Does
ctrl.bind(spec, description, action, options) Bind a button. description is optional — passing the action second works too. options.activator is tap/long/double/release
ctrl.unbind(spec, options) Remove a binding, or just one activator of it
ctrl.modifiers({ ... }) Declare which buttons are pure modifiers
ctrl.sequence(leader, follower, description, action) Leader-then-follower sequence
ctrl.chord({ ... }, description, action) Simultaneous press
ctrl.app(match, fn) Per-app profile. match takes class, title, replace
ctrl.timings({ ... }) Override timings
ctrl.radial(button, items, options) Radial menu toggled by a button. options: stick, deadzone, activate, back
ctrl.device({ name = ..., ... }) Configure a Hyprland input device. Applied at config load, not bound to a button

Actions

Call Does
ctrl.key(spec) Send a key to the focused surface. "SUPER + SHIFT + K"; + or spaces separate
ctrl.key_down(spec) / ctrl.key_up(spec) Hold or release a key
ctrl.dispatch(expr) Run a Hyprland dispatcher. Takes what follows hl.dsp., so "window.close()" runs hl.dispatch(hl.dsp.window.close())
ctrl.exec(command) Run a command
ctrl.launch(command) Run a GUI app the way Omarchy does (uwsm-app --), so it lands in its own systemd scope
ctrl.notify(message) Send a low-urgency notification
ctrl.callback(fn) Wrap a Lua function
ctrl.dictation(what) toggle, start or stop, via voxtype

Helpers

Call Does
ctrl.has(command) Is a command on PATH? Use it to guard bindings on optional tools
ctrl.buttons Every canonical button name, as a list

A binding also accepts a bare function or a bare string:

ctrl.bind("l3", "Say hello", function() os.execute("notify-send hi") end)
ctrl.bind("r3", "Screenshot", "omarchy-capture-screenshot")   -- treated as exec

Modifiers accepted by ctrl.key: SUPER (MOD, WIN, META, CMD), CTRL (CONTROL), SHIFT, ALT (OPT).

A malformed binding is reported and skipped, not fatal — one bad line never costs you the rest of your config.

Default bindings

The base layer is navigation only, and instant. Everything that acts on windows lives behind a held modifier.

Buttons Does
south / east Return / Escape
north / west Tab / Shift+Tab
d-pad arrow keys
start Omarchy menu
select Keybindings cheatsheet
r1 + south Go to app
r1 + east Close application
r1 + north / west Full screen / toggle floating
r1 + d-pad Focus window in that direction
l1 + d-pad Previous / next / former workspace
l1 + south / east Scratchpad / move window to scratchpad
guide Radial menu: Apps, Capture, Windows, Style ›, Menu, System ›
l1 + start Toggle dictation — only bound when voxtype is installed

Plus one app profile: in a terminal, r1 + d-pad up/down pages the scrollback, and when the title says nvim, north/west jump forward and back.

How it works

An external daemon written in LuaJIT reads controllers from evdev and drives the desktop through Hyprland's IPC socket.

The daemon itself is deliberately not QML. Omarchy's plugin kinds are all QML, and QML cannot read evdev — so the input loop has to live outside the shell. The plugin therefore supervises the daemon rather than containing it:

  • Service.qml runs bin/omarchy-controllerd as a child process. It starts with the shell, dies with it, and comes back on omarchy restart shell — which is why there is no systemd unit to enable and no second install step. A daemon that dies immediately (a missing luajit, a broken module) backs off exponentially to 30 s rather than respawning in a tight loop.
  • BarWidget.qml shows the state.

The two do not talk to each other. Omarchy routes inter-plugin calls only to panels and overlays, not to services, so the daemon mirrors its state to $XDG_RUNTIME_DIR/omarchy-controller.state and the widget watches that file with FileView. The file is written whole and renamed into place, so a watcher never reads a half-written line, and only changes are written — an idle pad produces no writes at all.

The same state goes to the daemon's stdout as one JSON line per change, which is how Service.qml knows it is alive and talking. Logs go to stderr, so the two never interleave.

The radial takes a different route. A wheel needs many frames a second while the stick moves, which is more than a watched file should carry, so it goes over a unix socket instead:

daemon  --connect-->  SocketServer in Service.qml  --summon/callIfLoaded-->  Overlay.qml

The daemon is the client and the shell is the server, which is the opposite of what it looks like it should be. Quickshell provides SocketServer in a few lines of QML, whereas listening from the daemon would mean bind/listen/ accept hand-written over the FFI — and connecting is something the daemon already does to reach Hyprland. The socket is silent unless a radial is open.

The service summons the overlay on the first frame, passing that frame as the payload so the wheel is right on its first paint, and forwards the rest with callIfLoaded. That indirection exists because Omarchy loads overlays through the shell rather than exposing them to other plugins.

The daemon decides everything; the overlay only draws. Which petal the stick is on, when to descend, what activate runs — all of it is in Lua, which keeps the input semantics in one place and makes the whole interaction testable with synthetic stick positions and no screen at all.

LuaJIT was chosen because omarchy plugin add is a git clone with no build step — it validates a manifest and copies files, and never runs install hooks. A compiled daemon would mean committing binaries or shipping a separate package. Writing the daemon in Lua also collapses the config question: the config language and the host language are the same, so ctrl is just a table the daemon leaves global before loading your files. LuaJIT specifically, for its FFI — plain Lua 5.5 has no way to call ioctl.

Files

Path Role
CLAUDE.md Working agreement, and the Omarchy plugin conventions this repo follows
manifest.json Plugin id, kinds, entry points, widget settings
Service.qml Supervises the daemon, and hosts the radial socket
BarWidget.qml Connected pad and the layer being held
Overlay.qml Draws the radial menu
bin/omarchy-controllerd Entry point and CLI
lua/controllerd/evdev.lua FFI bindings to the Linux input ABI: input_event, EVIOCGABS, non-blocking reads, poll
lua/controllerd/hidraw.lua Non-blocking raw HID transport for Bluetooth microphone reports
lua/controllerd/discover.lua Finding pads and retaining the DualSense sibling set needed for safe dictation
lua/controllerd/device.lua Selects evdev or raw DualSense input, with evdev fallback
lua/controllerd/dualsense_protocol.lua Clean-room Bluetooth report validation, decoding and microphone gating
lua/controllerd/dualsense.lua Raw DualSense controls, audio routing and temporary evdev isolation
lua/controllerd/axes.lua Ranges, calibration, radial deadzone, response curve, trigger hysteresis
lua/controllerd/codes.lua Positional names and brand aliases
lua/controllerd/engine.lua Layers, activators, sequences, chords, per-app resolution
lua/controllerd/mode.lua control / dictating / passthrough state machine
lua/controllerd/dictation.lua Transaction joining the controller, PipeWire and voxtype
lua/controllerd/opus.lua 48 kHz mono microphone decoder through libopus
lua/controllerd/pipe_source.lua Temporary PipeWire source, default restoration and crash lease
lua/controllerd/actions.lua key / dispatch / exec and friends
lua/controllerd/hypr.lua Hyprland IPC: request socket and event stream
lua/controllerd/config.lua package.path, loading user Lua, starter generation, rollback
lua/controllerd/api.lua The ctrl global
lua/controllerd/watch.lua inotify, for hot reload
lua/controllerd/clock.lua CLOCK_MONOTONIC, for deadlines
lua/controllerd/radial.lua Wheel geometry and menu-tree walking
lua/controllerd/ui.lua The radial socket client
lua/controllerd/state.lua The JSON state channel, on stdout and in a watched file
lua/controllerd/log.lua Levelled logging to stderr
default/controller/ Shipped defaults
starter/ Copied to ~/.config/omarchy/controller/ on first run
tests/*_test.lua 344 headless tests covering engine, UI state, protocol, audio and rollback

Permissions

Normal controller operation needs no extra permissions: reading a pad and driving the desktop both turn out to be free.

Reading. systemd's own 70-uaccess.rules already grants the logged-in seat user an ACL on every joystick:

SUBSYSTEM=="input", ENV{ID_INPUT_JOYSTICK}=="?*", TAG+="uaccess"

Confirm it on your own pad:

getfacl /dev/input/event25
#   user:you:rw-

Writing. Output goes through the compositor rather than a synthetic input device, so there is no /dev/uinput to unlock, no udev rule to install, and no input group to join.

Wireless DualSense dictation is the exception. Bluetooth microphone frames are carried in proprietary hidraw reports, so the daemon also needs read/write access to the controller's /dev/hidraw* node. On Arch, the steam-devices package normally installs the required udev rules: sudo pacman -S steam-devices.

Note that only the pad itself is uaccess-granted. The DualSense Touchpad and Motion Sensors devices, and xpadneo's Mouse and Keyboard devices, are root:input 660 — unreadable by a default user. Normal control mode never reads them: they belong to libinput, which is exactly what makes mouse control work. Wireless DualSense dictation must temporarily grab its Motion Sensors and Touchpad siblings as well as the pad, so that path additionally requires input-group membership or equivalent ACLs. It fails before enabling the microphone if any sibling cannot be isolated.

Reaching the desktop

Hyprland 0.56 embeds a Lua VM and exposes it over IPC, so any process can run Lua inside the compositor:

printf 'eval hl.dispatch(hl.dsp.send_shortcut({ mods = "", key = "Escape" }))' \
  | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket.sock

That is the same hl global your ~/.config/hypr/bindings.lua uses. Two verbs matter: eval runs Lua for its side effects, and repl runs Lua and returns the result — which is how the daemon reads compositor state such as the focused window.

The daemon talks to the socket directly rather than shelling out to hyprctl, which keeps a keypress at roughly 50 µs instead of forking a process per event.

hl.dsp.send_shortcut delivers to whatever surface holds keyboard focus — not just windows. Layer-shell surfaces (the Omarchy menu, the emoji picker, a launcher) receive keys correctly, which is what lets the controller drive Omarchy's own UI rather than only the apps inside it.

Focus changes arrive on .socket2.sock as activewindow>>class,title, so per-app bindings need no polling.

What ctrl.key does not do

ctrl.key sends keys to the focused application. It does not re-enter Hyprland's keybind layer:

ctrl.bind("r1 + south", ctrl.key("SUPER + SPACE"))   -- does NOT open the launcher

SUPER + SPACE is delivered to the focused app, which ignores it. Your bindings.lua is never consulted. (Verified: a SUPER+F13 bind registered and visible in hyprctl binds does not fire when sent this way.)

Name the action directly instead. Omarchy's own binds are mostly omarchy-* commands you can reuse verbatim:

ctrl.bind("r1 + south", ctrl.exec("omarchy-menu toggle apps"))    -- works
ctrl.bind("r1 + east", ctrl.dispatch("window.close()"))           -- works

Use ctrl.key for its real job: talking to the focused application — Return, Escape, arrows in a TUI, CTRL + o in nvim.

Finding a controller

A pad is any input device advertising BTN_GAMEPAD. That test matters. A DualSense presents three devices and an Xbox pad under xpadneo presents four:

DualSense Wireless Controller                    ← BTN_GAMEPAD, bound
DualSense Wireless Controller Motion Sensors     ← also has a js* node
DualSense Wireless Controller Touchpad           ← libinput's
Xbox Wireless Controller                         ← BTN_GAMEPAD, bound
Xbox Wireless Controller Mouse                   ← libinput's
Xbox Wireless Controller Keyboard
Xbox Wireless Controller Consumer Control

Because the Motion Sensors device also carries a js* node, joystick-node presence alone would match the wrong thing.

Discovery re-runs every 2 s. Bluetooth pads drop and reappear on their own, so scanning once at startup is not enough. When a pad vanishes mid-press its held buttons are cleared, so a modifier layer cannot be stranded on.

Per-pad differences

Absorbed in the device layer, so bindings never see them:

DualSense Xbox (xpadneo)
Stick range 0..255, unsigned signed 16-bit
Resting centre not the midpoint — measured 129/127/127/132 near centre
Kernel deadzone none (flat=0) none
Digital triggers yes (BTN_TL2/BTN_TR2) no — analogue only

Consequences, all handled:

  • Every axis is normalised against its own EVIOCGABS range. Nothing assumes a scale.
  • The resting value reported when the pad is opened is taken as centre, and the two sides are scaled independently. Assuming the midpoint leaves the cursor drifting forever.
  • The kernel supplies no deadzone, so the daemon owns it. The deadzone is radial — applied to the stick vector, not per axis, so the dead area is a circle. A per-axis deadzone leaves a square hole that passes pure diagonals while blocking the same magnitude along an axis.
  • l2/r2 are derived from ABS_Z/ABS_RZ with separate on and off thresholds on pads without digital triggers, so a trigger held near the edge cannot chatter. Pads that report them natively keep their own timing.

The event loop

One poll() owns everything: the controller fds, Hyprland's event stream, and an inotify fd for the config. The loop sleeps until something happens, so an idle controller costs no wakeups.

The sleep is bounded by the nearest pending deadline — a long press, a double window, a sequence expiry — so timed activators still fire on a pad sitting perfectly still. With nothing pending, the only wakeup is the 2 s device rescan.

Deadlines use CLOCK_MONOTONIC. Wall-clock time would move under a clock adjustment, and os.clock measures CPU time, which barely advances in a process that spends its life blocked in poll.

Hot reload

An inotify watch on the config directory triggers a reload, debounced by 150 ms because editors write in bursts.

A reload evaluates into a fresh builder and a fresh set of Lua modules. If anything raises, the previously loaded bindings stay live and the error is logged. A typo must never leave you holding a dead controller — which matters more than usual when the controller may be how you are driving the machine.

The daemon's own modules are deliberately not reloaded; swapping code out from under the running loop is not worth the risk.

Latency

Path Cost
evdev read → engine microseconds; non-blocking, drained until EAGAIN
engine → Hyprland ~50 µs; one unix-socket round trip, no fork
tap-only binding fires on key-down, no timer

For comparison, forking hyprctl per event costs 5–15 ms, which is why the daemon speaks to the socket itself.

Mouse control

It already works, and this plugin does not implement it. Both pads present a real pointer device that libinput and Hyprland consume directly:

  • DualSense — the touchpad is a genuine clickable touchpad (INPUT_PROP_POINTER + INPUT_PROP_BUTTONPAD, with BTN_LEFT). Hyprland holds it as dualsense-wireless-controller-touchpad.
  • Xbox with xpadneo — a full mouse: REL_X, REL_Y, both scroll axes, and eight buttons.

Check yours with hyprctl devices, and tune them with ctrl.device, which passes straight through to Hyprland.

Synthesising pointer input was investigated and rejected. Hyprland can move the cursor (hl.dsp.cursor.move) but cannot synthesise mouse buttons — verified against both a window and a layer surface, with send_shortcut and send_key_state, using mouse:272 and BTN_LEFT. Doing it ourselves would have meant uinput, and with it a udev rule and a sudo step, to reimplement what the kernel already provides.

Development

for test in tests/*_test.lua; do luajit "$test" || exit 1; done
OMARCHY_CONTROLLER_LOG=debug ./bin/omarchy-controllerd    # watch bindings fire

The tests drive the state machines with synthetic timestamps and feed packet fixtures into the raw adapter. Long presses, CRC rejection, Opus decoding and failure rollback are therefore testable without a controller or screen.

Useful while working:

# what pads does the kernel see?
for d in /sys/class/input/event*; do echo "$(basename $d) $(cat $d/device/name)"; done

# does this device advertise BTN_GAMEPAD (bit 304)?
cat /sys/class/input/event25/device/capabilities/key

# axis ranges, deadzones and resting values
luajit -e 'package.path="lua/?.lua;"..package.path
local e=require("controllerd.evdev"); local fd=e.open("/dev/input/event25")
for _,a in ipairs{0,1,2,3,4,5} do local i=e.absinfo(fd,a)
  if i then print(a, i.minimum, i.maximum, i.flat, i.value) end end'

# talk to Hyprland the way the daemon does
printf 'repl return hl.get_active_window().class' \
  | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket.sock

Working on the plugin

omarchy plugin add /path/to/omarchy-controller-support --enable --yes  # a local path clones fine
omarchy plugin update io.github.bruce-forte.controller-support --yes   # pull later commits
omarchy plugin remove io.github.bruce-forte.controller-support

omarchy-plugin-validate .
qmllint -I "$OMARCHY_PATH/shell" Service.qml BarWidget.qml
journalctl --user -f | grep controllerd        # daemon logs, relayed by the service

plugin add clones, so only committed work is installed.

The radial can be driven by hand, without a controller — useful when working on how it looks:

ID=io.github.bruce-forte.controller-support
FRAME='{"event":"radial","items":[{"label":"One","submenu":false},{"label":"Two","submenu":true}],"selected":1,"depth":0}'

omarchy-shell shell summon $ID "$FRAME"          # draw a wheel
omarchy-shell shell call $ID applyFrame "$FRAME" # update it
omarchy-shell shell call $ID applyFrame '{"event":"radial-close"}'

# or push a frame straight down the socket the daemon uses
printf '%s\n' "$FRAME" | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/omarchy-controller-ui.sock

Reload rules.

Edited Takes effect
~/.config/omarchy/controller/*.lua On save
lua/ in a checkout --stop, then run again
lua/ in the installed plugin Saving reloads the plugin, which restarts the daemon
Service.qml / BarWidget.qml omarchy restart shell

Note that qmllint cannot resolve the BarWidget base type, so it will not catch a property clashing with one of QQuickItem's — layer is FINAL there, and that only shows up as Cannot override FINAL property in the shell log at runtime.

Troubleshooting

Nothing fails silently. A controller button has nowhere to print, so anything that goes wrong — a config that will not load, a binding skipped during a load, an action that fails, a dependency that is not running — is raised as a desktop notification naming the problem and, where there is one, the command that fixes it. Repeats are throttled per problem, so a failing binding pressed repeatedly cannot bury the desktop. A config that will not load leaves your previous bindings live, so the controller keeps working while you fix it.

Symptom Cause and fix
Nothing happens on any button Run with OMARCHY_CONTROLLER_LOG=debug. No controller connected line means the pad was not found — see the next row
controller connected but no fire lines The button is unbound in the active layer, or a modifier you are holding selected a layer where it is not bound. debug prints the layer with each fire
fire appears but nothing happens The action ran and its target ignored it. Most often ctrl.key used for a window-manager action — see What ctrl.key does not do
Controller not detected It must advertise BTN_GAMEPAD. List devices with the snippet above; if it is listed but unreadable, your seat lacks the uaccess ACL — check getfacl /dev/input/eventN
Xbox pad connects then drops, logo flashing Bluetooth ERTM. Install xpadneo-dkms, then echo 'options bluetooth disable_ertm=1' | sudo tee /etc/modprobe.d/99-xpadneo-bluetooth.conf, reboot, re-pair
x does something unexpected It is the Xbox X button, which sits west. Use cross or south for PlayStation's ✕
Config changes ignored The daemon logs config reloaded on every save. config reloaded failed means your previous bindings are still live and the message names the error
already running as pid N A daemon is already up. --stop it first
Every binding fires twice Two daemons. Should be impossible via the guard, but check --status and pgrep -x luajit
A trigger fires repeatedly Only on pads with derived triggers. Widen the gap between the on and off thresholds
Buttons stuck after the pad disconnects Should self-clear on the 2 s rescan. If not, restart the daemon
Widget missing from the bar omarchy plugin list | grep controller — it must be enabled. The widget hides itself when no pad is connected unless showWhenDisconnected is set
Cannot override FINAL property in the shell log A widget property collides with one of QQuickItem's (layer, state, data, …). Rename it; qmllint will not catch this
A binding does nothing It should have notified — failed actions, broken config and skipped bindings all raise a desktop notification. If nothing appeared, the press never reached the daemon: check journalctl --user -f | grep controllerd
"Dictation is not available" voxtype is installed but not running. omarchy-voxtype-install downloads the model and enables the service
Daemon keeps restarting journalctl --user -f | grep controllerd. The service backs off to 30 s, so the log names the reason each time
Radial does not appear journalctl --user -f | grep radial — the service logs when it opens. No line means the press never reached the daemon; a line but no wheel means the overlay failed to load
Radial appears but the highlight lags or jumps The stick is evaluated once per evdev frame. If a petal is hard to hit, raise deadzone
Flicking straight through two submenus Descending re-arms only at the centre, so this should not happen. If a petal is entered unintentionally, raise deadzone
Radial opens instead of the layer, or vice versa A button cannot be both a modifier and a radial. Drop it from ctrl.modifiers
Widget shows a pad that is not connected A stale state file from a daemon that was killed. The service clears it on exit; omarchy restart shell fixes it

Uninstall

omarchy plugin remove io.github.bruce-forte.controller-support
rm -rf ~/.config/omarchy/controller     # your config, if you want it gone

Removing the plugin stops the daemon with it and takes the widget off the bar.

There is no state directory to clean up: everything this plugin writes is either your config (above) or lives in $XDG_RUNTIME_DIR and disappears with the session. Plugins that keep caches, virtualenvs or build output put them in ${XDG_STATE_HOME:-~/.local/state}/<plugin-id>/ and would need that removed too; this one has none.

Nothing else needs undoing. The daemon installs no system files, adds no udev rules, changes no group memberships, and writes nothing outside ~/.config/omarchy/controller/ and a pidfile in $XDG_RUNTIME_DIR.

Roadmap

Phases 1–3 are done — the daemon, the shell plugin, and radial menus — along with the layer HUD from phase 4. What remains is rumble and lightbar feedback, daisywheel text entry, per-workspace profiles, and stick-as-mouse, the one item from the original brief that does not exist.

ROADMAP.md has the detail, including what was deliberately not built and why, and what is built but not yet verified on hardware.

Security

The daemon runs unsandboxed as your user, and your config is executed as Lua — the same trust model as ~/.config/hypr/bindings.lua.

Touches What
Reads /dev/input/event*, but only devices advertising BTN_GAMEPAD
Reads /sys/class/input/*/device/{name,capabilities/key} for discovery
Connects Hyprland's .socket.sock and .socket2.sock
Reads & writes ~/.config/omarchy/controller/
Writes $XDG_RUNTIME_DIR/omarchy-controllerd.pid and omarchy-controller.state
Never writes inside its own plugin directory — Omarchy watches that and reloads the shell on any write
Connects $XDG_RUNTIME_DIR/omarchy-controller-ui.sock, the shell's radial socket
Never opens a network connection, requires elevated privileges, or installs anything outside your home directory

Because it speaks to Hyprland's Lua VM, a binding can run any command your user can. That is the point — it is exactly the power your Hyprland config already has — but it means a controller config written by someone else deserves the same reading you would give a shell script before running it.

License

Apache-2.0. See LICENSE.

About

Controller support plugin for Omarchy

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages