8 Commits
85 changed files with 6606 additions and 471 deletions

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
---
description: Grotti-Konventionen für Agenten (Changelog, Doku, Version, Sicherheit)
alwaysApply: true
---
# Grotti
Grotti ist ein eigenständiges Repo. Alles, was das Tool betrifft, bleibt hier.
- Sprache: Deutsch, in Oberfläche, Meldungen, Doku und Commits.
- Jede Änderung bekommt einen Eintrag in `CHANGELOG.md` unter `[Unreleased]` (Neu, Geändert, Behoben).
- Version in `grotti/version.py` nach SemVer: Patch für Fehlerbehebung, Minor für Funktion, Major für Bruch an API oder Datenbankschema. Beim Release `[Unreleased]` in `[x.y.z] – Datum` umbenennen. Git-Tag `vX.Y.Z`.
- Nutzer-Anleitung: `docs/Anleitung-*.md` unter `/doku`. Technik/Debug: `docs/internal/` — nicht in der App.
- CSS-Version in `web/templates/layout.html` (`grotti.css?v=`) erhöhen, wenn sich `grotti.css` ändert.
- Keine freie G-code-Konsole, kein `FIRMWARE_RESTART` (GoKlipper deadlockt). Druckstarts brauchen `confirm: true`.
- Auth: Session oder API-Token (`grt_…`); Rollen admin/operator.
Druckbeobachtungen und Slicer-Profile gehören nicht hierher, sondern in den FDM-Wissen-Tresor (`D:\Projekte\3DP\FDM\Wissen\`).
+1 -1
View File
@@ -5,6 +5,6 @@ Grotti ist ein eigenständiges Repo. Alles, was das Tool betrifft, bleibt hier.
- Sprache: Deutsch, in Oberfläche, Meldungen, Doku und Commits.
- Jede Änderung bekommt einen Eintrag in `CHANGELOG.md` unter `[Unreleased]` (Neu, Geändert, Behoben).
- Version in `grotti/version.py` nach SemVer: Patch für Fehlerbehebung, Minor für Funktion, Major für Bruch an API oder Datenbankschema. Beim Release `[Unreleased]` in `[x.y.z] – Datum` umbenennen. Git-Tag `vX.Y.Z`.
- Technische Doku liegt in `docs/` und wird unter `/doku/<name>` gerendert. Neue Erkenntnisse zum Tool dort ergänzen.
- Nutzer-Anleitung: `docs/Anleitung-*.md`, gerendert unter `/doku`. Technik und Debug: `docs/internal/` — erscheint **nicht** in der App (inkl. MCP.md, Anycubic-Stack, …).
- CSS-Version in `web/templates/layout.html` (`grotti.css?v=`) erhöhen, wenn sich `grotti.css` ändert.
- Keine freie G-code-Konsole, kein `FIRMWARE_RESTART` (GoKlipper deadlockt). Druckstarts brauchen `confirm: true`.
+77
View File
@@ -2,6 +2,83 @@
Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), Versionen nach [SemVer](https://semver.org/lang/de/).
## [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
### Neu
- **Benutzerverwaltung:** lokaler Login (Admin/Operator), Bootstrap „Ersten Admin anlegen“, Session-Cookies, CSRF, Login-Rate-Limit.
- **API-Tokens** (`grt_…`) unter Einstellungen für App, Home-Automation und MCP; Shared-`config.token` bleibt als Migrationsbrücke (Admin).
- **MCP-Server** (`python -m grotti.mcp_server`): Lesen + bestätigte Steuer-Tools; `GROTTI_MCP_READ_ONLY`; Doku `docs/internal/MCP.md`.
- OIDC-Platzhalter in Config (`oauth`) für späteren Anschluss.
- Schema: `users`, `api_tokens`, `oauth_identities`.
- **Logo / Favicon:** Markenzeichen in der Sidebar und als Favicon (`web/static/brand/`).
- Login: statt Nutzhinweis wechselnde Ingenieurszitate (ein Zitat pro Tag).
- Umfangreiche Bedienungsanleitung unter `/doku` (`docs/Anleitung-*.md`) mit UI-Screenshots.
- Task-Hygiene wie Rinkhals (Kobra 3 und S1): `ensure_task_free` vor Start; bei **10101** cancel + Retry.
- Live-Snapshot `anycubic_task` auf der Wand.
- Cursor-Regel `.cursor/rules/grotti.mdc`.
### Geändert
- `/doku` zeigt nur noch `Anleitung-*.md`; Technik/Debug unter `docs/internal/` (nicht in der App). Changelog nicht mehr in der UI.
- Anleitungen ohne interne Stack-/Debug-Details.
- HTML und API hinter Auth (außer Setup/Login); kein Token mehr im Meta-Tag.
- Kamera im Cockpit: Layout-Klassen bleiben erhalten; ohne erreichbare Webcam kein Platzreservierung; `has_camera` im Drucker-Payload.
- `POST /api/v1/druck-queue` braucht `confirm: true` (wie Senden).
### Behoben
- Cockpit: periodisches Layout-Ruckeln durch `className`-Reset der Kamera-Folds.
- Kameras-Seite und leere Flotte: echte Empty-States statt Raw-API-URL / leerem Raster.
- Spulen-Etikett: QR-Code wieder sichtbar — `qrcode.min.js` lokal.
## [2.0.0] – 2026-10-15
### Neu
+12 -6
View File
@@ -51,7 +51,7 @@ Die Familie (Kobra 3 oder S1) liest Grotti aus dem Namen.
.\.venv\Scripts\python.exe server.py
```
Dann <http://127.0.0.1:8770/> öffnen. Handbuch in der Oberfläche unter **Doku**.
Dann <http://127.0.0.1:8770/> öffnen. Bedienungsanleitung mit Screenshots in der Oberfläche unter **Doku** (Kapitel `Anleitung-*`), Technik-Notizen darunter.
`config.json` und `data/` sind lokal und nicht im Git.
@@ -62,33 +62,39 @@ Dann <http://127.0.0.1:8770/> öffnen. Handbuch in der Oberfläche unter **Doku*
| `server.py` | Start, Datenbank vorbereiten, Hintergrund-Threads |
| `grotti/` | Kern: Moonraker, Warteschlange (`print_queue.py`), Senden (`send_queue.py`), Abräumen (`eject.py`), Zustandswächter (`watcher.py`), Ereignisse und Telegram (`notify.py`), Datenbank (`store.py`, `schema.sql`) |
| `web/` | Flask-App (`app.py`), Seiten (`templates/`), CSS und JS (`static/`) |
| `docs/` | Technische Notizen, gerendert unter `/doku/<name>` |
| `docs/` | Bedienungsanleitung (`Anleitung-*.md`) unter `/doku`; Technik unter `docs/internal/` (nicht in der App) |
| `web/static/doku/` | Screenshots für die Anleitung |
| `CHANGELOG.md` | Änderungen je Version |
## API
Alles unter `/api/v1`, JSON. Die wichtigsten Endpunkte:
Alles unter `/api/v1`, JSON. Auth: Session-Cookie (UI) oder `Authorization: Bearer grt_…` (API-Token unter Einstellungen). Optional noch Shared-`token` in der Config (Migrationsbrücke).
| Methode und Pfad | Zweck |
| --- | --- |
| `POST /auth/setup`, `/auth/login`, `/auth/logout` | Ersten Admin, Login, Logout |
| `GET /me`, `/users`, `/tokens` | Konto, Benutzer (Admin), API-Tokens |
| `GET /version` | Version |
| `GET /drucker?live=1` | Drucker mit Live-Zustand, laufendem und wartendem Auftrag |
| `POST /drucker/<host>/<aktion>` | Pause, Fortsetzen, Abbrechen, Fehler löschen |
| `POST /drucker/<host>/steuern` | Temperatur, Speed, Flow, Lüfter |
| `GET /dateien`, `POST /dateien` | Bibliothek, Upload |
| `POST /senden` | Datei an Drucker. `confirm: true` ist Pflicht. `when_free: true` reiht ein. |
| `GET /druck-queue`, `PATCH /druck-queue/<id>` | Warteschlange, Abbrechen (`status: cancelled`), Ablauf fortsetzen (`resume`) |
| `GET /druck-queue`, `POST /druck-queue`, `PATCH /druck-queue/<id>` | Warteschlange (`confirm` beim Einreihen) |
| `POST /spulen/<id>/einlegen` | Spule an Drucker/Platz; alte Rolle dort ins Regal |
| `POST /ablauf` | Ablauf für Kobra 3 einreihen |
| `GET /ereignisse?host=&kind=&since=&limit=` | Aktivität |
| `POST /ereignisse/gelesen` | Alerts als gelesen |
| `GET /spulen`, `GET /laeufe`, `GET /projekte` | Material, Protokoll, Projekte |
MCP: `python -m grotti.mcp_server` mit `GROTTI_URL` und `GROTTI_TOKEN` (siehe `docs/internal/MCP.md`).
## Sicherheit
- Login Pflicht (Setup legt den ersten Admin an). Rollen: Admin, Operator.
- Druckstarts brauchen `confirm: true`. Die Oberfläche fragt per Zwei-Klick.
- Keine freie G-code-Konsole.
- Kein `FIRMWARE_RESTART`, GoKlipper hängt sich dabei auf. Fehler werden über „Fehler löschen“ geräumt.
- Keine freie G-code-Konsole; Firmware-Neustart über Grotti ist gesperrt.
- Für Internet: Reverse Proxy mit TLS; nicht den Entwickler-Server öffentlich freigeben.
## Versionen und Changelog
+8
View File
@@ -5,8 +5,16 @@
"watch_dir": "",
"database_url": "postgresql://[email protected]:5432/grotti",
"token": "",
"secret_key": "",
"fqdn": "",
"auto_queue": true,
"oauth": {
"enabled": false,
"issuer": "",
"client_id": "",
"client_secret": "",
"redirect_uri": ""
},
"energy": {
"watts": 120,
"euro_per_kwh": 0.35,
+40
View File
@@ -0,0 +1,40 @@
# ACE und Spulen
Stand **17.10.2026**. Mehrfarb-Drucke brauchen vor dem Start eine Zuordnung Datei-Farbe → ACE-Platz und eine passende Spule am Drucker.
![ACE-Zuordnung beim Senden](../web/static/doku/ace-zuordnung.png)
## Spule einlegen
1. Unter **Material → Spulen** die Rolle wählen oder anlegen.
2. Ort = Drucker und **ACE-Platz** (1–8) oder externe Spule.
3. Materialtyp und Farbe sollten zur Slice-Datei passen.
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.md).
## ACE-Map beim Senden
Unter **Dateien → Optionen → ACE-Zuordnung**:
- Jede Farbe aus der Datei (`paint_index`) einem **ACE-Platz** zuordnen
- Liegt unter Material eine Spule auf dem Platz, zeigt die Liste Name, Farbe und Restgramm mit
- Ohne passende Map schreibt der Drucker oft **„cannot parse the file“** und startet nicht
- Grotti legt die Sidecar `*.gcode.acm` an und schickt sie mit
Externe Spule / keine ACE-Slots: keine `.acm` nötig.
## Typische Fehler
| Symptom | Check |
| --- | --- |
| „cannot parse the file“ | ACE-Zuordnung vollständig? Farben richtig? |
| Material-Warnung | Eingelegte Sorte ≠ Datei — Spule wechseln oder Slice prüfen |
| Rest zu gering | Andere Spule oder leichteres Teil |
| ACE blinkt nicht / kein Wechsel | Spule und Platz prüfen, ggf. Druck neu starten |
## Weiter
- [Dateien senden](Anleitung-Dateien.md)
- [Material](Anleitung-Material.md)
+49
View File
@@ -0,0 +1,49 @@
# Dateien senden
Stand **15.10.2026**. Seite `/dateien` — Bibliothek links, Senden rechts.
![Dateien: Bibliothek und Senden](../web/static/doku/dateien-flow.png)
## Bibliothek
- Suche, Filter nach Maschine und Projekt
- Vorschaubild, Material, Zeit, Gewicht, Schichthöhe
- Info-Popup mit Slice-Metadaten (Kopf und Footer)
- **Hochladen** per Knopf oder Drag & Drop
- **Ordner neu lesen**, wenn `watch_dir` in der Config auf den Slice-Ordner zeigt
Ohne `watch_dir` geht nur manueller Upload — das reicht für den Alltag.
## Senden: drei Schritte
1. **Datei** wählen (Klick in der Liste).
2. **Drucker** wählen — Kacheln zeigen Live-Zustand und eingelegte Rolle. Passt die Maschine nicht, ist die Kachel blass.
3. **Wann:**
- **Jetzt** — sofort senden und starten (Zwei-Klick)
- **Wenn frei** — in die Warteschlange für diesen Drucker
- **Nächster freier** — erster passender freier Drucker
Auswahl merkt sich der Browser (`localStorage`).
### Optionen
Unter Optionen u. a.:
- **Bettnetz / Auto-Level** beim MQTT-Start (Anycubic)
- Maschinen-Override, Projekt zuweisen
- **ACE-Zuordnung** — siehe [ACE und Spulen](Anleitung-ACE.md)
## Warum es dauert
Große Dateien brauchen oft **eine Minute**. In der Sidebar unter **Senden** siehst du den MB-Fortschritt. Danach startet der Druck.
## Nach dem Start
- Fortschritt auf der [Wand](Anleitung-Wand.md) und in der Sidebar **Läuft jetzt**
- Bei „Wenn frei“: Eintrag unter [Warteschlange](Anleitung-Warteschlange.md)
- Später unter **Protokoll** mit **Nochmal** (inkl. gespeicherter ACE-Map)
## Weiter
- [ACE und Spulen](Anleitung-ACE.md)
- [Warteschlange](Anleitung-Warteschlange.md)
+66
View File
@@ -0,0 +1,66 @@
# Einrichtung
Stand **15.10.2026**. Grotti auf dem Haus-PC installieren und den Browser öffnen. Die Drucker brauchen vorher Moonraker (Rinkhals auf Kobra 3 und S1).
## Was du brauchst
- Python 3.11
- PostgreSQL (leere Datenbank `grotti` reicht; Schema legt Grotti an)
- Drucker im selben Netz, Moonraker auf Port 80
- Dieses Repo und eine `config.json`
## Einmalig einrichten
Im Projektordner:
```powershell
cd D:\Projekte\Grotti
python -m venv .venv
.\.venv\Scripts\pip install -r requirements.txt
copy config.example.json config.json
```
`config.json` anpassen. Die wichtigsten Schlüssel:
| Schlüssel | Bedeutung |
| --- | --- |
| `host`, `port` | Wo die Oberfläche lauscht. Standard `127.0.0.1:8770`. |
| `database_url` | PostgreSQL-Verbindung. |
| `data_dir` | Uploads, Vorschaubilder, Cache. Standard `data`. |
| `watch_dir` | Slice-Ordner, den Grotti beobachtet. Leer = nur manueller Upload. |
| `token` | Optionaler Shared-API-Token (Übergang). Mit Benutzerverwaltung nutzt du API-Tokens unter Einstellungen. |
| `fqdn` | Öffentliche Basis-URL für QR auf Spulen-Etiketten. Leer = Request-Host. |
| `auto_queue` | `false` stoppt neue „wenn frei“-Starts. |
| `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. |
Drucker beim ersten Start über `drucker.json` (Seed). Danach pflegt man sie unter [Einstellungen](Anleitung-Einstellungen.md).
```json
[{ "name": "Kobra 3 da26", "host": "10.10.1.60" }]
```
Die Familie (Kobra 3 oder S1) liest Grotti aus dem Namen.
## Starten
```powershell
.\.venv\Scripts\python.exe server.py
```
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.
![Druckerwand nach dem Start](../web/static/doku/wand-uebersicht.png)
## Sicherheit
- Druckstart nur mit Bestätigung (Zwei-Klick in der UI bzw. `confirm: true` in der API).
- Keine freie G-code-Konsole; Firmware-Neustart über Grotti ist gesperrt.
- Benutzer und API-Tokens unter Einstellungen. Für Zugriff aus dem Internet: Reverse Proxy mit TLS, nicht den Entwickler-Server öffentlich freigeben.
- `config.json` und `data/` bleiben lokal, nicht im Git.
## Weiter
- [Oberfläche](Anleitung-Oberflaeche.md) — Navigation und Sidebar
- [Dateien senden](Anleitung-Dateien.md) — erster Druck
- Ausführlich im `README.md` im Projektordner
+44
View File
@@ -0,0 +1,44 @@
# Einstellungen
Stand **15.10.2026**. Seite `/einstellungen` — Drucker, Automatik, Anzeige, Telegram, Energie.
![Einstellungen: Drucker](../web/static/doku/einstellungen-drucker.png)
## Dieser Browser
- **Desktop** oder **Regal (Touch)** — nur lokal im Browser, siehe [Oberfläche](Anleitung-Oberflaeche.md).
## Automatik
Checkbox **Automatik-Warteschlange** (`auto_queue`). Aus = keine neuen „wenn frei“-Starts. Laufende Abläufe und manuelle „Jetzt“-Starts bleiben möglich.
## Drucker
Tabelle: Name, Host, Familie, **gesperrt**.
- Seed beim allerersten Start aus `drucker.json`, danach nur noch hier (Datenbank).
- Familie aus dem Namen (Kobra 3 / S1) oder gesetzt.
- **Gesperrt:** keine neuen Queue-Starts auf diesem Host.
## FQDN
Öffentliche Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host aus dem aktuellen Request. Wichtig, wenn du Etiketten von einem Handy scannst, das nicht `127.0.0.1` kennt.
## Telegram und Alerts
Bot-Token und Chat-ID. Pro Ereignisart an/aus (Fertig, Fehler, Offline, Queue, …). Test-Knopf zum Prüfen. Wichtige Ereignisse stehen auch in der Sidebar unter Alerts.
## Benutzer und API-Tokens
Unter **Benutzer** (nur Admin) legst du Konten mit Rolle Admin oder Operator an. Unter **API-Tokens** erzeugst du Schlüssel für Home-Automation, App oder MCP — der Klartext erscheint nur einmal (`Authorization: Bearer grt_…`).
MCP (optional): Token erzeugen, dann `python -m grotti.mcp_server` mit `GROTTI_URL` und `GROTTI_TOKEN`. Für nur Lesen: `GROTTI_MCP_READ_ONLY=1`.
## Energie
Watt und Strompreis für die Kosten im **Protokoll**. Optional Trockner/Ausschuss/Auslastung für die Kalkulation.
## Weiter
- [Einrichtung](Anleitung-Einrichtung.md) (Config-Datei)
- [Störungen](Anleitung-Stoerungen.md)
+50
View File
@@ -0,0 +1,50 @@
# Material und mehr
Stand **17.10.2026**. Spulen, Etiketten, Projekte, Protokoll, Statistik, Kameras, Aktivität und Ideen.
![Material: Spulen](../web/static/doku/material-spule.png)
## Material
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). Unter der ausgewählten Spule die **Akte**: verdruckte Gramm, Ausschuss, Leer-in.
**Sorten:** Profile (Dichte, Preis, Maschinen). „Spule damit anlegen“ wechselt zum Spulen-Reiter.
### Etikett und QR
Pro Spule ein Etikett mit Chargennummer `S-…` und QR. Der Code öffnet Material mit der Spule vorausgewählt. Dafür in den Einstellungen ggf. **FQDN** setzen (öffentliche URL), sonst gilt der Host aus dem Request.
Druck-Etiketten (`D-…`) kommen vom Druckbericht: ein Etikett für den ganzen Lauf oder je Objekt. Scan von `D-…` öffnet den Bericht.
## Projekte
Unter **Projekte** Aufträge mit Notiz und Kalkulation. Dateien lassen sich einem Projekt zuweisen (Bibliothek / Senden).
## Protokoll und Statistik
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` — alle Snapshots nebeneinander mit Live-Zustand. Praktisch auf dem Regal-Tablet.
## Aktivität
Zeitstrahl: Starts, Schritte, Fertig, Fehler, Abbrüche, offline. Filter nach Drucker, Art, Zeitraum. Ungelesene Alerts sind markiert; in der Sidebar kannst du sie als gelesen setzen.
## Ideen
`/ideen` — Notizen und Ideen für später. Kein Druckfluss, nur Merkliste.
## Weiter
- [ACE und Spulen](Anleitung-ACE.md)
- [Einstellungen](Anleitung-Einstellungen.md)
+37
View File
@@ -0,0 +1,37 @@
# Oberfläche
Stand **15.10.2026**. Wie die App aufgebaut ist — Menü, Live-Leiste, Desktop und Regal.
## Navigation
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.
![Seitenleiste mit Live-Boxen](../web/static/doku/sidebar-live.png)
## Live-Leiste
Unter dem Menü, eigene Scrollfläche. Boxen auf- und zuklappbar (Merker im Browser):
| Box | Inhalt |
| --- | --- |
| **Läuft jetzt** | Laufende Aufträge und Abläufe, Schrittleiste, Abbrechen. Auch Direktstarts ohne Queue-Zeile. |
| **Wartet** | Wartende Aufträge: Priorität, Abbrechen. Gestoppte Abläufe mit Fortsetzen. |
| **Aufgaben** | Platte belegt ohne laufenden Ablauf — **geräumt** (Zwei-Klick). |
| **Senden** | Uploads mit MB-Fortschritt. |
| **Alerts** | Ungelesene wichtige Ereignisse; als gelesen markieren oder zur Aktivität. |
## Desktop und Regal
Unter **Einstellungen → Dieser Browser**:
- **Desktop** — normale Dichte
- **Regal (Touch)** — größere Bedienelemente, kompaktere Nav, Sidebar oft eingeklappt
Shortcut in der URL: `?shelf=1` bzw. `?shelf=0`. Nur dieser Browser (`localStorage`).
## Weiter
- [Druckerwand](Anleitung-Wand.md)
- [Dateien senden](Anleitung-Dateien.md)
+60
View File
@@ -0,0 +1,60 @@
# Störungen
Stand **15.10.2026**. Was tun, wenn etwas hängt. Kurzfassung auch auf der [Doku-Startseite](/doku#haengt).
## Auftrag startet nicht
| Anzeige | Maßnahme |
| --- | --- |
| **Platte belegt** | Teil runter, dann **Platte geräumt** (Karte oder Sidebar → Aufgaben). |
| Drucker **gesperrt** | Unter Einstellungen entsperren. |
| Automatik aus | Einstellungen → Automatik-Warteschlange an. |
| Falsche Maschine | Andere Datei oder anderen Drucker wählen. |
## Fehlerzustand auf dem Drucker
Auf der Karte **Fehler löschen** (Zwei-Klick). Grotti räumt den hängenden Fehler und schaltet die Heizung aus. Ein Neustart der Firmware über Grotti ist absichtlich nicht möglich — bei Bedarf Display-Neustart oder Strom aus/an.
Hängende Fehler vor einem Start räumt Grotti oft selbst, wenn kein Druck aktiv ist.
## Ablauf gestoppt
1. Grund in Sidebar / Karte / Aktivität lesen (Schieben, Tasten, Timeout, …).
2. Mechanisch klären (Kiste, Teil, Platte).
3. **Weiter** ab dem fehlgeschlagenen Schritt — oder Verwerfen und neu einreihen.
## Start scheitert nach Abbruch
Manchmal bleibt nach einem Abbruch ein alter Auftrag auf dem Drucker hängen. Der nächste Start scheitert dann oft mit einer Fehlermeldung (z. B. Code **10101**).
Was tun: Abbruch über Grotti (nicht nur am Display), auf der Wand den Task-Hinweis prüfen, erneut starten. Bleibt es hängen: Strom aus und wieder an.
## ACE / „cannot parse“
ACE-Zuordnung unter Dateien → Optionen prüfen. Spulen am richtigen Platz. Siehe [ACE und Spulen](Anleitung-ACE.md).
## Offline
Strom, WLAN und Erreichbarkeit des Druckers prüfen. Grotti meldet „wieder erreichbar“ unter Aktivität und ggf. Telegram.
## E-Stop
Im Cockpit (nur bei aktivem Druck/Heizen). Danach oft **Strom aus und wieder an**. Firmware-Neustart über Grotti ist absichtlich gesperrt.
## Upload hängt bei großen Dateien
Oft noch Verarbeitung auf dem Drucker. In der Sidebar unter **Senden** den Fortschritt beobachten. Nach einem Grotti-Update den Dienst einmal neu starten.
## „gcode … not found“ (10115)
Oft Dateinamen mit Nicht-ASCII-Zeichen (z. B. Chinesisch). Grotti normalisiert den Namen beim Senden auf ASCII. Nochmal senden; in der Bibliothek darf der Originalname bleiben.
## Wo nachschauen
- Sidebar: Alerts, Läuft, Wartet, Senden
- **Aktivität** — Zeitstrahl mit Filtern
## Weiter
- [Druckerwand](Anleitung-Wand.md)
- [Warteschlange](Anleitung-Warteschlange.md)
+54
View File
@@ -0,0 +1,54 @@
# Druckerwand
Stand **15.10.2026**. Startseite `/` — eine Karte je Drucker. Klick öffnet das Cockpit.
![Druckerwand](../web/static/doku/wand-uebersicht.png)
## Karte
Pro Drucker siehst du:
- Zustand (Chip), Fertig-Uhrzeit, Fortschrittsbalken
- Restzeit, Schicht, Laufzeit
- Temperaturen Düse/Bett (mit Ziel)
- Dateiname, bei ACE oft Farb-Swatches
- Bei Ablauf: Schrittleiste und Abbrechen
Aktionen auf der Karte brauchen oft **zwei Klicks** (erster Klick „scharf“, zweiter bestätigt):
| Aktion | Wann |
| --- | --- |
| **Pause** / **Weiter** | Während des Drucks |
| **Abbrechen** | Druck oder Pause |
| **Fehler löschen** | Zustand Fehler — räumt den hängenden Fehler, Heizung aus |
| **Platte geräumt** | Nach Fertig, wenn die Platte wieder frei ist |
| **Weiter** (Ablauf) | Gestoppter Ablauf — ab dem fehlgeschlagenen Schritt, wenn die Kiste steht |
Während eines Abräum-Ablaufs blendet Grotti „Platte geräumt“ aus — der Ablauf räumt selbst.
## Cockpit
URL mit Hash: `/#<host>` (z. B. nach Klick auf die Karte).
![Cockpit eines Druckers](../web/static/doku/cockpit.png)
Inhalt:
- **Kamera** — nur wenn eine Webcam erreichbar ist; sonst bleibt der Platz weg
- Status, Temps inkl. Bauraum wo verfügbar, Lüfter
- **Steuerung:** Zieltemperaturen, Speed, Flow, Fan — fest vorgegebene Befehle, keine freie Konsole. Mit ACE zusätzlich Trockner (Temp, Dauer, Start/Stopp) und pro bestücktem Platz **an den Drucker** (Sorte und Farbe aus der Grotti-Spule)
- **E-Stop** nur bei printing/paused/heating (Zwei-Klick). Danach oft Strom aus/an
- **3D-Pfad** der laufenden oder gewählten Datei; Objekte ausschließen (mit Bestätigung)
- **Bettnetz**-Heatmap, wenn vorhanden
## Tipps
- Gesperrte Drucker (Einstellungen) starten keine neuen „wenn frei“-Jobs.
- 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.md).
## Weiter
- [Dateien senden](Anleitung-Dateien.md)
- [Warteschlange und Ablauf](Anleitung-Warteschlange.md)
- [Störungen](Anleitung-Stoerungen.md)
+52
View File
@@ -0,0 +1,52 @@
# Warteschlange und Ablauf
Stand **15.10.2026**. Aufträge „wenn frei“, Priorität, und der Kobra-3-Ablauf (drucken → abkühlen → schieben → tasten).
![Warteschlange](../web/static/doku/warteschlange.png)
## Seite Warteschlange
Filter: **Läuft** / **Wartet** / **Fehler** / **Historie** (und „Alle aktiv“).
Pro Auftrag:
- Priorität hoch/runter
- **Abbrechen**
- Bei gestopptem Ablauf: **Fortsetzen** (ab dem fehlgeschlagenen Schritt)
Unter **Läuft** siehst du auch Direktstarts ohne Queue-Zeile (Link zur Wand).
Dieselbe Information steht kompakt in der Sidebar (**Läuft jetzt** / **Wartet**).
## Wann startet ein wartender Auftrag?
Sobald der Drucker bereit ist (`standby` / fertig / abgebrochen), die **Platte frei** ist und der Drucker nicht gesperrt. Automatik aus (`auto_queue`) oder gesperrt → keine neuen Starts; laufende Abläufe laufen weiter.
## Ablauf (nur Kobra 3)
Unter **Dateien → Ablauf** eine Kette bauen und einreihen:
```text
Drucken → Abkühlen → Schieben → Tasten → (Wiederholung)
```
| Schritt | Bedeutung |
| --- | --- |
| **Abkühlen** | Wartet, bis Bett und Düse unter der Grenze sind. PLA löst sich oft von selbst. |
| **Schieben** | Extruder schiebt das Teil von hinten nach vorn in die Kiste. |
| **Tasten** | LeviQ-Netz tippt; Maß aus `Result is z=`. Über 1 mm stoppt den Ablauf. |
Aktueller Schritt: auf der Druckerkarte und in der Sidebar. Abbrechen an beiden Stellen. Jeder Schritt landet unter **Aktivität**.
**Schieben testen** und „Bett ist leer“: im Ablauf-Reiter, bevor du unbeaufsichtigt läuft.
## Gestoppter Ablauf
1. Ursache beheben (Kiste voll, Teil hängt, Tasten zu hoch, …).
2. Auf der Karte oder in der Sidebar **Weiter** — nicht nur neu einreihen.
3. Oder verwerfen / abbrechen und später neu starten.
## Weiter
- [Druckerwand](Anleitung-Wand.md)
- [Störungen](Anleitung-Stoerungen.md)
File renamed without changes.
File renamed without changes.
+141
View File
@@ -0,0 +1,141 @@
# Anycubic-Stack: wie Druck, Abbruch und ACE wirklich laufen
Stand **15.10.2026**. Quellen: Anycubic [Klipper-go](https://github.com/ANYCUBIC-3D/Klipper-go), [K3-klipper-mcu](https://github.com/ANYCUBIC-3D/K3-klipper-mcu); Rinkhals `kobra.py`, `mmu_ace.py`, `mmu_ace_metadata.py`, Docs `firmware/mqtt.md` und `firmware/goklipper.md` (lokal unter `D:\Projekte\_ref\`). Nicht geraten — am Quellcode und an der Rinkhals-Doku gelesen.
## Schichten
```mermaid
flowchart TB
subgraph host [PC]
Grotti[Grotti]
end
subgraph printer [Kobra 3 / S1]
Moonraker[Moonraker + kobra.py]
MmuAce[mmu_ace + mmu_ace_metadata]
Gkapi[gkapi MQTT Broker]
Gklib[gklib GoKlipper]
Ui[K3SysUi Touch]
Mcu[MCU Firmware]
AceHw[ACE Hardware]
end
Grotti -->|"HTTP :80 upload / print/start / cancel"| Moonraker
Grotti -->|"MQTTS :9883 print start/stop"| Gkapi
Grotti -->|"HTTP :18910 info/ctrl Credentials"| Gkapi
Moonraker -->|"bei print/start oft MQTT lokal :2883"| Gkapi
Moonraker --> Gklib
MmuAce --> Moonraker
Gkapi --> Gklib
Ui --> Gkapi
Gklib --> Mcu
Gklib --> AceHw
```
| Komponente | Offen? | Rolle |
| --- | --- | --- |
| **gklib** (GoKlipper) | teilweise ([Klipper-go](https://github.com/ANYCUBIC-3D/Klipper-go)) | Klipper-Host: Motion, Heater, `virtual_sdcard`, ACE-G-codes |
| **MCU** | teilweise ([K3-klipper-mcu](https://github.com/ANYCUBIC-3D/K3-klipper-mcu)) | Stepper/Sensoren |
| **gkapi** | nein (proprietär) | MQTT-Broker, Druck-Tasks, Cloud/LAN, Credentials `:18910` |
| **K3SysUi** | nein | Touch-UI |
| **Rinkhals Moonraker** | ja | Vanilla Moonraker + `kobra.py` + `mmu_ace*` |
Anycubic schreibt in den READMEs: proprietäre Features sind gekapselt. MQTT-Taskverwaltung und Cloud stecken nicht in Klipper-go.
## Zwei Welten beim Druckstart
1. **Klipper-Pfad** (Moonraker `POST /printer/print/start` → gklib `virtual_sdcard`): setzt `print_stats`, lädt G-code in gklib.
2. **Anycubic-Task** (MQTT `type: print`, `action: start` an Topic `…/slicer/printer/{modelId}/{deviceId}/print`): legt den Job an, den Display und Cloud kennen; steuert LeviQ/`auto_leveling`, ACE-Map in der Payload.
Rinkhals `kobra.py` `patch_mqtt_print` hängt den Moonraker-`print/start` oft auf MQTT um (`mqtt_print_file`), lokal `127.0.0.1:2883`. Grotti kann denselben Start von außen über **MQTTS `:9883`** mit Credentials aus `:18910` machen — dann mit eigener `task_settings` und `ams_settings` (siehe [Senden-Queue](Senden-Queue)).
### MQTT Start (belegt)
Aus Rinkhals-Doku `firmware/mqtt.md` und `kobra.py`:
- Publish: `anycubic/anycubicCloud/v1/slicer/printer/{modelId}/{deviceId}/print`
- Report: `…/printer/public/{modelId}/{deviceId}/print/report`
- Pflichtfelder Start: `filename`, `filetype` (1), `taskid`
- Optional: `ams_settings.ams_box_mapping`, `task_settings.auto_leveling` (0/1 Integer)
- Rinkhals und Grotti setzen `taskid` auf eine Zufallszahl; die Doc-Minimalbeispiel nutzt `"-1"` — beides kommt vor
### MQTT Stop (belegt) — warum 10101
```json
{ "type": "print", "action": "stop", "data": { "taskid": "-1" } }
```
Moonraker `/printer/print/cancel` trifft nur gklib (`print_stats` → `cancelled`). Der Anycubic-Task in gkapi bleibt → nächster MQTT-Start mit **code 10101** („Print task already exists“ / chinesische Meldung). Rinkhals `mqtt_print_file` kennt 10101 und **retryt einmal**; Grotti sendet bei Abbruch Moonraker-cancel **und** MQTT-stop.
### Task-Hygiene (wie Rinkhals)
Vorbild: `kobra.py` `mqtt_print_file` + `firmware/mqtt.md`. **Kobra 3 und Kobra S1** — dieselbe gkapi-API (`:18910` / MQTTS `:9883`). Gemessen 15.10.2026: S1 `modelId` 20025 / `state: free`, K3 `modelId` 20024; `project: null` zählt nicht als busy. Abräumen-Ablauf bleibt nur Kobra 3.
| Schritt | Verhalten in Grotti |
| --- | --- |
| Vor MQTT-Start | `ensure_task_free`: `info/query`; wenn `state` nicht free oder `project` gesetzt → MQTT-stop, bis zu 3× pollen |
| Vor Moonraker-`print/start` | dieselbe Hygiene best-effort (Rinkhals wandelt Start oft intern zu MQTT um) |
| gkapi kurz weg | `print_info` → `None`, negativer Cache ~45 s; Start über Moonraker läuft weiter |
| Report **10101** | einmal cancel + 1,5 s Pause + erneuter Start |
| Report **10107** | klare Filament-Meldung |
| Live | Snapshot-Feld `anycubic_task` (`state`, `task_id`, `filename`) aus gecachtem `print_info` (TTL ~10 s); Wand zeigt die Zeile |
Codes und Retry: `grotti/anycubic_lan.py` (`message_for_code`, `ensure_task_free`, `start_print`).
### Report-Timeout
`kobra.py` wartet max. 30 s auf `print/report`. Nach Bettnetz/`auto_leveling` kann der Report später kommen, der Druck trotzdem laufen (`info/query` → `project.task_id`). Grotti prüft bei Timeout deshalb `print_info` (MQTT `info/query`).
## FIRMWARE_RESTART
In **Klipper-go** ist der Befehl implementiert wie bei Klipper:
- `Cmd_FIRMWARE_RESTART` → `Request_restart("firmware_restart")` (`gcode.go`)
- Nach dem Reactor-Ende: Event `project:firmware_restart` → MCU `_firmware_restart` (`k3c.go`, `Mcu.go`)
Auf dem Gerät **deadlockt gklib** trotzdem (beobachtet, Rinkhals #32): Log zeigt Annahme des Befehls, danach keine weitere Verarbeitung, Moonraker bleibt in `startup`. Rinkhals `patch_klipper_restart` **lehnt** `RESTART` / `FIRMWARE_RESTART` unter GoKlipper ab. Grotti ebenso. Config: Display-Reboot oder Strom. Hängenden Fehler: **Fehler löschen** (`SDCARD_RESET_FILE`), nicht Firmware-Restart.
Der Open-Source-Code erklärt den Intent des Befehls; die Deadlock-Ursache steckt vermutlich im ausgelieferten Binary / der Anbindung an gkapi — nicht im veröffentlichten Happy Path.
## ACE
### In gklib (Klipper-go `extras_ace.go`)
G-codes: `ACE_CHANGE_TOOL`, `ACE_FEED`, `ACE_RETRACT`, `ACE_SET_SLOT`, Trocknen, Endless Spool, … Kommunikation über Seriell (`extras_ace_commun.go`). `Get_status` liefert ACE-`info` + Endless-Spool-Flags — **nicht** das Moonraker-Objekt `mmu` mit `gate_color`.
### In Rinkhals Moonraker (`mmu_ace.py`)
- Emuliert Happy-Hare-ähnliches **`mmu`**-Objekt (`gate_color`, `gate_material`, …) für Clients
- Beim MQTT-Start: `register_print_data_patcher` → wenn keine `ams_settings` in der Payload, baut Identity-/TTG-Map aus den Gates und hängt `ams_settings` an
- Liest/schreibt Sidecar **`basename.acm`** neben dem G-code; ohne ACE kann sie nach `.acm.disabled` umbenannt werden
- Auto-Feed-Logik vor Start, wenn nichts im Toolhead ist (Issue #464)
### Upload-Metadaten (`mmu_ace_metadata.py`)
Beim Moonraker-Upload: ganze Datei lesen, ggf. umschreiben. Fingerprint `; processed by MmuAcePatcher`. Trailer `; referenced_tools = 0,2,…`. Ohne Trailer: gklib „skipping auto-feed (no trailer found)“. Grotti setzt Fingerprint + Trailer vor dem Upload, damit der teure Durchlauf entfällt ([Senden-Queue](Senden-Queue)).
### `.acm` vs MQTT-`ams_settings`
Zwei Kanäle für dieselbe Zuordnung:
| Kanal | Wann |
| --- | --- |
| Datei `name.gcode.acm` / `name.acm` auf dem Drucker | Stock/gklib liest Sidecar beim Start |
| MQTT-Feld `data.ams_settings` | gkapi / Rinkhals-Patcher legt Map in die Payload |
Ohne Map: Rinkhals füllt oft Identity T0→Platz0 … — deshalb Grotti bei eigener ACE-Zuordnung per MQTT mit expliziter Map startet und beide `.acm`-Varianten hochlädt ([ACE-Live](ACE-Live)).
## Was Grotti bewusst nutzt
| Aktion | Weg | Beleg |
| --- | --- | --- |
| Einfacher Start | Upload + Moonraker `print/start` → Rinkhals MQTT | `kobra.py` `patch_mqtt_print` |
| Start mit Bettnetz oder ACE-Map | Upload (+ `.acm`) + Grotti-MQTT `:9883` | `anycubic_lan.start_print`, mqtt.md |
| Abbruch | Moonraker cancel + MQTT stop `taskid:-1` | `mqtt_stop_print`, mqtt.md |
| Credentials | `:18910/info` + `/ctrl` AES | `anycubic_lan.credentials` |
| Slots anzeigen | Moonraker `mmu` (Rinkhals) | `mmu_ace.py` |
| Kein Firmware-Restart | abgelehnt | Rinkhals #32, `patch_klipper_restart` |
## Offene Grenzen
- Quellcode von **gkapi** (Task-State-Machine, exakte 10101-Bedingungen) ist nicht öffentlich.
- Deadlock in **FIRMWARE_RESTART** ist beobachtet und in Rinkhals abgefangen, Root Cause im Binary nicht freigelegt.
- Kobra X / K4-Stack ist ein anderes System (C++-Klipper), nicht GoKlipper — für unsere Drucker irrelevant.
@@ -1,6 +1,6 @@
# Chargen, Etiketten und 3D-Viewer
Stand 14.10.2026.
Stand 17.10.2026.
## Spulen-Chargen
@@ -8,11 +8,17 @@ Jede Spule in Grotti bekommt automatisch eine `chargen_nr` der Form `S-YYYYMMDD-
Scannen: auf Material den Code tippen oder per USB-Handscanner (Tastatur + Enter). Lookup `GET /api/v1/spulen/by-code/<code>`. QR auf dem Etikett öffnet `/material?spool=<id>`.
Etikett: `/etikett/<id>`, Druck über den Browser. QR per qrcode.js (CDN). 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
`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.
File renamed without changes.
+26
View File
@@ -0,0 +1,26 @@
# Druckbericht und Statistik
Stand **17.10.2026**.
## Tabellen
- `runs`: zusätzlich `weight_planned_g`, `weight_used_g`, `weight_source`, `filament_used_mm`/`filament_total_mm`, `progress_last`, `estimated_s`, `chargen_nr`, `message`, `objects`
- `run_spools`: Abzug je Spule (`is_estimate` bei Mehrfarb)
- `run_segments`: Abschnitte bei Rollenwechsel (Einlegen während Druck/Pause)
- `run_samples`: Temperatur/Fortschritt ca. alle 30 s (Watcher)
- `run_labels`: Objekt-Chargen `D-…-01`
- `events.run_id`: optionaler Bezug zum Lauf
## Abzug
`grotti/usage.py` rechnet Ist-Gramm aus `filament_used` / `filament_total` × Soll. Abbruch ohne Extrusion: 0. Ohne Zähler: Fortschritt × Soll (Schätzwert). Fertig ohne Messgröße: Soll bleibt, Quelle `estimate`.
`apply_deduction` schreibt zuerst `run_spools`, dann `deducted`/`remaining_g` (auch unter 0); `ensure_run_chargen` oft schon während des Laufs. Ohne `run_spools` bleibt die Spulenakte leer.
## Live
`StateWatcher` hängt am offenen History-Job oder legt `live:…` an, schreibt Proben, vergibt `chargen_nr`, merkt Objektnamen. Vor neuem Start: `abandon_live_runs`. `sync_history` übernimmt Proben/Segmente/Charge (`adopt_live_run`, nicht auf ältere fertige Jobs derselben Datei) und setzt bei Bedarf `needs_clear`.
## Statistik-Filter
`since` inklusiv, reines `until` als Datum (`YYYY-MM-DD`) inklusiv (intern Folgetag exklusiv). Presets in der UI setzen Von/Bis. CSV nutzt dieselben Query-Parameter.
@@ -37,10 +37,15 @@ Stand **14.10.2026**, v1.4.0.
## Material
- Reiter **Spulen** / **Sorten**. Scan-Feld im Seitenkopf.
- Spulen: Filter Alle / Am Drucker / Regal / Wenig Rest + Suche. Formular nur bei Auswahl oder „Neue Spule“ (× schließt). Ort = Drucker · ACE/extern bzw. Regal.
- Reiter **Spulen** / **Sorten**. Scan-Feld im Seitenkopf (`S-…` Spule, `D-…` Druckbericht).
- Spulen: Filter Alle / Am Drucker / Regal / Wenig Rest + Suche. Formular nur bei Auswahl oder „Neue Spule“ (× schließt). Ort = Drucker · ACE/extern bzw. Regal. Akte mit Verbrauch und Leer-in.
- Sorten: Profile (Dichte, mm³/s, Preis, Maschinen). Anlegen/ändern/löschen; „Spule damit anlegen“ wechselt zum Spulen-Reiter.
## Protokoll und Statistik
- Protokoll-Zeile → Druckbericht `/protokoll/<id>` (Soll/Ist, Spulen, Temperatur, Charge, Etiketten). Charge schon am laufenden Druck; Etikett an der Wand und im Bericht.
- Statistik `/statistik`: KPIs, nach Drucker/Material, Zeitraum-Presets, CSV mit denselben Filtern.
## Warteschlange
- Seite `/warteschlange`: Filter Läuft / Wartet / Fehler / Historie. Priorität, Abbrechen, Fortsetzen.
+89
View File
@@ -0,0 +1,89 @@
# Glossar: Moonraker, Mainsail und der Rest
Stand **15.10.2026**. Begriffe rund um Kobra 3 / S1, Rinkhals und Grotti. Architektur-Details: [Anycubic-Stack](Anycubic-Stack).
## Die Schichten in einem Satz
| Schicht | Rolle |
| --- | --- |
| **MCU-Firmware** | Läuft auf der Drucker-Platine: Stepper, Heizer, Sensoren. |
| **gklib (GoKlipper)** | „Gehirn“ auf dem Linux der Kobra — Anycubics Go-Port von Klipper. |
| **gkapi** | Anycubic-Dienst: MQTT, Cloud/LAN, Druck-Tasks, Touch-Anbindung. |
| **Moonraker** | HTTP/WebSocket-API vor gklib — das, was Apps und Grotti ansprechen. |
| **Mainsail / Fluidd** | Web-Oberflächen *auf* Moonraker (Live-Steuerung, Konsole). |
| **Grotti** | Eure lokale Farm-Seite: Queue, Wand, Material, Abräumen — spricht Moonraker (+ MQTT wo nötig). |
| **Rinkhals** | Community-Aufsatz auf der Stock-Firmware: bringt Moonraker, SSH, Apps. |
```mermaid
flowchart TB
UI[Mainsail / Fluidd / Grotti / Orca]
UI --> MR[Moonraker]
UI -.->|Start mit ACE/Bettnetz, Stop| MQTT[gkapi MQTT]
MR --> GK[gklib GoKlipper]
MQTT --> GK
Touch[K3SysUi Touch] --> MQTT
GK --> MCU[MCU]
GK --> ACE[ACE]
```
## A–Z
### ACE
Anycubic Color Engine — Multimaterial-Box (vier Plätze). Ansteuerung in gklib (`ACE_*`-G-codes); Zuordnung Farbe→Platz oft über Sidecar **`.acm`** und/oder MQTT **`ams_settings`**. In Moonraker erscheint das bei Rinkhals oft als Objekt **`mmu`** (`gate_color`, …).
### Fluidd
Web-UI für Klipper/Moonraker, Alternative zu Mainsail. Gleiche API, anderes Aussehen. Bei Rinkhals typisch Port **4408**.
### FIRMWARE_RESTART
Klipper-Befehl: Host + MCU neu laden. Auf **GoKlipper** deadlockt gklib oft (Rinkhals #32). Grotti und Rinkhals lehnen ihn ab → Display-Reboot oder Strom.
### gklib / GoKlipper
Anycubics Klipper-Host in Go (Open Source: [Klipper-go](https://github.com/ANYCUBIC-3D/Klipper-go)). Entspricht grob dem klassischen `klippy`. Dateiname auf dem Drucker: `gklib`.
### gkapi
Geschlossener Anycubic-Dienst neben gklib. Betreibt den lokalen MQTT-Broker, Druck-Tasks, Credentials (`:18910`), Anbindung Touch/Cloud. Hier sitzt der Task, der bei Abbruch **10101** verursacht, wenn nur Moonraker-cancel kommt.
### Grotti
Dieses Tool: lokale deutsche Seite für mehrere Moonraker-Drucker — Wand, Warteschlange „wenn frei“, Abräumen (Kobra 3), Material, Kosten, Telegram. Kein Slicer, keine Cloud.
### K3SysUi
Anycubic-Touch-Oberfläche (geschlossen). Rinkhals patcht sie, um das eigene Menü einzuhängen.
### Klipper
Open-Source-Firmware-Architektur: schwere Berechnung auf dem Host (Linux), MCU führt Bewegungen aus. Vanilla-Klipper ist Python (`klippy`); auf der Kobra 3 läuft die Go-Variante.
### Mainsail
Beliebte Web-UI für Moonraker: Status, Temp, G-code-Konsole, Dateien, Webcam. Bei Rinkhals oft Port **80** / **4409**. Fühlt sich „direkter“ an als Grotti, weil die Konsole offen ist.
### Moonraker
API-Server vor Klipper/gklib: HTTP und WebSocket. Upload, `print/start`, Pause, `gcode/script`, `objects/query`, Kamera-Proxy. **OctoPrint-Äquivalent** in der Klipper-Welt. Grotti und Mainsail sind Clients davon — Moonraker ist nicht die schöne Oberfläche selbst.
### MQTT (hier)
Protokoll zu **gkapi**, nicht zu Moonraker. Topics wie `…/slicer/printer/{modelId}/{deviceId}/print`. Genutzt für Druck **start/stop**, Info-Query, optional Temp/Speed/Licht. Ports: lokal oft **2883**, von außen TLS **9883**.
### OctoPrint
Klassische Host-Software für (meist) Marlin-Drucker: Web-UI, Plugins, oft G-code-**Streaming** Zeile für Zeile. Bei Klipper/Rinkhals ersetzt durch **Moonraker + Mainsail/Fluidd** (Datei komplett hochladen, Host spielt ab).
### Orca / AnycubicSlicerNext
Slicer auf dem PC. Schicken fertige `.gcode` per Moonraker (oder USB). Kein Teil der Drucker-Firmware.
### printer.cfg / printer.generated.cfg
Klipper-Konfiguration. Auf Anycubic oft generiert/neu geschrieben; Rinkhals warnt vor wildem Editieren (Boot-Risiko). Z-Offset u. a. in `printer_mutable.cfg`.
### Repetier-Server
Host-Software ähnlich OctoPrint (eigene UI, oft Streaming/direktes G-code). Gleicher Kontrast: auf der Kobra ist der Host **gklib**, die API **Moonraker**.
### Rinkhals
Community-Overlay auf Stock-Kobra-OS: Moonraker, Mainsail/Fluidd, SSH, App-Store, ohne die Stock-Firmware zu ersetzen. Installiert der Nutzer selbst (SWU). Docs: [rinkhals-community/Rinkhals](https://github.com/rinkhals-community/Rinkhals).
### SSH
Root-Shell auf dem Drucker-Linux (Rinkhals: Port 22, `root` / `rockchip`). Zum Debuggen, Logs, App-Properties — **nicht** der normale Weg für Drucksteuerung (das bleibt Moonraker).
### SWU
Anycubic-Update-Paket auf USB (Ordner `aGVscF9zb3Nf`). Darüber kommen Stock-Updates und Rinkhals/Installer.
### virtual_sdcard
Klipper-Modul: G-code liegt als Datei auf dem Host und wird von gklib abgespielt. Deshalb Upload + Start statt Streaming über WLAN.
### 10101
Anycubic-MQTT-Fehler: „Druck-Task existiert schon“. Typisch nach Abbruch nur über Moonraker, ohne MQTT `action: stop`.
File renamed without changes.
+30
View File
@@ -0,0 +1,30 @@
# MCP-Server
Stand **15.10.2026**. Agenten steuern Grotti nur über die HTTP-API mit einem API-Token.
## Start
```powershell
$env:GROTTI_URL = "http://127.0.0.1:8770"
$env:GROTTI_TOKEN = "grt_…" # unter Einstellungen → API-Tokens
$env:GROTTI_MCP_READ_ONLY = "1" # optional: nur lesen
.\.venv\Scripts\pip install mcp
.\.venv\Scripts\python.exe -m grotti.mcp_server
```
## Tools
| Tool | Schreibend | Confirm |
| --- | --- | --- |
| `list_printers`, `get_printer`, `list_running`, `list_queue`, `list_events`, `list_files`, `list_spools`, `get_version` | nein | — |
| `pause_printer`, `resume_printer` | ja | — |
| `cancel_printer`, `clear_bed`, `enqueue_*` | ja | `confirm=true` |
| `steer_temp`, `steer_speed`, `steer_fan` | ja | — |
Nicht exponiert: freie G-code, Firmware-Restart, E-Stop, User/Token/Settings.
## Sicherheit
- Token mit Rolle Operator reicht für den Alltag; Admin-Token vermeiden.
- Read-only-Flag für vorsichtige Agents.
- Server ideal nur lokal; Grotti selbst hinter Login/TLS.
File renamed without changes.
+5
View File
@@ -0,0 +1,5 @@
# Interne Technik-Notizen
Diese Dateien sind für Entwicklung und Agenten — **nicht** unter `/doku` in der App.
Nutzer-Handbuch: `docs/Anleitung-*.md`.
@@ -1,6 +1,6 @@
# Grotti: Senden und Start dauert
Stand **14.10.2026**. Spulen kommen vom Ziel-Drucker zur Startzeit (`ace_map` + eingelegte Rolle), nicht aus einem freien Dropdown. `sends` speichert `ace_map` und Bettnetz für „Nochmal“.
Stand **15.10.2026** (Stack-Quellen und Task-Hygiene in [Anycubic-Stack](Anycubic-Stack)). Spulen kommen vom Ziel-Drucker zur Startzeit (`ace_map` + eingelegte Rolle), nicht aus einem freien Dropdown. `sends` speichert `ace_map` und Bettnetz für „Nochmal“.
## Warum Sekunden / Minuten
@@ -20,6 +20,8 @@ Ab 12.10.2026: Start legt Hintergrund-Aufträge an (`pending` → `running` →
`watch_dir` in `config.json` zeigt auf den Slice-Ordner der Bibliothek. Ohne Ordner geht Hochladen über die Dateien-Seite trotzdem.
**Dateinamen:** Moonraker-Multipart nutzt nur noch einen ASCII-`filename` (`moonraker.safe_upload_name`). Früher standen Nicht-ASCII-Zeichen in `filename*=UTF-8''…`, während `filename=` Unterstriche bekam — gklib speicherte A, Start suchte B → **10115 not found**.
## ACE / `.acm`
Drucker mit ACE/MMU-Plätzen (`mmu.gate_color` / `gate_material`, Grotti-`slots`) brauchen vor `print/start` eine Sidecar-Datei `*.gcode.acm`. In der UI unter Optionen → ACE-Zuordnung (Datei-Farbe → Platz). Ohne passende `.acm` lehnt der Drucker oft mit „cannot parse the file“ ab — Details hier, nicht als Banner in der App:
@@ -71,6 +73,10 @@ Credentials holt Grotti ohne SSH: `GET :18910/info` → signiertes `POST :18910/
**Abbruch:** Grotti `abbrechen` = Moonraker `/printer/print/cancel` **plus** MQTT `action: stop` / `taskid: "-1"` (gleicher Topic wie Start). Nur Moonraker-cancel lässt den Anycubic-Task stehen → nächster MQTT-Start mit **10101**. 13.10.2026: Gridfinity auf da26 so hängen geblieben, per MQTT-stop freigemacht (`data.state: free`).
### Task-Hygiene vor dem Start (15.10.2026)
Vor jedem Start (MQTT und reiner Moonraker-Pfad) räumt Grotti einen hängenden gkapi-Task best-effort — **Kobra 3 und S1**: `print_info` → ggf. MQTT-stop → poll. Kommt trotzdem **10101**, einmal cancel + 1,5 s + Retry (wie Rinkhals). Ist gkapi kurz unerreichbar, läuft der Moonraker-Start trotzdem (negativer Cache ~45 s). Live zeigt die Wand `anycubic_task` aus demselben `info/query` (Cache ~10 s). Details: [Anycubic-Stack](Anycubic-Stack) Abschnitt Task-Hygiene.
**Start-Report verspätet:** 13.10.2026 ~21:35, A2 (`10.10.1.59`), Ablauf 2/5 direkt nach der Bettnetz-Prüfung. Innerhalb von 30 s kam kein `print/report`, der Druck lief trotzdem an. `info/query` (Topic `…/slicer/printer/{modelId}/{deviceId}/info`, Antwort auf `…/info/report`) zeigte `project.task_id` = Grotti-`taskid`, `state: auto_leveling`. Moonraker meldet in dieser Phase noch `print_stats.state: complete` vom Vorauftrag. Grotti prüft deshalb bei einem Start-Timeout bis zu dreimal `info/query`. Steht dort die eigene `taskid`, gilt der Start als erfolgreich (`anycubic_lan.print_info`).
Fallback ohne MQTT: global in Rinkhals Touch → Manage apps → 40-moonraker → Auto leveling (gilt dann für alle Moonraker-Starts inkl. Mainsail/Orca).
File renamed without changes.
File renamed without changes.
+203 -54
View File
@@ -24,13 +24,23 @@ from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
CRED_TTL = 300
INFO_TTL = 10
FAIL_TTL = 45
_CRED_CACHE = {}
_INFO_CACHE = {}
_FAIL_CACHE = {}
# Anycubic MQTT-Report-Codes (Rinkhals kobra.py / mqtt.md)
# Gilt für Kobra 3 und Kobra S1 (gleiche gkapi-Ports :18910 / :9883).
CODE_TASK_ACTIVE = 10101
CODE_FILAMENT = 10107
class LanError(Exception):
def __init__(self, message):
def __init__(self, message, code=None):
super().__init__(message)
self.message = message
self.code = code
def _info(host):
@@ -141,17 +151,103 @@ def normalize_ams(ams_settings):
return {"use_ams": True, "ams_box_mapping": entries}
def _report_error(body, default_message):
code = body.get("code")
if code == 10107:
return "Filament gerissen. Bitte neues laden. (code 10107)"
if code == 10101:
def _parse_code(value):
if value is None or value == "":
return None
try:
return int(value)
except (TypeError, ValueError):
return value
def message_for_code(code, default_message="Druckbefehl abgelehnt"):
"""MQTT-Report-Code → deutsche Meldung (für Tests und UI)."""
parsed = _parse_code(code)
if parsed == CODE_FILAMENT:
return "Filament gerissen oder leer. Bitte neues laden. (code 10107)"
if parsed == CODE_TASK_ACTIVE:
return (
"Alter Druckauftrag noch aktiv (Anycubic-MQTT). "
"Bitte zuerst abbrechen. (code 10101)"
"Alter Druckauftrag noch aktiv (Anycubic-Task). "
"Wird geräumt und ggf. erneut gestartet. (code 10101)"
)
message = str(body.get("msg") or default_message)
return "%s%s" % (message, (" (code %s)" % code) if code else "")
message = str(default_message or "Druckbefehl abgelehnt")
if parsed is None:
return message
return "%s (code %s)" % (message, parsed)
def _report_error(body, default_message):
code = _parse_code(body.get("code"))
raw = str(body.get("msg") or "").strip()
if code in (CODE_FILAMENT, CODE_TASK_ACTIVE):
text = message_for_code(code, default_message)
elif raw:
text = "%s%s" % (raw, (" (code %s)" % code) if code is not None else "")
else:
text = message_for_code(code, default_message)
return text, code
def _task_busy(info):
"""True, wenn gkapi einen laufenden/hängenden Task meldet."""
if not isinstance(info, dict):
return False
state = str(info.get("state") or "").strip().lower()
if state and state not in ("free", "idle", "done", ""):
return True
project = info.get("project")
if isinstance(project, dict) and (project.get("task_id") or project.get("filename")):
return True
return False
def task_summary(info):
"""Kompaktes Live-Feld aus print_info-data."""
if not isinstance(info, dict):
return None
project = info.get("project") if isinstance(info.get("project"), dict) else {}
state = str(info.get("state") or "").strip() or None
task_id = project.get("task_id")
if task_id is not None:
task_id = str(task_id)
filename = project.get("filename") or project.get("name") or None
if isinstance(filename, str):
filename = filename.replace("\\", "/").rsplit("/", 1)[-1] or None
if not state and not task_id and not filename:
return None
return {
"state": state,
"task_id": task_id,
"filename": filename,
}
def ensure_task_free(host, attempts=3, wait_s=1.5):
"""Hängenden Anycubic-Task best-effort räumen (Stop + poll).
Kobra 3 und S1. Ohne erreichbares gkapi: None (kein Fehler) — Moonraker-Start
darf nicht an LAN-Ausfall scheitern.
"""
info = print_info(host, timeout=8, use_cache=False)
if info is None or not _task_busy(info):
return info
last_err = None
for _ in range(max(1, int(attempts))):
try:
cancel_print(host, timeout=12)
except LanError as exc:
last_err = exc
time.sleep(max(0.5, float(wait_s)))
info = print_info(host, timeout=8, use_cache=False)
if info is None or not _task_busy(info):
return info
raise LanError(
last_err.message if last_err else (
"Anycubic-Task ließ sich nicht freimachen (state=%s)."
% ((info or {}).get("state") or "?")
),
code=getattr(last_err, "code", CODE_TASK_ACTIVE),
)
def _mqtt_print(host, request, timeout=30, timeout_message="Timeout bei MQTT-Druckbefehl."):
@@ -165,7 +261,7 @@ def _mqtt_print(host, request, timeout=30, timeout_message="Timeout bei MQTT-Dru
model_id, device_id,
)
state = {"done": False, "error": None, "report": None}
state = {"done": False, "error": None, "code": None, "report": None}
def on_connect(client, userdata, flags, reason_code, properties=None):
if getattr(reason_code, "is_failure", False):
@@ -184,9 +280,13 @@ def _mqtt_print(host, request, timeout=30, timeout_message="Timeout bei MQTT-Dru
report = str(body.get("state") or "")
action = str(request.get("action") or "")
if action == "start" and report in ("failed", "stoped", "stopped"):
state["error"] = _report_error(body, "Druckstart abgelehnt")
text, code = _report_error(body, "Druckstart abgelehnt")
state["error"] = text
state["code"] = code
elif action == "stop" and report in ("failed",):
state["error"] = _report_error(body, "Druckabbruch abgelehnt")
text, code = _report_error(body, "Druckabbruch abgelehnt")
state["error"] = text
state["code"] = code
# start: heating / leveling / printing / preheating = angenommen
# stop: stoped / stopped / done = angenommen
state["done"] = True
@@ -221,59 +321,93 @@ def _mqtt_print(host, request, timeout=30, timeout_message="Timeout bei MQTT-Dru
pass
if state["error"]:
raise LanError(state["error"])
raise LanError(state["error"], code=state.get("code"))
return state["report"]
def start_print(host, filename, auto_leveling=True, ams_settings=None, timeout=30):
"""MQTT-Start wie Rinkhals, mit task_settings.auto_leveling 1/0."""
"""MQTT-Start wie Rinkhals: Task freimachen, bei 10101 einmal Retry."""
name = (filename or "").replace("\\", "/").split("/")[-1]
if not name:
raise LanError("Dateiname fehlt.")
data = {
"filename": name,
"filepath": "/",
"taskid": str(random.randint(0, 1000000)),
"task_mode": 1,
"filetype": 1,
"task_settings": {
"auto_leveling": 1 if auto_leveling else 0,
"vibration_compensation": 0,
"flow_calibration": 0,
},
}
ams = normalize_ams(ams_settings)
if ams:
data["ams_settings"] = ams
request = {
"type": "print",
"action": "start",
"msgid": str(uuid.uuid4()),
"timestamp": int(time.time() * 1000),
"data": data,
}
timeout_message = "Timeout beim MQTT-Druckstart."
try:
_mqtt_print(host, request, timeout=timeout, timeout_message=timeout_message)
except LanError as exc:
# Nach Bettnetz-Prüfung kam das Start-Report schon > 30 s zu spät, der Druck lief trotzdem.
if exc.message != timeout_message:
ensure_task_free(host)
except LanError:
# freimachen fehlgeschlagen — Start trotzdem versuchen (Retry fängt 10101)
pass
ams = normalize_ams(ams_settings)
timeout_message = "Timeout beim MQTT-Druckstart."
last_exc = None
for attempt in range(2):
data = {
"filename": name,
"filepath": "/",
"taskid": str(random.randint(0, 1000000)),
"task_mode": 1,
"filetype": 1,
"task_settings": {
"auto_leveling": 1 if auto_leveling else 0,
"vibration_compensation": 0,
"flow_calibration": 0,
},
}
if ams:
data["ams_settings"] = ams
request = {
"type": "print",
"action": "start",
"msgid": str(uuid.uuid4()),
"timestamp": int(time.time() * 1000),
"data": data,
}
try:
_mqtt_print(host, request, timeout=timeout, timeout_message=timeout_message)
return {"filename": name, "auto_leveling": bool(auto_leveling)}
except LanError as exc:
last_exc = exc
if exc.message == timeout_message:
for _ in range(3):
project = (print_info(host, use_cache=False) or {}).get("project") or {}
if str(project.get("task_id") or "") == data["taskid"]:
return {"filename": name, "auto_leveling": bool(auto_leveling)}
time.sleep(5)
raise
if exc.code == CODE_TASK_ACTIVE and attempt == 0:
try:
cancel_print(host, timeout=12)
except LanError:
pass
time.sleep(1.5)
continue
raise
for attempt in range(3):
project = (print_info(host) or {}).get("project") or {}
if str(project.get("task_id") or "") == data["taskid"]:
break
time.sleep(5)
else:
raise
return {"filename": name, "auto_leveling": bool(auto_leveling)}
if last_exc:
raise last_exc
raise LanError("Druckstart fehlgeschlagen.")
def print_info(host, timeout=10):
"""MQTT info/query, liefert data aus info/report (project.task_id, state …)."""
cred = credentials(host)
def print_info(host, timeout=10, use_cache=True):
"""MQTT info/query, liefert data aus info/report (project.task_id, state …).
Bei LAN-/MQTT-Fehler: None (kein Raise), kurz negativ cachen — Live-Wand und
Moonraker-Start bleiben für Kobra 3 und S1 nutzbar, wenn gkapi kurz weg ist.
"""
now = time.time()
if use_cache:
cached = _INFO_CACHE.get(host)
if cached and now - cached[0] < INFO_TTL:
return cached[1]
fail_at = _FAIL_CACHE.get(host)
if fail_at and now - fail_at < FAIL_TTL:
return None
try:
cred = credentials(host)
except LanError:
_FAIL_CACHE[host] = time.time()
return None
model_id = cred["modelId"]
device_id = cred["deviceId"]
topic_query = "anycubic/anycubicCloud/v1/slicer/printer/%s/%s/info" % (model_id, device_id)
@@ -316,6 +450,7 @@ def print_info(host, timeout=10):
try:
client.connect(host, 9883, keepalive=30)
except OSError:
_FAIL_CACHE[host] = time.time()
return None
deadline = time.time() + max(3, int(timeout))
try:
@@ -326,9 +461,22 @@ def print_info(host, timeout=10):
client.disconnect()
except Exception:
pass
if state["data"] is None:
_FAIL_CACHE[host] = time.time()
return None
_FAIL_CACHE.pop(host, None)
_INFO_CACHE[host] = (time.time(), state["data"])
return state["data"]
def anycubic_task(host, use_cache=True):
"""Live-Zusammenfassung des gkapi-Tasks (cached), oder null."""
try:
return task_summary(print_info(host, timeout=6, use_cache=use_cache))
except Exception:
return None
def cancel_print(host, timeout=15):
"""MQTT-Stop wie Rinkhals Docs: action stop, taskid -1 (Anycubic-Task)."""
request = {
@@ -344,4 +492,5 @@ def cancel_print(host, timeout=15):
timeout=timeout,
timeout_message="Timeout beim MQTT-Druckabbruch.",
)
_INFO_CACHE.pop(host, None)
return {"mqtt": "stop", "report": report}
+121
View File
@@ -0,0 +1,121 @@
"""Benutzer, Sessions, API-Tokens und CSRF."""
from __future__ import annotations
import hashlib
import secrets
import time
from collections import defaultdict
from werkzeug.security import check_password_hash, generate_password_hash
from grotti.errors import GrottiError
ROLES = ("admin", "operator")
ROLE_RANK = {"operator": 1, "admin": 2}
TOKEN_PREFIX = "grt_"
LOGIN_WINDOW_S = 300
LOGIN_MAX_ATTEMPTS = 12
_login_hits: dict[str, list[float]] = defaultdict(list)
def hash_password(password: str) -> str:
return generate_password_hash(password, method="scrypt")
def verify_password(password_hash: str, password: str) -> bool:
if not password_hash or not password:
return False
return check_password_hash(password_hash, password)
def validate_username(username: str) -> str:
name = (username or "").strip()
if len(name) < 2 or len(name) > 64:
raise GrottiError("Benutzername: 2–64 Zeichen.", "user")
if not all(ch.isalnum() or ch in "._-" for ch in name):
raise GrottiError("Benutzername: nur Buchstaben, Ziffern, . _ -", "user")
return name
def validate_password(password: str) -> str:
if not password or len(password) < 8:
raise GrottiError("Passwort mindestens 8 Zeichen.", "password")
if len(password) > 200:
raise GrottiError("Passwort zu lang.", "password")
return password
def normalize_role(role: str, *, default: str = "operator") -> str:
role = (role or default).strip().lower()
if role not in ROLES:
raise GrottiError("Rolle muss admin oder operator sein.", "role")
return role
def role_at_least(have: str, need: str) -> bool:
return ROLE_RANK.get(have or "", 0) >= ROLE_RANK.get(need or "", 99)
def clamp_token_role(user_role: str, token_role: str | None) -> str:
wanted = normalize_role(token_role or user_role, default=user_role)
if not role_at_least(user_role, wanted):
raise GrottiError("Token-Rolle darf nicht höher als die des Benutzers sein.", "role")
return wanted
def new_api_token() -> str:
return TOKEN_PREFIX + secrets.token_urlsafe(32)
def hash_token(raw: str) -> str:
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def token_prefix(raw: str) -> str:
return (raw or "")[:12]
def new_csrf() -> str:
return secrets.token_urlsafe(24)
def login_rate_ok(key: str) -> bool:
now = time.time()
hits = _login_hits[key]
_login_hits[key] = [t for t in hits if now - t < LOGIN_WINDOW_S]
return len(_login_hits[key]) < LOGIN_MAX_ATTEMPTS
def login_rate_hit(key: str) -> None:
_login_hits[key].append(time.time())
def public_user(row) -> dict:
if not row:
return {}
created = row.get("created_at")
last = row.get("last_login")
return {
"id": row.get("id"),
"username": row.get("username"),
"role": row.get("role"),
"active": bool(row["active"]) if "active" in row else True,
"created_at": created.isoformat() if created and hasattr(created, "isoformat") else created,
"last_login": last.isoformat() if last and hasattr(last, "isoformat") else last,
}
def public_token(row) -> dict:
return {
"id": row["id"],
"user_id": row["user_id"],
"name": row["name"],
"prefix": row["token_prefix"],
"role": row["role"],
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
"last_used": row["last_used"].isoformat() if row.get("last_used") else None,
"expires_at": row["expires_at"].isoformat() if row.get("expires_at") else None,
"revoked": bool(row.get("revoked_at")),
}
+40 -2
View File
@@ -1,6 +1,7 @@
"""Host, Port, Datenordner und Datenbank kommen aus config.json."""
import json
import secrets
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
@@ -59,6 +60,27 @@ def public_base(cfg, request_root=None):
return str(request_root or "").rstrip("/")
def _oauth(raw):
oauth = raw.get("oauth") or {}
return {
"enabled": bool(oauth.get("enabled")),
"issuer": str(oauth.get("issuer") or ""),
"client_id": str(oauth.get("client_id") or ""),
"client_secret": str(oauth.get("client_secret") or ""),
"redirect_uri": str(oauth.get("redirect_uri") or ""),
}
def _ensure_secret_key(path, raw):
key = str(raw.get("secret_key") or "").strip()
if key:
return key
key = secrets.token_hex(32)
raw["secret_key"] = key
path.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
return key
def load():
path = CONFIG_PATH
if not path.exists():
@@ -66,8 +88,14 @@ def load():
raw = json.loads(path.read_text(encoding="utf-8"))
host = (raw.get("host") or "127.0.0.1").strip()
token = raw.get("token") or ""
if host not in LOOPBACK and not token:
raise SystemExit("Ohne Token startet der Dienst nicht, wenn er nicht nur lokal lauscht.")
secret_key = _ensure_secret_key(path, raw)
# Nicht-Loopback ohne Shared-Token ist ok — Login/API-Tokens schützen die App.
# Neu laden falls secret_key geschrieben wurde
if raw.get("secret_key") == secret_key:
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
pass
data_dir = Path(raw.get("data_dir") or "data")
if not data_dir.is_absolute():
data_dir = ROOT / data_dir
@@ -82,6 +110,8 @@ def load():
"watch_dir": watch_dir,
"database_url": raw.get("database_url") or "postgresql://[email protected]:5432/grotti",
"token": token,
"secret_key": secret_key,
"oauth": _oauth(raw),
"fqdn": normalize_fqdn(raw.get("fqdn") or ""),
"printers_file": ROOT / "drucker.json",
"energy": _energy(raw),
@@ -102,10 +132,12 @@ def mask_token(token):
def settings_public(cfg):
tg = cfg.get("telegram") or {}
oauth = cfg.get("oauth") or {}
return {
"watch_dir": str(cfg.get("watch_dir") or ""),
"auto_queue": bool(cfg.get("auto_queue", True)),
"fqdn": str(cfg.get("fqdn") or ""),
"legacy_token_set": bool(cfg.get("token")),
"energy": dict(cfg.get("energy") or {}),
"telegram": {
"enabled": bool(tg.get("enabled")),
@@ -114,6 +146,12 @@ def settings_public(cfg):
"chat_id": str(tg.get("chat_id") or ""),
},
"alerts": dict(cfg.get("alerts") or {}),
"oauth": {
"enabled": bool(oauth.get("enabled")),
"issuer": str(oauth.get("issuer") or ""),
"client_id": str(oauth.get("client_id") or ""),
"redirect_uri": str(oauth.get("redirect_uri") or ""),
},
}
+246 -52
View File
@@ -5,7 +5,7 @@ import urllib.error
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from grotti import acm, eject, library, moonraker, store
from grotti import acm, eject, library, moonraker, store, usage
from grotti.cost import energy_cost
from grotti.errors import GrottiError
from grotti.parse import content_has_ace_trailer, prepare_rinkhals_gcode
@@ -15,17 +15,7 @@ TERMINAL = {"completed", "cancelled", "error", "interrupted"}
def weight_of(metadata):
raw = (metadata or {}).get("filament_weight_total")
if isinstance(raw, list):
return sum(float(item or 0) for item in raw)
if isinstance(raw, (int, float)):
return float(raw)
if isinstance(raw, str):
try:
return float(raw.replace(",", "."))
except ValueError:
return 0.0
return 0.0
return usage.weight_sum(metadata)
def material_of(metadata):
@@ -35,6 +25,37 @@ def material_of(metadata):
return str(raw).split(";")[0].strip()
def _ace_map_of(sent):
if not sent:
return []
raw = sent.get("ace_map")
if isinstance(raw, list):
return acm.normalize_mapping(raw)
if isinstance(raw, str):
try:
return acm.normalize_mapping(json.loads(raw))
except json.JSONDecodeError:
return []
return []
def _targets_from_ace(conn, host, ace):
targets = []
for item in ace:
slot = int(item["ams_index"]) + 1
row = conn.execute(
"""
SELECT * FROM spools
WHERE printer_host = %s AND ace_slot = %s
ORDER BY id LIMIT 1
""",
(host, slot),
).fetchone()
if row:
targets.append(row)
return targets
def unix_to_dt(value):
if not value:
return None
@@ -128,50 +149,152 @@ def assert_spool_at_host(conn, spool_id, host):
)
def apply_deduction(conn, run, energy):
if run["deducted"] or run["weight_g"] <= 0 or run["state"] not in TERMINAL:
def apply_deduction(conn, run, energy, metadata=None):
if run["deducted"] or run["state"] not in TERMINAL:
return False
weight = float(run["weight_g"] or 0)
# ACE: Abzug anteilig auf die Plätze der letzten Sendung
planned = float(run.get("weight_planned_g") or run.get("weight_g") or 0)
used = float(run.get("weight_used_g") or 0)
source = run.get("weight_source") or ""
if used <= 0 and planned > 0 and source != "none":
used, source = usage.resolve_used_grams(
planned,
run.get("filament_used_mm") or 0,
run.get("filament_total_mm") or 0,
run.get("progress_last") or 0,
run["state"],
run.get("duration_s") or 0,
)
if used <= 0:
return False
sent = store.latest_send(conn, run["printer_host"], run.get("filename") or "")
ace = []
if sent:
raw = sent.get("ace_map")
if isinstance(raw, list):
ace = raw
elif isinstance(raw, str):
try:
ace = json.loads(raw)
except json.JSONDecodeError:
ace = []
targets = []
if ace:
for item in acm.normalize_mapping(ace):
slot = int(item["ams_index"]) + 1
row = conn.execute(
"""
SELECT * FROM spools
WHERE printer_host = %s AND ace_slot = %s
ORDER BY id LIMIT 1
""",
(run["printer_host"], slot),
).fetchone()
if row:
targets.append(row)
ace = _ace_map_of(sent)
meta = metadata or {}
if not meta and run.get("file_id"):
file_row = store.get_file_row(conn, run["file_id"]) if hasattr(store, "get_file_row") else None
if file_row:
meta = {"filament_weight_total": file_row.get("weight_g") or planned}
store.close_open_segments(conn, run["id"], None, run.get("filament_used_mm") or 0)
segments = store.list_run_segments(conn, run["id"])
entries = []
targets = _targets_from_ace(conn, run["printer_host"], ace) if ace else []
if not targets and run.get("spool_id"):
spool = store.get_spool_row(conn, run["spool_id"])
if spool:
targets = [spool]
if not targets:
# Abschnitte ohne ACE-Map: externe Rollen nacheinander
if segments and not ace:
final_mm = float(run.get("filament_used_mm") or 0)
total_mm = float(run.get("filament_total_mm") or 0)
deltas = usage.segment_deltas(segments, final_mm)
for spool_id, ace_slot, delta_mm in deltas:
spool = store.get_spool_row(conn, spool_id)
if not spool:
continue
grams = usage.grams_from_mm(delta_mm, total_mm, planned)
cost = (grams / 1000.0) * float(spool.get("price_per_kg") or 0)
entries.append({
"spool_id": spool_id,
"ace_slot": ace_slot,
"weight_g": grams,
"weight_planned_g": planned / max(1, len(deltas)),
"cost": cost,
"is_estimate": source != "measured",
})
elif targets:
shares = usage.planned_shares(meta, ace, len(targets))
parts, is_estimate = usage.split_used(used, shares, multi=len(targets) > 1)
if source != "measured":
is_estimate = True
# ACE-Rollenwechsel: Anteil der gewechselten Farbe nach mm aufteilen
by_slot = {}
if segments and ace:
final_mm = float(run.get("filament_used_mm") or 0)
for spool_id, ace_slot, delta_mm in usage.segment_deltas(segments, final_mm):
by_slot.setdefault(ace_slot, []).append((spool_id, delta_mm))
for index, spool in enumerate(targets):
plan = shares[index] if index < len(shares) else 0
grams = parts[index] if index < len(parts) else 0
slot = spool.get("ace_slot")
slot_segs = by_slot.get(slot) or []
if len(slot_segs) > 1:
mm_sum = sum(d for _, d in slot_segs) or 1.0
for seg_spool_id, delta_mm in slot_segs:
seg_spool = store.get_spool_row(conn, seg_spool_id)
if not seg_spool:
continue
share_g = grams * (delta_mm / mm_sum)
cost = (share_g / 1000.0) * float(seg_spool.get("price_per_kg") or 0)
entries.append({
"spool_id": seg_spool_id,
"ace_slot": slot,
"weight_g": share_g,
"weight_planned_g": plan / len(slot_segs),
"cost": cost,
"is_estimate": True,
})
else:
cost = (grams / 1000.0) * float(spool.get("price_per_kg") or 0)
entries.append({
"spool_id": spool["id"],
"ace_slot": slot,
"weight_g": grams,
"weight_planned_g": plan,
"cost": cost,
"is_estimate": is_estimate,
})
elif segments:
# Fallback: Abschnitte ohne aktuelle Targets
final_mm = float(run.get("filament_used_mm") or 0)
total_mm = float(run.get("filament_total_mm") or 0)
for spool_id, ace_slot, delta_mm in usage.segment_deltas(segments, final_mm):
spool = store.get_spool_row(conn, spool_id)
if not spool:
continue
grams = usage.grams_from_mm(delta_mm, total_mm, planned)
cost = (grams / 1000.0) * float(spool.get("price_per_kg") or 0)
entries.append({
"spool_id": spool_id,
"ace_slot": ace_slot,
"weight_g": grams,
"weight_planned_g": 0,
"cost": cost,
"is_estimate": source != "measured",
})
else:
return False
share = weight / len(targets)
filament = sum((share / 1000) * float(spool["price_per_kg"] or 0) for spool in targets)
if not entries:
return False
used_total = sum(item["weight_g"] for item in entries)
filament = sum(item["cost"] for item in entries)
power = energy_cost(run["duration_s"], energy)
marked = store.mark_deducted(conn, run["id"], targets[0]["id"], filament, power)
# run_spools vor deducted=true, sonst bleibt die Akte leer bei Abbruch dazwischen
store.replace_run_spools(conn, run["id"], entries)
marked = store.mark_deducted(
conn,
run["id"],
entries[0]["spool_id"],
filament,
power,
weight_used_g=used_total,
weight_source=source or ("estimate" if any(e["is_estimate"] for e in entries) else "measured"),
)
if not marked:
store.replace_run_spools(conn, run["id"], [])
return False
for spool in targets:
store.deduct_spool(conn, spool["id"], share)
for item in entries:
store.deduct_spool(conn, item["spool_id"], item["weight_g"])
store.ensure_run_chargen(conn, run["id"], used_total)
objects = run.get("objects")
if isinstance(objects, str):
try:
objects = json.loads(objects)
except json.JSONDecodeError:
objects = []
store.ensure_run_labels(conn, run["id"], objects or [], qty=1)
return True
@@ -190,13 +313,16 @@ def sync_history(conn, cfg):
deducted = 0
for printer, job in collected:
job_id = str(job.get("job_id") or "")
if not job_id:
if not job_id or str(job_id).startswith("live:"):
continue
filename = job.get("filename") or ""
metadata = job.get("metadata") or {}
weight = weight_of(metadata)
planned = weight_of(metadata)
material = material_of(metadata)
state = job.get("status") or ""
used_mm = usage.filament_used_mm(job)
total_mm = usage.filament_total_mm(metadata, job)
estimated = float(metadata.get("estimated_time") or 0)
sent = store.latest_send(conn, printer["host"], filename)
known = store.file_by_name(conn, filename)
file_id = (sent or {}).get("file_id") or (known or {}).get("id")
@@ -204,6 +330,23 @@ def sync_history(conn, cfg):
hinted = (sent or {}).get("spool_id") or (known or {}).get("spool_id")
spool = choose_spool(conn, printer["host"], material, hinted)
terminal = state in TERMINAL
live = store.find_live_run(conn, printer["host"], filename)
progress = float((live or {}).get("progress_last") or 0)
if used_mm <= 0 and live:
used_mm = float(live.get("filament_used_mm") or 0)
if total_mm <= 0 and live:
total_mm = float(live.get("filament_total_mm") or 0)
if planned <= 0 and live:
planned = float(live.get("weight_planned_g") or 0)
objects = (live or {}).get("objects") or []
if isinstance(objects, str):
try:
objects = json.loads(objects)
except json.JSONDecodeError:
objects = []
used_g, source = usage.resolve_used_grams(
planned, used_mm, total_mm, progress, state, job.get("print_duration") or 0
)
row = store.upsert_run(conn, {
"printer_host": printer["host"],
"printer_name": printer["name"],
@@ -215,13 +358,33 @@ def sync_history(conn, cfg):
"started_at": unix_to_dt(job.get("start_time")),
"ended_at": unix_to_dt(job.get("end_time")),
"duration_s": float(job.get("print_duration") or job.get("total_duration") or 0),
"weight_g": weight,
"weight_g": planned,
"weight_planned_g": planned,
"weight_used_g": used_g,
"weight_source": source,
"filament_used_mm": used_mm,
"filament_total_mm": total_mm,
"progress_last": progress,
"estimated_s": estimated,
"energy_cost": energy_cost(job.get("print_duration") or job.get("total_duration") or 0, cfg["energy"]),
"moonraker_job_id": job_id,
"needs_spool": bool(terminal and weight > 0 and not spool),
"needs_spool": bool(terminal and used_g > 0 and not spool),
"material_type": material,
"message": (job.get("message") or "") if state == "error" else "",
"objects": objects,
})
if apply_deduction(conn, row, cfg["energy"]):
# Live sofort auf History hängen — auch solange noch in_progress (eine Protokoll-Zeile)
store.adopt_live_run(conn, printer["host"], filename, row["id"])
row = store.get_run_row(conn, row["id"])
if terminal:
# Nachzug nicht während eines neuen Live-Drucks — sonst „Platte belegt“ zu Unrecht
active = store.find_live_run(conn, printer["host"])
if not active:
ended = row.get("ended_at") or unix_to_dt(job.get("end_time"))
store.ensure_bed_after_print(
conn, printer["host"], ended_at=ended, message=filename or state
)
if apply_deduction(conn, row, cfg["energy"], metadata=metadata):
deducted += 1
return {"gelesen": len(collected), "abgezogen": deducted}
@@ -373,7 +536,11 @@ def send_file(
filaments = [{"type": "PLA", "color": ""}]
progress("vorbereiten", 30, "Datei vorbereiten…")
filename, content, plate = library.printer_file(row)
filename, content, plate = library.printer_file(
row, conn=conn, data_dir=(cfg or {}).get("data_dir")
)
# CJK/Unicode im Namen → Rinkhals 10115 not found (Upload ≠ Start-Name)
filename = moonraker.safe_upload_name(filename)
ace_tools = [item.get("paint_index") for item in mapping] if mapping else None
if (row.get("kind") or "") == "gcode" and str(filename).endswith(".gcode"):
content = prepare_rinkhals_gcode(content, tools=ace_tools)
@@ -484,6 +651,33 @@ def steuern(conn, host, action, params=None):
printer = store.printer_by_host(conn, host)
if not printer:
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
params = dict(params) if isinstance(params, dict) else {}
if action == "slot_setzen":
try:
ace_slot = int(params.get("ace_slot"))
except (TypeError, ValueError) as exc:
raise GrottiError("ACE-Platz muss 1–8 sein.", "platz") from exc
spool = None
for row in store.spools_for_printer(conn, host):
raw_slot = row.get("ace_slot")
if raw_slot in (None, ""):
continue
try:
if int(raw_slot) == ace_slot:
spool = row
break
except (TypeError, ValueError):
continue
if not spool:
raise GrottiError("Auf diesem Platz liegt keine Spule in Grotti.", "spule")
material = (spool.get("material_type") or "").strip()
if not material:
raise GrottiError("Die Spule braucht eine Sorte.", "material")
color = moonraker.spool_rgb(spool)
if not color:
raise GrottiError("Die Spule braucht eine Farbe.", "farbe")
hub_id, index = moonraker.ace_hub_index(ace_slot)
params = {"id": hub_id, "index": index, "type": material, "color": color}
try:
moonraker.steuern(host, action, params)
except moonraker.PrinterError as exc:
+27 -5
View File
@@ -142,8 +142,30 @@ def ensure_library_gcode(conn, cfg, host, filename):
return save_upload(conn, name, content, cfg)
def printer_file(row):
path = Path(row["stored_path"])
def resolve_stored_path(conn, row, data_dir=None):
"""Gespeicherter Pfad, ggf. nach Umzug von data_dir unter eingang/<name> wiederfinden."""
stored = (row or {}).get("stored_path") or ""
path = Path(stored) if stored else None
if path and path.is_file():
return path
name = (row or {}).get("name") or (path.name if path else "")
if not name or data_dir is None:
return None
candidate = Path(data_dir) / "eingang" / name
if not candidate.is_file():
return None
resolved = str(candidate.resolve())
file_id = (row or {}).get("id")
if file_id and conn is not None:
conn.execute("UPDATE files SET stored_path = %s WHERE id = %s", (resolved, file_id))
row["stored_path"] = resolved
return candidate.resolve()
def printer_file(row, conn=None, data_dir=None):
path = resolve_stored_path(conn, row, data_dir) if (conn is not None or data_dir is not None) else Path(row["stored_path"])
if path is None:
path = Path(row.get("stored_path") or "")
if not path.is_file():
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
if row["kind"] == "gcode":
@@ -153,9 +175,9 @@ def printer_file(row):
return path.name, path.read_bytes(), row["plate"] or ""
def preview_png(row):
path = Path(row["stored_path"])
if not path.is_file():
def preview_png(row, conn=None, data_dir=None):
path = resolve_stored_path(conn, row, data_dir) if (conn is not None or data_dir is not None) else Path(row["stored_path"])
if path is None or not path.is_file():
return None
entry = row["thumb_entry"]
if row["kind"] == "gcode":
+263
View File
@@ -0,0 +1,263 @@
"""MCP-Server für Grotti — spricht nur die HTTP-API mit einem API-Token.
Umgebung:
GROTTI_URL Basis-URL (Standard http://127.0.0.1:8770)
GROTTI_TOKEN Bearer-Token (grt_…)
GROTTI_MCP_READ_ONLY=1 nur Lese-Tools
Start:
python -m grotti.mcp_server
"""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
READ_ONLY = os.environ.get("GROTTI_MCP_READ_ONLY", "").strip() in ("1", "true", "yes")
BASE = (os.environ.get("GROTTI_URL") or "http://127.0.0.1:8770").rstrip("/")
TOKEN = (os.environ.get("GROTTI_TOKEN") or "").strip()
def _api(method: str, path: str, body: dict | None = None):
if not TOKEN:
raise RuntimeError("GROTTI_TOKEN fehlt.")
url = BASE + "/api/v1" + path
data = None
headers = {
"Authorization": "Bearer " + TOKEN,
"Accept": "application/json",
}
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
if not raw:
return None
return json.loads(raw.decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
try:
payload = json.loads(detail)
message = payload.get("error") or detail
except json.JSONDecodeError:
message = detail or str(exc)
raise RuntimeError(message) from exc
def _write_guard():
if READ_ONLY:
raise RuntimeError("MCP läuft im Read-only-Modus (GROTTI_MCP_READ_ONLY).")
def main():
try:
from mcp.server.fastmcp import FastMCP
except ImportError:
print(
"Paket „mcp“ fehlt. Installieren: pip install mcp",
file=sys.stderr,
)
sys.exit(1)
server = FastMCP(
"grotti",
instructions=(
"Grotti steuert die 3D-Drucker-Farm. Destruktive Aktionen brauchen "
"confirm=true. Kein freier G-code, kein Firmware-Restart."
),
)
@server.tool()
def list_printers(live: bool = True) -> str:
"""Druckerliste mit optionalem Live-Zustand."""
q = "?live=1" if live else "?live=0"
return json.dumps(_api("GET", "/drucker" + q), ensure_ascii=False, indent=2)
@server.tool()
def get_printer(host: str) -> str:
"""Einen Drucker anhand Host/IP aus der Live-Liste."""
rows = _api("GET", "/drucker?live=1") or []
for row in rows:
if row.get("host") == host:
return json.dumps(row, ensure_ascii=False, indent=2)
raise RuntimeError("Drucker nicht gefunden: %s" % host)
@server.tool()
def list_running() -> str:
"""Laufende Drucke und Abläufe."""
return json.dumps(_api("GET", "/laeuft"), ensure_ascii=False, indent=2)
@server.tool()
def list_queue(limit: int = 40) -> str:
"""Druckwarteschlange."""
return json.dumps(
_api("GET", "/druck-queue?limit=%d" % max(1, min(limit, 200))),
ensure_ascii=False,
indent=2,
)
@server.tool()
def list_events(limit: int = 30) -> str:
"""Letzte Ereignisse / Aktivität."""
return json.dumps(
_api("GET", "/ereignisse?limit=%d" % max(1, min(limit, 200))),
ensure_ascii=False,
indent=2,
)
@server.tool()
def list_files(limit: int = 50) -> str:
"""Dateien in der Bibliothek."""
rows = _api("GET", "/dateien") or []
return json.dumps(rows[: max(1, min(limit, 200))], ensure_ascii=False, indent=2)
@server.tool()
def list_spools() -> str:
"""Spulen / Materialbestand."""
return json.dumps(_api("GET", "/spulen"), ensure_ascii=False, indent=2)
@server.tool()
def get_version() -> str:
"""Grotti-Version."""
return json.dumps(_api("GET", "/version"), ensure_ascii=False, indent=2)
@server.tool()
def pause_printer(host: str) -> str:
"""Druck pausieren."""
_write_guard()
return json.dumps(
_api("POST", "/drucker/%s/pause" % urllib.parse.quote(host)),
ensure_ascii=False,
)
@server.tool()
def resume_printer(host: str) -> str:
"""Druck fortsetzen."""
_write_guard()
return json.dumps(
_api("POST", "/drucker/%s/fortsetzen" % urllib.parse.quote(host)),
ensure_ascii=False,
)
@server.tool()
def cancel_printer(host: str, confirm: bool = False) -> str:
"""Druck abbrechen. confirm muss true sein."""
_write_guard()
if not confirm:
raise RuntimeError("Abbruch braucht confirm=true.")
return json.dumps(
_api("POST", "/drucker/%s/abbrechen" % urllib.parse.quote(host)),
ensure_ascii=False,
)
@server.tool()
def clear_bed(host: str, confirm: bool = False) -> str:
"""Platte als geräumt quittieren (kein physisches Schieben)."""
_write_guard()
if not confirm:
raise RuntimeError("Quittierung braucht confirm=true.")
return json.dumps(
_api(
"POST",
"/drucker/%s/abraeumen" % urllib.parse.quote(host),
{"confirm": True},
),
ensure_ascii=False,
)
@server.tool()
def enqueue_when_free(
file_id: int,
host: str = "",
confirm: bool = False,
bed_leveling: bool = False,
) -> str:
"""Datei in die Warteschlange („wenn frei“). confirm muss true sein."""
_write_guard()
if not confirm:
raise RuntimeError("Einreihen braucht confirm=true.")
payload = {
"file_id": file_id,
"host": host,
"confirm": True,
"bed_leveling": bool(bed_leveling),
}
return json.dumps(_api("POST", "/druck-queue", payload), ensure_ascii=False, indent=2)
@server.tool()
def enqueue_print(
file_id: int,
host: str,
confirm: bool = False,
bed_leveling: bool = False,
) -> str:
"""Sofort senden und starten. confirm muss true sein."""
_write_guard()
if not confirm:
raise RuntimeError("Start braucht confirm=true.")
if not host:
raise RuntimeError("host fehlt.")
payload = {
"file_id": file_id,
"host": host,
"confirm": True,
"bed_leveling": bool(bed_leveling),
}
return json.dumps(_api("POST", "/senden", payload), ensure_ascii=False, indent=2)
@server.tool()
def steer_temp(host: str, nozzle: float | None = None, bed: float | None = None) -> str:
"""Zieltemperaturen setzen (Whitelist)."""
_write_guard()
out = []
if nozzle is not None:
out.append(_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
"action": "nozzle", "value": nozzle,
}))
if bed is not None:
out.append(_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
"action": "bed", "value": bed,
}))
if not out:
raise RuntimeError("nozzle und/oder bed angeben.")
return json.dumps(out, ensure_ascii=False, indent=2)
@server.tool()
def steer_speed(host: str, speed: float) -> str:
"""Geschwindigkeitsfaktor in Prozent."""
_write_guard()
return json.dumps(
_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
"action": "speed", "value": speed,
}),
ensure_ascii=False,
)
@server.tool()
def steer_fan(host: str, fan: float) -> str:
"""Teilekühlung 0–100."""
_write_guard()
return json.dumps(
_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
"action": "fan", "value": fan,
}),
ensure_ascii=False,
)
@server.resource("grotti://version")
def resource_version() -> str:
return json.dumps(_api("GET", "/version"), ensure_ascii=False, indent=2)
server.run()
if __name__ == "__main__":
main()
+250 -17
View File
@@ -165,18 +165,53 @@ def post_json(host, path, payload, timeout=20):
raise PrinterError("Der Drucker ist nicht erreichbar.") from exc
def safe_upload_name(filename):
"""Dateiname für Moonraker/Rinkhals/gklib — nur ASCII.
`filename` vs. `filename*` mit CJK führte zu Upload unter `_` und Start
unter dem Originalnamen → Fehler 10115 „gcode … not found“.
"""
raw = (filename or "").replace("\\", "/")
parts = [part for part in raw.split("/") if part]
base = parts[-1] if parts else "job.gcode"
out = []
for ch in base:
if ch.isascii() and ch not in '"\\':
out.append(ch if ch not in " \t" else "_")
else:
out.append("_")
text = "".join(out)
while "__" in text:
text = text.replace("__", "_")
while "_-" in text or "-_" in text:
text = text.replace("_-", "-").replace("-_", "-")
text = text.strip("._-") or "job"
lower = text.lower()
if not (lower.endswith(".gcode") or lower.endswith(".gcode.3mf") or lower.endswith(".3mf") or lower.endswith(".acm")):
# Suffix vom Original behalten, falls nur der Stamm zerstört wurde
if base.lower().endswith(".gcode.3mf"):
text += ".gcode.3mf"
elif base.lower().endswith(".gcode"):
text += ".gcode"
elif base.lower().endswith(".acm"):
text += ".acm"
if parts:
parts[-1] = text
return "/".join(parts)
return text
def multipart(filename, content):
boundary = "----GrottiBoundary7f3a"
ascii_name = "".join(ch if ch.isascii() and ch not in '"\\' else "_" for ch in filename) or "job.gcode"
quoted = urllib.parse.quote(filename)
name = safe_upload_name(filename)
fields = (
"--%s\r\nContent-Disposition: form-data; name=\"root\"\r\n\r\ngcodes\r\n" % boundary
).encode("ascii")
head = (
"--%s\r\n"
"Content-Disposition: form-data; name=\"file\"; filename=\"%s\"; filename*=UTF-8''%s\r\n"
"Content-Disposition: form-data; name=\"file\"; filename=\"%s\"\r\n"
"Content-Type: application/octet-stream\r\n\r\n"
) % (boundary, ascii_name, quoted)
) % (boundary, name)
tail = ("\r\n--%s--\r\n" % boundary).encode("ascii")
return boundary, fields + head.encode("ascii") + content + tail
@@ -217,6 +252,7 @@ def upload_timeout(size):
def upload(host, filename, content, on_progress=None):
filename = safe_upload_name(filename)
boundary, body = multipart(filename, content)
stream = _UploadBody(body, on_progress=on_progress)
request = urllib.request.Request(
@@ -236,7 +272,8 @@ def upload(host, filename, content, on_progress=None):
except (urllib.error.URLError, TimeoutError) as exc:
raise PrinterError("Hochladen ist fehlgeschlagen.") from exc
item = (payload.get("result") or {}).get("item") or {}
return item.get("path") or filename
# Immer denselben ASCII-Namen nutzen, den wir hochgeladen haben
return safe_upload_name(item.get("path") or filename)
def start_print(host, filename, bed_leveling=False, ams_settings=None):
@@ -246,21 +283,29 @@ def start_print(host, filename, bed_leveling=False, ams_settings=None):
MQTT selbst (auto_leveling aus App-Property; ams_settings aus .acm /
Identity-Fallback). Bei bed_leveling oder gesetzter Grotti-ams_settings
startet Grotti per MQTTS :9883 und legt die Map explizit in die Payload.
Vor dem reinen Moonraker-Pfad: best-effort ensure_task_free — Rinkhals
wandelt Start oft intern zu MQTT um; hängende gkapi-Tasks (10101) blocken.
"""
use_mqtt = bool(bed_leveling) or bool(ams_settings)
if not use_mqtt:
return post_json(host, "/printer/print/start", {"filename": filename})
from grotti import anycubic_lan
if use_mqtt:
try:
return anycubic_lan.start_print(
host,
filename,
auto_leveling=bool(bed_leveling),
ams_settings=ams_settings,
)
except anycubic_lan.LanError as exc:
raise PrinterError(exc.message) from exc
try:
return anycubic_lan.start_print(
host,
filename,
auto_leveling=bool(bed_leveling),
ams_settings=ams_settings,
)
except anycubic_lan.LanError as exc:
raise PrinterError(exc.message) from exc
anycubic_lan.ensure_task_free(host)
except Exception:
pass
return post_json(host, "/printer/print/start", {"filename": filename})
def command(host, action):
@@ -373,6 +418,64 @@ def steuern(host, action, params=None):
if not re.fullmatch(r"[A-Za-z0-9._+\-]+", name):
raise PrinterError("Ungültiger Objektname.")
return gcode_script(host, "EXCLUDE_OBJECT NAME=%s" % name, timeout=20)
if action == "dryer_start":
try:
unit = int(params.get("id"))
except (TypeError, ValueError) as exc:
raise PrinterError("ACE-Box muss 0 oder 1 sein.") from exc
if unit not in (0, 1):
raise PrinterError("ACE-Box muss 0 oder 1 sein.")
try:
temp = int(round(float(params.get("temp"))))
duration = int(round(float(params.get("duration"))))
except (TypeError, ValueError) as exc:
raise PrinterError("Temperatur und Dauer müssen Zahlen sein.") from exc
if temp < 35 or temp > 65:
raise PrinterError("Trockner-Temperatur außerhalb 35–65 °C.")
if duration < 1 or duration > 720:
raise PrinterError("Trockner-Dauer außerhalb 1–720 Minuten.")
return post_json(
host,
"/server/filament_hub/start_drying",
{"id": unit, "temp": temp, "duration": duration, "fan_speed": 0},
timeout=15,
)
if action == "dryer_stop":
try:
unit = int(params.get("id"))
except (TypeError, ValueError) as exc:
raise PrinterError("ACE-Box muss 0 oder 1 sein.") from exc
if unit not in (0, 1):
raise PrinterError("ACE-Box muss 0 oder 1 sein.")
return post_json(host, "/server/filament_hub/stop_drying", {"id": unit}, timeout=15)
if action == "slot_setzen":
hub_id = _int_or_none(params.get("id"))
index = _int_or_none(params.get("index"))
if hub_id not in (0, 1) or index is None or index < 0 or index > 3:
raise PrinterError("ACE-Platz ungültig.")
material = str(params.get("type") or "").strip()
if not material:
raise PrinterError("Die Spule braucht eine Sorte.")
color = params.get("color")
if not isinstance(color, dict):
raise PrinterError("Die Spule braucht eine Farbe.")
try:
red = max(0, min(255, int(color.get("R"))))
green = max(0, min(255, int(color.get("G"))))
blue = max(0, min(255, int(color.get("B"))))
except (TypeError, ValueError) as exc:
raise PrinterError("Die Spule braucht eine Farbe.") from exc
return post_json(
host,
"/printer/filament_hub/set_filament_info",
{
"id": hub_id,
"index": index,
"type": material,
"color": {"R": red, "G": green, "B": blue},
},
timeout=15,
)
if action == "firmware_restart":
# GoKlipper/Rinkhals: FIRMWARE_RESTART deadlocked gklib (Rinkhals #32).
raise PrinterError(
@@ -555,6 +658,80 @@ def extract_slots(status):
return found[:8]
def _int_or_none(value):
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
return int(value)
def extract_dryers(status):
"""Echte ACE-Boxen aus mmu_machine (num_gates > 0), Dummy-Unit bleibt weg."""
machine = (status or {}).get("mmu_machine")
if not isinstance(machine, dict):
return []
found = []
for unit_id in (0, 1):
unit = machine.get("unit_%d" % unit_id)
if not isinstance(unit, dict):
continue
gates = _int_or_none(unit.get("num_gates"))
if gates is None or gates < 1:
continue
found.append({
"id": unit_id,
"status": str(unit.get("dryer_status") or "stop"),
"temp": _int_or_none(unit.get("dryer_temp")) or 0,
"target": _int_or_none(unit.get("dryer_target_temp")) or 0,
"remain": _int_or_none(unit.get("dryer_remaining")) or 0,
"humidity": _int_or_none(unit.get("dryer_humidity")) or 0,
"gates": gates,
})
return found
def ace_hub_index(ace_slot):
"""Grotti-Platz 1–8 → Moonraker id (0/1) und index in der Box (0–3)."""
try:
slot = int(ace_slot)
except (TypeError, ValueError) as exc:
raise PrinterError("ACE-Platz muss 1–8 sein.") from exc
if slot < 1 or slot > 8:
raise PrinterError("ACE-Platz muss 1–8 sein.")
return (slot - 1) // 4, (slot - 1) % 4
def spool_rgb(spool):
"""Erste Farbe der Spule als RGB-Dict für set_filament_info."""
colors = (spool or {}).get("colors") or []
if isinstance(colors, str):
try:
colors = json.loads(colors)
except (json.JSONDecodeError, TypeError):
colors = []
hex_value = ""
if isinstance(colors, list):
for item in colors:
if isinstance(item, dict) and item.get("hex"):
hex_value = str(item["hex"]).strip()
break
if isinstance(item, str) and item.strip():
hex_value = item.strip()
break
if not hex_value:
hex_value = str((spool or {}).get("color") or "").strip()
if hex_value.startswith("#"):
hex_value = hex_value[1:]
if len(hex_value) == 8:
hex_value = hex_value[:6]
if len(hex_value) != 6 or any(ch not in "0123456789abcdefABCDEF" for ch in hex_value):
return None
return {
"R": int(hex_value[0:2], 16),
"G": int(hex_value[2:4], 16),
"B": int(hex_value[4:6], 16),
}
def slots(host, status=None):
if status is not None:
found = extract_slots(status)
@@ -823,6 +1000,8 @@ def query(host):
extras = []
if "probe" in names:
extras.append("probe")
if "mmu_machine" in names:
extras.append("mmu_machine")
url = "http://%s/printer/objects/query?%s" % (host, "&".join([base] + extras))
payload = fetch_json(url)
status = payload["result"]["status"]
@@ -830,6 +1009,21 @@ def query(host):
card = status.get("virtual_sdcard") or {}
extruder = status.get("extruder") or {}
bed = status.get("heater_bed") or {}
mmu = status.get("mmu") or {}
mmu_tool = mmu.get("tool")
if not isinstance(mmu_tool, (int, float)) or isinstance(mmu_tool, bool):
mmu_tool = mmu.get("gate")
if isinstance(mmu_tool, (int, float)) and not isinstance(mmu_tool, bool):
mmu_tool = int(mmu_tool)
if mmu_tool < 0:
mmu_tool = None
else:
mmu_tool = None
filament_used = stats.get("filament_used")
if not isinstance(filament_used, (int, float)) or isinstance(filament_used, bool):
filament_used = None
else:
filament_used = float(filament_used)
raw_name = stats.get("filename") or ""
filename = raw_name.replace("\\", "/").rsplit("/", 1)[-1] if raw_name else ""
meta = metadata(host, raw_name) if raw_name else {}
@@ -888,8 +1082,15 @@ def query(host):
"bed_target": bed.get("target"),
"chamber": chamber,
"chamber_target": chamber_target,
"filament_used": filament_used,
"filament_total": meta.get("filament_total") or 0,
"mmu_tool": mmu_tool,
"material": card.get("filament_type") or meta.get("filament_type") or "",
"weight": meta.get("filament_weight_total") or 0,
"weight": (
sum(float(x or 0) for x in meta["filament_weight_total"])
if isinstance(meta.get("filament_weight_total"), list)
else (meta.get("filament_weight_total") or 0)
),
"height": meta.get("object_height") or 0,
"layer_height": meta.get("layer_height") or card.get("layer_height") or 0,
"estimated": meta.get("estimated_time") or 0,
@@ -899,7 +1100,7 @@ def query(host):
"/api/v1/vorschau?host=%s&embedded=%s" % (quoted_host, urllib.parse.quote(raw_name))
if raw_name and EMBEDDED.get((host, raw_name)) else ""
),
"camera": "/api/v1/kamera?host=%s" % quoted_host,
**_camera_fields(host, quoted_host),
**mesh_fields(host, shown, mesh_profile),
"fan": fan_speed,
"fan_rpm": fan_rpm,
@@ -907,10 +1108,22 @@ def query(host):
"speed_factor": speed_factor,
"extrude_factor": extrude_factor,
"slots": slots(host, status),
"dryers": extract_dryers(status),
"exclude": exclude_status(host),
"anycubic_task": _anycubic_task_field(host),
}
def _anycubic_task_field(host):
"""gkapi-Task aus print_info (TTL-Cache in anycubic_lan), best-effort."""
try:
from grotti import anycubic_lan
return anycubic_lan.anycubic_task(host)
except Exception:
return None
def snapshot(printers):
def one(printer):
card = {
@@ -927,6 +1140,8 @@ def snapshot(printers):
"ok": False,
"state": "offline",
"message": str(error),
"camera": "",
"has_camera": False,
"fan": None,
"fan_rpm": None,
"fans": {"part": None, "chamber": None, "aux": None},
@@ -935,6 +1150,8 @@ def snapshot(printers):
"speed_factor": None,
"extrude_factor": None,
"slots": [],
"dryers": [],
"anycubic_task": None,
})
card.update(mesh_fields(printer["host"]))
return card
@@ -966,5 +1183,21 @@ def camera_image(host):
return CAMERA_CACHE[host][2]
def _camera_fields(host, quoted_host):
"""Kamera-URL nur, wenn nicht als tot bekannt (Cache-Miss = erst versuchen)."""
cached = CAMERA_CACHE.get(host)
if cached is not None and cached[2] is None:
return {"camera": "", "has_camera": False}
if cached is not None and cached[2] is not None:
return {
"camera": "/api/v1/kamera?host=%s" % quoted_host,
"has_camera": True,
}
return {
"camera": "/api/v1/kamera?host=%s" % quoted_host,
"has_camera": None,
}
def embedded_bytes(host, name):
return EMBEDDED.get((host, name))
+2 -2
View File
@@ -43,11 +43,11 @@ def alert_enabled(cfg, kind):
return bool(alerts.get(key, key not in DEFAULT_OFF))
def record(conn, cfg, host, printer, kind, message, filename="", dedupe_s=60):
def record(conn, cfg, host, printer, kind, message, filename="", dedupe_s=60, run_id=None):
"""Ereignis immer speichern; Telegram nur einmal pro Art und Drucker im Zeitfenster."""
from grotti import store
recent = store.recent_event_kind(conn, host, kind, within_s=dedupe_s)
store.add_event(conn, host, printer, kind, message, filename or "")
store.add_event(conn, host, printer, kind, message, filename or "", run_id=run_id)
if not recent and alert_enabled(cfg, kind):
send_telegram(cfg, message)
+118
View File
@@ -180,3 +180,121 @@ CREATE TABLE IF NOT EXISTS bed_status (
cleared_at timestamptz,
message text NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username text NOT NULL UNIQUE,
password_hash text NOT NULL,
role text NOT NULL DEFAULT 'operator',
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
last_login timestamptz
);
CREATE TABLE IF NOT EXISTS api_tokens (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
name text NOT NULL DEFAULT '',
token_hash text NOT NULL UNIQUE,
token_prefix text NOT NULL DEFAULT '',
role text NOT NULL DEFAULT 'operator',
created_at timestamptz NOT NULL DEFAULT now(),
last_used timestamptz,
revoked_at timestamptz,
expires_at timestamptz
);
CREATE INDEX IF NOT EXISTS api_tokens_user ON api_tokens (user_id) WHERE revoked_at IS NULL;
-- OIDC später: Identitäten an lokale User binden.
CREATE TABLE IF NOT EXISTS oauth_identities (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
provider text NOT NULL,
subject text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (provider, subject)
);
-- Druckbericht: Soll/Ist, Charge, Live-Fortschritt
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_planned_g double precision NOT NULL DEFAULT 0;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_used_g double precision NOT NULL DEFAULT 0;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_source text NOT NULL DEFAULT '';
ALTER TABLE runs ADD COLUMN IF NOT EXISTS filament_used_mm double precision NOT NULL DEFAULT 0;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS filament_total_mm double precision NOT NULL DEFAULT 0;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS progress_last double precision NOT NULL DEFAULT 0;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS estimated_s double precision NOT NULL DEFAULT 0;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS chargen_nr text;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS message text NOT NULL DEFAULT '';
ALTER TABLE runs ADD COLUMN IF NOT EXISTS objects jsonb NOT NULL DEFAULT '[]'::jsonb;
CREATE UNIQUE INDEX IF NOT EXISTS runs_chargen_nr
ON runs (chargen_nr) WHERE chargen_nr IS NOT NULL AND chargen_nr <> '';
ALTER TABLE events ADD COLUMN IF NOT EXISTS run_id bigint REFERENCES runs (id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS events_run ON events (run_id) WHERE run_id IS NOT NULL;
-- Abzug je Spule (Messwert oder Schätzwert)
CREATE TABLE IF NOT EXISTS run_spools (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
spool_id bigint NOT NULL REFERENCES spools (id) ON DELETE RESTRICT,
ace_slot smallint,
weight_g double precision NOT NULL DEFAULT 0,
weight_planned_g double precision NOT NULL DEFAULT 0,
cost double precision NOT NULL DEFAULT 0,
is_estimate boolean NOT NULL DEFAULT false,
UNIQUE (run_id, spool_id, ace_slot)
);
CREATE INDEX IF NOT EXISTS run_spools_spool ON run_spools (spool_id);
CREATE INDEX IF NOT EXISTS run_spools_run ON run_spools (run_id);
-- Abschnitte bei Rollenwechsel mitten im Druck
CREATE TABLE IF NOT EXISTS run_segments (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
spool_id bigint NOT NULL REFERENCES spools (id) ON DELETE RESTRICT,
ace_slot smallint,
mm_start double precision NOT NULL DEFAULT 0,
mm_end double precision,
created_at timestamptz NOT NULL DEFAULT now(),
closed_at timestamptz
);
CREATE INDEX IF NOT EXISTS run_segments_run ON run_segments (run_id);
CREATE INDEX IF NOT EXISTS run_segments_open
ON run_segments (run_id) WHERE mm_end IS NULL;
-- Temperatur / Fortschritt während des Drucks
CREATE TABLE IF NOT EXISTS run_samples (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
sampled_at timestamptz NOT NULL DEFAULT now(),
nozzle double precision,
nozzle_target double precision,
bed double precision,
bed_target double precision,
chamber double precision,
fan double precision,
speed_factor double precision,
extrude_factor double precision,
layer integer,
progress double precision,
filament_used_mm double precision,
mmu_tool smallint
);
CREATE INDEX IF NOT EXISTS run_samples_run_time ON run_samples (run_id, sampled_at);
-- Objekt-Chargen für Etiketten
CREATE TABLE IF NOT EXISTS run_labels (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
object_name text NOT NULL DEFAULT '',
chargen_nr text NOT NULL,
excluded boolean NOT NULL DEFAULT false,
UNIQUE (run_id, object_name)
);
CREATE UNIQUE INDEX IF NOT EXISTS run_labels_chargen ON run_labels (chargen_nr);
+1204 -14
View File
File diff suppressed because it is too large. Load diff
+70 -29
View File
@@ -8,8 +8,8 @@ MOVE = re.compile(r"^(?:G0|G00|G1|G01)\b(.*)$", re.IGNORECASE)
# Auch E.39 / Z.6 ohne führende Null (Anycubic/Prusa-Stil)
AXIS = re.compile(r"([XYZE])\s*(-?\d*\.?\d+)", re.IGNORECASE)
# Cache-/Viewer-Version: Segment-IDs, kein Sehnen-Thinning
TOOLPATH_VERSION = 5
# Cache-/Viewer-Version: ganze Z-Höhen dicht behalten (keine gesprenkelten Stichproben)
TOOLPATH_VERSION = 8
def _gcode_text(path: Path) -> str:
@@ -28,48 +28,89 @@ def _gcode_text(path: Path) -> str:
return path.read_text(encoding="utf-8", errors="replace")
def _z_key(z):
return round(float(z), 2)
def _thin_points(points, layer_breaks, max_points):
"""Extrusions-Segmente stichprobenartig behalten — nie Endpunkte einer Bahn
weglassen und die Nachbarn verbinden (sonst Sehnen statt Infill)."""
"""Pfad kürzen, ohne Wände zu sprenkeln.
Strategie: ganze Z-Höhen (Druckschichten) vollständig behalten und nur
ganze Höhen weglassen. Stichproben entlang der Bahn erzeugen Lücken.
"""
if len(points) <= max_points:
return points, layer_breaks
extrude_ends = [i for i in range(1, len(points)) if points[i][3]]
keep = {0, len(points) - 1}
for break_i in layer_breaks:
keep.add(max(0, min(len(points) - 1, break_i)))
if break_i > 0:
keep.add(break_i - 1)
# Nur Z-Höhen mit Extrusion — Z-Hops allein zählen nicht
z_order = []
seen = set()
for point in points:
if not point[3]:
continue
key = _z_key(point[2])
if key not in seen:
seen.add(key)
z_order.append(key)
if not z_order:
return points[:max_points], [0]
# Budget für Segment-Endpunkte (jedes Segment braucht 2 Indizes, viele teilen sich)
target_segs = max(2000, max_points // 2)
step = max(1, (len(extrude_ends) + target_segs - 1) // target_segs)
for j, end_i in enumerate(extrude_ends):
if j % step == 0:
def keep_for_zs(keep_zs):
keep = {0, len(points) - 1}
for i, point in enumerate(points):
if _z_key(point[2]) in keep_zs:
keep.add(i)
if i > 0:
keep.add(i - 1)
return sorted(keep)
# So viele Höhen wie ins Budget passen (Schätzung über mittlere Punkte/Höhe)
per_z = max(1, len(points) // max(1, len(z_order)))
max_zs = max(2, min(len(z_order), max_points // per_z))
stride = max(1, (len(z_order) + max_zs - 1) // max_zs)
def pick_zs(step):
chosen = {z_order[0], z_order[-1]}
for i in range(0, len(z_order), step):
chosen.add(z_order[i])
return chosen
step = stride
ordered = keep_for_zs(pick_zs(step))
while len(ordered) > max_points and step < len(z_order):
step += 1
ordered = keep_for_zs(pick_zs(step))
# Immer noch zu groß: innerhalb behaltener Höhen jedes n-te Extrusionssegment
if len(ordered) > max_points:
keep_zs = pick_zs(step)
segs = [
i
for i in range(1, len(points))
if points[i][3] and _z_key(points[i][2]) in keep_zs
]
budget = max(1, (max_points - 4) // 2)
seg_step = max(1, (len(segs) + budget - 1) // budget)
keep = {0, len(points) - 1}
for end_i in segs[::seg_step]:
keep.add(end_i - 1)
keep.add(end_i)
ordered = sorted(keep)
# Falls immer noch zu groß: Segmente grober
if len(ordered) > max_points:
step2 = max(1, (len(ordered) + max_points - 1) // max_points)
ordered = ordered[::step2]
if ordered[-1] != len(points) - 1:
ordered.append(len(points) - 1)
ordered = sorted(keep)
index_map = {old: new for new, old in enumerate(ordered)}
kept = [points[i] for i in ordered]
new_breaks = sorted({
index_map[b]
for b in layer_breaks
if b in index_map
})
new_breaks = []
last_z = None
for new_i, point in enumerate(kept):
z = _z_key(point[2])
if last_z is None or abs(z - last_z) > 0.01:
new_breaks.append(new_i)
last_z = z
if not new_breaks:
new_breaks = [0]
return kept, new_breaks
def build_toolpath(path: Path, max_points=120000):
def build_toolpath(path: Path, max_points=220000):
text = _gcode_text(path)
if not text:
return {
+164
View File
@@ -0,0 +1,164 @@
"""Ist-Gramm aus Moonraker-Filamentzählern und Spulenanteilen."""
from __future__ import annotations
def as_float(value, default=0.0):
if value is None or value is False:
return float(default)
if isinstance(value, bool):
return float(default)
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
try:
return float(value.replace(",", ".").strip())
except ValueError:
return float(default)
if isinstance(value, list):
return sum(as_float(item) for item in value)
return float(default)
def weight_list(metadata):
"""Gramm je Farbe aus filament_weight_total (Zahl oder Liste)."""
raw = (metadata or {}).get("filament_weight_total")
if isinstance(raw, list):
return [as_float(item) for item in raw]
total = as_float(raw)
return [total] if total > 0 else []
def weight_sum(metadata):
return sum(weight_list(metadata))
def filament_total_mm(metadata, job=None):
raw = (metadata or {}).get("filament_total")
if raw in (None, "", [], 0, 0.0) and isinstance(job, dict):
raw = job.get("filament_total")
if isinstance(raw, list):
return sum(as_float(item) for item in raw)
return as_float(raw)
def filament_used_mm(job):
if not isinstance(job, dict):
return 0.0
return as_float(job.get("filament_used"))
def planned_shares(metadata, ace_map, target_count):
"""Geplante Gramm je Ziel-Spule. Index entspricht targets-Reihenfolge."""
weights = weight_list(metadata)
mapping = ace_map if isinstance(ace_map, list) else []
if weights and mapping and len(weights) >= len(mapping) and target_count == len(mapping):
shares = []
for item in mapping:
paint = item.get("paint_index")
try:
index = int(paint)
except (TypeError, ValueError):
index = len(shares)
if 0 <= index < len(weights):
shares.append(weights[index])
else:
shares.append(0.0)
if sum(shares) > 0:
return shares
if weights and target_count == len(weights):
return list(weights)
total = sum(weights) if weights else 0.0
if target_count <= 0:
return []
if total <= 0:
return [0.0] * target_count
each = total / target_count
return [each] * target_count
def resolve_used_grams(planned_g, used_mm, total_mm, progress, state, duration_s):
"""
Ist-Gramm und Herkunft.
measured = filament_used, estimate = Fortschritt oder Soll, none = nichts.
"""
planned = max(0.0, as_float(planned_g))
used = max(0.0, as_float(used_mm))
total = max(0.0, as_float(total_mm))
prog = max(0.0, min(1.0, as_float(progress)))
duration = max(0.0, as_float(duration_s))
terminal_abort = state in ("cancelled", "error", "interrupted")
if used > 0.5 and total > 0:
grams = planned * (used / total) if planned > 0 else 0.0
if grams <= 0 and used > 0.5:
# Länge ohne Gewicht: grob 1.24 g/cm³ · 1.75 mm → ~3 g/m
grams = used * 0.003
return max(0.0, grams), "measured"
if used > 0.5 and planned > 0 and total <= 0:
# Millimeter da, Gesamtlänge fehlt — Fortschritt oder Dauer helfen nicht
return used * 0.003, "estimate"
if terminal_abort and used <= 0.5:
if prog > 0.005 and planned > 0:
return planned * prog, "estimate"
return 0.0, "none"
if planned > 0:
if prog > 0.005 and prog < 0.995 and state != "completed":
return planned * prog, "estimate"
return planned, "estimate" if state == "completed" else "none"
return 0.0, "none"
def split_used(used_g, planned_shares_list, multi):
"""
Ist-Gramm auf Spulen verteilen.
multi=True → Anteile sind Schätzwerte, Summe bleibt used_g.
"""
used = max(0.0, as_float(used_g))
shares = [max(0.0, as_float(s)) for s in (planned_shares_list or [])]
if not shares:
return [], False
total_plan = sum(shares)
if total_plan <= 0:
each = used / len(shares)
parts = [each] * len(shares)
else:
parts = [used * (share / total_plan) for share in shares]
is_estimate = bool(multi) and len(shares) > 1
return parts, is_estimate
def segment_deltas(segments, final_mm):
"""
Abschnitte mit mm_start/mm_end → Delta-mm je (spool_id, ace_slot).
Offene Abschnitte schließen auf final_mm.
"""
end = max(0.0, as_float(final_mm))
totals = {}
order = []
for seg in segments or []:
start = max(0.0, as_float(seg.get("mm_start")))
raw_end = seg.get("mm_end")
stop = end if raw_end is None else max(0.0, as_float(raw_end))
delta = max(0.0, stop - start)
key = (seg.get("spool_id"), seg.get("ace_slot"))
if key not in totals:
totals[key] = 0.0
order.append(key)
totals[key] += delta
return [(key[0], key[1], totals[key]) for key in order]
def grams_from_mm(delta_mm, total_mm, planned_g):
delta = max(0.0, as_float(delta_mm))
total = max(0.0, as_float(total_mm))
planned = max(0.0, as_float(planned_g))
if delta <= 0:
return 0.0
if total > 0 and planned > 0:
return planned * (delta / total)
return delta * 0.003
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
__version__ = "2.0.0"
__version__ = "2.5.1"
+152 -20
View File
@@ -4,10 +4,13 @@ from __future__ import annotations
import threading
import time
from datetime import datetime, timezone
from grotti import moonraker, notify, store
from grotti import moonraker, notify, store, usage
from grotti.db import connect
SAMPLE_EVERY_S = 30
class StateWatcher:
def __init__(self, database_url, cfg, interval=10):
@@ -21,6 +24,7 @@ class StateWatcher:
self._updated_at = 0.0
self._stop = threading.Event()
self._thread = None
self._last_sample = {}
def start(self):
if self._thread and self._thread.is_alive():
@@ -79,6 +83,7 @@ class StateWatcher:
prev = self._prev.get(host) or {}
prev_state = (prev.get("state") or "").lower()
prev_offline = bool(prev.get("offline"))
run_id = None
if offline:
hits = self._offline_hits.get(host, 0) + 1
@@ -89,16 +94,32 @@ class StateWatcher:
return
self._offline_hits[host] = 0
starting = (
bool(prev_state)
and prev_state not in ("printing", "paused")
and state == "printing"
)
if starting:
store.abandon_live_runs(conn, host)
if state in ("printing", "paused"):
run = self._track_print(conn, host, name, filename, live, state)
run_id = run["id"] if run else None
if prev_offline:
self._emit(conn, host, name, "offline", "%s ist wieder erreichbar." % name, filename)
elif prev_state and prev_state not in ("printing", "paused") and state == "printing":
self._emit(conn, host, name, "start", "%s druckt %s" % (name, filename or "einen Auftrag"), filename)
self._emit(conn, host, name, "offline", "%s ist wieder erreichbar." % name, filename, run_id)
elif starting:
self._emit(
conn, host, name, "start",
"%s druckt %s" % (name, filename or "einen Auftrag"),
filename, run_id,
)
self._ensure_start_segment(conn, host, filename, live, run_id)
elif prev_state == "printing" and state == "paused":
self._emit(conn, host, name, "start", "%s pausiert." % name, filename)
self._emit(conn, host, name, "start", "%s pausiert." % name, filename, run_id)
elif prev_state == "paused" and state == "printing":
self._emit(conn, host, name, "start", "%s druckt weiter." % name, filename)
self._emit(conn, host, name, "start", "%s druckt weiter." % name, filename, run_id)
elif prev_state == "printing" and state == "complete":
store.set_bed_needs_clear(conn, host, True, filename or "fertig")
store.ensure_bed_after_print(conn, host, message=filename or "fertig")
self._emit(
conn,
host,
@@ -106,8 +127,8 @@ class StateWatcher:
"fertig",
"%s fertig: %s" % (name, filename or "Druck"),
filename,
run_id or (prev.get("run_id")),
)
# Protokoll und Spulenabzug nicht erst nach manuellem Knopf
try:
from grotti import jobs
@@ -115,7 +136,7 @@ class StateWatcher:
except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
elif prev_state in ("printing", "paused") and state in ("cancelled", "standby"):
store.set_bed_needs_clear(conn, host, True, filename or state)
store.ensure_bed_after_print(conn, host, message=filename or state)
self._emit(
conn,
host,
@@ -123,10 +144,17 @@ class StateWatcher:
"abbruch",
"%s: Druck abgebrochen (%s)" % (name, filename or "ohne Datei"),
filename,
run_id or prev.get("run_id"),
)
try:
from grotti import jobs
jobs.sync_history(conn, self.cfg)
except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
elif state == "error" and prev_state != "error":
if prev_state == "printing" or prev_state == "paused":
store.set_bed_needs_clear(conn, host, True, filename or "Fehler")
store.ensure_bed_after_print(conn, host, message=filename or "Fehler")
msg = (live.get("message") or "").strip()
if not msg or msg.lower() in ("error", "fehler"):
msg = "Ursache unbekannt — in Mainsail prüfen"
@@ -138,18 +166,19 @@ class StateWatcher:
"fehler",
"%s: %s · Heizung aus" % (name, msg),
filename,
run_id or prev.get("run_id"),
)
try:
from grotti import jobs
jobs.sync_history(conn, self.cfg)
except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
elif state == "error":
# Schon im Fehler, Heizer laufen noch → aus
if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0:
self._cool_heaters(host)
elif state == "complete":
known = conn.execute(
"SELECT 1 FROM bed_status WHERE host = %s", (host,)
).fetchone()
if not known:
# Erster Stand nach Deploy: fertiger Druck ohne Quittierung
store.set_bed_needs_clear(conn, host, True, filename or "fertig")
elif state == "complete" and (not prev_state or prev_offline):
store.ensure_bed_after_print(conn, host, message=filename or "fertig")
spool_warn = bool(prev.get("spool_warn"))
if state == "printing" and not spool_warn:
@@ -160,8 +189,108 @@ class StateWatcher:
"offline": False,
"filename": filename,
"spool_warn": spool_warn if state == "printing" else False,
"run_id": run_id or (prev.get("run_id") if state in ("printing", "paused") else None),
}
def _track_print(self, conn, host, name, filename, live, state):
sent = store.latest_send(conn, host, filename) if filename else None
spool_id = (sent or {}).get("spool_id")
run = store.ensure_live_run(conn, host, name, filename, spool_id=spool_id)
objects = self._merge_objects(run.get("objects"), live.get("exclude"))
planned = usage.as_float(live.get("weight"))
total_mm = usage.as_float(live.get("filament_total"))
used_mm = usage.as_float(live.get("filament_used"))
progress = usage.as_float(live.get("progress"))
estimated = usage.as_float(live.get("estimated"))
store.update_run_progress(conn, run["id"], {
"state": "paused" if state == "paused" else "in_progress",
"progress_last": progress,
"filament_used_mm": used_mm,
"filament_total_mm": total_mm,
"weight_planned_g": planned,
"estimated_s": estimated,
"message": (live.get("message") or "") if state == "error" else "",
"objects": objects,
"duration_s": usage.as_float(live.get("elapsed")),
})
store.ensure_run_chargen(conn, run["id"])
self._maybe_sample(conn, run["id"], live, used_mm)
return store.get_run_row(conn, run["id"])
def _merge_objects(self, current, exclude):
known = {}
if isinstance(current, str):
try:
import json
current = json.loads(current)
except Exception:
current = []
for item in current or []:
if isinstance(item, dict) and item.get("name"):
known[item["name"]] = {
"name": item["name"],
"excluded": bool(item.get("excluded")),
}
exclude = exclude or {}
excluded = set(exclude.get("excluded") or [])
for item in exclude.get("objects") or []:
name = (item.get("name") if isinstance(item, dict) else str(item)).strip()
if not name:
continue
known[name] = {"name": name, "excluded": name in excluded}
for name in excluded:
if name not in known:
known[name] = {"name": name, "excluded": True}
return list(known.values())
def _maybe_sample(self, conn, run_id, live, used_mm):
now = time.monotonic()
last = self._last_sample.get(run_id, 0)
if now - last < SAMPLE_EVERY_S:
return
self._last_sample[run_id] = now
store.add_run_sample(conn, run_id, {
"sampled_at": datetime.now(timezone.utc),
"nozzle": live.get("nozzle"),
"nozzle_target": live.get("nozzle_target"),
"bed": live.get("bed"),
"bed_target": live.get("bed_target"),
"chamber": live.get("chamber"),
"fan": live.get("fan"),
"speed_factor": live.get("speed_factor"),
"extrude_factor": live.get("extrude_factor"),
"layer": live.get("layer") or 0,
"progress": live.get("progress"),
"filament_used_mm": used_mm,
"mmu_tool": live.get("mmu_tool"),
})
def _ensure_start_segment(self, conn, host, filename, live, run_id):
"""Nur externe Einzelspule: ein Abschnitt. ACE-Farben ohne Wechsel → Anteil-Split."""
if not run_id:
return
existing = store.list_run_segments(conn, run_id)
if existing:
return
sent = store.latest_send(conn, host, filename) if filename else None
ace = (sent or {}).get("ace_map") or []
from grotti import acm
mapping = acm.normalize_mapping(ace) if ace else []
if mapping:
return
used_mm = usage.as_float(live.get("filament_used"))
rows = conn.execute(
"""
SELECT id FROM spools
WHERE printer_host = %s AND ace_slot IS NULL
ORDER BY id LIMIT 1
""",
(host,),
).fetchone()
if rows:
store.open_segment(conn, run_id, rows["id"], None, used_mm)
def _cool_heaters(self, host):
try:
moonraker.heaters_off(host)
@@ -193,5 +322,8 @@ class StateWatcher:
)
return True
def _emit(self, conn, host, printer, kind, message, filename):
notify.record(conn, self.cfg, host, printer, kind, message, filename, dedupe_s=60)
def _emit(self, conn, host, printer, kind, message, filename, run_id=None):
notify.record(
conn, self.cfg, host, printer, kind, message, filename,
dedupe_s=60, run_id=run_id,
)
+1
View File
@@ -3,3 +3,4 @@ psycopg[binary]>=3.2
paho-mqtt>=2.1
cryptography>=42
markdown>=3.6
mcp>=1.2
+103
View File
@@ -0,0 +1,103 @@
"""ACE-Trockner und Platz-Schreiben: Grenzen und Mapping."""
import pytest
from grotti.moonraker import (
PrinterError,
ace_hub_index,
extract_dryers,
spool_rgb,
steuern,
)
def test_extract_dryers_skips_dummy_unit():
status = {
"mmu_machine": {
"num_units": 1,
"unit_0": {
"num_gates": 4,
"dryer_status": "drying",
"dryer_temp": 44,
"dryer_target_temp": 45,
"dryer_remaining": 120,
"dryer_humidity": 20,
},
"unit_1": {
"num_gates": 0,
"dryer_status": "stop",
},
}
}
dryers = extract_dryers(status)
assert len(dryers) == 1
assert dryers[0]["id"] == 0
assert dryers[0]["status"] == "drying"
assert dryers[0]["target"] == 45
assert dryers[0]["remain"] == 120
def test_extract_dryers_empty_without_machine():
assert extract_dryers({}) == []
assert extract_dryers({"mmu_machine": {}}) == []
def test_ace_hub_index():
assert ace_hub_index(1) == (0, 0)
assert ace_hub_index(4) == (0, 3)
assert ace_hub_index(5) == (1, 0)
assert ace_hub_index(8) == (1, 3)
with pytest.raises(PrinterError):
ace_hub_index(0)
with pytest.raises(PrinterError):
ace_hub_index(9)
def test_spool_rgb_from_colors():
assert spool_rgb({"colors": [{"name": "Blau", "hex": "#002B49"}]}) == {
"R": 0, "G": 43, "B": 73,
}
assert spool_rgb({"colors": [], "color": "Weiß"}) is None
assert spool_rgb({"color": "#ff00aa"}) == {"R": 255, "G": 0, "B": 170}
def test_dryer_start_bounds(monkeypatch):
calls = []
def fake_post(host, path, payload, timeout=20):
calls.append((host, path, payload))
return {"result": "ok"}
monkeypatch.setattr("grotti.moonraker.post_json", fake_post)
steuern("10.10.1.60", "dryer_start", {"id": 0, "temp": 45, "duration": 240})
assert calls[0][1] == "/server/filament_hub/start_drying"
assert calls[0][2]["fan_speed"] == 0
with pytest.raises(PrinterError):
steuern("10.10.1.60", "dryer_start", {"id": 2, "temp": 45, "duration": 60})
with pytest.raises(PrinterError):
steuern("10.10.1.60", "dryer_start", {"id": 0, "temp": 20, "duration": 60})
with pytest.raises(PrinterError):
steuern("10.10.1.60", "dryer_start", {"id": 0, "temp": 45, "duration": 0})
def test_slot_setzen_payload(monkeypatch):
calls = []
def fake_post(host, path, payload, timeout=20):
calls.append((host, path, payload))
return {"result": "ok"}
monkeypatch.setattr("grotti.moonraker.post_json", fake_post)
steuern(
"10.10.1.60",
"slot_setzen",
{"id": 0, "index": 2, "type": "PLA", "color": {"R": 1, "G": 2, "B": 3}},
)
assert calls[0][1] == "/printer/filament_hub/set_filament_info"
assert calls[0][2] == {
"id": 0,
"index": 2,
"type": "PLA",
"color": {"R": 1, "G": 2, "B": 3},
}
+195
View File
@@ -0,0 +1,195 @@
"""Unit-Tests für Anycubic-MQTT-Codes, Task-Hygiene und 10101-Retry."""
import sys
import time
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from grotti import anycubic_lan
from grotti.anycubic_lan import (
CODE_FILAMENT,
CODE_TASK_ACTIVE,
LanError,
ensure_task_free,
message_for_code,
start_print,
task_summary,
)
class MessageForCodeTests(unittest.TestCase):
def test_10101(self):
text = message_for_code(CODE_TASK_ACTIVE)
self.assertIn("10101", text)
self.assertIn("aktiv", text.lower())
def test_10107(self):
text = message_for_code(CODE_FILAMENT)
self.assertIn("10107", text)
self.assertIn("Filament", text)
def test_unknown_with_default(self):
text = message_for_code(99999, "Druckstart abgelehnt")
self.assertIn("99999", text)
self.assertIn("Druckstart abgelehnt", text)
def test_string_code(self):
self.assertEqual(
message_for_code("10107"),
message_for_code(10107),
)
class ReportErrorTests(unittest.TestCase):
def test_report_carries_code(self):
text, code = anycubic_lan._report_error(
{"code": 10101, "msg": "Print task already exists"},
"Druckstart abgelehnt",
)
self.assertEqual(code, 10101)
self.assertIn("10101", text)
def test_raw_msg_unknown_code(self):
text, code = anycubic_lan._report_error(
{"code": 42, "msg": "irgendwas"},
"Druckstart abgelehnt",
)
self.assertEqual(code, 42)
self.assertIn("irgendwas", text)
self.assertIn("42", text)
class TaskSummaryTests(unittest.TestCase):
def test_free(self):
summary = task_summary({"state": "free", "project": {}})
self.assertEqual(summary["state"], "free")
self.assertIsNone(summary["task_id"])
def test_busy_project(self):
summary = task_summary({
"state": "printing",
"project": {"task_id": 12345, "filename": "/tmp/foo.gcode"},
})
self.assertEqual(summary["task_id"], "12345")
self.assertEqual(summary["filename"], "foo.gcode")
def test_empty(self):
self.assertIsNone(task_summary({}))
self.assertIsNone(task_summary(None))
class TaskBusyTests(unittest.TestCase):
def test_free_idle(self):
self.assertFalse(anycubic_lan._task_busy({"state": "free"}))
self.assertFalse(anycubic_lan._task_busy({"state": "idle"}))
def test_s1_style_null_project(self):
# S1 liefert project: null + last_project; last_project zählt nicht.
self.assertFalse(anycubic_lan._task_busy({
"state": "free",
"project": None,
"last_project": {"task_id": "old"},
}))
def test_busy_state(self):
self.assertTrue(anycubic_lan._task_busy({"state": "printing"}))
self.assertTrue(anycubic_lan._task_busy({"state": "auto_leveling"}))
def test_busy_project(self):
self.assertTrue(anycubic_lan._task_busy({
"state": "free",
"project": {"task_id": "9"},
}))
class PrintInfoSoftFailTests(unittest.TestCase):
def tearDown(self):
anycubic_lan._FAIL_CACHE.clear()
anycubic_lan._INFO_CACHE.clear()
@mock.patch("grotti.anycubic_lan.credentials", side_effect=LanError("weg"))
def test_credentials_fail_returns_none(self, _cred):
self.assertIsNone(anycubic_lan.print_info("10.0.0.9", use_cache=False))
self.assertIn("10.0.0.9", anycubic_lan._FAIL_CACHE)
@mock.patch("grotti.anycubic_lan.credentials", side_effect=LanError("weg"))
def test_fail_cache_skips_retry(self, cred):
anycubic_lan._FAIL_CACHE["10.0.0.9"] = time.time()
self.assertIsNone(anycubic_lan.print_info("10.0.0.9", use_cache=True))
cred.assert_not_called()
@mock.patch("grotti.anycubic_lan.print_info", return_value=None)
def test_ensure_free_when_lan_down(self, _info):
self.assertIsNone(ensure_task_free("10.0.0.9"))
class EnsureTaskFreeTests(unittest.TestCase):
@mock.patch("grotti.anycubic_lan.time.sleep", return_value=None)
@mock.patch("grotti.anycubic_lan.cancel_print")
@mock.patch("grotti.anycubic_lan.print_info")
def test_already_free(self, print_info, cancel_print, _sleep):
print_info.return_value = {"state": "free", "project": {}}
info = ensure_task_free("10.0.0.1")
self.assertEqual(info["state"], "free")
cancel_print.assert_not_called()
@mock.patch("grotti.anycubic_lan.time.sleep", return_value=None)
@mock.patch("grotti.anycubic_lan.cancel_print")
@mock.patch("grotti.anycubic_lan.print_info")
def test_clears_busy(self, print_info, cancel_print, _sleep):
print_info.side_effect = [
{"state": "printing", "project": {"task_id": "1"}},
{"state": "free", "project": {}},
]
info = ensure_task_free("10.0.0.1", attempts=3, wait_s=0.01)
self.assertEqual(info["state"], "free")
cancel_print.assert_called_once()
@mock.patch("grotti.anycubic_lan.time.sleep", return_value=None)
@mock.patch("grotti.anycubic_lan.cancel_print")
@mock.patch("grotti.anycubic_lan.print_info")
def test_raises_when_stuck(self, print_info, cancel_print, _sleep):
print_info.return_value = {"state": "printing", "project": {"task_id": "1"}}
with self.assertRaises(LanError) as ctx:
ensure_task_free("10.0.0.1", attempts=2, wait_s=0.01)
self.assertEqual(cancel_print.call_count, 2)
self.assertIn("freimachen", ctx.exception.message)
class StartPrintRetryTests(unittest.TestCase):
@mock.patch("grotti.anycubic_lan.time.sleep", return_value=None)
@mock.patch("grotti.anycubic_lan.ensure_task_free")
@mock.patch("grotti.anycubic_lan.cancel_print")
@mock.patch("grotti.anycubic_lan._mqtt_print")
def test_retry_on_10101(self, mqtt_print, cancel_print, ensure_free, _sleep):
ensure_free.return_value = {"state": "free"}
mqtt_print.side_effect = [
LanError(message_for_code(CODE_TASK_ACTIVE), code=CODE_TASK_ACTIVE),
{"state": "printing"},
]
result = start_print("10.0.0.1", "demo.gcode", auto_leveling=False)
self.assertEqual(result["filename"], "demo.gcode")
self.assertEqual(mqtt_print.call_count, 2)
cancel_print.assert_called_once()
@mock.patch("grotti.anycubic_lan.time.sleep", return_value=None)
@mock.patch("grotti.anycubic_lan.ensure_task_free")
@mock.patch("grotti.anycubic_lan._mqtt_print")
def test_no_retry_on_filament(self, mqtt_print, ensure_free, _sleep):
ensure_free.return_value = {"state": "free"}
mqtt_print.side_effect = LanError(
message_for_code(CODE_FILAMENT),
code=CODE_FILAMENT,
)
with self.assertRaises(LanError) as ctx:
start_print("10.0.0.1", "demo.gcode")
self.assertEqual(ctx.exception.code, CODE_FILAMENT)
self.assertEqual(mqtt_print.call_count, 1)
if __name__ == "__main__":
unittest.main()
+64
View File
@@ -0,0 +1,64 @@
"""Tests für Benutzer-/Token-Helfer und Kamera-Felder."""
import unittest
from grotti import auth
from grotti import moonraker
class AuthHelpersTest(unittest.TestCase):
def test_password_roundtrip(self):
hashed = auth.hash_password("geheim123")
self.assertTrue(auth.verify_password(hashed, "geheim123"))
self.assertFalse(auth.verify_password(hashed, "falsch"))
def test_username_rules(self):
self.assertEqual(auth.validate_username("admin"), "admin")
with self.assertRaises(Exception):
auth.validate_username("a")
with self.assertRaises(Exception):
auth.validate_username("bad name")
def test_role_rank(self):
self.assertTrue(auth.role_at_least("admin", "operator"))
self.assertTrue(auth.role_at_least("admin", "admin"))
self.assertFalse(auth.role_at_least("operator", "admin"))
def test_token_hash_stable(self):
raw = auth.new_api_token()
self.assertTrue(raw.startswith("grt_"))
self.assertEqual(auth.hash_token(raw), auth.hash_token(raw))
self.assertNotEqual(auth.hash_token(raw), auth.hash_token(raw + "x"))
def test_clamp_token_role(self):
self.assertEqual(auth.clamp_token_role("admin", "operator"), "operator")
with self.assertRaises(Exception):
auth.clamp_token_role("operator", "admin")
def test_login_rate(self):
key = "test-rate-%s" % id(self)
for _ in range(auth.LOGIN_MAX_ATTEMPTS):
self.assertTrue(auth.login_rate_ok(key))
auth.login_rate_hit(key)
self.assertFalse(auth.login_rate_ok(key))
class CameraFieldsTest(unittest.TestCase):
def test_unknown_offers_url(self):
moonraker.CAMERA_CACHE.clear()
fields = moonraker._camera_fields("10.0.0.1", "10.0.0.1")
self.assertTrue(fields["camera"])
self.assertIsNone(fields["has_camera"])
def test_known_dead_hides_url(self):
import time
moonraker.CAMERA_CACHE["10.0.0.2"] = (time.time(), 30, None)
fields = moonraker._camera_fields("10.0.0.2", "10.0.0.2")
self.assertEqual(fields["camera"], "")
self.assertFalse(fields["has_camera"])
moonraker.CAMERA_CACHE.pop("10.0.0.2", None)
if __name__ == "__main__":
unittest.main()
+25
View File
@@ -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"
+55
View File
@@ -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
+75
View File
@@ -0,0 +1,75 @@
"""Ist-Gramm und Spulenanteile ohne Datenbank."""
import unittest
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from grotti import usage
class ResolveUsedTests(unittest.TestCase):
def test_measured_from_mm(self):
grams, source = usage.resolve_used_grams(100, 500, 1000, 0, "cancelled", 60)
self.assertAlmostEqual(grams, 50)
self.assertEqual(source, "measured")
def test_abort_before_extrude(self):
grams, source = usage.resolve_used_grams(100, 0, 1000, 0, "cancelled", 5)
self.assertEqual(grams, 0)
self.assertEqual(source, "none")
def test_abort_progress_estimate(self):
grams, source = usage.resolve_used_grams(100, 0, 0, 0.25, "cancelled", 120)
self.assertAlmostEqual(grams, 25)
self.assertEqual(source, "estimate")
def test_completed_keeps_planned_without_mm(self):
grams, source = usage.resolve_used_grams(80, 0, 0, 1, "completed", 3600)
self.assertAlmostEqual(grams, 80)
self.assertEqual(source, "estimate")
def test_overshoot_no_cap(self):
grams, source = usage.resolve_used_grams(100, 1200, 1000, 1, "completed", 3600)
self.assertAlmostEqual(grams, 120)
self.assertEqual(source, "measured")
class SplitTests(unittest.TestCase):
def test_single_is_measured(self):
parts, estimate = usage.split_used(50, [100], multi=False)
self.assertEqual(parts, [50])
self.assertFalse(estimate)
def test_multi_is_estimate(self):
parts, estimate = usage.split_used(90, [30, 60], multi=True)
self.assertAlmostEqual(parts[0], 30)
self.assertAlmostEqual(parts[1], 60)
self.assertTrue(estimate)
class SegmentTests(unittest.TestCase):
def test_deltas(self):
segs = [
{"spool_id": 1, "ace_slot": None, "mm_start": 0, "mm_end": 400},
{"spool_id": 2, "ace_slot": None, "mm_start": 400, "mm_end": None},
]
deltas = usage.segment_deltas(segs, 1000)
self.assertEqual(deltas[0], (1, None, 400))
self.assertEqual(deltas[1], (2, None, 600))
def test_grams_from_mm(self):
self.assertAlmostEqual(usage.grams_from_mm(250, 1000, 80), 20)
class WeightListTests(unittest.TestCase):
def test_list_and_sum(self):
meta = {"filament_weight_total": [10, 20, 5]}
self.assertEqual(usage.weight_list(meta), [10, 20, 5])
self.assertEqual(usage.weight_sum(meta), 35)
if __name__ == "__main__":
unittest.main()
+640 -47
View File
@@ -3,6 +3,7 @@
import json
import re
import secrets
import time
import uuid
from datetime import datetime
from pathlib import Path
@@ -10,10 +11,11 @@ from urllib.parse import quote
import markdown
import psycopg
from flask import Flask, abort, g, jsonify, render_template, request
from flask import Flask, abort, g, jsonify, redirect, render_template, request, session, url_for
from markupsafe import Markup
from psycopg.rows import dict_row
from grotti import auth as grotti_auth
from grotti import config as grotti_config
from grotti import eject, jobs, library, moonraker, notify, store
from grotti.catalog import color_catalog
@@ -29,14 +31,13 @@ from grotti.watcher import StateWatcher
ROOT = Path(__file__).resolve().parent
TOOL_ROOT = ROOT.parent
DOCS_DIR = TOOL_ROOT / "docs"
CHANGELOG = TOOL_ROOT / "CHANGELOG.md"
PROJECT_STATUS = {"idee", "bereit", "druckt", "fertig"}
VERDICTS = {"", "gut", "ausschuss"}
IDEA_STATUS = {"offen", "spaeter", "erledigt"}
PRINTER_FAMILIES = set(FAMILY_LABELS) | {""}
STEER_ACTIONS = {
"nozzle", "bed", "speed", "flow", "fan", "emergency_stop", "clear_error",
"exclude_object",
"exclude_object", "dryer_start", "dryer_stop", "slot_setzen",
}
@@ -47,8 +48,13 @@ def create_app(cfg):
static_folder=str(ROOT / "static"),
)
app.config["GROTTI"] = cfg
app.config["SECRET_KEY"] = cfg.get("secret_key") or secrets.token_hex(32)
app.config["TEMPLATES_AUTO_RELOAD"] = True
app.config["MAX_CONTENT_LENGTH"] = 256 * 1024 * 1024
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
if cfg.get("fqdn", "").startswith("https://"):
app.config["SESSION_COOKIE_SECURE"] = True
app.json.ensure_ascii = False
app.extensions["send_queue"] = SendQueue(cfg["database_url"], cfg)
watcher = StateWatcher(cfg["database_url"], cfg)
@@ -58,19 +64,167 @@ def create_app(cfg):
watcher.start()
dispatcher.start()
@app.before_request
def check_token():
token = cfg["token"]
if not token or not request.path.startswith("/api/"):
PUBLIC_PATHS = {
"/login",
"/setup",
"/api/v1/auth/status",
"/api/v1/auth/login",
"/api/v1/auth/setup",
"/api/v1/version",
}
ADMIN_API_PREFIXES = (
"/api/v1/users",
"/api/v1/einstellungen",
)
ADMIN_API_EXACT = {
"/api/v1/telegram/test",
}
ADMIN_METHODS_ON = {
# Drucker anlegen/ändern/löschen nur Admin
("POST", "/api/v1/drucker"),
}
def conn():
if "db" not in g:
g.db = psycopg.connect(cfg["database_url"], row_factory=dict_row)
return g.db
def ensure_csrf():
if not session.get("csrf"):
session["csrf"] = grotti_auth.new_csrf()
return session["csrf"]
def auth_user():
return getattr(g, "user", None)
def require_role(need):
user = auth_user()
if not user or not grotti_auth.role_at_least(user.get("role"), need):
raise GrottiError("Keine Berechtigung.", "forbidden", 403)
return user
def resolve_bearer(presented):
legacy = cfg.get("token") or ""
if legacy and secrets.compare_digest(presented, legacy):
return {
"id": None,
"username": "legacy-token",
"role": "admin",
"auth": "legacy",
}
if not presented.startswith(grotti_auth.TOKEN_PREFIX):
return None
row = store.get_api_token_by_hash(conn(), grotti_auth.hash_token(presented))
if not row or row.get("revoked_at") or not row.get("user_active"):
return None
if row.get("expires_at") and row["expires_at"].timestamp() < time.time():
return None
store.touch_api_token(conn(), row["id"])
return {
"id": row["user_id"],
"username": row["username"],
"role": row["role"],
"auth": "token",
"token_id": row["id"],
}
def resolve_session_user():
uid = session.get("user_id")
if not uid:
return None
row = store.get_user(conn(), uid)
if not row or not row.get("active"):
session.clear()
return None
return {
"id": row["id"],
"username": row["username"],
"role": row["role"],
"auth": "session",
}
def wants_json():
if request.path.startswith("/api/"):
return True
accept = request.accept_mimetypes
return accept.best == "application/json"
@app.before_request
def check_auth():
g.user = None
path = request.path
if path.startswith("/static/"):
return None
if path in PUBLIC_PATHS:
return None
users_exist = store.user_count(conn()) > 0
if not users_exist:
if path.startswith("/api/") and path != "/api/v1/auth/setup":
if path == "/api/v1/auth/status":
return None
return jsonify({
"error": "Zuerst Admin anlegen.",
"code": "setup",
"setup": True,
}), 503
if path != "/setup":
return redirect(url_for("setup_page"))
return None
presented = ""
header = request.headers.get("Authorization", "")
if header.startswith("Bearer "):
presented = header[7:]
elif request.method == "GET":
presented = request.args.get("token", "")
if not presented or not secrets.compare_digest(presented, token):
return jsonify({"error": "Token fehlt oder stimmt nicht.", "code": "token"}), 401
presented = header[7:].strip()
elif request.method == "GET" and request.path.startswith("/api/"):
presented = (request.args.get("token") or "").strip()
user = None
if presented:
user = resolve_bearer(presented)
if not user:
return jsonify({"error": "Token fehlt oder stimmt nicht.", "code": "token"}), 401
else:
user = resolve_session_user()
if not user:
if wants_json() or path.startswith("/api/"):
return jsonify({"error": "Anmeldung nötig.", "code": "auth"}), 401
return redirect(url_for("login_page", next=path))
g.user = user
ensure_csrf()
# CSRF für Cookie-Sessions bei mutierenden API-Calls
if (
user.get("auth") == "session"
and path.startswith("/api/")
and request.method in ("POST", "PATCH", "PUT", "DELETE")
):
sent = request.headers.get("X-CSRF-Token") or ""
expected = session.get("csrf") or ""
if not expected or not secrets.compare_digest(sent, expected):
return jsonify({"error": "CSRF-Token fehlt oder ungültig.", "code": "csrf"}), 403
need_admin = False
if path.startswith(ADMIN_API_PREFIXES) or path in ADMIN_API_EXACT:
if path == "/api/v1/einstellungen" and request.method == "GET":
need_admin = False
elif path == "/api/v1/einstellungen" and request.method == "PATCH":
# Operator darf nur auto_queue schalten
payload = request.get_json(silent=True) or {}
keys = {k for k, v in payload.items() if v is not None}
need_admin = keys - {"auto_queue"} != set()
elif path.startswith("/api/v1/users"):
need_admin = True
elif request.method != "GET":
need_admin = True
if (request.method, path) in ADMIN_METHODS_ON:
need_admin = True
if path.startswith("/api/v1/drucker/id/") and request.method in ("PATCH", "DELETE"):
need_admin = True
if need_admin and not grotti_auth.role_at_least(user.get("role"), "admin"):
return jsonify({"error": "Nur für Admins.", "code": "forbidden"}), 403
return None
@app.teardown_appcontext
@@ -88,16 +242,11 @@ def create_app(cfg):
@app.errorhandler(GrottiError)
def grotti_error(err):
conn = g.get("db")
if conn:
conn.rollback()
db = g.get("db")
if db:
db.rollback()
return jsonify({"error": err.message, "code": err.code}), err.status
def conn():
if "db" not in g:
g.db = psycopg.connect(cfg["database_url"], row_factory=dict_row)
return g.db
def body():
data = request.get_json(silent=True)
if not isinstance(data, dict):
@@ -124,19 +273,261 @@ def create_app(cfg):
return host
@app.context_processor
def inject_version():
return {"version": __version__}
def inject_globals():
user = auth_user()
return {
"version": __version__,
"csrf": session.get("csrf") or "",
"current_user": user,
}
def page(name, title):
return render_template(name, page=name.split(".")[0], title=title, token=cfg["token"])
return render_template(
name,
page=name.split(".")[0],
title=title,
token="",
)
def audit(kind, message, host=""):
user = auth_user()
who = (user or {}).get("username") or "?"
store.add_event(conn(), host, "", kind, "%s · %s" % (who, message))
@app.get("/login")
def login_page():
if store.user_count(conn()) == 0:
return redirect(url_for("setup_page"))
if resolve_session_user():
return redirect("/")
ensure_csrf()
return render_template("login.html", title="Anmelden", csrf=session.get("csrf") or "")
@app.get("/setup")
def setup_page():
if store.user_count(conn()) > 0:
return redirect(url_for("login_page"))
ensure_csrf()
return render_template("setup.html", title="Ersten Admin anlegen", csrf=session.get("csrf") or "")
@app.get("/api/v1/auth/status")
def auth_status():
count = store.user_count(conn())
user = resolve_session_user()
return jsonify({
"setup_required": count == 0,
"authenticated": bool(user or auth_user()),
"user": grotti_auth.public_user(user) if user else None,
"oauth_enabled": bool((cfg.get("oauth") or {}).get("enabled")),
"legacy_token_set": bool(cfg.get("token")),
})
@app.post("/api/v1/auth/setup")
def auth_setup():
if store.user_count(conn()) > 0:
raise GrottiError("Setup ist schon erledigt.", "setup", 409)
data = body()
username = grotti_auth.validate_username(data.get("username") or "")
password = grotti_auth.validate_password(data.get("password") or "")
row = store.create_user(conn(), username, grotti_auth.hash_password(password), "admin")
store.touch_user_login(conn(), row["id"])
session.clear()
session["user_id"] = row["id"]
session["csrf"] = grotti_auth.new_csrf()
store.add_event(conn(), "", "", "auth", "Admin %s angelegt" % username)
return jsonify({"user": grotti_auth.public_user(row), "csrf": session["csrf"]}), 201
@app.post("/api/v1/auth/login")
def auth_login():
if store.user_count(conn()) == 0:
raise GrottiError("Zuerst Admin anlegen.", "setup", 503)
key = request.remote_addr or "unknown"
if not grotti_auth.login_rate_ok(key):
raise GrottiError("Zu viele Versuche. Bitte warten.", "rate", 429)
data = body()
username = (data.get("username") or "").strip()
password = data.get("password") or ""
row = store.get_user_by_name(conn(), username)
if not row or not row.get("active") or not grotti_auth.verify_password(row["password_hash"], password):
grotti_auth.login_rate_hit(key)
raise GrottiError("Benutzer oder Passwort falsch.", "auth", 401)
store.touch_user_login(conn(), row["id"])
session.clear()
session["user_id"] = row["id"]
session["csrf"] = grotti_auth.new_csrf()
store.add_event(conn(), "", "", "auth", "Login %s" % row["username"])
return jsonify({"user": grotti_auth.public_user(row), "csrf": session["csrf"]})
@app.post("/api/v1/auth/logout")
def auth_logout():
session.clear()
return jsonify({"ok": True})
@app.get("/api/v1/me")
def me():
user = require_role("operator")
return jsonify({"user": {
"id": user["id"],
"username": user["username"],
"role": user["role"],
"auth": user.get("auth"),
}, "csrf": ensure_csrf()})
@app.get("/api/v1/users")
def users_list():
require_role("admin")
return jsonify([grotti_auth.public_user(r) for r in store.list_users(conn())])
@app.post("/api/v1/users")
def users_create():
require_role("admin")
data = body()
username = grotti_auth.validate_username(data.get("username") or "")
password = grotti_auth.validate_password(data.get("password") or "")
role = grotti_auth.normalize_role(data.get("role") or "operator")
if store.get_user_by_name(conn(), username):
raise GrottiError("Benutzer gibt es schon.", "user", 409)
row = store.create_user(conn(), username, grotti_auth.hash_password(password), role)
audit("auth", "Benutzer %s (%s) angelegt" % (username, role))
return jsonify(grotti_auth.public_user(row)), 201
@app.patch("/api/v1/users/<int:user_id>")
def users_patch(user_id):
actor = require_role("admin")
data = body()
row = store.get_user(conn(), user_id)
if not row:
raise GrottiError("Benutzer fehlt.", "user", 404)
password_hash = None
if data.get("password"):
password_hash = grotti_auth.hash_password(grotti_auth.validate_password(data["password"]))
role = grotti_auth.normalize_role(data["role"]) if "role" in data else None
active = data.get("active") if "active" in data else None
if active is False and row["id"] == actor.get("id"):
raise GrottiError("Eigenes Konto nicht deaktivieren.", "user")
if role == "operator" and row["role"] == "admin" and row["id"] == actor.get("id"):
admins = [u for u in store.list_users(conn()) if u["role"] == "admin" and u["active"]]
if len(admins) <= 1:
raise GrottiError("Letzter Admin bleibt Admin.", "user")
updated = store.update_user(conn(), user_id, password_hash=password_hash, role=role, active=active)
audit("auth", "Benutzer %s geändert" % row["username"])
return jsonify(grotti_auth.public_user(updated))
@app.delete("/api/v1/users/<int:user_id>")
def users_delete(user_id):
actor = require_role("admin")
row = store.get_user(conn(), user_id)
if not row:
raise GrottiError("Benutzer fehlt.", "user", 404)
if row["id"] == actor.get("id"):
raise GrottiError("Eigenes Konto nicht löschen.", "user")
if row["role"] == "admin":
admins = [u for u in store.list_users(conn()) if u["role"] == "admin" and u["active"]]
if len(admins) <= 1:
raise GrottiError("Letzter Admin bleibt.", "user")
store.delete_user(conn(), user_id)
audit("auth", "Benutzer %s gelöscht" % row["username"])
return "", 204
@app.get("/api/v1/tokens")
def tokens_list():
user = require_role("operator")
if user.get("role") == "admin" and request.args.get("all") == "1":
rows = store.list_api_tokens(conn(), user_id=None)
else:
if not user.get("id"):
return jsonify([])
rows = store.list_api_tokens(conn(), user_id=user["id"])
out = []
for row in rows:
item = grotti_auth.public_token(row)
item["username"] = row.get("username")
out.append(item)
return jsonify(out)
@app.post("/api/v1/tokens")
def tokens_create():
user = require_role("operator")
if not user.get("id"):
raise GrottiError("Legacy-Token: bitte Benutzer-Token anlegen.", "token")
data = body()
name = (data.get("name") or "Token").strip()[:80]
role = grotti_auth.clamp_token_role(user["role"], data.get("role"))
raw = grotti_auth.new_api_token()
row = store.create_api_token(
conn(),
user["id"],
name,
grotti_auth.hash_token(raw),
grotti_auth.token_prefix(raw),
role,
)
audit("auth", "API-Token „%s“ erzeugt" % name)
item = grotti_auth.public_token(row)
item["token"] = raw
return jsonify(item), 201
@app.delete("/api/v1/tokens/<int:token_id>")
def tokens_revoke(token_id):
user = require_role("operator")
if user.get("role") == "admin":
row = store.revoke_api_token(conn(), token_id, user_id=None)
else:
row = store.revoke_api_token(conn(), token_id, user_id=user.get("id"))
if not row:
raise GrottiError("Token fehlt.", "token", 404)
audit("auth", "API-Token #%s widerrufen" % token_id)
return jsonify(grotti_auth.public_token(row))
def doc_names():
"""Nur Anleitung-*.md — Technik liegt unter docs/internal/ (nicht in der App)."""
if not DOCS_DIR.is_dir():
return []
return sorted(p.stem for p in DOCS_DIR.glob("*.md"))
return sorted(p.stem for p in DOCS_DIR.glob("Anleitung-*.md"))
ANLEITUNG_ORDER = (
"Anleitung-Einrichtung",
"Anleitung-Oberflaeche",
"Anleitung-Wand",
"Anleitung-Dateien",
"Anleitung-ACE",
"Anleitung-Warteschlange",
"Anleitung-Material",
"Anleitung-Einstellungen",
"Anleitung-Stoerungen",
)
def anleitung_list(names=None):
names = names if names is not None else doc_names()
anleitung_set = set(names)
ordered = [n for n in ANLEITUNG_ORDER if n in anleitung_set]
ordered += sorted(anleitung_set - set(ordered))
return ordered
def anleitung_title(name):
labels = {
"Anleitung-Einrichtung": "Einrichtung",
"Anleitung-Oberflaeche": "Oberfläche",
"Anleitung-Wand": "Druckerwand",
"Anleitung-Dateien": "Dateien senden",
"Anleitung-ACE": "ACE und Spulen",
"Anleitung-Warteschlange": "Warteschlange",
"Anleitung-Material": "Material und mehr",
"Anleitung-Einstellungen": "Einstellungen",
"Anleitung-Stoerungen": "Störungen",
}
return labels.get(name, name.replace("Anleitung-", "").replace("-", " "))
def render_markdown(path):
"""Markdown für die App: Git-relative Bild-/Kapitelpfade → App-URLs."""
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"])
return Markup(html)
@@ -180,7 +571,7 @@ def create_app(cfg):
"etikett.html",
page="etikett",
title="Etikett %s" % spool["chargen_nr"],
token=cfg["token"],
token="",
spool=spool,
qr_url=qr_url,
)
@@ -189,6 +580,58 @@ def create_app(cfg):
def protokoll():
return page("protokoll.html", "Protokoll")
@app.get("/protokoll/<int:run_id>")
def protokoll_detail(run_id):
row = store.get_run_row(conn(), run_id)
if not row:
abort(404)
return page("bericht.html", "Druckbericht")
@app.get("/statistik")
def statistik_page():
return page("statistik.html", "Statistik")
@app.get("/etikett/lauf/<int:run_id>")
def etikett_lauf(run_id):
row = store.get_run_row(conn(), run_id)
if not row:
abort(404)
run = store.run_out(row)
if not run.get("chargen_nr"):
store.ensure_run_chargen(conn(), run_id, run.get("weight_used_g"))
row = store.get_run_row(conn(), run_id)
run = store.run_out(row)
if not run.get("chargen_nr"):
abort(404)
labels = store.list_run_labels(conn(), run_id)
mode = (request.args.get("mode") or "druck").strip()
qty = max(1, min(50, int(request.args.get("qty") or 1)))
if mode == "objekt":
labels = store.ensure_run_labels(conn(), run_id, run.get("objects") or [], qty=qty)
sheets = [lab for lab in labels if not lab.get("excluded")]
else:
sheets = [{"object_name": "", "chargen_nr": run["chargen_nr"], "excluded": False}]
if not sheets:
abort(404)
spools = store.etikett_spools_for_run(conn(), run)
base = grotti_config.public_base(cfg, request.url_root)
target_g = float(run.get("weight_planned_g") or run.get("weight_g") or 0)
for sheet in sheets:
sheet["qr_url"] = "%s/protokoll/%s" % (base, run_id)
sheet["target_g"] = target_g
if sheet.get("object_name"):
sheet["qr_url"] += "#objekt-%s" % quote(sheet["object_name"])
return render_template(
"etikett_lauf.html",
page="etikett",
title="Etikett %s" % run["chargen_nr"],
token="",
run=run,
sheets=sheets,
spools=spools,
target_g=target_g,
)
@app.get("/ideen")
def ideen():
return page("ideen.html", "Ideen")
@@ -203,37 +646,42 @@ def create_app(cfg):
@app.get("/doku")
def doku():
names = doc_names()
anleitung = anleitung_list(names)
return render_template(
"doku.html",
page="doku",
title="Doku",
token=cfg["token"],
docs=doc_names(),
token="",
docs=names,
anleitung=anleitung,
anleitung_title=anleitung_title,
)
@app.get("/doku/<name>")
def doku_page(name):
if name == "changelog":
path, title = CHANGELOG, "Changelog"
elif name in doc_names():
path, title = DOCS_DIR / ("%s.md" % name), name.replace("-", " ")
else:
names = doc_names()
if name not in names:
abort(404)
path = DOCS_DIR / ("%s.md" % name)
if not path.is_file():
abort(404)
anleitung = anleitung_list(names)
return render_template(
"doku_page.html",
page="doku",
title=title,
token=cfg["token"],
title=anleitung_title(name),
token="",
content=render_markdown(path),
docs=doc_names(),
docs=names,
anleitung=anleitung,
anleitung_title=anleitung_title,
current=name,
)
@app.get("/api/v1/version")
def version_info():
return jsonify({"version": __version__, "changelog": "/doku/changelog"})
return jsonify({"version": __version__})
@app.get("/api/v1/einstellungen")
def settings():
@@ -262,7 +710,7 @@ def create_app(cfg):
return jsonify({"ok": True})
@app.get("/api/v1/drucker")
@app.get("/api/drucker")
@app.get("/api/drucker") # veraltet — Alias zu /api/v1/drucker
def printers():
rows = store.list_printers(conn())
clears = store.bed_clear_map(conn())
@@ -276,6 +724,9 @@ def create_app(cfg):
item["queue_failed"] = queue.get("failed")
item["queue_waiting"] = queue.get("waiting", 0)
item["queue_waiting_next"] = queue.get("waiting_next", "")
live_run = store.find_live_run(conn(), host) if host else None
item["live_run_id"] = (live_run or {}).get("id")
item["live_chargen"] = (live_run or {}).get("chargen_nr") or ""
by_id = {row["host"]: row for row in rows}
if request.args.get("live", "1") == "0":
@@ -477,7 +928,7 @@ def create_app(cfg):
row = store.get_file_row(conn(), file_id)
if not row:
abort(404)
png = library.preview_png(row)
png = library.preview_png(row, conn=conn(), data_dir=cfg["data_dir"])
if not png:
abort(404)
return png, 200, {"Content-Type": "image/png", "Cache-Control": "private, max-age=300"}
@@ -487,8 +938,8 @@ def create_app(cfg):
row = store.get_file_row(conn(), file_id)
if not row:
raise GrottiError("Die Datei fehlt.", "datei", 404)
path = Path(row["stored_path"])
if not path.is_file():
path = library.resolve_stored_path(conn(), row, cfg["data_dir"])
if path is None or not path.is_file():
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
cache_dir = Path(cfg["data_dir"]) / "toolpath"
cache_dir.mkdir(parents=True, exist_ok=True)
@@ -514,7 +965,10 @@ def create_app(cfg):
pass
data["file_id"] = file_id
data["name"] = row["name"]
return jsonify(data)
data["version"] = TOOLPATH_VERSION
resp = jsonify(data)
resp.headers["Cache-Control"] = "no-store"
return resp
@app.patch("/api/v1/dateien/<int:file_id>")
def file_patch(file_id):
@@ -767,6 +1221,8 @@ def create_app(cfg):
data = body()
if isinstance(data.get("steps"), list) and data.get("steps"):
return jsonify(enqueue_ablauf_payload(data)), 202
if not bool(data.get("confirm")):
raise GrottiError("Bestätigung fehlt.", "bestaetigung")
try:
file_id = int(data.get("file_id"))
except (TypeError, ValueError) as exc:
@@ -1105,11 +1561,28 @@ def create_app(cfg):
@app.get("/api/v1/spulen/by-code/<path:code>")
def spool_by_code(code):
row = store.get_spool_by_code(conn(), code)
text = (code or "").strip()
if text.upper().startswith("D-"):
run = store.get_run_by_chargen(conn(), text)
if not run:
raise GrottiError("Kein Druck mit dieser Charge.", "charge", 404)
return jsonify({"kind": "lauf", "run": store.run_out(run), "redirect": "/protokoll/%s" % run["id"]})
row = store.get_spool_by_code(conn(), text)
if not row:
raise GrottiError("Keine Spule mit diesem Code.", "spule", 404)
return jsonify(row)
@app.get("/api/v1/spulen/<int:spool_id>/akte")
def spool_akte(spool_id):
spool = store.get_spool_row(conn(), spool_id)
if not spool:
raise GrottiError("Die Spule fehlt.", "spule", 404)
return jsonify({
"spool": store.spool_out(spool),
"runs": store.list_spool_runs(conn(), spool_id),
"stats": store.spool_usage_stats(conn(), spool_id),
})
@app.post("/api/v1/spulen")
def spool_create():
return jsonify(store.create_spool(conn(), spool_fields(body()))), 201
@@ -1130,6 +1603,18 @@ def create_app(cfg):
spool = store.get_spool_row(conn(), spool_id)
if not spool:
raise GrottiError("Die Spule fehlt.", "spule", 404)
live_run = store.find_live_run(conn(), host)
mm_now = float((live_run or {}).get("filament_used_mm") or 0)
printing = bool(
live_run and (live_run.get("state") or "") in ("in_progress", "printing", "paused")
)
if printing:
try:
snap = moonraker.query(host)
if isinstance(snap.get("filament_used"), (int, float)):
mm_now = float(snap["filament_used"])
except Exception:
pass
# Alte Rolle am Platz / externe Rolle am Host freimachen
if slot:
old = conn().execute(
@@ -1141,22 +1626,32 @@ def create_app(cfg):
"SELECT * FROM spools WHERE printer_host = %s AND ace_slot IS NULL AND id <> %s",
(host, spool_id),
).fetchall()
printer = store.printer_by_host(conn(), host) or {}
if printing:
closed = store.close_open_segments(conn(), live_run["id"], slot, mm_now)
if not closed and old:
# Erster Wechsel: alter Abschnitt von 0 bis jetzt
for row in old:
store.open_segment(conn(), live_run["id"], row["id"], slot, 0)
store.close_open_segments(conn(), live_run["id"], slot, mm_now)
for row in old:
store.update_spool(conn(), row["id"], {"printer_host": "", "ace_slot": None})
store.add_event(
conn(),
host,
(store.printer_by_host(conn(), host) or {}).get("name") or host,
printer.get("name") or host,
"spule",
"%s: „%s“ ins Regal%s" % (
(store.printer_by_host(conn(), host) or {}).get("name") or host,
printer.get("name") or host,
row["name"],
(" (Platz %s)" % slot) if slot else "",
),
"",
(live_run or {}).get("filename") or "",
run_id=(live_run or {}).get("id"),
)
updated = store.update_spool(conn(), spool_id, {"printer_host": host, "ace_slot": slot})
printer = store.printer_by_host(conn(), host) or {}
if printing:
store.open_segment(conn(), live_run["id"], spool_id, slot, mm_now)
store.add_event(
conn(),
host,
@@ -1167,7 +1662,8 @@ def create_app(cfg):
updated["name"],
(" auf Platz %s" % slot) if slot else "",
),
"",
(live_run or {}).get("filename") or "",
run_id=(live_run or {}).get("id"),
)
return jsonify(updated)
@@ -1303,6 +1799,36 @@ def create_app(cfg):
def run_list():
return jsonify(store.list_runs(conn()))
@app.get("/api/v1/laeufe/<int:run_id>")
def run_get(run_id):
row = store.get_run_row(conn(), run_id)
if not row:
raise GrottiError("Der Lauf fehlt.", "lauf", 404)
run = store.run_out(row)
if not run.get("chargen_nr"):
store.ensure_run_chargen(conn(), run_id)
row = store.get_run_row(conn(), run_id) or row
run = store.run_out(row)
if run.get("chargen_nr") and run.get("objects"):
store.ensure_run_labels(conn(), run_id, run.get("objects") or [], qty=1)
file_row = store.get_file_row(conn(), run["file_id"]) if run.get("file_id") else None
project = store.get_project(conn(), run["project_id"]) if run.get("project_id") else None
samples = store.list_run_samples(conn(), run_id)
# Anzeige auf ca. 1 Punkt / Minute verdichten
if len(samples) > 180:
step = max(1, len(samples) // 120)
samples = samples[::step]
return jsonify({
"run": run,
"spools": store.list_run_spools(conn(), run_id),
"segments": store.list_run_segments(conn(), run_id),
"samples": samples,
"labels": store.list_run_labels(conn(), run_id),
"events": store.list_run_events(conn(), run_id),
"file": store.file_out(file_row) if file_row else None,
"project": project,
})
@app.post("/api/v1/laeufe/abgleich")
def run_sync():
library.sync_watch(conn(), cfg, force=False)
@@ -1325,4 +1851,71 @@ def create_app(cfg):
row = store.run_out(current)
return jsonify(row)
@app.get("/api/v1/statistik")
def statistik_api():
since = request.args.get("since") or None
until = request.args.get("until") or None
host = (request.args.get("host") or "").strip()
material = (request.args.get("material") or "").strip()
brand = (request.args.get("brand") or "").strip()
project_id = optional_id(request.args.get("project_id"))
return jsonify(store.statistics(
conn(),
since=since,
until=until,
host=host,
material=material or "",
brand=brand or "",
project_id=project_id,
))
@app.get("/api/v1/statistik.csv")
def statistik_csv():
import csv
import io
from flask import Response
since = request.args.get("since") or None
until = request.args.get("until") or None
host = (request.args.get("host") or "").strip()
material = (request.args.get("material") or "").strip()
brand = (request.args.get("brand") or "").strip()
runs = store.list_runs(
conn(),
limit=2000,
since=since,
until=until,
host=host,
material=material,
brand=brand,
)
buf = io.StringIO()
writer = csv.writer(buf, delimiter=";")
writer.writerow([
"id", "drucker", "datei", "zustand", "start", "ende", "dauer_s",
"soll_g", "ist_g", "quelle", "filament_eur", "strom_eur", "urteil", "charge",
])
for run in runs:
writer.writerow([
run["id"],
run.get("printer_name") or "",
run.get("filename") or "",
run.get("state") or "",
run.get("started_at") or "",
run.get("ended_at") or "",
run.get("duration_s") or 0,
run.get("weight_planned_g") or 0,
run.get("weight_used_g") or 0,
run.get("weight_source") or "",
run.get("filament_cost") or 0,
run.get("energy_cost") or 0,
run.get("verdict") or "",
run.get("chargen_nr") or "",
])
return Response(
buf.getvalue(),
mimetype="text/csv; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=grotti-laeufe.csv"},
)
return app
+24 -4
View File
@@ -1,4 +1,5 @@
const TOKEN = document.querySelector('meta[name="token"]')?.content || "";
const TOKEN = ""; // Legacy: Auth über Session-Cookie oder Bearer von außen
const CSRF = document.querySelector('meta[name="csrf"]')?.content || "";
const STATE_LABELS = {
printing: "druckt", paused: "pausiert", complete: "fertig", standby: "bereit",
@@ -17,17 +18,29 @@ function escapeHtml(value) {
async function api(path, options = {}) {
const headers = new Headers(options.headers || {});
if (TOKEN) headers.set("Authorization", "Bearer " + TOKEN);
const method = (options.method || "GET").toUpperCase();
if (CSRF && method !== "GET" && method !== "HEAD") {
headers.set("X-CSRF-Token", CSRF);
}
let body = options.body;
if (options.json !== undefined) {
headers.set("Content-Type", "application/json");
body = JSON.stringify(options.json);
}
const response = await fetch("/api/v1" + path, {
method: options.method || "GET",
method,
headers,
body,
cache: "no-store",
credentials: "same-origin",
});
if (response.status === 401) {
const here = location.pathname + location.search;
if (!here.startsWith("/login") && !here.startsWith("/setup")) {
location.href = "/login?next=" + encodeURIComponent(here);
}
throw new Error("Anmeldung nötig.");
}
if (!response.ok) {
let message = response.statusText;
try {
@@ -45,8 +58,8 @@ async function api(path, options = {}) {
}
function withToken(url) {
if (!url || !TOKEN) return url;
return url + (url.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(TOKEN);
// Session-Cookie reicht für same-origin Kameras; kein Token in der URL.
return url;
}
function euro(value) {
@@ -103,6 +116,13 @@ function chipHtml(state, label, small) {
return `<span class="chip ${escapeHtml(state || "")}${small ? " sm" : ""}">${escapeHtml(text)}</span>`;
}
function shownState(printer) {
const state = (printer && printer.state) || "offline";
const hot = ((printer && printer.nozzle_target) || 0) > 40 || ((printer && printer.bed_target) || 0) > 30;
if ((state === "standby" || state === "complete") && hot) return "heating";
return state;
}
function stepsHtml(labels, index, compact) {
if (!labels || !labels.length) return "";
const parts = labels.map((label, i) => {
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

+2 -2
View File
@@ -10,8 +10,8 @@ window.GrottiGcodeViewer = (function () {
const COLOR_OBJ_EX = 0xef4444;
const COLOR_OBJ_CUR = 0xfbbf24;
const GRID_STEP = 10;
// Nur leichte Doppelung — starke Offsets + Punkte wirkten wie Vollkörper mit Löchern
const FAT_OFFSETS = [0, -0.22, 0.22];
// Keine Parallel-Offsets: bei ausgedünnten Pfaden wirkten sie gesprenkelt
const FAT_OFFSETS = [0];
function create(canvas) {
const wrap = document.createElement("div");
+254 -24
View File
@@ -111,20 +111,21 @@ summary:focus:not(:focus-visible) { outline: none; }
flex: 1;
min-width: 0;
}
.brand-mark {
flex: none;
width: 1.55rem;
height: 1.55rem;
border-radius: 99px;
object-fit: contain;
display: block;
}
.brand-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.nav-toggle {
flex: none;
width: 2.5rem;
height: 2.5rem;
min-width: 2.5rem;
}
.brand::before {
content: "";
width: 0.65rem;
height: 0.65rem;
border-radius: 99px;
background: var(--accent);
box-shadow: 0 0 0 4px var(--accent-soft);
}
.nav-main, .nav-secondary { display: flex; flex-direction: column; gap: 0.1rem; }
.nav-main a, .nav-secondary a {
color: var(--muted);
@@ -183,10 +184,14 @@ html.nav-collapsed .brand,
body.nav-collapsed .brand {
padding: 0.2rem;
justify-content: center;
font-size: 0;
}
html.nav-collapsed .brand::before,
body.nav-collapsed .brand::before { margin: 0; }
html.nav-collapsed .brand-name,
body.nav-collapsed .brand-name { display: none; }
html.nav-collapsed .brand-mark,
body.nav-collapsed .brand-mark {
width: 1.75rem;
height: 1.75rem;
}
html.nav-collapsed .brand-row,
body.nav-collapsed .brand-row { flex-direction: column; padding-bottom: 0.35rem; }
html.nav-collapsed .nav-toggle,
@@ -400,6 +405,36 @@ body.shelf [data-role="hero"] { font-size: 1.35rem; }
padding: 0.25rem 0.35rem;
font-size: 0.82rem;
}
.steer-ace,
.steer-ace-slots {
display: grid;
gap: 0.35rem;
padding-top: 0.35rem;
border-top: 1px solid var(--line);
}
.steer-ace-head {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.65rem;
align-items: baseline;
font-size: 0.78rem;
}
.steer-ace-head b { font-weight: 650; }
.steer-slot-row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.55rem;
align-items: center;
font-size: 0.78rem;
}
.steer-slot-row .dot {
width: 0.85rem;
height: 0.85rem;
border-radius: 99px;
flex: none;
border: 1px solid rgba(255, 255, 255, 0.15);
}
.steer-slot-row .grow { min-width: 0; overflow-wrap: anywhere; }
.queue-name { color: var(--ink); font-size: var(--t-sm); font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
.queue-msg { color: var(--muted); font-size: var(--t-xs); line-height: 1.3; overflow-wrap: anywhere; }
.queue-sub { color: var(--faint); font-size: 0.66rem; font-variant-numeric: tabular-nums; flex: none; }
@@ -462,12 +497,16 @@ body.shelf [data-role="hero"] { font-size: 1.35rem; }
.ace-printer-name { margin: 0; font-size: 0.82rem; font-weight: 650; color: var(--ink); }
.ace-row {
display: grid;
grid-template-columns: 0.85rem minmax(0, 1fr) minmax(7rem, 0.9fr);
grid-template-columns: 0.85rem minmax(0, 1fr) minmax(12rem, 1.4fr);
gap: 0.45rem;
align-items: center;
margin: 0;
font-size: 0.82rem;
}
.ace-row select {
min-width: 0;
width: 100%;
}
.ace-swatch {
width: 0.85rem;
height: 0.85rem;
@@ -509,6 +548,23 @@ h2 { font-size: 0.95rem; margin: 0 0 0.65rem; letter-spacing: -0.02em; font-weig
border-radius: var(--radius-sm);
min-height: var(--control-h);
}
.page-tools .date-field {
width: auto;
min-width: 9.5rem;
max-width: 11rem;
flex: 0 0 auto;
}
.page-tools .period-presets {
width: auto;
max-width: 100%;
flex: 1 1 100%;
grid-auto-columns: auto;
overflow-x: auto;
}
.page-tools .period-presets button {
padding: 0.4rem 0.55rem;
white-space: nowrap;
}
.lead, .tiny, .summary { color: var(--muted); }
.summary { margin: 0.3rem 0 0; }
.tiny { font-size: 0.8rem; margin: 0.2rem 0 0; line-height: 1.4; max-width: 52ch; }
@@ -615,12 +671,44 @@ article.machine.offline:hover { opacity: 1; }
.job-line {
display: flex;
align-items: center;
gap: 0.45rem;
gap: 0.55rem;
min-width: 0;
color: var(--muted);
font-size: var(--t-sm);
padding: 0.45rem 0.5rem;
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--line);
}
.job-thumb {
width: 4.5rem;
height: 4.5rem;
object-fit: contain;
border-radius: 9px;
border: 1px solid var(--line);
background: var(--card-2);
flex: none;
}
.job-thumb[hidden] { display: none; }
.job-ico { display: inline-flex; flex: none; color: var(--faint); }
.job-ico .ico { width: 1.1rem; height: 1.1rem; }
.job-line.has-thumb .job-ico { display: none; }
.job-meta {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 0.3rem;
align-items: flex-start;
}
.job-line .name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
color: var(--ink);
font-weight: 650;
}
.job-line .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; color: var(--ink); }
.job-line .swatches { padding: 0; min-height: 0; flex: none; }
.badge {
font-size: 0.65rem;
@@ -803,6 +891,11 @@ article.printer {
font-weight: 650;
border-bottom: 1px solid var(--line);
}
.camera-tile .cam-stage {
position: relative;
min-height: 140px;
background: var(--card-2);
}
.camera-tile img {
display: block;
width: 100%;
@@ -810,16 +903,22 @@ article.printer {
object-fit: contain;
background: var(--card-2);
}
.camera-tile img.broken { min-height: 120px; opacity: 0.35; }
.cam-url {
display: block;
padding: 0.4rem 0.75rem 0.6rem;
font-size: 0.72rem;
color: var(--faint);
word-break: break-all;
text-decoration: none;
.camera-tile .cam-empty {
margin: 0;
min-height: 140px;
display: grid;
place-content: center;
justify-items: center;
gap: 0.35rem;
color: var(--muted);
font-size: 0.85rem;
}
.camera-tile .cam-empty .ico { width: 1.6rem; height: 1.6rem; opacity: 0.55; }
.camera-tile .cam-open {
display: inline-block;
margin: 0.45rem 0.75rem 0.65rem;
font-size: 0.82rem;
}
.cam-url:hover { color: var(--accent); }
.card {
background: var(--card);
@@ -937,6 +1036,60 @@ tr.pick:hover td { background: rgba(255, 255, 255, 0.04); }
tr.on td { background: var(--accent-soft); }
td select { width: auto; min-width: 8.5rem; padding: 0.3rem 0.45rem; min-height: 1.9rem; border-radius: 7px; }
.thumb { width: 64px; height: 64px; object-fit: contain; background: var(--card-2); border-radius: 12px; border: 1px solid var(--line); }
.auth-body {
min-height: 100vh;
display: grid;
place-items: center;
padding: 1.5rem;
background:
radial-gradient(ellipse at 20% 0%, rgba(61, 206, 124, 0.12), transparent 45%),
var(--bg);
}
.auth-card {
width: min(22rem, 100%);
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1.4rem 1.35rem 1.5rem;
}
.auth-card h1 { margin: 0 0 0.35rem; font-size: 1.45rem; }
.auth-brand {
display: flex;
align-items: center;
gap: 0.55rem;
}
.auth-brand img {
width: 2.25rem;
height: 2.25rem;
border-radius: 99px;
object-fit: contain;
}
.auth-quote {
margin: 0.55rem 0 0;
padding: 0;
border: 0;
}
.auth-quote blockquote {
margin: 0;
font-size: 0.82rem;
line-height: 1.45;
color: var(--muted);
font-style: italic;
}
.auth-quote figcaption {
margin-top: 0.35rem;
font-size: 0.7rem;
color: var(--faint);
font-style: normal;
}
.auth-card .stack { display: grid; gap: 0.75rem; margin-top: 1rem; }
.side-user {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.35rem;
}
.empty { color: var(--muted); padding: 1rem 0.3rem; }
.empty-state {
text-align: center;
@@ -1804,7 +1957,6 @@ body.desk-on main {
.summary-text b { color: var(--ink); }
.page-head-side { display: flex; align-items: center; gap: 0.75rem 1rem; flex-wrap: wrap; }
.page-head-side .check { margin: 0; font-size: var(--t-sm); color: var(--muted); }
.job-line > .ico { color: var(--faint); }
.job-line[hidden] { display: none; }
article.machine.flash { animation: flash 2s var(--ease); }
@keyframes flash { 0%, 40% { box-shadow: 0 0 0 2px var(--accent), var(--shadow); } }
@@ -2092,6 +2244,17 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); }
.prose table { margin: 0.8rem 0; font-size: var(--t-sm); }
.prose blockquote { margin: 0.8rem 0; padding: 0.4rem 0.9rem; border-left: 3px solid var(--accent); background: var(--accent-soft); border-radius: 0 10px 10px 0; }
.prose .lede { font-size: 1.05rem; color: var(--ink); }
.prose img {
display: block;
max-width: 100%;
height: auto;
margin: 1rem 0;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--card-2);
}
.prose .doc-chapters { columns: 2; column-gap: 1.5rem; }
.prose .doc-chapters li { break-inside: avoid; }
.flow-diagram { display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; margin: 0.8rem 0 1rem; }
.flow-diagram .step { font-size: var(--t-xs); padding: 0.3rem 0.65rem; }
.terms { display: grid; grid-template-columns: max-content 1fr; gap: 0.45rem 1rem; margin: 0.6rem 0; }
@@ -2193,3 +2356,70 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); }
.nav-secondary a { padding: 0.4rem; }
.nav-main a.on { box-shadow: none; border-radius: 9px; padding-left: 0.55rem; }
}
/* Druckbericht & Statistik */
.bericht { display: grid; gap: 0.85rem; }
.bericht-head { display: grid; gap: 0.5rem; }
.bericht-meta p { margin: 0.2rem 0; }
.temp-chart {
width: 100%;
max-width: 40rem;
height: auto;
background: #0c0c10;
border-radius: 10px;
border: 1px solid var(--line);
}
.chart-label { fill: var(--muted); font-size: 11px; }
.chart-legend { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.35rem; }
.chart-legend .leg::before {
content: "";
display: inline-block;
width: 0.65rem;
height: 0.65rem;
border-radius: 50%;
margin-right: 0.3rem;
vertical-align: -0.05rem;
}
.chart-legend .nozzle::before { background: #ea580c; }
.chart-legend .bed::before { background: #2563eb; }
.chart-legend .chamber::before { background: #16a34a; }
.tag {
display: inline-block;
font-size: 0.72rem;
font-weight: 650;
padding: 0.05rem 0.35rem;
border-radius: 6px;
background: var(--accent-soft);
color: var(--accent);
}
.kpi-row {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
gap: 0.65rem;
margin-bottom: 0.85rem;
}
.kpi {
background: var(--card);
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.7rem 0.8rem;
}
.kpi-val {
font-size: 1.25rem;
font-weight: 750;
letter-spacing: -0.02em;
}
.kpi-label { color: var(--muted); font-size: 0.78rem; font-weight: 650; }
.spool-akte {
margin-top: 0.85rem;
padding-top: 0.7rem;
border-top: 1px solid var(--line);
}
.spool-akte h3 { margin: 0 0 0.35rem; font-size: 0.95rem; }
ul.plain { list-style: none; margin: 0; padding: 0; }
ul.plain li { padding: 0.2rem 0; border-bottom: 1px solid var(--line); }
ul.plain li:last-child { border-bottom: 0; }
.grid-2 { display: grid; gap: 0.85rem; }
@media (min-width: 800px) {
.grid-2 { grid-template-columns: 1fr 1fr; }
}
+7
View File
@@ -0,0 +1,7 @@
/**
* Skipped minification because the original files appears to be already minified.
* Original file: /npm/qrcode@1.5.1/build/qrcode.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
var QRCode=function(t){"use strict";var r,e=function(){return"function"==typeof Promise&&Promise.prototype&&Promise.prototype.then},n=[0,26,44,70,100,134,172,196,242,292,346,404,466,532,581,655,733,815,901,991,1085,1156,1258,1364,1474,1588,1706,1828,1921,2051,2185,2323,2465,2611,2761,2876,3034,3196,3362,3532,3706],o=function(t){if(!t)throw new Error('"version" cannot be null or undefined');if(t<1||t>40)throw new Error('"version" should be in range from 1 to 40');return 4*t+17},a=function(t){return n[t]},i=function(t){for(var r=0;0!==t;)r++,t>>>=1;return r},u=function(t){if("function"!=typeof t)throw new Error('"toSJISFunc" is not a valid function.');r=t},s=function(){return void 0!==r},f=function(t){return r(t)};function h(t,r){return t(r={exports:{}},r.exports),r.exports}var c=h((function(t,r){r.L={bit:1},r.M={bit:0},r.Q={bit:3},r.H={bit:2},r.isValid=function(t){return t&&void 0!==t.bit&&t.bit>=0&&t.bit<4},r.from=function(t,e){if(r.isValid(t))return t;try{return function(t){if("string"!=typeof t)throw new Error("Param is not a string");switch(t.toLowerCase()){case"l":case"low":return r.L;case"m":case"medium":return r.M;case"q":case"quartile":return r.Q;case"h":case"high":return r.H;default:throw new Error("Unknown EC Level: "+t)}}(t)}catch(t){return e}}}));function g(){this.buffer=[],this.length=0}c.L,c.M,c.Q,c.H,c.isValid,g.prototype={get:function(t){var r=Math.floor(t/8);return 1==(this.buffer[r]>>>7-t%8&1)},put:function(t,r){for(var e=0;e<r;e++)this.putBit(1==(t>>>r-e-1&1))},getLengthInBits:function(){return this.length},putBit:function(t){var r=Math.floor(this.length/8);this.buffer.length<=r&&this.buffer.push(0),t&&(this.buffer[r]|=128>>>this.length%8),this.length++}};var d=g;function l(t){if(!t||t<1)throw new Error("BitMatrix size must be defined and greater than 0");this.size=t,this.data=new Uint8Array(t*t),this.reservedBit=new Uint8Array(t*t)}l.prototype.set=function(t,r,e,n){var o=t*this.size+r;this.data[o]=e,n&&(this.reservedBit[o]=!0)},l.prototype.get=function(t,r){return this.data[t*this.size+r]},l.prototype.xor=function(t,r,e){this.data[t*this.size+r]^=e},l.prototype.isReserved=function(t,r){return this.reservedBit[t*this.size+r]};var v=l,p=h((function(t,r){var e=o;r.getRowColCoords=function(t){if(1===t)return[];for(var r=Math.floor(t/7)+2,n=e(t),o=145===n?26:2*Math.ceil((n-13)/(2*r-2)),a=[n-7],i=1;i<r-1;i++)a[i]=a[i-1]-o;return a.push(6),a.reverse()},r.getPositions=function(t){for(var e=[],n=r.getRowColCoords(t),o=n.length,a=0;a<o;a++)for(var i=0;i<o;i++)0===a&&0===i||0===a&&i===o-1||a===o-1&&0===i||e.push([n[a],n[i]]);return e}}));p.getRowColCoords,p.getPositions;var w=o,m=function(t){var r=w(t);return[[0,0],[r-7,0],[0,r-7]]},E=h((function(t,r){r.Patterns={PATTERN000:0,PATTERN001:1,PATTERN010:2,PATTERN011:3,PATTERN100:4,PATTERN101:5,PATTERN110:6,PATTERN111:7};var e=3,n=3,o=40,a=10;function i(t,e,n){switch(t){case r.Patterns.PATTERN000:return(e+n)%2==0;case r.Patterns.PATTERN001:return e%2==0;case r.Patterns.PATTERN010:return n%3==0;case r.Patterns.PATTERN011:return(e+n)%3==0;case r.Patterns.PATTERN100:return(Math.floor(e/2)+Math.floor(n/3))%2==0;case r.Patterns.PATTERN101:return e*n%2+e*n%3==0;case r.Patterns.PATTERN110:return(e*n%2+e*n%3)%2==0;case r.Patterns.PATTERN111:return(e*n%3+(e+n)%2)%2==0;default:throw new Error("bad maskPattern:"+t)}}r.isValid=function(t){return null!=t&&""!==t&&!isNaN(t)&&t>=0&&t<=7},r.from=function(t){return r.isValid(t)?parseInt(t,10):void 0},r.getPenaltyN1=function(t){for(var r=t.size,n=0,o=0,a=0,i=null,u=null,s=0;s<r;s++){o=a=0,i=u=null;for(var f=0;f<r;f++){var h=t.get(s,f);h===i?o++:(o>=5&&(n+=e+(o-5)),i=h,o=1),(h=t.get(f,s))===u?a++:(a>=5&&(n+=e+(a-5)),u=h,a=1)}o>=5&&(n+=e+(o-5)),a>=5&&(n+=e+(a-5))}return n},r.getPenaltyN2=function(t){for(var r=t.size,e=0,o=0;o<r-1;o++)for(var a=0;a<r-1;a++){var i=t.get(o,a)+t.get(o,a+1)+t.get(o+1,a)+t.get(o+1,a+1);4!==i&&0!==i||e++}return e*n},r.getPenaltyN3=function(t){for(var r=t.size,e=0,n=0,a=0,i=0;i<r;i++){n=a=0;for(var u=0;u<r;u++)n=n<<1&2047|t.get(i,u),u>=10&&(1488===n||93===n)&&e++,a=a<<1&2047|t.get(u,i),u>=10&&(1488===a||93===a)&&e++}return e*o},r.getPenaltyN4=function(t){for(var r=0,e=t.data.length,n=0;n<e;n++)r+=t.data[n];return Math.abs(Math.ceil(100*r/e/5)-10)*a},r.applyMask=function(t,r){for(var e=r.size,n=0;n<e;n++)for(var o=0;o<e;o++)r.isReserved(o,n)||r.xor(o,n,i(t,o,n))},r.getBestMask=function(t,e){for(var n=Object.keys(r.Patterns).length,o=0,a=1/0,i=0;i<n;i++){e(i),r.applyMask(i,t);var u=r.getPenaltyN1(t)+r.getPenaltyN2(t)+r.getPenaltyN3(t)+r.getPenaltyN4(t);r.applyMask(i,t),u<a&&(a=u,o=i)}return o}}));E.Patterns,E.isValid,E.getPenaltyN1,E.getPenaltyN2,E.getPenaltyN3,E.getPenaltyN4,E.applyMask,E.getBestMask;var y=[1,1,1,1,1,1,1,1,1,1,2,2,1,2,2,4,1,2,4,4,2,4,4,4,2,4,6,5,2,4,6,6,2,5,8,8,4,5,8,8,4,5,8,11,4,8,10,11,4,9,12,16,4,9,16,16,6,10,12,18,6,10,17,16,6,11,16,19,6,13,18,21,7,14,21,25,8,16,20,25,8,17,23,25,9,17,23,34,9,18,25,30,10,20,27,32,12,21,29,35,12,23,34,37,12,25,34,40,13,26,35,42,14,28,38,45,15,29,Line truncated
+4 -7
View File
@@ -3,15 +3,12 @@
<p>Handbuch</p>
<a href="/doku#grotti" data-sec="grotti" class="{{ 'on' if current == 'start' else '' }}">Was Grotti ist</a>
<a href="/doku#druck" data-sec="druck">Ein Druck von A bis Z</a>
<a href="/doku#ablauf" data-sec="ablauf">Ablauf (Kobra 3)</a>
<a href="/doku#seiten" data-sec="seiten">Seiten</a>
<a href="/doku#kapitel" data-sec="kapitel">Anleitung</a>
<a href="/doku#haengt" data-sec="haengt">Wenn etwas hängt</a>
<a href="/doku#begriffe" data-sec="begriffe">Begriffe</a>
<p>Versionen</p>
<a href="/doku/changelog" class="{{ 'on' if current == 'changelog' else '' }}">Changelog · v{{ version }}</a>
<p>Technik</p>
{% for name in docs %}
<a href="/doku/{{ name }}" class="{{ 'on' if current == name else '' }}">{{ name | replace('-', ' ') }}</a>
<p>Anleitung</p>
{% for name in anleitung %}
<a href="/doku/{{ name }}" class="{{ 'on' if current == name else '' }}">{{ anleitung_title(name) }}</a>
{% endfor %}
</nav>
{% endmacro %}
+146
View File
@@ -0,0 +1,146 @@
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Druckbericht</h1>
<p class="tiny" id="bericht-sub">Lauf wird geladen …</p>
</div>
<div class="page-head-side">
<a class="ghost" href="/protokoll">← Protokoll</a>
<a class="ghost" id="etikett-druck" href="#" hidden>Etikett Druck</a>
<a class="ghost" id="etikett-objekt" href="#" hidden>Etiketten Objekte</a>
</div>
</div>
<p id="bericht-note"></p>
<div id="bericht" class="bericht"></div>
{% endblock %}
{% block script %}
<script>
const runId = Number(location.pathname.split("/").pop());
const states = {
completed: "fertig", cancelled: "abgebrochen", error: "Fehler",
in_progress: "druckt", printing: "druckt", paused: "pausiert", interrupted: "unterbrochen",
};
function sourceLabel(source, estimate) {
if (estimate) return "Schätzwert";
if (source === "measured") return "Messwert";
if (source === "estimate") return "Schätzwert";
return source || "";
}
function chart(samples) {
if (!samples || samples.length < 2) {
return '<p class="tiny">Kein Temperaturverlauf (nur für neue Drucke).</p>';
}
const w = 640, h = 180, pad = 28;
const temps = samples.flatMap((s) => [s.nozzle, s.bed, s.chamber, s.nozzle_target, s.bed_target].filter((v) => v != null));
const minT = Math.min(...temps, 0);
const maxT = Math.max(...temps, 100);
const x = (i) => pad + (i / (samples.length - 1)) * (w - pad * 2);
const y = (v) => h - pad - ((Number(v) - minT) / Math.max(1, maxT - minT)) * (h - pad * 2);
function path(key, color) {
const pts = samples.map((s, i) => s[key] == null ? null : `${x(i)},${y(s[key])}`).filter(Boolean);
if (pts.length < 2) return "";
return `<polyline fill="none" stroke="${color}" stroke-width="1.6" points="${pts.join(" ")}" />`;
}
return `<svg class="temp-chart" viewBox="0 0 ${w} ${h}" role="img" aria-label="Temperaturverlauf">
${path("nozzle_target", "#fdba74")}
${path("bed_target", "#93c5fd")}
${path("nozzle", "#ea580c")}
${path("bed", "#2563eb")}
${path("chamber", "#16a34a")}
<text x="${pad}" y="14" class="chart-label">${Math.round(maxT)} °C</text>
<text x="${pad}" y="${h - 8}" class="chart-label">${Math.round(minT)} °C</text>
</svg>
<p class="tiny chart-legend"><span class="leg nozzle">Düse</span> <span class="leg bed">Bett</span> <span class="leg chamber">Kammer</span> · gestrichelt ≈ Soll</p>`;
}
function render(data) {
const run = data.run;
const name = (run.filename || "").split("/").pop() || "ohne Datei";
document.getElementById("bericht-sub").textContent =
(run.printer_name || run.printer_host) + " · " + name
+ (run.chargen_nr ? " · " + run.chargen_nr : "");
const planned = Number(run.weight_planned_g) || 0;
const used = Number(run.weight_used_g) || 0;
const diff = Math.round(used) - Math.round(planned);
const cost = Number(run.filament_cost || 0) + Number(run.energy_cost || 0);
const spoolRows = (data.spools || []).map((s) => `
<tr>
<td>${escapeHtml(s.name)}${s.ace_slot ? " · ACE " + s.ace_slot : ""}</td>
<td>${escapeHtml(s.material_type || "")}</td>
<td class="num">${grams(s.weight_planned_g)}</td>
<td class="num">${grams(s.weight_g)}${s.is_estimate ? ' <span class="tag">Schätzwert</span>' : ""}</td>
<td class="num">${euro(s.cost)}</td>
<td class="nowrap">${escapeHtml(s.chargen_nr || "")}</td>
</tr>`).join("") || '<tr><td class="empty" colspan="6">Keine Spule zugeordnet.</td></tr>';
const events = (data.events || []).map((e) =>
`<li><span class="tiny">${escapeHtml((e.created_at || "").replace("T", " ").slice(0, 19))}</span> ${escapeHtml(e.message || e.kind)}</li>`
).join("") || "<li class=\"tiny\">Keine Ereignisse.</li>";
const labels = (data.labels || []).filter((l) => !l.excluded);
const labelBits = labels.length
? labels.map((l) => `<li id="objekt-${encodeURIComponent(l.object_name || "")}"><code>${escapeHtml(l.chargen_nr)}</code> ${escapeHtml(l.object_name || "Druck")}</li>`).join("")
: (run.chargen_nr ? `<li><code>${escapeHtml(run.chargen_nr)}</code> gesamter Druck</li>` : "<li class=\"tiny\">Noch keine Charge.</li>");
const segments = (data.segments || []).map((s) => {
const end = s.mm_end == null ? "…" : Math.round(s.mm_end);
return `<li>${escapeHtml(s.spool_name)} · ${Math.round(s.mm_start)}–${end} mm${s.ace_slot ? " · Platz " + s.ace_slot : ""}</li>`;
}).join("");
document.getElementById("bericht").innerHTML = `
<div class="card bericht-head">
<div class="bericht-meta">
<p><b>${escapeHtml(states[run.state] || run.state)}</b>${run.verdict ? " · Urteil " + escapeHtml(run.verdict) : ""}</p>
<p class="tiny">${escapeHtml(run.started_at || "—")} → ${escapeHtml(run.ended_at || "—")}</p>
<p>Dauer <b>${hours(run.duration_s)}</b>${run.estimated_s ? " · Soll " + hours(run.estimated_s) : ""}</p>
<p>Material <b>${grams(used)}</b> von ${grams(planned)}${planned ? " (" + (diff > 0 ? "+" : "") + diff + " g)" : ""}
${run.weight_source ? ' · <span class="tag">' + sourceLabel(run.weight_source) + "</span>" : ""}</p>
<p>Kosten <b>${euro(cost)}</b> (Filament ${euro(run.filament_cost)} · Strom ${euro(run.energy_cost)})</p>
${run.message ? "<p class=\"msg\">" + escapeHtml(run.message) + "</p>" : ""}
${data.project ? "<p class=\"tiny\">Projekt: " + escapeHtml(data.project.name) + "</p>" : ""}
</div>
</div>
<div class="card">
<h2>Spulen</h2>
<div class="table-wrap"><table>
<thead><tr><th>Spule</th><th>Sorte</th><th>Soll</th><th>Ist</th><th>Kosten</th><th>Charge</th></tr></thead>
<tbody>${spoolRows}</tbody>
</table></div>
${segments ? "<h3>Abschnitte</h3><ul class=\"plain\">" + segments + "</ul>" : ""}
</div>
<div class="card">
<h2>Verlauf</h2>
${chart(data.samples)}
</div>
<div class="card grid-2">
<div>
<h2>Chargen</h2>
<ul class="plain">${labelBits}</ul>
</div>
<div>
<h2>Ereignisse</h2>
<ul class="plain">${events}</ul>
</div>
</div>`;
const druck = document.getElementById("etikett-druck");
const objekt = document.getElementById("etikett-objekt");
if (run.chargen_nr) {
druck.hidden = false;
druck.href = "/etikett/lauf/" + run.id + "?mode=druck";
objekt.hidden = false;
objekt.href = "/etikett/lauf/" + run.id + "?mode=objekt&qty=1";
} else {
druck.hidden = true;
objekt.hidden = true;
}
}
api("/laeufe/" + runId).then(render).catch((error) => showNote("bericht-note", error));
</script>
{% endblock %}
+45 -9
View File
@@ -103,7 +103,7 @@
<select id="send-project"><option value="">keins</option></select>
<div id="ace-box" class="ace-box" hidden>
<h3>ACE-Zuordnung <span class="tip" title="Datei-Farbe auf ACE-Platz. Ohne Zuordnung startet der ACE oft nicht."><svg class="ico"><use href="#i-info"/></svg></span></h3>
<p class="tiny msg" id="ace-trailer-warn" hidden>Für Auto-Feed fehlen Slicer-Metadaten in der Datei.</p>
<p class="tiny" id="ace-trailer-warn" hidden>Mehrfarb ohne Slicer-Trailer — Grotti ergänzt `; referenced_tools` beim Senden.</p>
<div id="ace-maps"></div>
</div>
</div>
@@ -146,7 +146,7 @@
{% endblock %}
{% block script %}
<script src="{{ url_for('static', filename='three.min.js') }}?v=1"></script>
<script src="{{ url_for('static', filename='gcode-viewer.js') }}?v=8"></script>
<script src="{{ url_for('static', filename='gcode-viewer.js') }}?v=10"></script>
<script src="{{ url_for('static', filename='sweep-viewer.js') }}?v=1"></script>
<script>
const families = { kobra_s1: "Kobra S1", kobra_3: "Kobra 3" };
@@ -198,7 +198,7 @@ async function showToolpath(file, progress) {
if (viewerFileId !== file.id) {
meta.textContent = "Pfad wird gelesen …";
try {
const data = await api("/dateien/" + file.id + "/pfad");
const data = await api("/dateien/" + file.id + "/pfad?tp=8");
viewer.setPath(data);
viewerFileId = file.id;
meta.textContent = (data.count || 0).toLocaleString("de-DE") + " Punkte";
@@ -252,13 +252,41 @@ function loadSendMemory() {
/* ---------- ACE / Spule ---------- */
function slotLabel(slot) {
function spoolAtSlot(host, place) {
return spoolsOnHost(host).find((s) => Number(s.ace_slot) === Number(place)) || null;
}
function slotLabel(host, slot) {
const place = Number(slot.index) + 1;
const box = place <= 4 ? "ACE 1 · " + place : "ACE 2 · " + (place - 4);
const spool = spoolAtSlot(host, place);
if (spool) {
const bits = [box];
const kind = spool.material_type || slot.type || "";
if (kind) bits.push(kind);
if (spool.color) bits.push(spool.color);
if (spool.name) bits.push(spool.name);
bits.push(grams(spool.remaining_g));
return bits.join(" · ");
}
const kind = slot.type ? " · " + slot.type : "";
return box + kind;
}
function slotTitle(host, slot) {
const place = Number(slot.index) + 1;
const spool = spoolAtSlot(host, place);
if (!spool) return slot.type ? String(slot.type) : "";
const bits = [];
if (spool.brand) bits.push(spool.brand);
if (spool.name) bits.push(spool.name);
if (spool.material_type) bits.push(spool.material_type);
if (spool.color) bits.push(spool.color);
bits.push(grams(spool.remaining_g) + " Rest");
if (spool.chargen_nr) bits.push(spool.chargen_nr);
return bits.join(" · ");
}
function filamentLabel(filament, index) {
const kind = filament.type || "Filament";
const color = filament.color ? " · " + filament.color : "";
@@ -369,7 +397,10 @@ function renderAceMaps(force) {
const paints = filePaints(file);
box.hidden = false;
document.getElementById("options").open = true;
if (trailerWarn) trailerWarn.hidden = !!file.ace_trailer;
// Mono: Trailer fehlt oft und ist kein Fehler — Grotti setzt ihn beim Senden mit ACE-Map.
if (trailerWarn) {
trailerWarn.hidden = !!file.ace_trailer || paints.length <= 1;
}
root.innerHTML = "";
chosen.forEach((printer) => {
const block = document.createElement("div");
@@ -393,10 +424,15 @@ function renderAceMaps(force) {
printer.slots.forEach((slot) => {
const option = document.createElement("option");
option.value = String(slot.index);
option.textContent = slotLabel(slot);
if (slot.color) option.style.borderLeft = "4px solid " + slot.color;
// einfache Vorauswahl: gleiche Sorte, sonst Platz in Reihenfolge
if (!select.dataset.picked && filament.type && slot.type && filament.type.toLowerCase() === String(slot.type).toLowerCase()) {
option.textContent = slotLabel(printer.host, slot);
option.title = slotTitle(printer.host, slot);
const spool = spoolAtSlot(printer.host, Number(slot.index) + 1);
const hex = (spool && spool.colors && spool.colors[0] && spool.colors[0].hex) || slot.color || "";
if (hex) option.style.borderLeft = "4px solid " + hex;
const spoolType = (spool && spool.material_type) || slot.type || "";
// Vorauswahl: gleiche Sorte (Grotti-Spule bevorzugt), sonst Platz in Reihenfolge
if (!select.dataset.picked && filament.type && spoolType
&& filament.type.toLowerCase() === String(spoolType).toLowerCase()) {
option.selected = true;
select.dataset.picked = "1";
}
+13 -37
View File
@@ -6,8 +6,8 @@
<article class="prose">
<h1 id="grotti">Grotti <span class="badge">v{{ version }}</span></h1>
<p class="lede">Grotti steuert die Drucker im Haus von einer Seite aus: sehen, was läuft, Dateien schicken, Aufträge einreihen und nachlesen, was passiert ist.</p>
<p>Sie läuft auf diesem Rechner und spricht jeden Drucker über Moonraker an (Rinkhals auf Kobra 3 und Kobra S1). Daten liegen in PostgreSQL. Alles, was die Seiten zeigen, gibt es auch unter <code>/api/v1</code>.</p>
<blockquote>Sicherheit: Ein Druck startet nur nach ausdrücklicher Bestätigung. Es gibt keine freie G-code-Konsole und kein <code>FIRMWARE_RESTART</code>.</blockquote>
<p>Sie läuft auf diesem Rechner und spricht jeden Drucker an. Alles, was die Seiten zeigen, gibt es auch unter <code>/api/v1</code> — mit Login oder API-Token.</p>
<blockquote>Sicherheit: Ein Druck startet nur nach ausdrücklicher Bestätigung. Es gibt keine freie G-code-Konsole.</blockquote>
<h2 id="druck">Ein Druck von A bis Z</h2>
<div class="flow-diagram">
@@ -22,46 +22,28 @@
<li><strong>Drucker wählen.</strong> Die Kacheln zeigen live, was jeder Drucker gerade tut. Passt die Maschine nicht zur Datei, ist die Kachel blass.</li>
<li><strong>Wann.</strong> <em>Jetzt</em> startet sofort. <em>Wenn frei</em> reiht für diesen Drucker ein. <em>Nächster freier</em> lässt Grotti den ersten freien passenden Drucker nehmen.</li>
<li><strong>Starten.</strong> „Jetzt“ will zwei Klicks. Der zweite ist die Bestätigung.</li>
<li><strong>Während des Drucks</strong> zeigt die <a href="/">Wand</a> Fortschritt, Fertig-Uhrzeit und Temperaturen. Ein Klick auf die Karte öffnet das Cockpit mit Kamera, Steuerung, Bettnetz und 3D-Pfad. Die Seitenleiste zeigt immer, was läuft und was wartet.</li>
<li><strong>Während des Drucks</strong> zeigt die <a href="/">Wand</a> Fortschritt, Fertig-Uhrzeit und Temperaturen. Ein Klick auf die Karte öffnet das Cockpit mit Kamera (falls vorhanden), Steuerung, Bettnetz und 3D-Pfad. Die Seitenleiste zeigt immer, was läuft und was wartet.</li>
<li><strong>Danach</strong> ist die Platte belegt. Ein wartender Auftrag startet erst, wenn auf der Karte <em>Platte geräumt</em> gedrückt wurde, oder ein Ablauf sie selbst freischiebt.</li>
</ol>
<p>Ausführlich mit Screenshots: <a href="/doku/Anleitung-Dateien">Dateien senden</a>, <a href="/doku/Anleitung-Wand">Druckerwand</a>.</p>
<h2 id="ablauf">Ablauf (Kobra 3)</h2>
<p>Ein Ablauf ist eine Kette, die der Kobra 3 alleine abarbeitet und beliebig oft wiederholt:</p>
<div class="flow-diagram">
<span class="step">Drucken</span><span class="step-sep"></span>
<span class="step">Abkühlen</span><span class="step-sep"></span>
<span class="step">Schieben</span><span class="step-sep"></span>
<span class="step">Tasten</span><span class="step-sep"></span>
<span class="step">nochmal</span>
</div>
<ul>
<li><strong>Abkühlen</strong> wartet, bis Bett und Düse unter der Grenze sind. Danach löst sich PLA meist von selbst.</li>
<li><strong>Schieben</strong> fährt mit dem Extruder knapp über dem Bett von hinten nach vorn und schiebt das Teil in die Kiste.</li>
<li><strong>Tasten</strong> tippt mit aktivem LeviQ-Netz (ohne <code>BED_MESH_CLEAR</code>, ohne <code>G28 Z</code>). Tastpunkt einstellbar. Maß: <code>Result is z=</code> über der lokalen Platte. Über 1 mm stoppt den Ablauf.</li>
<h2 id="kapitel">Anleitung</h2>
<p>Schritt für Schritt mit Screenshots. Kurzfassung oben, Details in den Kapiteln.</p>
<ul class="doc-chapters">
{% for name in anleitung %}
<li><a href="/doku/{{ name }}">{{ anleitung_title(name) }}</a></li>
{% endfor %}
</ul>
<p>Einreihen unter <a href="/dateien?tab=ablauf">Dateien → Ablauf</a>. Der aktuelle Schritt steht auf der Druckerkarte und in der Seitenleiste. Abbrechen geht an beiden Stellen. Jeder Schritt landet unter <a href="/aktivitaet">Aktivität</a>.</p>
<h2 id="seiten">Seiten</h2>
<dl class="terms">
<dt><a href="/">Drucker</a></dt><dd>Die Wand. Eine Karte je Drucker mit Zustand, Fertig-Uhrzeit, Temperaturen und Ablauf-Schritten. Klick öffnet das Cockpit mit Kamera, Steuerung, Bettnetz und 3D-Pfad.</dd>
<dt><a href="/dateien">Dateien</a></dt><dd>Bibliothek und Senden. Spule und ACE-Plätze unter Optionen. Warnt, wenn das Filament nicht reicht.</dd>
<dt><a href="/kameras">Kameras</a></dt><dd>Alle Kamerabilder nebeneinander.</dd>
<dt><a href="/aktivitaet">Aktivität</a></dt><dd>Zeitstrahl: Starts, Schritte, Fertig, Fehler, Abbrüche, offline. Filter nach Drucker, Art und Zeitraum.</dd>
<dt><a href="/protokoll">Protokoll</a></dt><dd>Abgeschlossene Läufe mit Dauer, Gramm, Kosten und Urteil.</dd>
<dt><a href="/material">Material</a></dt><dd>Sorten, Spulen, Farben, ACE-Plätze, Etiketten.</dd>
<dt><a href="/projekte">Projekte</a></dt><dd>Aufträge mit Notiz und Kalkulation.</dd>
<dt><a href="/einstellungen">Einstellungen</a></dt><dd>Drucker, Telegram-Alerts, Energie.</dd>
</dl>
<h2 id="haengt">Wenn etwas hängt</h2>
<dl class="terms">
<dt>Auftrag wartet und startet nicht</dt><dd>Auf der Karte steht <em>Platte belegt</em>. Platte räumen, dann <em>Platte geräumt</em> drücken.</dd>
<dt>Drucker im Fehlerzustand</dt><dd>Auf der Karte <em>Fehler löschen</em>. Grotti räumt hängende Fehler vor einem Start selbst, wenn kein Druck aktiv ist.</dd>
<dt>Ablauf gestoppt</dt><dd>Die Seitenleiste zeigt den Grund. Ursache beheben, Ablauf neu einreihen. Unter <a href="/aktivitaet">Aktivität</a> steht, welcher Schritt zuletzt lief.</dd>
<dt>Ablauf gestoppt</dt><dd>Auf der Karte oder in der Seitenleiste Ursache beheben, dann <em>Weiter</em> (ab dem fehlgeschlagenen Schritt) oder Verwerfen. Unter <a href="/aktivitaet">Aktivität</a> steht, welcher Schritt zuletzt lief.</dd>
<dt>Drucker offline</dt><dd>Strom und WLAN prüfen. Grotti meldet, wenn er wieder da ist.</dd>
<dt>ACE startet nicht</dt><dd>Unter Optionen die ACE-Zuordnung prüfen. Ohne Zuordnung kommt oft „cannot parse“.</dd>
</dl>
<p>Mehr Fälle: <a href="/doku/Anleitung-Stoerungen">Störungen</a>.</p>
<h2 id="begriffe">Begriffe</h2>
<dl class="terms">
@@ -71,14 +53,8 @@
<dt>Platte belegt</dt><dd>Nach jedem Druck gesetzt. Blockiert den nächsten Auftrag auf diesem Drucker.</dd>
<dt>Alert</dt><dd>Wichtiges Ereignis (Fertig, Fehler, Abbruch, offline). Steht in der Seitenleiste und geht auf Wunsch an Telegram.</dd>
<dt>Ereignis</dt><dd>Jeder Eintrag unter Aktivität. Ablauf-Schritte und Starts sind Ereignisse, aber keine Alerts.</dd>
<dt>API-Token</dt><dd>Persönlicher Schlüssel für App, Home-Automation oder MCP — unter Einstellungen erzeugen.</dd>
</dl>
<h2>Technik</h2>
<p>Die Notizen zu Aufbau und Schnittstellen:</p>
<ul>
{% for name in docs %}<li><a href="/doku/{{ name }}">{{ name | replace('-', ' ') }}</a></li>{% endfor %}
</ul>
<p>Was sich je Version geändert hat, steht im <a href="/doku/changelog">Changelog</a>.</p>
</article>
</div>
{% endblock %}
+216 -26
View File
@@ -29,7 +29,7 @@
<p id="auto-note"></p>
</section>
<section class="card">
<section class="card admin-only">
<h2>Öffentliche Adresse</h2>
<p class="hint">FQDN bzw. Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host der aktuellen Anfrage (z.&nbsp;B. localhost).</p>
<div class="field" style="max-width:28rem">
@@ -41,7 +41,7 @@
<p id="fqdn-note"></p>
</section>
<section class="card">
<section class="card admin-only">
<h2>Drucker</h2>
<p class="hint">Gesperrt = kein neuer „wenn frei“-Start auf diesem Drucker.</p>
<div class="table-wrap">
@@ -72,7 +72,7 @@
<p id="printer-note"></p>
</section>
<section class="card">
<section class="card admin-only">
<h2>Telegram</h2>
<label class="check"><input type="checkbox" id="tg-enabled"> Benachrichtigungen aktiv</label>
<div class="grid-2" style="margin-top:0.75rem">
@@ -101,7 +101,7 @@
<p id="tg-note"></p>
</section>
<section class="card">
<section class="card admin-only">
<h2>Energie</h2>
<p class="hint">Für Kosten im Protokoll. Filament zählt erst mit zugeordneter Spule.</p>
<div class="grid-2">
@@ -115,10 +115,61 @@
<div class="actions"><button type="button" id="en-save">Speichern</button></div>
<p id="en-note"></p>
</section>
<section class="card admin-only">
<h2>Benutzer</h2>
<p class="hint">Rollen: <strong>Admin</strong> (alles) und <strong>Operator</strong> (Drucker, Queue, Material — keine Benutzerverwaltung).</p>
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>Rolle</th><th>Aktiv</th><th></th></tr></thead>
<tbody id="user-rows"></tbody>
</table>
</div>
<form id="user-add" class="grid-2" style="margin-top:1rem">
<div class="field"><label for="user-name">Benutzer</label><input id="user-name" required minlength="2" autocomplete="off"></div>
<div class="field"><label for="user-pass">Passwort</label><input id="user-pass" type="password" required minlength="8" autocomplete="new-password"></div>
<div class="field">
<label for="user-role">Rolle</label>
<select id="user-role"><option value="operator">Operator</option><option value="admin">Admin</option></select>
</div>
<div class="actions" style="align-self:end"><button type="submit">Anlegen</button></div>
</form>
<p id="user-note"></p>
</section>
<section class="card">
<h2>API-Tokens</h2>
<p class="hint">Für App, Home-Automation oder MCP. Klartext erscheint nur einmal bei der Erstellung. Authorization: <code>Bearer grt_…</code></p>
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>Prefix</th><th>Rolle</th><th></th></tr></thead>
<tbody id="token-rows"></tbody>
</table>
</div>
<form id="token-add" class="grid-2" style="margin-top:1rem">
<div class="field"><label for="token-name">Bezeichnung</label><input id="token-name" placeholder="Home Assistant" required></div>
<div class="field">
<label for="token-role">Rolle</label>
<select id="token-role"><option value="operator">Operator</option><option value="admin">Admin</option></select>
</div>
<div class="actions" style="align-self:end"><button type="submit">Token erzeugen</button></div>
</form>
<p id="token-note"></p>
<p class="ok" id="token-once" hidden></p>
<p class="hint" id="legacy-token-hint" hidden>In der Config steht noch ein Shared-Token — der gilt weiter als Admin. Besser auf Benutzer-Tokens umstellen und <code>token</code> in der Config leeren.</p>
</section>
{% endblock %}
{% block script %}
<script>
const families = { kobra_3: "Kobra 3", kobra_s1: "Kobra S1", "": "—" };
const isAdmin = document.querySelector('meta[name="user-role"]')?.content === "admin";
document.querySelectorAll(".admin-only").forEach((el) => { el.hidden = !isAdmin; });
if (!isAdmin) {
const roleSel = document.getElementById("token-role");
if (roleSel) {
[...roleSel.options].forEach((opt) => { if (opt.value === "admin") opt.remove(); });
}
}
/* showNote aus api.js: Inline-Hinweis + Toast */
@@ -181,36 +232,175 @@ function renderPrinters(rows) {
});
}
function renderUsers(rows) {
const body = document.getElementById("user-rows");
if (!body) return;
body.innerHTML = "";
rows.forEach((row) => {
const tr = document.createElement("tr");
tr.innerHTML = `<td>${escapeHtml(row.username)}</td>
<td><select data-f="role"><option value="operator">Operator</option><option value="admin">Admin</option></select></td>
<td><label class="check"><input type="checkbox" data-f="active"></label></td><td></td>`;
tr.querySelector('[data-f="role"]').value = row.role;
tr.querySelector('[data-f="active"]').checked = !!row.active;
const save = document.createElement("button");
save.type = "button";
save.textContent = "Speichern";
save.addEventListener("click", async () => {
try {
await api("/users/" + row.id, {
method: "PATCH",
json: {
role: tr.querySelector('[data-f="role"]').value,
active: tr.querySelector('[data-f="active"]').checked,
},
});
showNote("user-note", null, "Benutzer gespeichert.");
loadUsers();
} catch (error) {
showNote("user-note", error);
}
});
const del = document.createElement("button");
del.type = "button";
del.className = "ghost warn";
del.textContent = "Löschen";
del.addEventListener("click", () => {
armButton(del, "Wirklich löschen?", async () => {
try {
await api("/users/" + row.id, { method: "DELETE" });
showNote("user-note", null, "Benutzer gelöscht.");
loadUsers();
} catch (error) {
showNote("user-note", error);
}
});
});
tr.children[3].appendChild(save);
tr.children[3].appendChild(del);
body.appendChild(tr);
});
}
function renderTokens(rows) {
const body = document.getElementById("token-rows");
body.innerHTML = "";
if (!rows.length) {
body.innerHTML = '<tr><td class="empty" colspan="4">Keine Tokens.</td></tr>';
return;
}
rows.forEach((row) => {
const tr = document.createElement("tr");
tr.innerHTML = `<td>${escapeHtml(row.name)}${row.username ? ' <span class="tiny">(' + escapeHtml(row.username) + ")</span>" : ""}</td>
<td><code>${escapeHtml(row.prefix)}…</code></td>
<td>${escapeHtml(row.role)}</td><td></td>`;
const rev = document.createElement("button");
rev.type = "button";
rev.className = "ghost warn";
rev.textContent = "Widerrufen";
rev.addEventListener("click", () => {
armButton(rev, "Widerrufen?", async () => {
try {
await api("/tokens/" + row.id, { method: "DELETE" });
showNote("token-note", null, "Token widerrufen.");
loadTokens();
} catch (error) {
showNote("token-note", error);
}
});
});
tr.children[3].appendChild(rev);
body.appendChild(tr);
});
}
async function loadUsers() {
if (!isAdmin) return;
renderUsers(await api("/users"));
}
async function loadTokens() {
renderTokens(await api("/tokens" + (isAdmin ? "?all=1" : "")));
}
async function load() {
const [settings, printers] = await Promise.all([
api("/einstellungen"),
api("/drucker?live=0"),
]);
renderPrinters(printers);
if (isAdmin) renderPrinters(printers);
document.getElementById("auto-queue").checked = settings.auto_queue !== false;
document.getElementById("fqdn").value = settings.fqdn || "";
document.getElementById("tg-enabled").checked = !!(settings.telegram && settings.telegram.enabled);
document.getElementById("tg-token").value = "";
document.getElementById("tg-token").placeholder = settings.telegram && settings.telegram.bot_token_set
? ("gesetzt: " + (settings.telegram.bot_token || "••••"))
: "Bot-Token";
document.getElementById("tg-chat").value = (settings.telegram && settings.telegram.chat_id) || "";
const alerts = settings.alerts || {};
document.getElementById("al-fertig").checked = alerts.fertig !== false;
document.getElementById("al-fehler").checked = alerts.fehler !== false;
document.getElementById("al-offline").checked = alerts.offline !== false;
document.getElementById("al-queue").checked = alerts.queue !== false;
document.getElementById("al-ablauf").checked = !!alerts.ablauf;
document.getElementById("al-start").checked = !!alerts.start;
const en = settings.energy || {};
document.getElementById("en-watts").value = en.watts ?? 120;
document.getElementById("en-kwh").value = en.euro_per_kwh ?? 0.35;
document.getElementById("en-dryer").value = en.dryer_watts ?? 0;
document.getElementById("en-dryh").value = en.dry_hours ?? 0;
document.getElementById("en-waste").value = en.waste_percent ?? 0;
document.getElementById("en-util").value = en.utilization ?? 50;
const fqdn = document.getElementById("fqdn");
if (fqdn) fqdn.value = settings.fqdn || "";
const tgEnabled = document.getElementById("tg-enabled");
if (tgEnabled) {
tgEnabled.checked = !!(settings.telegram && settings.telegram.enabled);
document.getElementById("tg-token").value = "";
document.getElementById("tg-token").placeholder = settings.telegram && settings.telegram.bot_token_set
? ("gesetzt: " + (settings.telegram.bot_token || "••••"))
: "Bot-Token";
document.getElementById("tg-chat").value = (settings.telegram && settings.telegram.chat_id) || "";
const alerts = settings.alerts || {};
document.getElementById("al-fertig").checked = alerts.fertig !== false;
document.getElementById("al-fehler").checked = alerts.fehler !== false;
document.getElementById("al-offline").checked = alerts.offline !== false;
document.getElementById("al-queue").checked = alerts.queue !== false;
document.getElementById("al-ablauf").checked = !!alerts.ablauf;
document.getElementById("al-start").checked = !!alerts.start;
const en = settings.energy || {};
document.getElementById("en-watts").value = en.watts ?? 120;
document.getElementById("en-kwh").value = en.euro_per_kwh ?? 0.35;
document.getElementById("en-dryer").value = en.dryer_watts ?? 0;
document.getElementById("en-dryh").value = en.dry_hours ?? 0;
document.getElementById("en-waste").value = en.waste_percent ?? 0;
document.getElementById("en-util").value = en.utilization ?? 50;
}
const legacy = document.getElementById("legacy-token-hint");
if (legacy) legacy.hidden = !settings.legacy_token_set;
await Promise.all([loadUsers(), loadTokens()]);
}
document.getElementById("user-add")?.addEventListener("submit", async (event) => {
event.preventDefault();
try {
await api("/users", {
method: "POST",
json: {
username: document.getElementById("user-name").value,
password: document.getElementById("user-pass").value,
role: document.getElementById("user-role").value,
},
});
document.getElementById("user-add").reset();
showNote("user-note", null, "Benutzer angelegt.");
loadUsers();
} catch (error) {
showNote("user-note", error);
}
});
document.getElementById("token-add").addEventListener("submit", async (event) => {
event.preventDefault();
const once = document.getElementById("token-once");
once.hidden = true;
try {
const out = await api("/tokens", {
method: "POST",
json: {
name: document.getElementById("token-name").value,
role: document.getElementById("token-role").value,
},
});
document.getElementById("token-add").reset();
once.hidden = false;
once.textContent = "Token (nur jetzt sichtbar): " + out.token;
showNote("token-note", null, "Token erzeugt — jetzt kopieren.");
loadTokens();
} catch (error) {
showNote("token-note", error);
}
});
document.getElementById("auto-save").addEventListener("click", async () => {
try {
await api("/einstellungen", {
+27 -6
View File
@@ -41,14 +41,17 @@
border: 1px solid #ccc;
border-radius: 4px;
display: grid;
grid-template-columns: 1fr 22mm;
gap: 3mm;
grid-template-columns: 1fr 20mm;
gap: 2.5mm;
align-items: center;
overflow: hidden;
}
.meta { min-width: 0; }
.meta h1 {
margin: 0 0 2mm;
font-size: 4.2mm;
letter-spacing: -0.02em;
overflow-wrap: anywhere;
}
.meta p { margin: 0 0 1.2mm; font-size: 3.1mm; color: #333; }
.meta .code { font-weight: 700; font-variant-numeric: tabular-nums; }
@@ -61,7 +64,19 @@
margin-left: -1mm;
}
.dot:first-child { margin-left: 0; }
#qr { width: 20mm; height: 20mm; }
.qr-wrap {
width: 20mm;
height: 20mm;
overflow: hidden;
flex-shrink: 0;
}
.qr-wrap canvas {
display: block;
width: 20mm !important;
height: 20mm !important;
max-width: 100%;
max-height: 100%;
}
@media print {
body { background: #fff; }
.toolbar { display: none; }
@@ -99,14 +114,20 @@
<p>Hersteller: {{ spool.hersteller_charge }}</p>
{% endif %}
</div>
<canvas id="qr" width="120" height="120"></canvas>
<div class="qr-wrap">
<canvas id="qr" width="80" height="80"></canvas>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/qrcode.min.js"></script>
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
<script>
QRCode.toCanvas(document.getElementById("qr"), {{ qr_url|tojson }}, {
width: 120,
width: 80,
margin: 1,
color: { dark: "#111111", light: "#ffffff" }
}, () => {
const node = document.getElementById("qr");
node.style.width = "20mm";
node.style.height = "20mm";
});
</script>
</body>
+126
View File
@@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body {
margin: 0;
font: 14px/1.35 "Segoe UI", system-ui, sans-serif;
color: #111;
background: #f3f3f3;
}
.toolbar {
display: flex;
gap: 0.6rem;
padding: 0.8rem 1rem;
background: #1a1a1a;
color: #eee;
}
.toolbar a, .toolbar button {
color: #111;
background: #f97316;
border: 0;
border-radius: 8px;
padding: 0.4rem 0.85rem;
font: inherit;
font-weight: 650;
text-decoration: none;
cursor: pointer;
}
.toolbar a.ghost { background: transparent; color: #eee; border: 1px solid #555; }
.sheet {
width: 70mm;
min-height: 40mm;
margin: 1.2rem auto;
padding: 3.5mm;
background: #fff;
border: 1px solid #ccc;
border-radius: 4px;
display: grid;
grid-template-columns: 1fr 20mm;
gap: 2.5mm;
align-items: center;
overflow: hidden;
page-break-after: always;
}
.sheet:last-child { page-break-after: auto; }
.meta { min-width: 0; }
.meta h1 {
margin: 0 0 2mm;
font-size: 4.2mm;
letter-spacing: -0.02em;
overflow-wrap: anywhere;
}
.meta p { margin: 0 0 1.2mm; font-size: 3.1mm; color: #333; }
.meta .code { font-weight: 700; font-variant-numeric: tabular-nums; }
.qr-wrap {
width: 20mm;
height: 20mm;
overflow: hidden;
flex-shrink: 0;
}
.qr-wrap canvas,
.qr-wrap img {
display: block;
width: 20mm !important;
height: 20mm !important;
max-width: 100%;
max-height: 100%;
}
@media print {
body { background: #fff; }
.toolbar { display: none; }
.sheet {
margin: 0;
border: 0;
border-radius: 0;
page-break-inside: avoid;
}
}
</style>
</head>
<body>
<div class="toolbar">
<button type="button" onclick="window.print()">Drucken</button>
<a class="ghost" href="/protokoll/{{ run.id }}">Zurück</a>
</div>
{% for sheet in sheets %}
<div class="sheet">
<div class="meta">
<h1>{{ sheet.object_name or (run.filename.split('/')[-1] if run.filename else 'Druck') }}</h1>
<p class="code">{{ sheet.chargen_nr }}</p>
{% set soll = sheet.target_g if sheet.target_g is defined else target_g %}
{% if soll %}
<p>Ziel {{ '%.0f'|format(soll) }} g</p>
{% endif %}
<p>{{ run.printer_name }} · {{ (run.ended_at or run.started_at or '')[:10] }}</p>
{% for spool in spools %}
<p>{% if spool.material_type %}{{ spool.material_type }}{% endif %}{% if spool.brand %} · {{ spool.brand }}{% endif %}{% if spool.name %} · {{ spool.name }}{% endif %}{% if spool.chargen_nr %} · {{ spool.chargen_nr }}{% endif %}{% if spool.hersteller_charge %} · H: {{ spool.hersteller_charge }}{% endif %}</p>
{% else %}
<p class="code" style="font-weight:500;color:#666">kein Material zugeordnet</p>
{% endfor %}
</div>
<div class="qr-wrap">
<canvas class="qr" id="qr-{{ loop.index0 }}" width="80" height="80" data-url="{{ sheet.qr_url }}"></canvas>
</div>
</div>
{% endfor %}
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
<script>
document.querySelectorAll("canvas.qr").forEach((node) => {
QRCode.toCanvas(node, node.dataset.url, {
width: 80,
margin: 1,
color: { dark: "#111111", light: "#ffffff" }
}, () => {
node.style.width = "20mm";
node.style.height = "20mm";
});
});
</script>
</body>
</html>
+28 -24
View File
@@ -11,23 +11,12 @@
{% endblock %}
{% block script %}
<script>
const labels = {
printing: "druckt", paused: "pausiert", complete: "fertig", standby: "bereit",
heating: "heizt", error: "Fehler", cancelled: "abgebrochen", offline: "offline"
};
const tiles = new Map();
function shownState(printer) {
const state = printer.state || "offline";
const hot = (printer.nozzle_target || 0) > 40 || (printer.bed_target || 0) > 30;
if ((state === "standby" || state === "complete") && hot) return "heating";
return state;
}
function metaLine(printer) {
const state = shownState(printer);
const busy = state === "printing" || state === "paused";
const bits = [labels[state] || state];
const bits = [STATE_LABELS[state] || state];
if (busy) {
const pct = Math.round((printer.progress || 0) * 100);
bits.push(pct + " %");
@@ -52,8 +41,11 @@ function ensureTile(printer) {
<span class="host"></span>
</figcaption>
<p class="cam-meta tiny"></p>
<img alt="" data-role="cam">
<a class="cam-url" href="#"></a>`;
<div class="cam-stage">
<img alt="" data-role="cam" hidden>
<p class="cam-empty" data-role="cam-empty">${icon("camera")}<span>Keine Kamera</span></p>
</div>
<a class="ghost cam-open" href="/#${encodeURIComponent(printer.host)}">Cockpit</a>`;
tiles.set(printer.host, tile);
document.getElementById("grid").appendChild(tile);
return tile;
@@ -65,10 +57,10 @@ async function refresh() {
const grid = document.getElementById("grid");
if (!printers.length) {
tiles.clear();
grid.innerHTML = '<p class="empty">Keine Drucker eingetragen.</p>';
grid.innerHTML = `<div class="empty-state">${icon("camera", "lg")}<p>Noch keine Drucker.</p><a href="/einstellungen">Zu den Einstellungen</a></div>`;
return;
}
if (grid.querySelector(".empty")) grid.innerHTML = "";
if (grid.querySelector(".empty-state")) grid.innerHTML = "";
const seen = new Set();
for (const printer of printers) {
seen.add(printer.host);
@@ -78,16 +70,28 @@ async function refresh() {
tile.querySelector(".cam-name").textContent = printer.name;
tile.querySelector(".host").textContent = printer.host;
tile.querySelector(".cam-meta").textContent = metaLine(printer);
const path = "/api/v1/kamera?host=" + encodeURIComponent(printer.host);
const shown = "/api/v1/kamera?host=" + printer.host;
tile.querySelector(".cam-open").href = "/#" + encodeURIComponent(printer.host);
const img = tile.querySelector('[data-role="cam"]');
const empty = tile.querySelector('[data-role="cam-empty"]');
if (!printer.camera || printer.has_camera === false) {
img.removeAttribute("src");
img.hidden = true;
empty.hidden = false;
empty.innerHTML = icon("camera") + "<span>Keine Kamera</span>";
continue;
}
img.alt = "Kamera " + printer.name;
img.src = withToken(path) + "&t=" + Math.floor(Date.now() / 2000);
img.classList.remove("broken");
img.onerror = () => img.classList.add("broken");
const link = tile.querySelector(".cam-url");
link.href = withToken(path);
link.textContent = shown;
const next = withToken(printer.camera) + "&t=" + Math.floor(Date.now() / 2000);
img.onerror = () => {
img.hidden = true;
empty.hidden = false;
empty.innerHTML = icon("camera") + "<span>Keine Kamera</span>";
};
img.onload = () => {
img.hidden = false;
empty.hidden = true;
};
img.src = next;
}
for (const [host, tile] of [...tiles.entries()]) {
if (!seen.has(host)) {
+62 -10
View File
@@ -3,9 +3,13 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="token" content="{{ token }}">
<meta name="csrf" content="{{ csrf }}">
{% if current_user %}<meta name="user-role" content="{{ current_user.role }}">{% endif %}
<title>{{ title }} · Grotti</title>
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=36">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=45">
<script>
(function () {
try {
@@ -84,7 +88,10 @@
</svg>
<aside class="sidebar">
<div class="brand-row">
<a class="brand" href="/">Grotti</a>
<a class="brand" href="/" title="Grotti">
<img class="brand-mark" src="{{ url_for('static', filename='brand/logo.png') }}" alt="" width="28" height="28" decoding="async">
<span class="brand-name">Grotti</span>
</a>
<button type="button" class="icon-btn nav-toggle" id="nav-toggle" title="Menü einklappen" aria-label="Menü einklappen" aria-pressed="false"><svg class="ico"><use href="#i-panel"/></svg></button>
</div>
<nav class="nav-main" aria-label="Hauptmenü">
@@ -93,7 +100,8 @@
<a href="/kameras" class="{{ 'on' if page == 'kameras' else '' }}" title="Kameras"><svg class="ico"><use href="#i-camera"/></svg><span>Kameras</span></a>
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk" title="Aktivität"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}" title="Warteschlange"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
<a href="/protokoll" class="{{ 'on' if page == 'protokoll' else '' }} nav-desk" title="Protokoll"><svg class="ico"><use href="#i-list"/></svg><span>Protokoll</span></a>
<a href="/protokoll" class="{{ 'on' if page == 'protokoll' or page == 'bericht' else '' }} nav-desk" title="Protokoll"><svg class="ico"><use href="#i-list"/></svg><span>Protokoll</span></a>
<a href="/statistik" class="{{ 'on' if page == 'statistik' else '' }} nav-desk" title="Statistik"><svg class="ico"><use href="#i-activity"/></svg><span>Statistik</span></a>
<a href="/material" class="{{ 'on' if page == 'material' else '' }} nav-desk" title="Material"><svg class="ico"><use href="#i-spool"/></svg><span>Material</span></a>
<a href="/projekte" class="{{ 'on' if page == 'projekte' else '' }} nav-desk" title="Projekte"><svg class="ico"><use href="#i-folder"/></svg><span>Projekte</span></a>
</nav>
@@ -127,8 +135,14 @@
<a href="/doku" class="{{ 'on' if page == 'doku' else '' }}" title="Doku"><svg class="ico"><use href="#i-book"/></svg><span>Doku</span></a>
</nav>
<div class="side-foot">
{% if current_user %}
<div class="side-user">
<span class="tiny" title="{{ current_user.role }}">{{ current_user.username }}</span>
<button type="button" class="ghost tiny-btn" id="logout-btn">Abmelden</button>
</div>
{% endif %}
<span class="side-copy">© by <a href="mailto:[email protected]">[email protected]</a></span>
<a href="/doku/changelog" title="Changelog">v{{ version }}</a>
<a href="/doku" title="Doku">v{{ version }}</a>
</div>
</aside>
<div class="app-main">
@@ -143,8 +157,12 @@
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }} nav-shelf"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
</nav>
<script src="{{ url_for('static', filename='api.js') }}?v=23"></script>
<script src="{{ url_for('static', filename='api.js') }}?v=24"></script>
<script>
document.getElementById("logout-btn")?.addEventListener("click", async () => {
try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {}
location.href = "/login";
});
(function applyShellClasses() {
const shelf = document.documentElement.classList.contains("shelf");
const collapsed = document.documentElement.classList.contains("nav-collapsed");
@@ -272,6 +290,31 @@ function directItemHtml(p) {
}
let queueCache = [];
const lastLiveFilename = Object.create(null);
function setListHtml(el, html) {
if (!el) return;
if (el._html === html) return;
el._html = html;
el.innerHTML = html;
}
function stickyLiveFilename(host, filename, state) {
const next = filename || "";
if (next) {
lastLiveFilename[host] = next;
return next;
}
const prev = lastLiveFilename[host] || "";
if (prev && ["printing", "paused", "heating", "complete"].includes(String(state || "").toLowerCase())) {
return prev;
}
if (!["printing", "paused", "heating"].includes(String(state || "").toLowerCase())) {
delete lastLiveFilename[host];
}
return prev && ["complete"].includes(String(state || "").toLowerCase()) ? prev : "";
}
async function refreshPrintQueue() {
let jobs;
let active = [];
@@ -281,7 +324,10 @@ async function refreshPrintQueue() {
queueCache = jobs || [];
const running = queueCache.filter((job) => job.status === "running");
const queuedHosts = new Set(running.map((job) => job.host));
const direct = (active || []).filter((p) => !queuedHosts.has(p.host));
const direct = (active || []).filter((p) => !queuedHosts.has(p.host)).map((p) => ({
...p,
filename: stickyLiveFilename(p.host, p.filename, p.state),
}));
const waiting = queueCache.filter((job) => job.status === "waiting");
const errors = queueCache
.filter((job) => job.status === "error")
@@ -291,15 +337,21 @@ async function refreshPrintQueue() {
const runBox = document.getElementById("run-box");
runBox.hidden = !(running.length || direct.length);
document.getElementById("run-count").textContent = running.length + direct.length;
document.getElementById("run-list").innerHTML = running.map(runItemHtml).join("") + direct.map(directItemHtml).join("");
setListHtml(
document.getElementById("run-list"),
running.map(runItemHtml).join("") + direct.map(directItemHtml).join(""),
);
const waitList = document.getElementById("wait-list");
document.getElementById("wait-count").textContent = waiting.length;
document.getElementById("wait-count").className = "count" + (errors.length ? " bad" : waiting.length ? " hot" : "");
document.getElementById("wait-empty").hidden = !!(waiting.length || errors.length);
document.getElementById("wait-box").classList.toggle("is-empty", !(waiting.length || errors.length));
waitList.innerHTML = errors.map((job) => waitItemHtml(job, 0, 1)).join("")
+ waiting.map((job, i) => waitItemHtml(job, i, waiting.length)).join("");
setListHtml(
waitList,
errors.map((job) => waitItemHtml(job, 0, 1)).join("")
+ waiting.map((job, i) => waitItemHtml(job, i, waiting.length)).join(""),
);
const panel = document.getElementById("live-panel");
panel.querySelectorAll("[data-cancel-q]").forEach((btn) => {
+76
View File
@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf" content="{{ csrf }}">
<title>Anmelden · Grotti</title>
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=40">
</head>
<body class="auth-body">
<main class="auth-card">
<h1 class="auth-brand"><img src="{{ url_for('static', filename='brand/logo.png') }}" alt="" width="40" height="40" decoding="async"> Grotti</h1>
<figure class="auth-quote" id="auth-quote" hidden>
<blockquote id="auth-quote-text"></blockquote>
<figcaption id="auth-quote-by"></figcaption>
</figure>
<form id="login-form" class="stack">
<div class="field">
<label for="username">Benutzer</label>
<input id="username" name="username" autocomplete="username" required autofocus>
</div>
<div class="field">
<label for="password">Passwort</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
</div>
<button type="submit">Anmelden</button>
<p class="msg" id="note" hidden></p>
</form>
</main>
<script>
(function () {
const quotes = [
{ t: "Wissenschaftler entdecken die Welt, die ist; Ingenieure schaffen die Welt, die noch nie war.", a: "Theodore von Kármán" },
{ t: "Perfektion ist nicht dann erreicht, wenn man nichts mehr hinzufügen, sondern wenn man nichts mehr weglassen kann.", a: "Antoine de Saint-Exupéry" },
{ t: "Wenn man es nicht messen kann, kann man es nicht verbessern.", a: "Lord Kelvin" },
{ t: "Einfachheit ist die höchste Form der Raffinesse.", a: "Leonardo da Vinci" },
{ t: "Es gibt nichts Praktischeres als eine gute Theorie.", a: "Kurt Lewin" },
{ t: "Gott steckt im Detail.", a: "Ludwig Mies van der Rohe" },
{ t: "Erst das Problem lösen. Dann den Code schreiben.", a: "John Johnson" },
{ t: "Qualität heißt, es von Anfang an richtig zu machen.", a: "W. Edwards Deming" },
{ t: "Wer misst, misst Mist — wer nicht misst, steuert blind.", a: "Werkstattweisheit" },
{ t: "Simplicity is a great virtue, but it requires hard work to achieve it.", a: "Edsger W. Dijkstra" },
];
const pick = quotes[Math.floor(Date.now() / 86400000) % quotes.length];
document.getElementById("auth-quote-text").textContent = pick.t;
document.getElementById("auth-quote-by").textContent = "— " + pick.a;
document.getElementById("auth-quote").hidden = false;
})();
document.getElementById("login-form").addEventListener("submit", async (event) => {
event.preventDefault();
const note = document.getElementById("note");
note.hidden = true;
try {
const response = await fetch("/api/v1/auth/login", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: document.getElementById("username").value,
password: document.getElementById("password").value,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || response.statusText);
const next = new URLSearchParams(location.search).get("next") || "/";
location.href = next.startsWith("/") ? next : "/";
} catch (error) {
note.hidden = false;
note.textContent = error.message || String(error);
note.className = "msg";
}
});
</script>
</body>
</html>
+58 -1
View File
@@ -120,6 +120,11 @@
<button type="button" class="warn" id="spool-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
</div>
</form>
<div id="spool-akte" class="spool-akte" hidden>
<h3>Akte</h3>
<p class="tiny" id="akte-summary"></p>
<ul class="plain" id="akte-runs"></ul>
</div>
<p id="spool-note"></p>
</section>
</div>
@@ -424,6 +429,49 @@ function payload() {
};
}
async function loadAkte(spoolId) {
const box = document.getElementById("spool-akte");
if (!spoolId) {
box.hidden = true;
return;
}
try {
const data = await api("/spulen/" + spoolId + "/akte");
const stats = data.stats || {};
const fillPct = Math.round(Number(stats.fill_pct) || 0);
let leer = "—";
if (stats.empty_in_days == null) leer = "kein Verbrauch";
else if (stats.empty_in_days <= 0) leer = "leer";
else leer = "noch ~" + Math.round(stats.empty_in_days) + " Tage";
document.getElementById("akte-summary").textContent =
"Verdruckt " + grams(stats.total_g)
+ " · Ausschuss " + grams(stats.scrap_g)
+ " · " + euro(stats.total_cost)
+ " · Füllstand " + fillPct + " % · Leer-in: " + leer;
const list = document.getElementById("akte-runs");
list.innerHTML = "";
(data.runs || []).slice(0, 12).forEach((run) => {
const li = document.createElement("li");
const a = document.createElement("a");
a.href = "/protokoll/" + run.id;
a.textContent = (run.filename || "").split("/").pop() || ("#" + run.id);
li.appendChild(a);
li.appendChild(document.createTextNode(
" · " + grams(run.spool_weight_g)
+ (run.spool_is_estimate ? " ≈" : "")
+ " · " + (run.verdict || run.state || "")
));
list.appendChild(li);
});
if (!(data.runs || []).length) {
list.innerHTML = '<li class="tiny">Noch kein Druck auf dieser Spule.</li>';
}
box.hidden = false;
} catch (error) {
box.hidden = true;
}
}
function fill(spool) {
openSpoolPanel();
document.getElementById("spool-id").value = spool ? spool.id : "";
@@ -451,6 +499,7 @@ function fill(spool) {
showHint();
renderPalette();
renderSpools();
loadAkte(spool ? spool.id : null);
document.getElementById("spool-panel").scrollIntoView({ block: "nearest", behavior: "smooth" });
}
@@ -580,6 +629,7 @@ function renderSpools() {
const rest = Number(spool.remaining_g) || 0;
tr.children[5].textContent = grams(rest);
if (rest < 100) tr.children[5].classList.add("msg");
if (rest < 0) tr.children[5].title = "Überzogen — Rest korrigieren";
tr.addEventListener("click", () => fill(spool));
body.appendChild(tr);
});
@@ -806,7 +856,14 @@ async function openByCode(raw) {
const match = code.match(/[?&]spool=(\d+)/);
if (match) spool = spools.find((row) => String(row.id) === match[1]) || null;
}
if (!spool) spool = await api("/spulen/by-code/" + encodeURIComponent(code));
if (!spool) {
const found = await api("/spulen/by-code/" + encodeURIComponent(code));
if (found && found.kind === "lauf" && found.redirect) {
location.href = found.redirect;
return;
}
spool = found;
}
setView("spools");
fill(spool);
setScanNote("Spule " + (spool.chargen_nr || spool.name) + " geöffnet.", true);
+22 -3
View File
@@ -24,7 +24,7 @@
<p id="run-note"></p>
<div class="card table-wrap">
<table>
<thead><tr><th>Drucker</th><th>Datei</th><th>Zustand</th><th>Dauer</th><th>Gramm</th><th>Kosten</th><th>Urteil</th><th>Spule</th><th></th></tr></thead>
<thead><tr><th>Drucker</th><th>Datei</th><th>Zustand</th><th>Dauer</th><th>Ist</th><th>Kosten</th><th>Urteil</th><th>Spule</th><th></th></tr></thead>
<tbody id="run-rows"></tbody>
</table>
</div>
@@ -80,12 +80,31 @@ function render() {
}
shown.forEach((run) => {
const tr = document.createElement("tr");
tr.className = "pick";
tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>";
tr.children[0].textContent = run.printer_name;
tr.children[1].textContent = (run.filename || "").split("/").pop();
const fileCell = tr.children[1];
const link = document.createElement("a");
link.href = "/protokoll/" + run.id;
link.textContent = (run.filename || "").split("/").pop() || ("#" + run.id);
fileCell.appendChild(link);
if (run.chargen_nr) {
const code = document.createElement("div");
code.className = "tiny";
code.textContent = run.chargen_nr;
fileCell.appendChild(code);
}
tr.children[2].textContent = states[run.state] || run.state;
tr.children[3].textContent = hours(run.duration_s);
tr.children[4].textContent = run.weight_g ? grams(run.weight_g) : "";
const used = Number(run.weight_used_g) || Number(run.weight_g) || 0;
const planned = Number(run.weight_planned_g) || Number(run.weight_g) || 0;
tr.children[4].textContent = used ? grams(used) : "";
if (used && planned && Math.abs(used - planned) > 0.5) {
tr.children[4].title = "Soll " + grams(planned);
}
if (run.weight_source === "estimate" || (run.weight_source === "measured" && false)) {
tr.children[4].textContent += run.weight_source === "estimate" ? " ≈" : "";
}
const cost = Number(run.filament_cost || 0) + Number(run.energy_cost || 0);
tr.children[5].textContent = cost ? euro(cost) : "";
const verdict = document.createElement("select");
+65
View File
@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf" content="{{ csrf }}">
<title>Setup · Grotti</title>
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=40">
</head>
<body class="auth-body">
<main class="auth-card">
<h1 class="auth-brand"><img src="{{ url_for('static', filename='brand/logo.png') }}" alt="" width="40" height="40" decoding="async"> Grotti einrichten</h1>
<p class="tiny">Lege den ersten Administrator an. Danach ist Login Pflicht.</p>
<form id="setup-form" class="stack">
<div class="field">
<label for="username">Admin-Benutzer</label>
<input id="username" name="username" autocomplete="username" required autofocus minlength="2">
</div>
<div class="field">
<label for="password">Passwort</label>
<input id="password" name="password" type="password" autocomplete="new-password" required minlength="8">
</div>
<div class="field">
<label for="password2">Passwort wiederholen</label>
<input id="password2" name="password2" type="password" autocomplete="new-password" required minlength="8">
</div>
<button type="submit">Admin anlegen</button>
<p class="msg" id="note" hidden></p>
</form>
</main>
<script>
document.getElementById("setup-form").addEventListener("submit", async (event) => {
event.preventDefault();
const note = document.getElementById("note");
note.hidden = true;
const password = document.getElementById("password").value;
if (password !== document.getElementById("password2").value) {
note.hidden = false;
note.textContent = "Passwörter stimmen nicht überein.";
note.className = "msg";
return;
}
try {
const response = await fetch("/api/v1/auth/setup", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: document.getElementById("username").value,
password,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || response.statusText);
location.href = "/";
} catch (error) {
note.hidden = false;
note.textContent = error.message || String(error);
note.className = "msg";
}
});
</script>
</body>
</html>
+214
View File
@@ -0,0 +1,214 @@
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Statistik</h1>
<p class="tiny">Auslastung, Verbrauch und Erfolgsquote aus dem Protokoll.</p>
</div>
<div class="page-head-side">
<a class="ghost" id="csv-link" href="/api/v1/statistik.csv">CSV</a>
</div>
</div>
<div class="page-tools">
<div class="segmented period-presets" id="period" role="group" aria-label="Zeitraum">
<button type="button" data-period="all" class="on">Alles</button>
<button type="button" data-period="day">Tag</button>
<button type="button" data-period="week">Woche</button>
<button type="button" data-period="month">Monat</button>
<button type="button" data-period="yesterday">Gestern</button>
<button type="button" data-period="last_week">Letzte Woche</button>
<button type="button" data-period="last_month">Letzter Monat</button>
</div>
<input type="date" id="since" title="Von" class="date-field">
<input type="date" id="until" title="Bis" class="date-field">
<select id="host"><option value="">Alle Drucker</option></select>
<input type="text" id="material" placeholder="Sorte" size="10">
<input type="text" id="brand" placeholder="Marke" size="10">
<button type="button" id="reload">Aktualisieren</button>
</div>
<p id="stat-note"></p>
<div id="stat" class="statistik"></div>
{% endblock %}
{% block script %}
<script>
function kpi(label, value, hint) {
return `<div class="kpi"><div class="kpi-val">${value}</div><div class="kpi-label">${escapeHtml(label)}</div>${hint ? '<div class="tiny">' + escapeHtml(hint) + "</div>" : ""}</div>`;
}
function isoDate(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return y + "-" + m + "-" + d;
}
function startOfWeek(date) {
const day = date.getDay();
const mondayOffset = day === 0 ? -6 : 1 - day;
const start = new Date(date.getFullYear(), date.getMonth(), date.getDate() + mondayOffset);
return start;
}
function periodRange(key) {
const today = new Date();
today.setHours(0, 0, 0, 0);
if (key === "all") return { since: "", until: "" };
if (key === "day") return { since: isoDate(today), until: isoDate(today) };
if (key === "yesterday") {
const day = new Date(today);
day.setDate(day.getDate() - 1);
return { since: isoDate(day), until: isoDate(day) };
}
if (key === "week") {
const start = startOfWeek(today);
const end = new Date(start);
end.setDate(end.getDate() + 6);
return { since: isoDate(start), until: isoDate(end) };
}
if (key === "last_week") {
const start = startOfWeek(today);
start.setDate(start.getDate() - 7);
const end = new Date(start);
end.setDate(end.getDate() + 6);
return { since: isoDate(start), until: isoDate(end) };
}
if (key === "month") {
const start = new Date(today.getFullYear(), today.getMonth(), 1);
const end = new Date(today.getFullYear(), today.getMonth() + 1, 0);
return { since: isoDate(start), until: isoDate(end) };
}
if (key === "last_month") {
const start = new Date(today.getFullYear(), today.getMonth() - 1, 1);
const end = new Date(today.getFullYear(), today.getMonth(), 0);
return { since: isoDate(start), until: isoDate(end) };
}
return { since: "", until: "" };
}
function setPeriodActive(key) {
document.querySelectorAll("#period [data-period]").forEach((btn) => {
btn.classList.toggle("on", btn.dataset.period === key);
});
}
function clearPeriodActive() {
document.querySelectorAll("#period [data-period]").forEach((btn) => btn.classList.remove("on"));
}
function fillPrinters(rows) {
const select = document.getElementById("host");
const current = select.value;
const known = new Set([...select.options].map((o) => o.value));
(rows || []).forEach((row) => {
if (!row.printer_host || known.has(row.printer_host)) return;
const opt = document.createElement("option");
opt.value = row.printer_host;
opt.textContent = row.printer_name || row.printer_host;
select.appendChild(opt);
known.add(row.printer_host);
});
if ([...select.options].some((o) => o.value === current)) select.value = current;
}
function render(data) {
fillPrinters(data.by_printer);
const o = data.overview || {};
const jobs = o.jobs || 0;
const success = jobs ? Math.round(100 * (o.completed || 0) / jobs) : 0;
const scrap = jobs ? Math.round(100 * (o.scrap || 0) / jobs) : 0;
const ratio = data.estimate_ratio != null
? (data.estimate_ratio * 100).toFixed(0) + " %"
: "—";
const printers = (data.by_printer || []).map((p) => `
<tr>
<td>${escapeHtml(p.printer_name || p.printer_host)}</td>
<td class="num">${p.jobs}</td>
<td class="num">${hours(p.duration_s)}</td>
<td class="num">${grams(p.weight_g)}</td>
<td class="num">${euro(p.cost)}</td>
<td class="num">${p.completed}</td>
</tr>`).join("") || '<tr><td class="empty" colspan="6">Keine Daten.</td></tr>';
const materials = (data.by_material || []).map((m) => `
<tr>
<td>${escapeHtml(m.material_type || "—")}</td>
<td>${escapeHtml(m.brand || "")}</td>
<td class="num">${grams(m.weight_g)}</td>
<td class="num">${euro(m.cost)}</td>
<td class="num">${m.jobs}</td>
</tr>`).join("") || '<tr><td class="empty" colspan="5">Kein Spulenabzug im Zeitraum.</td></tr>';
document.getElementById("stat").innerHTML = `
<div class="kpi-row">
${kpi("Läufe", String(jobs))}
${kpi("Druckzeit", hours(o.duration_s))}
${kpi("Verbrauch", grams(o.weight_g))}
${kpi("Kosten", euro(o.cost))}
${kpi("Erfolg", success + " %", (o.completed || 0) + " fertig · " + (o.failed || 0) + " abgebrochen")}
${kpi("Ausschuss", scrap + " %", (o.scrap || 0) + " mit Urteil Ausschuss")}
${kpi("Schätztreue", ratio, data.estimate_count ? data.estimate_count + " Läufe mit Soll-Zeit" : "keine Soll-Zeit")}
</div>
<div class="card">
<h2>Nach Drucker</h2>
<div class="table-wrap"><table>
<thead><tr><th>Drucker</th><th>Läufe</th><th>Zeit</th><th>Gramm</th><th>Kosten</th><th>Fertig</th></tr></thead>
<tbody>${printers}</tbody>
</table></div>
</div>
<div class="card">
<h2>Nach Material</h2>
<div class="table-wrap"><table>
<thead><tr><th>Sorte</th><th>Marke</th><th>Gramm</th><th>Kosten</th><th>Läufe</th></tr></thead>
<tbody>${materials}</tbody>
</table></div>
</div>`;
}
async function load() {
const params = new URLSearchParams();
const since = document.getElementById("since").value;
const until = document.getElementById("until").value;
const host = document.getElementById("host").value;
const material = document.getElementById("material").value.trim();
const brand = document.getElementById("brand").value.trim();
if (since) params.set("since", since);
if (until) params.set("until", until);
if (host) params.set("host", host);
if (material) params.set("material", material);
if (brand) params.set("brand", brand);
const q = params.toString();
document.getElementById("csv-link").href = "/api/v1/statistik.csv" + (q ? "?" + q : "");
const data = await api("/statistik" + (q ? "?" + q : ""));
render(data);
}
function applyPeriod(key) {
const range = periodRange(key);
document.getElementById("since").value = range.since;
document.getElementById("until").value = range.until;
setPeriodActive(key);
load().catch((error) => showNote("stat-note", error));
}
document.getElementById("period").addEventListener("click", (event) => {
const button = event.target.closest("[data-period]");
if (!button) return;
applyPeriod(button.dataset.period);
});
document.getElementById("reload").addEventListener("click", () => {
load().catch((error) => showNote("stat-note", error));
});
["since", "until"].forEach((id) => {
document.getElementById(id).addEventListener("change", () => {
clearPeriodActive();
load().catch((error) => showNote("stat-note", error));
});
});
document.getElementById("host").addEventListener("change", () => {
load().catch((error) => showNote("stat-note", error));
});
load().catch((error) => showNote("stat-note", error));
</script>
{% endblock %}
+316 -62
View File
@@ -49,7 +49,7 @@
<div class="desk-facts" data-role="facts"></div>
<div data-role="flow"></div>
<div data-role="strips"></div>
<details class="desk-fold desk-steer-fold" data-fold="steer" open>
<details class="desk-fold desk-steer-fold" data-fold="steer">
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Steuerung</span></summary>
<div data-role="steer"></div>
</details>
@@ -61,7 +61,7 @@
</aside>
</div>
<div class="desk-tools">
<details class="desk-fold desk-tool" data-fold="path" open>
<details class="desk-fold desk-tool" data-fold="path">
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">3D-Pfad</span><span class="tiny" data-role="path-caption"></span></summary>
<div class="desk-fold-body">
<p class="tiny">Ziehen dreht · Rad zoomt · Rechtsklick/Shift schwenkt · Doppelklick setzt zurück</p>
@@ -75,7 +75,7 @@
</div>
</div>
</details>
<details class="desk-fold desk-tool desk-tool-mesh" data-fold="mesh" open>
<details class="desk-fold desk-tool desk-tool-mesh" data-fold="mesh">
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Bettnetz</span></summary>
<div class="desk-fold-body" data-role="mesh">
<p class="desk-empty"><svg class="ico"><use href="#i-grid"/></svg><span>Noch kein Bettnetz</span></p>
@@ -86,22 +86,31 @@
{% endblock %}
{% block script %}
<script src="{{ url_for('static', filename='three.min.js') }}?v=1"></script>
<script src="{{ url_for('static', filename='gcode-viewer.js') }}?v=8"></script>
<script src="{{ url_for('static', filename='gcode-viewer.js') }}?v=10"></script>
<script>
const labels = {
printing: "druckt", paused: "pausiert", complete: "fertig", standby: "bereit",
heating: "heizt", error: "Fehler", cancelled: "abgebrochen", offline: "offline"
};
const labels = STATE_LABELS;
const rank = { printing: 0, paused: 1, error: 2, heating: 3, complete: 4, cancelled: 5, standby: 6, offline: 7 };
const DESK_STATES = ["printing", "paused", "complete", "standby", "heating", "error", "cancelled", "offline"];
let deskHost = "";
let hideOffline = false;
let libraryFiles = [];
let wallSpools = [];
let deskViewer = null;
let deskCamFails = 0;
let deskViewerFileId = null;
let deskSyncGen = 0;
let deskExcludeSelected = "";
const wallPullTried = new Set();
const toolpathCache = new Map();
/** Kurzes leeres filename vom Drucker nicht übernehmen — sonst springt der Name alle paar Sekunden. */
function stickyFilename(printer, previous) {
const next = (printer && printer.filename) || "";
if (next) return next;
const state = shownState(printer || {});
if (previous && ["printing", "paused", "heating", "complete"].includes(state)) return previous;
return "";
}
const cardNodes = new Map();
const runningJobs = new Map();
const printerCache = new Map();
@@ -123,9 +132,9 @@ function syncDeskCamCollapsed() {
}
function restoreDeskFolds() {
let closed = [];
try { closed = JSON.parse(localStorage.getItem(DESK_FOLD_KEY) || "[]"); } catch (error) { closed = []; }
if (!Array.isArray(closed)) closed = [];
let closed;
try { closed = JSON.parse(localStorage.getItem(DESK_FOLD_KEY) || "null"); } catch (error) { closed = null; }
if (!Array.isArray(closed)) closed = ["steer", "path", "mesh"];
deskEl.querySelectorAll("details[data-fold]").forEach((box) => {
box.open = !closed.includes(box.dataset.fold);
box.addEventListener("toggle", () => {
@@ -193,13 +202,6 @@ function heatMapHtml(printer) {
return `<div class="mesh desk-mesh"><p>Aktuelles Netz, oben ist hinten. ${low.toFixed(2)} bis ${high.toFixed(2)} mm</p><div class="gridmesh" style="grid-template-columns:repeat(${rows[0].length},1fr)">${cells}</div><p class="tiny">${bits.join(" · ")}</p></div>`;
}
function shownState(printer) {
const state = printer.state || "offline";
const hot = (printer.nozzle_target || 0) > 40 || (printer.bed_target || 0) > 30;
if ((state === "standby" || state === "complete") && hot) return "heating";
return state;
}
function jobName(filename) {
return (filename || "").replace(/\.gcode$/i, "").replace(/^(\d+-)+/, "");
}
@@ -216,6 +218,14 @@ function setHtml(el, html) {
}
}
function setText(el, text) {
if (!el) return;
const next = text == null ? "" : String(text);
if (el._text === next) return;
el._text = next;
el.textContent = next;
}
function tempbarHtml(label, value, target, scale, iconName) {
if (value == null) return "";
const now = Math.round(value);
@@ -273,12 +283,19 @@ function swatchesHtml(printer) {
}).join("");
}
function actionsHtml(state) {
function etikettHtml(printer) {
const runId = printer && printer.live_run_id;
if (!runId) return "";
return `<a class="ghost" href="/etikett/lauf/${runId}?mode=druck" target="_blank" rel="noopener">Etikett</a>`;
}
function actionsHtml(state, printer) {
const label = (state === "printing" || state === "paused") ? etikettHtml(printer) : "";
if (state === "printing") {
return `<button type="button" class="ghost" data-cmd="pause">${icon("pause")}Pause</button><button type="button" class="warn" data-cmd="abbrechen">${icon("stop")}Abbrechen</button>`;
return `${label}<button type="button" class="ghost" data-cmd="pause">${icon("pause")}Pause</button><button type="button" class="warn" data-cmd="abbrechen">${icon("stop")}Abbrechen</button>`;
}
if (state === "paused") {
return `<button type="button" data-cmd="fortsetzen">${icon("play")}Weiter</button><button type="button" class="warn" data-cmd="abbrechen">${icon("stop")}Abbrechen</button>`;
return `${label}<button type="button" data-cmd="fortsetzen">${icon("play")}Weiter</button><button type="button" class="warn" data-cmd="abbrechen">${icon("stop")}Abbrechen</button>`;
}
return "";
}
@@ -335,13 +352,107 @@ function stripsHtml(printer, state) {
return bits.join("");
}
function dryerLabel(status) {
if (status === "drying") return "trocknet";
if (status === "heater_err") return "Fehler";
return "aus";
}
function dryerDefaultTemp(dryer) {
if (dryer.target > 0) return dryer.target;
if (dryer.temp > 0) return dryer.temp;
return 45;
}
function aceSteerKey(printer) {
const dryers = printer.dryers || [];
const dryerPart = dryers.map((d) => String(d.id)).join(",");
const slots = wallSpools
.filter((s) => s.printer_host === printer.host && s.ace_slot)
.map((s) => Number(s.ace_slot))
.sort((a, b) => a - b)
.join(",");
return dryerPart + "|" + slots;
}
function spoolHex(spool) {
const colors = spool && spool.colors;
if (Array.isArray(colors)) {
for (const item of colors) {
if (item && item.hex) return item.hex;
if (typeof item === "string" && item.trim()) return item.trim();
}
}
const label = spool && spool.color;
if (typeof label === "string" && /^#?[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(label.trim())) {
return label.trim().startsWith("#") ? label.trim() : "#" + label.trim();
}
return "";
}
function aceSlotLabel(slot) {
const n = Number(slot);
if (n <= 4) return "ACE 1 · " + n;
return "ACE 2 · " + (n - 4);
}
function aceSteerHtml(printer) {
const dryers = printer.dryers || [];
if (!dryers.length) return "";
const bits = [];
dryers.forEach((dryer) => {
const id = Number(dryer.id);
const label = "ACE " + (id + 1);
const status = dryerLabel(dryer.status);
const temp = dryer.temp || 0;
const target = dryer.target || 0;
const remain = dryer.remain || 0;
const dur = remain > 0 ? remain : 240;
const hot = dryerDefaultTemp(dryer);
const info = [
status,
temp || target ? temp + "°" + (target ? " / " + target + "°" : "") : "",
remain > 0 ? "noch " + remain + " min" : "",
].filter(Boolean).join(" · ");
bits.push(`<div class="steer-ace" data-dryer-id="${id}">
<div class="steer-ace-head"><b>${escapeHtml(label)} Trockner</b><span class="tiny" data-dryer-info>${escapeHtml(info)}</span></div>
<div class="steer-row">
<label>Temp °C <input type="number" data-steer="dryer-temp-${id}" min="35" max="65" value="${escapeHtml(String(hot))}"></label>
<label>Dauer min <input type="number" data-steer="dryer-dur-${id}" min="1" max="720" value="${escapeHtml(String(dur))}"></label>
<button type="button" data-steer-go="dryer_start" data-dryer-id="${id}">Start</button>
<button type="button" class="ghost" data-steer-go="dryer_stop" data-dryer-id="${id}">Stopp</button>
</div>
</div>`);
});
const loaded = wallSpools
.filter((s) => s.printer_host === printer.host && s.ace_slot)
.slice()
.sort((a, b) => Number(a.ace_slot) - Number(b.ace_slot));
if (loaded.length) {
bits.push(`<div class="steer-ace-slots"><div class="steer-ace-head"><b>Plätze</b><span class="tiny">Sorte und Farbe aus Grotti</span></div>`);
loaded.forEach((spool) => {
const slot = Number(spool.ace_slot);
const hex = spoolHex(spool) || "#555";
const kind = spool.material_type || "—";
bits.push(`<div class="steer-slot-row">
<span class="dot" style="background:${escapeHtml(hex)}"></span>
<span class="grow"><b>${escapeHtml(aceSlotLabel(slot))}</b> · ${escapeHtml(kind)} · ${escapeHtml(spool.name || "")}</span>
<button type="button" class="ghost" data-steer-go="slot_setzen" data-ace-slot="${slot}">an den Drucker</button>
</div>`);
});
bits.push(`</div>`);
}
return bits.join("");
}
function steerPanelHtml(printer) {
const n = printer.nozzle_target != null ? Math.round(printer.nozzle_target) : "";
const b = printer.bed_target != null ? Math.round(printer.bed_target) : "";
const sp = typeof printer.speed_factor === "number" ? Math.round(printer.speed_factor * 100) : 100;
const flow = typeof printer.extrude_factor === "number" ? Math.round(printer.extrude_factor * 100) : 100;
const fan = typeof printer.fan === "number" ? Math.round(printer.fan * 100) : 0;
return `<div class="steer" data-steer-host="${escapeHtml(printer.host)}">
const aceKey = aceSteerKey(printer);
return `<div class="steer" data-steer-host="${escapeHtml(printer.host)}" data-ace-key="${escapeHtml(aceKey)}">
<div class="steer-row">
<label>${icon("nozzle")} Düse <input type="number" data-steer="nozzle" data-steer-action="nozzle" min="0" max="350" value="${escapeHtml(String(n))}"></label>
<label>${icon("bed")} Bett <input type="number" data-steer="bed" data-steer-action="bed" min="0" max="150" value="${escapeHtml(String(b))}"></label>
@@ -353,6 +464,7 @@ function steerPanelHtml(printer) {
<label>Flow % <input type="number" data-steer="flow" data-steer-action="flow" min="50" max="150" value="${escapeHtml(String(flow))}"></label>
<label>${icon("fan")} Fan % <input type="number" data-steer="fan" data-steer-action="fan" min="0" max="100" value="${escapeHtml(String(fan))}"></label>
</div>
${aceSteerHtml(printer)}
<p class="tiny">Enter im Feld setzt nur diesen Wert.</p>
</div>`;
}
@@ -435,13 +547,29 @@ function factsHtml(printer) {
${chamberFan ? "<span class=\"fact-ico\">" + icon("fan-chamber") + " Bauraum <b>" + chamberFan + "</b></span>" : ""}
${auxFan ? "<span class=\"fact-ico\">" + icon("fan-aux") + " Aux <b>" + auxFan + "</b></span>" : ""}
${speed ? "<span>Speed <b>" + speed + "</b></span>" : ""}
${anycubicTaskLine(printer)}
<span>Host <b>${escapeHtml(printer.host)}</b></span>
${printer.message && printer.ok === false ? `<p class="msg">${escapeHtml(printer.message)}</p>` : ""}
`;
}
function jobLineVisible(printer, state) {
return !!printer.filename && state !== "standby" && state !== "offline";
function anycubicTaskLine(printer) {
const task = printer.anycubic_task;
if (!task || typeof task !== "object") return "";
const state = String(task.state || "").trim();
const tid = task.task_id != null && String(task.task_id).trim() ? String(task.task_id) : "";
const file = task.filename ? String(task.filename) : "";
if (!state && !tid && !file) return "";
const bits = [];
if (state) bits.push(state);
if (tid) bits.push("task " + tid);
if (file) bits.push(file);
return "<span>Anycubic <b>" + escapeHtml(bits.join(" · ")) + "</b></span>";
}
function jobLineVisible(printer, state, filename) {
const name = filename != null ? filename : (printer && printer.filename);
return !!name && state !== "standby" && state !== "offline";
}
function shortName(name) {
@@ -483,7 +611,14 @@ function buildCard(printer) {
<div data-role="bar"></div>
<div class="stats" data-role="stats"></div>
<div data-role="temps"></div>
<div class="job-line" data-role="jobline">${icon("cube")}<span class="name" data-role="job"></span><span class="swatches" data-role="swatches"></span></div>
<div class="job-line" data-role="jobline">
<img class="job-thumb" data-role="thumb" alt="" hidden>
<span class="job-ico" data-role="jobico">${icon("cube")}</span>
<div class="job-meta">
<span class="name" data-role="job"></span>
<span class="swatches" data-role="swatches"></span>
</div>
</div>
<div data-role="flow"></div>
<div data-role="strips"></div>
<div class="card-actions" data-role="controls"></div>
@@ -503,11 +638,13 @@ function patchSteerValues(root, printer) {
if (steerHasFocus(root)) return;
const steer = root.querySelector("[data-role=steer]");
if (!steer) return;
const nozzle = steer.querySelector('[data-steer="nozzle"]');
const bed = steer.querySelector('[data-steer="bed"]');
const speed = steer.querySelector('[data-steer="speed"]');
const flow = steer.querySelector('[data-steer="flow"]');
const fan = steer.querySelector('[data-steer="fan"]');
const panel = steer.querySelector(".steer");
if (!panel) return;
const nozzle = panel.querySelector('[data-steer="nozzle"]');
const bed = panel.querySelector('[data-steer="bed"]');
const speed = panel.querySelector('[data-steer="speed"]');
const flow = panel.querySelector('[data-steer="flow"]');
const fan = panel.querySelector('[data-steer="fan"]');
if (nozzle && document.activeElement !== nozzle) {
nozzle.value = printer.nozzle_target != null ? Math.round(printer.nozzle_target) : "";
}
@@ -523,13 +660,39 @@ function patchSteerValues(root, printer) {
if (fan && document.activeElement !== fan && typeof printer.fan === "number") {
fan.value = Math.round(printer.fan * 100);
}
(printer.dryers || []).forEach((dryer) => {
const id = Number(dryer.id);
const box = panel.querySelector(`[data-dryer-id="${id}"]`);
if (!box) return;
const info = box.querySelector("[data-dryer-info]");
if (info) {
const status = dryerLabel(dryer.status);
const temp = dryer.temp || 0;
const target = dryer.target || 0;
const remain = dryer.remain || 0;
info.textContent = [
status,
temp || target ? temp + "°" + (target ? " / " + target + "°" : "") : "",
remain > 0 ? "noch " + remain + " min" : "",
].filter(Boolean).join(" · ");
}
const tempInput = box.querySelector(`[data-steer="dryer-temp-${id}"]`);
const durInput = box.querySelector(`[data-steer="dryer-dur-${id}"]`);
if (tempInput && document.activeElement !== tempInput && dryer.status === "drying" && dryer.target > 0) {
tempInput.value = dryer.target;
}
if (durInput && document.activeElement !== durInput && dryer.remain > 0) {
durInput.value = dryer.remain;
}
});
}
function patchCard(article, printer) {
const state = shownState(printer);
const clearNow = needsClear(printer);
const shownFile = stickyFilename(printer, article.dataset.filename || "");
article.dataset.state = state;
article.dataset.filename = printer.filename || "";
article.dataset.filename = shownFile;
article.dataset.progress = String(printer.progress || 0);
article.dataset.family = printer.family || "";
article.dataset.needsClear = clearNow ? "1" : "";
@@ -544,29 +707,59 @@ function patchCard(article, printer) {
setHtml(role("stats"), statsHtml(printer, state));
setHtml(role("temps"), tempbarsHtml(printer, state));
const jobline = role("jobline");
if (jobline) jobline.hidden = !jobLineVisible(printer, state);
const jobVisible = jobLineVisible(printer, state, shownFile);
const hasThumb = !!(jobVisible && printer.preview);
if (jobline) {
jobline.hidden = !jobVisible;
jobline.classList.toggle("has-thumb", hasThumb);
}
const thumb = role("thumb");
const jobico = role("jobico");
if (thumb) {
if (hasThumb) {
const next = withToken(printer.preview);
if (thumb.dataset.srcKey !== next) {
thumb.dataset.srcKey = next;
thumb.src = next;
}
thumb.hidden = false;
thumb.onerror = () => {
thumb.hidden = true;
thumb.dataset.srcKey = "";
if (jobline) jobline.classList.remove("has-thumb");
if (jobico) jobico.hidden = false;
};
if (jobico) jobico.hidden = true;
} else {
thumb.hidden = true;
thumb.removeAttribute("src");
thumb.dataset.srcKey = "";
if (jobico) jobico.hidden = false;
}
}
const job = role("job");
if (job) {
job.textContent = jobName(printer.filename) || "";
job.title = printer.filename || "";
setText(job, jobName(shownFile) || "");
job.title = shownFile || "";
}
setHtml(role("swatches"), swatchesHtml(printer));
setHtml(role("flow"), flowHtml(printer));
setHtml(role("strips"), stripsHtml(printer, state));
setHtml(role("controls"), actionsHtml(state));
setHtml(role("controls"), actionsHtml(state, printer));
}
function refreshDeskCamera(printer) {
const img = deskEl.querySelector('[data-role="camera"]');
const empty = deskEl.querySelector('[data-role="cam-empty"]');
if (!img || !empty) return;
if (!printer || !printer.camera) {
if (!printer || !printer.camera || printer.has_camera === false) {
img.removeAttribute("src");
img.hidden = true;
empty.hidden = false;
empty.innerHTML = icon("camera") + "<span>Keine Kamera</span>";
setDeskCameraMode(false, "keine");
img.dataset.srcKey = "";
deskCamFails = 0;
return;
}
// Layout (no-cam) nicht bei jedem Frame-Fehler umwerfen — sonst springt die
@@ -578,6 +771,7 @@ function refreshDeskCamera(printer) {
const probe = new Image();
probe.onload = () => {
if (img.dataset.srcKey !== next) return;
deskCamFails = 0;
img.src = next;
img.hidden = false;
empty.hidden = true;
@@ -585,7 +779,16 @@ function refreshDeskCamera(printer) {
};
probe.onerror = () => {
if (img.dataset.srcKey !== next) return;
// Altes Bild behalten; nur wenn noch nie was kam, Platzhalter zeigen
deskCamFails += 1;
if (deskCamFails >= 2) {
img.removeAttribute("src");
img.hidden = true;
empty.hidden = false;
empty.innerHTML = icon("camera") + "<span>Keine Kamera</span>";
setDeskCameraMode(false, "offline");
img.dataset.srcKey = "";
return;
}
if (!img.src || img.hidden) {
empty.hidden = false;
empty.innerHTML = icon("camera") + "<span>Kamera nicht erreichbar</span>";
@@ -633,11 +836,14 @@ function patchDesk(printer) {
if (!printer || printer.host !== deskHost) return;
const state = shownState(printer);
const prevFile = deskEl.dataset.filename || "";
const fileChanged = prevFile !== (printer.filename || "");
const shownFile = stickyFilename(printer, prevFile);
const fileChanged = prevFile !== shownFile;
deskEl.dataset.host = printer.host;
deskEl.dataset.filename = printer.filename || "";
deskEl.dataset.filename = shownFile;
deskEl.dataset.progress = String(printer.progress || 0);
deskEl.className = "desk " + state;
deskEl.classList.remove(...DESK_STATES);
deskEl.classList.add(state);
syncDeskCamCollapsed();
const role = (name) => deskEl.querySelector(`[data-role=${name}]`);
const nameEl = role("name");
@@ -656,11 +862,12 @@ function patchDesk(printer) {
setHtml(role("temps"), tempbarsHtml(printer, state));
const jobline = role("jobline");
if (jobline) jobline.hidden = !jobLineVisible(printer, state) && !printer.preview;
const jobVisible = jobLineVisible(printer, state, shownFile);
if (jobline) jobline.hidden = !jobVisible && !printer.preview;
const job = role("job");
if (job) {
job.textContent = jobName(printer.filename) || (jobLineVisible(printer, state) ? "" : "Kein Auftrag");
job.title = printer.filename || "";
setText(job, jobName(shownFile) || (jobVisible ? "" : "Kein Auftrag"));
job.title = shownFile || "";
}
const preview = role("preview");
if (preview) {
@@ -676,7 +883,7 @@ function patchDesk(printer) {
setHtml(role("facts"), factsHtml(printer));
setHtml(role("flow"), flowHtml(printer));
setHtml(role("strips"), stripsHtml(printer, state));
setHtml(role("controls"), actionsHtml(state));
setHtml(role("controls"), actionsHtml(state, printer));
const estop = role("estop");
if (estop) {
const showStop = state === "printing" || state === "paused" || state === "heating";
@@ -689,10 +896,14 @@ function patchDesk(printer) {
}
const steer = role("steer");
if (steer && (!steer.querySelector(".steer") || fileChanged)) {
setHtml(steer, steerPanelHtml(printer));
} else {
patchSteerValues(deskEl, printer);
if (steer) {
const panel = steer.querySelector(".steer");
const nextKey = aceSteerKey(printer);
if (!panel || fileChanged || panel.dataset.aceKey !== nextKey) {
setHtml(steer, steerPanelHtml(printer));
} else {
patchSteerValues(deskEl, printer);
}
}
setHtml(role("mesh"), heatMapHtml(printer));
@@ -705,8 +916,9 @@ async function syncDeskViewer(printer) {
const canvas = deskEl.querySelector('[data-role="path-canvas"]');
const caption = deskEl.querySelector('[data-role="path-caption"]');
if (!canvas || !window.GrottiGcodeViewer || !window.THREE) return;
const filename = printer.filename || "";
const filename = stickyFilename(printer, deskEl.dataset.filename || "");
const progress = Number(printer.progress || 0);
const gen = ++deskSyncGen;
if (!deskViewer) {
deskViewer = GrottiGcodeViewer.create(canvas);
deskViewer.setExcludeObjects({ objects: [], excluded: [], current: null }, {
@@ -721,45 +933,55 @@ async function syncDeskViewer(printer) {
let file = matchLibraryFile(filename);
if (!file && filename && !wallPullTried.has(deskHost + "|" + filename)) {
wallPullTried.add(deskHost + "|" + filename);
if (caption) caption.textContent = "lade vom Drucker …";
setText(caption, "lade vom Drucker …");
try {
const saved = await api("/dateien/von-drucker", {
method: "POST",
json: { host: deskHost, filename },
});
if (gen !== deskSyncGen) return;
libraryFiles = await api("/dateien");
if (gen !== deskSyncGen) return;
file = saved || matchLibraryFile(filename);
} catch (error) {
/* bleibt leer */
}
}
if (!file) {
// Kurz leerer filename: Pfad und Caption nicht verwerfen (sonst Flackern alle 5 s).
if (!filename && deskViewerFileId) return;
deskViewer.setPath({ points: [] });
deskViewerFileId = null;
if (caption) caption.textContent = filename ? "Datei nicht in Grotti" : "kein Auftrag";
setText(caption, filename ? "Datei nicht in Grotti" : "kein Auftrag");
renderExcludePanel(printer);
return;
}
try {
let data = toolpathCache.get(file.id);
const needLoad = !data || data.version !== 5 || deskViewerFileId !== file.id;
if (needLoad) {
if (caption) caption.textContent = "wird gelesen …";
data = await api("/dateien/" + file.id + "/pfad");
const cachedOk = data && Number(data.version) === 8;
const needFetch = !cachedOk;
const needSetPath = deskViewerFileId !== file.id;
if (needFetch) {
if (needSetPath) setText(caption, "wird gelesen …");
data = await api("/dateien/" + file.id + "/pfad?tp=8");
if (gen !== deskSyncGen) return;
toolpathCache.set(file.id, data);
const sameFile = deskViewerFileId === file.id;
deskViewer.setPath(data, { keepView: sameFile });
}
if (needFetch || needSetPath) {
deskViewer.setPath(data, { keepView: deskViewerFileId === file.id });
deskViewerFileId = file.id;
}
if (!(data.points && data.points.length)) {
if (caption) caption.textContent = "leer in Datei";
setText(caption, "leer in Datei");
return;
}
} catch (error) {
// File-Id behalten — sonst setzt der nächste Poll die 3D-Ansicht zurück
if (caption) caption.textContent = error.message || "fehlgeschlagen";
if (gen !== deskSyncGen) return;
setText(caption, error.message || "fehlgeschlagen");
return;
}
if (gen !== deskSyncGen) return;
deskViewer.setProgress(progress, {
layer: printer.layer,
layers: printer.layers,
@@ -780,7 +1002,7 @@ async function syncDeskViewer(printer) {
const layerTxt = printer.layers
? " · Schicht " + (printer.layer || 0) + "/" + printer.layers
: "";
if (caption) caption.textContent = file.name + " · " + Math.round(progress * 100) + " %" + layerTxt;
setText(caption, jobName(file.name) + " · " + Math.round(progress * 100) + " %" + layerTxt);
}
function renderExcludePanel(printer) {
@@ -980,8 +1202,8 @@ async function refresh() {
if (!libraryFiles.length) {
try { libraryFiles = await api("/dateien"); } catch (error) { libraryFiles = []; }
}
if (!wallSpools.length) {
try { wallSpools = await api("/spulen"); } catch (error) { wallSpools = []; }
if (!wallSpools.length || deskHost) {
try { wallSpools = await api("/spulen"); } catch (error) { /* behalten */ }
}
const all = await api("/drucker");
const printers = sortPrinters(all.slice());
@@ -991,6 +1213,14 @@ async function refresh() {
document.getElementById("summary").innerHTML = summaryText(all) || "keine Drucker";
document.getElementById("when").textContent = new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
if (!all.length) {
lastFleetKey = "";
cardNodes.clear();
cardsEl.innerHTML = `<div class="empty-state">${icon("printer", "lg")}<p>Noch keine Drucker.</p><p class="tiny">Unter Einstellungen anlegen.</p><a href="/einstellungen">Zu den Einstellungen</a></div>`;
if (deskHost) closeDesk();
return;
}
const sections = ensureFleetShell(printers);
const seen = new Set();
for (const printer of printers) {
@@ -1195,11 +1425,35 @@ async function handleWallClick(event) {
method: "POST", json: { action: "fan", percent: 0 },
});
toast("Heizung und Lüfter aus.");
} else if (go === "dryer_start") {
const id = Number(steerBtn.dataset.dryerId);
const temp = val("dryer-temp-" + id);
const duration = val("dryer-dur-" + id);
await api("/drucker/" + encodeURIComponent(host) + "/steuern", {
method: "POST",
json: { action: "dryer_start", id, temp, duration },
});
toast("Trockner gestartet.");
} else if (go === "dryer_stop") {
const id = Number(steerBtn.dataset.dryerId);
await api("/drucker/" + encodeURIComponent(host) + "/steuern", {
method: "POST",
json: { action: "dryer_stop", id },
});
toast("Trockner gestoppt.");
} else if (go === "slot_setzen") {
const aceSlot = Number(steerBtn.dataset.aceSlot);
await api("/drucker/" + encodeURIComponent(host) + "/steuern", {
method: "POST",
json: { action: "slot_setzen", ace_slot: aceSlot },
});
toast("Platz an den Drucker geschrieben.");
}
showNote("note", null);
refresh();
} catch (error) {
showNote("note", error);
toast(error.message, "bad");
} finally {
steerBtn.disabled = false;
}