diff --git a/Cargo.lock b/Cargo.lock index d1aa6ae0..4bdda731 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -272,7 +272,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 1.1.0", "shlex", "syn 2.0.66", ] @@ -288,6 +288,9 @@ name = "bitflags" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" +dependencies = [ + "serde", +] [[package]] name = "bitvec" @@ -324,6 +327,16 @@ dependencies = [ "piper", ] +[[package]] +name = "bstr" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a68f1f47cdf0ec8ee4b941b2eee2a80cb796db73118c0dd09ac63fbe405be22" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.16.0" @@ -546,6 +559,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "erased-serde" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24e2389d65ab4fab27dc2a5de7b191e1f6617d1f1c8855c0dc569c94a4cbb18d" +dependencies = [ + "serde", + "typeid", +] + [[package]] name = "errno" version = "0.3.9" @@ -762,6 +785,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "humantime" version = "2.1.0" @@ -847,6 +879,7 @@ dependencies = [ "inotify", "log", "mio", + "mlua", "nix 0.29.0", "packed_struct", "procfs", @@ -986,6 +1019,25 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "lua-src" +version = "547.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edaf29e3517b49b8b746701e5648ccb5785cde1c119062cbabbc5d5cd115e42" +dependencies = [ + "cc", +] + +[[package]] +name = "luajit-src" +version = "210.5.11+97813fb" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3015551c284515db7c30c559fc1080f9cb9ee990d1f6fca315451a107c7540bb" +dependencies = [ + "cc", + "which", +] + [[package]] name = "memchr" version = "2.7.2" @@ -1046,6 +1098,37 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "mlua" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae9546e4a268c309804e8bbb7526e31cbfdedca7cd60ac1b987d0b212e0d876" +dependencies = [ + "bstr", + "either", + "erased-serde", + "futures-util", + "mlua-sys", + "num-traits", + "parking_lot", + "rustc-hash 2.0.0", + "serde", + "serde-value", +] + +[[package]] +name = "mlua-sys" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efa6bf1a64f06848749b7e7727417f4ec2121599e2a10ef0a8a3888b0e9a5a0d" +dependencies = [ + "cc", + "cfg-if", + "lua-src", + "luajit-src", + "pkg-config", +] + [[package]] name = "nix" version = "0.23.2" @@ -1144,6 +1227,15 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -1281,28 +1373,29 @@ dependencies = [ [[package]] name = "procfs" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "731e0d9356b0c25f16f33b5be79b1c57b562f141ebfcdb0ad8ac2c13a24293b4" +checksum = "cc5b72d8145275d844d4b5f6d4e1eef00c8cd889edb6035c21675d1bb1f45c9f" dependencies = [ "bitflags 2.5.0", "chrono", "flate2", "hex", - "lazy_static", "procfs-core", "rustix", + "serde", ] [[package]] name = "procfs-core" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d3554923a69f4ce04c4a754260c338f505ce22642d3830e049a399fc2059a29" +checksum = "239df02d8349b06fc07398a3a1697b06418223b1c7725085e801e7c0fc6a12ec" dependencies = [ "bitflags 2.5.0", "chrono", "hex", + "serde", ] [[package]] @@ -1400,6 +1493,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152" + [[package]] name = "rustix" version = "0.38.34" @@ -1434,6 +1533,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_derive" version = "1.0.204" @@ -1738,6 +1847,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "typeid" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e13db2e0ccd5e14a544e8a246ba2312cd25223f616442d7f2cb0e3db614236e" + [[package]] name = "typenum" version = "1.17.0" @@ -1904,6 +2019,18 @@ version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" +[[package]] +name = "which" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8211e4f58a2b2805adfbefbc07bab82958fc91e3836339b1ab7ae32465dce0d7" +dependencies = [ + "either", + "home", + "rustix", + "winsafe", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2083,6 +2210,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "wyz" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 2ad83449..ecd40733 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,9 +39,16 @@ log = { version = "0.4.22", features = [ "release_max_level_debug", ] } mio = { version = "0.8.11", features = ["os-poll", "os-ext", "net"] } +mlua = { version = "0.10.1", features = [ + "lua54", + "vendored", + "async", + "send", + "serialize", +] } nix = { version = "0.29.0", features = ["fs"] } packed_struct = "0.10.1" -procfs = "0.16.0" +procfs = { version = "0.17.0", features = ["serde1"] } rand = "0.8.5" serde = { version = "1.0.204", features = ["derive"] } serde_yaml = "0.9.34" diff --git a/README.md b/README.md index 65d75eb2..7c656f5c 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,11 @@ and translate their input to a variety of virtual device formats. ### Features - [x] Combine multiple input devices +- [x] Handheld gamepad support (Steam Deck, ROG Ally, Legion Go, and more) - [x] Emulate mouse, keyboard, and gamepad inputs - [x] Intercept and route input over DBus for overlay interface control - [x] Input mapping profiles to translate source input into the desired target input +- [x] Custom input routing via Lua scripting API - [ ] Route input over the network ## Install @@ -169,6 +171,12 @@ looks at all the input devices on the system and checks to see if they match a composite device configuration. If they do, the input devices are combined into a single logical composite device. +InputPlumber looks for composite device configuration files in the following +locations: + +* `/etc/inputplumber/devices.d` +* `/usr/share/inputplumber/devices` + A composite device configuration looks like this: ```yaml @@ -207,7 +215,7 @@ target_devices: - mouse - keyboard -# The ID of a device capability mapping in the 'capability_maps' folder +# The ID of a device capability mapping in the 'capability_maps' folder (optional) capability_map_id: oxp1 ``` @@ -221,6 +229,11 @@ Capability maps are defined in a separate YAML configuration file that follows the [Capability Map Schema](./rootfs/usr/share/inputplumber/schema/capability_map_v1.json) and are referenced by their unique ID. +InputPlumber loads capabilities maps from the following locations: + +* `/etc/inputplumber/capability_maps.d` +* `/usr/share/inputplumber/capability_maps` + A capability map configuration looks like this: ```yaml @@ -241,6 +254,131 @@ mapping: button: Guide ``` +## Custom routing & scripting + +InputPlumber supports custom input routing with a Lua scripting API. Input scripts +allow you to define custom input routing logic that can run at any step of the input +pipeline for any device managed by InputPlumber, giving you the power to route and +transform input events in almost any way you can imagine. + +When InputPlumber starts managing a device, it will load input scripts from the +following locations: + +* `/etc/inputplumber/scripts.d` +* `/usr/share/inputplumber/scripts` + +Each input script should return a table with function(s) defined to run at +particular stages of the input pipeline. As input events flow from physical +source device(s) to virtual target device(s), the composite device will execute +the Lua function in each script that matches the pipeline stage. + +These are the stages of the input pipeline in order of execution: + +* `preprocess_event` - executed on each event _before_ capability mapping **and** _before_ input profile translation +* `process_event` - executed on each event _after_ capability mapping **but** _before_ input profile translation +* `postprocess_event` - executed on each event _after_ capability mapping **and** _after_ input profile translation + +``` + Source Device(s) + │ + (event) + │ + └── Composite Device + │ + (preprocess_event) + │ + (process_event) + │ + (postprocess_event) + │ + └── Target Device(s) + │ + (event) +``` + +For example, this script will print a message to the log whenever the start button is pressed +and processed during the `process_event` stage: + +```lua +-- process_event is called on every input event -after- capability mapping +-- but -before- input profile translation. +local process_event = function(event) + if event.capability == "Gamepad:Button:Start" then + print("Start button was pressed! Value:", event.value) + end + + -- Returning 'true' allows the event to be processed further by the input + -- pipeline. + return true +end + +return { + process_event = process_event, +} +``` + +### Global Variables + +Several global variables are available that can be used in your script that +expose information about the input device and methods for emitting input events. + +* `system` - contains system and cpu information +* `device` - composite device properties and methods + +### Examples + +#### Transform guide button events (XBox button, PS button, etc.) into start button events + +```lua +-- process_event is called on every input event -after- capability mapping +-- but -before- input profile translation. +local process_event = function(event) + -- If this is not a guide button event, continue processing the event like + -- normal by returning 'true'. + if event.capability ~= "Gamepad:Button:Guide" then + return true + end + + -- Events can be emitted using the 'write_event' method on the 'device' global + local new_event = { + capability = "Gamepad:Button:Start", + value = event.value, + } + device.write_event(new_event) + + -- Returning 'false' stops further processing of this event + return false +end + +return { + process_event = process_event, +} +``` + +#### Print every input event only if the device is a Sony DualSense controller + +```lua +-- If the composite device configuration is not a Sony DualSense controller, +-- return a table with 'enabled' set to false so no events are procesed by +-- this script. +if device.config.name ~= "Sony Interactive Entertainment DualSense Wireless Controller" then + return { + enabled = false, + } +end + +-- preprocess_event is called on every input event -before- capability mapping +-- and input profile translation. +local preprocess_event = function(event) + print("Event: ", event.capability, event.value) + return true +end + +return { + preprocess_event = preprocess_event, +} +``` + ## License InputPlumber is licensed under THE GNU GPLv3+. See LICENSE for details. diff --git a/rootfs/usr/share/inputplumber/scripts/test.lua b/rootfs/usr/share/inputplumber/scripts/test.lua new file mode 100644 index 00000000..71332f14 --- /dev/null +++ b/rootfs/usr/share/inputplumber/scripts/test.lua @@ -0,0 +1,106 @@ +-- Input scripts are evaluated when InputPlumber starts managing an input device +-- and should return a table which includes functions that can be called during +-- each step of the input pipeline. +-- +-- The input pipeline looks like this: +-- +-- Source Device(s) +-- │ +-- (event) +-- │ +-- └── Composite Device +-- │ +-- (preprocess_event) +-- │ +-- (process_event) +-- │ +-- (postprocess_event) +-- │ +-- └── Target Device(s) + +print("Loaded example script") + +-- Several global variables are available with system information, composite +-- device configuration, and methods for sending events. +-- +-- Globals: +-- system - contains system and cpu information +-- device - composite device properties and methods +print("--- System DMI Data ---") +for key, value in pairs(system.dmi) do + print(key, value) +end + +print("--- System CPU ---") +for key, value in pairs(system.cpu) do + print(key, value) +end + +print("--- Device Config ---") +for key, value in pairs(device.config) do + print(key, value) +end + +-- Scripts can disable themselves under certain conditions by returning a +-- table with 'enabled' set to false. +if system.dmi.product_family == "Desktop" then + return { + enabled = false, + } +end + +-- The composite device configuration can also be accessed +if device.config.name ~= "Sony Interactive Entertainment DualSense Wireless Controller" then + return { + enabled = false, + } +end + +-- preprocess_event is called on every input event -before- capability translation +-- and input profile translation. +local preprocess_event = function(event) + if event.capability == "Gamepad:Button:Guide" then + print("[preprocess] Got guide button: ", event.value) + end + + -- Returning 'true' allows the event to be processed further by the input + -- pipeline. + return true +end + +-- process_event is called on every input event -after- capability translation +-- but -before- input profile translation. +local process_event = function(event) + if event.capability == "Gamepad:Button:Guide" then + print("[process] Got guide button: ", event.value) + + -- Events can be emitted using the 'write_event' method on the 'device' global + local new_event = { + capability = "Gamepad:Button:South", + value = event.value, + } + device.write_event(new_event) + + -- Returning 'false' stops further processing of this event + return false + end + + return true +end + +-- postprocess_event is called on every input event -after- capability translation +-- and -after- input profile translation. +local postprocess_event = function(event) + if event.capability == "Gamepad:Button:Guide" then + print("[postprocess] Got guide button: ", event.value) + end + + return true +end + +return { + enabled = true, + preprocess_event = preprocess_event, + process_event = process_event, + postprocess_event = postprocess_event, +} diff --git a/src/config/mod.rs b/src/config/mod.rs index e9ad6bee..c889838e 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -5,7 +5,7 @@ use std::io; use ::procfs::CpuInfo; use glob_match::glob_match; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::{ @@ -23,7 +23,7 @@ pub enum LoadError { DeserializeError(#[from] serde_yaml::Error), } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "snake_case")] pub struct DeviceProfile { pub version: u32, //useful? @@ -49,7 +49,7 @@ impl DeviceProfile { } } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "snake_case")] pub struct ProfileMapping { pub name: String, @@ -155,7 +155,7 @@ impl ProfileMapping { } } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "snake_case")] pub struct CapabilityMap { pub version: u32, @@ -181,7 +181,7 @@ impl CapabilityMap { } } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "snake_case")] pub struct CapabilityMapping { pub name: String, @@ -189,7 +189,7 @@ pub struct CapabilityMapping { pub target_event: CapabilityConfig, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "snake_case")] pub struct CapabilityConfig { pub gamepad: Option, @@ -200,7 +200,7 @@ pub struct CapabilityConfig { pub touchscreen: Option, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "snake_case")] pub struct GamepadCapability { pub axis: Option, @@ -209,7 +209,7 @@ pub struct GamepadCapability { pub gyro: Option, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "snake_case")] pub struct AxisCapability { pub name: String, @@ -217,14 +217,14 @@ pub struct AxisCapability { pub deadzone: Option, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "snake_case")] pub struct TriggerCapability { pub name: String, pub deadzone: Option, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "snake_case")] pub struct GyroCapability { pub name: String, @@ -233,35 +233,35 @@ pub struct GyroCapability { pub axis: Option, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "snake_case")] pub struct MouseCapability { pub button: Option, pub motion: Option, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "snake_case")] pub struct MouseMotionCapability { pub direction: Option, pub speed_pps: Option, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "snake_case")] pub struct TouchpadCapability { pub name: String, pub touch: TouchCapability, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "snake_case")] pub struct TouchCapability { pub button: Option, pub motion: Option, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "snake_case")] pub struct TouchMotionCapability { pub region: Option, @@ -269,7 +269,7 @@ pub struct TouchMotionCapability { } /// Defines available options for loading a [CompositeDeviceConfig] -#[derive(Debug, Deserialize, Clone, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "snake_case")] pub struct CompositeDeviceConfigOptions { /// If true, InputPlumber will automatically try to manage the input device. @@ -279,14 +279,14 @@ pub struct CompositeDeviceConfigOptions { } /// Defines a platform match for loading a [CompositeDeviceConfig] -#[derive(Debug, Deserialize, Clone, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "snake_case")] pub struct Match { pub dmi_data: Option, } /// Match DMI data for loading a [CompositeDevice] -#[derive(Debug, Deserialize, Clone, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "snake_case")] pub struct DMIMatch { pub bios_release: Option, @@ -300,7 +300,7 @@ pub struct DMIMatch { pub cpu_vendor: Option, } -#[derive(Debug, Deserialize, Clone, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "snake_case")] pub struct SourceDevice { pub group: String, @@ -313,7 +313,7 @@ pub struct SourceDevice { pub ignore: Option, } -#[derive(Debug, Deserialize, Clone, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "snake_case")] pub struct Evdev { pub name: Option, @@ -323,7 +323,7 @@ pub struct Evdev { pub product_id: Option, } -#[derive(Debug, Deserialize, Clone, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "snake_case")] pub struct Hidraw { pub vendor_id: Option, @@ -333,7 +333,7 @@ pub struct Hidraw { pub name: Option, } -#[derive(Debug, Deserialize, Clone, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "snake_case")] pub struct Udev { pub attributes: Option>, @@ -346,14 +346,14 @@ pub struct Udev { pub sys_path: Option, } -#[derive(Debug, Deserialize, Clone, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "snake_case")] pub struct UdevAttribute { pub name: String, pub value: Option, } -#[derive(Debug, Deserialize, Clone, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "snake_case")] #[allow(clippy::upper_case_acronyms)] pub struct IIO { @@ -362,7 +362,7 @@ pub struct IIO { pub mount_matrix: Option, } -#[derive(Debug, Deserialize, Clone, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "snake_case")] #[allow(clippy::upper_case_acronyms)] pub struct MountMatrix { @@ -372,7 +372,7 @@ pub struct MountMatrix { } /// Defines a combined device -#[derive(Debug, Deserialize, Clone, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "snake_case")] pub struct CompositeDeviceConfig { pub version: u32, diff --git a/src/dmi/data.rs b/src/dmi/data.rs index 6a20dc70..7e777756 100644 --- a/src/dmi/data.rs +++ b/src/dmi/data.rs @@ -1,5 +1,7 @@ +use serde::{Deserialize, Serialize}; + /// Container for system DMI data -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct DMIData { pub bios_date: String, pub bios_release: String, diff --git a/src/input/capability.rs b/src/input/capability.rs index 883051a7..d7d11f58 100644 --- a/src/input/capability.rs +++ b/src/input/capability.rs @@ -23,6 +23,26 @@ pub enum Capability { Touchscreen(Touch), } +impl Capability { + pub fn to_capability_string(&self) -> String { + match self { + Capability::Gamepad(gamepad) => match gamepad { + Gamepad::Button(button) => format!("Gamepad:Button:{}", button), + Gamepad::Axis(axis) => format!("Gamepad:Axis:{}", axis), + Gamepad::Trigger(trigger) => format!("Gamepad:Trigger:{}", trigger), + Gamepad::Accelerometer => "Gamepad:Accelerometer".to_string(), + Gamepad::Gyro => "Gamepad:Gyro".to_string(), + }, + Capability::Mouse(mouse) => match mouse { + Mouse::Motion => "Mouse:Motion".to_string(), + Mouse::Button(button) => format!("Mouse:Button:{}", button), + }, + Capability::Keyboard(key) => format!("Keyboard:{}", key), + _ => self.to_string(), + } + } +} + impl fmt::Display for Capability { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { diff --git a/src/input/composite_device/mod.rs b/src/input/composite_device/mod.rs index 10f56454..03ec933a 100644 --- a/src/input/composite_device/mod.rs +++ b/src/input/composite_device/mod.rs @@ -1,6 +1,8 @@ pub mod client; pub mod command; +pub mod script; +use script::{CompositeDeviceLua, ScriptEventAction}; use std::{ borrow::Borrow, collections::{BTreeSet, HashMap, HashSet}, @@ -64,6 +66,8 @@ pub enum InterceptMode { pub struct CompositeDevice { /// Connection to DBus conn: Connection, + /// Lua state instance + lua: CompositeDeviceLua, /// Transmit channel to communicate with the input manager manager: mpsc::Sender, /// Configuration for the CompositeDevice @@ -164,6 +168,7 @@ impl CompositeDevice { let name = config.name.clone(); let mut device = Self { conn, + lua: CompositeDeviceLua::new(tx.clone().into(), config.clone()), manager, config, name, @@ -671,6 +676,11 @@ impl CompositeDevice { return Ok(()); } + // Process the event with lua + if self.lua.preprocess_event(&event) == ScriptEventAction::Stop { + return Ok(()); + } + // Check if the event needs to be translated based on the // capability map. Translated events will be re-enqueued, so this will // return early. @@ -835,6 +845,11 @@ impl CompositeDevice { // Track the delay for chord events. let mut sleep_time = 0; + // Process the event with lua + if self.lua.process_event(&event) == ScriptEventAction::Stop { + return Ok(()); + } + // Translate the event using the device profile. let mut events = if self.device_profile.is_some() { self.translate_event(&event).await? @@ -956,9 +971,13 @@ impl CompositeDevice { /// Writes the given event to the appropriate target device. async fn write_event(&self, event: NativeEvent) -> Result<(), Box> { - let cap = event.as_capability(); + // Run post-process scripts + if self.lua.postprocess_event(&event) == ScriptEventAction::Stop { + return Ok(()); + } // If this event implements the DBus capability, send the event to DBus devices + let cap = event.as_capability(); if matches!(cap, Capability::DBus(_)) { log::trace!("Emit dbus event: {:?}", event); #[allow(clippy::for_kv_map)] @@ -1096,6 +1115,7 @@ impl CompositeDevice { async fn set_intercept_mode(&mut self, mode: InterceptMode) { log::debug!("Setting intercept mode to: {:?}", mode); self.intercept_mode = mode; + self.lua.set_intercept_mode(mode); // Nothing else is required when turning off input interception. if mode == InterceptMode::None || mode == InterceptMode::Pass { @@ -1368,6 +1388,8 @@ impl CompositeDevice { if let Some(idx) = self.source_device_paths.iter().position(|str| str == &path) { self.source_device_paths.remove(idx); + self.lua + .set_source_device_paths(self.source_device_paths.clone()); }; if let Some(idx) = self.source_devices_used.iter().position(|str| str == &id) { @@ -1475,6 +1497,8 @@ impl CompositeDevice { let device_path = source_device.get_device_path(); self.source_devices_discovered.push(source_device); self.source_device_paths.push(device_path); + self.lua + .set_source_device_paths(self.source_device_paths.clone()); self.source_devices_used.push(id); Ok(()) diff --git a/src/input/composite_device/script.rs b/src/input/composite_device/script.rs new file mode 100644 index 00000000..7d9a5948 --- /dev/null +++ b/src/input/composite_device/script.rs @@ -0,0 +1,375 @@ +use std::{collections::HashMap, str::FromStr}; + +use mlua::prelude::*; + +use crate::{ + config::CompositeDeviceConfig, + dmi::{get_cpu_info, get_dmi_data}, + input::{ + capability::Capability, + event::{native::NativeEvent, value::InputValue}, + }, +}; + +use super::{client::CompositeDeviceClient, InterceptMode}; + +/// List of lua functions to load from each discovered script +const LUA_FUNCTIONS: &[&str] = &["preprocess_event", "process_event", "postprocess_event"]; + +/// [ScriptEventAction] defines how an event should be processed further in the +/// input pipeline after executing a script function. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScriptEventAction { + /// Continue processing the event through the pipeline + Continue, + /// Stop processing the event any further + Stop, +} + +/// CompositeDeviceLua is responsible for managing a Lua runtime to execute user +/// defined scripts during the CompositeDevice input pipeline. +#[derive(Debug)] +pub struct CompositeDeviceLua { + /// Lua state instance + lua: Lua, + /// Lua event pipeline scripts + lua_scripts: HashMap<&'static str, Vec>, + /// Reference to the composite device + composite_device: CompositeDeviceClient, +} + +impl CompositeDeviceLua { + /// Creates a new Lua instance + pub fn new(client: CompositeDeviceClient, config: CompositeDeviceConfig) -> Self { + // Initialize lua state + let lua = Lua::new(); + let mut lua_scripts = HashMap::new(); + + // Initialize globals in Lua + CompositeDeviceLua::init_globals(&lua, &client, config); + + // Load the script(s) to execute during the event pipeline + let scripts = ["./rootfs/usr/share/inputplumber/scripts/test.lua"]; + for path in scripts { + let script_data = std::fs::read_to_string(path).unwrap(); + let chunk = lua.load(script_data); + + // Valid chunks should evaluate to returning a table + let table = match chunk.eval::() { + Ok(table) => table, + Err(e) => { + log::error!("Error loading script '{path}': {e:?}"); + continue; + } + }; + + // Load all functions from the evaluated table + for func_name in LUA_FUNCTIONS.iter() { + CompositeDeviceLua::load_function(&mut lua_scripts, &table, func_name, path); + } + } + + Self { + lua, + lua_scripts, + composite_device: client, + } + } + + /// Initializes global scripting variables that will be available inside Lua + /// on startup. + fn init_globals(lua: &Lua, client: &CompositeDeviceClient, config: CompositeDeviceConfig) { + // Global 'system' + let system = lua.create_table().unwrap(); + + // Load DMI data and expose it to Lua + log::debug!("Loading DMI data"); + let dmi_data = lua.to_value(&get_dmi_data()).unwrap(); + system.set("dmi", dmi_data).unwrap(); + + // Load CPU data and expose it to Lua + log::debug!("Loading CPU info"); + let cpu_info = match get_cpu_info() { + Ok(info) => info, + Err(e) => { + log::error!("Failed to get CPU info: {e:?}"); + panic!("Unable to determine CPU info!"); + } + }; + let cpu_info = lua.to_value(&cpu_info).unwrap(); + system.set("cpu", cpu_info).unwrap(); + if let Err(e) = lua.globals().set("system", system) { + log::error!("Failed to set cpu info: {e:?}"); + } + + // Global 'device' + let device = lua.create_table().unwrap(); + + // Populate event globals + lua.globals() + .set("_events_to_write", lua.create_table().unwrap()) + .unwrap(); + + // Load the device config and expose it to Lua + let config = lua.to_value(&config).unwrap(); + if let Err(e) = device.set("config", config) { + log::error!("Failed to set device config: {e:?}"); + } + if let Err(e) = device.set("intercept_mode", 0) { + log::error!("Failed to set intercept mode: {e:?}"); + } + + // Bind the 'write_event' method to the 'device' global + let write_event = lua + .create_function(move |lua, event: LuaTable| { + // The [CompositeDeviceClient] cannot be moved into a lua method, so + // instead, write the event to a global so it can be sent later. + let events_to_write = lua.globals().get::("_events_to_write")?; + events_to_write.push(event)?; + + Ok(()) + }) + .unwrap(); + if let Err(e) = device.set("write_event", write_event) { + log::error!("Failed to set write_event method: {e:?}"); + } + + // Expose the 'device' global to Lua + if let Err(e) = lua.globals().set("device", device) { + log::error!("Failed to set cpu info: {e:?}"); + } + } + + /// Load the function with the given name from the Lua table and update the + /// hashmap. + fn load_function( + lua_scripts: &mut HashMap<&'static str, Vec>, + table: &LuaTable, + name: &'static str, + path: &str, + ) { + // Extract the functions for each step in the pipeline + let process_event = table.get::(name); + match process_event { + Ok(func) => { + log::info!("Successfully loaded '{name}' from script '{path}'"); + lua_scripts + .entry(name) + .and_modify(|e: &mut Vec| e.push(func.clone())) + .or_insert_with(|| vec![func]); + } + Err(e) => match e { + LuaError::FromLuaConversionError { + from, + to: _, + message: _, + } => { + if from == "nil" { + log::trace!("Function not found in table"); + } + } + _ => { + log::error!("Failed to load '{name}' function from '{path}': {e:?}"); + } + }, + } + } + + /// Expose the given intercept mode to lua + pub fn set_intercept_mode(&self, mode: InterceptMode) { + let device = match self.lua.globals().get::("device") { + Ok(dev) => dev, + Err(e) => { + log::error!("Failed to get 'device' global: {e:?}"); + return; + } + }; + let mode = match mode { + InterceptMode::None => 0, + InterceptMode::Pass => 1, + InterceptMode::Always => 2, + InterceptMode::GamepadOnly => 3, + }; + if let Err(e) = device.set("intercept_mode", mode) { + log::error!("Failed to set intercept mode on device: {e:?}"); + } + } + + /// Expose the given source device paths in lua + pub fn set_source_device_paths(&self, paths: Vec) { + let device = match self.lua.globals().get::("device") { + Ok(dev) => dev, + Err(e) => { + log::error!("Failed to get 'device' global: {e:?}"); + return; + } + }; + if let Err(e) = device.set("source_device_paths", paths) { + log::error!("Failed to set source_device_paths on device: {e:?}"); + } + } + + /// Executes the 'preprocess_event' function in any loaded Lua scripts. + /// The preprocess_event function should be executed on all input events + /// -before- capability map translation. + pub fn preprocess_event(&self, event: &NativeEvent) -> ScriptEventAction { + self.process_event_func("preprocess_event", event) + } + + /// Executes the 'process_event' function in any loaded Lua scripts. + /// The process_event function should be executed on all input events + /// -after- capability map translation, but -before- input profile translation. + pub fn process_event(&self, event: &NativeEvent) -> ScriptEventAction { + self.process_event_func("process_event", event) + } + + /// Executes the 'postprocess_event' function in any loaded Lua scripts. + /// The postprocess_event function should be executed on all input events + /// -after- capability map translation and -after- input profile translation. + pub fn postprocess_event(&self, event: &NativeEvent) -> ScriptEventAction { + self.process_event_func("postprocess_event", event) + } + + /// Executes the event function with the given name from any loaded Lua scripts. + fn process_event_func(&self, func_name: &str, event: &NativeEvent) -> ScriptEventAction { + let Some(scripts) = self.lua_scripts.get(func_name) else { + return ScriptEventAction::Continue; + }; + if scripts.is_empty() { + return ScriptEventAction::Continue; + } + + // Convert the event into a lua table + let Some(event_table) = self.event_to_table(event) else { + log::error!("Unable to convert event"); + return ScriptEventAction::Continue; + }; + + // Clear any global data that is used to dispatch events + if let Ok(events_to_write) = self.lua.globals().get::("_events_to_write") { + events_to_write.clear().unwrap(); + } + + // Execute the process_event method on all scripts + let mut action = ScriptEventAction::Continue; + for script in scripts { + match script.call::(event_table.clone()) { + Ok(true) => (), + Ok(false) => { + action = ScriptEventAction::Stop; + break; + } + Err(e) => { + log::error!("Failed to execute {func_name}: {e:?}"); + continue; + } + }; + } + + // Execute composite device commands based on global state + if let Ok(events_to_write) = self.lua.globals().get::("_events_to_write") { + if events_to_write.is_empty() { + return action; + } + + // Write the events to the composite device + for event in events_to_write.sequence_values::() { + let Ok(event) = event else { + continue; + }; + + // Convert the table to an event to emit + let Some(native_event) = self.table_to_event(&event) else { + continue; + }; + + // Write the event to the composite device + let device = self.composite_device.clone(); + tokio::task::spawn(async move { + if let Err(e) = device.write_event(native_event).await { + log::error!("Failed to write event: {e:?}"); + } + }); + } + } + + action + } + + /// Convert the given event to a Lua table + fn event_to_table(&self, event: &NativeEvent) -> Option { + // Convert the event into a lua table + let event_table = self.lua.create_table().ok()?; + event_table + .set("capability", event.as_capability().to_capability_string()) + .ok()?; + match event.get_value() { + InputValue::None => (), + InputValue::Bool(value) => event_table.set("value", value).unwrap(), + InputValue::Float(value) => event_table.set("value", value).unwrap(), + InputValue::Vector2 { x, y } => { + let value = self.lua.create_table().unwrap(); + if let Some(x) = x { + value.set("x", x).unwrap(); + } + if let Some(y) = y { + value.set("y", y).unwrap(); + } + event_table.set("value", value).unwrap(); + } + InputValue::Vector3 { x, y, z } => { + let value = self.lua.create_table().unwrap(); + if let Some(x) = x { + value.set("x", x).unwrap(); + } + if let Some(y) = y { + value.set("y", y).unwrap(); + } + if let Some(z) = z { + value.set("z", z).unwrap(); + } + event_table.set("value", value).unwrap(); + } + InputValue::Touch { + index, + is_touching, + pressure, + x, + y, + } => { + //TODO + } + } + + Some(event_table) + } + + /// Convert the given Lua table into a native event + fn table_to_event(&self, table: &LuaTable) -> Option { + let cap = table.get::("capability").ok()?; + let cap = Capability::from_str(cap.as_str()).ok()?; + let value = table.get::("value").ok()?; + + let value = match value.type_name() { + "boolean" => { + let value = value.as_boolean().unwrap(); + InputValue::Bool(value) + } + "number" => { + let value = value.as_f64().unwrap(); + InputValue::Float(value) + } + "integer" => { + let value = value.as_f64().unwrap(); + InputValue::Float(value) + } + // TODO: + //"table" => + _ => return None, + }; + + let event = NativeEvent::new(cap, value); + + Some(event) + } +}