Compare commits

...
3 Commits
Author SHA1 Message Date
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
ckochandClaude Opus 5.5 db78b1f2e6 Start Gyre from an icon: launch / quit / install-desktop
- python3 -m sandtable install-desktop puts a Gyre icon in the app grid and on
  the desktop (freedesktop .desktop entry with a "Quit Gyre" action).
- The icon runs `launch`: starts the web page in the background (no terminal,
  own session, log in .cache/server.log) if it isn't running, then opens it.
  A dialog shows the reason if it can't start.
- Quit link on the page (local computer only) and `quit`: stop any design,
  motors off, close the page.
- Gyre icon (sandtable/static/gyre.svg), also the page's tab icon.
- Simulator takes several lines per tick, so streaming tests aren't slowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:25:10 -05:00
ckochandClaude Opus 5.5 8a377af116 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>
2026-11-01 21:10:06 -05:00
22 changed files with 537 additions and 25 deletions

No files matched your search

+4 -6
View File
@@ -247,17 +247,15 @@ The full details are in [`software/README.md`](software/README.md). In short, fr
5. **Glass:** lay it on the sand ring. The two finger notches in the rim help lift it out. 5. **Glass:** lay it on the sand ring. The two finger notches in the rim help lift it out.
## 14. Everyday use ## 14. Everyday use
From the `software` folder, run: Once, from the `software` folder, run `python3 -m sandtable install-desktop`. It adds a **Gyre** icon to your app grid and desktop (Linux). On GNOME, right-click the desktop icon once and choose **Allow Launching**.
```
python3 -m sandtable web From then on, click the Gyre icon. It opens a page in your browser with the design library:
```
This opens a page in your browser with the design library:
- click a design to draw it; it homes and erases the sand first; - click a design to draw it; it homes and erases the sand first;
- **Play all** runs the whole list, shuffled and repeating; - **Play all** runs the whole list, shuffled and repeating;
- when the designs finish (or you press Stop), the motors switch off and the page shows "Motors off". The next design homes first; - when the designs finish (or you press Stop), the motors switch off and the page shows "Motors off". The next design homes first;
- **Upload** takes `.thr` files (from [sandify.org](https://sandify.org)) and `.svg` line drawings. - **Upload** takes `.thr` files (from [sandify.org](https://sandify.org)) and `.svg` line drawings.
Leave the terminal open while the table runs. See [`software/README.md`](software/README.md) for everything else. **Quit**, at the bottom of the page, closes Gyre. See [`software/README.md`](software/README.md) for everything else, and [`pi/README.md`](pi/README.md) to run the table from a Raspberry Pi Zero 2 W with no computer at all.
## 15. Troubleshooting ## 15. Troubleshooting
| Symptom | Likely cause | Fix | | Symptom | Likely cause | Fix |
+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.
+1 -1
View File
@@ -1,7 +1,7 @@
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
SRC = '/home/ckoch/ComfyUI/output/sandtable/step{}_00001_.png' SRC = '/home/ckoch/ComfyUI/output/sandtable/step{}_00001_.png'
OUT = '/home/ckoch/Documents/sandTable/docs/build-guide.png' OUT = '/home/ckoch/Documents/projects/sandTable/docs/build-guide.png'
F = '/usr/share/fonts/truetype/noto/NotoSans-{}.ttf' F = '/usr/share/fonts/truetype/noto/NotoSans-{}.ttf'
MONO = '/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf' MONO = '/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf'
INK, GRAY, TEAL, RULE = (27, 36, 32), (90, 103, 98), (31, 128, 116), (214, 222, 218) INK, GRAY, TEAL, RULE = (27, 36, 32), (90, 103, 98), (31, 128, 116), (214, 222, 218)
+1 -1
View File
@@ -106,7 +106,7 @@ The v0.3 boards work; the v0.4 changes add margin. On a v0.3 board:
| `archive/v0.3/` | The v0.3 board as ordered: KiCad files, order files, schematic and the generator's `design.py` | | `archive/v0.3/` | The v0.3 board as ordered: KiCad files, order files, schematic and the generator's `design.py` |
| `tools/` | Python generator: `design.py` (parts, placement, nets, sourcing) → `route.py` (router, ground pour, stitching vias, board file), `sch.py` (schematic), `fab.py` (PCBWay order folder, renders, copy into this folder). `place_new.py` finds free spots for parts added in a revision | | `tools/` | Python generator: `design.py` (parts, placement, nets, sourcing) → `route.py` (router, ground pour, stitching vias, board file), `sch.py` (schematic), `fab.py` (PCBWay order folder, renders, copy into this folder). `place_new.py` finds free spots for parts added in a revision |
To regenerate everything, run `python3 tools/route.py && python3 tools/sch.py && python3 tools/fab.py`. The KiCad files are built in `~/kicad/sandtable-controller` because the KiCad snap can't read inside `~/snap/`. To regenerate everything, run `python3 tools/route.py && python3 tools/sch.py && python3 tools/fab.py`. The KiCad files are built in `~/Documents/projects/sandTable-local/sandtable-controller`, outside the repo.
## Notes ## Notes
- **5 V budget:** the 5 V regulator supplies 1 A. The ESP32 takes up to about 0.4 A during Wi-Fi bursts, which leaves about 0.6 A for an LED strip: roughly 10 WS2812B LEDs at full white, or more at lower brightness. On USB power alone, keep the strip off; USB gives 0.5 A in total. Use a separate 5 V supply for longer strips. - **5 V budget:** the 5 V regulator supplies 1 A. The ESP32 takes up to about 0.4 A during Wi-Fi bursts, which leaves about 0.6 A for an LED strip: roughly 10 WS2812B LEDs at full white, or more at lower brightness. On USB power alone, keep the strip off; USB gives 0.5 A in total. Use a separate 5 V supply for longer strips.
+3 -3
View File
@@ -1,7 +1,7 @@
"""Build the PCBWay order files and copy the KiCad project + outputs into the repo. """Build the PCBWay order files and copy the KiCad project + outputs into the repo.
Run after route.py and sch.py. Writes to ~/kicad/sandtable-controller/fab (the KiCad snap can't Run after route.py and sch.py. Writes to ~/Documents/projects/sandTable-local/sandtable-controller/fab (outside
read inside ~/snap), then copies the results to ../ (the repo's pcb/ folder). the repo), then copies the results to ../ (the repo's pcb/ folder).
""" """
import csv import csv
import glob import glob
@@ -14,7 +14,7 @@ import zipfile
sys.path.insert(0, os.path.dirname(__file__)) sys.path.insert(0, os.path.dirname(__file__))
import design as D import design as D
WORK = os.path.expanduser('~/kicad/sandtable-controller') WORK = os.path.expanduser('~/Documents/projects/sandTable-local/sandtable-controller')
NAME = 'sandtable-controller' NAME = 'sandtable-controller'
PCB = os.path.join(WORK, NAME + '.kicad_pcb') PCB = os.path.join(WORK, NAME + '.kicad_pcb')
SCH = os.path.join(WORK, NAME + '.kicad_sch') SCH = os.path.join(WORK, NAME + '.kicad_sch')
+1 -1
View File
@@ -6,7 +6,7 @@ sys.path.insert(0, os.path.dirname(__file__))
import design as D import design as D
from board import build, check_placement, write_pcb, write_project, keepout_zone from board import build, check_placement, write_pcb, write_project, keepout_zone
OUT = os.path.expanduser('~/kicad/sandtable-controller') OUT = os.path.expanduser('~/Documents/projects/sandTable-local/sandtable-controller')
parts = build() parts = build()
probs = check_placement(parts) probs = check_placement(parts)
+1 -1
View File
@@ -9,7 +9,7 @@ import design as D
from board import build, check_placement, write_pcb, write_project, keepout_zone, text_node from board import build, check_placement, write_pcb, write_project, keepout_zone, text_node
import router as R import router as R
OUT = os.path.expanduser('~/kicad/sandtable-controller') OUT = os.path.expanduser('~/Documents/projects/sandTable-local/sandtable-controller')
# routing width per net # routing width per net
WIDTH = {} WIDTH = {}
+1 -1
View File
@@ -9,7 +9,7 @@ from board import uid, fmt
from kilib import load_symbol, sym_pins from kilib import load_symbol, sym_pins
from sexpr import QStr, dumps, find, findall from sexpr import QStr, dumps, find, findall
OUT = os.path.expanduser('~/kicad/sandtable-controller') OUT = os.path.expanduser('~/Documents/projects/sandTable-local/sandtable-controller')
NAME = 'sandtable-controller' NAME = 'sandtable-controller'
ROOT = uid('rootsheet') ROOT = uid('rootsheet')
STUB = 2.54 STUB = 2.54
+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/projects/sandTable-local/gyre-pi (or give another one)
```
1. **First run:** it copies `settings.example.env` to `~/Documents/projects/sandTable-local/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/projects/sandTable-local/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/projects/sandTable-local/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/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."
+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/projects/sandTable-local/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=""
+6 -4
View File
@@ -10,10 +10,12 @@ A small Python program that drives the table from your computer over USB. It has
**Status:** running on the table since 2026-11-01. On a new build, do the first-run steps below in order. **Status:** running on the table since 2026-11-01. On a new build, do the first-run steps below in order.
## Web page (everyday use) ## Web page (everyday use)
``` **Start it from the Gyre icon** in your app grid or on the desktop. It starts Gyre in the background, with no terminal, and opens http://127.0.0.1:8765 in your browser. Clicking it again while Gyre runs just opens the page.
python3 -m sandtable web - **Close it:** use **Quit** at the bottom of the page, or right-click the icon and choose **Quit Gyre**. Either way any design stops and the motors switch off first.
``` - **One-time setup (Linux):** `python3 -m sandtable install-desktop` adds the icon to the app grid and the desktop. On GNOME, right-click the desktop icon once and choose **Allow Launching**. To add it to the dock, right-click it in the app grid and choose **Pin to Dash**.
This opens http://127.0.0.1:8765 in your browser. The table stays on the USB cable and the page is the remote control, so leave the program running in its terminal (Ctrl-C there quits). - **If it can't start,** a pop-up shows the reason. The log is `.cache/server.log`.
- **From a terminal:** `python3 -m sandtable web` does the same in the foreground (Ctrl-C quits).
- **On a Raspberry Pi** inside the table, there's no computer to start at all: see `../pi/README.md`.
![web page](previews/web-page.png) ![web page](previews/web-page.png)
+7
View File
@@ -452,6 +452,9 @@ def main(argv=None):
sub = ap.add_subparsers(dest='cmd', required=True) sub = ap.add_subparsers(dest='cmd', required=True)
sub.add_parser('ports', help='list serial ports') sub.add_parser('ports', help='list serial ports')
sub.add_parser('info', help='connect and show the controller and its settings') sub.add_parser('info', help='connect and show the controller and its settings')
sub.add_parser('launch', help='start the web page in the background if needed and open it (the Gyre icon)')
sub.add_parser('quit', help='close the web page started by launch (stops any design, motors off)')
sub.add_parser('install-desktop', help='put a Gyre icon in the app grid and on the desktop (Linux)')
for name, text in (('web', 'the web page: a library of designs to play, plus the jog panel'), for name, text in (('web', 'the web page: a library of designs to play, plus the jog panel'),
('ui', 'the same web page, opened at the jog panel: home, jog, calibrate')): ('ui', 'the same web page, opened at the jog panel: home, jog, calibrate')):
u = sub.add_parser(name, help=text) u = sub.add_parser(name, help=text)
@@ -483,5 +486,9 @@ def main(argv=None):
m.add_argument('--no-home', action='store_true') m.add_argument('--no-home', action='store_true')
args = ap.parse_args(argv) args = ap.parse_args(argv)
cfg = config.load(args.config) cfg = config.load(args.config)
if args.cmd in ('launch', 'quit', 'install-desktop'): # no USB connection needed here
from . import launcher
sys.exit({'launch': launcher.launch, 'quit': launcher.quit_gyre,
'install-desktop': lambda: launcher.install_desktop() and 0}[args.cmd]())
{'ports': cmd_ports, 'info': cmd_info, 'flash': cmd_flash, 'ui': cmd_web, 'web': cmd_web, 'setup': cmd_setup, 'test': cmd_test, 'home': cmd_home, {'ports': cmd_ports, 'info': cmd_info, 'flash': cmd_flash, 'ui': cmd_web, 'web': cmd_web, 'setup': cmd_setup, 'test': cmd_test, 'home': cmd_home,
'play': cmd_play, 'gen': cmd_gen, 'erase': cmd_erase, 'move': cmd_move}[args.cmd](cfg, args) 'play': cmd_play, 'gen': cmd_gen, 'erase': cmd_erase, 'move': cmd_move}[args.cmd](cfg, args)
+112
View File
@@ -0,0 +1,112 @@
"""Start Gyre from a desktop icon: run the web page in the background (no terminal window) and
open it in the browser. Used by the Gyre icon (`install-desktop` puts it in the app grid and on
the desktop):
python3 -m sandtable launch start if needed, then open the page
python3 -m sandtable quit stop any design, motors off, close Gyre
"""
import json
import os
import shutil
import subprocess
import sys
import time
import urllib.request
import webbrowser
SOFTWARE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ICON = os.path.join(SOFTWARE, 'sandtable', 'static', 'gyre.svg')
LOG = os.path.join(SOFTWARE, '.cache', 'server.log')
def status(port):
"""The running Gyre page's status, or None if nothing (or something else) answers."""
try:
with urllib.request.urlopen(f'http://127.0.0.1:{port}/api/status', timeout=1.5) as r:
d = json.load(r)
return d if 'machine' in d else None
except Exception:
return None
def say(title, msg, error=False):
"""Tell the person at the desktop: a dialog if possible, else a notification."""
print(f'{title}: {msg}', flush=True)
if shutil.which('zenity'):
subprocess.run(['zenity', '--error' if error else '--info', '--title', title, '--width', '420',
'--text', msg], stderr=subprocess.DEVNULL)
elif shutil.which('notify-send'):
subprocess.run(['notify-send', '-a', 'Gyre', title, msg])
def launch(port=8765):
if status(port) is None:
os.makedirs(os.path.dirname(LOG), exist_ok=True)
log = open(LOG, 'a')
log.write(time.strftime('\n=== %Y-%m-%d %H:%M:%S starting from the Gyre icon\n'))
log.flush()
subprocess.Popen([sys.executable, '-m', 'sandtable', 'web', '--no-browser', '--http-port', str(port)],
cwd=SOFTWARE, stdout=log, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
start_new_session=True) # keeps running after this launcher exits
t_end = time.time() + 25
while time.time() < t_end and status(port) is None:
time.sleep(0.4)
if status(port) is None:
with open(LOG) as f:
tail = ''.join(f.readlines()[-12:])
say('Gyre could not start', f'The page did not come up. The last lines of its log:\n\n{tail}', error=True)
return 1
webbrowser.open(f'http://127.0.0.1:{port}/')
return 0
def quit_gyre(port=8765):
try:
req = urllib.request.Request(f'http://127.0.0.1:{port}/api/quit', data=b'{}', method='POST',
headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(req, timeout=40) as r:
ok = json.load(r).get('ok')
except Exception:
ok = False
if not ok:
say('Gyre', 'Gyre is not running.')
return 0
def install_desktop():
"""Put a Gyre icon in the app grid and on the desktop (Linux desktops that follow the
freedesktop.org standard: GNOME, KDE, Cinnamon, Xfce...)."""
entry = f"""[Desktop Entry]
Type=Application
Name=Gyre
GenericName=Sand art table
Comment=Draw designs on the Gyre sand table
Exec={sys.executable} -m sandtable launch
Path={SOFTWARE}
Icon={ICON}
Terminal=false
Categories=Utility;
StartupNotify=false
Actions=quit;
[Desktop Action quit]
Name=Quit Gyre
Exec={sys.executable} -m sandtable quit
"""
apps = os.path.expanduser('~/.local/share/applications')
os.makedirs(apps, exist_ok=True)
places = [os.path.join(apps, 'gyre.desktop')]
desktop = subprocess.run(['xdg-user-dir', 'DESKTOP'], capture_output=True, text=True).stdout.strip() \
if shutil.which('xdg-user-dir') else os.path.expanduser('~/Desktop')
if desktop and os.path.isdir(desktop) and desktop != os.path.expanduser('~'):
places.append(os.path.join(desktop, 'Gyre.desktop'))
for p in places:
with open(p, 'w') as f:
f.write(entry)
os.chmod(p, 0o755)
if shutil.which('gio'): # GNOME: allow launching from the desktop
subprocess.run(['gio', 'set', p, 'metadata::trusted', 'true'], stderr=subprocess.DEVNULL)
if shutil.which('update-desktop-database'):
subprocess.run(['update-desktop-database', apps], stderr=subprocess.DEVNULL)
for p in places:
print(' wrote', p)
return places
+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
<defs>
<radialGradient id="sand" cx="45%" cy="40%" r="65%">
<stop offset="0" stop-color="#f3e8d2"/><stop offset="1" stop-color="#d9c6a3"/>
</radialGradient>
<radialGradient id="ball" cx="35%" cy="30%" r="70%">
<stop offset="0" stop-color="#ffffff"/><stop offset="0.45" stop-color="#b9bec4"/><stop offset="1" stop-color="#5d6268"/>
</radialGradient>
</defs>
<circle cx="128" cy="128" r="122" fill="#1d1d1f"/>
<circle cx="128" cy="128" r="104" fill="url(#sand)"/>
<path d="M128.0,122.0 L128.3,121.8 L128.6,121.7 L129.0,121.5 L129.4,121.4 L129.7,121.3 L130.1,121.2 L130.5,121.1 L130.9,121.1 L131.4,121.1 L131.8,121.1 L132.2,121.1 L132.7,121.2 L133.1,121.3 L133.6,121.4 L134.1,121.5 L134.5,121.7 L135.0,121.9 L135.4,122.2 L135.8,122.4 L136.3,122.7 L136.7,123.1 L137.1,123.4 L137.5,123.8 L137.9,124.2 L138.2,124.7 L138.6,125.1 L138.9,125.6 L139.2,126.2 L139.4,126.7 L139.7,127.3 L139.9,127.9 L140.1,128.5 L140.2,129.1 L140.3,129.7 L140.4,130.4 L140.5,131.0 L140.5,131.7 L140.5,132.4 L140.4,133.1 L140.3,133.8 L140.2,134.5 L140.0,135.2 L139.8,135.9 L139.5,136.6 L139.2,137.3 L138.9,138.0 L138.5,138.6 L138.1,139.3 L137.6,139.9 L137.1,140.5 L136.6,141.1 L136.0,141.7 L135.4,142.3 L134.7,142.8 L134.1,143.3 L133.3,143.8 L132.6,144.2 L131.8,144.6 L131.0,144.9 L130.2,145.3 L129.3,145.5 L128.4,145.8 L127.5,146.0 L126.6,146.1 L125.7,146.2 L124.8,146.3 L123.8,146.3 L122.8,146.2 L121.9,146.1 L120.9,145.9 L119.9,145.7 L119.0,145.5 L118.0,145.2 L117.0,144.8 L116.1,144.4 L115.2,143.9 L114.3,143.4 L113.4,142.8 L112.5,142.2 L111.7,141.5 L110.9,140.8 L110.1,140.0 L109.3,139.2 L108.6,138.3 L108.0,137.4 L107.3,136.5 L106.8,135.5 L106.2,134.5 L105.7,133.4 L105.3,132.3 L104.9,131.2 L104.6,130.1 L104.3,128.9 L104.1,127.7 L104.0,126.5 L103.9,125.3 L103.9,124.0 L103.9,122.8 L104.0,121.5 L104.2,120.3 L104.5,119.0 L104.8,117.8 L105.1,116.5 L105.6,115.3 L106.1,114.1 L106.7,112.9 L107.3,111.7 L108.0,110.6 L108.8,109.5 L109.6,108.4 L110.5,107.3 L111.4,106.3 L112.4,105.4 L113.5,104.5 L114.6,103.6 L115.7,102.8 L117.0,102.0 L118.2,101.3 L119.5,100.7 L120.8,100.1 L122.2,99.6 L123.6,99.2 L125.1,98.8 L126.5,98.5 L128.0,98.2 L129.5,98.1 L131.0,98.0 L132.6,98.0 L134.1,98.1 L135.6,98.3 L137.2,98.5 L138.7,98.8 L140.2,99.2 L141.8,99.7 L143.2,100.3 L144.7,100.9 L146.2,101.6 L147.6,102.4 L149.0,103.3 L150.3,104.2 L151.6,105.3 L152.9,106.3 L154.1,107.5 L155.2,108.7 L156.3,110.0 L157.4,111.4 L158.3,112.8 L159.2,114.3 L160.1,115.8 L160.8,117.3 L161.5,119.0 L162.1,120.6 L162.6,122.3 L163.0,124.0 L163.4,125.8 L163.6,127.6 L163.8,129.4 L163.9,131.2 L163.9,133.0 L163.8,134.8 L163.6,136.7 L163.3,138.5 L162.9,140.3 L162.4,142.1 L161.8,143.9 L161.1,145.7 L160.4,147.4 L159.5,149.1 L158.5,150.8 L157.5,152.4 L156.4,154.0 L155.2,155.5 L153.9,157.0 L152.5,158.4 L151.1,159.8 L149.5,161.0 L148.0,162.2 L146.3,163.4 L144.6,164.4 L142.8,165.4 L141.0,166.3 L139.1,167.0 L137.1,167.7 L135.2,168.3 L133.2,168.8 L131.1,169.2 L129.0,169.5 L127.0,169.7 L124.8,169.8 L122.7,169.8 L120.6,169.6 L118.5,169.4 L116.4,169.0 L114.2,168.6 L112.2,168.0 L110.1,167.4 L108.0,166.6 L106.0,165.7 L104.1,164.7 L102.1,163.6 L100.3,162.4 L98.4,161.1 L96.7,159.7 L95.0,158.2 L93.4,156.7 L91.8,155.0 L90.3,153.2 L89.0,151.4 L87.7,149.5 L86.5,147.5 L85.4,145.5 L84.4,143.4 L83.5,141.2 L82.7,139.0 L82.0,136.8 L81.4,134.5 L81.0,132.1 L80.6,129.8 L80.4,127.4 L80.3,125.0 L80.3,122.6 L80.5,120.2 L80.7,117.7 L81.1,115.3 L81.6,112.9 L82.3,110.6 L83.0,108.2 L83.9,105.9 L84.9,103.6 L86.0,101.4 L87.3,99.2 L88.6,97.1 L90.1,95.0 L91.7,93.0 L93.3,91.1 L95.1,89.2 L97.0,87.5 L98.9,85.8 L101.0,84.3 L103.1,82.8 L105.4,81.4 L107.7,80.2 L110.0,79.0 L112.4,78.0 L114.9,77.1 L117.5,76.3 L120.0,75.7 L122.7,75.1 L125.3,74.8 L128.0,74.5 L130.7,74.4 L133.4,74.4 L136.1,74.5 L138.8,74.8 L141.5,75.3 L144.2,75.8 L146.9,76.5 L149.5,77.4 L152.1,78.3 L154.7,79.5 L157.2,80.7 L159.6,82.1 L162.0,83.6 L164.3,85.2 L166.6,86.9 L168.7,88.8 L170.8,90.8 L172.8,92.8 L174.6,95.0 L176.4,97.3 L178.0,99.7 L179.6,102.1 L181.0,104.7 L182.2,107.3 L183.4,110.0 L184.4,112.8 L185.3,115.6 L186.0,118.4 L186.6,121.3 L187.1,124.3 L187.4,127.3 L187.5,130.2 L187.5,133.3 L187.4,136.3 L187.1,139.3 L186.6,142.3 L186.0,145.3 L185.3,148.2 L184.3,151.1 L183.3,154.0 L182.1,156.8 L180.7,159.6 L179.2,162.3 L177.6,165.0 L175.8,167.6 L173.9,170.0 L171.9,172.4 L169.7,174.7 L167.4,176.9 L165.0,179.0 L162.5,180.9 L159.9,182.8 L157.2,184.5 L154.4,186.0 L151.5,187.5 L148.6,188.7 L145.6,189.9 L142.5,190.9 L139.3,191.7 L136.1,192.4 L132.9,192.9 L129.6,193.3 L126.4,193.4 L123.1,193.5 L119.7,193.3 L116.4,193.0 L113.1,192.5 L109.9,191.9 L106.6,191.1 L103.4,190.1 L100.2,189.0 L97.1,187.7 L94.1,186.2 L91.1,184.6 L88.2,182.8 L85.4,180.9 L82.6,178.8 L80.0,176.6 L77.5,174.3 L75.1,171.8 L72.8,169.2 L70.6,166.5 L68.6,163.6 L66.7,160.7 L65.0,157.7 L63.4,154.5 L62.0,151.3 L60.7,148.0 L59.6,144.7 L58.7,141.2 L57.9,137.8 L57.3,134.2 L56.9,130.7 L56.6,127.1 L56.6,123.5 L56.7,119.9 L57.0,116.3 L57.5,112.7 L58.2,109.1 L59.0,105.6 L60.1,102.1 L61.3,98.6 L62.7,95.2 L64.3,91.9 L66.0,88.6 L67.9,85.5 L69.9,82.4 L72.2,79.4 L74.5,76.5 L77.1,73.8 L79.7,71.1 L82.5,68.6 L85.5,66.3 L88.5,64.0 L91.7,62.0 L95.0,60.1 L98.4,58.3 L101.8,56.7 L105.4,55.3 L109.0,54.1 L112.7,53.1 L116.5,52.2 L120.3,51.5 L124.1,51.0 L128.0,50.8 L131.9,50.7 L135.8,50.8 L139.7,51.1 L143.6,51.6 L147.4,52.3 L151.3,53.1 L155.1,54.2 L158.8,55.5 L162.5,57.0 L166.1,58.6 L169.7,60.5 L173.1,62.5 L176.5,64.7 L179.7,67.1 L182.8,69.6 L185.8,72.3 L188.7,75.2 L191.4,78.2 L194.0,81.3 L196.4,84.6 L198.7,88.0 L200.8,91.Line truncated
<circle cx="206.0" cy="102.7" r="11" fill="url(#ball)" stroke="#3a3d41" stroke-width="1.5"/>
<circle cx="128" cy="128" r="104" fill="none" stroke="#000" stroke-opacity="0.25" stroke-width="3"/>
</svg>

After

Width:  |  Height:  |  Size: 5.8 KiB

+1
View File
@@ -14,6 +14,7 @@ from .grbl import RT_HOLD, RT_JOG_CANCEL, GrblError
PAGE = r"""<!doctype html> PAGE = r"""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Gyre - Jog</title> <title>Gyre - Jog</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<style> <style>
:root{--bg:#f6f4ef;--card:#fff;--ink:#222;--mute:#666;--line:#ddd;--acc:#2f6f5e;--stop:#c0392b} :root{--bg:#f6f4ef;--card:#fff;--ink:#222;--mute:#666;--line:#ddd;--acc:#2f6f5e;--stop:#c0392b}
@media (prefers-color-scheme:dark){:root{--bg:#1b1c1e;--card:#26282b;--ink:#eee;--mute:#aaa;--line:#3a3d41;--acc:#5fb49c}} @media (prefers-color-scheme:dark){:root{--bg:#1b1c1e;--card:#26282b;--ink:#eee;--mute:#aaa;--line:#3a3d41;--acc:#5fb49c}}
+80 -7
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
@@ -325,6 +344,7 @@ class Player:
PAGE = r"""<!doctype html> PAGE = r"""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Gyre - Sand Art Table</title> <title>Gyre - Sand Art Table</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<style> <style>
:root{--bg:#f4f1ea;--card:#fffdf8;--ink:#2a2622;--mute:#6f675d;--line:#e2dbcf;--acc:#2f6f5e;--acc-ink:#fff;--stop:#c0392b;--bar:#e9e2d4} :root{--bg:#f4f1ea;--card:#fffdf8;--ink:#2a2622;--mute:#6f675d;--line:#e2dbcf;--acc:#2f6f5e;--acc-ink:#fff;--stop:#c0392b;--bar:#e9e2d4}
@media (prefers-color-scheme:dark){:root{--bg:#18191b;--card:#232528;--ink:#ece8e1;--mute:#a59e94;--line:#36393d;--acc:#5fb49c;--acc-ink:#0d1f1a;--bar:#2e3135}} @media (prefers-color-scheme:dark){:root{--bg:#18191b;--card:#232528;--ink:#ece8e1;--mute:#a59e94;--line:#36393d;--acc:#5fb49c;--acc-ink:#0d1f1a;--bar:#2e3135}}
@@ -411,7 +431,9 @@ 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="quit" style="display:none"> · <a href="#" onclick="return quitApp()">Quit</a></span>
<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 +507,8 @@ 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';
$('quit').style.display = ST.can_quit ? '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,7 +524,17 @@ 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?';}
} }
loadLib(); tick(); setInterval(tick,1000); function quitApp(){
if(!confirm('Close Gyre? Any design stops and the motors switch off.')) return false;
post('quit').then(j=>{ if(j.ok){ clearInterval(TICK); showErr(j.msg); $('mach').textContent='closed'; } });
return false;
}
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(); const TICK=setInterval(tick,1000);
</script></body></html> </script></body></html>
""" """
@@ -514,6 +548,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'
@@ -537,6 +590,9 @@ def serve(ctrl, cfg, host='127.0.0.1', port=8765, open_browser=True, open_path='
q = parse_qs(u.query) q = parse_qs(u.query)
if u.path in ('/', '/index.html'): if u.path in ('/', '/index.html'):
self._send(200, PAGE, 'text/html; charset=utf-8') self._send(200, PAGE, 'text/html; charset=utf-8')
elif u.path == '/favicon.svg':
with open(os.path.join(os.path.dirname(__file__), 'static', 'gyre.svg'), 'rb') as f:
self._send(200, f.read(), 'image/svg+xml')
elif u.path == '/jog': elif u.path == '/jog':
self._send(200, jog_page, 'text/html; charset=utf-8') self._send(200, jog_page, 'text/html; charset=utf-8')
elif u.path == '/api/status': elif u.path == '/api/status':
@@ -545,7 +601,8 @@ 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,
'can_quit': not 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 +652,22 @@ 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 == 'quit':
if POWER_BUTTONS or self.client_address[0] not in ('127.0.0.1', '::1'):
ok, msg = False, 'Quit only works on the computer Gyre runs on'
else:
log('quit from the page or the Gyre icon')
player.stop()
try:
if panel.connected:
with panel.lock:
ctrl.motors_off()
except Exception:
pass
threading.Thread(target=srv.shutdown, daemon=True).start()
ok, msg = True, 'Gyre is closed. Start it again from the Gyre icon.'
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)),
+1
View File
@@ -195,6 +195,7 @@ class FakeGrbl:
real_dt = now - self.t_last real_dt = now - self.t_last
dt = real_dt * self.speedup dt = real_dt * self.speedup
self.t_last = now self.t_last = now
for _ in range(16): # take several queued lines per tick, as fast as GRBL
self._take_line() self._take_line()
if self.sleeping: if self.sleeping:
self.state = 'Sleep' self.state = 'Sleep'
+31
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):
@@ -216,6 +225,26 @@ class TestReconnect(unittest.TestCase):
fake.close() fake.close()
class TestQuit(unittest.TestCase):
def test_quit_closes_the_page_and_rests_the_motors(self):
with tempfile.TemporaryDirectory() as tmp:
lib = make_library(tmp)
fake, c = make_controller(100)
s = socket.socket(); s.bind(('127.0.0.1', 0)); port = s.getsockname()[1]; s.close()
th = threading.Thread(target=serve, args=(c, CFG), kwargs={'port': port, 'open_browser': False, 'library': lib},
daemon=True)
th.start()
from sandtable import launcher
self.assertTrue(wait_for(lambda: launcher.status(port) is not None, 10))
self.assertTrue(launcher.status(port)['can_quit'])
launcher.quit_gyre(port)
th.join(10)
self.assertFalse(th.is_alive()) # the page has closed
self.assertIsNone(launcher.status(port))
self.assertTrue(fake.sleeping) # motors off
fake.close()
class TestHttp(unittest.TestCase): class TestHttp(unittest.TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
@@ -264,6 +293,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__':