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.

master
Gina Häußge 2013-01-30 20:56:17 +01:00
parent 84c9403e65
commit 49cd1ffbd6
9 changed files with 432 additions and 69 deletions

View File

@ -0,0 +1 @@

281
octoprint/gcodefiles.py Normal file
View File

@ -0,0 +1,281 @@
# coding=utf-8
__author__ = "Gina Häußge <osd@foosel.net>"
__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
<ul>
<li>The file has the extension ".gcode"</li>
<li>exists and is a file (not a directory) if "mustExist" is set to True</li>
</ul>
@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

View File

@ -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)

View File

@ -3,16 +3,17 @@ __author__ = "Gina Häußge <osd@foosel.net>"
__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/<filename>", 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/<filename>", 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):

View File

@ -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;
}

View File

@ -695,6 +695,29 @@ function GcodeFilesViewModel() {
}
}
self.getPopoverContent = function(data) {
var output = "<p><strong>Uploaded:</strong> " + data["date"] + "</p>";
if (data["gcodeAnalysis"]) {
output += "<p>";
output += "<strong>Filament:</strong> " + data["gcodeAnalysis"]["filament"] + "<br>";
output += "<strong>Estimated Print Time:</strong> " + data["gcodeAnalysis"]["estimatedPrintTime"];
output += "</p>";
}
if (data["prints"] && data["prints"]["last"]) {
output += "<p>";
output += "<strong>Last Print:</strong> <span class=\"" + (data["prints"]["last"]["success"] ? "text-success" : "text-error") + "\">" + data["prints"]["last"]["date"] + "</span>";
output += "</p>";
}
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);
}
})
}
);

View File

@ -77,7 +77,7 @@
<div class="accordion-heading">
<a class="accordion-toggle" data-toggle="collapse" href="#files"><i class="icon-list"></i> Files</a>
</div>
<div class="accordion-body collapse in" id="files">
<div class="accordion-body collapse in overflow_visible" id="files">
<div class="accordion-inner">
<table class="table table-striped table-hover table-condensed table-hover" id="gcode_files">
<thead>
@ -88,7 +88,7 @@
</tr>
</thead>
<tbody data-bind="foreach: paginatedFiles">
<tr data-bind="attr: {title: name}">
<tr data-bind="css: $root.getSuccessClass($data), popover: { title: name, animation: true, html: true, placement: 'right', trigger: 'hover', delay: 0, content: $root.getPopoverContent($data), html: true }">
<td class="gcode_files_name" data-bind="text: name"></td>
<td class="gcode_files_size" data-bind="text: size"></td>
<td class="gcode_files_action"><a href="#" class="icon-trash" data-bind="click: function() { $root.removeFile($data.name); }"></a>&nbsp;|&nbsp;<a href="#" class="icon-folder-open" data-bind="click: function() { $root.loadFile($data.name); }"></a></td>

View File

@ -0,0 +1,30 @@
# coding=utf-8
__author__ = "Gina Häußge <osd@foosel.net>"
__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")

View File

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