484 lines
16 KiB
Rust
484 lines
16 KiB
Rust
//! 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<String, String> {
|
|
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::<f64>() 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<f64> = 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::<u64>().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<f64> = 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<settings.max_temperature_c).unwrap_or(true) && start.elapsed()>=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<f64> {
|
|
text.lines().find_map(|l| {
|
|
l.strip_prefix("PEAK_TEMPERATURE_C=")
|
|
.and_then(|s| s.parse::<f64>().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);
|
|
}
|
|
}
|