Skip to main content

input/
chwd_input.rs

1//! chwd-style deterministic profile selection over fused DMI, udev, and evdev facts.
2//!
3//! Statistical scorers may rank profiles only after their hard predicates
4//! match. They never create capabilities, override a udev role, or turn an
5//! unmatched device into an input device.
6
7use crate::capforge::{knn_scores, tiny_mlp_scores, CapabilityKind};
8use std::collections::HashMap;
9use std::fs;
10use std::path::Path;
11
12const FEATURE_COUNT: usize = 16;
13
14#[derive(Clone, Debug, Default, PartialEq, Eq)]
15pub struct SystemFacts {
16    pub dmi_vendor: String,
17    pub dmi_product: String,
18    pub dmi_product_version: String,
19    pub dmi_board: String,
20    pub cpu_vendor: String,
21    pub thinkpad: bool,
22    pub apple: bool,
23    pub handheld: bool,
24}
25
26#[derive(Clone, Debug)]
27pub struct DeviceFacts<'a> {
28    pub name: &'a str,
29    pub bus: u16,
30    pub vendor: u16,
31    pub product: u16,
32    pub kind: CapabilityKind,
33    pub properties: &'a HashMap<String, String>,
34    pub has_relative_motion: bool,
35    pub has_absolute_xy: bool,
36    pub has_multitouch: bool,
37    pub key_count: usize,
38}
39
40#[derive(Clone, Copy, Debug, Default, PartialEq)]
41pub struct ProfileApply {
42    pub lenovo_x230_motion: bool,
43    pub trackpoint_multiplier: Option<f64>,
44    pub phantom_click_filter: bool,
45}
46
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48enum MatchRule {
49    ThinkPad,
50    Apple,
51    Handheld,
52    DmiProduct(&'static str),
53    Name(&'static str),
54    Kind(CapabilityKind),
55    Udev(&'static str, &'static str),
56}
57
58#[derive(Clone, Copy, Debug)]
59struct Profile {
60    id: &'static str,
61    priority: i32,
62    rules: &'static [MatchRule],
63    centroid: [f64; FEATURE_COUNT],
64    apply: ProfileApply,
65}
66
67#[derive(Clone, Copy, Debug, PartialEq)]
68pub struct ProfileDecision {
69    pub id: &'static str,
70    pub priority: i32,
71    pub score: f64,
72    pub apply: ProfileApply,
73}
74
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76pub enum DetectSource {
77    Profile,
78    MlFallback,
79    Heuristic,
80}
81
82#[derive(Clone, Copy, Debug, PartialEq)]
83pub struct DetectionResult {
84    pub class: CapabilityKind,
85    pub profile_id: &'static str,
86    pub priority: i32,
87    pub confidence: f64,
88    pub source: DetectSource,
89    pub apply: ProfileApply,
90}
91
92#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93pub struct ProfileInfo {
94    pub id: &'static str,
95    pub priority: i32,
96}
97
98#[derive(Clone, Debug)]
99pub struct ScannedDevice {
100    pub path: std::path::PathBuf,
101    pub name: String,
102    pub bus: u16,
103    pub vendor: u16,
104    pub product: u16,
105    pub kind: CapabilityKind,
106    pub properties: HashMap<String, String>,
107    pub has_relative_motion: bool,
108    pub has_absolute_xy: bool,
109    pub has_multitouch: bool,
110    pub key_count: usize,
111}
112
113impl ScannedDevice {
114    pub fn facts(&self) -> DeviceFacts<'_> {
115        DeviceFacts {
116            name: &self.name,
117            bus: self.bus,
118            vendor: self.vendor,
119            product: self.product,
120            kind: self.kind,
121            properties: &self.properties,
122            has_relative_motion: self.has_relative_motion,
123            has_absolute_xy: self.has_absolute_xy,
124            has_multitouch: self.has_multitouch,
125            key_count: self.key_count,
126        }
127    }
128}
129
130const GENERIC_TOUCHPAD_FEATURES: [f64; FEATURE_COUNT] = [
131    0.0, 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
132];
133const GENERIC_MOUSE_FEATURES: [f64; FEATURE_COUNT] = [
134    0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
135];
136
137const PROFILES: &[Profile] = &[
138    Profile {
139        id: "lenovo-x230-touchpad",
140        priority: 100,
141        rules: &[
142            MatchRule::ThinkPad,
143            MatchRule::DmiProduct("x230"),
144            MatchRule::Kind(CapabilityKind::Touchpad),
145        ],
146        centroid: GENERIC_TOUCHPAD_FEATURES,
147        apply: ProfileApply {
148            lenovo_x230_motion: true,
149            trackpoint_multiplier: None,
150            phantom_click_filter: false,
151        },
152    },
153    Profile {
154        id: "thinkpad-p53-elan",
155        priority: 90,
156        rules: &[
157            MatchRule::ThinkPad,
158            MatchRule::DmiProduct("p53"),
159            MatchRule::Name("elan"),
160            MatchRule::Kind(CapabilityKind::Touchpad),
161            // This filter changes click semantics and must be enabled only
162            // from an affected packet recording or an equivalent explicit
163            // device signature, never from DMI and a broad ELAN name alone.
164            MatchRule::Udev("LIBINPUT_RS_P53_PHANTOM_CLICK_SIGNATURE", "1"),
165        ],
166        centroid: GENERIC_TOUCHPAD_FEATURES,
167        apply: ProfileApply {
168            lenovo_x230_motion: false,
169            trackpoint_multiplier: None,
170            phantom_click_filter: true,
171        },
172    },
173    Profile {
174        id: "thinkpad-trackpoint",
175        priority: 80,
176        rules: &[MatchRule::ThinkPad, MatchRule::Name("trackpoint")],
177        centroid: GENERIC_MOUSE_FEATURES,
178        apply: ProfileApply {
179            lenovo_x230_motion: false,
180            trackpoint_multiplier: Some(1.0),
181            phantom_click_filter: false,
182        },
183    },
184    Profile {
185        id: "apple-touchpad",
186        priority: 70,
187        rules: &[MatchRule::Apple, MatchRule::Kind(CapabilityKind::Touchpad)],
188        centroid: GENERIC_TOUCHPAD_FEATURES,
189        apply: ProfileApply {
190            lenovo_x230_motion: false,
191            trackpoint_multiplier: None,
192            phantom_click_filter: false,
193        },
194    },
195    Profile {
196        id: "handheld-touchpad",
197        priority: 60,
198        rules: &[
199            MatchRule::Handheld,
200            MatchRule::Kind(CapabilityKind::Touchpad),
201        ],
202        centroid: GENERIC_TOUCHPAD_FEATURES,
203        apply: ProfileApply {
204            lenovo_x230_motion: false,
205            trackpoint_multiplier: None,
206            phantom_click_filter: false,
207        },
208    },
209    Profile {
210        id: "udev-touchpad",
211        priority: 10,
212        rules: &[MatchRule::Udev("ID_INPUT_TOUCHPAD", "1")],
213        centroid: GENERIC_TOUCHPAD_FEATURES,
214        apply: ProfileApply {
215            lenovo_x230_motion: false,
216            trackpoint_multiplier: None,
217            phantom_click_filter: false,
218        },
219    },
220];
221
222pub fn profile_inventory() -> impl Iterator<Item = ProfileInfo> {
223    PROFILES.iter().map(|profile| ProfileInfo {
224        id: profile.id,
225        priority: profile.priority,
226    })
227}
228
229pub fn scan_event_node(path: &Path) -> std::io::Result<ScannedDevice> {
230    use evdev_upstream::{AbsoluteAxisCode, RelativeAxisCode};
231
232    let mut bits = crate::capforge::CapabilityBits::from_sysfs_event_node(path);
233    let properties = crate::hwdetect::udev_database_properties(path);
234    let sys_device = Path::new("/sys/class/input")
235        .join(path.file_name().unwrap_or_default())
236        .join("device");
237    let mut name = read_trimmed(sys_device.join("name"));
238    let mut bus = read_hex_u16(sys_device.join("id/bustype"));
239    let mut vendor = read_hex_u16(sys_device.join("id/vendor"));
240    let mut product = read_hex_u16(sys_device.join("id/product"));
241
242    if let Ok(device) = evdev_upstream::Device::open(path) {
243        let input_id = device.input_id();
244        name = device.name().unwrap_or("unknown").to_string();
245        bus = input_id.bus_type().0;
246        vendor = input_id.vendor();
247        product = input_id.product();
248        for event_type in device.supported_events().iter() {
249            bits.set_event(event_type.0);
250        }
251        if let Some(codes) = device.supported_keys() {
252            for code in codes.iter() {
253                bits.set_key(code.0);
254            }
255        }
256        if let Some(axes) = device.supported_relative_axes() {
257            for axis in axes.iter() {
258                bits.set_relative(axis.0);
259            }
260        }
261        if let Some(axes) = device.supported_absolute_axes() {
262            for axis in axes.iter() {
263                bits.set_absolute(axis.0);
264            }
265        }
266        for property in device.properties().iter() {
267            bits.set_property(property.0);
268        }
269    } else if !sys_device.is_dir() {
270        return Err(std::io::Error::new(
271            std::io::ErrorKind::NotFound,
272            "event node has no sysfs device",
273        ));
274    }
275
276    Ok(ScannedDevice {
277        path: path.to_path_buf(),
278        name,
279        bus,
280        vendor,
281        product,
282        kind: bits.classify(),
283        properties,
284        has_relative_motion: bits.has_relative(RelativeAxisCode::REL_X.0)
285            || bits.has_relative(RelativeAxisCode::REL_Y.0),
286        has_absolute_xy: bits.has_absolute(AbsoluteAxisCode::ABS_X.0)
287            && bits.has_absolute(AbsoluteAxisCode::ABS_Y.0),
288        has_multitouch: bits.has_absolute(AbsoluteAxisCode::ABS_MT_POSITION_X.0)
289            && bits.has_absolute(AbsoluteAxisCode::ABS_MT_POSITION_Y.0),
290        key_count: bits.key_count(),
291    })
292}
293
294fn read_hex_u16(path: impl AsRef<Path>) -> u16 {
295    u16::from_str_radix(read_trimmed(path).trim_start_matches("0x"), 16).unwrap_or(0)
296}
297
298pub fn scan_system_facts() -> SystemFacts {
299    let dmi_vendor = read_trimmed("/sys/class/dmi/id/sys_vendor");
300    let dmi_product = read_trimmed("/sys/class/dmi/id/product_name");
301    let dmi_product_version = read_trimmed("/sys/class/dmi/id/product_version");
302    let dmi_board = read_trimmed("/sys/class/dmi/id/board_name");
303    let cpu_vendor = fs::read_to_string("/proc/cpuinfo")
304        .ok()
305        .and_then(|contents| {
306            contents.lines().find_map(|line| {
307                line.strip_prefix("vendor_id")
308                    .and_then(|rest| rest.split_once(':'))
309                    .map(|(_, value)| value.trim().to_string())
310            })
311        })
312        .unwrap_or_default();
313    let vendor = dmi_vendor.to_ascii_lowercase();
314    let product = dmi_product.to_ascii_lowercase();
315    let product_version = dmi_product_version.to_ascii_lowercase();
316    let board = dmi_board.to_ascii_lowercase();
317    SystemFacts {
318        thinkpad: vendor.contains("lenovo")
319            && (product.contains("thinkpad")
320                || product_version.contains("thinkpad")
321                || board.contains("thinkpad")),
322        apple: vendor.contains("apple")
323            || product.contains("macbook")
324            || product_version.contains("macbook"),
325        handheld: ["jupiter", "galileo", "steam deck", "rog ally", "rc71"]
326            .iter()
327            .any(|needle| product.contains(needle) || board.contains(needle)),
328        dmi_vendor,
329        dmi_product,
330        dmi_product_version,
331        dmi_board,
332        cpu_vendor,
333    }
334}
335
336fn read_trimmed(path: impl AsRef<Path>) -> String {
337    fs::read_to_string(path)
338        .map(|value| value.trim().to_string())
339        .unwrap_or_default()
340}
341
342fn contains_folded(value: &str, needle: &str) -> bool {
343    value.to_ascii_lowercase().contains(needle)
344}
345
346fn rule_matches(rule: MatchRule, system: &SystemFacts, device: &DeviceFacts<'_>) -> bool {
347    match rule {
348        MatchRule::ThinkPad => system.thinkpad,
349        MatchRule::Apple => system.apple,
350        MatchRule::Handheld => system.handheld,
351        MatchRule::DmiProduct(value) => {
352            contains_folded(&system.dmi_product, value)
353                || contains_folded(&system.dmi_product_version, value)
354        }
355        MatchRule::Name(value) => contains_folded(device.name, value),
356        MatchRule::Kind(kind) => device.kind == kind,
357        MatchRule::Udev(key, value) => device.properties.get(key).is_some_and(|v| v == value),
358    }
359}
360
361fn features(system: &SystemFacts, device: &DeviceFacts<'_>) -> [f64; FEATURE_COUNT] {
362    [
363        f64::from(device.vendor) / f64::from(u16::MAX),
364        f64::from(device.product) / f64::from(u16::MAX),
365        f64::from(device.bus) / 32.0,
366        f64::from(device.kind == CapabilityKind::Touchpad),
367        f64::from(device.has_relative_motion),
368        f64::from(device.has_absolute_xy),
369        f64::from(device.has_multitouch),
370        (device.key_count as f64 / 512.0).min(1.0),
371        f64::from(system.thinkpad),
372        f64::from(system.apple),
373        f64::from(system.handheld),
374        f64::from(contains_folded(device.name, "elan")),
375        f64::from(contains_folded(device.name, "synaptics")),
376        f64::from(contains_folded(device.name, "trackpoint")),
377        f64::from(
378            device
379                .properties
380                .get("ID_INPUT_TOUCHPAD")
381                .is_some_and(|v| v == "1"),
382        ),
383        f64::from(
384            device
385                .properties
386                .get("ID_INPUT_MOUSE")
387                .is_some_and(|v| v == "1"),
388        ),
389    ]
390}
391
392pub fn select_profile(system: &SystemFacts, device: &DeviceFacts<'_>) -> Option<ProfileDecision> {
393    let mut candidates = PROFILES
394        .iter()
395        .filter(|profile| {
396            profile
397                .rules
398                .iter()
399                .all(|rule| rule_matches(*rule, system, device))
400        })
401        .collect::<Vec<_>>();
402    let priority = candidates.iter().map(|profile| profile.priority).max()?;
403    candidates.retain(|profile| profile.priority == priority);
404    if candidates.len() == 1 {
405        let profile = candidates[0];
406        return Some(ProfileDecision {
407            id: profile.id,
408            priority: profile.priority,
409            score: 1.0,
410            apply: profile.apply,
411        });
412    }
413
414    let feature_tensor = features(system, device);
415    let centroids = candidates
416        .iter()
417        .flat_map(|profile| profile.centroid)
418        .collect::<Vec<_>>();
419    let knn = knn_scores(&feature_tensor, &centroids, candidates.len());
420    let hidden_bias = [0.0, 0.0, 0.0, 0.0];
421    let mut input_weights = vec![0.0; FEATURE_COUNT * hidden_bias.len()];
422    for (index, weight) in input_weights.iter_mut().enumerate() {
423        *weight = if index % (FEATURE_COUNT + 1) == 0 {
424            1.0
425        } else {
426            0.0
427        };
428    }
429    let output_weights = vec![0.25; candidates.len() * hidden_bias.len()];
430    let output_bias = vec![0.0; candidates.len()];
431    let mlp = tiny_mlp_scores(
432        &feature_tensor,
433        &input_weights,
434        &hidden_bias,
435        &output_weights,
436        &output_bias,
437    );
438    candidates
439        .into_iter()
440        .zip(knn.into_iter().zip(mlp))
441        .map(|(profile, (knn_score, mlp_score))| ProfileDecision {
442            id: profile.id,
443            priority: profile.priority,
444            score: knn_score * 0.75 + mlp_score * 0.25,
445            apply: profile.apply,
446        })
447        .max_by(|left, right| {
448            left.score
449                .total_cmp(&right.score)
450                .then_with(|| right.id.cmp(left.id))
451        })
452}
453
454pub fn detect_device(
455    system: &SystemFacts,
456    device: &DeviceFacts<'_>,
457    use_ml: bool,
458) -> DetectionResult {
459    if let Some(profile) = select_profile(system, device) {
460        return DetectionResult {
461            class: device.kind,
462            profile_id: profile.id,
463            priority: profile.priority,
464            confidence: 1.0,
465            source: DetectSource::Profile,
466            apply: profile.apply,
467        };
468    }
469    if use_ml {
470        if let Some((class, confidence)) = ml_classify(system, device) {
471            if class == device.kind && confidence >= 0.55 {
472                return DetectionResult {
473                    class,
474                    profile_id: ml_profile_id(class),
475                    priority: 5,
476                    confidence,
477                    source: DetectSource::MlFallback,
478                    apply: ProfileApply::default(),
479                };
480            }
481        }
482    }
483    DetectionResult {
484        class: device.kind,
485        profile_id: "heuristic",
486        priority: 0,
487        confidence: 0.4,
488        source: DetectSource::Heuristic,
489        apply: ProfileApply::default(),
490    }
491}
492
493fn ml_profile_id(class: CapabilityKind) -> &'static str {
494    match class {
495        CapabilityKind::Keyboard => "ml-keyboard",
496        CapabilityKind::Key => "ml-key",
497        CapabilityKind::Mouse => "ml-mouse",
498        CapabilityKind::Touchpad => "ml-touchpad",
499        CapabilityKind::Touchscreen => "ml-touchscreen",
500        CapabilityKind::Tablet => "ml-tablet",
501        CapabilityKind::Joystick => "ml-joystick",
502        CapabilityKind::Switch => "ml-switch",
503        CapabilityKind::Unknown => "ml-unknown",
504    }
505}
506
507fn ml_classify(system: &SystemFacts, device: &DeviceFacts<'_>) -> Option<(CapabilityKind, f64)> {
508    const CLASSES: [CapabilityKind; 6] = [
509        CapabilityKind::Keyboard,
510        CapabilityKind::Mouse,
511        CapabilityKind::Touchpad,
512        CapabilityKind::Touchscreen,
513        CapabilityKind::Tablet,
514        CapabilityKind::Switch,
515    ];
516    const CENTROIDS: [[f64; FEATURE_COUNT]; 6] = [
517        [
518            0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.8, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
519        ],
520        GENERIC_MOUSE_FEATURES,
521        GENERIC_TOUCHPAD_FEATURES,
522        [
523            0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
524        ],
525        [
526            0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
527        ],
528        [0.0; FEATURE_COUNT],
529    ];
530    let tensor = features(system, device);
531    let flat_centroids = CENTROIDS.into_iter().flatten().collect::<Vec<_>>();
532    let mut scores = knn_scores(&tensor, &flat_centroids, CLASSES.len());
533    let hidden_bias = [0.0, 0.0, 0.0, 0.0];
534    let input_weights = (0..FEATURE_COUNT * hidden_bias.len())
535        .map(|index| {
536            if index % (FEATURE_COUNT + 1) == 0 {
537                1.0
538            } else {
539                0.0
540            }
541        })
542        .collect::<Vec<_>>();
543    let output_weights = vec![0.25; CLASSES.len() * hidden_bias.len()];
544    let output_bias = vec![0.0; CLASSES.len()];
545    let mlp = tiny_mlp_scores(
546        &tensor,
547        &input_weights,
548        &hidden_bias,
549        &output_weights,
550        &output_bias,
551    );
552    for (score, mlp_score) in scores.iter_mut().zip(mlp) {
553        *score = *score * 4.0 + mlp_score;
554    }
555    let maximum = scores.iter().copied().reduce(f64::max)?;
556    let normalizer = scores
557        .iter()
558        .map(|score| (score - maximum).exp())
559        .sum::<f64>();
560    let (index, score) = scores
561        .iter()
562        .enumerate()
563        .max_by(|left, right| left.1.total_cmp(right.1))?;
564    Some((CLASSES[index], (*score - maximum).exp() / normalizer))
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570
571    fn facts(name: &str, kind: CapabilityKind) -> DeviceFacts<'_> {
572        DeviceFacts {
573            name,
574            bus: 0x11,
575            vendor: 0x04f3,
576            product: 0x0001,
577            kind,
578            properties: Box::leak(Box::default()),
579            has_relative_motion: false,
580            has_absolute_xy: true,
581            has_multitouch: true,
582            key_count: 3,
583        }
584    }
585
586    #[test]
587    fn p53_phantom_click_filter_requires_an_explicit_device_signature() {
588        let system = SystemFacts {
589            dmi_vendor: "LENOVO".into(),
590            dmi_product: "ThinkPad P53".into(),
591            thinkpad: true,
592            ..SystemFacts::default()
593        };
594        let unsigned = facts("ELAN Touchpad", CapabilityKind::Touchpad);
595        assert!(select_profile(&system, &unsigned).is_none());
596
597        let mut properties = HashMap::new();
598        properties.insert(
599            "LIBINPUT_RS_P53_PHANTOM_CLICK_SIGNATURE".to_string(),
600            "1".to_string(),
601        );
602        let signed = DeviceFacts {
603            properties: &properties,
604            ..unsigned
605        };
606        let decision = select_profile(&system, &signed).unwrap();
607        assert_eq!(decision.id, "thinkpad-p53-elan");
608        assert!(decision.apply.phantom_click_filter);
609    }
610
611    #[test]
612    fn unmatched_device_cannot_be_invented_by_ml() {
613        assert!(select_profile(
614            &SystemFacts::default(),
615            &facts("Unknown sensor", CapabilityKind::Unknown),
616        )
617        .is_none());
618    }
619
620    #[test]
621    fn ml_fallback_cannot_cross_the_capability_lattice() {
622        let unknown = facts("Unknown sensor", CapabilityKind::Unknown);
623        let result = detect_device(&SystemFacts::default(), &unknown, true);
624        assert_eq!(result.class, CapabilityKind::Unknown);
625        assert_ne!(result.source, DetectSource::MlFallback);
626    }
627}