Grotti 3.6.1: Abgebrochene Läufe bleiben im Protokoll, Spulenwechsel bucht den laufenden Druck nicht vorzeitig ab, und der Pi öffnet große Dateien nicht mehr parallel.

This commit is contained in:
Dominic Gundel committed 2026-11-01 18:56:14 +01:00
1 parent 2b1e36634a
commit 5081212212
23 files changed
+559 -65

No files matched your search

+9
View File
@@ -124,6 +124,14 @@ def _oauth(raw):
}
def _http_threads(raw):
try:
count = int(raw.get("http_threads") or 16)
except (TypeError, ValueError):
count = 16
return max(2, min(32, count))
def _ensure_secret_key(path, raw):
key = str(raw.get("secret_key") or "").strip()
if key:
@@ -176,6 +184,7 @@ def load():
"alerts": _alerts(raw),
"auto_queue": bool(raw.get("auto_queue", True)),
"auto_retry": bool(raw.get("auto_retry", True)),
"http_threads": _http_threads(raw),
"_raw": raw,
"_path": path,
}
+30 -3
View File
@@ -128,7 +128,11 @@ def is_filament_resume(
def undo_deduction(conn, run):
"""Spulenabzug eines Laufs zurücknehmen. Abschnitte bleiben."""
"""Spulenabzug eines Laufs zurücknehmen.
Letzten Abschnitt wieder öffnen — nach Filamentpause schließt ein
vorzeitiger History-Abgleich den Abschnitt, bevor der Druck weiterläuft.
"""
if not run or not run.get("deducted"):
return False
rows = conn.execute(
@@ -147,6 +151,20 @@ def undo_deduction(conn, run):
"UPDATE runs SET deducted = false, filament_cost = 0 WHERE id = %s",
(run["id"],),
)
conn.execute(
"""
UPDATE run_segments
SET mm_end = NULL, closed_at = NULL
WHERE id = (
SELECT id FROM run_segments
WHERE run_id = %s
ORDER BY id DESC
LIMIT 1
)
AND mm_end IS NOT NULL
""",
(run["id"],),
)
return True
@@ -583,11 +601,15 @@ def apply_deduction(conn, run, energy, metadata=None):
final_mm = float(run.get("filament_used_mm") or 0)
total_mm = float(run.get("filament_total_mm") or 0)
deltas = usage.segment_deltas(segments, final_mm)
# Messwert (oder Soll) anteilig nach mm — nicht Soll gegen Soll-Gesamtlänge,
# sonst fehlen Gramm, wenn ein Abschnitt zu früh geschlossen wurde.
base_g = used if used > 0 else planned
split_mm = sum(d for _, _, d in deltas) or final_mm or total_mm
for spool_id, ace_slot, delta_mm in deltas:
spool = store.get_spool_row(conn, spool_id)
if not spool:
continue
grams = usage.grams_from_mm(delta_mm, total_mm, planned)
grams = usage.grams_from_mm(delta_mm, split_mm, base_g)
cost = (grams / 1000.0) * float(spool.get("price_per_kg") or 0)
entries.append({
"spool_id": spool_id,
@@ -798,6 +820,10 @@ def _sync_one_job(conn, cfg, printer, job, watcher_resume=False):
conn, printer["host"], ended_at=ended, message=filename or state
)
store.set_printer_active_run(conn, printer["host"], None)
# Resume nach Filamentpause: History kann den alten Teil schon als „fertig“
# melden. Abzug und Segment-Schluss erst beim echten Druckende.
if watcher_resume:
return 0
if apply_deduction(conn, row, cfg["energy"], metadata=metadata):
return 1
return 0
@@ -835,7 +861,8 @@ def sync_history(conn, cfg, host=None, watcher_resume=False):
deducted += _sync_one_job(
conn, cfg, printer, job, watcher_resume=watcher_resume
)
deducted += deduct_stale_live_runs(conn, cfg)
if not watcher_resume:
deducted += deduct_stale_live_runs(conn, cfg)
finally:
conn.execute("SELECT pg_advisory_unlock(%s)", (SYNC_LOCK_KEY,))
return {"gelesen": len(collected), "abgezogen": deducted}
+23 -8
View File
@@ -46,7 +46,14 @@ def sync_watch(conn, cfg, force=False):
now = time.monotonic()
if not force and _LAST_WATCH_SYNC and now - _LAST_WATCH_SYNC < _WATCH_MIN_INTERVAL_S:
return {"gelesen": 0, "hinweis": "", "skipped": True}
known = {}
if not force:
for row in conn.execute(
"SELECT stored_path, size_bytes, mtime FROM files WHERE source = 'watch'"
).fetchall():
known[row["stored_path"]] = (int(row["size_bytes"] or 0), float(row["mtime"] or 0))
seen = []
gelesen = 0
for path in folder.rglob("*"):
if not path.is_file() or skip_path(path, cfg["data_dir"]):
continue
@@ -54,14 +61,21 @@ def sync_watch(conn, cfg, force=False):
if not (lower.endswith(".gcode") or lower.endswith(".3mf")):
continue
try:
resolved = str(path.resolve())
seen.append(resolved)
if not force:
st = path.stat()
prev = known.get(resolved)
if prev and prev[0] == st.st_size and prev[1] == float(st.st_mtime):
continue
store.upsert_scanned(conn, meta_for(path), force=force)
seen.append(str(path.resolve()))
gelesen += 1
except (OSError, zipfile.BadZipFile):
continue
store.forget_missing_watch(conn, seen)
link_watch_files_to_projects(conn, cfg)
_LAST_WATCH_SYNC = now
return {"gelesen": len(seen), "hinweis": ""}
return {"gelesen": gelesen, "hinweis": ""}
def _apply_meta(conn, file_id, row, meta):
@@ -77,6 +91,7 @@ def _apply_meta(conn, file_id, row, meta):
"kind": meta.get("kind") or row.get("kind"),
"plate": meta.get("plate") or "",
"slice_meta": meta.get("slice_meta") or {},
"ace_trailer": bool(meta.get("ace_trailer")),
},
)
@@ -95,7 +110,7 @@ def refresh_one_file(conn, file_id, watch_dir=None):
row = store.get_file_row(conn, file_id) or row
except (OSError, zipfile.BadZipFile):
pass
return store.file_out(row, watch_dir=watch_dir)
return store.file_out(row, watch_dir=watch_dir, conn=conn)
def filaments_need_paint_index(filaments):
@@ -276,7 +291,7 @@ def move_file(conn, cfg, file_id, folder):
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / src.name
if dest.resolve() == src.resolve() and source == "watch":
return store.file_out(row, watch_dir=root)
return store.file_out(row, watch_dir=root, conn=conn)
if dest.exists() and dest.resolve() != src.resolve():
dest = unique_path(dest_dir, src.name)
try:
@@ -295,7 +310,7 @@ def move_file(conn, cfg, file_id, folder):
)
_link_file_to_project_folder(conn, file_id, str(dest.resolve()), root)
row = store.get_file_row(conn, file_id)
return store.file_out(row, watch_dir=root)
return store.file_out(row, watch_dir=root, conn=conn)
def path_allowed_for_delete(stored_path, cfg):
@@ -365,7 +380,7 @@ def rename_library_file(conn, cfg, file_id, new_name):
raise GrottiError("Ungültiger Dateiname.", "datei")
dest = src.with_name(final)
if dest.resolve() == src.resolve():
return store.file_out(row, watch_dir=cfg.get("watch_dir"))
return store.file_out(row, watch_dir=cfg.get("watch_dir"), conn=conn)
if dest.exists():
raise GrottiError("Unter diesem Namen liegt schon eine Datei.", "datei")
try:
@@ -382,7 +397,7 @@ def rename_library_file(conn, cfg, file_id, new_name):
mtime=stat.st_mtime,
)
row = store.get_file_row(conn, file_id)
return store.file_out(row, watch_dir=cfg.get("watch_dir"))
return store.file_out(row, watch_dir=cfg.get("watch_dir"), conn=conn)
_PRINT_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
@@ -559,7 +574,7 @@ def ensure_library_gcode(conn, cfg, host, filename):
if known:
path = Path(known.get("stored_path") or "")
if path.is_file():
return store.file_out(known)
return store.file_out(known, conn=conn)
from grotti import moonraker
pulled = moonraker.download_gcode(host, base)
+3
View File
@@ -0,0 +1,3 @@
-- ACE-Trailer-Flag einmalig speichern, damit die Dateiliste die Datei nicht erneut öffnet.
ALTER TABLE files
ADD COLUMN IF NOT EXISTS ace_trailer boolean;
+142 -20
View File
@@ -201,14 +201,23 @@ def file_folder(stored_path, source, watch_dir=None):
return "" if parent in (".", "") else parent
def file_out(row, watch_dir=None):
def file_out(row, watch_dir=None, conn=None):
# stored_path bleibt intern (get_file_row / list_file_rows), nicht in der API.
from grotti.parse import path_has_ace_trailer
stored = row.get("stored_path") or ""
ace_trailer = False
if stored:
ace_trailer = path_has_ace_trailer(stored, kind=row.get("kind") or "", plate=row.get("plate") or "")
ace_trailer = row.get("ace_trailer")
if ace_trailer is None and stored:
from grotti.parse import path_has_ace_trailer
ace_trailer = path_has_ace_trailer(
stored, kind=row.get("kind") or "", plate=row.get("plate") or ""
)
if conn is not None and row.get("id") is not None:
conn.execute(
"UPDATE files SET ace_trailer = %s WHERE id = %s",
(bool(ace_trailer), row["id"]),
)
else:
ace_trailer = bool(ace_trailer)
return {
"id": row["id"],
"project_id": row["project_id"],
@@ -482,7 +491,7 @@ def get_project(conn, project_id, watch_dir=None):
file_stats = _file_print_stats(conn, [item["id"] for item in files])
file_outs = []
for item in files:
fo = file_out(item, watch_dir=watch_dir)
fo = file_out(item, watch_dir=watch_dir, conn=conn)
st = file_stats.get(item["id"]) or {"printed": 0, "queued": 0, "ok_once": False}
fo["printed"] = st["printed"]
fo["queued"] = st["queued"]
@@ -625,7 +634,7 @@ def list_files(
sql += " LIMIT %s OFFSET %s"
params.extend([limit, offset])
rows = conn.execute(sql, params).fetchall()
items = [file_out(row, watch_dir=watch_dir) for row in rows]
items = [file_out(row, watch_dir=watch_dir, conn=conn) for row in rows]
return {"items": items, "total": total, "limit": limit, "offset": int(offset or 0)}
@@ -637,11 +646,11 @@ def upsert_scanned(conn, meta, force=False):
statement = """
INSERT INTO files (
name, stored_path, source, kind, printer_family, filaments, weight_g,
estimated_s, thumb_entry, plate, size_bytes, mtime, slice_meta
estimated_s, thumb_entry, plate, size_bytes, mtime, slice_meta, ace_trailer
) VALUES (
%(name)s, %(stored_path)s, 'watch', %(kind)s, %(printer_family)s, %(filaments)s,
%(weight_g)s, %(estimated_s)s, %(thumb_entry)s, %(plate)s, %(size_bytes)s, %(mtime)s,
%(slice_meta)s
%(slice_meta)s, %(ace_trailer)s
)
ON CONFLICT (source, stored_path) DO UPDATE SET
name = EXCLUDED.name,
@@ -654,7 +663,8 @@ def upsert_scanned(conn, meta, force=False):
plate = EXCLUDED.plate,
size_bytes = EXCLUDED.size_bytes,
mtime = EXCLUDED.mtime,
slice_meta = EXCLUDED.slice_meta
slice_meta = EXCLUDED.slice_meta,
ace_trailer = EXCLUDED.ace_trailer
"""
if not force:
statement += """
@@ -664,6 +674,7 @@ def upsert_scanned(conn, meta, force=False):
payload = dict(meta)
payload["filaments"] = Json(meta.get("filaments") or [])
payload["slice_meta"] = Json(meta.get("slice_meta") or {})
payload["ace_trailer"] = bool(meta.get("ace_trailer"))
conn.execute(statement, payload)
@@ -672,11 +683,11 @@ def insert_upload(conn, meta):
"""
INSERT INTO files (
name, stored_path, source, kind, printer_family, filaments, weight_g,
estimated_s, thumb_entry, plate, size_bytes, mtime, project_id, slice_meta
estimated_s, thumb_entry, plate, size_bytes, mtime, project_id, slice_meta, ace_trailer
) VALUES (
%(name)s, %(stored_path)s, 'upload', %(kind)s, %(printer_family)s, %(filaments)s,
%(weight_g)s, %(estimated_s)s, %(thumb_entry)s, %(plate)s, %(size_bytes)s, %(mtime)s,
%(project_id)s, %(slice_meta)s
%(project_id)s, %(slice_meta)s, %(ace_trailer)s
)
RETURNING *
""",
@@ -684,9 +695,10 @@ def insert_upload(conn, meta):
**meta,
"filaments": Json(meta.get("filaments") or []),
"slice_meta": Json(meta.get("slice_meta") or {}),
"ace_trailer": bool(meta.get("ace_trailer")),
},
).fetchone()
return file_out(row)
return file_out(row, conn=conn)
def update_file(conn, file_id, fields, watch_dir=None):
@@ -702,15 +714,18 @@ def update_file(conn, file_id, fields, watch_dir=None):
if "slice_meta" in fields:
sets.append("slice_meta = %s")
values.append(Json(fields.get("slice_meta") or {}))
if "ace_trailer" in fields:
sets.append("ace_trailer = %s")
values.append(bool(fields.get("ace_trailer")))
if not sets:
row = get_file_row(conn, file_id)
return file_out(row, watch_dir=watch_dir) if row else None
return file_out(row, watch_dir=watch_dir, conn=conn) if row else None
values.append(file_id)
row = conn.execute(
"UPDATE files SET %s WHERE id = %%s RETURNING *" % ", ".join(sets),
values,
).fetchone()
return file_out(row, watch_dir=watch_dir) if row else None
return file_out(row, watch_dir=watch_dir, conn=conn) if row else None
def update_file_location(conn, file_id, stored_path, name=None, size_bytes=None, mtime=None, source=None):
@@ -1023,6 +1038,70 @@ def spool_at_slot(conn, host, slot):
).fetchone()
def prev_spool_id_for_slot(conn, host, slot, live_run, exclude_spool_id, old_rows=None):
"""Vorige Spule für Segment 0→jetzt: Platz → ACE-Map/Send → Abschnitt → Lauf."""
try:
exclude = int(exclude_spool_id)
except (TypeError, ValueError):
return None
for row in old_rows or []:
try:
sid = int(row["id"])
except (TypeError, ValueError, KeyError):
continue
if sid != exclude:
return sid
filename = (live_run or {}).get("filename") or ""
sent = latest_send(conn, host, filename) if host and filename else None
if sent:
from grotti import acm
mapping = acm.normalize_mapping(sent.get("ace_map") or [])
slot_ok = False
if slot is None:
slot_ok = not mapping
elif mapping:
try:
ams = int(slot) - 1
slot_ok = any(int(item["ams_index"]) == ams for item in mapping)
except (TypeError, ValueError):
slot_ok = False
if slot_ok:
hint = sent.get("spool_id")
try:
hint = int(hint) if hint is not None else None
except (TypeError, ValueError):
hint = None
if hint and hint != exclude:
return hint
run_id = (live_run or {}).get("id")
if run_id:
for seg in reversed(list_run_segments(conn, run_id)):
try:
seg_slot = seg.get("ace_slot")
if slot is None:
if seg_slot is not None:
continue
elif seg_slot is None or int(seg_slot) != int(slot):
continue
sid = int(seg["spool_id"])
except (TypeError, ValueError, KeyError):
continue
if sid != exclude:
return sid
hint = (live_run or {}).get("spool_id")
try:
hint = int(hint) if hint is not None else None
except (TypeError, ValueError):
hint = None
if hint and hint != exclude:
return hint
return None
def file_by_name(conn, filename):
base = (filename or "").replace("\\", "/").rsplit("/", 1)[-1]
if not base:
@@ -1981,8 +2060,30 @@ def find_open_history_run(conn, host, filename=""):
).fetchone()
def latest_run_id(conn, host, filename):
"""Neueste Lauf-ID dieser Datei auf dem Drucker."""
host = (host or "").strip()
filename = (filename or "").strip()
if not host or not filename:
return None
row = conn.execute(
"""
SELECT id FROM runs
WHERE printer_host = %s AND filename = %s
ORDER BY id DESC
LIMIT 1
""",
(host, filename),
).fetchone()
return row["id"] if row else None
def find_live_run(conn, host, filename=""):
"""Offener Live-Lauf für diesen Drucker (Watcher)."""
"""Offener Live-Lauf für diesen Drucker (Watcher).
Mit Dateiname nur derselbe Lauf oder eine noch namenlose Hülle.
Ein anderer Dateiname ist ein anderer Druck und wird nicht geliefert.
"""
if filename:
row = conn.execute(
"""
@@ -1998,6 +2099,18 @@ def find_live_run(conn, host, filename=""):
).fetchone()
if row:
return row
return conn.execute(
"""
SELECT * FROM runs
WHERE printer_host = %s
AND moonraker_job_id LIKE 'live:%%'
AND state IN ('in_progress', 'printing', 'paused')
AND COALESCE(filename, '') = ''
ORDER BY id DESC
LIMIT 1
""",
(host,),
).fetchone()
return conn.execute(
"""
SELECT * FROM runs
@@ -2046,13 +2159,18 @@ def ensure_live_run(conn, host, printer_name, filename, spool_id=None):
return hist
row = find_live_run(conn, host, filename)
if row:
if filename and row.get("filename") != filename:
existing_name = (row.get("filename") or "").strip()
# Anderer Dateiname ist ein anderer Druck — nicht umbenennen.
if filename and existing_name and existing_name != filename:
row = None
elif filename and row.get("filename") != filename:
conn.execute(
"UPDATE runs SET filename = %s WHERE id = %s",
(filename, row["id"]),
)
row = get_run_row(conn, row["id"])
if spool_id and row and row.get("spool_id") != spool_id:
if row:
if spool_id and row.get("spool_id") != spool_id:
conn.execute("UPDATE runs SET spool_id = %s WHERE id = %s", (spool_id, row["id"]))
row = get_run_row(conn, row["id"])
return row
@@ -2174,6 +2292,10 @@ def adopt_live_run(conn, host, filename, real_run_id):
real = get_run_row(conn, real_run_id)
if not real:
return
live_name = (live.get("filename") or "").strip()
want = (filename or real.get("filename") or "").strip()
if live_name and want and live_name != want:
return
live_start = live.get("started_at")
real_end = real.get("ended_at")
# Gleiche Datei, neuer Druck: Live nicht an schon beendeten History-Job hängen.
@@ -2387,7 +2509,7 @@ def list_run_segments(conn, run_id):
FROM run_segments seg
JOIN spools s ON s.id = seg.spool_id
WHERE seg.run_id = %s
ORDER BY seg.id
ORDER BY seg.mm_start, seg.id
""",
(run_id,),
).fetchall()
+8
View File
@@ -1,6 +1,7 @@
"""G-code zu einem dünnen 3D-Pfad für den Viewer."""
import re
import threading
import zipfile
from pathlib import Path
@@ -10,6 +11,8 @@ AXIS = re.compile(r"([XYZE])\s*(-?\d*\.?\d+)", re.IGNORECASE)
# Cache-/Viewer-Version: ganze Z-Höhen dicht behalten (keine gesprenkelten Stichproben)
TOOLPATH_VERSION = 8
# Die volle Bahn liegt kurz im Speicher. Parallel auf einem Pi mit 2 GB füllt das den Swap.
_build_lock = threading.Lock()
def _gcode_text(path: Path) -> str:
@@ -111,6 +114,11 @@ def _thin_points(points, layer_breaks, max_points):
def build_toolpath(path: Path, max_points=220000):
with _build_lock:
return _build_toolpath(path, max_points)
def _build_toolpath(path: Path, max_points=220000):
text = _gcode_text(path)
if not text:
return {
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
__version__ = "3.6.0"
__version__ = "3.6.1"
+46 -7
View File
@@ -133,6 +133,14 @@ class StateWatcher:
file_changed = False
if prev_offline and state in ("printing", "paused"):
file_changed = self._release_replaced_run(conn, host, name, filename)
elif state == "printing" and filename and prev_state in (
"printing", "paused", "cancelled", "standby",
):
# Abbruch verpasst oder sofort neu gestartet: alte Datei nicht umbenennen.
existing = store.find_live_run(conn, host)
old_name = (existing or {}).get("filename") or ""
if old_name and old_name != filename:
file_changed = self._release_replaced_run(conn, host, name, filename)
starting = (
(
bool(prev_state)
@@ -256,16 +264,21 @@ class StateWatcher:
store.set_bed_needs_clear(conn, host, True, file_label)
store.abandon_live_runs(conn, host)
store.set_printer_active_run(conn, host, None)
emit_id = run_id or prev.get("run_id") or (dangling or {}).get("id")
label = file_label if file_label != state else ""
if emit_id and not store.get_run_row(conn, emit_id):
# History-Abgleich hat den Live-Lauf übernommen und gelöscht.
emit_id = store.latest_run_id(conn, host, label)
self._emit(
conn,
host,
name,
"abbruch",
"%s: Druck abgebrochen (%s)" % (
name, file_label if file_label != state else "ohne Datei",
name, label or "ohne Datei",
),
file_label if file_label != state else "",
run_id or prev.get("run_id") or (dangling or {}).get("id"),
label,
emit_id,
)
# prev-Zustand behalten, bis die Hits reichen.
if hits < 3 and from_active:
@@ -351,12 +364,21 @@ class StateWatcher:
return False
if contact_gap_keeps_run(existing.get("filename") or "", filename, "printing"):
return False
label = existing.get("filename") or "ohne Datei"
old_id = existing["id"]
try:
jobs.sync_history(conn, self.cfg)
jobs.sync_history(conn, self.cfg, host=host)
except Exception as exc:
log.warning("Watcher Abgleich nach Offline %s: %s", host, exc)
existing = store.find_live_run(conn, host)
if not existing:
# History hat den Live-Lauf übernommen. Ereignis an den überlebenden Lauf.
emit_id = old_id if store.get_run_row(conn, old_id) else store.latest_run_id(conn, host, label)
self._emit(
conn, host, name, "abbruch",
"%s: Druck unterbrochen (%s)" % (name, label),
label, emit_id,
)
return True
if contact_gap_keeps_run(existing.get("filename") or "", filename, "printing"):
return False
@@ -488,7 +510,11 @@ class StateWatcher:
})
def _ensure_start_segment(self, conn, host, filename, live, run_id):
"""Nur externe Einzelspule: ein Abschnitt. ACE-Farben ohne Wechsel → Anteil-Split."""
"""Startabschnitte: externe Spule oder ACE-Plätze mit eingelegter Spule.
Ohne Rollenwechsel bleibt ACE-Abzug Anteil-Split (ein Segment je Platz).
Mit Wechsel greift die mm-Aufteilung innerhalb der Farbe (0→jetzt).
"""
if not run_id:
return
existing = store.list_run_segments(conn, run_id)
@@ -499,9 +525,18 @@ class StateWatcher:
from grotti import acm
mapping = acm.normalize_mapping(ace) if ace else []
if mapping:
return
used_mm = usage.as_float(live.get("filament_used"))
if mapping:
seen = set()
for item in mapping:
slot = int(item["ams_index"]) + 1
if slot in seen:
continue
seen.add(slot)
row = store.spool_at_slot(conn, host, slot)
if row:
store.open_segment(conn, run_id, row["id"], slot, used_mm)
return
rows = conn.execute(
"""
SELECT id FROM spools
@@ -552,6 +587,10 @@ class StateWatcher:
return True
def _emit(self, conn, host, printer, kind, message, filename, run_id=None):
# Eine gelöschte Lauf-ID (Live wurde gerade übernommen) darf den Tick
# nicht am Fremdschlüssel zurückrollen — sonst ist der Abbruch weg.
if run_id is not None and not store.get_run_row(conn, run_id):
run_id = None
notify.record(
conn, self.cfg, host, printer, kind, message, filename,
dedupe_s=60, run_id=run_id,