Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e59df22e5 | ||
|
|
8e88efc094 | ||
|
|
0236278929 | ||
|
|
9e22cebb81 |
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -4,6 +4,92 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [3.1.0] – 2026-10-18
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- **Bedarf in der UI:** Sidebar-Warnung, Karte unter Material, Chip in der Warteschlange.
|
||||||
|
- **Start-Zeitfenster** je Drucker in den Einstellungen (Queue nur im Fenster).
|
||||||
|
- **HT/NT-Tarif** in den Energie-Einstellungen.
|
||||||
|
- **ntfy-Test** wie bei Telegram.
|
||||||
|
- **Helles Theme** (Dunkel / Hell / System) pro Browser.
|
||||||
|
- Statistik: Balkendiagramme Druckzeit/Verbrauch, Empty-State ohne Daten.
|
||||||
|
- Wartung: KPIs, Empty-State, Reset mit Bestätigung (`armButton`).
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Cockpit und Warteschlange nutzen SSE/`createPoller` statt hartem 5‑s-Intervall.
|
||||||
|
- Shared-`config.token` wird nicht mehr als Auth akzeptiert — nur noch Session und `grt_…`-Tokens.
|
||||||
|
- Legacy-Alias `GET /api/drucker` entfernt (nur `/api/v1/drucker`).
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- SSE unter waitress: Hop-by-Hop-Header `Connection` entfernt (sonst Fehler auf `/api/v1/stream`).
|
||||||
|
- Bedarf: Material/Farbe aus `files.filaments` statt nicht existierender Spalten.
|
||||||
|
- Chrome: Service-Worker lieferte bei Fehlern die Startseite statt der angeklickten Route; „Mehr“-Dialog blockierte die Navigation; SSE und Poller liefen parallel und stauten Verbindungen.
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|||||||
@@ -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`).
|
||||||
|
|
||||||
|
|||||||
+18
-3
@@ -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,23 +16,38 @@
|
|||||||
"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,
|
||||||
"waste_percent": 0,
|
"waste_percent": 0,
|
||||||
"utilization": 50
|
"utilization": 50,
|
||||||
|
"tariff": {
|
||||||
|
"ht_euro": 0.35,
|
||||||
|
"nt_euro": 0.22,
|
||||||
|
"nt_from": "22:00",
|
||||||
|
"nt_to": "06:00"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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.
|
||||||
|
|
||||||

|

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

|

|
||||||
|
|
||||||
## 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).
|
||||||
|
- **Darstellung** — Dunkel, Hell oder System (folgt dem Gerät).
|
||||||
|
- **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**.
|
Pro Drucker: Name, Host, Familie, **gesperrt**, optional **Start-Zeitfenster**.
|
||||||
|
|
||||||
- Seed beim allerersten Start aus `drucker.json`, danach nur noch hier (Datenbank).
|
- Seed beim allerersten Start aus `drucker.json`, danach nur noch hier (Datenbank).
|
||||||
- Familie aus dem Namen (Kobra 3 / S1) oder gesetzt.
|
- Familie aus dem Namen (Kobra 3 / S1) oder gesetzt.
|
||||||
- **Gesperrt:** keine neuen Queue-Starts auf diesem Host.
|
- **Gesperrt:** keine neuen Queue-Starts auf diesem Host.
|
||||||
|
- **Zeitfenster:** Queue startet nur zwischen Von/Bis (z. B. 22:00–06:00). Optional Max.-Stunden für Nachtjobs.
|
||||||
|
|
||||||
## FQDN
|
## FQDN
|
||||||
|
|
||||||
Öffentliche Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host aus dem aktuellen Request. Wichtig, wenn du Etiketten von einem Handy scannst, das nicht `127.0.0.1` kennt.
|
Öffentliche Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host aus dem aktuellen Request. Wichtig, wenn du Etiketten von einem Handy scannst, das nicht `127.0.0.1` kennt.
|
||||||
|
|
||||||
## Telegram und Alerts
|
## Telegram, ntfy und Alerts
|
||||||
|
|
||||||
Bot-Token und Chat-ID. Pro Ereignisart an/aus (Fertig, Fehler, Offline, Queue, …). Test-Knopf zum Prüfen. Wichtige Ereignisse stehen auch in der Sidebar unter Alerts.
|
Telegram: Bot-Token und Chat-ID, pro Ereignisart an/aus, Test-Knopf. **ntfy:** Server, Topic, optional Token — gleiche Meldungen, unabhängig schaltbar, ebenfalls mit Test. Ereignisse stehen auch in der Sidebar unter Alerts.
|
||||||
|
|
||||||
## Benutzer und API-Tokens
|
## Benutzer und API-Tokens
|
||||||
|
|
||||||
Unter **Benutzer** (nur Admin) legst du Konten mit Rolle Admin oder Operator an. Unter **API-Tokens** erzeugst du Schlüssel für Home-Automation, App oder MCP — der Klartext erscheint nur einmal (`Authorization: Bearer grt_…`).
|
Unter **Benutzer** (nur Admin) legst du Konten mit Rolle Admin oder Operator an. Unter **API-Tokens** erzeugst du Schlüssel für Home-Automation, App oder MCP — der Klartext erscheint nur einmal (`Authorization: Bearer grt_…`). Ein Shared-`token` in der Config-Datei gilt nicht mehr.
|
||||||
|
|
||||||
MCP (optional): Token erzeugen, dann `python -m grotti.mcp_server` mit `GROTTI_URL` und `GROTTI_TOKEN`. Für nur Lesen: `GROTTI_MCP_READ_ONLY=1`.
|
MCP (optional): Token erzeugen, dann `python -m grotti.mcp_server` mit `GROTTI_URL` und `GROTTI_TOKEN`. Für nur Lesen: `GROTTI_MCP_READ_ONLY=1`.
|
||||||
|
|
||||||
## Energie
|
## 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. Optional **HT/NT-Tarif** mit Zeiten und Preisen — Grotti gewichtet dann die Stromkosten nach Uhrzeit. Beim Speichern warnt die Seite, wenn Düse oder Bett noch 0 sind.
|
||||||
|
|
||||||
## Weiter
|
## Weiter
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Material und mehr
|
# Material und mehr
|
||||||
|
|
||||||
Stand **17.10.2026**. Spulen, Etiketten, Projekte, Protokoll, Statistik, Kameras, Aktivität und Ideen.
|
Stand **18.10.2026**. Spulen, Etiketten, Aufträge, Protokoll, Statistik, Kameras, Aktivität.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -8,6 +8,8 @@ Stand **17.10.2026**. Spulen, Etiketten, Projekte, Protokoll, Statistik, Kameras
|
|||||||
|
|
||||||
Zwei Reiter: **Spulen** und **Sorten**.
|
Zwei Reiter: **Spulen** und **Sorten**.
|
||||||
|
|
||||||
|
Oben die Karte **Bedarf aus Warteschlange**, wenn wartende Jobs Filament brauchen: Queue-Gewicht vs. Spulenrest je Sorte/Farbe. Fehlmengen erscheinen auch in der Sidebar und als Chip in der Warteschlange.
|
||||||
|
|
||||||
**Spulen:** Filter Alle / Am Drucker / Regal / Wenig Rest. Formular bei Auswahl oder „Neue Spule“. Ort = Drucker · ACE/extern oder Regal. Scan-Feld im Seitenkopf (QR vom Etikett). Unter der ausgewählten Spule die **Akte**: verdruckte Gramm, Ausschuss, Leer-in.
|
**Spulen:** Filter Alle / Am Drucker / Regal / Wenig Rest. Formular bei Auswahl oder „Neue Spule“. Ort = Drucker · ACE/extern oder Regal. Scan-Feld im Seitenkopf (QR vom Etikett). Unter der ausgewählten Spule die **Akte**: verdruckte Gramm, Ausschuss, Leer-in.
|
||||||
|
|
||||||
**Sorten:** Profile (Dichte, Preis, Maschinen). „Spule damit anlegen“ wechselt zum Spulen-Reiter.
|
**Sorten:** Profile (Dichte, Preis, Maschinen). „Spule damit anlegen“ wechselt zum Spulen-Reiter.
|
||||||
@@ -18,17 +20,17 @@ Pro Spule ein Etikett mit Chargennummer `S-…` und QR. Der Code öffnet Materia
|
|||||||
|
|
||||||
Druck-Etiketten (`D-…`) kommen vom Druckbericht: ein Etikett für den ganzen Lauf oder je Objekt. Scan von `D-…` öffnet den Bericht.
|
Druck-Etiketten (`D-…`) kommen vom Druckbericht: ein Etikett für den ganzen Lauf oder je Objekt. Scan von `D-…` öffnet den Bericht.
|
||||||
|
|
||||||
## Projekte
|
## Aufträge
|
||||||
|
|
||||||
Unter **Projekte** Aufträge mit Notiz und Kalkulation. Dateien lassen sich einem Projekt zuweisen (Bibliothek / Senden).
|
Unter **Aufträge** Kunden, Positionen und Fortschritt. Dateien lassen sich einem Auftrag zuweisen; siehe [Aufträge](Anleitung-Auftraege.md).
|
||||||
|
|
||||||
## Protokoll und Statistik
|
## Protokoll und Statistik
|
||||||
|
|
||||||
Abgeschlossene Läufe: Dauer, Ist-Gramm, Kosten, Urteil. Zeile öffnet den **Druckbericht** (Soll/Ist, Spulen, Temperaturverlauf, Charge). **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option.
|
Abgeschlossene Läufe: Dauer, Ist-Gramm, Kosten, Urteil. Zeile öffnet den **Druckbericht** (Soll/Ist, Spulen, Temperaturverlauf, Charge). **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option.
|
||||||
|
|
||||||
Unter **Statistik** Auslastung, Erfolg, Verbrauch und CSV-Export. Zeitraum per Knopf (Tag, Woche, Monat, Gestern, Letzte Woche, Letzter Monat) oder eigene Von/Bis-Daten.
|
Unter **Statistik** KPIs, Balkendiagramme (Druckzeit je Drucker, Verbrauch je Material), Tabellen und CSV-Export. Zeitraum per Knopf oder Von/Bis.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,27 @@
|
|||||||
# Oberfläche
|
# Oberfläche
|
||||||
|
|
||||||
Stand **15.10.2026**. Wie die App aufgebaut ist — Menü, Live-Leiste, Desktop und Regal.
|
Stand **18.10.2026**. Wie die App aufgebaut ist — Menü, Live-Leiste, Desktop und Regal.
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
|
|
||||||
Links (Desktop): **Drucker**, **Dateien**, **Kameras**, **Aktivität**, **Warteschlange**, **Protokoll**, **Statistik**, **Material**, **Projekte**. Darunter **Einstellungen**, **Ideen**, **Doku**. Fuß: Version (öffnet die Doku).
|
Links (Desktop): **Drucker**, **Dateien**, **Kameras**, **Warteschlange**, **Aufträge**, **Protokoll**, **Statistik**, **Aktivität**, **Material**, **Wartung**. Darunter **Einstellungen**, **Doku**. Fuß: Version (öffnet die Doku).
|
||||||
|
|
||||||
Unter ca. 760 px: untere Leiste mit den wichtigsten Seiten. Das Seitenmenü lässt sich zur Icon-Rail einklappen.
|
Unter ca. 760 px: untere Leiste plus **Mehr**-Sheet. Das Seitenmenü lässt sich zur Icon-Rail einklappen.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Live-Leiste
|
## Live-Leiste
|
||||||
|
|
||||||
Unter dem Menü, eigene Scrollfläche. Boxen auf- und zuklappbar (Merker im Browser):
|
Unter dem Menü, eigene Scrollfläche. Boxen auf- und zuklappbar (Merker im Browser). Aktualisierung über Live-Stream (SSE), sonst Polling.
|
||||||
|
|
||||||
| 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. |
|
||||||
| **Alerts** | Ungelesene wichtige Ereignisse; als gelesen markieren oder zur Aktivität. |
|
| **Alerts** | Ungelesene wichtige Ereignisse; als gelesen markieren oder zur Aktivität. |
|
||||||
|
| **Bedarf** | Fehlende Filamentmenge für die Queue (Link zu Material). |
|
||||||
|
|
||||||
## Desktop und Regal
|
## Desktop und Regal
|
||||||
|
|
||||||
@@ -28,6 +29,8 @@ 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
|
||||||
|
- **Darstellung** — Dunkel, Hell oder System
|
||||||
|
- **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`).
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Warteschlange und Ablauf
|
# Warteschlange und Ablauf
|
||||||
|
|
||||||
Stand **15.10.2026**. Aufträge „wenn frei“, Priorität, und der Kobra-3-Ablauf (drucken → abkühlen → schieben → tasten).
|
Stand **18.10.2026**. Aufträge „wenn frei“, Priorität, und der Kobra-3-Ablauf (drucken → abkühlen → schieben → tasten).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -16,11 +16,13 @@ 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.
|
||||||
|
|
||||||
|
Reicht der Spulenrest nicht für wartende Jobs, erscheint oben ein **Fehlmenge**-Hinweis (Details unter Material → Bedarf) und in der Sidebar unter **Bedarf**.
|
||||||
|
|
||||||
## Wann startet ein wartender Auftrag?
|
## Wann startet ein wartender Auftrag?
|
||||||
|
|
||||||
Sobald der Drucker bereit ist (`standby` / fertig / abgebrochen), die **Platte frei** ist und der Drucker nicht gesperrt. Automatik aus (`auto_queue`) oder gesperrt → keine neuen Starts; laufende Abläufe laufen weiter.
|
Sobald der Drucker bereit ist (`standby` / fertig / abgebrochen), die **Platte frei** ist und der Drucker nicht gesperrt. Automatik aus (`auto_queue`) oder gesperrt → keine neuen Starts; laufende Abläufe laufen weiter. Mit **Start-Zeitfenster** in den Einstellungen nur innerhalb von Von/Bis.
|
||||||
|
|
||||||
## Ablauf (nur Kobra 3)
|
## Ablauf (nur Kobra 3)
|
||||||
|
|
||||||
@@ -34,7 +36,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**.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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'`.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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**.
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""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.filaments,
|
||||||
|
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:
|
||||||
|
filaments = row.get("filaments") or []
|
||||||
|
first = filaments[0] if isinstance(filaments, list) and filaments else {}
|
||||||
|
if not isinstance(first, dict):
|
||||||
|
first = {}
|
||||||
|
mat = (row.get("spool_material") or first.get("type") or "").strip()
|
||||||
|
color = (row.get("spool_color") or first.get("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
@@ -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:
|
||||||
|
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)),
|
||||||
}
|
}
|
||||||
|
|||||||
+178
-3
@@ -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):
|
||||||
hours = float(duration_s or 0) / 3600
|
if value is None:
|
||||||
return (float(energy["watts"]) / 1000) * hours * float(energy["euro_per_kwh"])
|
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
|
||||||
|
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
@@ -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
@@ -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 ""
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE printers ADD COLUMN IF NOT EXISTS start_window jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||||
@@ -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
@@ -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
@@ -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()
|
||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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):
|
||||||
|
|||||||
+368
-60
@@ -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)
|
||||||
)
|
placed = True
|
||||||
known_names.add(name)
|
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1785,20 +2081,28 @@ def create_printer(conn, printer_id, name, host, family):
|
|||||||
return printer_out(row)
|
return printer_out(row)
|
||||||
|
|
||||||
|
|
||||||
def update_printer(conn, printer_id, name, host, family, locked=None):
|
def update_printer(conn, printer_id, name, host, family, locked=None, start_window=None):
|
||||||
current = printer_by_id(conn, printer_id)
|
current = printer_by_id(conn, printer_id)
|
||||||
if not current:
|
if not current:
|
||||||
return None
|
return None
|
||||||
if locked is None:
|
if locked is None:
|
||||||
locked = bool(current.get("locked"))
|
locked = bool(current.get("locked"))
|
||||||
|
if start_window is None:
|
||||||
|
sw = current.get("start_window") or {}
|
||||||
|
elif isinstance(start_window, dict):
|
||||||
|
sw = start_window
|
||||||
|
else:
|
||||||
|
sw = {}
|
||||||
|
if not isinstance(sw, dict):
|
||||||
|
sw = {}
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE printers
|
UPDATE printers
|
||||||
SET name = %s, host = %s, family = %s, locked = %s
|
SET name = %s, host = %s, family = %s, locked = %s, start_window = %s
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""",
|
""",
|
||||||
(name, host, family, bool(locked), printer_id),
|
(name, host, family, bool(locked), Json(sw), printer_id),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if current["host"] != host:
|
if current["host"] != host:
|
||||||
old = current["host"]
|
old = current["host"]
|
||||||
@@ -2018,6 +2322,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 +2340,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 +2352,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 +2372,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 +2652,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):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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.1.0"
|
||||||
+99
-27
@@ -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,18 +63,21 @@ class StateWatcher:
|
|||||||
self._stop.wait(self.interval)
|
self._stop.wait(self.interval)
|
||||||
|
|
||||||
def tick(self):
|
def tick(self):
|
||||||
with connect(self.database_url) as conn:
|
try:
|
||||||
printers = store.list_printers(conn)
|
with connect(self.database_url) as conn:
|
||||||
if not printers:
|
printers = store.list_printers(conn)
|
||||||
|
if not printers:
|
||||||
|
conn.commit()
|
||||||
|
return
|
||||||
|
lives = moonraker.snapshot(printers)
|
||||||
|
with self._lock:
|
||||||
|
self.latest = lives
|
||||||
|
self._updated_at = time.monotonic()
|
||||||
|
for live in lives:
|
||||||
|
self._one(conn, live)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return
|
finally:
|
||||||
lives = moonraker.snapshot(printers)
|
publish_live("drucker", {"at": datetime.now(timezone.utc).isoformat()})
|
||||||
with self._lock:
|
|
||||||
self.latest = lives
|
|
||||||
self._updated_at = time.monotonic()
|
|
||||||
for live in lives:
|
|
||||||
self._one(conn, live)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
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)
|
||||||
spool_id = (sent or {}).get("spool_id")
|
ace = (sent or {}).get("ace_map") or []
|
||||||
if not spool_id:
|
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")
|
||||||
|
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", {})
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest==9.1.1
|
||||||
+7
-5
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Hilfsskripte (Orca-Nachbearbeitung u. a.)."""
|
||||||
@@ -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())
|
||||||
@@ -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__":
|
||||||
|
|||||||
Whitespace-only changes.
@@ -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()
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""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,
|
||||||
|
"filaments": [{"type": "PLA", "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,
|
||||||
|
"filaments": [{"type": "PETG", "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()
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
+531
-38
@@ -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,9 +99,11 @@ 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",
|
||||||
|
"/api/v1/ntfy/test",
|
||||||
}
|
}
|
||||||
ADMIN_METHODS_ON = {
|
ADMIN_METHODS_ON = {
|
||||||
# Drucker anlegen/ändern/löschen nur Admin
|
# Drucker anlegen/ändern/löschen nur Admin
|
||||||
@@ -86,7 +112,13 @@ def create_app(cfg):
|
|||||||
|
|
||||||
def conn():
|
def conn():
|
||||||
if "db" not in g:
|
if "db" not in g:
|
||||||
g.db = psycopg.connect(cfg["database_url"], row_factory=dict_row)
|
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_from_pool = False
|
||||||
return g.db
|
return g.db
|
||||||
|
|
||||||
def ensure_csrf():
|
def ensure_csrf():
|
||||||
@@ -104,14 +136,6 @@ def create_app(cfg):
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
def resolve_bearer(presented):
|
def resolve_bearer(presented):
|
||||||
legacy = cfg.get("token") or ""
|
|
||||||
if legacy and secrets.compare_digest(presented, legacy):
|
|
||||||
return {
|
|
||||||
"id": None,
|
|
||||||
"username": "legacy-token",
|
|
||||||
"role": "admin",
|
|
||||||
"auth": "legacy",
|
|
||||||
}
|
|
||||||
if not presented.startswith(grotti_auth.TOKEN_PREFIX):
|
if not presented.startswith(grotti_auth.TOKEN_PREFIX):
|
||||||
return None
|
return None
|
||||||
row = store.get_api_token_by_hash(conn(), grotti_auth.hash_token(presented))
|
row = store.get_api_token_by_hash(conn(), grotti_auth.hash_token(presented))
|
||||||
@@ -176,7 +200,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 +218,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 +261,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 +398,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 +457,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 +560,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 +583,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 +621,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 +683,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 +730,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 +788,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")
|
||||||
@@ -709,8 +814,17 @@ def create_app(cfg):
|
|||||||
)
|
)
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
@app.post("/api/v1/ntfy/test")
|
||||||
|
def ntfy_test():
|
||||||
|
ok = notify.send_ntfy(cfg, "Grotti-Test: ntfy ist verbunden.")
|
||||||
|
if not ok:
|
||||||
|
raise GrottiError(
|
||||||
|
"ntfy nicht erreichbar. Server, Topic und „aktiv“ prüfen.",
|
||||||
|
"ntfy",
|
||||||
|
)
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
@app.get("/api/v1/drucker")
|
@app.get("/api/v1/drucker")
|
||||||
@app.get("/api/drucker") # veraltet — Alias zu /api/v1/drucker
|
|
||||||
def printers():
|
def printers():
|
||||||
rows = store.list_printers(conn())
|
rows = store.list_printers(conn())
|
||||||
clears = store.bed_clear_map(conn())
|
clears = store.bed_clear_map(conn())
|
||||||
@@ -766,11 +880,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,21 +898,30 @@ 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)
|
||||||
if other and str(other["id"]) != str(printer_id):
|
if other and str(other["id"]) != str(printer_id):
|
||||||
raise GrottiError("Diesen Host gibt es schon.", "host")
|
raise GrottiError("Diesen Host gibt es schon.", "host")
|
||||||
locked = data.get("locked") if "locked" in data else current.get("locked")
|
locked = data.get("locked") if "locked" in data else current.get("locked")
|
||||||
row = store.update_printer(conn(), printer_id, name, host, family, locked=bool(locked))
|
start_window = data.get("start_window") if "start_window" in data else None
|
||||||
|
if start_window is not None and not isinstance(start_window, dict):
|
||||||
|
raise GrottiError("Zeitfenster muss ein Objekt sein.", "start_window")
|
||||||
|
row = store.update_printer(
|
||||||
|
conn(),
|
||||||
|
printer_id,
|
||||||
|
name,
|
||||||
|
host,
|
||||||
|
family,
|
||||||
|
locked=bool(locked),
|
||||||
|
start_window=start_window,
|
||||||
|
)
|
||||||
return jsonify(row)
|
return jsonify(row)
|
||||||
|
|
||||||
@app.delete("/api/v1/drucker/id/<printer_id>")
|
@app.delete("/api/v1/drucker/id/<printer_id>")
|
||||||
@@ -906,7 +1027,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 +1035,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 +1442,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 +1733,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 +2162,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 +2199,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 +2215,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 +2240,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 +2376,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)
|
||||||
|
|
||||||
|
# Kein Connection-Header: waitress verbietet Hop-by-Hop-Header (PEP 3333).
|
||||||
|
return Response(
|
||||||
|
stream_with_context(generate()),
|
||||||
|
mimetype="text/event-stream; charset=utf-8",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
+152
-10
@@ -12,7 +12,8 @@ function escapeHtml(value) {
|
|||||||
.replaceAll("&", "&")
|
.replaceAll("&", "&")
|
||||||
.replaceAll("<", "<")
|
.replaceAll("<", "<")
|
||||||
.replaceAll(">", ">")
|
.replaceAll(">", ">")
|
||||||
.replaceAll('"', """);
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function api(path, options = {}) {
|
async function api(path, options = {}) {
|
||||||
@@ -27,13 +28,29 @@ async function api(path, options = {}) {
|
|||||||
headers.set("Content-Type", "application/json");
|
headers.set("Content-Type", "application/json");
|
||||||
body = JSON.stringify(options.json);
|
body = JSON.stringify(options.json);
|
||||||
}
|
}
|
||||||
const response = await fetch("/api/v1" + path, {
|
const timeoutMs = options.timeoutMs != null ? Number(options.timeoutMs) : 20000;
|
||||||
method,
|
const ctrl = options.signal ? null : new AbortController();
|
||||||
headers,
|
const timer = ctrl && timeoutMs > 0
|
||||||
body,
|
? setTimeout(() => ctrl.abort(), timeoutMs)
|
||||||
cache: "no-store",
|
: 0;
|
||||||
credentials: "same-origin",
|
let response;
|
||||||
});
|
try {
|
||||||
|
response = await fetch("/api/v1" + path, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
cache: "no-store",
|
||||||
|
credentials: "same-origin",
|
||||||
|
signal: options.signal || (ctrl && ctrl.signal),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error && error.name === "AbortError") {
|
||||||
|
throw new Error("Zeitüberschreitung — Server antwortet nicht.");
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
}
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
const here = location.pathname + location.search;
|
const here = location.pathname + location.search;
|
||||||
if (!here.startsWith("/login") && !here.startsWith("/setup")) {
|
if (!here.startsWith("/login") && !here.startsWith("/setup")) {
|
||||||
@@ -89,7 +106,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 +294,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(() => {
|
||||||
|
|||||||
+321
-13
@@ -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; }
|
||||||
@@ -2299,7 +2398,7 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); }
|
|||||||
.nav-main { display: none; }
|
.nav-main { display: none; }
|
||||||
html.shelf .mobile-nav,
|
html.shelf .mobile-nav,
|
||||||
body.shelf .mobile-nav {
|
body.shelf .mobile-nav {
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(5, 1fr);
|
||||||
}
|
}
|
||||||
.nav-secondary { margin-top: 0; border-top: 0; padding-top: 0; flex: 1; justify-content: flex-end; }
|
.nav-secondary { margin-top: 0; border-top: 0; padding-top: 0; flex: 1; justify-content: flex-end; }
|
||||||
.side-foot { display: none; }
|
.side-foot { display: none; }
|
||||||
@@ -2322,7 +2421,7 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); }
|
|||||||
.desk:not(.no-cam) .desk-cam { max-height: 18rem; }
|
.desk:not(.no-cam) .desk-cam { max-height: 18rem; }
|
||||||
.mobile-nav {
|
.mobile-nav {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(5, 1fr);
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
@@ -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,164 @@ 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f4f4f6;
|
||||||
|
--ink: #16161a;
|
||||||
|
--muted: #5c5c66;
|
||||||
|
--faint: #6e6e78;
|
||||||
|
--line: rgba(0, 0, 0, 0.08);
|
||||||
|
--line-strong: rgba(0, 0, 0, 0.14);
|
||||||
|
--card: #ffffff;
|
||||||
|
--card-2: #f0f0f3;
|
||||||
|
--accent: #ea580c;
|
||||||
|
--accent-ink: #fff7ed;
|
||||||
|
--accent-soft: rgba(234, 88, 12, 0.12);
|
||||||
|
--warn: #dc2626;
|
||||||
|
--pause: #d97706;
|
||||||
|
--track: #e4e4ea;
|
||||||
|
--shadow: 0 10px 28px rgba(20, 20, 30, 0.08);
|
||||||
|
--live: #16a34a;
|
||||||
|
--live-soft: rgba(22, 163, 74, 0.12);
|
||||||
|
--st-printing: #16a34a;
|
||||||
|
--st-printing-soft: rgba(22, 163, 74, 0.1);
|
||||||
|
--st-heating: #d97706;
|
||||||
|
--st-heating-soft: rgba(217, 119, 6, 0.1);
|
||||||
|
--st-done: #4f46e5;
|
||||||
|
--st-done-soft: rgba(79, 70, 229, 0.1);
|
||||||
|
--st-error: #dc2626;
|
||||||
|
--st-error-soft: rgba(220, 38, 38, 0.1);
|
||||||
|
--st-offline: #73737c;
|
||||||
|
--st-offline-soft: rgba(0, 0, 0, 0.04);
|
||||||
|
--st-waiting: #ea580c;
|
||||||
|
--st-waiting-soft: rgba(234, 88, 12, 0.1);
|
||||||
|
--st-idle: #5c5c66;
|
||||||
|
--st-idle-soft: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
html[data-theme="light"] body {
|
||||||
|
background:
|
||||||
|
radial-gradient(800px 400px at 0% 0%, rgba(234, 88, 12, 0.06), transparent 55%),
|
||||||
|
var(--bg);
|
||||||
|
}
|
||||||
|
html[data-theme="light"] a:hover { color: #c2410c; }
|
||||||
|
html[data-theme="light"] {
|
||||||
|
scrollbar-color: rgba(0, 0, 0, 0.18) transparent;
|
||||||
|
}
|
||||||
|
html[data-theme="light"]::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.18); }
|
||||||
|
html[data-theme="light"]::-webkit-scrollbar-thumb:hover { background: rgba(0, 0, 0, 0.28); }
|
||||||
|
|
||||||
|
.narrow-fields { max-width: 36rem; }
|
||||||
|
.printer-edit-list { display: grid; gap: 0.85rem; }
|
||||||
|
.printer-edit {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
background: var(--card-2);
|
||||||
|
}
|
||||||
|
.printer-edit .actions { margin-top: 0.65rem; gap: 0.5rem; }
|
||||||
|
.bedarf-card { margin-bottom: 1rem; }
|
||||||
|
.bedarf-card tr.warn td { color: var(--warn); }
|
||||||
|
.wartung-card .lib-head { align-items: center; }
|
||||||
|
|
||||||
|
.bar-chart { display: grid; gap: 0.45rem; margin-top: 0.35rem; }
|
||||||
|
.bar-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(5rem, 8rem) 1fr auto;
|
||||||
|
gap: 0.55rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.bar-label {
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
color: var(--muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.bar-track {
|
||||||
|
height: 0.55rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: var(--track);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bar-fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent) 60%, #fbbf24));
|
||||||
|
}
|
||||||
|
.bar-val {
|
||||||
|
font-size: var(--t-xs);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--faint);
|
||||||
|
min-width: 3.5rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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; }
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/* Nur Static-Assets cachen — nie HTML-Navigation abfangen (sonst falsche Seite in Chrome). */
|
||||||
|
const CACHE = "grotti-shell-v2";
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
self.skipWaiting();
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE).then((cache) =>
|
||||||
|
cache.addAll([
|
||||||
|
"/static/grotti.css?v=56",
|
||||||
|
"/static/api.js?v=29",
|
||||||
|
"/static/ui.js?v=3",
|
||||||
|
]).catch(() => undefined)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
|
||||||
|
).then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const req = event.request;
|
||||||
|
if (req.method !== "GET") return;
|
||||||
|
// Dokument-Navigation immer ans Netz — kein Fallback auf „/“.
|
||||||
|
if (req.mode === "navigate") return;
|
||||||
|
const url = new URL(req.url);
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
if (url.pathname.startsWith("/api/")) return;
|
||||||
|
if (!url.pathname.startsWith("/static/")) return;
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
fetch(req)
|
||||||
|
.then((res) => {
|
||||||
|
if (res.ok) {
|
||||||
|
const copy = res.clone();
|
||||||
|
caches.open(CACHE).then((c) => c.put(req, copy)).catch(() => {});
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match(req))
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
{% 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;
|
||||||
|
}
|
||||||
|
const selected = document.getElementById("order-id").value;
|
||||||
|
orders.forEach((order) => {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.className = "pick" + (selected && String(order.id) === selected ? " on" : "");
|
||||||
|
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 () => {
|
||||||
|
try {
|
||||||
|
const full = await api("/auftraege/" + order.id);
|
||||||
|
fillOrder(full);
|
||||||
|
renderList();
|
||||||
|
} catch (error) {
|
||||||
|
showNote("order-note", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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
@@ -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
@@ -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" });
|
||||||
|
|||||||
@@ -10,14 +10,34 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Dieser Browser</h2>
|
<h2>Dieser Browser</h2>
|
||||||
<p class="hint">Nur auf diesem Gerät / in diesem Browser. Anderer PC oder Inkognito bleibt unberührt.</p>
|
<p class="hint">Nur auf diesem Gerät / in diesem Browser. Anderer PC oder Inkognito bleibt unberührt.</p>
|
||||||
<div class="field" style="max-width:16rem">
|
<div class="grid-2 narrow-fields">
|
||||||
<label for="display-mode">Anzeige</label>
|
<div class="field">
|
||||||
<select id="display-mode">
|
<label for="display-mode">Anzeige</label>
|
||||||
<option value="desktop">Desktop</option>
|
<select id="display-mode">
|
||||||
<option value="shelf">Regal (Touch)</option>
|
<option value="desktop">Desktop</option>
|
||||||
</select>
|
<option value="shelf">Regal (Touch)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="theme-mode">Darstellung</label>
|
||||||
|
<select id="theme-mode">
|
||||||
|
<option value="dark">Dunkel</option>
|
||||||
|
<option value="light">Hell</option>
|
||||||
|
<option value="system">System</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<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>
|
||||||
</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 — Shortcut <code>?shelf=1</code>. Darstellung und Zeitzone 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 +49,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. B. localhost).</p>
|
<p class="hint">FQDN bzw. Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host der aktuellen Anfrage (z. B. localhost).</p>
|
||||||
@@ -43,14 +77,9 @@
|
|||||||
|
|
||||||
<section class="card admin-only">
|
<section class="card admin-only">
|
||||||
<h2>Drucker</h2>
|
<h2>Drucker</h2>
|
||||||
<p class="hint">Gesperrt = kein neuer „wenn frei“-Start auf diesem Drucker.</p>
|
<p class="hint">Gesperrt = kein neuer „wenn frei“-Start. Zeitfenster begrenzt Starts (z. B. Nachtstrom).</p>
|
||||||
<div class="table-wrap">
|
<div id="printer-list" class="printer-edit-list"></div>
|
||||||
<table>
|
<form id="printer-add" class="grid-2 section-gap">
|
||||||
<thead><tr><th>Name</th><th>Host</th><th>Familie</th><th>Gesperrt</th><th></th></tr></thead>
|
|
||||||
<tbody id="printer-rows"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<form id="printer-add" class="grid-2" style="margin-top:1rem">
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="add-name">Name</label>
|
<label for="add-name">Name</label>
|
||||||
<input id="add-name" required placeholder="z. B. Kobra 3 Werkstatt">
|
<input id="add-name" required placeholder="z. B. Kobra 3 Werkstatt">
|
||||||
@@ -101,17 +130,64 @@
|
|||||||
<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. Push in der ntfy-App auf dem Handy.</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" id="ntfy-save">Speichern</button>
|
||||||
|
<button type="button" class="ghost" id="ntfy-test">Test senden</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>
|
||||||
|
<h3 class="section-gap">HT / NT-Tarif</h3>
|
||||||
|
<label class="check"><input type="checkbox" id="en-tariff"> Unterschiedliche Preise für Tag und Nacht</label>
|
||||||
|
<div class="grid-2" id="en-tariff-fields" hidden>
|
||||||
|
<div class="field"><label for="en-ht">HT € / kWh</label><input id="en-ht" type="number" step="0.01"></div>
|
||||||
|
<div class="field"><label for="en-nt">NT € / kWh</label><input id="en-nt" type="number" step="0.01"></div>
|
||||||
|
<div class="field"><label for="en-nt-from">NT ab</label><input id="en-nt-from" type="time" value="22:00"></div>
|
||||||
|
<div class="field"><label for="en-nt-to">NT bis</label><input id="en-nt-to" type="time" value="06:00"></div>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Grundlast = Elektronik/Motion. Düse/Bett 0 = nur pauschal. Mit HT/NT gewichtet Grotti die Stromkosten nach Uhrzeit.</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>
|
||||||
@@ -156,7 +232,7 @@
|
|||||||
</form>
|
</form>
|
||||||
<p id="token-note"></p>
|
<p id="token-note"></p>
|
||||||
<p class="ok" id="token-once" hidden></p>
|
<p class="ok" id="token-once" hidden></p>
|
||||||
<p class="hint" id="legacy-token-hint" hidden>In der Config steht noch ein Shared-Token — der gilt weiter als Admin. Besser auf Benutzer-Tokens umstellen und <code>token</code> in der Config leeren.</p>
|
<p class="hint" id="legacy-token-hint" hidden>In der Config steht noch ein Shared-<code>token</code> — der wird seit 3.1 nicht mehr akzeptiert. Bitte leeren und API-Tokens (<code>grt_…</code>) nutzen.</p>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block script %}
|
{% block script %}
|
||||||
@@ -173,39 +249,87 @@ if (!isAdmin) {
|
|||||||
|
|
||||||
/* showNote aus api.js: Inline-Hinweis + Toast */
|
/* showNote aus api.js: Inline-Hinweis + Toast */
|
||||||
|
|
||||||
|
function windowFrom(row) {
|
||||||
|
const sw = row.start_window || {};
|
||||||
|
const allowed = Array.isArray(sw.allowed) ? sw.allowed : [];
|
||||||
|
const first = allowed[0] || {};
|
||||||
|
return {
|
||||||
|
enabled: allowed.length > 0,
|
||||||
|
from: first.from || "22:00",
|
||||||
|
to: first.to || "06:00",
|
||||||
|
maxHours: sw.max_hours_overnight || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function renderPrinters(rows) {
|
function renderPrinters(rows) {
|
||||||
const body = document.getElementById("printer-rows");
|
const root = document.getElementById("printer-list");
|
||||||
body.innerHTML = "";
|
root.innerHTML = "";
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
body.innerHTML = '<tr><td class="empty" colspan="5">Keine Drucker.</td></tr>';
|
root.innerHTML = '<div class="empty-state"><svg class="ico"><use href="#i-printer"/></svg><p>Keine Drucker.</p></div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
rows.forEach((row) => {
|
rows.forEach((row) => {
|
||||||
const tr = document.createElement("tr");
|
const win = windowFrom(row);
|
||||||
tr.innerHTML = `<td><input data-f="name"></td><td><input data-f="host"></td><td><select data-f="family">
|
const card = document.createElement("div");
|
||||||
<option value="kobra_3">Kobra 3</option>
|
card.className = "printer-edit";
|
||||||
<option value="kobra_s1">Kobra S1</option>
|
card.innerHTML = `
|
||||||
<option value="">—</option>
|
<div class="grid-2">
|
||||||
</select></td><td><label class="check"><input type="checkbox" data-f="locked"></label></td><td></td>`;
|
<div class="field"><label>Name</label><input data-f="name"></div>
|
||||||
tr.querySelector('[data-f="name"]').value = row.name || "";
|
<div class="field"><label>Host / IP</label><input data-f="host"></div>
|
||||||
tr.querySelector('[data-f="host"]').value = row.host || "";
|
<div class="field"><label>Familie</label><select data-f="family">
|
||||||
tr.querySelector('[data-f="family"]').value = row.family || "";
|
<option value="kobra_3">Kobra 3</option>
|
||||||
tr.querySelector('[data-f="locked"]').checked = !!row.locked;
|
<option value="kobra_s1">Kobra S1</option>
|
||||||
|
<option value="">—</option>
|
||||||
|
</select></div>
|
||||||
|
<div class="field"><label class="check" style="margin-top:1.4rem"><input type="checkbox" data-f="locked"> Gesperrt</label></div>
|
||||||
|
</div>
|
||||||
|
<details class="mat-more" ${win.enabled ? "open" : ""}>
|
||||||
|
<summary><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Start-Zeitfenster</span>
|
||||||
|
<span class="tiny">${win.enabled ? escapeHtml(win.from) + "–" + escapeHtml(win.to) : "aus"}</span>
|
||||||
|
</summary>
|
||||||
|
<div class="grid-2" style="margin-top:0.6rem">
|
||||||
|
<label class="check" style="grid-column:1/-1"><input type="checkbox" data-f="win-on"> Nur in Zeitfenster starten</label>
|
||||||
|
<div class="field"><label>Von</label><input type="time" data-f="win-from"></div>
|
||||||
|
<div class="field"><label>Bis</label><input type="time" data-f="win-to"></div>
|
||||||
|
<div class="field"><label>Max. Stunden (Nachtjob)</label><input type="number" min="0" step="0.5" data-f="win-max" placeholder="leer = egal"></div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<div class="actions"></div>`;
|
||||||
|
card.querySelector('[data-f="name"]').value = row.name || "";
|
||||||
|
card.querySelector('[data-f="host"]').value = row.host || "";
|
||||||
|
card.querySelector('[data-f="family"]').value = row.family || "";
|
||||||
|
card.querySelector('[data-f="locked"]').checked = !!row.locked;
|
||||||
|
card.querySelector('[data-f="win-on"]').checked = win.enabled;
|
||||||
|
card.querySelector('[data-f="win-from"]').value = win.from;
|
||||||
|
card.querySelector('[data-f="win-to"]').value = win.to;
|
||||||
|
card.querySelector('[data-f="win-max"]').value = win.maxHours;
|
||||||
const save = document.createElement("button");
|
const save = document.createElement("button");
|
||||||
save.type = "button";
|
save.type = "button";
|
||||||
save.textContent = "Speichern";
|
save.textContent = "Speichern";
|
||||||
save.addEventListener("click", async () => {
|
save.addEventListener("click", async () => {
|
||||||
|
const on = card.querySelector('[data-f="win-on"]').checked;
|
||||||
|
const start_window = {};
|
||||||
|
if (on) {
|
||||||
|
start_window.allowed = [{
|
||||||
|
from: card.querySelector('[data-f="win-from"]').value || "22:00",
|
||||||
|
to: card.querySelector('[data-f="win-to"]').value || "06:00",
|
||||||
|
}];
|
||||||
|
const maxH = Number(card.querySelector('[data-f="win-max"]').value);
|
||||||
|
if (maxH > 0) start_window.max_hours_overnight = maxH;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await api("/drucker/id/" + row.id, {
|
await api("/drucker/id/" + row.id, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
json: {
|
json: {
|
||||||
name: tr.querySelector('[data-f="name"]').value,
|
name: card.querySelector('[data-f="name"]').value,
|
||||||
host: tr.querySelector('[data-f="host"]').value,
|
host: card.querySelector('[data-f="host"]').value,
|
||||||
family: tr.querySelector('[data-f="family"]').value,
|
family: card.querySelector('[data-f="family"]').value,
|
||||||
locked: tr.querySelector('[data-f="locked"]').checked,
|
locked: card.querySelector('[data-f="locked"]').checked,
|
||||||
|
start_window,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
showNote("printer-note", null, "Gespeichert: " + tr.querySelector('[data-f="name"]').value);
|
showNote("printer-note", null, "Gespeichert: " + card.querySelector('[data-f="name"]').value);
|
||||||
|
toast("Drucker gespeichert.");
|
||||||
load();
|
load();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showNote("printer-note", error);
|
showNote("printer-note", error);
|
||||||
@@ -226,9 +350,8 @@ function renderPrinters(rows) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
tr.children[4].appendChild(save);
|
card.querySelector(".actions").append(save, del);
|
||||||
tr.children[4].appendChild(del);
|
root.appendChild(card);
|
||||||
body.appendChild(tr);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,6 +453,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 +475,35 @@ 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;
|
||||||
|
const tariff = en.tariff || null;
|
||||||
|
document.getElementById("en-tariff").checked = !!tariff;
|
||||||
|
document.getElementById("en-tariff-fields").hidden = !tariff;
|
||||||
|
document.getElementById("en-ht").value = (tariff && tariff.ht_euro) ?? (en.euro_per_kwh ?? 0.35);
|
||||||
|
document.getElementById("en-nt").value = (tariff && tariff.nt_euro) ?? (en.euro_per_kwh ?? 0.35);
|
||||||
|
document.getElementById("en-nt-from").value = (tariff && tariff.nt_from) || "22:00";
|
||||||
|
document.getElementById("en-nt-to").value = (tariff && tariff.nt_to) || "06:00";
|
||||||
|
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 +563,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 +651,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,27 +703,84 @@ document.getElementById("tg-test").addEventListener("click", async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById("ntfy-test")?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await api("/ntfy/test", { method: "POST", json: {} });
|
||||||
|
showNote("ntfy-note", null, "Testnachricht gesendet.");
|
||||||
|
} catch (error) {
|
||||||
|
showNote("ntfy-note", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("en-tariff")?.addEventListener("change", (event) => {
|
||||||
|
document.getElementById("en-tariff-fields").hidden = !event.target.checked;
|
||||||
|
});
|
||||||
|
|
||||||
|
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 {
|
||||||
await api("/einstellungen", {
|
const nozzle = Number(document.getElementById("en-nozzle").value) || 0;
|
||||||
method: "PATCH",
|
const bed = Number(document.getElementById("en-bed").value) || 0;
|
||||||
json: {
|
const energy = {
|
||||||
energy: {
|
watts: Number(document.getElementById("en-watts").value),
|
||||||
watts: Number(document.getElementById("en-watts").value),
|
nozzle_watts: nozzle,
|
||||||
euro_per_kwh: Number(document.getElementById("en-kwh").value),
|
bed_watts: bed,
|
||||||
dryer_watts: Number(document.getElementById("en-dryer").value),
|
euro_per_kwh: Number(document.getElementById("en-kwh").value),
|
||||||
dry_hours: Number(document.getElementById("en-dryh").value),
|
dryer_watts: Number(document.getElementById("en-dryer").value),
|
||||||
waste_percent: Number(document.getElementById("en-waste").value),
|
dry_hours: Number(document.getElementById("en-dryh").value),
|
||||||
utilization: Number(document.getElementById("en-util").value),
|
waste_percent: Number(document.getElementById("en-waste").value),
|
||||||
},
|
utilization: Number(document.getElementById("en-util").value),
|
||||||
},
|
};
|
||||||
});
|
if (document.getElementById("en-tariff").checked) {
|
||||||
showNote("en-note", null, "Energie gespeichert.");
|
energy.tariff = {
|
||||||
|
ht_euro: Number(document.getElementById("en-ht").value),
|
||||||
|
nt_euro: Number(document.getElementById("en-nt").value),
|
||||||
|
nt_from: document.getElementById("en-nt-from").value || "22:00",
|
||||||
|
nt_to: document.getElementById("en-nt-to").value || "06:00",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await api("/einstellungen", { method: "PATCH", json: { energy } });
|
||||||
|
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.");
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showNote("en-note", error);
|
showNote("en-note", error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function flashDisplayNote() {
|
||||||
|
const note = document.getElementById("display-note");
|
||||||
|
if (!note) return;
|
||||||
|
note.hidden = false;
|
||||||
|
setTimeout(() => { note.hidden = true; }, 2500);
|
||||||
|
}
|
||||||
|
|
||||||
(function initDisplayMode() {
|
(function initDisplayMode() {
|
||||||
const sel = document.getElementById("display-mode");
|
const sel = document.getElementById("display-mode");
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
@@ -526,6 +796,43 @@ document.getElementById("en-save").addEventListener("click", async () => {
|
|||||||
document.documentElement.classList.toggle("shelf", sel.value === "shelf");
|
document.documentElement.classList.toggle("shelf", sel.value === "shelf");
|
||||||
document.body.classList.toggle("shelf", sel.value === "shelf");
|
document.body.classList.toggle("shelf", sel.value === "shelf");
|
||||||
}
|
}
|
||||||
|
flashDisplayNote();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
(function initThemeMode() {
|
||||||
|
const sel = document.getElementById("theme-mode");
|
||||||
|
if (!sel) return;
|
||||||
|
try {
|
||||||
|
const value = localStorage.getItem("grotti-theme") || "dark";
|
||||||
|
sel.value = ["dark", "light", "system"].includes(value) ? value : "dark";
|
||||||
|
} catch (error) {
|
||||||
|
sel.value = "dark";
|
||||||
|
}
|
||||||
|
sel.addEventListener("change", () => {
|
||||||
|
try { localStorage.setItem("grotti-theme", sel.value); } catch (error) {}
|
||||||
|
if (typeof applyTheme === "function") applyTheme(sel.value);
|
||||||
|
else {
|
||||||
|
const light = sel.value === "light" || (sel.value === "system" && window.matchMedia("(prefers-color-scheme: light)").matches);
|
||||||
|
document.documentElement.dataset.theme = light ? "light" : "dark";
|
||||||
|
document.documentElement.style.colorScheme = light ? "light" : "dark";
|
||||||
|
}
|
||||||
|
flashDisplayNote();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
(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");
|
const note = document.getElementById("display-note");
|
||||||
if (note) {
|
if (note) {
|
||||||
note.hidden = false;
|
note.hidden = false;
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
+235
-42
@@ -9,9 +9,27 @@
|
|||||||
<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=56">
|
||||||
|
<link rel="manifest" href="{{ url_for('static', filename='manifest.webmanifest') }}">
|
||||||
|
<meta name="theme-color" content="#0a0a0c" id="theme-color-meta">
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
function applyThemeEarly(mode) {
|
||||||
|
var light = mode === "light";
|
||||||
|
if (mode === "system") {
|
||||||
|
try { light = window.matchMedia("(prefers-color-scheme: light)").matches; } catch (e) { light = false; }
|
||||||
|
}
|
||||||
|
document.documentElement.dataset.theme = light ? "light" : "dark";
|
||||||
|
document.documentElement.style.colorScheme = light ? "light" : "dark";
|
||||||
|
var meta = document.getElementById("theme-color-meta");
|
||||||
|
if (meta) meta.content = light ? "#f4f4f6" : "#0a0a0c";
|
||||||
|
}
|
||||||
|
window.applyTheme = function (mode) {
|
||||||
|
try { if (mode) localStorage.setItem("grotti-theme", mode); } catch (e) {}
|
||||||
|
var m = mode;
|
||||||
|
try { m = m || localStorage.getItem("grotti-theme") || "dark"; } catch (e) { m = "dark"; }
|
||||||
|
applyThemeEarly(m);
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
if (!localStorage.getItem("grotti-display") && localStorage.getItem("farm-display")) {
|
if (!localStorage.getItem("grotti-display") && localStorage.getItem("farm-display")) {
|
||||||
localStorage.setItem("grotti-display", localStorage.getItem("farm-display"));
|
localStorage.setItem("grotti-display", localStorage.getItem("farm-display"));
|
||||||
@@ -33,11 +51,20 @@
|
|||||||
if (nav === "1" || (nav == null && localStorage.getItem("grotti-display") === "shelf")) {
|
if (nav === "1" || (nav == null && localStorage.getItem("grotti-display") === "shelf")) {
|
||||||
document.documentElement.classList.add("nav-collapsed");
|
document.documentElement.classList.add("nav-collapsed");
|
||||||
}
|
}
|
||||||
|
applyThemeEarly(localStorage.getItem("grotti-theme") || "dark");
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", function () {
|
||||||
|
var mode = "dark";
|
||||||
|
try { mode = localStorage.getItem("grotti-theme") || "dark"; } catch (err) {}
|
||||||
|
if (mode === "system") applyThemeEarly("system");
|
||||||
|
});
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
})();
|
})();
|
||||||
</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 +123,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>
|
||||||
@@ -128,10 +156,14 @@
|
|||||||
<ul class="queue-list" id="alert-list"></ul>
|
<ul class="queue-list" id="alert-list"></ul>
|
||||||
<div class="queue-foot"><button type="button" class="ghost tiny-btn" id="events-clear">gelesen</button> <a href="/aktivitaet">alle →</a></div>
|
<div class="queue-foot"><button type="button" class="ghost tiny-btn" id="events-clear">gelesen</button> <a href="/aktivitaet">alle →</a></div>
|
||||||
</details>
|
</details>
|
||||||
|
<details class="queue-box" data-box="bedarf" id="bedarf-box" open hidden>
|
||||||
|
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Bedarf</span><span class="count bad" id="bedarf-count">0</span></summary>
|
||||||
|
<ul class="queue-list" id="bedarf-list"></ul>
|
||||||
|
<div class="queue-foot"><a href="/material#bedarf">Material →</a></div>
|
||||||
|
</details>
|
||||||
</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 +184,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" value="cancel">Schließen</button></form>
|
||||||
|
</dialog>
|
||||||
|
<script src="{{ url_for('static', filename='api.js') }}?v=29"></script>
|
||||||
|
<script src="{{ url_for('static', filename='ui.js') }}?v=3"></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 +287,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 +345,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 +386,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,
|
||||||
@@ -431,6 +506,25 @@ async function refreshEvents() {
|
|||||||
</li>`).join("");
|
</li>`).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshBedarf() {
|
||||||
|
let data;
|
||||||
|
try { data = await api("/bedarf"); } catch (error) { return; }
|
||||||
|
const shortfall = data.shortfall || [];
|
||||||
|
const box = document.getElementById("bedarf-box");
|
||||||
|
if (!box) return;
|
||||||
|
box.hidden = !shortfall.length;
|
||||||
|
document.getElementById("bedarf-count").textContent = shortfall.length;
|
||||||
|
document.getElementById("bedarf-list").innerHTML = shortfall.slice(0, 5).map((item) => {
|
||||||
|
const label = [item.material_type, item.color].filter(Boolean).join(" · ") || "Filament";
|
||||||
|
return `<li class="queue-item">
|
||||||
|
<div class="queue-row"><span class="grow"><span class="queue-name">${escapeHtml(label)}</span></span>
|
||||||
|
<span class="queue-sub">${grams(item.shortfall_g)} fehlen</span></div>
|
||||||
|
<span class="queue-msg">${grams(item.weight_g)} Queue · ${grams(item.remaining_g)} Rest</span>
|
||||||
|
</li>`;
|
||||||
|
}).join("");
|
||||||
|
if (typeof window.onBedarfChanged === "function") window.onBedarfChanged(data);
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshTasks() {
|
async function refreshTasks() {
|
||||||
let items;
|
let items;
|
||||||
try { items = await api("/aufgaben"); } catch (error) { return; }
|
try { items = await api("/aufgaben"); } catch (error) { return; }
|
||||||
@@ -447,17 +541,45 @@ 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;
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
(function mobileMoreSheet() {
|
||||||
|
const sheet = document.getElementById("mobile-more-sheet");
|
||||||
|
const btn = document.getElementById("mobile-more");
|
||||||
|
if (!sheet || !btn) return;
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
if (typeof sheet.showModal === "function") sheet.showModal();
|
||||||
|
});
|
||||||
|
// Modal schließt und blockiert sonst die ganze App (Chrome).
|
||||||
|
sheet.addEventListener("click", (event) => {
|
||||||
|
const link = event.target.closest("a[href]");
|
||||||
|
if (!link) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const href = link.getAttribute("href");
|
||||||
|
sheet.close();
|
||||||
|
if (href) location.href = href;
|
||||||
|
});
|
||||||
|
sheet.addEventListener("close", () => {
|
||||||
|
btn.setAttribute("aria-expanded", "false");
|
||||||
|
});
|
||||||
|
btn.addEventListener("click", () => btn.setAttribute("aria-expanded", "true"));
|
||||||
|
})();
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
navigator.serviceWorker.register("/static/sw.js", { updateViaCache: "none" }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById("events-clear").addEventListener("click", async (event) => {
|
document.getElementById("events-clear").addEventListener("click", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -468,15 +590,86 @@ document.getElementById("events-clear").addEventListener("click", async (event)
|
|||||||
|
|
||||||
window.refreshPrintQueue = refreshPrintQueue;
|
window.refreshPrintQueue = refreshPrintQueue;
|
||||||
window.refreshSendQueue = refreshSendQueue;
|
window.refreshSendQueue = refreshSendQueue;
|
||||||
|
window.refreshBedarf = refreshBedarf;
|
||||||
refreshSendQueue();
|
refreshSendQueue();
|
||||||
refreshPrintQueue();
|
refreshPrintQueue();
|
||||||
refreshEvents();
|
refreshEvents();
|
||||||
refreshTasks();
|
refreshTasks();
|
||||||
|
refreshBedarf();
|
||||||
window.refreshTasks = refreshTasks;
|
window.refreshTasks = refreshTasks;
|
||||||
setInterval(refreshTasks, 8000);
|
|
||||||
setInterval(refreshSendQueue, 2000);
|
(function liveUpdates() {
|
||||||
setInterval(refreshPrintQueue, 4000);
|
let streamLive = false;
|
||||||
setInterval(refreshEvents, 8000);
|
const sseKinds = {
|
||||||
|
queue: () => {
|
||||||
|
refreshPrintQueue();
|
||||||
|
refreshBedarf();
|
||||||
|
},
|
||||||
|
senden: () => refreshSendQueue(),
|
||||||
|
ereignis: () => refreshEvents(),
|
||||||
|
aufgaben: () => refreshTasks(),
|
||||||
|
drucker: () => {
|
||||||
|
refreshPrintQueue();
|
||||||
|
refreshTasks();
|
||||||
|
if (typeof window.onDruckerLive === "function") window.onDruckerLive();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function whenStreamDown(fn) {
|
||||||
|
return async () => {
|
||||||
|
if (streamLive) return;
|
||||||
|
await fn();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof EventSource !== "undefined") {
|
||||||
|
let es;
|
||||||
|
let retryMs = 2000;
|
||||||
|
const retryMax = 60000;
|
||||||
|
let hadStream = false;
|
||||||
|
let retryTimer = 0;
|
||||||
|
|
||||||
|
function connectStream() {
|
||||||
|
if (es) {
|
||||||
|
try { es.close(); } catch (e) {}
|
||||||
|
}
|
||||||
|
es = new EventSource("/api/v1/stream", { withCredentials: true });
|
||||||
|
es.addEventListener("open", () => {
|
||||||
|
streamLive = true;
|
||||||
|
retryMs = 2000;
|
||||||
|
if (hadStream) setConnectionBanner(true, "Live-Verbindung wieder da.");
|
||||||
|
hadStream = true;
|
||||||
|
});
|
||||||
|
es.addEventListener("error", () => {
|
||||||
|
streamLive = false;
|
||||||
|
try { es.close(); } catch (e) {}
|
||||||
|
setConnectionBanner(false, "Live-Verbindung unterbrochen — Fallback-Polling läuft.");
|
||||||
|
clearTimeout(retryTimer);
|
||||||
|
retryTimer = setTimeout(connectStream, retryMs);
|
||||||
|
retryMs = Math.min(retryMax, retryMs * 2);
|
||||||
|
});
|
||||||
|
Object.keys(sseKinds).forEach((kind) => {
|
||||||
|
es.addEventListener(kind, () => sseKinds[kind]());
|
||||||
|
});
|
||||||
|
es.addEventListener("ping", () => {});
|
||||||
|
}
|
||||||
|
connectStream();
|
||||||
|
window.addEventListener("pagehide", () => {
|
||||||
|
streamLive = false;
|
||||||
|
clearTimeout(retryTimer);
|
||||||
|
if (es) {
|
||||||
|
try { es.close(); } catch (e) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poller nur als Fallback, wenn SSE tot ist — sonst staut Chrome die Verbindungen.
|
||||||
|
createPoller(whenStreamDown(refreshPrintQueue), 8000).start();
|
||||||
|
createPoller(whenStreamDown(refreshSendQueue), 4000).start();
|
||||||
|
createPoller(whenStreamDown(refreshEvents), 12000).start();
|
||||||
|
createPoller(whenStreamDown(refreshTasks), 12000).start();
|
||||||
|
createPoller(whenStreamDown(refreshBedarf), 30000).start();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% block script %}{% endblock %}
|
{% block script %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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>
|
||||||
+157
-59
@@ -14,6 +14,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section class="card bedarf-card" id="bedarf-card" hidden>
|
||||||
|
<div class="lib-head">
|
||||||
|
<h2 id="bedarf">Bedarf aus Warteschlange</h2>
|
||||||
|
<span class="count" id="bedarf-short-count">0</span>
|
||||||
|
</div>
|
||||||
|
<p class="hint" id="bedarf-hint">Fehlmenge = Queue-Gewicht minus Spulenrest (gleiche Sorte/Farbe).</p>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Sorte</th><th>Farbe</th><th class="num">Queue</th><th class="num">Rest</th><th class="num">Fehlt</th><th class="num">Jobs</th></tr></thead>
|
||||||
|
<tbody id="bedarf-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="tabs mat-tabs" role="tablist">
|
<div class="tabs mat-tabs" role="tablist">
|
||||||
<button type="button" class="on" data-view="spools" role="tab"><svg class="ico"><use href="#i-spool"/></svg>Spulen</button>
|
<button type="button" class="on" data-view="spools" role="tab"><svg class="ico"><use href="#i-spool"/></svg>Spulen</button>
|
||||||
<button type="button" data-view="sorten" role="tab"><svg class="ico"><use href="#i-layers"/></svg>Sorten</button>
|
<button type="button" data-view="sorten" role="tab"><svg class="ico"><use href="#i-layers"/></svg>Sorten</button>
|
||||||
@@ -61,35 +75,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 +93,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-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="0.1" value="1000"></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-vol">Volumen mm³/s</label><input id="spool-vol" type="number" min="0.1" step="0.1" value="12"></div>
|
|
||||||
</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-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>
|
||||||
|
</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 +143,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 +450,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 +492,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 +538,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) : "";
|
||||||
@@ -678,17 +735,58 @@ function render() {
|
|||||||
renderSorten();
|
renderSorten();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderBedarf(data) {
|
||||||
|
const card = document.getElementById("bedarf-card");
|
||||||
|
const shortfall = (data && data.shortfall) || [];
|
||||||
|
const needed = (data && data.needed) || [];
|
||||||
|
if (!needed.length && !shortfall.length) {
|
||||||
|
card.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
card.hidden = false;
|
||||||
|
document.getElementById("bedarf-short-count").textContent = shortfall.length
|
||||||
|
? shortfall.length + " Fehl"
|
||||||
|
: "ok";
|
||||||
|
document.getElementById("bedarf-hint").textContent = shortfall.length
|
||||||
|
? shortfall.length + " Sorten reichen nicht für die wartende Queue."
|
||||||
|
: "Queue hat Bedarf — Spulenrest reicht.";
|
||||||
|
const rows = (shortfall.length ? shortfall : needed).map((item) => {
|
||||||
|
const missing = item.shortfall_g != null ? grams(item.shortfall_g) : "—";
|
||||||
|
const rest = item.remaining_g != null
|
||||||
|
? grams(item.remaining_g)
|
||||||
|
: "—";
|
||||||
|
return `<tr class="${item.shortfall_g > 0 ? "warn" : ""}">
|
||||||
|
<td>${escapeHtml(item.material_type || "—")}</td>
|
||||||
|
<td>${escapeHtml(item.color || "—")}</td>
|
||||||
|
<td class="num">${grams(item.weight_g)}</td>
|
||||||
|
<td class="num">${rest}</td>
|
||||||
|
<td class="num">${missing}</td>
|
||||||
|
<td class="num">${item.jobs || 0}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join("");
|
||||||
|
document.getElementById("bedarf-rows").innerHTML = rows
|
||||||
|
|| '<tr><td class="empty" colspan="6">Kein Queue-Bedarf.</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
[spools, printers, materials, palette] = await Promise.all([
|
const [spoolData, printerData, materialData, paletteData, bedarfData] = await Promise.all([
|
||||||
api("/spulen"),
|
api("/spulen"),
|
||||||
api("/drucker?live=0"),
|
api("/drucker?live=0"),
|
||||||
api("/materialien"),
|
api("/materialien"),
|
||||||
api("/farben"),
|
api("/farben"),
|
||||||
|
api("/bedarf").catch(() => ({ needed: [], shortfall: [] })),
|
||||||
]);
|
]);
|
||||||
|
spools = spoolData;
|
||||||
|
printers = printerData;
|
||||||
|
materials = materialData;
|
||||||
|
palette = paletteData;
|
||||||
render();
|
render();
|
||||||
renderPalette();
|
renderPalette();
|
||||||
|
renderBedarf(bedarfData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.onBedarfChanged = renderBedarf;
|
||||||
|
|
||||||
function setScanNote(text, ok) {
|
function setScanNote(text, ok) {
|
||||||
const note = document.getElementById("scan-note");
|
const note = document.getElementById("scan-note");
|
||||||
if (!text) {
|
if (!text) {
|
||||||
|
|||||||
@@ -110,6 +110,21 @@ function fillPrinters(rows) {
|
|||||||
if ([...select.options].some((o) => o.value === current)) select.value = current;
|
if ([...select.options].some((o) => o.value === current)) select.value = current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function barChart(items, valueKey, labelFn) {
|
||||||
|
const rows = (items || []).filter((item) => Number(item[valueKey]) > 0);
|
||||||
|
if (!rows.length) return '<p class="empty">Keine Werte fürs Diagramm.</p>';
|
||||||
|
const max = Math.max(...rows.map((item) => Number(item[valueKey]) || 0)) || 1;
|
||||||
|
return `<div class="bar-chart" role="img">${rows.map((item) => {
|
||||||
|
const value = Number(item[valueKey]) || 0;
|
||||||
|
const pct = Math.max(4, Math.round(100 * value / max));
|
||||||
|
return `<div class="bar-row">
|
||||||
|
<span class="bar-label">${escapeHtml(labelFn(item))}</span>
|
||||||
|
<span class="bar-track"><span class="bar-fill" style="width:${pct}%"></span></span>
|
||||||
|
<span class="bar-val">${escapeHtml(String(item._barLabel || value))}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join("")}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function render(data) {
|
function render(data) {
|
||||||
fillPrinters(data.by_printer);
|
fillPrinters(data.by_printer);
|
||||||
const o = data.overview || {};
|
const o = data.overview || {};
|
||||||
@@ -120,6 +135,16 @@ function render(data) {
|
|||||||
? (data.estimate_ratio * 100).toFixed(0) + " %"
|
? (data.estimate_ratio * 100).toFixed(0) + " %"
|
||||||
: "—";
|
: "—";
|
||||||
|
|
||||||
|
if (!jobs) {
|
||||||
|
document.getElementById("stat").innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<svg class="ico"><use href="#i-grid"/></svg>
|
||||||
|
<p>Keine Läufe im gewählten Zeitraum.</p>
|
||||||
|
<a class="ghost" href="/protokoll">Protokoll →</a>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const printers = (data.by_printer || []).map((p) => `
|
const printers = (data.by_printer || []).map((p) => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${escapeHtml(p.printer_name || p.printer_host)}</td>
|
<td>${escapeHtml(p.printer_name || p.printer_host)}</td>
|
||||||
@@ -130,6 +155,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>
|
||||||
@@ -139,6 +170,15 @@ function render(data) {
|
|||||||
<td class="num">${m.jobs}</td>
|
<td class="num">${m.jobs}</td>
|
||||||
</tr>`).join("") || '<tr><td class="empty" colspan="5">Kein Spulenabzug im Zeitraum.</td></tr>';
|
</tr>`).join("") || '<tr><td class="empty" colspan="5">Kein Spulenabzug im Zeitraum.</td></tr>';
|
||||||
|
|
||||||
|
const chartPrinters = (data.by_printer || []).map((p) => ({
|
||||||
|
...p,
|
||||||
|
_barLabel: hours(p.duration_s),
|
||||||
|
}));
|
||||||
|
const chartMaterials = (data.by_material || []).map((m) => ({
|
||||||
|
...m,
|
||||||
|
_barLabel: grams(m.weight_g),
|
||||||
|
}));
|
||||||
|
|
||||||
document.getElementById("stat").innerHTML = `
|
document.getElementById("stat").innerHTML = `
|
||||||
<div class="kpi-row">
|
<div class="kpi-row">
|
||||||
${kpi("Läufe", String(jobs))}
|
${kpi("Läufe", String(jobs))}
|
||||||
@@ -149,6 +189,16 @@ function render(data) {
|
|||||||
${kpi("Ausschuss", scrap + " %", (o.scrap || 0) + " mit Urteil Ausschuss")}
|
${kpi("Ausschuss", scrap + " %", (o.scrap || 0) + " mit Urteil Ausschuss")}
|
||||||
${kpi("Schätztreue", ratio, data.estimate_count ? data.estimate_count + " Läufe mit Soll-Zeit" : "keine Soll-Zeit")}
|
${kpi("Schätztreue", ratio, data.estimate_count ? data.estimate_count + " Läufe mit Soll-Zeit" : "keine Soll-Zeit")}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="split">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Druckzeit je Drucker</h2>
|
||||||
|
${barChart(chartPrinters, "duration_s", (p) => p.printer_name || p.printer_host || "—")}
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Verbrauch je Material</h2>
|
||||||
|
${barChart(chartMaterials, "weight_g", (m) => [m.material_type, m.brand].filter(Boolean).join(" · ") || "—")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Nach Drucker</h2>
|
<h2>Nach Drucker</h2>
|
||||||
<div class="table-wrap"><table>
|
<div class="table-wrap"><table>
|
||||||
@@ -156,6 +206,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>
|
||||||
@@ -209,6 +266,17 @@ document.getElementById("reload").addEventListener("click", () => {
|
|||||||
document.getElementById("host").addEventListener("change", () => {
|
document.getElementById("host").addEventListener("change", () => {
|
||||||
load().catch((error) => showNote("stat-note", error));
|
load().catch((error) => showNote("stat-note", error));
|
||||||
});
|
});
|
||||||
|
["material", "brand"].forEach((id) => {
|
||||||
|
const node = document.getElementById(id);
|
||||||
|
if (!node) return;
|
||||||
|
node.addEventListener("change", () => load().catch((error) => showNote("stat-note", error)));
|
||||||
|
node.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
load().catch((error) => showNote("stat-note", error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
load().catch((error) => showNote("stat-note", error));
|
load().catch((error) => showNote("stat-note", error));
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
+79
-5
@@ -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);
|
||||||
@@ -1266,6 +1337,7 @@ async function refresh() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.onQueueChanged = () => refresh().catch(() => {});
|
window.onQueueChanged = () => refresh().catch(() => {});
|
||||||
|
window.onDruckerLive = () => refresh().catch(() => {});
|
||||||
|
|
||||||
document.getElementById("hide-offline").addEventListener("change", (event) => {
|
document.getElementById("hide-offline").addEventListener("change", (event) => {
|
||||||
hideOffline = event.target.checked;
|
hideOffline = event.target.checked;
|
||||||
@@ -1491,19 +1563,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;
|
||||||
@@ -1554,7 +1628,7 @@ deskEl.addEventListener("keydown", async (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
refresh().catch((error) => showNote("note", error));
|
refresh().catch((error) => showNote("note", error));
|
||||||
setInterval(() => refresh().catch((error) => showNote("note", error)), 5000);
|
createPoller(() => refresh().catch((error) => showNote("note", error)), 12000).start();
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
api("/dateien").then((rows) => { libraryFiles = rows || []; }).catch(() => {});
|
api("/dateien").then((rows) => { libraryFiles = rows || []; }).catch(() => {});
|
||||||
api("/spulen").then((rows) => { wallSpools = rows || []; }).catch(() => {});
|
api("/spulen").then((rows) => { wallSpools = rows || []; }).catch(() => {});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<p class="tiny">Wartende und laufende Aufträge. Direktstarts erscheinen unter Läuft. Priorität mit den Pfeilen ändern.</p>
|
<p class="tiny">Wartende und laufende Aufträge. Direktstarts erscheinen unter Läuft. Priorität mit den Pfeilen ändern.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-head-side">
|
<div class="page-head-side">
|
||||||
|
<a class="ghost" id="q-bedarf" href="/material#bedarf" hidden></a>
|
||||||
<button type="button" class="ghost" id="q-refresh"><svg class="ico"><use href="#i-refresh"/></svg>Neu laden</button>
|
<button type="button" class="ghost" id="q-refresh"><svg class="ico"><use href="#i-refresh"/></svg>Neu laden</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -212,7 +213,18 @@ document.getElementById("q-rows").addEventListener("click", async (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
window.onQueueChanged = load;
|
window.onQueueChanged = load;
|
||||||
|
window.onBedarfChanged = (data) => {
|
||||||
|
const link = document.getElementById("q-bedarf");
|
||||||
|
if (!link) return;
|
||||||
|
const n = (data.shortfall || []).length;
|
||||||
|
if (!n) {
|
||||||
|
link.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
link.hidden = false;
|
||||||
|
link.textContent = n + " Fehlmenge" + (n === 1 ? "" : "n");
|
||||||
|
};
|
||||||
load();
|
load();
|
||||||
setInterval(load, 5000);
|
if (typeof refreshBedarf === "function") refreshBedarf();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
{% 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 class="page-head-side">
|
||||||
|
<button type="button" class="ghost" id="wartung-refresh"><svg class="ico"><use href="#i-refresh"/></svg>Neu laden</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="wartung-summary" class="kpi-row" hidden></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 tiny-btn" data-reset="${part.id}">Zurücksetzen</button></td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printerCard(block) {
|
||||||
|
const c = block.counters || {};
|
||||||
|
const overdue = (block.parts || []).filter((p) => p.overdue_hours || p.overdue_meters).length;
|
||||||
|
const parts = (block.parts || []).map(partRow).join("")
|
||||||
|
|| '<tr><td colspan="6" class="empty">Noch keine Teile — unten anlegen.</td></tr>';
|
||||||
|
return `<section class="card wartung-card">
|
||||||
|
<div class="lib-head">
|
||||||
|
<h2>${escapeHtml(block.name || block.host)}</h2>
|
||||||
|
${overdue ? chipHtml("error", overdue + " fällig") : chipHtml("idle", "ok")}
|
||||||
|
</div>
|
||||||
|
<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>Filament</th><th>Intervall h</th><th>Intervall mm</th><th></th></tr></thead>
|
||||||
|
<tbody>${parts}</tbody>
|
||||||
|
</table></div>
|
||||||
|
<details class="mat-more section-gap">
|
||||||
|
<summary><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Teil anlegen</span></summary>
|
||||||
|
<form class="grid-2 wartung-add" data-host="${escapeHtml(block.host)}">
|
||||||
|
<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>
|
||||||
|
</details>
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummary(data) {
|
||||||
|
const box = document.getElementById("wartung-summary");
|
||||||
|
if (!data.length) {
|
||||||
|
box.hidden = true;
|
||||||
|
box.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let hours = 0;
|
||||||
|
let meters = 0;
|
||||||
|
let overdue = 0;
|
||||||
|
data.forEach((block) => {
|
||||||
|
hours += Number((block.counters || {}).hours) || 0;
|
||||||
|
meters += Number((block.counters || {}).meters) || 0;
|
||||||
|
overdue += (block.parts || []).filter((p) => p.overdue_hours || p.overdue_meters).length;
|
||||||
|
});
|
||||||
|
box.hidden = false;
|
||||||
|
box.innerHTML = `
|
||||||
|
<div class="kpi"><div class="kpi-val">${data.length}</div><div class="kpi-label">Drucker</div></div>
|
||||||
|
<div class="kpi"><div class="kpi-val">${hours.toLocaleString("de-DE", { maximumFractionDigits: 0 })} h</div><div class="kpi-label">Laufzeit</div></div>
|
||||||
|
<div class="kpi"><div class="kpi-val">${Math.round(meters / 1000).toLocaleString("de-DE")} m</div><div class="kpi-label">Filament</div></div>
|
||||||
|
<div class="kpi"><div class="kpi-val">${overdue}</div><div class="kpi-label">Fällig</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const data = await api("/wartung");
|
||||||
|
renderSummary(data || []);
|
||||||
|
document.getElementById("wartung-root").innerHTML = (data || []).map(printerCard).join("")
|
||||||
|
|| `<div class="empty-state">
|
||||||
|
<svg class="ico"><use href="#i-nozzle"/></svg>
|
||||||
|
<p>Keine Drucker angelegt.</p>
|
||||||
|
<a class="ghost" href="/einstellungen">Einstellungen →</a>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("wartung-refresh")?.addEventListener("click", () => {
|
||||||
|
load().catch((error) => showNote("wartung-note", error));
|
||||||
|
});
|
||||||
|
|
||||||
|
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.");
|
||||||
|
toast("Wartungsteil angelegt.");
|
||||||
|
load();
|
||||||
|
} catch (error) {
|
||||||
|
showNote("wartung-note", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("wartung-root").addEventListener("click", (event) => {
|
||||||
|
const btn = event.target.closest("[data-reset]");
|
||||||
|
if (!btn) return;
|
||||||
|
armButton(btn, "Wirklich zurücksetzen?", async () => {
|
||||||
|
try {
|
||||||
|
await api("/wartung/teile/" + btn.dataset.reset + "/reset", { method: "POST", json: {} });
|
||||||
|
showNote("wartung-note", null, "Teil zurückgesetzt.");
|
||||||
|
toast("Zähler zurückgesetzt.");
|
||||||
|
load();
|
||||||
|
} catch (error) {
|
||||||
|
showNote("wartung-note", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
load().catch((error) => showNote("wartung-note", error));
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in new issue
Block a user