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>
This commit is contained in:
ckochandClaude Opus 5.5 committed 2026-11-01 14:19:12 -05:00
1 parent 8967271e90
commit 7afe562124
10 files changed
+104 -10

No files matched your search

+1
View File
@@ -254,6 +254,7 @@ python3 -m sandtable web
This opens a page in your browser with the design library: This opens a page in your browser with the design library:
- click a design to draw it; it homes and erases the sand first; - click a design to draw it; it homes and erases the sand first;
- **Play all** runs the whole list, shuffled and repeating; - **Play all** runs the whole list, shuffled and repeating;
- when the designs finish (or you press Stop), the motors switch off and the page shows "Motors off". The next design homes first;
- **Upload** takes `.thr` files (from [sandify.org](https://sandify.org)) and `.svg` line drawings. - **Upload** takes `.thr` files (from [sandify.org](https://sandify.org)) and `.svg` line drawings.
Leave the terminal open while the table runs. See [`software/README.md`](software/README.md) for everything else. Leave the terminal open while the table runs. See [`software/README.md`](software/README.md) for everything else.
+6 -1
View File
@@ -24,6 +24,9 @@ This opens http://127.0.0.1:8765 in your browser. The table stays on the USB cab
- **STOP** at the top stops everything at once; - **STOP** at the top stops everything at once;
- the position is kept after a stop, so the next design starts without homing again. - the position is kept after a stop, so the next design starts without homing again.
- **Speed and "erase first":** these apply from the next design on, and reset when the program restarts. Lasting defaults go in `sandtable.toml`. - **Speed and "erase first":** these apply from the next design on, and reset when the program restarts. Lasting defaults go in `sandtable.toml`.
- **Motors off when done:** when the designs finish, or you press Stop, the motors switch off, so the table is quiet and they don't sit warm. The status then reads "Motors off".
- The arm can be turned by hand then, so the next design homes first.
- To keep them powered, set `motors_off_when_done = false` under `[motion]` in `sandtable.toml`.
- **Upload .thr / .svg:** adds your own designs. They go in `patterns/uploads/`. - **Upload .thr / .svg:** adds your own designs. They go in `patterns/uploads/`.
- `.thr`: for example from [Sandify](https://sandify.org). - `.thr`: for example from [Sandify](https://sandify.org).
- `.svg`: a line drawing. Every line in it is joined into one path for the ball (see "Line drawings" below). - `.svg`: a line drawing. Every line in it is joined into one path for the ball (see "Line drawings" below).
@@ -73,7 +76,8 @@ python3 -m sandtable erase # spiral-erase, bal
``` ```
- **Homing:** each `play` homes rho first. The Uno restarts whenever the port is opened, so it doesn't remember where the carriage was. - **Homing:** each `play` homes rho first. The Uno restarts whenever the port is opened, so it doesn't remember where the carriage was.
- **Erasing:** before each pattern the ball spirals across the whole tray (7 mm apart) and finishes exactly where the pattern starts. Turn it off with `--no-erase`. - **Erasing:** before each pattern the ball spirals across the whole tray (7 mm apart) and finishes exactly where the pattern starts. Turn it off with `--no-erase`.
- **Pause and stop:** press Ctrl-C to pause (feed hold), then choose resume or stop. Stopping keeps the position. - **Pause and stop:** press Ctrl-C to pause (feed hold), then choose resume or stop.
- **Motors off:** when `play` finishes or is stopped, it switches the motors off (`$SLP`), unless `motors_off_when_done = false`.
- **Home switch guard:** see the next section. - **Home switch guard:** see the next section.
- **Preview first:** add `--dry-run --preview out.png` to see the pattern and its running time without moving anything; `--gcode out.gcode` also writes the G-code. - **Preview first:** add `--dry-run --preview out.png` to see the pattern and its running time without moving anything; `--gcode out.gcode` also writes the G-code.
- **Other options:** `--rotate DEG` and `--mirror` change how a pattern is placed. - **Other options:** `--rotate DEG` and `--mirror` change how a pattern is placed.
@@ -127,6 +131,7 @@ The ball can't lift, so a drawing's separate lines have to become one line. `san
| `machine.theta_offset_deg` | 0 | Turns every pattern | | `machine.theta_offset_deg` | 0 | Turns every pattern |
| `motion.ball_speed_mm_s` | 25 | Drawing speed. Slower is crisper and quieter | | `motion.ball_speed_mm_s` | 25 | Drawing speed. Slower is crisper and quieter |
| `motion.max_theta_deg_s` | 60 | Arm spin limit, which also limits speed near the centre | | `motion.max_theta_deg_s` | 60 | Arm spin limit, which also limits speed near the centre |
| `motion.motors_off_when_done` | true | Switch the motors off when the designs finish or are stopped; the next design homes first |
| `erase.pitch_mm`, `erase.speed_mm_s` | 7, 35 | Erase spiral spacing and speed | | `erase.pitch_mm`, `erase.speed_mm_s` | 7, 35 | Erase spiral spacing and speed |
All other defaults (steps, accelerations, homing speeds) are in `sandtable/config.py`. All other defaults (steps, accelerations, homing speeds) are in `sandtable/config.py`.
+12
View File
@@ -353,6 +353,18 @@ def cmd_play(cfg, args):
def _play(c, cfg, args, items): def _play(c, cfg, args, items):
try:
_play_list(c, cfg, args, items)
finally:
if cfg['motion'].get('motors_off_when_done', True):
try:
c.motors_off() # done, stopped or failed: let the motors rest
print(' motors off')
except Exception:
pass # e.g. the USB link has gone
def _play_list(c, cfg, args, items):
if not args.no_home: if not args.no_home:
print(' homing rho ...', flush=True) print(' homing rho ...', flush=True)
c.home_rho(cfg) c.home_rho(cfg)
+2
View File
@@ -26,6 +26,8 @@ DEFAULTS = {
}, },
'motion': { 'motion': {
'ball_speed_mm_s': 25.0, # drawing speed of the ball across the sand 'ball_speed_mm_s': 25.0, # drawing speed of the ball across the sand
'motors_off_when_done': True, # switch the motors off when the designs are done or stopped
# (quiet, cool); the next design homes first
'max_theta_deg_s': 60.0, # arm spin limit (10 rpm) 'max_theta_deg_s': 60.0, # arm spin limit (10 rpm)
'max_rho_mm_s': 25.0, # carriage limit 'max_rho_mm_s': 25.0, # carriage limit
'theta_accel_deg_s2': 120.0, 'theta_accel_deg_s2': 120.0,
+26
View File
@@ -346,7 +346,33 @@ class Controller:
self._presses = 0 self._presses = 0
return self.query() 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): def unlock(self):
self.wake_up()
if self.query().state == 'Alarm': if self.query().state == 'Alarm':
self.command('$X') self.command('$X')
+6 -1
View File
@@ -212,7 +212,8 @@ class Panel:
def status(self): def status(self):
st = self.c.status st = self.c.status
return {'state': 'Disconnected' if not self.connected else self.busy or (st.state + (':' + st.sub if st.sub else '')), 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, 'connected': self.connected,
'theta': st.wpos[0], 'rho': st.wpos[1], 'switch': 'Y' in st.pins, 'pins': st.pins, '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} 'homed': self.homed, 'steps': self.steps, 'error': self.error}
@@ -224,6 +225,9 @@ class Panel:
feed = 1200 if axis == 'X' else 600 # 20 deg/s, 10 mm/s feed = 1200 if axis == 'X' else 600 # 20 deg/s, 10 mm/s
with self.lock: with self.lock:
st = self.c.query() 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': if st.state == 'Alarm':
return False, 'alarm: press Unlock first' return False, 'alarm: press Unlock first'
inward = axis == 'Y' and d < 0 inward = axis == 'Y' and d < 0
@@ -263,6 +267,7 @@ class Panel:
def unlock(self): def unlock(self):
with self.lock: with self.lock:
self.c.unlock() # also wakes the motors after a design
self.c.command('$X') self.c.command('$X')
return True, 'unlocked' return True, 'unlocked'
+7
View File
@@ -275,6 +275,13 @@ class Player:
finally: finally:
c.guard = None c.guard = None
c.abort = False c.abort = False
if self.cfg['motion'].get('motors_off_when_done', True) and self.p.connected:
try:
c.motors_off() # done or stopped: let the motors rest
self.p.homed = False # the arm can drift now, so home before the next one
log('motors off')
except Exception as e:
log(f'could not switch the motors off: {e!r}')
self.now = None self.now = None
self.state = 'idle' self.state = 'idle'
+11 -1
View File
@@ -34,6 +34,7 @@ class FakeGrbl:
self.hold = False self.hold = False
self.alarm = False self.alarm = False
self.unplugged = False # set to pretend the USB cable came out self.unplugged = False # set to pretend the USB cable came out
self.sleeping = False # after $SLP: motors off until a reset
self.absolute = True self.absolute = True
self.settings = {'$3': '0', '$100': '250.000', '$101': '250.000', '$110': '500', '$111': '500'} self.settings = {'$3': '0', '$100': '250.000', '$101': '250.000', '$110': '500', '$111': '500'}
self.max_rx = 0 self.max_rx = 0
@@ -80,6 +81,8 @@ class FakeGrbl:
self.hold = False self.hold = False
elif b == 0x18: elif b == 0x18:
moving = bool(self.planner) and not self.hold moving = bool(self.planner) and not self.hold
if self.sleeping: # waking from sleep: GRBL comes up locked
self.sleeping, self.alarm = False, True
if moving: if moving:
self.alarm = True self.alarm = True
self._emit('ALARM:3') # reset while in motion: position lost self._emit('ALARM:3') # reset while in motion: position lost
@@ -124,7 +127,7 @@ class FakeGrbl:
f'{self.wco[0]:.3f},{self.wco[1]:.3f},0.000{pn}>') f'{self.wco[0]:.3f},{self.wco[1]:.3f},0.000{pn}>')
def _take_line(self): def _take_line(self):
if b'\n' not in self.rx or len(self.planner) >= self.PLANNER: if self.sleeping or b'\n' not in self.rx or len(self.planner) >= self.PLANNER:
return return
raw, _, rest = bytes(self.rx).partition(b'\n') raw, _, rest = bytes(self.rx).partition(b'\n')
self.rx = bytearray(rest) self.rx = bytearray(rest)
@@ -146,6 +149,10 @@ class FakeGrbl:
if ax in w: if ax in w:
tgt[i] = (self.mpos[i] + float(w[ax])) if rel else float(w[ax]) + self.wco[i] tgt[i] = (self.mpos[i] + float(w[ax])) if rel else float(w[ax]) + self.wco[i]
self.planner.append(['jog', tgt, float(w.get('F', 600))]) self.planner.append(['jog', tgt, float(w.get('F', 600))])
elif line == '$SLP':
self.sleeping = True
self.planner.clear()
self.state = 'Sleep'
elif line.startswith('$X'): elif line.startswith('$X'):
self.alarm = False self.alarm = False
self.state = 'Idle' self.state = 'Idle'
@@ -189,6 +196,9 @@ class FakeGrbl:
dt = real_dt * self.speedup dt = real_dt * self.speedup
self.t_last = now self.t_last = now
self._take_line() self._take_line()
if self.sleeping:
self.state = 'Sleep'
continue
if self.alarm: if self.alarm:
self.state = 'Alarm' self.state = 'Alarm'
continue continue
+2
View File
@@ -241,6 +241,8 @@ class TestCommands(unittest.TestCase):
self.assertTrue(any(l.startswith('$J=') for l in f.lines)) # homed first self.assertTrue(any(l.startswith('$J=') for l in f.lines)) # homed first
self.assertGreater(sum(1 for l in f.lines if l.startswith('G1X')), 100) self.assertGreater(sum(1 for l in f.lines if l.startswith('G1X')), 100)
self.assertLessEqual(f.max_rx, 128) self.assertLessEqual(f.max_rx, 128)
self.assertEqual(f.lines[-1], '$SLP') # motors off at the end
self.assertTrue(f.sleeping)
if __name__ == '__main__': if __name__ == '__main__':
+31 -7
View File
@@ -88,11 +88,11 @@ class TestPlayer(unittest.TestCase):
def tearDown(self): def tearDown(self):
self.tmp.cleanup() self.tmp.cleanup()
def make(self, speedup): def make(self, speedup, cfg=CFG):
self.fake, self.c = make_controller(speedup) self.fake, self.c = make_controller(speedup)
self.panel = Panel(self.c, CFG) self.panel = Panel(self.c, cfg)
self.alerts = [] self.alerts = []
self.player = Player(self.panel, CFG, self.lib, alert=lambda t, m: self.alerts.append(m)) self.player = Player(self.panel, cfg, self.lib, alert=lambda t, m: self.alerts.append(m))
self.player.speed = 40 self.player.speed = 40
def test_plays_to_the_end(self): def test_plays_to_the_end(self):
@@ -101,13 +101,36 @@ class TestPlayer(unittest.TestCase):
self.assertTrue(wait_for(lambda: not self.player.running(), 60)) self.assertTrue(wait_for(lambda: not self.player.running(), 60))
self.assertEqual(self.player.state, 'idle') self.assertEqual(self.player.state, 'idle')
self.assertEqual(self.player.message, '') self.assertEqual(self.player.message, '')
self.assertTrue(self.panel.homed)
self.assertLessEqual(self.fake.max_rx, 128) self.assertLessEqual(self.fake.max_rx, 128)
self.assertTrue(any(l.startswith('$J=') for l in self.fake.lines)) # homed first self.assertTrue(any(l.startswith('$J=') for l in self.fake.lines)) # homed first
# done: the motors are switched off, so the next design wakes them and homes again
self.assertTrue(self.fake.sleeping)
self.assertEqual(self.panel.status()['state'], 'Motors off')
self.assertFalse(self.panel.homed)
n_jogs = sum(1 for l in self.fake.lines if l.startswith('$J='))
self.player.play(['spirals/tiny.thr'])
self.assertTrue(wait_for(lambda: not self.player.running(), 60))
self.assertEqual(self.player.message, '')
self.assertGreater(sum(1 for l in self.fake.lines if l.startswith('$J=')), n_jogs) # homed again
self.assertTrue(self.fake.sleeping)
self.fake.close()
def test_stop_switches_the_motors_off(self):
self.make(20)
self.player.play(['flowers/rose.thr'])
self.assertTrue(wait_for(lambda: self.player.now and self.player.now['frac'] > 0.02, 60))
self.player.stop()
self.assertEqual(self.player.state, 'idle')
self.assertTrue(self.fake.sleeping)
self.assertFalse(self.fake.alarm) # stopped cleanly first, then slept
self.assertFalse(self.panel.homed)
self.fake.close() self.fake.close()
def test_stop_keeps_the_position(self): def test_stop_keeps_the_position(self):
self.make(20) import copy
cfg = copy.deepcopy(CFG)
cfg['motion']['motors_off_when_done'] = False # motors left on: no re-homing needed
self.make(20, cfg)
self.player.play(['flowers/rose.thr']) self.player.play(['flowers/rose.thr'])
self.assertTrue(wait_for(lambda: self.player.now and self.player.now['frac'] > 0.02, 60)) self.assertTrue(wait_for(lambda: self.player.now and self.player.now['frac'] > 0.02, 60))
self.player.stop() self.player.stop()
@@ -186,8 +209,9 @@ class TestReconnect(unittest.TestCase):
self.assertEqual(c.query().state, 'Idle') self.assertEqual(c.query().state, 'Idle')
player.play(['spirals/tiny.thr']) # homes again, then draws player.play(['spirals/tiny.thr']) # homes again, then draws
self.assertTrue(wait_for(lambda: player.now and player.now['name'] == 'tiny', 60)) self.assertTrue(wait_for(lambda: player.now and player.now['name'] == 'tiny', 60))
player.stop()
self.assertTrue(panel.homed) self.assertTrue(panel.homed)
player.stop()
self.assertTrue(new.sleeping) # motors off after the stop
new.close() new.close()
fake.close() fake.close()
@@ -235,7 +259,7 @@ class TestHttp(unittest.TestCase):
self.assertFalse(self.post('jog', {'axis': 'Y', 'd': 1})['ok']) # refused while playing self.assertFalse(self.post('jog', {'axis': 'Y', 'd': 1})['ok']) # refused while playing
self.assertTrue(wait_for(lambda: json.loads(self.get('/api/status'))['player']['state'] == 'idle', 60)) self.assertTrue(wait_for(lambda: json.loads(self.get('/api/status'))['player']['state'] == 'idle', 60))
st = json.loads(self.get('/api/status')) st = json.loads(self.get('/api/status'))
self.assertTrue(st['machine']['homed']) self.assertEqual(st['machine']['state'], 'Motors off')
self.assertEqual(st['player']['message'], '') self.assertEqual(st['player']['message'], '')
self.assertFalse(self.post('play', {'ids': ['../../etc/passwd']})['ok']) self.assertFalse(self.post('play', {'ids': ['../../etc/passwd']})['ok'])
up = self.post('upload?name=new.thr', raw=b'0 0\n6.28 1\n') up = self.post('upload?name=new.thr', raw=b'0 0\n6.28 1\n')