Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efd0801cd1 | ||
|
|
b277ca48aa | ||
|
|
7da895bbcd |
No files matched your search
@@ -4,6 +4,51 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
||||||
|
|
||||||
|
- Anleitungen mit relativen Bild- und Kapitelpfaden (`../web/static/doku/…`, `Anleitung-*.md`), damit Screenshots und Querverweise auch auf Git lesbar sind; die App schreibt sie beim Rendern auf `/static/…` bzw. `/doku/…` um.
|
||||||
|
|
||||||
## [2.2.0] – 2026-10-15
|
## [2.2.0] – 2026-10-15
|
||||||
|
|
||||||
### Neu
|
### Neu
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# ACE und Spulen
|
# ACE und Spulen
|
||||||
|
|
||||||
Stand **15.10.2026**. Mehrfarb-Drucke brauchen vor dem Start eine Zuordnung Datei-Farbe → ACE-Platz und eine passende Spule am Drucker.
|
Stand **17.10.2026**. Mehrfarb-Drucke brauchen vor dem Start eine Zuordnung Datei-Farbe → ACE-Platz und eine passende Spule am Drucker.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Spule einlegen
|
## Spule einlegen
|
||||||
|
|
||||||
@@ -12,13 +12,14 @@ Stand **15.10.2026**. Mehrfarb-Drucke brauchen vor dem Start eine Zuordnung Date
|
|||||||
|
|
||||||
Es gibt **kein freies Spulen-Dropdown** beim Start: Grotti nimmt die Rolle(n), die am Ziel-Drucker liegen. Material-Mismatch oder zu wenig Rest → Warnung vor dem Start.
|
Es gibt **kein freies Spulen-Dropdown** beim Start: Grotti nimmt die Rolle(n), die am Ziel-Drucker liegen. Material-Mismatch oder zu wenig Rest → Warnung vor dem Start.
|
||||||
|
|
||||||
Etiketten mit QR: [Material und mehr](Anleitung-Material).
|
Etiketten mit QR: [Material und mehr](Anleitung-Material.md).
|
||||||
|
|
||||||
## ACE-Map beim Senden
|
## ACE-Map beim Senden
|
||||||
|
|
||||||
Unter **Dateien → Optionen → ACE-Zuordnung**:
|
Unter **Dateien → Optionen → ACE-Zuordnung**:
|
||||||
|
|
||||||
- Jede Farbe aus der Datei (`paint_index`) einem **ACE-Platz** zuordnen
|
- Jede Farbe aus der Datei (`paint_index`) einem **ACE-Platz** zuordnen
|
||||||
|
- Liegt unter Material eine Spule auf dem Platz, zeigt die Liste Name, Farbe und Restgramm mit
|
||||||
- Ohne passende Map schreibt der Drucker oft **„cannot parse the file“** und startet nicht
|
- Ohne passende Map schreibt der Drucker oft **„cannot parse the file“** und startet nicht
|
||||||
- Grotti legt die Sidecar `*.gcode.acm` an und schickt sie mit
|
- Grotti legt die Sidecar `*.gcode.acm` an und schickt sie mit
|
||||||
|
|
||||||
@@ -35,5 +36,5 @@ Externe Spule / keine ACE-Slots: keine `.acm` nötig.
|
|||||||
|
|
||||||
## Weiter
|
## Weiter
|
||||||
|
|
||||||
- [Dateien senden](Anleitung-Dateien)
|
- [Dateien senden](Anleitung-Dateien.md)
|
||||||
- [Material](Anleitung-Material)
|
- [Material](Anleitung-Material.md)
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Stand **15.10.2026**. Seite `/dateien` — Bibliothek links, Senden rechts.
|
Stand **15.10.2026**. Seite `/dateien` — Bibliothek links, Senden rechts.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Bibliothek
|
## Bibliothek
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ Unter Optionen u. a.:
|
|||||||
|
|
||||||
- **Bettnetz / Auto-Level** beim MQTT-Start (Anycubic)
|
- **Bettnetz / Auto-Level** beim MQTT-Start (Anycubic)
|
||||||
- Maschinen-Override, Projekt zuweisen
|
- Maschinen-Override, Projekt zuweisen
|
||||||
- **ACE-Zuordnung** — siehe [ACE und Spulen](Anleitung-ACE)
|
- **ACE-Zuordnung** — siehe [ACE und Spulen](Anleitung-ACE.md)
|
||||||
|
|
||||||
## Warum es dauert
|
## Warum es dauert
|
||||||
|
|
||||||
@@ -39,11 +39,11 @@ Große Dateien brauchen oft **eine Minute**. In der Sidebar unter **Senden** sie
|
|||||||
|
|
||||||
## Nach dem Start
|
## Nach dem Start
|
||||||
|
|
||||||
- Fortschritt auf der [Wand](Anleitung-Wand) und in der Sidebar **Läuft jetzt**
|
- Fortschritt auf der [Wand](Anleitung-Wand.md) und in der Sidebar **Läuft jetzt**
|
||||||
- Bei „Wenn frei“: Eintrag unter [Warteschlange](Anleitung-Warteschlange)
|
- Bei „Wenn frei“: Eintrag unter [Warteschlange](Anleitung-Warteschlange.md)
|
||||||
- Später unter **Protokoll** mit **Nochmal** (inkl. gespeicherter ACE-Map)
|
- Später unter **Protokoll** mit **Nochmal** (inkl. gespeicherter ACE-Map)
|
||||||
|
|
||||||
## Weiter
|
## Weiter
|
||||||
|
|
||||||
- [ACE und Spulen](Anleitung-ACE)
|
- [ACE und Spulen](Anleitung-ACE.md)
|
||||||
- [Warteschlange](Anleitung-Warteschlange)
|
- [Warteschlange](Anleitung-Warteschlange.md)
|
||||||
@@ -34,7 +34,7 @@ copy config.example.json config.json
|
|||||||
| `energy`, `telegram`, `alerts` | Kosten im Protokoll und Telegram — später auch unter Einstellungen. |
|
| `energy`, `telegram`, `alerts` | Kosten im Protokoll und Telegram — später auch unter Einstellungen. |
|
||||||
| `secret_key` | Für Login-Sessions. Beim ersten Start wird ein Wert erzeugt, wenn leer. |
|
| `secret_key` | Für Login-Sessions. Beim ersten Start wird ein Wert erzeugt, wenn leer. |
|
||||||
|
|
||||||
Drucker beim ersten Start über `drucker.json` (Seed). Danach pflegt man sie unter [Einstellungen](Anleitung-Einstellungen).
|
Drucker beim ersten Start über `drucker.json` (Seed). Danach pflegt man sie unter [Einstellungen](Anleitung-Einstellungen.md).
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[{ "name": "Kobra 3 da26", "host": "10.10.1.60" }]
|
[{ "name": "Kobra 3 da26", "host": "10.10.1.60" }]
|
||||||
@@ -50,7 +50,7 @@ Die Familie (Kobra 3 oder S1) liest Grotti aus dem Namen.
|
|||||||
|
|
||||||
Dann <http://127.0.0.1:8770/> öffnen. Beim ersten Start legst du den Admin-Benutzer an. Nach Code-Änderungen an Python oft neu starten.
|
Dann <http://127.0.0.1:8770/> öffnen. Beim ersten Start legst du den Admin-Benutzer an. Nach Code-Änderungen an Python oft neu starten.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Sicherheit
|
## Sicherheit
|
||||||
|
|
||||||
@@ -61,6 +61,6 @@ Dann <http://127.0.0.1:8770/> öffnen. Beim ersten Start legst du den Admin-Benu
|
|||||||
|
|
||||||
## Weiter
|
## Weiter
|
||||||
|
|
||||||
- [Oberfläche](Anleitung-Oberflaeche) — Navigation und Sidebar
|
- [Oberfläche](Anleitung-Oberflaeche.md) — Navigation und Sidebar
|
||||||
- [Dateien senden](Anleitung-Dateien) — erster Druck
|
- [Dateien senden](Anleitung-Dateien.md) — erster Druck
|
||||||
- Ausführlich im `README.md` im Projektordner
|
- Ausführlich im `README.md` im Projektordner
|
||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
Stand **15.10.2026**. Seite `/einstellungen` — Drucker, Automatik, Anzeige, Telegram, Energie.
|
Stand **15.10.2026**. Seite `/einstellungen` — Drucker, Automatik, Anzeige, Telegram, Energie.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Dieser Browser
|
## Dieser Browser
|
||||||
|
|
||||||
- **Desktop** oder **Regal (Touch)** — nur lokal im Browser, siehe [Oberfläche](Anleitung-Oberflaeche).
|
- **Desktop** oder **Regal (Touch)** — nur lokal im Browser, siehe [Oberfläche](Anleitung-Oberflaeche.md).
|
||||||
|
|
||||||
## Automatik
|
## Automatik
|
||||||
|
|
||||||
@@ -40,5 +40,5 @@ Watt und Strompreis für die Kosten im **Protokoll**. Optional Trockner/Ausschus
|
|||||||
|
|
||||||
## Weiter
|
## Weiter
|
||||||
|
|
||||||
- [Einrichtung](Anleitung-Einrichtung) (Config-Datei)
|
- [Einrichtung](Anleitung-Einrichtung.md) (Config-Datei)
|
||||||
- [Störungen](Anleitung-Stoerungen)
|
- [Störungen](Anleitung-Stoerungen.md)
|
||||||
@@ -1,28 +1,36 @@
|
|||||||
# Material und mehr
|
# Material und mehr
|
||||||
|
|
||||||
Stand **15.10.2026**. Spulen, Etiketten, Projekte, Protokoll, Kameras, Aktivität und Ideen.
|
Stand **17.10.2026**. Spulen, Etiketten, Projekte, Protokoll, Statistik, Kameras, Aktivität und Ideen.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Material
|
## Material
|
||||||
|
|
||||||
Zwei Reiter: **Spulen** und **Sorten**.
|
Zwei Reiter: **Spulen** und **Sorten**.
|
||||||
|
|
||||||
**Spulen:** Filter Alle / Am Drucker / Regal / Wenig Rest. Formular bei Auswahl oder „Neue Spule“. Ort = Drucker · ACE/extern oder Regal. Scan-Feld im Seitenkopf (QR vom Etikett).
|
**Spulen:** Filter Alle / Am Drucker / Regal / Wenig Rest. Formular bei Auswahl oder „Neue Spule“. Ort = Drucker · ACE/extern oder Regal. Scan-Feld im Seitenkopf (QR vom Etikett). Unter der ausgewählten Spule die **Akte**: verdruckte Gramm, Ausschuss, Leer-in.
|
||||||
|
|
||||||
**Sorten:** Profile (Dichte, Preis, Maschinen). „Spule damit anlegen“ wechselt zum Spulen-Reiter.
|
**Sorten:** Profile (Dichte, Preis, Maschinen). „Spule damit anlegen“ wechselt zum Spulen-Reiter.
|
||||||
|
|
||||||
### Etikett und QR
|
### Etikett und QR
|
||||||
|
|
||||||
Pro Spule ein Etikett mit Chargennummer und QR. Der Code öffnet Material mit der Spule vorausgewählt. Dafür in den Einstellungen ggf. **FQDN** setzen (öffentliche URL), sonst gilt der Host aus dem Request.
|
Pro Spule ein Etikett mit Chargennummer `S-…` und QR. Der Code öffnet Material mit der Spule vorausgewählt. Dafür in den Einstellungen ggf. **FQDN** setzen (öffentliche URL), sonst gilt der Host aus dem Request.
|
||||||
|
|
||||||
|
Druck-Etiketten (`D-…`) kommen vom Druckbericht: ein Etikett für den ganzen Lauf oder je Objekt. Scan von `D-…` öffnet den Bericht.
|
||||||
|
|
||||||
## Projekte
|
## Projekte
|
||||||
|
|
||||||
Unter **Projekte** Aufträge mit Notiz und Kalkulation. Dateien lassen sich einem Projekt zuweisen (Bibliothek / Senden).
|
Unter **Projekte** Aufträge mit Notiz und Kalkulation. Dateien lassen sich einem Projekt zuweisen (Bibliothek / Senden).
|
||||||
|
|
||||||
## Protokoll
|
## Protokoll und Statistik
|
||||||
|
|
||||||
Abgeschlossene Läufe: Dauer, Gramm, Kosten, Urteil. **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option.
|
Abgeschlossene Läufe: Dauer, Ist-Gramm, Kosten, Urteil. Zeile öffnet den **Druckbericht** (Soll/Ist, Spulen, Temperaturverlauf, Charge). **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option.
|
||||||
|
|
||||||
|
Unter **Statistik** Auslastung, Erfolg, Verbrauch und CSV-Export. Zeitraum per Knopf (Tag, Woche, Monat, Gestern, Letzte Woche, Letzter Monat) oder eigene Von/Bis-Daten.
|
||||||
|
|
||||||
|
Druck-Etiketten (`D-…`) schon während des Drucks: an der Wand **Etikett**, im Druckbericht **Etikett Druck** / **Etiketten Objekte**. Auf dem Bogen steht das Zielgewicht. Bei Ausschuss die gedruckten Bögen wegwerfen; die Charge bleibt im Protokoll.
|
||||||
|
|
||||||
|
Abzug nach dem Druck nimmt das Ist aus `filament_used` (Abbruch nur anteilig). Mehrfarb-Anteile sind als Schätzwert gekennzeichnet. Restgramm darf unter 0 gehen.
|
||||||
|
|
||||||
## Kameras
|
## Kameras
|
||||||
|
|
||||||
@@ -38,5 +46,5 @@ Zeitstrahl: Starts, Schritte, Fertig, Fehler, Abbrüche, offline. Filter nach Dr
|
|||||||
|
|
||||||
## Weiter
|
## Weiter
|
||||||
|
|
||||||
- [ACE und Spulen](Anleitung-ACE)
|
- [ACE und Spulen](Anleitung-ACE.md)
|
||||||
- [Einstellungen](Anleitung-Einstellungen)
|
- [Einstellungen](Anleitung-Einstellungen.md)
|
||||||
@@ -4,11 +4,11 @@ Stand **15.10.2026**. Wie die App aufgebaut ist — Menü, Live-Leiste, Desktop
|
|||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
|
|
||||||
Links (Desktop): **Drucker**, **Dateien**, **Kameras**, **Aktivität**, **Warteschlange**, **Protokoll**, **Material**, **Projekte**. Darunter **Einstellungen**, **Ideen**, **Doku**. Fuß: Version (öffnet die Doku).
|
Links (Desktop): **Drucker**, **Dateien**, **Kameras**, **Aktivität**, **Warteschlange**, **Protokoll**, **Statistik**, **Material**, **Projekte**. Darunter **Einstellungen**, **Ideen**, **Doku**. Fuß: Version (öffnet die Doku).
|
||||||
|
|
||||||
Unter ca. 760 px: untere Leiste mit den wichtigsten Seiten. Das Seitenmenü lässt sich zur Icon-Rail einklappen.
|
Unter ca. 760 px: untere Leiste mit den wichtigsten Seiten. Das Seitenmenü lässt sich zur Icon-Rail einklappen.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Live-Leiste
|
## Live-Leiste
|
||||||
|
|
||||||
@@ -33,5 +33,5 @@ Shortcut in der URL: `?shelf=1` bzw. `?shelf=0`. Nur dieser Browser (`localStora
|
|||||||
|
|
||||||
## Weiter
|
## Weiter
|
||||||
|
|
||||||
- [Druckerwand](Anleitung-Wand)
|
- [Druckerwand](Anleitung-Wand.md)
|
||||||
- [Dateien senden](Anleitung-Dateien)
|
- [Dateien senden](Anleitung-Dateien.md)
|
||||||
@@ -31,7 +31,7 @@ Was tun: Abbruch über Grotti (nicht nur am Display), auf der Wand den Task-Hinw
|
|||||||
|
|
||||||
## ACE / „cannot parse“
|
## ACE / „cannot parse“
|
||||||
|
|
||||||
ACE-Zuordnung unter Dateien → Optionen prüfen. Spulen am richtigen Platz. Siehe [ACE und Spulen](Anleitung-ACE).
|
ACE-Zuordnung unter Dateien → Optionen prüfen. Spulen am richtigen Platz. Siehe [ACE und Spulen](Anleitung-ACE.md).
|
||||||
|
|
||||||
## Offline
|
## Offline
|
||||||
|
|
||||||
@@ -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.
|
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
|
## Wo nachschauen
|
||||||
|
|
||||||
- Sidebar: Alerts, Läuft, Wartet, Senden
|
- Sidebar: Alerts, Läuft, Wartet, Senden
|
||||||
@@ -52,5 +56,5 @@ Oft noch Verarbeitung auf dem Drucker. In der Sidebar unter **Senden** den Forts
|
|||||||
|
|
||||||
## Weiter
|
## Weiter
|
||||||
|
|
||||||
- [Druckerwand](Anleitung-Wand)
|
- [Druckerwand](Anleitung-Wand.md)
|
||||||
- [Warteschlange](Anleitung-Warteschlange)
|
- [Warteschlange](Anleitung-Warteschlange.md)
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Stand **15.10.2026**. Startseite `/` — eine Karte je Drucker. Klick öffnet das Cockpit.
|
Stand **15.10.2026**. Startseite `/` — eine Karte je Drucker. Klick öffnet das Cockpit.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Karte
|
## Karte
|
||||||
|
|
||||||
@@ -30,13 +30,13 @@ Während eines Abräum-Ablaufs blendet Grotti „Platte geräumt“ aus — der
|
|||||||
|
|
||||||
URL mit Hash: `/#<host>` (z. B. nach Klick auf die Karte).
|
URL mit Hash: `/#<host>` (z. B. nach Klick auf die Karte).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Inhalt:
|
Inhalt:
|
||||||
|
|
||||||
- **Kamera** — nur wenn eine Webcam erreichbar ist; sonst bleibt der Platz weg
|
- **Kamera** — nur wenn eine Webcam erreichbar ist; sonst bleibt der Platz weg
|
||||||
- Status, Temps inkl. Bauraum wo verfügbar, Lüfter
|
- Status, Temps inkl. Bauraum wo verfügbar, Lüfter
|
||||||
- **Steuerung:** Zieltemperaturen, Speed, Flow, Fan — fest vorgegebene Befehle, keine freie Konsole
|
- **Steuerung:** Zieltemperaturen, Speed, Flow, Fan — fest vorgegebene Befehle, keine freie Konsole. Mit ACE zusätzlich Trockner (Temp, Dauer, Start/Stopp) und pro bestücktem Platz **an den Drucker** (Sorte und Farbe aus der Grotti-Spule)
|
||||||
- **E-Stop** nur bei printing/paused/heating (Zwei-Klick). Danach oft Strom aus/an
|
- **E-Stop** nur bei printing/paused/heating (Zwei-Klick). Danach oft Strom aus/an
|
||||||
- **3D-Pfad** der laufenden oder gewählten Datei; Objekte ausschließen (mit Bestätigung)
|
- **3D-Pfad** der laufenden oder gewählten Datei; Objekte ausschließen (mit Bestätigung)
|
||||||
- **Bettnetz**-Heatmap, wenn vorhanden
|
- **Bettnetz**-Heatmap, wenn vorhanden
|
||||||
@@ -45,10 +45,10 @@ Inhalt:
|
|||||||
|
|
||||||
- Gesperrte Drucker (Einstellungen) starten keine neuen „wenn frei“-Jobs.
|
- Gesperrte Drucker (Einstellungen) starten keine neuen „wenn frei“-Jobs.
|
||||||
- Live-Daten kommen mit wenigen Sekunden Verzögerung.
|
- Live-Daten kommen mit wenigen Sekunden Verzögerung.
|
||||||
- Ein Task-Hinweis auf der Karte zeigt, wenn auf dem Drucker noch ein alter Auftrag hängt — siehe [Störungen](Anleitung-Stoerungen).
|
- Ein Task-Hinweis auf der Karte zeigt, wenn auf dem Drucker noch ein alter Auftrag hängt — siehe [Störungen](Anleitung-Stoerungen.md).
|
||||||
|
|
||||||
## Weiter
|
## Weiter
|
||||||
|
|
||||||
- [Dateien senden](Anleitung-Dateien)
|
- [Dateien senden](Anleitung-Dateien.md)
|
||||||
- [Warteschlange und Ablauf](Anleitung-Warteschlange)
|
- [Warteschlange und Ablauf](Anleitung-Warteschlange.md)
|
||||||
- [Störungen](Anleitung-Stoerungen)
|
- [Störungen](Anleitung-Stoerungen.md)
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Stand **15.10.2026**. Aufträge „wenn frei“, Priorität, und der Kobra-3-Ablauf (drucken → abkühlen → schieben → tasten).
|
Stand **15.10.2026**. Aufträge „wenn frei“, Priorität, und der Kobra-3-Ablauf (drucken → abkühlen → schieben → tasten).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Seite Warteschlange
|
## Seite Warteschlange
|
||||||
|
|
||||||
@@ -48,5 +48,5 @@ Aktueller Schritt: auf der Druckerkarte und in der Sidebar. Abbrechen an beiden
|
|||||||
|
|
||||||
## Weiter
|
## Weiter
|
||||||
|
|
||||||
- [Druckerwand](Anleitung-Wand)
|
- [Druckerwand](Anleitung-Wand.md)
|
||||||
- [Störungen](Anleitung-Stoerungen)
|
- [Störungen](Anleitung-Stoerungen.md)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Chargen, Etiketten und 3D-Viewer
|
# Chargen, Etiketten und 3D-Viewer
|
||||||
|
|
||||||
Stand 14.10.2026.
|
Stand 17.10.2026.
|
||||||
|
|
||||||
## Spulen-Chargen
|
## Spulen-Chargen
|
||||||
|
|
||||||
@@ -10,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`.
|
Etikett: `/etikett/<id>`, Druck über den Browser. QR per lokalem `qrcode.min.js` (`web/static/`). Basis-URL für den QR kommt aus Einstellungen → **FQDN** (`config.json` Key `fqdn`); leer = Host der aktuellen Anfrage. Beispiel: `https://grotti.example.de/material?spool=12`.
|
||||||
|
|
||||||
|
## Druck-Chargen
|
||||||
|
|
||||||
|
`D-YYYYMMDD-0001` vergibt der Watcher am aktiven Lauf (History-Job, sonst Live). `adopt_live_run` übernimmt die Nummer. Objekt-Etiketten `D-…-01` sobald Objektnamen da sind.
|
||||||
|
|
||||||
|
Druck über `/etikett/lauf/<id>?mode=druck|objekt`. Auf dem Bogen: Charge, **Zielgewicht** (Slicer-Soll), Drucker, Material (Abzug oder Send/ACE/Lauf-Spule), QR 20 mm im 70‑mm-Bogen. Wand: Knopf **Etikett** bei Druck/Pause. Urteil Ausschuss ändert die Charge nicht — gedruckte Bögen verwerfen.
|
||||||
|
|
||||||
## 3D-Pfad
|
## 3D-Pfad
|
||||||
|
|
||||||
`GET /api/v1/dateien/<id>/pfad` liefert einen gedünnten Toolpath (bis ca. 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.
|
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
|
## Material
|
||||||
|
|
||||||
- Reiter **Spulen** / **Sorten**. Scan-Feld im Seitenkopf.
|
- Reiter **Spulen** / **Sorten**. Scan-Feld im Seitenkopf (`S-…` Spule, `D-…` Druckbericht).
|
||||||
- Spulen: Filter Alle / Am Drucker / Regal / Wenig Rest + Suche. Formular nur bei Auswahl oder „Neue Spule“ (× schließt). Ort = Drucker · ACE/extern bzw. Regal.
|
- Spulen: Filter Alle / Am Drucker / Regal / Wenig Rest + Suche. Formular nur bei Auswahl oder „Neue Spule“ (× schließt). Ort = Drucker · ACE/extern bzw. Regal. Akte mit Verbrauch und Leer-in.
|
||||||
- Sorten: Profile (Dichte, mm³/s, Preis, Maschinen). Anlegen/ändern/löschen; „Spule damit anlegen“ wechselt zum Spulen-Reiter.
|
- Sorten: Profile (Dichte, mm³/s, Preis, Maschinen). Anlegen/ändern/löschen; „Spule damit anlegen“ wechselt zum Spulen-Reiter.
|
||||||
|
|
||||||
|
## Protokoll und Statistik
|
||||||
|
|
||||||
|
- Protokoll-Zeile → Druckbericht `/protokoll/<id>` (Soll/Ist, Spulen, Temperatur, Charge, Etiketten). Charge schon am laufenden Druck; Etikett an der Wand und im Bericht.
|
||||||
|
- Statistik `/statistik`: KPIs, nach Drucker/Material, Zeitraum-Presets, CSV mit denselben Filtern.
|
||||||
|
|
||||||
## Warteschlange
|
## Warteschlange
|
||||||
|
|
||||||
- Seite `/warteschlange`: Filter Läuft / Wartet / Fehler / Historie. Priorität, Abbrechen, Fortsetzen.
|
- Seite `/warteschlange`: Filter Läuft / Wartet / Fehler / Historie. Priorität, Abbrechen, Fortsetzen.
|
||||||
|
|||||||
@@ -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.
|
`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`
|
## 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:
|
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 concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from grotti import acm, eject, library, moonraker, store
|
from grotti import acm, eject, library, moonraker, store, usage
|
||||||
from grotti.cost import energy_cost
|
from grotti.cost import energy_cost
|
||||||
from grotti.errors import GrottiError
|
from grotti.errors import GrottiError
|
||||||
from grotti.parse import content_has_ace_trailer, prepare_rinkhals_gcode
|
from grotti.parse import content_has_ace_trailer, prepare_rinkhals_gcode
|
||||||
@@ -15,17 +15,7 @@ TERMINAL = {"completed", "cancelled", "error", "interrupted"}
|
|||||||
|
|
||||||
|
|
||||||
def weight_of(metadata):
|
def weight_of(metadata):
|
||||||
raw = (metadata or {}).get("filament_weight_total")
|
return usage.weight_sum(metadata)
|
||||||
if isinstance(raw, list):
|
|
||||||
return sum(float(item or 0) for item in raw)
|
|
||||||
if isinstance(raw, (int, float)):
|
|
||||||
return float(raw)
|
|
||||||
if isinstance(raw, str):
|
|
||||||
try:
|
|
||||||
return float(raw.replace(",", "."))
|
|
||||||
except ValueError:
|
|
||||||
return 0.0
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def material_of(metadata):
|
def material_of(metadata):
|
||||||
@@ -35,6 +25,37 @@ def material_of(metadata):
|
|||||||
return str(raw).split(";")[0].strip()
|
return str(raw).split(";")[0].strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _ace_map_of(sent):
|
||||||
|
if not sent:
|
||||||
|
return []
|
||||||
|
raw = sent.get("ace_map")
|
||||||
|
if isinstance(raw, list):
|
||||||
|
return acm.normalize_mapping(raw)
|
||||||
|
if isinstance(raw, str):
|
||||||
|
try:
|
||||||
|
return acm.normalize_mapping(json.loads(raw))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return []
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _targets_from_ace(conn, host, ace):
|
||||||
|
targets = []
|
||||||
|
for item in ace:
|
||||||
|
slot = int(item["ams_index"]) + 1
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM spools
|
||||||
|
WHERE printer_host = %s AND ace_slot = %s
|
||||||
|
ORDER BY id LIMIT 1
|
||||||
|
""",
|
||||||
|
(host, slot),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
targets.append(row)
|
||||||
|
return targets
|
||||||
|
|
||||||
|
|
||||||
def unix_to_dt(value):
|
def unix_to_dt(value):
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
@@ -128,50 +149,152 @@ def assert_spool_at_host(conn, spool_id, host):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def apply_deduction(conn, run, energy):
|
def apply_deduction(conn, run, energy, metadata=None):
|
||||||
if run["deducted"] or run["weight_g"] <= 0 or run["state"] not in TERMINAL:
|
if run["deducted"] or run["state"] not in TERMINAL:
|
||||||
return False
|
return False
|
||||||
weight = float(run["weight_g"] or 0)
|
planned = float(run.get("weight_planned_g") or run.get("weight_g") or 0)
|
||||||
# ACE: Abzug anteilig auf die Plätze der letzten Sendung
|
used = float(run.get("weight_used_g") or 0)
|
||||||
|
source = run.get("weight_source") or ""
|
||||||
|
if used <= 0 and planned > 0 and source != "none":
|
||||||
|
used, source = usage.resolve_used_grams(
|
||||||
|
planned,
|
||||||
|
run.get("filament_used_mm") or 0,
|
||||||
|
run.get("filament_total_mm") or 0,
|
||||||
|
run.get("progress_last") or 0,
|
||||||
|
run["state"],
|
||||||
|
run.get("duration_s") or 0,
|
||||||
|
)
|
||||||
|
if used <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
sent = store.latest_send(conn, run["printer_host"], run.get("filename") or "")
|
sent = store.latest_send(conn, run["printer_host"], run.get("filename") or "")
|
||||||
ace = []
|
ace = _ace_map_of(sent)
|
||||||
if sent:
|
meta = metadata or {}
|
||||||
raw = sent.get("ace_map")
|
if not meta and run.get("file_id"):
|
||||||
if isinstance(raw, list):
|
file_row = store.get_file_row(conn, run["file_id"]) if hasattr(store, "get_file_row") else None
|
||||||
ace = raw
|
if file_row:
|
||||||
elif isinstance(raw, str):
|
meta = {"filament_weight_total": file_row.get("weight_g") or planned}
|
||||||
try:
|
|
||||||
ace = json.loads(raw)
|
store.close_open_segments(conn, run["id"], None, run.get("filament_used_mm") or 0)
|
||||||
except json.JSONDecodeError:
|
segments = store.list_run_segments(conn, run["id"])
|
||||||
ace = []
|
|
||||||
targets = []
|
entries = []
|
||||||
if ace:
|
targets = _targets_from_ace(conn, run["printer_host"], ace) if ace else []
|
||||||
for item in acm.normalize_mapping(ace):
|
|
||||||
slot = int(item["ams_index"]) + 1
|
|
||||||
row = conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT * FROM spools
|
|
||||||
WHERE printer_host = %s AND ace_slot = %s
|
|
||||||
ORDER BY id LIMIT 1
|
|
||||||
""",
|
|
||||||
(run["printer_host"], slot),
|
|
||||||
).fetchone()
|
|
||||||
if row:
|
|
||||||
targets.append(row)
|
|
||||||
if not targets and run.get("spool_id"):
|
if not targets and run.get("spool_id"):
|
||||||
spool = store.get_spool_row(conn, run["spool_id"])
|
spool = store.get_spool_row(conn, run["spool_id"])
|
||||||
if spool:
|
if spool:
|
||||||
targets = [spool]
|
targets = [spool]
|
||||||
if not targets:
|
# Abschnitte ohne ACE-Map: externe Rollen nacheinander
|
||||||
|
if segments and not ace:
|
||||||
|
final_mm = float(run.get("filament_used_mm") or 0)
|
||||||
|
total_mm = float(run.get("filament_total_mm") or 0)
|
||||||
|
deltas = usage.segment_deltas(segments, final_mm)
|
||||||
|
for spool_id, ace_slot, delta_mm in deltas:
|
||||||
|
spool = store.get_spool_row(conn, spool_id)
|
||||||
|
if not spool:
|
||||||
|
continue
|
||||||
|
grams = usage.grams_from_mm(delta_mm, total_mm, planned)
|
||||||
|
cost = (grams / 1000.0) * float(spool.get("price_per_kg") or 0)
|
||||||
|
entries.append({
|
||||||
|
"spool_id": spool_id,
|
||||||
|
"ace_slot": ace_slot,
|
||||||
|
"weight_g": grams,
|
||||||
|
"weight_planned_g": planned / max(1, len(deltas)),
|
||||||
|
"cost": cost,
|
||||||
|
"is_estimate": source != "measured",
|
||||||
|
})
|
||||||
|
elif targets:
|
||||||
|
shares = usage.planned_shares(meta, ace, len(targets))
|
||||||
|
parts, is_estimate = usage.split_used(used, shares, multi=len(targets) > 1)
|
||||||
|
if source != "measured":
|
||||||
|
is_estimate = True
|
||||||
|
# ACE-Rollenwechsel: Anteil der gewechselten Farbe nach mm aufteilen
|
||||||
|
by_slot = {}
|
||||||
|
if segments and ace:
|
||||||
|
final_mm = float(run.get("filament_used_mm") or 0)
|
||||||
|
for spool_id, ace_slot, delta_mm in usage.segment_deltas(segments, final_mm):
|
||||||
|
by_slot.setdefault(ace_slot, []).append((spool_id, delta_mm))
|
||||||
|
for index, spool in enumerate(targets):
|
||||||
|
plan = shares[index] if index < len(shares) else 0
|
||||||
|
grams = parts[index] if index < len(parts) else 0
|
||||||
|
slot = spool.get("ace_slot")
|
||||||
|
slot_segs = by_slot.get(slot) or []
|
||||||
|
if len(slot_segs) > 1:
|
||||||
|
mm_sum = sum(d for _, d in slot_segs) or 1.0
|
||||||
|
for seg_spool_id, delta_mm in slot_segs:
|
||||||
|
seg_spool = store.get_spool_row(conn, seg_spool_id)
|
||||||
|
if not seg_spool:
|
||||||
|
continue
|
||||||
|
share_g = grams * (delta_mm / mm_sum)
|
||||||
|
cost = (share_g / 1000.0) * float(seg_spool.get("price_per_kg") or 0)
|
||||||
|
entries.append({
|
||||||
|
"spool_id": seg_spool_id,
|
||||||
|
"ace_slot": slot,
|
||||||
|
"weight_g": share_g,
|
||||||
|
"weight_planned_g": plan / len(slot_segs),
|
||||||
|
"cost": cost,
|
||||||
|
"is_estimate": True,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
cost = (grams / 1000.0) * float(spool.get("price_per_kg") or 0)
|
||||||
|
entries.append({
|
||||||
|
"spool_id": spool["id"],
|
||||||
|
"ace_slot": slot,
|
||||||
|
"weight_g": grams,
|
||||||
|
"weight_planned_g": plan,
|
||||||
|
"cost": cost,
|
||||||
|
"is_estimate": is_estimate,
|
||||||
|
})
|
||||||
|
elif segments:
|
||||||
|
# Fallback: Abschnitte ohne aktuelle Targets
|
||||||
|
final_mm = float(run.get("filament_used_mm") or 0)
|
||||||
|
total_mm = float(run.get("filament_total_mm") or 0)
|
||||||
|
for spool_id, ace_slot, delta_mm in usage.segment_deltas(segments, final_mm):
|
||||||
|
spool = store.get_spool_row(conn, spool_id)
|
||||||
|
if not spool:
|
||||||
|
continue
|
||||||
|
grams = usage.grams_from_mm(delta_mm, total_mm, planned)
|
||||||
|
cost = (grams / 1000.0) * float(spool.get("price_per_kg") or 0)
|
||||||
|
entries.append({
|
||||||
|
"spool_id": spool_id,
|
||||||
|
"ace_slot": ace_slot,
|
||||||
|
"weight_g": grams,
|
||||||
|
"weight_planned_g": 0,
|
||||||
|
"cost": cost,
|
||||||
|
"is_estimate": source != "measured",
|
||||||
|
})
|
||||||
|
else:
|
||||||
return False
|
return False
|
||||||
share = weight / len(targets)
|
|
||||||
filament = sum((share / 1000) * float(spool["price_per_kg"] or 0) for spool in targets)
|
if not entries:
|
||||||
|
return False
|
||||||
|
used_total = sum(item["weight_g"] for item in entries)
|
||||||
|
filament = sum(item["cost"] for item in entries)
|
||||||
power = energy_cost(run["duration_s"], energy)
|
power = energy_cost(run["duration_s"], energy)
|
||||||
marked = store.mark_deducted(conn, run["id"], targets[0]["id"], filament, power)
|
# run_spools vor deducted=true, sonst bleibt die Akte leer bei Abbruch dazwischen
|
||||||
|
store.replace_run_spools(conn, run["id"], entries)
|
||||||
|
marked = store.mark_deducted(
|
||||||
|
conn,
|
||||||
|
run["id"],
|
||||||
|
entries[0]["spool_id"],
|
||||||
|
filament,
|
||||||
|
power,
|
||||||
|
weight_used_g=used_total,
|
||||||
|
weight_source=source or ("estimate" if any(e["is_estimate"] for e in entries) else "measured"),
|
||||||
|
)
|
||||||
if not marked:
|
if not marked:
|
||||||
|
store.replace_run_spools(conn, run["id"], [])
|
||||||
return False
|
return False
|
||||||
for spool in targets:
|
for item in entries:
|
||||||
store.deduct_spool(conn, spool["id"], share)
|
store.deduct_spool(conn, item["spool_id"], item["weight_g"])
|
||||||
|
store.ensure_run_chargen(conn, run["id"], used_total)
|
||||||
|
objects = run.get("objects")
|
||||||
|
if isinstance(objects, str):
|
||||||
|
try:
|
||||||
|
objects = json.loads(objects)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
objects = []
|
||||||
|
store.ensure_run_labels(conn, run["id"], objects or [], qty=1)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -190,13 +313,16 @@ def sync_history(conn, cfg):
|
|||||||
deducted = 0
|
deducted = 0
|
||||||
for printer, job in collected:
|
for printer, job in collected:
|
||||||
job_id = str(job.get("job_id") or "")
|
job_id = str(job.get("job_id") or "")
|
||||||
if not job_id:
|
if not job_id or str(job_id).startswith("live:"):
|
||||||
continue
|
continue
|
||||||
filename = job.get("filename") or ""
|
filename = job.get("filename") or ""
|
||||||
metadata = job.get("metadata") or {}
|
metadata = job.get("metadata") or {}
|
||||||
weight = weight_of(metadata)
|
planned = weight_of(metadata)
|
||||||
material = material_of(metadata)
|
material = material_of(metadata)
|
||||||
state = job.get("status") or ""
|
state = job.get("status") or ""
|
||||||
|
used_mm = usage.filament_used_mm(job)
|
||||||
|
total_mm = usage.filament_total_mm(metadata, job)
|
||||||
|
estimated = float(metadata.get("estimated_time") or 0)
|
||||||
sent = store.latest_send(conn, printer["host"], filename)
|
sent = store.latest_send(conn, printer["host"], filename)
|
||||||
known = store.file_by_name(conn, filename)
|
known = store.file_by_name(conn, filename)
|
||||||
file_id = (sent or {}).get("file_id") or (known or {}).get("id")
|
file_id = (sent or {}).get("file_id") or (known or {}).get("id")
|
||||||
@@ -204,6 +330,23 @@ def sync_history(conn, cfg):
|
|||||||
hinted = (sent or {}).get("spool_id") or (known or {}).get("spool_id")
|
hinted = (sent or {}).get("spool_id") or (known or {}).get("spool_id")
|
||||||
spool = choose_spool(conn, printer["host"], material, hinted)
|
spool = choose_spool(conn, printer["host"], material, hinted)
|
||||||
terminal = state in TERMINAL
|
terminal = state in TERMINAL
|
||||||
|
live = store.find_live_run(conn, printer["host"], filename)
|
||||||
|
progress = float((live or {}).get("progress_last") or 0)
|
||||||
|
if used_mm <= 0 and live:
|
||||||
|
used_mm = float(live.get("filament_used_mm") or 0)
|
||||||
|
if total_mm <= 0 and live:
|
||||||
|
total_mm = float(live.get("filament_total_mm") or 0)
|
||||||
|
if planned <= 0 and live:
|
||||||
|
planned = float(live.get("weight_planned_g") or 0)
|
||||||
|
objects = (live or {}).get("objects") or []
|
||||||
|
if isinstance(objects, str):
|
||||||
|
try:
|
||||||
|
objects = json.loads(objects)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
objects = []
|
||||||
|
used_g, source = usage.resolve_used_grams(
|
||||||
|
planned, used_mm, total_mm, progress, state, job.get("print_duration") or 0
|
||||||
|
)
|
||||||
row = store.upsert_run(conn, {
|
row = store.upsert_run(conn, {
|
||||||
"printer_host": printer["host"],
|
"printer_host": printer["host"],
|
||||||
"printer_name": printer["name"],
|
"printer_name": printer["name"],
|
||||||
@@ -215,13 +358,33 @@ def sync_history(conn, cfg):
|
|||||||
"started_at": unix_to_dt(job.get("start_time")),
|
"started_at": unix_to_dt(job.get("start_time")),
|
||||||
"ended_at": unix_to_dt(job.get("end_time")),
|
"ended_at": unix_to_dt(job.get("end_time")),
|
||||||
"duration_s": float(job.get("print_duration") or job.get("total_duration") or 0),
|
"duration_s": float(job.get("print_duration") or job.get("total_duration") or 0),
|
||||||
"weight_g": weight,
|
"weight_g": planned,
|
||||||
|
"weight_planned_g": planned,
|
||||||
|
"weight_used_g": used_g,
|
||||||
|
"weight_source": source,
|
||||||
|
"filament_used_mm": used_mm,
|
||||||
|
"filament_total_mm": total_mm,
|
||||||
|
"progress_last": progress,
|
||||||
|
"estimated_s": estimated,
|
||||||
"energy_cost": energy_cost(job.get("print_duration") or job.get("total_duration") or 0, cfg["energy"]),
|
"energy_cost": energy_cost(job.get("print_duration") or job.get("total_duration") or 0, cfg["energy"]),
|
||||||
"moonraker_job_id": job_id,
|
"moonraker_job_id": job_id,
|
||||||
"needs_spool": bool(terminal and weight > 0 and not spool),
|
"needs_spool": bool(terminal and used_g > 0 and not spool),
|
||||||
"material_type": material,
|
"material_type": material,
|
||||||
|
"message": (job.get("message") or "") if state == "error" else "",
|
||||||
|
"objects": objects,
|
||||||
})
|
})
|
||||||
if apply_deduction(conn, row, cfg["energy"]):
|
# Live sofort auf History hängen — auch solange noch in_progress (eine Protokoll-Zeile)
|
||||||
|
store.adopt_live_run(conn, printer["host"], filename, row["id"])
|
||||||
|
row = store.get_run_row(conn, row["id"])
|
||||||
|
if terminal:
|
||||||
|
# Nachzug nicht während eines neuen Live-Drucks — sonst „Platte belegt“ zu Unrecht
|
||||||
|
active = store.find_live_run(conn, printer["host"])
|
||||||
|
if not active:
|
||||||
|
ended = row.get("ended_at") or unix_to_dt(job.get("end_time"))
|
||||||
|
store.ensure_bed_after_print(
|
||||||
|
conn, printer["host"], ended_at=ended, message=filename or state
|
||||||
|
)
|
||||||
|
if apply_deduction(conn, row, cfg["energy"], metadata=metadata):
|
||||||
deducted += 1
|
deducted += 1
|
||||||
return {"gelesen": len(collected), "abgezogen": deducted}
|
return {"gelesen": len(collected), "abgezogen": deducted}
|
||||||
|
|
||||||
@@ -373,7 +536,11 @@ def send_file(
|
|||||||
filaments = [{"type": "PLA", "color": ""}]
|
filaments = [{"type": "PLA", "color": ""}]
|
||||||
|
|
||||||
progress("vorbereiten", 30, "Datei vorbereiten…")
|
progress("vorbereiten", 30, "Datei vorbereiten…")
|
||||||
filename, content, plate = library.printer_file(row)
|
filename, content, plate = library.printer_file(
|
||||||
|
row, conn=conn, data_dir=(cfg or {}).get("data_dir")
|
||||||
|
)
|
||||||
|
# CJK/Unicode im Namen → Rinkhals 10115 not found (Upload ≠ Start-Name)
|
||||||
|
filename = moonraker.safe_upload_name(filename)
|
||||||
ace_tools = [item.get("paint_index") for item in mapping] if mapping else None
|
ace_tools = [item.get("paint_index") for item in mapping] if mapping else None
|
||||||
if (row.get("kind") or "") == "gcode" and str(filename).endswith(".gcode"):
|
if (row.get("kind") or "") == "gcode" and str(filename).endswith(".gcode"):
|
||||||
content = prepare_rinkhals_gcode(content, tools=ace_tools)
|
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)
|
printer = store.printer_by_host(conn, host)
|
||||||
if not printer:
|
if not printer:
|
||||||
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
|
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
|
||||||
|
params = dict(params) if isinstance(params, dict) else {}
|
||||||
|
if action == "slot_setzen":
|
||||||
|
try:
|
||||||
|
ace_slot = int(params.get("ace_slot"))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise GrottiError("ACE-Platz muss 1–8 sein.", "platz") from exc
|
||||||
|
spool = None
|
||||||
|
for row in store.spools_for_printer(conn, host):
|
||||||
|
raw_slot = row.get("ace_slot")
|
||||||
|
if raw_slot in (None, ""):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if int(raw_slot) == ace_slot:
|
||||||
|
spool = row
|
||||||
|
break
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if not spool:
|
||||||
|
raise GrottiError("Auf diesem Platz liegt keine Spule in Grotti.", "spule")
|
||||||
|
material = (spool.get("material_type") or "").strip()
|
||||||
|
if not material:
|
||||||
|
raise GrottiError("Die Spule braucht eine Sorte.", "material")
|
||||||
|
color = moonraker.spool_rgb(spool)
|
||||||
|
if not color:
|
||||||
|
raise GrottiError("Die Spule braucht eine Farbe.", "farbe")
|
||||||
|
hub_id, index = moonraker.ace_hub_index(ace_slot)
|
||||||
|
params = {"id": hub_id, "index": index, "type": material, "color": color}
|
||||||
try:
|
try:
|
||||||
moonraker.steuern(host, action, params)
|
moonraker.steuern(host, action, params)
|
||||||
except moonraker.PrinterError as exc:
|
except moonraker.PrinterError as exc:
|
||||||
|
|||||||
+27
-5
@@ -142,8 +142,30 @@ def ensure_library_gcode(conn, cfg, host, filename):
|
|||||||
return save_upload(conn, name, content, cfg)
|
return save_upload(conn, name, content, cfg)
|
||||||
|
|
||||||
|
|
||||||
def printer_file(row):
|
def resolve_stored_path(conn, row, data_dir=None):
|
||||||
path = Path(row["stored_path"])
|
"""Gespeicherter Pfad, ggf. nach Umzug von data_dir unter eingang/<name> wiederfinden."""
|
||||||
|
stored = (row or {}).get("stored_path") or ""
|
||||||
|
path = Path(stored) if stored else None
|
||||||
|
if path and path.is_file():
|
||||||
|
return path
|
||||||
|
name = (row or {}).get("name") or (path.name if path else "")
|
||||||
|
if not name or data_dir is None:
|
||||||
|
return None
|
||||||
|
candidate = Path(data_dir) / "eingang" / name
|
||||||
|
if not candidate.is_file():
|
||||||
|
return None
|
||||||
|
resolved = str(candidate.resolve())
|
||||||
|
file_id = (row or {}).get("id")
|
||||||
|
if file_id and conn is not None:
|
||||||
|
conn.execute("UPDATE files SET stored_path = %s WHERE id = %s", (resolved, file_id))
|
||||||
|
row["stored_path"] = resolved
|
||||||
|
return candidate.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def printer_file(row, conn=None, data_dir=None):
|
||||||
|
path = resolve_stored_path(conn, row, data_dir) if (conn is not None or data_dir is not None) else Path(row["stored_path"])
|
||||||
|
if path is None:
|
||||||
|
path = Path(row.get("stored_path") or "")
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
|
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
|
||||||
if row["kind"] == "gcode":
|
if row["kind"] == "gcode":
|
||||||
@@ -153,9 +175,9 @@ def printer_file(row):
|
|||||||
return path.name, path.read_bytes(), row["plate"] or ""
|
return path.name, path.read_bytes(), row["plate"] or ""
|
||||||
|
|
||||||
|
|
||||||
def preview_png(row):
|
def preview_png(row, conn=None, data_dir=None):
|
||||||
path = Path(row["stored_path"])
|
path = resolve_stored_path(conn, row, data_dir) if (conn is not None or data_dir is not None) else Path(row["stored_path"])
|
||||||
if not path.is_file():
|
if path is None or not path.is_file():
|
||||||
return None
|
return None
|
||||||
entry = row["thumb_entry"]
|
entry = row["thumb_entry"]
|
||||||
if row["kind"] == "gcode":
|
if row["kind"] == "gcode":
|
||||||
|
|||||||
+201
-6
@@ -165,18 +165,53 @@ def post_json(host, path, payload, timeout=20):
|
|||||||
raise PrinterError("Der Drucker ist nicht erreichbar.") from exc
|
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):
|
def multipart(filename, content):
|
||||||
boundary = "----GrottiBoundary7f3a"
|
boundary = "----GrottiBoundary7f3a"
|
||||||
ascii_name = "".join(ch if ch.isascii() and ch not in '"\\' else "_" for ch in filename) or "job.gcode"
|
name = safe_upload_name(filename)
|
||||||
quoted = urllib.parse.quote(filename)
|
|
||||||
fields = (
|
fields = (
|
||||||
"--%s\r\nContent-Disposition: form-data; name=\"root\"\r\n\r\ngcodes\r\n" % boundary
|
"--%s\r\nContent-Disposition: form-data; name=\"root\"\r\n\r\ngcodes\r\n" % boundary
|
||||||
).encode("ascii")
|
).encode("ascii")
|
||||||
head = (
|
head = (
|
||||||
"--%s\r\n"
|
"--%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"
|
"Content-Type: application/octet-stream\r\n\r\n"
|
||||||
) % (boundary, ascii_name, quoted)
|
) % (boundary, name)
|
||||||
tail = ("\r\n--%s--\r\n" % boundary).encode("ascii")
|
tail = ("\r\n--%s--\r\n" % boundary).encode("ascii")
|
||||||
return boundary, fields + head.encode("ascii") + content + tail
|
return boundary, fields + head.encode("ascii") + content + tail
|
||||||
|
|
||||||
@@ -217,6 +252,7 @@ def upload_timeout(size):
|
|||||||
|
|
||||||
|
|
||||||
def upload(host, filename, content, on_progress=None):
|
def upload(host, filename, content, on_progress=None):
|
||||||
|
filename = safe_upload_name(filename)
|
||||||
boundary, body = multipart(filename, content)
|
boundary, body = multipart(filename, content)
|
||||||
stream = _UploadBody(body, on_progress=on_progress)
|
stream = _UploadBody(body, on_progress=on_progress)
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
@@ -236,7 +272,8 @@ def upload(host, filename, content, on_progress=None):
|
|||||||
except (urllib.error.URLError, TimeoutError) as exc:
|
except (urllib.error.URLError, TimeoutError) as exc:
|
||||||
raise PrinterError("Hochladen ist fehlgeschlagen.") from exc
|
raise PrinterError("Hochladen ist fehlgeschlagen.") from exc
|
||||||
item = (payload.get("result") or {}).get("item") or {}
|
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):
|
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):
|
if not re.fullmatch(r"[A-Za-z0-9._+\-]+", name):
|
||||||
raise PrinterError("Ungültiger Objektname.")
|
raise PrinterError("Ungültiger Objektname.")
|
||||||
return gcode_script(host, "EXCLUDE_OBJECT NAME=%s" % name, timeout=20)
|
return gcode_script(host, "EXCLUDE_OBJECT NAME=%s" % name, timeout=20)
|
||||||
|
if action == "dryer_start":
|
||||||
|
try:
|
||||||
|
unit = int(params.get("id"))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise PrinterError("ACE-Box muss 0 oder 1 sein.") from exc
|
||||||
|
if unit not in (0, 1):
|
||||||
|
raise PrinterError("ACE-Box muss 0 oder 1 sein.")
|
||||||
|
try:
|
||||||
|
temp = int(round(float(params.get("temp"))))
|
||||||
|
duration = int(round(float(params.get("duration"))))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise PrinterError("Temperatur und Dauer müssen Zahlen sein.") from exc
|
||||||
|
if temp < 35 or temp > 65:
|
||||||
|
raise PrinterError("Trockner-Temperatur außerhalb 35–65 °C.")
|
||||||
|
if duration < 1 or duration > 720:
|
||||||
|
raise PrinterError("Trockner-Dauer außerhalb 1–720 Minuten.")
|
||||||
|
return post_json(
|
||||||
|
host,
|
||||||
|
"/server/filament_hub/start_drying",
|
||||||
|
{"id": unit, "temp": temp, "duration": duration, "fan_speed": 0},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
if action == "dryer_stop":
|
||||||
|
try:
|
||||||
|
unit = int(params.get("id"))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise PrinterError("ACE-Box muss 0 oder 1 sein.") from exc
|
||||||
|
if unit not in (0, 1):
|
||||||
|
raise PrinterError("ACE-Box muss 0 oder 1 sein.")
|
||||||
|
return post_json(host, "/server/filament_hub/stop_drying", {"id": unit}, timeout=15)
|
||||||
|
if action == "slot_setzen":
|
||||||
|
hub_id = _int_or_none(params.get("id"))
|
||||||
|
index = _int_or_none(params.get("index"))
|
||||||
|
if hub_id not in (0, 1) or index is None or index < 0 or index > 3:
|
||||||
|
raise PrinterError("ACE-Platz ungültig.")
|
||||||
|
material = str(params.get("type") or "").strip()
|
||||||
|
if not material:
|
||||||
|
raise PrinterError("Die Spule braucht eine Sorte.")
|
||||||
|
color = params.get("color")
|
||||||
|
if not isinstance(color, dict):
|
||||||
|
raise PrinterError("Die Spule braucht eine Farbe.")
|
||||||
|
try:
|
||||||
|
red = max(0, min(255, int(color.get("R"))))
|
||||||
|
green = max(0, min(255, int(color.get("G"))))
|
||||||
|
blue = max(0, min(255, int(color.get("B"))))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise PrinterError("Die Spule braucht eine Farbe.") from exc
|
||||||
|
return post_json(
|
||||||
|
host,
|
||||||
|
"/printer/filament_hub/set_filament_info",
|
||||||
|
{
|
||||||
|
"id": hub_id,
|
||||||
|
"index": index,
|
||||||
|
"type": material,
|
||||||
|
"color": {"R": red, "G": green, "B": blue},
|
||||||
|
},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
if action == "firmware_restart":
|
if action == "firmware_restart":
|
||||||
# GoKlipper/Rinkhals: FIRMWARE_RESTART deadlocked gklib (Rinkhals #32).
|
# GoKlipper/Rinkhals: FIRMWARE_RESTART deadlocked gklib (Rinkhals #32).
|
||||||
raise PrinterError(
|
raise PrinterError(
|
||||||
@@ -563,6 +658,80 @@ def extract_slots(status):
|
|||||||
return found[:8]
|
return found[:8]
|
||||||
|
|
||||||
|
|
||||||
|
def _int_or_none(value):
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
|
return None
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_dryers(status):
|
||||||
|
"""Echte ACE-Boxen aus mmu_machine (num_gates > 0), Dummy-Unit bleibt weg."""
|
||||||
|
machine = (status or {}).get("mmu_machine")
|
||||||
|
if not isinstance(machine, dict):
|
||||||
|
return []
|
||||||
|
found = []
|
||||||
|
for unit_id in (0, 1):
|
||||||
|
unit = machine.get("unit_%d" % unit_id)
|
||||||
|
if not isinstance(unit, dict):
|
||||||
|
continue
|
||||||
|
gates = _int_or_none(unit.get("num_gates"))
|
||||||
|
if gates is None or gates < 1:
|
||||||
|
continue
|
||||||
|
found.append({
|
||||||
|
"id": unit_id,
|
||||||
|
"status": str(unit.get("dryer_status") or "stop"),
|
||||||
|
"temp": _int_or_none(unit.get("dryer_temp")) or 0,
|
||||||
|
"target": _int_or_none(unit.get("dryer_target_temp")) or 0,
|
||||||
|
"remain": _int_or_none(unit.get("dryer_remaining")) or 0,
|
||||||
|
"humidity": _int_or_none(unit.get("dryer_humidity")) or 0,
|
||||||
|
"gates": gates,
|
||||||
|
})
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def ace_hub_index(ace_slot):
|
||||||
|
"""Grotti-Platz 1–8 → Moonraker id (0/1) und index in der Box (0–3)."""
|
||||||
|
try:
|
||||||
|
slot = int(ace_slot)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise PrinterError("ACE-Platz muss 1–8 sein.") from exc
|
||||||
|
if slot < 1 or slot > 8:
|
||||||
|
raise PrinterError("ACE-Platz muss 1–8 sein.")
|
||||||
|
return (slot - 1) // 4, (slot - 1) % 4
|
||||||
|
|
||||||
|
|
||||||
|
def spool_rgb(spool):
|
||||||
|
"""Erste Farbe der Spule als RGB-Dict für set_filament_info."""
|
||||||
|
colors = (spool or {}).get("colors") or []
|
||||||
|
if isinstance(colors, str):
|
||||||
|
try:
|
||||||
|
colors = json.loads(colors)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
colors = []
|
||||||
|
hex_value = ""
|
||||||
|
if isinstance(colors, list):
|
||||||
|
for item in colors:
|
||||||
|
if isinstance(item, dict) and item.get("hex"):
|
||||||
|
hex_value = str(item["hex"]).strip()
|
||||||
|
break
|
||||||
|
if isinstance(item, str) and item.strip():
|
||||||
|
hex_value = item.strip()
|
||||||
|
break
|
||||||
|
if not hex_value:
|
||||||
|
hex_value = str((spool or {}).get("color") or "").strip()
|
||||||
|
if hex_value.startswith("#"):
|
||||||
|
hex_value = hex_value[1:]
|
||||||
|
if len(hex_value) == 8:
|
||||||
|
hex_value = hex_value[:6]
|
||||||
|
if len(hex_value) != 6 or any(ch not in "0123456789abcdefABCDEF" for ch in hex_value):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"R": int(hex_value[0:2], 16),
|
||||||
|
"G": int(hex_value[2:4], 16),
|
||||||
|
"B": int(hex_value[4:6], 16),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def slots(host, status=None):
|
def slots(host, status=None):
|
||||||
if status is not None:
|
if status is not None:
|
||||||
found = extract_slots(status)
|
found = extract_slots(status)
|
||||||
@@ -831,6 +1000,8 @@ def query(host):
|
|||||||
extras = []
|
extras = []
|
||||||
if "probe" in names:
|
if "probe" in names:
|
||||||
extras.append("probe")
|
extras.append("probe")
|
||||||
|
if "mmu_machine" in names:
|
||||||
|
extras.append("mmu_machine")
|
||||||
url = "http://%s/printer/objects/query?%s" % (host, "&".join([base] + extras))
|
url = "http://%s/printer/objects/query?%s" % (host, "&".join([base] + extras))
|
||||||
payload = fetch_json(url)
|
payload = fetch_json(url)
|
||||||
status = payload["result"]["status"]
|
status = payload["result"]["status"]
|
||||||
@@ -838,6 +1009,21 @@ def query(host):
|
|||||||
card = status.get("virtual_sdcard") or {}
|
card = status.get("virtual_sdcard") or {}
|
||||||
extruder = status.get("extruder") or {}
|
extruder = status.get("extruder") or {}
|
||||||
bed = status.get("heater_bed") or {}
|
bed = status.get("heater_bed") or {}
|
||||||
|
mmu = status.get("mmu") or {}
|
||||||
|
mmu_tool = mmu.get("tool")
|
||||||
|
if not isinstance(mmu_tool, (int, float)) or isinstance(mmu_tool, bool):
|
||||||
|
mmu_tool = mmu.get("gate")
|
||||||
|
if isinstance(mmu_tool, (int, float)) and not isinstance(mmu_tool, bool):
|
||||||
|
mmu_tool = int(mmu_tool)
|
||||||
|
if mmu_tool < 0:
|
||||||
|
mmu_tool = None
|
||||||
|
else:
|
||||||
|
mmu_tool = None
|
||||||
|
filament_used = stats.get("filament_used")
|
||||||
|
if not isinstance(filament_used, (int, float)) or isinstance(filament_used, bool):
|
||||||
|
filament_used = None
|
||||||
|
else:
|
||||||
|
filament_used = float(filament_used)
|
||||||
raw_name = stats.get("filename") or ""
|
raw_name = stats.get("filename") or ""
|
||||||
filename = raw_name.replace("\\", "/").rsplit("/", 1)[-1] if raw_name else ""
|
filename = raw_name.replace("\\", "/").rsplit("/", 1)[-1] if raw_name else ""
|
||||||
meta = metadata(host, raw_name) if raw_name else {}
|
meta = metadata(host, raw_name) if raw_name else {}
|
||||||
@@ -896,8 +1082,15 @@ def query(host):
|
|||||||
"bed_target": bed.get("target"),
|
"bed_target": bed.get("target"),
|
||||||
"chamber": chamber,
|
"chamber": chamber,
|
||||||
"chamber_target": chamber_target,
|
"chamber_target": chamber_target,
|
||||||
|
"filament_used": filament_used,
|
||||||
|
"filament_total": meta.get("filament_total") or 0,
|
||||||
|
"mmu_tool": mmu_tool,
|
||||||
"material": card.get("filament_type") or meta.get("filament_type") or "",
|
"material": card.get("filament_type") or meta.get("filament_type") or "",
|
||||||
"weight": meta.get("filament_weight_total") or 0,
|
"weight": (
|
||||||
|
sum(float(x or 0) for x in meta["filament_weight_total"])
|
||||||
|
if isinstance(meta.get("filament_weight_total"), list)
|
||||||
|
else (meta.get("filament_weight_total") or 0)
|
||||||
|
),
|
||||||
"height": meta.get("object_height") or 0,
|
"height": meta.get("object_height") or 0,
|
||||||
"layer_height": meta.get("layer_height") or card.get("layer_height") or 0,
|
"layer_height": meta.get("layer_height") or card.get("layer_height") or 0,
|
||||||
"estimated": meta.get("estimated_time") or 0,
|
"estimated": meta.get("estimated_time") or 0,
|
||||||
@@ -915,6 +1108,7 @@ def query(host):
|
|||||||
"speed_factor": speed_factor,
|
"speed_factor": speed_factor,
|
||||||
"extrude_factor": extrude_factor,
|
"extrude_factor": extrude_factor,
|
||||||
"slots": slots(host, status),
|
"slots": slots(host, status),
|
||||||
|
"dryers": extract_dryers(status),
|
||||||
"exclude": exclude_status(host),
|
"exclude": exclude_status(host),
|
||||||
"anycubic_task": _anycubic_task_field(host),
|
"anycubic_task": _anycubic_task_field(host),
|
||||||
}
|
}
|
||||||
@@ -956,6 +1150,7 @@ def snapshot(printers):
|
|||||||
"speed_factor": None,
|
"speed_factor": None,
|
||||||
"extrude_factor": None,
|
"extrude_factor": None,
|
||||||
"slots": [],
|
"slots": [],
|
||||||
|
"dryers": [],
|
||||||
"anycubic_task": None,
|
"anycubic_task": None,
|
||||||
})
|
})
|
||||||
card.update(mesh_fields(printer["host"]))
|
card.update(mesh_fields(printer["host"]))
|
||||||
|
|||||||
+2
-2
@@ -43,11 +43,11 @@ def alert_enabled(cfg, kind):
|
|||||||
return bool(alerts.get(key, key not in DEFAULT_OFF))
|
return bool(alerts.get(key, key not in DEFAULT_OFF))
|
||||||
|
|
||||||
|
|
||||||
def record(conn, cfg, host, printer, kind, message, filename="", dedupe_s=60):
|
def record(conn, cfg, host, printer, kind, message, filename="", dedupe_s=60, run_id=None):
|
||||||
"""Ereignis immer speichern; Telegram nur einmal pro Art und Drucker im Zeitfenster."""
|
"""Ereignis immer speichern; Telegram nur einmal pro Art und Drucker im Zeitfenster."""
|
||||||
from grotti import store
|
from grotti import store
|
||||||
|
|
||||||
recent = store.recent_event_kind(conn, host, kind, within_s=dedupe_s)
|
recent = store.recent_event_kind(conn, host, kind, within_s=dedupe_s)
|
||||||
store.add_event(conn, host, printer, kind, message, filename or "")
|
store.add_event(conn, host, printer, kind, message, filename or "", run_id=run_id)
|
||||||
if not recent and alert_enabled(cfg, kind):
|
if not recent and alert_enabled(cfg, kind):
|
||||||
send_telegram(cfg, message)
|
send_telegram(cfg, message)
|
||||||
@@ -215,3 +215,86 @@ CREATE TABLE IF NOT EXISTS oauth_identities (
|
|||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
UNIQUE (provider, subject)
|
UNIQUE (provider, subject)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Druckbericht: Soll/Ist, Charge, Live-Fortschritt
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_planned_g double precision NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_used_g double precision NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_source text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS filament_used_mm double precision NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS filament_total_mm double precision NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS progress_last double precision NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS estimated_s double precision NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS chargen_nr text;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS message text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS objects jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS runs_chargen_nr
|
||||||
|
ON runs (chargen_nr) WHERE chargen_nr IS NOT NULL AND chargen_nr <> '';
|
||||||
|
|
||||||
|
ALTER TABLE events ADD COLUMN IF NOT EXISTS run_id bigint REFERENCES runs (id) ON DELETE SET NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS events_run ON events (run_id) WHERE run_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Abzug je Spule (Messwert oder Schätzwert)
|
||||||
|
CREATE TABLE IF NOT EXISTS run_spools (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
|
||||||
|
spool_id bigint NOT NULL REFERENCES spools (id) ON DELETE RESTRICT,
|
||||||
|
ace_slot smallint,
|
||||||
|
weight_g double precision NOT NULL DEFAULT 0,
|
||||||
|
weight_planned_g double precision NOT NULL DEFAULT 0,
|
||||||
|
cost double precision NOT NULL DEFAULT 0,
|
||||||
|
is_estimate boolean NOT NULL DEFAULT false,
|
||||||
|
UNIQUE (run_id, spool_id, ace_slot)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS run_spools_spool ON run_spools (spool_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS run_spools_run ON run_spools (run_id);
|
||||||
|
|
||||||
|
-- Abschnitte bei Rollenwechsel mitten im Druck
|
||||||
|
CREATE TABLE IF NOT EXISTS run_segments (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
|
||||||
|
spool_id bigint NOT NULL REFERENCES spools (id) ON DELETE RESTRICT,
|
||||||
|
ace_slot smallint,
|
||||||
|
mm_start double precision NOT NULL DEFAULT 0,
|
||||||
|
mm_end double precision,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
closed_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS run_segments_run ON run_segments (run_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS run_segments_open
|
||||||
|
ON run_segments (run_id) WHERE mm_end IS NULL;
|
||||||
|
|
||||||
|
-- Temperatur / Fortschritt während des Drucks
|
||||||
|
CREATE TABLE IF NOT EXISTS run_samples (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
|
||||||
|
sampled_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
nozzle double precision,
|
||||||
|
nozzle_target double precision,
|
||||||
|
bed double precision,
|
||||||
|
bed_target double precision,
|
||||||
|
chamber double precision,
|
||||||
|
fan double precision,
|
||||||
|
speed_factor double precision,
|
||||||
|
extrude_factor double precision,
|
||||||
|
layer integer,
|
||||||
|
progress double precision,
|
||||||
|
filament_used_mm double precision,
|
||||||
|
mmu_tool smallint
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS run_samples_run_time ON run_samples (run_id, sampled_at);
|
||||||
|
|
||||||
|
-- Objekt-Chargen für Etiketten
|
||||||
|
CREATE TABLE IF NOT EXISTS run_labels (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
|
||||||
|
object_name text NOT NULL DEFAULT '',
|
||||||
|
chargen_nr text NOT NULL,
|
||||||
|
excluded boolean NOT NULL DEFAULT false,
|
||||||
|
UNIQUE (run_id, object_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS run_labels_chargen ON run_labels (chargen_nr);
|
||||||
+1077
-14
File diff suppressed because it is too large.
Load diff
+70
-29
@@ -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)
|
# Auch E.39 / Z.6 ohne führende Null (Anycubic/Prusa-Stil)
|
||||||
AXIS = re.compile(r"([XYZE])\s*(-?\d*\.?\d+)", re.IGNORECASE)
|
AXIS = re.compile(r"([XYZE])\s*(-?\d*\.?\d+)", re.IGNORECASE)
|
||||||
|
|
||||||
# Cache-/Viewer-Version: Segment-IDs, kein Sehnen-Thinning
|
# Cache-/Viewer-Version: ganze Z-Höhen dicht behalten (keine gesprenkelten Stichproben)
|
||||||
TOOLPATH_VERSION = 5
|
TOOLPATH_VERSION = 8
|
||||||
|
|
||||||
|
|
||||||
def _gcode_text(path: Path) -> str:
|
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")
|
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):
|
def _thin_points(points, layer_breaks, max_points):
|
||||||
"""Extrusions-Segmente stichprobenartig behalten — nie Endpunkte einer Bahn
|
"""Pfad kürzen, ohne Wände zu sprenkeln.
|
||||||
weglassen und die Nachbarn verbinden (sonst Sehnen statt Infill)."""
|
|
||||||
|
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:
|
if len(points) <= max_points:
|
||||||
return points, layer_breaks
|
return points, layer_breaks
|
||||||
|
|
||||||
extrude_ends = [i for i in range(1, len(points)) if points[i][3]]
|
# Nur Z-Höhen mit Extrusion — Z-Hops allein zählen nicht
|
||||||
keep = {0, len(points) - 1}
|
z_order = []
|
||||||
for break_i in layer_breaks:
|
seen = set()
|
||||||
keep.add(max(0, min(len(points) - 1, break_i)))
|
for point in points:
|
||||||
if break_i > 0:
|
if not point[3]:
|
||||||
keep.add(break_i - 1)
|
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)
|
def keep_for_zs(keep_zs):
|
||||||
target_segs = max(2000, max_points // 2)
|
keep = {0, len(points) - 1}
|
||||||
step = max(1, (len(extrude_ends) + target_segs - 1) // target_segs)
|
for i, point in enumerate(points):
|
||||||
for j, end_i in enumerate(extrude_ends):
|
if _z_key(point[2]) in keep_zs:
|
||||||
if j % step == 0:
|
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 - 1)
|
||||||
keep.add(end_i)
|
keep.add(end_i)
|
||||||
|
ordered = sorted(keep)
|
||||||
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)}
|
index_map = {old: new for new, old in enumerate(ordered)}
|
||||||
kept = [points[i] for i in ordered]
|
kept = [points[i] for i in ordered]
|
||||||
new_breaks = sorted({
|
new_breaks = []
|
||||||
index_map[b]
|
last_z = None
|
||||||
for b in layer_breaks
|
for new_i, point in enumerate(kept):
|
||||||
if b in index_map
|
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:
|
if not new_breaks:
|
||||||
new_breaks = [0]
|
new_breaks = [0]
|
||||||
return kept, new_breaks
|
return kept, new_breaks
|
||||||
|
|
||||||
|
|
||||||
def build_toolpath(path: Path, max_points=120000):
|
def build_toolpath(path: Path, max_points=220000):
|
||||||
text = _gcode_text(path)
|
text = _gcode_text(path)
|
||||||
if not text:
|
if not text:
|
||||||
return {
|
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 von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
|
||||||
|
|
||||||
__version__ = "2.2.0"
|
__version__ = "2.5.1"
|
||||||
+152
-20
@@ -4,10 +4,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from grotti import moonraker, notify, store
|
from grotti import moonraker, notify, store, usage
|
||||||
from grotti.db import connect
|
from grotti.db import connect
|
||||||
|
|
||||||
|
SAMPLE_EVERY_S = 30
|
||||||
|
|
||||||
|
|
||||||
class StateWatcher:
|
class StateWatcher:
|
||||||
def __init__(self, database_url, cfg, interval=10):
|
def __init__(self, database_url, cfg, interval=10):
|
||||||
@@ -21,6 +24,7 @@ class StateWatcher:
|
|||||||
self._updated_at = 0.0
|
self._updated_at = 0.0
|
||||||
self._stop = threading.Event()
|
self._stop = threading.Event()
|
||||||
self._thread = None
|
self._thread = None
|
||||||
|
self._last_sample = {}
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
if self._thread and self._thread.is_alive():
|
if self._thread and self._thread.is_alive():
|
||||||
@@ -79,6 +83,7 @@ class StateWatcher:
|
|||||||
prev = self._prev.get(host) or {}
|
prev = self._prev.get(host) or {}
|
||||||
prev_state = (prev.get("state") or "").lower()
|
prev_state = (prev.get("state") or "").lower()
|
||||||
prev_offline = bool(prev.get("offline"))
|
prev_offline = bool(prev.get("offline"))
|
||||||
|
run_id = None
|
||||||
|
|
||||||
if offline:
|
if offline:
|
||||||
hits = self._offline_hits.get(host, 0) + 1
|
hits = self._offline_hits.get(host, 0) + 1
|
||||||
@@ -89,16 +94,32 @@ class StateWatcher:
|
|||||||
return
|
return
|
||||||
|
|
||||||
self._offline_hits[host] = 0
|
self._offline_hits[host] = 0
|
||||||
|
starting = (
|
||||||
|
bool(prev_state)
|
||||||
|
and prev_state not in ("printing", "paused")
|
||||||
|
and state == "printing"
|
||||||
|
)
|
||||||
|
if starting:
|
||||||
|
store.abandon_live_runs(conn, host)
|
||||||
|
if state in ("printing", "paused"):
|
||||||
|
run = self._track_print(conn, host, name, filename, live, state)
|
||||||
|
run_id = run["id"] if run else None
|
||||||
|
|
||||||
if prev_offline:
|
if prev_offline:
|
||||||
self._emit(conn, host, name, "offline", "%s ist wieder erreichbar." % name, filename)
|
self._emit(conn, host, name, "offline", "%s ist wieder erreichbar." % name, filename, run_id)
|
||||||
elif prev_state and prev_state not in ("printing", "paused") and state == "printing":
|
elif starting:
|
||||||
self._emit(conn, host, name, "start", "%s druckt %s" % (name, filename or "einen Auftrag"), filename)
|
self._emit(
|
||||||
|
conn, host, name, "start",
|
||||||
|
"%s druckt %s" % (name, filename or "einen Auftrag"),
|
||||||
|
filename, run_id,
|
||||||
|
)
|
||||||
|
self._ensure_start_segment(conn, host, filename, live, run_id)
|
||||||
elif prev_state == "printing" and state == "paused":
|
elif prev_state == "printing" and state == "paused":
|
||||||
self._emit(conn, host, name, "start", "%s pausiert." % name, filename)
|
self._emit(conn, host, name, "start", "%s pausiert." % name, filename, run_id)
|
||||||
elif prev_state == "paused" and state == "printing":
|
elif prev_state == "paused" and state == "printing":
|
||||||
self._emit(conn, host, name, "start", "%s druckt weiter." % name, filename)
|
self._emit(conn, host, name, "start", "%s druckt weiter." % name, filename, run_id)
|
||||||
elif prev_state == "printing" and state == "complete":
|
elif prev_state == "printing" and state == "complete":
|
||||||
store.set_bed_needs_clear(conn, host, True, filename or "fertig")
|
store.ensure_bed_after_print(conn, host, message=filename or "fertig")
|
||||||
self._emit(
|
self._emit(
|
||||||
conn,
|
conn,
|
||||||
host,
|
host,
|
||||||
@@ -106,8 +127,8 @@ class StateWatcher:
|
|||||||
"fertig",
|
"fertig",
|
||||||
"%s fertig: %s" % (name, filename or "Druck"),
|
"%s fertig: %s" % (name, filename or "Druck"),
|
||||||
filename,
|
filename,
|
||||||
|
run_id or (prev.get("run_id")),
|
||||||
)
|
)
|
||||||
# Protokoll und Spulenabzug nicht erst nach manuellem Knopf
|
|
||||||
try:
|
try:
|
||||||
from grotti import jobs
|
from grotti import jobs
|
||||||
|
|
||||||
@@ -115,7 +136,7 @@ class StateWatcher:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
||||||
elif prev_state in ("printing", "paused") and state in ("cancelled", "standby"):
|
elif prev_state in ("printing", "paused") and state in ("cancelled", "standby"):
|
||||||
store.set_bed_needs_clear(conn, host, True, filename or state)
|
store.ensure_bed_after_print(conn, host, message=filename or state)
|
||||||
self._emit(
|
self._emit(
|
||||||
conn,
|
conn,
|
||||||
host,
|
host,
|
||||||
@@ -123,10 +144,17 @@ class StateWatcher:
|
|||||||
"abbruch",
|
"abbruch",
|
||||||
"%s: Druck abgebrochen (%s)" % (name, filename or "ohne Datei"),
|
"%s: Druck abgebrochen (%s)" % (name, filename or "ohne Datei"),
|
||||||
filename,
|
filename,
|
||||||
|
run_id or prev.get("run_id"),
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
from grotti import jobs
|
||||||
|
|
||||||
|
jobs.sync_history(conn, self.cfg)
|
||||||
|
except Exception as exc:
|
||||||
|
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
||||||
elif state == "error" and prev_state != "error":
|
elif state == "error" and prev_state != "error":
|
||||||
if prev_state == "printing" or prev_state == "paused":
|
if prev_state == "printing" or prev_state == "paused":
|
||||||
store.set_bed_needs_clear(conn, host, True, filename or "Fehler")
|
store.ensure_bed_after_print(conn, host, message=filename or "Fehler")
|
||||||
msg = (live.get("message") or "").strip()
|
msg = (live.get("message") or "").strip()
|
||||||
if not msg or msg.lower() in ("error", "fehler"):
|
if not msg or msg.lower() in ("error", "fehler"):
|
||||||
msg = "Ursache unbekannt — in Mainsail prüfen"
|
msg = "Ursache unbekannt — in Mainsail prüfen"
|
||||||
@@ -138,18 +166,19 @@ class StateWatcher:
|
|||||||
"fehler",
|
"fehler",
|
||||||
"%s: %s · Heizung aus" % (name, msg),
|
"%s: %s · Heizung aus" % (name, msg),
|
||||||
filename,
|
filename,
|
||||||
|
run_id or prev.get("run_id"),
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
from grotti import jobs
|
||||||
|
|
||||||
|
jobs.sync_history(conn, self.cfg)
|
||||||
|
except Exception as exc:
|
||||||
|
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
||||||
elif state == "error":
|
elif state == "error":
|
||||||
# Schon im Fehler, Heizer laufen noch → aus
|
|
||||||
if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0:
|
if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0:
|
||||||
self._cool_heaters(host)
|
self._cool_heaters(host)
|
||||||
elif state == "complete":
|
elif state == "complete" and (not prev_state or prev_offline):
|
||||||
known = conn.execute(
|
store.ensure_bed_after_print(conn, host, message=filename or "fertig")
|
||||||
"SELECT 1 FROM bed_status WHERE host = %s", (host,)
|
|
||||||
).fetchone()
|
|
||||||
if not known:
|
|
||||||
# Erster Stand nach Deploy: fertiger Druck ohne Quittierung
|
|
||||||
store.set_bed_needs_clear(conn, host, True, filename or "fertig")
|
|
||||||
|
|
||||||
spool_warn = bool(prev.get("spool_warn"))
|
spool_warn = bool(prev.get("spool_warn"))
|
||||||
if state == "printing" and not spool_warn:
|
if state == "printing" and not spool_warn:
|
||||||
@@ -160,8 +189,108 @@ class StateWatcher:
|
|||||||
"offline": False,
|
"offline": False,
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
"spool_warn": spool_warn if state == "printing" else False,
|
"spool_warn": spool_warn if state == "printing" else False,
|
||||||
|
"run_id": run_id or (prev.get("run_id") if state in ("printing", "paused") else None),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _track_print(self, conn, host, name, filename, live, state):
|
||||||
|
sent = store.latest_send(conn, host, filename) if filename else None
|
||||||
|
spool_id = (sent or {}).get("spool_id")
|
||||||
|
run = store.ensure_live_run(conn, host, name, filename, spool_id=spool_id)
|
||||||
|
objects = self._merge_objects(run.get("objects"), live.get("exclude"))
|
||||||
|
planned = usage.as_float(live.get("weight"))
|
||||||
|
total_mm = usage.as_float(live.get("filament_total"))
|
||||||
|
used_mm = usage.as_float(live.get("filament_used"))
|
||||||
|
progress = usage.as_float(live.get("progress"))
|
||||||
|
estimated = usage.as_float(live.get("estimated"))
|
||||||
|
store.update_run_progress(conn, run["id"], {
|
||||||
|
"state": "paused" if state == "paused" else "in_progress",
|
||||||
|
"progress_last": progress,
|
||||||
|
"filament_used_mm": used_mm,
|
||||||
|
"filament_total_mm": total_mm,
|
||||||
|
"weight_planned_g": planned,
|
||||||
|
"estimated_s": estimated,
|
||||||
|
"message": (live.get("message") or "") if state == "error" else "",
|
||||||
|
"objects": objects,
|
||||||
|
"duration_s": usage.as_float(live.get("elapsed")),
|
||||||
|
})
|
||||||
|
store.ensure_run_chargen(conn, run["id"])
|
||||||
|
self._maybe_sample(conn, run["id"], live, used_mm)
|
||||||
|
return store.get_run_row(conn, run["id"])
|
||||||
|
|
||||||
|
def _merge_objects(self, current, exclude):
|
||||||
|
known = {}
|
||||||
|
if isinstance(current, str):
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
current = json.loads(current)
|
||||||
|
except Exception:
|
||||||
|
current = []
|
||||||
|
for item in current or []:
|
||||||
|
if isinstance(item, dict) and item.get("name"):
|
||||||
|
known[item["name"]] = {
|
||||||
|
"name": item["name"],
|
||||||
|
"excluded": bool(item.get("excluded")),
|
||||||
|
}
|
||||||
|
exclude = exclude or {}
|
||||||
|
excluded = set(exclude.get("excluded") or [])
|
||||||
|
for item in exclude.get("objects") or []:
|
||||||
|
name = (item.get("name") if isinstance(item, dict) else str(item)).strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
known[name] = {"name": name, "excluded": name in excluded}
|
||||||
|
for name in excluded:
|
||||||
|
if name not in known:
|
||||||
|
known[name] = {"name": name, "excluded": True}
|
||||||
|
return list(known.values())
|
||||||
|
|
||||||
|
def _maybe_sample(self, conn, run_id, live, used_mm):
|
||||||
|
now = time.monotonic()
|
||||||
|
last = self._last_sample.get(run_id, 0)
|
||||||
|
if now - last < SAMPLE_EVERY_S:
|
||||||
|
return
|
||||||
|
self._last_sample[run_id] = now
|
||||||
|
store.add_run_sample(conn, run_id, {
|
||||||
|
"sampled_at": datetime.now(timezone.utc),
|
||||||
|
"nozzle": live.get("nozzle"),
|
||||||
|
"nozzle_target": live.get("nozzle_target"),
|
||||||
|
"bed": live.get("bed"),
|
||||||
|
"bed_target": live.get("bed_target"),
|
||||||
|
"chamber": live.get("chamber"),
|
||||||
|
"fan": live.get("fan"),
|
||||||
|
"speed_factor": live.get("speed_factor"),
|
||||||
|
"extrude_factor": live.get("extrude_factor"),
|
||||||
|
"layer": live.get("layer") or 0,
|
||||||
|
"progress": live.get("progress"),
|
||||||
|
"filament_used_mm": used_mm,
|
||||||
|
"mmu_tool": live.get("mmu_tool"),
|
||||||
|
})
|
||||||
|
|
||||||
|
def _ensure_start_segment(self, conn, host, filename, live, run_id):
|
||||||
|
"""Nur externe Einzelspule: ein Abschnitt. ACE-Farben ohne Wechsel → Anteil-Split."""
|
||||||
|
if not run_id:
|
||||||
|
return
|
||||||
|
existing = store.list_run_segments(conn, run_id)
|
||||||
|
if existing:
|
||||||
|
return
|
||||||
|
sent = store.latest_send(conn, host, filename) if filename else None
|
||||||
|
ace = (sent or {}).get("ace_map") or []
|
||||||
|
from grotti import acm
|
||||||
|
|
||||||
|
mapping = acm.normalize_mapping(ace) if ace else []
|
||||||
|
if mapping:
|
||||||
|
return
|
||||||
|
used_mm = usage.as_float(live.get("filament_used"))
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM spools
|
||||||
|
WHERE printer_host = %s AND ace_slot IS NULL
|
||||||
|
ORDER BY id LIMIT 1
|
||||||
|
""",
|
||||||
|
(host,),
|
||||||
|
).fetchone()
|
||||||
|
if rows:
|
||||||
|
store.open_segment(conn, run_id, rows["id"], None, used_mm)
|
||||||
|
|
||||||
def _cool_heaters(self, host):
|
def _cool_heaters(self, host):
|
||||||
try:
|
try:
|
||||||
moonraker.heaters_off(host)
|
moonraker.heaters_off(host)
|
||||||
@@ -193,5 +322,8 @@ class StateWatcher:
|
|||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _emit(self, conn, host, printer, kind, message, filename):
|
def _emit(self, conn, host, printer, kind, message, filename, run_id=None):
|
||||||
notify.record(conn, self.cfg, host, printer, kind, message, filename, dedupe_s=60)
|
notify.record(
|
||||||
|
conn, self.cfg, host, printer, kind, message, filename,
|
||||||
|
dedupe_s=60, run_id=run_id,
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
+214
-11
@@ -37,7 +37,7 @@ IDEA_STATUS = {"offen", "spaeter", "erledigt"}
|
|||||||
PRINTER_FAMILIES = set(FAMILY_LABELS) | {""}
|
PRINTER_FAMILIES = set(FAMILY_LABELS) | {""}
|
||||||
STEER_ACTIONS = {
|
STEER_ACTIONS = {
|
||||||
"nozzle", "bed", "speed", "flow", "fan", "emergency_stop", "clear_error",
|
"nozzle", "bed", "speed", "flow", "fan", "emergency_stop", "clear_error",
|
||||||
"exclude_object",
|
"exclude_object", "dryer_start", "dryer_stop", "slot_setzen",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -519,7 +519,15 @@ def create_app(cfg):
|
|||||||
return labels.get(name, name.replace("Anleitung-", "").replace("-", " "))
|
return labels.get(name, name.replace("Anleitung-", "").replace("-", " "))
|
||||||
|
|
||||||
def render_markdown(path):
|
def render_markdown(path):
|
||||||
|
"""Markdown für die App: Git-relative Bild-/Kapitelpfade → App-URLs."""
|
||||||
text = path.read_text(encoding="utf-8")
|
text = path.read_text(encoding="utf-8")
|
||||||
|
# docs/… → web/static/… im Repo; unter /doku als /static/…
|
||||||
|
text = text.replace("../web/static/", "/static/")
|
||||||
|
text = re.sub(
|
||||||
|
r"\]\((Anleitung-[A-Za-z0-9_-]+)(?:\.md)?\)",
|
||||||
|
r"](/doku/\1)",
|
||||||
|
text,
|
||||||
|
)
|
||||||
html = markdown.markdown(text, extensions=["tables", "fenced_code", "sane_lists"])
|
html = markdown.markdown(text, extensions=["tables", "fenced_code", "sane_lists"])
|
||||||
return Markup(html)
|
return Markup(html)
|
||||||
|
|
||||||
@@ -572,6 +580,58 @@ def create_app(cfg):
|
|||||||
def protokoll():
|
def protokoll():
|
||||||
return page("protokoll.html", "Protokoll")
|
return page("protokoll.html", "Protokoll")
|
||||||
|
|
||||||
|
@app.get("/protokoll/<int:run_id>")
|
||||||
|
def protokoll_detail(run_id):
|
||||||
|
row = store.get_run_row(conn(), run_id)
|
||||||
|
if not row:
|
||||||
|
abort(404)
|
||||||
|
return page("bericht.html", "Druckbericht")
|
||||||
|
|
||||||
|
@app.get("/statistik")
|
||||||
|
def statistik_page():
|
||||||
|
return page("statistik.html", "Statistik")
|
||||||
|
|
||||||
|
@app.get("/etikett/lauf/<int:run_id>")
|
||||||
|
def etikett_lauf(run_id):
|
||||||
|
row = store.get_run_row(conn(), run_id)
|
||||||
|
if not row:
|
||||||
|
abort(404)
|
||||||
|
run = store.run_out(row)
|
||||||
|
if not run.get("chargen_nr"):
|
||||||
|
store.ensure_run_chargen(conn(), run_id, run.get("weight_used_g"))
|
||||||
|
row = store.get_run_row(conn(), run_id)
|
||||||
|
run = store.run_out(row)
|
||||||
|
if not run.get("chargen_nr"):
|
||||||
|
abort(404)
|
||||||
|
labels = store.list_run_labels(conn(), run_id)
|
||||||
|
mode = (request.args.get("mode") or "druck").strip()
|
||||||
|
qty = max(1, min(50, int(request.args.get("qty") or 1)))
|
||||||
|
if mode == "objekt":
|
||||||
|
labels = store.ensure_run_labels(conn(), run_id, run.get("objects") or [], qty=qty)
|
||||||
|
sheets = [lab for lab in labels if not lab.get("excluded")]
|
||||||
|
else:
|
||||||
|
sheets = [{"object_name": "", "chargen_nr": run["chargen_nr"], "excluded": False}]
|
||||||
|
if not sheets:
|
||||||
|
abort(404)
|
||||||
|
spools = store.etikett_spools_for_run(conn(), run)
|
||||||
|
base = grotti_config.public_base(cfg, request.url_root)
|
||||||
|
target_g = float(run.get("weight_planned_g") or run.get("weight_g") or 0)
|
||||||
|
for sheet in sheets:
|
||||||
|
sheet["qr_url"] = "%s/protokoll/%s" % (base, run_id)
|
||||||
|
sheet["target_g"] = target_g
|
||||||
|
if sheet.get("object_name"):
|
||||||
|
sheet["qr_url"] += "#objekt-%s" % quote(sheet["object_name"])
|
||||||
|
return render_template(
|
||||||
|
"etikett_lauf.html",
|
||||||
|
page="etikett",
|
||||||
|
title="Etikett %s" % run["chargen_nr"],
|
||||||
|
token="",
|
||||||
|
run=run,
|
||||||
|
sheets=sheets,
|
||||||
|
spools=spools,
|
||||||
|
target_g=target_g,
|
||||||
|
)
|
||||||
|
|
||||||
@app.get("/ideen")
|
@app.get("/ideen")
|
||||||
def ideen():
|
def ideen():
|
||||||
return page("ideen.html", "Ideen")
|
return page("ideen.html", "Ideen")
|
||||||
@@ -664,6 +724,9 @@ def create_app(cfg):
|
|||||||
item["queue_failed"] = queue.get("failed")
|
item["queue_failed"] = queue.get("failed")
|
||||||
item["queue_waiting"] = queue.get("waiting", 0)
|
item["queue_waiting"] = queue.get("waiting", 0)
|
||||||
item["queue_waiting_next"] = queue.get("waiting_next", "")
|
item["queue_waiting_next"] = queue.get("waiting_next", "")
|
||||||
|
live_run = store.find_live_run(conn(), host) if host else None
|
||||||
|
item["live_run_id"] = (live_run or {}).get("id")
|
||||||
|
item["live_chargen"] = (live_run or {}).get("chargen_nr") or ""
|
||||||
|
|
||||||
by_id = {row["host"]: row for row in rows}
|
by_id = {row["host"]: row for row in rows}
|
||||||
if request.args.get("live", "1") == "0":
|
if request.args.get("live", "1") == "0":
|
||||||
@@ -865,7 +928,7 @@ def create_app(cfg):
|
|||||||
row = store.get_file_row(conn(), file_id)
|
row = store.get_file_row(conn(), file_id)
|
||||||
if not row:
|
if not row:
|
||||||
abort(404)
|
abort(404)
|
||||||
png = library.preview_png(row)
|
png = library.preview_png(row, conn=conn(), data_dir=cfg["data_dir"])
|
||||||
if not png:
|
if not png:
|
||||||
abort(404)
|
abort(404)
|
||||||
return png, 200, {"Content-Type": "image/png", "Cache-Control": "private, max-age=300"}
|
return png, 200, {"Content-Type": "image/png", "Cache-Control": "private, max-age=300"}
|
||||||
@@ -875,8 +938,8 @@ def create_app(cfg):
|
|||||||
row = store.get_file_row(conn(), file_id)
|
row = store.get_file_row(conn(), file_id)
|
||||||
if not row:
|
if not row:
|
||||||
raise GrottiError("Die Datei fehlt.", "datei", 404)
|
raise GrottiError("Die Datei fehlt.", "datei", 404)
|
||||||
path = Path(row["stored_path"])
|
path = library.resolve_stored_path(conn(), row, cfg["data_dir"])
|
||||||
if not path.is_file():
|
if path is None or not path.is_file():
|
||||||
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
|
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
|
||||||
cache_dir = Path(cfg["data_dir"]) / "toolpath"
|
cache_dir = Path(cfg["data_dir"]) / "toolpath"
|
||||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -902,7 +965,10 @@ def create_app(cfg):
|
|||||||
pass
|
pass
|
||||||
data["file_id"] = file_id
|
data["file_id"] = file_id
|
||||||
data["name"] = row["name"]
|
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>")
|
@app.patch("/api/v1/dateien/<int:file_id>")
|
||||||
def file_patch(file_id):
|
def file_patch(file_id):
|
||||||
@@ -1495,11 +1561,28 @@ def create_app(cfg):
|
|||||||
|
|
||||||
@app.get("/api/v1/spulen/by-code/<path:code>")
|
@app.get("/api/v1/spulen/by-code/<path:code>")
|
||||||
def spool_by_code(code):
|
def spool_by_code(code):
|
||||||
row = store.get_spool_by_code(conn(), code)
|
text = (code or "").strip()
|
||||||
|
if text.upper().startswith("D-"):
|
||||||
|
run = store.get_run_by_chargen(conn(), text)
|
||||||
|
if not run:
|
||||||
|
raise GrottiError("Kein Druck mit dieser Charge.", "charge", 404)
|
||||||
|
return jsonify({"kind": "lauf", "run": store.run_out(run), "redirect": "/protokoll/%s" % run["id"]})
|
||||||
|
row = store.get_spool_by_code(conn(), text)
|
||||||
if not row:
|
if not row:
|
||||||
raise GrottiError("Keine Spule mit diesem Code.", "spule", 404)
|
raise GrottiError("Keine Spule mit diesem Code.", "spule", 404)
|
||||||
return jsonify(row)
|
return jsonify(row)
|
||||||
|
|
||||||
|
@app.get("/api/v1/spulen/<int:spool_id>/akte")
|
||||||
|
def spool_akte(spool_id):
|
||||||
|
spool = store.get_spool_row(conn(), spool_id)
|
||||||
|
if not spool:
|
||||||
|
raise GrottiError("Die Spule fehlt.", "spule", 404)
|
||||||
|
return jsonify({
|
||||||
|
"spool": store.spool_out(spool),
|
||||||
|
"runs": store.list_spool_runs(conn(), spool_id),
|
||||||
|
"stats": store.spool_usage_stats(conn(), spool_id),
|
||||||
|
})
|
||||||
|
|
||||||
@app.post("/api/v1/spulen")
|
@app.post("/api/v1/spulen")
|
||||||
def spool_create():
|
def spool_create():
|
||||||
return jsonify(store.create_spool(conn(), spool_fields(body()))), 201
|
return jsonify(store.create_spool(conn(), spool_fields(body()))), 201
|
||||||
@@ -1520,6 +1603,18 @@ def create_app(cfg):
|
|||||||
spool = store.get_spool_row(conn(), spool_id)
|
spool = store.get_spool_row(conn(), spool_id)
|
||||||
if not spool:
|
if not spool:
|
||||||
raise GrottiError("Die Spule fehlt.", "spule", 404)
|
raise GrottiError("Die Spule fehlt.", "spule", 404)
|
||||||
|
live_run = store.find_live_run(conn(), host)
|
||||||
|
mm_now = float((live_run or {}).get("filament_used_mm") or 0)
|
||||||
|
printing = bool(
|
||||||
|
live_run and (live_run.get("state") or "") in ("in_progress", "printing", "paused")
|
||||||
|
)
|
||||||
|
if printing:
|
||||||
|
try:
|
||||||
|
snap = moonraker.query(host)
|
||||||
|
if isinstance(snap.get("filament_used"), (int, float)):
|
||||||
|
mm_now = float(snap["filament_used"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
# Alte Rolle am Platz / externe Rolle am Host freimachen
|
# Alte Rolle am Platz / externe Rolle am Host freimachen
|
||||||
if slot:
|
if slot:
|
||||||
old = conn().execute(
|
old = conn().execute(
|
||||||
@@ -1531,22 +1626,32 @@ def create_app(cfg):
|
|||||||
"SELECT * FROM spools WHERE printer_host = %s AND ace_slot IS NULL AND id <> %s",
|
"SELECT * FROM spools WHERE printer_host = %s AND ace_slot IS NULL AND id <> %s",
|
||||||
(host, spool_id),
|
(host, spool_id),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
printer = store.printer_by_host(conn(), host) or {}
|
||||||
|
if printing:
|
||||||
|
closed = store.close_open_segments(conn(), live_run["id"], slot, mm_now)
|
||||||
|
if not closed and old:
|
||||||
|
# Erster Wechsel: alter Abschnitt von 0 bis jetzt
|
||||||
|
for row in old:
|
||||||
|
store.open_segment(conn(), live_run["id"], row["id"], slot, 0)
|
||||||
|
store.close_open_segments(conn(), live_run["id"], slot, mm_now)
|
||||||
for row in old:
|
for row in old:
|
||||||
store.update_spool(conn(), row["id"], {"printer_host": "", "ace_slot": None})
|
store.update_spool(conn(), row["id"], {"printer_host": "", "ace_slot": None})
|
||||||
store.add_event(
|
store.add_event(
|
||||||
conn(),
|
conn(),
|
||||||
host,
|
host,
|
||||||
(store.printer_by_host(conn(), host) or {}).get("name") or host,
|
printer.get("name") or host,
|
||||||
"spule",
|
"spule",
|
||||||
"%s: „%s“ ins Regal%s" % (
|
"%s: „%s“ ins Regal%s" % (
|
||||||
(store.printer_by_host(conn(), host) or {}).get("name") or host,
|
printer.get("name") or host,
|
||||||
row["name"],
|
row["name"],
|
||||||
(" (Platz %s)" % slot) if slot else "",
|
(" (Platz %s)" % slot) if slot else "",
|
||||||
),
|
),
|
||||||
"",
|
(live_run or {}).get("filename") or "",
|
||||||
|
run_id=(live_run or {}).get("id"),
|
||||||
)
|
)
|
||||||
updated = store.update_spool(conn(), spool_id, {"printer_host": host, "ace_slot": slot})
|
updated = store.update_spool(conn(), spool_id, {"printer_host": host, "ace_slot": slot})
|
||||||
printer = store.printer_by_host(conn(), host) or {}
|
if printing:
|
||||||
|
store.open_segment(conn(), live_run["id"], spool_id, slot, mm_now)
|
||||||
store.add_event(
|
store.add_event(
|
||||||
conn(),
|
conn(),
|
||||||
host,
|
host,
|
||||||
@@ -1557,7 +1662,8 @@ def create_app(cfg):
|
|||||||
updated["name"],
|
updated["name"],
|
||||||
(" auf Platz %s" % slot) if slot else "",
|
(" auf Platz %s" % slot) if slot else "",
|
||||||
),
|
),
|
||||||
"",
|
(live_run or {}).get("filename") or "",
|
||||||
|
run_id=(live_run or {}).get("id"),
|
||||||
)
|
)
|
||||||
return jsonify(updated)
|
return jsonify(updated)
|
||||||
|
|
||||||
@@ -1693,6 +1799,36 @@ def create_app(cfg):
|
|||||||
def run_list():
|
def run_list():
|
||||||
return jsonify(store.list_runs(conn()))
|
return jsonify(store.list_runs(conn()))
|
||||||
|
|
||||||
|
@app.get("/api/v1/laeufe/<int:run_id>")
|
||||||
|
def run_get(run_id):
|
||||||
|
row = store.get_run_row(conn(), run_id)
|
||||||
|
if not row:
|
||||||
|
raise GrottiError("Der Lauf fehlt.", "lauf", 404)
|
||||||
|
run = store.run_out(row)
|
||||||
|
if not run.get("chargen_nr"):
|
||||||
|
store.ensure_run_chargen(conn(), run_id)
|
||||||
|
row = store.get_run_row(conn(), run_id) or row
|
||||||
|
run = store.run_out(row)
|
||||||
|
if run.get("chargen_nr") and run.get("objects"):
|
||||||
|
store.ensure_run_labels(conn(), run_id, run.get("objects") or [], qty=1)
|
||||||
|
file_row = store.get_file_row(conn(), run["file_id"]) if run.get("file_id") else None
|
||||||
|
project = store.get_project(conn(), run["project_id"]) if run.get("project_id") else None
|
||||||
|
samples = store.list_run_samples(conn(), run_id)
|
||||||
|
# Anzeige auf ca. 1 Punkt / Minute verdichten
|
||||||
|
if len(samples) > 180:
|
||||||
|
step = max(1, len(samples) // 120)
|
||||||
|
samples = samples[::step]
|
||||||
|
return jsonify({
|
||||||
|
"run": run,
|
||||||
|
"spools": store.list_run_spools(conn(), run_id),
|
||||||
|
"segments": store.list_run_segments(conn(), run_id),
|
||||||
|
"samples": samples,
|
||||||
|
"labels": store.list_run_labels(conn(), run_id),
|
||||||
|
"events": store.list_run_events(conn(), run_id),
|
||||||
|
"file": store.file_out(file_row) if file_row else None,
|
||||||
|
"project": project,
|
||||||
|
})
|
||||||
|
|
||||||
@app.post("/api/v1/laeufe/abgleich")
|
@app.post("/api/v1/laeufe/abgleich")
|
||||||
def run_sync():
|
def run_sync():
|
||||||
library.sync_watch(conn(), cfg, force=False)
|
library.sync_watch(conn(), cfg, force=False)
|
||||||
@@ -1715,4 +1851,71 @@ def create_app(cfg):
|
|||||||
row = store.run_out(current)
|
row = store.run_out(current)
|
||||||
return jsonify(row)
|
return jsonify(row)
|
||||||
|
|
||||||
|
@app.get("/api/v1/statistik")
|
||||||
|
def statistik_api():
|
||||||
|
since = request.args.get("since") or None
|
||||||
|
until = request.args.get("until") or None
|
||||||
|
host = (request.args.get("host") or "").strip()
|
||||||
|
material = (request.args.get("material") or "").strip()
|
||||||
|
brand = (request.args.get("brand") or "").strip()
|
||||||
|
project_id = optional_id(request.args.get("project_id"))
|
||||||
|
return jsonify(store.statistics(
|
||||||
|
conn(),
|
||||||
|
since=since,
|
||||||
|
until=until,
|
||||||
|
host=host,
|
||||||
|
material=material or "",
|
||||||
|
brand=brand or "",
|
||||||
|
project_id=project_id,
|
||||||
|
))
|
||||||
|
|
||||||
|
@app.get("/api/v1/statistik.csv")
|
||||||
|
def statistik_csv():
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from flask import Response
|
||||||
|
|
||||||
|
since = request.args.get("since") or None
|
||||||
|
until = request.args.get("until") or None
|
||||||
|
host = (request.args.get("host") or "").strip()
|
||||||
|
material = (request.args.get("material") or "").strip()
|
||||||
|
brand = (request.args.get("brand") or "").strip()
|
||||||
|
runs = store.list_runs(
|
||||||
|
conn(),
|
||||||
|
limit=2000,
|
||||||
|
since=since,
|
||||||
|
until=until,
|
||||||
|
host=host,
|
||||||
|
material=material,
|
||||||
|
brand=brand,
|
||||||
|
)
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf, delimiter=";")
|
||||||
|
writer.writerow([
|
||||||
|
"id", "drucker", "datei", "zustand", "start", "ende", "dauer_s",
|
||||||
|
"soll_g", "ist_g", "quelle", "filament_eur", "strom_eur", "urteil", "charge",
|
||||||
|
])
|
||||||
|
for run in runs:
|
||||||
|
writer.writerow([
|
||||||
|
run["id"],
|
||||||
|
run.get("printer_name") or "",
|
||||||
|
run.get("filename") or "",
|
||||||
|
run.get("state") or "",
|
||||||
|
run.get("started_at") or "",
|
||||||
|
run.get("ended_at") or "",
|
||||||
|
run.get("duration_s") or 0,
|
||||||
|
run.get("weight_planned_g") or 0,
|
||||||
|
run.get("weight_used_g") or 0,
|
||||||
|
run.get("weight_source") or "",
|
||||||
|
run.get("filament_cost") or 0,
|
||||||
|
run.get("energy_cost") or 0,
|
||||||
|
run.get("verdict") or "",
|
||||||
|
run.get("chargen_nr") or "",
|
||||||
|
])
|
||||||
|
return Response(
|
||||||
|
buf.getvalue(),
|
||||||
|
mimetype="text/csv; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=grotti-laeufe.csv"},
|
||||||
|
)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
@@ -10,8 +10,8 @@ window.GrottiGcodeViewer = (function () {
|
|||||||
const COLOR_OBJ_EX = 0xef4444;
|
const COLOR_OBJ_EX = 0xef4444;
|
||||||
const COLOR_OBJ_CUR = 0xfbbf24;
|
const COLOR_OBJ_CUR = 0xfbbf24;
|
||||||
const GRID_STEP = 10;
|
const GRID_STEP = 10;
|
||||||
// Nur leichte Doppelung — starke Offsets + Punkte wirkten wie Vollkörper mit Löchern
|
// Keine Parallel-Offsets: bei ausgedünnten Pfaden wirkten sie gesprenkelt
|
||||||
const FAT_OFFSETS = [0, -0.22, 0.22];
|
const FAT_OFFSETS = [0];
|
||||||
|
|
||||||
function create(canvas) {
|
function create(canvas) {
|
||||||
const wrap = document.createElement("div");
|
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;
|
padding: 0.25rem 0.35rem;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
.steer-ace,
|
||||||
|
.steer-ace-slots {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding-top: 0.35rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.steer-ace-head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem 0.65rem;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.steer-ace-head b { font-weight: 650; }
|
||||||
|
.steer-slot-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem 0.55rem;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.steer-slot-row .dot {
|
||||||
|
width: 0.85rem;
|
||||||
|
height: 0.85rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
flex: none;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
.steer-slot-row .grow { min-width: 0; overflow-wrap: anywhere; }
|
||||||
.queue-name { color: var(--ink); font-size: var(--t-sm); font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
|
.queue-name { color: var(--ink); font-size: var(--t-sm); font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
|
||||||
.queue-msg { color: var(--muted); font-size: var(--t-xs); line-height: 1.3; overflow-wrap: anywhere; }
|
.queue-msg { color: var(--muted); font-size: var(--t-xs); line-height: 1.3; overflow-wrap: anywhere; }
|
||||||
.queue-sub { color: var(--faint); font-size: 0.66rem; font-variant-numeric: tabular-nums; flex: none; }
|
.queue-sub { color: var(--faint); font-size: 0.66rem; font-variant-numeric: tabular-nums; flex: none; }
|
||||||
@@ -467,12 +497,16 @@ body.shelf [data-role="hero"] { font-size: 1.35rem; }
|
|||||||
.ace-printer-name { margin: 0; font-size: 0.82rem; font-weight: 650; color: var(--ink); }
|
.ace-printer-name { margin: 0; font-size: 0.82rem; font-weight: 650; color: var(--ink); }
|
||||||
.ace-row {
|
.ace-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 0.85rem minmax(0, 1fr) minmax(7rem, 0.9fr);
|
grid-template-columns: 0.85rem minmax(0, 1fr) minmax(12rem, 1.4fr);
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
.ace-row select {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
.ace-swatch {
|
.ace-swatch {
|
||||||
width: 0.85rem;
|
width: 0.85rem;
|
||||||
height: 0.85rem;
|
height: 0.85rem;
|
||||||
@@ -514,6 +548,23 @@ h2 { font-size: 0.95rem; margin: 0 0 0.65rem; letter-spacing: -0.02em; font-weig
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
min-height: var(--control-h);
|
min-height: var(--control-h);
|
||||||
}
|
}
|
||||||
|
.page-tools .date-field {
|
||||||
|
width: auto;
|
||||||
|
min-width: 9.5rem;
|
||||||
|
max-width: 11rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.page-tools .period-presets {
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
grid-auto-columns: auto;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.page-tools .period-presets button {
|
||||||
|
padding: 0.4rem 0.55rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.lead, .tiny, .summary { color: var(--muted); }
|
.lead, .tiny, .summary { color: var(--muted); }
|
||||||
.summary { margin: 0.3rem 0 0; }
|
.summary { margin: 0.3rem 0 0; }
|
||||||
.tiny { font-size: 0.8rem; margin: 0.2rem 0 0; line-height: 1.4; max-width: 52ch; }
|
.tiny { font-size: 0.8rem; margin: 0.2rem 0 0; line-height: 1.4; max-width: 52ch; }
|
||||||
@@ -620,12 +671,44 @@ article.machine.offline:hover { opacity: 1; }
|
|||||||
.job-line {
|
.job-line {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.55rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: var(--t-sm);
|
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; }
|
.job-line .swatches { padding: 0; min-height: 0; flex: none; }
|
||||||
.badge {
|
.badge {
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
@@ -1874,7 +1957,6 @@ body.desk-on main {
|
|||||||
.summary-text b { color: var(--ink); }
|
.summary-text b { color: var(--ink); }
|
||||||
.page-head-side { display: flex; align-items: center; gap: 0.75rem 1rem; flex-wrap: wrap; }
|
.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); }
|
.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; }
|
.job-line[hidden] { display: none; }
|
||||||
article.machine.flash { animation: flash 2s var(--ease); }
|
article.machine.flash { animation: flash 2s var(--ease); }
|
||||||
@keyframes flash { 0%, 40% { box-shadow: 0 0 0 2px var(--accent), var(--shadow); } }
|
@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-secondary a { padding: 0.4rem; }
|
||||||
.nav-main a.on { box-shadow: none; border-radius: 9px; padding-left: 0.55rem; }
|
.nav-main a.on { box-shadow: none; border-radius: 9px; padding-left: 0.55rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Druckbericht & Statistik */
|
||||||
|
.bericht { display: grid; gap: 0.85rem; }
|
||||||
|
.bericht-head { display: grid; gap: 0.5rem; }
|
||||||
|
.bericht-meta p { margin: 0.2rem 0; }
|
||||||
|
.temp-chart {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 40rem;
|
||||||
|
height: auto;
|
||||||
|
background: #0c0c10;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.chart-label { fill: var(--muted); font-size: 11px; }
|
||||||
|
.chart-legend { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.35rem; }
|
||||||
|
.chart-legend .leg::before {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.65rem;
|
||||||
|
height: 0.65rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 0.3rem;
|
||||||
|
vertical-align: -0.05rem;
|
||||||
|
}
|
||||||
|
.chart-legend .nozzle::before { background: #ea580c; }
|
||||||
|
.chart-legend .bed::before { background: #2563eb; }
|
||||||
|
.chart-legend .chamber::before { background: #16a34a; }
|
||||||
|
.tag {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 650;
|
||||||
|
padding: 0.05rem 0.35rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.kpi-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
.kpi {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.7rem 0.8rem;
|
||||||
|
}
|
||||||
|
.kpi-val {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.kpi-label { color: var(--muted); font-size: 0.78rem; font-weight: 650; }
|
||||||
|
.spool-akte {
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
padding-top: 0.7rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.spool-akte h3 { margin: 0 0 0.35rem; font-size: 0.95rem; }
|
||||||
|
ul.plain { list-style: none; margin: 0; padding: 0; }
|
||||||
|
ul.plain li { padding: 0.2rem 0; border-bottom: 1px solid var(--line); }
|
||||||
|
ul.plain li:last-child { border-bottom: 0; }
|
||||||
|
.grid-2 { display: grid; gap: 0.85rem; }
|
||||||
|
@media (min-width: 800px) {
|
||||||
|
.grid-2 { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
@@ -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>
|
<select id="send-project"><option value="">keins</option></select>
|
||||||
<div id="ace-box" class="ace-box" hidden>
|
<div id="ace-box" class="ace-box" hidden>
|
||||||
<h3>ACE-Zuordnung <span class="tip" title="Datei-Farbe auf ACE-Platz. Ohne Zuordnung startet der ACE oft nicht."><svg class="ico"><use href="#i-info"/></svg></span></h3>
|
<h3>ACE-Zuordnung <span class="tip" title="Datei-Farbe auf ACE-Platz. Ohne Zuordnung startet der ACE oft nicht."><svg class="ico"><use href="#i-info"/></svg></span></h3>
|
||||||
<p class="tiny msg" id="ace-trailer-warn" hidden>Für Auto-Feed fehlen Slicer-Metadaten in der Datei.</p>
|
<p class="tiny" id="ace-trailer-warn" hidden>Mehrfarb ohne Slicer-Trailer — Grotti ergänzt `; referenced_tools` beim Senden.</p>
|
||||||
<div id="ace-maps"></div>
|
<div id="ace-maps"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block script %}
|
{% block script %}
|
||||||
<script src="{{ url_for('static', filename='three.min.js') }}?v=1"></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 src="{{ url_for('static', filename='sweep-viewer.js') }}?v=1"></script>
|
||||||
<script>
|
<script>
|
||||||
const families = { kobra_s1: "Kobra S1", kobra_3: "Kobra 3" };
|
const families = { kobra_s1: "Kobra S1", kobra_3: "Kobra 3" };
|
||||||
@@ -198,7 +198,7 @@ async function showToolpath(file, progress) {
|
|||||||
if (viewerFileId !== file.id) {
|
if (viewerFileId !== file.id) {
|
||||||
meta.textContent = "Pfad wird gelesen …";
|
meta.textContent = "Pfad wird gelesen …";
|
||||||
try {
|
try {
|
||||||
const data = await api("/dateien/" + file.id + "/pfad");
|
const data = await api("/dateien/" + file.id + "/pfad?tp=8");
|
||||||
viewer.setPath(data);
|
viewer.setPath(data);
|
||||||
viewerFileId = file.id;
|
viewerFileId = file.id;
|
||||||
meta.textContent = (data.count || 0).toLocaleString("de-DE") + " Punkte";
|
meta.textContent = (data.count || 0).toLocaleString("de-DE") + " Punkte";
|
||||||
@@ -252,13 +252,41 @@ function loadSendMemory() {
|
|||||||
|
|
||||||
/* ---------- ACE / Spule ---------- */
|
/* ---------- ACE / Spule ---------- */
|
||||||
|
|
||||||
function slotLabel(slot) {
|
function spoolAtSlot(host, place) {
|
||||||
|
return spoolsOnHost(host).find((s) => Number(s.ace_slot) === Number(place)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotLabel(host, slot) {
|
||||||
const place = Number(slot.index) + 1;
|
const place = Number(slot.index) + 1;
|
||||||
const box = place <= 4 ? "ACE 1 · " + place : "ACE 2 · " + (place - 4);
|
const box = place <= 4 ? "ACE 1 · " + place : "ACE 2 · " + (place - 4);
|
||||||
|
const spool = spoolAtSlot(host, place);
|
||||||
|
if (spool) {
|
||||||
|
const bits = [box];
|
||||||
|
const kind = spool.material_type || slot.type || "";
|
||||||
|
if (kind) bits.push(kind);
|
||||||
|
if (spool.color) bits.push(spool.color);
|
||||||
|
if (spool.name) bits.push(spool.name);
|
||||||
|
bits.push(grams(spool.remaining_g));
|
||||||
|
return bits.join(" · ");
|
||||||
|
}
|
||||||
const kind = slot.type ? " · " + slot.type : "";
|
const kind = slot.type ? " · " + slot.type : "";
|
||||||
return box + kind;
|
return box + kind;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function slotTitle(host, slot) {
|
||||||
|
const place = Number(slot.index) + 1;
|
||||||
|
const spool = spoolAtSlot(host, place);
|
||||||
|
if (!spool) return slot.type ? String(slot.type) : "";
|
||||||
|
const bits = [];
|
||||||
|
if (spool.brand) bits.push(spool.brand);
|
||||||
|
if (spool.name) bits.push(spool.name);
|
||||||
|
if (spool.material_type) bits.push(spool.material_type);
|
||||||
|
if (spool.color) bits.push(spool.color);
|
||||||
|
bits.push(grams(spool.remaining_g) + " Rest");
|
||||||
|
if (spool.chargen_nr) bits.push(spool.chargen_nr);
|
||||||
|
return bits.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
function filamentLabel(filament, index) {
|
function filamentLabel(filament, index) {
|
||||||
const kind = filament.type || "Filament";
|
const kind = filament.type || "Filament";
|
||||||
const color = filament.color ? " · " + filament.color : "";
|
const color = filament.color ? " · " + filament.color : "";
|
||||||
@@ -369,7 +397,10 @@ function renderAceMaps(force) {
|
|||||||
const paints = filePaints(file);
|
const paints = filePaints(file);
|
||||||
box.hidden = false;
|
box.hidden = false;
|
||||||
document.getElementById("options").open = true;
|
document.getElementById("options").open = true;
|
||||||
if (trailerWarn) trailerWarn.hidden = !!file.ace_trailer;
|
// Mono: Trailer fehlt oft und ist kein Fehler — Grotti setzt ihn beim Senden mit ACE-Map.
|
||||||
|
if (trailerWarn) {
|
||||||
|
trailerWarn.hidden = !!file.ace_trailer || paints.length <= 1;
|
||||||
|
}
|
||||||
root.innerHTML = "";
|
root.innerHTML = "";
|
||||||
chosen.forEach((printer) => {
|
chosen.forEach((printer) => {
|
||||||
const block = document.createElement("div");
|
const block = document.createElement("div");
|
||||||
@@ -393,10 +424,15 @@ function renderAceMaps(force) {
|
|||||||
printer.slots.forEach((slot) => {
|
printer.slots.forEach((slot) => {
|
||||||
const option = document.createElement("option");
|
const option = document.createElement("option");
|
||||||
option.value = String(slot.index);
|
option.value = String(slot.index);
|
||||||
option.textContent = slotLabel(slot);
|
option.textContent = slotLabel(printer.host, slot);
|
||||||
if (slot.color) option.style.borderLeft = "4px solid " + slot.color;
|
option.title = slotTitle(printer.host, slot);
|
||||||
// einfache Vorauswahl: gleiche Sorte, sonst Platz in Reihenfolge
|
const spool = spoolAtSlot(printer.host, Number(slot.index) + 1);
|
||||||
if (!select.dataset.picked && filament.type && slot.type && filament.type.toLowerCase() === String(slot.type).toLowerCase()) {
|
const hex = (spool && spool.colors && spool.colors[0] && spool.colors[0].hex) || slot.color || "";
|
||||||
|
if (hex) option.style.borderLeft = "4px solid " + hex;
|
||||||
|
const spoolType = (spool && spool.material_type) || slot.type || "";
|
||||||
|
// Vorauswahl: gleiche Sorte (Grotti-Spule bevorzugt), sonst Platz in Reihenfolge
|
||||||
|
if (!select.dataset.picked && filament.type && spoolType
|
||||||
|
&& filament.type.toLowerCase() === String(spoolType).toLowerCase()) {
|
||||||
option.selected = true;
|
option.selected = true;
|
||||||
select.dataset.picked = "1";
|
select.dataset.picked = "1";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,14 +41,17 @@
|
|||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 22mm;
|
grid-template-columns: 1fr 20mm;
|
||||||
gap: 3mm;
|
gap: 2.5mm;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.meta { min-width: 0; }
|
||||||
.meta h1 {
|
.meta h1 {
|
||||||
margin: 0 0 2mm;
|
margin: 0 0 2mm;
|
||||||
font-size: 4.2mm;
|
font-size: 4.2mm;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.meta p { margin: 0 0 1.2mm; font-size: 3.1mm; color: #333; }
|
.meta p { margin: 0 0 1.2mm; font-size: 3.1mm; color: #333; }
|
||||||
.meta .code { font-weight: 700; font-variant-numeric: tabular-nums; }
|
.meta .code { font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||||
@@ -61,7 +64,19 @@
|
|||||||
margin-left: -1mm;
|
margin-left: -1mm;
|
||||||
}
|
}
|
||||||
.dot:first-child { margin-left: 0; }
|
.dot:first-child { margin-left: 0; }
|
||||||
#qr { width: 20mm; height: 20mm; }
|
.qr-wrap {
|
||||||
|
width: 20mm;
|
||||||
|
height: 20mm;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.qr-wrap canvas {
|
||||||
|
display: block;
|
||||||
|
width: 20mm !important;
|
||||||
|
height: 20mm !important;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
@media print {
|
@media print {
|
||||||
body { background: #fff; }
|
body { background: #fff; }
|
||||||
.toolbar { display: none; }
|
.toolbar { display: none; }
|
||||||
@@ -99,14 +114,20 @@
|
|||||||
<p>Hersteller: {{ spool.hersteller_charge }}</p>
|
<p>Hersteller: {{ spool.hersteller_charge }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<canvas id="qr" width="120" height="120"></canvas>
|
<div class="qr-wrap">
|
||||||
|
<canvas id="qr" width="80" height="80"></canvas>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
|
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
|
||||||
<script>
|
<script>
|
||||||
QRCode.toCanvas(document.getElementById("qr"), {{ qr_url|tojson }}, {
|
QRCode.toCanvas(document.getElementById("qr"), {{ qr_url|tojson }}, {
|
||||||
width: 120,
|
width: 80,
|
||||||
margin: 1,
|
margin: 1,
|
||||||
color: { dark: "#111111", light: "#ffffff" }
|
color: { dark: "#111111", light: "#ffffff" }
|
||||||
|
}, () => {
|
||||||
|
const node = document.getElementById("qr");
|
||||||
|
node.style.width = "20mm";
|
||||||
|
node.style.height = "20mm";
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
|
||||||
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}">
|
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}">
|
||||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}">
|
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=39">
|
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=45">
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
try {
|
try {
|
||||||
@@ -100,7 +100,8 @@
|
|||||||
<a href="/kameras" class="{{ 'on' if page == 'kameras' else '' }}" title="Kameras"><svg class="ico"><use href="#i-camera"/></svg><span>Kameras</span></a>
|
<a href="/kameras" class="{{ 'on' if page == 'kameras' else '' }}" title="Kameras"><svg class="ico"><use href="#i-camera"/></svg><span>Kameras</span></a>
|
||||||
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk" title="Aktivität"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
|
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk" title="Aktivität"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
|
||||||
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}" title="Warteschlange"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
|
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}" title="Warteschlange"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
|
||||||
<a href="/protokoll" class="{{ 'on' if page == 'protokoll' else '' }} nav-desk" title="Protokoll"><svg class="ico"><use href="#i-list"/></svg><span>Protokoll</span></a>
|
<a href="/protokoll" class="{{ 'on' if page == 'protokoll' or page == 'bericht' else '' }} nav-desk" title="Protokoll"><svg class="ico"><use href="#i-list"/></svg><span>Protokoll</span></a>
|
||||||
|
<a href="/statistik" class="{{ 'on' if page == 'statistik' else '' }} nav-desk" title="Statistik"><svg class="ico"><use href="#i-activity"/></svg><span>Statistik</span></a>
|
||||||
<a href="/material" class="{{ 'on' if page == 'material' else '' }} nav-desk" title="Material"><svg class="ico"><use href="#i-spool"/></svg><span>Material</span></a>
|
<a href="/material" class="{{ 'on' if page == 'material' else '' }} nav-desk" title="Material"><svg class="ico"><use href="#i-spool"/></svg><span>Material</span></a>
|
||||||
<a href="/projekte" class="{{ 'on' if page == 'projekte' else '' }} nav-desk" title="Projekte"><svg class="ico"><use href="#i-folder"/></svg><span>Projekte</span></a>
|
<a href="/projekte" class="{{ 'on' if page == 'projekte' else '' }} nav-desk" title="Projekte"><svg class="ico"><use href="#i-folder"/></svg><span>Projekte</span></a>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -289,6 +290,31 @@ function directItemHtml(p) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let queueCache = [];
|
let queueCache = [];
|
||||||
|
const lastLiveFilename = Object.create(null);
|
||||||
|
|
||||||
|
function setListHtml(el, html) {
|
||||||
|
if (!el) return;
|
||||||
|
if (el._html === html) return;
|
||||||
|
el._html = html;
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stickyLiveFilename(host, filename, state) {
|
||||||
|
const next = filename || "";
|
||||||
|
if (next) {
|
||||||
|
lastLiveFilename[host] = next;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
const prev = lastLiveFilename[host] || "";
|
||||||
|
if (prev && ["printing", "paused", "heating", "complete"].includes(String(state || "").toLowerCase())) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
if (!["printing", "paused", "heating"].includes(String(state || "").toLowerCase())) {
|
||||||
|
delete lastLiveFilename[host];
|
||||||
|
}
|
||||||
|
return prev && ["complete"].includes(String(state || "").toLowerCase()) ? prev : "";
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshPrintQueue() {
|
async function refreshPrintQueue() {
|
||||||
let jobs;
|
let jobs;
|
||||||
let active = [];
|
let active = [];
|
||||||
@@ -298,7 +324,10 @@ async function refreshPrintQueue() {
|
|||||||
queueCache = jobs || [];
|
queueCache = jobs || [];
|
||||||
const running = queueCache.filter((job) => job.status === "running");
|
const running = queueCache.filter((job) => job.status === "running");
|
||||||
const queuedHosts = new Set(running.map((job) => job.host));
|
const queuedHosts = new Set(running.map((job) => job.host));
|
||||||
const direct = (active || []).filter((p) => !queuedHosts.has(p.host));
|
const direct = (active || []).filter((p) => !queuedHosts.has(p.host)).map((p) => ({
|
||||||
|
...p,
|
||||||
|
filename: stickyLiveFilename(p.host, p.filename, p.state),
|
||||||
|
}));
|
||||||
const waiting = queueCache.filter((job) => job.status === "waiting");
|
const waiting = queueCache.filter((job) => job.status === "waiting");
|
||||||
const errors = queueCache
|
const errors = queueCache
|
||||||
.filter((job) => job.status === "error")
|
.filter((job) => job.status === "error")
|
||||||
@@ -308,15 +337,21 @@ async function refreshPrintQueue() {
|
|||||||
const runBox = document.getElementById("run-box");
|
const runBox = document.getElementById("run-box");
|
||||||
runBox.hidden = !(running.length || direct.length);
|
runBox.hidden = !(running.length || direct.length);
|
||||||
document.getElementById("run-count").textContent = running.length + direct.length;
|
document.getElementById("run-count").textContent = running.length + direct.length;
|
||||||
document.getElementById("run-list").innerHTML = running.map(runItemHtml).join("") + direct.map(directItemHtml).join("");
|
setListHtml(
|
||||||
|
document.getElementById("run-list"),
|
||||||
|
running.map(runItemHtml).join("") + direct.map(directItemHtml).join(""),
|
||||||
|
);
|
||||||
|
|
||||||
const waitList = document.getElementById("wait-list");
|
const waitList = document.getElementById("wait-list");
|
||||||
document.getElementById("wait-count").textContent = waiting.length;
|
document.getElementById("wait-count").textContent = waiting.length;
|
||||||
document.getElementById("wait-count").className = "count" + (errors.length ? " bad" : waiting.length ? " hot" : "");
|
document.getElementById("wait-count").className = "count" + (errors.length ? " bad" : waiting.length ? " hot" : "");
|
||||||
document.getElementById("wait-empty").hidden = !!(waiting.length || errors.length);
|
document.getElementById("wait-empty").hidden = !!(waiting.length || errors.length);
|
||||||
document.getElementById("wait-box").classList.toggle("is-empty", !(waiting.length || errors.length));
|
document.getElementById("wait-box").classList.toggle("is-empty", !(waiting.length || errors.length));
|
||||||
waitList.innerHTML = errors.map((job) => waitItemHtml(job, 0, 1)).join("")
|
setListHtml(
|
||||||
+ waiting.map((job, i) => waitItemHtml(job, i, waiting.length)).join("");
|
waitList,
|
||||||
|
errors.map((job) => waitItemHtml(job, 0, 1)).join("")
|
||||||
|
+ waiting.map((job, i) => waitItemHtml(job, i, waiting.length)).join(""),
|
||||||
|
);
|
||||||
|
|
||||||
const panel = document.getElementById("live-panel");
|
const panel = document.getElementById("live-panel");
|
||||||
panel.querySelectorAll("[data-cancel-q]").forEach((btn) => {
|
panel.querySelectorAll("[data-cancel-q]").forEach((btn) => {
|
||||||
|
|||||||
@@ -120,6 +120,11 @@
|
|||||||
<button type="button" class="warn" id="spool-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
|
<button type="button" class="warn" id="spool-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<div id="spool-akte" class="spool-akte" hidden>
|
||||||
|
<h3>Akte</h3>
|
||||||
|
<p class="tiny" id="akte-summary"></p>
|
||||||
|
<ul class="plain" id="akte-runs"></ul>
|
||||||
|
</div>
|
||||||
<p id="spool-note"></p>
|
<p id="spool-note"></p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -424,6 +429,49 @@ function payload() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadAkte(spoolId) {
|
||||||
|
const box = document.getElementById("spool-akte");
|
||||||
|
if (!spoolId) {
|
||||||
|
box.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await api("/spulen/" + spoolId + "/akte");
|
||||||
|
const stats = data.stats || {};
|
||||||
|
const fillPct = Math.round(Number(stats.fill_pct) || 0);
|
||||||
|
let leer = "—";
|
||||||
|
if (stats.empty_in_days == null) leer = "kein Verbrauch";
|
||||||
|
else if (stats.empty_in_days <= 0) leer = "leer";
|
||||||
|
else leer = "noch ~" + Math.round(stats.empty_in_days) + " Tage";
|
||||||
|
document.getElementById("akte-summary").textContent =
|
||||||
|
"Verdruckt " + grams(stats.total_g)
|
||||||
|
+ " · Ausschuss " + grams(stats.scrap_g)
|
||||||
|
+ " · " + euro(stats.total_cost)
|
||||||
|
+ " · Füllstand " + fillPct + " % · Leer-in: " + leer;
|
||||||
|
const list = document.getElementById("akte-runs");
|
||||||
|
list.innerHTML = "";
|
||||||
|
(data.runs || []).slice(0, 12).forEach((run) => {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = "/protokoll/" + run.id;
|
||||||
|
a.textContent = (run.filename || "").split("/").pop() || ("#" + run.id);
|
||||||
|
li.appendChild(a);
|
||||||
|
li.appendChild(document.createTextNode(
|
||||||
|
" · " + grams(run.spool_weight_g)
|
||||||
|
+ (run.spool_is_estimate ? " ≈" : "")
|
||||||
|
+ " · " + (run.verdict || run.state || "")
|
||||||
|
));
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
if (!(data.runs || []).length) {
|
||||||
|
list.innerHTML = '<li class="tiny">Noch kein Druck auf dieser Spule.</li>';
|
||||||
|
}
|
||||||
|
box.hidden = false;
|
||||||
|
} catch (error) {
|
||||||
|
box.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function fill(spool) {
|
function fill(spool) {
|
||||||
openSpoolPanel();
|
openSpoolPanel();
|
||||||
document.getElementById("spool-id").value = spool ? spool.id : "";
|
document.getElementById("spool-id").value = spool ? spool.id : "";
|
||||||
@@ -451,6 +499,7 @@ function fill(spool) {
|
|||||||
showHint();
|
showHint();
|
||||||
renderPalette();
|
renderPalette();
|
||||||
renderSpools();
|
renderSpools();
|
||||||
|
loadAkte(spool ? spool.id : null);
|
||||||
document.getElementById("spool-panel").scrollIntoView({ block: "nearest", behavior: "smooth" });
|
document.getElementById("spool-panel").scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,6 +629,7 @@ function renderSpools() {
|
|||||||
const rest = Number(spool.remaining_g) || 0;
|
const rest = Number(spool.remaining_g) || 0;
|
||||||
tr.children[5].textContent = grams(rest);
|
tr.children[5].textContent = grams(rest);
|
||||||
if (rest < 100) tr.children[5].classList.add("msg");
|
if (rest < 100) tr.children[5].classList.add("msg");
|
||||||
|
if (rest < 0) tr.children[5].title = "Überzogen — Rest korrigieren";
|
||||||
tr.addEventListener("click", () => fill(spool));
|
tr.addEventListener("click", () => fill(spool));
|
||||||
body.appendChild(tr);
|
body.appendChild(tr);
|
||||||
});
|
});
|
||||||
@@ -806,7 +856,14 @@ async function openByCode(raw) {
|
|||||||
const match = code.match(/[?&]spool=(\d+)/);
|
const match = code.match(/[?&]spool=(\d+)/);
|
||||||
if (match) spool = spools.find((row) => String(row.id) === match[1]) || null;
|
if (match) spool = spools.find((row) => String(row.id) === match[1]) || null;
|
||||||
}
|
}
|
||||||
if (!spool) spool = await api("/spulen/by-code/" + encodeURIComponent(code));
|
if (!spool) {
|
||||||
|
const found = await api("/spulen/by-code/" + encodeURIComponent(code));
|
||||||
|
if (found && found.kind === "lauf" && found.redirect) {
|
||||||
|
location.href = found.redirect;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
spool = found;
|
||||||
|
}
|
||||||
setView("spools");
|
setView("spools");
|
||||||
fill(spool);
|
fill(spool);
|
||||||
setScanNote("Spule " + (spool.chargen_nr || spool.name) + " geöffnet.", true);
|
setScanNote("Spule " + (spool.chargen_nr || spool.name) + " geöffnet.", true);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
<p id="run-note"></p>
|
<p id="run-note"></p>
|
||||||
<div class="card table-wrap">
|
<div class="card table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Drucker</th><th>Datei</th><th>Zustand</th><th>Dauer</th><th>Gramm</th><th>Kosten</th><th>Urteil</th><th>Spule</th><th></th></tr></thead>
|
<thead><tr><th>Drucker</th><th>Datei</th><th>Zustand</th><th>Dauer</th><th>Ist</th><th>Kosten</th><th>Urteil</th><th>Spule</th><th></th></tr></thead>
|
||||||
<tbody id="run-rows"></tbody>
|
<tbody id="run-rows"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,12 +80,31 @@ function render() {
|
|||||||
}
|
}
|
||||||
shown.forEach((run) => {
|
shown.forEach((run) => {
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
|
tr.className = "pick";
|
||||||
tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>";
|
tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>";
|
||||||
tr.children[0].textContent = run.printer_name;
|
tr.children[0].textContent = run.printer_name;
|
||||||
tr.children[1].textContent = (run.filename || "").split("/").pop();
|
const fileCell = tr.children[1];
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = "/protokoll/" + run.id;
|
||||||
|
link.textContent = (run.filename || "").split("/").pop() || ("#" + run.id);
|
||||||
|
fileCell.appendChild(link);
|
||||||
|
if (run.chargen_nr) {
|
||||||
|
const code = document.createElement("div");
|
||||||
|
code.className = "tiny";
|
||||||
|
code.textContent = run.chargen_nr;
|
||||||
|
fileCell.appendChild(code);
|
||||||
|
}
|
||||||
tr.children[2].textContent = states[run.state] || run.state;
|
tr.children[2].textContent = states[run.state] || run.state;
|
||||||
tr.children[3].textContent = hours(run.duration_s);
|
tr.children[3].textContent = hours(run.duration_s);
|
||||||
tr.children[4].textContent = run.weight_g ? grams(run.weight_g) : "";
|
const used = Number(run.weight_used_g) || Number(run.weight_g) || 0;
|
||||||
|
const planned = Number(run.weight_planned_g) || Number(run.weight_g) || 0;
|
||||||
|
tr.children[4].textContent = used ? grams(used) : "";
|
||||||
|
if (used && planned && Math.abs(used - planned) > 0.5) {
|
||||||
|
tr.children[4].title = "Soll " + grams(planned);
|
||||||
|
}
|
||||||
|
if (run.weight_source === "estimate" || (run.weight_source === "measured" && false)) {
|
||||||
|
tr.children[4].textContent += run.weight_source === "estimate" ? " ≈" : "";
|
||||||
|
}
|
||||||
const cost = Number(run.filament_cost || 0) + Number(run.energy_cost || 0);
|
const cost = Number(run.filament_cost || 0) + Number(run.energy_cost || 0);
|
||||||
tr.children[5].textContent = cost ? euro(cost) : "";
|
tr.children[5].textContent = cost ? euro(cost) : "";
|
||||||
const verdict = document.createElement("select");
|
const verdict = document.createElement("select");
|
||||||
|
|||||||
@@ -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 %}
|
||||||
+268
-42
@@ -86,7 +86,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block script %}
|
{% block script %}
|
||||||
<script src="{{ url_for('static', filename='three.min.js') }}?v=1"></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>
|
<script>
|
||||||
const labels = STATE_LABELS;
|
const labels = STATE_LABELS;
|
||||||
const rank = { printing: 0, paused: 1, error: 2, heating: 3, complete: 4, cancelled: 5, standby: 6, offline: 7 };
|
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 deskViewer = null;
|
||||||
let deskCamFails = 0;
|
let deskCamFails = 0;
|
||||||
let deskViewerFileId = null;
|
let deskViewerFileId = null;
|
||||||
|
let deskSyncGen = 0;
|
||||||
let deskExcludeSelected = "";
|
let deskExcludeSelected = "";
|
||||||
const wallPullTried = new Set();
|
const wallPullTried = new Set();
|
||||||
const toolpathCache = new Map();
|
const toolpathCache = new Map();
|
||||||
|
|
||||||
|
/** Kurzes leeres filename vom Drucker nicht übernehmen — sonst springt der Name alle paar Sekunden. */
|
||||||
|
function stickyFilename(printer, previous) {
|
||||||
|
const next = (printer && printer.filename) || "";
|
||||||
|
if (next) return next;
|
||||||
|
const state = shownState(printer || {});
|
||||||
|
if (previous && ["printing", "paused", "heating", "complete"].includes(state)) return previous;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
const cardNodes = new Map();
|
const cardNodes = new Map();
|
||||||
const runningJobs = new Map();
|
const runningJobs = new Map();
|
||||||
const printerCache = new Map();
|
const printerCache = new Map();
|
||||||
@@ -208,6 +218,14 @@ function setHtml(el, html) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setText(el, text) {
|
||||||
|
if (!el) return;
|
||||||
|
const next = text == null ? "" : String(text);
|
||||||
|
if (el._text === next) return;
|
||||||
|
el._text = next;
|
||||||
|
el.textContent = next;
|
||||||
|
}
|
||||||
|
|
||||||
function tempbarHtml(label, value, target, scale, iconName) {
|
function tempbarHtml(label, value, target, scale, iconName) {
|
||||||
if (value == null) return "";
|
if (value == null) return "";
|
||||||
const now = Math.round(value);
|
const now = Math.round(value);
|
||||||
@@ -265,12 +283,19 @@ function swatchesHtml(printer) {
|
|||||||
}).join("");
|
}).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function actionsHtml(state) {
|
function etikettHtml(printer) {
|
||||||
|
const runId = printer && printer.live_run_id;
|
||||||
|
if (!runId) return "";
|
||||||
|
return `<a class="ghost" href="/etikett/lauf/${runId}?mode=druck" target="_blank" rel="noopener">Etikett</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionsHtml(state, printer) {
|
||||||
|
const label = (state === "printing" || state === "paused") ? etikettHtml(printer) : "";
|
||||||
if (state === "printing") {
|
if (state === "printing") {
|
||||||
return `<button type="button" class="ghost" data-cmd="pause">${icon("pause")}Pause</button><button type="button" class="warn" data-cmd="abbrechen">${icon("stop")}Abbrechen</button>`;
|
return `${label}<button type="button" class="ghost" data-cmd="pause">${icon("pause")}Pause</button><button type="button" class="warn" data-cmd="abbrechen">${icon("stop")}Abbrechen</button>`;
|
||||||
}
|
}
|
||||||
if (state === "paused") {
|
if (state === "paused") {
|
||||||
return `<button type="button" data-cmd="fortsetzen">${icon("play")}Weiter</button><button type="button" class="warn" data-cmd="abbrechen">${icon("stop")}Abbrechen</button>`;
|
return `${label}<button type="button" data-cmd="fortsetzen">${icon("play")}Weiter</button><button type="button" class="warn" data-cmd="abbrechen">${icon("stop")}Abbrechen</button>`;
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -327,13 +352,107 @@ function stripsHtml(printer, state) {
|
|||||||
return bits.join("");
|
return bits.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dryerLabel(status) {
|
||||||
|
if (status === "drying") return "trocknet";
|
||||||
|
if (status === "heater_err") return "Fehler";
|
||||||
|
return "aus";
|
||||||
|
}
|
||||||
|
|
||||||
|
function dryerDefaultTemp(dryer) {
|
||||||
|
if (dryer.target > 0) return dryer.target;
|
||||||
|
if (dryer.temp > 0) return dryer.temp;
|
||||||
|
return 45;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aceSteerKey(printer) {
|
||||||
|
const dryers = printer.dryers || [];
|
||||||
|
const dryerPart = dryers.map((d) => String(d.id)).join(",");
|
||||||
|
const slots = wallSpools
|
||||||
|
.filter((s) => s.printer_host === printer.host && s.ace_slot)
|
||||||
|
.map((s) => Number(s.ace_slot))
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.join(",");
|
||||||
|
return dryerPart + "|" + slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
function spoolHex(spool) {
|
||||||
|
const colors = spool && spool.colors;
|
||||||
|
if (Array.isArray(colors)) {
|
||||||
|
for (const item of colors) {
|
||||||
|
if (item && item.hex) return item.hex;
|
||||||
|
if (typeof item === "string" && item.trim()) return item.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const label = spool && spool.color;
|
||||||
|
if (typeof label === "string" && /^#?[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(label.trim())) {
|
||||||
|
return label.trim().startsWith("#") ? label.trim() : "#" + label.trim();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function aceSlotLabel(slot) {
|
||||||
|
const n = Number(slot);
|
||||||
|
if (n <= 4) return "ACE 1 · " + n;
|
||||||
|
return "ACE 2 · " + (n - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function aceSteerHtml(printer) {
|
||||||
|
const dryers = printer.dryers || [];
|
||||||
|
if (!dryers.length) return "";
|
||||||
|
const bits = [];
|
||||||
|
dryers.forEach((dryer) => {
|
||||||
|
const id = Number(dryer.id);
|
||||||
|
const label = "ACE " + (id + 1);
|
||||||
|
const status = dryerLabel(dryer.status);
|
||||||
|
const temp = dryer.temp || 0;
|
||||||
|
const target = dryer.target || 0;
|
||||||
|
const remain = dryer.remain || 0;
|
||||||
|
const dur = remain > 0 ? remain : 240;
|
||||||
|
const hot = dryerDefaultTemp(dryer);
|
||||||
|
const info = [
|
||||||
|
status,
|
||||||
|
temp || target ? temp + "°" + (target ? " / " + target + "°" : "") : "",
|
||||||
|
remain > 0 ? "noch " + remain + " min" : "",
|
||||||
|
].filter(Boolean).join(" · ");
|
||||||
|
bits.push(`<div class="steer-ace" data-dryer-id="${id}">
|
||||||
|
<div class="steer-ace-head"><b>${escapeHtml(label)} Trockner</b><span class="tiny" data-dryer-info>${escapeHtml(info)}</span></div>
|
||||||
|
<div class="steer-row">
|
||||||
|
<label>Temp °C <input type="number" data-steer="dryer-temp-${id}" min="35" max="65" value="${escapeHtml(String(hot))}"></label>
|
||||||
|
<label>Dauer min <input type="number" data-steer="dryer-dur-${id}" min="1" max="720" value="${escapeHtml(String(dur))}"></label>
|
||||||
|
<button type="button" data-steer-go="dryer_start" data-dryer-id="${id}">Start</button>
|
||||||
|
<button type="button" class="ghost" data-steer-go="dryer_stop" data-dryer-id="${id}">Stopp</button>
|
||||||
|
</div>
|
||||||
|
</div>`);
|
||||||
|
});
|
||||||
|
const loaded = wallSpools
|
||||||
|
.filter((s) => s.printer_host === printer.host && s.ace_slot)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => Number(a.ace_slot) - Number(b.ace_slot));
|
||||||
|
if (loaded.length) {
|
||||||
|
bits.push(`<div class="steer-ace-slots"><div class="steer-ace-head"><b>Plätze</b><span class="tiny">Sorte und Farbe aus Grotti</span></div>`);
|
||||||
|
loaded.forEach((spool) => {
|
||||||
|
const slot = Number(spool.ace_slot);
|
||||||
|
const hex = spoolHex(spool) || "#555";
|
||||||
|
const kind = spool.material_type || "—";
|
||||||
|
bits.push(`<div class="steer-slot-row">
|
||||||
|
<span class="dot" style="background:${escapeHtml(hex)}"></span>
|
||||||
|
<span class="grow"><b>${escapeHtml(aceSlotLabel(slot))}</b> · ${escapeHtml(kind)} · ${escapeHtml(spool.name || "")}</span>
|
||||||
|
<button type="button" class="ghost" data-steer-go="slot_setzen" data-ace-slot="${slot}">an den Drucker</button>
|
||||||
|
</div>`);
|
||||||
|
});
|
||||||
|
bits.push(`</div>`);
|
||||||
|
}
|
||||||
|
return bits.join("");
|
||||||
|
}
|
||||||
|
|
||||||
function steerPanelHtml(printer) {
|
function steerPanelHtml(printer) {
|
||||||
const n = printer.nozzle_target != null ? Math.round(printer.nozzle_target) : "";
|
const n = printer.nozzle_target != null ? Math.round(printer.nozzle_target) : "";
|
||||||
const b = printer.bed_target != null ? Math.round(printer.bed_target) : "";
|
const b = printer.bed_target != null ? Math.round(printer.bed_target) : "";
|
||||||
const sp = typeof printer.speed_factor === "number" ? Math.round(printer.speed_factor * 100) : 100;
|
const sp = typeof printer.speed_factor === "number" ? Math.round(printer.speed_factor * 100) : 100;
|
||||||
const flow = typeof printer.extrude_factor === "number" ? Math.round(printer.extrude_factor * 100) : 100;
|
const flow = typeof printer.extrude_factor === "number" ? Math.round(printer.extrude_factor * 100) : 100;
|
||||||
const fan = typeof printer.fan === "number" ? Math.round(printer.fan * 100) : 0;
|
const fan = typeof printer.fan === "number" ? Math.round(printer.fan * 100) : 0;
|
||||||
return `<div class="steer" data-steer-host="${escapeHtml(printer.host)}">
|
const aceKey = aceSteerKey(printer);
|
||||||
|
return `<div class="steer" data-steer-host="${escapeHtml(printer.host)}" data-ace-key="${escapeHtml(aceKey)}">
|
||||||
<div class="steer-row">
|
<div class="steer-row">
|
||||||
<label>${icon("nozzle")} Düse <input type="number" data-steer="nozzle" data-steer-action="nozzle" min="0" max="350" value="${escapeHtml(String(n))}"></label>
|
<label>${icon("nozzle")} Düse <input type="number" data-steer="nozzle" data-steer-action="nozzle" min="0" max="350" value="${escapeHtml(String(n))}"></label>
|
||||||
<label>${icon("bed")} Bett <input type="number" data-steer="bed" data-steer-action="bed" min="0" max="150" value="${escapeHtml(String(b))}"></label>
|
<label>${icon("bed")} Bett <input type="number" data-steer="bed" data-steer-action="bed" min="0" max="150" value="${escapeHtml(String(b))}"></label>
|
||||||
@@ -345,6 +464,7 @@ function steerPanelHtml(printer) {
|
|||||||
<label>Flow % <input type="number" data-steer="flow" data-steer-action="flow" min="50" max="150" value="${escapeHtml(String(flow))}"></label>
|
<label>Flow % <input type="number" data-steer="flow" data-steer-action="flow" min="50" max="150" value="${escapeHtml(String(flow))}"></label>
|
||||||
<label>${icon("fan")} Fan % <input type="number" data-steer="fan" data-steer-action="fan" min="0" max="100" value="${escapeHtml(String(fan))}"></label>
|
<label>${icon("fan")} Fan % <input type="number" data-steer="fan" data-steer-action="fan" min="0" max="100" value="${escapeHtml(String(fan))}"></label>
|
||||||
</div>
|
</div>
|
||||||
|
${aceSteerHtml(printer)}
|
||||||
<p class="tiny">Enter im Feld setzt nur diesen Wert.</p>
|
<p class="tiny">Enter im Feld setzt nur diesen Wert.</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -447,8 +567,9 @@ function anycubicTaskLine(printer) {
|
|||||||
return "<span>Anycubic <b>" + escapeHtml(bits.join(" · ")) + "</b></span>";
|
return "<span>Anycubic <b>" + escapeHtml(bits.join(" · ")) + "</b></span>";
|
||||||
}
|
}
|
||||||
|
|
||||||
function jobLineVisible(printer, state) {
|
function jobLineVisible(printer, state, filename) {
|
||||||
return !!printer.filename && state !== "standby" && state !== "offline";
|
const name = filename != null ? filename : (printer && printer.filename);
|
||||||
|
return !!name && state !== "standby" && state !== "offline";
|
||||||
}
|
}
|
||||||
|
|
||||||
function shortName(name) {
|
function shortName(name) {
|
||||||
@@ -490,7 +611,14 @@ function buildCard(printer) {
|
|||||||
<div data-role="bar"></div>
|
<div data-role="bar"></div>
|
||||||
<div class="stats" data-role="stats"></div>
|
<div class="stats" data-role="stats"></div>
|
||||||
<div data-role="temps"></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="flow"></div>
|
||||||
<div data-role="strips"></div>
|
<div data-role="strips"></div>
|
||||||
<div class="card-actions" data-role="controls"></div>
|
<div class="card-actions" data-role="controls"></div>
|
||||||
@@ -510,11 +638,13 @@ function patchSteerValues(root, printer) {
|
|||||||
if (steerHasFocus(root)) return;
|
if (steerHasFocus(root)) return;
|
||||||
const steer = root.querySelector("[data-role=steer]");
|
const steer = root.querySelector("[data-role=steer]");
|
||||||
if (!steer) return;
|
if (!steer) return;
|
||||||
const nozzle = steer.querySelector('[data-steer="nozzle"]');
|
const panel = steer.querySelector(".steer");
|
||||||
const bed = steer.querySelector('[data-steer="bed"]');
|
if (!panel) return;
|
||||||
const speed = steer.querySelector('[data-steer="speed"]');
|
const nozzle = panel.querySelector('[data-steer="nozzle"]');
|
||||||
const flow = steer.querySelector('[data-steer="flow"]');
|
const bed = panel.querySelector('[data-steer="bed"]');
|
||||||
const fan = steer.querySelector('[data-steer="fan"]');
|
const speed = panel.querySelector('[data-steer="speed"]');
|
||||||
|
const flow = panel.querySelector('[data-steer="flow"]');
|
||||||
|
const fan = panel.querySelector('[data-steer="fan"]');
|
||||||
if (nozzle && document.activeElement !== nozzle) {
|
if (nozzle && document.activeElement !== nozzle) {
|
||||||
nozzle.value = printer.nozzle_target != null ? Math.round(printer.nozzle_target) : "";
|
nozzle.value = printer.nozzle_target != null ? Math.round(printer.nozzle_target) : "";
|
||||||
}
|
}
|
||||||
@@ -530,13 +660,39 @@ function patchSteerValues(root, printer) {
|
|||||||
if (fan && document.activeElement !== fan && typeof printer.fan === "number") {
|
if (fan && document.activeElement !== fan && typeof printer.fan === "number") {
|
||||||
fan.value = Math.round(printer.fan * 100);
|
fan.value = Math.round(printer.fan * 100);
|
||||||
}
|
}
|
||||||
|
(printer.dryers || []).forEach((dryer) => {
|
||||||
|
const id = Number(dryer.id);
|
||||||
|
const box = panel.querySelector(`[data-dryer-id="${id}"]`);
|
||||||
|
if (!box) return;
|
||||||
|
const info = box.querySelector("[data-dryer-info]");
|
||||||
|
if (info) {
|
||||||
|
const status = dryerLabel(dryer.status);
|
||||||
|
const temp = dryer.temp || 0;
|
||||||
|
const target = dryer.target || 0;
|
||||||
|
const remain = dryer.remain || 0;
|
||||||
|
info.textContent = [
|
||||||
|
status,
|
||||||
|
temp || target ? temp + "°" + (target ? " / " + target + "°" : "") : "",
|
||||||
|
remain > 0 ? "noch " + remain + " min" : "",
|
||||||
|
].filter(Boolean).join(" · ");
|
||||||
|
}
|
||||||
|
const tempInput = box.querySelector(`[data-steer="dryer-temp-${id}"]`);
|
||||||
|
const durInput = box.querySelector(`[data-steer="dryer-dur-${id}"]`);
|
||||||
|
if (tempInput && document.activeElement !== tempInput && dryer.status === "drying" && dryer.target > 0) {
|
||||||
|
tempInput.value = dryer.target;
|
||||||
|
}
|
||||||
|
if (durInput && document.activeElement !== durInput && dryer.remain > 0) {
|
||||||
|
durInput.value = dryer.remain;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchCard(article, printer) {
|
function patchCard(article, printer) {
|
||||||
const state = shownState(printer);
|
const state = shownState(printer);
|
||||||
const clearNow = needsClear(printer);
|
const clearNow = needsClear(printer);
|
||||||
|
const shownFile = stickyFilename(printer, article.dataset.filename || "");
|
||||||
article.dataset.state = state;
|
article.dataset.state = state;
|
||||||
article.dataset.filename = printer.filename || "";
|
article.dataset.filename = shownFile;
|
||||||
article.dataset.progress = String(printer.progress || 0);
|
article.dataset.progress = String(printer.progress || 0);
|
||||||
article.dataset.family = printer.family || "";
|
article.dataset.family = printer.family || "";
|
||||||
article.dataset.needsClear = clearNow ? "1" : "";
|
article.dataset.needsClear = clearNow ? "1" : "";
|
||||||
@@ -551,16 +707,45 @@ function patchCard(article, printer) {
|
|||||||
setHtml(role("stats"), statsHtml(printer, state));
|
setHtml(role("stats"), statsHtml(printer, state));
|
||||||
setHtml(role("temps"), tempbarsHtml(printer, state));
|
setHtml(role("temps"), tempbarsHtml(printer, state));
|
||||||
const jobline = role("jobline");
|
const jobline = role("jobline");
|
||||||
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");
|
const job = role("job");
|
||||||
if (job) {
|
if (job) {
|
||||||
job.textContent = jobName(printer.filename) || "";
|
setText(job, jobName(shownFile) || "");
|
||||||
job.title = printer.filename || "";
|
job.title = shownFile || "";
|
||||||
}
|
}
|
||||||
setHtml(role("swatches"), swatchesHtml(printer));
|
setHtml(role("swatches"), swatchesHtml(printer));
|
||||||
setHtml(role("flow"), flowHtml(printer));
|
setHtml(role("flow"), flowHtml(printer));
|
||||||
setHtml(role("strips"), stripsHtml(printer, state));
|
setHtml(role("strips"), stripsHtml(printer, state));
|
||||||
setHtml(role("controls"), actionsHtml(state));
|
setHtml(role("controls"), actionsHtml(state, printer));
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshDeskCamera(printer) {
|
function refreshDeskCamera(printer) {
|
||||||
@@ -651,9 +836,10 @@ function patchDesk(printer) {
|
|||||||
if (!printer || printer.host !== deskHost) return;
|
if (!printer || printer.host !== deskHost) return;
|
||||||
const state = shownState(printer);
|
const state = shownState(printer);
|
||||||
const prevFile = deskEl.dataset.filename || "";
|
const prevFile = deskEl.dataset.filename || "";
|
||||||
const fileChanged = prevFile !== (printer.filename || "");
|
const shownFile = stickyFilename(printer, prevFile);
|
||||||
|
const fileChanged = prevFile !== shownFile;
|
||||||
deskEl.dataset.host = printer.host;
|
deskEl.dataset.host = printer.host;
|
||||||
deskEl.dataset.filename = printer.filename || "";
|
deskEl.dataset.filename = shownFile;
|
||||||
deskEl.dataset.progress = String(printer.progress || 0);
|
deskEl.dataset.progress = String(printer.progress || 0);
|
||||||
deskEl.classList.remove(...DESK_STATES);
|
deskEl.classList.remove(...DESK_STATES);
|
||||||
deskEl.classList.add(state);
|
deskEl.classList.add(state);
|
||||||
@@ -676,11 +862,12 @@ function patchDesk(printer) {
|
|||||||
setHtml(role("temps"), tempbarsHtml(printer, state));
|
setHtml(role("temps"), tempbarsHtml(printer, state));
|
||||||
|
|
||||||
const jobline = role("jobline");
|
const jobline = role("jobline");
|
||||||
if (jobline) jobline.hidden = !jobLineVisible(printer, state) && !printer.preview;
|
const jobVisible = jobLineVisible(printer, state, shownFile);
|
||||||
|
if (jobline) jobline.hidden = !jobVisible && !printer.preview;
|
||||||
const job = role("job");
|
const job = role("job");
|
||||||
if (job) {
|
if (job) {
|
||||||
job.textContent = jobName(printer.filename) || (jobLineVisible(printer, state) ? "" : "Kein Auftrag");
|
setText(job, jobName(shownFile) || (jobVisible ? "" : "Kein Auftrag"));
|
||||||
job.title = printer.filename || "";
|
job.title = shownFile || "";
|
||||||
}
|
}
|
||||||
const preview = role("preview");
|
const preview = role("preview");
|
||||||
if (preview) {
|
if (preview) {
|
||||||
@@ -696,7 +883,7 @@ function patchDesk(printer) {
|
|||||||
setHtml(role("facts"), factsHtml(printer));
|
setHtml(role("facts"), factsHtml(printer));
|
||||||
setHtml(role("flow"), flowHtml(printer));
|
setHtml(role("flow"), flowHtml(printer));
|
||||||
setHtml(role("strips"), stripsHtml(printer, state));
|
setHtml(role("strips"), stripsHtml(printer, state));
|
||||||
setHtml(role("controls"), actionsHtml(state));
|
setHtml(role("controls"), actionsHtml(state, printer));
|
||||||
const estop = role("estop");
|
const estop = role("estop");
|
||||||
if (estop) {
|
if (estop) {
|
||||||
const showStop = state === "printing" || state === "paused" || state === "heating";
|
const showStop = state === "printing" || state === "paused" || state === "heating";
|
||||||
@@ -709,10 +896,14 @@ function patchDesk(printer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const steer = role("steer");
|
const steer = role("steer");
|
||||||
if (steer && (!steer.querySelector(".steer") || fileChanged)) {
|
if (steer) {
|
||||||
setHtml(steer, steerPanelHtml(printer));
|
const panel = steer.querySelector(".steer");
|
||||||
} else {
|
const nextKey = aceSteerKey(printer);
|
||||||
patchSteerValues(deskEl, printer);
|
if (!panel || fileChanged || panel.dataset.aceKey !== nextKey) {
|
||||||
|
setHtml(steer, steerPanelHtml(printer));
|
||||||
|
} else {
|
||||||
|
patchSteerValues(deskEl, printer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setHtml(role("mesh"), heatMapHtml(printer));
|
setHtml(role("mesh"), heatMapHtml(printer));
|
||||||
|
|
||||||
@@ -725,8 +916,9 @@ async function syncDeskViewer(printer) {
|
|||||||
const canvas = deskEl.querySelector('[data-role="path-canvas"]');
|
const canvas = deskEl.querySelector('[data-role="path-canvas"]');
|
||||||
const caption = deskEl.querySelector('[data-role="path-caption"]');
|
const caption = deskEl.querySelector('[data-role="path-caption"]');
|
||||||
if (!canvas || !window.GrottiGcodeViewer || !window.THREE) return;
|
if (!canvas || !window.GrottiGcodeViewer || !window.THREE) return;
|
||||||
const filename = printer.filename || "";
|
const filename = stickyFilename(printer, deskEl.dataset.filename || "");
|
||||||
const progress = Number(printer.progress || 0);
|
const progress = Number(printer.progress || 0);
|
||||||
|
const gen = ++deskSyncGen;
|
||||||
if (!deskViewer) {
|
if (!deskViewer) {
|
||||||
deskViewer = GrottiGcodeViewer.create(canvas);
|
deskViewer = GrottiGcodeViewer.create(canvas);
|
||||||
deskViewer.setExcludeObjects({ objects: [], excluded: [], current: null }, {
|
deskViewer.setExcludeObjects({ objects: [], excluded: [], current: null }, {
|
||||||
@@ -741,45 +933,55 @@ async function syncDeskViewer(printer) {
|
|||||||
let file = matchLibraryFile(filename);
|
let file = matchLibraryFile(filename);
|
||||||
if (!file && filename && !wallPullTried.has(deskHost + "|" + filename)) {
|
if (!file && filename && !wallPullTried.has(deskHost + "|" + filename)) {
|
||||||
wallPullTried.add(deskHost + "|" + filename);
|
wallPullTried.add(deskHost + "|" + filename);
|
||||||
if (caption) caption.textContent = "lade vom Drucker …";
|
setText(caption, "lade vom Drucker …");
|
||||||
try {
|
try {
|
||||||
const saved = await api("/dateien/von-drucker", {
|
const saved = await api("/dateien/von-drucker", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
json: { host: deskHost, filename },
|
json: { host: deskHost, filename },
|
||||||
});
|
});
|
||||||
|
if (gen !== deskSyncGen) return;
|
||||||
libraryFiles = await api("/dateien");
|
libraryFiles = await api("/dateien");
|
||||||
|
if (gen !== deskSyncGen) return;
|
||||||
file = saved || matchLibraryFile(filename);
|
file = saved || matchLibraryFile(filename);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
/* bleibt leer */
|
/* bleibt leer */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!file) {
|
if (!file) {
|
||||||
|
// Kurz leerer filename: Pfad und Caption nicht verwerfen (sonst Flackern alle 5 s).
|
||||||
|
if (!filename && deskViewerFileId) return;
|
||||||
deskViewer.setPath({ points: [] });
|
deskViewer.setPath({ points: [] });
|
||||||
deskViewerFileId = null;
|
deskViewerFileId = null;
|
||||||
if (caption) caption.textContent = filename ? "Datei nicht in Grotti" : "kein Auftrag";
|
setText(caption, filename ? "Datei nicht in Grotti" : "kein Auftrag");
|
||||||
renderExcludePanel(printer);
|
renderExcludePanel(printer);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
let data = toolpathCache.get(file.id);
|
let data = toolpathCache.get(file.id);
|
||||||
const needLoad = !data || data.version !== 5 || deskViewerFileId !== file.id;
|
const cachedOk = data && Number(data.version) === 8;
|
||||||
if (needLoad) {
|
const needFetch = !cachedOk;
|
||||||
if (caption) caption.textContent = "wird gelesen …";
|
const needSetPath = deskViewerFileId !== file.id;
|
||||||
data = await api("/dateien/" + file.id + "/pfad");
|
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);
|
toolpathCache.set(file.id, data);
|
||||||
const sameFile = deskViewerFileId === file.id;
|
}
|
||||||
deskViewer.setPath(data, { keepView: sameFile });
|
if (needFetch || needSetPath) {
|
||||||
|
deskViewer.setPath(data, { keepView: deskViewerFileId === file.id });
|
||||||
deskViewerFileId = file.id;
|
deskViewerFileId = file.id;
|
||||||
}
|
}
|
||||||
if (!(data.points && data.points.length)) {
|
if (!(data.points && data.points.length)) {
|
||||||
if (caption) caption.textContent = "leer in Datei";
|
setText(caption, "leer in Datei");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// File-Id behalten — sonst setzt der nächste Poll die 3D-Ansicht zurück
|
// File-Id behalten — sonst setzt der nächste Poll die 3D-Ansicht zurück
|
||||||
if (caption) caption.textContent = error.message || "fehlgeschlagen";
|
if (gen !== deskSyncGen) return;
|
||||||
|
setText(caption, error.message || "fehlgeschlagen");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (gen !== deskSyncGen) return;
|
||||||
deskViewer.setProgress(progress, {
|
deskViewer.setProgress(progress, {
|
||||||
layer: printer.layer,
|
layer: printer.layer,
|
||||||
layers: printer.layers,
|
layers: printer.layers,
|
||||||
@@ -800,7 +1002,7 @@ async function syncDeskViewer(printer) {
|
|||||||
const layerTxt = printer.layers
|
const layerTxt = printer.layers
|
||||||
? " · Schicht " + (printer.layer || 0) + "/" + printer.layers
|
? " · Schicht " + (printer.layer || 0) + "/" + printer.layers
|
||||||
: "";
|
: "";
|
||||||
if (caption) caption.textContent = file.name + " · " + Math.round(progress * 100) + " %" + layerTxt;
|
setText(caption, jobName(file.name) + " · " + Math.round(progress * 100) + " %" + layerTxt);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderExcludePanel(printer) {
|
function renderExcludePanel(printer) {
|
||||||
@@ -1000,8 +1202,8 @@ async function refresh() {
|
|||||||
if (!libraryFiles.length) {
|
if (!libraryFiles.length) {
|
||||||
try { libraryFiles = await api("/dateien"); } catch (error) { libraryFiles = []; }
|
try { libraryFiles = await api("/dateien"); } catch (error) { libraryFiles = []; }
|
||||||
}
|
}
|
||||||
if (!wallSpools.length) {
|
if (!wallSpools.length || deskHost) {
|
||||||
try { wallSpools = await api("/spulen"); } catch (error) { wallSpools = []; }
|
try { wallSpools = await api("/spulen"); } catch (error) { /* behalten */ }
|
||||||
}
|
}
|
||||||
const all = await api("/drucker");
|
const all = await api("/drucker");
|
||||||
const printers = sortPrinters(all.slice());
|
const printers = sortPrinters(all.slice());
|
||||||
@@ -1223,11 +1425,35 @@ async function handleWallClick(event) {
|
|||||||
method: "POST", json: { action: "fan", percent: 0 },
|
method: "POST", json: { action: "fan", percent: 0 },
|
||||||
});
|
});
|
||||||
toast("Heizung und Lüfter aus.");
|
toast("Heizung und Lüfter aus.");
|
||||||
|
} else if (go === "dryer_start") {
|
||||||
|
const id = Number(steerBtn.dataset.dryerId);
|
||||||
|
const temp = val("dryer-temp-" + id);
|
||||||
|
const duration = val("dryer-dur-" + id);
|
||||||
|
await api("/drucker/" + encodeURIComponent(host) + "/steuern", {
|
||||||
|
method: "POST",
|
||||||
|
json: { action: "dryer_start", id, temp, duration },
|
||||||
|
});
|
||||||
|
toast("Trockner gestartet.");
|
||||||
|
} else if (go === "dryer_stop") {
|
||||||
|
const id = Number(steerBtn.dataset.dryerId);
|
||||||
|
await api("/drucker/" + encodeURIComponent(host) + "/steuern", {
|
||||||
|
method: "POST",
|
||||||
|
json: { action: "dryer_stop", id },
|
||||||
|
});
|
||||||
|
toast("Trockner gestoppt.");
|
||||||
|
} else if (go === "slot_setzen") {
|
||||||
|
const aceSlot = Number(steerBtn.dataset.aceSlot);
|
||||||
|
await api("/drucker/" + encodeURIComponent(host) + "/steuern", {
|
||||||
|
method: "POST",
|
||||||
|
json: { action: "slot_setzen", ace_slot: aceSlot },
|
||||||
|
});
|
||||||
|
toast("Platz an den Drucker geschrieben.");
|
||||||
}
|
}
|
||||||
showNote("note", null);
|
showNote("note", null);
|
||||||
refresh();
|
refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showNote("note", error);
|
showNote("note", error);
|
||||||
|
toast(error.message, "bad");
|
||||||
} finally {
|
} finally {
|
||||||
steerBtn.disabled = false;
|
steerBtn.disabled = false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user