Software: pattern player, web page with a design library, GRBL tools

- python3 -m sandtable web: local web page to play designs from a library of
  27 built-in patterns, upload .thr or .svg, pause/skip/stop, jog and calibrate.
  Reconnects by itself when the USB cable comes back.
- Plays .thr files and built-in generators with an erase spiral between them,
  constant ball speed, host-side homing on stock GRBL 1.1 (Uno + CNC shield),
  FluidNC for the ESP32 board.
- Home switch guard: patterns never press the switch, so a press means lost
  steps and stops the machine at once with an alert.
- lineart.py turns SVG line drawings into one continuous path.
- Tests against a GRBL simulator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ckochandClaude Opus 5.5 committed 2026-11-01 13:40:23 -05:00
1 parent 1400cc8afd
commit 95ad4d25de
52 files changed
+388117

No files matched your search

+624
View File
@@ -0,0 +1,624 @@
"""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')
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.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):
st = os.stat(self.path(ident))
return f'{st.st_mtime_ns}-{st.st_size}'
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)."""
for it in self.items():
try:
self.thumb(it['id'])
except Exception:
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('Sand table 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('Sand table: 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('Sand table 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('Sand table stopped', self.message)
finally:
c.guard = None
c.abort = False
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>Sand 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>Sand Table</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 &amp; calibrate</a></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;
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?';}
}
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>Sand table: jog &amp; calibrate</h1>',
'<p><a href="/">&larr; Library</a></p><h1>Sand table: jog &amp; calibrate</h1>')
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']}))
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 ('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'Sand table 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()