"""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((' 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""" Gyre - Sand Art Table

Gyre sand art table

connecting…
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 sandify.org (export as Theta Rho .thr), or upload a line drawing as .svg: every line in it is joined into one path for the ball. · Jog & calibrate
""" 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('

Gyre: jog & calibrate

', '

← Library

Gyre: jog & calibrate

') 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'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()