From 8a377af11602af89876bbb0ef3a46ca0daf3d933 Mon Sep 17 00:00:00 2001 From: Christopher Koch Date: Sun, 1 Nov 2026 21:10:06 -0500 Subject: [PATCH] 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 --- README.md | 1 + pi/README.md | 44 +++++ pi/build-image.sh | 183 +++++++++++++++++++++ pi/debs.txt | 15 ++ pi/overlay/etc/sudoers.d/gyre-power | 2 + pi/overlay/etc/systemd/system/gyre.service | 17 ++ pi/settings.example.env | 10 ++ software/sandtable/web.py | 59 ++++++- software/tests/test_web.py | 11 ++ 9 files changed, 336 insertions(+), 6 deletions(-) create mode 100644 pi/README.md create mode 100755 pi/build-image.sh create mode 100644 pi/debs.txt create mode 100644 pi/overlay/etc/sudoers.d/gyre-power create mode 100644 pi/overlay/etc/systemd/system/gyre.service create mode 100644 pi/settings.example.env diff --git a/README.md b/README.md index 564fe3c..bcc427d 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Gyre is a 12" kinetic sand table that draws patterns in sand. A steel ball rolls - `sand-table-v3.solidcraft`: the printable design (latest). Bodies named `REF_*` are bought parts, modeled for fit only. `sand-table-v2.solidcraft` is the previous version. - `print/v3/`: STL files for every printed part of the v3 build. **Use this set.** Print the two fit tests (`00`, `00b`) first. `print/README.md` has the parts list, print notes and assembly order; the older folders are kept for reference. - `software/`: the pattern player. Start it with `python3 -m sandtable web` for a web page with a library of designs; it drives the table from a computer over USB and erases between patterns. See `software/README.md`. +- `pi/`: run Gyre on a Raspberry Pi Zero 2 W inside the table, with no computer needed: builds a ready-to-flash SD card image. See `pi/README.md`. - `pcb/`: an optional custom controller board (ESP32 + 2× DRV8825, FluidNC) that fits the Uno standoffs, with PCBWay order files. See `pcb/README.md`. - `sand-table-v2-assembly.step`: the v2 assembly, for viewing in any CAD tool. - `design-notes.md`: how it works, dimensions, parts to buy, hardware list and print settings. diff --git a/pi/README.md b/pi/README.md new file mode 100644 index 0000000..a337169 --- /dev/null +++ b/pi/README.md @@ -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. diff --git a/pi/build-image.sh b/pi/build-image.sh new file mode 100755 index 0000000..bcb76be --- /dev/null +++ b/pi/build-image.sh @@ -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://.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" < "$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." diff --git a/pi/debs.txt b/pi/debs.txt new file mode 100644 index 0000000..dc70774 --- /dev/null +++ b/pi/debs.txt @@ -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 diff --git a/pi/overlay/etc/sudoers.d/gyre-power b/pi/overlay/etc/sudoers.d/gyre-power new file mode 100644 index 0000000..a6ae1fd --- /dev/null +++ b/pi/overlay/etc/sudoers.d/gyre-power @@ -0,0 +1,2 @@ +# The Gyre page's Power off / Restart buttons +gyre ALL=(root) NOPASSWD: /usr/bin/systemctl poweroff, /usr/bin/systemctl reboot diff --git a/pi/overlay/etc/systemd/system/gyre.service b/pi/overlay/etc/systemd/system/gyre.service new file mode 100644 index 0000000..64b9199 --- /dev/null +++ b/pi/overlay/etc/systemd/system/gyre.service @@ -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 diff --git a/pi/settings.example.env b/pi/settings.example.env new file mode 100644 index 0000000..d104a9c --- /dev/null +++ b/pi/settings.example.env @@ -0,0 +1,10 @@ +# Copy to /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="" diff --git a/software/sandtable/web.py b/software/sandtable/web.py index c257f99..ad7f74b 100644 --- a/software/sandtable/web.py +++ b/software/sandtable/web.py @@ -25,6 +25,7 @@ CACHE_DIR = os.path.join(SOFTWARE, '.cache', 'thumbs') REMOVED_DIR = os.path.join(SOFTWARE, 'removed-patterns') UPLOADS = 'uploads' LOG_PATH = os.path.join(SOFTWARE, '.cache', 'web.log') +POWER_BUTTONS = os.environ.get('GYRE_POWER_BUTTONS') == '1' # set by the Raspberry Pi service def log(msg): @@ -51,6 +52,7 @@ class Library: self.cfg, self.root, self.cache, self.removed = cfg, root, cache, removed os.makedirs(cache, exist_ok=True) self.lock = threading.Lock() + self._hashes = {} self.index_path = os.path.join(cache, 'index.json') try: with open(self.index_path) as f: @@ -82,8 +84,18 @@ class Library: return os.path.basename(ident)[:-4], thr.load(self.path(ident)) def _key(self, ident): - st = os.stat(self.path(ident)) - return f'{st.st_mtime_ns}-{st.st_size}' + """Identifies a design's contents, so a thumbnail made on another computer (the Pi image + ships them pre-made) is still recognised. Hashed once per file version.""" + p = self.path(ident) + st = os.stat(p) + stamp = (st.st_mtime_ns, st.st_size) + hit = self._hashes.get(p) + if not hit or hit[0] != stamp: + import hashlib + with open(p, 'rb') as f: + hit = (stamp, f'{st.st_size}-{hashlib.sha1(f.read()).hexdigest()[:16]}') + self._hashes[p] = hit + return hit[1] def thumb(self, ident, size=360): """PNG path for a design's thumbnail, made on first use.""" @@ -109,12 +121,19 @@ class Library: return png def warm(self): - """Make every missing thumbnail (in the background at start-up).""" + """Make every missing thumbnail (in the background at start-up) and delete stale ones.""" + keep = set() for it in self.items(): try: - self.thumb(it['id']) + keep.add(os.path.basename(self.thumb(it['id']))) except Exception: pass + for fn in os.listdir(self.cache): + if fn.endswith('.png') and fn not in keep: + try: + os.remove(os.path.join(self.cache, fn)) + except OSError: + pass def add(self, filename, text): """Save an uploaded design into patterns/uploads/ and return its id. A .thr file is kept as @@ -411,7 +430,8 @@ a{color:var(--acc)}
Click a design to draw it. Each one starts with an erase spiral (turn off "erase first" to draw over the sand as it is). Make your own at sandify.org (export as Theta Rho .thr), or upload a line drawing as .svg: every line in it is joined into one path for the ball. - · Jog & calibrate
+ · Jog & calibrate + """ @@ -514,6 +540,25 @@ def serve(ctrl, cfg, host='127.0.0.1', port=8765, open_browser=True, open_path=' jog_page = JOG_PAGE.replace('

Gyre: jog & calibrate

', '

← Library

Gyre: jog & calibrate

') + def power(what): + """Shut the computer this runs on down (or restart it) cleanly: stop any design and let + the motors rest first. Pulling the plug on a Raspberry Pi can corrupt its SD card.""" + if not POWER_BUTTONS: + return False, 'not available here' + log(f'{what} requested from the page') + player.stop() + try: + if panel.connected: + with panel.lock: + ctrl.motors_off() + except Exception: + pass + import subprocess + r = subprocess.run(['sudo', '-n', '/usr/bin/systemctl', what], capture_output=True, text=True) + if r.returncode != 0: + return False, f'could not {what}: {(r.stderr or r.stdout).strip()}' + return True, 'powering off: wait until the Pi\'s green light stops blinking before unplugging' if what == 'poweroff' else 'restarting' + def busy(): if not panel.connected: return False, panel.error or 'the Uno is not connected' @@ -545,7 +590,7 @@ def serve(ctrl, cfg, host='127.0.0.1', port=8765, open_browser=True, open_path=' self._send(200, json.dumps({'machine': st, 'player': player.status(), 'state': st['state'], 'theta': st['theta'], 'rho': st['rho'], 'switch': st['switch'], 'pins': st['pins'], 'homed': st['homed'], 'steps': st['steps'], - 'error': st['error']})) + 'error': st['error'], 'power_buttons': POWER_BUTTONS})) elif u.path == '/api/library': self._send(200, json.dumps(lib.items())) elif u.path == '/thumb': @@ -595,6 +640,8 @@ def serve(ctrl, cfg, host='127.0.0.1', port=8765, open_browser=True, open_path=' ok, msg = True, 'saved' elif act == 'delete': lib.remove(body.get('id', '')); ok, msg = True, 'removed' + elif act in ('poweroff', 'reboot'): + ok, msg = power(act) elif act in ('jog', 'home', 'unlock', 'calibrate'): ok, msg = busy() or { 'jog': lambda: panel.jog(body.get('axis'), body.get('d', 0)), diff --git a/software/tests/test_web.py b/software/tests/test_web.py index a78a12a..8e3a414 100644 --- a/software/tests/test_web.py +++ b/software/tests/test_web.py @@ -64,6 +64,15 @@ class TestLibrary(unittest.TestCase): self.assertTrue(os.path.getsize(png) > 1000) self.assertGreater(self.lib.items()[1]['est'], 0) # running time known once the thumbnail is made + def test_thumb_key_follows_contents_not_dates(self): + k1 = self.lib._key('spirals/tiny.thr') + p = self.lib.path('spirals/tiny.thr') + os.utime(p, (1, 1)) # same contents, other date + self.assertEqual(self.lib._key('spirals/tiny.thr'), k1) + with open(p, 'a') as f: + f.write('0 0\n') + self.assertNotEqual(self.lib._key('spirals/tiny.thr'), k1) + def test_refuses_paths_outside(self): for bad in ('../cache/index.json', '../../tiny.thr', '/etc/passwd', 'spirals/../../x.thr', 'spirals/nope.thr'): with self.assertRaises(KeyError): @@ -264,6 +273,8 @@ class TestHttp(unittest.TestCase): self.assertFalse(self.post('play', {'ids': ['../../etc/passwd']})['ok']) up = self.post('upload?name=new.thr', raw=b'0 0\n6.28 1\n') self.assertEqual(up['msg'], 'uploads/new.thr') + self.assertFalse(json.loads(self.get('/api/status'))['power_buttons']) # off unless on the Pi + self.assertFalse(self.post('poweroff')['ok']) if __name__ == '__main__':