Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efd0801cd1 | ||
|
|
b277ca48aa | ||
|
|
7da895bbcd | ||
|
|
5a3716b26a | ||
|
|
b20f8a7779 | ||
|
|
ead7e5718e | ||
|
|
28a1469f61 | ||
|
|
99ede12f01 |
No files matched your search
@@ -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\`).
|
||||||
@@ -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.
|
- Sprache: Deutsch, in Oberfläche, Meldungen, Doku und Commits.
|
||||||
- Jede Änderung bekommt einen Eintrag in `CHANGELOG.md` unter `[Unreleased]` (Neu, Geändert, Behoben).
|
- 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`.
|
- 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.
|
- 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`.
|
- Keine freie G-code-Konsole, kein `FIRMWARE_RESTART` (GoKlipper deadlockt). Druckstarts brauchen `confirm: true`.
|
||||||
@@ -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/).
|
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
|
## [2.0.0] – 2026-10-15
|
||||||
|
|
||||||
### Neu
|
### Neu
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ Die Familie (Kobra 3 oder S1) liest Grotti aus dem Namen.
|
|||||||
.\.venv\Scripts\python.exe server.py
|
.\.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.
|
`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 |
|
| `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`) |
|
| `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/`) |
|
| `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 |
|
| `CHANGELOG.md` | Änderungen je Version |
|
||||||
|
|
||||||
## API
|
## 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 |
|
| 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 /version` | Version |
|
||||||
| `GET /drucker?live=1` | Drucker mit Live-Zustand, laufendem und wartendem Auftrag |
|
| `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>/<aktion>` | Pause, Fortsetzen, Abbrechen, Fehler löschen |
|
||||||
| `POST /drucker/<host>/steuern` | Temperatur, Speed, Flow, Lüfter |
|
| `POST /drucker/<host>/steuern` | Temperatur, Speed, Flow, Lüfter |
|
||||||
| `GET /dateien`, `POST /dateien` | Bibliothek, Upload |
|
| `GET /dateien`, `POST /dateien` | Bibliothek, Upload |
|
||||||
| `POST /senden` | Datei an Drucker. `confirm: true` ist Pflicht. `when_free: true` reiht ein. |
|
| `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 /spulen/<id>/einlegen` | Spule an Drucker/Platz; alte Rolle dort ins Regal |
|
||||||
| `POST /ablauf` | Ablauf für Kobra 3 einreihen |
|
| `POST /ablauf` | Ablauf für Kobra 3 einreihen |
|
||||||
| `GET /ereignisse?host=&kind=&since=&limit=` | Aktivität |
|
| `GET /ereignisse?host=&kind=&since=&limit=` | Aktivität |
|
||||||
| `POST /ereignisse/gelesen` | Alerts als gelesen |
|
| `POST /ereignisse/gelesen` | Alerts als gelesen |
|
||||||
| `GET /spulen`, `GET /laeufe`, `GET /projekte` | Material, Protokoll, Projekte |
|
| `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
|
## Sicherheit
|
||||||
|
|
||||||
|
- Login Pflicht (Setup legt den ersten Admin an). Rollen: Admin, Operator.
|
||||||
- Druckstarts brauchen `confirm: true`. Die Oberfläche fragt per Zwei-Klick.
|
- Druckstarts brauchen `confirm: true`. Die Oberfläche fragt per Zwei-Klick.
|
||||||
- Keine freie G-code-Konsole.
|
- Keine freie G-code-Konsole; Firmware-Neustart über Grotti ist gesperrt.
|
||||||
- Kein `FIRMWARE_RESTART`, GoKlipper hängt sich dabei auf. Fehler werden über „Fehler löschen“ geräumt.
|
- Für Internet: Reverse Proxy mit TLS; nicht den Entwickler-Server öffentlich freigeben.
|
||||||
|
|
||||||
## Versionen und Changelog
|
## Versionen und Changelog
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,16 @@
|
|||||||
"watch_dir": "",
|
"watch_dir": "",
|
||||||
"database_url": "postgresql://[email protected]:5432/grotti",
|
"database_url": "postgresql://[email protected]:5432/grotti",
|
||||||
"token": "",
|
"token": "",
|
||||||
|
"secret_key": "",
|
||||||
"fqdn": "",
|
"fqdn": "",
|
||||||
"auto_queue": true,
|
"auto_queue": true,
|
||||||
|
"oauth": {
|
||||||
|
"enabled": false,
|
||||||
|
"issuer": "",
|
||||||
|
"client_id": "",
|
||||||
|
"client_secret": "",
|
||||||
|
"redirect_uri": ""
|
||||||
|
},
|
||||||
"energy": {
|
"energy": {
|
||||||
"watts": 120,
|
"watts": 120,
|
||||||
"euro_per_kwh": 0.35,
|
"euro_per_kwh": 0.35,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Dateien senden
|
||||||
|
|
||||||
|
Stand **15.10.2026**. Seite `/dateien` — Bibliothek links, Senden rechts.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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)
|
||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Einstellungen
|
||||||
|
|
||||||
|
Stand **15.10.2026**. Seite `/einstellungen` — Drucker, Automatik, Anzeige, Telegram, Energie.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Material und mehr
|
||||||
|
|
||||||
|
Stand **17.10.2026**. Spulen, Etiketten, Projekte, Protokoll, Statistik, Kameras, Aktivität und Ideen.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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)
|
||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Druckerwand
|
||||||
|
|
||||||
|
Stand **15.10.2026**. Startseite `/` — eine Karte je Drucker. Klick öffnet das Cockpit.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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)
|
||||||
@@ -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).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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)
|
||||||
@@ -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
|
# Chargen, Etiketten und 3D-Viewer
|
||||||
|
|
||||||
Stand 14.10.2026.
|
Stand 17.10.2026.
|
||||||
|
|
||||||
## Spulen-Chargen
|
## 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>`.
|
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
|
## 3D-Pfad
|
||||||
|
|
||||||
`GET /api/v1/dateien/<id>/pfad` liefert einen gedünnten Toolpath (bis ca. 120 000 Punkte, plus `layer_breaks`, Cache-`version` 5) aus dem lokalen G-code. Die Dateien-Seite und das Cockpit zeichnen ihn mit Three.js.
|
`GET /api/v1/dateien/<id>/pfad` liefert einen gedünnten Toolpath (bis ca. 220 000 Punkte, plus `layer_breaks`, Cache-`version` 8) aus dem lokalen G-code. Thinning behält ganze Z-Höhen dicht und lässt nur ganze Schichten weg (sonst gesprenkelte Wände). Die Dateien-Seite und das Cockpit zeichnen ihn mit Three.js.
|
||||||
|
|
||||||
Darstellung: nur Extrusionswege. Preview = orange. Live: gedruckt leicht verdickt grün, offen grau; Schnitt nach `progress`. Parser akzeptiert `E.39`/`Z.6`. Bett: Gitter 10 mm, mm-Sprites, Legende.
|
Darstellung: nur Extrusionswege. Preview = orange. Live: gedruckt leicht verdickt grün, offen grau; Schnitt nach `progress`. Parser akzeptiert `E.39`/`Z.6`. Bett: Gitter 10 mm, mm-Sprites, Legende.
|
||||||
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Druckbericht und Statistik
|
||||||
|
|
||||||
|
Stand **17.10.2026**.
|
||||||
|
|
||||||
|
## Tabellen
|
||||||
|
|
||||||
|
- `runs`: zusätzlich `weight_planned_g`, `weight_used_g`, `weight_source`, `filament_used_mm`/`filament_total_mm`, `progress_last`, `estimated_s`, `chargen_nr`, `message`, `objects`
|
||||||
|
- `run_spools`: Abzug je Spule (`is_estimate` bei Mehrfarb)
|
||||||
|
- `run_segments`: Abschnitte bei Rollenwechsel (Einlegen während Druck/Pause)
|
||||||
|
- `run_samples`: Temperatur/Fortschritt ca. alle 30 s (Watcher)
|
||||||
|
- `run_labels`: Objekt-Chargen `D-…-01`
|
||||||
|
- `events.run_id`: optionaler Bezug zum Lauf
|
||||||
|
|
||||||
|
## Abzug
|
||||||
|
|
||||||
|
`grotti/usage.py` rechnet Ist-Gramm aus `filament_used` / `filament_total` × Soll. Abbruch ohne Extrusion: 0. Ohne Zähler: Fortschritt × Soll (Schätzwert). Fertig ohne Messgröße: Soll bleibt, Quelle `estimate`.
|
||||||
|
|
||||||
|
`apply_deduction` schreibt zuerst `run_spools`, dann `deducted`/`remaining_g` (auch unter 0); `ensure_run_chargen` oft schon während des Laufs. Ohne `run_spools` bleibt die Spulenakte leer.
|
||||||
|
|
||||||
|
## Live
|
||||||
|
|
||||||
|
`StateWatcher` hängt am offenen History-Job oder legt `live:…` an, schreibt Proben, vergibt `chargen_nr`, merkt Objektnamen. Vor neuem Start: `abandon_live_runs`. `sync_history` übernimmt Proben/Segmente/Charge (`adopt_live_run`, nicht auf ältere fertige Jobs derselben Datei) und setzt bei Bedarf `needs_clear`.
|
||||||
|
|
||||||
|
## Statistik-Filter
|
||||||
|
|
||||||
|
`since` inklusiv, reines `until` als Datum (`YYYY-MM-DD`) inklusiv (intern Folgetag exklusiv). Presets in der UI setzen Von/Bis. CSV nutzt dieselben Query-Parameter.
|
||||||
@@ -37,10 +37,15 @@ Stand **14.10.2026**, v1.4.0.
|
|||||||
|
|
||||||
## Material
|
## Material
|
||||||
|
|
||||||
- Reiter **Spulen** / **Sorten**. Scan-Feld im Seitenkopf.
|
- Reiter **Spulen** / **Sorten**. Scan-Feld im Seitenkopf (`S-…` Spule, `D-…` Druckbericht).
|
||||||
- Spulen: Filter Alle / Am Drucker / Regal / Wenig Rest + Suche. Formular nur bei Auswahl oder „Neue Spule“ (× schließt). Ort = Drucker · ACE/extern bzw. Regal.
|
- Spulen: Filter Alle / Am Drucker / Regal / Wenig Rest + Suche. Formular nur bei Auswahl oder „Neue Spule“ (× schließt). Ort = Drucker · ACE/extern bzw. Regal. Akte mit Verbrauch und Leer-in.
|
||||||
- Sorten: Profile (Dichte, mm³/s, Preis, Maschinen). Anlegen/ändern/löschen; „Spule damit anlegen“ wechselt zum Spulen-Reiter.
|
- Sorten: Profile (Dichte, mm³/s, Preis, Maschinen). Anlegen/ändern/löschen; „Spule damit anlegen“ wechselt zum Spulen-Reiter.
|
||||||
|
|
||||||
|
## Protokoll und Statistik
|
||||||
|
|
||||||
|
- Protokoll-Zeile → Druckbericht `/protokoll/<id>` (Soll/Ist, Spulen, Temperatur, Charge, Etiketten). Charge schon am laufenden Druck; Etikett an der Wand und im Bericht.
|
||||||
|
- Statistik `/statistik`: KPIs, nach Drucker/Material, Zeitraum-Presets, CSV mit denselben Filtern.
|
||||||
|
|
||||||
## Warteschlange
|
## Warteschlange
|
||||||
|
|
||||||
- Seite `/warteschlange`: Filter Läuft / Wartet / Fehler / Historie. Priorität, Abbrechen, Fortsetzen.
|
- Seite `/warteschlange`: Filter Läuft / Wartet / Fehler / Historie. Priorität, Abbrechen, Fortsetzen.
|
||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -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
|
# 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
|
## 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.
|
`watch_dir` in `config.json` zeigt auf den Slice-Ordner der Bibliothek. Ohne Ordner geht Hochladen über die Dateien-Seite trotzdem.
|
||||||
|
|
||||||
|
**Dateinamen:** Moonraker-Multipart nutzt nur noch einen ASCII-`filename` (`moonraker.safe_upload_name`). Früher standen Nicht-ASCII-Zeichen in `filename*=UTF-8''…`, während `filename=` Unterstriche bekam — gklib speicherte A, Start suchte B → **10115 not found**.
|
||||||
|
|
||||||
## ACE / `.acm`
|
## ACE / `.acm`
|
||||||
|
|
||||||
Drucker mit ACE/MMU-Plätzen (`mmu.gate_color` / `gate_material`, Grotti-`slots`) brauchen vor `print/start` eine Sidecar-Datei `*.gcode.acm`. In der UI unter Optionen → ACE-Zuordnung (Datei-Farbe → Platz). Ohne passende `.acm` lehnt der Drucker oft mit „cannot parse the file“ ab — Details hier, nicht als Banner in der App:
|
Drucker mit ACE/MMU-Plätzen (`mmu.gate_color` / `gate_material`, Grotti-`slots`) brauchen vor `print/start` eine Sidecar-Datei `*.gcode.acm`. In der UI unter Optionen → ACE-Zuordnung (Datei-Farbe → Platz). Ohne passende `.acm` lehnt der Drucker oft mit „cannot parse the file“ ab — Details hier, nicht als Banner in der App:
|
||||||
@@ -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`).
|
**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`).
|
**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).
|
Fallback ohne MQTT: global in Rinkhals Touch → Manage apps → 40-moonraker → Auto leveling (gilt dann für alle Moonraker-Starts inkl. Mainsail/Orca).
|
||||||
@@ -24,13 +24,23 @@ from cryptography.hazmat.primitives import padding
|
|||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
|
||||||
CRED_TTL = 300
|
CRED_TTL = 300
|
||||||
|
INFO_TTL = 10
|
||||||
|
FAIL_TTL = 45
|
||||||
_CRED_CACHE = {}
|
_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):
|
class LanError(Exception):
|
||||||
def __init__(self, message):
|
def __init__(self, message, code=None):
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
self.message = message
|
self.message = message
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
def _info(host):
|
def _info(host):
|
||||||
@@ -141,17 +151,103 @@ def normalize_ams(ams_settings):
|
|||||||
return {"use_ams": True, "ams_box_mapping": entries}
|
return {"use_ams": True, "ams_box_mapping": entries}
|
||||||
|
|
||||||
|
|
||||||
def _report_error(body, default_message):
|
def _parse_code(value):
|
||||||
code = body.get("code")
|
if value is None or value == "":
|
||||||
if code == 10107:
|
return None
|
||||||
return "Filament gerissen. Bitte neues laden. (code 10107)"
|
try:
|
||||||
if code == 10101:
|
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 (
|
return (
|
||||||
"Alter Druckauftrag noch aktiv (Anycubic-MQTT). "
|
"Alter Druckauftrag noch aktiv (Anycubic-Task). "
|
||||||
"Bitte zuerst abbrechen. (code 10101)"
|
"Wird geräumt und ggf. erneut gestartet. (code 10101)"
|
||||||
|
)
|
||||||
|
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),
|
||||||
)
|
)
|
||||||
message = str(body.get("msg") or default_message)
|
|
||||||
return "%s%s" % (message, (" (code %s)" % code) if code else "")
|
|
||||||
|
|
||||||
|
|
||||||
def _mqtt_print(host, request, timeout=30, timeout_message="Timeout bei MQTT-Druckbefehl."):
|
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,
|
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):
|
def on_connect(client, userdata, flags, reason_code, properties=None):
|
||||||
if getattr(reason_code, "is_failure", False):
|
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 "")
|
report = str(body.get("state") or "")
|
||||||
action = str(request.get("action") or "")
|
action = str(request.get("action") or "")
|
||||||
if action == "start" and report in ("failed", "stoped", "stopped"):
|
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",):
|
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
|
# start: heating / leveling / printing / preheating = angenommen
|
||||||
# stop: stoped / stopped / done = angenommen
|
# stop: stoped / stopped / done = angenommen
|
||||||
state["done"] = True
|
state["done"] = True
|
||||||
@@ -221,16 +321,26 @@ def _mqtt_print(host, request, timeout=30, timeout_message="Timeout bei MQTT-Dru
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if state["error"]:
|
if state["error"]:
|
||||||
raise LanError(state["error"])
|
raise LanError(state["error"], code=state.get("code"))
|
||||||
return state["report"]
|
return state["report"]
|
||||||
|
|
||||||
|
|
||||||
def start_print(host, filename, auto_leveling=True, ams_settings=None, timeout=30):
|
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]
|
name = (filename or "").replace("\\", "/").split("/")[-1]
|
||||||
if not name:
|
if not name:
|
||||||
raise LanError("Dateiname fehlt.")
|
raise LanError("Dateiname fehlt.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
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 = {
|
data = {
|
||||||
"filename": name,
|
"filename": name,
|
||||||
"filepath": "/",
|
"filepath": "/",
|
||||||
@@ -243,10 +353,8 @@ def start_print(host, filename, auto_leveling=True, ams_settings=None, timeout=3
|
|||||||
"flow_calibration": 0,
|
"flow_calibration": 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
ams = normalize_ams(ams_settings)
|
|
||||||
if ams:
|
if ams:
|
||||||
data["ams_settings"] = ams
|
data["ams_settings"] = ams
|
||||||
|
|
||||||
request = {
|
request = {
|
||||||
"type": "print",
|
"type": "print",
|
||||||
"action": "start",
|
"action": "start",
|
||||||
@@ -254,26 +362,52 @@ def start_print(host, filename, auto_leveling=True, ams_settings=None, timeout=3
|
|||||||
"timestamp": int(time.time() * 1000),
|
"timestamp": int(time.time() * 1000),
|
||||||
"data": data,
|
"data": data,
|
||||||
}
|
}
|
||||||
timeout_message = "Timeout beim MQTT-Druckstart."
|
|
||||||
try:
|
try:
|
||||||
_mqtt_print(host, request, timeout=timeout, timeout_message=timeout_message)
|
_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:
|
|
||||||
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)}
|
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
|
||||||
|
if last_exc:
|
||||||
|
raise last_exc
|
||||||
|
raise LanError("Druckstart fehlgeschlagen.")
|
||||||
|
|
||||||
|
|
||||||
def print_info(host, timeout=10):
|
def print_info(host, timeout=10, use_cache=True):
|
||||||
"""MQTT info/query, liefert data aus info/report (project.task_id, state …)."""
|
"""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)
|
cred = credentials(host)
|
||||||
|
except LanError:
|
||||||
|
_FAIL_CACHE[host] = time.time()
|
||||||
|
return None
|
||||||
|
|
||||||
model_id = cred["modelId"]
|
model_id = cred["modelId"]
|
||||||
device_id = cred["deviceId"]
|
device_id = cred["deviceId"]
|
||||||
topic_query = "anycubic/anycubicCloud/v1/slicer/printer/%s/%s/info" % (model_id, device_id)
|
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:
|
try:
|
||||||
client.connect(host, 9883, keepalive=30)
|
client.connect(host, 9883, keepalive=30)
|
||||||
except OSError:
|
except OSError:
|
||||||
|
_FAIL_CACHE[host] = time.time()
|
||||||
return None
|
return None
|
||||||
deadline = time.time() + max(3, int(timeout))
|
deadline = time.time() + max(3, int(timeout))
|
||||||
try:
|
try:
|
||||||
@@ -326,9 +461,22 @@ def print_info(host, timeout=10):
|
|||||||
client.disconnect()
|
client.disconnect()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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"]
|
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):
|
def cancel_print(host, timeout=15):
|
||||||
"""MQTT-Stop wie Rinkhals Docs: action stop, taskid -1 (Anycubic-Task)."""
|
"""MQTT-Stop wie Rinkhals Docs: action stop, taskid -1 (Anycubic-Task)."""
|
||||||
request = {
|
request = {
|
||||||
@@ -344,4 +492,5 @@ def cancel_print(host, timeout=15):
|
|||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
timeout_message="Timeout beim MQTT-Druckabbruch.",
|
timeout_message="Timeout beim MQTT-Druckabbruch.",
|
||||||
)
|
)
|
||||||
|
_INFO_CACHE.pop(host, None)
|
||||||
return {"mqtt": "stop", "report": report}
|
return {"mqtt": "stop", "report": report}
|
||||||
@@ -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")),
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Host, Port, Datenordner und Datenbank kommen aus config.json."""
|
"""Host, Port, Datenordner und Datenbank kommen aus config.json."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import secrets
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
@@ -59,6 +60,27 @@ def public_base(cfg, request_root=None):
|
|||||||
return str(request_root or "").rstrip("/")
|
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():
|
def load():
|
||||||
path = CONFIG_PATH
|
path = CONFIG_PATH
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
@@ -66,8 +88,14 @@ def load():
|
|||||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
host = (raw.get("host") or "127.0.0.1").strip()
|
host = (raw.get("host") or "127.0.0.1").strip()
|
||||||
token = raw.get("token") or ""
|
token = raw.get("token") or ""
|
||||||
if host not in LOOPBACK and not token:
|
secret_key = _ensure_secret_key(path, raw)
|
||||||
raise SystemExit("Ohne Token startet der Dienst nicht, wenn er nicht nur lokal lauscht.")
|
# 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")
|
data_dir = Path(raw.get("data_dir") or "data")
|
||||||
if not data_dir.is_absolute():
|
if not data_dir.is_absolute():
|
||||||
data_dir = ROOT / data_dir
|
data_dir = ROOT / data_dir
|
||||||
@@ -82,6 +110,8 @@ def load():
|
|||||||
"watch_dir": watch_dir,
|
"watch_dir": watch_dir,
|
||||||
"database_url": raw.get("database_url") or "postgresql://[email protected]:5432/grotti",
|
"database_url": raw.get("database_url") or "postgresql://[email protected]:5432/grotti",
|
||||||
"token": token,
|
"token": token,
|
||||||
|
"secret_key": secret_key,
|
||||||
|
"oauth": _oauth(raw),
|
||||||
"fqdn": normalize_fqdn(raw.get("fqdn") or ""),
|
"fqdn": normalize_fqdn(raw.get("fqdn") or ""),
|
||||||
"printers_file": ROOT / "drucker.json",
|
"printers_file": ROOT / "drucker.json",
|
||||||
"energy": _energy(raw),
|
"energy": _energy(raw),
|
||||||
@@ -102,10 +132,12 @@ def mask_token(token):
|
|||||||
|
|
||||||
def settings_public(cfg):
|
def settings_public(cfg):
|
||||||
tg = cfg.get("telegram") or {}
|
tg = cfg.get("telegram") or {}
|
||||||
|
oauth = cfg.get("oauth") or {}
|
||||||
return {
|
return {
|
||||||
"watch_dir": str(cfg.get("watch_dir") or ""),
|
"watch_dir": str(cfg.get("watch_dir") or ""),
|
||||||
"auto_queue": bool(cfg.get("auto_queue", True)),
|
"auto_queue": bool(cfg.get("auto_queue", True)),
|
||||||
"fqdn": str(cfg.get("fqdn") or ""),
|
"fqdn": str(cfg.get("fqdn") or ""),
|
||||||
|
"legacy_token_set": bool(cfg.get("token")),
|
||||||
"energy": dict(cfg.get("energy") or {}),
|
"energy": dict(cfg.get("energy") or {}),
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": bool(tg.get("enabled")),
|
"enabled": bool(tg.get("enabled")),
|
||||||
@@ -114,6 +146,12 @@ def settings_public(cfg):
|
|||||||
"chat_id": str(tg.get("chat_id") or ""),
|
"chat_id": str(tg.get("chat_id") or ""),
|
||||||
},
|
},
|
||||||
"alerts": dict(cfg.get("alerts") 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 ""),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import urllib.error
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from grotti import acm, eject, library, moonraker, store
|
from grotti import acm, eject, library, moonraker, store, usage
|
||||||
from grotti.cost import energy_cost
|
from grotti.cost import energy_cost
|
||||||
from grotti.errors import GrottiError
|
from grotti.errors import GrottiError
|
||||||
from grotti.parse import content_has_ace_trailer, prepare_rinkhals_gcode
|
from grotti.parse import content_has_ace_trailer, prepare_rinkhals_gcode
|
||||||
@@ -15,17 +15,7 @@ TERMINAL = {"completed", "cancelled", "error", "interrupted"}
|
|||||||
|
|
||||||
|
|
||||||
def weight_of(metadata):
|
def weight_of(metadata):
|
||||||
raw = (metadata or {}).get("filament_weight_total")
|
return usage.weight_sum(metadata)
|
||||||
if isinstance(raw, list):
|
|
||||||
return sum(float(item or 0) for item in raw)
|
|
||||||
if isinstance(raw, (int, float)):
|
|
||||||
return float(raw)
|
|
||||||
if isinstance(raw, str):
|
|
||||||
try:
|
|
||||||
return float(raw.replace(",", "."))
|
|
||||||
except ValueError:
|
|
||||||
return 0.0
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def material_of(metadata):
|
def material_of(metadata):
|
||||||
@@ -35,6 +25,37 @@ def material_of(metadata):
|
|||||||
return str(raw).split(";")[0].strip()
|
return str(raw).split(";")[0].strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _ace_map_of(sent):
|
||||||
|
if not sent:
|
||||||
|
return []
|
||||||
|
raw = sent.get("ace_map")
|
||||||
|
if isinstance(raw, list):
|
||||||
|
return acm.normalize_mapping(raw)
|
||||||
|
if isinstance(raw, str):
|
||||||
|
try:
|
||||||
|
return acm.normalize_mapping(json.loads(raw))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return []
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _targets_from_ace(conn, host, ace):
|
||||||
|
targets = []
|
||||||
|
for item in ace:
|
||||||
|
slot = int(item["ams_index"]) + 1
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM spools
|
||||||
|
WHERE printer_host = %s AND ace_slot = %s
|
||||||
|
ORDER BY id LIMIT 1
|
||||||
|
""",
|
||||||
|
(host, slot),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
targets.append(row)
|
||||||
|
return targets
|
||||||
|
|
||||||
|
|
||||||
def unix_to_dt(value):
|
def unix_to_dt(value):
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
@@ -128,50 +149,152 @@ def assert_spool_at_host(conn, spool_id, host):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def apply_deduction(conn, run, energy):
|
def apply_deduction(conn, run, energy, metadata=None):
|
||||||
if run["deducted"] or run["weight_g"] <= 0 or run["state"] not in TERMINAL:
|
if run["deducted"] or run["state"] not in TERMINAL:
|
||||||
return False
|
return False
|
||||||
weight = float(run["weight_g"] or 0)
|
planned = float(run.get("weight_planned_g") or run.get("weight_g") or 0)
|
||||||
# ACE: Abzug anteilig auf die Plätze der letzten Sendung
|
used = float(run.get("weight_used_g") or 0)
|
||||||
|
source = run.get("weight_source") or ""
|
||||||
|
if used <= 0 and planned > 0 and source != "none":
|
||||||
|
used, source = usage.resolve_used_grams(
|
||||||
|
planned,
|
||||||
|
run.get("filament_used_mm") or 0,
|
||||||
|
run.get("filament_total_mm") or 0,
|
||||||
|
run.get("progress_last") or 0,
|
||||||
|
run["state"],
|
||||||
|
run.get("duration_s") or 0,
|
||||||
|
)
|
||||||
|
if used <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
sent = store.latest_send(conn, run["printer_host"], run.get("filename") or "")
|
sent = store.latest_send(conn, run["printer_host"], run.get("filename") or "")
|
||||||
ace = []
|
ace = _ace_map_of(sent)
|
||||||
if sent:
|
meta = metadata or {}
|
||||||
raw = sent.get("ace_map")
|
if not meta and run.get("file_id"):
|
||||||
if isinstance(raw, list):
|
file_row = store.get_file_row(conn, run["file_id"]) if hasattr(store, "get_file_row") else None
|
||||||
ace = raw
|
if file_row:
|
||||||
elif isinstance(raw, str):
|
meta = {"filament_weight_total": file_row.get("weight_g") or planned}
|
||||||
try:
|
|
||||||
ace = json.loads(raw)
|
store.close_open_segments(conn, run["id"], None, run.get("filament_used_mm") or 0)
|
||||||
except json.JSONDecodeError:
|
segments = store.list_run_segments(conn, run["id"])
|
||||||
ace = []
|
|
||||||
targets = []
|
entries = []
|
||||||
if ace:
|
targets = _targets_from_ace(conn, run["printer_host"], ace) if ace else []
|
||||||
for item in acm.normalize_mapping(ace):
|
|
||||||
slot = int(item["ams_index"]) + 1
|
|
||||||
row = conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT * FROM spools
|
|
||||||
WHERE printer_host = %s AND ace_slot = %s
|
|
||||||
ORDER BY id LIMIT 1
|
|
||||||
""",
|
|
||||||
(run["printer_host"], slot),
|
|
||||||
).fetchone()
|
|
||||||
if row:
|
|
||||||
targets.append(row)
|
|
||||||
if not targets and run.get("spool_id"):
|
if not targets and run.get("spool_id"):
|
||||||
spool = store.get_spool_row(conn, run["spool_id"])
|
spool = store.get_spool_row(conn, run["spool_id"])
|
||||||
if spool:
|
if spool:
|
||||||
targets = [spool]
|
targets = [spool]
|
||||||
if not targets:
|
# Abschnitte ohne ACE-Map: externe Rollen nacheinander
|
||||||
|
if segments and not ace:
|
||||||
|
final_mm = float(run.get("filament_used_mm") or 0)
|
||||||
|
total_mm = float(run.get("filament_total_mm") or 0)
|
||||||
|
deltas = usage.segment_deltas(segments, final_mm)
|
||||||
|
for spool_id, ace_slot, delta_mm in deltas:
|
||||||
|
spool = store.get_spool_row(conn, spool_id)
|
||||||
|
if not spool:
|
||||||
|
continue
|
||||||
|
grams = usage.grams_from_mm(delta_mm, total_mm, planned)
|
||||||
|
cost = (grams / 1000.0) * float(spool.get("price_per_kg") or 0)
|
||||||
|
entries.append({
|
||||||
|
"spool_id": spool_id,
|
||||||
|
"ace_slot": ace_slot,
|
||||||
|
"weight_g": grams,
|
||||||
|
"weight_planned_g": planned / max(1, len(deltas)),
|
||||||
|
"cost": cost,
|
||||||
|
"is_estimate": source != "measured",
|
||||||
|
})
|
||||||
|
elif targets:
|
||||||
|
shares = usage.planned_shares(meta, ace, len(targets))
|
||||||
|
parts, is_estimate = usage.split_used(used, shares, multi=len(targets) > 1)
|
||||||
|
if source != "measured":
|
||||||
|
is_estimate = True
|
||||||
|
# ACE-Rollenwechsel: Anteil der gewechselten Farbe nach mm aufteilen
|
||||||
|
by_slot = {}
|
||||||
|
if segments and ace:
|
||||||
|
final_mm = float(run.get("filament_used_mm") or 0)
|
||||||
|
for spool_id, ace_slot, delta_mm in usage.segment_deltas(segments, final_mm):
|
||||||
|
by_slot.setdefault(ace_slot, []).append((spool_id, delta_mm))
|
||||||
|
for index, spool in enumerate(targets):
|
||||||
|
plan = shares[index] if index < len(shares) else 0
|
||||||
|
grams = parts[index] if index < len(parts) else 0
|
||||||
|
slot = spool.get("ace_slot")
|
||||||
|
slot_segs = by_slot.get(slot) or []
|
||||||
|
if len(slot_segs) > 1:
|
||||||
|
mm_sum = sum(d for _, d in slot_segs) or 1.0
|
||||||
|
for seg_spool_id, delta_mm in slot_segs:
|
||||||
|
seg_spool = store.get_spool_row(conn, seg_spool_id)
|
||||||
|
if not seg_spool:
|
||||||
|
continue
|
||||||
|
share_g = grams * (delta_mm / mm_sum)
|
||||||
|
cost = (share_g / 1000.0) * float(seg_spool.get("price_per_kg") or 0)
|
||||||
|
entries.append({
|
||||||
|
"spool_id": seg_spool_id,
|
||||||
|
"ace_slot": slot,
|
||||||
|
"weight_g": share_g,
|
||||||
|
"weight_planned_g": plan / len(slot_segs),
|
||||||
|
"cost": cost,
|
||||||
|
"is_estimate": True,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
cost = (grams / 1000.0) * float(spool.get("price_per_kg") or 0)
|
||||||
|
entries.append({
|
||||||
|
"spool_id": spool["id"],
|
||||||
|
"ace_slot": slot,
|
||||||
|
"weight_g": grams,
|
||||||
|
"weight_planned_g": plan,
|
||||||
|
"cost": cost,
|
||||||
|
"is_estimate": is_estimate,
|
||||||
|
})
|
||||||
|
elif segments:
|
||||||
|
# Fallback: Abschnitte ohne aktuelle Targets
|
||||||
|
final_mm = float(run.get("filament_used_mm") or 0)
|
||||||
|
total_mm = float(run.get("filament_total_mm") or 0)
|
||||||
|
for spool_id, ace_slot, delta_mm in usage.segment_deltas(segments, final_mm):
|
||||||
|
spool = store.get_spool_row(conn, spool_id)
|
||||||
|
if not spool:
|
||||||
|
continue
|
||||||
|
grams = usage.grams_from_mm(delta_mm, total_mm, planned)
|
||||||
|
cost = (grams / 1000.0) * float(spool.get("price_per_kg") or 0)
|
||||||
|
entries.append({
|
||||||
|
"spool_id": spool_id,
|
||||||
|
"ace_slot": ace_slot,
|
||||||
|
"weight_g": grams,
|
||||||
|
"weight_planned_g": 0,
|
||||||
|
"cost": cost,
|
||||||
|
"is_estimate": source != "measured",
|
||||||
|
})
|
||||||
|
else:
|
||||||
return False
|
return False
|
||||||
share = weight / len(targets)
|
|
||||||
filament = sum((share / 1000) * float(spool["price_per_kg"] or 0) for spool in targets)
|
if not entries:
|
||||||
|
return False
|
||||||
|
used_total = sum(item["weight_g"] for item in entries)
|
||||||
|
filament = sum(item["cost"] for item in entries)
|
||||||
power = energy_cost(run["duration_s"], energy)
|
power = energy_cost(run["duration_s"], energy)
|
||||||
marked = store.mark_deducted(conn, run["id"], targets[0]["id"], filament, power)
|
# run_spools vor deducted=true, sonst bleibt die Akte leer bei Abbruch dazwischen
|
||||||
|
store.replace_run_spools(conn, run["id"], entries)
|
||||||
|
marked = store.mark_deducted(
|
||||||
|
conn,
|
||||||
|
run["id"],
|
||||||
|
entries[0]["spool_id"],
|
||||||
|
filament,
|
||||||
|
power,
|
||||||
|
weight_used_g=used_total,
|
||||||
|
weight_source=source or ("estimate" if any(e["is_estimate"] for e in entries) else "measured"),
|
||||||
|
)
|
||||||
if not marked:
|
if not marked:
|
||||||
|
store.replace_run_spools(conn, run["id"], [])
|
||||||
return False
|
return False
|
||||||
for spool in targets:
|
for item in entries:
|
||||||
store.deduct_spool(conn, spool["id"], share)
|
store.deduct_spool(conn, item["spool_id"], item["weight_g"])
|
||||||
|
store.ensure_run_chargen(conn, run["id"], used_total)
|
||||||
|
objects = run.get("objects")
|
||||||
|
if isinstance(objects, str):
|
||||||
|
try:
|
||||||
|
objects = json.loads(objects)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
objects = []
|
||||||
|
store.ensure_run_labels(conn, run["id"], objects or [], qty=1)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -190,13 +313,16 @@ def sync_history(conn, cfg):
|
|||||||
deducted = 0
|
deducted = 0
|
||||||
for printer, job in collected:
|
for printer, job in collected:
|
||||||
job_id = str(job.get("job_id") or "")
|
job_id = str(job.get("job_id") or "")
|
||||||
if not job_id:
|
if not job_id or str(job_id).startswith("live:"):
|
||||||
continue
|
continue
|
||||||
filename = job.get("filename") or ""
|
filename = job.get("filename") or ""
|
||||||
metadata = job.get("metadata") or {}
|
metadata = job.get("metadata") or {}
|
||||||
weight = weight_of(metadata)
|
planned = weight_of(metadata)
|
||||||
material = material_of(metadata)
|
material = material_of(metadata)
|
||||||
state = job.get("status") or ""
|
state = job.get("status") or ""
|
||||||
|
used_mm = usage.filament_used_mm(job)
|
||||||
|
total_mm = usage.filament_total_mm(metadata, job)
|
||||||
|
estimated = float(metadata.get("estimated_time") or 0)
|
||||||
sent = store.latest_send(conn, printer["host"], filename)
|
sent = store.latest_send(conn, printer["host"], filename)
|
||||||
known = store.file_by_name(conn, filename)
|
known = store.file_by_name(conn, filename)
|
||||||
file_id = (sent or {}).get("file_id") or (known or {}).get("id")
|
file_id = (sent or {}).get("file_id") or (known or {}).get("id")
|
||||||
@@ -204,6 +330,23 @@ def sync_history(conn, cfg):
|
|||||||
hinted = (sent or {}).get("spool_id") or (known or {}).get("spool_id")
|
hinted = (sent or {}).get("spool_id") or (known or {}).get("spool_id")
|
||||||
spool = choose_spool(conn, printer["host"], material, hinted)
|
spool = choose_spool(conn, printer["host"], material, hinted)
|
||||||
terminal = state in TERMINAL
|
terminal = state in TERMINAL
|
||||||
|
live = store.find_live_run(conn, printer["host"], filename)
|
||||||
|
progress = float((live or {}).get("progress_last") or 0)
|
||||||
|
if used_mm <= 0 and live:
|
||||||
|
used_mm = float(live.get("filament_used_mm") or 0)
|
||||||
|
if total_mm <= 0 and live:
|
||||||
|
total_mm = float(live.get("filament_total_mm") or 0)
|
||||||
|
if planned <= 0 and live:
|
||||||
|
planned = float(live.get("weight_planned_g") or 0)
|
||||||
|
objects = (live or {}).get("objects") or []
|
||||||
|
if isinstance(objects, str):
|
||||||
|
try:
|
||||||
|
objects = json.loads(objects)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
objects = []
|
||||||
|
used_g, source = usage.resolve_used_grams(
|
||||||
|
planned, used_mm, total_mm, progress, state, job.get("print_duration") or 0
|
||||||
|
)
|
||||||
row = store.upsert_run(conn, {
|
row = store.upsert_run(conn, {
|
||||||
"printer_host": printer["host"],
|
"printer_host": printer["host"],
|
||||||
"printer_name": printer["name"],
|
"printer_name": printer["name"],
|
||||||
@@ -215,13 +358,33 @@ def sync_history(conn, cfg):
|
|||||||
"started_at": unix_to_dt(job.get("start_time")),
|
"started_at": unix_to_dt(job.get("start_time")),
|
||||||
"ended_at": unix_to_dt(job.get("end_time")),
|
"ended_at": unix_to_dt(job.get("end_time")),
|
||||||
"duration_s": float(job.get("print_duration") or job.get("total_duration") or 0),
|
"duration_s": float(job.get("print_duration") or job.get("total_duration") or 0),
|
||||||
"weight_g": weight,
|
"weight_g": planned,
|
||||||
|
"weight_planned_g": planned,
|
||||||
|
"weight_used_g": used_g,
|
||||||
|
"weight_source": source,
|
||||||
|
"filament_used_mm": used_mm,
|
||||||
|
"filament_total_mm": total_mm,
|
||||||
|
"progress_last": progress,
|
||||||
|
"estimated_s": estimated,
|
||||||
"energy_cost": energy_cost(job.get("print_duration") or job.get("total_duration") or 0, cfg["energy"]),
|
"energy_cost": energy_cost(job.get("print_duration") or job.get("total_duration") or 0, cfg["energy"]),
|
||||||
"moonraker_job_id": job_id,
|
"moonraker_job_id": job_id,
|
||||||
"needs_spool": bool(terminal and weight > 0 and not spool),
|
"needs_spool": bool(terminal and used_g > 0 and not spool),
|
||||||
"material_type": material,
|
"material_type": material,
|
||||||
|
"message": (job.get("message") or "") if state == "error" else "",
|
||||||
|
"objects": objects,
|
||||||
})
|
})
|
||||||
if apply_deduction(conn, row, cfg["energy"]):
|
# Live sofort auf History hängen — auch solange noch in_progress (eine Protokoll-Zeile)
|
||||||
|
store.adopt_live_run(conn, printer["host"], filename, row["id"])
|
||||||
|
row = store.get_run_row(conn, row["id"])
|
||||||
|
if terminal:
|
||||||
|
# Nachzug nicht während eines neuen Live-Drucks — sonst „Platte belegt“ zu Unrecht
|
||||||
|
active = store.find_live_run(conn, printer["host"])
|
||||||
|
if not active:
|
||||||
|
ended = row.get("ended_at") or unix_to_dt(job.get("end_time"))
|
||||||
|
store.ensure_bed_after_print(
|
||||||
|
conn, printer["host"], ended_at=ended, message=filename or state
|
||||||
|
)
|
||||||
|
if apply_deduction(conn, row, cfg["energy"], metadata=metadata):
|
||||||
deducted += 1
|
deducted += 1
|
||||||
return {"gelesen": len(collected), "abgezogen": deducted}
|
return {"gelesen": len(collected), "abgezogen": deducted}
|
||||||
|
|
||||||
@@ -373,7 +536,11 @@ def send_file(
|
|||||||
filaments = [{"type": "PLA", "color": ""}]
|
filaments = [{"type": "PLA", "color": ""}]
|
||||||
|
|
||||||
progress("vorbereiten", 30, "Datei vorbereiten…")
|
progress("vorbereiten", 30, "Datei vorbereiten…")
|
||||||
filename, content, plate = library.printer_file(row)
|
filename, content, plate = library.printer_file(
|
||||||
|
row, conn=conn, data_dir=(cfg or {}).get("data_dir")
|
||||||
|
)
|
||||||
|
# CJK/Unicode im Namen → Rinkhals 10115 not found (Upload ≠ Start-Name)
|
||||||
|
filename = moonraker.safe_upload_name(filename)
|
||||||
ace_tools = [item.get("paint_index") for item in mapping] if mapping else None
|
ace_tools = [item.get("paint_index") for item in mapping] if mapping else None
|
||||||
if (row.get("kind") or "") == "gcode" and str(filename).endswith(".gcode"):
|
if (row.get("kind") or "") == "gcode" and str(filename).endswith(".gcode"):
|
||||||
content = prepare_rinkhals_gcode(content, tools=ace_tools)
|
content = prepare_rinkhals_gcode(content, tools=ace_tools)
|
||||||
@@ -484,6 +651,33 @@ def steuern(conn, host, action, params=None):
|
|||||||
printer = store.printer_by_host(conn, host)
|
printer = store.printer_by_host(conn, host)
|
||||||
if not printer:
|
if not printer:
|
||||||
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
|
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
|
||||||
|
params = dict(params) if isinstance(params, dict) else {}
|
||||||
|
if action == "slot_setzen":
|
||||||
|
try:
|
||||||
|
ace_slot = int(params.get("ace_slot"))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise GrottiError("ACE-Platz muss 1–8 sein.", "platz") from exc
|
||||||
|
spool = None
|
||||||
|
for row in store.spools_for_printer(conn, host):
|
||||||
|
raw_slot = row.get("ace_slot")
|
||||||
|
if raw_slot in (None, ""):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if int(raw_slot) == ace_slot:
|
||||||
|
spool = row
|
||||||
|
break
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if not spool:
|
||||||
|
raise GrottiError("Auf diesem Platz liegt keine Spule in Grotti.", "spule")
|
||||||
|
material = (spool.get("material_type") or "").strip()
|
||||||
|
if not material:
|
||||||
|
raise GrottiError("Die Spule braucht eine Sorte.", "material")
|
||||||
|
color = moonraker.spool_rgb(spool)
|
||||||
|
if not color:
|
||||||
|
raise GrottiError("Die Spule braucht eine Farbe.", "farbe")
|
||||||
|
hub_id, index = moonraker.ace_hub_index(ace_slot)
|
||||||
|
params = {"id": hub_id, "index": index, "type": material, "color": color}
|
||||||
try:
|
try:
|
||||||
moonraker.steuern(host, action, params)
|
moonraker.steuern(host, action, params)
|
||||||
except moonraker.PrinterError as exc:
|
except moonraker.PrinterError as exc:
|
||||||
|
|||||||
@@ -142,8 +142,30 @@ def ensure_library_gcode(conn, cfg, host, filename):
|
|||||||
return save_upload(conn, name, content, cfg)
|
return save_upload(conn, name, content, cfg)
|
||||||
|
|
||||||
|
|
||||||
def printer_file(row):
|
def resolve_stored_path(conn, row, data_dir=None):
|
||||||
path = Path(row["stored_path"])
|
"""Gespeicherter Pfad, ggf. nach Umzug von data_dir unter eingang/<name> wiederfinden."""
|
||||||
|
stored = (row or {}).get("stored_path") or ""
|
||||||
|
path = Path(stored) if stored else None
|
||||||
|
if path and path.is_file():
|
||||||
|
return path
|
||||||
|
name = (row or {}).get("name") or (path.name if path else "")
|
||||||
|
if not name or data_dir is None:
|
||||||
|
return None
|
||||||
|
candidate = Path(data_dir) / "eingang" / name
|
||||||
|
if not candidate.is_file():
|
||||||
|
return None
|
||||||
|
resolved = str(candidate.resolve())
|
||||||
|
file_id = (row or {}).get("id")
|
||||||
|
if file_id and conn is not None:
|
||||||
|
conn.execute("UPDATE files SET stored_path = %s WHERE id = %s", (resolved, file_id))
|
||||||
|
row["stored_path"] = resolved
|
||||||
|
return candidate.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def printer_file(row, conn=None, data_dir=None):
|
||||||
|
path = resolve_stored_path(conn, row, data_dir) if (conn is not None or data_dir is not None) else Path(row["stored_path"])
|
||||||
|
if path is None:
|
||||||
|
path = Path(row.get("stored_path") or "")
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
|
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
|
||||||
if row["kind"] == "gcode":
|
if row["kind"] == "gcode":
|
||||||
@@ -153,9 +175,9 @@ def printer_file(row):
|
|||||||
return path.name, path.read_bytes(), row["plate"] or ""
|
return path.name, path.read_bytes(), row["plate"] or ""
|
||||||
|
|
||||||
|
|
||||||
def preview_png(row):
|
def preview_png(row, conn=None, data_dir=None):
|
||||||
path = Path(row["stored_path"])
|
path = resolve_stored_path(conn, row, data_dir) if (conn is not None or data_dir is not None) else Path(row["stored_path"])
|
||||||
if not path.is_file():
|
if path is None or not path.is_file():
|
||||||
return None
|
return None
|
||||||
entry = row["thumb_entry"]
|
entry = row["thumb_entry"]
|
||||||
if row["kind"] == "gcode":
|
if row["kind"] == "gcode":
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -165,18 +165,53 @@ def post_json(host, path, payload, timeout=20):
|
|||||||
raise PrinterError("Der Drucker ist nicht erreichbar.") from exc
|
raise PrinterError("Der Drucker ist nicht erreichbar.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def safe_upload_name(filename):
|
||||||
|
"""Dateiname für Moonraker/Rinkhals/gklib — nur ASCII.
|
||||||
|
|
||||||
|
`filename` vs. `filename*` mit CJK führte zu Upload unter `_` und Start
|
||||||
|
unter dem Originalnamen → Fehler 10115 „gcode … not found“.
|
||||||
|
"""
|
||||||
|
raw = (filename or "").replace("\\", "/")
|
||||||
|
parts = [part for part in raw.split("/") if part]
|
||||||
|
base = parts[-1] if parts else "job.gcode"
|
||||||
|
out = []
|
||||||
|
for ch in base:
|
||||||
|
if ch.isascii() and ch not in '"\\':
|
||||||
|
out.append(ch if ch not in " \t" else "_")
|
||||||
|
else:
|
||||||
|
out.append("_")
|
||||||
|
text = "".join(out)
|
||||||
|
while "__" in text:
|
||||||
|
text = text.replace("__", "_")
|
||||||
|
while "_-" in text or "-_" in text:
|
||||||
|
text = text.replace("_-", "-").replace("-_", "-")
|
||||||
|
text = text.strip("._-") or "job"
|
||||||
|
lower = text.lower()
|
||||||
|
if not (lower.endswith(".gcode") or lower.endswith(".gcode.3mf") or lower.endswith(".3mf") or lower.endswith(".acm")):
|
||||||
|
# Suffix vom Original behalten, falls nur der Stamm zerstört wurde
|
||||||
|
if base.lower().endswith(".gcode.3mf"):
|
||||||
|
text += ".gcode.3mf"
|
||||||
|
elif base.lower().endswith(".gcode"):
|
||||||
|
text += ".gcode"
|
||||||
|
elif base.lower().endswith(".acm"):
|
||||||
|
text += ".acm"
|
||||||
|
if parts:
|
||||||
|
parts[-1] = text
|
||||||
|
return "/".join(parts)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def multipart(filename, content):
|
def multipart(filename, content):
|
||||||
boundary = "----GrottiBoundary7f3a"
|
boundary = "----GrottiBoundary7f3a"
|
||||||
ascii_name = "".join(ch if ch.isascii() and ch not in '"\\' else "_" for ch in filename) or "job.gcode"
|
name = safe_upload_name(filename)
|
||||||
quoted = urllib.parse.quote(filename)
|
|
||||||
fields = (
|
fields = (
|
||||||
"--%s\r\nContent-Disposition: form-data; name=\"root\"\r\n\r\ngcodes\r\n" % boundary
|
"--%s\r\nContent-Disposition: form-data; name=\"root\"\r\n\r\ngcodes\r\n" % boundary
|
||||||
).encode("ascii")
|
).encode("ascii")
|
||||||
head = (
|
head = (
|
||||||
"--%s\r\n"
|
"--%s\r\n"
|
||||||
"Content-Disposition: form-data; name=\"file\"; filename=\"%s\"; filename*=UTF-8''%s\r\n"
|
"Content-Disposition: form-data; name=\"file\"; filename=\"%s\"\r\n"
|
||||||
"Content-Type: application/octet-stream\r\n\r\n"
|
"Content-Type: application/octet-stream\r\n\r\n"
|
||||||
) % (boundary, ascii_name, quoted)
|
) % (boundary, name)
|
||||||
tail = ("\r\n--%s--\r\n" % boundary).encode("ascii")
|
tail = ("\r\n--%s--\r\n" % boundary).encode("ascii")
|
||||||
return boundary, fields + head.encode("ascii") + content + tail
|
return boundary, fields + head.encode("ascii") + content + tail
|
||||||
|
|
||||||
@@ -217,6 +252,7 @@ def upload_timeout(size):
|
|||||||
|
|
||||||
|
|
||||||
def upload(host, filename, content, on_progress=None):
|
def upload(host, filename, content, on_progress=None):
|
||||||
|
filename = safe_upload_name(filename)
|
||||||
boundary, body = multipart(filename, content)
|
boundary, body = multipart(filename, content)
|
||||||
stream = _UploadBody(body, on_progress=on_progress)
|
stream = _UploadBody(body, on_progress=on_progress)
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
@@ -236,7 +272,8 @@ def upload(host, filename, content, on_progress=None):
|
|||||||
except (urllib.error.URLError, TimeoutError) as exc:
|
except (urllib.error.URLError, TimeoutError) as exc:
|
||||||
raise PrinterError("Hochladen ist fehlgeschlagen.") from exc
|
raise PrinterError("Hochladen ist fehlgeschlagen.") from exc
|
||||||
item = (payload.get("result") or {}).get("item") or {}
|
item = (payload.get("result") or {}).get("item") or {}
|
||||||
return item.get("path") or filename
|
# Immer denselben ASCII-Namen nutzen, den wir hochgeladen haben
|
||||||
|
return safe_upload_name(item.get("path") or filename)
|
||||||
|
|
||||||
|
|
||||||
def start_print(host, filename, bed_leveling=False, ams_settings=None):
|
def start_print(host, filename, bed_leveling=False, ams_settings=None):
|
||||||
@@ -246,12 +283,14 @@ def start_print(host, filename, bed_leveling=False, ams_settings=None):
|
|||||||
MQTT selbst (auto_leveling aus App-Property; ams_settings aus .acm /
|
MQTT selbst (auto_leveling aus App-Property; ams_settings aus .acm /
|
||||||
Identity-Fallback). Bei bed_leveling oder gesetzter Grotti-ams_settings
|
Identity-Fallback). Bei bed_leveling oder gesetzter Grotti-ams_settings
|
||||||
startet Grotti per MQTTS :9883 und legt die Map explizit in die Payload.
|
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)
|
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
|
from grotti import anycubic_lan
|
||||||
|
|
||||||
|
if use_mqtt:
|
||||||
try:
|
try:
|
||||||
return anycubic_lan.start_print(
|
return anycubic_lan.start_print(
|
||||||
host,
|
host,
|
||||||
@@ -262,6 +301,12 @@ def start_print(host, filename, bed_leveling=False, ams_settings=None):
|
|||||||
except anycubic_lan.LanError as exc:
|
except anycubic_lan.LanError as exc:
|
||||||
raise PrinterError(exc.message) from exc
|
raise PrinterError(exc.message) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
anycubic_lan.ensure_task_free(host)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return post_json(host, "/printer/print/start", {"filename": filename})
|
||||||
|
|
||||||
|
|
||||||
def command(host, action):
|
def command(host, action):
|
||||||
path = {
|
path = {
|
||||||
@@ -373,6 +418,64 @@ def steuern(host, action, params=None):
|
|||||||
if not re.fullmatch(r"[A-Za-z0-9._+\-]+", name):
|
if not re.fullmatch(r"[A-Za-z0-9._+\-]+", name):
|
||||||
raise PrinterError("Ungültiger Objektname.")
|
raise PrinterError("Ungültiger Objektname.")
|
||||||
return gcode_script(host, "EXCLUDE_OBJECT NAME=%s" % name, timeout=20)
|
return gcode_script(host, "EXCLUDE_OBJECT NAME=%s" % name, timeout=20)
|
||||||
|
if action == "dryer_start":
|
||||||
|
try:
|
||||||
|
unit = int(params.get("id"))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise PrinterError("ACE-Box muss 0 oder 1 sein.") from exc
|
||||||
|
if unit not in (0, 1):
|
||||||
|
raise PrinterError("ACE-Box muss 0 oder 1 sein.")
|
||||||
|
try:
|
||||||
|
temp = int(round(float(params.get("temp"))))
|
||||||
|
duration = int(round(float(params.get("duration"))))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise PrinterError("Temperatur und Dauer müssen Zahlen sein.") from exc
|
||||||
|
if temp < 35 or temp > 65:
|
||||||
|
raise PrinterError("Trockner-Temperatur außerhalb 35–65 °C.")
|
||||||
|
if duration < 1 or duration > 720:
|
||||||
|
raise PrinterError("Trockner-Dauer außerhalb 1–720 Minuten.")
|
||||||
|
return post_json(
|
||||||
|
host,
|
||||||
|
"/server/filament_hub/start_drying",
|
||||||
|
{"id": unit, "temp": temp, "duration": duration, "fan_speed": 0},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
if action == "dryer_stop":
|
||||||
|
try:
|
||||||
|
unit = int(params.get("id"))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise PrinterError("ACE-Box muss 0 oder 1 sein.") from exc
|
||||||
|
if unit not in (0, 1):
|
||||||
|
raise PrinterError("ACE-Box muss 0 oder 1 sein.")
|
||||||
|
return post_json(host, "/server/filament_hub/stop_drying", {"id": unit}, timeout=15)
|
||||||
|
if action == "slot_setzen":
|
||||||
|
hub_id = _int_or_none(params.get("id"))
|
||||||
|
index = _int_or_none(params.get("index"))
|
||||||
|
if hub_id not in (0, 1) or index is None or index < 0 or index > 3:
|
||||||
|
raise PrinterError("ACE-Platz ungültig.")
|
||||||
|
material = str(params.get("type") or "").strip()
|
||||||
|
if not material:
|
||||||
|
raise PrinterError("Die Spule braucht eine Sorte.")
|
||||||
|
color = params.get("color")
|
||||||
|
if not isinstance(color, dict):
|
||||||
|
raise PrinterError("Die Spule braucht eine Farbe.")
|
||||||
|
try:
|
||||||
|
red = max(0, min(255, int(color.get("R"))))
|
||||||
|
green = max(0, min(255, int(color.get("G"))))
|
||||||
|
blue = max(0, min(255, int(color.get("B"))))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise PrinterError("Die Spule braucht eine Farbe.") from exc
|
||||||
|
return post_json(
|
||||||
|
host,
|
||||||
|
"/printer/filament_hub/set_filament_info",
|
||||||
|
{
|
||||||
|
"id": hub_id,
|
||||||
|
"index": index,
|
||||||
|
"type": material,
|
||||||
|
"color": {"R": red, "G": green, "B": blue},
|
||||||
|
},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
if action == "firmware_restart":
|
if action == "firmware_restart":
|
||||||
# GoKlipper/Rinkhals: FIRMWARE_RESTART deadlocked gklib (Rinkhals #32).
|
# GoKlipper/Rinkhals: FIRMWARE_RESTART deadlocked gklib (Rinkhals #32).
|
||||||
raise PrinterError(
|
raise PrinterError(
|
||||||
@@ -555,6 +658,80 @@ def extract_slots(status):
|
|||||||
return found[:8]
|
return found[:8]
|
||||||
|
|
||||||
|
|
||||||
|
def _int_or_none(value):
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
|
return None
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_dryers(status):
|
||||||
|
"""Echte ACE-Boxen aus mmu_machine (num_gates > 0), Dummy-Unit bleibt weg."""
|
||||||
|
machine = (status or {}).get("mmu_machine")
|
||||||
|
if not isinstance(machine, dict):
|
||||||
|
return []
|
||||||
|
found = []
|
||||||
|
for unit_id in (0, 1):
|
||||||
|
unit = machine.get("unit_%d" % unit_id)
|
||||||
|
if not isinstance(unit, dict):
|
||||||
|
continue
|
||||||
|
gates = _int_or_none(unit.get("num_gates"))
|
||||||
|
if gates is None or gates < 1:
|
||||||
|
continue
|
||||||
|
found.append({
|
||||||
|
"id": unit_id,
|
||||||
|
"status": str(unit.get("dryer_status") or "stop"),
|
||||||
|
"temp": _int_or_none(unit.get("dryer_temp")) or 0,
|
||||||
|
"target": _int_or_none(unit.get("dryer_target_temp")) or 0,
|
||||||
|
"remain": _int_or_none(unit.get("dryer_remaining")) or 0,
|
||||||
|
"humidity": _int_or_none(unit.get("dryer_humidity")) or 0,
|
||||||
|
"gates": gates,
|
||||||
|
})
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def ace_hub_index(ace_slot):
|
||||||
|
"""Grotti-Platz 1–8 → Moonraker id (0/1) und index in der Box (0–3)."""
|
||||||
|
try:
|
||||||
|
slot = int(ace_slot)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise PrinterError("ACE-Platz muss 1–8 sein.") from exc
|
||||||
|
if slot < 1 or slot > 8:
|
||||||
|
raise PrinterError("ACE-Platz muss 1–8 sein.")
|
||||||
|
return (slot - 1) // 4, (slot - 1) % 4
|
||||||
|
|
||||||
|
|
||||||
|
def spool_rgb(spool):
|
||||||
|
"""Erste Farbe der Spule als RGB-Dict für set_filament_info."""
|
||||||
|
colors = (spool or {}).get("colors") or []
|
||||||
|
if isinstance(colors, str):
|
||||||
|
try:
|
||||||
|
colors = json.loads(colors)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
colors = []
|
||||||
|
hex_value = ""
|
||||||
|
if isinstance(colors, list):
|
||||||
|
for item in colors:
|
||||||
|
if isinstance(item, dict) and item.get("hex"):
|
||||||
|
hex_value = str(item["hex"]).strip()
|
||||||
|
break
|
||||||
|
if isinstance(item, str) and item.strip():
|
||||||
|
hex_value = item.strip()
|
||||||
|
break
|
||||||
|
if not hex_value:
|
||||||
|
hex_value = str((spool or {}).get("color") or "").strip()
|
||||||
|
if hex_value.startswith("#"):
|
||||||
|
hex_value = hex_value[1:]
|
||||||
|
if len(hex_value) == 8:
|
||||||
|
hex_value = hex_value[:6]
|
||||||
|
if len(hex_value) != 6 or any(ch not in "0123456789abcdefABCDEF" for ch in hex_value):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"R": int(hex_value[0:2], 16),
|
||||||
|
"G": int(hex_value[2:4], 16),
|
||||||
|
"B": int(hex_value[4:6], 16),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def slots(host, status=None):
|
def slots(host, status=None):
|
||||||
if status is not None:
|
if status is not None:
|
||||||
found = extract_slots(status)
|
found = extract_slots(status)
|
||||||
@@ -823,6 +1000,8 @@ def query(host):
|
|||||||
extras = []
|
extras = []
|
||||||
if "probe" in names:
|
if "probe" in names:
|
||||||
extras.append("probe")
|
extras.append("probe")
|
||||||
|
if "mmu_machine" in names:
|
||||||
|
extras.append("mmu_machine")
|
||||||
url = "http://%s/printer/objects/query?%s" % (host, "&".join([base] + extras))
|
url = "http://%s/printer/objects/query?%s" % (host, "&".join([base] + extras))
|
||||||
payload = fetch_json(url)
|
payload = fetch_json(url)
|
||||||
status = payload["result"]["status"]
|
status = payload["result"]["status"]
|
||||||
@@ -830,6 +1009,21 @@ def query(host):
|
|||||||
card = status.get("virtual_sdcard") or {}
|
card = status.get("virtual_sdcard") or {}
|
||||||
extruder = status.get("extruder") or {}
|
extruder = status.get("extruder") or {}
|
||||||
bed = status.get("heater_bed") or {}
|
bed = status.get("heater_bed") or {}
|
||||||
|
mmu = status.get("mmu") or {}
|
||||||
|
mmu_tool = mmu.get("tool")
|
||||||
|
if not isinstance(mmu_tool, (int, float)) or isinstance(mmu_tool, bool):
|
||||||
|
mmu_tool = mmu.get("gate")
|
||||||
|
if isinstance(mmu_tool, (int, float)) and not isinstance(mmu_tool, bool):
|
||||||
|
mmu_tool = int(mmu_tool)
|
||||||
|
if mmu_tool < 0:
|
||||||
|
mmu_tool = None
|
||||||
|
else:
|
||||||
|
mmu_tool = None
|
||||||
|
filament_used = stats.get("filament_used")
|
||||||
|
if not isinstance(filament_used, (int, float)) or isinstance(filament_used, bool):
|
||||||
|
filament_used = None
|
||||||
|
else:
|
||||||
|
filament_used = float(filament_used)
|
||||||
raw_name = stats.get("filename") or ""
|
raw_name = stats.get("filename") or ""
|
||||||
filename = raw_name.replace("\\", "/").rsplit("/", 1)[-1] if raw_name else ""
|
filename = raw_name.replace("\\", "/").rsplit("/", 1)[-1] if raw_name else ""
|
||||||
meta = metadata(host, raw_name) if raw_name else {}
|
meta = metadata(host, raw_name) if raw_name else {}
|
||||||
@@ -888,8 +1082,15 @@ def query(host):
|
|||||||
"bed_target": bed.get("target"),
|
"bed_target": bed.get("target"),
|
||||||
"chamber": chamber,
|
"chamber": chamber,
|
||||||
"chamber_target": chamber_target,
|
"chamber_target": chamber_target,
|
||||||
|
"filament_used": filament_used,
|
||||||
|
"filament_total": meta.get("filament_total") or 0,
|
||||||
|
"mmu_tool": mmu_tool,
|
||||||
"material": card.get("filament_type") or meta.get("filament_type") or "",
|
"material": card.get("filament_type") or meta.get("filament_type") or "",
|
||||||
"weight": meta.get("filament_weight_total") or 0,
|
"weight": (
|
||||||
|
sum(float(x or 0) for x in meta["filament_weight_total"])
|
||||||
|
if isinstance(meta.get("filament_weight_total"), list)
|
||||||
|
else (meta.get("filament_weight_total") or 0)
|
||||||
|
),
|
||||||
"height": meta.get("object_height") or 0,
|
"height": meta.get("object_height") or 0,
|
||||||
"layer_height": meta.get("layer_height") or card.get("layer_height") or 0,
|
"layer_height": meta.get("layer_height") or card.get("layer_height") or 0,
|
||||||
"estimated": meta.get("estimated_time") or 0,
|
"estimated": meta.get("estimated_time") or 0,
|
||||||
@@ -899,7 +1100,7 @@ def query(host):
|
|||||||
"/api/v1/vorschau?host=%s&embedded=%s" % (quoted_host, urllib.parse.quote(raw_name))
|
"/api/v1/vorschau?host=%s&embedded=%s" % (quoted_host, urllib.parse.quote(raw_name))
|
||||||
if raw_name and EMBEDDED.get((host, raw_name)) else ""
|
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),
|
**mesh_fields(host, shown, mesh_profile),
|
||||||
"fan": fan_speed,
|
"fan": fan_speed,
|
||||||
"fan_rpm": fan_rpm,
|
"fan_rpm": fan_rpm,
|
||||||
@@ -907,10 +1108,22 @@ def query(host):
|
|||||||
"speed_factor": speed_factor,
|
"speed_factor": speed_factor,
|
||||||
"extrude_factor": extrude_factor,
|
"extrude_factor": extrude_factor,
|
||||||
"slots": slots(host, status),
|
"slots": slots(host, status),
|
||||||
|
"dryers": extract_dryers(status),
|
||||||
"exclude": exclude_status(host),
|
"exclude": exclude_status(host),
|
||||||
|
"anycubic_task": _anycubic_task_field(host),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 snapshot(printers):
|
||||||
def one(printer):
|
def one(printer):
|
||||||
card = {
|
card = {
|
||||||
@@ -927,6 +1140,8 @@ def snapshot(printers):
|
|||||||
"ok": False,
|
"ok": False,
|
||||||
"state": "offline",
|
"state": "offline",
|
||||||
"message": str(error),
|
"message": str(error),
|
||||||
|
"camera": "",
|
||||||
|
"has_camera": False,
|
||||||
"fan": None,
|
"fan": None,
|
||||||
"fan_rpm": None,
|
"fan_rpm": None,
|
||||||
"fans": {"part": None, "chamber": None, "aux": None},
|
"fans": {"part": None, "chamber": None, "aux": None},
|
||||||
@@ -935,6 +1150,8 @@ def snapshot(printers):
|
|||||||
"speed_factor": None,
|
"speed_factor": None,
|
||||||
"extrude_factor": None,
|
"extrude_factor": None,
|
||||||
"slots": [],
|
"slots": [],
|
||||||
|
"dryers": [],
|
||||||
|
"anycubic_task": None,
|
||||||
})
|
})
|
||||||
card.update(mesh_fields(printer["host"]))
|
card.update(mesh_fields(printer["host"]))
|
||||||
return card
|
return card
|
||||||
@@ -966,5 +1183,21 @@ def camera_image(host):
|
|||||||
return CAMERA_CACHE[host][2]
|
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):
|
def embedded_bytes(host, name):
|
||||||
return EMBEDDED.get((host, name))
|
return EMBEDDED.get((host, name))
|
||||||
@@ -43,11 +43,11 @@ def alert_enabled(cfg, kind):
|
|||||||
return bool(alerts.get(key, key not in DEFAULT_OFF))
|
return bool(alerts.get(key, key not in DEFAULT_OFF))
|
||||||
|
|
||||||
|
|
||||||
def record(conn, cfg, host, printer, kind, message, filename="", dedupe_s=60):
|
def record(conn, cfg, host, printer, kind, message, filename="", dedupe_s=60, run_id=None):
|
||||||
"""Ereignis immer speichern; Telegram nur einmal pro Art und Drucker im Zeitfenster."""
|
"""Ereignis immer speichern; Telegram nur einmal pro Art und Drucker im Zeitfenster."""
|
||||||
from grotti import store
|
from grotti import store
|
||||||
|
|
||||||
recent = store.recent_event_kind(conn, host, kind, within_s=dedupe_s)
|
recent = store.recent_event_kind(conn, host, kind, within_s=dedupe_s)
|
||||||
store.add_event(conn, host, printer, kind, message, filename or "")
|
store.add_event(conn, host, printer, kind, message, filename or "", run_id=run_id)
|
||||||
if not recent and alert_enabled(cfg, kind):
|
if not recent and alert_enabled(cfg, kind):
|
||||||
send_telegram(cfg, message)
|
send_telegram(cfg, message)
|
||||||
@@ -180,3 +180,121 @@ CREATE TABLE IF NOT EXISTS bed_status (
|
|||||||
cleared_at timestamptz,
|
cleared_at timestamptz,
|
||||||
message text NOT NULL DEFAULT ''
|
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);
|
||||||
@@ -8,8 +8,8 @@ MOVE = re.compile(r"^(?:G0|G00|G1|G01)\b(.*)$", re.IGNORECASE)
|
|||||||
# Auch E.39 / Z.6 ohne führende Null (Anycubic/Prusa-Stil)
|
# Auch E.39 / Z.6 ohne führende Null (Anycubic/Prusa-Stil)
|
||||||
AXIS = re.compile(r"([XYZE])\s*(-?\d*\.?\d+)", re.IGNORECASE)
|
AXIS = re.compile(r"([XYZE])\s*(-?\d*\.?\d+)", re.IGNORECASE)
|
||||||
|
|
||||||
# Cache-/Viewer-Version: Segment-IDs, kein Sehnen-Thinning
|
# Cache-/Viewer-Version: ganze Z-Höhen dicht behalten (keine gesprenkelten Stichproben)
|
||||||
TOOLPATH_VERSION = 5
|
TOOLPATH_VERSION = 8
|
||||||
|
|
||||||
|
|
||||||
def _gcode_text(path: Path) -> str:
|
def _gcode_text(path: Path) -> str:
|
||||||
@@ -28,48 +28,89 @@ def _gcode_text(path: Path) -> str:
|
|||||||
return path.read_text(encoding="utf-8", errors="replace")
|
return path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _z_key(z):
|
||||||
|
return round(float(z), 2)
|
||||||
|
|
||||||
|
|
||||||
def _thin_points(points, layer_breaks, max_points):
|
def _thin_points(points, layer_breaks, max_points):
|
||||||
"""Extrusions-Segmente stichprobenartig behalten — nie Endpunkte einer Bahn
|
"""Pfad kürzen, ohne Wände zu sprenkeln.
|
||||||
weglassen und die Nachbarn verbinden (sonst Sehnen statt Infill)."""
|
|
||||||
|
Strategie: ganze Z-Höhen (Druckschichten) vollständig behalten und nur
|
||||||
|
ganze Höhen weglassen. Stichproben entlang der Bahn erzeugen Lücken.
|
||||||
|
"""
|
||||||
if len(points) <= max_points:
|
if len(points) <= max_points:
|
||||||
return points, layer_breaks
|
return points, layer_breaks
|
||||||
|
|
||||||
extrude_ends = [i for i in range(1, len(points)) if points[i][3]]
|
# Nur Z-Höhen mit Extrusion — Z-Hops allein zählen nicht
|
||||||
keep = {0, len(points) - 1}
|
z_order = []
|
||||||
for break_i in layer_breaks:
|
seen = set()
|
||||||
keep.add(max(0, min(len(points) - 1, break_i)))
|
for point in points:
|
||||||
if break_i > 0:
|
if not point[3]:
|
||||||
keep.add(break_i - 1)
|
continue
|
||||||
|
key = _z_key(point[2])
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
z_order.append(key)
|
||||||
|
if not z_order:
|
||||||
|
return points[:max_points], [0]
|
||||||
|
|
||||||
# Budget für Segment-Endpunkte (jedes Segment braucht 2 Indizes, viele teilen sich)
|
def keep_for_zs(keep_zs):
|
||||||
target_segs = max(2000, max_points // 2)
|
keep = {0, len(points) - 1}
|
||||||
step = max(1, (len(extrude_ends) + target_segs - 1) // target_segs)
|
for i, point in enumerate(points):
|
||||||
for j, end_i in enumerate(extrude_ends):
|
if _z_key(point[2]) in keep_zs:
|
||||||
if j % step == 0:
|
keep.add(i)
|
||||||
|
if i > 0:
|
||||||
|
keep.add(i - 1)
|
||||||
|
return sorted(keep)
|
||||||
|
|
||||||
|
# So viele Höhen wie ins Budget passen (Schätzung über mittlere Punkte/Höhe)
|
||||||
|
per_z = max(1, len(points) // max(1, len(z_order)))
|
||||||
|
max_zs = max(2, min(len(z_order), max_points // per_z))
|
||||||
|
stride = max(1, (len(z_order) + max_zs - 1) // max_zs)
|
||||||
|
|
||||||
|
def pick_zs(step):
|
||||||
|
chosen = {z_order[0], z_order[-1]}
|
||||||
|
for i in range(0, len(z_order), step):
|
||||||
|
chosen.add(z_order[i])
|
||||||
|
return chosen
|
||||||
|
|
||||||
|
step = stride
|
||||||
|
ordered = keep_for_zs(pick_zs(step))
|
||||||
|
while len(ordered) > max_points and step < len(z_order):
|
||||||
|
step += 1
|
||||||
|
ordered = keep_for_zs(pick_zs(step))
|
||||||
|
|
||||||
|
# Immer noch zu groß: innerhalb behaltener Höhen jedes n-te Extrusionssegment
|
||||||
|
if len(ordered) > max_points:
|
||||||
|
keep_zs = pick_zs(step)
|
||||||
|
segs = [
|
||||||
|
i
|
||||||
|
for i in range(1, len(points))
|
||||||
|
if points[i][3] and _z_key(points[i][2]) in keep_zs
|
||||||
|
]
|
||||||
|
budget = max(1, (max_points - 4) // 2)
|
||||||
|
seg_step = max(1, (len(segs) + budget - 1) // budget)
|
||||||
|
keep = {0, len(points) - 1}
|
||||||
|
for end_i in segs[::seg_step]:
|
||||||
keep.add(end_i - 1)
|
keep.add(end_i - 1)
|
||||||
keep.add(end_i)
|
keep.add(end_i)
|
||||||
|
|
||||||
ordered = sorted(keep)
|
ordered = sorted(keep)
|
||||||
# Falls immer noch zu groß: Segmente grober
|
|
||||||
if len(ordered) > max_points:
|
|
||||||
step2 = max(1, (len(ordered) + max_points - 1) // max_points)
|
|
||||||
ordered = ordered[::step2]
|
|
||||||
if ordered[-1] != len(points) - 1:
|
|
||||||
ordered.append(len(points) - 1)
|
|
||||||
|
|
||||||
index_map = {old: new for new, old in enumerate(ordered)}
|
index_map = {old: new for new, old in enumerate(ordered)}
|
||||||
kept = [points[i] for i in ordered]
|
kept = [points[i] for i in ordered]
|
||||||
new_breaks = sorted({
|
new_breaks = []
|
||||||
index_map[b]
|
last_z = None
|
||||||
for b in layer_breaks
|
for new_i, point in enumerate(kept):
|
||||||
if b in index_map
|
z = _z_key(point[2])
|
||||||
})
|
if last_z is None or abs(z - last_z) > 0.01:
|
||||||
|
new_breaks.append(new_i)
|
||||||
|
last_z = z
|
||||||
if not new_breaks:
|
if not new_breaks:
|
||||||
new_breaks = [0]
|
new_breaks = [0]
|
||||||
return kept, new_breaks
|
return kept, new_breaks
|
||||||
|
|
||||||
|
|
||||||
def build_toolpath(path: Path, max_points=120000):
|
def build_toolpath(path: Path, max_points=220000):
|
||||||
text = _gcode_text(path)
|
text = _gcode_text(path)
|
||||||
if not text:
|
if not text:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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,3 +1,3 @@
|
|||||||
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
|
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
|
||||||
|
|
||||||
__version__ = "2.0.0"
|
__version__ = "2.5.1"
|
||||||
@@ -4,10 +4,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from grotti import moonraker, notify, store
|
from grotti import moonraker, notify, store, usage
|
||||||
from grotti.db import connect
|
from grotti.db import connect
|
||||||
|
|
||||||
|
SAMPLE_EVERY_S = 30
|
||||||
|
|
||||||
|
|
||||||
class StateWatcher:
|
class StateWatcher:
|
||||||
def __init__(self, database_url, cfg, interval=10):
|
def __init__(self, database_url, cfg, interval=10):
|
||||||
@@ -21,6 +24,7 @@ class StateWatcher:
|
|||||||
self._updated_at = 0.0
|
self._updated_at = 0.0
|
||||||
self._stop = threading.Event()
|
self._stop = threading.Event()
|
||||||
self._thread = None
|
self._thread = None
|
||||||
|
self._last_sample = {}
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
if self._thread and self._thread.is_alive():
|
if self._thread and self._thread.is_alive():
|
||||||
@@ -79,6 +83,7 @@ class StateWatcher:
|
|||||||
prev = self._prev.get(host) or {}
|
prev = self._prev.get(host) or {}
|
||||||
prev_state = (prev.get("state") or "").lower()
|
prev_state = (prev.get("state") or "").lower()
|
||||||
prev_offline = bool(prev.get("offline"))
|
prev_offline = bool(prev.get("offline"))
|
||||||
|
run_id = None
|
||||||
|
|
||||||
if offline:
|
if offline:
|
||||||
hits = self._offline_hits.get(host, 0) + 1
|
hits = self._offline_hits.get(host, 0) + 1
|
||||||
@@ -89,16 +94,32 @@ class StateWatcher:
|
|||||||
return
|
return
|
||||||
|
|
||||||
self._offline_hits[host] = 0
|
self._offline_hits[host] = 0
|
||||||
|
starting = (
|
||||||
|
bool(prev_state)
|
||||||
|
and prev_state not in ("printing", "paused")
|
||||||
|
and state == "printing"
|
||||||
|
)
|
||||||
|
if starting:
|
||||||
|
store.abandon_live_runs(conn, host)
|
||||||
|
if state in ("printing", "paused"):
|
||||||
|
run = self._track_print(conn, host, name, filename, live, state)
|
||||||
|
run_id = run["id"] if run else None
|
||||||
|
|
||||||
if prev_offline:
|
if prev_offline:
|
||||||
self._emit(conn, host, name, "offline", "%s ist wieder erreichbar." % name, filename)
|
self._emit(conn, host, name, "offline", "%s ist wieder erreichbar." % name, filename, run_id)
|
||||||
elif prev_state and prev_state not in ("printing", "paused") and state == "printing":
|
elif starting:
|
||||||
self._emit(conn, host, name, "start", "%s druckt %s" % (name, filename or "einen Auftrag"), filename)
|
self._emit(
|
||||||
|
conn, host, name, "start",
|
||||||
|
"%s druckt %s" % (name, filename or "einen Auftrag"),
|
||||||
|
filename, run_id,
|
||||||
|
)
|
||||||
|
self._ensure_start_segment(conn, host, filename, live, run_id)
|
||||||
elif prev_state == "printing" and state == "paused":
|
elif prev_state == "printing" and state == "paused":
|
||||||
self._emit(conn, host, name, "start", "%s pausiert." % name, filename)
|
self._emit(conn, host, name, "start", "%s pausiert." % name, filename, run_id)
|
||||||
elif prev_state == "paused" and state == "printing":
|
elif prev_state == "paused" and state == "printing":
|
||||||
self._emit(conn, host, name, "start", "%s druckt weiter." % name, filename)
|
self._emit(conn, host, name, "start", "%s druckt weiter." % name, filename, run_id)
|
||||||
elif prev_state == "printing" and state == "complete":
|
elif prev_state == "printing" and state == "complete":
|
||||||
store.set_bed_needs_clear(conn, host, True, filename or "fertig")
|
store.ensure_bed_after_print(conn, host, message=filename or "fertig")
|
||||||
self._emit(
|
self._emit(
|
||||||
conn,
|
conn,
|
||||||
host,
|
host,
|
||||||
@@ -106,8 +127,8 @@ class StateWatcher:
|
|||||||
"fertig",
|
"fertig",
|
||||||
"%s fertig: %s" % (name, filename or "Druck"),
|
"%s fertig: %s" % (name, filename or "Druck"),
|
||||||
filename,
|
filename,
|
||||||
|
run_id or (prev.get("run_id")),
|
||||||
)
|
)
|
||||||
# Protokoll und Spulenabzug nicht erst nach manuellem Knopf
|
|
||||||
try:
|
try:
|
||||||
from grotti import jobs
|
from grotti import jobs
|
||||||
|
|
||||||
@@ -115,7 +136,7 @@ class StateWatcher:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
||||||
elif prev_state in ("printing", "paused") and state in ("cancelled", "standby"):
|
elif prev_state in ("printing", "paused") and state in ("cancelled", "standby"):
|
||||||
store.set_bed_needs_clear(conn, host, True, filename or state)
|
store.ensure_bed_after_print(conn, host, message=filename or state)
|
||||||
self._emit(
|
self._emit(
|
||||||
conn,
|
conn,
|
||||||
host,
|
host,
|
||||||
@@ -123,10 +144,17 @@ class StateWatcher:
|
|||||||
"abbruch",
|
"abbruch",
|
||||||
"%s: Druck abgebrochen (%s)" % (name, filename or "ohne Datei"),
|
"%s: Druck abgebrochen (%s)" % (name, filename or "ohne Datei"),
|
||||||
filename,
|
filename,
|
||||||
|
run_id or prev.get("run_id"),
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
from grotti import jobs
|
||||||
|
|
||||||
|
jobs.sync_history(conn, self.cfg)
|
||||||
|
except Exception as exc:
|
||||||
|
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
||||||
elif state == "error" and prev_state != "error":
|
elif state == "error" and prev_state != "error":
|
||||||
if prev_state == "printing" or prev_state == "paused":
|
if prev_state == "printing" or prev_state == "paused":
|
||||||
store.set_bed_needs_clear(conn, host, True, filename or "Fehler")
|
store.ensure_bed_after_print(conn, host, message=filename or "Fehler")
|
||||||
msg = (live.get("message") or "").strip()
|
msg = (live.get("message") or "").strip()
|
||||||
if not msg or msg.lower() in ("error", "fehler"):
|
if not msg or msg.lower() in ("error", "fehler"):
|
||||||
msg = "Ursache unbekannt — in Mainsail prüfen"
|
msg = "Ursache unbekannt — in Mainsail prüfen"
|
||||||
@@ -138,18 +166,19 @@ class StateWatcher:
|
|||||||
"fehler",
|
"fehler",
|
||||||
"%s: %s · Heizung aus" % (name, msg),
|
"%s: %s · Heizung aus" % (name, msg),
|
||||||
filename,
|
filename,
|
||||||
|
run_id or prev.get("run_id"),
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
from grotti import jobs
|
||||||
|
|
||||||
|
jobs.sync_history(conn, self.cfg)
|
||||||
|
except Exception as exc:
|
||||||
|
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
||||||
elif state == "error":
|
elif state == "error":
|
||||||
# Schon im Fehler, Heizer laufen noch → aus
|
|
||||||
if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0:
|
if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0:
|
||||||
self._cool_heaters(host)
|
self._cool_heaters(host)
|
||||||
elif state == "complete":
|
elif state == "complete" and (not prev_state or prev_offline):
|
||||||
known = conn.execute(
|
store.ensure_bed_after_print(conn, host, message=filename or "fertig")
|
||||||
"SELECT 1 FROM bed_status WHERE host = %s", (host,)
|
|
||||||
).fetchone()
|
|
||||||
if not known:
|
|
||||||
# Erster Stand nach Deploy: fertiger Druck ohne Quittierung
|
|
||||||
store.set_bed_needs_clear(conn, host, True, filename or "fertig")
|
|
||||||
|
|
||||||
spool_warn = bool(prev.get("spool_warn"))
|
spool_warn = bool(prev.get("spool_warn"))
|
||||||
if state == "printing" and not spool_warn:
|
if state == "printing" and not spool_warn:
|
||||||
@@ -160,8 +189,108 @@ class StateWatcher:
|
|||||||
"offline": False,
|
"offline": False,
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
"spool_warn": spool_warn if state == "printing" else False,
|
"spool_warn": spool_warn if state == "printing" else False,
|
||||||
|
"run_id": run_id or (prev.get("run_id") if state in ("printing", "paused") else None),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _track_print(self, conn, host, name, filename, live, state):
|
||||||
|
sent = store.latest_send(conn, host, filename) if filename else None
|
||||||
|
spool_id = (sent or {}).get("spool_id")
|
||||||
|
run = store.ensure_live_run(conn, host, name, filename, spool_id=spool_id)
|
||||||
|
objects = self._merge_objects(run.get("objects"), live.get("exclude"))
|
||||||
|
planned = usage.as_float(live.get("weight"))
|
||||||
|
total_mm = usage.as_float(live.get("filament_total"))
|
||||||
|
used_mm = usage.as_float(live.get("filament_used"))
|
||||||
|
progress = usage.as_float(live.get("progress"))
|
||||||
|
estimated = usage.as_float(live.get("estimated"))
|
||||||
|
store.update_run_progress(conn, run["id"], {
|
||||||
|
"state": "paused" if state == "paused" else "in_progress",
|
||||||
|
"progress_last": progress,
|
||||||
|
"filament_used_mm": used_mm,
|
||||||
|
"filament_total_mm": total_mm,
|
||||||
|
"weight_planned_g": planned,
|
||||||
|
"estimated_s": estimated,
|
||||||
|
"message": (live.get("message") or "") if state == "error" else "",
|
||||||
|
"objects": objects,
|
||||||
|
"duration_s": usage.as_float(live.get("elapsed")),
|
||||||
|
})
|
||||||
|
store.ensure_run_chargen(conn, run["id"])
|
||||||
|
self._maybe_sample(conn, run["id"], live, used_mm)
|
||||||
|
return store.get_run_row(conn, run["id"])
|
||||||
|
|
||||||
|
def _merge_objects(self, current, exclude):
|
||||||
|
known = {}
|
||||||
|
if isinstance(current, str):
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
current = json.loads(current)
|
||||||
|
except Exception:
|
||||||
|
current = []
|
||||||
|
for item in current or []:
|
||||||
|
if isinstance(item, dict) and item.get("name"):
|
||||||
|
known[item["name"]] = {
|
||||||
|
"name": item["name"],
|
||||||
|
"excluded": bool(item.get("excluded")),
|
||||||
|
}
|
||||||
|
exclude = exclude or {}
|
||||||
|
excluded = set(exclude.get("excluded") or [])
|
||||||
|
for item in exclude.get("objects") or []:
|
||||||
|
name = (item.get("name") if isinstance(item, dict) else str(item)).strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
known[name] = {"name": name, "excluded": name in excluded}
|
||||||
|
for name in excluded:
|
||||||
|
if name not in known:
|
||||||
|
known[name] = {"name": name, "excluded": True}
|
||||||
|
return list(known.values())
|
||||||
|
|
||||||
|
def _maybe_sample(self, conn, run_id, live, used_mm):
|
||||||
|
now = time.monotonic()
|
||||||
|
last = self._last_sample.get(run_id, 0)
|
||||||
|
if now - last < SAMPLE_EVERY_S:
|
||||||
|
return
|
||||||
|
self._last_sample[run_id] = now
|
||||||
|
store.add_run_sample(conn, run_id, {
|
||||||
|
"sampled_at": datetime.now(timezone.utc),
|
||||||
|
"nozzle": live.get("nozzle"),
|
||||||
|
"nozzle_target": live.get("nozzle_target"),
|
||||||
|
"bed": live.get("bed"),
|
||||||
|
"bed_target": live.get("bed_target"),
|
||||||
|
"chamber": live.get("chamber"),
|
||||||
|
"fan": live.get("fan"),
|
||||||
|
"speed_factor": live.get("speed_factor"),
|
||||||
|
"extrude_factor": live.get("extrude_factor"),
|
||||||
|
"layer": live.get("layer") or 0,
|
||||||
|
"progress": live.get("progress"),
|
||||||
|
"filament_used_mm": used_mm,
|
||||||
|
"mmu_tool": live.get("mmu_tool"),
|
||||||
|
})
|
||||||
|
|
||||||
|
def _ensure_start_segment(self, conn, host, filename, live, run_id):
|
||||||
|
"""Nur externe Einzelspule: ein Abschnitt. ACE-Farben ohne Wechsel → Anteil-Split."""
|
||||||
|
if not run_id:
|
||||||
|
return
|
||||||
|
existing = store.list_run_segments(conn, run_id)
|
||||||
|
if existing:
|
||||||
|
return
|
||||||
|
sent = store.latest_send(conn, host, filename) if filename else None
|
||||||
|
ace = (sent or {}).get("ace_map") or []
|
||||||
|
from grotti import acm
|
||||||
|
|
||||||
|
mapping = acm.normalize_mapping(ace) if ace else []
|
||||||
|
if mapping:
|
||||||
|
return
|
||||||
|
used_mm = usage.as_float(live.get("filament_used"))
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM spools
|
||||||
|
WHERE printer_host = %s AND ace_slot IS NULL
|
||||||
|
ORDER BY id LIMIT 1
|
||||||
|
""",
|
||||||
|
(host,),
|
||||||
|
).fetchone()
|
||||||
|
if rows:
|
||||||
|
store.open_segment(conn, run_id, rows["id"], None, used_mm)
|
||||||
|
|
||||||
def _cool_heaters(self, host):
|
def _cool_heaters(self, host):
|
||||||
try:
|
try:
|
||||||
moonraker.heaters_off(host)
|
moonraker.heaters_off(host)
|
||||||
@@ -193,5 +322,8 @@ class StateWatcher:
|
|||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _emit(self, conn, host, printer, kind, message, filename):
|
def _emit(self, conn, host, printer, kind, message, filename, run_id=None):
|
||||||
notify.record(conn, self.cfg, host, printer, kind, message, filename, dedupe_s=60)
|
notify.record(
|
||||||
|
conn, self.cfg, host, printer, kind, message, filename,
|
||||||
|
dedupe_s=60, run_id=run_id,
|
||||||
|
)
|
||||||
@@ -3,3 +3,4 @@ psycopg[binary]>=3.2
|
|||||||
paho-mqtt>=2.1
|
paho-mqtt>=2.1
|
||||||
cryptography>=42
|
cryptography>=42
|
||||||
markdown>=3.6
|
markdown>=3.6
|
||||||
|
mcp>=1.2
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""ACE-Trockner und Platz-Schreiben: Grenzen und Mapping."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from grotti.moonraker import (
|
||||||
|
PrinterError,
|
||||||
|
ace_hub_index,
|
||||||
|
extract_dryers,
|
||||||
|
spool_rgb,
|
||||||
|
steuern,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_dryers_skips_dummy_unit():
|
||||||
|
status = {
|
||||||
|
"mmu_machine": {
|
||||||
|
"num_units": 1,
|
||||||
|
"unit_0": {
|
||||||
|
"num_gates": 4,
|
||||||
|
"dryer_status": "drying",
|
||||||
|
"dryer_temp": 44,
|
||||||
|
"dryer_target_temp": 45,
|
||||||
|
"dryer_remaining": 120,
|
||||||
|
"dryer_humidity": 20,
|
||||||
|
},
|
||||||
|
"unit_1": {
|
||||||
|
"num_gates": 0,
|
||||||
|
"dryer_status": "stop",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dryers = extract_dryers(status)
|
||||||
|
assert len(dryers) == 1
|
||||||
|
assert dryers[0]["id"] == 0
|
||||||
|
assert dryers[0]["status"] == "drying"
|
||||||
|
assert dryers[0]["target"] == 45
|
||||||
|
assert dryers[0]["remain"] == 120
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_dryers_empty_without_machine():
|
||||||
|
assert extract_dryers({}) == []
|
||||||
|
assert extract_dryers({"mmu_machine": {}}) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_ace_hub_index():
|
||||||
|
assert ace_hub_index(1) == (0, 0)
|
||||||
|
assert ace_hub_index(4) == (0, 3)
|
||||||
|
assert ace_hub_index(5) == (1, 0)
|
||||||
|
assert ace_hub_index(8) == (1, 3)
|
||||||
|
with pytest.raises(PrinterError):
|
||||||
|
ace_hub_index(0)
|
||||||
|
with pytest.raises(PrinterError):
|
||||||
|
ace_hub_index(9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_spool_rgb_from_colors():
|
||||||
|
assert spool_rgb({"colors": [{"name": "Blau", "hex": "#002B49"}]}) == {
|
||||||
|
"R": 0, "G": 43, "B": 73,
|
||||||
|
}
|
||||||
|
assert spool_rgb({"colors": [], "color": "Weiß"}) is None
|
||||||
|
assert spool_rgb({"color": "#ff00aa"}) == {"R": 255, "G": 0, "B": 170}
|
||||||
|
|
||||||
|
|
||||||
|
def test_dryer_start_bounds(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_post(host, path, payload, timeout=20):
|
||||||
|
calls.append((host, path, payload))
|
||||||
|
return {"result": "ok"}
|
||||||
|
|
||||||
|
monkeypatch.setattr("grotti.moonraker.post_json", fake_post)
|
||||||
|
steuern("10.10.1.60", "dryer_start", {"id": 0, "temp": 45, "duration": 240})
|
||||||
|
assert calls[0][1] == "/server/filament_hub/start_drying"
|
||||||
|
assert calls[0][2]["fan_speed"] == 0
|
||||||
|
|
||||||
|
with pytest.raises(PrinterError):
|
||||||
|
steuern("10.10.1.60", "dryer_start", {"id": 2, "temp": 45, "duration": 60})
|
||||||
|
with pytest.raises(PrinterError):
|
||||||
|
steuern("10.10.1.60", "dryer_start", {"id": 0, "temp": 20, "duration": 60})
|
||||||
|
with pytest.raises(PrinterError):
|
||||||
|
steuern("10.10.1.60", "dryer_start", {"id": 0, "temp": 45, "duration": 0})
|
||||||
|
|
||||||
|
|
||||||
|
def test_slot_setzen_payload(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_post(host, path, payload, timeout=20):
|
||||||
|
calls.append((host, path, payload))
|
||||||
|
return {"result": "ok"}
|
||||||
|
|
||||||
|
monkeypatch.setattr("grotti.moonraker.post_json", fake_post)
|
||||||
|
steuern(
|
||||||
|
"10.10.1.60",
|
||||||
|
"slot_setzen",
|
||||||
|
{"id": 0, "index": 2, "type": "PLA", "color": {"R": 1, "G": 2, "B": 3}},
|
||||||
|
)
|
||||||
|
assert calls[0][1] == "/printer/filament_hub/set_filament_info"
|
||||||
|
assert calls[0][2] == {
|
||||||
|
"id": 0,
|
||||||
|
"index": 2,
|
||||||
|
"type": "PLA",
|
||||||
|
"color": {"R": 1, "G": 2, "B": 3},
|
||||||
|
}
|
||||||
@@ -0,0 +1,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()
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""ASCII-Dateinamen für Moonraker-Upload."""
|
||||||
|
|
||||||
|
from grotti.moonraker import multipart, safe_upload_name
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_upload_strips_cjk():
|
||||||
|
name = "1015-2159-虎钳式强力夹子_plate(01)_PLA_0.2_53m57s.gcode"
|
||||||
|
safe = safe_upload_name(name)
|
||||||
|
assert safe.isascii()
|
||||||
|
assert safe.endswith(".gcode")
|
||||||
|
assert "plate(01)" in safe
|
||||||
|
assert "虎" not in safe
|
||||||
|
|
||||||
|
|
||||||
|
def test_multipart_single_ascii_filename():
|
||||||
|
name = "模型_test.gcode"
|
||||||
|
_boundary, body = multipart(name, b"G28\n")
|
||||||
|
head = body.split(b"G28\n", 1)[0].decode("ascii")
|
||||||
|
assert "filename*=UTF-8" not in head
|
||||||
|
assert 'filename="test.gcode"' in head
|
||||||
|
assert all(ord(ch) < 128 for ch in head)
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_upload_preserves_path():
|
||||||
|
assert safe_upload_name("subdir/foo.gcode") == "subdir/foo.gcode"
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Toolpath-Thinning: ganze Schichten dicht, keine gesprenkelten Stichproben."""
|
||||||
|
|
||||||
|
from grotti.toolpath import TOOLPATH_VERSION, _thin_points, build_toolpath
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_points(layers=40, per_layer=800):
|
||||||
|
points = []
|
||||||
|
breaks = []
|
||||||
|
seq = 0
|
||||||
|
for layer in range(layers):
|
||||||
|
z = 0.2 + layer * 0.2
|
||||||
|
breaks.append(len(points))
|
||||||
|
for i in range(per_layer):
|
||||||
|
extrude = 1 if i > 0 else 0
|
||||||
|
points.append([float(i), float(layer), z, extrude, seq])
|
||||||
|
seq += 1
|
||||||
|
return points, breaks
|
||||||
|
|
||||||
|
|
||||||
|
def test_thin_keeps_consecutive_extrude_pairs():
|
||||||
|
points, breaks = _fake_points()
|
||||||
|
kept, new_breaks = _thin_points(points, breaks, max_points=8_000)
|
||||||
|
assert len(kept) <= 8_000
|
||||||
|
assert new_breaks
|
||||||
|
consec = 0
|
||||||
|
for i in range(1, len(kept)):
|
||||||
|
if kept[i][3] and kept[i][4] == kept[i - 1][4] + 1:
|
||||||
|
consec += 1
|
||||||
|
assert consec > 500, "Thinning darf keine zeichnbaren Segmente übrig lassen"
|
||||||
|
|
||||||
|
|
||||||
|
def test_thin_keeps_solid_layers():
|
||||||
|
points, breaks = _fake_points(layers=50, per_layer=600)
|
||||||
|
kept, _ = _thin_points(points, breaks, max_points=10_000)
|
||||||
|
# Pro behaltener Z-Höhe: hoher Anteil aufeinanderfolgender Extrusionen
|
||||||
|
by_z = {}
|
||||||
|
for i in range(1, len(kept)):
|
||||||
|
if not (kept[i][3] and kept[i][4] == kept[i - 1][4] + 1):
|
||||||
|
continue
|
||||||
|
z = round(kept[i][2], 2)
|
||||||
|
by_z[z] = by_z.get(z, 0) + 1
|
||||||
|
assert by_z, "keine Extrusionssegmente"
|
||||||
|
assert max(by_z.values()) >= 40, "behaltene Schichten sollen dicht sein"
|
||||||
|
|
||||||
|
|
||||||
|
def test_toolpath_version():
|
||||||
|
assert TOOLPATH_VERSION >= 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_empty(tmp_path):
|
||||||
|
path = tmp_path / "empty.gcode"
|
||||||
|
path.write_text("; nix\n", encoding="utf-8")
|
||||||
|
data = build_toolpath(path)
|
||||||
|
assert data["points"] == []
|
||||||
|
assert data["version"] == TOOLPATH_VERSION
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Ist-Gramm und Spulenanteile ohne Datenbank."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from grotti import usage
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveUsedTests(unittest.TestCase):
|
||||||
|
def test_measured_from_mm(self):
|
||||||
|
grams, source = usage.resolve_used_grams(100, 500, 1000, 0, "cancelled", 60)
|
||||||
|
self.assertAlmostEqual(grams, 50)
|
||||||
|
self.assertEqual(source, "measured")
|
||||||
|
|
||||||
|
def test_abort_before_extrude(self):
|
||||||
|
grams, source = usage.resolve_used_grams(100, 0, 1000, 0, "cancelled", 5)
|
||||||
|
self.assertEqual(grams, 0)
|
||||||
|
self.assertEqual(source, "none")
|
||||||
|
|
||||||
|
def test_abort_progress_estimate(self):
|
||||||
|
grams, source = usage.resolve_used_grams(100, 0, 0, 0.25, "cancelled", 120)
|
||||||
|
self.assertAlmostEqual(grams, 25)
|
||||||
|
self.assertEqual(source, "estimate")
|
||||||
|
|
||||||
|
def test_completed_keeps_planned_without_mm(self):
|
||||||
|
grams, source = usage.resolve_used_grams(80, 0, 0, 1, "completed", 3600)
|
||||||
|
self.assertAlmostEqual(grams, 80)
|
||||||
|
self.assertEqual(source, "estimate")
|
||||||
|
|
||||||
|
def test_overshoot_no_cap(self):
|
||||||
|
grams, source = usage.resolve_used_grams(100, 1200, 1000, 1, "completed", 3600)
|
||||||
|
self.assertAlmostEqual(grams, 120)
|
||||||
|
self.assertEqual(source, "measured")
|
||||||
|
|
||||||
|
|
||||||
|
class SplitTests(unittest.TestCase):
|
||||||
|
def test_single_is_measured(self):
|
||||||
|
parts, estimate = usage.split_used(50, [100], multi=False)
|
||||||
|
self.assertEqual(parts, [50])
|
||||||
|
self.assertFalse(estimate)
|
||||||
|
|
||||||
|
def test_multi_is_estimate(self):
|
||||||
|
parts, estimate = usage.split_used(90, [30, 60], multi=True)
|
||||||
|
self.assertAlmostEqual(parts[0], 30)
|
||||||
|
self.assertAlmostEqual(parts[1], 60)
|
||||||
|
self.assertTrue(estimate)
|
||||||
|
|
||||||
|
|
||||||
|
class SegmentTests(unittest.TestCase):
|
||||||
|
def test_deltas(self):
|
||||||
|
segs = [
|
||||||
|
{"spool_id": 1, "ace_slot": None, "mm_start": 0, "mm_end": 400},
|
||||||
|
{"spool_id": 2, "ace_slot": None, "mm_start": 400, "mm_end": None},
|
||||||
|
]
|
||||||
|
deltas = usage.segment_deltas(segs, 1000)
|
||||||
|
self.assertEqual(deltas[0], (1, None, 400))
|
||||||
|
self.assertEqual(deltas[1], (2, None, 600))
|
||||||
|
|
||||||
|
def test_grams_from_mm(self):
|
||||||
|
self.assertAlmostEqual(usage.grams_from_mm(250, 1000, 80), 20)
|
||||||
|
|
||||||
|
|
||||||
|
class WeightListTests(unittest.TestCase):
|
||||||
|
def test_list_and_sum(self):
|
||||||
|
meta = {"filament_weight_total": [10, 20, 5]}
|
||||||
|
self.assertEqual(usage.weight_list(meta), [10, 20, 5])
|
||||||
|
self.assertEqual(usage.weight_sum(meta), 35)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -10,10 +11,11 @@ from urllib.parse import quote
|
|||||||
|
|
||||||
import markdown
|
import markdown
|
||||||
import psycopg
|
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 markupsafe import Markup
|
||||||
from psycopg.rows import dict_row
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
|
from grotti import auth as grotti_auth
|
||||||
from grotti import config as grotti_config
|
from grotti import config as grotti_config
|
||||||
from grotti import eject, jobs, library, moonraker, notify, store
|
from grotti import eject, jobs, library, moonraker, notify, store
|
||||||
from grotti.catalog import color_catalog
|
from grotti.catalog import color_catalog
|
||||||
@@ -29,14 +31,13 @@ from grotti.watcher import StateWatcher
|
|||||||
ROOT = Path(__file__).resolve().parent
|
ROOT = Path(__file__).resolve().parent
|
||||||
TOOL_ROOT = ROOT.parent
|
TOOL_ROOT = ROOT.parent
|
||||||
DOCS_DIR = TOOL_ROOT / "docs"
|
DOCS_DIR = TOOL_ROOT / "docs"
|
||||||
CHANGELOG = TOOL_ROOT / "CHANGELOG.md"
|
|
||||||
PROJECT_STATUS = {"idee", "bereit", "druckt", "fertig"}
|
PROJECT_STATUS = {"idee", "bereit", "druckt", "fertig"}
|
||||||
VERDICTS = {"", "gut", "ausschuss"}
|
VERDICTS = {"", "gut", "ausschuss"}
|
||||||
IDEA_STATUS = {"offen", "spaeter", "erledigt"}
|
IDEA_STATUS = {"offen", "spaeter", "erledigt"}
|
||||||
PRINTER_FAMILIES = set(FAMILY_LABELS) | {""}
|
PRINTER_FAMILIES = set(FAMILY_LABELS) | {""}
|
||||||
STEER_ACTIONS = {
|
STEER_ACTIONS = {
|
||||||
"nozzle", "bed", "speed", "flow", "fan", "emergency_stop", "clear_error",
|
"nozzle", "bed", "speed", "flow", "fan", "emergency_stop", "clear_error",
|
||||||
"exclude_object",
|
"exclude_object", "dryer_start", "dryer_stop", "slot_setzen",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -47,8 +48,13 @@ def create_app(cfg):
|
|||||||
static_folder=str(ROOT / "static"),
|
static_folder=str(ROOT / "static"),
|
||||||
)
|
)
|
||||||
app.config["GROTTI"] = cfg
|
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["TEMPLATES_AUTO_RELOAD"] = True
|
||||||
app.config["MAX_CONTENT_LENGTH"] = 256 * 1024 * 1024
|
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.json.ensure_ascii = False
|
||||||
app.extensions["send_queue"] = SendQueue(cfg["database_url"], cfg)
|
app.extensions["send_queue"] = SendQueue(cfg["database_url"], cfg)
|
||||||
watcher = StateWatcher(cfg["database_url"], cfg)
|
watcher = StateWatcher(cfg["database_url"], cfg)
|
||||||
@@ -58,19 +64,167 @@ def create_app(cfg):
|
|||||||
watcher.start()
|
watcher.start()
|
||||||
dispatcher.start()
|
dispatcher.start()
|
||||||
|
|
||||||
@app.before_request
|
PUBLIC_PATHS = {
|
||||||
def check_token():
|
"/login",
|
||||||
token = cfg["token"]
|
"/setup",
|
||||||
if not token or not request.path.startswith("/api/"):
|
"/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
|
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 = ""
|
presented = ""
|
||||||
header = request.headers.get("Authorization", "")
|
header = request.headers.get("Authorization", "")
|
||||||
if header.startswith("Bearer "):
|
if header.startswith("Bearer "):
|
||||||
presented = header[7:]
|
presented = header[7:].strip()
|
||||||
elif request.method == "GET":
|
elif request.method == "GET" and request.path.startswith("/api/"):
|
||||||
presented = request.args.get("token", "")
|
presented = (request.args.get("token") or "").strip()
|
||||||
if not presented or not secrets.compare_digest(presented, token):
|
|
||||||
|
user = None
|
||||||
|
if presented:
|
||||||
|
user = resolve_bearer(presented)
|
||||||
|
if not user:
|
||||||
return jsonify({"error": "Token fehlt oder stimmt nicht.", "code": "token"}), 401
|
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
|
return None
|
||||||
|
|
||||||
@app.teardown_appcontext
|
@app.teardown_appcontext
|
||||||
@@ -88,16 +242,11 @@ def create_app(cfg):
|
|||||||
|
|
||||||
@app.errorhandler(GrottiError)
|
@app.errorhandler(GrottiError)
|
||||||
def grotti_error(err):
|
def grotti_error(err):
|
||||||
conn = g.get("db")
|
db = g.get("db")
|
||||||
if conn:
|
if db:
|
||||||
conn.rollback()
|
db.rollback()
|
||||||
return jsonify({"error": err.message, "code": err.code}), err.status
|
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():
|
def body():
|
||||||
data = request.get_json(silent=True)
|
data = request.get_json(silent=True)
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
@@ -124,19 +273,261 @@ def create_app(cfg):
|
|||||||
return host
|
return host
|
||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def inject_version():
|
def inject_globals():
|
||||||
return {"version": __version__}
|
user = auth_user()
|
||||||
|
return {
|
||||||
|
"version": __version__,
|
||||||
|
"csrf": session.get("csrf") or "",
|
||||||
|
"current_user": user,
|
||||||
|
}
|
||||||
|
|
||||||
def page(name, title):
|
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():
|
def doc_names():
|
||||||
|
"""Nur Anleitung-*.md — Technik liegt unter docs/internal/ (nicht in der App)."""
|
||||||
if not DOCS_DIR.is_dir():
|
if not DOCS_DIR.is_dir():
|
||||||
return []
|
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):
|
def render_markdown(path):
|
||||||
|
"""Markdown für die App: Git-relative Bild-/Kapitelpfade → App-URLs."""
|
||||||
text = path.read_text(encoding="utf-8")
|
text = path.read_text(encoding="utf-8")
|
||||||
|
# docs/… → web/static/… im Repo; unter /doku als /static/…
|
||||||
|
text = text.replace("../web/static/", "/static/")
|
||||||
|
text = re.sub(
|
||||||
|
r"\]\((Anleitung-[A-Za-z0-9_-]+)(?:\.md)?\)",
|
||||||
|
r"](/doku/\1)",
|
||||||
|
text,
|
||||||
|
)
|
||||||
html = markdown.markdown(text, extensions=["tables", "fenced_code", "sane_lists"])
|
html = markdown.markdown(text, extensions=["tables", "fenced_code", "sane_lists"])
|
||||||
return Markup(html)
|
return Markup(html)
|
||||||
|
|
||||||
@@ -180,7 +571,7 @@ def create_app(cfg):
|
|||||||
"etikett.html",
|
"etikett.html",
|
||||||
page="etikett",
|
page="etikett",
|
||||||
title="Etikett %s" % spool["chargen_nr"],
|
title="Etikett %s" % spool["chargen_nr"],
|
||||||
token=cfg["token"],
|
token="",
|
||||||
spool=spool,
|
spool=spool,
|
||||||
qr_url=qr_url,
|
qr_url=qr_url,
|
||||||
)
|
)
|
||||||
@@ -189,6 +580,58 @@ def create_app(cfg):
|
|||||||
def protokoll():
|
def protokoll():
|
||||||
return page("protokoll.html", "Protokoll")
|
return page("protokoll.html", "Protokoll")
|
||||||
|
|
||||||
|
@app.get("/protokoll/<int:run_id>")
|
||||||
|
def protokoll_detail(run_id):
|
||||||
|
row = store.get_run_row(conn(), run_id)
|
||||||
|
if not row:
|
||||||
|
abort(404)
|
||||||
|
return page("bericht.html", "Druckbericht")
|
||||||
|
|
||||||
|
@app.get("/statistik")
|
||||||
|
def statistik_page():
|
||||||
|
return page("statistik.html", "Statistik")
|
||||||
|
|
||||||
|
@app.get("/etikett/lauf/<int:run_id>")
|
||||||
|
def etikett_lauf(run_id):
|
||||||
|
row = store.get_run_row(conn(), run_id)
|
||||||
|
if not row:
|
||||||
|
abort(404)
|
||||||
|
run = store.run_out(row)
|
||||||
|
if not run.get("chargen_nr"):
|
||||||
|
store.ensure_run_chargen(conn(), run_id, run.get("weight_used_g"))
|
||||||
|
row = store.get_run_row(conn(), run_id)
|
||||||
|
run = store.run_out(row)
|
||||||
|
if not run.get("chargen_nr"):
|
||||||
|
abort(404)
|
||||||
|
labels = store.list_run_labels(conn(), run_id)
|
||||||
|
mode = (request.args.get("mode") or "druck").strip()
|
||||||
|
qty = max(1, min(50, int(request.args.get("qty") or 1)))
|
||||||
|
if mode == "objekt":
|
||||||
|
labels = store.ensure_run_labels(conn(), run_id, run.get("objects") or [], qty=qty)
|
||||||
|
sheets = [lab for lab in labels if not lab.get("excluded")]
|
||||||
|
else:
|
||||||
|
sheets = [{"object_name": "", "chargen_nr": run["chargen_nr"], "excluded": False}]
|
||||||
|
if not sheets:
|
||||||
|
abort(404)
|
||||||
|
spools = store.etikett_spools_for_run(conn(), run)
|
||||||
|
base = grotti_config.public_base(cfg, request.url_root)
|
||||||
|
target_g = float(run.get("weight_planned_g") or run.get("weight_g") or 0)
|
||||||
|
for sheet in sheets:
|
||||||
|
sheet["qr_url"] = "%s/protokoll/%s" % (base, run_id)
|
||||||
|
sheet["target_g"] = target_g
|
||||||
|
if sheet.get("object_name"):
|
||||||
|
sheet["qr_url"] += "#objekt-%s" % quote(sheet["object_name"])
|
||||||
|
return render_template(
|
||||||
|
"etikett_lauf.html",
|
||||||
|
page="etikett",
|
||||||
|
title="Etikett %s" % run["chargen_nr"],
|
||||||
|
token="",
|
||||||
|
run=run,
|
||||||
|
sheets=sheets,
|
||||||
|
spools=spools,
|
||||||
|
target_g=target_g,
|
||||||
|
)
|
||||||
|
|
||||||
@app.get("/ideen")
|
@app.get("/ideen")
|
||||||
def ideen():
|
def ideen():
|
||||||
return page("ideen.html", "Ideen")
|
return page("ideen.html", "Ideen")
|
||||||
@@ -203,37 +646,42 @@ def create_app(cfg):
|
|||||||
|
|
||||||
@app.get("/doku")
|
@app.get("/doku")
|
||||||
def doku():
|
def doku():
|
||||||
|
names = doc_names()
|
||||||
|
anleitung = anleitung_list(names)
|
||||||
return render_template(
|
return render_template(
|
||||||
"doku.html",
|
"doku.html",
|
||||||
page="doku",
|
page="doku",
|
||||||
title="Doku",
|
title="Doku",
|
||||||
token=cfg["token"],
|
token="",
|
||||||
docs=doc_names(),
|
docs=names,
|
||||||
|
anleitung=anleitung,
|
||||||
|
anleitung_title=anleitung_title,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/doku/<name>")
|
@app.get("/doku/<name>")
|
||||||
def doku_page(name):
|
def doku_page(name):
|
||||||
if name == "changelog":
|
names = doc_names()
|
||||||
path, title = CHANGELOG, "Changelog"
|
if name not in names:
|
||||||
elif name in doc_names():
|
|
||||||
path, title = DOCS_DIR / ("%s.md" % name), name.replace("-", " ")
|
|
||||||
else:
|
|
||||||
abort(404)
|
abort(404)
|
||||||
|
path = DOCS_DIR / ("%s.md" % name)
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
abort(404)
|
abort(404)
|
||||||
|
anleitung = anleitung_list(names)
|
||||||
return render_template(
|
return render_template(
|
||||||
"doku_page.html",
|
"doku_page.html",
|
||||||
page="doku",
|
page="doku",
|
||||||
title=title,
|
title=anleitung_title(name),
|
||||||
token=cfg["token"],
|
token="",
|
||||||
content=render_markdown(path),
|
content=render_markdown(path),
|
||||||
docs=doc_names(),
|
docs=names,
|
||||||
|
anleitung=anleitung,
|
||||||
|
anleitung_title=anleitung_title,
|
||||||
current=name,
|
current=name,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/api/v1/version")
|
@app.get("/api/v1/version")
|
||||||
def version_info():
|
def version_info():
|
||||||
return jsonify({"version": __version__, "changelog": "/doku/changelog"})
|
return jsonify({"version": __version__})
|
||||||
|
|
||||||
@app.get("/api/v1/einstellungen")
|
@app.get("/api/v1/einstellungen")
|
||||||
def settings():
|
def settings():
|
||||||
@@ -262,7 +710,7 @@ def create_app(cfg):
|
|||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
@app.get("/api/v1/drucker")
|
@app.get("/api/v1/drucker")
|
||||||
@app.get("/api/drucker")
|
@app.get("/api/drucker") # veraltet — Alias zu /api/v1/drucker
|
||||||
def printers():
|
def printers():
|
||||||
rows = store.list_printers(conn())
|
rows = store.list_printers(conn())
|
||||||
clears = store.bed_clear_map(conn())
|
clears = store.bed_clear_map(conn())
|
||||||
@@ -276,6 +724,9 @@ def create_app(cfg):
|
|||||||
item["queue_failed"] = queue.get("failed")
|
item["queue_failed"] = queue.get("failed")
|
||||||
item["queue_waiting"] = queue.get("waiting", 0)
|
item["queue_waiting"] = queue.get("waiting", 0)
|
||||||
item["queue_waiting_next"] = queue.get("waiting_next", "")
|
item["queue_waiting_next"] = queue.get("waiting_next", "")
|
||||||
|
live_run = store.find_live_run(conn(), host) if host else None
|
||||||
|
item["live_run_id"] = (live_run or {}).get("id")
|
||||||
|
item["live_chargen"] = (live_run or {}).get("chargen_nr") or ""
|
||||||
|
|
||||||
by_id = {row["host"]: row for row in rows}
|
by_id = {row["host"]: row for row in rows}
|
||||||
if request.args.get("live", "1") == "0":
|
if request.args.get("live", "1") == "0":
|
||||||
@@ -477,7 +928,7 @@ def create_app(cfg):
|
|||||||
row = store.get_file_row(conn(), file_id)
|
row = store.get_file_row(conn(), file_id)
|
||||||
if not row:
|
if not row:
|
||||||
abort(404)
|
abort(404)
|
||||||
png = library.preview_png(row)
|
png = library.preview_png(row, conn=conn(), data_dir=cfg["data_dir"])
|
||||||
if not png:
|
if not png:
|
||||||
abort(404)
|
abort(404)
|
||||||
return png, 200, {"Content-Type": "image/png", "Cache-Control": "private, max-age=300"}
|
return png, 200, {"Content-Type": "image/png", "Cache-Control": "private, max-age=300"}
|
||||||
@@ -487,8 +938,8 @@ def create_app(cfg):
|
|||||||
row = store.get_file_row(conn(), file_id)
|
row = store.get_file_row(conn(), file_id)
|
||||||
if not row:
|
if not row:
|
||||||
raise GrottiError("Die Datei fehlt.", "datei", 404)
|
raise GrottiError("Die Datei fehlt.", "datei", 404)
|
||||||
path = Path(row["stored_path"])
|
path = library.resolve_stored_path(conn(), row, cfg["data_dir"])
|
||||||
if not path.is_file():
|
if path is None or not path.is_file():
|
||||||
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
|
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
|
||||||
cache_dir = Path(cfg["data_dir"]) / "toolpath"
|
cache_dir = Path(cfg["data_dir"]) / "toolpath"
|
||||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -514,7 +965,10 @@ def create_app(cfg):
|
|||||||
pass
|
pass
|
||||||
data["file_id"] = file_id
|
data["file_id"] = file_id
|
||||||
data["name"] = row["name"]
|
data["name"] = row["name"]
|
||||||
return jsonify(data)
|
data["version"] = TOOLPATH_VERSION
|
||||||
|
resp = jsonify(data)
|
||||||
|
resp.headers["Cache-Control"] = "no-store"
|
||||||
|
return resp
|
||||||
|
|
||||||
@app.patch("/api/v1/dateien/<int:file_id>")
|
@app.patch("/api/v1/dateien/<int:file_id>")
|
||||||
def file_patch(file_id):
|
def file_patch(file_id):
|
||||||
@@ -767,6 +1221,8 @@ def create_app(cfg):
|
|||||||
data = body()
|
data = body()
|
||||||
if isinstance(data.get("steps"), list) and data.get("steps"):
|
if isinstance(data.get("steps"), list) and data.get("steps"):
|
||||||
return jsonify(enqueue_ablauf_payload(data)), 202
|
return jsonify(enqueue_ablauf_payload(data)), 202
|
||||||
|
if not bool(data.get("confirm")):
|
||||||
|
raise GrottiError("Bestätigung fehlt.", "bestaetigung")
|
||||||
try:
|
try:
|
||||||
file_id = int(data.get("file_id"))
|
file_id = int(data.get("file_id"))
|
||||||
except (TypeError, ValueError) as exc:
|
except (TypeError, ValueError) as exc:
|
||||||
@@ -1105,11 +1561,28 @@ def create_app(cfg):
|
|||||||
|
|
||||||
@app.get("/api/v1/spulen/by-code/<path:code>")
|
@app.get("/api/v1/spulen/by-code/<path:code>")
|
||||||
def spool_by_code(code):
|
def spool_by_code(code):
|
||||||
row = store.get_spool_by_code(conn(), code)
|
text = (code or "").strip()
|
||||||
|
if text.upper().startswith("D-"):
|
||||||
|
run = store.get_run_by_chargen(conn(), text)
|
||||||
|
if not run:
|
||||||
|
raise GrottiError("Kein Druck mit dieser Charge.", "charge", 404)
|
||||||
|
return jsonify({"kind": "lauf", "run": store.run_out(run), "redirect": "/protokoll/%s" % run["id"]})
|
||||||
|
row = store.get_spool_by_code(conn(), text)
|
||||||
if not row:
|
if not row:
|
||||||
raise GrottiError("Keine Spule mit diesem Code.", "spule", 404)
|
raise GrottiError("Keine Spule mit diesem Code.", "spule", 404)
|
||||||
return jsonify(row)
|
return jsonify(row)
|
||||||
|
|
||||||
|
@app.get("/api/v1/spulen/<int:spool_id>/akte")
|
||||||
|
def spool_akte(spool_id):
|
||||||
|
spool = store.get_spool_row(conn(), spool_id)
|
||||||
|
if not spool:
|
||||||
|
raise GrottiError("Die Spule fehlt.", "spule", 404)
|
||||||
|
return jsonify({
|
||||||
|
"spool": store.spool_out(spool),
|
||||||
|
"runs": store.list_spool_runs(conn(), spool_id),
|
||||||
|
"stats": store.spool_usage_stats(conn(), spool_id),
|
||||||
|
})
|
||||||
|
|
||||||
@app.post("/api/v1/spulen")
|
@app.post("/api/v1/spulen")
|
||||||
def spool_create():
|
def spool_create():
|
||||||
return jsonify(store.create_spool(conn(), spool_fields(body()))), 201
|
return jsonify(store.create_spool(conn(), spool_fields(body()))), 201
|
||||||
@@ -1130,6 +1603,18 @@ def create_app(cfg):
|
|||||||
spool = store.get_spool_row(conn(), spool_id)
|
spool = store.get_spool_row(conn(), spool_id)
|
||||||
if not spool:
|
if not spool:
|
||||||
raise GrottiError("Die Spule fehlt.", "spule", 404)
|
raise GrottiError("Die Spule fehlt.", "spule", 404)
|
||||||
|
live_run = store.find_live_run(conn(), host)
|
||||||
|
mm_now = float((live_run or {}).get("filament_used_mm") or 0)
|
||||||
|
printing = bool(
|
||||||
|
live_run and (live_run.get("state") or "") in ("in_progress", "printing", "paused")
|
||||||
|
)
|
||||||
|
if printing:
|
||||||
|
try:
|
||||||
|
snap = moonraker.query(host)
|
||||||
|
if isinstance(snap.get("filament_used"), (int, float)):
|
||||||
|
mm_now = float(snap["filament_used"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
# Alte Rolle am Platz / externe Rolle am Host freimachen
|
# Alte Rolle am Platz / externe Rolle am Host freimachen
|
||||||
if slot:
|
if slot:
|
||||||
old = conn().execute(
|
old = conn().execute(
|
||||||
@@ -1141,22 +1626,32 @@ def create_app(cfg):
|
|||||||
"SELECT * FROM spools WHERE printer_host = %s AND ace_slot IS NULL AND id <> %s",
|
"SELECT * FROM spools WHERE printer_host = %s AND ace_slot IS NULL AND id <> %s",
|
||||||
(host, spool_id),
|
(host, spool_id),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
printer = store.printer_by_host(conn(), host) or {}
|
||||||
|
if printing:
|
||||||
|
closed = store.close_open_segments(conn(), live_run["id"], slot, mm_now)
|
||||||
|
if not closed and old:
|
||||||
|
# Erster Wechsel: alter Abschnitt von 0 bis jetzt
|
||||||
|
for row in old:
|
||||||
|
store.open_segment(conn(), live_run["id"], row["id"], slot, 0)
|
||||||
|
store.close_open_segments(conn(), live_run["id"], slot, mm_now)
|
||||||
for row in old:
|
for row in old:
|
||||||
store.update_spool(conn(), row["id"], {"printer_host": "", "ace_slot": None})
|
store.update_spool(conn(), row["id"], {"printer_host": "", "ace_slot": None})
|
||||||
store.add_event(
|
store.add_event(
|
||||||
conn(),
|
conn(),
|
||||||
host,
|
host,
|
||||||
(store.printer_by_host(conn(), host) or {}).get("name") or host,
|
printer.get("name") or host,
|
||||||
"spule",
|
"spule",
|
||||||
"%s: „%s“ ins Regal%s" % (
|
"%s: „%s“ ins Regal%s" % (
|
||||||
(store.printer_by_host(conn(), host) or {}).get("name") or host,
|
printer.get("name") or host,
|
||||||
row["name"],
|
row["name"],
|
||||||
(" (Platz %s)" % slot) if slot else "",
|
(" (Platz %s)" % slot) if slot else "",
|
||||||
),
|
),
|
||||||
"",
|
(live_run or {}).get("filename") or "",
|
||||||
|
run_id=(live_run or {}).get("id"),
|
||||||
)
|
)
|
||||||
updated = store.update_spool(conn(), spool_id, {"printer_host": host, "ace_slot": slot})
|
updated = store.update_spool(conn(), spool_id, {"printer_host": host, "ace_slot": slot})
|
||||||
printer = store.printer_by_host(conn(), host) or {}
|
if printing:
|
||||||
|
store.open_segment(conn(), live_run["id"], spool_id, slot, mm_now)
|
||||||
store.add_event(
|
store.add_event(
|
||||||
conn(),
|
conn(),
|
||||||
host,
|
host,
|
||||||
@@ -1167,7 +1662,8 @@ def create_app(cfg):
|
|||||||
updated["name"],
|
updated["name"],
|
||||||
(" auf Platz %s" % slot) if slot else "",
|
(" auf Platz %s" % slot) if slot else "",
|
||||||
),
|
),
|
||||||
"",
|
(live_run or {}).get("filename") or "",
|
||||||
|
run_id=(live_run or {}).get("id"),
|
||||||
)
|
)
|
||||||
return jsonify(updated)
|
return jsonify(updated)
|
||||||
|
|
||||||
@@ -1303,6 +1799,36 @@ def create_app(cfg):
|
|||||||
def run_list():
|
def run_list():
|
||||||
return jsonify(store.list_runs(conn()))
|
return jsonify(store.list_runs(conn()))
|
||||||
|
|
||||||
|
@app.get("/api/v1/laeufe/<int:run_id>")
|
||||||
|
def run_get(run_id):
|
||||||
|
row = store.get_run_row(conn(), run_id)
|
||||||
|
if not row:
|
||||||
|
raise GrottiError("Der Lauf fehlt.", "lauf", 404)
|
||||||
|
run = store.run_out(row)
|
||||||
|
if not run.get("chargen_nr"):
|
||||||
|
store.ensure_run_chargen(conn(), run_id)
|
||||||
|
row = store.get_run_row(conn(), run_id) or row
|
||||||
|
run = store.run_out(row)
|
||||||
|
if run.get("chargen_nr") and run.get("objects"):
|
||||||
|
store.ensure_run_labels(conn(), run_id, run.get("objects") or [], qty=1)
|
||||||
|
file_row = store.get_file_row(conn(), run["file_id"]) if run.get("file_id") else None
|
||||||
|
project = store.get_project(conn(), run["project_id"]) if run.get("project_id") else None
|
||||||
|
samples = store.list_run_samples(conn(), run_id)
|
||||||
|
# Anzeige auf ca. 1 Punkt / Minute verdichten
|
||||||
|
if len(samples) > 180:
|
||||||
|
step = max(1, len(samples) // 120)
|
||||||
|
samples = samples[::step]
|
||||||
|
return jsonify({
|
||||||
|
"run": run,
|
||||||
|
"spools": store.list_run_spools(conn(), run_id),
|
||||||
|
"segments": store.list_run_segments(conn(), run_id),
|
||||||
|
"samples": samples,
|
||||||
|
"labels": store.list_run_labels(conn(), run_id),
|
||||||
|
"events": store.list_run_events(conn(), run_id),
|
||||||
|
"file": store.file_out(file_row) if file_row else None,
|
||||||
|
"project": project,
|
||||||
|
})
|
||||||
|
|
||||||
@app.post("/api/v1/laeufe/abgleich")
|
@app.post("/api/v1/laeufe/abgleich")
|
||||||
def run_sync():
|
def run_sync():
|
||||||
library.sync_watch(conn(), cfg, force=False)
|
library.sync_watch(conn(), cfg, force=False)
|
||||||
@@ -1325,4 +1851,71 @@ def create_app(cfg):
|
|||||||
row = store.run_out(current)
|
row = store.run_out(current)
|
||||||
return jsonify(row)
|
return jsonify(row)
|
||||||
|
|
||||||
|
@app.get("/api/v1/statistik")
|
||||||
|
def statistik_api():
|
||||||
|
since = request.args.get("since") or None
|
||||||
|
until = request.args.get("until") or None
|
||||||
|
host = (request.args.get("host") or "").strip()
|
||||||
|
material = (request.args.get("material") or "").strip()
|
||||||
|
brand = (request.args.get("brand") or "").strip()
|
||||||
|
project_id = optional_id(request.args.get("project_id"))
|
||||||
|
return jsonify(store.statistics(
|
||||||
|
conn(),
|
||||||
|
since=since,
|
||||||
|
until=until,
|
||||||
|
host=host,
|
||||||
|
material=material or "",
|
||||||
|
brand=brand or "",
|
||||||
|
project_id=project_id,
|
||||||
|
))
|
||||||
|
|
||||||
|
@app.get("/api/v1/statistik.csv")
|
||||||
|
def statistik_csv():
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from flask import Response
|
||||||
|
|
||||||
|
since = request.args.get("since") or None
|
||||||
|
until = request.args.get("until") or None
|
||||||
|
host = (request.args.get("host") or "").strip()
|
||||||
|
material = (request.args.get("material") or "").strip()
|
||||||
|
brand = (request.args.get("brand") or "").strip()
|
||||||
|
runs = store.list_runs(
|
||||||
|
conn(),
|
||||||
|
limit=2000,
|
||||||
|
since=since,
|
||||||
|
until=until,
|
||||||
|
host=host,
|
||||||
|
material=material,
|
||||||
|
brand=brand,
|
||||||
|
)
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf, delimiter=";")
|
||||||
|
writer.writerow([
|
||||||
|
"id", "drucker", "datei", "zustand", "start", "ende", "dauer_s",
|
||||||
|
"soll_g", "ist_g", "quelle", "filament_eur", "strom_eur", "urteil", "charge",
|
||||||
|
])
|
||||||
|
for run in runs:
|
||||||
|
writer.writerow([
|
||||||
|
run["id"],
|
||||||
|
run.get("printer_name") or "",
|
||||||
|
run.get("filename") or "",
|
||||||
|
run.get("state") or "",
|
||||||
|
run.get("started_at") or "",
|
||||||
|
run.get("ended_at") or "",
|
||||||
|
run.get("duration_s") or 0,
|
||||||
|
run.get("weight_planned_g") or 0,
|
||||||
|
run.get("weight_used_g") or 0,
|
||||||
|
run.get("weight_source") or "",
|
||||||
|
run.get("filament_cost") or 0,
|
||||||
|
run.get("energy_cost") or 0,
|
||||||
|
run.get("verdict") or "",
|
||||||
|
run.get("chargen_nr") or "",
|
||||||
|
])
|
||||||
|
return Response(
|
||||||
|
buf.getvalue(),
|
||||||
|
mimetype="text/csv; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=grotti-laeufe.csv"},
|
||||||
|
)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
@@ -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 = {
|
const STATE_LABELS = {
|
||||||
printing: "druckt", paused: "pausiert", complete: "fertig", standby: "bereit",
|
printing: "druckt", paused: "pausiert", complete: "fertig", standby: "bereit",
|
||||||
@@ -17,17 +18,29 @@ function escapeHtml(value) {
|
|||||||
async function api(path, options = {}) {
|
async function api(path, options = {}) {
|
||||||
const headers = new Headers(options.headers || {});
|
const headers = new Headers(options.headers || {});
|
||||||
if (TOKEN) headers.set("Authorization", "Bearer " + TOKEN);
|
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;
|
let body = options.body;
|
||||||
if (options.json !== undefined) {
|
if (options.json !== undefined) {
|
||||||
headers.set("Content-Type", "application/json");
|
headers.set("Content-Type", "application/json");
|
||||||
body = JSON.stringify(options.json);
|
body = JSON.stringify(options.json);
|
||||||
}
|
}
|
||||||
const response = await fetch("/api/v1" + path, {
|
const response = await fetch("/api/v1" + path, {
|
||||||
method: options.method || "GET",
|
method,
|
||||||
headers,
|
headers,
|
||||||
body,
|
body,
|
||||||
cache: "no-store",
|
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) {
|
if (!response.ok) {
|
||||||
let message = response.statusText;
|
let message = response.statusText;
|
||||||
try {
|
try {
|
||||||
@@ -45,8 +58,8 @@ async function api(path, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function withToken(url) {
|
function withToken(url) {
|
||||||
if (!url || !TOKEN) return url;
|
// Session-Cookie reicht für same-origin Kameras; kein Token in der URL.
|
||||||
return url + (url.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(TOKEN);
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
function euro(value) {
|
function euro(value) {
|
||||||
@@ -103,6 +116,13 @@ function chipHtml(state, label, small) {
|
|||||||
return `<span class="chip ${escapeHtml(state || "")}${small ? " sm" : ""}">${escapeHtml(text)}</span>`;
|
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) {
|
function stepsHtml(labels, index, compact) {
|
||||||
if (!labels || !labels.length) return "";
|
if (!labels || !labels.length) return "";
|
||||||
const parts = labels.map((label, i) => {
|
const parts = labels.map((label, i) => {
|
||||||
|
|||||||
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 403 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 670 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 110 KiB |
@@ -10,8 +10,8 @@ window.GrottiGcodeViewer = (function () {
|
|||||||
const COLOR_OBJ_EX = 0xef4444;
|
const COLOR_OBJ_EX = 0xef4444;
|
||||||
const COLOR_OBJ_CUR = 0xfbbf24;
|
const COLOR_OBJ_CUR = 0xfbbf24;
|
||||||
const GRID_STEP = 10;
|
const GRID_STEP = 10;
|
||||||
// Nur leichte Doppelung — starke Offsets + Punkte wirkten wie Vollkörper mit Löchern
|
// Keine Parallel-Offsets: bei ausgedünnten Pfaden wirkten sie gesprenkelt
|
||||||
const FAT_OFFSETS = [0, -0.22, 0.22];
|
const FAT_OFFSETS = [0];
|
||||||
|
|
||||||
function create(canvas) {
|
function create(canvas) {
|
||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
|
|||||||
@@ -111,20 +111,21 @@ summary:focus:not(:focus-visible) { outline: none; }
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
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 {
|
.nav-toggle {
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 2.5rem;
|
width: 2.5rem;
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
min-width: 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, .nav-secondary { display: flex; flex-direction: column; gap: 0.1rem; }
|
||||||
.nav-main a, .nav-secondary a {
|
.nav-main a, .nav-secondary a {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -183,10 +184,14 @@ html.nav-collapsed .brand,
|
|||||||
body.nav-collapsed .brand {
|
body.nav-collapsed .brand {
|
||||||
padding: 0.2rem;
|
padding: 0.2rem;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 0;
|
|
||||||
}
|
}
|
||||||
html.nav-collapsed .brand::before,
|
html.nav-collapsed .brand-name,
|
||||||
body.nav-collapsed .brand::before { margin: 0; }
|
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,
|
html.nav-collapsed .brand-row,
|
||||||
body.nav-collapsed .brand-row { flex-direction: column; padding-bottom: 0.35rem; }
|
body.nav-collapsed .brand-row { flex-direction: column; padding-bottom: 0.35rem; }
|
||||||
html.nav-collapsed .nav-toggle,
|
html.nav-collapsed .nav-toggle,
|
||||||
@@ -400,6 +405,36 @@ body.shelf [data-role="hero"] { font-size: 1.35rem; }
|
|||||||
padding: 0.25rem 0.35rem;
|
padding: 0.25rem 0.35rem;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
.steer-ace,
|
||||||
|
.steer-ace-slots {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding-top: 0.35rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.steer-ace-head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem 0.65rem;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.steer-ace-head b { font-weight: 650; }
|
||||||
|
.steer-slot-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem 0.55rem;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.steer-slot-row .dot {
|
||||||
|
width: 0.85rem;
|
||||||
|
height: 0.85rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
flex: none;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
.steer-slot-row .grow { min-width: 0; overflow-wrap: anywhere; }
|
||||||
.queue-name { color: var(--ink); font-size: var(--t-sm); font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
|
.queue-name { color: var(--ink); font-size: var(--t-sm); font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
|
||||||
.queue-msg { color: var(--muted); font-size: var(--t-xs); line-height: 1.3; overflow-wrap: anywhere; }
|
.queue-msg { color: var(--muted); font-size: var(--t-xs); line-height: 1.3; overflow-wrap: anywhere; }
|
||||||
.queue-sub { color: var(--faint); font-size: 0.66rem; font-variant-numeric: tabular-nums; flex: none; }
|
.queue-sub { color: var(--faint); font-size: 0.66rem; font-variant-numeric: tabular-nums; flex: none; }
|
||||||
@@ -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-printer-name { margin: 0; font-size: 0.82rem; font-weight: 650; color: var(--ink); }
|
||||||
.ace-row {
|
.ace-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 0.85rem minmax(0, 1fr) minmax(7rem, 0.9fr);
|
grid-template-columns: 0.85rem minmax(0, 1fr) minmax(12rem, 1.4fr);
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
.ace-row select {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
.ace-swatch {
|
.ace-swatch {
|
||||||
width: 0.85rem;
|
width: 0.85rem;
|
||||||
height: 0.85rem;
|
height: 0.85rem;
|
||||||
@@ -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);
|
border-radius: var(--radius-sm);
|
||||||
min-height: var(--control-h);
|
min-height: var(--control-h);
|
||||||
}
|
}
|
||||||
|
.page-tools .date-field {
|
||||||
|
width: auto;
|
||||||
|
min-width: 9.5rem;
|
||||||
|
max-width: 11rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.page-tools .period-presets {
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
grid-auto-columns: auto;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.page-tools .period-presets button {
|
||||||
|
padding: 0.4rem 0.55rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.lead, .tiny, .summary { color: var(--muted); }
|
.lead, .tiny, .summary { color: var(--muted); }
|
||||||
.summary { margin: 0.3rem 0 0; }
|
.summary { margin: 0.3rem 0 0; }
|
||||||
.tiny { font-size: 0.8rem; margin: 0.2rem 0 0; line-height: 1.4; max-width: 52ch; }
|
.tiny { font-size: 0.8rem; margin: 0.2rem 0 0; line-height: 1.4; max-width: 52ch; }
|
||||||
@@ -615,12 +671,44 @@ article.machine.offline:hover { opacity: 1; }
|
|||||||
.job-line {
|
.job-line {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.55rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: var(--t-sm);
|
font-size: var(--t-sm);
|
||||||
|
padding: 0.45rem 0.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.job-thumb {
|
||||||
|
width: 4.5rem;
|
||||||
|
height: 4.5rem;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 9px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--card-2);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.job-thumb[hidden] { display: none; }
|
||||||
|
.job-ico { display: inline-flex; flex: none; color: var(--faint); }
|
||||||
|
.job-ico .ico { width: 1.1rem; height: 1.1rem; }
|
||||||
|
.job-line.has-thumb .job-ico { display: none; }
|
||||||
|
.job-meta {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.job-line .name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
.job-line .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; color: var(--ink); }
|
|
||||||
.job-line .swatches { padding: 0; min-height: 0; flex: none; }
|
.job-line .swatches { padding: 0; min-height: 0; flex: none; }
|
||||||
.badge {
|
.badge {
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
@@ -803,6 +891,11 @@ article.printer {
|
|||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
.camera-tile .cam-stage {
|
||||||
|
position: relative;
|
||||||
|
min-height: 140px;
|
||||||
|
background: var(--card-2);
|
||||||
|
}
|
||||||
.camera-tile img {
|
.camera-tile img {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -810,16 +903,22 @@ article.printer {
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
background: var(--card-2);
|
background: var(--card-2);
|
||||||
}
|
}
|
||||||
.camera-tile img.broken { min-height: 120px; opacity: 0.35; }
|
.camera-tile .cam-empty {
|
||||||
.cam-url {
|
margin: 0;
|
||||||
display: block;
|
min-height: 140px;
|
||||||
padding: 0.4rem 0.75rem 0.6rem;
|
display: grid;
|
||||||
font-size: 0.72rem;
|
place-content: center;
|
||||||
color: var(--faint);
|
justify-items: center;
|
||||||
word-break: break-all;
|
gap: 0.35rem;
|
||||||
text-decoration: none;
|
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 {
|
.card {
|
||||||
background: var(--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); }
|
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; }
|
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); }
|
.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 { color: var(--muted); padding: 1rem 0.3rem; }
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -1804,7 +1957,6 @@ body.desk-on main {
|
|||||||
.summary-text b { color: var(--ink); }
|
.summary-text b { color: var(--ink); }
|
||||||
.page-head-side { display: flex; align-items: center; gap: 0.75rem 1rem; flex-wrap: wrap; }
|
.page-head-side { display: flex; align-items: center; gap: 0.75rem 1rem; flex-wrap: wrap; }
|
||||||
.page-head-side .check { margin: 0; font-size: var(--t-sm); color: var(--muted); }
|
.page-head-side .check { margin: 0; font-size: var(--t-sm); color: var(--muted); }
|
||||||
.job-line > .ico { color: var(--faint); }
|
|
||||||
.job-line[hidden] { display: none; }
|
.job-line[hidden] { display: none; }
|
||||||
article.machine.flash { animation: flash 2s var(--ease); }
|
article.machine.flash { animation: flash 2s var(--ease); }
|
||||||
@keyframes flash { 0%, 40% { box-shadow: 0 0 0 2px var(--accent), var(--shadow); } }
|
@keyframes flash { 0%, 40% { box-shadow: 0 0 0 2px var(--accent), var(--shadow); } }
|
||||||
@@ -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 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 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 .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 { 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; }
|
.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; }
|
.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-secondary a { padding: 0.4rem; }
|
||||||
.nav-main a.on { box-shadow: none; border-radius: 9px; padding-left: 0.55rem; }
|
.nav-main a.on { box-shadow: none; border-radius: 9px; padding-left: 0.55rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Druckbericht & Statistik */
|
||||||
|
.bericht { display: grid; gap: 0.85rem; }
|
||||||
|
.bericht-head { display: grid; gap: 0.5rem; }
|
||||||
|
.bericht-meta p { margin: 0.2rem 0; }
|
||||||
|
.temp-chart {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 40rem;
|
||||||
|
height: auto;
|
||||||
|
background: #0c0c10;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.chart-label { fill: var(--muted); font-size: 11px; }
|
||||||
|
.chart-legend { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.35rem; }
|
||||||
|
.chart-legend .leg::before {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.65rem;
|
||||||
|
height: 0.65rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 0.3rem;
|
||||||
|
vertical-align: -0.05rem;
|
||||||
|
}
|
||||||
|
.chart-legend .nozzle::before { background: #ea580c; }
|
||||||
|
.chart-legend .bed::before { background: #2563eb; }
|
||||||
|
.chart-legend .chamber::before { background: #16a34a; }
|
||||||
|
.tag {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 650;
|
||||||
|
padding: 0.05rem 0.35rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.kpi-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
.kpi {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.7rem 0.8rem;
|
||||||
|
}
|
||||||
|
.kpi-val {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.kpi-label { color: var(--muted); font-size: 0.78rem; font-weight: 650; }
|
||||||
|
.spool-akte {
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
padding-top: 0.7rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.spool-akte h3 { margin: 0 0 0.35rem; font-size: 0.95rem; }
|
||||||
|
ul.plain { list-style: none; margin: 0; padding: 0; }
|
||||||
|
ul.plain li { padding: 0.2rem 0; border-bottom: 1px solid var(--line); }
|
||||||
|
ul.plain li:last-child { border-bottom: 0; }
|
||||||
|
.grid-2 { display: grid; gap: 0.85rem; }
|
||||||
|
@media (min-width: 800px) {
|
||||||
|
.grid-2 { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -3,15 +3,12 @@
|
|||||||
<p>Handbuch</p>
|
<p>Handbuch</p>
|
||||||
<a href="/doku#grotti" data-sec="grotti" class="{{ 'on' if current == 'start' else '' }}">Was Grotti ist</a>
|
<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#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#kapitel" data-sec="kapitel">Anleitung</a>
|
||||||
<a href="/doku#seiten" data-sec="seiten">Seiten</a>
|
|
||||||
<a href="/doku#haengt" data-sec="haengt">Wenn etwas hängt</a>
|
<a href="/doku#haengt" data-sec="haengt">Wenn etwas hängt</a>
|
||||||
<a href="/doku#begriffe" data-sec="begriffe">Begriffe</a>
|
<a href="/doku#begriffe" data-sec="begriffe">Begriffe</a>
|
||||||
<p>Versionen</p>
|
<p>Anleitung</p>
|
||||||
<a href="/doku/changelog" class="{{ 'on' if current == 'changelog' else '' }}">Changelog · v{{ version }}</a>
|
{% for name in anleitung %}
|
||||||
<p>Technik</p>
|
<a href="/doku/{{ name }}" class="{{ 'on' if current == name else '' }}">{{ anleitung_title(name) }}</a>
|
||||||
{% for name in docs %}
|
|
||||||
<a href="/doku/{{ name }}" class="{{ 'on' if current == name else '' }}">{{ name | replace('-', ' ') }}</a>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</nav>
|
</nav>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
{% block body %}
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>Druckbericht</h1>
|
||||||
|
<p class="tiny" id="bericht-sub">Lauf wird geladen …</p>
|
||||||
|
</div>
|
||||||
|
<div class="page-head-side">
|
||||||
|
<a class="ghost" href="/protokoll">← Protokoll</a>
|
||||||
|
<a class="ghost" id="etikett-druck" href="#" hidden>Etikett Druck</a>
|
||||||
|
<a class="ghost" id="etikett-objekt" href="#" hidden>Etiketten Objekte</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p id="bericht-note"></p>
|
||||||
|
<div id="bericht" class="bericht"></div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block script %}
|
||||||
|
<script>
|
||||||
|
const runId = Number(location.pathname.split("/").pop());
|
||||||
|
const states = {
|
||||||
|
completed: "fertig", cancelled: "abgebrochen", error: "Fehler",
|
||||||
|
in_progress: "druckt", printing: "druckt", paused: "pausiert", interrupted: "unterbrochen",
|
||||||
|
};
|
||||||
|
|
||||||
|
function sourceLabel(source, estimate) {
|
||||||
|
if (estimate) return "Schätzwert";
|
||||||
|
if (source === "measured") return "Messwert";
|
||||||
|
if (source === "estimate") return "Schätzwert";
|
||||||
|
return source || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function chart(samples) {
|
||||||
|
if (!samples || samples.length < 2) {
|
||||||
|
return '<p class="tiny">Kein Temperaturverlauf (nur für neue Drucke).</p>';
|
||||||
|
}
|
||||||
|
const w = 640, h = 180, pad = 28;
|
||||||
|
const temps = samples.flatMap((s) => [s.nozzle, s.bed, s.chamber, s.nozzle_target, s.bed_target].filter((v) => v != null));
|
||||||
|
const minT = Math.min(...temps, 0);
|
||||||
|
const maxT = Math.max(...temps, 100);
|
||||||
|
const x = (i) => pad + (i / (samples.length - 1)) * (w - pad * 2);
|
||||||
|
const y = (v) => h - pad - ((Number(v) - minT) / Math.max(1, maxT - minT)) * (h - pad * 2);
|
||||||
|
function path(key, color) {
|
||||||
|
const pts = samples.map((s, i) => s[key] == null ? null : `${x(i)},${y(s[key])}`).filter(Boolean);
|
||||||
|
if (pts.length < 2) return "";
|
||||||
|
return `<polyline fill="none" stroke="${color}" stroke-width="1.6" points="${pts.join(" ")}" />`;
|
||||||
|
}
|
||||||
|
return `<svg class="temp-chart" viewBox="0 0 ${w} ${h}" role="img" aria-label="Temperaturverlauf">
|
||||||
|
${path("nozzle_target", "#fdba74")}
|
||||||
|
${path("bed_target", "#93c5fd")}
|
||||||
|
${path("nozzle", "#ea580c")}
|
||||||
|
${path("bed", "#2563eb")}
|
||||||
|
${path("chamber", "#16a34a")}
|
||||||
|
<text x="${pad}" y="14" class="chart-label">${Math.round(maxT)} °C</text>
|
||||||
|
<text x="${pad}" y="${h - 8}" class="chart-label">${Math.round(minT)} °C</text>
|
||||||
|
</svg>
|
||||||
|
<p class="tiny chart-legend"><span class="leg nozzle">Düse</span> <span class="leg bed">Bett</span> <span class="leg chamber">Kammer</span> · gestrichelt ≈ Soll</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(data) {
|
||||||
|
const run = data.run;
|
||||||
|
const name = (run.filename || "").split("/").pop() || "ohne Datei";
|
||||||
|
document.getElementById("bericht-sub").textContent =
|
||||||
|
(run.printer_name || run.printer_host) + " · " + name
|
||||||
|
+ (run.chargen_nr ? " · " + run.chargen_nr : "");
|
||||||
|
|
||||||
|
const planned = Number(run.weight_planned_g) || 0;
|
||||||
|
const used = Number(run.weight_used_g) || 0;
|
||||||
|
const diff = Math.round(used) - Math.round(planned);
|
||||||
|
const cost = Number(run.filament_cost || 0) + Number(run.energy_cost || 0);
|
||||||
|
|
||||||
|
const spoolRows = (data.spools || []).map((s) => `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHtml(s.name)}${s.ace_slot ? " · ACE " + s.ace_slot : ""}</td>
|
||||||
|
<td>${escapeHtml(s.material_type || "")}</td>
|
||||||
|
<td class="num">${grams(s.weight_planned_g)}</td>
|
||||||
|
<td class="num">${grams(s.weight_g)}${s.is_estimate ? ' <span class="tag">Schätzwert</span>' : ""}</td>
|
||||||
|
<td class="num">${euro(s.cost)}</td>
|
||||||
|
<td class="nowrap">${escapeHtml(s.chargen_nr || "")}</td>
|
||||||
|
</tr>`).join("") || '<tr><td class="empty" colspan="6">Keine Spule zugeordnet.</td></tr>';
|
||||||
|
|
||||||
|
const events = (data.events || []).map((e) =>
|
||||||
|
`<li><span class="tiny">${escapeHtml((e.created_at || "").replace("T", " ").slice(0, 19))}</span> ${escapeHtml(e.message || e.kind)}</li>`
|
||||||
|
).join("") || "<li class=\"tiny\">Keine Ereignisse.</li>";
|
||||||
|
|
||||||
|
const labels = (data.labels || []).filter((l) => !l.excluded);
|
||||||
|
const labelBits = labels.length
|
||||||
|
? labels.map((l) => `<li id="objekt-${encodeURIComponent(l.object_name || "")}"><code>${escapeHtml(l.chargen_nr)}</code> ${escapeHtml(l.object_name || "Druck")}</li>`).join("")
|
||||||
|
: (run.chargen_nr ? `<li><code>${escapeHtml(run.chargen_nr)}</code> gesamter Druck</li>` : "<li class=\"tiny\">Noch keine Charge.</li>");
|
||||||
|
|
||||||
|
const segments = (data.segments || []).map((s) => {
|
||||||
|
const end = s.mm_end == null ? "…" : Math.round(s.mm_end);
|
||||||
|
return `<li>${escapeHtml(s.spool_name)} · ${Math.round(s.mm_start)}–${end} mm${s.ace_slot ? " · Platz " + s.ace_slot : ""}</li>`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
document.getElementById("bericht").innerHTML = `
|
||||||
|
<div class="card bericht-head">
|
||||||
|
<div class="bericht-meta">
|
||||||
|
<p><b>${escapeHtml(states[run.state] || run.state)}</b>${run.verdict ? " · Urteil " + escapeHtml(run.verdict) : ""}</p>
|
||||||
|
<p class="tiny">${escapeHtml(run.started_at || "—")} → ${escapeHtml(run.ended_at || "—")}</p>
|
||||||
|
<p>Dauer <b>${hours(run.duration_s)}</b>${run.estimated_s ? " · Soll " + hours(run.estimated_s) : ""}</p>
|
||||||
|
<p>Material <b>${grams(used)}</b> von ${grams(planned)}${planned ? " (" + (diff > 0 ? "+" : "") + diff + " g)" : ""}
|
||||||
|
${run.weight_source ? ' · <span class="tag">' + sourceLabel(run.weight_source) + "</span>" : ""}</p>
|
||||||
|
<p>Kosten <b>${euro(cost)}</b> (Filament ${euro(run.filament_cost)} · Strom ${euro(run.energy_cost)})</p>
|
||||||
|
${run.message ? "<p class=\"msg\">" + escapeHtml(run.message) + "</p>" : ""}
|
||||||
|
${data.project ? "<p class=\"tiny\">Projekt: " + escapeHtml(data.project.name) + "</p>" : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Spulen</h2>
|
||||||
|
<div class="table-wrap"><table>
|
||||||
|
<thead><tr><th>Spule</th><th>Sorte</th><th>Soll</th><th>Ist</th><th>Kosten</th><th>Charge</th></tr></thead>
|
||||||
|
<tbody>${spoolRows}</tbody>
|
||||||
|
</table></div>
|
||||||
|
${segments ? "<h3>Abschnitte</h3><ul class=\"plain\">" + segments + "</ul>" : ""}
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Verlauf</h2>
|
||||||
|
${chart(data.samples)}
|
||||||
|
</div>
|
||||||
|
<div class="card grid-2">
|
||||||
|
<div>
|
||||||
|
<h2>Chargen</h2>
|
||||||
|
<ul class="plain">${labelBits}</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>Ereignisse</h2>
|
||||||
|
<ul class="plain">${events}</ul>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const druck = document.getElementById("etikett-druck");
|
||||||
|
const objekt = document.getElementById("etikett-objekt");
|
||||||
|
if (run.chargen_nr) {
|
||||||
|
druck.hidden = false;
|
||||||
|
druck.href = "/etikett/lauf/" + run.id + "?mode=druck";
|
||||||
|
objekt.hidden = false;
|
||||||
|
objekt.href = "/etikett/lauf/" + run.id + "?mode=objekt&qty=1";
|
||||||
|
} else {
|
||||||
|
druck.hidden = true;
|
||||||
|
objekt.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api("/laeufe/" + runId).then(render).catch((error) => showNote("bericht-note", error));
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
<select id="send-project"><option value="">keins</option></select>
|
<select id="send-project"><option value="">keins</option></select>
|
||||||
<div id="ace-box" class="ace-box" hidden>
|
<div id="ace-box" class="ace-box" hidden>
|
||||||
<h3>ACE-Zuordnung <span class="tip" title="Datei-Farbe auf ACE-Platz. Ohne Zuordnung startet der ACE oft nicht."><svg class="ico"><use href="#i-info"/></svg></span></h3>
|
<h3>ACE-Zuordnung <span class="tip" title="Datei-Farbe auf ACE-Platz. Ohne Zuordnung startet der ACE oft nicht."><svg class="ico"><use href="#i-info"/></svg></span></h3>
|
||||||
<p class="tiny msg" id="ace-trailer-warn" hidden>Für Auto-Feed fehlen Slicer-Metadaten in der Datei.</p>
|
<p class="tiny" id="ace-trailer-warn" hidden>Mehrfarb ohne Slicer-Trailer — Grotti ergänzt `; referenced_tools` beim Senden.</p>
|
||||||
<div id="ace-maps"></div>
|
<div id="ace-maps"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block script %}
|
{% block script %}
|
||||||
<script src="{{ url_for('static', filename='three.min.js') }}?v=1"></script>
|
<script src="{{ url_for('static', filename='three.min.js') }}?v=1"></script>
|
||||||
<script src="{{ url_for('static', filename='gcode-viewer.js') }}?v=8"></script>
|
<script src="{{ url_for('static', filename='gcode-viewer.js') }}?v=10"></script>
|
||||||
<script src="{{ url_for('static', filename='sweep-viewer.js') }}?v=1"></script>
|
<script src="{{ url_for('static', filename='sweep-viewer.js') }}?v=1"></script>
|
||||||
<script>
|
<script>
|
||||||
const families = { kobra_s1: "Kobra S1", kobra_3: "Kobra 3" };
|
const families = { kobra_s1: "Kobra S1", kobra_3: "Kobra 3" };
|
||||||
@@ -198,7 +198,7 @@ async function showToolpath(file, progress) {
|
|||||||
if (viewerFileId !== file.id) {
|
if (viewerFileId !== file.id) {
|
||||||
meta.textContent = "Pfad wird gelesen …";
|
meta.textContent = "Pfad wird gelesen …";
|
||||||
try {
|
try {
|
||||||
const data = await api("/dateien/" + file.id + "/pfad");
|
const data = await api("/dateien/" + file.id + "/pfad?tp=8");
|
||||||
viewer.setPath(data);
|
viewer.setPath(data);
|
||||||
viewerFileId = file.id;
|
viewerFileId = file.id;
|
||||||
meta.textContent = (data.count || 0).toLocaleString("de-DE") + " Punkte";
|
meta.textContent = (data.count || 0).toLocaleString("de-DE") + " Punkte";
|
||||||
@@ -252,13 +252,41 @@ function loadSendMemory() {
|
|||||||
|
|
||||||
/* ---------- ACE / Spule ---------- */
|
/* ---------- ACE / Spule ---------- */
|
||||||
|
|
||||||
function slotLabel(slot) {
|
function spoolAtSlot(host, place) {
|
||||||
|
return spoolsOnHost(host).find((s) => Number(s.ace_slot) === Number(place)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotLabel(host, slot) {
|
||||||
const place = Number(slot.index) + 1;
|
const place = Number(slot.index) + 1;
|
||||||
const box = place <= 4 ? "ACE 1 · " + place : "ACE 2 · " + (place - 4);
|
const box = place <= 4 ? "ACE 1 · " + place : "ACE 2 · " + (place - 4);
|
||||||
|
const spool = spoolAtSlot(host, place);
|
||||||
|
if (spool) {
|
||||||
|
const bits = [box];
|
||||||
|
const kind = spool.material_type || slot.type || "";
|
||||||
|
if (kind) bits.push(kind);
|
||||||
|
if (spool.color) bits.push(spool.color);
|
||||||
|
if (spool.name) bits.push(spool.name);
|
||||||
|
bits.push(grams(spool.remaining_g));
|
||||||
|
return bits.join(" · ");
|
||||||
|
}
|
||||||
const kind = slot.type ? " · " + slot.type : "";
|
const kind = slot.type ? " · " + slot.type : "";
|
||||||
return box + kind;
|
return box + kind;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function slotTitle(host, slot) {
|
||||||
|
const place = Number(slot.index) + 1;
|
||||||
|
const spool = spoolAtSlot(host, place);
|
||||||
|
if (!spool) return slot.type ? String(slot.type) : "";
|
||||||
|
const bits = [];
|
||||||
|
if (spool.brand) bits.push(spool.brand);
|
||||||
|
if (spool.name) bits.push(spool.name);
|
||||||
|
if (spool.material_type) bits.push(spool.material_type);
|
||||||
|
if (spool.color) bits.push(spool.color);
|
||||||
|
bits.push(grams(spool.remaining_g) + " Rest");
|
||||||
|
if (spool.chargen_nr) bits.push(spool.chargen_nr);
|
||||||
|
return bits.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
function filamentLabel(filament, index) {
|
function filamentLabel(filament, index) {
|
||||||
const kind = filament.type || "Filament";
|
const kind = filament.type || "Filament";
|
||||||
const color = filament.color ? " · " + filament.color : "";
|
const color = filament.color ? " · " + filament.color : "";
|
||||||
@@ -369,7 +397,10 @@ function renderAceMaps(force) {
|
|||||||
const paints = filePaints(file);
|
const paints = filePaints(file);
|
||||||
box.hidden = false;
|
box.hidden = false;
|
||||||
document.getElementById("options").open = true;
|
document.getElementById("options").open = true;
|
||||||
if (trailerWarn) trailerWarn.hidden = !!file.ace_trailer;
|
// Mono: Trailer fehlt oft und ist kein Fehler — Grotti setzt ihn beim Senden mit ACE-Map.
|
||||||
|
if (trailerWarn) {
|
||||||
|
trailerWarn.hidden = !!file.ace_trailer || paints.length <= 1;
|
||||||
|
}
|
||||||
root.innerHTML = "";
|
root.innerHTML = "";
|
||||||
chosen.forEach((printer) => {
|
chosen.forEach((printer) => {
|
||||||
const block = document.createElement("div");
|
const block = document.createElement("div");
|
||||||
@@ -393,10 +424,15 @@ function renderAceMaps(force) {
|
|||||||
printer.slots.forEach((slot) => {
|
printer.slots.forEach((slot) => {
|
||||||
const option = document.createElement("option");
|
const option = document.createElement("option");
|
||||||
option.value = String(slot.index);
|
option.value = String(slot.index);
|
||||||
option.textContent = slotLabel(slot);
|
option.textContent = slotLabel(printer.host, slot);
|
||||||
if (slot.color) option.style.borderLeft = "4px solid " + slot.color;
|
option.title = slotTitle(printer.host, slot);
|
||||||
// einfache Vorauswahl: gleiche Sorte, sonst Platz in Reihenfolge
|
const spool = spoolAtSlot(printer.host, Number(slot.index) + 1);
|
||||||
if (!select.dataset.picked && filament.type && slot.type && filament.type.toLowerCase() === String(slot.type).toLowerCase()) {
|
const hex = (spool && spool.colors && spool.colors[0] && spool.colors[0].hex) || slot.color || "";
|
||||||
|
if (hex) option.style.borderLeft = "4px solid " + hex;
|
||||||
|
const spoolType = (spool && spool.material_type) || slot.type || "";
|
||||||
|
// Vorauswahl: gleiche Sorte (Grotti-Spule bevorzugt), sonst Platz in Reihenfolge
|
||||||
|
if (!select.dataset.picked && filament.type && spoolType
|
||||||
|
&& filament.type.toLowerCase() === String(spoolType).toLowerCase()) {
|
||||||
option.selected = true;
|
option.selected = true;
|
||||||
select.dataset.picked = "1";
|
select.dataset.picked = "1";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
<article class="prose">
|
<article class="prose">
|
||||||
<h1 id="grotti">Grotti <span class="badge">v{{ version }}</span></h1>
|
<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 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>
|
<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 und kein <code>FIRMWARE_RESTART</code>.</blockquote>
|
<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>
|
<h2 id="druck">Ein Druck von A bis Z</h2>
|
||||||
<div class="flow-diagram">
|
<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>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>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>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>
|
<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>
|
</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>
|
<h2 id="kapitel">Anleitung</h2>
|
||||||
<p>Ein Ablauf ist eine Kette, die der Kobra 3 alleine abarbeitet und beliebig oft wiederholt:</p>
|
<p>Schritt für Schritt mit Screenshots. Kurzfassung oben, Details in den Kapiteln.</p>
|
||||||
<div class="flow-diagram">
|
<ul class="doc-chapters">
|
||||||
<span class="step">Drucken</span><span class="step-sep"></span>
|
{% for name in anleitung %}
|
||||||
<span class="step">Abkühlen</span><span class="step-sep"></span>
|
<li><a href="/doku/{{ name }}">{{ anleitung_title(name) }}</a></li>
|
||||||
<span class="step">Schieben</span><span class="step-sep"></span>
|
{% endfor %}
|
||||||
<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>
|
|
||||||
</ul>
|
</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>
|
<h2 id="haengt">Wenn etwas hängt</h2>
|
||||||
<dl class="terms">
|
<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>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>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>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>
|
<dt>ACE startet nicht</dt><dd>Unter Optionen die ACE-Zuordnung prüfen. Ohne Zuordnung kommt oft „cannot parse“.</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
<p>Mehr Fälle: <a href="/doku/Anleitung-Stoerungen">Störungen</a>.</p>
|
||||||
|
|
||||||
<h2 id="begriffe">Begriffe</h2>
|
<h2 id="begriffe">Begriffe</h2>
|
||||||
<dl class="terms">
|
<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>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>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>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>
|
</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>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<p id="auto-note"></p>
|
<p id="auto-note"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card admin-only">
|
||||||
<h2>Öffentliche Adresse</h2>
|
<h2>Öffentliche Adresse</h2>
|
||||||
<p class="hint">FQDN bzw. Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host der aktuellen Anfrage (z. B. localhost).</p>
|
<p class="hint">FQDN bzw. Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host der aktuellen Anfrage (z. B. localhost).</p>
|
||||||
<div class="field" style="max-width:28rem">
|
<div class="field" style="max-width:28rem">
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
<p id="fqdn-note"></p>
|
<p id="fqdn-note"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card admin-only">
|
||||||
<h2>Drucker</h2>
|
<h2>Drucker</h2>
|
||||||
<p class="hint">Gesperrt = kein neuer „wenn frei“-Start auf diesem Drucker.</p>
|
<p class="hint">Gesperrt = kein neuer „wenn frei“-Start auf diesem Drucker.</p>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
<p id="printer-note"></p>
|
<p id="printer-note"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card admin-only">
|
||||||
<h2>Telegram</h2>
|
<h2>Telegram</h2>
|
||||||
<label class="check"><input type="checkbox" id="tg-enabled"> Benachrichtigungen aktiv</label>
|
<label class="check"><input type="checkbox" id="tg-enabled"> Benachrichtigungen aktiv</label>
|
||||||
<div class="grid-2" style="margin-top:0.75rem">
|
<div class="grid-2" style="margin-top:0.75rem">
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
<p id="tg-note"></p>
|
<p id="tg-note"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card admin-only">
|
||||||
<h2>Energie</h2>
|
<h2>Energie</h2>
|
||||||
<p class="hint">Für Kosten im Protokoll. Filament zählt erst mit zugeordneter Spule.</p>
|
<p class="hint">Für Kosten im Protokoll. Filament zählt erst mit zugeordneter Spule.</p>
|
||||||
<div class="grid-2">
|
<div class="grid-2">
|
||||||
@@ -115,10 +115,61 @@
|
|||||||
<div class="actions"><button type="button" id="en-save">Speichern</button></div>
|
<div class="actions"><button type="button" id="en-save">Speichern</button></div>
|
||||||
<p id="en-note"></p>
|
<p id="en-note"></p>
|
||||||
</section>
|
</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 %}
|
{% endblock %}
|
||||||
{% block script %}
|
{% block script %}
|
||||||
<script>
|
<script>
|
||||||
const families = { kobra_3: "Kobra 3", kobra_s1: "Kobra S1", "": "—" };
|
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 */
|
/* showNote aus api.js: Inline-Hinweis + Toast */
|
||||||
|
|
||||||
@@ -181,15 +232,109 @@ 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() {
|
async function load() {
|
||||||
const [settings, printers] = await Promise.all([
|
const [settings, printers] = await Promise.all([
|
||||||
api("/einstellungen"),
|
api("/einstellungen"),
|
||||||
api("/drucker?live=0"),
|
api("/drucker?live=0"),
|
||||||
]);
|
]);
|
||||||
renderPrinters(printers);
|
if (isAdmin) renderPrinters(printers);
|
||||||
document.getElementById("auto-queue").checked = settings.auto_queue !== false;
|
document.getElementById("auto-queue").checked = settings.auto_queue !== false;
|
||||||
document.getElementById("fqdn").value = settings.fqdn || "";
|
const fqdn = document.getElementById("fqdn");
|
||||||
document.getElementById("tg-enabled").checked = !!(settings.telegram && settings.telegram.enabled);
|
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").value = "";
|
||||||
document.getElementById("tg-token").placeholder = settings.telegram && settings.telegram.bot_token_set
|
document.getElementById("tg-token").placeholder = settings.telegram && settings.telegram.bot_token_set
|
||||||
? ("gesetzt: " + (settings.telegram.bot_token || "••••"))
|
? ("gesetzt: " + (settings.telegram.bot_token || "••••"))
|
||||||
@@ -209,8 +354,53 @@ async function load() {
|
|||||||
document.getElementById("en-dryh").value = en.dry_hours ?? 0;
|
document.getElementById("en-dryh").value = en.dry_hours ?? 0;
|
||||||
document.getElementById("en-waste").value = en.waste_percent ?? 0;
|
document.getElementById("en-waste").value = en.waste_percent ?? 0;
|
||||||
document.getElementById("en-util").value = en.utilization ?? 50;
|
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 () => {
|
document.getElementById("auto-save").addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
await api("/einstellungen", {
|
await api("/einstellungen", {
|
||||||
|
|||||||
@@ -41,14 +41,17 @@
|
|||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 22mm;
|
grid-template-columns: 1fr 20mm;
|
||||||
gap: 3mm;
|
gap: 2.5mm;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.meta { min-width: 0; }
|
||||||
.meta h1 {
|
.meta h1 {
|
||||||
margin: 0 0 2mm;
|
margin: 0 0 2mm;
|
||||||
font-size: 4.2mm;
|
font-size: 4.2mm;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.meta p { margin: 0 0 1.2mm; font-size: 3.1mm; color: #333; }
|
.meta p { margin: 0 0 1.2mm; font-size: 3.1mm; color: #333; }
|
||||||
.meta .code { font-weight: 700; font-variant-numeric: tabular-nums; }
|
.meta .code { font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||||
@@ -61,7 +64,19 @@
|
|||||||
margin-left: -1mm;
|
margin-left: -1mm;
|
||||||
}
|
}
|
||||||
.dot:first-child { margin-left: 0; }
|
.dot:first-child { margin-left: 0; }
|
||||||
#qr { width: 20mm; height: 20mm; }
|
.qr-wrap {
|
||||||
|
width: 20mm;
|
||||||
|
height: 20mm;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.qr-wrap canvas {
|
||||||
|
display: block;
|
||||||
|
width: 20mm !important;
|
||||||
|
height: 20mm !important;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
@media print {
|
@media print {
|
||||||
body { background: #fff; }
|
body { background: #fff; }
|
||||||
.toolbar { display: none; }
|
.toolbar { display: none; }
|
||||||
@@ -99,14 +114,20 @@
|
|||||||
<p>Hersteller: {{ spool.hersteller_charge }}</p>
|
<p>Hersteller: {{ spool.hersteller_charge }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<canvas id="qr" width="120" height="120"></canvas>
|
<div class="qr-wrap">
|
||||||
|
<canvas id="qr" width="80" height="80"></canvas>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="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>
|
<script>
|
||||||
QRCode.toCanvas(document.getElementById("qr"), {{ qr_url|tojson }}, {
|
QRCode.toCanvas(document.getElementById("qr"), {{ qr_url|tojson }}, {
|
||||||
width: 120,
|
width: 80,
|
||||||
margin: 1,
|
margin: 1,
|
||||||
color: { dark: "#111111", light: "#ffffff" }
|
color: { dark: "#111111", light: "#ffffff" }
|
||||||
|
}, () => {
|
||||||
|
const node = document.getElementById("qr");
|
||||||
|
node.style.width = "20mm";
|
||||||
|
node.style.height = "20mm";
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{ title }}</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font: 14px/1.35 "Segoe UI", system-ui, sans-serif;
|
||||||
|
color: #111;
|
||||||
|
background: #f3f3f3;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
|
.toolbar a, .toolbar button {
|
||||||
|
color: #111;
|
||||||
|
background: #f97316;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.4rem 0.85rem;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 650;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.toolbar a.ghost { background: transparent; color: #eee; border: 1px solid #555; }
|
||||||
|
.sheet {
|
||||||
|
width: 70mm;
|
||||||
|
min-height: 40mm;
|
||||||
|
margin: 1.2rem auto;
|
||||||
|
padding: 3.5mm;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 20mm;
|
||||||
|
gap: 2.5mm;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
page-break-after: always;
|
||||||
|
}
|
||||||
|
.sheet:last-child { page-break-after: auto; }
|
||||||
|
.meta { min-width: 0; }
|
||||||
|
.meta h1 {
|
||||||
|
margin: 0 0 2mm;
|
||||||
|
font-size: 4.2mm;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.meta p { margin: 0 0 1.2mm; font-size: 3.1mm; color: #333; }
|
||||||
|
.meta .code { font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||||
|
.qr-wrap {
|
||||||
|
width: 20mm;
|
||||||
|
height: 20mm;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.qr-wrap canvas,
|
||||||
|
.qr-wrap img {
|
||||||
|
display: block;
|
||||||
|
width: 20mm !important;
|
||||||
|
height: 20mm !important;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
body { background: #fff; }
|
||||||
|
.toolbar { display: none; }
|
||||||
|
.sheet {
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button type="button" onclick="window.print()">Drucken</button>
|
||||||
|
<a class="ghost" href="/protokoll/{{ run.id }}">Zurück</a>
|
||||||
|
</div>
|
||||||
|
{% for sheet in sheets %}
|
||||||
|
<div class="sheet">
|
||||||
|
<div class="meta">
|
||||||
|
<h1>{{ sheet.object_name or (run.filename.split('/')[-1] if run.filename else 'Druck') }}</h1>
|
||||||
|
<p class="code">{{ sheet.chargen_nr }}</p>
|
||||||
|
{% set soll = sheet.target_g if sheet.target_g is defined else target_g %}
|
||||||
|
{% if soll %}
|
||||||
|
<p>Ziel {{ '%.0f'|format(soll) }} g</p>
|
||||||
|
{% endif %}
|
||||||
|
<p>{{ run.printer_name }} · {{ (run.ended_at or run.started_at or '')[:10] }}</p>
|
||||||
|
{% for spool in spools %}
|
||||||
|
<p>{% if spool.material_type %}{{ spool.material_type }}{% endif %}{% if spool.brand %} · {{ spool.brand }}{% endif %}{% if spool.name %} · {{ spool.name }}{% endif %}{% if spool.chargen_nr %} · {{ spool.chargen_nr }}{% endif %}{% if spool.hersteller_charge %} · H: {{ spool.hersteller_charge }}{% endif %}</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="code" style="font-weight:500;color:#666">kein Material zugeordnet</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="qr-wrap">
|
||||||
|
<canvas class="qr" id="qr-{{ loop.index0 }}" width="80" height="80" data-url="{{ sheet.qr_url }}"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
|
||||||
|
<script>
|
||||||
|
document.querySelectorAll("canvas.qr").forEach((node) => {
|
||||||
|
QRCode.toCanvas(node, node.dataset.url, {
|
||||||
|
width: 80,
|
||||||
|
margin: 1,
|
||||||
|
color: { dark: "#111111", light: "#ffffff" }
|
||||||
|
}, () => {
|
||||||
|
node.style.width = "20mm";
|
||||||
|
node.style.height = "20mm";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -11,23 +11,12 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block script %}
|
{% block script %}
|
||||||
<script>
|
<script>
|
||||||
const labels = {
|
|
||||||
printing: "druckt", paused: "pausiert", complete: "fertig", standby: "bereit",
|
|
||||||
heating: "heizt", error: "Fehler", cancelled: "abgebrochen", offline: "offline"
|
|
||||||
};
|
|
||||||
const tiles = new Map();
|
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) {
|
function metaLine(printer) {
|
||||||
const state = shownState(printer);
|
const state = shownState(printer);
|
||||||
const busy = state === "printing" || state === "paused";
|
const busy = state === "printing" || state === "paused";
|
||||||
const bits = [labels[state] || state];
|
const bits = [STATE_LABELS[state] || state];
|
||||||
if (busy) {
|
if (busy) {
|
||||||
const pct = Math.round((printer.progress || 0) * 100);
|
const pct = Math.round((printer.progress || 0) * 100);
|
||||||
bits.push(pct + " %");
|
bits.push(pct + " %");
|
||||||
@@ -52,8 +41,11 @@ function ensureTile(printer) {
|
|||||||
<span class="host"></span>
|
<span class="host"></span>
|
||||||
</figcaption>
|
</figcaption>
|
||||||
<p class="cam-meta tiny"></p>
|
<p class="cam-meta tiny"></p>
|
||||||
<img alt="" data-role="cam">
|
<div class="cam-stage">
|
||||||
<a class="cam-url" href="#"></a>`;
|
<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);
|
tiles.set(printer.host, tile);
|
||||||
document.getElementById("grid").appendChild(tile);
|
document.getElementById("grid").appendChild(tile);
|
||||||
return tile;
|
return tile;
|
||||||
@@ -65,10 +57,10 @@ async function refresh() {
|
|||||||
const grid = document.getElementById("grid");
|
const grid = document.getElementById("grid");
|
||||||
if (!printers.length) {
|
if (!printers.length) {
|
||||||
tiles.clear();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (grid.querySelector(".empty")) grid.innerHTML = "";
|
if (grid.querySelector(".empty-state")) grid.innerHTML = "";
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
for (const printer of printers) {
|
for (const printer of printers) {
|
||||||
seen.add(printer.host);
|
seen.add(printer.host);
|
||||||
@@ -78,16 +70,28 @@ async function refresh() {
|
|||||||
tile.querySelector(".cam-name").textContent = printer.name;
|
tile.querySelector(".cam-name").textContent = printer.name;
|
||||||
tile.querySelector(".host").textContent = printer.host;
|
tile.querySelector(".host").textContent = printer.host;
|
||||||
tile.querySelector(".cam-meta").textContent = metaLine(printer);
|
tile.querySelector(".cam-meta").textContent = metaLine(printer);
|
||||||
const path = "/api/v1/kamera?host=" + encodeURIComponent(printer.host);
|
tile.querySelector(".cam-open").href = "/#" + encodeURIComponent(printer.host);
|
||||||
const shown = "/api/v1/kamera?host=" + printer.host;
|
|
||||||
const img = tile.querySelector('[data-role="cam"]');
|
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.alt = "Kamera " + printer.name;
|
||||||
img.src = withToken(path) + "&t=" + Math.floor(Date.now() / 2000);
|
const next = withToken(printer.camera) + "&t=" + Math.floor(Date.now() / 2000);
|
||||||
img.classList.remove("broken");
|
img.onerror = () => {
|
||||||
img.onerror = () => img.classList.add("broken");
|
img.hidden = true;
|
||||||
const link = tile.querySelector(".cam-url");
|
empty.hidden = false;
|
||||||
link.href = withToken(path);
|
empty.innerHTML = icon("camera") + "<span>Keine Kamera</span>";
|
||||||
link.textContent = shown;
|
};
|
||||||
|
img.onload = () => {
|
||||||
|
img.hidden = false;
|
||||||
|
empty.hidden = true;
|
||||||
|
};
|
||||||
|
img.src = next;
|
||||||
}
|
}
|
||||||
for (const [host, tile] of [...tiles.entries()]) {
|
for (const [host, tile] of [...tiles.entries()]) {
|
||||||
if (!seen.has(host)) {
|
if (!seen.has(host)) {
|
||||||
|
|||||||
@@ -3,9 +3,13 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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>
|
<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>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
try {
|
try {
|
||||||
@@ -84,7 +88,10 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="brand-row">
|
<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>
|
<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>
|
</div>
|
||||||
<nav class="nav-main" aria-label="Hauptmenü">
|
<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="/kameras" class="{{ 'on' if page == 'kameras' else '' }}" title="Kameras"><svg class="ico"><use href="#i-camera"/></svg><span>Kameras</span></a>
|
||||||
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk" title="Aktivität"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
|
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk" title="Aktivität"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
|
||||||
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}" title="Warteschlange"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
|
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}" title="Warteschlange"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
|
||||||
<a href="/protokoll" class="{{ 'on' if page == 'protokoll' else '' }} nav-desk" title="Protokoll"><svg class="ico"><use href="#i-list"/></svg><span>Protokoll</span></a>
|
<a href="/protokoll" class="{{ 'on' if page == 'protokoll' or page == 'bericht' else '' }} nav-desk" title="Protokoll"><svg class="ico"><use href="#i-list"/></svg><span>Protokoll</span></a>
|
||||||
|
<a href="/statistik" class="{{ 'on' if page == 'statistik' else '' }} nav-desk" title="Statistik"><svg class="ico"><use href="#i-activity"/></svg><span>Statistik</span></a>
|
||||||
<a href="/material" class="{{ 'on' if page == 'material' else '' }} nav-desk" title="Material"><svg class="ico"><use href="#i-spool"/></svg><span>Material</span></a>
|
<a href="/material" class="{{ 'on' if page == 'material' else '' }} nav-desk" title="Material"><svg class="ico"><use href="#i-spool"/></svg><span>Material</span></a>
|
||||||
<a href="/projekte" class="{{ 'on' if page == 'projekte' else '' }} nav-desk" title="Projekte"><svg class="ico"><use href="#i-folder"/></svg><span>Projekte</span></a>
|
<a href="/projekte" class="{{ 'on' if page == 'projekte' else '' }} nav-desk" title="Projekte"><svg class="ico"><use href="#i-folder"/></svg><span>Projekte</span></a>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -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>
|
<a href="/doku" class="{{ 'on' if page == 'doku' else '' }}" title="Doku"><svg class="ico"><use href="#i-book"/></svg><span>Doku</span></a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="side-foot">
|
<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>
|
<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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="app-main">
|
<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="/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>
|
<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>
|
</nav>
|
||||||
<script src="{{ url_for('static', filename='api.js') }}?v=23"></script>
|
<script src="{{ url_for('static', filename='api.js') }}?v=24"></script>
|
||||||
<script>
|
<script>
|
||||||
|
document.getElementById("logout-btn")?.addEventListener("click", async () => {
|
||||||
|
try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {}
|
||||||
|
location.href = "/login";
|
||||||
|
});
|
||||||
(function applyShellClasses() {
|
(function applyShellClasses() {
|
||||||
const shelf = document.documentElement.classList.contains("shelf");
|
const shelf = document.documentElement.classList.contains("shelf");
|
||||||
const collapsed = document.documentElement.classList.contains("nav-collapsed");
|
const collapsed = document.documentElement.classList.contains("nav-collapsed");
|
||||||
@@ -272,6 +290,31 @@ function directItemHtml(p) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let queueCache = [];
|
let queueCache = [];
|
||||||
|
const lastLiveFilename = Object.create(null);
|
||||||
|
|
||||||
|
function setListHtml(el, html) {
|
||||||
|
if (!el) return;
|
||||||
|
if (el._html === html) return;
|
||||||
|
el._html = html;
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stickyLiveFilename(host, filename, state) {
|
||||||
|
const next = filename || "";
|
||||||
|
if (next) {
|
||||||
|
lastLiveFilename[host] = next;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
const prev = lastLiveFilename[host] || "";
|
||||||
|
if (prev && ["printing", "paused", "heating", "complete"].includes(String(state || "").toLowerCase())) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
if (!["printing", "paused", "heating"].includes(String(state || "").toLowerCase())) {
|
||||||
|
delete lastLiveFilename[host];
|
||||||
|
}
|
||||||
|
return prev && ["complete"].includes(String(state || "").toLowerCase()) ? prev : "";
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshPrintQueue() {
|
async function refreshPrintQueue() {
|
||||||
let jobs;
|
let jobs;
|
||||||
let active = [];
|
let active = [];
|
||||||
@@ -281,7 +324,10 @@ async function refreshPrintQueue() {
|
|||||||
queueCache = jobs || [];
|
queueCache = jobs || [];
|
||||||
const running = queueCache.filter((job) => job.status === "running");
|
const running = queueCache.filter((job) => job.status === "running");
|
||||||
const queuedHosts = new Set(running.map((job) => job.host));
|
const queuedHosts = new Set(running.map((job) => job.host));
|
||||||
const direct = (active || []).filter((p) => !queuedHosts.has(p.host));
|
const direct = (active || []).filter((p) => !queuedHosts.has(p.host)).map((p) => ({
|
||||||
|
...p,
|
||||||
|
filename: stickyLiveFilename(p.host, p.filename, p.state),
|
||||||
|
}));
|
||||||
const waiting = queueCache.filter((job) => job.status === "waiting");
|
const waiting = queueCache.filter((job) => job.status === "waiting");
|
||||||
const errors = queueCache
|
const errors = queueCache
|
||||||
.filter((job) => job.status === "error")
|
.filter((job) => job.status === "error")
|
||||||
@@ -291,15 +337,21 @@ async function refreshPrintQueue() {
|
|||||||
const runBox = document.getElementById("run-box");
|
const runBox = document.getElementById("run-box");
|
||||||
runBox.hidden = !(running.length || direct.length);
|
runBox.hidden = !(running.length || direct.length);
|
||||||
document.getElementById("run-count").textContent = running.length + direct.length;
|
document.getElementById("run-count").textContent = running.length + direct.length;
|
||||||
document.getElementById("run-list").innerHTML = running.map(runItemHtml).join("") + direct.map(directItemHtml).join("");
|
setListHtml(
|
||||||
|
document.getElementById("run-list"),
|
||||||
|
running.map(runItemHtml).join("") + direct.map(directItemHtml).join(""),
|
||||||
|
);
|
||||||
|
|
||||||
const waitList = document.getElementById("wait-list");
|
const waitList = document.getElementById("wait-list");
|
||||||
document.getElementById("wait-count").textContent = waiting.length;
|
document.getElementById("wait-count").textContent = waiting.length;
|
||||||
document.getElementById("wait-count").className = "count" + (errors.length ? " bad" : waiting.length ? " hot" : "");
|
document.getElementById("wait-count").className = "count" + (errors.length ? " bad" : waiting.length ? " hot" : "");
|
||||||
document.getElementById("wait-empty").hidden = !!(waiting.length || errors.length);
|
document.getElementById("wait-empty").hidden = !!(waiting.length || errors.length);
|
||||||
document.getElementById("wait-box").classList.toggle("is-empty", !(waiting.length || errors.length));
|
document.getElementById("wait-box").classList.toggle("is-empty", !(waiting.length || errors.length));
|
||||||
waitList.innerHTML = errors.map((job) => waitItemHtml(job, 0, 1)).join("")
|
setListHtml(
|
||||||
+ waiting.map((job, i) => waitItemHtml(job, i, waiting.length)).join("");
|
waitList,
|
||||||
|
errors.map((job) => waitItemHtml(job, 0, 1)).join("")
|
||||||
|
+ waiting.map((job, i) => waitItemHtml(job, i, waiting.length)).join(""),
|
||||||
|
);
|
||||||
|
|
||||||
const panel = document.getElementById("live-panel");
|
const panel = document.getElementById("live-panel");
|
||||||
panel.querySelectorAll("[data-cancel-q]").forEach((btn) => {
|
panel.querySelectorAll("[data-cancel-q]").forEach((btn) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -120,6 +120,11 @@
|
|||||||
<button type="button" class="warn" id="spool-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
|
<button type="button" class="warn" id="spool-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<div id="spool-akte" class="spool-akte" hidden>
|
||||||
|
<h3>Akte</h3>
|
||||||
|
<p class="tiny" id="akte-summary"></p>
|
||||||
|
<ul class="plain" id="akte-runs"></ul>
|
||||||
|
</div>
|
||||||
<p id="spool-note"></p>
|
<p id="spool-note"></p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -424,6 +429,49 @@ function payload() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadAkte(spoolId) {
|
||||||
|
const box = document.getElementById("spool-akte");
|
||||||
|
if (!spoolId) {
|
||||||
|
box.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await api("/spulen/" + spoolId + "/akte");
|
||||||
|
const stats = data.stats || {};
|
||||||
|
const fillPct = Math.round(Number(stats.fill_pct) || 0);
|
||||||
|
let leer = "—";
|
||||||
|
if (stats.empty_in_days == null) leer = "kein Verbrauch";
|
||||||
|
else if (stats.empty_in_days <= 0) leer = "leer";
|
||||||
|
else leer = "noch ~" + Math.round(stats.empty_in_days) + " Tage";
|
||||||
|
document.getElementById("akte-summary").textContent =
|
||||||
|
"Verdruckt " + grams(stats.total_g)
|
||||||
|
+ " · Ausschuss " + grams(stats.scrap_g)
|
||||||
|
+ " · " + euro(stats.total_cost)
|
||||||
|
+ " · Füllstand " + fillPct + " % · Leer-in: " + leer;
|
||||||
|
const list = document.getElementById("akte-runs");
|
||||||
|
list.innerHTML = "";
|
||||||
|
(data.runs || []).slice(0, 12).forEach((run) => {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = "/protokoll/" + run.id;
|
||||||
|
a.textContent = (run.filename || "").split("/").pop() || ("#" + run.id);
|
||||||
|
li.appendChild(a);
|
||||||
|
li.appendChild(document.createTextNode(
|
||||||
|
" · " + grams(run.spool_weight_g)
|
||||||
|
+ (run.spool_is_estimate ? " ≈" : "")
|
||||||
|
+ " · " + (run.verdict || run.state || "")
|
||||||
|
));
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
if (!(data.runs || []).length) {
|
||||||
|
list.innerHTML = '<li class="tiny">Noch kein Druck auf dieser Spule.</li>';
|
||||||
|
}
|
||||||
|
box.hidden = false;
|
||||||
|
} catch (error) {
|
||||||
|
box.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function fill(spool) {
|
function fill(spool) {
|
||||||
openSpoolPanel();
|
openSpoolPanel();
|
||||||
document.getElementById("spool-id").value = spool ? spool.id : "";
|
document.getElementById("spool-id").value = spool ? spool.id : "";
|
||||||
@@ -451,6 +499,7 @@ function fill(spool) {
|
|||||||
showHint();
|
showHint();
|
||||||
renderPalette();
|
renderPalette();
|
||||||
renderSpools();
|
renderSpools();
|
||||||
|
loadAkte(spool ? spool.id : null);
|
||||||
document.getElementById("spool-panel").scrollIntoView({ block: "nearest", behavior: "smooth" });
|
document.getElementById("spool-panel").scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,6 +629,7 @@ function renderSpools() {
|
|||||||
const rest = Number(spool.remaining_g) || 0;
|
const rest = Number(spool.remaining_g) || 0;
|
||||||
tr.children[5].textContent = grams(rest);
|
tr.children[5].textContent = grams(rest);
|
||||||
if (rest < 100) tr.children[5].classList.add("msg");
|
if (rest < 100) tr.children[5].classList.add("msg");
|
||||||
|
if (rest < 0) tr.children[5].title = "Überzogen — Rest korrigieren";
|
||||||
tr.addEventListener("click", () => fill(spool));
|
tr.addEventListener("click", () => fill(spool));
|
||||||
body.appendChild(tr);
|
body.appendChild(tr);
|
||||||
});
|
});
|
||||||
@@ -806,7 +856,14 @@ async function openByCode(raw) {
|
|||||||
const match = code.match(/[?&]spool=(\d+)/);
|
const match = code.match(/[?&]spool=(\d+)/);
|
||||||
if (match) spool = spools.find((row) => String(row.id) === match[1]) || null;
|
if (match) spool = spools.find((row) => String(row.id) === match[1]) || null;
|
||||||
}
|
}
|
||||||
if (!spool) spool = await api("/spulen/by-code/" + encodeURIComponent(code));
|
if (!spool) {
|
||||||
|
const found = await api("/spulen/by-code/" + encodeURIComponent(code));
|
||||||
|
if (found && found.kind === "lauf" && found.redirect) {
|
||||||
|
location.href = found.redirect;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
spool = found;
|
||||||
|
}
|
||||||
setView("spools");
|
setView("spools");
|
||||||
fill(spool);
|
fill(spool);
|
||||||
setScanNote("Spule " + (spool.chargen_nr || spool.name) + " geöffnet.", true);
|
setScanNote("Spule " + (spool.chargen_nr || spool.name) + " geöffnet.", true);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
<p id="run-note"></p>
|
<p id="run-note"></p>
|
||||||
<div class="card table-wrap">
|
<div class="card table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Drucker</th><th>Datei</th><th>Zustand</th><th>Dauer</th><th>Gramm</th><th>Kosten</th><th>Urteil</th><th>Spule</th><th></th></tr></thead>
|
<thead><tr><th>Drucker</th><th>Datei</th><th>Zustand</th><th>Dauer</th><th>Ist</th><th>Kosten</th><th>Urteil</th><th>Spule</th><th></th></tr></thead>
|
||||||
<tbody id="run-rows"></tbody>
|
<tbody id="run-rows"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,12 +80,31 @@ function render() {
|
|||||||
}
|
}
|
||||||
shown.forEach((run) => {
|
shown.forEach((run) => {
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
|
tr.className = "pick";
|
||||||
tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>";
|
tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>";
|
||||||
tr.children[0].textContent = run.printer_name;
|
tr.children[0].textContent = run.printer_name;
|
||||||
tr.children[1].textContent = (run.filename || "").split("/").pop();
|
const fileCell = tr.children[1];
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = "/protokoll/" + run.id;
|
||||||
|
link.textContent = (run.filename || "").split("/").pop() || ("#" + run.id);
|
||||||
|
fileCell.appendChild(link);
|
||||||
|
if (run.chargen_nr) {
|
||||||
|
const code = document.createElement("div");
|
||||||
|
code.className = "tiny";
|
||||||
|
code.textContent = run.chargen_nr;
|
||||||
|
fileCell.appendChild(code);
|
||||||
|
}
|
||||||
tr.children[2].textContent = states[run.state] || run.state;
|
tr.children[2].textContent = states[run.state] || run.state;
|
||||||
tr.children[3].textContent = hours(run.duration_s);
|
tr.children[3].textContent = hours(run.duration_s);
|
||||||
tr.children[4].textContent = run.weight_g ? grams(run.weight_g) : "";
|
const used = Number(run.weight_used_g) || Number(run.weight_g) || 0;
|
||||||
|
const planned = Number(run.weight_planned_g) || Number(run.weight_g) || 0;
|
||||||
|
tr.children[4].textContent = used ? grams(used) : "";
|
||||||
|
if (used && planned && Math.abs(used - planned) > 0.5) {
|
||||||
|
tr.children[4].title = "Soll " + grams(planned);
|
||||||
|
}
|
||||||
|
if (run.weight_source === "estimate" || (run.weight_source === "measured" && false)) {
|
||||||
|
tr.children[4].textContent += run.weight_source === "estimate" ? " ≈" : "";
|
||||||
|
}
|
||||||
const cost = Number(run.filament_cost || 0) + Number(run.energy_cost || 0);
|
const cost = Number(run.filament_cost || 0) + Number(run.energy_cost || 0);
|
||||||
tr.children[5].textContent = cost ? euro(cost) : "";
|
tr.children[5].textContent = cost ? euro(cost) : "";
|
||||||
const verdict = document.createElement("select");
|
const verdict = document.createElement("select");
|
||||||
|
|||||||
@@ -0,0 +1,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>
|
||||||
@@ -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 %}
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
<div class="desk-facts" data-role="facts"></div>
|
<div class="desk-facts" data-role="facts"></div>
|
||||||
<div data-role="flow"></div>
|
<div data-role="flow"></div>
|
||||||
<div data-role="strips"></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>
|
<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>
|
<div data-role="steer"></div>
|
||||||
</details>
|
</details>
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
<div class="desk-tools">
|
<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>
|
<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">
|
<div class="desk-fold-body">
|
||||||
<p class="tiny">Ziehen dreht · Rad zoomt · Rechtsklick/Shift schwenkt · Doppelklick setzt zurück</p>
|
<p class="tiny">Ziehen dreht · Rad zoomt · Rechtsklick/Shift schwenkt · Doppelklick setzt zurück</p>
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</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>
|
<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">
|
<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>
|
<p class="desk-empty"><svg class="ico"><use href="#i-grid"/></svg><span>Noch kein Bettnetz</span></p>
|
||||||
@@ -86,22 +86,31 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block script %}
|
{% block script %}
|
||||||
<script src="{{ url_for('static', filename='three.min.js') }}?v=1"></script>
|
<script src="{{ url_for('static', filename='three.min.js') }}?v=1"></script>
|
||||||
<script src="{{ url_for('static', filename='gcode-viewer.js') }}?v=8"></script>
|
<script src="{{ url_for('static', filename='gcode-viewer.js') }}?v=10"></script>
|
||||||
<script>
|
<script>
|
||||||
const labels = {
|
const labels = STATE_LABELS;
|
||||||
printing: "druckt", paused: "pausiert", complete: "fertig", standby: "bereit",
|
|
||||||
heating: "heizt", error: "Fehler", cancelled: "abgebrochen", offline: "offline"
|
|
||||||
};
|
|
||||||
const rank = { printing: 0, paused: 1, error: 2, heating: 3, complete: 4, cancelled: 5, standby: 6, offline: 7 };
|
const rank = { printing: 0, paused: 1, error: 2, heating: 3, complete: 4, cancelled: 5, standby: 6, offline: 7 };
|
||||||
|
const DESK_STATES = ["printing", "paused", "complete", "standby", "heating", "error", "cancelled", "offline"];
|
||||||
let deskHost = "";
|
let deskHost = "";
|
||||||
let hideOffline = false;
|
let hideOffline = false;
|
||||||
let libraryFiles = [];
|
let libraryFiles = [];
|
||||||
let wallSpools = [];
|
let wallSpools = [];
|
||||||
let deskViewer = null;
|
let deskViewer = null;
|
||||||
|
let deskCamFails = 0;
|
||||||
let deskViewerFileId = null;
|
let deskViewerFileId = null;
|
||||||
|
let deskSyncGen = 0;
|
||||||
let deskExcludeSelected = "";
|
let deskExcludeSelected = "";
|
||||||
const wallPullTried = new Set();
|
const wallPullTried = new Set();
|
||||||
const toolpathCache = new Map();
|
const toolpathCache = new Map();
|
||||||
|
|
||||||
|
/** Kurzes leeres filename vom Drucker nicht übernehmen — sonst springt der Name alle paar Sekunden. */
|
||||||
|
function stickyFilename(printer, previous) {
|
||||||
|
const next = (printer && printer.filename) || "";
|
||||||
|
if (next) return next;
|
||||||
|
const state = shownState(printer || {});
|
||||||
|
if (previous && ["printing", "paused", "heating", "complete"].includes(state)) return previous;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
const cardNodes = new Map();
|
const cardNodes = new Map();
|
||||||
const runningJobs = new Map();
|
const runningJobs = new Map();
|
||||||
const printerCache = new Map();
|
const printerCache = new Map();
|
||||||
@@ -123,9 +132,9 @@ function syncDeskCamCollapsed() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function restoreDeskFolds() {
|
function restoreDeskFolds() {
|
||||||
let closed = [];
|
let closed;
|
||||||
try { closed = JSON.parse(localStorage.getItem(DESK_FOLD_KEY) || "[]"); } catch (error) { closed = []; }
|
try { closed = JSON.parse(localStorage.getItem(DESK_FOLD_KEY) || "null"); } catch (error) { closed = null; }
|
||||||
if (!Array.isArray(closed)) closed = [];
|
if (!Array.isArray(closed)) closed = ["steer", "path", "mesh"];
|
||||||
deskEl.querySelectorAll("details[data-fold]").forEach((box) => {
|
deskEl.querySelectorAll("details[data-fold]").forEach((box) => {
|
||||||
box.open = !closed.includes(box.dataset.fold);
|
box.open = !closed.includes(box.dataset.fold);
|
||||||
box.addEventListener("toggle", () => {
|
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>`;
|
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) {
|
function jobName(filename) {
|
||||||
return (filename || "").replace(/\.gcode$/i, "").replace(/^(\d+-)+/, "");
|
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) {
|
function tempbarHtml(label, value, target, scale, iconName) {
|
||||||
if (value == null) return "";
|
if (value == null) return "";
|
||||||
const now = Math.round(value);
|
const now = Math.round(value);
|
||||||
@@ -273,12 +283,19 @@ function swatchesHtml(printer) {
|
|||||||
}).join("");
|
}).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function actionsHtml(state) {
|
function etikettHtml(printer) {
|
||||||
|
const runId = printer && printer.live_run_id;
|
||||||
|
if (!runId) return "";
|
||||||
|
return `<a class="ghost" href="/etikett/lauf/${runId}?mode=druck" target="_blank" rel="noopener">Etikett</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionsHtml(state, printer) {
|
||||||
|
const label = (state === "printing" || state === "paused") ? etikettHtml(printer) : "";
|
||||||
if (state === "printing") {
|
if (state === "printing") {
|
||||||
return `<button type="button" class="ghost" data-cmd="pause">${icon("pause")}Pause</button><button type="button" class="warn" data-cmd="abbrechen">${icon("stop")}Abbrechen</button>`;
|
return `${label}<button type="button" class="ghost" data-cmd="pause">${icon("pause")}Pause</button><button type="button" class="warn" data-cmd="abbrechen">${icon("stop")}Abbrechen</button>`;
|
||||||
}
|
}
|
||||||
if (state === "paused") {
|
if (state === "paused") {
|
||||||
return `<button type="button" data-cmd="fortsetzen">${icon("play")}Weiter</button><button type="button" class="warn" data-cmd="abbrechen">${icon("stop")}Abbrechen</button>`;
|
return `${label}<button type="button" data-cmd="fortsetzen">${icon("play")}Weiter</button><button type="button" class="warn" data-cmd="abbrechen">${icon("stop")}Abbrechen</button>`;
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -335,13 +352,107 @@ function stripsHtml(printer, state) {
|
|||||||
return bits.join("");
|
return bits.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dryerLabel(status) {
|
||||||
|
if (status === "drying") return "trocknet";
|
||||||
|
if (status === "heater_err") return "Fehler";
|
||||||
|
return "aus";
|
||||||
|
}
|
||||||
|
|
||||||
|
function dryerDefaultTemp(dryer) {
|
||||||
|
if (dryer.target > 0) return dryer.target;
|
||||||
|
if (dryer.temp > 0) return dryer.temp;
|
||||||
|
return 45;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aceSteerKey(printer) {
|
||||||
|
const dryers = printer.dryers || [];
|
||||||
|
const dryerPart = dryers.map((d) => String(d.id)).join(",");
|
||||||
|
const slots = wallSpools
|
||||||
|
.filter((s) => s.printer_host === printer.host && s.ace_slot)
|
||||||
|
.map((s) => Number(s.ace_slot))
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.join(",");
|
||||||
|
return dryerPart + "|" + slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
function spoolHex(spool) {
|
||||||
|
const colors = spool && spool.colors;
|
||||||
|
if (Array.isArray(colors)) {
|
||||||
|
for (const item of colors) {
|
||||||
|
if (item && item.hex) return item.hex;
|
||||||
|
if (typeof item === "string" && item.trim()) return item.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const label = spool && spool.color;
|
||||||
|
if (typeof label === "string" && /^#?[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(label.trim())) {
|
||||||
|
return label.trim().startsWith("#") ? label.trim() : "#" + label.trim();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function aceSlotLabel(slot) {
|
||||||
|
const n = Number(slot);
|
||||||
|
if (n <= 4) return "ACE 1 · " + n;
|
||||||
|
return "ACE 2 · " + (n - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function aceSteerHtml(printer) {
|
||||||
|
const dryers = printer.dryers || [];
|
||||||
|
if (!dryers.length) return "";
|
||||||
|
const bits = [];
|
||||||
|
dryers.forEach((dryer) => {
|
||||||
|
const id = Number(dryer.id);
|
||||||
|
const label = "ACE " + (id + 1);
|
||||||
|
const status = dryerLabel(dryer.status);
|
||||||
|
const temp = dryer.temp || 0;
|
||||||
|
const target = dryer.target || 0;
|
||||||
|
const remain = dryer.remain || 0;
|
||||||
|
const dur = remain > 0 ? remain : 240;
|
||||||
|
const hot = dryerDefaultTemp(dryer);
|
||||||
|
const info = [
|
||||||
|
status,
|
||||||
|
temp || target ? temp + "°" + (target ? " / " + target + "°" : "") : "",
|
||||||
|
remain > 0 ? "noch " + remain + " min" : "",
|
||||||
|
].filter(Boolean).join(" · ");
|
||||||
|
bits.push(`<div class="steer-ace" data-dryer-id="${id}">
|
||||||
|
<div class="steer-ace-head"><b>${escapeHtml(label)} Trockner</b><span class="tiny" data-dryer-info>${escapeHtml(info)}</span></div>
|
||||||
|
<div class="steer-row">
|
||||||
|
<label>Temp °C <input type="number" data-steer="dryer-temp-${id}" min="35" max="65" value="${escapeHtml(String(hot))}"></label>
|
||||||
|
<label>Dauer min <input type="number" data-steer="dryer-dur-${id}" min="1" max="720" value="${escapeHtml(String(dur))}"></label>
|
||||||
|
<button type="button" data-steer-go="dryer_start" data-dryer-id="${id}">Start</button>
|
||||||
|
<button type="button" class="ghost" data-steer-go="dryer_stop" data-dryer-id="${id}">Stopp</button>
|
||||||
|
</div>
|
||||||
|
</div>`);
|
||||||
|
});
|
||||||
|
const loaded = wallSpools
|
||||||
|
.filter((s) => s.printer_host === printer.host && s.ace_slot)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => Number(a.ace_slot) - Number(b.ace_slot));
|
||||||
|
if (loaded.length) {
|
||||||
|
bits.push(`<div class="steer-ace-slots"><div class="steer-ace-head"><b>Plätze</b><span class="tiny">Sorte und Farbe aus Grotti</span></div>`);
|
||||||
|
loaded.forEach((spool) => {
|
||||||
|
const slot = Number(spool.ace_slot);
|
||||||
|
const hex = spoolHex(spool) || "#555";
|
||||||
|
const kind = spool.material_type || "—";
|
||||||
|
bits.push(`<div class="steer-slot-row">
|
||||||
|
<span class="dot" style="background:${escapeHtml(hex)}"></span>
|
||||||
|
<span class="grow"><b>${escapeHtml(aceSlotLabel(slot))}</b> · ${escapeHtml(kind)} · ${escapeHtml(spool.name || "")}</span>
|
||||||
|
<button type="button" class="ghost" data-steer-go="slot_setzen" data-ace-slot="${slot}">an den Drucker</button>
|
||||||
|
</div>`);
|
||||||
|
});
|
||||||
|
bits.push(`</div>`);
|
||||||
|
}
|
||||||
|
return bits.join("");
|
||||||
|
}
|
||||||
|
|
||||||
function steerPanelHtml(printer) {
|
function steerPanelHtml(printer) {
|
||||||
const n = printer.nozzle_target != null ? Math.round(printer.nozzle_target) : "";
|
const n = printer.nozzle_target != null ? Math.round(printer.nozzle_target) : "";
|
||||||
const b = printer.bed_target != null ? Math.round(printer.bed_target) : "";
|
const b = printer.bed_target != null ? Math.round(printer.bed_target) : "";
|
||||||
const sp = typeof printer.speed_factor === "number" ? Math.round(printer.speed_factor * 100) : 100;
|
const sp = typeof printer.speed_factor === "number" ? Math.round(printer.speed_factor * 100) : 100;
|
||||||
const flow = typeof printer.extrude_factor === "number" ? Math.round(printer.extrude_factor * 100) : 100;
|
const flow = typeof printer.extrude_factor === "number" ? Math.round(printer.extrude_factor * 100) : 100;
|
||||||
const fan = typeof printer.fan === "number" ? Math.round(printer.fan * 100) : 0;
|
const fan = typeof printer.fan === "number" ? Math.round(printer.fan * 100) : 0;
|
||||||
return `<div class="steer" data-steer-host="${escapeHtml(printer.host)}">
|
const aceKey = aceSteerKey(printer);
|
||||||
|
return `<div class="steer" data-steer-host="${escapeHtml(printer.host)}" data-ace-key="${escapeHtml(aceKey)}">
|
||||||
<div class="steer-row">
|
<div class="steer-row">
|
||||||
<label>${icon("nozzle")} Düse <input type="number" data-steer="nozzle" data-steer-action="nozzle" min="0" max="350" value="${escapeHtml(String(n))}"></label>
|
<label>${icon("nozzle")} Düse <input type="number" data-steer="nozzle" data-steer-action="nozzle" min="0" max="350" value="${escapeHtml(String(n))}"></label>
|
||||||
<label>${icon("bed")} Bett <input type="number" data-steer="bed" data-steer-action="bed" min="0" max="150" value="${escapeHtml(String(b))}"></label>
|
<label>${icon("bed")} Bett <input type="number" data-steer="bed" data-steer-action="bed" min="0" max="150" value="${escapeHtml(String(b))}"></label>
|
||||||
@@ -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>Flow % <input type="number" data-steer="flow" data-steer-action="flow" min="50" max="150" value="${escapeHtml(String(flow))}"></label>
|
||||||
<label>${icon("fan")} Fan % <input type="number" data-steer="fan" data-steer-action="fan" min="0" max="100" value="${escapeHtml(String(fan))}"></label>
|
<label>${icon("fan")} Fan % <input type="number" data-steer="fan" data-steer-action="fan" min="0" max="100" value="${escapeHtml(String(fan))}"></label>
|
||||||
</div>
|
</div>
|
||||||
|
${aceSteerHtml(printer)}
|
||||||
<p class="tiny">Enter im Feld setzt nur diesen Wert.</p>
|
<p class="tiny">Enter im Feld setzt nur diesen Wert.</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -435,13 +547,29 @@ function factsHtml(printer) {
|
|||||||
${chamberFan ? "<span class=\"fact-ico\">" + icon("fan-chamber") + " Bauraum <b>" + chamberFan + "</b></span>" : ""}
|
${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>" : ""}
|
${auxFan ? "<span class=\"fact-ico\">" + icon("fan-aux") + " Aux <b>" + auxFan + "</b></span>" : ""}
|
||||||
${speed ? "<span>Speed <b>" + speed + "</b></span>" : ""}
|
${speed ? "<span>Speed <b>" + speed + "</b></span>" : ""}
|
||||||
|
${anycubicTaskLine(printer)}
|
||||||
<span>Host <b>${escapeHtml(printer.host)}</b></span>
|
<span>Host <b>${escapeHtml(printer.host)}</b></span>
|
||||||
${printer.message && printer.ok === false ? `<p class="msg">${escapeHtml(printer.message)}</p>` : ""}
|
${printer.message && printer.ok === false ? `<p class="msg">${escapeHtml(printer.message)}</p>` : ""}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function jobLineVisible(printer, state) {
|
function anycubicTaskLine(printer) {
|
||||||
return !!printer.filename && state !== "standby" && state !== "offline";
|
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) {
|
function shortName(name) {
|
||||||
@@ -483,7 +611,14 @@ function buildCard(printer) {
|
|||||||
<div data-role="bar"></div>
|
<div data-role="bar"></div>
|
||||||
<div class="stats" data-role="stats"></div>
|
<div class="stats" data-role="stats"></div>
|
||||||
<div data-role="temps"></div>
|
<div data-role="temps"></div>
|
||||||
<div class="job-line" data-role="jobline">${icon("cube")}<span class="name" data-role="job"></span><span class="swatches" data-role="swatches"></span></div>
|
<div class="job-line" data-role="jobline">
|
||||||
|
<img class="job-thumb" data-role="thumb" alt="" hidden>
|
||||||
|
<span class="job-ico" data-role="jobico">${icon("cube")}</span>
|
||||||
|
<div class="job-meta">
|
||||||
|
<span class="name" data-role="job"></span>
|
||||||
|
<span class="swatches" data-role="swatches"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div data-role="flow"></div>
|
<div data-role="flow"></div>
|
||||||
<div data-role="strips"></div>
|
<div data-role="strips"></div>
|
||||||
<div class="card-actions" data-role="controls"></div>
|
<div class="card-actions" data-role="controls"></div>
|
||||||
@@ -503,11 +638,13 @@ function patchSteerValues(root, printer) {
|
|||||||
if (steerHasFocus(root)) return;
|
if (steerHasFocus(root)) return;
|
||||||
const steer = root.querySelector("[data-role=steer]");
|
const steer = root.querySelector("[data-role=steer]");
|
||||||
if (!steer) return;
|
if (!steer) return;
|
||||||
const nozzle = steer.querySelector('[data-steer="nozzle"]');
|
const panel = steer.querySelector(".steer");
|
||||||
const bed = steer.querySelector('[data-steer="bed"]');
|
if (!panel) return;
|
||||||
const speed = steer.querySelector('[data-steer="speed"]');
|
const nozzle = panel.querySelector('[data-steer="nozzle"]');
|
||||||
const flow = steer.querySelector('[data-steer="flow"]');
|
const bed = panel.querySelector('[data-steer="bed"]');
|
||||||
const fan = steer.querySelector('[data-steer="fan"]');
|
const speed = panel.querySelector('[data-steer="speed"]');
|
||||||
|
const flow = panel.querySelector('[data-steer="flow"]');
|
||||||
|
const fan = panel.querySelector('[data-steer="fan"]');
|
||||||
if (nozzle && document.activeElement !== nozzle) {
|
if (nozzle && document.activeElement !== nozzle) {
|
||||||
nozzle.value = printer.nozzle_target != null ? Math.round(printer.nozzle_target) : "";
|
nozzle.value = printer.nozzle_target != null ? Math.round(printer.nozzle_target) : "";
|
||||||
}
|
}
|
||||||
@@ -523,13 +660,39 @@ function patchSteerValues(root, printer) {
|
|||||||
if (fan && document.activeElement !== fan && typeof printer.fan === "number") {
|
if (fan && document.activeElement !== fan && typeof printer.fan === "number") {
|
||||||
fan.value = Math.round(printer.fan * 100);
|
fan.value = Math.round(printer.fan * 100);
|
||||||
}
|
}
|
||||||
|
(printer.dryers || []).forEach((dryer) => {
|
||||||
|
const id = Number(dryer.id);
|
||||||
|
const box = panel.querySelector(`[data-dryer-id="${id}"]`);
|
||||||
|
if (!box) return;
|
||||||
|
const info = box.querySelector("[data-dryer-info]");
|
||||||
|
if (info) {
|
||||||
|
const status = dryerLabel(dryer.status);
|
||||||
|
const temp = dryer.temp || 0;
|
||||||
|
const target = dryer.target || 0;
|
||||||
|
const remain = dryer.remain || 0;
|
||||||
|
info.textContent = [
|
||||||
|
status,
|
||||||
|
temp || target ? temp + "°" + (target ? " / " + target + "°" : "") : "",
|
||||||
|
remain > 0 ? "noch " + remain + " min" : "",
|
||||||
|
].filter(Boolean).join(" · ");
|
||||||
|
}
|
||||||
|
const tempInput = box.querySelector(`[data-steer="dryer-temp-${id}"]`);
|
||||||
|
const durInput = box.querySelector(`[data-steer="dryer-dur-${id}"]`);
|
||||||
|
if (tempInput && document.activeElement !== tempInput && dryer.status === "drying" && dryer.target > 0) {
|
||||||
|
tempInput.value = dryer.target;
|
||||||
|
}
|
||||||
|
if (durInput && document.activeElement !== durInput && dryer.remain > 0) {
|
||||||
|
durInput.value = dryer.remain;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchCard(article, printer) {
|
function patchCard(article, printer) {
|
||||||
const state = shownState(printer);
|
const state = shownState(printer);
|
||||||
const clearNow = needsClear(printer);
|
const clearNow = needsClear(printer);
|
||||||
|
const shownFile = stickyFilename(printer, article.dataset.filename || "");
|
||||||
article.dataset.state = state;
|
article.dataset.state = state;
|
||||||
article.dataset.filename = printer.filename || "";
|
article.dataset.filename = shownFile;
|
||||||
article.dataset.progress = String(printer.progress || 0);
|
article.dataset.progress = String(printer.progress || 0);
|
||||||
article.dataset.family = printer.family || "";
|
article.dataset.family = printer.family || "";
|
||||||
article.dataset.needsClear = clearNow ? "1" : "";
|
article.dataset.needsClear = clearNow ? "1" : "";
|
||||||
@@ -544,29 +707,59 @@ function patchCard(article, printer) {
|
|||||||
setHtml(role("stats"), statsHtml(printer, state));
|
setHtml(role("stats"), statsHtml(printer, state));
|
||||||
setHtml(role("temps"), tempbarsHtml(printer, state));
|
setHtml(role("temps"), tempbarsHtml(printer, state));
|
||||||
const jobline = role("jobline");
|
const jobline = role("jobline");
|
||||||
if (jobline) jobline.hidden = !jobLineVisible(printer, state);
|
const jobVisible = jobLineVisible(printer, state, shownFile);
|
||||||
|
const hasThumb = !!(jobVisible && printer.preview);
|
||||||
|
if (jobline) {
|
||||||
|
jobline.hidden = !jobVisible;
|
||||||
|
jobline.classList.toggle("has-thumb", hasThumb);
|
||||||
|
}
|
||||||
|
const thumb = role("thumb");
|
||||||
|
const jobico = role("jobico");
|
||||||
|
if (thumb) {
|
||||||
|
if (hasThumb) {
|
||||||
|
const next = withToken(printer.preview);
|
||||||
|
if (thumb.dataset.srcKey !== next) {
|
||||||
|
thumb.dataset.srcKey = next;
|
||||||
|
thumb.src = next;
|
||||||
|
}
|
||||||
|
thumb.hidden = false;
|
||||||
|
thumb.onerror = () => {
|
||||||
|
thumb.hidden = true;
|
||||||
|
thumb.dataset.srcKey = "";
|
||||||
|
if (jobline) jobline.classList.remove("has-thumb");
|
||||||
|
if (jobico) jobico.hidden = false;
|
||||||
|
};
|
||||||
|
if (jobico) jobico.hidden = true;
|
||||||
|
} else {
|
||||||
|
thumb.hidden = true;
|
||||||
|
thumb.removeAttribute("src");
|
||||||
|
thumb.dataset.srcKey = "";
|
||||||
|
if (jobico) jobico.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
const job = role("job");
|
const job = role("job");
|
||||||
if (job) {
|
if (job) {
|
||||||
job.textContent = jobName(printer.filename) || "";
|
setText(job, jobName(shownFile) || "");
|
||||||
job.title = printer.filename || "";
|
job.title = shownFile || "";
|
||||||
}
|
}
|
||||||
setHtml(role("swatches"), swatchesHtml(printer));
|
setHtml(role("swatches"), swatchesHtml(printer));
|
||||||
setHtml(role("flow"), flowHtml(printer));
|
setHtml(role("flow"), flowHtml(printer));
|
||||||
setHtml(role("strips"), stripsHtml(printer, state));
|
setHtml(role("strips"), stripsHtml(printer, state));
|
||||||
setHtml(role("controls"), actionsHtml(state));
|
setHtml(role("controls"), actionsHtml(state, printer));
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshDeskCamera(printer) {
|
function refreshDeskCamera(printer) {
|
||||||
const img = deskEl.querySelector('[data-role="camera"]');
|
const img = deskEl.querySelector('[data-role="camera"]');
|
||||||
const empty = deskEl.querySelector('[data-role="cam-empty"]');
|
const empty = deskEl.querySelector('[data-role="cam-empty"]');
|
||||||
if (!img || !empty) return;
|
if (!img || !empty) return;
|
||||||
if (!printer || !printer.camera) {
|
if (!printer || !printer.camera || printer.has_camera === false) {
|
||||||
img.removeAttribute("src");
|
img.removeAttribute("src");
|
||||||
img.hidden = true;
|
img.hidden = true;
|
||||||
empty.hidden = false;
|
empty.hidden = false;
|
||||||
empty.innerHTML = icon("camera") + "<span>Keine Kamera</span>";
|
empty.innerHTML = icon("camera") + "<span>Keine Kamera</span>";
|
||||||
setDeskCameraMode(false, "keine");
|
setDeskCameraMode(false, "keine");
|
||||||
img.dataset.srcKey = "";
|
img.dataset.srcKey = "";
|
||||||
|
deskCamFails = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Layout (no-cam) nicht bei jedem Frame-Fehler umwerfen — sonst springt die
|
// Layout (no-cam) nicht bei jedem Frame-Fehler umwerfen — sonst springt die
|
||||||
@@ -578,6 +771,7 @@ function refreshDeskCamera(printer) {
|
|||||||
const probe = new Image();
|
const probe = new Image();
|
||||||
probe.onload = () => {
|
probe.onload = () => {
|
||||||
if (img.dataset.srcKey !== next) return;
|
if (img.dataset.srcKey !== next) return;
|
||||||
|
deskCamFails = 0;
|
||||||
img.src = next;
|
img.src = next;
|
||||||
img.hidden = false;
|
img.hidden = false;
|
||||||
empty.hidden = true;
|
empty.hidden = true;
|
||||||
@@ -585,7 +779,16 @@ function refreshDeskCamera(printer) {
|
|||||||
};
|
};
|
||||||
probe.onerror = () => {
|
probe.onerror = () => {
|
||||||
if (img.dataset.srcKey !== next) return;
|
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) {
|
if (!img.src || img.hidden) {
|
||||||
empty.hidden = false;
|
empty.hidden = false;
|
||||||
empty.innerHTML = icon("camera") + "<span>Kamera nicht erreichbar</span>";
|
empty.innerHTML = icon("camera") + "<span>Kamera nicht erreichbar</span>";
|
||||||
@@ -633,11 +836,14 @@ function patchDesk(printer) {
|
|||||||
if (!printer || printer.host !== deskHost) return;
|
if (!printer || printer.host !== deskHost) return;
|
||||||
const state = shownState(printer);
|
const state = shownState(printer);
|
||||||
const prevFile = deskEl.dataset.filename || "";
|
const prevFile = deskEl.dataset.filename || "";
|
||||||
const fileChanged = prevFile !== (printer.filename || "");
|
const shownFile = stickyFilename(printer, prevFile);
|
||||||
|
const fileChanged = prevFile !== shownFile;
|
||||||
deskEl.dataset.host = printer.host;
|
deskEl.dataset.host = printer.host;
|
||||||
deskEl.dataset.filename = printer.filename || "";
|
deskEl.dataset.filename = shownFile;
|
||||||
deskEl.dataset.progress = String(printer.progress || 0);
|
deskEl.dataset.progress = String(printer.progress || 0);
|
||||||
deskEl.className = "desk " + state;
|
deskEl.classList.remove(...DESK_STATES);
|
||||||
|
deskEl.classList.add(state);
|
||||||
|
syncDeskCamCollapsed();
|
||||||
|
|
||||||
const role = (name) => deskEl.querySelector(`[data-role=${name}]`);
|
const role = (name) => deskEl.querySelector(`[data-role=${name}]`);
|
||||||
const nameEl = role("name");
|
const nameEl = role("name");
|
||||||
@@ -656,11 +862,12 @@ function patchDesk(printer) {
|
|||||||
setHtml(role("temps"), tempbarsHtml(printer, state));
|
setHtml(role("temps"), tempbarsHtml(printer, state));
|
||||||
|
|
||||||
const jobline = role("jobline");
|
const jobline = role("jobline");
|
||||||
if (jobline) jobline.hidden = !jobLineVisible(printer, state) && !printer.preview;
|
const jobVisible = jobLineVisible(printer, state, shownFile);
|
||||||
|
if (jobline) jobline.hidden = !jobVisible && !printer.preview;
|
||||||
const job = role("job");
|
const job = role("job");
|
||||||
if (job) {
|
if (job) {
|
||||||
job.textContent = jobName(printer.filename) || (jobLineVisible(printer, state) ? "" : "Kein Auftrag");
|
setText(job, jobName(shownFile) || (jobVisible ? "" : "Kein Auftrag"));
|
||||||
job.title = printer.filename || "";
|
job.title = shownFile || "";
|
||||||
}
|
}
|
||||||
const preview = role("preview");
|
const preview = role("preview");
|
||||||
if (preview) {
|
if (preview) {
|
||||||
@@ -676,7 +883,7 @@ function patchDesk(printer) {
|
|||||||
setHtml(role("facts"), factsHtml(printer));
|
setHtml(role("facts"), factsHtml(printer));
|
||||||
setHtml(role("flow"), flowHtml(printer));
|
setHtml(role("flow"), flowHtml(printer));
|
||||||
setHtml(role("strips"), stripsHtml(printer, state));
|
setHtml(role("strips"), stripsHtml(printer, state));
|
||||||
setHtml(role("controls"), actionsHtml(state));
|
setHtml(role("controls"), actionsHtml(state, printer));
|
||||||
const estop = role("estop");
|
const estop = role("estop");
|
||||||
if (estop) {
|
if (estop) {
|
||||||
const showStop = state === "printing" || state === "paused" || state === "heating";
|
const showStop = state === "printing" || state === "paused" || state === "heating";
|
||||||
@@ -689,11 +896,15 @@ function patchDesk(printer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const steer = role("steer");
|
const steer = role("steer");
|
||||||
if (steer && (!steer.querySelector(".steer") || fileChanged)) {
|
if (steer) {
|
||||||
|
const panel = steer.querySelector(".steer");
|
||||||
|
const nextKey = aceSteerKey(printer);
|
||||||
|
if (!panel || fileChanged || panel.dataset.aceKey !== nextKey) {
|
||||||
setHtml(steer, steerPanelHtml(printer));
|
setHtml(steer, steerPanelHtml(printer));
|
||||||
} else {
|
} else {
|
||||||
patchSteerValues(deskEl, printer);
|
patchSteerValues(deskEl, printer);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
setHtml(role("mesh"), heatMapHtml(printer));
|
setHtml(role("mesh"), heatMapHtml(printer));
|
||||||
|
|
||||||
// Kamera nur über Timer aktualisieren — sonst doppelter Reload + Layout-Ruckler
|
// Kamera nur über Timer aktualisieren — sonst doppelter Reload + Layout-Ruckler
|
||||||
@@ -705,8 +916,9 @@ async function syncDeskViewer(printer) {
|
|||||||
const canvas = deskEl.querySelector('[data-role="path-canvas"]');
|
const canvas = deskEl.querySelector('[data-role="path-canvas"]');
|
||||||
const caption = deskEl.querySelector('[data-role="path-caption"]');
|
const caption = deskEl.querySelector('[data-role="path-caption"]');
|
||||||
if (!canvas || !window.GrottiGcodeViewer || !window.THREE) return;
|
if (!canvas || !window.GrottiGcodeViewer || !window.THREE) return;
|
||||||
const filename = printer.filename || "";
|
const filename = stickyFilename(printer, deskEl.dataset.filename || "");
|
||||||
const progress = Number(printer.progress || 0);
|
const progress = Number(printer.progress || 0);
|
||||||
|
const gen = ++deskSyncGen;
|
||||||
if (!deskViewer) {
|
if (!deskViewer) {
|
||||||
deskViewer = GrottiGcodeViewer.create(canvas);
|
deskViewer = GrottiGcodeViewer.create(canvas);
|
||||||
deskViewer.setExcludeObjects({ objects: [], excluded: [], current: null }, {
|
deskViewer.setExcludeObjects({ objects: [], excluded: [], current: null }, {
|
||||||
@@ -721,45 +933,55 @@ async function syncDeskViewer(printer) {
|
|||||||
let file = matchLibraryFile(filename);
|
let file = matchLibraryFile(filename);
|
||||||
if (!file && filename && !wallPullTried.has(deskHost + "|" + filename)) {
|
if (!file && filename && !wallPullTried.has(deskHost + "|" + filename)) {
|
||||||
wallPullTried.add(deskHost + "|" + filename);
|
wallPullTried.add(deskHost + "|" + filename);
|
||||||
if (caption) caption.textContent = "lade vom Drucker …";
|
setText(caption, "lade vom Drucker …");
|
||||||
try {
|
try {
|
||||||
const saved = await api("/dateien/von-drucker", {
|
const saved = await api("/dateien/von-drucker", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
json: { host: deskHost, filename },
|
json: { host: deskHost, filename },
|
||||||
});
|
});
|
||||||
|
if (gen !== deskSyncGen) return;
|
||||||
libraryFiles = await api("/dateien");
|
libraryFiles = await api("/dateien");
|
||||||
|
if (gen !== deskSyncGen) return;
|
||||||
file = saved || matchLibraryFile(filename);
|
file = saved || matchLibraryFile(filename);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
/* bleibt leer */
|
/* bleibt leer */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!file) {
|
if (!file) {
|
||||||
|
// Kurz leerer filename: Pfad und Caption nicht verwerfen (sonst Flackern alle 5 s).
|
||||||
|
if (!filename && deskViewerFileId) return;
|
||||||
deskViewer.setPath({ points: [] });
|
deskViewer.setPath({ points: [] });
|
||||||
deskViewerFileId = null;
|
deskViewerFileId = null;
|
||||||
if (caption) caption.textContent = filename ? "Datei nicht in Grotti" : "kein Auftrag";
|
setText(caption, filename ? "Datei nicht in Grotti" : "kein Auftrag");
|
||||||
renderExcludePanel(printer);
|
renderExcludePanel(printer);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
let data = toolpathCache.get(file.id);
|
let data = toolpathCache.get(file.id);
|
||||||
const needLoad = !data || data.version !== 5 || deskViewerFileId !== file.id;
|
const cachedOk = data && Number(data.version) === 8;
|
||||||
if (needLoad) {
|
const needFetch = !cachedOk;
|
||||||
if (caption) caption.textContent = "wird gelesen …";
|
const needSetPath = deskViewerFileId !== file.id;
|
||||||
data = await api("/dateien/" + file.id + "/pfad");
|
if (needFetch) {
|
||||||
|
if (needSetPath) setText(caption, "wird gelesen …");
|
||||||
|
data = await api("/dateien/" + file.id + "/pfad?tp=8");
|
||||||
|
if (gen !== deskSyncGen) return;
|
||||||
toolpathCache.set(file.id, data);
|
toolpathCache.set(file.id, data);
|
||||||
const sameFile = deskViewerFileId === file.id;
|
}
|
||||||
deskViewer.setPath(data, { keepView: sameFile });
|
if (needFetch || needSetPath) {
|
||||||
|
deskViewer.setPath(data, { keepView: deskViewerFileId === file.id });
|
||||||
deskViewerFileId = file.id;
|
deskViewerFileId = file.id;
|
||||||
}
|
}
|
||||||
if (!(data.points && data.points.length)) {
|
if (!(data.points && data.points.length)) {
|
||||||
if (caption) caption.textContent = "leer in Datei";
|
setText(caption, "leer in Datei");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// File-Id behalten — sonst setzt der nächste Poll die 3D-Ansicht zurück
|
// File-Id behalten — sonst setzt der nächste Poll die 3D-Ansicht zurück
|
||||||
if (caption) caption.textContent = error.message || "fehlgeschlagen";
|
if (gen !== deskSyncGen) return;
|
||||||
|
setText(caption, error.message || "fehlgeschlagen");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (gen !== deskSyncGen) return;
|
||||||
deskViewer.setProgress(progress, {
|
deskViewer.setProgress(progress, {
|
||||||
layer: printer.layer,
|
layer: printer.layer,
|
||||||
layers: printer.layers,
|
layers: printer.layers,
|
||||||
@@ -780,7 +1002,7 @@ async function syncDeskViewer(printer) {
|
|||||||
const layerTxt = printer.layers
|
const layerTxt = printer.layers
|
||||||
? " · Schicht " + (printer.layer || 0) + "/" + printer.layers
|
? " · Schicht " + (printer.layer || 0) + "/" + printer.layers
|
||||||
: "";
|
: "";
|
||||||
if (caption) caption.textContent = file.name + " · " + Math.round(progress * 100) + " %" + layerTxt;
|
setText(caption, jobName(file.name) + " · " + Math.round(progress * 100) + " %" + layerTxt);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderExcludePanel(printer) {
|
function renderExcludePanel(printer) {
|
||||||
@@ -980,8 +1202,8 @@ async function refresh() {
|
|||||||
if (!libraryFiles.length) {
|
if (!libraryFiles.length) {
|
||||||
try { libraryFiles = await api("/dateien"); } catch (error) { libraryFiles = []; }
|
try { libraryFiles = await api("/dateien"); } catch (error) { libraryFiles = []; }
|
||||||
}
|
}
|
||||||
if (!wallSpools.length) {
|
if (!wallSpools.length || deskHost) {
|
||||||
try { wallSpools = await api("/spulen"); } catch (error) { wallSpools = []; }
|
try { wallSpools = await api("/spulen"); } catch (error) { /* behalten */ }
|
||||||
}
|
}
|
||||||
const all = await api("/drucker");
|
const all = await api("/drucker");
|
||||||
const printers = sortPrinters(all.slice());
|
const printers = sortPrinters(all.slice());
|
||||||
@@ -991,6 +1213,14 @@ async function refresh() {
|
|||||||
document.getElementById("summary").innerHTML = summaryText(all) || "keine Drucker";
|
document.getElementById("summary").innerHTML = summaryText(all) || "keine Drucker";
|
||||||
document.getElementById("when").textContent = new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
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 sections = ensureFleetShell(printers);
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
for (const printer of printers) {
|
for (const printer of printers) {
|
||||||
@@ -1195,11 +1425,35 @@ async function handleWallClick(event) {
|
|||||||
method: "POST", json: { action: "fan", percent: 0 },
|
method: "POST", json: { action: "fan", percent: 0 },
|
||||||
});
|
});
|
||||||
toast("Heizung und Lüfter aus.");
|
toast("Heizung und Lüfter aus.");
|
||||||
|
} else if (go === "dryer_start") {
|
||||||
|
const id = Number(steerBtn.dataset.dryerId);
|
||||||
|
const temp = val("dryer-temp-" + id);
|
||||||
|
const duration = val("dryer-dur-" + id);
|
||||||
|
await api("/drucker/" + encodeURIComponent(host) + "/steuern", {
|
||||||
|
method: "POST",
|
||||||
|
json: { action: "dryer_start", id, temp, duration },
|
||||||
|
});
|
||||||
|
toast("Trockner gestartet.");
|
||||||
|
} else if (go === "dryer_stop") {
|
||||||
|
const id = Number(steerBtn.dataset.dryerId);
|
||||||
|
await api("/drucker/" + encodeURIComponent(host) + "/steuern", {
|
||||||
|
method: "POST",
|
||||||
|
json: { action: "dryer_stop", id },
|
||||||
|
});
|
||||||
|
toast("Trockner gestoppt.");
|
||||||
|
} else if (go === "slot_setzen") {
|
||||||
|
const aceSlot = Number(steerBtn.dataset.aceSlot);
|
||||||
|
await api("/drucker/" + encodeURIComponent(host) + "/steuern", {
|
||||||
|
method: "POST",
|
||||||
|
json: { action: "slot_setzen", ace_slot: aceSlot },
|
||||||
|
});
|
||||||
|
toast("Platz an den Drucker geschrieben.");
|
||||||
}
|
}
|
||||||
showNote("note", null);
|
showNote("note", null);
|
||||||
refresh();
|
refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showNote("note", error);
|
showNote("note", error);
|
||||||
|
toast(error.message, "bad");
|
||||||
} finally {
|
} finally {
|
||||||
steerBtn.disabled = false;
|
steerBtn.disabled = false;
|
||||||
}
|
}
|
||||||
|
|||||||