3 Commits
15 changed files with 169 additions and 94 deletions

No files matched your search

+21
View File
@@ -4,6 +4,27 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
## [Unreleased] ## [Unreleased]
## [3.5.2] – 2026-10-21
### Geändert
- **Wenig Rest:** ACE oder Spule unter dem Dateigewicht warnt unter Dateien, bricht Start und Warteschlange nicht mehr ab. Falsche Sorte bleibt ein Wartegrund.
### Behoben
- **Queue „Nochmal — Start abgebrochen (Neustart)“ während der Drucker anläuft:** Nach dem Upload bleibt der Auftrag in `await_print`, bis Moonraker druckt. Die 45-Sekunden-Regel gilt nur noch, wenn der Upload-Thread weg ist. Wand und Seitenleiste zeigen „startet“ und dass es etwa eine Minute dauern kann.
## [3.5.1] – 2026-10-20
### Geändert
- **MCP:** Server-Anweisung für Clients (Ablauf, confirm, Filament-Pause, Platten-Quittung, kein G-code), damit der Agent die Tools in der richtigen Reihenfolge nutzt.
- **Wand:** „Aus der Warteschlange“-Strip nur noch bei Ablauf (mit Schritten); einfacher Queue-Druck zeigt nur DRUCKT.
### Behoben
- **Queue zu früh „Fertig.“:** Nach Start bleibt Phase `starting`, bis Moonraker `printing` meldet — alter `complete`/`standby` schließt den Job nicht mehr.
## [3.5.0] – 2026-10-19 ## [3.5.0] – 2026-10-19
### Neu ### Neu
+1 -1
View File
@@ -9,7 +9,7 @@
</p> </p>
<p align="center"> <p align="center">
<a href="https://git.gundel.dev/dg/grotti"><img src="https://img.shields.io/badge/Version-3.5.0-e85d04?style=flat-square" alt="Version 3.5.0"></a> <a href="https://git.gundel.dev/dg/grotti"><img src="https://img.shields.io/badge/Version-3.5.2-e85d04?style=flat-square" alt="Version 3.5.2"></a>
<img src="https://img.shields.io/badge/Python-3.11+-3776AB?style=flat-square&logo=python&logoColor=white" alt="Python 3.11+"> <img src="https://img.shields.io/badge/Python-3.11+-3776AB?style=flat-square&logo=python&logoColor=white" alt="Python 3.11+">
<img src="https://img.shields.io/badge/PostgreSQL-14+-4169E1?style=flat-square&logo=postgresql&logoColor=white" alt="PostgreSQL"> <img src="https://img.shields.io/badge/PostgreSQL-14+-4169E1?style=flat-square&logo=postgresql&logoColor=white" alt="PostgreSQL">
<img src="https://img.shields.io/badge/Moonraker-Klipper-2ecc71?style=flat-square" alt="Moonraker"> <img src="https://img.shields.io/badge/Moonraker-Klipper-2ecc71?style=flat-square" alt="Moonraker">
+2 -2
View File
@@ -1,6 +1,6 @@
# ACE und Spulen # ACE und Spulen
Stand **18.10.2026**. Mehrfarb-Drucke brauchen vor dem Start eine Zuordnung Datei-Farbe → ACE-Platz und eine passende Spule am Drucker. Stand **21.10.2026**. Mehrfarb-Drucke brauchen vor dem Start eine Zuordnung Datei-Farbe → ACE-Platz und eine passende Spule am Drucker.
![ACE-Zuordnung beim Senden](../web/static/doku/ace-zuordnung.png) ![ACE-Zuordnung beim Senden](../web/static/doku/ace-zuordnung.png)
@@ -44,7 +44,7 @@ Nach einem Grotti-Update mit ACE-Farben: unter Dateien **neu lesen**, damit alte
| „T… fehlt in der Map“ | Datei neu lesen, Zuordnung erneut setzen (Slicer-T ≠ ACE-Platz) | | „T… fehlt in der Map“ | Datei neu lesen, Zuordnung erneut setzen (Slicer-T ≠ ACE-Platz) |
| **invalid filament id** (10011700) | Alte Grotti-Version ohne T-Verdichtung, oder Map nicht neu gesetzt. Grotti neu starten, Datei neu lesen, nochmal senden. | | **invalid filament id** (10011700) | Alte Grotti-Version ohne T-Verdichtung, oder Map nicht neu gesetzt. Grotti neu starten, Datei neu lesen, nochmal senden. |
| Material-Warnung | Eingelegte Sorte ≠ Datei. Der Start geht trotzdem. PA12-CF steht in Grotti so, an den ACE geht PA6-CF. | | Material-Warnung | Eingelegte Sorte ≠ Datei. Der Start geht trotzdem. PA12-CF steht in Grotti so, an den ACE geht PA6-CF. |
| Rest zu gering | Andere Spule oder leichteres Teil | | Rest zu gering | Warnung unter Dateien („nur … g, Datei braucht … g“). Der Start geht trotzdem; leer pausiert der Drucker. |
| ACE blinkt nicht / kein Wechsel | Spule und Platz prüfen, ggf. Druck neu starten | | ACE blinkt nicht / kein Wechsel | Spule und Platz prüfen, ggf. Druck neu starten |
| Drucker hängt kurz nach Multi-Color-Start | Oft falsche Map (T10 gemappt als Platz 0). Neu lesen + Zuordnung. | | Drucker hängt kurz nach Multi-Color-Start | Oft falsche Map (T10 gemappt als Platz 0). Neu lesen + Zuordnung. |
+3 -1
View File
@@ -1,6 +1,8 @@
# MCP-Server # MCP-Server
Stand **15.10.2026**. Agenten steuern Grotti nur über die HTTP-API mit einem API-Token. Stand **19.10.2026**. Agenten steuern Grotti nur über die HTTP-API mit einem API-Token.
Beim Connect bekommt der Client die Server-Anweisung aus `grotti/mcp_server.py` (`INSTRUCTIONS`): Deutsch antworten, Host und Datei-id nachschlagen, erst lesen dann schreiben, `confirm=true` nur auf klaren Wunsch, Warteschlange vor Sofortstart, Filament-Pause nicht blind fortsetzen, `clear_bed` ist nur eine Quittung, kein G-code und kein Firmware-Restart.
## Start ## Start
+5 -1
View File
@@ -255,13 +255,17 @@ def build(conn, watcher_latest, printers=None, send_jobs=None):
live_by_host[host]["display_step_index"] = job.get("display_step_index") live_by_host[host]["display_step_index"] = job.get("display_step_index")
live_by_host[host]["step_index"] = job.get("step_index") live_by_host[host]["step_index"] = job.get("step_index")
else: else:
phase = (job.get("phase") or "").lower()
starting = phase in ("starting", "await_print")
running.append({ running.append({
"host": host, "host": host,
"name": job.get("printer_name") or host, "name": job.get("printer_name") or host,
"state": "running", "state": "starting" if starting else "running",
"starting": starting,
"progress": job.get("progress") or 0, "progress": job.get("progress") or 0,
"remain": job.get("remain") or 0, "remain": job.get("remain") or 0,
"filename": job.get("filename") or "", "filename": job.get("filename") or "",
"message": job.get("message") or "",
"queue_id": job.get("id"), "queue_id": job.get("id"),
"is_ablauf": bool(job.get("is_ablauf")), "is_ablauf": bool(job.get("is_ablauf")),
}) })
+2 -38
View File
@@ -415,44 +415,8 @@ def sync_history(conn, cfg):
def assert_filament_enough(conn, file_row, spool_id=None, ace_map=None, host=""): def assert_filament_enough(conn, file_row, spool_id=None, ace_map=None, host=""):
"""Dateien-Gewicht gegen Spule oder ACE-Plätze prüfen.""" """Zu wenig Rest bricht den Start nicht ab. Die Oberfläche warnt vorher."""
need = float((file_row or {}).get("weight_g") or 0) return
if need <= 0:
return
mapping = acm.normalize_mapping(ace_map)
if mapping and host:
leftovers = []
for item in mapping:
slot = int(item["ams_index"]) + 1
row = conn.execute(
"""
SELECT remaining_g, name FROM spools
WHERE printer_host = %s AND ace_slot = %s
ORDER BY id LIMIT 1
""",
(host, slot),
).fetchone()
if row:
leftovers.append(float(row["remaining_g"] or 0))
if leftovers and sum(leftovers) + 0.5 < need:
raise GrottiError(
"ACE-Plätze haben nur %.0f g, Datei braucht %.0f g."
% (sum(leftovers), need),
"spule",
)
return
if not spool_id:
return
spool = store.get_spool_row(conn, spool_id)
if not spool:
return
left = float(spool["remaining_g"] or 0)
if left + 0.5 < need:
raise GrottiError(
"Spule „%s“ hat nur %.0f g, Datei braucht %.0f g."
% (spool["name"], left, need),
"spule",
)
def prepare_send(conn, file_id, host, confirm, override, spool_id, ace_map=None): def prepare_send(conn, file_id, host, confirm, override, spool_id, ace_map=None):
+52 -13
View File
@@ -57,6 +57,51 @@ def _write_guard():
raise RuntimeError("MCP läuft im Read-only-Modus (GROTTI_MCP_READ_ONLY).") raise RuntimeError("MCP läuft im Read-only-Modus (GROTTI_MCP_READ_ONLY).")
# Wird beim Connect an den Client gegeben (Claude, Cursor, …).
INSTRUCTIONS = """\
Grotti steuert eine 3D-Drucker-Farm (Anycubic Kobra, Klipper/Moonraker).
Antworte dem Nutzer auf Deutsch. Handle nur über diese Tools.
Identität
- Drucker über das Feld host (IP). Den Namen nur zur Anzeige nutzen. Hosts nie raten: erst list_printers oder get_printer.
- Dateien über die numerische id aus list_files (Feld id), nicht über den Dateinamen allein.
- Vor jedem Schreibzugriff den aktuellen Stand lesen.
Lesen
- Farm: list_printers (live=true). Ein Drucker: get_printer(host).
- Läuft: list_running. Warteschlange: list_queue. Ereignisse: list_events.
- Bibliothek: list_files. Spulen: list_spools. Filament-Fehlmenge: bedarf_lesen.
- Kundenaufträge: auftraege_lesen. Verschleißteile: wartung_lesen.
Zustand (Feld state)
- printing: druckt. paused: Pause. standby: frei. complete: fertig, Platte oft noch belegt.
- error: Fehler, nicht einfach neu starten. offline: nicht erreichbar.
- progress ist 0…1. filament_empty oder pause_reason bei Pause heißt Filament leer.
Dann dem Nutzer sagen, die neue Spule in Grotti dem Drucker zuzuweisen, und erst danach resume_printer.
Eine Filament-Pause nicht blind fortsetzen.
Schreiben
confirm=true nur setzen, wenn der Nutzer diese Aktion in diesem Zug klar will. Nicht vorauseilend bestätigen.
- enqueue_when_free: Datei in die Warteschlange. host leer = Grotti wählt den nächsten Drucker mit passender Sorte. host gesetzt = dieser Drucker, sobald er frei ist und die Platte klar ist. Das ist der normale Weg.
- enqueue_print: sofort senden und starten. Nur wenn der Nutzer einen Sofortstart auf einem genannten freien Drucker will (state standby, Platte frei). Große Dateien brauchen oft etwa eine Minute bis zum Start.
- Den Material-Match macht Grotti (PLA Meta ist nicht Matte und nicht Silk). Eine falsche Maschine nicht erzwingen. Eine Ablehnung wörtlich weitergeben.
- bed_leveling=true nur auf ausdrücklichen Wunsch (Bettnetz / Auto-Level).
- pause_printer und resume_printer während des Drucks, ohne confirm.
- cancel_printer bricht den Druck ab und braucht confirm=true. Bei Unklarheit erst nachfragen.
- clear_bed ist nur die Quittung „Platte ist leer“ und schiebt nichts physisch. Erst aufrufen, wenn der Nutzer sagt, das Teil ist herunter. Ohne diese Quittung startet der nächste „wenn frei“-Job nicht.
- steer_temp (Düse und Bett in °C), steer_speed (Prozent), steer_fan (0–100): nur diese Werte. Kein freier G-code.
Grenzen
- Keine G-code-Konsole, kein Firmware-Restart, kein E-Stop, keine Benutzer- oder Token-Verwaltung. Diese Lücken nicht umgehen.
- Im Read-only-Modus (GROTTI_MCP_READ_ONLY) schlagen Schreib-Tools fehl. Das sagen und nicht wiederholen.
- Gesperrte Drucker und ausgeschaltete Automatik starten keine Warteschlangen-Jobs. Den Fehler nennen.
Antworten
- Kurz und konkret: Druckername, host, Datei, Zustand, was du getan hast.
- Den Fehlertext von Grotti zitieren. Keinen Workaround außerhalb der Tools erfinden.
"""
def main(): def main():
try: try:
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
@@ -67,23 +112,17 @@ def main():
) )
sys.exit(1) sys.exit(1)
server = FastMCP( server = FastMCP("grotti", instructions=INSTRUCTIONS)
"grotti",
instructions=(
"Grotti steuert die 3D-Drucker-Farm. Destruktive Aktionen brauchen "
"confirm=true. Kein freier G-code, kein Firmware-Restart."
),
)
@server.tool() @server.tool()
def list_printers(live: bool = True) -> str: def list_printers(live: bool = True) -> str:
"""Druckerliste mit optionalem Live-Zustand.""" """Alle Drucker. live=true liefert Zustand, Temps, Fortschritt, Dateiname. Host ist die IP."""
q = "?live=1" if live else "?live=0" q = "?live=1" if live else "?live=0"
return json.dumps(_api("GET", "/drucker" + q), ensure_ascii=False, indent=2) return json.dumps(_api("GET", "/drucker" + q), ensure_ascii=False, indent=2)
@server.tool() @server.tool()
def get_printer(host: str) -> str: def get_printer(host: str) -> str:
"""Einen Drucker anhand Host/IP aus der Live-Liste.""" """Einen Drucker anhand host (IP) aus der Live-Liste. Host vorher über list_printers holen."""
rows = _api("GET", "/drucker?live=1") or [] rows = _api("GET", "/drucker?live=1") or []
for row in rows: for row in rows:
if row.get("host") == host: if row.get("host") == host:
@@ -115,7 +154,7 @@ def main():
@server.tool() @server.tool()
def list_files(limit: int = 50) -> str: def list_files(limit: int = 50) -> str:
"""Dateien in der Bibliothek.""" """Dateien in der Bibliothek. Die numerische id ist die file_id fürs Einreihen und Starten."""
rows = _api("GET", "/dateien") or [] rows = _api("GET", "/dateien") or []
return json.dumps(rows[: max(1, min(limit, 200))], ensure_ascii=False, indent=2) return json.dumps(rows[: max(1, min(limit, 200))], ensure_ascii=False, indent=2)
@@ -181,7 +220,7 @@ def main():
@server.tool() @server.tool()
def clear_bed(host: str, confirm: bool = False) -> str: def clear_bed(host: str, confirm: bool = False) -> str:
"""Platte als geräumt quittieren (kein physisches Schieben).""" """Quittung, dass der Nutzer die Platte geleert hat. Schiebt nichts. confirm muss true sein. Danach kann die Warteschlange den nächsten Job starten."""
_write_guard() _write_guard()
if not confirm: if not confirm:
raise RuntimeError("Quittierung braucht confirm=true.") raise RuntimeError("Quittierung braucht confirm=true.")
@@ -201,7 +240,7 @@ def main():
confirm: bool = False, confirm: bool = False,
bed_leveling: bool = False, bed_leveling: bool = False,
) -> str: ) -> str:
"""Datei in die Warteschlange („wenn frei“). confirm muss true sein.""" """Datei in die Warteschlange. host leer: Grotti wählt den nächsten Drucker mit passender Sorte. host gesetzt: dieser Drucker, sobald frei und Platte klar. confirm muss true sein. Normaler Weg zum Drucken."""
_write_guard() _write_guard()
if not confirm: if not confirm:
raise RuntimeError("Einreihen braucht confirm=true.") raise RuntimeError("Einreihen braucht confirm=true.")
@@ -220,7 +259,7 @@ def main():
confirm: bool = False, confirm: bool = False,
bed_leveling: bool = False, bed_leveling: bool = False,
) -> str: ) -> str:
"""Sofort senden und starten. confirm muss true sein.""" """Sofort an diesen host senden und starten. Nur bei freiem Drucker (standby, Platte frei) und klarem Sofortstart-Wunsch. confirm muss true sein."""
_write_guard() _write_guard()
if not confirm: if not confirm:
raise RuntimeError("Start braucht confirm=true.") raise RuntimeError("Start braucht confirm=true.")
+25 -6
View File
@@ -30,7 +30,9 @@ BUSY = {"printing", "paused", "heating", "error"}
KEEPALIVE_S = 120 KEEPALIVE_S = 120
# Nach Start ohne print_active: Upload/MQTT/LeviQ brauchen Zeit, aber nicht ewig # Nach Start ohne print_active: Upload/MQTT/LeviQ brauchen Zeit, aber nicht ewig
PRINT_START_TIMEOUT_S = 600 PRINT_START_TIMEOUT_S = 600
# Einfacher Queue-Start ohne Worker (typisch Grotti-Neustart) # Upload-Worker weg (typisch Grotti-Neustart mitten im Hochladen).
# Nach erfolgreichem Senden ist die Phase await_print — dort gilt das nicht,
# sonst wird ein noch anlaufender Druck als „Neustart“ neu eingereiht.
SIMPLE_START_STUCK_S = 45 SIMPLE_START_STUCK_S = 45
@@ -421,16 +423,18 @@ class PrintQueueDispatcher:
from grotti import orders from grotti import orders
orders.attach_order_item_to_live_run(wconn, host, name, oid) orders.attach_order_item_to_live_run(wconn, host, name, oid)
# Nicht sofort „done“: sonst greift der nächste Wartende, während # await_print: Datei ist raus, Drucker meldet printing oft erst
# der Drucker noch druckt (Live-Cache kann kurz FREE bleiben). # nach einer Minute. Nicht bei starting lassen — sonst gilt nach
# 45 s der Neustart-Retry und der zweite Start trifft „druckt schon“.
store.update_print_queue( store.update_print_queue(
wconn, wconn,
item_id, item_id,
{ {
"status": "running", "status": "running",
"message": "Druckt auf %s: %s" % (printer_copy["name"], name), "message": "Datei ist auf %s. Druckstart kann etwa eine Minute dauern."
% printer_copy["name"],
"host": host, "host": host,
"phase": "print_active", "phase": "await_print",
"phase_started_at": "now", "phase_started_at": "now",
}, },
) )
@@ -487,7 +491,7 @@ class PrintQueueDispatcher:
"""Einfach-Druck: running bleiben bis Moonraker fertig/abgebrochen.""" """Einfach-Druck: running bleiben bis Moonraker fertig/abgebrochen."""
phase = (item.get("phase") or "").lower() phase = (item.get("phase") or "").lower()
state = (live.get("state") or "").lower() state = (live.get("state") or "").lower()
if phase == "starting": if phase in ("starting", "await_print"):
if state in ("printing", "paused", "heating") or live.get("print_active"): if state in ("printing", "paused", "heating") or live.get("print_active"):
store.update_print_queue( store.update_print_queue(
conn, conn,
@@ -498,6 +502,21 @@ class PrintQueueDispatcher:
"message": "Druckt…", "message": "Druckt…",
}, },
) )
return
if phase == "await_print" and self._print_start_timed_out(item):
store.update_print_queue(
conn,
item["id"],
{
"status": "error",
"message": "Druckstart-Timeout: Drucker hat nach %d min nicht angefangen."
% (PRINT_START_TIMEOUT_S // 60),
"phase": "",
"phase_started_at": None,
},
)
return
# complete/standby vom vorherigen Auftrag: noch warten, nicht „Fertig.“
return return
if phase != "print_active": if phase != "print_active":
return return
+4 -11
View File
@@ -2858,7 +2858,10 @@ def _match_spools_for_need(need, have, used_ids=None):
def material_fit_score(conn, host, file_id): def material_fit_score(conn, host, file_id):
"""Höher = besser. -1 = warten (falsche Sorte mit Profil, oder zu wenig Rest).""" """Höher = besser. -1 = warten (falsche Sorte mit Profil).
Zu wenig Rest blockiert nicht; genug Rest gibt Zusatzpunkte.
"""
need, weight = _file_needs(conn, file_id) need, weight = _file_needs(conn, file_id)
have = _spool_material_set(conn, host) have = _spool_material_set(conn, host)
if not need: if not need:
@@ -2883,11 +2886,6 @@ def material_fit_score(conn, host, file_id):
score += 5 score += 5
if weight and best["remaining_g"] + 1e-6 >= weight: if weight and best["remaining_g"] + 1e-6 >= weight:
score += 5 score += 5
elif weight and all(h["remaining_g"] + 1e-6 < weight for h in matched):
return -1, "braucht %s (%s g fehlen)" % (
_need_label(n),
int(max(0, weight - max(h["remaining_g"] for h in matched))),
)
return score, "" return score, ""
have_types = {h["material_type"] for h in have if h["material_type"]} have_types = {h["material_type"] for h in have if h["material_type"]}
@@ -2900,11 +2898,6 @@ def material_fit_score(conn, host, file_id):
score += 5 score += 5
if weight and any(h["remaining_g"] + 1e-6 >= weight for h in matched): if weight and any(h["remaining_g"] + 1e-6 >= weight for h in matched):
score += 5 score += 5
elif weight and all(h["remaining_g"] + 1e-6 < weight for h in matched):
return -1, "braucht %s (%s g fehlen)" % (
_need_label(n),
int(max(0, weight - max(h["remaining_g"] for h in matched))),
)
return score, "" return score, ""
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen.""" """Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
__version__ = "3.5.0" __version__ = "3.5.2"
+1 -1
View File
@@ -4,7 +4,7 @@ const CSRF = document.querySelector('meta[name="csrf"]')?.content || "";
const STATE_LABELS = { const STATE_LABELS = {
printing: "druckt", paused: "pausiert", complete: "fertig", standby: "bereit", printing: "druckt", paused: "pausiert", complete: "fertig", standby: "bereit",
heating: "heizt", error: "Fehler", cancelled: "abgebrochen", offline: "offline", heating: "heizt", error: "Fehler", cancelled: "abgebrochen", offline: "offline",
waiting: "wartet", running: "läuft", waiting: "wartet", running: "läuft", starting: "startet",
}; };
function escapeHtml(value) { function escapeHtml(value) {
+1 -1
View File
@@ -49,7 +49,7 @@
<dt>Auftrag</dt><dd>Ein Eintrag in der Warteschlange: eine Datei für einen Drucker oder für den nächsten freien.</dd> <dt>Auftrag</dt><dd>Ein Eintrag in der Warteschlange: eine Datei für einen Drucker oder für den nächsten freien.</dd>
<dt>Ablauf</dt><dd>Auftrag mit mehreren Schritten und Wiederholungen. Nur Kobra 3.</dd> <dt>Ablauf</dt><dd>Auftrag mit mehreren Schritten und Wiederholungen. Nur Kobra 3.</dd>
<dt>Dieser Drucker</dt><dd>Frei und Platte klar → sofort; sonst Warteschlange für genau diesen Drucker.</dd> <dt>Dieser Drucker</dt><dd>Frei und Platte klar → sofort; sonst Warteschlange für genau diesen Drucker.</dd>
<dt>Nächster passender</dt><dd>Grotti claimt den nächsten freien Drucker mit passender Katalog-Sorte und Restfilament.</dd> <dt>Nächster passender</dt><dd>Grotti claimt den nächsten freien Drucker mit passender Katalog-Sorte. Zu wenig Rest warnt nur und hält den Start nicht auf.</dd>
<dt>Platte belegt</dt><dd>Nach jedem Druck gesetzt. Blockiert den nächsten Auftrag auf diesem Drucker.</dd> <dt>Platte belegt</dt><dd>Nach jedem Druck gesetzt. Blockiert den nächsten Auftrag auf diesem Drucker.</dd>
<dt>Alert</dt><dd>Wichtiges Ereignis (Fertig, Fehler, Abbruch, offline). Steht in der Seitenleiste und geht auf Wunsch an Telegram.</dd> <dt>Alert</dt><dd>Wichtiges Ereignis (Fertig, Fehler, Abbruch, offline). Steht in der Seitenleiste und geht auf Wunsch an Telegram.</dd>
<dt>Ereignis</dt><dd>Jeder Eintrag unter Aktivität. Ablauf-Schritte und Starts sind Ereignisse, aber keine Alerts.</dd> <dt>Ereignis</dt><dd>Jeder Eintrag unter Aktivität. Ablauf-Schritte und Starts sind Ereignisse, aber keine Alerts.</dd>
+6 -4
View File
@@ -160,7 +160,7 @@
{% endif %} {% endif %}
<form method="dialog"><button class="ghost" value="cancel">Schließen</button></form> <form method="dialog"><button class="ghost" value="cancel">Schließen</button></form>
</dialog> </dialog>
<script src="{{ url_for('static', filename='api.js') }}?v=32"></script> <script src="{{ url_for('static', filename='api.js') }}?v=33"></script>
<script src="{{ url_for('static', filename='ui.js') }}?v=6"></script> <script src="{{ url_for('static', filename='ui.js') }}?v=6"></script>
<script> <script>
async function doLogout() { async function doLogout() {
@@ -315,9 +315,9 @@ function runLineHtml({ name, progress, remain, filename, tipExtra, cancelId, hos
const pct = Math.max(0, Math.min(100, Math.round((Number(progress) || 0) * 100))); const pct = Math.max(0, Math.min(100, Math.round((Number(progress) || 0) * 100)));
const finish = remain > 0 ? finishClock(remain) : ""; const finish = remain > 0 ? finishClock(remain) : "";
const bits = [name]; const bits = [name];
if (pct > 0 || remain > 0) bits.push(pct + " %");
if (finish) bits.push(finish);
if (tipExtra) bits.push(tipExtra); if (tipExtra) bits.push(tipExtra);
else if (pct > 0 || remain > 0) bits.push(pct + " %");
if (finish) bits.push(finish);
const tip = [ const tip = [
filename ? jobLabel(filename) : "", filename ? jobLabel(filename) : "",
remain > 0 ? "noch " + hours(remain) : "", remain > 0 ? "noch " + hours(remain) : "",
@@ -375,9 +375,10 @@ function renderLiveChips({ actionItems, running, activeSends, alertCount, etaTex
const pct = Math.max(0, Math.min(100, Math.round((Number(p.progress) || 0) * 100))); const pct = Math.max(0, Math.min(100, Math.round((Number(p.progress) || 0) * 100)));
const finish = p.remain > 0 ? finishClock(p.remain) : ""; const finish = p.remain > 0 ? finishClock(p.remain) : "";
const pausedFil = p.state === "paused" && (p.filament_empty || p.pause_reason); const pausedFil = p.state === "paused" && (p.filament_empty || p.pause_reason);
const starting = p.starting || p.state === "starting";
const label = [ const label = [
shortPrinter(p.name || p.host), shortPrinter(p.name || p.host),
pausedFil ? "Material!" : (pct ? pct + "%" : ""), pausedFil ? "Material!" : (starting ? "startet" : (pct ? pct + "%" : "")),
finish, finish,
].filter(Boolean).join(" · "); ].filter(Boolean).join(" · ");
const tip = [p.name || "", p.pause_reason || ""].filter(Boolean).join(" — "); const tip = [p.name || "", p.pause_reason || ""].filter(Boolean).join(" — ");
@@ -479,6 +480,7 @@ async function refreshJetzt() {
progress: p.progress, progress: p.progress,
remain: p.remain, remain: p.remain,
filename: p.filename, filename: p.filename,
tipExtra: (p.starting || p.state === "starting") ? "wird gestartet" : "",
cancelId: p.queue_id, cancelId: p.queue_id,
})).join(""), })).join(""),
); );
+40 -12
View File
@@ -322,13 +322,22 @@ function actionsHtml(state, printer) {
function flowHtml(printer) { function flowHtml(printer) {
const job = printer.queue_running; const job = printer.queue_running;
const bits = []; const bits = [];
if (job) { const pending = startingJob(printer);
if (pending) {
const name = jobName(pending.filename || "") || "Datei";
bits.push(`<div class="strip flow">
<div class="flow-head">${icon("play")}<span class="grow">Startet ${escapeHtml(name)}</span></div>
<div class="flow-msg">Die Datei ist auf dem Drucker. Bis der Druck läuft, kann es etwa eine Minute dauern — der Auftrag bleibt hier.</div>
</div>`);
}
// Nur Ablauf braucht den Strip (Schritte). Einfacher Queue-Druck: Karte zeigt schon DRUCKT.
if (job && job.is_ablauf) {
const rep = repeatText(job); const rep = repeatText(job);
const title = job.is_ablauf ? "Ablauf" + (rep ? " " + rep : "") : "Aus der Warteschlange"; const title = "Ablauf" + (rep ? " " + rep : "");
bits.push(`<div class="strip flow"> bits.push(`<div class="strip flow">
<div class="flow-head">${icon("queue")}<span class="grow">${escapeHtml(title)}</span> <div class="flow-head">${icon("queue")}<span class="grow">${escapeHtml(title)}</span>
<button type="button" class="icon-btn danger" data-cancel-run="${job.id}" title="Ablauf abbrechen">${icon("x")}</button></div> <button type="button" class="icon-btn danger" data-cancel-run="${job.id}" title="Ablauf abbrechen">${icon("x")}</button></div>
${job.is_ablauf ? stepsHtml(job.step_labels, job.display_step_index != null ? job.display_step_index : job.step_index) : ""} ${stepsHtml(job.step_labels, job.display_step_index != null ? job.display_step_index : job.step_index)}
${job.message ? `<div class="flow-msg">${escapeHtml(phaseText(job))}</div>` : ""} ${job.message ? `<div class="flow-msg">${escapeHtml(phaseText(job))}</div>` : ""}
</div>`); </div>`);
} }
@@ -510,6 +519,16 @@ function steerPanelHtml(printer) {
</div>`; </div>`;
} }
function startingJob(printer) {
const job = printer && printer.queue_running;
if (!job || job.is_ablauf) return null;
const phase = String(job.phase || "").toLowerCase();
if (phase !== "starting" && phase !== "await_print") return null;
const state = shownState(printer);
if (state === "printing" || state === "paused" || state === "heating") return null;
return job;
}
function heroHtml(printer, state) { function heroHtml(printer, state) {
const remain = hours(printer.remain); const remain = hours(printer.remain);
const eta = finishClock(printer.remain); const eta = finishClock(printer.remain);
@@ -531,6 +550,11 @@ function heroHtml(printer, state) {
if (state === "offline") { if (state === "offline") {
return `<div class="hero idle"><span class="hero-main">nicht erreichbar</span></div>`; return `<div class="hero idle"><span class="hero-main">nicht erreichbar</span></div>`;
} }
const pending = startingJob(printer);
if (pending) {
const name = jobName(pending.filename || "") || "Datei";
return `<div class="hero"><span class="hero-main"><small>startet</small>${escapeHtml(name)}</span><span class="hero-side">kann eine Minute dauern</span></div>`;
}
if (state === "complete") { if (state === "complete") {
const took = hours(printer.elapsed); const took = hours(printer.elapsed);
const last = jobLabel(printer.filename || ""); const last = jobLabel(printer.filename || "");
@@ -540,7 +564,7 @@ function heroHtml(printer, state) {
if (state === "cancelled") { if (state === "cancelled") {
return `<div class="hero idle"><span class="hero-main">abgebrochen</span></div>`; return `<div class="hero idle"><span class="hero-main">abgebrochen</span></div>`;
} }
const busyQueue = printer.queue_running ? "Ablauf läuft" : ""; const busyQueue = printer.queue_running && printer.queue_running.is_ablauf ? "Ablauf läuft" : "";
return `<div class="hero idle"><span class="hero-main">${needsClear(printer) ? "Platte belegt" : "bereit"}</span>${busyQueue ? `<span class="hero-side">${busyQueue}</span>` : ""}</div>`; return `<div class="hero idle"><span class="hero-main">${needsClear(printer) ? "Platte belegt" : "bereit"}</span>${busyQueue ? `<span class="hero-side">${busyQueue}</span>` : ""}</div>`;
} }
@@ -808,13 +832,15 @@ function patchCard(article, printer) {
else runningJobs.delete(printer.host); else runningJobs.delete(printer.host);
const role = (name) => article.querySelector(`[data-role=${name}]`); const role = (name) => article.querySelector(`[data-role=${name}]`);
setHtml(role("chip"), chipHtml(state, labels[state])); const pendingStart = startingJob(printer);
setHtml(role("chip"), chipHtml(pendingStart ? "heating" : state, pendingStart ? "startet" : labels[state]));
setHtml(role("hero"), heroHtml(printer, state)); setHtml(role("hero"), heroHtml(printer, state));
patchBar(role("bar"), printer, state); patchBar(role("bar"), printer, state);
setHtml(role("stats"), statsHtml(printer, state)); setHtml(role("stats"), statsHtml(printer, state));
setHtml(role("temps"), tempbarsHtml(printer, state)); setHtml(role("temps"), tempbarsHtml(printer, state));
const jobline = role("jobline"); const jobline = role("jobline");
const jobVisible = jobLineVisible(printer, state, shownFile); const startName = pendingStart ? (pendingStart.filename || "") : "";
const jobVisible = jobLineVisible(printer, state, shownFile || startName) || !!pendingStart;
const hasThumb = !!(jobVisible && printer.preview); const hasThumb = !!(jobVisible && printer.preview);
if (jobline) { if (jobline) {
jobline.hidden = !jobVisible; jobline.hidden = !jobVisible;
@@ -846,8 +872,8 @@ function patchCard(article, printer) {
} }
const job = role("job"); const job = role("job");
if (job) { if (job) {
setText(job, jobName(shownFile) || ""); setText(job, jobName(shownFile || startName) || "");
job.title = shownFile || ""; job.title = shownFile || startName || "";
} }
setHtml(role("swatches"), swatchesHtml(printer)); setHtml(role("swatches"), swatchesHtml(printer));
setHtml(role("flow"), flowHtml(printer)); setHtml(role("flow"), flowHtml(printer));
@@ -975,7 +1001,8 @@ function patchDesk(printer) {
const nameEl = role("name"); const nameEl = role("name");
if (nameEl) nameEl.textContent = printer.name || printer.host; if (nameEl) nameEl.textContent = printer.name || printer.host;
setHtml(role("badge"), printer.family_label ? `<span class="badge">${escapeHtml(printer.family_label)}</span>` : ""); setHtml(role("badge"), printer.family_label ? `<span class="badge">${escapeHtml(printer.family_label)}</span>` : "");
setHtml(role("chip"), chipHtml(state, labels[state])); const pendingStart = startingJob(printer);
setHtml(role("chip"), chipHtml(pendingStart ? "heating" : state, pendingStart ? "startet" : labels[state]));
setHtml(role("pills"), pillsHtml(printer.host)); setHtml(role("pills"), pillsHtml(printer.host));
const mainsail = role("mainsail"); const mainsail = role("mainsail");
if (mainsail) { if (mainsail) {
@@ -988,12 +1015,13 @@ function patchDesk(printer) {
setHtml(role("temps"), tempbarsHtml(printer, state)); setHtml(role("temps"), tempbarsHtml(printer, state));
const jobline = role("jobline"); const jobline = role("jobline");
const jobVisible = jobLineVisible(printer, state, shownFile); const startName = pendingStart ? (pendingStart.filename || "") : "";
const jobVisible = jobLineVisible(printer, state, shownFile || startName) || !!pendingStart;
if (jobline) jobline.hidden = !jobVisible && !printer.preview; if (jobline) jobline.hidden = !jobVisible && !printer.preview;
const job = role("job"); const job = role("job");
if (job) { if (job) {
setText(job, jobName(shownFile) || (jobVisible ? "" : "Kein Auftrag")); setText(job, jobName(shownFile || startName) || (jobVisible ? "" : "Kein Auftrag"));
job.title = shownFile || ""; job.title = shownFile || startName || "";
} }
const preview = role("preview"); const preview = role("preview");
if (preview) { if (preview) {
+5 -2
View File
@@ -136,9 +136,12 @@ function render() {
} else if (st === "waiting" || st === "running") { } else if (st === "waiting" || st === "running") {
tools.push(`<button type="button" class="icon-btn danger" data-cancel="${job.id}" title="Abbrechen">${icon("x")}</button>`); tools.push(`<button type="button" class="icon-btn danger" data-cancel="${job.id}" title="Abbrechen">${icon("x")}</button>`);
} }
const chipState = st === "running" ? "printing" : st === "waiting" ? "waiting" : st === "error" ? "error" : st === "done" ? "done" : st === "cancelled" || st === "dismissed" ? "dismissed" : "complete"; const phase = String(job.phase || "").toLowerCase();
const starting = st === "running" && (phase === "starting" || phase === "await_print");
const chipState = starting ? "heating" : st === "running" ? "printing" : st === "waiting" ? "waiting" : st === "error" ? "error" : st === "done" ? "done" : st === "cancelled" || st === "dismissed" ? "dismissed" : "complete";
const chipLabel = starting ? "startet" : (STATUS_LABEL[st] || st);
return `<tr data-id="${job.id}"> return `<tr data-id="${job.id}">
<td>${chipHtml(chipState, STATUS_LABEL[st] || st)}</td> <td>${chipHtml(chipState, chipLabel)}</td>
<td><span class="cell-file" title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</span>${rep ? " · " + escapeHtml(rep) : ""}</td> <td><span class="cell-file" title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</span>${rep ? " · " + escapeHtml(rep) : ""}</td>
<td>${job.host ? `<a href="/#${encodeURIComponent(job.host)}">${escapeHtml(targetOf(job))}</a>` : escapeHtml(targetOf(job))}</td> <td>${job.host ? `<a href="/#${encodeURIComponent(job.host)}">${escapeHtml(targetOf(job))}</a>` : escapeHtml(targetOf(job))}</td>
<td>${steps || "—"}</td> <td>${steps || "—"}</td>