#!/usr/bin/env bash # Savi Dynamics — Bare-metal Home Assistant OS installer # # Run this from a live Linux environment (Ubuntu Live, SystemRescue, Alpine, etc.) # on the target machine. It will pull the latest HAOS generic-x86-64 image from # GitHub, prompt for which internal drive to flash, and write it. # # Usage: # curl -sL https://raw.githubusercontent.com/Savi-Dynamics/savi-tools/main/scripts/savi-haos-install.sh | sudo bash # # Or download first and inspect: # curl -sLO https://raw.githubusercontent.com/Savi-Dynamics/savi-tools/main/scripts/savi-haos-install.sh # less savi-haos-install.sh # sudo bash savi-haos-install.sh set -euo pipefail # ─── Banner ───────────────────────────────────────────────────────────────── cat <<'EOF' ═══════════════════════════════════════════════════════════════ Savi Dynamics — Home Assistant OS Installer ═══════════════════════════════════════════════════════════════ This will: 1. Look up the latest HAOS generic-x86-64 release 2. Show you the drives in this system 3. Ask which one to install HAOS to 4. Wipe that drive and write HAOS to it 5. Tell you to remove the USB and reboot EOF # ─── Preflight ────────────────────────────────────────────────────────────── if [[ $EUID -ne 0 ]]; then echo "ERROR: must run as root (sudo bash ...)" >&2 exit 1 fi for cmd in curl jq xz dd lsblk findmnt sync; do if ! command -v "$cmd" >/dev/null 2>&1; then echo "ERROR: required command not found: $cmd" >&2 echo "Install with: sudo apt-get install -y $cmd (or equivalent)" >&2 exit 1 fi done # ─── Identify the boot device so we don't wipe it ─────────────────────────── # /cdrom is the common Ubuntu live mount; other distros vary BOOT_DISK="" for mnt in /cdrom /run/live/medium /lib/live/mount/medium /run/initramfs/live; do if mountpoint -q "$mnt" 2>/dev/null; then BOOT_SRC=$(findmnt -no SOURCE "$mnt" || true) if [[ -n "$BOOT_SRC" ]]; then BOOT_DISK=$(lsblk -no PKNAME "$BOOT_SRC" 2>/dev/null || true) [[ -n "$BOOT_DISK" ]] && break fi fi done # ─── Find latest HAOS release ─────────────────────────────────────────────── echo "Checking latest HAOS release..." RELEASE_JSON=$(curl -fsSL https://api.github.com/repos/home-assistant/operating-system/releases/latest) HAOS_VERSION=$(echo "$RELEASE_JSON" | jq -r .tag_name) HAOS_URL=$(echo "$RELEASE_JSON" | jq -r '.assets[] | select(.name | test("generic-x86-64-.+\\.img\\.xz$")) | .browser_download_url') HAOS_SIZE=$(echo "$RELEASE_JSON" | jq -r '.assets[] | select(.name | test("generic-x86-64-.+\\.img\\.xz$")) | .size') if [[ -z "$HAOS_URL" || "$HAOS_URL" == "null" ]]; then echo "ERROR: could not find a generic-x86-64 .img.xz asset in the latest release" >&2 exit 1 fi printf " Version: %s\n" "$HAOS_VERSION" printf " Image: %s\n" "$(basename "$HAOS_URL")" printf " Size: %s MB compressed\n\n" "$((HAOS_SIZE / 1024 / 1024))" # ─── Show drives, prompt for target ───────────────────────────────────────── echo "Block devices on this system:" echo lsblk -d -o NAME,SIZE,TYPE,MODEL,SERIAL,TRAN | grep -vE '^(loop|sr|NAME)' | awk ' BEGIN { printf " %-12s %-8s %-8s %-30s %-20s %s\n", "DEVICE", "SIZE", "TYPE", "MODEL", "SERIAL", "BUS" } { printf " /dev/%-7s %-8s %-8s %-30s %-20s %s\n", $1, $2, $3, $4, $5, $6 } ' echo if [[ -n "$BOOT_DISK" ]]; then echo "Detected boot device (the USB you booted from): /dev/$BOOT_DISK" echo "This will be EXCLUDED from the install target list." echo fi read -rp "Enter target device for HAOS install (e.g. /dev/sda or /dev/nvme0n1): " TARGET &2 exit 1 fi # ─── Validate the target ──────────────────────────────────────────────────── if [[ ! -b "$TARGET" ]]; then echo "ERROR: $TARGET is not a block device" >&2 exit 1 fi TARGET_BASENAME=$(basename "$TARGET") if [[ -n "$BOOT_DISK" && "$TARGET_BASENAME" == "$BOOT_DISK" ]]; then echo "ERROR: refusing to install to the boot device ($TARGET)" >&2 echo "That's the USB stick you booted from — wiping it mid-install would brick the install." >&2 exit 1 fi # Make sure target isn't a partition (e.g. /dev/sda1) — must be whole disk TARGET_TYPE=$(lsblk -no TYPE "$TARGET" | head -1) if [[ "$TARGET_TYPE" != "disk" ]]; then echo "ERROR: $TARGET is a $TARGET_TYPE, not a whole disk" >&2 echo "Use the whole disk (e.g. /dev/sda), not a partition (/dev/sda1)" >&2 exit 1 fi # ─── Confirm the destructive op ───────────────────────────────────────────── echo echo "About to wipe $TARGET and install HAOS $HAOS_VERSION:" echo lsblk -o NAME,SIZE,TYPE,FSTYPE,LABEL,MODEL "$TARGET" echo echo "ALL DATA on $TARGET will be destroyed." echo read -rp "Type the device path again to confirm (or anything else to abort): " CONFIRM &2 exit 1 fi # ─── Unmount anything mounted from the target ─────────────────────────────── echo echo "Unmounting any partitions on $TARGET..." for mnt in $(lsblk -nro MOUNTPOINT "$TARGET" | grep -v '^$' || true); do umount "$mnt" 2>/dev/null || true done # ─── Download HAOS ────────────────────────────────────────────────────────── TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT IMG="$TMP/haos.img.xz" echo echo "Downloading HAOS to $IMG..." curl -fL --progress-bar -o "$IMG" "$HAOS_URL" # Verify download size matches GitHub's reported size ACTUAL_SIZE=$(stat -c %s "$IMG" 2>/dev/null || stat -f %z "$IMG") if [[ "$ACTUAL_SIZE" != "$HAOS_SIZE" ]]; then echo "ERROR: download size mismatch (got $ACTUAL_SIZE, expected $HAOS_SIZE)" >&2 exit 1 fi # ─── Flash ────────────────────────────────────────────────────────────────── echo echo "Flashing HAOS to $TARGET..." echo "(Decompressing and writing in one pipe — this can take several minutes)" echo xz -dc "$IMG" | dd of="$TARGET" bs=4M status=progress conv=fsync sync # ─── Done ─────────────────────────────────────────────────────────────────── cat <