Files
sandTable/software/sandtable/grbl.py
T
ckochandClaude Opus 5.5 7afe562124 Switch the motors off when the designs are done or stopped
After the last design (or a Stop), the player sends $SLP (GRBL and FluidNC
sleep), so the motors stop holding and the table is quiet. The page shows
"Motors off"; the next design wakes the controller (reset + unlock) and homes
first, since the arm can be moved by hand while the motors are off. The jog
panel wakes it too. Setting: motion.motors_off_when_done (default true).

Checked on the real Uno: Idle -> $SLP -> Sleep -> reset -> Alarm -> $X -> Idle.

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

431 lines
17 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 motors_off(self):
"""Switch the motors off with the sleep command ($SLP; FluidNC has it too). They stop
holding, so the arm turns by hand and the position is lost: before moving again the
controller needs waking (a reset, see wake_up) and the table homing."""
self.guard = None
if self.query().state != 'Sleep':
self.command('$SLP')
self.query()
def wake_up(self):
"""Out of sleep: only a reset wakes it, and it then waits in ALARM until unlocked."""
if self.query().state != 'Sleep':
return
self.realtime(RT_RESET)
t_end, seen = time.monotonic() + 3, None
while time.monotonic() < t_end:
for line in self._lines():
if 'grbl' in line.lower() or 'fluidnc' in line.lower():
seen = time.monotonic()
if seen and time.monotonic() - seen > 0.2:
break
time.sleep(0.02)
self.pending.clear()
self.query()
def unlock(self):
self.wake_up()
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()