diff --git a/CHANGELOG.md b/CHANGELOG.md
index e561227..b890b08 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,12 +4,35 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
## [Unreleased]
+## [2.5.1] – 2026-10-17
+
### Neu
+- Cockpit-Steuerung: ACE-Trockner starten/stoppen (Temp, Dauer), nur wenn ein ACE hängt.
+- Cockpit-Steuerung: bestückte ACE-Plätze mit Sorte und Farbe aus Grotti an den Drucker schreiben.
- Wand-Kacheln zeigen das Druck-Vorschaubild (Moonraker-Thumbnail) in der Job-Zeile.
+- **Druckbericht** `/protokoll/`: Soll/Ist-Gramm, Spulenanteile, Temperaturverlauf, Ereignisse, Charge.
+- **Statistik** `/statistik`: Auslastung, Erfolg, Verbrauch, Ausschuss, Schätztreue; CSV-Export.
+- Spulenakte unter Material: verdruckte Gramm, Ausschuss, Leer-in aus dem Wochenverbrauch.
+- Etiketten pro Druck oder Objekt (`D-YYYYMMDD-nnnn`), QR auf den Bericht; Scan von `D-…` öffnet den Bericht.
+- Spulenabzug nach Ist (`filament_used`); Abbruch nur anteilig; Mehrfarb-Anteile als Schätzwert; Rest darf ins Minus.
+- Rollenwechsel während Druck/Pause (Einlegen) teilt den Verbrauch in Abschnitte.
+
+### Geändert
+
+- Statistik: Zeitraum per Knopf (Tag, Woche, Monat, Gestern, Letzte Woche, Letzter Monat, Alles); Bis-Datum zählt inklusiv; CSV übernimmt denselben Filter.
+- Druck-Charge `D-…` wieder während des Laufs (Protokoll, Wand-Etikett, Bericht); Adoption übernimmt die Nummer auf den History-Job. Ausschuss: gedruckte Bögen verwerfen, Charge bleibt.
+- Dateien → ACE-Zuordnung: Platz-Dropdown zeigt bei bekannter Spule Material, Farbe, Name und Restgramm.
### Behoben
+- Spulenakte blieb leer: Abzug schrieb oft kein `run_spools` (hängende History-Läufe, Abzug vor Akte-Tabelle); Nachzug ohne erneuten Rest-Abzug, Abzug schreibt `run_spools` vor `deducted`.
+- Protokoll: keine Doppel-„druckt“-Zeilen mehr (Live + History); Watcher hängt am History-Job, sobald Moonraker ihn kennt; Liste blendet überzählige Live-Zeilen aus.
+- Platte geräumt: `needs_clear` nach Watcher-Ende und History-Sync (nur wenn kein Live-Druck); neuer Druckstart bricht alte Live-Läufe ab.
+- Druck-Etikett: Material schon während des Laufs (Send/ACE/Spule); QR bleibt im 70‑mm-Bogen.
+- Druckstart: Dateien mit altem `stored_path` (z. B. nach Umzug von `3DP/…/Uebersicht` nach Grotti) werden unter `data/eingang/` wiedergefunden statt „liegt nicht mehr auf der Platte“.
+- Wand/Sidebar: Dateiname springt nicht mehr alle paar Sekunden, wenn Moonraker kurz keinen Namen liefert; 3D-Pfad-Caption flackert nicht mehr mit „wird gelesen …“.
+- Dateien: Hinweis zu fehlendem ACE-Trailer nur noch bei Mehrfarb (nicht als Warnung bei Monoteilen).
- 3D-Pfad wirkte gesprenkelt: Ausdünnen behält ganze Schichten dicht (statt Stichproben entlang der Bahn); keine Parallel-Offsets.
- Toolpath-API mit `Cache-Control: no-store`, damit der Browser keinen alten Pfad behält.
- Senden: Dateinamen mit Nicht-ASCII (z. B. CJK) werden für Moonraker/Rinkhals auf ASCII normalisiert — sonst Fehler 10115 „gcode … not found“.
diff --git a/docs/Anleitung-ACE.md b/docs/Anleitung-ACE.md
index a766168..e676c1d 100644
--- a/docs/Anleitung-ACE.md
+++ b/docs/Anleitung-ACE.md
@@ -1,6 +1,6 @@
# ACE und Spulen
-Stand **15.10.2026**. Mehrfarb-Drucke brauchen vor dem Start eine Zuordnung Datei-Farbe → ACE-Platz und eine passende Spule am Drucker.
+Stand **17.10.2026**. Mehrfarb-Drucke brauchen vor dem Start eine Zuordnung Datei-Farbe → ACE-Platz und eine passende Spule am Drucker.

@@ -19,6 +19,7 @@ Etiketten mit QR: [Material und mehr](Anleitung-Material.md).
Unter **Dateien → Optionen → ACE-Zuordnung**:
- Jede Farbe aus der Datei (`paint_index`) einem **ACE-Platz** zuordnen
+- Liegt unter Material eine Spule auf dem Platz, zeigt die Liste Name, Farbe und Restgramm mit
- Ohne passende Map schreibt der Drucker oft **„cannot parse the file“** und startet nicht
- Grotti legt die Sidecar `*.gcode.acm` an und schickt sie mit
diff --git a/docs/Anleitung-Material.md b/docs/Anleitung-Material.md
index d410acf..378474c 100644
--- a/docs/Anleitung-Material.md
+++ b/docs/Anleitung-Material.md
@@ -1,6 +1,6 @@
# Material und mehr
-Stand **15.10.2026**. Spulen, Etiketten, Projekte, Protokoll, Kameras, Aktivität und Ideen.
+Stand **17.10.2026**. Spulen, Etiketten, Projekte, Protokoll, Statistik, Kameras, Aktivität und Ideen.

@@ -8,21 +8,29 @@ Stand **15.10.2026**. Spulen, Etiketten, Projekte, Protokoll, Kameras, Aktivitä
Zwei Reiter: **Spulen** und **Sorten**.
-**Spulen:** Filter Alle / Am Drucker / Regal / Wenig Rest. Formular bei Auswahl oder „Neue Spule“. Ort = Drucker · ACE/extern oder Regal. Scan-Feld im Seitenkopf (QR vom Etikett).
+**Spulen:** Filter Alle / Am Drucker / Regal / Wenig Rest. Formular bei Auswahl oder „Neue Spule“. Ort = Drucker · ACE/extern oder Regal. Scan-Feld im Seitenkopf (QR vom Etikett). Unter der ausgewählten Spule die **Akte**: verdruckte Gramm, Ausschuss, Leer-in.
**Sorten:** Profile (Dichte, Preis, Maschinen). „Spule damit anlegen“ wechselt zum Spulen-Reiter.
### Etikett und QR
-Pro Spule ein Etikett mit Chargennummer und QR. Der Code öffnet Material mit der Spule vorausgewählt. Dafür in den Einstellungen ggf. **FQDN** setzen (öffentliche URL), sonst gilt der Host aus dem Request.
+Pro Spule ein Etikett mit Chargennummer `S-…` und QR. Der Code öffnet Material mit der Spule vorausgewählt. Dafür in den Einstellungen ggf. **FQDN** setzen (öffentliche URL), sonst gilt der Host aus dem Request.
+
+Druck-Etiketten (`D-…`) kommen vom Druckbericht: ein Etikett für den ganzen Lauf oder je Objekt. Scan von `D-…` öffnet den Bericht.
## Projekte
Unter **Projekte** Aufträge mit Notiz und Kalkulation. Dateien lassen sich einem Projekt zuweisen (Bibliothek / Senden).
-## Protokoll
+## Protokoll und Statistik
-Abgeschlossene Läufe: Dauer, Gramm, Kosten, Urteil. **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option.
+Abgeschlossene Läufe: Dauer, Ist-Gramm, Kosten, Urteil. Zeile öffnet den **Druckbericht** (Soll/Ist, Spulen, Temperaturverlauf, Charge). **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option.
+
+Unter **Statistik** Auslastung, Erfolg, Verbrauch und CSV-Export. Zeitraum per Knopf (Tag, Woche, Monat, Gestern, Letzte Woche, Letzter Monat) oder eigene Von/Bis-Daten.
+
+Druck-Etiketten (`D-…`) schon während des Drucks: an der Wand **Etikett**, im Druckbericht **Etikett Druck** / **Etiketten Objekte**. Auf dem Bogen steht das Zielgewicht. Bei Ausschuss die gedruckten Bögen wegwerfen; die Charge bleibt im Protokoll.
+
+Abzug nach dem Druck nimmt das Ist aus `filament_used` (Abbruch nur anteilig). Mehrfarb-Anteile sind als Schätzwert gekennzeichnet. Restgramm darf unter 0 gehen.
## Kameras
diff --git a/docs/Anleitung-Oberflaeche.md b/docs/Anleitung-Oberflaeche.md
index b0f147d..81a33f7 100644
--- a/docs/Anleitung-Oberflaeche.md
+++ b/docs/Anleitung-Oberflaeche.md
@@ -4,7 +4,7 @@ Stand **15.10.2026**. Wie die App aufgebaut ist — Menü, Live-Leiste, Desktop
## Navigation
-Links (Desktop): **Drucker**, **Dateien**, **Kameras**, **Aktivität**, **Warteschlange**, **Protokoll**, **Material**, **Projekte**. Darunter **Einstellungen**, **Ideen**, **Doku**. Fuß: Version (öffnet die Doku).
+Links (Desktop): **Drucker**, **Dateien**, **Kameras**, **Aktivität**, **Warteschlange**, **Protokoll**, **Statistik**, **Material**, **Projekte**. Darunter **Einstellungen**, **Ideen**, **Doku**. Fuß: Version (öffnet die Doku).
Unter ca. 760 px: untere Leiste mit den wichtigsten Seiten. Das Seitenmenü lässt sich zur Icon-Rail einklappen.
diff --git a/docs/Anleitung-Wand.md b/docs/Anleitung-Wand.md
index a771f72..fdeabe3 100644
--- a/docs/Anleitung-Wand.md
+++ b/docs/Anleitung-Wand.md
@@ -36,7 +36,7 @@ Inhalt:
- **Kamera** — nur wenn eine Webcam erreichbar ist; sonst bleibt der Platz weg
- Status, Temps inkl. Bauraum wo verfügbar, Lüfter
-- **Steuerung:** Zieltemperaturen, Speed, Flow, Fan — fest vorgegebene Befehle, keine freie Konsole
+- **Steuerung:** Zieltemperaturen, Speed, Flow, Fan — fest vorgegebene Befehle, keine freie Konsole. Mit ACE zusätzlich Trockner (Temp, Dauer, Start/Stopp) und pro bestücktem Platz **an den Drucker** (Sorte und Farbe aus der Grotti-Spule)
- **E-Stop** nur bei printing/paused/heating (Zwei-Klick). Danach oft Strom aus/an
- **3D-Pfad** der laufenden oder gewählten Datei; Objekte ausschließen (mit Bestätigung)
- **Bettnetz**-Heatmap, wenn vorhanden
diff --git a/docs/internal/Chargen-Etiketten-Viewer.md b/docs/internal/Chargen-Etiketten-Viewer.md
index af2482f..1f997df 100644
--- a/docs/internal/Chargen-Etiketten-Viewer.md
+++ b/docs/internal/Chargen-Etiketten-Viewer.md
@@ -1,6 +1,6 @@
# Chargen, Etiketten und 3D-Viewer
-Stand 14.10.2026.
+Stand 17.10.2026.
## Spulen-Chargen
@@ -10,6 +10,12 @@ Scannen: auf Material den Code tippen oder per USB-Handscanner (Tastatur + Enter
Etikett: `/etikett/`, Druck über den Browser. QR per lokalem `qrcode.min.js` (`web/static/`). Basis-URL für den QR kommt aus Einstellungen → **FQDN** (`config.json` Key `fqdn`); leer = Host der aktuellen Anfrage. Beispiel: `https://grotti.example.de/material?spool=12`.
+## Druck-Chargen
+
+`D-YYYYMMDD-0001` vergibt der Watcher am aktiven Lauf (History-Job, sonst Live). `adopt_live_run` übernimmt die Nummer. Objekt-Etiketten `D-…-01` sobald Objektnamen da sind.
+
+Druck über `/etikett/lauf/?mode=druck|objekt`. Auf dem Bogen: Charge, **Zielgewicht** (Slicer-Soll), Drucker, Material (Abzug oder Send/ACE/Lauf-Spule), QR 20 mm im 70‑mm-Bogen. Wand: Knopf **Etikett** bei Druck/Pause. Urteil Ausschuss ändert die Charge nicht — gedruckte Bögen verwerfen.
+
## 3D-Pfad
`GET /api/v1/dateien//pfad` liefert einen gedünnten Toolpath (bis ca. 220 000 Punkte, plus `layer_breaks`, Cache-`version` 8) aus dem lokalen G-code. Thinning behält ganze Z-Höhen dicht und lässt nur ganze Schichten weg (sonst gesprenkelte Wände). Die Dateien-Seite und das Cockpit zeichnen ihn mit Three.js.
diff --git a/docs/internal/Druckbericht-Statistik.md b/docs/internal/Druckbericht-Statistik.md
new file mode 100644
index 0000000..6fc896c
--- /dev/null
+++ b/docs/internal/Druckbericht-Statistik.md
@@ -0,0 +1,26 @@
+# Druckbericht und Statistik
+
+Stand **17.10.2026**.
+
+## Tabellen
+
+- `runs`: zusätzlich `weight_planned_g`, `weight_used_g`, `weight_source`, `filament_used_mm`/`filament_total_mm`, `progress_last`, `estimated_s`, `chargen_nr`, `message`, `objects`
+- `run_spools`: Abzug je Spule (`is_estimate` bei Mehrfarb)
+- `run_segments`: Abschnitte bei Rollenwechsel (Einlegen während Druck/Pause)
+- `run_samples`: Temperatur/Fortschritt ca. alle 30 s (Watcher)
+- `run_labels`: Objekt-Chargen `D-…-01`
+- `events.run_id`: optionaler Bezug zum Lauf
+
+## Abzug
+
+`grotti/usage.py` rechnet Ist-Gramm aus `filament_used` / `filament_total` × Soll. Abbruch ohne Extrusion: 0. Ohne Zähler: Fortschritt × Soll (Schätzwert). Fertig ohne Messgröße: Soll bleibt, Quelle `estimate`.
+
+`apply_deduction` schreibt zuerst `run_spools`, dann `deducted`/`remaining_g` (auch unter 0); `ensure_run_chargen` oft schon während des Laufs. Ohne `run_spools` bleibt die Spulenakte leer.
+
+## Live
+
+`StateWatcher` hängt am offenen History-Job oder legt `live:…` an, schreibt Proben, vergibt `chargen_nr`, merkt Objektnamen. Vor neuem Start: `abandon_live_runs`. `sync_history` übernimmt Proben/Segmente/Charge (`adopt_live_run`, nicht auf ältere fertige Jobs derselben Datei) und setzt bei Bedarf `needs_clear`.
+
+## Statistik-Filter
+
+`since` inklusiv, reines `until` als Datum (`YYYY-MM-DD`) inklusiv (intern Folgetag exklusiv). Presets in der UI setzen Von/Bis. CSV nutzt dieselben Query-Parameter.
diff --git a/docs/internal/Features-UI.md b/docs/internal/Features-UI.md
index 4e43867..a32cd83 100644
--- a/docs/internal/Features-UI.md
+++ b/docs/internal/Features-UI.md
@@ -37,10 +37,15 @@ Stand **14.10.2026**, v1.4.0.
## Material
-- Reiter **Spulen** / **Sorten**. Scan-Feld im Seitenkopf.
-- Spulen: Filter Alle / Am Drucker / Regal / Wenig Rest + Suche. Formular nur bei Auswahl oder „Neue Spule“ (× schließt). Ort = Drucker · ACE/extern bzw. Regal.
+- Reiter **Spulen** / **Sorten**. Scan-Feld im Seitenkopf (`S-…` Spule, `D-…` Druckbericht).
+- Spulen: Filter Alle / Am Drucker / Regal / Wenig Rest + Suche. Formular nur bei Auswahl oder „Neue Spule“ (× schließt). Ort = Drucker · ACE/extern bzw. Regal. Akte mit Verbrauch und Leer-in.
- Sorten: Profile (Dichte, mm³/s, Preis, Maschinen). Anlegen/ändern/löschen; „Spule damit anlegen“ wechselt zum Spulen-Reiter.
+## Protokoll und Statistik
+
+- Protokoll-Zeile → Druckbericht `/protokoll/` (Soll/Ist, Spulen, Temperatur, Charge, Etiketten). Charge schon am laufenden Druck; Etikett an der Wand und im Bericht.
+- Statistik `/statistik`: KPIs, nach Drucker/Material, Zeitraum-Presets, CSV mit denselben Filtern.
+
## Warteschlange
- Seite `/warteschlange`: Filter Läuft / Wartet / Fehler / Historie. Priorität, Abbrechen, Fortsetzen.
diff --git a/grotti/jobs.py b/grotti/jobs.py
index 15617b2..4bfee3d 100644
--- a/grotti/jobs.py
+++ b/grotti/jobs.py
@@ -5,7 +5,7 @@ import urllib.error
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
-from grotti import acm, eject, library, moonraker, store
+from grotti import acm, eject, library, moonraker, store, usage
from grotti.cost import energy_cost
from grotti.errors import GrottiError
from grotti.parse import content_has_ace_trailer, prepare_rinkhals_gcode
@@ -15,17 +15,7 @@ TERMINAL = {"completed", "cancelled", "error", "interrupted"}
def weight_of(metadata):
- raw = (metadata or {}).get("filament_weight_total")
- if isinstance(raw, list):
- return sum(float(item or 0) for item in raw)
- if isinstance(raw, (int, float)):
- return float(raw)
- if isinstance(raw, str):
- try:
- return float(raw.replace(",", "."))
- except ValueError:
- return 0.0
- return 0.0
+ return usage.weight_sum(metadata)
def material_of(metadata):
@@ -35,6 +25,37 @@ def material_of(metadata):
return str(raw).split(";")[0].strip()
+def _ace_map_of(sent):
+ if not sent:
+ return []
+ raw = sent.get("ace_map")
+ if isinstance(raw, list):
+ return acm.normalize_mapping(raw)
+ if isinstance(raw, str):
+ try:
+ return acm.normalize_mapping(json.loads(raw))
+ except json.JSONDecodeError:
+ return []
+ return []
+
+
+def _targets_from_ace(conn, host, ace):
+ targets = []
+ for item in ace:
+ slot = int(item["ams_index"]) + 1
+ row = conn.execute(
+ """
+ SELECT * FROM spools
+ WHERE printer_host = %s AND ace_slot = %s
+ ORDER BY id LIMIT 1
+ """,
+ (host, slot),
+ ).fetchone()
+ if row:
+ targets.append(row)
+ return targets
+
+
def unix_to_dt(value):
if not value:
return None
@@ -128,50 +149,152 @@ def assert_spool_at_host(conn, spool_id, host):
)
-def apply_deduction(conn, run, energy):
- if run["deducted"] or run["weight_g"] <= 0 or run["state"] not in TERMINAL:
+def apply_deduction(conn, run, energy, metadata=None):
+ if run["deducted"] or run["state"] not in TERMINAL:
return False
- weight = float(run["weight_g"] or 0)
- # ACE: Abzug anteilig auf die Plätze der letzten Sendung
+ planned = float(run.get("weight_planned_g") or run.get("weight_g") or 0)
+ used = float(run.get("weight_used_g") or 0)
+ source = run.get("weight_source") or ""
+ if used <= 0 and planned > 0 and source != "none":
+ used, source = usage.resolve_used_grams(
+ planned,
+ run.get("filament_used_mm") or 0,
+ run.get("filament_total_mm") or 0,
+ run.get("progress_last") or 0,
+ run["state"],
+ run.get("duration_s") or 0,
+ )
+ if used <= 0:
+ return False
+
sent = store.latest_send(conn, run["printer_host"], run.get("filename") or "")
- ace = []
- if sent:
- raw = sent.get("ace_map")
- if isinstance(raw, list):
- ace = raw
- elif isinstance(raw, str):
- try:
- ace = json.loads(raw)
- except json.JSONDecodeError:
- ace = []
- targets = []
- if ace:
- for item in acm.normalize_mapping(ace):
- slot = int(item["ams_index"]) + 1
- row = conn.execute(
- """
- SELECT * FROM spools
- WHERE printer_host = %s AND ace_slot = %s
- ORDER BY id LIMIT 1
- """,
- (run["printer_host"], slot),
- ).fetchone()
- if row:
- targets.append(row)
+ ace = _ace_map_of(sent)
+ meta = metadata or {}
+ if not meta and run.get("file_id"):
+ file_row = store.get_file_row(conn, run["file_id"]) if hasattr(store, "get_file_row") else None
+ if file_row:
+ meta = {"filament_weight_total": file_row.get("weight_g") or planned}
+
+ store.close_open_segments(conn, run["id"], None, run.get("filament_used_mm") or 0)
+ segments = store.list_run_segments(conn, run["id"])
+
+ entries = []
+ targets = _targets_from_ace(conn, run["printer_host"], ace) if ace else []
if not targets and run.get("spool_id"):
spool = store.get_spool_row(conn, run["spool_id"])
if spool:
targets = [spool]
- if not targets:
+ # Abschnitte ohne ACE-Map: externe Rollen nacheinander
+ if segments and not ace:
+ final_mm = float(run.get("filament_used_mm") or 0)
+ total_mm = float(run.get("filament_total_mm") or 0)
+ deltas = usage.segment_deltas(segments, final_mm)
+ for spool_id, ace_slot, delta_mm in deltas:
+ spool = store.get_spool_row(conn, spool_id)
+ if not spool:
+ continue
+ grams = usage.grams_from_mm(delta_mm, total_mm, planned)
+ cost = (grams / 1000.0) * float(spool.get("price_per_kg") or 0)
+ entries.append({
+ "spool_id": spool_id,
+ "ace_slot": ace_slot,
+ "weight_g": grams,
+ "weight_planned_g": planned / max(1, len(deltas)),
+ "cost": cost,
+ "is_estimate": source != "measured",
+ })
+ elif targets:
+ shares = usage.planned_shares(meta, ace, len(targets))
+ parts, is_estimate = usage.split_used(used, shares, multi=len(targets) > 1)
+ if source != "measured":
+ is_estimate = True
+ # ACE-Rollenwechsel: Anteil der gewechselten Farbe nach mm aufteilen
+ by_slot = {}
+ if segments and ace:
+ final_mm = float(run.get("filament_used_mm") or 0)
+ for spool_id, ace_slot, delta_mm in usage.segment_deltas(segments, final_mm):
+ by_slot.setdefault(ace_slot, []).append((spool_id, delta_mm))
+ for index, spool in enumerate(targets):
+ plan = shares[index] if index < len(shares) else 0
+ grams = parts[index] if index < len(parts) else 0
+ slot = spool.get("ace_slot")
+ slot_segs = by_slot.get(slot) or []
+ if len(slot_segs) > 1:
+ mm_sum = sum(d for _, d in slot_segs) or 1.0
+ for seg_spool_id, delta_mm in slot_segs:
+ seg_spool = store.get_spool_row(conn, seg_spool_id)
+ if not seg_spool:
+ continue
+ share_g = grams * (delta_mm / mm_sum)
+ cost = (share_g / 1000.0) * float(seg_spool.get("price_per_kg") or 0)
+ entries.append({
+ "spool_id": seg_spool_id,
+ "ace_slot": slot,
+ "weight_g": share_g,
+ "weight_planned_g": plan / len(slot_segs),
+ "cost": cost,
+ "is_estimate": True,
+ })
+ else:
+ cost = (grams / 1000.0) * float(spool.get("price_per_kg") or 0)
+ entries.append({
+ "spool_id": spool["id"],
+ "ace_slot": slot,
+ "weight_g": grams,
+ "weight_planned_g": plan,
+ "cost": cost,
+ "is_estimate": is_estimate,
+ })
+ elif segments:
+ # Fallback: Abschnitte ohne aktuelle Targets
+ final_mm = float(run.get("filament_used_mm") or 0)
+ total_mm = float(run.get("filament_total_mm") or 0)
+ for spool_id, ace_slot, delta_mm in usage.segment_deltas(segments, final_mm):
+ spool = store.get_spool_row(conn, spool_id)
+ if not spool:
+ continue
+ grams = usage.grams_from_mm(delta_mm, total_mm, planned)
+ cost = (grams / 1000.0) * float(spool.get("price_per_kg") or 0)
+ entries.append({
+ "spool_id": spool_id,
+ "ace_slot": ace_slot,
+ "weight_g": grams,
+ "weight_planned_g": 0,
+ "cost": cost,
+ "is_estimate": source != "measured",
+ })
+ else:
return False
- share = weight / len(targets)
- filament = sum((share / 1000) * float(spool["price_per_kg"] or 0) for spool in targets)
+
+ if not entries:
+ return False
+ used_total = sum(item["weight_g"] for item in entries)
+ filament = sum(item["cost"] for item in entries)
power = energy_cost(run["duration_s"], energy)
- marked = store.mark_deducted(conn, run["id"], targets[0]["id"], filament, power)
+ # run_spools vor deducted=true, sonst bleibt die Akte leer bei Abbruch dazwischen
+ store.replace_run_spools(conn, run["id"], entries)
+ marked = store.mark_deducted(
+ conn,
+ run["id"],
+ entries[0]["spool_id"],
+ filament,
+ power,
+ weight_used_g=used_total,
+ weight_source=source or ("estimate" if any(e["is_estimate"] for e in entries) else "measured"),
+ )
if not marked:
+ store.replace_run_spools(conn, run["id"], [])
return False
- for spool in targets:
- store.deduct_spool(conn, spool["id"], share)
+ for item in entries:
+ store.deduct_spool(conn, item["spool_id"], item["weight_g"])
+ store.ensure_run_chargen(conn, run["id"], used_total)
+ objects = run.get("objects")
+ if isinstance(objects, str):
+ try:
+ objects = json.loads(objects)
+ except json.JSONDecodeError:
+ objects = []
+ store.ensure_run_labels(conn, run["id"], objects or [], qty=1)
return True
@@ -190,13 +313,16 @@ def sync_history(conn, cfg):
deducted = 0
for printer, job in collected:
job_id = str(job.get("job_id") or "")
- if not job_id:
+ if not job_id or str(job_id).startswith("live:"):
continue
filename = job.get("filename") or ""
metadata = job.get("metadata") or {}
- weight = weight_of(metadata)
+ planned = weight_of(metadata)
material = material_of(metadata)
state = job.get("status") or ""
+ used_mm = usage.filament_used_mm(job)
+ total_mm = usage.filament_total_mm(metadata, job)
+ estimated = float(metadata.get("estimated_time") or 0)
sent = store.latest_send(conn, printer["host"], filename)
known = store.file_by_name(conn, filename)
file_id = (sent or {}).get("file_id") or (known or {}).get("id")
@@ -204,6 +330,23 @@ def sync_history(conn, cfg):
hinted = (sent or {}).get("spool_id") or (known or {}).get("spool_id")
spool = choose_spool(conn, printer["host"], material, hinted)
terminal = state in TERMINAL
+ live = store.find_live_run(conn, printer["host"], filename)
+ progress = float((live or {}).get("progress_last") or 0)
+ if used_mm <= 0 and live:
+ used_mm = float(live.get("filament_used_mm") or 0)
+ if total_mm <= 0 and live:
+ total_mm = float(live.get("filament_total_mm") or 0)
+ if planned <= 0 and live:
+ planned = float(live.get("weight_planned_g") or 0)
+ objects = (live or {}).get("objects") or []
+ if isinstance(objects, str):
+ try:
+ objects = json.loads(objects)
+ except json.JSONDecodeError:
+ objects = []
+ used_g, source = usage.resolve_used_grams(
+ planned, used_mm, total_mm, progress, state, job.get("print_duration") or 0
+ )
row = store.upsert_run(conn, {
"printer_host": printer["host"],
"printer_name": printer["name"],
@@ -215,13 +358,33 @@ def sync_history(conn, cfg):
"started_at": unix_to_dt(job.get("start_time")),
"ended_at": unix_to_dt(job.get("end_time")),
"duration_s": float(job.get("print_duration") or job.get("total_duration") or 0),
- "weight_g": weight,
+ "weight_g": planned,
+ "weight_planned_g": planned,
+ "weight_used_g": used_g,
+ "weight_source": source,
+ "filament_used_mm": used_mm,
+ "filament_total_mm": total_mm,
+ "progress_last": progress,
+ "estimated_s": estimated,
"energy_cost": energy_cost(job.get("print_duration") or job.get("total_duration") or 0, cfg["energy"]),
"moonraker_job_id": job_id,
- "needs_spool": bool(terminal and weight > 0 and not spool),
+ "needs_spool": bool(terminal and used_g > 0 and not spool),
"material_type": material,
+ "message": (job.get("message") or "") if state == "error" else "",
+ "objects": objects,
})
- if apply_deduction(conn, row, cfg["energy"]):
+ # Live sofort auf History hängen — auch solange noch in_progress (eine Protokoll-Zeile)
+ store.adopt_live_run(conn, printer["host"], filename, row["id"])
+ row = store.get_run_row(conn, row["id"])
+ if terminal:
+ # Nachzug nicht während eines neuen Live-Drucks — sonst „Platte belegt“ zu Unrecht
+ active = store.find_live_run(conn, printer["host"])
+ if not active:
+ ended = row.get("ended_at") or unix_to_dt(job.get("end_time"))
+ store.ensure_bed_after_print(
+ conn, printer["host"], ended_at=ended, message=filename or state
+ )
+ if apply_deduction(conn, row, cfg["energy"], metadata=metadata):
deducted += 1
return {"gelesen": len(collected), "abgezogen": deducted}
@@ -373,7 +536,9 @@ def send_file(
filaments = [{"type": "PLA", "color": ""}]
progress("vorbereiten", 30, "Datei vorbereiten…")
- filename, content, plate = library.printer_file(row)
+ filename, content, plate = library.printer_file(
+ row, conn=conn, data_dir=(cfg or {}).get("data_dir")
+ )
# 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
@@ -486,6 +651,33 @@ def steuern(conn, host, action, params=None):
printer = store.printer_by_host(conn, host)
if not printer:
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
+ params = dict(params) if isinstance(params, dict) else {}
+ if action == "slot_setzen":
+ try:
+ ace_slot = int(params.get("ace_slot"))
+ except (TypeError, ValueError) as exc:
+ raise GrottiError("ACE-Platz muss 1–8 sein.", "platz") from exc
+ spool = None
+ for row in store.spools_for_printer(conn, host):
+ raw_slot = row.get("ace_slot")
+ if raw_slot in (None, ""):
+ continue
+ try:
+ if int(raw_slot) == ace_slot:
+ spool = row
+ break
+ except (TypeError, ValueError):
+ continue
+ if not spool:
+ raise GrottiError("Auf diesem Platz liegt keine Spule in Grotti.", "spule")
+ material = (spool.get("material_type") or "").strip()
+ if not material:
+ raise GrottiError("Die Spule braucht eine Sorte.", "material")
+ color = moonraker.spool_rgb(spool)
+ if not color:
+ raise GrottiError("Die Spule braucht eine Farbe.", "farbe")
+ hub_id, index = moonraker.ace_hub_index(ace_slot)
+ params = {"id": hub_id, "index": index, "type": material, "color": color}
try:
moonraker.steuern(host, action, params)
except moonraker.PrinterError as exc:
diff --git a/grotti/library.py b/grotti/library.py
index 3e2b694..2958eec 100644
--- a/grotti/library.py
+++ b/grotti/library.py
@@ -142,8 +142,30 @@ def ensure_library_gcode(conn, cfg, host, filename):
return save_upload(conn, name, content, cfg)
-def printer_file(row):
- path = Path(row["stored_path"])
+def resolve_stored_path(conn, row, data_dir=None):
+ """Gespeicherter Pfad, ggf. nach Umzug von data_dir unter eingang/ wiederfinden."""
+ stored = (row or {}).get("stored_path") or ""
+ path = Path(stored) if stored else None
+ if path and path.is_file():
+ return path
+ name = (row or {}).get("name") or (path.name if path else "")
+ if not name or data_dir is None:
+ return None
+ candidate = Path(data_dir) / "eingang" / name
+ if not candidate.is_file():
+ return None
+ resolved = str(candidate.resolve())
+ file_id = (row or {}).get("id")
+ if file_id and conn is not None:
+ conn.execute("UPDATE files SET stored_path = %s WHERE id = %s", (resolved, file_id))
+ row["stored_path"] = resolved
+ return candidate.resolve()
+
+
+def printer_file(row, conn=None, data_dir=None):
+ path = resolve_stored_path(conn, row, data_dir) if (conn is not None or data_dir is not None) else Path(row["stored_path"])
+ if path is None:
+ path = Path(row.get("stored_path") or "")
if not path.is_file():
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
if row["kind"] == "gcode":
@@ -153,9 +175,9 @@ def printer_file(row):
return path.name, path.read_bytes(), row["plate"] or ""
-def preview_png(row):
- path = Path(row["stored_path"])
- if not path.is_file():
+def preview_png(row, conn=None, data_dir=None):
+ path = resolve_stored_path(conn, row, data_dir) if (conn is not None or data_dir is not None) else Path(row["stored_path"])
+ if path is None or not path.is_file():
return None
entry = row["thumb_entry"]
if row["kind"] == "gcode":
diff --git a/grotti/moonraker.py b/grotti/moonraker.py
index 3545ed6..2a08326 100644
--- a/grotti/moonraker.py
+++ b/grotti/moonraker.py
@@ -418,6 +418,64 @@ def steuern(host, action, params=None):
if not re.fullmatch(r"[A-Za-z0-9._+\-]+", name):
raise PrinterError("Ungültiger Objektname.")
return gcode_script(host, "EXCLUDE_OBJECT NAME=%s" % name, timeout=20)
+ if action == "dryer_start":
+ try:
+ unit = int(params.get("id"))
+ except (TypeError, ValueError) as exc:
+ raise PrinterError("ACE-Box muss 0 oder 1 sein.") from exc
+ if unit not in (0, 1):
+ raise PrinterError("ACE-Box muss 0 oder 1 sein.")
+ try:
+ temp = int(round(float(params.get("temp"))))
+ duration = int(round(float(params.get("duration"))))
+ except (TypeError, ValueError) as exc:
+ raise PrinterError("Temperatur und Dauer müssen Zahlen sein.") from exc
+ if temp < 35 or temp > 65:
+ raise PrinterError("Trockner-Temperatur außerhalb 35–65 °C.")
+ if duration < 1 or duration > 720:
+ raise PrinterError("Trockner-Dauer außerhalb 1–720 Minuten.")
+ return post_json(
+ host,
+ "/server/filament_hub/start_drying",
+ {"id": unit, "temp": temp, "duration": duration, "fan_speed": 0},
+ timeout=15,
+ )
+ if action == "dryer_stop":
+ try:
+ unit = int(params.get("id"))
+ except (TypeError, ValueError) as exc:
+ raise PrinterError("ACE-Box muss 0 oder 1 sein.") from exc
+ if unit not in (0, 1):
+ raise PrinterError("ACE-Box muss 0 oder 1 sein.")
+ return post_json(host, "/server/filament_hub/stop_drying", {"id": unit}, timeout=15)
+ if action == "slot_setzen":
+ hub_id = _int_or_none(params.get("id"))
+ index = _int_or_none(params.get("index"))
+ if hub_id not in (0, 1) or index is None or index < 0 or index > 3:
+ raise PrinterError("ACE-Platz ungültig.")
+ material = str(params.get("type") or "").strip()
+ if not material:
+ raise PrinterError("Die Spule braucht eine Sorte.")
+ color = params.get("color")
+ if not isinstance(color, dict):
+ raise PrinterError("Die Spule braucht eine Farbe.")
+ try:
+ red = max(0, min(255, int(color.get("R"))))
+ green = max(0, min(255, int(color.get("G"))))
+ blue = max(0, min(255, int(color.get("B"))))
+ except (TypeError, ValueError) as exc:
+ raise PrinterError("Die Spule braucht eine Farbe.") from exc
+ return post_json(
+ host,
+ "/printer/filament_hub/set_filament_info",
+ {
+ "id": hub_id,
+ "index": index,
+ "type": material,
+ "color": {"R": red, "G": green, "B": blue},
+ },
+ timeout=15,
+ )
if action == "firmware_restart":
# GoKlipper/Rinkhals: FIRMWARE_RESTART deadlocked gklib (Rinkhals #32).
raise PrinterError(
@@ -600,6 +658,80 @@ def extract_slots(status):
return found[:8]
+def _int_or_none(value):
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
+ return None
+ return int(value)
+
+
+def extract_dryers(status):
+ """Echte ACE-Boxen aus mmu_machine (num_gates > 0), Dummy-Unit bleibt weg."""
+ machine = (status or {}).get("mmu_machine")
+ if not isinstance(machine, dict):
+ return []
+ found = []
+ for unit_id in (0, 1):
+ unit = machine.get("unit_%d" % unit_id)
+ if not isinstance(unit, dict):
+ continue
+ gates = _int_or_none(unit.get("num_gates"))
+ if gates is None or gates < 1:
+ continue
+ found.append({
+ "id": unit_id,
+ "status": str(unit.get("dryer_status") or "stop"),
+ "temp": _int_or_none(unit.get("dryer_temp")) or 0,
+ "target": _int_or_none(unit.get("dryer_target_temp")) or 0,
+ "remain": _int_or_none(unit.get("dryer_remaining")) or 0,
+ "humidity": _int_or_none(unit.get("dryer_humidity")) or 0,
+ "gates": gates,
+ })
+ return found
+
+
+def ace_hub_index(ace_slot):
+ """Grotti-Platz 1–8 → Moonraker id (0/1) und index in der Box (0–3)."""
+ try:
+ slot = int(ace_slot)
+ except (TypeError, ValueError) as exc:
+ raise PrinterError("ACE-Platz muss 1–8 sein.") from exc
+ if slot < 1 or slot > 8:
+ raise PrinterError("ACE-Platz muss 1–8 sein.")
+ return (slot - 1) // 4, (slot - 1) % 4
+
+
+def spool_rgb(spool):
+ """Erste Farbe der Spule als RGB-Dict für set_filament_info."""
+ colors = (spool or {}).get("colors") or []
+ if isinstance(colors, str):
+ try:
+ colors = json.loads(colors)
+ except (json.JSONDecodeError, TypeError):
+ colors = []
+ hex_value = ""
+ if isinstance(colors, list):
+ for item in colors:
+ if isinstance(item, dict) and item.get("hex"):
+ hex_value = str(item["hex"]).strip()
+ break
+ if isinstance(item, str) and item.strip():
+ hex_value = item.strip()
+ break
+ if not hex_value:
+ hex_value = str((spool or {}).get("color") or "").strip()
+ if hex_value.startswith("#"):
+ hex_value = hex_value[1:]
+ if len(hex_value) == 8:
+ hex_value = hex_value[:6]
+ if len(hex_value) != 6 or any(ch not in "0123456789abcdefABCDEF" for ch in hex_value):
+ return None
+ return {
+ "R": int(hex_value[0:2], 16),
+ "G": int(hex_value[2:4], 16),
+ "B": int(hex_value[4:6], 16),
+ }
+
+
def slots(host, status=None):
if status is not None:
found = extract_slots(status)
@@ -868,6 +1000,8 @@ def query(host):
extras = []
if "probe" in names:
extras.append("probe")
+ if "mmu_machine" in names:
+ extras.append("mmu_machine")
url = "http://%s/printer/objects/query?%s" % (host, "&".join([base] + extras))
payload = fetch_json(url)
status = payload["result"]["status"]
@@ -875,6 +1009,21 @@ def query(host):
card = status.get("virtual_sdcard") or {}
extruder = status.get("extruder") or {}
bed = status.get("heater_bed") or {}
+ mmu = status.get("mmu") or {}
+ mmu_tool = mmu.get("tool")
+ if not isinstance(mmu_tool, (int, float)) or isinstance(mmu_tool, bool):
+ mmu_tool = mmu.get("gate")
+ if isinstance(mmu_tool, (int, float)) and not isinstance(mmu_tool, bool):
+ mmu_tool = int(mmu_tool)
+ if mmu_tool < 0:
+ mmu_tool = None
+ else:
+ mmu_tool = None
+ filament_used = stats.get("filament_used")
+ if not isinstance(filament_used, (int, float)) or isinstance(filament_used, bool):
+ filament_used = None
+ else:
+ filament_used = float(filament_used)
raw_name = stats.get("filename") or ""
filename = raw_name.replace("\\", "/").rsplit("/", 1)[-1] if raw_name else ""
meta = metadata(host, raw_name) if raw_name else {}
@@ -933,8 +1082,15 @@ def query(host):
"bed_target": bed.get("target"),
"chamber": chamber,
"chamber_target": chamber_target,
+ "filament_used": filament_used,
+ "filament_total": meta.get("filament_total") or 0,
+ "mmu_tool": mmu_tool,
"material": card.get("filament_type") or meta.get("filament_type") or "",
- "weight": meta.get("filament_weight_total") or 0,
+ "weight": (
+ sum(float(x or 0) for x in meta["filament_weight_total"])
+ if isinstance(meta.get("filament_weight_total"), list)
+ else (meta.get("filament_weight_total") or 0)
+ ),
"height": meta.get("object_height") or 0,
"layer_height": meta.get("layer_height") or card.get("layer_height") or 0,
"estimated": meta.get("estimated_time") or 0,
@@ -952,6 +1108,7 @@ def query(host):
"speed_factor": speed_factor,
"extrude_factor": extrude_factor,
"slots": slots(host, status),
+ "dryers": extract_dryers(status),
"exclude": exclude_status(host),
"anycubic_task": _anycubic_task_field(host),
}
@@ -993,6 +1150,7 @@ def snapshot(printers):
"speed_factor": None,
"extrude_factor": None,
"slots": [],
+ "dryers": [],
"anycubic_task": None,
})
card.update(mesh_fields(printer["host"]))
diff --git a/grotti/notify.py b/grotti/notify.py
index f91e55f..acbb9b7 100644
--- a/grotti/notify.py
+++ b/grotti/notify.py
@@ -43,11 +43,11 @@ def alert_enabled(cfg, kind):
return bool(alerts.get(key, key not in DEFAULT_OFF))
-def record(conn, cfg, host, printer, kind, message, filename="", dedupe_s=60):
+def record(conn, cfg, host, printer, kind, message, filename="", dedupe_s=60, run_id=None):
"""Ereignis immer speichern; Telegram nur einmal pro Art und Drucker im Zeitfenster."""
from grotti import store
recent = store.recent_event_kind(conn, host, kind, within_s=dedupe_s)
- store.add_event(conn, host, printer, kind, message, filename or "")
+ store.add_event(conn, host, printer, kind, message, filename or "", run_id=run_id)
if not recent and alert_enabled(cfg, kind):
send_telegram(cfg, message)
diff --git a/grotti/schema.sql b/grotti/schema.sql
index 2c9d68f..49635ad 100644
--- a/grotti/schema.sql
+++ b/grotti/schema.sql
@@ -215,3 +215,86 @@ CREATE TABLE IF NOT EXISTS oauth_identities (
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (provider, subject)
);
+
+-- Druckbericht: Soll/Ist, Charge, Live-Fortschritt
+ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_planned_g double precision NOT NULL DEFAULT 0;
+ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_used_g double precision NOT NULL DEFAULT 0;
+ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_source text NOT NULL DEFAULT '';
+ALTER TABLE runs ADD COLUMN IF NOT EXISTS filament_used_mm double precision NOT NULL DEFAULT 0;
+ALTER TABLE runs ADD COLUMN IF NOT EXISTS filament_total_mm double precision NOT NULL DEFAULT 0;
+ALTER TABLE runs ADD COLUMN IF NOT EXISTS progress_last double precision NOT NULL DEFAULT 0;
+ALTER TABLE runs ADD COLUMN IF NOT EXISTS estimated_s double precision NOT NULL DEFAULT 0;
+ALTER TABLE runs ADD COLUMN IF NOT EXISTS chargen_nr text;
+ALTER TABLE runs ADD COLUMN IF NOT EXISTS message text NOT NULL DEFAULT '';
+ALTER TABLE runs ADD COLUMN IF NOT EXISTS objects jsonb NOT NULL DEFAULT '[]'::jsonb;
+
+CREATE UNIQUE INDEX IF NOT EXISTS runs_chargen_nr
+ ON runs (chargen_nr) WHERE chargen_nr IS NOT NULL AND chargen_nr <> '';
+
+ALTER TABLE events ADD COLUMN IF NOT EXISTS run_id bigint REFERENCES runs (id) ON DELETE SET NULL;
+CREATE INDEX IF NOT EXISTS events_run ON events (run_id) WHERE run_id IS NOT NULL;
+
+-- Abzug je Spule (Messwert oder Schätzwert)
+CREATE TABLE IF NOT EXISTS run_spools (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
+ spool_id bigint NOT NULL REFERENCES spools (id) ON DELETE RESTRICT,
+ ace_slot smallint,
+ weight_g double precision NOT NULL DEFAULT 0,
+ weight_planned_g double precision NOT NULL DEFAULT 0,
+ cost double precision NOT NULL DEFAULT 0,
+ is_estimate boolean NOT NULL DEFAULT false,
+ UNIQUE (run_id, spool_id, ace_slot)
+);
+
+CREATE INDEX IF NOT EXISTS run_spools_spool ON run_spools (spool_id);
+CREATE INDEX IF NOT EXISTS run_spools_run ON run_spools (run_id);
+
+-- Abschnitte bei Rollenwechsel mitten im Druck
+CREATE TABLE IF NOT EXISTS run_segments (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
+ spool_id bigint NOT NULL REFERENCES spools (id) ON DELETE RESTRICT,
+ ace_slot smallint,
+ mm_start double precision NOT NULL DEFAULT 0,
+ mm_end double precision,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ closed_at timestamptz
+);
+
+CREATE INDEX IF NOT EXISTS run_segments_run ON run_segments (run_id);
+CREATE INDEX IF NOT EXISTS run_segments_open
+ ON run_segments (run_id) WHERE mm_end IS NULL;
+
+-- Temperatur / Fortschritt während des Drucks
+CREATE TABLE IF NOT EXISTS run_samples (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
+ sampled_at timestamptz NOT NULL DEFAULT now(),
+ nozzle double precision,
+ nozzle_target double precision,
+ bed double precision,
+ bed_target double precision,
+ chamber double precision,
+ fan double precision,
+ speed_factor double precision,
+ extrude_factor double precision,
+ layer integer,
+ progress double precision,
+ filament_used_mm double precision,
+ mmu_tool smallint
+);
+
+CREATE INDEX IF NOT EXISTS run_samples_run_time ON run_samples (run_id, sampled_at);
+
+-- Objekt-Chargen für Etiketten
+CREATE TABLE IF NOT EXISTS run_labels (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
+ object_name text NOT NULL DEFAULT '',
+ chargen_nr text NOT NULL,
+ excluded boolean NOT NULL DEFAULT false,
+ UNIQUE (run_id, object_name)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS run_labels_chargen ON run_labels (chargen_nr);
diff --git a/grotti/store.py b/grotti/store.py
index 959f561..43a9dbe 100644
--- a/grotti/store.py
+++ b/grotti/store.py
@@ -2,7 +2,7 @@
import json
import uuid
-from datetime import date
+from datetime import date, datetime, timedelta, timezone
from psycopg.types.json import Json
@@ -123,6 +123,10 @@ def ensure_spool_codes(conn):
def run_out(row):
+ planned = float(row.get("weight_planned_g") or 0) or float(row.get("weight_g") or 0)
+ used = float(row.get("weight_used_g") or 0)
+ if used <= 0 and row.get("deducted"):
+ used = float(row.get("weight_g") or 0)
return {
"id": row["id"],
"printer_host": row["printer_host"],
@@ -135,13 +139,23 @@ def run_out(row):
"started_at": iso(row["started_at"]),
"ended_at": iso(row["ended_at"]),
"duration_s": row["duration_s"],
- "weight_g": row["weight_g"],
+ "weight_g": float(row.get("weight_g") or 0),
+ "weight_planned_g": planned,
+ "weight_used_g": used,
+ "weight_source": row.get("weight_source") or "",
+ "filament_used_mm": float(row.get("filament_used_mm") or 0),
+ "filament_total_mm": float(row.get("filament_total_mm") or 0),
+ "progress_last": float(row.get("progress_last") or 0),
+ "estimated_s": float(row.get("estimated_s") or 0),
"filament_cost": row["filament_cost"],
"energy_cost": row["energy_cost"],
"deducted": row["deducted"],
"needs_spool": row["needs_spool"],
"verdict": row["verdict"],
"material_type": row["material_type"],
+ "chargen_nr": row.get("chargen_nr") or "",
+ "message": row.get("message") or "",
+ "objects": _json_list(row.get("objects")),
}
@@ -575,16 +589,36 @@ def latest_send_for_file(conn, host, file_id):
def upsert_run(conn, item):
+ payload = {
+ "weight_planned_g": float(item.get("weight_planned_g") or item.get("weight_g") or 0),
+ "weight_used_g": float(item.get("weight_used_g") or 0),
+ "weight_source": item.get("weight_source") or "",
+ "filament_used_mm": float(item.get("filament_used_mm") or 0),
+ "filament_total_mm": float(item.get("filament_total_mm") or 0),
+ "progress_last": float(item.get("progress_last") or 0),
+ "estimated_s": float(item.get("estimated_s") or 0),
+ "message": item.get("message") or "",
+ **item,
+ }
+ payload["objects"] = Json(item.get("objects") if item.get("objects") is not None else [])
+ if "weight_g" not in payload or payload.get("weight_g") is None:
+ payload["weight_g"] = payload["weight_planned_g"]
return conn.execute(
"""
INSERT INTO runs (
printer_host, printer_name, filename, file_id, project_id, spool_id,
state, started_at, ended_at, duration_s, weight_g, energy_cost,
- moonraker_job_id, needs_spool, material_type
+ moonraker_job_id, needs_spool, material_type,
+ weight_planned_g, weight_used_g, weight_source,
+ filament_used_mm, filament_total_mm, progress_last, estimated_s,
+ message, objects
) VALUES (
%(printer_host)s, %(printer_name)s, %(filename)s, %(file_id)s, %(project_id)s, %(spool_id)s,
%(state)s, %(started_at)s, %(ended_at)s, %(duration_s)s, %(weight_g)s, %(energy_cost)s,
- %(moonraker_job_id)s, %(needs_spool)s, %(material_type)s
+ %(moonraker_job_id)s, %(needs_spool)s, %(material_type)s,
+ %(weight_planned_g)s, %(weight_used_g)s, %(weight_source)s,
+ %(filament_used_mm)s, %(filament_total_mm)s, %(progress_last)s, %(estimated_s)s,
+ %(message)s, %(objects)s
)
ON CONFLICT (printer_host, moonraker_job_id) DO UPDATE SET
printer_name = EXCLUDED.printer_name,
@@ -597,6 +631,36 @@ def upsert_run(conn, item):
ended_at = EXCLUDED.ended_at,
duration_s = EXCLUDED.duration_s,
weight_g = CASE WHEN EXCLUDED.weight_g > 0 THEN EXCLUDED.weight_g ELSE runs.weight_g END,
+ weight_planned_g = CASE
+ WHEN EXCLUDED.weight_planned_g > 0 THEN EXCLUDED.weight_planned_g
+ ELSE runs.weight_planned_g
+ END,
+ weight_used_g = CASE
+ WHEN EXCLUDED.weight_used_g > 0 THEN EXCLUDED.weight_used_g
+ ELSE runs.weight_used_g
+ END,
+ weight_source = CASE
+ WHEN EXCLUDED.weight_source <> '' THEN EXCLUDED.weight_source
+ ELSE runs.weight_source
+ END,
+ filament_used_mm = GREATEST(runs.filament_used_mm, EXCLUDED.filament_used_mm),
+ filament_total_mm = CASE
+ WHEN EXCLUDED.filament_total_mm > 0 THEN EXCLUDED.filament_total_mm
+ ELSE runs.filament_total_mm
+ END,
+ progress_last = GREATEST(runs.progress_last, EXCLUDED.progress_last),
+ estimated_s = CASE
+ WHEN EXCLUDED.estimated_s > 0 THEN EXCLUDED.estimated_s
+ ELSE runs.estimated_s
+ END,
+ message = CASE
+ WHEN EXCLUDED.message <> '' THEN EXCLUDED.message
+ ELSE runs.message
+ END,
+ objects = CASE
+ WHEN EXCLUDED.objects <> '[]'::jsonb THEN EXCLUDED.objects
+ ELSE runs.objects
+ END,
energy_cost = EXCLUDED.energy_cost,
needs_spool = CASE WHEN runs.deducted THEN false ELSE EXCLUDED.needs_spool END,
material_type = CASE
@@ -605,20 +669,32 @@ def upsert_run(conn, item):
END
RETURNING *
""",
- item,
+ payload,
).fetchone()
-def mark_deducted(conn, run_id, spool_id, filament_cost, energy_cost):
+def mark_deducted(conn, run_id, spool_id, filament_cost, energy_cost, weight_used_g=None, weight_source=""):
return conn.execute(
"""
UPDATE runs
SET deducted = true, needs_spool = false, spool_id = %s,
- filament_cost = %s, energy_cost = %s
+ filament_cost = %s, energy_cost = %s,
+ weight_used_g = COALESCE(%s, weight_used_g),
+ weight_g = COALESCE(%s, weight_g),
+ weight_source = CASE WHEN %s <> '' THEN %s ELSE weight_source END
WHERE id = %s AND deducted = false
RETURNING id
""",
- (spool_id, filament_cost, energy_cost, run_id),
+ (
+ spool_id,
+ filament_cost,
+ energy_cost,
+ weight_used_g,
+ weight_used_g,
+ weight_source or "",
+ weight_source or "",
+ run_id,
+ ),
).fetchone()
@@ -629,9 +705,59 @@ def deduct_spool(conn, spool_id, grams):
).fetchone()
-def list_runs(conn):
+def list_runs(conn, limit=200, since=None, until=None, host="", material="", brand=""):
+ where = []
+ values = []
+ until_bound = exclusive_until(until)
+ if since:
+ where.append("r.started_at >= %s")
+ values.append(since)
+ if until_bound:
+ where.append("r.started_at < %s")
+ values.append(until_bound)
+ if host:
+ where.append("r.printer_host = %s")
+ values.append(host)
+ spool_join = ""
+ if material or brand:
+ spool_join = (
+ "JOIN run_spools rs0 ON rs0.run_id = r.id "
+ "JOIN spools s0 ON s0.id = rs0.spool_id"
+ )
+ if material:
+ where.append("s0.material_type ILIKE %s")
+ values.append(material)
+ if brand:
+ where.append("s0.brand ILIKE %s")
+ values.append(brand)
+ # Live-Zeile ausblenden, wenn derselbe Druck schon als History-Job offen ist
+ where.append(
+ """
+ NOT (
+ r.moonraker_job_id LIKE 'live:%%'
+ AND r.state IN ('in_progress', 'printing', 'paused')
+ AND EXISTS (
+ SELECT 1 FROM runs h
+ WHERE h.printer_host = r.printer_host
+ AND h.filename = r.filename
+ AND h.moonraker_job_id NOT LIKE 'live:%%'
+ AND h.state IN ('in_progress', 'printing', 'paused')
+ )
+ )
+ """
+ )
+ clause = (" WHERE " + " AND ".join(where)) if where else ""
+ values.append(int(limit))
rows = conn.execute(
- "SELECT * FROM runs ORDER BY started_at DESC NULLS LAST, id DESC LIMIT 200"
+ """
+ SELECT DISTINCT r.*
+ FROM runs r
+ %s
+ %s
+ ORDER BY r.started_at DESC NULLS LAST, r.id DESC
+ LIMIT %%s
+ """ % (spool_join, clause),
+ values,
).fetchall()
return [run_out(row) for row in rows]
@@ -640,6 +766,25 @@ def get_run_row(conn, run_id):
return conn.execute("SELECT * FROM runs WHERE id = %s", (run_id,)).fetchone()
+def get_run_by_chargen(conn, code):
+ text = (code or "").strip()
+ if not text:
+ return None
+ row = conn.execute(
+ "SELECT * FROM runs WHERE chargen_nr = %s LIMIT 1",
+ (text,),
+ ).fetchone()
+ if row:
+ return row
+ label = conn.execute(
+ "SELECT run_id FROM run_labels WHERE chargen_nr = %s LIMIT 1",
+ (text,),
+ ).fetchone()
+ if not label:
+ return None
+ return get_run_row(conn, label["run_id"])
+
+
def set_verdict(conn, run_id, verdict):
row = conn.execute(
"UPDATE runs SET verdict = %s WHERE id = %s RETURNING *",
@@ -660,6 +805,892 @@ def assign_run_spool(conn, run_id, spool_id):
).fetchone()
+def next_druck_chargen(conn):
+ day = datetime.now(timezone.utc).strftime("%Y%m%d")
+ prefix = "D-%s-" % day
+ row = conn.execute(
+ """
+ SELECT chargen_nr FROM runs
+ WHERE chargen_nr LIKE %s
+ ORDER BY chargen_nr DESC
+ LIMIT 1
+ """,
+ (prefix + "%",),
+ ).fetchone()
+ number = 1
+ if row and row["chargen_nr"]:
+ try:
+ number = int(str(row["chargen_nr"]).rsplit("-", 1)[-1]) + 1
+ except ValueError:
+ number = 1
+ return "%s%04d" % (prefix, number)
+
+
+def ensure_run_chargen(conn, run_id, weight_used_g=None):
+ """Vergibt D-… am laufenden oder beendeten Druck. Bestehende Nummer bleibt.
+
+ weight_used_g wird ignoriert (früher Gate); Parameter bleibt für Aufrufer.
+ """
+ row = get_run_row(conn, run_id)
+ if not row:
+ return None
+ if row.get("chargen_nr"):
+ return row["chargen_nr"]
+ state = row.get("state") or ""
+ if state not in (
+ "in_progress", "printing", "paused",
+ "completed", "cancelled", "error", "interrupted",
+ ):
+ return None
+ code = next_druck_chargen(conn)
+ updated = conn.execute(
+ """
+ UPDATE runs SET chargen_nr = %s
+ WHERE id = %s AND (chargen_nr IS NULL OR chargen_nr = '')
+ RETURNING chargen_nr
+ """,
+ (code, run_id),
+ ).fetchone()
+ return (updated or {}).get("chargen_nr") or code
+
+
+def replace_run_spools(conn, run_id, entries):
+ conn.execute("DELETE FROM run_spools WHERE run_id = %s", (run_id,))
+ for item in entries:
+ conn.execute(
+ """
+ INSERT INTO run_spools (
+ run_id, spool_id, ace_slot, weight_g, weight_planned_g, cost, is_estimate
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s)
+ """,
+ (
+ run_id,
+ item["spool_id"],
+ item.get("ace_slot"),
+ float(item.get("weight_g") or 0),
+ float(item.get("weight_planned_g") or 0),
+ float(item.get("cost") or 0),
+ bool(item.get("is_estimate")),
+ ),
+ )
+
+
+def list_run_spools(conn, run_id):
+ rows = conn.execute(
+ """
+ SELECT rs.*, s.name AS spool_name, s.chargen_nr AS spool_chargen,
+ s.material_type, s.brand, s.color, s.colors, s.hersteller_charge,
+ s.price_per_kg, s.remaining_g
+ FROM run_spools rs
+ JOIN spools s ON s.id = rs.spool_id
+ WHERE rs.run_id = %s
+ ORDER BY rs.ace_slot NULLS LAST, rs.id
+ """,
+ (run_id,),
+ ).fetchall()
+ out = []
+ for row in rows:
+ out.append({
+ "spool_id": row["spool_id"],
+ "ace_slot": row["ace_slot"],
+ "weight_g": float(row["weight_g"] or 0),
+ "weight_planned_g": float(row["weight_planned_g"] or 0),
+ "cost": float(row["cost"] or 0),
+ "is_estimate": bool(row["is_estimate"]),
+ "name": row["spool_name"],
+ "chargen_nr": row["spool_chargen"] or "",
+ "material_type": row["material_type"] or "",
+ "brand": row["brand"] or "",
+ "color": row["color"] or "",
+ "colors": _json_list(row.get("colors")),
+ "hersteller_charge": row["hersteller_charge"] or "",
+ "remaining_g": float(row["remaining_g"] or 0),
+ })
+ return out
+
+
+def _etikett_spool_line(row, ace_slot=None):
+ spool = spool_out(row)
+ return {
+ "spool_id": spool["id"],
+ "name": spool["name"],
+ "chargen_nr": spool["chargen_nr"],
+ "material_type": spool["material_type"],
+ "brand": spool["brand"],
+ "hersteller_charge": spool["hersteller_charge"],
+ "ace_slot": ace_slot if ace_slot is not None else spool.get("ace_slot"),
+ "color": spool.get("color") or "",
+ }
+
+
+def etikett_spools_for_run(conn, run):
+ """Material fürs Etikett: Abzug, sonst Send/ACE/Lauf-Spule, sonst Metadaten."""
+ existing = list_run_spools(conn, run["id"])
+ if existing:
+ return existing
+ out = []
+ seen = set()
+
+ def add_row(row, ace_slot=None):
+ if not row:
+ return
+ sid = row.get("id") if "id" in row else row.get("spool_id")
+ if sid is not None and sid in seen:
+ return
+ if sid is not None:
+ seen.add(sid)
+ out.append(_etikett_spool_line(row, ace_slot=ace_slot))
+
+ host = (run.get("printer_host") or "").strip()
+ sent = latest_send(conn, host, run.get("filename") or "") if host else None
+ ace_map = _json_list((sent or {}).get("ace_map")) if sent else []
+ if ace_map and host:
+ for item in ace_map:
+ try:
+ slot = int(item.get("ams_index")) + 1
+ except (TypeError, ValueError, AttributeError):
+ continue
+ row = conn.execute(
+ """
+ SELECT * FROM spools
+ WHERE printer_host = %s AND ace_slot = %s
+ ORDER BY id LIMIT 1
+ """,
+ (host, slot),
+ ).fetchone()
+ add_row(row, ace_slot=slot)
+ spool_id = run.get("spool_id") or (sent or {}).get("spool_id")
+ if spool_id:
+ add_row(get_spool_row(conn, spool_id))
+ if not out and host and not ace_map:
+ rows = conn.execute(
+ """
+ SELECT * FROM spools
+ WHERE printer_host = %s AND ace_slot IS NULL
+ ORDER BY id
+ """,
+ (host,),
+ ).fetchall()
+ for row in rows:
+ add_row(row)
+ if not out and (run.get("material_type") or "").strip():
+ out.append({
+ "spool_id": None,
+ "name": "",
+ "chargen_nr": "",
+ "material_type": (run.get("material_type") or "").strip(),
+ "brand": "",
+ "hersteller_charge": "",
+ "ace_slot": None,
+ "color": "",
+ })
+ return out
+
+
+def list_spool_runs(conn, spool_id, limit=50):
+ rows = conn.execute(
+ """
+ SELECT r.*, rs.weight_g AS spool_weight_g, rs.cost AS spool_cost,
+ rs.is_estimate AS spool_is_estimate
+ FROM run_spools rs
+ JOIN runs r ON r.id = rs.run_id
+ WHERE rs.spool_id = %s
+ ORDER BY r.started_at DESC NULLS LAST, r.id DESC
+ LIMIT %s
+ """,
+ (spool_id, int(limit)),
+ ).fetchall()
+ out = []
+ for row in rows:
+ item = run_out(row)
+ item["spool_weight_g"] = float(row["spool_weight_g"] or 0)
+ item["spool_cost"] = float(row["spool_cost"] or 0)
+ item["spool_is_estimate"] = bool(row["spool_is_estimate"])
+ out.append(item)
+ return out
+
+
+def spool_usage_stats(conn, spool_id, days=28):
+ row = conn.execute(
+ """
+ SELECT
+ COALESCE(SUM(rs.weight_g), 0) AS total_g,
+ COALESCE(SUM(CASE WHEN r.verdict = 'ausschuss' THEN rs.weight_g ELSE 0 END), 0) AS scrap_g,
+ COALESCE(SUM(rs.cost), 0) AS total_cost,
+ COALESCE(SUM(CASE
+ WHEN r.started_at >= now() - make_interval(days => %s) THEN rs.weight_g
+ ELSE 0
+ END), 0) AS recent_g
+ FROM run_spools rs
+ JOIN runs r ON r.id = rs.run_id
+ WHERE rs.spool_id = %s
+ """,
+ (int(days), spool_id),
+ ).fetchone()
+ recent = float((row or {}).get("recent_g") or 0)
+ per_day = recent / max(1, int(days))
+ spool = get_spool_row(conn, spool_id)
+ left = float((spool or {}).get("remaining_g") or 0)
+ empty_in_days = None
+ if per_day > 0.01 and left > 0:
+ empty_in_days = left / per_day
+ elif left <= 0:
+ empty_in_days = 0.0
+ return {
+ "total_g": float((row or {}).get("total_g") or 0),
+ "scrap_g": float((row or {}).get("scrap_g") or 0),
+ "total_cost": float((row or {}).get("total_cost") or 0),
+ "recent_g": recent,
+ "per_day_g": per_day,
+ "empty_in_days": empty_in_days,
+ "fill_pct": (left / 10.0) if left else (0.0 if left == 0 else left / 10.0),
+ "remaining_g": left,
+ }
+
+
+def find_open_history_run(conn, host, filename=""):
+ """Offener Moonraker-History-Job (nicht live:) für Host/Datei."""
+ host = (host or "").strip()
+ if not host:
+ return None
+ if filename:
+ row = conn.execute(
+ """
+ SELECT * FROM runs
+ WHERE printer_host = %s
+ AND filename = %s
+ AND moonraker_job_id NOT LIKE 'live:%%'
+ AND state IN ('in_progress', 'printing', 'paused')
+ ORDER BY id DESC
+ LIMIT 1
+ """,
+ (host, filename),
+ ).fetchone()
+ if row:
+ return row
+ return conn.execute(
+ """
+ SELECT * FROM runs
+ WHERE printer_host = %s
+ AND moonraker_job_id NOT LIKE 'live:%%'
+ AND state IN ('in_progress', 'printing', 'paused')
+ ORDER BY id DESC
+ LIMIT 1
+ """,
+ (host,),
+ ).fetchone()
+
+
+def find_live_run(conn, host, filename=""):
+ """Offener Live-Lauf für diesen Drucker (Watcher)."""
+ if filename:
+ row = conn.execute(
+ """
+ SELECT * FROM runs
+ WHERE printer_host = %s
+ AND filename = %s
+ AND moonraker_job_id LIKE 'live:%%'
+ AND state IN ('in_progress', 'printing', 'paused')
+ ORDER BY id DESC
+ LIMIT 1
+ """,
+ (host, filename),
+ ).fetchone()
+ if row:
+ return row
+ return conn.execute(
+ """
+ SELECT * FROM runs
+ WHERE printer_host = %s
+ AND moonraker_job_id LIKE 'live:%%'
+ AND state IN ('in_progress', 'printing', 'paused')
+ ORDER BY id DESC
+ LIMIT 1
+ """,
+ (host,),
+ ).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)
+ if hist:
+ live = find_live_run(conn, host, filename)
+ 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"):
+ 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
+ row = find_live_run(conn, host, filename)
+ if row:
+ if filename and row.get("filename") != filename:
+ conn.execute(
+ "UPDATE runs SET filename = %s WHERE id = %s",
+ (filename, 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, {
+ "printer_host": host,
+ "printer_name": printer_name or host,
+ "filename": filename or "",
+ "file_id": None,
+ "project_id": None,
+ "spool_id": spool_id,
+ "state": "in_progress",
+ "started_at": datetime.now(timezone.utc),
+ "ended_at": None,
+ "duration_s": 0,
+ "weight_g": 0,
+ "weight_planned_g": 0,
+ "weight_used_g": 0,
+ "weight_source": "",
+ "filament_used_mm": 0,
+ "filament_total_mm": 0,
+ "progress_last": 0,
+ "estimated_s": 0,
+ "energy_cost": 0,
+ "moonraker_job_id": job_id,
+ "needs_spool": False,
+ "material_type": "",
+ "message": "",
+ "objects": [],
+ })
+
+
+def abandon_live_runs(conn, host, keep_id=None):
+ """Offene Live-Läufe beenden (z. B. vor einem neuen Druckstart)."""
+ host = (host or "").strip()
+ if not host:
+ return 0
+ rows = conn.execute(
+ """
+ UPDATE runs
+ SET state = 'interrupted',
+ ended_at = COALESCE(ended_at, now())
+ WHERE printer_host = %s
+ AND moonraker_job_id LIKE 'live:%%'
+ AND state IN ('in_progress', 'printing', 'paused')
+ AND (%s::bigint IS NULL OR id <> %s)
+ RETURNING id
+ """,
+ (host, keep_id, keep_id),
+ ).fetchall()
+ return len(rows)
+
+
+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)
+ if not live or live["id"] == real_run_id:
+ return
+ real = get_run_row(conn, real_run_id)
+ if not real:
+ return
+ live_start = live.get("started_at")
+ real_end = real.get("ended_at")
+ # Gleiche Datei, neuer Druck: Live nicht an schon beendeten History-Job hängen.
+ if live_start and real_end and live_start > real_end:
+ return
+ real_state = (real.get("state") or "")
+ if real_state in ("completed", "cancelled", "error", "interrupted"):
+ if live_start and real.get("started_at") and live_start > real["started_at"]:
+ # Live begann nach dem History-Start und History ist schon durch — anderer Lauf.
+ if not real_end or live_start > real_end:
+ return
+ live_id = live["id"]
+ live_chargen = (live.get("chargen_nr") or "").strip()
+ for table in ("run_samples", "run_segments", "run_spools", "run_labels"):
+ conn.execute(
+ "UPDATE %s SET run_id = %%s WHERE run_id = %%s" % table,
+ (real_run_id, live_id),
+ )
+ conn.execute(
+ "UPDATE events SET run_id = %s WHERE run_id = %s",
+ (real_run_id, live_id),
+ )
+ conn.execute(
+ """
+ UPDATE runs AS dest SET
+ progress_last = GREATEST(dest.progress_last, src.progress_last),
+ filament_used_mm = GREATEST(dest.filament_used_mm, src.filament_used_mm),
+ objects = CASE
+ WHEN dest.objects::text IN ('[]', 'null') THEN src.objects
+ ELSE dest.objects
+ END
+ FROM runs AS src
+ WHERE dest.id = %s AND src.id = %s
+ """,
+ (real_run_id, live_id),
+ )
+ if live_chargen and real_state in ("completed", "cancelled", "error", "interrupted"):
+ conn.execute("UPDATE runs SET chargen_nr = NULL WHERE id = %s", (live_id,))
+ conn.execute(
+ """
+ UPDATE runs SET chargen_nr = %s
+ WHERE id = %s AND (chargen_nr IS NULL OR chargen_nr = '')
+ """,
+ (live_chargen, real_run_id),
+ )
+ else:
+ # Live-Charge war verfrüht — nicht übernehmen
+ conn.execute("UPDATE runs SET chargen_nr = NULL WHERE id = %s", (live_id,))
+ conn.execute("DELETE FROM runs WHERE id = %s", (live_id,))
+
+
+def update_run_progress(conn, run_id, fields):
+ allowed = {
+ "state", "progress_last", "filament_used_mm", "filament_total_mm",
+ "weight_planned_g", "estimated_s", "message", "objects", "duration_s",
+ }
+ sets = []
+ values = []
+ for key, value in fields.items():
+ if key not in allowed:
+ continue
+ if key == "objects":
+ sets.append("objects = %s")
+ values.append(Json(value if value is not None else []))
+ else:
+ sets.append("%s = %%s" % key)
+ values.append(value)
+ if not sets:
+ return get_run_row(conn, run_id)
+ values.append(run_id)
+ return conn.execute(
+ "UPDATE runs SET %s WHERE id = %%s RETURNING *" % ", ".join(sets),
+ values,
+ ).fetchone()
+
+
+def add_run_sample(conn, run_id, sample):
+ return conn.execute(
+ """
+ INSERT INTO run_samples (
+ run_id, sampled_at, nozzle, nozzle_target, bed, bed_target, chamber,
+ fan, speed_factor, extrude_factor, layer, progress,
+ filament_used_mm, mmu_tool
+ ) VALUES (
+ %(run_id)s, COALESCE(%(sampled_at)s, now()), %(nozzle)s, %(nozzle_target)s,
+ %(bed)s, %(bed_target)s, %(chamber)s, %(fan)s, %(speed_factor)s,
+ %(extrude_factor)s, %(layer)s, %(progress)s, %(filament_used_mm)s, %(mmu_tool)s
+ )
+ RETURNING id
+ """,
+ {"run_id": run_id, "sampled_at": sample.get("sampled_at"), **sample},
+ ).fetchone()
+
+
+def last_sample_at(conn, run_id):
+ row = conn.execute(
+ "SELECT sampled_at FROM run_samples WHERE run_id = %s ORDER BY sampled_at DESC LIMIT 1",
+ (run_id,),
+ ).fetchone()
+ return row["sampled_at"] if row else None
+
+
+def list_run_samples(conn, run_id, limit=2000):
+ rows = conn.execute(
+ """
+ SELECT * FROM run_samples
+ WHERE run_id = %s
+ ORDER BY sampled_at ASC
+ LIMIT %s
+ """,
+ (run_id, int(limit)),
+ ).fetchall()
+ out = []
+ for row in rows:
+ out.append({
+ "sampled_at": iso(row["sampled_at"]),
+ "nozzle": row["nozzle"],
+ "nozzle_target": row["nozzle_target"],
+ "bed": row["bed"],
+ "bed_target": row["bed_target"],
+ "chamber": row["chamber"],
+ "fan": row["fan"],
+ "speed_factor": row["speed_factor"],
+ "extrude_factor": row["extrude_factor"],
+ "layer": row["layer"],
+ "progress": row["progress"],
+ "filament_used_mm": row["filament_used_mm"],
+ "mmu_tool": row["mmu_tool"],
+ })
+ return out
+
+
+def open_segment(conn, run_id, spool_id, ace_slot, mm_start):
+ return conn.execute(
+ """
+ INSERT INTO run_segments (run_id, spool_id, ace_slot, mm_start)
+ VALUES (%s, %s, %s, %s)
+ RETURNING *
+ """,
+ (run_id, spool_id, ace_slot, float(mm_start or 0)),
+ ).fetchone()
+
+
+def close_open_segments(conn, run_id, ace_slot, mm_end):
+ """Offene Abschnitte am Platz (oder alle bei ace_slot=None) schließen."""
+ if ace_slot is None:
+ return conn.execute(
+ """
+ UPDATE run_segments
+ SET mm_end = %s, closed_at = now()
+ WHERE run_id = %s AND mm_end IS NULL
+ RETURNING *
+ """,
+ (float(mm_end or 0), run_id),
+ ).fetchall()
+ return conn.execute(
+ """
+ UPDATE run_segments
+ SET mm_end = %s, closed_at = now()
+ WHERE run_id = %s AND mm_end IS NULL
+ AND (ace_slot IS NOT DISTINCT FROM %s)
+ RETURNING *
+ """,
+ (float(mm_end or 0), run_id, ace_slot),
+ ).fetchall()
+
+
+def list_run_segments(conn, run_id):
+ rows = conn.execute(
+ """
+ SELECT seg.*, s.name AS spool_name, s.chargen_nr AS spool_chargen
+ FROM run_segments seg
+ JOIN spools s ON s.id = seg.spool_id
+ WHERE seg.run_id = %s
+ ORDER BY seg.id
+ """,
+ (run_id,),
+ ).fetchall()
+ return [
+ {
+ "id": row["id"],
+ "spool_id": row["spool_id"],
+ "ace_slot": row["ace_slot"],
+ "mm_start": float(row["mm_start"] or 0),
+ "mm_end": None if row["mm_end"] is None else float(row["mm_end"]),
+ "spool_name": row["spool_name"],
+ "spool_chargen": row["spool_chargen"] or "",
+ "created_at": iso(row["created_at"]),
+ "closed_at": iso(row["closed_at"]),
+ }
+ for row in rows
+ ]
+
+
+def ensure_run_labels(conn, run_id, objects, qty=1):
+ """Objekt- oder Stückzahl-Etiketten anlegen. Charge vom Lauf vorausgesetzt."""
+ run = get_run_row(conn, run_id)
+ if not run or not run.get("chargen_nr"):
+ return []
+ base = run["chargen_nr"]
+ existing = conn.execute(
+ "SELECT * FROM run_labels WHERE run_id = %s ORDER BY id",
+ (run_id,),
+ ).fetchall()
+ known_names = {
+ (row["object_name"] or "").strip()
+ for row in existing
+ if (row["object_name"] or "").strip()
+ }
+ names = []
+ for item in objects or []:
+ if isinstance(item, dict):
+ name = (item.get("name") or "").strip()
+ excluded = bool(item.get("excluded"))
+ else:
+ name = str(item).strip()
+ excluded = False
+ if name:
+ names.append((name, excluded))
+ if not existing and not names:
+ count = max(1, int(qty or 1))
+ for index in range(count):
+ code = base if count == 1 else "%s-%02d" % (base, index + 1)
+ conn.execute(
+ """
+ INSERT INTO run_labels (run_id, object_name, chargen_nr, excluded)
+ VALUES (%s, %s, %s, false)
+ """,
+ (run_id, "" if count == 1 else "Teil %s" % (index + 1), code),
+ )
+ elif names:
+ index = len(known_names)
+ for name, excluded in names:
+ if excluded or name in known_names:
+ continue
+ index += 1
+ code = "%s-%02d" % (base, index)
+ conn.execute(
+ """
+ INSERT INTO run_labels (run_id, object_name, chargen_nr, excluded)
+ VALUES (%s, %s, %s, false)
+ """,
+ (run_id, name, code),
+ )
+ known_names.add(name)
+ rows = conn.execute(
+ "SELECT * FROM run_labels WHERE run_id = %s ORDER BY id",
+ (run_id,),
+ ).fetchall()
+ return [
+ {
+ "object_name": row["object_name"],
+ "chargen_nr": row["chargen_nr"],
+ "excluded": bool(row["excluded"]),
+ }
+ for row in rows
+ ]
+
+
+def list_run_labels(conn, run_id):
+ rows = conn.execute(
+ "SELECT * FROM run_labels WHERE run_id = %s ORDER BY id",
+ (run_id,),
+ ).fetchall()
+ return [
+ {
+ "object_name": row["object_name"],
+ "chargen_nr": row["chargen_nr"],
+ "excluded": bool(row["excluded"]),
+ }
+ for row in rows
+ ]
+
+
+def list_run_events(conn, run_id, limit=50):
+ rows = conn.execute(
+ """
+ SELECT * FROM events
+ WHERE run_id = %s
+ ORDER BY created_at ASC, id ASC
+ LIMIT %s
+ """,
+ (run_id, int(limit)),
+ ).fetchall()
+ return [event_out(row) for row in rows]
+
+
+def attach_event_run(conn, event_id, run_id):
+ conn.execute("UPDATE events SET run_id = %s WHERE id = %s", (run_id, event_id))
+
+
+def exclusive_until(until):
+ """Reines Datum YYYY-MM-DD zählt inklusiv: Grenze = Folgetag."""
+ if until is None or until == "":
+ return None
+ text = str(until).strip()
+ if len(text) == 10 and text[4] == "-" and text[7] == "-":
+ try:
+ day = date.fromisoformat(text)
+ except ValueError:
+ return until
+ return (day + timedelta(days=1)).isoformat()
+ return until
+
+
+def statistics(conn, since=None, until=None, host="", material="", brand="", project_id=None):
+ where = ["r.state IN ('completed', 'cancelled', 'error', 'interrupted')"]
+ values = []
+ until_bound = exclusive_until(until)
+ if since:
+ where.append("r.started_at >= %s")
+ values.append(since)
+ if until_bound:
+ where.append("r.started_at < %s")
+ values.append(until_bound)
+ if host:
+ where.append("r.printer_host = %s")
+ values.append(host)
+ if project_id:
+ where.append("r.project_id = %s")
+ values.append(project_id)
+
+ spool_join = ""
+ if material or brand:
+ spool_join = (
+ "JOIN run_spools rs0 ON rs0.run_id = r.id "
+ "JOIN spools s0 ON s0.id = rs0.spool_id"
+ )
+ if material:
+ where.append("s0.material_type ILIKE %s")
+ values.append(material)
+ if brand:
+ where.append("s0.brand ILIKE %s")
+ values.append(brand)
+ clause = " AND ".join(where)
+
+ overview = conn.execute(
+ """
+ SELECT
+ COUNT(DISTINCT r.id) AS jobs,
+ COALESCE(SUM(DISTINCT r.duration_s), 0) AS duration_s,
+ COALESCE(SUM(DISTINCT COALESCE(NULLIF(r.weight_used_g, 0), r.weight_g)), 0) AS weight_g,
+ COALESCE(SUM(DISTINCT r.filament_cost + r.energy_cost), 0) AS cost,
+ COUNT(DISTINCT r.id) FILTER (WHERE r.state = 'completed') AS completed,
+ COUNT(DISTINCT r.id) FILTER (
+ WHERE r.state IN ('cancelled', 'error', 'interrupted')
+ ) AS failed,
+ COUNT(DISTINCT r.id) FILTER (WHERE r.verdict = 'ausschuss') AS scrap,
+ COUNT(DISTINCT r.id) FILTER (WHERE r.verdict = 'gut') AS good
+ FROM runs r
+ %s
+ WHERE %s
+ """ % (spool_join, clause),
+ values,
+ ).fetchone()
+
+ # Ohne Spulen-Join erneut (sonst doppelte Zeilen bei Mehrfarb)
+ run_where = ["r.state IN ('completed', 'cancelled', 'error', 'interrupted')"]
+ run_values = []
+ if since:
+ run_where.append("r.started_at >= %s")
+ run_values.append(since)
+ if until_bound:
+ run_where.append("r.started_at < %s")
+ run_values.append(until_bound)
+ if host:
+ run_where.append("r.printer_host = %s")
+ run_values.append(host)
+ if project_id:
+ run_where.append("r.project_id = %s")
+ run_values.append(project_id)
+ run_clause = " AND ".join(run_where)
+
+ by_printer = conn.execute(
+ """
+ SELECT r.printer_name, r.printer_host,
+ COUNT(*) AS jobs,
+ COALESCE(SUM(r.duration_s), 0) AS duration_s,
+ COALESCE(SUM(COALESCE(NULLIF(r.weight_used_g, 0), r.weight_g)), 0) AS weight_g,
+ COALESCE(SUM(r.filament_cost + r.energy_cost), 0) AS cost,
+ COUNT(*) FILTER (WHERE r.state = 'completed') AS completed
+ FROM runs r
+ WHERE %s
+ GROUP BY r.printer_name, r.printer_host
+ ORDER BY duration_s DESC
+ """ % run_clause,
+ run_values,
+ ).fetchall()
+
+ mat_where = list(run_where)
+ mat_values = list(run_values)
+ if material:
+ mat_where.append("s.material_type ILIKE %s")
+ mat_values.append(material)
+ if brand:
+ mat_where.append("s.brand ILIKE %s")
+ mat_values.append(brand)
+ mat_clause = " AND ".join(mat_where)
+
+ by_material = conn.execute(
+ """
+ SELECT s.material_type, s.brand,
+ COALESCE(SUM(rs.weight_g), 0) AS weight_g,
+ COALESCE(SUM(rs.cost), 0) AS cost,
+ COUNT(DISTINCT rs.run_id) AS jobs
+ FROM run_spools rs
+ 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
+ ORDER BY weight_g DESC
+ """ % mat_clause,
+ mat_values,
+ ).fetchall()
+
+ estimate = conn.execute(
+ """
+ SELECT
+ percentile_cont(0.5) WITHIN GROUP (
+ ORDER BY r.duration_s / NULLIF(r.estimated_s, 0)
+ ) AS median_ratio,
+ COUNT(*) FILTER (WHERE r.estimated_s > 0 AND r.duration_s > 0) AS counted
+ FROM runs r
+ WHERE %s AND r.state = 'completed' AND r.estimated_s > 0
+ """ % run_clause,
+ run_values,
+ ).fetchone()
+
+ ov = overview or {}
+ # Bei Spulen-Join können SUM(DISTINCT …) ungenau sein — Überblick ohne Join neu
+ if spool_join:
+ overview2 = conn.execute(
+ """
+ SELECT
+ COUNT(*) AS jobs,
+ COALESCE(SUM(r.duration_s), 0) AS duration_s,
+ COALESCE(SUM(COALESCE(NULLIF(r.weight_used_g, 0), r.weight_g)), 0) AS weight_g,
+ COALESCE(SUM(r.filament_cost + r.energy_cost), 0) AS cost,
+ COUNT(*) FILTER (WHERE r.state = 'completed') AS completed,
+ COUNT(*) FILTER (WHERE r.state IN ('cancelled', 'error', 'interrupted')) AS failed,
+ COUNT(*) FILTER (WHERE r.verdict = 'ausschuss') AS scrap,
+ COUNT(*) FILTER (WHERE r.verdict = 'gut') AS good
+ FROM runs r
+ WHERE %s
+ AND (
+ NOT EXISTS (SELECT 1 FROM run_spools rs WHERE rs.run_id = r.id)
+ OR EXISTS (
+ SELECT 1 FROM run_spools rs
+ 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 []),
+ ).fetchone()
+ ov = overview2 or ov
+
+ return {
+ "overview": {
+ "jobs": int(ov.get("jobs") or 0),
+ "duration_s": float(ov.get("duration_s") or 0),
+ "weight_g": float(ov.get("weight_g") or 0),
+ "cost": float(ov.get("cost") or 0),
+ "completed": int(ov.get("completed") or 0),
+ "failed": int(ov.get("failed") or 0),
+ "scrap": int(ov.get("scrap") or 0),
+ "good": int(ov.get("good") or 0),
+ },
+ "by_printer": [
+ {
+ "printer_name": row["printer_name"],
+ "printer_host": row["printer_host"],
+ "jobs": int(row["jobs"] or 0),
+ "duration_s": float(row["duration_s"] or 0),
+ "weight_g": float(row["weight_g"] or 0),
+ "cost": float(row["cost"] or 0),
+ "completed": int(row["completed"] or 0),
+ }
+ for row in by_printer
+ ],
+ "by_material": [
+ {
+ "material_type": row["material_type"] or "",
+ "brand": row["brand"] or "",
+ "weight_g": float(row["weight_g"] or 0),
+ "cost": float(row["cost"] or 0),
+ "jobs": int(row["jobs"] or 0),
+ }
+ for row in by_material
+ ],
+ "estimate_ratio": float((estimate or {}).get("median_ratio") or 0) or None,
+ "estimate_count": int((estimate or {}).get("counted") or 0),
+ }
+
+
def idea_out(row):
return {
"id": row["id"],
@@ -843,6 +1874,7 @@ def event_out(row):
"kind": row["kind"],
"message": row["message"] or "",
"filename": row["filename"] or "",
+ "run_id": row.get("run_id"),
"created_at": iso(row["created_at"]),
"read_at": iso(row["read_at"]),
"unread": row["read_at"] is None,
@@ -853,14 +1885,22 @@ def event_out(row):
QUIET_EVENT_KINDS = {"ablauf", "start", "fertig"}
-def add_event(conn, host, printer, kind, message, filename=""):
+def add_event(conn, host, printer, kind, message, filename="", run_id=None):
row = conn.execute(
"""
- INSERT INTO events (host, printer, kind, message, filename, read_at)
- VALUES (%s, %s, %s, %s, %s, CASE WHEN %s THEN now() ELSE NULL END)
+ INSERT INTO events (host, printer, kind, message, filename, run_id, read_at)
+ VALUES (%s, %s, %s, %s, %s, %s, CASE WHEN %s THEN now() ELSE NULL END)
RETURNING *
""",
- (host or "", printer or "", kind, message or "", filename or "", kind in QUIET_EVENT_KINDS),
+ (
+ host or "",
+ printer or "",
+ kind,
+ message or "",
+ filename or "",
+ run_id,
+ kind in QUIET_EVENT_KINDS,
+ ),
).fetchone()
return event_out(row)
@@ -1281,6 +2321,29 @@ def set_bed_needs_clear(conn, host, needs, message=""):
)
+def ensure_bed_after_print(conn, host, ended_at=None, message=""):
+ """Platte belegen, wenn dieser Druck nach der letzten Quittierung endete.
+
+ Deckt verpasste Watcher-Übergänge ab (Neustart, Poll-Lücke): History und
+ erster Blick auf state=complete setzen needs_clear nur, wenn cleared_at
+ vor dem Druckende liegt oder noch fehlt.
+ """
+ host = (host or "").strip()
+ if not host:
+ return False
+ row = conn.execute(
+ "SELECT needs_clear, cleared_at FROM bed_status WHERE host = %s",
+ (host,),
+ ).fetchone()
+ if row and row["needs_clear"]:
+ return False
+ cleared = row["cleared_at"] if row else None
+ if ended_at is not None and cleared is not None and cleared >= ended_at:
+ return False
+ set_bed_needs_clear(conn, host, True, message or "Druckende")
+ return True
+
+
def user_count(conn):
return int(conn.execute("SELECT count(*) AS n FROM users").fetchone()["n"])
diff --git a/grotti/usage.py b/grotti/usage.py
new file mode 100644
index 0000000..4eaea67
--- /dev/null
+++ b/grotti/usage.py
@@ -0,0 +1,164 @@
+"""Ist-Gramm aus Moonraker-Filamentzählern und Spulenanteilen."""
+
+from __future__ import annotations
+
+
+def as_float(value, default=0.0):
+ if value is None or value is False:
+ return float(default)
+ if isinstance(value, bool):
+ return float(default)
+ if isinstance(value, (int, float)):
+ return float(value)
+ if isinstance(value, str):
+ try:
+ return float(value.replace(",", ".").strip())
+ except ValueError:
+ return float(default)
+ if isinstance(value, list):
+ return sum(as_float(item) for item in value)
+ return float(default)
+
+
+def weight_list(metadata):
+ """Gramm je Farbe aus filament_weight_total (Zahl oder Liste)."""
+ raw = (metadata or {}).get("filament_weight_total")
+ if isinstance(raw, list):
+ return [as_float(item) for item in raw]
+ total = as_float(raw)
+ return [total] if total > 0 else []
+
+
+def weight_sum(metadata):
+ return sum(weight_list(metadata))
+
+
+def filament_total_mm(metadata, job=None):
+ raw = (metadata or {}).get("filament_total")
+ if raw in (None, "", [], 0, 0.0) and isinstance(job, dict):
+ raw = job.get("filament_total")
+ if isinstance(raw, list):
+ return sum(as_float(item) for item in raw)
+ return as_float(raw)
+
+
+def filament_used_mm(job):
+ if not isinstance(job, dict):
+ return 0.0
+ return as_float(job.get("filament_used"))
+
+
+def planned_shares(metadata, ace_map, target_count):
+ """Geplante Gramm je Ziel-Spule. Index entspricht targets-Reihenfolge."""
+ 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 = []
+ for item in mapping:
+ paint = item.get("paint_index")
+ try:
+ index = int(paint)
+ except (TypeError, ValueError):
+ index = len(shares)
+ if 0 <= index < len(weights):
+ shares.append(weights[index])
+ else:
+ shares.append(0.0)
+ if sum(shares) > 0:
+ return shares
+ if weights and target_count == len(weights):
+ return list(weights)
+ total = sum(weights) if weights else 0.0
+ if target_count <= 0:
+ return []
+ if total <= 0:
+ return [0.0] * target_count
+ each = total / target_count
+ return [each] * target_count
+
+
+def resolve_used_grams(planned_g, used_mm, total_mm, progress, state, duration_s):
+ """
+ Ist-Gramm und Herkunft.
+ measured = filament_used, estimate = Fortschritt oder Soll, none = nichts.
+ """
+ planned = max(0.0, as_float(planned_g))
+ used = max(0.0, as_float(used_mm))
+ total = max(0.0, as_float(total_mm))
+ prog = max(0.0, min(1.0, as_float(progress)))
+ duration = max(0.0, as_float(duration_s))
+ terminal_abort = state in ("cancelled", "error", "interrupted")
+
+ if used > 0.5 and total > 0:
+ grams = planned * (used / total) if planned > 0 else 0.0
+ if grams <= 0 and used > 0.5:
+ # Länge ohne Gewicht: grob 1.24 g/cm³ · 1.75 mm → ~3 g/m
+ grams = used * 0.003
+ return max(0.0, grams), "measured"
+
+ if used > 0.5 and planned > 0 and total <= 0:
+ # Millimeter da, Gesamtlänge fehlt — Fortschritt oder Dauer helfen nicht
+ return used * 0.003, "estimate"
+
+ if terminal_abort and used <= 0.5:
+ if prog > 0.005 and planned > 0:
+ return planned * prog, "estimate"
+ return 0.0, "none"
+
+ if planned > 0:
+ if prog > 0.005 and prog < 0.995 and state != "completed":
+ return planned * prog, "estimate"
+ return planned, "estimate" if state == "completed" else "none"
+
+ return 0.0, "none"
+
+
+def split_used(used_g, planned_shares_list, multi):
+ """
+ Ist-Gramm auf Spulen verteilen.
+ multi=True → Anteile sind Schätzwerte, Summe bleibt used_g.
+ """
+ used = max(0.0, as_float(used_g))
+ shares = [max(0.0, as_float(s)) for s in (planned_shares_list or [])]
+ if not shares:
+ return [], False
+ total_plan = sum(shares)
+ if total_plan <= 0:
+ each = used / len(shares)
+ parts = [each] * len(shares)
+ else:
+ parts = [used * (share / total_plan) for share in shares]
+ is_estimate = bool(multi) and len(shares) > 1
+ return parts, is_estimate
+
+
+def segment_deltas(segments, final_mm):
+ """
+ Abschnitte mit mm_start/mm_end → Delta-mm je (spool_id, ace_slot).
+ Offene Abschnitte schließen auf final_mm.
+ """
+ end = max(0.0, as_float(final_mm))
+ totals = {}
+ order = []
+ for seg in segments or []:
+ start = max(0.0, as_float(seg.get("mm_start")))
+ raw_end = seg.get("mm_end")
+ stop = end if raw_end is None else max(0.0, as_float(raw_end))
+ delta = max(0.0, stop - start)
+ key = (seg.get("spool_id"), seg.get("ace_slot"))
+ if key not in totals:
+ totals[key] = 0.0
+ order.append(key)
+ totals[key] += delta
+ return [(key[0], key[1], totals[key]) for key in order]
+
+
+def grams_from_mm(delta_mm, total_mm, planned_g):
+ delta = max(0.0, as_float(delta_mm))
+ total = max(0.0, as_float(total_mm))
+ planned = max(0.0, as_float(planned_g))
+ if delta <= 0:
+ return 0.0
+ if total > 0 and planned > 0:
+ return planned * (delta / total)
+ return delta * 0.003
diff --git a/grotti/version.py b/grotti/version.py
index 54b8270..ab791d0 100644
--- a/grotti/version.py
+++ b/grotti/version.py
@@ -1,3 +1,3 @@
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
-__version__ = "2.2.6"
+__version__ = "2.5.1"
diff --git a/grotti/watcher.py b/grotti/watcher.py
index 4e5255c..38dc966 100644
--- a/grotti/watcher.py
+++ b/grotti/watcher.py
@@ -4,10 +4,13 @@ from __future__ import annotations
import threading
import time
+from datetime import datetime, timezone
-from grotti import moonraker, notify, store
+from grotti import moonraker, notify, store, usage
from grotti.db import connect
+SAMPLE_EVERY_S = 30
+
class StateWatcher:
def __init__(self, database_url, cfg, interval=10):
@@ -21,6 +24,7 @@ class StateWatcher:
self._updated_at = 0.0
self._stop = threading.Event()
self._thread = None
+ self._last_sample = {}
def start(self):
if self._thread and self._thread.is_alive():
@@ -79,6 +83,7 @@ class StateWatcher:
prev = self._prev.get(host) or {}
prev_state = (prev.get("state") or "").lower()
prev_offline = bool(prev.get("offline"))
+ run_id = None
if offline:
hits = self._offline_hits.get(host, 0) + 1
@@ -89,16 +94,32 @@ class StateWatcher:
return
self._offline_hits[host] = 0
+ starting = (
+ bool(prev_state)
+ and prev_state not in ("printing", "paused")
+ and state == "printing"
+ )
+ if starting:
+ store.abandon_live_runs(conn, host)
+ if state in ("printing", "paused"):
+ run = self._track_print(conn, host, name, filename, live, state)
+ run_id = run["id"] if run else None
+
if prev_offline:
- self._emit(conn, host, name, "offline", "%s ist wieder erreichbar." % name, filename)
- elif prev_state and prev_state not in ("printing", "paused") and state == "printing":
- self._emit(conn, host, name, "start", "%s druckt %s" % (name, filename or "einen Auftrag"), filename)
+ self._emit(conn, host, name, "offline", "%s ist wieder erreichbar." % name, filename, run_id)
+ elif starting:
+ self._emit(
+ conn, host, name, "start",
+ "%s druckt %s" % (name, filename or "einen Auftrag"),
+ filename, run_id,
+ )
+ self._ensure_start_segment(conn, host, filename, live, run_id)
elif prev_state == "printing" and state == "paused":
- self._emit(conn, host, name, "start", "%s pausiert." % name, filename)
+ self._emit(conn, host, name, "start", "%s pausiert." % name, filename, run_id)
elif prev_state == "paused" and state == "printing":
- self._emit(conn, host, name, "start", "%s druckt weiter." % name, filename)
+ self._emit(conn, host, name, "start", "%s druckt weiter." % name, filename, run_id)
elif prev_state == "printing" and state == "complete":
- store.set_bed_needs_clear(conn, host, True, filename or "fertig")
+ store.ensure_bed_after_print(conn, host, message=filename or "fertig")
self._emit(
conn,
host,
@@ -106,8 +127,8 @@ class StateWatcher:
"fertig",
"%s fertig: %s" % (name, filename or "Druck"),
filename,
+ run_id or (prev.get("run_id")),
)
- # Protokoll und Spulenabzug nicht erst nach manuellem Knopf
try:
from grotti import jobs
@@ -115,7 +136,7 @@ class StateWatcher:
except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
elif prev_state in ("printing", "paused") and state in ("cancelled", "standby"):
- store.set_bed_needs_clear(conn, host, True, filename or state)
+ store.ensure_bed_after_print(conn, host, message=filename or state)
self._emit(
conn,
host,
@@ -123,10 +144,17 @@ class StateWatcher:
"abbruch",
"%s: Druck abgebrochen (%s)" % (name, filename or "ohne Datei"),
filename,
+ run_id or prev.get("run_id"),
)
+ try:
+ from grotti import jobs
+
+ jobs.sync_history(conn, self.cfg)
+ except Exception as exc:
+ print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
elif state == "error" and prev_state != "error":
if prev_state == "printing" or prev_state == "paused":
- store.set_bed_needs_clear(conn, host, True, filename or "Fehler")
+ store.ensure_bed_after_print(conn, host, message=filename or "Fehler")
msg = (live.get("message") or "").strip()
if not msg or msg.lower() in ("error", "fehler"):
msg = "Ursache unbekannt — in Mainsail prüfen"
@@ -138,18 +166,19 @@ class StateWatcher:
"fehler",
"%s: %s · Heizung aus" % (name, msg),
filename,
+ run_id or prev.get("run_id"),
)
+ try:
+ from grotti import jobs
+
+ jobs.sync_history(conn, self.cfg)
+ except Exception as exc:
+ print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
elif state == "error":
- # Schon im Fehler, Heizer laufen noch → aus
if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0:
self._cool_heaters(host)
- elif state == "complete":
- known = conn.execute(
- "SELECT 1 FROM bed_status WHERE host = %s", (host,)
- ).fetchone()
- if not known:
- # Erster Stand nach Deploy: fertiger Druck ohne Quittierung
- store.set_bed_needs_clear(conn, host, True, filename or "fertig")
+ elif state == "complete" and (not prev_state or prev_offline):
+ store.ensure_bed_after_print(conn, host, message=filename or "fertig")
spool_warn = bool(prev.get("spool_warn"))
if state == "printing" and not spool_warn:
@@ -160,8 +189,108 @@ class StateWatcher:
"offline": False,
"filename": filename,
"spool_warn": spool_warn if state == "printing" else False,
+ "run_id": run_id or (prev.get("run_id") if state in ("printing", "paused") else None),
}
+ def _track_print(self, conn, host, name, filename, live, state):
+ sent = store.latest_send(conn, host, filename) if filename else None
+ spool_id = (sent or {}).get("spool_id")
+ 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"))
+ total_mm = usage.as_float(live.get("filament_total"))
+ used_mm = usage.as_float(live.get("filament_used"))
+ progress = usage.as_float(live.get("progress"))
+ estimated = usage.as_float(live.get("estimated"))
+ store.update_run_progress(conn, run["id"], {
+ "state": "paused" if state == "paused" else "in_progress",
+ "progress_last": progress,
+ "filament_used_mm": used_mm,
+ "filament_total_mm": total_mm,
+ "weight_planned_g": planned,
+ "estimated_s": estimated,
+ "message": (live.get("message") or "") if state == "error" else "",
+ "objects": objects,
+ "duration_s": usage.as_float(live.get("elapsed")),
+ })
+ store.ensure_run_chargen(conn, run["id"])
+ self._maybe_sample(conn, run["id"], live, used_mm)
+ return store.get_run_row(conn, run["id"])
+
+ def _merge_objects(self, current, exclude):
+ known = {}
+ if isinstance(current, str):
+ try:
+ import json
+ current = json.loads(current)
+ except Exception:
+ current = []
+ for item in current or []:
+ if isinstance(item, dict) and item.get("name"):
+ known[item["name"]] = {
+ "name": item["name"],
+ "excluded": bool(item.get("excluded")),
+ }
+ exclude = exclude or {}
+ excluded = set(exclude.get("excluded") or [])
+ for item in exclude.get("objects") or []:
+ name = (item.get("name") if isinstance(item, dict) else str(item)).strip()
+ if not name:
+ continue
+ known[name] = {"name": name, "excluded": name in excluded}
+ for name in excluded:
+ if name not in known:
+ known[name] = {"name": name, "excluded": True}
+ return list(known.values())
+
+ def _maybe_sample(self, conn, run_id, live, used_mm):
+ now = time.monotonic()
+ last = self._last_sample.get(run_id, 0)
+ if now - last < SAMPLE_EVERY_S:
+ return
+ self._last_sample[run_id] = now
+ store.add_run_sample(conn, run_id, {
+ "sampled_at": datetime.now(timezone.utc),
+ "nozzle": live.get("nozzle"),
+ "nozzle_target": live.get("nozzle_target"),
+ "bed": live.get("bed"),
+ "bed_target": live.get("bed_target"),
+ "chamber": live.get("chamber"),
+ "fan": live.get("fan"),
+ "speed_factor": live.get("speed_factor"),
+ "extrude_factor": live.get("extrude_factor"),
+ "layer": live.get("layer") or 0,
+ "progress": live.get("progress"),
+ "filament_used_mm": used_mm,
+ "mmu_tool": live.get("mmu_tool"),
+ })
+
+ def _ensure_start_segment(self, conn, host, filename, live, run_id):
+ """Nur externe Einzelspule: ein Abschnitt. ACE-Farben ohne Wechsel → Anteil-Split."""
+ if not run_id:
+ return
+ existing = store.list_run_segments(conn, run_id)
+ if existing:
+ return
+ sent = store.latest_send(conn, host, filename) if filename else None
+ ace = (sent or {}).get("ace_map") or []
+ from grotti import acm
+
+ mapping = acm.normalize_mapping(ace) if ace else []
+ if mapping:
+ return
+ used_mm = usage.as_float(live.get("filament_used"))
+ rows = conn.execute(
+ """
+ SELECT id FROM spools
+ WHERE printer_host = %s AND ace_slot IS NULL
+ ORDER BY id LIMIT 1
+ """,
+ (host,),
+ ).fetchone()
+ if rows:
+ store.open_segment(conn, run_id, rows["id"], None, used_mm)
+
def _cool_heaters(self, host):
try:
moonraker.heaters_off(host)
@@ -193,5 +322,8 @@ class StateWatcher:
)
return True
- def _emit(self, conn, host, printer, kind, message, filename):
- notify.record(conn, self.cfg, host, printer, kind, message, filename, dedupe_s=60)
+ def _emit(self, conn, host, printer, kind, message, filename, run_id=None):
+ notify.record(
+ conn, self.cfg, host, printer, kind, message, filename,
+ dedupe_s=60, run_id=run_id,
+ )
diff --git a/tests/test_ace_steer.py b/tests/test_ace_steer.py
new file mode 100644
index 0000000..cb1e1ee
--- /dev/null
+++ b/tests/test_ace_steer.py
@@ -0,0 +1,103 @@
+"""ACE-Trockner und Platz-Schreiben: Grenzen und Mapping."""
+
+import pytest
+
+from grotti.moonraker import (
+ PrinterError,
+ ace_hub_index,
+ extract_dryers,
+ spool_rgb,
+ steuern,
+)
+
+
+def test_extract_dryers_skips_dummy_unit():
+ status = {
+ "mmu_machine": {
+ "num_units": 1,
+ "unit_0": {
+ "num_gates": 4,
+ "dryer_status": "drying",
+ "dryer_temp": 44,
+ "dryer_target_temp": 45,
+ "dryer_remaining": 120,
+ "dryer_humidity": 20,
+ },
+ "unit_1": {
+ "num_gates": 0,
+ "dryer_status": "stop",
+ },
+ }
+ }
+ dryers = extract_dryers(status)
+ assert len(dryers) == 1
+ assert dryers[0]["id"] == 0
+ assert dryers[0]["status"] == "drying"
+ assert dryers[0]["target"] == 45
+ assert dryers[0]["remain"] == 120
+
+
+def test_extract_dryers_empty_without_machine():
+ assert extract_dryers({}) == []
+ assert extract_dryers({"mmu_machine": {}}) == []
+
+
+def test_ace_hub_index():
+ assert ace_hub_index(1) == (0, 0)
+ assert ace_hub_index(4) == (0, 3)
+ assert ace_hub_index(5) == (1, 0)
+ assert ace_hub_index(8) == (1, 3)
+ with pytest.raises(PrinterError):
+ ace_hub_index(0)
+ with pytest.raises(PrinterError):
+ ace_hub_index(9)
+
+
+def test_spool_rgb_from_colors():
+ assert spool_rgb({"colors": [{"name": "Blau", "hex": "#002B49"}]}) == {
+ "R": 0, "G": 43, "B": 73,
+ }
+ assert spool_rgb({"colors": [], "color": "Weiß"}) is None
+ assert spool_rgb({"color": "#ff00aa"}) == {"R": 255, "G": 0, "B": 170}
+
+
+def test_dryer_start_bounds(monkeypatch):
+ calls = []
+
+ def fake_post(host, path, payload, timeout=20):
+ calls.append((host, path, payload))
+ return {"result": "ok"}
+
+ monkeypatch.setattr("grotti.moonraker.post_json", fake_post)
+ steuern("10.10.1.60", "dryer_start", {"id": 0, "temp": 45, "duration": 240})
+ assert calls[0][1] == "/server/filament_hub/start_drying"
+ assert calls[0][2]["fan_speed"] == 0
+
+ with pytest.raises(PrinterError):
+ steuern("10.10.1.60", "dryer_start", {"id": 2, "temp": 45, "duration": 60})
+ with pytest.raises(PrinterError):
+ steuern("10.10.1.60", "dryer_start", {"id": 0, "temp": 20, "duration": 60})
+ with pytest.raises(PrinterError):
+ steuern("10.10.1.60", "dryer_start", {"id": 0, "temp": 45, "duration": 0})
+
+
+def test_slot_setzen_payload(monkeypatch):
+ calls = []
+
+ def fake_post(host, path, payload, timeout=20):
+ calls.append((host, path, payload))
+ return {"result": "ok"}
+
+ monkeypatch.setattr("grotti.moonraker.post_json", fake_post)
+ steuern(
+ "10.10.1.60",
+ "slot_setzen",
+ {"id": 0, "index": 2, "type": "PLA", "color": {"R": 1, "G": 2, "B": 3}},
+ )
+ assert calls[0][1] == "/printer/filament_hub/set_filament_info"
+ assert calls[0][2] == {
+ "id": 0,
+ "index": 2,
+ "type": "PLA",
+ "color": {"R": 1, "G": 2, "B": 3},
+ }
diff --git a/tests/test_usage.py b/tests/test_usage.py
new file mode 100644
index 0000000..a471cb5
--- /dev/null
+++ b/tests/test_usage.py
@@ -0,0 +1,75 @@
+"""Ist-Gramm und Spulenanteile ohne Datenbank."""
+
+import unittest
+from pathlib import Path
+import sys
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+from grotti import usage
+
+
+class ResolveUsedTests(unittest.TestCase):
+ def test_measured_from_mm(self):
+ grams, source = usage.resolve_used_grams(100, 500, 1000, 0, "cancelled", 60)
+ self.assertAlmostEqual(grams, 50)
+ self.assertEqual(source, "measured")
+
+ def test_abort_before_extrude(self):
+ grams, source = usage.resolve_used_grams(100, 0, 1000, 0, "cancelled", 5)
+ self.assertEqual(grams, 0)
+ self.assertEqual(source, "none")
+
+ def test_abort_progress_estimate(self):
+ grams, source = usage.resolve_used_grams(100, 0, 0, 0.25, "cancelled", 120)
+ self.assertAlmostEqual(grams, 25)
+ self.assertEqual(source, "estimate")
+
+ def test_completed_keeps_planned_without_mm(self):
+ grams, source = usage.resolve_used_grams(80, 0, 0, 1, "completed", 3600)
+ self.assertAlmostEqual(grams, 80)
+ self.assertEqual(source, "estimate")
+
+ def test_overshoot_no_cap(self):
+ grams, source = usage.resolve_used_grams(100, 1200, 1000, 1, "completed", 3600)
+ self.assertAlmostEqual(grams, 120)
+ self.assertEqual(source, "measured")
+
+
+class SplitTests(unittest.TestCase):
+ def test_single_is_measured(self):
+ parts, estimate = usage.split_used(50, [100], multi=False)
+ self.assertEqual(parts, [50])
+ self.assertFalse(estimate)
+
+ def test_multi_is_estimate(self):
+ parts, estimate = usage.split_used(90, [30, 60], multi=True)
+ self.assertAlmostEqual(parts[0], 30)
+ self.assertAlmostEqual(parts[1], 60)
+ self.assertTrue(estimate)
+
+
+class SegmentTests(unittest.TestCase):
+ def test_deltas(self):
+ segs = [
+ {"spool_id": 1, "ace_slot": None, "mm_start": 0, "mm_end": 400},
+ {"spool_id": 2, "ace_slot": None, "mm_start": 400, "mm_end": None},
+ ]
+ deltas = usage.segment_deltas(segs, 1000)
+ self.assertEqual(deltas[0], (1, None, 400))
+ self.assertEqual(deltas[1], (2, None, 600))
+
+ def test_grams_from_mm(self):
+ self.assertAlmostEqual(usage.grams_from_mm(250, 1000, 80), 20)
+
+
+class WeightListTests(unittest.TestCase):
+ def test_list_and_sum(self):
+ meta = {"filament_weight_total": [10, 20, 5]}
+ self.assertEqual(usage.weight_list(meta), [10, 20, 5])
+ self.assertEqual(usage.weight_sum(meta), 35)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/web/app.py b/web/app.py
index 1e00182..892f932 100644
--- a/web/app.py
+++ b/web/app.py
@@ -37,7 +37,7 @@ IDEA_STATUS = {"offen", "spaeter", "erledigt"}
PRINTER_FAMILIES = set(FAMILY_LABELS) | {""}
STEER_ACTIONS = {
"nozzle", "bed", "speed", "flow", "fan", "emergency_stop", "clear_error",
- "exclude_object",
+ "exclude_object", "dryer_start", "dryer_stop", "slot_setzen",
}
@@ -580,6 +580,58 @@ def create_app(cfg):
def protokoll():
return page("protokoll.html", "Protokoll")
+ @app.get("/protokoll/")
+ def protokoll_detail(run_id):
+ row = store.get_run_row(conn(), run_id)
+ if not row:
+ abort(404)
+ return page("bericht.html", "Druckbericht")
+
+ @app.get("/statistik")
+ def statistik_page():
+ return page("statistik.html", "Statistik")
+
+ @app.get("/etikett/lauf/")
+ def etikett_lauf(run_id):
+ row = store.get_run_row(conn(), run_id)
+ if not row:
+ abort(404)
+ run = store.run_out(row)
+ if not run.get("chargen_nr"):
+ store.ensure_run_chargen(conn(), run_id, run.get("weight_used_g"))
+ row = store.get_run_row(conn(), run_id)
+ run = store.run_out(row)
+ if not run.get("chargen_nr"):
+ abort(404)
+ labels = store.list_run_labels(conn(), run_id)
+ mode = (request.args.get("mode") or "druck").strip()
+ qty = max(1, min(50, int(request.args.get("qty") or 1)))
+ if mode == "objekt":
+ labels = store.ensure_run_labels(conn(), run_id, run.get("objects") or [], qty=qty)
+ sheets = [lab for lab in labels if not lab.get("excluded")]
+ else:
+ sheets = [{"object_name": "", "chargen_nr": run["chargen_nr"], "excluded": False}]
+ if not sheets:
+ abort(404)
+ spools = store.etikett_spools_for_run(conn(), run)
+ base = grotti_config.public_base(cfg, request.url_root)
+ target_g = float(run.get("weight_planned_g") or run.get("weight_g") or 0)
+ for sheet in sheets:
+ sheet["qr_url"] = "%s/protokoll/%s" % (base, run_id)
+ sheet["target_g"] = target_g
+ if sheet.get("object_name"):
+ sheet["qr_url"] += "#objekt-%s" % quote(sheet["object_name"])
+ return render_template(
+ "etikett_lauf.html",
+ page="etikett",
+ title="Etikett %s" % run["chargen_nr"],
+ token="",
+ run=run,
+ sheets=sheets,
+ spools=spools,
+ target_g=target_g,
+ )
+
@app.get("/ideen")
def ideen():
return page("ideen.html", "Ideen")
@@ -672,6 +724,9 @@ def create_app(cfg):
item["queue_failed"] = queue.get("failed")
item["queue_waiting"] = queue.get("waiting", 0)
item["queue_waiting_next"] = queue.get("waiting_next", "")
+ live_run = store.find_live_run(conn(), host) if host else None
+ item["live_run_id"] = (live_run or {}).get("id")
+ item["live_chargen"] = (live_run or {}).get("chargen_nr") or ""
by_id = {row["host"]: row for row in rows}
if request.args.get("live", "1") == "0":
@@ -873,7 +928,7 @@ def create_app(cfg):
row = store.get_file_row(conn(), file_id)
if not row:
abort(404)
- png = library.preview_png(row)
+ png = library.preview_png(row, conn=conn(), data_dir=cfg["data_dir"])
if not png:
abort(404)
return png, 200, {"Content-Type": "image/png", "Cache-Control": "private, max-age=300"}
@@ -883,8 +938,8 @@ def create_app(cfg):
row = store.get_file_row(conn(), file_id)
if not row:
raise GrottiError("Die Datei fehlt.", "datei", 404)
- path = Path(row["stored_path"])
- if not path.is_file():
+ path = library.resolve_stored_path(conn(), row, cfg["data_dir"])
+ if path is None or not path.is_file():
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
cache_dir = Path(cfg["data_dir"]) / "toolpath"
cache_dir.mkdir(parents=True, exist_ok=True)
@@ -1506,11 +1561,28 @@ def create_app(cfg):
@app.get("/api/v1/spulen/by-code/")
def spool_by_code(code):
- row = store.get_spool_by_code(conn(), code)
+ text = (code or "").strip()
+ if text.upper().startswith("D-"):
+ run = store.get_run_by_chargen(conn(), text)
+ if not run:
+ raise GrottiError("Kein Druck mit dieser Charge.", "charge", 404)
+ return jsonify({"kind": "lauf", "run": store.run_out(run), "redirect": "/protokoll/%s" % run["id"]})
+ row = store.get_spool_by_code(conn(), text)
if not row:
raise GrottiError("Keine Spule mit diesem Code.", "spule", 404)
return jsonify(row)
+ @app.get("/api/v1/spulen//akte")
+ def spool_akte(spool_id):
+ spool = store.get_spool_row(conn(), spool_id)
+ if not spool:
+ raise GrottiError("Die Spule fehlt.", "spule", 404)
+ return jsonify({
+ "spool": store.spool_out(spool),
+ "runs": store.list_spool_runs(conn(), spool_id),
+ "stats": store.spool_usage_stats(conn(), spool_id),
+ })
+
@app.post("/api/v1/spulen")
def spool_create():
return jsonify(store.create_spool(conn(), spool_fields(body()))), 201
@@ -1531,6 +1603,18 @@ def create_app(cfg):
spool = store.get_spool_row(conn(), spool_id)
if not spool:
raise GrottiError("Die Spule fehlt.", "spule", 404)
+ live_run = store.find_live_run(conn(), host)
+ mm_now = float((live_run or {}).get("filament_used_mm") or 0)
+ printing = bool(
+ live_run and (live_run.get("state") or "") in ("in_progress", "printing", "paused")
+ )
+ if printing:
+ try:
+ snap = moonraker.query(host)
+ if isinstance(snap.get("filament_used"), (int, float)):
+ mm_now = float(snap["filament_used"])
+ except Exception:
+ pass
# Alte Rolle am Platz / externe Rolle am Host freimachen
if slot:
old = conn().execute(
@@ -1542,22 +1626,32 @@ def create_app(cfg):
"SELECT * FROM spools WHERE printer_host = %s AND ace_slot IS NULL AND id <> %s",
(host, spool_id),
).fetchall()
+ printer = store.printer_by_host(conn(), host) or {}
+ if printing:
+ closed = store.close_open_segments(conn(), live_run["id"], slot, mm_now)
+ if not closed and old:
+ # Erster Wechsel: alter Abschnitt von 0 bis jetzt
+ for row in old:
+ store.open_segment(conn(), live_run["id"], row["id"], slot, 0)
+ store.close_open_segments(conn(), live_run["id"], slot, mm_now)
for row in old:
store.update_spool(conn(), row["id"], {"printer_host": "", "ace_slot": None})
store.add_event(
conn(),
host,
- (store.printer_by_host(conn(), host) or {}).get("name") or host,
+ printer.get("name") or host,
"spule",
"%s: „%s“ ins Regal%s" % (
- (store.printer_by_host(conn(), host) or {}).get("name") or host,
+ printer.get("name") or host,
row["name"],
(" (Platz %s)" % slot) if slot else "",
),
- "",
+ (live_run or {}).get("filename") or "",
+ run_id=(live_run or {}).get("id"),
)
updated = store.update_spool(conn(), spool_id, {"printer_host": host, "ace_slot": slot})
- printer = store.printer_by_host(conn(), host) or {}
+ if printing:
+ store.open_segment(conn(), live_run["id"], spool_id, slot, mm_now)
store.add_event(
conn(),
host,
@@ -1568,7 +1662,8 @@ def create_app(cfg):
updated["name"],
(" auf Platz %s" % slot) if slot else "",
),
- "",
+ (live_run or {}).get("filename") or "",
+ run_id=(live_run or {}).get("id"),
)
return jsonify(updated)
@@ -1704,6 +1799,36 @@ def create_app(cfg):
def run_list():
return jsonify(store.list_runs(conn()))
+ @app.get("/api/v1/laeufe/")
+ def run_get(run_id):
+ row = store.get_run_row(conn(), run_id)
+ if not row:
+ raise GrottiError("Der Lauf fehlt.", "lauf", 404)
+ run = store.run_out(row)
+ if not run.get("chargen_nr"):
+ store.ensure_run_chargen(conn(), run_id)
+ row = store.get_run_row(conn(), run_id) or row
+ run = store.run_out(row)
+ if run.get("chargen_nr") and run.get("objects"):
+ store.ensure_run_labels(conn(), run_id, run.get("objects") or [], qty=1)
+ file_row = store.get_file_row(conn(), run["file_id"]) if run.get("file_id") else None
+ project = store.get_project(conn(), run["project_id"]) if run.get("project_id") else None
+ samples = store.list_run_samples(conn(), run_id)
+ # Anzeige auf ca. 1 Punkt / Minute verdichten
+ if len(samples) > 180:
+ step = max(1, len(samples) // 120)
+ samples = samples[::step]
+ return jsonify({
+ "run": run,
+ "spools": store.list_run_spools(conn(), run_id),
+ "segments": store.list_run_segments(conn(), run_id),
+ "samples": samples,
+ "labels": store.list_run_labels(conn(), run_id),
+ "events": store.list_run_events(conn(), run_id),
+ "file": store.file_out(file_row) if file_row else None,
+ "project": project,
+ })
+
@app.post("/api/v1/laeufe/abgleich")
def run_sync():
library.sync_watch(conn(), cfg, force=False)
@@ -1726,4 +1851,71 @@ def create_app(cfg):
row = store.run_out(current)
return jsonify(row)
+ @app.get("/api/v1/statistik")
+ def statistik_api():
+ since = request.args.get("since") or None
+ until = request.args.get("until") or None
+ host = (request.args.get("host") or "").strip()
+ material = (request.args.get("material") or "").strip()
+ brand = (request.args.get("brand") or "").strip()
+ project_id = optional_id(request.args.get("project_id"))
+ return jsonify(store.statistics(
+ conn(),
+ since=since,
+ until=until,
+ host=host,
+ material=material or "",
+ brand=brand or "",
+ project_id=project_id,
+ ))
+
+ @app.get("/api/v1/statistik.csv")
+ def statistik_csv():
+ import csv
+ import io
+ from flask import Response
+
+ since = request.args.get("since") or None
+ until = request.args.get("until") or None
+ host = (request.args.get("host") or "").strip()
+ material = (request.args.get("material") or "").strip()
+ brand = (request.args.get("brand") or "").strip()
+ runs = store.list_runs(
+ conn(),
+ limit=2000,
+ since=since,
+ until=until,
+ host=host,
+ material=material,
+ brand=brand,
+ )
+ buf = io.StringIO()
+ writer = csv.writer(buf, delimiter=";")
+ writer.writerow([
+ "id", "drucker", "datei", "zustand", "start", "ende", "dauer_s",
+ "soll_g", "ist_g", "quelle", "filament_eur", "strom_eur", "urteil", "charge",
+ ])
+ for run in runs:
+ writer.writerow([
+ run["id"],
+ run.get("printer_name") or "",
+ run.get("filename") or "",
+ run.get("state") or "",
+ run.get("started_at") or "",
+ run.get("ended_at") or "",
+ run.get("duration_s") or 0,
+ run.get("weight_planned_g") or 0,
+ run.get("weight_used_g") or 0,
+ run.get("weight_source") or "",
+ run.get("filament_cost") or 0,
+ run.get("energy_cost") or 0,
+ run.get("verdict") or "",
+ run.get("chargen_nr") or "",
+ ])
+ return Response(
+ buf.getvalue(),
+ mimetype="text/csv; charset=utf-8",
+ headers={"Content-Disposition": "attachment; filename=grotti-laeufe.csv"},
+ )
+
return app
diff --git a/web/static/grotti.css b/web/static/grotti.css
index e813675..64a779c 100644
--- a/web/static/grotti.css
+++ b/web/static/grotti.css
@@ -405,6 +405,36 @@ body.shelf [data-role="hero"] { font-size: 1.35rem; }
padding: 0.25rem 0.35rem;
font-size: 0.82rem;
}
+.steer-ace,
+.steer-ace-slots {
+ display: grid;
+ gap: 0.35rem;
+ padding-top: 0.35rem;
+ border-top: 1px solid var(--line);
+}
+.steer-ace-head {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem 0.65rem;
+ align-items: baseline;
+ font-size: 0.78rem;
+}
+.steer-ace-head b { font-weight: 650; }
+.steer-slot-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem 0.55rem;
+ align-items: center;
+ font-size: 0.78rem;
+}
+.steer-slot-row .dot {
+ width: 0.85rem;
+ height: 0.85rem;
+ border-radius: 99px;
+ flex: none;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+}
+.steer-slot-row .grow { min-width: 0; overflow-wrap: anywhere; }
.queue-name { color: var(--ink); font-size: var(--t-sm); font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
.queue-msg { color: var(--muted); font-size: var(--t-xs); line-height: 1.3; overflow-wrap: anywhere; }
.queue-sub { color: var(--faint); font-size: 0.66rem; font-variant-numeric: tabular-nums; flex: none; }
@@ -467,12 +497,16 @@ body.shelf [data-role="hero"] { font-size: 1.35rem; }
.ace-printer-name { margin: 0; font-size: 0.82rem; font-weight: 650; color: var(--ink); }
.ace-row {
display: grid;
- grid-template-columns: 0.85rem minmax(0, 1fr) minmax(7rem, 0.9fr);
+ grid-template-columns: 0.85rem minmax(0, 1fr) minmax(12rem, 1.4fr);
gap: 0.45rem;
align-items: center;
margin: 0;
font-size: 0.82rem;
}
+.ace-row select {
+ min-width: 0;
+ width: 100%;
+}
.ace-swatch {
width: 0.85rem;
height: 0.85rem;
@@ -514,6 +548,23 @@ h2 { font-size: 0.95rem; margin: 0 0 0.65rem; letter-spacing: -0.02em; font-weig
border-radius: var(--radius-sm);
min-height: var(--control-h);
}
+.page-tools .date-field {
+ width: auto;
+ min-width: 9.5rem;
+ max-width: 11rem;
+ flex: 0 0 auto;
+}
+.page-tools .period-presets {
+ width: auto;
+ max-width: 100%;
+ flex: 1 1 100%;
+ grid-auto-columns: auto;
+ overflow-x: auto;
+}
+.page-tools .period-presets button {
+ padding: 0.4rem 0.55rem;
+ white-space: nowrap;
+}
.lead, .tiny, .summary { color: var(--muted); }
.summary { margin: 0.3rem 0 0; }
.tiny { font-size: 0.8rem; margin: 0.2rem 0 0; line-height: 1.4; max-width: 52ch; }
@@ -2305,3 +2356,70 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); }
.nav-secondary a { padding: 0.4rem; }
.nav-main a.on { box-shadow: none; border-radius: 9px; padding-left: 0.55rem; }
}
+
+/* Druckbericht & Statistik */
+.bericht { display: grid; gap: 0.85rem; }
+.bericht-head { display: grid; gap: 0.5rem; }
+.bericht-meta p { margin: 0.2rem 0; }
+.temp-chart {
+ width: 100%;
+ max-width: 40rem;
+ height: auto;
+ background: #0c0c10;
+ border-radius: 10px;
+ border: 1px solid var(--line);
+}
+.chart-label { fill: var(--muted); font-size: 11px; }
+.chart-legend { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.35rem; }
+.chart-legend .leg::before {
+ content: "";
+ display: inline-block;
+ width: 0.65rem;
+ height: 0.65rem;
+ border-radius: 50%;
+ margin-right: 0.3rem;
+ vertical-align: -0.05rem;
+}
+.chart-legend .nozzle::before { background: #ea580c; }
+.chart-legend .bed::before { background: #2563eb; }
+.chart-legend .chamber::before { background: #16a34a; }
+.tag {
+ display: inline-block;
+ font-size: 0.72rem;
+ font-weight: 650;
+ padding: 0.05rem 0.35rem;
+ border-radius: 6px;
+ background: var(--accent-soft);
+ color: var(--accent);
+}
+.kpi-row {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
+ gap: 0.65rem;
+ margin-bottom: 0.85rem;
+}
+.kpi {
+ background: var(--card);
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 0.7rem 0.8rem;
+}
+.kpi-val {
+ font-size: 1.25rem;
+ font-weight: 750;
+ letter-spacing: -0.02em;
+}
+.kpi-label { color: var(--muted); font-size: 0.78rem; font-weight: 650; }
+.spool-akte {
+ margin-top: 0.85rem;
+ padding-top: 0.7rem;
+ border-top: 1px solid var(--line);
+}
+.spool-akte h3 { margin: 0 0 0.35rem; font-size: 0.95rem; }
+ul.plain { list-style: none; margin: 0; padding: 0; }
+ul.plain li { padding: 0.2rem 0; border-bottom: 1px solid var(--line); }
+ul.plain li:last-child { border-bottom: 0; }
+.grid-2 { display: grid; gap: 0.85rem; }
+@media (min-width: 800px) {
+ .grid-2 { grid-template-columns: 1fr 1fr; }
+}
diff --git a/web/templates/bericht.html b/web/templates/bericht.html
new file mode 100644
index 0000000..0ad87a1
--- /dev/null
+++ b/web/templates/bericht.html
@@ -0,0 +1,146 @@
+{% extends "layout.html" %}
+{% block body %}
+
+
+
Druckbericht
+
Lauf wird geladen …
+
+
+
+
+
+{% endblock %}
+{% block script %}
+
+{% endblock %}
diff --git a/web/templates/dateien.html b/web/templates/dateien.html
index 8c94c22..26c313c 100644
--- a/web/templates/dateien.html
+++ b/web/templates/dateien.html
@@ -103,7 +103,7 @@
ACE-Zuordnung
-
Für Auto-Feed fehlen Slicer-Metadaten in der Datei.
+
Mehrfarb ohne Slicer-Trailer — Grotti ergänzt `; referenced_tools` beim Senden.
@@ -252,13 +252,41 @@ function loadSendMemory() {
/* ---------- ACE / Spule ---------- */
-function slotLabel(slot) {
+function spoolAtSlot(host, place) {
+ return spoolsOnHost(host).find((s) => Number(s.ace_slot) === Number(place)) || null;
+}
+
+function slotLabel(host, slot) {
const place = Number(slot.index) + 1;
const box = place <= 4 ? "ACE 1 · " + place : "ACE 2 · " + (place - 4);
+ const spool = spoolAtSlot(host, place);
+ if (spool) {
+ const bits = [box];
+ const kind = spool.material_type || slot.type || "";
+ if (kind) bits.push(kind);
+ if (spool.color) bits.push(spool.color);
+ if (spool.name) bits.push(spool.name);
+ bits.push(grams(spool.remaining_g));
+ return bits.join(" · ");
+ }
const kind = slot.type ? " · " + slot.type : "";
return box + kind;
}
+function slotTitle(host, slot) {
+ const place = Number(slot.index) + 1;
+ const spool = spoolAtSlot(host, place);
+ if (!spool) return slot.type ? String(slot.type) : "";
+ const bits = [];
+ if (spool.brand) bits.push(spool.brand);
+ if (spool.name) bits.push(spool.name);
+ if (spool.material_type) bits.push(spool.material_type);
+ if (spool.color) bits.push(spool.color);
+ bits.push(grams(spool.remaining_g) + " Rest");
+ if (spool.chargen_nr) bits.push(spool.chargen_nr);
+ return bits.join(" · ");
+}
+
function filamentLabel(filament, index) {
const kind = filament.type || "Filament";
const color = filament.color ? " · " + filament.color : "";
@@ -369,7 +397,10 @@ function renderAceMaps(force) {
const paints = filePaints(file);
box.hidden = false;
document.getElementById("options").open = true;
- if (trailerWarn) trailerWarn.hidden = !!file.ace_trailer;
+ // Mono: Trailer fehlt oft und ist kein Fehler — Grotti setzt ihn beim Senden mit ACE-Map.
+ if (trailerWarn) {
+ trailerWarn.hidden = !!file.ace_trailer || paints.length <= 1;
+ }
root.innerHTML = "";
chosen.forEach((printer) => {
const block = document.createElement("div");
@@ -393,10 +424,15 @@ function renderAceMaps(force) {
printer.slots.forEach((slot) => {
const option = document.createElement("option");
option.value = String(slot.index);
- option.textContent = slotLabel(slot);
- if (slot.color) option.style.borderLeft = "4px solid " + slot.color;
- // einfache Vorauswahl: gleiche Sorte, sonst Platz in Reihenfolge
- if (!select.dataset.picked && filament.type && slot.type && filament.type.toLowerCase() === String(slot.type).toLowerCase()) {
+ option.textContent = slotLabel(printer.host, slot);
+ option.title = slotTitle(printer.host, slot);
+ const spool = spoolAtSlot(printer.host, Number(slot.index) + 1);
+ const hex = (spool && spool.colors && spool.colors[0] && spool.colors[0].hex) || slot.color || "";
+ if (hex) option.style.borderLeft = "4px solid " + hex;
+ const spoolType = (spool && spool.material_type) || slot.type || "";
+ // Vorauswahl: gleiche Sorte (Grotti-Spule bevorzugt), sonst Platz in Reihenfolge
+ if (!select.dataset.picked && filament.type && spoolType
+ && filament.type.toLowerCase() === String(spoolType).toLowerCase()) {
option.selected = true;
select.dataset.picked = "1";
}
diff --git a/web/templates/etikett.html b/web/templates/etikett.html
index 9e3a954..eb517bd 100644
--- a/web/templates/etikett.html
+++ b/web/templates/etikett.html
@@ -41,14 +41,17 @@
border: 1px solid #ccc;
border-radius: 4px;
display: grid;
- grid-template-columns: 1fr 22mm;
- gap: 3mm;
+ grid-template-columns: 1fr 20mm;
+ gap: 2.5mm;
align-items: center;
+ overflow: hidden;
}
+ .meta { min-width: 0; }
.meta h1 {
margin: 0 0 2mm;
font-size: 4.2mm;
letter-spacing: -0.02em;
+ overflow-wrap: anywhere;
}
.meta p { margin: 0 0 1.2mm; font-size: 3.1mm; color: #333; }
.meta .code { font-weight: 700; font-variant-numeric: tabular-nums; }
@@ -61,7 +64,19 @@
margin-left: -1mm;
}
.dot:first-child { margin-left: 0; }
- #qr { width: 20mm; height: 20mm; }
+ .qr-wrap {
+ width: 20mm;
+ height: 20mm;
+ overflow: hidden;
+ flex-shrink: 0;
+ }
+ .qr-wrap canvas {
+ display: block;
+ width: 20mm !important;
+ height: 20mm !important;
+ max-width: 100%;
+ max-height: 100%;
+ }
@media print {
body { background: #fff; }
.toolbar { display: none; }
@@ -99,14 +114,20 @@
Hersteller: {{ spool.hersteller_charge }}
{% endif %}
-
+
+
+
+
+{% for sheet in sheets %}
+
+{% endfor %}
+
+
+
diff --git a/web/templates/etikett_lauf.html b/web/templates/etikett_lauf.html
new file mode 100644
index 0000000..70a1a60
--- /dev/null
+++ b/web/templates/etikett_lauf.html
@@ -0,0 +1,126 @@
+
+
+