Compare commits
3
Commits
97ebae93b2
...
f9b4c08d60
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9b4c08d60 | ||
|
|
db78b1f2e6 | ||
|
|
8a377af116 |
No files matched your search
@@ -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.
|
||||
|
||||
## 14. Everyday use
|
||||
From the `software` folder, run:
|
||||
```
|
||||
python3 -m sandtable web
|
||||
```
|
||||
This opens a page in your browser with the design library:
|
||||
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**.
|
||||
|
||||
From then on, click the Gyre icon. It opens a page in your browser with the design library:
|
||||
- click a design to draw it; it homes and erases the sand first;
|
||||
- **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;
|
||||
- **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
|
||||
| Symptom | Likely cause | Fix |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
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'
|
||||
MONO = '/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf'
|
||||
INK, GRAY, TEAL, RULE = (27, 36, 32), (90, 103, 98), (31, 128, 116), (214, 222, 218)
|
||||
|
||||
+1
-1
@@ -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` |
|
||||
| `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
|
||||
- **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
@@ -1,7 +1,7 @@
|
||||
"""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
|
||||
read inside ~/snap), then copies the results to ../ (the repo's pcb/ folder).
|
||||
Run after route.py and sch.py. Writes to ~/Documents/projects/sandTable-local/sandtable-controller/fab (outside
|
||||
the repo), then copies the results to ../ (the repo's pcb/ folder).
|
||||
"""
|
||||
import csv
|
||||
import glob
|
||||
@@ -14,7 +14,7 @@ import zipfile
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
import design as D
|
||||
|
||||
WORK = os.path.expanduser('~/kicad/sandtable-controller')
|
||||
WORK = os.path.expanduser('~/Documents/projects/sandTable-local/sandtable-controller')
|
||||
NAME = 'sandtable-controller'
|
||||
PCB = os.path.join(WORK, NAME + '.kicad_pcb')
|
||||
SCH = os.path.join(WORK, NAME + '.kicad_sch')
|
||||
|
||||
@@ -6,7 +6,7 @@ sys.path.insert(0, os.path.dirname(__file__))
|
||||
import design as D
|
||||
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()
|
||||
probs = check_placement(parts)
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import design as D
|
||||
from board import build, check_placement, write_pcb, write_project, keepout_zone, text_node
|
||||
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
|
||||
WIDTH = {}
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ from board import uid, fmt
|
||||
from kilib import load_symbol, sym_pins
|
||||
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'
|
||||
ROOT = uid('rootsheet')
|
||||
STUB = 2.54
|
||||
|
||||
@@ -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.
|
||||
Executable
+183
@@ -0,0 +1,183 @@
|
||||
#!/bin/bash
|
||||
# Builds a ready-to-flash SD card image for a Raspberry Pi Zero 2 W that runs Gyre by itself:
|
||||
# Raspberry Pi OS Lite + the Gyre software as a service on http://<hostname>.local
|
||||
#
|
||||
# pi/build-image.sh [work dir] (default work dir: ~/Documents/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
@@ -0,0 +1,15 @@
|
||||
# Packages Gyre needs that Raspberry Pi OS Lite (2026-10-06, trixie arm64) doesn't have:
|
||||
# pyserial and Pillow with its libraries. Installed from the card on first boot (no internet needed).
|
||||
# url sha256
|
||||
https://deb.debian.org/debian/pool/main/f/freetype/libfreetype6_2.13.3+dfsg-1+deb13u1_arm64.deb 0c426e83f1af816b9a4df9056fd2238463d1cd07969ee640965da9fb6ecaf107
|
||||
https://deb.debian.org/debian/pool/main/f/fribidi/libfribidi0_1.0.16-1_arm64.deb 5f5bfdf6ef126b0a38f8ebb8751cbbb845fe2cd4e1dd5258d241be243e09897f
|
||||
https://deb.debian.org/debian/pool/main/g/graphite2/libgraphite2-3_1.3.14-2+deb13u1_arm64.deb b0c3414b113de62be1dc28fa2c899ac0856256e2f094d92c36c3275a5053311a
|
||||
https://deb.debian.org/debian/pool/main/h/harfbuzz/libharfbuzz0b_10.2.0-1+deb13u1_arm64.deb 3ff928f981b54b491ee12e4cf68558cc5d61c652bdab276cd4c4c3fbbcee16ca
|
||||
https://deb.debian.org/debian/pool/main/libi/libimagequant/libimagequant0_2.18.0-1+b2_arm64.deb cac78241d9022f4107c9e167aa9e3fe68878eb8b0c6388b780b99ea1ad7eb628
|
||||
https://deb.debian.org/debian/pool/main/l/lcms2/liblcms2-2_2.16-2+deb13u2_arm64.deb 6636997fc488327a8d0951a038df6e5178a52b3f5e5cc2386d42655621f5672d
|
||||
https://deb.debian.org/debian/pool/main/o/openjpeg2/libopenjp2-7_2.5.3-2.1~deb13u2_arm64.deb e27f4d03d4b5836473be3210b16567aa9435a337f8c4537868773a123f9c9e57
|
||||
https://deb.debian.org/debian/pool/main/r/raqm/libraqm0_0.10.2-1_arm64.deb 64fc3ba82f0ed40a2ed03f91aaaa27b8b68284417be1f0c926ae92c87d9f6563
|
||||
https://deb.debian.org/debian/pool/main/libw/libwebp/libwebpdemux2_1.5.0-0.1_arm64.deb 3ce742d0420c9b4c7f875a150eebed63e2e2fb1c7191edb0bdd482c28cc52f51
|
||||
https://deb.debian.org/debian/pool/main/libw/libwebp/libwebpmux3_1.5.0-0.1_arm64.deb 84a98af076003c5a502afb95b2a1412e14dbe029e10272ddcdf2ac6f75d8c0d1
|
||||
https://deb.debian.org/debian/pool/main/p/pillow/python3-pil_11.1.0-5+deb13u4_arm64.deb ad294f2932ceb9115eece925337b19a6beae3164be9ebb7a4d80057c472fd788
|
||||
https://deb.debian.org/debian/pool/main/p/pyserial/python3-serial_3.5-2_all.deb 55adf00b96ef91b1d8ad935d134c341f7c3dd021a5666cd53e75f37144006a51
|
||||
@@ -0,0 +1,2 @@
|
||||
# The Gyre page's Power off / Restart buttons
|
||||
gyre ALL=(root) NOPASSWD: /usr/bin/systemctl poweroff, /usr/bin/systemctl reboot
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Gyre sand art table: web page and pattern player
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
User=gyre
|
||||
WorkingDirectory=/opt/gyre/software
|
||||
Environment=PYTHONUNBUFFERED=1 GYRE_POWER_BUTTONS=1
|
||||
ExecStart=/usr/bin/python3 -m sandtable web --host 0.0.0.0 --http-port 80 --no-browser
|
||||
# lets it use port 80 (so the address is just http://gyre.local) without running as root
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,10 @@
|
||||
# Copy to <work dir>/settings.env (default ~/Documents/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
@@ -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.
|
||||
|
||||
## Web page (everyday use)
|
||||
```
|
||||
python3 -m sandtable web
|
||||
```
|
||||
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).
|
||||
**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.
|
||||
- **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**.
|
||||
- **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`.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -452,6 +452,9 @@ def main(argv=None):
|
||||
sub = ap.add_subparsers(dest='cmd', required=True)
|
||||
sub.add_parser('ports', help='list serial ports')
|
||||
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'),
|
||||
('ui', 'the same web page, opened at the jog panel: home, jog, calibrate')):
|
||||
u = sub.add_parser(name, help=text)
|
||||
@@ -483,5 +486,9 @@ def main(argv=None):
|
||||
m.add_argument('--no-home', action='store_true')
|
||||
args = ap.parse_args(argv)
|
||||
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,
|
||||
'play': cmd_play, 'gen': cmd_gen, 'erase': cmd_erase, 'move': cmd_move}[args.cmd](cfg, args)
|
||||
@@ -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
|
||||
@@ -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 |
@@ -14,6 +14,7 @@ from .grbl import RT_HOLD, RT_JOG_CANCEL, GrblError
|
||||
PAGE = r"""<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Gyre - Jog</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<style>
|
||||
: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}}
|
||||
|
||||
@@ -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
|
||||
@@ -325,6 +344,7 @@ class Player:
|
||||
PAGE = r"""<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Gyre - Sand Art Table</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<style>
|
||||
: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}}
|
||||
@@ -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).
|
||||
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 & calibrate</a></footer>
|
||||
· <a href="/jog">Jog & 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>
|
||||
<script>
|
||||
let LIB=[], CAT='all', ST=null;
|
||||
@@ -485,6 +507,8 @@ async function tick(){
|
||||
if(!m.connected && !p.message) showErr(m.error);
|
||||
else if(m.connected && !p.message && $('err').textContent===lastConnErr) showErr('');
|
||||
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(p.message) showErr(p.message);
|
||||
const now=p.now;
|
||||
@@ -500,7 +524,17 @@ async function tick(){
|
||||
markPlaying();
|
||||
}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>
|
||||
"""
|
||||
|
||||
@@ -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 & calibrate</h1>',
|
||||
'<p><a href="/">← Library</a></p><h1>Gyre: jog & 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():
|
||||
if not panel.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)
|
||||
if u.path in ('/', '/index.html'):
|
||||
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':
|
||||
self._send(200, jog_page, 'text/html; charset=utf-8')
|
||||
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(),
|
||||
'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,
|
||||
'can_quit': not POWER_BUTTONS}))
|
||||
elif u.path == '/api/library':
|
||||
self._send(200, json.dumps(lib.items()))
|
||||
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'
|
||||
elif act == 'delete':
|
||||
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'):
|
||||
ok, msg = busy() or {
|
||||
'jog': lambda: panel.jog(body.get('axis'), body.get('d', 0)),
|
||||
|
||||
@@ -195,7 +195,8 @@ class FakeGrbl:
|
||||
real_dt = now - self.t_last
|
||||
dt = real_dt * self.speedup
|
||||
self.t_last = now
|
||||
self._take_line()
|
||||
for _ in range(16): # take several queued lines per tick, as fast as GRBL
|
||||
self._take_line()
|
||||
if self.sleeping:
|
||||
self.state = 'Sleep'
|
||||
continue
|
||||
|
||||
@@ -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):
|
||||
@@ -216,6 +225,26 @@ class TestReconnect(unittest.TestCase):
|
||||
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):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
@@ -264,6 +293,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__':
|
||||
|
||||
Reference in new issue
Block a user