Update hardware utilities and USB validation

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-04-17 00:04:08 +01:00
parent 054acba072
commit d20d44df15
7 changed files with 583 additions and 21 deletions
+103
View File
@@ -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 |
@@ -67,6 +67,10 @@ path = "src/bin/redbear-phase-iommu-check.rs"
name = "redbear-phase-dma-check" name = "redbear-phase-dma-check"
path = "src/bin/redbear-phase-dma-check.rs" path = "src/bin/redbear-phase-dma-check.rs"
[[bin]]
name = "redbear-usb-check"
path = "src/bin/redbear-usb-check.rs"
[dependencies] [dependencies]
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
@@ -2,10 +2,12 @@ use std::fs;
use std::process; use std::process;
use redbear_hwutils::{parse_args, parse_pci_location, PciLocation}; 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."; 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 { struct PciDeviceSummary {
location: PciLocation, location: PciLocation,
vendor_id: u16, vendor_id: u16,
@@ -14,6 +16,9 @@ struct PciDeviceSummary {
subclass: u8, subclass: u8,
prog_if: u8, prog_if: u8,
revision: u8, revision: u8,
subvendor_id: u16,
subdevice_id: u16,
quirk_flags: PciQuirkFlags,
} }
fn main() { 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> { fn run() -> Result<(), String> {
parse_args("lspci", USAGE, std::env::args())?; parse_args("lspci", USAGE, std::env::args())?;
let mut devices = collect_devices()?; let mut devices = collect_devices()?;
devices.sort(); devices.sort_by_key(|d| d.location);
for device in devices { for device in devices {
println!( print!(
"{} class {:02x}:{:02x}.{:02x} vendor {:04x} device {:04x} rev {:02x}", "{} class {:02x}:{:02x}.{:02x} vendor {:04x} device {:04x} rev {:02x}",
device.location, device.location,
device.class_code, device.class_code,
@@ -44,6 +153,10 @@ fn run() -> Result<(), String> {
device.device_id, device.device_id,
device.revision, device.revision,
); );
if !device.quirk_flags.is_empty() {
print!(" quirks: {}", format_quirk_flags(device.quirk_flags));
}
println!();
} }
Ok(()) Ok(())
@@ -79,14 +192,44 @@ fn collect_devices() -> Result<Vec<PciDeviceSummary>, String> {
continue; 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 { devices.push(PciDeviceSummary {
location, location,
vendor_id: u16::from_le_bytes([config[0x00], config[0x01]]), vendor_id,
device_id: u16::from_le_bytes([config[0x02], config[0x03]]), device_id,
revision: config[0x08], revision,
prog_if: config[0x09], prog_if,
subclass: config[0x0A], subclass,
class_code: config[0x0B], class_code,
subvendor_id,
subdevice_id,
quirk_flags,
}); });
} }
@@ -4,6 +4,7 @@ use std::process;
use std::str::FromStr; use std::str::FromStr;
use redbear_hwutils::{describe_usb_device, parse_args}; use redbear_hwutils::{describe_usb_device, parse_args};
use redox_driver_sys::quirks::{lookup_usb_quirks, UsbQuirkFlags};
use serde::Deserialize; use serde::Deserialize;
const USAGE: &str = "Usage: lsusb\nList USB devices exposed by native usb.* schemes."; 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> { fn run() -> Result<(), String> {
parse_args("lsusb", USAGE, std::env::args())?; parse_args("lsusb", USAGE, std::env::args())?;
@@ -175,7 +208,7 @@ fn run() -> Result<(), String> {
}); });
for device in devices { for device in devices {
println!( print!(
"{} {} ID {:04x}:{:04x} class {:02x}/{:02x}/{:02x} usb {}.{:02x} {}", "{} {} ID {:04x}:{:04x} class {:02x}/{:02x}/{:02x} usb {}.{:02x} {}",
device.controller, device.controller,
device.port, device.port,
@@ -188,6 +221,11 @@ fn run() -> Result<(), String> {
device.usb_minor, device.usb_minor,
device.description, 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 { for fallback in fallback_ports {
@@ -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 BATTERY_LEVEL_CHAR_UUID: &str = "00002a19-0000-1000-8000-00805f9b34fb";
const TRANSPORT_STATUS_PATH: &str = "/var/run/redbear-btusb/status"; const TRANSPORT_STATUS_PATH: &str = "/var/run/redbear-btusb/status";
const BTCTL_ROOT: &str = "/scheme/btctl"; 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_CONNECTION_STATE_PATH: &str = "/scheme/btctl/adapters/hci0/connection-state";
const BTCTL_CONNECT_RESULT_PATH: &str = "/scheme/btctl/adapters/hci0/connect-result"; const BTCTL_CONNECT_RESULT_PATH: &str = "/scheme/btctl/adapters/hci0/connect-result";
const BTCTL_DISCONNECT_RESULT_PATH: &str = "/scheme/btctl/adapters/hci0/disconnect-result"; const BTCTL_DISCONNECT_RESULT_PATH: &str = "/scheme/btctl/adapters/hci0/disconnect-result";
@@ -69,9 +68,13 @@ impl RuntimeSession {
"redbear-btctl scheme registration", "redbear-btctl scheme registration",
Duration::from_secs(20), Duration::from_secs(20),
|| { || {
Ok::<bool, String>( let ready = run_command("redbear-btctl", &["--status"])
Path::new(BTCTL_ROOT).exists() && Path::new(BTCTL_ADAPTER_ROOT).exists(), .map(|output| {
) output.stdout.contains("adapter=hci0")
&& output.stdout.contains("bond_store_root=")
})
.unwrap_or(false);
Ok::<bool, String>(ready)
}, },
) )
.map_err(|err| { .map_err(|err| {
@@ -395,13 +398,7 @@ fn verify_btusb_restart_path(session: &mut RuntimeSession) -> Result<(), String>
fn verify_scheme_surface() -> Result<(), String> { fn verify_scheme_surface() -> Result<(), String> {
require_path(BTCTL_ROOT)?; require_path(BTCTL_ROOT)?;
require_path("/scheme/btctl/adapters")?; require_path("/scheme/btctl/adapters")?;
require_path(BTCTL_ADAPTER_ROOT)?; require_file_contains("/scheme/btctl/adapters", ADAPTER)?;
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)?;
Ok(()) Ok(())
} }
@@ -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<String> {
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::<serde_json::Value>(&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);
}
}
+177
View File
@@ -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