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/images.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
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
|
+
# Spool pictures for the Spoolman notifications of push_notifications.
|
|
6
|
+
#
|
|
7
|
+
# One row per toolhead: its spool, drawn in the filament's colour (or
|
|
8
|
+
# colours: wedges and stripes for a multi-colour filament) and filled
|
|
9
|
+
# to how much is left (10% steps of a full spool; under 10% shows a few
|
|
10
|
+
# wraps), with arrows to the spools the printer refills from, in the order
|
|
11
|
+
# it would use them. The toolhead's own spool has the left column to
|
|
12
|
+
# itself; a chain longer than two backups wraps to a second line, joined
|
|
13
|
+
# by an elbow arrow (down, left, down).
|
|
14
|
+
# Colours: red = not enough, orange = may be insufficient / running low,
|
|
15
|
+
# green = enough. Uses Pillow only (no numpy).
|
|
16
|
+
#
|
|
17
|
+
# This file may be distributed under the terms of the GNU GPLv3 license.
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
import math
|
|
21
|
+
import os
|
|
22
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
23
|
+
|
|
24
|
+
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
|
25
|
+
|
|
26
|
+
RGB = Tuple[int, int, int]
|
|
27
|
+
RED: RGB = (176, 38, 38)
|
|
28
|
+
ORANGE: RGB = (196, 104, 0)
|
|
29
|
+
GREEN: RGB = (36, 116, 58)
|
|
30
|
+
INK: RGB = (32, 34, 38)
|
|
31
|
+
GREY: RGB = (95, 98, 105)
|
|
32
|
+
STATUS_COLOURS = {"red": RED, "orange": ORANGE, "green": GREEN, "ink": INK}
|
|
33
|
+
|
|
34
|
+
WIDTH = 900
|
|
35
|
+
ROW = 330 # one line of spools
|
|
36
|
+
LINE_GAP = 44 # between the lines of a wrapped chain (the elbow arrow)
|
|
37
|
+
NOTE_H = 30 # the "order may differ" note under a chain
|
|
38
|
+
COLUMN = 300 # width of each spool's column
|
|
39
|
+
COLUMNS = (150, 450, 750) # centres: toolhead, then its refill spools
|
|
40
|
+
NOTE_X = 600 # centre of the no-refill note (the two right columns)
|
|
41
|
+
SPOOL = 170 # spool height in px
|
|
42
|
+
SQUASH = 0.5 # side view at ~60 degrees
|
|
43
|
+
DEPTH = 0.32 # spool width between flanges, fraction of its height
|
|
44
|
+
R_HUB = 0.34 # hub radius, fraction of the flange radius
|
|
45
|
+
R_FULL = 0.90 # radius of a full spool's windings
|
|
46
|
+
SS = 2 # supersampling for smooth edges
|
|
47
|
+
UNKNOWN_FILL = 0.5 # Spoolman says empty but it's loaded: drawn half full
|
|
48
|
+
|
|
49
|
+
FONT_DIRS = ("/usr/share/fonts/truetype/dejavu", "/usr/share/fonts/dejavu",
|
|
50
|
+
"/usr/share/fonts/TTF", "/usr/share/fonts")
|
|
51
|
+
_fonts: Dict[Tuple[bool, int], Any] = {}
|
|
52
|
+
# The printer has no font files: Pillow's built-in font (one weight) is
|
|
53
|
+
# used, and bold is drawn with an outline
|
|
54
|
+
_fake_bold = False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def font(bold: bool, size: int) -> Any:
|
|
58
|
+
key = (bold, size)
|
|
59
|
+
if key in _fonts:
|
|
60
|
+
return _fonts[key]
|
|
61
|
+
name = "DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf"
|
|
62
|
+
f = None
|
|
63
|
+
for d in FONT_DIRS:
|
|
64
|
+
try:
|
|
65
|
+
f = ImageFont.truetype(os.path.join(d, name), size)
|
|
66
|
+
break
|
|
67
|
+
except OSError:
|
|
68
|
+
continue
|
|
69
|
+
if f is None:
|
|
70
|
+
global _fake_bold
|
|
71
|
+
_fake_bold = True
|
|
72
|
+
try:
|
|
73
|
+
f = ImageFont.load_default(size=size) # Pillow >= 10.1
|
|
74
|
+
except TypeError:
|
|
75
|
+
f = ImageFont.load_default()
|
|
76
|
+
_fonts[key] = f
|
|
77
|
+
return f
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parse_hex(value: Any) -> Optional[RGB]:
|
|
81
|
+
s = str(value or "").strip().lstrip("#")
|
|
82
|
+
if len(s) >= 6:
|
|
83
|
+
try:
|
|
84
|
+
return (int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16))
|
|
85
|
+
except ValueError:
|
|
86
|
+
return None
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def level(remaining: float, full: Optional[float]) -> float:
|
|
91
|
+
"""Drawn fill: 10% steps of a full spool; under 10% (not empty) shows
|
|
92
|
+
a few wraps. Unknown full weight: drawn full."""
|
|
93
|
+
if remaining <= 0:
|
|
94
|
+
return 0.
|
|
95
|
+
if not full or full <= 0:
|
|
96
|
+
return 1.
|
|
97
|
+
f = remaining / full
|
|
98
|
+
return 0.06 if f < 0.1 else min(1., round(f * 10) / 10)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _scale(c: Sequence[float], k: float) -> RGB:
|
|
102
|
+
return tuple(max(0, min(255, int(v * k))) for v in c[:3]) # type: ignore
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _ellipse(d: ImageDraw.ImageDraw, cx: float, cy: float, rx: float,
|
|
106
|
+
ry: float, **kw: Any) -> None:
|
|
107
|
+
d.ellipse([cx - rx, cy - ry, cx + rx, cy + ry], **kw)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _shades(colour: RGB) -> Tuple[RGB, RGB, RGB]:
|
|
111
|
+
"""(face, side, groove) shades of a winding colour."""
|
|
112
|
+
lum = sum(colour) / 3.
|
|
113
|
+
face = colour
|
|
114
|
+
side = _scale(colour, 0.78)
|
|
115
|
+
groove = _scale(colour, 0.9)
|
|
116
|
+
if lum < 45: # lift black so the windings show
|
|
117
|
+
face = tuple(min(255, v + 18) for v in colour) # type: ignore
|
|
118
|
+
side = tuple(min(255, v + 10) for v in colour) # type: ignore
|
|
119
|
+
groove = tuple(min(255, v + 34) for v in colour) # type: ignore
|
|
120
|
+
elif lum > 225: # white: keep shading visible
|
|
121
|
+
side = _scale(colour, 0.84)
|
|
122
|
+
groove = _scale(colour, 0.93)
|
|
123
|
+
return face, side, groove
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def spool(fill: float, colour: Any) -> Image.Image:
|
|
127
|
+
"""An angled spool, front flange on the left, smoked clear plastic.
|
|
128
|
+
`colour` is one RGB, or a list of them for a multi-colour filament:
|
|
129
|
+
the face is split into wedges and the barrel into stripes, one per
|
|
130
|
+
colour, in the order given (coextruded or sequential alike)."""
|
|
131
|
+
colours: List[RGB] = [tuple(c) for c in colour] \
|
|
132
|
+
if colour and isinstance(colour[0], (tuple, list)) else [tuple(colour)]
|
|
133
|
+
colours = colours[:8] or [(200, 200, 200)]
|
|
134
|
+
h = SPOOL * SS
|
|
135
|
+
r = h / 2.
|
|
136
|
+
rx = r * SQUASH
|
|
137
|
+
depth = int(h * DEPTH)
|
|
138
|
+
w = int(2 * rx) + depth + 4 * SS
|
|
139
|
+
img = Image.new("RGBA", (w, h + 4 * SS), (0, 0, 0, 0))
|
|
140
|
+
d = ImageDraw.Draw(img)
|
|
141
|
+
cy = h / 2. + 2 * SS
|
|
142
|
+
fx = rx + 2 * SS # front face centre
|
|
143
|
+
r_fill = math.sqrt(R_HUB ** 2 + fill * (R_FULL ** 2 - R_HUB ** 2)) \
|
|
144
|
+
if fill > 0 else 0.
|
|
145
|
+
n = len(colours)
|
|
146
|
+
shades = [_shades(c) for c in colours]
|
|
147
|
+
# back flange
|
|
148
|
+
_ellipse(d, fx + depth, cy, rx, r, fill=(85, 89, 96, 235))
|
|
149
|
+
# barrel: hub, then windings, stacked from the back; a multi-colour
|
|
150
|
+
# filament's barrel is striped along its depth
|
|
151
|
+
for i in range(depth, 0, -SS):
|
|
152
|
+
_ellipse(d, fx + i, cy, rx * R_HUB, r * R_HUB, fill=(96, 100, 108))
|
|
153
|
+
if r_fill:
|
|
154
|
+
k = min(n - 1, int((depth - i) / depth * n))
|
|
155
|
+
_ellipse(d, fx + i, cy, rx * r_fill, r * r_fill,
|
|
156
|
+
fill=shades[k][1])
|
|
157
|
+
if r_fill:
|
|
158
|
+
box = [fx - rx * r_fill, cy - r * r_fill, fx + rx * r_fill,
|
|
159
|
+
cy + r * r_fill]
|
|
160
|
+
if n == 1:
|
|
161
|
+
d.ellipse(box, fill=shades[0][0])
|
|
162
|
+
else:
|
|
163
|
+
for k in range(n):
|
|
164
|
+
d.pieslice(box, -90 + 360. * k / n, -90 + 360. * (k + 1) / n,
|
|
165
|
+
fill=shades[k][0])
|
|
166
|
+
# winding grooves on the front face, in each wedge's own shade
|
|
167
|
+
kk = r_fill
|
|
168
|
+
step = 0.035
|
|
169
|
+
while kk > R_HUB + step:
|
|
170
|
+
gbox = [fx - rx * kk, cy - r * kk, fx + rx * kk, cy + r * kk]
|
|
171
|
+
if n == 1:
|
|
172
|
+
d.ellipse(gbox, outline=shades[0][2], width=SS)
|
|
173
|
+
else:
|
|
174
|
+
for k in range(n):
|
|
175
|
+
d.arc(gbox, -90 + 360. * k / n, -90 + 360. * (k + 1) / n,
|
|
176
|
+
fill=shades[k][2], width=SS)
|
|
177
|
+
kk -= step
|
|
178
|
+
# hub and centre hole
|
|
179
|
+
_ellipse(d, fx, cy, rx * R_HUB, r * R_HUB, fill=(132, 136, 145))
|
|
180
|
+
_ellipse(d, fx - rx * 0.04, cy - r * 0.04, rx * R_HUB * 0.86,
|
|
181
|
+
r * R_HUB * 0.86, fill=(146, 150, 159))
|
|
182
|
+
_ellipse(d, fx, cy, rx * 0.12, r * 0.12, fill=(52, 54, 60))
|
|
183
|
+
# front flange: smoked clear plastic, opaque rim, soft glare
|
|
184
|
+
fl = Image.new("RGBA", img.size, (0, 0, 0, 0))
|
|
185
|
+
fd = ImageDraw.Draw(fl)
|
|
186
|
+
_ellipse(fd, fx, cy, rx, r, fill=(150, 158, 170, 72))
|
|
187
|
+
glare = Image.new("RGBA", img.size, (0, 0, 0, 0))
|
|
188
|
+
gd = ImageDraw.Draw(glare)
|
|
189
|
+
_ellipse(gd, fx - rx * 0.45, cy - r * 0.42, rx * 0.22, r * 0.3,
|
|
190
|
+
fill=(255, 255, 255, 120))
|
|
191
|
+
glare = glare.filter(ImageFilter.GaussianBlur(10 * SS))
|
|
192
|
+
fl = Image.alpha_composite(fl, glare)
|
|
193
|
+
fd = ImageDraw.Draw(fl)
|
|
194
|
+
_ellipse(fd, fx, cy, rx, r, outline=(80, 84, 94, 245), width=4 * SS)
|
|
195
|
+
img = Image.alpha_composite(img, fl)
|
|
196
|
+
return img.resize((img.width // SS, img.height // SS), Image.LANCZOS)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _text(d: ImageDraw.ImageDraw, xy: Tuple[float, float], text: str,
|
|
200
|
+
bold: bool, size: int, fill: RGB, center: bool = False) -> None:
|
|
201
|
+
f = font(bold, size)
|
|
202
|
+
x, y = xy
|
|
203
|
+
if bold and _fake_bold:
|
|
204
|
+
# Outlined letters need a little more space between them
|
|
205
|
+
gap = max(1., size * 0.07)
|
|
206
|
+
widths = [d.textlength(c, font=f) for c in text]
|
|
207
|
+
if center:
|
|
208
|
+
x -= (sum(widths) + gap * (len(text) - 1)) / 2
|
|
209
|
+
for c, w in zip(text, widths):
|
|
210
|
+
d.text((x, y), c, font=f, fill=fill, stroke_width=1,
|
|
211
|
+
stroke_fill=fill)
|
|
212
|
+
x += w + gap
|
|
213
|
+
return
|
|
214
|
+
if center:
|
|
215
|
+
x -= d.textlength(text, font=f) / 2
|
|
216
|
+
d.text((x, y), text, font=f, fill=fill)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _fit(d: ImageDraw.ImageDraw, text: str, f: Any, width: float) -> str:
|
|
220
|
+
if d.textlength(text, font=f) <= width:
|
|
221
|
+
return text
|
|
222
|
+
while text and d.textlength(text + "…", font=f) > width:
|
|
223
|
+
text = text[:-1]
|
|
224
|
+
return text.rstrip() + "…"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _put(bg: Image.Image, cx: float, y: int, sp: Image.Image) -> Image.Image:
|
|
228
|
+
x = int(cx - sp.width / 2)
|
|
229
|
+
sh = Image.new("RGBA", bg.size, (0, 0, 0, 0))
|
|
230
|
+
sd = ImageDraw.Draw(sh)
|
|
231
|
+
sd.ellipse([x + 8, y + SPOOL - 14, x + sp.width + 8, y + SPOOL + 16],
|
|
232
|
+
fill=(0, 0, 0, 80))
|
|
233
|
+
bg = Image.alpha_composite(bg, sh.filter(ImageFilter.GaussianBlur(9)))
|
|
234
|
+
bg.alpha_composite(sp, (x, y))
|
|
235
|
+
return bg
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _background(height: int) -> Image.Image:
|
|
239
|
+
bg = Image.new("RGBA", (WIDTH, height))
|
|
240
|
+
d = ImageDraw.Draw(bg)
|
|
241
|
+
for y in range(height):
|
|
242
|
+
g = int(238 - 18 * y / max(1, height - 1))
|
|
243
|
+
d.line([(0, y), (WIDTH, y)], fill=(g, g + 1, g + 5, 255))
|
|
244
|
+
return bg
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _layout(row: Dict[str, Any]) -> List[Tuple[int, int]]:
|
|
248
|
+
"""(line, column) of each refill spool: the first line holds up to
|
|
249
|
+
two after the toolhead's own; later lines up to two, never in the
|
|
250
|
+
toolhead's (left) column."""
|
|
251
|
+
return [(0, 1 + k) if k < 2 else (1 + (k - 2) // 2, 1 + (k - 2) % 2)
|
|
252
|
+
for k in range(len(row.get("refills") or []))]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _row_height(row: Dict[str, Any]) -> int:
|
|
256
|
+
places = _layout(row)
|
|
257
|
+
lines = 1 + max((ln for ln, _c in places), default=0)
|
|
258
|
+
note = NOTE_H if len(places) >= 2 else 0
|
|
259
|
+
return ROW * lines + LINE_GAP * (lines - 1) + note
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _warning(d: ImageDraw.ImageDraw, x: float, y: float, size: int) -> None:
|
|
263
|
+
"""A warning sign (as the emoji): yellow triangle, dark "!"."""
|
|
264
|
+
top = y + 1
|
|
265
|
+
d.polygon([(x + size / 2, top), (x + size, top + size - 1),
|
|
266
|
+
(x, top + size - 1)], fill=(242, 184, 0),
|
|
267
|
+
outline=(150, 110, 0))
|
|
268
|
+
cx = x + size / 2
|
|
269
|
+
d.line([cx, top + size * 0.32, cx, top + size * 0.66], fill=INK, width=2)
|
|
270
|
+
d.ellipse([cx - 1.2, top + size * 0.75, cx + 1.2, top + size * 0.88],
|
|
271
|
+
fill=INK)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _arrow_right(d: ImageDraw.ImageDraw, x0: float, x1: float,
|
|
275
|
+
y: float) -> None:
|
|
276
|
+
d.line([x0, y, x1 - 22, y], fill=(45, 48, 54), width=8)
|
|
277
|
+
d.polygon([(x1, y), (x1 - 28, y - 21), (x1 - 28, y + 21)],
|
|
278
|
+
fill=(45, 48, 54))
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _arrow_elbow(d: ImageDraw.ImageDraw, x0: float, y0: float, x1: float,
|
|
282
|
+
y1: float) -> None:
|
|
283
|
+
"""Down from (x0, y0), left to x1, down to (x1, y1)."""
|
|
284
|
+
mid = y0 + (y1 - y0) * 0.45
|
|
285
|
+
d.line([(x0, y0), (x0, mid), (x1, mid), (x1, y1 - 18)],
|
|
286
|
+
fill=(45, 48, 54), width=6, joint="curve")
|
|
287
|
+
d.polygon([(x1, y1), (x1 - 15, y1 - 22), (x1 + 15, y1 - 22)],
|
|
288
|
+
fill=(45, 48, 54))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def render(rows: List[Dict[str, Any]], path: str) -> str:
|
|
292
|
+
"""Draw the rows and save a JPEG at `path`. Each row:
|
|
293
|
+
toolhead, word, status, needs (str or ""), remaining (None: not
|
|
294
|
+
known, drawn full with no amount line), full, colour,
|
|
295
|
+
name, and optionally refills = [{toolhead, remaining, full, colour,
|
|
296
|
+
name, status, match ("Exact match" / "Inexact match")}, ...] (in the order the printer uses them) or
|
|
297
|
+
note = [lines].
|
|
298
|
+
Every spool is drawn as the same stack: toolhead above, amount left,
|
|
299
|
+
what it needs (the toolhead's own spool only) and the filament's name
|
|
300
|
+
below. `word` is not drawn: the colour and the amounts say it.
|
|
301
|
+
"""
|
|
302
|
+
heights = [_row_height(r) for r in rows]
|
|
303
|
+
height = sum(heights) + 12
|
|
304
|
+
bg = _background(height)
|
|
305
|
+
|
|
306
|
+
def stack(d: ImageDraw.ImageDraw, cx: float, y0: int, sp: Dict[str, Any],
|
|
307
|
+
col: RGB, needs: str = "") -> None:
|
|
308
|
+
_text(d, (cx, y0 + 2), sp["toolhead"], True, 24, col, center=True)
|
|
309
|
+
y = y0 + SPOOL + 66
|
|
310
|
+
if sp.get("remaining") is not None:
|
|
311
|
+
# No Spoolman: no amount, the spool is drawn full
|
|
312
|
+
_text(d, (cx, y), "Amount left unknown" if sp.get("unknown")
|
|
313
|
+
else grams_text(sp["remaining"], sp.get("full")),
|
|
314
|
+
True, 20, col, center=True)
|
|
315
|
+
y += 27
|
|
316
|
+
if needs:
|
|
317
|
+
_text(d, (cx, y), needs.rstrip("."), False, 20, INK, center=True)
|
|
318
|
+
y += 27
|
|
319
|
+
_text(d, (cx, y), _fit(d, sp.get("name") or "", font(False, 16),
|
|
320
|
+
COLUMN - 20), False, 16, GREY, center=True)
|
|
321
|
+
if sp.get("match"):
|
|
322
|
+
# A refill spool: the same colour as the toolhead's, or not
|
|
323
|
+
# (with a warning sign: drawn, the printer has no emoji font)
|
|
324
|
+
m = sp["match"]
|
|
325
|
+
if m.startswith("Inexact"):
|
|
326
|
+
w = d.textlength(m, font=font(False, 16))
|
|
327
|
+
size = 16
|
|
328
|
+
gap = 6
|
|
329
|
+
x = cx - (w + size + gap) / 2
|
|
330
|
+
_warning(d, x, y + 22, size)
|
|
331
|
+
_text(d, (x + size + gap, y + 22), m, False, 16, GREY)
|
|
332
|
+
else:
|
|
333
|
+
_text(d, (cx, y + 22), m, False, 16, GREY, center=True)
|
|
334
|
+
|
|
335
|
+
def draw(sp: Dict[str, Any], cx: float, y: int) -> None:
|
|
336
|
+
nonlocal bg
|
|
337
|
+
bg = _put(bg, cx, y + 40, spool(
|
|
338
|
+
UNKNOWN_FILL if sp.get("unknown")
|
|
339
|
+
else 1. if sp.get("remaining") is None
|
|
340
|
+
else level(sp["remaining"], sp.get("full")),
|
|
341
|
+
sp.get("colour") or (200, 200, 200)))
|
|
342
|
+
|
|
343
|
+
y0 = 6
|
|
344
|
+
for i, row in enumerate(rows):
|
|
345
|
+
col = STATUS_COLOURS.get(row["status"], INK)
|
|
346
|
+
refills = row.get("refills") or []
|
|
347
|
+
places = _layout(row)
|
|
348
|
+
line_y = lambda ln: y0 + ln * (ROW + LINE_GAP)
|
|
349
|
+
draw(row, COLUMNS[0], y0)
|
|
350
|
+
for sp, (ln, c) in zip(refills, places):
|
|
351
|
+
draw(sp, COLUMNS[c], line_y(ln))
|
|
352
|
+
d = ImageDraw.Draw(bg)
|
|
353
|
+
if i:
|
|
354
|
+
d.line([40, y0 - 4, WIDTH - 40, y0 - 4], fill=(190, 193, 200),
|
|
355
|
+
width=2)
|
|
356
|
+
stack(d, COLUMNS[0], y0, row, col, row.get("needs") or "")
|
|
357
|
+
prev = (0, 0)
|
|
358
|
+
for k, (sp, (ln, c)) in enumerate(zip(refills, places)):
|
|
359
|
+
if ln == prev[0]:
|
|
360
|
+
ay = line_y(ln) + 40 + SPOOL // 2
|
|
361
|
+
x0 = COLUMNS[prev[1]] + COLUMN / 2 - 100
|
|
362
|
+
x1 = COLUMNS[c] - COLUMN / 2 + 100
|
|
363
|
+
_arrow_right(d, x0, x1, ay)
|
|
364
|
+
_text(d, ((x0 + x1) / 2, ay - 52), "refill", False, 18,
|
|
365
|
+
GREY, center=True)
|
|
366
|
+
else:
|
|
367
|
+
# From under the last spool of the line above, down to the
|
|
368
|
+
# first of this one
|
|
369
|
+
ey0, ey1 = line_y(prev[0]) + ROW - 18, line_y(ln) - 2
|
|
370
|
+
_arrow_elbow(d, COLUMNS[prev[1]], ey0, COLUMNS[c], ey1)
|
|
371
|
+
_text(d, ((COLUMNS[prev[1]] + COLUMNS[c]) / 2,
|
|
372
|
+
ey0 + (ey1 - ey0) * 0.45 - 25), "refill", False,
|
|
373
|
+
18, GREY, center=True)
|
|
374
|
+
stack(d, COLUMNS[c], line_y(ln), sp,
|
|
375
|
+
STATUS_COLOURS.get(sp.get("status"), INK))
|
|
376
|
+
prev = (ln, c)
|
|
377
|
+
if not refills:
|
|
378
|
+
lines = row.get("note") or []
|
|
379
|
+
y = y0 + 40 + SPOOL // 2 - 15 * len(lines)
|
|
380
|
+
for k, line in enumerate(lines):
|
|
381
|
+
_text(d, (NOTE_X, y + k * 30), line, False, 20, GREY,
|
|
382
|
+
center=True)
|
|
383
|
+
elif len(refills) >= 2:
|
|
384
|
+
_text(d, (WIDTH / 2, y0 + heights[i] - NOTE_H + 2),
|
|
385
|
+
"Backup spool order may differ from what's shown",
|
|
386
|
+
False, 16, GREY, center=True)
|
|
387
|
+
y0 += heights[i]
|
|
388
|
+
bg.convert("RGB").save(path, quality=88)
|
|
389
|
+
return path
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def grams_text(remaining: float, full: Optional[float]) -> str:
|
|
393
|
+
if full and full > 0:
|
|
394
|
+
return f"{remaining:.0f} of {full:.0f} g left"
|
|
395
|
+
return f"{remaining:.0f} g left"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pushraker
|
|
3
|
+
Version: 0.1.0.dev0
|
|
4
|
+
Summary: Push notifications for Klipper printers: a Moonraker component with pictures, sent through Apprise (ntfy, Pushover, Telegram, Discord, webhooks, email)
|
|
5
|
+
Author: noon92
|
|
6
|
+
License-Expression: GPL-3.0-or-later
|
|
7
|
+
Project-URL: Homepage, https://github.com/noon92/pushraker
|
|
8
|
+
Project-URL: Issues, https://github.com/noon92/pushraker/issues
|
|
9
|
+
Keywords: klipper,moonraker,notifications,ntfy,apprise,3d-printing
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
12
|
+
Classifier: Topic :: System :: Monitoring
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: apprise>=1.9
|
|
17
|
+
Requires-Dist: requests
|
|
18
|
+
Requires-Dist: Pillow>=10
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# pushraker
|
|
22
|
+
|
|
23
|
+
Push notifications for Klipper printers, as a Moonraker component: print
|
|
24
|
+
started, paused, resumed, complete, cancelled, progress, first layer, faults
|
|
25
|
+
and shutdowns, power-loss recovery, chamber temperature, filament (Spoolman
|
|
26
|
+
and MMU backup spools), with pictures (camera snapshot, model preview, spool
|
|
27
|
+
drawings). Sent through [Apprise](https://github.com/caronc/apprise): ntfy,
|
|
28
|
+
Pushover, Gotify, Telegram, Discord, a webhook, email.
|
|
29
|
+
|
|
30
|
+
Grew out of the Push Notifications feature of the
|
|
31
|
+
[Snapmaker U1 Extended Firmware](https://github.com/paxx12-snapmaker-u1/SnapmakerU1-Extended-Firmware);
|
|
32
|
+
it is being split out so it works on any Moonraker printer and is configured
|
|
33
|
+
from its own web page, live, without editing `moonraker.conf`.
|
|
34
|
+
|
|
35
|
+
**Status: work in progress — not yet usable on its own.** The component is
|
|
36
|
+
here and tested; the settings API, the web page and the installer are next.
|
|
37
|
+
|
|
38
|
+
## Layout
|
|
39
|
+
|
|
40
|
+
- `src/pushraker/component.py` — the Moonraker component
|
|
41
|
+
- `src/pushraker/images.py` — spool pictures (Pillow)
|
|
42
|
+
- `src/pushraker/email_html.py`, `src/pushraker/discord.py` — email card, Discord plugin
|
|
43
|
+
- `shims/push_notifications.py` — the one file that goes into Moonraker's
|
|
44
|
+
`components/` directory to load the package
|
|
45
|
+
- `tests/test_pushraker.py` — the test suite (`python tests/test_pushraker.py`)
|
|
46
|
+
|
|
47
|
+
## License
|
|
48
|
+
|
|
49
|
+
GPL-3.0-or-later.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pushraker/__init__.py,sha256=C71xg-cEQxmjP3-a6Bcw00-1vKjUD5dDupY_hkpdhMY,259
|
|
2
|
+
pushraker/component.py,sha256=_eIJEa6k5reScoXLl2FQZVPF0nAMGvMICgRzjEarj_o,174434
|
|
3
|
+
pushraker/discord.py,sha256=29UUZC81w1uqVEoG_89x2rihaRDvN258X1jFKQJjqf4,9547
|
|
4
|
+
pushraker/email_html.py,sha256=tQ44rTB5pEshg3azFRTIPG1Qhi15ZHHCY4iE8Dmd4Jw,5450
|
|
5
|
+
pushraker/images.py,sha256=_VeEBRwJ5OU0YME3NuYwTl_qNqj1zvC9kU_qGUJv2Sw,16285
|
|
6
|
+
pushraker-0.1.0.dev0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
7
|
+
pushraker-0.1.0.dev0.dist-info/METADATA,sha256=HhKkTXWYcOnu53Nvd8Y4c2gtF0hY5ABgO_69xC5PYeo,2133
|
|
8
|
+
pushraker-0.1.0.dev0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
9
|
+
pushraker-0.1.0.dev0.dist-info/top_level.txt,sha256=fscrjWibYf9xECHe72Ro-7ruj9tz6CjIiQ7k2Z6prJE,10
|
|
10
|
+
pushraker-0.1.0.dev0.dist-info/RECORD,,
|