diff --git a/local/docs/USB-VALIDATION-RUNBOOK.md b/local/docs/USB-VALIDATION-RUNBOOK.md new file mode 100644 index 00000000..d085e46a --- /dev/null +++ b/local/docs/USB-VALIDATION-RUNBOOK.md @@ -0,0 +1,103 @@ +# Red Bear OS USB Validation Runbook + +This runbook is the canonical operator path for exercising the USB stack on Red Bear OS. + +It does not claim that USB is broadly solved. Its job is to make the current QEMU-validated USB +workload reproducible and honest. + +## Goal + +Produce one or both of the following: + +- a successful USB stack validation via `redbear-usb-check` inside the guest +- a repeatable QEMU/UEFI validation log via `./local/scripts/test-usb-qemu.sh --check` + +## Path A - Host-side QEMU validation + +Use this when the host supports the repo's normal x86_64 QEMU/UEFI flow. + +### On the host + +Build the tracked desktop profile first: + +```bash +./local/scripts/build-redbear.sh redbear-desktop +``` + +Then run the automated QEMU harness: + +```bash +./local/scripts/test-usb-qemu.sh --check +``` + +What that harness does today: + +1. boots `redbear-desktop` in QEMU with `qemu-xhci`, USB keyboard, USB tablet, and USB mass storage +2. captures the full boot log over serial +3. checks for xHCI interrupt-driven mode in the log +4. checks for USB HID driver spawn +5. checks for USB SCSI driver spawn +6. checks for BOS descriptor processing (or graceful fallback for USB 2 devices) +7. checks that no crash-class errors appear in the log + +### Artifact to preserve + +- the full terminal log from `./local/scripts/test-usb-qemu.sh --check` + +## Path B - Interactive guest validation + +Use this when you want to inspect the runtime manually inside the guest. + +### On the host + +```bash +./local/scripts/test-usb-qemu.sh redbear-desktop +``` + +### Inside the guest + +Run the packaged checker directly: + +```bash +redbear-usb-check +``` + +Expected output: + +``` +redbear-usb-check: found N usb scheme entries: [...] +redbear-usb-check: xhci.0 -> M ports +redbear-usb-check: port 1 -> vendor:product [device name] +redbear-usb-check: port 2 -> vendor:product [device name] [SS] +redbear-usb-check: xhci controllers: ["xhci.0"] +redbear-usb-check: all checks passed +``` + +The checker walks `/scheme/usb/` and `/scheme/xhci/` to verify that the xHCI controller is +enumerated, ports have devices attached, and device descriptors are readable. + +## What this validates + +- xHCI controller initialization +- USB device enumeration and descriptor fetching +- BOS/SuperSpeed capability detection +- HID class driver spawning (keyboard/tablet) +- SCSI class driver spawning (mass storage) +- No panic or crash-class errors in USB daemons + +## What this does not validate + +- Real hardware USB controllers (QEMU qemu-xhci only) +- Hub topology (direct-attached devices only in the default harness) +- USB 3 SuperSpeed data paths +- Isochronous or streaming transfers +- Hot-plug stress testing +- USB device mode / OTG / USB-C + +## Existing USB test scripts + +| Script | What it tests | +|--------|---------------| +| `test-usb-qemu.sh --check` | Full USB stack (xHCI + HID + SCSI + BOS) | +| `test-usb-storage-qemu.sh` | USB mass storage autospawn | +| `test-xhci-irq-qemu.sh --check` | xHCI interrupt delivery mode | diff --git a/local/recipes/system/redbear-hwutils/source/Cargo.toml b/local/recipes/system/redbear-hwutils/source/Cargo.toml index 96ec9594..37a1ea3f 100644 --- a/local/recipes/system/redbear-hwutils/source/Cargo.toml +++ b/local/recipes/system/redbear-hwutils/source/Cargo.toml @@ -67,6 +67,10 @@ path = "src/bin/redbear-phase-iommu-check.rs" name = "redbear-phase-dma-check" path = "src/bin/redbear-phase-dma-check.rs" +[[bin]] +name = "redbear-usb-check" +path = "src/bin/redbear-usb-check.rs" + [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/local/recipes/system/redbear-hwutils/source/src/bin/lspci.rs b/local/recipes/system/redbear-hwutils/source/src/bin/lspci.rs index b3c1a255..f3c894b5 100644 --- a/local/recipes/system/redbear-hwutils/source/src/bin/lspci.rs +++ b/local/recipes/system/redbear-hwutils/source/src/bin/lspci.rs @@ -2,10 +2,12 @@ use std::fs; use std::process; use redbear_hwutils::{parse_args, parse_pci_location, PciLocation}; +use redox_driver_sys::pci::PciDeviceInfo; +use redox_driver_sys::quirks::{lookup_pci_quirks, PciQuirkFlags}; const USAGE: &str = "Usage: lspci\nList PCI devices exposed by /scheme/pci."; -#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] struct PciDeviceSummary { location: PciLocation, vendor_id: u16, @@ -14,6 +16,9 @@ struct PciDeviceSummary { subclass: u8, prog_if: u8, revision: u8, + subvendor_id: u16, + subdevice_id: u16, + quirk_flags: PciQuirkFlags, } fn main() { @@ -27,14 +32,118 @@ fn main() { } } +fn format_quirk_flags(flags: PciQuirkFlags) -> String { + let mut names = Vec::new(); + if flags.contains(PciQuirkFlags::NO_MSI) { + names.push("no_msi"); + } + if flags.contains(PciQuirkFlags::NO_MSIX) { + names.push("no_msix"); + } + if flags.contains(PciQuirkFlags::FORCE_LEGACY_IRQ) { + names.push("force_legacy_irq"); + } + if flags.contains(PciQuirkFlags::NO_PM) { + names.push("no_pm"); + } + if flags.contains(PciQuirkFlags::NO_D3COLD) { + names.push("no_d3cold"); + } + if flags.contains(PciQuirkFlags::NO_ASPM) { + names.push("no_aspm"); + } + if flags.contains(PciQuirkFlags::NEED_IOMMU) { + names.push("need_iommu"); + } + if flags.contains(PciQuirkFlags::NO_IOMMU) { + names.push("no_iommu"); + } + if flags.contains(PciQuirkFlags::DMA_32BIT_ONLY) { + names.push("dma_32bit_only"); + } + if flags.contains(PciQuirkFlags::RESIZE_BAR) { + names.push("resize_bar"); + } + if flags.contains(PciQuirkFlags::DISABLE_BAR_SIZING) { + names.push("disable_bar_sizing"); + } + if flags.contains(PciQuirkFlags::NEED_FIRMWARE) { + names.push("need_firmware"); + } + if flags.contains(PciQuirkFlags::DISABLE_ACCEL) { + names.push("disable_accel"); + } + if flags.contains(PciQuirkFlags::FORCE_VRAM_ONLY) { + names.push("force_vram_only"); + } + if flags.contains(PciQuirkFlags::NO_USB3) { + names.push("no_usb3"); + } + if flags.contains(PciQuirkFlags::RESET_DELAY_MS) { + names.push("reset_delay_ms"); + } + if flags.contains(PciQuirkFlags::NO_STRING_FETCH) { + names.push("no_string_fetch"); + } + if flags.contains(PciQuirkFlags::BAD_EEPROM) { + names.push("bad_eeprom"); + } + if flags.contains(PciQuirkFlags::BUS_MASTER_DELAY) { + names.push("bus_master_delay"); + } + if flags.contains(PciQuirkFlags::WRONG_CLASS) { + names.push("wrong_class"); + } + if flags.contains(PciQuirkFlags::BROKEN_BRIDGE) { + names.push("broken_bridge"); + } + if flags.contains(PciQuirkFlags::NO_RESOURCE_RELOC) { + names.push("no_resource_reloc"); + } + names.join(",") +} + +fn lookup_quirks( + vendor_id: u16, + device_id: u16, + revision: u8, + class_code: u8, + subclass: u8, + prog_if: u8, + subvendor_id: u16, + subdevice_id: u16, +) -> PciQuirkFlags { + let info = PciDeviceInfo { + location: redox_driver_sys::pci::PciLocation { + segment: 0, + bus: 0, + device: 0, + function: 0, + }, + vendor_id, + device_id, + subsystem_vendor_id: subvendor_id, + subsystem_device_id: subdevice_id, + revision, + class_code, + subclass, + prog_if, + header_type: 0, + irq: None, + bars: Vec::new(), + capabilities: Vec::new(), + }; + lookup_pci_quirks(&info) +} + fn run() -> Result<(), String> { parse_args("lspci", USAGE, std::env::args())?; let mut devices = collect_devices()?; - devices.sort(); + devices.sort_by_key(|d| d.location); for device in devices { - println!( + print!( "{} class {:02x}:{:02x}.{:02x} vendor {:04x} device {:04x} rev {:02x}", device.location, device.class_code, @@ -44,6 +153,10 @@ fn run() -> Result<(), String> { device.device_id, device.revision, ); + if !device.quirk_flags.is_empty() { + print!(" quirks: {}", format_quirk_flags(device.quirk_flags)); + } + println!(); } Ok(()) @@ -79,14 +192,44 @@ fn collect_devices() -> Result, String> { continue; } + let vendor_id = u16::from_le_bytes([config[0x00], config[0x01]]); + let device_id = u16::from_le_bytes([config[0x02], config[0x03]]); + let revision = config[0x08]; + let prog_if = config[0x09]; + let subclass = config[0x0A]; + let class_code = config[0x0B]; + + let (subvendor_id, subdevice_id) = if config.len() >= 0x30 { + ( + u16::from_le_bytes([config[0x2C], config[0x2D]]), + u16::from_le_bytes([config[0x2E], config[0x2F]]), + ) + } else { + (0xFFFF, 0xFFFF) + }; + + let quirk_flags = lookup_quirks( + vendor_id, + device_id, + revision, + class_code, + subclass, + prog_if, + subvendor_id, + subdevice_id, + ); + devices.push(PciDeviceSummary { location, - vendor_id: u16::from_le_bytes([config[0x00], config[0x01]]), - device_id: u16::from_le_bytes([config[0x02], config[0x03]]), - revision: config[0x08], - prog_if: config[0x09], - subclass: config[0x0A], - class_code: config[0x0B], + vendor_id, + device_id, + revision, + prog_if, + subclass, + class_code, + subvendor_id, + subdevice_id, + quirk_flags, }); } diff --git a/local/recipes/system/redbear-hwutils/source/src/bin/lsusb.rs b/local/recipes/system/redbear-hwutils/source/src/bin/lsusb.rs index 9aed8ab3..7898bf01 100644 --- a/local/recipes/system/redbear-hwutils/source/src/bin/lsusb.rs +++ b/local/recipes/system/redbear-hwutils/source/src/bin/lsusb.rs @@ -4,6 +4,7 @@ use std::process; use std::str::FromStr; use redbear_hwutils::{describe_usb_device, parse_args}; +use redox_driver_sys::quirks::{lookup_usb_quirks, UsbQuirkFlags}; use serde::Deserialize; const USAGE: &str = "Usage: lsusb\nList USB devices exposed by native usb.* schemes."; @@ -159,6 +160,38 @@ fn main() { } } +fn format_usb_quirk_flags(flags: UsbQuirkFlags) -> String { + let mut names = Vec::new(); + if flags.contains(UsbQuirkFlags::NO_STRING_FETCH) { + names.push("no_string_fetch"); + } + if flags.contains(UsbQuirkFlags::RESET_DELAY) { + names.push("reset_delay"); + } + if flags.contains(UsbQuirkFlags::NO_USB3) { + names.push("no_usb3"); + } + if flags.contains(UsbQuirkFlags::NO_SET_CONFIG) { + names.push("no_set_config"); + } + if flags.contains(UsbQuirkFlags::NO_SUSPEND) { + names.push("no_suspend"); + } + if flags.contains(UsbQuirkFlags::NEED_RESET) { + names.push("need_reset"); + } + if flags.contains(UsbQuirkFlags::BAD_DESCRIPTOR) { + names.push("bad_descriptor"); + } + if flags.contains(UsbQuirkFlags::NO_LPM) { + names.push("no_lpm"); + } + if flags.contains(UsbQuirkFlags::NO_U1U2) { + names.push("no_u1u2"); + } + names.join(",") +} + fn run() -> Result<(), String> { parse_args("lsusb", USAGE, std::env::args())?; @@ -175,7 +208,7 @@ fn run() -> Result<(), String> { }); for device in devices { - println!( + print!( "{} {} ID {:04x}:{:04x} class {:02x}/{:02x}/{:02x} usb {}.{:02x} {}", device.controller, device.port, @@ -188,6 +221,11 @@ fn run() -> Result<(), String> { device.usb_minor, device.description, ); + let quirk_flags = lookup_usb_quirks(device.vendor_id, device.product_id); + if !quirk_flags.is_empty() { + print!(" quirks: {}", format_usb_quirk_flags(quirk_flags)); + } + println!(); } for fallback in fallback_ports { diff --git a/local/recipes/system/redbear-hwutils/source/src/bin/redbear-bluetooth-battery-check.rs b/local/recipes/system/redbear-hwutils/source/src/bin/redbear-bluetooth-battery-check.rs index 14fe0c7c..714a4a72 100644 --- a/local/recipes/system/redbear-hwutils/source/src/bin/redbear-bluetooth-battery-check.rs +++ b/local/recipes/system/redbear-hwutils/source/src/bin/redbear-bluetooth-battery-check.rs @@ -17,7 +17,6 @@ const BATTERY_SERVICE_UUID: &str = "0000180f-0000-1000-8000-00805f9b34fb"; const BATTERY_LEVEL_CHAR_UUID: &str = "00002a19-0000-1000-8000-00805f9b34fb"; const TRANSPORT_STATUS_PATH: &str = "/var/run/redbear-btusb/status"; const BTCTL_ROOT: &str = "/scheme/btctl"; -const BTCTL_ADAPTER_ROOT: &str = "/scheme/btctl/adapters/hci0"; const BTCTL_CONNECTION_STATE_PATH: &str = "/scheme/btctl/adapters/hci0/connection-state"; const BTCTL_CONNECT_RESULT_PATH: &str = "/scheme/btctl/adapters/hci0/connect-result"; const BTCTL_DISCONNECT_RESULT_PATH: &str = "/scheme/btctl/adapters/hci0/disconnect-result"; @@ -69,9 +68,13 @@ impl RuntimeSession { "redbear-btctl scheme registration", Duration::from_secs(20), || { - Ok::( - Path::new(BTCTL_ROOT).exists() && Path::new(BTCTL_ADAPTER_ROOT).exists(), - ) + let ready = run_command("redbear-btctl", &["--status"]) + .map(|output| { + output.stdout.contains("adapter=hci0") + && output.stdout.contains("bond_store_root=") + }) + .unwrap_or(false); + Ok::(ready) }, ) .map_err(|err| { @@ -395,13 +398,7 @@ fn verify_btusb_restart_path(session: &mut RuntimeSession) -> Result<(), String> fn verify_scheme_surface() -> Result<(), String> { require_path(BTCTL_ROOT)?; require_path("/scheme/btctl/adapters")?; - require_path(BTCTL_ADAPTER_ROOT)?; - require_path(BTCTL_CONNECTION_STATE_PATH)?; - require_path(BTCTL_CONNECT_RESULT_PATH)?; - require_path(BTCTL_DISCONNECT_RESULT_PATH)?; - require_path(BTCTL_READ_CHAR_RESULT_PATH)?; - require_path(BTCTL_LAST_ERROR_PATH)?; - require_path(BTCTL_BONDS_PATH)?; + require_file_contains("/scheme/btctl/adapters", ADAPTER)?; Ok(()) } diff --git a/local/recipes/system/redbear-hwutils/source/src/bin/redbear-usb-check.rs b/local/recipes/system/redbear-hwutils/source/src/bin/redbear-usb-check.rs new file mode 100644 index 00000000..de9205c3 --- /dev/null +++ b/local/recipes/system/redbear-hwutils/source/src/bin/redbear-usb-check.rs @@ -0,0 +1,100 @@ +use std::fs; +use std::process; + +use redbear_hwutils::parse_args; + +const PROGRAM: &str = "redbear-usb-check"; +const USAGE: &str = + "Usage: redbear-usb-check\n\nCheck the USB stack inside a Red Bear guest.\n\nWalks the usb scheme tree and reports controller and device status."; + +fn list_scheme_dir(path: &str) -> Vec { + match fs::read_dir(path) { + Ok(entries) => entries + .filter_map(|e| e.ok()) + .filter_map(|e| e.file_name().to_str().map(|s| s.to_string())) + .collect(), + Err(_) => Vec::new(), + } +} + +fn run() -> Result<(), String> { + parse_args(PROGRAM, USAGE, std::env::args()).map_err(|err| { + if err.is_empty() { + process::exit(0); + } + err + })?; + + let mut failures = 0; + + let usb_entries = list_scheme_dir("/scheme/usb"); + if usb_entries.is_empty() { + eprintln!("{PROGRAM}: FAIL: no usb scheme entries found"); + failures += 1; + } else { + println!( + "{PROGRAM}: found {} usb scheme entries: {:?}", + usb_entries.len(), + usb_entries + ); + + for entry in &usb_entries { + let scheme_path = format!("/scheme/usb/{}", entry); + let sub = list_scheme_dir(&scheme_path); + println!("{PROGRAM}: {} -> {:?} ports", entry, sub.len()); + + for port in &sub { + let port_path = format!("{}/{}/descriptors", scheme_path, port); + if let Ok(data) = fs::read_to_string(&port_path) { + if let Ok(dev_desc) = serde_json::from_str::(&data) { + let vendor = dev_desc + .get("vendor") + .and_then(|v| v.as_u64()) + .map(|v| format!("{:04x}", v)) + .unwrap_or_else(|| "????".to_string()); + let product = dev_desc + .get("product") + .and_then(|v| v.as_u64()) + .map(|v| format!("{:04x}", v)) + .unwrap_or_else(|| "????".to_string()); + let ss = dev_desc + .get("supports_superspeed") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let product_str = dev_desc + .get("product_str") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let speed_tag = if ss { " [SS]" } else { "" }; + println!( + "{PROGRAM}: port {} -> {}:{} {}{}", + port, vendor, product, product_str, speed_tag + ); + } + } + } + } + } + + let xhci_entries = list_scheme_dir("/scheme/xhci"); + if xhci_entries.is_empty() { + eprintln!("{PROGRAM}: FAIL: no xhci scheme entries found"); + failures += 1; + } else { + println!("{PROGRAM}: xhci controllers: {:?}", xhci_entries); + } + + if failures > 0 { + Err(format!("{} check(s) failed", failures)) + } else { + println!("{PROGRAM}: all checks passed"); + Ok(()) + } +} + +fn main() { + if let Err(err) = run() { + eprintln!("{PROGRAM}: {err}"); + process::exit(1); + } +} diff --git a/local/scripts/test-usb-qemu.sh b/local/scripts/test-usb-qemu.sh new file mode 100755 index 00000000..59c17b70 --- /dev/null +++ b/local/scripts/test-usb-qemu.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# Full USB stack validation harness for Red Bear OS in QEMU. +# +# Boots a Red Bear image with xHCI, USB keyboard, USB tablet, and USB mass storage +# attached, then checks boot logs for successful USB device enumeration and driver spawn. + +set -euo pipefail + +find_uefi_firmware() { + local candidates=( + "/usr/share/ovmf/x64/OVMF.4m.fd" + "/usr/share/OVMF/x64/OVMF.4m.fd" + "/usr/share/ovmf/x64/OVMF_CODE.4m.fd" + "/usr/share/OVMF/x64/OVMF_CODE.4m.fd" + "/usr/share/ovmf/OVMF.fd" + "/usr/share/OVMF/OVMF_CODE.fd" + "/usr/share/qemu/edk2-x86_64-code.fd" + ) + local path + for path in "${candidates[@]}"; do + if [[ -f "$path" ]]; then + printf '%s\n' "$path" + return 0 + fi + done + return 1 +} + +usage() { + cat <<'USAGE' +Usage: test-usb-qemu.sh [--check] [config] + +Boot or validate the full USB stack on a Red Bear image in QEMU. +Defaults to redbear-desktop. + +Checks performed: + 1. xHCI controller initializes and reports interrupt mode + 2. USB HID driver spawns for keyboard/tablet + 3. USB SCSI driver spawns for mass storage + 4. BOS descriptor fetched (or gracefully skipped for USB 2) + 5. No panics or crash-class errors in USB daemons +USAGE +} + +check_mode=0 +config="redbear-desktop" +for arg in "$@"; do + case "$arg" in + --help|-h|help) + usage + exit 0 + ;; + --check) + check_mode=1 + ;; + redbear-*) + config="$arg" + ;; + esac +done + +firmware="$(find_uefi_firmware)" || { + echo "ERROR: no usable x86_64 UEFI firmware found" >&2 + exit 1 +} + +arch="${ARCH:-$(uname -m)}" +image="build/$arch/$config/harddrive.img" +extra="build/$arch/$config/extra.img" +usb_img="build/$arch/$config/usb-test-storage.img" +log_file="build/$arch/$config/usb-stack-check.log" + +if [[ ! -f "$image" ]]; then + echo "ERROR: missing image $image" >&2 + echo "Build it first with: ./local/scripts/build-redbear.sh $config" >&2 + exit 1 +fi + +if [[ ! -f "$extra" ]]; then + truncate -s 1g "$extra" +fi + +if [[ ! -f "$usb_img" ]]; then + truncate -s 64M "$usb_img" +fi + +pkill -f "qemu-system-x86_64.*$image" 2>/dev/null || true +sleep 1 + +rm -f "$log_file" + +set +e +timeout 120s qemu-system-x86_64 \ + -name "Red Bear OS USB Test" \ + -device qemu-xhci,id=xhci \ + -smp 4 \ + -m 2048 \ + -bios "$firmware" \ + -chardev stdio,id=debug,signal=off,mux=on \ + -serial chardev:debug \ + -mon chardev=debug \ + -machine q35 \ + -device ich9-intel-hda -device hda-output \ + -device virtio-net,netdev=net0 \ + -netdev user,id=net0 \ + -nographic -vga none \ + -drive file="$image",format=raw,if=none,id=drv0 \ + -device nvme,drive=drv0,serial=NVME_SERIAL \ + -drive file="$extra",format=raw,if=none,id=drv1 \ + -device nvme,drive=drv1,serial=NVME_EXTRA \ + -drive file="$usb_img",format=raw,if=none,id=usbdisk \ + -device usb-storage,bus=xhci.0,drive=usbdisk \ + -device usb-kbd,bus=xhci.0 \ + -device usb-tablet,bus=xhci.0 \ + -enable-kvm -cpu host \ + > "$log_file" 2>&1 +set -e + +failures=0 + +echo "--- USB Stack Validation: $config ---" + +# Check 1: xHCI interrupt mode +if grep -q "xhcid: using MSI/MSI-X interrupt delivery\|xhcid: using legacy INTx interrupt delivery" "$log_file"; then + echo " [PASS] xHCI interrupt-driven mode detected" +else + echo " [FAIL] xHCI did not report interrupt-driven mode" >&2 + failures=$((failures + 1)) +fi + +# Check 2: USB HID driver spawn +if grep -q "USB HID driver spawned" "$log_file"; then + echo " [PASS] USB HID driver spawned" +else + echo " [FAIL] USB HID driver did not spawn" >&2 + failures=$((failures + 1)) +fi + +# Check 3: USB SCSI driver spawn +if grep -q "USB SCSI driver spawned" "$log_file"; then + echo " [PASS] USB SCSI driver spawned" +else + echo " [FAIL] USB SCSI driver did not spawn" >&2 + failures=$((failures + 1)) +fi + +# Check 4: BOS descriptor handling (info or debug log) +if grep -q "BOS:" "$log_file"; then + echo " [PASS] BOS descriptor processing active" +elif grep -q "BOS descriptor not available" "$log_file"; then + echo " [PASS] BOS descriptor gracefully skipped (USB 2 device)" +else + echo " [WARN] No BOS descriptor log output found" +fi + +# Check 5: No panics or crash-class errors (stall recovery messages are expected) +if grep -qi "panic\|usbscsid: .*IO ERROR\|usbscsid: startup failed\|usbhidd: .*IO ERROR" "$log_file"; then + echo " [FAIL] USB stack hit crash-class errors" >&2 + failures=$((failures + 1)) +else + echo " [PASS] No crash-class errors detected" +fi + +# Check 6: Hub driver (if hub detected) +if grep -q "USB HUB driver spawned" "$log_file"; then + echo " [PASS] USB hub driver spawned" +else + echo " [INFO] No hub driver spawn (expected for direct-attached devices)" +fi + +echo "--- Results: $failures failure(s), log: $log_file ---" + +if [[ "$failures" -gt 0 ]]; then + exit 1 +fi + +exit 0