From 49cd1ffbd6e1c8736642aa64b3f56cbb19a66b63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Wed, 30 Jan 2013 20:56:17 +0100 Subject: [PATCH] Gcode filemanagement now lives in its own module. Upload triggers gcode analysis, result is stored into metadata file. Print jobs triggered and their results are saved as well. Adjusted UI to display gcode analysis result and last print date and (color coded) result if available. Also adjusted gcode file list to color code entries according to last print result. --- octoprint/__init__.py | 1 + octoprint/gcodefiles.py | 281 +++++++++++++++++++++++++++++ octoprint/printer.py | 32 ++-- octoprint/server.py | 78 +++----- octoprint/static/css/ui.css | 12 ++ octoprint/static/js/ui.js | 52 ++++++ octoprint/templates/index.html | 4 +- octoprint/util/__init__.py | 30 +++ octoprint/util/gcodeInterpreter.py | 11 +- 9 files changed, 432 insertions(+), 69 deletions(-) create mode 100644 octoprint/gcodefiles.py diff --git a/octoprint/__init__.py b/octoprint/__init__.py index e69de29..8b13789 100644 --- a/octoprint/__init__.py +++ b/octoprint/__init__.py @@ -0,0 +1 @@ + diff --git a/octoprint/gcodefiles.py b/octoprint/gcodefiles.py new file mode 100644 index 0000000..f1a9361 --- /dev/null +++ b/octoprint/gcodefiles.py @@ -0,0 +1,281 @@ +# coding=utf-8 +__author__ = "Gina Häußge " +__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' + +import os +import Queue +import threading +import datetime +import yaml +import time +import octoprint.util as util +import octoprint.util.gcodeInterpreter as gcodeInterpreter +from octoprint.settings import settings + +from werkzeug.utils import secure_filename + +class GcodeManager: + def __init__(self): + self._uploadFolder = settings().getBaseFolder("uploads") + + self._metadata = {} + self._metadataDirty = False + self._metadataFile = os.path.join(self._uploadFolder, "metadata.yaml") + self._metadataFileAccessMutex = threading.Lock() + + self._metadataAnalyzer = MetadataAnalyzer(getPathCallback=self.getAbsolutePath, loadedCallback=self._onMetadataAnalysisFinished) + + self._loadMetadata() + + def _onMetadataAnalysisFinished(self, filename, gcode): + print("Got gcode analysis for file %s: %r" % (filename, gcode)) + if filename is None or gcode is None: + return + + basename = os.path.basename(filename) + + absolutePath = self.getAbsolutePath(basename) + if absolutePath is None: + return + + analysisResult = {} + dirty = False + if gcode.totalMoveTimeMinute: + analysisResult["estimatedPrintTime"] = util.getFormattedTimeDelta(datetime.timedelta(minutes=gcode.totalMoveTimeMinute)) + dirty = True + if gcode.extrusionAmount: + analysisResult["filament"] = "%.2fm" % (gcode.extrusionAmount / 1000) + dirty = True + + if dirty: + metadata = self.getFileMetadata(basename) + metadata["gcodeAnalysis"] = analysisResult + self._metadata[basename] = metadata + self._metadataDirty = True + self._saveMetadata() + + def _loadMetadata(self): + if os.path.exists(self._metadataFile) and os.path.isfile(self._metadataFile): + with self._metadataFileAccessMutex: + with open(self._metadataFile, "r") as f: + self._metadata = yaml.safe_load(f) + + def _saveMetadata(self, force=False): + if not self._metadataDirty and not force: + return + + with self._metadataFileAccessMutex: + with open(self._metadataFile, "wb") as f: + yaml.safe_dump(self._metadata, f, default_flow_style=False, indent=" ", allow_unicode=True) + self._metadataDirty = False + self._loadMetadata() + + def _getBasicFilename(self, filename): + if filename.startswith(self._uploadFolder): + return filename[len(self._uploadFolder + os.path.sep):] + else: + return filename + + def addFile(self, file): + if file: + absolutePath = self.getAbsolutePath(file.filename, mustExist=False) + if absolutePath is not None: + file.save(absolutePath) + self._metadataAnalyzer.addFileToQueue(os.path.basename(absolutePath)) + return absolutePath + return None + + def removeFile(self, filename): + filename = self._getBasicFilename(filename) + absolutePath = self.getAbsolutePath(filename) + if absolutePath is not None: + os.remove(absolutePath) + + def getAbsolutePath(self, filename, mustExist=True): + """ + Returns the absolute path of the given filename in the gcode upload folder. + + Ensures that + + + @param filename the name of the file for which to determine the absolute path + @param mustExist if set to true, the method also checks if the file exists and is a file + @return the absolute path of the file or None if the file is not valid + """ + filename = self._getBasicFilename(filename) + + if not util.isAllowedFile(filename, set(["gcode"])): + return None + + secure = os.path.join(self._uploadFolder, secure_filename(self._getBasicFilename(filename))) + if mustExist and (not os.path.exists(secure) or not os.path.isfile(secure)): + return None + + return secure + + def getAllFileData(self): + files = [] + for osFile in os.listdir(self._uploadFolder): + fileData = self.getFileData(osFile) + if fileData is not None: + files.append(fileData) + return files + + def getFileData(self, filename): + filename = self._getBasicFilename(filename) + absolutePath = self.getAbsolutePath(filename) + if absolutePath is None: + return None + + statResult = os.stat(absolutePath) + fileData = { + "name": filename, + "size": util.getFormattedSize(statResult.st_size), + "date": util.getFormattedDateTime(datetime.datetime.fromtimestamp(statResult.st_ctime)) + } + + # enrich with additional metadata from analysis if available + if filename in self._metadata.keys(): + for key in self._metadata[filename].keys(): + if key == "prints": + val = self._metadata[filename][key] + formattedLast = None + if val["last"] is not None: + formattedLast = { + "date": util.getFormattedDateTime(datetime.datetime.fromtimestamp(val["last"]["date"])), + "success": val["last"]["success"] + } + formattedPrints = { + "success": val["success"], + "failure": val["failure"], + "last": formattedLast + } + fileData["prints"] = formattedPrints + else: + fileData[key] = self._metadata[filename][key] + + return fileData + + def getFileMetadata(self, filename): + filename = self._getBasicFilename(filename) + if filename in self._metadata.keys(): + return self._metadata[filename] + else: + return { + "prints": { + "success": 0, + "failure": 0, + "last": None + } + } + + def setFileMetadata(self, filename, metadata): + filename = self._getBasicFilename(filename) + self._metadata[filename] = metadata + self._metadataDirty = True + + def printSucceeded(self, filename): + filename = self._getBasicFilename(filename) + absolutePath = self.getAbsolutePath(filename) + if absolutePath is None: + return + + metadata = self.getFileMetadata(filename) + metadata["prints"]["success"] += 1 + metadata["prints"]["last"] = { + "date": time.time(), + "success": True + } + self.setFileMetadata(filename, metadata) + self._saveMetadata() + + def printFailed(self, filename): + filename = self._getBasicFilename(filename) + absolutePath = self.getAbsolutePath(filename) + if absolutePath is None: + return + + metadata = self.getFileMetadata(filename) + metadata["prints"]["failure"] += 1 + metadata["prints"]["last"] = { + "date": time.time(), + "success": False + } + self.setFileMetadata(filename, metadata) + self._saveMetadata() + +class MetadataAnalyzer: + def __init__(self, getPathCallback, loadedCallback): + self._getPathCallback = getPathCallback + self._loadedCallback = loadedCallback + + self._active = threading.Event() + self._active.set() + + self._currentFile = None + self._currentProgress = None + + self._queue = Queue.Queue() + self._gcode = None + + self._worker = threading.Thread(target=self._work) + self._worker.daemon = True + self._worker.start() + + def addFileToQueue(self, filename): + self._queue.put(filename) + + def working(self): + return self.isActive() and not (self._queue.empty() and self._currentFile is None) + + def isActive(self): + return self._active.is_set() + + def pause(self): + self._active.clear() + if self._gcode is not None: + self._gcode.abort() + + def resume(self): + self._active.set() + + def _work(self): + aborted = None + while True: + self._active.wait() + + if aborted is not None: + filename = aborted + aborted = None + else: + filename = self._queue.get() + + try: + self._analyzeGcode(filename) + self._queue.task_done() + except gcodeInterpreter.AnalysisAborted: + aborted = filename + + def _analyzeGcode(self, filename): + path = self._getPathCallback(filename) + if path is None: + return + + self._currentFile = filename + self._currentProgress = 0 + + try: + self._gcode = gcodeInterpreter.gcode() + self._gcode.progressCallback = self._onParsingProgress + self._gcode.load(path) + self._loadedCallback(self._currentFile, self._gcode) + finally: + self._gcode = None + self._currentProgress = None + self._currentFile = None + + def _onParsingProgress(self, progress): + self._currentProgress = progress diff --git a/octoprint/printer.py b/octoprint/printer.py index ac09506..f281bd1 100644 --- a/octoprint/printer.py +++ b/octoprint/printer.py @@ -9,6 +9,7 @@ import copy import os import octoprint.util.comm as comm +import octoprint.util as util from octoprint.util import gcodeInterpreter from octoprint.settings import settings @@ -25,7 +26,9 @@ def getConnectionOptions(): } class Printer(): - def __init__(self): + def __init__(self, gcodeManager): + self._gcodeManager = gcodeManager + # state self._temp = None self._bedTemp = None @@ -209,6 +212,9 @@ class Printer(): self._setCurrentZ(None) self._setProgressData(None, None, None) + # mark print as failure + self._gcodeManager.printFailed(self._filename) + #~~ state monitoring def setTimelapse(self, timelapse): @@ -249,11 +255,11 @@ class Printer(): formattedPrintTime = None if (self._printTime): - formattedPrintTime = _getFormattedTimeDelta(datetime.timedelta(seconds=self._printTime)) + formattedPrintTime = util.getFormattedTimeDelta(datetime.timedelta(seconds=self._printTime)) formattedPrintTimeLeft = None if (self._printTimeLeft): - formattedPrintTimeLeft = _getFormattedTimeDelta(datetime.timedelta(minutes=self._printTimeLeft)) + formattedPrintTimeLeft = util.getFormattedTimeDelta(datetime.timedelta(minutes=self._printTimeLeft)) self._stateMonitor.setProgress({"progress": self._progress, "printTime": formattedPrintTime, "printTimeLeft": formattedPrintTimeLeft}) @@ -292,7 +298,7 @@ class Printer(): formattedFilament = None if self._gcode: if self._gcode.totalMoveTimeMinute: - formattedPrintTimeEstimation = _getFormattedTimeDelta(datetime.timedelta(minutes=self._gcode.totalMoveTimeMinute)) + formattedPrintTimeEstimation = util.getFormattedTimeDelta(datetime.timedelta(minutes=self._gcode.totalMoveTimeMinute)) if self._gcode.extrusionAmount: formattedFilament = "%.2fm" % (self._gcode.extrusionAmount / 1000) elif not settings().getBoolean("feature", "analyzeGcode"): @@ -347,12 +353,19 @@ class Printer(): """ oldState = self._state + # if self._timelapse is not None: - if oldState == self._comm.STATE_PRINTING: + if oldState == self._comm.STATE_PRINTING and state != self._comm.STATE_PAUSED: self._timelapse.onPrintjobStopped() - elif state == self._comm.STATE_PRINTING: + elif state == self._comm.STATE_PRINTING and oldState != self._comm.STATE_PAUSED: self._timelapse.onPrintjobStarted(self._filename) + if oldState == self._comm.STATE_PRINTING: + if state == self._comm.STATE_OPERATIONAL: + self._gcodeManager.printSucceeded(self._filename) + elif state == self._comm.STATE_CLOSED or state == self._comm.STATE_ERROR or state == self._comm.STATE_CLOSED_WITH_ERROR: + self._gcodeManager.printFailed(self._filename) + self._setState(state) @@ -604,10 +617,3 @@ class StateMonitor(object): "progress": self._progress } -def _getFormattedTimeDelta(d): - if d is None: - return None - hours = d.seconds // 3600 - minutes = (d.seconds % 3600) // 60 - seconds = d.seconds % 60 - return "%02d:%02d:%02d" % (hours, minutes, seconds) diff --git a/octoprint/server.py b/octoprint/server.py index 5f25764..9f7acb8 100644 --- a/octoprint/server.py +++ b/octoprint/server.py @@ -3,16 +3,17 @@ __author__ = "Gina Häußge " __license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' from flask import Flask, request, render_template, jsonify, send_from_directory, abort, url_for -from werkzeug import secure_filename +from werkzeug.utils import secure_filename import tornadio2 import os -import fnmatch import threading from octoprint.printer import Printer, getConnectionOptions, PrinterCallback from octoprint.settings import settings import octoprint.timelapse as timelapse +import octoprint.gcodefiles as gcodefiles +import octoprint.util as util BASEURL = "/ajax/" SUCCESS = {} @@ -20,7 +21,8 @@ SUCCESS = {} UPLOAD_FOLDER = settings().getBaseFolder("uploads") app = Flask("octoprint") -printer = Printer() +gcodeManager = gcodefiles.GcodeManager() +printer = Printer(gcodeManager) @app.route("/") def index(): @@ -158,7 +160,7 @@ def setTargetTemperature(): if request.values.has_key("temp"): # set target temperature - temp = request.values["temp"]; + temp = request.values["temp"] printer.command("M104 S" + temp) if request.values.has_key("bedTemp"): @@ -174,22 +176,22 @@ def jog(): # do not jog when a print job is running or we don"t have a connection return jsonify(SUCCESS) - if request.values.has_key("x"): + if "x" in request.values.keys(): # jog x x = request.values["x"] printer.commands(["G91", "G1 X" + x + " F6000", "G90"]) - if request.values.has_key("y"): + if "y" in request.values.keys(): # jog y y = request.values["y"] printer.commands(["G91", "G1 Y" + y + " F6000", "G90"]) - if request.values.has_key("z"): + if "z" in request.values.keys(): # jog z z = request.values["z"] printer.commands(["G91", "G1 Z" + z + " F200", "G90"]) - if request.values.has_key("homeXY"): + if "homeXY" in request.values.keys(): # home x/y printer.command("G28 X0 Y0") - if request.values.has_key("homeZ"): + if "homeZ" in request.values.keys(): # home z printer.command("G28 Z0") @@ -197,7 +199,7 @@ def jog(): @app.route(BASEURL + "control/speed", methods=["GET"]) def getSpeedValues(): - return jsonify(feedrate = printer.feedrateState()) + return jsonify(feedrate=printer.feedrateState()) @app.route(BASEURL + "control/speed", methods=["POST"]) def speed(): @@ -205,7 +207,7 @@ def speed(): return jsonify(SUCCESS) for key in ["outerWall", "innerWall", "fill", "support"]: - if request.values.has_key(key): + if key in request.values.keys(): value = int(request.values[key]) printer.setFeedrateModifier(key, value) @@ -214,47 +216,34 @@ def speed(): @app.route(BASEURL + "control/custom", methods=["GET"]) def getCustomControls(): customControls = settings().getObject("controls") - return jsonify(controls = customControls) + return jsonify(controls=customControls) #~~ GCODE file handling @app.route(BASEURL + "gcodefiles", methods=["GET"]) def readGcodeFiles(): - files = [] - for osFile in os.listdir(UPLOAD_FOLDER): - if not fnmatch.fnmatch(osFile, "*.gcode"): - continue - files.append({ - "name": osFile, - "size": sizeof_fmt(os.stat(os.path.join(UPLOAD_FOLDER, osFile)).st_size) - }) - return jsonify(files=files) + return jsonify(files=gcodeManager.getAllFileData()) @app.route(BASEURL + "gcodefiles/upload", methods=["POST"]) def uploadGcodeFile(): - if request.files.has_key("gcode_file"): + if "gcode_file" in request.files.keys(): file = request.files["gcode_file"] - if file and allowed_file(file.filename, set(["gcode"])): - secure = secure_filename(file.filename) - filename = os.path.join(UPLOAD_FOLDER, secure) - file.save(filename) + gcodeManager.addFile(file) return readGcodeFiles() @app.route(BASEURL + "gcodefiles/load", methods=["POST"]) def loadGcodeFile(): - if request.values.has_key("filename"): - filename = request.values["filename"] - printer.loadGcode(os.path.join(UPLOAD_FOLDER, filename)) + if "filename" in request.values.keys(): + filename = gcodeManager.getAbsolutePath(request.values["filename"]) + if filename is not None: + printer.loadGcode(filename) return jsonify(SUCCESS) @app.route(BASEURL + "gcodefiles/delete", methods=["POST"]) def deleteGcodeFile(): - if request.values.has_key("filename"): + if "filename" in request.values.keys(): filename = request.values["filename"] - if allowed_file(filename, set(["gcode"])): - secure = os.path.join(UPLOAD_FOLDER, secure_filename(filename)) - if os.path.exists(secure): - os.remove(secure) + gcodeManager.removeFile(filename) return readGcodeFiles() #~~ timelapse handling @@ -275,7 +264,7 @@ def getTimelapseData(): files = timelapse.getFinishedTimelapses() for file in files: - file["size"] = sizeof_fmt(file["size"]) + file["size"] = util.getFormattedSize(file["size"]) file["url"] = url_for("downloadTimelapse", filename=file["name"]) return jsonify({ @@ -286,12 +275,12 @@ def getTimelapseData(): @app.route(BASEURL + "timelapse/", methods=["GET"]) def downloadTimelapse(filename): - if allowed_file(filename, set(["mpg"])): + if util.isAllowedFile(filename, set(["mpg"])): return send_from_directory(settings().getBaseFolder("timelapse"), filename, as_attachment=True) @app.route(BASEURL + "timelapse/", methods=["DELETE"]) def deleteTimelapse(filename): - if allowed_file(filename, set(["mpg"])): + if util.isAllowedFile(filename, set(["mpg"])): secure = os.path.join(settings().getBaseFolder("timelapse"), secure_filename(filename)) if os.path.exists(secure): os.remove(secure) @@ -337,21 +326,6 @@ def setSettings(): s.save() return getSettings() -#~~ helper functions - -def sizeof_fmt(num): - """ - Taken from http://stackoverflow.com/questions/1094841/reusable-library-to-get-human-readable-version-of-file-size - """ - for x in ["bytes","KB","MB","GB"]: - if num < 1024.0: - return "%3.1f%s" % (num, x) - num /= 1024.0 - return "%3.1f%s" % (num, "TB") - -def allowed_file(filename, extensions): - return "." in filename and filename.rsplit(".", 1)[1] in extensions - #~~ startup code def run(host = "0.0.0.0", port = 5000, debug = False): diff --git a/octoprint/static/css/ui.css b/octoprint/static/css/ui.css index 6632ef6..83e4b93 100644 --- a/octoprint/static/css/ui.css +++ b/octoprint/static/css/ui.css @@ -133,4 +133,16 @@ table th.timelapse_files_action, table td.timelapse_files_action { #webcam_container { width: 100%; +} + +#files .popover { + font-size: 85%; +} + +#files .popover p:last-child { + margin-bottom: 0; +} + +.overflow_visible { + overflow: visible !important; } \ No newline at end of file diff --git a/octoprint/static/js/ui.js b/octoprint/static/js/ui.js index 717cce2..d14c839 100644 --- a/octoprint/static/js/ui.js +++ b/octoprint/static/js/ui.js @@ -695,6 +695,29 @@ function GcodeFilesViewModel() { } } + self.getPopoverContent = function(data) { + var output = "

Uploaded: " + data["date"] + "

"; + if (data["gcodeAnalysis"]) { + output += "

"; + output += "Filament: " + data["gcodeAnalysis"]["filament"] + "
"; + output += "Estimated Print Time: " + data["gcodeAnalysis"]["estimatedPrintTime"]; + output += "

"; + } + if (data["prints"] && data["prints"]["last"]) { + output += "

"; + output += "Last Print: " + data["prints"]["last"]["date"] + ""; + output += "

"; + } + return output; + } + + self.getSuccessClass = function(data) { + if (!data["prints"] || !data["prints"]["last"]) { + return ""; + } + return data["prints"]["last"]["success"] ? "text-success" : "text-error"; + } + } function WebcamViewModel() { @@ -962,6 +985,23 @@ $(function() { //~~ knockout.js bindings + ko.bindingHandlers.popover = { + init: function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) { + var val = ko.utils.unwrapObservable(valueAccessor()); + + var options = { + title: val.title, + animation: val.animation, + placement: val.placement, + trigger: val.trigger, + delay: val.delay, + content: val.content, + html: val.html + }; + $(element).popover(options); + } + } + ko.applyBindings(connectionViewModel, document.getElementById("connection")); ko.applyBindings(printerStateViewModel, document.getElementById("state")); ko.applyBindings(gcodeFilesViewModel, document.getElementById("files")); @@ -982,6 +1022,18 @@ $(function() { gcodeFilesViewModel.requestData(); webcamViewModel.requestData(); + //~~ UI stuff + + $(".accordion-toggle[href='#files']").click(function() { + if ($("#files").hasClass("in")) { + $("#files").removeClass("overflow_visible"); + } else { + setTimeout(function() { + $("#files").addClass("overflow_visible"); + }, 1000); + } + }) + } ); diff --git a/octoprint/templates/index.html b/octoprint/templates/index.html index c2b7910..4eb47ee 100644 --- a/octoprint/templates/index.html +++ b/octoprint/templates/index.html @@ -77,7 +77,7 @@ -
+
@@ -88,7 +88,7 @@ - + diff --git a/octoprint/util/__init__.py b/octoprint/util/__init__.py index e69de29..52d8361 100644 --- a/octoprint/util/__init__.py +++ b/octoprint/util/__init__.py @@ -0,0 +1,30 @@ +# coding=utf-8 +__author__ = "Gina Häußge " +__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' + +def getFormattedSize(num): + """ + Taken from http://stackoverflow.com/questions/1094841/reusable-library-to-get-human-readable-version-of-file-size + """ + for x in ["bytes","KB","MB","GB"]: + if num < 1024.0: + return "%3.1f%s" % (num, x) + num /= 1024.0 + return "%3.1f%s" % (num, "TB") + +def isAllowedFile(filename, extensions): + return "." in filename and filename.rsplit(".", 1)[1] in extensions + +def getFormattedTimeDelta(d): + if d is None: + return None + hours = d.seconds // 3600 + minutes = (d.seconds % 3600) // 60 + seconds = d.seconds % 60 + return "%02d:%02d:%02d" % (hours, minutes, seconds) + +def getFormattedDateTime(d): + if d is None: + return None + + return d.strftime("%Y-%m-%d %H:%M") diff --git a/octoprint/util/gcodeInterpreter.py b/octoprint/util/gcodeInterpreter.py index 1db8585..a0e2349 100644 --- a/octoprint/util/gcodeInterpreter.py +++ b/octoprint/util/gcodeInterpreter.py @@ -22,6 +22,9 @@ def getPreference(key, default=None): else: return default +class AnalysisAborted(Exception): + pass + class gcodePath(object): def __init__(self, newType, pathType, layerThickness, startPoint): self.type = newType @@ -36,6 +39,7 @@ class gcode(object): self.extrusionAmount = 0 self.totalMoveTimeMinute = 0 self.progressCallback = None + self._abort = False def load(self, filename): if os.path.isfile(filename): @@ -46,6 +50,9 @@ class gcode(object): def loadList(self, l): self._load(l) + + def abort(self): + self._abort = True def _load(self, gcodeFile): filePos = 0 @@ -69,6 +76,8 @@ class gcode(object): currentPath.list[0].extrudeAmountMultiply = extrudeAmountMultiply currentLayer.append(currentPath) for line in gcodeFile: + if self._abort: + raise StopIteration if type(line) is tuple: line = line[0] if self.progressCallback != None: @@ -253,8 +262,6 @@ class gcode(object): self.layerList.append(currentLayer) self.extrusionAmount = maxExtrusion self.totalMoveTimeMinute = totalMoveTimeMinute - #print "Extruded a total of: %d mm of filament" % (self.extrusionAmount) - #print "Estimated print duration: %.2f minutes" % (self.totalMoveTimeMinute) def getCodeInt(self, line, code): if code not in self.regMatch:
 |