Grotti 3.3.0: Projekte mit Ordner, Datei-Löschen/Verschieben, Material-Archiv, UI-Polish.
This commit is contained in:
1 parent
4ddffbbeb1
commit
67a89d1794
45 files changed
+2856
-320
No files matched your search
+171
-2
@@ -73,13 +73,182 @@ def normalize_mapping(raw):
|
||||
ams = int(item.get("ams_index"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if paint < 0 or ams < 0 or ams > 7 or paint in seen:
|
||||
# paint_index = Slicer-T (oft 0–15 bei Bambu), ams_index = ACE-Platz 0–7
|
||||
if paint < 0 or paint > 63 or ams < 0 or ams > 7 or paint in seen:
|
||||
continue
|
||||
seen.add(paint)
|
||||
out.append({"paint_index": paint, "ams_index": ams})
|
||||
return out
|
||||
|
||||
|
||||
def filament_for_paint(filaments, paint):
|
||||
"""Filament-Eintrag zur Slicer-T-Nummer; Fallback Array-Index für Alt-Daten."""
|
||||
filaments = filaments or []
|
||||
try:
|
||||
paint = int(paint)
|
||||
except (TypeError, ValueError):
|
||||
return {}
|
||||
for index, row in enumerate(filaments):
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
try:
|
||||
key = int(row.get("paint_index"))
|
||||
except (TypeError, ValueError):
|
||||
key = index
|
||||
if key == paint:
|
||||
return row
|
||||
if 0 <= paint < len(filaments) and isinstance(filaments[paint], dict):
|
||||
return filaments[paint]
|
||||
return {}
|
||||
|
||||
|
||||
def densify_mapping(mapping):
|
||||
"""Slicer-T mit Lücken (T1/T10) → dichte 0…n−1 für gklib/ACE.
|
||||
|
||||
gklib meldet bei zu hoher Filament-ID ``invalid filament id`` (10011700).
|
||||
Rückgabe: (remap alt→neu, dichte Map). Identity → ({}, unverändert).
|
||||
"""
|
||||
mapping = normalize_mapping(mapping)
|
||||
paints = sorted(item["paint_index"] for item in mapping)
|
||||
if not paints or paints == list(range(len(paints))):
|
||||
return {}, mapping
|
||||
remap = {old: new for new, old in enumerate(paints)}
|
||||
dense = [
|
||||
{"paint_index": remap[item["paint_index"]], "ams_index": item["ams_index"]}
|
||||
for item in mapping
|
||||
]
|
||||
return remap, dense
|
||||
|
||||
|
||||
def remap_filaments(filaments, remap):
|
||||
"""paint_index der Filament-Metadaten mit densify-Remap mitziehen."""
|
||||
if not remap:
|
||||
return list(filaments or [])
|
||||
out = []
|
||||
for row in filaments or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
copy = dict(row)
|
||||
try:
|
||||
old = int(row.get("paint_index"))
|
||||
except (TypeError, ValueError):
|
||||
out.append(copy)
|
||||
continue
|
||||
if old in remap:
|
||||
copy["paint_index"] = remap[old]
|
||||
out.append(copy)
|
||||
return out
|
||||
|
||||
|
||||
def align_tools_for_ace(content, mapping, filaments=None):
|
||||
"""G-code-T und ACE-Map auf dichte 0…n−1 bringen.
|
||||
|
||||
Maßgeblich sind die ``Tn`` im G-code. Die UI-Map darf echte Slicer-Indizes
|
||||
(T1/T10) oder fälschlich kompakte 0/1 haben — ACE-Plätze werden in
|
||||
Sortierreihenfolge der Map-Einträge den sortierten T-Nummern zugeordnet.
|
||||
Sonst: ACM 0/1 + G-code T10 → 10011700 invalid filament id.
|
||||
"""
|
||||
from grotti.parse import tools_used_in_gcode
|
||||
|
||||
mapping = normalize_mapping(mapping)
|
||||
used = sorted(tools_used_in_gcode(content))
|
||||
if not mapping:
|
||||
return content, mapping, list(filaments or []), {}
|
||||
if not used:
|
||||
remap, dense = densify_mapping(mapping)
|
||||
new_content = rewrite_gcode_tools(content, remap) if remap else content
|
||||
return new_content, dense, remap_filaments(filaments, remap), remap
|
||||
|
||||
if len(mapping) != len(used):
|
||||
from grotti.errors import GrottiError
|
||||
|
||||
raise GrottiError(
|
||||
"ACE-Zuordnung (%d Farben) passt nicht zum G-code (T%s). "
|
||||
"Datei neu lesen und Zuordnung erneut setzen."
|
||||
% (len(mapping), ", T".join(str(t) for t in used)),
|
||||
"ace",
|
||||
)
|
||||
|
||||
mapped_paints = {item["paint_index"] for item in mapping}
|
||||
paints_match_gcode = mapped_paints == set(used)
|
||||
if paints_match_gcode:
|
||||
ams_by_tool = {item["paint_index"]: item["ams_index"] for item in mapping}
|
||||
ams_order = [ams_by_tool[tool] for tool in used]
|
||||
else:
|
||||
# UI kompakt 0…n−1, G-code sparse — Reihenfolge der Map = Reihenfolge der Farben
|
||||
ordered = sorted(mapping, key=lambda item: item["paint_index"])
|
||||
ams_order = [item["ams_index"] for item in ordered]
|
||||
|
||||
remap = {old: new for new, old in enumerate(used)}
|
||||
dense = [
|
||||
{"paint_index": index, "ams_index": ams_order[index]}
|
||||
for index in range(len(used))
|
||||
]
|
||||
|
||||
source = list(filaments or [])
|
||||
if paints_match_gcode:
|
||||
new_fils = remap_filaments(source, remap)
|
||||
else:
|
||||
# Metadaten folgen der Map-Reihenfolge (0,1,…), nicht den G-code-T
|
||||
ordered_paints = sorted(item["paint_index"] for item in mapping)
|
||||
new_fils = []
|
||||
for index, tool in enumerate(used):
|
||||
row = filament_for_paint(source, ordered_paints[index]) or (
|
||||
source[index] if index < len(source) and isinstance(source[index], dict) else {}
|
||||
)
|
||||
if not row:
|
||||
row = filament_for_paint(source, tool) or {}
|
||||
copy = dict(row) if isinstance(row, dict) else {}
|
||||
copy["paint_index"] = index
|
||||
new_fils.append(copy)
|
||||
|
||||
# Identity-Remap weglassen (schon T0…n−1)
|
||||
if used == list(range(len(used))):
|
||||
return content, dense, new_fils, {}
|
||||
new_content = rewrite_gcode_tools(content, remap)
|
||||
return new_content, dense, new_fils, remap
|
||||
|
||||
|
||||
_TOOL_NUM_RE = re.compile(
|
||||
rb"^(?P<prefix>(?:MMU_CHANGE_TOOL(?:_STANDALONE)?[^\n]*?\bTOOL=)|T)(?P<num>\d{1,2})\b",
|
||||
re.I,
|
||||
)
|
||||
_REF_TOOLS_RE = re.compile(rb"^(;\s*referenced_tools\s*=\s*)(.+?)\s*$", re.I)
|
||||
|
||||
|
||||
def rewrite_gcode_tools(content, remap):
|
||||
"""Tn / TOOL=n / referenced_tools laut Remap umschreiben (T10 vor T1)."""
|
||||
if not content or not remap:
|
||||
return content
|
||||
text = content if isinstance(content, (bytes, bytearray)) else str(content).encode("utf-8", "replace")
|
||||
lines = text.splitlines(keepends=True)
|
||||
out = []
|
||||
for line in lines:
|
||||
stripped = line[:-2] if line.endswith(b"\r\n") else (line[:-1] if line.endswith(b"\n") else line)
|
||||
ending = line[len(stripped) :]
|
||||
ref = _REF_TOOLS_RE.match(stripped)
|
||||
if ref:
|
||||
parts = []
|
||||
for bit in ref.group(2).split(b","):
|
||||
raw = bit.strip()
|
||||
try:
|
||||
number = int(raw)
|
||||
except ValueError:
|
||||
parts.append(raw)
|
||||
continue
|
||||
parts.append(str(remap.get(number, number)).encode("ascii"))
|
||||
out.append(ref.group(1) + b",".join(parts) + ending)
|
||||
continue
|
||||
match = _TOOL_NUM_RE.match(stripped)
|
||||
if match:
|
||||
old = int(match.group("num"))
|
||||
if old in remap:
|
||||
new = str(remap[old]).encode("ascii")
|
||||
stripped = stripped[: match.start("num")] + new + stripped[match.end("num") :]
|
||||
out.append(stripped + ending)
|
||||
return b"".join(out)
|
||||
|
||||
|
||||
def build_acm(filaments, mapping, slots):
|
||||
filaments = filaments or []
|
||||
slots = slots or []
|
||||
@@ -88,7 +257,7 @@ def build_acm(filaments, mapping, slots):
|
||||
for item in normalize_mapping(mapping):
|
||||
paint = item["paint_index"]
|
||||
ams = item["ams_index"]
|
||||
filament = filaments[paint] if 0 <= paint < len(filaments) else {}
|
||||
filament = filament_for_paint(filaments, paint)
|
||||
slot = slot_by_index.get(ams) or {}
|
||||
paint_color = color_channels(filament.get("color")) or [0, 0, 0]
|
||||
ams_color = color_channels(slot.get("color")) or paint_color
|
||||
|
||||
+53
-13
@@ -57,25 +57,47 @@ def unix_to_dt(value):
|
||||
|
||||
def choose_spool(conn, host, material, hinted_id, ended_at=None):
|
||||
"""Spule für History-Abzug. Ohne Hint nur Rollen, die vor Druckende existierten."""
|
||||
at_host = [
|
||||
row for row in store.spools_for_printer(conn, host)
|
||||
if _spool_valid_for_end(row, ended_at)
|
||||
]
|
||||
if hinted_id:
|
||||
row = store.get_spool_row(conn, hinted_id)
|
||||
if row and not _spool_valid_for_end(row, ended_at):
|
||||
return None
|
||||
# Hint nur nutzen, wenn die Rolle noch am Drucker liegt — sonst Rollenwechsel
|
||||
if row and (row.get("printer_host") or "") == (host or ""):
|
||||
return row
|
||||
if row and not at_host:
|
||||
# Rolle schon im Regal, kein Nachfolger am Host → Hint behalten (alte Jobs)
|
||||
return row
|
||||
if len(at_host) == 1:
|
||||
return at_host[0]
|
||||
if material:
|
||||
same = [row for row in at_host if (row["material_type"] or "").lower() == material.lower()]
|
||||
if len(same) == 1:
|
||||
return same[0]
|
||||
if hinted_id:
|
||||
row = store.get_spool_row(conn, hinted_id)
|
||||
if row and _spool_valid_for_end(row, ended_at):
|
||||
return row
|
||||
if row and not _spool_valid_for_end(row, ended_at):
|
||||
return None
|
||||
rows = [
|
||||
row for row in store.spools_for_printer(conn, host)
|
||||
if _spool_valid_for_end(row, ended_at)
|
||||
]
|
||||
if len(rows) == 1:
|
||||
return rows[0]
|
||||
if material:
|
||||
same = [row for row in rows if (row["material_type"] or "").lower() == material.lower()]
|
||||
if len(same) == 1:
|
||||
return same[0]
|
||||
return None
|
||||
|
||||
|
||||
def resolve_tracking_spool(conn, host, hinted_id, ace_map=None):
|
||||
"""Spule für Live-Tracking: am Host liegende Rolle schlägt veralteten Send-Hint."""
|
||||
current = resolve_spools_at_host(conn, host, ace_map=ace_map)
|
||||
if len(current) == 1:
|
||||
return current[0]["id"]
|
||||
if hinted_id:
|
||||
row = store.get_spool_row(conn, hinted_id)
|
||||
if row and (row.get("printer_host") or "") == (host or ""):
|
||||
return hinted_id
|
||||
if current:
|
||||
return current[0]["id"]
|
||||
return hinted_id
|
||||
|
||||
|
||||
def _spool_valid_for_end(spool, ended_at):
|
||||
"""Verhindert Abzug alter Moonraker-Jobs auf neu eingelegte Spulen."""
|
||||
if ended_at is None or not spool:
|
||||
@@ -544,12 +566,30 @@ def send_file(
|
||||
)
|
||||
# CJK/Unicode im Namen → Rinkhals 10115 not found (Upload ≠ Start-Name)
|
||||
filename = moonraker.safe_upload_name(filename)
|
||||
ace_tools = [item.get("paint_index") for item in mapping] if mapping else None
|
||||
if (row.get("kind") or "") == "gcode" and str(filename).endswith(".gcode"):
|
||||
if slots and mapping:
|
||||
# Immer an G-code-Tn ausrichten (auch wenn UI fälschlich 0/1 schickt)
|
||||
content, mapping, filaments, remap = acm.align_tools_for_ace(
|
||||
content, mapping, filaments
|
||||
)
|
||||
if remap:
|
||||
progress(
|
||||
"ace",
|
||||
32,
|
||||
"Slicer-T verdichtet (%s)…"
|
||||
% ", ".join("T%s→T%s" % pair for pair in sorted(remap.items())),
|
||||
)
|
||||
ace_tools = [item.get("paint_index") for item in mapping] if mapping else None
|
||||
content = prepare_rinkhals_gcode(content, tools=ace_tools)
|
||||
if keep_motors:
|
||||
# Slice-Ende `M84` → sofort unhomed; Abräumen braucht die Stepper.
|
||||
content = eject.strip_end_motor_disable(content)
|
||||
else:
|
||||
if slots and mapping:
|
||||
_, mapping, filaments, _ = acm.align_tools_for_ace(
|
||||
b"", mapping, filaments
|
||||
)
|
||||
ace_tools = [item.get("paint_index") for item in mapping] if mapping else None
|
||||
size_mb = len(content) / (1024 * 1024)
|
||||
|
||||
def upload_progress(sent, total):
|
||||
|
||||
+259
-22
@@ -59,10 +59,55 @@ def sync_watch(conn, cfg, force=False):
|
||||
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": ""}
|
||||
|
||||
|
||||
def _apply_meta(conn, file_id, row, meta):
|
||||
store.update_file(
|
||||
conn,
|
||||
file_id,
|
||||
{
|
||||
"printer_family": meta.get("printer_family") or row.get("printer_family"),
|
||||
"filaments": meta.get("filaments") or [],
|
||||
"weight_g": meta.get("weight_g") or 0,
|
||||
"estimated_s": meta.get("estimated_s") or 0,
|
||||
"thumb_entry": meta.get("thumb_entry") or "",
|
||||
"kind": meta.get("kind") or row.get("kind"),
|
||||
"plate": meta.get("plate") or "",
|
||||
"slice_meta": meta.get("slice_meta") or {},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def refresh_one_file(conn, file_id, watch_dir=None):
|
||||
"""Eine Datei neu parsen (paint_index / ACE). None wenn nicht gefunden."""
|
||||
row = store.get_file_row(conn, file_id)
|
||||
if not row:
|
||||
return None
|
||||
stored = row.get("stored_path") or ""
|
||||
path = Path(stored) if stored else None
|
||||
if path is not None and path.is_file():
|
||||
try:
|
||||
meta = meta_for(path)
|
||||
_apply_meta(conn, file_id, row, meta)
|
||||
row = store.get_file_row(conn, file_id) or row
|
||||
except (OSError, zipfile.BadZipFile):
|
||||
pass
|
||||
return store.file_out(row, watch_dir=watch_dir)
|
||||
|
||||
|
||||
def filaments_need_paint_index(filaments):
|
||||
rows = filaments if isinstance(filaments, list) else []
|
||||
if len(rows) < 2:
|
||||
return False
|
||||
for item in rows:
|
||||
if not isinstance(item, dict) or "paint_index" not in item:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def refresh_file_meta(conn, force=False):
|
||||
"""Metadaten bestehender Dateien neu lesen (z. B. paint_info → filaments)."""
|
||||
if not force:
|
||||
@@ -80,20 +125,7 @@ def refresh_file_meta(conn, force=False):
|
||||
meta = meta_for(path)
|
||||
except (OSError, zipfile.BadZipFile):
|
||||
continue
|
||||
store.update_file(
|
||||
conn,
|
||||
row["id"],
|
||||
{
|
||||
"printer_family": meta.get("printer_family") or row.get("printer_family"),
|
||||
"filaments": meta.get("filaments") or [],
|
||||
"weight_g": meta.get("weight_g") or 0,
|
||||
"estimated_s": meta.get("estimated_s") or 0,
|
||||
"thumb_entry": meta.get("thumb_entry") or "",
|
||||
"kind": meta.get("kind") or row.get("kind"),
|
||||
"plate": meta.get("plate") or "",
|
||||
"slice_meta": meta.get("slice_meta") or {},
|
||||
},
|
||||
)
|
||||
_apply_meta(conn, row["id"], row, meta)
|
||||
updated += 1
|
||||
return updated
|
||||
|
||||
@@ -213,27 +245,39 @@ def delete_folder(cfg, rel):
|
||||
return folder
|
||||
|
||||
|
||||
def _link_file_to_project_folder(conn, file_id, stored_path, watch_dir):
|
||||
"""Wenn Pfad unter einem Projekt-Ordner liegt → project_id setzen."""
|
||||
folder = store.file_folder(stored_path, "watch", watch_dir)
|
||||
if not folder:
|
||||
return
|
||||
for row in store.list_project_folders(conn):
|
||||
base = (row.get("folder") or "").strip().replace("\\", "/").strip("/")
|
||||
if not base:
|
||||
continue
|
||||
if folder == base or folder.startswith(base + "/"):
|
||||
store.set_file_project(conn, file_id, row["id"])
|
||||
return
|
||||
|
||||
|
||||
def move_file(conn, cfg, file_id, folder):
|
||||
"""Watch-Datei in Unterordner (oder Wurzel) verschieben; ID bleibt."""
|
||||
"""Watch- oder Upload-Datei in Slice-Unterordner (oder Wurzel); Upload wird Watch."""
|
||||
root = require_watch_dir(cfg)
|
||||
folder = normalize_folder(folder)
|
||||
row = store.get_file_row(conn, file_id)
|
||||
if not row:
|
||||
raise GrottiError("Die Datei fehlt.", "datei", 404)
|
||||
if row.get("source") != "watch":
|
||||
raise GrottiError(
|
||||
"Nur Dateien aus dem Slice-Ordner lassen sich verschieben.",
|
||||
"watch",
|
||||
)
|
||||
source = row.get("source") or ""
|
||||
if source not in ("watch", "upload"):
|
||||
raise GrottiError("Diese Datei lässt sich nicht verschieben.", "datei")
|
||||
src = Path(row.get("stored_path") or "")
|
||||
if not src.is_file():
|
||||
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
|
||||
dest_dir = path_under_watch(root, folder)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = dest_dir / src.name
|
||||
if dest.resolve() == src.resolve():
|
||||
if dest.resolve() == src.resolve() and source == "watch":
|
||||
return store.file_out(row, watch_dir=root)
|
||||
if dest.exists():
|
||||
if dest.exists() and dest.resolve() != src.resolve():
|
||||
dest = unique_path(dest_dir, src.name)
|
||||
try:
|
||||
shutil.move(str(src), str(dest))
|
||||
@@ -247,11 +291,204 @@ def move_file(conn, cfg, file_id, folder):
|
||||
name=dest.name,
|
||||
size_bytes=stat.st_size,
|
||||
mtime=stat.st_mtime,
|
||||
source="watch",
|
||||
)
|
||||
_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)
|
||||
|
||||
|
||||
def path_allowed_for_delete(stored_path, cfg):
|
||||
"""Nur Dateien unter Slice-Ordner oder data/eingang."""
|
||||
try:
|
||||
resolved = Path(stored_path).resolve()
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
roots = []
|
||||
watch = cfg.get("watch_dir")
|
||||
if watch:
|
||||
roots.append(Path(watch).resolve())
|
||||
data_dir = cfg.get("data_dir")
|
||||
if data_dir:
|
||||
roots.append((Path(data_dir) / "eingang").resolve())
|
||||
for root in roots:
|
||||
try:
|
||||
if resolved == root or root in resolved.parents:
|
||||
return True
|
||||
except OSError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def delete_library_file(conn, cfg, file_id):
|
||||
"""Bibliotheksdatei löschen (Upload oder Watch) — DB + Datei auf der Platte."""
|
||||
row = store.get_file_row(conn, file_id)
|
||||
if not row:
|
||||
raise GrottiError("Die Datei fehlt.", "datei", 404)
|
||||
stored = row.get("stored_path") or ""
|
||||
if stored and not path_allowed_for_delete(stored, cfg):
|
||||
raise GrottiError("Löschen außerhalb von Slice-Ordner oder Eingang ist nicht erlaubt.", "datei")
|
||||
store.delete_file(conn, file_id)
|
||||
if stored:
|
||||
Path(stored).unlink(missing_ok=True)
|
||||
return row
|
||||
|
||||
|
||||
_PRINT_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
|
||||
|
||||
|
||||
def is_print_filename(name):
|
||||
low = (name or "").lower()
|
||||
return any(low.endswith(suf) for suf in _PRINT_SUFFIXES)
|
||||
|
||||
|
||||
def project_slug(name):
|
||||
"""Ordner-sichere Kurzform aus Projektnamen."""
|
||||
text = re.sub(r"[^\w\- äöüÄÖÜß.]+", "-", (name or "").strip(), flags=re.UNICODE)
|
||||
text = re.sub(r"\s+", "-", text)
|
||||
text = re.sub(r"-{2,}", "-", text).strip("-.")
|
||||
text = text[:60] or "projekt"
|
||||
return normalize_folder(text)
|
||||
|
||||
|
||||
def ensure_project_folder(cfg, folder_rel):
|
||||
"""Projektordner unter watch_dir anlegen; gibt normalisierten Relativpfad zurück."""
|
||||
root = require_watch_dir(cfg)
|
||||
folder = normalize_folder(folder_rel)
|
||||
if not folder:
|
||||
raise GrottiError("Projektordner fehlt.", "ordner")
|
||||
path_under_watch(root, folder).mkdir(parents=True, exist_ok=True)
|
||||
return folder
|
||||
|
||||
|
||||
def default_project_folder(name):
|
||||
return "Projekte/" + project_slug(name)
|
||||
|
||||
|
||||
def link_watch_files_to_projects(conn, cfg):
|
||||
"""Watch-Dateien in Projektordnern dem passenden Projekt zuordnen."""
|
||||
watch = cfg.get("watch_dir")
|
||||
if not watch:
|
||||
return 0
|
||||
projects = store.list_project_folders(conn)
|
||||
if not projects:
|
||||
return 0
|
||||
linked = 0
|
||||
for row in store.list_file_rows(conn):
|
||||
if (row.get("source") or "") != "watch":
|
||||
continue
|
||||
folder = store.file_folder(row.get("stored_path"), "watch", watch)
|
||||
if not folder:
|
||||
continue
|
||||
for proj in projects:
|
||||
base = (proj.get("folder") or "").strip().replace("\\", "/").strip("/")
|
||||
if not base:
|
||||
continue
|
||||
if folder == base or folder.startswith(base + "/"):
|
||||
if row.get("project_id") != proj["id"]:
|
||||
store.set_file_project(conn, row["id"], proj["id"])
|
||||
linked += 1
|
||||
break
|
||||
return linked
|
||||
|
||||
|
||||
def list_project_assets(cfg, folder_rel):
|
||||
"""Nicht-Druckdateien im Projektordner (flach + Unterordner)."""
|
||||
root = require_watch_dir(cfg)
|
||||
folder = normalize_folder(folder_rel)
|
||||
if not folder:
|
||||
return []
|
||||
base = path_under_watch(root, folder)
|
||||
if not base.is_dir():
|
||||
return []
|
||||
items = []
|
||||
for path in sorted(base.rglob("*")):
|
||||
if not path.is_file() or skip_path(path, cfg["data_dir"]):
|
||||
continue
|
||||
if is_print_filename(path.name):
|
||||
continue
|
||||
try:
|
||||
rel = path.resolve().relative_to(base).as_posix()
|
||||
except ValueError:
|
||||
continue
|
||||
stat = path.stat()
|
||||
items.append({
|
||||
"name": path.name,
|
||||
"rel": rel,
|
||||
"size_bytes": stat.st_size,
|
||||
"mtime": stat.st_mtime,
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
def resolve_project_asset(cfg, folder_rel, rel_name):
|
||||
"""Sicheren Pfad zu einer Asset-Datei im Projektordner."""
|
||||
root = require_watch_dir(cfg)
|
||||
folder = normalize_folder(folder_rel)
|
||||
if not folder:
|
||||
raise GrottiError("Projektordner fehlt.", "ordner")
|
||||
base = path_under_watch(root, folder)
|
||||
rel = str(rel_name or "").replace("\\", "/").strip("/")
|
||||
if not rel or ".." in rel.split("/"):
|
||||
raise GrottiError("Ungültiger Dateiname.", "datei")
|
||||
target = (base / rel).resolve()
|
||||
if base not in target.parents and target != base:
|
||||
raise GrottiError("Pfad außerhalb des Projektordners.", "ordner")
|
||||
if not target.is_file():
|
||||
raise GrottiError("Die Datei fehlt.", "datei", 404)
|
||||
return target
|
||||
|
||||
|
||||
def save_project_asset(cfg, folder_rel, filename, content):
|
||||
root = require_watch_dir(cfg)
|
||||
folder = normalize_folder(folder_rel)
|
||||
if not folder:
|
||||
raise GrottiError("Projektordner fehlt.", "ordner")
|
||||
dest_dir = path_under_watch(root, folder)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = unique_path(dest_dir, filename)
|
||||
path.write_bytes(content)
|
||||
return {
|
||||
"name": path.name,
|
||||
"rel": path.name,
|
||||
"size_bytes": path.stat().st_size,
|
||||
"mtime": path.stat().st_mtime,
|
||||
}
|
||||
|
||||
|
||||
def delete_project_asset(cfg, folder_rel, rel_name):
|
||||
path = resolve_project_asset(cfg, folder_rel, rel_name)
|
||||
path.unlink(missing_ok=True)
|
||||
return True
|
||||
|
||||
|
||||
def delete_project_disk_folder(cfg, folder_rel, force=False):
|
||||
"""Projektordner auf der Platte entfernen (leer oder force inkl. Inhalt)."""
|
||||
if not folder_rel:
|
||||
return
|
||||
try:
|
||||
root = require_watch_dir(cfg)
|
||||
except GrottiError:
|
||||
return
|
||||
folder = normalize_folder(folder_rel)
|
||||
if not folder:
|
||||
return
|
||||
target = path_under_watch(root, folder)
|
||||
if not target.is_dir():
|
||||
return
|
||||
if force:
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
return
|
||||
try:
|
||||
next(target.iterdir())
|
||||
raise GrottiError(
|
||||
"Projektordner ist nicht leer — mit Bestätigung inkl. Inhalt löschen.",
|
||||
"ordner",
|
||||
)
|
||||
except StopIteration:
|
||||
target.rmdir()
|
||||
|
||||
|
||||
def save_upload(conn, filename, content, cfg, project_id=None):
|
||||
directory = cfg["data_dir"] / "eingang"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Leere/alte Spulen aus der Alltagsliste nehmen
|
||||
ALTER TABLE spools ADD COLUMN IF NOT EXISTS archived_at timestamptz;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Projekt-Arbeitsmappe: Relativordner unter dem Slice-Pfad
|
||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS folder text NOT NULL DEFAULT '';
|
||||
+80
-9
@@ -91,40 +91,111 @@ def color_hex(channels):
|
||||
return "#%02x%02x%02x" % tuple(rgb)
|
||||
|
||||
|
||||
def profile_name(raw):
|
||||
"""Orca-Profil → Katalogname: `Sunlu PLA Meta @Kobra 3` → `Sunlu PLA Meta`."""
|
||||
text = (raw or "").strip().strip('"').strip("'").strip()
|
||||
if " @" in text:
|
||||
text = text.rsplit(" @", 1)[0].strip()
|
||||
return text
|
||||
|
||||
|
||||
def filament_profiles(comments):
|
||||
"""Profilnamen aus `; filament_settings_id`, Index = Slicer-Slot / paint_index."""
|
||||
raw = (comments or {}).get("filament_settings_id") or ""
|
||||
if not raw.strip():
|
||||
fallback = profile_name((comments or {}).get("default_filament_profile") or "")
|
||||
return [fallback] if fallback else []
|
||||
return [profile_name(part) for part in raw.split(";") if profile_name(part)]
|
||||
|
||||
|
||||
def attach_profiles(filaments, comments):
|
||||
"""Hängt `profile` (Sorte) an Filament-Einträge anhand des paint_index."""
|
||||
profiles = filament_profiles(comments)
|
||||
if not filaments:
|
||||
return filaments
|
||||
if not profiles:
|
||||
return filaments
|
||||
for index, row in enumerate(filaments):
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
try:
|
||||
paint = int(row.get("paint_index"))
|
||||
except (TypeError, ValueError):
|
||||
paint = index
|
||||
name = ""
|
||||
if 0 <= paint < len(profiles):
|
||||
name = profiles[paint]
|
||||
elif len(filaments) == 1 and len(profiles) == 1:
|
||||
name = profiles[0]
|
||||
if name:
|
||||
row["profile"] = name
|
||||
return filaments
|
||||
|
||||
|
||||
def filaments_from_paint_info(raw):
|
||||
"""Farben aus `; paint_info` — inkl. originalem `paint_index` (T-Nummer).
|
||||
|
||||
Bambu/Anycubic nutzen oft Lücken (T1 + T10 bei 16 Slots). Die Liste bleibt
|
||||
kompakt (nur belegte Farben), aber jeder Eintrag trägt den Slicer-Index,
|
||||
damit `.acm` und ACE-Map denselben Wert wie `Tn` im G-code schreiben.
|
||||
"""
|
||||
try:
|
||||
items = json.loads(raw or "")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return []
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
found = []
|
||||
found = {}
|
||||
next_fallback = 0
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
index = int(item.get("paint_index"))
|
||||
except (TypeError, ValueError):
|
||||
index = len(found)
|
||||
while len(found) <= index:
|
||||
found.append({"type": "", "color": ""})
|
||||
found[index] = {
|
||||
index = next_fallback
|
||||
if index < 0:
|
||||
continue
|
||||
next_fallback = max(next_fallback, index + 1)
|
||||
row = {
|
||||
"type": str(item.get("material_type") or "").strip(),
|
||||
"color": color_hex(item.get("paint_color")),
|
||||
"paint_index": index,
|
||||
}
|
||||
return [row for row in found if row["type"] or row["color"]]
|
||||
if row["type"] or row["color"]:
|
||||
found[index] = row
|
||||
return [found[key] for key in sorted(found)]
|
||||
|
||||
|
||||
def filaments_of(comments):
|
||||
painted = filaments_from_paint_info(comments.get("paint_info"))
|
||||
if painted:
|
||||
return painted
|
||||
return attach_profiles(painted, comments)
|
||||
types = [part.strip() for part in (comments.get("filament_type") or "").split(";") if part.strip()]
|
||||
colors = [part.strip() for part in (comments.get("filament_colour") or comments.get("filament_color") or "").split(";")]
|
||||
return [
|
||||
{"type": kind, "color": colors[index] if index < len(colors) else ""}
|
||||
rows = [
|
||||
{
|
||||
"type": kind,
|
||||
"color": colors[index] if index < len(colors) else "",
|
||||
"paint_index": index,
|
||||
}
|
||||
for index, kind in enumerate(types)
|
||||
]
|
||||
return attach_profiles(rows, comments)
|
||||
|
||||
|
||||
def tools_used_in_gcode(content):
|
||||
"""T-/MMU-Werkzeuge aus dem G-code (wie Rinkhals mmu_ace_metadata)."""
|
||||
if not content:
|
||||
return set()
|
||||
if isinstance(content, str):
|
||||
content = content.encode("utf-8", "replace")
|
||||
found = set()
|
||||
for line in _gcode_lines(content):
|
||||
match = _TOOL_RE.match(line)
|
||||
if match:
|
||||
found.add(int(match.group(1)))
|
||||
return found
|
||||
|
||||
|
||||
def _gcode_lines(content):
|
||||
|
||||
+49
-1
@@ -10,7 +10,7 @@ import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from grotti import eject, jobs, moonraker, notify, store
|
||||
from grotti import acm, eject, jobs, moonraker, notify, store
|
||||
from grotti.db import connect
|
||||
from grotti.errors import GrottiError
|
||||
from grotti import time_window
|
||||
@@ -306,6 +306,11 @@ class PrintQueueDispatcher:
|
||||
if (waiting.get("message") or "") != WINDOW_WAIT_MSG:
|
||||
store.update_print_queue(conn, waiting["id"], {"message": WINDOW_WAIT_MSG})
|
||||
continue
|
||||
# Nächster passender / leere Map: ACE aus Spulen bauen oder warten
|
||||
if not _has_ablauf(waiting):
|
||||
waiting = self._ensure_ace_map(conn, waiting, host, live)
|
||||
if not waiting:
|
||||
continue
|
||||
claimed = store.claim_print_queue(conn, waiting["id"], host)
|
||||
if not claimed:
|
||||
continue
|
||||
@@ -317,6 +322,49 @@ class PrintQueueDispatcher:
|
||||
self._start_simple(conn, claimed, printer)
|
||||
conn.commit()
|
||||
|
||||
def _ensure_ace_map(self, conn, waiting, host, live):
|
||||
"""Bei ACE-Drucker und leerer Map: aus Spulen zuordnen oder Wartegrund setzen."""
|
||||
slots = (live or {}).get("slots") or []
|
||||
if not slots:
|
||||
return waiting
|
||||
ace = waiting.get("ace_map") or []
|
||||
if isinstance(ace, str):
|
||||
import json
|
||||
|
||||
try:
|
||||
ace = json.loads(ace)
|
||||
except json.JSONDecodeError:
|
||||
ace = []
|
||||
if acm.normalize_mapping(ace if isinstance(ace, list) else []):
|
||||
return waiting
|
||||
file_id = waiting.get("file_id")
|
||||
if not file_id:
|
||||
return waiting
|
||||
mapping, reason = store.auto_ace_map(conn, host, file_id)
|
||||
if reason:
|
||||
if (waiting.get("wait_reason") or "") != reason or (waiting.get("message") or "") != reason:
|
||||
store.update_print_queue(
|
||||
conn,
|
||||
waiting["id"],
|
||||
{"wait_reason": reason, "message": reason},
|
||||
)
|
||||
return None
|
||||
if not mapping:
|
||||
# ACE am Drucker, aber keine ACE-Spulen in Grotti → nicht raten
|
||||
reason = "ACE-Zuordnung fehlt (keine passende Spule)"
|
||||
if (waiting.get("wait_reason") or "") != reason:
|
||||
store.update_print_queue(
|
||||
conn,
|
||||
waiting["id"],
|
||||
{"wait_reason": reason, "message": reason},
|
||||
)
|
||||
return None
|
||||
store.update_print_queue(conn, waiting["id"], {"ace_map": mapping, "wait_reason": "", "message": ""})
|
||||
waiting = dict(waiting)
|
||||
waiting["ace_map"] = mapping
|
||||
waiting["wait_reason"] = ""
|
||||
return waiting
|
||||
|
||||
def _start_simple(self, conn, item, printer):
|
||||
"""Upload/Start im Worker — mehrere freie Drucker parallel."""
|
||||
host = printer["host"]
|
||||
|
||||
@@ -7,9 +7,12 @@ CREATE TABLE IF NOT EXISTS projects (
|
||||
notes text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'idee',
|
||||
quote jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
folder text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS folder text NOT NULL DEFAULT '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS spools (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
@@ -123,6 +126,7 @@ ALTER TABLE spools ADD COLUMN IF NOT EXISTS material_id bigint REFERENCES materi
|
||||
ALTER TABLE spools ADD COLUMN IF NOT EXISTS chargen_nr text;
|
||||
ALTER TABLE spools ADD COLUMN IF NOT EXISTS hersteller_charge text NOT NULL DEFAULT '';
|
||||
ALTER TABLE spools ADD COLUMN IF NOT EXISTS code text;
|
||||
ALTER TABLE spools ADD COLUMN IF NOT EXISTS archived_at timestamptz;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS spools_chargen_nr ON spools (chargen_nr) WHERE chargen_nr IS NOT NULL AND chargen_nr <> '';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS spools_code ON spools (code) WHERE code IS NOT NULL AND code <> '';
|
||||
|
||||
+283
-37
@@ -20,6 +20,7 @@ def project_out(row, files=None, file_count=None):
|
||||
"notes": row["notes"],
|
||||
"status": row["status"],
|
||||
"quote": row["quote"] or {},
|
||||
"folder": (row.get("folder") or "").strip(),
|
||||
"created_at": iso(row["created_at"]),
|
||||
}
|
||||
if files is not None:
|
||||
@@ -76,6 +77,7 @@ def spool_out(row):
|
||||
colors = row.get("colors") or []
|
||||
if isinstance(colors, str):
|
||||
colors = json.loads(colors)
|
||||
archived = row.get("archived_at")
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
@@ -93,6 +95,8 @@ def spool_out(row):
|
||||
"chargen_nr": row.get("chargen_nr") or "",
|
||||
"hersteller_charge": row.get("hersteller_charge") or "",
|
||||
"code": row.get("code") or "",
|
||||
"archived_at": archived.isoformat() if archived and hasattr(archived, "isoformat") else (archived or None),
|
||||
"archived": bool(archived),
|
||||
}
|
||||
|
||||
|
||||
@@ -209,14 +213,14 @@ def get_project(conn, project_id):
|
||||
return project_out(row, files=[file_out(item) for item in files])
|
||||
|
||||
|
||||
def create_project(conn, name, notes, status):
|
||||
def create_project(conn, name, notes, status, folder=""):
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO projects (name, notes, status)
|
||||
VALUES (%s, %s, %s)
|
||||
INSERT INTO projects (name, notes, status, folder)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(name, notes, status),
|
||||
(name, notes, status, (folder or "").strip()),
|
||||
).fetchone()
|
||||
return project_out(row, files=[], file_count=0)
|
||||
|
||||
@@ -224,10 +228,10 @@ def create_project(conn, name, notes, status):
|
||||
def update_project(conn, project_id, fields):
|
||||
sets = []
|
||||
values = []
|
||||
for key in ("name", "notes", "status"):
|
||||
for key in ("name", "notes", "status", "folder"):
|
||||
if key in fields:
|
||||
sets.append("%s = %%s" % key)
|
||||
values.append(fields[key])
|
||||
values.append(fields[key] if key != "folder" else (fields[key] or "").strip())
|
||||
if "quote" in fields:
|
||||
sets.append("quote = %s")
|
||||
values.append(Json(fields["quote"]))
|
||||
@@ -243,6 +247,24 @@ def update_project(conn, project_id, fields):
|
||||
return get_project(conn, project_id)
|
||||
|
||||
|
||||
def list_project_folders(conn):
|
||||
"""id + folder für Sync (nur Projekte mit Ordner)."""
|
||||
return conn.execute(
|
||||
"""
|
||||
SELECT id, folder FROM projects
|
||||
WHERE coalesce(folder, '') <> ''
|
||||
ORDER BY length(folder) DESC, folder
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
|
||||
def set_file_project(conn, file_id, project_id):
|
||||
conn.execute(
|
||||
"UPDATE files SET project_id = %s WHERE id = %s",
|
||||
(project_id, file_id),
|
||||
)
|
||||
|
||||
|
||||
def delete_project(conn, project_id):
|
||||
row = conn.execute("DELETE FROM projects WHERE id = %s RETURNING id", (project_id,)).fetchone()
|
||||
return bool(row)
|
||||
@@ -341,7 +363,7 @@ def update_file(conn, file_id, fields, watch_dir=None):
|
||||
return file_out(row, watch_dir=watch_dir) if row else None
|
||||
|
||||
|
||||
def update_file_location(conn, file_id, stored_path, name=None, size_bytes=None, mtime=None):
|
||||
def update_file_location(conn, file_id, stored_path, name=None, size_bytes=None, mtime=None, source=None):
|
||||
"""Pfad nach Verschieben auf der Platte — ID und Zuordnungen bleiben."""
|
||||
sets = ["stored_path = %s"]
|
||||
values = [stored_path]
|
||||
@@ -354,6 +376,9 @@ def update_file_location(conn, file_id, stored_path, name=None, size_bytes=None,
|
||||
if mtime is not None:
|
||||
sets.append("mtime = %s")
|
||||
values.append(mtime)
|
||||
if source is not None:
|
||||
sets.append("source = %s")
|
||||
values.append(source)
|
||||
values.append(file_id)
|
||||
return conn.execute(
|
||||
"UPDATE files SET %s WHERE id = %%s RETURNING *" % ", ".join(sets),
|
||||
@@ -440,6 +465,14 @@ def update_spool(conn, spool_id, fields):
|
||||
if key in fields:
|
||||
sets.append("%s = %%s" % key)
|
||||
values.append(Json(fields[key]) if key == "colors" else fields[key])
|
||||
if "archived_at" in fields:
|
||||
if fields["archived_at"] is None or fields["archived_at"] is False:
|
||||
sets.append("archived_at = NULL")
|
||||
elif fields["archived_at"] is True or fields["archived_at"] == "now":
|
||||
sets.append("archived_at = now()")
|
||||
else:
|
||||
sets.append("archived_at = %s")
|
||||
values.append(fields["archived_at"])
|
||||
if not sets:
|
||||
row = get_spool_row(conn, spool_id)
|
||||
return spool_out(row) if row else None
|
||||
@@ -451,6 +484,23 @@ def update_spool(conn, spool_id, fields):
|
||||
return spool_out(row) if row else None
|
||||
|
||||
|
||||
def mark_spool_empty(conn, spool_id):
|
||||
"""Rest auf 0, vom Drucker ins Regal, archivieren."""
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE spools
|
||||
SET remaining_g = 0,
|
||||
printer_host = '',
|
||||
ace_slot = NULL,
|
||||
archived_at = COALESCE(archived_at, now())
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(spool_id,),
|
||||
).fetchone()
|
||||
return spool_out(row) if row else None
|
||||
|
||||
|
||||
def material_out(row):
|
||||
s1 = row["volumetric_s1"]
|
||||
return {
|
||||
@@ -1278,6 +1328,27 @@ def find_live_run(conn, host, filename=""):
|
||||
).fetchone()
|
||||
|
||||
|
||||
def find_active_run(conn, host, filename=""):
|
||||
"""Offener Lauf am Host: Live bevorzugt, sonst offener History-Job (Pause/Resume)."""
|
||||
return find_live_run(conn, host, filename) or find_open_history_run(conn, host, filename)
|
||||
|
||||
|
||||
def update_send_spool(conn, host, filename, spool_id):
|
||||
"""Spule am letzten Send dieser Datei korrigieren (Rollenwechsel während Druck)."""
|
||||
host = (host or "").strip()
|
||||
if not host or not spool_id:
|
||||
return None
|
||||
sent = latest_send(conn, host, filename or "")
|
||||
if not sent:
|
||||
return None
|
||||
if sent.get("spool_id") == spool_id:
|
||||
return sent
|
||||
return conn.execute(
|
||||
"UPDATE sends SET spool_id = %s WHERE id = %s RETURNING *",
|
||||
(spool_id, sent["id"]),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def ensure_live_run(conn, host, printer_name, filename, spool_id=None):
|
||||
"""Aktiven Lauf: History-Job bevorzugen, sonst Live — nie parallel beide offen."""
|
||||
hist = find_open_history_run(conn, host, filename)
|
||||
@@ -1286,7 +1357,7 @@ def ensure_live_run(conn, host, printer_name, filename, spool_id=None):
|
||||
if live:
|
||||
adopt_live_run(conn, host, filename or hist.get("filename") or "", hist["id"])
|
||||
hist = get_run_row(conn, hist["id"]) or hist
|
||||
if spool_id and not hist.get("spool_id"):
|
||||
if spool_id and hist.get("spool_id") != spool_id:
|
||||
conn.execute("UPDATE runs SET spool_id = %s WHERE id = %s", (spool_id, hist["id"]))
|
||||
hist = get_run_row(conn, hist["id"]) or hist
|
||||
return hist
|
||||
@@ -1298,6 +1369,9 @@ def ensure_live_run(conn, host, printer_name, filename, spool_id=None):
|
||||
(filename, row["id"]),
|
||||
)
|
||||
row = get_run_row(conn, row["id"])
|
||||
if spool_id and row 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
|
||||
job_id = "live:%s:%s" % (host, datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S"))
|
||||
return upsert_run(conn, {
|
||||
@@ -1349,6 +1423,30 @@ def abandon_live_runs(conn, host, keep_id=None):
|
||||
return len(rows)
|
||||
|
||||
|
||||
def complete_open_run(conn, run_id, state="completed"):
|
||||
"""Offenen Lauf als beendet markieren (Abräumen / Druckende ohne History)."""
|
||||
state = (state or "completed").strip() or "completed"
|
||||
if state not in ("completed", "cancelled", "error", "interrupted"):
|
||||
state = "completed"
|
||||
return conn.execute(
|
||||
"""
|
||||
UPDATE runs
|
||||
SET state = %s,
|
||||
ended_at = COALESCE(ended_at, now()),
|
||||
duration_s = CASE
|
||||
WHEN COALESCE(duration_s, 0) > 0 THEN duration_s
|
||||
WHEN started_at IS NOT NULL
|
||||
THEN EXTRACT(EPOCH FROM (now() - started_at))
|
||||
ELSE COALESCE(duration_s, 0)
|
||||
END
|
||||
WHERE id = %s
|
||||
AND state IN ('in_progress', 'printing', 'paused')
|
||||
RETURNING *
|
||||
""",
|
||||
(state, run_id),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def adopt_live_run(conn, host, filename, real_run_id):
|
||||
"""Proben/Segmente vom Live-Lauf auf den History-Lauf umhängen."""
|
||||
live = find_live_run(conn, host, filename)
|
||||
@@ -1772,7 +1870,7 @@ def exclusive_until(until):
|
||||
return until
|
||||
|
||||
|
||||
def statistics(conn, since=None, until=None, host="", material="", brand="", project_id=None):
|
||||
def statistics(conn, since=None, until=None, host="", material="", brand="", color="", project_id=None):
|
||||
where = ["r.state IN ('completed', 'cancelled', 'error', 'interrupted')"]
|
||||
values = []
|
||||
until_bound = exclusive_until(until)
|
||||
@@ -1790,7 +1888,7 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
|
||||
values.append(project_id)
|
||||
|
||||
spool_join = ""
|
||||
if material or brand:
|
||||
if material or brand or color:
|
||||
spool_join = (
|
||||
"JOIN run_spools rs0 ON rs0.run_id = r.id "
|
||||
"JOIN spools s0 ON s0.id = rs0.spool_id"
|
||||
@@ -1801,6 +1899,9 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
|
||||
if brand:
|
||||
where.append("s0.brand ILIKE %s")
|
||||
values.append(brand)
|
||||
if color:
|
||||
where.append("s0.color ILIKE %s")
|
||||
values.append(color)
|
||||
clause = " AND ".join(where)
|
||||
|
||||
overview = conn.execute(
|
||||
@@ -1874,11 +1975,14 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
|
||||
if brand:
|
||||
mat_where.append("s.brand ILIKE %s")
|
||||
mat_values.append(brand)
|
||||
if color:
|
||||
mat_where.append("s.color ILIKE %s")
|
||||
mat_values.append(color)
|
||||
mat_clause = " AND ".join(mat_where)
|
||||
|
||||
by_material = conn.execute(
|
||||
"""
|
||||
SELECT s.material_type, s.brand,
|
||||
SELECT s.material_type, s.brand, s.color,
|
||||
COALESCE(SUM(rs.weight_g), 0) AS weight_g,
|
||||
COALESCE(SUM(rs.cost), 0) AS cost,
|
||||
COUNT(DISTINCT rs.run_id) AS jobs
|
||||
@@ -1886,7 +1990,7 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
|
||||
JOIN runs r ON r.id = rs.run_id
|
||||
JOIN spools s ON s.id = rs.spool_id
|
||||
WHERE %s
|
||||
GROUP BY s.material_type, s.brand
|
||||
GROUP BY s.material_type, s.brand, s.color
|
||||
ORDER BY weight_g DESC
|
||||
""" % mat_clause,
|
||||
mat_values,
|
||||
@@ -1920,6 +2024,17 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
|
||||
ov = overview or {}
|
||||
# Bei Spulen-Join können SUM(DISTINCT …) ungenau sein — Überblick ohne Join neu
|
||||
if spool_join:
|
||||
spool_filters = []
|
||||
spool_vals = list(run_values)
|
||||
if material:
|
||||
spool_filters.append("AND s.material_type ILIKE %s")
|
||||
spool_vals.append(material)
|
||||
if brand:
|
||||
spool_filters.append("AND s.brand ILIKE %s")
|
||||
spool_vals.append(brand)
|
||||
if color:
|
||||
spool_filters.append("AND s.color ILIKE %s")
|
||||
spool_vals.append(color)
|
||||
overview2 = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
@@ -1940,15 +2055,10 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
|
||||
JOIN spools s ON s.id = rs.spool_id
|
||||
WHERE rs.run_id = r.id
|
||||
%s
|
||||
%s
|
||||
)
|
||||
)
|
||||
""" % (
|
||||
run_clause,
|
||||
("AND s.material_type ILIKE %%s" if material else ""),
|
||||
("AND s.brand ILIKE %%s" if brand else ""),
|
||||
),
|
||||
run_values + ([material] if material else []) + ([brand] if brand else []),
|
||||
""" % (run_clause, " ".join(spool_filters)),
|
||||
spool_vals,
|
||||
).fetchone()
|
||||
ov = overview2 or ov
|
||||
|
||||
@@ -1986,6 +2096,7 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
|
||||
{
|
||||
"material_type": row["material_type"] or "",
|
||||
"brand": row["brand"] or "",
|
||||
"color": row.get("color") or "",
|
||||
"weight_g": float(row["weight_g"] or 0),
|
||||
"cost": float(row["cost"] or 0),
|
||||
"jobs": int(row["jobs"] or 0),
|
||||
@@ -2579,14 +2690,50 @@ def update_print_queue(conn, item_id, fields):
|
||||
return print_queue_out(fresh, fresh.get("filename")) if fresh else None
|
||||
|
||||
|
||||
def _profile_matches(need_profile, spool_name):
|
||||
"""Sorte: exakter Katalogname (Meta ≠ Matte ≠ PLA). Zusatz nur hinter dem Namen."""
|
||||
a = (need_profile or "").strip().lower()
|
||||
b = (spool_name or "").strip().lower()
|
||||
if not a:
|
||||
return True
|
||||
if not b:
|
||||
return False
|
||||
if a == b:
|
||||
return True
|
||||
# Spulen-Anzeigename: "Sunlu PLA Meta schwarz" — nicht "Sunlu PLA" ⊂ "Sunlu PLA Matte"
|
||||
if b.startswith(a) and len(b) > len(a) and not b[len(a)].isalnum():
|
||||
rest = b[len(a):].lstrip(" ·-–_|/")
|
||||
# Nächstes Wort darf keine bekannte PLA-/PETG-Variante sein
|
||||
first = (rest.split() or [""])[0]
|
||||
if first in {"meta", "matte", "matt", "silk", "plus", "+", "hf", "cf"}:
|
||||
return False
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _need_label(need):
|
||||
profile = (need.get("profile") or "").strip()
|
||||
if profile:
|
||||
return profile
|
||||
kind = (need.get("material_type") or "").strip()
|
||||
color = (need.get("color") or "").strip()
|
||||
return (kind + ((" " + color) if color else "")).strip() or "Material"
|
||||
|
||||
|
||||
def _spool_material_set(conn, host):
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT lower(coalesce(material_type, '')) AS mt,
|
||||
lower(coalesce(color, '')) AS color,
|
||||
coalesce(remaining_g, 0) AS remaining_g
|
||||
FROM spools
|
||||
WHERE printer_host = %s
|
||||
SELECT lower(coalesce(s.material_type, '')) AS mt,
|
||||
lower(coalesce(s.color, '')) AS color,
|
||||
coalesce(s.remaining_g, 0) AS remaining_g,
|
||||
lower(coalesce(m.name, s.name, '')) AS material_name,
|
||||
s.ace_slot AS ace_slot,
|
||||
s.id AS spool_id
|
||||
FROM spools s
|
||||
LEFT JOIN materials m ON m.id = s.material_id
|
||||
WHERE s.printer_host = %s
|
||||
AND s.archived_at IS NULL
|
||||
AND coalesce(s.remaining_g, 0) > 0.5
|
||||
""",
|
||||
(host or "",),
|
||||
).fetchall()
|
||||
@@ -2595,6 +2742,9 @@ def _spool_material_set(conn, host):
|
||||
"material_type": (r["mt"] or "").strip(),
|
||||
"color": (r["color"] or "").strip(),
|
||||
"remaining_g": float(r["remaining_g"] or 0),
|
||||
"material_name": (r["material_name"] or "").strip(),
|
||||
"ace_slot": r["ace_slot"],
|
||||
"spool_id": r["spool_id"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -2617,48 +2767,144 @@ def _file_needs(conn, file_id):
|
||||
except Exception:
|
||||
filaments = []
|
||||
types = []
|
||||
for item in filaments if isinstance(filaments, list) else []:
|
||||
for index, item in enumerate(filaments if isinstance(filaments, list) else []):
|
||||
if isinstance(item, dict):
|
||||
t = (item.get("type") or item.get("material_type") or "").strip().lower()
|
||||
c = (item.get("color") or "").strip().lower()
|
||||
if t:
|
||||
types.append({"material_type": t, "color": c})
|
||||
profile = (item.get("profile") or "").strip()
|
||||
try:
|
||||
paint = int(item.get("paint_index"))
|
||||
except (TypeError, ValueError):
|
||||
paint = index
|
||||
if t or profile:
|
||||
types.append({
|
||||
"material_type": t,
|
||||
"color": c,
|
||||
"profile": profile,
|
||||
"paint_index": paint,
|
||||
})
|
||||
elif isinstance(item, str) and item.strip():
|
||||
types.append({"material_type": item.strip().lower(), "color": ""})
|
||||
types.append({
|
||||
"material_type": item.strip().lower(),
|
||||
"color": "",
|
||||
"profile": "",
|
||||
"paint_index": index,
|
||||
})
|
||||
return types, float(row.get("weight_g") or 0)
|
||||
|
||||
|
||||
def _match_spools_for_need(need, have, used_ids=None):
|
||||
used = used_ids or set()
|
||||
candidates = [h for h in have if h.get("spool_id") not in used]
|
||||
profile = (need.get("profile") or "").strip()
|
||||
if profile:
|
||||
by_profile = [h for h in candidates if _profile_matches(profile, h.get("material_name"))]
|
||||
if not by_profile:
|
||||
return []
|
||||
colored = [
|
||||
h for h in by_profile
|
||||
if not need.get("color") or h["color"] == need["color"] or not h["color"]
|
||||
]
|
||||
return colored or by_profile
|
||||
kind = need.get("material_type") or ""
|
||||
return [
|
||||
h for h in candidates
|
||||
if (not kind or h["material_type"] == kind)
|
||||
and (not need.get("color") or h["color"] == need["color"] or not h["color"])
|
||||
]
|
||||
|
||||
|
||||
def material_fit_score(conn, host, file_id):
|
||||
"""Höher = besser. -1 nur bei zu wenig Rest. Falsche Sorte startet trotzdem."""
|
||||
"""Höher = besser. -1 = warten (falsche Sorte mit Profil, oder zu wenig Rest)."""
|
||||
need, weight = _file_needs(conn, file_id)
|
||||
have = _spool_material_set(conn, host)
|
||||
if not need:
|
||||
return 0, ""
|
||||
if not have:
|
||||
# Mit Profil: ohne Spule warten. Ohne Profil: soft (wie bisher).
|
||||
if any((n.get("profile") or "").strip() for n in need):
|
||||
return -1, "braucht %s" % _need_label(need[0])
|
||||
return 0, ""
|
||||
|
||||
has_profiles = any((n.get("profile") or "").strip() for n in need)
|
||||
if has_profiles:
|
||||
score = 20
|
||||
used = set()
|
||||
for n in need:
|
||||
matched = _match_spools_for_need(n, have, used)
|
||||
if not matched:
|
||||
return -1, "braucht %s" % _need_label(n)
|
||||
best = max(matched, key=lambda h: h["remaining_g"])
|
||||
used.add(best.get("spool_id"))
|
||||
if n.get("color") and any(h["color"] == n["color"] for h in matched):
|
||||
score += 5
|
||||
if weight and best["remaining_g"] + 1e-6 >= weight:
|
||||
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, ""
|
||||
|
||||
have_types = {h["material_type"] for h in have if h["material_type"]}
|
||||
need_types = {n["material_type"] for n in need if n["material_type"]}
|
||||
# Falsche Sorte hält den Job nicht fest. Dateien warnt, der Start geht trotzdem.
|
||||
# Ohne Profil: falscher Typ hält den Job nicht fest (Sofort-Start warnt in der UI).
|
||||
score = 0 if (need_types and have_types and need_types.isdisjoint(have_types)) else 10
|
||||
# Farbe + Rest
|
||||
for n in need:
|
||||
matched = [
|
||||
h for h in have
|
||||
if h["material_type"] == n["material_type"]
|
||||
and (not n["color"] or h["color"] == n["color"] or not h["color"])
|
||||
]
|
||||
matched = _match_spools_for_need(n, have)
|
||||
if matched:
|
||||
score += 5
|
||||
if weight and any(h["remaining_g"] + 1e-6 >= weight for h in matched):
|
||||
score += 5
|
||||
elif weight and all(h["remaining_g"] + 1e-6 < weight for h in matched):
|
||||
return -1, "braucht %s (%s g fehlen)" % (
|
||||
n["material_type"] + ((" " + n["color"]) if n["color"] else ""),
|
||||
_need_label(n),
|
||||
int(max(0, weight - max(h["remaining_g"] for h in matched))),
|
||||
)
|
||||
return score, ""
|
||||
|
||||
|
||||
def auto_ace_map(conn, host, file_id):
|
||||
"""ACE-Map aus eingelegten Spulen: Sorte (Profil) zuerst, Farbe soft.
|
||||
|
||||
Rückgabe: (mapping, reason). reason gesetzt → nicht starten, warten.
|
||||
Ohne ACE-Spulen am Host: leere Map und kein Grund (externe Rolle).
|
||||
"""
|
||||
need, _weight = _file_needs(conn, file_id)
|
||||
have = [h for h in _spool_material_set(conn, host) if h.get("ace_slot")]
|
||||
if not need:
|
||||
return [], ""
|
||||
if not have:
|
||||
return [], ""
|
||||
mapping = []
|
||||
used = set()
|
||||
for n in need:
|
||||
matched = _match_spools_for_need(n, have, used)
|
||||
# Nur ACE-Plätze
|
||||
matched = [h for h in matched if h.get("ace_slot")]
|
||||
if not matched:
|
||||
return [], "braucht %s auf ACE" % _need_label(n)
|
||||
# Farbe bevorzugen, dann Rest
|
||||
matched.sort(
|
||||
key=lambda h: (
|
||||
0 if (n.get("color") and h["color"] == n["color"]) else 1,
|
||||
-h["remaining_g"],
|
||||
)
|
||||
)
|
||||
chosen = matched[0]
|
||||
used.add(chosen.get("spool_id"))
|
||||
try:
|
||||
paint = int(n.get("paint_index"))
|
||||
except (TypeError, ValueError):
|
||||
paint = len(mapping)
|
||||
ams = int(chosen["ace_slot"]) - 1
|
||||
if ams < 0 or ams > 7:
|
||||
return [], "ACE-Platz von %s ungültig" % _need_label(n)
|
||||
mapping.append({"paint_index": paint, "ams_index": ams})
|
||||
return mapping, ""
|
||||
|
||||
|
||||
def next_waiting_print(conn, host=None, family=None):
|
||||
"""Nächster wartender Druck: Termin (due_at) vor Priorität, Material-Match bevorzugt."""
|
||||
if host:
|
||||
|
||||
+14
-6
@@ -53,15 +53,23 @@ def planned_shares(metadata, ace_map, target_count):
|
||||
weights = weight_list(metadata)
|
||||
mapping = ace_map if isinstance(ace_map, list) else []
|
||||
if weights and mapping and len(weights) >= len(mapping) and target_count == len(mapping):
|
||||
shares = []
|
||||
paints = []
|
||||
for item in mapping:
|
||||
paint = item.get("paint_index")
|
||||
try:
|
||||
index = int(paint)
|
||||
paints.append(int(item.get("paint_index")))
|
||||
except (TypeError, ValueError):
|
||||
index = len(shares)
|
||||
if 0 <= index < len(weights):
|
||||
shares.append(weights[index])
|
||||
paints.append(-1)
|
||||
# Nur bei dichter 0…n−1-Map paint_index = Gewichtsindex; sonst Map-Reihenfolge
|
||||
# (Bambu/Anycubic oft T1+T10 bei zwei kompakten Gewichten).
|
||||
dense = sorted(paints) == list(range(len(paints))) and all(
|
||||
0 <= paint < len(weights) for paint in paints
|
||||
)
|
||||
shares = []
|
||||
for order, paint in enumerate(paints):
|
||||
if dense:
|
||||
shares.append(weights[paint])
|
||||
elif order < len(weights):
|
||||
shares.append(weights[order])
|
||||
else:
|
||||
shares.append(0.0)
|
||||
if sum(shares) > 0:
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
|
||||
|
||||
__version__ = "3.2.0"
|
||||
__version__ = "3.3.0"
|
||||
+14
-1
@@ -6,7 +6,7 @@ import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from grotti import moonraker, notify, store, usage
|
||||
from grotti import jobs, moonraker, notify, store, usage
|
||||
from grotti.events_bus import publish as publish_live
|
||||
from grotti.db import connect
|
||||
|
||||
@@ -170,6 +170,17 @@ class StateWatcher:
|
||||
jobs.sync_history(conn, self.cfg)
|
||||
except Exception as exc:
|
||||
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
||||
# History oft ohne Endzeit (interrupted) — Live trotzdem schließen.
|
||||
dangling = store.find_live_run(conn, host)
|
||||
if dangling:
|
||||
closed = store.complete_open_run(conn, dangling["id"], state="completed")
|
||||
if closed:
|
||||
try:
|
||||
from grotti import jobs
|
||||
|
||||
jobs.apply_deduction(conn, closed, self.cfg["energy"])
|
||||
except Exception as exc:
|
||||
print("Watcher Abzug %s: %s" % (host, exc), flush=True)
|
||||
elif state in ("cancelled", "standby"):
|
||||
# Live-Lauf schließen: normaler Abbruch, oder nach Offline/Neustart
|
||||
# (prev wird „offline“, der Übergang paused→standby geht sonst verloren).
|
||||
@@ -286,6 +297,8 @@ class StateWatcher:
|
||||
return None
|
||||
sent = store.latest_send(conn, host, filename) if filename else None
|
||||
spool_id = (sent or {}).get("spool_id")
|
||||
# Send kann nach Rollenwechsel veraltet sein — aktuelle Rolle am Host bevorzugt
|
||||
spool_id = jobs.resolve_tracking_spool(conn, host, spool_id, ace_map=(sent or {}).get("ace_map"))
|
||||
run = store.ensure_live_run(conn, host, name, filename, spool_id=spool_id)
|
||||
objects = self._merge_objects(run.get("objects"), live.get("exclude"))
|
||||
planned = usage.as_float(live.get("weight"))
|
||||
|
||||
Reference in new issue
Block a user