Files
sandTable/pi/build-image.sh
T
ckochandClaude Opus 5.5 f9b4c08d60 Point local work folders at ~/Documents/projects/sandTable-local
The KiCad working copy and the Pi image work dir moved out of ~/kicad and
~/Documents into ~/Documents/projects/sandTable-local.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 10:48:07 -05:00

184 lines
7.7 KiB
Bash
Executable File

#!/bin/bash
# Builds a ready-to-flash SD card image for a Raspberry Pi Zero 2 W that runs Gyre by itself:
# Raspberry Pi OS Lite + the Gyre software as a service on http://<hostname>.local
#
# pi/build-image.sh [work dir] (default work dir: ~/Documents/projects/sandTable-local/gyre-pi)
#
# The work dir holds settings.env (your Wi-Fi; see settings.example.env), the downloads, the
# build scratch area and out/gyre-pi.img.xz. Nothing secret is written inside the repository.
# No root needed: the boot partition is edited with mtools, the root filesystem with debugfs.
set -euo pipefail
REPO=$(cd "$(dirname "$0")/.." && pwd)
PI="$REPO/pi"
W=$(realpath -m "${1:-$HOME/Documents/projects/sandTable-local/gyre-pi}")
mkdir -p "$W"/{download,debs,tools,out}
[ -f "$W/settings.env" ] || { cp "$PI/settings.example.env" "$W/settings.env"; echo "Fill in $W/settings.env, then run again."; exit 1; }
. "$W/settings.env"
[ -n "${SSID:-}" ] && [ "$SSID" != "your network" ] || { echo "Set SSID in $W/settings.env"; exit 1; }
SSH_PUBKEY=${SSH_PUBKEY/#\~/$HOME}
BASE_URL=https://downloads.raspberrypi.com/raspios_lite_arm64/images/raspios_lite_arm64-2026-10-06/2026-10-06-raspios-trixie-arm64-lite.img.xz
BASE="$W/download/$(basename "$BASE_URL")"
B="$W/build"; OUT="$W/out/gyre-pi.img"
rm -rf "$B"; mkdir -p "$B/gen" "$B/sw"
echo "== downloads"
if [ ! -f "$BASE" ]; then
curl -fSL -o "$BASE" "$BASE_URL"
curl -fsSL -o "$BASE.sha256" "$BASE_URL.sha256"
fi
(cd "$W/download" && sha256sum -c "$(basename "$BASE").sha256" >/dev/null) || { echo "base image checksum failed"; exit 1; }
grep -v '^#' "$PI/debs.txt" | while read -r url sum; do
f="$W/debs/$(basename "$url")"
[ -f "$f" ] || curl -fsSL -o "$f" "$url"
echo "$sum $f" | sha256sum -c --quiet || { echo "checksum failed: $f"; exit 1; }
done
if command -v mcopy >/dev/null; then MT=$(dirname "$(command -v mcopy)")
else
MT="$W/tools/root/usr/bin"
[ -x "$MT/mcopy" ] || (cd "$W/tools" && apt-get download mtools >/dev/null && dpkg -x mtools_*.deb root)
fi
echo "== keys and passwords"
if [ ! -f "$SSH_PUBKEY" ]; then ssh-keygen -q -t ed25519 -N '' -C gyre-pi -f "${SSH_PUBKEY%.pub}"; fi
umask 077
if [ -z "${USER_PASSWORD:-}" ]; then
if [ -f "$W/CREDENTIALS.txt" ]; then USER_PASSWORD=$(sed -n 's/^user gyre password: //p' "$W/CREDENTIALS.txt"); fi
[ -n "$USER_PASSWORD" ] || USER_PASSWORD=$(openssl rand -base64 12 | tr -d '/+=' | cut -c1-14)
fi
cat > "$W/CREDENTIALS.txt" <<EOF
Gyre Raspberry Pi ($HOSTNAME)
page: http://$HOSTNAME.local
user gyre password: $USER_PASSWORD
ssh: ssh -i ${SSH_PUBKEY%.pub} gyre@$HOSTNAME.local (key only; the password is for the console and sudo)
EOF
HASH=$(openssl passwd -6 "$USER_PASSWORD")
umask 022
echo "== unpack base image"
xz -dc "$BASE" > "$OUT.tmp"
part() { sfdisk -d "$OUT.tmp" | sed -n "s/.*img.tmp$1 : start= *\([0-9]*\), size= *\([0-9]*\),.*/\1 \2/p"; }
read -r BSTART BSIZE < <(part 1)
read -r RSTART RSIZE < <(part 2)
dd if="$OUT.tmp" of="$B/boot.vfat" bs=512 skip="$BSTART" count="$BSIZE" status=none
dd if="$OUT.tmp" of="$B/root.ext4" bs=512 skip="$RSTART" count="$RSIZE" status=none
dbg() { debugfs -R "$1" "$B/root.ext4" 2>/dev/null; }
echo "== first-boot settings (cloud-init, on the boot partition)"
python3 - "$B/gen" "$HOSTNAME" "$TIMEZONE" "$HASH" "$SSH_PUBKEY" "$SSID" "$WIFI_PASSWORD" "$COUNTRY" <<'PY'
import json, sys, time
out, host, tz, hsh, key, ssid, pw, cc = sys.argv[1:9]
q = json.dumps # a JSON string is a valid YAML double-quoted string
pub = open(key).read().strip()
open(f'{out}/user-data', 'w').write(f"""#cloud-config
# Gyre sand art table: set up on the first boot
hostname: {q(host)}
manage_etc_hosts: true
timezone: {q(tz)}
users:
- name: gyre
gecos: Gyre sand art table
groups: [users, dialout, sudo, plugdev, netdev, video]
shell: /bin/bash
lock_passwd: false
passwd: {q(hsh)}
ssh_authorized_keys:
- {q(pub)}
ssh_pwauth: false
runcmd:
- [sh, -c, "dpkg -i /var/lib/gyre/debs/*.deb || (apt-get update && apt-get -y -f install)"]
- [chown, -R, "gyre:gyre", /opt/gyre]
- [systemctl, enable, --now, ssh]
- [systemctl, enable, --now, gyre]
""")
open(f'{out}/network-config', 'w').write(f"""network:
version: 2
wifis:
wlan0:
dhcp4: true
optional: true
regulatory-domain: {q(cc)}
access-points:
{q(ssid)}:
password: {q(pw)}
""")
open(f'{out}/meta-data', 'w').write(f"dsmode: local\ninstance_id: gyre-{int(time.time())}\n")
PY
chmod 600 "$B/gen/network-config"
"$MT/mtype" -i "$B/boot.vfat" ::/config.txt > "$B/gen/config.txt"
grep -q 'Gyre:' "$B/gen/config.txt" || cat >> "$B/gen/config.txt" <<'EOF'
[pi02]
# Gyre: the micro-USB data port is a USB host for the Arduino, whatever cable is used
dtoverlay=dwc2,dr_mode=host
[all]
EOF
for f in user-data network-config meta-data config.txt; do
"$MT/mcopy" -o -i "$B/boot.vfat" "$B/gen/$f" "::/$f"
done
echo "== Gyre software and packages (root filesystem)"
SW="$REPO/software"
# pre-made thumbnails, so the Pi doesn't spend its first minutes drawing them
(cd "$SW" && python3 -c "from sandtable import config; from sandtable.web import Library; Library(config.load()).warm()")
(cd "$SW" && find sandtable patterns firmware -type f ! -path '*/__pycache__/*' ! -name '*.pyc'
echo sandtable.toml; echo requirements.txt; echo README.md
find .cache/thumbs -type f) | sort > "$B/sw.list"
# every folder the files need, parents first
mapfile -t DIRS < <(python3 - "$B/sw.list" <<'PY'
import sys
dirs = {'/opt/gyre', '/opt/gyre/software', '/var/lib/gyre', '/var/lib/gyre/debs'}
for line in open(sys.argv[1]):
parts = line.strip().split('/')[:-1]
for i in range(1, len(parts) + 1):
dirs.add('/opt/gyre/software/' + '/'.join(parts[:i]))
for d in sorted(dirs, key=lambda d: (d.count('/'), d)):
print(d)
PY
)
FILES=()
while read -r f; do FILES+=("/opt/gyre/software/$f 644 $SW/$f"); done < "$B/sw.list"
for d in "$W"/debs/*.deb; do FILES+=("/var/lib/gyre/debs/$(basename "$d") 644 $d"); done
FILES+=("/etc/systemd/system/gyre.service 644 $PI/overlay/etc/systemd/system/gyre.service")
FILES+=("/etc/sudoers.d/gyre-power 440 $PI/overlay/etc/sudoers.d/gyre-power")
CMD="$B/debugfs.cmd"; : > "$CMD"
for d in "${DIRS[@]}"; do
dbg "stat $d" | grep -q 'Type: directory' || echo "mkdir $d" >> "$CMD"
printf 'sif %s uid 0\nsif %s gid 0\nsif %s mode 040755\n' "$d" "$d" "$d" >> "$CMD"
done
for e in "${FILES[@]}"; do
read -r path mode src <<<"$e"
printf 'rm %s\nwrite %s %s\nsif %s uid 0\nsif %s gid 0\nsif %s mode 0100%s\n' \
"$path" "$src" "$path" "$path" "$path" "$path" "$mode" >> "$CMD"
done
debugfs -w -f "$CMD" "$B/root.ext4" > "$B/debugfs.log" 2>&1
if grep -vE '^debugfs |^rm: File not found by ext2_lookup' "$B/debugfs.log" | grep -iE 'error|fail|not found|cannot|could not|bad'; then
echo "debugfs reported problems (see $B/debugfs.log)"; exit 1
fi
echo "== check"
e2fsck -fn "$B/root.ext4" > "$B/fsck.log" 2>&1 || { cat "$B/fsck.log"; exit 1; }
tail -1 "$B/fsck.log"
fail=0
for e in "${FILES[@]}"; do
read -r path mode src <<<"$e"
cmp -s <(dbg "cat $path") "$src" || { echo "MISMATCH $path"; fail=1; }
done
for f in user-data network-config meta-data config.txt; do
cmp -s <("$MT/mtype" -i "$B/boot.vfat" "::/$f") "$B/gen/$f" || { echo "MISMATCH boot/$f"; fail=1; }
done
[ $fail = 0 ] || exit 1
echo "all ${#FILES[@]} files and 4 boot files verified"
echo "== assemble"
dd if="$B/boot.vfat" of="$OUT.tmp" bs=512 seek="$BSTART" conv=notrunc status=none
dd if="$B/root.ext4" of="$OUT.tmp" bs=512 seek="$RSTART" conv=notrunc status=none
mv "$OUT.tmp" "$OUT"
rm -f "$OUT.xz"
xz -T0 -6 -k "$OUT"
(cd "$W/out" && sha256sum gyre-pi.img gyre-pi.img.xz > SHA256SUMS)
rm -rf "$B"
ls -l "$W/out"
echo "Done. Write $OUT.xz with Raspberry Pi Imager: Choose OS > Use custom, and answer NO to OS customisation."