diff --git a/scripts/grbl-stream b/scripts/grbl-stream index b761ced..3d61a2c 100755 --- a/scripts/grbl-stream +++ b/scripts/grbl-stream @@ -3,22 +3,30 @@ import curses import time import six +import copy +import argparse +import re +import threading +import serial +for pygcode_lib_type in ('installed_lib', 'relative_lib'): + try: + # pygcode + from pygcode import NullMachine + from pygcode import GCodeRapidMove + from pygcode import GCodeIncrementalDistanceMode + from pygcode import text2gcodes -# on-screen elements -# Status -# - status (Idle, and so on) -# - position (as read from status) -# - spindle (running cw/ccw, off) -# ref: https://github.com/gnea/grbl/wiki/Grbl-v1.1-Interface -# Joggging -# - numpad style layout (arrows, pgup/dn) -# - radio selection of jog rates (<,>) -# - -# + except ImportError: + import sys, os, inspect + # Add pygcode (relative to this test-path) to the system path + _this_path = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) + sys.path.insert(0, os.path.join(_this_path, '..', 'src')) + if pygcode_lib_type == 'installed_lib': + continue # import was attempted before sys.path addition. retry import + raise # otherwise the raised ImportError is a genuine problem + break -# Layout (80 x 24) -################################################################################ class Widget(object): pass @@ -102,11 +110,17 @@ class Banner(Widget): self.row = row self.render() - def render(self): + def render(self, strong=False): (y, x) = self.window.getmaxyx() label_str = " {} ".format(self._label) - self.window.hline(0, 0, curses.ACS_HLINE, x) - self.window.addstr(self.row, self.label_col, " {} ".format(self._label)) + hline_params = [0, 0, curses.ACS_HLINE, x] + addstr_params = [self.row, self.label_col, " {} ".format(self._label)] + if strong: + hline_params.append(curses.A_BOLD) + addstr_params.append(curses.A_BOLD) + + self.window.hline(*hline_params) + self.window.addstr(*addstr_params) @property def label(self): @@ -120,19 +134,22 @@ class Banner(Widget): class Status(object): row = 0 + banner_prefix = 'Status: ' def __init__(self, screen): self.screen = screen (max_y, max_x) = self.screen.getmaxyx() self.window = curses.newwin(5, max_x, self.row, 0) - self.banner = Banner(self.window, 'Status: Idle') + self.status = '' + self.banner = Banner(self.window, self.banner_prefix) + # Print backdrop (to use as template) backdrop = [ 'Jog: [xxxx.yyy ] MPos WPos', - ' [Y+] [Z+] X |xxxx.yyy |xxxx.yyy | Spindle: On (1000rpm)', - '[X-] [X+] Y |xxxx.yyy |xxxx.yyy | Coolant: Off', - ' [Y-] [Z-] Z |xxxx.yyy |xxxx.yyy | Feed Rate: 100', + ' [Y+] [Z+] X |xxxx.yyy |xxxx.yyy | Feed Rate: ?', + '[X-] [X+] Y |xxxx.yyy |xxxx.yyy | Spindle: ?', + ' [Y-] [Z-] Z |xxxx.yyy |xxxx.yyy | ', ] for (row, line) in enumerate(backdrop): self.window.addstr(row + 1, 0, line[:max_x]) @@ -152,17 +169,24 @@ class Status(object): 'WPosX': NumberLabel(self.window, 2, 31), 'WPosY': NumberLabel(self.window, 3, 31), 'WPosZ': NumberLabel(self.window, 4, 31), - 'spindle': Label(self.window, 2, 44, len=20, prefix='Spindle: '), - 'coolant': Label(self.window, 3, 44, len=20, prefix='Coolant: '), - 'feed_rate': Label(self.window, 4, 44, len=20, prefix='Feed Rate: '), + 'feed_rate': Label(self.window, 2, 44, len=20, text='?', prefix='Feed Rate: '), + 'spindle': Label(self.window, 3, 44, len=20, text='?', prefix='Spindle: '), } self.refresh() - def set_status(self, label): - self.banner.label = "Status: {}".format(label) + def set_status(self, status): + self.status = status + self.banner.label = "{prefix}{label}".format( + prefix=self.banner_prefix, + label=status, + ) self.refresh() + @property + def is_idle(self): + return self.status == 'Idle' + def refresh(self): self.window.refresh() @@ -199,6 +223,7 @@ class ConsoleLine(object): line += self.content.to_str(max(0, self._width - len(line))) else: line += str(self.content) + line = ("{:%i.%is}" % (self._width-1,self._width-1)).format(line) self.window.addstr(row, 0, line) def update_width(self): @@ -213,7 +238,6 @@ class ConsoleLine(object): self._cur = value - class AccordionWindow(object): """ A region of the screen that can push those above and below it around. @@ -232,21 +256,31 @@ class AccordionWindow(object): self.window = None self.banner = None + self.add_line_callback = None def init_window(self, row, height): self.window = curses.newwin(height, self.screen.getmaxyx()[1], row, 0) self.banner = Banner(self.window, self.title) - def add_line(self, line): + def _add_line(self, line): + i = len(self.lines) self.lines.append(line) + if self.add_line_callback: + self.add_line_callback(self) def move_window(self, row, height): self.window.resize(height, self.screen.getmaxyx()[1]) self.window.mvwin(row, 0) - self.window.clear() #self.window.box() #self.window.addstr(0, 5, self.title) - self.banner.render() + + def render(self, active=False): + #self.window.clear() # FIXME: start fresh every render?, inefficient + self.banner.render(strong=active) + (rows, cols) = self.window.getmaxyx() + if rows > 1: # we have room to render lines + for (i, line) in enumerate(self.lines[-(rows - 1):]): + line.render(i + 1) self.refresh() def __eq__(self, other): @@ -269,11 +303,16 @@ class AccordionWindowManager(object): self._log = [] # Initialize accordion window's curses.Window instance + def _add_line_cb(window): + self.update_distrobution() + window.render() + cur_row = self.header_height for (i, window) in enumerate(self.windows): window.init_window(cur_row, window.soft_height) window.refresh() - cur_row += window.soft_height + window.add_line_callback = _add_line_cb + cur_row += window.min_height @property def focus(self): @@ -309,49 +348,39 @@ class AccordionWindowManager(object): available -= height - w.min_height w.move_window(cur_row, height) + w.render(active=True if w == self.focus else False) cur_row += height + self.refresh() - #rows_available = self.screen.getmaxyx()[0] - (self.header_height + self.footer_height) - # - #focus_rows_available = rows_available - sum(w.min_height for (i, w) in enumerate(self.windows) if i != self._focus_index) - #focus_rows = min(focus_rows_available, max(self.focus.min_height, len(self.focus.lines) + 1)) - #non_focus_rows_available = rows_available - focus_rows - #cur_row = self.header_height - #heights = [] - #for (i, window) in enumerate(self.windows): - # if i == self._focus_index: - # height = focus_rows - # else: - # height = min(non_focus_rows_available, max(window.min_height, len(window.lines) + 1)) - # non_focus_rows_available -= height - # - # heights.append(height) - # try: - # window.move_window(cur_row, max(1, height)) - # except Exception as e: - # raise RuntimeError({ - # 'rows_available': rows_available, - # 'focus_rows_available': focus_rows_available, - # 'non_focus_rows_available': non_focus_rows_available, - # 'cur_row': cur_row, - # 'height': height, - # 'window': window, - # 'focus': self.focus, - # }) - # cur_row += height - #self.screen.addstr(0, 0, "heights = %s" % str(heights)) + def refresh(self): + for w in self.windows: + w.refresh() class InitWindow(AccordionWindow): - pass + def add_line(self, text): + obj = ConsoleLine(self.window, text) + self._add_line(obj) + return obj + class JoggingWindow(AccordionWindow): - pass + def add_line(self, gcode, sent=False, status=''): + obj = ConsoleLine(self.window, GCodeContent( + gcode=gcode, sent=sent, status=status + )) + self._add_line(obj) + return obj class StreamWindow(AccordionWindow): - pass + def add_line(self, gcode, sent=False, status=''): + obj = ConsoleLine(self.window, GCodeContent( + gcode=gcode, sent=sent, status=status + )) + self._add_line(obj) + return obj JOGGING_VALUES = [ @@ -366,137 +395,701 @@ def jog_value(cur_val, dindex): i = max(0, min(i + dindex, len(JOGGING_VALUES) - 1)) return JOGGING_VALUES[i] -def main(screen): + +# ---------------- Serial Utilities --------------------- + +class SerialPort(object): + def __init__(self, device, baudrate, logfilename=None): + self.device = device + self.baudrate = baudrate + self.serial = serial.Serial(self.device, self.baudrate) + self.logfilename = logfilename + self.log = None + + self._cur_line = '' # buffered line chars before '\n' received + + if self.logfilename: + self.log = open(self.logfilename, 'w') + + def __del__(self): + self.serial.close() + if self.log: + self.log.close() + + def _log_write(self, prefix, msg): + if self.log: + self.log.write("[{time:.2f}] {prefix} {msg}\n".format( + time=time.time(), + prefix=prefix, + msg=msg.replace('\n', r'\n').replace('\r', r'\r'), + )) + + def write(self, data): + self._log_write('>>', data) + self.serial.write(data) + + def readlines(self, timeout=None): + start_time = time.time() + orig_timeout = self.serial.timeout + + def _new_timeout(): + """Return linearly diminished timeout (with realtime)""" + if timeout is None: + return None + cur_time = time.time() + if cur_time - start_time >= timeout: + return 0.0 + return timeout - (cur_time - start_time) + + while True: + # Set read timeout + time_remaining = _new_timeout() + if time_remaining == 0: + break + self.serial.timeout = time_remaining + + # read + # FIXME: char by char (a bit clunky, but easy to write) + try: + received_chr = self.serial.read() + except serial.serialutil.SerialException: + continue # terminal resize interrupts serial read + self._cur_line += received_chr + if self._cur_line.endswith('\n'): + self._log_write('<<', self._cur_line) + received_line = re.sub(r'\r?\n$', '', self._cur_line) + self._cur_line = '' + yield received_line + + self.serial.timeout = orig_timeout + + +class GCodeStreamException(Exception): + """Raised when GRBL responds with error""" + pass + + +class GCodeStreamer(object): + # - keep track of GRBL buffer size + # - transmit to GRBL + # - read GRLB responses and delegate accordingly + + class Line(object): + # - handle status & screen update + # - new + # - sent + # - received (status string) (raise exception if bad + # - send + # - publish on screen + # - set status + # - publish status on screen + # - report bad status back to streamer + def __init__(self, gcode, widget=None): + # verify parameter(s) + if widget is not None: + assert isinstance(widget, ConsoleLine), "bad widget type: %r" % widget + assert isinstance(widget.content, GCodeContent), "bad widget content: %r" % widget.content + + # initialize + self.gcode = gcode + self.widget = widget + + + def set_sent(self, value=True): + if self.widget: + self.widget.content.sent = value + + def set_status(self, msg): + if self.widget: + self.widget.content.status = msg + + def _normalized(self): + return re.sub(r'\(.*?\)|;.*|\s', '', self.gcode).upper() + + def __str__(self): + """ + :return: str to be sent over serial (including newline) + """ + return self._normalized() + "\n" + + def __len__(self): + return len(str(self)) + + def __bool__(self): + if self._normalized(): + return True + return False + + __nonzero__ = __bool__ # python 2.x compatability + + + DEFAULT_MAX_BUFFER = 128 + RESPONSE_REGEX = re.compile(r'^(?P(ok|error))', re.I) + + def __init__(self, serial, max_buffer=None): + assert isinstance(serial, SerialPort), "bad serial type: %r" % serial + self.serial = serial + self.max_buffer = max_buffer if max_buffer is not None else self.DEFAULT_MAX_BUFFER + + # --- Lines + # Description: + # a moving window buffer of GCodeStreamer.Line instances sent to GRBL. + # this moving window stretches between: + # [0] oldest sent gcode that has not yet received a GRBL response. + # once status is received, [0] is removed: self.sent_lines.pop(0) + # [-1] most recent gcode sent to GRBL device. + self.sent_lines = [] + self.pending_lines = [] + + def is_valid_response(self, response_msg): # TODO: delete if not used + """returns truthy: regex match if valid, None otherwise""" + return self.RESPONSE_REGEX.search(response_msg) + + def process_response(self, response): + """ + A response from GRBL is assumed to be to gcode at self.sent_lines[0] + Once processed, the first of self.sent_lines is removed + :param response: str received from GRBL device + """ + match = self.RESPONSE_REGEX.search(response) + if match: + # Pop oldest line + line = self.sent_lines.pop(0) + line.set_status(response) + if match.group('keyword').lower() == 'error': + raise GCodeStreamException("error on gcode: '{gcode}' {msg}".format( + gcode=line.gcode, + msg=response + )) + + # Send next line (if possible) + self.poll_transmission() + else: + raise GCodeStreamException("unidentified message: %s" % response) + + def can_send(self, line): + """ + Can the given line be transmitted? + :return: True if line will not push GRBL's buffer over it's limit + """ + assert isinstance(line, GCodeStreamer.Line) + return (self.used_buffer + len(line)) <= self.max_buffer + + def send(self, line): + """Add to pending lines, then poll transmission (once)""" + assert isinstance(line, GCodeStreamer.Line) + self.pending_lines.append(line) + self.poll_transmission() + + def _transmit(self, line): + assert isinstance(line, GCodeStreamer.Line) + self.serial.write(str(line)) # Send to GRBL device + + def poll_transmission(self): + """ + Send next line if there's enough room in GRBL's buffer. + :return: True if there's data to transmit, False if it's all been sent + """ + if not self.pending_lines: + return False + elif self.can_send(self.pending_lines[0]): + line = self.pending_lines.pop(0) + self._transmit(line) + self.sent_lines.append(line) # Add to line buffer + line.set_sent() + return True + + @property + def finished(self): + if self.sent_lines or self.pending_lines: + return False + return True + + @property + def used_buffer(self): + return sum(len(l) for l in self.sent_lines) + + @property + def pending_count(self): + return len(self.pending_lines) + + +# ---------------- Arduino Util --------------------- +def arduino_comports(): + """ + List of serial comports serial numbers of Arduino boards connected to this machine + :return: generator of comports connected to arduino + """ + #for comport in arduino_comports(): + # comport.serial_number # '55639303235351C071B0' + # comport.device # '/def/ttyACM0' + from serial.tools.list_ports_posix import comports + + arduino_manufacturer_regex = re.compile(r'arduino', re.IGNORECASE) # simple, because I've only got one to test on + for comport in comports(): + if comport.manufacturer: + match = arduino_manufacturer_regex.search(comport.manufacturer) + if match: + yield comport + + +def device_type(value): + # argparse type + + def _safe_comport_wrapper(key): + # define comports list + try: + comports = [cp for cp in arduino_comports() if key(cp)] + except Exception: + print("ERROR: could not utilise serial library to find connected Arduino " + "(for given device: %s) just try the serial device (eg: /dev/ttyACM0)" % value) + raise + if len(comports) == 1: + # exchange serial number for serial device Arduino is connected to + return comports[0] + else: + raise argparse.ArgumentTypeError("could not find Arduino from '%s', just try the serial device (eg: /dev/ttyACM0)" % value) + + if value is None: + comport = _safe_comport_wrapper(lambda cp: True) # all + value = comport.device + elif re.search(r'^[0-9a-f]{15,25}$', value, re.IGNORECASE): + comport = _safe_comport_wrapper(lambda cp: cp.serial_number.upper() == value.upper()) + value = comport.device + + return value + + +# ---------------- Parse Arguments ------------------- +# Defaults +DEFAULT_BAUDRATE = 115200 +DEFAULT_BUFFERSIZE = GCodeStreamer.DEFAULT_MAX_BUFFER +DEFAULT_POLL_INTERVAL = 0.2 # seconds +DEFAULT_PENDING_COUNT = 5 +DEFAULT_END_SLEEP = 1 + +parser = argparse.ArgumentParser( + description="GRBL gcode streamer for CNC machine. Assist jogging to " + "position, then stream gcode via serial.", +) + +parser.add_argument( + 'infile', help="gcode file to stream", +) + +parser.add_argument( + '--quiet', '-q', + action='store_const', const=True, default=False, + help="if quiet, help messages won't be printed", +) + +# Serial Connection +group = parser.add_argument_group("Serial Connectivity") +group.add_argument( + '-d', '--device', type=device_type, default=None, + help="serial device GRBL is connected to, can use direct device name " + "(eg: /dev/ttyACM0) or the Arduino's serial number (eg: 55639303235351C071B0)" +) +group.add_argument( + '-b', '--baudrate', type=int, default=DEFAULT_BAUDRATE, + help="serial baud rate (default: %i)" % DEFAULT_BAUDRATE +) + +# Debugging +group = parser.add_argument_group("Debug Parameters") +group.add_argument( + '--disable-status', dest='status_poll', + action='store_const', const=False, default=True, + help="disables '?' being sent every '--poll-interval'", +) +group.add_argument( + '--poll-interval', dest='poll_interval', type=float, default=DEFAULT_POLL_INTERVAL, + help="frequency of sending '?' for status report when status is enabled", +) +group.add_argument( + '--buffer-size', dest='buffer_size', type=int, default=DEFAULT_BUFFERSIZE, + help="GRBL internal serial buffer size", +) +group.add_argument( + '--serial-log', dest='serial_log', default=None, metavar="LOG_FILE", + help="if given, data read from, and written to serial port is logged here " + "(note: \\r and \\n characters are escaped for debugging purposes)", +) +group.add_argument( + '--pending-count', dest='pending_count', default=DEFAULT_PENDING_COUNT, + help="number of gcode lines to display ahead of them being sent", +) +group.add_argument( + '--no-close', dest='no_close', + action='store_const', const=True, default=False, + help="if set, window won't close when job is done", +) + +args = parser.parse_args() +if args.device is None: + args.device = device_type(args.device) # FIXME: there has to be a way to do this native to argparse + +# ----------------- Curses Shi.. stuff ----------------- +def curses_wrap(screen): + + # ----------------- Initialise widgets 'n' stuff ----------------- # Clear screen screen.clear() screen.refresh() + def keypress(): + k = None + try: + k = screen.getkey() + except curses.error as e: + pass # raised if no key event is buffered + # (because that's elegant... hmm) + return k + status = Status(screen=screen) - init = InitWindow(screen=screen, title='GRBL Init: /dev/ttyACM0', min_height=2) - jogging = JoggingWindow(screen=screen, title='Jogging', min_height=2) - stream = StreamWindow(screen=screen, title='Stream: some_file.ngc', min_height=6) - - #init.lines = list(range(100)) - #jogging.lines = list(range(100)) - #stream.lines = list(range(100)) - + init = InitWindow( + screen=screen, + title='Initialize Device: {}'.format(args.device), + min_height=1, + ) + jogging = JoggingWindow( + screen=screen, + title='Jogging', + min_height=1, + ) + stream = StreamWindow( + screen=screen, + title='Stream: {}'.format(args.infile), + min_height=min(6, args.pending_count + 1), + ) accordion = AccordionWindowManager( screen=screen, windows=[init, jogging, stream], header_height=status.window.getmaxyx()[0], - footer_height=0, # FIXME ) - - # ----------- Initialize GRBL + # dump first few lines into stream window + with open(args.infile, 'r') as fh: + for i in range(args.pending_count): + line = fh.readline().rstrip() + stream.add_line(line) + stream.add_line('... [truncated]') + # ----------------- Virtual Machine ----------------- + machine = NullMachine() - # ----------- Jogging, user input - while True: - k = screen.getkey() + # Detect & Set Machine's Mode from GRBL text + machine_mode_regex = re.compile(r'^\s*\[GC:\s*(?P[^\]]+)\]\s*$', re.I) + def machine_set_mode(mode_match): + machine.set_mode(*text2gcodes(mode_match.group('gcode'))) - if k in ('q', 'Q'): - break + # Detect & Set Machine's State from GRBL text + machine_state_regex = re.compile(r'^\s*<(?P[^\>\<]*)>\s*$') + def machine_set_state(state_match): + # + feed_rate = None # float + spindle = None # float + abs_pos = None # Position + work_pos = None # Position + work_offset = None # Position - # Jogging Keys - elif k == "KEY_RIGHT": - status.widgets['MPosX'].value += status.widgets['jog'].value # FIXME - status.widgets['X+'].flash() - elif k == "KEY_LEFT": - status.widgets['MPosX'].value -= status.widgets['jog'].value # FIXME - status.widgets['X-'].flash() - elif k == "KEY_UP": - status.widgets['MPosY'].value += status.widgets['jog'].value # FIXME - status.widgets['Y+'].flash() - elif k == "KEY_DOWN": - status.widgets['MPosY'].value -= status.widgets['jog'].value # FIXME - status.widgets['Y-'].flash() - elif k == "KEY_PPAGE": - status.widgets['MPosZ'].value += status.widgets['jog'].value # FIXME - status.widgets['Z+'].flash() - elif k == "KEY_NPAGE": - status.widgets['MPosZ'].value -= status.widgets['jog'].value # FIXME - status.widgets['Z-'].flash() + coords = lambda s: [float(x) for x in s[s.find(':')+1:].split(',')] + pos = lambda s: machine.Position(**dict(zip('XYZ', coords(s)))) + for (i, state_str) in enumerate(state_match.group('state').split('|')): + if i == 0: + status.set_status(state_str) + elif state_str.startswith('MPos:'): # machine.abs_pos + abs_pos = pos(state_str) + elif state_str.startswith('WPos:'): # machine.pos + work_pos = pos(state_str) + elif state_str.startswith('F:'): + (feed_rate,) = coords(state_str) + elif state_str.startswith('FS:'): + (feed_rate, spindle) = coords(state_str) + elif state_str.startswith('WCO:'): # machine.state.coord_sys.offset + work_offset = pos(state_str) - # Jogging Increment (up/down) - elif k in '[],.': - status.widgets['jog'].value = jog_value( - status.widgets['jog'].value, - {'[': 1, ',': 1, ']': -1, '.': -1}[k] - ) + # Update Machine Axes + if work_offset is not None: + machine.state.coord_sys.offset = work_offset # done first + if abs_pos is not None: + machine.abs_pos = abs_pos + if work_pos is not None: + machine.pos = work_pos - # Testing - elif k == 'a': - init.add_line('abc') - accordion.update_distrobution() - elif k == 'A': - accordion.focus = init + # Update Status Display + status.widgets['MPosX'].value = machine.abs_pos.X + status.widgets['MPosY'].value = machine.abs_pos.Y + status.widgets['MPosZ'].value = machine.abs_pos.Z + status.widgets['WPosX'].value = machine.pos.X + status.widgets['WPosY'].value = machine.pos.Y + status.widgets['WPosZ'].value = machine.pos.Z + if feed_rate is not None: + status.widgets['feed_rate'].text = "%g" % feed_rate + if spindle is not None: + spindle_str = ('%g (rpm)' % spindle) if spindle else 'off' + status.widgets['spindle'].text = spindle_str - elif k == 's': - jogging.add_line('abc') - accordion.update_distrobution() - elif k == 'S': - accordion.focus = jogging - - elif k == 'd': - stream.add_line('abc') - accordion.update_distrobution() - elif k == 'D': - accordion.focus = stream - - - elif k == ' ': - # Zero WPos coordinates - status.set_status('Zero Work Offsets...') # FIXME - - elif k == '\n': - # Jogging complete, begin stream - status.set_status('Begin Stream...') # FIXME - - #elif k == 'KEY_RESIZE': - # # dummy keypress on console window resize - # pass # FIXME: re-render everything (?) - - else: - status.set_status(k) # FIXME: remove status.refresh() -curses.wrapper(main) + # ----------------- Initialize Serial ----------------- + + # Initialize & Wake up grbl + serialport = SerialPort(args.device, args.baudrate, args.serial_log) + serialport.write("\r\n\r\n") + + # Wait for grbl to initialize and flush startup text in serial input + accordion.focus = init + init_complete = False + for line in serialport.readlines(timeout=5): + line = line.strip() + if line and (line != 'ok'): + init.add_line(line) + + # Process line + mode_match = machine_mode_regex.search(line) + state_match = machine_state_regex.search(line) + if re.search(r'^GRBL', line, re.I): + # Request: Mode - Request Machine's Mode + serialport.write('$G\n') + elif mode_match: + # Received: Mode - Machine's Mode received, pass to virtual machine + machine_set_mode(mode_match) + # Request: State + serialport.write('?') + elif state_match: + # Received: State + machine_set_state(state_match) + init_complete = True + if init_complete: + break + + if init_complete: + serialport.serial.flushInput() + else: + raise RuntimeError("Could not initialize GRBL serial interface") + + # Machine state initialized, remember it. + # machine's mode may be altered while jogging. + # machine's mode is duplicated so it may be reverted after jogging + init_machine = copy.copy(machine) + + # Start period polling of status + poll_daemon_keepalive = True + poll_daemon_thread = None + if args.poll_interval: + def _poll_daemon_runtime(): + while poll_daemon_keepalive: + serialport.write('?') + time.sleep(args.poll_interval) + poll_daemon_thread = threading.Thread(target=_poll_daemon_runtime) + poll_daemon_thread.daemon = True + poll_daemon_thread.start() -""" Example Output... ----- Status: Idle -------------------------------------------------------------- -Jog: [ xx.yyy ] MPos WPos - [Y+] [Z+] X: | xxx.yyy | xxx.yyy | Spindle: On (1000rpm) - [X-] [X+] Y: | xxx.yyy | xxx.yyy | Coolant: Off - [Y-] [Z-] Z: | xxx.yyy | xxx.yyy | Feed Rate: 100 ----- GRBL Init: /dev/ttyACM0 --------------------------------------------------- - Grbl 1.1f ['$' for help] - [GC:G0 G54 G17 G21 G90 G94 M5 M9 T0 F0 S0] ----- Jogging ------------------------------------------------------------------- - G91 > ok - G00 X-1 > ok - G00 Y20 > ok -> awaiting input ... [space] zero offset, [enter] start stream ----- Stream: some_file.ngc ----------------------------------------------------- - G17 G20 G40 G49 G54 G80 G90 G94 > - T1M06 > - F47.244 > - S1000 > - G00 Z0.197 > - M03 > - G04 P1 > - G00X1.0961Y0.0126Z0.1969 > - G01 Z0.0000 F5.9055 > - F47.2441 > - X1.0961 Y0.0126 Z0.0000 > - X0.3182 Y0.0126 Z0.0000 > - X0.1454 Y0.0126 Z0.0000 > - X0.0432 Y0.0126 Z0.0000 > - X0.0039 Y0.0126 Z0.0000 > - Z0.1969 > - G00 X1.0961 Y0.0293 Z0.1969 + # Serial Polling Process + message_regex = re.compile(r'^\s*\[(?P.*)\]\s*$') + def poll_serial(timeout, callback=None): + for line in serialport.readlines(timeout=0.05): + line = line.rstrip('\r\n').lstrip('\r\n') + state_match = machine_state_regex.search(line) + message_match = message_regex.search(line) + #jogging.add_line("%s : %s" % (line, "state" if state_match else "dunno")) + if state_match: # Received: State + machine_set_state(state_match) + elif message_match: + # TODO: how to display messages + pass + else: + streamer.process_response(line) + if callback: + callback() -""" + def _poll_callback_jogging(): + jogging.render() + jogging.refresh() + + def _poll_callback_streaming(): + jogging.render() + jogging.refresh() + stream.render() + stream.refresh() + + + # Connect GCode Streamer + streamer = GCodeStreamer(serialport, args.buffer_size) + + def send_gcode(gcode, window): + widget = window.add_line(str(gcode)) + line = GCodeStreamer.Line(str(gcode), widget) + if line: # don't send blank lines + streamer.send(line) + + # ----------------- Interactive Jogging ----------------- + if not args.quiet: + help_lines = [ + "; Jogging Keys", + "; - Arrow Keys: X/Y axes", + "; - PgUp/PgDn: Z axis", + "; - []: increase/decrease jog distance", + "; - Space: zero work offsets (all axes)", + "; - x,y,z: zero individual axis", + "; - Enter: start streaming gcode", + ] + for line in help_lines: + jogging.add_line(line) + accordion.focus = jogging + + screen.nodelay(True) # non-blocking .getkey() behaviour + + while True: + k = keypress() + + if k is None: + pass # nothing pressed; do nothing + else: + # Process key-press... + if k in ('q', 'Q'): + break + elif k == '?': + serialport.write('?') + + # Jogging Keys + elif k in ["KEY_RIGHT", "KEY_LEFT", "KEY_UP", "KEY_DOWN", "KEY_PPAGE", "KEY_NPAGE"]: + # put machine into incremental mode (if it's not already) + if not isinstance(machine.mode.distance, GCodeIncrementalDistanceMode): + dist_mode = GCodeIncrementalDistanceMode() + send_gcode(dist_mode, jogging) + machine.process_gcodes(dist_mode) # changes machine's mode + + if k == "KEY_RIGHT": + send_gcode(GCodeRapidMove(X=status.widgets['jog'].value), jogging) + status.widgets['X+'].flash() + elif k == "KEY_LEFT": + send_gcode(GCodeRapidMove(X=-status.widgets['jog'].value), jogging) + status.widgets['X-'].flash() + elif k == "KEY_UP": + send_gcode(GCodeRapidMove(Y=status.widgets['jog'].value), jogging) + status.widgets['Y+'].flash() + elif k == "KEY_DOWN": + send_gcode(GCodeRapidMove(Y=-status.widgets['jog'].value), jogging) + status.widgets['Y-'].flash() + elif k == "KEY_PPAGE": + send_gcode(GCodeRapidMove(Z=status.widgets['jog'].value), jogging) + status.widgets['Z+'].flash() + elif k == "KEY_NPAGE": + send_gcode(GCodeRapidMove(Z=-status.widgets['jog'].value), jogging) + status.widgets['Z-'].flash() + + # Jogging Increment (up/down) + elif k in '[],.': + status.widgets['jog'].value = jog_value( + status.widgets['jog'].value, + {'[': 1, ',': 1, ']': -1, '.': -1}[k] + ) + + # Testing + elif k == 'a': + init.add_line(str(time.time())) + #accordion.update_distrobution() + elif k == 'A': + accordion.focus = init + elif k == 's': + jogging.add_line(str(time.time())) + #accordion.update_distrobution() + elif k == 'S': + accordion.focus = jogging + elif k == 'd': + stream.add_line(str(time.time()), sent=True, status='ok') + #accordion.update_distrobution() + elif k == 'D': + accordion.focus = stream + + + elif k == ' ': + # Zero WPos coordinates + send_gcode(text2gcodes('G10 L20 P1 X0 Y0 Z0')[0], jogging) + + elif k == '\n': + # Jogging complete, begin stream + break + + elif k == 'KEY_RESIZE': + # dummy keypress on console window resize + accordion.update_distrobution() + + else: + status.set_status(k) # FIXME: remove + status.refresh() + + # spend 50ms processing received serial lines + # then loop back up to process another key + poll_serial(0.05, _poll_callback_jogging) + + streamer.poll_transmission() + + # Revert machine's state after jogging + mode_keys = set() + mode_keys |= set(init_machine.mode.modal_groups.keys()) + mode_keys |= set(machine.mode.modal_groups.keys()) + + for key in mode_keys: + initial = init_machine.mode.modal_groups[key] + current = machine.mode.modal_groups[key] + if initial != current: + send_gcode(initial, jogging) + + + # ----------------- File Streaming ----------------- + accordion.focus = stream + + def _check_resize(): + if keypress() == 'KEY_RESIZE': + accordion.update_distrobution() + + + with open(args.infile, 'r') as gcode_file: + gcode_file_moredata = True + while gcode_file_moredata: + # Push pending lines into streamer + while streamer.pending_count < args.pending_count: + line_data = gcode_file.readline() + if not line_data: + gcode_file_moredata = False + break # file's done + + send_gcode(line_data.strip(), stream) + + # process serial packets, then try again + poll_serial(0.05, _poll_callback_streaming) + + _check_resize() + + # streamer is still: + # - sending gcodes to GRBL + # - processing GRBL's responses in confirmation of those codes + while not streamer.finished: + # process serial packets, then try again + poll_serial(0.05, _poll_callback_streaming) + _check_resize() + + # Machine is still working, wait 'till it's Idle + while not status.is_idle: + # process serial packets, then try again + poll_serial(0.05, _poll_callback_streaming) + _check_resize() + + + # ---- Kill status polling daemon + poll_daemon_keepalive = False # kills polling daemon (if running) + poll_daemon_thread.join() # blocks until daemon is complete + serialport.serial.flushInput() + + while args.no_close: + if keypress() == 'q': + break + time.sleep(0.1) + +curses.wrapper(curses_wrap)