pushraker 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pushraker/component.py ADDED
@@ -0,0 +1,3718 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ # SPDX-PackageHomePage: https://github.com/noon92/pushraker
3
+ # SPDX-FileCopyrightText: Copyright (c) 2026 @noon92
4
+
5
+ # Push Notifications β€” print, progress and fault notifications with images,
6
+ # sent through Apprise (ntfy, Pushover, Telegram, Discord, a webhook, email).
7
+ #
8
+ # Sources:
9
+ # * Moonraker job events: started, paused, resumed, error (and complete /
10
+ # cancelled as a fallback when the print hooks did not report them).
11
+ # * The Klipper print hooks (`_PRINT_END_NTFY`, `_CANCEL_PRINT_NTFY`), via
12
+ # the `push_notification` remote method, so complete/cancelled snapshots
13
+ # are taken before the printer parks.
14
+ # * Snapmaker's exception manager: coded faults and warnings, including
15
+ # those that never reach print_stats (Klipper shutdowns, idle faults).
16
+ # * print_stats layer info and file progress: first layer and milestones.
17
+ # * Power-loss recovery data after boot.
18
+ #
19
+ # Images are fetched before sending; when the camera or the thumbnail is not
20
+ # available, the notification is sent without the image. A send that the
21
+ # server answers with an error (or that can't connect) is retried after 6, 10
22
+ # and 30 s; the last retry is without the image. Fault codes use Snapmaker's
23
+ # format LLLL-IIII-XXXX-CCCC (level-id-index-code).
24
+ #
25
+ # Normally generated by notifications-config.py from extended2.cfg. Each
26
+ # notification can go to a push service (`<notification>: ...`) and/or email
27
+ # (`<notification>_email: ...`) and/or a webhook (`<notification>_webhook:
28
+ # ...`), each `<render|snapshot|spool|none>, <apprise url>`;
29
+ # notifications that are not listed are disabled. Each destination is sent
30
+ # and retried on its own. Emails, Telegram and Discord get the links (Web UI, Live
31
+ # Camera, Error Index) in the text, since they have no buttons:
32
+ #
33
+ # [push_notifications]
34
+ # name: U1
35
+ # snapshot_url: http://127.0.0.1/webcam/snapshot.jpg
36
+ # layers_complete: 2 # first-layer row: after layer 1, 2 or 3
37
+ # timezone: Asia/Jerusalem # for ETA clock times (printer runs UTC)
38
+ # web_ui_url: http://u1/ # links in the text (optional)
39
+ # camera_url: http://u1/webcam/webrtc
40
+ # started: render, ntfys://ntfy.example.com/u1?priority=default
41
+ # error: snapshot, ntfys://ntfy.example.com/u1?priority=max
42
+ # error_email: snapshot, mailtos://user:pass@smtp.example.com:587?...
43
+ #
44
+ # This file may be distributed under the terms of the GNU GPLv3 license.
45
+
46
+ from __future__ import annotations
47
+ import asyncio
48
+ import datetime
49
+ import html
50
+ import http
51
+ import json
52
+ import logging
53
+ import os
54
+ import re
55
+ import shutil
56
+ import tempfile
57
+ import time
58
+ import urllib.request
59
+ from typing import (
60
+ TYPE_CHECKING, Any, Awaitable, Dict, List, NamedTuple, Optional, Set,
61
+ Tuple
62
+ )
63
+
64
+ import apprise
65
+
66
+ try:
67
+ from . import images
68
+ except Exception as e: # no Pillow: Spoolman notifications without images
69
+ logging.warning("[push_notifications] spool images unavailable: %s", e)
70
+ images = None # type: ignore
71
+
72
+ try:
73
+ from . import email_html
74
+ except Exception as e:
75
+ logging.warning("[push_notifications] HTML email unavailable: %s", e)
76
+ email_html = None # type: ignore
77
+
78
+ try:
79
+ # Registers the discordhook:// and discorddm:// Apprise plugins
80
+ from . import discord
81
+ except Exception as e:
82
+ logging.warning("[push_notifications] Discord unavailable: %s", e)
83
+ discord = None # type: ignore
84
+
85
+ from moonraker.common import JobEvent, KlippyState
86
+
87
+ if TYPE_CHECKING:
88
+ from moonraker.confighelper import ConfigHelper
89
+ from moonraker.components.klippy_apis import KlippyAPI as APIComp
90
+
91
+ EXCEPTION_EVENT = "snapmaker:exception_notification"
92
+ REMOTE_METHOD = "push_notification"
93
+ MILESTONES = (25, 50, 75)
94
+ ROW_IDS = (
95
+ "started", "layer_started", "first_layer", "progress_25", "progress_50", "progress_75",
96
+ "paused", "file_pause", "resumed", "object_complete", "complete", "cancelled",
97
+ "calibration",
98
+ "chamber_waiting", "chamber_reached", "chamber_fault",
99
+ "filament_change",
100
+ "macro_min", "macro_low", "macro_default", "macro_high", "macro_max",
101
+ "error", "warning", "shutdown", "power_loss", "driver_hot",
102
+ "filament_short", "filament_low", "spool_switch", "slot_empty",
103
+ )
104
+ SPOOLMAN_ROWS = ("filament_short", "filament_low", "spool_switch")
105
+ TITLES = {
106
+ "started": "▢️ {name} started",
107
+ "layer_started": "🎬 {name} first layer started",
108
+ "first_layer": "πŸ” {name} {ordinal} layer complete",
109
+ "progress": "⏳ {name} {pct}%",
110
+ "paused": "⏸️ {name} paused",
111
+ "resumed": "▢️ {name} resumed",
112
+ "recovered": "▢️ {name} resumed after power loss",
113
+ "object_complete": "πŸ“¦ {name} object complete",
114
+ "complete": "βœ… {name} complete",
115
+ "cancelled": "🚫 {name} cancelled",
116
+ "calibration": "πŸ“ {name} {what} complete",
117
+ "macro": "πŸ“£ {name} {title}",
118
+ "calibration_failed": "πŸ“ {name} {what} failed",
119
+ "intervention": "πŸ‘‹ {name} user intervention needed",
120
+ "file_pause": "πŸ‘‹ {name} paused by the print job",
121
+ "chamber_waiting": "🌑️ {name} waiting for chamber temperature",
122
+ "chamber_reached": "🌑️ {name} chamber temperature reached",
123
+ "chamber_timeout": "⚠️ {name} timed out waiting for chamber "
124
+ "temperature, printing anyway",
125
+ "chamber_stalled": "⚠️ {name} chamber not heating",
126
+ "chamber_fault": "⚠️ {name} chamber heater fault",
127
+ "chamber_disconnected": "⚠️ {name} chamber heater disconnected",
128
+ "error": "⚠️ {name} Error: {kind}",
129
+ "warning": "⚠️ {name} Warning",
130
+ "shutdown": "πŸ›‘ {name} Error: shutdown",
131
+ "power_loss": "⚑ {name} power loss",
132
+ "driver_hot": "🌑️ {name} driver running hot",
133
+ "filament_loaded": "🧡 {name} filament loaded",
134
+ "filament_unloaded": "🧡 {name} filament unloaded",
135
+ "filament_short": "🧡 {name} not enough filament",
136
+ "filament_close": "🧡 {name} filament may be insufficient",
137
+ "filament_unknown": "🧡 {name} filament amount unknown",
138
+ "filament_problems": "🧡 {name} filament problems on {n} toolheads",
139
+ "filament_low": "🧡 {name} spool running low",
140
+ "filament_lows": "🧡 {name} spools running low on {n} toolheads",
141
+ "spool_switch": "πŸ”„ {name} switched to backup spool",
142
+ "slot_empty": "🧡 {name} ACE slot ran empty",
143
+ }
144
+ IMAGE_KINDS = ("render", "snapshot", "spool", "none")
145
+ # machine_state_manager.main_state values of the manual calibrations run
146
+ # from the touchscreen (klippy/extras/machine_state_manager.py); the
147
+ # calibration is complete when the state goes back to IDLE (0; PRINTING
148
+ # is 1, seen in the Moonraker log as "machine state: {...}")
149
+ CALIBRATIONS = {2: "XYZ offset calibration", 3: "Bed levelling",
150
+ 4: "Flow calibration", 5: "Input shaper calibration"}
151
+ MSM_IDLE = 0
152
+ MSM_ABNORMAL = 7
153
+ # action_code values (same file) of the steps that wait for a person:
154
+ # MANUAL_CLEAN_EXTRUDER..3 (brush the nozzle, then tap the screen)
155
+ MANUAL_CLEAN = {192: 1, 193: 2, 194: 3, 195: 4}
156
+ # ...and a calibration sitting at action IDLE (0) this long, with Klipper
157
+ # idle too (idle_timeout "Ready": no moves, no heat-up running), is the
158
+ # screen waiting for a tap (plate out / plate back in). Picking up and
159
+ # heating a toolhead also runs at action IDLE, for longer than this, but
160
+ # Klipper is "Printing" then, which restarts the count.
161
+ INTERVENTION_HOLD = 15.
162
+ # Filament loaded / unloaded: print_task_config.filament_exist per toolhead
163
+ # (the toolhead's sensor, or the feeder's). It can flicker while filament
164
+ # is fed, so a change is reported once it has held this long. Not during
165
+ # a print job: runouts and refills have their own notifications.
166
+ FILAMENT_CHANGE_SETTLE = 3.
167
+ FAULT_ROWS = ("error", "warning", "shutdown")
168
+ # Chamber temperature, whatever heats the chamber: a print job "waits" when,
169
+ # before its first layer, there is a chamber target and the chamber is below
170
+ # it. Targets come from any heater_generic (DragonBreath, Panda Breath,
171
+ # others; M141/M191) or Snapmaker's own chamber wait (WAIT_CHAMBER_TEMP,
172
+ # which only runs with Snapmaker's purifier connected and shows as action
173
+ # PREHEAT_CHAMBER). M191 releases a few degrees short of the target
174
+ # (DragonBreath: 5 Β°C), so within this of it counts as reached.
175
+ CHAMBER_ROWS = ("chamber_waiting", "chamber_reached", "chamber_fault")
176
+ CHAMBER_TOLERANCE = 5.
177
+ # Waiting with no rise of this much for this long: "not heating"
178
+ CHAMBER_STALL_RISE = 1.
179
+ CHAMBER_STALL_TIME = 600.
180
+ # A chamber wait counts once it has lasted this long: the printer's own
181
+ # wait can end within seconds (it asks for 45 Β°C in keep-warm mode and
182
+ # gives up at once with the lid off), and that is not worth two
183
+ # notifications. Nothing is sent about a wait that ended sooner.
184
+ CHAMBER_WAIT_MIN = 30.
185
+ CHAMBER_SENSOR = "temperature_sensor cavity"
186
+ ACTION_PREHEAT_CHAMBER = 3
187
+ DRAGONBREATH = "dragonbreath"
188
+ ERROR_INDEX_URL = ("https://wiki.snapmaker.com/en/snapmaker_u1/"
189
+ "troubleshooting/u1_error_codes")
190
+ ORDINALS = {1: "1st", 2: "2nd", 3: "3rd"}
191
+ SNAPSHOT_TIMEOUT = 5.
192
+ SNAPSHOT_MAX = 5 * 1024 * 1024
193
+ # A pause is reported when it starts; the head moves away from the part
194
+ # afterwards, so the snapshot waits (the bed doesn't lower on a pause).
195
+ PAUSE_SNAPSHOT_DELAY = 3.
196
+ # A runout pause on a toolhead the printer can refill from a backup spool:
197
+ # the printer swaps toolheads within ~10 s and resumes by itself, so the
198
+ # error is held this long and dropped if the refill happens (the "Switched
199
+ # to backup spool" notification says it instead)
200
+ REFILL_WAIT = 25.
201
+ # multiACE (klipper/extras/ace.py): with Quad Replenish on, a runout on a
202
+ # head fed by an ACE is reloaded from another slot of the ACE (a full load
203
+ # takes ~90 s) and the print job resumes by itself: the error is held
204
+ # this long instead. Status fields read from the `ace` object; indices in
205
+ # it are 0-based, shown 1-based like multiACE's own messages.
206
+ ACE_OBJECT = "ace"
207
+ ACE_FIELDS = ["head_source", "aces", "mode", "head_ace",
208
+ "quad_replenish", "quad_first", "swap_in_progress"]
209
+ ACE_REFILL_WAIT = 180.
210
+ MACRO_PREFIX = "notify "
211
+ MACRO_SEP = " || "
212
+ MACRO_LEVELS = ("min", "low", "default", "high", "max")
213
+ RUNOUT_EXCEPTION_ID = 523
214
+ # Before each attempt, wait this long at most for the printer to have a
215
+ # network (Wi-Fi is often not up yet right after boot). Waiting doesn't
216
+ # count as an attempt; nothing has been sent, so it can't cause doubles.
217
+ NETWORK_WAIT = 600.
218
+ NETWORK_POLL = 5.
219
+ # Spoolman notifications: how often to read the spools' remaining weight
220
+ # during a print. A toolhead is "may be insufficient" (orange) when it has
221
+ # enough by less than max(20 g, 5% of what it needs).
222
+ FILAMENT_POLL = 60.
223
+ FILAMENT_CLOSE = 0.05
224
+ FILAMENT_CLOSE_G = 20.
225
+ RANK = {"green": 0, "orange": 1, "red": 2}
226
+ # Message length limits: (counted in characters?, limit). ntfy's is 4096
227
+ # bytes; Pushover's 1024 characters. Longer messages are split between
228
+ # toolheads, and names shortened with "…" only if one toolhead won't fit.
229
+ # Telegram's is 4096 characters after HTML escaping and the links.
230
+ # Discord's embed text is 4096 characters, after Markdown escaping and links.
231
+ BODY_LIMITS = {"pushover": (True, 1024), "ntfy": (False, 4000),
232
+ "telegram": (True, 3500), "discord": (True, 3500)}
233
+ # Discord's embed colour (Apprise notification type) for each notification
234
+ NOTIFY_TYPES = {"complete": "success", "object_complete": "success",
235
+ "calibration": "success", "chamber_reached": "success",
236
+ "chamber_fault": "warning",
237
+ "driver_hot": "warning", "error": "failure",
238
+ "warning": "failure", "shutdown": "failure",
239
+ "power_loss": "failure", "filament_short": "failure",
240
+ "cancelled": "warning", "paused": "warning",
241
+ "file_pause": "warning",
242
+ "filament_low": "warning", "spool_switch": "warning",
243
+ "slot_empty": "warning"}
244
+ FILE_CAP = 30
245
+ # Emails about one print go in one thread (see _email_prepare). Faults
246
+ # join it while printing and this long after the print ended.
247
+ THREAD_ROWS = ("started", "layer_started", "first_layer",
248
+ "progress_25", "progress_50",
249
+ "progress_75", "paused", "file_pause", "resumed", "object_complete",
250
+ "complete", "cancelled",
251
+ "chamber_waiting", "chamber_reached", "chamber_fault",
252
+ "filament_change", "filament_short", "filament_low",
253
+ "spool_switch", "slot_empty")
254
+ THREAD_FAULT_WINDOW = 120.
255
+ NAME_CAP_MIN = 8
256
+ PTC_FIELDS = ["extruder_map_table", "filament_spool_id", "extruders_used",
257
+ "auto_replenish_filament", "replenish_ignore_color",
258
+ "filament_exist", "filament_vendor", "filament_type",
259
+ "filament_sub_type", "filament_color_rgba",
260
+ "filament_color_multi"]
261
+ # The printer refills only from a toolhead with the same nozzle
262
+ EXTRUDERS = ["extruder", "extruder1", "extruder2", "extruder3"]
263
+ NOZZLE_FIELDS = ["nozzle_diameter", "nozzle_volume_type"]
264
+ # A job that starts this far into its file is a power-loss recovery
265
+ RECOVERY_MIN_PROGRESS = 0.01
266
+ # ...and either kept its print time, or is this far in (a small file's
267
+ # start G-code alone is past 1 %)
268
+ RECOVERY_MIN_TIME = 30.
269
+ RECOVERY_SURE_PROGRESS = 0.10
270
+ # Below this file progress, time left comes from the slicer estimate rather
271
+ # than print time / progress (the first layer alone makes that ratio useless)
272
+ EARLY_PROGRESS = 0.10
273
+ SEND_RETRY_DELAYS = (6, 10, 30)
274
+ # ntfy timestamps messages to the second and its app orders by that time:
275
+ # two sends this far apart can't share a second and swap places
276
+ SEND_GAP = {"ntfy": 2.} # seconds before each retry of a failed send
277
+ # Within one service (services never wait on each other), faults go ahead of
278
+ # everything else, and these status notifications are dropped if they could
279
+ # not be sent this long after they happened (e.g. a long outage): a 25%
280
+ # notification an hour late is noise. Faults and complete never expire.
281
+ URGENT_ROWS = ("error", "warning", "shutdown", "power_loss",
282
+ "chamber_fault")
283
+ EXPIRING_ROWS = ("started", "layer_started", "first_layer", "progress_25",
284
+ "progress_50", "progress_75", "object_complete",
285
+ "chamber_waiting", "chamber_reached", "filament_change")
286
+ STATUS_EXPIRY = 300.
287
+ # Spoolman usage counts as tracked once spools lost this much; if nothing
288
+ # was used by this far into the file, it isn't tracked and the filament
289
+ # checks during the print job are skipped (the one at the start still runs)
290
+ USAGE_MIN_G = 0.5
291
+ USAGE_GRACE = 0.05
292
+ # Wait this long for the server's answer (Apprise default: 4 s), so a slow
293
+ # upload that the server accepted is not counted as failed and sent twice.
294
+ SEND_READ_TIMEOUT = 60.
295
+ # Apprise's default (4 s) is short for a slow or relayed connection
296
+ SEND_CONNECT_TIMEOUT = 10.
297
+ PL_ENV_PATHS = (
298
+ "/home/lava/printer_data/klippy/pl_print_file_env.json",
299
+ "/home/lava/printer_data/config/snapmaker/pl_print_file_env.json",
300
+ )
301
+ PL_WAIT_TIME = 60.
302
+ BOOT_WINDOW = 600.
303
+ # After boot the camera service may still be starting: the power-loss
304
+ # snapshot is retried this long before sending without it.
305
+ PL_CAMERA_WAIT = 45.
306
+ PL_CAMERA_POLL = 5.
307
+
308
+ FaultKey = Tuple[Any, Any, Any]
309
+ GCODE_EXTS = (".gcode", ".gco", ".g")
310
+ # The X and Y stepper drivers report their own temperature (TMC2240 ADC);
311
+ # the alert goes once per print job, and again after a pause (so a print
312
+ # job paused for the heat and resumed can warn a second time)
313
+ DRIVERS = {"tmc2240 stepper_x": "X", "tmc2240 stepper_y": "Y"}
314
+ DRIVER_HOT_DEFAULT = 105
315
+ # Print-by-object (Orca "print_sequence = by object"): the slicer's config
316
+ # block at the end of the file says so, and its EXCLUDE_OBJECT markers tell
317
+ # Klipper which object is being printed. Layer numbers run on across the
318
+ # objects, so an object's own layer is counted from the layer it began on.
319
+ CONFIG_TAIL = 256 * 1024
320
+ # Pauses the print job itself asks for, put there by the slicer ("add
321
+ # pause", "change filament") or by hand: PAUSE, M601, M226, M0, M1, M600,
322
+ # and Orca's ;PAUSE_PRINT marker (followed by the printer's pause G-code).
323
+ # A marker and a command within PAUSE_MERGE bytes are one pause. The
324
+ # pause is told from a person's by virtual_sdcard.file_position, which
325
+ # sits just past the pause line when the file did it.
326
+ RX_PAUSE_CMD = re.compile(rb"^(PAUSE|M601|M226|M0|M1|M600)\b[ \t]*([^;\r\n]*)"
327
+ rb"(?:;[ \t]*([^\r\n]*))?", re.I)
328
+ RX_PAUSE_MARK = re.compile(rb"^;[ \t]*PAUSE_PRINT\b", re.I)
329
+ RX_LAYER = re.compile(rb"^;[ \t]*(LAYER_CHANGE|LAYER:[ \t]*(\d+))", re.I)
330
+ RX_M117 = re.compile(rb"^M117[ \t]+([^\r\n]*)", re.I)
331
+ PAUSE_MERGE = 400
332
+ PAUSE_WINDOW = 2048
333
+ RX_BY_OBJECT = re.compile(rb"^; *print_sequence *= *by object *$", re.M)
334
+ RX_OBJECT_NAME = re.compile(r"^(.*?)_id_(\d+)_copy_(\d+)$")
335
+
336
+
337
+ def shown(filename: str) -> str:
338
+ """File name as shown to people: without the G-code extension (Klipper
339
+ only prints .gcode, .gco and .g files)."""
340
+ low = filename.lower()
341
+ for ext in GCODE_EXTS:
342
+ if low.endswith(ext) and len(filename) > len(ext):
343
+ return filename[:-len(ext)]
344
+ return filename
345
+
346
+
347
+ def format_code(ex: Dict[str, Any]) -> str:
348
+ try:
349
+ return "%04d-%04d-%04d-%04d" % (
350
+ int(ex["level"]), int(ex["id"]), int(ex["index"]),
351
+ int(ex["code"]))
352
+ except (KeyError, TypeError, ValueError):
353
+ return "-".join(str(ex.get(k)) for k in ("level", "id", "index",
354
+ "code"))
355
+
356
+
357
+ # Klipper's fault messages are internal ("detected noodle"). The printer's
358
+ # screen shows its own text per code, from the firmware's exception text
359
+ # file ("<id>-<index>-<code>": "<title>_<details>"); notifications use the
360
+ # same. Faults it doesn't list keep Klipper's message.
361
+ EXCEPTION_TEXT_PATHS = ("/home/lava/resource/text/exception_en-GB.json",)
362
+ _exception_text: Optional[Dict[str, str]] = None
363
+
364
+
365
+ def exception_text() -> Dict[str, str]:
366
+ global _exception_text
367
+ if _exception_text is None:
368
+ _exception_text = {}
369
+ for path in EXCEPTION_TEXT_PATHS:
370
+ try:
371
+ with open(path, encoding="utf-8") as f:
372
+ data = json.load(f)
373
+ if isinstance(data, dict):
374
+ _exception_text = {str(k): str(v) for k, v in data.items()}
375
+ break
376
+ except Exception as e:
377
+ logging.info("[push_notifications] no exception texts in "
378
+ "%s: %s", path, e)
379
+ return _exception_text
380
+
381
+
382
+ # An emoji before the error code for some kinds of fault, by module id and
383
+ # code (any toolhead), going by each code's text in exception_en-GB.json.
384
+ # Codes not listed get none.
385
+ def _codes(module: int, *codes: Any) -> Set[Tuple[int, int]]:
386
+ out: Set[Tuple[int, int]] = set()
387
+ for c in codes:
388
+ for n in (c if isinstance(c, range) else (c,)):
389
+ out.add((module, n))
390
+ return out
391
+
392
+
393
+ FAULT_EMOJI: Dict[Tuple[int, int], str] = {}
394
+ for _emoji, _set in (
395
+ # Temperature: heating module, nozzle, heated bed, chamber
396
+ ("🌑️", _codes(522, 15) | _codes(523, 1, 2, 3, 36) | _codes(526, 0, 1)
397
+ | _codes(527, 0) | _codes(533, 1)),
398
+ ("πŸ’Ύ", _codes(531, 9)), # storage space
399
+ ("☁️", _codes(531, 7, 11, 12)), # cloud print file
400
+ # Calibration (vibration compensation, flow, XYZ offset, homing)
401
+ ("πŸ“", _codes(522, 21) | _codes(523, 9000, 9001)
402
+ | _codes(530, range(0, 10), 18, 19, 20, 23, range(24, 29))),
403
+ ("🍝", _codes(532, 2)), # spaghetti
404
+ # Spool and filament: runout, filament info, feeder loading/unloading
405
+ ("🧡", _codes(523, 0, 39) | _codes(525, 0, 1, 2, 3, 5, 6, 8,
406
+ range(10, 16), range(30, 36), 70,
407
+ 90)),
408
+ # Toolhead: swapping, picking, parking, detached, placement, Pogopin,
409
+ # maintenance
410
+ ("πŸ”§", _codes(523, range(7, 36), range(37, 38), range(40, 48))
411
+ | _codes(528, 13, 14) | _codes(530, range(13, 17))),
412
+ ):
413
+ for _k in _set:
414
+ FAULT_EMOJI[_k] = _emoji
415
+
416
+
417
+ def fault_emoji(ex: Dict[str, Any]) -> str:
418
+ try:
419
+ return FAULT_EMOJI.get((int(ex.get("id")), int(ex.get("code"))), "")
420
+ except (TypeError, ValueError):
421
+ return ""
422
+
423
+
424
+ def describe(ex: Dict[str, Any]) -> str:
425
+ """Error code and the text the printer shows for it, e.g.
426
+ "0002-0532-0000-0002: Possible Spaghetti Defect Detected" and the
427
+ screen's advice on the next line."""
428
+ msg = str(ex.get("message") or "").strip()
429
+ try:
430
+ key = "%04d-%04d-%04d" % (int(ex.get("id")), int(ex.get("index")),
431
+ int(ex.get("code")))
432
+ except (TypeError, ValueError):
433
+ key = ""
434
+ text = exception_text().get(key, "")
435
+ title, _, details = text.partition("_")
436
+ emoji = fault_emoji(ex)
437
+ code = f"{emoji} {format_code(ex)}" if emoji else format_code(ex)
438
+ if not title.strip():
439
+ return f"{code}: {msg}"
440
+ return "\n".join(x for x in (f"{code}: {title.strip()}",
441
+ details.strip()) if x)
442
+
443
+
444
+ def coded(ps: Dict[str, Any]) -> Optional[Dict[str, Any]]:
445
+ """The Snapmaker fault recorded in print_stats, if any."""
446
+ ex = ps.get("exception") or {}
447
+ if isinstance(ex, dict) and ex.get("code") is not None:
448
+ return ex
449
+ return None
450
+
451
+
452
+ def fault_key(ex: Dict[str, Any]) -> FaultKey:
453
+ return (ex.get("id"), ex.get("index"), ex.get("code"))
454
+
455
+
456
+ def format_duration(seconds: float) -> str:
457
+ t = int(seconds or 0)
458
+ if t >= 3600:
459
+ return "%dh %02dm" % (t // 3600, (t % 3600) // 60)
460
+ return "%dm" % (t // 60)
461
+
462
+
463
+ class ChannelGate:
464
+ """One service's send order. Each notification takes a ticket when it
465
+ happens: (urgency, sequence). A send attempt waits until its ticket is
466
+ the first one still pending and nothing else is sending, so
467
+ notifications arrive in the order they happened (resumed never before
468
+ paused), except that faults go ahead of status notifications. A ticket
469
+ is held until its notification is sent or given up, so one that is
470
+ waiting to retry holds the channel's later notifications back (they
471
+ would arrive out of order otherwise); faults, with a lower ticket, go
472
+ ahead of it. Services never wait on each other."""
473
+
474
+ def __init__(self) -> None:
475
+ self._seq = 0
476
+ self._tickets: Set[Tuple[int, int]] = set()
477
+ self._busy: Optional[Tuple[int, int]] = None
478
+ self._cond: Optional[asyncio.Condition] = None
479
+ self.last_sent = -1e9 # monotonic time the last attempt ended
480
+
481
+ def _condition(self) -> asyncio.Condition:
482
+ if self._cond is None:
483
+ self._cond = asyncio.Condition()
484
+ return self._cond
485
+
486
+ def ticket(self, urgent: bool) -> Tuple[int, int]:
487
+ self._seq += 1
488
+ t = (0 if urgent else 1, self._seq)
489
+ self._tickets.add(t)
490
+ return t
491
+
492
+ async def acquire(self, t: Tuple[int, int]) -> None:
493
+ cond = self._condition()
494
+ async with cond:
495
+ await cond.wait_for(
496
+ lambda: self._busy is None and min(self._tickets) == t)
497
+ self._busy = t
498
+
499
+ async def release(self, t: Tuple[int, int]) -> None:
500
+ cond = self._condition()
501
+ async with cond:
502
+ if self._busy == t:
503
+ self._busy = None
504
+ cond.notify_all()
505
+
506
+ async def done(self, t: Tuple[int, int]) -> None:
507
+ cond = self._condition()
508
+ async with cond:
509
+ self._tickets.discard(t)
510
+ if self._busy == t:
511
+ self._busy = None
512
+ cond.notify_all()
513
+
514
+
515
+ class Ticket(NamedTuple):
516
+ gate: ChannelGate
517
+ ticket: Tuple[int, int]
518
+ created: float # monotonic time the notification happened
519
+
520
+
521
+ # Console lines (Fluidd/Mainsail): what was sent, to which services, and
522
+ # each failed attempt with a few words on why. Failed attempts of one
523
+ # notification with the same cause, at the same attempt, within this many
524
+ # seconds are one line.
525
+ CONSOLE_GROUP = 1.
526
+ CHANNEL_NAMES = {"ntfy": "ntfy", "gotify": "Gotify", "pushover": "Pushover",
527
+ "telegram": "Telegram", "discord": "Discord",
528
+ "email": "email", "webhook": "webhook"}
529
+ CHANNEL_ORDER = list(CHANNEL_NAMES)
530
+ # Which service an Apprise log line is about: the file that logged it
531
+ APPRISE_SOURCES = (("discord.py", "discord"),
532
+ ("email", "email"), ("ntfy", "ntfy"),
533
+ ("gotify", "gotify"), ("pushover", "pushover"),
534
+ ("telegram", "telegram"), ("discord", "discord"),
535
+ ("json", "webhook"))
536
+ SMTP_PHRASES = {421: "server busy", 450: "mailbox busy", 451: "server busy",
537
+ 530: "login required", 534: "login rejected",
538
+ 535: "login rejected", 550: "rejected", 553: "rejected",
539
+ 554: "rejected"}
540
+
541
+
542
+ def short_cause(messages: List[str]) -> str:
543
+ """Why a send failed, in a few words, from what Apprise logged:
544
+ "401 Unauthorized", "timed out", "SSL error", "535 login rejected"."""
545
+ text = " | ".join(messages)
546
+ low = text.lower()
547
+ m = re.search(r"error=(\d{3})\b", text)
548
+ if m:
549
+ code = int(m.group(1))
550
+ try:
551
+ return f"{code} {http.HTTPStatus(code).phrase}"
552
+ except ValueError:
553
+ return f"HTTP {code}"
554
+ m = re.search(r"\((\d{3}), b['\"]", text)
555
+ if m:
556
+ code = int(m.group(1))
557
+ return f"{code} {SMTP_PHRASES.get(code, 'rejected by server')}"
558
+ if "timed out" in low or "timeout" in low:
559
+ return "timed out"
560
+ if "ssl" in low or "certificate" in low:
561
+ return "SSL error"
562
+ if any(k in low for k in ("name or service not known", "name resolution",
563
+ "nodename nor servname", "getaddrinfo",
564
+ "no address associated", "failed to resolve")):
565
+ return "server not found"
566
+ if "connection refused" in low:
567
+ return "connection refused"
568
+ if "connect" in low:
569
+ return "could not connect"
570
+ return "failed"
571
+
572
+
573
+ class AppriseLog(logging.Handler):
574
+ """Apprise's messages per service (debug ones too), to say why a send
575
+ failed. Moonraker's log gets what it got before: Apprise's own logger
576
+ no longer passes records up, and this passes on those at the level it
577
+ logged at (debug lines can hold URLs with tokens)."""
578
+ installed: Optional["AppriseLog"] = None
579
+
580
+ def __init__(self, level: int) -> None:
581
+ super().__init__(logging.DEBUG)
582
+ self.forward_level = level
583
+ self.messages: Dict[str, List[str]] = {}
584
+
585
+ @classmethod
586
+ def install(cls) -> "AppriseLog":
587
+ if cls.installed is None:
588
+ log = logging.getLogger("apprise")
589
+ cls.installed = cls(log.getEffectiveLevel())
590
+ log.addHandler(cls.installed)
591
+ log.setLevel(logging.DEBUG)
592
+ log.propagate = False
593
+ return cls.installed
594
+
595
+ def emit(self, record: logging.LogRecord) -> None:
596
+ if record.levelno >= self.forward_level:
597
+ logging.getLogger().handle(record)
598
+ path = (record.pathname or "").replace("\\", "/")
599
+ where = path.rsplit("/apprise/", 1)[-1] if "/apprise/" in path \
600
+ else os.path.basename(path)
601
+ for key, channel in APPRISE_SOURCES:
602
+ if key in where:
603
+ try:
604
+ msgs = self.messages.setdefault(channel, [])
605
+ msgs.append(record.getMessage()[:500])
606
+ del msgs[:-20]
607
+ except Exception:
608
+ pass
609
+ return
610
+
611
+ def take(self, channel: str) -> List[str]:
612
+ return self.messages.pop(channel, [])
613
+
614
+
615
+ class Destination(NamedTuple):
616
+ channel: str # "ntfy", "pushover", "email", ... (for logs)
617
+ ap: apprise.Apprise
618
+ email: bool
619
+
620
+
621
+ def has_network() -> bool:
622
+ """True if any interface other than loopback has an active route.
623
+
624
+ Read from /proc, so it costs nothing and sends nothing. Unknown (no
625
+ /proc) counts as a network."""
626
+ try:
627
+ with open("/proc/net/route") as f:
628
+ next(f, None)
629
+ for line in f:
630
+ fields = line.split()
631
+ if len(fields) > 3 and fields[0] != "lo" and \
632
+ int(fields[3], 16) & 0x1:
633
+ return True
634
+ return False
635
+ except (OSError, ValueError):
636
+ return True
637
+
638
+
639
+ class PushNotifications:
640
+ def __init__(self, config: ConfigHelper) -> None:
641
+ self.apprise_log = AppriseLog.install()
642
+ # Failed attempts waiting CONSOLE_GROUP to be joined into one line
643
+ self._console_pending: Dict[Tuple[Any, ...], List[str]] = {}
644
+ self.server = config.get_server()
645
+ self.eventloop = self.server.get_event_loop()
646
+ self.name: str = config.get("name", "U1")
647
+ self.snapshot_url: Optional[str] = config.get("snapshot_url", None)
648
+ # "Layer N complete" is reported when layer N + 1 starts
649
+ self.layers_complete: int = config.getint(
650
+ "layers_complete", 2, minval=1, maxval=3)
651
+ # Print-by-object: "layer N complete" for each object, or once
652
+ self.first_layer_objects: str = config.get(
653
+ "first_layer_objects", "each")
654
+ # Which of loaded / unloaded the Filament loaded / unloaded row sends
655
+ self.filament_change_events = {
656
+ e.strip() for e in config.get(
657
+ "filament_change_events", "loaded,unloaded").split(",")}
658
+ self.tz: Optional[datetime.tzinfo] = None
659
+ tz_name = config.get("timezone", "")
660
+ if tz_name:
661
+ try:
662
+ from zoneinfo import ZoneInfo
663
+ self.tz = ZoneInfo(tz_name)
664
+ except Exception as e:
665
+ logging.warning("[push_notifications] time zone %s "
666
+ "unavailable, ETA shown as time left only: %s",
667
+ tz_name, e)
668
+ self.min_level: int = config.getint("min_level", 1, minval=1)
669
+ self.settle_time: float = config.getfloat(
670
+ "settle_time", 3., minval=0.)
671
+ self.repeat_interval: float = config.getfloat(
672
+ "repeat_interval", 60., minval=0.)
673
+ self.spoolman_url: str = config.get("spoolman_url", "").rstrip("/")
674
+ self.filament_low_percent: float = config.getfloat(
675
+ "filament_low_percent", 10., minval=0., maxval=100.)
676
+ self.filament_low_grams: float = config.getfloat(
677
+ "filament_low_grams", 50., minval=0.)
678
+ # Warn at print start about a loaded spool Spoolman shows at 0 g
679
+ # ("amount unknown"); off for people who don't track weights
680
+ self.filament_unknown: bool = config.getboolean(
681
+ "filament_unknown", True)
682
+ # The option is driver_temp: driver_hot is the row
683
+ self.driver_hot: float = config.getfloat(
684
+ "driver_temp", DRIVER_HOT_DEFAULT, minval=40., maxval=150.)
685
+ self._driver_hot_sent = False
686
+ self._filament_job = 0 # bumped per job; stops an old watcher
687
+ self._job_map: List[Any] = [] # extruder_map_table at start
688
+ self._job_tools: Set[int] = set() # slicer filaments the job uses
689
+ self._job_weights: Dict[int, float] = {} # grams per slicer filament
690
+ self._switched_to: Set[int] = set() # channels refilled from
691
+ self._job_ptc: Dict[str, Any] = {} # print_task_config at start
692
+ # Filament still needed is measured, not estimated: the slicer's
693
+ # total for each slicer filament minus what Spoolman says its spools
694
+ # used since the print job started (see _need_now)
695
+ self._job_progress0 = 0. # file progress the watch began at
696
+ self._spool_start: Dict[int, float] = {} # spool id -> g at start
697
+ self._spool_now: Dict[int, float] = {} # spool id -> g, latest
698
+ self._tool_spools: Dict[int, Set[int]] = {} # slicer filament -> spools
699
+ # Email thread of the current print: {"id", "file"}; kept across
700
+ # restarts so a power-loss recovery continues the same thread
701
+ self._thread: Optional[Dict[str, str]] = None
702
+ self._job_ended = -1e9 # monotonic time the last job finished
703
+ # Bumped when a job starts or ends: an await that outlives its job
704
+ # (seeding, the filament watch) checks it and gives up
705
+ self._job_gen = 0
706
+ # Klipper keeps print_stats.exception after a fault-pause is
707
+ # resumed; the fault is over, and must not colour a later pause or
708
+ # cancel. A new fault arrives as an exception event and clears this.
709
+ self._stale_fault: Optional[FaultKey] = None
710
+ self._pl_file = "" # file of the print a power loss stopped
711
+ # Spools already reported as running low (spool id -> time)
712
+ self._low_sent: Optional[Dict[str, float]] = None
713
+ try:
714
+ data_path = self.server.get_app_args().get("data_path") or ""
715
+ except Exception:
716
+ data_path = ""
717
+ data_path = data_path or "/home/lava/printer_data"
718
+ self._low_path = os.path.join(data_path, ".push_notifications.json")
719
+ self.web_ui_url: str = config.get("web_ui_url", "")
720
+ self.camera_url: str = config.get("camera_url", "")
721
+ self.rows: Dict[str, Tuple[str, List[Destination]]] = {}
722
+ for rid in ROW_IDS:
723
+ image = ""
724
+ dests: List[Destination] = []
725
+ for key, email in ((rid, False), (f"{rid}_email", True),
726
+ (f"{rid}_webhook", False)):
727
+ value = self._option(config, key)
728
+ if not value:
729
+ continue
730
+ kind, _, url = value.partition(",")
731
+ kind, url = kind.strip(), url.strip()
732
+ if kind not in IMAGE_KINDS or not url:
733
+ raise config.error(
734
+ f"[{config.get_name()}] {key}: expected "
735
+ "'<render|snapshot|spool|none>, <url>'")
736
+ ap = apprise.Apprise()
737
+ if not ap.add(url):
738
+ raise config.error(f"[{config.get_name()}] {key}: "
739
+ "invalid notification URL")
740
+ for srv in ap:
741
+ srv.socket_read_timeout = max(
742
+ srv.socket_read_timeout, SEND_READ_TIMEOUT)
743
+ srv.socket_connect_timeout = max(
744
+ srv.socket_connect_timeout, SEND_CONNECT_TIMEOUT)
745
+ channel = "email" if email else url.split(":", 1)[0]
746
+ channel = {"ntfys": "ntfy", "pover": "pushover",
747
+ "gotifys": "gotify",
748
+ "tgram": "telegram", "discordhook": "discord",
749
+ "discorddm": "discord", "json": "webhook",
750
+ "jsons": "webhook"}.get(
751
+ channel, channel)
752
+ image = image or kind
753
+ dests.append(Destination(channel, ap, email))
754
+ if dests:
755
+ self.rows[rid] = (image, dests)
756
+
757
+ self._tasks: Set[asyncio.Task] = set()
758
+ # Faults already reported: key -> monotonic time
759
+ self._faults_sent: Dict[FaultKey, float] = {}
760
+ # Send order per channel (see ChannelGate); channels are independent
761
+ self._gates: Dict[str, ChannelGate] = {}
762
+ self._faults_pending: Set[FaultKey] = set()
763
+ self._last_fault_send = 0.
764
+ self._ready_once = False
765
+ # Per-job state
766
+ self._job_active = False
767
+ self._job_done_sent = False
768
+ self._first_layer_sent = False
769
+ self._layer_started_sent = False
770
+ self._milestones_sent: Set[int] = set()
771
+ self._gcode_bytes: Tuple[Any, Any] = (None, None)
772
+ self._job_filename = ""
773
+ # Print-by-object: the object being printed, the layer each began
774
+ # on, and the objects already reported
775
+ self._by_object = False
776
+ # Pauses in the print job's file (see _scan_pauses) and the ones
777
+ # already reported, by index
778
+ self._job_pauses: List[Dict[str, Any]] = []
779
+ self._pauses_seen: Set[int] = set()
780
+ self._obj_cur: Optional[str] = None
781
+ self._obj_start: Dict[str, int] = {}
782
+ self._obj_first_sent: Set[str] = set()
783
+ self._status: Dict[str, Dict[str, Any]] = {
784
+ "print_stats": {}, "virtual_sdcard": {}, "print_task_config": {},
785
+ "exclude_object": {}, "machine_state_manager": {},
786
+ "idle_timeout": {}, ACE_OBJECT: {},
787
+ **{d: {} for d in DRIVERS}, **{e: {} for e in EXTRUDERS}}
788
+ self._cal_busy_at = 0. # when Klipper was last seen busy
789
+ # Filament loaded / unloaded: last reported filament_exist per
790
+ # toolhead (None until seeded at klippy_ready), the filament each
791
+ # toolhead last had (to name what was unloaded), pending changes
792
+ self._exist_seen: Optional[List[bool]] = None
793
+ self._exist_names: Dict[int, str] = {}
794
+ self._exist_gen = 0
795
+ # Chamber: the printer's heater_generic objects, whether it has
796
+ # Snapmaker's chamber sensor / purifier / a DragonBreath (found at
797
+ # klippy_ready), and this job's chamber state (see _track_chamber)
798
+ self._ch_heaters: List[str] = []
799
+ self._ch_sensor = False
800
+ self._ch_purifier = False
801
+ self._ch_device = False
802
+ self._ch: Dict[str, Any] = {}
803
+ self._calibrating: Optional[int] = None # main_state of a running one
804
+ self._refills = 0 # refills from a backup spool seen so far
805
+ # multiACE (see _track_ace): found at klippy_ready; the last
806
+ # head_source seen; heads with a runout being held; slots already
807
+ # reported empty this job
808
+ self._ace_on = False
809
+ self._ace_src: Dict[str, Any] = {}
810
+ self._ace_runouts: Set[int] = set()
811
+ self._slot_empty_sent: Set[Tuple[int, int]] = set()
812
+ self._cal_action: Optional[int] = None # its last action_code
813
+ self._cal_hold = 0 # bumps on every action change
814
+
815
+ self.server.register_event_handler(
816
+ "job_state:state_changed", self._on_job_event)
817
+ self.server.register_event_handler(
818
+ EXCEPTION_EVENT, self._on_exception)
819
+ self.server.register_event_handler(
820
+ "server:klippy_shutdown", self._on_klippy_shutdown)
821
+ self.server.register_event_handler(
822
+ "server:klippy_ready", self._on_klippy_ready)
823
+ self.server.register_remote_method(REMOTE_METHOD, self._on_remote)
824
+ # RESPOND PREFIX=notify MSG="title || message || priority"
825
+ self.macro_priority: str = config.get("macro_priority", "default")
826
+ self.server.register_event_handler(
827
+ "server:gcode_response", self._on_gcode_response)
828
+ logging.info("[push_notifications] enabled: %s", ", ".join(
829
+ f"{rid} ({'+'.join(d.channel for d in dests)})"
830
+ for rid, (_, dests) in self.rows.items()) or "none")
831
+
832
+ # ---- helpers -------------------------------------------------------
833
+
834
+ @staticmethod
835
+ def _option(config: ConfigHelper, key: str) -> Optional[str]:
836
+ """An option as a template: the URLs refer to their tokens and
837
+ passwords as {secrets.notifications.<name>}, kept in Moonraker's
838
+ secrets file (never served or logged) by notifications-config.py."""
839
+ if config.get(key, None) is None:
840
+ return None
841
+ try:
842
+ return str(config.gettemplate(key).render()).strip()
843
+ except Exception as e:
844
+ raise config.error(f"[{config.get_name()}] {key}: {e}")
845
+
846
+
847
+ def _spawn(self, coro: Awaitable) -> None:
848
+ task = self.eventloop.create_task(coro)
849
+ self._tasks.add(task)
850
+
851
+ def _done(t: asyncio.Task) -> None:
852
+ self._tasks.discard(t)
853
+ if not t.cancelled() and t.exception() is not None:
854
+ logging.error("[push_notifications] task failed: %s",
855
+ t.exception(), exc_info=t.exception())
856
+ task.add_done_callback(_done)
857
+
858
+ async def _query(self, objects: Dict[str, Any]) -> Dict[str, Any]:
859
+ kapis: APIComp = self.server.lookup_component("klippy_apis")
860
+ try:
861
+ return await kapis.query_objects(objects, default={})
862
+ except Exception as e:
863
+ logging.error("[push_notifications] state query failed: %s", e)
864
+ return {}
865
+
866
+ def _klippy_shutdown(self) -> bool:
867
+ try:
868
+ kconn = self.server.lookup_component("klippy_connection")
869
+ return kconn.state == KlippyState.SHUTDOWN
870
+ except Exception:
871
+ return False
872
+
873
+ def _live_fault(self, ps: Dict[str, Any]) -> Optional[Dict[str, Any]]:
874
+ """The fault in print_stats, unless it's the one a resume already
875
+ put behind us (Klipper doesn't clear it on RESUME)."""
876
+ ex = coded(ps)
877
+ if ex is not None and fault_key(ex) == self._stale_fault:
878
+ return None
879
+ return ex
880
+
881
+ def _fault_recent(self, key: FaultKey) -> bool:
882
+ last = self._faults_sent.get(key)
883
+ return last is not None and \
884
+ time.monotonic() - last < self.repeat_interval
885
+
886
+ def _mark_fault(self, key: FaultKey) -> None:
887
+ self._faults_sent[key] = time.monotonic()
888
+ self._last_fault_send = time.monotonic()
889
+
890
+ def _fault_row(self) -> Optional[str]:
891
+ """Where a fault that ended a print is reported."""
892
+ if "error" in self.rows:
893
+ return "error"
894
+ if "cancelled" in self.rows:
895
+ return "cancelled"
896
+ return None
897
+
898
+ def _fetch_snapshot_sync(self, quiet: bool = False) -> Optional[str]:
899
+ if not self.snapshot_url:
900
+ return None
901
+ try:
902
+ with urllib.request.urlopen(
903
+ self.snapshot_url, timeout=SNAPSHOT_TIMEOUT) as resp:
904
+ data = resp.read(SNAPSHOT_MAX + 1)
905
+ except Exception as e:
906
+ if not quiet:
907
+ logging.warning("[push_notifications] no snapshot: %s", e)
908
+ return None
909
+ if not data.startswith(b"\xff\xd8") or len(data) > SNAPSHOT_MAX:
910
+ logging.warning("[push_notifications] snapshot is not a JPEG")
911
+ return None
912
+ tmpdir = tempfile.mkdtemp(prefix="push_notifications_")
913
+ path = os.path.join(tmpdir, "snapshot.jpg")
914
+ try:
915
+ with open(path, "wb") as f:
916
+ f.write(data)
917
+ except OSError as e:
918
+ shutil.rmtree(tmpdir, ignore_errors=True)
919
+ logging.warning("[push_notifications] snapshot not saved: %s", e)
920
+ return None
921
+ return path
922
+
923
+ async def _fetch_snapshot(self, quiet: bool = False) -> Optional[str]:
924
+ return await self.eventloop.run_in_thread(
925
+ self._fetch_snapshot_sync, quiet)
926
+
927
+ async def _wait_for_snapshot(self, timeout: float) -> Optional[str]:
928
+ """Snapshot, waiting up to `timeout` for the camera to come up."""
929
+ deadline = time.monotonic() + timeout
930
+ while True:
931
+ last = time.monotonic() + PL_CAMERA_POLL >= deadline
932
+ path = await self._fetch_snapshot(quiet=not last)
933
+ if path is not None or last:
934
+ return path
935
+ await asyncio.sleep(PL_CAMERA_POLL)
936
+
937
+ def _thumbnail(self, filename: str) -> Optional[str]:
938
+ try:
939
+ fm = self.server.lookup_component("file_manager")
940
+ meta = fm.get_metadata_storage().get(filename, {}) or {}
941
+ root = fm.get_directory("gcodes")
942
+ except Exception as e:
943
+ logging.warning("[push_notifications] metadata lookup failed: %s",
944
+ e)
945
+ return None
946
+ thumbs = [t for t in meta.get("thumbnails") or []
947
+ if t.get("relative_path")]
948
+ if not thumbs:
949
+ return None
950
+ best = max(thumbs, key=lambda t: t.get("width", 0) or 0)
951
+ rel = best["relative_path"]
952
+ for path in (os.path.join(root, os.path.dirname(filename), rel),
953
+ os.path.join(root, rel)):
954
+ if os.path.isfile(path):
955
+ return path
956
+ return None
957
+
958
+ async def _send(
959
+ self, rid: str, title: str, body: str,
960
+ filename: str = "", snapshot: Optional[str] = None,
961
+ snapshot_ready: bool = False, delay: float = 0.,
962
+ camera_wait: float = 0.,
963
+ ) -> bool:
964
+ """Send one notification to each of its destinations; images are
965
+ optional and never block it.
966
+
967
+ `delay` postpones the snapshot (e.g. until the head has moved away
968
+ after a pause). Each channel sends one notification at a time, in
969
+ the order they happened, so one that is retrying or delayed is not
970
+ overtaken (e.g. resumed arriving before paused). `camera_wait`
971
+ gives the camera that long to come up (e.g. right after boot).
972
+ """
973
+ row = self.rows.get(rid)
974
+ if row is None:
975
+ return False
976
+ image, dests = row
977
+ # Take a place in each channel's queue now, before any waiting
978
+ tickets = [self._reserve(d.channel, rid) for d in dests]
979
+ try:
980
+ if delay:
981
+ await asyncio.sleep(delay)
982
+ if camera_wait and image == "snapshot" and not snapshot_ready:
983
+ path = await self._wait_for_snapshot(camera_wait)
984
+ else:
985
+ path = await self._image(image, filename, snapshot,
986
+ snapshot_ready)
987
+ named = self._named_copy(path, title, filename)
988
+ report: List[Dict[str, Any]] = []
989
+ try:
990
+ results = self._outcomes(rid, dests, await asyncio.gather(*(
991
+ self._deliver(rid, d, t, [(title, body, named)], report)
992
+ for d, t in zip(dests, tickets)), return_exceptions=True))
993
+ self._console_report(report)
994
+ finally:
995
+ if named is not None and named != path:
996
+ shutil.rmtree(os.path.dirname(named), ignore_errors=True)
997
+ if path is not None and image == "snapshot":
998
+ shutil.rmtree(os.path.dirname(path), ignore_errors=True)
999
+ finally:
1000
+ for t in tickets:
1001
+ await t.gate.done(t.ticket)
1002
+ return all(results)
1003
+
1004
+ @staticmethod
1005
+ def _outcomes(rid: str, dests: List[Destination],
1006
+ results: List[Any]) -> List[bool]:
1007
+ """gather(return_exceptions=True) hands back exception objects,
1008
+ which are truthy: a delivery that crashed is logged and counts as
1009
+ failed, not sent."""
1010
+ out = []
1011
+ for d, r in zip(dests, results):
1012
+ if isinstance(r, BaseException):
1013
+ logging.error("[push_notifications] %s (%s): send crashed: "
1014
+ "%r", rid, d.channel, r, exc_info=r)
1015
+ out.append(False)
1016
+ else:
1017
+ out.append(bool(r))
1018
+ return out
1019
+
1020
+ def _image_name(self, title: str, filename: str, ext: str) -> str:
1021
+ """File name for a notification's picture, so saved or exported
1022
+ images say what they were: time, printer, notification, file, e.g.
1023
+ 2026-10-22_17-05-03_U1-martha_50pct_Benchy.jpg. Letters, digits,
1024
+ . _ - only (it is also the email's inline image reference)."""
1025
+ now = datetime.datetime.now(self.tz) if self.tz else \
1026
+ datetime.datetime.now()
1027
+ # The notification as its title says it, without the emoji and the
1028
+ # printer's name ("⏳ U1-martha 50%" -> "50%")
1029
+ event = title.split(" ", 1)[1] if " " in title else title
1030
+ if event.startswith(self.name + " "):
1031
+ event = event[len(self.name) + 1:]
1032
+ event = event.replace("%", "pct")
1033
+
1034
+ def clean(text: str, cap: int) -> str:
1035
+ text = re.sub(r"[^A-Za-z0-9._-]+", "-", text).strip("-.")
1036
+ return text[:cap].rstrip("-.")
1037
+ parts = [now.strftime("%Y-%m-%d_%H-%M-%S"), clean(self.name, 32),
1038
+ clean(event, 48), clean(shown(filename), 64)]
1039
+ return "_".join(p for p in parts if p) + ext
1040
+
1041
+ def _named_copy(
1042
+ self, path: Optional[str], title: str, filename: str = ""
1043
+ ) -> Optional[str]:
1044
+ """A copy of the picture under its descriptive name, in its own
1045
+ temporary folder (the original may be a Moonraker thumbnail, or
1046
+ shared with another notification). The original on failure."""
1047
+ if path is None:
1048
+ return None
1049
+ try:
1050
+ name = self._image_name(title, filename or self._job_filename,
1051
+ os.path.splitext(path)[1] or ".jpg")
1052
+ tmpdir = tempfile.mkdtemp(prefix="push_notifications_")
1053
+ dest = os.path.join(tmpdir, name)
1054
+ shutil.copyfile(path, dest)
1055
+ return dest
1056
+ except Exception as e:
1057
+ logging.warning("[push_notifications] image name: %s", e)
1058
+ return path
1059
+
1060
+ def _reserve(self, channel: str, rid: str) -> Ticket:
1061
+ """Place in a channel's send order, taken when the notification
1062
+ happens."""
1063
+ gate = self._gates.setdefault(channel, ChannelGate())
1064
+ return Ticket(gate, gate.ticket(rid in URGENT_ROWS), time.monotonic())
1065
+
1066
+ def _prepare(
1067
+ self, rid: str, dest: Destination, title: str, body: str,
1068
+ path: Optional[str]
1069
+ ) -> Tuple[str, str, Dict[str, str]]:
1070
+ """Title, text and (email) headers for one destination: the same for
1071
+ every kind of notification. Email: HTML card and thread headers;
1072
+ Telegram: escaped HTML and named links; Discord: escaped Markdown and
1073
+ links."""
1074
+ if dest.email:
1075
+ return self._email_prepare(rid, title, body, path)
1076
+ if dest.channel == "telegram":
1077
+ title, body = self._telegram_prepare(rid, title, body)
1078
+ elif dest.channel == "gotify":
1079
+ body = self._gotify_markdown(body.strip() or title)
1080
+ links = " Β· ".join(f"[{self._md_escape(t)}]({u})"
1081
+ for t, u in self._links(rid))
1082
+ if links:
1083
+ body += "\n\n" + links
1084
+ elif dest.channel == "discord" and discord is not None:
1085
+ body = discord.escape(body.strip() or title)
1086
+ links = " Β· ".join(discord.link(t, u) for t, u in self._links(rid))
1087
+ if links:
1088
+ body += "\n\n" + links
1089
+ return title, body, {}
1090
+
1091
+ @staticmethod
1092
+ def _md_escape(text: str) -> str:
1093
+ return re.sub(r"([\\`*_\[\]<>#|~])", r"\\\1", text)
1094
+
1095
+ def _gotify_markdown(self, text: str) -> str:
1096
+ """Gotify shows Markdown: escape it, and keep line breaks (a single
1097
+ newline is a space in Markdown)."""
1098
+ return "\n\n".join(
1099
+ " \n".join(self._md_escape(line) for line in para.split("\n"))
1100
+ for para in text.split("\n\n"))
1101
+
1102
+ def _email_prepare(
1103
+ self, rid: str, title: str, body: str, path: Optional[str] = None
1104
+ ) -> Tuple[str, str, Dict[str, str]]:
1105
+ """Emails about one print go in one thread: the same subject
1106
+ (printer and file name; the notification's title moves into the
1107
+ email) and References to the same thread id. Others keep their
1108
+ title as subject. Sent as an HTML card (push_notifications_email)
1109
+ with the image inline. The headers are set right before each attempt
1110
+ (the channel sends one at a time)."""
1111
+ thread = self._thread_for(rid)
1112
+ headers: Dict[str, str] = {}
1113
+ # Gmail hides text a thread already showed ("β€’β€’β€’"), so a repeated
1114
+ # notification (same fault twice) would look empty: the time at the
1115
+ # top and bottom keeps every email distinct.
1116
+ now = datetime.datetime.now(self.tz) if self.tz else \
1117
+ datetime.datetime.now()
1118
+ subject = title
1119
+ if thread is not None:
1120
+ subject = f"{self.name}: {shown(thread['file'])}"
1121
+ ref = f"<{thread['id']}@push-notifications.u1>"
1122
+ headers = {"In-Reply-To": ref, "References": ref}
1123
+ text = body.strip() or title
1124
+ if email_html is not None:
1125
+ page = email_html.render(
1126
+ title, text, self._links(rid), NOTIFY_TYPES.get(rid, "info"),
1127
+ os.path.basename(path) if path else None, now, self.name)
1128
+ return subject, page, headers
1129
+ body = self._lines(text, "", *(f"{t}: {u}" for t, u in
1130
+ self._links(rid)))
1131
+ if thread is not None:
1132
+ body = self._lines(f"{title} Β· {now:%H:%M}", "", body, "",
1133
+ f"Sent {now:%Y-%m-%d %H:%M:%S}")
1134
+ return subject, body, headers
1135
+
1136
+ def _thread_for(self, rid: str) -> Optional[Dict[str, str]]:
1137
+ self._low_load() # restores the thread after a restart
1138
+ th = self._thread
1139
+ if th is None:
1140
+ return None
1141
+ if rid in THREAD_ROWS:
1142
+ return th
1143
+ if rid == "power_loss":
1144
+ return th if self._pl_file and \
1145
+ self._pl_file == os.path.basename(th["file"]) else None
1146
+ # Faults: while printing, or right as the print ended
1147
+ if self._job_active or (
1148
+ time.monotonic() - self._job_ended < THREAD_FAULT_WINDOW):
1149
+ return th
1150
+ return None
1151
+
1152
+ def _thread_start(self, filename: str, recovered: bool) -> None:
1153
+ self._low_load()
1154
+ if recovered and self._thread and self._thread["file"] == filename:
1155
+ return # power-loss recovery: same thread
1156
+ self._thread = {"id": f"{int(time.time())}.{os.getpid()}."
1157
+ f"{abs(hash(filename)) % 100000}",
1158
+ "file": filename}
1159
+ self._low_save()
1160
+
1161
+ def _telegram_prepare(
1162
+ self, rid: str, title: str, body: str
1163
+ ) -> Tuple[str, str]:
1164
+ """Telegram is sent as HTML: the text is escaped, and the links
1165
+ are added as named links. Apprise joins blank lines in HTML, so a
1166
+ blank line is kept as a line with a space."""
1167
+ def esc(t: str) -> str:
1168
+ return html.escape(t, quote=False)
1169
+ text = esc(body.strip() or title)
1170
+ links = " Β· ".join(f'<a href="{html.escape(u)}">{esc(t)}</a>'
1171
+ for t, u in self._links(rid))
1172
+ if links:
1173
+ text += "\n\n" + links
1174
+ return esc(title), text.replace("\n\n", "\n \n")
1175
+
1176
+ def _links(self, rid: str) -> List[Tuple[str, str]]:
1177
+ """(name, url) of the links added to emails and Telegram."""
1178
+ out = []
1179
+ if self.web_ui_url:
1180
+ out.append(("Web UI", self.web_ui_url))
1181
+ if self.camera_url:
1182
+ out.append(("Live Camera", self.camera_url))
1183
+ if rid in FAULT_ROWS:
1184
+ out.append(("Error Index", ERROR_INDEX_URL))
1185
+ return out
1186
+
1187
+ async def _image(
1188
+ self, image: str, filename: str, snapshot: Optional[str],
1189
+ snapshot_ready: bool
1190
+ ) -> Optional[str]:
1191
+ if image == "snapshot":
1192
+ return snapshot if snapshot_ready else await self._fetch_snapshot()
1193
+ if image == "render" and filename:
1194
+ return self._thumbnail(filename)
1195
+ return None
1196
+
1197
+ async def _wait_network(self, rid: str, title: str = "") -> None:
1198
+ if has_network():
1199
+ return
1200
+ logging.info("[push_notifications] %s: no network, waiting up to "
1201
+ "%ds", rid, NETWORK_WAIT)
1202
+ if title:
1203
+ self._console_join(("wait", title), "")
1204
+ deadline = time.monotonic() + NETWORK_WAIT
1205
+ while time.monotonic() < deadline:
1206
+ await asyncio.sleep(NETWORK_POLL)
1207
+ if has_network():
1208
+ logging.info("[push_notifications] %s: network is up", rid)
1209
+ return
1210
+ logging.warning("[push_notifications] %s: still no network, "
1211
+ "trying anyway", rid)
1212
+
1213
+ async def _deliver(
1214
+ self, rid: str, dest: Destination, t: Ticket,
1215
+ msgs: List[Tuple[str, str, Optional[str]]],
1216
+ report: Optional[List[Dict[str, Any]]] = None
1217
+ ) -> bool:
1218
+ """Send one or more messages (title, text, image) to one
1219
+ destination, in its channel's turn. Doesn't remove the images
1220
+ (shared by destinations). Each outcome goes in `report`, for the
1221
+ console."""
1222
+ ok = True
1223
+ for shown_title, body, path in msgs:
1224
+ title, body, headers = self._prepare(rid, dest, shown_title,
1225
+ body, path)
1226
+ result: Dict[str, Any] = dict(title=shown_title,
1227
+ channel=dest.channel)
1228
+ try:
1229
+ ok = await self._attempts(rid, dest, t, title, body, headers,
1230
+ path, result) and ok
1231
+ except Exception as e:
1232
+ # Reported on the console as a failure too (the caller
1233
+ # logs it with its traceback)
1234
+ result.update(ok=False, cause=f"error: {type(e).__name__}",
1235
+ attempts=result.get("attempts")
1236
+ or 1 + len(SEND_RETRY_DELAYS))
1237
+ if report is not None:
1238
+ report.append(result)
1239
+ raise
1240
+ if report is not None:
1241
+ report.append(result)
1242
+ return ok
1243
+
1244
+ # -- console --
1245
+
1246
+ def _console(self, line: str) -> None:
1247
+ try:
1248
+ self.server.send_event("server:gcode_response", line)
1249
+ except Exception as e:
1250
+ logging.debug("[push_notifications] console: %s", e)
1251
+
1252
+ @staticmethod
1253
+ def _services(channels: List[str]) -> str:
1254
+ seen = sorted(set(channels), key=lambda c: CHANNEL_ORDER.index(c)
1255
+ if c in CHANNEL_ORDER else len(CHANNEL_ORDER))
1256
+ return ", ".join(CHANNEL_NAMES.get(c, c) for c in seen)
1257
+
1258
+ def _console_join(self, key: Tuple[Any, ...], channel: str) -> None:
1259
+ """A line that others like it (same notification, cause, attempt)
1260
+ may join for CONSOLE_GROUP seconds before it's printed."""
1261
+ first = key not in self._console_pending
1262
+ self._console_pending.setdefault(key, []).append(channel)
1263
+ if first:
1264
+ self._spawn(self._console_flush(key))
1265
+
1266
+ async def _console_flush(self, key: Tuple[Any, ...]) -> None:
1267
+ await asyncio.sleep(CONSOLE_GROUP)
1268
+ channels = self._console_pending.pop(key, [])
1269
+ if key[0] == "wait":
1270
+ self._console(f"Waiting up to {NETWORK_WAIT / 60:.0f} minutes "
1271
+ f"for network to return, to send notification "
1272
+ f"\"{key[1]}\"")
1273
+ return
1274
+ _kind, title, cause, attempt, attempts, retry = key
1275
+ self._console(f"!! Failed to send notification \"{title}\" to "
1276
+ f"{self._services(channels)} ({cause}), attempt "
1277
+ f"{attempt}/{attempts}, will retry in {retry} seconds")
1278
+
1279
+ def _console_report(self, report: List[Dict[str, Any]]) -> None:
1280
+ """After a notification's sends: what went where, then what
1281
+ failed for good (one line per cause)."""
1282
+ titles: List[str] = []
1283
+ for r in report:
1284
+ if r["title"] not in titles:
1285
+ titles.append(r["title"])
1286
+ for title in titles:
1287
+ mine = [r for r in report if r["title"] == title]
1288
+ sent = [r for r in mine if r.get("ok")]
1289
+ if sent:
1290
+ pic = [r["channel"] for r in sent if r.get("picture")]
1291
+ text = [r["channel"] for r in sent if not r.get("picture")]
1292
+ parts = []
1293
+ if pic:
1294
+ parts.append(f"{self._services(pic)}, with picture")
1295
+ if text:
1296
+ parts.append(self._services(text))
1297
+ self._console(f"Sent notification \"{title}\" to "
1298
+ + ", and to ".join(parts))
1299
+ causes: Dict[Tuple[str, int], List[str]] = {}
1300
+ for r in mine:
1301
+ if not r.get("ok") and r.get("attempts"):
1302
+ causes.setdefault((r.get("cause") or "failed",
1303
+ r["attempts"]), []).append(r["channel"])
1304
+ for (cause, n), channels in causes.items():
1305
+ self._console(f"!! Failed to send notification \"{title}\" "
1306
+ f"to {self._services(channels)} ({cause}), "
1307
+ f"attempt {n}/{n}")
1308
+
1309
+ def _expired(self, rid: str, t: Ticket) -> bool:
1310
+ return rid in EXPIRING_ROWS and \
1311
+ time.monotonic() - t.created > STATUS_EXPIRY
1312
+
1313
+ async def _attempts(
1314
+ self, rid: str, dest: Destination, t: Ticket, title: str, body: str,
1315
+ headers: Dict[str, str], path: Optional[str],
1316
+ result: Optional[Dict[str, Any]] = None
1317
+ ) -> bool:
1318
+ """One message with retries. The channel is busy only during an
1319
+ attempt, but this notification's ticket keeps the channel's later
1320
+ (non-fault) notifications waiting until it is sent or given up,
1321
+ so they arrive in order; a fault can go ahead between attempts."""
1322
+ what = f"{rid} ({dest.channel})"
1323
+ ap = dest.ap
1324
+ body = body.strip() or title
1325
+ if dest.channel == "gotify":
1326
+ path = None # Gotify messages can't carry attachments
1327
+ ok = False
1328
+ attempts = 1 + len(SEND_RETRY_DELAYS)
1329
+ kind = NOTIFY_TYPES.get(rid, "info")
1330
+ result = {} if result is None else result
1331
+ shown_title = result.get("title", title)
1332
+ pictured = False
1333
+ for attempt in range(attempts):
1334
+ if attempt:
1335
+ await asyncio.sleep(SEND_RETRY_DELAYS[attempt - 1])
1336
+ await self._wait_network(what, shown_title)
1337
+ await t.gate.acquire(t.ticket)
1338
+ try:
1339
+ if self._expired(rid, t):
1340
+ self._skip(what, "too old (sent more than %d min after "
1341
+ "it happened)" % (STATUS_EXPIRY // 60))
1342
+ return False
1343
+ gap = SEND_GAP.get(dest.channel, 0.)
1344
+ if gap:
1345
+ # Holding the channel: the next one waits behind this
1346
+ wait = t.gate.last_sent + gap - time.monotonic()
1347
+ if wait > 0:
1348
+ await asyncio.sleep(wait)
1349
+ if dest.email:
1350
+ # Thread headers for email only: a webhook's own
1351
+ # +Header parameters would be wiped
1352
+ for srv in ap:
1353
+ if hasattr(srv, "headers"):
1354
+ srv.headers = dict(headers)
1355
+ # Each attempt waits for the server's answer before the next
1356
+ # is scheduled. The last one is text only, in case the image
1357
+ # is what the server rejects (e.g. no attachments allowed).
1358
+ self.apprise_log.take(dest.channel)
1359
+ if attempt < attempts - 1 or path is None:
1360
+ pictured = path is not None
1361
+ ok = await ap.async_notify(
1362
+ body=body, title=title, attach=path, notify_type=kind)
1363
+ else:
1364
+ pictured = False
1365
+ if email_html is not None and email_html.is_page(body):
1366
+ last = email_html.without_image(body)
1367
+ else:
1368
+ last = body + "\n(image could not be attached)"
1369
+ ok = await ap.async_notify(
1370
+ body=last, title=title, notify_type=kind)
1371
+ finally:
1372
+ t.gate.last_sent = time.monotonic()
1373
+ await t.gate.release(t.ticket)
1374
+ if ok:
1375
+ break
1376
+ cause = short_cause(self.apprise_log.take(dest.channel))
1377
+ result.update(cause=cause, attempts=attempts)
1378
+ if attempt < attempts - 1:
1379
+ self._console_join(
1380
+ ("fail", shown_title, cause, attempt + 1, attempts,
1381
+ SEND_RETRY_DELAYS[attempt]), dest.channel)
1382
+ logging.warning(
1383
+ "[push_notifications] %s: attempt %d failed, "
1384
+ "retrying in %ds%s", what, attempt + 1,
1385
+ SEND_RETRY_DELAYS[attempt],
1386
+ " without the image"
1387
+ if attempt == attempts - 2 and path is not None
1388
+ else "")
1389
+ result.update(ok=bool(ok), picture=bool(ok) and pictured)
1390
+ if ok:
1391
+ logging.info("[push_notifications] %s: sent", what)
1392
+ else:
1393
+ logging.error("[push_notifications] %s: send failed after %d "
1394
+ "attempts", what, attempts)
1395
+ return bool(ok)
1396
+
1397
+ @staticmethod
1398
+ def _skip(what: str, why: str) -> None:
1399
+ logging.info("[push_notifications] %s: not sent, %s", what, why)
1400
+
1401
+ def _clock(self, seconds_from_now: float) -> str:
1402
+ if self.tz is None:
1403
+ return ""
1404
+ now = datetime.datetime.now(self.tz)
1405
+ at = now + datetime.timedelta(seconds=seconds_from_now)
1406
+ fmt = "%H:%M" if at.date() == now.date() else "%a %H:%M"
1407
+ return at.strftime(fmt)
1408
+
1409
+ def _time_left(self) -> str:
1410
+ """Time left like Fluidd's default ("file") estimate:
1411
+ print time / file progress - print time. Snapmaker's power-loss
1412
+ recovery keeps the print time, so this holds for recovered jobs
1413
+ too. Early in the job that ratio is far off (heating, purge and the
1414
+ slow first layer are most of the time so far), so until
1415
+ EARLY_PROGRESS the slicer's estimate minus the time printed is used
1416
+ when there is one."""
1417
+ pd = float(self._status["print_stats"].get("print_duration") or 0.)
1418
+ progress = self._progress()
1419
+ if progress <= 0. or progress >= 1.:
1420
+ return ""
1421
+ est = self._estimated_time(self._job_filename)
1422
+ if est > 0. and (pd <= 0. or progress < EARLY_PROGRESS):
1423
+ left = max(est - pd, est * (1. - progress) * 0.5)
1424
+ elif pd > 0.:
1425
+ left = pd / progress - pd
1426
+ else:
1427
+ return ""
1428
+ clock = self._clock(left)
1429
+ # "~": time left is an estimate (the slicer estimate is labelled)
1430
+ return f"~{format_duration(left)} left" + (f", ETA {clock}" if clock
1431
+ else "")
1432
+
1433
+ def _estimated_time(self, filename: str) -> float:
1434
+ try:
1435
+ fm = self.server.lookup_component("file_manager")
1436
+ meta = fm.get_metadata_storage().get(filename, {}) or {}
1437
+ return float(meta.get("estimated_time") or 0.)
1438
+ except Exception:
1439
+ return 0.
1440
+
1441
+ def _slicer_estimate(self, filename: str) -> str:
1442
+ est = self._estimated_time(filename)
1443
+ if est <= 0.:
1444
+ return ""
1445
+ clock = self._clock(est)
1446
+ return f"Estimated print time {format_duration(est)}" + (
1447
+ f", ETA {clock}" if clock else "")
1448
+
1449
+ @staticmethod
1450
+ def _lines(*parts: str) -> str:
1451
+ return "\n".join(p for p in parts if p)
1452
+
1453
+ def _title(self, key: str, **kw: Any) -> str:
1454
+ return TITLES[key].format(name=self.name, **kw)
1455
+
1456
+ # ---- job events ----------------------------------------------------
1457
+
1458
+ async def _on_job_event(
1459
+ self, job_event: JobEvent, prev_stats: Dict[str, Any],
1460
+ new_stats: Dict[str, Any]
1461
+ ) -> None:
1462
+ ps = new_stats
1463
+ filename = ps.get("filename") or ""
1464
+ ex = self._live_fault(ps)
1465
+ if job_event == JobEvent.STARTED:
1466
+ self._stale_fault = None
1467
+ self._job_gen += 1
1468
+ gen = self._job_gen
1469
+ progress = await self._start_job(filename)
1470
+ if gen != self._job_gen:
1471
+ self._job_active = False # it ended while being seeded
1472
+ return
1473
+ # Snapmaker resumes a power-loss print as a new job, part-way
1474
+ # into the file with its print time kept. A small file's start
1475
+ # G-code alone is past 1 %, so the print time decides there.
1476
+ recovered = progress >= RECOVERY_MIN_PROGRESS and (
1477
+ float(ps.get("print_duration") or 0.) > RECOVERY_MIN_TIME
1478
+ or progress >= RECOVERY_SURE_PROGRESS)
1479
+ self._thread_start(filename, recovered)
1480
+ if recovered:
1481
+ # Snapmaker resumes a power-loss print as a new job
1482
+ self._spawn(self._send(
1483
+ "resumed", self._title("recovered"),
1484
+ self._lines(shown(filename), self._time_left())))
1485
+ else:
1486
+ self._spawn(self._send(
1487
+ "started", self._title("started"),
1488
+ self._lines(shown(filename),
1489
+ self._slicer_estimate(filename),
1490
+ self._queue_line(filename, starting=True),
1491
+ self._pauses_line()),
1492
+ filename=filename))
1493
+ # After the started notification has taken its place in the
1494
+ # queues, so filament warnings always arrive after it
1495
+ self._start_filament_watch(filename, progress, check_start=True)
1496
+ elif job_event == JobEvent.PAUSED:
1497
+ if ex is not None and "error" in self.rows and \
1498
+ self._refill_expected(ex):
1499
+ self._mark_fault(fault_key(ex))
1500
+ self._spawn(self._runout_hold(filename, ex))
1501
+ elif ex is not None and "error" in self.rows:
1502
+ self._mark_fault(fault_key(ex))
1503
+ self._spawn(self._send(
1504
+ "error", self._title("error", kind="paused"),
1505
+ f"{shown(filename)}\n{describe(ex)}",
1506
+ delay=PAUSE_SNAPSHOT_DELAY))
1507
+ elif "paused" in self.rows and self._job_pauses:
1508
+ self._spawn(self._file_pause_alert(filename))
1509
+ else:
1510
+ self._spawn(self._send(
1511
+ "paused", self._title("paused"), shown(filename),
1512
+ delay=PAUSE_SNAPSHOT_DELAY))
1513
+ elif job_event == JobEvent.RESUMED:
1514
+ self._driver_hot_sent = False # a hot driver may warn again
1515
+ if ex is not None:
1516
+ self._stale_fault = fault_key(ex)
1517
+ self._status["print_stats"].update(
1518
+ {k: ps[k] for k in ("print_duration",) if k in ps})
1519
+ self._spawn(self._send(
1520
+ "resumed", self._title("resumed"),
1521
+ self._lines(shown(filename), self._time_left())))
1522
+ elif job_event == JobEvent.ERROR:
1523
+ # A Klipper shutdown ends the job with an error too: that goes
1524
+ # to the shutdown row (the exception handler would otherwise
1525
+ # find it already reported here)
1526
+ shutdown = self._klippy_shutdown()
1527
+ rid = "shutdown" if shutdown else "error"
1528
+ if self._job_done_sent or \
1529
+ (ex is not None and self._fault_recent(fault_key(ex))):
1530
+ self._skip(rid, "already reported")
1531
+ elif rid in self.rows:
1532
+ if ex is not None:
1533
+ self._mark_fault(fault_key(ex))
1534
+ detail = f"{describe(ex)}"
1535
+ else:
1536
+ detail = ps.get("message") or "Print failed"
1537
+ if shutdown:
1538
+ self._last_fault_send = time.monotonic()
1539
+ self._spawn(self._send(
1540
+ rid, self._title("shutdown") if shutdown
1541
+ else self._title("error", kind="error"),
1542
+ f"{shown(filename)}\n{detail}"))
1543
+ elif job_event in (JobEvent.COMPLETE, JobEvent.CANCELLED):
1544
+ if self._job_done_sent:
1545
+ self._skip(str(job_event), "already reported by the print hook")
1546
+ else:
1547
+ # The print hooks did not report it (e.g. no PRINT_END)
1548
+ logging.info("[push_notifications] %s: no print hook "
1549
+ "report, sending from the job event", job_event)
1550
+ self._spawn(self._report_end(
1551
+ "complete" if job_event == JobEvent.COMPLETE
1552
+ else "cancelled", ps))
1553
+ if job_event.finished:
1554
+ self._job_active = False
1555
+ self._job_ended = time.monotonic()
1556
+ self._job_gen += 1
1557
+ self._filament_job += 1
1558
+
1559
+ async def _start_job(self, filename: str, query: bool = True) -> float:
1560
+ """Reset per-job state. Progress already made (power-loss resume,
1561
+ Moonraker restart) is treated as already notified. The job is only
1562
+ marked active once seeded, so status updates arriving meanwhile
1563
+ can't report old progress."""
1564
+ self._job_active = False
1565
+ self._job_done_sent = False
1566
+ if query:
1567
+ state = await self._query(
1568
+ {"print_stats": ["info"], "virtual_sdcard": ["file_position"]})
1569
+ for obj in self._status:
1570
+ self._status[obj].update(state.get(obj) or {})
1571
+ self._gcode_bytes = self._get_gcode_bytes(filename)
1572
+ self._job_filename = filename
1573
+ layer = ((self._status["print_stats"].get("info") or {})
1574
+ .get("current_layer") or 0)
1575
+ self._first_layer_sent = int(layer) > self.layers_complete
1576
+ self._layer_started_sent = int(layer) >= 1
1577
+ self._driver_hot_sent = False
1578
+ # Chamber: a job joined past its first layer has no wait to report
1579
+ self._ch = {"phase": "done" if int(layer) >= 1 else "none",
1580
+ "target": 0., "source": "", "ref": None, "ref_time": 0.,
1581
+ "stall_sent": False, "used": False, "faults": set(),
1582
+ "fault": None, "connected": None,
1583
+ "wait_start": 0., "announced": False, "gen": 0}
1584
+ self._by_object = await self.eventloop.run_in_thread(
1585
+ self._is_by_object, filename)
1586
+ self._job_pauses = await self.eventloop.run_in_thread(
1587
+ self._scan_pauses, filename)
1588
+ self._pauses_seen = set()
1589
+ self._obj_start = {}
1590
+ self._obj_first_sent = set()
1591
+ self._obj_cur = self._status["exclude_object"].get("current_object")
1592
+ if self._obj_cur:
1593
+ # Joined part-way through this object (power loss, Moonraker
1594
+ # restart): its layers so far are unknown, so don't report them
1595
+ self._obj_start[self._obj_cur] = -10 ** 6
1596
+ self._obj_first_sent.add(self._obj_cur)
1597
+ if self._by_object:
1598
+ logging.info("[push_notifications] %s is printed by object",
1599
+ shown(filename))
1600
+ pct = self._progress() * 100.
1601
+ self._milestones_sent = {m for m in MILESTONES if m <= pct}
1602
+ if self._first_layer_sent or self._milestones_sent:
1603
+ self._skip("progress", f"job already at layer {layer}, {pct:.0f}%")
1604
+ self._job_active = True
1605
+ return pct / 100.
1606
+
1607
+ # ---- print hooks ---------------------------------------------------
1608
+
1609
+ async def _on_remote(self, event: str = "", **_kw: Any) -> None:
1610
+ if event in ("complete", "cancelled"):
1611
+ self._job_done_sent = True
1612
+ state = await self._query({"print_stats": None})
1613
+ ps = state.get("print_stats") or {}
1614
+ ex = self._live_fault(ps)
1615
+ reported = ex is not None and self._fault_recent(fault_key(ex))
1616
+ if event == "cancelled" and ex is not None and not reported:
1617
+ # Mark now, so the job 'error' event that follows a
1618
+ # fault-cancel is not reported a second time
1619
+ self._mark_fault(fault_key(ex))
1620
+ self._spawn(self._report_end(event, ps, from_hook=True,
1621
+ fault_reported=reported))
1622
+ else:
1623
+ logging.warning("[push_notifications] unknown hook event: %s",
1624
+ event)
1625
+
1626
+ async def _report_end(
1627
+ self, event: str, ps: Dict[str, Any], from_hook: bool = False,
1628
+ fault_reported: bool = False
1629
+ ) -> None:
1630
+ """`fault_reported`: the fault behind this cancel was already sent
1631
+ with its pause, so this is a plain cancel that names it."""
1632
+ self._job_done_sent = True
1633
+ filename = ps.get("filename") or ""
1634
+ ex = self._live_fault(ps)
1635
+ if event == "complete":
1636
+ rid, title = "complete", self._title("complete")
1637
+ body = self._lines(
1638
+ shown(filename),
1639
+ "Print time: "
1640
+ f"{format_duration(ps.get('print_duration', 0))}",
1641
+ self._queue_line(filename, starting=False))
1642
+ elif ex is not None and fault_reported:
1643
+ rid, title = "cancelled", self._title("cancelled")
1644
+ body = f"{shown(filename)}\nAfter: {describe(ex)}"
1645
+ elif ex is not None:
1646
+ rid = self._fault_row()
1647
+ if rid is None:
1648
+ return
1649
+ if self._fault_recent(fault_key(ex)) and rid == "error" \
1650
+ and not from_hook:
1651
+ self._skip("error", f"{format_code(ex)} already reported")
1652
+ return
1653
+ self._mark_fault(fault_key(ex))
1654
+ title = self._title("error", kind="cancelled") \
1655
+ if rid == "error" else self._title("cancelled")
1656
+ body = f"{shown(filename)}\n{describe(ex)}"
1657
+ else:
1658
+ rid, title, body = "cancelled", self._title("cancelled"), shown(filename)
1659
+ row = self.rows.get(rid)
1660
+ if row is None:
1661
+ return
1662
+ # Grab the snapshot right away: the hook reports once the toolhead
1663
+ # has lifted, and the stock macro keeps raising it from there
1664
+ snapshot = await self._fetch_snapshot() if row[0] == "snapshot" \
1665
+ else None
1666
+ await self._send(rid, title, body, snapshot=snapshot,
1667
+ snapshot_ready=True)
1668
+
1669
+ # ---- job queue -----------------------------------------------------
1670
+
1671
+ def _queue_line(self, filename: str, starting: bool) -> str:
1672
+ """Moonraker's job queue, if it has jobs: "5 prints remaining in
1673
+ queue (total 9h 12m)". At print start the count and the time
1674
+ include the print that just started; when a print ends they are
1675
+ what is still queued. Nothing when the queue is empty."""
1676
+ try:
1677
+ jq = self.server.lookup_component("job_queue", None)
1678
+ except Exception:
1679
+ jq = None
1680
+ if jq is None:
1681
+ return ""
1682
+ files = [str(j) for j in list(getattr(jq, "queued_jobs", {})
1683
+ .values())]
1684
+ state = getattr(jq, "queue_state", "")
1685
+ # The queue starts its next job before taking it off the list
1686
+ if starting and files and state in ("loading", "starting") and \
1687
+ files[0] == filename:
1688
+ files = files[1:]
1689
+ if not files:
1690
+ return ""
1691
+ if starting:
1692
+ files = [filename] + files
1693
+ total = sum(self._estimated_time(f) for f in files)
1694
+ n = len(files)
1695
+ line = (f"Print 1/{n} remaining in queue" if starting
1696
+ else f"{n} print{'s' if n != 1 else ''} remaining in queue")
1697
+ return line + (f" (total {format_duration(total)})" if total > 0
1698
+ else "")
1699
+
1700
+ # ---- exceptions ----------------------------------------------------
1701
+
1702
+ # Event handlers must return quickly; do the waiting in a task.
1703
+ def _on_gcode_response(self, response: str) -> None:
1704
+ """Notifications from G-code and macros: a console line
1705
+ "notify <title> || <message> || <priority>" (RESPOND
1706
+ PREFIX=notify MSG=...). The title is everything before the first
1707
+ " || ", the priority the last field if it is one of min..max, the
1708
+ message everything in between. On printers without this component
1709
+ the line is only console output."""
1710
+ for line in str(response or "").splitlines():
1711
+ if not line.startswith(MACRO_PREFIX):
1712
+ continue
1713
+ parts = line[len(MACRO_PREFIX):].split(MACRO_SEP)
1714
+ priority = self.macro_priority
1715
+ if len(parts) > 1 and parts[-1].strip().lower() in MACRO_LEVELS:
1716
+ priority = parts.pop().strip().lower()
1717
+ title = parts[0].strip()
1718
+ message = MACRO_SEP.join(parts[1:]).strip()
1719
+ rid = f"macro_{priority}"
1720
+ if not title or rid not in self.rows:
1721
+ if title:
1722
+ self._skip("macro", f"{priority} notifications are off")
1723
+ continue
1724
+ self._spawn(self._send(
1725
+ rid, self._title("macro", title=title), message,
1726
+ filename=self._job_filename if self._job_active else ""))
1727
+
1728
+ def _track_calibration(self) -> None:
1729
+ """Manual calibrations (touchscreen: bed levelling, XYZ offset,
1730
+ flow, input shaper), one row: "<which> complete" when its machine
1731
+ state ends (idle, or straight into the next queued calibration),
1732
+ and "user intervention needed" when it reaches a step that waits
1733
+ for a person. The bed mesh inside a print job's start routine sets
1734
+ the same state, so nothing is reported while a print job is
1735
+ active."""
1736
+ if "calibration" not in self.rows:
1737
+ return
1738
+ msm = self._status["machine_state_manager"]
1739
+ try:
1740
+ state = int(msm.get("main_state"))
1741
+ action = int(msm.get("action_code") or 0)
1742
+ except (TypeError, ValueError):
1743
+ return
1744
+ if self._job_active:
1745
+ self._calibrating = self._cal_action = None
1746
+ return
1747
+ if state != self._calibrating:
1748
+ done, self._calibrating = self._calibrating, None
1749
+ self._cal_action = None
1750
+ self._cal_hold += 1
1751
+ if done is not None and state == MSM_ABNORMAL:
1752
+ self._spawn(self._send(
1753
+ "calibration",
1754
+ self._title("calibration_failed",
1755
+ what=CALIBRATIONS[done]),
1756
+ "The printer reports an abnormal state; check the "
1757
+ "screen.", camera_wait=0.))
1758
+ elif done is not None and (state == MSM_IDLE
1759
+ or state in CALIBRATIONS):
1760
+ self._spawn(self._send(
1761
+ "calibration",
1762
+ self._title("calibration", what=CALIBRATIONS[done]),
1763
+ "Printer is idle." if state == MSM_IDLE else
1764
+ f"{CALIBRATIONS[state]} starts next.", camera_wait=0.))
1765
+ if state in CALIBRATIONS:
1766
+ self._calibrating = state
1767
+ else:
1768
+ return
1769
+ if action == self._cal_action:
1770
+ return
1771
+ self._cal_action = action
1772
+ self._cal_hold += 1
1773
+ what = CALIBRATIONS[state]
1774
+ if action in MANUAL_CLEAN:
1775
+ self._spawn(self._send(
1776
+ "calibration", self._title("intervention"),
1777
+ self._lines(what, f"Brush the nozzle of Toolhead "
1778
+ f"{MANUAL_CLEAN[action]}, then tap the screen."),
1779
+ camera_wait=0.))
1780
+ elif action == 0:
1781
+ self._spawn(self._intervention_hold(self._cal_hold, what))
1782
+
1783
+ # ---- chamber temperature -------------------------------------------
1784
+
1785
+ def _chamber_now(self) -> Tuple[float, Optional[float], str]:
1786
+ """(target, chamber temperature, source) right now: the first
1787
+ heater_generic with a target, else Snapmaker's chamber wait while it
1788
+ runs. No target: (0, None, "")."""
1789
+ for h in self._ch_heaters:
1790
+ st = self._status.get(h) or {}
1791
+ try:
1792
+ target = float(st.get("target") or 0.)
1793
+ temp = st.get("temperature")
1794
+ if target > 0:
1795
+ return target, (None if temp is None else float(temp)), h
1796
+ except (TypeError, ValueError):
1797
+ continue
1798
+ pur = self._status.get("purifier") or {}
1799
+ msm = self._status["machine_state_manager"]
1800
+ try:
1801
+ target = float(pur.get("desired_chamber_temp") or 0.)
1802
+ action = int(msm.get("action_code") or 0)
1803
+ except (TypeError, ValueError):
1804
+ return 0., None, ""
1805
+ if pur.get("power_detected") and target > 0:
1806
+ temp = (self._status.get(CHAMBER_SENSOR) or {}).get("temperature")
1807
+ waiting = action == ACTION_PREHEAT_CHAMBER
1808
+ return target, (None if temp is None else float(temp)), \
1809
+ "purifier" if waiting else "purifier_idle"
1810
+ return 0., None, ""
1811
+
1812
+ def _track_chamber(self) -> None:
1813
+ """Before the first layer: waiting / reached / timed out / not
1814
+ heating. Through the whole job: DragonBreath faults and losing it."""
1815
+ ch = self._ch
1816
+ ps = self._status["print_stats"]
1817
+ filename = shown(ps.get("filename") or self._job_filename)
1818
+ layer = (ps.get("info") or {}).get("current_layer")
1819
+ try:
1820
+ layer = int(layer or 0)
1821
+ except (TypeError, ValueError):
1822
+ layer = 0
1823
+ target, temp, source = self._chamber_now()
1824
+ if source == DRAGONBREATH or source.endswith(" " + DRAGONBREATH):
1825
+ ch["used"] = True
1826
+ now = time.monotonic()
1827
+ deg = lambda t: f"{t:.0f} Β°C"
1828
+ state = lambda t, T: f"Chamber {deg(t)}, target {deg(T)}."
1829
+ if ch["phase"] in ("none", "waiting") and temp is not None:
1830
+ if layer >= 1:
1831
+ if ch["phase"] == "waiting" and ch["announced"]:
1832
+ T = ch["target"]
1833
+ if temp >= T - CHAMBER_TOLERANCE:
1834
+ self._chamber_send("chamber_reached", "chamber_reached",
1835
+ filename, state(temp, T),
1836
+ "Printing…")
1837
+ else:
1838
+ self._chamber_send("chamber_fault", "chamber_timeout",
1839
+ filename, state(temp, T),
1840
+ "Printing below target chamber "
1841
+ "temperature…")
1842
+ ch["phase"] = "done"
1843
+ elif ch["phase"] == "none" and target > 0 and \
1844
+ source != "purifier_idle" and (
1845
+ temp < target if source == "purifier"
1846
+ else temp < target - CHAMBER_TOLERANCE):
1847
+ ch.update(phase="waiting", target=target, source=source,
1848
+ ref=temp, ref_time=now, wait_start=now,
1849
+ announced=False, gen=ch["gen"] + 1)
1850
+ self._spawn(self._chamber_announce(ch["gen"], filename))
1851
+ elif ch["phase"] == "waiting":
1852
+ if target > 0 and source != "purifier_idle":
1853
+ ch["target"] = target
1854
+ T = ch["target"]
1855
+ purifier_done = ch["source"] == "purifier" and \
1856
+ source != "purifier"
1857
+ if temp >= T or purifier_done:
1858
+ if not ch["announced"]:
1859
+ pass # over before it counted as a wait: silent
1860
+ elif temp >= T:
1861
+ self._chamber_send("chamber_reached",
1862
+ "chamber_reached", filename,
1863
+ state(temp, T), "Printing…")
1864
+ else:
1865
+ # Snapmaker's wait gave up (no rise for its TIMEOUT)
1866
+ self._chamber_send("chamber_fault", "chamber_timeout",
1867
+ filename, state(temp, T),
1868
+ "Printing below target chamber "
1869
+ "temperature…")
1870
+ ch["phase"] = "done"
1871
+ elif ch["ref"] is None or \
1872
+ temp >= ch["ref"] + CHAMBER_STALL_RISE:
1873
+ ch.update(ref=temp, ref_time=now)
1874
+ elif not ch["stall_sent"] and \
1875
+ now - ch["ref_time"] >= CHAMBER_STALL_TIME:
1876
+ ch["stall_sent"] = True
1877
+ self._chamber_send(
1878
+ "chamber_fault", "chamber_stalled", filename,
1879
+ f"No rise in {CHAMBER_STALL_TIME / 60:.0f} min.",
1880
+ f"Printer still waiting for {deg(T)} "
1881
+ f"(currently {deg(temp)})…")
1882
+ if not self._ch_device:
1883
+ return
1884
+ dev = self._status.get(DRAGONBREATH) or {}
1885
+ if not ch["used"]:
1886
+ ch["connected"] = dev.get("connected")
1887
+ return
1888
+
1889
+ def next_line() -> str:
1890
+ t = (self._status.get(f"heater_generic {DRAGONBREATH}") or {}) \
1891
+ .get("temperature")
1892
+ t = temp if t is None else float(t)
1893
+ if ch["phase"] == "waiting":
1894
+ return (f"Printer still waiting for {deg(ch['target'])}"
1895
+ + (f" (currently {deg(t)})…" if t is not None
1896
+ else "…"))
1897
+ return ("Print job continuing"
1898
+ + (f", chamber {deg(t)}…" if t is not None else "…"))
1899
+ connected = dev.get("connected")
1900
+ if ch["connected"] and connected is False:
1901
+ self._chamber_send(
1902
+ "chamber_fault", "chamber_disconnected", filename,
1903
+ "DragonBreath heater not responding, should shut off when "
1904
+ "disconnected from printer.", next_line())
1905
+ ch["connected"] = connected
1906
+ reason = str(dev.get("fault_reason") or "fault").strip()
1907
+ if dev.get("fault"):
1908
+ if ch["fault"] != reason and reason not in ch["faults"]:
1909
+ ch["faults"].add(reason)
1910
+ self._chamber_send(
1911
+ "chamber_fault", "chamber_fault", filename,
1912
+ f"DragonBreath heater fault: {reason}.", next_line())
1913
+ ch["fault"] = reason
1914
+ else:
1915
+ ch["fault"] = None
1916
+
1917
+ async def _chamber_announce(self, gen: int, filename: str) -> None:
1918
+ """"Waiting for chamber temperature", once the wait has lasted
1919
+ CHAMBER_WAIT_MIN (a wait that ended sooner sends nothing)."""
1920
+ await asyncio.sleep(CHAMBER_WAIT_MIN)
1921
+ ch = self._ch
1922
+ if not self._job_active or ch.get("gen") != gen or \
1923
+ ch["phase"] != "waiting":
1924
+ return
1925
+ ch["announced"] = True
1926
+ _target, temp, _source = self._chamber_now()
1927
+ if temp is None:
1928
+ temp = ch["ref"] or 0.
1929
+ self._chamber_send("chamber_waiting", "chamber_waiting", filename,
1930
+ f"Chamber {temp:.0f} Β°C, target "
1931
+ f"{ch['target']:.0f} Β°C.", "Printer waiting…")
1932
+
1933
+ def _chamber_send(self, rid: str, title: str, filename: str,
1934
+ *lines: str) -> None:
1935
+ if rid not in self.rows:
1936
+ return
1937
+ self._spawn(self._send(rid, self._title(title),
1938
+ self._lines(filename, *lines)))
1939
+
1940
+ # ---- filament loaded / unloaded ----------------------------------
1941
+
1942
+ def _track_filament_change(self) -> None:
1943
+ ptc = self._status["print_task_config"]
1944
+ exist = [bool(x) for x in (ptc.get("filament_exist") or [])]
1945
+ if not exist:
1946
+ return
1947
+ for ch in range(len(exist)):
1948
+ name = self._printer_filament(ptc, ch)
1949
+ if name:
1950
+ self._exist_names[ch] = name
1951
+ if self._exist_seen is None or len(self._exist_seen) != len(exist):
1952
+ self._exist_seen = exist # seed: nothing to report yet
1953
+ return
1954
+ for ch, (was, now) in enumerate(zip(self._exist_seen, exist)):
1955
+ if was != now:
1956
+ self._exist_gen += 1
1957
+ self._spawn(self._filament_change(ch, now, self._exist_gen))
1958
+
1959
+ async def _filament_change(self, ch: int, now: bool, gen: int) -> None:
1960
+ await asyncio.sleep(FILAMENT_CHANGE_SETTLE)
1961
+ if gen != self._exist_gen:
1962
+ return # changed again meanwhile: the newer wait reports
1963
+ ptc = self._status["print_task_config"]
1964
+ exist = [bool(x) for x in (ptc.get("filament_exist") or [])]
1965
+ if ch >= len(exist) or exist[ch] != now or self._exist_seen is None:
1966
+ return
1967
+ if self._exist_seen[ch] == now:
1968
+ return # already reported (e.g. the same change twice)
1969
+ self._exist_seen[ch] = now
1970
+ if self._job_active or "filament_change" not in self.rows or \
1971
+ ("loaded" if now else "unloaded") \
1972
+ not in self.filament_change_events:
1973
+ return # a print job's runouts and refills say it instead
1974
+ # Loaded: the printer's filament info arrives shortly after the
1975
+ # sensor; unloaded: what it had (the info may be cleared by now)
1976
+ ids = list(ptc.get("filament_spool_id") or [])
1977
+ sid = ids[ch] if ch < len(ids) else 0
1978
+ spool = await self._spool(sid) if now and self._spoolman_active() \
1979
+ else None
1980
+ remaining = self._remaining(spool)
1981
+ name = self._spool_name(spool) or self._printer_filament(ptc, ch) \
1982
+ or self._exist_names.get(ch, "")
1983
+ if name:
1984
+ self._exist_names[ch] = name
1985
+ title = self._title("filament_loaded" if now else "filament_unloaded")
1986
+ body = self._lines(
1987
+ f"{self._toolhead(ch)}: {name}" if name else self._toolhead(ch),
1988
+ f"{remaining:.0f} g left" if remaining is not None
1989
+ and remaining > 0 else "")
1990
+ # The spool picture: its colour from Spoolman or the printer, the
1991
+ # amount only when Spoolman knows it
1992
+ row = dict(toolhead=self._toolhead(ch), word="", status="ink",
1993
+ needs="", remaining=remaining, full=self._full_weight(spool),
1994
+ colour=self._spool_colour(spool, ch), name=name)
1995
+ await self._send_parts("filament_change", [(title, body, [row])])
1996
+
1997
+ async def _intervention_hold(self, hold: int, what: str) -> None:
1998
+ start = time.monotonic()
1999
+ while True:
2000
+ now = time.monotonic()
2001
+ if self._status["idle_timeout"].get("state") == "Printing":
2002
+ self._cal_busy_at = now
2003
+ due = max(start, self._cal_busy_at) + INTERVENTION_HOLD
2004
+ if now >= due:
2005
+ break
2006
+ await asyncio.sleep(min(due - now, 1.))
2007
+ if hold != self._cal_hold or self._job_active:
2008
+ return # the calibration moved on (or ended) meanwhile
2009
+ await self._send("calibration", self._title("intervention"),
2010
+ self._lines(what, "The screen is waiting for you."),
2011
+ camera_wait=0.)
2012
+
2013
+ async def _on_exception(self, excep: Dict[str, Any]) -> None:
2014
+ if fault_key(excep) == self._stale_fault:
2015
+ self._stale_fault = None # it happened again
2016
+ self._spawn(self._handle_exception(dict(excep)))
2017
+
2018
+ async def _on_klippy_shutdown(self) -> None:
2019
+ self._spawn(self._handle_shutdown())
2020
+
2021
+ async def _handle_exception(self, excep: Dict[str, Any]) -> None:
2022
+ key = fault_key(excep)
2023
+ level = int(excep.get("level") or 0)
2024
+ if level < self.min_level:
2025
+ return
2026
+ if key in self._faults_pending or self._fault_recent(key):
2027
+ self._skip("fault", f"{format_code(excep)} already reported")
2028
+ return
2029
+ self._faults_pending.add(key)
2030
+ try:
2031
+ # Let a pause/cancel caused by this fault report it first
2032
+ await asyncio.sleep(self.settle_time)
2033
+ if self._fault_recent(key):
2034
+ self._skip("fault", f"{format_code(excep)} reported with the pause or cancel")
2035
+ return
2036
+ state = await self._query({"print_stats": None, "webhooks": None})
2037
+ ps = state.get("print_stats") or {}
2038
+ shutdown = (state.get("webhooks") or {}).get("state") == "shutdown"
2039
+ if shutdown:
2040
+ rid, title = "shutdown", self._title("shutdown")
2041
+ elif level <= 1:
2042
+ rid, title = "warning", self._title("warning")
2043
+ else:
2044
+ rid, title = "error", self._title("error", kind="fault")
2045
+ if rid not in self.rows:
2046
+ return
2047
+ details = describe(excep)
2048
+ filename = ps.get("filename") or ""
2049
+ if filename and ps.get("state") in ("printing", "paused"):
2050
+ details = f"{shown(filename)}\n{details}"
2051
+ self._mark_fault(key)
2052
+ await self._send(rid, title, details.strip())
2053
+ finally:
2054
+ self._faults_pending.discard(key)
2055
+
2056
+ async def _handle_shutdown(self) -> None:
2057
+ # Fallback for shutdowns that never produced a coded exception.
2058
+ if "shutdown" not in self.rows:
2059
+ return
2060
+ await asyncio.sleep(self.settle_time + 2.)
2061
+ if time.monotonic() - self._last_fault_send < self.settle_time + 10.:
2062
+ self._skip("shutdown", "already reported with its error code")
2063
+ return
2064
+ state = await self._query({"print_stats": None, "webhooks": None})
2065
+ wh = state.get("webhooks") or {}
2066
+ if wh.get("state") != "shutdown":
2067
+ return
2068
+ ps = state.get("print_stats") or {}
2069
+ msg = (wh.get("state_message") or "").strip()
2070
+ msg = msg.splitlines()[0] if msg else "Klipper shutdown"
2071
+ # print_stats keeps the last file name after the job: name it only
2072
+ # if the shutdown interrupted it
2073
+ filename = ps.get("filename") or "" \
2074
+ if ps.get("state") in ("printing", "paused") else ""
2075
+ self._last_fault_send = time.monotonic()
2076
+ await self._send("shutdown", self._title("shutdown"),
2077
+ f"{shown(filename)}\n{msg}" if filename else msg)
2078
+
2079
+ # ---- print progress ------------------------------------------------
2080
+
2081
+ async def _on_klippy_ready(self) -> None:
2082
+ first_ready = not self._ready_once
2083
+ self._ready_once = True
2084
+ kapis: APIComp = self.server.lookup_component("klippy_apis")
2085
+ chamber = {}
2086
+ try:
2087
+ objs = await kapis.get_object_list(default=[])
2088
+ except Exception:
2089
+ objs = []
2090
+ self._ace_on = ACE_OBJECT in objs
2091
+ if any(r in self.rows for r in CHAMBER_ROWS):
2092
+ self._ch_heaters = [o for o in objs
2093
+ if o.startswith("heater_generic ")]
2094
+ self._ch_sensor = CHAMBER_SENSOR in objs
2095
+ self._ch_purifier = "purifier" in objs
2096
+ self._ch_device = DRAGONBREATH in objs
2097
+ chamber = dict(
2098
+ {h: ["temperature", "target"] for h in self._ch_heaters},
2099
+ **({CHAMBER_SENSOR: ["temperature"]}
2100
+ if self._ch_sensor else {}),
2101
+ **({"purifier": ["power_detected", "desired_chamber_temp"]}
2102
+ if self._ch_purifier else {}),
2103
+ **({DRAGONBREATH: ["fault", "fault_reason", "connected"]}
2104
+ if self._ch_device else {}))
2105
+ for obj in chamber:
2106
+ self._status.setdefault(obj, {})
2107
+ try:
2108
+ status = await kapis.subscribe_objects(
2109
+ dict({"print_stats": ["state", "filename", "info",
2110
+ "print_duration"],
2111
+ "virtual_sdcard": ["progress", "file_position"],
2112
+ "exclude_object": ["objects", "current_object",
2113
+ "excluded_objects"],
2114
+ **{d: ["temperature"] for d in DRIVERS},
2115
+ # Always: the runout hold and the backup-spool switch
2116
+ # read the toolhead map and the refill settings from
2117
+ # it, with or without Spoolman
2118
+ "print_task_config": PTC_FIELDS,
2119
+ **{e: NOZZLE_FIELDS for e in EXTRUDERS}},
2120
+ **({"machine_state_manager": ["main_state",
2121
+ "action_code"]}
2122
+ if "calibration" in self.rows
2123
+ or self._ch_purifier else {}),
2124
+ **({"idle_timeout": ["state"]}
2125
+ if "calibration" in self.rows else {}),
2126
+ **({ACE_OBJECT: ACE_FIELDS} if self._ace_on else {}),
2127
+ **chamber),
2128
+ self._on_status_update, default={})
2129
+ except Exception as e:
2130
+ logging.error("[push_notifications] subscribe failed: %s", e)
2131
+ status = {}
2132
+ for obj in self._status:
2133
+ self._status[obj].update(status.get(obj) or {})
2134
+ self._calibrating = self._cal_action = None # Klipper restarted
2135
+ self._ace_src = self._ace_sources()
2136
+ self._exist_seen = None
2137
+ self._exist_gen += 1
2138
+ self._track_filament_change()
2139
+ self._cal_hold += 1
2140
+ ps = self._status["print_stats"]
2141
+ if ps.get("state") in ("printing", "paused"):
2142
+ # Started before Moonraker: track it without re-notifying
2143
+ await self._start_job(ps.get("filename") or "", query=False)
2144
+ self._start_filament_watch(ps.get("filename") or "",
2145
+ self._progress(), check_start=False)
2146
+ else:
2147
+ self._job_active = False
2148
+ if first_ready and "power_loss" in self.rows and self._booted_recently():
2149
+ self._spawn(self._check_power_loss())
2150
+
2151
+ async def _on_status_update(
2152
+ self, data: Dict[str, Any], _eventtime: float
2153
+ ) -> None:
2154
+ for obj, values in data.items():
2155
+ if obj in self._status and isinstance(values, dict):
2156
+ self._status[obj].update(values)
2157
+ ps = self._status["print_stats"]
2158
+ if "print_task_config" in data and self._job_active:
2159
+ self._check_spool_switch()
2160
+ if (data.get("idle_timeout") or {}).get("state") == "Printing":
2161
+ self._cal_busy_at = time.monotonic()
2162
+ if "machine_state_manager" in data:
2163
+ self._track_calibration()
2164
+ if "filament_exist" in (data.get("print_task_config") or {}):
2165
+ self._track_filament_change()
2166
+ if ACE_OBJECT in data:
2167
+ self._track_ace()
2168
+ if self._job_active and self._ch:
2169
+ self._track_chamber()
2170
+ if not self._job_active or ps.get("state") != "printing":
2171
+ return
2172
+ filename = ps.get("filename") or ""
2173
+ if "driver_hot" in self.rows and not self._driver_hot_sent and \
2174
+ any(d in data for d in DRIVERS):
2175
+ temps = {axis: self._status[d].get("temperature")
2176
+ for d, axis in DRIVERS.items()}
2177
+ hot = [a for a, t in temps.items()
2178
+ if t is not None and float(t) >= self.driver_hot]
2179
+ if hot:
2180
+ self._driver_hot_sent = True
2181
+ self._spawn(self._send(
2182
+ "driver_hot", self._title("driver_hot"),
2183
+ self._lines(shown(filename), *(
2184
+ f"{a} driver: {temps[a]:.0f} Β°C"
2185
+ + (f" (alert at {self.driver_hot:.0f} Β°C)"
2186
+ if a in hot else "")
2187
+ for a in DRIVERS.values()
2188
+ if temps[a] is not None))))
2189
+ if self._by_object and not self._objects():
2190
+ # No EXCLUDE_OBJECT markers (label objects off): nothing to
2191
+ # track per object, so it's reported like a by-layer job
2192
+ self._by_object = False
2193
+ logging.info("[push_notifications] no object markers in %s: "
2194
+ "reported as one job", shown(filename))
2195
+ if "layer_started" in self.rows and not self._layer_started_sent:
2196
+ # Layer 1 begins once the start routine (homing, bed mesh,
2197
+ # flow calibration) is over: time to watch the first layer
2198
+ layer = (ps.get("info") or {}).get("current_layer")
2199
+ if layer is not None and int(layer) >= 1:
2200
+ self._layer_started_sent = True
2201
+ self._spawn(self._send(
2202
+ "layer_started", self._title("layer_started"),
2203
+ self._lines(shown(filename), self._time_left())))
2204
+ if self._by_object:
2205
+ self._track_objects(filename)
2206
+ elif "first_layer" in self.rows and not self._first_layer_sent:
2207
+ layer = (ps.get("info") or {}).get("current_layer")
2208
+ if layer is not None and int(layer) > self.layers_complete:
2209
+ self._first_layer_sent = True
2210
+ self._spawn(self._send(
2211
+ "first_layer", self._title(
2212
+ "first_layer",
2213
+ ordinal=ORDINALS[self.layers_complete]),
2214
+ self._lines(shown(filename), self._time_left())))
2215
+ pct = self._progress() * 100.
2216
+ crossed = [m for m in MILESTONES
2217
+ if m <= pct and m not in self._milestones_sent]
2218
+ if crossed:
2219
+ # Only report the highest one if several were crossed at once
2220
+ self._milestones_sent.update(crossed)
2221
+ for m in sorted(crossed, reverse=True):
2222
+ if f"progress_{m}" in self.rows:
2223
+ self._spawn(self._send(
2224
+ f"progress_{m}", self._title("progress", pct=m),
2225
+ self._lines(shown(filename), self._time_left())))
2226
+ break
2227
+
2228
+ def _is_by_object(self, filename: str) -> bool:
2229
+ """Whether the slicer's config block (at the end of the file) says
2230
+ the print job is by object. Runs in a thread: it reads the file."""
2231
+ if not filename:
2232
+ return False
2233
+ try:
2234
+ fm = self.server.lookup_component("file_manager")
2235
+ path = os.path.join(fm.get_directory("gcodes"), filename)
2236
+ with open(path, "rb") as f:
2237
+ f.seek(max(0, os.path.getsize(path) - CONFIG_TAIL))
2238
+ return RX_BY_OBJECT.search(f.read()) is not None
2239
+ except Exception as e:
2240
+ logging.warning("[push_notifications] could not read %s: %s",
2241
+ shown(filename), e)
2242
+ return False
2243
+
2244
+ def _scan_pauses(self, filename: str) -> List[Dict[str, Any]]:
2245
+ """The pauses the print job's file asks for: [{offset, layer,
2246
+ message, kind}], in file order. Runs in a thread: it reads the
2247
+ file."""
2248
+ if not filename:
2249
+ return []
2250
+ out: List[Dict[str, Any]] = []
2251
+ try:
2252
+ fm = self.server.lookup_component("file_manager")
2253
+ path = os.path.join(fm.get_directory("gcodes"), filename)
2254
+ layer = 0
2255
+ m117 = (b"", -10 ** 9) # last M117 text and where
2256
+ with open(path, "rb") as f:
2257
+ pos = 0
2258
+ for line in f:
2259
+ at, pos = pos, pos + len(line)
2260
+ if not line.startswith((b";", b"P", b"p", b"M", b"m")):
2261
+ continue
2262
+ m = RX_LAYER.match(line)
2263
+ if m:
2264
+ layer = int(m.group(2)) + 1 if m.group(2) else layer + 1
2265
+ continue
2266
+ m = RX_M117.match(line)
2267
+ if m:
2268
+ m117 = (m.group(1).strip(), at)
2269
+ continue
2270
+ if RX_PAUSE_MARK.match(line):
2271
+ if out and at - out[-1]["offset"] <= PAUSE_MERGE:
2272
+ continue
2273
+ out.append(dict(offset=at, layer=layer, message="",
2274
+ kind=""))
2275
+ continue
2276
+ m = RX_PAUSE_CMD.match(line)
2277
+ if not m:
2278
+ continue
2279
+ kind = ("filament change"
2280
+ if m.group(1).upper() == b"M600" else "")
2281
+ msg = (m.group(3) or b"").strip()
2282
+ if not msg and at - m117[1] <= PAUSE_MERGE:
2283
+ msg = m117[0]
2284
+ text = msg.decode("utf-8", "replace")
2285
+ if out and at - out[-1]["offset"] <= PAUSE_MERGE \
2286
+ and not out[-1]["kind"]:
2287
+ out[-1].update(message=out[-1]["message"] or text,
2288
+ kind=kind)
2289
+ continue
2290
+ out.append(dict(offset=at, layer=layer, kind=kind,
2291
+ message=text))
2292
+ except Exception as e:
2293
+ logging.warning("[push_notifications] could not read %s: %s",
2294
+ shown(filename), e)
2295
+ if out:
2296
+ logging.info("[push_notifications] %s has %d pause(s) in the "
2297
+ "file: %s", shown(filename), len(out),
2298
+ ", ".join(f"layer {p['layer']} @{p['offset']}"
2299
+ for p in out))
2300
+ return out
2301
+
2302
+ def _pauses_line(self) -> str:
2303
+ """For the started notification: how many pauses the file has."""
2304
+ ps = self._job_pauses
2305
+ if not ps:
2306
+ return ""
2307
+ layers = [p["layer"] for p in ps if p["layer"]]
2308
+ where = ""
2309
+ if len(layers) == len(ps):
2310
+ where = (f" (layer {layers[0]})" if len(layers) == 1 else
2311
+ " (layers " + ", ".join(str(n) for n in layers[:-1])
2312
+ + f" and {layers[-1]})")
2313
+ return ("\nPrint job contains a pause" if len(ps) == 1 else
2314
+ f"\nPrint job contains {len(ps)} pauses") + where + "."
2315
+
2316
+ def _file_pause(self, position: Any) -> Optional[int]:
2317
+ """Index of the file's pause the print job stopped at, if the file
2318
+ position is just past one of them and it hasn't been reported."""
2319
+ try:
2320
+ pos = int(position)
2321
+ except (TypeError, ValueError):
2322
+ return None
2323
+ for i, p in enumerate(self._job_pauses):
2324
+ if i not in self._pauses_seen and \
2325
+ p["offset"] <= pos <= p["offset"] + PAUSE_WINDOW:
2326
+ return i
2327
+ return None
2328
+
2329
+ async def _file_pause_alert(self, filename: str) -> None:
2330
+ state = await self._query({"virtual_sdcard": ["file_position"]})
2331
+ pos = (state.get("virtual_sdcard") or {}).get("file_position")
2332
+ i = self._file_pause(pos)
2333
+ if i is None:
2334
+ await self._send("paused", self._title("paused"), shown(filename),
2335
+ delay=PAUSE_SNAPSHOT_DELAY)
2336
+ return
2337
+ self._pauses_seen.add(i)
2338
+ p = self._job_pauses[i]
2339
+ n = len(self._job_pauses)
2340
+ what = (f"Pause {i + 1} of {n}" if n > 1 else "Pause") + (
2341
+ f" at layer {p['layer']}" if p["layer"] else "") + (
2342
+ " for a filament change" if p["kind"] else "")
2343
+ line = f"{what}, as planned in the print job."
2344
+ if p["message"]:
2345
+ line += f' "{p["message"]}"'
2346
+ # The Paused row's destinations at the highest priority (the
2347
+ # file_pause row the config writes next to paused)
2348
+ await self._send("file_pause" if "file_pause" in self.rows
2349
+ else "paused", self._title("file_pause"),
2350
+ self._lines(shown(filename), line,
2351
+ "Waiting for you to resume…"),
2352
+ delay=PAUSE_SNAPSHOT_DELAY)
2353
+
2354
+ def _objects(self) -> List[str]:
2355
+ return [str(o.get("name") or "") for o in
2356
+ self._status["exclude_object"].get("objects") or []]
2357
+
2358
+ def _object_line(self, name: str) -> str:
2359
+ """'Object 2 of 3: Assembly' for a notification body."""
2360
+ objects = self._objects()
2361
+ n = objects.index(name) + 1 if name in objects else 0
2362
+ # Orca names objects <model>_id_<n>_copy_<m>; show the model, and
2363
+ # a number when the same model is on the plate more than once
2364
+ pretty = name
2365
+ m = RX_OBJECT_NAME.match(name)
2366
+ if m:
2367
+ base = m.group(1)
2368
+ same = [o for o in objects
2369
+ if (RX_OBJECT_NAME.match(o) or m).group(1) == base]
2370
+ pretty = f"{base} #{same.index(name) + 1}" if len(same) > 1 \
2371
+ else base
2372
+ where = f"Object {n} of {len(objects)}" if n else "Object"
2373
+ return f"{where}: {pretty}"
2374
+
2375
+ def _track_objects(self, filename: str) -> None:
2376
+ """Print-by-object: report an object's first layers and its
2377
+ completion. Layer numbers run on across the objects (Orca), so an
2378
+ object's layer is counted from the layer it began on; a new object
2379
+ beginning means the previous one is complete."""
2380
+ ex = self._status["exclude_object"]
2381
+ cur = ex.get("current_object") or None
2382
+ layer = int((self._status["print_stats"].get("info") or {})
2383
+ .get("current_layer") or 0)
2384
+ if cur and cur != self._obj_cur:
2385
+ prev = self._obj_cur
2386
+ self._obj_cur = cur
2387
+ self._obj_start.setdefault(cur, layer)
2388
+ if prev is not None and prev != cur and \
2389
+ prev not in (ex.get("excluded_objects") or []):
2390
+ self._object_done(prev, filename)
2391
+ cur = self._obj_cur
2392
+ if not cur or "first_layer" not in self.rows or \
2393
+ cur in self._obj_first_sent or \
2394
+ cur in (ex.get("excluded_objects") or []):
2395
+ return
2396
+ if self.first_layer_objects != "each" and self._first_layer_sent:
2397
+ return
2398
+ if layer - self._obj_start.get(cur, layer) >= self.layers_complete:
2399
+ self._obj_first_sent.add(cur)
2400
+ self._first_layer_sent = True
2401
+ self._spawn(self._send(
2402
+ "first_layer",
2403
+ self._title("first_layer",
2404
+ ordinal=ORDINALS[self.layers_complete]),
2405
+ self._lines(shown(filename), self._object_line(cur),
2406
+ self._time_left())))
2407
+
2408
+ def _object_done(self, name: str, filename: str) -> None:
2409
+ if "object_complete" not in self.rows:
2410
+ return
2411
+ self._spawn(self._send(
2412
+ "object_complete", self._title("object_complete"),
2413
+ self._lines(shown(filename), self._object_line(name),
2414
+ self._time_left())))
2415
+
2416
+ def _get_gcode_bytes(self, filename: str) -> Tuple[Any, Any]:
2417
+ if not filename:
2418
+ return (None, None)
2419
+ try:
2420
+ fm = self.server.lookup_component("file_manager")
2421
+ meta = fm.get_metadata_storage().get(filename, {}) or {}
2422
+ return (meta.get("gcode_start_byte"), meta.get("gcode_end_byte"))
2423
+ except Exception as e:
2424
+ logging.warning("[push_notifications] metadata lookup failed: %s",
2425
+ e)
2426
+ return (None, None)
2427
+
2428
+ def _progress(self) -> float:
2429
+ # Same calculation as Fluidd's default ("file relative"), which is
2430
+ # also what the Snapmaker app shows.
2431
+ sd = self._status["virtual_sdcard"]
2432
+ start, end = self._gcode_bytes
2433
+ pos = sd.get("file_position")
2434
+ if start is not None and end is not None and pos is not None:
2435
+ if pos <= start:
2436
+ return 0.
2437
+ if pos >= end:
2438
+ return 1.
2439
+ if end > start:
2440
+ return (pos - start) / (end - start)
2441
+ return float(sd.get("progress") or 0.)
2442
+
2443
+ # ---- power loss ----------------------------------------------------
2444
+
2445
+ @staticmethod
2446
+ def _booted_recently() -> bool:
2447
+ try:
2448
+ with open("/proc/uptime") as f:
2449
+ return float(f.read().split()[0]) < BOOT_WINDOW
2450
+ except Exception:
2451
+ return False
2452
+
2453
+ async def _check_power_loss(self) -> None:
2454
+ # pl_env_valid is set once the MCUs have reported their saved
2455
+ # state, which can take a while after klippy_ready.
2456
+ deadline = time.monotonic() + PL_WAIT_TIME
2457
+ while time.monotonic() < deadline:
2458
+ result = await self._query({"virtual_sdcard": ["pl_env_valid"]})
2459
+ if (result.get("virtual_sdcard") or {}).get("pl_env_valid"):
2460
+ break
2461
+ await asyncio.sleep(2.)
2462
+ else:
2463
+ return
2464
+ filename = ""
2465
+ env_path = next((p for p in PL_ENV_PATHS if os.path.isfile(p)), None)
2466
+ if env_path is not None:
2467
+ try:
2468
+ with open(env_path, "r") as f:
2469
+ filename = os.path.basename(
2470
+ json.load(f).get("file_path") or "")
2471
+ except Exception as e:
2472
+ logging.warning("[push_notifications] power-loss file "
2473
+ "unreadable: %s", e)
2474
+ self._pl_file = filename
2475
+ details = "Print interrupted by power loss. " \
2476
+ "Resume is available on the touchscreen."
2477
+ await self._send("power_loss", self._title("power_loss"),
2478
+ f"{shown(filename)}\n{details}" if filename else details,
2479
+ camera_wait=PL_CAMERA_WAIT)
2480
+
2481
+ # ---- Spoolman: filament checks ----------------------------------------
2482
+ #
2483
+ # Toolheads are numbered 1-4 for people; channels are 0-3. The slicer's
2484
+ # filaments (T0..) are mapped to channels by print_task_config's
2485
+ # extruder_map_table, and each channel's Spoolman spool is in
2486
+ # filament_spool_id. Only toolheads the print uses are checked.
2487
+ #
2488
+ # * filament_short: a toolhead has not enough filament for the rest of
2489
+ # the print (red), or only just enough (orange, within FILAMENT_CLOSE).
2490
+ # Checked at print start (one notification for all toolheads at risk)
2491
+ # and every FILAMENT_POLL during the print (only when a toolhead gets
2492
+ # worse than already reported). Nothing is sent when all is fine.
2493
+ # * filament_low: a spool is down to filament_low_percent of a full
2494
+ # spool or filament_low_grams, whichever comes first. Once per spool
2495
+ # (remembered across restarts) until it is refilled or replaced.
2496
+ # * spool_switch: the printer refilled from another toolhead.
2497
+
2498
+ def _spoolman_active(self) -> bool:
2499
+ return bool(self.spoolman_url) and any(
2500
+ r in self.rows for r in SPOOLMAN_ROWS)
2501
+
2502
+ def _start_filament_watch(
2503
+ self, filename: str, progress: float, check_start: bool
2504
+ ) -> None:
2505
+ self._job_map = list(
2506
+ self._status["print_task_config"].get("extruder_map_table") or [])
2507
+ self._job_ptc = dict(self._status["print_task_config"])
2508
+ self._job_tools = set()
2509
+ self._job_weights = {}
2510
+ self._switched_to = set()
2511
+ self._slot_empty_sent = set()
2512
+ self._job_progress0 = progress
2513
+ self._spool_start = {}
2514
+ self._spool_now = {}
2515
+ self._tool_spools = {}
2516
+ self._filament_job += 1
2517
+ self._spawn(self._filament_watch(
2518
+ self._filament_job, filename, progress, check_start))
2519
+
2520
+ async def _spool(self, spool_id: int) -> Optional[Dict[str, Any]]:
2521
+ if not spool_id:
2522
+ return None
2523
+ try:
2524
+ http = self.server.lookup_component("http_client")
2525
+ resp = await http.get(
2526
+ f"{self.spoolman_url}/api/v1/spool/{spool_id}",
2527
+ enable_cache=False)
2528
+ if resp.status_code == 200:
2529
+ return resp.json()
2530
+ logging.warning("[push_notifications] Spoolman spool %d: HTTP "
2531
+ "%s", spool_id, resp.status_code)
2532
+ except Exception as e:
2533
+ logging.warning("[push_notifications] Spoolman spool %d: %s",
2534
+ spool_id, e)
2535
+ return None
2536
+
2537
+ @staticmethod
2538
+ def _remaining(spool: Optional[Dict[str, Any]]) -> Optional[float]:
2539
+ r = (spool or {}).get("remaining_weight")
2540
+ return float(r) if isinstance(r, (int, float)) else None
2541
+
2542
+ @staticmethod
2543
+ def _full_weight(spool: Optional[Dict[str, Any]]) -> Optional[float]:
2544
+ """A full spool's filament weight: the spool's own initial weight,
2545
+ else the filament's net weight."""
2546
+ sp = spool or {}
2547
+ for w in (sp.get("initial_weight"),
2548
+ (sp.get("filament") or {}).get("weight")):
2549
+ if isinstance(w, (int, float)) and w > 0:
2550
+ return float(w)
2551
+ return None
2552
+
2553
+ @staticmethod
2554
+ def _toolhead(channel: int) -> str:
2555
+ return f"Toolhead {channel + 1}"
2556
+
2557
+ @staticmethod
2558
+ def _spool_name(spool: Optional[Dict[str, Any]]) -> str:
2559
+ fil = (spool or {}).get("filament") or {}
2560
+ vendor = (fil.get("vendor") or {}).get("name") or ""
2561
+ fname = fil.get("name") or fil.get("material") or ""
2562
+ # Spoolman filament names often already start with the vendor
2563
+ if vendor and fname.lower().startswith(vendor.lower()):
2564
+ vendor = ""
2565
+ return " ".join(x for x in (vendor, fname) if x)
2566
+
2567
+ def _spool_colour(self, spool: Optional[Dict[str, Any]],
2568
+ ch: int) -> Any:
2569
+ """The filament's colour for the picture: one RGB, or a list of
2570
+ them for a multi-colour filament (Spoolman's multi_color_hexes,
2571
+ else the printer's filament_color_multi), falling back to the
2572
+ single colour (Spoolman's color_hex, the printer's rgba)."""
2573
+ if images is None:
2574
+ return None
2575
+ fil = (spool or {}).get("filament") or {}
2576
+ multi = [images.parse_hex(v) for v in
2577
+ str(fil.get("multi_color_hexes") or "").split(",") if v]
2578
+ multi = [c for c in multi if c]
2579
+ if len(multi) > 1:
2580
+ return multi
2581
+ single = images.parse_hex(fil.get("color_hex"))
2582
+ if single:
2583
+ return single
2584
+ if multi:
2585
+ return multi[0]
2586
+ ptc = self._job_ptc or self._status["print_task_config"]
2587
+ pm = list(ptc.get("filament_color_multi") or [])
2588
+ if ch < len(pm) and isinstance(pm[ch], dict):
2589
+ cols = [images.parse_hex(v) for v in (pm[ch].get("colors") or [])]
2590
+ cols = [c for c in cols if c]
2591
+ if len(cols) > 1:
2592
+ return cols
2593
+ if cols:
2594
+ return cols[0]
2595
+ cols = list(ptc.get("filament_color_rgba") or [])
2596
+ if ch < len(cols):
2597
+ return images.parse_hex(cols[ch])
2598
+ return None
2599
+
2600
+ @staticmethod
2601
+ def _cap(text: str, cap: Optional[int]) -> str:
2602
+ if cap is None or len(text) <= cap:
2603
+ return text
2604
+ return text[:max(1, cap - 1)].rstrip() + "…"
2605
+
2606
+ @staticmethod
2607
+ def _printer_filament(ptc: Dict[str, Any], ch: int) -> str:
2608
+ """The filament as the printer knows it (no Spoolman): vendor and
2609
+ type, e.g. "Elegoo PLA"."""
2610
+ parts = []
2611
+ for k in ("filament_vendor", "filament_type"):
2612
+ v = list(ptc.get(k) or [])
2613
+ val = str(v[ch]).strip() if ch < len(v) else ""
2614
+ if val and val.upper() != "NONE":
2615
+ parts.append(val)
2616
+ return " ".join(parts)
2617
+
2618
+ def _desc(self, ch: int, name: str, sid: int,
2619
+ cap: Optional[int] = None) -> str:
2620
+ inner = ", ".join(x for x in (self._cap(name, cap),
2621
+ f"spool #{sid}" if sid else "") if x)
2622
+ return f"{self._toolhead(ch)} ({inner})" if inner \
2623
+ else self._toolhead(ch)
2624
+
2625
+ def _needed_grams(self, filename: str) -> Dict[int, float]:
2626
+ """Slicer filament weight per slicer filament (T0..)."""
2627
+ try:
2628
+ fm = self.server.lookup_component("file_manager")
2629
+ meta = fm.get_metadata_storage().get(filename, {}) or {}
2630
+ except Exception:
2631
+ return {}
2632
+ weights = meta.get("filament_weight")
2633
+ if isinstance(weights, (int, float)):
2634
+ weights = [weights]
2635
+ if not isinstance(weights, list):
2636
+ total = meta.get("filament_weight_total")
2637
+ weights = [total] if total else []
2638
+ return {i: float(w) for i, w in enumerate(weights)
2639
+ if isinstance(w, (int, float)) and w > 0}
2640
+
2641
+ def _nozzle(self, ch: int) -> Tuple[Any, Any]:
2642
+ st = self._status.get(EXTRUDERS[ch] if ch < len(EXTRUDERS)
2643
+ else "") or {}
2644
+ return st.get("nozzle_diameter"), st.get("nozzle_volume_type")
2645
+
2646
+ @staticmethod
2647
+ def _colour_multi(src: Dict[str, Any], c: int) -> Any:
2648
+ """The printer compares colours on filament_color_multi (the whole
2649
+ set, for multi-colour filaments); older firmware has only
2650
+ filament_color_rgba."""
2651
+ multi = list(src.get("filament_color_multi") or [])
2652
+ if c < len(multi) and isinstance(multi[c], dict) and multi[c]:
2653
+ return multi[c]
2654
+ rgba = list(src.get("filament_color_rgba") or [])
2655
+ return {"colors": [str(rgba[c])[:6].upper()]} if c < len(rgba) \
2656
+ else None
2657
+
2658
+ def _refill_check(self, ptc: Dict[str, Any], ch: int, c: int,
2659
+ src: Dict[str, Any]) -> Tuple[bool, str]:
2660
+ """Whether the printer may refill `ch` from `c`, as
2661
+ print_task_config.py (cmd_INNER_AUTO_REPLENISH_FILAMENT) decides
2662
+ it, and if not, why, in words for the notification ("" when `c`
2663
+ isn't even the same material, which isn't worth a line)."""
2664
+ def field(d: Dict[str, Any], k: str, i: int) -> str:
2665
+ v = list(d.get(k) or [])
2666
+ return str(v[i]).strip() if i < len(v) else ""
2667
+ exist = list(ptc.get("filament_exist") or [])
2668
+ if c < len(exist) and not exist[c]:
2669
+ return False, ""
2670
+ same_type = field(ptc, "filament_type", c).lower() == \
2671
+ field(src, "filament_type", ch).lower() != ""
2672
+ nd, nv = self._nozzle(ch)
2673
+ cd, cv = self._nozzle(c)
2674
+ if nd is not None and cd is not None and (nd != cd or nv != cv):
2675
+ return False, (f"nozzle {cd} vs {nd} mm" if nd != cd
2676
+ else f"{cv or 'other'} nozzle vs {nv or 'other'}"
2677
+ ) if same_type else ""
2678
+ if field(ptc, "filament_vendor", c).upper() in ("", "NONE"):
2679
+ return False, ("no filament info set on the printer (vendor "
2680
+ "NONE)" if same_type else "")
2681
+ for k, what in (("filament_vendor", "vendor"),
2682
+ ("filament_type", "type"),
2683
+ ("filament_sub_type", "sub-type")):
2684
+ if field(ptc, k, c).lower() != field(src, k, ch).lower():
2685
+ return False, (f"different {what} ({field(ptc, k, c) or 'none'}"
2686
+ f" vs {field(src, k, ch) or 'none'})"
2687
+ if same_type else "")
2688
+ if not ptc.get("replenish_ignore_color") and \
2689
+ self._colour_multi(ptc, c) != self._colour_multi(src, ch):
2690
+ return False, "colour differs (refill needs an exact match)"
2691
+ return True, ""
2692
+
2693
+ def _backups(self, ptc: Dict[str, Any], ch: int,
2694
+ attrs_from: Optional[Dict[str, Any]] = None) -> List[int]:
2695
+ """Channels the printer can refill `ch` from, in the order it would
2696
+ use them (each one refilled from in turn as the one before runs
2697
+ out). `attrs_from` gives `ch`'s own filament (e.g. as it was at
2698
+ print start, before it ran out).
2699
+
2700
+ print_task_config.py (cmd_INNER_AUTO_REPLENISH_FILAMENT): same
2701
+ nozzle, a vendor set (not NONE), vendor, type and sub-type equal.
2702
+ Of those, the first exact colour match (filament_color_multi) in
2703
+ toolhead order; else, with "ignore colour", the nearest first
2704
+ colour (Rec. 601 weighted RGB distance). When that spool runs out,
2705
+ the next is chosen the same way from it."""
2706
+ if not ptc.get("auto_replenish_filament"):
2707
+ return []
2708
+ ignore = bool(ptc.get("replenish_ignore_color"))
2709
+
2710
+ def first_colour(src: Dict[str, Any], c: int) -> str:
2711
+ m = self._colour_multi(src, c) or {}
2712
+ cols = list(m.get("colors") or [])
2713
+ return str(cols[0]).upper()[:6] if cols else ""
2714
+
2715
+ def distance(a: str, b: str) -> float:
2716
+ try:
2717
+ d = [int(a[i:i + 2], 16) - int(b[i:i + 2], 16)
2718
+ for i in (0, 2, 4)]
2719
+ except ValueError:
2720
+ return float("inf")
2721
+ return ((0.299 * d[0]) ** 2 + (0.587 * d[1]) ** 2
2722
+ + (0.114 * d[2]) ** 2) ** 0.5
2723
+ ids = list(ptc.get("filament_spool_id") or [])
2724
+ src = attrs_from or ptc
2725
+ relaxed = dict(ptc, replenish_ignore_color=True)
2726
+ left = [c for c in range(len(ids)) if c != ch
2727
+ and self._refill_check(relaxed, ch, c, src)[0]]
2728
+ order: List[int] = []
2729
+ cur_src, cur = src, ch
2730
+ while left:
2731
+ exact = [c for c in left if self._colour_multi(ptc, c)
2732
+ == self._colour_multi(cur_src, cur)]
2733
+ if exact:
2734
+ pick = exact[0]
2735
+ elif ignore:
2736
+ tc = first_colour(cur_src, cur)
2737
+ pick = min(left, key=lambda c: (
2738
+ distance(first_colour(ptc, c), tc), c))
2739
+ else:
2740
+ break
2741
+ order.append(pick)
2742
+ left.remove(pick)
2743
+ cur_src, cur = ptc, pick
2744
+ return order
2745
+
2746
+ def _refill_blockers(self, ptc: Dict[str, Any], ch: int,
2747
+ attrs_from: Optional[Dict[str, Any]] = None
2748
+ ) -> List[Tuple[int, str]]:
2749
+ """Toolheads with the same material as `ch` that the printer will
2750
+ not refill from, and why: the moment to fix it is now."""
2751
+ src = attrs_from or ptc
2752
+ ids = list(ptc.get("filament_spool_id") or [])
2753
+ out = []
2754
+ for c in range(len(ids)):
2755
+ if c == ch:
2756
+ continue
2757
+ ok, why = self._refill_check(ptc, ch, c, src)
2758
+ if not ok and why:
2759
+ out.append((c, why))
2760
+ return out
2761
+
2762
+ @staticmethod
2763
+ def _status_of(remaining: float, need: float) -> str:
2764
+ """red: not enough; orange: enough, but only just; green: fine."""
2765
+ if remaining < need:
2766
+ return "red"
2767
+ if remaining < need + max(FILAMENT_CLOSE_G, need * FILAMENT_CLOSE):
2768
+ return "orange"
2769
+ return "green"
2770
+
2771
+ def _seen(self, sid: int, remaining: Optional[float]) -> None:
2772
+ """Spoolman's weight for a spool, as read during the job."""
2773
+ if not sid or remaining is None or remaining <= 0:
2774
+ return
2775
+ self._spool_start.setdefault(sid, remaining)
2776
+ self._spool_now[sid] = remaining
2777
+
2778
+ def _used(self) -> Optional[Dict[int, float]]:
2779
+ """Grams each spool lost since the job started, or None if Spoolman
2780
+ isn't tracking usage (nothing used yet well into the print)."""
2781
+ used = {sid: max(0., g - self._spool_now.get(sid, g))
2782
+ for sid, g in self._spool_start.items()}
2783
+ if sum(used.values()) < USAGE_MIN_G and \
2784
+ self._progress() > USAGE_GRACE:
2785
+ return None
2786
+ return used
2787
+
2788
+ def _need_now(self, ch: int) -> Optional[float]:
2789
+ """Grams the rest of the print job still needs from channel `ch`:
2790
+ the slicer's total for the slicer filaments on it now, minus what
2791
+ their spools (including refill spools they moved from) used since
2792
+ the job started, per Spoolman. None when that can't be measured:
2793
+ no slicer weights, Spoolman not tracking usage, or the watch began
2794
+ partway through the job (e.g. power-loss recovery)."""
2795
+ if not self._job_weights or \
2796
+ self._job_progress0 > RECOVERY_MIN_PROGRESS:
2797
+ return None
2798
+ used = self._used()
2799
+ if used is None:
2800
+ return None
2801
+ emap = self._job_map or []
2802
+ tools = [t for t in self._job_weights
2803
+ if (emap[t] if t < len(emap) else t) == ch]
2804
+ if not tools:
2805
+ return None
2806
+ total = sum(self._job_weights[t] for t in tools)
2807
+ spools: Set[int] = set()
2808
+ for t in tools:
2809
+ spools |= self._tool_spools.get(t, set())
2810
+ return max(0., total - sum(used.get(sid, 0.) for sid in spools))
2811
+
2812
+ # -- low-spool memory (once per spool until it is refilled) --
2813
+
2814
+ def _low_load(self) -> Dict[str, float]:
2815
+ if self._low_sent is None:
2816
+ try:
2817
+ with open(self._low_path) as f:
2818
+ data = json.load(f)
2819
+ self._low_sent = {str(k): float(v) for k, v in
2820
+ (data.get("low_sent") or {}).items()}
2821
+ th = data.get("thread")
2822
+ if self._thread is None and isinstance(th, dict) and \
2823
+ th.get("id") and "file" in th:
2824
+ self._thread = {"id": str(th["id"]),
2825
+ "file": str(th["file"])}
2826
+ except Exception:
2827
+ self._low_sent = {}
2828
+ return self._low_sent
2829
+
2830
+ def _low_save(self) -> None:
2831
+ try:
2832
+ tmp = self._low_path + ".tmp"
2833
+ state = {"low_sent": self._low_sent or {}}
2834
+ if self._thread:
2835
+ state["thread"] = self._thread
2836
+ with open(tmp, "w") as f:
2837
+ json.dump(state, f)
2838
+ os.replace(tmp, self._low_path)
2839
+ except Exception as e:
2840
+ logging.warning("[push_notifications] can't save %s: %s",
2841
+ self._low_path, e)
2842
+
2843
+ def _low_threshold(self, full: Optional[float]) -> float:
2844
+ """Whichever comes first: % of a full spool, or grams."""
2845
+ pct = (self.filament_low_percent / 100. * full
2846
+ if self.filament_low_percent and full else 0.)
2847
+ return max(pct, self.filament_low_grams or 0.)
2848
+
2849
+ def _low_mark(self, sid: int) -> None:
2850
+ self._low_load()[str(sid)] = time.time()
2851
+ self._low_save()
2852
+
2853
+ async def _check_low(
2854
+ self, ptc: Dict[str, Any], ch: int, sid: int,
2855
+ spool: Optional[Dict[str, Any]], remaining: float,
2856
+ needs: Dict[int, float]
2857
+ ) -> Optional[Dict[str, Any]]:
2858
+ """The spool if it just went below the running-low threshold (sent
2859
+ together with the others from the same check, see _low_alert)."""
2860
+ if remaining <= 0:
2861
+ return None # amount unknown
2862
+ sent = self._low_load()
2863
+ thr = self._low_threshold(self._full_weight(spool))
2864
+ key = str(sid)
2865
+ if key in sent and (not thr or remaining > thr):
2866
+ # Refilled, re-weighed or replaced: may alert again later
2867
+ del sent[key]
2868
+ self._low_save()
2869
+ return None
2870
+ if not thr or remaining > thr or key in sent:
2871
+ return None
2872
+ self._low_mark(sid)
2873
+ if "filament_low" not in self.rows:
2874
+ return None
2875
+ # With refill on, the spools the printer would refill it from
2876
+ return await self._issue(ptc, ch, sid, spool, remaining,
2877
+ needs.get(ch) or 0., needs)
2878
+
2879
+ def _low_text(self, it: Dict[str, Any], cap: Optional[int]) -> str:
2880
+ left = (f"{it['rem']:.0f} of {it['full']:.0f} g left" if it["full"]
2881
+ else f"{it['rem']:.0f} g left")
2882
+ lines = [f"{self._desc(it['ch'], it['name'], it['sid'], cap)}: "
2883
+ f"{left}.",
2884
+ f"This print needs ~{it['need']:.0f} g more."
2885
+ if it["need"] else ""]
2886
+ if it["refill_on"] and it["backups"]:
2887
+ for b in it["backups"]:
2888
+ lines.append(
2889
+ f"Refill is on: "
2890
+ f"{self._desc(b['ch'], b['name'], b['sid'], cap)}"
2891
+ + (f", {b['rem']:.0f} g left" if b["rem"] > 0
2892
+ else ", amount left unknown")
2893
+ + f", is {self._match_text(b)}.")
2894
+ elif it["refill_on"]:
2895
+ lines.append("Refill is on. " + (
2896
+ "No exact match spool for refill found on printer."
2897
+ if it["exact"] else
2898
+ "No compatible spool for refill found on printer."))
2899
+ lines.extend(self._blocker_lines(it))
2900
+ return self._lines(*lines)
2901
+
2902
+ @staticmethod
2903
+ def _match_text(b: Dict[str, Any]) -> str:
2904
+ return ("an exact match" if b.get("exact") else
2905
+ "an inexact match (same filament, different colour)")
2906
+
2907
+ def _blocker_lines(self, it: Dict[str, Any]) -> List[str]:
2908
+ return [f"Won't refill from {self._toolhead(c)}: {why}."
2909
+ for c, why in it.get("blockers") or []]
2910
+
2911
+ def _low_row(self, it: Dict[str, Any]) -> Dict[str, Any]:
2912
+ """Like the not-enough picture (with the refill spool, or a note
2913
+ that there is none), worded as running low."""
2914
+ row = self._issue_row(dict(it, status="orange"))
2915
+ row.update(word="Running low.",
2916
+ needs=(f"Needs ~{it['need']:.0f} g more." if it["need"]
2917
+ else ""))
2918
+ for r in row.get("refills") or []:
2919
+ r["status"] = "ink" if not it["need"] else r["status"]
2920
+ return row
2921
+
2922
+ async def _low_alert(self, items: List[Optional[Dict[str, Any]]]) -> None:
2923
+ """Spools that went below the threshold in the same check: one
2924
+ notification, in toolhead order (split only if the service's
2925
+ length limit requires it)."""
2926
+ lows = sorted((it for it in items if it), key=lambda i: i["ch"])
2927
+ if not lows:
2928
+ return
2929
+ title = self._title("filament_low") if len(lows) == 1 else \
2930
+ self._title("filament_lows", n=len(lows))
2931
+ await self._send_parts("filament_low", None, packer=(
2932
+ title, "", lows, self._low_text, self._low_row))
2933
+
2934
+ # -- not enough filament --
2935
+
2936
+ async def _issue(
2937
+ self, ptc: Dict[str, Any], ch: int, sid: int,
2938
+ spool: Optional[Dict[str, Any]], remaining: float, need: float,
2939
+ needs: Dict[int, float]
2940
+ ) -> Dict[str, Any]:
2941
+ """Everything a not-enough notification says about one toolhead,
2942
+ including the spools the printer could refill it from."""
2943
+ ids = list(ptc.get("filament_spool_id") or [])
2944
+ it: Dict[str, Any] = dict(
2945
+ ch=ch, sid=sid, name=self._spool_name(spool), rem=remaining,
2946
+ full=self._full_weight(spool), colour=self._spool_colour(spool, ch),
2947
+ need=need, status=self._status_of(remaining, need),
2948
+ refill_on=bool(ptc.get("auto_replenish_filament")),
2949
+ exact=not ptc.get("replenish_ignore_color"), backups=[])
2950
+ unknown_backup = False
2951
+ total = max(0., remaining)
2952
+
2953
+ own_colour = self._colour_multi(self._job_ptc or ptc, ch)
2954
+ it["blockers"] = self._refill_blockers(ptc, ch, self._job_ptc or None) \
2955
+ if it["refill_on"] else []
2956
+ for c in self._backups(ptc, ch, self._job_ptc or None):
2957
+ b_sid = ids[c] if c < len(ids) else 0
2958
+ b = await self._spool(b_sid)
2959
+ b_rem = self._remaining(b)
2960
+ if b_rem is None:
2961
+ continue
2962
+ own = needs.get(c, 0.)
2963
+ if b_rem <= 0:
2964
+ # Loaded, but Spoolman thinks it's empty: amount unknown
2965
+ unknown_backup = True
2966
+ elif b_rem - own <= 0:
2967
+ # The print needs all of it itself: nothing to spare
2968
+ continue
2969
+ else:
2970
+ total += b_rem - own
2971
+ it["backups"].append(dict(
2972
+ ch=c, sid=b_sid, name=self._spool_name(b), rem=b_rem,
2973
+ full=self._full_weight(b), colour=self._spool_colour(b, c),
2974
+ own=own, exact=self._colour_multi(ptc, c) == own_colour))
2975
+ # multiACE: the slots Quad Replenish would reload from, before the
2976
+ # other toolheads (quad_first, the default) or after them. Their
2977
+ # amounts are not known here.
2978
+ ace_backups = self._ace_backups(ch)
2979
+ if ace_backups:
2980
+ unknown_backup = True
2981
+ it["backups"] = (ace_backups + it["backups"]
2982
+ if self._ace().get("quad_first", True)
2983
+ else it["backups"] + ace_backups)
2984
+ it["total"] = total
2985
+ it["together"] = self._status_of(total, need)
2986
+ it["unknown_backup"] = unknown_backup
2987
+ if unknown_backup and it["together"] != "green":
2988
+ it["together"] = "orange"
2989
+ if remaining <= 0:
2990
+ it["status"] = "unknown"
2991
+ return it
2992
+
2993
+ def _issue_text(self, it: Dict[str, Any], cap: Optional[int]) -> str:
2994
+ desc = self._desc(it['ch'], it['name'], it['sid'], cap)
2995
+ if it["status"] == "unknown":
2996
+ lines = [f"{desc}: amount of filament left is unknown (Spoolman "
2997
+ f"shows {it['rem']:.0f} g). Needs ~{it['need']:.0f} g."]
2998
+ else:
2999
+ what = ("not enough filament." if it["status"] == "red"
3000
+ else "remaining filament may be insufficient.")
3001
+ lines = [f"{desc}: {what} Needs ~{it['need']:.0f} g, "
3002
+ f"{it['rem']:.0f} g left."]
3003
+ if it["backups"]:
3004
+ for b in it["backups"]:
3005
+ if b.get("ace"):
3006
+ lines.append(f"Quad Replenish is on: "
3007
+ f"{self._bdesc(b, cap)}, is an exact match.")
3008
+ continue
3009
+ lines.append(
3010
+ f"Refill is on: {self._bdesc(b, cap)}"
3011
+ + (f", {b['rem']:.0f} g left" if b["rem"] > 0
3012
+ else ", amount left unknown")
3013
+ + f", is {self._match_text(b)}." + (
3014
+ f" This print also needs ~{b['own']:.0f} g from it."
3015
+ if b["own"] and b["rem"] > 0 else ""))
3016
+ unknown = it["unknown_backup"] or it["status"] == "unknown"
3017
+ if unknown and it["together"] == "green":
3018
+ lines.append(f"Together at least {it['total']:.0f} g: "
3019
+ "enough for this print.")
3020
+ elif unknown:
3021
+ lines.append(f"Together {it['total']:.0f} g plus an unknown "
3022
+ "amount: may be insufficient.")
3023
+ else:
3024
+ lines.append(f"Together {it['total']:.0f} g: " + {
3025
+ "green": "enough for this print.",
3026
+ "orange": "may be insufficient.",
3027
+ "red": "still not enough."}[it["together"]])
3028
+ elif it["refill_on"]:
3029
+ lines.append("Refill is on. " + (
3030
+ "No exact match spool for refill found on printer."
3031
+ if it["exact"] else
3032
+ "No compatible spool for refill found on printer."))
3033
+ if not it["backups"]:
3034
+ lines.extend(self._ace_none_lines(it["ch"]))
3035
+ lines.extend(self._blocker_lines(it))
3036
+ return "\n".join(lines)
3037
+
3038
+ def _issue_row(self, it: Dict[str, Any]) -> Dict[str, Any]:
3039
+ row = dict(toolhead=self._toolhead(it["ch"]),
3040
+ word={"red": "Not enough.", "orange": "May be insufficient.",
3041
+ "unknown": "Amount unknown."}[it["status"]],
3042
+ status=it["status"], needs=f"Needs ~{it['need']:.0f} g.",
3043
+ remaining=it["rem"], full=it["full"], colour=it["colour"],
3044
+ name=it["name"], unknown=it["status"] == "unknown")
3045
+ if it["backups"]:
3046
+ # The whole chain, in the order the printer would use it
3047
+ row["refills"] = [dict(
3048
+ toolhead=self._blabel(b), remaining=b["rem"],
3049
+ full=b["full"], colour=b["colour"], name=b["name"],
3050
+ status=it["together"],
3051
+ unknown=b["rem"] is not None and b["rem"] <= 0,
3052
+ match="Exact match" if b.get("exact") else "Inexact match")
3053
+ for b in it["backups"]]
3054
+ elif it["refill_on"]:
3055
+ blockers = it.get("blockers") or []
3056
+ if blockers:
3057
+ c, why = blockers[0]
3058
+ row["note"] = ["Refill is on.", f"Won't refill from "
3059
+ f"{self._toolhead(c)}:", why]
3060
+ else:
3061
+ row["note"] = ["Refill is on.",
3062
+ "No exact match spool for" if it["exact"]
3063
+ else "No compatible spool for",
3064
+ "refill found on printer."]
3065
+ elif self._ace_head(it["ch"]) is not None:
3066
+ row["note"] = self._ace_none_lines(it["ch"])
3067
+ return row
3068
+
3069
+ def _short_title(self, issues: List[Dict[str, Any]]) -> str:
3070
+ if len(issues) == 1:
3071
+ return self._title({"red": "filament_short",
3072
+ "orange": "filament_close",
3073
+ "unknown": "filament_unknown"}[
3074
+ issues[0]["status"]])
3075
+ return self._title("filament_problems", n=len(issues))
3076
+
3077
+ async def _short_alert(
3078
+ self, filename: str, issues: List[Dict[str, Any]]
3079
+ ) -> None:
3080
+ """One notification (or as few as the service's length limit
3081
+ allows) for all toolheads at risk, in toolhead order."""
3082
+ issues = sorted(issues, key=lambda i: i["ch"])
3083
+ title = self._short_title(issues)
3084
+ await self._send_parts("filament_short", None, packer=(
3085
+ title, shown(filename), issues, self._issue_text,
3086
+ self._issue_row))
3087
+
3088
+ async def _filament_watch(
3089
+ self, job: int, filename: str, progress: float, check_start: bool
3090
+ ) -> None:
3091
+ ptc = (await self._query({"print_task_config": PTC_FIELDS})
3092
+ ).get("print_task_config") or {}
3093
+ self._status["print_task_config"].update(ptc)
3094
+ self._job_ptc = dict(ptc)
3095
+ ids = list(ptc.get("filament_spool_id") or [])
3096
+ emap = list(ptc.get("extruder_map_table") or [])
3097
+ self._job_map = emap
3098
+ weights = self._needed_grams(filename)
3099
+ self._job_weights = weights
3100
+ self._job_tools = set(weights) or {
3101
+ t for t, c in enumerate(emap[:len(ids)]) if isinstance(c, int)}
3102
+ if not self._spoolman_active():
3103
+ return # seeded for the runout hold and the backup switch
3104
+ need: Dict[int, float] = {}
3105
+ for tool, grams in weights.items():
3106
+ ch = emap[tool] if tool < len(emap) else tool
3107
+ if isinstance(ch, int) and 0 <= ch < len(ids):
3108
+ # Started partway (power-loss recovery): what's left of the
3109
+ # job can't be measured, so only the spools are watched
3110
+ need[ch] = need.get(ch, 0.) + (
3111
+ grams if progress <= RECOVERY_MIN_PROGRESS else 0.)
3112
+ if ids[ch]:
3113
+ self._tool_spools.setdefault(tool, set()).add(ids[ch])
3114
+ if not need:
3115
+ used = list(ptc.get("extruders_used") or [])
3116
+ need = {ch: 0. for ch, u in enumerate(used) if u}
3117
+ # channel -> {sid, reported: 0 nothing, 1 orange, 2 red}
3118
+ track: Dict[int, Dict[str, Any]] = {}
3119
+ issues = []
3120
+ spools: Dict[int, Tuple[Optional[Dict[str, Any]], float]] = {}
3121
+ for ch in sorted(need):
3122
+ sid = ids[ch] if ch < len(ids) else 0
3123
+ if not sid:
3124
+ self._skip(f"filament ({self._toolhead(ch)})",
3125
+ "no Spoolman spool assigned")
3126
+ continue
3127
+ spool = await self._spool(sid)
3128
+ remaining = self._remaining(spool)
3129
+ if remaining is None:
3130
+ self._skip(f"filament (spool #{sid})",
3131
+ "Spoolman has no remaining weight for it")
3132
+ continue
3133
+ track[ch] = dict(sid=sid, reported=0)
3134
+ spools[ch] = (spool, remaining)
3135
+ self._seen(sid, remaining)
3136
+ # Spoolman at 0 g (or less) for a loaded spool: amount unknown
3137
+ if remaining <= 0 and not self.filament_unknown:
3138
+ continue
3139
+ if need[ch] > 0 and (remaining <= 0 or self._status_of(
3140
+ remaining, need[ch]) != "green"):
3141
+ it = await self._issue(ptc, ch, sid, spool, remaining,
3142
+ need[ch], need)
3143
+ # Unknown amounts are only reported at print start
3144
+ track[ch]["reported"] = RANK.get(it["status"], 0)
3145
+ issues.append(it)
3146
+ if issues and check_start and "filament_short" in self.rows:
3147
+ # One warning at print start: a spool that is not enough (or
3148
+ # only just) is not also reported as running low
3149
+ for it in issues:
3150
+ self._low_mark(it["sid"])
3151
+ await self._short_alert(filename, issues)
3152
+ await self._low_alert([
3153
+ await self._check_low(ptc, ch, track[ch]["sid"], spool,
3154
+ remaining, need)
3155
+ for ch, (spool, remaining) in spools.items()])
3156
+ if not track:
3157
+ return
3158
+ logging.info("[push_notifications] filament: watching %s", ", ".join(
3159
+ f"{self._toolhead(ch)} spool #{t['sid']}"
3160
+ for ch, t in track.items()))
3161
+ while True:
3162
+ await asyncio.sleep(FILAMENT_POLL)
3163
+ if job != self._filament_job or not self._job_active:
3164
+ return
3165
+ if self._status["print_stats"].get("state") != "printing":
3166
+ continue
3167
+ await self._poll_spools(filename, track)
3168
+
3169
+ async def _poll_spools(
3170
+ self, filename: str, track: Dict[int, Dict[str, Any]]
3171
+ ) -> None:
3172
+ ptc = self._status["print_task_config"]
3173
+ ids = list(ptc.get("filament_spool_id") or [])
3174
+ # Toolheads the printer switched to (refill) are watched too
3175
+ for ch in self._switched_to - set(track):
3176
+ if ch < len(ids) and ids[ch]:
3177
+ track[ch] = dict(sid=ids[ch], reported=0)
3178
+ fetched: Dict[int, Tuple[int, Optional[Dict[str, Any]],
3179
+ Optional[float]]] = {}
3180
+ for ch, t in track.items():
3181
+ sid = ids[ch] if ch < len(ids) and ids[ch] else t["sid"]
3182
+ spool = await self._spool(sid)
3183
+ remaining = self._remaining(spool)
3184
+ self._seen(sid, remaining)
3185
+ fetched[ch] = (sid, spool, remaining)
3186
+ needs = {ch: self._need_now(ch) or 0. for ch in track}
3187
+ issues = []
3188
+ lows = []
3189
+ for ch, t in track.items():
3190
+ sid, spool, remaining = fetched[ch]
3191
+ if remaining is None:
3192
+ continue
3193
+ if sid != t["sid"]:
3194
+ logging.info("[push_notifications] filament: %s now spool "
3195
+ "#%d (%.0f g)", self._toolhead(ch), sid,
3196
+ remaining)
3197
+ t.update(sid=sid, reported=0)
3198
+ need = needs.get(ch) or 0.
3199
+ if remaining <= 0:
3200
+ continue # amount unknown: nothing to compare
3201
+ status = self._status_of(remaining, need) if need > 0 \
3202
+ else "green"
3203
+ # Only when it got worse than already reported; then it isn't
3204
+ # also reported as running low
3205
+ if RANK[status] > t["reported"] and "filament_short" in self.rows:
3206
+ t["reported"] = RANK[status]
3207
+ issues.append(await self._issue(
3208
+ ptc, ch, sid, spool, remaining, need, needs))
3209
+ self._low_mark(sid)
3210
+ else:
3211
+ lows.append(await self._check_low(ptc, ch, sid, spool,
3212
+ remaining, needs))
3213
+ if issues:
3214
+ await self._short_alert(filename, issues)
3215
+ await self._low_alert(lows)
3216
+
3217
+ def _refill_expected(self, ex: Dict[str, Any]) -> bool:
3218
+ """A filament runout on a toolhead with a backup spool the printer
3219
+ will refill from by itself. Only when the refill would be reported
3220
+ (the Switched to backup spool row): a held-and-dropped error with
3221
+ nothing in its place would make the refill invisible."""
3222
+ if "spool_switch" not in self.rows:
3223
+ return False
3224
+ try:
3225
+ # 523 is the whole toolhead module (tangles, pickup and park
3226
+ # failures too); code 0 with "runout" is the runout itself
3227
+ runout = int(ex.get("id", -1)) == RUNOUT_EXCEPTION_ID and \
3228
+ int(ex.get("code", -1)) == 0 and \
3229
+ "runout" in str(ex.get("message", "")).lower()
3230
+ ch = int(ex.get("index", -1))
3231
+ except (TypeError, ValueError):
3232
+ return False
3233
+ ptc = self._status["print_task_config"]
3234
+ return runout and ch >= 0 and bool(
3235
+ self._backups(ptc, ch, self._job_ptc or None)
3236
+ or self._ace_candidates(ch))
3237
+
3238
+ async def _runout_hold(self, filename: str, ex: Dict[str, Any]) -> None:
3239
+ refills = self._refills
3240
+ ch = int(ex.get("index", -1))
3241
+ ace = self._ace_head(ch) is not None
3242
+ if ace:
3243
+ self._ace_runouts.add(ch)
3244
+ deadline = time.monotonic() + (ACE_REFILL_WAIT if ace else REFILL_WAIT)
3245
+ try:
3246
+ while time.monotonic() < deadline:
3247
+ await asyncio.sleep(0.5)
3248
+ if self._refills != refills:
3249
+ self._skip("error", "runout refilled from a backup "
3250
+ "spool (reported as Switched to backup spool)")
3251
+ return
3252
+ if self._status["print_stats"].get("state") != "paused":
3253
+ break # resumed or ended without a refill being seen
3254
+ finally:
3255
+ self._ace_runouts.discard(ch)
3256
+ await self._send("error", self._title("error", kind="paused"),
3257
+ f"{shown(filename)}\n{describe(ex)}")
3258
+
3259
+ def _check_spool_switch(self) -> None:
3260
+ """A slicer filament moved to another toolhead mid-print: the
3261
+ printer refilled from a backup spool."""
3262
+ ptc = self._status["print_task_config"]
3263
+ new = list(ptc.get("extruder_map_table") or [])
3264
+ old = self._job_map or []
3265
+ if not new or new == old:
3266
+ return
3267
+ self._job_map = new
3268
+ if not old:
3269
+ return
3270
+ logging.info("[push_notifications] filament: toolhead map changed "
3271
+ "%s -> %s", old[:4], new[:4])
3272
+ # The printer refills while the print job is paused (it pauses on
3273
+ # the runout, swaps the map, then resumes), and resets the map when
3274
+ # the job ends: only a change during the job counts, and only one
3275
+ # that looks like a refill: every moved slicer filament leaves the
3276
+ # same (empty) toolhead, for a toolhead the printer is allowed to
3277
+ # refill it from. The reset comes ~10 s after the print-end hook,
3278
+ # while print_stats still says "printing", so the hook report
3279
+ # counts as the end too.
3280
+ if (self._job_done_sent or self._status["print_stats"].get("state")
3281
+ not in ("printing", "paused")):
3282
+ return
3283
+ moved = [t for t in range(min(len(old), len(new)))
3284
+ if old[t] != new[t] and t in self._job_tools]
3285
+ if not moved or len(set(old[t] for t in moved)) != 1:
3286
+ return
3287
+ for tool in moved:
3288
+ if new[tool] not in self._backups(ptc, old[tool],
3289
+ self._job_ptc or None):
3290
+ logging.info("[push_notifications] filament: toolhead %d -> "
3291
+ "%d is not a refill, ignored",
3292
+ old[tool] + 1, new[tool] + 1)
3293
+ continue
3294
+ self._switched_to.add(new[tool])
3295
+ self._refills += 1
3296
+ ids = list(ptc.get("filament_spool_id") or [])
3297
+ if new[tool] < len(ids) and ids[new[tool]]:
3298
+ self._tool_spools.setdefault(tool, set()).add(ids[new[tool]])
3299
+ if "spool_switch" in self.rows:
3300
+ self._spawn(self._switch_alert(tool, old[tool], new[tool]))
3301
+
3302
+ async def _switch_alert(self, tool: int, frm: int, to: int) -> None:
3303
+ ptc = self._status["print_task_config"]
3304
+ ids = list(ptc.get("filament_spool_id") or [])
3305
+ f_sid = ids[frm] if frm < len(ids) else 0
3306
+ t_sid = ids[to] if to < len(ids) else 0
3307
+ if not f_sid:
3308
+ # The emptied toolhead's spool is unassigned on runout (SpoolLink
3309
+ # clears it before the refill): name the spool it had at start
3310
+ start = list((self._job_ptc or {}).get("filament_spool_id") or [])
3311
+ f_sid = start[frm] if frm < len(start) else 0
3312
+ f_spool, t_spool = await self._spool(f_sid), await self._spool(t_sid)
3313
+ f_rem = self._remaining(f_spool) or 0.
3314
+ t_rem = self._remaining(t_spool)
3315
+ need = self._need_now(to)
3316
+ filename = self._status["print_stats"].get("filename") or ""
3317
+ f_name = self._spool_name(f_spool) or \
3318
+ self._printer_filament(self._job_ptc or ptc, frm)
3319
+ t_name = self._spool_name(t_spool) or self._printer_filament(ptc, to)
3320
+ body = self._lines(
3321
+ shown(filename),
3322
+ f"From {self._desc(frm, f_name, f_sid)}.",
3323
+ f"To {self._desc(to, t_name, t_sid)}"
3324
+ + (f", {t_rem:.0f} g left" if t_rem is not None else "") + ".")
3325
+ row = dict(toolhead=self._toolhead(frm), word="Switched to refill.",
3326
+ status="red", needs="", remaining=f_rem,
3327
+ full=self._full_weight(f_spool),
3328
+ colour=self._spool_colour(f_spool, frm), name=f_name,
3329
+ refills=[dict(
3330
+ toolhead=self._toolhead(to), remaining=t_rem or 0.,
3331
+ full=self._full_weight(t_spool),
3332
+ colour=self._spool_colour(t_spool, to), name=t_name,
3333
+ status=(self._status_of(t_rem, need)
3334
+ if need and t_rem is not None else "ink"))])
3335
+ await self._send_parts("spool_switch", [(
3336
+ self._title("spool_switch"), body, [row])])
3337
+
3338
+ # -- multiACE --
3339
+ #
3340
+ # klipper/extras/ace.py exposes an `ace` object: head_source (the ACE
3341
+ # and slot feeding each head), aces[].slots[] (status, material,
3342
+ # brand, colour), mode, head_ace, quad_replenish, quad_first. Quad
3343
+ # Replenish (ace.py _quad_replenish_candidate) reloads a head that ran
3344
+ # out from the first slot whose gate has filament and whose declared
3345
+ # material equals the head's (colour too, when both declare one):
3346
+ # head mode, the other slots of the head's own ACE in slot order;
3347
+ # otherwise the same slot of the other ACEs. Nothing in the status
3348
+ # says "refilled": head_source changing while the runout is held is
3349
+ # the signal. Indices are 0-based, shown 1-based as multiACE does.
3350
+
3351
+ def _ace(self) -> Dict[str, Any]:
3352
+ return self._status.get(ACE_OBJECT) or {}
3353
+
3354
+ def _ace_sources(self) -> Dict[str, Tuple[int, int]]:
3355
+ """head_source as {head: (ace, slot)} for the heads an ACE feeds."""
3356
+ out: Dict[str, Tuple[int, int]] = {}
3357
+ for h, src in (self._ace().get("head_source") or {}).items():
3358
+ if isinstance(src, dict) and src.get("ace_index") is not None \
3359
+ and src.get("slot") is not None:
3360
+ out[str(h)] = (int(src["ace_index"]), int(src["slot"]))
3361
+ return out
3362
+
3363
+ def _ace_head(self, ch: int) -> Optional[Tuple[int, int]]:
3364
+ return self._ace_sources().get(str(ch))
3365
+
3366
+ def _ace_slot(self, a: int, s: int) -> Dict[str, Any]:
3367
+ for i, unit in enumerate(self._ace().get("aces") or []):
3368
+ if int(unit.get("idx", i)) == a:
3369
+ slots = list(unit.get("slots") or [])
3370
+ return slots[s] if 0 <= s < len(slots) else {}
3371
+ return {}
3372
+
3373
+ def _ace_ident(self, a: int, s: int) -> Optional[Tuple[str, str]]:
3374
+ """A slot's declared identity, (material, colour): material in
3375
+ lower case, colour as 6 hex digits or "" when not declared; None
3376
+ when the slot declares no material."""
3377
+ slot = self._ace_slot(a, s)
3378
+ mat = str(slot.get("material") or "").strip().lower()
3379
+ if not mat:
3380
+ return None
3381
+ col = slot.get("color")
3382
+ if isinstance(col, (list, tuple)) and len(col) == 3:
3383
+ try:
3384
+ hexcol = "%02X%02X%02X" % tuple(int(c) for c in col)
3385
+ except (TypeError, ValueError):
3386
+ hexcol = ""
3387
+ else:
3388
+ hexcol = str(col or "").strip().lstrip("#").upper()[:6]
3389
+ return mat, hexcol
3390
+
3391
+ def _ace_candidates(self, ch: int) -> List[Tuple[int, int]]:
3392
+ """The slots Quad Replenish would reload head `ch` from, in the
3393
+ order it tries them (ace.py _quad_replenish_candidate)."""
3394
+ ace = self._ace()
3395
+ if not ace.get("quad_replenish"):
3396
+ return []
3397
+ src = (ace.get("head_source") or {}).get(str(ch))
3398
+ if not isinstance(src, dict) or src.get("ace_index") is None \
3399
+ or src.get("slot") is None:
3400
+ return []
3401
+ sa, ss = int(src["ace_index"]), int(src["slot"])
3402
+ want_t = str(src.get("type") or "").strip().lower()
3403
+ want_c = str(src.get("color") or "").strip().lstrip("#").upper()[:6]
3404
+ if not want_t:
3405
+ ident = self._ace_ident(sa, ss)
3406
+ if ident is None:
3407
+ return []
3408
+ want_t, want_c = ident
3409
+ if ace.get("mode") == "head":
3410
+ try:
3411
+ wired = int((ace.get("head_ace") or {}).get(str(ch), ch))
3412
+ except (TypeError, ValueError):
3413
+ wired = ch
3414
+ lanes = [(wired, s) for s in range(4)
3415
+ if not (wired == sa and s == ss)]
3416
+ else:
3417
+ idxs = sorted(int(u.get("idx", i)) for i, u in
3418
+ enumerate(ace.get("aces") or []))
3419
+ lanes = [(a, ch) for a in idxs if a != sa]
3420
+ out = []
3421
+ for a, s in lanes:
3422
+ if self._ace_slot(a, s).get("status") != "ready":
3423
+ continue # gate empty, or busy
3424
+ ident = self._ace_ident(a, s)
3425
+ if ident is None:
3426
+ continue
3427
+ mat, col = ident
3428
+ if mat != want_t or (want_c and col and col != want_c):
3429
+ continue
3430
+ out.append((a, s))
3431
+ return out
3432
+
3433
+ @staticmethod
3434
+ def _ace_label(a: int, s: int) -> str:
3435
+ return f"ACE {a + 1} slot {s + 1}"
3436
+
3437
+ def _ace_name(self, a: int, s: int, ch: int) -> str:
3438
+ """Brand and material of a slot ("Generic PLA"), falling back to
3439
+ what head `ch` captured when it loaded from it."""
3440
+ slot = self._ace_slot(a, s)
3441
+ parts = [str(slot.get(k) or "").strip() for k in ("brand", "material")]
3442
+ name = " ".join(x for x in parts if x)
3443
+ if name:
3444
+ return name
3445
+ src = (self._ace().get("head_source") or {}).get(str(ch)) or {}
3446
+ parts = [str(src.get(k) or "").strip() for k in ("brand", "type")]
3447
+ return " ".join(x for x in parts if x)
3448
+
3449
+ def _ace_colour(self, a: int, s: int, ch: int) -> Any:
3450
+ if images is None:
3451
+ return None
3452
+ ident = self._ace_ident(a, s)
3453
+ if ident and ident[1]:
3454
+ return images.parse_hex(ident[1])
3455
+ src = (self._ace().get("head_source") or {}).get(str(ch)) or {}
3456
+ return images.parse_hex(src.get("color"))
3457
+
3458
+ def _ace_backups(self, ch: int) -> List[Dict[str, Any]]:
3459
+ """Quad Replenish's slots for head `ch` as backup entries (like
3460
+ _issue's toolhead backups; amounts unknown)."""
3461
+ return [dict(ch=ch, ace=(a, s), sid=0, name=self._ace_name(a, s, ch),
3462
+ rem=None, full=None, colour=self._ace_colour(a, s, ch),
3463
+ own=0., exact=True)
3464
+ for a, s in self._ace_candidates(ch)]
3465
+
3466
+ def _ace_none_lines(self, ch: int) -> List[str]:
3467
+ """Why Quad Replenish has nothing for head `ch` (an ACE-fed head
3468
+ only)."""
3469
+ src = self._ace_head(ch)
3470
+ if src is None:
3471
+ return []
3472
+ if not self._ace().get("quad_replenish"):
3473
+ return ["Quad Replenish is off."]
3474
+ where = f"ACE {src[0] + 1}" if self._ace().get("mode") == "head" \
3475
+ else "the other ACEs"
3476
+ return ["Quad Replenish is on.",
3477
+ f"No matching slot on {where}."]
3478
+
3479
+ def _blabel(self, b: Dict[str, Any]) -> str:
3480
+ return self._ace_label(*b["ace"]) if b.get("ace") \
3481
+ else self._toolhead(b["ch"])
3482
+
3483
+ def _bdesc(self, b: Dict[str, Any], cap: Optional[int] = None) -> str:
3484
+ if b.get("ace"):
3485
+ name = self._cap(b["name"], cap)
3486
+ return f"{self._ace_label(*b['ace'])} ({name})" if name \
3487
+ else self._ace_label(*b["ace"])
3488
+ return self._desc(b["ch"], b["name"], b["sid"], cap)
3489
+
3490
+ def _job_channels(self) -> Set[int]:
3491
+ emap = self._job_map or []
3492
+ chs = {emap[t] for t in self._job_tools
3493
+ if t < len(emap) and isinstance(emap[t], int)}
3494
+ return chs or set(range(len(EXTRUDERS)))
3495
+
3496
+ def _track_ace(self) -> None:
3497
+ """head_source changing for a head whose runout is being held: a
3498
+ Quad Replenish reload (the print job resumes by itself). A slot
3499
+ feeding a toolhead of the job going empty: the toolhead is on the
3500
+ last of that spool (the filament path between the ACE and the
3501
+ nozzle), reported once per slot."""
3502
+ new = self._ace_sources()
3503
+ old, self._ace_src = self._ace_src, new
3504
+ state = self._status["print_stats"].get("state")
3505
+ if not self._job_active or state not in ("printing", "paused"):
3506
+ return
3507
+ for h in list(self._ace_runouts):
3508
+ o, n = old.get(str(h)), new.get(str(h))
3509
+ if n is not None and n != o:
3510
+ self._ace_runouts.discard(h)
3511
+ self._refills += 1
3512
+ logging.info("[push_notifications] filament: Toolhead %d "
3513
+ "reloaded from %s", h + 1, self._ace_label(*n))
3514
+ if "spool_switch" in self.rows:
3515
+ self._spawn(self._ace_switch_alert(h, o, n))
3516
+ if "slot_empty" not in self.rows or state != "printing":
3517
+ return
3518
+ for h, (a, s) in new.items():
3519
+ ch = int(h)
3520
+ if ch not in self._job_channels() or (a, s) in self._slot_empty_sent:
3521
+ continue
3522
+ if self._ace_slot(a, s).get("status") == "empty":
3523
+ self._slot_empty_sent.add((a, s))
3524
+ self._spawn(self._slot_empty_alert(ch, a, s))
3525
+
3526
+ def _ace_next_lines(self, ch: int, runs_out: str) -> List[str]:
3527
+ nxt = self._ace_candidates(ch)
3528
+ if nxt:
3529
+ return [f"Next: {self._bdesc(b)}, an exact match."
3530
+ for b in self._ace_backups(ch)]
3531
+ none = self._ace_none_lines(ch)
3532
+ if none:
3533
+ none[-1] = none[-1].rstrip(".") + f": {runs_out}"
3534
+ return none
3535
+
3536
+ def _ace_row(self, ch: int, a: int, s: int, word: str,
3537
+ remaining: Optional[float], status: str,
3538
+ refills: List[Dict[str, Any]]) -> Dict[str, Any]:
3539
+ row = dict(toolhead=self._ace_label(a, s), word=word, status=status,
3540
+ needs="", remaining=remaining, full=None,
3541
+ colour=self._ace_colour(a, s, ch),
3542
+ name=self._ace_name(a, s, ch))
3543
+ if refills:
3544
+ row["refills"] = [dict(
3545
+ toolhead=self._blabel(b), remaining=None, full=None,
3546
+ colour=b["colour"], name=b["name"], status="ink",
3547
+ match="Exact match") for b in refills]
3548
+ elif self._ace_none_lines(ch):
3549
+ row["note"] = self._ace_none_lines(ch)
3550
+ return row
3551
+
3552
+ async def _slot_empty_alert(self, ch: int, a: int, s: int) -> None:
3553
+ filename = self._status["print_stats"].get("filename") or ""
3554
+ th = self._toolhead(ch)
3555
+ body = self._lines(
3556
+ shown(filename),
3557
+ f"{self._ace_label(a, s)} ({self._ace_name(a, s, ch)}), feeding "
3558
+ f"{th}, has run out. {th} is printing what is left in the "
3559
+ "filament path.",
3560
+ *self._ace_next_lines(
3561
+ ch, f"the print job will pause when {th} runs out."))
3562
+ row = self._ace_row(ch, a, s, "Ran empty.", 0., "red",
3563
+ self._ace_backups(ch))
3564
+ await self._send_parts("slot_empty", [(
3565
+ self._title("slot_empty"), body, [row])])
3566
+
3567
+ async def _ace_switch_alert(self, ch: int, old: Optional[Tuple[int, int]],
3568
+ new: Tuple[int, int]) -> None:
3569
+ filename = self._status["print_stats"].get("filename") or ""
3570
+ frm = (f"{self._ace_label(*old)} ({self._ace_name(*old, ch)})"
3571
+ if old else "an empty slot")
3572
+ body = self._lines(
3573
+ shown(filename),
3574
+ f"From {frm} on {self._toolhead(ch)}.",
3575
+ f"To {self._ace_label(*new)} ({self._ace_name(*new, ch)}).",
3576
+ *self._ace_next_lines(
3577
+ ch, f"the print job will pause when it runs out."))
3578
+ row = self._ace_row(ch, *(old or new), "Switched to refill.", 0.,
3579
+ "red", [dict(ace=new, ch=ch,
3580
+ name=self._ace_name(*new, ch),
3581
+ colour=self._ace_colour(*new, ch))]
3582
+ + self._ace_backups(ch))
3583
+ await self._send_parts("spool_switch", [(
3584
+ self._title("spool_switch"), body, [row])])
3585
+
3586
+ # -- sending, split to fit the service --
3587
+
3588
+ @staticmethod
3589
+ def _fits(channel: str, text: str) -> bool:
3590
+ limit = BODY_LIMITS.get(channel)
3591
+ if limit is None:
3592
+ return True
3593
+ chars, n = limit
3594
+ return (len(text) if chars else len(text.encode())) <= n
3595
+
3596
+ def _pack(
3597
+ self, channel: str, header: str, issues: List[Dict[str, Any]],
3598
+ text_fn: Any
3599
+ ) -> List[Tuple[List[int], str]]:
3600
+ """Split the issues into as few messages as fit the channel. One
3601
+ toolhead's text is never split: if it doesn't fit on its own, the
3602
+ file name and then the spool names are shortened (all names to the
3603
+ same length, just enough)."""
3604
+ def body(head: str, blocks: List[str]) -> str:
3605
+ return "\n\n".join([head + "\n" + blocks[0]] + blocks[1:]) \
3606
+ if head else "\n\n".join(blocks)
3607
+
3608
+ def fitted(i: int) -> Tuple[str, str]:
3609
+ for head in (header, self._cap(header, FILE_CAP)):
3610
+ block = text_fn(issues[i], None)
3611
+ if self._fits(channel, body(head, [block])):
3612
+ return head, block
3613
+ head = self._cap(header, FILE_CAP)
3614
+ longest = max([len(issues[i]["name"])] +
3615
+ [len(b["name"]) for b in issues[i]["backups"]])
3616
+ lo, hi = NAME_CAP_MIN, max(NAME_CAP_MIN, longest)
3617
+ while lo < hi: # longest cap that fits
3618
+ mid = (lo + hi + 1) // 2
3619
+ if self._fits(channel, body(head, [text_fn(issues[i], mid)])):
3620
+ lo = mid
3621
+ else:
3622
+ hi = mid - 1
3623
+ return head, text_fn(issues[i], lo)
3624
+
3625
+ out: List[Tuple[List[int], str]] = []
3626
+ idx: List[int] = []
3627
+ blocks: List[str] = []
3628
+ head_cur = header
3629
+ for i in range(len(issues)):
3630
+ head, block = fitted(i)
3631
+ if idx:
3632
+ for h in (head_cur, self._cap(header, FILE_CAP)):
3633
+ if self._fits(channel, body(h, blocks + [block])):
3634
+ head_cur = h
3635
+ idx.append(i)
3636
+ blocks.append(block)
3637
+ break
3638
+ else:
3639
+ out.append((idx, body(head_cur, blocks)))
3640
+ idx, blocks, head_cur = [i], [block], head
3641
+ else:
3642
+ idx, blocks, head_cur = [i], [block], head
3643
+ if idx:
3644
+ out.append((idx, body(head_cur, blocks)))
3645
+ return out
3646
+
3647
+ def _render_rows(self, rows: List[Dict[str, Any]]) -> Optional[str]:
3648
+ if images is None:
3649
+ return None
3650
+ tmpdir = tempfile.mkdtemp(prefix="push_notifications_")
3651
+ try:
3652
+ return images.render(rows, os.path.join(tmpdir, "spools.jpg"))
3653
+ except Exception as e:
3654
+ shutil.rmtree(tmpdir, ignore_errors=True)
3655
+ logging.warning("[push_notifications] spool image failed: %s", e)
3656
+ return None
3657
+
3658
+ async def _send_parts(
3659
+ self, rid: str,
3660
+ messages: Optional[List[Tuple[str, str, List[Dict[str, Any]]]]],
3661
+ packer: Optional[Tuple[Any, ...]] = None
3662
+ ) -> bool:
3663
+ """Send one or more messages, each with its own spool picture, to
3664
+ each of the row's destinations, in their channels' turn. Either
3665
+ fixed `messages` (title, body, image rows), or `packer` = (title,
3666
+ header, issues, text_fn, row_fn), split per channel to fit."""
3667
+ row = self.rows.get(rid)
3668
+ if row is None:
3669
+ return False
3670
+ kind, dests = row
3671
+ tickets = [self._reserve(d.channel, rid) for d in dests]
3672
+ paths: Dict[Tuple[int, ...], Optional[str]] = {}
3673
+ try:
3674
+ per_dest: List[List[Tuple[str, str, Optional[str]]]] = []
3675
+ for d in dests:
3676
+ if packer is None:
3677
+ msgs = [(t, b, rows) for t, b, rows in messages or []]
3678
+ keyed = [((k,), m) for k, m in enumerate(msgs)]
3679
+ else:
3680
+ title, header, issues, text_fn, row_fn = packer
3681
+ parts = self._pack(d.channel, header, issues, text_fn)
3682
+ keyed = []
3683
+ for n, (idx, body) in enumerate(parts, 1):
3684
+ t = title if len(parts) == 1 \
3685
+ else f"{title} ({n}/{len(parts)})"
3686
+ keyed.append((tuple(idx), (
3687
+ t, body, [row_fn(issues[i]) for i in idx])))
3688
+ out = []
3689
+ for key, (t, b, rows) in keyed:
3690
+ path = None
3691
+ if kind == "spool":
3692
+ if key not in paths:
3693
+ drawn = await self.eventloop.run_in_thread(
3694
+ self._render_rows, rows)
3695
+ paths[key] = self._named_copy(drawn, t)
3696
+ if drawn and paths[key] != drawn:
3697
+ shutil.rmtree(os.path.dirname(drawn),
3698
+ ignore_errors=True)
3699
+ path = paths[key]
3700
+ out.append((t, b, path))
3701
+ per_dest.append(out)
3702
+ report: List[Dict[str, Any]] = []
3703
+ results = self._outcomes(rid, dests, await asyncio.gather(*(
3704
+ self._deliver(rid, d, t, msgs, report)
3705
+ for d, t, msgs in zip(dests, tickets, per_dest)),
3706
+ return_exceptions=True))
3707
+ self._console_report(report)
3708
+ finally:
3709
+ for t in tickets:
3710
+ await t.gate.done(t.ticket)
3711
+ for p in paths.values():
3712
+ if p:
3713
+ shutil.rmtree(os.path.dirname(p), ignore_errors=True)
3714
+ return all(results)
3715
+
3716
+
3717
+ def load_component(config: ConfigHelper) -> PushNotifications:
3718
+ return PushNotifications(config)