- python3 -m sandtable install-desktop puts a Gyre icon in the app grid and on the desktop (freedesktop .desktop entry with a "Quit Gyre" action). - The icon runs `launch`: starts the web page in the background (no terminal, own session, log in .cache/server.log) if it isn't running, then opens it. A dialog shows the reason if it can't start. - Quit link on the page (local computer only) and `quit`: stop any design, motors off, close the page. - Gyre icon (sandtable/static/gyre.svg), also the page's tab icon. - Simulator takes several lines per tick, so streaming tests aren't slowed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
348 lines
15 KiB
Python
348 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>
|
|
<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}}
|
|
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 & 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)">−10 mm</button><button onclick="jog('Y',-0.5)">−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)">−90°</button><button onclick="jog('X',-10)">−10°</button>
|
|
<button onclick="jog('X',-0.5)">−0.5°</button><button onclick="jog('X',0.5)">+0.5°</button>
|
|
<button onclick="jog('X',10)">+10°</button><button onclick="jog('X',90)">+90°</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()
|