diff --git a/docs/api/push.rst b/docs/api/push.rst index fab32b0f..cf46eb2d 100644 --- a/docs/api/push.rst +++ b/docs/api/push.rst @@ -35,6 +35,9 @@ following message types are currently available for usage by 3rd party clients: payload data model as ``current``, see :ref:`below `. * ``event``: Events triggered within OctoPrint, such as e.g. ``PrintFailed`` or ``MovieRenderDone``. Payload is the event type and payload, see :ref:`below `. Sent when an event is triggered internally. + * ``slicingProgress``: Progress updates from an active slicing background job, payload contains information about the + model being sliced, the target file, the slicer being used and the progress as a percentage. + See :ref:`the payload data model `. Clients must ignore any unknown messages. @@ -112,3 +115,41 @@ Datamodel - 1 - Object - Payload associated with the event + +.. _sec-api-push-datamodel-slicingprogress: + +``slicingProgress`` payload +--------------------------- + +.. list-table:: + :widths: 15 5 10 30 + :header-rows: 1 + + * - Name + - Multiplicity + - Type + - Description + * - ``slicer`` + - 1 + - String + - Name of the slicer used + * - ``source_location`` + - 1 + - String + - Location of the source file being sliced, at the moment either ``local`` or ``sdcard`` + * - ``source_path`` + - 1 + - String + - Path of the source file being sliced (e.g. an STL file) + * - ``dest_location`` + - 1 + - String + - Location of the destination file being created, at the moment either ``local`` or ``sdcard`` + * - ``dest_path`` + - 1 + - String + - Path of the destination file being sliced (e.g. a GCODE file) + * - ``progress`` + - 1 + - Number (Float) + - Percentage of slicing job already completed diff --git a/src/octoprint/filemanager/__init__.py b/src/octoprint/filemanager/__init__.py index 5c702fab..a27bfe72 100644 --- a/src/octoprint/filemanager/__init__.py +++ b/src/octoprint/filemanager/__init__.py @@ -105,9 +105,18 @@ class FileManager(object): self._slicing_jobs = dict() self._slicing_jobs_mutex = threading.Lock() + self._slicing_progress_callbacks = [] + self._last_slicing_progress = None + for storage_type, storage_manager in self._storage_managers.items(): self._determine_analysis_backlog(storage_type, storage_manager) + def register_slicingprogress_callback(self, callback): + self._slicing_progress_callbacks.append(callback) + + def unregister_slicingprogress_callback(self, callback): + self._slicing_progress_callbacks.remove(callback) + def _determine_analysis_backlog(self, storage_type, storage_manager): self._logger.info("Adding backlog items from {storage_type} to analysis queue".format(**locals())) for entry, path in storage_manager.analysis_backlog: @@ -197,7 +206,30 @@ class FileManager(object): self._slicing_jobs[dest_location] = (slicer_name, absolute_source_path, temp_path) args = (source_location, source_path, temp_path, dest_location, dest_path, start_time, callback, callback_args) - return self._slicing_manager.slice(slicer_name, absolute_source_path, temp_path, profile, stlProcessed, callback_args=args, overrides=overrides) + return self._slicing_manager.slice( + slicer_name, + absolute_source_path, + temp_path, + profile, + stlProcessed, + callback_args=args, + overrides=overrides, + on_progress=self.on_slicing_progress, + on_progress_args=(slicer_name, source_location, source_path, dest_location, dest_path)) + + def on_slicing_progress(self, slicer, source_location, source_path, dest_location, dest_path, _progress=None): + if not _progress: + return + + progress_int = int(_progress * 100) + if self._last_slicing_progress == progress_int: + return + else: + self._last_slicing_progress = progress_int + + for callback in self._slicing_progress_callbacks: + try: callback.sendSlicingProgress(slicer, source_location, source_path, dest_location, dest_path, progress_int) + except: pass def file_exists(self, destination, path): return self._storage(destination).file_exists(path) diff --git a/src/octoprint/plugin/types.py b/src/octoprint/plugin/types.py index 94addc5a..72954377 100644 --- a/src/octoprint/plugin/types.py +++ b/src/octoprint/plugin/types.py @@ -212,7 +212,7 @@ class SlicerPlugin(Plugin): def save_slicer_profile(self, path, profile, allow_overwrite=True, overrides=None): pass - def do_slice(self, model_path, machinecode_path=None, profile_path=None): + def do_slice(self, model_path, machinecode_path=None, profile_path=None, on_progress=None, on_progress_args=None, on_progress_kwargs=None): pass def cancel_slicing(self, machinecode_path): diff --git a/src/octoprint/plugins/cura/__init__.py b/src/octoprint/plugins/cura/__init__.py index fb87263b..7df5b930 100644 --- a/src/octoprint/plugins/cura/__init__.py +++ b/src/octoprint/plugins/cura/__init__.py @@ -198,13 +198,19 @@ class CuraPlugin(octoprint.plugin.SlicerPlugin, self._save_profile(path, new_profile, allow_overwrite=allow_overwrite) - def do_slice(self, model_path, machinecode_path=None, profile_path=None): + def do_slice(self, model_path, machinecode_path=None, profile_path=None, on_progress=None, on_progress_args=None, on_progress_kwargs=None): if not profile_path: profile_path = s.get(["default_profile"]) if not machinecode_path: path, _ = os.path.splitext(model_path) machinecode_path = path + ".gco" + if on_progress: + if not on_progress_args: + on_progress_args = () + if not on_progress_kwargs: + on_progress_kwargs = dict() + engine_settings = self._convert_to_engine(profile_path) executable = s.get(["cura_engine"]) @@ -221,10 +227,75 @@ class CuraPlugin(octoprint.plugin.SlicerPlugin, command = " ".join(args) self._logger.info("Running %r in %s" % (command, working_dir)) try: - p = sarge.run(command, cwd=working_dir, async=True) - with self._slicing_commands_mutex: - self._slicing_commands[machinecode_path] = p.commands[0] - p.wait() + p = sarge.run(command, cwd=working_dir, async=True, stdout=sarge.Capture(), stderr=sarge.Capture()) + try: + with self._slicing_commands_mutex: + self._slicing_commands[machinecode_path] = p.commands[0] + + if on_progress is not None: + # The Cura slicing process has three individual steps, each consisting of substeps: + # + # - inset + # - skin + # - export + # + # So each layer will be processed three times, once for each step, resulting in a total amount of + # substeps of 3 * . + # + # The CuraEngine reports the calculated layer count and the continuous progress on stderr. + # The layer count gets reported right at the beginning in a line of the format: + # + # Layer count: + # + # The individual progress per each of the three steps gets reported on stderr in a line of + # the format: + # + # Progress::: + # + # Thus, for determining the overall progress the following formula applies: + # + # progress = * + / * 3 + # + # with being 0 for "inset", 1 for "skin" and 2 for "export". + + import time + layer_count = None + line_seen = False + step_factor = dict( + inset=0, + skin=1, + export=2 + ) + while p.returncode is None: + line = p.stderr.readline(timeout=0.5) + if not line: + if line_seen: + break + else: + continue + + line_seen = True + if line.startswith("Layer count:") and layer_count is None: + try: + layer_count = float(line[len("Layer count:"):].strip()) + except: + pass + + elif line.startswith("Progress:"): + split_line = line[len("Progress:"):].strip().split(":") + if len(split_line) == 3: + step, current_layer, _ = split_line + try: + current_layer = float(current_layer) + except: + pass + else: + if not step in step_factor: + continue + on_progress_kwargs["_progress"] = (step_factor[step] * layer_count + current_layer) / (layer_count * 3) + on_progress(*on_progress_args, **on_progress_kwargs) + finally: + p.close() with self._cancelled_jobs_mutex: if machinecode_path in self._cancelled_jobs: diff --git a/src/octoprint/server/util/sockjs.py b/src/octoprint/server/util/sockjs.py index 1634f989..c5a561d0 100644 --- a/src/octoprint/server/util/sockjs.py +++ b/src/octoprint/server/util/sockjs.py @@ -47,6 +47,7 @@ class PrinterStateConnection(sockjs.tornado.SockJSConnection): self._emit("connected", {"apikey": octoprint.server.UI_API_KEY, "version": octoprint.server.VERSION, "display_version": octoprint.server.DISPLAY_VERSION}) self._printer.registerCallback(self) + self._fileManager.register_slicingprogress_callback(self) octoprint.timelapse.registerCallback(self) self._eventManager.fire(Events.CLIENT_OPENED, {"remoteAddress": remoteAddress}) @@ -58,6 +59,7 @@ class PrinterStateConnection(sockjs.tornado.SockJSConnection): def on_close(self): self._logger.info("Client connection closed") self._printer.unregisterCallback(self) + self._fileManager.unregister_slicingprogress_callback(self) octoprint.timelapse.unregisterCallback(self) self._eventManager.fire(Events.CLIENT_CLOSED) @@ -100,6 +102,11 @@ class PrinterStateConnection(sockjs.tornado.SockJSConnection): def sendTimelapseConfig(self, timelapseConfig): self._emit("timelapse", timelapseConfig) + def sendSlicingProgress(self, slicer, source_location, source_path, dest_location, dest_path, progress): + self._emit("slicingProgress", + dict(slicer=slicer, source_location=source_location, source_path=source_path, dest_location=dest_location, dest_path=dest_path, progress=progress) + ) + def addLog(self, data): with self._logBacklogMutex: self._logBacklog.append(data) diff --git a/src/octoprint/slicing/__init__.py b/src/octoprint/slicing/__init__.py index a0415626..3421e4e6 100644 --- a/src/octoprint/slicing/__init__.py +++ b/src/octoprint/slicing/__init__.py @@ -56,6 +56,15 @@ class SlicingManager(object): self._slicer_names = dict() self._load_slicers() + self._progress_callbacks = [] + self._last_progress_report = None + + def register_progress_callback(self, callback): + self._progress_callbacks.append(callback) + + def unregister_progress_callback(self, callback): + self._progress_callbacks.remove(callback) + def _load_slicers(self): plugins = octoprint.plugin.plugin_manager().get_implementations(octoprint.plugin.SlicerPlugin) for name, plugin in plugins.items(): @@ -81,7 +90,7 @@ class SlicingManager(object): def get_slicer(self, slicer): return self._slicers[slicer] if slicer in self._slicers else None - def slice(self, slicer_name, source_path, dest_path, profile_name, callback, callback_args=None, callback_kwargs=None, overrides=None): + def slice(self, slicer_name, source_path, dest_path, profile_name, callback, callback_args=None, callback_kwargs=None, overrides=None, on_progress=None, on_progress_args=None, on_progress_kwargs=None): if callback_args is None: callback_args = () if callback_kwargs is None: @@ -97,8 +106,16 @@ class SlicingManager(object): def slicer_worker(slicer, model_path, machinecode_path, profile_name, overrides, callback, callback_args, callback_kwargs): try: - with self.temporary_profile(slicer.get_slicer_type(), name=profile_name, overrides=overrides) as profile_path: - ok, result = slicer.do_slice(model_path, machinecode_path=machinecode_path, profile_path=profile_path) + slicer_name = slicer.get_slicer_type() + with self.temporary_profile(slicer_name, name=profile_name, overrides=overrides) as profile_path: + ok, result = slicer.do_slice( + model_path, + machinecode_path=machinecode_path, + profile_path=profile_path, + on_progress=on_progress, + on_progress_args=on_progress_args, + on_progress_kwargs=on_progress_kwargs + ) if not ok: callback_kwargs.update(dict(_error=result)) diff --git a/src/octoprint/static/js/app/dataupdater.js b/src/octoprint/static/js/app/dataupdater.js index 15df566d..626f0082 100644 --- a/src/octoprint/static/js/app/dataupdater.js +++ b/src/octoprint/static/js/app/dataupdater.js @@ -94,6 +94,9 @@ function DataUpdater(allViewModels) { var data = e.data[prop]; + var gcodeUploadProgress = $("#gcode_upload_progress"); + var gcodeUploadProgressBar = $(".bar", gcodeUploadProgress); + switch (prop) { case "connected": { // update the current UI API key and send it with any request @@ -137,14 +140,21 @@ function DataUpdater(allViewModels) { }); break; } + case "slicingProgress": { + gcodeUploadProgressBar.text(_.sprintf(gettext("Slicing ... (%(percentage)d%%)"), {percentage: Math.round(data["progress"])})); + + _.each(self.allViewModels, function(viewModel) { + if (viewModel.hasOwnProperty("onSlicingProgress")) { + viewModel.onSlicingProgress(data["slicer"], data["model_path"], data["machinecode_path"], data["progress"]); + } + }); + break; + } case "event": { var type = data["type"]; var payload = data["payload"]; var html = ""; - var gcodeUploadProgress = $("#gcode_upload_progress"); - var gcodeUploadProgressBar = $(".bar", gcodeUploadProgress); - console.log("Got event " + type + " with payload: " + JSON.stringify(payload)) if (type == "UpdatedFiles") { @@ -171,18 +181,27 @@ function DataUpdater(allViewModels) { } else if (type == "SlicingStarted") { gcodeUploadProgress.addClass("progress-striped").addClass("active"); gcodeUploadProgressBar.css("width", "100%"); - gcodeUploadProgressBar.text(gettext("Slicing ...")); + gcodeUploadProgressBar.text(_.sprintf(gettext("Slicing ... (%(percentage)d%%)"), {percentage: 0})); } else if (type == "SlicingDone") { gcodeUploadProgress.removeClass("progress-striped").removeClass("active"); gcodeUploadProgressBar.css("width", "0%"); gcodeUploadProgressBar.text(""); new PNotify({title: gettext("Slicing done"), text: _.sprintf(gettext("Sliced %(stl)s to %(gcode)s, took %(time).2f seconds"), payload)}); - _.each(self.allViewModels, function(viewModel) { + _.each(self.allViewModels, function (viewModel) { if (viewModel.hasOwnProperty("onSlicingDone")) { viewModel.onSlicingDone(payload); } }); + } else if (type == "SlicingCancelled") { + gcodeUploadProgress.removeClass("progress-striped").removeClass("active"); + gcodeUploadProgressBar.css("width", "0%"); + gcodeUploadProgressBar.text(""); + _.each(self.allViewModels, function (viewModel) { + if (viewModel.hasOwnProperty("onSlicingCancelled")) { + viewModel.onSlicingCancelled(payload); + } + }); } else if (type == "SlicingFailed") { gcodeUploadProgress.removeClass("progress-striped").removeClass("active"); gcodeUploadProgressBar.css("width", "0%"); @@ -190,6 +209,11 @@ function DataUpdater(allViewModels) { html = _.sprintf(gettext("Could not slice %(stl)s to %(gcode)s: %(reason)s"), payload); new PNotify({title: gettext("Slicing failed"), text: html, type: "error", hide: false}); + _.each(self.allViewModels, function (viewModel) { + if (viewModel.hasOwnProperty("onSlicingFailed")) { + viewModel.onSlicingFailed(payload); + } + }); } else if (type == "TransferStarted") { gcodeUploadProgress.addClass("progress-striped").addClass("active"); gcodeUploadProgressBar.css("width", "100%");