Grotti 3.4.0: Dateien umbenennen, Bedarf/PLA-Match, Pause- und gklib-Fixes.
This commit is contained in:
1 parent
67a89d1794
commit
dd0e7ebc6b
16 files changed
+430
-58
No files matched your search
+27
-2
@@ -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({
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
+43
-25
@@ -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:
|
||||
|
||||
@@ -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"):
|
||||
|
||||
+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
|
||||
|
||||
|
||||
_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):
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
"""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
|
||||
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
|
||||
|
||||
|
||||
Reference in new issue
Block a user