USB-AIO für automatische Geräteprüfung
Some checks failed
Tests / test (push) Has been cancelled

This commit is contained in:
2026-10-14 20:14:36 +02:00
commit 81f06cf6b4
40 changed files with 9956 additions and 0 deletions

483
gui/src/diagnostics.rs Normal file
View File

@@ -0,0 +1,483 @@
//! 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);
}
}