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:
1 parent
1400cc8afd
commit
95ad4d25de
52 files changed
+388117
No files matched your search
@@ -0,0 +1,404 @@
|
||||
"""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()
|
||||
Reference in new issue
Block a user