1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum DeviceKind {
36 Keyboard,
38 Mouse,
40}
41
42#[derive(Debug, Clone, Default, PartialEq, Eq)]
44pub struct InputDevice {
45 pub name: String,
47 pub handlers: Vec<String>,
49 pub sysfs: String,
51 pub key_bits: Vec<u64>,
53}
54
55const ALPHA_KEYS: &[u32] = &[
60 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, ];
64
65pub fn parse_bitmap(value: &str) -> Vec<u64> {
71 let mut words: Vec<u64> = value
72 .split_whitespace()
73 .filter_map(|w| u64::from_str_radix(w, 16).ok())
74 .collect();
75 words.reverse();
76 words
77}
78
79fn bit_set(bits: &[u64], bit: u32) -> bool {
81 let word = (bit / 64) as usize;
82 let offset = bit % 64;
83 bits.get(word).is_some_and(|w| (w >> offset) & 1 == 1)
84}
85
86pub fn has_alpha_block(key_bits: &[u64]) -> bool {
88 ALPHA_KEYS.iter().all(|&k| bit_set(key_bits, k))
89}
90
91pub fn parse_input_devices(content: &str) -> Vec<InputDevice> {
97 let mut out = Vec::new();
98 let mut cur = InputDevice::default();
99
100 let flush = |cur: &mut InputDevice, out: &mut Vec<InputDevice>| {
101 if !cur.name.is_empty() {
102 out.push(std::mem::take(cur));
103 } else {
104 *cur = InputDevice::default();
105 }
106 };
107
108 for line in content.lines() {
109 let line = line.trim_end();
110 if line.is_empty() {
111 flush(&mut cur, &mut out);
112 continue;
113 }
114 if let Some(rest) = line.strip_prefix("N: Name=") {
115 cur.name = rest.trim().trim_matches('"').to_string();
116 } else if let Some(rest) = line.strip_prefix("H: Handlers=") {
117 cur.handlers = rest.split_whitespace().map(|s| s.to_string()).collect();
118 } else if let Some(rest) = line.strip_prefix("S: Sysfs=") {
119 cur.sysfs = rest.trim().to_string();
120 } else if let Some(rest) = line.strip_prefix("B: KEY=") {
121 cur.key_bits = parse_bitmap(rest);
122 }
123 }
124 flush(&mut cur, &mut out);
125 out
126}
127
128pub fn classify_device<F>(dev: &InputDevice, model_name: F) -> Option<DeviceKind>
138where
139 F: Fn(&str) -> Option<String>,
140{
141 let has_kbd = dev.handlers.iter().any(|h| h == "kbd") && has_alpha_block(&dev.key_bits);
142 let has_ptr = dev.handlers.iter().any(|h| h.starts_with("mouse"));
143
144 match (has_kbd, has_ptr) {
145 (true, false) => Some(DeviceKind::Keyboard),
146 (false, true) => Some(DeviceKind::Mouse),
147 (false, false) => None,
148 (true, true) => {
151 let model = model_name(&dev.sysfs)?.to_lowercase();
152 if model.contains("keyboard") {
153 Some(DeviceKind::Keyboard)
154 } else if model.contains("mouse") || model.contains("trackball") {
155 Some(DeviceKind::Mouse)
156 } else {
157 None
158 }
159 }
160 }
161}
162
163pub fn classify_input_devices_with<F>(
169 devices: &[InputDevice],
170 model_name: F,
171) -> (Vec<String>, Vec<String>)
172where
173 F: Fn(&str) -> Option<String>,
174{
175 let mut keyboards: Vec<String> = Vec::new();
176 let mut mice: Vec<String> = Vec::new();
177
178 for dev in devices {
179 let target = match classify_device(dev, &model_name) {
180 Some(DeviceKind::Keyboard) => &mut keyboards,
181 Some(DeviceKind::Mouse) => &mut mice,
182 None => continue,
183 };
184 if !target.contains(&dev.name) {
185 target.push(dev.name.clone());
186 }
187 }
188 (keyboards, mice)
189}
190
191#[cfg(target_os = "linux")]
197fn hidpp_model_name(sysfs: &str) -> Option<String> {
198 use std::path::Path;
199 let hid_dir = Path::new("/sys")
200 .join(sysfs.trim_start_matches('/'))
201 .parent()?
202 .parent()?
203 .to_path_buf();
204 for entry in std::fs::read_dir(hid_dir.join("power_supply"))
205 .ok()?
206 .flatten()
207 {
208 if let Ok(model) = std::fs::read_to_string(entry.path().join("model_name")) {
209 let model = model.trim();
210 if !model.is_empty() {
211 return Some(model.to_string());
212 }
213 }
214 }
215 None
216}
217
218pub fn detect_input_devices() -> (Vec<String>, Vec<String>) {
224 #[cfg(target_os = "linux")]
225 {
226 let Ok(content) = std::fs::read_to_string("/proc/bus/input/devices") else {
227 return (Vec::new(), Vec::new());
228 };
229 let devices = parse_input_devices(&content);
230 classify_input_devices_with(&devices, hidpp_model_name)
231 }
232 #[cfg(not(target_os = "linux"))]
233 {
234 (Vec::new(), Vec::new())
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 const FIXTURE: &str = r#"I: Bus=0019 Vendor=0000 Product=0001 Version=0000
247N: Name="Power Button"
248P: Phys=PNP0C0C/button/input0
249S: Sysfs=/devices/platform/PNP0C0C:00/input/input1
250U: Uniq=
251H: Handlers=kbd event1
252B: PROP=0
253B: EV=3
254B: KEY=8000 10000000000000 0
255
256I: Bus=0011 Vendor=0001 Product=0001 Version=ab83
257N: Name="AT Translated Set 2 keyboard"
258P: Phys=isa0060/serio0/input0
259S: Sysfs=/devices/platform/i8042/serio0/input/input3
260U: Uniq=
261H: Handlers=sysrq kbd leds event3
262B: PROP=0
263B: EV=120013
264B: KEY=2000000000000000 0 40000 0 0 0 0 11100f02902007 f780307cfb10f001 feffffdfffcfffff fffffffffffffffe
265
266I: Bus=0011 Vendor=0002 Product=0001 Version=0000
267N: Name="PS/2 Generic Mouse"
268P: Phys=isa0060/serio1/input0
269S: Sysfs=/devices/platform/i8042/serio1/input/input5
270U: Uniq=
271H: Handlers=mouse0 event4
272B: PROP=0
273B: EV=7
274B: KEY=70000 0 0 0 0
275
276I: Bus=0018 Vendor=06cb Product=cf06 Version=0100
277N: Name="VEN_06CB:00 06CB:CF06 Touchpad"
278P: Phys=i2c-VEN_06CB:00
279S: Sysfs=/devices/pci0000:00/0000:00:15.0/i2c_designware.0/i2c-1/i2c-VEN_06CB:00/0018:06CB:CF06.0001/input/input8
280U: Uniq=
281H: Handlers=mouse2 event6
282B: PROP=5
283B: EV=1b
284B: KEY=e520 10000 0 0 0 0
285
286I: Bus=0003 Vendor=046d Product=408a Version=0111
287N: Name="Logitech MX Keys"
288P: Phys=usb-0000:00:14.0-2/input2:1
289S: Sysfs=/devices/pci0000:00/0000:00:14.0/usb3/3-2/3-2.4/0003:046D:C52B.000D/0003:046D:408A.000F/input/input53
290U: Uniq=
291H: Handlers=sysrq kbd leds mouse6 event26
292B: PROP=0
293B: EV=12001f
294B: KEY=3f00733fff 0 0 483ffff17aff32d bfd4444600000000 ffff0001 130ff38b17d007 ffff7bfad941dfff ffbeffdfffefffff fffffffffffffffe
295
296I: Bus=0003 Vendor=046d Product=4082 Version=0111
297N: Name="Logitech MX Master 3"
298P: Phys=usb-0000:00:14.0-2/input2:2
299S: Sysfs=/devices/pci0000:00/0000:00:14.0/usb3/3-2/3-2.4/0003:046D:C52B.000D/0003:046D:4082.0011/input/input51
300U: Uniq=
301H: Handlers=sysrq kbd leds mouse5 event25
302B: PROP=0
303B: EV=12001f
304B: KEY=3f00733fff 0 0 483ffff17aff32d bfd4444600000000 ffff0001 130ff38b17d007 ffff7bfad9415fff ffbeffdfffefffff fffffffffffffffe
305"#;
306
307 fn fixture_model_name(sysfs: &str) -> Option<String> {
309 if sysfs.contains("408A") {
310 Some("MX Keys Wireless Keyboard".to_string())
311 } else if sysfs.contains("4082") {
312 Some("Wireless Mouse MX Master 3".to_string())
313 } else {
314 None
315 }
316 }
317
318 #[test]
319 fn test_parse_bitmap_is_low_word_first() {
320 assert_eq!(parse_bitmap("2 1"), vec![1, 2]);
322 assert_eq!(parse_bitmap("ff"), vec![0xff]);
323 assert_eq!(parse_bitmap(""), Vec::<u64>::new());
324 assert_eq!(parse_bitmap("zz 3"), vec![3]);
326 }
327
328 #[test]
329 fn test_bit_set_across_word_boundary() {
330 let bits = parse_bitmap("1 8000000000000000");
331 assert!(bit_set(&bits, 63), "bit 63 is the top of the low word");
332 assert!(bit_set(&bits, 64), "bit 64 is the bottom of the high word");
333 assert!(!bit_set(&bits, 62));
334 assert!(!bit_set(&bits, 65));
335 assert!(!bit_set(&bits, 4096));
337 }
338
339 #[test]
340 fn test_has_alpha_block() {
341 let devices = parse_input_devices(FIXTURE);
342 let by_name = |n: &str| devices.iter().find(|d| d.name == n).unwrap().clone();
343 assert!(has_alpha_block(
344 &by_name("AT Translated Set 2 keyboard").key_bits
345 ));
346 assert!(
347 !has_alpha_block(&by_name("Power Button").key_bits),
348 "a power button registers `kbd` but offers no text entry"
349 );
350 assert!(!has_alpha_block(&by_name("PS/2 Generic Mouse").key_bits));
351 }
352
353 #[test]
354 fn test_parse_input_devices_fields() {
355 let devices = parse_input_devices(FIXTURE);
356 assert_eq!(
357 devices.len(),
358 6,
359 "one record per blank-line-separated block"
360 );
361 let kb = devices
362 .iter()
363 .find(|d| d.name == "AT Translated Set 2 keyboard")
364 .unwrap();
365 assert_eq!(kb.handlers, vec!["sysrq", "kbd", "leds", "event3"]);
366 assert_eq!(kb.sysfs, "/devices/platform/i8042/serio0/input/input3");
367 assert_eq!(*kb.key_bits.first().unwrap(), 0xfffffffffffffffe);
368 }
369
370 #[test]
371 fn test_classify_unambiguous_devices() {
372 let devices = parse_input_devices(FIXTURE);
373 let find = |n: &str| devices.iter().find(|d| d.name == n).unwrap();
374
375 assert_eq!(
376 classify_device(find("AT Translated Set 2 keyboard"), fixture_model_name),
377 Some(DeviceKind::Keyboard)
378 );
379 assert_eq!(
380 classify_device(find("PS/2 Generic Mouse"), fixture_model_name),
381 Some(DeviceKind::Mouse)
382 );
383 assert_eq!(
384 classify_device(find("VEN_06CB:00 06CB:CF06 Touchpad"), fixture_model_name),
385 Some(DeviceKind::Mouse),
386 "a touchpad is a pointing device"
387 );
388 assert_eq!(
389 classify_device(find("Power Button"), fixture_model_name),
390 None,
391 "`kbd` handler without the alphabet block is neither"
392 );
393 }
394
395 #[test]
396 fn test_merged_hidpp_endpoints_use_the_model_name_tiebreak() {
397 let devices = parse_input_devices(FIXTURE);
398 let find = |n: &str| devices.iter().find(|d| d.name == n).unwrap();
399
400 let keys = find("Logitech MX Keys");
403 let master = find("Logitech MX Master 3");
404 assert_eq!(keys.key_bits.len(), master.key_bits.len());
405 assert!(has_alpha_block(&keys.key_bits) && has_alpha_block(&master.key_bits));
406
407 assert_eq!(
408 classify_device(keys, fixture_model_name),
409 Some(DeviceKind::Keyboard)
410 );
411 assert_eq!(
412 classify_device(master, fixture_model_name),
413 Some(DeviceKind::Mouse)
414 );
415 }
416
417 #[test]
418 fn test_ambiguous_device_without_model_name_is_omitted() {
419 let devices = parse_input_devices(FIXTURE);
420 let keys = devices
421 .iter()
422 .find(|d| d.name == "Logitech MX Keys")
423 .unwrap();
424 assert_eq!(classify_device(keys, |_| None), None);
427 }
428
429 #[test]
430 fn test_classify_input_devices_with_end_to_end() {
431 let devices = parse_input_devices(FIXTURE);
432 let (keyboards, mice) = classify_input_devices_with(&devices, fixture_model_name);
433 assert_eq!(
434 keyboards,
435 vec!["AT Translated Set 2 keyboard", "Logitech MX Keys"]
436 );
437 assert_eq!(
438 mice,
439 vec![
440 "PS/2 Generic Mouse",
441 "VEN_06CB:00 06CB:CF06 Touchpad",
442 "Logitech MX Master 3"
443 ]
444 );
445 }
446
447 #[test]
448 fn test_duplicate_names_are_collapsed() {
449 let doubled = format!("{}\n{}", FIXTURE, FIXTURE);
451 let devices = parse_input_devices(&doubled);
452 let (keyboards, mice) = classify_input_devices_with(&devices, fixture_model_name);
453 assert_eq!(keyboards.len(), 2, "names must not repeat");
454 assert_eq!(mice.len(), 3);
455 }
456
457 #[test]
458 fn test_empty_and_malformed_input() {
459 assert!(parse_input_devices("").is_empty());
460 assert!(parse_input_devices("garbage\nlines\nwith no records").is_empty());
461 assert!(parse_input_devices("H: Handlers=kbd event0\nB: EV=3\n").is_empty());
463 let (k, m) = classify_input_devices_with(&[], fixture_model_name);
464 assert!(k.is_empty() && m.is_empty());
465 }
466}