Files
sandTable/software/sandtable/ui.py
T
ckochandClaude Opus 5.5 97ebae93b2 Name it Gyre - Sand Art Table
README, build guide, software README, web page header and title, jog page,
desktop alerts and the start-up message now say Gyre. The repo, the Python
package and its commands keep their names (sandTable, python3 -m sandtable),
so links and setups keep working. New web page screenshot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:09:05 -05:00

347 lines
15 KiB
Python

"""Jog panel: a small web page (http://127.0.0.1:8765) to home, jog and calibrate the table.
Keeps the USB connection open the whole time, so the Uno doesn't restart between moves.
Start it with `python3 -m sandtable ui`.
"""
import json
import threading
import time
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
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>
<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}}
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.4 system-ui,sans-serif}
main{max-width:720px;margin:0 auto;padding:16px}
h1{font-size:20px;margin:4px 0 12px}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px;margin-bottom:12px}
.row{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.stat{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
.stat div{background:var(--bg);border-radius:8px;padding:8px}
.stat b{display:block;font-size:22px;font-variant-numeric:tabular-nums}
.stat span{font-size:12px;color:var(--mute)}
button{font:inherit;padding:10px 14px;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--ink);cursor:pointer;min-width:64px}
button:hover{border-color:var(--acc)}
button.main{background:var(--acc);color:#fff;border-color:var(--acc)}
button.stop{background:var(--stop);color:#fff;border-color:var(--stop);font-weight:700;width:100%;padding:16px;font-size:18px}
.lbl{width:90px;color:var(--mute)}
input{font:inherit;width:90px;padding:8px;border-radius:6px;border:1px solid var(--line);background:var(--bg);color:var(--ink)}
.msg{min-height:1.4em;color:var(--mute);font-size:14px}
.err{color:var(--stop)}
small{color:var(--mute)}
</style></head><body><main>
<h1>Gyre: jog &amp; calibrate</h1>
<div class="card"><div class="stat">
<div><span>State</span><b id="state">-</b></div>
<div><span>Rho (mm)</span><b id="rho">-</b></div>
<div><span>Theta (deg)</span><b id="theta">-</b></div>
<div><span>Home switch</span><b id="sw">-</b></div>
</div><p class="msg" id="msg"></p></div>
<div class="card"><button class="stop" onclick="post('stop')">STOP</button></div>
<div class="card">
<div class="row"><button class="main" onclick="post('home')">Home rho</button>
<button onclick="post('unlock')">Unlock</button>
<small id="homed">not homed since connecting</small></div>
</div>
<div class="card">
<div class="row"><span class="lbl">Rho</span>
<button onclick="jog('Y',-10)">&minus;10 mm</button><button onclick="jog('Y',-0.5)">&minus;0.5</button>
<button onclick="jog('Y',0.5)">+0.5</button><button onclick="jog('Y',10)">+10 mm</button></div>
<p><small>+ moves the magnet outward. An inward jog stops by itself when it presses the home switch; nothing stops it at the outer end, so watch the carriage there.</small></p>
<div class="row"><span class="lbl">Theta</span>
<button onclick="jog('X',-90)">&minus;90&deg;</button><button onclick="jog('X',-10)">&minus;10&deg;</button>
<button onclick="jog('X',-0.5)">&minus;0.5&deg;</button><button onclick="jog('X',0.5)">+0.5&deg;</button>
<button onclick="jog('X',10)">+10&deg;</button><button onclick="jog('X',90)">+90&deg;</button></div>
<p><small>+ turns the arm counter-clockwise, seen from above.</small></p>
</div>
<div class="card">
<b>Calibrate</b>
<p><small>Move a known amount with the buttons above, measure what really happened, and enter both. The new steps value is written to the Uno.</small></p>
<div class="row"><span class="lbl">Rho</span>
commanded <input id="rc" value="20"> mm, measured <input id="rm" placeholder="mm">
<button onclick="cal('$101','rc','rm')">Apply</button></div>
<div class="row" style="margin-top:8px"><span class="lbl">Theta</span>
commanded <input id="tc" value="360"> deg, measured <input id="tm" placeholder="deg">
<button onclick="cal('$100','tc','tm')">Apply</button></div>
<p class="msg">Now: rho <b id="s101">-</b> steps/mm, theta <b id="s100">-</b> steps/deg</p>
</div>
</main>
<script>
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(); show(j.msg, !j.ok); return j;
}
function jog(axis, d){ post('jog', {axis, d}); }
function cal(key, c, m){
const cmd = parseFloat(document.getElementById(c).value), meas = parseFloat(document.getElementById(m).value);
if(!(cmd>0) || !(meas>0)){ show('enter both numbers', true); return; }
post('calibrate', {key, commanded: cmd, measured: meas});
}
function show(t, bad){ const e = document.getElementById('msg'); e.textContent = t||''; e.className = 'msg' + (bad?' err':''); }
async function tick(){
try{
const s = await (await fetch('/api/status')).json();
document.getElementById('state').textContent = s.state;
document.getElementById('rho').textContent = s.rho.toFixed(2);
document.getElementById('theta').textContent = s.theta.toFixed(1);
const other = (s.pins||'').replace('Y','');
document.getElementById('sw').textContent = (s.switch ? 'pressed' : 'open') + (other ? ' (other inputs on: ' + other + ')' : '');
document.getElementById('homed').textContent = s.homed ? 'homed: rho is measured from the centre' : 'not homed since connecting';
document.getElementById('s101').textContent = s.steps['$101']||'-';
document.getElementById('s100').textContent = s.steps['$100']||'-';
if(s.error) show(s.error, true);
}catch(e){ document.getElementById('state').textContent = 'offline'; }
setTimeout(tick, 300);
}
tick();
</script></body></html>
"""
def _log(msg):
try:
from .web import log
log(msg)
except Exception:
print(msg, flush=True)
def open_usb(cfg):
"""Find the Uno and open it, for reconnecting. Raises OSError with a plain message."""
import serial
from serial.tools import list_ports
from .cli import USB_IDS
dev = cfg['serial']['port']
if not dev or dev == 'auto':
cands = [p.device for p in list_ports.comports() if p.vid in USB_IDS]
if not cands:
raise OSError('the Uno is not connected: plug in its USB cable (it reconnects by itself)')
dev = cands[0]
try:
return serial.Serial(dev, cfg['serial']['baud'], timeout=0.02, write_timeout=2)
except Exception as e:
if getattr(e, 'errno', None) == 13 or 'Permission denied' in str(e):
raise OSError(f'no permission for {dev}: run sudo chmod a+rw {dev} (it reconnects by itself)')
raise OSError(f'cannot open {dev}: {e}')
class Panel:
def __init__(self, ctrl, cfg, connected=True, opener=open_usb):
self.c = ctrl
self.cfg = cfg
self.lock = threading.Lock() # one serial conversation at a time
self.homed = False
self.error = ''
self.steps = {}
self.busy = ''
self.jog_in = False # the last jog moved rho inward, toward the switch
self.connected = connected
self.opener = opener
self._next_try = 0.0
if connected:
with self.lock:
self._read_steps()
else:
self.error = 'the Uno is not connected: plug in its USB cable (it reconnects by itself)'
threading.Thread(target=self._poll, daemon=True).start()
def lost(self, e):
"""The USB link dropped: forget the position and start trying to reconnect."""
_log(f'USB connection lost: {e!r}')
self.connected = False
self.homed = False
self.error = f'USB connection lost ({e}). Plug the Uno back in; the page reconnects by itself.'
try:
self.c.port.close()
except Exception:
pass
def _reconnect(self):
if time.monotonic() < self._next_try:
return
self._next_try = time.monotonic() + 2.0
try:
self.c.reopen(self.opener(self.cfg))
self.c.query()
self._read_steps()
except Exception as e:
self.error = str(e)
return
self.connected = True
self.error = ''
_log('USB connection back')
def _read_steps(self):
s = self.c.settings()
self.steps = {k: s.get(k, '') for k in ('$100', '$101')}
def _poll(self):
while True:
fast = False
if self.lock.acquire(timeout=0.05):
try:
if not self.connected:
self._reconnect()
else:
st = self.c.query()
fast = st.state == 'Jog'
if fast and self.jog_in and 'Y' in st.pins:
# inward jog reached the home switch: stop before the carriage hits the end stop
self.c.realtime(RT_JOG_CANCEL)
self.jog_in = False
self.error = 'inward jog stopped: the home switch is pressed'
except OSError as e: # pyserial's errors are OSErrors: the cable came out
self.lost(e)
except Exception as e: # keep the page alive; show the problem
if str(e) != self.error:
_log(f'panel: {e}')
self.error = str(e)
finally:
self.lock.release()
time.sleep(0.05 if fast else 0.25)
def status(self):
st = self.c.status
state = 'Motors off' if st.state == 'Sleep' else st.state + (':' + st.sub if st.sub else '')
return {'state': 'Disconnected' if not self.connected else self.busy or state,
'connected': self.connected,
'theta': st.wpos[0], 'rho': st.wpos[1], 'switch': 'Y' in st.pins, 'pins': st.pins,
'homed': self.homed, 'steps': self.steps, 'error': self.error}
# ---------------------------------------------------------- actions
def jog(self, axis, d):
axis = 'X' if axis == 'X' else 'Y'
d = max(-360.0, min(360.0, float(d)))
feed = 1200 if axis == 'X' else 600 # 20 deg/s, 10 mm/s
with self.lock:
st = self.c.query()
if st.state == 'Sleep': # motors switched off after a design: wake them
self.c.unlock()
st = self.c.query()
if st.state == 'Alarm':
return False, 'alarm: press Unlock first'
inward = axis == 'Y' and d < 0
if inward and 'Y' in st.pins:
return False, 'the home switch is pressed: rho can only move outward (+) from here'
self.error = ''
self.jog_in = inward
self.c.command(f'$J=G91 G21 {axis}{d:.3f} F{feed}')
return True, f'jog {"theta" if axis == "X" else "rho"} {d:+g}'
def home(self):
if not self.lock.acquire(timeout=1):
return False, 'busy'
try:
self.busy = 'Homing'
self.jog_in = False
self.c.abort = False
self.c.home_rho(self.cfg, log=lambda m: None)
self.homed = True
self.error = ''
return True, 'homed'
except GrblError as e:
self.homed = False
return False, str(e)
finally:
self.busy = ''
self.lock.release()
def stop(self):
# real-time bytes go straight out, even while another request holds the lock
self.c.abort = True
self.c.realtime(RT_JOG_CANCEL)
self.c.realtime(RT_HOLD)
with self.lock:
self.c.stop() # waits for the stop, clears the queue, keeps the position
return True, 'stopped'
def unlock(self):
with self.lock:
self.c.unlock() # also wakes the motors after a design
self.c.command('$X')
return True, 'unlocked'
def calibrate(self, key, commanded, measured):
if key not in ('$100', '$101'):
return False, 'bad setting'
with self.lock:
old = float(self.c.settings().get(key, '0'))
new = old * float(commanded) / float(measured)
if not 0.2 * old < new < 5 * old:
return False, f'that would change {key} from {old:g} to {new:.3f}; check the numbers'
self.c.command(f'{key}={new:.3f}')
self._read_steps()
what = 'rho steps/mm' if key == '$101' else 'theta steps/deg'
return True, (f'{what}: {old:g} -> {new:.3f}. Saved on the Uno. Home again, then copy it into '
f'sandtable.toml so `setup` keeps it.')
def serve(ctrl, cfg, port=8765, open_browser=True):
panel = Panel(ctrl, cfg)
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.end_headers()
self.wfile.write(data)
def do_GET(self):
if self.path in ('/', '/index.html'):
self._send(200, PAGE, 'text/html; charset=utf-8')
elif self.path == '/api/status':
self._send(200, json.dumps(panel.status()))
else:
self._send(404, '{}')
def do_POST(self):
n = int(self.headers.get('Content-Length') or 0)
body = json.loads(self.rfile.read(n) or b'{}')
act = self.path.rsplit('/', 1)[-1]
try:
if act == 'jog':
ok, msg = panel.jog(body.get('axis'), body.get('d', 0))
elif act == 'home':
ok, msg = panel.home()
elif act == 'stop':
ok, msg = panel.stop()
elif act == 'unlock':
ok, msg = panel.unlock()
elif act == 'calibrate':
ok, msg = panel.calibrate(body.get('key'), body.get('commanded'), body.get('measured'))
else:
ok, msg = False, 'unknown action'
except Exception as e:
ok, msg = False, str(e)
self._send(200, json.dumps({'ok': ok, 'msg': msg}))
srv = ThreadingHTTPServer(('127.0.0.1', port), H)
url = f'http://127.0.0.1:{port}'
print(f'Jog panel at {url} (Ctrl-C here to quit)', flush=True)
if open_browser:
try:
webbrowser.open(url)
except Exception:
pass
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
finally:
srv.server_close()