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:
+32
@@ -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
|
||||
Executable
+734
@@ -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 "$@"
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+96
@@ -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!"
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
@@ -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())
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user