Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1eb87ba25 | ||
|
|
5166ed6530 | ||
|
|
f034c5e626 |
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -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. |
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user