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

+11 -1
View File
@@ -34,6 +34,7 @@ class FakeGrbl:
self.hold = False
self.alarm = False
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.settings = {'$3': '0', '$100': '250.000', '$101': '250.000', '$110': '500', '$111': '500'}
self.max_rx = 0
@@ -80,6 +81,8 @@ class FakeGrbl:
self.hold = False
elif b == 0x18:
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:
self.alarm = True
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}>')
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
raw, _, rest = bytes(self.rx).partition(b'\n')
self.rx = bytearray(rest)
@@ -146,6 +149,10 @@ class FakeGrbl:
if ax in w:
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))])
elif line == '$SLP':
self.sleeping = True
self.planner.clear()
self.state = 'Sleep'
elif line.startswith('$X'):
self.alarm = False
self.state = 'Idle'
@@ -189,6 +196,9 @@ class FakeGrbl:
dt = real_dt * self.speedup
self.t_last = now
self._take_line()
if self.sleeping:
self.state = 'Sleep'
continue
if self.alarm:
self.state = 'Alarm'
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.assertGreater(sum(1 for l in f.lines if l.startswith('G1X')), 100)
self.assertLessEqual(f.max_rx, 128)
self.assertEqual(f.lines[-1], '$SLP') # motors off at the end
self.assertTrue(f.sleeping)
if __name__ == '__main__':
+31 -7
View File
@@ -88,11 +88,11 @@ class TestPlayer(unittest.TestCase):
def tearDown(self):
self.tmp.cleanup()
def make(self, speedup):
def make(self, speedup, cfg=CFG):
self.fake, self.c = make_controller(speedup)
self.panel = Panel(self.c, CFG)
self.panel = Panel(self.c, cfg)
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
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.assertEqual(self.player.state, 'idle')
self.assertEqual(self.player.message, '')
self.assertTrue(self.panel.homed)
self.assertLessEqual(self.fake.max_rx, 128)
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()
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.assertTrue(wait_for(lambda: self.player.now and self.player.now['frac'] > 0.02, 60))
self.player.stop()
@@ -186,8 +209,9 @@ class TestReconnect(unittest.TestCase):
self.assertEqual(c.query().state, 'Idle')
player.play(['spirals/tiny.thr']) # homes again, then draws
self.assertTrue(wait_for(lambda: player.now and player.now['name'] == 'tiny', 60))
player.stop()
self.assertTrue(panel.homed)
player.stop()
self.assertTrue(new.sleeping) # motors off after the stop
new.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.assertTrue(wait_for(lambda: json.loads(self.get('/api/status'))['player']['state'] == 'idle', 60))
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.assertFalse(self.post('play', {'ids': ['../../etc/passwd']})['ok'])
up = self.post('upload?name=new.thr', raw=b'0 0\n6.28 1\n')