//! Read-only or temporary hardware diagnostics. Never mark a technician check PASS automatically. use std::{ fs, path::Path, process::{Command, Stdio}, thread, time::{Duration, Instant}, }; fn available(tool: &str) -> bool { Command::new("sh") .args(["-c", "command -v \"$1\" >/dev/null 2>&1", "sh", tool]) .status() .map(|s| s.success()) .unwrap_or(false) } fn exec(tool: &str, args: &[&str]) -> Result { let out = Command::new("timeout") .args(["--kill-after=3s", "150s", tool]) .args(args) .output() .map_err(|e| format!("{tool}: {e}"))?; let log = format!( "{}\n{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr) ); if out.status.success() { Ok(log) } else { Err(format!("{tool} ({})\n{log}", out.status)) } } fn temps() -> Vec<(String, f64)> { let mut values = Vec::new(); let Ok(chips) = fs::read_dir("/sys/class/hwmon") else { return values; }; for entry in chips.flatten() { let base = entry.path(); let chip = fs::read_to_string(base.join("name")) .unwrap_or_default() .trim() .to_owned(); let Ok(files) = fs::read_dir(&base) else { continue; }; for file in files.flatten() { let name = file.file_name().to_string_lossy().to_string(); if !name.starts_with("temp") || !name.ends_with("_input") { continue; } let Ok(raw) = fs::read_to_string(file.path()) else { continue; }; let Ok(milli) = raw.trim().parse::() else { continue; }; let c = milli / 1000.0; if !(-20.0..=150.0).contains(&c) { continue; } values.push((format!("{chip}:{name}"), c)); } } values } fn thermal(settings: &crate::workflow::Settings) -> (bool, String) { if !available("stress-ng") { return (false, "UNAVAILABLE: stress-ng fehlt im Live-System".into()); } let baseline = temps(); let start = Instant::now(); let mut child = match Command::new("stress-ng") .args([ "--cpu", "0", "--timeout", &format!("{}s", settings.stress_seconds), "--metrics-brief", ]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() { Ok(c) => c, Err(e) => return (false, format!("stress-ng konnte nicht starten: {e}")), }; let mut samples = 0usize; let mut peak: Option = None; loop { for (_, t) in temps() { peak = Some(peak.map_or(t, |p| p.max(t))); samples += 1; } match child.try_wait() { Ok(Some(_)) => break, Ok(None) => {} Err(e) => return (false, format!("Prozessprüfung fehlgeschlagen: {e}")), } if start.elapsed() > Duration::from_secs(settings.stress_seconds + 15) { let _ = child.kill(); break; } thread::sleep(Duration::from_secs(2)); } let output = match child.wait_with_output() { Ok(o) => o, Err(e) => return (false, format!("Ausgabe fehlt: {e}")), }; let mut log = format!( "Dauer: {:.0}s\nSensorwerte zu Beginn: {:?}\nMesspunkte: {}\nMaximal beobachtet: {}\n{}", start.elapsed().as_secs_f64(), baseline, samples, peak.map(|p| format!("{p:.1} °C")) .unwrap_or("UNAVAILABLE".into()), String::from_utf8_lossy(&output.stderr) ); if let Some(p) = peak { log.push_str(&format!("\nPEAK_TEMPERATURE_C={p}")); } if let Some(p) = peak { if p >= settings.max_temperature_c { log.push_str( "\nWARNUNG: Werkstatt-Temperaturgrenze überschritten; manuelle Prüfung nötig.", ); } } if samples == 0 { log.push_str("\nWARNUNG: Kein Temperatursensor auslesbar."); } ( output.status.success() && samples > 0 && peak .map(|p| p < settings.max_temperature_c) .unwrap_or(false) && start.elapsed() >= Duration::from_secs(settings.stress_seconds.saturating_sub(2)), log, ) } fn stability(settings: &crate::workflow::Settings) -> (bool, String) { if !available("stress-ng") { return (false, "UNAVAILABLE: stress-ng fehlt".into()); } let mem_kib = fs::read_to_string("/proc/meminfo") .ok() .and_then(|s| { s.lines() .find(|l| l.starts_with("MemAvailable:")) .and_then(|l| l.split_whitespace().nth(1)) .and_then(|v| v.parse::().ok()) }) .unwrap_or(0); // Conservatively cap test allocation; never allocate all free RAM in a live USB GUI. if mem_kib < 256 * 1024 { return ( false, "Insufficient available RAM for a bounded stress test / Zu wenig freier RAM".into(), ); } let ram_mib = ((mem_kib / 1024) / 4).clamp(128, 2048); let mut child = match Command::new("stress-ng") .args([ "--cpu", "0", "--vm", "1", "--vm-bytes", &format!("{ram_mib}M"), "--verify", "--timeout", &format!("{}s", settings.stress_seconds), "--metrics-brief", ]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() { Ok(c) => c, Err(e) => return (false, format!("Start fehlgeschlagen: {e}")), }; let start = Instant::now(); let mut peak: Option = None; loop { for (_, v) in temps() { peak = Some(peak.map_or(v, |p| p.max(v))); } match child.try_wait() { Ok(Some(_)) => break, Ok(None) => {} Err(e) => return (false, format!("Prozessfehler: {e}")), } if start.elapsed() > Duration::from_secs(settings.stress_seconds + 15) { let _ = child.kill(); break; } thread::sleep(Duration::from_secs(2)); } match child.wait_with_output(){Ok(o)=>(o.status.success() && peak.map(|p|p=Duration::from_secs(settings.stress_seconds.saturating_sub(2)),format!("CPU-/RAM-Test {:.0}s, RAM-Verbrauch ca. {} MiB, Temp-Spitze {}\nPEAK_TEMPERATURE_C={}\n{}",start.elapsed().as_secs_f64(),ram_mib,peak.map(|p|format!("{p:.1}°C")).unwrap_or("UNKNOWN".into()),peak.map(|p|p.to_string()).unwrap_or_default(),String::from_utf8_lossy(&o.stderr))),Err(e)=>(false,format!("Ausgabe fehlgeschlagen: {e}"))} } fn microphone(settings: &crate::workflow::Settings) -> (bool, String) { if !available("arecord") || !available("aplay") { return ( false, "UNAVAILABLE: alsa-utils / arecord / aplay fehlen".into(), ); } let path = format!( "/tmp/csr-audio-{}-{}.wav", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_nanos() ); let record = exec( "arecord", &[ "-q", "-d", &settings.recording_seconds.to_string(), "-f", "S16_LE", "-r", "16000", "-c", "1", &path, ], ); let result = match record { Err(e) => (false, format!("Mikrofonaufnahme fehlgeschlagen:\n{e}")), Ok(_) => match exec("aplay", &["-q", &path]) { Ok(_) => ( true, format!( "{} Sekunden aufgenommen und abgespielt. Bitte Tonqualität bestätigen.", settings.recording_seconds ), ), Err(e) => (false, format!("Wiedergabe fehlgeschlagen: {e}")), }, }; if Path::new(&path).exists() { let _ = fs::remove_file(&path); } result } fn camera(settings: &crate::workflow::Settings) -> (bool, String) { if !Path::new("/dev/video0").exists() { return (false, "UNAVAILABLE: keine Kamera unter /dev/video0".into()); } if !available("ffplay") { return (false, "UNAVAILABLE: ffplay fehlt".into()); } let out = exec( "timeout", &[ &format!("{}s", settings.camera_seconds), "ffplay", "-hide_banner", "-loglevel", "error", "-f", "video4linux2", "-i", "/dev/video0", ], ); match out { Ok(s) => ( true, format!("Vorschau beendet. Sichtprüfung erforderlich.\n{s}"), ), Err(s) => { // timeout exits 124 after a normal 25s bounded preview. if s.contains("124") { ( true, format!( "{} Sekunden Vorschau beendet. Sichtprüfung erforderlich.", settings.camera_seconds ), ) } else { (false, s) } } } } fn run_legacy(tool: &str, settings: &crate::workflow::Settings) -> (bool, String) { match tool { "audio" => match exec("speaker-test", &["-c", "2", "-t", "wav", "-l", "1"]) { Ok(s) => ( true, format!("Linker/rechter Testkanal ausgegeben. Bitte Hörtest bestätigen.\n{s}"), ), Err(s) => (false, s), }, "microphone" => microphone(settings), "camera" => camera(settings), "thermals" => thermal(settings), "stability" => stability(settings), "wifi" => { let service = exec("nmcli", &["general", "status"]); match service { Err(s) => (false, format!("NetworkManager nicht bereit: {s}")), Ok(status) => { let dev = exec("nmcli", &["device", "status"]).unwrap_or_else(|e| e); let scan = exec( "nmcli", &["-f", "IN-USE,SSID,SIGNAL", "device", "wifi", "list"], ) .unwrap_or_else(|e| e); (true,format!("Status:\n{status}\nGeräte:\n{dev}\nNetze:\n{scan}\nLAN trennen und WLAN-Verbindung aktiv testen; Sichtbarkeit ist noch kein PASS.")) } } } "video" => ( true, "Kameratest bitte über Kameravorschau durchführen.".into(), ), _ => (false, "Unbekannter Diagnosetest".into()), } } #[derive(Clone, Debug)] pub struct Diagnostic { pub outcome: crate::workflow::Outcome, pub text: String, } pub fn run(tool: &str, settings: &crate::workflow::Settings) -> Diagnostic { use crate::workflow::Outcome; let required: &[&str] = match tool { "audio" => &["speaker-test"], "microphone" => &["arecord", "aplay"], "webcam" => &["ffplay", "timeout"], "thermals" | "stability" => &["stress-ng"], "usb_ports" => &["lsusb"], _ => &[], }; if !available("timeout") { return Diagnostic { outcome: Outcome::Unavailable, text: "timeout tool missing / fehlt".into(), }; } let missing: Vec<_> = required.iter().filter(|p| !available(p)).copied().collect(); if !missing.is_empty() { return Diagnostic { outcome: Outcome::Unavailable, text: format!("Missing tools / Fehlende Werkzeuge: {}", missing.join(", ")), }; } if let Some(disk) = tool.strip_prefix("disk:") { if !disk .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') { return Diagnostic { outcome: Outcome::SetupProblem, text: "Invalid disk name".into(), }; } if !available("smartctl") { return Diagnostic { outcome: Outcome::Unavailable, text: "smartctl missing / fehlt".into(), }; } return match Command::new("timeout") .args(["--kill-after=3s", "30s", "smartctl"]) .args(["-j", "-H", "-A", &format!("/dev/{disk}")]) .output() { Ok(o) => { let v: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap_or_default(); let outcome = match v .pointer("/smart_status/passed") .and_then(serde_json::Value::as_bool) { Some(false) => Outcome::HardwareFailure, Some(true) => Outcome::NeedsReview, None => Outcome::SetupProblem, }; Diagnostic { outcome, text: format!( "{}\n{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr) ), } } Err(e) => Diagnostic { outcome: Outcome::SetupProblem, text: e.to_string(), }, }; } if tool == "charging" { let mut lines = Vec::new(); if let Ok(entries) = fs::read_dir("/sys/class/power_supply") { for e in entries.flatten() { let p = e.path(); let read = |k: &str| { fs::read_to_string(p.join(k)) .unwrap_or_default() .trim() .to_owned() }; lines.push(format!("{}: type={} status={} charge={}% online={} energy_full={} energy_full_design={} charge_full={} charge_full_design={}",e.file_name().to_string_lossy(),read("type"),read("status"),read("capacity"),read("online"),read("energy_full"),read("energy_full_design"),read("charge_full"),read("charge_full_design"))); } } return Diagnostic { outcome: if lines.is_empty() { Outcome::Unavailable } else { Outcome::NeedsReview }, text: if lines.is_empty() { "No power supply data / Keine Akkudaten".into() } else { lines.join("\n") }, }; } if tool == "usb_ports" { return match exec("lsusb", &[]) { Ok(text) => Diagnostic { outcome: Outcome::NeedsReview, text, }, Err(text) => Diagnostic { outcome: Outcome::SetupProblem, text, }, }; } let (ok, text) = run_legacy(if tool == "webcam" { "camera" } else { tool }, settings); // Command failures are setup problems, never proof of a broken component. Diagnostic { outcome: if text.starts_with("UNAVAILABLE") || (tool == "thermals" && text.contains("Messpunkte: 0")) { Outcome::Unavailable } else if peak_temperature(&text) .map(|v| v >= settings.max_temperature_c) .unwrap_or(false) { Outcome::NeedsReview } else if ok { Outcome::NeedsReview } else { Outcome::SetupProblem }, text, } } pub fn peak_temperature(text: &str) -> Option { text.lines().find_map(|l| { l.strip_prefix("PEAK_TEMPERATURE_C=") .and_then(|s| s.parse::().ok()) .filter(|v| v.is_finite()) }) } #[cfg(test)] mod tests { use super::*; #[test] fn peak_is_machine_readable_and_finite() { assert_eq!( peak_temperature("log\nPEAK_TEMPERATURE_C=94.5\nend"), Some(94.5) ); assert_eq!(peak_temperature("PEAK_TEMPERATURE_C=NaN"), None); } }