From 639d8d4c5f8432679793431d11461fdfb0531736 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Wed, 8 Mar 2017 13:36:37 +0100 Subject: [PATCH] Perform gcode analysis in subprocess --- src/octoprint/filemanager/analysis.py | 106 +++++++++++++++----- src/octoprint/util/gcodeInterpreter.py | 130 +++++++++++++++++++++---- 2 files changed, 191 insertions(+), 45 deletions(-) diff --git a/src/octoprint/filemanager/analysis.py b/src/octoprint/filemanager/analysis.py index a774cc37..9f75d349 100644 --- a/src/octoprint/filemanager/analysis.py +++ b/src/octoprint/filemanager/analysis.py @@ -305,39 +305,95 @@ class GcodeAnalysisQueue(AbstractAnalysisQueue): * The extruded volume in cm³ """ - def _do_analysis(self, high_priority=False): - try: - throttle = settings().getFloat(["gcodeAnalysis", "throttle_highprio"]) if high_priority else settings().getFloat(["gcodeAnalysis", "throttle_normalprio"]) - throttle_lines = settings().getInt(["gcodeAnalysis", "throttle_lines"]) - if throttle > 0: - def throttle_callback(filePos, readBytes): - if filePos % throttle_lines == 0: - # only apply throttle every 100 lines - time.sleep(throttle) - else: - throttle_callback = None + def __init__(self, finished_callback): + AbstractAnalysisQueue.__init__(self, finished_callback) - self._gcode = gcodeInterpreter.gcode() - self._gcode.load(self._current.absolute_path, self._current.printer_profile, throttle=throttle_callback) + self._aborted = False + self._reenqueue = False + + def _do_analysis(self, high_priority=False): + import sarge + import sys + import yaml + + try: + throttle = settings().getFloat(["gcodeAnalysis", "throttle_highprio"]) if high_priority \ + else settings().getFloat(["gcodeAnalysis", "throttle_normalprio"]) + throttle_lines = settings().getInt(["gcodeAnalysis", "throttle_lines"]) + max_extruders = settings().getInt(["gcodeAnalysis", "maxExtruders"]) + g90_extruder = settings().getBoolean(["feature", "g90InfluencesExtruder"]) + speedx = self._current.printer_profile["axes"]["x"]["speed"] + speedy = self._current.printer_profile["axes"]["y"]["speed"] + offsets = self._current.printer_profile["extruder"]["offsets"] + + interpreter = os.path.realpath(os.path.join(os.path.dirname(__file__), "..", "util", "gcodeInterpreter.py")) + command = [sys.executable, interpreter, "--speed-x={}".format(speedx), "--speed-y={}".format(speedy), + "--max-t={}".format(max_extruders), "--throttle={}".format(throttle), + "--throttle-lines={}".format(throttle_lines)] + for offset in offsets[1:]: + command += ["--offset", offset[0], offset[1]] + if g90_extruder: + command += ["--g90-extruder"] + command.append(self._current.absolute_path) + + self._logger.info("Invoking analysis command: {}".format(" ".join(command))) + + self._aborted = False + p = sarge.run(command, async=True, stdout=sarge.Capture()) + + while len(p.commands) == 0: + # somewhat ugly... we can't use wait_events because + # the events might not be all set if an exception + # by sarge is triggered within the async process + # thread + time.sleep(0.01) + + # by now we should have a command, let's wait for its + # process to have been prepared + p.commands[0].process_ready.wait() + + if not p.commands[0].process: + # the process might have been set to None in case of any exception + raise RuntimeError(u"Error while trying to run command {}".format(" ".join(command))) + + try: + # let's wait for stuff to finish + while p.returncode is None: + if self._aborted: + # oh, we shall abort, let's do so! + p.commands[0].terminate() + raise AnalysisAborted(reenqueue=self._reenqueue) + + # else continue + p.commands[0].poll() + finally: + p.close() + + output = p.stdout.text + self._logger.debug("Got output: {!r}".format(output)) + + if not "RESULTS:" in output: + raise RuntimeError("No analysis result found") + + _, output = output.split("RESULTS:") + analysis = yaml.safe_load(output) result = dict() - result["printingArea"] = self._gcode.printing_area - result["dimensions"] = self._gcode.dimensions - if self._gcode.totalMoveTimeMinute: - result["estimatedPrintTime"] = self._gcode.totalMoveTimeMinute * 60 - if self._gcode.extrusionAmount: + result["printingArea"] = analysis["printing_area"] + result["dimensions"] = analysis["dimensions"] + if analysis["total_time"]: + result["estimatedPrintTime"] = analysis["total_time"] * 60 + if analysis["extrusion_length"]: result["filament"] = dict() - for i in range(len(self._gcode.extrusionAmount)): + for i in range(len(analysis["extrusion_length"])): result["filament"]["tool%d" % i] = { - "length": self._gcode.extrusionAmount[i], - "volume": self._gcode.extrusionVolume[i] + "length": analysis["extrusion_length"][i], + "volume": analysis["extrusion_volume"][i] } return result - except gcodeInterpreter.AnalysisAborted as ex: - raise AnalysisAborted(reenqueue=ex.reenqueue) finally: self._gcode = None def _do_abort(self, reenqueue=True): - if self._gcode: - self._gcode.abort(reenqueue=reenqueue) + self._aborted = True + self._reenqueue = reenqueue diff --git a/src/octoprint/util/gcodeInterpreter.py b/src/octoprint/util/gcodeInterpreter.py index 8f2a26b5..ea6d291a 100644 --- a/src/octoprint/util/gcodeInterpreter.py +++ b/src/octoprint/util/gcodeInterpreter.py @@ -10,8 +10,7 @@ import os import base64 import zlib import logging - -from octoprint.settings import settings +import codecs class Vector3D(object): @@ -178,18 +177,18 @@ class AnalysisAborted(Exception): class gcode(object): - def __init__(self): + def __init__(self, progress_callback=None): self._logger = logging.getLogger(__name__) self.layerList = None self.extrusionAmount = [0] self.extrusionVolume = [0] self.totalMoveTimeMinute = 0 self.filename = None - self.progressCallback = None self._abort = False self._reenqueue = True self._filamentDiameter = 0 self._minMax = MinMax3D() + self._progress_callback = progress_callback @property def dimensions(self): @@ -207,20 +206,19 @@ class gcode(object): maxY=self._minMax.max.y, maxZ=self._minMax.max.z) - def load(self, filename, printer_profile, throttle=None): + def load(self, filename, throttle=None, speedx=6000, speedy=6000, offsets=None, max_extruders=10, g90_extruder=False): if os.path.isfile(filename): self.filename = filename self._fileSize = os.stat(filename).st_size - import codecs with codecs.open(filename, encoding="utf-8", errors="replace") as f: - self._load(f, printer_profile, throttle=throttle) + self._load(f, throttle=throttle, speedx=speedx, speedy=speedy, offsets=offsets, max_extruders=max_extruders, g90_extruder=g90_extruder) def abort(self, reenqueue=True): self._abort = True self._reenqueue = reenqueue - def _load(self, gcodeFile, printer_profile, throttle=None): + def _load(self, gcodeFile, throttle=None, speedx=6000, speedy=6000, offsets=None, max_extruders=10, g90_extruder=False): lineNo = 0 readBytes = 0 pos = Vector3D(0.0, 0.0, 0.0) @@ -236,21 +234,23 @@ class gcode(object): fwretractTime = 0 fwretractDist = 0 fwrecoverTime = 0 - feedrate = min(printer_profile["axes"]["x"]["speed"], printer_profile["axes"]["y"]["speed"]) + feedrate = min(speedx, speedy) if feedrate == 0: # some somewhat sane default if axes speeds are insane... feedrate = 2000 - offsets = printer_profile["extruder"]["offsets"] - g90InfluencesExtruder = settings().getBoolean(["feature", "g90InfluencesExtruder"]) + if offsets is None or not isinstance(offsets, (list, tuple)): + offsets = [] + if len(offsets) < max_extruders: + offsets += [(0, 0)] * (max_extruders - len(offsets)) for line in gcodeFile: if self._abort: raise AnalysisAborted(reenqueue=self._reenqueue) lineNo += 1 - readBytes += len(line) + readBytes += len(line.encode("utf-8")) - if isinstance(gcodeFile, (file)): + if isinstance(gcodeFile, (file, codecs.StreamReaderWriter)): percentage = float(readBytes) / float(self._fileSize) elif isinstance(gcodeFile, (list)): percentage = float(lineNo) / float(len(gcodeFile)) @@ -258,8 +258,8 @@ class gcode(object): percentage = None try: - if self.progressCallback is not None and (lineNo % 1000 == 0) and percentage is not None: - self.progressCallback(percentage) + if self._progress_callback is not None and (lineNo % 1000 == 0) and percentage is not None: + self._progress_callback(percentage) except: pass @@ -395,11 +395,11 @@ class gcode(object): pos.z = center.z elif G == 90: #Absolute position relativeMode = False - if g90InfluencesExtruder: + if g90_extruder: relativeE = False elif G == 91: #Relative position relativeMode = True - if g90InfluencesExtruder: + if g90_extruder: relativeE = True elif G == 92: x = getCodeFloat(line, 'X') @@ -440,7 +440,7 @@ class gcode(object): fwrecoverTime = (fwretractDist + s) / f elif T is not None: - if T > settings().getInt(["gcodeAnalysis", "maxExtruders"]): + if T > max_extruders: self._logger.warn("GCODE tried to select tool %d, that looks wrong, ignoring for GCODE analysis" % T) else: toolOffset.x -= offsets[currentExtruder][0] if currentExtruder < len(offsets) else 0 @@ -463,8 +463,8 @@ class gcode(object): if throttle is not None: throttle(lineNo, readBytes) - if self.progressCallback is not None: - self.progressCallback(100.0) + if self._progress_callback is not None: + self._progress_callback(100.0) self.extrusionAmount = maxExtrusion self.extrusionVolume = [0] * len(maxExtrusion) @@ -476,6 +476,13 @@ class gcode(object): def _parseCuraProfileString(self, comment, prefix): return {key: value for (key, value) in map(lambda x: x.split("=", 1), zlib.decompress(base64.b64decode(comment[len(prefix):])).split("\b"))} + def get_result(self): + return dict(total_time=self.totalMoveTimeMinute, + extrusion_length=self.extrusionAmount, + extrusion_volume=self.extrusionVolume, + dimensions=self.dimensions, + printing_area=self.printing_area) + def getCodeInt(line, code): n = line.find(code) + 1 if n < 1: @@ -503,3 +510,86 @@ def getCodeFloat(line, code): return val if not (math.isnan(val) or math.isinf(val)) else None except: return None + +if __name__ == "__main__": + import click + import time + import yaml + import sys + + @click.command() + @click.option("--throttle", "throttle", type=float, default=None) + @click.option("--throttle-lines", "throttle_lines", type=int, default=None) + @click.option("--speed-x", "speedx", type=float, default=6000) + @click.option("--speed-y", "speedy", type=float, default=6000) + @click.option("--speed-z", "speedz", type=float, default=300) + @click.option("--offset", "offset", type=(float, float), multiple=True) + @click.option("--max-t", "maxt", type=int, default=10) + @click.option("--g90-extruder", "g90_extruder", is_flag=True) + @click.option("--progress", "progress", is_flag=True) + @click.argument("path", type=click.Path()) + def main(path, speedx, speedy, speedz, offset, maxt, throttle, throttle_lines, g90_extruder, progress): + throttle_callback = None + if throttle: + def throttle_callback(filePos, readBytes): + if filePos % throttle_lines == 0: + # only apply throttle every $throttle_lines lines + time.sleep(throttle) + + offsets = offset + if offsets is None: + offsets = [] + elif isinstance(offset, tuple): + offsets = list(offsets) + offsets = [(0, 0)] + offsets + if len(offsets) < maxt: + offsets += [(0, 0)] * (maxt - len(offsets)) + + start_time = time.time() + + progress_callback = None + if progress: + def progress_callback(percentage): + print("PROGRESS:{}".format(percentage)) + interpreter = gcode(progress_callback=progress_callback) + + interpreter.load(path, + speedx=speedx, + speedy=speedy, + offsets=offsets, + throttle=throttle_callback, + max_extruders=maxt, + g90_extruder=g90_extruder) + + print("DONE:{}s".format(time.time() - start_time)) + print("RESULTS:") + print(yaml.safe_dump(interpreter.get_result(), default_flow_style=False, indent=" ", allow_unicode=True)) + + # os args are gained differently on win32 + try: + from click.utils import get_os_args + args = get_os_args() + except ImportError: + # for whatever reason we are running an older Click version? + args = sys.argv[1:] + + if len(args) >= len(sys.argv): + # Now some ugly preprocessing of our arguments starts. We have a somewhat difficult situation on our hands + # here if we are running under Windows and want to be able to handle utf-8 command line parameters (think + # plugin parameters such as names or something, e.g. for the "dev plugin:new" command) while at the same + # time also supporting sys.argv rewriting for debuggers etc (e.g. PyCharm). + # + # So what we try to do here is solve this... Generally speaking, sys.argv and whatever Windows returns + # for its CommandLineToArgvW win32 function should have the same length. If it doesn't however and + # sys.argv is shorter than the win32 specific command line arguments, obviously stuff was cut off from + # sys.argv which also needs to be cut off of the win32 command line arguments. + # + # So this is what we do here. + + # -1 because first entry is the script that was called + sys_args_length = len(sys.argv) - 1 + + # cut off stuff from the beginning + args = args[-1 * sys_args_length:] if sys_args_length else [] + + main(args=args, prog_name="octoprint-gcode-analysis")