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>
679 lines
33 KiB
Python
679 lines
33 KiB
Python
"""Web app: a library of designs to play, plus the jog panel, served from this computer.
|
|
The table stays on the USB cable; the page is just the remote control.
|
|
|
|
Start it with `python3 -m sandtable web` and open http://127.0.0.1:8765.
|
|
"""
|
|
import json
|
|
import os
|
|
import random
|
|
import re
|
|
import shutil
|
|
import threading
|
|
import time
|
|
import webbrowser
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from . import thr
|
|
from .grbl import RT_JOG_CANCEL, Aborted, GrblError, SwitchTripped
|
|
from .motion import HEADER, Planner
|
|
from .ui import PAGE as JOG_PAGE, Panel
|
|
|
|
SOFTWARE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
LIBRARY_DIR = os.path.join(SOFTWARE, 'patterns')
|
|
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):
|
|
"""What the page did and every problem it hit: printed here and kept in .cache/web.log."""
|
|
line = time.strftime('%Y-%m-%d %H:%M:%S ') + msg
|
|
print(line, flush=True)
|
|
if os.environ.get('SANDTABLE_QUIET'): # the tests: keep the real log clean
|
|
return
|
|
try:
|
|
os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True)
|
|
with open(LOG_PATH, 'a') as f:
|
|
f.write(line + '\n')
|
|
except OSError:
|
|
pass
|
|
NOT_IN_PLAY_ALL = {'tools'}
|
|
|
|
|
|
# ---------------------------------------------------------------- library
|
|
class Library:
|
|
"""The .thr files under patterns/, one folder per category. Thumbnails and running times
|
|
are worked out once per file and cached in .cache/thumbs/."""
|
|
|
|
def __init__(self, cfg, root=LIBRARY_DIR, cache=CACHE_DIR, removed=REMOVED_DIR):
|
|
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:
|
|
self.index = json.load(f)
|
|
except (OSError, ValueError):
|
|
self.index = {}
|
|
|
|
def items(self):
|
|
out = []
|
|
for dirpath, _, files in os.walk(self.root):
|
|
for fn in files:
|
|
if not fn.lower().endswith('.thr'):
|
|
continue
|
|
rel = os.path.relpath(os.path.join(dirpath, fn), self.root).replace(os.sep, '/')
|
|
cat = rel.split('/')[0] if '/' in rel else 'other'
|
|
info = self.index.get(rel, {})
|
|
out.append({'id': rel, 'name': fn[:-4], 'category': cat, 'est': info.get('est')})
|
|
out.sort(key=lambda d: (d['category'] == 'tools', d['category'], d['name']))
|
|
return out
|
|
|
|
def path(self, ident):
|
|
"""The file for a library id, refusing anything outside the library folder."""
|
|
p = os.path.realpath(os.path.join(self.root, ident))
|
|
if not p.startswith(os.path.realpath(self.root) + os.sep) or not p.endswith('.thr') or not os.path.isfile(p):
|
|
raise KeyError(f'no design {ident!r}')
|
|
return p
|
|
|
|
def load(self, ident):
|
|
return os.path.basename(ident)[:-4], thr.load(self.path(ident))
|
|
|
|
def _key(self, ident):
|
|
"""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."""
|
|
from .preview import render
|
|
key = self._key(ident)
|
|
with self.lock:
|
|
info = self.index.get(ident)
|
|
png = os.path.join(self.cache, re.sub(r'[^A-Za-z0-9_.-]', '_', ident) + f'-{key}.png')
|
|
if info and info.get('key') == key and os.path.exists(png):
|
|
return png
|
|
_, pts = self.load(ident)
|
|
m = self.cfg['machine']
|
|
pl = Planner(self.cfg, 0.0, m['rho_min_mm'])
|
|
_, segs, est = pl.program(pts, erase=False)
|
|
render([s for s in segs if s[0] == 'pattern'], m['rho_max_mm'], png, size=size,
|
|
invert_theta=m['invert_theta'], transparent=True)
|
|
with self.lock:
|
|
self.index[ident] = {'key': key, 'est': round(est), 'points': len(pts)}
|
|
tmp = self.index_path + '.tmp'
|
|
with open(tmp, 'w') as f:
|
|
json.dump(self.index, f)
|
|
os.replace(tmp, self.index_path)
|
|
return png
|
|
|
|
def warm(self):
|
|
"""Make every missing thumbnail (in the background at start-up) and delete stale ones."""
|
|
keep = set()
|
|
for it in self.items():
|
|
try:
|
|
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
|
|
it is; an .svg line drawing is turned into a pattern first (see lineart.py)."""
|
|
if filename.lower().endswith('.svg') or text.lstrip().startswith(('<svg', '<?xml')):
|
|
from .lineart import svg_strokes, drawing_to_polar
|
|
strokes = svg_strokes(text)
|
|
if not strokes:
|
|
raise ValueError('no lines found in that SVG (text and images are skipped; convert them to paths)')
|
|
pts = drawing_to_polar(strokes)
|
|
text = f'# from {os.path.basename(filename)}: {len(strokes)} strokes\n' + thr.dumps(pts)
|
|
pts = thr.loads(text)
|
|
if len(pts) < 2:
|
|
raise ValueError('that file has no theta-rho points (a .thr file has lines like "0.000 1.000")')
|
|
base = re.sub(r'[^A-Za-z0-9_-]+', '-', os.path.splitext(os.path.basename(filename))[0]).strip('-') or 'design'
|
|
folder = os.path.join(self.root, UPLOADS)
|
|
os.makedirs(folder, exist_ok=True)
|
|
name, n = base, 2
|
|
while os.path.exists(os.path.join(folder, name + '.thr')):
|
|
name, n = f'{base}-{n}', n + 1
|
|
with open(os.path.join(folder, name + '.thr'), 'w') as f:
|
|
f.write(text if text.endswith('\n') else text + '\n')
|
|
return f'{UPLOADS}/{name}.thr'
|
|
|
|
def remove(self, ident):
|
|
"""Move a design out of the library (into removed-patterns/, so it can be put back)."""
|
|
src = self.path(ident)
|
|
dst = os.path.join(self.removed, ident)
|
|
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
|
shutil.move(src, dst)
|
|
|
|
|
|
# ---------------------------------------------------------------- player
|
|
class Player:
|
|
"""Plays a list of designs in a background thread: home if needed, then for each design the
|
|
erase spiral and the design itself. Holds the panel's lock while it runs, so the jog panel's
|
|
buttons wait (the page greys them out)."""
|
|
|
|
def __init__(self, panel, cfg, library, alert=None):
|
|
self.p, self.c, self.cfg, self.lib = panel, panel.c, cfg, library
|
|
self.alert = alert or (lambda title, msg: None)
|
|
self.thread = None
|
|
self.state = 'idle' # idle | playing | paused | stopping
|
|
self.now = None # what is playing: name, phase, progress, times
|
|
self.message = '' # the last problem, shown in red on the page
|
|
self.speed = cfg['motion']['ball_speed_mm_s']
|
|
self.erase = bool(cfg['erase']['enabled'])
|
|
self.pause_s = 0.0
|
|
self.order, self.loop = [], False
|
|
self._stop = self._skip = False
|
|
|
|
def running(self):
|
|
return bool(self.thread and self.thread.is_alive())
|
|
|
|
def status(self):
|
|
now = dict(self.now) if self.now else None
|
|
if now:
|
|
el = time.monotonic() - now.pop('t0')
|
|
now['elapsed'] = round(el)
|
|
now['left'] = round(max(0.0, now['est'] * (1 - now['frac'])))
|
|
return {'state': self.state, 'now': now, 'message': self.message, 'loop': self.loop,
|
|
'queue': len(self.order), 'settings': {'speed': self.speed, 'erase': self.erase, 'pause': self.pause_s}}
|
|
|
|
def play(self, ids, loop=False, shuffle=False):
|
|
for i in ids:
|
|
if not i.startswith('erase:'):
|
|
self.lib.path(i) # raises for an unknown design
|
|
if self.running():
|
|
self.stop()
|
|
order = list(ids)
|
|
if shuffle:
|
|
random.shuffle(order)
|
|
self.order, self.loop, self.shuffle = order, loop, shuffle
|
|
log(f'play {order}' + (' (repeat)' if loop else '') + (' (shuffled)' if shuffle else ''))
|
|
self._stop = self._skip = False
|
|
self.message = ''
|
|
self.state = 'playing'
|
|
self.thread = threading.Thread(target=self._run, daemon=True)
|
|
self.thread.start()
|
|
|
|
def pause(self):
|
|
if self.state == 'playing':
|
|
self.c.hold()
|
|
self.state = 'paused'
|
|
|
|
def resume(self):
|
|
if self.state == 'paused':
|
|
self.c.resume()
|
|
self.state = 'playing'
|
|
|
|
def skip(self):
|
|
if self.running():
|
|
self._skip = True
|
|
self.c.abort = True
|
|
|
|
def stop(self, wait=30):
|
|
if self.running():
|
|
self._stop = True
|
|
self.state = 'stopping'
|
|
self.c.abort = True
|
|
self.thread.join(wait)
|
|
|
|
# ------------------------------------------------------------ the worker thread
|
|
def _run(self):
|
|
c, cfg = self.c, self.cfg
|
|
with self.p.lock:
|
|
try:
|
|
c.abort = False
|
|
if not self.p.homed or c.query().state == 'Alarm':
|
|
self.now = {'name': 'Homing', 'id': None, 'phase': 'homing', 'frac': 0.0, 'est': 20, 't0': time.monotonic()}
|
|
c.home_rho(cfg, log=lambda m: None)
|
|
self.p.homed = True
|
|
for h in HEADER:
|
|
c.command(h)
|
|
c.arm_guard(cfg)
|
|
while not self._stop:
|
|
for ident in list(self.order):
|
|
if self._stop:
|
|
break
|
|
self._play_one(ident)
|
|
t_end = time.monotonic() + self.pause_s
|
|
while time.monotonic() < t_end and not (self._stop or self._skip):
|
|
c.pause(0.2)
|
|
self._skip = False
|
|
if not self.loop:
|
|
break
|
|
if self.shuffle:
|
|
random.shuffle(self.order)
|
|
except Aborted: # a STOP (during homing, or passed on by _play_one)
|
|
log('stopped')
|
|
c.realtime(RT_JOG_CANCEL)
|
|
c.stop() # nothing to do if _play_one already stopped it
|
|
except SwitchTripped as e:
|
|
self.p.homed = False
|
|
self.message = f'Stopped: {e}. Check the arm, belt and carriage, then play again (it homes first).'
|
|
log('ERROR ' + self.message + f' | status {c.status.state} pins={c.status.pins!r} wpos={c.status.wpos}')
|
|
self.alert('Gyre stopped: home switch pressed', self.message)
|
|
except OSError as e: # the USB cable came out: the panel reconnects
|
|
self.p.lost(e)
|
|
self.message = 'Stopped: the USB connection to the Uno was lost. Plug it back in; the page reconnects by itself.'
|
|
log(f'ERROR USB lost: {e!r}')
|
|
self.alert('Gyre: USB connection lost', self.message)
|
|
except GrblError as e:
|
|
self.p.homed = False
|
|
self.message = f'Stopped: {e}'
|
|
log('ERROR ' + self.message + f' | status {c.status.state} wpos={c.status.wpos} errors={c.errors[-3:]}')
|
|
self.alert('Gyre stopped', self.message)
|
|
except Exception as e: # a bug: stop safely and say so
|
|
import traceback
|
|
self.p.homed = False
|
|
self.message = f'Stopped by an unexpected error: {e!r}'
|
|
log('ERROR ' + self.message + '\n' + traceback.format_exc())
|
|
try:
|
|
c.stop()
|
|
except Exception:
|
|
pass
|
|
self.alert('Gyre stopped', self.message)
|
|
finally:
|
|
c.guard = None
|
|
c.abort = False
|
|
if self.cfg['motion'].get('motors_off_when_done', True) and self.p.connected:
|
|
try:
|
|
c.motors_off() # done or stopped: let the motors rest
|
|
self.p.homed = False # the arm can drift now, so home before the next one
|
|
log('motors off')
|
|
except Exception as e:
|
|
log(f'could not switch the motors off: {e!r}')
|
|
self.now = None
|
|
self.state = 'idle'
|
|
|
|
def _play_one(self, ident):
|
|
c = self.c
|
|
if ident.startswith('erase:'):
|
|
name, pts = 'Erase', [(0.0, 0.0 if ident.endswith('centre') else 1.0)] * 2
|
|
else:
|
|
name, pts = self.lib.load(ident)
|
|
st = c.query()
|
|
pl = Planner(self.cfg, st.wpos[0], st.wpos[1])
|
|
lines, _, est = pl.program(pts, erase=self.erase or ident.startswith('erase:'), speed=self.speed)
|
|
n = sum(1 for l in lines if not l.startswith('('))
|
|
self.now = {'name': name, 'id': ident if not ident.startswith('erase:') else None, 'phase': 'drawing',
|
|
'frac': 0.0, 'est': round(est), 't0': time.monotonic()}
|
|
log(f'start {name}: {n} moves, about {round(est)} s, from theta {st.wpos[0]:.1f} rho {st.wpos[1]:.1f}')
|
|
try:
|
|
done = 0
|
|
for line in lines:
|
|
if line.startswith('(---'):
|
|
self.now['phase'] = 'erasing' if 'erase' in line else 'drawing'
|
|
continue
|
|
n_err = len(c.errors)
|
|
c.send(line)
|
|
if len(c.errors) > n_err:
|
|
raise GrblError(f'the controller rejected a line ({c.errors[-1]}): {line}')
|
|
done += 1
|
|
self.now['frac'] = done / n
|
|
c.wait_idle()
|
|
log(f'done {name}')
|
|
except Aborted:
|
|
log(f'{"skip" if self._skip and not self._stop else "stop"} {name} at {100 * self.now["frac"]:.0f}%')
|
|
self.state = 'stopping'
|
|
c.stop() # stops, clears the queue and keeps the position
|
|
if self._stop:
|
|
raise
|
|
self.state = 'playing' # a skip: carry on with the next design
|
|
|
|
|
|
# ---------------------------------------------------------------- page
|
|
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>
|
|
<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}}
|
|
*{box-sizing:border-box}
|
|
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.45 system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
main{max-width:1100px;margin:0 auto;padding:16px}
|
|
header{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-bottom:14px}
|
|
h1{font-size:20px;margin:0 auto 0 0;letter-spacing:.2px}
|
|
.pill{font-size:13px;padding:4px 10px;border-radius:99px;background:var(--card);border:1px solid var(--line);color:var(--mute);font-variant-numeric:tabular-nums}
|
|
button{font:inherit;padding:8px 14px;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--ink);cursor:pointer}
|
|
button:hover:not(:disabled){border-color:var(--acc)}
|
|
button:disabled{opacity:.45;cursor:default}
|
|
button.main{background:var(--acc);color:var(--acc-ink);border-color:var(--acc);font-weight:600}
|
|
button.stop{background:var(--stop);color:#fff;border-color:var(--stop);font-weight:700}
|
|
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;margin-bottom:14px}
|
|
#now{display:none;gap:16px;align-items:center;flex-wrap:wrap}
|
|
#now img{width:110px;height:110px;flex:none}
|
|
#now .info{flex:1 1 200px;min-width:0}
|
|
#now .title{font-size:18px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
.bar{height:8px;background:var(--bar);border-radius:99px;overflow:hidden;margin:8px 0 6px}
|
|
.bar div{height:100%;background:var(--acc);width:0;transition:width .5s}
|
|
.row{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
|
.mute{color:var(--mute);font-size:13px}
|
|
.err{display:none;background:#fdecea;color:#8a1f14;border-color:#f1b8b0}
|
|
@media (prefers-color-scheme:dark){.err{background:#3a1f1c;color:#ffb4a8;border-color:#6b2b24}}
|
|
.controls label{display:flex;align-items:center;gap:6px;font-size:14px;color:var(--mute)}
|
|
.controls input[type=range]{width:110px}
|
|
.chips{display:flex;flex-wrap:wrap;gap:6px;margin:4px 0 12px}
|
|
.chips button{padding:5px 12px;border-radius:99px;font-size:14px}
|
|
.chips button.on{background:var(--ink);color:var(--bg);border-color:var(--ink)}
|
|
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px}
|
|
.tile{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:10px;display:flex;flex-direction:column;gap:6px;position:relative}
|
|
.tile.playing{border-color:var(--acc);box-shadow:0 0 0 2px var(--acc) inset}
|
|
.tile img{width:100%;aspect-ratio:1;display:block;cursor:pointer}
|
|
.tile .name{font-weight:600;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
.tile .meta{display:flex;justify-content:space-between;align-items:center}
|
|
.tile .del{position:absolute;top:6px;right:6px;padding:2px 8px;font-size:13px;opacity:0;border-radius:6px}
|
|
.tile:hover .del{opacity:.8}
|
|
@media (hover:none){.tile .del{opacity:.6}}
|
|
footer{margin:20px 0 8px;color:var(--mute);font-size:13px}
|
|
a{color:var(--acc)}
|
|
@media (max-width:560px){#now img{width:72px;height:72px} #now .info{flex-basis:150px} #now .row{width:100%} #now .row button{flex:1} h1{font-size:18px}}
|
|
</style></head><body><main>
|
|
<header>
|
|
<h1>Gyre <span style="font-weight:400;color:var(--mute);font-size:15px">sand art table</span></h1>
|
|
<span class="pill" id="mach">connecting…</span>
|
|
<button id="homeBtn" onclick="post('home')">Home</button>
|
|
<button class="stop" onclick="post('stop')">STOP</button>
|
|
</header>
|
|
|
|
<div class="card err" id="err"></div>
|
|
|
|
<div class="card" id="now">
|
|
<img id="nowImg" alt="">
|
|
<div class="info">
|
|
<div class="mute" id="nowPhase"></div>
|
|
<div class="title" id="nowName"></div>
|
|
<div class="bar"><div id="nowBar"></div></div>
|
|
<div class="mute" id="nowTime"></div>
|
|
</div>
|
|
<div class="row">
|
|
<button id="pauseBtn" onclick="pauseResume()">Pause</button>
|
|
<button onclick="post('skip')" id="skipBtn">Skip</button>
|
|
<button onclick="post('stop')">Stop</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card controls">
|
|
<div class="row">
|
|
<button class="main" onclick="playAll()">Play all</button>
|
|
<label><input type="checkbox" id="shuffle" checked> shuffle</label>
|
|
<label><input type="checkbox" id="loop" checked> repeat</label>
|
|
<button onclick="post('play',{ids:['erase:edge']})">Erase now</button>
|
|
<span style="flex:1"></span>
|
|
<label>Speed <input type="range" id="speed" min="10" max="40" step="1" onchange="saveSettings()"> <span id="speedVal"></span></label>
|
|
<label><input type="checkbox" id="erase" onchange="saveSettings()"> erase first</label>
|
|
<button onclick="document.getElementById('file').click()">Upload .thr / .svg</button>
|
|
<input type="file" id="file" accept=".thr,.txt,.svg" multiple hidden onchange="upload(this.files)">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="chips" id="chips"></div>
|
|
<div class="grid" id="grid"></div>
|
|
|
|
<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>
|
|
<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;
|
|
const $=id=>document.getElementById(id);
|
|
const fmt=s=>s==null?'':(s>=3600?Math.floor(s/3600)+' h '+Math.round(s%3600/60)+' min':s>=60?Math.round(s/60)+' min':s+' s');
|
|
const thumb=id=>'/thumb?id='+encodeURIComponent(id);
|
|
async function post(path, body){
|
|
const r=await fetch('/api/'+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body||{})});
|
|
const j=await r.json(); if(!j.ok) showErr(j.msg); else if(path!=='settings') showErr(''); tick(); return j;
|
|
}
|
|
function showErr(m){const e=$('err'); e.textContent=m||''; e.style.display=m?'block':'none';}
|
|
function visible(){return LIB.filter(d=>CAT==='all'?true:d.category===CAT);}
|
|
function playAll(){
|
|
const ids=visible().filter(d=>d.category!=='tools').map(d=>d.id);
|
|
if(!ids.length){showErr('nothing to play in this list');return;}
|
|
post('play',{ids,loop:$('loop').checked,shuffle:$('shuffle').checked});
|
|
}
|
|
function playOne(d){
|
|
if(ST && ST.player.state!=='idle' && !confirm('Stop what is playing and draw "'+d.name+'" instead?')) return;
|
|
post('play',{ids:[d.id]});
|
|
}
|
|
async function del(d,ev){
|
|
ev.stopPropagation();
|
|
if(!confirm('Remove "'+d.name+'" from the library? (It moves to the removed-patterns folder.)')) return;
|
|
await post('delete',{id:d.id}); loadLib();
|
|
}
|
|
function pauseResume(){post(ST&&ST.player.state==='paused'?'resume':'pause');}
|
|
function saveSettings(){$('speedVal').textContent=$('speed').value+' mm/s';
|
|
post('settings',{speed:+$('speed').value,erase:$('erase').checked});}
|
|
async function upload(files){
|
|
let last='';
|
|
for(const f of files){
|
|
const r=await fetch('/api/upload?name='+encodeURIComponent(f.name),{method:'POST',body:await f.text()});
|
|
const j=await r.json(); if(!j.ok){showErr(f.name+': '+j.msg);return;} last=j.msg;
|
|
}
|
|
$('file').value=''; CAT='uploads'; await loadLib(); showErr('');
|
|
}
|
|
function renderChips(){
|
|
const cats=['all',...new Set(LIB.map(d=>d.category))];
|
|
$('chips').innerHTML='';
|
|
for(const c of cats){const b=document.createElement('button'); b.textContent=c; if(c===CAT)b.className='on';
|
|
b.onclick=()=>{CAT=c;renderChips();renderGrid();}; $('chips').appendChild(b);}
|
|
}
|
|
function renderGrid(){
|
|
const g=$('grid'); g.innerHTML='';
|
|
for(const d of visible()){
|
|
const t=document.createElement('div'); t.className='tile'; t.dataset.id=d.id;
|
|
t.innerHTML='<img loading="lazy" alt=""><div class="name"></div><div class="meta"><span class="mute"></span><button class="main">Play</button></div><button class="del" title="Remove">✕</button>';
|
|
t.querySelector('img').src=thumb(d.id); t.querySelector('.name').textContent=d.name; t.querySelector('.name').title=d.id;
|
|
t.querySelector('.mute').textContent=d.est!=null?'~'+fmt(d.est):'';
|
|
t.querySelector('img').onclick=()=>playOne(d); t.querySelector('.main').onclick=()=>playOne(d);
|
|
t.querySelector('.del').onclick=ev=>del(d,ev);
|
|
g.appendChild(t);
|
|
}
|
|
markPlaying();
|
|
}
|
|
function markPlaying(){
|
|
const id=ST&&ST.player.now?ST.player.now.id:null;
|
|
document.querySelectorAll('.tile').forEach(t=>t.classList.toggle('playing',t.dataset.id===id));
|
|
}
|
|
async function loadLib(){LIB=await (await fetch('/api/library')).json(); if(CAT!=='all'&&!LIB.some(d=>d.category===CAT))CAT='all'; renderChips(); renderGrid();
|
|
if(LIB.some(d=>d.est==null)) setTimeout(loadLib,4000);} // running times arrive as the thumbnails are made
|
|
let settingsLoaded=false, lastConnErr='';
|
|
async function tick(){
|
|
try{
|
|
ST=await (await fetch('/api/status')).json();
|
|
const m=ST.machine, p=ST.player;
|
|
$('mach').textContent=m.connected?m.state+' · rho '+m.rho.toFixed(1)+' mm · '+(m.homed?'homed':'not homed'):'USB disconnected';
|
|
$('mach').style.color=m.connected?'':'var(--stop)';
|
|
$('homeBtn').disabled=p.state!=='idle'||!m.connected;
|
|
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';
|
|
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;
|
|
$('now').style.display=now?'flex':'none';
|
|
if(now){
|
|
const src=now.id?thumb(now.id):''; if($('nowImg').dataset.src!==src){$('nowImg').src=src;$('nowImg').dataset.src=src;$('nowImg').style.visibility=src?'visible':'hidden';}
|
|
$('nowName').textContent=now.name;
|
|
$('nowPhase').textContent=(p.state==='paused'?'Paused':p.state==='stopping'?'Stopping…':{homing:'Homing',erasing:'Erasing first',drawing:'Drawing'}[now.phase]||'')+(p.queue>1?' · '+p.queue+' in the list'+(p.loop?', repeating':''):'');
|
|
$('nowBar').style.width=(100*now.frac).toFixed(1)+'%';
|
|
$('nowTime').textContent=Math.round(100*now.frac)+'% · '+fmt(now.elapsed)+' gone · about '+fmt(now.left)+' left';
|
|
$('pauseBtn').textContent=p.state==='paused'?'Resume':'Pause';
|
|
}
|
|
markPlaying();
|
|
}catch(e){$('mach').textContent='offline: is `python3 -m sandtable web` still running?';}
|
|
}
|
|
function powerAct(what){
|
|
const q = what==='poweroff' ? 'Power off the table\'s computer? Unplug it only after the green light on the Pi stops blinking.' : 'Restart the table\'s computer? The page comes back in about a minute.';
|
|
if(confirm(q)) post(what).then(j=>{ if(j.ok) showErr(j.msg); });
|
|
return false;
|
|
}
|
|
loadLib(); tick(); setInterval(tick,1000);
|
|
</script></body></html>
|
|
"""
|
|
|
|
|
|
def serve(ctrl, cfg, host='127.0.0.1', port=8765, open_browser=True, open_path='/', alert=None, library=None,
|
|
connected=True):
|
|
panel = Panel(ctrl, cfg, connected=connected)
|
|
lib = library or Library(cfg)
|
|
player = Player(panel, cfg, lib, alert=alert)
|
|
threading.Thread(target=lib.warm, daemon=True).start()
|
|
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'
|
|
return (False, 'a design is playing: stop it first') if player.running() else None
|
|
|
|
class H(BaseHTTPRequestHandler):
|
|
def log_message(self, *a):
|
|
pass
|
|
|
|
def _send(self, code, body, ctype='application/json'):
|
|
data = body.encode() if isinstance(body, str) else body
|
|
self.send_response(code)
|
|
self.send_header('Content-Type', ctype)
|
|
self.send_header('Content-Length', str(len(data)))
|
|
self.send_header('Cache-Control', 'no-store' if ctype != 'image/png' else 'max-age=3600')
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
|
|
def do_GET(self):
|
|
u = urlparse(self.path)
|
|
q = parse_qs(u.query)
|
|
if u.path in ('/', '/index.html'):
|
|
self._send(200, PAGE, 'text/html; charset=utf-8')
|
|
elif u.path == '/jog':
|
|
self._send(200, jog_page, 'text/html; charset=utf-8')
|
|
elif u.path == '/api/status':
|
|
st = panel.status()
|
|
st['homed'] = panel.homed
|
|
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'], 'power_buttons': POWER_BUTTONS}))
|
|
elif u.path == '/api/library':
|
|
self._send(200, json.dumps(lib.items()))
|
|
elif u.path == '/thumb':
|
|
try:
|
|
with open(lib.thumb(q.get('id', [''])[0]), 'rb') as f:
|
|
self._send(200, f.read(), 'image/png')
|
|
except Exception:
|
|
self._send(404, '{}')
|
|
else:
|
|
self._send(404, '{}')
|
|
|
|
def do_POST(self):
|
|
u = urlparse(self.path)
|
|
n = int(self.headers.get('Content-Length') or 0)
|
|
raw = self.rfile.read(n) if n else b''
|
|
act = u.path.rsplit('/', 1)[-1]
|
|
try:
|
|
if act == 'upload':
|
|
name = parse_qs(u.query).get('name', ['design.thr'])[0]
|
|
ok, msg = True, lib.add(name, raw.decode('utf-8', 'replace'))
|
|
self._send(200, json.dumps({'ok': ok, 'msg': msg}))
|
|
return
|
|
body = json.loads(raw or b'{}')
|
|
if act == 'play' and not panel.connected:
|
|
ok, msg = False, panel.error or 'the Uno is not connected'
|
|
elif act == 'play':
|
|
player.play(body.get('ids') or [], loop=bool(body.get('loop')), shuffle=bool(body.get('shuffle')))
|
|
ok, msg = True, 'playing'
|
|
elif act == 'pause':
|
|
player.pause(); ok, msg = True, 'paused'
|
|
elif act == 'resume':
|
|
player.resume(); ok, msg = True, 'playing'
|
|
elif act == 'skip':
|
|
player.skip(); ok, msg = True, 'skipping'
|
|
elif act == 'stop':
|
|
if player.running():
|
|
player.stop(); ok, msg = True, 'stopped'
|
|
else:
|
|
ok, msg = panel.stop()
|
|
elif act == 'settings':
|
|
if 'speed' in body:
|
|
player.speed = max(5.0, min(60.0, float(body['speed'])))
|
|
if 'erase' in body:
|
|
player.erase = bool(body['erase'])
|
|
if 'pause' in body:
|
|
player.pause_s = max(0.0, float(body['pause']))
|
|
ok, msg = True, 'saved'
|
|
elif act == 'delete':
|
|
lib.remove(body.get('id', '')); ok, msg = True, 'removed'
|
|
elif act in ('poweroff', 'reboot'):
|
|
ok, msg = power(act)
|
|
elif act in ('jog', 'home', 'unlock', 'calibrate'):
|
|
ok, msg = busy() or {
|
|
'jog': lambda: panel.jog(body.get('axis'), body.get('d', 0)),
|
|
'home': panel.home,
|
|
'unlock': panel.unlock,
|
|
'calibrate': lambda: panel.calibrate(body.get('key'), body.get('commanded'), body.get('measured')),
|
|
}[act]()
|
|
else:
|
|
ok, msg = False, 'unknown action'
|
|
except Exception as e:
|
|
ok, msg = False, str(e)
|
|
if not ok:
|
|
log(f'page: {act} {raw[:200]!r} -> {msg}')
|
|
self._send(200, json.dumps({'ok': ok, 'msg': msg}))
|
|
|
|
log(f'page started, {"connected to " + str(getattr(ctrl.port, "port", "the Uno")) if connected else "waiting for the Uno"}')
|
|
srv = ThreadingHTTPServer((host, port), H)
|
|
srv.daemon_threads = True
|
|
url = f'http://{"127.0.0.1" if host in ("0.0.0.0", "") else host}:{port}'
|
|
print(f'Gyre page at {url}{open_path} (Ctrl-C here to quit)', flush=True)
|
|
if host in ('0.0.0.0', ''):
|
|
print(' Also open to other devices on your network, with no password: anyone on it can drive the table.')
|
|
if open_browser:
|
|
try:
|
|
webbrowser.open(url + open_path)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
srv.serve_forever()
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
player.stop()
|
|
srv.server_close()
|