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:
1 parent
2b1e36634a
commit
5081212212
23 files changed
+559
-65
No files matched your search
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user