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:
 |