1use 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 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, ¢roids, 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}