Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd0e7ebc6b |
No files matched your search
@@ -4,6 +4,20 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [3.3.0] – 2026-10-18
|
||||||
|
|
||||||
### Neu
|
### Neu
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Stand **18.10.2026**. Seite `/dateien` — Bibliothek links, Senden rechts.
|
|||||||
- Info-Popup mit Slice-Metadaten (Kopf und Footer)
|
- Info-Popup mit Slice-Metadaten (Kopf und Footer)
|
||||||
- **Hochladen** per Knopf oder Drag & Drop
|
- **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)
|
- 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`)
|
- **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
|
- **Ordner neu lesen**, wenn unter Einstellungen ein Slice-Ordner gesetzt ist
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
## Wo nachschauen
|
||||||
|
|
||||||
- Sidebar: Alerts, Läuft, Wartet, Senden
|
- Sidebar: Alerts, Läuft, Wartet, Senden
|
||||||
|
|||||||
+27
-2
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
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):
|
def _group_key(material_type, color):
|
||||||
return (
|
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):
|
def compute(conn):
|
||||||
"""Wartende Jobs × Dateigewicht vs. Spulenrest, gruppiert nach Sorte/Farbe."""
|
"""Wartende Jobs × Dateigewicht vs. Spulenrest, gruppiert nach Sorte/Farbe."""
|
||||||
queue_rows = conn.execute(
|
queue_rows = conn.execute(
|
||||||
@@ -52,6 +77,7 @@ def compute(conn):
|
|||||||
"""
|
"""
|
||||||
SELECT material_type, color, COALESCE(SUM(remaining_g), 0) AS remaining_g
|
SELECT material_type, color, COALESCE(SUM(remaining_g), 0) AS remaining_g
|
||||||
FROM spools
|
FROM spools
|
||||||
|
WHERE archived_at IS NULL
|
||||||
GROUP BY material_type, color
|
GROUP BY material_type, color
|
||||||
"""
|
"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
@@ -67,8 +93,7 @@ def compute(conn):
|
|||||||
)
|
)
|
||||||
shortfall = []
|
shortfall = []
|
||||||
for item in needed:
|
for item in needed:
|
||||||
key = _group_key(item["material_type"], item["color"])
|
have = _stock_for_need(stock_map, item["material_type"], item["color"])
|
||||||
have = stock_map.get(key, 0.0)
|
|
||||||
gap = item["weight_g"] - have
|
gap = item["weight_g"] - have
|
||||||
if gap > 0.5:
|
if gap > 0.5:
|
||||||
shortfall.append({
|
shortfall.append({
|
||||||
|
|||||||
@@ -334,6 +334,57 @@ def delete_library_file(conn, cfg, file_id):
|
|||||||
return row
|
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")
|
_PRINT_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+43
-25
@@ -917,11 +917,27 @@ def last_filament_gcode_hint(host, limit=40):
|
|||||||
return ""
|
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):
|
def filament_out_hint(mmu, message="", host=None):
|
||||||
"""Vermutetes Material-Ende bei Pause: (code, Text) oder (None, None).
|
"""Vermutetes Material-Ende bei Pause: (code, Text) oder (None, None).
|
||||||
|
|
||||||
Quellen: print_stats.message, ACE/mmu (active_filament.empty, gate_status),
|
Quellen: print_stats.message, mmu.reason_for_pause, gate_status bei aktivem Tool,
|
||||||
optional G-code-Store (nur wenn host gesetzt).
|
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()
|
text = (message or "").strip()
|
||||||
if text and _FILAMENT_HINT_RE.search(text):
|
if text and _FILAMENT_HINT_RE.search(text):
|
||||||
@@ -930,33 +946,35 @@ def filament_out_hint(mmu, message="", host=None):
|
|||||||
return "message", text[:160]
|
return "message", text[:160]
|
||||||
|
|
||||||
if isinstance(mmu, dict) and mmu:
|
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")
|
active = mmu.get("active_filament")
|
||||||
if isinstance(active, dict) and (
|
if (
|
||||||
active.get("empty") is True
|
tool_i is not None
|
||||||
or str(active.get("empty") or "").lower() in ("1", "true")
|
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)"
|
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:
|
if host:
|
||||||
hint = last_filament_gcode_hint(host)
|
hint = last_filament_gcode_hint(host)
|
||||||
if hint:
|
if hint:
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ TRAILER_RE = re.compile(rb";\s*referenced_tools\s*=", re.I)
|
|||||||
RINKHALS_FINGERPRINT = b"; processed by MmuAcePatcher"
|
RINKHALS_FINGERPRINT = b"; processed by MmuAcePatcher"
|
||||||
_SLICER_RE = re.compile(rb"^;.*generated by ([A-Za-z]*) .*$|^; (BambuStudio) .*$", re.I)
|
_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)
|
_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 = {
|
_AUTHORIZED_SLICERS = {
|
||||||
"prusaslicer",
|
"prusaslicer",
|
||||||
"superslicer",
|
"superslicer",
|
||||||
@@ -244,6 +246,36 @@ def _paint_tool_indexes(content):
|
|||||||
return found
|
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):
|
def prepare_rinkhals_gcode(content, tools=None):
|
||||||
"""Kopfzeile für Rinkhals plus `; referenced_tools`, wenn sie fehlt.
|
"""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":
|
if not content or content[:2] == b"PK":
|
||||||
return content
|
return content
|
||||||
|
content = strip_filament_colour_type(content, tools=tools)
|
||||||
first_end = content.find(b"\n")
|
first_end = content.find(b"\n")
|
||||||
first = content if first_end < 0 else content[:first_end]
|
first = content if first_end < 0 else content[:first_end]
|
||||||
if first.endswith(b"\r"):
|
if first.endswith(b"\r"):
|
||||||
|
|||||||
+47
-14
@@ -2690,6 +2690,26 @@ def update_print_queue(conn, item_id, fields):
|
|||||||
return print_queue_out(fresh, fresh.get("filename")) if fresh else None
|
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):
|
def _profile_matches(need_profile, spool_name):
|
||||||
"""Sorte: exakter Katalogname (Meta ≠ Matte ≠ PLA). Zusatz nur hinter dem Namen."""
|
"""Sorte: exakter Katalogname (Meta ≠ Matte ≠ PLA). Zusatz nur hinter dem Namen."""
|
||||||
a = (need_profile or "").strip().lower()
|
a = (need_profile or "").strip().lower()
|
||||||
@@ -2705,7 +2725,7 @@ def _profile_matches(need_profile, spool_name):
|
|||||||
rest = b[len(a):].lstrip(" ·-–_|/")
|
rest = b[len(a):].lstrip(" ·-–_|/")
|
||||||
# Nächstes Wort darf keine bekannte PLA-/PETG-Variante sein
|
# Nächstes Wort darf keine bekannte PLA-/PETG-Variante sein
|
||||||
first = (rest.split() or [""])[0]
|
first = (rest.split() or [""])[0]
|
||||||
if first in {"meta", "matte", "matt", "silk", "plus", "+", "hf", "cf"}:
|
if first in _PROFILE_VARIANTS:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -2797,21 +2817,34 @@ def _match_spools_for_need(need, have, used_ids=None):
|
|||||||
used = used_ids or set()
|
used = used_ids or set()
|
||||||
candidates = [h for h in have if h.get("spool_id") not in used]
|
candidates = [h for h in have if h.get("spool_id") not in used]
|
||||||
profile = (need.get("profile") or "").strip()
|
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:
|
if profile:
|
||||||
by_profile = [h for h in candidates if _profile_matches(profile, h.get("material_name"))]
|
by_profile = [h for h in candidates if _profile_matches(profile, h.get("material_name"))]
|
||||||
if not by_profile:
|
if by_profile:
|
||||||
return []
|
colored = [
|
||||||
colored = [
|
h for h in by_profile
|
||||||
h for h in by_profile
|
if not need.get("color") or h["color"] == need["color"] or not h["color"]
|
||||||
if not need.get("color") or h["color"] == need["color"] or not h["color"]
|
]
|
||||||
]
|
return colored or by_profile
|
||||||
return colored or by_profile
|
# Basis-Profil ohne Meta/Matte/Silk: Marke egal — Typ reicht (Sunlu PLA ↔ Eryone PLA).
|
||||||
kind = need.get("material_type") or ""
|
# Spulen mit Variante im Namen bleiben draußen (kein Meta für „nur PLA“).
|
||||||
return [
|
if not _profile_has_variant(profile):
|
||||||
h for h in candidates
|
kind = _profile_base_type(profile) or (need.get("material_type") or "")
|
||||||
if (not kind or h["material_type"] == kind)
|
if kind:
|
||||||
and (not need.get("color") or h["color"] == need["color"] or not h["color"])
|
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):
|
def material_fit_score(conn, host, file_id):
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
|
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
|
||||||
|
|
||||||
__version__ = "3.3.0"
|
__version__ = "3.4.0"
|
||||||
@@ -95,6 +95,30 @@ class StateWatcher:
|
|||||||
self._offline_hits[host] = hits
|
self._offline_hits[host] = hits
|
||||||
if hits >= 2 and not prev_offline:
|
if hits >= 2 and not prev_offline:
|
||||||
self._emit(conn, host, name, "offline", "%s ist offline." % name, filename)
|
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}
|
self._prev[host] = {"state": "offline", "offline": True, "filename": filename}
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,30 @@ class BedarfTests(unittest.TestCase):
|
|||||||
out = compute(conn)
|
out = compute(conn)
|
||||||
self.assertEqual(out["shortfall"], [])
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -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()
|
||||||
@@ -11,11 +11,43 @@ class FilamentOutHintTests(unittest.TestCase):
|
|||||||
self.assertEqual(code, "message")
|
self.assertEqual(code, "message")
|
||||||
self.assertIn("10107", text)
|
self.assertIn("10107", text)
|
||||||
|
|
||||||
def test_active_filament_empty(self):
|
def test_reason_for_pause(self):
|
||||||
code, text = filament_out_hint({"active_filament": {"empty": True}})
|
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.assertEqual(code, "active_empty")
|
||||||
self.assertIn("leer", text.lower())
|
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):
|
def test_gate_status_zero_for_tool(self):
|
||||||
code, text = filament_out_hint({
|
code, text = filament_out_hint({
|
||||||
"tool": 2,
|
"tool": 2,
|
||||||
@@ -25,13 +57,15 @@ class FilamentOutHintTests(unittest.TestCase):
|
|||||||
self.assertEqual(code, "gate_empty")
|
self.assertEqual(code, "gate_empty")
|
||||||
self.assertIn("3", text)
|
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({
|
code, text = filament_out_hint({
|
||||||
"is_paused": True,
|
"tool": 0,
|
||||||
"active_filament": {},
|
"gate_status": [0, 0, 0, 0],
|
||||||
|
"active_filament": {"type": "PLA", "empty": False},
|
||||||
})
|
})
|
||||||
self.assertEqual(code, "mmu_paused")
|
self.assertIsNone(code)
|
||||||
self.assertTrue(text)
|
self.assertIsNone(text)
|
||||||
|
|
||||||
def test_no_hint_for_plain_pause(self):
|
def test_no_hint_for_plain_pause(self):
|
||||||
code, text = filament_out_hint({"tool": 0, "gate_status": [1, 1]}, "paused")
|
code, text = filament_out_hint({"tool": 0, "gate_status": [1, 1]}, "paused")
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ class ProfileMatchTests(unittest.TestCase):
|
|||||||
def test_empty_profile_always_ok(self):
|
def test_empty_profile_always_ok(self):
|
||||||
self.assertTrue(store._profile_matches("", "Sunlu PLA Meta"))
|
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):
|
class MatchSpoolsTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -37,8 +43,22 @@ class MatchSpoolsTests(unittest.TestCase):
|
|||||||
"material_name": "sunlu pla matte",
|
"material_name": "sunlu pla matte",
|
||||||
"ace_slot": 2,
|
"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):
|
def test_picks_meta_not_matte(self):
|
||||||
need = {"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "", "paint_index": 0}
|
need = {"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "", "paint_index": 0}
|
||||||
matched = store._match_spools_for_need(need, self.have)
|
matched = store._match_spools_for_need(need, self.have)
|
||||||
|
|||||||
@@ -1233,6 +1233,14 @@ def create_app(cfg):
|
|||||||
moved = library.move_file(conn(), cfg, file_id, folder)
|
moved = library.move_file(conn(), cfg, file_id, folder)
|
||||||
return jsonify(moved)
|
return jsonify(moved)
|
||||||
|
|
||||||
|
@app.post("/api/v1/dateien/<int:file_id>/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")
|
@app.post("/api/v1/dateien")
|
||||||
def file_upload():
|
def file_upload():
|
||||||
upload = request.files.get("file")
|
upload = request.files.get("file")
|
||||||
|
|||||||
@@ -360,17 +360,37 @@ function spoolWarnText() {
|
|||||||
const hosts = selectedHosts();
|
const hosts = selectedHosts();
|
||||||
const fileTypes = ((file.filaments || []).map((f) => (f.type || "").toLowerCase()).filter(Boolean));
|
const fileTypes = ((file.filaments || []).map((f) => (f.type || "").toLowerCase()).filter(Boolean));
|
||||||
const fileProfiles = ((file.filaments || []).map((f) => (f.profile || "").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) {
|
function profileOk(spoolRows) {
|
||||||
if (!fileProfiles.length) return true;
|
if (!fileProfiles.length) return true;
|
||||||
const names = spoolRows.map((s) => (s.name || "").toLowerCase()).filter(Boolean);
|
const names = spoolRows.map((s) => (s.name || "").toLowerCase()).filter(Boolean);
|
||||||
if (!names.length) return true;
|
const types = spoolRows.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean);
|
||||||
return fileProfiles.every((want) => names.some((n) => {
|
if (!names.length && !types.length) return true;
|
||||||
if (n === want) return true;
|
return fileProfiles.every((want) => {
|
||||||
if (!n.startsWith(want) || /[a-z0-9]/i.test(n.charAt(want.length) || "")) return false;
|
if (names.some((n) => {
|
||||||
const rest = n.slice(want.length).replace(/^[\s·\-_|/]+/, "");
|
if (n === want) return true;
|
||||||
const first = (rest.split(/\s+/)[0] || "").toLowerCase();
|
if (!n.startsWith(want) || /[a-z0-9]/i.test(n.charAt(want.length) || "")) return false;
|
||||||
return !["meta", "matte", "matt", "silk", "plus", "hf", "cf"].includes(first);
|
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) {
|
for (const host of hosts) {
|
||||||
const name = (printers.find((p) => p.host === host) || {}).name || host;
|
const name = (printers.find((p) => p.host === host) || {}).name || host;
|
||||||
@@ -827,11 +847,12 @@ function thumbHtml(file) {
|
|||||||
|
|
||||||
function libItemHtml(file) {
|
function libItemHtml(file) {
|
||||||
const project = projects.find((p) => String(p.id) === String(file.project_id));
|
const project = projects.find((p) => String(p.id) === String(file.project_id));
|
||||||
|
const rename = `<button type="button" class="icon-btn" data-rename="${file.id}" title="Umbenennen">${icon("edit")}</button>`;
|
||||||
const del = `<button type="button" class="icon-btn" data-delete="${file.id}" title="Datei löschen">${icon("trash")}</button>`;
|
const del = `<button type="button" class="icon-btn" data-delete="${file.id}" title="Datei löschen">${icon("trash")}</button>`;
|
||||||
return `<div class="lib-item ${String(file.id) === String(sel.fileId) ? "on" : ""}" role="button" tabindex="0" data-file="${file.id}">
|
return `<div class="lib-item ${String(file.id) === String(sel.fileId) ? "on" : ""}" role="button" tabindex="0" data-file="${file.id}">
|
||||||
${thumbHtml(file)}
|
${thumbHtml(file)}
|
||||||
<span style="min-width:0"><span class="t" title="${escapeHtml(file.name)}">${escapeHtml(file.name)}</span><span class="m">${alwaysMeta(file, project)}</span></span>
|
<span style="min-width:0"><span class="t" title="${escapeHtml(file.name)}">${escapeHtml(file.name)}</span><span class="m">${alwaysMeta(file, project)}</span></span>
|
||||||
<span class="side">${moveSelectHtml(file)}${infoPopupHtml(file)}${del}</span>
|
<span class="side">${moveSelectHtml(file)}${infoPopupHtml(file)}${rename}${del}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1530,6 +1551,24 @@ document.getElementById("lib-list").addEventListener("click", (event) => {
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
return;
|
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]");
|
const del = event.target.closest("[data-delete]");
|
||||||
if (del) {
|
if (del) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|||||||
Reference in new issue
Block a user