diff --git a/Cargo.lock b/Cargo.lock index 0711465..b9a2b75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -230,6 +230,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "lock_api" version = "0.4.1" @@ -269,6 +279,18 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85db2feff6bf70ebc3a4793191517d5f0331100a2f10f9bf93b5e5214f32b7b7" +dependencies = [ + "bitflags 1.2.1", + "cc", + "cfg-if 0.1.10", + "libc", +] + [[package]] name = "notify-rust" version = "4.0.0" @@ -451,9 +473,11 @@ dependencies = [ "evdev-rs", "hidapi", "lazy_static", + "nix", "notify-rust", "parking_lot", "signal-hook", + "udev", ] [[package]] @@ -487,6 +511,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "udev" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "048df778e99eea028c08cca7853b9b521df6948b59bb29ab8bb737c057f58e6d" +dependencies = [ + "libc", + "libudev-sys", +] + [[package]] name = "unicode-xid" version = "0.0.4" diff --git a/Cargo.toml b/Cargo.toml index c85a47f..e1cb5ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,9 +10,11 @@ directories = "3.0" evdev-rs = { git = "https://github.com/ndesh26/evdev-rs.git", rev = "8e995b8bf" } hidapi = { version = "1.2.3", default-features = false, features = ["linux-shared-hidraw"] } lazy_static = "1.4" +nix = "0.19.0" notify-rust = "4" parking_lot = "0.11.0" signal-hook = "0.1.16" +udev = "0.5" # HACK: Using >1 virtual uinput devices will segfault in release builds. # diff --git a/README.md b/README.md index 156367e..99ddcbf 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,13 @@ Things will change. Things will break. Things are probably buggy. -You've been warned :eyes: +Bug reports are appreciated! ## Overview -`surface-dial-daemon` is a background daemon which recieves raw events and translates them to various actions. +`surface-dial-daemon` is a background daemon which receives raw events from the surface dial, and translates them to various actions. -Aside from haptic feedback, the daemon also uses FreeDesktop notifications to provide visual feedback when performing various actions. +The daemon uses FreeDesktop notifications to provide visual feedback when switching between actions. ![](notif-demo.gif) @@ -22,10 +22,14 @@ It would be cool to create some sort of GUI overlay (similar to the Windows one) ## Implementation -- `libevdev` to read events from the surface dial via `/dev/input/eventXX` -- `libevdev` to fake input via `/dev/uinput` (for keypresses / media controls) -- `hidapi` to configure dial sensitivity + haptics -- `notify-rust` to send notifications over D-Bus +Core functionality is provided by the following libraries. + +- `libudev` to monitor when the dial connects/disconnects. +- `libevdev` to read events from the surface dial through `/dev/input/eventXX`, and to fake input through `/dev/uinput`. +- `hidapi` to configure dial sensitivity + haptics. +- `notify-rust` to send desktop notifications over D-Bus. + +While the device-handling code itself is somewhat messy at the moment, it should be really easy to add new operating modes. Just add a new mode implementation under `src/controller/controls` (making sure to update `src/controller/controls/mod.rs`), and add it to the list of available modes in `main.rs`! ## Functionality @@ -34,12 +38,12 @@ It would be cool to create some sort of GUI overlay (similar to the Windows one) - [x] Volume Controls - [x] Media Controls - [x] Scrolling - using a virtual mouse-wheel - - [x] Scrolling - using a virtual touchpad (for [smoother scrolling](https://who-t.blogspot.com/2020/04/high-resolution-wheel-scrolling-in.html)) + - [ ] Scrolling - using a virtual touchpad (for [smoother scrolling](https://who-t.blogspot.com/2020/04/high-resolution-wheel-scrolling-in.html)) - **WIP** - [x] Zooming - [x] [Paddle](https://www.google.com/search?q=arkanoid+paddle) (emulated left, right, and space key) - [ ] \(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) + - [x] Using a long-press activated "meta" mode - [ ] Context-sensitive (based on currently open application) - [x] Mode Persistence (keep mode when dial disconnects) - [x] Haptic Feedback @@ -47,10 +51,8 @@ It would be cool to create some sort of GUI overlay (similar to the Windows one) - https://www.usb.org/sites/default/files/hutrr63b_-_haptics_page_redline_0.pdf - https://www.usb.org/sites/default/files/hut1_21.pdf - _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) +- [x] Visual Feedback + - [x] FreeDesktop Notifications Feel free to contribute new features! @@ -60,16 +62,17 @@ Building `surface-dial-daemon` requires the following: - Linux Kernel 4.19 or higher - A fairly recent version of the Rust compiler +- `libudev` - `libevdev` - `hidapi` You can install Rust through [`rustup`](https://rustup.rs/). -Unless you're a cool hackerman, the easiest way to get `libevdev` and `hidapi` is via your distro's package manager. +Unless you're a cool hackerman, the easiest way to get `libudev`, `libevdev`, and `hidapi` is via your distro's package manager. ```bash # e.g: on ubuntu -sudo apt install libevdev-dev libhidapi-dev +sudo apt install libevdev-dev libhidapi-dev libudev-dev ``` ## Building @@ -82,20 +85,37 @@ cargo build -p surface-dial-daemon --release The resulting binary is output to `target/release/surface-dial-daemon` +## Running + +The daemon is able to handle the dial disconnecting/reconnecting, so as long as it's running in the background, things should Just Work:tm:. + +Note that the daemon must run as a _user process_ (**not** as root), as it needs access to the user's D-Bus to send notifications. + +Having to run as a user process complicates things a bit, as the daemon must be able to access several restricted-by-default devices under `/dev/`. Notably, the `/dev/uinput` device will need to have it's permissions changed for things to work correctly. The proper way to do this is using the included [udev rule](https://wiki.debian.org/udev), though if you just want to get something up and running, `sudo chmod 666 /dev/uinput` should work fine (though it will revert back once you reboot!). + +See the Installation instructions below for how to set up the permissions / udev rules. + +During development, the easiest way to run `surface-dial-linux` is using `cargo`: + +```bash +cargo run -p surface-dial-daemon +``` + +Alternatively, you can run the daemon directly using the executable at `target/release/surface-dial-daemon`. + ## Installation -At the moment, the daemon dies whenever the Surface Dial disconnects (which naturally happens after a brief period of inactivity). +I encourage you to tweak the following setup procedure for your particular linux configuration. -Instead of doing the Right Thing :tm: and having the daemon detect when the dial connects/disconnects (PRs appreciated!), I've come up with a [cunning plan](https://www.youtube.com/watch?v=AsXKS8Nyu8Q) to spawn the daemon whenever the Surface Dial connects. - -This will only work on systems with `systemd`. +The following steps have been tested working on Ubuntu 20.04/20.10. ```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 +# IMPORTANT: modify the .service file to reflect where you placed the `service-dial-daemon` executable. +# if you used `cargo install`, this should be as simple as replacing `danielprilik` with your own user id vi ./install/surface-dial.service # create new group for uinput @@ -110,17 +130,22 @@ sudo gpasswd -a $(whoami) $(stat -c "%G" /dev/input/event0) mkdir -p ~/.config/systemd/user/ cp ./install/surface-dial.service ~/.config/systemd/user/surface-dial.service -# install the udev rules +# install the udev rule sudo cp ./install/99-uinput.rules /etc/udev/rules.d/99-uinput.rules -sudo cp ./install/50-surface-dial.rules /etc/udev/rules.d/50-surface-dial.rules # reload systemd + udev systemctl --user daemon-reload sudo udevadm control --reload + +# enable and start the user service +systemctl --user enable surface-dial.service +systemctl --user start surface-dial.service ``` +To see if the service is running correctly, run `systemctl --user status surface-dial.service`. + You may need to reboot to have the various groups / udev rules propagate. -## License +If things aren't working, feel free to file a bug report! -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. +_Call for Contributors:_ It would be awesome to have a proper rpm/deb package as well. diff --git a/install/50-surface-dial.rules b/install/50-surface-dial.rules deleted file mode 100644 index 07e737e..0000000 --- a/install/50-surface-dial.rules +++ /dev/null @@ -1 +0,0 @@ -ACTION=="add", ATTR{name}=="Surface Dial System Multi Axis", TAG+="systemd", ENV{SYSTEMD_USER_WANTS}="surface-dial.service" diff --git a/install/surface-dial.service b/install/surface-dial.service index b614743..3b9e980 100644 --- a/install/surface-dial.service +++ b/install/surface-dial.service @@ -1,5 +1,12 @@ -# IMPORTANT: modify the "ExecStart" field to reflect your userid + surface-dial-daemon install dir +# IMPORTANT: modify the "ExecStart" field to reflect your surface-dial-daemon install dir + +[Unit] +Description=Surface Dial Daemon [Service] -# HACK: this service needs to run _after_ the /dev/input/eventXX files have been created -ExecStart=bash -c 'sleep 1 && /home/danielprilik/.cargo/bin/surface-dial-daemon' +Type=simple +StandardOutput=journal +ExecStart=/home/danielprilik/.cargo/bin/surface-dial-daemon + +[Install] +WantedBy=default.target diff --git a/src/controller/mod.rs b/src/controller/mod.rs index b897ce1..a28eab8 100644 --- a/src/controller/mod.rs +++ b/src/controller/mod.rs @@ -35,7 +35,7 @@ pub struct DialController { active_mode: ActiveMode, new_mode: Arc>>, - meta_mode: Box, // always MetaMode + meta_mode: Box, // concrete type is always `MetaMode` } impl DialController { @@ -60,12 +60,6 @@ impl DialController { } pub fn run(&mut self) -> Result<()> { - let initial_mode = match self.active_mode { - ActiveMode::Normal(i) => i, - ActiveMode::Meta => 0, - }; - self.modes[initial_mode].on_start(self.device.haptics())?; - loop { let evt = self.device.next_event()?; let haptics = self.device.haptics(); @@ -80,13 +74,22 @@ impl DialController { ActiveMode::Meta => &mut self.meta_mode, }; - // TODO: press and hold (+ rotate?) to switch between modes - match evt.kind { DialEventKind::Ignored => {} + + DialEventKind::Connect => { + eprintln!("Dial Connected"); + mode.on_start(haptics)? + } + DialEventKind::Disconnect => { + eprintln!("Dial Disconnected"); + mode.on_end(haptics)? + } + 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) { diff --git a/src/dial_device.rs b/src/dial_device.rs deleted file mode 100644 index 8f5190b..0000000 --- a/src/dial_device.rs +++ /dev/null @@ -1,226 +0,0 @@ -use std::fs; -use std::sync::mpsc; -use std::time::Duration; - -use evdev_rs::{Device, InputEvent, ReadStatus}; -use hidapi::{HidApi, HidDevice}; - -use crate::error::{Error, Result}; - -pub struct DialDevice { - long_press_timeout: Duration, - haptics: DialHaptics, - events: mpsc::Receiver>, - - possible_long_press: bool, -} - -#[derive(Debug)] -pub struct DialEvent { - pub time: Duration, - pub kind: DialEventKind, -} - -#[derive(Debug)] -pub enum DialEventKind { - Ignored, - ButtonPress, - ButtonRelease, - Dial(i32), - ButtonLongPress, -} - -impl DialDevice { - pub fn new(long_press_timeout: Duration) -> Result { - let mut control = None; - let mut axis = None; - - // discover the evdev devices - for e in fs::read_dir("/dev/input/").map_err(Error::OpenDevInputDir)? { - let e = e.map_err(Error::OpenDevInputDir)?; - if !e.file_name().to_str().unwrap().starts_with("event") { - continue; - } - - let file = - fs::File::open(e.path()).map_err(|err| Error::OpenEventFile(e.path(), err))?; - 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; - } - } - - // 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 - // (once we figure out what control events actually do...) - - // inb4 "y not async/await" - std::thread::spawn({ - let events = events_tx; - move || loop { - let _ = events.send(axis.next_event(evdev_rs::ReadFlag::NORMAL)); - } - }); - - Ok(DialDevice { - long_press_timeout, - events: events_rx, - haptics: DialHaptics::new()?, - - possible_long_press: false, - }) - } - - 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 event = match evt { - Ok(Ok((_event_status, event))) => { - // assert!(matches!(axis_status, ReadStatus::Success)); - let event = - DialEvent::from_raw_evt(event.clone()).ok_or(Error::UnexpectedEvt(event))?; - match event.kind { - DialEventKind::ButtonPress => self.possible_long_press = true, - DialEventKind::ButtonRelease => self.possible_long_press = false, - _ => {} - } - event - } - Ok(Err(e)) => return Err(Error::Evdev(e)), - 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) - } - - pub fn haptics(&self) -> &DialHaptics { - &self.haptics - } -} - -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) - } -} - -pub struct DialHaptics { - hid_device: HidDevice, -} - -impl DialHaptics { - fn new() -> Result { - let api = HidApi::new().map_err(Error::HidError)?; - let hid_device = api.open(0x045e, 0x091b).map_err(|_| Error::MissingDial)?; - - // let mut buf = [0; 256]; - - // buf[0] = 1; - // let len = device - // .get_feature_report(&mut buf) - // .map_err(Error::HidError)?; - // eprintln!("1: {:02x?}", &buf[..len]); - - // buf[0] = 2; - // let len = device - // .get_feature_report(&mut buf) - // .map_err(Error::HidError)?; - // eprintln!("2: {:02x?}", &buf[..len]); - - Ok(DialHaptics { hid_device }) - } - - /// `steps` should be a value between 0 and 3600, which corresponds to the - /// number of subdivisions the dial should use. If left unspecified, this - /// defaults to 36 (an arbitrary choice that "feels good" most of the time) - pub fn set_mode(&self, haptics: bool, steps: Option) -> Result<()> { - let steps = steps.unwrap_or(36); - assert!(steps <= 3600); - - let steps_lo = steps & 0xff; - let steps_hi = (steps >> 8) & 0xff; - - let mut buf = [0; 8]; - buf[0] = 1; - buf[1] = steps_lo as u8; // steps - buf[2] = steps_hi as u8; // steps - buf[3] = 0x00; // Repeat Count - buf[4] = if haptics { 0x03 } else { 0x02 }; // auto trigger - buf[5] = 0x00; // Waveform Cutoff Time - buf[6] = 0x00; // retrigger period - buf[7] = 0x00; // retrigger period - self.hid_device - .send_feature_report(&buf[..8]) - .map_err(Error::HidError)?; - - Ok(()) - } - - pub fn buzz(&self, repeat: u8) -> Result<()> { - let mut buf = [0; 5]; - buf[0] = 0x01; // Report ID - buf[1] = repeat; // RepeatCount - buf[2] = 0x03; // ManualTrigger - buf[3] = 0x00; // RetriggerPeriod (lo) - buf[4] = 0x00; // RetriggerPeriod (hi) - self.hid_device.write(&buf).map_err(Error::HidError)?; - Ok(()) - } -} diff --git a/src/dial_device/events.rs b/src/dial_device/events.rs new file mode 100644 index 0000000..9a74d6d --- /dev/null +++ b/src/dial_device/events.rs @@ -0,0 +1,154 @@ +use std::fs; +use std::sync::mpsc; +use std::time::Duration; + +use evdev_rs::{InputEvent, ReadStatus}; +use std::os::unix::io::AsRawFd; + +use super::DialHapticsWorkerMsg; + +pub enum RawInputEvent { + Event(ReadStatus, InputEvent), + Connect, + Disconnect, +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum DialInputKind { + Control, + MultiAxis, +} + +pub struct EventsWorker { + events: mpsc::Sender, + haptics_msg: mpsc::Sender, + input_kind: DialInputKind, +} + +impl EventsWorker { + pub(super) fn new( + input_kind: DialInputKind, + events: mpsc::Sender, + haptics_msg: mpsc::Sender, + ) -> EventsWorker { + EventsWorker { + input_kind, + events, + haptics_msg, + } + } + + fn udev_to_evdev(&self, device: &udev::Device) -> std::io::Result> { + let devnode = match device.devnode() { + Some(path) => path, + None => return Ok(None), + }; + + // we care about the `/dev/input/eventXX` device, which is a child of the + // actual input device (that has a nice name we can match against) + match device.parent() { + None => return Ok(None), + Some(parent) => { + let name = parent + .property_value("NAME") + .unwrap_or_else(|| std::ffi::OsStr::new("")) + .to_string_lossy(); + + match (self.input_kind, name.as_ref()) { + (DialInputKind::Control, r#""Surface Dial System Control""#) => {} + (DialInputKind::MultiAxis, r#""Surface Dial System Multi Axis""#) => {} + _ => return Ok(None), + } + } + } + + let file = fs::File::open(devnode)?; + evdev_rs::Device::new_from_fd(file).map(Some) + } + + fn event_loop(&mut self, device: evdev_rs::Device) -> std::io::Result<()> { + // HACK: don't want to double-send these events + if self.input_kind != DialInputKind::Control { + self.haptics_msg + .send(DialHapticsWorkerMsg::DialConnected) + .unwrap(); + self.events.send(RawInputEvent::Connect).unwrap(); + } + + loop { + let _ = self + .events + .send(match device.next_event(evdev_rs::ReadFlag::NORMAL) { + Ok((read_status, event)) => RawInputEvent::Event(read_status, event), + // this error corresponds to the device disconnecting, which is fine + Err(e) if e.raw_os_error() == Some(19) => break, + Err(e) => return Err(e), + }); + } + + // HACK: don't want to double-send these events + if self.input_kind != DialInputKind::Control { + self.haptics_msg + .send(DialHapticsWorkerMsg::DialDisconnected) + .unwrap(); + self.events.send(RawInputEvent::Disconnect).unwrap(); + } + + Ok(()) + } + + pub fn run(&mut self) -> std::io::Result<()> { + // eagerly check if the device already exists + + let mut enumerator = { + let mut e = udev::Enumerator::new()?; + e.match_subsystem("input")?; + e + }; + for device in enumerator.scan_devices()? { + let dev = match self.udev_to_evdev(&device)? { + None => continue, + Some(dev) => dev, + }; + + self.event_loop(dev)?; + } + + // enter udev event loop to gracefully handle disconnect/reconnect + + let mut socket = udev::MonitorBuilder::new()? + .match_subsystem("input")? + .listen()?; + + loop { + nix::poll::ppoll( + &mut [nix::poll::PollFd::new( + socket.as_raw_fd(), + nix::poll::PollFlags::POLLIN, + )], + None, + nix::sys::signal::SigSet::empty(), + ) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + + let event = match socket.next() { + Some(evt) => evt, + None => { + std::thread::sleep(Duration::from_millis(10)); + continue; + } + }; + + if !matches!(event.event_type(), udev::EventType::Add) { + continue; + } + + let dev = match self.udev_to_evdev(&event.device())? { + None => continue, + Some(dev) => dev, + }; + + self.event_loop(dev)?; + } + } +} diff --git a/src/dial_device/haptics.rs b/src/dial_device/haptics.rs new file mode 100644 index 0000000..079cbc0 --- /dev/null +++ b/src/dial_device/haptics.rs @@ -0,0 +1,123 @@ +use std::sync::mpsc; + +use hidapi::{HidApi, HidDevice}; + +use crate::error::{Error, Result}; + +/// Proxy object - forwards requests to the DialHapticsWorker task +pub struct DialHaptics { + msg: mpsc::Sender, +} + +impl DialHaptics { + pub(super) fn new(msg: mpsc::Sender) -> Result { + Ok(DialHaptics { msg }) + } + + /// `steps` should be a value between 0 and 3600, which corresponds to the + /// number of subdivisions the dial should use. If left unspecified, this + /// defaults to 36 (an arbitrary choice that "feels good" most of the time) + pub fn set_mode(&self, haptics: bool, steps: Option) -> Result<()> { + let _ = (self.msg).send(DialHapticsWorkerMsg::SetMode { haptics, steps }); + Ok(()) + } + + pub fn buzz(&self, repeat: u8) -> Result<()> { + let _ = (self.msg).send(DialHapticsWorkerMsg::Manual { repeat }); + Ok(()) + } +} + +#[derive(Debug)] +pub(super) enum DialHapticsWorkerMsg { + DialConnected, + DialDisconnected, + SetMode { haptics: bool, steps: Option }, + Manual { repeat: u8 }, +} + +pub(super) struct DialHapticsWorker { + msg: mpsc::Receiver, +} + +impl DialHapticsWorker { + pub(super) fn new(msg: mpsc::Receiver) -> Result { + Ok(DialHapticsWorker { msg }) + } + + pub(super) fn run(&mut self) -> Result<()> { + loop { + eprintln!("haptics worker is waiting..."); + + loop { + match self.msg.recv().unwrap() { + DialHapticsWorkerMsg::DialConnected => break, + other => eprintln!("haptics worker dropped an event: {:?}", other), + } + } + + eprintln!("haptics worker is ready"); + + let api = HidApi::new().map_err(Error::HidError)?; + let hid_device = api.open(0x045e, 0x091b).map_err(|_| Error::MissingDial)?; + let wrapper = DialHidWrapper { hid_device }; + + loop { + match self.msg.recv().unwrap() { + DialHapticsWorkerMsg::DialConnected => { + eprintln!("Unexpected haptics worker ready event."); + // should be fine though? + } + DialHapticsWorkerMsg::DialDisconnected => break, + DialHapticsWorkerMsg::SetMode { haptics, steps } => { + wrapper.set_mode(haptics, steps)? + } + DialHapticsWorkerMsg::Manual { repeat } => wrapper.buzz(repeat)?, + } + } + } + } +} + +struct DialHidWrapper { + hid_device: HidDevice, +} + +impl DialHidWrapper { + /// `steps` should be a value between 0 and 3600, which corresponds to the + /// number of subdivisions the dial should use. If left unspecified, this + /// defaults to 36 (an arbitrary choice that "feels good" most of the time) + fn set_mode(&self, haptics: bool, steps: Option) -> Result<()> { + let steps = steps.unwrap_or(36); + assert!(steps <= 3600); + + let steps_lo = steps & 0xff; + let steps_hi = (steps >> 8) & 0xff; + + let mut buf = [0; 8]; + buf[0] = 1; + buf[1] = steps_lo as u8; // steps + buf[2] = steps_hi as u8; // steps + buf[3] = 0x00; // Repeat Count + buf[4] = if haptics { 0x03 } else { 0x02 }; // auto trigger + buf[5] = 0x00; // Waveform Cutoff Time + buf[6] = 0x00; // retrigger period + buf[7] = 0x00; // retrigger period + self.hid_device + .send_feature_report(&buf[..8]) + .map_err(Error::HidError)?; + + Ok(()) + } + + fn buzz(&self, repeat: u8) -> Result<()> { + let mut buf = [0; 5]; + buf[0] = 0x01; // Report ID + buf[1] = repeat; // RepeatCount + buf[2] = 0x03; // ManualTrigger + buf[3] = 0x00; // RetriggerPeriod (lo) + buf[4] = 0x00; // RetriggerPeriod (hi) + self.hid_device.write(&buf).map_err(Error::HidError)?; + Ok(()) + } +} diff --git a/src/dial_device/mod.rs b/src/dial_device/mod.rs new file mode 100644 index 0000000..21a46da --- /dev/null +++ b/src/dial_device/mod.rs @@ -0,0 +1,170 @@ +use std::sync::mpsc; +use std::time::Duration; + +use crate::error::{Error, Result}; + +mod events; +mod haptics; + +use haptics::{DialHapticsWorker, DialHapticsWorkerMsg}; + +pub use haptics::DialHaptics; + +/// Encapsulates all the the nitty-gritty (and pretty gnarly) device handling +/// code, exposing a simple interface to wait for incoming [`DialEvent`]s. +pub struct DialDevice { + // configurable constants + long_press_timeout: Duration, + + // handles + haptics: DialHaptics, + events: mpsc::Receiver, + + // mutable state + possible_long_press: bool, +} + +#[derive(Debug)] +pub struct DialEvent { + pub time: Duration, + pub kind: DialEventKind, +} + +#[derive(Debug)] +pub enum DialEventKind { + Connect, + Disconnect, + + Ignored, + ButtonPress, + ButtonRelease, + Dial(i32), + + /// NOTE: this is a synthetic event, and is _not_ directly provided by the + /// dial itself. + ButtonLongPress, +} + +impl DialDevice { + pub fn new(long_press_timeout: Duration) -> Result { + let (events_tx, events_rx) = mpsc::channel(); + let (haptics_msg_tx, haptics_msg_rx) = mpsc::channel(); + + // TODO: interleave control events with regular events + // (once we figure out what control events actually do...) + + std::thread::spawn({ + let haptics_msg_tx = haptics_msg_tx.clone(); + let mut worker = events::EventsWorker::new( + events::DialInputKind::MultiAxis, + events_tx, + haptics_msg_tx, + ); + move || { + worker.run().unwrap(); + eprintln!("the events worker died!"); + } + }); + + std::thread::spawn({ + let mut worker = DialHapticsWorker::new(haptics_msg_rx)?; + move || { + worker.run().unwrap(); + eprintln!("the haptics worker died!"); + } + }); + + Ok(DialDevice { + long_press_timeout, + events: events_rx, + haptics: DialHaptics::new(haptics_msg_tx)?, + + possible_long_press: false, + }) + } + + /// Blocks until a new dial event comes occurs. + // TODO?: rewrite code using async/await? + // TODO?: "cheat" by exposing an async interface to the current next_event impl + 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 event = match evt { + Ok(events::RawInputEvent::Event(_event_status, event)) => { + // assert!(matches!(axis_status, ReadStatus::Success)); + let event = + DialEvent::from_raw_evt(event.clone()).ok_or(Error::UnexpectedEvt(event))?; + + match event.kind { + DialEventKind::ButtonPress => self.possible_long_press = true, + DialEventKind::ButtonRelease => self.possible_long_press = false, + _ => {} + } + + event + } + Ok(events::RawInputEvent::Connect) => { + DialEvent { + time: Duration::from_secs(0), // this could be improved... + kind: DialEventKind::Connect, + } + } + Ok(events::RawInputEvent::Disconnect) => { + DialEvent { + time: Duration::from_secs(0), // this could be improved... + kind: DialEventKind::Disconnect, + } + } + 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) + } + + pub fn haptics(&self) -> &DialHaptics { + &self.haptics + } +} + +impl DialEvent { + fn from_raw_evt(evt: evdev_rs::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/main.rs b/src/main.rs index 850d4fc..c89ae08 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +#![deny(unsafe_code)] #![allow(clippy::collapsible_if, clippy::new_without_default)] pub mod common; @@ -37,16 +38,6 @@ fn main() { } }); - let active_notification = Notification::new() - .hint(Hint::Resident(true)) - .hint(Hint::Category("device".into())) - .timeout(Timeout::Never) - .summary("Surface Dial") - .body("Active!") - .icon("media-optical") // it should be vaguely circular :P - .show() - .expect("Failed to send notification. NOTE: this daemon (probably) can't run as root!"); - let (silent, msg, icon) = match terminate_rx.recv() { Ok(Ok(())) => (true, "".into(), ""), Ok(Err(e)) => { @@ -64,8 +55,6 @@ fn main() { } }; - active_notification.close(); - if !silent { Notification::new() .hint(Hint::Transient(true)) @@ -88,7 +77,6 @@ fn controller_main() -> Result<()> { let cfg = config::Config::from_disk()?; let dial = DialDevice::new(std::time::Duration::from_millis(750))?; - println!("Found the dial"); let mut controller = DialController::new( dial,