Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Notifier Print Progress Enhancements #840

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
101 changes: 84 additions & 17 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -2624,6 +2624,7 @@ events: *
# cancelled
# paused
# resumed
# layer_changed - note, this is excluded from *. See below.
# This parameter must be provided.
body: "Your printer status has changed to {event_name}"
# The body of the notification. This option accepts Jinja2 templates, where
Expand Down Expand Up @@ -2657,22 +2658,29 @@ attach:
```

!!! Tip
The `event_args` field of the Jinja2 context passed to templates in
this section receives a list of "arguments" passed to the event. For
those familiar with Python this list is known as "variable arguments".
Currently the notifier only supports two kinds of events: those
triggered by a change in the job state and those triggered from a remote
method call frm a `gcode_macro`.

For `remote method` events the `event_args` field will always be
an empty list. For `job state` events the `event_args` field will
contain two items. The first item (`event_args[0]`) contains the
job state recorded prior to the event, the second item (`event_args[1]`)
contains the current job state. In most cases users will be interested
in the current job state (`event_args[1]`).

The `job state` is a dict that contains the values reported by
Klipper's [print_stats](printer_objects.md#print_stats) object.
The `event_args` field of the Jinja2 context passed to templates in
this section receives a list of "arguments" passed to the event. For
those familiar with Python this list is known as "variable arguments".
Currently the notifier only supports two kinds of events: those
triggered by a change in the job state and those triggered from a remote
method call frm a `gcode_macro`.

For `remote method` events the `event_args` field will always be
an empty list. For `job state` events the `event_args` field will
contain two items. The first item (`event_args[0]`) contains the
job state recorded prior to the event, the second item (`event_args[1]`)
contains the current job state. In most cases users will be interested
in the current job state (`event_args[1]`).

The `job state` is a dict that contains the values reported by
Klipper's [print_stats](printer_objects.md#print_stats) object.

A `timestamp` value, generated from Python's `datetime.now()` function, is also
included in the event data. This can be presented in a notification message in
your preferred date and time format in Jinja2 using the `strftime` function and
[format patterns](
https://docs.python.org/2/library/datetime.html#strftime-and-strptime-behavior).
For example: `{timestamp.strftime('%d/%m/%Y %H:%M.%S')}`

#### An example:
```ini
Expand Down Expand Up @@ -2702,6 +2710,65 @@ body: {event_message}
attach: http://192.168.1.100/webcam/?action=snapshot
```

#### Configuring layer change progress notifications
The `layer_changed` notification event can be generated at configurable layer change
percentages during the print. Additional configuration parameters can be created in a
`[notifier my_layer_change_message]` block.

The layer_change event receives a reduced set of information from the print.
`event_args[0]` is empty in the layer change event, with `event_args[1]` holding a
limited version of the current print state.

```
event_arg[1].total_duration: current number of seconds the job has been running.
event_arg[1].print_duration: current number of seconds the job has been printing.
event_arg[1].filament used: current length of filament used.
event_arg[1].info.total_layer: total number of layers declared in the job's G-code.
event_arg[1].info.current_layer: the current layer number.
```

Multiple `events: layer_changed` notifier configurations are permitted, e.g. if you
want to use multiple notifier services, or permit more frequent progress updates for
larger (taller) prints.

In order to pass the total layer count and current layer count, additional G-code is
required from the the slicer. Add the following to the `Start G-code` and `After layer
change G-code`. The following are taken from the printer custom G-code in PrusaSlicer.

#### Start G-code:
`SET_PRINT_STATS_INFO TOTAL_LAYER=[total_layer_count]`

#### After layer change G-code:
`SET_PRINT_STATS_INFO CURRENT_LAYER={layer_num + 1}`

An example:
```ini
# moonraker.conf

[notifier event_layerchange]
url: tgram://{bottoken}/{ChatID}
events: layer_changed
layer_trigger: 0.25
# a ratio between 0.0 and 1.0 representing at which percentage of the progess
# (current layer / total layers) to trigger the layer change message.
# Note this can only be generated based on layer progress. There are currently no
# estimated duration statistics available.
# 0.25 would generate a message every 25% (25%, 50% and 75%). No layer change is
# generated at 0%, because this is the start event, nor 100% as this is the complete
# event. If layer_trigger is not specified, or out of 0.0-1.0 it defaults to 0 and
# no messages are generated.
minimum_layers: 100
# the whole number of total layers the print must be before progress messages are
# generated. This stops progress messages being generated in quick succession
# for smaller prints.
body: Progress Update
Layer Count: {event_args[1].info.current_layer} of {event_args[1].info.total_layer}
Layer Progress: {
(event_args[1].info.current_layer / event_args[1].info.total_layer * 100
if event_args[1].info.total_layer is not none and event_args[1].info.total_layer > 0
else 0)| int}%
```

#### Notifying from Klipper
It is possible to invoke your notifiers from the Klippy host, this can be done
with a gcode_macro, such as:
Expand Down Expand Up @@ -3047,4 +3114,4 @@ make the changes.
check to make sure that the formatting is correct.

Once the changes are complete you may use the UI to restart Moonraker and
the warnings should clear.
the warnings should clear.
1 change: 1 addition & 0 deletions moonraker/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ def __str__(self) -> str:
return self._name_.lower() # type: ignore

class JobEvent(ExtendedEnum):
LAYER_CHANGED = 0

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it be better to append the new event to the bottom of the Enum with a value of 8?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did spot this when I was coding it originally. Setting it to 8 would require additional changes a few lines further down in common.py which determines if the printer has finished or aborted. Adding the new state as 0 (zero) didn't disturb that existing code.

    @property
    def finished(self) -> bool:
        return self.value >= 5

    @property
    def aborted(self) -> bool:
        return self.value >= 6

We could change it to this untested code, but I don't know if that might cause any problems elsewhere.

    LAYER_CHANGED = 8

    @property
    def finished(self) -> bool:
        return (self.value >= 5 and self.value != 8)

    @property
    def aborted(self) -> bool:
        return (self.value >= 6 and self.value != 8)

STANDBY = 1
STARTED = 2
PAUSED = 3
Expand Down
12 changes: 10 additions & 2 deletions moonraker/components/job_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,18 @@ async def _status_update(self, data: Dict[str, Any], _: float) -> None:
)
if "info" in ps:
cur_layer: Optional[int] = ps["info"].get("current_layer")
prev_ps = dict(self.last_print_stats)
if "filename" in prev_ps: # inject filename from last_print_stats
ps["filename"] = prev_ps["filename"]
if cur_layer is not None:
total: int = ps["info"].get("total_layer", 0)
self.server.send_event(
"job_state:layer_changed", cur_layer, total
"job_state:layer_changed",
"layer_changed",
{}, # empty previous stats
ps # print stats
# This layout keeps the event.args[1] consistent with other
# notifier events as the current print stats in the
# moonraker.conf notifier configurations
)
self.last_print_stats.update(ps)

Expand Down
63 changes: 60 additions & 3 deletions moonraker/components/notifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import logging
import pathlib
import re
from datetime import datetime # To allow timestamp to be generated
from ..common import JobEvent, RequestType

# Annotation imports
Expand Down Expand Up @@ -41,7 +42,13 @@ def __init__(self, config: ConfigHelper) -> None:
if job_event == JobEvent.STANDBY:
continue
evt_name = str(job_event)
if "*" in notifier.events or evt_name in notifier.events:
# add extra AND condition (and evt_name != 'layer_change')
# to * to exclude layer_change from being automatically picked
# up by the * otherwise it triggers on EVERY layer change
if ("*" in notifier.events and evt_name != 'layer_changed') or \
evt_name in notifier.events:

logging.info(f"Job Event loading: {evt_name}")
self.events.setdefault(evt_name, []).append(notifier)
logging.info(f"Registered notifier: '{notifier.get_name()}'")
except Exception as e:
Expand All @@ -54,6 +61,9 @@ def __init__(self, config: ConfigHelper) -> None:
self.server.register_event_handler(
"job_state:state_changed", self._on_job_state_changed
)
self.server.register_event_handler(
"job_state:layer_changed", self._on_job_layer_changed
)

def register_remote_actions(self):
self.server.register_remote_method("notify", self.notify_action)
Expand All @@ -74,6 +84,38 @@ async def _on_job_state_changed(
for notifier in self.events.get(evt_name, []):
await notifier.notify(evt_name, [prev_stats, new_stats])

async def _on_job_layer_changed(
self,
job_event: JobEvent,
prev_stats: Dict[str, Any],
new_stats: Dict[str, Any]
) -> None:
evt_name = str(job_event)
if "info" in new_stats:
for notifier in self.events.get(evt_name, []):
# get config value:
# percentage of layers to generate the layer change notification
layer_trigger = notifier.layer_trigger
# get config value:
# only send message if the layer number is greater than this value.
minimum_layers = notifier.minimum_layers
# total layers from the gcode
total = int(new_stats["info"]["total_layer"])
# current layer counter
current = int(new_stats["info"]["current_layer"])
# calculate if we've hit a % trigger point above the min height
# this isn't nice to read because of the 88 char line limit
if layer_trigger != 0 and total >= minimum_layers and current > 0 and \
current != total and \
round(current/total/layer_trigger, 5)//1 != \
round(((current-1)/total)/layer_trigger, 5)//1:

logging.info(
"Layer change notification at "
f"{round(current/total/layer_trigger,5)//1*layer_trigger*100}%"
)
await notifier.notify(evt_name, [{}, new_stats])

def register_endpoints(self, config: ConfigHelper):
self.server.register_endpoint(
"/server/notifiers/list", RequestType.GET, self._handle_notifier_list
Expand Down Expand Up @@ -123,6 +165,16 @@ def __init__(self, config: ConfigHelper) -> None:
self.apprise = apprise.Apprise()
self.attach = config.gettemplate("attach", None)
url_template = config.gettemplate("url")
self.layer_trigger = float(config.get("layer_trigger", 0))
self.minimum_layers = int(config.get("minimum_layers", 0))
if self.layer_trigger < 0 or self.layer_trigger > 1:
# layer_trigger out of range. Default to 0 and send logging info
logging.info(
f"Layer change trigger out of range for Notifier {self.name}. "
f"Expected decimal between 0 and 1, found {self.layer_trigger}. "
"Ignoring layer change trigger."
)
self.layer_trigger = 0
self.url = url_template.render()

if re.match(r"\w+?://", self.url) is None:
Expand All @@ -145,7 +197,10 @@ def as_dict(self):
"body": self.config.get("body", None),
"body_format": self.config.get("body_format", None),
"events": self.events,
"attach": self.attach
"attach": self.attach,
# include the additional parameters in the return data
"layer_trigger": self.config.get("layer_trigger", 0),
"minimum_layers": self.config.get("minimum_layers", 0)
}

async def notify(
Expand All @@ -154,7 +209,9 @@ async def notify(
context = {
"event_name": event_name,
"event_args": event_args,
"event_message": message
"event_message": message,
# Add linux timestamp to every notication payload
"timestamp": datetime.now()
}

rendered_title = (
Expand Down
Loading