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 1932494..7c4dd76 100644 --- a/continuousprint/__init__.py +++ b/continuousprint/__init__.py @@ -12,6 +12,7 @@ ASSETS, TEMPLATES, update_info, + SIMULATOR_DEFAULT_SYMTABLE, ) from .storage import queries from .api import Permission as CPQPermission @@ -26,7 +27,6 @@ class ContinuousprintPlugin( octoprint.plugin.StartupPlugin, octoprint.plugin.EventHandlerPlugin, ): - # -------------------- Begin BlueprintPlugin -------------------- def get_blueprint(self): @@ -53,6 +53,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(), @@ -64,6 +65,13 @@ 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() # It's possible to miss events or for some weirdness to occur in conditionals. Adding a watchdog @@ -108,6 +116,7 @@ def get_template_vars(self): gcode_scripts=list(GCODE_SCRIPTS.values()), custom_events=[e.as_dict() for e in CustomEvents], local_ip=local_ip, + simulator_default_symtable=SIMULATOR_DEFAULT_SYMTABLE, ) def get_template_configs(self): diff --git a/continuousprint/api.py b/continuousprint/api.py index 9e0b13b..62f98e6 100644 --- a/continuousprint/api.py +++ b/continuousprint/api.py @@ -3,10 +3,12 @@ from octoprint.access.permissions import Permissions, ADMIN_GROUP from octoprint.server.util.flask import restricted_access from .queues.lan import ValidationError +from .automation import getInterpreter, genEventScript import flask import json from .storage import queries from .storage.database import DEFAULT_QUEUE +from .data import CustomEvents from .driver import Action as DA from abc import ABC, abstractmethod @@ -344,3 +346,27 @@ def get_automation(self): def set_automation_external_symbols(self): self._set_external_symbols(flask.request.get_json()) return json.dumps("OK") + + # PRIVATE API METHOD - may change without warning. + @octoprint.plugin.BlueprintPlugin.route("/automation/simulate", methods=["POST"]) + @restricted_access + @cpq_permission(Permission.EDITAUTOMATION) + def simulate_automation(self): + symtable = json.loads(flask.request.form.get("symtable")) + automation = json.loads(flask.request.form.get("automation")) + interp, out, err = getInterpreter(symtable) + symtable = interp.symtable.copy() # Pick up defaults + result = dict( + gcode=genEventScript(automation, interp), + symtable_diff={}, + ) + + err.seek(0) + result["stderr"] = err.read() + out.seek(0) + result["stdout"] = out.read() + for k, v in interp.symtable.items(): + if k not in symtable or symtable[k] != v: + result["symtable_diff"][k] = repr(v) + self._logger.debug(f"Simulator result: {result}") + return json.dumps(result) diff --git a/continuousprint/api_test.py b/continuousprint/api_test.py index a7c37a7..84c86c6 100644 --- a/continuousprint/api_test.py +++ b/continuousprint/api_test.py @@ -2,7 +2,7 @@ import json import logging from .driver import Action as DA -from unittest.mock import patch, MagicMock, call +from unittest.mock import patch, MagicMock, call, PropertyMock import imp from flask import Flask from .api import Permission, cpq_permission @@ -87,6 +87,7 @@ def test_role_access_denied(self): ("GETAUTOMATION", "/automation/get"), ("EDITAUTOMATION", "/automation/edit"), ("EDITAUTOMATION", "/automation/external"), + ("EDITAUTOMATION", "/automation/simulate"), ] self.api._get_queue = None # MagicMock interferes with checking @@ -102,7 +103,7 @@ def test_role_access_denied(self): num_perms = len([p for p in Permission]) self.assertEqual(num_perms_tested, num_perms) - for (role, endpoint) in testcases: + for role, endpoint in testcases: p = getattr(self.perm, f"PLUGIN_CONTINUOUSPRINT_{role}") p.can.return_value = False if role.startswith("GET"): @@ -301,3 +302,36 @@ def test_automation_external(self, q): rep = self.client.post("/automation/external", json=dict(foo="bar")) self.assertEqual(rep.status_code, 200) self.api._set_external_symbols.assert_called_with(dict(foo="bar")) + + @patch("continuousprint.api.getInterpreter") + @patch("continuousprint.api.genEventScript") + def test_automation_simulate(self, ge, gi): + self.perm.PLUGIN_CONTINUOUSPRINT_EDITAUTOMATION.can.return_value = True + st = PropertyMock(side_effect=[dict(), dict(b=2, c=3)]) + mi = MagicMock() + type(mi).symtable = st + out = MagicMock() + out.read.return_value = "stdout" + err = MagicMock() + err.read.return_value = "stderr" + + gi.return_value = (mi, out, err) + ge.return_value = "gcode" + + rep = self.client.post( + "/automation/simulate", + data=dict( + automation=json.dumps([]), + symtable=json.dumps(dict(a=1, b=1)), + ), + ) + self.assertEqual(rep.status_code, 200) + self.assertEqual( + json.loads(rep.get_data(as_text=True)), + { + "gcode": "gcode", + "stderr": "stderr", + "stdout": "stdout", + "symtable_diff": {"b": "2", "c": "3"}, + }, + ) diff --git a/continuousprint/automation.py b/continuousprint/automation.py new file mode 100644 index 0000000..4f47da1 --- /dev/null +++ b/continuousprint/automation.py @@ -0,0 +1,45 @@ +from io import StringIO +import re +from asteval import Interpreter + + +def getInterpreter(symbols): + out = StringIO() + err = StringIO() + interp = Interpreter(writer=out, err_writer=err) + # Merge in so default symbols (e.g. exceptions) are retained + for k, v in symbols.items(): + interp.symtable[k] = v + return interp, out, err + + +def genEventScript(automation: list, interp=None, logger=None) -> str: + result = [] + for script, preprocessor in automation: + procval = True + if preprocessor is not None and preprocessor.strip() != "": + procval = interp(preprocessor) + if logger: + logger.info( + f"EventHook preprocessor: {preprocessor}\nResult: {procval}" + ) + + if procval is None or procval is False: + continue + elif procval is True: + formatted = script + elif type(procval) is dict: + if logger: + logger.info(f"Appending script using formatting data {procval}") + formatted = script.format(**procval) + else: + raise Exception( + f"Invalid return type {type(procval)} for peprocessor {preprocessor}" + ) + + leftovers = re.findall(r"\{.*?\}", formatted) + if len(leftovers) > 0: + ppname = " (preprocessed)" if e.preprocessor is not None else "" + raise Exception(f"Unformatted placeholders in script{ppname}: {leftovers}") + result.append(formatted) + return "\n".join(result) diff --git a/continuousprint/automation_test.py b/continuousprint/automation_test.py new file mode 100644 index 0000000..47542a6 --- /dev/null +++ b/continuousprint/automation_test.py @@ -0,0 +1,37 @@ +import unittest +from .automation import getInterpreter, genEventScript + + +class TestInterpreter(unittest.TestCase): + def testGetInterpreter(self): + interp, _, _ = getInterpreter(dict(a=1)) + self.assertEqual(interp.symtable["a"], 1) + + +class TestGenEventScript(unittest.TestCase): + def testEvalTrueFalseNone(self): + a = [("gcode1", "p1")] + self.assertEqual(genEventScript(a, lambda cond: True), "gcode1") + self.assertEqual(genEventScript(a, lambda cond: False), "") + self.assertEqual(genEventScript(a, lambda cond: None), "") + + def testPlaceholderNoPreprocessor(self): + a = [("{foo} will never be formatted!", None)] + with self.assertRaises(Exception): + genEventScript(a, lambda cond: False) + + def testEvalMissedPlaceholder(self): + a = [("{foo} will never be formatted!", "p1")] + with self.assertRaises(Exception): + genEventScript(a, lambda cond: dict(bar="baz")) + + def testEvalFormat(self): + a = [("Hello {val}", "p1")] + self.assertEqual( + genEventScript(a, lambda cond: dict(val="World")), "Hello World" + ) + + def testEvalBadType(self): + a = [("dontcare", "p1")] + with self.assertRaises(Exception): + genEventScript(a, lambda cond: 7) diff --git a/continuousprint/data/__init__.py b/continuousprint/data/__init__.py index 99d9cf2..b2ddac7 100644 --- a/continuousprint/data/__init__.py +++ b/continuousprint/data/__init__.py @@ -17,59 +17,119 @@ ) +# This is used for running the preprocessor simulator in the settings page. +SIMULATOR_DEFAULT_SYMTABLE = { + "current": { + "path": "testprint.gcode", + "materials": ["PLA_red_#ff0000"], + "bed_temp": 23.59, + "state": "printing", + "action": "TICK", + }, + "external": {}, + "metadata": { + "hash": "123abc", + "analysis": { + "printingArea": { + "maxX": 3, + "maxY": 6, + "maxZ": 9, + "minX": -3, + "minY": -6, + "minZ": -9.0, + }, + "dimensions": {"depth": 5, "height": 10, "width": 15}, + "estimatedPrintTime": 12345, + "filament": {"tool0": {"length": 123, "volume": 456}}, + }, + "continuousprint": {"profile": "Generic"}, + "history": [ + { + "timestamp": 1234567890, + "printTime": 100.0, + "success": True, + "printerProfile": "_default", + }, + ], + "statistics": { + "averagePrintTime": {"_default": 100.0}, + "lastPrintTime": {"_default": 100.0}, + }, + }, +} + + class CustomEvents(Enum): ACTIVATE = ( "continuousprint_activate", "Queue Activated", "Fires when the queue is started, e.g. via the 'Start Managing' button.", + "inactive", ) PRINT_START = ( "continuousprint_start_print", "Print Start", "Fires when a new print is starting from the queue. Unlike OctoPrint events, this does not fire when event scripts are executed.", + "idle", ) PRINT_SUCCESS = ( "continuousprint_success", "Print Success", "Fires when the active print finishes. This will also fire for prints running before the queue was started. The final print will fire QUEUE_FINISH instead of PRINT_SUCCESS.", + "printing", ) PRINT_CANCEL = ( "continuousprint_cancel", "Print Cancel", "Fires when automation or the user has cancelled the active print.", + "printing", ) COOLDOWN = ( "continuousprint_cooldown", "Bed Cooldown", "Fires when bed cooldown is starting. Bed Cooldown is disabled by default - see the settings below.", + "idle", ) FINISH = ( "continuousprint_finish", "Queue Finished", "Fires when there is no work left to do and the plugin goes idle.", + "printing", ) AWAITING_MATERIAL = ( "continuousprint_awaiting_material", "Awaiting Material", "Fires once when the current job requires a different material than what is currently loaded. This requires SpoolManager to be installed (see Integrations).", + "idle", ) DEACTIVATE = ( "continuousprint_deactivate", "Queue Deactivated", "Fires when the queue is no longer actively managed. This script may be skipped if another print is underway when the queue goes inactive.", + "idle", ) - def __init__(self, event, displayName, desc): + def __init__(self, event, displayName, desc, sym_state): self.event = event self.displayName = displayName self.desc = desc + self.sym_state = sym_state + + @classmethod + def from_event(self, k): + evts = dict([(e.event, e) for e in self]) + return evts[k] def as_dict(self): - return dict(event=self.event, display=self.displayName, desc=self.desc) + return dict( + event=self.event, + display=self.displayName, + desc=self.desc, + sym_state=self.sym_state, + ) class Keys(Enum): - BED_COOLDOWN_SCRIPT_DEPRECATED = ( "cp_bed_cooldown_script", "; Put script to run before bed cools here\n", @@ -97,6 +157,9 @@ class Keys(Enum): ) # One of "do_nothing", "add_draft", "add_printable" 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 @@ -115,9 +178,11 @@ def __init__(self, setting, default): "js/continuousprint_api.js", "js/continuousprint_history_row.js", "js/continuousprint_set.js", + "js/continuousprint_stats.js", "js/continuousprint_job.js", "js/continuousprint_queue.js", "js/continuousprint_viewmodel.js", + "js/continuousprint_settings_event.js", "js/continuousprint_settings.js", "js/continuousprint.js", ], diff --git a/continuousprint/data/data_test.py b/continuousprint/data/data_test.py index 22e03eb..4083c4d 100644 --- a/continuousprint/data/data_test.py +++ b/continuousprint/data/data_test.py @@ -52,7 +52,7 @@ def testcase(self): def runInterp(symtable): stdout = StringIO() interp = Interpreter(writer=stdout) - for (k, v) in symtable.items(): + for k, v in symtable.items(): interp.symtable[k] = v return interp(PREPROCESSORS[name]["body"], raise_errors=True), stdout diff --git a/continuousprint/data/gcode_scripts.yaml b/continuousprint/data/gcode_scripts.yaml index 256e2b6..a6d75ba 100644 --- a/continuousprint/data/gcode_scripts.yaml +++ b/continuousprint/data/gcode_scripts.yaml @@ -101,3 +101,22 @@ GScript: G90 ; back to absolute positioning M104 S0; Set Hot-end to 0C (off) M140 S0; Set bed to 0C (off) + - name: "Creality Ender 3 S1 Pro" + description: > + This script is for Creality Ender 3 S1 Pro with a build volume of 220mm x 220mm XY build area and z-height of 270mm. + It uses the printer's extruder to push the part off the build plate." + version: 0.0.1 + gcode: | + M17 ;enable steppers + G91 ; Set relative for lift + G0 Z10 ; lift z by 10 + G90 ;back to absolute positioning + M104 T0 S0 ; extruder heater off + M140 S0 ; heated bed heater off + M190 R25 ; set bed to 25 and wait for cooldown + G0 X220 Y220 ;move to back corner + G0 X110 Y220 ;move to mid bed aft + G0 Z5 ;come down to 5MM from bed + G0 Y0 ;wipe forward + G0 Y220 ;wipe aft + G28 ; home diff --git a/continuousprint/data/printer_profiles.yaml b/continuousprint/data/printer_profiles.yaml index ec08f9b..2f9c127 100644 --- a/continuousprint/data/printer_profiles.yaml +++ b/continuousprint/data/printer_profiles.yaml @@ -305,7 +305,7 @@ PrinterProfile: clearBed: Pause finished: Generic Off depth: 210 - extra_tags: [] + extra_tags: ["MK3", "MK3S"] formFactor: rectangular height: 210 make: Prusa @@ -565,3 +565,15 @@ PrinterProfile: name: GridBot One selfClearing: false width: 300 + - name: Creality Ender 3 S1 Pro + make: Creality + model: Ender 3 S1 Pro + width: 220 + depth: 220 + height: 270 + formFactor: rectangular + selfClearing: false + defaults: + clearBed: Pause + finished: Generic Off + extra_tags: [] diff --git a/continuousprint/driver.py b/continuousprint/driver.py index cc3d1c9..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() @@ -25,6 +27,21 @@ class StatusType(Enum): ERROR = auto() +def blockCoreEventScripts(func): + """Decorates driver `_state_*` functions where running OctoPrint's + default GCODE event scripts would cause unexpected behavior, e.g. + `beforePrintStarted` happening before a CPQ bed clearing script is run. + + See https://docs.octoprint.org/en/master/features/gcode_scripts.html#gcode-scripts + """ + func._block_core_events = True + return func + + +def shouldBlockCoreEvents(state): + return getattr(state, "_block_core_events", False) + + # Inspired by answers at # https://stackoverflow.com/questions/6108819/javascript-timestamp-to-relative-time def timeAgo(elapsed): @@ -38,7 +55,7 @@ def timeAgo(elapsed): class Driver: # If the printer is idle for this long while printing, break out of the printing state (consider it a failure) - PRINTING_IDLE_BREAKOUT_SEC = 15.0 + PRINTING_IDLE_BREAKOUT_SEC = 20.0 TIMELAPSE_WAIT_SEC = 30 def __init__( @@ -161,6 +178,7 @@ def _state_inactive(self, a: Action, p: Printer): "Inactive (active print continues unmanaged)", StatusType.NEEDS_ACTION ) + @blockCoreEventScripts def _state_activating(self, a: Action, p: Printer): self._set_status("Running startup script") if a == Action.SUCCESS or self._long_idle(p): @@ -178,6 +196,7 @@ def _state_idle(self, a: Action, p: Printer): else: return self._enter_start_print(a, p) + @blockCoreEventScripts def _state_preprint(self, a: Action, p: Printer): self._set_status("Running pre-print script") if a == Action.SUCCESS or self._long_idle(p): @@ -190,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: @@ -213,12 +232,33 @@ def _materials_match(self, item): return False return True + def _verify_active_status_msg(self, rep): + if rep["misconfig"]: + return "SpoolManager: missing metadata or spool fields" + elif len(rep["nospool"]) > 0: + return "SpoolManager: extruder(s) in use do not have a spool selected" + elif len(rep["notenough"]) > 0: + tools = [ + f"T{i.get('toolIndex', -1)} ({i.get('spoolName', '')})" + for i in rep["notenough"] + ] + return "SpoolManager: not enough filament left for " + ",".join(tools) + else: + return "SpoolManager: failed validation" + + @blockCoreEventScripts def _state_awaiting_material(self, a: Action, p: Printer): item = self.q.get_set_or_acquire() if item is None: self._set_status("No work to do; going idle") return self._state_idle + valid, rep = self._runner.verify_active() + if not valid and rep is not None: + self._set_status( + self._verify_active_status_msg(rep), StatusType.NEEDS_ACTION + ) + if self._materials_match(item): return self._enter_start_print(a, p) else: @@ -227,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 @@ -237,24 +277,62 @@ 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._materials_match(item): + 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) + if rep is not None: + self._set_status( + self._verify_active_status_msg(rep), StatusType.NEEDS_ACTION + ) 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 @@ -287,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 @@ -313,6 +391,7 @@ def _state_paused(self, a: Action, p: Printer): elif p == Printer.BUSY: return self._state_printing + @blockCoreEventScripts def _state_spaghetti_recovery(self, a: Action, p: Printer): self._set_status("Cancelling (spaghetti early in print)", StatusType.ERROR) if p == Printer.PAUSED: @@ -359,6 +438,7 @@ def _state_success(self, a: Action, p: Printer): self._logger.debug("_state_success no next item --> _start_finishing") return self._state_start_finishing + @blockCoreEventScripts def _state_start_clearing(self, a: Action, p: Printer): if p != Printer.IDLE: self._set_status("Waiting for printer to be ready") @@ -375,6 +455,7 @@ def _state_start_clearing(self, a: Action, p: Printer): self._runner.run_script_for_event(CustomEvents.PRINT_SUCCESS) return self._state_clearing + @blockCoreEventScripts def _state_cooldown(self, a: Action, p: Printer): clear = False if self._bed_temp < self.cooldown_threshold: @@ -392,6 +473,7 @@ def _state_cooldown(self, a: Action, p: Printer): self._runner.run_script_for_event(CustomEvents.PRINT_SUCCESS) return self._state_clearing + @blockCoreEventScripts def _state_clearing(self, a: Action, p: Printer): if a == Action.SUCCESS: return self._enter_start_print(a, p) @@ -404,6 +486,7 @@ def _state_clearing(self, a: Action, p: Printer): else: self._set_status("Clearing bed") + @blockCoreEventScripts def _state_start_finishing(self, a: Action, p: Printer): if p != Printer.IDLE: self._set_status("Waiting for printer to be ready") @@ -412,6 +495,7 @@ def _state_start_finishing(self, a: Action, p: Printer): self._runner.run_script_for_event(CustomEvents.FINISH) return self._state_finishing + @blockCoreEventScripts def _state_finishing(self, a: Action, p: Printer): if a == Action.FAILURE: return self._enter_inactive() diff --git a/continuousprint/driver_test.py b/continuousprint/driver_test.py index 84c3b62..7483b43 100644 --- a/continuousprint/driver_test.py +++ b/continuousprint/driver_test.py @@ -17,10 +17,13 @@ def setUp(self): script_runner=MagicMock(), logger=logging.getLogger(), ) + self.d._runner.verify_active.return_value = (True, None) 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 @@ -57,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__) @@ -103,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) @@ -218,11 +271,14 @@ def setUp(self): script_runner=MagicMock(), logger=logging.getLogger(), ) + 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() @@ -237,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 ) @@ -344,7 +401,9 @@ def test_user_deactivate_sets_inactive(self): self.d.q.end_run.assert_not_called() -class TestMaterialConstraints(unittest.TestCase): +class MaterialTest(unittest.TestCase): + """Test harness for testing material & spool checking""" + def setUp(self): self.d = Driver( queue=MagicMock(), @@ -353,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): @@ -361,6 +421,64 @@ def _setItemMaterials(self, m): self.d.q.get_set.return_value = item self.d.q.get_set_or_acquire.return_value = item + +class TestSpoolVerification(MaterialTest): + def testNotOK(self): + self._setItemMaterials(["tool1mat"]) + for retval, expr in ( + ( + dict( + misconfig=True, + nospool=[], + notenough=[], + ), + "missing metadata", + ), + ( + dict( + misconfig=False, + nospool=[1, 2, 3], + notenough=[], + ), + "do not have a spool", + ), + ( + dict( + misconfig=False, + nospool=[], + notenough=[dict(toolIndex=0, spoolName="spool")], + ), + "not enough filament", + ), + ): + with self.subTest(retval=retval, expr=expr): + self.d._runner.verify_active.return_value = (False, retval) + self.d.action(DA.ACTIVATE, DP.IDLE, materials=["tool1mat"]) + self.d._runner.start_print.assert_not_called() + self.assertEqual( + self.d.state.__name__, self.d._state_awaiting_material.__name__ + ) + self.assertRegex(self.d.status, expr) + + def testOK(self): + self._setItemMaterials(["tool1mat"]) + self.d._runner.verify_active.return_value = (True, {}) + self.d.action(DA.ACTIVATE, DP.IDLE, materials=["tool1mat"]) + self.d._runner.start_print.assert_called() + self.assertEqual(self.d.state.__name__, self.d._state_printing.__name__) + + +class TestMaterialConstraints(MaterialTest): + def setUp(self): + super().setUp() + self.d._runner.verify_active.return_value = (True, None) # No spoolmanager + + def _setItemMaterials(self, m): + item = MagicMock() + item.materials.return_value = m + self.d.q.get_set.return_value = item + self.d.q.get_set_or_acquire.return_value = item + def test_empty(self): self._setItemMaterials([]) self.d.action(DA.ACTIVATE, DP.IDLE) @@ -426,7 +544,11 @@ def test_tool1mat_tool2mat_reversed(self): def test_recovery(self): self._setItemMaterials(["tool0mat"]) - self.d.action(DA.ACTIVATE, DP.IDLE, materials=["tool0bad"]) # awaiting + self.d.action(DA.ACTIVATE, DP.IDLE, materials=["tool0bad"]) + self.assertEqual( + self.d.state.__name__, self.d._state_awaiting_material.__name__ + ) + self.d.action(DA.ACTIVATE, DP.IDLE, materials=["tool0mat"]) self.d._runner.start_print.assert_called() self.assertEqual(self.d.state.__name__, self.d._state_printing.__name__) diff --git a/continuousprint/integration_test.py b/continuousprint/integration_test.py index 8d848cb..2f10b32 100644 --- a/continuousprint/integration_test.py +++ b/continuousprint/integration_test.py @@ -46,6 +46,10 @@ def onupdate(): # Bypass running of scripts on activate, start print, deactivate etc. 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) @@ -190,11 +194,15 @@ 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(), fire_event=MagicMock(), + spool_manager=None, ) + self.s._ensure_tempdir = MagicMock() self.s._get_user = lambda: "foo" self.s._wrap_stream = MagicMock(return_value=None) self.mq = MultiQueue(queries, Strategy.IN_ORDER, onupdate) @@ -295,7 +303,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( @@ -344,12 +354,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", @@ -368,18 +377,29 @@ def onupdate(): script_runner=MagicMock(), logger=logging.getLogger(f"peer{i}:Driver"), ) + 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 1c98ec7..dbf91ca 100644 --- a/continuousprint/plugin.py +++ b/continuousprint/plugin.py @@ -16,7 +16,8 @@ from peerprint.filesharing import Fileshare from .analysis import CPQProfileAnalysisQueue -from .driver import Driver, Action as DA, Printer as DP +from .thirdparty.spoolmanager import SpoolManagerIntegration +from .driver import Driver, Action as DA, Printer as DP, shouldBlockCoreEvents from .queues.lan import LANQueue from .queues.multi import MultiQueue from .queues.local import LocalQueue @@ -58,6 +59,7 @@ def __init__( printer, settings, file_manager, + slicing_manager, plugin_manager, queries, data_folder, @@ -70,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 @@ -271,7 +274,9 @@ def _setup_thirdparty_plugin_integration(self): # Code based loosely on https://github.com/OllisGit/OctoPrint-PrintJobHistory/ (see _getPluginInformation) smplugin = self._plugin_manager.plugins.get("SpoolManager") if smplugin is not None and smplugin.enabled: - self._spool_manager = smplugin.implementation + self._spool_manager = SpoolManagerIntegration( + smplugin.implementation, self._logger + ) self._logger.info("SpoolManager found - enabling material selection") self._set_key(Keys.MATERIAL_SELECTION, True) else: @@ -293,6 +298,57 @@ def _setup_thirdparty_plugin_integration(self): octoprint.events.Events, "PLUGIN__SPOOLMANAGER_SPOOL_DESELECTED", None ) + def patchCommJobReader(self): + # Patch the comms interface to allow for suppressing GCODE script events when the + # queue is running script events + try: + if self._get_key(Keys.SKIP_GCODE_COMMANDS).strip() == "": + self._logger.info( + "Skipping patch of comm._get_next_from_job; no commands configured to skip" + ) + return + self._ignore_cmd_list = set( + [ + c.split(";", 1)[0].strip().upper() + for c in self._get_key(Keys.SKIP_GCODE_COMMANDS).split("\n") + ] + ) + + self._jobCommReaderOrig = self._printer._comm._get_next_from_job + self._printer._comm._get_next_from_job = self.gatedCommJobReader + self._logger.info( + f"Patched comm._get_next_from_job; will ignore commands: {self._ignore_cmd_list}" + ) + except Exception: + self._logger.error(traceback.format_exc()) + + def gatedCommJobReader(self, *args, **kwargs): + # As this patches core OctoPrint functionality, we wrap *everything* + # in try/catch to ensure it continues to execute if CPQ raises an exception. + result = self._jobCommReaderOrig(*args, **kwargs) + try: + # Only mess with gcode commands of printed files, not events + if self.d.state != self.d._state_printing: + return result + + while result[0] is not None: + if type(result[0]) != str: + return result + line = result[0].strip() + if line == "": + return result + + # Normalize command, without uppercase + cmd = result[0].split(";", 1)[0].strip().upper() + if cmd not in self._ignore_cmd_list: + break + self._logger.warning(f"Skip GCODE: {result}") + result = self._jobCommReaderOrig(*args, **kwargs) + except Exception: + self._logger.error(traceback.format_exc()) + finally: + return result + def _init_fileshare(self, fs_cls=Fileshare): # Note: fileshare_dir referenced when cleaning up old files self.fileshare_dir = self._path_on_disk( @@ -403,10 +459,13 @@ 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, self._fire_event, + self._spool_manager, ) self.d = dcls( queue=self.q, @@ -474,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: @@ -590,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] @@ -603,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 @@ -719,18 +779,7 @@ def _update(self, a: DA): if self._spool_manager is not None: # We need *all* selected spools for all tools, so we must look it up from the plugin itself # (event payload also excludes color hex string which is needed for our identifiers) - try: - materials = self._spool_manager.api_getSelectedSpoolInformations() - materials = [ - f"{m['material']}_{m['colorName']}_{m['color']}" - if m is not None - else None - for m in materials - ] - except Exception: - self._logger.warning( - "SpoolManager getSelectedSpoolInformations() returned error; skipping material assignment" - ) + materials = self._spool_manager.get_materials() bed_temp = self._printer.get_current_temperatures().get("bed") if bed_temp is not None: @@ -811,3 +860,29 @@ def _commit_queues(self, added, removed): # We trigger state update rather than returning it here, because this is called by the settings viewmodel # (not the main viewmodel that displays the queues) self._sync_state() + + def patchComms(self): + # Patch the comms interface to allow for suppressing GCODE script events when the + # qeue is running script events + try: + self._sendGcodeScriptOrig = self._printer._comm.sendGcodeScript + self._printer._comm.sendGcodeScript = self.gatedSendGcodeScript + self._logger.info("Patched sendGCodeScript") + except Exception: + self._logger.error(traceback.format_exc()) + + def gatedSendGcodeScript(self, *args, **kwargs): + # As this patches core OctoPrint functionality, we wrap *everything* + # in try/catch to ensure it continues to execute if CPQ raises an exception. + shouldCall = True + try: + if shouldBlockCoreEvents(self.d.state): + shouldCall = False + self._logger.warning( + f"Suppressing sendGcodeScript({args[0]}) as driver is in state {self.d.state}" + ) + except Exception: + self._logger.error(traceback.format_exc()) + finally: + if shouldCall: + return self._sendGcodeScriptOrig(*args, **kwargs) diff --git a/continuousprint/plugin_test.py b/continuousprint/plugin_test.py index abea5f1..d9ec006 100644 --- a/continuousprint/plugin_test.py +++ b/continuousprint/plugin_test.py @@ -4,9 +4,9 @@ from .analysis import CPQProfileAnalysisQueue from .storage.queries import getJobsAndSets from .storage.database import DEFAULT_QUEUE, ARCHIVE_QUEUE -from unittest.mock import MagicMock, patch, ANY, call +from unittest.mock import MagicMock, patch, ANY, call, PropertyMock from octoprint.filemanager.analysis import QueueEntry -from .driver import Action as DA +from .driver import Driver, Action as DA from octoprint.events import Events import logging import tempfile @@ -37,11 +37,12 @@ def global_set(self, gk, v): return self.set([":".join(gk)], v) -def mockplugin(): +def setupPlugin(): return CPQPlugin( printer=MagicMock(), settings=MockSettings(), file_manager=MagicMock(), + slicing_manager=MagicMock(), plugin_manager=MagicMock(), fire_event=MagicMock(), queries=MagicMock(), @@ -54,7 +55,7 @@ def mockplugin(): class TestStartup(unittest.TestCase): def testThirdPartyMissing(self): - p = mockplugin() + p = setupPlugin() p._plugin_manager.plugins.get.return_value = None p._setup_thirdparty_plugin_integration() @@ -63,7 +64,7 @@ def testThirdPartyMissing(self): self.assertEqual(p._get_key(Keys.RESTART_ON_PAUSE), False) # Obico def testObicoFound(self): - p = mockplugin() + p = setupPlugin() p._plugin_manager.plugins.get.return_value = None with patch( @@ -75,7 +76,7 @@ def testObicoFound(self): self.assertEqual(p._get_key(Keys.RESTART_ON_PAUSE), True) # Obico def testSpoolManagerFound(self): - p = mockplugin() + p = setupPlugin() p._plugin_manager.plugins.get.return_value = MagicMock() p._setup_thirdparty_plugin_integration() @@ -84,15 +85,80 @@ def testSpoolManagerFound(self): self.assertEqual(p._get_key(Keys.MATERIAL_SELECTION), True) # Spoolmanager self.assertEqual(p._get_key(Keys.RESTART_ON_PAUSE), False) # Obico + def testPatchCommJobReader(self): + p = setupPlugin() + gnfj = p._printer._comm._get_next_from_job + p.d = MagicMock(_state_printing="foo", state="foo") + p._set_key(Keys.SKIP_GCODE_COMMANDS, "FOO 1\nBAR ; Settings comment") + p.patchCommJobReader() + + mm = MagicMock() + gnfj.side_effect = [ + (line, None, None) + for line in ( + "", + mm, + "foo 1", # Case insensitive + "BAR ; I have a comment that should be ignored ;;;", + "G0 X0", + None, + ) + ] + + # Passes whitespace lines + self.assertEqual(p._printer._comm._get_next_from_job(), ("", ANY, ANY)) + + # Passes foreign objects (e.g. for SendQueueMarker in OctoPrint) + self.assertEqual(p._printer._comm._get_next_from_job(), (mm, ANY, ANY)) + + # Skips cmds in skip-list + self.assertEqual(p._printer._comm._get_next_from_job(), ("G0 X0", ANY, ANY)) + + # Stops on end of file + self.assertEqual(p._printer._comm._get_next_from_job(), (None, ANY, ANY)) + + # Test exception inside loop returns a decent result + gnfj.side_effect = [("foo 1", None, None), Exception("Testing exception")] + self.assertEqual(p._printer._comm._get_next_from_job(), ("foo 1", ANY, ANY)) + + # Ignored when not printing + p.d = MagicMock(_state_printing="foo", state="bar") + gnfj.side_effect = [("foo 1", None, None)] + self.assertEqual(p._printer._comm._get_next_from_job(), ("foo 1", ANY, ANY)) + + def testPatchComms(self): + p = setupPlugin() + sgs = p._printer._comm.sendGcodeScript + p.patchComms() + + # Suppress states in which we're running user configured event scripts + sgs.reset_mock() + p.d = MagicMock(state=Driver._state_activating) + p._printer._comm.sendGcodeScript("FOO") + sgs.assert_not_called() + + # Pass through states where default OctoPrint behavior should be obeyed + sgs.reset_mock() + p.d = MagicMock(state=Driver._state_printing) + p._printer._comm.sendGcodeScript("FOO") + sgs.assert_called() + + # Passthru still happens despite exceptions + sgs.reset_mock() + p.d = MagicMock() + type(p.d).state = PropertyMock(side_effect=Exception("testing error")) + p._printer._comm.sendGcodeScript("FOO") + sgs.assert_called() + def testDBNew(self): - p = mockplugin() + p = setupPlugin() with tempfile.TemporaryDirectory() as td: p._data_folder = td p._init_db() @patch("continuousprint.plugin.migrateScriptsFromSettings") def testDBMigrateScripts(self, msfs): - p = mockplugin() + p = setupPlugin() p._set_key(Keys.CLEARING_SCRIPT_DEPRECATED, "s1") p._set_key(Keys.FINISHED_SCRIPT_DEPRECATED, "s2") p._set_key(Keys.BED_COOLDOWN_SCRIPT_DEPRECATED, "s3") @@ -103,7 +169,7 @@ def testDBMigrateScripts(self, msfs): msfs.assert_called_with("s1", "s2", "s3") def testDBWithLegacySettings(self): - p = mockplugin() + p = setupPlugin() p._set_key( Keys.QUEUE_DEPRECATED, json.dumps( @@ -129,7 +195,7 @@ def testDBWithLegacySettings(self): self.assertEqual(len(getJobsAndSets(DEFAULT_QUEUE)), 1) def testFileshare(self): - p = mockplugin() + p = setupPlugin() fs = MagicMock() p.get_local_addr = lambda: ("111.111.111.111:0") p._file_manager.path_on_disk.return_value = "/testpath" @@ -139,14 +205,14 @@ def testFileshare(self): fs.assert_called_with("111.111.111.111:0", "/testpath", logging.getLogger()) def testFileshareAddrFailure(self): - p = mockplugin() + p = setupPlugin() fs = MagicMock() p.get_local_addr = MagicMock(side_effect=[OSError("testing")]) p._init_fileshare(fs_cls=fs) # Does not raise exception self.assertEqual(p._fileshare, None) def testFileshareConnectFailure(self): - p = mockplugin() + p = setupPlugin() fs = MagicMock() p.get_local_addr = lambda: "111.111.111.111:0" fs.connect.side_effect = OSError("testing") @@ -154,7 +220,7 @@ def testFileshareConnectFailure(self): self.assertEqual(p._fileshare, fs()) def testQueues(self): - p = mockplugin() + p = setupPlugin() QT = namedtuple("MockQueue", ["name", "addr"]) p._queries.getQueues.return_value = [ QT(name="LAN", addr="0.0.0.0:0"), @@ -166,7 +232,7 @@ def testQueues(self): self.assertEqual(len(p.q.queues), 2) # 2 queues created, archive skipped def testDriver(self): - p = mockplugin() + p = setupPlugin() p.q = MagicMock() p._sync_state = MagicMock() p._printer_profile = None @@ -178,12 +244,13 @@ def testDriver(self): class TestEventHandling(unittest.TestCase): def setUp(self): - self.p = mockplugin() + self.p = setupPlugin() self.p._spool_manager = None self.p._printer_profile = None self.p.d = MagicMock() self.p.q = MagicMock() self.p._sync_state = MagicMock() + self.p._plugin_manager.plugins.get.return_value = None self.p._setup_thirdparty_plugin_integration() def testTick(self): @@ -243,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): @@ -266,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() @@ -364,7 +437,7 @@ def testFileAddedWithNoAnalysis(self): class TestGetters(unittest.TestCase): def setUp(self): - self.p = mockplugin() + self.p = setupPlugin() self.p._spool_manager = None self.p._printer_profile = None self.p.d = MagicMock() @@ -415,7 +488,7 @@ def testHistoryJSON(self): class TestAutoReconnect(unittest.TestCase): def setUp(self): - self.p = mockplugin() + self.p = setupPlugin() def testOfflineAutoReconnectDisabledByDefault(self): # No need to _set_key here, since it should be off by default (prevent unexpected @@ -467,7 +540,7 @@ def testWaitsForTerminalState(self): class TestAnalysis(unittest.TestCase): def setUp(self): - self.p = mockplugin() + self.p = setupPlugin() def testInitAnalysisNoFiles(self): self.p._file_manager.list_files.return_value = dict(local=dict()) @@ -562,7 +635,7 @@ def setUp(self): {"hash": "d", "peer_": "peer2", "acquired_by_": None}, ] ) - self.p = mockplugin() + self.p = setupPlugin() self.p.fileshare_dir = self.td.name self.p.q = MagicMock() self.p.q.queues.items.return_value = [("q", q)] @@ -588,7 +661,7 @@ def testCleanupWithFiles(self): class TestLocalAddressResolution(unittest.TestCase): def setUp(self): - self.p = mockplugin() + self.p = setupPlugin() @patch("continuousprint.plugin.socket") def testResolutionViaCheckAddrOK(self, msock): diff --git a/continuousprint/queues/lan.py b/continuousprint/queues/lan.py index 47ff450..0a756d4 100644 --- a/continuousprint/queues/lan.py +++ b/continuousprint/queues/lan.py @@ -117,7 +117,7 @@ def _normalize_job(self, data): def _get_jobs(self) -> list: joblocks = self.lan.q.getLocks() jobs = [] - for (jid, v) in self.lan.q.getJobs(): + for jid, v in self.lan.q.getJobs(): jobs.append(self._annotate_job(v, joblocks.get(jid))) return jobs @@ -311,7 +311,7 @@ def edit_job(self, job_id, data) -> bool: # This is because the backing .gjob format is a single file containing the full manifest. j = self.get_job_view(job_id) - for (k, v) in data.items(): + for k, v in data.items(): if k in ("id", "peer_", "queue"): continue if k == "sets": diff --git a/continuousprint/queues/local.py b/continuousprint/queues/local.py index 4d7f38b..f02b9b6 100644 --- a/continuousprint/queues/local.py +++ b/continuousprint/queues/local.py @@ -124,7 +124,7 @@ def import_job_from_view(self, v, copy_fn=shutil.copytree): # TODO make transaction, move to storage/queries.py j = self.add_job() - for (k, v) in manifest.items(): + for k, v in manifest.items(): if k in ("peer_", "sets", "id", "acquired", "queue"): continue setattr(j, k, v) diff --git a/continuousprint/script_runner.py b/continuousprint/script_runner.py index 73e4f2f..007efe3 100644 --- a/continuousprint/script_runner.py +++ b/continuousprint/script_runner.py @@ -1,15 +1,16 @@ import time -from io import BytesIO, StringIO - -from asteval import Interpreter +from io import BytesIO from pathlib import Path from octoprint.filemanager.util import StreamWrapper 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 .storage.queries import genEventScript +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 class ScriptRunner: @@ -17,17 +18,23 @@ def __init__( self, msg, file_manager, + get_key, + slicing_manager, logger, printer, refresh_ui_state, fire_event, + spool_manager, ): 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 self._symbols = dict( current=dict(), external=dict(), @@ -46,6 +53,7 @@ def _wrap_stream(self, name, gcode): def _execute_gcode(self, evt, gcode): file_wrapper = self._wrap_stream(evt.event, gcode) path = str(Path(TEMP_FILE_DIR) / f"{evt.event}.gcode") + self._ensure_tempdir() added_file = self._file_manager.add_file( FileDestinations.LOCAL, path, @@ -94,18 +102,59 @@ def set_external_symbols(self, symbols): assert type(symbols) is dict self._symbols["external"] = symbols - def _get_interpreter(self): - out = StringIO() - err = StringIO() - interp = Interpreter(writer=out, err_writer=err) - # Merge in so default symbols (e.g. exceptions) are retained - for (k, v) in self._symbols.items(): - interp.symtable[k] = v - return interp, out, err + def set_active(self, item, cb): + path = item.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})") + self._printer.select_file( + path, sd=item.sd, printAfterSelect=False, user=self._get_user() + ) + return True + except InvalidFileLocation as e: + self._logger.error(e) + self._msg("File not found: " + path, type="error") + return False + except InvalidFileType as e: + self._logger.error(e) + self._msg("File not gcode: " + path, type="error") + return False + + def verify_active(self): + # SpoolManager does its filament estimation based on the current active + # gcode file (the "job" in OctoPrint parlance). + # Failing this verification should put the queue in a "needs action" state and prevent printing the next file. + if self._spool_manager is not None: + ap = self._spool_manager.allowed_to_print() + ap = dict( + misconfig=ap.get("metaOrAttributesMissing", False), + nospool=ap.get("result", {}).get("noSpoolSelected", []), + notenough=ap.get("result", {}).get("filamentNotEnough", []), + ) + valid = ( + not ap["misconfig"] + and len(ap["nospool"]) == 0 + and len(ap["notenough"]) == 0 + ) + return valid, ap + else: + return True, None def run_script_for_event(self, evt, msg=None, msgtype=None): - interp, out, err = self._get_interpreter() - gcode = genEventScript(evt, interp, self._logger) + interp, out, err = getInterpreter(self._symbols) + automation = getAutomationForEvent(evt) + gcode = genEventScript(automation, interp, self._logger) if len(interp.error) > 0: for err in interp.error: self._logger.error(err.get_error()) @@ -126,8 +175,8 @@ def run_script_for_event(self, evt, msg=None, msgtype=None): else: self._do_msg(evt, running=(gcode != "")) - # Cancellation happens before custom scripts are run if evt == CustomEvents.PRINT_CANCEL: + # Cancellation happens before custom scripts are run self._printer.cancel_print() result = self._execute_gcode(evt, gcode) if gcode != "" else None @@ -139,37 +188,108 @@ 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}") + def _ensure_tempdir(self): + tmpFileDir = Path( + self._file_manager.path_on_disk(FileDestinations.LOCAL, TEMP_FILE_DIR) + ) + if not tmpFileDir.exists(): + self._logger.info(f"Creating temp file directory: {TEMP_FILE_DIR}") + tmpFileDir.mkdir() - path = item.path - # LAN 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 LAN print path for {path}", type="error") - return False - self._logger.info(f"Resolved LAN print path to {path}") + 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 + + self._ensure_tempdir() + 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._logger.info(f"Attempting to print {path} (sd={item.sd})") - self._printer.select_file( - path, sd=item.sd, printAfterSelect=True, user=self._get_user() + self._slicing_manager.slice( + slicer, + self._file_manager.path_on_disk(FileDestinations.LOCAL, item.path), + gcode_path, + profile, + callback=slicer_cb, ) - self._fire_event(CustomEvents.PRINT_START) - except InvalidFileLocation as e: - self._logger.error(e) - self._msg("File not found: " + path, type="error") - return False - except InvalidFileType as e: + except SlicingException as e: self._logger.error(e) - self._msg("File not gcode: " + path, type="error") + 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. + # We must run startPrintConfirmed before starting a new print, or else + # temperature offsets aren't applied. + # See https://github.com/smartin015/continuousprint/issues/191 + self._spool_manager.start_print_confirmed() + + 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 af5f4b0..49f8eb7 100644 --- a/continuousprint/script_runner_test.py +++ b/continuousprint/script_runner_test.py @@ -1,37 +1,58 @@ 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(), fire_event=MagicMock(), + spool_manager=MagicMock(), ) + self.s._ensure_tempdir = MagicMock() self.s._get_user = lambda: "foo" self.s._wrap_stream = MagicMock(return_value=None) self.s._get_interpreter = lambda: (MagicMock(error=[]), StringIO(), StringIO()) @patch("continuousprint.script_runner.genEventScript", return_value="foo") - def test_run_script_for_event(self, ges): + @patch("continuousprint.script_runner.getAutomationForEvent", return_value=[]) + def test_run_script_for_event(self, gae, ges): # Note: default scripts are populated on db_init for FINISH and PRINT_SUCCESS self.s.run_script_for_event(CustomEvents.FINISH) self.s._file_manager.add_file.assert_called() @@ -41,68 +62,158 @@ def test_run_script_for_event(self, ges): printAfterSelect=True, user="foo", ) + self.s._spool_manager.start_print_confirmed.assert_not_called() self.s._fire_event.assert_called_with(CustomEvents.FINISH) @patch("continuousprint.script_runner.genEventScript", return_value="") - def test_run_script_for_event_cancel(self, ges): + @patch("continuousprint.script_runner.getAutomationForEvent", return_value=[]) + def test_run_script_for_event_cancel(self, gae, ges): # Script run behavior is already tested in test_run_script_for_event self.s.run_script_for_event(CustomEvents.PRINT_CANCEL) self.s._printer.cancel_print.assert_called() @patch("continuousprint.script_runner.genEventScript", return_value="") - def test_run_script_for_event_cooldown(self, ges): + @patch("continuousprint.script_runner.getAutomationForEvent", return_value=[]) + def test_run_script_for_event_cooldown(self, gae, ges): # Script run behavior is already tested in test_run_script_for_event self.s.run_script_for_event(CustomEvents.COOLDOWN) self.s._printer.set_temperature.assert_called_with("bed", 0) - def test_start_print_local(self): - self.assertEqual(self.s.start_print(LI(False, "a.gcode", LJ("job1"))), True) + def test_verify_active(self): + self.s._spool_manager.allowed_to_print.return_value = dict( + metaOrAttributesMissing=True + ) + self.assertEqual(self.s.verify_active()[0], False) + + self.s._spool_manager.allowed_to_print.return_value = dict( + result=dict(noSpoolSelected=[1]) + ) + self.assertEqual(self.s.verify_active()[0], False) + + self.s._spool_manager.allowed_to_print.return_value = dict( + result=(dict(filamentNotEnough=[1])) + ) + self.assertEqual(self.s.verify_active()[0], False) + + self.s._spool_manager.allowed_to_print.return_value = dict() + self.assertEqual(self.s.verify_active()[0], True) + + self.s._spool_manager = None + self.assertEqual(self.s.verify_active()[0], 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=True, + printAfterSelect=False, user="foo", ) - 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=True, + printAfterSelect=False, user="foo", ) - 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=True, - 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._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): @@ -110,11 +221,15 @@ 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(), fire_event=MagicMock(), + spool_manager=MagicMock(), ) + self.s._ensure_tempdir = MagicMock() self.s._get_user = lambda: "foo" self.s._wrap_stream = MagicMock(return_value=None) self.s._execute_gcode = MagicMock() diff --git a/continuousprint/scripts/extract_profile.py b/continuousprint/scripts/extract_profile.py index 2dfbc2a..d883c11 100644 --- a/continuousprint/scripts/extract_profile.py +++ b/continuousprint/scripts/extract_profile.py @@ -50,6 +50,24 @@ def get_profile(self, hdr, ftr) -> str: return "" +class Simplify3DProcessor: + @classmethod + def match(self, hdr, ftr): + for line in hdr: + if line.startswith("; G-Code generated by Simplify3D"): + return True + return False + + @classmethod + def get_profile(self, hdr, ftr) -> str: + for line in hdr: + m = re.match("; profileName,(.*)", line) + print(line, "->", m) + if m is not None: + return m[1] + return "" + + def token_string_match(profstr): # Remove non-alpha characters from profile string # Convert all into bag-of-words @@ -72,7 +90,7 @@ def token_string_match(profstr): PROCESSORS = [ (cls.__name__, cls.match, cls.get_profile) - for cls in [KiriMotoProcessor, PrusaSlicerProcessor] + for cls in [KiriMotoProcessor, PrusaSlicerProcessor, Simplify3DProcessor] ] gcode_move_re = re.compile("^G[012] .*") diff --git a/continuousprint/scripts/test_extract_profile.py b/continuousprint/scripts/test_extract_profile.py index 3611233..11c5636 100644 --- a/continuousprint/scripts/test_extract_profile.py +++ b/continuousprint/scripts/test_extract_profile.py @@ -25,6 +25,11 @@ def testParameterized(self): "; printer_model = MK3S\n", "Prusa i3 MK3S+", ), + ( + "; G-Code generated by Simplify3D(R) Version 4.1.2\n; profileName,Prusa Research Original Prusa i3 MK3", + "", + "Prusa i3 MK3S+", + ), ]: with self.subTest(header=header, footer=footer, want=want): hdr = header.split("\n") diff --git a/continuousprint/static/css/continuousprint.css b/continuousprint/static/css/continuousprint.css index 70e241d..80e60b1 100644 --- a/continuousprint/static/css/continuousprint.css +++ b/continuousprint/static/css/continuousprint.css @@ -281,6 +281,30 @@ margin-top: var(--cpq-pad2); padding-top: var(--cpq-pad2); } +#settings_plugin_continuousprint .simulation-output { + display:flex; + flex-direction: row; + justify-content: center; +} +#settings_plugin_continuousprint .simulation-output > div { + flex: 1; + padding: var(--cpq-pad); +} +#settings_plugin_continuousprint .simulation-output h5 { + margin: 0; + text-align: center; + opacity: 0.5; +} +#settings_plugin_continuousprint .output-block { + border: 1px #e5e5e5 solid; /* copy from .accordion-group */ + border-radius: var(--cpq-pad); + width: 100%; + min-height: 150px; + max-height: 400px; + overflow-y: scroll; + padding: 0; + margin: 0; +} #tab_plugin_continuousprint .queue-header, #settings_continuousprint_queues .queue-header { display: flex; justify-content: space-between; @@ -318,7 +342,8 @@ font-weight: italic; opacity: 0.7; } -#tab_plugin_continuousprint .hint { +#tab_plugin_continuousprint .hint, +#settings_plugin_continuousprint .hint { font-weight: italic; font-size: 85%; opacity: 0.7; 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 = /