-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathplatereader.py
More file actions
279 lines (244 loc) · 8.61 KB
/
Copy pathplatereader.py
File metadata and controls
279 lines (244 loc) · 8.61 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
#!/usr/bin/env python3
"""Simple GRBL pen-plotter controller over serial.
Drives a GRBL-based controller (e.g. EleksMaker Mana SE) by streaming G-code
over USB serial and waiting for GRBL's "ok"/"error" responses.
Usage:
python3 platereader.py -p /dev/ttyUSB0
python3 platereader.py --dry-run # print G-code, don't open the port
Commands (case-insensitive):
MOVE x y rapid move to absolute X,Y in mm (G0, pen-up travel)
LINE x y feed move to absolute X,Y in mm (G1 at current SPEED)
PEN UP raise pen
PEN DOWN lower pen
ZERO set current position as work origin (G92 X0 Y0)
HOME go to work origin 0,0
SPEED v set feed rate in mm/min for LINE moves
STATUS query GRBL real-time status (?)
SETTINGS dump GRBL settings ($$)
RESET soft-reset GRBL (Ctrl-X)
G <gcode> send a raw G-code / GRBL line
EXIT / QUIT
"""
import sys
import os
import glob
import time
import argparse
# Defaults --------------------------------------------------------------------
GRBL_PORT = '/dev/ttyUSB0'
GRBL_BAUD = 115200
DEFAULT_FEED = 1000 # mm/min for LINE (pen-down) moves
# Pen is a servo wired to the spindle PWM output; M3 S<val> sets the PWM duty
# (servo angle). Verified on this machine 2026-06-07: S0 = pen UP (raised clear
# of paper), S1000 = pen DOWN (on paper). We hold the up position with M3 S0
# rather than M5 so the servo doesn't droop.
PEN_UP_CMD = 'M3 S0'
PEN_DOWN_CMD = 'M3 S1000'
PEN_DWELL_S = 0.3 # give the servo time to physically move
def add_venv_site_packages():
"""Make pyserial importable when run with the system Python."""
base = os.path.normpath(os.path.join(os.path.dirname(__file__), '.venv', 'lib'))
paths = [os.path.join(base, f'python{sys.version_info.major}.{sys.version_info.minor}', 'site-packages')]
paths.extend(glob.glob(os.path.join(base, 'python*', 'site-packages')))
for path in paths:
if os.path.isdir(path) and path not in sys.path:
sys.path.insert(0, path)
return True
return False
def import_serial():
try:
import serial
return serial
except Exception:
pass
if add_venv_site_packages():
try:
import serial
return serial
except Exception:
pass
return None
serial = import_serial()
class GrblController:
def __init__(self, port=GRBL_PORT, baud=GRBL_BAUD, feed=DEFAULT_FEED,
pen_up_cmd=PEN_UP_CMD, pen_down_cmd=PEN_DOWN_CMD,
pen_dwell=PEN_DWELL_S, dry_run=False):
self.port = port
self.baud = baud
self.feed = feed
self.pen_up_cmd = pen_up_cmd
self.pen_down_cmd = pen_down_cmd
self.pen_dwell = pen_dwell
self.dry_run = dry_run
self.ser = None
# -- connection -----------------------------------------------------------
def connect(self):
if self.dry_run:
print('[grbl] dry-run: not opening serial port', file=sys.stderr)
return
if serial is None:
raise RuntimeError('pyserial not available; run inside .venv or pip install pyserial')
print(f'[grbl] opening {self.port} @ {self.baud}', file=sys.stderr)
self.ser = serial.Serial(self.port, self.baud, timeout=2)
# Opening the port pulses DTR, which resets the GRBL board. Wait for it
# to boot, then wake it and discard the startup banner.
time.sleep(2)
self.ser.reset_input_buffer()
self.ser.write(b'\r\n\r\n')
time.sleep(1)
self.ser.reset_input_buffer()
# Sane defaults: absolute positioning, millimeters.
self.send('G90')
self.send('G21')
def disconnect(self):
if self.ser is not None:
try:
self.wait_for_idle() # let any buffered motion finish
self.pen_up()
self.wait_for_idle()
finally:
self.ser.close()
self.ser = None
def wait_for_idle(self, poll=0.2, timeout=300.0):
"""Block until GRBL reports it has drained its motion buffer (Idle).
GRBL acks each line as it is buffered, not when motion completes, so the
planner can still be running after the last 'ok'. Poll the realtime '?'
status until the machine state is no longer 'Run'/'Hold'.
"""
if self.dry_run or self.ser is None:
return None
deadline = time.time() + timeout
while time.time() < deadline:
self.ser.write(b'?')
time.sleep(poll)
out = self.ser.read(self.ser.in_waiting or 1).decode(errors='ignore')
if '<' in out and '>' in out:
# e.g. "<Idle,MPos:...>" (0.9) or "<Run|MPos:...>" (1.1)
state = out[out.index('<') + 1:].replace('|', ',').split(',', 1)[0]
if state not in ('Run', 'Hold', 'Jog'):
return state
return None
# -- low-level send -------------------------------------------------------
def send(self, cmd, reply_timeout=60.0):
"""Send one G-code line and block until GRBL's 'ok'/'error' reply.
GRBL only acknowledges a line once it has been accepted into the planner;
for dwells (G4) and slow moves that can take longer than the serial read
timeout, so we keep reading past empty (timed-out) reads until we see the
terminating 'ok'/'error' or hit reply_timeout. Bailing early would let the
next line be sent against the wrong response and desync the stream.
"""
cmd = cmd.strip()
if not cmd:
return []
if self.dry_run or self.ser is None:
print(f'[grbl] >> {cmd} (dry-run)')
return ['ok']
self.ser.write((cmd + '\n').encode())
responses = []
deadline = time.time() + reply_timeout
while time.time() < deadline:
resp = self.ser.readline().decode(errors='ignore').strip()
if resp == '': # read timeout; GRBL still busy, keep waiting
continue
responses.append(resp)
if resp == 'ok' or resp.startswith('error'):
break
else:
print(f'[grbl] >> {cmd} << TIMED OUT waiting for ok', file=sys.stderr)
print(f'[grbl] >> {cmd} <<', ' | '.join(responses) or '(no reply)')
return responses
def send_realtime(self, byte):
"""Send a single realtime byte (e.g. '?' status, 0x18 soft reset)."""
if self.dry_run or self.ser is None:
print(f'[grbl] realtime {byte!r} (dry-run)')
return
self.ser.write(byte)
time.sleep(0.1)
out = self.ser.read(self.ser.in_waiting or 1).decode(errors='ignore').strip()
if out:
print('[grbl]', out)
# -- high-level motion ----------------------------------------------------
def move(self, x, y):
self.send(f'G0 X{x:.3f} Y{y:.3f}')
def line(self, x, y):
self.send(f'G1 X{x:.3f} Y{y:.3f} F{self.feed:.0f}')
def pen_up(self):
self.send(self.pen_up_cmd)
if self.pen_dwell:
self.send(f'G4 P{self.pen_dwell}')
def pen_down(self):
self.send(self.pen_down_cmd)
if self.pen_dwell:
self.send(f'G4 P{self.pen_dwell}')
def zero(self):
self.send('G92 X0 Y0')
def home(self):
self.send('G90')
self.move(0, 0)
def set_speed(self, v):
self.feed = float(v)
print(f'[grbl] feed rate set to {self.feed:.0f} mm/min')
def status(self):
self.send_realtime(b'?')
def settings(self):
self.send('$$')
def soft_reset(self):
self.send_realtime(b'\x18')
def parse_args():
p = argparse.ArgumentParser(description='GRBL pen-plotter controller')
p.add_argument('-p', '--port', default=GRBL_PORT, help='Serial port')
p.add_argument('--baud', type=int, default=GRBL_BAUD, help='Baud rate')
p.add_argument('--feed', type=float, default=DEFAULT_FEED, help='Feed rate mm/min for LINE moves')
p.add_argument('--pen-up-cmd', default=PEN_UP_CMD, help='G-code to raise the pen')
p.add_argument('--pen-down-cmd', default=PEN_DOWN_CMD, help='G-code to lower the pen')
p.add_argument('--pen-dwell', type=float, default=PEN_DWELL_S, help='Seconds to wait after a pen move')
p.add_argument('--dry-run', action='store_true', help='Print G-code instead of sending it')
return p.parse_args()
def repl(ctl):
ctl.connect()
try:
while True:
line = input('> ').strip()
if not line:
continue
parts = line.split()
cmd = parts[0].upper()
if cmd == 'MOVE' and len(parts) >= 3:
ctl.move(float(parts[1]), float(parts[2]))
elif cmd == 'LINE' and len(parts) >= 3:
ctl.line(float(parts[1]), float(parts[2]))
elif cmd == 'PEN' and len(parts) >= 2:
if parts[1].upper() == 'DOWN':
ctl.pen_down()
else:
ctl.pen_up()
elif cmd == 'ZERO':
ctl.zero()
elif cmd == 'HOME':
ctl.home()
elif cmd == 'SPEED' and len(parts) >= 2:
ctl.set_speed(parts[1])
elif cmd == 'STATUS':
ctl.status()
elif cmd == 'SETTINGS':
ctl.settings()
elif cmd == 'RESET':
ctl.soft_reset()
elif cmd == 'G':
ctl.send(line[1:].strip())
elif cmd in ('EXIT', 'QUIT'):
break
else:
print('Unknown command')
except (KeyboardInterrupt, EOFError):
pass
finally:
ctl.disconnect()
if __name__ == '__main__':
args = parse_args()
ctl = GrblController(
port=args.port, baud=args.baud, feed=args.feed,
pen_up_cmd=args.pen_up_cmd, pen_down_cmd=args.pen_down_cmd,
pen_dwell=args.pen_dwell, dry_run=args.dry_run,
)
repl(ctl)