Grotti 3.7.0: Treiber für Kobra 4 und Marlin, smartere Dateien-Auswahl und einstellbare Etiketten.

Senden und Abbruch laufen über den Treiber, Orca-Doppelzeilen und tote Ideen-API fallen weg, Drucker folgen der Datei, Etikettenformat liegt in den Einstellungen.
This commit is contained in:
Dominic Gundel committed 2026-11-02 14:03:57 +01:00
1 parent 5081212212
commit db6a85a703
54 files changed
+3379 -384

No files matched your search

+77 -51
View File
@@ -40,10 +40,13 @@ DOCS_DIR = TOOL_ROOT / "docs"
PROJECT_STATUS = {"idee", "bereit", "druckt", "fertig"}
VERDICTS = {"", "gut", "ausschuss"}
VERDICT_REASONS = sorted(grotti_verdict.VERDICT_REASONS)
IDEA_STATUS = {"offen", "spaeter", "erledigt"}
PRINTER_FAMILIES = set(FAMILY_LABELS) | {""}
UPLOAD_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
HOST_RE = re.compile(r"^[A-Za-z0-9.\-]+(?::\d{1,5})?$")
SERIAL_RE = re.compile(
r"^(?:COM\d+|/dev/tty(?:USB|ACM)\d+|/dev/serial/by-id/[A-Za-z0-9._\-]+)(?:@\d{3,7})?$",
re.IGNORECASE,
)
TOKEN_QUERY_PATHS = (
"/api/v1/kamera",
"/api/v1/kamera/stream",
@@ -59,13 +62,11 @@ STEER_ACTIONS = {
def validate_printer_host(host):
host = (host or "").strip()
if not host or not HOST_RE.fullmatch(host):
if not host or ".." in host or not (HOST_RE.fullmatch(host) or SERIAL_RE.fullmatch(host)):
raise GrottiError(
"Host nur als Hostname oder IP, optional mit Port (z. B. 10.10.1.60).",
"Host als IP, Hostname, COM-Port (COM5 oder COM5@250000) oder /dev/tty…",
"host",
)
if ".." in host:
raise GrottiError("Ungültiger Host.", "host")
return host
@@ -732,6 +733,7 @@ def create_app(cfg):
token="",
spool=spool,
qr_url=qr_url,
etikett=cfg.get("etikett") or grotti_config._etikett({}),
)
@app.get("/protokoll")
@@ -792,6 +794,7 @@ def create_app(cfg):
sheets=sheets,
spools=spools,
target_g=target_g,
etikett=cfg.get("etikett") or grotti_config._etikett({}),
)
@app.get("/ideen")
@@ -879,6 +882,7 @@ def create_app(cfg):
auto_retry = data.get("auto_retry") if "auto_retry" in data else None
fqdn = data.get("fqdn") if "fqdn" in data else None
watch_dir = data.get("watch_dir") if "watch_dir" in data else None
etikett = data.get("etikett") if isinstance(data.get("etikett"), dict) else None
return jsonify(grotti_config.save_settings(
cfg,
energy=energy,
@@ -889,6 +893,7 @@ def create_app(cfg):
auto_retry=auto_retry,
fqdn=fqdn,
watch_dir=watch_dir,
etikett=etikett,
))
@app.post("/api/v1/telegram/test")
@@ -931,8 +936,12 @@ def create_app(cfg):
by_id = {row["host"]: row for row in rows}
if request.args.get("live", "1") == "0":
from grotti.drivers import for_printer
for row in rows:
row["mainsail"] = "http://%s/" % row["host"]
if for_printer(row).transport == "moonraker":
row["mainsail"] = "http://%s/" % row["host"]
else:
row["mainsail"] = ""
attach(row)
return jsonify(rows)
# Eine Live-Quelle: Watcher. Frischer als 60 s, sonst einmal selbst abfragen.
@@ -940,7 +949,8 @@ def create_app(cfg):
cached = watcher.by_host()
age = watcher.age_s()
if not cached or age is None or age > 60:
lives = moonraker.snapshot(rows)
from grotti.drivers import snapshot
lives = snapshot(rows)
else:
lives = []
for row in rows:
@@ -955,7 +965,11 @@ def create_app(cfg):
item = dict(row)
item["ok"] = False
item["state"] = "offline"
item["mainsail"] = "http://%s/" % row["host"]
from grotti.drivers import for_printer as _for_printer
if _for_printer(row).transport == "moonraker":
item["mainsail"] = "http://%s/" % row["host"]
else:
item["mainsail"] = ""
lives.append(item)
for live in lives:
meta = by_id.get(live.get("host") or "") or {}
@@ -963,6 +977,53 @@ def create_app(cfg):
attach(live)
return jsonify(lives)
@app.get("/api/v1/marlin-dienst")
def marlin_dienst():
from grotti.marlin_supervisor import current_status, ensure_if_enabled
ensure_if_enabled()
return jsonify(current_status())
@app.post("/api/v1/meldungen")
def meldung_create():
from grotti import meldung as grotti_meldung
require_role("operator")
data = body()
try:
row = grotti_meldung.create(
conn(),
cfg,
text=data.get("text") or "",
page=data.get("page") or "",
printer_host=data.get("printer_host") or "",
error_text=data.get("error_text") or "",
user=g.user,
)
conn().commit()
except ValueError as exc:
raise GrottiError(str(exc), "meldung") from exc
return jsonify(row), 201
@app.get("/api/v1/meldungen")
def meldung_list():
from grotti import meldung as grotti_meldung
require_role("admin")
status = (request.args.get("status") or "").strip() or None
return jsonify(grotti_meldung.list_rows(conn(), status=status))
@app.post("/api/v1/meldungen/<int:meldung_id>/erledigt")
def meldung_close(meldung_id):
from grotti import meldung as grotti_meldung
require_role("admin")
row = grotti_meldung.close(conn(), meldung_id)
if not row:
raise GrottiError("Die Meldung fehlt.", "meldung", 404)
conn().commit()
return jsonify(row)
@app.post("/api/v1/drucker")
def printer_create():
data = body()
@@ -1555,6 +1616,7 @@ def create_app(cfg):
optional_id(data.get("spool_id")),
ace_maps,
bed_leveling=bool(data.get("bed_leveling")),
marlin_mode=(data.get("marlin_mode") or "stream"),
)
return jsonify({"jobs": created, "count": len(created), "mode": "now"}), 202
@@ -1973,8 +2035,13 @@ def create_app(cfg):
# laufenden Druck auf dem Host best-effort stoppen
if was_running and row.get("host"):
try:
moonraker.command(row["host"], "abbrechen")
stopped = ", Druck gestoppt"
from grotti.drivers import for_printer
printer = store.printer_by_host(conn(), row["host"])
if printer:
for_printer(printer).command(row["host"], "abbrechen")
stopped = ", Druck gestoppt"
else:
stopped = ", Druck nicht gestoppt"
except Exception:
stopped = ", Druck nicht gestoppt"
store.add_event(
@@ -2142,47 +2209,6 @@ def create_app(cfg):
def color_list_api():
return jsonify(color_catalog())
@app.get("/api/v1/ideen")
def idea_list():
return jsonify(store.list_ideas(conn()))
@app.post("/api/v1/ideen")
def idea_create():
data = body()
title = (data.get("title") or "").strip()
if not title:
raise GrottiError("Die Idee braucht einen Titel.", "titel")
status = data.get("status") or "offen"
if status not in IDEA_STATUS:
raise GrottiError("Unbekannter Status.", "status")
return jsonify(store.create_idea(conn(), title, (data.get("body") or "").strip(), status)), 201
@app.patch("/api/v1/ideen/<int:idea_id>")
def idea_patch(idea_id):
data = body()
fields = {}
if "title" in data:
title = (data.get("title") or "").strip()
if not title:
raise GrottiError("Die Idee braucht einen Titel.", "titel")
fields["title"] = title
if "body" in data:
fields["body"] = (data.get("body") or "").strip()
if "status" in data:
if data.get("status") not in IDEA_STATUS:
raise GrottiError("Unbekannter Status.", "status")
fields["status"] = data.get("status")
row = store.update_idea(conn(), idea_id, fields)
if not row:
raise GrottiError("Die Idee fehlt.", "idee", 404)
return jsonify(row)
@app.delete("/api/v1/ideen/<int:idea_id>")
def idea_delete(idea_id):
if not store.delete_idea(conn(), idea_id):
raise GrottiError("Die Idee fehlt.", "idee", 404)
return "", 204
@app.get("/api/v1/kunden")
def kunden_list():
return jsonify(grotti_orders.list_customers(conn()))