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:
Dominic Gundel committed 2026-10-18 14:58:32 +02:00
1 parent 6e59df22e5
commit 4ddffbbeb1
68 files changed
+4348 -1215

No files matched your search

+9 -1
View File
@@ -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"},
+8
View File
@@ -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
View File
@@ -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
),
}
+150
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+3
View File
@@ -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 '';
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"],
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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: