#!/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://.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" < "$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."