Skip to main content

retch_sysinfo/
input.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Keyboard and pointing-device detection.
5//!
6//! Linux reads `/proc/bus/input/devices`, a single file listing every evdev node with its
7//! handlers and capability bitmaps. Classification is deliberately **exclusive**: a device is
8//! reported as a keyboard or a mouse or neither, never both.
9//!
10//! ## Why a device can be unclassifiable
11//!
12//! A peripheral paired through a Logitech Unifying/Bolt receiver is presented by the kernel
13//! with a *merged* capability set — the receiver synthesizes one descriptor covering every
14//! device class it can carry. Measured on a Bolt receiver carrying an MX Keys keyboard and an
15//! MX Master 3 mouse, **every** kernel-visible signal is identical for the two:
16//!
17//! | source | MX Keys (keyboard) | MX Master 3 (mouse) |
18//! |---|---|---|
19//! | `/proc/bus/input/devices` handlers | `sysrq kbd leds mouse6 event26` | `sysrq kbd leds mouse5 event25` |
20//! | `capabilities/rel` | `0x1943` | `0x1943` |
21//! | `capabilities/key` (alphabet block) | present | present |
22//! | udev `ID_INPUT_*` | `POINTINGSTICK` | `POINTINGSTICK` |
23//! | USB HID `bInterfaceProtocol` | `00` | `00` |
24//! | HID report descriptor prologue | `05 01 09 06 a1 01` | `05 01 09 06 a1 01` |
25//!
26//! So capability inspection alone *cannot* separate them, and a classifier that guesses gets
27//! it wrong in both directions (fastfetch 2.66 reports the MX Master 3 as a keyboard and the
28//! MX Keys as a mouse on this hardware). The one place the truth survives is the HID++
29//! driver's battery `model_name` (`"MX Keys Wireless Keyboard"` / `"Wireless Mouse MX Master
30//! 3"`), so that is consulted as a tiebreak — and when it is absent too, the device is
31//! reported in **neither** field rather than asserted into the wrong one.
32
33/// What a device was classified as.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum DeviceKind {
36    /// A key-entry device: `kbd` handler plus the full alphabetic key block.
37    Keyboard,
38    /// A pointing device: exposes a `mouseN` handler.
39    Mouse,
40}
41
42/// One record from `/proc/bus/input/devices`.
43#[derive(Debug, Clone, Default, PartialEq, Eq)]
44pub struct InputDevice {
45    /// Device name from the `N: Name="…"` line.
46    pub name: String,
47    /// Handler tokens from the `H: Handlers=…` line (e.g. `kbd`, `mouse0`, `event3`).
48    pub handlers: Vec<String>,
49    /// Sysfs path from the `S: Sysfs=…` line, relative to `/sys`.
50    pub sysfs: String,
51    /// `B: KEY=…` bitmap, **low word first** (see [`parse_bitmap`]).
52    pub key_bits: Vec<u64>,
53}
54
55/// Linux key codes for the alphabetic block, from `include/uapi/linux/input-event-codes.h`.
56///
57/// A device claiming all of these is offering real text entry rather than the handful of
58/// consumer-control or power-button keys that also register a `kbd` handler.
59const ALPHA_KEYS: &[u32] = &[
60    16, 17, 18, 19, 20, 21, 22, 23, 24, 25, // Q W E R T Y U I O P
61    30, 31, 32, 33, 34, 35, 36, 37, 38, // A S D F G H J K L
62    44, 45, 46, 47, 48, 49, 50, // Z X C V B N M
63];
64
65/// Parses a `B: …=` capability bitmap into 64-bit words, **least-significant word first**.
66///
67/// The kernel prints these words most-significant first (the last word covers bits 0–63), so
68/// the order is reversed here. Getting this backwards silently inverts every capability test,
69/// which is why it is a separate, directly-tested function.
70pub 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
79/// Returns whether `bit` is set in a low-word-first bitmap.
80fn 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
86/// Returns whether a key bitmap covers the whole alphabetic block ([`ALPHA_KEYS`]).
87pub fn has_alpha_block(key_bits: &[u64]) -> bool {
88    ALPHA_KEYS.iter().all(|&k| bit_set(key_bits, k))
89}
90
91/// Parses the whole of `/proc/bus/input/devices` into records.
92///
93/// Records are separated by blank lines; unrecognised lines are ignored, and a record with no
94/// name is dropped (it cannot be displayed anyway). Pure, so it is tested against a fixture
95/// rather than the host's real hardware.
96pub 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
128/// Classifies one device, consulting `model_name` only when the capabilities are ambiguous.
129///
130/// `model_name` receives the device's sysfs path and returns the HID++ battery model string
131/// when one exists. It is injected rather than read directly so this stays a pure function —
132/// the same parameterised-resolver pattern `display::parse_xrandr_displays_with` uses, and for
133/// the same reason: a test must not depend on what is plugged into the machine running it.
134///
135/// Returns `None` for a device that is neither (a power button, a consumer-control endpoint)
136/// **and** for one that claims both capabilities with no model string to break the tie.
137pub 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        // Ambiguous: a merged HID++ endpoint. Physical devices are not both, so trust the
149        // manufacturer's own description when the driver exposes one, and omit otherwise.
150        (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
163/// Classifies a whole device list into `(keyboards, mice)`, de-duplicated by name.
164///
165/// De-duplication matters on real hardware: one physical peripheral routinely registers
166/// several evdev nodes under the same name (a receiver exposing separate endpoints), and
167/// listing it repeatedly is noise rather than information.
168pub 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/// Reads a device's HID++ battery `model_name`, if the driver exposes one.
192///
193/// `sysfs` is the `S: Sysfs=` value, e.g. `/devices/…/0003:046D:408A.000F/input/input53`; the
194/// owning HID device is two levels up, and `hid-logitech-hidpp` hangs a `power_supply` node
195/// there whose `model_name` carries the manufacturer's description of the device.
196#[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
218/// Detects connected keyboards and pointing devices as `(keyboards, mice)`.
219///
220/// Linux only; returns two empty vectors elsewhere, so both fields simply do not render.
221/// Reads one file (`/proc/bus/input/devices`) plus, only for ambiguous devices, a small sysfs
222/// lookup — no subprocess, no elevation.
223pub 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    /// Verbatim excerpt of `/proc/bus/input/devices` from a Fedora 44 laptop with a Bolt
243    /// receiver, trimmed to the interesting records. Covers every classification branch:
244    /// a plain keyboard, a plain mouse, a touchpad, a `kbd`-handler non-keyboard, and two
245    /// merged HID++ endpoints that are indistinguishable by capability alone.
246    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    /// Resolver standing in for the real HID++ lookup, keyed on the fixture's sysfs paths.
308    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        // The kernel prints the highest word first, so the LAST printed word holds bits 0-63.
321        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        // Junk words are skipped rather than panicking.
325        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        // Out-of-range reads are false, never a panic.
336        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        // These two records are identical in every capability field; only the model name
401        // separates them. This is the case fastfetch gets wrong in both directions.
402        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        // A paired-but-idle device exposes no battery node, so no model name resolves. It must
425        // be reported in neither field rather than guessed into one.
426        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        // One physical peripheral commonly registers several endpoints under one name.
450        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        // A record with handlers but no name is unusable and is dropped.
462        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}