From 31ac17dd411bec541fbd86f087bf92624d7758a7 Mon Sep 17 00:00:00 2001 From: Denis Stoecklmeier Date: Thu, 15 Oct 2026 10:12:29 +0200 Subject: [PATCH] Offene Werkstatt-Issues umsetzen --- README.md | 1 + controller/src/extended.rs | 20 ++++++++++++--- gui/src/diagnostics.rs | 49 +++++++++++++++++++++++++++++++++++- gui/src/main.rs | 42 +++++++++++++++++++++++++++++++ gui/src/workflow.rs | 5 ++++ live/intake-login.sh | 13 ++++++++++ scripts/inventory-sync.py | 10 +++++++- tests/test_inventory_sync.py | 11 ++++++++ 8 files changed, 146 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 879a615..b47154f 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Reparaturen, Berichte und die Synchronisationswarteschlange sind über das Menü - automatische Hardware-ID und serverseitig vergebene Gerätenummer - Schutz vor doppelten Datensätzen - gespeichertes Werkstatt-WLAN mit geschütztem Passwort +- umschaltbare deutsche oder US-Tastaturbelegung - geräteabhängige Tests für Akku, Touchpad, Webcam und mehrere Laufwerke - getrennte Ergebnisse für Hardwarefehler, nicht verfügbaren Test, Einrichtungsproblem, nicht geprüft und nicht vorhanden diff --git a/controller/src/extended.rs b/controller/src/extended.rs index 1a1e0b8..2428011 100644 --- a/controller/src/extended.rs +++ b/controller/src/extended.rs @@ -101,6 +101,20 @@ fn ethernet_connected() -> bool { .iter() .any(|(_, kind, carrier, _)| kind == "LAN/other" && carrier == "1") } +fn mobile_device() -> bool { + let chassis = read_trim("/sys/class/dmi/id/chassis_type") + .or_else(|| read_trim("/run/csr-intake/chassis_type")) + .unwrap_or_default(); + matches!( + chassis.as_str(), + "8" | "9" | "10" | "11" | "14" | "30" | "31" | "32" + ) || fs::read_dir("/sys/class/power_supply").is_ok_and(|entries| { + entries.flatten().any(|entry| { + read_trim(&entry.path().join("type").to_string_lossy()) + .is_some_and(|kind| kind == "Battery") + }) + }) +} fn memory_dmi() -> (String, String) { let text = match output("dmidecode", &["-t", "memory"]) { Some(s) => s, @@ -257,8 +271,8 @@ pub fn guided_tests(data: &Path) { println!("Invalid asset ID"); return; } - println!("\nNETWORK PRECHECK: Before Wi-Fi test, unplug Ethernet if one is connected."); - if ethernet_connected() { + println!("\nNETWORK PRECHECK"); + if mobile_device() && ethernet_connected() { println!("WARNING: Wired network link detected. Unplug LAN cable before Wi-Fi test."); let _ = ask("After unplugging LAN press Enter to recheck..."); if ethernet_connected() { @@ -267,7 +281,7 @@ pub fn guided_tests(data: &Path) { } else { println!("No active wired link detected. Wireless testing may proceed."); } - let wifi = if ethernet_connected() { + let wifi = if mobile_device() && ethernet_connected() { "NOT_TESTED" } else { println!( diff --git a/gui/src/diagnostics.rs b/gui/src/diagnostics.rs index f61fe3e..a68a94c 100644 --- a/gui/src/diagnostics.rs +++ b/gui/src/diagnostics.rs @@ -31,6 +31,38 @@ fn exec(tool: &str, args: &[&str]) -> Result { Err(format!("{tool} ({})\n{log}", out.status)) } } +fn mobile_chassis(value: &str) -> bool { + matches!( + value.trim(), + "8" | "9" | "10" | "11" | "14" | "30" | "31" | "32" + ) +} +fn mobile_device() -> bool { + let chassis = fs::read_to_string("/sys/class/dmi/id/chassis_type") + .or_else(|_| fs::read_to_string("/run/csr-intake/chassis_type")) + .unwrap_or_default(); + if mobile_chassis(&chassis) { + return true; + } + fs::read_dir("/sys/class/power_supply").is_ok_and(|entries| { + entries.flatten().any(|entry| { + fs::read_to_string(entry.path().join("type")).is_ok_and(|kind| kind.trim() == "Battery") + }) + }) +} +fn wired_link_active() -> bool { + fs::read_dir("/sys/class/net").is_ok_and(|entries| { + entries.flatten().any(|entry| { + let path = entry.path(); + entry.file_name() != "lo" + && !path.join("wireless").exists() + && fs::read_to_string(path.join("carrier")).is_ok_and(|v| v.trim() == "1") + }) + }) +} +pub fn needs_lan_disconnect() -> bool { + mobile_device() && wired_link_active() +} fn temps() -> Vec<(String, f64)> { let mut values = Vec::new(); let Ok(chips) = fs::read_dir("/sys/class/hwmon") else { @@ -316,7 +348,12 @@ fn run_legacy(tool: &str, settings: &crate::workflow::Settings) -> (bool, String &["-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.")) + let lan = if needs_lan_disconnect() { + "\nWARNUNG: Mobiles Gerät mit aktiver LAN-Verbindung. LAN-Kabel vor dem WLAN-Test entfernen." + } else { + "" + }; + (true,format!("Status:\n{status}\nGeräte:\n{dev}\nNetze:\n{scan}{lan}\nWLAN-Verbindung aktiv testen; Sichtbarkeit ist noch kein PASS.")) } } } @@ -479,4 +516,14 @@ mod tests { ); assert_eq!(peak_temperature("PEAK_TEMPERATURE_C=NaN"), None); } + + #[test] + fn portable_chassis_types_are_recognized() { + for value in ["8", "9", "10", "11", "14", "30", "31", "32"] { + assert!(mobile_chassis(value)); + } + for value in ["3", "7", "13", "15", "unknown"] { + assert!(!mobile_chassis(value)); + } + } } diff --git a/gui/src/main.rs b/gui/src/main.rs index f732026..286ab73 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -650,6 +650,15 @@ impl App { )); if let Some(d) = &self.station_device { ui.heading(format!("#{}", d["number"])); + match d["_registration"].as_str() { + Some("NEW") => { + ui.colored_label(GREEN, tr(en, "Neu im Inventory registriert", "Newly registered in inventory")); + } + Some("EXISTING") => { + ui.label(tr(en, "Bereits im Inventory vorhanden", "Existing inventory device")); + } + _ => {} + } let stage = match d["stage"].as_str().unwrap_or("") { "INTAKE" => "Eingang", "INSPECTION" => "Inspektion", @@ -912,6 +921,16 @@ impl App { } ui.add_enabled_ui(!conflict, |ui| { if key == "wifi" { + if diagnostics::needs_lan_disconnect() { + ui.colored_label( + ORANGE, + tr( + en, + "LAN-Kabel entfernen: Bei diesem mobilen Gerät muss der WLAN-Test ohne aktive Kabelverbindung erfolgen.", + "Disconnect Ethernet: test Wi-Fi on this mobile device without an active wired connection.", + ), + ); + } self.wifi.set_language(en); let was_busy = self.wifi.busy(); self.wifi.ui(ui, self.receiver.is_some()); @@ -1437,6 +1456,29 @@ impl App { ui.selectable_value(&mut self.settings_draft.language, "de".into(), "Deutsch"); ui.selectable_value(&mut self.settings_draft.language, "en".into(), "English"); }); + ui.label(tr(en, "Tastaturbelegung", "Keyboard layout")); + egui::ComboBox::from_id_salt("keyboard-layout") + .selected_text(match self.settings_draft.keyboard_layout.as_str() { + "us" => "English (US)", + _ => "Deutsch (DE)", + }) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.settings_draft.keyboard_layout, + "de".into(), + "Deutsch (DE)", + ); + ui.selectable_value( + &mut self.settings_draft.keyboard_layout, + "us".into(), + "English (US)", + ); + }); + ui.small(tr( + en, + "Die Tastaturbelegung wird beim nächsten Start des USB-Systems angewendet.", + "The keyboard layout is applied on the next USB-system boot.", + )); ui.label(tr(en, "WLAN-Testserver", "Wi-Fi test server")); ui.text_edit_singleline(&mut self.settings_draft.test_url); ui.horizontal(|ui| { diff --git a/gui/src/workflow.rs b/gui/src/workflow.rs index 222e003..cd8c282 100644 --- a/gui/src/workflow.rs +++ b/gui/src/workflow.rs @@ -86,6 +86,7 @@ pub fn init_index(data: &Path) -> Result<(), String> { #[serde(default)] pub struct Settings { pub language: String, + pub keyboard_layout: String, pub test_url: String, pub stress_seconds: u64, pub recording_seconds: u64, @@ -98,6 +99,7 @@ impl Default for Settings { fn default() -> Self { Self { language: "de".into(), + keyboard_layout: "de".into(), test_url: "https://example.com".into(), stress_seconds: 120, recording_seconds: 5, @@ -113,6 +115,9 @@ impl Settings { if !["de", "en"].contains(&self.language.as_str()) { return Err("Language must be de or en".into()); } + if !["de", "us"].contains(&self.keyboard_layout.as_str()) { + return Err("Keyboard layout must be de or us".into()); + } if !(self.test_url.starts_with("https://") || self.test_url.starts_with("http://")) || self.test_url.contains(['\n', '\r']) { diff --git a/live/intake-login.sh b/live/intake-login.sh index 0f1487e..31ac085 100644 --- a/live/intake-login.sh +++ b/live/intake-login.sh @@ -4,6 +4,19 @@ [[ -z "${WAYLAND_DISPLAY:-}" ]] || return 0 export WLR_RENDERER_ALLOW_SOFTWARE=1 export XDG_SESSION_TYPE=wayland +keyboard_layout=$(python3 - /mnt/intake-data/config/workshop.json <<'PY' +import json +import pathlib +import sys + +try: + value = json.loads(pathlib.Path(sys.argv[1]).read_text()).get('keyboard_layout', 'de') +except (OSError, ValueError): + value = 'de' +print(value if value in ('de', 'us') else 'de') +PY +) +export XKB_DEFAULT_LAYOUT="$keyboard_layout" if [[ ! -w /mnt/intake-data ]]; then printf '\nCS-R Intake: data partition not writable; no scan can be saved.\n' >&2 printf 'Press Enter for diagnostic console...' diff --git a/scripts/inventory-sync.py b/scripts/inventory-sync.py index 2489685..d39c63e 100644 --- a/scripts/inventory-sync.py +++ b/scripts/inventory-sync.py @@ -111,7 +111,12 @@ def api_request(base, key, method, suffix, payload=None, timeout=12, ca_file=Non if ca_file: context.load_verify_locations(cafile=str(ca_file)) with urllib.request.urlopen(request, timeout=timeout, context=context) as res: - return json.loads(res.read(1024 * 1024).decode('utf-8')) + result = json.loads(res.read(1024 * 1024).decode('utf-8')) + if method == 'POST' and suffix.strip('/') == 'devices/resolve': + status = getattr(res, 'status', None) + if status in (200, 201): + result['_registration'] = 'NEW' if status == 201 else 'EXISTING' + return result def credentials(config): base = config.get('url', '') @@ -346,9 +351,12 @@ def run_station(root,action,report_path): else: report=json.loads(report_path.read_text()) if report_path else station_identity() device=station_resolve(config,report) + registration=device.get('_registration') if action=='intake' and device['stage']=='INTAKE': device=station_api(config,'POST',f"devices/{device['number']}/intake/",{ 'hwid':hardware_id(report),'expected_revision':device['revision'],'specifications':map_fields(report)}) + if registration: + device['_registration']=registration if action == 'intake' and report_path: raw=report_path.read_bytes(); sha=hashlib.sha256(raw).hexdigest() with contextlib.closing(sqlite3.connect(root/'database/intake.sqlite3')) as db, db: diff --git a/tests/test_inventory_sync.py b/tests/test_inventory_sync.py index d037061..3689ab3 100644 --- a/tests/test_inventory_sync.py +++ b/tests/test_inventory_sync.py @@ -157,6 +157,17 @@ class ConnectionTests(unittest.TestCase): self.assertTrue(context.check_hostname) self.assertIs(request.call_args.kwargs['context'], context) + def test_resolve_exposes_new_or_existing_registration(self): + for status, expected in ((201, 'NEW'), (200, 'EXISTING')): + with self.subTest(status=status), patch.object(bridge.urllib.request, 'urlopen') as request: + response = request.return_value.__enter__.return_value + response.status = status + response.read.return_value = b'{"number": 42}' + result = bridge.api_request( + 'https://inventory.test', 'test-key', 'POST', 'devices/resolve/', {} + ) + self.assertEqual(result['_registration'], expected) + def test_network_errors_are_actionable_without_raw_secrets(self): cases = [ (bridge.ssl.SSLCertVerificationError(1, 'secret'), 'certificate'),