Grotti 2.5.1: Druckbericht, Statistik, Spulenakte und Protokoll-Fixes.

This commit is contained in:
Dominic Gundel committed 2026-10-17 15:32:51 +02:00
1 parent b277ca48aa
commit efd0801cd1
30 files changed
+3393 -178

No files matched your search

+23
View File
@@ -4,12 +4,35 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
## [Unreleased] ## [Unreleased]
## [2.5.1] – 2026-10-17
### Neu ### 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. - Wand-Kacheln zeigen das Druck-Vorschaubild (Moonraker-Thumbnail) in der Job-Zeile.
- **Druckbericht** `/protokoll/<id>`: 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 ### 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/<name>` 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. - 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. - 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“. - Senden: Dateinamen mit Nicht-ASCII (z. B. CJK) werden für Moonraker/Rinkhals auf ASCII normalisiert — sonst Fehler 10115 „gcode … not found“.
+2 -1
View File
@@ -1,6 +1,6 @@
# ACE und Spulen # 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) ![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**: Unter **Dateien → Optionen → ACE-Zuordnung**:
- Jede Farbe aus der Datei (`paint_index`) einem **ACE-Platz** zuordnen - 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 - 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 - Grotti legt die Sidecar `*.gcode.acm` an und schickt sie mit
+13 -5
View File
@@ -1,6 +1,6 @@
# Material und mehr # 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) ![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**. 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. **Sorten:** Profile (Dichte, Preis, Maschinen). „Spule damit anlegen“ wechselt zum Spulen-Reiter.
### Etikett und QR ### 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 ## Projekte
Unter **Projekte** Aufträge mit Notiz und Kalkulation. Dateien lassen sich einem Projekt zuweisen (Bibliothek / Senden). 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 ## Kameras
+1 -1
View File
@@ -4,7 +4,7 @@ Stand **15.10.2026**. Wie die App aufgebaut ist — Menü, Live-Leiste, Desktop
## Navigation ## 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. Unter ca. 760 px: untere Leiste mit den wichtigsten Seiten. Das Seitenmenü lässt sich zur Icon-Rail einklappen.
+1 -1
View File
@@ -36,7 +36,7 @@ Inhalt:
- **Kamera** — nur wenn eine Webcam erreichbar ist; sonst bleibt der Platz weg - **Kamera** — nur wenn eine Webcam erreichbar ist; sonst bleibt der Platz weg
- Status, Temps inkl. Bauraum wo verfügbar, Lüfter - 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 - **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) - **3D-Pfad** der laufenden oder gewählten Datei; Objekte ausschließen (mit Bestätigung)
- **Bettnetz**-Heatmap, wenn vorhanden - **Bettnetz**-Heatmap, wenn vorhanden
+7 -1
View File
@@ -1,6 +1,6 @@
# Chargen, Etiketten und 3D-Viewer # Chargen, Etiketten und 3D-Viewer
Stand 14.10.2026. Stand 17.10.2026.
## Spulen-Chargen ## Spulen-Chargen
@@ -10,6 +10,12 @@ Scannen: auf Material den Code tippen oder per USB-Handscanner (Tastatur + Enter
Etikett: `/etikett/<id>`, 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`. Etikett: `/etikett/<id>`, 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/<id>?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 ## 3D-Pfad
`GET /api/v1/dateien/<id>/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. `GET /api/v1/dateien/<id>/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.
+26
View File
@@ -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.
+7 -2
View File
@@ -37,10 +37,15 @@ Stand **14.10.2026**, v1.4.0.
## Material ## Material
- Reiter **Spulen** / **Sorten**. Scan-Feld im Seitenkopf. - 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. - 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. - 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/<id>` (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 ## Warteschlange
- Seite `/warteschlange`: Filter Läuft / Wartet / Fehler / Historie. Priorität, Abbrechen, Fortsetzen. - Seite `/warteschlange`: Filter Läuft / Wartet / Fehler / Historie. Priorität, Abbrechen, Fortsetzen.
+244 -52
View File
@@ -5,7 +5,7 @@ import urllib.error
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone 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.cost import energy_cost
from grotti.errors import GrottiError from grotti.errors import GrottiError
from grotti.parse import content_has_ace_trailer, prepare_rinkhals_gcode from grotti.parse import content_has_ace_trailer, prepare_rinkhals_gcode
@@ -15,17 +15,7 @@ TERMINAL = {"completed", "cancelled", "error", "interrupted"}
def weight_of(metadata): def weight_of(metadata):
raw = (metadata or {}).get("filament_weight_total") return usage.weight_sum(metadata)
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
def material_of(metadata): def material_of(metadata):
@@ -35,6 +25,37 @@ def material_of(metadata):
return str(raw).split(";")[0].strip() 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): def unix_to_dt(value):
if not value: if not value:
return None return None
@@ -128,50 +149,152 @@ def assert_spool_at_host(conn, spool_id, host):
) )
def apply_deduction(conn, run, energy): def apply_deduction(conn, run, energy, metadata=None):
if run["deducted"] or run["weight_g"] <= 0 or run["state"] not in TERMINAL: if run["deducted"] or run["state"] not in TERMINAL:
return False return False
weight = float(run["weight_g"] or 0) planned = float(run.get("weight_planned_g") or run.get("weight_g") or 0)
# ACE: Abzug anteilig auf die Plätze der letzten Sendung 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 "") sent = store.latest_send(conn, run["printer_host"], run.get("filename") or "")
ace = [] ace = _ace_map_of(sent)
if sent: meta = metadata or {}
raw = sent.get("ace_map") if not meta and run.get("file_id"):
if isinstance(raw, list): file_row = store.get_file_row(conn, run["file_id"]) if hasattr(store, "get_file_row") else None
ace = raw if file_row:
elif isinstance(raw, str): meta = {"filament_weight_total": file_row.get("weight_g") or planned}
try:
ace = json.loads(raw) store.close_open_segments(conn, run["id"], None, run.get("filament_used_mm") or 0)
except json.JSONDecodeError: segments = store.list_run_segments(conn, run["id"])
ace = []
targets = [] entries = []
if ace: targets = _targets_from_ace(conn, run["printer_host"], ace) if ace else []
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)
if not targets and run.get("spool_id"): if not targets and run.get("spool_id"):
spool = store.get_spool_row(conn, run["spool_id"]) spool = store.get_spool_row(conn, run["spool_id"])
if spool: if spool:
targets = [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 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) 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: if not marked:
store.replace_run_spools(conn, run["id"], [])
return False return False
for spool in targets: for item in entries:
store.deduct_spool(conn, spool["id"], share) 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 return True
@@ -190,13 +313,16 @@ def sync_history(conn, cfg):
deducted = 0 deducted = 0
for printer, job in collected: for printer, job in collected:
job_id = str(job.get("job_id") or "") job_id = str(job.get("job_id") or "")
if not job_id: if not job_id or str(job_id).startswith("live:"):
continue continue
filename = job.get("filename") or "" filename = job.get("filename") or ""
metadata = job.get("metadata") or {} metadata = job.get("metadata") or {}
weight = weight_of(metadata) planned = weight_of(metadata)
material = material_of(metadata) material = material_of(metadata)
state = job.get("status") or "" 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) sent = store.latest_send(conn, printer["host"], filename)
known = store.file_by_name(conn, filename) known = store.file_by_name(conn, filename)
file_id = (sent or {}).get("file_id") or (known or {}).get("id") 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") hinted = (sent or {}).get("spool_id") or (known or {}).get("spool_id")
spool = choose_spool(conn, printer["host"], material, hinted) spool = choose_spool(conn, printer["host"], material, hinted)
terminal = state in TERMINAL 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, { row = store.upsert_run(conn, {
"printer_host": printer["host"], "printer_host": printer["host"],
"printer_name": printer["name"], "printer_name": printer["name"],
@@ -215,13 +358,33 @@ def sync_history(conn, cfg):
"started_at": unix_to_dt(job.get("start_time")), "started_at": unix_to_dt(job.get("start_time")),
"ended_at": unix_to_dt(job.get("end_time")), "ended_at": unix_to_dt(job.get("end_time")),
"duration_s": float(job.get("print_duration") or job.get("total_duration") or 0), "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"]), "energy_cost": energy_cost(job.get("print_duration") or job.get("total_duration") or 0, cfg["energy"]),
"moonraker_job_id": job_id, "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, "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 deducted += 1
return {"gelesen": len(collected), "abgezogen": deducted} return {"gelesen": len(collected), "abgezogen": deducted}
@@ -373,7 +536,9 @@ def send_file(
filaments = [{"type": "PLA", "color": ""}] filaments = [{"type": "PLA", "color": ""}]
progress("vorbereiten", 30, "Datei vorbereiten…") 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) # CJK/Unicode im Namen → Rinkhals 10115 not found (Upload ≠ Start-Name)
filename = moonraker.safe_upload_name(filename) filename = moonraker.safe_upload_name(filename)
ace_tools = [item.get("paint_index") for item in mapping] if mapping else None 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) printer = store.printer_by_host(conn, host)
if not printer: if not printer:
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404) 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: try:
moonraker.steuern(host, action, params) moonraker.steuern(host, action, params)
except moonraker.PrinterError as exc: except moonraker.PrinterError as exc:
+27 -5
View File
@@ -142,8 +142,30 @@ def ensure_library_gcode(conn, cfg, host, filename):
return save_upload(conn, name, content, cfg) return save_upload(conn, name, content, cfg)
def printer_file(row): def resolve_stored_path(conn, row, data_dir=None):
path = Path(row["stored_path"]) """Gespeicherter Pfad, ggf. nach Umzug von data_dir unter eingang/<name> 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(): if not path.is_file():
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404) raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
if row["kind"] == "gcode": if row["kind"] == "gcode":
@@ -153,9 +175,9 @@ def printer_file(row):
return path.name, path.read_bytes(), row["plate"] or "" return path.name, path.read_bytes(), row["plate"] or ""
def preview_png(row): def preview_png(row, conn=None, data_dir=None):
path = Path(row["stored_path"]) 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 not path.is_file(): if path is None or not path.is_file():
return None return None
entry = row["thumb_entry"] entry = row["thumb_entry"]
if row["kind"] == "gcode": if row["kind"] == "gcode":
+159 -1
View File
@@ -418,6 +418,64 @@ def steuern(host, action, params=None):
if not re.fullmatch(r"[A-Za-z0-9._+\-]+", name): if not re.fullmatch(r"[A-Za-z0-9._+\-]+", name):
raise PrinterError("Ungültiger Objektname.") raise PrinterError("Ungültiger Objektname.")
return gcode_script(host, "EXCLUDE_OBJECT NAME=%s" % name, timeout=20) 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": if action == "firmware_restart":
# GoKlipper/Rinkhals: FIRMWARE_RESTART deadlocked gklib (Rinkhals #32). # GoKlipper/Rinkhals: FIRMWARE_RESTART deadlocked gklib (Rinkhals #32).
raise PrinterError( raise PrinterError(
@@ -600,6 +658,80 @@ def extract_slots(status):
return found[:8] 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): def slots(host, status=None):
if status is not None: if status is not None:
found = extract_slots(status) found = extract_slots(status)
@@ -868,6 +1000,8 @@ def query(host):
extras = [] extras = []
if "probe" in names: if "probe" in names:
extras.append("probe") extras.append("probe")
if "mmu_machine" in names:
extras.append("mmu_machine")
url = "http://%s/printer/objects/query?%s" % (host, "&".join([base] + extras)) url = "http://%s/printer/objects/query?%s" % (host, "&".join([base] + extras))
payload = fetch_json(url) payload = fetch_json(url)
status = payload["result"]["status"] status = payload["result"]["status"]
@@ -875,6 +1009,21 @@ def query(host):
card = status.get("virtual_sdcard") or {} card = status.get("virtual_sdcard") or {}
extruder = status.get("extruder") or {} extruder = status.get("extruder") or {}
bed = status.get("heater_bed") 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 "" raw_name = stats.get("filename") or ""
filename = raw_name.replace("\\", "/").rsplit("/", 1)[-1] if raw_name else "" filename = raw_name.replace("\\", "/").rsplit("/", 1)[-1] if raw_name else ""
meta = metadata(host, raw_name) 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"), "bed_target": bed.get("target"),
"chamber": chamber, "chamber": chamber,
"chamber_target": chamber_target, "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 "", "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, "height": meta.get("object_height") or 0,
"layer_height": meta.get("layer_height") or card.get("layer_height") or 0, "layer_height": meta.get("layer_height") or card.get("layer_height") or 0,
"estimated": meta.get("estimated_time") or 0, "estimated": meta.get("estimated_time") or 0,
@@ -952,6 +1108,7 @@ def query(host):
"speed_factor": speed_factor, "speed_factor": speed_factor,
"extrude_factor": extrude_factor, "extrude_factor": extrude_factor,
"slots": slots(host, status), "slots": slots(host, status),
"dryers": extract_dryers(status),
"exclude": exclude_status(host), "exclude": exclude_status(host),
"anycubic_task": _anycubic_task_field(host), "anycubic_task": _anycubic_task_field(host),
} }
@@ -993,6 +1150,7 @@ def snapshot(printers):
"speed_factor": None, "speed_factor": None,
"extrude_factor": None, "extrude_factor": None,
"slots": [], "slots": [],
"dryers": [],
"anycubic_task": None, "anycubic_task": None,
}) })
card.update(mesh_fields(printer["host"])) card.update(mesh_fields(printer["host"]))
+2 -2
View File
@@ -43,11 +43,11 @@ def alert_enabled(cfg, kind):
return bool(alerts.get(key, key not in DEFAULT_OFF)) 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.""" """Ereignis immer speichern; Telegram nur einmal pro Art und Drucker im Zeitfenster."""
from grotti import store from grotti import store
recent = store.recent_event_kind(conn, host, kind, within_s=dedupe_s) 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): if not recent and alert_enabled(cfg, kind):
send_telegram(cfg, message) send_telegram(cfg, message)
+83
View File
@@ -215,3 +215,86 @@ CREATE TABLE IF NOT EXISTS oauth_identities (
created_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (provider, subject) 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);
+1077 -14
View File
File diff suppressed because it is too large. Load diff
+164
View File
@@ -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
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen.""" """Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
__version__ = "2.2.6" __version__ = "2.5.1"
+152 -20
View File
@@ -4,10 +4,13 @@ from __future__ import annotations
import threading import threading
import time 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 from grotti.db import connect
SAMPLE_EVERY_S = 30
class StateWatcher: class StateWatcher:
def __init__(self, database_url, cfg, interval=10): def __init__(self, database_url, cfg, interval=10):
@@ -21,6 +24,7 @@ class StateWatcher:
self._updated_at = 0.0 self._updated_at = 0.0
self._stop = threading.Event() self._stop = threading.Event()
self._thread = None self._thread = None
self._last_sample = {}
def start(self): def start(self):
if self._thread and self._thread.is_alive(): if self._thread and self._thread.is_alive():
@@ -79,6 +83,7 @@ class StateWatcher:
prev = self._prev.get(host) or {} prev = self._prev.get(host) or {}
prev_state = (prev.get("state") or "").lower() prev_state = (prev.get("state") or "").lower()
prev_offline = bool(prev.get("offline")) prev_offline = bool(prev.get("offline"))
run_id = None
if offline: if offline:
hits = self._offline_hits.get(host, 0) + 1 hits = self._offline_hits.get(host, 0) + 1
@@ -89,16 +94,32 @@ class StateWatcher:
return return
self._offline_hits[host] = 0 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: if prev_offline:
self._emit(conn, host, name, "offline", "%s ist wieder erreichbar." % name, filename) self._emit(conn, host, name, "offline", "%s ist wieder erreichbar." % name, filename, run_id)
elif prev_state and prev_state not in ("printing", "paused") and state == "printing": elif starting:
self._emit(conn, host, name, "start", "%s druckt %s" % (name, filename or "einen Auftrag"), filename) 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": 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": 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": 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( self._emit(
conn, conn,
host, host,
@@ -106,8 +127,8 @@ class StateWatcher:
"fertig", "fertig",
"%s fertig: %s" % (name, filename or "Druck"), "%s fertig: %s" % (name, filename or "Druck"),
filename, filename,
run_id or (prev.get("run_id")),
) )
# Protokoll und Spulenabzug nicht erst nach manuellem Knopf
try: try:
from grotti import jobs from grotti import jobs
@@ -115,7 +136,7 @@ class StateWatcher:
except Exception as exc: except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True) print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
elif prev_state in ("printing", "paused") and state in ("cancelled", "standby"): 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( self._emit(
conn, conn,
host, host,
@@ -123,10 +144,17 @@ class StateWatcher:
"abbruch", "abbruch",
"%s: Druck abgebrochen (%s)" % (name, filename or "ohne Datei"), "%s: Druck abgebrochen (%s)" % (name, filename or "ohne Datei"),
filename, 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": elif state == "error" and prev_state != "error":
if prev_state == "printing" or prev_state == "paused": 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() msg = (live.get("message") or "").strip()
if not msg or msg.lower() in ("error", "fehler"): if not msg or msg.lower() in ("error", "fehler"):
msg = "Ursache unbekannt — in Mainsail prüfen" msg = "Ursache unbekannt — in Mainsail prüfen"
@@ -138,18 +166,19 @@ class StateWatcher:
"fehler", "fehler",
"%s: %s · Heizung aus" % (name, msg), "%s: %s · Heizung aus" % (name, msg),
filename, 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": 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: if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0:
self._cool_heaters(host) self._cool_heaters(host)
elif state == "complete": elif state == "complete" and (not prev_state or prev_offline):
known = conn.execute( store.ensure_bed_after_print(conn, host, message=filename or "fertig")
"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")
spool_warn = bool(prev.get("spool_warn")) spool_warn = bool(prev.get("spool_warn"))
if state == "printing" and not spool_warn: if state == "printing" and not spool_warn:
@@ -160,8 +189,108 @@ class StateWatcher:
"offline": False, "offline": False,
"filename": filename, "filename": filename,
"spool_warn": spool_warn if state == "printing" else False, "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): def _cool_heaters(self, host):
try: try:
moonraker.heaters_off(host) moonraker.heaters_off(host)
@@ -193,5 +322,8 @@ class StateWatcher:
) )
return True return True
def _emit(self, conn, host, printer, kind, message, filename): 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) notify.record(
conn, self.cfg, host, printer, kind, message, filename,
dedupe_s=60, run_id=run_id,
)
+103
View File
@@ -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},
}
+75
View File
@@ -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()
+202 -10
View File
@@ -37,7 +37,7 @@ IDEA_STATUS = {"offen", "spaeter", "erledigt"}
PRINTER_FAMILIES = set(FAMILY_LABELS) | {""} PRINTER_FAMILIES = set(FAMILY_LABELS) | {""}
STEER_ACTIONS = { STEER_ACTIONS = {
"nozzle", "bed", "speed", "flow", "fan", "emergency_stop", "clear_error", "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(): def protokoll():
return page("protokoll.html", "Protokoll") return page("protokoll.html", "Protokoll")
@app.get("/protokoll/<int:run_id>")
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/<int:run_id>")
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") @app.get("/ideen")
def ideen(): def ideen():
return page("ideen.html", "Ideen") return page("ideen.html", "Ideen")
@@ -672,6 +724,9 @@ def create_app(cfg):
item["queue_failed"] = queue.get("failed") item["queue_failed"] = queue.get("failed")
item["queue_waiting"] = queue.get("waiting", 0) item["queue_waiting"] = queue.get("waiting", 0)
item["queue_waiting_next"] = queue.get("waiting_next", "") 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} by_id = {row["host"]: row for row in rows}
if request.args.get("live", "1") == "0": if request.args.get("live", "1") == "0":
@@ -873,7 +928,7 @@ def create_app(cfg):
row = store.get_file_row(conn(), file_id) row = store.get_file_row(conn(), file_id)
if not row: if not row:
abort(404) abort(404)
png = library.preview_png(row) png = library.preview_png(row, conn=conn(), data_dir=cfg["data_dir"])
if not png: if not png:
abort(404) abort(404)
return png, 200, {"Content-Type": "image/png", "Cache-Control": "private, max-age=300"} 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) row = store.get_file_row(conn(), file_id)
if not row: if not row:
raise GrottiError("Die Datei fehlt.", "datei", 404) raise GrottiError("Die Datei fehlt.", "datei", 404)
path = Path(row["stored_path"]) path = library.resolve_stored_path(conn(), row, cfg["data_dir"])
if not path.is_file(): if path is None or not path.is_file():
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404) raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
cache_dir = Path(cfg["data_dir"]) / "toolpath" cache_dir = Path(cfg["data_dir"]) / "toolpath"
cache_dir.mkdir(parents=True, exist_ok=True) cache_dir.mkdir(parents=True, exist_ok=True)
@@ -1506,11 +1561,28 @@ def create_app(cfg):
@app.get("/api/v1/spulen/by-code/<path:code>") @app.get("/api/v1/spulen/by-code/<path:code>")
def spool_by_code(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: if not row:
raise GrottiError("Keine Spule mit diesem Code.", "spule", 404) raise GrottiError("Keine Spule mit diesem Code.", "spule", 404)
return jsonify(row) return jsonify(row)
@app.get("/api/v1/spulen/<int:spool_id>/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") @app.post("/api/v1/spulen")
def spool_create(): def spool_create():
return jsonify(store.create_spool(conn(), spool_fields(body()))), 201 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) spool = store.get_spool_row(conn(), spool_id)
if not spool: if not spool:
raise GrottiError("Die Spule fehlt.", "spule", 404) 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 # Alte Rolle am Platz / externe Rolle am Host freimachen
if slot: if slot:
old = conn().execute( 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", "SELECT * FROM spools WHERE printer_host = %s AND ace_slot IS NULL AND id <> %s",
(host, spool_id), (host, spool_id),
).fetchall() ).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: for row in old:
store.update_spool(conn(), row["id"], {"printer_host": "", "ace_slot": None}) store.update_spool(conn(), row["id"], {"printer_host": "", "ace_slot": None})
store.add_event( store.add_event(
conn(), conn(),
host, host,
(store.printer_by_host(conn(), host) or {}).get("name") or host, printer.get("name") or host,
"spule", "spule",
"%s: „%s“ ins Regal%s" % ( "%s: „%s“ ins Regal%s" % (
(store.printer_by_host(conn(), host) or {}).get("name") or host, printer.get("name") or host,
row["name"], row["name"],
(" (Platz %s)" % slot) if slot else "", (" (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}) 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( store.add_event(
conn(), conn(),
host, host,
@@ -1568,7 +1662,8 @@ def create_app(cfg):
updated["name"], updated["name"],
(" auf Platz %s" % slot) if slot else "", (" auf Platz %s" % slot) if slot else "",
), ),
"", (live_run or {}).get("filename") or "",
run_id=(live_run or {}).get("id"),
) )
return jsonify(updated) return jsonify(updated)
@@ -1704,6 +1799,36 @@ def create_app(cfg):
def run_list(): def run_list():
return jsonify(store.list_runs(conn())) return jsonify(store.list_runs(conn()))
@app.get("/api/v1/laeufe/<int:run_id>")
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") @app.post("/api/v1/laeufe/abgleich")
def run_sync(): def run_sync():
library.sync_watch(conn(), cfg, force=False) library.sync_watch(conn(), cfg, force=False)
@@ -1726,4 +1851,71 @@ def create_app(cfg):
row = store.run_out(current) row = store.run_out(current)
return jsonify(row) 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 return app
+119 -1
View File
@@ -405,6 +405,36 @@ body.shelf [data-role="hero"] { font-size: 1.35rem; }
padding: 0.25rem 0.35rem; padding: 0.25rem 0.35rem;
font-size: 0.82rem; 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-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-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; } .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-printer-name { margin: 0; font-size: 0.82rem; font-weight: 650; color: var(--ink); }
.ace-row { .ace-row {
display: grid; 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; gap: 0.45rem;
align-items: center; align-items: center;
margin: 0; margin: 0;
font-size: 0.82rem; font-size: 0.82rem;
} }
.ace-row select {
min-width: 0;
width: 100%;
}
.ace-swatch { .ace-swatch {
width: 0.85rem; width: 0.85rem;
height: 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); border-radius: var(--radius-sm);
min-height: var(--control-h); 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); } .lead, .tiny, .summary { color: var(--muted); }
.summary { margin: 0.3rem 0 0; } .summary { margin: 0.3rem 0 0; }
.tiny { font-size: 0.8rem; margin: 0.2rem 0 0; line-height: 1.4; max-width: 52ch; } .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-secondary a { padding: 0.4rem; }
.nav-main a.on { box-shadow: none; border-radius: 9px; padding-left: 0.55rem; } .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; }
}
+146
View File
@@ -0,0 +1,146 @@
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Druckbericht</h1>
<p class="tiny" id="bericht-sub">Lauf wird geladen …</p>
</div>
<div class="page-head-side">
<a class="ghost" href="/protokoll">← Protokoll</a>
<a class="ghost" id="etikett-druck" href="#" hidden>Etikett Druck</a>
<a class="ghost" id="etikett-objekt" href="#" hidden>Etiketten Objekte</a>
</div>
</div>
<p id="bericht-note"></p>
<div id="bericht" class="bericht"></div>
{% endblock %}
{% block script %}
<script>
const runId = Number(location.pathname.split("/").pop());
const states = {
completed: "fertig", cancelled: "abgebrochen", error: "Fehler",
in_progress: "druckt", printing: "druckt", paused: "pausiert", interrupted: "unterbrochen",
};
function sourceLabel(source, estimate) {
if (estimate) return "Schätzwert";
if (source === "measured") return "Messwert";
if (source === "estimate") return "Schätzwert";
return source || "";
}
function chart(samples) {
if (!samples || samples.length < 2) {
return '<p class="tiny">Kein Temperaturverlauf (nur für neue Drucke).</p>';
}
const w = 640, h = 180, pad = 28;
const temps = samples.flatMap((s) => [s.nozzle, s.bed, s.chamber, s.nozzle_target, s.bed_target].filter((v) => v != null));
const minT = Math.min(...temps, 0);
const maxT = Math.max(...temps, 100);
const x = (i) => pad + (i / (samples.length - 1)) * (w - pad * 2);
const y = (v) => h - pad - ((Number(v) - minT) / Math.max(1, maxT - minT)) * (h - pad * 2);
function path(key, color) {
const pts = samples.map((s, i) => s[key] == null ? null : `${x(i)},${y(s[key])}`).filter(Boolean);
if (pts.length < 2) return "";
return `<polyline fill="none" stroke="${color}" stroke-width="1.6" points="${pts.join(" ")}" />`;
}
return `<svg class="temp-chart" viewBox="0 0 ${w} ${h}" role="img" aria-label="Temperaturverlauf">
${path("nozzle_target", "#fdba74")}
${path("bed_target", "#93c5fd")}
${path("nozzle", "#ea580c")}
${path("bed", "#2563eb")}
${path("chamber", "#16a34a")}
<text x="${pad}" y="14" class="chart-label">${Math.round(maxT)} °C</text>
<text x="${pad}" y="${h - 8}" class="chart-label">${Math.round(minT)} °C</text>
</svg>
<p class="tiny chart-legend"><span class="leg nozzle">Düse</span> <span class="leg bed">Bett</span> <span class="leg chamber">Kammer</span> · gestrichelt ≈ Soll</p>`;
}
function render(data) {
const run = data.run;
const name = (run.filename || "").split("/").pop() || "ohne Datei";
document.getElementById("bericht-sub").textContent =
(run.printer_name || run.printer_host) + " · " + name
+ (run.chargen_nr ? " · " + run.chargen_nr : "");
const planned = Number(run.weight_planned_g) || 0;
const used = Number(run.weight_used_g) || 0;
const diff = Math.round(used) - Math.round(planned);
const cost = Number(run.filament_cost || 0) + Number(run.energy_cost || 0);
const spoolRows = (data.spools || []).map((s) => `
<tr>
<td>${escapeHtml(s.name)}${s.ace_slot ? " · ACE " + s.ace_slot : ""}</td>
<td>${escapeHtml(s.material_type || "")}</td>
<td class="num">${grams(s.weight_planned_g)}</td>
<td class="num">${grams(s.weight_g)}${s.is_estimate ? ' <span class="tag">Schätzwert</span>' : ""}</td>
<td class="num">${euro(s.cost)}</td>
<td class="nowrap">${escapeHtml(s.chargen_nr || "")}</td>
</tr>`).join("") || '<tr><td class="empty" colspan="6">Keine Spule zugeordnet.</td></tr>';
const events = (data.events || []).map((e) =>
`<li><span class="tiny">${escapeHtml((e.created_at || "").replace("T", " ").slice(0, 19))}</span> ${escapeHtml(e.message || e.kind)}</li>`
).join("") || "<li class=\"tiny\">Keine Ereignisse.</li>";
const labels = (data.labels || []).filter((l) => !l.excluded);
const labelBits = labels.length
? labels.map((l) => `<li id="objekt-${encodeURIComponent(l.object_name || "")}"><code>${escapeHtml(l.chargen_nr)}</code> ${escapeHtml(l.object_name || "Druck")}</li>`).join("")
: (run.chargen_nr ? `<li><code>${escapeHtml(run.chargen_nr)}</code> gesamter Druck</li>` : "<li class=\"tiny\">Noch keine Charge.</li>");
const segments = (data.segments || []).map((s) => {
const end = s.mm_end == null ? "…" : Math.round(s.mm_end);
return `<li>${escapeHtml(s.spool_name)} · ${Math.round(s.mm_start)}–${end} mm${s.ace_slot ? " · Platz " + s.ace_slot : ""}</li>`;
}).join("");
document.getElementById("bericht").innerHTML = `
<div class="card bericht-head">
<div class="bericht-meta">
<p><b>${escapeHtml(states[run.state] || run.state)}</b>${run.verdict ? " · Urteil " + escapeHtml(run.verdict) : ""}</p>
<p class="tiny">${escapeHtml(run.started_at || "—")} → ${escapeHtml(run.ended_at || "—")}</p>
<p>Dauer <b>${hours(run.duration_s)}</b>${run.estimated_s ? " · Soll " + hours(run.estimated_s) : ""}</p>
<p>Material <b>${grams(used)}</b> von ${grams(planned)}${planned ? " (" + (diff > 0 ? "+" : "") + diff + " g)" : ""}
${run.weight_source ? ' · <span class="tag">' + sourceLabel(run.weight_source) + "</span>" : ""}</p>
<p>Kosten <b>${euro(cost)}</b> (Filament ${euro(run.filament_cost)} · Strom ${euro(run.energy_cost)})</p>
${run.message ? "<p class=\"msg\">" + escapeHtml(run.message) + "</p>" : ""}
${data.project ? "<p class=\"tiny\">Projekt: " + escapeHtml(data.project.name) + "</p>" : ""}
</div>
</div>
<div class="card">
<h2>Spulen</h2>
<div class="table-wrap"><table>
<thead><tr><th>Spule</th><th>Sorte</th><th>Soll</th><th>Ist</th><th>Kosten</th><th>Charge</th></tr></thead>
<tbody>${spoolRows}</tbody>
</table></div>
${segments ? "<h3>Abschnitte</h3><ul class=\"plain\">" + segments + "</ul>" : ""}
</div>
<div class="card">
<h2>Verlauf</h2>
${chart(data.samples)}
</div>
<div class="card grid-2">
<div>
<h2>Chargen</h2>
<ul class="plain">${labelBits}</ul>
</div>
<div>
<h2>Ereignisse</h2>
<ul class="plain">${events}</ul>
</div>
</div>`;
const druck = document.getElementById("etikett-druck");
const objekt = document.getElementById("etikett-objekt");
if (run.chargen_nr) {
druck.hidden = false;
druck.href = "/etikett/lauf/" + run.id + "?mode=druck";
objekt.hidden = false;
objekt.href = "/etikett/lauf/" + run.id + "?mode=objekt&qty=1";
} else {
druck.hidden = true;
objekt.hidden = true;
}
}
api("/laeufe/" + runId).then(render).catch((error) => showNote("bericht-note", error));
</script>
{% endblock %}
+43 -7
View File
@@ -103,7 +103,7 @@
<select id="send-project"><option value="">keins</option></select> <select id="send-project"><option value="">keins</option></select>
<div id="ace-box" class="ace-box" hidden> <div id="ace-box" class="ace-box" hidden>
<h3>ACE-Zuordnung <span class="tip" title="Datei-Farbe auf ACE-Platz. Ohne Zuordnung startet der ACE oft nicht."><svg class="ico"><use href="#i-info"/></svg></span></h3> <h3>ACE-Zuordnung <span class="tip" title="Datei-Farbe auf ACE-Platz. Ohne Zuordnung startet der ACE oft nicht."><svg class="ico"><use href="#i-info"/></svg></span></h3>
<p class="tiny msg" id="ace-trailer-warn" hidden>Für Auto-Feed fehlen Slicer-Metadaten in der Datei.</p> <p class="tiny" id="ace-trailer-warn" hidden>Mehrfarb ohne Slicer-Trailer — Grotti ergänzt `; referenced_tools` beim Senden.</p>
<div id="ace-maps"></div> <div id="ace-maps"></div>
</div> </div>
</div> </div>
@@ -252,13 +252,41 @@ function loadSendMemory() {
/* ---------- ACE / Spule ---------- */ /* ---------- 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 place = Number(slot.index) + 1;
const box = place <= 4 ? "ACE 1 · " + place : "ACE 2 · " + (place - 4); 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 : ""; const kind = slot.type ? " · " + slot.type : "";
return box + kind; 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) { function filamentLabel(filament, index) {
const kind = filament.type || "Filament"; const kind = filament.type || "Filament";
const color = filament.color ? " · " + filament.color : ""; const color = filament.color ? " · " + filament.color : "";
@@ -369,7 +397,10 @@ function renderAceMaps(force) {
const paints = filePaints(file); const paints = filePaints(file);
box.hidden = false; box.hidden = false;
document.getElementById("options").open = true; 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 = ""; root.innerHTML = "";
chosen.forEach((printer) => { chosen.forEach((printer) => {
const block = document.createElement("div"); const block = document.createElement("div");
@@ -393,10 +424,15 @@ function renderAceMaps(force) {
printer.slots.forEach((slot) => { printer.slots.forEach((slot) => {
const option = document.createElement("option"); const option = document.createElement("option");
option.value = String(slot.index); option.value = String(slot.index);
option.textContent = slotLabel(slot); option.textContent = slotLabel(printer.host, slot);
if (slot.color) option.style.borderLeft = "4px solid " + slot.color; option.title = slotTitle(printer.host, slot);
// einfache Vorauswahl: gleiche Sorte, sonst Platz in Reihenfolge const spool = spoolAtSlot(printer.host, Number(slot.index) + 1);
if (!select.dataset.picked && filament.type && slot.type && filament.type.toLowerCase() === String(slot.type).toLowerCase()) { 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; option.selected = true;
select.dataset.picked = "1"; select.dataset.picked = "1";
} }
+26 -5
View File
@@ -41,14 +41,17 @@
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 4px; border-radius: 4px;
display: grid; display: grid;
grid-template-columns: 1fr 22mm; grid-template-columns: 1fr 20mm;
gap: 3mm; gap: 2.5mm;
align-items: center; align-items: center;
overflow: hidden;
} }
.meta { min-width: 0; }
.meta h1 { .meta h1 {
margin: 0 0 2mm; margin: 0 0 2mm;
font-size: 4.2mm; font-size: 4.2mm;
letter-spacing: -0.02em; letter-spacing: -0.02em;
overflow-wrap: anywhere;
} }
.meta p { margin: 0 0 1.2mm; font-size: 3.1mm; color: #333; } .meta p { margin: 0 0 1.2mm; font-size: 3.1mm; color: #333; }
.meta .code { font-weight: 700; font-variant-numeric: tabular-nums; } .meta .code { font-weight: 700; font-variant-numeric: tabular-nums; }
@@ -61,7 +64,19 @@
margin-left: -1mm; margin-left: -1mm;
} }
.dot:first-child { margin-left: 0; } .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 { @media print {
body { background: #fff; } body { background: #fff; }
.toolbar { display: none; } .toolbar { display: none; }
@@ -99,14 +114,20 @@
<p>Hersteller: {{ spool.hersteller_charge }}</p> <p>Hersteller: {{ spool.hersteller_charge }}</p>
{% endif %} {% endif %}
</div> </div>
<canvas id="qr" width="120" height="120"></canvas> <div class="qr-wrap">
<canvas id="qr" width="80" height="80"></canvas>
</div>
</div> </div>
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script> <script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
<script> <script>
QRCode.toCanvas(document.getElementById("qr"), {{ qr_url|tojson }}, { QRCode.toCanvas(document.getElementById("qr"), {{ qr_url|tojson }}, {
width: 120, width: 80,
margin: 1, margin: 1,
color: { dark: "#111111", light: "#ffffff" } color: { dark: "#111111", light: "#ffffff" }
}, () => {
const node = document.getElementById("qr");
node.style.width = "20mm";
node.style.height = "20mm";
}); });
</script> </script>
</body> </body>
+126
View File
@@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body {
margin: 0;
font: 14px/1.35 "Segoe UI", system-ui, sans-serif;
color: #111;
background: #f3f3f3;
}
.toolbar {
display: flex;
gap: 0.6rem;
padding: 0.8rem 1rem;
background: #1a1a1a;
color: #eee;
}
.toolbar a, .toolbar button {
color: #111;
background: #f97316;
border: 0;
border-radius: 8px;
padding: 0.4rem 0.85rem;
font: inherit;
font-weight: 650;
text-decoration: none;
cursor: pointer;
}
.toolbar a.ghost { background: transparent; color: #eee; border: 1px solid #555; }
.sheet {
width: 70mm;
min-height: 40mm;
margin: 1.2rem auto;
padding: 3.5mm;
background: #fff;
border: 1px solid #ccc;
border-radius: 4px;
display: grid;
grid-template-columns: 1fr 20mm;
gap: 2.5mm;
align-items: center;
overflow: hidden;
page-break-after: always;
}
.sheet:last-child { page-break-after: auto; }
.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; }
.qr-wrap {
width: 20mm;
height: 20mm;
overflow: hidden;
flex-shrink: 0;
}
.qr-wrap canvas,
.qr-wrap img {
display: block;
width: 20mm !important;
height: 20mm !important;
max-width: 100%;
max-height: 100%;
}
@media print {
body { background: #fff; }
.toolbar { display: none; }
.sheet {
margin: 0;
border: 0;
border-radius: 0;
page-break-inside: avoid;
}
}
</style>
</head>
<body>
<div class="toolbar">
<button type="button" onclick="window.print()">Drucken</button>
<a class="ghost" href="/protokoll/{{ run.id }}">Zurück</a>
</div>
{% for sheet in sheets %}
<div class="sheet">
<div class="meta">
<h1>{{ sheet.object_name or (run.filename.split('/')[-1] if run.filename else 'Druck') }}</h1>
<p class="code">{{ sheet.chargen_nr }}</p>
{% set soll = sheet.target_g if sheet.target_g is defined else target_g %}
{% if soll %}
<p>Ziel {{ '%.0f'|format(soll) }} g</p>
{% endif %}
<p>{{ run.printer_name }} · {{ (run.ended_at or run.started_at or '')[:10] }}</p>
{% for spool in spools %}
<p>{% 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 %}</p>
{% else %}
<p class="code" style="font-weight:500;color:#666">kein Material zugeordnet</p>
{% endfor %}
</div>
<div class="qr-wrap">
<canvas class="qr" id="qr-{{ loop.index0 }}" width="80" height="80" data-url="{{ sheet.qr_url }}"></canvas>
</div>
</div>
{% endfor %}
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
<script>
document.querySelectorAll("canvas.qr").forEach((node) => {
QRCode.toCanvas(node, node.dataset.url, {
width: 80,
margin: 1,
color: { dark: "#111111", light: "#ffffff" }
}, () => {
node.style.width = "20mm";
node.style.height = "20mm";
});
});
</script>
</body>
</html>
+41 -6
View File
@@ -9,7 +9,7 @@
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}"> <link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}"> <link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}"> <link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=41"> <link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=45">
<script> <script>
(function () { (function () {
try { try {
@@ -100,7 +100,8 @@
<a href="/kameras" class="{{ 'on' if page == 'kameras' else '' }}" title="Kameras"><svg class="ico"><use href="#i-camera"/></svg><span>Kameras</span></a> <a href="/kameras" class="{{ 'on' if page == 'kameras' else '' }}" title="Kameras"><svg class="ico"><use href="#i-camera"/></svg><span>Kameras</span></a>
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk" title="Aktivität"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a> <a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk" title="Aktivität"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}" title="Warteschlange"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a> <a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}" title="Warteschlange"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
<a href="/protokoll" class="{{ 'on' if page == 'protokoll' else '' }} nav-desk" title="Protokoll"><svg class="ico"><use href="#i-list"/></svg><span>Protokoll</span></a> <a href="/protokoll" class="{{ 'on' if page == 'protokoll' or page == 'bericht' else '' }} nav-desk" title="Protokoll"><svg class="ico"><use href="#i-list"/></svg><span>Protokoll</span></a>
<a href="/statistik" class="{{ 'on' if page == 'statistik' else '' }} nav-desk" title="Statistik"><svg class="ico"><use href="#i-activity"/></svg><span>Statistik</span></a>
<a href="/material" class="{{ 'on' if page == 'material' else '' }} nav-desk" title="Material"><svg class="ico"><use href="#i-spool"/></svg><span>Material</span></a> <a href="/material" class="{{ 'on' if page == 'material' else '' }} nav-desk" title="Material"><svg class="ico"><use href="#i-spool"/></svg><span>Material</span></a>
<a href="/projekte" class="{{ 'on' if page == 'projekte' else '' }} nav-desk" title="Projekte"><svg class="ico"><use href="#i-folder"/></svg><span>Projekte</span></a> <a href="/projekte" class="{{ 'on' if page == 'projekte' else '' }} nav-desk" title="Projekte"><svg class="ico"><use href="#i-folder"/></svg><span>Projekte</span></a>
</nav> </nav>
@@ -289,6 +290,31 @@ function directItemHtml(p) {
} }
let queueCache = []; let queueCache = [];
const lastLiveFilename = Object.create(null);
function setListHtml(el, html) {
if (!el) return;
if (el._html === html) return;
el._html = html;
el.innerHTML = html;
}
function stickyLiveFilename(host, filename, state) {
const next = filename || "";
if (next) {
lastLiveFilename[host] = next;
return next;
}
const prev = lastLiveFilename[host] || "";
if (prev && ["printing", "paused", "heating", "complete"].includes(String(state || "").toLowerCase())) {
return prev;
}
if (!["printing", "paused", "heating"].includes(String(state || "").toLowerCase())) {
delete lastLiveFilename[host];
}
return prev && ["complete"].includes(String(state || "").toLowerCase()) ? prev : "";
}
async function refreshPrintQueue() { async function refreshPrintQueue() {
let jobs; let jobs;
let active = []; let active = [];
@@ -298,7 +324,10 @@ async function refreshPrintQueue() {
queueCache = jobs || []; queueCache = jobs || [];
const running = queueCache.filter((job) => job.status === "running"); const running = queueCache.filter((job) => job.status === "running");
const queuedHosts = new Set(running.map((job) => job.host)); const queuedHosts = new Set(running.map((job) => job.host));
const direct = (active || []).filter((p) => !queuedHosts.has(p.host)); const direct = (active || []).filter((p) => !queuedHosts.has(p.host)).map((p) => ({
...p,
filename: stickyLiveFilename(p.host, p.filename, p.state),
}));
const waiting = queueCache.filter((job) => job.status === "waiting"); const waiting = queueCache.filter((job) => job.status === "waiting");
const errors = queueCache const errors = queueCache
.filter((job) => job.status === "error") .filter((job) => job.status === "error")
@@ -308,15 +337,21 @@ async function refreshPrintQueue() {
const runBox = document.getElementById("run-box"); const runBox = document.getElementById("run-box");
runBox.hidden = !(running.length || direct.length); runBox.hidden = !(running.length || direct.length);
document.getElementById("run-count").textContent = running.length + direct.length; document.getElementById("run-count").textContent = running.length + direct.length;
document.getElementById("run-list").innerHTML = running.map(runItemHtml).join("") + direct.map(directItemHtml).join(""); setListHtml(
document.getElementById("run-list"),
running.map(runItemHtml).join("") + direct.map(directItemHtml).join(""),
);
const waitList = document.getElementById("wait-list"); const waitList = document.getElementById("wait-list");
document.getElementById("wait-count").textContent = waiting.length; document.getElementById("wait-count").textContent = waiting.length;
document.getElementById("wait-count").className = "count" + (errors.length ? " bad" : waiting.length ? " hot" : ""); document.getElementById("wait-count").className = "count" + (errors.length ? " bad" : waiting.length ? " hot" : "");
document.getElementById("wait-empty").hidden = !!(waiting.length || errors.length); document.getElementById("wait-empty").hidden = !!(waiting.length || errors.length);
document.getElementById("wait-box").classList.toggle("is-empty", !(waiting.length || errors.length)); document.getElementById("wait-box").classList.toggle("is-empty", !(waiting.length || errors.length));
waitList.innerHTML = errors.map((job) => waitItemHtml(job, 0, 1)).join("") setListHtml(
+ waiting.map((job, i) => waitItemHtml(job, i, waiting.length)).join(""); waitList,
errors.map((job) => waitItemHtml(job, 0, 1)).join("")
+ waiting.map((job, i) => waitItemHtml(job, i, waiting.length)).join(""),
);
const panel = document.getElementById("live-panel"); const panel = document.getElementById("live-panel");
panel.querySelectorAll("[data-cancel-q]").forEach((btn) => { panel.querySelectorAll("[data-cancel-q]").forEach((btn) => {
+58 -1
View File
@@ -120,6 +120,11 @@
<button type="button" class="warn" id="spool-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button> <button type="button" class="warn" id="spool-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
</div> </div>
</form> </form>
<div id="spool-akte" class="spool-akte" hidden>
<h3>Akte</h3>
<p class="tiny" id="akte-summary"></p>
<ul class="plain" id="akte-runs"></ul>
</div>
<p id="spool-note"></p> <p id="spool-note"></p>
</section> </section>
</div> </div>
@@ -424,6 +429,49 @@ function payload() {
}; };
} }
async function loadAkte(spoolId) {
const box = document.getElementById("spool-akte");
if (!spoolId) {
box.hidden = true;
return;
}
try {
const data = await api("/spulen/" + spoolId + "/akte");
const stats = data.stats || {};
const fillPct = Math.round(Number(stats.fill_pct) || 0);
let leer = "—";
if (stats.empty_in_days == null) leer = "kein Verbrauch";
else if (stats.empty_in_days <= 0) leer = "leer";
else leer = "noch ~" + Math.round(stats.empty_in_days) + " Tage";
document.getElementById("akte-summary").textContent =
"Verdruckt " + grams(stats.total_g)
+ " · Ausschuss " + grams(stats.scrap_g)
+ " · " + euro(stats.total_cost)
+ " · Füllstand " + fillPct + " % · Leer-in: " + leer;
const list = document.getElementById("akte-runs");
list.innerHTML = "";
(data.runs || []).slice(0, 12).forEach((run) => {
const li = document.createElement("li");
const a = document.createElement("a");
a.href = "/protokoll/" + run.id;
a.textContent = (run.filename || "").split("/").pop() || ("#" + run.id);
li.appendChild(a);
li.appendChild(document.createTextNode(
" · " + grams(run.spool_weight_g)
+ (run.spool_is_estimate ? " ≈" : "")
+ " · " + (run.verdict || run.state || "")
));
list.appendChild(li);
});
if (!(data.runs || []).length) {
list.innerHTML = '<li class="tiny">Noch kein Druck auf dieser Spule.</li>';
}
box.hidden = false;
} catch (error) {
box.hidden = true;
}
}
function fill(spool) { function fill(spool) {
openSpoolPanel(); openSpoolPanel();
document.getElementById("spool-id").value = spool ? spool.id : ""; document.getElementById("spool-id").value = spool ? spool.id : "";
@@ -451,6 +499,7 @@ function fill(spool) {
showHint(); showHint();
renderPalette(); renderPalette();
renderSpools(); renderSpools();
loadAkte(spool ? spool.id : null);
document.getElementById("spool-panel").scrollIntoView({ block: "nearest", behavior: "smooth" }); document.getElementById("spool-panel").scrollIntoView({ block: "nearest", behavior: "smooth" });
} }
@@ -580,6 +629,7 @@ function renderSpools() {
const rest = Number(spool.remaining_g) || 0; const rest = Number(spool.remaining_g) || 0;
tr.children[5].textContent = grams(rest); tr.children[5].textContent = grams(rest);
if (rest < 100) tr.children[5].classList.add("msg"); if (rest < 100) tr.children[5].classList.add("msg");
if (rest < 0) tr.children[5].title = "Überzogen — Rest korrigieren";
tr.addEventListener("click", () => fill(spool)); tr.addEventListener("click", () => fill(spool));
body.appendChild(tr); body.appendChild(tr);
}); });
@@ -806,7 +856,14 @@ async function openByCode(raw) {
const match = code.match(/[?&]spool=(\d+)/); const match = code.match(/[?&]spool=(\d+)/);
if (match) spool = spools.find((row) => String(row.id) === match[1]) || null; if (match) spool = spools.find((row) => String(row.id) === match[1]) || null;
} }
if (!spool) spool = await api("/spulen/by-code/" + encodeURIComponent(code)); if (!spool) {
const found = await api("/spulen/by-code/" + encodeURIComponent(code));
if (found && found.kind === "lauf" && found.redirect) {
location.href = found.redirect;
return;
}
spool = found;
}
setView("spools"); setView("spools");
fill(spool); fill(spool);
setScanNote("Spule " + (spool.chargen_nr || spool.name) + " geöffnet.", true); setScanNote("Spule " + (spool.chargen_nr || spool.name) + " geöffnet.", true);
+22 -3
View File
@@ -24,7 +24,7 @@
<p id="run-note"></p> <p id="run-note"></p>
<div class="card table-wrap"> <div class="card table-wrap">
<table> <table>
<thead><tr><th>Drucker</th><th>Datei</th><th>Zustand</th><th>Dauer</th><th>Gramm</th><th>Kosten</th><th>Urteil</th><th>Spule</th><th></th></tr></thead> <thead><tr><th>Drucker</th><th>Datei</th><th>Zustand</th><th>Dauer</th><th>Ist</th><th>Kosten</th><th>Urteil</th><th>Spule</th><th></th></tr></thead>
<tbody id="run-rows"></tbody> <tbody id="run-rows"></tbody>
</table> </table>
</div> </div>
@@ -80,12 +80,31 @@ function render() {
} }
shown.forEach((run) => { shown.forEach((run) => {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.className = "pick";
tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>"; tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>";
tr.children[0].textContent = run.printer_name; tr.children[0].textContent = run.printer_name;
tr.children[1].textContent = (run.filename || "").split("/").pop(); const fileCell = tr.children[1];
const link = document.createElement("a");
link.href = "/protokoll/" + run.id;
link.textContent = (run.filename || "").split("/").pop() || ("#" + run.id);
fileCell.appendChild(link);
if (run.chargen_nr) {
const code = document.createElement("div");
code.className = "tiny";
code.textContent = run.chargen_nr;
fileCell.appendChild(code);
}
tr.children[2].textContent = states[run.state] || run.state; tr.children[2].textContent = states[run.state] || run.state;
tr.children[3].textContent = hours(run.duration_s); tr.children[3].textContent = hours(run.duration_s);
tr.children[4].textContent = run.weight_g ? grams(run.weight_g) : ""; const used = Number(run.weight_used_g) || Number(run.weight_g) || 0;
const planned = Number(run.weight_planned_g) || Number(run.weight_g) || 0;
tr.children[4].textContent = used ? grams(used) : "";
if (used && planned && Math.abs(used - planned) > 0.5) {
tr.children[4].title = "Soll " + grams(planned);
}
if (run.weight_source === "estimate" || (run.weight_source === "measured" && false)) {
tr.children[4].textContent += run.weight_source === "estimate" ? " ≈" : "";
}
const cost = Number(run.filament_cost || 0) + Number(run.energy_cost || 0); const cost = Number(run.filament_cost || 0) + Number(run.energy_cost || 0);
tr.children[5].textContent = cost ? euro(cost) : ""; tr.children[5].textContent = cost ? euro(cost) : "";
const verdict = document.createElement("select"); const verdict = document.createElement("select");
+214
View File
@@ -0,0 +1,214 @@
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Statistik</h1>
<p class="tiny">Auslastung, Verbrauch und Erfolgsquote aus dem Protokoll.</p>
</div>
<div class="page-head-side">
<a class="ghost" id="csv-link" href="/api/v1/statistik.csv">CSV</a>
</div>
</div>
<div class="page-tools">
<div class="segmented period-presets" id="period" role="group" aria-label="Zeitraum">
<button type="button" data-period="all" class="on">Alles</button>
<button type="button" data-period="day">Tag</button>
<button type="button" data-period="week">Woche</button>
<button type="button" data-period="month">Monat</button>
<button type="button" data-period="yesterday">Gestern</button>
<button type="button" data-period="last_week">Letzte Woche</button>
<button type="button" data-period="last_month">Letzter Monat</button>
</div>
<input type="date" id="since" title="Von" class="date-field">
<input type="date" id="until" title="Bis" class="date-field">
<select id="host"><option value="">Alle Drucker</option></select>
<input type="text" id="material" placeholder="Sorte" size="10">
<input type="text" id="brand" placeholder="Marke" size="10">
<button type="button" id="reload">Aktualisieren</button>
</div>
<p id="stat-note"></p>
<div id="stat" class="statistik"></div>
{% endblock %}
{% block script %}
<script>
function kpi(label, value, hint) {
return `<div class="kpi"><div class="kpi-val">${value}</div><div class="kpi-label">${escapeHtml(label)}</div>${hint ? '<div class="tiny">' + escapeHtml(hint) + "</div>" : ""}</div>`;
}
function isoDate(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return y + "-" + m + "-" + d;
}
function startOfWeek(date) {
const day = date.getDay();
const mondayOffset = day === 0 ? -6 : 1 - day;
const start = new Date(date.getFullYear(), date.getMonth(), date.getDate() + mondayOffset);
return start;
}
function periodRange(key) {
const today = new Date();
today.setHours(0, 0, 0, 0);
if (key === "all") return { since: "", until: "" };
if (key === "day") return { since: isoDate(today), until: isoDate(today) };
if (key === "yesterday") {
const day = new Date(today);
day.setDate(day.getDate() - 1);
return { since: isoDate(day), until: isoDate(day) };
}
if (key === "week") {
const start = startOfWeek(today);
const end = new Date(start);
end.setDate(end.getDate() + 6);
return { since: isoDate(start), until: isoDate(end) };
}
if (key === "last_week") {
const start = startOfWeek(today);
start.setDate(start.getDate() - 7);
const end = new Date(start);
end.setDate(end.getDate() + 6);
return { since: isoDate(start), until: isoDate(end) };
}
if (key === "month") {
const start = new Date(today.getFullYear(), today.getMonth(), 1);
const end = new Date(today.getFullYear(), today.getMonth() + 1, 0);
return { since: isoDate(start), until: isoDate(end) };
}
if (key === "last_month") {
const start = new Date(today.getFullYear(), today.getMonth() - 1, 1);
const end = new Date(today.getFullYear(), today.getMonth(), 0);
return { since: isoDate(start), until: isoDate(end) };
}
return { since: "", until: "" };
}
function setPeriodActive(key) {
document.querySelectorAll("#period [data-period]").forEach((btn) => {
btn.classList.toggle("on", btn.dataset.period === key);
});
}
function clearPeriodActive() {
document.querySelectorAll("#period [data-period]").forEach((btn) => btn.classList.remove("on"));
}
function fillPrinters(rows) {
const select = document.getElementById("host");
const current = select.value;
const known = new Set([...select.options].map((o) => o.value));
(rows || []).forEach((row) => {
if (!row.printer_host || known.has(row.printer_host)) return;
const opt = document.createElement("option");
opt.value = row.printer_host;
opt.textContent = row.printer_name || row.printer_host;
select.appendChild(opt);
known.add(row.printer_host);
});
if ([...select.options].some((o) => o.value === current)) select.value = current;
}
function render(data) {
fillPrinters(data.by_printer);
const o = data.overview || {};
const jobs = o.jobs || 0;
const success = jobs ? Math.round(100 * (o.completed || 0) / jobs) : 0;
const scrap = jobs ? Math.round(100 * (o.scrap || 0) / jobs) : 0;
const ratio = data.estimate_ratio != null
? (data.estimate_ratio * 100).toFixed(0) + " %"
: "—";
const printers = (data.by_printer || []).map((p) => `
<tr>
<td>${escapeHtml(p.printer_name || p.printer_host)}</td>
<td class="num">${p.jobs}</td>
<td class="num">${hours(p.duration_s)}</td>
<td class="num">${grams(p.weight_g)}</td>
<td class="num">${euro(p.cost)}</td>
<td class="num">${p.completed}</td>
</tr>`).join("") || '<tr><td class="empty" colspan="6">Keine Daten.</td></tr>';
const materials = (data.by_material || []).map((m) => `
<tr>
<td>${escapeHtml(m.material_type || "—")}</td>
<td>${escapeHtml(m.brand || "")}</td>
<td class="num">${grams(m.weight_g)}</td>
<td class="num">${euro(m.cost)}</td>
<td class="num">${m.jobs}</td>
</tr>`).join("") || '<tr><td class="empty" colspan="5">Kein Spulenabzug im Zeitraum.</td></tr>';
document.getElementById("stat").innerHTML = `
<div class="kpi-row">
${kpi("Läufe", String(jobs))}
${kpi("Druckzeit", hours(o.duration_s))}
${kpi("Verbrauch", grams(o.weight_g))}
${kpi("Kosten", euro(o.cost))}
${kpi("Erfolg", success + " %", (o.completed || 0) + " fertig · " + (o.failed || 0) + " abgebrochen")}
${kpi("Ausschuss", scrap + " %", (o.scrap || 0) + " mit Urteil Ausschuss")}
${kpi("Schätztreue", ratio, data.estimate_count ? data.estimate_count + " Läufe mit Soll-Zeit" : "keine Soll-Zeit")}
</div>
<div class="card">
<h2>Nach Drucker</h2>
<div class="table-wrap"><table>
<thead><tr><th>Drucker</th><th>Läufe</th><th>Zeit</th><th>Gramm</th><th>Kosten</th><th>Fertig</th></tr></thead>
<tbody>${printers}</tbody>
</table></div>
</div>
<div class="card">
<h2>Nach Material</h2>
<div class="table-wrap"><table>
<thead><tr><th>Sorte</th><th>Marke</th><th>Gramm</th><th>Kosten</th><th>Läufe</th></tr></thead>
<tbody>${materials}</tbody>
</table></div>
</div>`;
}
async function load() {
const params = new URLSearchParams();
const since = document.getElementById("since").value;
const until = document.getElementById("until").value;
const host = document.getElementById("host").value;
const material = document.getElementById("material").value.trim();
const brand = document.getElementById("brand").value.trim();
if (since) params.set("since", since);
if (until) params.set("until", until);
if (host) params.set("host", host);
if (material) params.set("material", material);
if (brand) params.set("brand", brand);
const q = params.toString();
document.getElementById("csv-link").href = "/api/v1/statistik.csv" + (q ? "?" + q : "");
const data = await api("/statistik" + (q ? "?" + q : ""));
render(data);
}
function applyPeriod(key) {
const range = periodRange(key);
document.getElementById("since").value = range.since;
document.getElementById("until").value = range.until;
setPeriodActive(key);
load().catch((error) => showNote("stat-note", error));
}
document.getElementById("period").addEventListener("click", (event) => {
const button = event.target.closest("[data-period]");
if (!button) return;
applyPeriod(button.dataset.period);
});
document.getElementById("reload").addEventListener("click", () => {
load().catch((error) => showNote("stat-note", error));
});
["since", "until"].forEach((id) => {
document.getElementById(id).addEventListener("change", () => {
clearPeriodActive();
load().catch((error) => showNote("stat-note", error));
});
});
document.getElementById("host").addEventListener("change", () => {
load().catch((error) => showNote("stat-note", error));
});
load().catch((error) => showNote("stat-note", error));
</script>
{% endblock %}
+229 -39
View File
@@ -98,9 +98,19 @@ let wallSpools = [];
let deskViewer = null; let deskViewer = null;
let deskCamFails = 0; let deskCamFails = 0;
let deskViewerFileId = null; let deskViewerFileId = null;
let deskSyncGen = 0;
let deskExcludeSelected = ""; let deskExcludeSelected = "";
const wallPullTried = new Set(); const wallPullTried = new Set();
const toolpathCache = new Map(); 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 cardNodes = new Map();
const runningJobs = new Map(); const runningJobs = new Map();
const printerCache = 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) { function tempbarHtml(label, value, target, scale, iconName) {
if (value == null) return ""; if (value == null) return "";
const now = Math.round(value); const now = Math.round(value);
@@ -265,12 +283,19 @@ function swatchesHtml(printer) {
}).join(""); }).join("");
} }
function actionsHtml(state) { function etikettHtml(printer) {
const runId = printer && printer.live_run_id;
if (!runId) return "";
return `<a class="ghost" href="/etikett/lauf/${runId}?mode=druck" target="_blank" rel="noopener">Etikett</a>`;
}
function actionsHtml(state, printer) {
const label = (state === "printing" || state === "paused") ? etikettHtml(printer) : "";
if (state === "printing") { if (state === "printing") {
return `<button type="button" class="ghost" data-cmd="pause">${icon("pause")}Pause</button><button type="button" class="warn" data-cmd="abbrechen">${icon("stop")}Abbrechen</button>`; return `${label}<button type="button" class="ghost" data-cmd="pause">${icon("pause")}Pause</button><button type="button" class="warn" data-cmd="abbrechen">${icon("stop")}Abbrechen</button>`;
} }
if (state === "paused") { if (state === "paused") {
return `<button type="button" data-cmd="fortsetzen">${icon("play")}Weiter</button><button type="button" class="warn" data-cmd="abbrechen">${icon("stop")}Abbrechen</button>`; return `${label}<button type="button" data-cmd="fortsetzen">${icon("play")}Weiter</button><button type="button" class="warn" data-cmd="abbrechen">${icon("stop")}Abbrechen</button>`;
} }
return ""; return "";
} }
@@ -327,13 +352,107 @@ function stripsHtml(printer, state) {
return bits.join(""); 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(`<div class="steer-ace" data-dryer-id="${id}">
<div class="steer-ace-head"><b>${escapeHtml(label)} Trockner</b><span class="tiny" data-dryer-info>${escapeHtml(info)}</span></div>
<div class="steer-row">
<label>Temp °C <input type="number" data-steer="dryer-temp-${id}" min="35" max="65" value="${escapeHtml(String(hot))}"></label>
<label>Dauer min <input type="number" data-steer="dryer-dur-${id}" min="1" max="720" value="${escapeHtml(String(dur))}"></label>
<button type="button" data-steer-go="dryer_start" data-dryer-id="${id}">Start</button>
<button type="button" class="ghost" data-steer-go="dryer_stop" data-dryer-id="${id}">Stopp</button>
</div>
</div>`);
});
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(`<div class="steer-ace-slots"><div class="steer-ace-head"><b>Plätze</b><span class="tiny">Sorte und Farbe aus Grotti</span></div>`);
loaded.forEach((spool) => {
const slot = Number(spool.ace_slot);
const hex = spoolHex(spool) || "#555";
const kind = spool.material_type || "—";
bits.push(`<div class="steer-slot-row">
<span class="dot" style="background:${escapeHtml(hex)}"></span>
<span class="grow"><b>${escapeHtml(aceSlotLabel(slot))}</b> · ${escapeHtml(kind)} · ${escapeHtml(spool.name || "")}</span>
<button type="button" class="ghost" data-steer-go="slot_setzen" data-ace-slot="${slot}">an den Drucker</button>
</div>`);
});
bits.push(`</div>`);
}
return bits.join("");
}
function steerPanelHtml(printer) { function steerPanelHtml(printer) {
const n = printer.nozzle_target != null ? Math.round(printer.nozzle_target) : ""; const n = printer.nozzle_target != null ? Math.round(printer.nozzle_target) : "";
const b = printer.bed_target != null ? Math.round(printer.bed_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 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 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; const fan = typeof printer.fan === "number" ? Math.round(printer.fan * 100) : 0;
return `<div class="steer" data-steer-host="${escapeHtml(printer.host)}"> const aceKey = aceSteerKey(printer);
return `<div class="steer" data-steer-host="${escapeHtml(printer.host)}" data-ace-key="${escapeHtml(aceKey)}">
<div class="steer-row"> <div class="steer-row">
<label>${icon("nozzle")} Düse <input type="number" data-steer="nozzle" data-steer-action="nozzle" min="0" max="350" value="${escapeHtml(String(n))}"></label> <label>${icon("nozzle")} Düse <input type="number" data-steer="nozzle" data-steer-action="nozzle" min="0" max="350" value="${escapeHtml(String(n))}"></label>
<label>${icon("bed")} Bett <input type="number" data-steer="bed" data-steer-action="bed" min="0" max="150" value="${escapeHtml(String(b))}"></label> <label>${icon("bed")} Bett <input type="number" data-steer="bed" data-steer-action="bed" min="0" max="150" value="${escapeHtml(String(b))}"></label>
@@ -345,6 +464,7 @@ function steerPanelHtml(printer) {
<label>Flow % <input type="number" data-steer="flow" data-steer-action="flow" min="50" max="150" value="${escapeHtml(String(flow))}"></label> <label>Flow % <input type="number" data-steer="flow" data-steer-action="flow" min="50" max="150" value="${escapeHtml(String(flow))}"></label>
<label>${icon("fan")} Fan % <input type="number" data-steer="fan" data-steer-action="fan" min="0" max="100" value="${escapeHtml(String(fan))}"></label> <label>${icon("fan")} Fan % <input type="number" data-steer="fan" data-steer-action="fan" min="0" max="100" value="${escapeHtml(String(fan))}"></label>
</div> </div>
${aceSteerHtml(printer)}
<p class="tiny">Enter im Feld setzt nur diesen Wert.</p> <p class="tiny">Enter im Feld setzt nur diesen Wert.</p>
</div>`; </div>`;
} }
@@ -447,8 +567,9 @@ function anycubicTaskLine(printer) {
return "<span>Anycubic <b>" + escapeHtml(bits.join(" · ")) + "</b></span>"; return "<span>Anycubic <b>" + escapeHtml(bits.join(" · ")) + "</b></span>";
} }
function jobLineVisible(printer, state) { function jobLineVisible(printer, state, filename) {
return !!printer.filename && state !== "standby" && state !== "offline"; const name = filename != null ? filename : (printer && printer.filename);
return !!name && state !== "standby" && state !== "offline";
} }
function shortName(name) { function shortName(name) {
@@ -517,11 +638,13 @@ function patchSteerValues(root, printer) {
if (steerHasFocus(root)) return; if (steerHasFocus(root)) return;
const steer = root.querySelector("[data-role=steer]"); const steer = root.querySelector("[data-role=steer]");
if (!steer) return; if (!steer) return;
const nozzle = steer.querySelector('[data-steer="nozzle"]'); const panel = steer.querySelector(".steer");
const bed = steer.querySelector('[data-steer="bed"]'); if (!panel) return;
const speed = steer.querySelector('[data-steer="speed"]'); const nozzle = panel.querySelector('[data-steer="nozzle"]');
const flow = steer.querySelector('[data-steer="flow"]'); const bed = panel.querySelector('[data-steer="bed"]');
const fan = steer.querySelector('[data-steer="fan"]'); 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) { if (nozzle && document.activeElement !== nozzle) {
nozzle.value = printer.nozzle_target != null ? Math.round(printer.nozzle_target) : ""; 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") { if (fan && document.activeElement !== fan && typeof printer.fan === "number") {
fan.value = Math.round(printer.fan * 100); 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) { function patchCard(article, printer) {
const state = shownState(printer); const state = shownState(printer);
const clearNow = needsClear(printer); const clearNow = needsClear(printer);
const shownFile = stickyFilename(printer, article.dataset.filename || "");
article.dataset.state = state; article.dataset.state = state;
article.dataset.filename = printer.filename || ""; article.dataset.filename = shownFile;
article.dataset.progress = String(printer.progress || 0); article.dataset.progress = String(printer.progress || 0);
article.dataset.family = printer.family || ""; article.dataset.family = printer.family || "";
article.dataset.needsClear = clearNow ? "1" : ""; article.dataset.needsClear = clearNow ? "1" : "";
@@ -558,7 +707,7 @@ function patchCard(article, printer) {
setHtml(role("stats"), statsHtml(printer, state)); setHtml(role("stats"), statsHtml(printer, state));
setHtml(role("temps"), tempbarsHtml(printer, state)); setHtml(role("temps"), tempbarsHtml(printer, state));
const jobline = role("jobline"); const jobline = role("jobline");
const jobVisible = jobLineVisible(printer, state); const jobVisible = jobLineVisible(printer, state, shownFile);
const hasThumb = !!(jobVisible && printer.preview); const hasThumb = !!(jobVisible && printer.preview);
if (jobline) { if (jobline) {
jobline.hidden = !jobVisible; jobline.hidden = !jobVisible;
@@ -590,13 +739,13 @@ function patchCard(article, printer) {
} }
const job = role("job"); const job = role("job");
if (job) { if (job) {
job.textContent = jobName(printer.filename) || ""; setText(job, jobName(shownFile) || "");
job.title = printer.filename || ""; job.title = shownFile || "";
} }
setHtml(role("swatches"), swatchesHtml(printer)); setHtml(role("swatches"), swatchesHtml(printer));
setHtml(role("flow"), flowHtml(printer)); setHtml(role("flow"), flowHtml(printer));
setHtml(role("strips"), stripsHtml(printer, state)); setHtml(role("strips"), stripsHtml(printer, state));
setHtml(role("controls"), actionsHtml(state)); setHtml(role("controls"), actionsHtml(state, printer));
} }
function refreshDeskCamera(printer) { function refreshDeskCamera(printer) {
@@ -687,9 +836,10 @@ function patchDesk(printer) {
if (!printer || printer.host !== deskHost) return; if (!printer || printer.host !== deskHost) return;
const state = shownState(printer); const state = shownState(printer);
const prevFile = deskEl.dataset.filename || ""; 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.host = printer.host;
deskEl.dataset.filename = printer.filename || ""; deskEl.dataset.filename = shownFile;
deskEl.dataset.progress = String(printer.progress || 0); deskEl.dataset.progress = String(printer.progress || 0);
deskEl.classList.remove(...DESK_STATES); deskEl.classList.remove(...DESK_STATES);
deskEl.classList.add(state); deskEl.classList.add(state);
@@ -712,11 +862,12 @@ function patchDesk(printer) {
setHtml(role("temps"), tempbarsHtml(printer, state)); setHtml(role("temps"), tempbarsHtml(printer, state));
const jobline = role("jobline"); 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"); const job = role("job");
if (job) { if (job) {
job.textContent = jobName(printer.filename) || (jobLineVisible(printer, state) ? "" : "Kein Auftrag"); setText(job, jobName(shownFile) || (jobVisible ? "" : "Kein Auftrag"));
job.title = printer.filename || ""; job.title = shownFile || "";
} }
const preview = role("preview"); const preview = role("preview");
if (preview) { if (preview) {
@@ -732,7 +883,7 @@ function patchDesk(printer) {
setHtml(role("facts"), factsHtml(printer)); setHtml(role("facts"), factsHtml(printer));
setHtml(role("flow"), flowHtml(printer)); setHtml(role("flow"), flowHtml(printer));
setHtml(role("strips"), stripsHtml(printer, state)); setHtml(role("strips"), stripsHtml(printer, state));
setHtml(role("controls"), actionsHtml(state)); setHtml(role("controls"), actionsHtml(state, printer));
const estop = role("estop"); const estop = role("estop");
if (estop) { if (estop) {
const showStop = state === "printing" || state === "paused" || state === "heating"; const showStop = state === "printing" || state === "paused" || state === "heating";
@@ -745,10 +896,14 @@ function patchDesk(printer) {
} }
const steer = role("steer"); const steer = role("steer");
if (steer && (!steer.querySelector(".steer") || fileChanged)) { if (steer) {
setHtml(steer, steerPanelHtml(printer)); const panel = steer.querySelector(".steer");
} else { const nextKey = aceSteerKey(printer);
patchSteerValues(deskEl, printer); if (!panel || fileChanged || panel.dataset.aceKey !== nextKey) {
setHtml(steer, steerPanelHtml(printer));
} else {
patchSteerValues(deskEl, printer);
}
} }
setHtml(role("mesh"), heatMapHtml(printer)); setHtml(role("mesh"), heatMapHtml(printer));
@@ -761,8 +916,9 @@ async function syncDeskViewer(printer) {
const canvas = deskEl.querySelector('[data-role="path-canvas"]'); const canvas = deskEl.querySelector('[data-role="path-canvas"]');
const caption = deskEl.querySelector('[data-role="path-caption"]'); const caption = deskEl.querySelector('[data-role="path-caption"]');
if (!canvas || !window.GrottiGcodeViewer || !window.THREE) return; 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 progress = Number(printer.progress || 0);
const gen = ++deskSyncGen;
if (!deskViewer) { if (!deskViewer) {
deskViewer = GrottiGcodeViewer.create(canvas); deskViewer = GrottiGcodeViewer.create(canvas);
deskViewer.setExcludeObjects({ objects: [], excluded: [], current: null }, { deskViewer.setExcludeObjects({ objects: [], excluded: [], current: null }, {
@@ -777,45 +933,55 @@ async function syncDeskViewer(printer) {
let file = matchLibraryFile(filename); let file = matchLibraryFile(filename);
if (!file && filename && !wallPullTried.has(deskHost + "|" + filename)) { if (!file && filename && !wallPullTried.has(deskHost + "|" + filename)) {
wallPullTried.add(deskHost + "|" + filename); wallPullTried.add(deskHost + "|" + filename);
if (caption) caption.textContent = "lade vom Drucker …"; setText(caption, "lade vom Drucker …");
try { try {
const saved = await api("/dateien/von-drucker", { const saved = await api("/dateien/von-drucker", {
method: "POST", method: "POST",
json: { host: deskHost, filename }, json: { host: deskHost, filename },
}); });
if (gen !== deskSyncGen) return;
libraryFiles = await api("/dateien"); libraryFiles = await api("/dateien");
if (gen !== deskSyncGen) return;
file = saved || matchLibraryFile(filename); file = saved || matchLibraryFile(filename);
} catch (error) { } catch (error) {
/* bleibt leer */ /* bleibt leer */
} }
} }
if (!file) { if (!file) {
// Kurz leerer filename: Pfad und Caption nicht verwerfen (sonst Flackern alle 5 s).
if (!filename && deskViewerFileId) return;
deskViewer.setPath({ points: [] }); deskViewer.setPath({ points: [] });
deskViewerFileId = null; deskViewerFileId = null;
if (caption) caption.textContent = filename ? "Datei nicht in Grotti" : "kein Auftrag"; setText(caption, filename ? "Datei nicht in Grotti" : "kein Auftrag");
renderExcludePanel(printer); renderExcludePanel(printer);
return; return;
} }
try { try {
let data = toolpathCache.get(file.id); let data = toolpathCache.get(file.id);
const needLoad = !data || data.version !== 8 || deskViewerFileId !== file.id; const cachedOk = data && Number(data.version) === 8;
if (needLoad) { const needFetch = !cachedOk;
if (caption) caption.textContent = "wird gelesen …"; const needSetPath = deskViewerFileId !== file.id;
if (needFetch) {
if (needSetPath) setText(caption, "wird gelesen …");
data = await api("/dateien/" + file.id + "/pfad?tp=8"); data = await api("/dateien/" + file.id + "/pfad?tp=8");
if (gen !== deskSyncGen) return;
toolpathCache.set(file.id, data); 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; deskViewerFileId = file.id;
} }
if (!(data.points && data.points.length)) { if (!(data.points && data.points.length)) {
if (caption) caption.textContent = "leer in Datei"; setText(caption, "leer in Datei");
return; return;
} }
} catch (error) { } catch (error) {
// File-Id behalten — sonst setzt der nächste Poll die 3D-Ansicht zurück // 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; return;
} }
if (gen !== deskSyncGen) return;
deskViewer.setProgress(progress, { deskViewer.setProgress(progress, {
layer: printer.layer, layer: printer.layer,
layers: printer.layers, layers: printer.layers,
@@ -836,7 +1002,7 @@ async function syncDeskViewer(printer) {
const layerTxt = printer.layers const layerTxt = printer.layers
? " · Schicht " + (printer.layer || 0) + "/" + 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) { function renderExcludePanel(printer) {
@@ -1036,8 +1202,8 @@ async function refresh() {
if (!libraryFiles.length) { if (!libraryFiles.length) {
try { libraryFiles = await api("/dateien"); } catch (error) { libraryFiles = []; } try { libraryFiles = await api("/dateien"); } catch (error) { libraryFiles = []; }
} }
if (!wallSpools.length) { if (!wallSpools.length || deskHost) {
try { wallSpools = await api("/spulen"); } catch (error) { wallSpools = []; } try { wallSpools = await api("/spulen"); } catch (error) { /* behalten */ }
} }
const all = await api("/drucker"); const all = await api("/drucker");
const printers = sortPrinters(all.slice()); const printers = sortPrinters(all.slice());
@@ -1259,11 +1425,35 @@ async function handleWallClick(event) {
method: "POST", json: { action: "fan", percent: 0 }, method: "POST", json: { action: "fan", percent: 0 },
}); });
toast("Heizung und Lüfter aus."); 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); showNote("note", null);
refresh(); refresh();
} catch (error) { } catch (error) {
showNote("note", error); showNote("note", error);
toast(error.message, "bad");
} finally { } finally {
steerBtn.disabled = false; steerBtn.disabled = false;
} }