#!/usr/bin/env bash set -euo pipefail # Onefinity CNC Controller - SD Card Backup & Restore # # Backs up the Raspberry Pi's SD card over SSH as a compressed image. # Compression runs on the local machine (fast), raw bytes stream from the Pi. # # Usage: # ./onefinity-backup.sh backup # backup with defaults # ./onefinity-backup.sh backup -o myfile.gz # custom output file # ./onefinity-backup.sh restore image.gz # restore to SD card # ./onefinity-backup.sh verify image.gz # verify image integrity # # Environment: # ONEFINITY_HOST - Pi IP/hostname (default: 10.1.10.55) # ONEFINITY_USER - SSH user (default: bbmc) # ONEFINITY_PASS - sudo password (default: onefinity) HOST="${ONEFINITY_HOST:-10.1.10.55}" USER="${ONEFINITY_USER:-bbmc}" PASS="${ONEFINITY_PASS:-onefinity}" BACKUP_DIR="$(cd "$(dirname "$0")" && pwd)" DEVICE="/dev/mmcblk0" ssh_cmd() { ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o LogLevel=ERROR "$USER@$HOST" "$@" } sudo_ssh() { ssh_cmd "echo '$PASS' | sudo -S bash -c '$1' 2>/dev/null" } die() { echo "ERROR: $*" >&2; exit 1; } # ── Backup ────────────────────────────────────────────────────────────────── do_backup() { local outfile="" while [[ $# -gt 0 ]]; do case "$1" in -o|--output) outfile="$2"; shift 2 ;; *) die "Unknown option: $1" ;; esac done if [[ -z "$outfile" ]]; then outfile="$BACKUP_DIR/onefinity-$(date +%Y%m%d-%H%M).img.gz" fi echo "╔══════════════════════════════════════════════════════╗" echo "║ Onefinity CNC Controller - SD Card Backup ║" echo "╚══════════════════════════════════════════════════════╝" echo "" echo " Host: $USER@$HOST" echo " Device: $DEVICE" echo " Output: $outfile" echo "" # Check connectivity echo "→ Checking SSH connection..." ssh_cmd 'hostname' >/dev/null 2>&1 || die "Cannot SSH to $USER@$HOST" # Get card size local card_bytes card_bytes=$(sudo_ssh "blockdev --getsize64 $DEVICE") local card_gb=$(echo "scale=1; $card_bytes / 1073741824" | bc) echo " SD card: ${card_gb}GB ($card_bytes bytes)" echo "" # Check for enough local disk space (compressed is ~4% of raw) local avail_bytes avail_bytes=$(df -P "$(dirname "$outfile")" | tail -1 | awk '{print $4 * 1024}') local need_bytes=$((card_bytes / 10)) # conservative: assume 10% compressed if (( avail_bytes < need_bytes )); then die "Not enough local disk space. Need ~$(echo "scale=1; $need_bytes/1073741824" | bc)GB, have $(echo "scale=1; $avail_bytes/1073741824" | bc)GB" fi # Stream raw dd from Pi, compress locally with gzip # The Pi's SD card reads at ~20MB/s which is the bottleneck. # Compressing locally on a fast machine is much better than on the ARM. echo "→ Streaming SD card image (this takes ~20-50 minutes)..." echo " Pi: dd → SSH → local gzip → $outfile" echo "" local start_time=$SECONDS local tmpfile="${outfile}.partial" ssh_cmd "echo '$PASS' | sudo -S dd if=$DEVICE bs=4M 2>/dev/null" 2>/dev/null \ | gzip -1 > "$tmpfile" & local pid=$! # Progress monitor while kill -0 $pid 2>/dev/null; do sleep 15 if [[ -f "$tmpfile" ]]; then local size_h size_h=$(ls -lh "$tmpfile" 2>/dev/null | awk '{print $5}') local elapsed=$(( SECONDS - start_time )) local min=$(( elapsed / 60 )) local sec=$(( elapsed % 60 )) printf "\r %dm%02ds elapsed — %s compressed" "$min" "$sec" "$size_h" fi done wait $pid local exit_code=$? echo "" if [[ $exit_code -ne 0 ]]; then rm -f "$tmpfile" die "Backup failed (exit code $exit_code)" fi mv "$tmpfile" "$outfile" local elapsed=$(( SECONDS - start_time )) local final_size final_size=$(ls -lh "$outfile" | awk '{print $5}') echo "" echo "→ Verifying image integrity..." if gzip -t "$outfile" 2>/dev/null; then echo " ✓ gzip integrity OK" else die "Image file is corrupt!" fi # Verify full size by counting decompressed bytes local actual_bytes actual_bytes=$(gzip -d -c "$outfile" | wc -c | tr -d ' ') if [[ "$actual_bytes" -eq "$card_bytes" ]]; then echo " ✓ Size matches: $actual_bytes bytes (full ${card_gb}GB card)" else echo " ⚠ Size mismatch: expected $card_bytes, got $actual_bytes" fi echo "" echo "╔══════════════════════════════════════════════════════╗" echo " ✓ Backup complete" echo " File: $outfile" echo " Size: $final_size compressed (${card_gb}GB raw)" echo " Time: $(( elapsed / 60 ))m $(( elapsed % 60 ))s" echo "╚══════════════════════════════════════════════════════╝" } # ── Restore ───────────────────────────────────────────────────────────────── do_restore() { local imgfile="$1" local target="${2:-}" [[ -f "$imgfile" ]] || die "Image file not found: $imgfile" echo "╔══════════════════════════════════════════════════════╗" echo "║ Onefinity CNC Controller - SD Card Restore ║" echo "╚══════════════════════════════════════════════════════╝" echo "" if [[ -n "$target" ]]; then # ── Local restore: write to a local SD card device ── [[ -b "$target" ]] || die "$target is not a block device" local target_bytes target_bytes=$(diskutil info -plist "$target" 2>/dev/null \ | plutil -extract TotalSize raw - 2>/dev/null \ || blockdev --getsize64 "$target" 2>/dev/null \ || echo 0) echo " Image: $imgfile" echo " Target: $target ($(echo "scale=1; $target_bytes/1073741824" | bc)GB)" echo "" echo " ⚠ THIS WILL ERASE ALL DATA ON $target" echo "" read -rp " Type YES to continue: " confirm [[ "$confirm" == "YES" ]] || die "Aborted" echo "" echo "→ Unmounting target..." diskutil unmountDisk "$target" 2>/dev/null || true echo "→ Writing image to $target..." local raw_target raw_target=$(echo "$target" | sed 's|/dev/disk|/dev/rdisk|') gzip -d -c "$imgfile" | sudo dd of="$raw_target" bs=4M status=progress sync echo "" echo " ✓ Restore complete. Safe to eject $target." else # ── Remote restore: write back to Pi over SSH ── echo " Image: $imgfile" echo " Target: $USER@$HOST:$DEVICE" echo "" echo " ⚠ THIS WILL ERASE THE PI'S SD CARD" echo " ⚠ The Pi must be booted from USB/network, not the SD card" echo "" read -rp " Type YES to continue: " confirm [[ "$confirm" == "YES" ]] || die "Aborted" echo "" echo "→ Writing image to $HOST:$DEVICE..." gzip -d -c "$imgfile" \ | ssh_cmd "echo '$PASS' | sudo -S dd of=$DEVICE bs=4M 2>/dev/null" echo "" echo " ✓ Remote restore complete." fi } # ── Verify ────────────────────────────────────────────────────────────────── do_verify() { local imgfile="$1" [[ -f "$imgfile" ]] || die "Image file not found: $imgfile" echo "Verifying: $imgfile" echo "" local compressed_size compressed_size=$(ls -lh "$imgfile" | awk '{print $5}') echo " Compressed size: $compressed_size" echo " Checking gzip integrity..." if gzip -t "$imgfile" 2>/dev/null; then echo " ✓ gzip OK" else die "gzip integrity check FAILED" fi echo " Counting uncompressed bytes..." local raw_bytes raw_bytes=$(gzip -d -c "$imgfile" | wc -c | tr -d ' ') local raw_gb=$(echo "scale=1; $raw_bytes / 1073741824" | bc) echo " ✓ Uncompressed size: ${raw_gb}GB ($raw_bytes bytes)" echo " Checking partition table..." gzip -d -c "$imgfile" 2>/dev/null | head -c 512 | xxd | head -4 || true echo "" echo " ✓ Image looks valid" } # ── Main ──────────────────────────────────────────────────────────────────── usage() { cat < [options] Commands: backup [-o file.img.gz] Backup SD card from Pi over SSH restore [/dev/diskN] Restore image to local SD card or remote Pi verify Verify image integrity Environment variables: ONEFINITY_HOST Pi address (default: 10.1.10.55) ONEFINITY_USER SSH user (default: bbmc) ONEFINITY_PASS sudo password (default: onefinity) Examples: $(basename "$0") backup $(basename "$0") backup -o /tmp/mybackup.img.gz $(basename "$0") restore backup/onefinity-20260430.img.gz /dev/disk4 $(basename "$0") verify backup/onefinity-20260430.img.gz EOF exit 1 } [[ $# -ge 1 ]] || usage case "$1" in backup) shift; do_backup "$@" ;; restore) shift; [[ $# -ge 1 ]] || usage; do_restore "$@" ;; verify) shift; [[ $# -ge 1 ]] || usage; do_verify "$@" ;; *) usage ;; esac