Raspberry Pi Zero 2 W image builder; Power off/Restart buttons
pi/build-image.sh makes a ready-to-flash card: Raspberry Pi OS Lite 2026-10-06 + the Gyre software as a service on http://gyre.local (port 80), first-boot setup through cloud-init (user, SSH key, Wi-Fi, packages from the card), USB host mode on the Zero 2's data port. No root: mtools for the boot partition, debugfs for the root filesystem. Wi-Fi and credentials stay in the work dir. Web page: Power off / Restart links when GYRE_POWER_BUTTONS=1 (set by the Pi service); thumbnails keyed by file contents so pre-made ones ship on the card; stale thumbnails pruned. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
97ebae93b2
commit
8a377af116
9 files changed
+336
-6
No files matched your search
@@ -0,0 +1,44 @@
|
||||
# Gyre on a Raspberry Pi Zero 2 W
|
||||
|
||||
A Raspberry Pi Zero 2 W inside the table runs the Gyre software by itself, so you don't need a computer. Open **http://gyre.local** on any phone or computer on your Wi-Fi.
|
||||
|
||||
## What you need
|
||||
- A Raspberry Pi Zero 2 W and a microSD card (8 GB or more).
|
||||
- **Power:** a 12 V to 5 V (3 A) step-down converter with a micro-USB plug. Its input goes to the 12 V terminal on the CNC shield, beside the supply wires; the plug goes into the Pi's **PWR** port. The table keeps one power plug.
|
||||
- **To the Uno:** a micro-USB (male) to USB-B (male) cable, or a micro-USB OTG adapter plus the Uno's usual cable. It goes in the Pi's **USB** port, not PWR. It also powers the Uno.
|
||||
- **Wi-Fi:** a 2.4 GHz network. The Pi Zero 2 W has no 5 GHz radio.
|
||||
|
||||
## Build the card image
|
||||
```
|
||||
pi/build-image.sh # work dir ~/Documents/gyre-pi (or give another one)
|
||||
```
|
||||
1. **First run:** it copies `settings.example.env` to `~/Documents/gyre-pi/settings.env` and stops. Fill in your Wi-Fi name, password and country.
|
||||
2. **Run it again.** In about a minute it writes `~/Documents/gyre-pi/out/gyre-pi.img.xz`.
|
||||
- **What it does:** it downloads Raspberry Pi OS Lite (2026-10-06, 64-bit) and 12 small packages (pyserial and Pillow), all checksum-checked. Then it puts the Gyre software, the packages and the first-boot settings into the image.
|
||||
- **No root needed.**
|
||||
- **Login details:** a password for the `gyre` user and an SSH key (`~/.ssh/gyre_pi`) are made for you. Both are written to `~/Documents/gyre-pi/CREDENTIALS.txt`.
|
||||
- **Kept out of the repository:** your Wi-Fi password and the credentials.
|
||||
|
||||
## Write and start it
|
||||
1. **Write the card:** in Raspberry Pi Imager, choose **Raspberry Pi Zero 2 W**, then **Choose OS → Use custom** and pick `gyre-pi.img.xz`. When it offers OS customisation, answer **No**; everything is already set.
|
||||
2. **Start it:** put the card in the Pi and power the table. The first start takes a few minutes. The Pi:
|
||||
- joins your Wi-Fi as `gyre`;
|
||||
- creates the `gyre` user;
|
||||
- installs the packages from the card;
|
||||
- starts the Gyre service, which restarts itself if it ever stops.
|
||||
3. **Open the page:** go to **http://gyre.local**. If your device doesn't support `.local` names, use the Pi's address from your router.
|
||||
|
||||
## On the page
|
||||
- **Power off and Restart:** links at the bottom, shown only on the Pi. Use Power off before unplugging the table. Pulling the plug on a running Pi can corrupt its card. Power off stops any design and switches the motors off first.
|
||||
- **Everything else** works as on a computer. See `../software/README.md`.
|
||||
|
||||
## Maintenance
|
||||
- **Log in:** `ssh -i ~/.ssh/gyre_pi gyre@gyre.local` (key only). The password in `CREDENTIALS.txt` is for the console and `sudo`.
|
||||
- **Service commands:**
|
||||
- `systemctl status gyre`
|
||||
- `journalctl -u gyre -f`
|
||||
- `sudo systemctl restart gyre`
|
||||
- **Files:** the software is in `/opt/gyre/software`; your uploads go to `/opt/gyre/software/patterns/uploads`.
|
||||
- **Settings:**
|
||||
- `config.txt` puts the Pi Zero 2 W's micro-USB data port in USB host mode. So any micro-USB to USB-B cable works, not only "OTG" ones.
|
||||
- The service runs as `gyre` (in the `dialout` group, for the Uno's port). It's allowed onto port 80, and allowed `sudo` only for power off and restart.
|
||||
Executable
+183
@@ -0,0 +1,183 @@
|
||||
#!/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/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/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."
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# Packages Gyre needs that Raspberry Pi OS Lite (2026-10-06, trixie arm64) doesn't have:
|
||||
# pyserial and Pillow with its libraries. Installed from the card on first boot (no internet needed).
|
||||
# url sha256
|
||||
https://deb.debian.org/debian/pool/main/f/freetype/libfreetype6_2.13.3+dfsg-1+deb13u1_arm64.deb 0c426e83f1af816b9a4df9056fd2238463d1cd07969ee640965da9fb6ecaf107
|
||||
https://deb.debian.org/debian/pool/main/f/fribidi/libfribidi0_1.0.16-1_arm64.deb 5f5bfdf6ef126b0a38f8ebb8751cbbb845fe2cd4e1dd5258d241be243e09897f
|
||||
https://deb.debian.org/debian/pool/main/g/graphite2/libgraphite2-3_1.3.14-2+deb13u1_arm64.deb b0c3414b113de62be1dc28fa2c899ac0856256e2f094d92c36c3275a5053311a
|
||||
https://deb.debian.org/debian/pool/main/h/harfbuzz/libharfbuzz0b_10.2.0-1+deb13u1_arm64.deb 3ff928f981b54b491ee12e4cf68558cc5d61c652bdab276cd4c4c3fbbcee16ca
|
||||
https://deb.debian.org/debian/pool/main/libi/libimagequant/libimagequant0_2.18.0-1+b2_arm64.deb cac78241d9022f4107c9e167aa9e3fe68878eb8b0c6388b780b99ea1ad7eb628
|
||||
https://deb.debian.org/debian/pool/main/l/lcms2/liblcms2-2_2.16-2+deb13u2_arm64.deb 6636997fc488327a8d0951a038df6e5178a52b3f5e5cc2386d42655621f5672d
|
||||
https://deb.debian.org/debian/pool/main/o/openjpeg2/libopenjp2-7_2.5.3-2.1~deb13u2_arm64.deb e27f4d03d4b5836473be3210b16567aa9435a337f8c4537868773a123f9c9e57
|
||||
https://deb.debian.org/debian/pool/main/r/raqm/libraqm0_0.10.2-1_arm64.deb 64fc3ba82f0ed40a2ed03f91aaaa27b8b68284417be1f0c926ae92c87d9f6563
|
||||
https://deb.debian.org/debian/pool/main/libw/libwebp/libwebpdemux2_1.5.0-0.1_arm64.deb 3ce742d0420c9b4c7f875a150eebed63e2e2fb1c7191edb0bdd482c28cc52f51
|
||||
https://deb.debian.org/debian/pool/main/libw/libwebp/libwebpmux3_1.5.0-0.1_arm64.deb 84a98af076003c5a502afb95b2a1412e14dbe029e10272ddcdf2ac6f75d8c0d1
|
||||
https://deb.debian.org/debian/pool/main/p/pillow/python3-pil_11.1.0-5+deb13u4_arm64.deb ad294f2932ceb9115eece925337b19a6beae3164be9ebb7a4d80057c472fd788
|
||||
https://deb.debian.org/debian/pool/main/p/pyserial/python3-serial_3.5-2_all.deb 55adf00b96ef91b1d8ad935d134c341f7c3dd021a5666cd53e75f37144006a51
|
||||
@@ -0,0 +1,2 @@
|
||||
# The Gyre page's Power off / Restart buttons
|
||||
gyre ALL=(root) NOPASSWD: /usr/bin/systemctl poweroff, /usr/bin/systemctl reboot
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Gyre sand art table: web page and pattern player
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
User=gyre
|
||||
WorkingDirectory=/opt/gyre/software
|
||||
Environment=PYTHONUNBUFFERED=1 GYRE_POWER_BUTTONS=1
|
||||
ExecStart=/usr/bin/python3 -m sandtable web --host 0.0.0.0 --http-port 80 --no-browser
|
||||
# lets it use port 80 (so the address is just http://gyre.local) without running as root
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,10 @@
|
||||
# Copy to <work dir>/settings.env (default ~/Documents/gyre-pi/settings.env) and fill in.
|
||||
# It holds your Wi-Fi password, so keep it out of git.
|
||||
SSID="your network" # 2.4 GHz: the Pi Zero 2 W has no 5 GHz radio
|
||||
WIFI_PASSWORD="your password"
|
||||
COUNTRY=US # Wi-Fi country code
|
||||
HOSTNAME=gyre # the page is then at http://gyre.local
|
||||
TIMEZONE=America/New_York
|
||||
SSH_PUBKEY=~/.ssh/gyre_pi.pub # made for you if missing
|
||||
# The 'gyre' user's console/sudo password: leave empty to generate one (saved in CREDENTIALS.txt)
|
||||
USER_PASSWORD=""
|
||||
Reference in new issue
Block a user