diff --git a/40-streamdeck.rules b/40-streamdeck.rules index 7b94676..3b057fe 100644 --- a/40-streamdeck.rules +++ b/40-streamdeck.rules @@ -3,3 +3,4 @@ SUBSYSTEM=="usb", ATTR{idVendor}=="0fd9", ATTR{idProduct}=="0063", MODE="0660", SUBSYSTEM=="usb", ATTR{idVendor}=="0fd9", ATTR{idProduct}=="006c", MODE="0660", GROUP="plugdev" SUBSYSTEM=="usb", ATTR{idVendor}=="0fd9", ATTR{idProduct}=="006d", MODE="0660", GROUP="plugdev" SUBSYSTEM=="usb", ATTR{idVendor}=="0fd9", ATTR{idProduct}=="0090", MODE="0660", GROUP="plugdev" +SUBSYSTEM=="usb", ATTR{idVendor}=="0fd9", ATTR{idProduct}=="0084", MODE="0660", GROUP="plugdev" \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 55bf6c8..49e0f70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ edition = "2018" [features] util = [ "structopt", "simplelog", "humantime" ] +input-manager = [] default = [ "util" ] [dependencies] diff --git a/README.md b/README.md index c5cd394..b2f6ce7 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,11 @@ Features: - [x] Stream Deck Original (untested) - [x] Stream Deck Original V2 - [x] Stream Deck XL + - [x] Stream Deck Plus (dials and touchscreen require the `input-manager` feature; setting the touchscreen image is not yet supported) - [x] Stream Deck Module 6Keys - [x] Stream Deck Module 15Keys - [x] Stream Deck Module 32Keys (untested) - +- [x] Optional `input-manager` as a higher level wrapper for translating raw HID reports into high-level input events. Needs to be enabled using the `input-manager` feature flag. ## Getting started @@ -81,6 +82,56 @@ SUBCOMMANDS: ``` + +### Using the input manager + +The `InputManager` provides a stateful, high-level interface for reading input from Stream Deck devices. Instead of interpreting raw HID reports yourself, it tracks button, dial, and touchscreen state and emits discrete `InputEvent` values with press/release semantics. + +Enable it via the `input-manager` feature (on by default): + +```toml +streamdeck = { version = "0.10", features = ["input-manager"] } +``` + +The `InputManager` takes ownership of the `StreamDeck` instance: + +```rust +use streamdeck::{StreamDeck, InputManager}; + +let vendor_id = 0x0fd9; // Elgato +let product_id = 0x0084; // Stream Deck Plus + +let deck = StreamDeck::connect(vendor_id, product_id, None)?; +let mut manager = InputManager::new(deck); + +loop { + let events = manager.handle_input(None)?; + for event in events { + match event { + InputEvent::Button { index, action } => { + println!("Button {index}: {action:?}"); + } + InputEvent::Dial { index, action } => { + // Stream Deck Plus only + println!("Dial {index}: {action:?}"); + } + InputEvent::Touch { x, y, action } => { + // Stream Deck Plus only + println!("Touch at ({x}, {y}): {action:?}"); + } + } + } +} +``` + +`handle_input` accepts an optional `Duration` timeout. It returns a `Vec` which can contain any combination of: + +- **`InputEvent::Button`** — a button was pressed or released (all devices) +- **`InputEvent::Dial`** — a dial was pressed, released, or turned with a signed delta (Stream Deck Plus only) +- **`InputEvent::Touch`** — a short tap, long press, or drag on the touchscreen (Stream Deck Plus only) + +The manager internally tracks which buttons and dials are currently held down, so each physical press and release produces exactly one event. + ## Related Works This library stands on the shoulders of giants (who had already done all the reversing work)... diff --git a/src/images.rs b/src/images.rs index 2316ea8..bce663c 100644 --- a/src/images.rs +++ b/src/images.rs @@ -1,9 +1,8 @@ use std::str::FromStr; use image::codecs::jpeg::JpegEncoder; -use image::ImageReader; use image::{imageops::FilterType, Pixel, Rgba}; -use image::{DynamicImage, ExtendedColorType}; +use image::{DynamicImage, ExtendedColorType, ImageReader}; use crate::info::{ColourOrder, Mirroring, Rotation}; use crate::{rgb_to_bgr, Error}; diff --git a/src/info.rs b/src/info.rs index c90c8fa..828dd4f 100644 --- a/src/info.rs +++ b/src/info.rs @@ -13,6 +13,7 @@ pub enum Kind { Module32Keys, } + /// Stream Deck key layout direction #[derive(Debug, Copy, Clone, PartialEq)] pub enum KeyDirection { @@ -52,7 +53,23 @@ pub enum Mirroring { Both, } +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg(feature = "input-manager")] +pub(crate) struct TouchDataIndices { + pub event_type_index: usize, + pub x_low: usize, + pub x_high: usize, + pub y_low: usize, + pub y_high: usize, + pub drag_x_low: usize, + pub drag_x_high: usize, + pub drag_y: usize, +} + impl Kind { + ///Different types of dial events that can be generated by streamdecks with dials + pub fn keys(&self) -> u8 { match self { Kind::Original | Kind::OriginalV2 | Kind::Mk2 => 15, @@ -102,6 +119,30 @@ impl Kind { } } + #[cfg(feature = "input-manager")] + pub(crate) fn dials(&self) -> u8 { + match self { + Kind::Plus => 4, + _ => 0, + } + } + + #[cfg(feature = "input-manager")] + pub(crate) fn dial_data_offset(&self) -> usize { + match self { + Kind::Plus => 5, + _ => 0, + } + } + + #[cfg(feature = "input-manager")] + pub(crate) fn dial_press_flag_index(&self) -> usize { + match self { + Kind::Plus => 4, + _ => 0, + } + } + pub fn image_mode(&self) -> ImageMode { match self { Kind::Original | Kind::Mini | Kind::RevisedMini | Kind::Module6Keys => ImageMode::Bmp, @@ -180,6 +221,24 @@ impl Kind { } } + #[cfg(feature = "input-manager")] + pub(crate) fn touch_data_indices(&self) -> Option { + match self { + Kind::Plus => Some(TouchDataIndices { + event_type_index: 4, + x_low: 6, + x_high: 7, + y_low: 8, + y_high: 9, //Irrelevant for SD Plus, as the touch area is only 100px high, but here for future proofing + drag_x_low: 10, + drag_x_high: 11, + drag_y: 12, + }), + _ => None, + } + } + + pub(crate) fn is_module(&self) -> bool { match self { Kind::Module6Keys | Kind::Module15Keys | Kind::Module32Keys => true, diff --git a/src/input.rs b/src/input.rs new file mode 100644 index 0000000..2dd294d --- /dev/null +++ b/src/input.rs @@ -0,0 +1,236 @@ +use std::{collections::HashSet, time::Duration, vec}; + +use crate::{KeyDirection, Kind, StreamDeck}; + + + +/// The InputEvent enum represents the different types of input events that can be generated by Streamdeck devices. +/// Most streamdeck devices only have buttons, the Streamdeck Plus also has dials and a touchscreen. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum InputEvent { + ///Button event, index is the zero-based index of the button, released is true if the button was released + Button { + index: u8, + action: ButtonAction, + }, + ///Dial event, index is the zero-based index of the dial, action is the performed DialAction + Dial { + index: u8, + action: DialAction + }, + ///Touch event, x and y are the coordinates of the touch event on the touchscreen, action is the performed TouchAction + Touch { + x: u16, + y: u16, + action: TouchAction, + }, +} + +///Different types of button events that can be generated by streamdeck devices +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum ButtonAction { + Pressed, + Released, +} + +///Different types of touch events that can be generated by streamdecks with touchscreens +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum TouchAction { + ///A short touch event + Short, + ///A long touch event + Long, + ///A drag event, x and y are the end coordinates of the drag + Drag { + x: u16, + y: u16, + }, +} + +///Different types of dial events that can be generated by streamdecks with dials +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum DialAction { + ///The dial was pressed + Pressed, + ///The dial was released + Released, + ///The dial was turned, the value is the delta of the turn. + ///Negative values are counter-clockwise, positive values are clockwise + Turned(i8), +} + +///Manages inputs for the Streamdeck device. Keeps track of pressed keys and dials and touchscreens and generates InputEvents +pub struct InputManager { + deck: StreamDeck, + pressed_keys: HashSet, + pressed_dials: HashSet, +} + +impl InputManager { + pub fn new(deck: StreamDeck) -> Self { + InputManager { + deck, + pressed_keys: HashSet::new(), + pressed_dials: HashSet::new(), + } + } + + ///Handles input events for the Streamdeck device and returns a Vec of InputEvents + pub fn handle_input( + &mut self, + timeout: Option + ) -> Result, crate::Error> { + let cmd = self.deck.read_input(timeout)?; + let kind = self.deck.kind; + //SD Plus has Dials and Touchscreen, other models only have buttons + if kind == Kind::Plus { + return Ok(match cmd[1] { + 0 => self.handle_button_event(&cmd, &kind)?, + 2 => self.handle_touchscreen_event(&cmd, &kind)?, + 3 => self.handle_dial_event(&cmd, &kind), + _ => return Err(crate::Error::UnsupportedInput), + }); + } + + Ok(self.handle_button_event(&cmd, &kind)?) + } + + ///Handles touchscreen events (short touch, long touch, drag) and returns a Vec of InputEvents + fn handle_touchscreen_event(&self, cmd: &[u8; 36], kind: &Kind) -> Result, crate::Error> { + let indices = kind.touch_data_indices(); + + if indices.is_none() { + return Err(crate::Error::UnsupportedInput); + } + let indices = indices.unwrap(); + /* + * Indices are hardcoded for now, as the SD+ is the only one with a touchscreen. + * TODO: create a new fn in Kind struct to return the relevant indices for the current device + * if more Streamdeck models with touchscreens are released. + */ + let action = match cmd[indices.event_type_index] { + 1 => TouchAction::Short, + 2 => TouchAction::Long, + 3 => TouchAction::Drag{ + x: ((cmd[indices.drag_x_high] as u16) << 8) + cmd[indices.drag_x_low] as u16, + y: cmd[indices.drag_y] as u16, + }, + _ => return Err(crate::Error::UnsupportedInput) + }; + + Ok(vec![InputEvent::Touch { + action, + x: ((cmd[indices.x_high] as u16) << 8) + cmd[indices.x_low] as u16, + y: ((cmd[indices.y_high] as u16) << 8) + cmd[indices.y_low] as u16, + }]) + } + + ///Handles dial events (press, release, turn) and returns a Vec of InputEvents + fn handle_dial_event(&mut self, cmd: &[u8; 36], kind: &Kind) -> Vec { + let offset = kind.dial_data_offset(); + let dials = kind.dials() as usize; + let press = cmd[kind.dial_press_flag_index()] == 0; + let mut events = Vec::new(); + + if !press { + for i in offset..offset + dials { + if cmd[i] == 0 { + continue; + } + let delta: i8; + if cmd[(i) as usize] > 127 { + //convert to i8 and invert. subtract 1 to make it 0-based + delta = -((255 - cmd[(i) as usize]) as i8) -1; + } + else { + delta = cmd[(i) as usize] as i8; + } + events.push(InputEvent::Dial { + index: (i - offset) as u8, + action: DialAction::Turned(delta), + }); + } + return events; + } + + let mut fresh_presses = HashSet::new(); + for i in offset..offset + dials { + if cmd[i] == 1 { + let dial = i - offset; + if self.pressed_dials.contains(&dial) { + continue; + } + fresh_presses.insert(dial); + events.push(InputEvent::Dial { + index: dial as u8, + action: DialAction::Pressed, + }); + } + } + + self.pressed_dials.retain(|dial| { + if cmd[(offset + *dial) as usize] == 0 && !fresh_presses.contains(dial) { + events.push(InputEvent::Dial { + index: *dial as u8, + action: DialAction::Released, + }); + return false; + } + true + }); + + self.pressed_dials.extend(fresh_presses); + events + } + + ///Handles button events (press, release) and returns a Vec of InputEvents + fn handle_button_event(&mut self, cmd: &[u8; 36], kind: &Kind) -> Result, crate::Error> { + let mut fresh_presses = HashSet::new(); + let mut events = Vec::new(); + let keys = kind.keys() as usize; + let offset = kind.key_data_offset(); + + for i in 1 + offset..offset + keys + 1 { + if cmd[i] == 0 { + continue; + } + + let button = match self.deck.kind.key_direction() { + KeyDirection::RightToLeft => self.deck.translate_key_index((i - offset) as u8)? - 1, + KeyDirection::LeftToRight => (i - offset - 1) as u8, + }; + + // If the button was already reported as pressed, skip it + if self.pressed_keys.contains(&button) { + continue; + } + + // If the button press is fresh, add it to the fresh_presses HashSet and the events Vec + fresh_presses.insert(button); + events.push(InputEvent::Button { + index: button, + action: ButtonAction::Pressed, + }); + } + + // Remove released buttons from the pressed_keys HashSet and add them to the events Vec as released + self.pressed_keys.retain(|button| { + if cmd[offset + 1 + *button as usize] == 0 && !fresh_presses.contains(button) { + events.push(InputEvent::Button { + index: *button, + action: ButtonAction::Released, + }); + return false; + } + true + }); + + // Add the fresh_presses HashSet to the pressed_keys HashSet + self.pressed_keys.extend(fresh_presses); + Ok(events) + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 5bad77a..a7734bd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,6 +17,12 @@ pub use crate::images::{Colour, ImageOptions}; pub mod info; pub use info::*; +pub use info::Kind; + +#[cfg(feature = "input-manager")] +pub mod input; +#[cfg(feature = "input-manager")] +pub use input::*; use imageproc::drawing::draw_text_mut; use std::str::FromStr; @@ -24,7 +30,7 @@ use thiserror::Error; /// StreamDeck object pub struct StreamDeck { - kind: Kind, + pub kind: Kind, device: HidDevice, } @@ -128,7 +134,6 @@ impl StreamDeck { pids::MK2 => Kind::Mk2, pids::REVISED_MINI => Kind::RevisedMini, pids::PLUS => Kind::Plus, - pids::MODULE_6_KEYS => Kind::Module6Keys, pids::MODULE_15_KEYS => Kind::Module15Keys, pids::MODULE_32_KEYS => Kind::Module32Keys, @@ -265,6 +270,7 @@ impl StreamDeck { Ok(()) } + /// Probe for connected devices. /// /// Returns a list of results, @@ -292,6 +298,23 @@ impl StreamDeck { Ok(available_devices) } + /// Read input from the device + /// + /// This is a raw read of the device input intended for the input managerand is not recommended for general use. + #[cfg(feature = "input-manager")] + pub fn read_input(&mut self, timeout: Option) -> Result<[u8; 36], Error> { + let mut cmd = [0u8; 36]; + + match timeout { + Some(t) => self + .device + .read_timeout(&mut cmd, t.as_millis() as i32)?, + None => self.device.read(&mut cmd)?, + }; + + Ok(cmd) + } + /// Fetch button states /// /// In blocking mode this will wait until a report packet has been received @@ -313,15 +336,14 @@ impl StreamDeck { return Err(Error::NoData); } - if self.kind == Kind::Plus { - //If the second byte is not 0, a dial or the touchscreen was used, we don't support that here - //This would write to indices which represent buttons and thus create faulty output - if cmd[1] != 0 { - return Err(Error::UnsupportedInput); - } + // If the second byte on SD Plus is not 0, a dial or the touchscreen was used, we don't support that here. + // This would write to indices which represent buttons here and thus create faulty output + if self.kind == Kind::Plus && cmd[1] != 0 { + return Err(Error::UnsupportedInput); } - + let mut out = vec![0u8; keys]; + match self.kind.key_direction() { KeyDirection::RightToLeft => { for (i, val) in out.iter_mut().enumerate() { diff --git a/src/main.rs b/src/main.rs index eacd153..5a61563 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,11 @@ use structopt::StructOpt; extern crate humantime; use humantime::Duration; -use streamdeck::{StreamDeck, Filter, Colour, ImageOptions, Error}; +pub use streamdeck::{info, Colour, Error, Filter, ImageOptions, Kind, StreamDeck}; +#[cfg(feature = "input-manager")] +pub use streamdeck::{InputEvent, InputManager}; + + #[derive(StructOpt)] #[structopt(name = "streamdeck-cli", about = "A CLI for the Elgato StreamDeck")] @@ -32,8 +36,6 @@ pub enum Commands { Reset, /// Fetch the device firmware version Version, - /// Search for connected streamdecks - Probe, /// Set device display brightness SetBrightness{ /// Brightness value from 0 to 100 @@ -49,6 +51,17 @@ pub enum Commands { /// Read continuously continuous: bool, }, + #[cfg(feature = "input-manager")] + /// Fetch input events + GetInput { + #[structopt(long)] + /// Timeout for input reading + timeout: Option, + + #[structopt(long)] + /// Read continuously + continuous: bool, + }, /// Set button colours SetColour { /// Index of button to be set @@ -67,7 +80,8 @@ pub enum Commands { #[structopt(flatten)] opts: ImageOptions, - } + }, + Probe, } fn main() { @@ -94,12 +108,12 @@ fn main() { opts.filter.vid, opts.filter.pid, serial); // Run the command - if let Err(e) = do_command(&mut deck, opts.cmd) { + if let Err(e) = do_command(deck, opts.cmd) { error!("Command error: {:?}", e); } } -fn do_command(deck: &mut StreamDeck, cmd: Commands) -> Result<(), Error> { +fn do_command(mut deck: StreamDeck, cmd: Commands) -> Result<(), Error> { match cmd { Commands::Reset => { deck.reset()?; @@ -121,20 +135,30 @@ fn do_command(deck: &mut StreamDeck, cmd: Commands) -> Result<(), Error> { } } }, + #[cfg(feature = "input-manager")] + Commands::GetInput { + timeout, + continuous, + } => { + let mut manager = InputManager::new(deck); + loop { + let input = manager.handle_input(timeout.map(|t| *t))?; + info!("input: {:?}", input); + + if !continuous { + break; + } + } + }, Commands::Probe => { let results = StreamDeck::probe()?; - if results.is_empty() { - info!("No devices found"); - return Ok(()); - } - info!("Found {} devices", results.len()); - for res in results { - match res { - Ok((device, pid)) => info!("Streamdeck: {:?} (pid: {:#x})", device, pid), - Err(_) => warn!("Found Elgato device with unsupported PID"), + for result in results { + match result { + Ok(deck) => info!("Found device: {:?} (pid: {:#X})", deck.0, deck.1), + Err(e) => error!("Error probing device: {:?}", e), } } - } + }, Commands::SetColour{key, colour} => { info!("Setting key {} colour to: ({:?})", key, colour); deck.set_button_rgb(key, &colour)?;