Skip to content

specis/surface-dial-linux

 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

49 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

surface-dial-linux

A Linux userspace controller for the Microsoft Surface Dial. Requires Linux Kernel 4.19 or higher.

Overview

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.

Operating 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.


D-Bus Integration (DbusMode)

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.

Service details

Property Value
Service name com.dialmenu.Daemon
Object path /com/dialmenu/Daemon
Interface com.dialmenu.Daemon

Signals

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

Listening from the command line

# print every signal in real time
dbus-monitor "type='signal',interface='com.dialmenu.Daemon'"

Listening from Python

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.


Focus-based mode switching (profiles.toml)

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 file location

~/.config/surface-dial/profiles.toml

Format

[[profile]]
name = "browser"
match_app_id = "firefox"
mode = "DbusMode"

[[profile]]
name = "media"
match_app_id = "vlc"
mode = "Media"

[[profile]]
name = "default"
mode = "Scroll"

Profile fields

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.

Requirements

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.


Custom Modes via modes.yaml

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.

Format

- 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"]

Fields

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.

Supported key names

Letters (KEY_AKEY_Z), digits (KEY_0KEY_9), function keys (KEY_F1KEY_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

What YAML modes can't do

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.


Requirements

Hardware

  • Microsoft Surface Dial (Bluetooth)

Kernel

  • Linux 4.19 or higher — required for stable Surface Dial input driver support
    • REL_DIAL event 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 both REL_WHEEL and REL_WHEEL_HI_RES so it works on older kernels too

System libraries (build-time)

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-dev

On some Ubuntu versions you may also need:

sudo apt install librust-libdbus-sys-dev

Runtime

  • A running D-Bus session — required for desktop notifications when switching modes
  • udev rules — mandatory for the daemon to access /dev/uinput and the dial's hidraw device without running as root (see Installation)
  • input group 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

Building

cargo build --release

The binary is placed at target/release/surface-dial-daemon.


Running

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 run

Installation

The 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.service

Check the service status with:

systemctl --user status surface-dial.service

You 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.


Adding a coded mode

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:

  1. Create src/controller/controls/my_mode.rs and implement ControlMode:
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(())
    }
}
  1. Re-export it from src/controller/controls/mod.rs:
mod my_mode;
pub use self::my_mode::*;
  1. 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!


Implementation Notes

Core functionality is provided by:

  • libudev — monitors when the dial connects/disconnects
  • libevdev — reads raw events from /dev/input/eventXX and emits fake input via /dev/uinput
  • hidapi — configures dial sensitivity and haptics over HID
  • notify-rust — sends desktop notifications over D-Bus
  • serde + serde_yaml — parses modes.yaml for custom modes
  • serde + toml — parses profiles.toml for focus-based mode switching
  • zbus — D-Bus session bus integration (signal emission in DbusMode, window-focus queries in FocusWatcher)
  • tokio — async runtime used by zbus inside 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.


Feature Roadmap

  • 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
  • Packaging pipeline (deb/rpm)

About

A Linux userspace controller for the Microsoft Surface Dial. Requires Linux Kernel 4.19 or higher.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages

  • Rust 100.0%