From a066c9eb695abea6dd0b3ed6c6caab503ee6c3fd Mon Sep 17 00:00:00 2001 From: Scott Martin Date: Thu, 29 Dec 2022 09:23:41 -0500 Subject: [PATCH 01/17] Suppress core OctoPrint GCODE scripts when running CPQ scripts of our own --- continuousprint/__init__.py | 1 + continuousprint/driver.py | 24 +++++++++++++ continuousprint/plugin.py | 25 ++++++++++++- continuousprint/plugin_test.py | 64 +++++++++++++++++++++++----------- 4 files changed, 93 insertions(+), 21 deletions(-) diff --git a/continuousprint/__init__.py b/continuousprint/__init__.py index 1932494..fcd8eca 100644 --- a/continuousprint/__init__.py +++ b/continuousprint/__init__.py @@ -64,6 +64,7 @@ def on_startup(self, host=None, port=None): ) def on_after_startup(self): + self._plugin.patchComms() self._plugin.start() # It's possible to miss events or for some weirdness to occur in conditionals. Adding a watchdog diff --git a/continuousprint/driver.py b/continuousprint/driver.py index cc3d1c9..c692e56 100644 --- a/continuousprint/driver.py +++ b/continuousprint/driver.py @@ -25,6 +25,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): @@ -161,6 +176,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 +194,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): @@ -213,6 +230,7 @@ def _materials_match(self, item): return False return True + @blockCoreEventScripts def _state_awaiting_material(self, a: Action, p: Printer): item = self.q.get_set_or_acquire() if item is None: @@ -313,6 +331,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 +378,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 +395,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 +413,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 +426,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 +435,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/plugin.py b/continuousprint/plugin.py index 1c98ec7..418794e 100644 --- a/continuousprint/plugin.py +++ b/continuousprint/plugin.py @@ -16,7 +16,7 @@ from peerprint.filesharing import Fileshare from .analysis import CPQProfileAnalysisQueue -from .driver import Driver, Action as DA, Printer as DP +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 @@ -811,3 +811,26 @@ 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 + self._sendGcodeScriptOrig = self._printer._comm.sendGcodeScript + self._printer._comm.sendGcodeScript = self.gatedSendGcodeScript + self._logger.info("Patched sendGCodeScript") + + 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: + self._sendGcodeScriptOrig(*args, **kwargs) diff --git a/continuousprint/plugin_test.py b/continuousprint/plugin_test.py index abea5f1..5268afc 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,7 +37,7 @@ def global_set(self, gk, v): return self.set([":".join(gk)], v) -def mockplugin(): +def setupPlugin(): return CPQPlugin( printer=MagicMock(), settings=MockSettings(), @@ -54,7 +54,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 +63,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 +75,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 +84,39 @@ 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 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 +127,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 +153,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 +163,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 +178,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 +190,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,7 +202,7 @@ 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() @@ -364,7 +388,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 +439,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 +491,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 +586,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 +612,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): From 04b8f6822343ff62e88cf7eeeb1cdac908da5a9f Mon Sep 17 00:00:00 2001 From: Scott Martin Date: Thu, 29 Dec 2022 10:06:13 -0500 Subject: [PATCH 02/17] Also protect the patchComms() function from exceptions e.g. due to OctoPrint refactors --- continuousprint/plugin.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/continuousprint/plugin.py b/continuousprint/plugin.py index 418794e..f353c4f 100644 --- a/continuousprint/plugin.py +++ b/continuousprint/plugin.py @@ -815,9 +815,12 @@ def _commit_queues(self, added, removed): def patchComms(self): # Patch the comms interface to allow for suppressing GCODE script events when the # qeue is running script events - self._sendGcodeScriptOrig = self._printer._comm.sendGcodeScript - self._printer._comm.sendGcodeScript = self.gatedSendGcodeScript - self._logger.info("Patched sendGCodeScript") + 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* From 034efe5026a4c225071552f4e7ae3451d87e4122 Mon Sep 17 00:00:00 2001 From: Scott Martin Date: Thu, 29 Dec 2022 10:11:48 -0500 Subject: [PATCH 03/17] Also return original script values in passthru --- continuousprint/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/continuousprint/plugin.py b/continuousprint/plugin.py index f353c4f..b083e43 100644 --- a/continuousprint/plugin.py +++ b/continuousprint/plugin.py @@ -836,4 +836,4 @@ def gatedSendGcodeScript(self, *args, **kwargs): self._logger.error(traceback.format_exc()) finally: if shouldCall: - self._sendGcodeScriptOrig(*args, **kwargs) + return self._sendGcodeScriptOrig(*args, **kwargs) From 6b17766b6fa7941a26b273e2e7992e8bbf07f530 Mon Sep 17 00:00:00 2001 From: Scott Martin Date: Thu, 29 Dec 2022 11:55:56 -0500 Subject: [PATCH 04/17] Configurable GCODE override in print files --- continuousprint/__init__.py | 1 + continuousprint/data/__init__.py | 1 + continuousprint/plugin.py | 51 +++++++++++++++++++ continuousprint/plugin_test.py | 41 +++++++++++++++ .../templates/continuousprint_settings.jinja2 | 6 +++ docs/gcode-scripting.md | 42 +++++++++++++++ 6 files changed, 142 insertions(+) diff --git a/continuousprint/__init__.py b/continuousprint/__init__.py index 1932494..801556c 100644 --- a/continuousprint/__init__.py +++ b/continuousprint/__init__.py @@ -64,6 +64,7 @@ def on_startup(self, host=None, port=None): ) def on_after_startup(self): + self._plugin.patchCommJobReader() self._plugin.start() # It's possible to miss events or for some weirdness to occur in conditionals. Adding a watchdog diff --git a/continuousprint/data/__init__.py b/continuousprint/data/__init__.py index 99d9cf2..c768535 100644 --- a/continuousprint/data/__init__.py +++ b/continuousprint/data/__init__.py @@ -97,6 +97,7 @@ 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", "") def __init__(self, setting, default): self.setting = setting diff --git a/continuousprint/plugin.py b/continuousprint/plugin.py index 1c98ec7..75f0d6a 100644 --- a/continuousprint/plugin.py +++ b/continuousprint/plugin.py @@ -293,6 +293,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( diff --git a/continuousprint/plugin_test.py b/continuousprint/plugin_test.py index abea5f1..471340d 100644 --- a/continuousprint/plugin_test.py +++ b/continuousprint/plugin_test.py @@ -84,6 +84,47 @@ 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 = mockplugin() + 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 testDBNew(self): p = mockplugin() with tempfile.TemporaryDirectory() as td: diff --git a/continuousprint/templates/continuousprint_settings.jinja2 b/continuousprint/templates/continuousprint_settings.jinja2 index d04401a..a7dbb93 100644 --- a/continuousprint/templates/continuousprint_settings.jinja2 +++ b/continuousprint/templates/continuousprint_settings.jinja2 @@ -385,6 +385,12 @@ +
+ +
+ +
+
Bed Cooldown Settings diff --git a/docs/gcode-scripting.md b/docs/gcode-scripting.md index ec7dd52..ee46a18 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. From 9934f8dca5a15ae68d526ab2eb65c942f8fe1fa4 Mon Sep 17 00:00:00 2001 From: Scott Martin Date: Sun, 15 Jan 2023 16:10:18 -0500 Subject: [PATCH 05/17] Properly order sets by rank --- continuousprint/storage/database.py | 2 +- continuousprint/storage/database_test.py | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/continuousprint/storage/database.py b/continuousprint/storage/database.py index 956c37d..4a215ce 100644 --- a/continuousprint/storage/database.py +++ b/continuousprint/storage/database.py @@ -132,7 +132,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) diff --git a/continuousprint/storage/database_test.py b/continuousprint/storage/database_test.py index 1f750c6..2681ab5 100644 --- a/continuousprint/storage/database_test.py +++ b/continuousprint/storage/database_test.py @@ -292,13 +292,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 +307,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): From 1ae01474897ecdcb3fe520b4ee2c90e6c1b88005 Mon Sep 17 00:00:00 2001 From: Scott Martin Date: Tue, 17 Jan 2023 11:00:34 -0500 Subject: [PATCH 06/17] Preprocessor simulator and preview (#194) * Partial commit - extended CustomEvents to include sim stuff, added sim api endpoint, split out settings JS into a separate _event.js file * Fixed simulation execution events, added styling, plus modifiable symtable and display of mutations * Pluralize simulation summary, separate asteval code into automation.py from storage/queries.py, set up tests * Fix tests * Add tests for CPSettingsEvent * Make simulator UI more friendly/helpful and well formatted * Remove todo lines --- continuousprint/__init__.py | 2 + continuousprint/api.py | 26 +++ continuousprint/api_test.py | 36 +++- continuousprint/automation.py | 45 +++++ continuousprint/automation_test.py | 37 ++++ continuousprint/data/__init__.py | 66 ++++++- continuousprint/script_runner.py | 21 +-- .../static/css/continuousprint.css | 26 ++- .../static/js/continuousprint_api.js | 3 + .../static/js/continuousprint_settings.js | 25 +-- .../js/continuousprint_settings.test.js | 1 + .../js/continuousprint_settings_event.js | 176 ++++++++++++++++++ .../js/continuousprint_settings_event.test.js | 120 ++++++++++++ continuousprint/storage/queries.py | 55 ++---- continuousprint/storage/queries_test.py | 61 +----- .../templates/continuousprint_settings.jinja2 | 43 +++++ 16 files changed, 608 insertions(+), 135 deletions(-) create mode 100644 continuousprint/automation.py create mode 100644 continuousprint/automation_test.py create mode 100644 continuousprint/static/js/continuousprint_settings_event.js create mode 100644 continuousprint/static/js/continuousprint_settings_event.test.js diff --git a/continuousprint/__init__.py b/continuousprint/__init__.py index 88fd45f..b17b7ff 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 @@ -110,6 +111,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..1e9946e 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) + print(result) + return json.dumps(result) diff --git a/continuousprint/api_test.py b/continuousprint/api_test.py index a7c37a7..613fd82 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 @@ -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..ebb2ec3 --- /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}\nSymbols: {interp.symtable}\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 c768535..f042e5d 100644 --- a/continuousprint/data/__init__.py +++ b/continuousprint/data/__init__.py @@ -17,55 +17,116 @@ ) +# 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): @@ -119,6 +180,7 @@ def __init__(self, setting, default): "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/script_runner.py b/continuousprint/script_runner.py index 73e4f2f..70f7d6f 100644 --- a/continuousprint/script_runner.py +++ b/continuousprint/script_runner.py @@ -1,7 +1,5 @@ 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 @@ -9,7 +7,8 @@ from octoprint.server import current_user from .storage.lan import ResolveError from .data import TEMP_FILE_DIR, CustomEvents -from .storage.queries import genEventScript +from .storage.queries import getAutomationForEvent +from .automation import genEventScript, getInterpreter class ScriptRunner: @@ -94,18 +93,10 @@ 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 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()) diff --git a/continuousprint/static/css/continuousprint.css b/continuousprint/static/css/continuousprint.css index 70e241d..f4d3f09 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,7 @@ font-weight: italic; opacity: 0.7; } -#tab_plugin_continuousprint .hint { +#tab_plugin_continuousprint, #settings_plugin_continuousprint .hint { font-weight: italic; font-size: 85%; opacity: 0.7; 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_settings.js b/continuousprint/static/js/continuousprint_settings.js index 0bda78b..f386ebf 100644 --- a/continuousprint/static/js/continuousprint_settings.js +++ b/continuousprint/static/js/continuousprint_settings.js @@ -9,9 +9,11 @@ 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'); } -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) { var self = this; self.PLUGIN_ID = "octoprint.plugins.continuousprint"; self.log = log.getLogger(self.PLUGIN_ID); @@ -343,10 +345,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); @@ -375,19 +374,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..0d2f11b 100644 --- a/continuousprint/static/js/continuousprint_settings.test.js +++ b/continuousprint/static/js/continuousprint_settings.test.js @@ -61,6 +61,7 @@ function mocks() { init: jest.fn(), get: jest.fn((_, cb) => cb([])), edit: jest.fn(), + simulate: jest.fn(), }, ]; } 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/storage/queries.py b/continuousprint/storage/queries.py index cdacd59..634ee31 100644 --- a/continuousprint/storage/queries.py +++ b/continuousprint/storage/queries.py @@ -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..1d61efb 100644 --- a/continuousprint/storage/queries_test.py +++ b/continuousprint/storage/queries_test.py @@ -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 a7dbb93..98dfb67 100644 --- a/continuousprint/templates/continuousprint_settings.jinja2 +++ b/continuousprint/templates/continuousprint_settings.jinja2 @@ -9,6 +9,7 @@ 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}}; };