diff --git a/README.md b/README.md index a884156..f84f84c 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,13 @@ You've been warned :eyes: ## Overview -Consists of two components: +`surface-dial-daemon` is a background daemon which recieves raw events and translates them to various actions. -- `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) +Aside from haptic feedback, the daemon also uses FreeDesktop notifications to provide visual feedback when performing various actions. -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. +![](notif-demo.gif) + +It would be cool to create some sort of GUI overlay (similar to the Windows one), though that's out of scope at the moment. ## Functionality @@ -33,12 +34,12 @@ It would be cool to create some sort of GUI overlay (similar to the Windows one) - [x] Media Controls - [x] D-Pad (emulated left, right, and space key) - [x] Scrolling / Zooming - - [ ] \(meta\) Specify modes via config file(s) -- [ ] Dynamically switch between operating modes - - _currently requires re-compiling the daemon_ - - [ ] Using some-sort of on-device mechanism (e.g: long-press) - - [ ] Using `surface-dial-cli` application + - [ ] \(meta\) custom modes specified via config file(s) +- [x] Dynamically switch between operating modes + - [x] Using some-sort of on-device mechanism (e.g: long-press) - [ ] Context-sensitive (based on currently open application) +- [ ] Mode Persistence + - _At the moment, whenever the dial disconnects, the daemon is re-launched, which resets the active mode to the default one. It would be good to have some on-disk persistence to remember the last selected mode._ - [x] Haptic Feedback - https://docs.microsoft.com/en-us/windows-hardware/design/component-guidelines/radial-controller-protocol-implementation - https://www.usb.org/sites/default/files/hutrr63b_-_haptics_page_redline_0.pdf @@ -46,6 +47,7 @@ It would be cool to create some sort of GUI overlay (similar to the Windows one) - _This was tricky to figure out, but in the end, it was surprisingly straightforward! Big thanks to [Geo](https://www.linkedin.com/in/geo-palakunnel-57718245/) for pointing me in the right direction!_ - [x] Desktop Notifications - [x] On Launch + - [x] When switching between modes - [x] When switching between sub-modes (e.g: scroll/zoom) Feel free to contribute new features! @@ -88,10 +90,6 @@ 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). diff --git a/notif-demo.gif b/notif-demo.gif new file mode 100644 index 0000000..a3b7936 Binary files /dev/null and b/notif-demo.gif differ diff --git a/src/common.rs b/src/common.rs index 1f8d4c1..e336fcf 100644 --- a/src/common.rs +++ b/src/common.rs @@ -2,6 +2,7 @@ use notify_rust::error::Result as NotifyResult; use notify_rust::{Hint, Notification, NotificationHandle, Timeout}; pub fn action_notification(msg: &str, icon: &str) -> NotifyResult { + eprintln!("sending notification: {}", msg); Notification::new() .hint(Hint::Transient(true)) .hint(Hint::Category("device".into())) diff --git a/src/controller/controls/dpad.rs b/src/controller/controls/dpad.rs index 0560f43..5a1226e 100644 --- a/src/controller/controls/dpad.rs +++ b/src/controller/controls/dpad.rs @@ -3,7 +3,7 @@ use std::sync::mpsc; use std::thread::JoinHandle; use std::time::Duration; -use crate::controller::ControlMode; +use crate::controller::{ControlMode, ControlModeMeta}; use crate::dial_device::DialHaptics; use crate::fake_input::FakeInput; use crate::DynResult; @@ -123,6 +123,13 @@ impl DPad { } impl ControlMode for DPad { + fn meta(&self) -> ControlModeMeta { + ControlModeMeta { + name: "Paddle", + icon: "input-gaming", + } + } + fn on_start(&mut self, haptics: &DialHaptics) -> DynResult<()> { haptics.set_mode(false, Some(3600))?; Ok(()) diff --git a/src/controller/controls/media.rs b/src/controller/controls/media.rs index f993fe7..44a2f13 100644 --- a/src/controller/controls/media.rs +++ b/src/controller/controls/media.rs @@ -1,4 +1,4 @@ -use crate::controller::ControlMode; +use crate::controller::{ControlMode, ControlModeMeta}; use crate::dial_device::DialHaptics; use crate::fake_input::FakeInput; use crate::DynResult; @@ -18,6 +18,13 @@ impl Media { } impl ControlMode for Media { + fn meta(&self) -> ControlModeMeta { + ControlModeMeta { + name: "Media", + icon: "applications-multimedia", + } + } + fn on_start(&mut self, haptics: &DialHaptics) -> DynResult<()> { haptics.set_mode(false, Some(36))?; Ok(()) diff --git a/src/controller/controls/null.rs b/src/controller/controls/null.rs index 02d38a3..1218c12 100644 --- a/src/controller/controls/null.rs +++ b/src/controller/controls/null.rs @@ -1,16 +1,15 @@ -use crate::controller::ControlMode; +use crate::controller::{ControlMode, ControlModeMeta}; use crate::dial_device::DialHaptics; use crate::DynResult; -pub struct Null {} - -impl Null { - pub fn new() -> Null { - Null {} +impl ControlMode for () { + fn meta(&self) -> ControlModeMeta { + ControlModeMeta { + name: "null", + icon: "", + } } -} -impl ControlMode for Null { fn on_start(&mut self, haptics: &DialHaptics) -> DynResult<()> { haptics.set_mode(false, Some(0))?; Ok(()) diff --git a/src/controller/controls/scroll_zoom.rs b/src/controller/controls/scroll_zoom.rs index 9273888..72e2642 100644 --- a/src/controller/controls/scroll_zoom.rs +++ b/src/controller/controls/scroll_zoom.rs @@ -1,5 +1,5 @@ use crate::common::action_notification; -use crate::controller::ControlMode; +use crate::controller::{ControlMode, ControlModeMeta}; use crate::dial_device::DialHaptics; use crate::fake_input::{FakeInput, ScrollStep}; use crate::DynResult; @@ -26,6 +26,13 @@ const ZOOM_SENSITIVITY: u16 = 36; const SCROLL_SENSITIVITY: u16 = 90; impl ControlMode for ScrollZoom { + fn meta(&self) -> ControlModeMeta { + ControlModeMeta { + name: "Scroll/Zoom", + icon: "input-mouse", + } + } + fn on_start(&mut self, haptics: &DialHaptics) -> DynResult<()> { haptics.set_mode(false, Some(SCROLL_SENSITIVITY))?; Ok(()) @@ -43,7 +50,7 @@ impl ControlMode for ScrollZoom { action_notification("Zoom Mode", "zoom-in")?; haptics.set_mode(false, Some(ZOOM_SENSITIVITY))?; } else { - action_notification("ScrollZoom Mode", "input-mouse")?; + action_notification("Scroll Mode", "input-mouse")?; haptics.set_mode(false, Some(SCROLL_SENSITIVITY))?; } diff --git a/src/controller/controls/volume.rs b/src/controller/controls/volume.rs index 89274e6..2fb6168 100644 --- a/src/controller/controls/volume.rs +++ b/src/controller/controls/volume.rs @@ -1,4 +1,4 @@ -use crate::controller::ControlMode; +use crate::controller::{ControlMode, ControlModeMeta}; use crate::dial_device::DialHaptics; use crate::fake_input::FakeInput; use crate::DynResult; @@ -18,8 +18,15 @@ impl Volume { } impl ControlMode for Volume { + fn meta(&self) -> ControlModeMeta { + ControlModeMeta { + name: "Volume", + icon: "audio-volume-high", + } + } + fn on_start(&mut self, haptics: &DialHaptics) -> DynResult<()> { - haptics.set_mode(false, Some(36 * 2))?; + haptics.set_mode(true, Some(36 * 2))?; Ok(()) } diff --git a/src/controller/mod.rs b/src/controller/mod.rs index fba7b2c..14affef 100644 --- a/src/controller/mod.rs +++ b/src/controller/mod.rs @@ -1,47 +1,191 @@ -use crate::DynResult; +use std::sync::{Arc, Mutex}; use crate::dial_device::{DialDevice, DialEventKind, DialHaptics}; +use crate::DynResult; pub mod controls; +pub struct ControlModeMeta { + name: &'static str, + icon: &'static str, +} + pub trait ControlMode { + fn meta(&self) -> ControlModeMeta; + fn on_start(&mut self, haptics: &DialHaptics) -> DynResult<()>; + fn on_end(&mut self, _haptics: &DialHaptics) -> DynResult<()> { + Ok(()) + } + fn on_btn_press(&mut self, haptics: &DialHaptics) -> DynResult<()>; fn on_btn_release(&mut self, haptics: &DialHaptics) -> DynResult<()>; fn on_dial(&mut self, haptics: &DialHaptics, delta: i32) -> DynResult<()>; } +enum ActiveMode { + Normal(usize), + Meta, +} + pub struct DialController { device: DialDevice, - mode: Box, + modes: Vec>, + active_mode: ActiveMode, + + new_mode: Arc>>, + meta_mode: Box, // always MetaMode } impl DialController { - pub fn new(device: DialDevice, default_mode: Box) -> DialController { - DialController { - mode: default_mode, + pub fn new(device: DialDevice, modes: Vec>) -> DialController { + let metas = modes.iter().map(|m| m.meta()).collect(); + let new_mode = Arc::new(Mutex::new(None)); + + DialController { device, + + modes, + active_mode: ActiveMode::Normal(0), + + new_mode: new_mode.clone(), + meta_mode: Box::new(MetaMode::new(new_mode, 0, metas)), } } pub fn run(&mut self) -> DynResult<()> { - let haptics = self.device.haptics(); - - self.mode.on_start(haptics)?; + self.modes[0].on_start(self.device.haptics())?; loop { let evt = self.device.next_event()?; + let haptics = self.device.haptics(); + + if let Some(new_mode) = self.new_mode.lock().unwrap().take() { + self.active_mode = ActiveMode::Normal(new_mode); + self.modes[new_mode].on_start(haptics)?; + } + + let mode = match self.active_mode { + ActiveMode::Normal(idx) => &mut self.modes[idx], + ActiveMode::Meta => &mut self.meta_mode, + }; // TODO: press and hold (+ rotate?) to switch between modes match evt.kind { DialEventKind::Ignored => {} - DialEventKind::ButtonPress => self.mode.on_btn_press(haptics)?, - DialEventKind::ButtonRelease => self.mode.on_btn_release(haptics)?, - DialEventKind::Dial(delta) => self.mode.on_dial(haptics, delta)?, + DialEventKind::ButtonPress => mode.on_btn_press(haptics)?, + DialEventKind::ButtonRelease => mode.on_btn_release(haptics)?, + DialEventKind::Dial(delta) => mode.on_dial(haptics, delta)?, + DialEventKind::ButtonLongPress => { + eprintln!("long press!"); + if !matches!(self.active_mode, ActiveMode::Meta) { + mode.on_end(haptics)?; + self.active_mode = ActiveMode::Meta; + self.meta_mode.on_start(haptics)?; + } + } } } } } + +/// A mode for switching between modes. +struct MetaMode { + // constant + metas: Vec, + + // stateful (across invocations) + current_mode: usize, + new_mode: Arc>>, + + // reset in on_start + first_release: bool, + notif: Option, +} + +impl MetaMode { + fn new( + new_mode: Arc>>, + current_mode: usize, + metas: Vec, + ) -> MetaMode { + MetaMode { + metas, + + current_mode, + new_mode, + + first_release: true, + notif: None, + } + } +} + +impl ControlMode for MetaMode { + fn meta(&self) -> ControlModeMeta { + unreachable!() // meta mode never queries itself + } + + fn on_start(&mut self, haptics: &DialHaptics) -> DynResult<()> { + use notify_rust::*; + self.notif = Some( + Notification::new() + .hint(Hint::Resident(true)) + .hint(Hint::Category("device".into())) + .timeout(Timeout::Never) + .summary("Surface Dial") + .body(&format!( + "Entered Meta Mode (From Mode: {})", + self.metas[self.current_mode].name + )) + .icon("emblem-system") + .show()?, + ); + + haptics.buzz(1)?; + + self.first_release = true; + + haptics.set_mode(true, Some(36))?; + Ok(()) + } + + fn on_btn_press(&mut self, _haptics: &DialHaptics) -> DynResult<()> { + Ok(()) + } + + fn on_btn_release(&mut self, haptics: &DialHaptics) -> DynResult<()> { + if self.first_release { + self.first_release = false; + } else { + *self.new_mode.lock().unwrap() = Some(self.current_mode); + haptics.buzz(1)?; + + self.notif.take().unwrap().close(); + } + Ok(()) + } + + fn on_dial(&mut self, _haptics: &DialHaptics, delta: i32) -> DynResult<()> { + if delta > 0 { + self.current_mode += 1; + } else { + self.current_mode -= 1; + } + + self.current_mode %= self.metas.len(); + + let mode_meta = &self.metas[self.current_mode]; + if let Some(ref mut notification) = self.notif { + notification + .body(&format!("New Mode: {}", mode_meta.name)) + .icon(mode_meta.icon); + notification.update(); + } + + Ok(()) + } +} diff --git a/src/dial_device.rs b/src/dial_device.rs index 029d15d..58fedd2 100644 --- a/src/dial_device.rs +++ b/src/dial_device.rs @@ -1,4 +1,5 @@ use std::fs; +use std::sync::mpsc; use std::time::Duration; use evdev_rs::{Device, InputEvent}; @@ -7,10 +8,11 @@ use hidapi::{HidApi, HidDevice}; use crate::error::Error; pub struct DialDevice { - // TODO: explore what the control channel can be used for... - _control: Device, - axis: Device, + long_press_timeout: Duration, haptics: DialHaptics, + events: mpsc::Receiver, + + possible_long_press: bool, } #[derive(Debug)] @@ -25,10 +27,11 @@ pub enum DialEventKind { ButtonPress, ButtonRelease, Dial(i32), + ButtonLongPress, } impl DialDevice { - pub fn new() -> Result { + pub fn new(long_press_timeout: Duration) -> Result { let mut control = None; let mut axis = None; @@ -62,24 +65,66 @@ impl DialDevice { } } + // TODO: explore what the control channel can be used for... + let _control = control.ok_or(Error::MissingDial)?; + let axis = axis.ok_or(Error::MissingDial)?; + + let (events_tx, events_rx) = mpsc::channel(); + + // TODO: interleave control events with regular events + + std::thread::spawn({ + let events = events_tx; + move || { + loop { + let (_axis_status, axis_evt) = axis + .next_event(evdev_rs::ReadFlag::NORMAL) + .expect("Error::Evdev"); + // assert!(matches!(axis_status, ReadStatus::Success)); + let event = DialEvent::from_raw_evt(axis_evt.clone()) + .expect("Error::UnexpectedEvt(axis_evt)"); + + events.send(event).expect("failed to send axis event"); + } + } + }); + Ok(DialDevice { - _control: control.ok_or(Error::MissingDial)?, - axis: axis.ok_or(Error::MissingDial)?, + long_press_timeout, + events: events_rx, haptics: DialHaptics::new()?, + + possible_long_press: false, }) } - pub fn next_event(&self) -> Result { - // TODO: figure out how to interleave control events into the same event stream. + pub fn next_event(&mut self) -> Result { + let evt = if self.possible_long_press { + self.events.recv_timeout(self.long_press_timeout) + } else { + self.events + .recv() + .map_err(|_| mpsc::RecvTimeoutError::Disconnected) + }; - 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))?; + let event = match evt { + Ok(event) => { + match event.kind { + DialEventKind::ButtonPress => self.possible_long_press = true, + DialEventKind::ButtonRelease => self.possible_long_press = false, + _ => {} + } + event + } + Err(mpsc::RecvTimeoutError::Timeout) => { + self.possible_long_press = false; + DialEvent { + time: Duration::from_secs(0), // this could be improved... + kind: DialEventKind::ButtonLongPress, + } + } + Err(_e) => panic!("Could not recv event"), + }; Ok(event) } diff --git a/src/main.rs b/src/main.rs index eea415c..882a338 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,7 +24,7 @@ fn main() { fn true_main() -> DynResult<()> { println!("Started."); - let dial = DialDevice::new()?; + let dial = DialDevice::new(std::time::Duration::from_millis(750))?; println!("Found the dial."); std::thread::spawn(move || { @@ -34,7 +34,7 @@ fn true_main() -> DynResult<()> { .timeout(Timeout::Never) .summary("Surface Dial") .body("Active!") - .icon("input-mouse") + .icon("media-optical") // it should be vaguely circular :P .show() .expect("failed to send notification"); @@ -46,13 +46,15 @@ fn true_main() -> DynResult<()> { } }); - // let default_mode = Box::new(controller::controls::Null::new()); - let default_mode = Box::new(controller::controls::ScrollZoom::new()); - // let default_mode = Box::new(controller::controls::Volume::new()); - // let default_mode = Box::new(controller::controls::Media::new()); - // let default_mode = Box::new(controller::controls::DPad::new()); - - let mut controller = DialController::new(dial, default_mode); + let mut controller = DialController::new( + dial, + vec![ + Box::new(controller::controls::ScrollZoom::new()), + Box::new(controller::controls::Volume::new()), + Box::new(controller::controls::Media::new()), + Box::new(controller::controls::DPad::new()), + ], + ); controller.run() }