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 = /
([\s\S]*)<.div>/mi; let template = '
'; - let mctmpl = $($.parseHTML('
' + $("#files_template_machinecode").text() + '
')[0]); + let mc = $("#files_template_machinecode"); + let mctmpl = $($.parseHTML('
' + mc.text() + '
')[0]); let actions = mctmpl.find('.action-buttons'); actions.attr('data-bind', "css: 'cpq-' + display.split('.')[1]"); actions.append(template); @@ -25,7 +26,16 @@ $(function() { title.append(``); title.append(''); - $("#files_template_machinecode").text(mctmpl.html()); + mc.text(mctmpl.html()); + + // Also inject the add-to-queue button for models, which can be auto-sliced + let mdl = $("#files_template_model"); + let modeltmpl = $($.parseHTML('
' + mdl.text() + '
')[0]); + actions = modeltmpl.find('.action-buttons'); + actions.attr('data-bind', "css: 'cpq-' + display.split('.')[1]"); + actions.append(template); + + mdl.text(modeltmpl.html()); // This injects the status of the queue into PrinterStateViewModel (the "State" panel) $("#state .accordion-inner").prepend(` diff --git a/continuousprint/static/js/continuousprint_api.js b/continuousprint/static/js/continuousprint_api.js index 79d12a0..504ad9d 100644 --- a/continuousprint/static/js/continuousprint_api.js +++ b/continuousprint/static/js/continuousprint_api.js @@ -101,6 +101,9 @@ class CPAPI { this._call_base(`${this.BASE}/set_active`, {active}, cb); } + simulate(automation, symtable, cb, err_cb) { + this._call(this.AUTOMATION, 'simulate', {symtable: JSON.stringify(symtable), automation: JSON.stringify(automation)}, cb, err_cb, false); + } } try { diff --git a/continuousprint/static/js/continuousprint_job.js b/continuousprint/static/js/continuousprint_job.js index 9880e0d..f713a00 100644 --- a/continuousprint/static/js/continuousprint_job.js +++ b/continuousprint/static/js/continuousprint_job.js @@ -10,11 +10,18 @@ if (typeof ko === "undefined" || ko === null) { } if (typeof CPSet === "undefined" || CPSet === null) { CPSet = require('./continuousprint_set'); + CPStats = require('./continuousprint_stats'); + CP_STATS_DIMENSIONS={ + completed: null, + count: null, + remaining: null, + total: null, + }; } // jobs and sets are derived from self.queue, but they must be // observableArrays in order for Sortable to be able to reorder it. -function CPJob(obj, peers, api, profile, materials) { +function CPJob(obj, peers, api, profile, materials, stats_dimensions=CP_STATS_DIMENSIONS) { if (api === undefined) { throw Error("API must be provided when creating CPJob"); } @@ -113,42 +120,6 @@ function CPJob(obj, peers, api, profile, materials) { api.edit(api.JOB, data, self._update); } - self._safeParse = function(v) { - v = parseInt(v, 10); - if (isNaN(v)) { - return 0; - } - return v; - } - - - self.humanize = function(num, unit="") { - // Humanizes numbers by condensing and adding units - let v = ''; - if (num < 1000) { - v = (num % 1 === 0) ? num : num.toFixed(1); - } else if (num < 100000) { - let k = (num/1000); - v = ((k % 1 === 0) ? k : k.toFixed(1)) + 'k'; - } - return v + unit; - }; - - self.humanTime = function(s) { - // Humanizes time values; parameter is seconds - if (s < 60) { - return Math.round(s) + 's'; - } else if (s < 3600) { - return Math.round(s/60) + 'm'; - } else if (s < 86400) { - let h = s/3600; - return ((h % 1 === 0) ? h : h.toFixed(1)) + 'h'; - } else { - let d = s/86400; - return ((d % 1 === 0) ? d : d.toFixed(1)) + 'd'; - } - }; - self.getMaterialLinearMasses = ko.computed(function() { let result = []; for (let m of materials()) { @@ -162,101 +133,8 @@ function CPJob(obj, peers, api, profile, materials) { return result; }); - self.raw_stats = ko.computed(function() { - let result = {completed: 0, remaining: 0, count: 0}; - for (let qs of self.sets()) { - if (!qs.profile_matches()) { - continue; - } - result.remaining += self._safeParse(qs.remaining()); - result.count += self._safeParse(qs.count()); - result.completed += self._safeParse(qs.completed()); - } - return result; - }); - self.totals = ko.computed(function() { - let r = [ - {legend: 'Total items', title: null}, - {legend: 'Total time', title: "Uses Octoprint's file analysis estimate; may be inaccurate"}, - {legend: 'Total mass', title: "Mass is calculated using active spool(s) in SpoolManager"}, - ]; - - let linmasses = self.getMaterialLinearMasses(); - - for (let t of r) { - t.count = 0; - t.completed = 0; - t.remaining = 0; - t.total = 0; - t.error = 0; - } - - for (let qs of self.sets()) { - if (!qs.profile_matches()) { - continue; - } - - let rem = self._safeParse(qs.remaining()) - let tot = self._safeParse(qs.length_remaining()); - let count = self._safeParse(qs.count()); - let cplt = self._safeParse(qs.completed()); - - let meta = qs.metadata; - let ept = meta && meta.estimatedPrintTime - let len = meta && meta.filamentLengths; - - // Update print count totals - r[0].remaining += rem; - r[0].total += tot; - r[0].count += count; - r[0].completed += cplt; - - if (ept === null || ept === undefined) { - r[1].error += 1; - } else { - r[1].remaining += rem * ept; - r[1].total += tot * ept - r[1].count += count * ept; - r[1].completed += cplt * ept; - } - - if (len === null || len === undefined || len.length === 0) { - r[2].error += 1; - } else { - let mass = 0; - for (let i = 0; i < len.length; i++) { - mass += linmasses[i] * len[i]; - } - - if (!isNaN(mass)) { - r[2].remaining += rem * mass; - r[2].total += tot * mass; - r[2].count += count * mass; - r[2].completed += cplt * mass; - } else { - r[2].error += 1; - } - } - - } - // Assign error texts - r[0].error = ''; - r[1].error = (r[1].error > 0) ? `${r[1].error} sets missing time estimates` : ''; - r[2].error = (r[2].error > 0) ? `${r[1].error} errors calculating mass` : ''; - - for (let k of ['remaining', 'total', 'count', 'completed']) { - r[0][k] = self.humanize(r[0][k]); - r[1][k] = self.humanTime(r[1][k]); - r[2][k] = self.humanize(r[2][k], 'g'); - } - - // Hide mass details if linmasses is empty (implies SpoolManager not set up) - if (linmasses.length === 0) { - r.splice(2,1); - } - - return r; + return new CPStats(() => [self], stats_dimensions); }); self.checkFraction = ko.computed(function() { diff --git a/continuousprint/static/js/continuousprint_job.test.js b/continuousprint/static/js/continuousprint_job.test.js index 4bface3..0485dbe 100644 --- a/continuousprint/static/js/continuousprint_job.test.js +++ b/continuousprint/static/js/continuousprint_job.test.js @@ -52,9 +52,11 @@ test('onSetModified existing', () => { }); test('totals', () => { - let j = new Job({count: 3, completed: 2, remaining: 1, sets: sets()}, [], api(), prof(), mats()); + let j = new Job({ + count: 3, completed: 2, remaining: 1, sets: sets() + }, [], api(), prof(), mats()); - let t = j.totals(); + let t = j.totals().values_humanized(); expect(t[0]).toStrictEqual({ completed: "2", // sets have 1/2 completed this run count: "4", // 2 sets each with count=2 diff --git a/continuousprint/static/js/continuousprint_queue.js b/continuousprint/static/js/continuousprint_queue.js index b194cf6..7c09ac9 100644 --- a/continuousprint/static/js/continuousprint_queue.js +++ b/continuousprint/static/js/continuousprint_queue.js @@ -9,12 +9,19 @@ if (typeof CPJob === "undefined" || CPJob === null) { // In the testing environment, dependencies must be manually imported ko = require('knockout'); CPJob = require('./continuousprint_job'); + CPStats = require('./continuousprint_stats'); + CP_STATS_DIMENSIONS={ + completed: null, + count: null, + remaining: null, + total: null, + }; log = { "getLogger": () => {return console;} }; } -function CPQueue(data, api, files, profile, materials) { +function CPQueue(data, api, files, profile, materials, stats_dimensions=CP_STATS_DIMENSIONS) { var self = this; self.api = api; self.files = files; @@ -23,7 +30,7 @@ function CPQueue(data, api, files, profile, materials) { self.addr = data.addr; self.jobs = ko.observableArray([]); self._pushJob = function(jdata) { - self.jobs.push(new CPJob(jdata, data.peers, self.api, profile, materials)); + self.jobs.push(new CPJob(jdata, data.peers, self.api, profile, materials, stats_dimensions)); }; for (let j of data.jobs) { self._pushJob(j); @@ -31,6 +38,7 @@ function CPQueue(data, api, files, profile, materials) { self.shiftsel = ko.observable(-1); self.details = ko.observable(""); self.fullDetails = ko.observable(""); + self.showStats = ko.observable(true); self.ready = ko.observable(data.name === 'local' || Object.keys(data.peers).length > 0); if (self.addr !== null && data.peers !== undefined) { let pkeys = Object.keys(data.peers); @@ -95,18 +103,19 @@ function CPQueue(data, api, files, profile, materials) { break; case "Unstarted Jobs": for (let j of self.jobs()) { - j.onChecked(j.sets().length !== 0 && j.raw_stats().completed === 0); + let t = j.totals().values()[0]; + j.onChecked(j.sets().length !== 0 && t.completed === 0 && j.completed() === 0); } break; case "Incomplete Jobs": for (let j of self.jobs()) { - let t = j.raw_stats(); - j.onChecked(t.remaining > 0 && t.remaining < t.count); + let t = j.totals().values()[0]; + j.onChecked(j.remaining() > 0 && (j.completed() > 0 || t.completed > 0)); } break; case "Completed Jobs": for (let j of self.jobs()) { - j.onChecked(j.sets().length !== 0 && j.raw_stats().remaining == 0); + j.onChecked(j.sets().length !== 0 && j.remaining() === 0); } break; default: @@ -158,6 +167,10 @@ function CPQueue(data, api, files, profile, materials) { e.preventDefault(); } + self.totals = ko.computed(function() { + return new CPStats(self.jobs, stats_dimensions); + }); + // *** ko template methods *** self._getSelections = function() { let jobs = []; diff --git a/continuousprint/static/js/continuousprint_queue.test.js b/continuousprint/static/js/continuousprint_queue.test.js index 6f527c0..262312b 100644 --- a/continuousprint/static/js/continuousprint_queue.test.js +++ b/continuousprint/static/js/continuousprint_queue.test.js @@ -90,7 +90,7 @@ test('setCount', () => { describe('batchSelect', () => { let v = init(njobs=4); v.jobs()[0].sets([]); // job 1 is empty - // job 2 is unstarted; no action needed + // job 2 (idx 1) is unstarted; no action needed v.jobs()[2].sets()[0].count(3); // Job 3 is incomplete, set 5 is incomplete v.jobs()[2].sets()[0].completed(1); v.jobs()[2].sets()[0].remaining(2); diff --git a/continuousprint/static/js/continuousprint_settings.js b/continuousprint/static/js/continuousprint_settings.js index 0bda78b..87267c0 100644 --- a/continuousprint/static/js/continuousprint_settings.js +++ b/continuousprint/static/js/continuousprint_settings.js @@ -9,14 +9,17 @@ if (typeof log === "undefined" || log === null) { CP_CUSTOM_EVENTS = []; CP_LOCAL_IP = ''; CPAPI = require('./continuousprint_api'); + CP_SIMULATOR_DEFAULT_SYMTABLE = function() {return {};}; + CPSettingsEvent = require('./continuousprint_settings_event'); + OctoPrint = undefined; } -function CPSettingsViewModel(parameters, profiles=CP_PRINTER_PROFILES, default_scripts=CP_GCODE_SCRIPTS, custom_events=CP_CUSTOM_EVENTS) { +function CPSettingsViewModel(parameters, profiles=CP_PRINTER_PROFILES, default_scripts=CP_GCODE_SCRIPTS, custom_events=CP_CUSTOM_EVENTS, default_symtable=CP_SIMULATOR_DEFAULT_SYMTABLE, octoprint=OctoPrint) { var self = this; self.PLUGIN_ID = "octoprint.plugins.continuousprint"; self.log = log.getLogger(self.PLUGIN_ID); self.settings = parameters[0]; - self.files = parameters[1] + self.files = parameters[1]; self.api = parameters[2] || new CPAPI(); self.loading = ko.observable(false); self.api.init(self.loading, function(code, reason) { @@ -31,6 +34,40 @@ function CPSettingsViewModel(parameters, profiles=CP_PRINTER_PROFILES, default_s }); self.local_ip = ko.observable(CP_LOCAL_IP || ''); + // We have to use the global slicer data retriever instead of + // slicingViewModel because the latter does not make its profiles + // available without modifying the slicing modal. + self.slicers = ko.observable({}); + self.slicer = ko.observable(); + self.slicer_profile = ko.observable(); + if (octoprint !== undefined) { + octoprint.slicing.listAllSlicersAndProfiles().done(function (data) { + let result = {}; + for (let d of Object.values(data)) { + let profiles = []; + let default_profile = null; + for (let p of Object.keys(d.profiles)) { + if (d.profiles[p].default) { + default_profile = p; + continue; + } + profiles.push(p); + } + if (default_profile) { + profiles.unshift(default_profile); + } + result[d.key] = { + name: d.displayName, + key: d.key, + profiles, + }; + } + self.slicers(result); + }); + } + self.slicerProfiles = ko.computed(function() { + return (self.slicers()[self.slicer()] || {}).profiles; + }); // Constants defined in continuousprint_settings.jinja2, passed from the plugin (see `get_template_vars()` in __init__.py) self.profiles = {}; for (let prof of profiles) { @@ -306,6 +343,8 @@ function CPSettingsViewModel(parameters, profiles=CP_PRINTER_PROFILES, default_s self.selected_model(prof.model); break; } + self.slicer(self.settings.settings.plugins.continuousprint.cp_slicer()); + self.slicer_profile(self.settings.settings.plugins.continuousprint.cp_slicer_profile()); } // Queues and scripts are stored in the DB; we must fetch them whenever // the settings page is loaded @@ -343,10 +382,7 @@ function CPSettingsViewModel(parameters, profiles=CP_PRINTER_PROFILES, default_s preprocessor: ko.observable(preprocessors[a.preprocessor]), }); } - events.push({ - ...k, - actions: ko.observableArray(actions), - }); + events.push(new CPSettingsEvent(k, actions, self.api, default_symtable())); } events.sort((a, b) => a.display < b.display); self.events(events); @@ -356,6 +392,10 @@ function CPSettingsViewModel(parameters, profiles=CP_PRINTER_PROFILES, default_s // Called automatically by SettingsViewModel self.onSettingsBeforeSave = function() { + let cpset = self.settings.settings.plugins.continuousprint; + cpset.cp_slicer(self.slicer()); + cpset.cp_slicer_profile(self.slicer_profile()); + let queues = self.queues(); if (JSON.stringify(queues) !== self.queue_fingerprint) { // Sadly it appears flask doesn't have good parsing of nested POST structures, @@ -375,19 +415,9 @@ function CPSettingsViewModel(parameters, profiles=CP_PRINTER_PROFILES, default_s } let events = {}; for (let e of self.events()) { - let ks = []; - for (let a of e.actions()) { - let pp = a.preprocessor() - if (pp !== null) { - pp = pp.name(); - } - ks.push({ - script: a.script.name(), - preprocessor: pp, - }); - } - if (ks.length !== 0) { - events[e.event] = ks; + let e2 = e.pack(); + if (e2) { + events[e.event] = e2; } } let data = {scripts, preprocessors, events}; diff --git a/continuousprint/static/js/continuousprint_settings.test.js b/continuousprint/static/js/continuousprint_settings.test.js index 69a62f6..9bd8c4c 100644 --- a/continuousprint/static/js/continuousprint_settings.test.js +++ b/continuousprint/static/js/continuousprint_settings.test.js @@ -37,6 +37,14 @@ const EVENTS = [ {event: 'e1'}, ]; +function mock_oprint() { + return { + slicing: { + listAllSlicersAndProfiles: jest.fn(), + }, + }; +} + function mocks() { return [ { @@ -46,6 +54,8 @@ function mocks() { cp_bed_clearing_script: jest.fn(), cp_queue_finished_script: jest.fn(), cp_printer_profile: jest.fn(), + cp_slicer: jest.fn(), + cp_slicer_profile: jest.fn(), }, }, }, @@ -61,6 +71,7 @@ function mocks() { init: jest.fn(), get: jest.fn((_, cb) => cb([])), edit: jest.fn(), + simulate: jest.fn(), }, ]; } @@ -289,3 +300,55 @@ test('add new preprocessor from Events tab', () =>{ expect(v.gotoTab).toHaveBeenCalled(); }); + +test('Get slicers and profiles for dropdowns', () => { + let op = mock_oprint(); + let cb = null; + + op.slicing.listAllSlicersAndProfiles = () => { + return { + done: (c) => cb = c + }; + }; + let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS, EVENTS, CP_SIMULATOR_DEFAULT_SYMTABLE, op); + cb({"preprintservice":{ + "configured":false, + "default":false, + "displayName":"PrePrintService", + "extensions":{ + "destination":["gco","gcode","g"], + "source":["stl"] + }, + "key":"preprintservice", + "profiles":{ + "profile_015mm_brim":{ + "default":true, + "description":"Imported ...", + "displayName":"profile_015mm_brim\n", + "key":"profile_015mm_brim", + "resource":"http://localhost:5000/api/slicing/preprintservice/profiles/profile_015mm_brim" + } + }, + "sameDevice":false + }}); + expect(v.slicers()).toEqual({ + "preprintservice": { + "key": "preprintservice", + "name": "PrePrintService", + "profiles": ["profile_015mm_brim"], + }, + }); +}); + +test('Set slicer & profile before save', () => { + let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS, EVENTS); + v.slicers({slicername: {key: "slicername", name: "Slicer", profiles: ["profile1", "profile2"]}}); + + v.slicer("slicername"); + expect(v.slicerProfiles()).toEqual(["profile1", "profile2"]); + v.slicer_profile("profile2"); + + v.onSettingsBeforeSave(); + expect(v.settings.settings.plugins.continuousprint.cp_slicer).toHaveBeenCalledWith("slicername"); + expect(v.settings.settings.plugins.continuousprint.cp_slicer_profile).toHaveBeenCalledWith("profile2"); +}); diff --git a/continuousprint/static/js/continuousprint_settings_event.js b/continuousprint/static/js/continuousprint_settings_event.js new file mode 100644 index 0000000..41ea5c2 --- /dev/null +++ b/continuousprint/static/js/continuousprint_settings_event.js @@ -0,0 +1,176 @@ +if (typeof log === "undefined" || log === null) { + // In the testing environment, dependencies must be manually imported + ko = require('knockout'); + log = { + "getLogger": () => {return console;} + }; + CP_PRINTER_PROFILES = []; + CP_GCODE_SCRIPTS = []; + CP_CUSTOM_EVENTS = []; + CP_LOCAL_IP = ''; + CPAPI = require('./continuousprint_api'); +} + +function CPSettingsEvent(evt, actions, api, default_symtable) { + var self = this; + self.api = api; + self.name = evt.name; + self.event = evt.event; + self.display = evt.display; + self.desc = evt.desc; + + // Construct symtable used for simulating output + let sym = default_symtable; + if (sym.current) { + sym.current.state = evt.sym_state; + } + self.symtable = ko.observable(sym); + self.symtableEdit = ko.observable(JSON.stringify(sym, null, 2)); + self.symtableEditError = ko.observable(null); + self.symtableEdit.subscribe(function(newValue) { + try { + let v = JSON.parse(newValue); + self.symtableEditError(null); + self.symtable(v); + } catch(e) { + self.symtableEditError(e.toString()); + } + }); + + // actions is an array of {script, preprocessor (observable)} + self.actions = ko.observableArray(actions); + + self.timeout = null; + self.running = ko.observable(false); + self.simulation = ko.observable({ + gcode: "", + stdout: "", + stderr: "", + symtable_diff: {}, + }); + self.simGcodeOutput = ko.computed(function() { + let s = self.simulation(); + if (self.running()) { + return "..."; + } else if (s.stderr !== "") { + return "@PAUSE; Preprocessor error"; + } else { + return s.gcode; + } + }); + self.combinedSimOutput = ko.computed(function() { + let s = self.simulation(); + if (self.running()) { + return "..."; + } + return s.stdout + '\n' + s.stderr; + }); + self.simSymtable = ko.computed(function() { + let s = self.simulation(); + if (self.running()) { + return []; + } + let r = []; + for (let k of Object.keys(s.symtable_diff)) { + r.push({key: k, value: s.symtable_diff[k]}); + } + return r; + }); + self.apply = function() { + // This is called by the sortable code for some reason, no idea why + // but otherwise it raises an exception. + }; + let numlines = function(text) { + if (text === "") { + return 0; + } + let m = text.match(/\n/g); + if (m === null) { + return 1; + } else if (text[text.length-1] == "\n") { + return m.length; + } + return m.length + 1; + } + self.simSummary = ko.computed(function() { + let s = self.simulation(); + if (self.running()) { + return "running simulation..."; + } else if (s.stderr !== "") { + return "Simulation: execution error!"; + } + let r = "Simulation OK: "; + let gline = numlines(s.gcode); + r += (gline === 1) ? '1 line' : `${gline} lines`; + let nline = numlines(s.stdout); + if (nline > 0) { + r += (nline === 1) ? ', 1 notification' : `, ${nline} notifications`; + } + return r; + }); + self.simExpanded = ko.observable(true); + self.symtableExpanded = ko.observable(true); + self.updater = ko.computed(function() { + // Computed function doesn't return anything itself, but does + // update the simulation observable above. It does need to be + // referenced from the HTML though. + // We operate after a timeout so as not to send unnecessary load + // to the server. + self.running(true); + + // This must run on *every* call to the updater, so that the + // correct listeners are applied + let automation = []; + let sym = self.symtable(); + for (let a of self.actions()) { + let pp = a.preprocessor(); + automation.push([ + a.script.body(), + (pp && pp.body()) + ]); + } + + if (self.timeout !== null) { + clearTimeout(self.timeout); + } + self.timeout = setTimeout(function() { + self.api.simulate(automation, sym, (result) => { + self.simulation(result); + self.timeout = null; + self.running(false); + }, (code, reason) => { + let msg = `Server error (${code}): ${reason}`; + console.error(msg); + self.simulation({ + gcode: '', + stdout: '', + stderr: msg, + symtable_diff: [], + }); + self.timeout = null; + self.running(false); + }); + }, 1000); + }); + + self.pack = function() { + let ks = []; + for (let a of self.actions()) { + let pp = a.preprocessor(); + if (pp !== null) { + pp = pp.name(); + } + ks.push({ + script: a.script.name(), + preprocessor: pp, + }); + } + if (ks.length !== 0) { + return ks; + } + }; +} + +try { +module.exports = CPSettingsEvent; +} catch {} diff --git a/continuousprint/static/js/continuousprint_settings_event.test.js b/continuousprint/static/js/continuousprint_settings_event.test.js new file mode 100644 index 0000000..a40d1f1 --- /dev/null +++ b/continuousprint/static/js/continuousprint_settings_event.test.js @@ -0,0 +1,120 @@ +const CPSettingsEvent = require('./continuousprint_settings_event'); + +function testAction(n, gcode, py) { + return { + script: {name: ko.observable('s' + n), body: ko.observable(gcode)}, + preprocessor: ko.observable({name: ko.observable('p' + n), body: ko.observable(py)}), + } +} + +function testEvent() { + return new CPSettingsEvent( + {name: 'name', event: 'event', display: 'display', desc: 'desc', sym_state: 'evt_state'}, + [testAction(0, "g1", "p1"), testAction(1, "g2", "p2")], + {simulate: jest.fn()}, + {defaultsym: 'asdf'} + ); +} + +describe('visible text computed vars', () => { + test('sim running', () => { + let vm = testEvent(); + vm.running(true); + + expect(vm.simGcodeOutput()).toEqual("..."); + expect(vm.combinedSimOutput()).toEqual("..."); + expect(vm.simSymtable()).toEqual([]); + expect(vm.simSummary()).toEqual("running simulation..."); + }); + test('sim successful', () => { + let vm = testEvent(); + vm.simulation({ + gcode: "gcode", + stdout: "stdout", + stderr: "", + symtable_diff: {a: 'foo'}, + }); + vm.running(false); + + expect(vm.simGcodeOutput()).toEqual("gcode"); + expect(vm.combinedSimOutput()).toEqual("stdout\n"); + expect(vm.simSymtable()).toEqual([{key: 'a', value: 'foo'}]); + expect(vm.simSummary()).toEqual("Simulation OK: 1 line, 1 notification"); + }); + test('sim error', () => { + let vm = testEvent(); + vm.simulation({ + gcode: "gcode ignored", + stdout: "stdout", + stderr: "stderr", + symtable_diff: {a: 'foo'}, + }); + vm.running(false); + + expect(vm.simGcodeOutput()).toEqual("@PAUSE; Preprocessor error"); + expect(vm.combinedSimOutput()).toEqual("stdout\nstderr"); + expect(vm.simSymtable()).toEqual([{key: 'a', value: 'foo'}]); + expect(vm.simSummary()).toEqual("Simulation: execution error!"); + }); +}); + +describe('symtableEdit', () => { + test('updates symtable when symtableEdit edited successfully', () => { + let vm = testEvent(); + vm.symtableEdit("123"); + expect(vm.symtable()).toEqual(123); + }); + test('updates symtableEditError when JSON parse fails', () => { + let vm = testEvent(); + vm.symtableEdit("not parseable"); + expect(vm.symtableEditError()).toMatch(/^SyntaxError.*/); + }); +}); + +describe('updater', () => { + test('updates simulation values exactly once', () => { + jest.useFakeTimers(); + let vm = testEvent(); + let want = {symtable_diff: [], gcode: "result", stdout: "", stderr: ""}; + vm.api.simulate = jest.fn((auto, sym, cb, errcb) => { + cb(want); + }); + vm.actions(vm.actions()); // Set dirty bit + vm.updater(); + expect(vm.timer).not.toEqual(null); + expect(vm.running()).toEqual(true); + + // Trigger updater a couple more times for good measure + for (let i = 0; i < 10; i++) { + vm.actions(vm.actions()); // Set dirty bit + vm.updater(); + } + + jest.runAllTimers(); + expect(vm.api.simulate).toHaveBeenCalledTimes(1); + + expect(vm.running()).toEqual(false); + expect(vm.simulation()).toEqual(want); + }); + test('handles server error', () => { + jest.useFakeTimers(); + let vm = testEvent(); + let want = {symtable_diff: [], gcode: "result", stdout: "", stderr: ""}; + vm.api.simulate = jest.fn((auto, sym, cb, errcb) => { + errcb(123, "test"); + }); + vm.actions(vm.actions()); // Set dirty bit + vm.updater(); + jest.runAllTimers(); + expect(vm.running()).toEqual(false); + expect(vm.simulation().stderr).toEqual('Server error (123): test'); + }); +}); + +test('pack', () => { + let vm = testEvent(); + expect(vm.pack()).toEqual([ + {"preprocessor": "p0", "script": "s0"}, + {"preprocessor": "p1", "script": "s1"} + ]); +}); diff --git a/continuousprint/static/js/continuousprint_stats.js b/continuousprint/static/js/continuousprint_stats.js new file mode 100644 index 0000000..05a9237 --- /dev/null +++ b/continuousprint/static/js/continuousprint_stats.js @@ -0,0 +1,158 @@ +/* + * View model for OctoPrint-Print-Queue + * + * Contributors: Michael New, Scott Martin + * License: AGPLv3 + */ + +if (typeof ko === "undefined" || ko === null) { + ko = require('knockout'); +} +if (typeof CPSet === "undefined" || CPSet === null) { + CPSet = require('./continuousprint_set'); +} + +// Computes aggregate statistics of time, filament, counts etc. +function CPStats(jobs, stats_dimensions) { + var self = this; + + const Stat = { + COUNT: 0, + TIME: 1, + MASS: 2, + }; + + self.header = [ + {legend: 'Total items', title: null}, + {legend: 'Total time', title: "Uses Octoprint's file analysis estimate; may be inaccurate"}, + {legend: 'Total mass', title: "Mass is calculated using active spool(s) in SpoolManager"} + ]; + + self._safeParse = function(v) { + v = parseInt(v, 10); + return (isNaN(v)) ? 0 : v; + }; + + self._appendCount = function(r, d) { + for (let dim of Object.keys(stats_dimensions)) { + r[Stat.COUNT][dim] += d[dim]; + } + }; + + self._appendTime = function(r, d) { + if (d.ept === null || d.ept === undefined) { + r[Stat.TIME].error += 1; + return; + } + for (let dim of Object.keys(stats_dimensions)) { + r[Stat.TIME][dim] += d[dim] * d.ept; + } + }; + + self._appendMass = function(r, d) { + if (d.len === null || d.len === undefined || d.len.length === 0) { + r[Stat.MASS].error += 1; + return; + } + let mass = 0; + for (let i = 0; i < d.len.length; i++) { + mass += d.linmasses[i] * d.len[i]; + } + if (isNaN(mass)) { + r[Stat.MASS].error += 1; + return; + } + for (let dim of Object.keys(stats_dimensions)) { + r[Stat.MASS][dim] += d[dim] * mass; + } + }; + + self.values = ko.computed(function() { + r = Array(Object.keys(Stat).length); + for (let i = 0; i < Object.keys(Stat).length; i++) { + r[i] = {error:0}; + for (let d of Object.keys(stats_dimensions)) { + r[i][d] = 0; + } + } + for (let j of jobs()) { + let lm = j.getMaterialLinearMasses(); + for (let qs of j.sets()) { + if (!qs.profile_matches()) { + continue; + } + let meta = qs.metadata; + let d = { + remaining: self._safeParse(qs.remaining()), + total: self._safeParse(qs.length_remaining()), + count: self._safeParse(qs.count()), + completed: self._safeParse(qs.completed()), + ept: meta && meta.estimatedPrintTime, + len: meta && meta.filamentLengths, + linmasses: lm, + }; + self._appendCount(r, d); + self._appendTime(r, d); + self._appendMass(r, d); + } + } + return r; + }); + + self.humanize = function(num, unit="") { + // Humanizes numbers by condensing and adding units + let v = ''; + if (num < 1000) { + v = (num % 1 === 0) ? num : num.toFixed(1); + } else if (num < 100000) { + let k = (num/1000); + v = ((k % 1 === 0) ? k : k.toFixed(1)) + 'k'; + } + return v + unit; + }; + + self.humanTime = function(s) { + // Humanizes time values; parameter is seconds + if (s < 60) { + return Math.round(s) + 's'; + } else if (s < 3600) { + return Math.round(s/60) + 'm'; + } else if (s < 86400) { + let h = s/3600; + return ((h % 1 === 0) ? h : h.toFixed(1)) + 'h'; + } else { + let d = s/86400; + return ((d % 1 === 0) ? d : d.toFixed(1)) + 'd'; + } + }; + + self.values_humanized = ko.computed(function() { + let r = JSON.parse(JSON.stringify(self.values())); // Simple but expensive deep-copy, otherwise changes here will affect the result of values() + for (let i=0; i < r.length; i++) { + r[i] = { ...self.header[i], ...r[i]}; + } + for (let k of Object.keys(stats_dimensions)) { + r[Stat.COUNT][k] = self.humanize(r[0][k]); + r[Stat.TIME][k] = self.humanTime(r[1][k]); + r[Stat.MASS][k] = self.humanize(r[2][k], 'g'); + } + // Assign error texts + r[Stat.COUNT].error = ''; + r[Stat.TIME].error = (r[1].error > 0) ? `${r[1].error} sets missing time estimates` : ''; + r[Stat.MASS].error = (r[2].error > 0) ? `${r[1].error} errors calculating mass` : ''; + + // Hide mass details if mass is zero (implies SpoolManager not set up) + let hasMass = false; + for (let d of Object.keys(stats_dimensions)) { + hasMass |= (r[Stat.MASS][d] !== '0g'); + } + if (!hasMass) { + r.splice(Stat.MASS,1); + } + return r; + }); +} + +try { + module.exports = CPStats; +} catch {} diff --git a/continuousprint/static/js/continuousprint_viewmodel.js b/continuousprint/static/js/continuousprint_viewmodel.js index abca6fa..ab32a9e 100644 --- a/continuousprint/static/js/continuousprint_viewmodel.js +++ b/continuousprint/static/js/continuousprint_viewmodel.js @@ -112,7 +112,6 @@ function CPViewModel(parameters) { self.hideRemoveConfirmModal(); }; self.showSettingsHelp = function() { - console.log(self.settings); self.settings.show('settings_plugin_continuousprint'); $(`#settings_plugin_continuousprint a[href="#settings_continuousprint_help"]`).tab('show'); }; diff --git a/continuousprint/storage/database.py b/continuousprint/storage/database.py index 956c37d..c27e3b6 100644 --- a/continuousprint/storage/database.py +++ b/continuousprint/storage/database.py @@ -21,12 +21,20 @@ import datetime from enum import IntEnum, auto import sys +import logging import inspect import os import yaml import time +logging.getLogger("peewee").setLevel(logging.INFO) + + +class STLResolveError(Exception): + pass + + # Defer initialization class DB: # Adding foreign_keys pragma is necessary for ON DELETE behavior @@ -102,7 +110,8 @@ def as_dict(self): class JobView: """The job view contains functions used to manipulate an underlying Job model. - This is distinct from the Job class to facilitate other storage implementations (e.g. LAN queue data)""" + This is distinct from the Job class to facilitate other storage implementations (e.g. LAN queue data) + """ def refresh_sets(self): raise NotImplementedError() @@ -132,7 +141,7 @@ def _next_set(self, profile, custom_filter): # for the given profile/filter. If this is False then # decrementing the set/job won't do anything WRT set availability any_printable = False - for s in self.sets: + for s in sorted(self.sets, key=lambda s: s.rank): if custom_filter is not None and not custom_filter(s): continue printable = s.is_printable(profile) @@ -218,6 +227,20 @@ def decrement(self, profile): self.save() # Save must occur before job is observed return self.job.next_set(profile) + def resolve(self, override=None): + if override is not None: + self._resolved = override + + # TODO use registered slicer object types per octoprint hook + if not hasattr(self, "_resolved") or self._resolved is None: + raise NotImplementedError( + "Implementer of SetView must implement .resolve()" + ) + elif self._resolved.endswith(".stl"): + raise STLResolveError(f"Set path {self._resolved} requires slicing") + else: + return self._resolved + @classmethod def from_dict(self, s): raise NotImplementedError @@ -285,6 +308,11 @@ def from_dict(self, s): del s[listform] return Set(**s) + def resolve(self, override=None): + if getattr(self, "_resolved", None) is None: + self._resolved = self.path + return super().resolve(override) + class Run(Model): # Runs are totally decoupled from queues, jobs, and sets - this ensures that @@ -462,7 +490,7 @@ def migrateScriptsFromSettings(clearing_script, finished_script, cooldown_script # In v2.2.0 and earlier, a fixed list of scripts were stored in OctoPrint settings. # This converts them to DB format for use in events. with DB.automation.atomic(): - for (evt, name, body) in [ + for evt, name, body in [ (CustomEvents.PRINT_SUCCESS, BED_CLEARING_SCRIPT, clearing_script), (CustomEvents.FINISH, FINISHING_SCRIPT, finished_script), (CustomEvents.COOLDOWN, COOLDOWN_SCRIPT, cooldown_script), diff --git a/continuousprint/storage/database_test.py b/continuousprint/storage/database_test.py index 1f750c6..441d7b6 100644 --- a/continuousprint/storage/database_test.py +++ b/continuousprint/storage/database_test.py @@ -11,11 +11,13 @@ migrateQueuesV2ToV3, Job, Set, + SetView, Run, Script, EventHook, StorageDetails, DEFAULT_QUEUE, + STLResolveError, ) from ..data import CustomEvents import tempfile @@ -27,10 +29,7 @@ class QueuesDBTest(unittest.TestCase): def setUp(self): self.tmpQueues = tempfile.NamedTemporaryFile(delete=True) self.addCleanup(self.tmpQueues.close) - init_queues( - self.tmpQueues.name, - logger=logging.getLogger(), - ) + init_queues(self.tmpQueues.name) self.q = Queue.get(name=DEFAULT_QUEUE) @@ -38,10 +37,7 @@ class AutomationDBTest(unittest.TestCase): def setUp(self): self.tmpAutomation = tempfile.NamedTemporaryFile(delete=True) self.addCleanup(self.tmpAutomation.close) - init_automation( - self.tmpAutomation.name, - logger=logging.getLogger(), - ) + init_automation(self.tmpAutomation.name) class DBTest(QueuesDBTest, AutomationDBTest): @@ -292,13 +288,14 @@ def setUp(self): queue=self.q, name="a", rank=0, count=5, remaining=5, draft=False ) self.s = [] - for name in ("a", "b"): + # Note: b's rank is earlier than A but inserted later (i.e. later DB id) + for name, rank in (("a", 1), ("b", 0)): self.s.append( Set.create( path="a", sd=False, job=self.j, - rank=0, + rank=rank, count=2, remaining=2, material_keys="m1,m2", @@ -306,15 +303,15 @@ def setUp(self): ) ) - def testSetsAreSequential(self): + def testSetsAreSequentialByRank(self): p = dict(name="p1") - self.assertEqual(self.j.next_set(p), self.s[0]) - Set.get(1).decrement(p) - self.assertEqual(self.j.next_set(p), self.s[0]) - Set.get(1).decrement(p) self.assertEqual(self.j.next_set(p), self.s[1]) Set.get(2).decrement(p) self.assertEqual(self.j.next_set(p), self.s[1]) + Set.get(2).decrement(p) + self.assertEqual(self.j.next_set(p), self.s[0]) + Set.get(1).decrement(p) + self.assertEqual(self.j.next_set(p), self.s[0]) class TestSet(QueuesDBTest): @@ -360,6 +357,23 @@ def testDecrementEndNotDoubleCounted(self): self.assertEqual(self.j.remaining, 0) self.assertEqual(self.s.remaining, 0) + def testResolveUnimplemented(self): + sv = SetView() + with self.assertRaises(NotImplementedError): + sv.resolve() + + def testResolveGcode(self): + self.assertEqual(self.s.resolve(), self.s.path) + + def testResolveSTL(self): + self.s.path = "testpath.stl" + with self.assertRaises(STLResolveError): + self.s.resolve() + + def testResolveAlreadySet(self): + self.s._resolved = "testval" + self.assertEqual(self.s.resolve(), "testval") + def testFromDict(self): d = self.s.as_dict() s = Set.from_dict(d) diff --git a/continuousprint/storage/lan.py b/continuousprint/storage/lan.py index 272ec75..638dc90 100644 --- a/continuousprint/storage/lan.py +++ b/continuousprint/storage/lan.py @@ -52,7 +52,7 @@ def refresh_sets(self): self.save() -class ResolveError(Exception): +class LANResolveError(Exception): pass @@ -71,13 +71,13 @@ def __init__(self, data, job, rank): self.profile_keys = ",".join(data.get("profiles", [])) self._resolved = None - def resolve(self) -> str: + def resolve(self, override=None) -> str: if self._resolved is None: try: self._resolved = str(Path(self.job.get_base_dir()) / self.path) except HTTPError as e: - raise ResolveError(f"Failed to resolve {self.path}") from e - return self._resolved + raise LANResolveError(f"Failed to resolve {self.path}") from e + return super().resolve(override) def save(self): self.job.save() diff --git a/continuousprint/storage/lan_test.py b/continuousprint/storage/lan_test.py index f85d0f4..91ece90 100644 --- a/continuousprint/storage/lan_test.py +++ b/continuousprint/storage/lan_test.py @@ -1,6 +1,7 @@ import unittest from unittest.mock import MagicMock -from .lan import LANJobView, LANSetView, ResolveError +from .database import STLResolveError +from .lan import LANJobView, LANSetView, LANResolveError from requests.exceptions import HTTPError @@ -25,6 +26,13 @@ def test_resolve_file(self): self.lq.get_gjob_dirpath.return_value = "/path/to/" self.assertEqual(self.s.resolve(), "/path/to/a.gcode") + def test_resolve_stl(self): + # Ensure STL checking from the parent class is still triggered + self.j.sets[0].path = "a.stl" + self.lq.get_gjob_dirpath.return_value = "/path/to/" + with self.assertRaises(STLResolveError): + self.s.resolve() + def test_remap_set_paths(self): self.lq.get_gjob_dirpath.return_value = "/path/to/" self.j.remap_set_paths() @@ -32,7 +40,7 @@ def test_remap_set_paths(self): def test_resolve_http_error(self): self.lq.get_gjob_dirpath.side_effect = HTTPError - with self.assertRaises(ResolveError): + with self.assertRaises(LANResolveError): self.s.resolve() def test_decrement_refreshes_sets_and_saves(self): diff --git a/continuousprint/storage/queries.py b/continuousprint/storage/queries.py index cdacd59..1874d3b 100644 --- a/continuousprint/storage/queries.py +++ b/continuousprint/storage/queries.py @@ -245,8 +245,8 @@ def _rankBalance(cls): with DB.queues.atomic(): # TODO discriminate by queue - may case weirdness with archived jobs ranker = _genRank(cls.select().count()) - for (l, c) in zip(ranker, cls.select().order_by(cls.rank)): - c.rank = l + for r, c in zip(ranker, cls.select().order_by(cls.rank)): + c.rank = r c.save() @@ -499,47 +499,14 @@ def getAutomation(): return dict(scripts=scripts, events=events, preprocessors=preprocessors) -def genEventScript(evt: CustomEvents, interp=None, logger=None) -> str: - result = [] - for e in ( - EventHook.select() - .join_from(EventHook, Script, JOIN.LEFT_OUTER) - .join_from(EventHook, Preprocessor, JOIN.LEFT_OUTER) - .where(EventHook.name == evt.event) - .order_by(EventHook.rank) - ): - procval = True - if e.preprocessor is not None and e.preprocessor.body.strip() != "": - procval = interp(e.preprocessor.body) - if logger: - logger.info( - f"EventHook preprocessor for script {e.script.name} ({e.preprocessor.name}): {e.preprocessor.body}\nSymbols: {interp.symtable}\nResult: {procval}" - ) - - if procval is None or procval is False: - continue - elif procval is True: - formatted = e.script.body - elif type(procval) is dict: - if logger: - logger.info( - f"Appending script {e.script.name} using formatting data {procval}" - ) - formatted = e.script.body.format(**procval) - else: - raise Exception( - f"Invalid return type {type(procval)} for peprocessor {e.preprocessor.name}" - ) - - leftovers = re.findall(r"\{.*?\}", formatted) - if len(leftovers) > 0: - ppname = ( - f"f from preprocessor {e.preprocessor.name}" - if e.preprocessor is not None - else "" - ) - raise Exception( - f"Unformatted placeholders in {e.script.name}{ppname}: {leftovers}" - ) - result.append(formatted) - return "\n".join(result) +def getAutomationForEvent(evt: CustomEvents) -> list: + return [ + (e.script.body, e.preprocessor.body if e.preprocessor else None) + for e in ( + EventHook.select() + .join_from(EventHook, Script, JOIN.LEFT_OUTER) + .join_from(EventHook, Preprocessor, JOIN.LEFT_OUTER) + .where(EventHook.name == evt.event) + .order_by(EventHook.rank) + ) + ] diff --git a/continuousprint/storage/queries_test.py b/continuousprint/storage/queries_test.py index 45f1800..2c9ceb7 100644 --- a/continuousprint/storage/queries_test.py +++ b/continuousprint/storage/queries_test.py @@ -300,7 +300,7 @@ def rank(): ) def testMoveJob(self): - for (moveArgs, want) in [((1, 2), [2, 1]), ((2, None), [2, 1])]: + for moveArgs, want in [((1, 2), [2, 1]), ((2, None), [2, 1])]: with self.subTest(f"moveJob({moveArgs}) -> want {want}"): q.moveJob(*moveArgs) self.assertEqual([j.id for j in q.getJobsAndSets(DEFAULT_QUEUE)], want) @@ -380,7 +380,7 @@ def testAssignGet(self): self.assertEqual(got["events"][evt], [dict(script="foo", preprocessor=None)]) def testAssignBadEventKey(self): - with self.assertRaisesRegexp(KeyError, "No such CPQ event"): + with self.assertRaisesRegex(KeyError, "No such CPQ event"): q.assignAutomation( dict(), dict(), dict(evt=[dict(script="foo", preprocessor=None)]) ) @@ -409,7 +409,10 @@ def testMultiScriptEvent(self): ] ), ) - self.assertEqual(q.genEventScript(CustomEvents.PRINT_SUCCESS), "gcode1\ngcode2") + self.assertEqual( + [a[0] for a in q.getAutomationForEvent(CustomEvents.PRINT_SUCCESS)], + ["gcode1", "gcode2"], + ) # Ordering of event matters q.assignAutomation( @@ -427,57 +430,7 @@ def testMultiScriptEvent(self): ] ), ) - self.assertEqual(q.genEventScript(CustomEvents.PRINT_SUCCESS), "gcode2\ngcode1") - - def testPreprocessorEvalTrueFalseNone(self): - e = CustomEvents.PRINT_SUCCESS - q.assignAutomation( - dict(s1="gcode1"), - dict(p1="python1"), - dict([(e.event, [dict(script="s1", preprocessor="p1")])]), - ) - - self.assertEqual(q.genEventScript(e, lambda cond: True), "gcode1") - self.assertEqual(q.genEventScript(e, lambda cond: False), "") - self.assertEqual(q.genEventScript(e, lambda cond: None), "") - - def testNoProcessorPlaceholder(self): - e = CustomEvents.PRINT_SUCCESS - q.assignAutomation( - dict(s1="{foo} will never be formatted!"), - dict(), - dict([(e.event, [dict(script="s1", preprocessor=None)])]), - ) - with self.assertRaises(Exception): - q.genEventScript(e, lambda cond: False) - - def testProcessorEvalFormat(self): - e = CustomEvents.PRINT_SUCCESS - q.assignAutomation( - dict(s1="Hello {val}"), - dict(p1="mocked"), - dict([(e.event, [dict(script="s1", preprocessor="p1")])]), - ) self.assertEqual( - q.genEventScript(e, lambda cond: dict(val="World")), "Hello World" - ) - - def testProcessorEvalBadType(self): - e = CustomEvents.PRINT_SUCCESS - q.assignAutomation( - dict(s1="don'tcare"), - dict(p1="mocked"), - dict([(e.event, [dict(script="s1", preprocessor="p1")])]), + [a[0] for a in q.getAutomationForEvent(CustomEvents.PRINT_SUCCESS)], + ["gcode2", "gcode1"], ) - with self.assertRaises(Exception): - q.genEventScript(e, lambda cond: 7) - - def testProcessorEvalMissedPlaceholder(self): - e = CustomEvents.PRINT_SUCCESS - q.assignAutomation( - dict(s1="{foo} will never be formatted!"), - dict(p1="mocked"), - dict([(e.event, [dict(script="s1", preprocessor="p1")])]), - ) - with self.assertRaises(Exception): - q.genEventScript(e, lambda cond: dict(bar="baz")) diff --git a/continuousprint/templates/continuousprint_settings.jinja2 b/continuousprint/templates/continuousprint_settings.jinja2 index d04401a..9a48b5d 100644 --- a/continuousprint/templates/continuousprint_settings.jinja2 +++ b/continuousprint/templates/continuousprint_settings.jinja2 @@ -9,6 +9,30 @@ const CP_CUSTOM_EVENTS = {{plugin_continuousprint_custom_events|tojson|safe}}; const CP_LOCAL_IP = {{plugin_continuousprint_local_ip|tojson|safe}}; const CP_EXCEPTIONS = {{plugin_continuousprint_exceptions|tojson|safe}}; + const CP_SIMULATOR_DEFAULT_SYMTABLE = function() {return {{plugin_continuousprint_simulator_default_symtable|tojson|safe}}; }; + + const CP_STATS_DIMENSIONS = { + completed: { + name: "completed", + display: "Complete", + desc: "Number of prints completed for this iteration of the job", + }, + count: { + name: "count", + display: "Per Run", + desc: "Number of prints per each iteration of the job", + }, + remaining: { + name: "remaining", + display: "This Run", + desc: "Number of prints yet to be printed for the current job iteration", + }, + total: { + name: "total", + display: "Pending", + desc: "Total number of prints yet to be printed across all remaining iterations of the job", + }, + };
+ + This hidden element needed for rendering simulation() results; + see CPSettingsEvent class + +
+
+ +
+
+
+
+
+
+
GCODE
+

+                
+
+
Notifications & Errors
+

+                
+
+ toggle state details +
+
Modified state
+
+ Hint: these variables were modified by preprocessors during the simulation. +
+
None
+
+
=
+
+
Simulation initial state
+
+ Hint: edit to simulate behavior with different states of the printer, file etc. - this has no effect when the event is fired for real. +
+
+ +
+
+
+
+ +
@@ -373,6 +440,7 @@
+
@@ -385,6 +453,34 @@
+ +
+ +
+ +
+ +
+ +
+
+ +
+ +
+ +
+
Bed Cooldown Settings diff --git a/continuousprint/templates/continuousprint_tab.jinja2 b/continuousprint/templates/continuousprint_tab.jinja2 index e5bea5c..b892855 100644 --- a/continuousprint/templates/continuousprint_tab.jinja2 +++ b/continuousprint/templates/continuousprint_tab.jinja2 @@ -92,7 +92,6 @@
-
@@ -181,10 +180,11 @@
Set?
-
Complete?
-
Per Run?
-
This Run?
-
Pending?
+ +
+ ? +
+
@@ -275,7 +275,7 @@
-
+
?
@@ -293,8 +293,31 @@
-
-
+ +
+ show/hide queue stats +
+
+
+ +
+ +
+
+
+
?
+
+
+
+
+
+
+
+
+
+ + +
Hint: Set up additional queues (including LAN queues) in the plugin settings. diff --git a/continuousprint/thirdparty/__init__.py b/continuousprint/thirdparty/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/continuousprint/thirdparty/spoolmanager.py b/continuousprint/thirdparty/spoolmanager.py new file mode 100644 index 0000000..6fb8753 --- /dev/null +++ b/continuousprint/thirdparty/spoolmanager.py @@ -0,0 +1,46 @@ +from octoprint.server import app +import json + + +class SpoolManagerException(Exception): + pass + + +class SpoolManagerIntegration: + def __init__(self, impl, logger): + self._logger = logger + self._impl = impl + + def get_materials(self): + try: + materials = self._impl.api_getSelectedSpoolInformations() + materials = [ + f"{m['material']}_{m['colorName']}_{m['color']}" + if m is not None + else None + for m in materials + ] + return materials + except Exception as e: + self._logger.warning( + f"Skipping material assignment due to SpoolManager error: {e}" + ) + return [] + + def allowed_to_print(self): + with app.app_context(): + r = self._impl.allowed_to_print() + if r.status_code != 200: + raise SpoolManagerException( + f"SpoolManager allowed_to_print() error: {r.data}" + ) + return json.loads(r.data) + + def start_print_confirmed(self): + with app.app_context(): + r = self._impl.start_print_confirmed() + if r.status_code != 200: + raise SpoolManagerException( + f"SpoolManager error {r.status_code} on print start: {r.data}" + ) + return json.loads(r.data) diff --git a/continuousprint/thirdparty/spoolmanager_test.py b/continuousprint/thirdparty/spoolmanager_test.py new file mode 100644 index 0000000..4445491 --- /dev/null +++ b/continuousprint/thirdparty/spoolmanager_test.py @@ -0,0 +1,49 @@ +import unittest +from unittest.mock import MagicMock +import logging +from .spoolmanager import SpoolManagerIntegration, SpoolManagerException + + +class TestSpoolManagerIntegration(unittest.TestCase): + def setUp(self): + self.s = SpoolManagerIntegration( + impl=MagicMock(), + logger=logging.getLogger(), + ) + + def test_get_materials_ok(self): + self.s._impl.api_getSelectedSpoolInformations.return_value = [ + dict(material="PLA", colorName="red", color="FF0000"), + dict(material="ABS", colorName="blue", color="0000FF"), + ] + self.assertEqual(self.s.get_materials(), ["PLA_red_FF0000", "ABS_blue_0000FF"]) + + def test_get_materials_exception(self): + self.s._impl.api_getSelectedSpoolInformations.side_effect = Exception("testing") + self.assertEqual(self.s.get_materials(), []) + + def test_allowed_to_print(self): + self.s._impl.allowed_to_print.return_value = MagicMock( + status_code=200, data="123" + ) + self.assertEqual(self.s.allowed_to_print(), 123) + + def test_allowed_to_print_err(self): + self.s._impl.allowed_to_print.return_value = MagicMock( + status_code=500, data="testing error" + ) + with self.assertRaises(SpoolManagerException): + self.s.allowed_to_print() + + def test_start_print_confirmed(self): + self.s._impl.start_print_confirmed.return_value = MagicMock( + status_code=200, data="123" + ) + self.assertEqual(self.s.start_print_confirmed(), 123) + + def test_start_print_confirmed_err(self): + self.s._impl.start_print_confirmed.return_value = MagicMock( + status_code=500, data="testing error" + ) + with self.assertRaises(SpoolManagerException): + self.s.allowed_to_print() diff --git a/docker-compose.yaml b/docker-compose.yaml index ed2bf7a..8ffb69b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -10,6 +10,9 @@ services: - "./volume:/home/oprint/.octoprint" environment: - "PYTHONUNBUFFERED=1" + hostname: "octoprint" + networks: + - preprintservice dev2: image: continuousprint-dev build: . @@ -20,3 +23,8 @@ services: - "./volume2:/home/oprint/.octoprint" environment: - "PYTHONUNBUFFERED=1" + +networks: + preprintservice: + name: octoprint-preprintservice_default + external: true diff --git a/docs/auto-slicer.md b/docs/auto-slicer.md new file mode 100644 index 0000000..90668da --- /dev/null +++ b/docs/auto-slicer.md @@ -0,0 +1,92 @@ +# Automatic Slicing + +## Use Case + +Continuous Print normally prints `.gcode` files. These files are sliced for a specific printer and are not portable across makes/models. + +Typically, 3d models are sliced by external slicer programs, and [profiles](printer-profiles.md) are assigned in the queue so it only runs on compatible printers. This is especially important for heterogeneous [LAN queues](lan-queues.md). + +With automatic slicing, **you can add 3D models directly to the queue for printing**. This eliminates some manual effort and sources of error. + + +## Setup + +OctoPrint supports integration with slicers via the [`SlicerPlugin` mixin](https://docs.octoprint.org/en/master/plugins/mixins.html#slicerplugin) - this mixin is inherited by various plugins to allow slicing models in the OctoPrint file manager by clicking a "slice" button next to the file. + +Any plugin that uses this mixin should enable automated slicing, but for the sake of awesomeness we will use [PrePrintService](https://github.com/christophschranz/OctoPrint-PrePrintService) which can also automatically orient your model before slicing it to maximize the likelihood of a successful print. + +!!! Warning + + Just because the file automatically slices, doesn't mean it'll slice *correctly*. + + PrePrintService improves the odds with automatic orientation, but this will only work as correctly as it's configured, and may not work at all if your printer is non-cartesian (e.g. a belt printer). + +### Requirements + +You will need: + +* A machine with [Docker](https://www.docker.com/) installed and running - this may be the same as the OctoPrint server, or a different one on the same network. +* Some form of [git](https://git-scm.com/) tool to download the forked PrePrintService repository + +### Install PrePrintService + +First, we'll set up the slicer server. On your OctoPrint machine or another machine accessible over the network, run the following commands (assuming Linux): + +``` +git clone https://github.com/ChristophSchranz/Octoprint-PrePrintService.git --branch test_and_fix --single-branch +cd Octoprint-PrePrintService +docker-compose up --build -d + +# To follow the logs: +docker-compose logs -f +``` + +Now, we need to install the plugin so OctoPrint can communicate with the slicer. + +1. Navigate to `Settings > Plugin Manager > + Get More` in the OctoPrint interface. +2. Add the following URL into the `... from URL` box. +3. Click the adjacent `Install` button to install the forked PrePrintService plugin, then restart OctoPrint when prompted. +4. Navigate to `Settings > PrePrintService Plugin`. +5. Set the `PrePrintService URL` text input to point to your slicer server, e.g. `http://pre-print-service:2304/tweak`. +6. Uncheck the `Receive auto-rotated model file` setting to prevent the slicer server from pushing intermediate models into the queue. +7. Import a slic3r profile - you can generate one in [Slic3r](https://slic3r.org/) and export it [like this](https://manual.slic3r.org/configuration-organization/configuration-organization#:~:text=If%20you%20want%20to%20store,not%20just%20the%20selected%20profiles). +8. Click `Save` to save your settings, then restart OctoPrint. + +You should be able to click the "magic wand" button next to an STL file in the file manager to slice the file to .gcode - this may take a minute or two if you installed the slicer server on a slow machine (e.g. raspberry pi). + +Finally, we need Continuous Print to know what slicer to use when running STL files: + +1. Navigate to `Settings > Continuous Print` in the OctoPrint interface, then click the `Behavior` tab to show behavior settings. +2. Select `PrePrintService` under `Slicer for auto-slicing`. +3. Select the profile you uploaded earlier under `Slicer Profile`. +4. Click Save. + +After following these instructions, you should have: + +* The service container started and running +* PrePrintService plugin installed and pointing to the service +* The Continuous Print plugin installed (of course!) + +You'll know you have the correct settings when you see in the logs: + +``` +Connection to PrePrintService on <...> is ready +``` + +## Usage + +!!! Warning + + Auto-slicing may make weird decisions about how to orient your print, or even incorrect decisions if your printer is not correctly modeled ([e.g. belt printers are not currently supported in Tweaker](https://github.com/ChristophSchranz/Tweaker-3/issues/24)). + + It's strongly recommended to watch your first few print attempts until you're confident in the setup. + + Also, consider setting up [failure recovery](failure-recovery.md) so failing prints are more likely to be caught automatically. + +With the default slicer configured, it's time to try it out! + +1. Upload an `.stl` file you wish to test out. +1. Click the `+` arrow in the file manager to add it to the queue. +`. Click `Start Managing`, and watch as the STL is detected, sliced into `.gcode`, and printed. + +Note that you can mix `.gcode` and `.stl` files in your queue, and Continuous Print will handle them accordingly. diff --git a/docs/contributing.md b/docs/contributing.md index 6d8133e..f2c90ae 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -186,3 +186,4 @@ This is a collection of random tidbits intended to help you get your bearings. * Applied fix from https://github.com/SortableJS/knockout-sortablejs/pull/13 * Applied fix from https://github.com/SortableJS/knockout-sortablejs/issues/14 * Discussion at https://github.com/smartin015/continuousprint/issues/14 (conflict with a different `knockout-sortable` library) +* Running PrePrintService for development purposes can be done through the usual installation steps, but using `http://pre-print-service:2304` as the host. CPQ's docker-compose.yaml file is already configured to join PrePrintService's docker network. diff --git a/docs/gcode-scripting.md b/docs/gcode-scripting.md index ec7dd52..292df4b 100644 --- a/docs/gcode-scripting.md +++ b/docs/gcode-scripting.md @@ -55,6 +55,48 @@ Now try it out! Whenever your event fires, it should run this new script. You can also run multiple scripts in the same event - they are executed from top to bottom, and you can drag to reorder them. +### Advanced: Skip certain commands in GCODE files + +In `Settings > Continuous Print > Behaviors` there's an `Ignore GCODE lines while printing` text box where you can specify parts of `.gcode` files to ignore. + +This is useful if your gcode slicer includes cleanup code to turn off hotends, heated beds, stepper motors etc. which may be important when printing without Continuous Print, but which aren't useful when printing multiple files in a row. + +Here's an example of the end of a .gcode file sliced with Kiri:Moto: + +``` +; --- shutdown --- +G28 ;(Stick out the part) +M190 S0 ;(Turn off heat bed, don't wait.) +G92 E10 ;(Set extruder to 10) +G1 E7 F200 ;(retract 3mm) +M104 S0 ;(Turn off nozzle, don't wait) +M107 ;(Turn off part fan) +M84 ;(Turn off stepper motors.) +M82 ; absolute extrusion mode +M104 T0 S0 +``` + +In this case, you may want to have the following configuration for `Ignore GCODE lines while printing`: + +``` +M190 S0 +M104 S0 +M104 T0 S0 +M107 ; may also occur at the start, bed adhesion may be affected +M84 +``` + +Be advised that: + +* This setting does not apply to Continuous Print event scripts, only to `.gcode` files being printed. +* Comments are stripped away when comparing lines in the .gcode file to lines in the settings textbox + (e.g. `M84 ; this is a comment` will match `M84 ; a different comment`) +* Commands are not case sensitive (e.g. `m84` will match `M84`) +* Apart from the above behaviors, the commands must match exactly (e.g. `M190` in gcode will not match `M190 S0`) +* Any removed cooling / stepper disabling commands should probably be added to a "Queue Finished" event script, so that the printer is put in a safe state +when all jobs are completed. +* Any changes to the list of ignored commands will take effect after a restart of OctoPrint. + ### Optional: Use BedReady to check bed state [OctoPrint-BedReady](https://plugins.octoprint.org/plugins/bedready/) is a plugin that checks the webcam image of the bed against a refrence image where the bed is clear. @@ -67,8 +109,15 @@ You may discover that you want more complex behavior than just running the same This can be done by adding a **Preprocessor**, which is a little bit of extra code that modifies how your GCODE script is executed. +### The Basics + Preprocessors are optionally added to assigned scripts in the `Events` settings tab. They evaluate based on instantaneous state details, print file metadata, and optional externally provided state. +* An Event can have multiple Scripts; each Script executes in-order when the Event fires. +* Each Script can have either zero or one Preprocessor assigned. + * If it does not have a Preprocessor, it is run as-is. + * If it does have a Preprocessor, the Preprocessor is run first and its output is applied to that Script before that script is executed. + ### Language Preprocessors are evaluated using [ASTEVAL](https://newville.github.io/asteval/) which is a [Python](https://www.python.org/)-like interpreter. Most simple Python scripts will run just fine. diff --git a/docs/managed-bed-cooldown.md b/docs/managed-bed-cooldown.md index a4e5a66..c43f62e 100644 --- a/docs/managed-bed-cooldown.md +++ b/docs/managed-bed-cooldown.md @@ -1,16 +1,18 @@ # Managed Bed Cooldown + ## Use Case + Depending on your printer model the g-code instruction M190 (Wait for Bed Temperature) is not always respected when the targed temperature is cooling down. For printers that don't respect the M190 cooldown instruction but depend on the bed cooling to a specified temperature this feature should be enabled. ## Configure feature + This feature can be configured in the Continuous Print settings panel under `Bed Cooldown Settings`. **Enable Managed Bed Cooldown Box** enables and disables the feature. - **Bed Cooldown Script** is the G-Code script that will run once print in queue is finished, but before bed cooldown is run. Useful for triggering events via g-code like activating part cooling fan, or moving print head from above part while it cools. **Bed Cooldown Threshold** is the temperature in Celsius that once met triggers the bed to clear. @@ -18,7 +20,6 @@ The goal is to pick a temperature at which the part becomes free from the bed. E **Bed Cooldown Timeout** a timeout in minutes starting from after the bed clear script has run when once exceeded bed will be cleared regardless of bed temperature. Useful for cases where the target bed temperature is not being met, but the part is ready to be cleared anyway. Useful for cases where the part cools faster than the bed, or external environment is too hot so bed is not meeting temperature, but part has already cooled enough. - Once configured the final event flow will look like this `PRINT FINISHES -> Bed Cooldown Script Runs -> Bed is turned off -> Wait until measured temp meets threshold OR timeout is exceeded -> Bed Clearing Script Runs -> NEXT PRINT BEGINS` diff --git a/mkdocs.yml b/mkdocs.yml index 0d07a07..ffcd0fc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -32,6 +32,7 @@ nav: - 'material-selection.md' - 'printer-profiles.md' - 'managed-bed-cooldown.md' + - 'auto-slicer.md' - 'action-commands.md' - 'contributing.md' - 'api.md' diff --git a/setup.py b/setup.py index fba0ec8..33c6b7c 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ plugin_name = "continuousprint" # The plugin's version. Can be overwritten within OctoPrint's internal data via __plugin_version__ in the plugin module -plugin_version = "2.3.0" +plugin_version = "2.4.0rc1" # The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin # module