diff --git a/docs/configuration.md b/docs/configuration.md index a035caa4a..539a3317c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 @@ -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 @@ -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: @@ -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. \ No newline at end of file diff --git a/moonraker/common.py b/moonraker/common.py index c8460389d..4efdbaef8 100644 --- a/moonraker/common.py +++ b/moonraker/common.py @@ -109,6 +109,7 @@ def __str__(self) -> str: return self._name_.lower() # type: ignore class JobEvent(ExtendedEnum): + LAYER_CHANGED = 0 STANDBY = 1 STARTED = 2 PAUSED = 3 diff --git a/moonraker/components/job_state.py b/moonraker/components/job_state.py index eec17cd83..50a874e16 100644 --- a/moonraker/components/job_state.py +++ b/moonraker/components/job_state.py @@ -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) diff --git a/moonraker/components/notifier.py b/moonraker/components/notifier.py index 7f01296e6..d32441476 100644 --- a/moonraker/components/notifier.py +++ b/moonraker/components/notifier.py @@ -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 @@ -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: @@ -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) @@ -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 @@ -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: @@ -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( @@ -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 = (