#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeviceKind {
Keyboard,
Mouse,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct InputDevice {
pub name: String,
pub handlers: Vec<String>,
pub sysfs: String,
pub key_bits: Vec<u64>,
}
const ALPHA_KEYS: &[u32] = &[
16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 30, 31, 32, 33, 34, 35, 36, 37, 38, 44, 45, 46, 47, 48, 49, 50, ];
pub fn parse_bitmap(value: &str) -> Vec<u64> {
let mut words: Vec<u64> = value
.split_whitespace()
.filter_map(|w| u64::from_str_radix(w, 16).ok())
.collect();
words.reverse();
words
}
fn bit_set(bits: &[u64], bit: u32) -> bool {
let word = (bit / 64) as usize;
let offset = bit % 64;
bits.get(word).is_some_and(|w| (w >> offset) & 1 == 1)
}
pub fn has_alpha_block(key_bits: &[u64]) -> bool {
ALPHA_KEYS.iter().all(|&k| bit_set(key_bits, k))
}
pub fn parse_input_devices(content: &str) -> Vec<InputDevice> {
let mut out = Vec::new();
let mut cur = InputDevice::default();
let flush = |cur: &mut InputDevice, out: &mut Vec<InputDevice>| {
if !cur.name.is_empty() {
out.push(std::mem::take(cur));
} else {
*cur = InputDevice::default();
}
};
for line in content.lines() {
let line = line.trim_end();
if line.is_empty() {
flush(&mut cur, &mut out);
continue;
}
if let Some(rest) = line.strip_prefix("N: Name=") {
cur.name = rest.trim().trim_matches('"').to_string();
} else if let Some(rest) = line.strip_prefix("H: Handlers=") {
cur.handlers = rest.split_whitespace().map(|s| s.to_string()).collect();
} else if let Some(rest) = line.strip_prefix("S: Sysfs=") {
cur.sysfs = rest.trim().to_string();
} else if let Some(rest) = line.strip_prefix("B: KEY=") {
cur.key_bits = parse_bitmap(rest);
}
}
flush(&mut cur, &mut out);
out
}
pub fn classify_device<F>(dev: &InputDevice, model_name: F) -> Option<DeviceKind>
where
F: Fn(&str) -> Option<String>,
{
let has_kbd = dev.handlers.iter().any(|h| h == "kbd") && has_alpha_block(&dev.key_bits);
let has_ptr = dev.handlers.iter().any(|h| h.starts_with("mouse"));
match (has_kbd, has_ptr) {
(true, false) => Some(DeviceKind::Keyboard),
(false, true) => Some(DeviceKind::Mouse),
(false, false) => None,
(true, true) => {
let model = model_name(&dev.sysfs)?.to_lowercase();
if model.contains("keyboard") {
Some(DeviceKind::Keyboard)
} else if model.contains("mouse") || model.contains("trackball") {
Some(DeviceKind::Mouse)
} else {
None
}
}
}
}
pub fn classify_input_devices_with<F>(
devices: &[InputDevice],
model_name: F,
) -> (Vec<String>, Vec<String>)
where
F: Fn(&str) -> Option<String>,
{
let mut keyboards: Vec<String> = Vec::new();
let mut mice: Vec<String> = Vec::new();
for dev in devices {
let target = match classify_device(dev, &model_name) {
Some(DeviceKind::Keyboard) => &mut keyboards,
Some(DeviceKind::Mouse) => &mut mice,
None => continue,
};
if !target.contains(&dev.name) {
target.push(dev.name.clone());
}
}
(keyboards, mice)
}
#[cfg(target_os = "linux")]
fn hidpp_model_name(sysfs: &str) -> Option<String> {
use std::path::Path;
let hid_dir = Path::new("/sys")
.join(sysfs.trim_start_matches('/'))
.parent()?
.parent()?
.to_path_buf();
for entry in std::fs::read_dir(hid_dir.join("power_supply"))
.ok()?
.flatten()
{
if let Ok(model) = std::fs::read_to_string(entry.path().join("model_name")) {
let model = model.trim();
if !model.is_empty() {
return Some(model.to_string());
}
}
}
None
}
pub fn detect_input_devices() -> (Vec<String>, Vec<String>) {
#[cfg(target_os = "linux")]
{
let Ok(content) = std::fs::read_to_string("/proc/bus/input/devices") else {
return (Vec::new(), Vec::new());
};
let devices = parse_input_devices(&content);
classify_input_devices_with(&devices, hidpp_model_name)
}
#[cfg(not(target_os = "linux"))]
{
(Vec::new(), Vec::new())
}
}
#[cfg(test)]
mod tests {
use super::*;
const FIXTURE: &str = r#"I: Bus=0019 Vendor=0000 Product=0001 Version=0000
N: Name="Power Button"
P: Phys=PNP0C0C/button/input0
S: Sysfs=/devices/platform/PNP0C0C:00/input/input1
U: Uniq=
H: Handlers=kbd event1
B: PROP=0
B: EV=3
B: KEY=8000 10000000000000 0
I: Bus=0011 Vendor=0001 Product=0001 Version=ab83
N: Name="AT Translated Set 2 keyboard"
P: Phys=isa0060/serio0/input0
S: Sysfs=/devices/platform/i8042/serio0/input/input3
U: Uniq=
H: Handlers=sysrq kbd leds event3
B: PROP=0
B: EV=120013
B: KEY=2000000000000000 0 40000 0 0 0 0 11100f02902007 f780307cfb10f001 feffffdfffcfffff fffffffffffffffe
I: Bus=0011 Vendor=0002 Product=0001 Version=0000
N: Name="PS/2 Generic Mouse"
P: Phys=isa0060/serio1/input0
S: Sysfs=/devices/platform/i8042/serio1/input/input5
U: Uniq=
H: Handlers=mouse0 event4
B: PROP=0
B: EV=7
B: KEY=70000 0 0 0 0
I: Bus=0018 Vendor=06cb Product=cf06 Version=0100
N: Name="VEN_06CB:00 06CB:CF06 Touchpad"
P: Phys=i2c-VEN_06CB:00
S: Sysfs=/devices/pci0000:00/0000:00:15.0/i2c_designware.0/i2c-1/i2c-VEN_06CB:00/0018:06CB:CF06.0001/input/input8
U: Uniq=
H: Handlers=mouse2 event6
B: PROP=5
B: EV=1b
B: KEY=e520 10000 0 0 0 0
I: Bus=0003 Vendor=046d Product=408a Version=0111
N: Name="Logitech MX Keys"
P: Phys=usb-0000:00:14.0-2/input2:1
S: Sysfs=/devices/pci0000:00/0000:00:14.0/usb3/3-2/3-2.4/0003:046D:C52B.000D/0003:046D:408A.000F/input/input53
U: Uniq=
H: Handlers=sysrq kbd leds mouse6 event26
B: PROP=0
B: EV=12001f
B: KEY=3f00733fff 0 0 483ffff17aff32d bfd4444600000000 ffff0001 130ff38b17d007 ffff7bfad941dfff ffbeffdfffefffff fffffffffffffffe
I: Bus=0003 Vendor=046d Product=4082 Version=0111
N: Name="Logitech MX Master 3"
P: Phys=usb-0000:00:14.0-2/input2:2
S: Sysfs=/devices/pci0000:00/0000:00:14.0/usb3/3-2/3-2.4/0003:046D:C52B.000D/0003:046D:4082.0011/input/input51
U: Uniq=
H: Handlers=sysrq kbd leds mouse5 event25
B: PROP=0
B: EV=12001f
B: KEY=3f00733fff 0 0 483ffff17aff32d bfd4444600000000 ffff0001 130ff38b17d007 ffff7bfad9415fff ffbeffdfffefffff fffffffffffffffe
"#;
fn fixture_model_name(sysfs: &str) -> Option<String> {
if sysfs.contains("408A") {
Some("MX Keys Wireless Keyboard".to_string())
} else if sysfs.contains("4082") {
Some("Wireless Mouse MX Master 3".to_string())
} else {
None
}
}
#[test]
fn test_parse_bitmap_is_low_word_first() {
assert_eq!(parse_bitmap("2 1"), vec![1, 2]);
assert_eq!(parse_bitmap("ff"), vec![0xff]);
assert_eq!(parse_bitmap(""), Vec::<u64>::new());
assert_eq!(parse_bitmap("zz 3"), vec![3]);
}
#[test]
fn test_bit_set_across_word_boundary() {
let bits = parse_bitmap("1 8000000000000000");
assert!(bit_set(&bits, 63), "bit 63 is the top of the low word");
assert!(bit_set(&bits, 64), "bit 64 is the bottom of the high word");
assert!(!bit_set(&bits, 62));
assert!(!bit_set(&bits, 65));
assert!(!bit_set(&bits, 4096));
}
#[test]
fn test_has_alpha_block() {
let devices = parse_input_devices(FIXTURE);
let by_name = |n: &str| devices.iter().find(|d| d.name == n).unwrap().clone();
assert!(has_alpha_block(
&by_name("AT Translated Set 2 keyboard").key_bits
));
assert!(
!has_alpha_block(&by_name("Power Button").key_bits),
"a power button registers `kbd` but offers no text entry"
);
assert!(!has_alpha_block(&by_name("PS/2 Generic Mouse").key_bits));
}
#[test]
fn test_parse_input_devices_fields() {
let devices = parse_input_devices(FIXTURE);
assert_eq!(
devices.len(),
6,
"one record per blank-line-separated block"
);
let kb = devices
.iter()
.find(|d| d.name == "AT Translated Set 2 keyboard")
.unwrap();
assert_eq!(kb.handlers, vec!["sysrq", "kbd", "leds", "event3"]);
assert_eq!(kb.sysfs, "/devices/platform/i8042/serio0/input/input3");
assert_eq!(*kb.key_bits.first().unwrap(), 0xfffffffffffffffe);
}
#[test]
fn test_classify_unambiguous_devices() {
let devices = parse_input_devices(FIXTURE);
let find = |n: &str| devices.iter().find(|d| d.name == n).unwrap();
assert_eq!(
classify_device(find("AT Translated Set 2 keyboard"), fixture_model_name),
Some(DeviceKind::Keyboard)
);
assert_eq!(
classify_device(find("PS/2 Generic Mouse"), fixture_model_name),
Some(DeviceKind::Mouse)
);
assert_eq!(
classify_device(find("VEN_06CB:00 06CB:CF06 Touchpad"), fixture_model_name),
Some(DeviceKind::Mouse),
"a touchpad is a pointing device"
);
assert_eq!(
classify_device(find("Power Button"), fixture_model_name),
None,
"`kbd` handler without the alphabet block is neither"
);
}
#[test]
fn test_merged_hidpp_endpoints_use_the_model_name_tiebreak() {
let devices = parse_input_devices(FIXTURE);
let find = |n: &str| devices.iter().find(|d| d.name == n).unwrap();
let keys = find("Logitech MX Keys");
let master = find("Logitech MX Master 3");
assert_eq!(keys.key_bits.len(), master.key_bits.len());
assert!(has_alpha_block(&keys.key_bits) && has_alpha_block(&master.key_bits));
assert_eq!(
classify_device(keys, fixture_model_name),
Some(DeviceKind::Keyboard)
);
assert_eq!(
classify_device(master, fixture_model_name),
Some(DeviceKind::Mouse)
);
}
#[test]
fn test_ambiguous_device_without_model_name_is_omitted() {
let devices = parse_input_devices(FIXTURE);
let keys = devices
.iter()
.find(|d| d.name == "Logitech MX Keys")
.unwrap();
assert_eq!(classify_device(keys, |_| None), None);
}
#[test]
fn test_classify_input_devices_with_end_to_end() {
let devices = parse_input_devices(FIXTURE);
let (keyboards, mice) = classify_input_devices_with(&devices, fixture_model_name);
assert_eq!(
keyboards,
vec!["AT Translated Set 2 keyboard", "Logitech MX Keys"]
);
assert_eq!(
mice,
vec![
"PS/2 Generic Mouse",
"VEN_06CB:00 06CB:CF06 Touchpad",
"Logitech MX Master 3"
]
);
}
#[test]
fn test_duplicate_names_are_collapsed() {
let doubled = format!("{}\n{}", FIXTURE, FIXTURE);
let devices = parse_input_devices(&doubled);
let (keyboards, mice) = classify_input_devices_with(&devices, fixture_model_name);
assert_eq!(keyboards.len(), 2, "names must not repeat");
assert_eq!(mice.len(), 3);
}
#[test]
fn test_empty_and_malformed_input() {
assert!(parse_input_devices("").is_empty());
assert!(parse_input_devices("garbage\nlines\nwith no records").is_empty());
assert!(parse_input_devices("H: Handlers=kbd event0\nB: EV=3\n").is_empty());
let (k, m) = classify_input_devices_with(&[], fixture_model_name);
assert!(k.is_empty() && m.is_empty());
}
}