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 = /