Grotti 3.3.0: Projekte mit Ordner, Datei-Löschen/Verschieben, Material-Archiv, UI-Polish.

This commit is contained in:
Dominic Gundel committed 2026-10-18 20:10:32 +02:00
1 parent 4ddffbbeb1
commit 67a89d1794
45 files changed
+2856 -320

No files matched your search

+171 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+2
View File
@@ -0,0 +1,2 @@
-- Leere/alte Spulen aus der Alltagsliste nehmen
ALTER TABLE spools ADD COLUMN IF NOT EXISTS archived_at timestamptz;
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"))