From eccc9d6fbde0c46cdd6e255fd5ce9a30077ba954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gina=20H=C3=A4u=C3=9Fge?= Date: Sat, 21 Dec 2013 14:46:20 +0100 Subject: [PATCH] WARNING: A lot of changes to the existing API and the event system. This WILL break existing API clients and probably some event handlers too. I'm sorry for the disruptive changes, but I needed to rectify some decisions before they went too far utilized elsewhere to still be corrected. Basically this change completely removes the old API and switches it (same endpoint) with the new one, that's basically the existing AJAX API that the client uses, but way more RESTful and based on JSON (exception being the file upload). The event system has been revamped to carry more payload data (and in an extensible form as dictionary, to allow for later addition of attributes to single events), with the existing event listeners adjusted to also allow users to make use of this data in their consumers. Documentation has been greatly enhanced for the REST API (and is still being added to), the events will be documented here as well. --- docs/images/octoprint-logo.png | Bin 0 -> 28062 bytes src/octoprint/events.py | 71 ++++- src/octoprint/gcodefiles.py | 26 +- src/octoprint/printer.py | 35 ++- src/octoprint/server/__init__.py | 9 +- src/octoprint/server/ajax/gcodefiles.py | 195 ------------- src/octoprint/server/api.py | 55 ---- .../server/{ajax => api}/__init__.py | 36 ++- src/octoprint/server/{ajax => api}/control.py | 81 ++++-- src/octoprint/server/api/files.py | 275 ++++++++++++++++++ .../server/{ajax => api}/settings.py | 6 +- .../server/{ajax => api}/timelapse.py | 23 +- src/octoprint/server/{ajax => api}/users.py | 18 +- src/octoprint/server/util.py | 43 ++- src/octoprint/static/js/app/dataupdater.js | 10 + src/octoprint/static/js/app/main.js | 26 +- .../viewmodels/{gcodefiles.js => files.js} | 51 ++-- .../static/js/app/viewmodels/firstrun.js | 1 + .../static/js/app/viewmodels/gcode.js | 4 +- .../static/js/app/viewmodels/printerstate.js | 9 +- .../static/js/app/viewmodels/temperature.js | 2 +- .../static/js/app/viewmodels/timelapse.js | 7 +- src/octoprint/templates/index.jinja2 | 10 +- src/octoprint/timelapse.py | 45 +-- src/octoprint/util/comm.py | 153 +++++++--- 25 files changed, 720 insertions(+), 471 deletions(-) create mode 100644 docs/images/octoprint-logo.png delete mode 100644 src/octoprint/server/ajax/gcodefiles.py delete mode 100644 src/octoprint/server/api.py rename src/octoprint/server/{ajax => api}/__init__.py (85%) rename src/octoprint/server/{ajax => api}/control.py (77%) create mode 100644 src/octoprint/server/api/files.py rename src/octoprint/server/{ajax => api}/settings.py (98%) rename src/octoprint/server/{ajax => api}/timelapse.py (84%) rename src/octoprint/server/{ajax => api}/users.py (89%) rename src/octoprint/static/js/app/viewmodels/{gcodefiles.js => files.js} (84%) diff --git a/docs/images/octoprint-logo.png b/docs/images/octoprint-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..41f10936b6149752f647dc8651faff6dcd0d394f GIT binary patch literal 28062 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 literal 0 HcmV?d00001 diff --git a/src/octoprint/events.py b/src/octoprint/events.py index dc6fcef1..064b75f0 100644 --- a/src/octoprint/events.py +++ b/src/octoprint/events.py @@ -15,12 +15,72 @@ from octoprint.settings import settings # singleton _instance = None + +class Events(object): + # application startup + STARTUP = "Startup" + + # connect/disconnect to printer + CONNECTED = "Connected" + DISCONNECTED = "Disconnected" + + # connect/disconnect by client + CLIENT_OPENED = "ClientOpened" + CLIENT_CLOSED = "ClientClosed" + + # File management + UPLOAD = "Upload" + FILE_SELECTED = "FileSelected" + FILE_DESELECTED = "FileDeselected" + UPDATED_FILES = "UpdatedFiles" + METADATA_ANALYSIS_STARTED = "MetadataAnalysisStarted" + METADATA_ANALYSIS_FINISHED = "MetadataAnalysisFinished" + + # SD Upload + TRANSFER_STARTED = "TransferStarted" + TRANSFER_DONE = "TransferDone" + + # print job + PRINT_STARTED = "PrintStarted" + PRINT_DONE = "PrintDone" + PRINT_FAILED = "PrintFailed" + PRINT_CANCELLED = "PrintCancelled" + PRINT_PAUSED = "PrintPaused" + PRINT_RESUMED = "PrintResumed" + ERROR = "Error" + + # print/gcode events + POWER_ON = "PowerOn" + POWER_OFF = "PowerOff" + HOME = "Home" + Z_CHANGE = "ZChange" + WAITING = "Waiting" + COOLING = "Cooling" + ALERT = "Alert" + CONVEYOR = "Conveyor" + EJECT = "Eject" + E_STOP = "EStop" + + # Timelapse + CAPTURE_START = "CaptureStart" + CAPTURE_DONE = "CaptureDone" + MOVIE_RENDERING = "MovieRendering" + MOVIE_DONE = "MovieDone" + MOVIE_FAILED = "MovieFailed" + + # Slicing + SLICING_STARTED = "SlicingStarted" + SLICING_DONE = "SlicingDone" + SLICING_FAILED = "SlicingFailed" + + def eventManager(): global _instance if _instance is None: _instance = EventManager() return _instance + class EventManager(object): """ Handles receiving events and dispatching them to subscribers @@ -97,6 +157,7 @@ class EventManager(object): self._registeredListeners[event].remove(callback) self._logger.debug("Unsubscribed listener %r for event %s" % (callback, event)) + class GenericEventListener(object): """ The GenericEventListener can be subclassed to easily create custom event listeners. @@ -128,21 +189,19 @@ class GenericEventListener(object): """ pass + class DebugEventListener(GenericEventListener): def __init__(self): GenericEventListener.__init__(self) - events = ["Startup", "Connected", "Disconnected", "ClientOpen", "ClientClosed", "PowerOn", "PowerOff", "Upload", - "FileSelected", "TransferStarted", "TransferDone", "PrintStarted", "PrintDone", "PrintFailed", - "Cancelled", "Home", "ZChange", "Paused", "Waiting", "Cooling", "Alert", "Conveyor", "Eject", - "CaptureStart", "CaptureDone", "MovieRendering", "MovieDone", "MovieFailed", "EStop", "Error", - "SlicingStarted", "SlicingDone", "SlicingFailed", "UpdatedFiles"] + events = filter(lambda x: not x.startswith("__"), dir(Events)) self.subscribe(events) def eventCallback(self, event, payload): GenericEventListener.eventCallback(self, event, payload) self._logger.debug("Received event: %s (Payload: %r)" % (event, payload)) + class CommandTrigger(GenericEventListener): def __init__(self, triggerType, printer): GenericEventListener.__init__(self) @@ -244,6 +303,7 @@ class CommandTrigger(GenericEventListener): return command.format(**params) + class SystemCommandTrigger(CommandTrigger): """ Performs configured system commands for configured events. @@ -261,6 +321,7 @@ class SystemCommandTrigger(CommandTrigger): except Exception, ex: self._logger.exception("Command failed") + class GcodeCommandTrigger(CommandTrigger): """ Sends configured GCODE commands to the printer for configured events. diff --git a/src/octoprint/gcodefiles.py b/src/octoprint/gcodefiles.py index 3a1dca9d..22adc219 100644 --- a/src/octoprint/gcodefiles.py +++ b/src/octoprint/gcodefiles.py @@ -13,7 +13,7 @@ import octoprint.util as util import octoprint.util.gcodeInterpreter as gcodeInterpreter from octoprint.settings import settings -from octoprint.events import eventManager +from octoprint.events import eventManager, Events from werkzeug.utils import secure_filename @@ -121,7 +121,7 @@ class GcodeManager: self._metadata[basename] = metadata self._metadataDirty = True self._saveMetadata() - eventManager().fire("MetadataAnalysisFinished", {"filename": basename, "result": analysisResult}) + eventManager().fire(Events.METADATA_ANALYSIS_FINISHED, {"file": basename, "result": analysisResult}) def _loadMetadata(self): if os.path.exists(self._metadataFile) and os.path.isfile(self._metadataFile): @@ -192,8 +192,9 @@ class GcodeManager: return self.processGcode(absolutePath, destination, uploadCallback), True else: if curaEnabled and isSTLFileName(filename): - self.processStl(absolutePath, destination, uploadCallback) - return filename, False + return self.processStl(absolutePath, destination, uploadCallback), False + else: + return filename, False def getFutureFileName(self, file): if not file: @@ -216,17 +217,19 @@ class GcodeManager: def stlProcessed(stlPath, gcodePath, error=None): if error: - eventManager().fire("SlicingFailed", {"stl": self._getBasicFilename(stlPath), "gcode": self._getBasicFilename(gcodePath), "reason": error}) + eventManager().fire(Events.SLICING_FAILED, {"stl": self._getBasicFilename(stlPath), "gcode": self._getBasicFilename(gcodePath), "reason": error}) if os.path.exists(stlPath): os.remove(stlPath) else: slicingStop = time.time() - eventManager().fire("SlicingDone", {"stl": self._getBasicFilename(stlPath), "gcode": self._getBasicFilename(gcodePath), "time": "%.2f" % (slicingStop - slicingStart)}) + eventManager().fire(Events.SLICING_DONE, {"stl": self._getBasicFilename(stlPath), "gcode": self._getBasicFilename(gcodePath), "time": "%.2f" % (slicingStop - slicingStart)}) self.processGcode(gcodePath, destination, uploadCallback) - eventManager().fire("SlicingStarted", {"stl": self._getBasicFilename(absolutePath), "gcode": self._getBasicFilename(gcodePath)}) + eventManager().fire(Events.SLICING_STARTED, {"stl": self._getBasicFilename(absolutePath), "gcode": self._getBasicFilename(gcodePath)}) cura.process_file(config, gcodePath, absolutePath, stlProcessed, [absolutePath, gcodePath]) + return self._getBasicFilename(gcodePath) + def processGcode(self, absolutePath, destination, uploadCallback=None): if absolutePath is None: return None @@ -242,8 +245,9 @@ class GcodeManager: self._metadataAnalyzer.addFileToQueue(os.path.basename(absolutePath)) if uploadCallback is not None: - uploadCallback(filename, absolutePath, destination) - return filename + return uploadCallback(filename, absolutePath, destination) + else: + return filename def getFutureFilename(self, file): if not file: @@ -298,6 +302,9 @@ class GcodeManager: return secure + def getAllFilenames(self): + return map(lambda x: x["name"], self.getAllFileData()) + def getAllFileData(self): files = [] for osFile in os.listdir(self._uploadFolder): @@ -509,6 +516,7 @@ class MetadataAnalyzer: try: self._logger.debug("Starting analysis of file %s" % filename) + eventManager().fire(Events.METADATA_ANALYSIS_STARTED, {"file": filename}) self._gcode = gcodeInterpreter.gcode() self._gcode.progressCallback = self._onParsingProgress self._gcode.load(path) diff --git a/src/octoprint/printer.py b/src/octoprint/printer.py index 94d4e0ba..8f9711df 100644 --- a/src/octoprint/printer.py +++ b/src/octoprint/printer.py @@ -13,7 +13,7 @@ import octoprint.util.comm as comm import octoprint.util as util from octoprint.settings import settings -from octoprint.events import eventManager +from octoprint.events import eventManager, Events from octoprint.filemanager.destinations import FileDestinations @@ -163,7 +163,7 @@ class Printer(): if self._comm is not None: self._comm.close() self._comm = None - eventManager().fire("Disconnected") + eventManager().fire(Events.DISCONNECTED) def command(self, command): """ @@ -247,7 +247,13 @@ class Printer(): # mark print as failure if self._selectedFile is not None: self._gcodeManager.printFailed(self._selectedFile["filename"]) - eventManager().fire("PrintFailed", self._selectedFile["filename"]) + payload = { + "file": self._selectedFile["filename"], + "origin": FileDestinations.LOCAL + } + if self._selectedFile["sd"]: + payload["origin"] = FileDestinations.SDCARD + eventManager().fire(Events.PRINT_FAILED, payload) #~~ state monitoring @@ -357,11 +363,6 @@ class Printer(): pass def _getStateFlags(self): - if not settings().getBoolean(["feature", "sdSupport"]) or self._comm is None: - sdReady = False - else: - sdReady = self._comm.isSdReady() - return { "operational": self.isOperational(), "printing": self.isPrinting(), @@ -369,7 +370,7 @@ class Printer(): "error": self.isError(), "paused": self.isPaused(), "ready": self.isReady(), - "sdReady": sdReady + "sdReady": self.isSdReady() } def getCurrentData(self): @@ -428,7 +429,7 @@ class Printer(): if newZ != oldZ: # we have to react to all z-changes, even those that might "go backward" due to a slicer's retraction or # anti-backlash-routines. Event subscribes should individually take care to filter out "wrong" z-changes - eventManager().fire("ZChange", newZ) + eventManager().fire(Events.Z_CHANGE, {"new": newZ, "old": oldZ}) self._setCurrentZ(newZ) @@ -436,7 +437,7 @@ class Printer(): self._stateMonitor.setState({"state": self._state, "stateString": self.getStateString(), "flags": self._getStateFlags()}) def mcSdFiles(self, files): - eventManager().fire("UpdatedFiles", {"type": "gcode", "files": files}) + eventManager().fire(Events.UPDATED_FILES, {"type": "gcode", "files": files}) self._sdFilelistAvailable.set() def mcFileSelected(self, filename, filesize, sd): @@ -489,8 +490,10 @@ class Printer(): self.refreshSdFiles(blocking=True) existingSdFiles = self._comm.getSdFiles() - self._sdRemoteName = util.getDosFilename(filename, existingSdFiles) - self._comm.startFileTransfer(absolutePath, self._sdRemoteName) + remoteName = util.getDosFilename(filename, existingSdFiles) + self._comm.startFileTransfer(absolutePath, filename, remoteName) + + return remoteName def deleteSdFile(self, filename): if not self._comm or not self._comm.isSdReady(): @@ -583,6 +586,12 @@ class Printer(): def isReady(self): return self.isOperational() and not self._comm.isStreaming() + def isSdReady(self): + if not settings().getBoolean(["feature", "sdSupport"]) or self._comm is None: + return False + else: + return self._comm.isSdReady() + def isLoading(self): return self._gcodeLoader is not None diff --git a/src/octoprint/server/__init__.py b/src/octoprint/server/__init__.py index a6a34185..401e6f1d 100644 --- a/src/octoprint/server/__init__.py +++ b/src/octoprint/server/__init__.py @@ -3,7 +3,7 @@ __author__ = "Gina Häußge " __license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' from sockjs.tornado import SockJSRouter -from flask import Flask, render_template, send_from_directory +from flask import Flask, render_template, send_from_directory, make_response from flask.ext.login import LoginManager from flask.ext.principal import Principal, Permission, RoleNeed, identity_loaded, UserNeed @@ -12,6 +12,7 @@ import logging import logging.config SUCCESS = {} +NO_CONTENT = ("", 204) app = Flask("octoprint") debug = False @@ -164,23 +165,21 @@ class Server(): logger.info("Listening on http://%s:%d" % (self._host, self._port)) app.debug = self._debug - from octoprint.server.ajax import ajax from octoprint.server.api import api - app.register_blueprint(ajax, url_prefix="/ajax") app.register_blueprint(api, url_prefix="/api") self._router = SockJSRouter(self._createSocketConnection, "/sockjs") self._tornado_app = Application(self._router.urls + [ (r"/downloads/timelapse/([^/]*\.mpg)", LargeResponseHandler, {"path": settings().getBaseFolder("timelapse"), "as_attachment": True}), - (r"/downloads/gcode/([^/]*\.(gco|gcode))", LargeResponseHandler, {"path": settings().getBaseFolder("uploads"), "as_attachment": True}), + (r"/downloads/files/local/([^/]*\.(gco|gcode))", LargeResponseHandler, {"path": settings().getBaseFolder("uploads"), "as_attachment": True}), (r".*", FallbackHandler, {"fallback": WSGIContainer(app.wsgi_app)}) ]) self._server = HTTPServer(self._tornado_app) self._server.listen(self._port, address=self._host) - eventManager.fire("Startup") + eventManager.fire(events.Events.STARTUP) if settings().getBoolean(["serial", "autoconnect"]): (port, baudrate) = settings().get(["serial", "port"]), settings().getInt(["serial", "baudrate"]) connectionOptions = getConnectionOptions() diff --git a/src/octoprint/server/ajax/gcodefiles.py b/src/octoprint/server/ajax/gcodefiles.py deleted file mode 100644 index a08f515b..00000000 --- a/src/octoprint/server/ajax/gcodefiles.py +++ /dev/null @@ -1,195 +0,0 @@ -# coding=utf-8 -__author__ = "Gina Häußge " -__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' - -from flask import request, jsonify, make_response, url_for - -import octoprint.gcodefiles as gcodefiles -import octoprint.util as util -from octoprint.filemanager.destinations import FileDestinations -from octoprint.settings import settings, valid_boolean_trues -from octoprint.server import printer, gcodeManager, eventManager, restricted_access, SUCCESS -from octoprint.server.util import redirectToTornado -from octoprint.server.ajax import ajax - - -#~~ GCODE file handling - - -@ajax.route("/gcodefiles", methods=["GET"]) -def readGcodeFiles(): - files = _getFileList(FileDestinations.LOCAL) - files.extend(_getFileList(FileDestinations.SDCARD)) - return jsonify(files=files, free=util.getFormattedSize(util.getFreeBytes(settings().getBaseFolder("uploads")))) - - -@ajax.route("/gcodefiles/", methods=["GET"]) -def readGcodeFilesForTarget(origin): - if origin not in [FileDestinations.LOCAL, FileDestinations.SDCARD]: - return make_response("Invalid target: %s" % origin, 400) - - return jsonify(files=_getFileList(origin), free=util.getFormattedSize(util.getFreeBytes(settings().getBaseFolder("uploads")))) - - -def _getFileList(origin): - if origin == FileDestinations.SDCARD: - sdFileList = printer.getSdFiles() - - files = [] - if sdFileList is not None: - for sdFile in sdFileList: - files.append({ - "name": sdFile, - "size": "n/a", - "bytes": 0, - "date": "n/a", - "origin": FileDestinations.SDCARD - }) - else: - files = gcodeManager.getAllFileData() - return files - - -@ajax.route("/gcodefiles/", methods=["POST"]) -@restricted_access -def uploadGcodeFile(target): - if not target in [FileDestinations.LOCAL, FileDestinations.SDCARD]: - return make_response("Invalid target: %s" % target, 400) - - if "gcode_file" in request.files.keys(): - file = request.files["gcode_file"] - sd = target == FileDestinations.SDCARD - selectAfterUpload = "select" in request.values.keys() and request.values["select"] in valid_boolean_trues - printAfterSelect = "print" in request.values.keys() and request.values["print"] in valid_boolean_trues - - # determine current job - currentFilename = None - currentSd = None - currentJob = printer.getCurrentJob() - if currentJob is not None and "filename" in currentJob.keys() and "sd" in currentJob.keys(): - currentFilename = currentJob["filename"] - currentSd = currentJob["sd"] - - # determine future filename of file to be uploaded, abort if it can't be uploaded - futureFilename = gcodeManager.getFutureFilename(file) - if futureFilename is None or (not settings().getBoolean(["cura", "enabled"]) and not gcodefiles.isGcodeFileName(futureFilename)): - return make_response("Can not upload file %s, wrong format?" % file.filename, 400) - - # prohibit overwriting currently selected file while it's being printed - if futureFilename == currentFilename and sd == currentSd and printer.isPrinting() or printer.isPaused(): - return make_response("Trying to overwrite file that is currently being printed: %s" % currentFilename, 403) - - filename = None - - def fileProcessingFinished(filename, absFilename, destination): - """ - Callback for when the file processing (upload, optional slicing, addition to analysis queue) has - finished. - - Depending on the file's destination triggers either streaming to SD card or directly calls selectOrPrint. - """ - sd = destination == FileDestinations.SDCARD - if sd: - printer.addSdFile(filename, absFilename, selectAndOrPrint) - else: - selectAndOrPrint(absFilename, destination) - - def selectAndOrPrint(nameToSelect, destination): - """ - Callback for when the file is ready to be selected and optionally printed. For SD file uploads this only - the case after they have finished streaming to the printer, which is why this callback is also used - for the corresponding call to addSdFile. - - Selects the just uploaded file if either selectAfterUpload or printAfterSelect are True, or if the - exact file is already selected, such reloading it. - """ - sd = destination == FileDestinations.SDCARD - if selectAfterUpload or printAfterSelect or (currentFilename == filename and currentSd == sd): - printer.selectFile(nameToSelect, sd, printAfterSelect) - - destination = FileDestinations.SDCARD if sd else FileDestinations.LOCAL - filename, done = gcodeManager.addFile(file, destination, fileProcessingFinished) - if filename is None: - return make_response("Could not upload the file %s" % file.filename, 500) - - eventManager.fire("Upload", filename) - return jsonify(files=gcodeManager.getAllFileData(), filename=filename, done=done) - else: - return make_response("No gcode_file included", 400) - - -@ajax.route("/gcodefiles/local/", methods=["GET"]) -def readGcodeFile(filename): - return redirectToTornado(request, url_for("index") + "downloads/gcode/" + filename) - - -@ajax.route("/gcodefiles//", methods=["POST"]) -@restricted_access -def gcodeFileCommand(filename, target): - if not target in [FileDestinations.LOCAL, FileDestinations.SDCARD]: - return make_response("Invalid target: %s" % target, 400) - - # valid file commands, dict mapping command name to mandatory parameters - valid_commands = { - "load": [] - } - - command, data, response = util.getJsonCommandFromRequest(request, valid_commands) - if response is not None: - return response - - if command == "load": - # selects/loads a file - printAfterLoading = False - if "print" in data.keys() and data["print"]: - printAfterLoading = True - - sd = False - if target == FileDestinations.SDCARD: - filenameToSelect = filename - sd = True - else: - filenameToSelect = gcodeManager.getAbsolutePath(filename) - printer.selectFile(filenameToSelect, sd, printAfterLoading) - return jsonify(SUCCESS) - - return make_response("Command %s is currently not implemented" % command, 400) - - -@ajax.route("/gcodefiles//", methods=["DELETE"]) -@restricted_access -def deleteGcodeFile(filename, target): - if not target in [FileDestinations.LOCAL, FileDestinations.SDCARD]: - return make_response("Invalid target: %s" % target, 400) - - sd = target == FileDestinations.SDCARD - - currentJob = printer.getCurrentJob() - currentFilename = None - currentSd = None - if currentJob is not None and "filename" in currentJob.keys() and "sd" in currentJob.keys(): - currentFilename = currentJob["filename"] - currentSd = currentJob["sd"] - - if currentFilename is not None and filename == currentFilename and not (printer.isPrinting() or printer.isPaused()): - printer.unselectFile() - - if not (currentFilename == filename and currentSd == sd and (printer.isPrinting() or printer.isPaused())): - if sd: - printer.deleteSdFile(filename) - else: - gcodeManager.removeFile(filename) - return readGcodeFiles() - - -@ajax.route("/gcodefiles//refresh", methods=["POST"]) -@restricted_access -def refreshFiles(target): - if not target in [FileDestinations.LOCAL, FileDestinations.SDCARD]: - return make_response("Invalid target: %s" % target, 400) - - if target == FileDestinations.SDCARD: - printer.updateSdFiles() - - return jsonify(SUCCESS) - diff --git a/src/octoprint/server/api.py b/src/octoprint/server/api.py deleted file mode 100644 index acbfd760..00000000 --- a/src/octoprint/server/api.py +++ /dev/null @@ -1,55 +0,0 @@ -# coding=utf-8 -__author__ = "Gina Häußge " -__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' - -import logging - -from flask import Blueprint, request, jsonify, abort - -from octoprint.server import printer, gcodeManager, SUCCESS -from octoprint.server.util import api_access -from octoprint.settings import valid_boolean_trues -from octoprint.filemanager.destinations import FileDestinations -import octoprint.gcodefiles as gcodefiles - -api = Blueprint("api", __name__) - - - -@api.route("/load", methods=["POST"]) -@api_access -def apiLoad(): - logger = logging.getLogger(__name__) - - if not "file" in request.files.keys(): - abort(400) - - # Perform an upload - f = request.files["file"] - if not gcodefiles.isGcodeFileName(f.filename): - abort(400) - - destination = FileDestinations.LOCAL - filename, done = gcodeManager.addFile(f, destination) - if filename is None: - logger.warn("Upload via API failed") - abort(500) - - # Immediately perform a file select and possibly print too - printAfterSelect = False - if "print" in request.values.keys() and request.values["print"] in valid_boolean_trues: - printAfterSelect = True - filepath = gcodeManager.getAbsolutePath(filename) - if filepath is not None: - printer.selectFile(filepath, False, printAfterSelect) - return jsonify(SUCCESS) - -@api.route("/state", methods=["GET"]) -@api_access -def apiPrinterState(): - currentData = printer.getCurrentData() - currentData.update({ - "temperatures": printer.getCurrentTemperatures() - }) - return jsonify(currentData) - diff --git a/src/octoprint/server/ajax/__init__.py b/src/octoprint/server/api/__init__.py similarity index 85% rename from src/octoprint/server/ajax/__init__.py rename to src/octoprint/server/api/__init__.py index cd921b8f..c72cbd8b 100644 --- a/src/octoprint/server/ajax/__init__.py +++ b/src/octoprint/server/api/__init__.py @@ -12,24 +12,24 @@ from flask.ext.principal import Identity, identity_changed, AnonymousIdentity import octoprint.util as util import octoprint.users -from octoprint.server import restricted_access, SUCCESS, admin_permission, loginManager, principals +from octoprint.server import printer, restricted_access, SUCCESS, admin_permission, loginManager, principals from octoprint.settings import settings as s, valid_boolean_trues -#~~ init ajax blueprint, including sub modules +#~~ init api blueprint, including sub modules -ajax = Blueprint("ajax", __name__) +api = Blueprint("api", __name__) -from . import control as ajax_control -from . import gcodefiles as ajax_gcodefiles -from . import settings as ajax_settings -from . import timelapse as ajax_timelapse -from . import users as ajax_users +from . import control as api_control +from . import files as api_files +from . import settings as api_settings +from . import timelapse as api_timelapse +from . import users as api_users #~~ first run setup -@ajax.route("/setup", methods=["POST"]) +@api.route("/setup", methods=["POST"]) def firstRunSetup(): if not s().getBoolean(["server", "firstRun"]): abort(403) @@ -52,11 +52,23 @@ def firstRunSetup(): s().save() return jsonify(SUCCESS) +#~~ system state + + +@api.route("/state", methods=["GET"]) +@restricted_access +def apiPrinterState(): + currentData = printer.getCurrentData() + currentData.update({ + "temperatures": printer.getCurrentTemperatures() + }) + return jsonify(currentData) + #~~ system control -@ajax.route("/system", methods=["POST"]) +@api.route("/system", methods=["POST"]) @restricted_access @admin_permission.require(403) def performSystemAction(): @@ -81,7 +93,7 @@ def performSystemAction(): #~~ Login/user handling -@ajax.route("/login", methods=["POST"]) +@api.route("/login", methods=["POST"]) def login(): if octoprint.server.userManager is not None and "user" in request.values.keys() and "pass" in request.values.keys(): username = request.values["user"] @@ -127,7 +139,7 @@ def login(): return jsonify(SUCCESS) -@ajax.route("/logout", methods=["POST"]) +@api.route("/logout", methods=["POST"]) @restricted_access def logout(): # Remove session keys set by Flask-Principal diff --git a/src/octoprint/server/ajax/control.py b/src/octoprint/server/api/control.py similarity index 77% rename from src/octoprint/server/ajax/control.py rename to src/octoprint/server/api/control.py index e283a3c2..b5171849 100644 --- a/src/octoprint/server/ajax/control.py +++ b/src/octoprint/server/api/control.py @@ -6,15 +6,16 @@ from flask import request, jsonify, make_response from octoprint.settings import settings from octoprint.printer import getConnectionOptions -from octoprint.server import printer, restricted_access, SUCCESS -from octoprint.server.ajax import ajax +from octoprint.server import printer, restricted_access, NO_CONTENT +from octoprint.server.api import api import octoprint.util as util + #~~ Printer control -@ajax.route("/control/connection", methods=["GET"]) -def connectionOptions(): +@api.route("/control/connection", methods=["GET"]) +def connectionState(): state, port, baudrate = printer.getCurrentConnection() current = { "state": state, @@ -24,7 +25,7 @@ def connectionOptions(): return jsonify({"current": current, "options": getConnectionOptions()}) -@ajax.route("/control/connection", methods=["POST"]) +@api.route("/control/connection", methods=["POST"]) @restricted_access def connectionCommand(): valid_commands = { @@ -52,20 +53,22 @@ def connectionCommand(): if "save" in data.keys() and data["save"]: settings().set(["serial", "port"], port) settings().setInt(["serial", "baudrate"], baudrate) - settings().setBoolean(["serial", "autoconnect"], data["autoconnect"]) + if "autoconnect" in data.keys(): + settings().setBoolean(["serial", "autoconnect"], data["autoconnect"]) settings().save() printer.connect(port=port, baudrate=baudrate) elif command == "disconnect": printer.disconnect() - return jsonify(SUCCESS) + return NO_CONTENT -@ajax.route("/control/printer/command", methods=["POST"]) +@api.route("/control/printer/command", methods=["POST"]) @restricted_access def printerCommand(): + # TODO: document me if not printer.isOperational(): - return make_response("Printer is not operational", 403) + return make_response("Printer is not operational", 409) if not "application/json" in request.headers["Content-Type"]: return make_response("Expected content type JSON", 400) @@ -88,17 +91,18 @@ def printerCommand(): printer.commands(commandsToSend) - return jsonify(SUCCESS) + return NO_CONTENT -@ajax.route("/control/job", methods=["POST"]) +@api.route("/control/job", methods=["POST"]) @restricted_access def controlJob(): if not printer.isOperational(): - return make_response("Printer is not operational", 403) + return make_response("Printer is not operational", 409) valid_commands = { "start": [], + "restart": [], "pause": [], "cancel": [] } @@ -107,20 +111,32 @@ def controlJob(): if response is not None: return response + activePrintjob = printer.isPrinting() or printer.isPaused() + if command == "start": + if activePrintjob: + return make_response("Printer already has an active print job, did you mean 'restart'?", 409) + printer.startPrint() + elif command == "restart": + if not printer.isPaused(): + return make_response("Printer does not have an active print job or is not paused", 409) printer.startPrint() elif command == "pause": + if not activePrintjob: + return make_response("Printer is neither printing nor paused, 'pause' command cannot be performed", 409) printer.togglePausePrint() elif command == "cancel": + if not activePrintjob: + return make_response("Printer is neither printing nor paused, 'cancel' command cannot be performed", 409) printer.cancelPrint() - return jsonify(SUCCESS) + return NO_CONTENT -@ajax.route("/control/printer/hotend", methods=["POST"]) +@api.route("/control/printer/heater", methods=["POST"]) @restricted_access def controlPrinterHotend(): if not printer.isOperational(): - return make_response("Printer is not operational", 403) + return make_response("Printer is not operational", 409) valid_commands = { "temp": ["temps"], @@ -175,15 +191,15 @@ def controlPrinterHotend(): elif "bed" in validated_values: printer.setTemperatureOffset(None, validated_values["bed"]) - return jsonify(SUCCESS) + return NO_CONTENT -@ajax.route("/control/printer/printhead", methods=["POST"]) +@api.route("/control/printer/printhead", methods=["POST"]) @restricted_access def controlPrinterPrinthead(): if not printer.isOperational() or printer.isPrinting(): # do not jog when a print job is running or we don't have a connection - return make_response("Printer is not operational or currently printing", 403) + return make_response("Printer is not operational or currently printing", 409) valid_commands = { "jog": [], @@ -225,15 +241,15 @@ def controlPrinterPrinthead(): # TODO make this a generic method call (printer.home(axis, ...)) to get rid of gcode here printer.commands(["G91", "G28 %s" % " ".join(map(lambda x: "%s0" % x.upper(), validated_values)), "G90"]) - return jsonify(SUCCESS) + return NO_CONTENT -@ajax.route("/control/printer/feeder", methods=["POST"]) +@api.route("/control/printer/feeder", methods=["POST"]) @restricted_access def controlPrinterFeeder(): if not printer.isOperational() or printer.isPrinting(): # do not jog when a print job is running or we don't have a connection - return make_response("Printer is not operational or currently printing", 403) + return make_response("Printer is not operational or currently printing", 409) valid_commands = { "extrude": ["amount"] @@ -252,20 +268,23 @@ def controlPrinterFeeder(): # TODO make this a generic method call (printer.extruder([hotend,] amount)) to get rid of gcode here printer.commands(["G91", "G1 E%s F%d" % (data["amount"], extrusionSpeed), "G90"]) - return jsonify(SUCCESS) + return NO_CONTENT - -@ajax.route("/control/custom", methods=["GET"]) +@api.route("/control/custom", methods=["GET"]) def getCustomControls(): + # TODO: document me customControls = settings().get(["controls"]) return jsonify(controls=customControls) -@ajax.route("/control/sd", methods=["POST"]) +@api.route("/control/printer/sd", methods=["POST"]) @restricted_access def sdCommand(): - if not settings().getBoolean(["feature", "sdSupport"]) or not printer.isOperational() or printer.isPrinting(): - return make_response("SD support is disabled", 403) + if not settings().getBoolean(["feature", "sdSupport"]): + return make_response("SD support is disabled", 404) + + if not printer.isOperational() or printer.isPrinting() or printer.isPaused(): + return make_response("Printer is not operational or currently busy", 409) valid_commands = { "init": [], @@ -283,6 +302,12 @@ def sdCommand(): elif command == "release": printer.releaseSdCard() - return jsonify(SUCCESS) + return NO_CONTENT +@api.route("/control/printer/sd", methods=["GET"]) +def sdState(): + if not settings().getBoolean(["feature", "sdSupport"]): + return make_response("SD support is disabled", 404) + + return jsonify(ready=printer.isSdReady()) diff --git a/src/octoprint/server/api/files.py b/src/octoprint/server/api/files.py new file mode 100644 index 00000000..8918601d --- /dev/null +++ b/src/octoprint/server/api/files.py @@ -0,0 +1,275 @@ +# coding=utf-8 +from octoprint.events import Events + +__author__ = "Gina Häußge " +__license__ = 'GNU Affero General Public License http://www.gnu.org/licenses/agpl.html' + +from flask import request, jsonify, make_response, url_for + +import octoprint.gcodefiles as gcodefiles +import octoprint.util as util +from octoprint.filemanager.destinations import FileDestinations +from octoprint.settings import settings, valid_boolean_trues +from octoprint.server import printer, gcodeManager, eventManager, restricted_access, NO_CONTENT +from octoprint.server.util import redirectToTornado, urlForDownload +from octoprint.server.api import api + + +#~~ GCODE file handling + + +@api.route("/files", methods=["GET"]) +def readGcodeFiles(): + files = _getFileList(FileDestinations.LOCAL) + files.extend(_getFileList(FileDestinations.SDCARD)) + return jsonify(files=files, free=util.getFormattedSize(util.getFreeBytes(settings().getBaseFolder("uploads")))) + + +@api.route("/files/", methods=["GET"]) +def readGcodeFilesForOrigin(origin): + if origin not in [FileDestinations.LOCAL, FileDestinations.SDCARD]: + return make_response("Unknown origin: %s" % origin, 404) + + files = _getFileList(origin) + + if origin == FileDestinations.LOCAL: + return jsonify(files=files, free=util.getFormattedSize(util.getFreeBytes(settings().getBaseFolder("uploads")))) + else: + return jsonify(files=files) + + +def _getFileDetails(origin, filename): + files = _getFileList(origin) + for file in files: + if file["name"] == filename: + return file + return None + + +def _getFileList(origin): + if origin == FileDestinations.SDCARD: + sdFileList = printer.getSdFiles() + + files = [] + if sdFileList is not None: + for sdFile in sdFileList: + files.append({ + "name": sdFile, + "origin": FileDestinations.SDCARD, + "refs": { + "resource": url_for(".readGcodeFile", target=FileDestinations.SDCARD, filename=sdFile, _external=True) + } + }) + else: + files = gcodeManager.getAllFileData() + for file in files: + file.update({ + "origin": FileDestinations.LOCAL, + "refs": { + "resource": url_for(".readGcodeFile", target=FileDestinations.LOCAL, filename=file["name"], _external=True), + "download": urlForDownload(FileDestinations.LOCAL, file["name"]) + } + }) + return files + + +def _verifyFileExists(origin, filename): + if origin == FileDestinations.SDCARD: + availableFiles = printer.getSdFiles() + else: + availableFiles = gcodeManager.getAllFilenames() + + return filename in availableFiles + + +@api.route("/files/", methods=["POST"]) +@restricted_access +def uploadGcodeFile(target): + if not target in [FileDestinations.LOCAL, FileDestinations.SDCARD]: + return make_response("Unknown target: %s" % target, 404) + + if not "file" in request.files.keys(): + return make_response("No file included", 400) + + if target == FileDestinations.SDCARD and not settings().getBoolean(["feature", "sdSupport"]): + return make_response("SD card support is disabled", 404) + + file = request.files["file"] + sd = target == FileDestinations.SDCARD + selectAfterUpload = "select" in request.values.keys() and request.values["select"] in valid_boolean_trues + printAfterSelect = "print" in request.values.keys() and request.values["print"] in valid_boolean_trues + + if sd: + # validate that all preconditions for SD upload are met before attempting it + if not (printer.isOperational() and not (printer.isPrinting() or printer.isPaused())): + return make_response("Can not upload to SD card, printer is either not operational or already busy", 409) + if not printer.isSdReady(): + return make_response("Can not upload to SD card, not yet initialized", 409) + + # determine current job + currentFilename = None + currentSd = None + currentJob = printer.getCurrentJob() + if currentJob is not None and "filename" in currentJob.keys() and "sd" in currentJob.keys(): + currentFilename = currentJob["filename"] + currentSd = currentJob["sd"] + + # determine future filename of file to be uploaded, abort if it can't be uploaded + futureFilename = gcodeManager.getFutureFilename(file) + if futureFilename is None or (not settings().getBoolean(["cura", "enabled"]) and not gcodefiles.isGcodeFileName(futureFilename)): + return make_response("Can not upload file %s, wrong format?" % file.filename, 415) + + # prohibit overwriting currently selected file while it's being printed + if futureFilename == currentFilename and sd == currentSd and printer.isPrinting() or printer.isPaused(): + return make_response("Trying to overwrite file that is currently being printed: %s" % currentFilename, 409) + + filename = None + + def fileProcessingFinished(filename, absFilename, destination): + """ + Callback for when the file processing (upload, optional slicing, addition to analysis queue) has + finished. + + Depending on the file's destination triggers either streaming to SD card or directly calls selectAndOrPrint. + """ + if destination == FileDestinations.SDCARD: + return filename, printer.addSdFile(filename, absFilename, selectAndOrPrint) + else: + selectAndOrPrint(absFilename, destination) + return filename + + def selectAndOrPrint(nameToSelect, destination): + """ + Callback for when the file is ready to be selected and optionally printed. For SD file uploads this is only + the case after they have finished streaming to the printer, which is why this callback is also used + for the corresponding call to addSdFile. + + Selects the just uploaded file if either selectAfterUpload or printAfterSelect are True, or if the + exact file is already selected, such reloading it. + """ + sd = destination == FileDestinations.SDCARD + if selectAfterUpload or printAfterSelect or (currentFilename == filename and currentSd == sd): + printer.selectFile(nameToSelect, sd, printAfterSelect) + + destination = FileDestinations.SDCARD if sd else FileDestinations.LOCAL + filename, done = gcodeManager.addFile(file, destination, fileProcessingFinished) + if filename is None: + return make_response("Could not upload the file %s" % file.filename, 500) + + sdFilename = None + if isinstance(filename, tuple): + filename, sdFilename = filename + + eventManager.fire(Events.UPLOAD, {"file": filename, "target": target}) + + files = {} + if done: + files.update({ + FileDestinations.LOCAL: { + "name": filename, + "origin": FileDestinations.LOCAL, + "refs": { + "resource": url_for(".readGcodeFile", target=FileDestinations.LOCAL, filename=filename, _external=True), + "download": urlForDownload(FileDestinations.LOCAL, filename) + } + } + }) + + if sd and sdFilename: + files.update({ + FileDestinations.SDCARD: { + "name": sdFilename, + "origin": FileDestinations.SDCARD, + "refs": { + "resource": url_for(".readGcodeFile", target=FileDestinations.SDCARD, filename=sdFilename, _external=True) + } + } + }) + + return make_response(jsonify(files=files, done=done), 201) + + +@api.route("/files//", methods=["GET"]) +def readGcodeFile(target, filename): + if not target in [FileDestinations.LOCAL, FileDestinations.SDCARD]: + return make_response("Unknown target: %s" % target, 404) + + file = _getFileDetails(target, filename) + if not file: + return make_response("File not found on '%s': %s" % (target, filename), 404) + + return jsonify(file) + + +@api.route("/files//", methods=["POST"]) +@restricted_access +def gcodeFileCommand(filename, target): + if not target in [FileDestinations.LOCAL, FileDestinations.SDCARD]: + return make_response("Unknown target: %s" % target, 404) + + if not _verifyFileExists(target, filename): + return make_response("File not found on '%s': %s" % (target, filename), 404) + + # valid file commands, dict mapping command name to mandatory parameters + valid_commands = { + "select": [] + } + + command, data, response = util.getJsonCommandFromRequest(request, valid_commands) + if response is not None: + return response + + if command == "select": + # selects/loads a file + printAfterLoading = False + if "print" in data.keys() and data["print"]: + if not printer.isOperational(): + return make_response("Printer is not operational, cannot directly start printing", 409) + printAfterLoading = True + + sd = False + if target == FileDestinations.SDCARD: + filenameToSelect = filename + sd = True + else: + filenameToSelect = gcodeManager.getAbsolutePath(filename) + printer.selectFile(filenameToSelect, sd, printAfterLoading) + + return NO_CONTENT + + +@api.route("/files//", methods=["DELETE"]) +@restricted_access +def deleteGcodeFile(filename, target): + if not target in [FileDestinations.LOCAL, FileDestinations.SDCARD]: + return make_response("Unknown target: %s" % target, 404) + + if not _verifyFileExists(target, filename): + return make_response("File not found on '%s': %s" % (target, filename), 404) + + sd = target == FileDestinations.SDCARD + + currentJob = printer.getCurrentJob() + currentFilename = None + currentSd = None + if currentJob is not None and "filename" in currentJob.keys() and "sd" in currentJob.keys(): + currentFilename = currentJob["filename"] + currentSd = currentJob["sd"] + + # prohibit deleting the file that is currently being printed + if currentFilename == filename and currentSd == sd and (printer.isPrinting() or printer.isPaused()): + make_response("Trying to delete file that is currently being printed: %s" % filename, 409) + + # deselect the file if it's currently selected + if currentFilename is not None and filename == currentFilename: + printer.unselectFile() + + # delete it + if sd: + printer.deleteSdFile(filename) + else: + gcodeManager.removeFile(filename) + + # return an updated list of files + return readGcodeFiles() + diff --git a/src/octoprint/server/ajax/settings.py b/src/octoprint/server/api/settings.py similarity index 98% rename from src/octoprint/server/ajax/settings.py rename to src/octoprint/server/api/settings.py index a9001898..3203e827 100644 --- a/src/octoprint/server/ajax/settings.py +++ b/src/octoprint/server/api/settings.py @@ -10,13 +10,13 @@ from octoprint.settings import settings from octoprint.printer import getConnectionOptions from octoprint.server import restricted_access, admin_permission -from octoprint.server.ajax import ajax +from octoprint.server.api import api #~~ settings -@ajax.route("/settings", methods=["GET"]) +@api.route("/settings", methods=["GET"]) def getSettings(): s = settings() @@ -91,7 +91,7 @@ def getSettings(): }) -@ajax.route("/settings", methods=["POST"]) +@api.route("/settings", methods=["POST"]) @restricted_access @admin_permission.require(403) def setSettings(): diff --git a/src/octoprint/server/ajax/timelapse.py b/src/octoprint/server/api/timelapse.py similarity index 84% rename from src/octoprint/server/ajax/timelapse.py rename to src/octoprint/server/api/timelapse.py index 5b5e1f87..45aab87c 100644 --- a/src/octoprint/server/ajax/timelapse.py +++ b/src/octoprint/server/api/timelapse.py @@ -13,43 +13,42 @@ from octoprint.settings import settings, valid_boolean_trues from octoprint.server import restricted_access, admin_permission from octoprint.server.util import redirectToTornado -from octoprint.server.ajax import ajax +from octoprint.server.api import api #~~ timelapse handling -@ajax.route("/timelapse", methods=["GET"]) +@api.route("/timelapse", methods=["GET"]) def getTimelapseData(): timelapse = octoprint.timelapse.current type = "off" - additionalConfig = {} + config = {"type": "off"} if timelapse is not None and isinstance(timelapse, octoprint.timelapse.ZTimelapse): - type = "zchange" + config["type"] = "zchange" elif timelapse is not None and isinstance(timelapse, octoprint.timelapse.TimedTimelapse): - type = "timed" - additionalConfig = { + config["type"] = "timed" + config.update({ "interval": timelapse.interval() - } + }) files = octoprint.timelapse.getFinishedTimelapses() for file in files: file["url"] = url_for("index") + "downloads/timelapse/" + file["name"] return jsonify({ - "type": type, - "config": additionalConfig, + "config": config, "files": files }) -@ajax.route("/timelapse/", methods=["GET"]) +@api.route("/timelapse/", methods=["GET"]) def downloadTimelapse(filename): return redirectToTornado(request, url_for("index") + "downloads/timelapse/" + filename) -@ajax.route("/timelapse/", methods=["DELETE"]) +@api.route("/timelapse/", methods=["DELETE"]) @restricted_access def deleteTimelapse(filename): if util.isAllowedFile(filename, {"mpg"}): @@ -59,7 +58,7 @@ def deleteTimelapse(filename): return getTimelapseData() -@ajax.route("/timelapse", methods=["POST"]) +@api.route("/timelapse", methods=["POST"]) @restricted_access def setTimelapseConfig(): if "type" in request.values: diff --git a/src/octoprint/server/ajax/users.py b/src/octoprint/server/api/users.py similarity index 89% rename from src/octoprint/server/ajax/users.py rename to src/octoprint/server/api/users.py index ed287f44..9228acc9 100644 --- a/src/octoprint/server/ajax/users.py +++ b/src/octoprint/server/api/users.py @@ -8,13 +8,13 @@ from flask.ext.login import current_user import octoprint.users as users from octoprint.server import restricted_access, SUCCESS, admin_permission, userManager -from octoprint.server.ajax import ajax +from octoprint.server.api import api #~~ user settings -@ajax.route("/users", methods=["GET"]) +@api.route("/users", methods=["GET"]) @restricted_access @admin_permission.require(403) def getUsers(): @@ -24,7 +24,7 @@ def getUsers(): return jsonify({"users": userManager.getAllUsers()}) -@ajax.route("/users", methods=["POST"]) +@api.route("/users", methods=["POST"]) @restricted_access @admin_permission.require(403) def addUser(): @@ -49,7 +49,7 @@ def addUser(): return getUsers() -@ajax.route("/users/", methods=["GET"]) +@api.route("/users/", methods=["GET"]) @restricted_access def getUser(username): if userManager is None: @@ -65,7 +65,7 @@ def getUser(username): abort(403) -@ajax.route("/users/", methods=["PUT"]) +@api.route("/users/", methods=["PUT"]) @restricted_access @admin_permission.require(403) def updateUser(username): @@ -91,7 +91,7 @@ def updateUser(username): abort(404) -@ajax.route("/users/", methods=["DELETE"]) +@api.route("/users/", methods=["DELETE"]) @restricted_access @admin_permission.require(http_exception=403) def removeUser(username): @@ -105,7 +105,7 @@ def removeUser(username): abort(404) -@ajax.route("/users//password", methods=["PUT"]) +@api.route("/users//password", methods=["PUT"]) @restricted_access def changePasswordForUser(username): if userManager is None: @@ -124,7 +124,7 @@ def changePasswordForUser(username): return make_response(("Forbidden", 403, [])) -@ajax.route("/users//apikey", methods=["DELETE"]) +@api.route("/users//apikey", methods=["DELETE"]) @restricted_access def deleteApikeyForUser(username): if userManager is None: @@ -140,7 +140,7 @@ def deleteApikeyForUser(username): return make_response(("Forbidden", 403, [])) -@ajax.route("/users//apikey", methods=["POST"]) +@api.route("/users//apikey", methods=["POST"]) @restricted_access def generateApikeyForUser(username): if userManager is None: diff --git a/src/octoprint/server/util.py b/src/octoprint/server/util.py index 51b1eeca..befa1285 100644 --- a/src/octoprint/server/util.py +++ b/src/octoprint/server/util.py @@ -19,6 +19,7 @@ from octoprint.settings import settings import octoprint.timelapse import octoprint.server from octoprint.users import ApiUser +from octoprint.events import Events def restricted_access(func, apiEnabled=True): """ @@ -45,10 +46,8 @@ def restricted_access(func, apiEnabled=True): return make_response("OctoPrint isn't setup yet", 403) # if API is globally enabled, enabled for this request and an api key is provided, try to use that - if settings().get(["api", "enabled"]) and apiEnabled and "apikey" in request.values.keys(): - apikey = request.values["apikey"] - user = None - + apikey = _getApiKey(request) + if settings().get(["api", "enabled"]) and apiEnabled and apikey is not None: if apikey == settings().get(["api", "key"]): # master key was used user = ApiUser() @@ -57,7 +56,7 @@ def restricted_access(func, apiEnabled=True): user = octoprint.server.userManager.findUser(apikey=apikey) if user is None: - make_response("Invalid API key", 403) + make_response("Invalid API key", 401) if login_user(user, remember=False): identity_changed.send(current_app._get_current_object(), identity=Identity(user.get_id())) return func(*args, **kwargs) @@ -72,19 +71,30 @@ def api_access(func): def decorated_view(*args, **kwargs): if not settings().get(["api", "enabled"]): make_response("API disabled", 401) - if not "apikey" in request.values.keys(): + apikey = _getApiKey(request) + if apikey is None: make_response("No API key provided", 401) - if request.values["apikey"] != settings().get(["api", "key"]): + if apikey != settings().get(["api", "key"]): make_response("Invalid API key", 403) return func(*args, **kwargs) return decorated_view + +def _getApiKey(request): + if "apikey" in request.values: + return request.values["apikey"] + if "X-Api-Key" in request.headers.keys(): + return request.headers.get("X-Api-Key") + return None + + #~~ Printer state class PrinterStateConnection(SockJSConnection): - EVENTS = ["UpdatedFiles", "MetadataAnalysisFinished", "MovieRendering", "MovieDone", - "MovieFailed", "SlicingStarted", "SlicingDone", "SlicingFailed"] + EVENTS = [Events.UPDATED_FILES, Events.METADATA_ANALYSIS_FINISHED, Events.MOVIE_RENDERING, Events.MOVIE_DONE, + Events.MOVIE_FAILED, Events.SLICING_STARTED, Events.SLICING_DONE, Events.SLICING_FAILED, + Events.TRANSFER_STARTED, Events.TRANSFER_DONE] def __init__(self, printer, gcodeManager, userManager, eventManager, session): SockJSConnection.__init__(self, session) @@ -110,24 +120,25 @@ class PrinterStateConnection(SockJSConnection): return info.ip def on_open(self, info): - self._logger.info("New connection from client: %s" % self._getRemoteAddress(info)) + remoteAddress = self._getRemoteAddress(info) + self._logger.info("New connection from client: %s" % remoteAddress) self._printer.registerCallback(self) self._gcodeManager.registerCallback(self) octoprint.timelapse.registerCallback(self) - self._eventManager.fire("ClientOpened") + self._eventManager.fire(Events.CLIENT_OPENED, {"remoteAddress": remoteAddress}) for event in PrinterStateConnection.EVENTS: self._eventManager.subscribe(event, self._onEvent) octoprint.timelapse.notifyCallbacks(octoprint.timelapse.current) def on_close(self): - self._logger.info("Closed client connection") + self._logger.info("Client connection closed") self._printer.unregisterCallback(self) self._gcodeManager.unregisterCallback(self) octoprint.timelapse.unregisterCallback(self) - self._eventManager.fire("ClientClosed") + self._eventManager.fire(Events.CLIENT_CLOSED) for event in PrinterStateConnection.EVENTS: self._eventManager.unsubscribe(event, self._onEvent) @@ -314,7 +325,7 @@ class ReverseProxied(object): def redirectToTornado(request, target): requestUrl = request.url - appBaseUrl = requestUrl[:requestUrl.find(url_for("index") + "/ajax")] + appBaseUrl = requestUrl[:requestUrl.find(url_for("index") + "api")] redirectUrl = appBaseUrl + target if "?" in requestUrl: @@ -322,3 +333,7 @@ def redirectToTornado(request, target): redirectUrl += fragment return redirect(redirectUrl) + +def urlForDownload(origin, filename): + return url_for("index", _external=True) + "downloads/files/" + origin + "/" + filename + diff --git a/src/octoprint/static/js/app/dataupdater.js b/src/octoprint/static/js/app/dataupdater.js index bb95739a..30f66eed 100644 --- a/src/octoprint/static/js/app/dataupdater.js +++ b/src/octoprint/static/js/app/dataupdater.js @@ -133,6 +133,16 @@ function DataUpdater(loginStateViewModel, connectionViewModel, printerStateViewM gcodeUploadProgressBar.css("width", "0%"); gcodeUploadProgressBar.text(""); $.pnotify({title: "Slicing failed", text: "Could not slice " + payload.stl + " to " + payload.gcode + ": " + payload.reason, type: "error"}); + } else if (type == "TransferStarted") { + gcodeUploadProgress.addClass("progress-striped").addClass("active"); + gcodeUploadProgressBar.css("width", "100%"); + gcodeUploadProgressBar.text("Streaming ..."); + } else if (type == "TransferDone") { + gcodeUploadProgress.removeClass("progress-striped").removeClass("active"); + gcodeUploadProgressBar.css("width", "0%"); + gcodeUploadProgressBar.text(""); + $.pnotify({title: "Streaming done", text: "Streamed " + payload.local + " to " + payload.remote + " on SD, took " + payload.time + " seconds"}); + gcodeFilesViewModel.requestData(payload.remote, "sdcard"); } break; } diff --git a/src/octoprint/static/js/app/main.js b/src/octoprint/static/js/app/main.js index d2800559..9e0fe6e6 100644 --- a/src/octoprint/static/js/app/main.js +++ b/src/octoprint/static/js/app/main.js @@ -75,7 +75,17 @@ $(function() { //~~ Gcode upload function gcode_upload_done(e, data) { - gcodeFilesViewModel.fromResponse(data.result); + var filename = undefined; + var location = undefined; + if (data.result.files.hasOwnProperty("sdcard")) { + filename = data.result.files.sdcard.name; + location = "sdcard"; + } else if (data.result.files.hasOwnProperty("local")) { + filename = data.result.files.local.name; + location = "local"; + } + gcodeFilesViewModel.requestData(filename, location); + if (data.result.done) { $("#gcode_upload_progress .bar").css("width", "0%"); $("#gcode_upload_progress").removeClass("progress-striped").removeClass("active"); @@ -107,7 +117,7 @@ $(function() { function enable_local_dropzone() { $("#gcode_upload").fileupload({ - url: API_BASEURL + "gcodefiles/local", + url: API_BASEURL + "files/local", dataType: "json", dropZone: localTarget, done: gcode_upload_done, @@ -118,7 +128,7 @@ $(function() { function disable_local_dropzone() { $("#gcode_upload").fileupload({ - url: API_BASEURL + "gcodefiles/local", + url: API_BASEURL + "files/local", dataType: "json", dropZone: null, done: gcode_upload_done, @@ -129,7 +139,7 @@ $(function() { function enable_sd_dropzone() { $("#gcode_upload_sd").fileupload({ - url: API_BASEURL + "gcodefiles/sdcard", + url: API_BASEURL + "files/sdcard", dataType: "json", dropZone: $("#drop_sd"), done: gcode_upload_done, @@ -140,10 +150,9 @@ $(function() { function disable_sd_dropzone() { $("#gcode_upload_sd").fileupload({ - url: API_BASEURL + "gcodefiles/sdcard", + url: API_BASEURL + "files/sdcard", dataType: "json", dropZone: null, - formData: {target: "sd"}, done: gcode_upload_done, fail: gcode_upload_fail, progressall: gcode_upload_progress @@ -282,6 +291,7 @@ $(function() { ko.applyBindings(terminalViewModel, document.getElementById("term")); var gcode = document.getElementById("gcode"); if (gcode) { + gcodeViewModel.initialize(); ko.applyBindings(gcodeViewModel, gcode); } ko.applyBindings(settingsViewModel, document.getElementById("settings_dialog")); @@ -293,10 +303,6 @@ $(function() { if (timelapseElement) { ko.applyBindings(timelapseViewModel, timelapseElement); } - var gCodeVisualizerElement = document.getElementById("gcode"); - if (gCodeVisualizerElement) { - gcodeViewModel.initialize(); - } //~~ startup commands diff --git a/src/octoprint/static/js/app/viewmodels/gcodefiles.js b/src/octoprint/static/js/app/viewmodels/files.js similarity index 84% rename from src/octoprint/static/js/app/viewmodels/gcodefiles.js rename to src/octoprint/static/js/app/viewmodels/files.js index d159f1aa..2be71d1e 100644 --- a/src/octoprint/static/js/app/viewmodels/gcodefiles.js +++ b/src/octoprint/static/js/app/viewmodels/files.js @@ -96,56 +96,56 @@ function GcodeFilesViewModel(printerStateViewModel, loginStateViewModel) { self.isSdReady(data.flags.sdReady); } - self.requestData = function(filenameOverride) { + self.requestData = function(filenameToFocus, locationToFocus) { $.ajax({ - url: API_BASEURL + "gcodefiles", + url: API_BASEURL + "files", method: "GET", dataType: "json", success: function(response) { - if (filenameOverride) { - response.filename = filenameOverride - } - self.fromResponse(response); + self.fromResponse(response, filenameToFocus, locationToFocus); } }); } - self.fromResponse = function(response) { - self.listHelper.updateItems(response.files); + self.fromResponse = function(response, filenameToFocus, locationToFocus) { + var files = response.files; + _.each(files, function(element, index, list) { + if (!element.hasOwnProperty("size")) element.size = "n/a"; + if (!element.hasOwnProperty("date")) element.date = "n/a"; + }); + self.listHelper.updateItems(files); - if (response.filename) { + if (filenameToFocus) { // got a file to scroll to - self.listHelper.switchToItem(function(item) {return item.name == response.filename}); + if (locationToFocus === undefined) { + locationToFocus = "local"; + } + self.listHelper.switchToItem(function(item) {return item.name == filenameToFocus && item.origin == locationToFocus}); } - self.freeSpace(response.free); + if (response.free) { + self.freeSpace(response.free); + } self.highlightFilename(self.printerState.filename()); } self.loadFile = function(filename, printAfterLoad) { var file = self.listHelper.getItem(function(item) {return item.name == filename}); - if (!file) return; - - var origin; - if (file.origin === undefined) { - origin = "local"; - } else { - origin = file.origin; - } + if (!file || !file.refs || !file.refs.hasOwnProperty("resource")) return; $.ajax({ - url: API_BASEURL + "gcodefiles/" + origin + "/" + filename, + url: file.refs.resource, type: "POST", dataType: "json", contentType: "application/json; charset=UTF-8", - data: JSON.stringify({command: "load", print: printAfterLoad}) + data: JSON.stringify({command: "select", print: printAfterLoad}) }) } self.removeFile = function(filename) { var file = self.listHelper.getItem(function(item) {return item.name == filename}); - if (!file) return; + if (!file || !file.refs || !file.refs.hasOwnProperty("resource")) return; var origin; if (file.origin === undefined) { @@ -155,9 +155,8 @@ function GcodeFilesViewModel(printerStateViewModel, loginStateViewModel) { } $.ajax({ - url: API_BASEURL + "gcodefiles/" + origin + "/" + filename, - type: "DELETE", - success: self.fromResponse + url: file.refs.resource, + type: "DELETE" }) } @@ -175,7 +174,7 @@ function GcodeFilesViewModel(printerStateViewModel, loginStateViewModel) { self._sendSdCommand = function(command) { $.ajax({ - url: API_BASEURL + "control/sd", + url: API_BASEURL + "control/printer/sd", type: "POST", dataType: "json", contentType: "application/json; charset=UTF-8", diff --git a/src/octoprint/static/js/app/viewmodels/firstrun.js b/src/octoprint/static/js/app/viewmodels/firstrun.js index f2913dc3..89ddc15e 100644 --- a/src/octoprint/static/js/app/viewmodels/firstrun.js +++ b/src/octoprint/static/js/app/viewmodels/firstrun.js @@ -37,6 +37,7 @@ function FirstRunViewModel() { $("#confirmation_dialog .confirmation_dialog_message").html("If you disable Access Control and your OctoPrint " + "installation is accessible from the internet, your printer will be accessible by everyone - " + "that also includes the bad guys!"); + $("#confirmation_dialog .confirmation_dialog_acknowledge").unbind("click"); $("#confirmation_dialog .confirmation_dialog_acknowledge").click(function(e) { e.preventDefault(); $("#confirmation_dialog").modal("hide"); diff --git a/src/octoprint/static/js/app/viewmodels/gcode.js b/src/octoprint/static/js/app/viewmodels/gcode.js index a412be27..9c0c28f9 100644 --- a/src/octoprint/static/js/app/viewmodels/gcode.js +++ b/src/octoprint/static/js/app/viewmodels/gcode.js @@ -19,7 +19,7 @@ function GcodeViewModel(loginStateViewModel) { if (self.status == 'idle' && self.errorCount < 3) { self.status = 'request'; $.ajax({ - url: BASEURL + "downloads/gcode/" + filename, + url: BASEURL + "downloads/files/local/" + filename, data: { "mtime": mtime }, type: "GET", success: function(response, rstatus) { @@ -67,7 +67,7 @@ function GcodeViewModel(loginStateViewModel) { } } self.errorCount = 0 - } else if (data.job.filename) { + } else if (data.job.filename && !data.job.sd) { self.loadFile(data.job.filename, data.job.mtime); } } diff --git a/src/octoprint/static/js/app/viewmodels/printerstate.js b/src/octoprint/static/js/app/viewmodels/printerstate.js index b9b53b31..a13d0464 100644 --- a/src/octoprint/static/js/app/viewmodels/printerstate.js +++ b/src/octoprint/static/js/app/viewmodels/printerstate.js @@ -120,16 +120,17 @@ function PrinterStateViewModel(loginStateViewModel) { } self.print = function() { - var printAction = function() { - self._jobCommand("start"); + var restartCommand = function() { + self._jobCommand("restart"); } if (self.isPaused()) { $("#confirmation_dialog .confirmation_dialog_message").text("This will restart the print job from the beginning."); - $("#confirmation_dialog .confirmation_dialog_acknowledge").click(function(e) {e.preventDefault(); $("#confirmation_dialog").modal("hide"); printAction(); }); + $("#confirmation_dialog .confirmation_dialog_acknowledge").unbind("click"); + $("#confirmation_dialog .confirmation_dialog_acknowledge").click(function(e) {e.preventDefault(); $("#confirmation_dialog").modal("hide"); restartCommand(); }); $("#confirmation_dialog").modal("show"); } else { - printAction(); + self._jobCommand("start"); } } diff --git a/src/octoprint/static/js/app/viewmodels/temperature.js b/src/octoprint/static/js/app/viewmodels/temperature.js index c974b21e..2bc97899 100644 --- a/src/octoprint/static/js/app/viewmodels/temperature.js +++ b/src/octoprint/static/js/app/viewmodels/temperature.js @@ -217,7 +217,7 @@ function TemperatureViewModel(loginStateViewModel, settingsViewModel) { data[group][type] = parseInt(temp); $.ajax({ - url: API_BASEURL + "control/printer/hotend", + url: API_BASEURL + "control/printer/heater", type: "POST", dataType: "json", contentType: "application/json; charset=UTF-8", diff --git a/src/octoprint/static/js/app/viewmodels/timelapse.js b/src/octoprint/static/js/app/viewmodels/timelapse.js index 96128ff3..9d0ac397 100644 --- a/src/octoprint/static/js/app/viewmodels/timelapse.js +++ b/src/octoprint/static/js/app/viewmodels/timelapse.js @@ -76,10 +76,13 @@ function TimelapseViewModel(loginStateViewModel) { }; self.fromResponse = function(response) { - self.timelapseType(response.type); + var config = response.config; + if (config === undefined) return; + + self.timelapseType(config.type); self.listHelper.updateItems(response.files); - if (response.type == "timed" && response.config && response.config.interval) { + if (config.type == "timed" && response.config.interval) { self.timelapseTimedInterval(response.config.interval); } else { self.timelapseTimedInterval(undefined); diff --git a/src/octoprint/templates/index.jinja2 b/src/octoprint/templates/index.jinja2 index 4e1c735a..16856d0c 100644 --- a/src/octoprint/templates/index.jinja2 +++ b/src/octoprint/templates/index.jinja2 @@ -18,7 +18,7 @@ - + diff --git a/src/octoprint/timelapse.py b/src/octoprint/timelapse.py index a71607d7..cd4e9da1 100644 --- a/src/octoprint/timelapse.py +++ b/src/octoprint/timelapse.py @@ -16,7 +16,7 @@ import sys import octoprint.util as util from octoprint.settings import settings -from octoprint.events import eventManager +from octoprint.events import eventManager, Events # currently configured timelapse current = None @@ -104,10 +104,10 @@ class Timelapse(object): self._captureMutex = threading.Lock() # subscribe events - eventManager().subscribe("PrintStarted", self.onPrintStarted) - eventManager().subscribe("PrintFailed", self.onPrintDone) - eventManager().subscribe("PrintDone", self.onPrintDone) - eventManager().subscribe("PrintResumed", self.onPrintResumed) + eventManager().subscribe(Events.PRINT_STARTED, self.onPrintStarted) + eventManager().subscribe(Events.PRINT_FAILED, self.onPrintDone) + eventManager().subscribe(Events.PRINT_DONE, self.onPrintDone) + eventManager().subscribe(Events.PRINT_RESUMED, self.onPrintResumed) for (event, callback) in self.eventSubscriptions(): eventManager().subscribe(event, callback) @@ -116,10 +116,10 @@ class Timelapse(object): self.stopTimelapse(doCreateMovie=False) # unsubscribe events - eventManager().unsubscribe("PrintStarted", self.onPrintStarted) - eventManager().unsubscribe("PrintFailed", self.onPrintDone) - eventManager().unsubscribe("PrintDone", self.onPrintDone) - eventManager().unsubscribe("PrintResumed", self.onPrintResumed) + eventManager().unsubscribe(Events.PRINT_STARTED, self.onPrintStarted) + eventManager().unsubscribe(Events.PRINT_FAILED, self.onPrintDone) + eventManager().unsubscribe(Events.PRINT_DONE, self.onPrintDone) + eventManager().unsubscribe(Events.PRINT_RESUMED, self.onPrintResumed) for (event, callback) in self.eventSubscriptions(): eventManager().unsubscribe(event, callback) @@ -127,20 +127,20 @@ class Timelapse(object): """ Override this to perform additional actions upon start of a print job. """ - self.startTimelapse(payload) + self.startTimelapse(payload["file"]) def onPrintDone(self, event, payload): """ Override this to perform additional actions upon the stop of a print job. """ - self.stopTimelapse() + self.stopTimelapse(success=(event==Events.PRINT_DONE)) def onPrintResumed(self, event, payload): """ Override this to perform additional actions upon the pausing of a print job. """ if not self._inTimelapse: - self.startTimelapse(payload) + self.startTimelapse(payload["file"]) def eventSubscriptions(self): """ @@ -172,11 +172,11 @@ class Timelapse(object): self._inTimelapse = True self._gcodeFile = os.path.basename(gcodeFile) - def stopTimelapse(self, doCreateMovie=True): + def stopTimelapse(self, doCreateMovie=True, success=True): self._logger.debug("Stopping timelapse") if doCreateMovie: - self._renderThread = threading.Thread(target=self._createMovie) + self._renderThread = threading.Thread(target=self._createMovie, kwargs={"success": success}) self._renderThread.daemon = True self._renderThread.start() @@ -197,12 +197,12 @@ class Timelapse(object): captureThread.start() def _captureWorker(self, filename): - eventManager().fire("CaptureStart", filename); + eventManager().fire(Events.CAPTURE_START, {"file": filename}); urllib.urlretrieve(self._snapshotUrl, filename) self._logger.debug("Image %s captured from %s" % (filename, self._snapshotUrl)) - eventManager().fire("CaptureDone", filename); + eventManager().fire(Events.CAPTURE_DONE, {"file": filename}); - def _createMovie(self): + def _createMovie(self, success=True): ffmpeg = settings().get(["webcam", "ffmpeg"]) bitrate = settings().get(["webcam", "bitrate"]) if ffmpeg is None or bitrate is None: @@ -210,7 +210,10 @@ class Timelapse(object): return input = os.path.join(self._captureDir, "tmp_%05d.jpg") - output = os.path.join(self._movieDir, "%s_%s.mpg" % (os.path.splitext(self._gcodeFile)[0], time.strftime("%Y%m%d%H%M%S"))) + if success: + output = os.path.join(self._movieDir, "%s_%s.mpg" % (os.path.splitext(self._gcodeFile)[0], time.strftime("%Y%m%d%H%M%S"))) + else: + output = os.path.join(self._movieDir, "%s_%s-failed.mpg" % (os.path.splitext(self._gcodeFile)[0], time.strftime("%Y%m%d%H%M%S"))) # prepare ffmpeg command command = [ @@ -252,13 +255,13 @@ class Timelapse(object): # finalize command with output file self._logger.debug("Rendering movie to %s" % output) command.append(output) - eventManager().fire("MovieRendering", {"gcode": self._gcodeFile, "movie": output, "movie_basename": os.path.basename(output)}) + eventManager().fire(Events.MOVIE_RENDERING, {"gcode": self._gcodeFile, "movie": output, "movie_basename": os.path.basename(output)}) try: subprocess.check_call(command) - eventManager().fire("MovieDone", {"gcode": self._gcodeFile, "movie": output, "movie_basename": os.path.basename(output)}) + eventManager().fire(Events.MOVIE_DONE, {"gcode": self._gcodeFile, "movie": output, "movie_basename": os.path.basename(output)}) except subprocess.CalledProcessError as (e): self._logger.warn("Could not render movie, got return code %r" % e.returncode) - eventManager().fire("MovieFailed", {"gcode": self._gcodeFile, "movie": output, "movie_basename": os.path.basename(output), "returncode": e.returncode}) + eventManager().fire(Events.MOVIE_FAILED, {"gcode": self._gcodeFile, "movie": output, "movie_basename": os.path.basename(output), "returncode": e.returncode}) def cleanCaptureDir(self): if not os.path.isdir(self._captureDir): diff --git a/src/octoprint/util/comm.py b/src/octoprint/util/comm.py index 85f58465..235d4df3 100644 --- a/src/octoprint/util/comm.py +++ b/src/octoprint/util/comm.py @@ -18,7 +18,8 @@ from octoprint.util.avr_isp import stk500v2 from octoprint.util.avr_isp import ispBase from octoprint.settings import settings -from octoprint.events import eventManager +from octoprint.events import eventManager, Events +from octoprint.filemanager.destinations import FileDestinations from octoprint.gcodefiles import isGcodeFileName from octoprint.util import getExceptionString, getNewTimeout from octoprint.util.virtual import VirtualPrinter @@ -68,17 +69,32 @@ def baudrateList(): return ret gcodeToEvent = { - "M226": "Waiting", # pause for user input - "M0": "Waiting", - "M1": "Waiting", - "M245": "Cooling", # part cooler - "M240": "Conveyor", # part conveyor - "M40": "Eject", # part ejector - "M300": "Alert", # user alert - "G28": "Home", # home print head - "M112": "EStop", - "M80": "PowerOn", - "M81": "PowerOff" + # pause for user input + "M226": Events.WAITING, + "M0": Events.WAITING, + "M1": Events.WAITING, + + # part cooler + "M245": Events.COOLING, + + # part conveyor + "M240": Events.CONVEYOR, + + # part ejector + "M40": Events.EJECT, + + # user alert + "M300": Events.ALERT, + + # home print head + "G28": Events.HOME, + + # emergency stop + "M112": Events.E_STOP, + + # motors on/off + "M80": Events.POWER_ON, + "M81": Events.POWER_OFF, } class MachineCom(object): @@ -310,8 +326,14 @@ class MachineCom(object): self._sdFileList = [] if printing: - eventManager().fire("PrintFailed") - eventManager().fire("Disconnected") + payload = None + if self._currentFile is not None: + payload = { + "file": self._currentFile.getFilename(), + "origin": self._currentFile.getFileLocation() + } + eventManager().fire(Events.PRINT_FAILED, payload) + eventManager().fire(Events.DISCONNECTED) def setTemperatureOffset(self, extruder=None, bed=None): if extruder is not None: @@ -337,7 +359,10 @@ class MachineCom(object): wasPaused = self.isPaused() self._printSection = "CUSTOM" self._changeState(self.STATE_PRINTING) - eventManager().fire("PrintStarted", self._currentFile.getFilename()) + eventManager().fire(Events.PRINT_STARTED, { + "file": self._currentFile.getFilename(), + "origin": self._currentFile.getFileLocation() + }) try: self._currentFile.start() @@ -351,18 +376,18 @@ class MachineCom(object): except: self._errorValue = getExceptionString() self._changeState(self.STATE_ERROR) - eventManager().fire("Error", self.getErrorString()) + eventManager().fire(Events.ERROR, self.getErrorString()) - def startFileTransfer(self, filename, remoteFilename): + def startFileTransfer(self, filename, localFilename, remoteFilename): if not self.isOperational() or self.isBusy(): logging.info("Printer is not operation or busy") return - self._currentFile = StreamingGcodeFileInformation(filename) + self._currentFile = StreamingGcodeFileInformation(filename, localFilename, remoteFilename) self._currentFile.start() self.sendCommand("M28 %s" % remoteFilename) - eventManager().fire("TransferStart", remoteFilename) + eventManager().fire(Events.TRANSFER_STARTED, {"local": localFilename, "remote": remoteFilename}) self._callback.mcFileTransferStarted(remoteFilename, self._currentFile.getFilesize()) def selectFile(self, filename, sd): @@ -376,7 +401,10 @@ class MachineCom(object): self.sendCommand("M23 %s" % filename) else: self._currentFile = PrintingGcodeFileInformation(filename, self.getOffsets) - eventManager().fire("FileSelected", filename) + eventManager().fire(Events.FILE_SELECTED, { + "file": self._currentFile.getFilename(), + "origin": self._currentFile.getFileLocation() + }) self._callback.mcFileSelected(filename, self._currentFile.getFilesize(), False) def unselectFile(self): @@ -384,7 +412,7 @@ class MachineCom(object): return self._currentFile = None - eventManager().fire("FileSelected", None) + eventManager().fire(Events.FILE_DESELECTED) self._callback.mcFileSelected(None, None, False) def cancelPrint(self): @@ -397,7 +425,10 @@ class MachineCom(object): self.sendCommand("M25") # pause print self.sendCommand("M26 S0") # reset position in file to byte 0 - eventManager().fire("PrintCancelled") + eventManager().fire(Events.PRINT_CANCELLED, { + "file": self._currentFile.getFilename(), + "origin": self._currentFile.getFileLocation() + }) def setPause(self, pause): if self.isStreaming(): @@ -409,13 +440,20 @@ class MachineCom(object): self.sendCommand("M24") else: self._sendNext() - eventManager().fire("PrintResumed", self._currentFile.getFilename()) - if pause and self.isPrinting(): + + eventManager().fire(Events.PRINT_RESUMED, { + "file": self._currentFile.getFilename(), + "origin": self._currentFile.getFileLocation() + }) + elif pause and self.isPrinting(): self._changeState(self.STATE_PAUSED) if self.isSdFileSelected(): self.sendCommand("M25") # pause print - eventManager().fire("Paused") + eventManager().fire(Events.PRINT_PAUSED, { + "file": self._currentFile.getFilename(), + "origin": self._currentFile.getFileLocation() + }) def getSdFiles(self): return self._sdFiles @@ -560,7 +598,10 @@ class MachineCom(object): # final answer to M23, at least on Marlin, Repetier and Sprinter: "File selected" if self._currentFile is not None: self._callback.mcFileSelected(self._currentFile.getFilename(), self._currentFile.getFilesize(), True) - eventManager().fire("FileSelected", self._currentFile.getFilename()) + eventManager().fire(Events.FILE_SELECTED, { + "file": self._currentFile.getFilename(), + "origin": self._currentFile.getFileLocation() + }) elif 'Writing to file' in line: # anwer to M28, at least on Marlin, Repetier and Sprinter: "Writing to file: %s" self._printSection = "CUSTOM" @@ -571,7 +612,10 @@ class MachineCom(object): self._sdFilePos = 0 self._callback.mcPrintjobDone() self._changeState(self.STATE_OPERATIONAL) - eventManager().fire("PrintDone") + eventManager().fire(Events.PRINT_DONE, { + "file": self._currentFile.getFilename(), + "origin": self._currentFile.getFileLocation() + }) elif 'Done saving file' in line: self.refreshSdFiles() @@ -625,7 +669,7 @@ class MachineCom(object): self.close() self._errorValue = "No more baudrates to test, and no suitable baudrate found." self._changeState(self.STATE_ERROR) - eventManager().fire("Error", self.getErrorString()) + eventManager().fire(Events.ERROR, self.getErrorString()) elif self._baudrateDetectRetry > 0: self._baudrateDetectRetry -= 1 self._serial.write('\n') @@ -657,7 +701,7 @@ class MachineCom(object): self._changeState(self.STATE_OPERATIONAL) if self._sdAvailable: self.refreshSdFiles() - eventManager().fire("Connected", "%s at %s baud" % (self._port, self._baudrate)) + eventManager().fire(Events.CONNECTED, {"port": self._port, "baudrate": self._baudrate}) else: self._testingBaudrate = False @@ -671,7 +715,7 @@ class MachineCom(object): self._changeState(self.STATE_OPERATIONAL) if not self._sdAvailable: self.initSdCard() - eventManager().fire("Connected", "%s at %s baud" % (self._port, self._baudrate)) + eventManager().fire(Events.CONNECTED, {"port": self._port, "baudrate": self._baudrate}) elif time.time() > timeout: self.close() @@ -736,7 +780,7 @@ class MachineCom(object): self._log(errorMsg) self._errorValue = errorMsg self._changeState(self.STATE_ERROR) - eventManager().fire("Error", self.getErrorString()) + eventManager().fire(Events.ERROR, self.getErrorString()) self._log("Connection closed, closing down monitor") def _openSerial(self): @@ -760,7 +804,7 @@ class MachineCom(object): self._log("Failed to autodetect serial port") self._errorValue = 'Failed to autodetect serial port.' self._changeState(self.STATE_ERROR) - eventManager().fire("Error", self.getErrorString()) + eventManager().fire(Events.ERROR, self.getErrorString()) return False elif self._port == 'VIRTUAL': self._changeState(self.STATE_OPEN_SERIAL) @@ -777,7 +821,7 @@ class MachineCom(object): self._log("Unexpected error while connecting to serial port: %s %s" % (self._port, getExceptionString())) self._errorValue = "Failed to open serial port, permissions correct?" self._changeState(self.STATE_ERROR) - eventManager().fire("Error", self.getErrorString()) + eventManager().fire(Events.ERROR, self.getErrorString()) return False return True @@ -802,7 +846,7 @@ class MachineCom(object): elif not self.isError(): self._errorValue = line[6:] self._changeState(self.STATE_ERROR) - eventManager().fire("Error", self.getErrorString()) + eventManager().fire(Events.ERROR, self.getErrorString()) return line def _readline(self): @@ -827,16 +871,27 @@ class MachineCom(object): if line is None: if self.isStreaming(): self._sendCommand("M29") + filename = self._currentFile.getFilename() + payload = { + "local": self._currentFile.getLocalFilename(), + "remote": self._currentFile.getRemoteFilename(), + "time": "%.2f" % (time.time() - self._currentFile.getStartTime()) + } + self._currentFile = None self._changeState(self.STATE_OPERATIONAL) self._callback.mcFileTransferDone(filename) - eventManager().fire("TransferDone", filename) + eventManager().fire(Events.TRANSFER_DONE, payload) self.refreshSdFiles() else: + payload = { + "file": self._currentFile.getFilename(), + "origin": self._currentFile.getFileLocation() + } self._callback.mcPrintjobDone() self._changeState(self.STATE_OPERATIONAL) - eventManager().fire("PrintDone", self._currentFile.getFilename()) + eventManager().fire(Events.PRINT_DONE, payload) return self._sendCommand(line, True) @@ -845,7 +900,7 @@ class MachineCom(object): def _handleResendRequest(self, line): lineToResend = None try: - lineToResend = int(line.replace("N:"," ").replace("N"," ").replace(":"," ").split()[-1]) + lineToResend = int(line.replace("N:", " ").replace("N", " ").replace(":", " ").split()[-1]) except: if "rs" in line: lineToResend = int(line.split()[1]) @@ -858,7 +913,7 @@ class MachineCom(object): if self.isPrinting(): # abort the print, there's nothing we can do to rescue it now self._changeState(self.STATE_ERROR) - eventManager().fire("Error", self.getErrorString()) + eventManager().fire(Events.ERROR, self.getErrorString()) else: # reset resend delta, we can't do anything about it self._resendDelta = None @@ -1064,6 +1119,9 @@ class PrintingFileInformation(object): def getFilepos(self): return self._filepos + def getFileLocation(self): + return FileDestinations.LOCAL + def getProgress(self): """ The current progress of the file, calculated as relation between file position and absolute size. Returns -1 @@ -1100,6 +1158,9 @@ class PrintingSdFileInformation(PrintingFileInformation): """ self._filepos = filepos + def getFileLocation(self): + return FileDestinations.SDCARD + class PrintingGcodeFileInformation(PrintingFileInformation): """ Encapsulates information regarding an ongoing direct print. Takes care of the needed file handle and ensures @@ -1191,5 +1252,17 @@ class PrintingGcodeFileInformation(PrintingFileInformation): return None class StreamingGcodeFileInformation(PrintingGcodeFileInformation): - def __init__(self, filename): - PrintingGcodeFileInformation.__init__(self, filename, None) \ No newline at end of file + def __init__(self, path, localFilename, remoteFilename): + PrintingGcodeFileInformation.__init__(self, path, None) + self._localFilename = localFilename + self._remoteFilename = remoteFilename + + def start(self): + PrintingGcodeFileInformation.start(self) + self._startTime = time.time() + + def getLocalFilename(self): + return self._localFilename + + def getRemoteFilename(self): + return self._remoteFilename \ No newline at end of file