From b97976bc09b0d04ac477754ae421a61fdb91bc68 Mon Sep 17 00:00:00 2001 From: pimpest <82343504+pimpest@users.noreply.github.com> Date: Sat, 2 May 2026 22:10:21 +0200 Subject: [PATCH] Initial POC --- .gitignore | 1 + Cargo.lock | 214 +++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 8 ++ src/main.rs | 105 ++++++++++++++++++++ src/virtual_gamepad.rs | 147 ++++++++++++++++++++++++++++ 5 files changed, 475 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/main.rs create mode 100644 src/virtual_gamepad.rs 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..e5b90c8 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,214 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "evdev" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b686663ba7f08d92880ff6ba22170f1df4e83629341cba34cf82cd65ebea99" +dependencies = [ + "bitvec", + "cfg-if", + "futures-core", + "libc", + "nix", + "tokio", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "keyboard_controller" +version = "0.1.0" +dependencies = [ + "evdev", + "tokio", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tokio" +version = "1.52.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1f6b11b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "keyboard_controller" +version = "0.1.0" +edition = "2024" + +[dependencies] +evdev = { version = "0.13.2", features = ["stream-trait"] } +tokio = { version = "1.52.1", features = ["macros", "rt", "sync", "time", "tokio-macros"] } diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..b36f936 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,105 @@ +mod virtual_gamepad; + +use std::path::PathBuf; + +use evdev::{Device, EventType, KeyCode}; +use tokio::sync::mpsc::{Receiver, Sender, channel}; +use virtual_gamepad::VirtualGamepad; + +async fn watch_keyboard(path: PathBuf, target: KeyCode, tx: Sender) { + let device = Device::open(path.clone()).unwrap(); + let Ok(mut stream) = device.into_event_stream() else { + return; + }; + + while let Ok(event) = stream.next_event().await { + if event.event_type() == EventType::KEY + && event.code() == target.code() + && event.value() == 0 + { + let _ = tx.send(path).await; + return; + } + } +} + +fn enumerate_keyboards() -> Vec { + evdev::enumerate() + .filter_map(|(path, device)| { + if device.supported_keys()?.contains(KeyCode::KEY_SPACE) { + Some(path) + } else { + None + } + }) + .collect() +} + +async fn find_target_keyboard(device_paths: &Vec) -> Option{ + + let (tx, mut rx): (Sender, Receiver) = channel(1); + + let mut handles = vec![]; + + for path in device_paths { + handles.push(tokio::spawn(watch_keyboard( + path.clone(), + KeyCode::KEY_SPACE, + tx.clone(), + ))); + } + + drop(tx); + + let path = rx.recv().await?; + + for handle in handles { + handle.abort(); + } + + Device::open(path).ok() +} + +#[tokio::main(flavor = "local")] +async fn main() { + + + let devices_paths = enumerate_keyboards(); + + println!("Found the following devices"); + for path in &devices_paths { + if let Ok(d) = Device::open(path){ + println!("\t{} ({})", d.name().unwrap_or("null"), path.display()) + } + } + + + + let Some(kb) = find_target_keyboard(&devices_paths).await else { + return; + }; + + + let Ok(mut kb) = kb.into_event_stream() else { + return; + }; + + let mut gamepad = VirtualGamepad::new("Virtual Gamepad 1"); + + if !kb.device_mut().grab().is_ok() { + println!("Failed to grab device"); + return; + } + + println!("Device is grabbed"); + + gamepad.handle_events(&mut kb).await; + + match kb.device_mut().ungrab() { + Err(err) => println!("Failed to release device: {err}"), + _ => () + }; + + println!("Device released"); + +} diff --git a/src/virtual_gamepad.rs b/src/virtual_gamepad.rs new file mode 100644 index 0000000..bd72af1 --- /dev/null +++ b/src/virtual_gamepad.rs @@ -0,0 +1,147 @@ +use evdev::uinput::VirtualDevice; +use evdev::{ + AbsInfo, AbsoluteAxisCode, AttributeSet, EventStream, EventType, InputEvent, KeyCode, UinputAbsSetup +}; + +use std::collections::HashMap; + +#[derive(PartialEq, Debug)] +#[derive(Clone, Copy)] +#[repr(u8)] +pub enum Direction { + Up = 0, + Down, + Left, + Right, +} + +#[derive(Debug)] +pub struct VirtualGamepad { + _name: String, + ui: VirtualDevice, + + btn_bindings: HashMap, + + dpad_bindings: HashMap, + + dpad_state: [i8; 4], +} + +impl VirtualGamepad { + pub fn new(name: &str) -> VirtualGamepad { + let mut keys = AttributeSet::::new(); + keys.insert(KeyCode::BTN_SOUTH); + keys.insert(KeyCode::BTN_EAST); + keys.insert(KeyCode::BTN_NORTH); + keys.insert(KeyCode::BTN_WEST); + + keys.insert(KeyCode::BTN_TL); + keys.insert(KeyCode::BTN_TR); + keys.insert(KeyCode::BTN_TL2); + keys.insert(KeyCode::BTN_TR2); + + keys.insert(KeyCode::BTN_START); + keys.insert(KeyCode::BTN_SELECT); + keys.insert(KeyCode::BTN_MODE); + + keys.insert(KeyCode::BTN_THUMBL); + keys.insert(KeyCode::BTN_THUMBR); + + let mut btn_bindings = HashMap::new(); + + btn_bindings.insert(KeyCode::KEY_J.code(), KeyCode::BTN_SOUTH); + btn_bindings.insert(KeyCode::KEY_I.code(), KeyCode::BTN_EAST); + btn_bindings.insert(KeyCode::KEY_O.code(), KeyCode::BTN_NORTH); + btn_bindings.insert(KeyCode::KEY_L.code(), KeyCode::BTN_WEST); + btn_bindings.insert(KeyCode::KEY_M.code(), KeyCode::BTN_TR); + btn_bindings.insert(KeyCode::KEY_K.code(), KeyCode::BTN_THUMBL); + btn_bindings.insert(KeyCode::KEY_SEMICOLON.code(), KeyCode::BTN_TL); + btn_bindings.insert(KeyCode::KEY_ENTER.code(), KeyCode::BTN_START); + btn_bindings.insert(KeyCode::KEY_ESC.code(), KeyCode::BTN_SELECT); + + let mut dpad_bindings = HashMap::new(); + + dpad_bindings.insert(KeyCode::KEY_SPACE.code(), Direction::Up); + dpad_bindings.insert(KeyCode::KEY_E.code(), Direction::Down); + dpad_bindings.insert(KeyCode::KEY_F.code(), Direction::Right); + dpad_bindings.insert(KeyCode::KEY_W.code(), Direction::Left); + + + + let ui = VirtualDevice::builder() + .unwrap() + .name(&name) + .with_keys(&keys) + .unwrap() + .with_absolute_axis(&UinputAbsSetup::new( + AbsoluteAxisCode::ABS_HAT0X, + AbsInfo::new(0, -1, 1, 0, 0, 0), + )) + .unwrap() + .with_absolute_axis(&UinputAbsSetup::new( + AbsoluteAxisCode::ABS_HAT0Y, + AbsInfo::new(0, -1, 1, 0, 0, 0), + )) + .unwrap() + .build() + .unwrap(); + + + VirtualGamepad { + ui, + btn_bindings, + dpad_bindings, + _name: name.to_owned(), + dpad_state: [0, 0, 0, 0], + } + } + + pub fn press_btn(&mut self, btn: KeyCode, press: bool) { + let _ = self + .ui + .emit(&[InputEvent::new(EventType::KEY.0, btn.code(), press as i32)]); + } + + pub fn press_dpad(&mut self, btn: Direction, press: bool) { + match btn { + Direction::Up => self.dpad_state[0] = press as i8, + Direction::Down => self.dpad_state[1] = press as i8, + Direction::Left => self.dpad_state[2] = press as i8, + Direction::Right => self.dpad_state[3] = press as i8, + }; + + if [Direction::Up, Direction::Down].contains(&btn) { + let _ = self.ui.emit(&[InputEvent::new( + EventType::ABSOLUTE.0, + AbsoluteAxisCode::ABS_HAT0Y.0, + (-self.dpad_state[0] + self.dpad_state[1]) as i32, + )]); + } else { + let _ = self.ui.emit(&[InputEvent::new( + EventType::ABSOLUTE.0, + AbsoluteAxisCode::ABS_HAT0X.0, + (-self.dpad_state[2] + self.dpad_state[3]) as i32, + )]); + } + } + + pub async fn handle_events(&mut self, es: &mut EventStream) { + while let Ok(event) = es.next_event().await { + if event.event_type() != EventType::KEY { + continue; + } + + if let Some(btn) = self.btn_bindings.get(&event.code()) { + self.press_btn(btn.clone(), event.value() > 0); + } + + if let Some(dir) = self.dpad_bindings.get(&event.code()) { + self.press_dpad(dir.clone(), event.value() > 0); + } + + if event.code() == KeyCode::KEY_END.code() { + return; + } + } + } +}