Files
grotti/grotti/jetzt.py
T
Dominic Gundel 4ddffbbeb1 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.
2026-10-18 14:58:32 +02:00

290 lines
10 KiB
Python

"""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),
}