diff --git a/printrun/gcoder.py b/printrun/gcoder.py index ef5fd0a83..1a3fdaff5 100755 --- a/printrun/gcoder.py +++ b/printrun/gcoder.py @@ -240,6 +240,8 @@ def prepare(self, data = None, home_pos = None, layer_callback = None): self.layer_idxs = array('I', []) self.line_idxs = array('I', []) + def has_index(self, i): + return i < len(self) def __len__(self): return len(self.line_idxs) diff --git a/printrun/gsync.py b/printrun/gsync.py new file mode 100644 index 000000000..31f7578bd --- /dev/null +++ b/printrun/gsync.py @@ -0,0 +1,204 @@ +# This file is part of the Printrun suite. +# +# Printrun is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Printrun is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Printrun. If not, see . + +# Module for sending sync gcodes to printer with optional return info, e.g. M114 position +import time +from printrun.plugins.sample import SampleHandler +import re +import threading +import printcore +import numpy as np + +verbose = False +class MyHandler(SampleHandler): + def __init__(self): + SampleHandler.__init__(self) + self.clear() + def clear(self): + self.printer_response = [] + def on_recv(self, ln): + self.printer_response.append(ln) + if ln.startswith('ok'): + with ok_received: + ok_received.notify() + elif verbose: + print('>', ln, end=''); + +def connect(tty, baud): + global p, h + p = printcore.printcore(tty, baud, True) + h = MyHandler() + p.event_handler.append(h) + while not p.online: + print('not online, reset()...') + p.reset(); + time.sleep(3) + if p.online: + print('online') + return p +ok_received = threading.Condition() +def wait_ok(): + with ok_received: + ok_received.wait() +def send(gcode): + h.clear() + p.send(gcode) + wait_ok() +#-- +class SyncCommand(object): + def __init__(self, has_return=False): + self.has_return = has_return + def send(self, *args): + send(self.name() + ' ' + ' '.join(args)) + if self.has_return: + self.parseResponse(h.printer_response) + def name(self): + return self.__class__.__name__.replace('Class', '') + + def parseResponse(self, resp): + pass # override + +class M114Class(SyncCommand): + def __init__(self): + SyncCommand.__init__(self, True) + self.x = self.y = self.z = None + def parseResponse(self, lines): + #X:0.00 Y:0.00 Z:0.00 E:0.00 + # process all lines b/c we can receive 'echo:busy: processing' from previous G0 cmds + for ln in lines: + m = re.match('X:([\d.-]+)\sY:([\d.-]+)\sZ:([\d.-]+)', ln) + if m: + self.x, self.y, self.z = [float(f) for f in m.groups()] +class M420Class(SyncCommand): + def __init__(self): + SyncCommand.__init__(self,True) + def parseResponse(self, lines): + self.leveling_enabled = None + dc = {} + max_j = -1 + for ln in lines: + #UBL: + # 2 | +0.500 +0.400 0.000 + # 0 |[+0.170] +0.370 +0.100 + #ABL: + # 0 ===== ===== ===== + m = re.match('\s+([\d]+)\s*\|?([ \[+\d.\]=-]+)', ln) + if m: + j = int(m.group(1)) + max_j = max(max_j, j) + dc[j] = [float(z.strip('[]').replace('=====', 'nan')) for z in m.group(2).split()] + #echo:Bed Leveling On + else: + m = re.match('echo:Bed Leveling (On|Off)', ln) + if m: + self.leveling_enabled = m.group(1) == 'On' + self.topology = [None] * (max_j + 1) + for j, r in dc.items(): + self.topology[j] = r + if self.leveling_enabled is None: + assert False, 'Did not find bed status' +class M503Class(SyncCommand): + def __init__(self): + SyncCommand.__init__(self,True) + self.hasUBL = False + def parseResponse(self, lines): + self.hasUBL = self.hasABL = False + z_map = {} + max_i = max_j = -1 + for ln in lines: + if ln.startswith('Unified Bed Leveling'): + self.hasUBL = True + elif ln.startswith('echo:Auto Bed Leveling:'): + self.hasABL = True + else: + #echo: G29 W I1 J2 Z0.00000 + #print(ln) + m = re.match('echo: G29 W I([\d]+) J([\d]+) Z([\d.-]+)', ln) + #print('m', m) + if m: + i = int(m.group(1)) + j = int(m.group(2)) + z_map[i,j] = float(m.group(3)) + max_i = max(max_i, i) + max_j = max(max_j, j) + self.topology = [[None] * (max_i + 1) for j in range(max_j + 1)] + #print('self.topology', z_map, self.topology) + for (i, j), z in z_map.items(): + #print(i, j, z) + self.topology[j][i] = z + +class G29WClass(SyncCommand): + def __init__(self): + SyncCommand.__init__(self, True) + def parseResponse(self, lines): + for ln in lines: + m = re.match('(GRID_MAX_POINTS_[XY])\s+([\d]+)', ln) + if m: + setattr(self, m.group(1), int(m.group(2))) + continue + #X-Axis Mesh Points at: 0.000 150.000 300.000 + m = re.match('[XY]-Axis Mesh Points at:', ln) + if m: + pts = ln.split(':')[1] + pts = np.array([float(pt) for pt in pts.split()]) + if ln[0] == 'X': + self.x_mesh_points = pts + else: + self.y_mesh_points = pts + def send(self, *args): + SyncCommand.send(self, 'W') + def name(self): + return 'G29' +class G42Class(SyncCommand): + def __init__(self): + SyncCommand.__init__(self, True) + def parseResponse(self, lines): + for ln in lines: + m = re.match('(GRID_MAX_POINTS_[XY])\s+([\d]+)', ln) + if m: + setattr(self, m.group(1), int(m.group(2))) + continue + #X-Axis Mesh Points at: 0.000 150.000 300.000 + m = re.match('[XY]-Axis Mesh Points at:', ln) + if m: + pts = ln.split(':')[1] + pts = np.array([float(pt) for pt in pts.split()]) + if ln[0] == 'X': + self.x_mesh_points = pts + else: + self.y_mesh_points = pts + +M114 = M114Class() +M420 = M420Class() +M503 = M503Class() +G29W = G29WClass() + +if __name__ == '__main__': + #dev testing + print('run from cmd line') + try: + p = connect('/dev/ttyUSB0', 115200) + send('G28 X Y') + M114.send() + print('pos', M114.x, M114.y, M114.z) + M503.send() + if M503.hasUBL: + print('UBL detected') + G29W.send() + print('mesh pts', G29W.x_mesh_points) + send('G0 X10') + send('M400') + finally: + p.disconnect() diff --git a/printrun/printcore.py b/printrun/printcore.py index beb9102d3..a4a545ac8 100644 --- a/printrun/printcore.py +++ b/printrun/printcore.py @@ -586,7 +586,7 @@ def _sendnext(self): self._send(self.priqueue.get_nowait()) self.priqueue.task_done() return - if self.printing and self.queueindex < len(self.mainqueue): + if self.printing and self.mainqueue.has_index(self.queueindex): (layer, line) = self.mainqueue.idxs(self.queueindex) gline = self.mainqueue.all_layers[layer][line] if self.queueindex > 0: @@ -604,7 +604,7 @@ def _sendnext(self): try: handler.on_preprintsend(gline, self.queueindex, self.mainqueue) except: logging.error(traceback.format_exc()) if self.preprintsendcb: - if self.queueindex + 1 < len(self.mainqueue): + if self.mainqueue.has_index(self.queueindex + 1): (next_layer, next_line) = self.mainqueue.idxs(self.queueindex + 1) next_gline = self.mainqueue.all_layers[next_layer][next_line] else: diff --git a/printrun/pronsole.py b/printrun/pronsole.py index 9528a80a7..60218b390 100644 --- a/printrun/pronsole.py +++ b/printrun/pronsole.py @@ -104,6 +104,37 @@ def bed_enabled(self): def extruder_enabled(self): return self.extruder_temp != 0 +class RGSGCoder(): + """Bare alternative to gcoder.LightGCode which does not preload all lines in memory, +but still allows run_gcode_script (hence the RGS) to be processed by do_print (checksum,threading,ok waiting)""" + def __init__(self, line): + self.lines = True + self.filament_length = 0. + self.filament_length_multi = [0] + self.proc = run_command(line, {"$s": 'str(self.filename)'}, stdout = subprocess.PIPE, universal_newlines = True) + lr = gcoder.Layer([]) + lr.duration = 0. + self.all_layers = [lr] + self.read() #empty layer causes division by zero during progress calculation + def read(self): + ln = self.proc.stdout.readline() + if not ln: + self.proc.stdout.close() + return None + ln = ln.strip() + if not ln: + return None + pyLn = gcoder.PyLightLine(ln) + self.all_layers[0].append(pyLn) + return pyLn + def has_index(self, i): + while i >= len(self.all_layers[0]) and not self.proc.stdout.closed: + self.read() + return i < len(self.all_layers[0]) + def __len__(self): + return len(self.all_layers[0]) + def idxs(self, i): + return 0, i #layer, line class pronsole(cmd.Cmd): def __init__(self): @@ -184,7 +215,7 @@ def postloop(self): def preloop(self): self.log(_("Welcome to the printer console! Type \"help\" for a list of available commands.")) - self.prompt = self.promptf() + self.calc_prompt() cmd.Cmd.preloop(self) # We replace this function, defined in cmd.py . @@ -275,8 +306,9 @@ def logError(self, *msg): self.log("Error command output:") self.log(output.rstrip()) - def promptf(self): - """A function to generate prompts so that we can do dynamic prompts. """ + def calc_prompt(self): + """Calculate prompt string and save it in self.prompt without writing it out. + Always change prompt with this method only, so prompt_visible_len is calculated. """ if self.in_macro: promptstr = self.promptstrs["macro"] elif not self.p.online: @@ -285,34 +317,38 @@ def promptf(self): promptstr = self.promptstrs["online"] else: promptstr = self.promptstrs["fallback"] - if "%" not in promptstr: - return promptstr + if '%' not in promptstr: + self.prompt = promptstr + self.prompt_visible_len = len(promptstr) + return + specials = {} + specials["extruder_temp"] = str(int(self.status.extruder_temp)) + specials["extruder_temp_target"] = str(int(self.status.extruder_temp_target)) + specials["port"] = self.settings.port[5:] + if self.status.extruder_temp_target == 0: + specials["extruder_temp_fancy"] = str(int(self.status.extruder_temp)) + DEG else: - specials = {} - specials["extruder_temp"] = str(int(self.status.extruder_temp)) - specials["extruder_temp_target"] = str(int(self.status.extruder_temp_target)) - specials["port"] = self.settings.port[5:] - if self.status.extruder_temp_target == 0: - specials["extruder_temp_fancy"] = str(int(self.status.extruder_temp)) + DEG - else: - specials["extruder_temp_fancy"] = "%s%s/%s%s" % (str(int(self.status.extruder_temp)), DEG, str(int(self.status.extruder_temp_target)), DEG) - if self.p.printing: - progress = int(1000 * float(self.p.queueindex) / len(self.p.mainqueue)) / 10 - elif self.sdprinting: - progress = self.percentdone - else: - progress = 0.0 - specials["progress"] = str(progress) - if self.p.printing or self.sdprinting: - specials["progress_fancy"] = " " + str(progress) + "%" - else: - specials["progress_fancy"] = "" - specials["red"] = "\033[31m" - specials["green"] = "\033[32m" - specials["white"] = "\033[37m" - specials["bold"] = "\033[01m" - specials["normal"] = "\033[00m" - return promptstr % specials + specials["extruder_temp_fancy"] = "%s%s/%s%s" % (str(int(self.status.extruder_temp)), DEG, str(int(self.status.extruder_temp_target)), DEG) + if self.p.printing: + progress = int(1000 * float(self.p.queueindex) / len(self.p.mainqueue)) / 10 + elif self.sdprinting: + progress = self.percentdone + else: + progress = 0.0 + specials["progress"] = str(progress) + if self.p.printing or self.sdprinting: + specials["progress_fancy"] = " " + str(progress) + "%" + else: + specials["progress_fancy"] = "" + specials["red"] = "\033[31m" + specials["green"] = "\033[32m" + specials["white"] = "\033[37m" + specials["bold"] = "\033[01m" + specials["normal"] = "\033[00m" + self.prompt = promptstr % specials + for k in 'red', 'green', 'white', 'bold', 'normal': + specials[k] = '' + self.prompt_visible_len = len(promptstr % specials) def postcmd(self, stop, line): """ A hook we override to generate prompts after @@ -321,7 +357,7 @@ def postcmd(self, stop, line): temp info gets updated for the prompt.""" if self.p.online and self.dynamic_temp: self.p.send_now("M105") - self.prompt = self.promptf() + self.calc_prompt() return stop def kill(self): @@ -332,9 +368,31 @@ def kill(self): if self.rpc_server is not None: self.rpc_server.shutdown() - def write_prompt(self): - sys.stdout.write(self.promptf()) + def write_prompt(self, calc=True, overwrite_line=False): + if calc: + self.calc_prompt() + prefix = save_cursor = restore_cursor = '' + if overwrite_line: + # user may have written some partial command + # old prompt: G0 X100 + # new longer prompt: G0 X100 + len_change = self.prompt_visible_len - self.prompt_written_len + prefix = '\r' + save_cursor = '\0337' #tput sc + restore_cursor = '\0338' #tput rc + if len_change < 0: + #must shift to left, delete + #got from bash$ tput dch + prefix += '\033[%dP' % -len_change + restore_cursor += '\033[%dD' % -len_change #tput cub + elif len_change > 0: + #must shift to right, insert + #got from bash$ tput ich + prefix += '\033[%d@' % len_change + restore_cursor += '\033[%dC' % len_change + sys.stdout.write(save_cursor + prefix + self.prompt + restore_cursor) sys.stdout.flush() + self.prompt_written_len = self.prompt_visible_len def help_help(self, l = ""): self.do_help("") @@ -430,7 +488,7 @@ def hook_macro(self, l): def end_macro(self): if "onecmd" in self.__dict__: del self.onecmd # remove override self.in_macro = False - self.prompt = self.promptf() + self.calc_prompt() if self.cur_macro_def != "": self.macros[self.cur_macro_name] = self.cur_macro_def macro = self.compile_macro(self.cur_macro_name, self.cur_macro_def) @@ -491,7 +549,7 @@ def start_macro(self, macro_name, prev_definition = "", suppress_instructions = self.cur_macro_def = "" self.onecmd = self.hook_macro # override onecmd temporarily self.in_macro = False - self.prompt = self.promptf() + self.calc_prompt() def delete_macro(self, macro_name): if macro_name in self.macros.keys(): @@ -1283,8 +1341,7 @@ def recvcb_actions(self, l): self.do_pause(None) msg = l.split(" ", 1) if len(msg) > 1 and self.silent is False: self.logError(msg[1].ljust(15)) - sys.stdout.write(self.promptf()) - sys.stdout.flush() + self.write_prompt() return True elif l.startswith("//"): command = l.split(" ", 1) @@ -1296,18 +1353,15 @@ def recvcb_actions(self, l): command = command[1] if command == "pause": self.do_pause(None) - sys.stdout.write(self.promptf()) - sys.stdout.flush() + self.write_prompt() return True elif command == "resume": self.do_resume(None) - sys.stdout.write(self.promptf()) - sys.stdout.flush() + self.write_prompt() return True elif command == "disconnect": self.do_disconnect(None) - sys.stdout.write(self.promptf()) - sys.stdout.flush() + self.write_prompt() return True return False @@ -1319,13 +1373,13 @@ def recvcb(self, l): report_type = self.recvcb_report(l) if report_type & REPORT_TEMP: self.status.update_tempreading(l) + self.write_prompt(overwrite_line=True) if not self.lineignorepattern.match(l) and l[:4] != "wait" and not self.sdlisting \ and not self.monitoring and (report_type == REPORT_NONE or report_type & REPORT_MANUAL): if l[:5] == "echo:": l = l[5:].lstrip() if self.silent is False: self.log("\r" + l.ljust(15)) - sys.stdout.write(self.promptf()) - sys.stdout.flush() + self.write_prompt() def layer_change_cb(self, newlayer): layerz = self.fgcode.all_layers[newlayer].z @@ -1732,9 +1786,24 @@ def help_run_script(self): self.log(_("Runs a custom script. Current gcode filename can be given using $s token.")) def do_run_gcode_script(self, l): - p = run_command(l, {"$s": str(self.filename)}, stdout = subprocess.PIPE, universal_newlines = True) - for line in p.stdout.readlines(): - self.onecmd(line.strip()) + try: + self.fgcode = RGSGCoder(l) + self.do_print(None) + except BaseException as e: + self.logError(traceback.format_exc()) def help_run_gcode_script(self): self.log(_("Runs a custom script which output gcode which will in turn be executed. Current gcode filename can be given using $s token.")) + + def complete_run_gcode_script(self, text, line, begidx, endidx): + words = line.split() + sep = os.path.sep + if len(words) < 2: + return ['.' + sep , sep] + corrected_text = words[-1] # text arg skips leading '/', include it + if corrected_text == '.': + return ['./'] # guide user that in linux, PATH does not include . and relative executed scripts must start with ./ + prefix_len = len(corrected_text) - len(text) + res = [((f + sep) if os.path.isdir(f) else f)[prefix_len:] #skip unskipped prefix_len + for f in glob.glob(corrected_text + '*')] + return res diff --git a/testtools/lengthy.py b/testtools/lengthy.py new file mode 100644 index 000000000..e739b071e --- /dev/null +++ b/testtools/lengthy.py @@ -0,0 +1,10 @@ +#!/usr/bin/python3 +#generate many g1 to test serial buffer overflow in run_gcode_script +#run like this: +#in pronsole> run_gcode_script ./testtools/lengthy.py +print('G28 X') +print('G1 X0') +for x in range(100): + print() + print(' ') + print('G1 X', x)