- 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>
405 lines
16 KiB
Python
405 lines
16 KiB
Python
"""Talk to GRBL 1.1 (Arduino Uno) or FluidNC over USB serial.
|
|
|
|
Streaming uses GRBL's character-counting method: keep sending lines while the bytes not yet
|
|
acknowledged fit in the controller's 128-byte receive buffer. That keeps the planner full, so
|
|
the ball doesn't stutter on short moves.
|
|
"""
|
|
import re
|
|
import time
|
|
|
|
RX_BUFFER = 127
|
|
STATUS_RE = re.compile(r'<([^|>]+)(.*)>')
|
|
|
|
# real-time commands (single bytes, acted on at once, never queued)
|
|
RT_STATUS = b'?'
|
|
RT_HOLD = b'!'
|
|
RT_RESUME = b'~'
|
|
RT_RESET = b'\x18'
|
|
RT_JOG_CANCEL = b'\x85'
|
|
|
|
|
|
class GrblError(RuntimeError):
|
|
pass
|
|
|
|
|
|
class Aborted(GrblError):
|
|
"""A wait was cut short because `abort` was set (a STOP from the web page or the jog panel)."""
|
|
|
|
|
|
class SwitchTripped(GrblError):
|
|
"""The home switch was pressed while a pattern was running: the carriage has lost its place."""
|
|
|
|
|
|
class Status:
|
|
def __init__(self):
|
|
self.state = 'Unknown' # Idle, Run, Hold, Jog, Alarm, ...
|
|
self.sub = '' # e.g. Hold:0 = stopped, Hold:1 = still slowing down
|
|
self.mpos = (0.0, 0.0, 0.0)
|
|
self.wco = (0.0, 0.0, 0.0)
|
|
self.pins = ''
|
|
self.when = 0.0
|
|
|
|
@property
|
|
def wpos(self):
|
|
return tuple(m - w for m, w in zip(self.mpos, self.wco))
|
|
|
|
def parse(self, line):
|
|
m = STATUS_RE.match(line)
|
|
if not m:
|
|
return False
|
|
state = m.group(1)
|
|
self.state, _, self.sub = state.partition(':')
|
|
self.pins = ''
|
|
wpos = None
|
|
for field in m.group(2).split('|'):
|
|
k, _, v = field.partition(':')
|
|
if k in ('MPos', 'WPos', 'WCO'):
|
|
vals = (tuple(float(x) for x in v.split(',')) + (0.0, 0.0, 0.0))[:3]
|
|
if k == 'MPos':
|
|
self.mpos = vals
|
|
elif k == 'WCO':
|
|
self.wco = vals
|
|
else:
|
|
wpos = vals
|
|
elif k == 'Pn':
|
|
self.pins = v
|
|
if wpos is not None:
|
|
self.mpos = tuple(a + b for a, b in zip(wpos, self.wco))
|
|
self.when = time.monotonic()
|
|
return True
|
|
|
|
|
|
class Controller:
|
|
"""One open connection. `port` is a pyserial-like object (the tests pass a simulator)."""
|
|
|
|
def __init__(self, port, kind='grbl', log=print):
|
|
self.port = port
|
|
self.kind = kind
|
|
self.log = log
|
|
self.status = Status()
|
|
self.pending = [] # byte lengths of lines sent but not yet acknowledged
|
|
self.errors = []
|
|
self.banner = ''
|
|
self._buf = b''
|
|
self.abort = False # set from another thread (the jog panel's STOP) to end a wait
|
|
self.guard = None # None, or 'release' / 'armed' while patterns run (see arm_guard)
|
|
self.guard_stuck_mm = 0.0
|
|
self.status_every = 0.08 # while guarded, ask for a status report this often (s)
|
|
self._presses = 0
|
|
self._last_q = 0.0
|
|
|
|
# ------------------------------------------------------------ open / close
|
|
@classmethod
|
|
def open(cls, device, baud=115200, kind='grbl', log=print):
|
|
import serial
|
|
p = serial.Serial(device, baud, timeout=0.02, write_timeout=2)
|
|
c = cls(p, kind, log)
|
|
c.wake()
|
|
return c
|
|
|
|
def reopen(self, port):
|
|
"""Carry on with a new port (after the USB link dropped and came back): fresh state, then
|
|
wait for the Uno's start-up banner. The position is lost, so home again before drawing."""
|
|
try:
|
|
if self.port:
|
|
self.port.close()
|
|
except Exception:
|
|
pass
|
|
self.port = port
|
|
self._buf, self.banner = b'', ''
|
|
self.pending.clear()
|
|
self.guard, self.abort = None, False
|
|
self.status = Status()
|
|
self.wake()
|
|
|
|
def wake(self, wait=3.0):
|
|
"""Opening the port resets an Uno (about 2 s); wait for its start-up banner.
|
|
(No blank lines are sent to wake it: their late 'ok's would upset the byte counting.)"""
|
|
t_end = time.monotonic() + wait
|
|
seen = None
|
|
while time.monotonic() < t_end:
|
|
for line in self._lines():
|
|
if 'grbl' in line.lower() or 'fluidnc' in line.lower():
|
|
self.banner, seen = line, time.monotonic()
|
|
if seen and time.monotonic() - seen > 0.3:
|
|
break
|
|
self.port.reset_input_buffer()
|
|
self._buf = b''
|
|
self.pending.clear()
|
|
self.query()
|
|
|
|
def close(self):
|
|
self.port.close()
|
|
|
|
# ------------------------------------------------------------ low level
|
|
def _lines(self):
|
|
data = self.port.read(self.port.in_waiting or 1)
|
|
if data:
|
|
self._buf += data
|
|
out = []
|
|
while b'\n' in self._buf:
|
|
raw, self._buf = self._buf.split(b'\n', 1)
|
|
line = raw.decode('ascii', 'replace').strip()
|
|
if line:
|
|
out.append(line)
|
|
return out
|
|
|
|
def poll(self):
|
|
"""Read whatever has arrived and act on it."""
|
|
if self.guard and time.monotonic() - self._last_q > self.status_every:
|
|
self.realtime(RT_STATUS)
|
|
for line in self._lines():
|
|
if line == 'ok':
|
|
if self.pending:
|
|
self.pending.pop(0)
|
|
elif line.startswith('error:'):
|
|
if self.pending:
|
|
self.pending.pop(0)
|
|
self.errors.append(line)
|
|
elif line.startswith('<'):
|
|
self.status.parse(line)
|
|
if self.guard:
|
|
self._check_guard()
|
|
elif line.startswith('ALARM'):
|
|
self.status.state = 'Alarm'
|
|
raise GrblError(f'{line}: the controller stopped. Check the table, then run `home` again.')
|
|
elif line.startswith('[MSG:'):
|
|
self.log(f'controller says {line}')
|
|
elif 'grbl' in line.lower():
|
|
self.banner = line
|
|
|
|
def realtime(self, b):
|
|
if b == RT_STATUS:
|
|
self._last_q = time.monotonic()
|
|
self.port.write(b)
|
|
|
|
# ------------------------------------------------------------ switch guard
|
|
def arm_guard(self, cfg):
|
|
"""Stop at once if the home switch is pressed while a pattern runs.
|
|
|
|
Patterns stay at least `rho_min_mm` out, 1 mm past where the switch trips, so they never
|
|
press it. A press can then only mean the carriage isn't where the controller thinks:
|
|
missed steps, a slipping belt, or something in the way. Left alone, it would grind into
|
|
the end stop, and later patterns would run off the other end of the arm.
|
|
Right after homing the switch may still be pressed; that's fine until it has been seen
|
|
released once (or the carriage is clearly past it, which means the switch is stuck)."""
|
|
self.guard = 'release'
|
|
self.guard_stuck_mm = cfg['homing']['switch_rho_mm'] + 5.0
|
|
self._presses = 0
|
|
|
|
def disarm_guard(self):
|
|
self.guard = None
|
|
|
|
def _check_guard(self):
|
|
st = self.status
|
|
if st.state in ('Alarm', 'Home'):
|
|
return
|
|
pressed = 'Y' in st.pins
|
|
if self.guard == 'release':
|
|
if not pressed:
|
|
self.guard = 'armed'
|
|
elif st.wpos[1] > self.guard_stuck_mm:
|
|
self._trip(f'the home switch is still pressed at rho {st.wpos[1]:.1f} mm, so it is stuck '
|
|
f'or its wiring is shorted')
|
|
return
|
|
# two reports in a row (about 0.1 s apart), so one electrical blip doesn't stop a pattern
|
|
self._presses = self._presses + 1 if pressed else 0
|
|
if self._presses >= 2:
|
|
self._trip(f'the home switch was pressed at rho {st.wpos[1]:.1f} mm, where the carriage should never '
|
|
f'reach it. The carriage has lost its place (missed steps, a slipping belt, or '
|
|
f'something blocking it)')
|
|
|
|
def _trip(self, why):
|
|
self.realtime(RT_RESET) # stops the motors at once; the position is lost anyway
|
|
self.guard = None
|
|
self.status.state = 'Alarm'
|
|
self.pending.clear()
|
|
raise SwitchTripped(why)
|
|
|
|
def query(self, timeout=1.0):
|
|
"""Ask for a status report and wait for it."""
|
|
before = self.status.when
|
|
self.realtime(RT_STATUS)
|
|
t_end = time.monotonic() + timeout
|
|
while time.monotonic() < t_end:
|
|
self.poll()
|
|
if self.status.when != before:
|
|
return self.status
|
|
raise GrblError('no status report from the controller (wrong port, or not GRBL?)')
|
|
|
|
def send(self, line):
|
|
"""Queue one line, waiting while the controller's receive buffer is full."""
|
|
data = (line.strip() + '\n').encode('ascii')
|
|
while sum(self.pending) + len(data) > RX_BUFFER:
|
|
if self.abort:
|
|
raise Aborted('stopped')
|
|
self.poll()
|
|
self.port.write(data)
|
|
self.pending.append(len(data))
|
|
|
|
def command(self, line, timeout=10.0):
|
|
"""Send one line and wait for its ok (raises on an error)."""
|
|
n_err = len(self.errors)
|
|
self.send(line)
|
|
t_end = time.monotonic() + timeout
|
|
while self.pending:
|
|
if self.abort:
|
|
raise Aborted('stopped')
|
|
self.poll()
|
|
if time.monotonic() > t_end:
|
|
raise GrblError(f'no answer to {line!r}')
|
|
if len(self.errors) > n_err:
|
|
raise GrblError(f'{line!r} -> {self.errors[-1]}')
|
|
|
|
def text(self, line, timeout=3.0):
|
|
"""Send a $ query (only while idle) and collect the lines before its ok."""
|
|
self.port.write((line + '\n').encode())
|
|
out, t_end = [], time.monotonic() + timeout
|
|
while time.monotonic() < t_end:
|
|
for ln in self._lines():
|
|
if ln == 'ok':
|
|
return out
|
|
if ln.startswith('error'):
|
|
raise GrblError(f'{line} -> {ln}')
|
|
if not ln.startswith('<'):
|
|
out.append(ln)
|
|
raise GrblError(f'no answer to {line!r}')
|
|
|
|
def settings(self):
|
|
"""$$ -> {'$100': '71.111', ...}"""
|
|
out = {}
|
|
for ln in self.text('$$'):
|
|
k, _, v = ln.partition('=')
|
|
if k.startswith('$'):
|
|
out[k] = v.split()[0] if v else ''
|
|
return out
|
|
|
|
def wait_idle(self, every=0.15, on_status=None):
|
|
"""Wait until everything sent is acknowledged and the machine has stopped."""
|
|
while True:
|
|
if self.abort:
|
|
raise Aborted('stopped')
|
|
st = self.query()
|
|
if on_status:
|
|
on_status(st)
|
|
if not self.pending and st.state == 'Idle':
|
|
return st
|
|
self.pause(every)
|
|
|
|
def pause(self, secs):
|
|
"""Wait, but keep reading the controller (and, while guarded, keep checking the switch)."""
|
|
t_end = time.monotonic() + secs
|
|
while time.monotonic() < t_end:
|
|
self.poll()
|
|
time.sleep(0.005)
|
|
|
|
# ------------------------------------------------------------ jobs
|
|
def stream(self, lines, on_progress=None, status_every=0.25):
|
|
"""Send G-code lines. on_progress(sent, total, status) is called a few times a second."""
|
|
total, last, n_err = len(lines), 0.0, len(self.errors)
|
|
for i, line in enumerate(lines, 1):
|
|
self.send(line)
|
|
if len(self.errors) > n_err:
|
|
self.hold()
|
|
raise GrblError(f'the controller rejected a line ({self.errors[-1]}); stopped')
|
|
now = time.monotonic()
|
|
if now - last > status_every:
|
|
self.realtime(RT_STATUS)
|
|
last = now
|
|
if on_progress:
|
|
on_progress(i, total, self.status)
|
|
self.wait_idle(on_status=(lambda st: on_progress(total, total, st)) if on_progress else None)
|
|
|
|
def hold(self):
|
|
self.realtime(RT_HOLD)
|
|
|
|
def resume(self):
|
|
self.realtime(RT_RESUME)
|
|
|
|
def stop(self):
|
|
"""Stop cleanly: feed hold, wait until the motion has ended (Hold:0), then reset to throw
|
|
away the moves still queued. Resetting while stopped keeps the machine position, but GRBL
|
|
clears the G92 offset that homing set, so it is put back: no re-homing needed."""
|
|
self.abort = False
|
|
guard, self.guard = self.guard, None
|
|
self.realtime(RT_HOLD)
|
|
t_end = time.monotonic() + 15
|
|
while time.monotonic() < t_end:
|
|
st = self.query()
|
|
if st.state == 'Idle' or (st.state == 'Hold' and st.sub == '0'):
|
|
break
|
|
time.sleep(0.05)
|
|
if st.state == 'Idle' and not self.pending:
|
|
return st # nothing moving and nothing queued: no reset needed
|
|
x, y = st.wpos[0], st.wpos[1]
|
|
self.realtime(RT_RESET)
|
|
time.sleep(0.5)
|
|
t_end = time.monotonic() + 3
|
|
while time.monotonic() < t_end:
|
|
self._lines()
|
|
time.sleep(0.05)
|
|
self.pending.clear()
|
|
if self.query().state != 'Alarm':
|
|
self.command(f'G92 X{x:.3f} Y{y:.3f}')
|
|
if guard:
|
|
self.guard = 'release'
|
|
self._presses = 0
|
|
return self.query()
|
|
|
|
def unlock(self):
|
|
if self.query().state == 'Alarm':
|
|
self.command('$X')
|
|
|
|
# ------------------------------------------------------------ homing (rho only)
|
|
def home_rho(self, cfg, log=print):
|
|
"""Home the carriage against the switch at the centre end of the arm, then set
|
|
theta = 0 and rho = the switch position.
|
|
|
|
GRBL on the Uno: done from here with jog moves and the switch state that GRBL puts in
|
|
every status report ('Pn:Y'), so the stock firmware needs no changes.
|
|
FluidNC: its own $H (configured in its config.yaml)."""
|
|
h = cfg['homing']
|
|
self.disarm_guard()
|
|
if self.kind == 'fluidnc':
|
|
self.command('$H', timeout=90)
|
|
self.command('G92 X0')
|
|
return self.wait_idle()
|
|
self.unlock()
|
|
self.command('G21')
|
|
self.command('G90')
|
|
|
|
def pressed():
|
|
return 'Y' in self.query().pins
|
|
|
|
if pressed():
|
|
log('the switch is already pressed; moving off it first')
|
|
self.command(f"$J=G91 G21 Y{h['pulloff_mm'] + 3:.2f} F{h['seek_mm_s'] * 60:.0f}")
|
|
self.wait_idle()
|
|
if pressed():
|
|
raise GrblError('the home switch stays pressed: check its wiring (COM and NO to the Y- pins)')
|
|
for speed, final in ((h['seek_mm_s'], False), (h['feed_mm_s'], True)):
|
|
self.command(f"$J=G91 G21 Y-{h['max_travel_mm']:.1f} F{speed * 60:.0f}")
|
|
t_end = time.monotonic() + h['max_travel_mm'] / speed + 5
|
|
found = False
|
|
while time.monotonic() < t_end:
|
|
if self.abort:
|
|
raise Aborted('stopped')
|
|
if pressed():
|
|
self.realtime(RT_JOG_CANCEL)
|
|
found = True
|
|
break
|
|
time.sleep(0.005)
|
|
if not found:
|
|
self.realtime(RT_JOG_CANCEL)
|
|
self.wait_idle()
|
|
raise GrblError('home switch not found: is the 12 V supply on? Without it the motors stay still. '
|
|
'Otherwise check the switch wiring, and that +Y moves the magnet outward (run `test`)')
|
|
self.wait_idle()
|
|
if not final:
|
|
self.command(f"$J=G91 G21 Y{h['pulloff_mm']:.2f} F{h['seek_mm_s'] * 60:.0f}")
|
|
self.wait_idle()
|
|
# G92 sets the position without writing the Uno's EEPROM (G10 would, on every homing)
|
|
self.command(f"G92 X0 Y{h['switch_rho_mm']:.3f}")
|
|
self.command('G90')
|
|
return self.wait_idle()
|