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:
ckochandClaude Opus 5.5 committed 2026-11-01 21:10:06 -05:00
1 parent 97ebae93b2
commit 8a377af116
9 files changed
+336 -6

No files matched your search

+1
View File
@@ -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. - `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. - `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`. - `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`. - `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. - `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. - `design-notes.md`: how it works, dimensions, parts to buy, hardware list and print settings.
+44
View File
@@ -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.
+183
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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
+10
View File
@@ -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=""
+53 -6
View File
@@ -25,6 +25,7 @@ CACHE_DIR = os.path.join(SOFTWARE, '.cache', 'thumbs')
REMOVED_DIR = os.path.join(SOFTWARE, 'removed-patterns') REMOVED_DIR = os.path.join(SOFTWARE, 'removed-patterns')
UPLOADS = 'uploads' UPLOADS = 'uploads'
LOG_PATH = os.path.join(SOFTWARE, '.cache', 'web.log') 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): def log(msg):
@@ -51,6 +52,7 @@ class Library:
self.cfg, self.root, self.cache, self.removed = cfg, root, cache, removed self.cfg, self.root, self.cache, self.removed = cfg, root, cache, removed
os.makedirs(cache, exist_ok=True) os.makedirs(cache, exist_ok=True)
self.lock = threading.Lock() self.lock = threading.Lock()
self._hashes = {}
self.index_path = os.path.join(cache, 'index.json') self.index_path = os.path.join(cache, 'index.json')
try: try:
with open(self.index_path) as f: with open(self.index_path) as f:
@@ -82,8 +84,18 @@ class Library:
return os.path.basename(ident)[:-4], thr.load(self.path(ident)) return os.path.basename(ident)[:-4], thr.load(self.path(ident))
def _key(self, ident): def _key(self, ident):
st = os.stat(self.path(ident)) """Identifies a design's contents, so a thumbnail made on another computer (the Pi image
return f'{st.st_mtime_ns}-{st.st_size}' 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): def thumb(self, ident, size=360):
"""PNG path for a design's thumbnail, made on first use.""" """PNG path for a design's thumbnail, made on first use."""
@@ -109,12 +121,19 @@ class Library:
return png return png
def warm(self): 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(): for it in self.items():
try: try:
self.thumb(it['id']) keep.add(os.path.basename(self.thumb(it['id'])))
except Exception: except Exception:
pass 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): def add(self, filename, text):
"""Save an uploaded design into patterns/uploads/ and return its id. A .thr file is kept as """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)}
<footer>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). <footer>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 <a href="https://sandify.org" target="_blank" rel="noopener">sandify.org</a> (export as Theta Rho .thr), or upload a line drawing as .svg: every line in it is joined into one path for the ball. Make your own at <a href="https://sandify.org" target="_blank" rel="noopener">sandify.org</a> (export as Theta Rho .thr), or upload a line drawing as .svg: every line in it is joined into one path for the ball.
· <a href="/jog">Jog &amp; calibrate</a></footer> · <a href="/jog">Jog &amp; calibrate</a>
<span id="power" style="display:none"> · <a href="#" onclick="return powerAct('reboot')">Restart</a> · <a href="#" onclick="return powerAct('poweroff')">Power off</a></span></footer>
</main> </main>
<script> <script>
let LIB=[], CAT='all', ST=null; let LIB=[], CAT='all', ST=null;
@@ -485,6 +505,7 @@ async function tick(){
if(!m.connected && !p.message) showErr(m.error); if(!m.connected && !p.message) showErr(m.error);
else if(m.connected && !p.message && $('err').textContent===lastConnErr) showErr(''); else if(m.connected && !p.message && $('err').textContent===lastConnErr) showErr('');
lastConnErr=m.connected?'':m.error; lastConnErr=m.connected?'':m.error;
$('power').style.display = ST.power_buttons ? 'inline' : 'none';
if(!settingsLoaded){$('speed').value=p.settings.speed;$('speedVal').textContent=p.settings.speed+' mm/s';$('erase').checked=p.settings.erase;settingsLoaded=true;} if(!settingsLoaded){$('speed').value=p.settings.speed;$('speedVal').textContent=p.settings.speed+' mm/s';$('erase').checked=p.settings.erase;settingsLoaded=true;}
if(p.message) showErr(p.message); if(p.message) showErr(p.message);
const now=p.now; const now=p.now;
@@ -500,6 +521,11 @@ async function tick(){
markPlaying(); markPlaying();
}catch(e){$('mach').textContent='offline: is `python3 -m sandtable web` still running?';} }catch(e){$('mach').textContent='offline: is `python3 -m sandtable web` still running?';}
} }
function powerAct(what){
const q = what==='poweroff' ? 'Power off the table\'s computer? Unplug it only after the green light on the Pi stops blinking.' : 'Restart the table\'s computer? The page comes back in about a minute.';
if(confirm(q)) post(what).then(j=>{ if(j.ok) showErr(j.msg); });
return false;
}
loadLib(); tick(); setInterval(tick,1000); loadLib(); tick(); setInterval(tick,1000);
</script></body></html> </script></body></html>
""" """
@@ -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('<h1>Gyre: jog &amp; calibrate</h1>', jog_page = JOG_PAGE.replace('<h1>Gyre: jog &amp; calibrate</h1>',
'<p><a href="/">&larr; Library</a></p><h1>Gyre: jog &amp; calibrate</h1>') '<p><a href="/">&larr; Library</a></p><h1>Gyre: jog &amp; calibrate</h1>')
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(): def busy():
if not panel.connected: if not panel.connected:
return False, panel.error or 'the Uno is not 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(), self._send(200, json.dumps({'machine': st, 'player': player.status(),
'state': st['state'], 'theta': st['theta'], 'rho': st['rho'], 'state': st['state'], 'theta': st['theta'], 'rho': st['rho'],
'switch': st['switch'], 'pins': st['pins'], 'homed': st['homed'], 'steps': st['steps'], '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': elif u.path == '/api/library':
self._send(200, json.dumps(lib.items())) self._send(200, json.dumps(lib.items()))
elif u.path == '/thumb': 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' ok, msg = True, 'saved'
elif act == 'delete': elif act == 'delete':
lib.remove(body.get('id', '')); ok, msg = True, 'removed' 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'): elif act in ('jog', 'home', 'unlock', 'calibrate'):
ok, msg = busy() or { ok, msg = busy() or {
'jog': lambda: panel.jog(body.get('axis'), body.get('d', 0)), 'jog': lambda: panel.jog(body.get('axis'), body.get('d', 0)),
+11
View File
@@ -64,6 +64,15 @@ class TestLibrary(unittest.TestCase):
self.assertTrue(os.path.getsize(png) > 1000) self.assertTrue(os.path.getsize(png) > 1000)
self.assertGreater(self.lib.items()[1]['est'], 0) # running time known once the thumbnail is made 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): def test_refuses_paths_outside(self):
for bad in ('../cache/index.json', '../../tiny.thr', '/etc/passwd', 'spirals/../../x.thr', 'spirals/nope.thr'): for bad in ('../cache/index.json', '../../tiny.thr', '/etc/passwd', 'spirals/../../x.thr', 'spirals/nope.thr'):
with self.assertRaises(KeyError): with self.assertRaises(KeyError):
@@ -264,6 +273,8 @@ class TestHttp(unittest.TestCase):
self.assertFalse(self.post('play', {'ids': ['../../etc/passwd']})['ok']) self.assertFalse(self.post('play', {'ids': ['../../etc/passwd']})['ok'])
up = self.post('upload?name=new.thr', raw=b'0 0\n6.28 1\n') up = self.post('upload?name=new.thr', raw=b'0 0\n6.28 1\n')
self.assertEqual(up['msg'], 'uploads/new.thr') 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__': if __name__ == '__main__':