commit c4039edc55606f805bbd555c121ff6345de0593a Author: Daniel Prilik Date: Thu Oct 29 00:30:06 2020 -0400 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6cfa7a2 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,66 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +[[package]] +name = "bitflags" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" + +[[package]] +name = "cc" +version = "1.0.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed67cbde08356238e75fc4656be4749481eeffb09e19f320a25237d5221c985d" + +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + +[[package]] +name = "evdev-rs" +version = "0.4.0" +dependencies = [ + "bitflags", + "evdev-sys", + "libc", + "log", +] + +[[package]] +name = "evdev-sys" +version = "0.2.1" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "libc" +version = "0.2.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58d1b70b004888f764dfbf6a26a3b0342a1632d33968e4a179d8011c760614" + +[[package]] +name = "log" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fabed175da42fed1fa0746b0ea71f412aa9d35e76e95e59b192c64b9dc2bf8b" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "pkg-config" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3831453b3449ceb48b6d9c7ad7c96d5ea673e9b470a1dc578c2ce6521230884c" + +[[package]] +name = "surface-dial-daemon" +version = "0.1.0" +dependencies = [ + "evdev-rs", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..036eb4f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "surface-dial-daemon" +version = "0.1.0" +authors = ["Daniel Prilik "] +edition = "2018" + +[dependencies] +evdev-rs = { path = "../evdev-rs/" } diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a42de8 --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# surface-dial-linux + +A Linux userspace controller for the [Microsoft Surface Dial](https://www.microsoft.com/en-us/p/surface-dial/925r551sktgn). Requires Linux Kernel 4.19 or higher. + +- Uses the [`evdev`](https://en.wikipedia.org/wiki/Evdev) API + `libevdev` to read events from the surface dial. +- Uses `libevdev` to fake input via `/dev/uinput` (for keypresses / media controls) + +**DISCLAIMER: This is WIP software!** + +Things will change. +Things will break. +Things are probably buggy. + +There's also a non-zero chance that I'll just stop working on it at some point once I decide that it's Good Enough:tm: for me. + +You've been warned :eyes: + +## Overview + +Consists of two components: + +- `surface-dial-daemon` - A background daemon which recieves raw events and translates them to various actions. +- `surface-dial-cli` - Controller to configure daemon functionality (e.g: change operating modes) + +It would be cool to create some sort of GUI overlay (similar to the Windows one), though that's a bit out of scope at the moment. + +## Functionality + +- [x] Interpret raw Surface Dial event +- [ ] Dynamically switch between operating modes + - [ ] Context-sensitive (based on currently open application) + - [ ] Using `surface-dial-cli` application + - [ ] Using some-sort of on-device mechanism (e.g: long-press) +- Various Operating Modes + - [x] Volume Controls + - [x] Media Controls + - [x] D-Pad (emulated left, right, and space key) + - [ ] Scrolling / Zooming + +Feel free to suggest / contribute new features! + +## Building + +Building `surface-dial-daemon` requires the following: + +- A fairly recent version of the Rust compiler +- `libevdev` + +If `libevdev` is not installed, the `evdev_rs` Rust library will try to build it from source, which may require other bits of build tooling. As such, it's recommended to install `libevdev` if it's available through your distribution. + +```bash +# e.g: on ubuntu +sudo apt install libevdev-dev +``` + +Otherwise, `surface-dial-daemon` uses the bog-standard `cargo build` flow. + +```bash +cargo build -p surface-dial-daemon --release +``` + +The resulting binary is output to `target/release/surface-dial-daemon` + +## Running `surface-dial-daemon` + +For testing changes locally, you'll typically want to run the following: + +```bash +cargo build -p surface-dial-daemon && sudo target/debug/surface-dial-daemon +``` + +Note the use of `sudo`, as `surface-dial-daemon` requires permission to access files under `/dev/input/` and `/dev/uinput`. + +## Using `surface-dial-cli` + +TODO (the controller cli doesn't exist yet lol) + +## Installation + +As you might have noticed, the daemon dies whenever the Surface Dial disconnects (which happens after a brief period of inactivity). + +I personally haven't figured out a good way to have the daemon gracefully handle the dial connecting/disconnecting (PRs appreciated!), so instead, I've come up with a [cunning plan](https://www.youtube.com/watch?v=AsXKS8Nyu8Q) to spawn the daemon whenever the Surface Dial connects :wink: + +This will only work on systems with `systemd`. +If your distro doesn't use `systemd`, you'll have to come up with something yourself I'm afraid... + +```bash +# Install the `surface-dial-daemon` (i.e: build it, and place it under ~/.cargo/bin/surface-dial-daemon) +# You could also just copy the executable from /target/release/surface-dial-daemon to wherever you like. +cargo install --path . + +# IMPORTANT: modify the .service file to reflect where you placed the `service-dial-daemon` executable +vi surface-dial.service + +# install the systemd service +sudo cp surface-dial.service /etc/systemd/system/surface-dial.service +# install the service-dial udev rule +sudo cp surface-dial-udev.rules /etc/udev/rules.d/50-surface-dial.rules + +# reload systemd + udev +sudo systemctl daemon-reload +sudo udevadm control --reload +``` + +You many need to disconnect + reconnect the Surface Dial for the `udev` rule to trigger. + +## License + +At the moment, this software is deliberately unlicensed. I'm not opposed to adding a license at some point, it's moreso that I don't think the project is at the stage where it needs a license. diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..606e292 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +wrap_comments = true \ No newline at end of file diff --git a/src/common.rs b/src/common.rs new file mode 100644 index 0000000..6aee25b --- /dev/null +++ b/src/common.rs @@ -0,0 +1,34 @@ +pub enum DialDir { + Left, + Right, +} + +pub struct ThresholdHelper { + sensitivity: i32, + pos: i32, +} + +impl ThresholdHelper { + pub fn new(sensitivity: i32) -> ThresholdHelper { + ThresholdHelper { + sensitivity, + pos: 0, + } + } + + pub fn update(&mut self, delta: i32) -> Option { + self.pos += delta; + + if self.pos > self.sensitivity { + self.pos -= self.sensitivity; + return Some(DialDir::Right); + } + + if self.pos < -self.sensitivity { + self.pos += self.sensitivity; + return Some(DialDir::Left); + } + + None + } +} diff --git a/src/controller/controls/dpad.rs b/src/controller/controls/dpad.rs new file mode 100644 index 0000000..aa82773 --- /dev/null +++ b/src/controller/controls/dpad.rs @@ -0,0 +1,145 @@ +use std::cmp::Ordering; +use std::sync::mpsc; +use std::thread::JoinHandle; +use std::time::Duration; + +use crate::controller::ControlMode; +use crate::fake_input::FakeInput; +use crate::DynResult; + +use evdev_rs::enums::EV_KEY; + +enum Msg { + Kill, + Delta(i32), +} + +struct Worker { + msg: mpsc::Receiver, + fake_input: FakeInput, + + timeout: u64, + falloff: i32, + cap: i32, + deadzone: i32, + + last_delta: i32, + velocity: i32, +} + +impl Worker { + pub fn new(msg: mpsc::Receiver) -> Worker { + Worker { + msg, + fake_input: FakeInput::new(), + + // tweak these for "feel" + timeout: 5, + falloff: 10, + cap: 250, + deadzone: 10, + + last_delta: 0, + velocity: 0, + } + } + + pub fn run(&mut self) { + loop { + let falloff = self.velocity.abs() / self.falloff + 1; + + match self.msg.recv_timeout(Duration::from_millis(self.timeout)) { + Ok(Msg::Kill) => return, + Ok(Msg::Delta(delta)) => { + // abrupt direction change! + if (delta < 0) != (self.last_delta < 0) { + self.velocity = 0 + } + self.last_delta = delta; + + self.velocity += delta + } + Err(mpsc::RecvTimeoutError::Timeout) => match self.velocity.cmp(&0) { + Ordering::Equal => {} + Ordering::Less => self.velocity += falloff, + Ordering::Greater => self.velocity -= falloff, + }, + Err(other) => panic!("{}", other), + } + + // clamp velocity within the cap bounds + if self.velocity > self.cap { + self.velocity = self.cap; + } else if self.velocity < -self.cap { + self.velocity = -self.cap; + } + + if self.velocity.abs() < self.deadzone { + self.fake_input + .key_release(&[EV_KEY::KEY_LEFT, EV_KEY::KEY_RIGHT]) + .unwrap(); + continue; + } + + match self.velocity.cmp(&0) { + Ordering::Equal => {} + Ordering::Less => self.fake_input.key_press(&[EV_KEY::KEY_LEFT]).unwrap(), + Ordering::Greater => self.fake_input.key_press(&[EV_KEY::KEY_RIGHT]).unwrap(), + } + + eprintln!("{:?}", self.velocity); + } + } +} + +/// A bit of a misnomer, since it's only left-right. +pub struct DPad { + _worker: JoinHandle<()>, + msg: mpsc::Sender, + + fake_input: FakeInput, +} + +impl Drop for DPad { + fn drop(&mut self) { + let _ = self.msg.send(Msg::Kill); + } +} + +impl Default for DPad { + fn default() -> Self { + Self::new() + } +} + +impl DPad { + pub fn new() -> DPad { + let (msg_tx, msg_rx) = mpsc::channel(); + + let worker = std::thread::spawn(move || Worker::new(msg_rx).run()); + + DPad { + _worker: worker, + msg: msg_tx, + + fake_input: FakeInput::new(), + } + } +} + +impl ControlMode for DPad { + fn on_btn_press(&mut self) -> DynResult<()> { + eprintln!("space"); + self.fake_input.key_click(&[EV_KEY::KEY_SPACE])?; + Ok(()) + } + + fn on_btn_release(&mut self) -> DynResult<()> { + Ok(()) + } + + fn on_dial(&mut self, delta: i32) -> DynResult<()> { + self.msg.send(Msg::Delta(delta))?; + Ok(()) + } +} diff --git a/src/controller/controls/media.rs b/src/controller/controls/media.rs new file mode 100644 index 0000000..185a0aa --- /dev/null +++ b/src/controller/controls/media.rs @@ -0,0 +1,48 @@ +use crate::common::{DialDir, ThresholdHelper}; +use crate::controller::ControlMode; +use crate::fake_input::FakeInput; +use crate::DynResult; + +use evdev_rs::enums::EV_KEY; + +pub struct Media { + thresh: ThresholdHelper, + + fake_input: FakeInput, +} + +impl Media { + pub fn new(sensitivity: i32) -> Media { + Media { + thresh: ThresholdHelper::new(sensitivity), + + fake_input: FakeInput::new(), + } + } +} + +impl ControlMode for Media { + fn on_btn_press(&mut self) -> DynResult<()> { + self.fake_input.key_click(&[EV_KEY::KEY_PLAYPAUSE])?; + Ok(()) + } + + fn on_btn_release(&mut self) -> DynResult<()> { + Ok(()) + } + + fn on_dial(&mut self, delta: i32) -> DynResult<()> { + match self.thresh.update(delta) { + Some(DialDir::Left) => { + eprintln!("next song"); + self.fake_input.key_click(&[EV_KEY::KEY_NEXTSONG])?; + } + Some(DialDir::Right) => { + eprintln!("last song"); + self.fake_input.key_click(&[EV_KEY::KEY_PREVIOUSSONG])?; + } + None => {} + } + Ok(()) + } +} diff --git a/src/controller/controls/mod.rs b/src/controller/controls/mod.rs new file mode 100644 index 0000000..a3247fe --- /dev/null +++ b/src/controller/controls/mod.rs @@ -0,0 +1,7 @@ +mod dpad; +mod media; +mod volume; + +pub use self::dpad::*; +pub use self::media::*; +pub use self::volume::*; diff --git a/src/controller/controls/volume.rs b/src/controller/controls/volume.rs new file mode 100644 index 0000000..0675af2 --- /dev/null +++ b/src/controller/controls/volume.rs @@ -0,0 +1,54 @@ +use crate::common::{DialDir, ThresholdHelper}; +use crate::controller::ControlMode; +use crate::fake_input::FakeInput; +use crate::DynResult; + +use evdev_rs::enums::EV_KEY; + +pub struct Volume { + thresh: ThresholdHelper, + + fake_input: FakeInput, +} + +impl Volume { + pub fn new(sensitivity: i32) -> Volume { + Volume { + thresh: ThresholdHelper::new(sensitivity), + + fake_input: FakeInput::new(), + } + } +} + +impl ControlMode for Volume { + fn on_btn_press(&mut self) -> DynResult<()> { + // TODO: support double-click to mute + + eprintln!("play/pause"); + // self.fake_input.mute()? + self.fake_input.key_click(&[EV_KEY::KEY_PLAYPAUSE])?; + Ok(()) + } + + fn on_btn_release(&mut self) -> DynResult<()> { + Ok(()) + } + + fn on_dial(&mut self, delta: i32) -> DynResult<()> { + match self.thresh.update(delta) { + Some(DialDir::Left) => { + eprintln!("volume down"); + self.fake_input + .key_click(&[EV_KEY::KEY_LEFTSHIFT, EV_KEY::KEY_VOLUMEDOWN])? + } + Some(DialDir::Right) => { + eprintln!("volume up"); + self.fake_input + .key_click(&[EV_KEY::KEY_LEFTSHIFT, EV_KEY::KEY_VOLUMEUP])? + } + None => {} + } + Ok(()) + } +} diff --git a/src/controller/mod.rs b/src/controller/mod.rs new file mode 100644 index 0000000..e549bd3 --- /dev/null +++ b/src/controller/mod.rs @@ -0,0 +1,42 @@ +use crate::DynResult; + +use crate::dial_device::{DialDevice, DialEventKind}; + +pub mod controls; + +pub trait ControlMode { + fn on_btn_press(&mut self) -> DynResult<()>; + fn on_btn_release(&mut self) -> DynResult<()>; + fn on_dial(&mut self, delta: i32) -> DynResult<()>; +} + +pub struct DialController { + device: DialDevice, + + mode: Box, +} + +impl DialController { + pub fn new(device: DialDevice, default_mode: Box) -> DialController { + DialController { + mode: default_mode, + + device, + } + } + + pub fn run(&mut self) -> DynResult<()> { + loop { + let evt = self.device.next_event()?; + + // TODO: press and hold + rotate to switch between modes + + match evt.kind { + DialEventKind::Ignored => {} + DialEventKind::ButtonPress => self.mode.on_btn_press()?, + DialEventKind::ButtonRelease => self.mode.on_btn_release()?, + DialEventKind::Dial(delta) => self.mode.on_dial(delta)?, + } + } + } +} diff --git a/src/dial_device.rs b/src/dial_device.rs new file mode 100644 index 0000000..18d71c0 --- /dev/null +++ b/src/dial_device.rs @@ -0,0 +1,111 @@ +use std::fs; +use std::time::Duration; + +use evdev_rs::{Device, InputEvent}; + +use crate::error::Error; + +pub struct DialDevice { + // TODO: explore what the control channel can be used for... + _control: Device, + axis: Device, +} + +#[derive(Debug)] +pub struct DialEvent { + pub time: Duration, + pub kind: DialEventKind, +} + +#[derive(Debug)] +pub enum DialEventKind { + Ignored, + ButtonPress, + ButtonRelease, + Dial(i32), +} + +impl DialDevice { + pub fn new() -> Result { + let mut control = None; + let mut axis = None; + + for e in fs::read_dir("/dev/input/").map_err(Error::Io)? { + let e = e.map_err(Error::Io)?; + if !e.file_name().to_str().unwrap().starts_with("event") { + continue; + } + + let file = fs::File::open(e.path()).map_err(Error::Io)?; + let dev = Device::new_from_fd(file).map_err(Error::Evdev)?; + + match dev.name() { + Some("Surface Dial System Control") => match control { + None => control = Some(dev), + Some(_) => return Err(Error::MultipleDials), + }, + Some("Surface Dial System Multi Axis") => match axis { + None => axis = Some(dev), + Some(_) => return Err(Error::MultipleDials), + }, + // Some(other) => println!("{:?}", other), + _ => {} + } + + // early return once both were found + if control.is_some() && axis.is_some() { + break; + } + } + + Ok(DialDevice { + _control: control.ok_or(Error::MissingDial)?, + axis: axis.ok_or(Error::MissingDial)?, + }) + } + + pub fn next_event(&self) -> Result { + // TODO: figure out how to interleave control events into the same event stream. + + let (_axis_status, axis_evt) = self + .axis + .next_event(evdev_rs::ReadFlag::NORMAL) + .map_err(Error::Evdev)?; + // assert!(matches!(axis_status, ReadStatus::Success)); + + let event = + DialEvent::from_raw_evt(axis_evt.clone()).ok_or(Error::UnexpectedEvt(axis_evt))?; + + Ok(event) + } +} + +impl DialEvent { + fn from_raw_evt(evt: InputEvent) -> Option { + use evdev_rs::enums::*; + + let evt_kind = match evt.event_type { + EventType::EV_SYN | EventType::EV_MSC => DialEventKind::Ignored, + EventType::EV_KEY => match evt.event_code { + EventCode::EV_KEY(EV_KEY::BTN_0) => match evt.value { + 0 => DialEventKind::ButtonRelease, + 1 => DialEventKind::ButtonPress, + _ => return None, + }, + _ => return None, + }, + EventType::EV_REL => match evt.event_code { + EventCode::EV_REL(EV_REL::REL_DIAL) => DialEventKind::Dial(evt.value), + _ => return None, + }, + _ => return None, + }; + + let evt = DialEvent { + time: Duration::new(evt.time.tv_sec as u64, (evt.time.tv_usec * 1000) as u32), + kind: evt_kind, + }; + + Some(evt) + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..b667bf2 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,26 @@ +use std::fmt; + +use evdev_rs::InputEvent; + +#[derive(Debug)] +pub enum Error { + MissingDial, + MultipleDials, + UnexpectedEvt(InputEvent), + Evdev(std::io::Error), + Io(std::io::Error), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::MissingDial => write!(f, "Could not find the Surface Dial"), + Error::MultipleDials => write!(f, "Found multiple dials"), + Error::UnexpectedEvt(evt) => write!(f, "Unexpected event: {:?}", evt), + Error::Evdev(e) => write!(f, "Evdev error: {:?}", e), + Error::Io(e) => write!(f, "Io error: {:?}", e), + } + } +} + +impl std::error::Error for Error {} diff --git a/src/fake_input.rs b/src/fake_input.rs new file mode 100644 index 0000000..335ad11 --- /dev/null +++ b/src/fake_input.rs @@ -0,0 +1,102 @@ +use std::io; + +use evdev_rs::enums::*; +use evdev_rs::{Device, InputEvent, TimeVal, UInputDevice}; + +static mut FAKE_INPUT: Option = None; +fn get_fake_input() -> io::Result<&'static UInputDevice> { + if unsafe { FAKE_INPUT.is_none() } { + let device = Device::new().unwrap(); + device.set_name("Surface Dial Virtual Input"); + + device.enable(&EventType::EV_SYN)?; + device.enable(&EventCode::EV_SYN(EV_SYN::SYN_REPORT))?; + + device.enable(&EventType::EV_KEY)?; + device.enable(&EventCode::EV_KEY(EV_KEY::KEY_LEFTSHIFT))?; + + device.enable(&EventCode::EV_KEY(EV_KEY::KEY_MUTE))?; + device.enable(&EventCode::EV_KEY(EV_KEY::KEY_VOLUMEDOWN))?; + device.enable(&EventCode::EV_KEY(EV_KEY::KEY_VOLUMEUP))?; + device.enable(&EventCode::EV_KEY(EV_KEY::KEY_NEXTSONG))?; + device.enable(&EventCode::EV_KEY(EV_KEY::KEY_PLAYPAUSE))?; + device.enable(&EventCode::EV_KEY(EV_KEY::KEY_PREVIOUSSONG))?; + + device.enable(&EventCode::EV_KEY(EV_KEY::KEY_LEFT))?; + device.enable(&EventCode::EV_KEY(EV_KEY::KEY_RIGHT))?; + device.enable(&EventCode::EV_KEY(EV_KEY::KEY_SPACE))?; + + device.enable(&EventType::EV_MSC)?; + device.enable(&EventCode::EV_MSC(EV_MSC::MSC_SCAN))?; + + unsafe { FAKE_INPUT = Some(UInputDevice::create_from_device(&device)?) } + } + unsafe { Ok(FAKE_INPUT.as_ref().unwrap()) } +} + +#[non_exhaustive] +pub struct FakeInput { + uinput: &'static UInputDevice, +} + +macro_rules! input_event { + ($type:ident, $code:ident, $value:expr) => { + InputEvent { + time: TimeVal::new(0, 0), + event_code: EventCode::$type($type::$code), + event_type: EventType::$type, + value: $value, + } + }; +} + +impl Default for FakeInput { + fn default() -> Self { + Self::new() + } +} + +impl FakeInput { + pub fn new() -> FakeInput { + FakeInput { + uinput: get_fake_input().expect("could not install fake input device"), + } + } + + fn syn_report(&self) -> io::Result<()> { + self.uinput + .write_event(&input_event!(EV_SYN, SYN_REPORT, 0)) + } + + pub fn key_click(&self, keys: &[EV_KEY]) -> io::Result<()> { + self.key_press(keys)?; + self.key_release(keys)?; + Ok(()) + } + + pub fn key_press(&self, keys: &[EV_KEY]) -> io::Result<()> { + for key in keys { + self.uinput.write_event(&InputEvent { + time: TimeVal::new(0, 0), + event_code: EventCode::EV_KEY(*key), + event_type: EventType::EV_KEY, + value: 1, + })?; + } + self.syn_report()?; + Ok(()) + } + + pub fn key_release(&self, keys: &[EV_KEY]) -> io::Result<()> { + for key in keys.iter().clone() { + self.uinput.write_event(&InputEvent { + time: TimeVal::new(0, 0), + event_code: EventCode::EV_KEY(*key), + event_type: EventType::EV_KEY, + value: 0, + })?; + } + self.syn_report()?; + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..9c527b3 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,24 @@ +mod common; +pub mod controller; +mod dial_device; +mod error; +mod fake_input; + +pub type DynResult = Result>; + +use crate::controller::DialController; +use crate::dial_device::DialDevice; +use crate::error::Error; + +fn main() -> DynResult<()> { + let dial = DialDevice::new()?; + println!("Found the dial."); + + let default_mode = Box::new(controller::controls::Volume::new(30)); + // let default_mode = Box::new(controls::Media::new(50)); + // let default_mode = Box::new(controls::DPad::new()); + + let mut controller = DialController::new(dial, default_mode); + + controller.run() +} diff --git a/surface-dial-udev.rules b/surface-dial-udev.rules new file mode 100644 index 0000000..36d86e7 --- /dev/null +++ b/surface-dial-udev.rules @@ -0,0 +1 @@ +ACTION=="add", ATTR{name}=="Surface Dial System Multi Axis", TAG+="systemd", ENV{SYSTEMD_WANTS}="surface-dial.service" diff --git a/surface-dial.service b/surface-dial.service new file mode 100644 index 0000000..e92ff87 --- /dev/null +++ b/surface-dial.service @@ -0,0 +1,4 @@ +# modify ExecStart to wherever you put surface-dial-daemon +[Service] +Type=oneshot +ExecStart=/home/danielprilik/.cargo/bin/surface-dial-daemon