diff --git a/README.md b/README.md index 7a8e3684..42212cf8 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Each relay has the following settings *(in order of appearance)*: | Icon `ON` / `OFF` | An image or emoji to indicate the relay state (supports HTML) | | GPIO Number | The [GPIO pin on the Raspberry Pi](https://pinout.xyz/) | | Inverted output | For normally closed relay: the relay is `ON` without power | -| Warn if turning `OFF` | Enables a confirmation dialog when turning the relay `OFF` | +| Confirm turning `OFF` | Enables a confirmation dialog when turning the relay `OFF` | | Alert on switches ahead | Notifies on upcoming switch with an ability to cancel it | | **Events:** | Behavior customization (automation) | | on Startup | The state to switch the relay to when OctoPrint started | diff --git a/octoprint_octorelay/__init__.py b/octoprint_octorelay/__init__.py index 671f347a..cb4580cb 100755 --- a/octoprint_octorelay/__init__.py +++ b/octoprint_octorelay/__init__.py @@ -14,7 +14,7 @@ from .const import ( get_default_settings, get_templates, get_ui_vars, RELAY_INDEXES, ASSETS, SWITCH_PERMISSION, UPDATES_CONFIG, POLLING_INTERVAL, UPDATE_COMMAND, GET_STATUS_COMMAND, LIST_ALL_COMMAND, AT_COMMAND, SETTINGS_VERSION, - STARTUP, PRINTING_STOPPED, PRINTING_STARTED, CANCELLATION_EXCEPTIONS, PREEMPTIVE_CANCELLATION_CUTOFF, + STARTUP, PRINTING_STOPPED, PRINTING_STARTED, PRIORITIES, FALLBACK_PRIORITY, PREEMPTIVE_CANCELLATION_CUTOFF, CANCEL_TASK_COMMAND, USER_ACTION, TURNED_ON ) from .driver import Relay @@ -187,16 +187,17 @@ def handle_plugin_event(self, event, scope = None): needs_ui_update = False for index in scope: if bool(settings[index]["active"]): + did_cancel = self.cancel_tasks(subject = index, initiator = event) # issue 205 + needs_ui_update = needs_ui_update or did_cancel target = settings[index]["rules"][event]["state"] if target is not None: target = bool(target) if target and event == TURNED_ON: self._logger.debug(f"Skipping {index} to avoid infinite loop") continue # avoid infinite loop - self.cancel_tasks(subject = index, initiator = event) delay = int(settings[index]["rules"][event]["delay"] or 0) if delay == 0: - self.toggle_relay(index, target) + self.toggle_relay(index, target) # UI update conducted by the polling thread else: self._logger.debug(f"Postponing the switching of the relay {index} by {delay}s") task = Task( @@ -237,15 +238,19 @@ def toggle_relay(self, index, target: Optional[bool] = None): if state: self.handle_plugin_event(TURNED_ON, scope = [index]) - def cancel_tasks(self, subject: str, initiator: str, target: Optional[bool] = None, owner: Optional[str] = None): + def cancel_tasks( + self, subject: str, initiator: str, + target: Optional[bool] = None, owner: Optional[str] = None + ) -> bool: # returns True when cancelled some tasks self._logger.debug(f"Cancelling tasks by request from {initiator} for relay {subject}") - exceptions = CANCELLATION_EXCEPTIONS.get(initiator) or [] + priority = PRIORITIES.get(initiator) or FALLBACK_PRIORITY + count_before = len(self.tasks) def handler(task: Task): - not_exception = task.owner not in exceptions + lower_priority = (PRIORITIES.get(task.owner) or FALLBACK_PRIORITY) >= priority same_subject = subject == task.subject same_target = True if target is None else task.target == target same_owner = True if owner is None else task.owner == owner - if same_subject and not_exception and same_target and same_owner: + if same_subject and lower_priority and same_target and same_owner: try: task.cancel_timer() self._logger.info(f"Cancelled the task: {task}") @@ -254,7 +259,13 @@ def handler(task: Task): return False # exclude return True # include self.tasks = list(filter(handler, self.tasks)) - self._logger.debug("The cancelled tasks removed from the registry") + count_cancelled = count_before - len(self.tasks) + did_cancel = count_cancelled > 0 + self._logger.debug( + f"Cancelled ({count_cancelled}) tasks and removed from the registry" + if did_cancel else "No tasks cancelled" + ) + return did_cancel def run_system_command(self, cmd): if cmd: diff --git a/octoprint_octorelay/const.py b/octoprint_octorelay/const.py index a81f13e2..b6633a52 100644 --- a/octoprint_octorelay/const.py +++ b/octoprint_octorelay/const.py @@ -8,9 +8,19 @@ TURNED_ON = "TURNED_ON" USER_ACTION = "USER_ACTION" -# Task cancellation exceptions -# { eventHappened: [ events which postponed timers should NOT be cancelled ] -CANCELLATION_EXCEPTIONS = {} +# Event having higher or same priority (lower or equal number here) cancells the tasks placed by previous events +# Highest priority is 1. +PRIORITIES = { + USER_ACTION: 1, + STARTUP: 2, + PRINTING_STARTED: 2, + PRINTING_STOPPED: 2, + TURNED_ON: 3 +} + +# Missing events above will be treated as ones having this priority +FALLBACK_PRIORITY = 5 + # min seconds before the task can be cancelled PREEMPTIVE_CANCELLATION_CUTOFF = 2 diff --git a/octoprint_octorelay/static/css/octorelay.css b/octoprint_octorelay/static/css/octorelay.css index 0e210b18..b363a99d 100644 --- a/octoprint_octorelay/static/css/octorelay.css +++ b/octoprint_octorelay/static/css/octorelay.css @@ -19,7 +19,8 @@ color: inherit; } -#settings_plugin_octorelay .control-label span.label { +#settings_plugin_octorelay .control-label span.label, +#settings_plugin_octorelay .help-inline span.label { zoom: 0.85; /* not scale */ } diff --git a/octoprint_octorelay/templates/octorelay_settings.jinja2 b/octoprint_octorelay/templates/octorelay_settings.jinja2 index f8ce414e..68e58c78 100755 --- a/octoprint_octorelay/templates/octorelay_settings.jinja2 +++ b/octoprint_octorelay/templates/octorelay_settings.jinja2 @@ -85,6 +85,10 @@ {% endfor %} + + {{ _('Disconnects when turning') }} + {{ _('OFF') }} + @@ -163,7 +167,7 @@
@@ -183,6 +187,9 @@ {% endfor %}
+ + {{ _('Enables an extra dialog') }} +
diff --git a/tests/snapshots/snap_test_templates.py b/tests/snapshots/snap_test_templates.py index baa1b620..7ae8fe05 100644 --- a/tests/snapshots/snap_test_templates.py +++ b/tests/snapshots/snap_test_templates.py @@ -235,6 +235,10 @@ + + Disconnects when turning + OFF + @@ -343,7 +347,7 @@
@@ -376,6 +380,9 @@
+ + Enables an extra dialog +
@@ -821,6 +828,10 @@ + + Disconnects when turning + OFF + @@ -929,7 +940,7 @@
@@ -962,6 +973,9 @@
+ + Enables an extra dialog +
@@ -1407,6 +1421,10 @@ + + Disconnects when turning + OFF + @@ -1515,7 +1533,7 @@
@@ -1548,6 +1566,9 @@
+ + Enables an extra dialog +
@@ -1993,6 +2014,10 @@ + + Disconnects when turning + OFF + @@ -2101,7 +2126,7 @@
@@ -2134,6 +2159,9 @@
+ + Enables an extra dialog +
@@ -2579,6 +2607,10 @@ + + Disconnects when turning + OFF + @@ -2687,7 +2719,7 @@
@@ -2720,6 +2752,9 @@
+ + Enables an extra dialog +
@@ -3165,6 +3200,10 @@ + + Disconnects when turning + OFF + @@ -3273,7 +3312,7 @@
@@ -3306,6 +3345,9 @@
+ + Enables an extra dialog +
@@ -3751,6 +3793,10 @@ + + Disconnects when turning + OFF + @@ -3859,7 +3905,7 @@
@@ -3892,6 +3938,9 @@
+ + Enables an extra dialog +
@@ -4337,6 +4386,10 @@ + + Disconnects when turning + OFF + @@ -4445,7 +4498,7 @@
@@ -4478,6 +4531,9 @@
+ + Enables an extra dialog +
diff --git a/tests/test_init.py b/tests/test_init.py index 5ede7bcb..9c1097d1 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -675,7 +675,7 @@ def test_handle_plugin_event(self): ] for case in cases: self.plugin_instance.update_ui = Mock() - self.plugin_instance.cancel_tasks = Mock() + self.plugin_instance.cancel_tasks = Mock(return_value=False) self.plugin_instance.tasks = [] utilMock.ResettableTimer.reset_mock() timerMock.start.reset_mock() @@ -692,8 +692,8 @@ def test_handle_plugin_event(self): } for index in RELAY_INDEXES }) self.plugin_instance.handle_plugin_event(case["event"]) + self.plugin_instance.cancel_tasks.assert_called_with(subject = "r8", initiator = case["event"]) if case["expectedCall"]: - self.plugin_instance.cancel_tasks.assert_called_with(subject = "r8", initiator = case["event"]) if case["delay"] == 0: self.plugin_instance.toggle_relay.assert_called_with("r8", case["state"]) else: @@ -710,7 +710,6 @@ def test_handle_plugin_event(self): self.assertEqual(self.plugin_instance.tasks[index].delay, case["delay"]) self.assertEqual(self.plugin_instance.tasks[index].target, case["state"]) else: - self.plugin_instance.cancel_tasks.assert_not_called() utilMock.ResettableTimer.assert_not_called() timerMock.start.assert_not_called() self.plugin_instance.toggle_relay.assert_not_called() @@ -718,23 +717,24 @@ def test_handle_plugin_event(self): def test_cancel_tasks(self): # Should remove the tasks for the certain relay and cancel its timer - timerMock.reset_mock() - remaining_task = Task( - subject = "r6", - target = False, - owner = "PRINTING_STOPPED", - delay = 0, - function = Mock(), - args = [] - ) - self.plugin_instance.tasks = [ - Task(subject = "r4", target = False, owner = "PRINTING_STOPPED", delay = 0, function = Mock(), args = []), - remaining_task, - Task(subject = "r4", target = False, owner = "STARTUP", delay = 0, function = Mock(), args = []) + task1 = Task(subject = "r4", target = False, owner = "PRINTING_STOPPED", delay = 0, function=Mock(), args=[]) + task2 = Task(subject = "r6", target = False, owner = "PRINTING_STOPPED", delay = 0, function=Mock(), args=[]) + task3 = Task(subject = "r4", target = False, owner = "STARTUP", delay = 0, function = Mock(), args = []) + cases = [ + { "initiator": "PRINTING_STARTED", "expected_rest": [ task2 ], "expected_call": True }, + # event having lower priority: + { "initiator": "TURNED_ON", "expected_rest": [ task1, task2, task3 ], "expected_call": False }, + # event having higher priority: + { "initiator": "USER_ACTION", "expected_rest": [ task2 ], "expected_call": False } ] - self.plugin_instance.cancel_tasks(subject = "r4", initiator = "PRINTING_STARTED") - self.assertEqual(self.plugin_instance.tasks, [remaining_task]) - timerMock.cancel.assert_called_with() + for case in cases: + timerMock.reset_mock() + self.plugin_instance.tasks = [ task1, task2, task3 ] + actual = self.plugin_instance.cancel_tasks(subject = "r4", initiator = case["initiator"]) + self.assertEqual(self.plugin_instance.tasks, case["expected_rest"]) + self.assertEqual(actual, len(case["expected_rest"]) < 3) + if case["expected_call"]: + timerMock.cancel.assert_called_with() def test_cancel_tasks__exception(self): # Should handle a possible exception when cancelling a timer @@ -742,10 +742,11 @@ def test_cancel_tasks__exception(self): Task(subject = "r4", target = False, owner = "PRINTING_STOPPED", delay = 0, function = Mock(), args = []) ] timerMock.cancel=Mock( side_effect=Exception("Caught!") ) - self.plugin_instance.cancel_tasks(subject = "r4", initiator = "PRINTING_STARTED") + actual = self.plugin_instance.cancel_tasks(subject = "r4", initiator = "PRINTING_STARTED") self.plugin_instance._logger.warn.assert_called_with( "Failed to cancel Task(r4,False,PRINTING_STOPPED,0), reason: Caught!" ) + self.assertTrue(actual) timerMock.reset_mock() @patch("time.time", Mock(return_value=500))