1
0

add long-press + notif based mode switching

oooooh boy, this is looking pretty slick. I'm kind-of impressed I was
able to throw this together in ~2 days of post-work hacking (though I
guess they were some pretty late-nights...)

There's really only one feature left that I _need_ to implement, which
is the on-disk persistence for selected mode. That shouldn't be too
tricky though...
This commit is contained in:
Daniel Prilik
2020-10-30 23:39:12 -04:00
parent e6fa6845fe
commit d14a92dfe5
11 changed files with 280 additions and 63 deletions

View File

@@ -18,12 +18,13 @@ You've been warned :eyes:
## Overview ## 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. Aside from haptic feedback, the daemon also uses FreeDesktop notifications to provide visual feedback when performing 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. ![](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 ## 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] Media Controls
- [x] D-Pad (emulated left, right, and space key) - [x] D-Pad (emulated left, right, and space key)
- [x] Scrolling / Zooming - [x] Scrolling / Zooming
- [ ] \(meta\) Specify modes via config file(s) - [ ] \(meta\) custom modes specified via config file(s)
- [ ] Dynamically switch between operating modes - [x] Dynamically switch between operating modes
- _currently requires re-compiling the daemon_ - [x] Using some-sort of on-device mechanism (e.g: long-press)
- [ ] Using some-sort of on-device mechanism (e.g: long-press)
- [ ] Using `surface-dial-cli` application
- [ ] Context-sensitive (based on currently open application) - [ ] 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 - [x] Haptic Feedback
- https://docs.microsoft.com/en-us/windows-hardware/design/component-guidelines/radial-controller-protocol-implementation - 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 - 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!_ - _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] Desktop Notifications
- [x] On Launch - [x] On Launch
- [x] When switching between modes
- [x] When switching between sub-modes (e.g: scroll/zoom) - [x] When switching between sub-modes (e.g: scroll/zoom)
Feel free to contribute new features! 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`. 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 ## Installation
As you might have noticed, the daemon dies whenever the Surface Dial disconnects (which happens after a brief period of inactivity). As you might have noticed, the daemon dies whenever the Surface Dial disconnects (which happens after a brief period of inactivity).

BIN
notif-demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

View File

@@ -2,6 +2,7 @@ use notify_rust::error::Result as NotifyResult;
use notify_rust::{Hint, Notification, NotificationHandle, Timeout}; use notify_rust::{Hint, Notification, NotificationHandle, Timeout};
pub fn action_notification(msg: &str, icon: &str) -> NotifyResult<NotificationHandle> { pub fn action_notification(msg: &str, icon: &str) -> NotifyResult<NotificationHandle> {
eprintln!("sending notification: {}", msg);
Notification::new() Notification::new()
.hint(Hint::Transient(true)) .hint(Hint::Transient(true))
.hint(Hint::Category("device".into())) .hint(Hint::Category("device".into()))

View File

@@ -3,7 +3,7 @@ use std::sync::mpsc;
use std::thread::JoinHandle; use std::thread::JoinHandle;
use std::time::Duration; use std::time::Duration;
use crate::controller::ControlMode; use crate::controller::{ControlMode, ControlModeMeta};
use crate::dial_device::DialHaptics; use crate::dial_device::DialHaptics;
use crate::fake_input::FakeInput; use crate::fake_input::FakeInput;
use crate::DynResult; use crate::DynResult;
@@ -123,6 +123,13 @@ impl DPad {
} }
impl ControlMode for DPad { impl ControlMode for DPad {
fn meta(&self) -> ControlModeMeta {
ControlModeMeta {
name: "Paddle",
icon: "input-gaming",
}
}
fn on_start(&mut self, haptics: &DialHaptics) -> DynResult<()> { fn on_start(&mut self, haptics: &DialHaptics) -> DynResult<()> {
haptics.set_mode(false, Some(3600))?; haptics.set_mode(false, Some(3600))?;
Ok(()) Ok(())

View File

@@ -1,4 +1,4 @@
use crate::controller::ControlMode; use crate::controller::{ControlMode, ControlModeMeta};
use crate::dial_device::DialHaptics; use crate::dial_device::DialHaptics;
use crate::fake_input::FakeInput; use crate::fake_input::FakeInput;
use crate::DynResult; use crate::DynResult;
@@ -18,6 +18,13 @@ impl Media {
} }
impl ControlMode for Media { impl ControlMode for Media {
fn meta(&self) -> ControlModeMeta {
ControlModeMeta {
name: "Media",
icon: "applications-multimedia",
}
}
fn on_start(&mut self, haptics: &DialHaptics) -> DynResult<()> { fn on_start(&mut self, haptics: &DialHaptics) -> DynResult<()> {
haptics.set_mode(false, Some(36))?; haptics.set_mode(false, Some(36))?;
Ok(()) Ok(())

View File

@@ -1,16 +1,15 @@
use crate::controller::ControlMode; use crate::controller::{ControlMode, ControlModeMeta};
use crate::dial_device::DialHaptics; use crate::dial_device::DialHaptics;
use crate::DynResult; use crate::DynResult;
pub struct Null {} impl ControlMode for () {
fn meta(&self) -> ControlModeMeta {
impl Null { ControlModeMeta {
pub fn new() -> Null { name: "null",
Null {} icon: "",
}
} }
}
impl ControlMode for Null {
fn on_start(&mut self, haptics: &DialHaptics) -> DynResult<()> { fn on_start(&mut self, haptics: &DialHaptics) -> DynResult<()> {
haptics.set_mode(false, Some(0))?; haptics.set_mode(false, Some(0))?;
Ok(()) Ok(())

View File

@@ -1,5 +1,5 @@
use crate::common::action_notification; use crate::common::action_notification;
use crate::controller::ControlMode; use crate::controller::{ControlMode, ControlModeMeta};
use crate::dial_device::DialHaptics; use crate::dial_device::DialHaptics;
use crate::fake_input::{FakeInput, ScrollStep}; use crate::fake_input::{FakeInput, ScrollStep};
use crate::DynResult; use crate::DynResult;
@@ -26,6 +26,13 @@ const ZOOM_SENSITIVITY: u16 = 36;
const SCROLL_SENSITIVITY: u16 = 90; const SCROLL_SENSITIVITY: u16 = 90;
impl ControlMode for ScrollZoom { impl ControlMode for ScrollZoom {
fn meta(&self) -> ControlModeMeta {
ControlModeMeta {
name: "Scroll/Zoom",
icon: "input-mouse",
}
}
fn on_start(&mut self, haptics: &DialHaptics) -> DynResult<()> { fn on_start(&mut self, haptics: &DialHaptics) -> DynResult<()> {
haptics.set_mode(false, Some(SCROLL_SENSITIVITY))?; haptics.set_mode(false, Some(SCROLL_SENSITIVITY))?;
Ok(()) Ok(())
@@ -43,7 +50,7 @@ impl ControlMode for ScrollZoom {
action_notification("Zoom Mode", "zoom-in")?; action_notification("Zoom Mode", "zoom-in")?;
haptics.set_mode(false, Some(ZOOM_SENSITIVITY))?; haptics.set_mode(false, Some(ZOOM_SENSITIVITY))?;
} else { } else {
action_notification("ScrollZoom Mode", "input-mouse")?; action_notification("Scroll Mode", "input-mouse")?;
haptics.set_mode(false, Some(SCROLL_SENSITIVITY))?; haptics.set_mode(false, Some(SCROLL_SENSITIVITY))?;
} }

View File

@@ -1,4 +1,4 @@
use crate::controller::ControlMode; use crate::controller::{ControlMode, ControlModeMeta};
use crate::dial_device::DialHaptics; use crate::dial_device::DialHaptics;
use crate::fake_input::FakeInput; use crate::fake_input::FakeInput;
use crate::DynResult; use crate::DynResult;
@@ -18,8 +18,15 @@ impl Volume {
} }
impl ControlMode for Volume { impl ControlMode for Volume {
fn meta(&self) -> ControlModeMeta {
ControlModeMeta {
name: "Volume",
icon: "audio-volume-high",
}
}
fn on_start(&mut self, haptics: &DialHaptics) -> DynResult<()> { fn on_start(&mut self, haptics: &DialHaptics) -> DynResult<()> {
haptics.set_mode(false, Some(36 * 2))?; haptics.set_mode(true, Some(36 * 2))?;
Ok(()) Ok(())
} }

View File

@@ -1,47 +1,191 @@
use crate::DynResult; use std::sync::{Arc, Mutex};
use crate::dial_device::{DialDevice, DialEventKind, DialHaptics}; use crate::dial_device::{DialDevice, DialEventKind, DialHaptics};
use crate::DynResult;
pub mod controls; pub mod controls;
pub struct ControlModeMeta {
name: &'static str,
icon: &'static str,
}
pub trait ControlMode { pub trait ControlMode {
fn meta(&self) -> ControlModeMeta;
fn on_start(&mut self, haptics: &DialHaptics) -> DynResult<()>; 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_press(&mut self, haptics: &DialHaptics) -> DynResult<()>;
fn on_btn_release(&mut self, haptics: &DialHaptics) -> DynResult<()>; fn on_btn_release(&mut self, haptics: &DialHaptics) -> DynResult<()>;
fn on_dial(&mut self, haptics: &DialHaptics, delta: i32) -> DynResult<()>; fn on_dial(&mut self, haptics: &DialHaptics, delta: i32) -> DynResult<()>;
} }
enum ActiveMode {
Normal(usize),
Meta,
}
pub struct DialController { pub struct DialController {
device: DialDevice, device: DialDevice,
mode: Box<dyn ControlMode>, modes: Vec<Box<dyn ControlMode>>,
active_mode: ActiveMode,
new_mode: Arc<Mutex<Option<usize>>>,
meta_mode: Box<dyn ControlMode>, // always MetaMode
} }
impl DialController { impl DialController {
pub fn new(device: DialDevice, default_mode: Box<dyn ControlMode>) -> DialController { pub fn new(device: DialDevice, modes: Vec<Box<dyn ControlMode>>) -> DialController {
DialController { let metas = modes.iter().map(|m| m.meta()).collect();
mode: default_mode,
let new_mode = Arc::new(Mutex::new(None));
DialController {
device, 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<()> { pub fn run(&mut self) -> DynResult<()> {
let haptics = self.device.haptics(); self.modes[0].on_start(self.device.haptics())?;
self.mode.on_start(haptics)?;
loop { loop {
let evt = self.device.next_event()?; 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 // TODO: press and hold (+ rotate?) to switch between modes
match evt.kind { match evt.kind {
DialEventKind::Ignored => {} DialEventKind::Ignored => {}
DialEventKind::ButtonPress => self.mode.on_btn_press(haptics)?, DialEventKind::ButtonPress => mode.on_btn_press(haptics)?,
DialEventKind::ButtonRelease => self.mode.on_btn_release(haptics)?, DialEventKind::ButtonRelease => mode.on_btn_release(haptics)?,
DialEventKind::Dial(delta) => self.mode.on_dial(haptics, delta)?, 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<ControlModeMeta>,
// stateful (across invocations)
current_mode: usize,
new_mode: Arc<Mutex<Option<usize>>>,
// reset in on_start
first_release: bool,
notif: Option<notify_rust::NotificationHandle>,
}
impl MetaMode {
fn new(
new_mode: Arc<Mutex<Option<usize>>>,
current_mode: usize,
metas: Vec<ControlModeMeta>,
) -> 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(())
}
}

View File

@@ -1,4 +1,5 @@
use std::fs; use std::fs;
use std::sync::mpsc;
use std::time::Duration; use std::time::Duration;
use evdev_rs::{Device, InputEvent}; use evdev_rs::{Device, InputEvent};
@@ -7,10 +8,11 @@ use hidapi::{HidApi, HidDevice};
use crate::error::Error; use crate::error::Error;
pub struct DialDevice { pub struct DialDevice {
// TODO: explore what the control channel can be used for... long_press_timeout: Duration,
_control: Device,
axis: Device,
haptics: DialHaptics, haptics: DialHaptics,
events: mpsc::Receiver<DialEvent>,
possible_long_press: bool,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -25,10 +27,11 @@ pub enum DialEventKind {
ButtonPress, ButtonPress,
ButtonRelease, ButtonRelease,
Dial(i32), Dial(i32),
ButtonLongPress,
} }
impl DialDevice { impl DialDevice {
pub fn new() -> Result<DialDevice, crate::Error> { pub fn new(long_press_timeout: Duration) -> Result<DialDevice, crate::Error> {
let mut control = None; let mut control = None;
let mut axis = 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 { Ok(DialDevice {
_control: control.ok_or(Error::MissingDial)?, long_press_timeout,
axis: axis.ok_or(Error::MissingDial)?, events: events_rx,
haptics: DialHaptics::new()?, haptics: DialHaptics::new()?,
possible_long_press: false,
}) })
} }
pub fn next_event(&self) -> Result<DialEvent, Error> { pub fn next_event(&mut self) -> Result<DialEvent, Error> {
// TODO: figure out how to interleave control events into the same event stream. 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 let event = match evt {
.axis Ok(event) => {
.next_event(evdev_rs::ReadFlag::NORMAL) match event.kind {
.map_err(Error::Evdev)?; DialEventKind::ButtonPress => self.possible_long_press = true,
// assert!(matches!(axis_status, ReadStatus::Success)); DialEventKind::ButtonRelease => self.possible_long_press = false,
_ => {}
let event = }
DialEvent::from_raw_evt(axis_evt.clone()).ok_or(Error::UnexpectedEvt(axis_evt))?; 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) Ok(event)
} }

View File

@@ -24,7 +24,7 @@ fn main() {
fn true_main() -> DynResult<()> { fn true_main() -> DynResult<()> {
println!("Started."); println!("Started.");
let dial = DialDevice::new()?; let dial = DialDevice::new(std::time::Duration::from_millis(750))?;
println!("Found the dial."); println!("Found the dial.");
std::thread::spawn(move || { std::thread::spawn(move || {
@@ -34,7 +34,7 @@ fn true_main() -> DynResult<()> {
.timeout(Timeout::Never) .timeout(Timeout::Never)
.summary("Surface Dial") .summary("Surface Dial")
.body("Active!") .body("Active!")
.icon("input-mouse") .icon("media-optical") // it should be vaguely circular :P
.show() .show()
.expect("failed to send notification"); .expect("failed to send notification");
@@ -46,13 +46,15 @@ fn true_main() -> DynResult<()> {
} }
}); });
// let default_mode = Box::new(controller::controls::Null::new()); let mut controller = DialController::new(
let default_mode = Box::new(controller::controls::ScrollZoom::new()); dial,
// let default_mode = Box::new(controller::controls::Volume::new()); vec![
// let default_mode = Box::new(controller::controls::Media::new()); Box::new(controller::controls::ScrollZoom::new()),
// let default_mode = Box::new(controller::controls::DPad::new()); Box::new(controller::controls::Volume::new()),
Box::new(controller::controls::Media::new()),
let mut controller = DialController::new(dial, default_mode); Box::new(controller::controls::DPad::new()),
],
);
controller.run() controller.run()
} }