From dd0e7ebc6b36681d0669d1bc7d1f4b93638649c7 Mon Sep 17 00:00:00 2001 From: Dominic Gundel Date: Sun, 18 Oct 2026 21:36:12 +0200 Subject: [PATCH] Grotti 3.4.0: Dateien umbenennen, Bedarf/PLA-Match, Pause- und gklib-Fixes. --- CHANGELOG.md | 14 +++++++ docs/Anleitung-Dateien.md | 1 + docs/Anleitung-Stoerungen.md | 4 ++ grotti/bedarf.py | 29 +++++++++++++- grotti/library.py | 51 +++++++++++++++++++++++++ grotti/moonraker.py | 68 +++++++++++++++++++++------------ grotti/parse.py | 33 ++++++++++++++++ grotti/store.py | 61 ++++++++++++++++++++++------- grotti/version.py | 2 +- grotti/watcher.py | 24 ++++++++++++ tests/test_bedarf.py | 24 ++++++++++++ tests/test_colour_type_strip.py | 44 +++++++++++++++++++++ tests/test_filament_pause.py | 48 +++++++++++++++++++---- tests/test_material_routing.py | 20 ++++++++++ web/app.py | 8 ++++ web/templates/dateien.html | 57 ++++++++++++++++++++++----- 16 files changed, 430 insertions(+), 58 deletions(-) create mode 100644 tests/test_colour_type_strip.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 14bfed2..6ce5fd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo ## [Unreleased] +## [3.4.0] – 2026-10-18 + +### Neu + +- **Dateien:** Umbenennen in der Bibliothek (Stift-Icon; Endung bleibt erhalten). + +### Behoben + +- **Bedarf „443 g fehlen“ trotz voller Spulen:** Slice-Farbe `#26A69A` matchte nicht Lagerfarbe „Grau“ → Rest 0. Bei Hex-Farbe zählt jetzt der Rest gleicher Sorte; archivierte Spulen nicht. +- **Falsches „Material nachlegen“ bei manueller Pause:** Anycubic meldet `active_filament.empty=true` auch ohne Runout (`tool`/`gate` = −1). Heuristik nur noch bei Meldung/`reason_for_pause`/10107, aktivem Tool + differenziertem `gate_status`, oder G-code-Store. +- **gklib 10111 nach Einfarbig-Upload:** Orca-Zeile `; filament_colour_type` wird beim Senden entfernt (nur Einfarbig; Multicolor unangetastet). +- **Protokoll „druckt“ nach Offline:** Live-Läufe werden bei anhaltendem Offline als `interrupted` geschlossen (z. B. gklib-Shutdown). +- **Sorte „Sunlu PLA“ vs. Eryone PLA:** Basis-Profil ohne Meta/Matte/Silk matcht nach Material-Typ (Marke egal); Varianten bleiben streng. + ## [3.3.0] – 2026-10-18 ### Neu diff --git a/docs/Anleitung-Dateien.md b/docs/Anleitung-Dateien.md index 96c59e8..3b63cca 100644 --- a/docs/Anleitung-Dateien.md +++ b/docs/Anleitung-Dateien.md @@ -14,6 +14,7 @@ Stand **18.10.2026**. Seite `/dateien` — Bibliothek links, Senden rechts. - Info-Popup mit Slice-Metadaten (Kopf und Footer) - **Hochladen** per Knopf oder Drag & Drop - Mit gesetztem Slice-Ordner ([Einstellungen](Anleitung-Einstellungen.md)): **Ordner anlegen**; Watch- **und** Upload-Dateien per Auswahl **verschieben** (Upload wird zur Watch-Datei im Slice-Ordner) +- **Umbenennen** (Stift-Icon) — Datei auf der Platte und in der Bibliothek; Endung `.gcode`/`.3mf` bleibt, wenn du sie weglässt - **Löschen** (Papierkorb, Zwei-Klick) für jede Bibliotheksdatei — entfernt Eintrag und Datei auf der Platte (nur unter Slice-Ordner oder `eingang`) - **Ordner neu lesen**, wenn unter Einstellungen ein Slice-Ordner gesetzt ist diff --git a/docs/Anleitung-Stoerungen.md b/docs/Anleitung-Stoerungen.md index f9f4d5c..27fa523 100644 --- a/docs/Anleitung-Stoerungen.md +++ b/docs/Anleitung-Stoerungen.md @@ -49,6 +49,10 @@ Oft noch Verarbeitung auf dem Drucker. In der Sidebar unter **Senden** den Forts Oft Dateinamen mit Nicht-ASCII-Zeichen (z. B. Chinesisch). Grotti normalisiert den Namen beim Senden auf ASCII. Nochmal senden; in der Bibliothek darf der Originalname bleiben. +## Drucker „heruntergefahren“ / 10111 / 506 nach Upload + +Oft **Einfarbig**-G-code mit `; filament_colour_type` (Orca) — gklib stürzt ab. Grotti entfernt die Zeile beim Senden nur bei Einfarbig. Multicolor bleibt unverändert. Betroffene Drucker: Display-Reboot oder Strom aus/an, dann in Grotti ggf. **Fehler löschen**. + ## Wo nachschauen - Sidebar: Alerts, Läuft, Wartet, Senden diff --git a/grotti/bedarf.py b/grotti/bedarf.py index cf89463..70525ef 100644 --- a/grotti/bedarf.py +++ b/grotti/bedarf.py @@ -2,6 +2,10 @@ from __future__ import annotations +import re + +_HEX_COLOR_RE = re.compile(r"^#?[0-9a-f]{6}([0-9a-f]{2})?$", re.I) + def _group_key(material_type, color): return ( @@ -10,6 +14,27 @@ def _group_key(material_type, color): ) +def _is_slicer_hex(color): + """Orca/Anycubic speichert oft #26A69A — Lagerfarben heißen „Grau“/„Weiß“.""" + return bool(_HEX_COLOR_RE.match((color or "").strip())) + + +def _stock_for_need(stock_map, mat, color): + """Exakte Farbe zuerst; bei Slice-Hex oder leerer Farbe: Summe gleicher Sorte.""" + key = _group_key(mat, color) + exact = stock_map.get(key, 0.0) + if exact > 0.5: + return exact + if not color or _is_slicer_hex(color): + mat_key = (mat or "").strip().lower() + return sum( + grams + for (m, _c), grams in stock_map.items() + if m == mat_key and grams > 0 + ) + return exact + + def compute(conn): """Wartende Jobs × Dateigewicht vs. Spulenrest, gruppiert nach Sorte/Farbe.""" queue_rows = conn.execute( @@ -52,6 +77,7 @@ def compute(conn): """ SELECT material_type, color, COALESCE(SUM(remaining_g), 0) AS remaining_g FROM spools + WHERE archived_at IS NULL GROUP BY material_type, color """ ).fetchall() @@ -67,8 +93,7 @@ def compute(conn): ) shortfall = [] for item in needed: - key = _group_key(item["material_type"], item["color"]) - have = stock_map.get(key, 0.0) + have = _stock_for_need(stock_map, item["material_type"], item["color"]) gap = item["weight_g"] - have if gap > 0.5: shortfall.append({ diff --git a/grotti/library.py b/grotti/library.py index 317e5da..0478a15 100644 --- a/grotti/library.py +++ b/grotti/library.py @@ -334,6 +334,57 @@ def delete_library_file(conn, cfg, file_id): return row +def _ensure_print_suffix(new_name, old_name): + """Endung .gcode / .3mf / .gcode.3mf behalten, wenn der Nutzer sie weglässt.""" + name = safe_filename(new_name) + low = name.lower() + old_low = (old_name or "").lower() + if low.endswith(".gcode.3mf") or low.endswith(".3mf") or low.endswith(".gcode"): + return name + if old_low.endswith(".gcode.3mf"): + return name + ".gcode.3mf" + if old_low.endswith(".3mf"): + return name + ".3mf" + if old_low.endswith(".gcode"): + return name + ".gcode" + return name + + +def rename_library_file(conn, cfg, file_id, new_name): + """Datei im selben Ordner umbenennen — Platte + DB.""" + row = store.get_file_row(conn, file_id) + if not row: + raise GrottiError("Die Datei fehlt.", "datei", 404) + src = Path(row.get("stored_path") or "") + if not src.is_file(): + raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404) + if not path_allowed_for_delete(str(src), cfg): + raise GrottiError("Umbenennen außerhalb von Slice-Ordner oder Eingang ist nicht erlaubt.", "datei") + final = _ensure_print_suffix(new_name, row.get("name") or src.name) + if not final or final in (".", ".."): + 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")) + if dest.exists(): + raise GrottiError("Unter diesem Namen liegt schon eine Datei.", "datei") + try: + src.rename(dest) + except OSError as exc: + raise GrottiError("Umbenennen fehlgeschlagen: %s" % exc, "datei") from exc + stat = dest.stat() + store.update_file_location( + conn, + file_id, + str(dest.resolve()), + name=dest.name, + size_bytes=stat.st_size, + mtime=stat.st_mtime, + ) + row = store.get_file_row(conn, file_id) + return store.file_out(row, watch_dir=cfg.get("watch_dir")) + + _PRINT_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf") diff --git a/grotti/moonraker.py b/grotti/moonraker.py index 3e58296..a339d42 100644 --- a/grotti/moonraker.py +++ b/grotti/moonraker.py @@ -917,11 +917,27 @@ def last_filament_gcode_hint(host, limit=40): return "" +def _mmu_tool_index(mmu): + """Aktives ACE/MMU-Tool, sonst None. −1 / fehlend = kein Tool (Anycubic-Normalfall).""" + if not isinstance(mmu, dict): + return None + tool = mmu.get("tool") + if not isinstance(tool, (int, float)) or isinstance(tool, bool): + tool = mmu.get("gate") + if not isinstance(tool, (int, float)) or isinstance(tool, bool): + return None + tool_i = int(tool) + return tool_i if tool_i >= 0 else None + + def filament_out_hint(mmu, message="", host=None): """Vermutetes Material-Ende bei Pause: (code, Text) oder (None, None). - Quellen: print_stats.message, ACE/mmu (active_filament.empty, gate_status), - optional G-code-Store (nur wenn host gesetzt). + Quellen: print_stats.message, mmu.reason_for_pause, gate_status bei aktivem Tool, + active_filament.empty nur mit aktivem Tool, optional G-code-Store (host). + + Nicht: active_filament.empty allein — Anycubic ACE meldet empty=true auch + beim normalen Druck und bei manueller Pause (tool/gate = −1). """ text = (message or "").strip() if text and _FILAMENT_HINT_RE.search(text): @@ -930,33 +946,35 @@ def filament_out_hint(mmu, message="", host=None): return "message", text[:160] if isinstance(mmu, dict) and mmu: + rfp = (mmu.get("reason_for_pause") or "").strip() + if rfp and _FILAMENT_HINT_RE.search(rfp): + if "10107" in rfp.lower(): + return "reason", "Filament gerissen oder leer (10107)" + return "reason", rfp[:160] + + tool_i = _mmu_tool_index(mmu) + states = mmu.get("gate_status") + # gate_status==0 nur, wenn Status differenziert (nicht alle 0 = Anycubic-Unknown) + if ( + tool_i is not None + and isinstance(states, list) + and tool_i < len(states) + and int(states[tool_i] or 0) == 0 + and any(int(s or 0) != 0 for s in states) + ): + return "gate_empty", "ACE-Platz %d leer" % (tool_i + 1) + active = mmu.get("active_filament") - if isinstance(active, dict) and ( - active.get("empty") is True - or str(active.get("empty") or "").lower() in ("1", "true") + if ( + tool_i is not None + and isinstance(active, dict) + and ( + active.get("empty") is True + or str(active.get("empty") or "").lower() in ("1", "true") + ) ): return "active_empty", "Aktives Filament leer (ACE)" - tool = mmu.get("tool") - if not isinstance(tool, (int, float)) or isinstance(tool, bool): - tool = mmu.get("gate") - if isinstance(tool, (int, float)) and not isinstance(tool, bool) and int(tool) >= 0: - tool_i = int(tool) - states = mmu.get("gate_status") - if isinstance(states, list) and tool_i < len(states) and states[tool_i] == 0: - return "gate_empty", "ACE-Platz %d leer" % (tool_i + 1) - - # ACE pausiert und kein aktives Filament → oft Runout ohne leeren gate_status - if mmu.get("is_paused") and isinstance(active, dict): - kind = ( - active.get("filament_type") - or active.get("type") - or active.get("material") - or "" - ) - if not str(kind).strip(): - return "mmu_paused", "ACE pausiert — kein Filament aktiv" - if host: hint = last_filament_gcode_hint(host) if hint: diff --git a/grotti/parse.py b/grotti/parse.py index a58665b..08691a4 100644 --- a/grotti/parse.py +++ b/grotti/parse.py @@ -17,6 +17,8 @@ TRAILER_RE = re.compile(rb";\s*referenced_tools\s*=", re.I) RINKHALS_FINGERPRINT = b"; processed by MmuAcePatcher" _SLICER_RE = re.compile(rb"^;.*generated by ([A-Za-z]*) .*$|^; (BambuStudio) .*$", re.I) _TOOL_RE = re.compile(rb"^(?:MMU_CHANGE_TOOL(?:_STANDALONE)? .*?TOOL=|T)(\d{1,2})", re.I) +# Orca `; filament_colour_type` — gklib parsed wie `filament_colour` und panickt (10111). +_COLOUR_TYPE_LINE_RE = re.compile(rb"(?m)^;\s*filament_colour_type\b[^\n]*\n?") _AUTHORIZED_SLICERS = { "prusaslicer", "superslicer", @@ -244,6 +246,36 @@ def _paint_tool_indexes(content): return found +def gcode_is_multicolor(content, tools=None): + """Mehr als ein Slicer-/ACE-Tool → Multicolor (Header nicht antasten).""" + if not content or content[:2] == b"PK": + return False + indexes = set() + for value in tools or []: + try: + indexes.add(int(value)) + except (TypeError, ValueError): + continue + for line in _gcode_lines(content): + match = _TOOL_RE.match(line) + if match: + indexes.add(int(match.group(1))) + indexes |= _paint_tool_indexes(content) + return len(indexes) > 1 + + +def strip_filament_colour_type(content, tools=None): + """`; filament_colour_type` nur bei Einfarbig entfernen (gklib 10111). + + Multicolor unverändert — ACE/paint_index und `; filament_colour` bleiben. + """ + if not content or content[:2] == b"PK": + return content + if gcode_is_multicolor(content, tools=tools): + return content + return _COLOUR_TYPE_LINE_RE.sub(b"", content) + + def prepare_rinkhals_gcode(content, tools=None): """Kopfzeile für Rinkhals plus `; referenced_tools`, wenn sie fehlt. @@ -253,6 +285,7 @@ def prepare_rinkhals_gcode(content, tools=None): """ if not content or content[:2] == b"PK": return content + content = strip_filament_colour_type(content, tools=tools) first_end = content.find(b"\n") first = content if first_end < 0 else content[:first_end] if first.endswith(b"\r"): diff --git a/grotti/store.py b/grotti/store.py index 789d20a..46d442c 100644 --- a/grotti/store.py +++ b/grotti/store.py @@ -2690,6 +2690,26 @@ def update_print_queue(conn, item_id, fields): return print_queue_out(fresh, fresh.get("filename")) if fresh else None +_PROFILE_VARIANTS = frozenset({"meta", "matte", "matt", "silk", "plus", "+", "hf", "cf"}) +_PROFILE_TYPES = frozenset({"pla", "petg", "abs", "asa", "tpu", "pa", "pc", "hips", "pva"}) + + +def _profile_tokens(profile): + return (profile or "").strip().lower().replace("_", " ").replace("-", " ").split() + + +def _profile_has_variant(profile): + return bool(_PROFILE_VARIANTS.intersection(_profile_tokens(profile))) + + +def _profile_base_type(profile): + """Letzter Material-Typ im Profilnamen (Sunlu PLA → pla).""" + for token in reversed(_profile_tokens(profile)): + if token in _PROFILE_TYPES: + return token + return "" + + def _profile_matches(need_profile, spool_name): """Sorte: exakter Katalogname (Meta ≠ Matte ≠ PLA). Zusatz nur hinter dem Namen.""" a = (need_profile or "").strip().lower() @@ -2705,7 +2725,7 @@ def _profile_matches(need_profile, spool_name): 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"}: + if first in _PROFILE_VARIANTS: return False return True return False @@ -2797,21 +2817,34 @@ 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() + + def _by_type(kind): + kind = (kind or "").strip().lower() + 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"]) + ] + 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"]) - ] + if by_profile: + 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 + # Basis-Profil ohne Meta/Matte/Silk: Marke egal — Typ reicht (Sunlu PLA ↔ Eryone PLA). + # Spulen mit Variante im Namen bleiben draußen (kein Meta für „nur PLA“). + if not _profile_has_variant(profile): + kind = _profile_base_type(profile) or (need.get("material_type") or "") + if kind: + return [ + h for h in _by_type(kind) + if not _profile_has_variant(h.get("material_name") or "") + ] + return [] + return _by_type(need.get("material_type") or "") def material_fit_score(conn, host, file_id): diff --git a/grotti/version.py b/grotti/version.py index d76592b..c5e9d4c 100644 --- a/grotti/version.py +++ b/grotti/version.py @@ -1,3 +1,3 @@ """Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen.""" -__version__ = "3.3.0" +__version__ = "3.4.0" diff --git a/grotti/watcher.py b/grotti/watcher.py index ea01a00..c35250e 100644 --- a/grotti/watcher.py +++ b/grotti/watcher.py @@ -95,6 +95,30 @@ class StateWatcher: self._offline_hits[host] = hits if hits >= 2 and not prev_offline: self._emit(conn, host, name, "offline", "%s ist offline." % name, filename) + # Nach Crash/Shutdown (503/gklib) bleibt sonst „druckt“ im Protokoll. + if hits >= 2: + dangling = store.find_live_run(conn, host) + if dangling: + closed = store.complete_open_run( + conn, dangling["id"], state="interrupted" + ) + if closed: + label = dangling.get("filename") or filename or "offline" + self._emit( + conn, + host, + name, + "abbruch", + "%s: Druck unterbrochen (Drucker offline)." % name, + label, + closed["id"], + ) + try: + from grotti import jobs + + jobs.apply_deduction(conn, closed, self.cfg["energy"]) + except Exception as exc: + print("Watcher Abzug offline %s: %s" % (host, exc), flush=True) self._prev[host] = {"state": "offline", "offline": True, "filename": filename} return diff --git a/tests/test_bedarf.py b/tests/test_bedarf.py index 5c06f05..610cf36 100644 --- a/tests/test_bedarf.py +++ b/tests/test_bedarf.py @@ -63,6 +63,30 @@ class BedarfTests(unittest.TestCase): out = compute(conn) self.assertEqual(out["shortfall"], []) + def test_slicer_hex_matches_named_stock(self): + """Slice #26A69A vs. Spule „Grau“ — 1 kg reicht, kein Fehlalarm.""" + conn = MagicMock() + conn.execute.side_effect = self._rows( + [ + { + "id": 1, + "spool_id": None, + "repeat_total": 1, + "repeat_done": 0, + "weight_g": 49.23, + "filaments": [{"type": "PLA", "color": "#26A69A"}], + "spool_material": None, + "spool_color": None, + }, + ], + [ + {"material_type": "PLA", "color": "Grau", "remaining_g": 1000.0}, + ], + ) + out = compute(conn) + self.assertEqual(out["shortfall"], []) + self.assertAlmostEqual(out["needed"][0]["weight_g"], 49.23) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_colour_type_strip.py b/tests/test_colour_type_strip.py new file mode 100644 index 0000000..1d449b0 --- /dev/null +++ b/tests/test_colour_type_strip.py @@ -0,0 +1,44 @@ +"""Einfarbig: filament_colour_type strippen; Multicolor unangetastet.""" + +import unittest + +from grotti.parse import gcode_is_multicolor, prepare_rinkhals_gcode, strip_filament_colour_type + + +class ColourTypeStripTest(unittest.TestCase): + def test_strip_single_color(self): + raw = ( + b"; generated by OrcaSlicer 2.3.0\n" + b"; filament_colour = #26A69A\n" + b"; filament_colour_type = 1\n" + b"G28\n" + b"T0\n" + b"G1 X10\n" + ) + out = strip_filament_colour_type(raw) + self.assertNotIn(b"filament_colour_type", out) + self.assertIn(b"filament_colour = #26A69A", out) + self.assertFalse(gcode_is_multicolor(raw)) + + def test_keep_multicolor(self): + raw = ( + b"; generated by OrcaSlicer 2.3.0\n" + b"; filament_colour = #111111;#222222\n" + b"; filament_colour_type = 0;1\n" + b"T0\n" + b"G1 X1\n" + b"T1\n" + b"G1 X2\n" + ) + self.assertTrue(gcode_is_multicolor(raw)) + out = strip_filament_colour_type(raw) + self.assertIn(b"filament_colour_type = 0;1", out) + + def test_prepare_strips_single(self): + raw = b"; filament_colour_type = 1\nG28\nT0\n" + out = prepare_rinkhals_gcode(raw) + self.assertNotIn(b"filament_colour_type", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_filament_pause.py b/tests/test_filament_pause.py index eb5d39a..db5902c 100644 --- a/tests/test_filament_pause.py +++ b/tests/test_filament_pause.py @@ -11,11 +11,43 @@ class FilamentOutHintTests(unittest.TestCase): self.assertEqual(code, "message") self.assertIn("10107", text) - def test_active_filament_empty(self): - code, text = filament_out_hint({"active_filament": {"empty": True}}) + def test_reason_for_pause(self): + code, text = filament_out_hint({"reason_for_pause": "Filament runout"}, "") + self.assertEqual(code, "reason") + self.assertIn("runout", text.lower()) + + def test_active_filament_empty_needs_active_tool(self): + code, text = filament_out_hint({ + "tool": 1, + "gate_status": [1, 1, 1, 1], + "active_filament": {"empty": True}, + }) self.assertEqual(code, "active_empty") self.assertIn("leer", text.lower()) + def test_anycubic_empty_without_tool_is_not_runout(self): + # Live A2/Kobra 3: empty=true, tool=-1, alle gate_status 0 — auch beim Druck + code, text = filament_out_hint({ + "tool": -1, + "gate": -1, + "is_paused": False, + "gate_status": [0, 0, 0, 0], + "active_filament": {"empty": True, "material": ""}, + "reason_for_pause": "", + }) + self.assertIsNone(code) + self.assertIsNone(text) + + def test_manual_pause_anycubic_not_material(self): + code, text = filament_out_hint({ + "tool": -1, + "is_paused": True, + "gate_status": [0, 0, 0, 0], + "active_filament": {"empty": True}, + }, "paused") + self.assertIsNone(code) + self.assertIsNone(text) + def test_gate_status_zero_for_tool(self): code, text = filament_out_hint({ "tool": 2, @@ -25,13 +57,15 @@ class FilamentOutHintTests(unittest.TestCase): self.assertEqual(code, "gate_empty") self.assertIn("3", text) - def test_mmu_paused_without_active_type(self): + def test_all_gates_zero_not_empty_signal(self): + # Anycubic: gate_status oft durchgängig 0 — kein Runout-Beweis code, text = filament_out_hint({ - "is_paused": True, - "active_filament": {}, + "tool": 0, + "gate_status": [0, 0, 0, 0], + "active_filament": {"type": "PLA", "empty": False}, }) - self.assertEqual(code, "mmu_paused") - self.assertTrue(text) + self.assertIsNone(code) + self.assertIsNone(text) def test_no_hint_for_plain_pause(self): code, text = filament_out_hint({"tool": 0, "gate_status": [1, 1]}, "paused") diff --git a/tests/test_material_routing.py b/tests/test_material_routing.py index 81699c7..2a190cd 100644 --- a/tests/test_material_routing.py +++ b/tests/test_material_routing.py @@ -17,6 +17,12 @@ class ProfileMatchTests(unittest.TestCase): def test_empty_profile_always_ok(self): self.assertTrue(store._profile_matches("", "Sunlu PLA Meta")) + def test_base_profile_helpers(self): + self.assertFalse(store._profile_has_variant("Sunlu PLA")) + self.assertTrue(store._profile_has_variant("Sunlu PLA Meta")) + self.assertEqual(store._profile_base_type("Sunlu PLA"), "pla") + self.assertEqual(store._profile_base_type("Generic PETG"), "petg") + class MatchSpoolsTests(unittest.TestCase): def setUp(self): @@ -37,8 +43,22 @@ class MatchSpoolsTests(unittest.TestCase): "material_name": "sunlu pla matte", "ace_slot": 2, }, + { + "spool_id": 3, + "material_type": "pla", + "color": "", + "remaining_g": 900, + "material_name": "eryone pla", + "ace_slot": None, + }, ] + def test_base_sunlu_pla_matches_plain_pla_not_meta(self): + need = {"profile": "Sunlu PLA", "material_type": "pla", "color": "", "paint_index": 0} + matched = store._match_spools_for_need(need, self.have) + ids = {m["spool_id"] for m in matched} + self.assertEqual(ids, {3}) # Eryone PLA; Meta/Matte nicht + def test_picks_meta_not_matte(self): need = {"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "", "paint_index": 0} matched = store._match_spools_for_need(need, self.have) diff --git a/web/app.py b/web/app.py index 962fca0..1c5fd3e 100644 --- a/web/app.py +++ b/web/app.py @@ -1233,6 +1233,14 @@ def create_app(cfg): moved = library.move_file(conn(), cfg, file_id, folder) return jsonify(moved) + @app.post("/api/v1/dateien//umbenennen") + def file_rename(file_id): + data = body() + name = (data.get("name") or "").strip() + if not name: + raise GrottiError("Neuer Dateiname fehlt.", "datei") + return jsonify(library.rename_library_file(conn(), cfg, file_id, name)) + @app.post("/api/v1/dateien") def file_upload(): upload = request.files.get("file") diff --git a/web/templates/dateien.html b/web/templates/dateien.html index cad156c..e618ac4 100644 --- a/web/templates/dateien.html +++ b/web/templates/dateien.html @@ -360,17 +360,37 @@ function spoolWarnText() { const hosts = selectedHosts(); const fileTypes = ((file.filaments || []).map((f) => (f.type || "").toLowerCase()).filter(Boolean)); const fileProfiles = ((file.filaments || []).map((f) => (f.profile || "").toLowerCase()).filter(Boolean)); + const PROFILE_VARIANTS = ["meta", "matte", "matt", "silk", "plus", "hf", "cf"]; + function profileHasVariant(want) { + return PROFILE_VARIANTS.some((v) => new RegExp("\\b" + v + "\\b", "i").test(want || "")); + } + function profileBaseType(want) { + const m = String(want || "").toLowerCase().match(/\b(pla|petg|abs|asa|tpu|pa|pc|hips|pva)\b/); + return m ? m[1] : ""; + } function profileOk(spoolRows) { if (!fileProfiles.length) return true; const names = spoolRows.map((s) => (s.name || "").toLowerCase()).filter(Boolean); - if (!names.length) return true; - return fileProfiles.every((want) => names.some((n) => { - if (n === want) return true; - if (!n.startsWith(want) || /[a-z0-9]/i.test(n.charAt(want.length) || "")) return false; - const rest = n.slice(want.length).replace(/^[\s·\-_|/]+/, ""); - const first = (rest.split(/\s+/)[0] || "").toLowerCase(); - return !["meta", "matte", "matt", "silk", "plus", "hf", "cf"].includes(first); - })); + const types = spoolRows.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean); + if (!names.length && !types.length) return true; + return fileProfiles.every((want) => { + if (names.some((n) => { + if (n === want) return true; + if (!n.startsWith(want) || /[a-z0-9]/i.test(n.charAt(want.length) || "")) return false; + const rest = n.slice(want.length).replace(/^[\s·\-_|/]+/, ""); + const first = (rest.split(/\s+/)[0] || "").toLowerCase(); + return !PROFILE_VARIANTS.includes(first); + })) return true; + // Basis-Profil (nur „… PLA“): Typ reicht, Marke egal — keine Meta/Matte-Spule + if (profileHasVariant(want)) return false; + const kind = profileBaseType(want); + if (!kind) return true; + return spoolRows.some((s) => { + const t = (s.material_type || "").toLowerCase(); + const n = (s.name || "").toLowerCase(); + return t === kind && !profileHasVariant(n); + }); + }); } for (const host of hosts) { const name = (printers.find((p) => p.host === host) || {}).name || host; @@ -827,11 +847,12 @@ function thumbHtml(file) { function libItemHtml(file) { const project = projects.find((p) => String(p.id) === String(file.project_id)); + const rename = ``; const del = ``; return `
${thumbHtml(file)} ${escapeHtml(file.name)}${alwaysMeta(file, project)} - ${moveSelectHtml(file)}${infoPopupHtml(file)}${del} + ${moveSelectHtml(file)}${infoPopupHtml(file)}${rename}${del}
`; } @@ -1530,6 +1551,24 @@ document.getElementById("lib-list").addEventListener("click", (event) => { event.stopPropagation(); return; } + const ren = event.target.closest("[data-rename]"); + if (ren) { + event.stopPropagation(); + const file = files.find((f) => String(f.id) === String(ren.dataset.rename)); + if (!file) return; + const next = window.prompt("Neuer Dateiname:", file.name); + if (next == null) return; + const trimmed = String(next).trim(); + if (!trimmed || trimmed === file.name) return; + api("/dateien/" + file.id + "/umbenennen", { method: "POST", json: { name: trimmed } }) + .then(async (saved) => { + toast("Umbenannt in „" + (saved.name || trimmed) + "“.", "good"); + await load(); + if (saved && saved.id) selectFile(saved.id); + }) + .catch((error) => toast(error.message || String(error), "bad")); + return; + } const del = event.target.closest("[data-delete]"); if (del) { event.stopPropagation();