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/__init__.py +7 -0
- pushraker/component.py +3718 -0
- pushraker/discord.py +240 -0
- pushraker/email_html.py +121 -0
- pushraker/images.py +395 -0
- pushraker-0.1.0.dev0.dist-info/METADATA +49 -0
- pushraker-0.1.0.dev0.dist-info/RECORD +10 -0
- pushraker-0.1.0.dev0.dist-info/WHEEL +5 -0
- pushraker-0.1.0.dev0.dist-info/licenses/LICENSE +674 -0
- pushraker-0.1.0.dev0.dist-info/top_level.txt +1 -0
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)
|