Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efd0801cd1 | ||
|
|
b277ca48aa |
No files matched your search
@@ -4,6 +4,45 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.5.1] – 2026-10-17
|
||||
|
||||
### Neu
|
||||
|
||||
- Cockpit-Steuerung: ACE-Trockner starten/stoppen (Temp, Dauer), nur wenn ein ACE hängt.
|
||||
- Cockpit-Steuerung: bestückte ACE-Plätze mit Sorte und Farbe aus Grotti an den Drucker schreiben.
|
||||
- Wand-Kacheln zeigen das Druck-Vorschaubild (Moonraker-Thumbnail) in der Job-Zeile.
|
||||
- **Druckbericht** `/protokoll/<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
|
||||
|
||||
- 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.
|
||||
- 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“.
|
||||
|
||||
## [2.2.2] – 2026-10-15
|
||||
|
||||
### Behoben
|
||||
|
||||
- 3D-Pfad: bei großen G-codes zerbrach das Ausdünnen die Segment-Paare — Viewer zeigte nur Objekt-Umrisse, keinen Druckfortschritt (grün/grau).
|
||||
|
||||
## [2.2.1] – 2026-10-15
|
||||
|
||||
### Geändert
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ACE und Spulen
|
||||
|
||||
Stand **15.10.2026**. Mehrfarb-Drucke brauchen vor dem Start eine Zuordnung Datei-Farbe → ACE-Platz und eine passende Spule am Drucker.
|
||||
Stand **17.10.2026**. Mehrfarb-Drucke brauchen vor dem Start eine Zuordnung Datei-Farbe → ACE-Platz und eine passende Spule am Drucker.
|
||||
|
||||

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

|
||||
|
||||
@@ -8,21 +8,29 @@ Stand **15.10.2026**. Spulen, Etiketten, Projekte, Protokoll, Kameras, Aktivitä
|
||||
|
||||
Zwei Reiter: **Spulen** und **Sorten**.
|
||||
|
||||
**Spulen:** Filter Alle / Am Drucker / Regal / Wenig Rest. Formular bei Auswahl oder „Neue Spule“. Ort = Drucker · ACE/extern oder Regal. Scan-Feld im Seitenkopf (QR vom Etikett).
|
||||
**Spulen:** Filter Alle / Am Drucker / Regal / Wenig Rest. Formular bei Auswahl oder „Neue Spule“. Ort = Drucker · ACE/extern oder Regal. Scan-Feld im Seitenkopf (QR vom Etikett). Unter der ausgewählten Spule die **Akte**: verdruckte Gramm, Ausschuss, Leer-in.
|
||||
|
||||
**Sorten:** Profile (Dichte, Preis, Maschinen). „Spule damit anlegen“ wechselt zum Spulen-Reiter.
|
||||
|
||||
### Etikett und QR
|
||||
|
||||
Pro Spule ein Etikett mit Chargennummer und QR. Der Code öffnet Material mit der Spule vorausgewählt. Dafür in den Einstellungen ggf. **FQDN** setzen (öffentliche URL), sonst gilt der Host aus dem Request.
|
||||
Pro Spule ein Etikett mit Chargennummer `S-…` und QR. Der Code öffnet Material mit der Spule vorausgewählt. Dafür in den Einstellungen ggf. **FQDN** setzen (öffentliche URL), sonst gilt der Host aus dem Request.
|
||||
|
||||
Druck-Etiketten (`D-…`) kommen vom Druckbericht: ein Etikett für den ganzen Lauf oder je Objekt. Scan von `D-…` öffnet den Bericht.
|
||||
|
||||
## Projekte
|
||||
|
||||
Unter **Projekte** Aufträge mit Notiz und Kalkulation. Dateien lassen sich einem Projekt zuweisen (Bibliothek / Senden).
|
||||
|
||||
## Protokoll
|
||||
## Protokoll und Statistik
|
||||
|
||||
Abgeschlossene Läufe: Dauer, Gramm, Kosten, Urteil. **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option.
|
||||
Abgeschlossene Läufe: Dauer, Ist-Gramm, Kosten, Urteil. Zeile öffnet den **Druckbericht** (Soll/Ist, Spulen, Temperaturverlauf, Charge). **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option.
|
||||
|
||||
Unter **Statistik** Auslastung, Erfolg, Verbrauch und CSV-Export. Zeitraum per Knopf (Tag, Woche, Monat, Gestern, Letzte Woche, Letzter Monat) oder eigene Von/Bis-Daten.
|
||||
|
||||
Druck-Etiketten (`D-…`) schon während des Drucks: an der Wand **Etikett**, im Druckbericht **Etikett Druck** / **Etiketten Objekte**. Auf dem Bogen steht das Zielgewicht. Bei Ausschuss die gedruckten Bögen wegwerfen; die Charge bleibt im Protokoll.
|
||||
|
||||
Abzug nach dem Druck nimmt das Ist aus `filament_used` (Abbruch nur anteilig). Mehrfarb-Anteile sind als Schätzwert gekennzeichnet. Restgramm darf unter 0 gehen.
|
||||
|
||||
## Kameras
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Stand **15.10.2026**. Wie die App aufgebaut ist — Menü, Live-Leiste, Desktop
|
||||
|
||||
## Navigation
|
||||
|
||||
Links (Desktop): **Drucker**, **Dateien**, **Kameras**, **Aktivität**, **Warteschlange**, **Protokoll**, **Material**, **Projekte**. Darunter **Einstellungen**, **Ideen**, **Doku**. Fuß: Version (öffnet die Doku).
|
||||
Links (Desktop): **Drucker**, **Dateien**, **Kameras**, **Aktivität**, **Warteschlange**, **Protokoll**, **Statistik**, **Material**, **Projekte**. Darunter **Einstellungen**, **Ideen**, **Doku**. Fuß: Version (öffnet die Doku).
|
||||
|
||||
Unter ca. 760 px: untere Leiste mit den wichtigsten Seiten. Das Seitenmenü lässt sich zur Icon-Rail einklappen.
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ Im Cockpit (nur bei aktivem Druck/Heizen). Danach oft **Strom aus und wieder an*
|
||||
|
||||
Oft noch Verarbeitung auf dem Drucker. In der Sidebar unter **Senden** den Fortschritt beobachten. Nach einem Grotti-Update den Dienst einmal neu starten.
|
||||
|
||||
## „gcode … not found“ (10115)
|
||||
|
||||
Oft Dateinamen mit Nicht-ASCII-Zeichen (z. B. Chinesisch). Grotti normalisiert den Namen beim Senden auf ASCII. Nochmal senden; in der Bibliothek darf der Originalname bleiben.
|
||||
|
||||
## Wo nachschauen
|
||||
|
||||
- Sidebar: Alerts, Läuft, Wartet, Senden
|
||||
|
||||
@@ -36,7 +36,7 @@ Inhalt:
|
||||
|
||||
- **Kamera** — nur wenn eine Webcam erreichbar ist; sonst bleibt der Platz weg
|
||||
- Status, Temps inkl. Bauraum wo verfügbar, Lüfter
|
||||
- **Steuerung:** Zieltemperaturen, Speed, Flow, Fan — fest vorgegebene Befehle, keine freie Konsole
|
||||
- **Steuerung:** Zieltemperaturen, Speed, Flow, Fan — fest vorgegebene Befehle, keine freie Konsole. Mit ACE zusätzlich Trockner (Temp, Dauer, Start/Stopp) und pro bestücktem Platz **an den Drucker** (Sorte und Farbe aus der Grotti-Spule)
|
||||
- **E-Stop** nur bei printing/paused/heating (Zwei-Klick). Danach oft Strom aus/an
|
||||
- **3D-Pfad** der laufenden oder gewählten Datei; Objekte ausschließen (mit Bestätigung)
|
||||
- **Bettnetz**-Heatmap, wenn vorhanden
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Chargen, Etiketten und 3D-Viewer
|
||||
|
||||
Stand 14.10.2026.
|
||||
Stand 17.10.2026.
|
||||
|
||||
## Spulen-Chargen
|
||||
|
||||
@@ -10,9 +10,15 @@ 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`.
|
||||
|
||||
## 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
|
||||
|
||||
`GET /api/v1/dateien/<id>/pfad` liefert einen gedünnten Toolpath (bis ca. 120 000 Punkte, plus `layer_breaks`, Cache-`version` 5) aus dem lokalen G-code. 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.
|
||||
|
||||
Darstellung: nur Extrusionswege. Preview = orange. Live: gedruckt leicht verdickt grün, offen grau; Schnitt nach `progress`. Parser akzeptiert `E.39`/`Z.6`. Bett: Gitter 10 mm, mm-Sprites, Legende.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -37,10 +37,15 @@ Stand **14.10.2026**, v1.4.0.
|
||||
|
||||
## Material
|
||||
|
||||
- Reiter **Spulen** / **Sorten**. Scan-Feld im Seitenkopf.
|
||||
- Spulen: Filter Alle / Am Drucker / Regal / Wenig Rest + Suche. Formular nur bei Auswahl oder „Neue Spule“ (× schließt). Ort = Drucker · ACE/extern bzw. Regal.
|
||||
- Reiter **Spulen** / **Sorten**. Scan-Feld im Seitenkopf (`S-…` Spule, `D-…` Druckbericht).
|
||||
- Spulen: Filter Alle / Am Drucker / Regal / Wenig Rest + Suche. Formular nur bei Auswahl oder „Neue Spule“ (× schließt). Ort = Drucker · ACE/extern bzw. Regal. Akte mit Verbrauch und Leer-in.
|
||||
- Sorten: Profile (Dichte, mm³/s, Preis, Maschinen). Anlegen/ändern/löschen; „Spule damit anlegen“ wechselt zum Spulen-Reiter.
|
||||
|
||||
## Protokoll und Statistik
|
||||
|
||||
- Protokoll-Zeile → Druckbericht `/protokoll/<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
|
||||
|
||||
- Seite `/warteschlange`: Filter Läuft / Wartet / Fehler / Historie. Priorität, Abbrechen, Fortsetzen.
|
||||
|
||||
@@ -20,6 +20,8 @@ Ab 12.10.2026: Start legt Hintergrund-Aufträge an (`pending` → `running` →
|
||||
|
||||
`watch_dir` in `config.json` zeigt auf den Slice-Ordner der Bibliothek. Ohne Ordner geht Hochladen über die Dateien-Seite trotzdem.
|
||||
|
||||
**Dateinamen:** Moonraker-Multipart nutzt nur noch einen ASCII-`filename` (`moonraker.safe_upload_name`). Früher standen Nicht-ASCII-Zeichen in `filename*=UTF-8''…`, während `filename=` Unterstriche bekam — gklib speicherte A, Start suchte B → **10115 not found**.
|
||||
|
||||
## ACE / `.acm`
|
||||
|
||||
Drucker mit ACE/MMU-Plätzen (`mmu.gate_color` / `gate_material`, Grotti-`slots`) brauchen vor `print/start` eine Sidecar-Datei `*.gcode.acm`. In der UI unter Optionen → ACE-Zuordnung (Datei-Farbe → Platz). Ohne passende `.acm` lehnt der Drucker oft mit „cannot parse the file“ ab — Details hier, nicht als Banner in der App:
|
||||
|
||||
+246
-52
@@ -5,7 +5,7 @@ import urllib.error
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from grotti import acm, eject, library, moonraker, store
|
||||
from grotti import acm, eject, library, moonraker, store, usage
|
||||
from grotti.cost import energy_cost
|
||||
from grotti.errors import GrottiError
|
||||
from grotti.parse import content_has_ace_trailer, prepare_rinkhals_gcode
|
||||
@@ -15,17 +15,7 @@ TERMINAL = {"completed", "cancelled", "error", "interrupted"}
|
||||
|
||||
|
||||
def weight_of(metadata):
|
||||
raw = (metadata or {}).get("filament_weight_total")
|
||||
if isinstance(raw, list):
|
||||
return sum(float(item or 0) for item in raw)
|
||||
if isinstance(raw, (int, float)):
|
||||
return float(raw)
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
return float(raw.replace(",", "."))
|
||||
except ValueError:
|
||||
return 0.0
|
||||
return 0.0
|
||||
return usage.weight_sum(metadata)
|
||||
|
||||
|
||||
def material_of(metadata):
|
||||
@@ -35,6 +25,37 @@ def material_of(metadata):
|
||||
return str(raw).split(";")[0].strip()
|
||||
|
||||
|
||||
def _ace_map_of(sent):
|
||||
if not sent:
|
||||
return []
|
||||
raw = sent.get("ace_map")
|
||||
if isinstance(raw, list):
|
||||
return acm.normalize_mapping(raw)
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
return acm.normalize_mapping(json.loads(raw))
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def _targets_from_ace(conn, host, ace):
|
||||
targets = []
|
||||
for item in ace:
|
||||
slot = int(item["ams_index"]) + 1
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT * FROM spools
|
||||
WHERE printer_host = %s AND ace_slot = %s
|
||||
ORDER BY id LIMIT 1
|
||||
""",
|
||||
(host, slot),
|
||||
).fetchone()
|
||||
if row:
|
||||
targets.append(row)
|
||||
return targets
|
||||
|
||||
|
||||
def unix_to_dt(value):
|
||||
if not value:
|
||||
return None
|
||||
@@ -128,50 +149,152 @@ def assert_spool_at_host(conn, spool_id, host):
|
||||
)
|
||||
|
||||
|
||||
def apply_deduction(conn, run, energy):
|
||||
if run["deducted"] or run["weight_g"] <= 0 or run["state"] not in TERMINAL:
|
||||
def apply_deduction(conn, run, energy, metadata=None):
|
||||
if run["deducted"] or run["state"] not in TERMINAL:
|
||||
return False
|
||||
weight = float(run["weight_g"] or 0)
|
||||
# ACE: Abzug anteilig auf die Plätze der letzten Sendung
|
||||
planned = float(run.get("weight_planned_g") or run.get("weight_g") or 0)
|
||||
used = float(run.get("weight_used_g") or 0)
|
||||
source = run.get("weight_source") or ""
|
||||
if used <= 0 and planned > 0 and source != "none":
|
||||
used, source = usage.resolve_used_grams(
|
||||
planned,
|
||||
run.get("filament_used_mm") or 0,
|
||||
run.get("filament_total_mm") or 0,
|
||||
run.get("progress_last") or 0,
|
||||
run["state"],
|
||||
run.get("duration_s") or 0,
|
||||
)
|
||||
if used <= 0:
|
||||
return False
|
||||
|
||||
sent = store.latest_send(conn, run["printer_host"], run.get("filename") or "")
|
||||
ace = []
|
||||
if sent:
|
||||
raw = sent.get("ace_map")
|
||||
if isinstance(raw, list):
|
||||
ace = raw
|
||||
elif isinstance(raw, str):
|
||||
try:
|
||||
ace = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
ace = []
|
||||
targets = []
|
||||
if ace:
|
||||
for item in acm.normalize_mapping(ace):
|
||||
slot = int(item["ams_index"]) + 1
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT * FROM spools
|
||||
WHERE printer_host = %s AND ace_slot = %s
|
||||
ORDER BY id LIMIT 1
|
||||
""",
|
||||
(run["printer_host"], slot),
|
||||
).fetchone()
|
||||
if row:
|
||||
targets.append(row)
|
||||
ace = _ace_map_of(sent)
|
||||
meta = metadata or {}
|
||||
if not meta and run.get("file_id"):
|
||||
file_row = store.get_file_row(conn, run["file_id"]) if hasattr(store, "get_file_row") else None
|
||||
if file_row:
|
||||
meta = {"filament_weight_total": file_row.get("weight_g") or planned}
|
||||
|
||||
store.close_open_segments(conn, run["id"], None, run.get("filament_used_mm") or 0)
|
||||
segments = store.list_run_segments(conn, run["id"])
|
||||
|
||||
entries = []
|
||||
targets = _targets_from_ace(conn, run["printer_host"], ace) if ace else []
|
||||
if not targets and run.get("spool_id"):
|
||||
spool = store.get_spool_row(conn, run["spool_id"])
|
||||
if spool:
|
||||
targets = [spool]
|
||||
if not targets:
|
||||
# Abschnitte ohne ACE-Map: externe Rollen nacheinander
|
||||
if segments and not ace:
|
||||
final_mm = float(run.get("filament_used_mm") or 0)
|
||||
total_mm = float(run.get("filament_total_mm") or 0)
|
||||
deltas = usage.segment_deltas(segments, final_mm)
|
||||
for spool_id, ace_slot, delta_mm in deltas:
|
||||
spool = store.get_spool_row(conn, spool_id)
|
||||
if not spool:
|
||||
continue
|
||||
grams = usage.grams_from_mm(delta_mm, total_mm, planned)
|
||||
cost = (grams / 1000.0) * float(spool.get("price_per_kg") or 0)
|
||||
entries.append({
|
||||
"spool_id": spool_id,
|
||||
"ace_slot": ace_slot,
|
||||
"weight_g": grams,
|
||||
"weight_planned_g": planned / max(1, len(deltas)),
|
||||
"cost": cost,
|
||||
"is_estimate": source != "measured",
|
||||
})
|
||||
elif targets:
|
||||
shares = usage.planned_shares(meta, ace, len(targets))
|
||||
parts, is_estimate = usage.split_used(used, shares, multi=len(targets) > 1)
|
||||
if source != "measured":
|
||||
is_estimate = True
|
||||
# ACE-Rollenwechsel: Anteil der gewechselten Farbe nach mm aufteilen
|
||||
by_slot = {}
|
||||
if segments and ace:
|
||||
final_mm = float(run.get("filament_used_mm") or 0)
|
||||
for spool_id, ace_slot, delta_mm in usage.segment_deltas(segments, final_mm):
|
||||
by_slot.setdefault(ace_slot, []).append((spool_id, delta_mm))
|
||||
for index, spool in enumerate(targets):
|
||||
plan = shares[index] if index < len(shares) else 0
|
||||
grams = parts[index] if index < len(parts) else 0
|
||||
slot = spool.get("ace_slot")
|
||||
slot_segs = by_slot.get(slot) or []
|
||||
if len(slot_segs) > 1:
|
||||
mm_sum = sum(d for _, d in slot_segs) or 1.0
|
||||
for seg_spool_id, delta_mm in slot_segs:
|
||||
seg_spool = store.get_spool_row(conn, seg_spool_id)
|
||||
if not seg_spool:
|
||||
continue
|
||||
share_g = grams * (delta_mm / mm_sum)
|
||||
cost = (share_g / 1000.0) * float(seg_spool.get("price_per_kg") or 0)
|
||||
entries.append({
|
||||
"spool_id": seg_spool_id,
|
||||
"ace_slot": slot,
|
||||
"weight_g": share_g,
|
||||
"weight_planned_g": plan / len(slot_segs),
|
||||
"cost": cost,
|
||||
"is_estimate": True,
|
||||
})
|
||||
else:
|
||||
cost = (grams / 1000.0) * float(spool.get("price_per_kg") or 0)
|
||||
entries.append({
|
||||
"spool_id": spool["id"],
|
||||
"ace_slot": slot,
|
||||
"weight_g": grams,
|
||||
"weight_planned_g": plan,
|
||||
"cost": cost,
|
||||
"is_estimate": is_estimate,
|
||||
})
|
||||
elif segments:
|
||||
# Fallback: Abschnitte ohne aktuelle Targets
|
||||
final_mm = float(run.get("filament_used_mm") or 0)
|
||||
total_mm = float(run.get("filament_total_mm") or 0)
|
||||
for spool_id, ace_slot, delta_mm in usage.segment_deltas(segments, final_mm):
|
||||
spool = store.get_spool_row(conn, spool_id)
|
||||
if not spool:
|
||||
continue
|
||||
grams = usage.grams_from_mm(delta_mm, total_mm, planned)
|
||||
cost = (grams / 1000.0) * float(spool.get("price_per_kg") or 0)
|
||||
entries.append({
|
||||
"spool_id": spool_id,
|
||||
"ace_slot": ace_slot,
|
||||
"weight_g": grams,
|
||||
"weight_planned_g": 0,
|
||||
"cost": cost,
|
||||
"is_estimate": source != "measured",
|
||||
})
|
||||
else:
|
||||
return False
|
||||
share = weight / len(targets)
|
||||
filament = sum((share / 1000) * float(spool["price_per_kg"] or 0) for spool in targets)
|
||||
|
||||
if not entries:
|
||||
return False
|
||||
used_total = sum(item["weight_g"] for item in entries)
|
||||
filament = sum(item["cost"] for item in entries)
|
||||
power = energy_cost(run["duration_s"], energy)
|
||||
marked = store.mark_deducted(conn, run["id"], targets[0]["id"], filament, power)
|
||||
# run_spools vor deducted=true, sonst bleibt die Akte leer bei Abbruch dazwischen
|
||||
store.replace_run_spools(conn, run["id"], entries)
|
||||
marked = store.mark_deducted(
|
||||
conn,
|
||||
run["id"],
|
||||
entries[0]["spool_id"],
|
||||
filament,
|
||||
power,
|
||||
weight_used_g=used_total,
|
||||
weight_source=source or ("estimate" if any(e["is_estimate"] for e in entries) else "measured"),
|
||||
)
|
||||
if not marked:
|
||||
store.replace_run_spools(conn, run["id"], [])
|
||||
return False
|
||||
for spool in targets:
|
||||
store.deduct_spool(conn, spool["id"], share)
|
||||
for item in entries:
|
||||
store.deduct_spool(conn, item["spool_id"], item["weight_g"])
|
||||
store.ensure_run_chargen(conn, run["id"], used_total)
|
||||
objects = run.get("objects")
|
||||
if isinstance(objects, str):
|
||||
try:
|
||||
objects = json.loads(objects)
|
||||
except json.JSONDecodeError:
|
||||
objects = []
|
||||
store.ensure_run_labels(conn, run["id"], objects or [], qty=1)
|
||||
return True
|
||||
|
||||
|
||||
@@ -190,13 +313,16 @@ def sync_history(conn, cfg):
|
||||
deducted = 0
|
||||
for printer, job in collected:
|
||||
job_id = str(job.get("job_id") or "")
|
||||
if not job_id:
|
||||
if not job_id or str(job_id).startswith("live:"):
|
||||
continue
|
||||
filename = job.get("filename") or ""
|
||||
metadata = job.get("metadata") or {}
|
||||
weight = weight_of(metadata)
|
||||
planned = weight_of(metadata)
|
||||
material = material_of(metadata)
|
||||
state = job.get("status") or ""
|
||||
used_mm = usage.filament_used_mm(job)
|
||||
total_mm = usage.filament_total_mm(metadata, job)
|
||||
estimated = float(metadata.get("estimated_time") or 0)
|
||||
sent = store.latest_send(conn, printer["host"], filename)
|
||||
known = store.file_by_name(conn, filename)
|
||||
file_id = (sent or {}).get("file_id") or (known or {}).get("id")
|
||||
@@ -204,6 +330,23 @@ def sync_history(conn, cfg):
|
||||
hinted = (sent or {}).get("spool_id") or (known or {}).get("spool_id")
|
||||
spool = choose_spool(conn, printer["host"], material, hinted)
|
||||
terminal = state in TERMINAL
|
||||
live = store.find_live_run(conn, printer["host"], filename)
|
||||
progress = float((live or {}).get("progress_last") or 0)
|
||||
if used_mm <= 0 and live:
|
||||
used_mm = float(live.get("filament_used_mm") or 0)
|
||||
if total_mm <= 0 and live:
|
||||
total_mm = float(live.get("filament_total_mm") or 0)
|
||||
if planned <= 0 and live:
|
||||
planned = float(live.get("weight_planned_g") or 0)
|
||||
objects = (live or {}).get("objects") or []
|
||||
if isinstance(objects, str):
|
||||
try:
|
||||
objects = json.loads(objects)
|
||||
except json.JSONDecodeError:
|
||||
objects = []
|
||||
used_g, source = usage.resolve_used_grams(
|
||||
planned, used_mm, total_mm, progress, state, job.get("print_duration") or 0
|
||||
)
|
||||
row = store.upsert_run(conn, {
|
||||
"printer_host": printer["host"],
|
||||
"printer_name": printer["name"],
|
||||
@@ -215,13 +358,33 @@ def sync_history(conn, cfg):
|
||||
"started_at": unix_to_dt(job.get("start_time")),
|
||||
"ended_at": unix_to_dt(job.get("end_time")),
|
||||
"duration_s": float(job.get("print_duration") or job.get("total_duration") or 0),
|
||||
"weight_g": weight,
|
||||
"weight_g": planned,
|
||||
"weight_planned_g": planned,
|
||||
"weight_used_g": used_g,
|
||||
"weight_source": source,
|
||||
"filament_used_mm": used_mm,
|
||||
"filament_total_mm": total_mm,
|
||||
"progress_last": progress,
|
||||
"estimated_s": estimated,
|
||||
"energy_cost": energy_cost(job.get("print_duration") or job.get("total_duration") or 0, cfg["energy"]),
|
||||
"moonraker_job_id": job_id,
|
||||
"needs_spool": bool(terminal and weight > 0 and not spool),
|
||||
"needs_spool": bool(terminal and used_g > 0 and not spool),
|
||||
"material_type": material,
|
||||
"message": (job.get("message") or "") if state == "error" else "",
|
||||
"objects": objects,
|
||||
})
|
||||
if apply_deduction(conn, row, cfg["energy"]):
|
||||
# Live sofort auf History hängen — auch solange noch in_progress (eine Protokoll-Zeile)
|
||||
store.adopt_live_run(conn, printer["host"], filename, row["id"])
|
||||
row = store.get_run_row(conn, row["id"])
|
||||
if terminal:
|
||||
# Nachzug nicht während eines neuen Live-Drucks — sonst „Platte belegt“ zu Unrecht
|
||||
active = store.find_live_run(conn, printer["host"])
|
||||
if not active:
|
||||
ended = row.get("ended_at") or unix_to_dt(job.get("end_time"))
|
||||
store.ensure_bed_after_print(
|
||||
conn, printer["host"], ended_at=ended, message=filename or state
|
||||
)
|
||||
if apply_deduction(conn, row, cfg["energy"], metadata=metadata):
|
||||
deducted += 1
|
||||
return {"gelesen": len(collected), "abgezogen": deducted}
|
||||
|
||||
@@ -373,7 +536,11 @@ def send_file(
|
||||
filaments = [{"type": "PLA", "color": ""}]
|
||||
|
||||
progress("vorbereiten", 30, "Datei vorbereiten…")
|
||||
filename, content, plate = library.printer_file(row)
|
||||
filename, content, plate = library.printer_file(
|
||||
row, conn=conn, data_dir=(cfg or {}).get("data_dir")
|
||||
)
|
||||
# CJK/Unicode im Namen → Rinkhals 10115 not found (Upload ≠ Start-Name)
|
||||
filename = moonraker.safe_upload_name(filename)
|
||||
ace_tools = [item.get("paint_index") for item in mapping] if mapping else None
|
||||
if (row.get("kind") or "") == "gcode" and str(filename).endswith(".gcode"):
|
||||
content = prepare_rinkhals_gcode(content, tools=ace_tools)
|
||||
@@ -484,6 +651,33 @@ def steuern(conn, host, action, params=None):
|
||||
printer = store.printer_by_host(conn, host)
|
||||
if not printer:
|
||||
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
|
||||
params = dict(params) if isinstance(params, dict) else {}
|
||||
if action == "slot_setzen":
|
||||
try:
|
||||
ace_slot = int(params.get("ace_slot"))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise GrottiError("ACE-Platz muss 1–8 sein.", "platz") from exc
|
||||
spool = None
|
||||
for row in store.spools_for_printer(conn, host):
|
||||
raw_slot = row.get("ace_slot")
|
||||
if raw_slot in (None, ""):
|
||||
continue
|
||||
try:
|
||||
if int(raw_slot) == ace_slot:
|
||||
spool = row
|
||||
break
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not spool:
|
||||
raise GrottiError("Auf diesem Platz liegt keine Spule in Grotti.", "spule")
|
||||
material = (spool.get("material_type") or "").strip()
|
||||
if not material:
|
||||
raise GrottiError("Die Spule braucht eine Sorte.", "material")
|
||||
color = moonraker.spool_rgb(spool)
|
||||
if not color:
|
||||
raise GrottiError("Die Spule braucht eine Farbe.", "farbe")
|
||||
hub_id, index = moonraker.ace_hub_index(ace_slot)
|
||||
params = {"id": hub_id, "index": index, "type": material, "color": color}
|
||||
try:
|
||||
moonraker.steuern(host, action, params)
|
||||
except moonraker.PrinterError as exc:
|
||||
|
||||
+27
-5
@@ -142,8 +142,30 @@ def ensure_library_gcode(conn, cfg, host, filename):
|
||||
return save_upload(conn, name, content, cfg)
|
||||
|
||||
|
||||
def printer_file(row):
|
||||
path = Path(row["stored_path"])
|
||||
def resolve_stored_path(conn, row, data_dir=None):
|
||||
"""Gespeicherter Pfad, ggf. nach Umzug von data_dir unter eingang/<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():
|
||||
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
|
||||
if row["kind"] == "gcode":
|
||||
@@ -153,9 +175,9 @@ def printer_file(row):
|
||||
return path.name, path.read_bytes(), row["plate"] or ""
|
||||
|
||||
|
||||
def preview_png(row):
|
||||
path = Path(row["stored_path"])
|
||||
if not path.is_file():
|
||||
def preview_png(row, conn=None, data_dir=None):
|
||||
path = resolve_stored_path(conn, row, data_dir) if (conn is not None or data_dir is not None) else Path(row["stored_path"])
|
||||
if path is None or not path.is_file():
|
||||
return None
|
||||
entry = row["thumb_entry"]
|
||||
if row["kind"] == "gcode":
|
||||
|
||||
+201
-6
@@ -165,18 +165,53 @@ def post_json(host, path, payload, timeout=20):
|
||||
raise PrinterError("Der Drucker ist nicht erreichbar.") from exc
|
||||
|
||||
|
||||
def safe_upload_name(filename):
|
||||
"""Dateiname für Moonraker/Rinkhals/gklib — nur ASCII.
|
||||
|
||||
`filename` vs. `filename*` mit CJK führte zu Upload unter `_` und Start
|
||||
unter dem Originalnamen → Fehler 10115 „gcode … not found“.
|
||||
"""
|
||||
raw = (filename or "").replace("\\", "/")
|
||||
parts = [part for part in raw.split("/") if part]
|
||||
base = parts[-1] if parts else "job.gcode"
|
||||
out = []
|
||||
for ch in base:
|
||||
if ch.isascii() and ch not in '"\\':
|
||||
out.append(ch if ch not in " \t" else "_")
|
||||
else:
|
||||
out.append("_")
|
||||
text = "".join(out)
|
||||
while "__" in text:
|
||||
text = text.replace("__", "_")
|
||||
while "_-" in text or "-_" in text:
|
||||
text = text.replace("_-", "-").replace("-_", "-")
|
||||
text = text.strip("._-") or "job"
|
||||
lower = text.lower()
|
||||
if not (lower.endswith(".gcode") or lower.endswith(".gcode.3mf") or lower.endswith(".3mf") or lower.endswith(".acm")):
|
||||
# Suffix vom Original behalten, falls nur der Stamm zerstört wurde
|
||||
if base.lower().endswith(".gcode.3mf"):
|
||||
text += ".gcode.3mf"
|
||||
elif base.lower().endswith(".gcode"):
|
||||
text += ".gcode"
|
||||
elif base.lower().endswith(".acm"):
|
||||
text += ".acm"
|
||||
if parts:
|
||||
parts[-1] = text
|
||||
return "/".join(parts)
|
||||
return text
|
||||
|
||||
|
||||
def multipart(filename, content):
|
||||
boundary = "----GrottiBoundary7f3a"
|
||||
ascii_name = "".join(ch if ch.isascii() and ch not in '"\\' else "_" for ch in filename) or "job.gcode"
|
||||
quoted = urllib.parse.quote(filename)
|
||||
name = safe_upload_name(filename)
|
||||
fields = (
|
||||
"--%s\r\nContent-Disposition: form-data; name=\"root\"\r\n\r\ngcodes\r\n" % boundary
|
||||
).encode("ascii")
|
||||
head = (
|
||||
"--%s\r\n"
|
||||
"Content-Disposition: form-data; name=\"file\"; filename=\"%s\"; filename*=UTF-8''%s\r\n"
|
||||
"Content-Disposition: form-data; name=\"file\"; filename=\"%s\"\r\n"
|
||||
"Content-Type: application/octet-stream\r\n\r\n"
|
||||
) % (boundary, ascii_name, quoted)
|
||||
) % (boundary, name)
|
||||
tail = ("\r\n--%s--\r\n" % boundary).encode("ascii")
|
||||
return boundary, fields + head.encode("ascii") + content + tail
|
||||
|
||||
@@ -217,6 +252,7 @@ def upload_timeout(size):
|
||||
|
||||
|
||||
def upload(host, filename, content, on_progress=None):
|
||||
filename = safe_upload_name(filename)
|
||||
boundary, body = multipart(filename, content)
|
||||
stream = _UploadBody(body, on_progress=on_progress)
|
||||
request = urllib.request.Request(
|
||||
@@ -236,7 +272,8 @@ def upload(host, filename, content, on_progress=None):
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
raise PrinterError("Hochladen ist fehlgeschlagen.") from exc
|
||||
item = (payload.get("result") or {}).get("item") or {}
|
||||
return item.get("path") or filename
|
||||
# Immer denselben ASCII-Namen nutzen, den wir hochgeladen haben
|
||||
return safe_upload_name(item.get("path") or filename)
|
||||
|
||||
|
||||
def start_print(host, filename, bed_leveling=False, ams_settings=None):
|
||||
@@ -381,6 +418,64 @@ def steuern(host, action, params=None):
|
||||
if not re.fullmatch(r"[A-Za-z0-9._+\-]+", name):
|
||||
raise PrinterError("Ungültiger Objektname.")
|
||||
return gcode_script(host, "EXCLUDE_OBJECT NAME=%s" % name, timeout=20)
|
||||
if action == "dryer_start":
|
||||
try:
|
||||
unit = int(params.get("id"))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise PrinterError("ACE-Box muss 0 oder 1 sein.") from exc
|
||||
if unit not in (0, 1):
|
||||
raise PrinterError("ACE-Box muss 0 oder 1 sein.")
|
||||
try:
|
||||
temp = int(round(float(params.get("temp"))))
|
||||
duration = int(round(float(params.get("duration"))))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise PrinterError("Temperatur und Dauer müssen Zahlen sein.") from exc
|
||||
if temp < 35 or temp > 65:
|
||||
raise PrinterError("Trockner-Temperatur außerhalb 35–65 °C.")
|
||||
if duration < 1 or duration > 720:
|
||||
raise PrinterError("Trockner-Dauer außerhalb 1–720 Minuten.")
|
||||
return post_json(
|
||||
host,
|
||||
"/server/filament_hub/start_drying",
|
||||
{"id": unit, "temp": temp, "duration": duration, "fan_speed": 0},
|
||||
timeout=15,
|
||||
)
|
||||
if action == "dryer_stop":
|
||||
try:
|
||||
unit = int(params.get("id"))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise PrinterError("ACE-Box muss 0 oder 1 sein.") from exc
|
||||
if unit not in (0, 1):
|
||||
raise PrinterError("ACE-Box muss 0 oder 1 sein.")
|
||||
return post_json(host, "/server/filament_hub/stop_drying", {"id": unit}, timeout=15)
|
||||
if action == "slot_setzen":
|
||||
hub_id = _int_or_none(params.get("id"))
|
||||
index = _int_or_none(params.get("index"))
|
||||
if hub_id not in (0, 1) or index is None or index < 0 or index > 3:
|
||||
raise PrinterError("ACE-Platz ungültig.")
|
||||
material = str(params.get("type") or "").strip()
|
||||
if not material:
|
||||
raise PrinterError("Die Spule braucht eine Sorte.")
|
||||
color = params.get("color")
|
||||
if not isinstance(color, dict):
|
||||
raise PrinterError("Die Spule braucht eine Farbe.")
|
||||
try:
|
||||
red = max(0, min(255, int(color.get("R"))))
|
||||
green = max(0, min(255, int(color.get("G"))))
|
||||
blue = max(0, min(255, int(color.get("B"))))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise PrinterError("Die Spule braucht eine Farbe.") from exc
|
||||
return post_json(
|
||||
host,
|
||||
"/printer/filament_hub/set_filament_info",
|
||||
{
|
||||
"id": hub_id,
|
||||
"index": index,
|
||||
"type": material,
|
||||
"color": {"R": red, "G": green, "B": blue},
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
if action == "firmware_restart":
|
||||
# GoKlipper/Rinkhals: FIRMWARE_RESTART deadlocked gklib (Rinkhals #32).
|
||||
raise PrinterError(
|
||||
@@ -563,6 +658,80 @@ def extract_slots(status):
|
||||
return found[:8]
|
||||
|
||||
|
||||
def _int_or_none(value):
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
return int(value)
|
||||
|
||||
|
||||
def extract_dryers(status):
|
||||
"""Echte ACE-Boxen aus mmu_machine (num_gates > 0), Dummy-Unit bleibt weg."""
|
||||
machine = (status or {}).get("mmu_machine")
|
||||
if not isinstance(machine, dict):
|
||||
return []
|
||||
found = []
|
||||
for unit_id in (0, 1):
|
||||
unit = machine.get("unit_%d" % unit_id)
|
||||
if not isinstance(unit, dict):
|
||||
continue
|
||||
gates = _int_or_none(unit.get("num_gates"))
|
||||
if gates is None or gates < 1:
|
||||
continue
|
||||
found.append({
|
||||
"id": unit_id,
|
||||
"status": str(unit.get("dryer_status") or "stop"),
|
||||
"temp": _int_or_none(unit.get("dryer_temp")) or 0,
|
||||
"target": _int_or_none(unit.get("dryer_target_temp")) or 0,
|
||||
"remain": _int_or_none(unit.get("dryer_remaining")) or 0,
|
||||
"humidity": _int_or_none(unit.get("dryer_humidity")) or 0,
|
||||
"gates": gates,
|
||||
})
|
||||
return found
|
||||
|
||||
|
||||
def ace_hub_index(ace_slot):
|
||||
"""Grotti-Platz 1–8 → Moonraker id (0/1) und index in der Box (0–3)."""
|
||||
try:
|
||||
slot = int(ace_slot)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise PrinterError("ACE-Platz muss 1–8 sein.") from exc
|
||||
if slot < 1 or slot > 8:
|
||||
raise PrinterError("ACE-Platz muss 1–8 sein.")
|
||||
return (slot - 1) // 4, (slot - 1) % 4
|
||||
|
||||
|
||||
def spool_rgb(spool):
|
||||
"""Erste Farbe der Spule als RGB-Dict für set_filament_info."""
|
||||
colors = (spool or {}).get("colors") or []
|
||||
if isinstance(colors, str):
|
||||
try:
|
||||
colors = json.loads(colors)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
colors = []
|
||||
hex_value = ""
|
||||
if isinstance(colors, list):
|
||||
for item in colors:
|
||||
if isinstance(item, dict) and item.get("hex"):
|
||||
hex_value = str(item["hex"]).strip()
|
||||
break
|
||||
if isinstance(item, str) and item.strip():
|
||||
hex_value = item.strip()
|
||||
break
|
||||
if not hex_value:
|
||||
hex_value = str((spool or {}).get("color") or "").strip()
|
||||
if hex_value.startswith("#"):
|
||||
hex_value = hex_value[1:]
|
||||
if len(hex_value) == 8:
|
||||
hex_value = hex_value[:6]
|
||||
if len(hex_value) != 6 or any(ch not in "0123456789abcdefABCDEF" for ch in hex_value):
|
||||
return None
|
||||
return {
|
||||
"R": int(hex_value[0:2], 16),
|
||||
"G": int(hex_value[2:4], 16),
|
||||
"B": int(hex_value[4:6], 16),
|
||||
}
|
||||
|
||||
|
||||
def slots(host, status=None):
|
||||
if status is not None:
|
||||
found = extract_slots(status)
|
||||
@@ -831,6 +1000,8 @@ def query(host):
|
||||
extras = []
|
||||
if "probe" in names:
|
||||
extras.append("probe")
|
||||
if "mmu_machine" in names:
|
||||
extras.append("mmu_machine")
|
||||
url = "http://%s/printer/objects/query?%s" % (host, "&".join([base] + extras))
|
||||
payload = fetch_json(url)
|
||||
status = payload["result"]["status"]
|
||||
@@ -838,6 +1009,21 @@ def query(host):
|
||||
card = status.get("virtual_sdcard") or {}
|
||||
extruder = status.get("extruder") or {}
|
||||
bed = status.get("heater_bed") or {}
|
||||
mmu = status.get("mmu") or {}
|
||||
mmu_tool = mmu.get("tool")
|
||||
if not isinstance(mmu_tool, (int, float)) or isinstance(mmu_tool, bool):
|
||||
mmu_tool = mmu.get("gate")
|
||||
if isinstance(mmu_tool, (int, float)) and not isinstance(mmu_tool, bool):
|
||||
mmu_tool = int(mmu_tool)
|
||||
if mmu_tool < 0:
|
||||
mmu_tool = None
|
||||
else:
|
||||
mmu_tool = None
|
||||
filament_used = stats.get("filament_used")
|
||||
if not isinstance(filament_used, (int, float)) or isinstance(filament_used, bool):
|
||||
filament_used = None
|
||||
else:
|
||||
filament_used = float(filament_used)
|
||||
raw_name = stats.get("filename") or ""
|
||||
filename = raw_name.replace("\\", "/").rsplit("/", 1)[-1] if raw_name else ""
|
||||
meta = metadata(host, raw_name) if raw_name else {}
|
||||
@@ -896,8 +1082,15 @@ def query(host):
|
||||
"bed_target": bed.get("target"),
|
||||
"chamber": chamber,
|
||||
"chamber_target": chamber_target,
|
||||
"filament_used": filament_used,
|
||||
"filament_total": meta.get("filament_total") or 0,
|
||||
"mmu_tool": mmu_tool,
|
||||
"material": card.get("filament_type") or meta.get("filament_type") or "",
|
||||
"weight": meta.get("filament_weight_total") or 0,
|
||||
"weight": (
|
||||
sum(float(x or 0) for x in meta["filament_weight_total"])
|
||||
if isinstance(meta.get("filament_weight_total"), list)
|
||||
else (meta.get("filament_weight_total") or 0)
|
||||
),
|
||||
"height": meta.get("object_height") or 0,
|
||||
"layer_height": meta.get("layer_height") or card.get("layer_height") or 0,
|
||||
"estimated": meta.get("estimated_time") or 0,
|
||||
@@ -915,6 +1108,7 @@ def query(host):
|
||||
"speed_factor": speed_factor,
|
||||
"extrude_factor": extrude_factor,
|
||||
"slots": slots(host, status),
|
||||
"dryers": extract_dryers(status),
|
||||
"exclude": exclude_status(host),
|
||||
"anycubic_task": _anycubic_task_field(host),
|
||||
}
|
||||
@@ -956,6 +1150,7 @@ def snapshot(printers):
|
||||
"speed_factor": None,
|
||||
"extrude_factor": None,
|
||||
"slots": [],
|
||||
"dryers": [],
|
||||
"anycubic_task": None,
|
||||
})
|
||||
card.update(mesh_fields(printer["host"]))
|
||||
|
||||
+2
-2
@@ -43,11 +43,11 @@ def alert_enabled(cfg, kind):
|
||||
return bool(alerts.get(key, key not in DEFAULT_OFF))
|
||||
|
||||
|
||||
def record(conn, cfg, host, printer, kind, message, filename="", dedupe_s=60):
|
||||
def record(conn, cfg, host, printer, kind, message, filename="", dedupe_s=60, run_id=None):
|
||||
"""Ereignis immer speichern; Telegram nur einmal pro Art und Drucker im Zeitfenster."""
|
||||
from grotti import store
|
||||
|
||||
recent = store.recent_event_kind(conn, host, kind, within_s=dedupe_s)
|
||||
store.add_event(conn, host, printer, kind, message, filename or "")
|
||||
store.add_event(conn, host, printer, kind, message, filename or "", run_id=run_id)
|
||||
if not recent and alert_enabled(cfg, kind):
|
||||
send_telegram(cfg, message)
|
||||
@@ -215,3 +215,86 @@ CREATE TABLE IF NOT EXISTS oauth_identities (
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (provider, subject)
|
||||
);
|
||||
|
||||
-- Druckbericht: Soll/Ist, Charge, Live-Fortschritt
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_planned_g double precision NOT NULL DEFAULT 0;
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_used_g double precision NOT NULL DEFAULT 0;
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_source text NOT NULL DEFAULT '';
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS filament_used_mm double precision NOT NULL DEFAULT 0;
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS filament_total_mm double precision NOT NULL DEFAULT 0;
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS progress_last double precision NOT NULL DEFAULT 0;
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS estimated_s double precision NOT NULL DEFAULT 0;
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS chargen_nr text;
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS message text NOT NULL DEFAULT '';
|
||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS objects jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS runs_chargen_nr
|
||||
ON runs (chargen_nr) WHERE chargen_nr IS NOT NULL AND chargen_nr <> '';
|
||||
|
||||
ALTER TABLE events ADD COLUMN IF NOT EXISTS run_id bigint REFERENCES runs (id) ON DELETE SET NULL;
|
||||
CREATE INDEX IF NOT EXISTS events_run ON events (run_id) WHERE run_id IS NOT NULL;
|
||||
|
||||
-- Abzug je Spule (Messwert oder Schätzwert)
|
||||
CREATE TABLE IF NOT EXISTS run_spools (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
|
||||
spool_id bigint NOT NULL REFERENCES spools (id) ON DELETE RESTRICT,
|
||||
ace_slot smallint,
|
||||
weight_g double precision NOT NULL DEFAULT 0,
|
||||
weight_planned_g double precision NOT NULL DEFAULT 0,
|
||||
cost double precision NOT NULL DEFAULT 0,
|
||||
is_estimate boolean NOT NULL DEFAULT false,
|
||||
UNIQUE (run_id, spool_id, ace_slot)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS run_spools_spool ON run_spools (spool_id);
|
||||
CREATE INDEX IF NOT EXISTS run_spools_run ON run_spools (run_id);
|
||||
|
||||
-- Abschnitte bei Rollenwechsel mitten im Druck
|
||||
CREATE TABLE IF NOT EXISTS run_segments (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
|
||||
spool_id bigint NOT NULL REFERENCES spools (id) ON DELETE RESTRICT,
|
||||
ace_slot smallint,
|
||||
mm_start double precision NOT NULL DEFAULT 0,
|
||||
mm_end double precision,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
closed_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS run_segments_run ON run_segments (run_id);
|
||||
CREATE INDEX IF NOT EXISTS run_segments_open
|
||||
ON run_segments (run_id) WHERE mm_end IS NULL;
|
||||
|
||||
-- Temperatur / Fortschritt während des Drucks
|
||||
CREATE TABLE IF NOT EXISTS run_samples (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
|
||||
sampled_at timestamptz NOT NULL DEFAULT now(),
|
||||
nozzle double precision,
|
||||
nozzle_target double precision,
|
||||
bed double precision,
|
||||
bed_target double precision,
|
||||
chamber double precision,
|
||||
fan double precision,
|
||||
speed_factor double precision,
|
||||
extrude_factor double precision,
|
||||
layer integer,
|
||||
progress double precision,
|
||||
filament_used_mm double precision,
|
||||
mmu_tool smallint
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS run_samples_run_time ON run_samples (run_id, sampled_at);
|
||||
|
||||
-- Objekt-Chargen für Etiketten
|
||||
CREATE TABLE IF NOT EXISTS run_labels (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
|
||||
object_name text NOT NULL DEFAULT '',
|
||||
chargen_nr text NOT NULL,
|
||||
excluded boolean NOT NULL DEFAULT false,
|
||||
UNIQUE (run_id, object_name)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS run_labels_chargen ON run_labels (chargen_nr);
|
||||
+1077
-14
File diff suppressed because it is too large.
Load diff
+69
-28
@@ -8,8 +8,8 @@ MOVE = re.compile(r"^(?:G0|G00|G1|G01)\b(.*)$", re.IGNORECASE)
|
||||
# Auch E.39 / Z.6 ohne führende Null (Anycubic/Prusa-Stil)
|
||||
AXIS = re.compile(r"([XYZE])\s*(-?\d*\.?\d+)", re.IGNORECASE)
|
||||
|
||||
# Cache-/Viewer-Version: Segment-IDs, kein Sehnen-Thinning
|
||||
TOOLPATH_VERSION = 5
|
||||
# Cache-/Viewer-Version: ganze Z-Höhen dicht behalten (keine gesprenkelten Stichproben)
|
||||
TOOLPATH_VERSION = 8
|
||||
|
||||
|
||||
def _gcode_text(path: Path) -> str:
|
||||
@@ -28,48 +28,89 @@ def _gcode_text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
def _z_key(z):
|
||||
return round(float(z), 2)
|
||||
|
||||
|
||||
def _thin_points(points, layer_breaks, max_points):
|
||||
"""Extrusions-Segmente stichprobenartig behalten — nie Endpunkte einer Bahn
|
||||
weglassen und die Nachbarn verbinden (sonst Sehnen statt Infill)."""
|
||||
"""Pfad kürzen, ohne Wände zu sprenkeln.
|
||||
|
||||
Strategie: ganze Z-Höhen (Druckschichten) vollständig behalten und nur
|
||||
ganze Höhen weglassen. Stichproben entlang der Bahn erzeugen Lücken.
|
||||
"""
|
||||
if len(points) <= max_points:
|
||||
return points, layer_breaks
|
||||
|
||||
extrude_ends = [i for i in range(1, len(points)) if points[i][3]]
|
||||
keep = {0, len(points) - 1}
|
||||
for break_i in layer_breaks:
|
||||
keep.add(max(0, min(len(points) - 1, break_i)))
|
||||
if break_i > 0:
|
||||
keep.add(break_i - 1)
|
||||
# Nur Z-Höhen mit Extrusion — Z-Hops allein zählen nicht
|
||||
z_order = []
|
||||
seen = set()
|
||||
for point in points:
|
||||
if not point[3]:
|
||||
continue
|
||||
key = _z_key(point[2])
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
z_order.append(key)
|
||||
if not z_order:
|
||||
return points[:max_points], [0]
|
||||
|
||||
# Budget für Segment-Endpunkte (jedes Segment braucht 2 Indizes, viele teilen sich)
|
||||
target_segs = max(2000, max_points // 2)
|
||||
step = max(1, (len(extrude_ends) + target_segs - 1) // target_segs)
|
||||
for j, end_i in enumerate(extrude_ends):
|
||||
if j % step == 0:
|
||||
def keep_for_zs(keep_zs):
|
||||
keep = {0, len(points) - 1}
|
||||
for i, point in enumerate(points):
|
||||
if _z_key(point[2]) in keep_zs:
|
||||
keep.add(i)
|
||||
if i > 0:
|
||||
keep.add(i - 1)
|
||||
return sorted(keep)
|
||||
|
||||
# So viele Höhen wie ins Budget passen (Schätzung über mittlere Punkte/Höhe)
|
||||
per_z = max(1, len(points) // max(1, len(z_order)))
|
||||
max_zs = max(2, min(len(z_order), max_points // per_z))
|
||||
stride = max(1, (len(z_order) + max_zs - 1) // max_zs)
|
||||
|
||||
def pick_zs(step):
|
||||
chosen = {z_order[0], z_order[-1]}
|
||||
for i in range(0, len(z_order), step):
|
||||
chosen.add(z_order[i])
|
||||
return chosen
|
||||
|
||||
step = stride
|
||||
ordered = keep_for_zs(pick_zs(step))
|
||||
while len(ordered) > max_points and step < len(z_order):
|
||||
step += 1
|
||||
ordered = keep_for_zs(pick_zs(step))
|
||||
|
||||
# Immer noch zu groß: innerhalb behaltener Höhen jedes n-te Extrusionssegment
|
||||
if len(ordered) > max_points:
|
||||
keep_zs = pick_zs(step)
|
||||
segs = [
|
||||
i
|
||||
for i in range(1, len(points))
|
||||
if points[i][3] and _z_key(points[i][2]) in keep_zs
|
||||
]
|
||||
budget = max(1, (max_points - 4) // 2)
|
||||
seg_step = max(1, (len(segs) + budget - 1) // budget)
|
||||
keep = {0, len(points) - 1}
|
||||
for end_i in segs[::seg_step]:
|
||||
keep.add(end_i - 1)
|
||||
keep.add(end_i)
|
||||
|
||||
ordered = sorted(keep)
|
||||
# Falls immer noch zu groß: Segmente grober
|
||||
if len(ordered) > max_points:
|
||||
step2 = max(1, (len(ordered) + max_points - 1) // max_points)
|
||||
ordered = ordered[::step2]
|
||||
if ordered[-1] != len(points) - 1:
|
||||
ordered.append(len(points) - 1)
|
||||
|
||||
index_map = {old: new for new, old in enumerate(ordered)}
|
||||
kept = [points[i] for i in ordered]
|
||||
new_breaks = sorted({
|
||||
index_map[b]
|
||||
for b in layer_breaks
|
||||
if b in index_map
|
||||
})
|
||||
new_breaks = []
|
||||
last_z = None
|
||||
for new_i, point in enumerate(kept):
|
||||
z = _z_key(point[2])
|
||||
if last_z is None or abs(z - last_z) > 0.01:
|
||||
new_breaks.append(new_i)
|
||||
last_z = z
|
||||
if not new_breaks:
|
||||
new_breaks = [0]
|
||||
return kept, new_breaks
|
||||
|
||||
|
||||
def build_toolpath(path: Path, max_points=120000):
|
||||
def build_toolpath(path: Path, max_points=220000):
|
||||
text = _gcode_text(path)
|
||||
if not text:
|
||||
return {
|
||||
|
||||
+164
@@ -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
@@ -1,3 +1,3 @@
|
||||
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
|
||||
|
||||
__version__ = "2.2.1"
|
||||
__version__ = "2.5.1"
|
||||
+152
-20
@@ -4,10 +4,13 @@ from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from grotti import moonraker, notify, store
|
||||
from grotti import moonraker, notify, store, usage
|
||||
from grotti.db import connect
|
||||
|
||||
SAMPLE_EVERY_S = 30
|
||||
|
||||
|
||||
class StateWatcher:
|
||||
def __init__(self, database_url, cfg, interval=10):
|
||||
@@ -21,6 +24,7 @@ class StateWatcher:
|
||||
self._updated_at = 0.0
|
||||
self._stop = threading.Event()
|
||||
self._thread = None
|
||||
self._last_sample = {}
|
||||
|
||||
def start(self):
|
||||
if self._thread and self._thread.is_alive():
|
||||
@@ -79,6 +83,7 @@ class StateWatcher:
|
||||
prev = self._prev.get(host) or {}
|
||||
prev_state = (prev.get("state") or "").lower()
|
||||
prev_offline = bool(prev.get("offline"))
|
||||
run_id = None
|
||||
|
||||
if offline:
|
||||
hits = self._offline_hits.get(host, 0) + 1
|
||||
@@ -89,16 +94,32 @@ class StateWatcher:
|
||||
return
|
||||
|
||||
self._offline_hits[host] = 0
|
||||
starting = (
|
||||
bool(prev_state)
|
||||
and prev_state not in ("printing", "paused")
|
||||
and state == "printing"
|
||||
)
|
||||
if starting:
|
||||
store.abandon_live_runs(conn, host)
|
||||
if state in ("printing", "paused"):
|
||||
run = self._track_print(conn, host, name, filename, live, state)
|
||||
run_id = run["id"] if run else None
|
||||
|
||||
if prev_offline:
|
||||
self._emit(conn, host, name, "offline", "%s ist wieder erreichbar." % name, filename)
|
||||
elif prev_state and prev_state not in ("printing", "paused") and state == "printing":
|
||||
self._emit(conn, host, name, "start", "%s druckt %s" % (name, filename or "einen Auftrag"), filename)
|
||||
self._emit(conn, host, name, "offline", "%s ist wieder erreichbar." % name, filename, run_id)
|
||||
elif starting:
|
||||
self._emit(
|
||||
conn, host, name, "start",
|
||||
"%s druckt %s" % (name, filename or "einen Auftrag"),
|
||||
filename, run_id,
|
||||
)
|
||||
self._ensure_start_segment(conn, host, filename, live, run_id)
|
||||
elif prev_state == "printing" and state == "paused":
|
||||
self._emit(conn, host, name, "start", "%s pausiert." % name, filename)
|
||||
self._emit(conn, host, name, "start", "%s pausiert." % name, filename, run_id)
|
||||
elif prev_state == "paused" and state == "printing":
|
||||
self._emit(conn, host, name, "start", "%s druckt weiter." % name, filename)
|
||||
self._emit(conn, host, name, "start", "%s druckt weiter." % name, filename, run_id)
|
||||
elif prev_state == "printing" and state == "complete":
|
||||
store.set_bed_needs_clear(conn, host, True, filename or "fertig")
|
||||
store.ensure_bed_after_print(conn, host, message=filename or "fertig")
|
||||
self._emit(
|
||||
conn,
|
||||
host,
|
||||
@@ -106,8 +127,8 @@ class StateWatcher:
|
||||
"fertig",
|
||||
"%s fertig: %s" % (name, filename or "Druck"),
|
||||
filename,
|
||||
run_id or (prev.get("run_id")),
|
||||
)
|
||||
# Protokoll und Spulenabzug nicht erst nach manuellem Knopf
|
||||
try:
|
||||
from grotti import jobs
|
||||
|
||||
@@ -115,7 +136,7 @@ class StateWatcher:
|
||||
except Exception as exc:
|
||||
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
||||
elif prev_state in ("printing", "paused") and state in ("cancelled", "standby"):
|
||||
store.set_bed_needs_clear(conn, host, True, filename or state)
|
||||
store.ensure_bed_after_print(conn, host, message=filename or state)
|
||||
self._emit(
|
||||
conn,
|
||||
host,
|
||||
@@ -123,10 +144,17 @@ class StateWatcher:
|
||||
"abbruch",
|
||||
"%s: Druck abgebrochen (%s)" % (name, filename or "ohne Datei"),
|
||||
filename,
|
||||
run_id or prev.get("run_id"),
|
||||
)
|
||||
try:
|
||||
from grotti import jobs
|
||||
|
||||
jobs.sync_history(conn, self.cfg)
|
||||
except Exception as exc:
|
||||
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
||||
elif state == "error" and prev_state != "error":
|
||||
if prev_state == "printing" or prev_state == "paused":
|
||||
store.set_bed_needs_clear(conn, host, True, filename or "Fehler")
|
||||
store.ensure_bed_after_print(conn, host, message=filename or "Fehler")
|
||||
msg = (live.get("message") or "").strip()
|
||||
if not msg or msg.lower() in ("error", "fehler"):
|
||||
msg = "Ursache unbekannt — in Mainsail prüfen"
|
||||
@@ -138,18 +166,19 @@ class StateWatcher:
|
||||
"fehler",
|
||||
"%s: %s · Heizung aus" % (name, msg),
|
||||
filename,
|
||||
run_id or prev.get("run_id"),
|
||||
)
|
||||
try:
|
||||
from grotti import jobs
|
||||
|
||||
jobs.sync_history(conn, self.cfg)
|
||||
except Exception as exc:
|
||||
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
||||
elif state == "error":
|
||||
# Schon im Fehler, Heizer laufen noch → aus
|
||||
if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0:
|
||||
self._cool_heaters(host)
|
||||
elif state == "complete":
|
||||
known = conn.execute(
|
||||
"SELECT 1 FROM bed_status WHERE host = %s", (host,)
|
||||
).fetchone()
|
||||
if not known:
|
||||
# Erster Stand nach Deploy: fertiger Druck ohne Quittierung
|
||||
store.set_bed_needs_clear(conn, host, True, filename or "fertig")
|
||||
elif state == "complete" and (not prev_state or prev_offline):
|
||||
store.ensure_bed_after_print(conn, host, message=filename or "fertig")
|
||||
|
||||
spool_warn = bool(prev.get("spool_warn"))
|
||||
if state == "printing" and not spool_warn:
|
||||
@@ -160,8 +189,108 @@ class StateWatcher:
|
||||
"offline": False,
|
||||
"filename": filename,
|
||||
"spool_warn": spool_warn if state == "printing" else False,
|
||||
"run_id": run_id or (prev.get("run_id") if state in ("printing", "paused") else None),
|
||||
}
|
||||
|
||||
def _track_print(self, conn, host, name, filename, live, state):
|
||||
sent = store.latest_send(conn, host, filename) if filename else None
|
||||
spool_id = (sent or {}).get("spool_id")
|
||||
run = store.ensure_live_run(conn, host, name, filename, spool_id=spool_id)
|
||||
objects = self._merge_objects(run.get("objects"), live.get("exclude"))
|
||||
planned = usage.as_float(live.get("weight"))
|
||||
total_mm = usage.as_float(live.get("filament_total"))
|
||||
used_mm = usage.as_float(live.get("filament_used"))
|
||||
progress = usage.as_float(live.get("progress"))
|
||||
estimated = usage.as_float(live.get("estimated"))
|
||||
store.update_run_progress(conn, run["id"], {
|
||||
"state": "paused" if state == "paused" else "in_progress",
|
||||
"progress_last": progress,
|
||||
"filament_used_mm": used_mm,
|
||||
"filament_total_mm": total_mm,
|
||||
"weight_planned_g": planned,
|
||||
"estimated_s": estimated,
|
||||
"message": (live.get("message") or "") if state == "error" else "",
|
||||
"objects": objects,
|
||||
"duration_s": usage.as_float(live.get("elapsed")),
|
||||
})
|
||||
store.ensure_run_chargen(conn, run["id"])
|
||||
self._maybe_sample(conn, run["id"], live, used_mm)
|
||||
return store.get_run_row(conn, run["id"])
|
||||
|
||||
def _merge_objects(self, current, exclude):
|
||||
known = {}
|
||||
if isinstance(current, str):
|
||||
try:
|
||||
import json
|
||||
current = json.loads(current)
|
||||
except Exception:
|
||||
current = []
|
||||
for item in current or []:
|
||||
if isinstance(item, dict) and item.get("name"):
|
||||
known[item["name"]] = {
|
||||
"name": item["name"],
|
||||
"excluded": bool(item.get("excluded")),
|
||||
}
|
||||
exclude = exclude or {}
|
||||
excluded = set(exclude.get("excluded") or [])
|
||||
for item in exclude.get("objects") or []:
|
||||
name = (item.get("name") if isinstance(item, dict) else str(item)).strip()
|
||||
if not name:
|
||||
continue
|
||||
known[name] = {"name": name, "excluded": name in excluded}
|
||||
for name in excluded:
|
||||
if name not in known:
|
||||
known[name] = {"name": name, "excluded": True}
|
||||
return list(known.values())
|
||||
|
||||
def _maybe_sample(self, conn, run_id, live, used_mm):
|
||||
now = time.monotonic()
|
||||
last = self._last_sample.get(run_id, 0)
|
||||
if now - last < SAMPLE_EVERY_S:
|
||||
return
|
||||
self._last_sample[run_id] = now
|
||||
store.add_run_sample(conn, run_id, {
|
||||
"sampled_at": datetime.now(timezone.utc),
|
||||
"nozzle": live.get("nozzle"),
|
||||
"nozzle_target": live.get("nozzle_target"),
|
||||
"bed": live.get("bed"),
|
||||
"bed_target": live.get("bed_target"),
|
||||
"chamber": live.get("chamber"),
|
||||
"fan": live.get("fan"),
|
||||
"speed_factor": live.get("speed_factor"),
|
||||
"extrude_factor": live.get("extrude_factor"),
|
||||
"layer": live.get("layer") or 0,
|
||||
"progress": live.get("progress"),
|
||||
"filament_used_mm": used_mm,
|
||||
"mmu_tool": live.get("mmu_tool"),
|
||||
})
|
||||
|
||||
def _ensure_start_segment(self, conn, host, filename, live, run_id):
|
||||
"""Nur externe Einzelspule: ein Abschnitt. ACE-Farben ohne Wechsel → Anteil-Split."""
|
||||
if not run_id:
|
||||
return
|
||||
existing = store.list_run_segments(conn, run_id)
|
||||
if existing:
|
||||
return
|
||||
sent = store.latest_send(conn, host, filename) if filename else None
|
||||
ace = (sent or {}).get("ace_map") or []
|
||||
from grotti import acm
|
||||
|
||||
mapping = acm.normalize_mapping(ace) if ace else []
|
||||
if mapping:
|
||||
return
|
||||
used_mm = usage.as_float(live.get("filament_used"))
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id FROM spools
|
||||
WHERE printer_host = %s AND ace_slot IS NULL
|
||||
ORDER BY id LIMIT 1
|
||||
""",
|
||||
(host,),
|
||||
).fetchone()
|
||||
if rows:
|
||||
store.open_segment(conn, run_id, rows["id"], None, used_mm)
|
||||
|
||||
def _cool_heaters(self, host):
|
||||
try:
|
||||
moonraker.heaters_off(host)
|
||||
@@ -193,5 +322,8 @@ class StateWatcher:
|
||||
)
|
||||
return True
|
||||
|
||||
def _emit(self, conn, host, printer, kind, message, filename):
|
||||
notify.record(conn, self.cfg, host, printer, kind, message, filename, dedupe_s=60)
|
||||
def _emit(self, conn, host, printer, kind, message, filename, run_id=None):
|
||||
notify.record(
|
||||
conn, self.cfg, host, printer, kind, message, filename,
|
||||
dedupe_s=60, run_id=run_id,
|
||||
)
|
||||
@@ -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},
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"""ASCII-Dateinamen für Moonraker-Upload."""
|
||||
|
||||
from grotti.moonraker import multipart, safe_upload_name
|
||||
|
||||
|
||||
def test_safe_upload_strips_cjk():
|
||||
name = "1015-2159-虎钳式强力夹子_plate(01)_PLA_0.2_53m57s.gcode"
|
||||
safe = safe_upload_name(name)
|
||||
assert safe.isascii()
|
||||
assert safe.endswith(".gcode")
|
||||
assert "plate(01)" in safe
|
||||
assert "虎" not in safe
|
||||
|
||||
|
||||
def test_multipart_single_ascii_filename():
|
||||
name = "模型_test.gcode"
|
||||
_boundary, body = multipart(name, b"G28\n")
|
||||
head = body.split(b"G28\n", 1)[0].decode("ascii")
|
||||
assert "filename*=UTF-8" not in head
|
||||
assert 'filename="test.gcode"' in head
|
||||
assert all(ord(ch) < 128 for ch in head)
|
||||
|
||||
|
||||
def test_safe_upload_preserves_path():
|
||||
assert safe_upload_name("subdir/foo.gcode") == "subdir/foo.gcode"
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Toolpath-Thinning: ganze Schichten dicht, keine gesprenkelten Stichproben."""
|
||||
|
||||
from grotti.toolpath import TOOLPATH_VERSION, _thin_points, build_toolpath
|
||||
|
||||
|
||||
def _fake_points(layers=40, per_layer=800):
|
||||
points = []
|
||||
breaks = []
|
||||
seq = 0
|
||||
for layer in range(layers):
|
||||
z = 0.2 + layer * 0.2
|
||||
breaks.append(len(points))
|
||||
for i in range(per_layer):
|
||||
extrude = 1 if i > 0 else 0
|
||||
points.append([float(i), float(layer), z, extrude, seq])
|
||||
seq += 1
|
||||
return points, breaks
|
||||
|
||||
|
||||
def test_thin_keeps_consecutive_extrude_pairs():
|
||||
points, breaks = _fake_points()
|
||||
kept, new_breaks = _thin_points(points, breaks, max_points=8_000)
|
||||
assert len(kept) <= 8_000
|
||||
assert new_breaks
|
||||
consec = 0
|
||||
for i in range(1, len(kept)):
|
||||
if kept[i][3] and kept[i][4] == kept[i - 1][4] + 1:
|
||||
consec += 1
|
||||
assert consec > 500, "Thinning darf keine zeichnbaren Segmente übrig lassen"
|
||||
|
||||
|
||||
def test_thin_keeps_solid_layers():
|
||||
points, breaks = _fake_points(layers=50, per_layer=600)
|
||||
kept, _ = _thin_points(points, breaks, max_points=10_000)
|
||||
# Pro behaltener Z-Höhe: hoher Anteil aufeinanderfolgender Extrusionen
|
||||
by_z = {}
|
||||
for i in range(1, len(kept)):
|
||||
if not (kept[i][3] and kept[i][4] == kept[i - 1][4] + 1):
|
||||
continue
|
||||
z = round(kept[i][2], 2)
|
||||
by_z[z] = by_z.get(z, 0) + 1
|
||||
assert by_z, "keine Extrusionssegmente"
|
||||
assert max(by_z.values()) >= 40, "behaltene Schichten sollen dicht sein"
|
||||
|
||||
|
||||
def test_toolpath_version():
|
||||
assert TOOLPATH_VERSION >= 8
|
||||
|
||||
|
||||
def test_build_empty(tmp_path):
|
||||
path = tmp_path / "empty.gcode"
|
||||
path.write_text("; nix\n", encoding="utf-8")
|
||||
data = build_toolpath(path)
|
||||
assert data["points"] == []
|
||||
assert data["version"] == TOOLPATH_VERSION
|
||||
@@ -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()
|
||||
+206
-11
@@ -37,7 +37,7 @@ IDEA_STATUS = {"offen", "spaeter", "erledigt"}
|
||||
PRINTER_FAMILIES = set(FAMILY_LABELS) | {""}
|
||||
STEER_ACTIONS = {
|
||||
"nozzle", "bed", "speed", "flow", "fan", "emergency_stop", "clear_error",
|
||||
"exclude_object",
|
||||
"exclude_object", "dryer_start", "dryer_stop", "slot_setzen",
|
||||
}
|
||||
|
||||
|
||||
@@ -580,6 +580,58 @@ def create_app(cfg):
|
||||
def protokoll():
|
||||
return page("protokoll.html", "Protokoll")
|
||||
|
||||
@app.get("/protokoll/<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")
|
||||
def ideen():
|
||||
return page("ideen.html", "Ideen")
|
||||
@@ -672,6 +724,9 @@ def create_app(cfg):
|
||||
item["queue_failed"] = queue.get("failed")
|
||||
item["queue_waiting"] = queue.get("waiting", 0)
|
||||
item["queue_waiting_next"] = queue.get("waiting_next", "")
|
||||
live_run = store.find_live_run(conn(), host) if host else None
|
||||
item["live_run_id"] = (live_run or {}).get("id")
|
||||
item["live_chargen"] = (live_run or {}).get("chargen_nr") or ""
|
||||
|
||||
by_id = {row["host"]: row for row in rows}
|
||||
if request.args.get("live", "1") == "0":
|
||||
@@ -873,7 +928,7 @@ def create_app(cfg):
|
||||
row = store.get_file_row(conn(), file_id)
|
||||
if not row:
|
||||
abort(404)
|
||||
png = library.preview_png(row)
|
||||
png = library.preview_png(row, conn=conn(), data_dir=cfg["data_dir"])
|
||||
if not png:
|
||||
abort(404)
|
||||
return png, 200, {"Content-Type": "image/png", "Cache-Control": "private, max-age=300"}
|
||||
@@ -883,8 +938,8 @@ def create_app(cfg):
|
||||
row = store.get_file_row(conn(), file_id)
|
||||
if not row:
|
||||
raise GrottiError("Die Datei fehlt.", "datei", 404)
|
||||
path = Path(row["stored_path"])
|
||||
if not path.is_file():
|
||||
path = library.resolve_stored_path(conn(), row, cfg["data_dir"])
|
||||
if path is None or not path.is_file():
|
||||
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
|
||||
cache_dir = Path(cfg["data_dir"]) / "toolpath"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -910,7 +965,10 @@ def create_app(cfg):
|
||||
pass
|
||||
data["file_id"] = file_id
|
||||
data["name"] = row["name"]
|
||||
return jsonify(data)
|
||||
data["version"] = TOOLPATH_VERSION
|
||||
resp = jsonify(data)
|
||||
resp.headers["Cache-Control"] = "no-store"
|
||||
return resp
|
||||
|
||||
@app.patch("/api/v1/dateien/<int:file_id>")
|
||||
def file_patch(file_id):
|
||||
@@ -1503,11 +1561,28 @@ def create_app(cfg):
|
||||
|
||||
@app.get("/api/v1/spulen/by-code/<path:code>")
|
||||
def spool_by_code(code):
|
||||
row = store.get_spool_by_code(conn(), code)
|
||||
text = (code or "").strip()
|
||||
if text.upper().startswith("D-"):
|
||||
run = store.get_run_by_chargen(conn(), text)
|
||||
if not run:
|
||||
raise GrottiError("Kein Druck mit dieser Charge.", "charge", 404)
|
||||
return jsonify({"kind": "lauf", "run": store.run_out(run), "redirect": "/protokoll/%s" % run["id"]})
|
||||
row = store.get_spool_by_code(conn(), text)
|
||||
if not row:
|
||||
raise GrottiError("Keine Spule mit diesem Code.", "spule", 404)
|
||||
return jsonify(row)
|
||||
|
||||
@app.get("/api/v1/spulen/<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")
|
||||
def spool_create():
|
||||
return jsonify(store.create_spool(conn(), spool_fields(body()))), 201
|
||||
@@ -1528,6 +1603,18 @@ def create_app(cfg):
|
||||
spool = store.get_spool_row(conn(), spool_id)
|
||||
if not spool:
|
||||
raise GrottiError("Die Spule fehlt.", "spule", 404)
|
||||
live_run = store.find_live_run(conn(), host)
|
||||
mm_now = float((live_run or {}).get("filament_used_mm") or 0)
|
||||
printing = bool(
|
||||
live_run and (live_run.get("state") or "") in ("in_progress", "printing", "paused")
|
||||
)
|
||||
if printing:
|
||||
try:
|
||||
snap = moonraker.query(host)
|
||||
if isinstance(snap.get("filament_used"), (int, float)):
|
||||
mm_now = float(snap["filament_used"])
|
||||
except Exception:
|
||||
pass
|
||||
# Alte Rolle am Platz / externe Rolle am Host freimachen
|
||||
if slot:
|
||||
old = conn().execute(
|
||||
@@ -1539,22 +1626,32 @@ def create_app(cfg):
|
||||
"SELECT * FROM spools WHERE printer_host = %s AND ace_slot IS NULL AND id <> %s",
|
||||
(host, spool_id),
|
||||
).fetchall()
|
||||
printer = store.printer_by_host(conn(), host) or {}
|
||||
if printing:
|
||||
closed = store.close_open_segments(conn(), live_run["id"], slot, mm_now)
|
||||
if not closed and old:
|
||||
# Erster Wechsel: alter Abschnitt von 0 bis jetzt
|
||||
for row in old:
|
||||
store.open_segment(conn(), live_run["id"], row["id"], slot, 0)
|
||||
store.close_open_segments(conn(), live_run["id"], slot, mm_now)
|
||||
for row in old:
|
||||
store.update_spool(conn(), row["id"], {"printer_host": "", "ace_slot": None})
|
||||
store.add_event(
|
||||
conn(),
|
||||
host,
|
||||
(store.printer_by_host(conn(), host) or {}).get("name") or host,
|
||||
printer.get("name") or host,
|
||||
"spule",
|
||||
"%s: „%s“ ins Regal%s" % (
|
||||
(store.printer_by_host(conn(), host) or {}).get("name") or host,
|
||||
printer.get("name") or host,
|
||||
row["name"],
|
||||
(" (Platz %s)" % slot) if slot else "",
|
||||
),
|
||||
"",
|
||||
(live_run or {}).get("filename") or "",
|
||||
run_id=(live_run or {}).get("id"),
|
||||
)
|
||||
updated = store.update_spool(conn(), spool_id, {"printer_host": host, "ace_slot": slot})
|
||||
printer = store.printer_by_host(conn(), host) or {}
|
||||
if printing:
|
||||
store.open_segment(conn(), live_run["id"], spool_id, slot, mm_now)
|
||||
store.add_event(
|
||||
conn(),
|
||||
host,
|
||||
@@ -1565,7 +1662,8 @@ def create_app(cfg):
|
||||
updated["name"],
|
||||
(" auf Platz %s" % slot) if slot else "",
|
||||
),
|
||||
"",
|
||||
(live_run or {}).get("filename") or "",
|
||||
run_id=(live_run or {}).get("id"),
|
||||
)
|
||||
return jsonify(updated)
|
||||
|
||||
@@ -1701,6 +1799,36 @@ def create_app(cfg):
|
||||
def run_list():
|
||||
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")
|
||||
def run_sync():
|
||||
library.sync_watch(conn(), cfg, force=False)
|
||||
@@ -1723,4 +1851,71 @@ def create_app(cfg):
|
||||
row = store.run_out(current)
|
||||
return jsonify(row)
|
||||
|
||||
@app.get("/api/v1/statistik")
|
||||
def statistik_api():
|
||||
since = request.args.get("since") or None
|
||||
until = request.args.get("until") or None
|
||||
host = (request.args.get("host") or "").strip()
|
||||
material = (request.args.get("material") or "").strip()
|
||||
brand = (request.args.get("brand") or "").strip()
|
||||
project_id = optional_id(request.args.get("project_id"))
|
||||
return jsonify(store.statistics(
|
||||
conn(),
|
||||
since=since,
|
||||
until=until,
|
||||
host=host,
|
||||
material=material or "",
|
||||
brand=brand or "",
|
||||
project_id=project_id,
|
||||
))
|
||||
|
||||
@app.get("/api/v1/statistik.csv")
|
||||
def statistik_csv():
|
||||
import csv
|
||||
import io
|
||||
from flask import Response
|
||||
|
||||
since = request.args.get("since") or None
|
||||
until = request.args.get("until") or None
|
||||
host = (request.args.get("host") or "").strip()
|
||||
material = (request.args.get("material") or "").strip()
|
||||
brand = (request.args.get("brand") or "").strip()
|
||||
runs = store.list_runs(
|
||||
conn(),
|
||||
limit=2000,
|
||||
since=since,
|
||||
until=until,
|
||||
host=host,
|
||||
material=material,
|
||||
brand=brand,
|
||||
)
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf, delimiter=";")
|
||||
writer.writerow([
|
||||
"id", "drucker", "datei", "zustand", "start", "ende", "dauer_s",
|
||||
"soll_g", "ist_g", "quelle", "filament_eur", "strom_eur", "urteil", "charge",
|
||||
])
|
||||
for run in runs:
|
||||
writer.writerow([
|
||||
run["id"],
|
||||
run.get("printer_name") or "",
|
||||
run.get("filename") or "",
|
||||
run.get("state") or "",
|
||||
run.get("started_at") or "",
|
||||
run.get("ended_at") or "",
|
||||
run.get("duration_s") or 0,
|
||||
run.get("weight_planned_g") or 0,
|
||||
run.get("weight_used_g") or 0,
|
||||
run.get("weight_source") or "",
|
||||
run.get("filament_cost") or 0,
|
||||
run.get("energy_cost") or 0,
|
||||
run.get("verdict") or "",
|
||||
run.get("chargen_nr") or "",
|
||||
])
|
||||
return Response(
|
||||
buf.getvalue(),
|
||||
mimetype="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": "attachment; filename=grotti-laeufe.csv"},
|
||||
)
|
||||
|
||||
return app
|
||||
@@ -10,8 +10,8 @@ window.GrottiGcodeViewer = (function () {
|
||||
const COLOR_OBJ_EX = 0xef4444;
|
||||
const COLOR_OBJ_CUR = 0xfbbf24;
|
||||
const GRID_STEP = 10;
|
||||
// Nur leichte Doppelung — starke Offsets + Punkte wirkten wie Vollkörper mit Löchern
|
||||
const FAT_OFFSETS = [0, -0.22, 0.22];
|
||||
// Keine Parallel-Offsets: bei ausgedünnten Pfaden wirkten sie gesprenkelt
|
||||
const FAT_OFFSETS = [0];
|
||||
|
||||
function create(canvas) {
|
||||
const wrap = document.createElement("div");
|
||||
|
||||
+153
-4
@@ -405,6 +405,36 @@ body.shelf [data-role="hero"] { font-size: 1.35rem; }
|
||||
padding: 0.25rem 0.35rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.steer-ace,
|
||||
.steer-ace-slots {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding-top: 0.35rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.steer-ace-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem 0.65rem;
|
||||
align-items: baseline;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.steer-ace-head b { font-weight: 650; }
|
||||
.steer-slot-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem 0.55rem;
|
||||
align-items: center;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.steer-slot-row .dot {
|
||||
width: 0.85rem;
|
||||
height: 0.85rem;
|
||||
border-radius: 99px;
|
||||
flex: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.steer-slot-row .grow { min-width: 0; overflow-wrap: anywhere; }
|
||||
.queue-name { color: var(--ink); font-size: var(--t-sm); font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
|
||||
.queue-msg { color: var(--muted); font-size: var(--t-xs); line-height: 1.3; overflow-wrap: anywhere; }
|
||||
.queue-sub { color: var(--faint); font-size: 0.66rem; font-variant-numeric: tabular-nums; flex: none; }
|
||||
@@ -467,12 +497,16 @@ body.shelf [data-role="hero"] { font-size: 1.35rem; }
|
||||
.ace-printer-name { margin: 0; font-size: 0.82rem; font-weight: 650; color: var(--ink); }
|
||||
.ace-row {
|
||||
display: grid;
|
||||
grid-template-columns: 0.85rem minmax(0, 1fr) minmax(7rem, 0.9fr);
|
||||
grid-template-columns: 0.85rem minmax(0, 1fr) minmax(12rem, 1.4fr);
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.ace-row select {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.ace-swatch {
|
||||
width: 0.85rem;
|
||||
height: 0.85rem;
|
||||
@@ -514,6 +548,23 @@ h2 { font-size: 0.95rem; margin: 0 0 0.65rem; letter-spacing: -0.02em; font-weig
|
||||
border-radius: var(--radius-sm);
|
||||
min-height: var(--control-h);
|
||||
}
|
||||
.page-tools .date-field {
|
||||
width: auto;
|
||||
min-width: 9.5rem;
|
||||
max-width: 11rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.page-tools .period-presets {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
flex: 1 1 100%;
|
||||
grid-auto-columns: auto;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.page-tools .period-presets button {
|
||||
padding: 0.4rem 0.55rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lead, .tiny, .summary { color: var(--muted); }
|
||||
.summary { margin: 0.3rem 0 0; }
|
||||
.tiny { font-size: 0.8rem; margin: 0.2rem 0 0; line-height: 1.4; max-width: 52ch; }
|
||||
@@ -620,12 +671,44 @@ article.machine.offline:hover { opacity: 1; }
|
||||
.job-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
gap: 0.55rem;
|
||||
min-width: 0;
|
||||
color: var(--muted);
|
||||
font-size: var(--t-sm);
|
||||
padding: 0.45rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.job-thumb {
|
||||
width: 4.5rem;
|
||||
height: 4.5rem;
|
||||
object-fit: contain;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--card-2);
|
||||
flex: none;
|
||||
}
|
||||
.job-thumb[hidden] { display: none; }
|
||||
.job-ico { display: inline-flex; flex: none; color: var(--faint); }
|
||||
.job-ico .ico { width: 1.1rem; height: 1.1rem; }
|
||||
.job-line.has-thumb .job-ico { display: none; }
|
||||
.job-meta {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.job-line .name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
color: var(--ink);
|
||||
font-weight: 650;
|
||||
}
|
||||
.job-line .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; color: var(--ink); }
|
||||
.job-line .swatches { padding: 0; min-height: 0; flex: none; }
|
||||
.badge {
|
||||
font-size: 0.65rem;
|
||||
@@ -1874,7 +1957,6 @@ body.desk-on main {
|
||||
.summary-text b { color: var(--ink); }
|
||||
.page-head-side { display: flex; align-items: center; gap: 0.75rem 1rem; flex-wrap: wrap; }
|
||||
.page-head-side .check { margin: 0; font-size: var(--t-sm); color: var(--muted); }
|
||||
.job-line > .ico { color: var(--faint); }
|
||||
.job-line[hidden] { display: none; }
|
||||
article.machine.flash { animation: flash 2s var(--ease); }
|
||||
@keyframes flash { 0%, 40% { box-shadow: 0 0 0 2px var(--accent), var(--shadow); } }
|
||||
@@ -2274,3 +2356,70 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); }
|
||||
.nav-secondary a { padding: 0.4rem; }
|
||||
.nav-main a.on { box-shadow: none; border-radius: 9px; padding-left: 0.55rem; }
|
||||
}
|
||||
|
||||
/* Druckbericht & Statistik */
|
||||
.bericht { display: grid; gap: 0.85rem; }
|
||||
.bericht-head { display: grid; gap: 0.5rem; }
|
||||
.bericht-meta p { margin: 0.2rem 0; }
|
||||
.temp-chart {
|
||||
width: 100%;
|
||||
max-width: 40rem;
|
||||
height: auto;
|
||||
background: #0c0c10;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.chart-label { fill: var(--muted); font-size: 11px; }
|
||||
.chart-legend { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.35rem; }
|
||||
.chart-legend .leg::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 0.65rem;
|
||||
height: 0.65rem;
|
||||
border-radius: 50%;
|
||||
margin-right: 0.3rem;
|
||||
vertical-align: -0.05rem;
|
||||
}
|
||||
.chart-legend .nozzle::before { background: #ea580c; }
|
||||
.chart-legend .bed::before { background: #2563eb; }
|
||||
.chart-legend .chamber::before { background: #16a34a; }
|
||||
.tag {
|
||||
display: inline-block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 650;
|
||||
padding: 0.05rem 0.35rem;
|
||||
border-radius: 6px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
.kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
|
||||
gap: 0.65rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.kpi {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 0.7rem 0.8rem;
|
||||
}
|
||||
.kpi-val {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.kpi-label { color: var(--muted); font-size: 0.78rem; font-weight: 650; }
|
||||
.spool-akte {
|
||||
margin-top: 0.85rem;
|
||||
padding-top: 0.7rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.spool-akte h3 { margin: 0 0 0.35rem; font-size: 0.95rem; }
|
||||
ul.plain { list-style: none; margin: 0; padding: 0; }
|
||||
ul.plain li { padding: 0.2rem 0; border-bottom: 1px solid var(--line); }
|
||||
ul.plain li:last-child { border-bottom: 0; }
|
||||
.grid-2 { display: grid; gap: 0.85rem; }
|
||||
@media (min-width: 800px) {
|
||||
.grid-2 { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
@@ -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 %}
|
||||
@@ -103,7 +103,7 @@
|
||||
<select id="send-project"><option value="">keins</option></select>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
@@ -146,7 +146,7 @@
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script src="{{ url_for('static', filename='three.min.js') }}?v=1"></script>
|
||||
<script src="{{ url_for('static', filename='gcode-viewer.js') }}?v=8"></script>
|
||||
<script src="{{ url_for('static', filename='gcode-viewer.js') }}?v=10"></script>
|
||||
<script src="{{ url_for('static', filename='sweep-viewer.js') }}?v=1"></script>
|
||||
<script>
|
||||
const families = { kobra_s1: "Kobra S1", kobra_3: "Kobra 3" };
|
||||
@@ -198,7 +198,7 @@ async function showToolpath(file, progress) {
|
||||
if (viewerFileId !== file.id) {
|
||||
meta.textContent = "Pfad wird gelesen …";
|
||||
try {
|
||||
const data = await api("/dateien/" + file.id + "/pfad");
|
||||
const data = await api("/dateien/" + file.id + "/pfad?tp=8");
|
||||
viewer.setPath(data);
|
||||
viewerFileId = file.id;
|
||||
meta.textContent = (data.count || 0).toLocaleString("de-DE") + " Punkte";
|
||||
@@ -252,13 +252,41 @@ function loadSendMemory() {
|
||||
|
||||
/* ---------- ACE / Spule ---------- */
|
||||
|
||||
function slotLabel(slot) {
|
||||
function spoolAtSlot(host, place) {
|
||||
return spoolsOnHost(host).find((s) => Number(s.ace_slot) === Number(place)) || null;
|
||||
}
|
||||
|
||||
function slotLabel(host, slot) {
|
||||
const place = Number(slot.index) + 1;
|
||||
const box = place <= 4 ? "ACE 1 · " + place : "ACE 2 · " + (place - 4);
|
||||
const spool = spoolAtSlot(host, place);
|
||||
if (spool) {
|
||||
const bits = [box];
|
||||
const kind = spool.material_type || slot.type || "";
|
||||
if (kind) bits.push(kind);
|
||||
if (spool.color) bits.push(spool.color);
|
||||
if (spool.name) bits.push(spool.name);
|
||||
bits.push(grams(spool.remaining_g));
|
||||
return bits.join(" · ");
|
||||
}
|
||||
const kind = slot.type ? " · " + slot.type : "";
|
||||
return box + kind;
|
||||
}
|
||||
|
||||
function slotTitle(host, slot) {
|
||||
const place = Number(slot.index) + 1;
|
||||
const spool = spoolAtSlot(host, place);
|
||||
if (!spool) return slot.type ? String(slot.type) : "";
|
||||
const bits = [];
|
||||
if (spool.brand) bits.push(spool.brand);
|
||||
if (spool.name) bits.push(spool.name);
|
||||
if (spool.material_type) bits.push(spool.material_type);
|
||||
if (spool.color) bits.push(spool.color);
|
||||
bits.push(grams(spool.remaining_g) + " Rest");
|
||||
if (spool.chargen_nr) bits.push(spool.chargen_nr);
|
||||
return bits.join(" · ");
|
||||
}
|
||||
|
||||
function filamentLabel(filament, index) {
|
||||
const kind = filament.type || "Filament";
|
||||
const color = filament.color ? " · " + filament.color : "";
|
||||
@@ -369,7 +397,10 @@ function renderAceMaps(force) {
|
||||
const paints = filePaints(file);
|
||||
box.hidden = false;
|
||||
document.getElementById("options").open = true;
|
||||
if (trailerWarn) trailerWarn.hidden = !!file.ace_trailer;
|
||||
// Mono: Trailer fehlt oft und ist kein Fehler — Grotti setzt ihn beim Senden mit ACE-Map.
|
||||
if (trailerWarn) {
|
||||
trailerWarn.hidden = !!file.ace_trailer || paints.length <= 1;
|
||||
}
|
||||
root.innerHTML = "";
|
||||
chosen.forEach((printer) => {
|
||||
const block = document.createElement("div");
|
||||
@@ -393,10 +424,15 @@ function renderAceMaps(force) {
|
||||
printer.slots.forEach((slot) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = String(slot.index);
|
||||
option.textContent = slotLabel(slot);
|
||||
if (slot.color) option.style.borderLeft = "4px solid " + slot.color;
|
||||
// einfache Vorauswahl: gleiche Sorte, sonst Platz in Reihenfolge
|
||||
if (!select.dataset.picked && filament.type && slot.type && filament.type.toLowerCase() === String(slot.type).toLowerCase()) {
|
||||
option.textContent = slotLabel(printer.host, slot);
|
||||
option.title = slotTitle(printer.host, slot);
|
||||
const spool = spoolAtSlot(printer.host, Number(slot.index) + 1);
|
||||
const hex = (spool && spool.colors && spool.colors[0] && spool.colors[0].hex) || slot.color || "";
|
||||
if (hex) option.style.borderLeft = "4px solid " + hex;
|
||||
const spoolType = (spool && spool.material_type) || slot.type || "";
|
||||
// Vorauswahl: gleiche Sorte (Grotti-Spule bevorzugt), sonst Platz in Reihenfolge
|
||||
if (!select.dataset.picked && filament.type && spoolType
|
||||
&& filament.type.toLowerCase() === String(spoolType).toLowerCase()) {
|
||||
option.selected = true;
|
||||
select.dataset.picked = "1";
|
||||
}
|
||||
|
||||
@@ -41,14 +41,17 @@
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 22mm;
|
||||
gap: 3mm;
|
||||
grid-template-columns: 1fr 20mm;
|
||||
gap: 2.5mm;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.meta { min-width: 0; }
|
||||
.meta h1 {
|
||||
margin: 0 0 2mm;
|
||||
font-size: 4.2mm;
|
||||
letter-spacing: -0.02em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.meta p { margin: 0 0 1.2mm; font-size: 3.1mm; color: #333; }
|
||||
.meta .code { font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
@@ -61,7 +64,19 @@
|
||||
margin-left: -1mm;
|
||||
}
|
||||
.dot:first-child { margin-left: 0; }
|
||||
#qr { width: 20mm; height: 20mm; }
|
||||
.qr-wrap {
|
||||
width: 20mm;
|
||||
height: 20mm;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.qr-wrap canvas {
|
||||
display: block;
|
||||
width: 20mm !important;
|
||||
height: 20mm !important;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
@media print {
|
||||
body { background: #fff; }
|
||||
.toolbar { display: none; }
|
||||
@@ -99,14 +114,20 @@
|
||||
<p>Hersteller: {{ spool.hersteller_charge }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<canvas id="qr" width="120" height="120"></canvas>
|
||||
<div class="qr-wrap">
|
||||
<canvas id="qr" width="80" height="80"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
|
||||
<script>
|
||||
QRCode.toCanvas(document.getElementById("qr"), {{ qr_url|tojson }}, {
|
||||
width: 120,
|
||||
width: 80,
|
||||
margin: 1,
|
||||
color: { dark: "#111111", light: "#ffffff" }
|
||||
}, () => {
|
||||
const node = document.getElementById("qr");
|
||||
node.style.width = "20mm";
|
||||
node.style.height = "20mm";
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -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>
|
||||
@@ -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="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="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=39">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=45">
|
||||
<script>
|
||||
(function () {
|
||||
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="/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="/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="/projekte" class="{{ 'on' if page == 'projekte' else '' }} nav-desk" title="Projekte"><svg class="ico"><use href="#i-folder"/></svg><span>Projekte</span></a>
|
||||
</nav>
|
||||
@@ -289,6 +290,31 @@ function directItemHtml(p) {
|
||||
}
|
||||
|
||||
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() {
|
||||
let jobs;
|
||||
let active = [];
|
||||
@@ -298,7 +324,10 @@ async function refreshPrintQueue() {
|
||||
queueCache = jobs || [];
|
||||
const running = queueCache.filter((job) => job.status === "running");
|
||||
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 errors = queueCache
|
||||
.filter((job) => job.status === "error")
|
||||
@@ -308,15 +337,21 @@ async function refreshPrintQueue() {
|
||||
const runBox = document.getElementById("run-box");
|
||||
runBox.hidden = !(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");
|
||||
document.getElementById("wait-count").textContent = waiting.length;
|
||||
document.getElementById("wait-count").className = "count" + (errors.length ? " bad" : waiting.length ? " hot" : "");
|
||||
document.getElementById("wait-empty").hidden = !!(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("")
|
||||
+ waiting.map((job, i) => waitItemHtml(job, i, waiting.length)).join("");
|
||||
setListHtml(
|
||||
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");
|
||||
panel.querySelectorAll("[data-cancel-q]").forEach((btn) => {
|
||||
|
||||
@@ -120,6 +120,11 @@
|
||||
<button type="button" class="warn" id="spool-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
|
||||
</div>
|
||||
</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>
|
||||
</section>
|
||||
</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) {
|
||||
openSpoolPanel();
|
||||
document.getElementById("spool-id").value = spool ? spool.id : "";
|
||||
@@ -451,6 +499,7 @@ function fill(spool) {
|
||||
showHint();
|
||||
renderPalette();
|
||||
renderSpools();
|
||||
loadAkte(spool ? spool.id : null);
|
||||
document.getElementById("spool-panel").scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
|
||||
@@ -580,6 +629,7 @@ function renderSpools() {
|
||||
const rest = Number(spool.remaining_g) || 0;
|
||||
tr.children[5].textContent = grams(rest);
|
||||
if (rest < 100) tr.children[5].classList.add("msg");
|
||||
if (rest < 0) tr.children[5].title = "Überzogen — Rest korrigieren";
|
||||
tr.addEventListener("click", () => fill(spool));
|
||||
body.appendChild(tr);
|
||||
});
|
||||
@@ -806,7 +856,14 @@ async function openByCode(raw) {
|
||||
const match = code.match(/[?&]spool=(\d+)/);
|
||||
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");
|
||||
fill(spool);
|
||||
setScanNote("Spule " + (spool.chargen_nr || spool.name) + " geöffnet.", true);
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<p id="run-note"></p>
|
||||
<div class="card table-wrap">
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
@@ -80,12 +80,31 @@ function render() {
|
||||
}
|
||||
shown.forEach((run) => {
|
||||
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.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[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);
|
||||
tr.children[5].textContent = cost ? euro(cost) : "";
|
||||
const verdict = document.createElement("select");
|
||||
|
||||
@@ -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 %}
|
||||
+265
-39
@@ -86,7 +86,7 @@
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script src="{{ url_for('static', filename='three.min.js') }}?v=1"></script>
|
||||
<script src="{{ url_for('static', filename='gcode-viewer.js') }}?v=8"></script>
|
||||
<script src="{{ url_for('static', filename='gcode-viewer.js') }}?v=10"></script>
|
||||
<script>
|
||||
const labels = STATE_LABELS;
|
||||
const rank = { printing: 0, paused: 1, error: 2, heating: 3, complete: 4, cancelled: 5, standby: 6, offline: 7 };
|
||||
@@ -98,9 +98,19 @@ let wallSpools = [];
|
||||
let deskViewer = null;
|
||||
let deskCamFails = 0;
|
||||
let deskViewerFileId = null;
|
||||
let deskSyncGen = 0;
|
||||
let deskExcludeSelected = "";
|
||||
const wallPullTried = new Set();
|
||||
const toolpathCache = new Map();
|
||||
|
||||
/** Kurzes leeres filename vom Drucker nicht übernehmen — sonst springt der Name alle paar Sekunden. */
|
||||
function stickyFilename(printer, previous) {
|
||||
const next = (printer && printer.filename) || "";
|
||||
if (next) return next;
|
||||
const state = shownState(printer || {});
|
||||
if (previous && ["printing", "paused", "heating", "complete"].includes(state)) return previous;
|
||||
return "";
|
||||
}
|
||||
const cardNodes = new Map();
|
||||
const runningJobs = new Map();
|
||||
const printerCache = new Map();
|
||||
@@ -208,6 +218,14 @@ function setHtml(el, html) {
|
||||
}
|
||||
}
|
||||
|
||||
function setText(el, text) {
|
||||
if (!el) return;
|
||||
const next = text == null ? "" : String(text);
|
||||
if (el._text === next) return;
|
||||
el._text = next;
|
||||
el.textContent = next;
|
||||
}
|
||||
|
||||
function tempbarHtml(label, value, target, scale, iconName) {
|
||||
if (value == null) return "";
|
||||
const now = Math.round(value);
|
||||
@@ -265,12 +283,19 @@ function swatchesHtml(printer) {
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function actionsHtml(state) {
|
||||
function etikettHtml(printer) {
|
||||
const runId = printer && printer.live_run_id;
|
||||
if (!runId) return "";
|
||||
return `<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") {
|
||||
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") {
|
||||
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 "";
|
||||
}
|
||||
@@ -327,13 +352,107 @@ function stripsHtml(printer, state) {
|
||||
return bits.join("");
|
||||
}
|
||||
|
||||
function dryerLabel(status) {
|
||||
if (status === "drying") return "trocknet";
|
||||
if (status === "heater_err") return "Fehler";
|
||||
return "aus";
|
||||
}
|
||||
|
||||
function dryerDefaultTemp(dryer) {
|
||||
if (dryer.target > 0) return dryer.target;
|
||||
if (dryer.temp > 0) return dryer.temp;
|
||||
return 45;
|
||||
}
|
||||
|
||||
function aceSteerKey(printer) {
|
||||
const dryers = printer.dryers || [];
|
||||
const dryerPart = dryers.map((d) => String(d.id)).join(",");
|
||||
const slots = wallSpools
|
||||
.filter((s) => s.printer_host === printer.host && s.ace_slot)
|
||||
.map((s) => Number(s.ace_slot))
|
||||
.sort((a, b) => a - b)
|
||||
.join(",");
|
||||
return dryerPart + "|" + slots;
|
||||
}
|
||||
|
||||
function spoolHex(spool) {
|
||||
const colors = spool && spool.colors;
|
||||
if (Array.isArray(colors)) {
|
||||
for (const item of colors) {
|
||||
if (item && item.hex) return item.hex;
|
||||
if (typeof item === "string" && item.trim()) return item.trim();
|
||||
}
|
||||
}
|
||||
const label = spool && spool.color;
|
||||
if (typeof label === "string" && /^#?[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(label.trim())) {
|
||||
return label.trim().startsWith("#") ? label.trim() : "#" + label.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function aceSlotLabel(slot) {
|
||||
const n = Number(slot);
|
||||
if (n <= 4) return "ACE 1 · " + n;
|
||||
return "ACE 2 · " + (n - 4);
|
||||
}
|
||||
|
||||
function aceSteerHtml(printer) {
|
||||
const dryers = printer.dryers || [];
|
||||
if (!dryers.length) return "";
|
||||
const bits = [];
|
||||
dryers.forEach((dryer) => {
|
||||
const id = Number(dryer.id);
|
||||
const label = "ACE " + (id + 1);
|
||||
const status = dryerLabel(dryer.status);
|
||||
const temp = dryer.temp || 0;
|
||||
const target = dryer.target || 0;
|
||||
const remain = dryer.remain || 0;
|
||||
const dur = remain > 0 ? remain : 240;
|
||||
const hot = dryerDefaultTemp(dryer);
|
||||
const info = [
|
||||
status,
|
||||
temp || target ? temp + "°" + (target ? " / " + target + "°" : "") : "",
|
||||
remain > 0 ? "noch " + remain + " min" : "",
|
||||
].filter(Boolean).join(" · ");
|
||||
bits.push(`<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) {
|
||||
const n = printer.nozzle_target != null ? Math.round(printer.nozzle_target) : "";
|
||||
const b = printer.bed_target != null ? Math.round(printer.bed_target) : "";
|
||||
const sp = typeof printer.speed_factor === "number" ? Math.round(printer.speed_factor * 100) : 100;
|
||||
const flow = typeof printer.extrude_factor === "number" ? Math.round(printer.extrude_factor * 100) : 100;
|
||||
const fan = typeof printer.fan === "number" ? Math.round(printer.fan * 100) : 0;
|
||||
return `<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">
|
||||
<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>
|
||||
@@ -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>${icon("fan")} Fan % <input type="number" data-steer="fan" data-steer-action="fan" min="0" max="100" value="${escapeHtml(String(fan))}"></label>
|
||||
</div>
|
||||
${aceSteerHtml(printer)}
|
||||
<p class="tiny">Enter im Feld setzt nur diesen Wert.</p>
|
||||
</div>`;
|
||||
}
|
||||
@@ -447,8 +567,9 @@ function anycubicTaskLine(printer) {
|
||||
return "<span>Anycubic <b>" + escapeHtml(bits.join(" · ")) + "</b></span>";
|
||||
}
|
||||
|
||||
function jobLineVisible(printer, state) {
|
||||
return !!printer.filename && state !== "standby" && state !== "offline";
|
||||
function jobLineVisible(printer, state, filename) {
|
||||
const name = filename != null ? filename : (printer && printer.filename);
|
||||
return !!name && state !== "standby" && state !== "offline";
|
||||
}
|
||||
|
||||
function shortName(name) {
|
||||
@@ -490,7 +611,14 @@ function buildCard(printer) {
|
||||
<div data-role="bar"></div>
|
||||
<div class="stats" data-role="stats"></div>
|
||||
<div data-role="temps"></div>
|
||||
<div class="job-line" data-role="jobline">${icon("cube")}<span class="name" data-role="job"></span><span class="swatches" data-role="swatches"></span></div>
|
||||
<div class="job-line" data-role="jobline">
|
||||
<img class="job-thumb" data-role="thumb" alt="" hidden>
|
||||
<span class="job-ico" data-role="jobico">${icon("cube")}</span>
|
||||
<div class="job-meta">
|
||||
<span class="name" data-role="job"></span>
|
||||
<span class="swatches" data-role="swatches"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div data-role="flow"></div>
|
||||
<div data-role="strips"></div>
|
||||
<div class="card-actions" data-role="controls"></div>
|
||||
@@ -510,11 +638,13 @@ function patchSteerValues(root, printer) {
|
||||
if (steerHasFocus(root)) return;
|
||||
const steer = root.querySelector("[data-role=steer]");
|
||||
if (!steer) return;
|
||||
const nozzle = steer.querySelector('[data-steer="nozzle"]');
|
||||
const bed = steer.querySelector('[data-steer="bed"]');
|
||||
const speed = steer.querySelector('[data-steer="speed"]');
|
||||
const flow = steer.querySelector('[data-steer="flow"]');
|
||||
const fan = steer.querySelector('[data-steer="fan"]');
|
||||
const panel = steer.querySelector(".steer");
|
||||
if (!panel) return;
|
||||
const nozzle = panel.querySelector('[data-steer="nozzle"]');
|
||||
const bed = panel.querySelector('[data-steer="bed"]');
|
||||
const speed = panel.querySelector('[data-steer="speed"]');
|
||||
const flow = panel.querySelector('[data-steer="flow"]');
|
||||
const fan = panel.querySelector('[data-steer="fan"]');
|
||||
if (nozzle && document.activeElement !== nozzle) {
|
||||
nozzle.value = printer.nozzle_target != null ? Math.round(printer.nozzle_target) : "";
|
||||
}
|
||||
@@ -530,13 +660,39 @@ function patchSteerValues(root, printer) {
|
||||
if (fan && document.activeElement !== fan && typeof printer.fan === "number") {
|
||||
fan.value = Math.round(printer.fan * 100);
|
||||
}
|
||||
(printer.dryers || []).forEach((dryer) => {
|
||||
const id = Number(dryer.id);
|
||||
const box = panel.querySelector(`[data-dryer-id="${id}"]`);
|
||||
if (!box) return;
|
||||
const info = box.querySelector("[data-dryer-info]");
|
||||
if (info) {
|
||||
const status = dryerLabel(dryer.status);
|
||||
const temp = dryer.temp || 0;
|
||||
const target = dryer.target || 0;
|
||||
const remain = dryer.remain || 0;
|
||||
info.textContent = [
|
||||
status,
|
||||
temp || target ? temp + "°" + (target ? " / " + target + "°" : "") : "",
|
||||
remain > 0 ? "noch " + remain + " min" : "",
|
||||
].filter(Boolean).join(" · ");
|
||||
}
|
||||
const tempInput = box.querySelector(`[data-steer="dryer-temp-${id}"]`);
|
||||
const durInput = box.querySelector(`[data-steer="dryer-dur-${id}"]`);
|
||||
if (tempInput && document.activeElement !== tempInput && dryer.status === "drying" && dryer.target > 0) {
|
||||
tempInput.value = dryer.target;
|
||||
}
|
||||
if (durInput && document.activeElement !== durInput && dryer.remain > 0) {
|
||||
durInput.value = dryer.remain;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function patchCard(article, printer) {
|
||||
const state = shownState(printer);
|
||||
const clearNow = needsClear(printer);
|
||||
const shownFile = stickyFilename(printer, article.dataset.filename || "");
|
||||
article.dataset.state = state;
|
||||
article.dataset.filename = printer.filename || "";
|
||||
article.dataset.filename = shownFile;
|
||||
article.dataset.progress = String(printer.progress || 0);
|
||||
article.dataset.family = printer.family || "";
|
||||
article.dataset.needsClear = clearNow ? "1" : "";
|
||||
@@ -551,16 +707,45 @@ function patchCard(article, printer) {
|
||||
setHtml(role("stats"), statsHtml(printer, state));
|
||||
setHtml(role("temps"), tempbarsHtml(printer, state));
|
||||
const jobline = role("jobline");
|
||||
if (jobline) jobline.hidden = !jobLineVisible(printer, state);
|
||||
const jobVisible = jobLineVisible(printer, state, shownFile);
|
||||
const hasThumb = !!(jobVisible && printer.preview);
|
||||
if (jobline) {
|
||||
jobline.hidden = !jobVisible;
|
||||
jobline.classList.toggle("has-thumb", hasThumb);
|
||||
}
|
||||
const thumb = role("thumb");
|
||||
const jobico = role("jobico");
|
||||
if (thumb) {
|
||||
if (hasThumb) {
|
||||
const next = withToken(printer.preview);
|
||||
if (thumb.dataset.srcKey !== next) {
|
||||
thumb.dataset.srcKey = next;
|
||||
thumb.src = next;
|
||||
}
|
||||
thumb.hidden = false;
|
||||
thumb.onerror = () => {
|
||||
thumb.hidden = true;
|
||||
thumb.dataset.srcKey = "";
|
||||
if (jobline) jobline.classList.remove("has-thumb");
|
||||
if (jobico) jobico.hidden = false;
|
||||
};
|
||||
if (jobico) jobico.hidden = true;
|
||||
} else {
|
||||
thumb.hidden = true;
|
||||
thumb.removeAttribute("src");
|
||||
thumb.dataset.srcKey = "";
|
||||
if (jobico) jobico.hidden = false;
|
||||
}
|
||||
}
|
||||
const job = role("job");
|
||||
if (job) {
|
||||
job.textContent = jobName(printer.filename) || "";
|
||||
job.title = printer.filename || "";
|
||||
setText(job, jobName(shownFile) || "");
|
||||
job.title = shownFile || "";
|
||||
}
|
||||
setHtml(role("swatches"), swatchesHtml(printer));
|
||||
setHtml(role("flow"), flowHtml(printer));
|
||||
setHtml(role("strips"), stripsHtml(printer, state));
|
||||
setHtml(role("controls"), actionsHtml(state));
|
||||
setHtml(role("controls"), actionsHtml(state, printer));
|
||||
}
|
||||
|
||||
function refreshDeskCamera(printer) {
|
||||
@@ -651,9 +836,10 @@ function patchDesk(printer) {
|
||||
if (!printer || printer.host !== deskHost) return;
|
||||
const state = shownState(printer);
|
||||
const prevFile = deskEl.dataset.filename || "";
|
||||
const fileChanged = prevFile !== (printer.filename || "");
|
||||
const shownFile = stickyFilename(printer, prevFile);
|
||||
const fileChanged = prevFile !== shownFile;
|
||||
deskEl.dataset.host = printer.host;
|
||||
deskEl.dataset.filename = printer.filename || "";
|
||||
deskEl.dataset.filename = shownFile;
|
||||
deskEl.dataset.progress = String(printer.progress || 0);
|
||||
deskEl.classList.remove(...DESK_STATES);
|
||||
deskEl.classList.add(state);
|
||||
@@ -676,11 +862,12 @@ function patchDesk(printer) {
|
||||
setHtml(role("temps"), tempbarsHtml(printer, state));
|
||||
|
||||
const jobline = role("jobline");
|
||||
if (jobline) jobline.hidden = !jobLineVisible(printer, state) && !printer.preview;
|
||||
const jobVisible = jobLineVisible(printer, state, shownFile);
|
||||
if (jobline) jobline.hidden = !jobVisible && !printer.preview;
|
||||
const job = role("job");
|
||||
if (job) {
|
||||
job.textContent = jobName(printer.filename) || (jobLineVisible(printer, state) ? "" : "Kein Auftrag");
|
||||
job.title = printer.filename || "";
|
||||
setText(job, jobName(shownFile) || (jobVisible ? "" : "Kein Auftrag"));
|
||||
job.title = shownFile || "";
|
||||
}
|
||||
const preview = role("preview");
|
||||
if (preview) {
|
||||
@@ -696,7 +883,7 @@ function patchDesk(printer) {
|
||||
setHtml(role("facts"), factsHtml(printer));
|
||||
setHtml(role("flow"), flowHtml(printer));
|
||||
setHtml(role("strips"), stripsHtml(printer, state));
|
||||
setHtml(role("controls"), actionsHtml(state));
|
||||
setHtml(role("controls"), actionsHtml(state, printer));
|
||||
const estop = role("estop");
|
||||
if (estop) {
|
||||
const showStop = state === "printing" || state === "paused" || state === "heating";
|
||||
@@ -709,11 +896,15 @@ function patchDesk(printer) {
|
||||
}
|
||||
|
||||
const steer = role("steer");
|
||||
if (steer && (!steer.querySelector(".steer") || fileChanged)) {
|
||||
if (steer) {
|
||||
const panel = steer.querySelector(".steer");
|
||||
const nextKey = aceSteerKey(printer);
|
||||
if (!panel || fileChanged || panel.dataset.aceKey !== nextKey) {
|
||||
setHtml(steer, steerPanelHtml(printer));
|
||||
} else {
|
||||
patchSteerValues(deskEl, printer);
|
||||
}
|
||||
}
|
||||
setHtml(role("mesh"), heatMapHtml(printer));
|
||||
|
||||
// Kamera nur über Timer aktualisieren — sonst doppelter Reload + Layout-Ruckler
|
||||
@@ -725,8 +916,9 @@ async function syncDeskViewer(printer) {
|
||||
const canvas = deskEl.querySelector('[data-role="path-canvas"]');
|
||||
const caption = deskEl.querySelector('[data-role="path-caption"]');
|
||||
if (!canvas || !window.GrottiGcodeViewer || !window.THREE) return;
|
||||
const filename = printer.filename || "";
|
||||
const filename = stickyFilename(printer, deskEl.dataset.filename || "");
|
||||
const progress = Number(printer.progress || 0);
|
||||
const gen = ++deskSyncGen;
|
||||
if (!deskViewer) {
|
||||
deskViewer = GrottiGcodeViewer.create(canvas);
|
||||
deskViewer.setExcludeObjects({ objects: [], excluded: [], current: null }, {
|
||||
@@ -741,45 +933,55 @@ async function syncDeskViewer(printer) {
|
||||
let file = matchLibraryFile(filename);
|
||||
if (!file && filename && !wallPullTried.has(deskHost + "|" + filename)) {
|
||||
wallPullTried.add(deskHost + "|" + filename);
|
||||
if (caption) caption.textContent = "lade vom Drucker …";
|
||||
setText(caption, "lade vom Drucker …");
|
||||
try {
|
||||
const saved = await api("/dateien/von-drucker", {
|
||||
method: "POST",
|
||||
json: { host: deskHost, filename },
|
||||
});
|
||||
if (gen !== deskSyncGen) return;
|
||||
libraryFiles = await api("/dateien");
|
||||
if (gen !== deskSyncGen) return;
|
||||
file = saved || matchLibraryFile(filename);
|
||||
} catch (error) {
|
||||
/* bleibt leer */
|
||||
}
|
||||
}
|
||||
if (!file) {
|
||||
// Kurz leerer filename: Pfad und Caption nicht verwerfen (sonst Flackern alle 5 s).
|
||||
if (!filename && deskViewerFileId) return;
|
||||
deskViewer.setPath({ points: [] });
|
||||
deskViewerFileId = null;
|
||||
if (caption) caption.textContent = filename ? "Datei nicht in Grotti" : "kein Auftrag";
|
||||
setText(caption, filename ? "Datei nicht in Grotti" : "kein Auftrag");
|
||||
renderExcludePanel(printer);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let data = toolpathCache.get(file.id);
|
||||
const needLoad = !data || data.version !== 5 || deskViewerFileId !== file.id;
|
||||
if (needLoad) {
|
||||
if (caption) caption.textContent = "wird gelesen …";
|
||||
data = await api("/dateien/" + file.id + "/pfad");
|
||||
const cachedOk = data && Number(data.version) === 8;
|
||||
const needFetch = !cachedOk;
|
||||
const needSetPath = deskViewerFileId !== file.id;
|
||||
if (needFetch) {
|
||||
if (needSetPath) setText(caption, "wird gelesen …");
|
||||
data = await api("/dateien/" + file.id + "/pfad?tp=8");
|
||||
if (gen !== deskSyncGen) return;
|
||||
toolpathCache.set(file.id, data);
|
||||
const sameFile = deskViewerFileId === file.id;
|
||||
deskViewer.setPath(data, { keepView: sameFile });
|
||||
}
|
||||
if (needFetch || needSetPath) {
|
||||
deskViewer.setPath(data, { keepView: deskViewerFileId === file.id });
|
||||
deskViewerFileId = file.id;
|
||||
}
|
||||
if (!(data.points && data.points.length)) {
|
||||
if (caption) caption.textContent = "leer in Datei";
|
||||
setText(caption, "leer in Datei");
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// File-Id behalten — sonst setzt der nächste Poll die 3D-Ansicht zurück
|
||||
if (caption) caption.textContent = error.message || "fehlgeschlagen";
|
||||
if (gen !== deskSyncGen) return;
|
||||
setText(caption, error.message || "fehlgeschlagen");
|
||||
return;
|
||||
}
|
||||
if (gen !== deskSyncGen) return;
|
||||
deskViewer.setProgress(progress, {
|
||||
layer: printer.layer,
|
||||
layers: printer.layers,
|
||||
@@ -800,7 +1002,7 @@ async function syncDeskViewer(printer) {
|
||||
const layerTxt = printer.layers
|
||||
? " · Schicht " + (printer.layer || 0) + "/" + printer.layers
|
||||
: "";
|
||||
if (caption) caption.textContent = file.name + " · " + Math.round(progress * 100) + " %" + layerTxt;
|
||||
setText(caption, jobName(file.name) + " · " + Math.round(progress * 100) + " %" + layerTxt);
|
||||
}
|
||||
|
||||
function renderExcludePanel(printer) {
|
||||
@@ -1000,8 +1202,8 @@ async function refresh() {
|
||||
if (!libraryFiles.length) {
|
||||
try { libraryFiles = await api("/dateien"); } catch (error) { libraryFiles = []; }
|
||||
}
|
||||
if (!wallSpools.length) {
|
||||
try { wallSpools = await api("/spulen"); } catch (error) { wallSpools = []; }
|
||||
if (!wallSpools.length || deskHost) {
|
||||
try { wallSpools = await api("/spulen"); } catch (error) { /* behalten */ }
|
||||
}
|
||||
const all = await api("/drucker");
|
||||
const printers = sortPrinters(all.slice());
|
||||
@@ -1223,11 +1425,35 @@ async function handleWallClick(event) {
|
||||
method: "POST", json: { action: "fan", percent: 0 },
|
||||
});
|
||||
toast("Heizung und Lüfter aus.");
|
||||
} else if (go === "dryer_start") {
|
||||
const id = Number(steerBtn.dataset.dryerId);
|
||||
const temp = val("dryer-temp-" + id);
|
||||
const duration = val("dryer-dur-" + id);
|
||||
await api("/drucker/" + encodeURIComponent(host) + "/steuern", {
|
||||
method: "POST",
|
||||
json: { action: "dryer_start", id, temp, duration },
|
||||
});
|
||||
toast("Trockner gestartet.");
|
||||
} else if (go === "dryer_stop") {
|
||||
const id = Number(steerBtn.dataset.dryerId);
|
||||
await api("/drucker/" + encodeURIComponent(host) + "/steuern", {
|
||||
method: "POST",
|
||||
json: { action: "dryer_stop", id },
|
||||
});
|
||||
toast("Trockner gestoppt.");
|
||||
} else if (go === "slot_setzen") {
|
||||
const aceSlot = Number(steerBtn.dataset.aceSlot);
|
||||
await api("/drucker/" + encodeURIComponent(host) + "/steuern", {
|
||||
method: "POST",
|
||||
json: { action: "slot_setzen", ace_slot: aceSlot },
|
||||
});
|
||||
toast("Platz an den Drucker geschrieben.");
|
||||
}
|
||||
showNote("note", null);
|
||||
refresh();
|
||||
} catch (error) {
|
||||
showNote("note", error);
|
||||
toast(error.message, "bad");
|
||||
} finally {
|
||||
steerBtn.disabled = false;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user