A Linux userspace controller for the Microsoft Surface Dial. Requires Linux Kernel 4.19 or higher.
surface-dial-daemon receives raw events from the Surface Dial and translates them into conventional input events (key presses, scroll wheel, haptic feedback, etc.).
The daemon uses FreeDesktop notifications to provide visual feedback when switching between modes.
Hold the button for ~750 ms to open the meta-menu (shown via desktop notification), which lets you switch modes on the fly. The last selected mode is saved to disk, so if you only ever want one mode you can set it and forget it.
Modes in bold are experimental — they work most of the time but could use more polish.
| Mode | Click | Rotate | Notes |
|---|---|---|---|
| Scroll | — | Scroll | Fakes chunky mouse-wheel scrolling 1 |
| Scroll (Fake Multitouch) | Reset touch event | Scroll | Fakes smooth two-finger scrolling |
| Zoom | — | Zoom in/out | Sends Ctrl+= / Ctrl+− |
| Volume | Mute | Volume up/down | |
| Media | Play/Pause | Next/Prev track | |
| Media + Volume | Play/Pause | Volume up/down | Double-click = Next Track |
| Paddle Controller | Space | Left/Right arrow key | Play arkanoid as the devs intended! |
| D-Bus | DialPressed | DialRotated | Broadcasts events on the session bus — for external integrations |
| Your custom modes… | configurable | configurable | Defined in modes.yaml — see below |
1 Most Linux programs still only handle the older, chunky scroll-wheel events. See this post for background.
The D-Bus mode broadcasts every dial event as a D-Bus signal on the session bus. It is intended as a building block for external overlays, applets, or scripts that need to react to dial input without having to handle raw evdev events themselves.
| Property | Value |
|---|---|
| Service name | com.dialmenu.Daemon |
| Object path | /com/dialmenu/Daemon |
| Interface | com.dialmenu.Daemon |
| Signal | Arguments | Emitted when… |
|---|---|---|
DialRotated(delta: i32) |
+1 or −1 |
The dial is rotated by one step |
DialPressed() |
— | The button is pressed down |
DialReleased() |
— | The button is released |
# print every signal in real time
dbus-monitor "type='signal',interface='com.dialmenu.Daemon'"import dbus, dbus.mainloop.glib
from gi.repository import GLib
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
bus = dbus.SessionBus()
bus.add_signal_receiver(
lambda delta: print("rotated", delta),
signal_name="DialRotated",
dbus_interface="com.dialmenu.Daemon",
)
GLib.MainLoop().run()If another process has already claimed the com.dialmenu.Daemon service name, the daemon will log a warning but still emit signals — the signals are broadcast to any subscriber regardless of who owns the name.
The daemon can automatically switch modes based on which application has focus. This feature is opt-in: if the config file does not exist, nothing happens.
~/.config/surface-dial/profiles.toml
[[profile]]
name = "browser"
match_app_id = "firefox"
mode = "DbusMode"
[[profile]]
name = "media"
match_app_id = "vlc"
mode = "Media"
[[profile]]
name = "default"
mode = "Scroll"| Field | Required | Description |
|---|---|---|
name |
yes | Human-readable label (not used at runtime, only for your reference) |
match_app_id |
no | Case-insensitive substring matched against the focused window's app-id. Omit for default. |
mode |
yes | Name of the ControlMode to activate (must match exactly, e.g. "Scroll", "DbusMode") |
Profiles are checked in order. The first entry whose match_app_id is found anywhere in the focused window's app-id wins. The first entry without a match_app_id acts as the fallback default. If no default entry exists, Scroll is used.
Focus tracking uses the org.gnome.Shell.Introspect D-Bus interface, which is provided by GNOME Shell 40 and later. The watcher logs a warning and exits cleanly if GNOME Shell is not running; other desktop environments are not currently supported.
You can add your own modes without writing any Rust code by dropping a modes.yaml file into the daemon's config directory:
~/.config/com.prilik/surface-dial-daemon/modes.yaml
Each entry in the file becomes a new mode that appears at the end of the meta-menu after the built-in modes.
- name: "Brightness"
icon: "display-brightness-symbolic" # FreeDesktop icon name or file:// path
haptics: true
steps: 36 # haptic click divisions (0–3600)
on_dial_cw: ["KEY_BRIGHTNESSUP"]
on_dial_ccw: ["KEY_BRIGHTNESSDOWN"]
- name: "Undo / Redo"
icon: "edit-undo"
haptics: true
steps: 36
on_dial_cw: ["KEY_LEFTCTRL", "KEY_Y"]
on_dial_ccw: ["KEY_LEFTCTRL", "KEY_Z"]
on_btn_release: ["KEY_LEFTCTRL", "KEY_S"]
- name: "Tab Switch"
icon: "view-paged"
haptics: false
steps: 90
on_dial_cw: ["KEY_LEFTCTRL", "KEY_TAB"]
on_dial_ccw: ["KEY_LEFTCTRL", "KEY_LEFTSHIFT", "KEY_TAB"]| Field | Required | Default | Description |
|---|---|---|---|
name |
yes | — | Display name shown in the meta-menu notification |
icon |
no | input-keyboard |
FreeDesktop icon name or file:///path/to/icon.png |
haptics |
no | false |
Whether the dial clicks when rotating |
steps |
no | 36 |
Number of haptic divisions per full rotation (0–3600) |
on_btn_press |
no | (no action) | Keys sent when the button is pressed |
on_btn_release |
no | (no action) | Keys sent when the button is released |
on_dial_cw |
no | (no action) | Keys sent on clockwise rotation |
on_dial_ccw |
no | (no action) | Keys sent on counter-clockwise rotation |
Each action value is a YAML list of key names pressed simultaneously as a chord. An empty list or omitting the field entirely means no action is taken.
Letters (KEY_A–KEY_Z), digits (KEY_0–KEY_9), function keys (KEY_F1–KEY_F12), and:
| Category | Keys |
|---|---|
| Modifiers | KEY_LEFTSHIFT KEY_RIGHTSHIFT KEY_LEFTCTRL KEY_RIGHTCTRL KEY_LEFTALT KEY_RIGHTALT KEY_LEFTMETA KEY_RIGHTMETA |
| Navigation | KEY_UP KEY_DOWN KEY_LEFT KEY_RIGHT KEY_HOME KEY_END KEY_PAGEUP KEY_PAGEDOWN |
| Editing | KEY_SPACE KEY_ENTER KEY_BACKSPACE KEY_DELETE KEY_INSERT KEY_TAB KEY_ESC KEY_CAPSLOCK |
| Symbols | KEY_EQUAL KEY_MINUS KEY_LEFTBRACE KEY_RIGHTBRACE KEY_SEMICOLON KEY_APOSTROPHE KEY_GRAVE KEY_BACKSLASH KEY_COMMA KEY_DOT KEY_SLASH |
| Media | KEY_MUTE KEY_VOLUMEUP KEY_VOLUMEDOWN KEY_PLAYPAUSE KEY_NEXTSONG KEY_PREVIOUSSONG KEY_STOPCD |
| System | KEY_BRIGHTNESSUP KEY_BRIGHTNESSDOWN KEY_PRINT KEY_SCROLLLOCK KEY_PAUSE KEY_SLEEP KEY_WAKEUP |
Some built-in modes need timing logic or special output that can't be expressed as simple key chords:
- Scroll wheel output — use the built-in Scroll or Scroll (Fake Multitouch) modes
- Double-click detection — use the built-in Media + Volume mode
- Velocity-based control — use the built-in Paddle Controller mode
For anything beyond what YAML supports, see Adding a coded mode below.
- Microsoft Surface Dial (Bluetooth)
- Linux 4.19 or higher — required for stable Surface Dial input driver support
REL_DIALevent support was introduced in 4.14, but 4.19 is the tested minimum- High-resolution wheel scrolling (
REL_WHEEL_HI_RES) requires kernel 5.0+; the daemon emits bothREL_WHEELandREL_WHEEL_HI_RESso it works on older kernels too
| Library | Purpose |
|---|---|
libevdev |
Read raw input events from /dev/input/eventXX and create virtual input devices via /dev/uinput |
libhidapi |
Configure haptics and sensitivity over HID (device 045e:091b) |
libudev |
Enumerate devices and monitor dial connect/disconnect events |
libdbus-1 |
D-Bus IPC — required indirectly by zbus; may need a dev package on some distros |
# Ubuntu / Debian
sudo apt install libevdev-dev libhidapi-dev libudev-devOn some Ubuntu versions you may also need:
sudo apt install librust-libdbus-sys-dev- A running D-Bus session — required for desktop notifications when switching modes
- udev rules — mandatory for the daemon to access
/dev/uinputand the dial'shidrawdevice without running as root (see Installation) inputgroup membership — required for read access to/dev/input/eventXX- GNOME Shell 40+ — only required for the optional focus-based automatic mode switching (
profiles.toml); all other features work without it
cargo build --releaseThe binary is placed at target/release/surface-dial-daemon.
The daemon handles dial disconnect/reconnect automatically, so it can run indefinitely in the background.
Important: run the daemon as a user process, not as root. It needs access to the user D-Bus session to send notifications.
During development:
cargo runThe following steps have been tested on Ubuntu 20.04/20.10.
# Build and install the binary to ~/.cargo/bin/
cargo install --path .
# Add yourself to the /dev/input group (usually `input` or `plugdev`)
sudo gpasswd -a $(whoami) $(stat -c "%G" /dev/input/event0)
# Install the systemd user service
mkdir -p ~/.config/systemd/user/
cp ./install/surface-dial.service ~/.config/systemd/user/surface-dial.service
# Install udev rules — mandatory for unprivileged access to /dev/uinput and the dial's hidraw device
sudo cp ./install/10-uinput.rules /etc/udev/rules.d/10-uinput.rules
sudo cp ./install/10-surface-dial.rules /etc/udev/rules.d/10-surface-dial.rules
# Reload systemd and udev
systemctl --user daemon-reload
sudo udevadm control --reload
# Enable and start the service
systemctl --user enable surface-dial.service
systemctl --user start surface-dial.serviceCheck the service status with:
systemctl --user status surface-dial.serviceYou may need to reboot for group memberships and udev rules to take effect.
If Bluetooth pairing fails, try setting DisableSecurity=true in /etc/bluetooth/network.conf.
YAML modes cover the common case of mapping dial events to key chords. For anything more sophisticated — scroll wheel output, timing-based logic, velocity curves — implement the ControlMode trait directly in Rust:
- Create
src/controller/controls/my_mode.rsand implementControlMode:
use crate::controller::{ControlMode, ControlModeMeta};
use crate::dial_device::DialHaptics;
use crate::error::Result;
use crate::fake_input;
use evdev::KeyCode;
pub struct MyMode;
impl MyMode {
pub fn new() -> MyMode { MyMode }
}
impl ControlMode for MyMode {
fn meta(&self) -> ControlModeMeta {
ControlModeMeta {
name: "My Mode".into(),
icon: "input-keyboard".into(),
haptics: true,
steps: 36,
}
}
fn on_btn_press(&mut self, _: &DialHaptics) -> Result<()> { Ok(()) }
fn on_btn_release(&mut self, _: &DialHaptics) -> Result<()> {
fake_input::key_click(&[KeyCode::KEY_ENTER]).map_err(crate::error::Error::Evdev)
}
fn on_dial(&mut self, _: &DialHaptics, delta: i32) -> Result<()> {
if delta > 0 {
fake_input::key_click(&[KeyCode::KEY_RIGHT]).map_err(crate::error::Error::Evdev)?;
} else {
fake_input::key_click(&[KeyCode::KEY_LEFT]).map_err(crate::error::Error::Evdev)?;
}
Ok(())
}
}- Re-export it from
src/controller/controls/mod.rs:
mod my_mode;
pub use self::my_mode::*;- Add it to the mode list in
src/main.rs:
Box::new(controller::controls::MyMode::new()),If you build something useful, consider opening a PR!
Core functionality is provided by:
libudev— monitors when the dial connects/disconnectslibevdev— reads raw events from/dev/input/eventXXand emits fake input via/dev/uinputhidapi— configures dial sensitivity and haptics over HIDnotify-rust— sends desktop notifications over D-Busserde+serde_yaml— parsesmodes.yamlfor custom modesserde+toml— parsesprofiles.tomlfor focus-based mode switchingzbus— D-Bus session bus integration (signal emission inDbusMode, window-focus queries inFocusWatcher)tokio— async runtime used byzbusinside dedicated background threads
The daemon uses threads and mpsc channels for non-blocking event handling. Each subsystem (event reading, haptics, paddle velocity, double-click detection) runs in its own thread and communicates via channels. All the device-level complexity is hidden behind the ControlMode trait — mode implementations only see clean on_dial, on_btn_press, and on_btn_release callbacks.
DbusMode and FocusWatcher each spin up their own background thread containing a tokio runtime so that async D-Bus I/O can run without making the rest of the daemon async. Mode switching from the focus watcher is coordinated through a ModeSwitcher handle that shares the controller's existing Arc<Mutex<Option<usize>>> slot, so no extra locking is introduced in the hot path.
- Raw Surface Dial event handling
- Haptic feedback
- Built-in operating modes
- YAML-configurable custom modes
- On-the-fly mode switching via long-press meta-menu
- Last-selected mode persistence
- Graceful disconnect/reconnect handling
- Visual feedback via FreeDesktop notifications
- D-Bus signal broadcasting (
DbusMode) - Focus-based automatic mode switching (
profiles.toml) - Config file for adjusting timings (long-press timeout, double-click window, etc.)
- Custom mode ordering in the meta-menu
- Focus tracking on non-GNOME desktops (KDE, sway, etc.)
- Windows-like wheel overlay UI
- Implemented via DBus such as surface-dial-overley project
- Packaging pipeline (deb/rpm)
