diff --git a/continuousprint/__init__.py b/continuousprint/__init__.py index 03b63f8..ac4a58f 100644 --- a/continuousprint/__init__.py +++ b/continuousprint/__init__.py @@ -99,6 +99,7 @@ def get_template_vars(self): return dict( printer_profiles=list(PRINTER_PROFILES.values()), gcode_scripts=list(GCODE_SCRIPTS.values()), + custom_events=[e.as_dict() for e in CustomEvents], local_ip=self._plugin.get_local_ip(), ) diff --git a/continuousprint/api.py b/continuousprint/api.py index b67f1d7..4173568 100644 --- a/continuousprint/api.py +++ b/continuousprint/api.py @@ -12,6 +12,11 @@ class Permission(Enum): + GETSTATE = ( + "Get state", + "Allows for fetching queue and management state of Continuous Print", + False, + ) STARTSTOP = ( "Start and Stop Queue", "Allows for starting and stopping the queue", @@ -31,17 +36,31 @@ class Permission(Enum): "Allows for fetching history of print runs by Continuous Print", False, ) - CLEARHISTORY = ( - "Clear history", + RESETHISTORY = ( + "Reset history", "Allows for deleting all continuous print history data", True, ) - GETQUEUES = ("Get queue", "Allows for fetching metadata on all print queues", False) + GETQUEUES = ( + "Get queues", + "Allows for fetching metadata on all print queues", + False, + ) EDITQUEUES = ( "Edit queues", "Allows for adding/removing queues and rearranging them", True, ) + GETAUTOMATION = ( + "Get automation scripts and events", + "Allows for fetching metadata on all scripts and the events they're configured for", + False, + ) + EDITAUTOMATION = ( + "Edit automation scripts and events", + "Allows for adding/removing gcode scripts and registering them to execute when events happen", + True, + ) def __init__(self, longname, desc, dangerous): self.longname = longname @@ -133,6 +152,7 @@ def _sync_history(self): # (e.g. 1.4.1 -> 2.0.0) @octoprint.plugin.BlueprintPlugin.route("/state/get", methods=["GET"]) @restricted_access + @cpq_permission(Permission.GETSTATE) def get_state(self): return self._state_json() @@ -248,7 +268,7 @@ def export_job(self): # PRIVATE API METHOD - may change without warning. @octoprint.plugin.BlueprintPlugin.route("/job/rm", methods=["POST"]) @restricted_access - @cpq_permission(Permission.EDITJOB) + @cpq_permission(Permission.RMJOB) def rm_job(self): return json.dumps( self._get_queue(flask.request.form["queue"]).remove_jobs( @@ -277,7 +297,7 @@ def get_history(self): # PRIVATE API METHOD - may change without warning. @octoprint.plugin.BlueprintPlugin.route("/history/reset", methods=["POST"]) @restricted_access - @cpq_permission(Permission.CLEARHISTORY) + @cpq_permission(Permission.RESETHISTORY) def reset_history(self): queries.resetHistory() return json.dumps("OK") @@ -298,3 +318,19 @@ def edit_queues(self): (absent_names, added) = queries.assignQueues(queues) self._commit_queues(added, absent_names) return json.dumps("OK") + + # PRIVATE API METHOD - may change without warning. + @octoprint.plugin.BlueprintPlugin.route("/automation/edit", methods=["POST"]) + @restricted_access + @cpq_permission(Permission.EDITAUTOMATION) + def edit_automation(self): + data = json.loads(flask.request.form.get("json")) + queries.assignScriptsAndEvents(data["scripts"], data["events"]) + return json.dumps("OK") + + # PRIVATE API METHOD - may change without warning. + @octoprint.plugin.BlueprintPlugin.route("/automation/get", methods=["GET"]) + @restricted_access + @cpq_permission(Permission.GETAUTOMATION) + def get_automation(self): + return json.dumps(queries.getScriptsAndEvents()) diff --git a/continuousprint/api_test.py b/continuousprint/api_test.py index c0a01ce..870be5f 100644 --- a/continuousprint/api_test.py +++ b/continuousprint/api_test.py @@ -1,6 +1,8 @@ import unittest +import json +import logging from .driver import Action as DA -from unittest.mock import patch, MagicMock +from unittest.mock import patch, MagicMock, call import imp from flask import Flask from .api import Permission, cpq_permission @@ -60,12 +62,57 @@ def kill_patches(): self.api = continuousprint.api.ContinuousPrintAPI() self.api._basefolder = "notexisty" self.api._identifier = "continuousprint" + self.api._get_queue = MagicMock() + self.api._logger = logging.getLogger() self.app.register_blueprint(self.api.get_blueprint()) self.app.config.update({"TESTING": True}) self.client = self.app.test_client() - self.api._state_json = lambda: "foo" + + def test_role_access_denied(self): + testcases = [ + ("GETSTATE", "/state/get"), + ("STARTSTOP", "/set_active"), + ("ADDSET", "/set/add"), + ("ADDJOB", "/job/add"), + ("EDITJOB", "/job/mv"), + ("EDITJOB", "/job/edit"), + ("ADDJOB", "/job/import"), + ("EXPORTJOB", "/job/export"), + ("RMJOB", "/job/rm"), + ("EDITJOB", "/job/reset"), + ("GETHISTORY", "/history/get"), + ("RESETHISTORY", "/history/reset"), + ("GETQUEUES", "/queues/get"), + ("EDITQUEUES", "/queues/edit"), + ("GETAUTOMATION", "/automation/get"), + ("EDITAUTOMATION", "/automation/edit"), + ] + self.api._get_queue = None # MagicMock interferes with checking + + num_handlers_tested = len(set([tc[1] for tc in testcases])) + handlers = [ + f + for f in dir(self.api) + if hasattr(getattr(self.api, f), "_blueprint_rules") + ] + self.assertEqual(num_handlers_tested, len(handlers)) + + num_perms_tested = len(set([tc[0] for tc in testcases])) + num_perms = len([p for p in Permission]) + self.assertEqual(num_perms_tested, num_perms) + + for (role, endpoint) in testcases: + p = getattr(self.perm, f"PLUGIN_CONTINUOUSPRINT_{role}") + p.can.return_value = False + if role.startswith("GET"): + rep = self.client.get(endpoint) + else: + rep = self.client.post(endpoint) + self.assertEqual(rep.status_code, 403) def test_get_state(self): + self.perm.PLUGIN_CONTINUOUSPRINT_GETSTATE.can.return_value = True + self.api._state_json = lambda: "foo" rep = self.client.get("/state/get") self.assertEqual(rep.status_code, 200) self.assertEqual(rep.data, b"foo") @@ -73,6 +120,7 @@ def test_get_state(self): def test_set_active(self): self.perm.PLUGIN_CONTINUOUSPRINT_STARTSTOP.can.return_value = True self.api._update = MagicMock() + self.api._state_json = lambda: "foo" rep = self.client.post("/set_active", data=dict(active="true")) self.assertEqual(rep.status_code, 200) self.api._update.assert_called_with(DA.ACTIVATE) @@ -93,37 +141,146 @@ def test_set_active(self): self.api._update.assert_called_with(DA.DEACTIVATE) def test_add_set(self): - self.skipTest("TODO") + self.perm.PLUGIN_CONTINUOUSPRINT_ADDSET.can.return_value = True + data = dict(foo="bar", job="jid") + self.api._get_queue().add_set.return_value = "ret" + self.api._preprocess_set = lambda s: s + + rep = self.client.post("/set/add", data=dict(json=json.dumps(data))) + + self.assertEqual(rep.status_code, 200) + self.assertEqual(rep.get_data(as_text=True), '"ret"') + self.api._get_queue().add_set.assert_called_with("jid", data) def test_add_job(self): - self.skipTest("TODO") + self.perm.PLUGIN_CONTINUOUSPRINT_ADDJOB.can.return_value = True + data = dict(name="jobname") + self.api._get_queue().add_job().as_dict.return_value = "ret" + + rep = self.client.post("/job/add", data=dict(json=json.dumps(data))) + + self.assertEqual(rep.status_code, 200) + self.assertEqual(rep.get_data(as_text=True), '"ret"') + self.api._get_queue().add_job.assert_called_with("jobname") def test_mv_job(self): - self.skipTest("TODO") + self.perm.PLUGIN_CONTINUOUSPRINT_EDITJOB.can.return_value = True + data = dict(id="foo", after_id="bar", src_queue="q1", dest_queue="q2") + + rep = self.client.post("/job/mv", data=data) + + self.assertEqual(rep.status_code, 200) + self.api._get_queue().mv_job.assert_called_with(data["id"], data["after_id"]) def test_edit_job(self): - self.skipTest("TODO") + self.perm.PLUGIN_CONTINUOUSPRINT_EDITJOB.can.return_value = True + data = dict(id="foo", queue="queue") + self.api._get_queue().edit_job.return_value = "ret" + rep = self.client.post("/job/edit", data=dict(json=json.dumps(data))) + + self.assertEqual(rep.status_code, 200) + self.assertEqual(rep.get_data(as_text=True), '"ret"') + self.api._get_queue().edit_job.assert_called_with(data["id"], data) def test_import_job(self): - self.skipTest("TODO") + self.perm.PLUGIN_CONTINUOUSPRINT_ADDJOB.can.return_value = True + data = dict(path="path", queue="queue") + self.api._get_queue().import_job().as_dict.return_value = "ret" + rep = self.client.post("/job/import", data=data) + + self.assertEqual(rep.status_code, 200) + self.assertEqual(rep.get_data(as_text=True), '"ret"') + self.api._get_queue().import_job.assert_called_with(data["path"]) def test_export_job(self): - self.skipTest("TODO") + self.perm.PLUGIN_CONTINUOUSPRINT_EXPORTJOB.can.return_value = True + data = {"job_ids[]": ["1", "2", "3"]} + self.api._get_queue().export_job.return_value = "ret" + self.api._path_in_storage = lambda p: p + self.api._path_on_disk = lambda p, sd: p + rep = self.client.post("/job/export", data=data) + + self.assertEqual(rep.status_code, 200) + self.assertEqual( + json.loads(rep.get_data(as_text=True)), + dict(errors=[], paths=["ret", "ret", "ret"]), + ) + self.api._get_queue().export_job.assert_has_calls( + [call(int(i), "/") for i in data["job_ids[]"]] + ) def test_rm_job(self): - self.skipTest("TODO") + self.perm.PLUGIN_CONTINUOUSPRINT_RMJOB.can.return_value = True + data = {"queue": "q", "job_ids[]": ["1", "2", "3"]} + self.api._get_queue().remove_jobs.return_value = "ret" + + rep = self.client.post("/job/rm", data=data) + + self.assertEqual(rep.status_code, 200) + self.assertEqual(rep.get_data(as_text=True), '"ret"') + self.api._get_queue().remove_jobs.assert_called_with(data["job_ids[]"]) def test_reset_multi(self): - self.skipTest("TODO") + self.perm.PLUGIN_CONTINUOUSPRINT_EDITJOB.can.return_value = True + data = {"queue": "q", "job_ids[]": ["1", "2", "3"]} + self.api._get_queue().reset_jobs.return_value = "ret" + + rep = self.client.post("/job/reset", data=data) + + self.assertEqual(rep.status_code, 200) + self.assertEqual(rep.get_data(as_text=True), '"ret"') + self.api._get_queue().reset_jobs.assert_called_with(data["job_ids[]"]) def test_get_history(self): - self.skipTest("TODO") + self.perm.PLUGIN_CONTINUOUSPRINT_GETHISTORY.can.return_value = True + self.api._history_json = lambda: "foo" + rep = self.client.get("/history/get") + self.assertEqual(rep.status_code, 200) + self.assertEqual(rep.data, b"foo") - def test_reset_history(self): - self.skipTest("TODO") + @patch("continuousprint.api.queries") + def test_reset_history(self, q): + self.perm.PLUGIN_CONTINUOUSPRINT_RESETHISTORY.can.return_value = True + rep = self.client.post("/history/reset") + q.resetHistory.assert_called_once() + self.assertEqual(rep.status_code, 200) + self.assertEqual(rep.data, b'"OK"') - def test_get_queues(self): - self.skipTest("TODO") + @patch("continuousprint.api.queries") + def test_get_queues(self, q): + self.perm.PLUGIN_CONTINUOUSPRINT_GETQUEUES.can.return_value = True + mq = MagicMock() + mq.as_dict.return_value = dict(foo="bar") + q.getQueues.return_value = [mq] + rep = self.client.get("/queues/get") + self.assertEqual(rep.status_code, 200) + self.assertEqual(json.loads(rep.get_data(as_text=True)), [dict(foo="bar")]) - def edit_queues(self): - self.skipTest("TODO") + @patch("continuousprint.api.queries") + def test_edit_queues(self, q): + self.perm.PLUGIN_CONTINUOUSPRINT_EDITQUEUES.can.return_value = True + q.assignQueues.return_value = ("absent", "added") + self.api._commit_queues = MagicMock() + rep = self.client.post("/queues/edit", data=dict(json='"foo"')) + self.assertEqual(rep.status_code, 200) + self.assertEqual(rep.data, b'"OK"') + self.api._commit_queues.assert_called_with("added", "absent") + + @patch("continuousprint.api.queries") + def test_edit_automation(self, q): + self.perm.PLUGIN_CONTINUOUSPRINT_EDITAUTOMATION.can.return_value = True + rep = self.client.post( + "/automation/edit", + data=dict(json=json.dumps(dict(scripts="scripts", events="events"))), + ) + self.assertEqual(rep.status_code, 200) + self.assertEqual(rep.data, b'"OK"') + q.assignScriptsAndEvents.assert_called_with("scripts", "events") + + @patch("continuousprint.api.queries") + def test_get_automation(self, q): + self.perm.PLUGIN_CONTINUOUSPRINT_GETHISTORY.can.return_value = True + q.getScriptsAndEvents.return_value = "foo" + rep = self.client.get("/automation/get") + self.assertEqual(rep.status_code, 200) + self.assertEqual(rep.data, b'"foo"') diff --git a/continuousprint/data/__init__.py b/continuousprint/data/__init__.py index b468e11..207baad 100644 --- a/continuousprint/data/__init__.py +++ b/continuousprint/data/__init__.py @@ -13,27 +13,72 @@ class CustomEvents(Enum): - START_PRINT = "continuousprint_start_print" - COOLDOWN = "continuousprint_cooldown" - CLEAR_BED = "continuousprint_clear_bed" - FINISH = "continuousprint_finish" - CANCEL = "continuousprint_cancel" + ACTIVE = ( + "continuousprint_active", + "Queue Active", + "TODO Fires when the queue is started, e.g. via the 'Start Managing' button.", + ) + 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.", + ) + 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.", + ) + PRINT_CANCEL = ( + "continuousprint_cancel", + "Print Cancel", + "Fires when automation or the user has cancelled the active print.", + ) + COOLDOWN = ( + "continuousprint_cooldown", + "Bed Cooldown", + "Fires when managed bed cooldown is starting.", + ) + FINISH = ( + "continuousprint_finish", + "Queue Finished", + "Fires when there is no work left to do and the plugin goes idle.", + ) + AWAITING_MATERIAL = ( + "continuousprint_awaiting_material", + "Awaiting Material", + "TODO Fires when the current job requires a different material than what is currently loaded.", + ) + INACTIVE = ( + "continuousprint_inactive", + "Queue Inactive", + "TODO Fires when the queue is no longer actively managed.", + ) + + def __init__(self, event, displayName, desc): + self.event = event + self.displayName = displayName + self.desc = desc + + def as_dict(self): + return dict(event=self.event, display=self.displayName, desc=self.desc) class Keys(Enum): + + BED_COOLDOWN_SCRIPT_DEPRECATED = ( + "cp_bed_cooldown_script", + "; Put script to run before bed cools here\n", + ) + FINISHED_SCRIPT_DEPRECATED = ("cp_queue_finished_script", "Generic Off") + CLEARING_SCRIPT_DEPRECATED = ("cp_bed_clearing_script", "Pause") + QUEUE_DEPRECATED = ("cp_queue", None) + # TODO migrate old setting names to enum names - QUEUE = ("cp_queue", None) PRINTER_PROFILE = ("cp_printer_profile", "Generic") - CLEARING_SCRIPT = ("cp_bed_clearing_script", "Pause") - FINISHED_SCRIPT = ("cp_queue_finished_script", "Generic Off") RESTART_MAX_RETRIES = ("cp_restart_on_pause_max_restarts", 3) RESTART_ON_PAUSE = ("cp_restart_on_pause_enabled", False) RESTART_MAX_TIME = ("cp_restart_on_pause_max_seconds", 60 * 60) BED_COOLDOWN_ENABLED = ("bed_cooldown_enabled", False) - BED_COOLDOWN_SCRIPT = ( - "cp_bed_cooldown_script", - "; Put script to run before bed cools here\n", - ) BED_COOLDOWN_THRESHOLD = ("bed_cooldown_threshold", 30) BED_COOLDOWN_TIMEOUT = ("bed_cooldown_timeout", 60) MATERIAL_SELECTION = ("cp_material_selection_enabled", False) @@ -58,13 +103,7 @@ def __init__(self, setting, default): PRINT_FILE_DIR = "ContinuousPrint" -TEMP_FILES = dict( - [ - (k.setting, f"{PRINT_FILE_DIR}/{k.setting}.gcode") - for k in [Keys.FINISHED_SCRIPT, Keys.CLEARING_SCRIPT, Keys.BED_COOLDOWN_SCRIPT] - ] -) - +TEMP_FILE_DIR = PRINT_FILE_DIR + "/tmp" ASSETS = dict( js=[ "js/cp_modified_sortable.js", diff --git a/continuousprint/driver.py b/continuousprint/driver.py index fa0a0a8..1f30298 100644 --- a/continuousprint/driver.py +++ b/continuousprint/driver.py @@ -1,6 +1,7 @@ import time from multiprocessing import Lock from enum import Enum, auto +from .data import CustomEvents class Action(Enum): @@ -266,7 +267,7 @@ def _state_paused(self, a: Action, p: Printer): def _state_spaghetti_recovery(self, a: Action, p: Printer): self._set_status("Cancelling (spaghetti early in print)", StatusType.ERROR) if p == Printer.PAUSED: - self._runner.cancel_print() + self._runner.run_script_for_event(CustomEvents.PRINT_CANCEL) return self._state_failure def _state_failure(self, a: Action, p: Printer): @@ -305,14 +306,14 @@ def _state_start_clearing(self, a: Action, p: Printer): return if self.managed_cooldown: - self._runner.start_cooldown() + self._runner.run_script_for_event(CustomEvents.COOLDOWN) self.cooldown_start = time.time() self._logger.info( f"Cooldown initiated (threshold={self.cooldown_threshold}, timeout={self.cooldown_timeout})" ) return self._state_cooldown else: - self._runner.clear_bed() + self._runner.run_script_for_event(CustomEvents.PRINT_SUCCESS) return self._state_clearing def _state_cooldown(self, a: Action, p: Printer): @@ -329,7 +330,7 @@ def _state_cooldown(self, a: Action, p: Printer): self._set_status("Cooling down") if clear: - self._runner.clear_bed() + self._runner.run_script_for_event(CustomEvents.PRINT_SUCCESS) return self._state_clearing def _state_clearing(self, a: Action, p: Printer): @@ -349,7 +350,7 @@ def _state_start_finishing(self, a: Action, p: Printer): self._set_status("Waiting for printer to be ready") return - self._runner.run_finish_script() + self._runner.run_script_for_event(CustomEvents.FINISH) return self._state_finishing def _state_finishing(self, a: Action, p: Printer): diff --git a/continuousprint/driver_test.py b/continuousprint/driver_test.py index e40aa9c..cede77d 100644 --- a/continuousprint/driver_test.py +++ b/continuousprint/driver_test.py @@ -3,6 +3,7 @@ import time from unittest.mock import MagicMock, ANY from .driver import Driver, Action as DA, Printer as DP +from .data import CustomEvents import logging import traceback @@ -36,7 +37,7 @@ def test_activate_already_printing(self): def test_events_cause_no_action_when_inactive(self): def assert_nocalls(): - self.d._runner.run_finish_script.assert_not_called() + self.d._runner.run_script_for_event.assert_not_called() self.d._runner.start_print.assert_not_called() for p in [DP.IDLE, DP.BUSY, DP.PAUSED]: @@ -66,10 +67,10 @@ def test_start_clearing_waits_for_idle(self): self.d.state = self.d._state_start_clearing self.d.action(DA.TICK, DP.BUSY) self.assertEqual(self.d.state.__name__, self.d._state_start_clearing.__name__) - self.d._runner.clear_bed.assert_not_called() + self.d._runner.run_script_for_event.assert_not_called() self.d.action(DA.TICK, DP.PAUSED) self.assertEqual(self.d.state.__name__, self.d._state_start_clearing.__name__) - self.d._runner.clear_bed.assert_not_called() + self.d._runner.run_script_for_event.assert_not_called() def test_idle_while_printing(self): self.d.state = self.d._state_printing @@ -115,14 +116,17 @@ def test_bed_clearing_cooldown_threshold(self): self.d.state = self.d._state_start_clearing self.d.action(DA.TICK, DP.IDLE, bed_temp=21) self.assertEqual(self.d.state.__name__, self.d._state_cooldown.__name__) + self.d._runner.run_script_for_event.reset_mock() self.d.action( DA.TICK, DP.IDLE, bed_temp=21 ) # -> stays in cooldown since bed temp too high self.assertEqual(self.d.state.__name__, self.d._state_cooldown.__name__) - self.d._runner.clear_bed.assert_not_called() + self.d._runner.run_script_for_event.assert_not_called() self.d.action(DA.TICK, DP.IDLE, bed_temp=19) # -> exits cooldown self.assertEqual(self.d.state.__name__, self.d._state_clearing.__name__) - self.d._runner.clear_bed.assert_called() + self.d._runner.run_script_for_event.assert_called_with( + CustomEvents.PRINT_SUCCESS + ) def test_bed_clearing_cooldown_timeout(self): self.d.set_managed_cooldown(True, 20, 60) @@ -131,13 +135,17 @@ def test_bed_clearing_cooldown_timeout(self): self.assertEqual(self.d.state.__name__, self.d._state_cooldown.__name__) orig_start = self.d.cooldown_start self.d.cooldown_start = orig_start - 60 * 59 # Still within timeout range + + self.d._runner.run_script_for_event.reset_mock() self.d.action(DA.TICK, DP.IDLE, bed_temp=21) self.assertEqual(self.d.state.__name__, self.d._state_cooldown.__name__) self.d.cooldown_start = orig_start - 60 * 61 - self.d._runner.clear_bed.assert_not_called() + self.d._runner.run_script_for_event.assert_not_called() self.d.action(DA.TICK, DP.IDLE, bed_temp=21) # exit due to timeout self.assertEqual(self.d.state.__name__, self.d._state_clearing.__name__) - self.d._runner.clear_bed.assert_called() + self.d._runner.run_script_for_event.assert_called_with( + CustomEvents.PRINT_SUCCESS + ) def test_finishing_failure(self): self.d.state = self.d._state_finishing @@ -155,7 +163,7 @@ def test_completed_last_print(self): self.d.action(DA.TICK, DP.IDLE) # -> start_finishing self.d.printer_state_ts = time.time() - (Driver.PRINTING_IDLE_BREAKOUT_SEC + 1) self.d.action(DA.TICK, DP.IDLE) # -> finishing - self.d._runner.run_finish_script.assert_called() + self.d._runner.run_script_for_event.assert_called_with(CustomEvents.FINISH) self.assertEqual(self.d.state.__name__, self.d._state_finishing.__name__) self.d.action(DA.TICK, DP.IDLE) # -> idle @@ -191,7 +199,9 @@ def test_success(self): self.d.q.get_set.return_value = item2 self.d.action(DA.TICK, DP.IDLE) # -> clearing - self.d._runner.clear_bed.assert_called_once() + self.d._runner.run_script_for_event.assert_called_with( + CustomEvents.PRINT_SUCCESS + ) self.d.action(DA.SUCCESS, DP.IDLE) # -> start_print -> printing self.d._runner.start_print.assert_called_with(item2) @@ -202,7 +212,9 @@ def test_paused_with_spaghetti_early_triggers_cancel(self): ) self.d.action(DA.SPAGHETTI, DP.BUSY) # -> spaghetti_recovery self.d.action(DA.TICK, DP.PAUSED) # -> cancel + failure - self.d._runner.cancel_print.assert_called() + self.d._runner.run_script_for_event.assert_called_with( + CustomEvents.PRINT_CANCEL + ) self.assertEqual(self.d.state.__name__, self.d._state_failure.__name__) def test_paused_with_spaghetti_late_waits_for_user(self): @@ -212,7 +224,7 @@ def test_paused_with_spaghetti_late_waits_for_user(self): ) self.d.action(DA.SPAGHETTI, DP.BUSY) # -> printing (ignore spaghetti) self.d.action(DA.TICK, DP.PAUSED) # -> paused - self.d._runner.cancel_print.assert_not_called() + self.d._runner.run_script_for_event.assert_not_called() self.assertEqual(self.d.state.__name__, self.d._state_paused.__name__) def test_paused_manually_early_waits_for_user(self): @@ -221,7 +233,7 @@ def test_paused_manually_early_waits_for_user(self): ) self.d.action(DA.TICK, DP.PAUSED) # -> paused self.d.action(DA.TICK, DP.PAUSED) # stay in paused state - self.d._runner.cancel_print.assert_not_called() + self.d._runner.run_script_for_event.assert_not_called() self.assertEqual(self.d.state.__name__, self.d._state_paused.__name__) def test_paused_manually_late_waits_for_user(self): @@ -230,18 +242,18 @@ def test_paused_manually_late_waits_for_user(self): ) self.d.action(DA.TICK, DP.PAUSED) # -> paused self.d.action(DA.TICK, DP.PAUSED) # stay in paused state - self.d._runner.cancel_print.assert_not_called() + self.d._runner.run_script_for_event.assert_not_called() self.assertEqual(self.d.state.__name__, self.d._state_paused.__name__) def test_paused_on_temp_file_falls_through(self): self.d.state = self.d._state_clearing # -> clearing self.d.action(DA.TICK, DP.PAUSED) - self.d._runner.cancel_print.assert_not_called() + self.d._runner.run_script_for_event.assert_not_called() self.assertEqual(self.d.state.__name__, self.d._state_clearing.__name__) self.d.state = self.d._state_finishing # -> finishing self.d.action(DA.TICK, DP.PAUSED) - self.d._runner.cancel_print.assert_not_called() + self.d._runner.run_script_for_event.assert_not_called() self.assertEqual(self.d.state.__name__, self.d._state_finishing.__name__) def test_user_deactivate_sets_inactive(self): diff --git a/continuousprint/integration_test.py b/continuousprint/integration_test.py index f9f9613..1b2d15f 100644 --- a/continuousprint/integration_test.py +++ b/continuousprint/integration_test.py @@ -6,12 +6,13 @@ import logging import traceback from .storage.database_test import DBTest -from .storage.database import DEFAULT_QUEUE, MODELS, populate as populate_db +from .storage.database import DEFAULT_QUEUE, MODELS, populate_queues from .storage import queries from .queues.multi import MultiQueue from .queues.local import LocalQueue from .queues.lan import LANQueue from .queues.abstract import Strategy +from .data import CustomEvents from peewee import SqliteDatabase from collections import defaultdict from peerprint.lan_queue import LANPrintQueueBase @@ -60,8 +61,10 @@ def assert_from_printing_state(self, want_path, finishing=False): self.d.state.__name__, self.d._state_start_clearing.__name__ ) self.d.action(DA.TICK, DP.IDLE) # -> clearing - self.d._runner.clear_bed.assert_called() - self.d._runner.clear_bed.reset_mock() + self.d._runner.run_script_for_event.assert_called_with( + CustomEvents.PRINT_SUCCESS + ) + self.d._runner.run_script_for_event.reset_mock() self.d.action(DA.SUCCESS, DP.IDLE) # -> start_print else: # Finishing self.d.action(DA.TICK, DP.IDLE) # -> start_finishing @@ -69,8 +72,10 @@ def assert_from_printing_state(self, want_path, finishing=False): self.d.state.__name__, self.d._state_start_finishing.__name__ ) self.d.action(DA.TICK, DP.IDLE) # -> finishing - self.d._runner.run_finish_script.assert_called() - self.d._runner.run_finish_script.reset_mock() + self.d._runner.run_script_for_event.assert_called_with( + CustomEvents.FINISH + ) + self.d._runner.run_script_for_event.reset_mock() self.d.action(DA.SUCCESS, DP.IDLE) # -> inactive self.assertEqual(self.d.state.__name__, self.d._state_idle.__name__) except AssertionError as e: @@ -103,7 +108,9 @@ def test_retries_failure(self): self.d.action(DA.ACTIVATE, DP.IDLE) # -> start_print -> printing self.d.action(DA.SPAGHETTI, DP.BUSY) # -> spaghetti_recovery self.d.action(DA.TICK, DP.PAUSED) # -> cancel + failure - self.d._runner.cancel_print.assert_called() + self.d._runner.run_script_for_event.assert_called_with( + CustomEvents.PRINT_CANCEL + ) self.assertEqual(self.d.state.__name__, self.d._state_failure.__name__) def test_multi_job(self): @@ -263,7 +270,7 @@ def onupdate(): self.peers = [] for i, db in enumerate(self.dbs): with db.bind_ctx(MODELS): - populate_db() + populate_queues() lq = LANQueue( "LAN", f"peer{i}:{12345+i}", diff --git a/continuousprint/plugin.py b/continuousprint/plugin.py index 33b0b55..3740d4e 100644 --- a/continuousprint/plugin.py +++ b/continuousprint/plugin.py @@ -23,6 +23,7 @@ from .queues.abstract import Strategy from .storage.database import ( migrateFromSettings, + migrateScriptsFromSettings, init as init_db, DEFAULT_QUEUE, ARCHIVE_QUEUE, @@ -31,8 +32,8 @@ PRINTER_PROFILES, GCODE_SCRIPTS, Keys, + TEMP_FILE_DIR, PRINT_FILE_DIR, - TEMP_FILES, ) from .api import ContinuousPrintAPI from .script_runner import ScriptRunner @@ -130,7 +131,19 @@ def get_open_port(self): return port def get_local_ip(self): - ip_address = [(s.connect((self._settings.global_get(["server","onlineCheck","host"]), self._settings.global_get(["server","onlineCheck","port"]))), s.getsockname()[0], s.close()) for s in [socket.socket(socket.AF_INET, socket.SOCK_DGRAM)]][0][1] + ip_address = [ + ( + s.connect( + ( + self._settings.global_get(["server", "onlineCheck", "host"]), + self._settings.global_get(["server", "onlineCheck", "port"]), + ) + ), + s.getsockname()[0], + s.close(), + ) + for s in [socket.socket(socket.AF_INET, socket.SOCK_DGRAM)] + ][0][1] return ip_address def _add_set(self, path, sd, draft=True, profiles=[]): @@ -249,21 +262,40 @@ def _init_fileshare(self, fs_cls=Fileshare): def _init_db(self): init_db( - db_path=Path(self._data_folder) / "queue.sqlite3", + queues_db=Path(self._data_folder) / "queue.sqlite3", + automation_db=Path(self._data_folder) / "automation.sqlite3", logger=self._logger, ) # Migrate from old JSON state if needed - state_data = self._get_key(Keys.QUEUE) + state_data = self._get_key(Keys.QUEUE_DEPRECATED) try: if state_data is not None and state_data != "[]": settings_state = json.loads(state_data) migrateFromSettings(settings_state) - self._get_key(Keys.QUEUE) + self._set_key(Keys.QUEUE_DEPRECATED, None) except Exception: self._logger.error(f"Could not migrate old json state: {state_data}") self._logger.error(traceback.format_exc()) + # Migrate from settings scripts if needed + dep_scripts = [ + Keys.CLEARING_SCRIPT_DEPRECATED, + Keys.FINISHED_SCRIPT_DEPRECATED, + Keys.BED_COOLDOWN_SCRIPT_DEPRECATED, + ] + script_data = [self._get_key(k) for k in dep_scripts] + try: + if not (None in script_data): + migrateScriptsFromSettings(*dep_scripts) + for k in dep_scripts: + self._set_key(k, None) + except Exception: + self._logger.error( + f"Could not migrate from settings scripts: {script_data}" + ) + self._logger.error(traceback.format_exc()) + self._queries.clearOldState() def _init_queues(self, lancls=LANQueue, localcls=LocalQueue): @@ -310,7 +342,6 @@ def _init_queues(self, lancls=LANQueue, localcls=LocalQueue): def _init_driver(self, srcls=ScriptRunner, dcls=Driver): self._runner = srcls( self.popup, - self._get_key, self._file_manager, self._logger, self._printer, @@ -405,7 +436,7 @@ def _enqueue_analysis_backlog(self): self._logger.info(f"Enqueued {counter} files for CPQ analysis") def _enqueue(self, path, high_priority=False): - if path in TEMP_FILES.values(): + if path.startswith(TEMP_FILE_DIR): return False # Exclude temp files from analysis queue_entry = QueueEntry( name=path.split("/")[-1], @@ -538,9 +569,8 @@ def on_event(self, event, payload): if event == Events.MOVIE_DONE: # Optionally delete time-lapses created from bed clearing/finishing scripts - temp_files_base = [f.split("/")[-1] for f in TEMP_FILES.values()] if ( - payload["gcode"] in temp_files_base + payload["gcode"].startswith(TEMP_FILE_DIR) and self._get_key(Keys.AUTOMATION_TIMELAPSE_ACTION) == "auto_remove" ): if self._delete_timelapse(payload["movie"]): diff --git a/continuousprint/plugin_test.py b/continuousprint/plugin_test.py index 01fc8b6..a8ca708 100644 --- a/continuousprint/plugin_test.py +++ b/continuousprint/plugin_test.py @@ -11,7 +11,7 @@ import logging import tempfile import json -from .data import Keys, TEMP_FILES +from .data import Keys, TEMP_FILE_DIR from .plugin import CPQPlugin # logging.basicConfig(level=logging.DEBUG) @@ -87,7 +87,7 @@ def testDBNew(self): def testDBWithLegacySettings(self): p = mockplugin() p._set_key( - Keys.QUEUE, + Keys.QUEUE_DEPRECATED, json.dumps( [ { @@ -238,7 +238,7 @@ def testTempFileMovieDone(self): self.p._delete_timelapse = MagicMock() self.p.on_event( Events.MOVIE_DONE, - dict(gcode=list(TEMP_FILES.values())[0].split("/")[-1], movie="test.mp4"), + dict(gcode=TEMP_FILE_DIR + "/test.gcode", movie="test.mp4"), ) self.p._delete_timelapse.assert_called_with("test.mp4") diff --git a/continuousprint/script_runner.py b/continuousprint/script_runner.py index 138a2b9..2ad0fa9 100644 --- a/continuousprint/script_runner.py +++ b/continuousprint/script_runner.py @@ -1,19 +1,20 @@ import time 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 Keys, TEMP_FILES, CustomEvents +from .data import TEMP_FILE_DIR, CustomEvents +from .storage.queries import genEventScript class ScriptRunner: def __init__( self, msg, - get_key, file_manager, logger, printer, @@ -21,7 +22,6 @@ def __init__( fire_event, ): self._msg = msg - self._get_key = get_key self._file_manager = file_manager self._logger = logger self._printer = printer @@ -29,15 +29,17 @@ def __init__( self._fire_event = fire_event def _get_user(self): - return current_user.get_name() + try: + return current_user.get_name() + except AttributeError: + return None def _wrap_stream(self, name, gcode): return StreamWrapper(name, BytesIO(gcode.encode("utf-8"))) - def _execute_gcode(self, key): - gcode = self._get_key(key) - file_wrapper = self._wrap_stream(key.setting, gcode) - path = TEMP_FILES[key.setting] + def _execute_gcode(self, evt, gcode): + file_wrapper = self._wrap_stream(evt.event, gcode) + path = str(Path(TEMP_FILE_DIR) / f"{evt.event}.gcode") added_file = self._file_manager.add_file( FileDestinations.LOCAL, path, @@ -50,27 +52,32 @@ def _execute_gcode(self, key): ) return added_file - def run_finish_script(self): - self._msg("Print Queue Complete", type="complete") - result = self._execute_gcode(Keys.FINISHED_SCRIPT) - self._fire_event(CustomEvents.FINISH) - return result + def _do_msg(self, evt): + if evt == CustomEvents.FINISH: + self._msg("Print Queue Complete", type="complete") + elif evt == CustomEvents.PRINT_CANCEL: + self._msg("Print cancelled", type="error") + elif evt == CustomEvents.COOLDOWN: + self._msg("Running bed cooldown script") + elif evt == CustomEvents.PRINT_SUCCESS: + self._msg("Running success script") + + def run_script_for_event(self, evt, msg=None, msgtype=None): + self._do_msg(evt) + gcode = genEventScript(evt) - def cancel_print(self): - self._msg("Print cancelled", type="error") - self._printer.cancel_print(user=self._get_user()) - self._fire_event(CustomEvents.CANCEL) + # Cancellation happens before custom scripts are run + if evt == CustomEvents.PRINT_CANCEL: + self._printer.cancel_print() - def start_cooldown(self): - self._msg("Running bed cooldown script") - self._execute_gcode(Keys.BED_COOLDOWN_SCRIPT) - self._printer.set_temperature("bed", 0) # turn bed off - self._fire_event(CustomEvents.COOLDOWN) + result = self._execute_gcode(evt, gcode) if gcode != "" else None - def clear_bed(self): - self._msg("Clearing bed") - self._execute_gcode(Keys.CLEARING_SCRIPT) - self._fire_event(CustomEvents.CLEAR_BED) + # Bed cooldown turn-off happens after custom scripts are run + if evt == CustomEvents.COOLDOWN: + self._printer.set_temperature("bed", 0) # turn bed off + + self._fire_event(evt) + return result def start_print(self, item): self._msg(f"{item.job.name}: printing {item.path}") @@ -95,7 +102,7 @@ def start_print(self, item): self._printer.select_file( path, sd=item.sd, printAfterSelect=True, user=self._get_user() ) - self._fire_event(CustomEvents.START_PRINT) + self._fire_event(CustomEvents.PRINT_START) except InvalidFileLocation as e: self._logger.error(e) self._msg("File not found: " + path, type="error") diff --git a/continuousprint/script_runner_test.py b/continuousprint/script_runner_test.py index e830b5e..8593199 100644 --- a/continuousprint/script_runner_test.py +++ b/continuousprint/script_runner_test.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock from .script_runner import ScriptRunner from .data import CustomEvents +from .storage.database_test import DBTest import logging logging.basicConfig(level=logging.DEBUG) @@ -12,11 +13,11 @@ LJ = namedtuple("Job", ["name"]) -class TestScriptRunner(unittest.TestCase): +class TestScriptRunner(DBTest): def setUp(self): + super().setUp() self.s = ScriptRunner( msg=MagicMock(), - get_key=MagicMock(), file_manager=MagicMock(), logger=logging.getLogger(), printer=MagicMock(), @@ -26,31 +27,26 @@ def setUp(self): self.s._get_user = lambda: "foo" self.s._wrap_stream = MagicMock(return_value=None) - def test_run_finish_script(self): - self.s.run_finish_script() + def test_run_script_for_event(self): + self.s.run_script_for_event(CustomEvents.FINISH) self.s._file_manager.add_file.assert_called() self.s._printer.select_file.assert_called_with( - "ContinuousPrint/cp_queue_finished_script.gcode", + "ContinuousPrint/tmp/continuousprint_finish.gcode", sd=False, printAfterSelect=True, user="foo", ) self.s._fire_event.assert_called_with(CustomEvents.FINISH) - def test_cancel_print(self): - self.s.cancel_print() - self.s._printer.cancel_print.assert_called_with(user="foo") - self.s._fire_event.assert_called_with(CustomEvents.CANCEL) + def test_run_script_for_event_cancel(self): + # 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() - def test_clear_bed(self): - self.s.clear_bed() - self.s._printer.select_file.assert_called_with( - "ContinuousPrint/cp_bed_clearing_script.gcode", - sd=False, - printAfterSelect=True, - user="foo", - ) - self.s._fire_event.assert_called_with(CustomEvents.CLEAR_BED) + def test_run_script_for_event_cooldown(self): + # 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) @@ -60,7 +56,7 @@ def test_start_print_local(self): printAfterSelect=True, user="foo", ) - self.s._fire_event.assert_called_with(CustomEvents.START_PRINT) + 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) @@ -70,7 +66,7 @@ def test_start_print_sd(self): printAfterSelect=True, user="foo", ) - self.s._fire_event.assert_called_with(CustomEvents.START_PRINT) + self.s._fire_event.assert_called_with(CustomEvents.PRINT_START) def test_start_print_lan(self): class NetItem: @@ -88,7 +84,7 @@ def resolve(self): printAfterSelect=True, user="foo", ) - self.s._fire_event.assert_called_with(CustomEvents.START_PRINT) + self.s._fire_event.assert_called_with(CustomEvents.PRINT_START) def test_start_print_invalid_location(self): self.s._printer.select_file.side_effect = InvalidFileLocation() diff --git a/continuousprint/scripts/test_extract_profile.py b/continuousprint/scripts/test_extract_profile.py index 878bece..3611233 100644 --- a/continuousprint/scripts/test_extract_profile.py +++ b/continuousprint/scripts/test_extract_profile.py @@ -9,7 +9,7 @@ def testParameterized(self): ("asdf", "", None), # Random garbage ("; Generated by Kiri:Moto\n", "", None), # Header match but no profile ( - "; Generated by Kiri:Moto\n; Target: applesauce", + "; Generated by Kiri:Moto\n; Target: orangesauce", "", None, ), # Unknown profile diff --git a/continuousprint/static/css/continuousprint.css b/continuousprint/static/css/continuousprint.css index 2f38e72..c3b4fa6 100644 --- a/continuousprint/static/css/continuousprint.css +++ b/continuousprint/static/css/continuousprint.css @@ -91,11 +91,11 @@ #tab_plugin_continuousprint .job.acquired .job-name { font-weight: bold; } -#tab_plugin_continuousprint .fa-grip-vertical { +#tab_plugin_continuousprint .fa-grip-vertical, #settings_plugin_continuousprint .fa-grip-vertical { opacity: 0.0; margin-left: 1px !important; } -#tab_plugin_continuousprint *:hover > .fa-grip-vertical { +#tab_plugin_continuousprint *:hover > .fa-grip-vertical, #settings_plugin_continuousprint *:hover > .fa-grip-vertical { opacity: 0.5; cursor: grab; } @@ -116,7 +116,7 @@ justify-content: center; align-items: center; } -#tab_plugin_continuousprint .accordion-heading, { +#tab_plugin_continuousprint .accordion-heading { width:100%; display: flex; flex-wrap: nowrap; @@ -261,6 +261,17 @@ #settings_plugin_continuousprint .cpq_title > img { width: 71px; } +#settings_plugin_continuousprint .header-row { + width:100%; + display: flex; + flex-wrap: nowrap; + flex-direction: row; + align-items: center; + min-height: 30px; +} +#settings_plugin_continuousprint .header-row > * { + margin-left: var(--cpq-pad); +} #tab_plugin_continuousprint .queue-header, #settings_continuousprint_queues .queue-header { display: flex; justify-content: space-between; diff --git a/continuousprint/static/js/continuousprint_api.js b/continuousprint/static/js/continuousprint_api.js index 1346fde..79d12a0 100644 --- a/continuousprint/static/js/continuousprint_api.js +++ b/continuousprint/static/js/continuousprint_api.js @@ -7,6 +7,7 @@ class CPAPI { STATE = "state" QUEUES = "queues" HISTORY = "history" + AUTOMATION = "automation" init(loading_vm, err_cb) { this.loading = loading_vm; @@ -17,6 +18,7 @@ class CPAPI { let self = this; if (blocking) { if (self.loading()) { + console.log(`Skipping blocking call to ${url}; another call in progress`); return; } self.loading(true); @@ -52,8 +54,8 @@ class CPAPI { } get(type, cb, err_cb=undefined) { - // History fetching doesn't mess with mutability - let blocking = (type !== this.HISTORY); + // History/scripts fetching doesn't mess with mutability + let blocking = (type !== this.HISTORY && type !== this.AUTOMATION); this._call(type, 'get', undefined, cb, err_cb, blocking); } diff --git a/continuousprint/static/js/continuousprint_queue.test.js b/continuousprint/static/js/continuousprint_queue.test.js index a3ec702..4f5b3b1 100644 --- a/continuousprint/static/js/continuousprint_queue.test.js +++ b/continuousprint/static/js/continuousprint_queue.test.js @@ -51,7 +51,7 @@ test('setCount allows only positive integers', () => { let v = init(); v.setCount(vm, {target: {value: "-5"}}); v.setCount(vm, {target: {value: "0"}}); - v.setCount(vm, {target: {value: "apple"}}); + v.setCount(vm, {target: {value: "orange"}}); expect(vm.set_count).not.toHaveBeenCalled(); v.setCount(vm, {target: {value: "5"}}); diff --git a/continuousprint/static/js/continuousprint_settings.js b/continuousprint/static/js/continuousprint_settings.js index e6f3c3d..3368318 100644 --- a/continuousprint/static/js/continuousprint_settings.js +++ b/continuousprint/static/js/continuousprint_settings.js @@ -6,11 +6,12 @@ if (typeof log === "undefined" || log === null) { }; CP_PRINTER_PROFILES = []; CP_GCODE_SCRIPTS = []; + CP_CUSTOM_EVENTS = []; CP_LOCAL_IP = ''; CPAPI = require('./continuousprint_api'); } -function CPSettingsViewModel(parameters, profiles=CP_PRINTER_PROFILES, scripts=CP_GCODE_SCRIPTS) { +function CPSettingsViewModel(parameters, profiles=CP_PRINTER_PROFILES, default_scripts=CP_GCODE_SCRIPTS, custom_events=CP_CUSTOM_EVENTS) { var self = this; self.PLUGIN_ID = "octoprint.plugins.continuousprint"; self.log = log.getLogger(self.PLUGIN_ID); @@ -38,9 +39,9 @@ function CPSettingsViewModel(parameters, profiles=CP_PRINTER_PROFILES, scripts=C } self.profiles[prof.make][prof.model] = prof; } - self.scripts = {}; - for (let s of scripts) { - self.scripts[s.name] = s.gcode; + self.default_scripts = {}; + for (let s of default_scripts) { + self.default_scripts[s.name] = s.gcode; } // Patch the settings viewmodel to allow for us to block saving when validation has failed. @@ -50,20 +51,11 @@ function CPSettingsViewModel(parameters, profiles=CP_PRINTER_PROFILES, scripts=C return self.settings.exchanging_orig() || !self.allValidQueueNames() || !self.allValidQueueAddr(); }); - // Queues are stored in the DB; we must fetch them. self.queues = ko.observableArray(); self.queue_fingerprint = null; - self.api.get(self.api.QUEUES, (result) => { - let queues = [] - for (let r of result) { - if (r.name === "archive") { - continue; // Archive is hidden - } - queues.push(r); - } - self.queues(queues); - self.queue_fingerprint = JSON.stringify(queues); - }); + self.scripts = ko.observableArray([]); + self.events = ko.observableArray([]); + self.scripts_fingerprint = null; self.selected_make = ko.observable(); self.selected_model = ko.observable(); @@ -85,11 +77,46 @@ function CPSettingsViewModel(parameters, profiles=CP_PRINTER_PROFILES, scripts=C return; } let cpset = self.settings.settings.plugins.continuousprint; - cpset.cp_bed_clearing_script(self.scripts[profile.defaults.clearBed]); - cpset.cp_queue_finished_script(self.scripts[profile.defaults.finished]); cpset.cp_printer_profile(profile.name); }; + self.loadScriptsFromProfile = function() { + let profile = (self.profiles[self.selected_make()] || {})[self.selected_model()]; + if (profile === undefined) { + return; + } + self.scripts.push({ + name: ko.observable(`Clear Bed (${profile.name})`), + body: ko.observable(self.default_scripts[profile.defaults.clearBed]), + expanded: ko.observable(true), + }); + self.scripts.push({ + name: ko.observable(`Finish (${profile.name})`), + body: ko.observable(self.default_scripts[profile.defaults.finished]), + expanded: ko.observable(true), + }); + } + + self.newScript = function() { + self.scripts.push({ + name: ko.observable(""), + body: ko.observable(""), + expanded: ko.observable(true), + }); + } + self.rmScript = function(s) { + for (let e of self.events()) { + e.actions.remove(s); + } + self.scripts.remove(s); + } + self.addAction = function(e, a) { + e.actions.push(a); + }; + self.rmAction = function(e, a) { + e.actions.remove(a); + } + self.newBlankQueue = function() { self.queues.push({name: "", addr: "", strategy: ""}); }; @@ -130,22 +157,89 @@ function CPSettingsViewModel(parameters, profiles=CP_PRINTER_PROFILES, scripts=C if (self.settings.settings.plugins.continuousprint.cp_printer_profile() === prof.name) { self.selected_make(prof.make); self.selected_model(prof.model); - return; + break; } } + // Queues and scripts are stored in the DB; we must fetch them whenever + // the settings page is loaded + self.api.get(self.api.QUEUES, (result) => { + let queues = [] + for (let r of result) { + if (r.name === "archive") { + continue; // Archive is hidden + } + queues.push(r); + } + self.queues(queues); + self.queue_fingerprint = JSON.stringify(queues); + }); + self.api.get(self.api.AUTOMATION, (result) => { + let scripts = [] + for (let k of Object.keys(result.scripts)) { + scripts.push({ + name: ko.observable(k), + body: ko.observable(result.scripts[k]), + expanded: ko.observable(true), + }); + } + self.scripts(scripts); + + let events = [] + for (let k of custom_events) { + let actions = []; + for (let a of result.events[k.event] || []) { + for (let s of scripts) { + if (s.name() === a.script) { + actions.push({...s, condition: a.condition}); + break; + } + } + } + events.push({ + ...k, + actions: ko.observableArray(actions), + }); + } + events.sort((a, b) => a.display < b.display); + self.events(events); + + self.scripts_fingerprint = JSON.stringify({ + scripts: result.scripts, + events: result.events, + }); + }); }; // Called automatically by SettingsViewModel self.onSettingsBeforeSave = function() { - let queues = self.queues() - if (JSON.stringify(queues) === self.queue_fingerprint) { - return; // Don't call out to API if we haven't changed anything + let queues = self.queues(); + if (JSON.stringify(queues) !== self.queue_fingerprint) { + // Sadly it appears flask doesn't have good parsing of nested POST structures, + // So we pass it a JSON string instead. + self.api.edit(self.api.QUEUES, queues, () => { + // Editing queues causes a UI refresh to the main viewmodel; no work is needed here + }); + } + + let scripts = {} + for (let s of self.scripts()) { + scripts[s.name()] = s.body(); + } + let events = {}; + // TODO push conditions + for (let e of self.events()) { + let ks = []; + for (let ea of e.actions()) { + ks.push(ea.name()); + } + if (ks.length !== 0) { + events[e.event] = ks; + } + } + let data = {scripts, events}; + if (JSON.stringify(data) !== self.scripts_fingerprint) { + self.api.edit(self.api.AUTOMATION, data, () => {}); } - // Sadly it appears flask doesn't have good parsing of nested POST structures, - // So we pass it a JSON string instead. - self.api.edit(self.api.QUEUES, queues, () => { - // Editing queues causes a UI refresh to the main viewmodel; no work is needed here - }); } self.sortStart = function() { diff --git a/continuousprint/static/js/continuousprint_settings.test.js b/continuousprint/static/js/continuousprint_settings.test.js index 9715e8c..1b80e91 100644 --- a/continuousprint/static/js/continuousprint_settings.test.js +++ b/continuousprint/static/js/continuousprint_settings.test.js @@ -33,6 +33,9 @@ const SCRIPTS = [ }, ]; +const EVENTS = [ + {event: 'e1'}, +]; function mocks() { return [ @@ -53,6 +56,8 @@ function mocks() { onServerConnect: jest.fn(), }, { + AUTOMATION: 'automation', + QUEUES: 'queues', init: jest.fn(), get: jest.fn((_, cb) => cb([])), edit: jest.fn(), @@ -61,72 +66,136 @@ function mocks() { } test('makes are populated', () => { - let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS); + let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS, EVENTS); expect(v.printer_makes().length).toBeGreaterThan(1); // Not just "Select one" }); test('models are populated based on selected_make', () => { - let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS); + let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS, EVENTS); v.selected_make("Test"); expect(v.printer_models()).toEqual(["-", "Printer"]); }); -test('valid model change updates settings scripts', () => { - let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS); +test('valid model change updates profile in settings', () => { + let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS, EVENTS); v.selected_make("Test"); v.selected_model("Printer"); v.modelChanged(); - expect(v.settings.settings.plugins.continuousprint.cp_bed_clearing_script).toHaveBeenCalledWith("test1"); - expect(v.settings.settings.plugins.continuousprint.cp_queue_finished_script).toHaveBeenCalledWith("test2"); + expect(v.settings.settings.plugins.continuousprint.cp_printer_profile).toHaveBeenCalledWith("TestPrinter"); +}); + +test('loadScriptsFromProfile', () => { + let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS, EVENTS); + v.selected_make("Test"); + v.selected_model("Printer"); + v.loadScriptsFromProfile(); + expect(v.scripts()[0].name()).toMatch(/^Clear Bed.*/); + expect(v.scripts()[1].name()).toMatch(/^Finish.*/); }); test('"auto" address allows submit', () =>{ - let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS); + let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS, EVENTS); v.queues.push({name: 'asdf', addr: 'auto'}); v.onSettingsBeforeSave(); expect(v.settings.exchanging()).toEqual(false); }); test('invalid address blocks submit', () =>{ - let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS); + let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS, EVENTS); v.queues.push({name: 'asdf', addr: 'something_invalid'}); v.onSettingsBeforeSave(); expect(v.settings.exchanging()).toEqual(true); }); test('valid address allows submit', () =>{ - let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS); + let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS, EVENTS); v.queues.push({name: 'asdf', addr: '192.168.1.69:13337'}); v.onSettingsBeforeSave(); expect(v.settings.exchanging()).toEqual(false); }); test('invalid model change is ignored', () => { - let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS); + let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS, EVENTS); v.modelChanged(); expect(v.settings.settings.plugins.continuousprint.cp_bed_clearing_script).not.toHaveBeenCalled(); expect(v.settings.settings.plugins.continuousprint.cp_queue_finished_script).not.toHaveBeenCalled(); }); -test('load queues', () => { +test('load queues and scripts on settings view shown', () => { m = mocks(); - m[2].get = (_, cb) => cb([ - {name: "archive"}, - {name: "local", addr: "", strategy:"IN_ORDER"}, - {name: "LAN", addr: "a:1", strategy:"IN_ORDER"}, - ]); - let v = new VM.CPSettingsViewModel(m, PROFILES, SCRIPTS); + m[2].get = function (typ, cb) { + console.log(typ); + if (typ === m[2].QUEUES) { + cb([ + {name: "archive"}, + {name: "local", addr: "", strategy:"IN_ORDER"}, + {name: "LAN", addr: "a:1", strategy:"IN_ORDER"}, + ]); + } else if (typ === m[2].AUTOMATION) { + cb({ + scripts: {a: 'g1', b: 'g2'}, + events: {e1: ['a']}, + }); + } + }; + let v = new VM.CPSettingsViewModel(m, PROFILES, SCRIPTS, EVENTS); + v.onSettingsShown(); expect(v.queues().length).toBe(2); // Archive excluded }); test('dirty exit commits queues', () => { - let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS); + let m = mocks(); + m[2].get = function (typ, cb) { + console.log(typ); + if (typ === m[2].QUEUES) { + cb([]); + } else if (typ === m[2].AUTOMATION) { + cb({ + scripts: {}, + events: {}, + }); + } + }; + let v = new VM.CPSettingsViewModel(m, PROFILES, SCRIPTS, EVENTS); + v.onSettingsShown(); v.queues.push({name: 'asdf', addr: ''}); v.onSettingsBeforeSave(); - expect(v.api.edit).toHaveBeenCalled(); + expect(v.api.edit).toHaveBeenCalledWith(m[2].QUEUES, expect.anything(), expect.anything()); }); test('non-dirty exit does not call commitQueues', () => { - let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS); + let m = mocks(); + m[2].get = function (typ, cb) { + console.log(typ); + if (typ === m[2].QUEUES) { + cb([]); + } else if (typ === m[2].AUTOMATION) { + cb({ + scripts: {}, + events: {}, + }); + } + }; + let v = new VM.CPSettingsViewModel(m, PROFILES, SCRIPTS, EVENTS); + v.onSettingsShown(); v.onSettingsBeforeSave(); expect(v.api.edit).not.toHaveBeenCalled(); +}); +test('addScript, rmScript', () => { + let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS, EVENTS); + v.newScript(); + expect(v.scripts().length).toEqual(1); + // rmScript also removes the script from any events + v.events([{actions: ko.observableArray([v.scripts()[0]])}]); + v.rmScript(v.scripts()[0]); + expect(v.scripts().length).toEqual(0); + expect(v.events()[0].actions().length).toEqual(0); +}); +test('addAction, rmAction', () => { + let v = new VM.CPSettingsViewModel(mocks(), PROFILES, SCRIPTS, EVENTS); + let e = {"actions": ko.observableArray([])}; + let a = "foo"; + v.addAction(e, a); + expect(e.actions()[0]).toEqual(a); + v.rmAction(e, a); + expect(e.actions().length).toEqual(0); }); diff --git a/continuousprint/storage/database.py b/continuousprint/storage/database.py index 98ffe94..7b3de02 100644 --- a/continuousprint/storage/database.py +++ b/continuousprint/storage/database.py @@ -9,12 +9,14 @@ FloatField, DateField, TimeField, + TextField, CompositeKey, JOIN, Check, ) from playhouse.migrate import SqliteMigrator, migrate +from ..data import CustomEvents from collections import defaultdict import datetime from enum import IntEnum, auto @@ -29,11 +31,37 @@ class DB: # Adding foreign_keys pragma is necessary for ON DELETE behavior queues = SqliteDatabase(None, pragmas={"foreign_keys": 1}) + automation = SqliteDatabase(None, pragmas={"foreign_keys": 1}) DEFAULT_QUEUE = "local" LAN_QUEUE = "LAN" ARCHIVE_QUEUE = "archive" +BED_CLEARING_SCRIPT = "Bed Clearing" +FINISHING_SCRIPT = "Finished" +COOLDOWN_SCRIPT = "Managed Cooldown" + + +class Script(Model): + name = CharField(unique=True) + created = DateTimeField(default=datetime.datetime.now) + body = TextField() + + class Meta: + database = DB.automation + + +class Event(Model): + name = CharField() + script = ForeignKeyField(Script, backref="events", on_delete="CASCADE") + rank = FloatField() + + # Currently unused, this will provide https://newville.github.io/asteval/ based conditional + # running of the script tied to this event. + condition = CharField(null=True) + + class Meta: + database = DB.automation class StorageDetails(Model): @@ -285,9 +313,10 @@ def file_exists(path: str) -> bool: MODELS = [Queue, Job, Set, Run, StorageDetails] +AUTOMATION = [Script, Event] -def populate(): +def populate_queues(): DB.queues.create_tables(MODELS) StorageDetails.create(schemaVersion="0.0.3") Queue.create(name=LAN_QUEUE, addr="auto", strategy="LINEAR", rank=1) @@ -295,7 +324,32 @@ def populate(): Queue.create(name=ARCHIVE_QUEUE, strategy="LINEAR", rank=-1) -def init(db_path="queues.sqlite3", logger=None): +def populate_automation(): + DB.automation.create_tables(AUTOMATION) + bc = Script.create(name=BED_CLEARING_SCRIPT, body="@pause") + fin = Script.create(name=FINISHING_SCRIPT, body="@pause") + Event.create(name=CustomEvents.PRINT_SUCCESS.event, script=bc, rank=0) + Event.create(name=CustomEvents.FINISH.event, script=fin, rank=0) + + +def init(automation_db, queues_db, logger=None): + init_automation(automation_db, logger) + init_queues(queues_db, logger) + + +def init_automation(db_path, logger=None): + db = DB.automation + needs_init = not file_exists(db_path) + db.init(None) + db.init(db_path) + db.connect() + if needs_init: + if logger is not None: + logger.debug("Initializing automation DB") + populate_automation() + + +def init_queues(db_path, logger=None): db = DB.queues needs_init = not file_exists(db_path) db.init(None) @@ -304,8 +358,8 @@ def init(db_path="queues.sqlite3", logger=None): if needs_init: if logger is not None: - logger.debug("DB needs init") - populate() + logger.debug("Initializing queues DB") + populate_queues() else: try: details = StorageDetails.select().limit(1).execute()[0] @@ -368,6 +422,21 @@ class TempSet(Set): return db +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 [ + (CustomEvents.PRINT_SUCCESS, BED_CLEARING_SCRIPT, clearing_script), + (CustomEvents.FINISH, FINISHING_SCRIPT, finished_script), + (CustomEvents.COOLDOWN, COOLDOWN_SCRIPT, cooldown_script), + ]: + Script.delete().where(Script.name == name).execute() + s = Script.create(name=name, body=body) + Event.delete().where(Event.name == evt.event).execute() + Event.create(name=evt.event, script=s, rank=0) + + def migrateFromSettings(data: list): # Prior to v2.0.0, all state for the plugin was stored in a json-serialized list # in OctoPrint settings. This method converts the various forms of the json blob diff --git a/continuousprint/storage/database_test.py b/continuousprint/storage/database_test.py index e8c9f33..e0c1dff 100644 --- a/continuousprint/storage/database_test.py +++ b/continuousprint/storage/database_test.py @@ -3,14 +3,18 @@ import logging from .database import ( migrateFromSettings, + migrateScriptsFromSettings, init as init_db, Queue, Job, Set, Run, + Script, + Event, StorageDetails, DEFAULT_QUEUE, ) +from ..data import CustomEvents import tempfile # logging.basicConfig(level=logging.DEBUG) @@ -18,12 +22,33 @@ class DBTest(unittest.TestCase): def setUp(self): - self.tmp = tempfile.NamedTemporaryFile(delete=True) - self.db = init_db(self.tmp.name, logger=logging.getLogger()) + self.tmpQueues = tempfile.NamedTemporaryFile(delete=True) + self.tmpScripts = tempfile.NamedTemporaryFile(delete=True) + self.db = init_db( + automation_db=self.tmpScripts.name, + queues_db=self.tmpQueues.name, + logger=logging.getLogger(), + ) self.q = Queue.get(name=DEFAULT_QUEUE) def tearDown(self): - self.tmp.close() + self.tmpQueues.close() + self.tmpScripts.close() + + +class TestScriptMigration(DBTest): + def testMigration(self): + migrateScriptsFromSettings("test_clearing", "test_finished", "test_cooldown") + self.assertEqual( + Event.get(name=CustomEvents.PRINT_SUCCESS.event).script.body, + "test_clearing", + ) + self.assertEqual( + Event.get(name=CustomEvents.FINISH.event).script.body, "test_finished" + ) + self.assertEqual( + Event.get(name=CustomEvents.COOLDOWN.event).script.body, "test_cooldown" + ) class TestMigration(DBTest): @@ -109,7 +134,9 @@ def testMigrationSchemav2tov3(self): rank=1, ) - self.db = init_db(self.tmp.name, logger=logging.getLogger()) + self.db = init_db( + self.tmpScripts.name, self.tmpQueues.name, logger=logging.getLogger() + ) # Destination set both exists and has computed `completed` field. # We don't actually check whether the constraints were properly applied, just assume that @@ -123,9 +150,6 @@ def setUp(self): super().setUp() self.j = Job.create(queue=self.q, name="a", rank=0, count=5, remaining=5) - def tearDown(self): - self.tmp.close() - def testNextSetNoSets(self): self.assertEqual(self.j.next_set(dict(name="foo")), None) @@ -150,9 +174,6 @@ def setUp(self): profile_keys="foo,baz", ) - def tearDown(self): - self.tmp.close() - def testNextSetDraft(self): self.j.draft = True self.assertEqual(self.j.next_set(dict(name="baz")), None) diff --git a/continuousprint/storage/queries.py b/continuousprint/storage/queries.py index e803c2f..4c50e6e 100644 --- a/continuousprint/storage/queries.py +++ b/continuousprint/storage/queries.py @@ -5,7 +5,18 @@ import base64 from pathlib import Path -from .database import Queue, Job, Set, Run, DB, DEFAULT_QUEUE, ARCHIVE_QUEUE +from .database import ( + Queue, + Job, + Set, + Run, + DB, + DEFAULT_QUEUE, + ARCHIVE_QUEUE, + Event, + Script, +) +from ..data import CustomEvents MAX_COUNT = 999999 @@ -437,3 +448,40 @@ def getHistory(): def resetHistory(): Run.delete().execute() + + +def assignScriptsAndEvents(scripts, events): + with DB.automation.atomic(): + Event.delete().execute() + Script.delete().execute() + s = dict() + for k, v in scripts.items(): + s[k] = Script.create(name=k, body=v) + for k, v in events.items(): + for i, n in enumerate(v): + Event.create(name=k, script=s[n], rank=i) + + +def getScriptsAndEvents(): + scripts = dict() + events = dict() + for s in Script.select(): + scripts[s.name] = s.body + for e in Event.select(): + if e.name not in events: + events[e.name] = [] + events[e.name].append(dict(script=e.script.name, condition=e.condition)) + + return dict(scripts=scripts, events=events) + + +def genEventScript(evt: CustomEvents) -> str: + result = [] + for e in ( + Event.select() + .join(Script, JOIN.LEFT_OUTER) + .where(Event.name == evt.event) + .order_by(Event.rank) + ): + result.append(e.script.body) + return "\n".join(result) diff --git a/continuousprint/storage/queries_test.py b/continuousprint/storage/queries_test.py index 8f53b64..2e84f83 100644 --- a/continuousprint/storage/queries_test.py +++ b/continuousprint/storage/queries_test.py @@ -1,6 +1,7 @@ import unittest from unittest.mock import ANY from pathlib import Path +from ..data import CustomEvents import logging import datetime import tempfile @@ -9,15 +10,7 @@ # logging.basicConfig(level=logging.DEBUG) -from .database import ( - Job, - Set, - Run, - Queue, - init as init_db, - DEFAULT_QUEUE, - ARCHIVE_QUEUE, -) +from .database import Job, Set, Run, Queue, DEFAULT_QUEUE, ARCHIVE_QUEUE, Event, Script from .database_test import DBTest from ..storage import queries as q @@ -363,3 +356,28 @@ def testAnnotateLastRun(self): r = Run.get(id=r.id) self.assertEqual(r.movie_path, "movie_path.mp4") self.assertEqual(r.thumb_path, "thumb_path.png") + + +class TestScriptsAndEvents(DBTest): + def testAssignGet(self): + q.assignScriptsAndEvents(dict(foo="bar"), dict(evt=["foo"])) + self.assertEqual( + q.getScriptsAndEvents(), + dict( + scripts=dict(foo="bar"), + events=dict(evt=[dict(script="foo", condition=None)]), + ), + ) + + def testMultiScriptEvent(self): + evt = CustomEvents.PRINT_SUCCESS.event + q.assignScriptsAndEvents( + dict(s1="gcode1", s2="gcode2"), dict([(evt, ["s1", "s2"])]) + ) + self.assertEqual(q.genEventScript(CustomEvents.PRINT_SUCCESS), "gcode1\ngcode2") + + # Ordering of event matters + q.assignScriptsAndEvents( + dict(s1="gcode1", s2="gcode2"), dict([(evt, ["s2", "s1"])]) + ) + self.assertEqual(q.genEventScript(CustomEvents.PRINT_SUCCESS), "gcode2\ngcode1") diff --git a/continuousprint/templates/continuousprint_settings.jinja2 b/continuousprint/templates/continuousprint_settings.jinja2 index 909b046..37524b6 100644 --- a/continuousprint/templates/continuousprint_settings.jinja2 +++ b/continuousprint/templates/continuousprint_settings.jinja2 @@ -6,18 +6,20 @@
-
+
Printer Profile

Load community-contributed bed clearing scripts for your printer.

@@ -41,27 +43,58 @@
- Customize -

- For examples and best practices, see the GCODE scripting guide. -

+ Network Identity + +

Settings for identifying this printer to LAN queues and other networked printers.

-
Bed clearing & Finishing
-
- +
+
- +
-
- -
- +
+ +
+ + +
+
+ Events +

+ Register scripts to execute on queue events. See the Scripts pane for configuring/editing scripts. +

+

+ You can execute multiple scripts in sequence, and drag them to rearrange the execution order. +

+ +
+

+

+ +
+
+ +
+ +
+
+ + + +
-
Bed Cooldown Settings
+ Bed Cooldown Settings

Some printers do not respect the M190 (Wait for Bed Temperature) command (see this bug).

@@ -114,7 +147,48 @@
-
+
+ +
+
+ Scripts +

+ For examples and best practices, see the GCODE scripting guide. +

+ +
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+ + +
+
+
+
+ + + + + +
+
@@ -171,20 +245,6 @@
-
- -
- Network -
- -
- -
-
-
- -
-
Queues @@ -216,9 +276,9 @@
Strategy
-
+
- +
diff --git a/docs/contributing.md b/docs/contributing.md index e67aede..6d8133e 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -181,7 +181,7 @@ This is a collection of random tidbits intended to help you get your bearings. * Sets of the same queue item are aggregated into a `CPQueueSet` (see continuousprint/static/js/continuousprint_queueset.js) * Multiple queuesets are grouped together and run one or more times as a `CPJob` (see continuousprint/static/js/continuousprint_job.js) * For simplicity, each level only understands the level below it - e.g. a Job doesn't care about QueueItems. -* Octoprint currently uses https://fontawesome.com/v5.15/icons/ for icons. +* Octoprint currently uses https://fontawesome.com/v5.15/icons/ for icons,and https://bootstrapdocs.com/v2.2.2/docs/ for CSS and JS components * Drag-and-drop functionality uses SortableJS wrapped with Knockout-SortableJS, both of which are heavily customized. For more details on changes see: * Applied fix from https://github.com/SortableJS/knockout-sortablejs/pull/13 * Applied fix from https://github.com/SortableJS/knockout-sortablejs/issues/14