Repository: OctoPrint/OctoPrint

Issue:
PrintDone event fires before GCode scripts have finished. 
<!--
READ THE FOLLOWING FIRST:

If not already done, please read the the Contribution Guidelines that
are linked to the right under "Helpful resources" > "Contributing".

Also read the FAQ: https://faq.octoprint.org

This is a bug and feature tracker, please only use it to report bugs
or request features within OctoPrint (not OctoPi, not any OctoPrint
plugins and not unofficial OctoPrint versions).

Do not seek support here ("I need help with ...", "I have a
question ..."), that belongs on the community forum at
community.octoprint.org, NOT here.

Mark requests with a "[Request]" prefix in the title please. For bug
reports fully fill out the bug reporting template (if you don't know
where to find some information - it's all described in the Contribution
Guidelines linked up there in the big yellow box).

When reporting a bug do NOT delete ANY lines from the template.

Make sure any bug you want to report is still present with the CURRENT
OctoPrint version and that it does not vanish when you start OctoPrint
in safe mode - how to do that is also explained in the Contribution
Guidelines linked up there in the big yellow box.

Thank you!
-->

#### What were you doing?

<!--
Please be as specific as possible here. The maintainers will need to
reproduce your issue in order to fix it and that is not possible if they
don't know what you did to get it to happen in the first place.

Ideally provide exact steps to follow in order to reproduce your problem:
-->

I have a plugin that snaps a picture of the PrintDone event. A user reported that the picture is taken before finishing gcode is done running. The code is from Octoprints GCode Scripts and not from a slicer. Similar to https://github.com/OctoPrint/OctoPrint/issues/2699

Original issue by user https://github.com/taxilian/OctoPrint-Twilio/issues/14

<!--
If you encountered a problem with specific files of any sorts, make sure
to also include a link to a file with which to reproduce the problem.
-->

#### What did you expect to happen?

That PrintDone would mean that all printer activity related to the current job has finished. 

#### What happened instead?

The printer continued to move after sending the PrintDone event.

#### Did the same happen when running OctoPrint in safe mode?

<!--
Test if you can reproduce your problem in safe mode. You can find information
on how to enable safe mode in the Contribution Guidelines.

If you can't reproduce in safe mode, this is a bug with one of your
installed third party plugins. Don't open a ticket here!

If you can't test this in safe mode, state why.
-->

I honestly didn't try.

#### Version of OctoPrint

<!--
Can be found in the lower left corner of the web interface. ALWAYS INCLUDE.
-->

```
shadycuz-> octoprint --version
octoprint, version 1.5.0rc3
```

I have noticed this in many versions going back a while. I'm not sure how to test this on a virtual printer but in the logs I see:

```
2020-12-08 04:27:55,181 - octoprint.util.comm - INFO - Changing monitoring state from "Printing" to "Finishing"
2020-12-08 04:27:55,189 - octoprint.printer.standard.job - INFO - Print job done - origin: local, path: test.gcode, owner: shadycuz
2020-12-08 04:27:55,323 - octoprint.util.comm - INFO - Changing monitoring state from "Finishing" to "Operational"
```
Seems like PrintDone is called while "Finishing" and not at the end of or after.
 
#### Operating System running OctoPrint
Ubuntu 20 something something on WSL
<!--
OctoPi, Linux, Windows, MacOS, something else? With version please.
OctoPi's version can be found in /etc/octopi_version or in the lower left
corner of the web interface.
-->

#### Printer model & used firmware incl. version

<!--
If applicable, always include if unsure.
-->

Virtual

#### Browser and version of browser, operating system running browser

<!--
If applicable, always include if unsure.
-->
Chrome
#### Link to octoprint.log

<!--
On gist.github.com or pastebin.com. ALWAYS INCLUDE and never truncate.

The Contribution Guidelines tell you where to find that.
-->

```
2020-12-08 04:27:55,181 - octoprint.util.comm - INFO - Changing monitoring state from "Printing" to "Finishing"
2020-12-08 04:27:55,189 - octoprint.printer.standard.job - INFO - Print job done - origin: local, path: test.gcode, owner: shadycuz
2020-12-08 04:27:55,323 - octoprint.util.comm - INFO - Changing monitoring state from "Finishing" to "Operational"
```

#### Link to contents of terminal tab or serial.log

<!--
On gist.github.com or pastebin.com. If applicable, always include if unsure or
reporting communication issues. Never truncate.

serial.log is usually not written due to performance reasons and must be
enabled explicitly. Provide at the very least the FULL contents of your
terminal tab at the time of the bug occurrence, even if you do not have
a serial.log (which the Contribution Guidelines tell you where to find).
-->
I can provide if needed

#### Link to contents of Javascript console in the browser

<!--
On gist.github.com or pastebin.com or alternatively a screenshot. If applicable -
always include if unsure or reporting UI issues.

The Contribution Guidelines tell you where to find that.
-->

Will provide if needed

#### Screenshot(s)/video(s) showing the problem:

<!--
If applicable. Always include if unsure or reporting UI issues.
-->

I have read the FAQ. (once or twice over the years.)



Discussion:
If you run OctoPrint in 'debug' mode `octoprint serve --debug` you can see logging for all the events too, which would allow you to pinpoint this. Otherwise we'll try too.
@cp2004 

I'm not sure what we are looking for, its the same output as I gave above, nothing has changed. 

```
2020-12-08 07:19:23,224 - octoprint.events.fire - DEBUG - Firing event: PrinterStateChanged (Payload: {'state_id': 'FINISHING', 'state_string': 'Finishing'})
...
2020-12-08 07:19:23,233 - octoprint.events.fire - DEBUG - Firing event: PrintDone (Payload: {'name': 'test.gcode', 'path': 'test.gcode', 'origin': 'local', 'size': 56, 'owner': 'shadycuz', 'time': 0.03050320001784712})
...
2020-12-08 07:19:23,241 - octoprint.plugin - DEBUG - Calling on_event on smsnotifier
...
2020-12-08 07:19:24,817 - octoprint.events.fire - DEBUG - Firing event: PrinterStateChanged (Payload: {'state_id': 'OPERATIONAL', 'state_string': 'Operational'})
```

This is the intended behavior of octoprint I think. I'm asking we change the intended behavior because I think it's misleading. The PrintDone event does not mean that the printer is finished, its that the end of the gcode file was reached. Since you can add additional gcode with octoprint the printer might actually have more things to do. If the printer is still doing things for the current job, then its not really done. 

I doubt you will want to change the current event as there is probably lots of plugins that rely on the current behavior, maybe its possible we add events for the additional start and end gcode.

It's also possible in my plugin I keep track of previous events? Not sure this would work?

```python
 def on_event(self, event, payload):
        
        if event != "PrinterStateChanged":
            return

        self.prev_state = self.cur_state
        self.cur_state = payload['state_id']
   
        if self.prev_state == 'FINISHING' and self.cur_state == 'OPERATIONAL':
            # Printer is actually finished, take a picture and send the notification.
```

@shadycuz debug logging was just to confirm that this was in fact the issue, that the PrintDone event was definitely sent before the script. There's events `PrintCancelling` and `PrintCancelled`, similarly for pausing - maybe it makes sense to bring PrintFinishing or something in to make this more consistent.
Yeah, switching the current PrintDone to PrintFinishing and then making the PrintDone happen after the post scripts makes sense to me and I don't think would impact any plugins.
I can submit a PR if @foosel is okay with one of these approaches.
I'm not 100% sure about this not impacting any plugins - changes like these are always ticking bombs. Still, I'd be open to a PR and then see if it generates any "WAIT WHAT?!" from the plugin developer community ;)
I wasn't going to change the existing event, just add new ones for the gcode scripts.

@foosel 
IMO moving the PRINT_DONE event after the gcode script makes sense. Being that events are fired async I wouldn't ever rely on receiving the event before "afterPrintDone". Because I cannot rely on it firing beforehand there is nothing I could reliably do that would change the operations performed by "afterPrintDone".

@kantlivelong but changing the current event is not going to be backwards compatible.  I think its best to just create a new event for the pre and post gcode steps. 

Probably a "start" and "end" event for each step. I do plan on working on this "eventually". Possibly after Christmas. If you want to start working on this then I can help on a branch in your fork if you're okay with that. 
@foosel 

Today I started looking into implementing new events for the [predefined gcode](https://docs.octoprint.org/en/master/features/gcode_scripts.html#sec-features-gcode-scripts-predefined) that is able to be set in the settings. I first started by finding where these things are [called](https://github.com/OctoPrint/OctoPrint/blob/bf8ff6358556aaa073da2d0646f193c3a1fef48f/src/octoprint/printer/standard.py#L1606-L1612). I noticed that [Printer.script](https://github.com/OctoPrint/OctoPrint/blob/bf8ff6358556aaa073da2d0646f193c3a1fef48f/src/octoprint/printer/standard.py#L427-L443) was probably the best place to put the new logic. 

So my plan was something like this inside the script function:

```python
def script(...):
    // error handing at the top of the script...

    eventManager().fire(f"{name}Start", context['event'])

    // rest of the functions logic

    eventManager().fire(f"{name}End", context['event'])
```

So this is pretty much all that is needed besides a tiny bit of logic handling if the context is `None`. This means all gcode scripts would have `Start` and `End` events.

I wanted to check with you because I had two main concerns. 

1. This is the first time that I know of that events have been dynamically generated. All these events in the docs are [statically named](https://docs.octoprint.org/en/master/events/index.html#available-events) and even in the event class they are all [class variables](https://github.com/OctoPrint/OctoPrint/blob/bf8ff6358556aaa073da2d0646f193c3a1fef48f/src/octoprint/events.py#L38-L46).
2. My next concern is about "registering" for events. I don't know what that means. I have an event mixin based plugin but I don't remember registering for the event my plugin listens for. I just do `if event != 'PrintDone': return` and that works fine. I read the code and it doesn't seem that `fire` method checks to see if the event is a class variable so it seems like if I fire `AnyRandomEvent` name then plugins with the event mixin SHOULD get it. I could probably test this theory out in 10 mins but I thought I would ask because of my first concern. 

In total, I see 5 calls to `script` in the print object. I'm not sure if there are more calls elsewhere. So at minimum, we would be adding start and end events for:
1. beforePrintStarted
2. afterPrintDone
3. afterPrintCancelled
4. afterPrintPaused
5. beforePrintResumed

Let me know your thoughts on the above and if you are okay with a dynamically generated event for each script custom gcode script. I can send a PR in the next 2 weeks. 

Thanks

@shadycuz Gina is on holiday until January 10th*ish*, so you might be waiting. In the meantime, I'll give you my thoughts 🙂 

That seems like quite a few events to me, but if it is useful then I suppose it will happen, and they will get fired. I'm not entirely sure about the overhead of sending events, but they are done asynchronously so I don't think it will block sending the gcode scripts. *Just had a look, doesn't seem like too much from code reading.*

---

> This is the first time that I know of that events have been dynamically generated. All these events in the docs are statically named and even in the event class they are all class variables.

I think that events should try and remain static ideally, rather than being dynamically generated (see documenting below). This means that anyone can import the events class without needing to worry about the events being adjusted/renamed easily. Maybe I'm weighing this too highly, and I can't really say either way.

In addition, you should keep events consistent - this means that they start with a capital and use CamelCase. As I read your code snippet, will the event be created as `beforePrintStartedStart` - I don't think this is very clear/concise personally.

They would also have to be documented, which means you would have to type them all out anyway. If they are scattered throughout the code, this makes documentation quite a lot more difficult.

My suggested solution to keeping them static but *referencing* them dynamically is using `getattr()` to find the class name. Something like:
```python
event = gettattr(octoprint.events.Events, name + "Start")
eventManager().fire(event, context['event'])
``` 

---

> My next concern is about "registering" for events. [...]

There are two ways that events can be 'handled'. First is as you describe, using `on_event()` in an `EventHandlerPlugin`. The second, is subscribing to events, such as:

```python
def event_callback():
    print("Event fired!")

octoprint.events.eventManager().subscribe(
    "PrintStarted",
    event_callback
)
```
Then the event callback will be called every time the event is fired. This should work for any kind of event, whether it is static/defined by a plugin/dynamic.


That seems like a lot of words, but hopefully it gets the point across! Let me know if you have more questions.

@cp2004 

Well the name scheme is easy to fix. As far as the names of these events being goofy... well we can try to work that out but it going to be a bit rough. For example `beforePrintStarted` is the name of the GCODE that is running. So if we need an event for when that gcode starts and ends it would have to be something like:

- `StartBeforePrintStarted`
- `StartBeforePrintStartedGcode` (This seems okayish)
- `BeforePrintStartedStart`
- `BeforePrintStartedStartGcode`
- `BeforePrintStartedInProgress` (This is nice too)
- `GcodeBeforePrintStartedRunning`
- `BeforePrintStartedGcodeRunning` (Also nice)  

I think of all these I probably like `BeforePrintStartedGcodeRunning`. I still don't think it would make sense to hardcode these into the class var for events. This is because the script function also handles [user-defined GCODE](https://docs.octoprint.org/en/master/features/gcode_scripts.html#id6) which we would not know ahead of time. Same with documentation. I would just add a single event that shows the name scheme `${ScriptName}GcodeRunning` and payload to the events docs.

I will start with appending `GcodeRunning` and `GcodeFinished` to the event names for now. I'll shoot over a PR with the dynamic stuff since most of the logic is the same even if we need to change it so the events are class vars.

Thanks

Resolving patch:
diff --git a/src/octoprint/printer/standard.py b/src/octoprint/printer/standard.py
--- a/src/octoprint/printer/standard.py
+++ b/src/octoprint/printer/standard.py
@@ -444,6 +444,15 @@ def script(
         if name is None or not name:
             raise ValueError("name must be set")
 
+        # .capitalize() will lowercase all letters but the first
+        # this code preserves existing CamelCase
+        event_name = name[0].upper() + name[1:]
+
+        event_start = "GcodeScript{}Running".format(event_name)
+        payload = context.get("event", None) if isinstance(context, dict) else None
+
+        eventManager().fire(event_start, payload)
+
         result = self._comm.sendGcodeScript(
             name,
             part_of_job=part_of_job,
@@ -453,6 +462,9 @@ def script(
         if not result and must_be_set:
             raise UnknownScript(name)
 
+        event_end = "GcodeScript{}Finished".format(event_name)
+        eventManager().fire(event_end, payload)
+
     def jog(self, axes, relative=True, speed=None, *args, **kwargs):
         if isinstance(axes, basestring):
             # legacy parameter format, there should be an amount as first anonymous positional arguments too


Test patch:
diff --git a/tests/printer/test_script.py b/tests/printer/test_script.py
new file mode 100644
--- /dev/null
+++ b/tests/printer/test_script.py
@@ -0,0 +1,76 @@
+__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html"
+__copyright__ = "Copyright (C) 2021 The OctoPrint Project - Released under terms of the AGPLv3 License"
+
+import unittest
+from unittest.mock import ANY, MagicMock, call, patch
+
+from octoprint.printer.standard import Printer
+
+
+class ScriptsTestCase(unittest.TestCase):
+    def setUp(self):
+        # mock comm
+        self.comm = MagicMock()
+
+        # mock event manager
+        self.event_manager = MagicMock()
+
+        self.event_manager_patcher = patch("octoprint.printer.standard.eventManager")
+        self.event_manager_getter = self.event_manager_patcher.start()
+        self.event_manager_getter.return_value = self.event_manager
+
+        # mock plugin manager
+        self.plugin_manager = MagicMock()
+        self.plugin_manager.get_hooks.return_value = {}
+
+        self.plugin_manager_patcher = patch("octoprint.printer.standard.plugin_manager")
+        self.plugin_manager_getter = self.plugin_manager_patcher.start()
+        self.plugin_manager_getter.return_value = self.plugin_manager
+
+        # mock settings
+        self.settings = MagicMock()
+        self.settings.getInt.return_value = 1
+        self.settings.getBoolean.return_value = False
+
+        self.settings_patcher = patch("octoprint.printer.standard.settings")
+        self.settings_getter = self.settings_patcher.start()
+        self.settings_getter.return_value = self.settings
+
+        self.printer = Printer(MagicMock(), MagicMock(), MagicMock())
+        self.printer._comm = self.comm
+
+    def tearDown(self):
+        self.settings_patcher.stop()
+        self.plugin_manager_patcher.stop()
+        self.event_manager_patcher.stop()
+
+    def test_event_name(self):
+        self.printer.script("testEvent")
+
+        self.event_manager.fire.assert_any_call("GcodeScriptTestEventRunning", ANY)
+
+    def test_event_order(self):
+        self.printer.script("EventOrder")
+
+        expected_order = [
+            call("GcodeScriptEventOrderRunning", ANY),
+            call("GcodeScriptEventOrderFinished", ANY),
+        ]
+
+        self.event_manager.fire.assert_has_calls(expected_order)
+
+    def test_payload_handling(self):
+        expected_paylod = {"payloadKey": "payloadValue"}
+        context = {"event": expected_paylod}
+
+        self.printer.script("GetPayload", context)
+
+        self.event_manager.fire.assert_called_with(ANY, expected_paylod)
+
+        self.printer.script("WrongPayload", [])
+
+        self.event_manager.fire.assert_called_with(ANY, None)
+
+        self.printer.script("WrongContext", {"noEvent": {}})
+
+        self.event_manager.fire.assert_called_with(ANY, None)