diff --git a/Dockerfile b/Dockerfile index 8000089..bf6e11d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,8 @@ FROM python:3.7 # Installing ffmpeg is needed for working with timelapses - can be ommitted otherwise -RUN apt-get update && apt-get -y install --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* +# Also install vim for later edit based debugging +RUN apt-get update && apt-get -y install --no-install-recommends ffmpeg vim && rm -rf /var/lib/apt/lists/* # IPFS installation for LAN filesharing RUN wget https://dist.ipfs.tech/kubo/v0.15.0/kubo_v0.15.0_linux-amd64.tar.gz \ diff --git a/continuousprint/__init__.py b/continuousprint/__init__.py index b17b7ff..64ed284 100644 --- a/continuousprint/__init__.py +++ b/continuousprint/__init__.py @@ -54,6 +54,7 @@ def on_startup(self, host=None, port=None): self._printer, self._settings, self._file_manager, + self._slicing_manager, self._plugin_manager, queries, self.get_plugin_data_folder(), @@ -65,6 +66,11 @@ def on_startup(self, host=None, port=None): ) def on_after_startup(self): + self._logger.debug( + "Starting ContinuousPrint with settings: {}".format( + self._settings.get_all_data() + ) + ) self._plugin.patchCommJobReader() self._plugin.patchComms() self._plugin.start() diff --git a/continuousprint/data/__init__.py b/continuousprint/data/__init__.py index 33cdbf6..6b9844a 100644 --- a/continuousprint/data/__init__.py +++ b/continuousprint/data/__init__.py @@ -159,6 +159,8 @@ class Keys(Enum): INFER_PROFILE = ("cp_infer_profile", True) AUTO_RECONNECT = ("cp_auto_reconnect", False) SKIP_GCODE_COMMANDS = ("cp_skip_gcode_commands", "") + SLICER = ("cp_slicer", "") + SLICER_PROFILE = ("cp_slicer_profile", "") def __init__(self, setting, default): self.setting = setting diff --git a/continuousprint/driver.py b/continuousprint/driver.py index eb44d21..1add521 100644 --- a/continuousprint/driver.py +++ b/continuousprint/driver.py @@ -7,6 +7,8 @@ class Action(Enum): ACTIVATE = auto() DEACTIVATE = auto() + RESOLVED = auto() + RESOLVE_FAILURE = auto() SUCCESS = auto() FAILURE = auto() SPAGHETTI = auto() @@ -207,10 +209,10 @@ def _enter_start_print(self, a: Action, p: Printer, run_pre_script=True): ): return self._state_preprint - # Pre-call start_print on entry to eliminate tick delay + # Pre-call resolve_print on entry to eliminate tick delay self.start_failures = 0 - nxt = self._state_start_print(a, p) - return nxt if nxt is not None else self._state_start_print + nxt = self._state_resolve_print(a, p) + return nxt if nxt is not None else self._state_resolve_print def _fmt_material_key(self, mk): try: @@ -265,7 +267,7 @@ def _state_awaiting_material(self, a: Action, p: Printer): StatusType.NEEDS_ACTION, ) - def _state_start_print(self, a: Action, p: Printer): + def _state_resolve_print(self, a: Action, p: Printer): if p != Printer.IDLE: self._set_status("Waiting for printer to be ready") return @@ -275,10 +277,14 @@ def _state_start_print(self, a: Action, p: Printer): self._set_status("No work to do; going idle") return self._state_idle - if not self._runner.set_active(item): - # TODO: handle this gracefully by marking the job as not runnable somehow and moving on - return self._state_inactive + sa = self._runner.set_active(item, self._slicing_callback) + if sa is False: + return self._fail_start() + elif sa is None: # Implies slicing + return self._state_slicing + # Invariant: item's path has been set as the active file in OctoPrint + # and the file is a .gcode file that's ready to go. valid, rep = self._runner.verify_active() if not self._materials_match(item) or not valid: self._runner.run_script_for_event(CustomEvents.AWAITING_MATERIAL) @@ -288,20 +294,45 @@ def _state_start_print(self, a: Action, p: Printer): ) return self._state_awaiting_material - self.q.begin_run() - if self._runner.start_print(item): - return self._state_printing + try: + self.q.begin_run() + self._runner.start_print(item) + except Exception as e: + self._logger.error(e) + return self._fail_start() + + return self._state_printing + + def _state_slicing(self, a: Action, p: Printer): + self._set_status("Waiting for print file to be ready") + if a == Action.RESOLVED: + return ( + self._state_resolve_print(Action.TICK, p) or self._state_resolve_print + ) + elif a == Action.RESOLVE_FAILURE: + return self._fail_start() + + def _slicing_callback(self, success: bool, error): + if error is not None: + return + + # Forward action. We assume printer is idle here. + self.action( + Action.RESOLVED if success else Action.RESOLVE_FAILURE, Printer.IDLE + ) + + def _fail_start(self): + # TODO bail out of the job and mark it as bad rather than dropping into inactive state + self.start_failures += 1 + if self.start_failures >= self.max_startup_attempts: + self._set_status("Failed to start; too many attempts", StatusType.ERROR) + return self._enter_inactive() else: - # TODO bail out of the job and mark it as bad rather than dropping into inactive state - self.start_failures += 1 - if self.start_failures >= self.max_startup_attempts: - self._set_status("Failed to start; too many attempts", StatusType.ERROR) - return self._enter_inactive() - else: - self._set_status( - f"Start attempt failed ({self.start_failures}/{self.max_startup_attempts})", - StatusType.ERROR, - ) + self._set_status( + f"Start attempt failed ({self.start_failures}/{self.max_startup_attempts})", + StatusType.ERROR, + ) + return self._state_resolve_print def _long_idle(self, p): # We wait until we're in idle state for a long-ish period before acting, as @@ -334,11 +365,11 @@ def _state_printing(self, a: Action, p: Printer, elapsed=None): # A limitation of `octoprint.printer`, the "current file" path passed to the driver is only # the file name, not the full path to the file. # See https://docs.octoprint.org/en/master/modules/printer.html#octoprint.printer.PrinterCallback.on_printer_send_current_data - if item.path.split("/")[-1] == self._cur_path: + if item.resolve().split("/")[-1] == self._cur_path: return self._state_success else: self._logger.info( - f"Completed print {self._cur_path} not matching current queue item {item.path} - clearing it in prep to start queue item" + f"Completed print {self._cur_path} not matching current queue item {item.resolve()} - clearing it in prep to start queue item" ) return self._state_start_clearing diff --git a/continuousprint/driver_test.py b/continuousprint/driver_test.py index a84ef35..7483b43 100644 --- a/continuousprint/driver_test.py +++ b/continuousprint/driver_test.py @@ -21,7 +21,9 @@ def setUp(self): self.d.set_retry_on_pause(True) self.d.action(DA.DEACTIVATE, DP.IDLE) self.d._runner.run_script_for_event.reset_mock() + self.d._runner.start_print.return_value = True item = MagicMock(path="asdf") # return same item by default every time + item.resolve.return_value = "asdf" self.d.q.get_set_or_acquire.return_value = item self.d.q.get_set.return_value = item @@ -58,9 +60,45 @@ def test_activate_with_preprint_script(self): self.assertEqual(self.d.state.__name__, self.d._state_printing.__name__) self.d._runner.start_print.assert_called() + def test_activate_start_print_failure(self): + self.d._runner.run_script_for_event.return_value = None + self.d._runner.set_active.return_value = False + self.d.action(DA.ACTIVATE, DP.IDLE) # -> fail, resolve_print + self.assertEqual(self.d.state.__name__, self.d._state_resolve_print.__name__) + self.assertEqual(self.d.start_failures, 1) + + def test_activate_start_print_failure_from_exception(self): + self.d._runner.run_script_for_event.return_value = None + self.d._runner.set_active.return_value = True + self.d._runner.start_print.side_effect = Exception("test") + self.d.action(DA.ACTIVATE, DP.IDLE) # -> fail, resolve_print + self.assertEqual(self.d.state.__name__, self.d._state_resolve_print.__name__) + self.assertEqual(self.d.start_failures, 1) + + def test_activate_start_print_slicer_failure(self): + self.d._runner.run_script_for_event.return_value = None + self.d._runner.set_active.return_value = None # Indicate callback + self.d.action(DA.ACTIVATE, DP.IDLE) # -> slicing + self.assertEqual(self.d.state.__name__, self.d._state_slicing.__name__) + self.d.action(DA.RESOLVE_FAILURE, DP.IDLE) # -> resolve_print attempt #2 + self.assertEqual(self.d.state.__name__, self.d._state_resolve_print.__name__) + self.assertEqual(self.d.start_failures, 1) + + def test_activate_start_print_slicer_success(self): + self.d._runner.run_script_for_event.return_value = None + self.d._runner.set_active.return_value = None # Indicate callback + self.d.action(DA.ACTIVATE, DP.IDLE) # -> slicing + self.assertEqual(self.d.state.__name__, self.d._state_slicing.__name__) + + self.d._runner.set_active.return_value = True # Now resolvable + self.d.action( + DA.RESOLVED, DP.IDLE + ) # script_runner finished slicing and started the print + self.assertEqual(self.d.state.__name__, self.d._state_printing.__name__) + def test_activate_not_yet_printing(self): self.d._runner.run_script_for_event.return_value = None - self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_printing -> printing + self.d.action(DA.ACTIVATE, DP.IDLE) # -> resolve_print -> printing self.d.q.begin_run.assert_called() self.d._runner.start_print.assert_called_with(self.d.q.get_set.return_value) self.assertEqual(self.d.state.__name__, self.d._state_printing.__name__) @@ -104,6 +142,20 @@ def test_completed_print_not_in_queue(self): # Verify no end_run call anywhere in this process, since print was not in queue self.d.q.end_run.assert_not_called() + def test_completed_stl(self): + # In the case of STLs, the item path is not the print path + # But we should still complete the currently active print item + item = MagicMock(path="asdf.stl") + item.resolve.return_value = "asdf.stl.gcode" + self.d.q.get_set_or_acquire.return_value = item + self.d.q.get_set.return_value = item + self.d._runner.run_script_for_event.return_value = None + self.d.action(DA.ACTIVATE, DP.BUSY) # -> start print -> printing + self.assertEqual(self.d.state.__name__, self.d._state_printing.__name__) + self.d.action(DA.SUCCESS, DP.IDLE, "asdf.stl.gcode") # -> success + self.d.action(DA.TICK, DP.IDLE) # -> start_clearing, end_run() called + self.d.q.end_run.assert_called() + def test_start_clearing_waits_for_idle(self): self.d.state = self.d._state_start_clearing self.d.action(DA.TICK, DP.BUSY) @@ -222,9 +274,11 @@ def setUp(self): self.d._runner.verify_active.return_value = (True, None) self.d.set_retry_on_pause(True) item = MagicMock(path="asdf") # return same item by default every time + item.resolve.return_value = "asdf" self.d.q.get_set_or_acquire.return_value = item self.d.q.get_set.return_value = item self.d._runner.run_script_for_event.return_value = None + self.d._runner.start_print.return_value = True self.d.action(DA.DEACTIVATE, DP.IDLE) self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print -> printing self.d._runner.run_script_for_event.reset_mock() @@ -239,6 +293,7 @@ def test_success(self): self.d.action(DA.TICK, DP.IDLE) # -> start_clearing self.d.q.end_run.assert_called_once() item2 = MagicMock(path="basdf") + item2.resolve.return_value = "basdf" self.d.q.get_set_or_acquire.return_value = ( item2 # manually move the supervisor forward in the queue ) @@ -357,6 +412,7 @@ def setUp(self): ) self.d.set_retry_on_pause(True) self.d._runner.run_script_for_event.return_value = None + self.d._runner.start_print.return_value = True self.d.action(DA.DEACTIVATE, DP.IDLE) def _setItemMaterials(self, m): diff --git a/continuousprint/integration_test.py b/continuousprint/integration_test.py index 00a48ce..89f6c42 100644 --- a/continuousprint/integration_test.py +++ b/continuousprint/integration_test.py @@ -48,6 +48,9 @@ def onupdate(): self.d._runner.run_script_for_event.return_value = None self.d._runner.verify_active.return_value = (True, None) + # Default to succeeding when activating print + self.d._runner.set_active.return_value = True + self.d.set_retry_on_pause(True) self.d.action(DA.DEACTIVATE, DP.IDLE) @@ -191,6 +194,8 @@ def onupdate(): self.s = ScriptRunner( msg=MagicMock(), file_manager=self.fm, + get_key=MagicMock(), + slicing_manager=MagicMock(), logger=logging.getLogger(), printer=MagicMock(), refresh_ui_state=MagicMock(), @@ -297,7 +302,9 @@ def setUp(self): ) self.lq.lan.q.locks = LocalLockManager(dict(), "lq") self.lq.lan.q.jobs = TestReplDict(lambda a, b: None) - self.lq.lan.q.peers = dict() + self.lq.lan.q.peers = {} + self.lq.lan.q.peers[self.lq.addr] = (time.time(), dict(fs_addr="mock")) + self.lq._fileshare.fetch.return_value = "from_fileshare.gcode" def test_completes_job_in_order(self): self.lq.lan.q.setJob( @@ -346,12 +353,11 @@ def onupdate(): self.locks = {} self.peers = [] - lqpeers = {} - lqjobs = TestReplDict(lambda a, b: None) for i, db in enumerate(self.dbs): with db.bind_ctx(MODELS): populate_queues() fsm = MagicMock(host="fsaddr", port=0) + fsm.fetch.return_value = "from_fileshare.gcode" profile = dict(name="profile") lq = LANQueue( "LAN", @@ -372,17 +378,27 @@ def onupdate(): ) d._runner.verify_active.return_value = (True, None) d._runner.run_script_for_event.return_value = None + d._runner.set_active.return_value = True d.set_retry_on_pause(True) d.action(DA.DEACTIVATE, DP.IDLE) lq.lan.q = LANPrintQueueBase( lq.ns, lq.addr, MagicMock(), logging.getLogger("lantestbase") ) lq.lan.q.locks = LocalLockManager(self.locks, f"peer{i}") - lq.lan.q.jobs = lqjobs - lq.lan.q.peers = lqpeers - lq.update_peer_state(lq.addr, "status", "run", profile) + if i == 0: + lq.lan.q.jobs = TestReplDict(lambda a, b: None) + lq.lan.q.peers = dict() + else: + lq.lan.q.peers = self.peers[0][2].lan.q.peers + lq.lan.q.jobs = self.peers[0][2].lan.q.jobs self.peers.append((d, mq, lq, db)) + for p in self.peers: + self.peers[0][2].lan.q.peers[p[2].addr] = ( + time.time(), + dict(fs_addr="fakeaddr", profile=dict(name="profile")), + ) + def test_ordered_acquisition(self): logging.info("============ BEGIN TEST ===========") self.assertEqual(len(self.peers), 2) diff --git a/continuousprint/plugin.py b/continuousprint/plugin.py index a4a3fb4..dbf91ca 100644 --- a/continuousprint/plugin.py +++ b/continuousprint/plugin.py @@ -59,6 +59,7 @@ def __init__( printer, settings, file_manager, + slicing_manager, plugin_manager, queries, data_folder, @@ -71,6 +72,7 @@ def __init__( self._printer = printer self._settings = settings self._file_manager = file_manager + self._slicing_manager = slicing_manager self._plugin_manager = plugin_manager self._queries = queries self._data_folder = data_folder @@ -457,6 +459,8 @@ def _init_driver(self, srcls=ScriptRunner, dcls=Driver): self._runner = srcls( self.popup, self._file_manager, + self._get_key, + self._slicing_manager, self._logger, self._printer, self._sync_state, @@ -529,7 +533,9 @@ def _backlog_from_file_list(self, data): for k, v in data.items(): if v["type"] == "folder": backlog += self._backlog_from_file_list(v["children"]) - elif v.get(CPQProfileAnalysisQueue.META_KEY) is None: + elif v.get(CPQProfileAnalysisQueue.META_KEY) is None and v["path"].split( + "." + )[-1] in ("gcode", "g", "gco"): self._logger.debug(f"File \"{v['path']}\" needs analysis") backlog.append(v["path"]) else: @@ -645,8 +651,10 @@ def on_event(self, event, payload): # it's placed in a pending queue (see self._add_set). Now that # the processing is done, we actually add the set. path = payload["path"] - self._logger.debug(f"Handling completed analysis for {path}") pend = self._set_add_awaiting_metadata.get(path) + self._logger.debug( + f"Handling completed analysis for {path} - pending: {pend}" + ) if pend is not None: (path, sd, draft, profiles) = pend prof = payload["result"][CPQProfileAnalysisQueue.PROFILE_KEY] @@ -658,33 +666,30 @@ def on_event(self, event, payload): self._add_set(path, sd, draft, profiles) del self._set_add_awaiting_metadata[path] return - if ( - event == Events.FILE_ADDED - and self._profile_from_path(payload["path"]) is None - ): - # Added files should be checked for metadata and enqueued into CPQ custom analysis - if self._enqueue(payload["path"]): - self._logger.debug(f"Enqueued newly added file {payload['path']}") - return - if ( - event == Events.UPLOAD - ): # https://docs.octoprint.org/en/master/events/index.html#file-handling + if event == Events.FILE_ADDED: + if self._profile_from_path(payload["path"]) is None: + # Added files should be checked for metadata and enqueued into CPQ custom analysis + if self._enqueue(payload["path"]): + self._logger.debug(f"Enqueued newly added file {payload['path']}") + if event == Events.UPLOAD: upload_action = self._get_key(Keys.UPLOAD_ACTION, "do_nothing") if upload_action != "do_nothing": - if payload["path"].endswith(".gcode"): + path = payload["path"] + # TODO get object list from octoprint + if path.endswith(".gcode") or path.endswith(".stl"): self._add_set( - path=payload["path"], - sd=payload["target"] != "local", + path=path, + # Events.UPLOAD uses "target", EVents.FILE_ADDED uses "storage" + sd=payload.get("storage", payload.get("target")) != "local", draft=(upload_action != "add_printable"), ) - elif payload["path"].endswith(".gjob"): + elif path.endswith(".gjob"): self._get_queue(DEFAULT_QUEUE).import_job( - payload["path"], draft=(upload_action != "add_printable") + path, draft=(upload_action != "add_printable") ) self._sync_state() - else: - return + return if event == Events.MOVIE_DONE: self._timelapse_start_ts = None # Optionally delete time-lapses created from bed clearing/finishing scripts diff --git a/continuousprint/plugin_test.py b/continuousprint/plugin_test.py index 5db89cb..d9ec006 100644 --- a/continuousprint/plugin_test.py +++ b/continuousprint/plugin_test.py @@ -42,6 +42,7 @@ def setupPlugin(): printer=MagicMock(), settings=MockSettings(), file_manager=MagicMock(), + slicing_manager=MagicMock(), plugin_manager=MagicMock(), fire_event=MagicMock(), queries=MagicMock(), @@ -309,7 +310,7 @@ def testAddSetWithPending(self): ) def testUploadNoAction(self): - self.p.on_event(Events.UPLOAD, dict()) + self.p.on_event(Events.UPLOAD, dict(path="testpath.gcode")) self.p.d.action.assert_not_called() def testUploadAddPrintableInvalidFile(self): @@ -332,6 +333,12 @@ def testUploadAddPrintableGJob(self): "testpath.gjob", draft=False ) + def testUploadAddSTL(self): + self.p._set_key(Keys.UPLOAD_ACTION, "add_printable") + self.p._add_set = MagicMock() + self.p.on_event(Events.UPLOAD, dict(path="testpath.stl", target="local")) + self.p._add_set.assert_called_with(draft=False, sd=False, path="testpath.stl") + def testTempFileMovieDone(self): self.p._set_key(Keys.AUTOMATION_TIMELAPSE_ACTION, "auto_remove") self.p._delete_timelapse = MagicMock() diff --git a/continuousprint/script_runner.py b/continuousprint/script_runner.py index d9980ba..15e9a03 100644 --- a/continuousprint/script_runner.py +++ b/continuousprint/script_runner.py @@ -5,8 +5,10 @@ from octoprint.filemanager.destinations import FileDestinations from octoprint.printer import InvalidFileLocation, InvalidFileType from octoprint.server import current_user -from .storage.lan import ResolveError -from .data import TEMP_FILE_DIR, CustomEvents +from octoprint.slicing.exceptions import SlicingException +from .storage.lan import LANResolveError +from .storage.database import STLResolveError +from .data import TEMP_FILE_DIR, CustomEvents, Keys from .storage.queries import getAutomationForEvent from .automation import genEventScript, getInterpreter @@ -16,6 +18,8 @@ def __init__( self, msg, file_manager, + get_key, + slicing_manager, logger, printer, refresh_ui_state, @@ -24,8 +28,10 @@ def __init__( ): self._msg = msg self._file_manager = file_manager + self._slicing_manager = slicing_manager self._logger = logger self._printer = printer + self._get_key = get_key self._refresh_ui_state = refresh_ui_state self._fire_event = fire_event self._spool_manager = spool_manager @@ -95,21 +101,19 @@ def set_external_symbols(self, symbols): assert type(symbols) is dict self._symbols["external"] = symbols - def set_active(self, item): + def set_active(self, item, cb): path = item.path - # Set objects may not link directly to the path of the print file. - # In this case, we have to resolve the path by syncing files / extracting - # gcode files from .gjob. This works without any extra FileManager changes - # only becaue self._fileshare was configured with a basedir in the OctoPrint - # file structure - if hasattr(item, "resolve"): - try: - path = item.resolve() - except ResolveError as e: - self._logger.error(e) - self._msg(f"Could not resolve print path for {path}", type="error") - return False - self._logger.info(f"Resolved print path to {path}") + # Sets may not link directly to the path of the print file, instead to .gjob, .stl + # or other format where unpacking or transformation is needed to get to .gcode. + try: + path = item.resolve() + except LANResolveError as e: + self._logger.error(e) + self._msg(f"Could not resolve LAN print path for {path}", type="error") + return False + except STLResolveError as e: + self._logger.warning(e) + return self._start_slicing(item, cb) try: self._logger.info(f"Selecting {path} (sd={item.sd})") @@ -183,11 +187,91 @@ def run_script_for_event(self, evt, msg=None, msgtype=None): self._fire_event(evt) return result - def start_print(self, item): - self._msg(f"{item.job.name}: printing {item.path}") - if not self.set_active(item): + def _output_gcode_path(self, item): + # Avoid splitting suffixes so that we can more easily + # match against the item when checking if the print is finished + name = str(Path(item.path).name) + ".gcode" + return str(Path(TEMP_FILE_DIR) / name) + + def _cancel_any_slicing(self, item): + slicer = self._get_key(Keys.SLICER) + profile = self._get_key(Keys.SLICER_PROFILE) + if item.sd or slicer == "" or profile == "": return False + self._slicing_manager.cancel_slicing( + slicer, + item.path, + self._output_gcode_path(item), + ) + + def _start_slicing(self, item, cb): + # Cannot slice SD files, as they cannot be read (only written) + # Similarly we can't slice if slicing is disabled or there is no + # default slicer. + slicer = self._get_key(Keys.SLICER) + profile = self._get_key(Keys.SLICER_PROFILE) + if item.sd or slicer == "" or profile == "": + msg = f"Cannot slice item {item.path}, because:" + if item.sd: + msg += "\n* print file is on SD card" + if slicer == "": + msg += "\n* slicer not configured in CPQ settings" + if profile == "": + msg += "\n* slicer profile not configured in CPQ settings" + self._logger.error(msg) + self._msg(msg, type="error") + return False + + gcode_path = self._file_manager.path_on_disk( + FileDestinations.LOCAL, self._output_gcode_path(item) + ) + msg = f"Slicing {item.path} using slicer {slicer} and profile {profile}; output to {gcode_path}" + self._logger.info(msg) + self._msg(msg) + + def slicer_cb(*args, **kwargs): + if kwargs.get("_error") is not None: + cb(success=False, error=kwargs["_error"]) + self._msg( + f"Slicing failed with error: {kwargs['_error']}", type="error" + ) + elif kwargs.get("_cancelled"): + cb(success=False, error=Exception("Slicing cancelled")) + self._msg("Slicing was cancelled") + else: + item.resolve(gcode_path) # override the resolve value + cb(success=True, error=None) + + # We use _slicing_manager here instead of _file_manager to prevent FileAdded events + # from causing additional queue activity. + # Also fully resolve source and dest path as required by slicing manager + try: + self._slicing_manager.slice( + slicer, + self._file_manager.path_on_disk(FileDestinations.LOCAL, item.path), + gcode_path, + profile, + callback=slicer_cb, + ) + except SlicingException as e: + self._logger.error(e) + self._msg(self) + return False + return None # "none" indicates upstream to wait for cb() + + def start_print(self, item): + current_file = self._printer.get_current_job().get("file", {}).get("name") + # A limitation of `octoprint.printer`, the "current file" path passed to the driver is only + # the file name, not the full path to the file. + # See https://docs.octoprint.org/en/master/modules/printer.html#octoprint.printer.PrinterCallback.on_printer_send_current_data + resolved = item.resolve() + if resolved.split("/")[-1] != current_file: + raise Exception( + f"File loaded is {current_file}, but attempting to print {resolved}" + ) + + self._msg(f"{item.job.name}: printing {item.path}") if self._spool_manager is not None: # SpoolManager has additional actions that are normally run in JS # before a print starts. @@ -199,4 +283,3 @@ def start_print(self, item): self._fire_event(CustomEvents.PRINT_START) self._printer.start_print() self._refresh_ui_state() - return True diff --git a/continuousprint/script_runner_test.py b/continuousprint/script_runner_test.py index cbe1c11..48841f0 100644 --- a/continuousprint/script_runner_test.py +++ b/continuousprint/script_runner_test.py @@ -1,26 +1,44 @@ import unittest +from dataclasses import dataclass from io import StringIO from octoprint.printer import InvalidFileLocation, InvalidFileType +from octoprint.filemanager.destinations import FileDestinations +from octoprint.slicing.exceptions import SlicingException from collections import namedtuple from unittest.mock import MagicMock, ANY, patch from .script_runner import ScriptRunner from .data import CustomEvents from .storage.database_test import AutomationDBTest from .storage import queries +from .storage.database import SetView +from .storage.lan import LANResolveError import logging # logging.basicConfig(level=logging.DEBUG) -LI = namedtuple("LocalItem", ["sd", "path", "job"]) LJ = namedtuple("Job", ["name"]) +@dataclass +class LI(SetView): + sd: bool = False + path: str = "test.gcode" + job: namedtuple = None + + def resolve(self, override=None): + if getattr(self, "_resolved", None) is None: + self._resolved = self.path + return super().resolve(override) + + class TestScriptRunner(unittest.TestCase): def setUp(self): super().setUp() self.s = ScriptRunner( msg=MagicMock(), file_manager=MagicMock(), + get_key=MagicMock(), + slicing_manager=MagicMock(), logger=logging.getLogger(), printer=MagicMock(), refresh_ui_state=MagicMock(), @@ -82,60 +100,119 @@ def test_verify_active(self): self.s._spool_manager = None self.assertEqual(self.s.verify_active()[0], True) - def test_start_print_local(self): - self.assertEqual(self.s.start_print(LI(False, "a.gcode", LJ("job1"))), True) + def test_start_print_ok(self): + self.s._printer.get_current_job.return_value = dict(file=dict(name="foo.gcode")) + self.s.start_print(LI(False, "foo.gcode", LJ("job1"))) + + self.s._printer.start_print.assert_called_once() + self.s._spool_manager.start_print_confirmed.assert_called() + self.s._fire_event.assert_called_with(CustomEvents.PRINT_START) + + def test_start_print_file_mismatch(self): + self.s._printer.get_current_job.return_value = dict(file=dict(name="foo.gcode")) + with self.assertRaises(Exception): + self.s.start_print(LI(False, "bar.gcode", LJ("job1"))) + + self.s._printer.start_print.assert_not_called() + self.s._spool_manager.start_print_confirmed.assert_not_called() + self.s._fire_event.assert_not_called() + + def test_set_active_local(self): + self.assertEqual( + self.s.set_active(LI(False, "a.gcode", LJ("job1")), MagicMock()), True + ) self.s._printer.select_file.assert_called_with( "a.gcode", sd=False, printAfterSelect=False, user="foo", ) - self.s._printer.start_print.assert_called_once() - self.s._spool_manager.start_print_confirmed.assert_called() - self.s._fire_event.assert_called_with(CustomEvents.PRINT_START) - def test_start_print_sd(self): - self.assertEqual(self.s.start_print(LI(True, "a.gcode", LJ("job1"))), True) + def test_set_active_sd(self): + self.assertEqual( + self.s.set_active(LI(True, "a.gcode", LJ("job1")), MagicMock()), True + ) self.s._printer.select_file.assert_called_with( "a.gcode", sd=True, printAfterSelect=False, user="foo", ) - self.s._printer.start_print.assert_called_once() - self.s._spool_manager.start_print_confirmed.assert_called() - self.s._fire_event.assert_called_with(CustomEvents.PRINT_START) - def test_start_print_lan(self): - class NetItem: - path = "a.gcode" - job = LJ("job1") - sd = False + def test_set_active_lan_resolve_error(self): + li = MagicMock(LI()) + li.resolve.side_effect = LANResolveError("testing error") + self.assertEqual(self.s.set_active(li, MagicMock()), False) + self.s._printer.select_file.assert_not_called() - def resolve(self): - return "net/a.gcode" + def test_set_active_invalid_location(self): + self.s._printer.select_file.side_effect = InvalidFileLocation() + self.assertEqual( + self.s.set_active(LI(True, "a.gcode", LJ("job1")), MagicMock()), False + ) + self.s._fire_event.assert_not_called() - self.assertEqual(self.s.start_print(NetItem()), True) - self.s._printer.select_file.assert_called_with( - "net/a.gcode", - sd=False, - printAfterSelect=False, - user="foo", + def test_set_active_invalid_filetype(self): + self.s._printer.select_file.side_effect = InvalidFileType() + self.assertEqual( + self.s.set_active(LI(True, "a.gcode", LJ("job1")), MagicMock()), False ) - self.s._printer.start_print.assert_called_once() - self.s._spool_manager.start_print_confirmed.assert_called() - self.s._fire_event.assert_called_with(CustomEvents.PRINT_START) + self.s._fire_event.assert_not_called() - def test_start_print_invalid_location(self): - self.s._printer.select_file.side_effect = InvalidFileLocation() - self.assertEqual(self.s.start_print(LI(True, "a.gcode", LJ("job1"))), False) + def test_set_active_stl_slicing_disabled(self): + self.s._file_manager = MagicMock(slicing_enabled=False) + self.assertEqual( + self.s.set_active(LI(True, "a.stl", LJ("job1")), MagicMock()), False + ) self.s._fire_event.assert_not_called() - def test_start_print_invalid_filetype(self): - self.s._printer.select_file.side_effect = InvalidFileType() - self.assertEqual(self.s.start_print(LI(True, "a.gcode", LJ("job1"))), False) + def test_set_active_stl_sd(self): + self.s._file_manager = MagicMock( + slicing_enabled=False, default_slicer="DEFAULT_SLICER" + ) + self.assertEqual( + self.s.set_active(LI(True, "a.stl", LJ("job1")), MagicMock()), False + ) self.s._fire_event.assert_not_called() + def test_set_active_stl(self): + cb = MagicMock() + self.s._file_manager.path_on_disk.side_effect = lambda d, p: p + self.s._get_key.side_effect = ("testslicer", "testprofile") + + self.assertEqual(self.s.set_active(LI(False, "a.stl", LJ("job1")), cb), None) + self.s._slicing_manager.slice.assert_called_with( + "testslicer", + "a.stl", + "ContinuousPrint/tmp/a.stl.gcode", + "testprofile", + callback=ANY, + ) + self.s._printer.select_file.assert_not_called() + + # Test callbacks + slice_cb = self.s._slicing_manager.slice.call_args[1]["callback"] + slice_cb(_analysis="foo") + cb.assert_called_with(success=True, error=None) + cb.reset_mock() + + slice_cb(_error="bar") + cb.assert_called_with(success=False, error="bar") + cb.reset_mock() + + slice_cb(_cancelled=True) + cb.assert_called_with(success=False, error=ANY) + cb.reset_mock() + + def test_set_active_stl_exception(self): + cb = MagicMock() + self.s._file_manager.path_on_disk.side_effect = lambda d, p: p + self.s._get_key.side_effect = ("testslicer", "testprofile") + + self.s._slicing_manager.slice.side_effect = SlicingException("test") + self.assertEqual(self.s.set_active(LI(False, "a.stl", LJ("job1")), cb), False) + self.s._printer.select_file.assert_not_called() + class TestWithInterpreter(AutomationDBTest): def setUp(self): @@ -143,6 +220,8 @@ def setUp(self): self.s = ScriptRunner( msg=MagicMock(), file_manager=MagicMock(), + get_key=MagicMock(), + slicing_manager=MagicMock(), logger=logging.getLogger(), printer=MagicMock(), refresh_ui_state=MagicMock(), diff --git a/continuousprint/static/js/continuousprint.js b/continuousprint/static/js/continuousprint.js index 4fe9c99..80691f3 100644 --- a/continuousprint/static/js/continuousprint.js +++ b/continuousprint/static/js/continuousprint.js @@ -16,7 +16,8 @@ $(function() { let titleregex = /
([\s\S]*)<.div>/mi; let template = '
'; - let mctmpl = $($.parseHTML('
' + $("#files_template_machinecode").text() + '
')[0]); + let mc = $("#files_template_machinecode"); + let mctmpl = $($.parseHTML('
' + mc.text() + '
')[0]); let actions = mctmpl.find('.action-buttons'); actions.attr('data-bind', "css: 'cpq-' + display.split('.')[1]"); actions.append(template); @@ -25,7 +26,16 @@ $(function() { title.append(``); title.append(''); - $("#files_template_machinecode").text(mctmpl.html()); + mc.text(mctmpl.html()); + + // Also inject the add-to-queue button for models, which can be auto-sliced + let mdl = $("#files_template_model"); + let modeltmpl = $($.parseHTML('
' + mdl.text() + '
')[0]); + actions = modeltmpl.find('.action-buttons'); + actions.attr('data-bind', "css: 'cpq-' + display.split('.')[1]"); + actions.append(template); + + mdl.text(modeltmpl.html()); // This injects the status of the queue into PrinterStateViewModel (the "State" panel) $("#state .accordion-inner").prepend(` diff --git a/continuousprint/static/js/continuousprint_settings.js b/continuousprint/static/js/continuousprint_settings.js index f386ebf..87267c0 100644 --- a/continuousprint/static/js/continuousprint_settings.js +++ b/continuousprint/static/js/continuousprint_settings.js @@ -11,14 +11,15 @@ if (typeof log === "undefined" || log === null) { CPAPI = require('./continuousprint_api'); CP_SIMULATOR_DEFAULT_SYMTABLE = function() {return {};}; CPSettingsEvent = require('./continuousprint_settings_event'); + OctoPrint = undefined; } -function CPSettingsViewModel(parameters, profiles=CP_PRINTER_PROFILES, default_scripts=CP_GCODE_SCRIPTS, custom_events=CP_CUSTOM_EVENTS, default_symtable=CP_SIMULATOR_DEFAULT_SYMTABLE) { +function CPSettingsViewModel(parameters, profiles=CP_PRINTER_PROFILES, default_scripts=CP_GCODE_SCRIPTS, custom_events=CP_CUSTOM_EVENTS, default_symtable=CP_SIMULATOR_DEFAULT_SYMTABLE, octoprint=OctoPrint) { var self = this; self.PLUGIN_ID = "octoprint.plugins.continuousprint"; self.log = log.getLogger(self.PLUGIN_ID); self.settings = parameters[0]; - self.files = parameters[1] + self.files = parameters[1]; self.api = parameters[2] || new CPAPI(); self.loading = ko.observable(false); self.api.init(self.loading, function(code, reason) { @@ -33,6 +34,40 @@ function CPSettingsViewModel(parameters, profiles=CP_PRINTER_PROFILES, default_s }); self.local_ip = ko.observable(CP_LOCAL_IP || ''); + // We have to use the global slicer data retriever instead of + // slicingViewModel because the latter does not make its profiles + // available without modifying the slicing modal. + self.slicers = ko.observable({}); + self.slicer = ko.observable(); + self.slicer_profile = ko.observable(); + if (octoprint !== undefined) { + octoprint.slicing.listAllSlicersAndProfiles().done(function (data) { + let result = {}; + for (let d of Object.values(data)) { + let profiles = []; + let default_profile = null; + for (let p of Object.keys(d.profiles)) { + if (d.profiles[p].default) { + default_profile = p; + continue; + } + profiles.push(p); + } + if (default_profile) { + profiles.unshift(default_profile); + } + result[d.key] = { + name: d.displayName, + key: d.key, + profiles, + }; + } + self.slicers(result); + }); + } + self.slicerProfiles = ko.computed(function() { + return (self.slicers()[self.slicer()] || {}).profiles; + }); // Constants defined in continuousprint_settings.jinja2, passed from the plugin (see `get_template_vars()` in __init__.py) self.profiles = {}; for (let prof of profiles) { @@ -308,6 +343,8 @@ function CPSettingsViewModel(parameters, profiles=CP_PRINTER_PROFILES, default_s self.selected_model(prof.model); break; } + self.slicer(self.settings.settings.plugins.continuousprint.cp_slicer()); + self.slicer_profile(self.settings.settings.plugins.continuousprint.cp_slicer_profile()); } // Queues and scripts are stored in the DB; we must fetch them whenever // the settings page is loaded @@ -355,6 +392,10 @@ function CPSettingsViewModel(parameters, profiles=CP_PRINTER_PROFILES, default_s // Called automatically by SettingsViewModel self.onSettingsBeforeSave = function() { + let cpset = self.settings.settings.plugins.continuousprint; + cpset.cp_slicer(self.slicer()); + cpset.cp_slicer_profile(self.slicer_profile()); + let queues = self.queues(); if (JSON.stringify(queues) !== self.queue_fingerprint) { // Sadly it appears flask doesn't have good parsing of nested POST structures, diff --git a/continuousprint/static/js/continuousprint_settings.test.js b/continuousprint/static/js/continuousprint_settings.test.js index 0d2f11b..9bd8c4c 100644 --- a/continuousprint/static/js/continuousprint_settings.test.js +++ b/continuousprint/static/js/continuousprint_settings.test.js @@ -37,6 +37,14 @@ const EVENTS = [ {event: 'e1'}, ]; +function mock_oprint() { + return { + slicing: { + listAllSlicersAndProfiles: jest.fn(), + }, + }; +} + function mocks() { return [ { @@ -46,6 +54,8 @@ function mocks() { cp_bed_clearing_script: jest.fn(), cp_queue_finished_script: jest.fn(), cp_printer_profile: jest.fn(), + cp_slicer: jest.fn(), + cp_slicer_profile: jest.fn(), }, }, }, @@ -290,3 +300,55 @@ test('add new preprocessor from Events tab', () =>{ expect(v.gotoTab).toHaveBeenCalled(); }); + +test('Get slicers and profiles for dropdowns', () => { + let op = mock_oprint(); + let cb = null; + + op.slicing.listAllSlicersAndProfiles = () => { + return { + done: (c) => cb = c + }; + }; + let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS, EVENTS, CP_SIMULATOR_DEFAULT_SYMTABLE, op); + cb({"preprintservice":{ + "configured":false, + "default":false, + "displayName":"PrePrintService", + "extensions":{ + "destination":["gco","gcode","g"], + "source":["stl"] + }, + "key":"preprintservice", + "profiles":{ + "profile_015mm_brim":{ + "default":true, + "description":"Imported ...", + "displayName":"profile_015mm_brim\n", + "key":"profile_015mm_brim", + "resource":"http://localhost:5000/api/slicing/preprintservice/profiles/profile_015mm_brim" + } + }, + "sameDevice":false + }}); + expect(v.slicers()).toEqual({ + "preprintservice": { + "key": "preprintservice", + "name": "PrePrintService", + "profiles": ["profile_015mm_brim"], + }, + }); +}); + +test('Set slicer & profile before save', () => { + let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS, EVENTS); + v.slicers({slicername: {key: "slicername", name: "Slicer", profiles: ["profile1", "profile2"]}}); + + v.slicer("slicername"); + expect(v.slicerProfiles()).toEqual(["profile1", "profile2"]); + v.slicer_profile("profile2"); + + v.onSettingsBeforeSave(); + expect(v.settings.settings.plugins.continuousprint.cp_slicer).toHaveBeenCalledWith("slicername"); + expect(v.settings.settings.plugins.continuousprint.cp_slicer_profile).toHaveBeenCalledWith("profile2"); +}); diff --git a/continuousprint/static/js/continuousprint_viewmodel.js b/continuousprint/static/js/continuousprint_viewmodel.js index abca6fa..ab32a9e 100644 --- a/continuousprint/static/js/continuousprint_viewmodel.js +++ b/continuousprint/static/js/continuousprint_viewmodel.js @@ -112,7 +112,6 @@ function CPViewModel(parameters) { self.hideRemoveConfirmModal(); }; self.showSettingsHelp = function() { - console.log(self.settings); self.settings.show('settings_plugin_continuousprint'); $(`#settings_plugin_continuousprint a[href="#settings_continuousprint_help"]`).tab('show'); }; diff --git a/continuousprint/storage/database.py b/continuousprint/storage/database.py index 4a215ce..6359efb 100644 --- a/continuousprint/storage/database.py +++ b/continuousprint/storage/database.py @@ -21,12 +21,20 @@ import datetime from enum import IntEnum, auto import sys +import logging import inspect import os import yaml import time +logging.getLogger("peewee").setLevel(logging.INFO) + + +class STLResolveError(Exception): + pass + + # Defer initialization class DB: # Adding foreign_keys pragma is necessary for ON DELETE behavior @@ -218,6 +226,20 @@ def decrement(self, profile): self.save() # Save must occur before job is observed return self.job.next_set(profile) + def resolve(self, override=None): + if override is not None: + self._resolved = override + + # TODO use registered slicer object types per octoprint hook + if not hasattr(self, "_resolved") or self._resolved is None: + raise NotImplementedError( + "Implementer of SetView must implement .resolve()" + ) + elif self._resolved.endswith(".stl"): + raise STLResolveError(f"Set path {self._resolved} requires slicing") + else: + return self._resolved + @classmethod def from_dict(self, s): raise NotImplementedError @@ -285,6 +307,11 @@ def from_dict(self, s): del s[listform] return Set(**s) + def resolve(self, override=None): + if getattr(self, "_resolved", None) is None: + self._resolved = self.path + return super().resolve(override) + class Run(Model): # Runs are totally decoupled from queues, jobs, and sets - this ensures that diff --git a/continuousprint/storage/database_test.py b/continuousprint/storage/database_test.py index 2681ab5..441d7b6 100644 --- a/continuousprint/storage/database_test.py +++ b/continuousprint/storage/database_test.py @@ -11,11 +11,13 @@ migrateQueuesV2ToV3, Job, Set, + SetView, Run, Script, EventHook, StorageDetails, DEFAULT_QUEUE, + STLResolveError, ) from ..data import CustomEvents import tempfile @@ -27,10 +29,7 @@ class QueuesDBTest(unittest.TestCase): def setUp(self): self.tmpQueues = tempfile.NamedTemporaryFile(delete=True) self.addCleanup(self.tmpQueues.close) - init_queues( - self.tmpQueues.name, - logger=logging.getLogger(), - ) + init_queues(self.tmpQueues.name) self.q = Queue.get(name=DEFAULT_QUEUE) @@ -38,10 +37,7 @@ class AutomationDBTest(unittest.TestCase): def setUp(self): self.tmpAutomation = tempfile.NamedTemporaryFile(delete=True) self.addCleanup(self.tmpAutomation.close) - init_automation( - self.tmpAutomation.name, - logger=logging.getLogger(), - ) + init_automation(self.tmpAutomation.name) class DBTest(QueuesDBTest, AutomationDBTest): @@ -361,6 +357,23 @@ def testDecrementEndNotDoubleCounted(self): self.assertEqual(self.j.remaining, 0) self.assertEqual(self.s.remaining, 0) + def testResolveUnimplemented(self): + sv = SetView() + with self.assertRaises(NotImplementedError): + sv.resolve() + + def testResolveGcode(self): + self.assertEqual(self.s.resolve(), self.s.path) + + def testResolveSTL(self): + self.s.path = "testpath.stl" + with self.assertRaises(STLResolveError): + self.s.resolve() + + def testResolveAlreadySet(self): + self.s._resolved = "testval" + self.assertEqual(self.s.resolve(), "testval") + def testFromDict(self): d = self.s.as_dict() s = Set.from_dict(d) diff --git a/continuousprint/storage/lan.py b/continuousprint/storage/lan.py index 272ec75..638dc90 100644 --- a/continuousprint/storage/lan.py +++ b/continuousprint/storage/lan.py @@ -52,7 +52,7 @@ def refresh_sets(self): self.save() -class ResolveError(Exception): +class LANResolveError(Exception): pass @@ -71,13 +71,13 @@ def __init__(self, data, job, rank): self.profile_keys = ",".join(data.get("profiles", [])) self._resolved = None - def resolve(self) -> str: + def resolve(self, override=None) -> str: if self._resolved is None: try: self._resolved = str(Path(self.job.get_base_dir()) / self.path) except HTTPError as e: - raise ResolveError(f"Failed to resolve {self.path}") from e - return self._resolved + raise LANResolveError(f"Failed to resolve {self.path}") from e + return super().resolve(override) def save(self): self.job.save() diff --git a/continuousprint/storage/lan_test.py b/continuousprint/storage/lan_test.py index f85d0f4..91ece90 100644 --- a/continuousprint/storage/lan_test.py +++ b/continuousprint/storage/lan_test.py @@ -1,6 +1,7 @@ import unittest from unittest.mock import MagicMock -from .lan import LANJobView, LANSetView, ResolveError +from .database import STLResolveError +from .lan import LANJobView, LANSetView, LANResolveError from requests.exceptions import HTTPError @@ -25,6 +26,13 @@ def test_resolve_file(self): self.lq.get_gjob_dirpath.return_value = "/path/to/" self.assertEqual(self.s.resolve(), "/path/to/a.gcode") + def test_resolve_stl(self): + # Ensure STL checking from the parent class is still triggered + self.j.sets[0].path = "a.stl" + self.lq.get_gjob_dirpath.return_value = "/path/to/" + with self.assertRaises(STLResolveError): + self.s.resolve() + def test_remap_set_paths(self): self.lq.get_gjob_dirpath.return_value = "/path/to/" self.j.remap_set_paths() @@ -32,7 +40,7 @@ def test_remap_set_paths(self): def test_resolve_http_error(self): self.lq.get_gjob_dirpath.side_effect = HTTPError - with self.assertRaises(ResolveError): + with self.assertRaises(LANResolveError): self.s.resolve() def test_decrement_refreshes_sets_and_saves(self): diff --git a/continuousprint/templates/continuousprint_settings.jinja2 b/continuousprint/templates/continuousprint_settings.jinja2 index 7201d11..9a48b5d 100644 --- a/continuousprint/templates/continuousprint_settings.jinja2 +++ b/continuousprint/templates/continuousprint_settings.jinja2 @@ -430,6 +430,7 @@
+
@@ -439,6 +440,7 @@
+
@@ -451,6 +453,28 @@
+ +
+ +
+ +
+ +
+ +
+
+
diff --git a/docker-compose.yaml b/docker-compose.yaml index ed2bf7a..8ffb69b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -10,6 +10,9 @@ services: - "./volume:/home/oprint/.octoprint" environment: - "PYTHONUNBUFFERED=1" + hostname: "octoprint" + networks: + - preprintservice dev2: image: continuousprint-dev build: . @@ -20,3 +23,8 @@ services: - "./volume2:/home/oprint/.octoprint" environment: - "PYTHONUNBUFFERED=1" + +networks: + preprintservice: + name: octoprint-preprintservice_default + external: true diff --git a/docs/auto-slicer.md b/docs/auto-slicer.md new file mode 100644 index 0000000..86417bc --- /dev/null +++ b/docs/auto-slicer.md @@ -0,0 +1,92 @@ +# Automatic Slicing + +## Use Case + +Continuous Print normally prints `.gcode` files. These files are sliced for a specific printer and are not portable across makes/models. + +Typically, 3d models are sliced by external slicer programs, and [profiles](/printer-profiles) are assigned in the queue so it only runs on compatible printers. This is especially important for heterogeneous [LAN queues](/lan-queues). + +With automatic slicing, **you can add 3D models directly to the queue for printing**. This eliminates some manual effort and sources of error. + + +## Setup + +OctoPrint supports integration with slicers via the [`SlicerPlugin` mixin](https://docs.octoprint.org/en/master/plugins/mixins.html#slicerplugin) - this mixin is inherited by various plugins to allow slicing models in the OctoPrint file manager by clicking a "slice" button next to the file. + +Any plugin that uses this mixin should enable automated slicing, but for the sake of awesomeness we will use [PrePrintService](https://github.com/christophschranz/OctoPrint-PrePrintService) which can also automatically orient your model before slicing it to maximize the likelihood of a successful print. + +!!! Warning + + Just because the file automatically slices, doesn't mean it'll slice *correctly*. + + PrePrintService improves the odds with automatic orientation, but this will only work as correctly as it's configured, and may not work at all if your printer is non-cartesian (e.g. a belt printer). + +### Requirements + +You will need: + +* A machine with [Docker](https://www.docker.com/) installed and running - this may be the same as the OctoPrint server, or a different one on the same network. +* Some form of [git](https://git-scm.com/) tool to download the forked PrePrintService repository + +### Install PrePrintService + +First, we'll set up the slicer server. On your OctoPrint machine or another machine accessible over the network, run the following commands (assuming Linux): + +``` +git clone https://github.com/ChristophSchranz/Octoprint-PrePrintService.git --branch test_and_fix --single-branch +cd Octoprint-PrePrintService +docker-compose up --build -d + +# To follow the logs: +docker-compose logs -f +``` + +Now, we need to install the plugin so OctoPrint can communicate with the slicer. + +1. Navigate to `Settings > Plugin Manager > + Get More` in the OctoPrint interface. +2. Add the following URL into the `... from URL` box. +3. Click the adjacent `Install` button to install the forked PrePrintService plugin, then restart OctoPrint when prompted. +4. Navigate to `Settings > PrePrintService Plugin`. +5. Set the `PrePrintService URL` text input to point to your slicer server, e.g. `http://pre-print-service:2304/tweak`. +6. Uncheck the `Receive auto-rotated model file` setting to prevent the slicer server from pushing intermediate models into the queue. +7. Import a slic3r profile - you can generate one in [Slic3r](https://slic3r.org/) and export it [like this](https://manual.slic3r.org/configuration-organization/configuration-organization#:~:text=If%20you%20want%20to%20store,not%20just%20the%20selected%20profiles). +8. Click `Save` to save your settings, then restart OctoPrint. + +You should be able to click the "magic wand" button next to an STL file in the file manager to slice the file to .gcode - this may take a minute or two if you installed the slicer server on a slow machine (e.g. raspberry pi). + +Finally, we need Continuous Print to know what slicer to use when running STL files: + +1. Navigate to `Settings > Continuous Print` in the OctoPrint interface, then click the `Behavior` tab to show behavior settings. +2. Select `PrePrintService` under `Slicer for auto-slicing`. +3. Select the profile you uploaded earlier under `Slicer Profile`. +4. Click Save. + +After following these instructions, you should have: + +* The service container started and running +* PrePrintService plugin installed and pointing to the service +* The Continuous Print plugin installed (of course!) + +You'll know you have the correct settings when you see in the logs: + +``` +Connection to PrePrintService on <...> is ready +``` + +## Usage + +!!! Warning + + Auto-slicing may make weird decisions about how to orient your print, or even incorrect decisions if your printer is not correctly modeled ([e.g. belt printers are not currently supported in Tweaker](https://github.com/ChristophSchranz/Tweaker-3/issues/24)). + + It's strongly recommended to watch your first few print attempts until you're confident in the setup. + + Also, consider setting up [failure recovery](/failure-recovery) so failing prints are more likely to be caught automatically. + +With the default slicer configured, it's time to try it out! + +1. Upload an `.stl` file you wish to test out. +1. Click the `+` arrow in the file manager to add it to the queue. +`. Click `Start Managing`, and watch as the STL is detected, sliced into `.gcode`, and printed. + +Note that you can mix `.gcode` and `.stl` files in your queue, and Continuous Print will handle them accordingly. diff --git a/docs/contributing.md b/docs/contributing.md index 6d8133e..f2c90ae 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -186,3 +186,4 @@ This is a collection of random tidbits intended to help you get your bearings. * Applied fix from https://github.com/SortableJS/knockout-sortablejs/pull/13 * Applied fix from https://github.com/SortableJS/knockout-sortablejs/issues/14 * Discussion at https://github.com/smartin015/continuousprint/issues/14 (conflict with a different `knockout-sortable` library) +* Running PrePrintService for development purposes can be done through the usual installation steps, but using `http://pre-print-service:2304` as the host. CPQ's docker-compose.yaml file is already configured to join PrePrintService's docker network. diff --git a/docs/managed-bed-cooldown.md b/docs/managed-bed-cooldown.md index a4e5a66..c43f62e 100644 --- a/docs/managed-bed-cooldown.md +++ b/docs/managed-bed-cooldown.md @@ -1,16 +1,18 @@ # Managed Bed Cooldown + ## Use Case + Depending on your printer model the g-code instruction M190 (Wait for Bed Temperature) is not always respected when the targed temperature is cooling down. For printers that don't respect the M190 cooldown instruction but depend on the bed cooling to a specified temperature this feature should be enabled. ## Configure feature + This feature can be configured in the Continuous Print settings panel under `Bed Cooldown Settings`. **Enable Managed Bed Cooldown Box** enables and disables the feature. - **Bed Cooldown Script** is the G-Code script that will run once print in queue is finished, but before bed cooldown is run. Useful for triggering events via g-code like activating part cooling fan, or moving print head from above part while it cools. **Bed Cooldown Threshold** is the temperature in Celsius that once met triggers the bed to clear. @@ -18,7 +20,6 @@ The goal is to pick a temperature at which the part becomes free from the bed. E **Bed Cooldown Timeout** a timeout in minutes starting from after the bed clear script has run when once exceeded bed will be cleared regardless of bed temperature. Useful for cases where the target bed temperature is not being met, but the part is ready to be cleared anyway. Useful for cases where the part cools faster than the bed, or external environment is too hot so bed is not meeting temperature, but part has already cooled enough. - Once configured the final event flow will look like this `PRINT FINISHES -> Bed Cooldown Script Runs -> Bed is turned off -> Wait until measured temp meets threshold OR timeout is exceeded -> Bed Clearing Script Runs -> NEXT PRINT BEGINS` diff --git a/mkdocs.yml b/mkdocs.yml index 0d07a07..ffcd0fc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -32,6 +32,7 @@ nav: - 'material-selection.md' - 'printer-profiles.md' - 'managed-bed-cooldown.md' + - 'auto-slicer.md' - 'action-commands.md' - 'contributing.md' - 'api.md'