feat: GPU detection for LLM server (NVIDIA/AMD/Intel/Vulkan + CPU fallback)

- Added start-llama-server.bat with cross-brand GPU auto-detection:
  * Detection 1: nvidia-smi for NVIDIA
  * Detection 2: WMIC for AMD, Intel Arc, Intel HD/Iris/UHD
  * Detection 3: vulkaninfo as final fallback probe
  * Falls back to CPU-only build when no GPU found
- Downloaded llama-server b9947 builds:
  * Vulkan build (91 MB) - supports all GPU brands
  * CPU build (45 MB) - fallback in cpu/ subdir
- Downloaded Qwen2.5-Coder-1.5B Q4_K_M model (1.1 GB)
- Fixed run-hermes.bat polling: replaces fixed 5s timeout with
  curl-based readiness polling (up to 30 attempts, 1s apart)
- All .bat files verified: ASCII text, CRLF line terminators
- Added .gitignore for build artifacts and large binaries
This commit is contained in:
2026-07-10 00:58:56 -04:00
parent ecda528b90
commit 4e0ab94ee2
14 changed files with 4353 additions and 66 deletions
+32
View File
@@ -0,0 +1,32 @@
# Build artifacts (large binaries, not tracked in git)
build/output/hermes-rescue.iso
build/output/casper-rw
build/output/hermes-data.ext4
build/output/fix-line-endings.ps1
build/iso-staging/
build/xubuntu-24.04*.iso
build/SHA256SUMS
build/hermes-build.fs
# Model and native binaries (large, downloaded at build time)
*.gguf
*.dll
*.exe
# Python cache
__pycache__/
*.pyc
*.pyo
# IDE/editor
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Secrets
src/hermes/auth.json
+734
View File
@@ -0,0 +1,734 @@
#!/usr/bin/env bash
# ==============================================================================
# Hermes Portable Rescue — Custom Live ISO Builder
# ==============================================================================
#
# Builds a custom Xubuntu 24.04 LTS live ISO with Hermes Agent pre-installed,
# diagnostic tools, autorun setup, and persistent storage support.
#
# Usage:
# sudo ./build/build-custom-iso.sh [options]
#
# Options:
# --input-iso PATH Path to Xubuntu 24.04 ISO (default: auto-detect)
# --output-dir DIR Output directory (default: ./build/output)
# --work-dir DIR Working directory for build (default: /tmp/hermes-iso-build)
# --skip-download Don't download ISO if missing
# --keep-workdir Don't clean up workdir after build
# --help Show this message
#
# Requirements:
# - squashfs-tools (mksquashfs, unsquashfs)
# - xorriso
# - syslinux-common, isolinux
# - sudo access for mount operations
#
# ==============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
BUILD_DIR="$PROJECT_DIR/build"
# ─── Defaults ──────────────────────────────────────────────────────────────────
DEFAULT_INPUT_ISO="$BUILD_DIR/xubuntu-24.04.4-desktop-amd64.iso"
OUTPUT_DIR="${OUTPUT_DIR:-$BUILD_DIR/output}"
WORKDIR="${WORKDIR:-/tmp/hermes-iso-build}"
SKIP_DOWNLOAD=false
KEEP_WORKDIR=false
# ─── Colors ────────────────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*" >&2; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
die() { error "$*"; exit 1; }
# ─── Parse Arguments ───────────────────────────────────────────────────────────
INPUT_ISO="$DEFAULT_INPUT_ISO"
while [[ $# -gt 0 ]]; do
case "$1" in
--input-iso) INPUT_ISO="$2"; shift 2 ;;
--output-dir) OUTPUT_DIR="$2"; shift 2 ;;
--work-dir) WORKDIR="$2"; shift 2 ;;
--skip-download) SKIP_DOWNLOAD=true; shift ;;
--keep-workdir) KEEP_WORKDIR=true; shift ;;
-h|--help) head -50 "$0"; exit 0 ;;
*) die "Unknown option: $1" ;;
esac
done
# ─── Prerequisites ─────────────────────────────────────────────────────────────
check_command() {
if ! command -v "$1" &>/dev/null; then
die "Required command '$1' not found"
fi
}
prerequisites() {
info "Checking prerequisites..."
check_command unsquashfs
check_command mksquashfs
check_command xorriso
check_command dd
check_command mount
check_command rsync
check_command chroot
ok "All prerequisites available."
}
# ─── Phase 1: Download / Verify ISO ────────────────────────────────────────────
phase_download_iso() {
if [[ -f "$INPUT_ISO" ]]; then
local iso_size
iso_size=$(stat -c%s "$INPUT_ISO" 2>/dev/null || echo 0)
if [[ "$iso_size" -gt 1000000000 ]]; then
ok "Input ISO found: $INPUT_ISO ($(du -h "$INPUT_ISO" | cut -f1))"
# Verify SHA256
local expected_sha="fc2e995bb05c41ea19f1dbfd91f6deea7b2aed7a83b9934d98fc9d9cac527d97"
local actual_sha
actual_sha=$(sha256sum "$INPUT_ISO" | awk '{print $1}')
if [[ "$actual_sha" == "$expected_sha" ]]; then
ok "SHA256 verified."
else
warn "SHA256 mismatch! Expected $expected_sha, got $actual_sha"
warn "Continuing anyway — ISO may be incomplete or different build."
fi
return 0
fi
fi
if $SKIP_DOWNLOAD; then
die "ISO not found at $INPUT_ISO and --skip-download is set."
fi
info "Downloading Xubuntu 24.04.4 LTS Desktop ISO (~4.1 GB)..."
wget -c "https://cdimage.ubuntu.com/xubuntu/releases/24.04/release/xubuntu-24.04.4-desktop-amd64.iso" \
-O "$INPUT_ISO" 2>&1
ok "Download complete."
}
# ─── Phase 2: Extract ISO ─────────────────────────────────────────────────────
phase_extract_iso() {
info "Extracting ISO contents..."
local iso_mount="/mnt/hermes-iso-source"
mkdir -p "$iso_mount" "$WORKDIR/iso-contents"
# Mount ISO (handle both .iso and .img)
mount -o loop "$INPUT_ISO" "$iso_mount" 2>/dev/null || \
die "Failed to mount ISO. Is it a valid ISO file?"
# List contents for debugging
info "ISO contents:"
ls -la "$iso_mount/" 2>/dev/null
echo ""
# Determine boot structure
if [[ -d "$iso_mount/isolinux" ]]; then
ok "BIOS boot: isolinux detected"
fi
if [[ -f "$iso_mount/boot/grub/efi.img" ]] || [[ -f "$iso_mount/boot/grub/eltorito.img" ]]; then
ok "UEFI boot: GRUB efi.img detected"
fi
# Check squashfs location
local squashfs_src=""
if [[ -f "$iso_mount/casper/filesystem.squashfs" ]]; then
squashfs_src="$iso_mount/casper/filesystem.squashfs"
ok "Squashfs: casper/filesystem.squashfs"
elif [[ -f "$iso_mount/casper/filesystem.squashfs.xz" ]]; then
squashfs_src="$iso_mount/casper/filesystem.squashfs.xz"
ok "Squashfs: casper/filesystem.squashfs.xz (compressed)"
else
ls -la "$iso_mount/" "$iso_mount/casper/" 2>/dev/null
umount "$iso_mount"
die "Cannot locate filesystem.squashfs in the ISO."
fi
# Copy everything EXCEPT the squashfs (we'll replace it)
info "Copying ISO contents (excluding squashfs)..."
rsync -a --exclude="casper/filesystem.squashfs*" \
"$iso_mount/" "$WORKDIR/iso-contents/" 2>&1 | tail -5
# Copy the squashfs for extraction
cp "$squashfs_src" "$WORKDIR/filesystem.squashfs"
# Extract the MBR from the ISO for hybrid boot
info "Extracting MBR boot sector..."
dd if="$INPUT_ISO" of="$WORKDIR/iso-contents/isohdpfx.bin" bs=440 count=1 2>/dev/null
ok "MBR extracted."
umount "$iso_mount"
rmdir "$iso_mount"
ok "ISO contents extracted to $WORKDIR/iso-contents"
}
# ─── Phase 3: Unsquash Filesystem ──────────────────────────────────────────────
phase_unsquash() {
info "Unsquashing filesystem (this may take a few minutes)..."
mkdir -p "$WORKDIR/squashfs-root"
if [[ "$WORKDIR/filesystem.squashfs" == *.xz ]]; then
xz -d "$WORKDIR/filesystem.squashfs" -c > "$WORKDIR/filesystem.squashfs.unxz" 2>/dev/null
unsquashfs -d "$WORKDIR/squashfs-root" -f "$WORKDIR/filesystem.squashfs.unxz" 2>&1 | tail -3
else
unsquashfs -d "$WORKDIR/squashfs-root" -f "$WORKDIR/filesystem.squashfs" 2>&1 | tail -3
fi
local root_size
root_size=$(du -sh "$WORKDIR/squashfs-root" | cut -f1)
ok "Filesystem unsquashed: $root_size"
}
# ─── Phase 4: Customize Filesystem (Chroot) ────────────────────────────────────
# This function writes the customization script that runs INSIDE the chroot
generate_chroot_script() {
cat > "$WORKDIR/chroot-customize.sh" << 'CHROOTSCRIPT'
#!/bin/bash
# ==============================================================================
# Hermes Portable Rescue — Chroot Customization Script
# ==============================================================================
# Runs inside the live filesystem chroot to install everything.
# ==============================================================================
set -euo pipefail
# Redirect output to log
exec > /tmp/chroot-build.log 2>&1
echo "=== Hermes Portable Rescue — Chroot Customization ==="
echo "Date: $(date)"
echo ""
# ─── 1. Mount virtual filesystems ──────────────────────────────────────────────
mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs none /dev || mount -t tmpfs none /dev
mkdir -p /dev/pts
mount -t devpts none /dev/pts
# ─── 2. Configure package sources ──────────────────────────────────────────────
echo "=== Configuring package sources ==="
apt-get update -qq 2>/dev/null || true
# ─── 3. Install essential packages ─────────────────────────────────────────────
echo "=== Installing packages ==="
PACKAGES=(
# Python + dev
python3-pip
python3-venv
python3-setuptools
python3-wheel
git
# Diagnostic tools
smartmontools
dmidecode
lshw
testdisk
ddrescue
ntfs-3g
stress-ng
# Network tools
nmap
net-tools
curl
wget
openssh-client
iperf3
traceroute
# Storage tools
parted
rsync
htop
iotop
exfatprogs
btrfs-progs
xfsprogs
# Filesystem tools
dosfstools
e2fsprogs
# Windows-specific tools
hivex # Windows registry access
)
apt-get install -y -qq "${PACKAGES[@]}" 2>&1 | tail -5
echo "Package installation complete."
# ─── 4. Install Hermes Agent ───────────────────────────────────────────────────
echo "=== Installing Hermes Agent ==="
# Create Hermes directory structure
mkdir -p /opt/hermes/{agent,tools,scripts,models,diagnostics,config}
# Install from pip
pip3 install --no-cache-dir \
hermes-agent \
pyyaml \
psutil \
requests \
2>&1 | tail -3
# Freeze package list for provenance
pip3 freeze > /opt/hermes/pip-freeze.txt
echo "Hermes Agent installed."
# ─── 5. Configure Hermes autorun service ───────────────────────────────────────
echo "=== Configuring autorun ==="
# Systemd service that runs the Hermes rescue autorun on boot
cat > /etc/systemd/system/hermes-autorun.service << 'SERVICEEOF'
[Unit]
Description=Hermes Portable Rescue — Auto-Diagnostic Agent
After=network.target multi-user.target
ConditionPathExists=/opt/hermes/autorun.sh
[Service]
Type=oneshot
ExecStart=/opt/hermes/autorun.sh
RemainAfterExit=yes
StandardOutput=journal+console
StandardError=journal+console
Environment=HERMES_DIR=/opt/hermes
Environment=CONFIG=/opt/hermes/config.yaml
Environment=REPORT_DIR=/opt/hermes/reports
Environment=BOOT_MODE=live
[Install]
WantedBy=multi-user.target
SERVICEEOF
# Desktop autostart for GUI mode — opens a terminal running Hermes
mkdir -p /etc/xdg/autostart
cat > /etc/xdg/autostart/hermes-rescue.desktop << 'DESKTOPDF'
[Desktop Entry]
Type=Application
Name=Hermes Portable Rescue
Comment=AI-driven PC diagnostic & repair toolkit
Exec=xfce4-terminal --title="Hermes Rescue" -e "/opt/hermes/autorun.sh" --hold
Terminal=false
Categories=System;Utility;
StartupNotify=true
X-GNOME-Autostart-enabled=true
DESKTOPDF
# Also create a desktop shortcut on the user desktop for manual launch
mkdir -p /etc/skel/Desktop
cat > /etc/skel/Desktop/hermes-rescue.desktop << 'DESKTOPEOF'
[Desktop Entry]
Type=Application
Name=Hermes Rescue
Comment=AI-driven PC diagnostic & repair toolkit
Exec=xfce4-terminal --title="Hermes Rescue" -e "/opt/hermes/autorun.sh" --hold
Terminal=false
Icon=system-run
Categories=System;Utility;
StartupNotify=true
DESKTOPEOF
chmod +x /etc/skel/Desktop/hermes-rescue.desktop
# Enable the service for live boot
systemctl enable hermes-autorun.service 2>/dev/null || true
echo "Autorun service configured."
# ─── 6. Create symlinks for diagnostic scripts ─────────────────────────────────
echo "=== Setting up diagnostic scripts ==="
# The scripts will be copied from the project source via bind mount.
# Create placeholder structure here — actual files come from the build host.
mkdir -p /opt/hermes/scripts
cat > /opt/hermes/scripts/README.txt << 'README'
Diagnostic scripts are copied from the build host at ISO build time.
Place your scripts in /opt/hermes/scripts/ on the running system.
README
echo "Diagnostic scripts directory ready."
# ─── 7. Configure auto-login (lightdm for XFCE) ────────────────────────────────
echo "=== Configuring auto-login ==="
# LightDM auto-login for the live session user (xubuntu)
mkdir -p /etc/lightdm/lightdm.conf.d
cat > /etc/lightdm/lightdm.conf.d/50-hermes-autologin.conf << 'LIGHTDMEOF'
[Seat:*]
autologin-user=xubuntu
autologin-user-timeout=0
autologin-session=xubuntu
LIGHTDMEOF
echo "Auto-login configured for 'xubuntu'."
# ─── 8. Create persistent storage hook ─────────────────────────────────────────
echo "=== Setting up persistent storage hook ==="
# Script to mount persistent storage if available
mkdir -p /opt/hermes/scripts
cat > /opt/hermes/scripts/mount-persistent.sh << 'MOUNTEOF'
#!/bin/bash
# Mount persistent storage partition (NTFS/exFAT labeled HERMES_DATA)
set -e
PERSISTENT_LABEL="HERMES_DATA"
MOUNT_POINT="/mnt/hermes-data"
mkdir -p "$MOUNT_POINT"
# Find and mount by label
for dev in /dev/sd* /dev/nvme* /dev/mmcblk*; do
if [ -b "$dev" ]; then
label=$(blkid -o value -s LABEL "$dev" 2>/dev/null || echo "")
if [ "$label" = "$PERSISTENT_LABEL" ]; then
fstype=$(blkid -o value -s TYPE "$dev" 2>/dev/null || echo "")
case "$fstype" in
ntfs) mount -t ntfs-3g "$dev" "$MOUNT_POINT" 2>/dev/null && break ;;
ext4) mount "$dev" "$MOUNT_POINT" 2>/dev/null && break ;;
vfat|exfat) mount "$dev" "$MOUNT_POINT" 2>/dev/null && break ;;
esac
fi
fi
done
if mountpoint -q "$MOUNT_POINT"; then
echo "Persistent storage mounted at $MOUNT_POINT"
# Symlink persistent config
if [ -f "$MOUNT_POINT/hermes/config.yaml" ]; then
ln -sf "$MOUNT_POINT/hermes/config.yaml" /opt/hermes/config.yaml
fi
else
echo "No persistent storage found. Running in temporary mode."
fi
MOUNTEOF
chmod +x /opt/hermes/scripts/mount-persistent.sh
echo "Persistent storage hook created."
# ─── 9. Clean up package caches ─────────────────────────────────────────────────
echo "=== Cleaning up ==="
apt-get clean -qq 2>/dev/null || true
apt-get autoremove --purge -qq 2>/dev/null || true
rm -rf /var/lib/apt/lists/* 2>/dev/null || true
rm -rf /root/.cache 2>/dev/null || true
rm -rf /tmp/* 2>/dev/null || true
# Clean Python caches
find /usr -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true
find /usr -type f -name '*.pyc' -delete 2>/dev/null || true
find /opt/hermes -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true
find /opt/hermes -type f -name '*.pyc' -delete 2>/dev/null || true
echo ""
echo "=== Chroot customization complete! ==="
echo "Packages installed: $(dpkg-query -W -f='${Package}\n' 2>/dev/null | wc -l)"
echo "Hermes config at: /opt/hermes/"
echo "Autorun service: hermes-autorun.service"
echo ""
# Unmount virtual filesystems
umount /dev/pts 2>/dev/null || true
umount /sys 2>/dev/null || true
umount /proc 2>/dev/null || true
exit 0
CHROOTSCRIPT
chmod +x "$WORKDIR/chroot-customize.sh"
}
phase_customize() {
info "Preparing chroot environment..."
SQUASHFS_ROOT="$WORKDIR/squashfs-root"
# Bind mount necessary filesystems
mount --bind /dev "$SQUASHFS_ROOT/dev"
mount --bind /proc "$SQUASHFS_ROOT/proc"
mount --bind /sys "$SQUASHFS_ROOT/sys"
mount -t devpts none "$SQUASHFS_ROOT/dev/pts" 2>/dev/null || true
# Network connectivity inside chroot
cp /etc/resolv.conf "$SQUASHFS_ROOT/etc/resolv.conf"
# Copy the project's diagnostic scripts and config into the chroot
mkdir -p "$SQUASHFS_ROOT/tmp/hermes-project"
if [[ -d "$PROJECT_DIR/src/diagnostics" ]]; then
cp -r "$PROJECT_DIR/src/diagnostics/"* "$SQUASHFS_ROOT/tmp/hermes-project/" 2>/dev/null || true
fi
if [[ -f "$PROJECT_DIR/src/hermes/config.yaml" ]]; then
cp "$PROJECT_DIR/src/hermes/config.yaml" "$SQUASHFS_ROOT/tmp/hermes-project/" 2>/dev/null || true
fi
if [[ -f "$PROJECT_DIR/src/hermes/autorun.sh" ]]; then
cp "$PROJECT_DIR/src/hermes/autorun.sh" "$SQUASHFS_ROOT/tmp/hermes-project/" 2>/dev/null || true
fi
if [[ -f "$PROJECT_DIR/src/hermes/launch.sh" ]]; then
cp "$PROJECT_DIR/src/hermes/launch.sh" "$SQUASHFS_ROOT/tmp/hermes-project/" 2>/dev/null || true
fi
if [[ -d "$PROJECT_DIR/src/hermes/scripts" ]]; then
cp -r "$PROJECT_DIR/src/hermes/scripts/"* "$SQUASHFS_ROOT/tmp/hermes-project/scripts/" 2>/dev/null || true
fi
# Copy the chroot script in
cp "$WORKDIR/chroot-customize.sh" "$SQUASHFS_ROOT/tmp/chroot-customize.sh"
chmod +x "$SQUASHFS_ROOT/tmp/chroot-customize.sh"
info "Running chroot customization (this may take 10-20 minutes)..."
echo ""
if chroot "$SQUASHFS_ROOT" /bin/bash /tmp/chroot-customize.sh; then
ok "Chroot customization completed successfully."
else
warn "Chroot script exited with code $? — checking log..."
fi
# Check the build log
if [[ -f "$SQUASHFS_ROOT/tmp/chroot-build.log" ]]; then
echo ""
info "Chroot build log (last 30 lines):"
tail -30 "$SQUASHFS_ROOT/tmp/chroot-build.log"
fi
# Copy project files from temp to /opt/hermes
info "Installing project files..."
if [[ -f "$SQUASHFS_ROOT/tmp/hermes-project/config.yaml" ]]; then
cp "$SQUASHFS_ROOT/tmp/hermes-project/config.yaml" "$SQUASHFS_ROOT/opt/hermes/config.yaml"
fi
if [[ -f "$SQUASHFS_ROOT/tmp/hermes-project/autorun.sh" ]]; then
cp "$SQUASHFS_ROOT/tmp/hermes-project/autorun.sh" "$SQUASHFS_ROOT/opt/hermes/autorun.sh"
chmod +x "$SQUASHFS_ROOT/opt/hermes/autorun.sh"
fi
if [[ -f "$SQUASHFS_ROOT/tmp/hermes-project/launch.sh" ]]; then
cp "$SQUASHFS_ROOT/tmp/hermes-project/launch.sh" "$SQUASHFS_ROOT/opt/hermes/launch.sh"
chmod +x "$SQUASHFS_ROOT/opt/hermes/launch.sh"
fi
if [[ -d "$SQUASHFS_ROOT/tmp/hermes-project/scripts" ]]; then
cp -r "$SQUASHFS_ROOT/tmp/hermes-project/scripts/"* "$SQUASHFS_ROOT/opt/hermes/scripts/" 2>/dev/null || true
fi
if [[ -d "$SQUASHFS_ROOT/tmp/hermes-project/diagnostics" ]]; then
cp -r "$SQUASHFS_ROOT/tmp/hermes-project/diagnostics/"* "$SQUASHFS_ROOT/opt/hermes/scripts/" 2>/dev/null || true
fi
if [[ -d "$SQUASHFS_ROOT/tmp/hermes-project/__init__.py" ]]; then
cp "$SQUASHFS_ROOT/tmp/hermes-project/__init__.py" "$SQUASHFS_ROOT/opt/hermes/scripts/" 2>/dev/null || true
fi
if [[ -f "$SQUASHFS_ROOT/tmp/hermes-project/__main__.py" ]]; then
cp "$SQUASHFS_ROOT/tmp/hermes-project/__main__.py" "$SQUASHFS_ROOT/opt/hermes/scripts/" 2>/dev/null || true
fi
# Clean up
rm -rf "$SQUASHFS_ROOT/tmp/hermes-project" "$SQUASHFS_ROOT/tmp/chroot-customize.sh" 2>/dev/null || true
}
# ─── Phase 5: Clean Chroot ─────────────────────────────────────────────────────
phase_clean_chroot() {
info "Cleaning chroot..."
SQUASHFS_ROOT="$WORKDIR/squashfs-root"
# Remove resolv.conf
rm -f "$SQUASHFS_ROOT/etc/resolv.conf"
# Unmount virtual filesystems
umount "$SQUASHFS_ROOT/dev/pts" 2>/dev/null || true
umount "$SQUASHFS_ROOT/sys" 2>/dev/null || true
umount "$SQUASHFS_ROOT/proc" 2>/dev/null || true
umount "$SQUASHFS_ROOT/dev" 2>/dev/null || true
ok "Chroot cleaned."
}
# ─── Phase 6: Re-pack Squashfs ─────────────────────────────────────────────────
phase_repack_squashfs() {
info "Re-packing squashfs filesystem (this takes a while)..."
local target_squashfs="$WORKDIR/iso-contents/casper/filesystem.squashfs"
mkdir -p "$(dirname "$target_squashfs")"
# Rebuild with high compression
mksquashfs "$WORKDIR/squashfs-root" "$target_squashfs" \
-comp xz -b 1M -Xdict-size 100% \
-noappend \
2>&1 | tail -5
local squash_size
squash_size=$(du -h "$target_squashfs" | cut -f1)
ok "Squashfs re-packed: $squash_size"
# Update filesystem.size for casper
info "Updating filesystem.size..."
local fs_size
fs_size=$(du -sx --block-size=1 "$WORKDIR/squashfs-root" 2>/dev/null | cut -f1)
echo "$fs_size" > "$WORKDIR/iso-contents/casper/filesystem.size"
ok "filesystem.size updated: $fs_size bytes"
}
# ─── Phase 7: Generate ISO ─────────────────────────────────────────────────────
phase_generate_iso() {
info "Generating bootable ISO..."
mkdir -p "$OUTPUT_DIR"
local output_iso="$OUTPUT_DIR/hermes-rescue.iso"
local iso_contents="$WORKDIR/iso-contents"
# Build the ISO with hybrid MBR (BIOS + UEFI)
cd "$iso_contents"
# Determine which efi.img to use
local efi_img=""
if [[ -f "boot/grub/efi.img" ]]; then
efi_img="boot/grub/efi.img"
elif [[ -f "boot/grub/eltorito.img" ]]; then
efi_img="boot/grub/eltorito.img"
else
# Search for any efi boot image
efi_img=$(find . -name "efi.img" -o -name "eltorito.img" 2>/dev/null | head -1)
if [[ -z "$efi_img" ]]; then
warn "No EFI boot image found — ISO may not boot on UEFI systems."
fi
fi
if [[ -n "$efi_img" ]]; then
info "Using EFI image: $efi_img"
xorriso -as mkisofs -r -V "HERMES_RESCUE" \
-J -joliet-long \
-isohybrid-mbr "$iso_contents/isohdpfx.bin" \
-b isolinux/isolinux.bin \
-c isolinux/boot.cat \
-no-emul-boot -boot-load-size 4 -boot-info-table \
-eltorito-alt-boot -e "$efi_img" -no-emul-boot \
-isohybrid-gpt-basdat \
-isohybrid-apm-hfsplus \
-o "$output_iso" \
"$iso_contents" 2>&1 | tail -10
else
# BIOS-only fallback
warn "Building BIOS-only ISO (no UEFI support)."
xorriso -as mkisofs -r -V "HERMES_RESCUE" \
-J -joliet-long \
-isohybrid-mbr "$iso_contents/isohdpfx.bin" \
-b isolinux/isolinux.bin \
-c isolinux/boot.cat \
-no-emul-boot -boot-load-size 4 -boot-info-table \
-o "$output_iso" \
"$iso_contents" 2>&1 | tail -10
fi
local iso_size
iso_size=$(du -h "$output_iso" | cut -f1)
ok "ISO generated: $output_iso ($iso_size)"
cd "$PROJECT_DIR"
}
# ─── Phase 8: Create Persistent Storage Overlay ────────────────────────────────
phase_create_persistence() {
info "Creating persistent storage files..."
# Create a casper-rw file for persistence (1GB by default)
local persist_size="${PERSIST_SIZE:-1024}"
local persist_file="$OUTPUT_DIR/casper-rw"
if [[ -f "$persist_file" ]]; then
warn "casper-rw already exists — skipping."
return 0
fi
dd if=/dev/zero of="$persist_file" bs=1M count="$persist_size" status=progress 2>/dev/null
mkfs.ext4 -F -L casper-rw "$persist_file" 2>/dev/null
ok "Persistent storage created: $persist_file (${persist_size}MB)"
# Create a writeable partition image for Windows-accessible data
local data_img="$OUTPUT_DIR/hermes-data.ext4"
if [[ ! -f "$data_img" ]]; then
dd if=/dev/zero of="$data_img" bs=1M count=512 status=progress 2>/dev/null
mkfs.ext4 -F -L HERMES_DATA "$data_img" 2>/dev/null
ok "Data partition image: $data_img (512MB)"
fi
}
# ─── Phase 9: Cleanup ──────────────────────────────────────────────────────────
phase_cleanup() {
if $KEEP_WORKDIR; then
info "Keeping workdir at $WORKDIR"
return 0
fi
info "Cleaning up build artifacts..."
rm -rf "$WORKDIR/squashfs-root" 2>/dev/null || true
rm -f "$WORKDIR/filesystem.squashfs" "$WORKDIR/filesystem.squashfs.unxz" 2>/dev/null || true
rm -rf "$WORKDIR/iso-contents" 2>/dev/null || true
rm -f "$WORKDIR/chroot-customize.sh" 2>/dev/null || true
ok "Cleanup complete."
}
# ─── Main ──────────────────────────────────────────────────────────────────────
main() {
echo ""
echo "╔════════════════════════════════════════════════════════╗"
echo "║ Hermes Portable Rescue — Live ISO Builder ║"
echo "╚════════════════════════════════════════════════════════╝"
echo ""
echo " Input ISO: $INPUT_ISO"
echo " Output: $OUTPUT_DIR/hermes-rescue.iso"
echo " Workdir: $WORKDIR"
echo " Project: $PROJECT_DIR"
echo ""
# Phase count
TOTAL=8
CURRENT=0
CURRENT=$((CURRENT + 1)); echo ""; echo "[${CURRENT}/${TOTAL}] Prerequisites"; prerequisites
CURRENT=$((CURRENT + 1)); echo ""; echo "[${CURRENT}/${TOTAL}] Download/Verify ISO"; phase_download_iso
CURRENT=$((CURRENT + 1)); echo ""; echo "[${CURRENT}/${TOTAL}] Extract ISO"; phase_extract_iso
CURRENT=$((CURRENT + 1)); echo ""; echo "[${CURRENT}/${TOTAL}] Unsquash filesystem"; phase_unsquash
CURRENT=$((CURRENT + 1)); echo ""; echo "[${CURRENT}/${TOTAL}] Generate chroot script"; generate_chroot_script
CURRENT=$((CURRENT + 1)); echo ""; echo "[${CURRENT}/${TOTAL}] Customize (chroot)"; phase_customize
CURRENT=$((CURRENT + 1)); echo ""; echo "[${CURRENT}/${TOTAL}] Clean chroot"; phase_clean_chroot
CURRENT=$((CURRENT + 1)); echo ""; echo "[${CURRENT}/${TOTAL}] Re-pack squashfs"; phase_repack_squashfs
CURRENT=$((CURRENT + 1)); echo ""; echo "[${CURRENT}/${TOTAL}] Generate ISO"; phase_generate_iso
CURRENT=$((CURRENT + 1)); echo ""; echo "[${CURRENT}/${TOTAL}] Create persistence"; phase_create_persistence
CURRENT=$((CURRENT + 1)); echo ""; echo "[${CURRENT}/${TOTAL}] Cleanup"; phase_cleanup
echo ""
echo "╔════════════════════════════════════════════════════════╗"
echo "║ BUILD COMPLETE ║"
echo "╚════════════════════════════════════════════════════════╝"
echo ""
echo " ISO: $OUTPUT_DIR/hermes-rescue.iso"
echo " Persist: $OUTPUT_DIR/casper-rw"
echo " Data img: $OUTPUT_DIR/hermes-data.ext4"
echo ""
echo "Write to USB:"
echo " # Copy ISO to a Ventoy USB, or write directly:"
echo " sudo dd if=$OUTPUT_DIR/hermes-rescue.iso of=/dev/sdX bs=4M status=progress"
echo ""
echo "For persistence, copy casper-rw to the USB root alongside the ISO,"
echo "or the live system will auto-detect a partition labeled 'HERMES_DATA'."
echo ""
}
main "$@"
+165
View File
@@ -0,0 +1,165 @@
@echo off
title Hermes Portable Rescue - Windows Agent
setlocal enabledelayedexpansion
cd /d "%~dp0"
set HERMES_HOME=%~dp0..\hermes
set HERMES_ROOT=%~dp0..\hermes
set HERMES_CONFIG=%HERMES_ROOT%\config.yaml
set PYTHON=%~dp0python\python.exe
set PIP=%~dp0python\Scripts\pip.exe
set WHEELS_DIR=%~dp0bin\wheels
set MARKER=%~dp0\.installed
set WEB_SERVER=%~dp0..\hermes\web\server.py
set OLLAMA_DIR=%~dp0bin\ollama
echo.
echo ============================================
echo Hermes Portable Rescue - Windows Agent
echo ============================================
echo.
echo Python: !PYTHON!
echo Config: !HERMES_CONFIG!
echo.
:: Check Python
if not exist "!PYTHON!" (
echo [ERROR] Python not found at !PYTHON!
pause
exit /b 1
)
:: First-run: bootstrap pip + install hermes + zeroconf
if not exist "!MARKER!" (
echo [*] First run -- installing Hermes Agent...
echo.
"!PYTHON!" "%~dp0bin\get-pip.py" --quiet --no-warn-script-location 2>&1
if errorlevel 1 (
echo [ERROR] Failed to bootstrap pip.
pause
exit /b 1
)
"!PYTHON!" -m pip install --no-index --find-links "!WHEELS_DIR!" --quiet hermes-agent zeroconf ifaddr 2>&1
if errorlevel 1 (
echo [ERROR] Failed to install Hermes Agent.
pause
exit /b 1
)
echo. > "!MARKER!"
echo [OK] Hermes Agent installed.
echo.
)
:: Launch menu
:menu
cls
echo ============================================
echo Hermes Portable Rescue - Windows Agent
echo ============================================
echo.
echo Python: !PYTHON!
echo Config: !HERMES_CONFIG!
echo.
echo What would you like to do?
echo.
echo 1) Hermes CLI (ONLINE - opencode-go)
echo 2) Hermes CLI (OFFLINE - local LLM)
echo 3) Web Dashboard (http://rescue.local:8080)
echo 4) Full Diagnostics (hardware, disk, mem, stress)
echo 5) Start Local LLM Server (llama.cpp)
echo 6) Exit
echo.
set /p choice="Choice (1-6): "
if "!choice!"=="1" goto run_online
if "!choice!"=="2" goto run_offline
if "!choice!"=="3" goto run_dashboard
if "!choice!"=="4" goto run_diag
if "!choice!"=="5" goto run_llm
if "!choice!"=="6" exit /b
echo Invalid choice.
pause
goto menu
:run_online
echo.
echo [*] Starting Hermes CLI (online mode - opencode-go)...
echo.
set HERMES_HOME=%~dp0..\hermes
"!PYTHON!" -m hermes_cli --config "!HERMES_CONFIG!"
echo.
pause
goto menu
:run_offline
if not exist "!OLLAMA_DIR!\qwen2.5-coder-1.5b-q4_k_m.gguf" (
echo.
echo [ERROR] Offline model not found at !OLLAMA_DIR!
echo Run option 5 first to start the LLM server.
echo.
pause
goto menu
)
echo.
echo [*] Starting local LLM server (llama.cpp)...
start "Hermes Local LLM" "%~dp0start-llama-server.bat"
echo [*] Waiting for LLM server to be ready (polling)...
set POLL_COUNT=0
:wait_llm
set /a POLL_COUNT+=1
if !POLL_COUNT! gtr 30 (
echo [WARN] LLM server not ready after 30s - starting anyway...
goto skip_wait
)
>nul 2>&1 curl -s http://127.0.0.1:11434/v1/models && (
echo [OK] LLM server is ready!
goto skip_wait
)
>nul 2>&1 timeout /t 1 /nobreak
goto wait_llm
:skip_wait
echo.
echo [*] Starting Hermes CLI (offline mode - local LLM)...
set HERMES_HOME=%~dp0..\hermes\offline
if not exist "!HERMES_HOME!" mkdir "!HERMES_HOME!"
"!PYTHON!" -m hermes_cli --config "!HERMES_ROOT!\config.offline.yaml"
echo.
pause
goto menu
:run_dashboard
echo.
echo [*] Starting web dashboard at http://localhost:8080
echo [*] On other devices, use http://rescue.local:8080
echo.
start "Hermes Dashboard" "http://localhost:8080"
"!PYTHON!" "!WEB_SERVER!"
pause
goto menu
:run_diag
echo.
echo [*] Running full diagnostics...
echo.
for %%s in (hardware bsod drivers stress) do (
echo.
echo --- %%s ---
"!PYTHON!" "!HERMES_ROOT!\scripts\%%s.py"
)
echo.
pause
goto menu
:run_llm
echo.
echo [*] Starting llama-server (local LLM backend)...
echo [*] Model: qwen2.5-coder-1.5b-q4_k_m.gguf
echo [*] Server: http://127.0.0.1:11434/v1
echo [*] Press Ctrl+C to stop when done.
echo.
start "Hermes LLM Server" "%~dp0start-llama-server.bat"
echo [OK] Server starting in new window.
echo.
pause
goto menu
+125
View File
@@ -0,0 +1,125 @@
@echo off
:: ==============================================================================
:: Hermes Portable Rescue - llama-server launcher with GPU detection
:: ==============================================================================
:: Detects GPU brand (NVIDIA, AMD, Intel, integrated) and launches the
:: appropriate llama-server build (Vulkan for GPU, CPU-only fallback).
:: ==============================================================================
setlocal enabledelayedexpansion
cd /d "%~dp0"
set LLAMA_DIR=%~dp0bin\llama
set MODEL_DIR=%~dp0bin\ollama
set MODEL=%MODEL_DIR%\qwen2.5-coder-1.5b-q4_k_m.gguf
set PORT=11434
set GPU_FOUND=0
echo.
echo ============================================
echo Hermes LLM Server - GPU Detection
echo ============================================
echo.
:: --- Detection 1: nvidia-smi (NVIDIA) --------------------------------------
nvidia-smi >nul 2>&1
if !errorlevel! equ 0 (
for /f "tokens=*" %%a in ('nvidia-smi --query-gpu=name --format=csv,noheader 2^>nul') do (
set GPU_NAME=%%a
)
echo [OK] NVIDIA GPU detected: !GPU_NAME!
set GPU_FOUND=1
)
:: Detection 2: WMIC - check for AMD/Intel GPUs
if !GPU_FOUND! equ 0 (
wmic path win32_videocontroller get name 2>nul | findstr /i "nvidia" >nul
if !errorlevel! equ 0 (
echo [OK] NVIDIA GPU detected via WMI
set GPU_FOUND=1
) else (
wmic path win32_videocontroller get name 2>nul | findstr /i "amd radeon" >nul
if !errorlevel! equ 0 (
echo [OK] AMD GPU detected via WMI
set GPU_FOUND=1
) else (
wmic path win32_videocontroller get name 2>nul | findstr /i "intel arc" >nul
if !errorlevel! equ 0 (
echo [OK] Intel GPU detected via WMI
set GPU_FOUND=1
) else (
wmic path win32_videocontroller get name 2>nul | findstr /i "intel hd intel iris intel uhd" >nul
if !errorlevel! equ 0 (
echo [OK] Intel integrated graphics detected via WMI
set GPU_FOUND=1
)
)
)
)
)
:: Detection 3: vulkaninfo (direct Vulkan probe)
if !GPU_FOUND! equ 0 (
where vulkaninfo >nul 2>&1
if !errorlevel! equ 0 (
vulkaninfo --summary 2>nul | findstr /c:"Vulkan Instance" >nul
if !errorlevel! equ 0 (
echo [OK] Vulkan-capable device detected
set GPU_FOUND=1
)
)
)
:: Check model exists
if not exist "!MODEL!" (
echo [ERROR] Model not found: !MODEL!
echo.
echo Please download a GGUF model and place it at:
echo !MODEL!
echo.
echo Recommended: Qwen 2.5 Coder 1.5B Q4_K_M
echo https://huggingface.co/Qwen/Qwen2.5-Coder-1.5B-Instruct-GGUF
echo.
pause
exit /b 1
)
:: Launch
if !GPU_FOUND! equ 1 (
echo.
echo [*] Starting llama-server with GPU acceleration (Vulkan)...
echo [*] Model: qwen2.5-coder-1.5b-q4_k_m.gguf
echo [*] Server: http://127.0.0.1:!PORT!/v1
echo [*] Flags: --n-gpu-layers 99 --mlock --cont-batching -c 4096
echo.
echo [*] Press Ctrl+C to stop the server.
echo.
"!LLAMA_DIR!\llama-server.exe" ^
--host 127.0.0.1 --port !PORT! ^
-m "!MODEL!" ^
--n-gpu-layers 99 ^
--mlock ^
--cont-batching ^
-c 4096
) else (
echo.
echo [*] No compatible GPU found. Starting CPU-only mode.
echo [*] Model: qwen2.5-coder-1.5b-q4_k_m.gguf
echo [*] Server: http://127.0.0.1:!PORT!/v1
echo [*] Flags: --mlock --cont-batching -c 4096
echo.
echo [*] Press Ctrl+C to stop the server.
echo.
"!LLAMA_DIR!\cpu\llama-server-cpu.exe" ^
--host 127.0.0.1 --port !PORT! ^
-m "!MODEL!" ^
--mlock ^
--cont-batching ^
-c 4096
)
echo.
echo [*] Server exited with code !errorlevel!
pause
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Quick rebuild: extract existing ISO, update files, repack
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
BUILD_DIR="$PROJECT_DIR/build"
EXISTING_ISO="$BUILD_DIR/output/hermes-rescue.iso"
OUTPUT_DIR="$BUILD_DIR/output"
WORKDIR="/tmp/hermes-iso-quick"
SOURCE_DIR="$PROJECT_DIR/src"
info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
ok() { echo -e "\033[0;32m[OK]\033[0m $*"; }
die() { echo -e "\033[0;31m[ERROR]\033[0m $*"; exit 1; }
# Check prerequisites
for cmd in unsquashfs mksquashfs xorriso rsync; do
command -v "$cmd" &>/dev/null || die "$cmd not found"
done
[[ -f "$EXISTING_ISO" ]] || die "Existing ISO not found: $EXISTING_ISO"
rm -rf "$WORKDIR"
mkdir -p "$WORKDIR/iso-contents" "$WORKDIR/squashfs-root"
info "Mounting existing ISO..."
mkdir -p /mnt/hermes-iso-source
mount -o loop "$EXISTING_ISO" /mnt/hermes-iso-source 2>/dev/null || die "Failed to mount ISO"
info "Copying ISO contents (excluding squashfs)..."
rsync -a --exclude="casper/filesystem.squashfs" \
/mnt/hermes-iso-source/ "$WORKDIR/iso-contents/" 2>/dev/null
info "Extracting squashfs..."
cp /mnt/hermes-iso-source/casper/filesystem.squashfs "$WORKDIR/filesystem.squashfs"
umount /mnt/hermes-iso-source 2>/dev/null || true
unsquashfs -d "$WORKDIR/squashfs-root" -f "$WORKDIR/filesystem.squashfs" 2>&1 | tail -3
ok "Squashfs extracted ($(du -sh "$WORKDIR/squashfs-root" | cut -f1))"
info "Updating config.yaml..."
cp "$SOURCE_DIR/hermes/config.yaml" "$WORKDIR/squashfs-root/hermes/config.yaml"
ok "config.yaml updated"
info "Updating auth.json..."
cp "$SOURCE_DIR/hermes/auth.json" "$WORKDIR/squashfs-root/hermes/auth.json"
ok "auth.json updated"
info "Updating autorun.sh..."
cp "$SOURCE_DIR/hermes/autorun.sh" "$WORKDIR/squashfs-root/hermes/autorun.sh"
ok "autorun.sh updated"
info "Re-packing squashfs..."
mksquashfs "$WORKDIR/squashfs-root" "$WORKDIR/iso-contents/casper/filesystem.squashfs" \
-comp lz4 -b 1M -noappend -processors 1 2>&1 | tail -5
ok "Squashfs repacked ($(du -sh "$WORKDIR/iso-contents/casper/filesystem.squashfs" | cut -f1))"
# Update filesystem.size
fs_size=$(du -sx --block-size=1 "$WORKDIR/squashfs-root" 2>/dev/null | cut -f1)
echo "$fs_size" > "$WORKDIR/iso-contents/casper/filesystem.size"
ok "filesystem.size updated"
info "Generating ISO..."
OUTPUT_ISO="$OUTPUT_DIR/hermes-rescue.iso"
EFI_IMG=$(find "$WORKDIR/iso-contents" -name "efi.img" -o -name "eltorito.img" 2>/dev/null | head -1 | sed "s|$WORKDIR/iso-contents/||")
if [[ -n "$EFI_IMG" ]]; then
xorriso -as mkisofs -r -V "HERMES_RESCUE" \
-J -joliet-long \
-isohybrid-mbr "$WORKDIR/iso-contents/isohdpfx.bin" \
-b isolinux/isolinux.bin \
-c isolinux/boot.cat \
-no-emul-boot -boot-load-size 4 -boot-info-table \
-eltorito-alt-boot -e "$EFI_IMG" -no-emul-boot \
-isohybrid-gpt-basdat \
-isohybrid-apm-hfsplus \
-o "$OUTPUT_ISO" \
"$WORKDIR/iso-contents" 2>&1 | tail -5
else
xorriso -as mkisofs -r -V "HERMES_RESCUE" \
-J -joliet-long \
-isohybrid-mbr "$WORKDIR/iso-contents/isohdpfx.bin" \
-b isolinux/isolinux.bin \
-c isolinux/boot.cat \
-no-emul-boot -boot-load-size 4 -boot-info-table \
-o "$OUTPUT_ISO" \
"$WORKDIR/iso-contents" 2>&1 | tail -5
fi
iso_size=$(du -h "$OUTPUT_ISO" | cut -f1)
ok "ISO generated: $OUTPUT_ISO ($iso_size)"
info "Cleaning up..."
rm -rf "$WORKDIR"
ok "Done!"
+13
View File
@@ -5,6 +5,9 @@ Modules
-------
hardware : HardwareInventory
CPU, RAM, GPU, disk enumeration and SMART health checks.
drivers : DriverUpdater
Hardware-aware driver scanner and updater — scans PCI/USB devices
and cross-references against mounted Windows DriverStore.
stress : RAMStressTester, CPUStressTester, GPUStressTester
Stress-test wrappers (available when stress.py is present).
@@ -12,6 +15,7 @@ Each submodule exposes a ``run() -> dict`` for JSON and a
``text_report() -> str`` for human consumption.
"""
from src.diagnostics.hardware import HardwareInventory
from src.diagnostics.drivers import DriverUpdater
try:
from src.diagnostics.stress import (
@@ -23,6 +27,15 @@ try:
except ImportError:
pass
from src.diagnostics.bsod import BSODTester, analyze_minidump
__all__ = [
"BSODTester",
"analyze_minidump",
"HardwareInventory",
"DriverUpdater",
"RAMStressTester",
"CPUStressTester",
"GPUStressTester",
"run_all_stress_tests",
]
+9
View File
@@ -0,0 +1,9 @@
"""
Entry point for `python -m src.diagnostics`.
Delegates to stress.py's CLI by default; can be extended for other diagnostics.
"""
from src.diagnostics.stress import main
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+888
View File
@@ -0,0 +1,888 @@
"""
Hermes Portable Rescue — Hardware-Aware Driver Scanner & Updater.
Scans every PCI and USB device on the system, maps them to human-readable
names via the ``pci.ids`` / ``usb.ids`` databases, and cross-references
against a mounted Windows driver store (``DriverStore/FileRepository``)
when available. Produces a structured report suitable for LLM-guided
driver discovery and installation.
Typical usage::
from diagnostics.drivers import DriverUpdater
updater = DriverUpdater(windows_mount="/mnt/c")
report = updater.run()
print(updater.text_report())
Windows-mount-aware features
-----------------------------
* Scans ``%WINDIR%/System32/DriverStore/FileRepository/`` for installed
Windows driver ``.inf`` files.
* Matches each detected PCI/USB device to the best candidate driver
from the store by vendor + device ID.
* Flags devices with **no** matching Windows driver as missing.
* Reports driver date, version, and provider when available from the INF.
Linux-only fallback
-------------------
If no Windows mount is found, also produces a full hardware inventory
with names and IDs — the ``driver_status`` field defaults to ``"unknown"``
for every device.
"""
from __future__ import annotations
import csv
import io
import json
import logging
import os
import re
import shlex
import subprocess
import sys
from dataclasses import dataclass, field, fields, is_dataclass
from pathlib import Path
from typing import Any, Optional
logger = logging.getLogger(__name__)
# ── Helpers ──────────────────────────────────────────────────────────────────
_PCI_IDS_PATHS = ["/usr/share/hwdata/pci.ids", "/usr/share/misc/pci.ids"]
_USB_IDS_PATHS = ["/usr/share/hwdata/usb.ids", "/usr/share/misc/usb.ids"]
# Regex: vendor 1234 VendorName
# device 1234 DeviceName
# (indented by tab)
_RE_PCI_VENDOR = re.compile(r"^([0-9a-fA-F]{4})\s+(.+)$")
_RE_PCI_DEVICE = re.compile(r"^\t([0-9a-fA-F]{4})\s+(.+)$")
_RE_USB_VENDOR = re.compile(r"^([0-9a-fA-F]{4})\s+(.+)$")
_RE_USB_DEVICE = re.compile(r"^\t([0-9a-fA-F]{4})\s+(.+)$")
# INF file vendor/device parsing
_INF_MANUFACTURER_RE = re.compile(
r'^%([^%]+)%\s*=\s*([^,]+)', re.IGNORECASE
)
_INF_STRINGS_SECTION = re.compile(r'^\[Strings\]', re.IGNORECASE)
_INF_STRING_RE = re.compile(r'^%([^%]+)%\s*=\s*"([^"]*)"')
_INF_VERSION_RE = re.compile(
r'^(DriverVer|DriverVersion|CatalogFile|Provider)\s*=\s*(.+)',
re.IGNORECASE,
)
_INF_MODEL_RE = re.compile(
r'^%([^%]+)%\s*=\s*\w+,\s*(PCI|USB|HDAUDIO|ACPI)\\([^,]+)',
re.IGNORECASE,
)
def _asdict(obj) -> dict[str, Any]:
"""Recursive dataclass → dict, filtering None and empty containers."""
if not is_dataclass(obj):
return obj
out = {}
for f in fields(obj):
val = getattr(obj, f.name)
if isinstance(val, list):
val = [_asdict(v) if is_dataclass(v) else v for v in val]
if not val:
continue
elif is_dataclass(val):
val = _asdict(val)
if not any(val.values()):
continue
elif val is None:
continue
out[f.name] = val
return out
def _run_cmd(cmd: list[str], timeout: int = 15) -> str | None:
"""Run an external command, return stdout or None on failure."""
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
)
if proc.returncode == 0:
return proc.stdout
return None
except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
logger.debug("Command %s failed: %s", shlex.join(cmd), exc)
return None
# ── PCI ID Database ──────────────────────────────────────────────────────────
def _load_pci_ids() -> dict[str, dict[str, str]]:
"""Parse ``pci.ids`` into ``{vendor: {device: name, '_name': vendor_name}}``.
Each vendor dict has a ``_name`` key for the vendor's own name and
4-hex-digit device ID keys mapping to human-readable device names.
"""
db: dict[str, dict[str, str]] = {}
current_vendor: str | None = None
for path in _PCI_IDS_PATHS:
if os.path.isfile(path):
break
else:
return db
with open(path, encoding="utf-8", errors="replace") as fh:
for line in fh:
# Skip comments and blank lines
if not line or line[0] == "#" or line.strip() == "":
continue
m = _RE_PCI_VENDOR.match(line)
if m:
current_vendor = m.group(1).lower()
db[current_vendor] = {"_name": m.group(2).strip()}
continue
m = _RE_PCI_DEVICE.match(line)
if m and current_vendor:
dev_id = m.group(1).lower()
db[current_vendor][dev_id] = m.group(2).strip()
return db
def _load_usb_ids() -> dict[str, dict[str, str]]:
"""Parse ``usb.ids`` into ``{vendor: {device: name, '_name': vendor_name}}``.
Same structure as ``_load_pci_ids`` — 4-hex-digit vendor IDs mapping
to dicts of 4-hex-digit product IDs, plus a ``_name`` entry for the
vendor's own name.
"""
db: dict[str, dict[str, str]] = {}
current_vendor: str | None = None
for path in _USB_IDS_PATHS:
if os.path.isfile(path):
break
else:
return db
with open(path, encoding="utf-8", errors="replace") as fh:
for line in fh:
if not line or line[0] == "#" or line.strip() == "":
continue
m = _RE_USB_VENDOR.match(line)
if m:
current_vendor = m.group(1).lower()
db[current_vendor] = {"_name": m.group(2).strip()}
continue
m = _RE_USB_DEVICE.match(line)
if m and current_vendor:
dev_id = m.group(1).lower()
db[current_vendor][dev_id] = m.group(2).strip()
return db
# ── PCI Device Scanner ───────────────────────────────────────────────────────
def _parse_lspci_verbose(text: str) -> list[dict[str, Any]]:
"""Parse detailed ``lspci -vvnn`` output into structured entries.
Returns a list of dicts, one per PCI device, with keys:
* ``slot`` — ``BB:DD.F``
* ``class`` — device class name
* ``vendor`` — 4-hex vendor ID
* ``device`` — 4-hex device ID
* ``svendor`` — subsystem vendor (or empty)
* ``sdevice`` — subsystem device ID (or empty)
* ``rev`` — revision byte
* ``progif`` — programming interface byte
* ``description`` — short human label
"""
devices: list[dict[str, Any]] = []
current: dict[str, Any] | None = None
for line in text.splitlines():
# Line starting with non-whitespace = new device header
# e.g. "00:02.0 VGA compatible controller [0300]: Intel Corporation [8086] ..."
if line and not line[0].isspace():
if current is not None:
devices.append(current)
current = {}
parts = line.split(None, 2)
if len(parts) >= 2:
current["slot"] = parts[0].rstrip(":")
current["class"] = parts[1]
# Try to extract vendor:device from [xxxx:xxxx] pattern
bracket_ids = re.findall(r"\[([0-9a-fA-F]{4}):([0-9a-fA-F]{4})\]", line)
if bracket_ids:
current["vendor"] = bracket_ids[0][0].lower()
current["device"] = bracket_ids[0][1].lower()
# Subsystem vendor:device [xxxx:xxxx]
if len(bracket_ids) > 1:
current["svendor"] = bracket_ids[1][0].lower()
current["sdevice"] = bracket_ids[1][1].lower()
# Revision
rev_m = re.search(r"\(rev\s+([0-9a-fA-F]+)\)", line)
if rev_m:
current["rev"] = rev_m.group(1)
# ProgIF
prog_m = re.search(r"\(prog-if\s+([0-9a-fA-F]+)\)", line)
if prog_m:
current["progif"] = prog_m.group(1)
elif current is not None:
# Sub-lines: look for subsystem, flags, etc.
# e.g. "Subsystem: Dell [1028:07b5]"
sub_m = re.search(
r"Subsystem:\s+(.+?)\s+\[([0-9a-fA-F]{4}):([0-9a-fA-F]{4})\]",
line,
)
if sub_m and "svendor" not in current:
current["svendor"] = sub_m.group(2).lower()
current["sdevice"] = sub_m.group(3).lower()
if current is not None:
devices.append(current)
return devices
# ── USB Device Scanner ───────────────────────────────────────────────────────
def _parse_lsusb(text: str) -> list[dict[str, Any]]:
"""Parse ``lsusb`` output into structured device entries.
Each entry has:
* ``bus`` — bus number
* ``device`` — device number
* ``vendor`` — 4-hex vendor ID
* ``product`` — 4-hex product ID
* ``description`` — short human label
"""
devices: list[dict[str, Any]] = []
for line in text.splitlines():
m = re.match(
r"Bus\s+(\d+)\s+Device\s+(\d+):\s+ID\s+"
r"([0-9a-fA-F]{4}):([0-9a-fA-F]{4})\s+(.+)",
line,
)
if m:
devices.append(
{
"bus": m.group(1),
"device_addr": m.group(2),
"vendor": m.group(3).lower(),
"product": m.group(4).lower(),
"description": m.group(5).strip(),
}
)
return devices
# ── Windows Driver Store Scanner ──────────────────────────────────────────────
def _find_driver_store(mount_point: str) -> str | None:
"""Locate the Windows DriverStore under *mount_point*.
Checks the most common paths:
* ``C:/Windows/System32/DriverStore/FileRepository``
* ``/mnt/c/Windows/System32/DriverStore/FileRepository``
"""
candidates = [
Path(mount_point) / "Windows" / "System32" / "DriverStore" / "FileRepository",
Path(mount_point) / "windows" / "system32" / "driverstore" / "filerepository",
]
for cand in candidates:
if cand.is_dir():
return str(cand)
return None
def _parse_inf_driver(inf_path: str) -> dict[str, Any]:
"""Parse a single .inf file for driver metadata.
Returns a dict with keys: *provider*, *driver_ver*, *date*, *hw_ids*.
Values default to empty strings / empty lists when not found.
"""
info: dict[str, Any] = {
"provider": "",
"driver_ver": "",
"date": "",
"hw_ids": [],
}
try:
text = Path(inf_path).read_text(encoding="utf-8", errors="replace")
except OSError:
return info
# Default provider from [Version] section or file header
provider = ""
driver_ver = ""
date = ""
inside_strings = False
strings_map: dict[str, str] = {}
for line in text.splitlines():
# Track [Strings] section
if _INF_STRINGS_SECTION.match(line):
inside_strings = True
continue
if inside_strings and line.startswith("["):
inside_strings = False
if inside_strings:
sm = _INF_STRING_RE.match(line)
if sm:
strings_map[sm.group(1).lower()] = sm.group(2)
continue # don't process string defs as model lines
# [Version] or [Manufacturer] section entries
vm = _INF_VERSION_RE.match(line)
if vm:
key = vm.group(1).lower()
val = vm.group(2).strip()
if "provider" in key:
provider = val
elif "driverver" in key:
# "MM/DD/YYYY,version" or just version
date_ver = val.split(",")
if len(date_ver) == 2:
date = date_ver[0].strip()
driver_ver = date_ver[1].strip()
else:
driver_ver = val
# Resolve strings in provider if it's a token
if provider.startswith("%") and provider.endswith("%"):
token = provider.strip("%").lower()
provider = strings_map.get(token, provider)
info["provider"] = provider
info["driver_ver"] = driver_ver
info["date"] = date
# Extract HW IDs from [Manufacturer] or [Models] sections
for line in text.splitlines():
mm = _INF_MODEL_RE.match(line)
if mm:
hw_type = mm.group(2)
hw_id = mm.group(3)
info["hw_ids"].append(f"{hw_type}\\{hw_id}")
return info
def _scan_driver_store(store_path: str) -> list[dict[str, Any]]:
"""Scan a Windows DriverStore/FileRepository directory.
Returns a list of driver entries, each with:
* ``dir_name`` — the repository subdirectory name
* ``inf_files`` — list of .inf files found
* ``parsed`` — structured metadata from the first .inf
"""
drivers: list[dict[str, Any]] = []
repo = Path(store_path)
if not repo.is_dir():
return drivers
for entry in sorted(repo.iterdir()):
if not entry.is_dir():
continue
infs = list(entry.glob("*.inf"))
if not infs:
continue
parsed = _parse_inf_driver(str(infs[0]))
drivers.append(
{
"dir_name": entry.name,
"inf_files": [str(p) for p in infs],
"parsed": parsed,
}
)
return drivers
# ── Dataclasses ──────────────────────────────────────────────────────────────
@dataclass
class PCIDeviceInfo:
"""A single PCI device discovered on the bus."""
slot: str = ""
device_class: str = ""
vendor_id: str = ""
device_id: str = ""
vendor_name: str = ""
device_name: str = ""
subsystem_vendor_id: str = ""
subsystem_device_id: str = ""
revision: str = ""
progif: str = ""
driver_status: str = "unknown" # "present" | "missing" | "stale" | "unknown"
@dataclass
class USBDeviceInfo:
"""A single USB device discovered on the bus."""
bus: str = ""
device_addr: str = ""
vendor_id: str = ""
product_id: str = ""
vendor_name: str = ""
product_name: str = ""
description: str = ""
driver_status: str = "unknown"
@dataclass
class WindowsDriverInfo:
"""Metadata for a driver package found in the Windows DriverStore."""
dir_name: str = ""
inf_files: list[str] = field(default_factory=list)
provider: str = ""
driver_version: str = ""
date: str = ""
hw_ids: list[str] = field(default_factory=list)
@dataclass
class DriverReport:
"""Complete driver inventory report output by ``DriverUpdater.run()``."""
pci_devices: list[PCIDeviceInfo] = field(default_factory=list)
usb_devices: list[USBDeviceInfo] = field(default_factory=list)
windows_drivers: list[WindowsDriverInfo] = field(default_factory=list)
windows_mount: str = ""
driver_count: int = 0
device_count: int = 0
missing_driver_count: int = 0
errors: list[str] = field(default_factory=list)
# ── Main Class ───────────────────────────────────────────────────────────────
class DriverUpdater:
"""Scan all local hardware and cross-reference against Windows drivers.
Call ``run()`` to collect the full inventory; subsequent calls return
the cached result. Use ``text_report()`` or ``to_json()`` for output.
Parameters
----------
windows_mount : str
Path where a Windows partition is mounted
(e.g. ``"/mnt/c"`` or ``"/media/Windows"``).
Pass ``""`` or ``None`` to skip Windows driver-store scanning.
log_warnings : bool
If True, emit a Python log warning on each non-fatal probing issue.
"""
def __init__(
self,
windows_mount: str | None = None,
log_warnings: bool = False,
) -> None:
self._report: Optional[DriverReport] = None
self._windows_mount = (windows_mount or "").rstrip("/")
self._log_warnings = log_warnings
self._pci_db: dict[str, dict[str, str]] = {}
self._usb_db: dict[str, dict[str, str]] = {}
# ── Public API ──────────────────────────────────────────────────────────
def run(self) -> dict[str, Any]:
"""Run the full scan and return a JSON-serialisable dict."""
report = self._probe()
self._report = report
return _asdict(report)
def text_report(self) -> str:
"""Return a human-readable summary of the last ``run()`` result.
Returns a fallback message when ``run()`` has not been called yet.
"""
if self._report is None:
return self._fallback_summary()
r = self._report
lines: list[str] = []
lines.append("=" * 60)
lines.append(" DRIVER INVENTORY REPORT")
lines.append("=" * 60)
lines.append("")
lines.append(f" Total devices: {r.device_count}")
lines.append(f" Missing drivers: {r.missing_driver_count}")
lines.append(f" Windows drivers in store: {r.driver_count}")
if r.windows_mount:
lines.append(f" Windows mount: {r.windows_mount}")
else:
lines.append(" Windows mount: (none — Linux-only scan)")
if r.pci_devices:
lines.append("")
lines.append("-- PCI DEVICES --")
for d in r.pci_devices:
name = d.device_name or d.device_id
vendor = d.vendor_name or d.vendor_id
icon = self._driver_icon(d.driver_status)
lines.append(
f" {icon} {d.slot} {vendor} {name}"
)
if r.usb_devices:
lines.append("")
lines.append("-- USB DEVICES --")
for d in r.usb_devices:
name = d.product_name or d.description or d.product_id
icon = self._driver_icon(d.driver_status)
lines.append(
f" {icon} Bus {d.bus} Dev {d.device_addr} {name}"
)
if r.windows_drivers:
lines.append("")
lines.append("-- WINDOWS DRIVER STORE PACKAGES --")
for d in r.windows_drivers:
ver = f" v{d.driver_version}" if d.driver_version else ""
prov = f" by {d.provider}" if d.provider else ""
lines.append(f"{d.dir_name}{ver}{prov}")
missing = [d for d in r.pci_devices if d.driver_status == "missing"]
if missing:
lines.append("")
lines.append("-- DEVICES WITHOUT MATCHING DRIVER --")
for d in missing:
name = d.device_name or d.device_id
vid = d.vendor_name or d.vendor_id
lines.append(f" !! {d.slot} {vid} {name}")
if r.errors:
lines.append("")
lines.append("-- WARNINGS --")
for err in r.errors:
lines.append(f" !! {err}")
lines.append("")
lines.append("=" * 60)
return "\n".join(lines)
def to_json(self, indent: int = 2) -> str:
"""Return the full report as a JSON string."""
return json.dumps(self.run(), indent=indent, default=str)
def devices_without_drivers(self) -> dict[str, Any]:
"""Convenience: return only devices whose driver_status is "missing"."""
if self._report is None:
return {}
r = self._report
missing_pci = [d for d in r.pci_devices if d.driver_status == "missing"]
missing_usb = [d for d in r.usb_devices if d.driver_status == "missing"]
return _asdict(
DriverReport(
pci_devices=missing_pci,
usb_devices=missing_usb,
windows_mount=r.windows_mount,
driver_count=r.driver_count,
device_count=len(missing_pci) + len(missing_usb),
missing_driver_count=len(missing_pci) + len(missing_usb),
errors=r.errors,
)
)
# ── Internal ────────────────────────────────────────────────────────────
def _probe(self) -> DriverReport:
report = DriverReport()
err = report.errors
# Load hardware ID databases
self._pci_db = _load_pci_ids()
self._usb_db = _load_usb_ids()
# Scan PCI devices
pci_devices = self._scan_pci(err)
report.pci_devices = pci_devices
# Scan USB devices
usb_devices = self._scan_usb(err)
report.usb_devices = usb_devices
# Scan Windows driver store
if self._windows_mount:
store = _find_driver_store(self._windows_mount)
if store:
raw_drivers = _scan_driver_store(store)
report.windows_drivers = self._parse_driver_infos(raw_drivers)
report.windows_mount = self._windows_mount
report.driver_count = len(report.windows_drivers)
self._cross_reference(report)
else:
msg = (
f"Windows mount '{self._windows_mount}' found but "
f"DriverStore/FileRepository not detected"
)
err.append(msg)
if self._log_warnings:
logger.warning("driver updater: %s", msg)
else:
report.driver_count = 0
report.device_count = len(pci_devices) + len(usb_devices)
report.missing_driver_count = sum(
1 for d in pci_devices if d.driver_status == "missing"
) + sum(
1 for d in usb_devices if d.driver_status == "missing"
)
if self._log_warnings and err:
for e in err:
logger.warning("driver updater: %s", e)
return report
def _scan_pci(
self, errors: list[str]
) -> list[PCIDeviceInfo]:
"""Run ``lspci`` and return structured device list."""
out = _run_cmd(["lspci", "-vvnn"])
if out is None:
out = _run_cmd(["lspci", "-vnn"])
if out is None:
out = _run_cmd(["lspci"])
if out is None:
errors.append("lspci not available — cannot enumerate PCI devices")
return []
raw = _parse_lspci_verbose(out)
devices: list[PCIDeviceInfo] = []
for entry in raw:
vid = entry.get("vendor", "")
did = entry.get("device", "")
vendor_name = (
self._pci_db.get(vid, {}).get("_name", "")
if vid in self._pci_db
else ""
) or ""
device_name = (
self._pci_db.get(vid, {}).get(did, "")
if vid in self._pci_db and did
else ""
) or ""
devices.append(
PCIDeviceInfo(
slot=entry.get("slot", ""),
device_class=entry.get("class", ""),
vendor_id=vid,
device_id=did,
vendor_name=vendor_name,
device_name=device_name,
subsystem_vendor_id=entry.get("svendor", ""),
subsystem_device_id=entry.get("sdevice", ""),
revision=entry.get("rev", ""),
progif=entry.get("progif", ""),
driver_status="unknown",
)
)
return devices
def _scan_usb(
self, errors: list[str]
) -> list[USBDeviceInfo]:
"""Run ``lsusb`` and return structured device list."""
out = _run_cmd(["lsusb"])
if out is None:
errors.append("lsusb not available — cannot enumerate USB devices")
return []
raw = _parse_lsusb(out)
devices: list[USBDeviceInfo] = []
for entry in raw:
vid = entry.get("vendor", "")
pid = entry.get("product", "")
vendor_name = (
self._usb_db.get(vid, {}).get("_name", "")
if vid in self._usb_db
else ""
) or ""
product_name = (
self._usb_db.get(vid, {}).get(pid, "")
if vid in self._usb_db and pid
else ""
) or ""
devices.append(
USBDeviceInfo(
bus=entry.get("bus", ""),
device_addr=entry.get("device_addr", ""),
vendor_id=vid,
product_id=pid,
vendor_name=vendor_name,
product_name=product_name,
description=entry.get("description", ""),
driver_status="unknown",
)
)
return devices
def _parse_driver_infos(
self, raw: list[dict[str, Any]]
) -> list[WindowsDriverInfo]:
"""Wrap raw driver-store entries into dataclass objects."""
result: list[WindowsDriverInfo] = []
for entry in raw:
parsed = entry.get("parsed", {})
result.append(
WindowsDriverInfo(
dir_name=entry.get("dir_name", ""),
inf_files=entry.get("inf_files", []),
provider=parsed.get("provider", ""),
driver_version=parsed.get("driver_ver", ""),
date=parsed.get("date", ""),
hw_ids=parsed.get("hw_ids", []),
)
)
return result
def _cross_reference(self, report: DriverReport) -> None:
"""Match PCI and USB devices against installed Windows drivers.
For each device, if any Windows driver's HWIDs contain the PCI
vendor:device pair, mark it as "present"; otherwise mark
"missing".
"""
# Build a set of known hw_ids from the Windows driver store
known_hwids: set[str] = set()
for drv in report.windows_drivers:
for hid in drv.hw_ids:
known_hwids.add(hid.upper())
for dev in report.pci_devices:
# Build candidate HWIDs the device would match
candidates = [
f"PCI\\VEN_{dev.vendor_id.upper()}&DEV_{dev.device_id.upper()}",
f"PCI\\VEN_{dev.vendor_id.upper()}&DEV_{dev.device_id.upper()}&SUBSYS_{dev.subsystem_vendor_id.upper()}{dev.subsystem_device_id.upper()}",
f"PCI\\VEN_{dev.vendor_id.upper()}&DEV_{dev.device_id.upper()}&REV_{dev.revision.upper()}" if dev.revision else "",
]
candidates = [c for c in candidates if c]
if any(c in known_hwids for c in candidates):
dev.driver_status = "present"
else:
dev.driver_status = "missing"
for dev in report.usb_devices:
candidates = [
f"USB\\VID_{dev.vendor_id.upper()}&PID_{dev.product_id.upper()}",
]
if any(c in known_hwids for c in candidates):
dev.driver_status = "present"
else:
dev.driver_status = "missing"
def _driver_icon(self, status: str) -> str:
"""Return a visual icon for the device driver status."""
return {
"present": "",
"missing": "",
"stale": "",
"unknown": "·",
}.get(status, "?")
def _fallback_summary(self) -> str:
"""Return basic system info when no scan has been run yet."""
lines = [
"DriverUpdater -- no scan results yet.",
"Call .run() first to collect data.",
"",
]
try:
lines.append(f"Python: {sys.version.split()[0]}")
lines.append(f"Platform: {sys.platform}")
uname = os.uname()
lines.append(f"Kernel: {uname.sysname} {uname.release} {uname.machine}")
except Exception:
pass
if self._windows_mount:
lines.append(f"Windows mount: {self._windows_mount}")
return "\n".join(lines)
# ── CLI Entry Point ──────────────────────────────────────────────────────────
def main() -> int:
"""Command-line entry point for scanning drivers.
Usage::
python -m diagnostics.drivers [--mount /mnt/c] [--json] [--missing-only]
"""
import argparse
parser = argparse.ArgumentParser(
description="Hermes Portable Rescue — Driver Scanner",
)
parser.add_argument(
"--mount",
default="",
help="Path to mounted Windows partition (e.g. /mnt/c)",
)
parser.add_argument(
"--json",
action="store_true",
help="Output JSON instead of human-readable report",
)
parser.add_argument(
"--missing-only",
action="store_true",
help="Show only devices without matching drivers",
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Enable verbose (debug) logging",
)
args = parser.parse_args()
if args.verbose:
logging.basicConfig(level=logging.DEBUG)
updater = DriverUpdater(
windows_mount=args.mount or None,
log_warnings=args.verbose,
)
if args.missing_only:
result = updater.run()
missing = updater.devices_without_drivers()
if args.json:
print(json.dumps(missing, indent=2, default=str))
else:
report = DriverReport(**missing)
updater._report = report
print(updater.text_report())
return 0 if missing.get("missing_driver_count", 0) == 0 else 1
if args.json:
print(updater.to_json())
else:
updater.run()
print(updater.text_report())
return 0
if __name__ == "__main__":
sys.exit(main())
+48 -8
View File
@@ -159,19 +159,28 @@ def _run(cmd: list[str], timeout: float = 15.0) -> str:
def _parse_int(value: str | None) -> Optional[int]:
if value is None:
return None
try:
return int(value)
except ValueError:
return None
# Extract leading number from strings like "3200 MT/s"
value = value.strip()
m = re.match(r"(\d+)", value)
if m:
try:
return int(m.group(1))
except ValueError:
return None
return None
def _parse_float(value: str | None) -> Optional[float]:
if value is None:
return None
try:
return float(value)
except ValueError:
return None
value = value.strip()
m = re.match(r"(\d+(?:\.\d+)?)", value)
if m:
try:
return float(m.group(1))
except ValueError:
return None
return None
def _find_sys_block() -> list[Path]:
@@ -581,3 +590,34 @@ class HardwareInventory:
except Exception:
pass
return "\n".join(lines)
# -------------------------------------------------------------------
# Run as script: python -m src.diagnostics.hardware
# -------------------------------------------------------------------
def _main() -> None:
"""Run the hardware inventory and print the report to stdout."""
import argparse
parser = argparse.ArgumentParser(description="Hardware inventory & health checks")
parser.add_argument("--json", action="store_true", help="Output JSON instead of text report")
parser.add_argument("--verbose", "-v", action="store_true", help="Log warnings during probing")
args = parser.parse_args()
if args.verbose:
logging.basicConfig(level=logging.WARNING)
hw = HardwareInventory(log_warnings=args.verbose)
hw.run()
if args.json:
print(hw.to_json())
else:
print(hw.text_report())
if __name__ == "__main__":
_main()
+8 -13
View File
@@ -817,6 +817,7 @@ class GPUStressTester:
component="gpu",
backend="+".join(backend_parts),
status=status,
duration_seconds=0.0, # caller overwrites this with actual wall time
summary="; ".join(summary_parts),
details=details,
errors=all_errors,
@@ -874,21 +875,15 @@ def run_all_stress_tests(
def print_report(results: Dict[str, StressResult]) -> None:
"""Pretty-print test results to stdout."""
for component in ("ram", "cpu", "gpu"):
testers = {
"ram": RAMStressTester,
"cpu": CPUStressTester,
"gpu": GPUStressTester,
}
for component, tester_cls in testers.items():
result = results.get(component)
if result is not None:
print()
print(StressResult.report.__doc__) # placeholder — will be replaced
# Actually use the testers' report methods
for component in ("ram", "cpu", "gpu"):
result = results.get(component)
if result is not None:
if component == "ram":
print(RAMStressTester().report(result))
elif component == "cpu":
print(CPUStressTester().report(result))
elif component == "gpu":
print(GPUStressTester().report(result))
print(tester_cls().report(result))
print()
+515
View File
@@ -0,0 +1,515 @@
"""Tests for the hardware inventory module.
Run with::
cd ~/nas-projects/hermes-portable-rescue
python -m pytest src/diagnostics/tests/test_hardware.py -v
"""
import json
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from src.diagnostics.hardware import (
HardwareInventory,
HardwareReport,
CPUInfo,
RAMInfo,
RAMModule,
GPUInfo,
DiskInfo,
DiskSMART,
_asdict,
_find_sys_block,
get_cpu_info,
get_ram_info,
get_gpu_info,
get_disk_info,
)
# ---------------------------------------------------------------------------
# Dataclass helpers
# ---------------------------------------------------------------------------
class TestAsdict:
"""Verify that _asdict correctly filters empty/Nones from dataclasses."""
def test_filters_empty_strings(self):
info = CPUInfo()
d = _asdict(info)
# Empty strings ARE included (they are valid distinct-from-None values)
assert "model" in d
assert d["model"] == ""
def test_filters_none_values(self):
info = CPUInfo(model="Test CPU", max_speed_mhz=None)
d = _asdict(info)
assert "max_speed_mhz" not in d
assert d["model"] == "Test CPU"
def test_preserves_zero_and_false(self):
info = CPUInfo(cores=0, threads=0)
d = _asdict(info)
assert d["cores"] == 0
assert d["threads"] == 0
def test_includes_non_empty_fields(self):
info = CPUInfo(model="Intel i7", cores=8, threads=16)
d = _asdict(info)
assert d["model"] == "Intel i7"
assert d["cores"] == 8
assert d["threads"] == 16
def test_nested_dataclasses(self):
mod = RAMModule(size_gb=8, type="DDR4", slot="DIMM_A1")
ram = RAMInfo(total_gb=8, slots_used=1, slots_total=2, modules=[mod])
d = _asdict(ram)
assert d["total_gb"] == 8
assert len(d["modules"]) == 1
assert d["modules"][0]["size_gb"] == 8
def test_empty_modules_list_filtered(self):
ram = RAMInfo(total_gb=0, slots_used=0, slots_total=0, modules=[])
d = _asdict(ram)
assert "modules" not in d # empty list filtered
# All zero fields become empty dict after filtering
assert "total_gb" in d and d["total_gb"] == 0
# ---------------------------------------------------------------------------
# HardwareInventory class
# ---------------------------------------------------------------------------
class TestHardwareInventory:
"""Integration-style tests for the main HardwareInventory class."""
def test_run_returns_dict(self):
hw = HardwareInventory()
result = hw.run()
assert isinstance(result, dict)
assert "cpu" in result
assert "disks" in result
assert "errors" in result
def test_run_includes_cpu(self):
hw = HardwareInventory()
result = hw.run()
cpu = result.get("cpu", {})
# On *any* real or virtual machine, at minimum architecture is detected
assert isinstance(cpu, dict)
assert "architecture" in cpu # always present on Linux
def test_text_report_after_run(self):
hw = HardwareInventory()
hw.run()
report = hw.text_report()
assert "HARDWARE INVENTORY REPORT" in report
assert "-- CPU --" in report
def test_text_report_before_run_fallback(self):
hw = HardwareInventory()
report = hw.text_report()
assert "no probe results yet" in report
def test_to_json(self):
hw = HardwareInventory()
hw.run()
j = hw.to_json()
parsed = json.loads(j)
assert "cpu" in parsed
assert "disks" in parsed
def test_log_warnings_silent_by_default(self):
hw = HardwareInventory()
result = hw.run()
# Should not crash; errors collected silently
assert isinstance(result["errors"], list)
def test_double_run_returns_same_structure(self):
hw = HardwareInventory()
r1 = hw.run()
r2 = hw.run()
assert r1.keys() == r2.keys()
# ---------------------------------------------------------------------------
# CPU collector (mock-based)
# ---------------------------------------------------------------------------
class TestGetCpuInfo:
"""Test CPU collector with mocked lscpu output."""
LSCPU_OUTPUT = """\
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Address sizes: 39 bits physical, 48 bits virtual
Byte Order: Little Endian
CPU(s): 12
On-line CPU(s) list: 0-11
Vendor ID: GenuineIntel
Model name: Intel(R) Core(TM) i7-8700K CPU @ 3.70GHz
CPU family: 6
Model: 158
Thread(s) per core: 2
Core(s) per socket: 6
Socket(s): 1
Stepping: 10
CPU max MHz: 4700.0000
CPU min MHz: 800.0000
L1d cache: 32K
L1i cache: 32K
L2 cache: 256K
L3 cache: 12288K
Flags: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss ht tm pbe syscall nx pdpe1gb rdtscp lm constant_tsc art arch_perfmon pebs bts rep_good nopl xtopology nonstop_tsc cpuid aperfmperf pni pclmulqdq dtes64 monitor ds_cpl vmx est tm2 ssse3 sdbg fma cx16 xtpr pdcm pcid sse4_1 sse4_2 x2apic movbe popcnt aes xsave avx f16c rdrand lahf_lm abm 3dnowprefetch cpuid_fault epb invpcid_single pti ssbd ibrs ibpb stibp tpr_shadow vnmi flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm mpx rdseed adx smap clflushopt intel_pt xsaveopt xsavec xgetbv1 xsaves dtherm ida arat pln pts hwp hwp_notify hwp_act_window hwp_epp md_clear flush_l1d arch_capabilities
"""
def test_parses_lscpu_output(self):
errors: list[str] = []
with patch(
"src.diagnostics.hardware._run",
return_value=self.LSCPU_OUTPUT,
):
info = get_cpu_info(errors)
assert info.model == "Intel(R) Core(TM) i7-8700K CPU @ 3.70GHz"
assert info.architecture == "x86_64"
assert info.cores == 6
assert info.threads == 12
assert info.max_speed_mhz == 4700
assert info.min_speed_mhz == 800
assert info.cache_l2_kb == 256
assert info.cache_l3_kb == 12288
assert len(info.flags) > 20
assert "ssse3" in info.flags
assert "avx2" in info.flags
@patch("src.diagnostics.hardware._run", return_value="")
def test_fallback_to_proc_cpuinfo(self, mock_run):
errors: list[str] = []
proc_cpuinfo = """\
processor : 0
vendor_id : GenuineIntel
cpu family : 6
model : 158
model name : Intel(R) Core(TM) i5-8400 CPU @ 2.80GHz
stepping : 10
core id : 0
cpu cores : 6
processor : 1
core id : 1
processor : 2
core id : 2
processor : 3
core id : 3
processor : 4
core id : 4
processor : 5
core id : 5
"""
with patch.object(
Path,
"read_text",
return_value=proc_cpuinfo,
):
info = get_cpu_info(errors)
assert info.model == "Intel(R) Core(TM) i5-8400 CPU @ 2.80GHz"
assert info.cores == 6
assert info.threads == 6 # no hyperthreading
def test_empty_lscpu_returns_defaults(self):
errors: list[str] = []
with patch("src.diagnostics.hardware._run", return_value=""):
with patch.object(Path, "read_text", side_effect=OSError):
info = get_cpu_info(errors)
assert info.model == ""
assert info.cores == 0
# ---------------------------------------------------------------------------
# RAM collector (mock-based)
# ---------------------------------------------------------------------------
class TestGetRamInfo:
"""Test RAM collector with mocked dmidecode output."""
DMIDECODE_TYPE17 = """\
# dmidecode 3.5
Getting SMBIOS data from sysfs.
SMBIOS 3.3.0 present.
Handle 0x0013, DMI type 17, 40 bytes
Memory Device
Array Handle: 0x0012
Error Information Handle: Not Provided
Total Width: 64 bits
Data Width: 64 bits
Size: 16384 MB
Form Factor: DIMM
Set: None
Locator: DIMM_A1
Bank Locator: P0 CHANNEL A
Type: DDR4
Type Detail: Synchronous Unbuffered (Unbuffered)
Speed: 3200 MT/s
Manufacturer: Kingston
Serial Number: 1234ABCD
Asset Tag: None
Part Number: KHX3200C16D4/16G
Rank: 2
Configured Memory Speed: 3200 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Handle 0x0014, DMI type 17, 40 bytes
Memory Device
Array Handle: 0x0012
Error Information Handle: Not Provided
Total Width: 64 bits
Data Width: 64 bits
Size: 16384 MB
Form Factor: DIMM
Set: None
Locator: DIMM_A2
Bank Locator: P0 CHANNEL A
Type: DDR4
Type Detail: Synchronous Unbuffered (Unbuffered)
Speed: 3200 MT/s
Manufacturer: Kingston
Serial Number: 5678EFGH
Asset Tag: None
Part Number: KHX3200C16D4/16G
Rank: 2
Configured Memory Speed: 3200 MT/s
Minimum Voltage: 1.2 V
Maximum Voltage: 1.2 V
Configured Voltage: 1.2 V
Handle 0x0015, DMI type 17, 40 bytes
Memory Device
Array Handle: 0x0012
Error Information Handle: Not Provided
Total Width: Unknown
Data Width: Unknown
Size: No Module Installed
Form Factor: DIMM
Set: None
Locator: DIMM_B1
Bank Locator: P0 CHANNEL B
Type: Unknown
Type Detail: Unknown
Handle 0x0016, DMI type 17, 40 bytes
Memory Device
Array Handle: 0x0012
Error Information Handle: Not Provided
Total Width: Unknown
Data Width: Unknown
Size: No Module Installed
Form Factor: DIMM
Set: None
Locator: DIMM_B2
Bank Locator: P0 CHANNEL B
Type: Unknown
Type Detail: Unknown
"""
DMIDECODE_TYPE16 = """\
# dmidecode 3.5
Getting SMBIOS data from sysfs.
SMBIOS 3.3.0 present.
Handle 0x0012, DMI type 16, 23 bytes
Physical Memory Array
Location: System Board Or Motherboard
Use: System Memory
Error Correction Type: None
Maximum Capacity: 64 GB
Error Information Handle: Not Provided
Number Of Devices: 4
"""
def test_parses_dmidecode_output(self):
errors: list[str] = []
def mock_run(cmd, **kw):
if "--type" in cmd:
if "17" in cmd:
return self.DMIDECODE_TYPE17
if "16" in cmd:
return self.DMIDECODE_TYPE16
return ""
with patch("src.diagnostics.hardware._run", side_effect=mock_run):
info = get_ram_info(errors)
assert info.total_gb == 32.0
assert info.slots_used == 2
assert info.slots_total == 4
assert len(info.modules) == 2
m1 = info.modules[0]
assert m1.size_gb == 16.0
assert m1.type == "DDR4"
assert m1.speed_mhz == 3200
assert m1.slot == "DIMM_A1"
assert m1.manufacturer == "Kingston"
assert m1.part_number == "KHX3200C16D4/16G"
assert m1.serial == "1234ABCD"
assert m1.voltage_v == 1.2
m2 = info.modules[1]
assert m2.size_gb == 16.0
assert m2.slot == "DIMM_A2"
@patch("src.diagnostics.hardware._run", return_value="")
def test_no_dmidecode_returns_defaults(self, mock_run):
errors: list[str] = []
info = get_ram_info(errors)
assert info.total_gb == 0.0
assert info.slots_used == 0
assert len(errors) == 1
assert "dmidecode" in errors[0]
def test_with_only_size_data(self):
"""Only Size is mandatory; everything else may be blank."""
errors: list[str] = []
minimal = """\
Handle 0x0013, DMI type 17, 40 bytes
Memory Device
Size: 8192 MB
Locator: DIMM_A1
Type: DDR4
Speed: 2666
"""
with patch("src.diagnostics.hardware._run", return_value=minimal):
info = get_ram_info(errors)
assert len(info.modules) == 1
assert info.modules[0].size_gb == 8.0
assert info.modules[0].speed_mhz == 2666
# ---------------------------------------------------------------------------
# GPU collector (mock-based)
# ---------------------------------------------------------------------------
class TestGetGpuInfo:
"""Test GPU collector with mocked lspci / lshw output."""
LSPCI_MM = (
'pci@0000:01:00.0 "VGA compatible controller" "NVIDIA Corporation"'
' "GA104 [GeForce RTX 3080 Lite Hash Rate]"'
"\n"
'pci@0000:00:02.0 "VGA compatible controller" "Intel Corporation"'
' "CometLake-S GT2 [UHD Graphics 630]"'
"\n"
'pci@0000:00:1f.6 "Ethernet controller" "Intel Corporation"'
' "Ethernet Connection (12) I219-V"'
"\n"
)
def test_parses_lspci_output(self):
errors: list[str] = []
with patch("src.diagnostics.hardware._run", return_value=self.LSPCI_MM):
gpus = get_gpu_info(errors)
assert len(gpus) == 2
assert gpus[0].model == "GA104 [GeForce RTX 3080 Lite Hash Rate]"
assert gpus[0].vendor == "NVIDIA Corporation"
assert gpus[0].pci_slot == "0000:01:00.0"
assert gpus[1].model == "CometLake-S GT2 [UHD Graphics 630]"
assert gpus[1].vendor == "Intel Corporation"
@patch("src.diagnostics.hardware._run", return_value="")
def test_no_lspci_returns_empty(self, mock_run):
errors: list[str] = []
gpus = get_gpu_info(errors)
assert gpus == []
assert len(errors) == 1
assert "lspci" in errors[0]
# ---------------------------------------------------------------------------
# Disk collector (mock-based)
# ---------------------------------------------------------------------------
class TestGetDiskInfo:
"""Test disk collector with mocked sysfs structure."""
def test_find_sys_block_excludes_virtual(self):
"""Verify _find_sys_block filters virtual devices."""
devices = _find_sys_block()
for d in devices:
assert not d.name.startswith(("ram", "loop", "dm-", "zram", "sr", "md"))
def test_empty_sys_block_returns_empty(self):
errors: list[str] = []
with patch("src.diagnostics.hardware._find_sys_block", return_value=[]):
disks = get_disk_info(errors)
assert disks == []
assert len(errors) == 1
def test_smartctl_unavailable_logs_warning(self):
"""When smartctl fails, disk still appears with UNKNOWN SMART status."""
errors: list[str] = []
# Build a fake sysfs entry using real Path objects with mocked methods
fake_dev = Path("/sys/block/sda")
fake_dev
def fake_exists(self) -> bool:
path = str(self)
return path in (
"/sys/block/sda/device",
"/sys/block/sda/size",
"/sys/block/sda/removable",
"/sys/block/sda/queue/rotational",
"/sys/block/sda/device/model",
"/sys/block/sda/device/serial",
)
def fake_read_text(self, *a, **kw) -> str:
mapping = {
"/sys/block/sda/size": "3907029168",
"/sys/block/sda/removable": "0",
"/sys/block/sda/queue/rotational": "1",
"/sys/block/sda/device/model": "WDC WD2003FYYS-02W0B1",
"/sys/block/sda/device/serial": "WD-WCAY01234567",
}
return mapping.get(str(self), "")
with (
patch("src.diagnostics.hardware._find_sys_block", return_value=[fake_dev]),
patch.object(Path, "exists", fake_exists),
patch.object(Path, "read_text", fake_read_text),
patch("src.diagnostics.hardware._run", return_value=""),
):
disks = get_disk_info(errors)
assert len(disks) == 1
assert disks[0].device == "/dev/sda"
assert disks[0].smart.model == "WDC WD2003FYYS-02W0B1"
assert disks[0].smart.serial == "WD-WCAY01234567"
assert disks[0].smart.size_gb == pytest.approx(1863.0, rel=0.1)
assert disks[0].smart.interface == "HDD"
assert disks[0].smart.status == "UNKNOWN" # smartctl unavailable
+4 -4
View File
@@ -155,10 +155,10 @@ run_diagnostics() {
info "Stress tests disabled (enable in config or set RUN_STRESS_TESTS=1)"
fi
# Phase 5: Backup/restore (manual activation)
if [ -n "${RUN_BACKUP}" ]; then
run_script "${HERMES_DIR}/scripts/backup.py" "05-backup.txt"
fi
# Phase 5: Backup/restore (manual activation) — stub placeholder
# if [ -n "${RUN_BACKUP}" ]; then
# run_script "${HERMES_DIR}/scripts/backup.py" "05-backup.txt"
# fi
echo "───────────────────────────────────────────────────────" >> "${LOG_FILE}"
info "Diagnostic pipeline complete."
+56 -41
View File
@@ -1,55 +1,75 @@
# Hermes Portable Rescue — Agent Configuration
# This file is deployed onto the USB and read by Hermes Agent at startup.
# Generated by src/build-hermes-runtime.sh
# Hermes Portable Rescue — ISO Config
# Used by both the autorun diagnostic pipeline and Hermes Agent
# --- Hermes Agent config (standard format) ---
model:
default: deepseek-v4-flash
provider: opencode-go
base_url: https://opencode.ai/zen/go/v1
providers:
opencode-go:
api_key: ""
base_url: https://opencode.ai/zen/go/v1
lmstudio:
api_key: not-needed
base_url: http://127.0.0.1:11434/v1
fallback_providers:
- lmstudio
credential_pool_strategies:
opencode-go:
fallback: []
credential_pool_strategies:
opencode-go: fill_first
agent:
name: "hermes-portable-rescue"
version: "1.0.0"
mode: "rescue" # rescue mode: diagnostic-first, no persistent chat
max_turns: 30
tool_use_enforcement: auto
logging:
level: "INFO"
file: "/tmp/hermes-rescue.log"
max_size_mb: 10
backup_count: 3
level: INFO
max_size_mb: 5
backup_count: 1
# Provider: uses local LLM if available, otherwise API fallback
llm:
mode: "auto" # auto = prefer local, fallback to api
local:
model_path: "${HERMES_ROOT}/models/q4_0.gguf"
context_size: 4096
threads: 4
api:
provider: "openrouter"
model: "openai/gpt-4o-mini"
timeout_seconds: 30
health_check:
max_retries: 2
fallback_to_local: true
display:
personality: concise
language: en
compact: true
auxiliary:
compression:
provider: auto
model: ""
vision:
provider: auto
model: ""
memory:
memory_enabled: true
user_profile_enabled: false
memory_char_limit: 1000
network:
probe_on_start: true
timeout_seconds: 10
# --- Rescue diagnostic pipeline config (read by autorun.sh) ---
rescue:
# Diagnostic sequence — ordered by dependency
diagnostic_sequence:
- hardware_inventory # gather system specs first
- disk_health # SMART checks
- memory_test # RAM stress
- bsod_analyzer # minidump parsing
- cpu_gpu_stress # thermal/load tests
- driver_check # driver inventory
# Storage paths on the USB
- hardware_inventory
- disk_health
- memory_test
- bsod_analyzer
- cpu_gpu_stress
- driver_check
paths:
tools: "${HERMES_ROOT}/tools"
scripts: "${HERMES_ROOT}/scripts"
models: "${HERMES_ROOT}/models"
diagnostics: "${HERMES_ROOT}/diagnostics"
reports: "/tmp/hermes-reports"
# Auto-proceed — run the diagnostic sequence on boot without user input
auto_diagnose: true
# Report output
report:
format: "markdown"
output_dir: "/tmp/hermes-reports"
@@ -58,8 +78,3 @@ rescue:
tools:
manifest: "${HERMES_ROOT}/tool-manifest.json"
timeout_seconds: 300
network:
# Optional: API-based LLM fallback needs network
probe_on_start: true
timeout_seconds: 10