From efd0801cd128b603a7414d567ea898126747a31c Mon Sep 17 00:00:00 2001 From: Dominic Gundel Date: Sat, 17 Oct 2026 15:32:51 +0200 Subject: [PATCH] Grotti 2.5.1: Druckbericht, Statistik, Spulenakte und Protokoll-Fixes. --- CHANGELOG.md | 23 + docs/Anleitung-ACE.md | 3 +- docs/Anleitung-Material.md | 18 +- docs/Anleitung-Oberflaeche.md | 2 +- docs/Anleitung-Wand.md | 2 +- docs/internal/Chargen-Etiketten-Viewer.md | 8 +- docs/internal/Druckbericht-Statistik.md | 26 + docs/internal/Features-UI.md | 9 +- grotti/jobs.py | 296 +++++- grotti/library.py | 32 +- grotti/moonraker.py | 160 ++- grotti/notify.py | 4 +- grotti/schema.sql | 83 ++ grotti/store.py | 1091 ++++++++++++++++++++- grotti/usage.py | 164 ++++ grotti/version.py | 2 +- grotti/watcher.py | 172 +++- tests/test_ace_steer.py | 103 ++ tests/test_usage.py | 75 ++ web/app.py | 212 +++- web/static/grotti.css | 120 ++- web/templates/bericht.html | 146 +++ web/templates/dateien.html | 50 +- web/templates/etikett.html | 31 +- web/templates/etikett_lauf.html | 126 +++ web/templates/layout.html | 47 +- web/templates/material.html | 59 +- web/templates/protokoll.html | 25 +- web/templates/statistik.html | 214 ++++ web/templates/wand.html | 268 ++++- 30 files changed, 3393 insertions(+), 178 deletions(-) create mode 100644 docs/internal/Druckbericht-Statistik.md create mode 100644 grotti/usage.py create mode 100644 tests/test_ace_steer.py create mode 100644 tests/test_usage.py create mode 100644 web/templates/bericht.html create mode 100644 web/templates/etikett_lauf.html create mode 100644 web/templates/statistik.html 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. ![ACE-Zuordnung beim Senden](../web/static/doku/ace-zuordnung.png) @@ -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. ![Material: Spulen](../web/static/doku/material-spule.png) @@ -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 @@ @@ -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 %} - +
+ +
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 @@ + + + + + +{{ title }} + + + +
+ + Zurück +
+{% for sheet in sheets %} +
+
+

{{ sheet.object_name or (run.filename.split('/')[-1] if run.filename else 'Druck') }}

+

{{ sheet.chargen_nr }}

+ {% set soll = sheet.target_g if sheet.target_g is defined else target_g %} + {% if soll %} +

Ziel {{ '%.0f'|format(soll) }} g

+ {% endif %} +

{{ run.printer_name }} · {{ (run.ended_at or run.started_at or '')[:10] }}

+ {% for spool in spools %} +

{% if spool.material_type %}{{ spool.material_type }}{% endif %}{% if spool.brand %} · {{ spool.brand }}{% endif %}{% if spool.name %} · {{ spool.name }}{% endif %}{% if spool.chargen_nr %} · {{ spool.chargen_nr }}{% endif %}{% if spool.hersteller_charge %} · H: {{ spool.hersteller_charge }}{% endif %}

+ {% else %} +

kein Material zugeordnet

+ {% endfor %} +
+
+ +
+
+{% endfor %} + + + + diff --git a/web/templates/layout.html b/web/templates/layout.html index abdbeb9..43e1c8c 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -9,7 +9,7 @@ - + +{% endblock %} diff --git a/web/templates/wand.html b/web/templates/wand.html index 8dc09a8..267ca97 100644 --- a/web/templates/wand.html +++ b/web/templates/wand.html @@ -98,9 +98,19 @@ let wallSpools = []; let deskViewer = null; let deskCamFails = 0; let deskViewerFileId = null; +let deskSyncGen = 0; let deskExcludeSelected = ""; const wallPullTried = new Set(); const toolpathCache = new Map(); + +/** Kurzes leeres filename vom Drucker nicht übernehmen — sonst springt der Name alle paar Sekunden. */ +function stickyFilename(printer, previous) { + const next = (printer && printer.filename) || ""; + if (next) return next; + const state = shownState(printer || {}); + if (previous && ["printing", "paused", "heating", "complete"].includes(state)) return previous; + return ""; +} const cardNodes = new Map(); const runningJobs = new Map(); const printerCache = new Map(); @@ -208,6 +218,14 @@ function setHtml(el, html) { } } +function setText(el, text) { + if (!el) return; + const next = text == null ? "" : String(text); + if (el._text === next) return; + el._text = next; + el.textContent = next; +} + function tempbarHtml(label, value, target, scale, iconName) { if (value == null) return ""; const now = Math.round(value); @@ -265,12 +283,19 @@ function swatchesHtml(printer) { }).join(""); } -function actionsHtml(state) { +function etikettHtml(printer) { + const runId = printer && printer.live_run_id; + if (!runId) return ""; + return `Etikett`; +} + +function actionsHtml(state, printer) { + const label = (state === "printing" || state === "paused") ? etikettHtml(printer) : ""; if (state === "printing") { - return ``; + return `${label}`; } if (state === "paused") { - return ``; + return `${label}`; } return ""; } @@ -327,13 +352,107 @@ function stripsHtml(printer, state) { return bits.join(""); } +function dryerLabel(status) { + if (status === "drying") return "trocknet"; + if (status === "heater_err") return "Fehler"; + return "aus"; +} + +function dryerDefaultTemp(dryer) { + if (dryer.target > 0) return dryer.target; + if (dryer.temp > 0) return dryer.temp; + return 45; +} + +function aceSteerKey(printer) { + const dryers = printer.dryers || []; + const dryerPart = dryers.map((d) => String(d.id)).join(","); + const slots = wallSpools + .filter((s) => s.printer_host === printer.host && s.ace_slot) + .map((s) => Number(s.ace_slot)) + .sort((a, b) => a - b) + .join(","); + return dryerPart + "|" + slots; +} + +function spoolHex(spool) { + const colors = spool && spool.colors; + if (Array.isArray(colors)) { + for (const item of colors) { + if (item && item.hex) return item.hex; + if (typeof item === "string" && item.trim()) return item.trim(); + } + } + const label = spool && spool.color; + if (typeof label === "string" && /^#?[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(label.trim())) { + return label.trim().startsWith("#") ? label.trim() : "#" + label.trim(); + } + return ""; +} + +function aceSlotLabel(slot) { + const n = Number(slot); + if (n <= 4) return "ACE 1 · " + n; + return "ACE 2 · " + (n - 4); +} + +function aceSteerHtml(printer) { + const dryers = printer.dryers || []; + if (!dryers.length) return ""; + const bits = []; + dryers.forEach((dryer) => { + const id = Number(dryer.id); + const label = "ACE " + (id + 1); + const status = dryerLabel(dryer.status); + const temp = dryer.temp || 0; + const target = dryer.target || 0; + const remain = dryer.remain || 0; + const dur = remain > 0 ? remain : 240; + const hot = dryerDefaultTemp(dryer); + const info = [ + status, + temp || target ? temp + "°" + (target ? " / " + target + "°" : "") : "", + remain > 0 ? "noch " + remain + " min" : "", + ].filter(Boolean).join(" · "); + bits.push(`
+
${escapeHtml(label)} Trockner${escapeHtml(info)}
+
+ + + + +
+
`); + }); + const loaded = wallSpools + .filter((s) => s.printer_host === printer.host && s.ace_slot) + .slice() + .sort((a, b) => Number(a.ace_slot) - Number(b.ace_slot)); + if (loaded.length) { + bits.push(`
PlätzeSorte und Farbe aus Grotti
`); + loaded.forEach((spool) => { + const slot = Number(spool.ace_slot); + const hex = spoolHex(spool) || "#555"; + const kind = spool.material_type || "—"; + bits.push(`
+ + ${escapeHtml(aceSlotLabel(slot))} · ${escapeHtml(kind)} · ${escapeHtml(spool.name || "")} + +
`); + }); + bits.push(`
`); + } + return bits.join(""); +} + function steerPanelHtml(printer) { const n = printer.nozzle_target != null ? Math.round(printer.nozzle_target) : ""; const b = printer.bed_target != null ? Math.round(printer.bed_target) : ""; const sp = typeof printer.speed_factor === "number" ? Math.round(printer.speed_factor * 100) : 100; const flow = typeof printer.extrude_factor === "number" ? Math.round(printer.extrude_factor * 100) : 100; const fan = typeof printer.fan === "number" ? Math.round(printer.fan * 100) : 0; - return `
+ const aceKey = aceSteerKey(printer); + return `
@@ -345,6 +464,7 @@ function steerPanelHtml(printer) {
+ ${aceSteerHtml(printer)}

Enter im Feld setzt nur diesen Wert.

`; } @@ -447,8 +567,9 @@ function anycubicTaskLine(printer) { return "Anycubic " + escapeHtml(bits.join(" · ")) + ""; } -function jobLineVisible(printer, state) { - return !!printer.filename && state !== "standby" && state !== "offline"; +function jobLineVisible(printer, state, filename) { + const name = filename != null ? filename : (printer && printer.filename); + return !!name && state !== "standby" && state !== "offline"; } function shortName(name) { @@ -517,11 +638,13 @@ function patchSteerValues(root, printer) { if (steerHasFocus(root)) return; const steer = root.querySelector("[data-role=steer]"); if (!steer) return; - const nozzle = steer.querySelector('[data-steer="nozzle"]'); - const bed = steer.querySelector('[data-steer="bed"]'); - const speed = steer.querySelector('[data-steer="speed"]'); - const flow = steer.querySelector('[data-steer="flow"]'); - const fan = steer.querySelector('[data-steer="fan"]'); + const panel = steer.querySelector(".steer"); + if (!panel) return; + const nozzle = panel.querySelector('[data-steer="nozzle"]'); + const bed = panel.querySelector('[data-steer="bed"]'); + const speed = panel.querySelector('[data-steer="speed"]'); + const flow = panel.querySelector('[data-steer="flow"]'); + const fan = panel.querySelector('[data-steer="fan"]'); if (nozzle && document.activeElement !== nozzle) { nozzle.value = printer.nozzle_target != null ? Math.round(printer.nozzle_target) : ""; } @@ -537,13 +660,39 @@ function patchSteerValues(root, printer) { if (fan && document.activeElement !== fan && typeof printer.fan === "number") { fan.value = Math.round(printer.fan * 100); } + (printer.dryers || []).forEach((dryer) => { + const id = Number(dryer.id); + const box = panel.querySelector(`[data-dryer-id="${id}"]`); + if (!box) return; + const info = box.querySelector("[data-dryer-info]"); + if (info) { + const status = dryerLabel(dryer.status); + const temp = dryer.temp || 0; + const target = dryer.target || 0; + const remain = dryer.remain || 0; + info.textContent = [ + status, + temp || target ? temp + "°" + (target ? " / " + target + "°" : "") : "", + remain > 0 ? "noch " + remain + " min" : "", + ].filter(Boolean).join(" · "); + } + const tempInput = box.querySelector(`[data-steer="dryer-temp-${id}"]`); + const durInput = box.querySelector(`[data-steer="dryer-dur-${id}"]`); + if (tempInput && document.activeElement !== tempInput && dryer.status === "drying" && dryer.target > 0) { + tempInput.value = dryer.target; + } + if (durInput && document.activeElement !== durInput && dryer.remain > 0) { + durInput.value = dryer.remain; + } + }); } function patchCard(article, printer) { const state = shownState(printer); const clearNow = needsClear(printer); + const shownFile = stickyFilename(printer, article.dataset.filename || ""); article.dataset.state = state; - article.dataset.filename = printer.filename || ""; + article.dataset.filename = shownFile; article.dataset.progress = String(printer.progress || 0); article.dataset.family = printer.family || ""; article.dataset.needsClear = clearNow ? "1" : ""; @@ -558,7 +707,7 @@ function patchCard(article, printer) { setHtml(role("stats"), statsHtml(printer, state)); setHtml(role("temps"), tempbarsHtml(printer, state)); const jobline = role("jobline"); - const jobVisible = jobLineVisible(printer, state); + const jobVisible = jobLineVisible(printer, state, shownFile); const hasThumb = !!(jobVisible && printer.preview); if (jobline) { jobline.hidden = !jobVisible; @@ -590,13 +739,13 @@ function patchCard(article, printer) { } const job = role("job"); if (job) { - job.textContent = jobName(printer.filename) || ""; - job.title = printer.filename || ""; + setText(job, jobName(shownFile) || ""); + job.title = shownFile || ""; } setHtml(role("swatches"), swatchesHtml(printer)); setHtml(role("flow"), flowHtml(printer)); setHtml(role("strips"), stripsHtml(printer, state)); - setHtml(role("controls"), actionsHtml(state)); + setHtml(role("controls"), actionsHtml(state, printer)); } function refreshDeskCamera(printer) { @@ -687,9 +836,10 @@ function patchDesk(printer) { if (!printer || printer.host !== deskHost) return; const state = shownState(printer); const prevFile = deskEl.dataset.filename || ""; - const fileChanged = prevFile !== (printer.filename || ""); + const shownFile = stickyFilename(printer, prevFile); + const fileChanged = prevFile !== shownFile; deskEl.dataset.host = printer.host; - deskEl.dataset.filename = printer.filename || ""; + deskEl.dataset.filename = shownFile; deskEl.dataset.progress = String(printer.progress || 0); deskEl.classList.remove(...DESK_STATES); deskEl.classList.add(state); @@ -712,11 +862,12 @@ function patchDesk(printer) { setHtml(role("temps"), tempbarsHtml(printer, state)); const jobline = role("jobline"); - if (jobline) jobline.hidden = !jobLineVisible(printer, state) && !printer.preview; + const jobVisible = jobLineVisible(printer, state, shownFile); + if (jobline) jobline.hidden = !jobVisible && !printer.preview; const job = role("job"); if (job) { - job.textContent = jobName(printer.filename) || (jobLineVisible(printer, state) ? "" : "Kein Auftrag"); - job.title = printer.filename || ""; + setText(job, jobName(shownFile) || (jobVisible ? "" : "Kein Auftrag")); + job.title = shownFile || ""; } const preview = role("preview"); if (preview) { @@ -732,7 +883,7 @@ function patchDesk(printer) { setHtml(role("facts"), factsHtml(printer)); setHtml(role("flow"), flowHtml(printer)); setHtml(role("strips"), stripsHtml(printer, state)); - setHtml(role("controls"), actionsHtml(state)); + setHtml(role("controls"), actionsHtml(state, printer)); const estop = role("estop"); if (estop) { const showStop = state === "printing" || state === "paused" || state === "heating"; @@ -745,10 +896,14 @@ function patchDesk(printer) { } const steer = role("steer"); - if (steer && (!steer.querySelector(".steer") || fileChanged)) { - setHtml(steer, steerPanelHtml(printer)); - } else { - patchSteerValues(deskEl, printer); + if (steer) { + const panel = steer.querySelector(".steer"); + const nextKey = aceSteerKey(printer); + if (!panel || fileChanged || panel.dataset.aceKey !== nextKey) { + setHtml(steer, steerPanelHtml(printer)); + } else { + patchSteerValues(deskEl, printer); + } } setHtml(role("mesh"), heatMapHtml(printer)); @@ -761,8 +916,9 @@ async function syncDeskViewer(printer) { const canvas = deskEl.querySelector('[data-role="path-canvas"]'); const caption = deskEl.querySelector('[data-role="path-caption"]'); if (!canvas || !window.GrottiGcodeViewer || !window.THREE) return; - const filename = printer.filename || ""; + const filename = stickyFilename(printer, deskEl.dataset.filename || ""); const progress = Number(printer.progress || 0); + const gen = ++deskSyncGen; if (!deskViewer) { deskViewer = GrottiGcodeViewer.create(canvas); deskViewer.setExcludeObjects({ objects: [], excluded: [], current: null }, { @@ -777,45 +933,55 @@ async function syncDeskViewer(printer) { let file = matchLibraryFile(filename); if (!file && filename && !wallPullTried.has(deskHost + "|" + filename)) { wallPullTried.add(deskHost + "|" + filename); - if (caption) caption.textContent = "lade vom Drucker …"; + setText(caption, "lade vom Drucker …"); try { const saved = await api("/dateien/von-drucker", { method: "POST", json: { host: deskHost, filename }, }); + if (gen !== deskSyncGen) return; libraryFiles = await api("/dateien"); + if (gen !== deskSyncGen) return; file = saved || matchLibraryFile(filename); } catch (error) { /* bleibt leer */ } } if (!file) { + // Kurz leerer filename: Pfad und Caption nicht verwerfen (sonst Flackern alle 5 s). + if (!filename && deskViewerFileId) return; deskViewer.setPath({ points: [] }); deskViewerFileId = null; - if (caption) caption.textContent = filename ? "Datei nicht in Grotti" : "kein Auftrag"; + setText(caption, filename ? "Datei nicht in Grotti" : "kein Auftrag"); renderExcludePanel(printer); return; } try { let data = toolpathCache.get(file.id); - const needLoad = !data || data.version !== 8 || deskViewerFileId !== file.id; - if (needLoad) { - if (caption) caption.textContent = "wird gelesen …"; + const cachedOk = data && Number(data.version) === 8; + const needFetch = !cachedOk; + const needSetPath = deskViewerFileId !== file.id; + if (needFetch) { + if (needSetPath) setText(caption, "wird gelesen …"); data = await api("/dateien/" + file.id + "/pfad?tp=8"); + if (gen !== deskSyncGen) return; toolpathCache.set(file.id, data); - const sameFile = deskViewerFileId === file.id; - deskViewer.setPath(data, { keepView: sameFile }); + } + if (needFetch || needSetPath) { + deskViewer.setPath(data, { keepView: deskViewerFileId === file.id }); deskViewerFileId = file.id; } if (!(data.points && data.points.length)) { - if (caption) caption.textContent = "leer in Datei"; + setText(caption, "leer in Datei"); return; } } catch (error) { // File-Id behalten — sonst setzt der nächste Poll die 3D-Ansicht zurück - if (caption) caption.textContent = error.message || "fehlgeschlagen"; + if (gen !== deskSyncGen) return; + setText(caption, error.message || "fehlgeschlagen"); return; } + if (gen !== deskSyncGen) return; deskViewer.setProgress(progress, { layer: printer.layer, layers: printer.layers, @@ -836,7 +1002,7 @@ async function syncDeskViewer(printer) { const layerTxt = printer.layers ? " · Schicht " + (printer.layer || 0) + "/" + printer.layers : ""; - if (caption) caption.textContent = file.name + " · " + Math.round(progress * 100) + " %" + layerTxt; + setText(caption, jobName(file.name) + " · " + Math.round(progress * 100) + " %" + layerTxt); } function renderExcludePanel(printer) { @@ -1036,8 +1202,8 @@ async function refresh() { if (!libraryFiles.length) { try { libraryFiles = await api("/dateien"); } catch (error) { libraryFiles = []; } } - if (!wallSpools.length) { - try { wallSpools = await api("/spulen"); } catch (error) { wallSpools = []; } + if (!wallSpools.length || deskHost) { + try { wallSpools = await api("/spulen"); } catch (error) { /* behalten */ } } const all = await api("/drucker"); const printers = sortPrinters(all.slice()); @@ -1259,11 +1425,35 @@ async function handleWallClick(event) { method: "POST", json: { action: "fan", percent: 0 }, }); toast("Heizung und Lüfter aus."); + } else if (go === "dryer_start") { + const id = Number(steerBtn.dataset.dryerId); + const temp = val("dryer-temp-" + id); + const duration = val("dryer-dur-" + id); + await api("/drucker/" + encodeURIComponent(host) + "/steuern", { + method: "POST", + json: { action: "dryer_start", id, temp, duration }, + }); + toast("Trockner gestartet."); + } else if (go === "dryer_stop") { + const id = Number(steerBtn.dataset.dryerId); + await api("/drucker/" + encodeURIComponent(host) + "/steuern", { + method: "POST", + json: { action: "dryer_stop", id }, + }); + toast("Trockner gestoppt."); + } else if (go === "slot_setzen") { + const aceSlot = Number(steerBtn.dataset.aceSlot); + await api("/drucker/" + encodeURIComponent(host) + "/steuern", { + method: "POST", + json: { action: "slot_setzen", ace_slot: aceSlot }, + }); + toast("Platz an den Drucker geschrieben."); } showNote("note", null); refresh(); } catch (error) { showNote("note", error); + toast(error.message, "bad"); } finally { steerBtn.disabled = false; }