Grotti 3.2.0: Jetzt-Liste, Filament-Pause, Strom-Torte, UI-Polish.
Jetzt/Topbar und Material-Routing, Pause bei Filament-Ende, Druckbericht mit Stromanteilen, Design- und Kamera-Stream-Polish; Bedarf-Alert-Spam und Platte-belegt-Bugs behoben.
This commit is contained in:
1 parent
6e59df22e5
commit
4ddffbbeb1
68 files changed
+4348
-1215
No files matched your search
+9
-1
@@ -136,7 +136,7 @@ MATERIALS = [
|
||||
{
|
||||
"name": "Sunlu PA12-CF",
|
||||
"brand": "Sunlu",
|
||||
"material_type": "PA",
|
||||
"material_type": "PA12-CF",
|
||||
"density": 1.08,
|
||||
"volumetric": 8,
|
||||
"volumetric_s1": 8,
|
||||
@@ -157,6 +157,14 @@ MATERIALS = [
|
||||
},
|
||||
]
|
||||
|
||||
def ace_material_type(material_type):
|
||||
"""Typ für den ACE. PA12-CF gibt es dort nicht, der Platz heißt PA6-CF."""
|
||||
text = (material_type or "").strip()
|
||||
if text.lower() == "pa12-cf":
|
||||
return "PA6-CF"
|
||||
return text
|
||||
|
||||
|
||||
SOLIDS = [
|
||||
{"id": "schwarz", "name": "Schwarz", "hex": "#1c1c1c"},
|
||||
{"id": "weiss", "name": "Weiß", "hex": "#f4f4f4"},
|
||||
|
||||
@@ -155,6 +155,7 @@ def load():
|
||||
"ntfy": _ntfy(raw),
|
||||
"alerts": _alerts(raw),
|
||||
"auto_queue": bool(raw.get("auto_queue", True)),
|
||||
"auto_retry": bool(raw.get("auto_retry", True)),
|
||||
"_raw": raw,
|
||||
"_path": path,
|
||||
}
|
||||
@@ -210,6 +211,7 @@ def settings_public(cfg):
|
||||
"watch_dir": str(cfg.get("watch_dir") or ""),
|
||||
"watch_dir_default": default_watch_suggestion(),
|
||||
"auto_queue": bool(cfg.get("auto_queue", True)),
|
||||
"auto_retry": bool(cfg.get("auto_retry", True)),
|
||||
"fqdn": str(cfg.get("fqdn") or ""),
|
||||
"legacy_token_set": bool(cfg.get("token")),
|
||||
"energy": dict(cfg.get("energy") or {}),
|
||||
@@ -243,6 +245,7 @@ def save_settings(
|
||||
ntfy=None,
|
||||
alerts=None,
|
||||
auto_queue=None,
|
||||
auto_retry=None,
|
||||
fqdn=None,
|
||||
watch_dir=None,
|
||||
):
|
||||
@@ -255,6 +258,7 @@ def save_settings(
|
||||
ntfy=ntfy,
|
||||
alerts=alerts,
|
||||
auto_queue=auto_queue,
|
||||
auto_retry=auto_retry,
|
||||
fqdn=fqdn,
|
||||
watch_dir=watch_dir,
|
||||
)
|
||||
@@ -267,6 +271,7 @@ def _save_settings_locked(
|
||||
ntfy=None,
|
||||
alerts=None,
|
||||
auto_queue=None,
|
||||
auto_retry=None,
|
||||
fqdn=None,
|
||||
watch_dir=None,
|
||||
):
|
||||
@@ -280,6 +285,9 @@ def _save_settings_locked(
|
||||
if auto_queue is not None:
|
||||
raw["auto_queue"] = bool(auto_queue)
|
||||
cfg["auto_queue"] = bool(auto_queue)
|
||||
if auto_retry is not None:
|
||||
raw["auto_retry"] = bool(auto_retry)
|
||||
cfg["auto_retry"] = bool(auto_retry)
|
||||
if watch_dir is not None:
|
||||
resolved, stored = parse_watch_dir(watch_dir, create=True, missing_ok=False)
|
||||
raw["watch_dir"] = stored
|
||||
|
||||
+60
-12
@@ -62,11 +62,13 @@ def _avg_power(a, b):
|
||||
return max(0.0, min(1.0, sum(vals) / len(vals)))
|
||||
|
||||
|
||||
def _ed_print_kwh(samples, base_w, nozzle_w, bed_w):
|
||||
"""Trapez aus Grundlast + ED×Heizerleistung. None wenn zu wenig ED-Proben."""
|
||||
def _ed_print_parts(samples, base_w, nozzle_w, bed_w):
|
||||
"""Trapez: Grundlast / Düse / Bett getrennt. None wenn zu wenig ED-Proben."""
|
||||
if not samples or len(samples) < 2:
|
||||
return None
|
||||
total = 0.0
|
||||
base_kwh = 0.0
|
||||
nozzle_kwh = 0.0
|
||||
bed_kwh = 0.0
|
||||
ed_steps = 0
|
||||
# Max. Intervall: 3× Normal-Sample (90 s), damit Lücken nicht die ED fortschreiben
|
||||
max_dt = 90.0
|
||||
@@ -77,6 +79,7 @@ def _ed_print_kwh(samples, base_w, nozzle_w, bed_w):
|
||||
if t0 is None or t1 is None or t1 <= t0:
|
||||
continue
|
||||
dt = min(t1 - t0, max_dt)
|
||||
hours = dt / 3600.0
|
||||
pn0, pn1 = a.get("nozzle_power"), b.get("nozzle_power")
|
||||
pb0, pb1 = a.get("bed_power"), b.get("bed_power")
|
||||
has_ed = any(
|
||||
@@ -87,11 +90,17 @@ def _ed_print_kwh(samples, base_w, nozzle_w, bed_w):
|
||||
ed_steps += 1
|
||||
ed_n = _avg_power(pn0, pn1)
|
||||
ed_b = _avg_power(pb0, pb1)
|
||||
watts = float(base_w) + float(nozzle_w) * ed_n + float(bed_w) * ed_b
|
||||
total += (watts / 1000.0) * (dt / 3600.0)
|
||||
base_kwh += (float(base_w) / 1000.0) * hours
|
||||
nozzle_kwh += (float(nozzle_w) * ed_n / 1000.0) * hours
|
||||
bed_kwh += (float(bed_w) * ed_b / 1000.0) * hours
|
||||
if ed_steps < 1:
|
||||
return None
|
||||
return total
|
||||
return {
|
||||
"base_kwh": base_kwh,
|
||||
"nozzle_kwh": nozzle_kwh,
|
||||
"bed_kwh": bed_kwh,
|
||||
"print_kwh": base_kwh + nozzle_kwh + bed_kwh,
|
||||
}
|
||||
|
||||
|
||||
def _is_nt_moment(moment, tariff):
|
||||
@@ -130,10 +139,21 @@ def effective_euro_per_kwh(duration_s, energy, end_dt=None):
|
||||
return nt_frac * nt + (1.0 - nt_frac) * ht
|
||||
|
||||
|
||||
def _part(key, label, kwh, euro):
|
||||
kwh = float(kwh or 0)
|
||||
return {
|
||||
"key": key,
|
||||
"label": label,
|
||||
"kwh": kwh,
|
||||
"cost": kwh * float(euro or 0),
|
||||
}
|
||||
|
||||
|
||||
def energy_estimate(duration_s, energy, samples=None, end_dt=None):
|
||||
"""Strom aus ED-Proben oder pauschal Dauer × Watt.
|
||||
|
||||
Quelle ``ed`` nur wenn Düse-/Bett-Watt gesetzt und Proben mit power vorliegen.
|
||||
``parts`` listet Grundlast / Düse / Bett / Trockner (kWh + €).
|
||||
"""
|
||||
energy = energy or {}
|
||||
euro = effective_euro_per_kwh(duration_s, energy, end_dt=end_dt)
|
||||
@@ -142,25 +162,43 @@ def energy_estimate(duration_s, energy, samples=None, end_dt=None):
|
||||
nozzle_w = float(energy.get("nozzle_watts") or 0)
|
||||
bed_w = float(energy.get("bed_watts") or 0)
|
||||
|
||||
print_kwh = None
|
||||
parts_map = None
|
||||
source = "flat"
|
||||
if (nozzle_w > 0 or bed_w > 0) and samples:
|
||||
print_kwh = _ed_print_kwh(samples, base_w, nozzle_w, bed_w)
|
||||
if print_kwh is not None:
|
||||
parts_map = _ed_print_parts(samples, base_w, nozzle_w, bed_w)
|
||||
if parts_map is not None:
|
||||
source = "ed"
|
||||
|
||||
if print_kwh is None:
|
||||
if parts_map is None:
|
||||
hours = float(duration_s or 0) / 3600
|
||||
print_kwh = (base_w / 1000) * hours
|
||||
parts_map = {
|
||||
"base_kwh": (base_w / 1000) * hours,
|
||||
"nozzle_kwh": 0.0,
|
||||
"bed_kwh": 0.0,
|
||||
"print_kwh": (base_w / 1000) * hours,
|
||||
}
|
||||
source = "flat"
|
||||
|
||||
print_kwh = float(parts_map["print_kwh"])
|
||||
kwh = print_kwh + dry_kwh
|
||||
parts = [
|
||||
_part("base", "Grundlast", parts_map["base_kwh"], euro),
|
||||
_part("nozzle", "Düse", parts_map["nozzle_kwh"], euro),
|
||||
_part("bed", "Bett", parts_map["bed_kwh"], euro),
|
||||
_part("dryer", "Trockner", dry_kwh, euro),
|
||||
]
|
||||
parts = [p for p in parts if p["kwh"] > 1e-6]
|
||||
return {
|
||||
"kwh": kwh,
|
||||
"print_kwh": print_kwh,
|
||||
"dry_kwh": dry_kwh,
|
||||
"base_kwh": float(parts_map["base_kwh"]),
|
||||
"nozzle_kwh": float(parts_map["nozzle_kwh"]),
|
||||
"bed_kwh": float(parts_map["bed_kwh"]),
|
||||
"cost": kwh * euro,
|
||||
"euro_per_kwh": euro,
|
||||
"source": source,
|
||||
"parts": parts,
|
||||
}
|
||||
|
||||
|
||||
@@ -199,6 +237,7 @@ def order_actuals(conn, order_id):
|
||||
SELECT
|
||||
COALESCE(SUM(r.filament_cost + r.energy_cost), 0) AS cost_total,
|
||||
COALESCE(SUM(r.duration_s), 0) AS duration_s,
|
||||
COALESCE(SUM(r.pieces_ok), 0) AS pieces_ok,
|
||||
COALESCE(SUM(r.pieces_bad), 0) AS pieces_bad,
|
||||
COALESCE(SUM(r.weight_g) FILTER (WHERE r.verdict = 'ausschuss'), 0) AS scrap_g,
|
||||
COUNT(*) FILTER (WHERE r.verdict = 'ausschuss') AS scrap_runs
|
||||
@@ -208,10 +247,19 @@ def order_actuals(conn, order_id):
|
||||
""",
|
||||
(order_id,),
|
||||
).fetchone()
|
||||
pieces_ok = int(row["pieces_ok"] or 0)
|
||||
cost_total = float(row["cost_total"] or 0)
|
||||
return {
|
||||
"cost_total": float(row["cost_total"] or 0),
|
||||
"cost_total": cost_total,
|
||||
"duration_s": float(row["duration_s"] or 0),
|
||||
"pieces_ok": pieces_ok,
|
||||
"pieces_bad": int(row["pieces_bad"] or 0),
|
||||
"scrap_g": float(row["scrap_g"] or 0),
|
||||
"scrap_runs": int(row["scrap_runs"] or 0),
|
||||
"cost_per_part": (cost_total / pieces_ok) if pieces_ok > 0 else None,
|
||||
"yield_percent": (
|
||||
100.0 * pieces_ok / (pieces_ok + int(row["pieces_bad"] or 0))
|
||||
if (pieces_ok + int(row["pieces_bad"] or 0)) > 0
|
||||
else None
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Tagesbericht und Push für Wartung/Bedarf/Termine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from grotti import bedarf as bedarf_mod
|
||||
from grotti import jetzt as jetzt_mod
|
||||
from grotti import notify
|
||||
from grotti import store
|
||||
from grotti.db import connect
|
||||
|
||||
|
||||
def _yesterday_stats(conn):
|
||||
today = date.today()
|
||||
yesterday = today - timedelta(days=1)
|
||||
start = datetime.combine(yesterday, datetime.min.time(), tzinfo=timezone.utc)
|
||||
end = datetime.combine(today, datetime.min.time(), tzinfo=timezone.utc)
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) AS runs,
|
||||
COALESCE(SUM(duration_s), 0) AS duration_s,
|
||||
COALESCE(SUM(weight_used_g), 0) AS grams,
|
||||
COALESCE(SUM(CASE WHEN verdict = 'ausschuss' THEN 1 ELSE 0 END), 0) AS scrap,
|
||||
COALESCE(SUM(pieces_ok), 0) AS pieces_ok,
|
||||
COALESCE(SUM(pieces_bad), 0) AS pieces_bad
|
||||
FROM runs
|
||||
WHERE started_at >= %s AND started_at < %s
|
||||
""",
|
||||
(start, end),
|
||||
).fetchone()
|
||||
return {
|
||||
"date": yesterday.isoformat(),
|
||||
"runs": int(row["runs"] or 0),
|
||||
"duration_s": float(row["duration_s"] or 0),
|
||||
"grams": float(row["grams"] or 0),
|
||||
"scrap": int(row["scrap"] or 0),
|
||||
"pieces_ok": int(row["pieces_ok"] or 0),
|
||||
"pieces_bad": int(row["pieces_bad"] or 0),
|
||||
}
|
||||
|
||||
|
||||
def build_text(conn, watcher_latest=None, printers=None):
|
||||
stats = _yesterday_stats(conn)
|
||||
waiting = len([j for j in store.list_print_queue(conn, limit=200) if j.get("status") == "waiting"])
|
||||
eta = jetzt_mod.farm_eta([
|
||||
{"remain": (live or {}).get("remain") or 0}
|
||||
for live in (watcher_latest or [])
|
||||
if (live or {}).get("state") in ("printing", "paused", "heating")
|
||||
])
|
||||
lines = [
|
||||
"Grotti Tagesbericht %s" % stats["date"],
|
||||
"Läufe: %d · Druckzeit: %.1f h · Material: %.0f g" % (
|
||||
stats["runs"], stats["duration_s"] / 3600.0, stats["grams"],
|
||||
),
|
||||
"Ausschuss-Läufe: %d · Stück gut/schlecht: %d/%d" % (
|
||||
stats["scrap"], stats["pieces_ok"], stats["pieces_bad"],
|
||||
),
|
||||
"Heute in Warteschlange: %d" % waiting,
|
||||
]
|
||||
if eta.get("all_done_in_s"):
|
||||
at = datetime.now(timezone.utc) + timedelta(seconds=eta["all_done_in_s"])
|
||||
lines.append("Laufende Drucke fertig gegen %s Uhr" % at.astimezone().strftime("%H:%M"))
|
||||
overdue = jetzt_mod.overdue_orders(conn, limit=5)
|
||||
if overdue:
|
||||
lines.append("Überfällig: " + ", ".join(o["title"] for o in overdue))
|
||||
short = (bedarf_mod.compute(conn).get("shortfall") or [])[:3]
|
||||
if short:
|
||||
bits = []
|
||||
for item in short:
|
||||
label = " ".join(x for x in [item.get("material_type"), item.get("color")] if x) or "Filament"
|
||||
bits.append("%s (−%sg)" % (label, int(item.get("shortfall_g") or 0)))
|
||||
lines.append("Bedarf: " + ", ".join(bits))
|
||||
parts = jetzt_mod.overdue_maintenance(conn, printers or store.list_printers(conn), limit=5)
|
||||
if parts:
|
||||
lines.append("Wartung: " + ", ".join("%s/%s" % (p["printer"], p["label"]) for p in parts))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def push_ops_alerts(conn, cfg, printers=None):
|
||||
"""Einmalige Push-Hinweise für Bedarf, Wartung, Termine (mit Dedup über Events).
|
||||
|
||||
kind beim Speichern muss zum Dedup-Check passen — sonst jede Minute ein neuer Eintrag
|
||||
(DailyReportThread tickt alle 60 s).
|
||||
"""
|
||||
printers = printers or store.list_printers(conn)
|
||||
for order in jetzt_mod.overdue_orders(conn, limit=10):
|
||||
msg = "Auftrag überfällig: %s (fällig %s)" % (order["title"], order.get("due_at") or "")
|
||||
key = "order:%s" % order["id"]
|
||||
if not store.recent_event_kind(conn, key, "auftrag", within_s=86400):
|
||||
notify.record(conn, cfg, key, order["title"], "auftrag", msg, dedupe_s=86400)
|
||||
for part in jetzt_mod.overdue_maintenance(conn, printers, limit=10):
|
||||
msg = "Wartung fällig: %s · %s" % (part["printer"], part["label"])
|
||||
key = "maint:%s" % part["id"]
|
||||
if not store.recent_event_kind(conn, key, "wartung", within_s=86400):
|
||||
notify.record(conn, cfg, key, part["printer"], "wartung", msg, dedupe_s=86400)
|
||||
for item in (bedarf_mod.compute(conn).get("shortfall") or [])[:5]:
|
||||
label = " ".join(x for x in [item.get("material_type"), item.get("color")] if x) or "Filament"
|
||||
msg = "Bedarf: %s — %s g fehlen" % (label, int(item.get("shortfall_g") or 0))
|
||||
key = "bedarf:%s:%s" % (item.get("material_type") or "", item.get("color") or "")
|
||||
if not store.recent_event_kind(conn, key, "bedarf", within_s=21600):
|
||||
notify.record(conn, cfg, key, label, "bedarf", msg, dedupe_s=21600)
|
||||
|
||||
|
||||
class DailyReportThread:
|
||||
"""Sendet um 7:00 Ortszeit den Tagesbericht und prüft periodisch Ops-Alerts."""
|
||||
|
||||
def __init__(self, database_url, cfg, watcher=None):
|
||||
self.database_url = database_url
|
||||
self.cfg = cfg
|
||||
self.watcher = watcher
|
||||
self._stop = threading.Event()
|
||||
self._thread = None
|
||||
self._last_report_day = None
|
||||
|
||||
def start(self):
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._thread = threading.Thread(target=self._loop, name="grotti-daily", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
self._stop.set()
|
||||
|
||||
def _loop(self):
|
||||
while not self._stop.wait(60):
|
||||
try:
|
||||
self._tick()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _tick(self):
|
||||
now = datetime.now().astimezone()
|
||||
with connect(self.database_url) as conn:
|
||||
push_ops_alerts(conn, self.cfg, store.list_printers(conn))
|
||||
conn.commit()
|
||||
if now.hour == 7 and now.minute < 2:
|
||||
day = now.date().isoformat()
|
||||
if self._last_report_day == day:
|
||||
return
|
||||
latest = (self.watcher.latest if self.watcher else None) or []
|
||||
text = build_text(conn, latest, store.list_printers(conn))
|
||||
notify.send_telegram(self.cfg, text)
|
||||
notify.send_ntfy(self.cfg, text)
|
||||
store.add_event(conn, "", "Grotti", "fertig", text.split("\n")[0])
|
||||
conn.commit()
|
||||
self._last_report_day = day
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
"""Aggregierte „Jetzt“-Handlungsliste für Sidebar und Mobile."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from grotti import bedarf as bedarf_mod
|
||||
from grotti import maintenance
|
||||
from grotti import store
|
||||
|
||||
|
||||
def _filename_label(filename):
|
||||
name = (filename or "").strip()
|
||||
for suffix in (".gcode.3mf", ".gcode", ".3mf"):
|
||||
if name.lower().endswith(suffix):
|
||||
name = name[: -len(suffix)]
|
||||
break
|
||||
while name and name[0].isdigit():
|
||||
parts = name.split("-", 1)
|
||||
if len(parts) == 2 and parts[0].isdigit():
|
||||
name = parts[1]
|
||||
else:
|
||||
break
|
||||
return name or filename or ""
|
||||
|
||||
|
||||
def _iso_remain(remain_s):
|
||||
remain = float(remain_s or 0)
|
||||
if remain <= 0:
|
||||
return None
|
||||
at = datetime.now(timezone.utc).timestamp() + remain
|
||||
return datetime.fromtimestamp(at, tz=timezone.utc).isoformat()
|
||||
|
||||
|
||||
def farm_eta(running_items):
|
||||
"""Früheste und späteste Fertig-Zeit aus laufenden Drucken."""
|
||||
remains = [
|
||||
float(item.get("remain") or 0)
|
||||
for item in (running_items or [])
|
||||
if float(item.get("remain") or 0) > 0
|
||||
]
|
||||
if not remains:
|
||||
return {"all_done_at": None, "all_done_in_s": None, "next_free_in_s": None}
|
||||
return {
|
||||
"all_done_at": _iso_remain(max(remains)),
|
||||
"all_done_in_s": max(remains),
|
||||
"next_free_in_s": min(remains),
|
||||
}
|
||||
|
||||
|
||||
def overdue_orders(conn, limit=8):
|
||||
today = date.today()
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT o.*, c.name AS customer_name
|
||||
FROM orders o
|
||||
LEFT JOIN customers c ON c.id = o.customer_id
|
||||
WHERE o.due_at IS NOT NULL
|
||||
AND o.due_at < %s
|
||||
AND o.status NOT IN ('fertig', 'geliefert', 'bezahlt')
|
||||
ORDER BY o.due_at ASC, o.id ASC
|
||||
LIMIT %s
|
||||
""",
|
||||
(today, int(limit)),
|
||||
).fetchall()
|
||||
out = []
|
||||
for row in rows:
|
||||
out.append({
|
||||
"id": row["id"],
|
||||
"title": row["title"],
|
||||
"status": row["status"],
|
||||
"due_at": row["due_at"].isoformat() if row.get("due_at") else None,
|
||||
"customer": row.get("customer_name") or "",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def overdue_maintenance(conn, printers, limit=8):
|
||||
items = []
|
||||
for printer in printers or []:
|
||||
host = printer.get("host") or ""
|
||||
counters = maintenance.counters_for_host(conn, host)
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM maintenance_parts WHERE printer_host = %s ORDER BY label, part_key, id",
|
||||
(host,),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
part = maintenance.part_out(row, counters, conn=conn)
|
||||
if not (part.get("overdue_hours") or part.get("overdue_meters")):
|
||||
continue
|
||||
items.append({
|
||||
"id": part["id"],
|
||||
"host": host,
|
||||
"printer": printer.get("name") or host,
|
||||
"label": part.get("label") or part.get("part_key") or "Teil",
|
||||
"overdue_hours": bool(part.get("overdue_hours")),
|
||||
"overdue_meters": bool(part.get("overdue_meters")),
|
||||
})
|
||||
if len(items) >= limit:
|
||||
return items
|
||||
return items
|
||||
|
||||
|
||||
def build(conn, watcher_latest, printers=None, send_jobs=None):
|
||||
"""Priorisierte Handlungsliste + laufende Drucke + Farm-ETA."""
|
||||
printers = printers if printers is not None else store.list_printers(conn)
|
||||
clears = store.bed_clear_map(conn)
|
||||
busy_hosts = {
|
||||
row["host"]
|
||||
for row in conn.execute(
|
||||
"SELECT DISTINCT host FROM print_queue WHERE status = 'running'"
|
||||
).fetchall()
|
||||
}
|
||||
queue = store.list_print_queue(conn, limit=80)
|
||||
waiting = [j for j in queue if j.get("status") == "waiting"]
|
||||
errors = [j for j in queue if j.get("status") == "error"]
|
||||
running_q = [j for j in queue if j.get("status") == "running"]
|
||||
|
||||
actions = []
|
||||
|
||||
for live in watcher_latest or []:
|
||||
host = live.get("host") or ""
|
||||
state = (live.get("state") or "").lower()
|
||||
if state == "paused" and (live.get("filament_empty") or (live.get("pause_reason") or "").strip()):
|
||||
actions.append({
|
||||
"kind": "filament_pause",
|
||||
"priority": 12,
|
||||
"host": host,
|
||||
"printer": live.get("name") or host,
|
||||
"title": "Material nachlegen",
|
||||
"detail": (live.get("pause_reason") or "Drucker pausiert — Filament leer?").strip(),
|
||||
"filename": live.get("filename") or "",
|
||||
"href": "/#" + host,
|
||||
})
|
||||
|
||||
for live in watcher_latest or []:
|
||||
host = live.get("host") or ""
|
||||
state = (live.get("state") or "").lower()
|
||||
if not clears.get(host) or host in busy_hosts or state in ("printing", "paused", "heating"):
|
||||
continue
|
||||
actions.append({
|
||||
"kind": "clear",
|
||||
"priority": 10,
|
||||
"host": host,
|
||||
"printer": live.get("name") or host,
|
||||
"title": "Platte räumen",
|
||||
"detail": _filename_label(live.get("filename") or ""),
|
||||
"filename": live.get("filename") or "",
|
||||
"href": "/#" + host,
|
||||
})
|
||||
|
||||
for job in errors[:6]:
|
||||
actions.append({
|
||||
"kind": "queue_error",
|
||||
"priority": 20,
|
||||
"id": job.get("id"),
|
||||
"host": job.get("host") or "",
|
||||
"printer": job.get("printer_name") or job.get("host") or "",
|
||||
"title": "Druck fehlgeschlagen",
|
||||
"detail": job.get("message") or _filename_label(job.get("filename") or ""),
|
||||
"filename": job.get("filename") or "",
|
||||
"is_ablauf": bool(job.get("is_ablauf")),
|
||||
"step_index": job.get("step_index"),
|
||||
"step_label_current": job.get("step_label_current") or "",
|
||||
"href": "/warteschlange",
|
||||
})
|
||||
|
||||
bedarf = bedarf_mod.compute(conn)
|
||||
for item in (bedarf.get("shortfall") or [])[:5]:
|
||||
label = " · ".join(
|
||||
x for x in [item.get("material_type"), item.get("color")] if x
|
||||
) or "Filament"
|
||||
actions.append({
|
||||
"kind": "bedarf",
|
||||
"priority": 30,
|
||||
"title": "Spule fehlt",
|
||||
"detail": "%s — %s g fehlen" % (label, int(item.get("shortfall_g") or 0)),
|
||||
"material_type": item.get("material_type") or "",
|
||||
"color": item.get("color") or "",
|
||||
"shortfall_g": item.get("shortfall_g") or 0,
|
||||
"href": "/material#bedarf",
|
||||
})
|
||||
|
||||
for part in overdue_maintenance(conn, printers):
|
||||
actions.append({
|
||||
"kind": "wartung",
|
||||
"priority": 40,
|
||||
"id": part["id"],
|
||||
"host": part["host"],
|
||||
"printer": part["printer"],
|
||||
"title": "Wartung fällig",
|
||||
"detail": "%s · %s" % (part["printer"], part["label"]),
|
||||
"href": "/wartung",
|
||||
})
|
||||
|
||||
for order in overdue_orders(conn):
|
||||
actions.append({
|
||||
"kind": "auftrag",
|
||||
"priority": 50,
|
||||
"id": order["id"],
|
||||
"title": "Auftrag überfällig",
|
||||
"detail": "%s · fällig %s" % (order["title"], order.get("due_at") or ""),
|
||||
"due_at": order.get("due_at"),
|
||||
"href": "/auftraege",
|
||||
})
|
||||
|
||||
shortfall = bedarf.get("shortfall") or []
|
||||
for job in waiting[:8]:
|
||||
tip = (job.get("wait_reason") or "").strip()
|
||||
if not tip and shortfall:
|
||||
# Fallback ohne Routing-Hinweis
|
||||
tip = ""
|
||||
if not tip:
|
||||
continue
|
||||
actions.append({
|
||||
"kind": "waiting",
|
||||
"priority": 60,
|
||||
"id": job.get("id"),
|
||||
"host": job.get("host") or "",
|
||||
"printer": job.get("printer_name") or job.get("host") or "",
|
||||
"title": "Wartet auf Material",
|
||||
"detail": tip,
|
||||
"filename": job.get("filename") or "",
|
||||
"reason": tip,
|
||||
"href": "/warteschlange",
|
||||
})
|
||||
|
||||
actions.sort(key=lambda a: (a.get("priority") or 99, a.get("id") or 0))
|
||||
|
||||
live_by_host = {}
|
||||
running = []
|
||||
for live in watcher_latest or []:
|
||||
state = (live.get("state") or "").lower()
|
||||
if state not in ("printing", "paused", "heating"):
|
||||
continue
|
||||
item = {
|
||||
"host": live.get("host") or "",
|
||||
"name": live.get("name") or "",
|
||||
"state": state,
|
||||
"progress": live.get("progress") or 0,
|
||||
"remain": live.get("remain") or 0,
|
||||
"filename": live.get("filename") or "",
|
||||
"filament_empty": bool(live.get("filament_empty")),
|
||||
"pause_reason": (live.get("pause_reason") or "").strip(),
|
||||
"queue_id": None,
|
||||
}
|
||||
live_by_host[item["host"]] = item
|
||||
running.append(item)
|
||||
for job in running_q:
|
||||
host = job.get("host") or ""
|
||||
if host in live_by_host:
|
||||
live_by_host[host]["queue_id"] = job.get("id")
|
||||
live_by_host[host]["is_ablauf"] = bool(job.get("is_ablauf"))
|
||||
live_by_host[host]["step_labels"] = job.get("step_labels")
|
||||
live_by_host[host]["display_step_index"] = job.get("display_step_index")
|
||||
live_by_host[host]["step_index"] = job.get("step_index")
|
||||
else:
|
||||
running.append({
|
||||
"host": host,
|
||||
"name": job.get("printer_name") or host,
|
||||
"state": "running",
|
||||
"progress": job.get("progress") or 0,
|
||||
"remain": job.get("remain") or 0,
|
||||
"filename": job.get("filename") or "",
|
||||
"queue_id": job.get("id"),
|
||||
"is_ablauf": bool(job.get("is_ablauf")),
|
||||
})
|
||||
|
||||
sends = []
|
||||
for job in send_jobs or []:
|
||||
if job.get("status") in ("pending", "running"):
|
||||
sends.append(job)
|
||||
elif job.get("status") == "error" and len(sends) < 2:
|
||||
sends.append(job)
|
||||
|
||||
alert_kinds = list(store.ALERT_EVENT_KINDS)
|
||||
alert_items = store.list_events(conn, limit=5, unread_only=True, kinds=alert_kinds)
|
||||
unread = store.unread_event_count(conn, kinds=alert_kinds)
|
||||
|
||||
return {
|
||||
"actions": actions,
|
||||
"running": running,
|
||||
"waiting_count": len(waiting),
|
||||
"sends": sends[:5],
|
||||
"alerts": alert_items,
|
||||
"unread_alerts": unread,
|
||||
"eta": farm_eta(running),
|
||||
"bedarf_shortfall": len(shortfall),
|
||||
}
|
||||
+4
-17
@@ -5,7 +5,7 @@ import urllib.error
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from grotti import acm, eject, library, moonraker, store, usage
|
||||
from grotti import acm, catalog, eject, library, moonraker, store, usage
|
||||
from grotti.cost import energy_cost
|
||||
from grotti.errors import GrottiError
|
||||
from grotti.parse import content_has_ace_trailer, prepare_rinkhals_gcode
|
||||
@@ -127,21 +127,8 @@ def resolve_spools_at_host(conn, host, ace_map=None):
|
||||
|
||||
|
||||
def assert_material_match(file_row, spools):
|
||||
"""Datei-Material gegen eingelegte Rollen — warnt nicht bei leerer Liste."""
|
||||
want = {t.lower() for t in file_material_types(file_row)}
|
||||
if not want or not spools:
|
||||
return
|
||||
have = {(row.get("material_type") or "").strip().lower() for row in spools}
|
||||
have.discard("")
|
||||
if not have:
|
||||
return
|
||||
if want.isdisjoint(have):
|
||||
raise GrottiError(
|
||||
"Material passt nicht: Datei braucht %s, im Drucker steckt %s."
|
||||
% (", ".join(sorted(want)), ", ".join(sorted(have)) or "unbekannt"),
|
||||
"material",
|
||||
409,
|
||||
)
|
||||
"""Falsche Sorte bricht den Start nicht ab. Die Oberfläche warnt vorher."""
|
||||
return
|
||||
|
||||
|
||||
def assert_spool_at_host(conn, spool_id, host):
|
||||
@@ -686,7 +673,7 @@ def steuern(conn, host, action, params=None):
|
||||
continue
|
||||
if not spool:
|
||||
raise GrottiError("Auf diesem Platz liegt keine Spule in Grotti.", "spule")
|
||||
material = (spool.get("material_type") or "").strip()
|
||||
material = catalog.ace_material_type(spool.get("material_type"))
|
||||
if not material:
|
||||
raise GrottiError("Die Spule braucht eine Sorte.", "material")
|
||||
color = moonraker.spool_rgb(spool)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Auto-Retry und Routing-Hinweise in der Warteschlange.
|
||||
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS retry_count integer NOT NULL DEFAULT 0;
|
||||
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS wait_reason text NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- PA12-CF bleibt in Grotti die echte Sorte. Der ACE kennt nur PA6-CF.
|
||||
UPDATE materials
|
||||
SET material_type = 'PA12-CF'
|
||||
WHERE name = 'Sunlu PA12-CF' AND material_type = 'PA';
|
||||
|
||||
UPDATE spools
|
||||
SET material_type = 'PA12-CF'
|
||||
WHERE name = 'Sunlu PA12-CF' AND material_type = 'PA';
|
||||
+100
-9
@@ -889,6 +889,81 @@ def enrich_error_message(host, stats, status):
|
||||
return " · ".join(bits) if bits else (raw or "Unbekannter Fehler — in Mainsail prüfen")
|
||||
|
||||
|
||||
_FILAMENT_HINT_RE = re.compile(
|
||||
r"filament|runout|out of fil|no fil|fil.?empty|10107|"
|
||||
r"material.?leer|spule.?leer|filament.?gerissen|filament.?fehlt|"
|
||||
r"filament.?sensor|out of material",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def last_filament_gcode_hint(host, limit=40):
|
||||
"""Letzte G-code-Zeile, die auf Filament-Ende / Runout hindeutet."""
|
||||
try:
|
||||
payload = fetch_json("http://%s/server/gcode_store?count=%d" % (host, int(limit)), timeout=3)
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, OSError):
|
||||
return ""
|
||||
items = ((payload.get("result") or {}).get("gcode_store") or [])
|
||||
for item in reversed(items):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
text = (item.get("message") or "").strip()
|
||||
if not text or not _FILAMENT_HINT_RE.search(text):
|
||||
continue
|
||||
low = text.lower()
|
||||
if "10107" in low:
|
||||
return "Filament gerissen oder leer (10107)"
|
||||
return text[:160]
|
||||
return ""
|
||||
|
||||
|
||||
def filament_out_hint(mmu, message="", host=None):
|
||||
"""Vermutetes Material-Ende bei Pause: (code, Text) oder (None, None).
|
||||
|
||||
Quellen: print_stats.message, ACE/mmu (active_filament.empty, gate_status),
|
||||
optional G-code-Store (nur wenn host gesetzt).
|
||||
"""
|
||||
text = (message or "").strip()
|
||||
if text and _FILAMENT_HINT_RE.search(text):
|
||||
if "10107" in text.lower():
|
||||
return "message", "Filament gerissen oder leer (10107)"
|
||||
return "message", text[:160]
|
||||
|
||||
if isinstance(mmu, dict) and mmu:
|
||||
active = mmu.get("active_filament")
|
||||
if isinstance(active, dict) and (
|
||||
active.get("empty") is True
|
||||
or str(active.get("empty") or "").lower() in ("1", "true")
|
||||
):
|
||||
return "active_empty", "Aktives Filament leer (ACE)"
|
||||
|
||||
tool = mmu.get("tool")
|
||||
if not isinstance(tool, (int, float)) or isinstance(tool, bool):
|
||||
tool = mmu.get("gate")
|
||||
if isinstance(tool, (int, float)) and not isinstance(tool, bool) and int(tool) >= 0:
|
||||
tool_i = int(tool)
|
||||
states = mmu.get("gate_status")
|
||||
if isinstance(states, list) and tool_i < len(states) and states[tool_i] == 0:
|
||||
return "gate_empty", "ACE-Platz %d leer" % (tool_i + 1)
|
||||
|
||||
# ACE pausiert und kein aktives Filament → oft Runout ohne leeren gate_status
|
||||
if mmu.get("is_paused") and isinstance(active, dict):
|
||||
kind = (
|
||||
active.get("filament_type")
|
||||
or active.get("type")
|
||||
or active.get("material")
|
||||
or ""
|
||||
)
|
||||
if not str(kind).strip():
|
||||
return "mmu_paused", "ACE pausiert — kein Filament aktiv"
|
||||
|
||||
if host:
|
||||
hint = last_filament_gcode_hint(host)
|
||||
if hint:
|
||||
return "gcode", hint
|
||||
return None, None
|
||||
|
||||
|
||||
def exclude_status(host):
|
||||
"""Klipper [exclude_object] — leer, wenn Modul fehlt oder Query scheitert."""
|
||||
empty = {"objects": [], "excluded": [], "current": None}
|
||||
@@ -1055,6 +1130,15 @@ def query(host):
|
||||
message = stats.get("message") or ""
|
||||
if (state or "").lower() == "error":
|
||||
message = enrich_error_message(host, stats, status)
|
||||
pause_code, pause_reason = None, ""
|
||||
filament_empty = False
|
||||
if (state or "").lower() == "paused":
|
||||
# G-code-Store nur bei Pause abfragen — sonst unnötige Last.
|
||||
pause_code, pause_reason = filament_out_hint(mmu, message, host=host)
|
||||
filament_empty = bool(pause_code)
|
||||
if filament_empty and pause_reason:
|
||||
if not message or message.lower() in ("paused", "pause", "pausiert"):
|
||||
message = pause_reason
|
||||
toolhead = status.get("toolhead") or {}
|
||||
axis_max = toolhead.get("axis_maximum") or []
|
||||
z_max = None
|
||||
@@ -1064,6 +1148,8 @@ def query(host):
|
||||
"ok": True,
|
||||
"state": state,
|
||||
"message": message,
|
||||
"filament_empty": filament_empty,
|
||||
"pause_reason": pause_reason or "",
|
||||
"homed_axes": toolhead.get("homed_axes") or "",
|
||||
"position": toolhead.get("position") or [],
|
||||
"z_max": z_max,
|
||||
@@ -1142,6 +1228,8 @@ def snapshot(printers):
|
||||
"ok": False,
|
||||
"state": "offline",
|
||||
"message": str(error),
|
||||
"filament_empty": False,
|
||||
"pause_reason": "",
|
||||
"camera": "",
|
||||
"has_camera": False,
|
||||
"fan": None,
|
||||
@@ -1185,20 +1273,23 @@ def camera_image(host):
|
||||
return CAMERA_CACHE[host][2]
|
||||
|
||||
|
||||
def open_camera_stream(host):
|
||||
"""Öffnet den MJPEG-Stream am Drucker. Aufrufer schließt die Response."""
|
||||
url = "http://%s/webcam/?action=stream" % host
|
||||
request = urllib.request.Request(url)
|
||||
return urllib.request.urlopen(request, timeout=30)
|
||||
|
||||
|
||||
def _camera_fields(host, quoted_host):
|
||||
"""Kamera-URL nur, wenn nicht als tot bekannt (Cache-Miss = erst versuchen)."""
|
||||
snap = "/api/v1/kamera?host=%s" % quoted_host
|
||||
stream = "/api/v1/kamera/stream?host=%s" % quoted_host
|
||||
cached = CAMERA_CACHE.get(host)
|
||||
if cached is not None and cached[2] is None:
|
||||
return {"camera": "", "has_camera": False}
|
||||
return {"camera": "", "camera_stream": "", "has_camera": False}
|
||||
if cached is not None and cached[2] is not None:
|
||||
return {
|
||||
"camera": "/api/v1/kamera?host=%s" % quoted_host,
|
||||
"has_camera": True,
|
||||
}
|
||||
return {
|
||||
"camera": "/api/v1/kamera?host=%s" % quoted_host,
|
||||
"has_camera": None,
|
||||
}
|
||||
return {"camera": snap, "camera_stream": stream, "has_camera": True}
|
||||
return {"camera": snap, "camera_stream": stream, "has_camera": None}
|
||||
|
||||
|
||||
def embedded_bytes(host, name):
|
||||
|
||||
+6
-1
@@ -34,7 +34,12 @@ def send_telegram(cfg, text):
|
||||
return False
|
||||
|
||||
|
||||
ALERT_GROUP = {"abbruch": "fehler"}
|
||||
ALERT_GROUP = {
|
||||
"abbruch": "fehler",
|
||||
"bedarf": "queue",
|
||||
"wartung": "queue",
|
||||
"auftrag": "queue",
|
||||
}
|
||||
DEFAULT_OFF = {"ablauf", "start"}
|
||||
|
||||
|
||||
|
||||
+99
-36
@@ -144,14 +144,46 @@ class PrintQueueDispatcher:
|
||||
return (datetime.now(timezone.utc) - updated).total_seconds()
|
||||
return 0
|
||||
|
||||
def _auto_retry_enabled(self):
|
||||
return bool((self.cfg or {}).get("auto_retry", True))
|
||||
|
||||
def _try_auto_retry(self, conn, item, message):
|
||||
"""Ein automatischer Neuversuch; sonst False."""
|
||||
if not self._auto_retry_enabled():
|
||||
return False
|
||||
retries = int(item.get("retry_count") or 0)
|
||||
if retries >= 1:
|
||||
return False
|
||||
store.update_print_queue(
|
||||
conn,
|
||||
item["id"],
|
||||
{
|
||||
"status": "waiting",
|
||||
"message": "Nochmal (%s)" % (message or "Fehler"),
|
||||
"retry_count": retries + 1,
|
||||
"phase": "",
|
||||
"phase_started_at": None,
|
||||
"host": item.get("host") or "",
|
||||
},
|
||||
)
|
||||
return True
|
||||
|
||||
def _fail_stuck_simple_start(self, conn, item, printer):
|
||||
host = printer["host"]
|
||||
msg = "Start abgebrochen (Neustart)"
|
||||
if self._try_auto_retry(conn, item, msg):
|
||||
self._event(
|
||||
conn, host, printer["name"], "queue",
|
||||
"%s: Nochmal — %s" % (printer["name"], msg),
|
||||
item.get("filename") or "",
|
||||
)
|
||||
return
|
||||
store.update_print_queue(
|
||||
conn,
|
||||
item["id"],
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Start abgebrochen (Neustart)",
|
||||
"message": msg,
|
||||
"phase": "",
|
||||
"phase_started_at": None,
|
||||
},
|
||||
@@ -828,43 +860,64 @@ class PrintQueueDispatcher:
|
||||
return state
|
||||
|
||||
def _start_cool_step(self, conn, item, printer, step):
|
||||
# TODO: _try_cool_park (bis 60 s G-code) blockiert tick() — ggf. in Worker auslagern.
|
||||
# Park (bis 60 s G-code) läuft im Worker, damit tick() nicht blockiert.
|
||||
host = printer["host"]
|
||||
cool = eject.normalize_cool(step)
|
||||
try:
|
||||
moonraker.gcode_script(host, eject.cool_script(cool), timeout=30)
|
||||
# Idle zuerst verlängern, dann Park (sonst Stepper aus → must home).
|
||||
try:
|
||||
moonraker.gcode_script(host, eject.idle_timeout_script(cool), timeout=15)
|
||||
except Exception:
|
||||
pass
|
||||
park = self._try_cool_park(host, item["id"], force=True)
|
||||
self._keepalive[item["id"]] = time.monotonic()
|
||||
park_note = ""
|
||||
if not park["ok"] and park["error"]:
|
||||
park_note = " — Mitte fehlgeschlagen: %s" % park["error"]
|
||||
elif park["ok"]:
|
||||
park_note = " · Düse Mitte"
|
||||
store.update_print_queue(
|
||||
conn,
|
||||
item["id"],
|
||||
{
|
||||
"phase": "cooling",
|
||||
"phase_started_at": "now",
|
||||
"message": self._ablauf_message(
|
||||
item,
|
||||
"kühlt ab (Bett ≤ %.0f °C, Düse ≤ %.0f °C)%s"
|
||||
% (cool["bed_max"], cool["nozzle_max"], park_note),
|
||||
),
|
||||
},
|
||||
)
|
||||
self._step_event(
|
||||
conn, item, printer,
|
||||
"kühlt ab bis Bett ≤ %.0f °C, Düse ≤ %.0f °C%s"
|
||||
% (cool["bed_max"], cool["nozzle_max"], park_note),
|
||||
)
|
||||
except Exception as exc:
|
||||
self._fail_ablauf(conn, item, printer, "Abkühlen: %s" % exc)
|
||||
item_id = item["id"]
|
||||
printer_copy = dict(printer)
|
||||
item_copy = dict(item)
|
||||
|
||||
def work():
|
||||
with connect(self.database_url) as wconn:
|
||||
try:
|
||||
moonraker.gcode_script(host, eject.cool_script(cool), timeout=30)
|
||||
try:
|
||||
moonraker.gcode_script(host, eject.idle_timeout_script(cool), timeout=15)
|
||||
except Exception:
|
||||
pass
|
||||
park = self._try_cool_park(host, item_id, force=True)
|
||||
self._keepalive[item_id] = time.monotonic()
|
||||
park_note = ""
|
||||
if not park["ok"] and park["error"]:
|
||||
park_note = " — Mitte fehlgeschlagen: %s" % park["error"]
|
||||
elif park["ok"]:
|
||||
park_note = " · Düse Mitte"
|
||||
row = store.get_print_queue_row(wconn, item_id) or item_copy
|
||||
store.update_print_queue(
|
||||
wconn,
|
||||
item_id,
|
||||
{
|
||||
"message": self._ablauf_message(row, "kühlt%s" % park_note),
|
||||
"phase": "cooling",
|
||||
"phase_started_at": datetime.now(timezone.utc),
|
||||
},
|
||||
)
|
||||
wconn.commit()
|
||||
except GrottiError as exc:
|
||||
self._fail_ablauf(wconn, item_copy, printer_copy, "Abkühlen: %s" % exc.message)
|
||||
wconn.commit()
|
||||
except Exception as exc:
|
||||
self._fail_ablauf(wconn, item_copy, printer_copy, "Abkühlen: %s" % exc)
|
||||
wconn.commit()
|
||||
|
||||
store.update_print_queue(
|
||||
conn,
|
||||
item_id,
|
||||
{
|
||||
"message": self._ablauf_message(
|
||||
item,
|
||||
"kühlt ab (Bett ≤ %.0f °C, Düse ≤ %.0f °C)"
|
||||
% (cool["bed_max"], cool["nozzle_max"]),
|
||||
),
|
||||
"phase": "cooling",
|
||||
"phase_started_at": datetime.now(timezone.utc),
|
||||
},
|
||||
)
|
||||
self._step_event(
|
||||
conn, item, printer,
|
||||
"kühlt ab bis Bett ≤ %.0f °C, Düse ≤ %.0f °C" % (cool["bed_max"], cool["nozzle_max"]),
|
||||
)
|
||||
self._spawn_worker(item_id, work)
|
||||
|
||||
def _poll_cool(self, conn, item, printer, live, step):
|
||||
cool = eject.normalize_cool(step)
|
||||
@@ -1214,6 +1267,16 @@ class PrintQueueDispatcher:
|
||||
self._fail_ablauf(conn, item, printer, "Unbekannter Schritt: %s" % kind)
|
||||
|
||||
def _fail_ablauf(self, conn, item, printer, message):
|
||||
if self._try_auto_retry(conn, item, message):
|
||||
self._event(
|
||||
conn,
|
||||
printer["host"],
|
||||
printer["name"],
|
||||
"queue",
|
||||
"%s Nochmal — %s" % (printer["name"], message),
|
||||
item.get("filename") or "",
|
||||
)
|
||||
return
|
||||
store.update_print_queue(
|
||||
conn,
|
||||
item["id"],
|
||||
|
||||
@@ -172,6 +172,8 @@ ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS repeat_total integer NOT NULL D
|
||||
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS repeat_done integer NOT NULL DEFAULT 0;
|
||||
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS phase text NOT NULL DEFAULT '';
|
||||
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS phase_started_at timestamptz;
|
||||
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS retry_count integer NOT NULL DEFAULT 0;
|
||||
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS wait_reason text NOT NULL DEFAULT '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS print_queue_waiting ON print_queue (status, priority, id);
|
||||
CREATE INDEX IF NOT EXISTS print_queue_running_host ON print_queue (host, status) WHERE status = 'running';
|
||||
|
||||
+258
-16
@@ -176,6 +176,12 @@ def run_out(row):
|
||||
"chargen_nr": row.get("chargen_nr") or "",
|
||||
"message": row.get("message") or "",
|
||||
"objects": _json_list(row.get("objects")),
|
||||
"cost_per_part": (
|
||||
(float(row.get("filament_cost") or 0) + float(row.get("energy_cost") or 0))
|
||||
/ int(row["pieces_ok"])
|
||||
if int(row.get("pieces_ok") or 0) > 0
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -1833,6 +1839,16 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
|
||||
run_where.append("r.project_id = %s")
|
||||
run_values.append(project_id)
|
||||
run_clause = " AND ".join(run_where)
|
||||
pieces_row = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(SUM(r.pieces_ok), 0) AS pieces_ok,
|
||||
COALESCE(SUM(r.pieces_bad), 0) AS pieces_bad
|
||||
FROM runs r
|
||||
WHERE %s
|
||||
""" % run_clause,
|
||||
run_values,
|
||||
).fetchone()
|
||||
|
||||
by_printer = conn.execute(
|
||||
"""
|
||||
@@ -1936,6 +1952,9 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
|
||||
).fetchone()
|
||||
ov = overview2 or ov
|
||||
|
||||
pieces_ok = int((pieces_row or {}).get("pieces_ok") or 0)
|
||||
pieces_bad = int((pieces_row or {}).get("pieces_bad") or 0)
|
||||
pieces_total = pieces_ok + pieces_bad
|
||||
return {
|
||||
"overview": {
|
||||
"jobs": int(ov.get("jobs") or 0),
|
||||
@@ -1946,6 +1965,10 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
|
||||
"failed": int(ov.get("failed") or 0),
|
||||
"scrap": int(ov.get("scrap") or 0),
|
||||
"good": int(ov.get("good") or 0),
|
||||
"pieces_ok": pieces_ok,
|
||||
"pieces_bad": pieces_bad,
|
||||
"yield_percent": (100.0 * pieces_ok / pieces_total) if pieces_total else None,
|
||||
"cost_per_part": (float(ov.get("cost") or 0) / pieces_ok) if pieces_ok else None,
|
||||
},
|
||||
"by_printer": [
|
||||
{
|
||||
@@ -2186,7 +2209,11 @@ def event_out(row):
|
||||
|
||||
|
||||
# Protokoll-Ereignisse, die kein Alert sind: gleich als gelesen speichern.
|
||||
QUIET_EVENT_KINDS = {"ablauf", "start", "fertig"}
|
||||
QUIET_EVENT_KINDS = {"ablauf", "start", "fertig", "spule", "auth"}
|
||||
# Was in der Sidebar als „Meldungen“ zählt.
|
||||
ALERT_EVENT_KINDS = frozenset({
|
||||
"fehler", "abbruch", "offline", "queue", "spule", "bedarf", "wartung", "auftrag",
|
||||
})
|
||||
|
||||
|
||||
def add_event(conn, host, printer, kind, message, filename="", run_id=None):
|
||||
@@ -2232,19 +2259,29 @@ def list_events(conn, limit=40, unread_only=False, host=None, kinds=None, since=
|
||||
return [event_out(row) for row in rows]
|
||||
|
||||
|
||||
def unread_event_count(conn):
|
||||
def unread_event_count(conn, kinds=None):
|
||||
if kinds:
|
||||
return conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM events WHERE read_at IS NULL AND kind = ANY(%s)",
|
||||
(list(kinds),),
|
||||
).fetchone()["n"]
|
||||
return conn.execute("SELECT COUNT(*) AS n FROM events WHERE read_at IS NULL").fetchone()["n"]
|
||||
|
||||
|
||||
def mark_events_read(conn, event_ids=None):
|
||||
def mark_events_read(conn, event_ids=None, kinds=None):
|
||||
if event_ids:
|
||||
conn.execute(
|
||||
"UPDATE events SET read_at = now() WHERE read_at IS NULL AND id = ANY(%s)",
|
||||
(list(event_ids),),
|
||||
)
|
||||
elif kinds:
|
||||
conn.execute(
|
||||
"UPDATE events SET read_at = now() WHERE read_at IS NULL AND kind = ANY(%s)",
|
||||
(list(kinds),),
|
||||
)
|
||||
else:
|
||||
conn.execute("UPDATE events SET read_at = now() WHERE read_at IS NULL")
|
||||
return unread_event_count(conn)
|
||||
return unread_event_count(conn, kinds=kinds or list(ALERT_EVENT_KINDS))
|
||||
|
||||
|
||||
def recent_event_kind(conn, host, kind, within_s=60):
|
||||
@@ -2323,6 +2360,8 @@ def print_queue_out(row, file_name=None):
|
||||
"phase_started_at": iso(row.get("phase_started_at")),
|
||||
"is_ablauf": bool(steps),
|
||||
"order_item_id": row.get("order_item_id"),
|
||||
"retry_count": int(row.get("retry_count") or 0),
|
||||
"wait_reason": row.get("wait_reason") or "",
|
||||
"created_at": iso(row["created_at"]),
|
||||
"updated_at": iso(row["updated_at"]),
|
||||
}
|
||||
@@ -2498,6 +2537,8 @@ def update_print_queue(conn, item_id, fields):
|
||||
"spool_id": "spool_id",
|
||||
"bed_leveling": "bed_leveling",
|
||||
"override_machine": "override_machine",
|
||||
"retry_count": "retry_count",
|
||||
"wait_reason": "wait_reason",
|
||||
}
|
||||
sets = []
|
||||
values = []
|
||||
@@ -2538,32 +2579,156 @@ def update_print_queue(conn, item_id, fields):
|
||||
return print_queue_out(fresh, fresh.get("filename")) if fresh else None
|
||||
|
||||
|
||||
def _spool_material_set(conn, host):
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT lower(coalesce(material_type, '')) AS mt,
|
||||
lower(coalesce(color, '')) AS color,
|
||||
coalesce(remaining_g, 0) AS remaining_g
|
||||
FROM spools
|
||||
WHERE printer_host = %s
|
||||
""",
|
||||
(host or "",),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"material_type": (r["mt"] or "").strip(),
|
||||
"color": (r["color"] or "").strip(),
|
||||
"remaining_g": float(r["remaining_g"] or 0),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _file_needs(conn, file_id):
|
||||
if not file_id:
|
||||
return [], 0.0
|
||||
row = conn.execute(
|
||||
"SELECT filaments, weight_g FROM files WHERE id = %s",
|
||||
(file_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return [], 0.0
|
||||
filaments = row.get("filaments") or []
|
||||
if isinstance(filaments, str):
|
||||
try:
|
||||
import json
|
||||
filaments = json.loads(filaments)
|
||||
except Exception:
|
||||
filaments = []
|
||||
types = []
|
||||
for item in filaments if isinstance(filaments, list) else []:
|
||||
if isinstance(item, dict):
|
||||
t = (item.get("type") or item.get("material_type") or "").strip().lower()
|
||||
c = (item.get("color") or "").strip().lower()
|
||||
if t:
|
||||
types.append({"material_type": t, "color": c})
|
||||
elif isinstance(item, str) and item.strip():
|
||||
types.append({"material_type": item.strip().lower(), "color": ""})
|
||||
return types, float(row.get("weight_g") or 0)
|
||||
|
||||
|
||||
def material_fit_score(conn, host, file_id):
|
||||
"""Höher = besser. -1 nur bei zu wenig Rest. Falsche Sorte startet trotzdem."""
|
||||
need, weight = _file_needs(conn, file_id)
|
||||
have = _spool_material_set(conn, host)
|
||||
if not need:
|
||||
return 0, ""
|
||||
if not have:
|
||||
return 0, ""
|
||||
have_types = {h["material_type"] for h in have if h["material_type"]}
|
||||
need_types = {n["material_type"] for n in need if n["material_type"]}
|
||||
# Falsche Sorte hält den Job nicht fest. Dateien warnt, der Start geht trotzdem.
|
||||
score = 0 if (need_types and have_types and need_types.isdisjoint(have_types)) else 10
|
||||
# Farbe + Rest
|
||||
for n in need:
|
||||
matched = [
|
||||
h for h in have
|
||||
if h["material_type"] == n["material_type"]
|
||||
and (not n["color"] or h["color"] == n["color"] or not h["color"])
|
||||
]
|
||||
if matched:
|
||||
score += 5
|
||||
if weight and any(h["remaining_g"] + 1e-6 >= weight for h in matched):
|
||||
score += 5
|
||||
elif weight and all(h["remaining_g"] + 1e-6 < weight for h in matched):
|
||||
return -1, "braucht %s (%s g fehlen)" % (
|
||||
n["material_type"] + ((" " + n["color"]) if n["color"] else ""),
|
||||
int(max(0, weight - max(h["remaining_g"] for h in matched))),
|
||||
)
|
||||
return score, ""
|
||||
|
||||
|
||||
def next_waiting_print(conn, host=None, family=None):
|
||||
"""Nächster wartender Druck: Termin (due_at) vor Priorität, Material-Match bevorzugt."""
|
||||
if host:
|
||||
row = conn.execute(
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT q.*, f.name AS filename
|
||||
SELECT q.*, f.name AS filename, o.due_at AS order_due_at
|
||||
FROM print_queue q
|
||||
LEFT JOIN files f ON f.id = q.file_id
|
||||
LEFT JOIN order_items oi ON oi.id = q.order_item_id
|
||||
LEFT JOIN orders o ON o.id = oi.order_id
|
||||
WHERE q.status = 'waiting'
|
||||
AND (q.host = %s OR (q.host = '' AND (q.family = '' OR q.family = %s)))
|
||||
ORDER BY q.priority ASC, q.id ASC
|
||||
LIMIT 1
|
||||
ORDER BY
|
||||
CASE WHEN o.due_at IS NULL THEN 1 ELSE 0 END,
|
||||
o.due_at ASC NULLS LAST,
|
||||
q.priority ASC,
|
||||
q.id ASC
|
||||
LIMIT 20
|
||||
""",
|
||||
(host, family or ""),
|
||||
).fetchone()
|
||||
).fetchall()
|
||||
else:
|
||||
row = conn.execute(
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT q.*, f.name AS filename
|
||||
SELECT q.*, f.name AS filename, o.due_at AS order_due_at
|
||||
FROM print_queue q
|
||||
LEFT JOIN files f ON f.id = q.file_id
|
||||
LEFT JOIN order_items oi ON oi.id = q.order_item_id
|
||||
LEFT JOIN orders o ON o.id = oi.order_id
|
||||
WHERE q.status = 'waiting'
|
||||
ORDER BY q.priority ASC, q.id ASC
|
||||
LIMIT 1
|
||||
ORDER BY
|
||||
CASE WHEN o.due_at IS NULL THEN 1 ELSE 0 END,
|
||||
o.due_at ASC NULLS LAST,
|
||||
q.priority ASC,
|
||||
q.id ASC
|
||||
LIMIT 20
|
||||
""",
|
||||
).fetchone()
|
||||
return row
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return None
|
||||
if not host:
|
||||
return rows[0]
|
||||
|
||||
best = None
|
||||
best_score = None
|
||||
for row in rows:
|
||||
score, reason = material_fit_score(conn, host, row.get("file_id"))
|
||||
if score < 0:
|
||||
# Wartegrund setzen, aber anderen Job bevorzugen
|
||||
if (row.get("wait_reason") or "") != reason or (row.get("message") or "") != reason:
|
||||
conn.execute(
|
||||
"UPDATE print_queue SET wait_reason = %s, message = %s, updated_at = now() WHERE id = %s",
|
||||
(reason, reason, row["id"]),
|
||||
)
|
||||
continue
|
||||
if reason == "" and row.get("wait_reason"):
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE print_queue
|
||||
SET message = CASE WHEN message = wait_reason THEN '' ELSE message END,
|
||||
wait_reason = '',
|
||||
updated_at = now()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(row["id"],),
|
||||
)
|
||||
if best is None or score > best_score:
|
||||
best = row
|
||||
best_score = score
|
||||
return best
|
||||
|
||||
|
||||
def running_print_on_host(conn, host):
|
||||
@@ -2633,7 +2798,10 @@ def ensure_bed_after_print(conn, host, ended_at=None, message=""):
|
||||
|
||||
Deckt verpasste Watcher-Übergänge ab (Neustart, Poll-Lücke): History und
|
||||
erster Blick auf state=complete setzen needs_clear nur, wenn cleared_at
|
||||
vor dem Druckende liegt oder noch fehlt.
|
||||
vor dem Druckende liegt oder noch fehlt. Ohne Endzeit und mit bestehender
|
||||
Quittierung nicht neu belegen — sonst setzt ein Neustart bereits
|
||||
Quittiertes wieder (cleared_at geht verloren). Live-Übergänge setzen
|
||||
needs_clear direkt per set_bed_needs_clear.
|
||||
"""
|
||||
host = (host or "").strip()
|
||||
if not host:
|
||||
@@ -2647,6 +2815,9 @@ def ensure_bed_after_print(conn, host, ended_at=None, message=""):
|
||||
cleared = row["cleared_at"] if row else None
|
||||
if ended_at is not None and cleared is not None and cleared >= ended_at:
|
||||
return False
|
||||
# Neustart / erster Blick ohne Endzeit: schon quittiert → stehen lassen
|
||||
if ended_at is None and cleared is not None:
|
||||
return False
|
||||
set_bed_needs_clear(conn, host, True, message or "Druckende")
|
||||
return True
|
||||
|
||||
@@ -2778,3 +2949,74 @@ def revoke_api_token(conn, token_id, user_id=None):
|
||||
(token_id, user_id),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def global_search(conn, query, limit=20):
|
||||
"""Drucker, Dateien, Spulen, Chargen, Aufträge für Ctrl+K."""
|
||||
q = (query or "").strip()
|
||||
if not q:
|
||||
return []
|
||||
like = "%" + q + "%"
|
||||
items = []
|
||||
for row in conn.execute(
|
||||
"SELECT host, name FROM printers WHERE name ILIKE %s OR host ILIKE %s ORDER BY name LIMIT 5",
|
||||
(like, like),
|
||||
).fetchall():
|
||||
items.append({
|
||||
"kind": "drucker",
|
||||
"title": row["name"] or row["host"],
|
||||
"subtitle": row["host"],
|
||||
"href": "/#" + row["host"],
|
||||
})
|
||||
for row in conn.execute(
|
||||
"SELECT id, name FROM files WHERE name ILIKE %s ORDER BY mtime DESC NULLS LAST, id DESC LIMIT 6",
|
||||
(like,),
|
||||
).fetchall():
|
||||
items.append({
|
||||
"kind": "datei",
|
||||
"title": row["name"],
|
||||
"subtitle": "Datei",
|
||||
"href": "/dateien?file=" + str(row["id"]),
|
||||
})
|
||||
for row in conn.execute(
|
||||
"""
|
||||
SELECT id, name, chargen_nr, code, material_type, color
|
||||
FROM spools
|
||||
WHERE name ILIKE %s OR chargen_nr ILIKE %s OR code ILIKE %s
|
||||
OR material_type ILIKE %s OR color ILIKE %s
|
||||
ORDER BY id DESC LIMIT 6
|
||||
""",
|
||||
(like, like, like, like, like),
|
||||
).fetchall():
|
||||
items.append({
|
||||
"kind": "spule",
|
||||
"title": row["name"],
|
||||
"subtitle": " · ".join(
|
||||
x for x in [row.get("chargen_nr") or row.get("code"), row.get("material_type"), row.get("color")] if x
|
||||
),
|
||||
"href": "/material?spool=" + str(row["id"]),
|
||||
})
|
||||
if q.upper().startswith("D-") or q.upper().startswith("S-"):
|
||||
code = q.upper()
|
||||
run = conn.execute(
|
||||
"SELECT id, chargen_nr, filename FROM runs WHERE chargen_nr ILIKE %s ORDER BY id DESC LIMIT 3",
|
||||
(code + "%",),
|
||||
).fetchall()
|
||||
for row in run:
|
||||
items.append({
|
||||
"kind": "charge",
|
||||
"title": row["chargen_nr"],
|
||||
"subtitle": row.get("filename") or "Druck",
|
||||
"href": "/protokoll/" + str(row["id"]),
|
||||
})
|
||||
for row in conn.execute(
|
||||
"SELECT id, title, status FROM orders WHERE title ILIKE %s ORDER BY updated_at DESC LIMIT 5",
|
||||
(like,),
|
||||
).fetchall():
|
||||
items.append({
|
||||
"kind": "auftrag",
|
||||
"title": row["title"],
|
||||
"subtitle": row["status"],
|
||||
"href": "/auftraege",
|
||||
})
|
||||
return items[: int(limit)]
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
|
||||
|
||||
__version__ = "3.1.0"
|
||||
__version__ = "3.2.0"
|
||||
+60
-20
@@ -139,11 +139,22 @@ class StateWatcher:
|
||||
)
|
||||
self._ensure_start_segment(conn, host, filename, live, run_id)
|
||||
elif prev_state == "printing" and state == "paused":
|
||||
self._emit(conn, host, name, "start", "%s pausiert." % name, filename, run_id)
|
||||
reason = (live.get("pause_reason") or "").strip()
|
||||
if live.get("filament_empty") or reason:
|
||||
detail = reason or "Filament leer oder gerissen"
|
||||
self._emit(
|
||||
conn, host, name, "spule",
|
||||
"%s pausiert — Material nachlegen (%s)." % (name, detail),
|
||||
filename, run_id,
|
||||
)
|
||||
else:
|
||||
self._emit(conn, host, name, "start", "%s pausiert." % name, filename, run_id)
|
||||
elif prev_state == "paused" and state == "printing":
|
||||
self._emit(conn, host, name, "start", "%s druckt weiter." % name, filename, run_id)
|
||||
elif prev_state == "printing" and state == "complete":
|
||||
store.ensure_bed_after_print(conn, host, message=filename or "fertig")
|
||||
# Live-Übergang: immer belegen (ensure_bed_after_print ohne Endzeit
|
||||
# würde nach Quittierung eines älteren Drucks stehen bleiben).
|
||||
store.set_bed_needs_clear(conn, host, True, filename or "fertig")
|
||||
self._emit(
|
||||
conn,
|
||||
host,
|
||||
@@ -159,26 +170,37 @@ class StateWatcher:
|
||||
jobs.sync_history(conn, self.cfg)
|
||||
except Exception as exc:
|
||||
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
||||
elif prev_state in ("printing", "paused") and state in ("cancelled", "standby"):
|
||||
store.ensure_bed_after_print(conn, host, message=filename or state)
|
||||
self._emit(
|
||||
conn,
|
||||
host,
|
||||
name,
|
||||
"abbruch",
|
||||
"%s: Druck abgebrochen (%s)" % (name, filename or "ohne Datei"),
|
||||
filename,
|
||||
run_id or prev.get("run_id"),
|
||||
)
|
||||
try:
|
||||
from grotti import jobs
|
||||
elif state in ("cancelled", "standby"):
|
||||
# Live-Lauf schließen: normaler Abbruch, oder nach Offline/Neustart
|
||||
# (prev wird „offline“, der Übergang paused→standby geht sonst verloren).
|
||||
dangling = store.find_live_run(conn, host)
|
||||
from_active = prev_state in ("printing", "paused")
|
||||
recover = bool(dangling) and (prev_offline or not prev_state)
|
||||
if from_active or recover:
|
||||
file_label = filename or (dangling or {}).get("filename") or state
|
||||
store.set_bed_needs_clear(conn, host, True, file_label)
|
||||
store.abandon_live_runs(conn, host)
|
||||
self._emit(
|
||||
conn,
|
||||
host,
|
||||
name,
|
||||
"abbruch",
|
||||
"%s: Druck abgebrochen (%s)" % (
|
||||
name, file_label if file_label != state else "ohne Datei",
|
||||
),
|
||||
file_label if file_label != state else "",
|
||||
run_id or prev.get("run_id") or (dangling or {}).get("id"),
|
||||
)
|
||||
try:
|
||||
from grotti import jobs
|
||||
|
||||
jobs.sync_history(conn, self.cfg)
|
||||
except Exception as exc:
|
||||
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
||||
jobs.sync_history(conn, self.cfg)
|
||||
except Exception as exc:
|
||||
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
||||
elif state == "error" and prev_state != "error":
|
||||
if prev_state == "printing" or prev_state == "paused":
|
||||
store.ensure_bed_after_print(conn, host, message=filename or "Fehler")
|
||||
store.set_bed_needs_clear(conn, host, True, filename or "Fehler")
|
||||
store.abandon_live_runs(conn, host)
|
||||
msg = (live.get("message") or "").strip()
|
||||
if not msg or msg.lower() in ("error", "fehler"):
|
||||
msg = "Ursache unbekannt — in Mainsail prüfen"
|
||||
@@ -202,7 +224,25 @@ class StateWatcher:
|
||||
if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0:
|
||||
self._cool_heaters(host)
|
||||
elif state == "complete" and (not prev_state or prev_offline):
|
||||
store.ensure_bed_after_print(conn, host, message=filename or "fertig")
|
||||
# Erster Blick nach Neustart: nur mit Endzeit des letzten Laufs,
|
||||
# sonst wird bereits Quittiertes wieder „Platte belegt“.
|
||||
ended = conn.execute(
|
||||
"""
|
||||
SELECT ended_at FROM runs
|
||||
WHERE printer_host = %s
|
||||
AND state IN ('completed', 'cancelled', 'error', 'interrupted')
|
||||
AND ended_at IS NOT NULL
|
||||
ORDER BY ended_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(host,),
|
||||
).fetchone()
|
||||
store.ensure_bed_after_print(
|
||||
conn,
|
||||
host,
|
||||
ended_at=ended["ended_at"] if ended else None,
|
||||
message=filename or "fertig",
|
||||
)
|
||||
|
||||
spool_warn = bool(prev.get("spool_warn"))
|
||||
if state == "printing" and not spool_warn:
|
||||
|
||||
Reference in new issue
Block a user