Files
USB-AIO/controller/src/main.rs
Denis Stoecklmeier 9dc37d1ece
Some checks failed
Tests / test (push) Has been cancelled
Qualitätsprüfungen und Fehlerpfade härten
2026-10-15 10:05:48 +02:00

313 lines
11 KiB
Rust

mod extended;
use std::{
env, fs,
io::{self, Write},
path::{Path, PathBuf},
process::Command,
time::{SystemTime, UNIX_EPOCH},
};
const DATA: &str = "/mnt/intake-data";
fn prompt(q: &str) -> String {
print!("{}", q);
let _ = io::stdout().flush();
let mut s = String::new();
let _ = io::stdin().read_line(&mut s);
s.trim().to_string()
}
fn run(program: &str, args: &[&str]) -> Result<(), String> {
let st = Command::new(program)
.args(args)
.status()
.map_err(|e| format!("{}: {}", program, e))?;
if st.success() {
Ok(())
} else {
Err(format!("{} exited with {}", program, st))
}
}
fn capture(program: &str, args: &[&str]) -> Result<String, String> {
let o = Command::new(program)
.args(args)
.output()
.map_err(|e| e.to_string())?;
if !o.status.success() {
return Err(String::from_utf8_lossy(&o.stderr).to_string());
}
Ok(String::from_utf8_lossy(&o.stdout).trim().to_string())
}
fn sqlquote(s: &str) -> String {
format!("'{}'", s.replace('\'', "''"))
}
fn db_path(data: &Path) -> PathBuf {
data.join("database/intake.sqlite3")
}
fn sql(data: &Path, statement: &str) -> Result<String, String> {
capture(
"sqlite3",
&[db_path(data).to_str().ok_or("Invalid DB path")?, statement],
)
}
fn init(data: &Path) -> Result<(), String> {
for part in ["database", "reports", "checklists", "config"] {
fs::create_dir_all(data.join(part)).map_err(|e| e.to_string())?;
}
let probe = data.join(".write-test");
fs::write(&probe, b"ok").map_err(|e| format!("Persistent partition not writable: {}", e))?;
let _ = fs::remove_file(probe);
sql(data,"PRAGMA journal_mode=DELETE; CREATE TABLE IF NOT EXISTS scans (id INTEGER PRIMARY KEY AUTOINCREMENT, asset_id TEXT NOT NULL, timestamp INTEGER NOT NULL, report_path TEXT NOT NULL UNIQUE, inspection_path TEXT, inspection_status TEXT NOT NULL DEFAULT 'NOT_TESTED', sync_status TEXT NOT NULL DEFAULT 'PENDING', synced_inventory_id TEXT);")?;
Ok(())
}
fn valid_id(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 64
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
fn latest(data: &Path, id: &str) -> Option<PathBuf> {
let root = data.join("reports").join(id);
let mut matches = Vec::new();
for d in fs::read_dir(root).ok()?.flatten() {
let p = d.path().join("report.json");
if p.is_file() {
let mt = p.metadata().ok()?.modified().ok()?;
matches.push((mt, p));
}
}
matches.sort_by_key(|(m, _)| *m);
matches.pop().map(|(_, p)| p)
}
fn get_time() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn scan(data: &Path, quick: bool) {
let id = prompt("Asset ID: ");
if !valid_id(&id) {
println!("Invalid ID (letters, digits, underscore and hyphen only)");
return;
}
let out = data.join("reports");
let outstr = out.to_string_lossy().to_string();
let mut cmd = Command::new("/usr/local/bin/pc-intake");
cmd.args(["--asset-id", &id, "--output", &outstr]);
if quick {
cmd.arg("--quick");
} else {
cmd.arg("--compile");
}
let checklist = data.join("checklists").join(format!("{}.txt", id));
if checklist.exists() {
cmd.args(["--checklist", &checklist.to_string_lossy()]);
}
println!("Running {} scan...", if quick { "quick" } else { "full" });
match cmd.status() {
Ok(status) if status.success() => {
if let Some(report) = latest(data, &id) {
let result=sql(data,&format!("INSERT OR IGNORE INTO scans(asset_id,timestamp,report_path) VALUES({},{},{});",sqlquote(&id),get_time(),sqlquote(&report.to_string_lossy())));
match result {
Ok(_) => {
println!("Saved: {}", report.display());
println!("Inventory sync: PENDING (not uploaded)");
}
Err(e) => println!("Database write failed: {}. JSON report remains saved.", e),
}
} else {
println!("Scanner exited successfully, but no report found");
}
}
Ok(status) => println!("Scanner failed: {}", status),
Err(e) => println!("Could not launch scanner: {}", e),
}
}
const CHECKS: &[(&str, &str)] = &[
(
"display",
"Show colors/brightness and inspect for dead pixels",
),
(
"keyboard",
"Check every key, including modifiers and function keys",
),
(
"touchpad",
"Verify movement, taps, buttons and scrolling; N/A for desktop",
),
(
"wifi",
"Connect to a network and open a local or remote test service",
),
("audio", "Play left/right sound and check for distortion"),
("microphone", "Record voice and play it back"),
(
"webcam",
"Preview image and verify focus and quality; N/A if absent",
),
(
"usb_ports",
"Insert a known-good test device in each physical port",
),
(
"charging",
"Verify AC adapter, charging status, battery runtime; N/A for desktop",
),
("thermals", "Verify fans, heat and sustained load"),
(
"stability",
"Check for crashes, unexpected resets or kernel errors",
),
];
fn technician(data: &Path) {
let id = prompt("Asset ID for inspection: ");
if !valid_id(&id) {
println!("Invalid ID");
return;
}
let mut rows = Vec::new();
for (key, desc) in CHECKS {
println!("\n{}: {}", key, desc);
if *key == "audio" {
println!("Optional speaker test (if installed): speaker-test -c 2 -t wav");
}
if *key == "webcam" {
println!(
"Optional camera detection: ls /dev/video*; preview requires graphical session"
);
}
loop {
let ans = prompt("[p]ass / [f]ail / [u]ntested / [n]ot applicable: ").to_lowercase();
if let Some(st) = match ans.as_str() {
"p" => Some("PASS"),
"f" => Some("FAIL"),
"u" | "" => Some("NOT_TESTED"),
"n" => Some("NOT_APPLICABLE"),
_ => None,
} {
rows.push((key.to_string(), st.to_string()));
break;
}
println!("Enter p, f, u or n");
}
}
let target = data.join("checklists").join(format!("{}.txt", id));
let mut raw = String::new();
for (k, v) in &rows {
raw.push_str(&format!("{}={}\n", k, v));
}
if let Err(e) = fs::write(&target, raw) {
println!("Could not save checklist: {}", e);
return;
}
let fail = rows.iter().any(|(_, s)| s == "FAIL");
let unknown = rows.iter().any(|(_, s)| s == "NOT_TESTED");
let verdict = if fail {
"FAIL"
} else if unknown {
"INCOMPLETE"
} else {
"COMPLETED"
};
let _=sql(data,&format!("UPDATE scans SET inspection_path={},inspection_status={} WHERE id=(SELECT MAX(id) FROM scans WHERE asset_id={});",sqlquote(&target.to_string_lossy()),sqlquote(verdict),sqlquote(&id)));
println!(
"Inspection {}. Saved {}; re-scan to embed checklist in hardware report.",
verdict,
target.display()
);
}
fn recent(data: &Path) {
let query = "SELECT id || ' | ' || asset_id || ' | ' || inspection_status || ' | ' || sync_status || ' | ' || report_path FROM scans ORDER BY id DESC LIMIT 20;";
match sql(data, query) {
Ok(rows) if rows.is_empty() => println!("No scans yet."),
Ok(rows) => println!("{}", rows),
Err(err) => println!("Database error: {}", err),
}
}
fn help() {
println!("PC Intake AIO v1.7.0 — offline-first technician controller");
println!("Usage: intake-controller [--data PATH] [--self-check]");
}
fn main() {
let args: Vec<String> = env::args().collect();
if args.iter().any(|x| x == "--help") {
help();
return;
}
if args.iter().any(|x| x == "--self-check") {
for tool in [
"sqlite3",
"smartctl",
"ffmpeg",
"mpv",
"glmark2",
"stress-ng",
"memtester",
"speaker-test",
] {
println!(
"{}: {}",
tool,
if Command::new("sh")
.args(["-c", &format!("command -v {} >/dev/null", tool)])
.status()
.map(|s| s.success())
.unwrap_or(false)
{
"FOUND"
} else {
"MISSING"
}
);
}
return;
}
let data = if let Some(p) = args.iter().position(|x| x == "--data") {
PathBuf::from(args.get(p + 1).unwrap_or(&DATA.to_owned()))
} else {
PathBuf::from(DATA)
};
if let Err(e) = init(&data) {
eprintln!("FATAL: {} — refusing to scan without persistent storage", e);
std::process::exit(1)
}
loop {
println!("\n==== PC INTAKE AIO v1.7.0 ====\nStorage: {}\n1 Full scan and local queue\n2 Quick inventory\n3 Guided technician checklist\n4 Recent scans and sync states\n5 Check diagnostic tools\n6 Safely power off\n7 Hardware details (UUID / MAC / RAM slots / screen)\n8 Guided audio / webcam / Wi-Fi test\n9 Installed-system maintenance instructions\n10 CPU and RAM stress test (opt-in)\n",data.display());
match prompt("Select: ").as_str() {
"1" => scan(&data, false),
"2" => scan(&data, true),
"3" => technician(&data),
"4" => recent(&data),
"5" => {
let _ = Command::new(
env::current_exe().unwrap_or_else(|_| PathBuf::from("intake-controller")),
)
.arg("--self-check")
.status();
}
"6" => {
let _ = run("sync", &[]);
let _ = run("systemctl", &["poweroff"]);
break;
}
"7" => extended::device_details(&data),
"8" => extended::guided_tests(&data),
"9" => extended::maintenance_guidance(),
"10" => extended::stress_test(&data),
_ => println!("Unknown selection"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn id_validation() {
assert!(valid_id("PC-0123"));
assert!(!valid_id("../../evil"));
}
#[test]
fn sql_escape() {
assert_eq!(sqlquote("O'Reilly"), "'O''Reilly'");
}
}