3 Commits
Author SHA1 Message Date
Dominic Gundel 8e88efc094 Grotti 3.0.0: Aufträge, Wartung, SSE, Ausschuss-Dialog, UI-Umbau.
Migrationen 003–009, Kundaufträge mit Lieferschein, Abräumen mit Urteil,
ntfy/Backup/Zeitfenster, Live-Stream und Mobile-Mehr-Menü.
2026-10-18 00:54:51 +02:00
Dominic Gundel 0236278929 Grotti 3.0 Phase 1: Migrationen, Pool, Queue- und Abzug-Fixes.
Nummerierte SQL-Migrationen, psycopg-Pool, waitress, hängende Queue-Jobs,
sichere History-Abzüge, Chargen-Locks, Upload-/Host-Validierung, ProxyFix.
2026-10-18 00:47:23 +02:00
Dominic Gundel 9e22cebb81 Grotti 2.10.0: Orca-Senden, Slice-Ordner, ED-Strom, Hygiene.
Slice-Watch mit Ordnern/Filtern, Orca-Nachbearbeitung, Cockpit-Verlauf
mit ED, Zeitzone im Browser. Tasten-Grenze und README-API angeglichen,
toter Code entfernt, Requirements gepinnt.
2026-10-18 00:41:15 +02:00
84 changed files with 6315 additions and 574 deletions

No files matched your search

+4
View File
@@ -2,6 +2,10 @@
config.json config.json
data/ data/
# Slice-/Watch-Ordner: nur der Platzhalter im Git, Inhalt lokal
slice/*
!slice/.gitkeep
.venv/ .venv/
__pycache__/ __pycache__/
*.pyc *.pyc
+62
View File
@@ -4,6 +4,68 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
## [Unreleased] ## [Unreleased]
## [3.0.0] – 2026-10-18
Major: neue Tabellen (Aufträge, Wartung), Projekte/Ideen → Aufträge, Navigation umgebaut, Dev-Server → waitress, Schema über Migrationen.
**Vor dem Update:** `pg_dump` der Datenbank. Danach Grotti starten (Migrationen 003–009 laufen automatisch).
### Neu
- **Aufträge:** Kunden, Positionen (Datei × Menge), Fortschritt, Einreihen, Lieferschein; Migration aus Projekten/Ideen; API `/api/v1/kunden`, `/api/v1/auftraege`; MCP-Lesetools.
- **Ausschuss-Pflicht:** Dialog beim „Platte geräumt“ (Urteil, Grund, Stückzahlen).
- **Wartung:** Laufzähler und Verschleißteile, Seite `/wartung`.
- **Bedarf:** Queue vs. Spulenrest (`GET /api/v1/bedarf`).
- **ntfy** neben Telegram; **Backup**-ZIP (pg_dump + data/); **Zeitfenster** je Drucker; optional HT/NT-Tarif.
- **SSE** Live-Stream `/api/v1/stream`, Verbindungsbanner, PWA-Manifest/Service-Worker.
- Migrationen unter `grotti/migrations/`, Connection-Pool, Indizes, Sample-Retention.
- Mobile-Nav mit „Mehr“-Sheet; Anleitungen Aufträge und Wartung.
### Geändert
- Server: waitress; Threads starten in `server.main()`.
- Navigation: Aufträge statt Projekte/Ideen; Wartung; Dateien-/Statistik-Icons.
- Kontrast `--faint`, `prefers-reduced-motion`, Touch-Ziele, Print-CSS.
- Upload-Whitelist, Host-Validierung, ProxyFix, Settings-Lock, `?token=` nur Bilder.
- Spulenabzug History ohne blinde Rate; Chargen-Locks; MCP `temp`/`percent`.
### Behoben
- Hängende Queue-Starts nach Neustart; Worker-Rollback; `snapshot()`-Exceptions.
- Toast-Dedupe; ACE-Map bei Send-Vorbereitung; Spulenwarnung mit ACE.
## [2.10.0] – 2026-10-18
### Neu
- Einstellungen: Slice-Ordner (`watch_dir`) setzen; Repo-Ordner `slice/` als Standard (Inhalt gitignored).
- Orca-Nachbearbeitung: `scripts/orca_senden.py` lädt den Export nach Grotti und öffnet `/dateien?file=…` (Drucker, ACE, Einreihen in der UI); Anleitung unter Doku.
- Bibliothek: Ordner unter dem Slice-Pfad anlegen und Watch-Dateien verschieben (ID/Projekt bleiben); API `…/dateien/ordner`, `…/verschieben`.
- Bibliothek: Filter Material und Ordner, Sortierung (Neueste / Name / Material / Ordner / Druckzeit / Gewicht), Suche auch in Material und Projekt; Filter bleiben im Browser; Gruppenköpfe bei Sort Material/Ordner.
- Cockpit: Verlauf (Temp/ED) des aktuellen oder letzten Drucks, Link zum Bericht; gemeinsame Chart-Hilfe in `api.js`.
- Druckbericht-Verlauf: Zeitachse (links °C, rechts ED %), gestrichelte Soll-Linien, Einschaltdauer Düse/Bett; Aufheizen wird dichter erfasst.
- Stromschätzung aus ED: Einstellungen → Düse-/Bett-Watt; Bericht zeigt kWh (ED oder pauschal).
- Einstellungen → Dieser Browser: Zeitzone für Anzeigezeiten (Browser automatisch oder z. B. Europe/Berlin).
### Geändert
- Energie-Defaults: Grundlast 40 W (Elektronik), Düse 60 W, Bett 220 W; Hinweis in den Einstellungen, wenn Düse/Bett 0 (nur pauschal).
- Druckbericht: Start/Ende als lesbare Ortszeit (`17.10.2026, 15:09 – 15:46`) statt ISO.
- Sidebar **Läuft jetzt**: eine Zeile pro Drucker (Name · % · Fertig-Uhr), Fortschritt in der Zeile; Klick öffnet die Warteschlange, × bricht ab.
- Spulenakte: KPI-Kacheln statt Fließtext; Druckzeilen mit Datum, Charge `D-…`, Kurzname, Gramm und Status.
- Material-Spulenformular: Marke, Chargen und Technik unter „Weitere Angaben“ eingeklappt.
- Druck-Etikett: „Soll … g“ statt „Ziel“ (Slicer-Filamentmenge, wie im Bericht).
- Tasten-Grenze im Ablauf: dokumentierter Default 3 mm (kalt an der Düse sonst Fehlalarm); Anleitung und Technik-Notiz angepasst.
- `requirements.txt` mit festen Versionen; `requirements-dev.txt` mit pytest.
- Unbenutzte Helfer entfernt (`printers.load_printers`, Mesh-Vergleich 3×3, `fetch_bed_mesh`, `last_sample_at`, …).
### Behoben
- Druckbericht: Objekt-Etiketten kollidieren nicht mehr, wenn dieselbe Chargennummer schon an einem anderen Lauf hängt (Seite `/protokoll/<id>` lieferte 500).
- Orca-Nachbearbeitung: Export unter dem Slice-Ordner (inkl. Unterordner) wird als Watch-Datei übernommen statt flachem Upload; Temp-Dateien `.gcode.pp` erkannt.
- Material: Rest-Gramm im Spulenformular akzeptiert Dezimalwerte (Abzug lieferte z. B. 501,34 g — Browser blockierte bei `step=1`).
## [2.5.1] – 2026-10-17 ## [2.5.1] – 2026-10-17
### Neu ### Neu
+22 -10
View File
@@ -30,7 +30,7 @@ copy config.example.json config.json
| `host`, `port` | Wo die Oberfläche lauscht. Standard `127.0.0.1:8770`. | | `host`, `port` | Wo die Oberfläche lauscht. Standard `127.0.0.1:8770`. |
| `database_url` | PostgreSQL-Verbindung. | | `database_url` | PostgreSQL-Verbindung. |
| `data_dir` | Uploads, Vorschaubilder, Cache. Standard `data`. | | `data_dir` | Uploads, Vorschaubilder, Cache. Standard `data`. |
| `watch_dir` | Slice-Ordner, der beobachtet wird. Leer = nur Upload. | | `watch_dir` | Slice-Ordner (unter Einstellungen setzen). Leer = nur Upload. |
| `token` | Wenn gesetzt, braucht die API `Authorization: Bearer <token>`. | | `token` | Wenn gesetzt, braucht die API `Authorization: Bearer <token>`. |
| `fqdn` | Öffentliche Basis-URL für Spulen-Etiketten-QR. Leer = Request-Host. | | `fqdn` | Öffentliche Basis-URL für Spulen-Etiketten-QR. Leer = Request-Host. |
| `energy` | Watt und Strompreis für die Kosten im Protokoll. | | `energy` | Watt und Strompreis für die Kosten im Protokoll. |
@@ -39,6 +39,8 @@ copy config.example.json config.json
Die Drucker stehen beim ersten Start in `drucker.json` und landen dann in der Datenbank. Danach werden sie unter Einstellungen gepflegt. Die Drucker stehen beim ersten Start in `drucker.json` und landen dann in der Datenbank. Danach werden sie unter Einstellungen gepflegt.
Schema-Änderungen laufen beim Start über `grotti/migrations/` (Tabelle `schema_version`). Vor Major-Updates die Datenbank sichern (`pg_dump`).
```json ```json
[{ "name": "Kobra 3 da26", "host": "10.10.1.60" }] [{ "name": "Kobra 3 da26", "host": "10.10.1.60" }]
``` ```
@@ -75,17 +77,27 @@ Alles unter `/api/v1`, JSON. Auth: Session-Cookie (UI) oder `Authorization: Bear
| `POST /auth/setup`, `/auth/login`, `/auth/logout` | Ersten Admin, Login, Logout | | `POST /auth/setup`, `/auth/login`, `/auth/logout` | Ersten Admin, Login, Logout |
| `GET /me`, `/users`, `/tokens` | Konto, Benutzer (Admin), API-Tokens | | `GET /me`, `/users`, `/tokens` | Konto, Benutzer (Admin), API-Tokens |
| `GET /version` | Version | | `GET /version` | Version |
| `GET`/`PATCH /einstellungen`, `POST /telegram/test` | Config und Telegram-Probe |
| `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` · `PATCH`/`DELETE /drucker/<id>` | Drucker anlegen/ändern (Admin) |
| `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, ACE |
| `GET /dateien`, `POST /dateien` | Bibliothek, Upload | | `POST /drucker/<host>/abraeumen` | Platte geräumt (`confirm`) |
| `POST /senden` | Datei an Drucker. `confirm: true` ist Pflicht. `when_free: true` reiht ein. | | `GET /kamera/<host>`, `/vorschau/<host>` | Snapshot / Job-Thumbnail |
| `GET /druck-queue`, `POST /druck-queue`, `PATCH /druck-queue/<id>` | Warteschlange (`confirm` beim Einreihen) | | `GET`/`POST /dateien`, `PATCH`/`DELETE /dateien/<id>` | Bibliothek, Upload, Metadaten |
| `POST /spulen/<id>/einlegen` | Spule an Drucker/Platz; alte Rolle dort ins Regal | | `POST /dateien/neu-lesen`, `/dateien/ordner`, `/dateien/<id>/verschieben` | Watch-Sync, Ordner, Verschieben |
| `POST /ablauf` | Ablauf für Kobra 3 einreihen | | `POST /senden`, `GET /sende-auftraege` | Sofort senden (`confirm`), Fortschritt |
| `GET /ereignisse?host=&kind=&since=&limit=` | Aktivität | | `GET`/`POST /druck-queue`, `PATCH /druck-queue/<id>` | Warteschlange (`confirm` beim Einreihen) |
| `POST /ereignisse/gelesen` | Alerts als gelesen | | `GET /laeuft`, `/aufgaben` | Direktstarts und Platte-belegt |
| `GET /spulen`, `GET /laeufe`, `GET /projekte` | Material, Protokoll, Projekte | | `POST /ablauf`, `/schieben-testen` | Ablauf Kobra 3 / Schieben-Test |
| `GET /spulen`, `POST /spulen`, `POST /spulen/<id>/einlegen` | Spulen und Einlegen |
| `GET /spulen/<id>/akte`, `/spulen/by-code` | Spulenakte, Scan `S-…` |
| `GET`/`POST /materialien`, `/farben` | Sorten und Farbpalette |
| `GET /laeufe`, `/laeufe/aktuell`, `/laeufe/<id>` | Protokoll und Druckbericht |
| `GET /statistik`, `/statistik.csv` | Auslastung und CSV |
| `GET`/`POST /projekte`, `/ideen` | Projekte und Ideen-Backlog |
| `GET /ereignisse`, `POST /ereignisse/gelesen` | Aktivität und Alerts |
| `POST /kalkulation` | Vorkalkulation Material+Strom |
MCP: `python -m grotti.mcp_server` mit `GROTTI_URL` und `GROTTI_TOKEN` (siehe `docs/internal/MCP.md`). MCP: `python -m grotti.mcp_server` mit `GROTTI_URL` und `GROTTI_TOKEN` (siehe `docs/internal/MCP.md`).
+11 -2
View File
@@ -2,7 +2,7 @@
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 8770, "port": 8770,
"data_dir": "data", "data_dir": "data",
"watch_dir": "", "watch_dir": "slice",
"database_url": "postgresql://[email protected]:5432/grotti", "database_url": "postgresql://[email protected]:5432/grotti",
"token": "", "token": "",
"secret_key": "", "secret_key": "",
@@ -16,7 +16,9 @@
"redirect_uri": "" "redirect_uri": ""
}, },
"energy": { "energy": {
"watts": 120, "watts": 40,
"nozzle_watts": 60,
"bed_watts": 220,
"euro_per_kwh": 0.35, "euro_per_kwh": 0.35,
"dryer_watts": 0, "dryer_watts": 0,
"dry_hours": 0, "dry_hours": 0,
@@ -28,11 +30,18 @@
"bot_token": "", "bot_token": "",
"chat_id": "" "chat_id": ""
}, },
"ntfy": {
"enabled": false,
"server": "https://ntfy.sh",
"topic": "",
"token": ""
},
"alerts": { "alerts": {
"fertig": true, "fertig": true,
"fehler": true, "fehler": true,
"offline": true, "offline": true,
"queue": true, "queue": true,
"spule": true,
"ablauf": false, "ablauf": false,
"start": false "start": false
} }
+19
View File
@@ -0,0 +1,19 @@
# Aufträge
Stand **18.10.2026**. Kundenaufträge mit Positionen (Datei × Menge), Fortschritt und Lieferschein.
## Seite Aufträge
Unter **Aufträge** legst du Kunden und Aufträge an. Status: Idee → Angebot → Bestätigt → Druckt → Fertig → Geliefert → Bezahlt.
Pro Position: Datei, Stückzahl, optional Preis und Material. **Einreihen** schickt die Position in die Warteschlange (`confirm`). Fertige gute Stücke aus dem Abräumen-Dialog zählen auf den Fortschritt „n von m“.
## Lieferschein
Im Auftrag **Lieferschein** öffnen: Positionen, Chargen `D-…`, druckbar. Danach Status auf **Geliefert** setzen.
## Weiter
- [Warteschlange](Anleitung-Warteschlange.md)
- [Material](Anleitung-Material.md)
- [Protokoll / Bericht](Anleitung-Oberflaeche.md)
+13 -4
View File
@@ -1,18 +1,22 @@
# Dateien senden # Dateien senden
Stand **15.10.2026**. Seite `/dateien` — Bibliothek links, Senden rechts. Stand **17.10.2026**. Seite `/dateien` — Bibliothek links, Senden rechts.
![Dateien: Bibliothek und Senden](../web/static/doku/dateien-flow.png) ![Dateien: Bibliothek und Senden](../web/static/doku/dateien-flow.png)
## Bibliothek ## Bibliothek
- Suche, Filter nach Maschine und Projekt - Suche nach Name, Material und Projekt
- Filter: Maschine, Projekt, Material, Ordner (Unterordner unter dem Slice-Ordner; Uploads als „Upload“)
- Sortierung: Neueste, Name, Material, Ordner, Druckzeit, Gewicht — bei Material/Ordner mit Gruppenköpfen
- Auswahl der Filter bleibt im Browser merken
- Vorschaubild, Material, Zeit, Gewicht, Schichthöhe - Vorschaubild, Material, Zeit, Gewicht, Schichthöhe
- Info-Popup mit Slice-Metadaten (Kopf und Footer) - Info-Popup mit Slice-Metadaten (Kopf und Footer)
- **Hochladen** per Knopf oder Drag & Drop - **Hochladen** per Knopf oder Drag & Drop
- **Ordner neu lesen**, wenn `watch_dir` in der Config auf den Slice-Ordner zeigt - Mit gesetztem Slice-Ordner ([Einstellungen](Anleitung-Einstellungen.md)): **Ordner anlegen**, Watch-Dateien per Auswahl **verschieben** (Datei bleibt in der Bibliothek dieselbe, inkl. Projekt)
- **Ordner neu lesen**, wenn unter Einstellungen ein Slice-Ordner gesetzt ist
Ohne `watch_dir` geht nur manueller Upload — das reicht für den Alltag. Ohne Slice-Ordner geht nur manueller Upload — das reicht für den Alltag. Uploads liegen flach unter „Upload“ und lassen sich in der Bibliothek nicht in Slice-Ordner schieben.
## Senden: drei Schritte ## Senden: drei Schritte
@@ -43,7 +47,12 @@ Große Dateien brauchen oft **eine Minute**. In der Sidebar unter **Senden** sie
- Bei „Wenn frei“: Eintrag unter [Warteschlange](Anleitung-Warteschlange.md) - Bei „Wenn frei“: Eintrag unter [Warteschlange](Anleitung-Warteschlange.md)
- Später unter **Protokoll** mit **Nochmal** (inkl. gespeicherter ACE-Map) - Später unter **Protokoll** mit **Nochmal** (inkl. gespeicherter ACE-Map)
## Aus Orca
Nach dem G-code-Export kann Orca die Datei direkt nach Grotti laden und diese Seite mit der Datei öffnen — siehe [Orca an Grotti](Anleitung-Orca.md).
## Weiter ## Weiter
- [Orca an Grotti](Anleitung-Orca.md)
- [ACE und Spulen](Anleitung-ACE.md) - [ACE und Spulen](Anleitung-ACE.md)
- [Warteschlange](Anleitung-Warteschlange.md) - [Warteschlange](Anleitung-Warteschlange.md)
+1 -1
View File
@@ -27,7 +27,7 @@ copy config.example.json config.json
| `host`, `port` | Wo die Oberfläche lauscht. Standard `127.0.0.1:8770`. | | `host`, `port` | Wo die Oberfläche lauscht. Standard `127.0.0.1:8770`. |
| `database_url` | PostgreSQL-Verbindung. | | `database_url` | PostgreSQL-Verbindung. |
| `data_dir` | Uploads, Vorschaubilder, Cache. Standard `data`. | | `data_dir` | Uploads, Vorschaubilder, Cache. Standard `data`. |
| `watch_dir` | Slice-Ordner, den Grotti beobachtet. Leer = nur manueller Upload. | | `watch_dir` | Slice-Ordner (meist unter **Einstellungen** setzen). Leer = nur manueller Upload. Standard-Vorschlag: Ordner `slice` im Repo. |
| `token` | Optionaler Shared-API-Token (Übergang). Mit Benutzerverwaltung nutzt du API-Tokens unter Einstellungen. | | `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. | | `fqdn` | Öffentliche Basis-URL für QR auf Spulen-Etiketten. Leer = Request-Host. |
| `auto_queue` | `false` stoppt neue „wenn frei“-Starts. | | `auto_queue` | `false` stoppt neue „wenn frei“-Starts. |
+7 -2
View File
@@ -1,17 +1,22 @@
# Einstellungen # Einstellungen
Stand **15.10.2026**. Seite `/einstellungen` — Drucker, Automatik, Anzeige, Telegram, Energie. Stand **17.10.2026**. Seite `/einstellungen` — Drucker, Automatik, Anzeige, Telegram, Energie.
![Einstellungen: Drucker](../web/static/doku/einstellungen-drucker.png) ![Einstellungen: Drucker](../web/static/doku/einstellungen-drucker.png)
## Dieser Browser ## Dieser Browser
- **Desktop** oder **Regal (Touch)** — nur lokal im Browser, siehe [Oberfläche](Anleitung-Oberflaeche.md). - **Desktop** oder **Regal (Touch)** — nur lokal im Browser, siehe [Oberfläche](Anleitung-Oberflaeche.md).
- **Zeitzone** — Browser automatisch oder fest (z. B. Europe/Berlin) für Protokoll, Bericht und Aktivität.
## Automatik ## Automatik
Checkbox **Automatik-Warteschlange** (`auto_queue`). Aus = keine neuen „wenn frei“-Starts. Laufende Abläufe und manuelle „Jetzt“-Starts bleiben möglich. Checkbox **Automatik-Warteschlange** (`auto_queue`). Aus = keine neuen „wenn frei“-Starts. Laufende Abläufe und manuelle „Jetzt“-Starts bleiben möglich.
## Slice-Ordner
Pfad zum Ordner, den die Bibliothek beobachtet (Export aus Orca/Anycubic, **Ordner neu lesen**, Unterordner). **Standardordner** setzt `slice` im Grotti-Repo. Leer = nur Hochladen. Nur Admin; der Ordner wird beim Speichern angelegt, falls er fehlt.
## Drucker ## Drucker
Tabelle: Name, Host, Familie, **gesperrt**. Tabelle: Name, Host, Familie, **gesperrt**.
@@ -36,7 +41,7 @@ MCP (optional): Token erzeugen, dann `python -m grotti.mcp_server` mit `GROTTI_U
## Energie ## Energie
Watt und Strompreis für die Kosten im **Protokoll**. Optional Trockner/Ausschuss/Auslastung für die Kalkulation. **Grundlast W** (Elektronik/Motion, nicht die Heizer) und Strompreis für die Kosten im **Protokoll**. **Düse W** / **Bett W** zusammen mit ED-Proben im Verlauf → Schätzung „aus ED“; stehen sie auf 0, bleibt es bei Dauer × Grundlast („pauschal“). Typisch Kobra: Grundlast ~40 W, Düse ~60 W, Bett ~200–300 W. Beim Speichern warnt die Seite, wenn Düse oder Bett noch 0 sind. Optional Trockner/Ausschuss/Auslastung für die Kalkulation.
## Weiter ## Weiter
+1 -1
View File
@@ -28,7 +28,7 @@ Abgeschlossene Läufe: Dauer, Ist-Gramm, Kosten, Urteil. Zeile öffnet den **Dru
Unter **Statistik** Auslastung, Erfolg, Verbrauch und CSV-Export. Zeitraum per Knopf (Tag, Woche, Monat, Gestern, Letzte Woche, Letzter Monat) oder eigene Von/Bis-Daten. 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. Druck-Etiketten (`D-…`) schon während des Drucks: an der Wand **Etikett**, im Druckbericht **Etikett Druck** / **Etiketten Objekte**. Auf dem Bogen steht das Sollgewicht aus dem Slicer (geplante Filamentmenge). 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. 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.
+2 -1
View File
@@ -16,7 +16,7 @@ Unter dem Menü, eigene Scrollfläche. Boxen auf- und zuklappbar (Merker im Brow
| Box | Inhalt | | Box | Inhalt |
| --- | --- | | --- | --- |
| **Läuft jetzt** | Laufende Aufträge und Abläufe, Schrittleiste, Abbrechen. Auch Direktstarts ohne Queue-Zeile. | | **Läuft jetzt** | Eine Zeile pro Drucker (Name · % · Fertig-Uhr); Klick öffnet die Warteschlange, × bricht ab. Auch Direktstarts ohne Queue-Zeile. |
| **Wartet** | Wartende Aufträge: Priorität, Abbrechen. Gestoppte Abläufe mit Fortsetzen. | | **Wartet** | Wartende Aufträge: Priorität, Abbrechen. Gestoppte Abläufe mit Fortsetzen. |
| **Aufgaben** | Platte belegt ohne laufenden Ablauf — **geräumt** (Zwei-Klick). | | **Aufgaben** | Platte belegt ohne laufenden Ablauf — **geräumt** (Zwei-Klick). |
| **Senden** | Uploads mit MB-Fortschritt. | | **Senden** | Uploads mit MB-Fortschritt. |
@@ -28,6 +28,7 @@ Unter **Einstellungen → Dieser Browser**:
- **Desktop** — normale Dichte - **Desktop** — normale Dichte
- **Regal (Touch)** — größere Bedienelemente, kompaktere Nav, Sidebar oft eingeklappt - **Regal (Touch)** — größere Bedienelemente, kompaktere Nav, Sidebar oft eingeklappt
- **Zeitzone** — Browser oder fest (z. B. Europe/Berlin) für Uhrzeiten in Protokoll und Bericht
Shortcut in der URL: `?shelf=1` bzw. `?shelf=0`. Nur dieser Browser (`localStorage`). Shortcut in der URL: `?shelf=1` bzw. `?shelf=0`. Nur dieser Browser (`localStorage`).
+55
View File
@@ -0,0 +1,55 @@
# Orca an Grotti
Stand **17.10.2026**. Nach dem Export in OrcaSlicer landet der G-code in der Bibliothek und die Seite [Dateien senden](Anleitung-Dateien.md) öffnet sich mit der Datei schon gewählt. Drucker, ACE-Platz und Einreihen wählst du dort wie gewohnt.
## Einmal einrichten
1. Grotti starten und im Browser anmelden (die Session bleibt für die Senden-Seite).
2. Unter **Einstellungen → API-Token** ein Token anlegen (Rolle Operator reicht). Den Wert `grt_…` nur einmal sichtbar.
3. Datei `data/orca-senden.json` anlegen (gleicher Ordner wie `config.json`):
```json
{
"url": "http://127.0.0.1:8770",
"token": "grt_…"
}
```
`url` ist die Adresse, unter der du Grotti im Browser öffnest.
4. Im **Prozessprofil** (Druckqualität, z. B. „0.20mm Standard“): **Sonstiges → Nachbearbeitungsskripte**, eine Zeile:
```text
D:\Projekte\Grotti\.venv\Scripts\python.exe D:\Projekte\Grotti\scripts\orca_senden.py
```
Gilt für **OrcaSlicer** und **AnycubicSlicerNext**. Pfade anpassen. Der Slicer hängt den Export-Pfad selbst an.
Unter **Gerät / Drucker** keinen Print-Host (Moonraker) eintragen — sonst geht der Job am Grotti-Senden vorbei.
## Ablauf
1. In Orca slicen und **G-code exportieren** (Platte schreiben). Als Ziel den Slice-Ordner wählen — **Unterordner** (z. B. `slice\Gridfinity\…`) gehen: Grotti legt die Datei dort ab und liest den Watch neu. Export außerhalb des Slice-Ordners landet als Upload (ohne Ordner-Verschieben).
2. Der Browser öffnet `/dateien?file=…` mit der neuen Datei.
3. Drucker wählen, unter Optionen Farbe → ACE-Platz, **Jetzt** / **Wenn frei** / **Nächster freier**, dann **Starten**.
Test ohne Orca:
```powershell
D:\Projekte\Grotti\.venv\Scripts\python.exe D:\Projekte\Grotti\scripts\orca_senden.py C:\pfad\datei.gcode
```
## Störungen
| Symptom | Check |
| --- | --- |
| Meldungsfenster „Config fehlt“ | `data/orca-senden.json` anlegen |
| „Grotti nicht erreichbar“ | Server läuft? `url` stimmt? |
| „Token fehlt oder stimmt nicht“ | Token neu anlegen, in der JSON eintragen |
| Browser ohne Datei / Login | Einmal anmelden; Deep-Link behält `?file=` nach dem Login |
| Skript läuft nicht | Export wirklich speichern; Zeile im **Druckerprofil**; Python-Pfad prüfen |
## Weiter
- [Dateien senden](Anleitung-Dateien.md)
- [ACE und Spulen](Anleitung-ACE.md)
+1
View File
@@ -38,6 +38,7 @@ Inhalt:
- Status, Temps inkl. Bauraum wo verfügbar, Lüfter - Status, Temps inkl. Bauraum wo verfügbar, Lüfter
- **Steuerung:** Zieltemperaturen, Speed, Flow, Fan — fest vorgegebene Befehle, keine freie Konsole. Mit ACE zusätzlich Trockner (Temp, Dauer, Start/Stopp) und pro bestücktem Platz **an den Drucker** (Sorte und Farbe aus der Grotti-Spule) - **Steuerung:** Zieltemperaturen, Speed, Flow, Fan — fest vorgegebene Befehle, keine freie Konsole. Mit ACE zusätzlich Trockner (Temp, Dauer, Start/Stopp) und pro bestücktem Platz **an den Drucker** (Sorte und Farbe aus der Grotti-Spule)
- **E-Stop** nur bei printing/paused/heating (Zwei-Klick). Danach oft Strom aus/an - **E-Stop** nur bei printing/paused/heating (Zwei-Klick). Danach oft Strom aus/an
- **Verlauf** Temperatur und ED des aktuellen oder letzten Drucks; Link zum Druckbericht
- **3D-Pfad** der laufenden oder gewählten Datei; Objekte ausschließen (mit Bestätigung) - **3D-Pfad** der laufenden oder gewählten Datei; Objekte ausschließen (mit Bestätigung)
- **Bettnetz**-Heatmap, wenn vorhanden - **Bettnetz**-Heatmap, wenn vorhanden
+2 -2
View File
@@ -16,7 +16,7 @@ Pro Auftrag:
Unter **Läuft** siehst du auch Direktstarts ohne Queue-Zeile (Link zur Wand). Unter **Läuft** siehst du auch Direktstarts ohne Queue-Zeile (Link zur Wand).
Dieselbe Information steht kompakt in der Sidebar (**Läuft jetzt** / **Wartet**). Dieselbe Information steht kompakt in der Sidebar (**Läuft jetzt** / **Wartet**). Unter **Läuft jetzt** eine Zeile pro Drucker; Klick öffnet diese Seite, × bricht ab.
## Wann startet ein wartender Auftrag? ## Wann startet ein wartender Auftrag?
@@ -34,7 +34,7 @@ Drucken → Abkühlen → Schieben → Tasten → (Wiederholung)
| --- | --- | | --- | --- |
| **Abkühlen** | Wartet, bis Bett und Düse unter der Grenze sind. PLA löst sich oft von selbst. | | **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. | | **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. | | **Tasten** | LeviQ-Netz tippt; Maß aus `Result is z=`. Über 3 mm stoppt den Ablauf (Default; enger geht unter Einstellungen/Ablauf). |
Aktueller Schritt: auf der Druckerkarte und in der Sidebar. Abbrechen an beiden Stellen. Jeder Schritt landet unter **Aktivität**. Aktueller Schritt: auf der Druckerkarte und in der Sidebar. Abbrechen an beiden Stellen. Jeder Schritt landet unter **Aktivität**.
+12
View File
@@ -0,0 +1,12 @@
# Wartung
Stand **18.10.2026**. Druckstunden, Filament-Meter und Verschleißteile je Drucker.
## Seite Wartung
Unter **Wartung** siehst du je Drucker die Summe aus dem Protokoll (Stunden, Meter, Starts). Teile mit Soll-Lebensdauer (Stunden oder Meter) und Einbaudatum; bei Reset wird der Zählerstand protokolliert.
## Weiter
- [Einstellungen](Anleitung-Einstellungen.md)
- [Statistik](Anleitung-Oberflaeche.md)
+1 -1
View File
@@ -17,7 +17,7 @@ Unter **Dateien** erscheint die Karte **Ablauf**, sobald mindestens eine Kobra 3
- **Drucken** — Datei, Bettnetz (LeviQ) standardmäßig an - **Drucken** — Datei, Bettnetz (LeviQ) standardmäßig an
- **Abkühlen** — Heizung aus, Lüfter an, Düse in die Bettmitte, warten bis Bett/Düse unter den Grenzen - **Abkühlen** — Heizung aus, Lüfter an, Düse in die Bettmitte, warten bis Bett/Düse unter den Grenzen
- **Schieben** — Bahn mit einstellbaren Zahlen, danach Lüfter aus - **Schieben** — Bahn mit einstellbaren Zahlen, danach Lüfter aus
- **Tasten** — kein `G28 Z`. **Mit aktivem LeviQ-Netz** (kein `BED_MESH_CLEAR`): `Result` = Höhe über der lokalen Platte. Bezugspunkt + Tastpunkt (x/y) einstellbar. Maß: letztes `Result is z=` aus dem Store. Über 1 mm = Restteil. - **Tasten** — kein `G28 Z`. **Mit aktivem LeviQ-Netz** (kein `BED_MESH_CLEAR`): `Result` = Höhe über der lokalen Platte. Bezugspunkt + Tastpunkt (x/y) einstellbar. Maß: letztes `Result is z=` aus dem Store. Über `max_delta_mm` (Default 3 mm) = Restteil. Enge 1 mm/2,5 mm-Werte aus alten Abläufen werden auf 3 mm angehoben (kalt an der Düse sonst Fehlalarm).
Darunter **wie oft hintereinander**. Einreihen legt einen Eintrag in die Warteschlange „Wenn frei“. **Schieben testen** fährt die Bahn auf einer freien Kobra 3 (Bestätigung „Bett ist leer“). Werte merkt sich der Browser lokal. Darunter **wie oft hintereinander**. Einreihen legt einen Eintrag in die Warteschlange „Wenn frei“. **Schieben testen** fährt die Bahn auf einer freien Kobra 3 (Bestätigung „Bett ist leer“). Werte merkt sich der Browser lokal.
+2 -2
View File
@@ -12,9 +12,9 @@ Etikett: `/etikett/<id>`, Druck über den Browser. QR per lokalem `qrcode.min.js
## Druck-Chargen ## 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. `D-YYYYMMDD-0001` vergibt der Watcher am aktiven Lauf (History-Job, sonst Live). Die nächste Nummer zählt Läufe und schon vergebene Objekt-Etiketten, damit eine Charge nicht doppelt entsteht. `adopt_live_run` übernimmt die Nummer nur auf einen fertigen History-Lauf ohne eigene Charge. Objekt-Etiketten (`D-…-01`) wandern nur mit, wenn der Ziel-Lauf dieselbe Charge behält; sonst fallen sie mit dem Live-Lauf weg und werden unter der verbleibenden Charge neu vergeben.
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. Druck über `/etikett/lauf/<id>?mode=druck|objekt`. Auf dem Bogen: Charge, **Soll … g** (Slicer-Filamentmenge), 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
+3 -1
View File
@@ -1,6 +1,8 @@
# Grotti-Dateien Metadaten # Grotti-Dateien Metadaten
`stored_path` liegt nur in der DB und in Rohzeilen (`get_file_row`, `list_file_rows`). Die API-Antwort (`file_out` / `GET /api/v1/dateien`) enthält den Pfad bewusst nicht. `stored_path` liegt nur in der DB und in Rohzeilen (`get_file_row`, `list_file_rows`). Die API-Antwort (`file_out` / `GET /api/v1/dateien`) enthält den Pfad bewusst nicht — stattdessen berechnet `folder` den Relativ-Elternordner unter `watch_dir` (Uploads → `Upload`).
Verschieben einer Watch-Datei: Dateisystem unter `watch_dir` + `UPDATE files.stored_path` über dieselbe ID (`library.move_file`), damit Projekt/Spule erhalten bleiben. Reiner Re-Scan nach manuellem Verschieben würde die ID verlieren.
`POST /api/v1/dateien/neu-lesen` liest Watch-Ordner neu und ruft `refresh_file_meta` auf Rohzeilen auf — nicht auf `list_files`, sonst `KeyError: 'stored_path'`. `POST /api/v1/dateien/neu-lesen` liest Watch-Ordner neu und ruft `refresh_file_meta` auf Rohzeilen auf — nicht auf `list_files`, sonst `KeyError: 'stored_path'`.
+2 -2
View File
@@ -7,7 +7,7 @@ Stand **17.10.2026**.
- `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` - `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_spools`: Abzug je Spule (`is_estimate` bei Mehrfarb)
- `run_segments`: Abschnitte bei Rollenwechsel (Einlegen während Druck/Pause) - `run_segments`: Abschnitte bei Rollenwechsel (Einlegen während Druck/Pause)
- `run_samples`: Temperatur/Fortschritt ca. alle 30 s (Watcher) - `run_samples`: Temperatur/Soll/ED (`nozzle_power`/`bed_power`) — alle ~30 s, beim Aufheizen ~10 s; Proben schon vor `printing`, wenn Targets gesetzt und Auftrag/Live-Lauf bekannt
- `run_labels`: Objekt-Chargen `D-…-01` - `run_labels`: Objekt-Chargen `D-…-01`
- `events.run_id`: optionaler Bezug zum Lauf - `events.run_id`: optionaler Bezug zum Lauf
@@ -19,7 +19,7 @@ Stand **17.10.2026**.
## Live ## 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`. `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 (`adopt_live_run`, nicht auf ältere fertige Jobs derselben Datei). Die Live-Charge geht nur auf einen fertigen Lauf ohne Nummer über; Objekt-Etiketten nur zusammen mit dieser Charge. Sonst werden sie neu vergeben, statt eine fremde `D-…-01` zu blockieren.
## Statistik-Filter ## Statistik-Filter
+18 -45
View File
@@ -1,60 +1,33 @@
# Grotti: Alltag und Oberfläche # Grotti: Alltag und Oberfläche
Stand **14.10.2026**, v1.4.0. Stand **18.10.2026**, v3.0.0.
## Design ## Design
- Dunkles UI mit Orange-Akzent, Lucide-ähnliche Stroke-Icons (SVG-Sprite in `layout.html`). - Dunkles UI mit Orange-Akzent, Lucide-ähnliche Stroke-Icons (SVG-Sprite in `layout.html`).
- Kurze Seitenuntertitel; Technik-/Config-Hinweise liegen in der Doku, nicht als Fließtext in den Formularen. - Tokens in `grotti.css`; `--faint` lesbar; `prefers-reduced-motion`; Touch-Ziele bei `pointer: coarse`.
- Einheitliche Seitenköpfe, Toolbar-Filter, kompakte Tabellen, Formularfelder mit kleinen Labels, Empty-States mit Icon. - **Anzeige pro Browser**: Desktop oder Regal (Touch); Zeitzone; Nav einklappbar.
- **Anzeige pro Browser** (Einstellungen → Dieser Browser): `Desktop` oder `Regal (Touch)` in `localStorage grotti-display`. Shortcut `?shelf=1` / `?shelf=0`. Regal: größere Controls, kompakte Nav (Drucker/Kameras/Warteschlange), Sidebar default eingeklappt. - PWA: `manifest.webmanifest` + Service-Worker (Shell-Cache).
- Seitenmenü einklappbar (Icon-Rail, `grotti-nav-collapsed`). Unter 760 px weiter Bottom-Nav. - Live: SSE `/api/v1/stream` plus Poll-Fallback; Verbindungsbanner.
## Seitenleiste ## Seitenleiste
- Hauptmenü: Drucker, Dateien, Kameras, Aktivität, Warteschlange, Protokoll, Material, Projekte. Darunter Einstellungen, Ideen, Doku (mit Icons); Fuß **© by dominic@gundel.dev** und Version (Changelog). - **Live:** Drucker, Dateien, Kameras, Warteschlange, Aufträge.
- Live-Leiste, eigene Scrollfläche, Boxen auf- und zuklappbar (Zustand in `localStorage` `grotti-live-closed`): - **Rückschau:** Protokoll, Statistik, Aktivität.
- **Läuft jetzt**: laufende Aufträge und Abläufe mit Schrittleiste, Durchlauf `n/m`, aktueller Phase und ×-Abbrechen; auch Direktstarts ohne Queue. - **Verwalten:** Material, Wartung; Einstellungen, Doku.
- **Wartet**: wartende Aufträge mit Ziel, Priorität hoch/runter, Abbrechen; die letzten Fehler mit Fortsetzen/ausblenden. - Live-Panel: Läuft / Wartet / Aufgaben / Senden / Alerts.
- **Aufgaben**: Platte belegt ohne Ablauf — „geräumt“ per Zwei-Klick. - Mobile: Bottom-Nav Drucker · Dateien · Queue · Aktivität · **Mehr** (Sheet).
- **Senden**: laufende Uploads mit Fortschritt.
- **Alerts**: ungelesene wichtige Ereignisse, „gelesen“, Link auf Aktivität.
- Aktualisierung: Senden 2 s, Queue 4 s, Aufgaben/Alerts 8 s.
## Wand ## Wand und Abräumen
- Karte je Drucker: Zustandschip, Fertig-Uhrzeit groß, Fortschrittsbalken, drei Kacheln (Rest, Schicht, Läuft), Temperaturbalken Düse/Bett mit Ziel, Datei. Klick auf die Karte öffnet das Cockpit. Pause, Abbrechen, Fehler löschen und Platte geräumt bleiben auf der Karte (Zwei-Klick). - Karten + Cockpit wie bisher.
- Laufender Ablauf als Streifen mit Schrittleiste und Abbrechen. Wartende Aufträge für diesen Drucker als Hinweis. - **Platte geräumt** öffnet Dialog: gut/Ausschuss, Grund, Stückzahlen.
- Streifen „Fehler“ mit **Fehler löschen** und „Platte belegt“ mit **Platte geräumt** (während eines Ablaufs ausgeblendet, der räumt selbst).
- **Cockpit** (Hash `/#<host>`): Kamera (Frame-Vorladen; zugeklappt oder ohne Kamera → eine Spalte, kein leeres 16:10), Status, Temps inkl. Bauraum wenn vorhanden, Lüfter-Icons, Steuerung, 3D-Pfad (Exclude-Object), Bettnetz. E-Stop nur bei printing/paused/heating, Zwei-Klick. Live vom Watcher.
## Dateien und Senden ## Aufträge und Wartung
- Links Bibliothek: Suche, Filter Maschine/Projekt, Vorschaubild; Zeile zeigt Material, Zeit, Gewicht, Schichthöhe, Maschine, Projekt; Details per Info-Popup. Metadaten aus G-code-Kopf **und** Footer. Hochladen / Ordner neu lesen. - `/auftraege`: Kunden, Positionen, Einreihen, Lieferschein. `/projekte` und `/ideen` leiten um.
- Rechts der Flow: Datei → Drucker (Live-Zustand, eingelegte Rolle) → Wann (Jetzt / Wenn frei / Nächster freier) oder Reiter Ablauf. - `/wartung`: Stunden/Meter und Verschleißteile.
- Kein freies Spulen-Dropdown: Abzug nimmt die Rolle(n) am Ziel-Drucker zur Startzeit. ACE: Zuordnung Datei-Farbe → Platz. Warnung bei Material-Mismatch oder zu wenig Rest.
- Startknopf mit Zwei-Klick bei „Jetzt“. Auswahl in `localStorage` `grotti-send-v1`.
## Material ## Dateien, Material, Statistik
- Reiter **Spulen** / **Sorten**. Scan-Feld im Seitenkopf (`S-…` Spule, `D-…` Druckbericht). - Unverändert im Kern (Bibliothek, Send-Flow, Spulen/Sorten, KPIs + Ausschuss-Gründe).
- Spulen: Filter Alle / Am Drucker / Regal / Wenig Rest + Suche. Formular nur bei Auswahl oder „Neue Spule“ (× schließt). Ort = Drucker · ACE/extern bzw. Regal. Akte mit Verbrauch und Leer-in.
- Sorten: Profile (Dichte, mm³/s, Preis, Maschinen). Anlegen/ändern/löschen; „Spule damit anlegen“ wechselt zum Spulen-Reiter.
## Protokoll und Statistik
- Protokoll-Zeile → Druckbericht `/protokoll/<id>` (Soll/Ist, Spulen, Temperatur, Charge, Etiketten). Charge schon am laufenden Druck; Etikett an der Wand und im Bericht.
- Statistik `/statistik`: KPIs, nach Drucker/Material, Zeitraum-Presets, CSV mit denselben Filtern.
## Warteschlange
- Seite `/warteschlange`: Filter Läuft / Wartet / Fehler / Historie. Priorität, Abbrechen, Fortsetzen.
- Unter **Läuft** / **Alle aktiv** auch Direktstarts aus `/laeuft` (ohne Queue-Zeile), mit Fortschritt und Link zur Wand.
## Aktivität
- Zeitstrahl nach Tagen. Filter in der URL. Ungelesene Alerts markiert.
## Kameras und Handy
- Kamerakacheln mit Live-Zustand (`live=1`). Snapshot-Cache 2 s. Unter 760 px: Schnellmenü Drucker / Dateien / Aktivität.
+1 -1
View File
@@ -18,7 +18,7 @@ Ab 12.10.2026: Start legt Hintergrund-Aufträge an (`pending` → `running` →
## Slice-Ordner / Upload ## Slice-Ordner / Upload
`watch_dir` in `config.json` zeigt auf den Slice-Ordner der Bibliothek. Ohne Ordner geht Hochladen über die Dateien-Seite trotzdem. Slice-Ordner unter **Einstellungen** (persistiert als `watch_dir`). 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**. **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**.
+1 -1
View File
@@ -24,7 +24,7 @@ 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 INFO_TTL = 45
FAIL_TTL = 45 FAIL_TTL = 45
_CRED_CACHE = {} _CRED_CACHE = {}
_INFO_CACHE = {} _INFO_CACHE = {}
+92
View File
@@ -0,0 +1,92 @@
"""Datenbank- und Datenordner-Backup."""
from __future__ import annotations
import os
import re
import subprocess
import zipfile
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
def backup_dir(cfg):
base = Path(cfg.get("data_dir") or "data")
path = base / "backup"
path.mkdir(parents=True, exist_ok=True)
return path
def _safe_name(name):
name = re.sub(r"[^\w.\-]+", "_", name or "")
return name[:180] or "backup.zip"
def create_backup(cfg):
"""ZIP mit pg_dump (stdout) und data/ ohne toolpath/ und backup/."""
data_dir = Path(cfg.get("data_dir") or "data")
out_dir = backup_dir(cfg)
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
filename = _safe_name("grotti-%s.zip" % stamp)
dest = out_dir / filename
url = cfg.get("database_url") or ""
parsed = urlparse(url)
db_name = (parsed.path or "/grotti").lstrip("/") or "grotti"
env = {}
if parsed.password:
env["PGPASSWORD"] = parsed.password
cmd = ["pg_dump", "--no-owner", "--no-acl", db_name]
if parsed.hostname:
cmd[1:1] = ["-h", parsed.hostname]
if parsed.port:
cmd[1:1] = ["-p", str(parsed.port)]
if parsed.username:
cmd[1:1] = ["-U", parsed.username]
proc = subprocess.run(cmd, capture_output=True, env={**os.environ, **env})
if proc.returncode != 0:
err = (proc.stderr or b"").decode("utf-8", "replace").strip()
raise RuntimeError(err or "pg_dump fehlgeschlagen.")
skip_dirs = {"toolpath", "backup"}
with zipfile.ZipFile(dest, "w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr("database.sql", proc.stdout)
if data_dir.is_dir():
for path in sorted(data_dir.rglob("*")):
if not path.is_file():
continue
rel = path.relative_to(data_dir)
if rel.parts and rel.parts[0] in skip_dirs:
continue
zf.write(path, "data/" + rel.as_posix())
return {
"name": filename,
"path": str(dest),
"size": dest.stat().st_size,
}
def list_backups(cfg):
out_dir = backup_dir(cfg)
items = []
for path in sorted(out_dir.glob("*.zip"), reverse=True):
try:
size = path.stat().st_size
except OSError:
size = 0
items.append({"name": path.name, "size": size})
return items
def backup_path(cfg, name):
out_dir = backup_dir(cfg)
safe = _safe_name(name)
path = (out_dir / safe).resolve()
if not str(path).startswith(str(out_dir.resolve())):
return None
if not path.is_file():
return None
return path
+76
View File
@@ -0,0 +1,76 @@
"""Filament-Bedarf aus wartender Queue vs. Spulenrest."""
from __future__ import annotations
def _group_key(material_type, color):
return (
(material_type or "").strip().lower(),
(color or "").strip().lower(),
)
def compute(conn):
"""Wartende Jobs × Dateigewicht vs. Spulenrest, gruppiert nach Sorte/Farbe."""
queue_rows = conn.execute(
"""
SELECT q.id, q.spool_id, q.repeat_total, q.repeat_done,
f.weight_g, f.material_type AS file_material, f.color AS file_color,
s.material_type AS spool_material, s.color AS spool_color
FROM print_queue q
JOIN files f ON f.id = q.file_id
LEFT JOIN spools s ON s.id = q.spool_id
WHERE q.status = 'waiting'
"""
).fetchall()
needed_map = {}
for row in queue_rows:
mat = (row.get("spool_material") or row.get("file_material") or "").strip()
color = (row.get("spool_color") or row.get("file_color") or "").strip()
key = _group_key(mat, color)
try:
repeats = max(1, int(row.get("repeat_total") or 1) - int(row.get("repeat_done") or 0))
except (TypeError, ValueError):
repeats = 1
weight = float(row.get("weight_g") or 0) * repeats
if key not in needed_map:
needed_map[key] = {
"material_type": mat,
"color": color,
"weight_g": 0.0,
"jobs": 0,
}
needed_map[key]["weight_g"] += weight
needed_map[key]["jobs"] += 1
spool_rows = conn.execute(
"""
SELECT material_type, color, COALESCE(SUM(remaining_g), 0) AS remaining_g
FROM spools
GROUP BY material_type, color
"""
).fetchall()
stock_map = {}
for row in spool_rows:
key = _group_key(row.get("material_type"), row.get("color"))
stock_map[key] = stock_map.get(key, 0.0) + float(row.get("remaining_g") or 0)
needed = sorted(
needed_map.values(),
key=lambda x: (-x["weight_g"], x["material_type"], x["color"]),
)
shortfall = []
for item in needed:
key = _group_key(item["material_type"], item["color"])
have = stock_map.get(key, 0.0)
gap = item["weight_g"] - have
if gap > 0.5:
shortfall.append({
**item,
"remaining_g": have,
"shortfall_g": gap,
})
shortfall.sort(key=lambda x: (-x["shortfall_g"], x["material_type"], x["color"]))
return {"needed": needed, "shortfall": shortfall}
+151 -10
View File
@@ -2,23 +2,61 @@
import json import json
import secrets import secrets
import threading
from pathlib import Path from pathlib import Path
from grotti.errors import GrottiError
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
LOOPBACK = {"127.0.0.1", "::1", "localhost"} LOOPBACK = {"127.0.0.1", "::1", "localhost"}
CONFIG_PATH = ROOT / "config.json" CONFIG_PATH = ROOT / "config.json"
DEFAULT_WATCH_REL = "slice"
_SETTINGS_LOCK = threading.Lock()
def _tariff(raw):
tariff = raw.get("tariff") if isinstance(raw, dict) else None
if not isinstance(tariff, dict) or not tariff:
return None
try:
ht = float(tariff.get("ht_euro"))
nt = float(tariff.get("nt_euro"))
except (TypeError, ValueError):
return None
return {
"ht_euro": ht,
"nt_euro": nt,
"nt_from": str(tariff.get("nt_from") or "22:00").strip(),
"nt_to": str(tariff.get("nt_to") or "06:00").strip(),
}
def _energy(raw): def _energy(raw):
energy = raw.get("energy") or {} energy = raw.get("energy") or {}
return { out = {
"watts": float(energy.get("watts", 120)), "watts": float(energy.get("watts", 40)),
"nozzle_watts": float(energy.get("nozzle_watts", 60)),
"bed_watts": float(energy.get("bed_watts", 220)),
"euro_per_kwh": float(energy.get("euro_per_kwh", 0.35)), "euro_per_kwh": float(energy.get("euro_per_kwh", 0.35)),
"dryer_watts": float(energy.get("dryer_watts", 0)), "dryer_watts": float(energy.get("dryer_watts", 0)),
"dry_hours": float(energy.get("dry_hours", 0)), "dry_hours": float(energy.get("dry_hours", 0)),
"waste_percent": float(energy.get("waste_percent", 0)), "waste_percent": float(energy.get("waste_percent", 0)),
"utilization": float(energy.get("utilization", 50)), "utilization": float(energy.get("utilization", 50)),
} }
tariff = _tariff(energy)
if tariff:
out["tariff"] = tariff
return out
def _ntfy(raw):
ntfy = raw.get("ntfy") or {}
return {
"enabled": bool(ntfy.get("enabled")),
"server": str(ntfy.get("server") or "https://ntfy.sh").rstrip("/"),
"topic": str(ntfy.get("topic") or ""),
"token": str(ntfy.get("token") or ""),
}
def _telegram(raw): def _telegram(raw):
@@ -37,6 +75,7 @@ def _alerts(raw):
"fehler": bool(alerts.get("fehler", True)), "fehler": bool(alerts.get("fehler", True)),
"offline": bool(alerts.get("offline", True)), "offline": bool(alerts.get("offline", True)),
"queue": bool(alerts.get("queue", True)), "queue": bool(alerts.get("queue", True)),
"spule": bool(alerts.get("spule", True)),
"ablauf": bool(alerts.get("ablauf", False)), "ablauf": bool(alerts.get("ablauf", False)),
"start": bool(alerts.get("start", False)), "start": bool(alerts.get("start", False)),
} }
@@ -99,10 +138,7 @@ def load():
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
watch = (raw.get("watch_dir") or "").strip() watch_dir, _stored = parse_watch_dir(raw.get("watch_dir") or "", create=False, missing_ok=True)
watch_dir = Path(watch) if watch else None
if watch_dir and not watch_dir.is_absolute():
watch_dir = (ROOT / watch_dir).resolve()
return { return {
"host": host, "host": host,
"port": int(raw.get("port") or 8770), "port": int(raw.get("port") or 8770),
@@ -116,6 +152,7 @@ def load():
"printers_file": ROOT / "drucker.json", "printers_file": ROOT / "drucker.json",
"energy": _energy(raw), "energy": _energy(raw),
"telegram": _telegram(raw), "telegram": _telegram(raw),
"ntfy": _ntfy(raw),
"alerts": _alerts(raw), "alerts": _alerts(raw),
"auto_queue": bool(raw.get("auto_queue", True)), "auto_queue": bool(raw.get("auto_queue", True)),
"_raw": raw, "_raw": raw,
@@ -130,11 +167,48 @@ def mask_token(token):
return token[:4] + "…" + token[-2:] return token[:4] + "…" + token[-2:]
def parse_watch_dir(value, *, create=False, missing_ok=False):
"""Pfad aus Einstellungen/Config → (Path|None, gespeicherter String).
Relativ zum Repo-Root. Leer = aus. create legt den Ordner an.
"""
text = str(value or "").strip()
if not text:
return None, ""
path = Path(text)
if not path.is_absolute():
path = (ROOT / path).resolve()
else:
path = path.resolve()
if path.exists() and not path.is_dir():
raise GrottiError("Der Slice-Ordner ist keine Ordner.", "watch")
if create:
try:
path.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise GrottiError("Slice-Ordner konnte nicht angelegt werden: %s" % exc, "watch") from exc
elif not path.is_dir():
if missing_ok:
return None, text
raise GrottiError("Slice-Ordner fehlt: %s" % path, "watch")
try:
stored = path.relative_to(ROOT.resolve()).as_posix()
except ValueError:
stored = str(path)
return path, stored
def default_watch_suggestion():
"""Absoluter Standardpfad (Repo/slice) für die Einstellungen."""
return str((ROOT / DEFAULT_WATCH_REL).resolve())
def settings_public(cfg): def settings_public(cfg):
tg = cfg.get("telegram") or {} tg = cfg.get("telegram") or {}
oauth = cfg.get("oauth") or {} oauth = cfg.get("oauth") or {}
return { return {
"watch_dir": str(cfg.get("watch_dir") or ""), "watch_dir": str(cfg.get("watch_dir") or ""),
"watch_dir_default": default_watch_suggestion(),
"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")), "legacy_token_set": bool(cfg.get("token")),
@@ -145,6 +219,13 @@ def settings_public(cfg):
"bot_token_set": bool(tg.get("bot_token")), "bot_token_set": bool(tg.get("bot_token")),
"chat_id": str(tg.get("chat_id") or ""), "chat_id": str(tg.get("chat_id") or ""),
}, },
"ntfy": {
"enabled": bool((cfg.get("ntfy") or {}).get("enabled")),
"server": str((cfg.get("ntfy") or {}).get("server") or "https://ntfy.sh"),
"topic": str((cfg.get("ntfy") or {}).get("topic") or ""),
"token": mask_token((cfg.get("ntfy") or {}).get("token") or ""),
"token_set": bool((cfg.get("ntfy") or {}).get("token")),
},
"alerts": dict(cfg.get("alerts") or {}), "alerts": dict(cfg.get("alerts") or {}),
"oauth": { "oauth": {
"enabled": bool(oauth.get("enabled")), "enabled": bool(oauth.get("enabled")),
@@ -155,8 +236,40 @@ def settings_public(cfg):
} }
def save_settings(cfg, energy=None, telegram=None, alerts=None, auto_queue=None, fqdn=None): def save_settings(
"""Schreibt energy/telegram/alerts/auto_queue/fqdn zurück nach config.json und aktualisiert cfg.""" cfg,
energy=None,
telegram=None,
ntfy=None,
alerts=None,
auto_queue=None,
fqdn=None,
watch_dir=None,
):
"""Schreibt Einstellungen zurück nach config.json und aktualisiert cfg."""
with _SETTINGS_LOCK:
return _save_settings_locked(
cfg,
energy=energy,
telegram=telegram,
ntfy=ntfy,
alerts=alerts,
auto_queue=auto_queue,
fqdn=fqdn,
watch_dir=watch_dir,
)
def _save_settings_locked(
cfg,
energy=None,
telegram=None,
ntfy=None,
alerts=None,
auto_queue=None,
fqdn=None,
watch_dir=None,
):
path = cfg.get("_path") or CONFIG_PATH path = cfg.get("_path") or CONFIG_PATH
raw = dict(cfg.get("_raw") or {}) raw = dict(cfg.get("_raw") or {})
if path.exists(): if path.exists():
@@ -167,19 +280,34 @@ def save_settings(cfg, energy=None, telegram=None, alerts=None, auto_queue=None,
if auto_queue is not None: if auto_queue is not None:
raw["auto_queue"] = bool(auto_queue) raw["auto_queue"] = bool(auto_queue)
cfg["auto_queue"] = bool(auto_queue) cfg["auto_queue"] = bool(auto_queue)
if watch_dir is not None:
resolved, stored = parse_watch_dir(watch_dir, create=True, missing_ok=False)
raw["watch_dir"] = stored
cfg["watch_dir"] = resolved
if fqdn is not None: if fqdn is not None:
normalized = normalize_fqdn(fqdn) normalized = normalize_fqdn(fqdn)
raw["fqdn"] = normalized raw["fqdn"] = normalized
cfg["fqdn"] = normalized cfg["fqdn"] = normalized
if energy is not None: if energy is not None:
raw["energy"] = { raw_energy = {
"watts": float(energy.get("watts", 120)), "watts": float(energy.get("watts", 40)),
"nozzle_watts": float(energy.get("nozzle_watts", 60)),
"bed_watts": float(energy.get("bed_watts", 220)),
"euro_per_kwh": float(energy.get("euro_per_kwh", 0.35)), "euro_per_kwh": float(energy.get("euro_per_kwh", 0.35)),
"dryer_watts": float(energy.get("dryer_watts", 0)), "dryer_watts": float(energy.get("dryer_watts", 0)),
"dry_hours": float(energy.get("dry_hours", 0)), "dry_hours": float(energy.get("dry_hours", 0)),
"waste_percent": float(energy.get("waste_percent", 0)), "waste_percent": float(energy.get("waste_percent", 0)),
"utilization": float(energy.get("utilization", 50)), "utilization": float(energy.get("utilization", 50)),
} }
tariff_in = energy.get("tariff") if isinstance(energy.get("tariff"), dict) else None
if tariff_in is not None:
raw_energy["tariff"] = {
"ht_euro": float(tariff_in.get("ht_euro", raw_energy["euro_per_kwh"])),
"nt_euro": float(tariff_in.get("nt_euro", raw_energy["euro_per_kwh"])),
"nt_from": str(tariff_in.get("nt_from") or "22:00"),
"nt_to": str(tariff_in.get("nt_to") or "06:00"),
}
raw["energy"] = raw_energy
cfg["energy"] = _energy(raw) cfg["energy"] = _energy(raw)
if telegram is not None: if telegram is not None:
current = _telegram(raw) current = _telegram(raw)
@@ -192,12 +320,25 @@ def save_settings(cfg, energy=None, telegram=None, alerts=None, auto_queue=None,
"chat_id": str(telegram.get("chat_id", current["chat_id"]) or ""), "chat_id": str(telegram.get("chat_id", current["chat_id"]) or ""),
} }
cfg["telegram"] = _telegram(raw) cfg["telegram"] = _telegram(raw)
if ntfy is not None:
current = _ntfy(raw)
token = ntfy.get("token")
if token is None or token == "" or "…" in str(token) or str(token).startswith("••••"):
token = current["token"]
raw["ntfy"] = {
"enabled": bool(ntfy.get("enabled", current["enabled"])),
"server": str(ntfy.get("server", current["server"]) or "https://ntfy.sh").rstrip("/"),
"topic": str(ntfy.get("topic", current["topic"]) or ""),
"token": str(token or ""),
}
cfg["ntfy"] = _ntfy(raw)
if alerts is not None: if alerts is not None:
raw["alerts"] = { raw["alerts"] = {
"fertig": bool(alerts.get("fertig", True)), "fertig": bool(alerts.get("fertig", True)),
"fehler": bool(alerts.get("fehler", True)), "fehler": bool(alerts.get("fehler", True)),
"offline": bool(alerts.get("offline", True)), "offline": bool(alerts.get("offline", True)),
"queue": bool(alerts.get("queue", True)), "queue": bool(alerts.get("queue", True)),
"spule": bool(alerts.get("spule", True)),
"ablauf": bool(alerts.get("ablauf", False)), "ablauf": bool(alerts.get("ablauf", False)),
"start": bool(alerts.get("start", False)), "start": bool(alerts.get("start", False)),
} }
+177 -2
View File
@@ -1,5 +1,9 @@
"""Dieselbe Rechnung wie in der Druckkalkulation.""" """Dieselbe Rechnung wie in der Druckkalkulation."""
from datetime import datetime, timedelta, timezone
from grotti.time_window import _in_segment, _parse_hm
def calculate(job, material): def calculate(job, material):
part_g = float(job.get("weight_g") or 0) * max(1, float(job.get("qty") or 1)) part_g = float(job.get("weight_g") or 0) * max(1, float(job.get("qty") or 1))
@@ -37,6 +41,177 @@ def calculate(job, material):
} }
def energy_cost(duration_s, energy): def _parse_ts(value):
if value is None:
return None
if isinstance(value, datetime):
return value.timestamp()
text = str(value).strip()
if not text:
return None
try:
return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp()
except ValueError:
return None
def _avg_power(a, b):
vals = [float(v) for v in (a, b) if isinstance(v, (int, float)) and not isinstance(v, bool)]
if not vals:
return 0.0
return max(0.0, min(1.0, sum(vals) / len(vals)))
def _ed_print_kwh(samples, base_w, nozzle_w, bed_w):
"""Trapez aus Grundlast + ED×Heizerleistung. None wenn zu wenig ED-Proben."""
if not samples or len(samples) < 2:
return None
total = 0.0
ed_steps = 0
# Max. Intervall: 3× Normal-Sample (90 s), damit Lücken nicht die ED fortschreiben
max_dt = 90.0
for i in range(len(samples) - 1):
a, b = samples[i], samples[i + 1]
t0 = _parse_ts(a.get("sampled_at"))
t1 = _parse_ts(b.get("sampled_at"))
if t0 is None or t1 is None or t1 <= t0:
continue
dt = min(t1 - t0, max_dt)
pn0, pn1 = a.get("nozzle_power"), b.get("nozzle_power")
pb0, pb1 = a.get("bed_power"), b.get("bed_power")
has_ed = any(
isinstance(v, (int, float)) and not isinstance(v, bool)
for v in (pn0, pn1, pb0, pb1)
)
if has_ed:
ed_steps += 1
ed_n = _avg_power(pn0, pn1)
ed_b = _avg_power(pb0, pb1)
watts = float(base_w) + float(nozzle_w) * ed_n + float(bed_w) * ed_b
total += (watts / 1000.0) * (dt / 3600.0)
if ed_steps < 1:
return None
return total
def _is_nt_moment(moment, tariff):
start_t = _parse_hm(tariff.get("nt_from"))
end_t = _parse_hm(tariff.get("nt_to"))
if start_t is None or end_t is None:
return False
now_t = moment.timetz().replace(tzinfo=None)
return _in_segment(now_t, start_t, end_t)
def effective_euro_per_kwh(duration_s, energy, end_dt=None):
energy = energy or {}
tariff = energy.get("tariff")
if not tariff or not duration_s or float(duration_s) <= 0:
return float(energy.get("euro_per_kwh") or 0)
end = end_dt if isinstance(end_dt, datetime) else datetime.now(timezone.utc).astimezone()
if end.tzinfo is None:
end = end.replace(tzinfo=timezone.utc)
start = end - timedelta(seconds=float(duration_s))
step = 300.0
t = start
nt_s = 0.0
total = 0.0
while t < end:
chunk = min(step, (end - t).total_seconds())
if _is_nt_moment(t, tariff):
nt_s += chunk
total += chunk
t += timedelta(seconds=chunk)
if total <= 0:
return float(energy.get("euro_per_kwh") or 0)
nt_frac = nt_s / total
ht = float(tariff.get("ht_euro") or energy.get("euro_per_kwh") or 0)
nt = float(tariff.get("nt_euro") or ht)
return nt_frac * nt + (1.0 - nt_frac) * ht
def energy_estimate(duration_s, energy, samples=None, end_dt=None):
"""Strom aus ED-Proben oder pauschal Dauer × Watt.
Quelle ``ed`` nur wenn Düse-/Bett-Watt gesetzt und Proben mit power vorliegen.
"""
energy = energy or {}
euro = effective_euro_per_kwh(duration_s, energy, end_dt=end_dt)
dry_kwh = (float(energy.get("dryer_watts") or 0) / 1000) * float(energy.get("dry_hours") or 0)
base_w = float(energy.get("watts") or 0)
nozzle_w = float(energy.get("nozzle_watts") or 0)
bed_w = float(energy.get("bed_watts") or 0)
print_kwh = None
source = "flat"
if (nozzle_w > 0 or bed_w > 0) and samples:
print_kwh = _ed_print_kwh(samples, base_w, nozzle_w, bed_w)
if print_kwh is not None:
source = "ed"
if print_kwh is None:
hours = float(duration_s or 0) / 3600 hours = float(duration_s or 0) / 3600
return (float(energy["watts"]) / 1000) * hours * float(energy["euro_per_kwh"]) print_kwh = (base_w / 1000) * hours
source = "flat"
kwh = print_kwh + dry_kwh
return {
"kwh": kwh,
"print_kwh": print_kwh,
"dry_kwh": dry_kwh,
"cost": kwh * euro,
"source": source,
}
def energy_cost(duration_s, energy, samples=None, end_dt=None):
return energy_estimate(duration_s, energy, samples, end_dt=end_dt)["cost"]
def offer_from_file(file_row, material_price, machine_rate_per_h, setup_min, margin):
"""Angebots-Schätzung aus Datei-Gewicht und Druckzeit."""
weight_g = float((file_row or {}).get("weight_g") or 0)
est_s = float((file_row or {}).get("estimated_s") or 0)
hours = est_s / 3600.0 if est_s > 0 else 0.0
mat = float(material_price or 0)
rate = float(machine_rate_per_h or 0)
setup_h = float(setup_min or 0) / 60.0
material_cost = (weight_g / 1000.0) * mat
machine_cost = hours * rate + setup_h * rate
subtotal = material_cost + machine_cost
pct = float(margin or 0)
total = subtotal * (1.0 + pct / 100.0) if subtotal > 0 else 0.0
return {
"weight_g": weight_g,
"hours": hours,
"material_cost": material_cost,
"machine_cost": machine_cost,
"subtotal": subtotal,
"margin_percent": pct,
"total": total,
}
def order_actuals(conn, order_id):
"""Summe Kosten, Zeit und Ausschuss der verknüpften Läufe."""
row = conn.execute(
"""
SELECT
COALESCE(SUM(r.filament_cost + r.energy_cost), 0) AS cost_total,
COALESCE(SUM(r.duration_s), 0) AS duration_s,
COALESCE(SUM(r.pieces_bad), 0) AS pieces_bad,
COALESCE(SUM(r.weight_g) FILTER (WHERE r.verdict = 'ausschuss'), 0) AS scrap_g,
COUNT(*) FILTER (WHERE r.verdict = 'ausschuss') AS scrap_runs
FROM order_items oi
JOIN runs r ON r.order_item_id = oi.id
WHERE oi.order_id = %s
""",
(order_id,),
).fetchone()
return {
"cost_total": float(row["cost_total"] or 0),
"duration_s": float(row["duration_s"] or 0),
"pieces_bad": int(row["pieces_bad"] or 0),
"scrap_g": float(row["scrap_g"] or 0),
"scrap_runs": int(row["scrap_runs"] or 0),
}
+174 -13
View File
@@ -1,6 +1,9 @@
"""PostgreSQL-Verbindung und Schema.""" """PostgreSQL-Verbindung, Pool und Migrationen."""
from __future__ import annotations
import re import re
import threading
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse, urlunparse from urllib.parse import urlparse, urlunparse
@@ -8,22 +11,105 @@ from urllib.parse import urlparse, urlunparse
import psycopg import psycopg
from psycopg import sql from psycopg import sql
from psycopg.rows import dict_row from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
SCHEMA = (Path(__file__).parent / "schema.sql").read_text(encoding="utf-8") MIGRATIONS_DIR = Path(__file__).parent / "migrations"
SCHEMA_LEGACY = Path(__file__).parent / "schema.sql"
_pool: ConnectionPool | None = None
_pool_lock = threading.Lock()
_user_count_cache = {"n": None, "at": 0.0}
_USER_COUNT_TTL = 5.0
def database_name(url): def database_name(url):
name = urlparse(url).path.lstrip("/") name = urlparse(url).path.lstrip("/")
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name or ""): if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name or ""):
raise SystemExit("Der Datenbankname in database_url darf nur Buchstaben, Zahlen und Unterstrich enthalten.") raise SystemExit(
"Der Datenbankname in database_url darf nur Buchstaben, Zahlen und Unterstrich enthalten."
)
return name return name
def apply_schema(conn): def _migration_files():
for statement in SCHEMA.split(";"): files = sorted(MIGRATIONS_DIR.glob("*.sql"))
out = []
for path in files:
match = re.match(r"^(\d+)_", path.name)
if not match:
continue
out.append((int(match.group(1)), path))
return out
def _split_statements(text):
"""Einfache Trennung an Semikolon — Migrationen ohne Funktionen mit ; im Körper."""
parts = []
for statement in text.split(";"):
cleaned = statement.strip() cleaned = statement.strip()
if cleaned: if cleaned:
conn.execute(cleaned) parts.append(cleaned)
return parts
def _ensure_version_table(conn):
conn.execute(
"""
CREATE TABLE IF NOT EXISTS schema_version (
version integer PRIMARY KEY,
name text NOT NULL,
applied_at timestamptz NOT NULL DEFAULT now()
)
"""
)
def _applied_versions(conn):
rows = conn.execute("SELECT version FROM schema_version ORDER BY version").fetchall()
return {int(row["version"] if isinstance(row, dict) else row[0]) for row in rows}
def _legacy_schema_present(conn):
row = conn.execute(
"""
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'printers'
"""
).fetchone()
return bool(row)
def apply_migrations(conn):
"""Wendet fehlende Migrationen an. Bestehende Installationen ohne Version bekommen 001 gutgeschrieben."""
_ensure_version_table(conn)
applied = _applied_versions(conn)
migrations = _migration_files()
if not migrations and SCHEMA_LEGACY.exists():
# Fallback: altes schema.sql einmalig
for statement in _split_statements(SCHEMA_LEGACY.read_text(encoding="utf-8")):
conn.execute(statement)
return
if not applied and _legacy_schema_present(conn):
# Schema war schon da (vor Migrationssystem) → Basis als angewandt markieren.
base = next((m for m in migrations if m[0] == 1), None)
if base:
conn.execute(
"INSERT INTO schema_version (version, name) VALUES (%s, %s) ON CONFLICT DO NOTHING",
(1, base[1].name),
)
applied.add(1)
for version, path in migrations:
if version in applied:
continue
text = path.read_text(encoding="utf-8")
for statement in _split_statements(text):
conn.execute(statement)
conn.execute(
"INSERT INTO schema_version (version, name) VALUES (%s, %s)",
(version, path.name),
)
def prepare(url): def prepare(url):
@@ -31,14 +117,16 @@ def prepare(url):
admin = urlunparse(urlparse(url)._replace(path="/postgres")) admin = urlunparse(urlparse(url)._replace(path="/postgres"))
try: try:
with psycopg.connect(admin, autocommit=True, connect_timeout=5) as conn: with psycopg.connect(admin, autocommit=True, connect_timeout=5) as conn:
exists = conn.execute("SELECT 1 FROM pg_database WHERE datname = %s", (name,)).fetchone() exists = conn.execute(
"SELECT 1 FROM pg_database WHERE datname = %s", (name,)
).fetchone()
if not exists: if not exists:
conn.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(name))) conn.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(name)))
except psycopg.Error: except psycopg.Error as exc:
pass print("Datenbank anlegen: %s" % exc, flush=True)
try: try:
with psycopg.connect(url, connect_timeout=5) as conn: with psycopg.connect(url, row_factory=dict_row, connect_timeout=5) as conn:
apply_schema(conn) apply_migrations(conn)
conn.commit() conn.commit()
except psycopg.Error as exc: except psycopg.Error as exc:
raise SystemExit( raise SystemExit(
@@ -50,9 +138,50 @@ def prepare(url):
) from exc ) from exc
def init_pool(url, min_size=2, max_size=16):
global _pool
with _pool_lock:
if _pool is not None:
return _pool
_pool = ConnectionPool(
conninfo=url,
min_size=min_size,
max_size=max_size,
kwargs={"row_factory": dict_row, "connect_timeout": 5},
open=True,
name="grotti",
)
return _pool
def close_pool():
global _pool
with _pool_lock:
if _pool is not None:
_pool.close()
_pool = None
def get_pool():
if _pool is None:
raise RuntimeError("Datenbank-Pool ist nicht gestartet.")
return _pool
@contextmanager @contextmanager
def connect(url): def connect(url=None):
conn = psycopg.connect(url, row_factory=dict_row) """Verbindung aus dem Pool, sonst einmalig direkt (Tests / prepare)."""
if _pool is not None:
with _pool.connection() as conn:
try:
yield conn
except Exception:
conn.rollback()
raise
return
if not url:
raise RuntimeError("Keine database_url und kein Pool.")
conn = psycopg.connect(url, row_factory=dict_row, connect_timeout=5)
try: try:
yield conn yield conn
except Exception: except Exception:
@@ -60,3 +189,35 @@ def connect(url):
raise raise
finally: finally:
conn.close() conn.close()
def cached_user_count(conn):
"""Zählt Benutzer mit kurzem Cache — vermeidet count(*) bei jedem Request."""
import time
now = time.monotonic()
cached = _user_count_cache["n"]
if cached is not None and (now - _user_count_cache["at"]) < _USER_COUNT_TTL:
return cached
n = int(conn.execute("SELECT COUNT(*) AS n FROM users").fetchone()["n"])
_user_count_cache["n"] = n
_user_count_cache["at"] = now
return n
def invalidate_user_count():
_user_count_cache["n"] = None
_user_count_cache["at"] = 0.0
def prune_run_samples(conn, keep_days=90):
"""Alte Temperaturproben löschen (Retention)."""
days = max(7, int(keep_days))
row = conn.execute(
"""
DELETE FROM run_samples
WHERE sampled_at < now() - (%s || ' days')::interval
""",
(str(days),),
)
return row.rowcount if row is not None else 0
+2 -101
View File
@@ -55,8 +55,8 @@ DEFAULT_SWEEP = {
# Ein Punkt in der Bettmitte — kein LeviQ, kein 3×3, kein Heizen. # Ein Punkt in der Bettmitte — kein LeviQ, kein 3×3, kein Heizen.
DEFAULT_CHECK = { DEFAULT_CHECK = {
"type": "check", "type": "check",
# Result is z= gegen Grenze. Temporär 3 mm — kalter Kunststoff an der Düse # Result is z= gegen Grenze. 3 mm: kalter Kunststoff an der Düse
# tastet sonst oft 1–2 mm und meldet Restteil. Später wieder straffen. # tastet sonst oft 1–2 mm und meldet Restteil.
"max_delta_mm": 3.0, "max_delta_mm": 3.0,
"timeout_min": 2, "timeout_min": 2,
"x": 125.0, "x": 125.0,
@@ -454,11 +454,6 @@ def sweep_lower_to_push_script(sweep):
]) ])
def sweep_probe_height_script(sweep):
"""Kompatibel: hoch, hinten, langsam auf z_push."""
return sweep_go_back_high_script(sweep) + "\n" + sweep_lower_to_push_script(sweep)
def assert_clear_before_lower(live, sweep, min_z_mm=15.0): def assert_clear_before_lower(live, sweep, min_z_mm=15.0):
"""Vor dem Senken: Toolhead muss noch klar über dem Bett stehen.""" """Vor dem Senken: Toolhead muss noch klar über dem Bett stehen."""
pos = (live or {}).get("position") or [] pos = (live or {}).get("position") or []
@@ -562,100 +557,6 @@ def check_lift_script(check):
]) ])
def _axis_coords(lo, hi, count):
if count <= 1:
return [(lo + hi) / 2.0]
return [lo + (hi - lo) * i / (count - 1) for i in range(count)]
def _matrix_ok(matrix):
if not isinstance(matrix, list) or not matrix:
return False
width = None
for row in matrix:
if not isinstance(row, list) or not row:
return False
if width is None:
width = len(row)
if len(row) != width:
return False
for value in row:
if not isinstance(value, (int, float)):
return False
return True
def interpolate_mesh_z(mesh, x, y):
"""Bilinear aus Roh-probed_matrix. Klipper: Zeile 0 = mesh_min Y."""
matrix = mesh.get("matrix") if isinstance(mesh, dict) else None
if not _matrix_ok(matrix):
return None
mesh_min = mesh.get("mesh_min") or [0, 0]
mesh_max = mesh.get("mesh_max") or [0, 0]
try:
x0, y0 = float(mesh_min[0]), float(mesh_min[1])
x1, y1 = float(mesh_max[0]), float(mesh_max[1])
except (TypeError, ValueError, IndexError):
return None
rows = len(matrix)
cols = len(matrix[0])
if cols < 1 or rows < 1:
return None
if abs(x1 - x0) < 1e-9 or abs(y1 - y0) < 1e-9:
return float(matrix[0][0])
tx = 0.0 if cols == 1 else (float(x) - x0) / (x1 - x0) * (cols - 1)
ty = 0.0 if rows == 1 else (float(y) - y0) / (y1 - y0) * (rows - 1)
tx = max(0.0, min(float(cols - 1), tx))
ty = max(0.0, min(float(rows - 1), ty))
c0 = int(tx)
r0 = int(ty)
c1 = min(cols - 1, c0 + 1)
r1 = min(rows - 1, r0 + 1)
fx = tx - c0
fy = ty - r0
z00 = float(matrix[r0][c0])
z10 = float(matrix[r0][c1])
z01 = float(matrix[r1][c0])
z11 = float(matrix[r1][c1])
z0 = z00 * (1 - fx) + z10 * fx
z1 = z01 * (1 - fx) + z11 * fx
return z0 * (1 - fy) + z1 * fy
def mesh_delta_mm(baseline, measured):
"""Max |Δ| und Mittel |Δ|: Messpunkte gegen interpoliertes Baseline-Netz."""
if not isinstance(baseline, dict) or not _matrix_ok(baseline.get("matrix")):
raise GrottiError("Kein Bettnetz zum Vergleich — vorher nivellieren.", "mesh")
if not isinstance(measured, dict) or not _matrix_ok(measured.get("matrix")):
raise GrottiError("Neues Bettnetz fehlt nach dem Antasten.", "mesh")
matrix = measured["matrix"]
mesh_min = measured.get("mesh_min") or [0, 0]
mesh_max = measured.get("mesh_max") or [0, 0]
try:
x0, y0 = float(mesh_min[0]), float(mesh_min[1])
x1, y1 = float(mesh_max[0]), float(mesh_max[1])
except (TypeError, ValueError, IndexError) as exc:
raise GrottiError("Messnetz ohne gültigen Bereich.", "mesh") from exc
rows = len(matrix)
cols = len(matrix[0])
xs = _axis_coords(x0, x1, cols)
ys = _axis_coords(y0, y1, rows)
deltas = []
for ri, y in enumerate(ys):
for ci, x in enumerate(xs):
expected = interpolate_mesh_z(baseline, x, y)
if expected is None:
raise GrottiError("Baseline-Netz lässt sich nicht interpolieren.", "mesh")
deltas.append(abs(float(matrix[ri][ci]) - expected))
if not deltas:
raise GrottiError("Keine Messpunkte zum Vergleich.", "mesh")
return {
"max_delta_mm": max(deltas),
"mean_delta_mm": sum(deltas) / len(deltas),
"points": len(deltas),
}
def step_label(step): def step_label(step):
if not isinstance(step, dict): if not isinstance(step, dict):
return "" return ""
+42
View File
@@ -0,0 +1,42 @@
"""Thread-sicherer Pub/Sub für Server-Sent Events (Live-UI)."""
from __future__ import annotations
import threading
from queue import Empty, Queue
_lock = threading.Lock()
_subscribers: list[Queue] = []
def subscribe() -> Queue:
q: Queue = Queue(maxsize=256)
with _lock:
_subscribers.append(q)
return q
def unsubscribe(q: Queue) -> None:
with _lock:
try:
_subscribers.remove(q)
except ValueError:
pass
def publish(kind: str, payload: dict | None = None) -> None:
msg = {"kind": kind or "message", "payload": payload if payload is not None else {}}
with _lock:
targets = list(_subscribers)
for q in targets:
try:
q.put_nowait(msg)
except Exception:
pass
def wait_event(q: Queue, timeout_s: float) -> dict | None:
try:
return q.get(timeout=timeout_s)
except Empty:
return None
+38 -22
View File
@@ -43,14 +43,7 @@ def _targets_from_ace(conn, host, ace):
targets = [] targets = []
for item in ace: for item in ace:
slot = int(item["ams_index"]) + 1 slot = int(item["ams_index"]) + 1
row = conn.execute( row = store.spool_at_slot(conn, host, slot)
"""
SELECT * FROM spools
WHERE printer_host = %s AND ace_slot = %s
ORDER BY id LIMIT 1
""",
(host, slot),
).fetchone()
if row: if row:
targets.append(row) targets.append(row)
return targets return targets
@@ -62,12 +55,18 @@ def unix_to_dt(value):
return datetime.fromtimestamp(float(value), timezone.utc) return datetime.fromtimestamp(float(value), timezone.utc)
def choose_spool(conn, host, material, hinted_id): def choose_spool(conn, host, material, hinted_id, ended_at=None):
"""Spule für History-Abzug. Ohne Hint nur Rollen, die vor Druckende existierten."""
if hinted_id: if hinted_id:
row = store.get_spool_row(conn, hinted_id) row = store.get_spool_row(conn, hinted_id)
if row: if row and _spool_valid_for_end(row, ended_at):
return row return row
rows = store.spools_for_printer(conn, host) if row and not _spool_valid_for_end(row, ended_at):
return None
rows = [
row for row in store.spools_for_printer(conn, host)
if _spool_valid_for_end(row, ended_at)
]
if len(rows) == 1: if len(rows) == 1:
return rows[0] return rows[0]
if material: if material:
@@ -77,6 +76,24 @@ def choose_spool(conn, host, material, hinted_id):
return None return None
def _spool_valid_for_end(spool, ended_at):
"""Verhindert Abzug alter Moonraker-Jobs auf neu eingelegte Spulen."""
if ended_at is None or not spool:
return True
created = spool.get("created_at")
if created is None:
return True
end = ended_at
if getattr(created, "tzinfo", None) is None and getattr(end, "tzinfo", None) is not None:
created = created.replace(tzinfo=timezone.utc)
if getattr(end, "tzinfo", None) is None and getattr(created, "tzinfo", None) is not None:
end = end.replace(tzinfo=timezone.utc)
try:
return created <= end
except TypeError:
return True
def file_material_types(file_row): def file_material_types(file_row):
types = [] types = []
for item in (file_row or {}).get("filaments") or []: for item in (file_row or {}).get("filaments") or []:
@@ -93,14 +110,7 @@ def resolve_spools_at_host(conn, host, ace_map=None):
if mapping: if mapping:
for item in mapping: for item in mapping:
slot = int(item["ams_index"]) + 1 slot = int(item["ams_index"]) + 1
row = conn.execute( row = store.spool_at_slot(conn, host, slot)
"""
SELECT * FROM spools
WHERE printer_host = %s AND ace_slot = %s
ORDER BY id LIMIT 1
""",
(host, slot),
).fetchone()
if row: if row:
found.append(row) found.append(row)
return found return found
@@ -270,7 +280,8 @@ def apply_deduction(conn, run, energy, metadata=None):
return False return False
used_total = sum(item["weight_g"] for item in entries) used_total = sum(item["weight_g"] for item in entries)
filament = sum(item["cost"] for item in entries) filament = sum(item["cost"] for item in entries)
power = energy_cost(run["duration_s"], energy) samples = store.list_run_samples(conn, run["id"])
power = energy_cost(run["duration_s"], energy, samples)
# run_spools vor deducted=true, sonst bleibt die Akte leer bei Abbruch dazwischen # run_spools vor deducted=true, sonst bleibt die Akte leer bei Abbruch dazwischen
store.replace_run_spools(conn, run["id"], entries) store.replace_run_spools(conn, run["id"], entries)
marked = store.mark_deducted( marked = store.mark_deducted(
@@ -328,7 +339,12 @@ def sync_history(conn, cfg):
file_id = (sent or {}).get("file_id") or (known or {}).get("id") file_id = (sent or {}).get("file_id") or (known or {}).get("id")
project_id = (sent or {}).get("project_id") or (known or {}).get("project_id") project_id = (sent or {}).get("project_id") or (known or {}).get("project_id")
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) ended_at = unix_to_dt(job.get("end_time"))
# Ohne Send-Eintrag keine Rate — sonst zieht eine neue Spule alle alten Jobs ab.
if not sent and not hinted:
spool = None
else:
spool = choose_spool(conn, printer["host"], material, hinted, ended_at=ended_at)
terminal = state in TERMINAL terminal = state in TERMINAL
live = store.find_live_run(conn, printer["host"], filename) live = store.find_live_run(conn, printer["host"], filename)
progress = float((live or {}).get("progress_last") or 0) progress = float((live or {}).get("progress_last") or 0)
@@ -502,7 +518,7 @@ def send_file(
progress("pruefen", 15, "Drucker prüfen…") progress("pruefen", 15, "Drucker prüfen…")
try: try:
live = moonraker.query(host) live = moonraker.query(host)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError) as exc: except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, OSError, ValueError) as exc:
raise GrottiError("Der Drucker ist nicht erreichbar.", "offline") from exc raise GrottiError("Der Drucker ist nicht erreichbar.", "offline") from exc
if live.get("state") in ("printing", "paused"): if live.get("state") in ("printing", "paused"):
raise GrottiError("Der Drucker druckt schon.", "belegt", 409) raise GrottiError("Der Drucker druckt schon.", "belegt", 409)
+141 -2
View File
@@ -1,5 +1,7 @@
"""Watch-Ordner, Upload und Vorschaubild.""" """Watch-Ordner, Upload und Vorschaubild."""
import re
import shutil
import time import time
import zipfile import zipfile
from pathlib import Path from pathlib import Path
@@ -10,6 +12,8 @@ from grotti.parse import embedded_png, inspect_file, safe_zip_name
_LAST_WATCH_SYNC = 0.0 _LAST_WATCH_SYNC = 0.0
_WATCH_MIN_INTERVAL_S = 20 _WATCH_MIN_INTERVAL_S = 20
_FOLDER_PART = re.compile(r"^[A-Za-z0-9._\- äöüÄÖÜß]+$")
_MAX_FOLDER_DEPTH = 6
def skip_path(path, data_dir): def skip_path(path, data_dir):
@@ -36,7 +40,7 @@ def sync_watch(conn, cfg, force=False):
global _LAST_WATCH_SYNC global _LAST_WATCH_SYNC
folder = cfg["watch_dir"] folder = cfg["watch_dir"]
if folder is None: if folder is None:
return {"gelesen": 0, "hinweis": "In config.json watch_dir auf den Slice-Ordner setzen."} return {"gelesen": 0, "hinweis": "Unter Einstellungen den Slice-Ordner setzen."}
if not folder.exists(): if not folder.exists():
return {"gelesen": 0, "hinweis": "Der Watch-Ordner fehlt: %s" % folder} return {"gelesen": 0, "hinweis": "Der Watch-Ordner fehlt: %s" % folder}
now = time.monotonic() now = time.monotonic()
@@ -96,7 +100,10 @@ def refresh_file_meta(conn, force=False):
def safe_filename(name): def safe_filename(name):
base = Path(name).name.replace("\\", "_").replace("/", "_").replace('"', "") base = Path(name).name.replace("\\", "_").replace("/", "_").replace('"', "")
return base or "datei.gcode" base = base.strip().strip(".")
if not base or base in (".", ".."):
return "datei.gcode"
return base
def unique_path(directory, filename): def unique_path(directory, filename):
@@ -113,6 +120,138 @@ def unique_path(directory, filename):
return candidate return candidate
def require_watch_dir(cfg):
folder = cfg.get("watch_dir")
if not folder:
raise GrottiError("Kein Slice-Ordner gesetzt — unter Einstellungen festlegen.", "watch")
root = Path(folder).resolve()
if not root.is_dir():
raise GrottiError("Der Slice-Ordner fehlt: %s" % root, "watch")
return root
def normalize_folder(rel):
"""Relativpfad unter watch_dir; leer = Wurzel. Kein .., begrenzte Tiefe."""
text = str(rel or "").strip().replace("\\", "/").strip("/")
if not text:
return ""
parts = []
for part in text.split("/"):
part = part.strip()
if not part or part in (".", ".."):
raise GrottiError("Ungültiger Ordnername.", "ordner")
if not _FOLDER_PART.match(part):
raise GrottiError(
"Ordnername nur Buchstaben, Ziffern, Leerzeichen, . _ -.",
"ordner",
)
parts.append(part)
if len(parts) > _MAX_FOLDER_DEPTH:
raise GrottiError("Ordner zu tief verschachtelt (max. %s)." % _MAX_FOLDER_DEPTH, "ordner")
return "/".join(parts)
def path_under_watch(watch_dir, rel_folder, name=None):
"""Absoluter Pfad unter watch_dir; wirft bei Escape."""
root = Path(watch_dir).resolve()
folder = normalize_folder(rel_folder)
target = root if not folder else (root / folder)
if name:
target = target / safe_filename(name)
resolved = target.resolve()
if resolved != root and root not in resolved.parents:
raise GrottiError("Pfad außerhalb des Slice-Ordners.", "ordner")
return resolved
def list_folders(cfg):
"""Unterordner unter watch_dir (auch leer), relativ, sortiert."""
try:
root = require_watch_dir(cfg)
except GrottiError:
return []
data_dir = cfg.get("data_dir")
found = set()
for path in root.rglob("*"):
if not path.is_dir():
continue
if data_dir is not None and skip_path(path, data_dir):
continue
try:
rel = path.resolve().relative_to(root).as_posix()
except ValueError:
continue
if rel and rel != ".":
found.add(rel)
return sorted(found, key=lambda s: s.lower())
def create_folder(cfg, rel):
root = require_watch_dir(cfg)
folder = normalize_folder(rel)
if not folder:
raise GrottiError("Ordnername fehlt.", "ordner")
target = path_under_watch(root, folder)
target.mkdir(parents=True, exist_ok=True)
return folder
def delete_folder(cfg, rel):
root = require_watch_dir(cfg)
folder = normalize_folder(rel)
if not folder:
raise GrottiError("Die Wurzel lässt sich nicht löschen.", "ordner")
target = path_under_watch(root, folder)
if not target.is_dir():
raise GrottiError("Der Ordner fehlt.", "ordner", 404)
try:
next(target.iterdir())
raise GrottiError("Ordner ist nicht leer.", "ordner")
except StopIteration:
pass
target.rmdir()
return folder
def move_file(conn, cfg, file_id, folder):
"""Watch-Datei in Unterordner (oder Wurzel) verschieben; ID bleibt."""
root = require_watch_dir(cfg)
folder = normalize_folder(folder)
row = store.get_file_row(conn, file_id)
if not row:
raise GrottiError("Die Datei fehlt.", "datei", 404)
if row.get("source") != "watch":
raise GrottiError(
"Nur Dateien aus dem Slice-Ordner lassen sich verschieben.",
"watch",
)
src = Path(row.get("stored_path") or "")
if not src.is_file():
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
dest_dir = path_under_watch(root, folder)
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / src.name
if dest.resolve() == src.resolve():
return store.file_out(row, watch_dir=root)
if dest.exists():
dest = unique_path(dest_dir, src.name)
try:
shutil.move(str(src), str(dest))
except OSError as exc:
raise GrottiError("Verschieben fehlgeschlagen: %s" % exc, "ordner") from exc
stat = dest.stat()
store.update_file_location(
conn,
file_id,
str(dest.resolve()),
name=dest.name,
size_bytes=stat.st_size,
mtime=stat.st_mtime,
)
row = store.get_file_row(conn, file_id)
return store.file_out(row, watch_dir=root)
def save_upload(conn, filename, content, cfg, project_id=None): def save_upload(conn, filename, content, cfg, project_id=None):
directory = cfg["data_dir"] / "eingang" directory = cfg["data_dir"] / "eingang"
directory.mkdir(parents=True, exist_ok=True) directory.mkdir(parents=True, exist_ok=True)
+156
View File
@@ -0,0 +1,156 @@
"""Wartungs-Teile und Laufzähler aus dem Protokoll."""
from __future__ import annotations
def _baseline(conn, host, part_key):
row = conn.execute(
"""
SELECT hours_at, meters_at FROM maintenance_log
WHERE printer_host = %s AND part_key = %s AND event IN ('install', 'reset')
ORDER BY created_at DESC, id DESC
LIMIT 1
""",
(host, part_key),
).fetchone()
if not row:
return 0.0, 0.0
return float(row.get("hours_at") or 0), float(row.get("meters_at") or 0)
def counters_for_host(conn, host):
"""Summe Druckzeit, Filament und Anzahl Läufe für einen Host."""
host = (host or "").strip()
if not host:
return {"hours": 0.0, "meters": 0.0, "runs": 0}
row = conn.execute(
"""
SELECT
COALESCE(SUM(duration_s), 0) AS duration_s,
COALESCE(SUM(filament_used_mm), 0) AS meters,
COUNT(*) AS runs
FROM runs
WHERE printer_host = %s AND state = 'completed'
""",
(host,),
).fetchone()
if not row:
return {"hours": 0.0, "meters": 0.0, "runs": 0}
return {
"hours": float(row.get("duration_s") or 0) / 3600.0,
"meters": float(row.get("meters") or 0),
"runs": int(row.get("runs") or 0),
}
def part_out(row, counters=None, conn=None):
counters = counters or {}
life_h = float(row.get("life_hours") or 0)
life_m = float(row.get("life_meters") or 0)
hours_total = float(counters.get("hours") or 0)
meters_total = float(counters.get("meters") or 0)
host = row["printer_host"]
part_key = row["part_key"]
base_h, base_m = (0.0, 0.0)
if conn is not None:
base_h, base_m = _baseline(conn, host, part_key)
hours_since = max(0.0, hours_total - base_h)
meters_since = max(0.0, meters_total - base_m)
installed = row.get("installed_at")
return {
"id": row["id"],
"printer_host": host,
"part_key": part_key,
"label": row.get("label") or "",
"installed_at": installed.isoformat() if hasattr(installed, "isoformat") else str(installed or ""),
"life_hours": life_h,
"life_meters": life_m,
"note": row.get("note") or "",
"hours_since": hours_since,
"meters_since": meters_since,
"overdue_hours": life_h > 0 and hours_since >= life_h,
"overdue_meters": life_m > 0 and meters_since >= life_m,
}
def list_for_api(conn, printers):
out = []
for printer in printers or []:
host = printer.get("host") or ""
counters = counters_for_host(conn, host)
rows = conn.execute(
"SELECT * FROM maintenance_parts WHERE printer_host = %s ORDER BY label, part_key, id",
(host,),
).fetchall()
out.append({
"host": host,
"name": printer.get("name") or host,
"counters": counters,
"parts": [part_out(r, counters, conn=conn) for r in rows],
})
return out
def create_part(conn, printer_host, part_key, label="", life_hours=0, life_meters=0, note=""):
printer_host = (printer_host or "").strip()
part_key = (part_key or "").strip()
if not printer_host or not part_key:
raise ValueError("Drucker und part_key nötig.")
row = conn.execute(
"""
INSERT INTO maintenance_parts (printer_host, part_key, label, life_hours, life_meters, note)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING *
""",
(
printer_host,
part_key,
(label or part_key).strip(),
float(life_hours or 0),
float(life_meters or 0),
(note or "").strip(),
),
).fetchone()
counters = counters_for_host(conn, printer_host)
conn.execute(
"""
INSERT INTO maintenance_log (printer_host, part_key, event, hours_at, meters_at, note)
VALUES (%s, %s, 'install', %s, %s, %s)
""",
(printer_host, part_key, counters["hours"], counters["meters"], (note or "").strip()),
)
return part_out(row, counters, conn=conn)
def reset_part(conn, part_id):
row = conn.execute(
"SELECT * FROM maintenance_parts WHERE id = %s",
(int(part_id),),
).fetchone()
if not row:
return None
host = row["printer_host"]
counters = counters_for_host(conn, host)
conn.execute(
"""
INSERT INTO maintenance_log (printer_host, part_key, event, hours_at, meters_at, note)
VALUES (%s, %s, 'reset', %s, %s, %s)
""",
(
host,
row["part_key"],
counters["hours"],
counters["meters"],
row.get("note") or "",
),
)
updated = conn.execute(
"""
UPDATE maintenance_parts
SET installed_at = now()
WHERE id = %s
RETURNING *
""",
(int(part_id),),
).fetchone()
fresh_counters = counters_for_host(conn, host)
return part_out(updated, fresh_counters, conn=conn)
+25 -4
View File
@@ -124,6 +124,27 @@ def main():
"""Spulen / Materialbestand.""" """Spulen / Materialbestand."""
return json.dumps(_api("GET", "/spulen"), ensure_ascii=False, indent=2) return json.dumps(_api("GET", "/spulen"), ensure_ascii=False, indent=2)
@server.tool()
def auftraege_lesen(limit: int = 30, status: str = "") -> str:
"""Aufträge (optional nach Status filtern)."""
st = (status or "").strip().lower()
q = "?status=" + urllib.parse.quote(st) if st else ""
rows = _api("GET", "/auftraege" + q) or []
cap = max(1, min(limit, 200))
if len(rows) > cap:
rows = rows[:cap]
return json.dumps(rows, ensure_ascii=False, indent=2)
@server.tool()
def bedarf_lesen() -> str:
"""Filament-Bedarf (Queue vs. Spulenrest)."""
return json.dumps(_api("GET", "/bedarf"), ensure_ascii=False, indent=2)
@server.tool()
def wartung_lesen() -> str:
"""Wartungsteile und Zählerstände."""
return json.dumps(_api("GET", "/wartung"), ensure_ascii=False, indent=2)
@server.tool() @server.tool()
def get_version() -> str: def get_version() -> str:
"""Grotti-Version.""" """Grotti-Version."""
@@ -220,11 +241,11 @@ def main():
out = [] out = []
if nozzle is not None: if nozzle is not None:
out.append(_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), { out.append(_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
"action": "nozzle", "value": nozzle, "action": "nozzle", "temp": nozzle,
})) }))
if bed is not None: if bed is not None:
out.append(_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), { out.append(_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
"action": "bed", "value": bed, "action": "bed", "temp": bed,
})) }))
if not out: if not out:
raise RuntimeError("nozzle und/oder bed angeben.") raise RuntimeError("nozzle und/oder bed angeben.")
@@ -236,7 +257,7 @@ def main():
_write_guard() _write_guard()
return json.dumps( return json.dumps(
_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), { _api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
"action": "speed", "value": speed, "action": "speed", "percent": speed,
}), }),
ensure_ascii=False, ensure_ascii=False,
) )
@@ -247,7 +268,7 @@ def main():
_write_guard() _write_guard()
return json.dumps( return json.dumps(
_api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), { _api("POST", "/drucker/%s/steuern" % urllib.parse.quote(host), {
"action": "fan", "value": fan, "action": "fan", "percent": fan,
}), }),
ensure_ascii=False, ensure_ascii=False,
) )
+305
View File
@@ -0,0 +1,305 @@
CREATE TABLE IF NOT EXISTS projects (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
notes text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'idee',
quote jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS spools (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
brand text NOT NULL DEFAULT '',
material_type text NOT NULL DEFAULT '',
color text NOT NULL DEFAULT '',
price_per_kg double precision NOT NULL DEFAULT 0,
density double precision NOT NULL DEFAULT 1.24,
volumetric double precision NOT NULL DEFAULT 12,
remaining_g double precision NOT NULL DEFAULT 1000,
printer_host text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS files (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
project_id bigint REFERENCES projects (id) ON DELETE SET NULL,
spool_id bigint REFERENCES spools (id) ON DELETE SET NULL,
name text NOT NULL,
stored_path text NOT NULL,
source text NOT NULL,
kind text NOT NULL,
printer_family text NOT NULL DEFAULT '',
filaments jsonb NOT NULL DEFAULT '[]'::jsonb,
weight_g double precision NOT NULL DEFAULT 0,
estimated_s double precision NOT NULL DEFAULT 0,
thumb_entry text NOT NULL DEFAULT '',
plate text NOT NULL DEFAULT '',
size_bytes bigint NOT NULL DEFAULT 0,
mtime double precision NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (source, stored_path)
);
ALTER TABLE files ADD COLUMN IF NOT EXISTS slice_meta jsonb NOT NULL DEFAULT '{}'::jsonb;
CREATE TABLE IF NOT EXISTS sends (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
printer_host text NOT NULL,
filename text NOT NULL,
file_id bigint REFERENCES files (id) ON DELETE SET NULL,
project_id bigint REFERENCES projects (id) ON DELETE SET NULL,
spool_id bigint REFERENCES spools (id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE sends ADD COLUMN IF NOT EXISTS ace_map jsonb NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE sends ADD COLUMN IF NOT EXISTS bed_leveling boolean NOT NULL DEFAULT false;
CREATE TABLE IF NOT EXISTS runs (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
printer_host text NOT NULL,
printer_name text NOT NULL DEFAULT '',
filename text NOT NULL DEFAULT '',
file_id bigint REFERENCES files (id) ON DELETE SET NULL,
project_id bigint REFERENCES projects (id) ON DELETE SET NULL,
spool_id bigint REFERENCES spools (id) ON DELETE SET NULL,
state text NOT NULL DEFAULT '',
started_at timestamptz,
ended_at timestamptz,
duration_s double precision NOT NULL DEFAULT 0,
weight_g double precision NOT NULL DEFAULT 0,
filament_cost double precision NOT NULL DEFAULT 0,
energy_cost double precision NOT NULL DEFAULT 0,
moonraker_job_id text NOT NULL,
deducted boolean NOT NULL DEFAULT false,
needs_spool boolean NOT NULL DEFAULT false,
verdict text NOT NULL DEFAULT '',
material_type text NOT NULL DEFAULT '',
UNIQUE (printer_host, moonraker_job_id)
);
CREATE INDEX IF NOT EXISTS files_project ON files (project_id);
CREATE INDEX IF NOT EXISTS runs_started ON runs (started_at DESC);
CREATE TABLE IF NOT EXISTS printers (
id uuid PRIMARY KEY,
name text NOT NULL,
host text NOT NULL UNIQUE,
family text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE printers ADD COLUMN IF NOT EXISTS locked boolean NOT NULL DEFAULT false;
CREATE TABLE IF NOT EXISTS ideas (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
body text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'offen',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS materials (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
brand text NOT NULL DEFAULT '',
material_type text NOT NULL DEFAULT '',
density double precision NOT NULL,
volumetric double precision NOT NULL,
volumetric_s1 double precision,
machines text NOT NULL DEFAULT 'beide',
price_per_kg double precision NOT NULL DEFAULT 0,
note text NOT NULL DEFAULT ''
);
ALTER TABLE spools ADD COLUMN IF NOT EXISTS colors jsonb NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE spools ADD COLUMN IF NOT EXISTS ace_slot smallint;
ALTER TABLE spools ADD COLUMN IF NOT EXISTS material_id bigint REFERENCES materials (id) ON DELETE SET NULL;
ALTER TABLE spools ADD COLUMN IF NOT EXISTS chargen_nr text;
ALTER TABLE spools ADD COLUMN IF NOT EXISTS hersteller_charge text NOT NULL DEFAULT '';
ALTER TABLE spools ADD COLUMN IF NOT EXISTS code text;
CREATE UNIQUE INDEX IF NOT EXISTS spools_chargen_nr ON spools (chargen_nr) WHERE chargen_nr IS NOT NULL AND chargen_nr <> '';
CREATE UNIQUE INDEX IF NOT EXISTS spools_code ON spools (code) WHERE code IS NOT NULL AND code <> '';
-- Ein ACE-Platz gehört höchstens einer Spule.
CREATE UNIQUE INDEX IF NOT EXISTS spools_host_slot
ON spools (printer_host, ace_slot)
WHERE printer_host <> '' AND ace_slot IS NOT NULL;
CREATE TABLE IF NOT EXISTS events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
host text NOT NULL DEFAULT '',
printer text NOT NULL DEFAULT '',
kind text NOT NULL,
message text NOT NULL DEFAULT '',
filename text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
read_at timestamptz
);
CREATE INDEX IF NOT EXISTS events_unread ON events (read_at, created_at DESC);
CREATE TABLE IF NOT EXISTS print_queue (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
file_id bigint NOT NULL REFERENCES files (id) ON DELETE CASCADE,
host text NOT NULL DEFAULT '',
family text NOT NULL DEFAULT '',
priority integer NOT NULL DEFAULT 100,
status text NOT NULL DEFAULT 'waiting',
spool_id bigint REFERENCES spools (id) ON DELETE SET NULL,
ace_map jsonb NOT NULL DEFAULT '[]'::jsonb,
bed_leveling boolean NOT NULL DEFAULT false,
override_machine boolean NOT NULL DEFAULT false,
message text NOT NULL DEFAULT '',
steps jsonb NOT NULL DEFAULT '[]'::jsonb,
step_index integer NOT NULL DEFAULT 0,
repeat_total integer NOT NULL DEFAULT 1,
repeat_done integer NOT NULL DEFAULT 0,
phase text NOT NULL DEFAULT '',
phase_started_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS steps jsonb NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS step_index integer NOT NULL DEFAULT 0;
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS repeat_total integer NOT NULL DEFAULT 1;
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS repeat_done integer NOT NULL DEFAULT 0;
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS phase text NOT NULL DEFAULT '';
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS phase_started_at timestamptz;
CREATE INDEX IF NOT EXISTS print_queue_waiting ON print_queue (status, priority, id);
CREATE INDEX IF NOT EXISTS print_queue_running_host ON print_queue (host, status) WHERE status = 'running';
-- Nach dem Druck: Platte manuell quittieren, bevor „wenn frei“ den nächsten Job startet.
CREATE TABLE IF NOT EXISTS bed_status (
host text PRIMARY KEY,
needs_clear boolean NOT NULL DEFAULT false,
cleared_at timestamptz,
message text NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username text NOT NULL UNIQUE,
password_hash text NOT NULL,
role text NOT NULL DEFAULT 'operator',
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
last_login timestamptz
);
CREATE TABLE IF NOT EXISTS api_tokens (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
name text NOT NULL DEFAULT '',
token_hash text NOT NULL UNIQUE,
token_prefix text NOT NULL DEFAULT '',
role text NOT NULL DEFAULT 'operator',
created_at timestamptz NOT NULL DEFAULT now(),
last_used timestamptz,
revoked_at timestamptz,
expires_at timestamptz
);
CREATE INDEX IF NOT EXISTS api_tokens_user ON api_tokens (user_id) WHERE revoked_at IS NULL;
-- OIDC später: Identitäten an lokale User binden.
CREATE TABLE IF NOT EXISTS oauth_identities (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
provider text NOT NULL,
subject text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (provider, subject)
);
-- Druckbericht: Soll/Ist, Charge, Live-Fortschritt
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_planned_g double precision NOT NULL DEFAULT 0;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_used_g double precision NOT NULL DEFAULT 0;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS weight_source text NOT NULL DEFAULT '';
ALTER TABLE runs ADD COLUMN IF NOT EXISTS filament_used_mm double precision NOT NULL DEFAULT 0;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS filament_total_mm double precision NOT NULL DEFAULT 0;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS progress_last double precision NOT NULL DEFAULT 0;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS estimated_s double precision NOT NULL DEFAULT 0;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS chargen_nr text;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS message text NOT NULL DEFAULT '';
ALTER TABLE runs ADD COLUMN IF NOT EXISTS objects jsonb NOT NULL DEFAULT '[]'::jsonb;
CREATE UNIQUE INDEX IF NOT EXISTS runs_chargen_nr
ON runs (chargen_nr) WHERE chargen_nr IS NOT NULL AND chargen_nr <> '';
ALTER TABLE events ADD COLUMN IF NOT EXISTS run_id bigint REFERENCES runs (id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS events_run ON events (run_id) WHERE run_id IS NOT NULL;
-- Abzug je Spule (Messwert oder Schätzwert)
CREATE TABLE IF NOT EXISTS run_spools (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
spool_id bigint NOT NULL REFERENCES spools (id) ON DELETE RESTRICT,
ace_slot smallint,
weight_g double precision NOT NULL DEFAULT 0,
weight_planned_g double precision NOT NULL DEFAULT 0,
cost double precision NOT NULL DEFAULT 0,
is_estimate boolean NOT NULL DEFAULT false,
UNIQUE (run_id, spool_id, ace_slot)
);
CREATE INDEX IF NOT EXISTS run_spools_spool ON run_spools (spool_id);
CREATE INDEX IF NOT EXISTS run_spools_run ON run_spools (run_id);
-- Abschnitte bei Rollenwechsel mitten im Druck
CREATE TABLE IF NOT EXISTS run_segments (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
spool_id bigint NOT NULL REFERENCES spools (id) ON DELETE RESTRICT,
ace_slot smallint,
mm_start double precision NOT NULL DEFAULT 0,
mm_end double precision,
created_at timestamptz NOT NULL DEFAULT now(),
closed_at timestamptz
);
CREATE INDEX IF NOT EXISTS run_segments_run ON run_segments (run_id);
CREATE INDEX IF NOT EXISTS run_segments_open
ON run_segments (run_id) WHERE mm_end IS NULL;
-- Temperatur / Fortschritt während des Drucks
CREATE TABLE IF NOT EXISTS run_samples (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
sampled_at timestamptz NOT NULL DEFAULT now(),
nozzle double precision,
nozzle_target double precision,
bed double precision,
bed_target double precision,
chamber double precision,
fan double precision,
speed_factor double precision,
extrude_factor double precision,
layer integer,
progress double precision,
filament_used_mm double precision,
mmu_tool smallint,
nozzle_power double precision,
bed_power double precision
);
CREATE INDEX IF NOT EXISTS run_samples_run_time ON run_samples (run_id, sampled_at);
ALTER TABLE run_samples ADD COLUMN IF NOT EXISTS nozzle_power double precision;
ALTER TABLE run_samples ADD COLUMN IF NOT EXISTS bed_power double precision;
-- 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);
+6
View File
@@ -0,0 +1,6 @@
-- Heiße Abfragepfade für Sends, Läufe, Dateien und Ereignisse.
CREATE INDEX IF NOT EXISTS sends_host_filename ON sends (printer_host, filename);
CREATE INDEX IF NOT EXISTS sends_host_file ON sends (printer_host, file_id);
CREATE INDEX IF NOT EXISTS runs_host_state ON runs (printer_host, state);
CREATE INDEX IF NOT EXISTS files_name ON files (name);
CREATE INDEX IF NOT EXISTS events_host_kind_created ON events (host, kind, created_at DESC);
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE runs ADD COLUMN IF NOT EXISTS verdict_reason text NOT NULL DEFAULT '';
ALTER TABLE runs ADD COLUMN IF NOT EXISTS pieces_ok int NOT NULL DEFAULT 0;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS pieces_bad int NOT NULL DEFAULT 0;
+25
View File
@@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS maintenance_parts (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
printer_host text NOT NULL,
part_key text NOT NULL,
label text NOT NULL DEFAULT '',
installed_at timestamptz NOT NULL DEFAULT now(),
life_hours double precision NOT NULL DEFAULT 0,
life_meters double precision NOT NULL DEFAULT 0,
note text NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS maintenance_parts_host ON maintenance_parts (printer_host);
CREATE TABLE IF NOT EXISTS maintenance_log (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
printer_host text NOT NULL,
part_key text NOT NULL,
event text NOT NULL DEFAULT '',
hours_at double precision NOT NULL DEFAULT 0,
meters_at double precision NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
note text NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS maintenance_log_host ON maintenance_log (printer_host, created_at DESC);
+1
View File
@@ -0,0 +1 @@
ALTER TABLE printers ADD COLUMN IF NOT EXISTS start_window jsonb NOT NULL DEFAULT '{}'::jsonb;
+63
View File
@@ -0,0 +1,63 @@
CREATE TABLE IF NOT EXISTS customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
email text NOT NULL DEFAULT '',
notes text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint REFERENCES customers (id) ON DELETE SET NULL,
title text NOT NULL,
status text NOT NULL DEFAULT 'idee',
due_at date,
notes text NOT NULL DEFAULT '',
quote jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS order_items (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint NOT NULL REFERENCES orders (id) ON DELETE CASCADE,
file_id bigint REFERENCES files (id) ON DELETE SET NULL,
qty int NOT NULL DEFAULT 1,
unit_price double precision NOT NULL DEFAULT 0,
material_type text NOT NULL DEFAULT '',
color text NOT NULL DEFAULT '',
done_ok int NOT NULL DEFAULT 0,
done_bad int NOT NULL DEFAULT 0,
note text NOT NULL DEFAULT ''
);
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS order_item_id bigint REFERENCES order_items (id) ON DELETE SET NULL;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS order_item_id bigint REFERENCES order_items (id) ON DELETE SET NULL;
INSERT INTO orders (title, status, notes, quote, created_at)
SELECT
p.name,
CASE p.status
WHEN 'bereit' THEN 'angebot'
WHEN 'druckt' THEN 'druckt'
WHEN 'fertig' THEN 'fertig'
ELSE 'idee'
END,
p.notes,
p.quote,
p.created_at
FROM projects p
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.title = p.name AND o.created_at = p.created_at);
INSERT INTO orders (title, status, notes, created_at)
SELECT
i.title,
CASE i.status WHEN 'erledigt' THEN 'fertig' ELSE 'idee' END,
i.body,
i.created_at
FROM ideas i
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.title = i.title AND o.created_at = i.created_at);
CREATE INDEX IF NOT EXISTS orders_status ON orders (status, updated_at DESC);
CREATE INDEX IF NOT EXISTS order_items_order ON order_items (order_id);
CREATE INDEX IF NOT EXISTS runs_order_item ON runs (order_item_id) WHERE order_item_id IS NOT NULL;
+33 -31
View File
@@ -17,6 +17,16 @@ SLOT_CACHE = {}
MESH_CACHE = {} MESH_CACHE = {}
OBJECT_CACHE = {} OBJECT_CACHE = {}
THUMB_RE_LIMIT = 180_000 THUMB_RE_LIMIT = 180_000
_CACHE_MAX = 128
def _trim_cache(cache, max_items=_CACHE_MAX):
"""Einfache Größenbegrenzung (älteste Keys zuerst bei dict-Reihenfolge)."""
while len(cache) > max_items:
try:
cache.pop(next(iter(cache)))
except StopIteration:
break
def matrix_rows(points): def matrix_rows(points):
@@ -50,31 +60,6 @@ def _raw_matrix(points):
return [] return []
def fetch_bed_mesh(host):
"""Roh-Bettnetz von Moonraker (ohne UI-Spiegelung)."""
url = "http://%s/printer/objects/query?bed_mesh" % host
payload = fetch_json(url, timeout=10)
status = (payload.get("result") or {}).get("status") or {}
mesh = status.get("bed_mesh") or {}
matrix = _raw_matrix(mesh.get("probed_matrix"))
if not matrix:
matrix = _raw_matrix(mesh.get("mesh_matrix"))
mesh_min = mesh.get("mesh_min")
mesh_max = mesh.get("mesh_max")
if not (isinstance(mesh_min, (list, tuple)) and len(mesh_min) >= 2):
mesh_min = None
if not (isinstance(mesh_max, (list, tuple)) and len(mesh_max) >= 2):
mesh_max = None
if not matrix or mesh_min is None or mesh_max is None:
return None
return {
"matrix": matrix,
"mesh_min": [float(mesh_min[0]), float(mesh_min[1])],
"mesh_max": [float(mesh_max[0]), float(mesh_max[1])],
"profile": mesh.get("profile_name") or "",
}
def store_mesh(host, shown, profile=""): def store_mesh(host, shown, profile=""):
if not shown: if not shown:
return return
@@ -118,11 +103,6 @@ class PrinterError(Exception):
self.message = message self.message = message
def is_timeout(exc):
text = (getattr(exc, "message", None) or str(exc) or "").lower()
return "zu lange nicht geantwortet" in text or "timed out" in text
def fetch_json(url, timeout=4): def fetch_json(url, timeout=4):
request = urllib.request.Request(url, headers={"Accept": "application/json"}) request = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(request, timeout=timeout) as response: with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -373,6 +353,11 @@ def steuern(host, action, params=None):
"""Whitelist-Steuerung ohne freie Konsole.""" """Whitelist-Steuerung ohne freie Konsole."""
params = params if isinstance(params, dict) else {} params = params if isinstance(params, dict) else {}
action = (action or "").strip() action = (action or "").strip()
# MCP und ältere Clients schicken "value" statt temp/percent.
if "temp" not in params and params.get("value") is not None and action in ("nozzle", "bed", "dryer_start"):
params = {**params, "temp": params["value"]}
if "percent" not in params and params.get("value") is not None and action in ("speed", "flow", "fan"):
params = {**params, "percent": params["value"]}
def pct(key, default=100, lo=0, hi=200): def pct(key, default=100, lo=0, hi=200):
try: try:
@@ -522,6 +507,7 @@ def metadata(host, path):
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError): except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError):
data = {} data = {}
META_CACHE[key] = (now, data) META_CACHE[key] = (now, data)
_trim_cache(META_CACHE)
return data return data
@@ -548,9 +534,11 @@ def embedded_preview(host, path):
text = response.read().decode("utf-8", "replace") text = response.read().decode("utf-8", "replace")
except (urllib.error.URLError, TimeoutError): except (urllib.error.URLError, TimeoutError):
EMBEDDED[key] = None EMBEDDED[key] = None
_trim_cache(EMBEDDED)
return None return None
png = embedded_png(text) png = embedded_png(text)
EMBEDDED[key] = png EMBEDDED[key] = png
_trim_cache(EMBEDDED)
return png return png
@@ -776,6 +764,16 @@ def fan_values(status):
return speed, rpm return speed, rpm
def _heater_power(entry):
"""Klipper-Einschaltdauer 0…1 (ED), sonst None."""
if not isinstance(entry, dict):
return None
power = entry.get("power")
if isinstance(power, (int, float)) and not isinstance(power, bool):
return max(0.0, min(1.0, float(power)))
return None
def _object_names(host): def _object_names(host):
now = time.time() now = time.time()
cached = OBJECT_CACHE.get(host) cached = OBJECT_CACHE.get(host)
@@ -1078,8 +1076,10 @@ def query(host):
"elapsed": stats.get("print_duration") or 0, "elapsed": stats.get("print_duration") or 0,
"nozzle": extruder.get("temperature"), "nozzle": extruder.get("temperature"),
"nozzle_target": extruder.get("target"), "nozzle_target": extruder.get("target"),
"nozzle_power": _heater_power(extruder),
"bed": bed.get("temperature"), "bed": bed.get("temperature"),
"bed_target": bed.get("target"), "bed_target": bed.get("target"),
"bed_power": _heater_power(bed),
"chamber": chamber, "chamber": chamber,
"chamber_target": chamber_target, "chamber_target": chamber_target,
"filament_used": filament_used, "filament_used": filament_used,
@@ -1135,7 +1135,9 @@ def snapshot(printers):
} }
try: try:
card.update(query(printer["host"])) card.update(query(printer["host"]))
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError) as error: except Exception as error:
# Auch RemoteDisconnected / IncompleteRead / ValueError — sonst fällt der ganze Tick aus.
print("snapshot %s: %s" % (printer.get("host"), error), flush=True)
card.update({ card.update({
"ok": False, "ok": False,
"state": "offline", "state": "offline",
+45 -3
View File
@@ -1,8 +1,9 @@
"""Telegram-Benachrichtigungen (Bot API).""" """Telegram- und ntfy-Benachrichtigungen."""
from __future__ import annotations from __future__ import annotations
import json import json
import threading
import urllib.error import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
@@ -43,11 +44,52 @@ 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 ntfy_configured(cfg):
ntfy = (cfg or {}).get("ntfy") or {}
return bool(ntfy.get("enabled") and ntfy.get("topic"))
def send_ntfy(cfg, text):
if not ntfy_configured(cfg):
return False
ntfy = cfg["ntfy"]
server = str(ntfy.get("server") or "https://ntfy.sh").rstrip("/")
topic = str(ntfy.get("topic") or "").strip()
if not topic:
return False
url = "%s/%s" % (server, urllib.parse.quote(topic, safe=""))
headers = {"Title": "Grotti", "Priority": "default"}
token = str(ntfy.get("token") or "").strip()
if token:
headers["Authorization"] = "Bearer %s" % token
request = urllib.request.Request(
url,
data=text[:4000].encode("utf-8"),
method="POST",
headers=headers,
)
try:
with urllib.request.urlopen(request, timeout=12) as response:
return 200 <= response.status < 300
except (urllib.error.URLError, TimeoutError, OSError):
return False
def _push_alerts(cfg, message):
send_telegram(cfg, message)
send_ntfy(cfg, message)
def record(conn, cfg, host, printer, kind, message, filename="", dedupe_s=60, run_id=None): 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/ntfy 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 "", run_id=run_id) 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) threading.Thread(
target=_push_alerts,
args=(cfg, message),
name="grotti-notify",
daemon=True,
).start()
+460
View File
@@ -0,0 +1,460 @@
"""Aufträge, Kunden und Positionen."""
from __future__ import annotations
from datetime import date
from psycopg.types.json import Json
from grotti import store
ORDER_STATUSES = frozenset({
"idee",
"angebot",
"bestaetigt",
"druckt",
"fertig",
"geliefert",
"bezahlt",
})
PROJECT_TO_ORDER = {
"idee": "idee",
"bereit": "angebot",
"druckt": "druckt",
"fertig": "fertig",
}
IDEA_TO_ORDER = {
"offen": "idee",
"spaeter": "idee",
"erledigt": "fertig",
}
def normalize_status(value):
s = (value or "idee").strip().lower()
if s not in ORDER_STATUSES:
raise ValueError("Unbekannter Auftragsstatus.")
return s
def customer_out(row):
return {
"id": row["id"],
"name": row["name"],
"email": row.get("email") or "",
"notes": row.get("notes") or "",
"created_at": store.iso(row["created_at"]),
}
def order_item_out(row, file_name=None):
return {
"id": row["id"],
"order_id": row["order_id"],
"file_id": row.get("file_id"),
"file_name": file_name or "",
"qty": int(row.get("qty") or 1),
"unit_price": float(row.get("unit_price") or 0),
"material_type": row.get("material_type") or "",
"color": row.get("color") or "",
"done_ok": int(row.get("done_ok") or 0),
"done_bad": int(row.get("done_bad") or 0),
"note": row.get("note") or "",
}
def order_out(row, customer=None, items=None, progress=None):
data = {
"id": row["id"],
"customer_id": row.get("customer_id"),
"title": row["title"],
"status": row["status"],
"due_at": row["due_at"].isoformat() if row.get("due_at") else None,
"notes": row.get("notes") or "",
"quote": row.get("quote") or {},
"created_at": store.iso(row["created_at"]),
"updated_at": store.iso(row.get("updated_at") or row["created_at"]),
}
if customer is not None:
data["customer"] = customer
if items is not None:
data["items"] = items
if progress is not None:
data["progress"] = progress
return data
def order_progress(conn, order_id):
row = conn.execute(
"""
SELECT
COALESCE(SUM(qty), 0) AS target,
COALESCE(SUM(LEAST(done_ok, qty)), 0) AS done_ok,
COALESCE(SUM(done_bad), 0) AS done_bad
FROM order_items
WHERE order_id = %s
""",
(order_id,),
).fetchone()
target = int(row["target"] or 0)
done = int(row["done_ok"] or 0)
return {
"target": target,
"done": done,
"done_bad": int(row["done_bad"] or 0),
"label": "%d von %d" % (done, target) if target else "0 von 0",
}
def list_customers(conn):
rows = conn.execute(
"SELECT * FROM customers ORDER BY name, id"
).fetchall()
return [customer_out(r) for r in rows]
def get_customer(conn, customer_id):
row = conn.execute("SELECT * FROM customers WHERE id = %s", (customer_id,)).fetchone()
return customer_out(row) if row else None
def create_customer(conn, name, email="", notes=""):
row = conn.execute(
"""
INSERT INTO customers (name, email, notes)
VALUES (%s, %s, %s)
RETURNING *
""",
(name.strip(), (email or "").strip(), (notes or "").strip()),
).fetchone()
return customer_out(row)
def update_customer(conn, customer_id, fields):
sets = []
values = []
for key in ("name", "email", "notes"):
if key in fields:
sets.append("%s = %%s" % key)
values.append(fields[key])
if not sets:
return get_customer(conn, customer_id)
values.append(customer_id)
row = conn.execute(
"UPDATE customers SET %s WHERE id = %%s RETURNING *" % ", ".join(sets),
values,
).fetchone()
return customer_out(row) if row else None
def delete_customer(conn, customer_id):
row = conn.execute(
"DELETE FROM customers WHERE id = %s RETURNING id",
(customer_id,),
).fetchone()
return bool(row)
def list_orders(conn, status=None):
where = ""
params = []
if status:
where = "WHERE o.status = %s"
params.append(status)
rows = conn.execute(
"""
SELECT o.*, c.name AS customer_name
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id
%s
ORDER BY o.updated_at DESC, o.id DESC
""" % where,
params,
).fetchall()
out = []
for row in rows:
prog = order_progress(conn, row["id"])
customer = None
if row.get("customer_id"):
customer = {"id": row["customer_id"], "name": row.get("customer_name") or ""}
out.append(order_out(row, customer=customer, progress=prog))
return out
def get_order(conn, order_id):
row = conn.execute(
"""
SELECT o.*, c.name AS customer_name, c.email AS customer_email
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id
WHERE o.id = %s
""",
(order_id,),
).fetchone()
if not row:
return None
items_rows = conn.execute(
"""
SELECT oi.*, f.name AS file_name
FROM order_items oi
LEFT JOIN files f ON f.id = oi.file_id
WHERE oi.order_id = %s
ORDER BY oi.id
""",
(order_id,),
).fetchall()
items = [order_item_out(r, r.get("file_name")) for r in items_rows]
customer = None
if row.get("customer_id"):
customer = {
"id": row["customer_id"],
"name": row.get("customer_name") or "",
"email": row.get("customer_email") or "",
}
return order_out(
row,
customer=customer,
items=items,
progress=order_progress(conn, order_id),
)
def create_order(conn, title, status="idee", customer_id=None, due_at=None, notes="", quote=None):
status = normalize_status(status)
due = None
if due_at:
if isinstance(due_at, date):
due = due_at
else:
due = date.fromisoformat(str(due_at)[:10])
row = conn.execute(
"""
INSERT INTO orders (customer_id, title, status, due_at, notes, quote)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING *
""",
(
customer_id,
title.strip(),
status,
due,
(notes or "").strip(),
Json(quote if isinstance(quote, dict) else {}),
),
).fetchone()
return get_order(conn, row["id"])
def update_order(conn, order_id, fields):
sets = ["updated_at = now()"]
values = []
if "title" in fields:
sets.append("title = %s")
values.append(fields["title"])
if "status" in fields:
sets.append("status = %s")
values.append(normalize_status(fields["status"]))
if "customer_id" in fields:
sets.append("customer_id = %s")
values.append(fields["customer_id"])
if "due_at" in fields:
sets.append("due_at = %s")
due = fields["due_at"]
if due in ("", None):
values.append(None)
elif isinstance(due, date):
values.append(due)
else:
values.append(date.fromisoformat(str(due)[:10]))
if "notes" in fields:
sets.append("notes = %s")
values.append(fields["notes"] or "")
if "quote" in fields:
sets.append("quote = %s")
values.append(Json(fields["quote"] if isinstance(fields["quote"], dict) else {}))
if len(sets) <= 1:
return get_order(conn, order_id)
values.append(order_id)
row = conn.execute(
"UPDATE orders SET %s WHERE id = %%s RETURNING id" % ", ".join(sets),
values,
).fetchone()
return get_order(conn, row["id"]) if row else None
def delete_order(conn, order_id):
row = conn.execute("DELETE FROM orders WHERE id = %s RETURNING id", (order_id,)).fetchone()
return bool(row)
def create_order_item(conn, order_id, file_id=None, qty=1, unit_price=0, material_type="", color="", note=""):
if not conn.execute("SELECT 1 FROM orders WHERE id = %s", (order_id,)).fetchone():
return None
try:
qty_n = max(1, int(qty))
except (TypeError, ValueError):
qty_n = 1
row = conn.execute(
"""
INSERT INTO order_items (
order_id, file_id, qty, unit_price, material_type, color, note
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING *
""",
(
order_id,
file_id,
qty_n,
float(unit_price or 0),
(material_type or "").strip(),
(color or "").strip(),
(note or "").strip(),
),
).fetchone()
conn.execute("UPDATE orders SET updated_at = now() WHERE id = %s", (order_id,))
file_name = ""
if file_id:
f = store.get_file_row(conn, file_id)
file_name = (f or {}).get("name") or ""
return order_item_out(row, file_name)
def get_order_item(conn, item_id):
row = conn.execute(
"""
SELECT oi.*, f.name AS file_name
FROM order_items oi
LEFT JOIN files f ON f.id = oi.file_id
WHERE oi.id = %s
""",
(item_id,),
).fetchone()
return order_item_out(row, row.get("file_name")) if row else None
def update_order_item(conn, item_id, fields):
sets = []
values = []
for key in ("file_id", "material_type", "color", "note"):
if key in fields:
sets.append("%s = %%s" % key)
values.append(fields[key])
if "qty" in fields:
try:
qty_n = max(1, int(fields["qty"]))
except (TypeError, ValueError):
qty_n = 1
sets.append("qty = %s")
values.append(qty_n)
if "unit_price" in fields:
sets.append("unit_price = %s")
values.append(float(fields["unit_price"] or 0))
if not sets:
return get_order_item(conn, item_id)
values.append(item_id)
row = conn.execute(
"""
UPDATE order_items SET %s
WHERE id = %%s
RETURNING order_id
""" % ", ".join(sets),
values,
).fetchone()
if not row:
return None
conn.execute("UPDATE orders SET updated_at = now() WHERE id = %s", (row["order_id"],))
return get_order_item(conn, item_id)
def delete_order_item(conn, item_id):
row = conn.execute(
"DELETE FROM order_items WHERE id = %s RETURNING order_id",
(item_id,),
).fetchone()
if row:
conn.execute("UPDATE orders SET updated_at = now() WHERE id = %s", (row["order_id"],))
return bool(row)
def apply_verdict_delta(conn, order_item_id, old_ok, old_bad, new_ok, new_bad):
"""Erhöht done_ok/done_bad an der Position — nur Delta zum vorherigen Lauf-Stand."""
if not order_item_id:
return
d_ok = int(new_ok or 0) - int(old_ok or 0)
d_bad = int(new_bad or 0) - int(old_bad or 0)
if d_ok == 0 and d_bad == 0:
return
conn.execute(
"""
UPDATE order_items
SET done_ok = done_ok + %s, done_bad = done_bad + %s
WHERE id = %s
""",
(d_ok, d_bad, order_item_id),
)
item = conn.execute(
"SELECT order_id FROM order_items WHERE id = %s",
(order_item_id,),
).fetchone()
if item:
conn.execute("UPDATE orders SET updated_at = now() WHERE id = %s", (item["order_id"],))
def enqueue_order_item(
conn,
order_id,
item_id,
host="",
family="",
priority=100,
spool_id=None,
ace_map=None,
bed_leveling=False,
override=False,
):
item = conn.execute(
"SELECT * FROM order_items WHERE id = %s AND order_id = %s",
(item_id, order_id),
).fetchone()
if not item:
raise ValueError("Position fehlt.")
file_id = item.get("file_id")
if not file_id:
raise ValueError("Position ohne Datei.")
if not store.get_file_row(conn, file_id):
raise ValueError("Datei fehlt.")
row = store.enqueue_print(
conn,
file_id,
host or "",
family or "",
priority,
spool_id,
ace_map if isinstance(ace_map, list) else [],
bool(bed_leveling),
bool(override),
order_item_id=item["id"],
)
conn.execute("UPDATE orders SET updated_at = now(), status = CASE WHEN status IN ('idee', 'angebot', 'bestaetigt') THEN 'druckt' ELSE status END WHERE id = %s", (order_id,))
return row
def attach_order_item_to_live_run(conn, host, filename, order_item_id):
"""Verknüpft gestarteten Lauf mit Auftragsposition."""
if not order_item_id or not host:
return
base = (filename or "").replace("\\", "/").rsplit("/", 1)[-1]
conn.execute(
"""
UPDATE runs
SET order_item_id = COALESCE(order_item_id, %s)
WHERE printer_host = %s
AND (filename = %s OR filename LIKE %s)
AND state IN ('printing', 'paused', 'in_progress', 'completed')
AND ended_at IS NULL
""",
(order_item_id, host, base, "%/" + base),
)
+94 -1
View File
@@ -13,6 +13,14 @@ from datetime import datetime, timezone
from grotti import eject, jobs, moonraker, notify, store from grotti import eject, jobs, moonraker, notify, store
from grotti.db import connect from grotti.db import connect
from grotti.errors import GrottiError from grotti.errors import GrottiError
from grotti import time_window
WINDOW_WAIT_MSG = "Wartet auf Zeitfenster…"
from grotti.events_bus import publish as publish_live
def _notify_queue():
publish_live("queue", {})
FREE = {"standby", "complete", "cancelled"} FREE = {"standby", "complete", "cancelled"}
BUSY = {"printing", "paused", "heating", "error"} BUSY = {"printing", "paused", "heating", "error"}
@@ -20,6 +28,8 @@ BUSY = {"printing", "paused", "heating", "error"}
KEEPALIVE_S = 120 KEEPALIVE_S = 120
# Nach Start ohne print_active: Upload/MQTT/LeviQ brauchen Zeit, aber nicht ewig # Nach Start ohne print_active: Upload/MQTT/LeviQ brauchen Zeit, aber nicht ewig
PRINT_START_TIMEOUT_S = 600 PRINT_START_TIMEOUT_S = 600
# Einfacher Queue-Start ohne Worker (typisch Grotti-Neustart)
SIMPLE_START_STUCK_S = 45
def _ablauf_needs_motors(item): def _ablauf_needs_motors(item):
@@ -120,6 +130,41 @@ class PrintQueueDispatcher:
thread = self._workers.get(item_id) thread = self._workers.get(item_id)
return bool(thread and thread.is_alive()) return bool(thread and thread.is_alive())
def _item_phase_age_s(self, item):
"""Alter von phase_started_at, sonst updated_at (für hängende Starts nach Neustart)."""
started = item.get("phase_started_at")
if started:
if getattr(started, "tzinfo", None) is None:
started = started.replace(tzinfo=timezone.utc)
return (datetime.now(timezone.utc) - started).total_seconds()
updated = item.get("updated_at")
if updated:
if getattr(updated, "tzinfo", None) is None:
updated = updated.replace(tzinfo=timezone.utc)
return (datetime.now(timezone.utc) - updated).total_seconds()
return 0
def _fail_stuck_simple_start(self, conn, item, printer):
host = printer["host"]
store.update_print_queue(
conn,
item["id"],
{
"status": "error",
"message": "Start abgebrochen (Neustart)",
"phase": "",
"phase_started_at": None,
},
)
self._event(
conn,
host,
printer["name"],
"queue",
"%s: Start abgebrochen (Neustart)" % printer["name"],
item.get("filename") or "",
)
def _spawn_worker(self, item_id, target, *args): def _spawn_worker(self, item_id, target, *args):
with self._worker_lock: with self._worker_lock:
existing = self._workers.get(item_id) existing = self._workers.get(item_id)
@@ -129,7 +174,10 @@ class PrintQueueDispatcher:
def run(): def run():
try: try:
target(*args) target(*args)
except Exception as exc:
print("Queue-Worker %s: %s" % (item_id, exc), flush=True)
finally: finally:
_notify_queue()
with self._worker_lock: with self._worker_lock:
self._workers.pop(item_id, None) self._workers.pop(item_id, None)
@@ -143,6 +191,12 @@ class PrintQueueDispatcher:
return True return True
def tick(self): def tick(self):
try:
self._tick_once()
finally:
_notify_queue()
def _tick_once(self):
with connect(self.database_url) as conn: with connect(self.database_url) as conn:
printers = store.list_printers(conn) printers = store.list_printers(conn)
if not printers: if not printers:
@@ -190,13 +244,36 @@ class PrintQueueDispatcher:
continue continue
if state not in FREE: if state not in FREE:
continue continue
if store.running_print_on_host(conn, host): running = store.running_print_on_host(conn, host)
if running:
if (
not _has_ablauf(running)
and (running.get("phase") or "").lower() == "starting"
and not self._worker_busy(running["id"])
and self._item_phase_age_s(running) > SIMPLE_START_STUCK_S
):
self._fail_stuck_simple_start(conn, running, printer)
conn.commit()
continue continue
if store.bed_needs_clear(conn, host): if store.bed_needs_clear(conn, host):
continue continue
waiting = store.next_waiting_print(conn, host=host, family=printer.get("family") or "") waiting = store.next_waiting_print(conn, host=host, family=printer.get("family") or "")
if not waiting: if not waiting:
continue continue
sw = printer.get("start_window") or {}
if sw and not time_window.allowed_now(sw):
if (waiting.get("message") or "") != WINDOW_WAIT_MSG:
store.update_print_queue(conn, waiting["id"], {"message": WINDOW_WAIT_MSG})
continue
est_s = 0
if waiting.get("file_id"):
file_row = store.get_file_row(conn, waiting["file_id"])
if file_row:
est_s = float(file_row.get("estimated_s") or 0)
if sw and not time_window.job_fits_overnight(sw, est_s):
if (waiting.get("message") or "") != WINDOW_WAIT_MSG:
store.update_print_queue(conn, waiting["id"], {"message": WINDOW_WAIT_MSG})
continue
claimed = store.claim_print_queue(conn, waiting["id"], host) claimed = store.claim_print_queue(conn, waiting["id"], host)
if not claimed: if not claimed:
continue continue
@@ -255,6 +332,11 @@ class PrintQueueDispatcher:
bed_leveling=bed_leveling, bed_leveling=bed_leveling,
) )
name = (result or {}).get("filename") or "" name = (result or {}).get("filename") or ""
oid = item.get("order_item_id")
if oid:
from grotti import orders
orders.attach_order_item_to_live_run(wconn, host, name, oid)
store.update_print_queue( store.update_print_queue(
wconn, wconn,
item_id, item_id,
@@ -276,6 +358,7 @@ class PrintQueueDispatcher:
) )
wconn.commit() wconn.commit()
except GrottiError as exc: except GrottiError as exc:
wconn.rollback()
store.update_print_queue( store.update_print_queue(
wconn, wconn,
item_id, item_id,
@@ -291,6 +374,7 @@ class PrintQueueDispatcher:
) )
wconn.commit() wconn.commit()
except Exception as exc: except Exception as exc:
wconn.rollback()
store.update_print_queue( store.update_print_queue(
wconn, wconn,
item_id, item_id,
@@ -684,10 +768,12 @@ class PrintQueueDispatcher:
self._step_event(wconn, row, printer_copy, "druckt %s" % name) self._step_event(wconn, row, printer_copy, "druckt %s" % name)
wconn.commit() wconn.commit()
except GrottiError as exc: except GrottiError as exc:
wconn.rollback()
row = store.get_print_queue_row(wconn, item_id) or item row = store.get_print_queue_row(wconn, item_id) or item
self._fail_ablauf(wconn, row, printer_copy, exc.message) self._fail_ablauf(wconn, row, printer_copy, exc.message)
wconn.commit() wconn.commit()
except Exception as exc: except Exception as exc:
wconn.rollback()
row = store.get_print_queue_row(wconn, item_id) or item row = store.get_print_queue_row(wconn, item_id) or item
self._fail_ablauf(wconn, row, printer_copy, str(exc) or "Druckstart fehlgeschlagen.") self._fail_ablauf(wconn, row, printer_copy, str(exc) or "Druckstart fehlgeschlagen.")
wconn.commit() wconn.commit()
@@ -742,6 +828,7 @@ class PrintQueueDispatcher:
return state return state
def _start_cool_step(self, conn, item, printer, step): def _start_cool_step(self, conn, item, printer, step):
# TODO: _try_cool_park (bis 60 s G-code) blockiert tick() — ggf. in Worker auslagern.
host = printer["host"] host = printer["host"]
cool = eject.normalize_cool(step) cool = eject.normalize_cool(step)
try: try:
@@ -908,14 +995,17 @@ class PrintQueueDispatcher:
self._next_step(wconn, row, printer_copy, {"state": "standby", "ok": True}) self._next_step(wconn, row, printer_copy, {"state": "standby", "ok": True})
wconn.commit() wconn.commit()
except GrottiError as exc: except GrottiError as exc:
wconn.rollback()
row = store.get_print_queue_row(wconn, item_id) or item row = store.get_print_queue_row(wconn, item_id) or item
self._fail_ablauf(wconn, row, printer_copy, exc.message) self._fail_ablauf(wconn, row, printer_copy, exc.message)
wconn.commit() wconn.commit()
except moonraker.PrinterError as exc: except moonraker.PrinterError as exc:
wconn.rollback()
row = store.get_print_queue_row(wconn, item_id) or item row = store.get_print_queue_row(wconn, item_id) or item
self._fail_ablauf(wconn, row, printer_copy, eject.explain_printer_error(exc.message)) self._fail_ablauf(wconn, row, printer_copy, eject.explain_printer_error(exc.message))
wconn.commit() wconn.commit()
except Exception as exc: except Exception as exc:
wconn.rollback()
row = store.get_print_queue_row(wconn, item_id) or item row = store.get_print_queue_row(wconn, item_id) or item
self._fail_ablauf(wconn, row, printer_copy, "Schieben: %s" % exc) self._fail_ablauf(wconn, row, printer_copy, "Schieben: %s" % exc)
wconn.commit() wconn.commit()
@@ -1013,14 +1103,17 @@ class PrintQueueDispatcher:
self._next_step(wconn, row, printer_copy, {"state": "standby", "ok": True}) self._next_step(wconn, row, printer_copy, {"state": "standby", "ok": True})
wconn.commit() wconn.commit()
except GrottiError as exc: except GrottiError as exc:
wconn.rollback()
row = store.get_print_queue_row(wconn, item_id) or item row = store.get_print_queue_row(wconn, item_id) or item
self._fail_ablauf(wconn, row, printer_copy, exc.message) self._fail_ablauf(wconn, row, printer_copy, exc.message)
wconn.commit() wconn.commit()
except moonraker.PrinterError as exc: except moonraker.PrinterError as exc:
wconn.rollback()
row = store.get_print_queue_row(wconn, item_id) or item row = store.get_print_queue_row(wconn, item_id) or item
self._fail_ablauf(wconn, row, printer_copy, eject.explain_printer_error(exc.message)) self._fail_ablauf(wconn, row, printer_copy, eject.explain_printer_error(exc.message))
wconn.commit() wconn.commit()
except Exception as exc: except Exception as exc:
wconn.rollback()
row = store.get_print_queue_row(wconn, item_id) or item row = store.get_print_queue_row(wconn, item_id) or item
self._fail_ablauf(wconn, row, printer_copy, "Tasten: %s" % exc) self._fail_ablauf(wconn, row, printer_copy, "Tasten: %s" % exc)
wconn.commit() wconn.commit()
-23
View File
@@ -1,7 +1,5 @@
"""Die Drucker: Familie aus dem Namen, die Liste selbst liegt in PostgreSQL.""" """Die Drucker: Familie aus dem Namen, die Liste selbst liegt in PostgreSQL."""
import json
FAMILY_LABELS = {"kobra_s1": "Kobra S1", "kobra_3": "Kobra 3"} FAMILY_LABELS = {"kobra_s1": "Kobra S1", "kobra_3": "Kobra 3"}
@@ -21,24 +19,3 @@ def family_from_text(text):
if "kobra 3" in low or "kobra3" in low: if "kobra 3" in low or "kobra3" in low:
return "kobra_3" return "kobra_3"
return "" return ""
def load_printers(path):
rows = json.loads(path.read_text(encoding="utf-8"))
printers = []
for row in rows:
family = row.get("family") or family_from_name(row["name"])
printers.append({
"name": row["name"],
"host": row["host"],
"family": family,
"family_label": FAMILY_LABELS.get(family, family),
})
return printers
def printer_by_host(path, host):
for printer in load_printers(path):
if printer["host"] == host:
return printer
return None
+73 -1
View File
@@ -1,3 +1,6 @@
-- Referenz-Schema. Laufende Migrationen liegen in grotti/migrations/*.sql
-- (001_base.sql entspricht diesem Stand, danach nummerierte Änderungen).
CREATE TABLE IF NOT EXISTS projects ( CREATE TABLE IF NOT EXISTS projects (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL, name text NOT NULL,
@@ -282,11 +285,16 @@ CREATE TABLE IF NOT EXISTS run_samples (
layer integer, layer integer,
progress double precision, progress double precision,
filament_used_mm double precision, filament_used_mm double precision,
mmu_tool smallint mmu_tool smallint,
nozzle_power double precision,
bed_power double precision
); );
CREATE INDEX IF NOT EXISTS run_samples_run_time ON run_samples (run_id, sampled_at); CREATE INDEX IF NOT EXISTS run_samples_run_time ON run_samples (run_id, sampled_at);
ALTER TABLE run_samples ADD COLUMN IF NOT EXISTS nozzle_power double precision;
ALTER TABLE run_samples ADD COLUMN IF NOT EXISTS bed_power double precision;
-- Objekt-Chargen für Etiketten -- Objekt-Chargen für Etiketten
CREATE TABLE IF NOT EXISTS run_labels ( CREATE TABLE IF NOT EXISTS run_labels (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
@@ -298,3 +306,67 @@ CREATE TABLE IF NOT EXISTS run_labels (
); );
CREATE UNIQUE INDEX IF NOT EXISTS run_labels_chargen ON run_labels (chargen_nr); CREATE UNIQUE INDEX IF NOT EXISTS run_labels_chargen ON run_labels (chargen_nr);
ALTER TABLE runs ADD COLUMN IF NOT EXISTS verdict_reason text NOT NULL DEFAULT '';
ALTER TABLE runs ADD COLUMN IF NOT EXISTS pieces_ok int NOT NULL DEFAULT 0;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS pieces_bad int NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS maintenance_parts (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
printer_host text NOT NULL,
part_key text NOT NULL,
label text NOT NULL DEFAULT '',
installed_at timestamptz NOT NULL DEFAULT now(),
life_hours double precision NOT NULL DEFAULT 0,
life_meters double precision NOT NULL DEFAULT 0,
note text NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS maintenance_log (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
printer_host text NOT NULL,
part_key text NOT NULL,
event text NOT NULL DEFAULT '',
hours_at double precision NOT NULL DEFAULT 0,
meters_at double precision NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
note text NOT NULL DEFAULT ''
);
ALTER TABLE printers ADD COLUMN IF NOT EXISTS start_window jsonb NOT NULL DEFAULT '{}'::jsonb;
CREATE TABLE IF NOT EXISTS customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
email text NOT NULL DEFAULT '',
notes text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint REFERENCES customers (id) ON DELETE SET NULL,
title text NOT NULL,
status text NOT NULL DEFAULT 'idee',
due_at date,
notes text NOT NULL DEFAULT '',
quote jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS order_items (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint NOT NULL REFERENCES orders (id) ON DELETE CASCADE,
file_id bigint REFERENCES files (id) ON DELETE SET NULL,
qty int NOT NULL DEFAULT 1,
unit_price double precision NOT NULL DEFAULT 0,
material_type text NOT NULL DEFAULT '',
color text NOT NULL DEFAULT '',
done_ok int NOT NULL DEFAULT 0,
done_bad int NOT NULL DEFAULT 0,
note text NOT NULL DEFAULT ''
);
ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS order_item_id bigint REFERENCES order_items (id) ON DELETE SET NULL;
ALTER TABLE runs ADD COLUMN IF NOT EXISTS order_item_id bigint REFERENCES order_items (id) ON DELETE SET NULL;
+28 -4
View File
@@ -11,18 +11,26 @@ from queue import Empty, Queue
from grotti import jobs from grotti import jobs
from grotti.db import connect from grotti.db import connect
from grotti.errors import GrottiError from grotti.errors import GrottiError
from grotti.events_bus import publish as publish_live
class SendQueue: class SendQueue:
def __init__(self, database_url, cfg, workers=4): def __init__(self, database_url, cfg, workers=4):
self.database_url = database_url self.database_url = database_url
self.cfg = cfg self.cfg = cfg
self._workers_n = max(1, int(workers))
self._lock = threading.Lock() self._lock = threading.Lock()
self._pending = Queue() self._pending = Queue()
self._jobs = {} self._jobs = {}
self._order = deque(maxlen=60) self._order = deque(maxlen=60)
self._threads = [] self._threads = []
for index in range(max(1, int(workers))): self._stop = threading.Event()
def start(self):
if self._threads:
return
self._stop.clear()
for index in range(self._workers_n):
thread = threading.Thread( thread = threading.Thread(
target=self._worker, target=self._worker,
name="grotti-send-queue-%s" % (index + 1), name="grotti-send-queue-%s" % (index + 1),
@@ -31,6 +39,14 @@ class SendQueue:
thread.start() thread.start()
self._threads.append(thread) self._threads.append(thread)
def stop(self):
self._stop.set()
for _ in self._threads:
try:
self._pending.put_nowait(None)
except Exception:
pass
def enqueue( def enqueue(
self, self,
conn, conn,
@@ -82,9 +98,14 @@ class SendQueue:
want_level = bool(bed_leveling) want_level = bool(bed_leveling)
created = [] created = []
for host in unique: for host in unique:
printer, row, chosen = jobs.prepare_send(conn, file_id, host, True, override, spool_id) # Mapping zuerst, damit Materialprüfung die ACE-Spulen sieht.
mapping = ace_maps.get(host) or []
printer, row, chosen = jobs.prepare_send(
conn, file_id, host, True, override, spool_id, ace_map=mapping
)
if not mapping:
mapping = ace_maps.get(printer["name"]) or []
job_id = uuid.uuid4().hex[:12] job_id = uuid.uuid4().hex[:12]
mapping = ace_maps.get(host) or ace_maps.get(printer["name"]) or []
job = { job = {
"id": job_id, "id": job_id,
"status": "pending", "status": "pending",
@@ -141,13 +162,16 @@ class SendQueue:
return return
job.update(fields) job.update(fields)
job["updated_at"] = time.time() job["updated_at"] = time.time()
publish_live("senden", {})
def _worker(self): def _worker(self):
while True: while not self._stop.is_set():
try: try:
item = self._pending.get(timeout=1) item = self._pending.get(timeout=1)
except Empty: except Empty:
continue continue
if item is None:
break
self._run(item) self._run(item)
def _run(self, item): def _run(self, item):
+356 -56
View File
@@ -3,13 +3,12 @@
import json import json
import uuid import uuid
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from psycopg.types.json import Json from psycopg.types.json import Json
from grotti.catalog import MACHINE_LABELS, MATERIALS from grotti.catalog import MACHINE_LABELS, MATERIALS
from grotti.printers import FAMILY_LABELS, family_from_name from grotti.printers import FAMILY_LABELS, family_from_name
def iso(value): def iso(value):
return value.isoformat() if value else None return value.isoformat() if value else None
@@ -30,7 +29,21 @@ def project_out(row, files=None, file_count=None):
return data return data
def file_out(row): def file_folder(stored_path, source, watch_dir=None):
"""Relativpfad des Elternordners unter watch_dir; Uploads → „Upload“."""
if (source or "") == "upload":
return "Upload"
if not stored_path or not watch_dir:
return ""
try:
rel = Path(stored_path).resolve().relative_to(Path(watch_dir).resolve())
except (ValueError, OSError):
return ""
parent = rel.parent.as_posix()
return "" if parent in (".", "") else parent
def file_out(row, watch_dir=None):
# stored_path bleibt intern (get_file_row / list_file_rows), nicht in der API. # stored_path bleibt intern (get_file_row / list_file_rows), nicht in der API.
from grotti.parse import path_has_ace_trailer from grotti.parse import path_has_ace_trailer
@@ -54,6 +67,7 @@ def file_out(row):
"mtime": row["mtime"], "mtime": row["mtime"],
"slice_meta": row.get("slice_meta") or {}, "slice_meta": row.get("slice_meta") or {},
"ace_trailer": ace_trailer, "ace_trailer": ace_trailer,
"folder": file_folder(stored, row.get("source"), watch_dir),
"preview": "/api/v1/dateien/%s/vorschau" % row["id"] if row["thumb_entry"] else "", "preview": "/api/v1/dateien/%s/vorschau" % row["id"] if row["thumb_entry"] else "",
} }
@@ -84,6 +98,8 @@ def spool_out(row):
def next_chargen(conn): def next_chargen(conn):
prefix = "S-%s-" % date.today().strftime("%Y%m%d") prefix = "S-%s-" % date.today().strftime("%Y%m%d")
# Advisory-Lock gegen Doppelvergabe bei parallelen Worker/Requests.
conn.execute("SELECT pg_advisory_xact_lock(%s)", (870011,))
row = conn.execute( row = conn.execute(
""" """
SELECT chargen_nr FROM spools SELECT chargen_nr FROM spools
@@ -152,6 +168,10 @@ def run_out(row):
"deducted": row["deducted"], "deducted": row["deducted"],
"needs_spool": row["needs_spool"], "needs_spool": row["needs_spool"],
"verdict": row["verdict"], "verdict": row["verdict"],
"verdict_reason": row.get("verdict_reason") or "",
"pieces_ok": int(row.get("pieces_ok") or 0),
"pieces_bad": int(row.get("pieces_bad") or 0),
"order_item_id": row.get("order_item_id"),
"material_type": row["material_type"], "material_type": row["material_type"],
"chargen_nr": row.get("chargen_nr") or "", "chargen_nr": row.get("chargen_nr") or "",
"message": row.get("message") or "", "message": row.get("message") or "",
@@ -227,8 +247,8 @@ def list_file_rows(conn):
return conn.execute("SELECT * FROM files ORDER BY mtime DESC, name").fetchall() return conn.execute("SELECT * FROM files ORDER BY mtime DESC, name").fetchall()
def list_files(conn): def list_files(conn, watch_dir=None):
return [file_out(row) for row in list_file_rows(conn)] return [file_out(row, watch_dir=watch_dir) for row in list_file_rows(conn)]
def get_file_row(conn, file_id): def get_file_row(conn, file_id):
@@ -291,7 +311,7 @@ def insert_upload(conn, meta):
return file_out(row) return file_out(row)
def update_file(conn, file_id, fields): def update_file(conn, file_id, fields, watch_dir=None):
sets = [] sets = []
values = [] values = []
for key in ("project_id", "spool_id", "printer_family", "weight_g", "estimated_s", "thumb_entry", "kind", "plate"): for key in ("project_id", "spool_id", "printer_family", "weight_g", "estimated_s", "thumb_entry", "kind", "plate"):
@@ -306,13 +326,33 @@ def update_file(conn, file_id, fields):
values.append(Json(fields.get("slice_meta") or {})) values.append(Json(fields.get("slice_meta") or {}))
if not sets: if not sets:
row = get_file_row(conn, file_id) row = get_file_row(conn, file_id)
return file_out(row) if row else None return file_out(row, watch_dir=watch_dir) if row else None
values.append(file_id) values.append(file_id)
row = conn.execute( row = conn.execute(
"UPDATE files SET %s WHERE id = %%s RETURNING *" % ", ".join(sets), "UPDATE files SET %s WHERE id = %%s RETURNING *" % ", ".join(sets),
values, values,
).fetchone() ).fetchone()
return file_out(row) if row else None return file_out(row, watch_dir=watch_dir) if row else None
def update_file_location(conn, file_id, stored_path, name=None, size_bytes=None, mtime=None):
"""Pfad nach Verschieben auf der Platte — ID und Zuordnungen bleiben."""
sets = ["stored_path = %s"]
values = [stored_path]
if name is not None:
sets.append("name = %s")
values.append(name)
if size_bytes is not None:
sets.append("size_bytes = %s")
values.append(size_bytes)
if mtime is not None:
sets.append("mtime = %s")
values.append(mtime)
values.append(file_id)
return conn.execute(
"UPDATE files SET %s WHERE id = %%s RETURNING *" % ", ".join(sets),
values,
).fetchone()
def delete_file(conn, file_id): def delete_file(conn, file_id):
@@ -508,6 +548,28 @@ def seed_materials(conn):
def delete_spool(conn, spool_id): def delete_spool(conn, spool_id):
used = conn.execute(
"SELECT 1 FROM run_spools WHERE spool_id = %s LIMIT 1",
(spool_id,),
).fetchone()
if used:
from grotti.errors import GrottiError
raise GrottiError(
"Die Spule hat Läufe — erst archivieren oder behalten.",
"spule",
409,
)
seg = conn.execute(
"SELECT 1 FROM run_segments WHERE spool_id = %s LIMIT 1",
(spool_id,),
).fetchone()
if seg:
from grotti.errors import GrottiError
raise GrottiError(
"Die Spule steckt in Abschnitten — nicht löschen.",
"spule",
409,
)
row = conn.execute("DELETE FROM spools WHERE id = %s RETURNING id", (spool_id,)).fetchone() row = conn.execute("DELETE FROM spools WHERE id = %s RETURNING id", (spool_id,)).fetchone()
return bool(row) return bool(row)
@@ -519,6 +581,20 @@ def spools_for_printer(conn, host):
).fetchall() ).fetchall()
def spool_at_slot(conn, host, slot):
"""Eine Spule an Host + ACE-Platz (1-basiert) oder None."""
if not host or slot is None:
return None
return conn.execute(
"""
SELECT * FROM spools
WHERE printer_host = %s AND ace_slot = %s
ORDER BY id LIMIT 1
""",
(host, int(slot)),
).fetchone()
def file_by_name(conn, filename): def file_by_name(conn, filename):
base = (filename or "").replace("\\", "/").rsplit("/", 1)[-1] base = (filename or "").replace("\\", "/").rsplit("/", 1)[-1]
if not base: if not base:
@@ -601,6 +677,8 @@ def upsert_run(conn, item):
**item, **item,
} }
payload["objects"] = Json(item.get("objects") if item.get("objects") is not None else []) payload["objects"] = Json(item.get("objects") if item.get("objects") is not None else [])
if "order_item_id" not in payload:
payload["order_item_id"] = item.get("order_item_id")
if "weight_g" not in payload or payload.get("weight_g") is None: if "weight_g" not in payload or payload.get("weight_g") is None:
payload["weight_g"] = payload["weight_planned_g"] payload["weight_g"] = payload["weight_planned_g"]
return conn.execute( return conn.execute(
@@ -608,14 +686,14 @@ def upsert_run(conn, item):
INSERT INTO runs ( INSERT INTO runs (
printer_host, printer_name, filename, file_id, project_id, spool_id, printer_host, printer_name, filename, file_id, project_id, spool_id,
state, started_at, ended_at, duration_s, weight_g, energy_cost, state, started_at, ended_at, duration_s, weight_g, energy_cost,
moonraker_job_id, needs_spool, material_type, moonraker_job_id, needs_spool, material_type, order_item_id,
weight_planned_g, weight_used_g, weight_source, weight_planned_g, weight_used_g, weight_source,
filament_used_mm, filament_total_mm, progress_last, estimated_s, filament_used_mm, filament_total_mm, progress_last, estimated_s,
message, objects message, objects
) VALUES ( ) VALUES (
%(printer_host)s, %(printer_name)s, %(filename)s, %(file_id)s, %(project_id)s, %(spool_id)s, %(printer_host)s, %(printer_name)s, %(filename)s, %(file_id)s, %(project_id)s, %(spool_id)s,
%(state)s, %(started_at)s, %(ended_at)s, %(duration_s)s, %(weight_g)s, %(energy_cost)s, %(state)s, %(started_at)s, %(ended_at)s, %(duration_s)s, %(weight_g)s, %(energy_cost)s,
%(moonraker_job_id)s, %(needs_spool)s, %(material_type)s, %(moonraker_job_id)s, %(needs_spool)s, %(material_type)s, %(order_item_id)s,
%(weight_planned_g)s, %(weight_used_g)s, %(weight_source)s, %(weight_planned_g)s, %(weight_used_g)s, %(weight_source)s,
%(filament_used_mm)s, %(filament_total_mm)s, %(progress_last)s, %(estimated_s)s, %(filament_used_mm)s, %(filament_total_mm)s, %(progress_last)s, %(estimated_s)s,
%(message)s, %(objects)s %(message)s, %(objects)s
@@ -626,6 +704,7 @@ def upsert_run(conn, item):
file_id = COALESCE(runs.file_id, EXCLUDED.file_id), file_id = COALESCE(runs.file_id, EXCLUDED.file_id),
project_id = COALESCE(runs.project_id, EXCLUDED.project_id), project_id = COALESCE(runs.project_id, EXCLUDED.project_id),
spool_id = COALESCE(runs.spool_id, EXCLUDED.spool_id), spool_id = COALESCE(runs.spool_id, EXCLUDED.spool_id),
order_item_id = COALESCE(runs.order_item_id, EXCLUDED.order_item_id),
state = EXCLUDED.state, state = EXCLUDED.state,
started_at = COALESCE(runs.started_at, EXCLUDED.started_at), started_at = COALESCE(runs.started_at, EXCLUDED.started_at),
ended_at = EXCLUDED.ended_at, ended_at = EXCLUDED.ended_at,
@@ -766,6 +845,34 @@ def get_run_row(conn, run_id):
return conn.execute("SELECT * FROM runs WHERE id = %s", (run_id,)).fetchone() return conn.execute("SELECT * FROM runs WHERE id = %s", (run_id,)).fetchone()
def latest_run_for_host(conn, host):
"""Aktueller oder letzter Lauf mit Proben fürs Cockpit-Verlauf."""
host = (host or "").strip()
if not host:
return None
runs = list_runs(conn, limit=12, host=host)
if not runs:
return None
active = [r for r in runs if (r.get("state") or "") in ("in_progress", "printing", "paused")]
candidates = active + [r for r in runs if r not in active]
best = None
best_n = -1
for run in candidates:
row = conn.execute(
"SELECT count(*) AS n FROM run_samples WHERE run_id = %s",
(run["id"],),
).fetchone()
n = int(row["n"] or 0) if row else 0
if (run.get("state") or "") in ("in_progress", "printing", "paused"):
run["_sample_count"] = n
return run
if n >= 2 and n > best_n:
best = run
best_n = n
best["_sample_count"] = n
return best
def get_run_by_chargen(conn, code): def get_run_by_chargen(conn, code):
text = (code or "").strip() text = (code or "").strip()
if not text: if not text:
@@ -785,14 +892,55 @@ def get_run_by_chargen(conn, code):
return get_run_row(conn, label["run_id"]) return get_run_row(conn, label["run_id"])
def set_verdict(conn, run_id, verdict): def set_verdict(conn, run_id, verdict, verdict_reason="", pieces_ok=0, pieces_bad=0):
row = conn.execute( from grotti import orders
"UPDATE runs SET verdict = %s WHERE id = %s RETURNING *",
(verdict, run_id), before = conn.execute(
"SELECT order_item_id, pieces_ok, pieces_bad FROM runs WHERE id = %s",
(run_id,),
).fetchone() ).fetchone()
row = conn.execute(
"""
UPDATE runs
SET verdict = %s, verdict_reason = %s, pieces_ok = %s, pieces_bad = %s
WHERE id = %s
RETURNING *
""",
(verdict, verdict_reason or "", int(pieces_ok or 0), int(pieces_bad or 0), run_id),
).fetchone()
if row and before and before.get("order_item_id"):
orders.apply_verdict_delta(
conn,
before["order_item_id"],
before.get("pieces_ok"),
before.get("pieces_bad"),
pieces_ok,
pieces_bad,
)
return run_out(row) if row else None return run_out(row) if row else None
def run_for_abraeumen_verdict(conn, host):
"""Letzter abgeschlossener oder noch laufender Lauf des Hosts."""
host = (host or "").strip()
if not host:
return None
return conn.execute(
"""
SELECT * FROM runs
WHERE printer_host = %s
AND state IN ('completed', 'in_progress', 'printing', 'paused')
ORDER BY
CASE WHEN state IN ('in_progress', 'printing', 'paused') THEN 0 ELSE 1 END,
ended_at DESC NULLS LAST,
started_at DESC NULLS LAST,
id DESC
LIMIT 1
""",
(host,),
).fetchone()
def assign_run_spool(conn, run_id, spool_id): def assign_run_spool(conn, run_id, spool_id):
return conn.execute( return conn.execute(
""" """
@@ -806,21 +954,34 @@ def assign_run_spool(conn, run_id, spool_id):
def next_druck_chargen(conn): def next_druck_chargen(conn):
day = datetime.now(timezone.utc).strftime("%Y%m%d") """Nächste Druck-Charge des Tages. Objekt-Etiketten (`D-…-01`) blockieren die Nummer."""
day = date.today().strftime("%Y%m%d")
prefix = "D-%s-" % day prefix = "D-%s-" % day
batch_re = "^D-%s-[0-9]{4}$" % day
object_re = "^D-%s-[0-9]{4}-[0-9]{2}$" % day
conn.execute("SELECT pg_advisory_xact_lock(%s)", (870012,))
row = conn.execute( row = conn.execute(
""" """
SELECT chargen_nr FROM runs SELECT n FROM (
WHERE chargen_nr LIKE %s SELECT chargen_nr AS n FROM runs
ORDER BY chargen_nr DESC WHERE chargen_nr ~ %s
UNION
SELECT CASE
WHEN chargen_nr ~ %s THEN regexp_replace(chargen_nr, '-[0-9]{2}$', '')
ELSE chargen_nr
END AS n
FROM run_labels
WHERE chargen_nr ~ %s OR chargen_nr ~ %s
) AS nums
ORDER BY n DESC
LIMIT 1 LIMIT 1
""", """,
(prefix + "%",), (batch_re, object_re, batch_re, object_re),
).fetchone() ).fetchone()
number = 1 number = 1
if row and row["chargen_nr"]: if row and row["n"]:
try: try:
number = int(str(row["chargen_nr"]).rsplit("-", 1)[-1]) + 1 number = int(str(row["n"]).rsplit("-", 1)[-1]) + 1
except ValueError: except ValueError:
number = 1 number = 1
return "%s%04d" % (prefix, number) return "%s%04d" % (prefix, number)
@@ -1203,7 +1364,7 @@ def adopt_live_run(conn, host, filename, real_run_id):
return return
live_id = live["id"] live_id = live["id"]
live_chargen = (live.get("chargen_nr") or "").strip() live_chargen = (live.get("chargen_nr") or "").strip()
for table in ("run_samples", "run_segments", "run_spools", "run_labels"): for table in ("run_samples", "run_segments", "run_spools"):
conn.execute( conn.execute(
"UPDATE %s SET run_id = %%s WHERE run_id = %%s" % table, "UPDATE %s SET run_id = %%s WHERE run_id = %%s" % table,
(real_run_id, live_id), (real_run_id, live_id),
@@ -1238,6 +1399,26 @@ def adopt_live_run(conn, host, filename, real_run_id):
else: else:
# Live-Charge war verfrüht — nicht übernehmen # Live-Charge war verfrüht — nicht übernehmen
conn.execute("UPDATE runs SET chargen_nr = NULL WHERE id = %s", (live_id,)) conn.execute("UPDATE runs SET chargen_nr = NULL WHERE id = %s", (live_id,))
# Etiketten nur mit derselben Charge. Sonst bleiben sie am Live-Lauf und
# fallen beim Löschen weg, statt eine fremde Nummer zu blockieren.
real_after = get_run_row(conn, real_run_id)
real_chargen = ((real_after or {}).get("chargen_nr") or "").strip()
if live_chargen and real_chargen == live_chargen:
conn.execute(
"""
DELETE FROM run_labels AS src
WHERE src.run_id = %s
AND EXISTS (
SELECT 1 FROM run_labels AS dst
WHERE dst.run_id = %s AND dst.object_name = src.object_name
)
""",
(live_id, real_run_id),
)
conn.execute(
"UPDATE run_labels SET run_id = %s WHERE run_id = %s",
(real_run_id, live_id),
)
conn.execute("DELETE FROM runs WHERE id = %s", (live_id,)) conn.execute("DELETE FROM runs WHERE id = %s", (live_id,))
@@ -1272,26 +1453,36 @@ def add_run_sample(conn, run_id, sample):
INSERT INTO run_samples ( INSERT INTO run_samples (
run_id, sampled_at, nozzle, nozzle_target, bed, bed_target, chamber, run_id, sampled_at, nozzle, nozzle_target, bed, bed_target, chamber,
fan, speed_factor, extrude_factor, layer, progress, fan, speed_factor, extrude_factor, layer, progress,
filament_used_mm, mmu_tool filament_used_mm, mmu_tool, nozzle_power, bed_power
) VALUES ( ) VALUES (
%(run_id)s, COALESCE(%(sampled_at)s, now()), %(nozzle)s, %(nozzle_target)s, %(run_id)s, COALESCE(%(sampled_at)s, now()), %(nozzle)s, %(nozzle_target)s,
%(bed)s, %(bed_target)s, %(chamber)s, %(fan)s, %(speed_factor)s, %(bed)s, %(bed_target)s, %(chamber)s, %(fan)s, %(speed_factor)s,
%(extrude_factor)s, %(layer)s, %(progress)s, %(filament_used_mm)s, %(mmu_tool)s %(extrude_factor)s, %(layer)s, %(progress)s, %(filament_used_mm)s, %(mmu_tool)s,
%(nozzle_power)s, %(bed_power)s
) )
RETURNING id RETURNING id
""", """,
{"run_id": run_id, "sampled_at": sample.get("sampled_at"), **sample}, {
"run_id": run_id,
"sampled_at": sample.get("sampled_at"),
"nozzle": sample.get("nozzle"),
"nozzle_target": sample.get("nozzle_target"),
"bed": sample.get("bed"),
"bed_target": sample.get("bed_target"),
"chamber": sample.get("chamber"),
"fan": sample.get("fan"),
"speed_factor": sample.get("speed_factor"),
"extrude_factor": sample.get("extrude_factor"),
"layer": sample.get("layer"),
"progress": sample.get("progress"),
"filament_used_mm": sample.get("filament_used_mm"),
"mmu_tool": sample.get("mmu_tool"),
"nozzle_power": sample.get("nozzle_power"),
"bed_power": sample.get("bed_power"),
},
).fetchone() ).fetchone()
def last_sample_at(conn, run_id):
row = conn.execute(
"SELECT sampled_at FROM run_samples WHERE run_id = %s ORDER BY sampled_at DESC LIMIT 1",
(run_id,),
).fetchone()
return row["sampled_at"] if row else None
def list_run_samples(conn, run_id, limit=2000): def list_run_samples(conn, run_id, limit=2000):
rows = conn.execute( rows = conn.execute(
""" """
@@ -1318,6 +1509,8 @@ def list_run_samples(conn, run_id, limit=2000):
"progress": row["progress"], "progress": row["progress"],
"filament_used_mm": row["filament_used_mm"], "filament_used_mm": row["filament_used_mm"],
"mmu_tool": row["mmu_tool"], "mmu_tool": row["mmu_tool"],
"nozzle_power": row.get("nozzle_power"),
"bed_power": row.get("bed_power"),
}) })
return out return out
@@ -1384,6 +1577,72 @@ def list_run_segments(conn, run_id):
] ]
def _object_chargen(base, index):
return "%s-%02d" % (base, index)
def _label_owner(conn, code):
return conn.execute(
"""
SELECT l.id, l.run_id, l.chargen_nr, COALESCE(r.chargen_nr, '') AS run_chargen
FROM run_labels l
JOIN runs r ON r.id = l.run_id
WHERE l.chargen_nr = %s
""",
(code,),
).fetchone()
def _claim_object_chargen(conn, base, start, reserved, depth=0):
"""Freie Objekt-Charge ab start. Nummer einer anderen Lauf-Charge wird dorthin umgehängt."""
if depth > 8:
raise RuntimeError("Objekt-Charge lässt sich nicht vergeben")
number = max(1, int(start or 1))
while number < 1000:
code = _object_chargen(base, number)
if code in reserved:
number += 1
continue
owner = _label_owner(conn, code)
if not owner:
return code
owner_base = (owner["run_chargen"] or "").strip()
if owner_base == base:
number += 1
continue
if owner_base:
moved = _claim_object_chargen(conn, owner_base, 1, reserved | {code}, depth + 1)
conn.execute(
"UPDATE run_labels SET chargen_nr = %s WHERE id = %s AND chargen_nr = %s",
(moved, owner["id"], code),
)
if not _label_owner(conn, code):
return code
else:
conn.execute("DELETE FROM run_labels WHERE id = %s AND chargen_nr = %s", (owner["id"], code))
if not _label_owner(conn, code):
return code
number += 1
raise RuntimeError("Keine freie Objekt-Charge")
def _insert_run_label(conn, run_id, name, code):
from psycopg.errors import UniqueViolation
try:
with conn.transaction():
conn.execute(
"""
INSERT INTO run_labels (run_id, object_name, chargen_nr, excluded)
VALUES (%s, %s, %s, false)
""",
(run_id, name, code),
)
return ""
except UniqueViolation as exc:
return (exc.diag.constraint_name if exc.diag else "") or "unique"
def ensure_run_labels(conn, run_id, objects, qty=1): def ensure_run_labels(conn, run_id, objects, qty=1):
"""Objekt- oder Stückzahl-Etiketten anlegen. Charge vom Lauf vorausgesetzt.""" """Objekt- oder Stückzahl-Etiketten anlegen. Charge vom Lauf vorausgesetzt."""
run = get_run_row(conn, run_id) run = get_run_row(conn, run_id)
@@ -1399,6 +1658,7 @@ def ensure_run_labels(conn, run_id, objects, qty=1):
for row in existing for row in existing
if (row["object_name"] or "").strip() if (row["object_name"] or "").strip()
} }
reserved = {(row["chargen_nr"] or "") for row in existing}
names = [] names = []
for item in objects or []: for item in objects or []:
if isinstance(item, dict): if isinstance(item, dict):
@@ -1412,29 +1672,44 @@ def ensure_run_labels(conn, run_id, objects, qty=1):
if not existing and not names: if not existing and not names:
count = max(1, int(qty or 1)) count = max(1, int(qty or 1))
for index in range(count): for index in range(count):
code = base if count == 1 else "%s-%02d" % (base, index + 1) label_name = "" if count == 1 else "Teil %s" % (index + 1)
conn.execute( placed = False
""" for _try in range(5):
INSERT INTO run_labels (run_id, object_name, chargen_nr, excluded) if count == 1 and _try == 0 and base not in reserved and not _label_owner(conn, base):
VALUES (%s, %s, %s, false) code = base
""", else:
(run_id, "" if count == 1 else "Teil %s" % (index + 1), code), code = _claim_object_chargen(conn, base, index + 1 + _try, reserved)
) clash = _insert_run_label(conn, run_id, label_name, code)
if not clash:
reserved.add(code)
placed = True
break
reserved.add(code)
if "object_name" in clash:
break
if not placed and count == 1:
break
elif names: elif names:
index = len(known_names) index = len(known_names)
for name, excluded in names: for name, excluded in names:
if excluded or name in known_names: if excluded or name in known_names:
continue continue
index += 1 index += 1
code = "%s-%02d" % (base, index) placed = False
conn.execute( for _try in range(5):
""" code = _claim_object_chargen(conn, base, index + _try, reserved)
INSERT INTO run_labels (run_id, object_name, chargen_nr, excluded) clash = _insert_run_label(conn, run_id, name, code)
VALUES (%s, %s, %s, false) if not clash:
""", reserved.add(code)
(run_id, name, code),
)
known_names.add(name) known_names.add(name)
placed = True
break
reserved.add(code)
if "object_name" in clash:
known_names.add(name)
break
if not placed:
continue
rows = conn.execute( rows = conn.execute(
"SELECT * FROM run_labels WHERE run_id = %s ORDER BY id", "SELECT * FROM run_labels WHERE run_id = %s ORDER BY id",
(run_id,), (run_id,),
@@ -1477,10 +1752,6 @@ def list_run_events(conn, run_id, limit=50):
return [event_out(row) for row in rows] return [event_out(row) for row in rows]
def attach_event_run(conn, event_id, run_id):
conn.execute("UPDATE events SET run_id = %s WHERE id = %s", (run_id, event_id))
def exclusive_until(until): def exclusive_until(until):
"""Reines Datum YYYY-MM-DD zählt inklusiv: Grenze = Folgetag.""" """Reines Datum YYYY-MM-DD zählt inklusiv: Grenze = Folgetag."""
if until is None or until == "": if until is None or until == "":
@@ -1618,6 +1889,18 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
run_values, run_values,
).fetchone() ).fetchone()
scrap_reasons = conn.execute(
"""
SELECT COALESCE(NULLIF(r.verdict_reason, ''), 'sonstiges') AS reason,
COUNT(*) AS n
FROM runs r
WHERE %s AND r.verdict = 'ausschuss'
GROUP BY 1
ORDER BY n DESC, reason
""" % run_clause,
run_values,
).fetchall()
ov = overview or {} ov = overview or {}
# Bei Spulen-Join können SUM(DISTINCT …) ungenau sein — Überblick ohne Join neu # Bei Spulen-Join können SUM(DISTINCT …) ungenau sein — Überblick ohne Join neu
if spool_join: if spool_join:
@@ -1688,6 +1971,10 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
], ],
"estimate_ratio": float((estimate or {}).get("median_ratio") or 0) or None, "estimate_ratio": float((estimate or {}).get("median_ratio") or 0) or None,
"estimate_count": int((estimate or {}).get("counted") or 0), "estimate_count": int((estimate or {}).get("counted") or 0),
"scrap_reasons": [
{"reason": row["reason"] or "sonstiges", "count": int(row["n"] or 0)}
for row in scrap_reasons
],
} }
@@ -1748,6 +2035,14 @@ def delete_idea(conn, idea_id):
def printer_out(row): def printer_out(row):
family = row["family"] or "" family = row["family"] or ""
sw = row.get("start_window") or {}
if isinstance(sw, str):
try:
sw = json.loads(sw)
except json.JSONDecodeError:
sw = {}
if not isinstance(sw, dict):
sw = {}
return { return {
"id": str(row["id"]), "id": str(row["id"]),
"name": row["name"], "name": row["name"],
@@ -1755,6 +2050,7 @@ def printer_out(row):
"family": family, "family": family,
"family_label": FAMILY_LABELS.get(family, ""), "family_label": FAMILY_LABELS.get(family, ""),
"locked": bool(row.get("locked")), "locked": bool(row.get("locked")),
"start_window": sw,
} }
@@ -2018,6 +2314,7 @@ def print_queue_out(row, file_name=None):
"phase": row.get("phase") or "", "phase": row.get("phase") or "",
"phase_started_at": iso(row.get("phase_started_at")), "phase_started_at": iso(row.get("phase_started_at")),
"is_ablauf": bool(steps), "is_ablauf": bool(steps),
"order_item_id": row.get("order_item_id"),
"created_at": iso(row["created_at"]), "created_at": iso(row["created_at"]),
"updated_at": iso(row["updated_at"]), "updated_at": iso(row["updated_at"]),
} }
@@ -2035,6 +2332,7 @@ def enqueue_print(
override, override,
steps=None, steps=None,
repeat_total=1, repeat_total=1,
order_item_id=None,
): ):
steps = steps if isinstance(steps, list) else [] steps = steps if isinstance(steps, list) else []
try: try:
@@ -2046,11 +2344,11 @@ def enqueue_print(
INSERT INTO print_queue ( INSERT INTO print_queue (
file_id, host, family, priority, spool_id, ace_map, file_id, host, family, priority, spool_id, ace_map,
bed_leveling, override_machine, status, message, bed_leveling, override_machine, status, message,
steps, step_index, repeat_total, repeat_done, phase steps, step_index, repeat_total, repeat_done, phase, order_item_id
) )
VALUES ( VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, 'waiting', %s, %s, %s, %s, %s, %s, %s, %s, %s, 'waiting', %s,
%s, 0, %s, 0, '' %s, 0, %s, 0, '', %s
) )
RETURNING * RETURNING *
""", """,
@@ -2066,6 +2364,7 @@ def enqueue_print(
"Ablauf wartet…" if steps else "Wartet auf freien Drucker…", "Ablauf wartet…" if steps else "Wartet auf freien Drucker…",
Json(steps), Json(steps),
repeats, repeats,
order_item_id,
), ),
).fetchone() ).fetchone()
file_row = get_file_row(conn, file_id) file_row = get_file_row(conn, file_id)
@@ -2345,7 +2644,8 @@ def ensure_bed_after_print(conn, host, ended_at=None, message=""):
def user_count(conn): def user_count(conn):
return int(conn.execute("SELECT count(*) AS n FROM users").fetchone()["n"]) from grotti.db import cached_user_count
return cached_user_count(conn)
def list_users(conn): def list_users(conn):
+72
View File
@@ -0,0 +1,72 @@
"""Start-Zeitfenster pro Drucker (Nachtdruck)."""
from __future__ import annotations
from datetime import datetime, time, timedelta
def _parse_hm(text):
text = str(text or "").strip()
if not text or ":" not in text:
return None
parts = text.split(":", 1)
try:
h, m = int(parts[0]), int(parts[1])
except (TypeError, ValueError):
return None
if not (0 <= h <= 23 and 0 <= m <= 59):
return None
return time(h, m)
def _in_segment(now_t, start_t, end_t):
if start_t <= end_t:
return start_t <= now_t <= end_t
return now_t >= start_t or now_t <= end_t
def allowed_now(start_window, now=None):
"""True wenn kein Fenster gesetzt oder die aktuelle Uhrzeit erlaubt ist."""
window = start_window or {}
if not isinstance(window, dict):
return True
allowed = window.get("allowed")
if not allowed:
return True
if not isinstance(allowed, list):
return True
now_dt = now if isinstance(now, datetime) else datetime.now().astimezone()
now_t = now_dt.timetz().replace(tzinfo=None)
for entry in allowed:
if not isinstance(entry, dict):
continue
start_t = _parse_hm(entry.get("from"))
end_t = _parse_hm(entry.get("to"))
if start_t is None or end_t is None:
continue
if _in_segment(now_t, start_t, end_t):
return True
return False
def overnight_max_hours(start_window):
window = start_window or {}
if not isinstance(window, dict):
return None
try:
val = float(window.get("max_hours_overnight") or 0)
except (TypeError, ValueError):
return None
return val if val > 0 else None
def job_fits_overnight(start_window, estimated_s, now=None):
"""Optional: Job darf max_hours_overnight nicht überschreiten (nur wenn gesetzt)."""
max_h = overnight_max_hours(start_window)
if max_h is None:
return True
if not estimated_s or float(estimated_s) <= 0:
return True
if allowed_now(start_window, now=now):
return float(estimated_s) <= max_h * 3600
return True
+33
View File
@@ -0,0 +1,33 @@
"""Urteil und Ausschuss-Gründe beim Abräumen."""
VERDICT_REASONS = frozenset({
"haftung",
"warping",
"spaghetti",
"layer_shift",
"verstopfung",
"farbe",
"sonstiges",
})
def normalize_abraeumen_verdict(verdict, verdict_reason, pieces_ok, pieces_bad):
v = (verdict or "").strip().lower()
if not v:
v = "gut"
if v not in ("gut", "ausschuss"):
raise ValueError("Urteil muss gut oder ausschuss sein.")
reason = (verdict_reason or "").strip().lower()
if v == "gut":
reason = ""
pieces_bad = 0
elif reason and reason not in VERDICT_REASONS:
raise ValueError("Unbekannter Ausschuss-Grund.")
try:
ok_n = max(0, int(pieces_ok or 0))
bad_n = max(0, int(pieces_bad or 0))
except (TypeError, ValueError) as exc:
raise ValueError("Stückzahlen müssen ganze Zahlen sein.") from exc
if v == "ausschuss" and bad_n <= 0 and ok_n <= 0:
bad_n = 1
return v, reason, ok_n, bad_n
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen.""" """Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
__version__ = "2.5.1" __version__ = "3.0.0"
+87 -15
View File
@@ -7,9 +7,11 @@ import time
from datetime import datetime, timezone from datetime import datetime, timezone
from grotti import moonraker, notify, store, usage from grotti import moonraker, notify, store, usage
from grotti.events_bus import publish as publish_live
from grotti.db import connect from grotti.db import connect
SAMPLE_EVERY_S = 30 SAMPLE_EVERY_S = 30
SAMPLE_HEAT_S = 10
class StateWatcher: class StateWatcher:
@@ -61,6 +63,7 @@ class StateWatcher:
self._stop.wait(self.interval) self._stop.wait(self.interval)
def tick(self): def tick(self):
try:
with connect(self.database_url) as conn: with connect(self.database_url) as conn:
printers = store.list_printers(conn) printers = store.list_printers(conn)
if not printers: if not printers:
@@ -73,6 +76,8 @@ class StateWatcher:
for live in lives: for live in lives:
self._one(conn, live) self._one(conn, live)
conn.commit() conn.commit()
finally:
publish_live("drucker", {"at": datetime.now(timezone.utc).isoformat()})
def _one(self, conn, live): def _one(self, conn, live):
host = live.get("host") or "" host = live.get("host") or ""
@@ -100,9 +105,28 @@ class StateWatcher:
and state == "printing" and state == "printing"
) )
if starting: if starting:
store.abandon_live_runs(conn, host) # Aufheiz-Proben behalten: Live-Lauf gleicher Datei nicht verwerfen
if state in ("printing", "paused"): keep = None
run = self._track_print(conn, host, name, filename, live, state) existing = store.find_live_run(conn, host)
if existing:
same = (
not filename
or not existing.get("filename")
or existing.get("filename") == filename
)
if same:
keep = existing["id"]
if filename and existing.get("filename") != filename:
conn.execute(
"UPDATE runs SET filename = %s WHERE id = %s",
(filename, keep),
)
store.abandon_live_runs(conn, host, keep_id=keep)
sample_now = self._should_sample(conn, host, live, state)
heating = sample_now and state not in ("printing", "paused")
if sample_now:
force = starting or (heating and not prev.get("heating"))
run = self._track_print(conn, host, name, filename, live, state, force_sample=force)
run_id = run["id"] if run else None run_id = run["id"] if run else None
if prev_offline: if prev_offline:
@@ -187,12 +211,39 @@ class StateWatcher:
self._prev[host] = { self._prev[host] = {
"state": state, "state": state,
"offline": False, "offline": False,
"heating": heating,
"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), "run_id": run_id or (prev.get("run_id") if state in ("printing", "paused") or heating else None),
} }
def _track_print(self, conn, host, name, filename, live, state): def _should_sample(self, conn, host, live, state):
"""Proben bei Druck/Pause und beim Aufheizen mit Bezug zu einem Auftrag."""
if state in ("printing", "paused"):
return True
if state in ("error", "offline", ""):
return False
nozzle_t = usage.as_float(live.get("nozzle_target"))
bed_t = usage.as_float(live.get("bed_target"))
if nozzle_t <= 40 and bed_t <= 30:
return False
if live.get("filename"):
return True
if store.running_print_on_host(conn, host):
return True
return bool(store.find_live_run(conn, host))
def _track_print(self, conn, host, name, filename, live, state, force_sample=False):
if not filename:
running = store.running_print_on_host(conn, host)
if running:
filename = running.get("filename") or ""
if not filename:
existing = store.find_live_run(conn, host)
if existing:
filename = existing.get("filename") or ""
if not filename and state not in ("printing", "paused"):
return None
sent = store.latest_send(conn, host, filename) if filename else None sent = store.latest_send(conn, host, filename) if filename else None
spool_id = (sent or {}).get("spool_id") spool_id = (sent or {}).get("spool_id")
run = store.ensure_live_run(conn, host, name, filename, spool_id=spool_id) run = store.ensure_live_run(conn, host, name, filename, spool_id=spool_id)
@@ -214,7 +265,7 @@ class StateWatcher:
"duration_s": usage.as_float(live.get("elapsed")), "duration_s": usage.as_float(live.get("elapsed")),
}) })
store.ensure_run_chargen(conn, run["id"]) store.ensure_run_chargen(conn, run["id"])
self._maybe_sample(conn, run["id"], live, used_mm) self._maybe_sample(conn, run["id"], live, used_mm, force=force_sample)
return store.get_run_row(conn, run["id"]) return store.get_run_row(conn, run["id"])
def _merge_objects(self, current, exclude): def _merge_objects(self, current, exclude):
@@ -243,10 +294,21 @@ class StateWatcher:
known[name] = {"name": name, "excluded": True} known[name] = {"name": name, "excluded": True}
return list(known.values()) return list(known.values())
def _maybe_sample(self, conn, run_id, live, used_mm): def _heat_gap(self, live):
"""Abstand Ist↔Soll — während Aufheizen dichter samplen."""
gaps = []
for key, tkey in (("nozzle", "nozzle_target"), ("bed", "bed_target")):
target = usage.as_float(live.get(tkey))
if target <= 0:
continue
gaps.append(abs(usage.as_float(live.get(key)) - target))
return max(gaps) if gaps else 0
def _maybe_sample(self, conn, run_id, live, used_mm, force=False):
now = time.monotonic() now = time.monotonic()
last = self._last_sample.get(run_id, 0) last = self._last_sample.get(run_id, 0)
if now - last < SAMPLE_EVERY_S: interval = SAMPLE_HEAT_S if self._heat_gap(live) > 3 else SAMPLE_EVERY_S
if not force and now - last < interval:
return return
self._last_sample[run_id] = now self._last_sample[run_id] = now
store.add_run_sample(conn, run_id, { store.add_run_sample(conn, run_id, {
@@ -263,6 +325,8 @@ class StateWatcher:
"progress": live.get("progress"), "progress": live.get("progress"),
"filament_used_mm": used_mm, "filament_used_mm": used_mm,
"mmu_tool": live.get("mmu_tool"), "mmu_tool": live.get("mmu_tool"),
"nozzle_power": live.get("nozzle_power"),
"bed_power": live.get("bed_power"),
}) })
def _ensure_start_segment(self, conn, host, filename, live, run_id): def _ensure_start_segment(self, conn, host, filename, live, run_id):
@@ -302,22 +366,29 @@ class StateWatcher:
if need <= 0 or not filename: if need <= 0 or not filename:
return False return False
sent = store.latest_send(conn, host, filename) sent = store.latest_send(conn, host, filename)
ace = (sent or {}).get("ace_map") or []
from grotti import jobs as grotti_jobs
at_host = grotti_jobs.resolve_spools_at_host(conn, host, ace)
if not at_host:
spool_id = (sent or {}).get("spool_id") spool_id = (sent or {}).get("spool_id")
if not spool_id: if spool_id:
row = store.get_spool_row(conn, spool_id)
if row:
at_host = [row]
if not at_host:
return False return False
spool = store.get_spool_row(conn, spool_id) left = sum(float(s.get("remaining_g") or 0) for s in at_host)
if not spool:
return False
left = float(spool["remaining_g"] or 0)
if left + 0.5 >= need: if left + 0.5 >= need:
return False return False
label = " / ".join(s.get("name") or "?" for s in at_host[:3])
self._emit( self._emit(
conn, conn,
host, host,
name, name,
"spule", "spule",
"%s: Spule „%s“ hat nur %.0f g, Druck braucht %.0f g." "%s: %s hat nur %.0f g, Druck braucht %.0f g."
% (name, spool["name"], left, need), % (name, label, left, need),
filename, filename,
) )
return True return True
@@ -327,3 +398,4 @@ class StateWatcher:
conn, self.cfg, host, printer, kind, message, filename, conn, self.cfg, host, printer, kind, message, filename,
dedupe_s=60, run_id=run_id, dedupe_s=60, run_id=run_id,
) )
publish_live("ereignis", {})
+2
View File
@@ -0,0 +1,2 @@
-r requirements.txt
pytest==9.1.1
+7 -5
View File
@@ -1,6 +1,8 @@
flask>=3.1 flask==3.1.3
psycopg[binary]>=3.2 psycopg[binary]==3.3.6
paho-mqtt>=2.1 psycopg-pool==3.3.3
cryptography>=42 paho-mqtt==2.1.0
markdown>=3.6 cryptography==50.0.2
markdown==3.11.1
mcp>=1.2 mcp>=1.2
waitress==3.0.2
+1
View File
@@ -0,0 +1 @@
"""Hilfsskripte (Orca-Nachbearbeitung u. a.)."""
+332
View File
@@ -0,0 +1,332 @@
#!/usr/bin/env python3
"""Orca/Anycubic-Nachbearbeitung: G-code nach Grotti laden und Senden-Seite öffnen.
Wenn SLIC3R_PP_OUTPUT_NAME unter dem Slice-Ordner liegt (auch Unterordner),
wird die Datei dorthin geschrieben und der Watch neu gelesen — nicht als Upload.
Sonst: Upload über die API.
Config: data/orca-senden.json mit url und token (grt_…).
"""
from __future__ import annotations
import json
import mimetypes
import os
import shutil
import sys
import uuid
import webbrowser
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
REPO_ROOT = Path(__file__).resolve().parent.parent
CONFIG_PATH = REPO_ROOT / "data" / "orca-senden.json"
DEBUG_LOG = REPO_ROOT / "data" / "orca-senden-last.log"
ALLOWED_SUFFIXES = (".gcode", ".gcode.3mf", ".3mf", ".gcode.pp", ".pp")
def show_error(message: str) -> None:
text = str(message or "Unbekannter Fehler.")
if sys.platform == "win32":
try:
import ctypes
ctypes.windll.user32.MessageBoxW(0, text, "Grotti · Orca senden", 0x10)
return
except Exception:
pass
print(text, file=sys.stderr)
def write_debug(argv: list[str], extra: str = "") -> None:
try:
DEBUG_LOG.parent.mkdir(parents=True, exist_ok=True)
lines = [
"argv:",
*[repr(item) for item in argv],
"",
"env:",
"SLIC3R_PP_HOST=%s" % (os.environ.get("SLIC3R_PP_HOST") or ""),
"SLIC3R_PP_OUTPUT_NAME=%s" % (os.environ.get("SLIC3R_PP_OUTPUT_NAME") or ""),
"",
extra,
]
DEBUG_LOG.write_text("\n".join(lines), encoding="utf-8")
except OSError:
pass
def load_config(path: Path = CONFIG_PATH) -> dict[str, str]:
if not path.is_file():
raise FileNotFoundError(
"Config fehlt: %s\nLege url und token (Einstellungen → API-Token) an." % path
)
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ValueError("Config ungültig: %s" % exc) from exc
if not isinstance(data, dict):
raise ValueError("Config muss ein JSON-Objekt sein.")
url = str(data.get("url") or "").strip().rstrip("/")
token = str(data.get("token") or "").strip()
if not url:
raise ValueError("Config: url fehlt (z. B. http://127.0.0.1:8770).")
if not token:
raise ValueError("Config: token fehlt (grt_… aus Einstellungen).")
return {"url": url, "token": token}
def is_gcode_path(path: Path) -> bool:
name = path.name.lower()
return any(name.endswith(suffix) for suffix in ALLOWED_SUFFIXES)
def output_target() -> Path | None:
env_name = (os.environ.get("SLIC3R_PP_OUTPUT_NAME") or "").strip().strip('"')
if not env_name:
return None
return Path(env_name)
def upload_name_for(path: Path) -> str:
"""Schöner Dateiname: SLIC3R_PP_OUTPUT_NAME, sonst Pfad ohne .pp."""
target = output_target()
if target is not None and target.name:
base = target.name
if not is_gcode_path(Path(base)) and path.name.lower().endswith(".pp"):
if not base.lower().endswith((".gcode", ".3mf")):
base = base + ".gcode"
return base
name = path.name
if name.lower().endswith(".pp"):
name = name[:-3]
if not name.lower().endswith((".gcode", ".3mf")):
name = name + ".gcode"
return name
def find_gcode_arg(argv: list[str]) -> Path:
"""Pfad aus argv oder SLIC3R_PP_OUTPUT_NAME; Temp-Dateien enden oft auf .pp."""
candidates: list[Path] = []
for raw in argv[1:]:
text = (raw or "").strip().strip('"')
if not text or text.startswith("-"):
continue
candidates.append(Path(text))
for candidate in reversed(candidates):
if not is_gcode_path(candidate):
continue
if candidate.is_file():
return candidate.resolve()
raise FileNotFoundError("Datei fehlt: %s" % candidate)
for candidate in reversed(candidates):
if candidate.is_file():
return candidate.resolve()
env_path = output_target()
if env_path is not None and env_path.is_file():
return env_path.resolve()
detail = "argv=%s" % (argv[1:] or ["(leer)"])
raise ValueError(
"Kein G-code-Pfad in den Argumenten.\n"
"G-code exportieren (nicht nur slicen).\n"
"%s\nDetails: data/orca-senden-last.log" % detail
)
def send_page_url(base_url: str, file_id: int) -> str:
return "%s/dateien?file=%s" % (base_url.rstrip("/"), int(file_id))
def _api_json(
base_url: str,
token: str,
method: str,
path: str,
*,
data: bytes | None = None,
headers: dict[str, str] | None = None,
timeout: float = 120.0,
) -> Any:
hdrs = {
"Authorization": "Bearer %s" % token,
"Accept": "application/json",
}
if headers:
hdrs.update(headers)
request = Request(
"%s/api/v1%s" % (base_url.rstrip("/"), path),
data=data,
method=method,
headers=hdrs,
)
try:
with urlopen(request, timeout=timeout) as response:
raw = response.read()
if not raw:
return None
return json.loads(raw.decode("utf-8"))
except HTTPError as exc:
detail = ""
try:
detail = json.loads(exc.read().decode("utf-8")).get("error") or ""
except Exception:
detail = str(exc.reason or exc)
raise RuntimeError("API %s fehlgeschlagen (%s): %s" % (path, exc.code, detail or exc)) from exc
except URLError as exc:
raise RuntimeError(
"Grotti nicht erreichbar (%s). Läuft der Server?" % (exc.reason or exc)
) from exc
def fetch_watch_dir(base_url: str, token: str) -> Path | None:
payload = _api_json(base_url, token, "GET", "/einstellungen")
if not isinstance(payload, dict):
return None
text = str(payload.get("watch_dir") or "").strip()
if not text:
return None
path = Path(text)
return path if path.is_dir() else None
def under_watch(watch_dir: Path, target: Path) -> tuple[str, str] | None:
"""(folder_relativ, dateiname) wenn target unter watch_dir liegt."""
try:
rel = target.resolve().relative_to(watch_dir.resolve())
except ValueError:
return None
parts = rel.parts
if not parts:
return None
name = parts[-1]
folder = "/".join(parts[:-1]) if len(parts) > 1 else ""
return folder, name
def place_in_watch(src: Path, dest: Path) -> Path:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(str(src), str(dest))
return dest.resolve()
def rescan(base_url: str, token: str) -> None:
_api_json(base_url, token, "POST", "/dateien/neu-lesen", data=b"{}", headers={"Content-Type": "application/json"})
def find_library_file(base_url: str, token: str, *, name: str, folder: str) -> dict[str, Any] | None:
rows = _api_json(base_url, token, "GET", "/dateien")
if not isinstance(rows, list):
return None
folder_key = folder or ""
matches = []
for row in rows:
if not isinstance(row, dict):
continue
if (row.get("name") or "") != name:
continue
row_folder = row.get("folder") or ""
if row.get("source") == "watch":
if row_folder == folder_key or (not folder_key and row_folder in ("", None)):
matches.append(row)
elif not folder_key and row_folder in ("", "Upload"):
matches.append(row)
if not matches:
# Name reicht, neueste ID
matches = [row for row in rows if isinstance(row, dict) and (row.get("name") or "") == name]
if not matches:
return None
return max(matches, key=lambda row: int(row.get("id") or 0))
def _multipart_body(filename: str, content: bytes, field: str = "file") -> tuple[bytes, str]:
boundary = "----GrottiOrca%s" % uuid.uuid4().hex
guessed = mimetypes.guess_type(filename)[0] or "application/octet-stream"
head = (
"--%s\r\n"
'Content-Disposition: form-data; name="%s"; filename="%s"\r\n'
"Content-Type: %s\r\n"
"\r\n"
) % (boundary, field, filename.replace('"', ""), guessed)
tail = "\r\n--%s--\r\n" % boundary
body = head.encode("utf-8") + content + tail.encode("ascii")
return body, "multipart/form-data; boundary=%s" % boundary
def upload_file(
base_url: str,
token: str,
path: Path,
filename: str | None = None,
timeout: float = 120.0,
) -> dict[str, Any]:
content = path.read_bytes()
name = filename or upload_name_for(path)
body, content_type = _multipart_body(name, content)
payload = _api_json(
base_url,
token,
"POST",
"/dateien",
data=body,
headers={"Content-Type": content_type},
timeout=timeout,
)
if not isinstance(payload, dict) or "id" not in payload:
raise RuntimeError("Unerwartete Antwort vom Upload.")
return payload
def open_browser(url: str) -> None:
webbrowser.open(url)
def deliver(base_url: str, token: str, src: Path) -> int:
"""Datei in Slice-Unterordner legen oder hochladen; gibt file_id zurück."""
watch = fetch_watch_dir(base_url, token)
target = output_target()
if watch is not None and target is not None:
placed = under_watch(watch, target)
if placed is not None:
folder, name = placed
dest = place_in_watch(src, target if target.suffix else target.with_suffix(".gcode"))
# Name aus Zielpfad, falls ohne Endung
if dest.name:
name = dest.name
rescan(base_url, token)
found = find_library_file(base_url, token, name=name, folder=folder)
if not found:
raise RuntimeError(
"Datei liegt unter %s, aber Grotti kennt sie noch nicht. "
"Unter Dateien „Ordner neu lesen“." % dest
)
return int(found["id"])
saved = upload_file(base_url, token, src, filename=upload_name_for(src))
return int(saved["id"])
def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv if argv is None else argv)
write_debug(argv)
try:
config = load_config()
path = find_gcode_arg(argv)
write_debug(
argv,
"chosen=%s\nupload_as=%s\noutput=%s"
% (path, upload_name_for(path), output_target() or ""),
)
file_id = deliver(config["url"], config["token"], path)
write_debug(argv, "file_id=%s" % file_id)
open_browser(send_page_url(config["url"], file_id))
except Exception as exc:
write_debug(argv, "error=%s" % exc)
show_error(str(exc))
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+73 -6
View File
@@ -1,19 +1,26 @@
"""Grotti starten. Kern und Seiten liegen daneben.""" """Grotti starten. Kern und Seiten liegen daneben."""
import atexit
import signal
import threading import threading
import time
from grotti import jobs, library, store from waitress import serve
from grotti import db, jobs, library, store
from grotti.config import load from grotti.config import load
from grotti.db import connect, prepare
from web.app import create_app from web.app import create_app
def background(cfg): def background(cfg):
def work(): def work():
try: try:
with connect(cfg["database_url"]) as conn: with db.connect(cfg["database_url"]) as conn:
library.sync_watch(conn, cfg, force=False) library.sync_watch(conn, cfg, force=False)
jobs.sync_history(conn, cfg) jobs.sync_history(conn, cfg)
deleted = db.prune_run_samples(conn, keep_days=90)
if deleted:
print("Proben gelöscht: %s" % deleted, flush=True)
conn.commit() conn.commit()
except Exception as exc: except Exception as exc:
print("Abgleich: %s" % exc, flush=True) print("Abgleich: %s" % exc, flush=True)
@@ -21,19 +28,79 @@ def background(cfg):
threading.Thread(target=work, name="grotti-abgleich", daemon=True).start() threading.Thread(target=work, name="grotti-abgleich", daemon=True).start()
def _retention_loop(cfg, stop_event):
"""Nächtlich alte run_samples entfernen."""
while not stop_event.wait(3600):
hour = time.localtime().tm_hour
if hour != 3:
continue
try:
with db.connect(cfg["database_url"]) as conn:
n = db.prune_run_samples(conn, keep_days=90)
conn.commit()
if n:
print("Retention: %s Proben gelöscht." % n, flush=True)
except Exception as exc:
print("Retention: %s" % exc, flush=True)
stop_event.wait(3600)
def main(): def main():
cfg = load() cfg = load()
cfg["data_dir"].mkdir(parents=True, exist_ok=True) cfg["data_dir"].mkdir(parents=True, exist_ok=True)
(cfg["data_dir"] / "eingang").mkdir(exist_ok=True) (cfg["data_dir"] / "eingang").mkdir(exist_ok=True)
prepare(cfg["database_url"]) db.prepare(cfg["database_url"])
with connect(cfg["database_url"]) as conn: db.init_pool(cfg["database_url"], min_size=2, max_size=16)
with db.connect(cfg["database_url"]) as conn:
store.seed_printers(conn, cfg["printers_file"]) store.seed_printers(conn, cfg["printers_file"])
store.seed_materials(conn) store.seed_materials(conn)
conn.commit() conn.commit()
app = create_app(cfg) app = create_app(cfg)
watcher = app.extensions["watcher"]
dispatcher = app.extensions["print_queue"]
send_queue = app.extensions["send_queue"]
watcher.start()
dispatcher.start()
if hasattr(send_queue, "start"):
send_queue.start()
stop_event = threading.Event()
retention = threading.Thread(
target=_retention_loop,
args=(cfg, stop_event),
name="grotti-retention",
daemon=True,
)
retention.start()
background(cfg) background(cfg)
def shutdown(*_args):
stop_event.set()
try:
watcher.stop()
except Exception:
pass
try:
dispatcher.stop()
except Exception:
pass
try:
if hasattr(send_queue, "stop"):
send_queue.stop()
except Exception:
pass
db.close_pool()
atexit.register(shutdown)
for sig in (signal.SIGINT, signal.SIGTERM):
try:
signal.signal(sig, lambda *_: shutdown())
except (ValueError, OSError):
pass
print("Grotti: http://%s:%s/" % (cfg["host"], cfg["port"]), flush=True) print("Grotti: http://%s:%s/" % (cfg["host"], cfg["port"]), flush=True)
app.run(host=cfg["host"], port=cfg["port"], threaded=True, use_reloader=False) serve(app, host=cfg["host"], port=cfg["port"], threads=16, channel_timeout=120)
if __name__ == "__main__": if __name__ == "__main__":
View File
Whitespace-only changes.
+27
View File
@@ -0,0 +1,27 @@
"""Urteil beim Abräumen (reine Hilfslogik ohne DB)."""
import unittest
from grotti.verdict import normalize_abraeumen_verdict
class AbraeumenVerdictTests(unittest.TestCase):
def test_gut_clears_reason(self):
v, reason, ok, bad = normalize_abraeumen_verdict("gut", "warping", 2, 1)
self.assertEqual(v, "gut")
self.assertEqual(reason, "")
self.assertEqual(bad, 0)
def test_ausschuss_requires_known_reason_if_set(self):
with self.assertRaises(ValueError):
normalize_abraeumen_verdict("ausschuss", "xyz", 0, 0)
def test_ausschuss_defaults_bad_piece(self):
v, reason, ok, bad = normalize_abraeumen_verdict("ausschuss", "spaghetti", 0, 0)
self.assertEqual(v, "ausschuss")
self.assertEqual(reason, "spaghetti")
self.assertEqual(bad, 1)
if __name__ == "__main__":
unittest.main()
+70
View File
@@ -0,0 +1,70 @@
"""Filament-Bedarf aus Queue und Spulen."""
import unittest
from unittest.mock import MagicMock
from grotti.bedarf import compute
class BedarfTests(unittest.TestCase):
def _rows(self, *lists):
mocks = []
for rows in lists:
m = MagicMock()
m.fetchall.return_value = rows
mocks.append(m)
return mocks
def test_shortfall_when_queue_exceeds_stock(self):
conn = MagicMock()
conn.execute.side_effect = self._rows(
[
{
"id": 1,
"spool_id": 10,
"repeat_total": 2,
"repeat_done": 0,
"weight_g": 50.0,
"file_material": "PLA",
"file_color": "Schwarz",
"spool_material": "PLA",
"spool_color": "Schwarz",
},
],
[
{"material_type": "PLA", "color": "Schwarz", "remaining_g": 40.0},
],
)
out = compute(conn)
self.assertEqual(len(out["needed"]), 1)
self.assertAlmostEqual(out["needed"][0]["weight_g"], 100.0)
self.assertEqual(len(out["shortfall"]), 1)
self.assertAlmostEqual(out["shortfall"][0]["shortfall_g"], 60.0)
def test_no_shortfall_when_enough_filament(self):
conn = MagicMock()
conn.execute.side_effect = self._rows(
[
{
"id": 1,
"spool_id": None,
"repeat_total": 1,
"repeat_done": 0,
"weight_g": 20.0,
"file_material": "PETG",
"file_color": "",
"spool_material": None,
"spool_color": None,
},
],
[
{"material_type": "PETG", "color": "", "remaining_g": 500.0},
],
)
out = compute(conn)
self.assertEqual(out["shortfall"], [])
if __name__ == "__main__":
unittest.main()
+49
View File
@@ -0,0 +1,49 @@
"""Slice-Ordner (watch_dir) in config."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from grotti import config
from grotti.errors import GrottiError
class WatchDirTest(unittest.TestCase):
def test_empty(self):
path, stored = config.parse_watch_dir("")
self.assertIsNone(path)
self.assertEqual(stored, "")
def test_relative_under_root(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
target = root / "slice"
target.mkdir()
with patch.object(config, "ROOT", root):
path, stored = config.parse_watch_dir("slice", create=False)
self.assertEqual(path, target.resolve())
self.assertEqual(stored, "slice")
def test_create(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
with patch.object(config, "ROOT", root):
path, stored = config.parse_watch_dir("slice", create=True)
self.assertTrue(path.is_dir())
self.assertEqual(stored, "slice")
def test_file_rejected(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
file_path = root / "not-a-dir"
file_path.write_text("x", encoding="utf-8")
with patch.object(config, "ROOT", root):
with self.assertRaises(GrottiError):
config.parse_watch_dir(str(file_path), create=False)
if __name__ == "__main__":
unittest.main()
+51
View File
@@ -0,0 +1,51 @@
"""ED-Stromschätzung."""
import unittest
from datetime import datetime, timezone
from grotti.cost import energy_estimate
def sample_at(offset_s, np=None, bp=None):
base = datetime(2026, 10, 17, 15, 0, tzinfo=timezone.utc).timestamp()
at = datetime.fromtimestamp(base + offset_s, tz=timezone.utc).isoformat()
return {"sampled_at": at, "nozzle_power": np, "bed_power": bp}
class EnergyEdTests(unittest.TestCase):
def test_flat_without_heater_watts(self):
est = energy_estimate(3600, {"watts": 100, "euro_per_kwh": 0.5}, [
sample_at(0, np=1.0, bp=1.0),
sample_at(1800, np=1.0, bp=1.0),
])
self.assertEqual(est["source"], "flat")
self.assertAlmostEqual(est["kwh"], 0.1)
self.assertAlmostEqual(est["cost"], 0.05)
def test_ed_caps_long_gaps(self):
# 60 min Lücke → nur 90 s werden gezählt
samples = [sample_at(0, np=0.5, bp=0.0), sample_at(3600, np=0.5, bp=0.0)]
est = energy_estimate(3600, {
"watts": 0,
"nozzle_watts": 100,
"bed_watts": 0,
"euro_per_kwh": 1,
}, samples)
self.assertEqual(est["source"], "ed")
self.assertAlmostEqual(est["print_kwh"], (100 * 0.5 / 1000) * (90 / 3600), places=6)
def test_ed_dense_samples_full_hour(self):
samples = [sample_at(i * 30, np=1.0, bp=0.0) for i in range(0, 121)]
est = energy_estimate(3600, {
"watts": 0,
"nozzle_watts": 200,
"bed_watts": 0,
"euro_per_kwh": 2,
}, samples)
self.assertEqual(est["source"], "ed")
self.assertAlmostEqual(est["print_kwh"], 0.2, places=3)
self.assertAlmostEqual(est["cost"], 0.4, places=3)
if __name__ == "__main__":
unittest.main()
+4 -1
View File
@@ -61,7 +61,10 @@ class EjectTests(unittest.TestCase):
self.assertFalse(eject.cool_parked({"position": [250, 220, 50]}, before_z=10, lift_mm=40)) self.assertFalse(eject.cool_parked({"position": [250, 220, 50]}, before_z=10, lift_mm=40))
def test_sweep_does_not_lower_before_back(self): def test_sweep_does_not_lower_before_back(self):
script = eject.sweep_probe_height_script(eject.normalize_sweep({})) sweep = eject.normalize_sweep({})
back = eject.sweep_go_back_high_script(sweep)
lower = eject.sweep_lower_to_push_script(sweep)
script = back + "\n" + lower
# Y hinten (Default 250) vor dem Senken auf z_push # Y hinten (Default 250) vor dem Senken auf z_push
y_idx = script.find("Y250") y_idx = script.find("Y250")
z_push_idx = script.find("Z2.00") z_push_idx = script.find("Z2.00")
+65
View File
@@ -0,0 +1,65 @@
"""Unit-Tests für Spulenwahl und Steuer-Parameter-Aliase."""
import unittest
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
from grotti import jobs
from grotti import moonraker
class ChooseSpoolTests(unittest.TestCase):
def test_rejects_spool_created_after_print_end(self):
end = datetime(2026, 10, 1, 12, 0, tzinfo=timezone.utc)
old = {
"id": 1,
"material_type": "PLA",
"created_at": end - timedelta(days=2),
}
new = {
"id": 2,
"material_type": "PLA",
"created_at": end + timedelta(hours=1),
}
conn = MagicMock()
with patch("grotti.jobs.store.spools_for_printer", return_value=[new]):
with patch("grotti.jobs.store.get_spool_row", return_value=new):
self.assertIsNone(jobs.choose_spool(conn, "h", "PLA", 2, ended_at=end))
with patch("grotti.jobs.store.spools_for_printer", return_value=[old]):
self.assertEqual(jobs.choose_spool(conn, "h", "PLA", None, ended_at=end)["id"], 1)
def test_spool_valid_for_end_naive(self):
end = datetime(2026, 10, 1, 12, 0, tzinfo=timezone.utc)
spool = {"created_at": datetime(2026, 9, 1, 12, 0)}
self.assertTrue(jobs._spool_valid_for_end(spool, end))
class SteuernAliasTests(unittest.TestCase):
@patch("grotti.moonraker.gcode_script", return_value={"ok": True})
def test_value_alias_for_temp(self, gcode):
moonraker.steuern("10.0.0.1", "nozzle", {"value": 210})
script = gcode.call_args[0][1]
self.assertIn("TARGET=210.0", script)
@patch("grotti.moonraker.gcode_script", return_value={"ok": True})
def test_percent_alias_for_speed(self, gcode):
moonraker.steuern("10.0.0.1", "speed", {"value": 120})
script = gcode.call_args[0][1]
self.assertIn("M220 S120", script)
class HostValidationTests(unittest.TestCase):
def test_validate_printer_host(self):
from web.app import validate_printer_host
from grotti.errors import GrottiError
self.assertEqual(validate_printer_host("10.10.1.60"), "10.10.1.60")
self.assertEqual(validate_printer_host("kobra.local:80"), "kobra.local:80")
with self.assertRaises(GrottiError):
validate_printer_host("http://evil")
with self.assertRaises(GrottiError):
validate_printer_host("../etc/passwd")
if __name__ == "__main__":
unittest.main()
+21
View File
@@ -0,0 +1,21 @@
"""Pfad- und Dateiname-Grenzen."""
import unittest
from grotti import library
from grotti.errors import GrottiError
class SafeFilenameTests(unittest.TestCase):
def test_rejects_dotdot(self):
self.assertEqual(library.safe_filename(".."), "datei.gcode")
self.assertEqual(library.safe_filename("../x.gcode"), "x.gcode")
def test_normalize_folder_blocks_escape(self):
with self.assertRaises(GrottiError):
library.normalize_folder("../secret")
self.assertEqual(library.normalize_folder("Gridfinity/A"), "Gridfinity/A")
if __name__ == "__main__":
unittest.main()
+28
View File
@@ -0,0 +1,28 @@
"""Wartungs-Zähler."""
import unittest
from unittest.mock import MagicMock
from grotti.maintenance import counters_for_host
class MaintenanceCounterTests(unittest.TestCase):
def test_counters_from_aggregate_row(self):
conn = MagicMock()
conn.execute.return_value.fetchone.return_value = {
"duration_s": 7200.0,
"meters": 150000.0,
"runs": 12,
}
out = counters_for_host(conn, "10.0.0.1")
self.assertAlmostEqual(out["hours"], 2.0)
self.assertAlmostEqual(out["meters"], 150000.0)
self.assertEqual(out["runs"], 12)
def test_empty_host(self):
out = counters_for_host(MagicMock(), "")
self.assertEqual(out, {"hours": 0.0, "meters": 0.0, "runs": 0})
if __name__ == "__main__":
unittest.main()
+103
View File
@@ -0,0 +1,103 @@
"""Tests für scripts/orca_senden.py ohne laufenden Server."""
from __future__ import annotations
import json
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from scripts import orca_senden as orca # noqa: E402
class OrcaSendenHelpersTest(unittest.TestCase):
def test_send_page_url(self):
self.assertEqual(
orca.send_page_url("http://127.0.0.1:8770/", 42),
"http://127.0.0.1:8770/dateien?file=42",
)
def test_find_gcode_arg_last_matching(self):
with tempfile.TemporaryDirectory() as tmp:
folder = Path(tmp)
gcode = folder / "teil.gcode"
gcode.write_text("; test\n", encoding="utf-8")
other = folder / "notes.txt"
other.write_text("x", encoding="utf-8")
found = orca.find_gcode_arg(["orca_senden.py", str(other), str(gcode)])
self.assertEqual(found, gcode.resolve())
def test_find_gcode_arg_pp_temp(self):
with tempfile.TemporaryDirectory() as tmp:
pp = Path(tmp) / "platte.gcode.pp"
pp.write_text("; temp\n", encoding="utf-8")
found = orca.find_gcode_arg(["orca_senden.py", str(pp)])
self.assertEqual(found, pp.resolve())
self.assertEqual(orca.upload_name_for(pp), "platte.gcode")
def test_under_watch_subfolder(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / "slice"
dest = root / "Gridfinity" / "teil.gcode"
dest.parent.mkdir(parents=True)
dest.write_text("; x\n", encoding="utf-8")
placed = orca.under_watch(root, dest)
self.assertEqual(placed, ("Gridfinity", "teil.gcode"))
def test_under_watch_outside(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / "slice"
root.mkdir()
other = Path(tmp) / "elsewhere" / "teil.gcode"
other.parent.mkdir()
other.write_text("; x\n", encoding="utf-8")
self.assertIsNone(orca.under_watch(root, other))
def test_find_gcode_arg_missing(self):
with self.assertRaises(ValueError):
orca.find_gcode_arg(["orca_senden.py"])
def test_find_gcode_arg_file_gone(self):
with self.assertRaises(FileNotFoundError):
orca.find_gcode_arg(["orca_senden.py", r"C:\gibt\es\nicht.gcode"])
def test_load_config_ok(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "orca-senden.json"
path.write_text(
json.dumps({"url": "http://127.0.0.1:8770/", "token": "grt_abc"}),
encoding="utf-8",
)
cfg = orca.load_config(path)
self.assertEqual(cfg["url"], "http://127.0.0.1:8770")
self.assertEqual(cfg["token"], "grt_abc")
def test_load_config_missing(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "fehlt.json"
with self.assertRaises(FileNotFoundError):
orca.load_config(path)
def test_load_config_empty_token(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "orca-senden.json"
path.write_text(json.dumps({"url": "http://x", "token": ""}), encoding="utf-8")
with self.assertRaises(ValueError):
orca.load_config(path)
def test_main_shows_error_without_config(self):
with patch.object(orca, "CONFIG_PATH", Path("/tmp/does-not-exist-orca.json")):
with patch.object(orca, "show_error") as shown:
code = orca.main(["orca_senden.py", "x.gcode"])
self.assertEqual(code, 1)
self.assertTrue(shown.called)
if __name__ == "__main__":
unittest.main()
+48
View File
@@ -0,0 +1,48 @@
"""Auftrags-Status und Angebotsrechnung."""
import unittest
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from grotti.cost import offer_from_file
from grotti.orders import ORDER_STATUSES, IDEA_TO_ORDER, PROJECT_TO_ORDER, normalize_status
class OrderStatusTests(unittest.TestCase):
def test_all_status_values(self):
for s in ("idee", "angebot", "bestaetigt", "druckt", "fertig", "geliefert", "bezahlt"):
self.assertIn(s, ORDER_STATUSES)
self.assertEqual(normalize_status(s), s)
def test_unknown_status(self):
with self.assertRaises(ValueError):
normalize_status("xyz")
def test_legacy_project_map(self):
self.assertEqual(PROJECT_TO_ORDER["bereit"], "angebot")
self.assertEqual(PROJECT_TO_ORDER["druckt"], "druckt")
def test_legacy_idea_map(self):
self.assertEqual(IDEA_TO_ORDER["erledigt"], "fertig")
self.assertEqual(IDEA_TO_ORDER["spaeter"], "idee")
class OfferFromFileTests(unittest.TestCase):
def test_basic_offer(self):
file_row = {"weight_g": 50, "estimated_s": 3600}
out = offer_from_file(file_row, material_price=20, machine_rate_per_h=10, setup_min=15, margin=20)
self.assertAlmostEqual(out["material_cost"], 1.0)
self.assertAlmostEqual(out["hours"], 1.0)
self.assertAlmostEqual(out["machine_cost"], 10 + 2.5)
self.assertAlmostEqual(out["total"], out["subtotal"] * 1.2)
def test_empty_file(self):
out = offer_from_file({}, 0, 0, 0, 0)
self.assertEqual(out["total"], 0.0)
if __name__ == "__main__":
unittest.main()
+24
View File
@@ -0,0 +1,24 @@
"""Start-Zeitfenster."""
import unittest
from datetime import datetime
from zoneinfo import ZoneInfo
from grotti.time_window import allowed_now
class TimeWindowTests(unittest.TestCase):
def test_overnight_window(self):
window = {"allowed": [{"from": "22:00", "to": "06:00"}]}
inside = datetime(2026, 10, 18, 23, 0, tzinfo=ZoneInfo("Europe/Berlin"))
outside = datetime(2026, 10, 18, 12, 0, tzinfo=ZoneInfo("Europe/Berlin"))
self.assertTrue(allowed_now(window, now=inside))
self.assertFalse(allowed_now(window, now=outside))
def test_empty_allows_always(self):
self.assertTrue(allowed_now({}))
self.assertTrue(allowed_now(None))
if __name__ == "__main__":
unittest.main()
+507 -27
View File
@@ -11,21 +11,25 @@ from urllib.parse import quote
import markdown import markdown
import psycopg import psycopg
from flask import Flask, abort, g, jsonify, redirect, render_template, request, session, url_for from flask import Flask, abort, g, jsonify, redirect, render_template, request, send_file, session, url_for
from markupsafe import Markup from markupsafe import Markup
from psycopg.rows import dict_row from psycopg.rows import dict_row
from werkzeug.middleware.proxy_fix import ProxyFix
from grotti import auth as grotti_auth from grotti import auth as grotti_auth
from grotti import config as grotti_config from grotti import config as grotti_config
from grotti import db as grotti_db
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
from grotti.cost import calculate from grotti.cost import calculate, offer_from_file, order_actuals
from grotti import orders as grotti_orders
from grotti.errors import GrottiError from grotti.errors import GrottiError
from grotti.printers import FAMILY_LABELS, family_from_name from grotti.printers import FAMILY_LABELS, family_from_name
from grotti.print_queue import PrintQueueDispatcher from grotti.print_queue import PrintQueueDispatcher
from grotti.send_queue import SendQueue from grotti.send_queue import SendQueue
from grotti.toolpath import TOOLPATH_VERSION, build_toolpath from grotti.toolpath import TOOLPATH_VERSION, build_toolpath
from grotti.version import __version__ from grotti.version import __version__
from grotti import verdict as grotti_verdict
from grotti.watcher import StateWatcher from grotti.watcher import StateWatcher
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
@@ -33,14 +37,33 @@ TOOL_ROOT = ROOT.parent
DOCS_DIR = TOOL_ROOT / "docs" DOCS_DIR = TOOL_ROOT / "docs"
PROJECT_STATUS = {"idee", "bereit", "druckt", "fertig"} PROJECT_STATUS = {"idee", "bereit", "druckt", "fertig"}
VERDICTS = {"", "gut", "ausschuss"} VERDICTS = {"", "gut", "ausschuss"}
VERDICT_REASONS = sorted(grotti_verdict.VERDICT_REASONS)
IDEA_STATUS = {"offen", "spaeter", "erledigt"} IDEA_STATUS = {"offen", "spaeter", "erledigt"}
PRINTER_FAMILIES = set(FAMILY_LABELS) | {""} PRINTER_FAMILIES = set(FAMILY_LABELS) | {""}
UPLOAD_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
HOST_RE = re.compile(r"^[A-Za-z0-9.\-]+(?::\d{1,5})?$")
TOKEN_QUERY_PATHS = (
"/api/v1/kamera",
"/api/v1/vorschau",
)
STEER_ACTIONS = { STEER_ACTIONS = {
"nozzle", "bed", "speed", "flow", "fan", "emergency_stop", "clear_error", "nozzle", "bed", "speed", "flow", "fan", "emergency_stop", "clear_error",
"exclude_object", "dryer_start", "dryer_stop", "slot_setzen", "exclude_object", "dryer_start", "dryer_stop", "slot_setzen",
} }
def validate_printer_host(host):
host = (host or "").strip()
if not host or not HOST_RE.fullmatch(host):
raise GrottiError(
"Host nur als Hostname oder IP, optional mit Port (z. B. 10.10.1.60).",
"host",
)
if ".." in host:
raise GrottiError("Ungültiger Host.", "host")
return host
def create_app(cfg): def create_app(cfg):
app = Flask( app = Flask(
__name__, __name__,
@@ -56,13 +79,14 @@ def create_app(cfg):
if cfg.get("fqdn", "").startswith("https://"): if cfg.get("fqdn", "").startswith("https://"):
app.config["SESSION_COOKIE_SECURE"] = True app.config["SESSION_COOKIE_SECURE"] = True
app.json.ensure_ascii = False app.json.ensure_ascii = False
# Hinter Reverse Proxy: X-Forwarded-For für Rate-Limits.
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
# Threads starten in server.main() — Tests können create_app ohne Netz/DB-Worker nutzen.
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)
dispatcher = PrintQueueDispatcher(cfg["database_url"], cfg, watcher=watcher) dispatcher = PrintQueueDispatcher(cfg["database_url"], cfg, watcher=watcher)
app.extensions["print_queue"] = dispatcher app.extensions["print_queue"] = dispatcher
app.extensions["watcher"] = watcher app.extensions["watcher"] = watcher
watcher.start()
dispatcher.start()
PUBLIC_PATHS = { PUBLIC_PATHS = {
"/login", "/login",
@@ -75,6 +99,7 @@ def create_app(cfg):
ADMIN_API_PREFIXES = ( ADMIN_API_PREFIXES = (
"/api/v1/users", "/api/v1/users",
"/api/v1/einstellungen", "/api/v1/einstellungen",
"/api/v1/backup",
) )
ADMIN_API_EXACT = { ADMIN_API_EXACT = {
"/api/v1/telegram/test", "/api/v1/telegram/test",
@@ -86,7 +111,13 @@ def create_app(cfg):
def conn(): def conn():
if "db" not in g: if "db" not in g:
try:
pool = grotti_db.get_pool()
g.db = pool.getconn()
g.db_from_pool = True
except RuntimeError:
g.db = psycopg.connect(cfg["database_url"], row_factory=dict_row) g.db = psycopg.connect(cfg["database_url"], row_factory=dict_row)
g.db_from_pool = False
return g.db return g.db
def ensure_csrf(): def ensure_csrf():
@@ -176,7 +207,11 @@ def create_app(cfg):
header = request.headers.get("Authorization", "") header = request.headers.get("Authorization", "")
if header.startswith("Bearer "): if header.startswith("Bearer "):
presented = header[7:].strip() presented = header[7:].strip()
elif request.method == "GET" and request.path.startswith("/api/"): elif (
request.method == "GET"
and any(request.path.startswith(p) for p in TOKEN_QUERY_PATHS)
):
# Token in der URL nur für Bild-Endpunkte (img-src ohne Header).
presented = (request.args.get("token") or "").strip() presented = (request.args.get("token") or "").strip()
user = None user = None
@@ -190,7 +225,11 @@ def create_app(cfg):
if not user: if not user:
if wants_json() or path.startswith("/api/"): if wants_json() or path.startswith("/api/"):
return jsonify({"error": "Anmeldung nötig.", "code": "auth"}), 401 return jsonify({"error": "Anmeldung nötig.", "code": "auth"}), 401
return redirect(url_for("login_page", next=path)) # Query behalten (z. B. /dateien?file=12 nach Orca-Upload)
next_path = path
if request.query_string:
next_path = path + "?" + request.query_string.decode("utf-8", "replace")
return redirect(url_for("login_page", next=next_path))
g.user = user g.user = user
ensure_csrf() ensure_csrf()
@@ -229,24 +268,60 @@ def create_app(cfg):
@app.teardown_appcontext @app.teardown_appcontext
def close_db(exc): def close_db(exc):
conn = g.pop("db", None) db_conn = g.pop("db", None)
if not conn: from_pool = g.pop("db_from_pool", False)
if not db_conn:
return return
try: try:
if exc: if exc:
conn.rollback() db_conn.rollback()
else: else:
conn.commit() db_conn.commit()
except Exception:
try:
db_conn.rollback()
except Exception:
pass
finally: finally:
conn.close() if from_pool:
try:
grotti_db.get_pool().putconn(db_conn)
except Exception:
try:
db_conn.close()
except Exception:
pass
else:
db_conn.close()
@app.errorhandler(GrottiError) @app.errorhandler(GrottiError)
def grotti_error(err): def grotti_error(err):
db = g.get("db") db_conn = g.get("db")
if db: if db_conn:
db.rollback() try:
db_conn.rollback()
except Exception:
pass
return jsonify({"error": err.message, "code": err.code}), err.status return jsonify({"error": err.message, "code": err.code}), err.status
@app.errorhandler(psycopg.Error)
def psycopg_error(err):
db_conn = g.get("db")
if db_conn:
try:
db_conn.rollback()
except Exception:
pass
message = "Datenbankfehler."
if isinstance(err, psycopg.errors.ForeignKeyViolation):
message = "Der Eintrag hängt noch an anderen Daten."
elif isinstance(err, psycopg.errors.UniqueViolation):
message = "Diesen Eintrag gibt es schon."
elif isinstance(err, psycopg.errors.InvalidTextRepresentation):
message = "Ungültiger Wert in der Anfrage."
print("DB: %s" % err, flush=True)
return jsonify({"error": message, "code": "db"}), 500
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):
@@ -330,6 +405,7 @@ def create_app(cfg):
username = grotti_auth.validate_username(data.get("username") or "") username = grotti_auth.validate_username(data.get("username") or "")
password = grotti_auth.validate_password(data.get("password") or "") password = grotti_auth.validate_password(data.get("password") or "")
row = store.create_user(conn(), username, grotti_auth.hash_password(password), "admin") row = store.create_user(conn(), username, grotti_auth.hash_password(password), "admin")
grotti_db.invalidate_user_count()
store.touch_user_login(conn(), row["id"]) store.touch_user_login(conn(), row["id"])
session.clear() session.clear()
session["user_id"] = row["id"] session["user_id"] = row["id"]
@@ -388,6 +464,7 @@ def create_app(cfg):
if store.get_user_by_name(conn(), username): if store.get_user_by_name(conn(), username):
raise GrottiError("Benutzer gibt es schon.", "user", 409) raise GrottiError("Benutzer gibt es schon.", "user", 409)
row = store.create_user(conn(), username, grotti_auth.hash_password(password), role) row = store.create_user(conn(), username, grotti_auth.hash_password(password), role)
grotti_db.invalidate_user_count()
audit("auth", "Benutzer %s (%s) angelegt" % (username, role)) audit("auth", "Benutzer %s (%s) angelegt" % (username, role))
return jsonify(grotti_auth.public_user(row)), 201 return jsonify(grotti_auth.public_user(row)), 201
@@ -490,9 +567,12 @@ def create_app(cfg):
"Anleitung-Oberflaeche", "Anleitung-Oberflaeche",
"Anleitung-Wand", "Anleitung-Wand",
"Anleitung-Dateien", "Anleitung-Dateien",
"Anleitung-Orca",
"Anleitung-ACE", "Anleitung-ACE",
"Anleitung-Warteschlange", "Anleitung-Warteschlange",
"Anleitung-Auftraege",
"Anleitung-Material", "Anleitung-Material",
"Anleitung-Wartung",
"Anleitung-Einstellungen", "Anleitung-Einstellungen",
"Anleitung-Stoerungen", "Anleitung-Stoerungen",
) )
@@ -510,9 +590,12 @@ def create_app(cfg):
"Anleitung-Oberflaeche": "Oberfläche", "Anleitung-Oberflaeche": "Oberfläche",
"Anleitung-Wand": "Druckerwand", "Anleitung-Wand": "Druckerwand",
"Anleitung-Dateien": "Dateien senden", "Anleitung-Dateien": "Dateien senden",
"Anleitung-Orca": "Orca an Grotti",
"Anleitung-ACE": "ACE und Spulen", "Anleitung-ACE": "ACE und Spulen",
"Anleitung-Warteschlange": "Warteschlange", "Anleitung-Warteschlange": "Warteschlange",
"Anleitung-Auftraege": "Aufträge",
"Anleitung-Material": "Material und mehr", "Anleitung-Material": "Material und mehr",
"Anleitung-Wartung": "Wartung",
"Anleitung-Einstellungen": "Einstellungen", "Anleitung-Einstellungen": "Einstellungen",
"Anleitung-Stoerungen": "Störungen", "Anleitung-Stoerungen": "Störungen",
} }
@@ -545,7 +628,23 @@ def create_app(cfg):
@app.get("/projekte") @app.get("/projekte")
def projekte(): def projekte():
return page("projekte.html", "Projekte") return redirect("/auftraege", code=301)
@app.get("/auftraege")
def auftraege_page():
return page("auftraege.html", "Aufträge")
@app.get("/auftraege/<int:order_id>/lieferschein")
def auftrag_lieferschein(order_id):
row = grotti_orders.get_order(conn(), order_id)
if not row:
abort(404)
return render_template(
"lieferschein.html",
page="auftraege",
title="Lieferschein #%s" % order_id,
order=row,
)
@app.get("/einstellungen") @app.get("/einstellungen")
def einstellungen_page(): def einstellungen_page():
@@ -591,6 +690,10 @@ def create_app(cfg):
def statistik_page(): def statistik_page():
return page("statistik.html", "Statistik") return page("statistik.html", "Statistik")
@app.get("/wartung")
def wartung_page():
return page("wartung.html", "Wartung")
@app.get("/etikett/lauf/<int:run_id>") @app.get("/etikett/lauf/<int:run_id>")
def etikett_lauf(run_id): def etikett_lauf(run_id):
row = store.get_run_row(conn(), run_id) row = store.get_run_row(conn(), run_id)
@@ -634,7 +737,7 @@ def create_app(cfg):
@app.get("/ideen") @app.get("/ideen")
def ideen(): def ideen():
return page("ideen.html", "Ideen") return redirect("/auftraege", code=301)
@app.get("/aktivitaet") @app.get("/aktivitaet")
def aktivitaet(): def aktivitaet():
@@ -692,11 +795,20 @@ def create_app(cfg):
data = body() data = body()
energy = data.get("energy") if isinstance(data.get("energy"), dict) else None energy = data.get("energy") if isinstance(data.get("energy"), dict) else None
telegram = data.get("telegram") if isinstance(data.get("telegram"), dict) else None telegram = data.get("telegram") if isinstance(data.get("telegram"), dict) else None
ntfy = data.get("ntfy") if isinstance(data.get("ntfy"), dict) else None
alerts = data.get("alerts") if isinstance(data.get("alerts"), dict) else None alerts = data.get("alerts") if isinstance(data.get("alerts"), dict) else None
auto_queue = data.get("auto_queue") if "auto_queue" in data else None auto_queue = data.get("auto_queue") if "auto_queue" in data else None
fqdn = data.get("fqdn") if "fqdn" in data else None fqdn = data.get("fqdn") if "fqdn" in data else None
watch_dir = data.get("watch_dir") if "watch_dir" in data else None
return jsonify(grotti_config.save_settings( return jsonify(grotti_config.save_settings(
cfg, energy=energy, telegram=telegram, alerts=alerts, auto_queue=auto_queue, fqdn=fqdn, cfg,
energy=energy,
telegram=telegram,
ntfy=ntfy,
alerts=alerts,
auto_queue=auto_queue,
fqdn=fqdn,
watch_dir=watch_dir,
)) ))
@app.post("/api/v1/telegram/test") @app.post("/api/v1/telegram/test")
@@ -766,11 +878,9 @@ def create_app(cfg):
def printer_create(): def printer_create():
data = body() data = body()
name = (data.get("name") or "").strip() name = (data.get("name") or "").strip()
host = (data.get("host") or "").strip() host = validate_printer_host(data.get("host") or "")
if not name: if not name:
raise GrottiError("Der Drucker braucht einen Namen.", "name") raise GrottiError("Der Drucker braucht einen Namen.", "name")
if not host:
raise GrottiError("Der Drucker braucht eine IP oder einen Host.", "host")
family = (data.get("family") or "").strip() or family_from_name(name) family = (data.get("family") or "").strip() or family_from_name(name)
if family not in PRINTER_FAMILIES: if family not in PRINTER_FAMILIES:
raise GrottiError("Unbekannte Druckerfamilie.", "family") raise GrottiError("Unbekannte Druckerfamilie.", "family")
@@ -786,14 +896,12 @@ def create_app(cfg):
raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404) raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404)
name = (data.get("name") if "name" in data else current["name"]) or "" name = (data.get("name") if "name" in data else current["name"]) or ""
name = str(name).strip() name = str(name).strip()
host = (data.get("host") if "host" in data else current["host"]) or "" host_raw = (data.get("host") if "host" in data else current["host"]) or ""
host = str(host).strip() host = validate_printer_host(str(host_raw).strip())
family = data.get("family") if "family" in data else current["family"] family = data.get("family") if "family" in data else current["family"]
family = (family or "").strip() or family_from_name(name) family = (family or "").strip() or family_from_name(name)
if not name: if not name:
raise GrottiError("Der Drucker braucht einen Namen.", "name") raise GrottiError("Der Drucker braucht einen Namen.", "name")
if not host:
raise GrottiError("Der Drucker braucht eine IP oder einen Host.", "host")
if family not in PRINTER_FAMILIES: if family not in PRINTER_FAMILIES:
raise GrottiError("Unbekannte Druckerfamilie.", "family") raise GrottiError("Unbekannte Druckerfamilie.", "family")
other = store.printer_by_host(conn(), host) other = store.printer_by_host(conn(), host)
@@ -906,7 +1014,7 @@ def create_app(cfg):
@app.get("/api/v1/dateien") @app.get("/api/v1/dateien")
def file_list(): def file_list():
library.sync_watch(conn(), cfg, force=False) library.sync_watch(conn(), cfg, force=False)
return jsonify(store.list_files(conn())) return jsonify(store.list_files(conn(), watch_dir=cfg.get("watch_dir")))
@app.post("/api/v1/dateien/neu-lesen") @app.post("/api/v1/dateien/neu-lesen")
def file_rescan(): def file_rescan():
@@ -914,11 +1022,45 @@ def create_app(cfg):
result["metadaten"] = library.refresh_file_meta(conn(), force=True) result["metadaten"] = library.refresh_file_meta(conn(), force=True)
return jsonify(result) return jsonify(result)
@app.get("/api/v1/dateien/ordner")
def file_folders():
return jsonify({"folders": library.list_folders(cfg)})
@app.post("/api/v1/dateien/ordner")
def file_folder_create():
data = body()
folder = library.create_folder(cfg, data.get("folder"))
return jsonify({"folder": folder, "folders": library.list_folders(cfg)}), 201
@app.delete("/api/v1/dateien/ordner")
def file_folder_delete():
data = body()
folder = library.delete_folder(cfg, data.get("folder"))
return jsonify({"folder": folder, "folders": library.list_folders(cfg)})
@app.post("/api/v1/dateien/<int:file_id>/verschieben")
def file_move(file_id):
data = body()
folder = data.get("folder")
if folder is None:
folder = ""
moved = library.move_file(conn(), cfg, file_id, folder)
return jsonify(moved)
@app.post("/api/v1/dateien") @app.post("/api/v1/dateien")
def file_upload(): def file_upload():
upload = request.files.get("file") upload = request.files.get("file")
if not upload or not upload.filename: if not upload or not upload.filename:
raise GrottiError("Keine Datei angehängt.", "datei") raise GrottiError("Keine Datei angehängt.", "datei")
low = upload.filename.lower().replace("\\", "/")
base = low.rsplit("/", 1)[-1]
if base in (".", "..") or not base.strip("."):
raise GrottiError("Ungültiger Dateiname.", "datei")
if not any(base.endswith(suf) for suf in UPLOAD_SUFFIXES):
raise GrottiError(
"Nur .gcode, .3mf oder .gcode.3mf sind erlaubt.",
"datei",
)
project_id = optional_id(request.form.get("project_id")) project_id = optional_id(request.form.get("project_id"))
saved = library.save_upload(conn(), upload.filename, upload.read(), cfg, project_id) saved = library.save_upload(conn(), upload.filename, upload.read(), cfg, project_id)
return jsonify(saved), 201 return jsonify(saved), 201
@@ -1287,8 +1429,31 @@ def create_app(cfg):
if not bool(data.get("confirm")): if not bool(data.get("confirm")):
raise GrottiError("Bestätigung fehlt.", "bestaetigung") raise GrottiError("Bestätigung fehlt.", "bestaetigung")
require_host(host) require_host(host)
store.set_bed_needs_clear(conn(), host, False, "quittiert") c = conn()
return jsonify({"ok": True, "host": host, "needs_clear": False, "message": "Platte quittiert."}) store.set_bed_needs_clear(c, host, False, "quittiert")
run_json = None
if any(k in data for k in ("verdict", "verdict_reason", "pieces_ok", "pieces_bad")):
run_row = store.run_for_abraeumen_verdict(c, host)
if not run_row:
raise GrottiError("Kein Lauf zum Bewerten — Protokoll prüfen.", "lauf", 404)
try:
verdict, reason, ok_n, bad_n = grotti_verdict.normalize_abraeumen_verdict(
data.get("verdict"),
data.get("verdict_reason"),
data.get("pieces_ok"),
data.get("pieces_bad"),
)
except ValueError as exc:
raise GrottiError(str(exc), "urteil") from exc
run_json = store.set_verdict(c, run_row["id"], verdict, reason, ok_n, bad_n)
return jsonify({
"ok": True,
"host": host,
"needs_clear": False,
"message": "Platte quittiert.",
"run": run_json,
"verdict_reasons": VERDICT_REASONS,
})
@app.get("/api/v1/abraeumen-defaults") @app.get("/api/v1/abraeumen-defaults")
def abraeumen_defaults(): def abraeumen_defaults():
@@ -1555,6 +1720,191 @@ def create_app(cfg):
raise GrottiError("Die Idee fehlt.", "idee", 404) raise GrottiError("Die Idee fehlt.", "idee", 404)
return "", 204 return "", 204
@app.get("/api/v1/kunden")
def kunden_list():
return jsonify(grotti_orders.list_customers(conn()))
@app.post("/api/v1/kunden")
def kunden_create():
data = body()
name = (data.get("name") or "").strip()
if not name:
raise GrottiError("Der Kunde braucht einen Namen.", "name")
return jsonify(
grotti_orders.create_customer(
conn(),
name,
(data.get("email") or "").strip(),
(data.get("notes") or "").strip(),
)
), 201
@app.patch("/api/v1/kunden/<int:customer_id>")
def kunden_patch(customer_id):
data = body()
fields = {}
if "name" in data:
name = (data.get("name") or "").strip()
if not name:
raise GrottiError("Der Kunde braucht einen Namen.", "name")
fields["name"] = name
if "email" in data:
fields["email"] = (data.get("email") or "").strip()
if "notes" in data:
fields["notes"] = (data.get("notes") or "").strip()
row = grotti_orders.update_customer(conn(), customer_id, fields)
if not row:
raise GrottiError("Der Kunde fehlt.", "kunde", 404)
return jsonify(row)
@app.delete("/api/v1/kunden/<int:customer_id>")
def kunden_delete(customer_id):
if not grotti_orders.delete_customer(conn(), customer_id):
raise GrottiError("Der Kunde fehlt.", "kunde", 404)
return "", 204
@app.get("/api/v1/auftraege")
def auftraege_list():
status = (request.args.get("status") or "").strip().lower()
if status and status not in grotti_orders.ORDER_STATUSES:
raise GrottiError("Unbekannter Status.", "status")
return jsonify(grotti_orders.list_orders(conn(), status=status or None))
@app.post("/api/v1/auftraege")
def auftraege_create():
data = body()
title = (data.get("title") or "").strip()
if not title:
raise GrottiError("Der Auftrag braucht einen Titel.", "titel")
try:
status = grotti_orders.normalize_status(data.get("status") or "idee")
except ValueError as exc:
raise GrottiError(str(exc), "status") from exc
cid = optional_id(data.get("customer_id"))
return jsonify(
grotti_orders.create_order(
conn(),
title,
status,
customer_id=cid,
due_at=data.get("due_at"),
notes=(data.get("notes") or "").strip(),
quote=data.get("quote") if isinstance(data.get("quote"), dict) else {},
)
), 201
@app.get("/api/v1/auftraege/<int:order_id>")
def auftrag_get(order_id):
row = grotti_orders.get_order(conn(), order_id)
if not row:
raise GrottiError("Der Auftrag fehlt.", "auftrag", 404)
row["actuals"] = order_actuals(conn(), order_id)
return jsonify(row)
@app.patch("/api/v1/auftraege/<int:order_id>")
def auftrag_patch(order_id):
data = body()
fields = {}
if "title" in data:
title = (data.get("title") or "").strip()
if not title:
raise GrottiError("Der Auftrag braucht einen Titel.", "titel")
fields["title"] = title
if "status" in data:
try:
fields["status"] = grotti_orders.normalize_status(data.get("status"))
except ValueError as exc:
raise GrottiError(str(exc), "status") from exc
if "customer_id" in data:
fields["customer_id"] = optional_id(data.get("customer_id"))
if "due_at" in data:
fields["due_at"] = data.get("due_at")
if "notes" in data:
fields["notes"] = data.get("notes") or ""
if "quote" in data and isinstance(data.get("quote"), dict):
fields["quote"] = data["quote"]
row = grotti_orders.update_order(conn(), order_id, fields)
if not row:
raise GrottiError("Der Auftrag fehlt.", "auftrag", 404)
row["actuals"] = order_actuals(conn(), order_id)
return jsonify(row)
@app.delete("/api/v1/auftraege/<int:order_id>")
def auftrag_delete(order_id):
if not grotti_orders.delete_order(conn(), order_id):
raise GrottiError("Der Auftrag fehlt.", "auftrag", 404)
return "", 204
@app.post("/api/v1/auftraege/<int:order_id>/positionen")
def auftrag_position_create(order_id):
data = body()
file_id = optional_id(data.get("file_id"))
row = grotti_orders.create_order_item(
conn(),
order_id,
file_id=file_id,
qty=data.get("qty", 1),
unit_price=data.get("unit_price", 0),
material_type=(data.get("material_type") or "").strip(),
color=(data.get("color") or "").strip(),
note=(data.get("note") or "").strip(),
)
if not row:
raise GrottiError("Der Auftrag fehlt.", "auftrag", 404)
return jsonify(row), 201
@app.patch("/api/v1/auftraege/positionen/<int:item_id>")
def auftrag_position_patch(item_id):
data = body()
fields = {}
for key in ("file_id", "qty", "unit_price", "material_type", "color", "note"):
if key in data:
fields[key] = data[key] if key != "file_id" else optional_id(data.get("file_id"))
row = grotti_orders.update_order_item(conn(), item_id, fields)
if not row:
raise GrottiError("Die Position fehlt.", "position", 404)
return jsonify(row)
@app.delete("/api/v1/auftraege/positionen/<int:item_id>")
def auftrag_position_delete(item_id):
if not grotti_orders.delete_order_item(conn(), item_id):
raise GrottiError("Die Position fehlt.", "position", 404)
return "", 204
@app.post("/api/v1/auftraege/<int:order_id>/einreihen")
def auftrag_einreihen(order_id):
data = body()
if not bool(data.get("confirm")):
raise GrottiError("Bestätigung fehlt.", "bestaetigung")
try:
item_id = int(data.get("item_id"))
except (TypeError, ValueError) as exc:
raise GrottiError("Position fehlt.", "position") from exc
host = optional_host(data.get("host") or "")
family = (data.get("family") or "").strip()
if not host and not family:
file_row = None
item = grotti_orders.get_order_item(conn(), item_id)
if item and item.get("file_id"):
file_row = store.get_file_row(conn(), item["file_id"])
family = (file_row or {}).get("printer_family") or ""
try:
row = grotti_orders.enqueue_order_item(
conn(),
order_id,
item_id,
host=host,
family=family,
priority=int(data.get("priority", 100)),
spool_id=optional_id(data.get("spool_id")),
ace_map=data.get("ace_map") if isinstance(data.get("ace_map"), list) else [],
bed_leveling=bool(data.get("bed_leveling")),
override=bool(data.get("override_machine")),
)
except ValueError as exc:
raise GrottiError(str(exc), "auftrag") from exc
return jsonify(row), 201
@app.get("/api/v1/spulen") @app.get("/api/v1/spulen")
def spool_list(): def spool_list():
return jsonify(store.list_spools(conn())) return jsonify(store.list_spools(conn()))
@@ -1799,6 +2149,28 @@ 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/aktuell")
def run_aktuell():
"""Aktueller oder letzter Temperatur-/ED-Verlauf fürs Cockpit (host=)."""
host = require_host(request.args.get("host", ""))
run = store.latest_run_for_host(conn(), host)
if not run:
return jsonify({"run": None, "samples": [], "energy": None})
run_id = run["id"]
samples = store.list_run_samples(conn(), run_id)
from grotti.cost import energy_estimate
energy = energy_estimate(run.get("duration_s"), cfg["energy"], samples)
if len(samples) > 180:
step = max(1, len(samples) // 120)
samples = samples[::step]
return jsonify({
"run": run,
"samples": samples,
"energy": energy,
"sample_count": int(run.get("_sample_count") or len(samples)),
})
@app.get("/api/v1/laeufe/<int:run_id>") @app.get("/api/v1/laeufe/<int:run_id>")
def run_get(run_id): def run_get(run_id):
row = store.get_run_row(conn(), run_id) row = store.get_run_row(conn(), run_id)
@@ -1814,6 +2186,9 @@ def create_app(cfg):
file_row = store.get_file_row(conn(), run["file_id"]) if run.get("file_id") else None 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 project = store.get_project(conn(), run["project_id"]) if run.get("project_id") else None
samples = store.list_run_samples(conn(), run_id) samples = store.list_run_samples(conn(), run_id)
from grotti.cost import energy_estimate
energy = energy_estimate(run.get("duration_s"), cfg["energy"], samples)
# Anzeige auf ca. 1 Punkt / Minute verdichten # Anzeige auf ca. 1 Punkt / Minute verdichten
if len(samples) > 180: if len(samples) > 180:
step = max(1, len(samples) // 120) step = max(1, len(samples) // 120)
@@ -1827,6 +2202,7 @@ def create_app(cfg):
"events": store.list_run_events(conn(), run_id), "events": store.list_run_events(conn(), run_id),
"file": store.file_out(file_row) if file_row else None, "file": store.file_out(file_row) if file_row else None,
"project": project, "project": project,
"energy": energy,
}) })
@app.post("/api/v1/laeufe/abgleich") @app.post("/api/v1/laeufe/abgleich")
@@ -1851,6 +2227,75 @@ def create_app(cfg):
row = store.run_out(current) row = store.run_out(current)
return jsonify(row) return jsonify(row)
@app.get("/api/v1/bedarf")
def bedarf_api():
from grotti import bedarf as bedarf_mod
return jsonify(bedarf_mod.compute(conn()))
@app.get("/api/v1/wartung")
def wartung_api():
from grotti import maintenance
return jsonify(maintenance.list_for_api(conn(), store.list_printers(conn())))
@app.post("/api/v1/wartung/teile")
def wartung_teil_create():
from grotti import maintenance
data = body()
host = validate_printer_host(data.get("printer_host") or data.get("host") or "")
require_host(host)
try:
part = maintenance.create_part(
conn(),
host,
data.get("part_key") or "",
label=data.get("label") or "",
life_hours=data.get("life_hours") or 0,
life_meters=data.get("life_meters") or 0,
note=data.get("note") or "",
)
except ValueError as exc:
raise GrottiError(str(exc), "wartung") from exc
conn().commit()
return jsonify(part)
@app.post("/api/v1/wartung/teile/<int:part_id>/reset")
def wartung_teil_reset(part_id):
from grotti import maintenance
part = maintenance.reset_part(conn(), part_id)
if not part:
raise GrottiError("Teil fehlt.", "wartung", 404)
conn().commit()
return jsonify(part)
@app.post("/api/v1/backup")
def backup_create():
from grotti import backup as backup_mod
try:
meta = backup_mod.create_backup(cfg)
except RuntimeError as exc:
raise GrottiError(str(exc), "backup") from exc
return jsonify(meta)
@app.get("/api/v1/backup")
def backup_list():
from grotti import backup as backup_mod
return jsonify({"items": backup_mod.list_backups(cfg)})
@app.get("/api/v1/backup/<name>")
def backup_download(name):
from grotti import backup as backup_mod
path = backup_mod.backup_path(cfg, name)
if not path:
raise GrottiError("Backup fehlt.", "backup", 404)
return send_file(path, as_attachment=True, download_name=path.name)
@app.get("/api/v1/statistik") @app.get("/api/v1/statistik")
def statistik_api(): def statistik_api():
since = request.args.get("since") or None since = request.args.get("since") or None
@@ -1918,4 +2363,39 @@ def create_app(cfg):
headers={"Content-Disposition": "attachment; filename=grotti-laeufe.csv"}, headers={"Content-Disposition": "attachment; filename=grotti-laeufe.csv"},
) )
@app.get("/api/v1/stream")
def api_event_stream():
from flask import Response, stream_with_context
from grotti import events_bus
def generate():
q = events_bus.subscribe()
try:
while True:
item = events_bus.wait_event(q, 15.0)
if item is None:
yield "event: ping\ndata: {}\n\n"
continue
kind = item.get("kind") or "message"
payload = item.get("payload") or {}
yield "event: %s\ndata: %s\n\n" % (
kind,
json.dumps(payload, ensure_ascii=False),
)
except GeneratorExit:
pass
finally:
events_bus.unsubscribe(q)
return Response(
stream_with_context(generate()),
mimetype="text/event-stream; charset=utf-8",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
return app return app
+129 -3
View File
@@ -12,7 +12,8 @@ function escapeHtml(value) {
.replaceAll("&", "&amp;") .replaceAll("&", "&amp;")
.replaceAll("<", "&lt;") .replaceAll("<", "&lt;")
.replaceAll(">", "&gt;") .replaceAll(">", "&gt;")
.replaceAll('"', "&quot;"); .replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
} }
async function api(path, options = {}) { async function api(path, options = {}) {
@@ -89,7 +90,126 @@ function clockOf(iso) {
if (!iso) return ""; if (!iso) return "";
const at = new Date(iso); const at = new Date(iso);
if (Number.isNaN(at.getTime())) return ""; if (Number.isNaN(at.getTime())) return "";
return at.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }); return at.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: appTimeZone() });
}
const TZ_KEY = "grotti-timezone";
function appTimeZone() {
try {
const value = localStorage.getItem(TZ_KEY);
if (value && value !== "auto") return value;
} catch (error) { /* Browser */ }
return undefined;
}
function formatDateTime(iso, opts) {
if (!iso) return "—";
const at = new Date(iso);
if (Number.isNaN(at.getTime())) return "—";
const options = {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZone: appTimeZone(),
...(opts || {}),
};
return at.toLocaleString("de-DE", options);
}
function formatDateRange(startIso, endIso) {
const start = startIso ? new Date(startIso) : null;
const end = endIso ? new Date(endIso) : null;
if (start && Number.isNaN(start.getTime())) return "—";
if (!start && !end) return "—";
if (start && !end) return formatDateTime(startIso) + " → …";
if (!start && end) return "… → " + formatDateTime(endIso);
const sameDay = start.toDateString() === end.toDateString();
const left = formatDateTime(startIso);
if (sameDay) {
const right = end.toLocaleTimeString("de-DE", {
hour: "2-digit",
minute: "2-digit",
timeZone: appTimeZone(),
});
return left + " – " + right;
}
return left + " – " + formatDateTime(endIso);
}
function tempChartHtml(samples) {
if (!samples || samples.length < 2) {
return '<p class="tiny">Noch zu wenig Proben für den Verlauf.</p>';
}
const times = samples.map((s) => new Date(s.sampled_at).getTime());
const t0 = times[0];
const t1 = times[times.length - 1];
const span = Math.max(1, t1 - t0);
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 hasEd = samples.some((s) => s.nozzle_power != null || s.bed_power != null);
const w = 640;
const h = 210;
const padL = 44;
const padR = hasEd ? 44 : 28;
const padT = 22;
const padB = 30;
const x = (ms) => padL + ((ms - t0) / span) * (w - padL - padR);
const yT = (v) => padT + (1 - (Number(v) - minT) / Math.max(1, maxT - minT)) * (h - padT - padB);
const yEd = (v) => padT + (1 - Math.max(0, Math.min(1, Number(v) || 0))) * (h - padT - padB);
function poly(key, color, dashed, scaleEd) {
const pts = [];
samples.forEach((s, i) => {
const v = s[key];
if (v == null) return;
const yy = scaleEd ? yEd(Number(v)) : yT(v);
pts.push(`${x(times[i]).toFixed(1)},${yy.toFixed(1)}`);
});
if (pts.length < 2) return "";
const dash = dashed ? ' stroke-dasharray="5 4"' : "";
return `<polyline fill="none" stroke="${color}" stroke-width="${dashed ? 1.2 : 1.7}"${dash} points="${pts.join(" ")}" />`;
}
const tickCount = Math.min(5, samples.length);
const timeTicks = [];
for (let i = 0; i < tickCount; i++) {
const ms = t0 + (span * i) / Math.max(1, tickCount - 1);
const label = new Date(ms).toLocaleTimeString("de-DE", {
hour: "2-digit",
minute: "2-digit",
timeZone: appTimeZone(),
});
timeTicks.push(`<text x="${x(ms).toFixed(1)}" y="${h - 6}" text-anchor="middle" class="chart-label">${escapeHtml(label)}</text>`);
}
const midY = (padT + h - padB) / 2;
return `<svg class="temp-chart" viewBox="0 0 ${w} ${h}" role="img" aria-label="Temperatur links in °C, Einschaltdauer rechts in Prozent">
${poly("nozzle_target", "#fdba74", true, false)}
${poly("bed_target", "#93c5fd", true, false)}
${poly("nozzle", "#ea580c", false, false)}
${poly("bed", "#2563eb", false, false)}
${poly("chamber", "#16a34a", false, false)}
${hasEd ? poly("nozzle_power", "#c084fc", false, true) : ""}
${hasEd ? poly("bed_power", "#22d3ee", false, true) : ""}
<text x="10" y="${midY}" class="chart-axis" transform="rotate(-90 10 ${midY})">Temp °C</text>
<text x="${padL}" y="14" class="chart-label">${Math.round(maxT)}</text>
<text x="${padL}" y="${h - padB + 2}" class="chart-label">${Math.round(minT)}</text>
${hasEd ? `<text x="${w - 10}" y="${midY}" class="chart-axis" text-anchor="middle" transform="rotate(90 ${w - 10} ${midY})">ED %</text>` : ""}
${hasEd ? `<text x="${w - padR}" y="14" text-anchor="end" class="chart-label">100</text>` : ""}
${hasEd ? `<text x="${w - padR}" y="${h - padB + 2}" text-anchor="end" class="chart-label">0</text>` : ""}
${timeTicks.join("")}
</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>
${hasEd ? '<span class="leg ed-n">ED Düse</span><span class="leg ed-b">ED Bett</span>' : ""}
· links °C · rechts % · gestrichelt ≈ Soll
</p>`;
} }
function showNote(id, error, ok) { function showNote(id, error, ok) {
@@ -158,10 +278,16 @@ function toast(message, kind) {
host.setAttribute("aria-live", "polite"); host.setAttribute("aria-live", "polite");
document.body.appendChild(host); document.body.appendChild(host);
} }
const text = String(message ?? "");
host.querySelectorAll(".toast").forEach((node) => {
const body = node.querySelector("div");
if (body && body.textContent === text) node.remove();
});
while (host.children.length >= 3) host.firstElementChild?.remove();
const node = document.createElement("div"); const node = document.createElement("div");
node.className = "toast " + (kind || "good"); node.className = "toast " + (kind || "good");
node.innerHTML = icon(kind === "bad" ? "alert" : "check", "lg") + "<div></div>"; node.innerHTML = icon(kind === "bad" ? "alert" : "check", "lg") + "<div></div>";
node.querySelector("div").textContent = message; node.querySelector("div").textContent = text;
host.appendChild(node); host.appendChild(node);
const ttl = kind === "bad" ? 7000 : 4000; const ttl = kind === "bad" ? 7000 : 4000;
setTimeout(() => { setTimeout(() => {
+226 -11
View File
@@ -5,7 +5,7 @@
--bg: #0a0a0c; --bg: #0a0a0c;
--ink: #f0f0f2; --ink: #f0f0f2;
--muted: #9a9aa3; --muted: #9a9aa3;
--faint: #6b6b75; --faint: #8d8d97;
--line: rgba(255, 255, 255, 0.08); --line: rgba(255, 255, 255, 0.08);
--line-strong: rgba(255, 255, 255, 0.14); --line-strong: rgba(255, 255, 255, 0.14);
--card: #141418; --card: #141418;
@@ -374,6 +374,47 @@ body.shelf [data-role="hero"] { font-size: 1.35rem; }
} }
.queue-item.running { border-color: rgba(61, 206, 124, 0.28); background: linear-gradient(180deg, rgba(61, 206, 124, 0.07), rgba(255, 255, 255, 0.02)); } .queue-item.running { border-color: rgba(61, 206, 124, 0.28); background: linear-gradient(180deg, rgba(61, 206, 124, 0.07), rgba(255, 255, 255, 0.02)); }
.queue-item.error { border-color: rgba(248, 113, 113, 0.3); background: var(--st-error-soft); } .queue-item.error { border-color: rgba(248, 113, 113, 0.3); background: var(--st-error-soft); }
.queue-item.run-line {
display: flex;
align-items: center;
gap: 0.2rem;
padding: 0.2rem 0.3rem 0.2rem 0.2rem;
background: rgba(255, 255, 255, 0.03);
}
.queue-item.run-line.running {
background: rgba(61, 206, 124, 0.04);
}
.run-line-link {
position: relative;
flex: 1;
min-width: 0;
display: flex;
align-items: center;
padding: 0.28rem 0.45rem;
border-radius: 7px;
overflow: hidden;
}
.run-line-fill {
position: absolute;
inset: 0 auto 0 0;
width: calc(var(--pct, 0) * 1%);
max-width: 100%;
background: rgba(61, 206, 124, 0.22);
pointer-events: none;
transition: width 0.35s ease;
}
.run-line-text {
position: relative;
z-index: 1;
color: var(--ink);
font-size: var(--t-xs);
font-weight: 650;
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.queue-link.run-line-link:hover .run-line-text { color: var(--accent); }
.queue-row { display: flex; align-items: center; gap: 0.4rem; min-width: 0; } .queue-row { display: flex; align-items: center; gap: 0.4rem; min-width: 0; }
.queue-row .grow { flex: 1; min-width: 0; } .queue-row .grow { flex: 1; min-width: 0; }
.queue-link { color: inherit; text-decoration: none; } .queue-link { color: inherit; text-decoration: none; }
@@ -443,6 +484,7 @@ body.shelf [data-role="hero"] { font-size: 1.35rem; }
.icon-btn.done-btn { color: var(--st-printing); border-color: rgba(61, 206, 124, 0.35); border-radius: 99px !important; } .icon-btn.done-btn { color: var(--st-printing); border-color: rgba(61, 206, 124, 0.35); border-radius: 99px !important; }
.icon-btn.done-btn:hover { color: var(--st-printing); background: rgba(61, 206, 124, 0.12); } .icon-btn.done-btn:hover { color: var(--st-printing); background: rgba(61, 206, 124, 0.12); }
.icon-btn.done-btn.confirm-armed { width: auto; padding: 0 0.5rem !important; font-size: 0.66rem; font-weight: 700; background: rgba(61, 206, 124, 0.18); } .icon-btn.done-btn.confirm-armed { width: auto; padding: 0 0.5rem !important; font-size: 0.66rem; font-weight: 700; background: rgba(61, 206, 124, 0.18); }
.icon-btn.confirm-armed { width: auto; padding: 0 0.5rem !important; font-size: 0.7rem; font-weight: 700; }
.queue-tools { display: flex; gap: 0.1rem; flex-wrap: nowrap; flex: none; } .queue-tools { display: flex; gap: 0.1rem; flex-wrap: nowrap; flex: none; }
.send-progress { margin-top: 0.75rem; display: grid; gap: 0.55rem; } .send-progress { margin-top: 0.75rem; display: grid; gap: 0.55rem; }
.send-job { display: grid; gap: 0.3rem; } .send-job { display: grid; gap: 0.3rem; }
@@ -1214,14 +1256,16 @@ button.swatch.on { border-color: var(--accent); background: var(--accent-soft);
} }
.mat-color-cap { color: var(--faint); font-size: var(--t-xs); } .mat-color-cap { color: var(--faint); font-size: var(--t-xs); }
.mat-form { position: sticky; top: 0.75rem; } .mat-form { position: sticky; top: 0.75rem; }
.mat-color { .mat-color,
.mat-more {
margin: 0.75rem 0 0; margin: 0.75rem 0 0;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 10px; border-radius: 10px;
background: rgba(255, 255, 255, 0.02); background: rgba(255, 255, 255, 0.02);
overflow: hidden; overflow: hidden;
} }
.mat-color > summary { .mat-color > summary,
.mat-more > summary {
list-style: none; list-style: none;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1233,11 +1277,16 @@ button.swatch.on { border-color: var(--accent); background: var(--accent-soft);
font-size: var(--t-sm); font-size: var(--t-sm);
font-weight: 650; font-weight: 650;
} }
.mat-color > summary::-webkit-details-marker { display: none; } .mat-color > summary::-webkit-details-marker,
.mat-color > summary .caret { transition: transform 0.2s var(--ease); opacity: 0.6; } .mat-more > summary::-webkit-details-marker { display: none; }
.mat-color:not([open]) > summary .caret { transform: rotate(-90deg); } .mat-color > summary .caret,
.mat-color > summary .grow { flex: 1; color: var(--ink); } .mat-more > summary .caret { transition: transform 0.2s var(--ease); opacity: 0.6; }
.mat-color-body { padding: 0 0.7rem 0.75rem; } .mat-color:not([open]) > summary .caret,
.mat-more:not([open]) > summary .caret { transform: rotate(-90deg); }
.mat-color > summary .grow,
.mat-more > summary .grow { flex: 1; color: var(--ink); }
.mat-color-body,
.mat-more-body { padding: 0 0.7rem 0.75rem; }
.mat-color-body .color-grid { margin-bottom: 0.35rem; } .mat-color-body .color-grid { margin-bottom: 0.35rem; }
.mat-color-body .picked { min-height: 1.4rem; margin-bottom: 0.45rem; } .mat-color-body .picked { min-height: 1.4rem; margin-bottom: 0.45rem; }
.viewer-card { margin-top: 1rem; } .viewer-card { margin-top: 1rem; }
@@ -1627,6 +1676,13 @@ body.desk-on main {
} }
.desk-tool-mesh .mesh { margin-top: 0; } .desk-tool-mesh .mesh { margin-top: 0; }
.desk-tool-mesh .gridmesh { width: 100%; max-width: 22rem; } .desk-tool-mesh .gridmesh { width: 100%; max-width: 22rem; }
.desk-verlauf .temp-chart { max-width: none; }
.desk-verlauf-head {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.45rem;
}
.desk-empty { margin: 0.35rem 0 0; color: var(--faint); } .desk-empty { margin: 0.35rem 0 0; color: var(--faint); }
.ablauf-card { margin-top: 1rem; } .ablauf-card { margin-top: 1rem; }
@@ -1904,6 +1960,31 @@ body.desk-on main {
.tabs button:hover { color: var(--ink); filter: none; } .tabs button:hover { color: var(--ink); filter: none; }
.tabs button.on { color: var(--ink); border-bottom-color: var(--accent); } .tabs button.on { color: var(--ink); border-bottom-color: var(--accent); }
.conn-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 120;
padding: 0.45rem 1rem;
text-align: center;
font-size: var(--t-sm);
font-weight: 500;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
pointer-events: none;
}
.conn-banner[hidden] { display: none !important; }
.conn-banner.bad {
background: rgba(127, 29, 29, 0.92);
color: #fecaca;
border-bottom: 1px solid rgba(248, 113, 113, 0.45);
}
.conn-banner.ok {
background: rgba(20, 83, 45, 0.92);
color: #bbf7d0;
border-bottom: 1px solid rgba(61, 206, 124, 0.45);
}
.toast-host { .toast-host {
position: fixed; position: fixed;
right: 1rem; right: 1rem;
@@ -1981,6 +2062,17 @@ article.machine.flash { animation: flash 2s var(--ease); }
} }
.lib-tools input[type="search"] { flex: 1 1 12rem; margin: 0; } .lib-tools input[type="search"] { flex: 1 1 12rem; margin: 0; }
.lib-tools select { flex: 0 1 11rem; width: auto; margin: 0; } .lib-tools select { flex: 0 1 11rem; width: auto; margin: 0; }
.lib-group {
font-size: var(--t-xs);
font-weight: 650;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.55rem 0.35rem 0.15rem;
margin-top: 0.25rem;
border-bottom: 1px solid var(--line);
}
.lib-group:first-child { margin-top: 0; padding-top: 0.15rem; }
.lib-list { display: grid; gap: 0.35rem; max-height: 70vh; overflow-y: auto; margin: 0 -0.3rem; padding: 0 0.3rem; } .lib-list { display: grid; gap: 0.35rem; max-height: 70vh; overflow-y: auto; margin: 0 -0.3rem; padding: 0 0.3rem; }
.lib-item { .lib-item {
display: grid; display: grid;
@@ -2004,6 +2096,13 @@ article.machine.flash { animation: flash 2s var(--ease); }
.lib-item .t { display: block; font-size: var(--t-sm); font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); } .lib-item .t { display: block; font-size: var(--t-sm); font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); }
.lib-item .m { display: flex; flex-wrap: wrap; gap: 0.15rem 0.6rem; font-size: var(--t-xs); color: var(--muted); margin-top: 0.12rem; font-variant-numeric: tabular-nums; } .lib-item .m { display: flex; flex-wrap: wrap; gap: 0.15rem 0.6rem; font-size: var(--t-xs); color: var(--muted); margin-top: 0.12rem; font-variant-numeric: tabular-nums; }
.lib-item .side { display: flex; gap: 0.25rem; align-items: center; } .lib-item .side { display: flex; gap: 0.25rem; align-items: center; }
.lib-move {
max-width: 7.5rem;
margin: 0;
padding: 0.2rem 0.35rem;
font-size: var(--t-xs);
border-radius: 6px;
}
.lib-info { position: relative; } .lib-info { position: relative; }
.lib-info > summary { list-style: none; } .lib-info > summary { list-style: none; }
.lib-info > summary::-webkit-details-marker { display: none; } .lib-info > summary::-webkit-details-marker { display: none; }
@@ -2370,6 +2469,7 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); }
border: 1px solid var(--line); border: 1px solid var(--line);
} }
.chart-label { fill: var(--muted); font-size: 11px; } .chart-label { fill: var(--muted); font-size: 11px; }
.chart-axis { fill: var(--faint); font-size: 10px; font-weight: 650; letter-spacing: 0.04em; }
.chart-legend { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.35rem; } .chart-legend { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.35rem; }
.chart-legend .leg::before { .chart-legend .leg::before {
content: ""; content: "";
@@ -2383,6 +2483,8 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); }
.chart-legend .nozzle::before { background: #ea580c; } .chart-legend .nozzle::before { background: #ea580c; }
.chart-legend .bed::before { background: #2563eb; } .chart-legend .bed::before { background: #2563eb; }
.chart-legend .chamber::before { background: #16a34a; } .chart-legend .chamber::before { background: #16a34a; }
.chart-legend .ed-n::before { background: #c084fc; }
.chart-legend .ed-b::before { background: #22d3ee; }
.tag { .tag {
display: inline-block; display: inline-block;
font-size: 0.72rem; font-size: 0.72rem;
@@ -2411,11 +2513,56 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); }
} }
.kpi-label { color: var(--muted); font-size: 0.78rem; font-weight: 650; } .kpi-label { color: var(--muted); font-size: 0.78rem; font-weight: 650; }
.spool-akte { .spool-akte {
margin-top: 0.85rem; margin-top: 1rem;
padding-top: 0.7rem; padding-top: 0.85rem;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
} }
.spool-akte h3 { margin: 0 0 0.35rem; font-size: 0.95rem; } .spool-akte h3 { margin: 0 0 0.55rem; font-size: 0.95rem; }
.akte-kpis {
margin-bottom: 0.65rem;
grid-template-columns: repeat(auto-fill, minmax(5.5rem, 1fr));
gap: 0.45rem;
}
.akte-kpis .kpi {
padding: 0.45rem 0.55rem;
border-radius: 10px;
}
.akte-kpis .kpi-val { font-size: 0.98rem; }
.akte-runs { display: flex; flex-direction: column; gap: 0; }
.akte-run {
display: grid;
grid-template-columns: 5.6rem minmax(5.5rem, auto) minmax(0, 1fr) auto auto;
gap: 0.35rem 0.55rem;
align-items: center;
padding: 0.38rem 0;
border-bottom: 1px solid var(--line);
font-size: 0.86rem;
}
.akte-run:last-child { border-bottom: 0; }
.akte-when { color: var(--muted); white-space: nowrap; }
.akte-charge { font-size: 0.72rem; text-decoration: none; }
.akte-file {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.akte-g { font-variant-numeric: tabular-nums; white-space: nowrap; font-weight: 650; }
.akte-state { color: var(--muted); white-space: nowrap; text-align: right; }
@media (max-width: 520px) {
.akte-run {
grid-template-columns: 1fr auto;
grid-template-areas:
"when g"
"charge state"
"file file";
}
.akte-when { grid-area: when; }
.akte-charge { grid-area: charge; }
.akte-file { grid-area: file; }
.akte-g { grid-area: g; }
.akte-state { grid-area: state; }
}
ul.plain { list-style: none; margin: 0; padding: 0; } 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 { padding: 0.2rem 0; border-bottom: 1px solid var(--line); }
ul.plain li:last-child { border-bottom: 0; } ul.plain li:last-child { border-bottom: 0; }
@@ -2423,3 +2570,71 @@ ul.plain li:last-child { border-bottom: 0; }
@media (min-width: 800px) { @media (min-width: 800px) {
.grid-2 { grid-template-columns: 1fr 1fr; } .grid-2 { grid-template-columns: 1fr 1fr; }
} }
/* Abräumen-Dialog + Mobile „Mehr“ */
.abraeumen-dlg,
.mobile-more-sheet {
border: 1px solid var(--line-strong);
border-radius: var(--radius);
padding: 1rem 1.1rem;
background: var(--card);
color: var(--ink);
max-width: min(22rem, calc(100vw - 2rem));
box-shadow: var(--shadow);
}
.abraeumen-dlg::backdrop,
.mobile-more-sheet::backdrop {
background: rgba(0, 0, 0, 0.55);
}
.abraeumen-form h2 { margin: 0 0 0.35rem; font-size: var(--t-lg); }
.abraeumen-form .actions { margin-top: 0.85rem; display: flex; gap: 0.5rem; flex-wrap: wrap; }
.mobile-more-sheet nav {
display: grid;
gap: 0.35rem;
margin-bottom: 0.75rem;
}
.mobile-more-sheet a {
display: block;
padding: 0.65rem 0.75rem;
border-radius: var(--radius-sm);
color: var(--ink);
text-decoration: none;
background: var(--card-2);
border: 1px solid var(--line);
}
.mobile-nav .mobile-more-btn {
appearance: none;
background: transparent;
border: 0;
color: inherit;
font: inherit;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
padding: 0.35rem 0.5rem;
cursor: pointer;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
@media (pointer: coarse) {
.icon-btn, .tiny-btn, .fchip, button, .mobile-nav a, .mobile-nav button {
min-height: 2.75rem;
min-width: 2.75rem;
}
}
@media print {
.sidebar, .mobile-nav, .conn-banner, .toast-host, .nav-toggle { display: none !important; }
.app-shell { display: block; }
.app-main { margin: 0; }
body { background: #fff; color: #111; }
.card, article { box-shadow: none; border-color: #ccc; }
}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "Grotti",
"short_name": "Grotti",
"description": "3D-Druck-Farm steuern",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0c",
"theme_color": "#0a0a0c",
"lang": "de",
"icons": [
{
"src": "/static/brand/favicon.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/brand/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png"
}
]
}
+27
View File
@@ -0,0 +1,27 @@
/* Minimaler Service-Worker: App-Shell-Cache für Offline-Hinweis. */
const CACHE = "grotti-shell-v1";
const SHELL = ["/", "/static/grotti.css?v=54", "/static/api.js?v=28", "/static/ui.js?v=2"];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL).catch(() => undefined)));
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener("fetch", (event) => {
const req = event.request;
if (req.method !== "GET") return;
const url = new URL(req.url);
if (url.pathname.startsWith("/api/")) return;
event.respondWith(
fetch(req).catch(() => caches.match(req).then((hit) => hit || caches.match("/")))
);
});
+208
View File
@@ -0,0 +1,208 @@
/** Live-UI: Verbindungsbanner, Poller, gemeinsame Zustands-Labels. */
const RUN_STATES = {
completed: "fertig",
cancelled: "abgebrochen",
error: "Fehler",
in_progress: "druckt",
printing: "druckt",
paused: "pausiert",
interrupted: "unterbrochen",
standby: "bereit",
heating: "heizt",
complete: "fertig",
offline: "offline",
waiting: "wartet",
running: "läuft",
};
let connBannerOk = true;
let connBannerTimer = 0;
function setConnectionBanner(ok, text) {
let el = document.getElementById("conn-banner");
if (!el) {
el = document.createElement("div");
el.id = "conn-banner";
el.className = "conn-banner";
el.setAttribute("role", "status");
el.hidden = true;
document.body.prepend(el);
}
clearTimeout(connBannerTimer);
if (ok) {
const wasBad = !connBannerOk;
connBannerOk = true;
if (wasBad && text) {
el.textContent = text;
el.className = "conn-banner ok";
el.hidden = false;
connBannerTimer = setTimeout(() => {
el.hidden = true;
el.textContent = "";
}, 2800);
return;
}
el.hidden = true;
el.textContent = "";
el.className = "conn-banner";
return;
}
connBannerOk = false;
el.textContent = text || "Keine Verbindung zum Server.";
el.className = "conn-banner bad";
el.hidden = false;
}
/**
* @param {() => void | Promise<void>} fn
* @param {number} intervalMs
* @returns {{ start: () => void, stop: () => void }}
*/
function createPoller(fn, intervalMs) {
const base = Math.max(1000, Number(intervalMs) || 5000);
const maxMs = 60000;
let timer = 0;
let busy = false;
let delay = base;
let fails = 0;
let running = false;
function schedule() {
if (!running) return;
clearTimeout(timer);
timer = setTimeout(tick, delay);
}
async function tick() {
if (!running || document.hidden || busy) {
schedule();
return;
}
busy = true;
try {
await fn();
delay = base;
fails = 0;
if (!connBannerOk) setConnectionBanner(true, "Wieder verbunden.");
else setConnectionBanner(true, "");
} catch (error) {
fails += 1;
delay = Math.min(maxMs, Math.max(base, delay * 2));
if (fails >= 2) {
setConnectionBanner(false, (error && error.message) || "Daten konnten nicht geladen werden.");
}
} finally {
busy = false;
schedule();
}
}
function onVis() {
if (!document.hidden && running) tick();
}
return {
start() {
if (running) return;
running = true;
document.addEventListener("visibilitychange", onVis);
tick();
},
stop() {
running = false;
document.removeEventListener("visibilitychange", onVis);
clearTimeout(timer);
timer = 0;
},
};
}
const VERDICT_REASON_LABELS = {
haftung: "Haftung",
warping: "Warping",
spaghetti: "Spaghetti",
layer_shift: "Layer-Shift",
verstopfung: "Verstopfung",
farbe: "Farbe",
sonstiges: "Sonstiges",
};
/**
* Dialog vor „Platte geräumt“: Urteil, optional Grund und Stückzahlen.
* @returns {Promise<object|null>} Payload für /abraeumen oder null bei Abbruch
*/
function promptAbraeumen(hostLabel) {
return new Promise((resolve) => {
const existing = document.getElementById("abraeumen-dlg");
if (existing) existing.remove();
const dlg = document.createElement("dialog");
dlg.id = "abraeumen-dlg";
dlg.className = "abraeumen-dlg";
const reasons = Object.entries(VERDICT_REASON_LABELS)
.map(([k, label]) => `<option value="${k}">${label}</option>`)
.join("");
dlg.innerHTML = `
<form method="dialog" class="abraeumen-form">
<h2>Platte geräumt</h2>
<p class="tiny">${escapeHtml(hostLabel || "Drucker")}</p>
<div class="field"><label for="ab-verdict">Urteil</label>
<select id="ab-verdict" name="verdict">
<option value="gut">gut</option>
<option value="ausschuss">Ausschuss</option>
</select>
</div>
<div class="field" id="ab-reason-wrap" hidden>
<label for="ab-reason">Grund</label>
<select id="ab-reason">${reasons}</select>
</div>
<div class="grid-2" id="ab-pieces" hidden>
<div class="field"><label for="ab-ok">Stück gut</label><input id="ab-ok" type="number" min="0" step="1" value="0"></div>
<div class="field"><label for="ab-bad">Stück schlecht</label><input id="ab-bad" type="number" min="0" step="1" value="1"></div>
</div>
<div class="actions">
<button type="submit" value="ok" class="btn-primary">Quittieren</button>
<button type="button" class="ghost" id="ab-cancel">Abbrechen</button>
</div>
</form>`;
document.body.appendChild(dlg);
const verdict = dlg.querySelector("#ab-verdict");
const reasonWrap = dlg.querySelector("#ab-reason-wrap");
const pieces = dlg.querySelector("#ab-pieces");
function sync() {
const scrap = verdict.value === "ausschuss";
reasonWrap.hidden = !scrap;
pieces.hidden = !scrap;
}
verdict.addEventListener("change", sync);
sync();
dlg.querySelector("#ab-cancel").addEventListener("click", () => {
dlg.close();
dlg.remove();
resolve(null);
});
dlg.addEventListener("close", () => {
if (dlg.returnValue !== "ok") {
dlg.remove();
resolve(null);
return;
}
const payload = {
confirm: true,
verdict: verdict.value,
verdict_reason: verdict.value === "ausschuss" ? dlg.querySelector("#ab-reason").value : "",
pieces_ok: Number(dlg.querySelector("#ab-ok").value) || 0,
pieces_bad: Number(dlg.querySelector("#ab-bad").value) || 0,
};
dlg.remove();
resolve(payload);
});
dlg.showModal();
});
}
window.RUN_STATES = RUN_STATES;
window.setConnectionBanner = setConnectionBanner;
window.createPoller = createPoller;
window.promptAbraeumen = promptAbraeumen;
window.VERDICT_REASON_LABELS = VERDICT_REASON_LABELS;
+320
View File
@@ -0,0 +1,320 @@
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Aufträge</h1>
<p class="tiny">Kunden, Angebote, Positionen und Fortschritt.</p>
</div>
<div class="actions">
<a class="ghost" id="lieferschein-link" href="#" hidden target="_blank" rel="noopener">Lieferschein</a>
</div>
</div>
<div class="split">
<section class="card">
<div class="field inline">
<label for="filter-status">Status</label>
<select id="filter-status">
<option value="">alle</option>
<option value="idee">Idee</option>
<option value="angebot">Angebot</option>
<option value="bestaetigt">bestätigt</option>
<option value="druckt">druckt</option>
<option value="fertig">fertig</option>
<option value="geliefert">geliefert</option>
<option value="bezahlt">bezahlt</option>
</select>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Titel</th><th>Status</th><th>Fortschritt</th></tr></thead>
<tbody id="order-rows"></tbody>
</table>
</div>
</section>
<section class="card">
<h2 id="order-title">Neuer Auftrag</h2>
<form id="order-form">
<input id="order-id" type="hidden">
<div class="field"><label for="order-title-in">Titel</label><input id="order-title-in" required></div>
<div class="grid-2">
<div class="field"><label for="order-status">Status</label>
<select id="order-status">
<option value="idee">Idee</option>
<option value="angebot">Angebot</option>
<option value="bestaetigt">bestätigt</option>
<option value="druckt">druckt</option>
<option value="fertig">fertig</option>
<option value="geliefert">geliefert</option>
<option value="bezahlt">bezahlt</option>
</select>
</div>
<div class="field"><label for="order-due">Fällig</label><input id="order-due" type="date"></div>
</div>
<div class="field"><label for="order-customer">Kunde</label>
<select id="order-customer"><option value="">— keiner —</option></select>
</div>
<div class="field"><label for="order-notes">Notiz</label><textarea id="order-notes"></textarea></div>
<div class="actions">
<button type="submit">Merken</button>
<button type="button" class="ghost" id="order-new">Neu</button>
<button type="button" class="warn" id="order-delete" hidden>Löschen</button>
</div>
</form>
<p id="order-note" class="tiny"></p>
<p class="tiny" id="order-progress"></p>
<p class="tiny" id="order-actuals"></p>
<h2 class="section-gap">Positionen</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Datei</th><th class="num">Stk</th><th class="num">fertig</th><th></th></tr></thead>
<tbody id="item-rows"></tbody>
</table>
</div>
<form id="item-form" class="section-gap">
<div class="grid-2">
<div class="field"><label for="item-file">Datei</label><select id="item-file"><option value="">—</option></select></div>
<div class="field"><label for="item-qty">Stück</label><input id="item-qty" type="number" min="1" step="1" value="1"></div>
<div class="field"><label for="item-price">Preis/Stk €</label><input id="item-price" type="number" min="0" step="0.01" value="0"></div>
<div class="field"><label for="item-material">Material</label><input id="item-material"></div>
</div>
<div class="actions"><button type="submit">Position hinzufügen</button></div>
</form>
<details class="section-gap">
<summary>Kunde anlegen</summary>
<form id="customer-form">
<div class="field"><label for="cust-name">Name</label><input id="cust-name" required></div>
<div class="field"><label for="cust-email">E-Mail</label><input id="cust-email" type="email"></div>
<button type="submit">Kunde speichern</button>
</form>
</details>
</section>
</div>
{% endblock %}
{% block script %}
<script>
const STATUS_LABEL = {
idee: "Idee", angebot: "Angebot", bestaetigt: "bestätigt", druckt: "druckt",
fertig: "fertig", geliefert: "geliefert", bezahlt: "bezahlt",
};
let orders = [];
let customers = [];
let files = [];
let current = null;
function fillCustomers() {
const sel = document.getElementById("order-customer");
const val = sel.value;
sel.innerHTML = '<option value="">— keiner —</option>';
customers.forEach((c) => {
const o = document.createElement("option");
o.value = c.id;
o.textContent = c.name;
sel.appendChild(o);
});
if (val) sel.value = val;
}
function fillFiles() {
const sel = document.getElementById("item-file");
sel.innerHTML = '<option value="">—</option>';
files.slice(0, 400).forEach((f) => {
const o = document.createElement("option");
o.value = f.id;
o.textContent = f.name;
sel.appendChild(o);
});
}
function paintActuals(order) {
const el = document.getElementById("order-actuals");
const a = order.actuals;
if (!a) { el.textContent = ""; return; }
el.textContent = "Ist: " + euro(a.cost_total) + " · " + (hours(a.duration_s) || "0 min") + " · Ausschuss " + a.pieces_bad + " Stk";
}
function fillOrder(order) {
current = order;
document.getElementById("order-id").value = order ? order.id : "";
document.getElementById("order-title-in").value = order ? order.title : "";
document.getElementById("order-status").value = order ? order.status : "idee";
document.getElementById("order-due").value = order && order.due_at ? order.due_at.slice(0, 10) : "";
document.getElementById("order-customer").value = order && order.customer_id ? order.customer_id : "";
document.getElementById("order-notes").value = order ? order.notes : "";
document.getElementById("order-title").textContent = order ? order.title : "Neuer Auftrag";
document.getElementById("order-delete").hidden = !order;
const link = document.getElementById("lieferschein-link");
if (order) {
link.href = "/auftraege/" + order.id + "/lieferschein";
link.hidden = false;
} else {
link.hidden = true;
}
const prog = order && order.progress;
document.getElementById("order-progress").textContent = prog ? "Fortschritt: " + prog.label : "";
paintActuals(order || {});
renderItems(order && order.items ? order.items : []);
}
function renderItems(items) {
const body = document.getElementById("item-rows");
body.innerHTML = "";
if (!items.length) {
body.innerHTML = '<tr><td colspan="4" class="empty">Keine Positionen.</td></tr>';
return;
}
items.forEach((item) => {
const tr = document.createElement("tr");
tr.innerHTML = "<td></td><td class=\"num\"></td><td class=\"num\"></td><td class=\"actions\"></td>";
tr.children[0].textContent = item.file_name || ("#" + (item.file_id || "—"));
tr.children[1].textContent = item.qty;
tr.children[2].textContent = item.done_ok + (item.done_bad ? " (−" + item.done_bad + ")" : "");
const btn = document.createElement("button");
btn.type = "button";
btn.className = "ghost tiny-btn";
btn.textContent = "Einreihen";
btn.disabled = !item.file_id || !current;
btn.addEventListener("click", () => einreihen(item.id));
tr.children[3].appendChild(btn);
body.appendChild(tr);
});
}
async function einreihen(itemId) {
if (!current) return;
try {
await api("/auftraege/" + current.id + "/einreihen", {
method: "POST",
json: { item_id: itemId, when_free: true, confirm: true },
});
toast("In Warteschlange.");
const full = await api("/auftraege/" + current.id);
fillOrder(full);
await loadList();
} catch (error) {
showNote("order-note", error);
}
}
function renderList() {
const body = document.getElementById("order-rows");
body.innerHTML = "";
if (!orders.length) {
body.innerHTML = '<tr><td colspan="3" class="empty">Noch kein Auftrag.</td></tr>';
return;
}
orders.forEach((order) => {
const tr = document.createElement("tr");
tr.className = "pick";
tr.innerHTML = "<td></td><td></td><td></td>";
tr.children[0].textContent = order.title;
tr.children[1].textContent = STATUS_LABEL[order.status] || order.status;
tr.children[2].textContent = (order.progress && order.progress.label) || "";
tr.addEventListener("click", async () => {
const full = await api("/auftraege/" + order.id);
fillOrder(full);
});
body.appendChild(tr);
});
}
async function loadList() {
const st = document.getElementById("filter-status").value;
const q = st ? "?status=" + encodeURIComponent(st) : "";
orders = await api("/auftraege" + q);
renderList();
}
async function loadBase() {
[customers, files] = await Promise.all([
api("/kunden").catch(() => []),
api("/dateien").catch(() => []),
]);
fillCustomers();
fillFiles();
await loadList();
fillOrder(null);
}
document.getElementById("filter-status").addEventListener("change", () => loadList().catch((e) => showNote("order-note", e)));
document.getElementById("order-form").addEventListener("submit", async (event) => {
event.preventDefault();
const id = document.getElementById("order-id").value;
const payload = {
title: document.getElementById("order-title-in").value,
status: document.getElementById("order-status").value,
notes: document.getElementById("order-notes").value,
due_at: document.getElementById("order-due").value || null,
customer_id: document.getElementById("order-customer").value || null,
};
try {
const saved = id
? await api("/auftraege/" + id, { method: "PATCH", json: payload })
: await api("/auftraege", { method: "POST", json: payload });
showNote("order-note", null);
document.getElementById("order-note").textContent = "Gemerkt.";
await loadList();
fillOrder(saved);
} catch (error) {
showNote("order-note", error);
}
});
document.getElementById("order-new").addEventListener("click", () => fillOrder(null));
document.getElementById("order-delete").addEventListener("click", (event) => {
const id = document.getElementById("order-id").value;
if (!id) return;
armButton(event.currentTarget, "Auftrag wirklich löschen?", async () => {
await api("/auftraege/" + id, { method: "DELETE" });
toast("Gelöscht.");
fillOrder(null);
loadList();
});
});
document.getElementById("item-form").addEventListener("submit", async (event) => {
event.preventDefault();
if (!current) {
showNote("order-note", new Error("Zuerst Auftrag speichern."));
return;
}
const payload = {
file_id: document.getElementById("item-file").value || null,
qty: Number(document.getElementById("item-qty").value),
unit_price: Number(document.getElementById("item-price").value),
material_type: document.getElementById("item-material").value,
};
try {
await api("/auftraege/" + current.id + "/positionen", { method: "POST", json: payload });
const full = await api("/auftraege/" + current.id);
fillOrder(full);
} catch (error) {
showNote("order-note", error);
}
});
document.getElementById("customer-form").addEventListener("submit", async (event) => {
event.preventDefault();
try {
const row = await api("/kunden", {
method: "POST",
json: {
name: document.getElementById("cust-name").value,
email: document.getElementById("cust-email").value,
},
});
customers.push(row);
fillCustomers();
document.getElementById("order-customer").value = row.id;
document.getElementById("cust-name").value = "";
document.getElementById("cust-email").value = "";
toast("Kunde angelegt.");
} catch (error) {
showNote("order-note", error);
}
});
loadBase().catch((error) => showNote("order-note", error));
</script>
{% endblock %}
+12 -32
View File
@@ -29,33 +29,6 @@ function sourceLabel(source, estimate) {
return source || ""; 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) { function render(data) {
const run = data.run; const run = data.run;
const name = (run.filename || "").split("/").pop() || "ohne Datei"; const name = (run.filename || "").split("/").pop() || "ohne Datei";
@@ -66,7 +39,14 @@ function render(data) {
const planned = Number(run.weight_planned_g) || 0; const planned = Number(run.weight_planned_g) || 0;
const used = Number(run.weight_used_g) || 0; const used = Number(run.weight_used_g) || 0;
const diff = Math.round(used) - Math.round(planned); const diff = Math.round(used) - Math.round(planned);
const cost = Number(run.filament_cost || 0) + Number(run.energy_cost || 0); const energy = data.energy || {};
const energyEuro = energy.cost != null ? Number(energy.cost) : Number(run.energy_cost || 0);
const cost = Number(run.filament_cost || 0) + energyEuro;
const energyHint = energy.source === "ed"
? (Number(energy.kwh) || 0).toLocaleString("de-DE", { maximumFractionDigits: 2 }) + " kWh aus ED"
: (energy.kwh != null
? (Number(energy.kwh) || 0).toLocaleString("de-DE", { maximumFractionDigits: 2 }) + " kWh pauschal"
: "");
const spoolRows = (data.spools || []).map((s) => ` const spoolRows = (data.spools || []).map((s) => `
<tr> <tr>
@@ -79,7 +59,7 @@ function render(data) {
</tr>`).join("") || '<tr><td class="empty" colspan="6">Keine Spule zugeordnet.</td></tr>'; </tr>`).join("") || '<tr><td class="empty" colspan="6">Keine Spule zugeordnet.</td></tr>';
const events = (data.events || []).map((e) => 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>` `<li><span class="tiny">${escapeHtml(formatDateTime(e.created_at))}</span> ${escapeHtml(e.message || e.kind)}</li>`
).join("") || "<li class=\"tiny\">Keine Ereignisse.</li>"; ).join("") || "<li class=\"tiny\">Keine Ereignisse.</li>";
const labels = (data.labels || []).filter((l) => !l.excluded); const labels = (data.labels || []).filter((l) => !l.excluded);
@@ -96,11 +76,11 @@ function render(data) {
<div class="card bericht-head"> <div class="card bericht-head">
<div class="bericht-meta"> <div class="bericht-meta">
<p><b>${escapeHtml(states[run.state] || run.state)}</b>${run.verdict ? " · Urteil " + escapeHtml(run.verdict) : ""}</p> <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 class="tiny">${escapeHtml(formatDateRange(run.started_at, run.ended_at))}</p>
<p>Dauer <b>${hours(run.duration_s)}</b>${run.estimated_s ? " · Soll " + hours(run.estimated_s) : ""}</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)" : ""} <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> ${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> <p>Kosten <b>${euro(cost)}</b> (Filament ${euro(run.filament_cost)} · Strom ${euro(energyEuro)}${energyHint ? " · " + energyHint : ""})</p>
${run.message ? "<p class=\"msg\">" + escapeHtml(run.message) + "</p>" : ""} ${run.message ? "<p class=\"msg\">" + escapeHtml(run.message) + "</p>" : ""}
${data.project ? "<p class=\"tiny\">Projekt: " + escapeHtml(data.project.name) + "</p>" : ""} ${data.project ? "<p class=\"tiny\">Projekt: " + escapeHtml(data.project.name) + "</p>" : ""}
</div> </div>
@@ -115,7 +95,7 @@ function render(data) {
</div> </div>
<div class="card"> <div class="card">
<h2>Verlauf</h2> <h2>Verlauf</h2>
${chart(data.samples)} ${tempChartHtml(data.samples)}
</div> </div>
<div class="card grid-2"> <div class="card grid-2">
<div> <div>
+267 -18
View File
@@ -11,6 +11,7 @@
</select> </select>
<label class="button ghost" for="upload-file"><svg class="ico"><use href="#i-upload"/></svg>Hochladen</label> <label class="button ghost" for="upload-file"><svg class="ico"><use href="#i-upload"/></svg>Hochladen</label>
<input id="upload-file" type="file" accept=".gcode,.3mf,.gcode.3mf" multiple hidden> <input id="upload-file" type="file" accept=".gcode,.3mf,.gcode.3mf" multiple hidden>
<button type="button" class="ghost" id="folder-create" hidden><svg class="ico"><use href="#i-folder"/></svg>Ordner anlegen</button>
<button type="button" class="ghost" id="rescan"><svg class="ico"><use href="#i-refresh"/></svg>Ordner neu lesen</button> <button type="button" class="ghost" id="rescan"><svg class="ico"><use href="#i-refresh"/></svg>Ordner neu lesen</button>
</div> </div>
</div> </div>
@@ -22,7 +23,7 @@
<span class="count" id="lib-count">0</span> <span class="count" id="lib-count">0</span>
</div> </div>
<div class="lib-tools"> <div class="lib-tools">
<input id="file-search" type="search" placeholder="Datei suchen…" autocomplete="off"> <input id="file-search" type="search" placeholder="Name, Material, Projekt…" autocomplete="off">
<select id="file-filter-family"> <select id="file-filter-family">
<option value="">Alle Maschinen</option> <option value="">Alle Maschinen</option>
<option value="kobra_3">Kobra 3</option> <option value="kobra_3">Kobra 3</option>
@@ -31,6 +32,20 @@
<select id="file-filter-project"> <select id="file-filter-project">
<option value="">Alle Projekte</option> <option value="">Alle Projekte</option>
</select> </select>
<select id="file-filter-material" title="Material">
<option value="">Alle Materialien</option>
</select>
<select id="file-filter-folder" title="Ordner">
<option value="">Alle Ordner</option>
</select>
<select id="file-sort" title="Sortierung">
<option value="mtime">Neueste</option>
<option value="name">Name A–Z</option>
<option value="material">Material</option>
<option value="folder">Ordner</option>
<option value="time">Druckzeit</option>
<option value="weight">Gewicht</option>
</select>
</div> </div>
<p id="file-note" class="tiny"></p> <p id="file-note" class="tiny"></p>
<div class="lib-list" id="lib-list"> <div class="lib-list" id="lib-list">
@@ -152,8 +167,11 @@
const families = { kobra_s1: "Kobra S1", kobra_3: "Kobra 3" }; const families = { kobra_s1: "Kobra S1", kobra_3: "Kobra 3" };
const ABLAUF_KEY = "grotti-ablauf-v2"; const ABLAUF_KEY = "grotti-ablauf-v2";
const SEND_KEY = "grotti-send-v1"; const SEND_KEY = "grotti-send-v1";
const LIB_KEY = "grotti-lib-v1";
const STEP_TITLES = { print: "Drucken", cool: "Abkühlen", sweep: "Schieben", check: "Tasten" }; const STEP_TITLES = { print: "Drucken", cool: "Abkühlen", sweep: "Schieben", check: "Tasten" };
let files = []; let files = [];
let knownFolders = [];
let hasWatchDir = false;
let printers = []; let printers = [];
let spools = []; let spools = [];
let projects = []; let projects = [];
@@ -470,17 +488,101 @@ function collectAceMaps() {
/* ---------- Bibliothek ---------- */ /* ---------- Bibliothek ---------- */
function projectNameOf(file) {
const project = projects.find((p) => String(p.id) === String(file.project_id));
return project ? project.name : "";
}
function materialKey(file) {
return materialText(file) || "";
}
function folderKey(file) {
const folder = file.folder;
if (folder === "Upload") return "Upload";
if (folder) return folder;
return "";
}
function folderLabel(folder) {
if (folder === "Upload") return "Upload";
if (folder) return folder;
return "Wurzel";
}
function saveLibMemory() {
try {
localStorage.setItem(LIB_KEY, JSON.stringify({
search: document.getElementById("file-search").value || "",
family: document.getElementById("file-filter-family").value || "",
project: document.getElementById("file-filter-project").value || "",
material: document.getElementById("file-filter-material").value || "",
folder: document.getElementById("file-filter-folder").value || "",
sort: document.getElementById("file-sort").value || "mtime",
}));
} catch (error) { /* ignore */ }
}
function loadLibMemory() {
try {
const data = JSON.parse(localStorage.getItem(LIB_KEY) || "{}");
const set = (id, value) => {
const node = document.getElementById(id);
if (!node || value == null) return;
node.value = value;
};
set("file-search", data.search || "");
set("file-filter-family", data.family || "");
set("file-filter-project", data.project || "");
set("file-filter-material", data.material || "");
set("file-filter-folder", data.folder || "");
set("file-sort", data.sort || "mtime");
} catch (error) { /* ignore */ }
}
function filteredFiles() { function filteredFiles() {
const q = (document.getElementById("file-search").value || "").trim().toLowerCase(); const q = (document.getElementById("file-search").value || "").trim().toLowerCase();
const family = document.getElementById("file-filter-family").value; const family = document.getElementById("file-filter-family").value;
const project = document.getElementById("file-filter-project").value; const project = document.getElementById("file-filter-project").value;
return files.filter((file) => { const material = document.getElementById("file-filter-material").value;
const folder = document.getElementById("file-filter-folder").value;
const sort = document.getElementById("file-sort").value || "mtime";
const shown = files.filter((file) => {
if (family && file.printer_family !== family) return false; if (family && file.printer_family !== family) return false;
if (project === "none" && file.project_id) return false; if (project === "none" && file.project_id) return false;
if (project && project !== "none" && String(file.project_id) !== project) return false; if (project && project !== "none" && String(file.project_id) !== project) return false;
if (material === "none" && materialKey(file)) return false;
if (material && material !== "none") {
const types = ((file.filaments) || []).map((f) => f.type).filter(Boolean);
if (!types.includes(material)) return false;
}
if (folder === "root" && folderKey(file)) return false;
if (folder && folder !== "root" && folderKey(file) !== folder) return false;
if (!q) return true; if (!q) return true;
return String(file.name || "").toLowerCase().includes(q); const hay = [
file.name || "",
materialKey(file),
projectNameOf(file),
folderKey(file),
].join(" ").toLowerCase();
return hay.includes(q);
}); });
const cmpStr = (a, b) => String(a || "").localeCompare(String(b || ""), "de", { sensitivity: "base" });
shown.sort((a, b) => {
if (sort === "name") return cmpStr(a.name, b.name) || (Number(b.mtime) || 0) - (Number(a.mtime) || 0);
if (sort === "material") {
return cmpStr(materialKey(a) || "\uffff", materialKey(b) || "\uffff")
|| cmpStr(a.name, b.name);
}
if (sort === "folder") {
return cmpStr(folderLabel(folderKey(a)), folderLabel(folderKey(b)))
|| cmpStr(a.name, b.name);
}
if (sort === "time") return (Number(b.estimated_s) || 0) - (Number(a.estimated_s) || 0) || cmpStr(a.name, b.name);
if (sort === "weight") return (Number(b.weight_g) || 0) - (Number(a.weight_g) || 0) || cmpStr(a.name, b.name);
return (Number(b.mtime) || 0) - (Number(a.mtime) || 0) || cmpStr(a.name, b.name);
});
return shown;
} }
function fillProjectFilter() { function fillProjectFilter() {
@@ -496,6 +598,70 @@ function fillProjectFilter() {
if ([...select.options].some((o) => o.value === current)) select.value = current; if ([...select.options].some((o) => o.value === current)) select.value = current;
} }
function fillMaterialFilter() {
const select = document.getElementById("file-filter-material");
const current = select.value;
const types = [...new Set(files.flatMap((f) => ((f.filaments) || []).map((x) => x.type).filter(Boolean)))];
types.sort((a, b) => a.localeCompare(b, "de", { sensitivity: "base" }));
select.innerHTML = '<option value="">Alle Materialien</option><option value="none">Ohne Material</option>';
types.forEach((type) => {
const option = document.createElement("option");
option.value = type;
option.textContent = type;
select.appendChild(option);
});
if ([...select.options].some((o) => o.value === current)) select.value = current;
}
function allFolderNames() {
const fromFiles = files.map((f) => folderKey(f)).filter((f) => f && f !== "Upload");
return [...new Set([...knownFolders, ...fromFiles])]
.sort((a, b) => folderLabel(a).localeCompare(folderLabel(b), "de", { sensitivity: "base" }));
}
function fillFolderFilter() {
const select = document.getElementById("file-filter-folder");
const current = select.value;
const folders = allFolderNames();
select.innerHTML = '<option value="">Alle Ordner</option><option value="root">Wurzel</option>';
folders.forEach((folder) => {
const option = document.createElement("option");
option.value = folder;
option.textContent = folderLabel(folder);
select.appendChild(option);
});
if ([...select.options].some((o) => o.value === current)) select.value = current;
}
function moveSelectHtml(file) {
if (!hasWatchDir || file.source !== "watch") return "";
const current = folderKey(file);
const opts = [
`<option value="" selected disabled>Verschieben…</option>`,
`<option value="__root__">Wurzel</option>`,
...allFolderNames().map((folder) =>
`<option value="${escapeHtml(folder)}"${folder === current ? " disabled" : ""}>${escapeHtml(folderLabel(folder))}</option>`
),
`<option value="__new__">Neuer Ordner…</option>`,
];
return `<select class="lib-move" data-move="${file.id}" title="In Ordner verschieben" onclick="event.stopPropagation()">${opts.join("")}</select>`;
}
async function promptCreateFolder() {
const name = window.prompt("Neuer Ordner unter dem Slice-Pfad (z. B. PETG oder PLA/tests):", "");
if (name == null) return null;
const trimmed = String(name).trim();
if (!trimmed) {
toast("Ordnername fehlt.", "bad");
return null;
}
const result = await api("/dateien/ordner", { method: "POST", json: { folder: trimmed } });
knownFolders = result.folders || [];
fillFolderFilter();
toast("Ordner „" + (result.folder || trimmed) + "“ angelegt.", "good");
return result.folder || trimmed;
}
function fillProjects() { function fillProjects() {
const opts = projects.map((row) => const opts = projects.map((row) =>
`<option value="${escapeHtml(row.id)}">${escapeHtml(row.name)}</option>` `<option value="${escapeHtml(row.id)}">${escapeHtml(row.name)}</option>`
@@ -585,9 +751,21 @@ function thumbHtml(file) {
: `<span class="ph">${icon("cube")}</span>`; : `<span class="ph">${icon("cube")}</span>`;
} }
function libItemHtml(file) {
const project = projects.find((p) => String(p.id) === String(file.project_id));
const del = file.source === "upload"
? `<button type="button" class="icon-btn" data-delete="${file.id}" title="Hochgeladene Datei löschen">${icon("x")}</button>` : "";
return `<div class="lib-item ${String(file.id) === String(sel.fileId) ? "on" : ""}" role="button" tabindex="0" data-file="${file.id}">
${thumbHtml(file)}
<span style="min-width:0"><span class="t" title="${escapeHtml(file.name)}">${escapeHtml(file.name)}</span><span class="m">${alwaysMeta(file, project)}</span></span>
<span class="side">${moveSelectHtml(file)}${infoPopupHtml(file)}${del}</span>
</div>`;
}
function renderLibrary() { function renderLibrary() {
const list = document.getElementById("lib-list"); const list = document.getElementById("lib-list");
const shown = filteredFiles(); const shown = filteredFiles();
const sort = document.getElementById("file-sort").value || "mtime";
document.getElementById("lib-count").textContent = shown.length === files.length ? String(files.length) : shown.length + "/" + files.length; document.getElementById("lib-count").textContent = shown.length === files.length ? String(files.length) : shown.length + "/" + files.length;
if (!files.length) { if (!files.length) {
list.innerHTML = '<div class="picked-empty">' + icon("file") + '<span>Noch keine Datei.</span><span class="tiny">Hochladen oder Slice-Ordner in den Einstellungen setzen.</span></div>'; list.innerHTML = '<div class="picked-empty">' + icon("file") + '<span>Noch keine Datei.</span><span class="tiny">Hochladen oder Slice-Ordner in den Einstellungen setzen.</span></div>';
@@ -597,16 +775,19 @@ function renderLibrary() {
list.innerHTML = '<div class="picked-empty">' + icon("search") + '<span>Keine Datei passt.</span></div>'; list.innerHTML = '<div class="picked-empty">' + icon("search") + '<span>Keine Datei passt.</span></div>';
return; return;
} }
list.innerHTML = shown.map((file) => { const parts = [];
const project = projects.find((p) => String(p.id) === String(file.project_id)); let lastGroup = null;
const del = file.source === "upload" shown.forEach((file) => {
? `<button type="button" class="icon-btn" data-delete="${file.id}" title="Hochgeladene Datei löschen">${icon("x")}</button>` : ""; let group = null;
return `<div class="lib-item ${String(file.id) === String(sel.fileId) ? "on" : ""}" role="button" tabindex="0" data-file="${file.id}"> if (sort === "material") group = materialKey(file) || "Ohne Material";
${thumbHtml(file)} else if (sort === "folder") group = folderLabel(folderKey(file));
<span style="min-width:0"><span class="t" title="${escapeHtml(file.name)}">${escapeHtml(file.name)}</span><span class="m">${alwaysMeta(file, project)}</span></span> if (group != null && group !== lastGroup) {
<span class="side">${infoPopupHtml(file)}${del}</span> parts.push(`<div class="lib-group">${escapeHtml(group)}</div>`);
</div>`; lastGroup = group;
}).join(""); }
parts.push(libItemHtml(file));
});
list.innerHTML = parts.join("");
} }
/* ---------- Flow rechts ---------- */ /* ---------- Flow rechts ---------- */
@@ -795,18 +976,23 @@ function selectFile(id) {
/* ---------- Laden ---------- */ /* ---------- Laden ---------- */
async function load() { async function load() {
const [settings, fileRows, printerRows, spoolRows, projectRows, defaults] = await Promise.all([ const [settings, fileRows, printerRows, spoolRows, projectRows, defaults, folderPayload] = await Promise.all([
api("/einstellungen"), api("/einstellungen"),
api("/dateien"), api("/dateien"),
api("/drucker?live=1"), api("/drucker?live=1"),
api("/spulen"), api("/spulen"),
api("/projekte").catch(() => []), api("/projekte").catch(() => []),
api("/abraeumen-defaults").catch(() => null), api("/abraeumen-defaults").catch(() => null),
api("/dateien/ordner").catch(() => ({ folders: [] })),
]); ]);
document.getElementById("watch").textContent = settings.watch_dir hasWatchDir = !!(settings.watch_dir);
document.getElementById("watch").textContent = hasWatchDir
? "Slice-Ordner: " + settings.watch_dir ? "Slice-Ordner: " + settings.watch_dir
: "Kein Slice-Ordner — Dateien hier hochladen."; : "Kein Slice-Ordner — Dateien hier hochladen.";
const createBtn = document.getElementById("folder-create");
if (createBtn) createBtn.hidden = !hasWatchDir;
files = fileRows; files = fileRows;
knownFolders = (folderPayload && folderPayload.folders) || [];
printers = printerRows; printers = printerRows;
spools = spoolRows; spools = spoolRows;
projects = projectRows || []; projects = projectRows || [];
@@ -816,11 +1002,26 @@ async function load() {
if (sel.fileId && !selectedFile()) sel.fileId = null; if (sel.fileId && !selectedFile()) sel.fileId = null;
fillProjects(); fillProjects();
fillProjectFilter(); fillProjectFilter();
fillMaterialFilter();
fillFolderFilter();
loadLibMemory();
fillProjectFilter();
fillMaterialFilter();
fillFolderFilter();
renderLibrary(); renderLibrary();
renderTabs(); renderTabs();
refreshFlow(); refreshFlow();
const file = selectedFile(); const file = selectedFile();
if (file) showToolpath(file, 0); if (file) showToolpath(file, 0);
// Deep-Link von Orca /orca_senden: Senden in den Blick, Optionen offen für ACE
if (new URLSearchParams(location.search).get("file") && file) {
sel.tab = "send";
renderTabs();
const opts = document.getElementById("options");
if (opts) opts.open = true;
const panel = document.querySelector(".send-panel");
if (panel) panel.scrollIntoView({ behavior: "smooth", block: "start" });
}
} }
async function refreshPrinters() { async function refreshPrinters() {
@@ -1161,6 +1362,10 @@ async function doAblauf() {
/* ---------- Ereignisse ---------- */ /* ---------- Ereignisse ---------- */
document.getElementById("lib-list").addEventListener("click", (event) => { document.getElementById("lib-list").addEventListener("click", (event) => {
if (event.target.closest(".lib-move")) {
event.stopPropagation();
return;
}
const del = event.target.closest("[data-delete]"); const del = event.target.closest("[data-delete]");
if (del) { if (del) {
event.stopPropagation(); event.stopPropagation();
@@ -1179,6 +1384,29 @@ document.getElementById("lib-list").addEventListener("click", (event) => {
const item = event.target.closest("[data-file]"); const item = event.target.closest("[data-file]");
if (item) selectFile(item.dataset.file); if (item) selectFile(item.dataset.file);
}); });
document.getElementById("lib-list").addEventListener("change", async (event) => {
const select = event.target.closest("select.lib-move");
if (!select) return;
event.stopPropagation();
const fileId = select.dataset.move;
let folder = select.value;
select.value = "";
if (!folder || !fileId) return;
try {
if (folder === "__new__") {
folder = await promptCreateFolder();
if (!folder) return;
} else if (folder === "__root__") {
folder = "";
}
await api("/dateien/" + fileId + "/verschieben", { method: "POST", json: { folder } });
toast(folder ? ("Nach „" + folder + "“ verschoben.") : "In die Wurzel verschoben.", "good");
await load();
} catch (error) {
toast(error.message || String(error), "bad");
}
});
document.getElementById("lib-list").addEventListener("keydown", (event) => { document.getElementById("lib-list").addEventListener("keydown", (event) => {
const item = event.target.closest("[data-file]"); const item = event.target.closest("[data-file]");
if (item && (event.key === "Enter" || event.key === " ")) { if (item && (event.key === "Enter" || event.key === " ")) {
@@ -1186,9 +1414,22 @@ document.getElementById("lib-list").addEventListener("keydown", (event) => {
selectFile(item.dataset.file); selectFile(item.dataset.file);
} }
}); });
["file-search", "file-filter-family", "file-filter-project"].forEach((id) => { [
document.getElementById(id).addEventListener("input", renderLibrary); "file-search",
document.getElementById(id).addEventListener("change", renderLibrary); "file-filter-family",
"file-filter-project",
"file-filter-material",
"file-filter-folder",
"file-sort",
].forEach((id) => {
const node = document.getElementById(id);
if (!node) return;
const onChange = () => {
saveLibMemory();
renderLibrary();
};
node.addEventListener("input", onChange);
node.addEventListener("change", onChange);
}); });
document.getElementById("targets").addEventListener("click", (event) => { document.getElementById("targets").addEventListener("click", (event) => {
@@ -1323,6 +1564,14 @@ const libCard = document.getElementById("lib-card");
})); }));
libCard.addEventListener("drop", (event) => uploadFiles(event.dataTransfer.files)); libCard.addEventListener("drop", (event) => uploadFiles(event.dataTransfer.files));
document.getElementById("folder-create")?.addEventListener("click", async () => {
try {
await promptCreateFolder();
} catch (error) {
toast(error.message || String(error), "bad");
}
});
document.getElementById("rescan").addEventListener("click", async () => { document.getElementById("rescan").addEventListener("click", async () => {
try { try {
const result = await api("/dateien/neu-lesen", { method: "POST" }); const result = await api("/dateien/neu-lesen", { method: "POST" });
+201 -4
View File
@@ -18,6 +18,17 @@
</select> </select>
</div> </div>
<p class="hint">Regal: größere Bedienelemente, kompakte Navigation — ideal für ~1024×860 Touch am Regal. Shortcut: <code>?shelf=1</code></p> <p class="hint">Regal: größere Bedienelemente, kompakte Navigation — ideal für ~1024×860 Touch am Regal. Shortcut: <code>?shelf=1</code></p>
<div class="field" style="max-width:16rem; margin-top:0.85rem">
<label for="tz-mode">Zeitzone</label>
<select id="tz-mode">
<option value="auto">Browser (automatisch)</option>
<option value="Europe/Berlin">Europe/Berlin</option>
<option value="Europe/Vienna">Europe/Vienna</option>
<option value="Europe/Zurich">Europe/Zurich</option>
<option value="UTC">UTC</option>
</select>
</div>
<p class="hint">Für Protokoll, Bericht und Aktivitätszeiten. Nur dieser Browser.</p>
<p id="display-note" class="ok" hidden>Gespeichert — gilt sofort.</p> <p id="display-note" class="ok" hidden>Gespeichert — gilt sofort.</p>
</section> </section>
@@ -29,6 +40,20 @@
<p id="auto-note"></p> <p id="auto-note"></p>
</section> </section>
<section class="card admin-only">
<h2>Slice-Ordner</h2>
<p class="hint">Ordner, den Grotti für die Bibliothek beobachtet (Orca-Export, „Ordner neu lesen“). Leer = nur manueller Upload. Standard: Ordner <code>slice</code> im Grotti-Repo.</p>
<div class="field" style="max-width:36rem">
<label for="watch-dir">Pfad</label>
<input id="watch-dir" type="text" autocomplete="off" placeholder="D:\Projekte\Grotti\slice" spellcheck="false">
</div>
<div class="actions">
<button type="button" id="watch-save">Speichern</button>
<button type="button" class="ghost" id="watch-default">Standardordner</button>
</div>
<p id="watch-note"></p>
</section>
<section class="card admin-only"> <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.&nbsp;B. localhost).</p> <p class="hint">FQDN bzw. Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host der aktuellen Anfrage (z.&nbsp;B. localhost).</p>
@@ -101,17 +126,53 @@
<p id="tg-note"></p> <p id="tg-note"></p>
</section> </section>
<section class="card admin-only">
<h2>ntfy</h2>
<label class="check"><input type="checkbox" id="ntfy-enabled"> Push über ntfy</label>
<div class="grid-2" style="margin-top:0.75rem">
<div class="field">
<label for="ntfy-server">Server</label>
<input id="ntfy-server" placeholder="https://ntfy.sh" autocomplete="off">
</div>
<div class="field">
<label for="ntfy-topic">Topic</label>
<input id="ntfy-topic" placeholder="grotti-werkstatt" autocomplete="off">
</div>
<div class="field" style="grid-column:1/-1">
<label for="ntfy-token">Token (optional)</label>
<input id="ntfy-token" type="password" autocomplete="off" placeholder="Bearer-Token">
</div>
</div>
<p class="hint">Gleiche Meldungen wie Telegram — unabhängig abschaltbar.</p>
<div class="actions"><button type="button" id="ntfy-save">Speichern</button></div>
<p id="ntfy-note"></p>
</section>
<section class="card admin-only">
<h2>Backup</h2>
<p class="hint">PostgreSQL-Dump und Datenordner (ohne toolpath/ und alte Backups).</p>
<div class="actions">
<button type="button" id="backup-now">Backup jetzt</button>
</div>
<ul id="backup-list" class="tiny"></ul>
<p id="backup-note"></p>
</section>
<section class="card admin-only"> <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">Kosten im Protokoll. Mit Düse-/Bett-Watt und ED-Proben schätzt Grotti den Verbrauch; sonst Dauer × Grundlast. Filament braucht eine Spule.</p>
<div class="grid-2"> <div class="grid-2">
<div class="field"><label for="en-watts">Drucker W</label><input id="en-watts" type="number" step="1"></div> <div class="field"><label for="en-watts">Grundlast W</label><input id="en-watts" type="number" step="1" title="Elektronik/Motion — nicht die Heizer"></div>
<div class="field"><label for="en-kwh">€ / kWh</label><input id="en-kwh" type="number" step="0.01"></div> <div class="field"><label for="en-kwh">€ / kWh</label><input id="en-kwh" type="number" step="0.01"></div>
<div class="field"><label for="en-nozzle">Düse W</label><input id="en-nozzle" type="number" step="1" placeholder="z. B. 60"></div>
<div class="field"><label for="en-bed">Bett W</label><input id="en-bed" type="number" step="1" placeholder="z. B. 220"></div>
<div class="field"><label for="en-dryer">Trockner W</label><input id="en-dryer" type="number" step="1"></div> <div class="field"><label for="en-dryer">Trockner W</label><input id="en-dryer" type="number" step="1"></div>
<div class="field"><label for="en-dryh">Trockenstunden</label><input id="en-dryh" type="number" step="0.1"></div> <div class="field"><label for="en-dryh">Trockenstunden</label><input id="en-dryh" type="number" step="0.1"></div>
<div class="field"><label for="en-waste">Ausschuss %</label><input id="en-waste" type="number" step="0.1"></div> <div class="field"><label for="en-waste">Ausschuss %</label><input id="en-waste" type="number" step="0.1"></div>
<div class="field"><label for="en-util">Auslastung %</label><input id="en-util" type="number" step="1"></div> <div class="field"><label for="en-util">Auslastung %</label><input id="en-util" type="number" step="1"></div>
</div> </div>
<p class="hint">Grundlast = Elektronik/Motion (nicht ganzer Drucker). Düse/Bett 0 = nur pauschal. Typisch Kobra: Düse ~60 W, Bett ~200–300 W.</p>
<p id="en-ed-hint" class="hint" hidden></p>
<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>
@@ -330,6 +391,11 @@ async function load() {
]); ]);
if (isAdmin) renderPrinters(printers); if (isAdmin) renderPrinters(printers);
document.getElementById("auto-queue").checked = settings.auto_queue !== false; document.getElementById("auto-queue").checked = settings.auto_queue !== false;
const watchDir = document.getElementById("watch-dir");
if (watchDir) {
watchDir.value = settings.watch_dir || "";
watchDir.dataset.default = settings.watch_dir_default || "";
}
const fqdn = document.getElementById("fqdn"); const fqdn = document.getElementById("fqdn");
if (fqdn) fqdn.value = settings.fqdn || ""; if (fqdn) fqdn.value = settings.fqdn || "";
const tgEnabled = document.getElementById("tg-enabled"); const tgEnabled = document.getElementById("tg-enabled");
@@ -347,17 +413,28 @@ async function load() {
document.getElementById("al-queue").checked = alerts.queue !== false; document.getElementById("al-queue").checked = alerts.queue !== false;
document.getElementById("al-ablauf").checked = !!alerts.ablauf; document.getElementById("al-ablauf").checked = !!alerts.ablauf;
document.getElementById("al-start").checked = !!alerts.start; document.getElementById("al-start").checked = !!alerts.start;
const ntfy = settings.ntfy || {};
document.getElementById("ntfy-enabled").checked = !!ntfy.enabled;
document.getElementById("ntfy-server").value = ntfy.server || "https://ntfy.sh";
document.getElementById("ntfy-topic").value = ntfy.topic || "";
document.getElementById("ntfy-token").value = "";
document.getElementById("ntfy-token").placeholder = ntfy.token_set
? ("gesetzt: " + (ntfy.token || "••••"))
: "optional";
const en = settings.energy || {}; const en = settings.energy || {};
document.getElementById("en-watts").value = en.watts ?? 120; document.getElementById("en-watts").value = en.watts ?? 40;
document.getElementById("en-nozzle").value = en.nozzle_watts ?? 60;
document.getElementById("en-bed").value = en.bed_watts ?? 220;
document.getElementById("en-kwh").value = en.euro_per_kwh ?? 0.35; document.getElementById("en-kwh").value = en.euro_per_kwh ?? 0.35;
document.getElementById("en-dryer").value = en.dryer_watts ?? 0; document.getElementById("en-dryer").value = en.dryer_watts ?? 0;
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;
updateEnergyEdHint();
} }
const legacy = document.getElementById("legacy-token-hint"); const legacy = document.getElementById("legacy-token-hint");
if (legacy) legacy.hidden = !settings.legacy_token_set; if (legacy) legacy.hidden = !settings.legacy_token_set;
await Promise.all([loadUsers(), loadTokens()]); await Promise.all([loadUsers(), loadTokens(), loadBackups()]);
} }
document.getElementById("user-add")?.addEventListener("submit", async (event) => { document.getElementById("user-add")?.addEventListener("submit", async (event) => {
@@ -417,6 +494,30 @@ document.getElementById("auto-save").addEventListener("click", async () => {
} }
}); });
document.getElementById("watch-default")?.addEventListener("click", () => {
const input = document.getElementById("watch-dir");
if (input) input.value = input.dataset.default || "";
});
document.getElementById("watch-save")?.addEventListener("click", async () => {
try {
const out = await api("/einstellungen", {
method: "PATCH",
json: { watch_dir: document.getElementById("watch-dir").value.trim() },
});
document.getElementById("watch-dir").value = out.watch_dir || "";
showNote(
"watch-note",
null,
out.watch_dir
? "Slice-Ordner aktiv: " + out.watch_dir
: "Kein Slice-Ordner — nur manueller Upload."
);
} catch (error) {
showNote("watch-note", error);
}
});
document.getElementById("fqdn-save").addEventListener("click", async () => { document.getElementById("fqdn-save").addEventListener("click", async () => {
try { try {
const out = await api("/einstellungen", { const out = await api("/einstellungen", {
@@ -481,6 +582,49 @@ document.getElementById("tg-save").addEventListener("click", async () => {
} }
}); });
document.getElementById("ntfy-save")?.addEventListener("click", async () => {
const token = document.getElementById("ntfy-token").value.trim();
const payload = {
ntfy: {
enabled: document.getElementById("ntfy-enabled").checked,
server: document.getElementById("ntfy-server").value.trim() || "https://ntfy.sh",
topic: document.getElementById("ntfy-topic").value.trim(),
},
};
if (token) payload.ntfy.token = token;
try {
await api("/einstellungen", { method: "PATCH", json: payload });
showNote("ntfy-note", null, "ntfy gespeichert.");
load();
} catch (error) {
showNote("ntfy-note", error);
}
});
async function loadBackups() {
const list = document.getElementById("backup-list");
if (!list) return;
try {
const out = await api("/backup");
list.innerHTML = (out.items || []).slice(0, 8).map((item) => {
const mb = ((item.size || 0) / (1024 * 1024)).toLocaleString("de-DE", { maximumFractionDigits: 1 });
return `<li><a href="/backup/${encodeURIComponent(item.name)}" download>${escapeHtml(item.name)}</a> · ${mb} MB</li>`;
}).join("") || "<li>Noch kein Backup.</li>";
} catch (error) {
list.innerHTML = "<li>Liste nicht verfügbar.</li>";
}
}
document.getElementById("backup-now")?.addEventListener("click", async () => {
try {
const out = await api("/backup", { method: "POST", json: {} });
showNote("backup-note", null, "Backup erstellt: " + (out.name || ""));
loadBackups();
} catch (error) {
showNote("backup-note", error);
}
});
document.getElementById("tg-test").addEventListener("click", async () => { document.getElementById("tg-test").addEventListener("click", async () => {
try { try {
await api("/telegram/test", { method: "POST", json: {} }); await api("/telegram/test", { method: "POST", json: {} });
@@ -490,13 +634,36 @@ document.getElementById("tg-test").addEventListener("click", async () => {
} }
}); });
function updateEnergyEdHint() {
const hint = document.getElementById("en-ed-hint");
if (!hint) return;
const nozzle = Number(document.getElementById("en-nozzle").value) || 0;
const bed = Number(document.getElementById("en-bed").value) || 0;
if (nozzle > 0 && bed > 0) {
hint.hidden = true;
hint.textContent = "";
return;
}
hint.hidden = false;
hint.textContent = "Düse oder Bett noch 0 — Bericht bleibt „pauschal“. Für ED z. B. Düse 60 W und Bett 220 W setzen.";
}
["en-nozzle", "en-bed"].forEach((id) => {
const node = document.getElementById(id);
if (node) node.addEventListener("input", updateEnergyEdHint);
});
document.getElementById("en-save").addEventListener("click", async () => { document.getElementById("en-save").addEventListener("click", async () => {
try { try {
const nozzle = Number(document.getElementById("en-nozzle").value) || 0;
const bed = Number(document.getElementById("en-bed").value) || 0;
await api("/einstellungen", { await api("/einstellungen", {
method: "PATCH", method: "PATCH",
json: { json: {
energy: { energy: {
watts: Number(document.getElementById("en-watts").value), watts: Number(document.getElementById("en-watts").value),
nozzle_watts: nozzle,
bed_watts: bed,
euro_per_kwh: Number(document.getElementById("en-kwh").value), euro_per_kwh: Number(document.getElementById("en-kwh").value),
dryer_watts: Number(document.getElementById("en-dryer").value), dryer_watts: Number(document.getElementById("en-dryer").value),
dry_hours: Number(document.getElementById("en-dryh").value), dry_hours: Number(document.getElementById("en-dryh").value),
@@ -505,7 +672,17 @@ document.getElementById("en-save").addEventListener("click", async () => {
}, },
}, },
}); });
updateEnergyEdHint();
if (nozzle <= 0 || bed <= 0) {
const note = document.getElementById("en-note");
if (note) {
note.textContent = "Gespeichert. Düse/Bett 0 → nur pauschal. Für ED z. B. 60 / 220 W setzen.";
note.className = "msg";
}
toast("Energie gespeichert — ED noch aus (Düse/Bett 0).", "bad");
} else {
showNote("en-note", null, "Energie gespeichert."); showNote("en-note", null, "Energie gespeichert.");
}
} catch (error) { } catch (error) {
showNote("en-note", error); showNote("en-note", error);
} }
@@ -534,6 +711,26 @@ document.getElementById("en-save").addEventListener("click", async () => {
}); });
})(); })();
(function initTimeZone() {
const sel = document.getElementById("tz-mode");
if (!sel) return;
const known = ["auto", "Europe/Berlin", "Europe/Vienna", "Europe/Zurich", "UTC"];
try {
const value = localStorage.getItem(TZ_KEY) || "auto";
sel.value = known.includes(value) ? value : "auto";
} catch (error) {
sel.value = "auto";
}
sel.addEventListener("change", () => {
try { localStorage.setItem(TZ_KEY, sel.value || "auto"); } catch (error) {}
const note = document.getElementById("display-note");
if (note) {
note.hidden = false;
setTimeout(() => { note.hidden = true; }, 2500);
}
});
})();
load().catch((error) => showNote("printer-note", error)); load().catch((error) => showNote("printer-note", error));
</script> </script>
{% endblock %} {% endblock %}
+1 -1
View File
@@ -95,7 +95,7 @@
<p class="code">{{ sheet.chargen_nr }}</p> <p class="code">{{ sheet.chargen_nr }}</p>
{% set soll = sheet.target_g if sheet.target_g is defined else target_g %} {% set soll = sheet.target_g if sheet.target_g is defined else target_g %}
{% if soll %} {% if soll %}
<p>Ziel {{ '%.0f'|format(soll) }} g</p> <p>Soll {{ '%.0f'|format(soll) }} g</p>
{% endif %} {% endif %}
<p>{{ run.printer_name }} · {{ (run.ended_at or run.started_at or '')[:10] }}</p> <p>{{ run.printer_name }} · {{ (run.ended_at or run.started_at or '')[:10] }}</p>
{% for spool in spools %} {% for spool in spools %}
+140 -42
View File
@@ -9,7 +9,9 @@
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}"> <link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}"> <link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}"> <link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=45"> <link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=54">
<link rel="manifest" href="{{ url_for('static', filename='manifest.webmanifest') }}">
<meta name="theme-color" content="#0a0a0c">
<script> <script>
(function () { (function () {
try { try {
@@ -38,6 +40,7 @@
</script> </script>
</head> </head>
<body class="app-shell"> <body class="app-shell">
<div id="conn-banner" class="conn-banner" hidden role="status"></div>
<svg width="0" height="0" style="position:absolute" aria-hidden="true"> <svg width="0" height="0" style="position:absolute" aria-hidden="true">
<symbol id="i-printer" viewBox="0 0 24 24"><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><path d="M6 9V3h12v6"/><rect x="6" y="14" width="12" height="8" rx="1"/></symbol> <symbol id="i-printer" viewBox="0 0 24 24"><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><path d="M6 9V3h12v6"/><rect x="6" y="14" width="12" height="8" rx="1"/></symbol>
<symbol id="i-camera" viewBox="0 0 24 24"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/></symbol> <symbol id="i-camera" viewBox="0 0 24 24"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/></symbol>
@@ -96,14 +99,15 @@
</div> </div>
<nav class="nav-main" aria-label="Hauptmenü"> <nav class="nav-main" aria-label="Hauptmenü">
<a href="/" class="{{ 'on' if page == 'wand' else '' }}" title="Drucker"><svg class="ico"><use href="#i-printer"/></svg><span>Drucker</span></a> <a href="/" class="{{ 'on' if page == 'wand' else '' }}" title="Drucker"><svg class="ico"><use href="#i-printer"/></svg><span>Drucker</span></a>
<a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }} nav-desk" title="Dateien"><svg class="ico"><use href="#i-send"/></svg><span>Dateien</span></a> <a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }} nav-desk" title="Dateien"><svg class="ico"><use href="#i-file"/></svg><span>Dateien</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="/kameras" class="{{ 'on' if page == 'kameras' else '' }}" title="Kameras"><svg class="ico"><use href="#i-camera"/></svg><span>Kameras</span></a>
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk" title="Aktivität"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}" title="Warteschlange"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a> <a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}" title="Warteschlange"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
<a href="/auftraege" class="{{ 'on' if page == 'auftraege' else '' }} nav-desk" title="Aufträge"><svg class="ico"><use href="#i-folder"/></svg><span>Aufträge</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="/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="/statistik" class="{{ 'on' if page == 'statistik' else '' }} nav-desk" title="Statistik"><svg class="ico"><use href="#i-grid"/></svg><span>Statistik</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="/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="/wartung" class="{{ 'on' if page == 'wartung' else '' }} nav-desk" title="Wartung"><svg class="ico"><use href="#i-nozzle"/></svg><span>Wartung</span></a>
</nav> </nav>
<div class="live-panel" id="live-panel"> <div class="live-panel" id="live-panel">
<details class="queue-box" data-box="run" id="run-box" open hidden> <details class="queue-box" data-box="run" id="run-box" open hidden>
@@ -131,7 +135,6 @@
</div> </div>
<nav class="nav-secondary" aria-label="Weitere Seiten"> <nav class="nav-secondary" aria-label="Weitere Seiten">
<a href="/einstellungen" class="{{ 'on' if page == 'einstellungen' else '' }}" title="Einstellungen"><svg class="ico"><use href="#i-settings"/></svg><span>Einstellungen</span></a> <a href="/einstellungen" class="{{ 'on' if page == 'einstellungen' else '' }}" title="Einstellungen"><svg class="ico"><use href="#i-settings"/></svg><span>Einstellungen</span></a>
<a href="/ideen" class="{{ 'on' if page == 'ideen' else '' }}" title="Ideen"><svg class="ico"><use href="#i-bulb"/></svg><span>Ideen</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> <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">
@@ -152,12 +155,26 @@
</div> </div>
<nav class="mobile-nav" aria-label="Schnellmenü"> <nav class="mobile-nav" aria-label="Schnellmenü">
<a href="/" class="{{ 'on' if page == 'wand' else '' }}"><svg class="ico"><use href="#i-printer"/></svg><span>Drucker</span></a> <a href="/" class="{{ 'on' if page == 'wand' else '' }}"><svg class="ico"><use href="#i-printer"/></svg><span>Drucker</span></a>
<a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }} nav-desk"><svg class="ico"><use href="#i-send"/></svg><span>Dateien</span></a> <a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }}"><svg class="ico"><use href="#i-file"/></svg><span>Dateien</span></a>
<a href="/kameras" class="{{ 'on' if page == 'kameras' else '' }} nav-shelf"><svg class="ico"><use href="#i-camera"/></svg><span>Kameras</span></a> <a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}"><svg class="ico"><use href="#i-queue"/></svg><span>Queue</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 '' }}"><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> <button type="button" class="mobile-more-btn" id="mobile-more" aria-expanded="false" aria-controls="mobile-more-sheet"><svg class="ico"><use href="#i-menu"/></svg><span>Mehr</span></button>
</nav> </nav>
<script src="{{ url_for('static', filename='api.js') }}?v=24"></script> <dialog id="mobile-more-sheet" class="mobile-more-sheet">
<nav aria-label="Weitere Seiten">
<a href="/kameras">Kameras</a>
<a href="/auftraege">Aufträge</a>
<a href="/protokoll">Protokoll</a>
<a href="/statistik">Statistik</a>
<a href="/material">Material</a>
<a href="/wartung">Wartung</a>
<a href="/einstellungen">Einstellungen</a>
<a href="/doku">Doku</a>
</nav>
<form method="dialog"><button class="ghost">Schließen</button></form>
</dialog>
<script src="{{ url_for('static', filename='api.js') }}?v=28"></script>
<script src="{{ url_for('static', filename='ui.js') }}?v=2"></script>
<script> <script>
document.getElementById("logout-btn")?.addEventListener("click", async () => { document.getElementById("logout-btn")?.addEventListener("click", async () => {
try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {} try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {}
@@ -241,21 +258,44 @@ async function cancelQueueItem(job, button) {
} }
window.cancelQueueItem = cancelQueueItem; window.cancelQueueItem = cancelQueueItem;
function runItemHtml(job) { function ablaufStepFrac(job) {
const rep = repeatText(job); if (!job || !job.is_ablauf || !job.step_labels || !job.step_labels.length) return "";
const steps = job.is_ablauf ? stepsHtml(job.step_labels, job.display_step_index != null ? job.display_step_index : job.step_index, true) : ""; const idx = Number(job.display_step_index != null ? job.display_step_index : job.step_index) || 0;
return `<li class="queue-item running"> return (idx + 1) + "/" + job.step_labels.length;
<div class="queue-row"> }
<a class="queue-link grow" href="/#${encodeURIComponent(job.host || "")}"><span class="queue-name">${escapeHtml(queueTarget(job))}</span></a>
${rep ? `<span class="queue-sub">${escapeHtml(rep)}</span>` : ""} function runLineHtml({ name, progress, remain, filename, tipExtra, cancelId }) {
<span class="queue-tools"><button type="button" class="icon-btn danger" data-cancel-q="${job.id}" title="Abbrechen">${icon("x")}</button></span> const pct = Math.max(0, Math.min(100, Math.round((Number(progress) || 0) * 100)));
</div> const finish = remain > 0 ? finishClock(remain) : "";
${steps} const bits = [name];
<span class="queue-msg">${escapeHtml(phaseText(job))}</span> if (pct > 0 || remain > 0) bits.push(pct + " %");
<span class="queue-sub">${escapeHtml(jobLabel(job.filename))}</span> if (finish) bits.push(finish);
if (tipExtra) bits.push(tipExtra);
const tip = [
filename ? jobLabel(filename) : "",
remain > 0 ? "noch " + hours(remain) : "",
tipExtra ? "Schritt " + tipExtra : "",
].filter(Boolean).join(" · ");
return `<li class="queue-item running run-line" style="--pct:${pct}">
<a class="queue-link run-line-link" href="/warteschlange" title="${escapeHtml(tip || "Zur Warteschlange")}">
<span class="run-line-fill" aria-hidden="true"></span>
<span class="run-line-text">${escapeHtml(bits.join(" · "))}</span>
</a>
${cancelId != null ? `<span class="queue-tools"><button type="button" class="icon-btn danger" data-cancel-q="${cancelId}" title="Abbrechen">${icon("x")}</button></span>` : ""}
</li>`; </li>`;
} }
function runItemHtml(job) {
return runLineHtml({
name: queueTarget(job),
progress: job.progress,
remain: job.remain,
filename: job.filename,
tipExtra: ablaufStepFrac(job),
cancelId: job.id,
});
}
function waitItemHtml(job, index, total) { function waitItemHtml(job, index, total) {
const isError = job.status === "error"; const isError = job.status === "error";
const label = job.is_ablauf ? "Ablauf · " + jobLabel(job.filename) : jobLabel(job.filename); const label = job.is_ablauf ? "Ablauf · " + jobLabel(job.filename) : jobLabel(job.filename);
@@ -276,17 +316,12 @@ function waitItemHtml(job, index, total) {
} }
function directItemHtml(p) { function directItemHtml(p) {
const pct = Math.round((Number(p.progress) || 0) * 100); return runLineHtml({
const eta = p.remain > 0 ? "noch " + hours(p.remain) + " · fertig " + finishClock(p.remain) : ""; name: p.name || p.host,
return `<li class="queue-item running"> progress: p.progress,
<div class="queue-row"> remain: p.remain,
<a class="queue-link grow" href="/#${encodeURIComponent(p.host)}"><span class="queue-name">${escapeHtml(p.name || p.host)}</span></a> filename: p.filename,
<span class="queue-sub">${pct} %</span> });
</div>
<span class="bar slim live"><span style="width:${pct}%"></span></span>
<span class="queue-msg">${escapeHtml(STATE_LABELS[p.state] || p.state)}${eta ? " · " + escapeHtml(eta) : ""}</span>
<span class="queue-sub">${escapeHtml(jobLabel(p.filename))}</span>
</li>`;
} }
let queueCache = []; let queueCache = [];
@@ -322,7 +357,18 @@ async function refreshPrintQueue() {
[jobs, active] = await Promise.all([api("/druck-queue"), api("/laeuft").catch(() => [])]); [jobs, active] = await Promise.all([api("/druck-queue"), api("/laeuft").catch(() => [])]);
} catch (error) { return; } } catch (error) { return; }
queueCache = jobs || []; queueCache = jobs || [];
const running = queueCache.filter((job) => job.status === "running"); const liveByHost = Object.create(null);
(active || []).forEach((p) => { if (p.host) liveByHost[p.host] = p; });
const running = queueCache.filter((job) => job.status === "running").map((job) => {
const live = liveByHost[job.host];
if (!live) return job;
return {
...job,
progress: live.progress,
remain: live.remain,
filename: stickyLiveFilename(job.host, live.filename || job.filename, live.state) || job.filename,
};
});
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)).map((p) => ({ const direct = (active || []).filter((p) => !queuedHosts.has(p.host)).map((p) => ({
...p, ...p,
@@ -447,16 +493,26 @@ async function refreshTasks() {
${p.filename ? `<span class="queue-sub" title="${escapeHtml(p.filename)}">${escapeHtml(jobLabel(p.filename))}</span>` : ""} ${p.filename ? `<span class="queue-sub" title="${escapeHtml(p.filename)}">${escapeHtml(jobLabel(p.filename))}</span>` : ""}
</li>`).join(""); </li>`).join("");
list.querySelectorAll("[data-cleared]").forEach((btn) => { list.querySelectorAll("[data-cleared]").forEach((btn) => {
btn.onclick = () => armButton(btn, "leer?", async () => { btn.onclick = async () => {
const host = btn.dataset.cleared;
const payload = await promptAbraeumen(btn.closest("li")?.querySelector(".queue-name")?.textContent || host);
if (!payload) return;
btn.disabled = true; btn.disabled = true;
try { try {
await api("/drucker/" + encodeURIComponent(btn.dataset.cleared) + "/abraeumen", { method: "POST", json: { confirm: true } }); await api("/drucker/" + encodeURIComponent(host) + "/abraeumen", { method: "POST", json: payload });
toast("Platte frei."); toast("Platte frei.");
} catch (error) { toast(error.message, "bad"); } } catch (error) { toast(error.message, "bad"); }
refreshTasks(); refreshTasks();
if (typeof window.onQueueChanged === "function") window.onQueueChanged(); if (typeof window.onQueueChanged === "function") window.onQueueChanged();
btn.disabled = false;
};
}); });
}); }
document.getElementById("mobile-more")?.addEventListener("click", () => {
document.getElementById("mobile-more-sheet")?.showModal();
});
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/static/sw.js").catch(() => {});
} }
document.getElementById("events-clear").addEventListener("click", async (event) => { document.getElementById("events-clear").addEventListener("click", async (event) => {
@@ -473,10 +529,52 @@ refreshPrintQueue();
refreshEvents(); refreshEvents();
refreshTasks(); refreshTasks();
window.refreshTasks = refreshTasks; window.refreshTasks = refreshTasks;
setInterval(refreshTasks, 8000);
setInterval(refreshSendQueue, 2000); (function liveUpdates() {
setInterval(refreshPrintQueue, 4000); const sseKinds = {
setInterval(refreshEvents, 8000); queue: () => refreshPrintQueue(),
senden: () => refreshSendQueue(),
ereignis: () => refreshEvents(),
aufgaben: () => refreshTasks(),
drucker: () => {
refreshPrintQueue();
refreshTasks();
if (typeof window.onDruckerLive === "function") window.onDruckerLive();
},
};
if (typeof EventSource !== "undefined") {
let es;
let retryMs = 2000;
const retryMax = 60000;
let hadStream = false;
function connectStream() {
es = new EventSource("/api/v1/stream", { withCredentials: true });
es.addEventListener("open", () => {
retryMs = 2000;
if (hadStream) setConnectionBanner(true, "Live-Verbindung wieder da.");
hadStream = true;
});
es.addEventListener("error", () => {
es.close();
setConnectionBanner(false, "Live-Verbindung unterbrochen — Fallback-Polling läuft.");
setTimeout(connectStream, retryMs);
retryMs = Math.min(retryMax, retryMs * 2);
});
Object.keys(sseKinds).forEach((kind) => {
es.addEventListener(kind, () => sseKinds[kind]());
});
es.addEventListener("ping", () => {});
}
connectStream();
}
createPoller(refreshPrintQueue, 8000).start();
createPoller(refreshSendQueue, 4000).start();
createPoller(refreshEvents, 12000).start();
createPoller(refreshTasks, 12000).start();
})();
</script> </script>
{% block script %}{% endblock %} {% block script %}{% endblock %}
</body> </body>
+69
View File
@@ -0,0 +1,69 @@
<!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.4 "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 {
max-width: 210mm; margin: 1.2rem auto; padding: 14mm;
background: #fff; border: 1px solid #ccc; border-radius: 4px;
}
h1 { margin: 0 0 4mm; font-size: 18px; }
.meta { margin-bottom: 8mm; color: #333; font-size: 13px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { border-bottom: 1px solid #ddd; padding: 3mm 2mm; text-align: left; }
th.num, td.num { text-align: right; font-variant-numeric: tabular-nums; }
@media print {
body { background: #fff; }
.toolbar { display: none; }
.sheet { margin: 0; border: 0; border-radius: 0; }
}
</style>
</head>
<body>
<div class="toolbar">
<button type="button" onclick="window.print()">Drucken</button>
<a class="ghost" href="/auftraege">Zurück</a>
</div>
<article class="sheet">
<h1>Lieferschein — Auftrag #{{ order.id }}</h1>
<div class="meta">
{% if order.customer %}<p><strong>Kunde:</strong> {{ order.customer.name }}{% if order.customer.email %} · {{ order.customer.email }}{% endif %}</p>{% endif %}
<p><strong>Titel:</strong> {{ order.title }}</p>
{% if order.due_at %}<p><strong>Fällig:</strong> {{ order.due_at }}</p>{% endif %}
<p><strong>Status:</strong> {{ order.status }}</p>
{% if order.notes %}<p>{{ order.notes }}</p>{% endif %}
</div>
<table>
<thead>
<tr><th>Position</th><th class="num">Menge</th><th class="num">geliefert</th><th class="num">€/Stk</th></tr>
</thead>
<tbody>
{% for item in order.items %}
<tr>
<td>{{ item.file_name or item.note or ('Datei #' ~ item.file_id) }}</td>
<td class="num">{{ item.qty }}</td>
<td class="num">{{ item.done_ok }}</td>
<td class="num">{{ '%.2f'|format(item.unit_price) }}</td>
</tr>
{% else %}
<tr><td colspan="4">Keine Positionen.</td></tr>
{% endfor %}
</tbody>
</table>
{% if order.progress %}
<p class="meta" style="margin-top:8mm">Fortschritt gesamt: {{ order.progress.label }}</p>
{% endif %}
</article>
</body>
</html>
+98 -55
View File
@@ -61,35 +61,6 @@
<input id="spool-name" required> <input id="spool-name" required>
</div> </div>
<div class="grid-2"> <div class="grid-2">
<div class="field"><label for="spool-brand">Marke</label><input id="spool-brand"></div>
<div class="field"><label for="spool-type">Typ</label><input id="spool-type" placeholder="PLA"></div>
</div>
<div class="grid-2">
<div class="field">
<label for="spool-chargen">Grotti-Charge</label>
<input id="spool-chargen" readonly placeholder="wird vergeben">
</div>
<div class="field">
<label for="spool-hersteller">Hersteller-Charge</label>
<input id="spool-hersteller" placeholder="vom Rollenetikett">
</div>
</div>
<details class="mat-color" id="color-box">
<summary>
<svg class="ico caret"><use href="#i-down"/></svg>
<span class="grow">Farbe</span>
<span class="tiny" id="color-sum">noch keine</span>
</summary>
<div class="mat-color-body">
<div id="color-picked" class="picked"></div>
<div id="color-solids" class="color-grid"></div>
<div id="color-multi" class="color-grid"></div>
<label class="mix check"><input id="color-mix" type="checkbox"> Eigene Mischung</label>
</div>
</details>
<div class="grid-2" style="margin-top:0.55rem">
<div class="field"> <div class="field">
<label for="spool-printer">Drucker</label> <label for="spool-printer">Drucker</label>
<select id="spool-printer"></select> <select id="spool-printer"></select>
@@ -108,11 +79,47 @@
<option value="8">ACE 2 · Platz 4</option> <option value="8">ACE 2 · Platz 4</option>
</select> </select>
</div> </div>
<div class="field"><label for="spool-left">Rest gramm</label><input id="spool-left" type="number" step="0.1" value="1000"></div>
</div>
<details class="mat-color" id="color-box">
<summary>
<svg class="ico caret"><use href="#i-down"/></svg>
<span class="grow">Farbe</span>
<span class="tiny" id="color-sum">noch keine</span>
</summary>
<div class="mat-color-body">
<div id="color-picked" class="picked"></div>
<div id="color-solids" class="color-grid"></div>
<div id="color-multi" class="color-grid"></div>
<label class="mix check"><input id="color-mix" type="checkbox"> Eigene Mischung</label>
</div>
</details>
<details class="mat-more" id="spool-more">
<summary>
<svg class="ico caret"><use href="#i-down"/></svg>
<span class="grow">Weitere Angaben</span>
</summary>
<div class="mat-more-body">
<div class="grid-2">
<div class="field"><label for="spool-brand">Marke</label><input id="spool-brand"></div>
<div class="field"><label for="spool-type">Typ</label><input id="spool-type" placeholder="PLA"></div>
<div class="field">
<label for="spool-chargen">Grotti-Charge</label>
<input id="spool-chargen" readonly placeholder="wird vergeben">
</div>
<div class="field">
<label for="spool-hersteller">Hersteller-Charge</label>
<input id="spool-hersteller" placeholder="vom Rollenetikett">
</div>
<div class="field"><label for="spool-price">€ pro kg</label><input id="spool-price" type="number" min="0" step="0.01" value="0"></div> <div class="field"><label for="spool-price">€ pro kg</label><input id="spool-price" type="number" min="0" step="0.01" value="0"></div>
<div class="field"><label for="spool-left">Rest gramm</label><input id="spool-left" type="number" step="1" value="1000"></div>
<div class="field"><label for="spool-density">Dichte g/cm³</label><input id="spool-density" type="number" min="0.1" step="0.01" value="1.24"></div> <div class="field"><label for="spool-density">Dichte g/cm³</label><input id="spool-density" type="number" min="0.1" step="0.01" value="1.24"></div>
<div class="field"><label for="spool-vol">Volumen mm³/s</label><input id="spool-vol" type="number" min="0.1" step="0.1" value="12"></div> <div class="field"><label for="spool-vol">Volumen mm³/s</label><input id="spool-vol" type="number" min="0.1" step="0.1" value="12"></div>
</div> </div>
</div>
</details>
<div class="actions"> <div class="actions">
<button type="submit">Merken</button> <button type="submit">Merken</button>
<button type="button" class="ghost" id="spool-shelf" hidden>Ins Regal</button> <button type="button" class="ghost" id="spool-shelf" hidden>Ins Regal</button>
@@ -122,8 +129,8 @@
</form> </form>
<div id="spool-akte" class="spool-akte" hidden> <div id="spool-akte" class="spool-akte" hidden>
<h3>Akte</h3> <h3>Akte</h3>
<p class="tiny" id="akte-summary"></p> <div class="kpi-row akte-kpis" id="akte-kpis"></div>
<ul class="plain" id="akte-runs"></ul> <div class="akte-runs" id="akte-runs"></div>
</div> </div>
<p id="spool-note"></p> <p id="spool-note"></p>
</section> </section>
@@ -429,6 +436,35 @@ function payload() {
}; };
} }
const akteStates = {
completed: "fertig", cancelled: "abgebrochen", error: "Fehler",
in_progress: "druckt", printing: "druckt", paused: "pausiert",
interrupted: "unterbrochen",
};
function akteWhen(iso) {
if (!iso) return "—";
const text = formatDateTime(iso, { year: undefined });
return text === "—" ? "—" : text.replace(/,\s*/, " ");
}
function akteFile(name) {
const base = (name || "").split("/").pop() || "";
if (!base) return "Druck";
if (base.length <= 42) return base;
return base.slice(0, 39) + "…";
}
function akteStatus(run) {
if (run.verdict === "ausschuss") return "Ausschuss";
if (run.verdict === "gut") return "gut";
return akteStates[run.state] || run.state || "";
}
function akteKpi(label, value) {
return '<div class="kpi"><div class="kpi-val">' + escapeHtml(value) + '</div><div class="kpi-label">' + escapeHtml(label) + "</div></div>";
}
async function loadAkte(spoolId) { async function loadAkte(spoolId) {
const box = document.getElementById("spool-akte"); const box = document.getElementById("spool-akte");
if (!spoolId) { if (!spoolId) {
@@ -442,29 +478,34 @@ async function loadAkte(spoolId) {
let leer = "—"; let leer = "—";
if (stats.empty_in_days == null) leer = "kein Verbrauch"; if (stats.empty_in_days == null) leer = "kein Verbrauch";
else if (stats.empty_in_days <= 0) leer = "leer"; else if (stats.empty_in_days <= 0) leer = "leer";
else leer = "noch ~" + Math.round(stats.empty_in_days) + " Tage"; else leer = "~" + Math.round(stats.empty_in_days) + " Tage";
document.getElementById("akte-summary").textContent = document.getElementById("akte-kpis").innerHTML =
"Verdruckt " + grams(stats.total_g) akteKpi("Verdruckt", grams(stats.total_g))
+ " · Ausschuss " + grams(stats.scrap_g) + akteKpi("Ausschuss", grams(stats.scrap_g))
+ " · " + euro(stats.total_cost) + akteKpi("Kosten", euro(stats.total_cost))
+ " · Füllstand " + fillPct + " % · Leer-in: " + leer; + akteKpi("Füllstand", fillPct + " %")
+ akteKpi("Leer-in", leer);
const list = document.getElementById("akte-runs"); const list = document.getElementById("akte-runs");
list.innerHTML = ""; const runs = data.runs || [];
(data.runs || []).slice(0, 12).forEach((run) => { if (!runs.length) {
const li = document.createElement("li"); list.innerHTML = '<p class="tiny">Noch kein Druck auf dieser Spule.</p>';
const a = document.createElement("a"); } else {
a.href = "/protokoll/" + run.id; list.innerHTML = runs.slice(0, 12).map((run) => {
a.textContent = (run.filename || "").split("/").pop() || ("#" + run.id); const href = "/protokoll/" + run.id;
li.appendChild(a); const charge = run.chargen_nr
li.appendChild(document.createTextNode( ? '<a class="tag akte-charge" href="' + href + '">' + escapeHtml(run.chargen_nr) + "</a>"
" · " + grams(run.spool_weight_g) : '<span class="akte-charge tiny">—</span>';
+ (run.spool_is_estimate ? " ≈" : "") const file = '<a class="akte-file" href="' + href + '" title="' + escapeHtml((run.filename || "").split("/").pop() || "") + '">'
+ " · " + (run.verdict || run.state || "") + escapeHtml(akteFile(run.filename)) + "</a>";
)); const gramsTxt = grams(run.spool_weight_g) + (run.spool_is_estimate ? " ≈" : "");
list.appendChild(li); return '<div class="akte-run">'
}); + '<span class="akte-when tiny">' + escapeHtml(akteWhen(run.started_at)) + "</span>"
if (!(data.runs || []).length) { + charge
list.innerHTML = '<li class="tiny">Noch kein Druck auf dieser Spule.</li>'; + file
+ '<span class="akte-g">' + escapeHtml(gramsTxt) + "</span>"
+ '<span class="akte-state tiny">' + escapeHtml(akteStatus(run)) + "</span>"
+ "</div>";
}).join("");
} }
box.hidden = false; box.hidden = false;
} catch (error) { } catch (error) {
@@ -483,7 +524,9 @@ function fill(spool) {
document.getElementById("spool-printer").value = spool ? spool.printer_host : ""; document.getElementById("spool-printer").value = spool ? spool.printer_host : "";
document.getElementById("spool-slot").value = spool && spool.ace_slot ? String(spool.ace_slot) : ""; document.getElementById("spool-slot").value = spool && spool.ace_slot ? String(spool.ace_slot) : "";
document.getElementById("spool-price").value = spool ? spool.price_per_kg : 0; document.getElementById("spool-price").value = spool ? spool.price_per_kg : 0;
document.getElementById("spool-left").value = spool ? spool.remaining_g : 1000; document.getElementById("spool-left").value = spool
? Math.round((Number(spool.remaining_g) || 0) * 10) / 10
: 1000;
document.getElementById("spool-density").value = spool ? spool.density : 1.24; document.getElementById("spool-density").value = spool ? spool.density : 1.24;
document.getElementById("spool-vol").value = spool ? spool.volumetric : 12; document.getElementById("spool-vol").value = spool ? spool.volumetric : 12;
document.getElementById("spool-material").value = spool && spool.material_id ? String(spool.material_id) : ""; document.getElementById("spool-material").value = spool && spool.material_id ? String(spool.material_id) : "";
+13
View File
@@ -130,6 +130,12 @@ function render(data) {
<td class="num">${p.completed}</td> <td class="num">${p.completed}</td>
</tr>`).join("") || '<tr><td class="empty" colspan="6">Keine Daten.</td></tr>'; </tr>`).join("") || '<tr><td class="empty" colspan="6">Keine Daten.</td></tr>';
const reasons = (data.scrap_reasons || []).map((r) => `
<tr>
<td>${escapeHtml(r.reason || "—")}</td>
<td class="num">${r.count}</td>
</tr>`).join("") || '<tr><td class="empty" colspan="2">Kein Ausschuss im Zeitraum.</td></tr>';
const materials = (data.by_material || []).map((m) => ` const materials = (data.by_material || []).map((m) => `
<tr> <tr>
<td>${escapeHtml(m.material_type || "—")}</td> <td>${escapeHtml(m.material_type || "—")}</td>
@@ -156,6 +162,13 @@ function render(data) {
<tbody>${printers}</tbody> <tbody>${printers}</tbody>
</table></div> </table></div>
</div> </div>
<div class="card">
<h2>Ausschuss-Gründe</h2>
<div class="table-wrap"><table>
<thead><tr><th>Grund</th><th>Anzahl</th></tr></thead>
<tbody>${reasons}</tbody>
</table></div>
</div>
<div class="card"> <div class="card">
<h2>Nach Material</h2> <h2>Nach Material</h2>
<div class="table-wrap"><table> <div class="table-wrap"><table>
+77 -4
View File
@@ -61,6 +61,12 @@
</aside> </aside>
</div> </div>
<div class="desk-tools"> <div class="desk-tools">
<details class="desk-fold desk-tool" data-fold="verlauf" open>
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Verlauf</span><span class="tiny" data-role="verlauf-caption"></span></summary>
<div class="desk-fold-body desk-verlauf" data-role="verlauf">
<p class="tiny">Lade Verlauf …</p>
</div>
</details>
<details class="desk-fold desk-tool" data-fold="path"> <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">
@@ -100,6 +106,9 @@ let deskCamFails = 0;
let deskViewerFileId = null; let deskViewerFileId = null;
let deskSyncGen = 0; let deskSyncGen = 0;
let deskExcludeSelected = ""; let deskExcludeSelected = "";
let deskVerlaufAt = 0;
let deskVerlaufHtml = "";
let deskVerlaufHost = "";
const wallPullTried = new Set(); const wallPullTried = new Set();
const toolpathCache = new Map(); const toolpathCache = new Map();
@@ -909,6 +918,61 @@ function patchDesk(printer) {
// Kamera nur über Timer aktualisieren — sonst doppelter Reload + Layout-Ruckler // Kamera nur über Timer aktualisieren — sonst doppelter Reload + Layout-Ruckler
syncDeskViewer(printer).catch(() => {}); syncDeskViewer(printer).catch(() => {});
refreshDeskVerlauf(false);
}
async function refreshDeskVerlauf(force) {
if (!deskHost || deskEl.hidden) return;
const now = Date.now();
if (!force && deskVerlaufHost === deskHost && now - deskVerlaufAt < 20000) return;
const host = deskHost;
deskVerlaufAt = now;
deskVerlaufHost = host;
const box = deskEl.querySelector('[data-role="verlauf"]');
const caption = deskEl.querySelector('[data-role="verlauf-caption"]');
if (!box) return;
try {
const data = await api("/laeufe/aktuell?host=" + encodeURIComponent(host));
if (deskHost !== host) return;
const run = data.run;
if (!run) {
const empty = '<p class="desk-empty"><svg class="ico"><use href="#i-activity"/></svg><span>Noch kein Verlauf</span></p>';
if (deskVerlaufHtml !== empty) {
deskVerlaufHtml = empty;
box.innerHTML = empty;
}
if (caption) caption.textContent = "";
return;
}
const active = ["in_progress", "printing", "paused"].includes(run.state);
const energy = data.energy || {};
const kwh = energy.kwh != null
? (Number(energy.kwh) || 0).toLocaleString("de-DE", { maximumFractionDigits: 2 }) + " kWh"
+ (energy.source === "ed" ? " ED" : "")
: "";
const bits = [
active ? "läuft" : "letzter Druck",
formatDateRange(run.started_at, run.ended_at),
kwh,
].filter(Boolean);
if (caption) caption.textContent = bits.join(" · ");
const head = `<div class="desk-verlauf-head">
<span class="tiny grow">${escapeHtml(jobLabel(run.filename) || "Druck")}${run.chargen_nr ? " · " + escapeHtml(run.chargen_nr) : ""}</span>
<a class="ghost tiny-btn" href="/protokoll/${run.id}">Bericht →</a>
</div>`;
const html = head + tempChartHtml(data.samples || []);
if (deskVerlaufHtml !== html) {
deskVerlaufHtml = html;
box.innerHTML = html;
}
} catch (error) {
if (deskHost !== host) return;
const fail = '<p class="tiny msg">' + escapeHtml(error.message || "Verlauf nicht geladen") + "</p>";
if (deskVerlaufHtml !== fail) {
deskVerlaufHtml = fail;
box.innerHTML = fail;
}
}
} }
async function syncDeskViewer(printer) { async function syncDeskViewer(printer) {
@@ -1068,10 +1132,15 @@ function openDesk(host, opts) {
const prev = deskHost; const prev = deskHost;
deskHost = host; deskHost = host;
setDeskVisible(true); setDeskVisible(true);
if (prev !== host) destroyDeskViewer(); if (prev !== host) {
destroyDeskViewer();
deskVerlaufHtml = "";
deskVerlaufAt = 0;
}
const steer = deskEl.querySelector("[data-role=steer]"); const steer = deskEl.querySelector("[data-role=steer]");
if (!same || !steer.querySelector(".steer")) setHtml(steer, steerPanelHtml(printer)); if (!same || !steer.querySelector(".steer")) setHtml(steer, steerPanelHtml(printer));
patchDesk(printer); patchDesk(printer);
refreshDeskVerlauf(true);
startCameraTimer(); startCameraTimer();
if (!same) { if (!same) {
const back = document.getElementById("desk-back"); const back = document.getElementById("desk-back");
@@ -1087,6 +1156,8 @@ function openDesk(host, opts) {
function closeDesk(opts) { function closeDesk(opts) {
if (!deskHost && deskEl.hidden) return; if (!deskHost && deskEl.hidden) return;
deskHost = ""; deskHost = "";
deskVerlaufHtml = "";
deskVerlaufHost = "";
stopCameraTimer(); stopCameraTimer();
destroyDeskViewer(); destroyDeskViewer();
setDeskVisible(false); setDeskVisible(false);
@@ -1491,19 +1562,21 @@ async function handleWallClick(event) {
return; return;
} }
if (button.dataset.cmd === "abraeumen") { if (button.dataset.cmd === "abraeumen") {
armButton(button, "Platte frei?", async () => { (async () => {
const payload = await promptAbraeumen(host);
if (!payload) return;
button.disabled = true; button.disabled = true;
try { try {
await api("/drucker/" + encodeURIComponent(host) + "/abraeumen", { await api("/drucker/" + encodeURIComponent(host) + "/abraeumen", {
method: "POST", method: "POST",
json: { confirm: true }, json: payload,
}); });
toast("Platte quittiert — der nächste Job darf starten."); toast("Platte quittiert — der nächste Job darf starten.");
refresh(); refresh();
if (window.refreshTasks) window.refreshTasks(); if (window.refreshTasks) window.refreshTasks();
} catch (error) { toast(error.message, "bad"); } } catch (error) { toast(error.message, "bad"); }
finally { button.disabled = false; } finally { button.disabled = false; }
}); })();
return; return;
} }
button.disabled = true; button.disabled = true;
+93
View File
@@ -0,0 +1,93 @@
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Wartung</h1>
<p class="tiny">Laufstunden und Filament je Drucker — Verschleißteile im Blick.</p>
</div>
</div>
<div id="wartung-root"></div>
<p id="wartung-note"></p>
{% endblock %}
{% block script %}
<script>
function partRow(part) {
const overdue = part.overdue_hours || part.overdue_meters;
return `<tr class="${overdue ? "warn" : ""}">
<td>${escapeHtml(part.label || part.part_key)}</td>
<td class="num">${(part.hours_since || 0).toLocaleString("de-DE", { maximumFractionDigits: 1 })} h</td>
<td class="num">${Math.round(part.meters_since || 0).toLocaleString("de-DE")} mm</td>
<td class="num">${part.life_hours ? part.life_hours + " h" : "—"}</td>
<td class="num">${part.life_meters ? Math.round(part.life_meters) + " mm" : "—"}</td>
<td><button type="button" class="ghost sm" data-reset="${part.id}">Zurücksetzen</button></td>
</tr>`;
}
function printerCard(block) {
const c = block.counters || {};
const parts = (block.parts || []).map(partRow).join("")
|| '<tr><td colspan="6" class="empty">Noch keine Teile.</td></tr>';
return `<section class="card">
<h2>${escapeHtml(block.name || block.host)}</h2>
<p class="hint">${escapeHtml(block.host)} · ${(c.hours || 0).toLocaleString("de-DE", { maximumFractionDigits: 1 })} h gesamt · ${Math.round(c.meters || 0).toLocaleString("de-DE")} mm · ${c.runs || 0} Läufe</p>
<div class="table-wrap"><table>
<thead><tr><th>Teil</th><th>Seit Reset</th><th>Seit Reset</th><th>Intervall h</th><th>Intervall mm</th><th></th></tr></thead>
<tbody>${parts}</tbody>
</table></div>
<form class="grid-2 wartung-add" data-host="${escapeHtml(block.host)}" style="margin-top:0.75rem">
<div class="field"><label>Schlüssel</label><input name="part_key" required placeholder="nozzle"></div>
<div class="field"><label>Bezeichnung</label><input name="label" placeholder="Düse 0.4"></div>
<div class="field"><label>Intervall h</label><input name="life_hours" type="number" step="1" min="0" value="0"></div>
<div class="field"><label>Intervall mm</label><input name="life_meters" type="number" step="1000" min="0" value="0"></div>
<div class="field" style="grid-column:1/-1"><label>Notiz</label><input name="note"></div>
<div class="actions"><button type="submit">Teil anlegen</button></div>
</form>
</section>`;
}
async function load() {
const data = await api("/wartung");
document.getElementById("wartung-root").innerHTML = (data || []).map(printerCard).join("")
|| '<p class="empty">Keine Drucker angelegt.</p>';
}
document.getElementById("wartung-root").addEventListener("submit", async (event) => {
const form = event.target.closest("form.wartung-add");
if (!form) return;
event.preventDefault();
const fd = new FormData(form);
try {
await api("/wartung/teile", {
method: "POST",
json: {
printer_host: form.dataset.host,
part_key: fd.get("part_key"),
label: fd.get("label"),
life_hours: Number(fd.get("life_hours") || 0),
life_meters: Number(fd.get("life_meters") || 0),
note: fd.get("note"),
},
});
form.reset();
showNote("wartung-note", null, "Teil gespeichert.");
load();
} catch (error) {
showNote("wartung-note", error);
}
});
document.getElementById("wartung-root").addEventListener("click", async (event) => {
const btn = event.target.closest("[data-reset]");
if (!btn) return;
try {
await api("/wartung/teile/" + btn.dataset.reset + "/reset", { method: "POST", json: {} });
showNote("wartung-note", null, "Teil zurückgesetzt.");
load();
} catch (error) {
showNote("wartung-note", error);
}
});
load();
</script>
{% endblock %}