Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5081212212 | ||
|
|
2b1e36634a | ||
|
|
d1eb87ba25 | ||
|
|
5166ed6530 | ||
|
|
f034c5e626 | ||
|
|
f1f79c2ce2 | ||
|
|
4ffe1dcfc6 |
No files matched your search
@@ -10,3 +10,8 @@ slice/*
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
_tmp_*
|
_tmp_*
|
||||||
|
%s
|
||||||
|
|
||||||
|
# gebaute .deb (Skript legt sie hier ab)
|
||||||
|
packaging/dist/*
|
||||||
|
!packaging/dist/.gitkeep
|
||||||
@@ -4,6 +4,99 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [3.6.1] – 2026-11-01
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **Abbruch fehlt nach Neustart:** Der History-Abgleich hat den Live-Lauf übernommen und gelöscht, das Ereignis „Druck abgebrochen“ zeigte noch auf diese Zeile. Der Fremdschlüssel hat den ganzen Watcher-Tick zurückgerollt, der Abbruch kam nie ins Protokoll, und der neue Druck hat dieselbe Zeile weiterbenutzt. Das Ereignis hängt sich an den überlebenden Lauf. Wechselt die Datei, wird der offene Lauf geschlossen statt umbenannt. Ein Live-Lauf einer anderen Datei wird nicht mehr übernommen.
|
||||||
|
- **Spulenwechsel:** Nach Filamentpause kein vorzeitiger Spulenabzug mehr (`watcher_resume`). Beim Zurücknehmen eines Abzugs wird der letzte Abschnitt wieder geöffnet; der Messwert wird anteilig nach mm verteilt. Beim Einlegen während des Drucks entsteht der erste Abschnitt auch dann (0→jetzt), wenn die alte Rolle schon im Regal ist oder ACE ohne Start-Abschnitt lief. Fallback slot-genau (Platz / ACE-Map / letzter Abschnitt). ACE-Start öffnet Abschnitte für gemappte Plätze mit Spule — ohne Wechsel bleibt Anteil-Split.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- **systemd:** `TimeoutStopSec=20` und `KillMode=mixed`, damit ein hängender Stopp nicht 90 s blockiert.
|
||||||
|
- **Orca:** Anleitung und Empfehlung zeigen auf Grotti am Pi (`http://10.10.1.41:8770/`), nicht mehr lokal.
|
||||||
|
- **Dateien:** Unveränderte Watch-Dateien werden beim Sync nur per Größe/mtime geprüft, nicht erneut geöffnet. `ace_trailer` liegt in der Datenbank; die Dateiliste öffnet die Datei nur noch einmal, wenn der Wert fehlt. Wand zeigt Drucker sofort, die Bibliothek lädt im Hintergrund. Pi-Paket: `http_threads` 8.
|
||||||
|
- **3D-Pfad:** Ungespeicherte Bahnen werden nacheinander gebaut, nicht parallel. `http_threads` begrenzt die Request-Threads. Sonst liest die Vorschau mehrere G-codes gleichzeitig und füllt auf einem Pi mit 2 GB den Speicher.
|
||||||
|
- **UI:** Wärmeres Orange-Licht (App und Login), Warten-Status in Amber statt Knopf-Orange, Druckerwand mit heller Kartenkante, größerer Prozentzahl und atmendem Druck-Streifen.
|
||||||
|
|
||||||
|
## [3.6.0] – 2026-10-25
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- **Projekte:** Kennzahlen (Läufe, Zeit, Filament, Kosten), Tab Läufe, Einreihen / Alle einreihen, Filter Aktiv/Fertig, Deep-Link `#id`. API: `GET …/laeufe`, `POST …/einreihen`.
|
||||||
|
- **Wand-Cockpit:** Block „Als Nächstes“ — Nochmal, Queue-Hinweis, passende Dateien (Start oder Anpassen unter Dateien).
|
||||||
|
- **Querverlinkung:** Datei/Projekt/Spule/Auftrag/Drucker verlinkt in Protokoll, Bericht, Warteschlange, Aufträge, Material, Statistik (Projektfilter), Dateien.
|
||||||
|
- **API für Apps:** `POST /auth/token`, QR bei Token-Erzeugung, Server-Filter für Dateien/Queue/Spulen (`limit`/`offset`, `X-Total-Count`), `?token=` für Datei-Vorschau und Assets, `/version` mit `api`/`features`, Aktions-Alias `resume`/`cancel`. Doku: `docs/internal/API.md`, `openapi.yaml`.
|
||||||
|
- **Pi:** Umzugsanleitung Windows→Pi, Skript `scripts/pfade_umziehen.py` im `.deb`.
|
||||||
|
- **Protokoll Verbinden:** Verdachtspaare nach Runout manuell zusammenlegen (`POST /laeufe/<id>/verbinden`). Liste zeigt Rollenwechsel, mehrere Spulen und Pausendauer.
|
||||||
|
- **Reparatur:** `scripts/laeufe_reparieren.py` findet Alt-Paare und verwaiste Live-Läufe (Probelauf standardmäßig). Orphan-Übernahme nur bei gleicher Datei und wenn der Live-Lauf wirklich verschwindet; kein Host-Fallback auf fremde offene Drucke.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- **Projekte / Aufträge:** Liste zuerst (volle Breite); Formular und Detail nur bei Neu, Auswahl oder Bearbeiten — wie Material. Aufträge: Positionen ohne `prompt()`, Kopf-Button „Neuer Auftrag“.
|
||||||
|
- **Layout:** `.edit-split.is-idle`, Split/Panel ab 1080px einspaltig; Pfad-Ellipsis in Tabellen; Protokoll-Hilfe einklappbar; Queue-Aktionen mit Textlabels; Jetzt-Seite zeigt „Alles erledigt“; Aktivität Drucker-Filter als Select; Statistik-Filter unter „Weitere Filter“; Nav Aufträge = Clipboard-Icon; Mobile-Mehr gruppiert.
|
||||||
|
- **Material-Routing:** PLA ist PLA. Meta, Matte, Silk und die Marke halten die Queue nicht mehr an; liegt die genannte Sorte am Drucker, wird sie weiter bevorzugt. Ein anderer Typ (PETG statt PLA) bleibt ein Wartegrund.
|
||||||
|
- **mcp** auf `2.0.0` gepinnt.
|
||||||
|
- **Lauf-Identität:** Zusammenlegen nur mit Beweis (`print_duration` fortgesetzt + Lücke ≤ 5 s, oder Watcher „druckt weiter“). Leere History-Hüllen werden nicht gespeichert. `sync_history` unter Advisory-Lock; Abzug atomar. Doku: `docs/internal/Lauf-Identitaet.md`.
|
||||||
|
- **Queue `print_active`:** Nur `complete` = fertig. `cancelled`/`standby` werden entprellt; echter Abbruch heißt „Druck abgebrochen“, nicht „Fertig.“. Kein Auto-Retry nach Abbruch. Worker respektiert Abbruch während Upload. `print_queue.run_id` + höchstens ein `running` pro Host.
|
||||||
|
- **Wand Runout:** Hinweis nennt ACE-Platz und verlinkt Material mit vorausgefülltem Drucker/Platz.
|
||||||
|
- **Live-UI:** SSE `queue`/`drucker` aktualisiert Warteschlange und Protokoll.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **Legacy-Kameras-Seite:** totes Template entfernt (Route leitete schon auf die Wand um).
|
||||||
|
- **Laufende Charge bleibt am History-Job:** Kommt die Moonraker-Job-ID, übernimmt dieser Lauf die Nummer. Sie fällt nicht weg, nur weil der Druck noch läuft.
|
||||||
|
- **Alte History-Jobs verbrauchen die Charge von heute:** Eine Filament-Pause öffnet eine zweite Job-ID. Bleibt die ohne Ende und ohne Dauer in der History stehen, hing Grotti sie als neuen Druck an den heutigen Tag und buchte das Filament noch einmal ab. Die Job-ID bleibt am ursprünglichen Lauf, eine leere Hülle überschreibt ihn nicht, und die Charge trägt das Startdatum des Drucks.
|
||||||
|
- **Kontaktverlust spaltet den Druck:** Zwei fehlende Polls melden den Drucker offline, lassen den Lauf aber offen und ziehen nichts ab. Dieselbe Charge läuft weiter, wenn derselbe Auftrag wieder da ist. Erst fertig, Abbruch, Fehler oder ein anderer Auftrag schließt den Lauf.
|
||||||
|
- **Queue bleibt auf „Druckstart kann eine Minute dauern“, während der Drucker längst druckt:** Der Abschluss hat `await_print` erst geprüft, wenn der Drucker wieder frei war, und den laufenden Druck dann als Start-Timeout gewertet.
|
||||||
|
- **Strombericht ohne Düse/Bett nach Pause:** Beim Zusammenlegen zweier History-Jobs bleiben Temperatur- und ED-Proben am Lauf. Vorher wurden die Proben des Folgejobs gelöscht, der Bericht fiel auf die Grundlast zurück.
|
||||||
|
- **Filament-Pause spaltet das Protokoll:** Setzt Anycubic nach dem Fortsetzen eine neue History-Job-ID, bleibt es ein Lauf. Der Abbruch-Teil wird nicht noch einmal als ganzer Druck abgezogen.
|
||||||
|
- **Materialwechsel öffnet trotzdem einen zweiten Auftrag:** Moonraker liefert die History neueste zuerst. Die Fortsetzung kam vor dem abgebrochenen Teil an die Reihe, fand keinen Vorgänger und wurde ein eigener Lauf; der alte Teil wurde als Abbruch abgezogen. Der Abgleich geht jetzt pro Drucker von alt nach neu. Eine eingeklappte Job-ID setzt den laufenden Druck nicht mehr auf „Abgebrochen“ zurück, und der zusammengelegte Lauf hat kein Ende, solange er druckt.
|
||||||
|
- **Fertig vor History bucht doppelt:** Live-Lauf wird geschlossen ohne Abzug; History bucht. Fallback nach 10 min ohne History.
|
||||||
|
- **Abbruch + schnelles Nochmal:** Frischer Neustart (Dauer nahe 0) wird nicht mehr als Filament-Fortsetzung zusammengelegt.
|
||||||
|
- **Alter Lauf auf der neuen Spule:** Der History-Abgleich zieht einen Druck nur von Rollen ab, die vor dem Druckende schon existierten. Die Rolle, die gerade im ACE liegt, bekommt keine alten Jobs.
|
||||||
|
- **Projektfilter leer neben „Wurzel“:** Ein gewähltes Projekt hebt den Ordnerfilter „Wurzel“ auf. Ein Ordner zeigt auch die Dateien in seinen Unterordnern.
|
||||||
|
|
||||||
|
## [3.5.2] – 2026-10-21
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- **Wenig Rest:** ACE oder Spule unter dem Dateigewicht warnt unter Dateien, bricht Start und Warteschlange nicht mehr ab. Falsche Sorte bleibt ein Wartegrund.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **Queue „Nochmal — Start abgebrochen (Neustart)“ während der Drucker anläuft:** Nach dem Upload bleibt der Auftrag in `await_print`, bis Moonraker druckt. Die 45-Sekunden-Regel gilt nur noch, wenn der Upload-Thread weg ist. Wand und Seitenleiste zeigen „startet“ und dass es etwa eine Minute dauern kann.
|
||||||
|
|
||||||
|
## [3.5.1] – 2026-10-20
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- **MCP:** Server-Anweisung für Clients (Ablauf, confirm, Filament-Pause, Platten-Quittung, kein G-code), damit der Agent die Tools in der richtigen Reihenfolge nutzt.
|
||||||
|
- **Wand:** „Aus der Warteschlange“-Strip nur noch bei Ablauf (mit Schritten); einfacher Queue-Druck zeigt nur DRUCKT.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **Queue zu früh „Fertig.“:** Nach Start bleibt Phase `starting`, bis Moonraker `printing` meldet — alter `complete`/`standby` schließt den Job nicht mehr.
|
||||||
|
|
||||||
|
## [3.5.0] – 2026-10-19
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- **Doku:** interne Makro-Referenz `docs/internal/Makros-Kobra.md` (Nicht-tun, Vanilla-Klipper-Alltag, ACE, LeviQ/Wipe für S1 und K3).
|
||||||
|
- **Pagination:** Dateien, Protokoll, Material, Aufträge, Warteschlange, Aktivität, Projekte — max. 40 Zeilen pro Seite (Zurück/Weiter).
|
||||||
|
- **Logging:** `log_level` in der Config; Kernpfade schreiben nach stdout (unter systemd → `journalctl -u grotti`).
|
||||||
|
- **Debian-Paket:** `packaging/build-deb.sh` baut `.deb` mit venv, systemd-Unit und PostgreSQL-`postinst` (Peer-Auth). Anleitung: `docs/Anleitung-Installation-Pi.md`.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- **Config:** `GROTTI_CONFIG` bzw. `/etc/grotti/config.json` für Paket-Install; sonst Repo-`config.json`.
|
||||||
|
- **README:** Banner, Badges, Feature-Übersicht und Community-Screenshots oben; Setup/API darunter; Pi-`.deb`-Hinweis.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **Queue „braucht Eryone PLA Lite“ trotz PLA-Spule:** Basis-Profil (ohne Meta/Matte/Silk) matcht nach Typ; Slice-Hex-Farbe (`#26A69A`) blockiert Lagerfarbe „Grau“ nicht mehr.
|
||||||
|
- **Dateien Info-Popup:** Details-Popup wird nicht mehr vom Bibliothek-Scroll abgeschnitten; Position fixed mit Flip nach oben, besserer Kontrast.
|
||||||
|
|
||||||
## [3.4.0] – 2026-10-18
|
## [3.4.0] – 2026-10-18
|
||||||
|
|
||||||
### Neu
|
### Neu
|
||||||
@@ -12,6 +105,7 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
|
|||||||
|
|
||||||
### Behoben
|
### Behoben
|
||||||
|
|
||||||
|
- **Queue „druckt schon“ bei Folgejobs:** Einfach-Druck bleibt `running` bis Moonraker fertig — nicht mehr sofort `done` nach Start (sonst startete der nächste Job zu früh).
|
||||||
- **Bedarf „443 g fehlen“ trotz voller Spulen:** Slice-Farbe `#26A69A` matchte nicht Lagerfarbe „Grau“ → Rest 0. Bei Hex-Farbe zählt jetzt der Rest gleicher Sorte; archivierte Spulen nicht.
|
- **Bedarf „443 g fehlen“ trotz voller Spulen:** Slice-Farbe `#26A69A` matchte nicht Lagerfarbe „Grau“ → Rest 0. Bei Hex-Farbe zählt jetzt der Rest gleicher Sorte; archivierte Spulen nicht.
|
||||||
- **Falsches „Material nachlegen“ bei manueller Pause:** Anycubic meldet `active_filament.empty=true` auch ohne Runout (`tool`/`gate` = −1). Heuristik nur noch bei Meldung/`reason_for_pause`/10107, aktivem Tool + differenziertem `gate_status`, oder G-code-Store.
|
- **Falsches „Material nachlegen“ bei manueller Pause:** Anycubic meldet `active_filament.empty=true` auch ohne Runout (`tool`/`gate` = −1). Heuristik nur noch bei Meldung/`reason_for_pause`/10107, aktivem Tool + differenziertem `gate_status`, oder G-code-Store.
|
||||||
- **gklib 10111 nach Einfarbig-Upload:** Orca-Zeile `; filament_colour_type` wird beim Senden entfernt (nur Einfarbig; Multicolor unangetastet).
|
- **gklib 10111 nach Einfarbig-Upload:** Orca-Zeile `; filament_colour_type` wird beim Senden entfernt (nur Einfarbig; Multicolor unangetastet).
|
||||||
|
|||||||
@@ -1,120 +1,156 @@
|
|||||||
# Grotti
|
<p align="center">
|
||||||
|
<img src="docs/brand/readme-banner.png" alt="Grotti" width="720">
|
||||||
|
</p>
|
||||||
|
|
||||||
Steuert die 3D-Drucker im Haus von einer Weboberfläche aus: Live-Zustand, Dateien senden, Warteschlange, Abläufe für den Kobra 3 (drucken, abkühlen, abschieben, prüfen), Material, Projekte und ein Protokoll, was wann passiert ist.
|
<p align="center">
|
||||||
|
<strong>Farm-Manager für Moonraker-Drucker</strong><br>
|
||||||
|
Eine Oberfläche für die ganze Farm: Wand, Warteschlange, Material, Abläufe und Protokoll.<br>
|
||||||
|
<em>Benannt nach der Mühle Grotti — sie mahlt ohne Pause, was man ihr aufträgt.</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
Der Name kommt von der Mühle Grotti: sie mahlt ohne Pause, was man ihr aufträgt.
|
<p align="center">
|
||||||
|
<a href="https://git.gundel.dev/dg/grotti"><img src="https://img.shields.io/badge/Version-3.6.1-e85d04?style=flat-square" alt="Version 3.6.1"></a>
|
||||||
|
<img src="https://img.shields.io/badge/Python-3.11+-3776AB?style=flat-square&logo=python&logoColor=white" alt="Python 3.11+">
|
||||||
|
<img src="https://img.shields.io/badge/PostgreSQL-14+-4169E1?style=flat-square&logo=postgresql&logoColor=white" alt="PostgreSQL">
|
||||||
|
<img src="https://img.shields.io/badge/Moonraker-Klipper-2ecc71?style=flat-square" alt="Moonraker">
|
||||||
|
<img src="https://img.shields.io/badge/UI-Deutsch-111827?style=flat-square" alt="UI Deutsch">
|
||||||
|
<img src="https://img.shields.io/badge/Self--hosted-lokal-6b7280?style=flat-square" alt="Self-hosted">
|
||||||
|
</p>
|
||||||
|
|
||||||
Die Drucker laufen mit Rinkhals (Kobra 3, Kobra S1). Grotti spricht sie über Moonraker an.
|
<p align="center">
|
||||||
|
<a href="#features">Features</a> ·
|
||||||
|
<a href="#screenshots">Screenshots</a> ·
|
||||||
|
<a href="#schnellstart">Schnellstart</a> ·
|
||||||
|
<a href="#api">API</a> ·
|
||||||
|
<a href="CHANGELOG.md">Changelog</a> ·
|
||||||
|
<a href="docs/community/README.md">Community</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
Aktuelle Version: siehe `grotti/version.py`, Änderungen in [CHANGELOG.md](CHANGELOG.md).
|
---
|
||||||
|
|
||||||
## Voraussetzungen
|
<p align="center">
|
||||||
|
<img src="docs/community/01-druckerwand.png" alt="Druckerwand" width="900">
|
||||||
|
</p>
|
||||||
|
|
||||||
- Python 3.11
|
## Features
|
||||||
- PostgreSQL (eine leere Datenbank `grotti` reicht, das Schema legt Grotti selbst an)
|
|
||||||
- Drucker im selben Netz mit Moonraker auf Port 80
|
|
||||||
|
|
||||||
## Einrichten
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| **Druckerwand & Cockpit** | Live-Status, Temps, Pause/Cancel, Farm-ETA |
|
||||||
|
| **Warteschlange** | Priorität, „nächster freier“, Material-Match, ACE-Map |
|
||||||
|
| **Dateien & Projekte** | Slice-Watch, Senden, Umbenennen, Projektordner |
|
||||||
|
| **Kobra-3-Ablauf** | Druck → Abkühlen → Schieben → Tasten |
|
||||||
|
| **Material** | Spulen, Restgewicht, Bedarf, Chargen |
|
||||||
|
| **Aufträge & Statistik** | Kunden, Lieferschein, Kosten (€ Material+Strom) |
|
||||||
|
| **Betrieb** | Auth (Session/`grt_…`), Telegram/ntfy, PWA |
|
||||||
|
|
||||||
|
Stack: **Python · Flask · PostgreSQL · Moonraker** (Rinkhals auf Kobra 3 / S1).
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/community/06-jetzt.png" alt="Jetzt-Liste" width="440">
|
||||||
|
|
||||||
|
<img src="docs/community/02-warteschlange.png" alt="Warteschlange" width="440">
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/community/09-dateien.png" alt="Dateien" width="440">
|
||||||
|
|
||||||
|
<img src="docs/community/03-material.png" alt="Material" width="440">
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/community/05-statistik.png" alt="Statistik" width="440">
|
||||||
|
|
||||||
|
<img src="docs/community/07-cockpit.png" alt="Cockpit" width="440">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Weitere Bilder und Post-Text: [`docs/community/`](docs/community/README.md).
|
||||||
|
|
||||||
|
## Schnellstart
|
||||||
|
|
||||||
|
**Voraussetzungen:** Python 3.11, PostgreSQL (leere DB `grotti`), Drucker mit Moonraker im LAN.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd D:\Projekte\Grotti
|
cd D:\Projekte\Grotti
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
.\.venv\Scripts\pip install -r requirements.txt
|
.\.venv\Scripts\pip install -r requirements.txt
|
||||||
copy config.example.json config.json
|
copy config.example.json config.json
|
||||||
```
|
|
||||||
|
|
||||||
`config.json` anpassen:
|
|
||||||
|
|
||||||
| Schlüssel | Bedeutung |
|
|
||||||
| --- | --- |
|
|
||||||
| `host`, `port` | Wo die Oberfläche lauscht. Standard `127.0.0.1:8770`. |
|
|
||||||
| `database_url` | PostgreSQL-Verbindung. |
|
|
||||||
| `data_dir` | Uploads, Vorschaubilder, Cache. Standard `data`. |
|
|
||||||
| `watch_dir` | Slice-Ordner (unter Einstellungen setzen). Leer = nur Upload. |
|
|
||||||
| `token` | Wenn gesetzt, braucht die API `Authorization: Bearer <token>`. |
|
|
||||||
| `fqdn` | Öffentliche Basis-URL für Spulen-Etiketten-QR. Leer = Request-Host. |
|
|
||||||
| `energy` | Watt und Strompreis für die Kosten im Protokoll. |
|
|
||||||
| `telegram`, `alerts` | Bot und welche Ereignisse als Nachricht kommen. |
|
|
||||||
| `auto_queue` | Wenn `false`, startet die Warteschlange nichts Neues. |
|
|
||||||
|
|
||||||
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
|
|
||||||
[{ "name": "Kobra 3 da26", "host": "10.10.1.60" }]
|
|
||||||
```
|
|
||||||
|
|
||||||
Die Familie (Kobra 3 oder S1) liest Grotti aus dem Namen.
|
|
||||||
|
|
||||||
## Starten
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\.venv\Scripts\python.exe server.py
|
.\.venv\Scripts\python.exe server.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Dann <http://127.0.0.1:8770/> öffnen. Bedienungsanleitung mit Screenshots in der Oberfläche unter **Doku** (Kapitel `Anleitung-*`), Technik-Notizen darunter.
|
Dann <http://127.0.0.1:8770/>. Bedienung unter **Doku** in der App (`docs/Anleitung-*.md`).
|
||||||
|
|
||||||
`config.json` und `data/` sind lokal und nicht im Git.
|
### Raspberry Pi (`.deb`)
|
||||||
|
|
||||||
|
Auf dem Pi (arm64 / Bookworm) bauen und installieren — PostgreSQL und systemd-Dienst werden mit eingerichtet:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash packaging/build-deb.sh
|
||||||
|
sudo apt install ./packaging/dist/grotti_*_arm64.deb
|
||||||
|
journalctl -u grotti -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Details: [docs/Anleitung-Installation-Pi.md](docs/Anleitung-Installation-Pi.md). Öffentliches apt-Repo folgt später.
|
||||||
|
|
||||||
|
`config.json` und `data/` bleiben lokal (nicht im Git). Drucker zuerst in `drucker.json`, danach unter Einstellungen.
|
||||||
|
|
||||||
|
| Schlüssel | Bedeutung |
|
||||||
|
| --- | --- |
|
||||||
|
| `host`, `port` | Oberfläche (Standard `127.0.0.1:8770`) |
|
||||||
|
| `database_url` | PostgreSQL |
|
||||||
|
| `watch_dir` | Slice-Ordner (oder leer = nur Upload) |
|
||||||
|
| `energy` / `telegram` / `alerts` | Kosten und Benachrichtigungen |
|
||||||
|
|
||||||
|
Schema-Migrationen laufen beim Start (`grotti/migrations/`). Vor Major-Updates: `pg_dump`.
|
||||||
|
|
||||||
## Ordner
|
## Ordner
|
||||||
|
|
||||||
| Pfad | Inhalt |
|
| Pfad | Inhalt |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `server.py` | Start, Datenbank vorbereiten, Hintergrund-Threads |
|
| `server.py` | Start, DB, Hintergrund-Threads |
|
||||||
| `grotti/` | Kern: Moonraker, Warteschlange (`print_queue.py`), Senden (`send_queue.py`), Abräumen (`eject.py`), Zustandswächter (`watcher.py`), Ereignisse und Telegram (`notify.py`), Datenbank (`store.py`, `schema.sql`) |
|
| `grotti/` | Moonraker, Queue, Senden, Abräumen, Watcher, Store |
|
||||||
| `web/` | Flask-App (`app.py`), Seiten (`templates/`), CSS und JS (`static/`) |
|
| `web/` | Flask, Templates, CSS/JS, Branding |
|
||||||
| `docs/` | Bedienungsanleitung (`Anleitung-*.md`) unter `/doku`; Technik unter `docs/internal/` (nicht in der App) |
|
| `docs/` | Anleitungen (`/doku`), Community-Screens, Technik unter `internal/` |
|
||||||
| `web/static/doku/` | Screenshots für die Anleitung |
|
| `CHANGELOG.md` | Versionen |
|
||||||
| `CHANGELOG.md` | Änderungen je Version |
|
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
Alles unter `/api/v1`, JSON. Auth: Session-Cookie (UI) oder `Authorization: Bearer grt_…` (API-Token unter Einstellungen). Optional noch Shared-`token` in der Config (Migrationsbrücke).
|
Alles unter `/api/v1`, JSON. Auth: Session oder `Authorization: Bearer grt_…` (kein CSRF). Referenz: [`docs/internal/API.md`](docs/internal/API.md), OpenAPI-Skizze [`docs/internal/openapi.yaml`](docs/internal/openapi.yaml).
|
||||||
|
|
||||||
| Methode und Pfad | Zweck |
|
| Methode und Pfad | Zweck |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `POST /auth/setup`, `/auth/login`, `/auth/logout` | Ersten Admin, Login, Logout |
|
| `POST /auth/setup`, `/auth/login`, `/auth/token`, `/auth/logout` | Admin, Session-Login, App-Token, 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, `api`, `features` |
|
||||||
| `GET`/`PATCH /einstellungen`, `POST /telegram/test` | Config und Telegram-Probe |
|
| `GET`/`PATCH /einstellungen`, `POST /telegram/test`, `/ntfy/test` | Config und Push-Probe |
|
||||||
| `GET /drucker?live=1` | Drucker mit Live-Zustand, laufendem und wartendem Auftrag |
|
| `GET /drucker?live=1` | Drucker mit Live-Zustand |
|
||||||
| `POST /drucker` · `PATCH`/`DELETE /drucker/<id>` | Drucker anlegen/ändern (Admin) |
|
| `POST /drucker/<host>/<aktion>` | `pause`, `fortsetzen`/`resume`, `abbrechen`/`cancel` |
|
||||||
| `POST /drucker/<host>/<aktion>` | Pause, Fortsetzen, Abbrechen, Fehler löschen |
|
| `POST /drucker/<host>/steuern` | Temperatur, Speed, Flow, Lüfter, ACE, `clear_error`, E-Stop |
|
||||||
| `POST /drucker/<host>/steuern` | Temperatur, Speed, Flow, Lüfter, ACE |
|
|
||||||
| `POST /drucker/<host>/abraeumen` | Platte geräumt (`confirm`) |
|
| `POST /drucker/<host>/abraeumen` | Platte geräumt (`confirm`) |
|
||||||
| `GET /kamera/<host>`, `/vorschau/<host>` | Snapshot / Job-Thumbnail |
|
| `GET`/`POST /dateien`, `PATCH`/`DELETE /dateien/<id>` | Bibliothek (Filter `family`, `material`, `project_id`, …) |
|
||||||
| `GET`/`POST /dateien`, `PATCH`/`DELETE /dateien/<id>` | Bibliothek, Upload, Metadaten |
|
| `POST /senden`, `GET`/`POST /druck-queue` | Senden und Warteschlange (`confirm`) |
|
||||||
| `POST /dateien/neu-lesen`, `/dateien/ordner`, `/dateien/<id>/verschieben` | Watch-Sync, Ordner, Verschieben |
|
| `GET`/`POST /projekte`, `…/einreihen`, Assets | Projekte |
|
||||||
| `POST /senden`, `GET /sende-auftraege` | Sofort senden (`confirm`), Fortschritt |
|
| `GET /spulen`, `POST /spulen/<id>/einlegen` | Material |
|
||||||
| `GET`/`POST /druck-queue`, `PATCH /druck-queue/<id>` | Warteschlange (`confirm` beim Einreihen) |
|
| `GET /laeufe`, `/statistik`, `/ereignisse`, `/stream` | Protokoll, Statistik, SSE |
|
||||||
| `GET /laeuft`, `/aufgaben` | Direktstarts und Platte-belegt |
|
|
||||||
| `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` / `GROTTI_TOKEN` → `docs/internal/MCP.md`.
|
||||||
|
|
||||||
## Sicherheit
|
## Sicherheit
|
||||||
|
|
||||||
- Login Pflicht (Setup legt den ersten Admin an). Rollen: Admin, Operator.
|
- Login Pflicht. Rollen: Admin, Operator.
|
||||||
- Druckstarts brauchen `confirm: true`. Die Oberfläche fragt per Zwei-Klick.
|
- Druckstarts brauchen `confirm: true` (Zwei-Klick in der UI).
|
||||||
- Keine freie G-code-Konsole; Firmware-Neustart über Grotti ist gesperrt.
|
- Keine freie G-code-Konsole; kein `FIRMWARE_RESTART` über Grotti.
|
||||||
- Für Internet: Reverse Proxy mit TLS; nicht den Entwickler-Server öffentlich freigeben.
|
- Nach außen: Reverse Proxy mit TLS.
|
||||||
|
|
||||||
## Versionen und Changelog
|
## Versionen
|
||||||
|
|
||||||
- Versionierung nach [SemVer](https://semver.org/lang/de/): Patch für Fehlerbehebung, Minor für neue Funktion, Major für Bruch an API oder Datenbankschema.
|
- SemVer in `grotti/version.py`, Einträge in [CHANGELOG.md](CHANGELOG.md).
|
||||||
- Jede Änderung kommt in `CHANGELOG.md` unter `[Unreleased]` (Neu, Geändert, Behoben).
|
- Release: `[Unreleased]` → `[x.y.z] – Datum`, Tag `vX.Y.Z`.
|
||||||
- Beim Release: Version in `grotti/version.py` setzen, `[Unreleased]` in `[x.y.z] – Datum` umbenennen, Git-Tag `vX.Y.Z`.
|
- CSS/JS-Cache: `?v=` in `layout.html` mitziehen.
|
||||||
- Ändert sich `web/static/grotti.css` oder `api.js`, in `layout.html` den `?v=` hochzählen.
|
|
||||||
|
|
||||||
## Repo
|
---
|
||||||
|
|
||||||
Eigenes Repository: <https://git.gundel.dev/dg/grotti>. Doku zum Tool gehört nach `docs/`.
|
<p align="center">
|
||||||
|
<img src="web/static/brand/logo.png" alt="" width="72"><br>
|
||||||
|
<sub>Repo: <a href="https://git.gundel.dev/dg/grotti">git.gundel.dev/dg/grotti</a></sub>
|
||||||
|
</p>
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
"token": "",
|
"token": "",
|
||||||
"secret_key": "",
|
"secret_key": "",
|
||||||
"fqdn": "",
|
"fqdn": "",
|
||||||
|
"log_level": "INFO",
|
||||||
"auto_queue": true,
|
"auto_queue": true,
|
||||||
"auto_retry": true,
|
"auto_retry": true,
|
||||||
"oauth": {
|
"oauth": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ACE und Spulen
|
# ACE und Spulen
|
||||||
|
|
||||||
Stand **18.10.2026**. Mehrfarb-Drucke brauchen vor dem Start eine Zuordnung Datei-Farbe → ACE-Platz und eine passende Spule am Drucker.
|
Stand **21.10.2026**. Mehrfarb-Drucke brauchen vor dem Start eine Zuordnung Datei-Farbe → ACE-Platz und eine passende Spule am Drucker.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ Es gibt **kein freies Spulen-Dropdown** beim Start: Grotti nimmt die Rolle(n), d
|
|||||||
|
|
||||||
Während Druck oder Pause die neue Spule unter Material dem Drucker zuweisen und speichern. Grotti schließt den Abschnitt der alten Rolle und öffnet einen neuen — im Druckbericht erscheinen dann zwei Spulenzeilen und Ereignisse. Nur Filament am Gerät laden ohne Zuweisung: Abzug und Protokoll bleiben bei der alten Rolle.
|
Während Druck oder Pause die neue Spule unter Material dem Drucker zuweisen und speichern. Grotti schließt den Abschnitt der alten Rolle und öffnet einen neuen — im Druckbericht erscheinen dann zwei Spulenzeilen und Ereignisse. Nur Filament am Gerät laden ohne Zuweisung: Abzug und Protokoll bleiben bei der alten Rolle.
|
||||||
|
|
||||||
Wenn Moonraker den Druck nach Filament-Pause als neuen History-Job führt, übernimmt Grotti die neu zugewiesene Rolle für den fortgesetzten Teil (Send- und Lauf-Spule werden beim Einlegen aktualisiert).
|
Wenn Moonraker den Druck nach Filament-Pause als neuen History-Job führt, übernimmt Grotti die neu zugewiesene Rolle für den fortgesetzten Teil (Send- und Lauf-Spule werden beim Einlegen aktualisiert). Die beiden Job-IDs bleiben **ein** Protokoll-Lauf, wenn die Druckdauer fortgesetzt wird oder die Wand „druckt weiter“ gesehen hat.
|
||||||
|
|
||||||
Etiketten mit QR: [Material und mehr](Anleitung-Material.md).
|
Etiketten mit QR: [Material und mehr](Anleitung-Material.md).
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ Unter **Dateien → Optionen → ACE-Zuordnung**:
|
|||||||
- Ohne passende Map schreibt der Drucker oft **„cannot parse the file“** und startet nicht
|
- Ohne passende Map schreibt der Drucker oft **„cannot parse the file“** und startet nicht
|
||||||
- Grotti legt die Sidecar `*.gcode.acm` an und schickt sie mit
|
- Grotti legt die Sidecar `*.gcode.acm` an und schickt sie mit
|
||||||
|
|
||||||
Bei **Nächster passender** (kein Drucker gewählt) baut Grotti die Map erst beim Claim aus den Spulen am gewählten Drucker. Passt die Sorte nicht → Wartegrund in der Queue.
|
Bei **Nächster passender** (kein Drucker gewählt) baut Grotti die Map erst beim Claim aus den Spulen am gewählten Drucker. Passt der Typ nicht (PETG statt PLA) → Wartegrund in der Queue. Meta/Matte/Silk halten nicht an; die genannte Sorte wird nur bevorzugt.
|
||||||
|
|
||||||
Externe Spule / keine ACE-Slots: keine `.acm` nötig.
|
Externe Spule / keine ACE-Slots: keine `.acm` nötig.
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ Nach einem Grotti-Update mit ACE-Farben: unter Dateien **neu lesen**, damit alte
|
|||||||
| „T… fehlt in der Map“ | Datei neu lesen, Zuordnung erneut setzen (Slicer-T ≠ ACE-Platz) |
|
| „T… fehlt in der Map“ | Datei neu lesen, Zuordnung erneut setzen (Slicer-T ≠ ACE-Platz) |
|
||||||
| **invalid filament id** (10011700) | Alte Grotti-Version ohne T-Verdichtung, oder Map nicht neu gesetzt. Grotti neu starten, Datei neu lesen, nochmal senden. |
|
| **invalid filament id** (10011700) | Alte Grotti-Version ohne T-Verdichtung, oder Map nicht neu gesetzt. Grotti neu starten, Datei neu lesen, nochmal senden. |
|
||||||
| Material-Warnung | Eingelegte Sorte ≠ Datei. Der Start geht trotzdem. PA12-CF steht in Grotti so, an den ACE geht PA6-CF. |
|
| Material-Warnung | Eingelegte Sorte ≠ Datei. Der Start geht trotzdem. PA12-CF steht in Grotti so, an den ACE geht PA6-CF. |
|
||||||
| Rest zu gering | Andere Spule oder leichteres Teil |
|
| Rest zu gering | Warnung unter Dateien („nur … g, Datei braucht … g“). Der Start geht trotzdem; leer pausiert der Drucker. |
|
||||||
| ACE blinkt nicht / kein Wechsel | Spule und Platz prüfen, ggf. Druck neu starten |
|
| ACE blinkt nicht / kein Wechsel | Spule und Platz prüfen, ggf. Druck neu starten |
|
||||||
| Drucker hängt kurz nach Multi-Color-Start | Oft falsche Map (T10 gemappt als Platz 0). Neu lesen + Zuordnung. |
|
| Drucker hängt kurz nach Multi-Color-Start | Oft falsche Map (T10 gemappt als Platz 0). Neu lesen + Zuordnung. |
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Projekte mit eigenem Ordner: [Projekte](Anleitung-Projekte.md).
|
|||||||
2. **Drucker** wählen — Kacheln zeigen Live-Zustand und eingelegte Rolle. Passt die Maschine nicht, ist die Kachel blass.
|
2. **Drucker** wählen — Kacheln zeigen Live-Zustand und eingelegte Rolle. Passt die Maschine nicht, ist die Kachel blass.
|
||||||
3. **Start:**
|
3. **Start:**
|
||||||
- **Dieser Drucker** — frei und Platte klar → sofort starten; sonst Warteschlange für diesen Drucker
|
- **Dieser Drucker** — frei und Platte klar → sofort starten; sonst Warteschlange für diesen Drucker
|
||||||
- **Nächster passender** — Grotti wählt den nächsten freien Drucker mit passender Sorte (z. B. Sunlu PLA Meta, nicht Matte)
|
- **Nächster passender** — Grotti wählt den nächsten freien Drucker mit passendem Typ (PLA, PETG, …). Die genannte Sorte (Meta, Matte) wird bevorzugt, wenn sie liegt; sonst reicht derselbe Typ.
|
||||||
|
|
||||||
Auswahl merkt sich der Browser (`localStorage`).
|
Auswahl merkt sich der Browser (`localStorage`).
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Einrichtung
|
# Einrichtung
|
||||||
|
|
||||||
Stand **15.10.2026**. Grotti auf dem Haus-PC installieren und den Browser öffnen. Die Drucker brauchen vorher Moonraker (Rinkhals auf Kobra 3 und S1).
|
Stand **24.10.2026**. Grotti auf dem Haus-PC oder Raspberry Pi installieren und den Browser öffnen. Die Drucker brauchen vorher Moonraker (Rinkhals auf Kobra 3 und S1).
|
||||||
|
|
||||||
## Was du brauchst
|
## Was du brauchst
|
||||||
|
|
||||||
@@ -56,8 +56,10 @@ Dann <http://127.0.0.1:8770/> öffnen. Beim ersten Start legst du den Admin-Benu
|
|||||||
|
|
||||||
- Druckstart über die API nur mit `confirm: true` (Dateien-UI: ein Klick).
|
- Druckstart über die API nur mit `confirm: true` (Dateien-UI: ein Klick).
|
||||||
- Keine freie G-code-Konsole; Firmware-Neustart über Grotti ist gesperrt.
|
- Keine freie G-code-Konsole; Firmware-Neustart über Grotti ist gesperrt.
|
||||||
- Benutzer und API-Tokens unter Einstellungen. Für Zugriff aus dem Internet: Reverse Proxy mit TLS, nicht den Entwickler-Server öffentlich freigeben.
|
- Benutzer und API-Tokens unter Einstellungen. App-Kopplung: Token erzeugen und QR scannen, oder `POST /api/v1/auth/token` mit Benutzer/Passwort. Push aufs Handy: **ntfy** (kein FCM). API-Referenz: intern `docs/internal/API.md`.
|
||||||
|
- Für Zugriff aus dem Internet: Reverse Proxy mit TLS, nicht den Entwickler-Server öffentlich freigeben.
|
||||||
- `config.json` und `data/` bleiben lokal, nicht im Git.
|
- `config.json` und `data/` bleiben lokal, nicht im Git.
|
||||||
|
- Raspberry Pi: [Installation auf dem Pi](Anleitung-Installation-Pi.md) (`.deb`, Umzug Windows→Pi).
|
||||||
|
|
||||||
## Weiter
|
## Weiter
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ Telegram: Bot-Token und Chat-ID, pro Ereignisart an/aus, Test-Knopf. **ntfy:** S
|
|||||||
|
|
||||||
## 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_…`). Ein Shared-`token` in der Config-Datei gilt nicht mehr.
|
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_…`), dazu ein QR mit Server-URL und Token zum Scannen. Alternativ stellt die App `POST /api/v1/auth/token` mit Benutzer/Passwort aus. 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`.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Installation auf dem Raspberry Pi
|
||||||
|
|
||||||
|
Stand **18.10.2026**. Phase 1: Installation per `.deb`-Datei. Ein öffentliches apt-Repository kommt später.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- Raspberry Pi OS **Bookworm** (64-Bit / arm64), Pi 4 oder 5 empfohlen
|
||||||
|
- Netz zu den Druckern (Moonraker/Rinkhals)
|
||||||
|
- ca. 1 GB freier Speicher für Paket + PostgreSQL
|
||||||
|
|
||||||
|
## Paket bauen (auf dem Pi oder arm64-Host)
|
||||||
|
|
||||||
|
Im geklonten Repo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y python3-venv rsync dpkg-dev
|
||||||
|
bash packaging/build-deb.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Ergebnis: `packaging/dist/grotti_<version>_arm64.deb`
|
||||||
|
|
||||||
|
## Installieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install ./packaging/dist/grotti_*_arm64.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
Das zieht **PostgreSQL** mit, legt Benutzer `grotti`, Datenbank `grotti` und den Dienst an.
|
||||||
|
|
||||||
|
- Oberfläche: `http://<pi-ip>:8770/`
|
||||||
|
- Config: `/etc/grotti/config.json` (wird bei Updates nicht überschrieben)
|
||||||
|
- Daten: `/var/lib/grotti/`
|
||||||
|
- Slice-Ordner: `/var/lib/grotti/slice` (unter Einstellungen änderbar)
|
||||||
|
|
||||||
|
Beim ersten Aufruf Admin-Konto anlegen (wie gewohnt).
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
Logs gehen nach **journald**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u grotti -f
|
||||||
|
journalctl -u grotti --since "1h ago"
|
||||||
|
journalctl -u grotti -n 200
|
||||||
|
```
|
||||||
|
|
||||||
|
Log-Level in `/etc/grotti/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"log_level": "INFO"
|
||||||
|
```
|
||||||
|
|
||||||
|
Werte: `DEBUG`, `INFO`, `WARNING`, `ERROR`. Danach:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart grotti
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dienst
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status grotti
|
||||||
|
sudo systemctl restart grotti
|
||||||
|
sudo systemctl stop grotti
|
||||||
|
```
|
||||||
|
|
||||||
|
## Update
|
||||||
|
|
||||||
|
Neue `.deb` bauen bzw. holen, dann erneut:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install ./packaging/dist/grotti_*_arm64.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
Config und `/var/lib/grotti` bleiben erhalten.
|
||||||
|
|
||||||
|
## Deinstallieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt remove grotti # Dienst weg, Daten bleiben
|
||||||
|
sudo apt purge grotti # zusätzlich /etc/grotti
|
||||||
|
# optional Datenbank:
|
||||||
|
# sudo -u postgres dropdb grotti
|
||||||
|
# sudo -u postgres dropuser grotti
|
||||||
|
# sudo rm -rf /var/lib/grotti
|
||||||
|
```
|
||||||
|
|
||||||
|
## Umzug Windows → Pi
|
||||||
|
|
||||||
|
1. **Backup auf dem alten Rechner** (Einstellungen → Backup) oder `pg_dump` der PostgreSQL-Datenbank.
|
||||||
|
2. Auf dem Pi Grotti per `.deb` installieren (oben).
|
||||||
|
3. Backup einspielen (Einstellungen → Backup wiederherstellen) **oder** `pg_restore` in die DB `grotti`.
|
||||||
|
4. Slice-Ordner nach `/var/lib/grotti/slice` kopieren (rsync/scp).
|
||||||
|
5. Absolute Windows-Pfade in der DB umschreiben (Dry-Run zuerst):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u grotti /opt/grotti/venv/bin/python /opt/grotti/scripts/pfade_umziehen.py \
|
||||||
|
--database-url 'postgresql:///grotti?host=/var/run/postgresql' \
|
||||||
|
--from 'D:/dein/slice' --to /var/lib/grotti/slice
|
||||||
|
# prüfen, dann mit --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
(Alternativ aus dem Repo-Clone mit derselben DB-URL.)
|
||||||
|
|
||||||
|
6. Unter Einstellungen: `watch_dir` = `/var/lib/grotti/slice`, ggf. `fqdn` auf die Pi-URL.
|
||||||
|
7. In Orca „Senden an Grotti“ auf `http://<pi-ip>:8770/` umstellen.
|
||||||
|
8. `sudo systemctl restart grotti` und unter **Dateien → Ordner neu lesen**.
|
||||||
|
|
||||||
|
## Last auf dem Pi
|
||||||
|
|
||||||
|
- Watcher und Toolpath-Cache liegen unter `/var/lib/grotti/`. Bei wenig Speicher Toolpath-Cache-Ordner (`data/toolpath`) gelegentlich leeren.
|
||||||
|
- Wenige SSE-Clients sind unkritisch; viele offene Browser-Tabs mit Kamera-Stream belasten CPU/Netz.
|
||||||
|
- Empfohlen: Pi 4/5 mit SSD oder schneller SD, nicht parallel schwere Builds auf demselben Gerät.
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
|
||||||
|
- Standard lauscht auf `0.0.0.0:8770` (LAN). Für Internet: Reverse Proxy mit TLS.
|
||||||
|
- Drucker unter **Einstellungen** anlegen (kein vorgefülltes `drucker.json` nötig).
|
||||||
|
- Öffentliches `apt install grotti` ohne Datei: kommt mit dem Repo-Server (Phase 2).
|
||||||
|
- `.deb` auf dem Ziel-Pi bauen (`bash packaging/build-deb.sh`), damit arm64-Wheels (`psycopg`, `cryptography`) passen.
|
||||||
@@ -26,9 +26,9 @@ Unter **Aufträge** Kunden, Positionen und Fortschritt. Dateien lassen sich eine
|
|||||||
|
|
||||||
## Protokoll und Statistik
|
## Protokoll und Statistik
|
||||||
|
|
||||||
Abgeschlossene Läufe: Dauer, Ist-Gramm, Kosten, Urteil. Zeile öffnet den **Druckbericht** (Soll/Ist, Spulen, Temperaturverlauf, Kosten-Aufschlüsselung Filament + Strom mit Tortendiagramm, 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, Kosten-Aufschlüsselung Filament + Strom mit Tortendiagramm, Charge). **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option (nicht während der Druck noch läuft).
|
||||||
|
|
||||||
Nach Filament-Ende: neue Spule unter **Material** dem Drucker/ACE-Platz zuweisen (Ort setzen und speichern — das legt den Rollenwechsel im Lauf an), am Gerät laden, an der Wand **Weiter**. Nur physisch nachlegen reicht nicht. Die Zuweisung gilt auch, wenn Moonraker den Druck nach der Pause als neuen Job zählt.
|
Nach Filament-Ende: neue Spule unter **Material** dem Drucker/ACE-Platz zuweisen (Ort setzen und speichern — das legt den Rollenwechsel im Lauf an), am Gerät laden, an der Wand **Weiter**. Nur physisch nachlegen reicht nicht. Die Zuweisung gilt auch, wenn Moonraker den Druck nach der Pause als neuen Job zählt — Grotti hält das als **einen** Lauf (gleiche Charge). Im Protokoll siehst du bei Rollenwechsel die Spulennamen; falls doch zwei Zeilen stehen, **Läufe holen** oder **Verbinden**.
|
||||||
|
|
||||||
Unter **Statistik** KPIs, Balkendiagramme (Druckzeit je Drucker, Verbrauch je Material), Tabellen und CSV-Export. Zeitraum per Knopf oder Von/Bis.
|
Unter **Statistik** KPIs, Balkendiagramme (Druckzeit je Drucker, Verbrauch je Material), Tabellen und CSV-Export. Zeitraum per Knopf oder Von/Bis.
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
# Orca an Grotti
|
# Orca an Grotti
|
||||||
|
|
||||||
Stand **18.10.2026**. Nach dem Export landet der G-code in der Bibliothek und Grotti öffnet sich als **eigenes Fenster** (Chrome/Edge-App auf dem Desktop). Drucker, ACE-Platz und Einreihen wählst du dort.
|
Stand **25.10.2026**. Nach dem Export landet der G-code in der Bibliothek und Grotti öffnet sich als **eigenes Fenster** (Chrome/Edge-App auf dem Desktop). Drucker, ACE-Platz und Einreihen wählst du dort. Ziel ist die Instanz auf dem Pi (`http://10.10.1.41:8770/`), nicht mehr lokal.
|
||||||
|
|
||||||
## Einmal einrichten
|
## Einmal einrichten
|
||||||
|
|
||||||
1. Grotti starten und im Browser anmelden (die Session bleibt für die Senden-Seite).
|
1. Grotti auf dem Pi erreichbar machen 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.
|
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`):
|
3. Datei `data/orca-senden.json` anlegen (im Grotti-Repo unter Windows, gleicher Ordner wie lokal `config.json`):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"url": "http://127.0.0.1:8770",
|
"url": "http://10.10.1.41:8770",
|
||||||
"token": "grt_…",
|
"token": "grt_…",
|
||||||
"browser": "app"
|
"browser": "app"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`url` ist die Adresse, unter der du Grotti im Browser öffnest.
|
`url` ist die Adresse des Pi (LAN-IP oder Hostname).
|
||||||
`browser`: `app` (Standard) = eigenes Desktop-Fenster; `tab` = klassischer Browser-Tab.
|
`browser`: `app` (Standard) = eigenes Desktop-Fenster; `tab` = klassischer Browser-Tab.
|
||||||
|
|
||||||
4. Im **Prozessprofil** (Druckqualität, z. B. „0.20mm Standard“): **Sonstiges → Nachbearbeitungsskripte**, eine Zeile:
|
4. Im **Prozessprofil** (Druckqualität, z. B. „0.20mm Standard“): **Sonstiges → Nachbearbeitungsskripte**, eine Zeile:
|
||||||
@@ -34,7 +34,7 @@ Damit unter **Gerät** die Grotti-Oberfläche erscheint:
|
|||||||
1. Orca → Drucker-Dropdown → **Physikalischer Drucker** (Verbindung/Stecker-Symbol).
|
1. Orca → Drucker-Dropdown → **Physikalischer Drucker** (Verbindung/Stecker-Symbol).
|
||||||
2. **Host-Typ:** z. B. `Octo/Klipper` (egal, solange du nicht „Drucken“ an den Host schickst).
|
2. **Host-Typ:** z. B. `Octo/Klipper` (egal, solange du nicht „Drucken“ an den Host schickst).
|
||||||
3. **Hostname / API:** leer lassen — oder nur eine Dummy-Adresse. **Nicht** die Moonraker-IP eines Kobras eintragen.
|
3. **Hostname / API:** leer lassen — oder nur eine Dummy-Adresse. **Nicht** die Moonraker-IP eines Kobras eintragen.
|
||||||
4. **Device UI / Web-UI-URL:** `http://127.0.0.1:8770/` (Grotti muss laufen).
|
4. **Device UI / Web-UI-URL:** `http://10.10.1.41:8770/` (Grotti auf dem Pi).
|
||||||
5. Speichern → Reiter **Gerät** öffnen → einmal bei Grotti anmelden.
|
5. Speichern → Reiter **Gerät** öffnen → einmal bei Grotti anmelden.
|
||||||
|
|
||||||
Wichtig: Den Orca-Button **Drucken / Upload zum Host** nicht nutzen — der spricht Moonraker/OctoPrint und umgeht ACE-Zuordnung in Grotti. Weiterhin **G-code exportieren** + Nachbearbeitungsskript.
|
Wichtig: Den Orca-Button **Drucken / Upload zum Host** nicht nutzen — der spricht Moonraker/OctoPrint und umgeht ACE-Zuordnung in Grotti. Weiterhin **G-code exportieren** + Nachbearbeitungsskript.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Projekte
|
# Projekte
|
||||||
|
|
||||||
Stand **18.10.2026**. Arbeitsmappen unter `/projekte` — getrennt von Kunden-[Aufträgen](Anleitung-Auftraege.md).
|
Stand **24.10.2026**. Arbeitsmappen unter `/projekte` — getrennt von Kunden-[Aufträgen](Anleitung-Auftraege.md).
|
||||||
|
|
||||||
## Wozu
|
## Wozu
|
||||||
|
|
||||||
@@ -16,11 +16,19 @@ Ein **Projekt** bündelt Konstruktion und Druckdateien in einem Ordner unter dem
|
|||||||
|
|
||||||
**Neues Projekt** — Name, Status, optional Ordner (Standard: `Projekte/<Kurzname>`). Mit gesetztem Slice-Ordner legt Grotti den Ordner an.
|
**Neues Projekt** — Name, Status, optional Ordner (Standard: `Projekte/<Kurzname>`). Mit gesetztem Slice-Ordner legt Grotti den Ordner an.
|
||||||
|
|
||||||
G-code und 3MF in diesem Ordner (oder dorthin [verschoben](Anleitung-Dateien.md)) werden dem Projekt zugeordnet und erscheinen in der Druck-Bibliothek.
|
Filter **Aktiv** / **Fertig** / **Alle** in der Liste. G-code und 3MF in diesem Ordner (oder dorthin [verschoben](Anleitung-Dateien.md)) werden dem Projekt zugeordnet und erscheinen in der Druck-Bibliothek.
|
||||||
|
|
||||||
## Weitere Dateien
|
## Detail
|
||||||
|
|
||||||
Im Projekt-Detail: Upload von STEP, STL, PDF, SolidWorks usw. — liegen im selben Ordner, erscheinen **nicht** in der Druck-Bibliothek. Download und Löschen pro Datei.
|
Im Projekt siehst du Kennzahlen (Läufe, Druckzeit, Filament, Kosten) und drei Tabs:
|
||||||
|
|
||||||
|
- **Druckdateien** — Link zur Bibliothek (`?file=` / `?project=`), **Einreihen** pro Datei oder **Alle einreihen** (Warteschlange, mit Bestätigung)
|
||||||
|
- **Läufe** — letzte Drucke mit Link zum Protokoll / Bericht
|
||||||
|
- **Weitere Dateien** — STEP, STL, PDF, SolidWorks usw. (nicht in der Druck-Bibliothek)
|
||||||
|
|
||||||
|
Wenn jede Datei mindestens einmal ok gedruckt wurde, schlägt Grotti vor, den Status auf **fertig** zu setzen.
|
||||||
|
|
||||||
|
Deep-Link: `/projekte#<id>`.
|
||||||
|
|
||||||
## Löschen
|
## Löschen
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Druckerwand
|
# Druckerwand
|
||||||
|
|
||||||
Stand **18.10.2026**. Startseite `/` — eine Karte je Drucker. Klick öffnet das Cockpit.
|
Stand **24.10.2026**. Startseite `/` — eine Karte je Drucker. Klick öffnet das Cockpit.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ Aktionen auf der Karte brauchen oft **zwei Klicks** (erster Klick „scharf“,
|
|||||||
|
|
||||||
| Aktion | Wann |
|
| Aktion | Wann |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Pause** / **Weiter** | Während des Drucks; nach Materialwechsel Spule in Grotti an den Drucker hängen, dann **Weiter** |
|
| **Pause** / **Weiter** | Während des Drucks; nach Materialwechsel gelber Hinweis mit ACE-Platz → Link zu Material (Drucker/Platz vorausgefüllt), Speichern, dann **Weiter**. Dieselbe Charge läuft weiter. |
|
||||||
| **Abbrechen** | Druck oder Pause |
|
| **Abbrechen** | Druck oder Pause |
|
||||||
| **Fehler löschen** | Zustand Fehler — räumt den hängenden Fehler, Heizung aus |
|
| **Fehler löschen** | Zustand Fehler — räumt den hängenden Fehler, Heizung aus |
|
||||||
| **Platte geräumt** | Nach Fertig, wenn die Platte wieder frei ist |
|
| **Platte geräumt** | Nach Fertig, wenn die Platte wieder frei ist |
|
||||||
@@ -39,7 +39,8 @@ 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 (dort Kosten: Filament + Grundlast/Düse/Bett)
|
- **Verlauf** Temperatur und ED des aktuellen oder letzten Drucks; Links zu Datei und Druckbericht (dort Kosten: Filament + Grundlast/Düse/Bett)
|
||||||
|
- **Als Nächstes** (nur wenn der Drucker frei und die Platte quittiert ist): **Nochmal** der letzten Datei, Hinweis zur Warteschlange, passende Dateien nach Familie/Material — Start mit Standardwerten oder **Anpassen…** unter Dateien
|
||||||
- **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
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
@@ -1,6 +1,8 @@
|
|||||||
# Grotti — Community-Vorstellung
|
# Grotti — Community-Vorstellung
|
||||||
|
|
||||||
Screenshots aus dem laufenden Betrieb (v3.2.0, Okt. 2026). Zielgruppe: mittlere bis große Printfarmen.
|
Screenshots aus dem laufenden Betrieb (v3.4.0, Okt. 2026). Zielgruppe: mittlere bis große Printfarmen.
|
||||||
|
|
||||||
|
Logo und Repo-Banner: [`docs/brand/`](../brand/). Haupt-README: [`../../README.md`](../../README.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Grotti HTTP-API (`/api/v1`)
|
||||||
|
|
||||||
|
Stand für Android/App-Clients und Integrationen. Nutzer-Anleitungen bleiben unter `docs/Anleitung-*.md`.
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
| Weg | Header / Cookie | CSRF |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Session | Cookie nach `POST /auth/login` | `X-CSRF-Token` bei mutierenden API-Calls |
|
||||||
|
| Bearer | `Authorization: Bearer grt_…` | nicht nötig |
|
||||||
|
|
||||||
|
**Token ausstellen**
|
||||||
|
|
||||||
|
- Eingeloggt: `POST /tokens` `{name, role?}` → Klartext einmalig in `token`
|
||||||
|
- Ohne Session (App): `POST /auth/token` `{username, password, device_name?, role?}` → `201` mit `token` + `user` (Rate-Limit wie Login)
|
||||||
|
|
||||||
|
Öffentlich ohne Login: `/auth/status`, `/auth/login`, `/auth/token`, `/auth/setup`, `/version`.
|
||||||
|
|
||||||
|
Rollen: `operator` ≤ `admin`. Admin-only u. a. Benutzer, Backup, volle Einstellungen, Drucker anlegen.
|
||||||
|
|
||||||
|
**Medien mit Query-Token** (nur GET): `?token=grt_…` für Kamera, Live-Vorschau, Datei-Vorschau (`/dateien/<id>/vorschau`), Projekt-Assets.
|
||||||
|
|
||||||
|
## Fehler
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"error": "Text", "code": "kurzcode"}
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP-Status: 400/401/403/404/409/429/503. Bild-Endpunkte liefern bei fehlendem Bild ebenfalls dieses JSON (nicht nur leeren 404).
|
||||||
|
|
||||||
|
## Pagination
|
||||||
|
|
||||||
|
Listen akzeptieren `limit` / `offset` wo sinnvoll. Antwort bleibt ein **JSON-Array**; Gesamtzahl im Header `X-Total-Count`.
|
||||||
|
|
||||||
|
Ohne `limit` liefern `/dateien` und `/spulen` die volle Liste (Web-UI).
|
||||||
|
|
||||||
|
## Bestätigung
|
||||||
|
|
||||||
|
Mutierende Druckaktionen brauchen oft `"confirm": true`:
|
||||||
|
|
||||||
|
- `POST /senden`, `POST /druck-queue`, `POST /ablauf`
|
||||||
|
- `POST /projekte/<id>/einreihen`, `POST /auftraege/<id>/einreihen`
|
||||||
|
- `POST /drucker/<host>/abraeumen`
|
||||||
|
- `steuern` mit `emergency_stop`, `clear_error`, `exclude_object`
|
||||||
|
|
||||||
|
## Server-Info
|
||||||
|
|
||||||
|
`GET /version` → `{name, version, api: 1, features: [...]}`.
|
||||||
|
|
||||||
|
## SSE Live
|
||||||
|
|
||||||
|
`GET /stream` (Session-Cookie oder Bearer). Events:
|
||||||
|
|
||||||
|
| Event | Bedeutung |
|
||||||
|
| --- | --- |
|
||||||
|
| `ping` | Keepalive (~15 s), Payload `{}` |
|
||||||
|
| `drucker` | Live-Zustand der Drucker geändert |
|
||||||
|
| `queue` | Druckwarteschlange geändert |
|
||||||
|
| `senden` | Sende-Auftrag geändert |
|
||||||
|
| `ereignis` | Neues Ereignis / Aktivität |
|
||||||
|
| `aufgaben` | Offene Aufgaben (Platte …) |
|
||||||
|
|
||||||
|
Ohne Stream: Client pollt `/drucker?live=1`, `/jetzt`, `/druck-queue`.
|
||||||
|
|
||||||
|
## Push an Geräte
|
||||||
|
|
||||||
|
Grotti sendet **kein** FCM. Empfohlen: **ntfy** (Topic unter Einstellungen). Die App kann denselben Topic abonnieren; Secrets (ntfy-Token) bleiben serverseitig.
|
||||||
|
|
||||||
|
## Wichtige Ressourcen
|
||||||
|
|
||||||
|
| Bereich | Pfade |
|
||||||
|
| --- | --- |
|
||||||
|
| Drucker | `GET /drucker?live=1`, `POST /drucker/<host>/pause\|fortsetzen\|abbrechen` (Alias `resume`/`cancel`), `…/steuern`, `…/abraeumen` |
|
||||||
|
| Dateien | `GET /dateien?family=&material=&project_id=&host=&q=&limit=&offset=`, Upload multipart, `…/vorschau`, `…/pfad` |
|
||||||
|
| Senden / Queue | `POST /senden`, `GET /senden-zuletzt?host=&file_id=`, `GET\|POST /druck-queue?host=&status=`, `POST /ablauf` |
|
||||||
|
| Projekte | `GET\|POST /projekte`, `GET /projekte/<id>`, `…/laeufe`, `POST …/einreihen`, Assets |
|
||||||
|
| Aufträge | `GET\|POST /auftraege`, Positionen, `…/einreihen` |
|
||||||
|
| Material | `GET /spulen?aktiv=1`, Akte, Einlegen |
|
||||||
|
| Protokoll | `GET /laeufe`, `GET /laeufe/<id>`, `GET /statistik?project_id=` |
|
||||||
|
| Kamera | `GET /kamera?host=`, `…/stream` |
|
||||||
|
|
||||||
|
Pfadnamen sind überwiegend **deutsch**. Aktionen: deutsch + englische Alias für Pause/Resume/Cancel.
|
||||||
|
|
||||||
|
## MCP
|
||||||
|
|
||||||
|
`python -m grotti.mcp_server` nutzt dieselbe REST-API (Teilmenge). Siehe `docs/internal/MCP.md`.
|
||||||
@@ -134,8 +134,23 @@ Ohne Map: Rinkhals füllt oft Identity T0→Platz0 … — deshalb Grotti bei ei
|
|||||||
| Slots anzeigen | Moonraker `mmu` (Rinkhals) | `mmu_ace.py` |
|
| Slots anzeigen | Moonraker `mmu` (Rinkhals) | `mmu_ace.py` |
|
||||||
| Kein Firmware-Restart | abgelehnt | Rinkhals #32, `patch_klipper_restart` |
|
| Kein Firmware-Restart | abgelehnt | Rinkhals #32, `patch_klipper_restart` |
|
||||||
|
|
||||||
|
## History nach Filament-Pause
|
||||||
|
|
||||||
|
Runout, Rolle tauschen, Fortsetzen: Anycubic schließt den History-Job als `cancelled` und öffnet eine neue Job-ID. Beobachtet am 2026-10-25 auf Kobra 3 A1 (`000040` → `000041`):
|
||||||
|
|
||||||
|
- Der neue `start_time` liegt etwa 0,04 s nach dem `end_time` des alten.
|
||||||
|
- `print_duration` und `total_duration` sind an beiden Einträgen gleich und laufen weiter. Der Zähler beginnt nicht bei 0.
|
||||||
|
- `/server/history/list` liefert neueste zuerst. Grotti sortiert pro Drucker nach `start_time`, sonst kommt die Fortsetzung vor ihrem Vorgänger dran (`jobs.history_in_order`).
|
||||||
|
- Nach dem Zusammenlegen hängt die alte Job-ID in `run_jobs` am Lauf. `store.history_job_retired` überspringt sie, sonst schreibt sie `cancelled` zurück.
|
||||||
|
- Zusammenlegen nur mit Beweis: `print_duration` fortgesetzt und Lücke ≤ 5 s, oder Watcher `paused→printing` (`docs/internal/Lauf-Identitaet.md`).
|
||||||
|
|
||||||
## Offene Grenzen
|
## Offene Grenzen
|
||||||
|
|
||||||
- Quellcode von **gkapi** (Task-State-Machine, exakte 10101-Bedingungen) ist nicht öffentlich.
|
- Quellcode von **gkapi** (Task-State-Machine, exakte 10101-Bedingungen) ist nicht öffentlich.
|
||||||
- Deadlock in **FIRMWARE_RESTART** ist beobachtet und in Rinkhals abgefangen, Root Cause im Binary nicht freigelegt.
|
- Deadlock in **FIRMWARE_RESTART** ist beobachtet und in Rinkhals abgefangen, Root Cause im Binary nicht freigelegt.
|
||||||
- Kobra X / K4-Stack ist ein anderes System (C++-Klipper), nicht GoKlipper — für unsere Drucker irrelevant.
|
- Kobra X / K4-Stack ist ein anderes System (C++-Klipper), nicht GoKlipper — für unsere Drucker irrelevant.
|
||||||
|
|
||||||
|
## Siehe auch
|
||||||
|
|
||||||
|
- [Makros-Kobra](Makros-Kobra.md) — Nicht-tun, Vanilla-Alltag, ACE, LeviQ/Wipe je Druckertyp
|
||||||
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Lauf-Identität: ein physischer Druck = ein Lauf
|
||||||
|
|
||||||
|
## Regel
|
||||||
|
|
||||||
|
Zusammengelegt wird nur mit **Beweis**, nicht nur wegen Zeit und Dateiname:
|
||||||
|
|
||||||
|
1. Der neue Moonraker-Job hat `print_duration` ≥ Dauer des Vorgängers − 30 s, und die Lücke `end→start` ist höchstens 5 s (Anycubic setzt `end_time` erst beim Fortsetzen).
|
||||||
|
2. Oder der Watcher hat `paused → printing` bei gleicher Datei gesehen (`watcher_resume`, `printers.active_run_id`).
|
||||||
|
|
||||||
|
Ein Neustart mit `print_duration` ≤ 60 s wird nie zusammengelegt.
|
||||||
|
|
||||||
|
Ein Live-Lauf wird nur übernommen, wenn der Dateiname übereinstimmt (eine noch namenlose Hülle zählt zum neuen Druck). Ein Abbruch-Ereignis auf eine bereits gelöschte Zeile rollt den Abgleich nicht zurück.
|
||||||
|
|
||||||
|
## Pfade
|
||||||
|
|
||||||
|
| Pfad | Datei |
|
||||||
|
| --- | --- |
|
||||||
|
| Beweis | `jobs.is_filament_resume` |
|
||||||
|
| Fold | `jobs.fold_filament_resume` |
|
||||||
|
| Abgleich | `jobs.sync_history` (Advisory-Lock) |
|
||||||
|
| Job-Aliase | `run_jobs` + `history_job_retired` |
|
||||||
|
| Leere Hülle | übersprungen in `_sync_one_job` |
|
||||||
|
| Live-Abzug | erst History; Fallback nach 10 min (`LIVE_DEDUCT_FALLBACK_S`) |
|
||||||
|
| Manuell | `POST /api/v1/laeufe/<id>/verbinden` |
|
||||||
|
|
||||||
|
## Queue
|
||||||
|
|
||||||
|
In `print_active` heißt nur `complete` fertig. `cancelled`/`standby` werden entprellt (`TERMINAL_CONFIRM_HITS`). Echter Abbruch → Status `cancelled`, nicht „Fertig.“.
|
||||||
|
|
||||||
|
## Reparatur
|
||||||
|
|
||||||
|
`scripts/laeufe_reparieren.py` (Probelauf standardmäßig, `--apply` nach Freigabe).
|
||||||
|
Verwaiste `live:`-Läufe nur an offenen History-Jobs **derselben Datei**; `adopt_live_run` greift geschlossene Live-Läufe nur ≤ 2 h.
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
# MCP-Server
|
# MCP-Server
|
||||||
|
|
||||||
Stand **15.10.2026**. Agenten steuern Grotti nur über die HTTP-API mit einem API-Token.
|
Stand **19.10.2026**. Agenten steuern Grotti nur über die HTTP-API mit einem API-Token.
|
||||||
|
|
||||||
|
Beim Connect bekommt der Client die Server-Anweisung aus `grotti/mcp_server.py` (`INSTRUCTIONS`): Deutsch antworten, Host und Datei-id nachschlagen, erst lesen dann schreiben, `confirm=true` nur auf klaren Wunsch, Warteschlange vor Sofortstart, Filament-Pause nicht blind fortsetzen, `clear_bed` ist nur eine Quittung, kein G-code und kein Firmware-Restart.
|
||||||
|
|
||||||
## Start
|
## Start
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
# Makros und G-code: Kobra S1 und Kobra 3
|
||||||
|
|
||||||
|
Stand **19.10.2026**. Interne Referenz — nicht unter `/doku`. Messwerte und Bettnetz-Story: `D:\Projekte\3DP\FDM\Wissen\Drucker\` (`Kobra S1.md`, `Kobra 3.md`). Stack: [Anycubic-Stack](Anycubic-Stack.md).
|
||||||
|
|
||||||
|
Quellen: `printer/gcode/help` (11.10. auf den Maschinen), GoKlipper/`extras_ace.go`, Rinkhals-RE `GOKLIPPER_ACE_GCODE_COMMANDS.md`, Grotti-Policy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nicht tun
|
||||||
|
|
||||||
|
| Nicht | Warum | Stattdessen |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `FIRMWARE_RESTART` / `RESTART` | gklib Deadlock bis Power-Cycle (Rinkhals #32) | Display-Reboot oder Strom; hängenden Job: **Fehler löschen** (`SDCARD_RESET_FILE`) |
|
||||||
|
| `LEVIQ` / `LEVIQ2` aus Mainsail | Heizung + volle Nivellierfolge, fährt unerwartet | Am Display Auto-Level; Einzelschritte nur bewusst |
|
||||||
|
| `SAVE_CONFIG` aus Mainsail | schreibt u. a. Z-Offset mit | Dauerhaft nur gezielt / nach Display-Nivellieren |
|
||||||
|
| Nur Moonraker-Cancel | Anycubic-Task bleibt → Start-Code **10101** | Cancel + MQTT-stop (Grotti `abbrechen`) |
|
||||||
|
| Community-`ACE_SET_SLOT` / manuelles `G9111` als Grotti-Pfad | Grotti mappt über `.acm` + MQTT | Map in der App; Slice-`G9111` im Start-G-code belassen |
|
||||||
|
| `minimum_cruise_ratio` auf **0** (S1) | Moonraker hängt auf dieser Firmware | bei **0,05** belassen |
|
||||||
|
| `G28 Y` mit Teilen auf dem Bett (K3) | Sensorless-Home nach **vorn** über die Teile | Bett leeren; Abräumen ohne `G28 Z` |
|
||||||
|
| Blind `FEED` / `FEED_FILAMENT` ohne Unwind | Blockade bis Neustart (RE-Doku) | Unload/Unwind-Sequenz einhalten |
|
||||||
|
| `SET_KINEMATIC_POSITION` mit erfundener Z nach Stepper-aus | Portal sackt → Null daneben, Probe „No trigger“ oder Crash | echte letzte XYZ; oder Bett leer + `G28` |
|
||||||
|
| Freie G-code-Konsole in Grotti für Experimente | Policy | nur bekannte, bestätigte Scripts |
|
||||||
|
| Abräumen-Makro in `printer.cfg` pflegen | Rinkhals schreibt Config neu | Grotti schickt Scripts per Moonraker |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vanilla-Klipper-Alltag (beide Typen)
|
||||||
|
|
||||||
|
Befehl → was er macht. Anycubic-Hinweis nur wo nötig.
|
||||||
|
|
||||||
|
### Homes und Bewegung
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `G28` | Homed die genannten Achsen (ohne Argument: alle). **K3:** Y-Home fährt nach vorn — Bett muss leer sein. |
|
||||||
|
| `G0` / `G1` | Lineare Bewegung / Extrusion. Absolute/relative Modi wie üblich (`G90`/`G91`, `M82`/`M83`). |
|
||||||
|
| `M400` | Wartet, bis alle Moves fertig sind. |
|
||||||
|
| `SET_VELOCITY_LIMIT` | Limits für Speed/Accel zur Laufzeit. |
|
||||||
|
| `SET_KINEMATIC_POSITION` | Setzt die interne Position ohne zu fahren. Nach `M84`/Leerlauf riskant — siehe „Nicht tun“. |
|
||||||
|
| `SET_IDLE_TIMEOUT` | Idle-Zeit bis Stepper aus. Abräumen hält Idle bewusst länger. |
|
||||||
|
| `M84` / `M18` | Stepper aus → Achsen unhomed. Slice-Ende K3 macht das oft; Abräumen entfernt es beim Upload. |
|
||||||
|
|
||||||
|
### Heizen
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `M104` / `M109` | Düse Soll / warten bis erreicht. |
|
||||||
|
| `M140` / `M190` | Bett Soll / warten. |
|
||||||
|
| `TURN_OFF_HEATERS` | Düse und Bett aus. |
|
||||||
|
| `PID_CALIBRATE` | PID für Extruder oder Bett kalibrieren. Danach oft `SAVE_CONFIG` — auf diesen Maschinen vorsichtig. |
|
||||||
|
| `M106` / `M107` | Teilkühlung an / aus. |
|
||||||
|
|
||||||
|
### Drucklauf
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `PAUSE` / `RESUME` / `CANCEL_PRINT` | Rinkhals-Makros um Pause/Weiter/Abbruch. Moonraker-Cancel allein räumt den Anycubic-Task nicht. |
|
||||||
|
| `SDCARD_RESET_FILE` | Löscht hängenden virtual-sdcard-/Druckstatus („Fehler löschen“ in Grotti). |
|
||||||
|
| `PRINT_STATS` | Status des laufenden/letzten Jobs (Objekt auch über Moonraker). |
|
||||||
|
|
||||||
|
### Bettnetz und Probe
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `BED_MESH_CALIBRATE` | Tastet das Netz und schaltet es aktiv. |
|
||||||
|
| `BED_MESH_OUTPUT` | Gibt die Punkte aus, ohne zu fahren. |
|
||||||
|
| `BED_MESH_MAP` | Schreibt das interpolierte Netz in die Konsole. |
|
||||||
|
| `BED_MESH_CLEAR` | Schaltet die Korrektur aus. |
|
||||||
|
| `BED_MESH_OFFSET` | Verschiebt das Netz in X/Y. |
|
||||||
|
| `BED_MESH_PROFILE` | `LOAD` / `SAVE` / `REMOVE` für Profile. Dauerhaft erst mit `SAVE_CONFIG`. |
|
||||||
|
| `PROBE` | Ein Tastpunkt an der aktuellen Position. |
|
||||||
|
| `PROBE_ACCURACY` | Mehrfach tasten, Streuung ausgeben. |
|
||||||
|
| `PROBE_CALIBRATE` | Sensor kalibrieren (nicht der Z-Regler am Display). |
|
||||||
|
| `Z_OFFSET_APPLY_PROBE` | Laufenden G-code-Offset in `probe.z_offset` schreiben. |
|
||||||
|
| `SET_GCODE_OFFSET` | Offset nur für den aktuellen Auftrag. |
|
||||||
|
| `SAVE_CONFIG` | Schreibt Config dauerhaft — **nicht** blind aus Mainsail. |
|
||||||
|
|
||||||
|
### Diagnose
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `QUERY_ENDSTOPS` / `M119` | Endstop-Zustände. |
|
||||||
|
| `GET_POSITION` / `M114` | Toolhead-/Stepper-Position. |
|
||||||
|
| `QUERY_ADC` | ADC-Werte (z. B. Thermistoren). |
|
||||||
|
| `DUMP_TMC` | TMC-Register (Stepper nennen). |
|
||||||
|
|
||||||
|
### Slice-Start Anycubic
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `G9111` | Vorheizen aus dem Slicer: typisch `bedTemp=…` und `extruderTemp=…`. Steht im Maschinen-Start-G-code. Grotti ersetzt das nicht und startet auch nicht darüber. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ACE (gemeinsam)
|
||||||
|
|
||||||
|
Zwei Namenswelten:
|
||||||
|
|
||||||
|
1. **Open-Source** GoKlipper `extras_ace.go` (BunnyACE-Stil): `ACE_CHANGE_TOOL`, `ACE_FEED`, `ACE_RETRACT`, `ACE_SET_SLOT`, `ACE_QUERY_SLOTS`, `ACE_START_DRYING` / `ACE_STOP_DRYING`, Feed-Assist, Endless Spool, …
|
||||||
|
2. **Ausgeliefertes Binary** (Rinkhals-RE): u. a. `FEED_FILAMENT`, `UNWIND_FILAMENT`, `UNWIND_ALL_FILAMENT`, `SET_CURRENT_FILAMENT`, `ACE_INFO`, `ACE_STATUS`, Cutter-/Pipe-Befehle.
|
||||||
|
|
||||||
|
Grotti nutzt **keine** Community-Makros `ACE_SET_SLOT` / manuelles Tool-Mapping per G-code. Zuordnung: Sidecar `.acm` + MQTT `ams_settings` ([Senden-Queue](Senden-Queue.md), [ACE-Live](ACE-Live.md)). Slots anzeigen: Moonraker-Objekt `mmu` (Rinkhals). Trocknen: Moonraker `POST /server/filament_hub/start_drying` (nicht zwingend der G-code).
|
||||||
|
|
||||||
|
| Befehl (Auswahl) | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `ACE_CHANGE_TOOL` / Tool-Macros `T0`… | Werkzeugwechsel über ACE. |
|
||||||
|
| `ACE_FEED` / `FEED_FILAMENT` | Filament vom Gate zur Düse laden. **Vorher unloaden.** |
|
||||||
|
| `ACE_RETRACT` / `UNWIND_FILAMENT` | Zurückziehen / parkieren. |
|
||||||
|
| `ACE_SET_SLOT` | Inventar Slot setzen — Grotti meidet das. |
|
||||||
|
| `ACE_START_DRYING` / `ACE_STOP_DRYING` | Trockner; alternativ `filament_hub`-API. |
|
||||||
|
| `ACE_ENABLE_ENDLESS_SPOOL` / `…_DISABLE` / `…_STATUS` | Endless Spool. |
|
||||||
|
|
||||||
|
Details und Parameter: Rinkhals `docs/GOKLIPPER_ACE_GCODE_COMMANDS.md` (lokal unter `D:\Projekte\_ref\Rinkhals\`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kobra S1 — Anycubic-Makros
|
||||||
|
|
||||||
|
Config-Sektion heißt `[leviQ3]`, die Befehle auf dem Gerät heißen **`LEVIQ2`**. Stecken in GoKlipper. Hilfe gelesen 11.10. über `printer/gcode/help`.
|
||||||
|
|
||||||
|
### LeviQ2
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `LEVIQ2` | Ganze Nivellierfolge: heizen, wischen, antasten. **Nicht** aus Mainsail. `SYNC=enable` startet danach neu. |
|
||||||
|
| `LEVIQ2_PREHEATING` | Vorheizen: Bett 55 °C, Düse 170 °C, dann `HOME_XY`. `S` = Düsentemp, `E` = Temp nach dem Wischen. |
|
||||||
|
| `LEVIQ2_WIPING` | Wischen: Netz löschen, Düse auf 140 °C, intern `G9112`, dann `G28 Z`. |
|
||||||
|
| `LEVIQ2_PROBE` | Antasten: `BED_MESH_CALIBRATE`, Lüfter/Bett aus, Z auf 20 mm, `HOME_XY`, Düse aus. |
|
||||||
|
| `LEVIQ_SET_ZERO` | Z-Basis und Home-Position auf 0. |
|
||||||
|
| `LEVIQ2_AUTO_ZOFFSET` | Löscht laut Hilfe den Koeffizienten K. |
|
||||||
|
| `LEVIQ2_AUTO_ZOFFSET_ON_OFF` | Schaltet automatischen Z-Offset. |
|
||||||
|
| `LEVIQ2_TEMP_OFFSET` | Temperaturausgleich (`expansion_factor` / `zoffset_base` in der Config). Fehlt im veröffentlichten Quelltext. |
|
||||||
|
| `HOME_XY` | Nur X/Y homen (von LeviQ-Schritten genutzt). |
|
||||||
|
|
||||||
|
Netz: 5×5, X/Y 5–245 mm. Details: Wissen `Kobra S1.md`.
|
||||||
|
|
||||||
|
### Wipe (Pad hinten)
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `WIPE_ENTER` | Anfahrt Pad (Y 250 → X 81, Y 273). |
|
||||||
|
| `WIPE_NOZZLE` | Wischbahn; wiederholt die Strecke X 96↔81. |
|
||||||
|
| `WIPE_STOP` | Kurze Stopp-Bahnen, dann X 75. |
|
||||||
|
| `WIPE_EXIT` | Weg vom Pad (X 83, Y 250). |
|
||||||
|
| `TO_THROW_POSITION` | Position zum Abwerfen (~X 47, Y 276). |
|
||||||
|
| `MOVE_TO_SWEEP_POSITION` | Wisch-/Kehrposition (gleiche Gegend). |
|
||||||
|
| `MOVE_TO_IMPACT_POSITION` | Weg vom Pad nach vorn rechts (Y 25, X 261). |
|
||||||
|
| `PRINT_LINE` | Purge-Linie bei Y 251, Z 0,28 bis X 125. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kobra 3 — Anycubic-Makros
|
||||||
|
|
||||||
|
Befehle gelesen 11.10. auf `da26` (`92ee`/`6e41` gleiche `printer.generated.cfg`). Im veröffentlichten Quelltext heißen Teile noch `LEVIQ3_*` / `LEVIQ2_PROBE` — auf den drei Druckern gelten die Namen unten.
|
||||||
|
|
||||||
|
### LeviQ
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `LEVIQ` | Ganze Folge inkl. Z-Offset-Rechnung. **Nicht** aus Mainsail. `SYNC=enable` startet danach neu. |
|
||||||
|
| `LEVIQ_PREHEATING` | Z-Basis 0, Bett 60 °C, Düse 170 °C, `HOME_XY`, auf Düse warten. `S` / `E` wie bei S1. |
|
||||||
|
| `LEVIQ_WIPING` | Netz löschen, Düse 140 °C, `G9112`, `G28 Z`. |
|
||||||
|
| `LEVIQ_PROBE` | `BED_MESH_CALIBRATE`, Lüfter/Bett aus, Z auf 30 mm, `HOME_XY`, Düse aus. |
|
||||||
|
| `LEVIQ_SET_ZERO` | Z-Basis und Home auf 0. |
|
||||||
|
| `LEVIQ_AUTO_ZOFFSET` | Löscht Koeffizient K (Hilfe). |
|
||||||
|
| `LEVIQ_AUTO_ZOFFSET_ON_OFF` | Automatischen Z-Offset schalten (oft an). |
|
||||||
|
| `LEVIQ_TEMP_OFFSET` | Temperaturausgleich (Hilfezeile „LEVIQ3 TEMP OFFSET“). Fehlt im Open-Source-Code. |
|
||||||
|
| `HOME_XY` | Nur X/Y homen. |
|
||||||
|
|
||||||
|
Netz: 5×5, X 15–240, Y 15–235 mm. Details: Wissen `Kobra 3.md`.
|
||||||
|
|
||||||
|
### Wipe
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `WIPE_NOZZLE` | Dreimal `SINGLE_WIPE_NOZZLE` (Y 260/259, X 46–90, Z −0,8↔1, Lüfter an). |
|
||||||
|
| `SINGLE_WIPE_NOZZLE` | Eine einzelne Wischbahn. |
|
||||||
|
| `MOVE_HEAT_POS` | Z 15, dann X 278 (Heiz-/Parknahe Position). |
|
||||||
|
| `UNDERLINE` | Purge-Linie Z 0,28 bis X 180, ~40 mm Extrusion. |
|
||||||
|
| `MOVE_END_POS` | Ende/Park X 5, Y 242. |
|
||||||
|
|
||||||
|
### Wägezelle
|
||||||
|
|
||||||
|
| Befehl | Funktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `CS1237_DUMP` | Debug-Ausgabe zum CS1237 (Z-Sensor / Kraft). Moonraker liefert keine laufende Gramm-Anzeige; `probe.last_z_result` fehlt. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Siehe auch
|
||||||
|
|
||||||
|
- [Anycubic-Stack](Anycubic-Stack.md) — MQTT-Start/Stop, 10101, kein Firmware-Restart
|
||||||
|
- [Senden-Queue](Senden-Queue.md) — `.acm`, kein Community-ACE-Makro
|
||||||
|
- [Abraeumen](Abraeumen.md) — K3 Farm-Scripts statt Druckermakro
|
||||||
|
- [ACE-Live](ACE-Live.md) — Live-Mapping und Trailer
|
||||||
@@ -27,7 +27,7 @@ Jedes Ereignis landet in `events` und auf der Seite `/aktivitaet`. Telegram ist
|
|||||||
| `fertig` | Watcher, `printing`→`complete` | nein, Platte unter „Aufgaben“ | an |
|
| `fertig` | Watcher, `printing`→`complete` | nein, Platte unter „Aufgaben“ | an |
|
||||||
| `fehler` | Watcher, Zustand `error` | ja | an |
|
| `fehler` | Watcher, Zustand `error` | ja | an |
|
||||||
| `abbruch` | Watcher, `printing`/`paused`→`cancelled`/`standby` | ja | wie `fehler` |
|
| `abbruch` | Watcher, `printing`/`paused`→`cancelled`/`standby` | ja | wie `fehler` |
|
||||||
| `offline` | Watcher, ≥2 Polls weg; auch „wieder erreichbar“ | ja | an |
|
| `offline` | Watcher, ≥2 Polls weg; auch „wieder erreichbar“. Beendet den Lauf nicht und zieht nichts ab — der letzte Druckzustand bleibt gemerkt | ja | an |
|
||||||
| `spule` | Watcher, Rest unter Job-Gewicht | ja | an |
|
| `spule` | Watcher, Rest unter Job-Gewicht | ja | an |
|
||||||
| `queue` | Dispatcher: Queue-Fehler, Ablauf gestoppt | ja | an |
|
| `queue` | Dispatcher: Queue-Fehler, Ablauf gestoppt | ja | an |
|
||||||
| `start` | Watcher: Druck startet, Pause, Fortsetzen | nein | aus |
|
| `start` | Watcher: Druck startet, Pause, Fortsetzen | nein | aus |
|
||||||
|
|||||||
@@ -3,3 +3,11 @@
|
|||||||
Diese Dateien sind für Entwicklung und Agenten — **nicht** unter `/doku` in der App.
|
Diese Dateien sind für Entwicklung und Agenten — **nicht** unter `/doku` in der App.
|
||||||
|
|
||||||
Nutzer-Handbuch: `docs/Anleitung-*.md`.
|
Nutzer-Handbuch: `docs/Anleitung-*.md`.
|
||||||
|
|
||||||
|
| Datei | Inhalt |
|
||||||
|
| --- | --- |
|
||||||
|
| [API.md](API.md) | HTTP-API für App/Integrationen (Auth, confirm, SSE, Filter) |
|
||||||
|
| [openapi.yaml](openapi.yaml) | OpenAPI-Skizze (Client-Generator) |
|
||||||
|
| [MCP.md](MCP.md) | MCP-Server und Tool-Ablauf |
|
||||||
|
| [Makros-Kobra.md](Makros-Kobra.md) | G-code/Makros Kobra S1 und 3: Nicht-tun, Vanilla-Alltag, ACE, LeviQ/Wipe |
|
||||||
|
| [Anycubic-Stack.md](Anycubic-Stack.md) | GoKlipper, MQTT-Start/Stop, ACE-Kanäle |
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Grotti API
|
||||||
|
version: "1"
|
||||||
|
description: |
|
||||||
|
JSON unter /api/v1. Auth: Session-Cookie oder Bearer grt_….
|
||||||
|
Mutierende Session-Calls brauchen Header X-CSRF-Token.
|
||||||
|
Siehe docs/internal/API.md.
|
||||||
|
servers:
|
||||||
|
- url: /api/v1
|
||||||
|
paths:
|
||||||
|
/version:
|
||||||
|
get:
|
||||||
|
summary: Server-Info
|
||||||
|
security: []
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: name, version, api, features
|
||||||
|
/auth/login:
|
||||||
|
post:
|
||||||
|
summary: Session-Login
|
||||||
|
security: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [username, password]
|
||||||
|
properties:
|
||||||
|
username: { type: string }
|
||||||
|
password: { type: string }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: user + csrf
|
||||||
|
/auth/token:
|
||||||
|
post:
|
||||||
|
summary: Passwort → API-Token (App)
|
||||||
|
security: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [username, password]
|
||||||
|
properties:
|
||||||
|
username: { type: string }
|
||||||
|
password: { type: string }
|
||||||
|
device_name: { type: string }
|
||||||
|
role: { type: string, enum: [operator, admin] }
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: token (einmalig) + user
|
||||||
|
/drucker:
|
||||||
|
get:
|
||||||
|
summary: Druckerliste
|
||||||
|
parameters:
|
||||||
|
- name: live
|
||||||
|
in: query
|
||||||
|
schema: { type: integer, enum: [0, 1] }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Array Drucker
|
||||||
|
/drucker/{host}/{action}:
|
||||||
|
post:
|
||||||
|
summary: Pause / Fortsetzen / Abbrechen
|
||||||
|
parameters:
|
||||||
|
- name: host
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
- name: action
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [pause, fortsetzen, abbrechen, resume, cancel]
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
/dateien:
|
||||||
|
get:
|
||||||
|
summary: Bibliothek
|
||||||
|
parameters:
|
||||||
|
- { name: family, in: query, schema: { type: string } }
|
||||||
|
- { name: material, in: query, schema: { type: string } }
|
||||||
|
- { name: project_id, in: query, schema: { type: integer } }
|
||||||
|
- { name: host, in: query, schema: { type: string } }
|
||||||
|
- { name: q, in: query, schema: { type: string } }
|
||||||
|
- { name: limit, in: query, schema: { type: integer } }
|
||||||
|
- { name: offset, in: query, schema: { type: integer } }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Array Dateien; Header X-Total-Count
|
||||||
|
headers:
|
||||||
|
X-Total-Count:
|
||||||
|
schema: { type: integer }
|
||||||
|
/senden:
|
||||||
|
post:
|
||||||
|
summary: Druck starten oder einreihen
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [file_id, confirm]
|
||||||
|
properties:
|
||||||
|
file_id: { type: integer }
|
||||||
|
hosts: { type: array, items: { type: string } }
|
||||||
|
confirm: { type: boolean }
|
||||||
|
when_free: { type: boolean }
|
||||||
|
ace_maps: { type: object }
|
||||||
|
bed_leveling: { type: boolean }
|
||||||
|
responses:
|
||||||
|
"202":
|
||||||
|
description: jobs
|
||||||
|
/druck-queue:
|
||||||
|
get:
|
||||||
|
summary: Warteschlange
|
||||||
|
parameters:
|
||||||
|
- { name: host, in: query, schema: { type: string } }
|
||||||
|
- { name: status, in: query, schema: { type: string } }
|
||||||
|
- { name: limit, in: query, schema: { type: integer } }
|
||||||
|
- { name: offset, in: query, schema: { type: integer } }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Array; X-Total-Count
|
||||||
|
post:
|
||||||
|
summary: Einreihen
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [file_id, confirm]
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Queue-Eintrag
|
||||||
|
/projekte/{id}/einreihen:
|
||||||
|
post:
|
||||||
|
summary: Projektdateien einreihen
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: integer }
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [confirm]
|
||||||
|
properties:
|
||||||
|
confirm: { type: boolean }
|
||||||
|
file_ids: { type: array, items: { type: integer } }
|
||||||
|
menge: { type: integer }
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: jobs
|
||||||
|
/stream:
|
||||||
|
get:
|
||||||
|
summary: SSE Live-Events
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: text/event-stream (drucker, queue, senden, ereignis, ping)
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: grt_
|
||||||
|
cookieAuth:
|
||||||
|
type: apiKey
|
||||||
|
in: cookie
|
||||||
|
name: session
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- cookieAuth: []
|
||||||
+33
-4
@@ -1,6 +1,7 @@
|
|||||||
"""Host, Port, Datenordner und Datenbank kommen aus config.json."""
|
"""Host, Port, Datenordner und Datenbank kommen aus config.json."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -9,11 +10,24 @@ 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"
|
|
||||||
DEFAULT_WATCH_REL = "slice"
|
DEFAULT_WATCH_REL = "slice"
|
||||||
_SETTINGS_LOCK = threading.Lock()
|
_SETTINGS_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def config_path():
|
||||||
|
"""GROTTI_CONFIG, sonst /etc/grotti/config.json (Paket), sonst Repo-config.json."""
|
||||||
|
env = (os.environ.get("GROTTI_CONFIG") or "").strip()
|
||||||
|
if env:
|
||||||
|
return Path(env)
|
||||||
|
etc = Path("/etc/grotti/config.json")
|
||||||
|
if etc.is_file():
|
||||||
|
return etc
|
||||||
|
return ROOT / "config.json"
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG_PATH = config_path()
|
||||||
|
|
||||||
|
|
||||||
def _tariff(raw):
|
def _tariff(raw):
|
||||||
tariff = raw.get("tariff") if isinstance(raw, dict) else None
|
tariff = raw.get("tariff") if isinstance(raw, dict) else None
|
||||||
if not isinstance(tariff, dict) or not tariff:
|
if not isinstance(tariff, dict) or not tariff:
|
||||||
@@ -110,6 +124,14 @@ def _oauth(raw):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _http_threads(raw):
|
||||||
|
try:
|
||||||
|
count = int(raw.get("http_threads") or 16)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
count = 16
|
||||||
|
return max(2, min(32, count))
|
||||||
|
|
||||||
|
|
||||||
def _ensure_secret_key(path, raw):
|
def _ensure_secret_key(path, raw):
|
||||||
key = str(raw.get("secret_key") or "").strip()
|
key = str(raw.get("secret_key") or "").strip()
|
||||||
if key:
|
if key:
|
||||||
@@ -121,9 +143,11 @@ def _ensure_secret_key(path, raw):
|
|||||||
|
|
||||||
|
|
||||||
def load():
|
def load():
|
||||||
path = CONFIG_PATH
|
path = config_path()
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
path.write_text((ROOT / "config.example.json").read_text(encoding="utf-8"), encoding="utf-8")
|
example = ROOT / "config.example.json"
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(example.read_text(encoding="utf-8"), encoding="utf-8")
|
||||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
host = (raw.get("host") or "127.0.0.1").strip()
|
host = (raw.get("host") or "127.0.0.1").strip()
|
||||||
token = raw.get("token") or ""
|
token = raw.get("token") or ""
|
||||||
@@ -139,6 +163,9 @@ def load():
|
|||||||
if not data_dir.is_absolute():
|
if not data_dir.is_absolute():
|
||||||
data_dir = ROOT / data_dir
|
data_dir = ROOT / data_dir
|
||||||
watch_dir, _stored = parse_watch_dir(raw.get("watch_dir") or "", create=False, missing_ok=True)
|
watch_dir, _stored = parse_watch_dir(raw.get("watch_dir") or "", create=False, missing_ok=True)
|
||||||
|
printers_file = Path(raw.get("printers_file") or (ROOT / "drucker.json"))
|
||||||
|
if not printers_file.is_absolute():
|
||||||
|
printers_file = ROOT / printers_file
|
||||||
return {
|
return {
|
||||||
"host": host,
|
"host": host,
|
||||||
"port": int(raw.get("port") or 8770),
|
"port": int(raw.get("port") or 8770),
|
||||||
@@ -149,13 +176,15 @@ def load():
|
|||||||
"secret_key": secret_key,
|
"secret_key": secret_key,
|
||||||
"oauth": _oauth(raw),
|
"oauth": _oauth(raw),
|
||||||
"fqdn": normalize_fqdn(raw.get("fqdn") or ""),
|
"fqdn": normalize_fqdn(raw.get("fqdn") or ""),
|
||||||
"printers_file": ROOT / "drucker.json",
|
"printers_file": printers_file,
|
||||||
|
"log_level": str(raw.get("log_level") or "INFO").strip() or "INFO",
|
||||||
"energy": _energy(raw),
|
"energy": _energy(raw),
|
||||||
"telegram": _telegram(raw),
|
"telegram": _telegram(raw),
|
||||||
"ntfy": _ntfy(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)),
|
||||||
"auto_retry": bool(raw.get("auto_retry", True)),
|
"auto_retry": bool(raw.get("auto_retry", True)),
|
||||||
|
"http_threads": _http_threads(raw),
|
||||||
"_raw": raw,
|
"_raw": raw,
|
||||||
"_path": path,
|
"_path": path,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ def _yesterday_stats(conn):
|
|||||||
|
|
||||||
def build_text(conn, watcher_latest=None, printers=None):
|
def build_text(conn, watcher_latest=None, printers=None):
|
||||||
stats = _yesterday_stats(conn)
|
stats = _yesterday_stats(conn)
|
||||||
waiting = len([j for j in store.list_print_queue(conn, limit=200) if j.get("status") == "waiting"])
|
waiting = len([
|
||||||
|
j for j in store.list_print_queue(conn, limit=200)["items"]
|
||||||
|
if j.get("status") == "waiting"
|
||||||
|
])
|
||||||
eta = jetzt_mod.farm_eta([
|
eta = jetzt_mod.farm_eta([
|
||||||
{"remain": (live or {}).get("remain") or 0}
|
{"remain": (live or {}).get("remain") or 0}
|
||||||
for live in (watcher_latest or [])
|
for live in (watcher_latest or [])
|
||||||
|
|||||||
+5
-1
@@ -13,6 +13,10 @@ from psycopg import sql
|
|||||||
from psycopg.rows import dict_row
|
from psycopg.rows import dict_row
|
||||||
from psycopg_pool import ConnectionPool
|
from psycopg_pool import ConnectionPool
|
||||||
|
|
||||||
|
from grotti import logutil
|
||||||
|
|
||||||
|
log = logutil.get("grotti.db")
|
||||||
|
|
||||||
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||||
SCHEMA_LEGACY = Path(__file__).parent / "schema.sql"
|
SCHEMA_LEGACY = Path(__file__).parent / "schema.sql"
|
||||||
|
|
||||||
@@ -123,7 +127,7 @@ def prepare(url):
|
|||||||
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 as exc:
|
except psycopg.Error as exc:
|
||||||
print("Datenbank anlegen: %s" % exc, flush=True)
|
log.warning("Datenbank anlegen: %s", exc)
|
||||||
try:
|
try:
|
||||||
with psycopg.connect(url, row_factory=dict_row, connect_timeout=5) as conn:
|
with psycopg.connect(url, row_factory=dict_row, connect_timeout=5) as conn:
|
||||||
apply_migrations(conn)
|
apply_migrations(conn)
|
||||||
|
|||||||
+6
-2
@@ -111,7 +111,7 @@ def build(conn, watcher_latest, printers=None, send_jobs=None):
|
|||||||
"SELECT DISTINCT host FROM print_queue WHERE status = 'running'"
|
"SELECT DISTINCT host FROM print_queue WHERE status = 'running'"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
}
|
}
|
||||||
queue = store.list_print_queue(conn, limit=80)
|
queue = store.list_print_queue(conn, limit=80)["items"]
|
||||||
waiting = [j for j in queue if j.get("status") == "waiting"]
|
waiting = [j for j in queue if j.get("status") == "waiting"]
|
||||||
errors = [j for j in queue if j.get("status") == "error"]
|
errors = [j for j in queue if j.get("status") == "error"]
|
||||||
running_q = [j for j in queue if j.get("status") == "running"]
|
running_q = [j for j in queue if j.get("status") == "running"]
|
||||||
@@ -255,13 +255,17 @@ def build(conn, watcher_latest, printers=None, send_jobs=None):
|
|||||||
live_by_host[host]["display_step_index"] = job.get("display_step_index")
|
live_by_host[host]["display_step_index"] = job.get("display_step_index")
|
||||||
live_by_host[host]["step_index"] = job.get("step_index")
|
live_by_host[host]["step_index"] = job.get("step_index")
|
||||||
else:
|
else:
|
||||||
|
phase = (job.get("phase") or "").lower()
|
||||||
|
starting = phase in ("starting", "await_print")
|
||||||
running.append({
|
running.append({
|
||||||
"host": host,
|
"host": host,
|
||||||
"name": job.get("printer_name") or host,
|
"name": job.get("printer_name") or host,
|
||||||
"state": "running",
|
"state": "starting" if starting else "running",
|
||||||
|
"starting": starting,
|
||||||
"progress": job.get("progress") or 0,
|
"progress": job.get("progress") or 0,
|
||||||
"remain": job.get("remain") or 0,
|
"remain": job.get("remain") or 0,
|
||||||
"filename": job.get("filename") or "",
|
"filename": job.get("filename") or "",
|
||||||
|
"message": job.get("message") or "",
|
||||||
"queue_id": job.get("id"),
|
"queue_id": job.get("id"),
|
||||||
"is_ablauf": bool(job.get("is_ablauf")),
|
"is_ablauf": bool(job.get("is_ablauf")),
|
||||||
})
|
})
|
||||||
|
|||||||
+550
-132
@@ -5,7 +5,7 @@ import urllib.error
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from grotti import acm, catalog, eject, library, moonraker, store, usage
|
from grotti import acm, catalog, eject, library, logutil, moonraker, store, usage
|
||||||
from grotti.cost import energy_cost
|
from grotti.cost import energy_cost
|
||||||
from grotti.errors import GrottiError
|
from grotti.errors import GrottiError
|
||||||
from grotti.parse import content_has_ace_trailer, prepare_rinkhals_gcode
|
from grotti.parse import content_has_ace_trailer, prepare_rinkhals_gcode
|
||||||
@@ -39,12 +39,12 @@ def _ace_map_of(sent):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _targets_from_ace(conn, host, ace):
|
def _targets_from_ace(conn, host, ace, ended_at=None):
|
||||||
targets = []
|
targets = []
|
||||||
for item in ace:
|
for item in ace:
|
||||||
slot = int(item["ams_index"]) + 1
|
slot = int(item["ams_index"]) + 1
|
||||||
row = store.spool_at_slot(conn, host, slot)
|
row = store.spool_at_slot(conn, host, slot)
|
||||||
if row:
|
if row and _spool_valid_for_end(row, ended_at):
|
||||||
targets.append(row)
|
targets.append(row)
|
||||||
return targets
|
return targets
|
||||||
|
|
||||||
@@ -55,6 +55,398 @@ def unix_to_dt(value):
|
|||||||
return datetime.fromtimestamp(float(value), timezone.utc)
|
return datetime.fromtimestamp(float(value), timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
# Anycubic schließt bei Filament-Pause den History-Job (cancelled) und öffnet
|
||||||
|
# beim Fortsetzen eine neue Job-ID. Zusammengelegt wird nur mit Beweis:
|
||||||
|
# print_duration läuft weiter und die Lücke ist klein, oder der Watcher
|
||||||
|
# hat paused→printing gesehen. Ein Neustart mit Dauer nahe 0 bleibt getrennt.
|
||||||
|
RESUME_GAP_S = 5
|
||||||
|
RESUME_DURATION_TOL_S = 30
|
||||||
|
RESUME_FRESH_MAX_S = 60
|
||||||
|
# Abwärtskompatibel für alte Aufrufe / Docs.
|
||||||
|
RESUME_JOIN_S = RESUME_GAP_S
|
||||||
|
LIVE_DEDUCT_FALLBACK_S = 600
|
||||||
|
SYNC_LOCK_KEY = 748291053
|
||||||
|
|
||||||
|
|
||||||
|
def _as_utc(value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if getattr(value, "tzinfo", None) is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def history_shell_is_empty(run_or_job):
|
||||||
|
"""History-Job ohne Ende und ohne Dauer. Nie als eigenen Lauf speichern."""
|
||||||
|
if not run_or_job:
|
||||||
|
return False
|
||||||
|
duration = float(
|
||||||
|
run_or_job.get("duration_s")
|
||||||
|
or run_or_job.get("print_duration")
|
||||||
|
or run_or_job.get("total_duration")
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
ended = run_or_job.get("ended_at") or run_or_job.get("end_time")
|
||||||
|
return duration <= 0 and not ended
|
||||||
|
|
||||||
|
|
||||||
|
def is_filament_resume(
|
||||||
|
prev_end,
|
||||||
|
new_start,
|
||||||
|
prev_duration_s=None,
|
||||||
|
new_duration_s=None,
|
||||||
|
watcher_resume=False,
|
||||||
|
gap_s=RESUME_GAP_S,
|
||||||
|
):
|
||||||
|
"""True nur mit Beweis: Dauer fortgesetzt + kleine Lücke, oder Watcher-Signal.
|
||||||
|
|
||||||
|
Aufruf mit zwei Zeitpunkten (alt): nur Lücke — gilt nicht mehr als Resume.
|
||||||
|
"""
|
||||||
|
prev_end = _as_utc(prev_end)
|
||||||
|
new_start = _as_utc(new_start)
|
||||||
|
if prev_end is None or new_start is None:
|
||||||
|
return False
|
||||||
|
gap = (new_start - prev_end).total_seconds()
|
||||||
|
if not (-30 <= gap <= max(float(gap_s), RESUME_GAP_S)):
|
||||||
|
# Watcher-Signal: Lücke darf länger sein (Pause vor dem Resume-Ende).
|
||||||
|
if not watcher_resume or gap > 24 * 3600 or gap < -30:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
if watcher_resume:
|
||||||
|
return True
|
||||||
|
# Zwei-Argument-Aufruf (Tests/alt): ohne Dauer kein Beweis → kein Resume.
|
||||||
|
if prev_duration_s is None and new_duration_s is None:
|
||||||
|
return False
|
||||||
|
prev_d = float(prev_duration_s or 0)
|
||||||
|
new_d = float(new_duration_s or 0)
|
||||||
|
# Frischer Neustart: Dauer beginnt nahe 0 — nie zusammenlegen.
|
||||||
|
if new_d <= RESUME_FRESH_MAX_S:
|
||||||
|
return False
|
||||||
|
if new_d < max(0.0, prev_d - RESUME_DURATION_TOL_S):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def undo_deduction(conn, run):
|
||||||
|
"""Spulenabzug eines Laufs zurücknehmen.
|
||||||
|
|
||||||
|
Letzten Abschnitt wieder öffnen — nach Filamentpause schließt ein
|
||||||
|
vorzeitiger History-Abgleich den Abschnitt, bevor der Druck weiterläuft.
|
||||||
|
"""
|
||||||
|
if not run or not run.get("deducted"):
|
||||||
|
return False
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT spool_id, weight_g FROM run_spools WHERE run_id = %s",
|
||||||
|
(run["id"],),
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
grams = float(row["weight_g"] or 0)
|
||||||
|
if grams and row["spool_id"]:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE spools SET remaining_g = remaining_g + %s WHERE id = %s",
|
||||||
|
(grams, row["spool_id"]),
|
||||||
|
)
|
||||||
|
conn.execute("DELETE FROM run_spools WHERE run_id = %s", (run["id"],))
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE runs SET deducted = false, filament_cost = 0 WHERE id = %s",
|
||||||
|
(run["id"],),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE run_segments
|
||||||
|
SET mm_end = NULL, closed_at = NULL
|
||||||
|
WHERE id = (
|
||||||
|
SELECT id FROM run_segments
|
||||||
|
WHERE run_id = %s
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
AND mm_end IS NOT NULL
|
||||||
|
""",
|
||||||
|
(run["id"],),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def fold_filament_resume(
|
||||||
|
conn,
|
||||||
|
host,
|
||||||
|
filename,
|
||||||
|
job_id,
|
||||||
|
start_dt,
|
||||||
|
print_duration_s=0,
|
||||||
|
watcher_resume=False,
|
||||||
|
):
|
||||||
|
"""Fortsetzung nach Filament-Pause auf den bestehenden Lauf legen.
|
||||||
|
|
||||||
|
Gibt die Lauf-ID zurück, deren moonraker_job_id jetzt job_id ist.
|
||||||
|
Leere Hüllen (Dauer 0, kein Ende) werden vorher übersprungen — nicht hier.
|
||||||
|
"""
|
||||||
|
pred = store.find_resume_predecessor(
|
||||||
|
conn, host, filename, job_id, start_dt, watcher_resume=watcher_resume
|
||||||
|
)
|
||||||
|
if not pred:
|
||||||
|
return None
|
||||||
|
if not is_filament_resume(
|
||||||
|
pred.get("ended_at"),
|
||||||
|
start_dt,
|
||||||
|
prev_duration_s=pred.get("duration_s"),
|
||||||
|
new_duration_s=print_duration_s,
|
||||||
|
watcher_resume=watcher_resume,
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
other = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM runs
|
||||||
|
WHERE printer_host = %s AND moonraker_job_id = %s AND id <> %s
|
||||||
|
""",
|
||||||
|
(host, str(job_id), pred["id"]),
|
||||||
|
).fetchone()
|
||||||
|
store.remember_run_job(conn, host, pred.get("moonraker_job_id"), pred["id"], replace=True)
|
||||||
|
undo_deduction(conn, pred)
|
||||||
|
kept = None
|
||||||
|
if other:
|
||||||
|
undo_deduction(conn, other)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE events SET run_id = %s WHERE run_id = %s",
|
||||||
|
(pred["id"], other["id"]),
|
||||||
|
)
|
||||||
|
if not (pred.get("verdict") or "").strip() and (other.get("verdict") or "").strip():
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE runs
|
||||||
|
SET verdict = %s, verdict_reason = %s, pieces_ok = %s, pieces_bad = %s
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
other.get("verdict") or "",
|
||||||
|
other.get("verdict_reason") or "",
|
||||||
|
other.get("pieces_ok") or 0,
|
||||||
|
other.get("pieces_bad") or 0,
|
||||||
|
pred["id"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
kept = {
|
||||||
|
"state": other.get("state") or "completed",
|
||||||
|
"ended_at": other.get("ended_at"),
|
||||||
|
"duration_s": float(other.get("duration_s") or 0),
|
||||||
|
"weight_used_g": float(other.get("weight_used_g") or 0),
|
||||||
|
"weight_source": other.get("weight_source") or "",
|
||||||
|
"filament_used_mm": float(other.get("filament_used_mm") or 0),
|
||||||
|
"filament_total_mm": float(other.get("filament_total_mm") or 0),
|
||||||
|
"progress_last": float(other.get("progress_last") or 0),
|
||||||
|
}
|
||||||
|
for table in ("run_samples", "run_segments"):
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE %s SET run_id = %%s WHERE run_id = %%s" % table,
|
||||||
|
(pred["id"], other["id"]),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(other["id"], pred["id"]),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE run_labels SET run_id = %s WHERE run_id = %s",
|
||||||
|
(pred["id"], other["id"]),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logutil.get("grotti.jobs").warning(
|
||||||
|
"Etiketten von Lauf %s nicht übernommen.", other["id"]
|
||||||
|
)
|
||||||
|
conn.execute("DELETE FROM run_spools WHERE run_id = %s", (other["id"],))
|
||||||
|
store.move_run_jobs(conn, host, other["id"], pred["id"])
|
||||||
|
# Job-ID freigeben, sonst Unique (printer_host, moonraker_job_id) beim Pred.
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE runs SET moonraker_job_id = %s WHERE id = %s",
|
||||||
|
("live:merged:%s" % other["id"], other["id"]),
|
||||||
|
)
|
||||||
|
conn.execute("DELETE FROM runs WHERE id = %s", (other["id"],))
|
||||||
|
# Der Druck läuft weiter. Das Ende kommt vom Folgejob.
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE runs
|
||||||
|
SET moonraker_job_id = %s,
|
||||||
|
ended_at = NULL,
|
||||||
|
state = CASE
|
||||||
|
WHEN %s IN ('in_progress', 'printing', 'paused') THEN %s
|
||||||
|
WHEN state IN ('cancelled', 'interrupted', 'error') THEN 'in_progress'
|
||||||
|
ELSE state
|
||||||
|
END
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
str(job_id),
|
||||||
|
(kept or {}).get("state") or "in_progress",
|
||||||
|
(kept or {}).get("state") or "in_progress",
|
||||||
|
pred["id"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
store.remember_run_job(conn, host, job_id, pred["id"], replace=True)
|
||||||
|
store.set_printer_active_run(conn, host, pred["id"])
|
||||||
|
if kept and kept.get("ended_at"):
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE runs SET
|
||||||
|
state = %s,
|
||||||
|
ended_at = COALESCE(%s, ended_at),
|
||||||
|
duration_s = GREATEST(COALESCE(duration_s, 0), %s),
|
||||||
|
weight_used_g = GREATEST(COALESCE(weight_used_g, 0), %s),
|
||||||
|
weight_source = CASE WHEN %s <> '' THEN %s ELSE weight_source END,
|
||||||
|
filament_used_mm = GREATEST(COALESCE(filament_used_mm, 0), %s),
|
||||||
|
filament_total_mm = CASE WHEN %s > 0 THEN %s ELSE filament_total_mm END,
|
||||||
|
progress_last = GREATEST(COALESCE(progress_last, 0), %s),
|
||||||
|
deducted = false
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
kept["state"],
|
||||||
|
kept["ended_at"],
|
||||||
|
kept["duration_s"],
|
||||||
|
kept["weight_used_g"],
|
||||||
|
kept["weight_source"],
|
||||||
|
kept["weight_source"],
|
||||||
|
kept["filament_used_mm"],
|
||||||
|
kept["filament_total_mm"],
|
||||||
|
kept["filament_total_mm"],
|
||||||
|
kept["progress_last"],
|
||||||
|
pred["id"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
elif print_duration_s:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE runs
|
||||||
|
SET duration_s = GREATEST(COALESCE(duration_s, 0), %s),
|
||||||
|
deducted = false
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(float(print_duration_s), pred["id"]),
|
||||||
|
)
|
||||||
|
return pred["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def history_in_order(items):
|
||||||
|
"""Älteste zuerst. Der abgebrochene Teil muss stehen, bevor die Fortsetzung kommt."""
|
||||||
|
return sorted(
|
||||||
|
items or [],
|
||||||
|
key=lambda job: (float(job.get("start_time") or 0), str(job.get("job_id") or "")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def manual_merge_runs(conn, keep_id, absorb_id, confirm=False):
|
||||||
|
"""Zwei Läufe manuell verbinden. keep überlebt, absorb wird übernommen."""
|
||||||
|
if not confirm:
|
||||||
|
raise GrottiError("Verbinden braucht eine Bestätigung.", "bestaetigung")
|
||||||
|
keep = store.get_run_row(conn, keep_id)
|
||||||
|
absorb = store.get_run_row(conn, absorb_id)
|
||||||
|
if not keep or not absorb:
|
||||||
|
raise GrottiError("Der Lauf fehlt.", "lauf", 404)
|
||||||
|
if keep["id"] == absorb["id"]:
|
||||||
|
raise GrottiError("Gleicher Lauf.", "lauf")
|
||||||
|
if (keep.get("printer_host") or "") != (absorb.get("printer_host") or ""):
|
||||||
|
raise GrottiError("Nur Läufe desselben Druckers.", "drucker")
|
||||||
|
if (keep.get("filename") or "") != (absorb.get("filename") or ""):
|
||||||
|
raise GrottiError("Nur Läufe derselben Datei.", "datei")
|
||||||
|
# Früherer Start bleibt der überlebende Lauf.
|
||||||
|
if keep.get("started_at") and absorb.get("started_at") and absorb["started_at"] < keep["started_at"]:
|
||||||
|
keep, absorb = absorb, keep
|
||||||
|
host = keep["printer_host"]
|
||||||
|
undo_deduction(conn, keep)
|
||||||
|
undo_deduction(conn, absorb)
|
||||||
|
snapshot = {
|
||||||
|
"keep_id": keep["id"],
|
||||||
|
"absorb_id": absorb["id"],
|
||||||
|
"absorb_job_id": absorb.get("moonraker_job_id"),
|
||||||
|
}
|
||||||
|
for table in ("run_samples", "run_segments", "run_spools", "events"):
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE %s SET run_id = %%s WHERE run_id = %%s" % table,
|
||||||
|
(keep["id"], absorb["id"]),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(absorb["id"], keep["id"]),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE run_labels SET run_id = %s WHERE run_id = %s",
|
||||||
|
(keep["id"], absorb["id"]),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
store.move_run_jobs(conn, host, absorb["id"], keep["id"])
|
||||||
|
job_id = absorb.get("moonraker_job_id") or keep.get("moonraker_job_id")
|
||||||
|
if job_id and not str(job_id).startswith("live:"):
|
||||||
|
store.remember_run_job(conn, host, job_id, keep["id"], replace=True)
|
||||||
|
# Job-ID am absorb freigeben, sonst Unique beim UPDATE auf keep.
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE runs SET moonraker_job_id = %s WHERE id = %s",
|
||||||
|
("live:merged:%s" % absorb["id"], absorb["id"]),
|
||||||
|
)
|
||||||
|
absorb_terminal = (absorb.get("state") or "") in TERMINAL
|
||||||
|
keep_open = (keep.get("state") or "") in ("in_progress", "printing", "paused")
|
||||||
|
new_state = absorb["state"] if absorb_terminal and not keep_open else (
|
||||||
|
absorb["state"] if not keep_open else keep["state"]
|
||||||
|
)
|
||||||
|
if keep_open or (absorb.get("state") or "") in ("in_progress", "printing", "paused"):
|
||||||
|
new_state = absorb["state"] if (absorb.get("state") or "") in ("in_progress", "printing", "paused") else "in_progress"
|
||||||
|
ended = None
|
||||||
|
else:
|
||||||
|
ended = absorb.get("ended_at") or keep.get("ended_at")
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE runs SET
|
||||||
|
moonraker_job_id = COALESCE(NULLIF(%s, ''), moonraker_job_id),
|
||||||
|
state = %s,
|
||||||
|
ended_at = %s,
|
||||||
|
duration_s = GREATEST(COALESCE(duration_s, 0), COALESCE(%s, 0)),
|
||||||
|
weight_used_g = GREATEST(COALESCE(weight_used_g, 0), COALESCE(%s, 0)),
|
||||||
|
filament_used_mm = GREATEST(COALESCE(filament_used_mm, 0), COALESCE(%s, 0)),
|
||||||
|
progress_last = GREATEST(COALESCE(progress_last, 0), COALESCE(%s, 0)),
|
||||||
|
deducted = false
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
str(job_id or ""),
|
||||||
|
new_state,
|
||||||
|
ended,
|
||||||
|
float(absorb.get("duration_s") or 0),
|
||||||
|
float(absorb.get("weight_used_g") or 0),
|
||||||
|
float(absorb.get("filament_used_mm") or 0),
|
||||||
|
float(absorb.get("progress_last") or 0),
|
||||||
|
keep["id"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE print_queue SET run_id = %s WHERE run_id = %s",
|
||||||
|
(keep["id"], absorb["id"]),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
conn.execute("DELETE FROM runs WHERE id = %s", (absorb["id"],))
|
||||||
|
store.set_printer_active_run(
|
||||||
|
conn, host, keep["id"] if new_state in ("in_progress", "printing", "paused") else None
|
||||||
|
)
|
||||||
|
fresh = store.get_run_row(conn, keep["id"])
|
||||||
|
return {"run": store.run_out(fresh), "undo": snapshot}
|
||||||
|
|
||||||
|
|
||||||
def choose_spool(conn, host, material, hinted_id, ended_at=None):
|
def choose_spool(conn, host, material, hinted_id, ended_at=None):
|
||||||
"""Spule für History-Abzug. Ohne Hint nur Rollen, die vor Druckende existierten."""
|
"""Spule für History-Abzug. Ohne Hint nur Rollen, die vor Druckende existierten."""
|
||||||
at_host = [
|
at_host = [
|
||||||
@@ -196,23 +588,28 @@ def apply_deduction(conn, run, energy, metadata=None):
|
|||||||
|
|
||||||
store.close_open_segments(conn, run["id"], None, run.get("filament_used_mm") or 0)
|
store.close_open_segments(conn, run["id"], None, run.get("filament_used_mm") or 0)
|
||||||
segments = store.list_run_segments(conn, run["id"])
|
segments = store.list_run_segments(conn, run["id"])
|
||||||
|
ended = run.get("ended_at")
|
||||||
|
|
||||||
entries = []
|
entries = []
|
||||||
targets = _targets_from_ace(conn, run["printer_host"], ace) if ace else []
|
targets = _targets_from_ace(conn, run["printer_host"], ace, ended_at=ended) if ace else []
|
||||||
if not targets and run.get("spool_id"):
|
if not targets and run.get("spool_id"):
|
||||||
spool = store.get_spool_row(conn, run["spool_id"])
|
spool = store.get_spool_row(conn, run["spool_id"])
|
||||||
if spool:
|
if spool and _spool_valid_for_end(spool, ended):
|
||||||
targets = [spool]
|
targets = [spool]
|
||||||
# Abschnitte ohne ACE-Map: externe Rollen nacheinander
|
# Abschnitte ohne ACE-Map: externe Rollen nacheinander
|
||||||
if segments and not ace:
|
if segments and not ace:
|
||||||
final_mm = float(run.get("filament_used_mm") or 0)
|
final_mm = float(run.get("filament_used_mm") or 0)
|
||||||
total_mm = float(run.get("filament_total_mm") or 0)
|
total_mm = float(run.get("filament_total_mm") or 0)
|
||||||
deltas = usage.segment_deltas(segments, final_mm)
|
deltas = usage.segment_deltas(segments, final_mm)
|
||||||
|
# Messwert (oder Soll) anteilig nach mm — nicht Soll gegen Soll-Gesamtlänge,
|
||||||
|
# sonst fehlen Gramm, wenn ein Abschnitt zu früh geschlossen wurde.
|
||||||
|
base_g = used if used > 0 else planned
|
||||||
|
split_mm = sum(d for _, _, d in deltas) or final_mm or total_mm
|
||||||
for spool_id, ace_slot, delta_mm in deltas:
|
for spool_id, ace_slot, delta_mm in deltas:
|
||||||
spool = store.get_spool_row(conn, spool_id)
|
spool = store.get_spool_row(conn, spool_id)
|
||||||
if not spool:
|
if not spool:
|
||||||
continue
|
continue
|
||||||
grams = usage.grams_from_mm(delta_mm, total_mm, planned)
|
grams = usage.grams_from_mm(delta_mm, split_mm, base_g)
|
||||||
cost = (grams / 1000.0) * float(spool.get("price_per_kg") or 0)
|
cost = (grams / 1000.0) * float(spool.get("price_per_kg") or 0)
|
||||||
entries.append({
|
entries.append({
|
||||||
"spool_id": spool_id,
|
"spool_id": spool_id,
|
||||||
@@ -291,8 +688,10 @@ def apply_deduction(conn, run, energy, metadata=None):
|
|||||||
filament = sum(item["cost"] for item in entries)
|
filament = sum(item["cost"] for item in entries)
|
||||||
samples = store.list_run_samples(conn, run["id"])
|
samples = store.list_run_samples(conn, run["id"])
|
||||||
power = energy_cost(run["duration_s"], energy, samples)
|
power = energy_cost(run["duration_s"], energy, samples)
|
||||||
# run_spools vor deducted=true, sonst bleibt die Akte leer bei Abbruch dazwischen
|
weight_source = source or (
|
||||||
store.replace_run_spools(conn, run["id"], entries)
|
"estimate" if any(e["is_estimate"] for e in entries) else "measured"
|
||||||
|
)
|
||||||
|
# Zuerst deducted=true — nur der Gewinner schreibt Akte und zieht ab.
|
||||||
marked = store.mark_deducted(
|
marked = store.mark_deducted(
|
||||||
conn,
|
conn,
|
||||||
run["id"],
|
run["id"],
|
||||||
@@ -300,11 +699,11 @@ def apply_deduction(conn, run, energy, metadata=None):
|
|||||||
filament,
|
filament,
|
||||||
power,
|
power,
|
||||||
weight_used_g=used_total,
|
weight_used_g=used_total,
|
||||||
weight_source=source or ("estimate" if any(e["is_estimate"] for e in entries) else "measured"),
|
weight_source=weight_source,
|
||||||
)
|
)
|
||||||
if not marked:
|
if not marked:
|
||||||
store.replace_run_spools(conn, run["id"], [])
|
|
||||||
return False
|
return False
|
||||||
|
store.replace_run_spools(conn, run["id"], entries)
|
||||||
for item in entries:
|
for item in entries:
|
||||||
store.deduct_spool(conn, item["spool_id"], item["weight_g"])
|
store.deduct_spool(conn, item["spool_id"], item["weight_g"])
|
||||||
store.ensure_run_chargen(conn, run["id"], used_total)
|
store.ensure_run_chargen(conn, run["id"], used_total)
|
||||||
@@ -318,141 +717,160 @@ def apply_deduction(conn, run, energy, metadata=None):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def sync_history(conn, cfg):
|
def _sync_one_job(conn, cfg, printer, job, watcher_resume=False):
|
||||||
|
"""Einen History-Job verarbeiten. Gibt 1 zurück wenn abgezogen wurde."""
|
||||||
|
job_id = str(job.get("job_id") or "")
|
||||||
|
if not job_id or str(job_id).startswith("live:"):
|
||||||
|
return 0
|
||||||
|
filename = job.get("filename") or ""
|
||||||
|
start_dt = unix_to_dt(job.get("start_time"))
|
||||||
|
end_dt = unix_to_dt(job.get("end_time"))
|
||||||
|
duration_s = float(job.get("print_duration") or job.get("total_duration") or 0)
|
||||||
|
state = job.get("status") or ""
|
||||||
|
# Leere Hülle: nie speichern. Sobald Dauer da ist, gilt die Beweis-Regel.
|
||||||
|
if history_shell_is_empty({
|
||||||
|
"duration_s": duration_s,
|
||||||
|
"ended_at": end_dt,
|
||||||
|
"print_duration": duration_s,
|
||||||
|
"end_time": end_dt,
|
||||||
|
}) and state not in TERMINAL:
|
||||||
|
return 0
|
||||||
|
if store.history_job_already_folded(conn, printer["host"], filename, job_id, end_dt):
|
||||||
|
return 0
|
||||||
|
if store.history_job_retired(conn, printer["host"], job_id):
|
||||||
|
return 0
|
||||||
|
fold_filament_resume(
|
||||||
|
conn,
|
||||||
|
printer["host"],
|
||||||
|
filename,
|
||||||
|
job_id,
|
||||||
|
start_dt,
|
||||||
|
print_duration_s=duration_s,
|
||||||
|
watcher_resume=watcher_resume,
|
||||||
|
)
|
||||||
|
metadata = job.get("metadata") or {}
|
||||||
|
planned = weight_of(metadata)
|
||||||
|
material = material_of(metadata)
|
||||||
|
used_mm = usage.filament_used_mm(job)
|
||||||
|
total_mm = usage.filament_total_mm(metadata, job)
|
||||||
|
estimated = float(metadata.get("estimated_time") or 0)
|
||||||
|
sent = store.latest_send(conn, printer["host"], filename)
|
||||||
|
known = store.file_by_name(conn, filename)
|
||||||
|
file_id = (sent or {}).get("file_id") or (known or {}).get("id")
|
||||||
|
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")
|
||||||
|
ended_at = end_dt
|
||||||
|
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
|
||||||
|
live = store.find_live_run(conn, printer["host"], filename)
|
||||||
|
progress = float((live or {}).get("progress_last") or 0)
|
||||||
|
if used_mm <= 0 and live:
|
||||||
|
used_mm = float(live.get("filament_used_mm") or 0)
|
||||||
|
if total_mm <= 0 and live:
|
||||||
|
total_mm = float(live.get("filament_total_mm") or 0)
|
||||||
|
if planned <= 0 and live:
|
||||||
|
planned = float(live.get("weight_planned_g") or 0)
|
||||||
|
objects = (live or {}).get("objects") or []
|
||||||
|
if isinstance(objects, str):
|
||||||
|
try:
|
||||||
|
objects = json.loads(objects)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
objects = []
|
||||||
|
used_g, source = usage.resolve_used_grams(
|
||||||
|
planned, used_mm, total_mm, progress, state, duration_s
|
||||||
|
)
|
||||||
|
row = store.upsert_run(conn, {
|
||||||
|
"printer_host": printer["host"],
|
||||||
|
"printer_name": printer["name"],
|
||||||
|
"filename": filename,
|
||||||
|
"file_id": file_id,
|
||||||
|
"project_id": project_id,
|
||||||
|
"spool_id": spool["id"] if spool else None,
|
||||||
|
"state": state,
|
||||||
|
"started_at": start_dt,
|
||||||
|
"ended_at": ended_at,
|
||||||
|
"duration_s": duration_s,
|
||||||
|
"weight_g": planned,
|
||||||
|
"weight_planned_g": planned,
|
||||||
|
"weight_used_g": used_g,
|
||||||
|
"weight_source": source,
|
||||||
|
"filament_used_mm": used_mm,
|
||||||
|
"filament_total_mm": total_mm,
|
||||||
|
"progress_last": progress,
|
||||||
|
"estimated_s": estimated,
|
||||||
|
"energy_cost": energy_cost(duration_s, cfg["energy"]),
|
||||||
|
"moonraker_job_id": job_id,
|
||||||
|
"needs_spool": bool(terminal and used_g > 0 and not spool),
|
||||||
|
"material_type": material,
|
||||||
|
"message": (job.get("message") or "") if state == "error" else "",
|
||||||
|
"objects": objects,
|
||||||
|
})
|
||||||
|
store.adopt_live_run(conn, printer["host"], filename, row["id"])
|
||||||
|
row = store.get_run_row(conn, row["id"])
|
||||||
|
if not terminal:
|
||||||
|
store.set_printer_active_run(conn, printer["host"], row["id"])
|
||||||
|
else:
|
||||||
|
active = store.find_live_run(conn, printer["host"])
|
||||||
|
if not active:
|
||||||
|
ended = row.get("ended_at") or ended_at
|
||||||
|
store.ensure_bed_after_print(
|
||||||
|
conn, printer["host"], ended_at=ended, message=filename or state
|
||||||
|
)
|
||||||
|
store.set_printer_active_run(conn, printer["host"], None)
|
||||||
|
# Resume nach Filamentpause: History kann den alten Teil schon als „fertig“
|
||||||
|
# melden. Abzug und Segment-Schluss erst beim echten Druckende.
|
||||||
|
if watcher_resume:
|
||||||
|
return 0
|
||||||
|
if apply_deduction(conn, row, cfg["energy"], metadata=metadata):
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def deduct_stale_live_runs(conn, cfg, older_than_s=LIVE_DEDUCT_FALLBACK_S):
|
||||||
|
"""Live-Läufe ohne History nach Frist abziehen (Fertig-Pfad ohne History)."""
|
||||||
|
rows = store.list_stale_live_runs(conn, older_than_s=older_than_s)
|
||||||
|
n = 0
|
||||||
|
for row in rows:
|
||||||
|
if apply_deduction(conn, row, cfg["energy"]):
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def sync_history(conn, cfg, host=None, watcher_resume=False):
|
||||||
|
"""History abgleichen. Serialisiert über Session-Advisory-Lock."""
|
||||||
printers = store.list_printers(conn)
|
printers = store.list_printers(conn)
|
||||||
|
if host:
|
||||||
|
printers = [p for p in printers if p.get("host") == host]
|
||||||
|
|
||||||
def fetch(printer):
|
def fetch(printer):
|
||||||
return printer, moonraker.history(printer["host"])
|
return printer, moonraker.history(printer["host"])
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=max(1, len(printers))) as pool:
|
with ThreadPoolExecutor(max_workers=max(1, len(printers) or 1)) as pool:
|
||||||
fetched = list(pool.map(fetch, printers))
|
fetched = list(pool.map(fetch, printers)) if printers else []
|
||||||
collected = []
|
collected = []
|
||||||
for printer, items in fetched:
|
for printer, items in fetched:
|
||||||
for job in items:
|
for job in history_in_order(items):
|
||||||
collected.append((printer, job))
|
collected.append((printer, job))
|
||||||
deducted = 0
|
deducted = 0
|
||||||
for printer, job in collected:
|
conn.execute("SELECT pg_advisory_lock(%s)", (SYNC_LOCK_KEY,))
|
||||||
job_id = str(job.get("job_id") or "")
|
try:
|
||||||
if not job_id or str(job_id).startswith("live:"):
|
for printer, job in collected:
|
||||||
continue
|
deducted += _sync_one_job(
|
||||||
filename = job.get("filename") or ""
|
conn, cfg, printer, job, watcher_resume=watcher_resume
|
||||||
metadata = job.get("metadata") or {}
|
)
|
||||||
planned = weight_of(metadata)
|
if not watcher_resume:
|
||||||
material = material_of(metadata)
|
deducted += deduct_stale_live_runs(conn, cfg)
|
||||||
state = job.get("status") or ""
|
finally:
|
||||||
used_mm = usage.filament_used_mm(job)
|
conn.execute("SELECT pg_advisory_unlock(%s)", (SYNC_LOCK_KEY,))
|
||||||
total_mm = usage.filament_total_mm(metadata, job)
|
|
||||||
estimated = float(metadata.get("estimated_time") or 0)
|
|
||||||
sent = store.latest_send(conn, printer["host"], filename)
|
|
||||||
known = store.file_by_name(conn, filename)
|
|
||||||
file_id = (sent or {}).get("file_id") or (known or {}).get("id")
|
|
||||||
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")
|
|
||||||
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
|
|
||||||
live = store.find_live_run(conn, printer["host"], filename)
|
|
||||||
progress = float((live or {}).get("progress_last") or 0)
|
|
||||||
if used_mm <= 0 and live:
|
|
||||||
used_mm = float(live.get("filament_used_mm") or 0)
|
|
||||||
if total_mm <= 0 and live:
|
|
||||||
total_mm = float(live.get("filament_total_mm") or 0)
|
|
||||||
if planned <= 0 and live:
|
|
||||||
planned = float(live.get("weight_planned_g") or 0)
|
|
||||||
objects = (live or {}).get("objects") or []
|
|
||||||
if isinstance(objects, str):
|
|
||||||
try:
|
|
||||||
objects = json.loads(objects)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
objects = []
|
|
||||||
used_g, source = usage.resolve_used_grams(
|
|
||||||
planned, used_mm, total_mm, progress, state, job.get("print_duration") or 0
|
|
||||||
)
|
|
||||||
row = store.upsert_run(conn, {
|
|
||||||
"printer_host": printer["host"],
|
|
||||||
"printer_name": printer["name"],
|
|
||||||
"filename": filename,
|
|
||||||
"file_id": file_id,
|
|
||||||
"project_id": project_id,
|
|
||||||
"spool_id": spool["id"] if spool else None,
|
|
||||||
"state": state,
|
|
||||||
"started_at": unix_to_dt(job.get("start_time")),
|
|
||||||
"ended_at": unix_to_dt(job.get("end_time")),
|
|
||||||
"duration_s": float(job.get("print_duration") or job.get("total_duration") or 0),
|
|
||||||
"weight_g": planned,
|
|
||||||
"weight_planned_g": planned,
|
|
||||||
"weight_used_g": used_g,
|
|
||||||
"weight_source": source,
|
|
||||||
"filament_used_mm": used_mm,
|
|
||||||
"filament_total_mm": total_mm,
|
|
||||||
"progress_last": progress,
|
|
||||||
"estimated_s": estimated,
|
|
||||||
"energy_cost": energy_cost(job.get("print_duration") or job.get("total_duration") or 0, cfg["energy"]),
|
|
||||||
"moonraker_job_id": job_id,
|
|
||||||
"needs_spool": bool(terminal and used_g > 0 and not spool),
|
|
||||||
"material_type": material,
|
|
||||||
"message": (job.get("message") or "") if state == "error" else "",
|
|
||||||
"objects": objects,
|
|
||||||
})
|
|
||||||
# Live sofort auf History hängen — auch solange noch in_progress (eine Protokoll-Zeile)
|
|
||||||
store.adopt_live_run(conn, printer["host"], filename, row["id"])
|
|
||||||
row = store.get_run_row(conn, row["id"])
|
|
||||||
if terminal:
|
|
||||||
# Nachzug nicht während eines neuen Live-Drucks — sonst „Platte belegt“ zu Unrecht
|
|
||||||
active = store.find_live_run(conn, printer["host"])
|
|
||||||
if not active:
|
|
||||||
ended = row.get("ended_at") or unix_to_dt(job.get("end_time"))
|
|
||||||
store.ensure_bed_after_print(
|
|
||||||
conn, printer["host"], ended_at=ended, message=filename or state
|
|
||||||
)
|
|
||||||
if apply_deduction(conn, row, cfg["energy"], metadata=metadata):
|
|
||||||
deducted += 1
|
|
||||||
return {"gelesen": len(collected), "abgezogen": deducted}
|
return {"gelesen": len(collected), "abgezogen": deducted}
|
||||||
|
|
||||||
|
|
||||||
def assert_filament_enough(conn, file_row, spool_id=None, ace_map=None, host=""):
|
def assert_filament_enough(conn, file_row, spool_id=None, ace_map=None, host=""):
|
||||||
"""Dateien-Gewicht gegen Spule oder ACE-Plätze prüfen."""
|
"""Zu wenig Rest bricht den Start nicht ab. Die Oberfläche warnt vorher."""
|
||||||
need = float((file_row or {}).get("weight_g") or 0)
|
return
|
||||||
if need <= 0:
|
|
||||||
return
|
|
||||||
mapping = acm.normalize_mapping(ace_map)
|
|
||||||
if mapping and host:
|
|
||||||
leftovers = []
|
|
||||||
for item in mapping:
|
|
||||||
slot = int(item["ams_index"]) + 1
|
|
||||||
row = conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT remaining_g, name FROM spools
|
|
||||||
WHERE printer_host = %s AND ace_slot = %s
|
|
||||||
ORDER BY id LIMIT 1
|
|
||||||
""",
|
|
||||||
(host, slot),
|
|
||||||
).fetchone()
|
|
||||||
if row:
|
|
||||||
leftovers.append(float(row["remaining_g"] or 0))
|
|
||||||
if leftovers and sum(leftovers) + 0.5 < need:
|
|
||||||
raise GrottiError(
|
|
||||||
"ACE-Plätze haben nur %.0f g, Datei braucht %.0f g."
|
|
||||||
% (sum(leftovers), need),
|
|
||||||
"spule",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if not spool_id:
|
|
||||||
return
|
|
||||||
spool = store.get_spool_row(conn, spool_id)
|
|
||||||
if not spool:
|
|
||||||
return
|
|
||||||
left = float(spool["remaining_g"] or 0)
|
|
||||||
if left + 0.5 < need:
|
|
||||||
raise GrottiError(
|
|
||||||
"Spule „%s“ hat nur %.0f g, Datei braucht %.0f g."
|
|
||||||
% (spool["name"], left, need),
|
|
||||||
"spule",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_send(conn, file_id, host, confirm, override, spool_id, ace_map=None):
|
def prepare_send(conn, file_id, host, confirm, override, spool_id, ace_map=None):
|
||||||
|
|||||||
+23
-8
@@ -46,7 +46,14 @@ def sync_watch(conn, cfg, force=False):
|
|||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if not force and _LAST_WATCH_SYNC and now - _LAST_WATCH_SYNC < _WATCH_MIN_INTERVAL_S:
|
if not force and _LAST_WATCH_SYNC and now - _LAST_WATCH_SYNC < _WATCH_MIN_INTERVAL_S:
|
||||||
return {"gelesen": 0, "hinweis": "", "skipped": True}
|
return {"gelesen": 0, "hinweis": "", "skipped": True}
|
||||||
|
known = {}
|
||||||
|
if not force:
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT stored_path, size_bytes, mtime FROM files WHERE source = 'watch'"
|
||||||
|
).fetchall():
|
||||||
|
known[row["stored_path"]] = (int(row["size_bytes"] or 0), float(row["mtime"] or 0))
|
||||||
seen = []
|
seen = []
|
||||||
|
gelesen = 0
|
||||||
for path in folder.rglob("*"):
|
for path in folder.rglob("*"):
|
||||||
if not path.is_file() or skip_path(path, cfg["data_dir"]):
|
if not path.is_file() or skip_path(path, cfg["data_dir"]):
|
||||||
continue
|
continue
|
||||||
@@ -54,14 +61,21 @@ def sync_watch(conn, cfg, force=False):
|
|||||||
if not (lower.endswith(".gcode") or lower.endswith(".3mf")):
|
if not (lower.endswith(".gcode") or lower.endswith(".3mf")):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
|
resolved = str(path.resolve())
|
||||||
|
seen.append(resolved)
|
||||||
|
if not force:
|
||||||
|
st = path.stat()
|
||||||
|
prev = known.get(resolved)
|
||||||
|
if prev and prev[0] == st.st_size and prev[1] == float(st.st_mtime):
|
||||||
|
continue
|
||||||
store.upsert_scanned(conn, meta_for(path), force=force)
|
store.upsert_scanned(conn, meta_for(path), force=force)
|
||||||
seen.append(str(path.resolve()))
|
gelesen += 1
|
||||||
except (OSError, zipfile.BadZipFile):
|
except (OSError, zipfile.BadZipFile):
|
||||||
continue
|
continue
|
||||||
store.forget_missing_watch(conn, seen)
|
store.forget_missing_watch(conn, seen)
|
||||||
link_watch_files_to_projects(conn, cfg)
|
link_watch_files_to_projects(conn, cfg)
|
||||||
_LAST_WATCH_SYNC = now
|
_LAST_WATCH_SYNC = now
|
||||||
return {"gelesen": len(seen), "hinweis": ""}
|
return {"gelesen": gelesen, "hinweis": ""}
|
||||||
|
|
||||||
|
|
||||||
def _apply_meta(conn, file_id, row, meta):
|
def _apply_meta(conn, file_id, row, meta):
|
||||||
@@ -77,6 +91,7 @@ def _apply_meta(conn, file_id, row, meta):
|
|||||||
"kind": meta.get("kind") or row.get("kind"),
|
"kind": meta.get("kind") or row.get("kind"),
|
||||||
"plate": meta.get("plate") or "",
|
"plate": meta.get("plate") or "",
|
||||||
"slice_meta": meta.get("slice_meta") or {},
|
"slice_meta": meta.get("slice_meta") or {},
|
||||||
|
"ace_trailer": bool(meta.get("ace_trailer")),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -95,7 +110,7 @@ def refresh_one_file(conn, file_id, watch_dir=None):
|
|||||||
row = store.get_file_row(conn, file_id) or row
|
row = store.get_file_row(conn, file_id) or row
|
||||||
except (OSError, zipfile.BadZipFile):
|
except (OSError, zipfile.BadZipFile):
|
||||||
pass
|
pass
|
||||||
return store.file_out(row, watch_dir=watch_dir)
|
return store.file_out(row, watch_dir=watch_dir, conn=conn)
|
||||||
|
|
||||||
|
|
||||||
def filaments_need_paint_index(filaments):
|
def filaments_need_paint_index(filaments):
|
||||||
@@ -276,7 +291,7 @@ def move_file(conn, cfg, file_id, folder):
|
|||||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
dest = dest_dir / src.name
|
dest = dest_dir / src.name
|
||||||
if dest.resolve() == src.resolve() and source == "watch":
|
if dest.resolve() == src.resolve() and source == "watch":
|
||||||
return store.file_out(row, watch_dir=root)
|
return store.file_out(row, watch_dir=root, conn=conn)
|
||||||
if dest.exists() and dest.resolve() != src.resolve():
|
if dest.exists() and dest.resolve() != src.resolve():
|
||||||
dest = unique_path(dest_dir, src.name)
|
dest = unique_path(dest_dir, src.name)
|
||||||
try:
|
try:
|
||||||
@@ -295,7 +310,7 @@ def move_file(conn, cfg, file_id, folder):
|
|||||||
)
|
)
|
||||||
_link_file_to_project_folder(conn, file_id, str(dest.resolve()), root)
|
_link_file_to_project_folder(conn, file_id, str(dest.resolve()), root)
|
||||||
row = store.get_file_row(conn, file_id)
|
row = store.get_file_row(conn, file_id)
|
||||||
return store.file_out(row, watch_dir=root)
|
return store.file_out(row, watch_dir=root, conn=conn)
|
||||||
|
|
||||||
|
|
||||||
def path_allowed_for_delete(stored_path, cfg):
|
def path_allowed_for_delete(stored_path, cfg):
|
||||||
@@ -365,7 +380,7 @@ def rename_library_file(conn, cfg, file_id, new_name):
|
|||||||
raise GrottiError("Ungültiger Dateiname.", "datei")
|
raise GrottiError("Ungültiger Dateiname.", "datei")
|
||||||
dest = src.with_name(final)
|
dest = src.with_name(final)
|
||||||
if dest.resolve() == src.resolve():
|
if dest.resolve() == src.resolve():
|
||||||
return store.file_out(row, watch_dir=cfg.get("watch_dir"))
|
return store.file_out(row, watch_dir=cfg.get("watch_dir"), conn=conn)
|
||||||
if dest.exists():
|
if dest.exists():
|
||||||
raise GrottiError("Unter diesem Namen liegt schon eine Datei.", "datei")
|
raise GrottiError("Unter diesem Namen liegt schon eine Datei.", "datei")
|
||||||
try:
|
try:
|
||||||
@@ -382,7 +397,7 @@ def rename_library_file(conn, cfg, file_id, new_name):
|
|||||||
mtime=stat.st_mtime,
|
mtime=stat.st_mtime,
|
||||||
)
|
)
|
||||||
row = store.get_file_row(conn, file_id)
|
row = store.get_file_row(conn, file_id)
|
||||||
return store.file_out(row, watch_dir=cfg.get("watch_dir"))
|
return store.file_out(row, watch_dir=cfg.get("watch_dir"), conn=conn)
|
||||||
|
|
||||||
|
|
||||||
_PRINT_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
|
_PRINT_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
|
||||||
@@ -559,7 +574,7 @@ def ensure_library_gcode(conn, cfg, host, filename):
|
|||||||
if known:
|
if known:
|
||||||
path = Path(known.get("stored_path") or "")
|
path = Path(known.get("stored_path") or "")
|
||||||
if path.is_file():
|
if path.is_file():
|
||||||
return store.file_out(known)
|
return store.file_out(known, conn=conn)
|
||||||
from grotti import moonraker
|
from grotti import moonraker
|
||||||
|
|
||||||
pulled = moonraker.download_gcode(host, base)
|
pulled = moonraker.download_gcode(host, base)
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Zentraler Logger für Dienst und journald (stdout)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
_CONFIGURED = False
|
||||||
|
|
||||||
|
LEVELS = {
|
||||||
|
"critical": logging.CRITICAL,
|
||||||
|
"error": logging.ERROR,
|
||||||
|
"warning": logging.WARNING,
|
||||||
|
"warn": logging.WARNING,
|
||||||
|
"info": logging.INFO,
|
||||||
|
"debug": logging.DEBUG,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_level(value, default=logging.INFO):
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
text = str(value or "").strip().lower()
|
||||||
|
return LEVELS.get(text, default)
|
||||||
|
|
||||||
|
|
||||||
|
def setup(level="INFO"):
|
||||||
|
"""Root-Logger einmalig auf stdout (systemd → journald)."""
|
||||||
|
global _CONFIGURED
|
||||||
|
resolved = parse_level(level)
|
||||||
|
root = logging.getLogger()
|
||||||
|
if _CONFIGURED:
|
||||||
|
root.setLevel(resolved)
|
||||||
|
return root
|
||||||
|
root.handlers.clear()
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setFormatter(
|
||||||
|
logging.Formatter(
|
||||||
|
"%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
root.addHandler(handler)
|
||||||
|
root.setLevel(resolved)
|
||||||
|
logging.getLogger("waitress").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
||||||
|
_CONFIGURED = True
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def get(name="grotti"):
|
||||||
|
return logging.getLogger(name)
|
||||||
+52
-13
@@ -57,6 +57,51 @@ def _write_guard():
|
|||||||
raise RuntimeError("MCP läuft im Read-only-Modus (GROTTI_MCP_READ_ONLY).")
|
raise RuntimeError("MCP läuft im Read-only-Modus (GROTTI_MCP_READ_ONLY).")
|
||||||
|
|
||||||
|
|
||||||
|
# Wird beim Connect an den Client gegeben (Claude, Cursor, …).
|
||||||
|
INSTRUCTIONS = """\
|
||||||
|
Grotti steuert eine 3D-Drucker-Farm (Anycubic Kobra, Klipper/Moonraker).
|
||||||
|
Antworte dem Nutzer auf Deutsch. Handle nur über diese Tools.
|
||||||
|
|
||||||
|
Identität
|
||||||
|
- Drucker über das Feld host (IP). Den Namen nur zur Anzeige nutzen. Hosts nie raten: erst list_printers oder get_printer.
|
||||||
|
- Dateien über die numerische id aus list_files (Feld id), nicht über den Dateinamen allein.
|
||||||
|
- Vor jedem Schreibzugriff den aktuellen Stand lesen.
|
||||||
|
|
||||||
|
Lesen
|
||||||
|
- Farm: list_printers (live=true). Ein Drucker: get_printer(host).
|
||||||
|
- Läuft: list_running. Warteschlange: list_queue. Ereignisse: list_events.
|
||||||
|
- Bibliothek: list_files. Spulen: list_spools. Filament-Fehlmenge: bedarf_lesen.
|
||||||
|
- Kundenaufträge: auftraege_lesen. Verschleißteile: wartung_lesen.
|
||||||
|
|
||||||
|
Zustand (Feld state)
|
||||||
|
- printing: druckt. paused: Pause. standby: frei. complete: fertig, Platte oft noch belegt.
|
||||||
|
- error: Fehler, nicht einfach neu starten. offline: nicht erreichbar.
|
||||||
|
- progress ist 0…1. filament_empty oder pause_reason bei Pause heißt Filament leer.
|
||||||
|
Dann dem Nutzer sagen, die neue Spule in Grotti dem Drucker zuzuweisen, und erst danach resume_printer.
|
||||||
|
Eine Filament-Pause nicht blind fortsetzen.
|
||||||
|
|
||||||
|
Schreiben
|
||||||
|
confirm=true nur setzen, wenn der Nutzer diese Aktion in diesem Zug klar will. Nicht vorauseilend bestätigen.
|
||||||
|
- enqueue_when_free: Datei in die Warteschlange. host leer = Grotti wählt den nächsten Drucker mit passender Sorte. host gesetzt = dieser Drucker, sobald er frei ist und die Platte klar ist. Das ist der normale Weg.
|
||||||
|
- enqueue_print: sofort senden und starten. Nur wenn der Nutzer einen Sofortstart auf einem genannten freien Drucker will (state standby, Platte frei). Große Dateien brauchen oft etwa eine Minute bis zum Start.
|
||||||
|
- Den Material-Match macht Grotti (PLA Meta ist nicht Matte und nicht Silk). Eine falsche Maschine nicht erzwingen. Eine Ablehnung wörtlich weitergeben.
|
||||||
|
- bed_leveling=true nur auf ausdrücklichen Wunsch (Bettnetz / Auto-Level).
|
||||||
|
- pause_printer und resume_printer während des Drucks, ohne confirm.
|
||||||
|
- cancel_printer bricht den Druck ab und braucht confirm=true. Bei Unklarheit erst nachfragen.
|
||||||
|
- clear_bed ist nur die Quittung „Platte ist leer“ und schiebt nichts physisch. Erst aufrufen, wenn der Nutzer sagt, das Teil ist herunter. Ohne diese Quittung startet der nächste „wenn frei“-Job nicht.
|
||||||
|
- steer_temp (Düse und Bett in °C), steer_speed (Prozent), steer_fan (0–100): nur diese Werte. Kein freier G-code.
|
||||||
|
|
||||||
|
Grenzen
|
||||||
|
- Keine G-code-Konsole, kein Firmware-Restart, kein E-Stop, keine Benutzer- oder Token-Verwaltung. Diese Lücken nicht umgehen.
|
||||||
|
- Im Read-only-Modus (GROTTI_MCP_READ_ONLY) schlagen Schreib-Tools fehl. Das sagen und nicht wiederholen.
|
||||||
|
- Gesperrte Drucker und ausgeschaltete Automatik starten keine Warteschlangen-Jobs. Den Fehler nennen.
|
||||||
|
|
||||||
|
Antworten
|
||||||
|
- Kurz und konkret: Druckername, host, Datei, Zustand, was du getan hast.
|
||||||
|
- Den Fehlertext von Grotti zitieren. Keinen Workaround außerhalb der Tools erfinden.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
try:
|
try:
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
@@ -67,23 +112,17 @@ def main():
|
|||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
server = FastMCP(
|
server = FastMCP("grotti", instructions=INSTRUCTIONS)
|
||||||
"grotti",
|
|
||||||
instructions=(
|
|
||||||
"Grotti steuert die 3D-Drucker-Farm. Destruktive Aktionen brauchen "
|
|
||||||
"confirm=true. Kein freier G-code, kein Firmware-Restart."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@server.tool()
|
@server.tool()
|
||||||
def list_printers(live: bool = True) -> str:
|
def list_printers(live: bool = True) -> str:
|
||||||
"""Druckerliste mit optionalem Live-Zustand."""
|
"""Alle Drucker. live=true liefert Zustand, Temps, Fortschritt, Dateiname. Host ist die IP."""
|
||||||
q = "?live=1" if live else "?live=0"
|
q = "?live=1" if live else "?live=0"
|
||||||
return json.dumps(_api("GET", "/drucker" + q), ensure_ascii=False, indent=2)
|
return json.dumps(_api("GET", "/drucker" + q), ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
@server.tool()
|
@server.tool()
|
||||||
def get_printer(host: str) -> str:
|
def get_printer(host: str) -> str:
|
||||||
"""Einen Drucker anhand Host/IP aus der Live-Liste."""
|
"""Einen Drucker anhand host (IP) aus der Live-Liste. Host vorher über list_printers holen."""
|
||||||
rows = _api("GET", "/drucker?live=1") or []
|
rows = _api("GET", "/drucker?live=1") or []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
if row.get("host") == host:
|
if row.get("host") == host:
|
||||||
@@ -115,7 +154,7 @@ def main():
|
|||||||
|
|
||||||
@server.tool()
|
@server.tool()
|
||||||
def list_files(limit: int = 50) -> str:
|
def list_files(limit: int = 50) -> str:
|
||||||
"""Dateien in der Bibliothek."""
|
"""Dateien in der Bibliothek. Die numerische id ist die file_id fürs Einreihen und Starten."""
|
||||||
rows = _api("GET", "/dateien") or []
|
rows = _api("GET", "/dateien") or []
|
||||||
return json.dumps(rows[: max(1, min(limit, 200))], ensure_ascii=False, indent=2)
|
return json.dumps(rows[: max(1, min(limit, 200))], ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
@@ -181,7 +220,7 @@ def main():
|
|||||||
|
|
||||||
@server.tool()
|
@server.tool()
|
||||||
def clear_bed(host: str, confirm: bool = False) -> str:
|
def clear_bed(host: str, confirm: bool = False) -> str:
|
||||||
"""Platte als geräumt quittieren (kein physisches Schieben)."""
|
"""Quittung, dass der Nutzer die Platte geleert hat. Schiebt nichts. confirm muss true sein. Danach kann die Warteschlange den nächsten Job starten."""
|
||||||
_write_guard()
|
_write_guard()
|
||||||
if not confirm:
|
if not confirm:
|
||||||
raise RuntimeError("Quittierung braucht confirm=true.")
|
raise RuntimeError("Quittierung braucht confirm=true.")
|
||||||
@@ -201,7 +240,7 @@ def main():
|
|||||||
confirm: bool = False,
|
confirm: bool = False,
|
||||||
bed_leveling: bool = False,
|
bed_leveling: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Datei in die Warteschlange („wenn frei“). confirm muss true sein."""
|
"""Datei in die Warteschlange. host leer: Grotti wählt den nächsten Drucker mit passender Sorte. host gesetzt: dieser Drucker, sobald frei und Platte klar. confirm muss true sein. Normaler Weg zum Drucken."""
|
||||||
_write_guard()
|
_write_guard()
|
||||||
if not confirm:
|
if not confirm:
|
||||||
raise RuntimeError("Einreihen braucht confirm=true.")
|
raise RuntimeError("Einreihen braucht confirm=true.")
|
||||||
@@ -220,7 +259,7 @@ def main():
|
|||||||
confirm: bool = False,
|
confirm: bool = False,
|
||||||
bed_leveling: bool = False,
|
bed_leveling: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Sofort senden und starten. confirm muss true sein."""
|
"""Sofort an diesen host senden und starten. Nur bei freiem Drucker (standby, Platte frei) und klarem Sofortstart-Wunsch. confirm muss true sein."""
|
||||||
_write_guard()
|
_write_guard()
|
||||||
if not confirm:
|
if not confirm:
|
||||||
raise RuntimeError("Start braucht confirm=true.")
|
raise RuntimeError("Start braucht confirm=true.")
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Jede Moonraker-Job-ID bleibt einem Lauf zugeordnet, auch nach dem Zusammenlegen.
|
||||||
|
CREATE TABLE IF NOT EXISTS run_jobs (
|
||||||
|
printer_host text NOT NULL,
|
||||||
|
job_id text NOT NULL,
|
||||||
|
run_id bigint NOT NULL REFERENCES runs (id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (printer_host, job_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS run_jobs_run ON run_jobs (run_id);
|
||||||
|
|
||||||
|
INSERT INTO run_jobs (printer_host, job_id, run_id)
|
||||||
|
SELECT printer_host, moonraker_job_id, id
|
||||||
|
FROM runs
|
||||||
|
WHERE moonraker_job_id <> ''
|
||||||
|
AND moonraker_job_id NOT LIKE 'live:%'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Last active run per printer (survives Grotti restart during pause)
|
||||||
|
ALTER TABLE printers
|
||||||
|
ADD COLUMN IF NOT EXISTS active_run_id bigint REFERENCES runs (id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS printers_active_run ON printers (active_run_id)
|
||||||
|
WHERE active_run_id IS NOT NULL;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Queue item points at the run it started. At most one running job per host.
|
||||||
|
ALTER TABLE print_queue
|
||||||
|
ADD COLUMN IF NOT EXISTS run_id bigint REFERENCES runs (id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS print_queue_run ON print_queue (run_id)
|
||||||
|
WHERE run_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS print_queue_one_running_per_host
|
||||||
|
ON print_queue (host)
|
||||||
|
WHERE status = 'running' AND host <> '';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- ACE-Trailer-Flag einmalig speichern, damit die Dateiliste die Datei nicht erneut öffnet.
|
||||||
|
ALTER TABLE files
|
||||||
|
ADD COLUMN IF NOT EXISTS ace_trailer boolean;
|
||||||
+4
-1
@@ -8,8 +8,11 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
from grotti import logutil
|
||||||
from grotti.parse import embedded_png
|
from grotti.parse import embedded_png
|
||||||
|
|
||||||
|
log = logutil.get("grotti.moonraker")
|
||||||
|
|
||||||
META_CACHE = {}
|
META_CACHE = {}
|
||||||
CAMERA_CACHE = {}
|
CAMERA_CACHE = {}
|
||||||
EMBEDDED = {}
|
EMBEDDED = {}
|
||||||
@@ -1241,7 +1244,7 @@ def snapshot(printers):
|
|||||||
card.update(query(printer["host"]))
|
card.update(query(printer["host"]))
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
# Auch RemoteDisconnected / IncompleteRead / ValueError — sonst fällt der ganze Tick aus.
|
# Auch RemoteDisconnected / IncompleteRead / ValueError — sonst fällt der ganze Tick aus.
|
||||||
print("snapshot %s: %s" % (printer.get("host"), error), flush=True)
|
log.warning("snapshot %s: %s", printer.get("host"), error)
|
||||||
card.update({
|
card.update({
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"state": "offline",
|
"state": "offline",
|
||||||
|
|||||||
+257
-36
@@ -10,7 +10,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from grotti import acm, eject, jobs, moonraker, notify, store
|
from grotti import acm, eject, jobs, logutil, 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
|
from grotti import time_window
|
||||||
@@ -18,6 +18,8 @@ from grotti import time_window
|
|||||||
WINDOW_WAIT_MSG = "Wartet auf Zeitfenster…"
|
WINDOW_WAIT_MSG = "Wartet auf Zeitfenster…"
|
||||||
from grotti.events_bus import publish as publish_live
|
from grotti.events_bus import publish as publish_live
|
||||||
|
|
||||||
|
log = logutil.get("grotti.queue")
|
||||||
|
|
||||||
|
|
||||||
def _notify_queue():
|
def _notify_queue():
|
||||||
publish_live("queue", {})
|
publish_live("queue", {})
|
||||||
@@ -28,8 +30,13 @@ 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)
|
# Upload-Worker weg (typisch Grotti-Neustart mitten im Hochladen).
|
||||||
|
# Nach erfolgreichem Senden ist die Phase await_print — dort gilt das nicht,
|
||||||
|
# sonst wird ein noch anlaufender Druck als „Neustart“ neu eingereiht.
|
||||||
SIMPLE_START_STUCK_S = 45
|
SIMPLE_START_STUCK_S = 45
|
||||||
|
# Anycubic meldet kurz cancelled/standby bei LeviQ und Filament-Resume.
|
||||||
|
TERMINAL_CONFIRM_HITS = 3
|
||||||
|
AWAIT_CANCEL_HITS = 3
|
||||||
|
|
||||||
|
|
||||||
def _ablauf_needs_motors(item):
|
def _ablauf_needs_motors(item):
|
||||||
@@ -111,7 +118,7 @@ class PrintQueueDispatcher:
|
|||||||
try:
|
try:
|
||||||
self.tick()
|
self.tick()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Druck-Queue: %s" % exc, flush=True)
|
log.warning("Druck-Queue: %s", exc)
|
||||||
self._stop.wait(self.interval)
|
self._stop.wait(self.interval)
|
||||||
|
|
||||||
def _live_map(self, printers):
|
def _live_map(self, printers):
|
||||||
@@ -147,10 +154,20 @@ class PrintQueueDispatcher:
|
|||||||
def _auto_retry_enabled(self):
|
def _auto_retry_enabled(self):
|
||||||
return bool((self.cfg or {}).get("auto_retry", True))
|
return bool((self.cfg or {}).get("auto_retry", True))
|
||||||
|
|
||||||
|
def _retryable_message(self, message):
|
||||||
|
text = (message or "").lower()
|
||||||
|
if "abgebrochen" in text:
|
||||||
|
return False
|
||||||
|
if "druck abgebrochen" in text:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def _try_auto_retry(self, conn, item, message):
|
def _try_auto_retry(self, conn, item, message):
|
||||||
"""Ein automatischer Neuversuch; sonst False."""
|
"""Ein automatischer Neuversuch; sonst False. Nicht nach Abbruch."""
|
||||||
if not self._auto_retry_enabled():
|
if not self._auto_retry_enabled():
|
||||||
return False
|
return False
|
||||||
|
if not self._retryable_message(message):
|
||||||
|
return False
|
||||||
retries = int(item.get("retry_count") or 0)
|
retries = int(item.get("retry_count") or 0)
|
||||||
if retries >= 1:
|
if retries >= 1:
|
||||||
return False
|
return False
|
||||||
@@ -168,6 +185,23 @@ class PrintQueueDispatcher:
|
|||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _terminal_hits(self, item_id, kind="end"):
|
||||||
|
key = "%s:%s" % (item_id, kind)
|
||||||
|
if not hasattr(self, "_term_hits"):
|
||||||
|
self._term_hits = {}
|
||||||
|
self._term_hits[key] = self._term_hits.get(key, 0) + 1
|
||||||
|
return self._term_hits[key]
|
||||||
|
|
||||||
|
def _clear_terminal_hits(self, item_id, kind=None):
|
||||||
|
if not hasattr(self, "_term_hits"):
|
||||||
|
return
|
||||||
|
if kind:
|
||||||
|
self._term_hits.pop("%s:%s" % (item_id, kind), None)
|
||||||
|
return
|
||||||
|
for key in list(self._term_hits):
|
||||||
|
if key.startswith("%s:" % item_id):
|
||||||
|
self._term_hits.pop(key, None)
|
||||||
|
|
||||||
def _fail_stuck_simple_start(self, conn, item, printer):
|
def _fail_stuck_simple_start(self, conn, item, printer):
|
||||||
host = printer["host"]
|
host = printer["host"]
|
||||||
msg = "Start abgebrochen (Neustart)"
|
msg = "Start abgebrochen (Neustart)"
|
||||||
@@ -207,7 +241,7 @@ class PrintQueueDispatcher:
|
|||||||
try:
|
try:
|
||||||
target(*args)
|
target(*args)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Queue-Worker %s: %s" % (item_id, exc), flush=True)
|
log.warning("Queue-Worker %s: %s", item_id, exc)
|
||||||
finally:
|
finally:
|
||||||
_notify_queue()
|
_notify_queue()
|
||||||
with self._worker_lock:
|
with self._worker_lock:
|
||||||
@@ -273,18 +307,27 @@ class PrintQueueDispatcher:
|
|||||||
lives[host] = live
|
lives[host] = live
|
||||||
state = (live.get("state") or "").lower()
|
state = (live.get("state") or "").lower()
|
||||||
if state in BUSY:
|
if state in BUSY:
|
||||||
|
# Laufenden Einfach-Druck mitziehen. Sonst bleibt await_print
|
||||||
|
# stehen, bis der Drucker frei ist — und der Start gilt als Timeout,
|
||||||
|
# obwohl er längst druckt.
|
||||||
|
running = store.running_print_on_host(conn, host)
|
||||||
|
if running and not _has_ablauf(running):
|
||||||
|
self._advance_simple(conn, running, printer, live)
|
||||||
|
conn.commit()
|
||||||
continue
|
continue
|
||||||
if state not in FREE:
|
if state not in FREE:
|
||||||
continue
|
continue
|
||||||
running = store.running_print_on_host(conn, host)
|
running = store.running_print_on_host(conn, host)
|
||||||
if running:
|
if running:
|
||||||
if (
|
if not _has_ablauf(running):
|
||||||
not _has_ablauf(running)
|
if (
|
||||||
and (running.get("phase") or "").lower() == "starting"
|
(running.get("phase") or "").lower() == "starting"
|
||||||
and not self._worker_busy(running["id"])
|
and not self._worker_busy(running["id"])
|
||||||
and self._item_phase_age_s(running) > SIMPLE_START_STUCK_S
|
and self._item_phase_age_s(running) > SIMPLE_START_STUCK_S
|
||||||
):
|
):
|
||||||
self._fail_stuck_simple_start(conn, running, printer)
|
self._fail_stuck_simple_start(conn, running, printer)
|
||||||
|
else:
|
||||||
|
self._advance_simple(conn, running, printer, live)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
continue
|
continue
|
||||||
if store.bed_needs_clear(conn, host):
|
if store.bed_needs_clear(conn, host):
|
||||||
@@ -412,22 +455,31 @@ class PrintQueueDispatcher:
|
|||||||
bed_leveling=bed_leveling,
|
bed_leveling=bed_leveling,
|
||||||
)
|
)
|
||||||
name = (result or {}).get("filename") or ""
|
name = (result or {}).get("filename") or ""
|
||||||
|
# Abbruch während Upload: Status nicht überschreiben, Druck stoppen.
|
||||||
|
current = store.get_print_queue_row(wconn, item_id)
|
||||||
|
if current and (current.get("status") or "") in ("cancelled", "dismissed"):
|
||||||
|
try:
|
||||||
|
moonraker.command(host, "cancel")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
oid = item.get("order_item_id")
|
oid = item.get("order_item_id")
|
||||||
if oid:
|
if oid:
|
||||||
from grotti import orders
|
from grotti import orders
|
||||||
|
|
||||||
orders.attach_order_item_to_live_run(wconn, host, name, oid)
|
orders.attach_order_item_to_live_run(wconn, host, name, oid)
|
||||||
store.update_print_queue(
|
live_run = store.find_active_run(wconn, host, name)
|
||||||
wconn,
|
fields = {
|
||||||
item_id,
|
"status": "running",
|
||||||
{
|
"message": "Datei ist auf %s. Druckstart kann etwa eine Minute dauern."
|
||||||
"status": "done",
|
% printer_copy["name"],
|
||||||
"message": "Gestartet auf %s: %s" % (printer_copy["name"], name),
|
"host": host,
|
||||||
"host": host,
|
"phase": "await_print",
|
||||||
"phase": "",
|
"phase_started_at": "now",
|
||||||
"phase_started_at": None,
|
}
|
||||||
},
|
if live_run:
|
||||||
)
|
fields["run_id"] = live_run["id"]
|
||||||
|
store.update_print_queue(wconn, item_id, fields)
|
||||||
self._event(
|
self._event(
|
||||||
wconn,
|
wconn,
|
||||||
host,
|
host,
|
||||||
@@ -477,6 +529,147 @@ class PrintQueueDispatcher:
|
|||||||
|
|
||||||
self._spawn_worker(item_id, work)
|
self._spawn_worker(item_id, work)
|
||||||
|
|
||||||
|
def _advance_simple(self, conn, item, printer, live):
|
||||||
|
"""Einfach-Druck: running bleiben bis Moonraker fertig/abgebrochen."""
|
||||||
|
phase = (item.get("phase") or "").lower()
|
||||||
|
state = (live.get("state") or "").lower()
|
||||||
|
if phase in ("starting", "await_print"):
|
||||||
|
if state in ("printing", "paused", "heating") or live.get("print_active"):
|
||||||
|
self._clear_terminal_hits(item["id"])
|
||||||
|
store.update_print_queue(
|
||||||
|
conn,
|
||||||
|
item["id"],
|
||||||
|
{
|
||||||
|
"phase": "print_active",
|
||||||
|
"phase_started_at": "now",
|
||||||
|
"message": "Druckt…",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if phase == "await_print" and state == "cancelled":
|
||||||
|
hits = self._terminal_hits(item["id"], "await_cancel")
|
||||||
|
if hits >= AWAIT_CANCEL_HITS:
|
||||||
|
self._clear_terminal_hits(item["id"])
|
||||||
|
store.update_print_queue(
|
||||||
|
conn,
|
||||||
|
item["id"],
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Druck abgebrochen.",
|
||||||
|
"phase": "",
|
||||||
|
"phase_started_at": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if phase == "await_print" and self._print_start_timed_out(item):
|
||||||
|
store.update_print_queue(
|
||||||
|
conn,
|
||||||
|
item["id"],
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Druckstart-Timeout: Drucker hat nach %d min nicht angefangen."
|
||||||
|
% (PRINT_START_TIMEOUT_S // 60),
|
||||||
|
"phase": "",
|
||||||
|
"phase_started_at": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
# complete/standby vom vorherigen Auftrag: noch warten, nicht „Fertig.“
|
||||||
|
return
|
||||||
|
if phase != "print_active":
|
||||||
|
return
|
||||||
|
if state in ("printing", "paused", "heating"):
|
||||||
|
self._clear_terminal_hits(item["id"])
|
||||||
|
return
|
||||||
|
if state == "error":
|
||||||
|
store.update_print_queue(
|
||||||
|
conn,
|
||||||
|
item["id"],
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": (live.get("message") or "Druckerfehler").strip()[:200],
|
||||||
|
"phase": "",
|
||||||
|
"phase_started_at": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
# Nur complete = fertig. cancelled/standby erst nach Entprellen.
|
||||||
|
if state == "complete":
|
||||||
|
self._clear_terminal_hits(item["id"])
|
||||||
|
store.update_print_queue(
|
||||||
|
conn,
|
||||||
|
item["id"],
|
||||||
|
{
|
||||||
|
"status": "done",
|
||||||
|
"message": "Fertig.",
|
||||||
|
"phase": "",
|
||||||
|
"phase_started_at": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._event(
|
||||||
|
conn,
|
||||||
|
printer["host"],
|
||||||
|
printer["name"],
|
||||||
|
"fertig",
|
||||||
|
"%s fertig: %s" % (printer["name"], item.get("filename") or "Druck"),
|
||||||
|
item.get("filename") or "",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if state in ("cancelled", "standby"):
|
||||||
|
hits = self._terminal_hits(item["id"], "end")
|
||||||
|
if hits < TERMINAL_CONFIRM_HITS:
|
||||||
|
return
|
||||||
|
live2 = live
|
||||||
|
try:
|
||||||
|
live2 = moonraker.query(printer["host"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
state2 = (live2.get("state") or "").lower()
|
||||||
|
if state2 in ("printing", "paused", "heating") or live2.get("print_active"):
|
||||||
|
self._clear_terminal_hits(item["id"])
|
||||||
|
return
|
||||||
|
self._clear_terminal_hits(item["id"])
|
||||||
|
if state2 == "complete":
|
||||||
|
store.update_print_queue(
|
||||||
|
conn,
|
||||||
|
item["id"],
|
||||||
|
{
|
||||||
|
"status": "done",
|
||||||
|
"message": "Fertig.",
|
||||||
|
"phase": "",
|
||||||
|
"phase_started_at": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._event(
|
||||||
|
conn,
|
||||||
|
printer["host"],
|
||||||
|
printer["name"],
|
||||||
|
"fertig",
|
||||||
|
"%s fertig: %s" % (printer["name"], item.get("filename") or "Druck"),
|
||||||
|
item.get("filename") or "",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
# Echter Abbruch — nicht „Fertig.“ und kein Auto-Retry.
|
||||||
|
store.update_print_queue(
|
||||||
|
conn,
|
||||||
|
item["id"],
|
||||||
|
{
|
||||||
|
"status": "cancelled",
|
||||||
|
"message": "Druck abgebrochen.",
|
||||||
|
"phase": "",
|
||||||
|
"phase_started_at": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._event(
|
||||||
|
conn,
|
||||||
|
printer["host"],
|
||||||
|
printer["name"],
|
||||||
|
"abbruch",
|
||||||
|
"%s: Druck abgebrochen (%s)"
|
||||||
|
% (printer["name"], item.get("filename") or "ohne Datei"),
|
||||||
|
item.get("filename") or "",
|
||||||
|
)
|
||||||
|
|
||||||
def _begin_ablauf(self, conn, item, printer, live):
|
def _begin_ablauf(self, conn, item, printer, live):
|
||||||
try:
|
try:
|
||||||
eject.require_kobra_3(printer)
|
eject.require_kobra_3(printer)
|
||||||
@@ -713,9 +906,31 @@ class PrintQueueDispatcher:
|
|||||||
live.get("message") or "Druckerfehler während des Drucks.",
|
live.get("message") or "Druckerfehler während des Drucks.",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
# Erst weiter, wenn der Job wirklich gelaufen ist — sonst Race
|
# Nur complete = Druckende. standby/cancelled entprellen (Filament-Resume).
|
||||||
# (noch standby/complete vom vorherigen Auftrag).
|
if phase == "print_active" and state == "complete":
|
||||||
if phase == "print_active" and state in ("complete", "standby"):
|
self._clear_terminal_hits(item["id"])
|
||||||
|
self._next_step(conn, item, printer, live)
|
||||||
|
return
|
||||||
|
if phase == "print_active" and state in ("cancelled", "standby"):
|
||||||
|
hits = self._terminal_hits(item["id"], "ablauf_end")
|
||||||
|
if hits < TERMINAL_CONFIRM_HITS:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
live = moonraker.query(printer["host"])
|
||||||
|
state = (live.get("state") or "").lower()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if state in ("printing", "paused", "heating") or live.get("print_active"):
|
||||||
|
self._clear_terminal_hits(item["id"])
|
||||||
|
return
|
||||||
|
self._clear_terminal_hits(item["id"])
|
||||||
|
if state == "complete":
|
||||||
|
self._next_step(conn, item, printer, live)
|
||||||
|
return
|
||||||
|
if state == "cancelled":
|
||||||
|
self._fail_ablauf(conn, item, printer, "Druck abgebrochen.")
|
||||||
|
return
|
||||||
|
# standby nach Entprellen: Job wirklich zu Ende
|
||||||
self._next_step(conn, item, printer, live)
|
self._next_step(conn, item, printer, live)
|
||||||
return
|
return
|
||||||
# Idle schon während des Drucks verlängern — sonst greift M84/Default zu früh.
|
# Idle schon während des Drucks verlängern — sonst greift M84/Default zu früh.
|
||||||
@@ -835,16 +1050,22 @@ class PrintQueueDispatcher:
|
|||||||
)
|
)
|
||||||
name = (result or {}).get("filename") or ""
|
name = (result or {}).get("filename") or ""
|
||||||
row = store.get_print_queue_row(wconn, item_id) or item
|
row = store.get_print_queue_row(wconn, item_id) or item
|
||||||
store.update_print_queue(
|
if (row.get("status") or "") in ("cancelled", "dismissed"):
|
||||||
wconn,
|
try:
|
||||||
item_id,
|
moonraker.command(printer_copy["host"], "cancel")
|
||||||
{
|
except Exception:
|
||||||
"phase": "printing",
|
pass
|
||||||
"phase_started_at": "now",
|
return
|
||||||
"file_id": step_copy["file_id"],
|
fields = {
|
||||||
"message": self._ablauf_message(row, "druckt %s" % name),
|
"phase": "printing",
|
||||||
},
|
"phase_started_at": "now",
|
||||||
)
|
"file_id": step_copy["file_id"],
|
||||||
|
"message": self._ablauf_message(row, "druckt %s" % name),
|
||||||
|
}
|
||||||
|
live_run = store.find_active_run(wconn, printer_copy["host"], name)
|
||||||
|
if live_run:
|
||||||
|
fields["run_id"] = live_run["id"]
|
||||||
|
store.update_print_queue(wconn, item_id, fields)
|
||||||
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:
|
||||||
|
|||||||
+965
-92
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,7 @@
|
|||||||
"""G-code zu einem dünnen 3D-Pfad für den Viewer."""
|
"""G-code zu einem dünnen 3D-Pfad für den Viewer."""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -10,6 +11,8 @@ AXIS = re.compile(r"([XYZE])\s*(-?\d*\.?\d+)", re.IGNORECASE)
|
|||||||
|
|
||||||
# Cache-/Viewer-Version: ganze Z-Höhen dicht behalten (keine gesprenkelten Stichproben)
|
# Cache-/Viewer-Version: ganze Z-Höhen dicht behalten (keine gesprenkelten Stichproben)
|
||||||
TOOLPATH_VERSION = 8
|
TOOLPATH_VERSION = 8
|
||||||
|
# Die volle Bahn liegt kurz im Speicher. Parallel auf einem Pi mit 2 GB füllt das den Swap.
|
||||||
|
_build_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _gcode_text(path: Path) -> str:
|
def _gcode_text(path: Path) -> str:
|
||||||
@@ -111,6 +114,11 @@ def _thin_points(points, layer_breaks, max_points):
|
|||||||
|
|
||||||
|
|
||||||
def build_toolpath(path: Path, max_points=220000):
|
def build_toolpath(path: Path, max_points=220000):
|
||||||
|
with _build_lock:
|
||||||
|
return _build_toolpath(path, max_points)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_toolpath(path: Path, max_points=220000):
|
||||||
text = _gcode_text(path)
|
text = _gcode_text(path)
|
||||||
if not text:
|
if not text:
|
||||||
return {
|
return {
|
||||||
|
|||||||
+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__ = "3.4.0"
|
__version__ = "3.6.1"
|
||||||
+198
-78
@@ -6,14 +6,31 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from grotti import jobs, moonraker, notify, store, usage
|
from grotti import jobs, logutil, moonraker, notify, store, usage
|
||||||
from grotti.events_bus import publish as publish_live
|
from grotti.events_bus import publish as publish_live
|
||||||
from grotti.db import connect
|
from grotti.db import connect
|
||||||
|
|
||||||
|
log = logutil.get("grotti.watcher")
|
||||||
|
|
||||||
SAMPLE_EVERY_S = 30
|
SAMPLE_EVERY_S = 30
|
||||||
SAMPLE_HEAT_S = 10
|
SAMPLE_HEAT_S = 10
|
||||||
|
|
||||||
|
|
||||||
|
def contact_gap_keeps_run(prev_filename, next_filename, next_state):
|
||||||
|
"""Nach einem Kontaktverlust bleibt der Lauf, solange kein anderer Auftrag läuft.
|
||||||
|
|
||||||
|
Ein Timeout ist kein Druckende. Ohne Dateiname gibt es keinen Beweis
|
||||||
|
für einen Wechsel, der offene Lauf bleibt.
|
||||||
|
"""
|
||||||
|
if next_state not in ("printing", "paused"):
|
||||||
|
return False
|
||||||
|
prev_filename = prev_filename or ""
|
||||||
|
next_filename = next_filename or ""
|
||||||
|
if not prev_filename or not next_filename:
|
||||||
|
return True
|
||||||
|
return prev_filename == next_filename
|
||||||
|
|
||||||
|
|
||||||
class StateWatcher:
|
class StateWatcher:
|
||||||
def __init__(self, database_url, cfg, interval=10):
|
def __init__(self, database_url, cfg, interval=10):
|
||||||
self.database_url = database_url
|
self.database_url = database_url
|
||||||
@@ -21,6 +38,7 @@ class StateWatcher:
|
|||||||
self.interval = max(5, int(interval))
|
self.interval = max(5, int(interval))
|
||||||
self._prev = {}
|
self._prev = {}
|
||||||
self._offline_hits = {}
|
self._offline_hits = {}
|
||||||
|
self._cancel_hits = {}
|
||||||
self.latest = []
|
self.latest = []
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._updated_at = 0.0
|
self._updated_at = 0.0
|
||||||
@@ -59,7 +77,7 @@ class StateWatcher:
|
|||||||
try:
|
try:
|
||||||
self.tick()
|
self.tick()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Watcher: %s" % exc, flush=True)
|
log.warning("Watcher: %s", exc)
|
||||||
self._stop.wait(self.interval)
|
self._stop.wait(self.interval)
|
||||||
|
|
||||||
def tick(self):
|
def tick(self):
|
||||||
@@ -93,40 +111,43 @@ class StateWatcher:
|
|||||||
if offline:
|
if offline:
|
||||||
hits = self._offline_hits.get(host, 0) + 1
|
hits = self._offline_hits.get(host, 0) + 1
|
||||||
self._offline_hits[host] = hits
|
self._offline_hits[host] = hits
|
||||||
if hits >= 2 and not prev_offline:
|
# Ein Aussetzer ist noch kein Offline. Erst der zweite Poll.
|
||||||
|
if hits < 2:
|
||||||
|
return
|
||||||
|
if not prev_offline:
|
||||||
self._emit(conn, host, name, "offline", "%s ist offline." % name, filename)
|
self._emit(conn, host, name, "offline", "%s ist offline." % name, filename)
|
||||||
# Nach Crash/Shutdown (503/gklib) bleibt sonst „druckt“ im Protokoll.
|
# Kontakt weg ist kein Druckende. Der letzte echte Zustand bleibt
|
||||||
if hits >= 2:
|
# gemerkt, damit fertig/Abbruch/Fehler beim nächsten Poll greifen.
|
||||||
dangling = store.find_live_run(conn, host)
|
remembered = prev_state if prev_state and prev_state != "offline" else ""
|
||||||
if dangling:
|
self._prev[host] = {
|
||||||
closed = store.complete_open_run(
|
"state": remembered,
|
||||||
conn, dangling["id"], state="interrupted"
|
"offline": True,
|
||||||
)
|
"heating": bool(prev.get("heating")),
|
||||||
if closed:
|
"filename": prev.get("filename") or filename or "",
|
||||||
label = dangling.get("filename") or filename or "offline"
|
"spool_warn": bool(prev.get("spool_warn")) if remembered == "printing" else False,
|
||||||
self._emit(
|
"run_id": prev.get("run_id"),
|
||||||
conn,
|
}
|
||||||
host,
|
|
||||||
name,
|
|
||||||
"abbruch",
|
|
||||||
"%s: Druck unterbrochen (Drucker offline)." % name,
|
|
||||||
label,
|
|
||||||
closed["id"],
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
from grotti import jobs
|
|
||||||
|
|
||||||
jobs.apply_deduction(conn, closed, self.cfg["energy"])
|
|
||||||
except Exception as exc:
|
|
||||||
print("Watcher Abzug offline %s: %s" % (host, exc), flush=True)
|
|
||||||
self._prev[host] = {"state": "offline", "offline": True, "filename": filename}
|
|
||||||
return
|
return
|
||||||
|
|
||||||
self._offline_hits[host] = 0
|
self._offline_hits[host] = 0
|
||||||
|
file_changed = False
|
||||||
|
if prev_offline and state in ("printing", "paused"):
|
||||||
|
file_changed = self._release_replaced_run(conn, host, name, filename)
|
||||||
|
elif state == "printing" and filename and prev_state in (
|
||||||
|
"printing", "paused", "cancelled", "standby",
|
||||||
|
):
|
||||||
|
# Abbruch verpasst oder sofort neu gestartet: alte Datei nicht umbenennen.
|
||||||
|
existing = store.find_live_run(conn, host)
|
||||||
|
old_name = (existing or {}).get("filename") or ""
|
||||||
|
if old_name and old_name != filename:
|
||||||
|
file_changed = self._release_replaced_run(conn, host, name, filename)
|
||||||
starting = (
|
starting = (
|
||||||
bool(prev_state)
|
(
|
||||||
and prev_state not in ("printing", "paused")
|
bool(prev_state)
|
||||||
and state == "printing"
|
and prev_state not in ("printing", "paused")
|
||||||
|
and state == "printing"
|
||||||
|
)
|
||||||
|
or (file_changed and state == "printing")
|
||||||
)
|
)
|
||||||
if starting:
|
if starting:
|
||||||
# Aufheiz-Proben behalten: Live-Lauf gleicher Datei nicht verwerfen
|
# Aufheiz-Proben behalten: Live-Lauf gleicher Datei nicht verwerfen
|
||||||
@@ -152,9 +173,21 @@ class StateWatcher:
|
|||||||
force = starting or (heating and not prev.get("heating"))
|
force = starting or (heating and not prev.get("heating"))
|
||||||
run = self._track_print(conn, host, name, filename, live, state, force_sample=force)
|
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 run_id:
|
||||||
|
store.set_printer_active_run(conn, host, run_id)
|
||||||
|
|
||||||
|
if state not in ("cancelled", "standby"):
|
||||||
|
self._cancel_hits[host] = 0
|
||||||
|
|
||||||
if prev_offline:
|
if prev_offline:
|
||||||
self._emit(conn, host, name, "offline", "%s ist wieder erreichbar." % name, filename, run_id)
|
self._emit(conn, host, name, "offline", "%s ist wieder erreichbar." % name, filename, run_id)
|
||||||
|
if file_changed and state == "printing":
|
||||||
|
self._emit(
|
||||||
|
conn, host, name, "start",
|
||||||
|
"%s druckt %s" % (name, filename or "einen Auftrag"),
|
||||||
|
filename, run_id,
|
||||||
|
)
|
||||||
|
self._ensure_start_segment(conn, host, filename, live, run_id)
|
||||||
elif starting:
|
elif starting:
|
||||||
self._emit(
|
self._emit(
|
||||||
conn, host, name, "start",
|
conn, host, name, "start",
|
||||||
@@ -175,9 +208,16 @@ class StateWatcher:
|
|||||||
self._emit(conn, host, name, "start", "%s pausiert." % name, filename, run_id)
|
self._emit(conn, host, name, "start", "%s pausiert." % name, filename, run_id)
|
||||||
elif prev_state == "paused" and state == "printing":
|
elif prev_state == "paused" and state == "printing":
|
||||||
self._emit(conn, host, name, "start", "%s druckt weiter." % name, filename, run_id)
|
self._emit(conn, host, name, "start", "%s druckt weiter." % name, filename, run_id)
|
||||||
|
try:
|
||||||
|
jobs.sync_history(conn, self.cfg, host=host, watcher_resume=True)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Watcher Abgleich Resume %s: %s", host, exc)
|
||||||
|
# Nach Fold: aktiven History-Lauf weiter tracken.
|
||||||
|
open_run = store.find_active_run(conn, host, filename)
|
||||||
|
if open_run:
|
||||||
|
run_id = open_run["id"]
|
||||||
|
store.set_printer_active_run(conn, host, run_id)
|
||||||
elif prev_state == "printing" and state == "complete":
|
elif prev_state == "printing" and state == "complete":
|
||||||
# Live-Übergang: immer belegen (ensure_bed_after_print ohne Endzeit
|
|
||||||
# würde nach Quittierung eines älteren Drucks stehen bleiben).
|
|
||||||
store.set_bed_needs_clear(conn, host, True, filename or "fertig")
|
store.set_bed_needs_clear(conn, host, True, filename or "fertig")
|
||||||
self._emit(
|
self._emit(
|
||||||
conn,
|
conn,
|
||||||
@@ -189,53 +229,77 @@ class StateWatcher:
|
|||||||
run_id or (prev.get("run_id")),
|
run_id or (prev.get("run_id")),
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
from grotti import jobs
|
jobs.sync_history(conn, self.cfg, host=host)
|
||||||
|
|
||||||
jobs.sync_history(conn, self.cfg)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
log.warning("Watcher Abgleich %s: %s", host, exc)
|
||||||
# History oft ohne Endzeit (interrupted) — Live trotzdem schließen.
|
# Live schließen ohne Abzug — History bucht; Fallback nach Frist.
|
||||||
dangling = store.find_live_run(conn, host)
|
dangling = store.find_live_run(conn, host)
|
||||||
if dangling:
|
if dangling:
|
||||||
closed = store.complete_open_run(conn, dangling["id"], state="completed")
|
store.complete_open_run(conn, dangling["id"], state="completed")
|
||||||
if closed:
|
store.set_printer_active_run(conn, host, None)
|
||||||
try:
|
|
||||||
from grotti import jobs
|
|
||||||
|
|
||||||
jobs.apply_deduction(conn, closed, self.cfg["energy"])
|
|
||||||
except Exception as exc:
|
|
||||||
print("Watcher Abzug %s: %s" % (host, exc), flush=True)
|
|
||||||
elif state in ("cancelled", "standby"):
|
elif state in ("cancelled", "standby"):
|
||||||
# Live-Lauf schließen: normaler Abbruch, oder nach Offline/Neustart
|
|
||||||
# (prev wird „offline“, der Übergang paused→standby geht sonst verloren).
|
|
||||||
dangling = store.find_live_run(conn, host)
|
dangling = store.find_live_run(conn, host)
|
||||||
from_active = prev_state in ("printing", "paused")
|
from_active = prev_state in ("printing", "paused")
|
||||||
recover = bool(dangling) and (prev_offline or not prev_state)
|
recover = bool(dangling) and (prev_offline or not prev_state)
|
||||||
if from_active or recover:
|
hits = self._cancel_hits.get(host, 0) + 1
|
||||||
|
self._cancel_hits[host] = hits
|
||||||
|
# Anycubic flackert kurz cancelled/standby während Pause/Resume —
|
||||||
|
# erst nach 3 Polls aufgeben. Nach Offline-Lücke sofort.
|
||||||
|
need_hits = 1 if recover else 3
|
||||||
|
if (from_active or recover) and hits >= need_hits:
|
||||||
file_label = filename or (dangling or {}).get("filename") or state
|
file_label = filename or (dangling or {}).get("filename") or state
|
||||||
store.set_bed_needs_clear(conn, host, True, file_label)
|
|
||||||
store.abandon_live_runs(conn, host)
|
|
||||||
self._emit(
|
|
||||||
conn,
|
|
||||||
host,
|
|
||||||
name,
|
|
||||||
"abbruch",
|
|
||||||
"%s: Druck abgebrochen (%s)" % (
|
|
||||||
name, file_label if file_label != state else "ohne Datei",
|
|
||||||
),
|
|
||||||
file_label if file_label != state else "",
|
|
||||||
run_id or prev.get("run_id") or (dangling or {}).get("id"),
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
from grotti import jobs
|
jobs.sync_history(conn, self.cfg, host=host)
|
||||||
|
|
||||||
jobs.sync_history(conn, self.cfg)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
log.warning("Watcher Abgleich %s: %s", host, exc)
|
||||||
|
# Nach Sync evtl. schon Fortsetzung — dann nicht aufgeben.
|
||||||
|
still = store.find_live_run(conn, host) or store.find_open_history_run(
|
||||||
|
conn, host, filename or (dangling or {}).get("filename") or ""
|
||||||
|
)
|
||||||
|
if still and (still.get("state") or "") in ("in_progress", "printing", "paused"):
|
||||||
|
self._cancel_hits[host] = 0
|
||||||
|
run_id = still["id"]
|
||||||
|
store.set_printer_active_run(conn, host, run_id)
|
||||||
|
else:
|
||||||
|
store.set_bed_needs_clear(conn, host, True, file_label)
|
||||||
|
store.abandon_live_runs(conn, host)
|
||||||
|
store.set_printer_active_run(conn, host, None)
|
||||||
|
emit_id = run_id or prev.get("run_id") or (dangling or {}).get("id")
|
||||||
|
label = file_label if file_label != state else ""
|
||||||
|
if emit_id and not store.get_run_row(conn, emit_id):
|
||||||
|
# History-Abgleich hat den Live-Lauf übernommen und gelöscht.
|
||||||
|
emit_id = store.latest_run_id(conn, host, label)
|
||||||
|
self._emit(
|
||||||
|
conn,
|
||||||
|
host,
|
||||||
|
name,
|
||||||
|
"abbruch",
|
||||||
|
"%s: Druck abgebrochen (%s)" % (
|
||||||
|
name, label or "ohne Datei",
|
||||||
|
),
|
||||||
|
label,
|
||||||
|
emit_id,
|
||||||
|
)
|
||||||
|
# prev-Zustand behalten, bis die Hits reichen.
|
||||||
|
if hits < 3 and from_active:
|
||||||
|
self._prev[host] = {
|
||||||
|
"state": prev_state,
|
||||||
|
"offline": False,
|
||||||
|
"heating": heating,
|
||||||
|
"filename": prev.get("filename") or filename,
|
||||||
|
"spool_warn": bool(prev.get("spool_warn")),
|
||||||
|
"run_id": run_id or prev.get("run_id"),
|
||||||
|
}
|
||||||
|
return
|
||||||
elif state == "error" and prev_state != "error":
|
elif state == "error" and prev_state != "error":
|
||||||
if prev_state == "printing" or prev_state == "paused":
|
if prev_state == "printing" or prev_state == "paused":
|
||||||
store.set_bed_needs_clear(conn, host, True, filename or "Fehler")
|
store.set_bed_needs_clear(conn, host, True, filename or "Fehler")
|
||||||
|
try:
|
||||||
|
jobs.sync_history(conn, self.cfg, host=host)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Watcher Abgleich %s: %s", host, exc)
|
||||||
store.abandon_live_runs(conn, host)
|
store.abandon_live_runs(conn, host)
|
||||||
|
store.set_printer_active_run(conn, host, None)
|
||||||
msg = (live.get("message") or "").strip()
|
msg = (live.get("message") or "").strip()
|
||||||
if not msg or msg.lower() in ("error", "fehler"):
|
if not msg or msg.lower() in ("error", "fehler"):
|
||||||
msg = "Ursache unbekannt — in Mainsail prüfen"
|
msg = "Ursache unbekannt — in Mainsail prüfen"
|
||||||
@@ -249,18 +313,10 @@ class StateWatcher:
|
|||||||
filename,
|
filename,
|
||||||
run_id or prev.get("run_id"),
|
run_id or prev.get("run_id"),
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
from grotti import jobs
|
|
||||||
|
|
||||||
jobs.sync_history(conn, self.cfg)
|
|
||||||
except Exception as exc:
|
|
||||||
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
|
||||||
elif state == "error":
|
elif state == "error":
|
||||||
if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0:
|
if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0:
|
||||||
self._cool_heaters(host)
|
self._cool_heaters(host)
|
||||||
elif state == "complete" and (not prev_state or prev_offline):
|
elif state == "complete" and (not prev_state or prev_offline):
|
||||||
# Erster Blick nach Neustart: nur mit Endzeit des letzten Laufs,
|
|
||||||
# sonst wird bereits Quittiertes wieder „Platte belegt“.
|
|
||||||
ended = conn.execute(
|
ended = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT ended_at FROM runs
|
SELECT ended_at FROM runs
|
||||||
@@ -278,6 +334,10 @@ class StateWatcher:
|
|||||||
ended_at=ended["ended_at"] if ended else None,
|
ended_at=ended["ended_at"] if ended else None,
|
||||||
message=filename or "fertig",
|
message=filename or "fertig",
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
jobs.sync_history(conn, self.cfg, host=host)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Watcher Abgleich %s: %s", host, exc)
|
||||||
|
|
||||||
spool_warn = bool(prev.get("spool_warn"))
|
spool_warn = bool(prev.get("spool_warn"))
|
||||||
if state == "printing" and not spool_warn:
|
if state == "printing" and not spool_warn:
|
||||||
@@ -292,6 +352,49 @@ class StateWatcher:
|
|||||||
"run_id": run_id or (prev.get("run_id") if state in ("printing", "paused") or heating else None),
|
"run_id": run_id or (prev.get("run_id") if state in ("printing", "paused") or heating else None),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _release_replaced_run(self, conn, host, name, filename):
|
||||||
|
"""Anderer Auftrag nach der Lücke: den offenen Lauf loslassen.
|
||||||
|
|
||||||
|
History zuerst, damit Proben am echten Job hängen. Bleibt der alte
|
||||||
|
Live-Lauf, wird er beendet ohne Abzug — der History-Abgleich bucht
|
||||||
|
das Filament, sobald der Job da ist.
|
||||||
|
"""
|
||||||
|
existing = store.find_live_run(conn, host)
|
||||||
|
if not existing:
|
||||||
|
return False
|
||||||
|
if contact_gap_keeps_run(existing.get("filename") or "", filename, "printing"):
|
||||||
|
return False
|
||||||
|
label = existing.get("filename") or "ohne Datei"
|
||||||
|
old_id = existing["id"]
|
||||||
|
try:
|
||||||
|
jobs.sync_history(conn, self.cfg, host=host)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Watcher Abgleich nach Offline %s: %s", host, exc)
|
||||||
|
existing = store.find_live_run(conn, host)
|
||||||
|
if not existing:
|
||||||
|
# History hat den Live-Lauf übernommen. Ereignis an den überlebenden Lauf.
|
||||||
|
emit_id = old_id if store.get_run_row(conn, old_id) else store.latest_run_id(conn, host, label)
|
||||||
|
self._emit(
|
||||||
|
conn, host, name, "abbruch",
|
||||||
|
"%s: Druck unterbrochen (%s)" % (name, label),
|
||||||
|
label, emit_id,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
if contact_gap_keeps_run(existing.get("filename") or "", filename, "printing"):
|
||||||
|
return False
|
||||||
|
label = existing.get("filename") or "ohne Datei"
|
||||||
|
store.abandon_live_runs(conn, host)
|
||||||
|
self._emit(
|
||||||
|
conn,
|
||||||
|
host,
|
||||||
|
name,
|
||||||
|
"abbruch",
|
||||||
|
"%s: Druck unterbrochen (%s)" % (name, label),
|
||||||
|
label,
|
||||||
|
existing["id"],
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
def _should_sample(self, conn, host, live, state):
|
def _should_sample(self, conn, host, live, state):
|
||||||
"""Proben bei Druck/Pause und beim Aufheizen mit Bezug zu einem Auftrag."""
|
"""Proben bei Druck/Pause und beim Aufheizen mit Bezug zu einem Auftrag."""
|
||||||
if state in ("printing", "paused"):
|
if state in ("printing", "paused"):
|
||||||
@@ -407,7 +510,11 @@ class StateWatcher:
|
|||||||
})
|
})
|
||||||
|
|
||||||
def _ensure_start_segment(self, conn, host, filename, live, run_id):
|
def _ensure_start_segment(self, conn, host, filename, live, run_id):
|
||||||
"""Nur externe Einzelspule: ein Abschnitt. ACE-Farben ohne Wechsel → Anteil-Split."""
|
"""Startabschnitte: externe Spule oder ACE-Plätze mit eingelegter Spule.
|
||||||
|
|
||||||
|
Ohne Rollenwechsel bleibt ACE-Abzug Anteil-Split (ein Segment je Platz).
|
||||||
|
Mit Wechsel greift die mm-Aufteilung innerhalb der Farbe (0→jetzt).
|
||||||
|
"""
|
||||||
if not run_id:
|
if not run_id:
|
||||||
return
|
return
|
||||||
existing = store.list_run_segments(conn, run_id)
|
existing = store.list_run_segments(conn, run_id)
|
||||||
@@ -418,9 +525,18 @@ class StateWatcher:
|
|||||||
from grotti import acm
|
from grotti import acm
|
||||||
|
|
||||||
mapping = acm.normalize_mapping(ace) if ace else []
|
mapping = acm.normalize_mapping(ace) if ace else []
|
||||||
if mapping:
|
|
||||||
return
|
|
||||||
used_mm = usage.as_float(live.get("filament_used"))
|
used_mm = usage.as_float(live.get("filament_used"))
|
||||||
|
if mapping:
|
||||||
|
seen = set()
|
||||||
|
for item in mapping:
|
||||||
|
slot = int(item["ams_index"]) + 1
|
||||||
|
if slot in seen:
|
||||||
|
continue
|
||||||
|
seen.add(slot)
|
||||||
|
row = store.spool_at_slot(conn, host, slot)
|
||||||
|
if row:
|
||||||
|
store.open_segment(conn, run_id, row["id"], slot, used_mm)
|
||||||
|
return
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id FROM spools
|
SELECT id FROM spools
|
||||||
@@ -436,7 +552,7 @@ class StateWatcher:
|
|||||||
try:
|
try:
|
||||||
moonraker.heaters_off(host)
|
moonraker.heaters_off(host)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Watcher Heizung aus %s: %s" % (host, exc), flush=True)
|
log.warning("Watcher Heizung aus %s: %s", host, exc)
|
||||||
|
|
||||||
def _check_spool(self, conn, host, name, filename, live):
|
def _check_spool(self, conn, host, name, filename, live):
|
||||||
need = float(live.get("weight") or 0)
|
need = float(live.get("weight") or 0)
|
||||||
@@ -471,6 +587,10 @@ class StateWatcher:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def _emit(self, conn, host, printer, kind, message, filename, run_id=None):
|
def _emit(self, conn, host, printer, kind, message, filename, run_id=None):
|
||||||
|
# Eine gelöschte Lauf-ID (Live wurde gerade übernommen) darf den Tick
|
||||||
|
# nicht am Fremdschlüssel zurückrollen — sonst ist der Abbruch weg.
|
||||||
|
if run_id is not None and not store.get_run_row(conn, run_id):
|
||||||
|
run_id = None
|
||||||
notify.record(
|
notify.record(
|
||||||
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,
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Baut grotti_<version>_<arch>.deb (auf dem Zielsystem oder arm64-Container).
|
||||||
|
# Aufruf aus dem Repo-Root: bash packaging/build-deb.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
if ! command -v dpkg-deb >/dev/null 2>&1; then
|
||||||
|
echo "dpkg-deb fehlt — bitte auf Debian/Raspberry Pi OS bauen." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="$(python3 -c "from grotti.version import __version__; print(__version__)")"
|
||||||
|
ARCH="$(dpkg --print-architecture)"
|
||||||
|
PKG="grotti_${VERSION}_${ARCH}"
|
||||||
|
DIST="$ROOT/packaging/dist"
|
||||||
|
STAGE="$DIST/$PKG"
|
||||||
|
|
||||||
|
rm -rf "$STAGE"
|
||||||
|
mkdir -p "$STAGE/DEBIAN" \
|
||||||
|
"$STAGE/usr/lib/grotti" \
|
||||||
|
"$STAGE/usr/bin" \
|
||||||
|
"$STAGE/etc/grotti" \
|
||||||
|
"$STAGE/lib/systemd/system" \
|
||||||
|
"$STAGE/usr/share/grotti" \
|
||||||
|
"$STAGE/usr/share/doc/grotti" \
|
||||||
|
"$STAGE/var/lib/grotti/data/eingang" \
|
||||||
|
"$STAGE/var/lib/grotti/slice"
|
||||||
|
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude '__pycache__' --exclude '*.pyc' --exclude '.pytest_cache' \
|
||||||
|
"$ROOT/grotti/" "$STAGE/usr/lib/grotti/grotti/"
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude '__pycache__' --exclude '*.pyc' \
|
||||||
|
"$ROOT/web/" "$STAGE/usr/lib/grotti/web/"
|
||||||
|
cp "$ROOT/server.py" "$STAGE/usr/lib/grotti/server.py"
|
||||||
|
cp "$ROOT/requirements.txt" "$STAGE/usr/lib/grotti/requirements.txt"
|
||||||
|
cp "$ROOT/config.example.json" "$STAGE/usr/lib/grotti/config.example.json"
|
||||||
|
mkdir -p "$STAGE/usr/lib/grotti/scripts"
|
||||||
|
cp "$ROOT/scripts/pfade_umziehen.py" "$STAGE/usr/lib/grotti/scripts/pfade_umziehen.py"
|
||||||
|
# Symlink-Pfad für die Anleitung
|
||||||
|
mkdir -p "$STAGE/opt/grotti"
|
||||||
|
ln -sfn /usr/lib/grotti/scripts "$STAGE/opt/grotti/scripts"
|
||||||
|
ln -sfn /usr/lib/grotti/venv "$STAGE/opt/grotti/venv"
|
||||||
|
echo '[]' > "$STAGE/usr/lib/grotti/drucker.json"
|
||||||
|
cp "$ROOT/packaging/config.pi.json" "$STAGE/usr/share/grotti/config.pi.json"
|
||||||
|
cp "$ROOT/packaging/config.pi.json" "$STAGE/etc/grotti/config.json"
|
||||||
|
cp "$ROOT/packaging/grotti.service" "$STAGE/lib/systemd/system/grotti.service"
|
||||||
|
cp "$ROOT/docs/Anleitung-Installation-Pi.md" "$STAGE/usr/share/doc/grotti/"
|
||||||
|
cp "$ROOT/CHANGELOG.md" "$STAGE/usr/share/doc/grotti/changelog"
|
||||||
|
gzip -9 -f "$STAGE/usr/share/doc/grotti/changelog"
|
||||||
|
|
||||||
|
cat > "$STAGE/usr/bin/grotti" <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
export GROTTI_CONFIG="${GROTTI_CONFIG:-/etc/grotti/config.json}"
|
||||||
|
exec /usr/lib/grotti/venv/bin/python /usr/lib/grotti/server.py "$@"
|
||||||
|
EOF
|
||||||
|
chmod 755 "$STAGE/usr/bin/grotti"
|
||||||
|
|
||||||
|
python3 -m venv "$STAGE/usr/lib/grotti/venv"
|
||||||
|
"$STAGE/usr/lib/grotti/venv/bin/pip" install --upgrade pip wheel
|
||||||
|
"$STAGE/usr/lib/grotti/venv/bin/pip" install -r "$ROOT/requirements.txt"
|
||||||
|
|
||||||
|
SIZE_KB="$(du -sk "$STAGE" | awk '{print $1}')"
|
||||||
|
cat > "$STAGE/DEBIAN/control" <<EOF
|
||||||
|
Package: grotti
|
||||||
|
Version: ${VERSION}
|
||||||
|
Section: web
|
||||||
|
Priority: optional
|
||||||
|
Architecture: ${ARCH}
|
||||||
|
Maintainer: Dominic Gundel <[email protected]>
|
||||||
|
Depends: python3 (>= 3.11), python3-venv, postgresql, adduser
|
||||||
|
Installed-Size: ${SIZE_KB}
|
||||||
|
Homepage: https://git.gundel.dev/dg/grotti
|
||||||
|
Description: Farm-Manager für Moonraker-Drucker (Kobra 3 / S1)
|
||||||
|
Grotti steuert eine 3D-Drucker-Farm über Moonraker: Wand, Warteschlange,
|
||||||
|
Material, Abläufe und Protokoll. PostgreSQL wird lokal mit eingerichtet.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cp "$ROOT/packaging/debian/conffiles" "$STAGE/DEBIAN/conffiles"
|
||||||
|
for script in postinst prerm postrm; do
|
||||||
|
cp "$ROOT/packaging/debian/$script" "$STAGE/DEBIAN/$script"
|
||||||
|
chmod 755 "$STAGE/DEBIAN/$script"
|
||||||
|
done
|
||||||
|
|
||||||
|
dpkg-deb --root-owner-group --build "$STAGE" "$DIST/${PKG}.deb"
|
||||||
|
echo "Gebaut: $DIST/${PKG}.deb"
|
||||||
|
echo "Install: sudo apt install ./packaging/dist/${PKG}.deb"
|
||||||
|
echo "Logs: journalctl -u grotti -f"
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 8770,
|
||||||
|
"data_dir": "/var/lib/grotti/data",
|
||||||
|
"watch_dir": "/var/lib/grotti/slice",
|
||||||
|
"database_url": "postgresql:///grotti?host=/var/run/postgresql",
|
||||||
|
"token": "",
|
||||||
|
"secret_key": "",
|
||||||
|
"fqdn": "",
|
||||||
|
"log_level": "INFO",
|
||||||
|
"http_threads": 8,
|
||||||
|
"auto_queue": true,
|
||||||
|
"auto_retry": true,
|
||||||
|
"oauth": {
|
||||||
|
"enabled": false,
|
||||||
|
"issuer": "",
|
||||||
|
"client_id": "",
|
||||||
|
"client_secret": "",
|
||||||
|
"redirect_uri": ""
|
||||||
|
},
|
||||||
|
"energy": {
|
||||||
|
"watts": 40,
|
||||||
|
"nozzle_watts": 60,
|
||||||
|
"bed_watts": 220,
|
||||||
|
"euro_per_kwh": 0.35,
|
||||||
|
"dryer_watts": 0,
|
||||||
|
"dry_hours": 0,
|
||||||
|
"waste_percent": 0,
|
||||||
|
"utilization": 50,
|
||||||
|
"tariff": {
|
||||||
|
"ht_euro": 0.35,
|
||||||
|
"nt_euro": 0.22,
|
||||||
|
"nt_from": "22:00",
|
||||||
|
"nt_to": "06:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"telegram": {
|
||||||
|
"enabled": false,
|
||||||
|
"bot_token": "",
|
||||||
|
"chat_id": ""
|
||||||
|
},
|
||||||
|
"ntfy": {
|
||||||
|
"enabled": false,
|
||||||
|
"server": "https://ntfy.sh",
|
||||||
|
"topic": "",
|
||||||
|
"token": ""
|
||||||
|
},
|
||||||
|
"alerts": {
|
||||||
|
"fertig": true,
|
||||||
|
"fehler": true,
|
||||||
|
"offline": true,
|
||||||
|
"queue": true,
|
||||||
|
"spule": true,
|
||||||
|
"ablauf": false,
|
||||||
|
"start": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/etc/grotti/config.json
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
_pg() {
|
||||||
|
if command -v runuser >/dev/null 2>&1; then
|
||||||
|
runuser -u postgres -- "$@"
|
||||||
|
else
|
||||||
|
su -s /bin/sh postgres -c "$*"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
configure)
|
||||||
|
if ! getent passwd grotti >/dev/null; then
|
||||||
|
adduser --system --group --home /var/lib/grotti --no-create-home \
|
||||||
|
--gecos "Grotti farm manager" grotti
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p /var/lib/grotti/data/eingang /var/lib/grotti/slice /etc/grotti
|
||||||
|
chown -R grotti:grotti /var/lib/grotti
|
||||||
|
chmod 750 /var/lib/grotti
|
||||||
|
|
||||||
|
if [ ! -f /etc/grotti/config.json ]; then
|
||||||
|
cp /usr/share/grotti/config.pi.json /etc/grotti/config.json
|
||||||
|
fi
|
||||||
|
chmod 640 /etc/grotti/config.json
|
||||||
|
chown root:grotti /etc/grotti/config.json
|
||||||
|
|
||||||
|
if getent passwd postgres >/dev/null 2>&1; then
|
||||||
|
_pg psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='grotti'" | grep -q 1 \
|
||||||
|
|| _pg createuser -D -R -S grotti || true
|
||||||
|
_pg psql -tAc "SELECT 1 FROM pg_database WHERE datname='grotti'" | grep -q 1 \
|
||||||
|
|| _pg createdb -O grotti grotti || true
|
||||||
|
_pg psql -c "GRANT ALL PRIVILEGES ON DATABASE grotti TO grotti;" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl daemon-reload || true
|
||||||
|
systemctl enable grotti.service || true
|
||||||
|
systemctl restart grotti.service || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
purge)
|
||||||
|
rm -f /etc/grotti/config.json
|
||||||
|
rmdir /etc/grotti 2>/dev/null || true
|
||||||
|
# Daten und DB bleiben absichtlich (Backup). Manuell:
|
||||||
|
# sudo rm -rf /var/lib/grotti
|
||||||
|
# sudo -u postgres dropdb grotti; sudo -u postgres dropuser grotti
|
||||||
|
;;
|
||||||
|
remove)
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl daemon-reload || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
remove|upgrade|deconfigure)
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl stop grotti.service 2>/dev/null || true
|
||||||
|
systemctl disable grotti.service 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
Vendored
Whitespace-only changes.
@@ -0,0 +1,27 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Grotti Farm-Manager
|
||||||
|
Documentation=file:///usr/share/doc/grotti/Anleitung-Installation-Pi.md
|
||||||
|
After=network-online.target postgresql.service
|
||||||
|
Wants=network-online.target
|
||||||
|
Requires=postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=grotti
|
||||||
|
Group=grotti
|
||||||
|
WorkingDirectory=/usr/lib/grotti
|
||||||
|
Environment=GROTTI_CONFIG=/etc/grotti/config.json
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
ExecStart=/usr/lib/grotti/venv/bin/python /usr/lib/grotti/server.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
# Stopp nicht 90 s hängen: nach TimeoutStopSec folgt SIGKILL
|
||||||
|
TimeoutStopSec=20
|
||||||
|
KillMode=mixed
|
||||||
|
# Logs → journald (journalctl -u grotti -f)
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=grotti
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
+1
-1
@@ -4,5 +4,5 @@ psycopg-pool==3.3.3
|
|||||||
paho-mqtt==2.1.0
|
paho-mqtt==2.1.0
|
||||||
cryptography==50.0.2
|
cryptography==50.0.2
|
||||||
markdown==3.11.1
|
markdown==3.11.1
|
||||||
mcp>=1.2
|
mcp==2.0.0
|
||||||
waitress==3.0.2
|
waitress==3.0.2
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Alt-Paare und verwaiste Live-Läufe finden. Standard: Probelauf.
|
||||||
|
|
||||||
|
python scripts/laeufe_reparieren.py
|
||||||
|
python scripts/laeufe_reparieren.py --apply
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from grotti import jobs, store # noqa: E402
|
||||||
|
from grotti.config import load # noqa: E402
|
||||||
|
from grotti.db import connect, prepare # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def find_pairs(conn):
|
||||||
|
return conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT a.id AS pred_id, b.id AS succ_id,
|
||||||
|
a.printer_host, a.filename,
|
||||||
|
a.state AS pred_state, b.state AS succ_state,
|
||||||
|
a.duration_s AS pred_dur, b.duration_s AS succ_dur,
|
||||||
|
a.ended_at, b.started_at,
|
||||||
|
a.deducted AS pred_deducted, b.deducted AS succ_deducted,
|
||||||
|
a.chargen_nr AS pred_chargen, b.chargen_nr AS succ_chargen
|
||||||
|
FROM runs a
|
||||||
|
JOIN runs b
|
||||||
|
ON a.printer_host = b.printer_host
|
||||||
|
AND a.filename = b.filename
|
||||||
|
AND a.id <> b.id
|
||||||
|
AND b.started_at BETWEEN a.ended_at - interval '30 seconds'
|
||||||
|
AND a.ended_at + interval '5 seconds'
|
||||||
|
WHERE a.state IN ('cancelled', 'interrupted', 'error')
|
||||||
|
AND a.ended_at IS NOT NULL
|
||||||
|
AND a.moonraker_job_id NOT LIKE 'live:%%'
|
||||||
|
AND b.moonraker_job_id NOT LIKE 'live:%%'
|
||||||
|
AND COALESCE(b.duration_s, 0) > 60
|
||||||
|
AND COALESCE(b.duration_s, 0) >= COALESCE(a.duration_s, 0) - 30
|
||||||
|
ORDER BY a.ended_at DESC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def find_orphan_live(conn):
|
||||||
|
return conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, printer_host, filename, state, started_at, ended_at, duration_s, chargen_nr
|
||||||
|
FROM runs
|
||||||
|
WHERE moonraker_job_id LIKE 'live:%%'
|
||||||
|
AND state IN ('interrupted', 'completed', 'cancelled')
|
||||||
|
ORDER BY COALESCE(ended_at, started_at) DESC
|
||||||
|
LIMIT 100
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Läufe nach Filament-Resume reparieren")
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Wirklich zusammenlegen")
|
||||||
|
args = parser.parse_args()
|
||||||
|
cfg = load()
|
||||||
|
prepare(cfg["database_url"])
|
||||||
|
with connect(cfg["database_url"]) as conn:
|
||||||
|
pairs = find_pairs(conn)
|
||||||
|
orphans = find_orphan_live(conn)
|
||||||
|
print("=== Verdachtspaare (Beweis: Dauer fortgesetzt, Lücke ≤ 5 s) ===")
|
||||||
|
for row in pairs:
|
||||||
|
print(json.dumps({k: str(v) for k, v in dict(row).items()}, ensure_ascii=False))
|
||||||
|
print("%d Paare" % len(pairs))
|
||||||
|
print("=== Verwaiste live:-Läufe ===")
|
||||||
|
for row in orphans:
|
||||||
|
print(json.dumps({k: str(v) for k, v in dict(row).items()}, ensure_ascii=False))
|
||||||
|
print("%d orphan" % len(orphans))
|
||||||
|
if not args.apply:
|
||||||
|
print("Probelauf. Mit --apply werden Paare verbunden.")
|
||||||
|
conn.rollback()
|
||||||
|
return
|
||||||
|
merged = 0
|
||||||
|
for row in pairs:
|
||||||
|
try:
|
||||||
|
jobs.manual_merge_runs(
|
||||||
|
conn, row["pred_id"], row["succ_id"], confirm=True
|
||||||
|
)
|
||||||
|
merged += 1
|
||||||
|
print("verbunden", row["pred_id"], "+", row["succ_id"])
|
||||||
|
except Exception as exc:
|
||||||
|
print("Fehler", row["pred_id"], row["succ_id"], exc)
|
||||||
|
conn.rollback()
|
||||||
|
# Orphans nur an History derselben Datei hängen (kein Host-Fallback).
|
||||||
|
# adopt_live_run nimmt geschlossene live: nur ≤ 2 h — ältere bleiben liegen.
|
||||||
|
cleaned = 0
|
||||||
|
skipped = 0
|
||||||
|
for row in orphans:
|
||||||
|
fname = (row.get("filename") or "").strip()
|
||||||
|
if not fname:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
hist = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM runs
|
||||||
|
WHERE printer_host = %s
|
||||||
|
AND filename = %s
|
||||||
|
AND moonraker_job_id NOT LIKE 'live:%%'
|
||||||
|
AND state IN ('in_progress', 'printing', 'paused')
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(row["printer_host"], fname),
|
||||||
|
).fetchone()
|
||||||
|
if not hist:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
store.adopt_live_run(conn, row["printer_host"], fname, hist["id"])
|
||||||
|
still = conn.execute(
|
||||||
|
"SELECT 1 FROM runs WHERE id = %s", (row["id"],)
|
||||||
|
).fetchone()
|
||||||
|
if still:
|
||||||
|
skipped += 1
|
||||||
|
else:
|
||||||
|
cleaned += 1
|
||||||
|
conn.commit()
|
||||||
|
print(
|
||||||
|
"verbunden=%d orphan_adopted=%d orphan_skipped=%d"
|
||||||
|
% (merged, cleaned, skipped)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Absolute Windows-Pfade in files.stored_path und projects.folder auf Linux umschreiben.
|
||||||
|
|
||||||
|
Beispiel (Dry-Run):
|
||||||
|
python scripts/pfade_umziehen.py \\
|
||||||
|
--database-url postgresql:///grotti?host=/var/run/postgresql \\
|
||||||
|
--from "D:/Projekte/3DP/Slice" --to /var/lib/grotti/slice
|
||||||
|
|
||||||
|
Ohne --apply nur Anzeige. Mit --apply werden die Zeilen geschrieben.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_slash(path: str) -> str:
|
||||||
|
return (path or "").replace("\\", "/").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def rewrite(value: str, old: str, new: str) -> str | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
cur = value.replace("\\", "/")
|
||||||
|
old_n = normalize_slash(old)
|
||||||
|
new_n = normalize_slash(new)
|
||||||
|
# case-insensitive für Windows-Laufwerke
|
||||||
|
if cur.lower().startswith(old_n.lower()):
|
||||||
|
rest = cur[len(old_n) :]
|
||||||
|
if rest and not rest.startswith("/"):
|
||||||
|
rest = "/" + rest
|
||||||
|
return new_n + rest
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Slice-Pfade Windows → Linux umschreiben")
|
||||||
|
parser.add_argument("--database-url", required=True)
|
||||||
|
parser.add_argument("--from", dest="old", required=True, help="Alter Prefix (Windows)")
|
||||||
|
parser.add_argument("--to", dest="new", required=True, help="Neuer Prefix (Linux)")
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Wirklich schreiben")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
|
old = args.old
|
||||||
|
new = args.new
|
||||||
|
changed_files = 0
|
||||||
|
changed_projects = 0
|
||||||
|
|
||||||
|
with psycopg.connect(args.database_url, row_factory=dict_row) as conn:
|
||||||
|
files = conn.execute("SELECT id, stored_path FROM files ORDER BY id").fetchall()
|
||||||
|
for row in files:
|
||||||
|
nxt = rewrite(row["stored_path"] or "", old, new)
|
||||||
|
if nxt is None or nxt == row["stored_path"]:
|
||||||
|
continue
|
||||||
|
changed_files += 1
|
||||||
|
print("file %s: %s → %s" % (row["id"], row["stored_path"], nxt))
|
||||||
|
if args.apply:
|
||||||
|
conn.execute("UPDATE files SET stored_path = %s WHERE id = %s", (nxt, row["id"]))
|
||||||
|
|
||||||
|
projects = conn.execute("SELECT id, folder FROM projects ORDER BY id").fetchall()
|
||||||
|
for row in projects:
|
||||||
|
folder = row["folder"] or ""
|
||||||
|
# folder ist relativ — nur absolute Reste bereinigen
|
||||||
|
nxt = rewrite(folder, old, new)
|
||||||
|
if nxt is None:
|
||||||
|
continue
|
||||||
|
# relativ zum neuen Slice-Root speichern, falls möglich
|
||||||
|
new_n = normalize_slash(new)
|
||||||
|
if nxt.lower().startswith(new_n.lower()):
|
||||||
|
rel = nxt[len(new_n) :].lstrip("/")
|
||||||
|
nxt = rel
|
||||||
|
changed_projects += 1
|
||||||
|
print("project %s: %s → %s" % (row["id"], folder, nxt))
|
||||||
|
if args.apply:
|
||||||
|
conn.execute("UPDATE projects SET folder = %s WHERE id = %s", (nxt, row["id"]))
|
||||||
|
|
||||||
|
if args.apply:
|
||||||
|
conn.commit()
|
||||||
|
print("Geschrieben: %s Dateien, %s Projekte." % (changed_files, changed_projects))
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
"Dry-Run: %s Dateien, %s Projekte würden sich ändern. Mit --apply ausführen."
|
||||||
|
% (changed_files, changed_projects)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -7,10 +7,12 @@ import time
|
|||||||
|
|
||||||
from waitress import serve
|
from waitress import serve
|
||||||
|
|
||||||
from grotti import db, jobs, library, store
|
from grotti import db, jobs, library, logutil, store
|
||||||
from grotti.config import load
|
from grotti.config import load
|
||||||
from web.app import create_app
|
from web.app import create_app
|
||||||
|
|
||||||
|
log = logutil.get("grotti.server")
|
||||||
|
|
||||||
|
|
||||||
def background(cfg):
|
def background(cfg):
|
||||||
def work():
|
def work():
|
||||||
@@ -20,10 +22,10 @@ def background(cfg):
|
|||||||
jobs.sync_history(conn, cfg)
|
jobs.sync_history(conn, cfg)
|
||||||
deleted = db.prune_run_samples(conn, keep_days=90)
|
deleted = db.prune_run_samples(conn, keep_days=90)
|
||||||
if deleted:
|
if deleted:
|
||||||
print("Proben gelöscht: %s" % deleted, flush=True)
|
log.info("Proben gelöscht: %s", deleted)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Abgleich: %s" % exc, flush=True)
|
log.warning("Abgleich: %s", exc)
|
||||||
|
|
||||||
threading.Thread(target=work, name="grotti-abgleich", daemon=True).start()
|
threading.Thread(target=work, name="grotti-abgleich", daemon=True).start()
|
||||||
|
|
||||||
@@ -39,14 +41,15 @@ def _retention_loop(cfg, stop_event):
|
|||||||
n = db.prune_run_samples(conn, keep_days=90)
|
n = db.prune_run_samples(conn, keep_days=90)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
if n:
|
if n:
|
||||||
print("Retention: %s Proben gelöscht." % n, flush=True)
|
log.info("Retention: %s Proben gelöscht.", n)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Retention: %s" % exc, flush=True)
|
log.warning("Retention: %s", exc)
|
||||||
stop_event.wait(3600)
|
stop_event.wait(3600)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
cfg = load()
|
cfg = load()
|
||||||
|
logutil.setup(cfg.get("log_level") or "INFO")
|
||||||
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)
|
||||||
db.prepare(cfg["database_url"])
|
db.prepare(cfg["database_url"])
|
||||||
@@ -107,8 +110,14 @@ def main():
|
|||||||
except (ValueError, OSError):
|
except (ValueError, OSError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
print("Grotti: http://%s:%s/" % (cfg["host"], cfg["port"]), flush=True)
|
log.info("Grotti: http://%s:%s/", cfg["host"], cfg["port"])
|
||||||
serve(app, host=cfg["host"], port=cfg["port"], threads=16, channel_timeout=120)
|
serve(
|
||||||
|
app,
|
||||||
|
host=cfg["host"],
|
||||||
|
port=cfg["port"],
|
||||||
|
threads=int(cfg.get("http_threads") or 16),
|
||||||
|
channel_timeout=120,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Leere History-Hülle, Resume-Beweis und Chargen-Tag."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from grotti.jobs import (
|
||||||
|
history_in_order,
|
||||||
|
history_shell_is_empty,
|
||||||
|
is_filament_resume,
|
||||||
|
)
|
||||||
|
from grotti.store import chargen_day, history_job_retired
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryShellTests(unittest.TestCase):
|
||||||
|
def test_pause_shell_is_empty(self):
|
||||||
|
self.assertTrue(history_shell_is_empty({"duration_s": 0, "ended_at": None}))
|
||||||
|
|
||||||
|
def test_recorded_cancel_is_not_a_shell(self):
|
||||||
|
self.assertFalse(history_shell_is_empty({
|
||||||
|
"duration_s": 653,
|
||||||
|
"ended_at": datetime(2026, 10, 18, 13, 18, tzinfo=timezone.utc),
|
||||||
|
}))
|
||||||
|
|
||||||
|
def test_missing_row_is_not_a_shell(self):
|
||||||
|
self.assertFalse(history_shell_is_empty(None))
|
||||||
|
|
||||||
|
|
||||||
|
class FilamentResumeProofTests(unittest.TestCase):
|
||||||
|
def test_duration_continued_with_tiny_gap(self):
|
||||||
|
# 2026-10-25 Kobra 3 A1: 000040 → 000041, 0,04 s Lücke, gleiche print_duration
|
||||||
|
end = datetime(2026, 10, 25, 5, 48, 27, 569483, tzinfo=timezone.utc)
|
||||||
|
start = end + timedelta(milliseconds=40)
|
||||||
|
self.assertTrue(is_filament_resume(
|
||||||
|
end, start, prev_duration_s=11711, new_duration_s=11711
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_fresh_restart_after_cancel_is_not_resume(self):
|
||||||
|
# Lauf 27941: 8 s abgebrochen, 45 s später Neustart mit Dauer ~0
|
||||||
|
end = datetime(2026, 10, 24, 11, 46, 8, tzinfo=timezone.utc)
|
||||||
|
start = end + timedelta(seconds=45)
|
||||||
|
self.assertFalse(is_filament_resume(
|
||||||
|
end, start, prev_duration_s=8, new_duration_s=5
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_two_arg_call_without_proof_is_false(self):
|
||||||
|
end = datetime(2026, 10, 21, 19, 32, 20, tzinfo=timezone.utc)
|
||||||
|
start = end + timedelta(milliseconds=40)
|
||||||
|
self.assertFalse(is_filament_resume(end, start))
|
||||||
|
|
||||||
|
def test_watcher_signal_allows_longer_gap(self):
|
||||||
|
end = datetime(2026, 10, 25, 1, 0, 0, tzinfo=timezone.utc)
|
||||||
|
start = end + timedelta(hours=4)
|
||||||
|
self.assertTrue(is_filament_resume(
|
||||||
|
end, start, prev_duration_s=1000, new_duration_s=1000, watcher_resume=True
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_duration_reset_is_not_resume(self):
|
||||||
|
end = datetime(2026, 10, 25, 5, 48, 27, tzinfo=timezone.utc)
|
||||||
|
start = end + timedelta(milliseconds=40)
|
||||||
|
self.assertFalse(is_filament_resume(
|
||||||
|
end, start, prev_duration_s=11711, new_duration_s=12
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryOrderTests(unittest.TestCase):
|
||||||
|
def test_cancelled_part_before_resume(self):
|
||||||
|
items = [
|
||||||
|
{"job_id": "000041", "start_time": 1792907307.607},
|
||||||
|
{"job_id": "000040", "start_time": 1792879062.392},
|
||||||
|
]
|
||||||
|
self.assertEqual([j["job_id"] for j in history_in_order(items)], ["000040", "000041"])
|
||||||
|
|
||||||
|
def test_missing_start_first(self):
|
||||||
|
items = [{"job_id": "b", "start_time": 5}, {"job_id": "a"}]
|
||||||
|
self.assertEqual([j["job_id"] for j in history_in_order(items)], ["a", "b"])
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryJobRetiredTests(unittest.TestCase):
|
||||||
|
def _conn(self, row):
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.execute.return_value.fetchone.return_value = row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def test_folded_predecessor_is_retired(self):
|
||||||
|
conn = self._conn({"moonraker_job_id": "000041"})
|
||||||
|
self.assertTrue(history_job_retired(conn, "h", "000040"))
|
||||||
|
|
||||||
|
def test_own_job_is_not_retired(self):
|
||||||
|
conn = self._conn({"moonraker_job_id": "000041"})
|
||||||
|
self.assertFalse(history_job_retired(conn, "h", "000041"))
|
||||||
|
|
||||||
|
def test_unknown_job_is_not_retired(self):
|
||||||
|
self.assertFalse(history_job_retired(self._conn(None), "h", "000042"))
|
||||||
|
|
||||||
|
|
||||||
|
class ChargenDayTests(unittest.TestCase):
|
||||||
|
def test_old_print_keeps_its_day(self):
|
||||||
|
started = datetime(2026, 10, 18, 13, 4, 16, tzinfo=timezone.utc)
|
||||||
|
self.assertEqual(chargen_day(started, today=date(2026, 10, 24)), date(2026, 10, 18))
|
||||||
|
|
||||||
|
def test_without_start_uses_today(self):
|
||||||
|
self.assertEqual(chargen_day(None, today=date(2026, 10, 24)), date(2026, 10, 24))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -69,13 +69,36 @@ class ChooseSpoolTests(unittest.TestCase):
|
|||||||
):
|
):
|
||||||
self.assertEqual(jobs.resolve_tracking_spool(conn, "h", 4), 14)
|
self.assertEqual(jobs.resolve_tracking_spool(conn, "h", 4), 14)
|
||||||
|
|
||||||
|
def test_ace_target_skips_spool_created_after_end(self):
|
||||||
|
end = datetime(2026, 10, 15, 22, 0, tzinfo=timezone.utc)
|
||||||
|
future = {"id": 18, "created_at": end + timedelta(days=6)}
|
||||||
|
conn = MagicMock()
|
||||||
|
with patch("grotti.jobs.store.spool_at_slot", return_value=future):
|
||||||
|
self.assertEqual(
|
||||||
|
jobs._targets_from_ace(conn, "h", [{"ams_index": 3}], ended_at=end),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
def test_spool_valid_for_end_naive(self):
|
def test_spool_valid_for_end_naive(self):
|
||||||
end = datetime(2026, 10, 1, 12, 0, tzinfo=timezone.utc)
|
end = datetime(2026, 10, 1, 12, 0, tzinfo=timezone.utc)
|
||||||
spool = {"created_at": datetime(2026, 9, 1, 12, 0)}
|
spool = {"created_at": datetime(2026, 9, 1, 12, 0)}
|
||||||
self.assertTrue(jobs._spool_valid_for_end(spool, end))
|
self.assertTrue(jobs._spool_valid_for_end(spool, end))
|
||||||
|
|
||||||
|
|
||||||
|
class UndoDeductionTests(unittest.TestCase):
|
||||||
|
def test_reopens_last_segment(self):
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.execute.return_value.fetchall.return_value = [
|
||||||
|
{"spool_id": 7, "weight_g": 10},
|
||||||
|
]
|
||||||
|
run = {"id": 42, "deducted": True}
|
||||||
|
self.assertTrue(jobs.undo_deduction(conn, run))
|
||||||
|
sqls = [str(call.args[0]) for call in conn.execute.call_args_list]
|
||||||
|
self.assertTrue(any("UPDATE run_segments" in sql and "mm_end = NULL" in sql for sql in sqls))
|
||||||
|
|
||||||
|
|
||||||
class SteuernAliasTests(unittest.TestCase):
|
class SteuernAliasTests(unittest.TestCase):
|
||||||
|
|
||||||
@patch("grotti.moonraker.gcode_script", return_value={"ok": True})
|
@patch("grotti.moonraker.gcode_script", return_value={"ok": True})
|
||||||
def test_value_alias_for_temp(self, gcode):
|
def test_value_alias_for_temp(self, gcode):
|
||||||
moonraker.steuern("10.0.0.1", "nozzle", {"value": 210})
|
moonraker.steuern("10.0.0.1", "nozzle", {"value": 210})
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Logging-Hilfen."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from grotti import logutil
|
||||||
|
|
||||||
|
|
||||||
|
class LogutilTests(unittest.TestCase):
|
||||||
|
def test_parse_level(self):
|
||||||
|
self.assertEqual(logutil.parse_level("DEBUG"), logging.DEBUG)
|
||||||
|
self.assertEqual(logutil.parse_level("warn"), logging.WARNING)
|
||||||
|
self.assertEqual(logutil.parse_level("nope"), logging.INFO)
|
||||||
|
|
||||||
|
def test_setup_idempotent(self):
|
||||||
|
logutil.setup("INFO")
|
||||||
|
logutil.setup("DEBUG")
|
||||||
|
self.assertEqual(logging.getLogger().level, logging.DEBUG)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -53,11 +53,56 @@ class MatchSpoolsTests(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
def test_base_sunlu_pla_matches_plain_pla_not_meta(self):
|
def test_base_sunlu_pla_matches_any_pla(self):
|
||||||
need = {"profile": "Sunlu PLA", "material_type": "pla", "color": "", "paint_index": 0}
|
need = {"profile": "Sunlu PLA", "material_type": "pla", "color": "", "paint_index": 0}
|
||||||
matched = store._match_spools_for_need(need, self.have)
|
matched = store._match_spools_for_need(need, self.have)
|
||||||
ids = {m["spool_id"] for m in matched}
|
ids = {m["spool_id"] for m in matched}
|
||||||
self.assertEqual(ids, {3}) # Eryone PLA; Meta/Matte nicht
|
self.assertEqual(ids, {1, 2, 3})
|
||||||
|
|
||||||
|
def test_base_pla_matches_matte_alone(self):
|
||||||
|
need = {"profile": "Sunlu PLA", "material_type": "pla", "color": "#f2a900", "paint_index": 0}
|
||||||
|
have = [self.have[1]]
|
||||||
|
matched = store._match_spools_for_need(need, have)
|
||||||
|
self.assertEqual([m["spool_id"] for m in matched], [2])
|
||||||
|
|
||||||
|
def test_meta_falls_back_to_any_pla(self):
|
||||||
|
need = {"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "", "paint_index": 0}
|
||||||
|
have = [self.have[1]]
|
||||||
|
matched = store._match_spools_for_need(need, have)
|
||||||
|
self.assertEqual([m["spool_id"] for m in matched], [2])
|
||||||
|
|
||||||
|
def test_petg_does_not_match_pla(self):
|
||||||
|
need = {"profile": "Sunlu PETG", "material_type": "petg", "color": "", "paint_index": 0}
|
||||||
|
self.assertEqual(store._match_spools_for_need(need, self.have), [])
|
||||||
|
|
||||||
|
def test_pla_lite_hex_matches_any_pla(self):
|
||||||
|
# Orca: Profil „Eryone PLA Lite“, Farbe #26A69A — Lager: Eryone/Sunlu PLA „Grau“
|
||||||
|
need = {
|
||||||
|
"profile": "Eryone PLA Lite",
|
||||||
|
"material_type": "pla",
|
||||||
|
"color": "#26A69A",
|
||||||
|
"paint_index": 0,
|
||||||
|
}
|
||||||
|
have = [
|
||||||
|
{
|
||||||
|
"spool_id": 15,
|
||||||
|
"material_type": "pla",
|
||||||
|
"color": "grau",
|
||||||
|
"remaining_g": 873,
|
||||||
|
"material_name": "sunlu pla",
|
||||||
|
"ace_slot": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"spool_id": 1,
|
||||||
|
"material_type": "pla",
|
||||||
|
"color": "grau",
|
||||||
|
"remaining_g": 800,
|
||||||
|
"material_name": "sunlu pla meta",
|
||||||
|
"ace_slot": 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
matched = store._match_spools_for_need(need, have)
|
||||||
|
self.assertEqual({m["spool_id"] for m in matched}, {15, 1})
|
||||||
|
|
||||||
def test_picks_meta_not_matte(self):
|
def test_picks_meta_not_matte(self):
|
||||||
need = {"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "", "paint_index": 0}
|
need = {"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "", "paint_index": 0}
|
||||||
|
|||||||
@@ -8,7 +8,15 @@ from unittest import mock
|
|||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
sys.path.insert(0, str(ROOT))
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
from grotti.print_queue import PRINT_START_TIMEOUT_S, PrintQueueDispatcher, _cool_wait_text
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from grotti.jobs import is_filament_resume
|
||||||
|
from grotti.print_queue import (
|
||||||
|
PRINT_START_TIMEOUT_S,
|
||||||
|
TERMINAL_CONFIRM_HITS,
|
||||||
|
PrintQueueDispatcher,
|
||||||
|
_cool_wait_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CoolTextTests(unittest.TestCase):
|
class CoolTextTests(unittest.TestCase):
|
||||||
@@ -20,9 +28,26 @@ class CoolTextTests(unittest.TestCase):
|
|||||||
self.assertNotIn(" · ", text)
|
self.assertNotIn(" · ", text)
|
||||||
|
|
||||||
|
|
||||||
|
class FilamentResumeTests(unittest.TestCase):
|
||||||
|
def test_same_moment_with_duration_is_resume(self):
|
||||||
|
end = datetime(2026, 10, 21, 19, 32, 20, tzinfo=timezone.utc)
|
||||||
|
start = end + timedelta(milliseconds=40)
|
||||||
|
self.assertTrue(is_filament_resume(
|
||||||
|
end, start, prev_duration_s=500, new_duration_s=520
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_later_reprint_is_not_resume(self):
|
||||||
|
end = datetime(2026, 10, 21, 19, 32, 20, tzinfo=timezone.utc)
|
||||||
|
start = end + timedelta(minutes=10)
|
||||||
|
self.assertFalse(is_filament_resume(
|
||||||
|
end, start, prev_duration_s=500, new_duration_s=10
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
class DispatcherHelpers(unittest.TestCase):
|
class DispatcherHelpers(unittest.TestCase):
|
||||||
def test_timeout_constant(self):
|
def test_timeout_constant(self):
|
||||||
self.assertGreaterEqual(PRINT_START_TIMEOUT_S, 300)
|
self.assertGreaterEqual(PRINT_START_TIMEOUT_S, 300)
|
||||||
|
self.assertGreaterEqual(TERMINAL_CONFIRM_HITS, 2)
|
||||||
|
|
||||||
def test_worker_busy_empty(self):
|
def test_worker_busy_empty(self):
|
||||||
d = PrintQueueDispatcher("postgresql://x", {}, watcher=None)
|
d = PrintQueueDispatcher("postgresql://x", {}, watcher=None)
|
||||||
@@ -34,6 +59,21 @@ class DispatcherHelpers(unittest.TestCase):
|
|||||||
d.cfg = {"auto_queue": False}
|
d.cfg = {"auto_queue": False}
|
||||||
self.assertFalse(d._auto_enabled())
|
self.assertFalse(d._auto_enabled())
|
||||||
|
|
||||||
|
def test_no_retry_after_abort(self):
|
||||||
|
d = PrintQueueDispatcher("postgresql://x", {}, watcher=None)
|
||||||
|
self.assertFalse(d._retryable_message("Druck abgebrochen."))
|
||||||
|
self.assertTrue(d._retryable_message("Start-Timeout"))
|
||||||
|
|
||||||
|
def test_advance_simple_ignores_single_cancelled(self):
|
||||||
|
d = PrintQueueDispatcher("postgresql://x", {}, watcher=None)
|
||||||
|
item = {"id": 1, "phase": "print_active", "filename": "a.gcode"}
|
||||||
|
printer = {"host": "h", "name": "P"}
|
||||||
|
live = {"state": "cancelled", "print_active": False}
|
||||||
|
with mock.patch("grotti.print_queue.store.update_print_queue") as upd:
|
||||||
|
d._advance_simple(mock.Mock(), item, printer, live)
|
||||||
|
upd.assert_not_called()
|
||||||
|
self.assertEqual(d._term_hits.get("1:end"), 1)
|
||||||
|
|
||||||
|
|
||||||
class ResolveSpoolTests(unittest.TestCase):
|
class ResolveSpoolTests(unittest.TestCase):
|
||||||
def test_material_match(self):
|
def test_material_match(self):
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Szenarien für einen physischen Druck = ein Lauf (ohne DB, mit Mocks)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from grotti import jobs
|
||||||
|
|
||||||
|
|
||||||
|
class SyncOneJobShellTests(unittest.TestCase):
|
||||||
|
def test_empty_shell_skipped(self):
|
||||||
|
conn = MagicMock()
|
||||||
|
printer = {"host": "h", "name": "P"}
|
||||||
|
job = {
|
||||||
|
"job_id": "000041",
|
||||||
|
"filename": "a.gcode",
|
||||||
|
"status": "in_progress",
|
||||||
|
"start_time": 1000,
|
||||||
|
"end_time": None,
|
||||||
|
"print_duration": 0,
|
||||||
|
"total_duration": 0,
|
||||||
|
"metadata": {},
|
||||||
|
}
|
||||||
|
self.assertEqual(jobs._sync_one_job(conn, {"energy": {}}, printer, job), 0)
|
||||||
|
conn.execute.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class FoldProofIntegrationLike(unittest.TestCase):
|
||||||
|
def test_fold_requires_duration_proof(self):
|
||||||
|
end = datetime(2026, 10, 25, 5, 48, 27, tzinfo=timezone.utc)
|
||||||
|
start = end + timedelta(milliseconds=40)
|
||||||
|
pred = {
|
||||||
|
"id": 1,
|
||||||
|
"ended_at": end,
|
||||||
|
"duration_s": 11711,
|
||||||
|
"moonraker_job_id": "000040",
|
||||||
|
"verdict": "",
|
||||||
|
}
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.execute.return_value.fetchone.return_value = None
|
||||||
|
with patch("grotti.jobs.store.find_resume_predecessor", return_value=pred), \
|
||||||
|
patch("grotti.jobs.store.remember_run_job"), \
|
||||||
|
patch("grotti.jobs.store.move_run_jobs"), \
|
||||||
|
patch("grotti.jobs.store.set_printer_active_run"), \
|
||||||
|
patch("grotti.jobs.undo_deduction"):
|
||||||
|
# Frischer Neustart (Dauer 12) — kein Fold
|
||||||
|
self.assertIsNone(jobs.fold_filament_resume(
|
||||||
|
conn, "h", "a.gcode", "000041", start, print_duration_s=12
|
||||||
|
))
|
||||||
|
# Fortgesetzte Dauer — Fold
|
||||||
|
self.assertEqual(
|
||||||
|
jobs.fold_filament_resume(
|
||||||
|
conn, "h", "a.gcode", "000041", start, print_duration_s=11711
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(os.environ.get("GROTTI_TEST_DB"), "GROTTI_TEST_DB nicht gesetzt")
|
||||||
|
class LiveDbScenarios(unittest.TestCase):
|
||||||
|
"""Gegen echte Test-DB: GROTTI_TEST_DB=postgresql://…"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
import psycopg
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
|
||||||
|
self.conn = psycopg.connect(os.environ["GROTTI_TEST_DB"], row_factory=dict_row)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.conn.rollback()
|
||||||
|
self.conn.close()
|
||||||
|
|
||||||
|
def test_connection(self):
|
||||||
|
self.assertTrue(self.conn.execute("SELECT 1").fetchone())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""ACE-Startabschnitte und Spulenwechsel 0→jetzt."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from grotti import store
|
||||||
|
from grotti.watcher import StateWatcher
|
||||||
|
|
||||||
|
|
||||||
|
class PrevSpoolForSlotTests(unittest.TestCase):
|
||||||
|
def test_prefers_old_at_slot(self):
|
||||||
|
conn = mock.Mock()
|
||||||
|
live = {"id": 1, "filename": "a.gcode", "spool_id": 99}
|
||||||
|
got = store.prev_spool_id_for_slot(
|
||||||
|
conn, "h", 4, live, 21, old_rows=[{"id": 18}],
|
||||||
|
)
|
||||||
|
self.assertEqual(got, 18)
|
||||||
|
|
||||||
|
def test_ace_map_send_when_old_empty(self):
|
||||||
|
conn = mock.Mock()
|
||||||
|
live = {"id": 33805, "filename": "x.gcode", "spool_id": None}
|
||||||
|
with mock.patch(
|
||||||
|
"grotti.store.latest_send",
|
||||||
|
return_value={
|
||||||
|
"ace_map": [{"paint_index": 0, "ams_index": 3}],
|
||||||
|
"spool_id": 18,
|
||||||
|
},
|
||||||
|
):
|
||||||
|
got = store.prev_spool_id_for_slot(conn, "h", 4, live, 21, old_rows=[])
|
||||||
|
self.assertEqual(got, 18)
|
||||||
|
|
||||||
|
def test_last_segment_on_slot(self):
|
||||||
|
conn = mock.Mock()
|
||||||
|
live = {"id": 7, "filename": "x.gcode", "spool_id": None}
|
||||||
|
with mock.patch("grotti.store.latest_send", return_value={"ace_map": [], "spool_id": None}), \
|
||||||
|
mock.patch(
|
||||||
|
"grotti.store.list_run_segments",
|
||||||
|
return_value=[
|
||||||
|
{"spool_id": 10, "ace_slot": 2, "mm_start": 0, "mm_end": 100},
|
||||||
|
{"spool_id": 18, "ace_slot": 4, "mm_start": 0, "mm_end": 500},
|
||||||
|
],
|
||||||
|
):
|
||||||
|
got = store.prev_spool_id_for_slot(conn, "h", 4, live, 21, old_rows=[])
|
||||||
|
self.assertEqual(got, 18)
|
||||||
|
|
||||||
|
|
||||||
|
class EnsureStartSegmentAceTests(unittest.TestCase):
|
||||||
|
def test_opens_mapped_slots_with_spool(self):
|
||||||
|
watcher = StateWatcher("postgresql://x", {})
|
||||||
|
conn = mock.Mock()
|
||||||
|
live = {"filament_used": 0}
|
||||||
|
with mock.patch("grotti.watcher.store.list_run_segments", return_value=[]), \
|
||||||
|
mock.patch(
|
||||||
|
"grotti.watcher.store.latest_send",
|
||||||
|
return_value={"ace_map": [{"paint_index": 0, "ams_index": 3}]},
|
||||||
|
), \
|
||||||
|
mock.patch(
|
||||||
|
"grotti.watcher.store.spool_at_slot",
|
||||||
|
return_value={"id": 18},
|
||||||
|
) as at_slot, \
|
||||||
|
mock.patch("grotti.watcher.store.open_segment") as open_seg:
|
||||||
|
watcher._ensure_start_segment(conn, "h", "a.gcode", live, 33805)
|
||||||
|
at_slot.assert_called_once_with(conn, "h", 4)
|
||||||
|
open_seg.assert_called_once_with(conn, 33805, 18, 4, 0.0)
|
||||||
|
|
||||||
|
def test_ace_without_spool_opens_nothing(self):
|
||||||
|
watcher = StateWatcher("postgresql://x", {})
|
||||||
|
conn = mock.Mock()
|
||||||
|
with mock.patch("grotti.watcher.store.list_run_segments", return_value=[]), \
|
||||||
|
mock.patch(
|
||||||
|
"grotti.watcher.store.latest_send",
|
||||||
|
return_value={"ace_map": [{"paint_index": 0, "ams_index": 3}]},
|
||||||
|
), \
|
||||||
|
mock.patch("grotti.watcher.store.spool_at_slot", return_value=None), \
|
||||||
|
mock.patch("grotti.watcher.store.open_segment") as open_seg:
|
||||||
|
watcher._ensure_start_segment(conn, "h", "a.gcode", {"filament_used": 0}, 1)
|
||||||
|
open_seg.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class SpoolLoadFallbackSegmentsTests(unittest.TestCase):
|
||||||
|
"""ACE ohne Segment + Einlegen ohne old → 0→jetzt + neuer offener Abschnitt."""
|
||||||
|
|
||||||
|
def test_creates_closed_and_open_segment(self):
|
||||||
|
conn = mock.Mock()
|
||||||
|
live = {"id": 33805, "filename": "x.gcode", "spool_id": None, "state": "in_progress"}
|
||||||
|
opened = []
|
||||||
|
closed = []
|
||||||
|
|
||||||
|
def open_segment(_c, run_id, spool_id, ace_slot, mm_start):
|
||||||
|
opened.append((run_id, spool_id, ace_slot, mm_start))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def close_open_segments(_c, run_id, ace_slot, mm_end):
|
||||||
|
closed.append((run_id, ace_slot, mm_end))
|
||||||
|
# Erster Aufruf: nichts offen; zweiter: nach open_segment
|
||||||
|
return 1 if opened else 0
|
||||||
|
|
||||||
|
with mock.patch("grotti.store.close_open_segments", side_effect=close_open_segments), \
|
||||||
|
mock.patch("grotti.store.open_segment", side_effect=open_segment), \
|
||||||
|
mock.patch(
|
||||||
|
"grotti.store.prev_spool_id_for_slot",
|
||||||
|
return_value=18,
|
||||||
|
):
|
||||||
|
# Logik wie in spool_load (Auszug)
|
||||||
|
slot = 4
|
||||||
|
spool_id = 21
|
||||||
|
mm_now = 5291.0
|
||||||
|
old = []
|
||||||
|
closed_n = store.close_open_segments(conn, live["id"], slot, mm_now)
|
||||||
|
if not closed_n and old:
|
||||||
|
pass
|
||||||
|
elif not closed_n and mm_now > 0:
|
||||||
|
prev_id = store.prev_spool_id_for_slot(
|
||||||
|
conn, "h", slot, live, spool_id, old_rows=old,
|
||||||
|
)
|
||||||
|
if prev_id:
|
||||||
|
store.open_segment(conn, live["id"], prev_id, slot, 0)
|
||||||
|
store.close_open_segments(conn, live["id"], slot, mm_now)
|
||||||
|
store.open_segment(conn, live["id"], spool_id, slot, mm_now)
|
||||||
|
|
||||||
|
self.assertEqual(opened[0], (33805, 18, 4, 0))
|
||||||
|
self.assertEqual(closed[-1], (33805, 4, 5291.0))
|
||||||
|
self.assertEqual(opened[-1], (33805, 21, 4, 5291.0))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"""Kontaktverlust beendet einen Druck nicht."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from grotti.watcher import StateWatcher, contact_gap_keeps_run
|
||||||
|
|
||||||
|
HOST = "10.10.1.88"
|
||||||
|
FILE = "boden_PETG_0.2_2h15m.gcode"
|
||||||
|
|
||||||
|
|
||||||
|
class ContactGapTests(unittest.TestCase):
|
||||||
|
def test_same_file_stays(self):
|
||||||
|
self.assertTrue(contact_gap_keeps_run(FILE, FILE, "printing"))
|
||||||
|
self.assertTrue(contact_gap_keeps_run(FILE, FILE, "paused"))
|
||||||
|
|
||||||
|
def test_other_file_does_not_stay(self):
|
||||||
|
self.assertFalse(contact_gap_keeps_run(FILE, "andere.gcode", "printing"))
|
||||||
|
|
||||||
|
def test_terminal_state_does_not_stay(self):
|
||||||
|
self.assertFalse(contact_gap_keeps_run(FILE, FILE, "standby"))
|
||||||
|
self.assertFalse(contact_gap_keeps_run(FILE, FILE, "complete"))
|
||||||
|
self.assertFalse(contact_gap_keeps_run(FILE, FILE, "error"))
|
||||||
|
|
||||||
|
def test_missing_name_is_not_a_change(self):
|
||||||
|
self.assertTrue(contact_gap_keeps_run("", FILE, "printing"))
|
||||||
|
self.assertTrue(contact_gap_keeps_run(FILE, "", "paused"))
|
||||||
|
|
||||||
|
|
||||||
|
class WatcherOfflineTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.watcher = StateWatcher("postgresql://x", {})
|
||||||
|
self.watcher._prev[HOST] = {
|
||||||
|
"state": "printing",
|
||||||
|
"offline": False,
|
||||||
|
"filename": FILE,
|
||||||
|
"run_id": 42,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_two_misses_keep_the_run(self):
|
||||||
|
live = {
|
||||||
|
"host": HOST,
|
||||||
|
"name": "Kobra S1",
|
||||||
|
"state": "offline",
|
||||||
|
"ok": False,
|
||||||
|
"filename": "",
|
||||||
|
}
|
||||||
|
with mock.patch.object(self.watcher, "_emit") as emit, \
|
||||||
|
mock.patch("grotti.watcher.store.complete_open_run") as close, \
|
||||||
|
mock.patch("grotti.watcher.jobs.apply_deduction") as deduct:
|
||||||
|
self.watcher._one(mock.Mock(), live)
|
||||||
|
self.watcher._one(mock.Mock(), live)
|
||||||
|
close.assert_not_called()
|
||||||
|
deduct.assert_not_called()
|
||||||
|
remembered = self.watcher._prev[HOST]
|
||||||
|
self.assertEqual(remembered["state"], "printing")
|
||||||
|
self.assertTrue(remembered["offline"])
|
||||||
|
self.assertEqual(remembered["run_id"], 42)
|
||||||
|
self.assertEqual(remembered["filename"], FILE)
|
||||||
|
self.assertEqual([call.args[3] for call in emit.call_args_list], ["offline"])
|
||||||
|
|
||||||
|
def test_same_print_after_gap_is_the_same_run(self):
|
||||||
|
self.watcher._prev[HOST]["offline"] = True
|
||||||
|
live = {
|
||||||
|
"host": HOST,
|
||||||
|
"name": "Kobra S1",
|
||||||
|
"state": "printing",
|
||||||
|
"ok": True,
|
||||||
|
"filename": FILE,
|
||||||
|
"weight": 0,
|
||||||
|
}
|
||||||
|
run = {"id": 42, "filename": FILE}
|
||||||
|
with mock.patch.object(self.watcher, "_emit") as emit, \
|
||||||
|
mock.patch.object(self.watcher, "_track_print", return_value=run) as track, \
|
||||||
|
mock.patch.object(self.watcher, "_check_spool", return_value=False), \
|
||||||
|
mock.patch("grotti.watcher.store.find_live_run", return_value=run), \
|
||||||
|
mock.patch("grotti.watcher.store.abandon_live_runs") as abandon, \
|
||||||
|
mock.patch("grotti.watcher.store.complete_open_run") as close, \
|
||||||
|
mock.patch("grotti.watcher.jobs.sync_history") as sync, \
|
||||||
|
mock.patch("grotti.watcher.jobs.apply_deduction") as deduct:
|
||||||
|
self.watcher._one(mock.Mock(), live)
|
||||||
|
close.assert_not_called()
|
||||||
|
deduct.assert_not_called()
|
||||||
|
abandon.assert_not_called()
|
||||||
|
sync.assert_not_called()
|
||||||
|
track.assert_called_once()
|
||||||
|
self.assertEqual([call.args[3] for call in emit.call_args_list], ["offline"])
|
||||||
|
self.assertIn("wieder erreichbar", emit.call_args_list[0].args[4])
|
||||||
|
self.assertEqual(self.watcher._prev[HOST]["state"], "printing")
|
||||||
|
self.assertFalse(self.watcher._prev[HOST]["offline"])
|
||||||
|
|
||||||
|
def test_standby_after_gap_closes(self):
|
||||||
|
self.watcher._prev[HOST]["offline"] = True
|
||||||
|
live = {
|
||||||
|
"host": HOST,
|
||||||
|
"name": "Kobra S1",
|
||||||
|
"state": "standby",
|
||||||
|
"ok": True,
|
||||||
|
"filename": "",
|
||||||
|
}
|
||||||
|
run = {"id": 42, "filename": FILE}
|
||||||
|
with mock.patch.object(self.watcher, "_emit") as emit, \
|
||||||
|
mock.patch.object(self.watcher, "_track_print", return_value=None), \
|
||||||
|
mock.patch.object(self.watcher, "_check_spool", return_value=False), \
|
||||||
|
mock.patch("grotti.watcher.store.find_live_run", return_value=run), \
|
||||||
|
mock.patch("grotti.watcher.store.running_print_on_host", return_value=None), \
|
||||||
|
mock.patch("grotti.watcher.store.abandon_live_runs") as abandon, \
|
||||||
|
mock.patch("grotti.watcher.store.set_bed_needs_clear"), \
|
||||||
|
mock.patch("grotti.watcher.jobs.sync_history") as sync:
|
||||||
|
self.watcher._one(mock.Mock(), live)
|
||||||
|
abandon.assert_called_once()
|
||||||
|
sync.assert_called_once()
|
||||||
|
self.assertIn("abbruch", [call.args[3] for call in emit.call_args_list])
|
||||||
|
|
||||||
|
def test_other_file_after_gap_releases_the_old_run(self):
|
||||||
|
self.watcher._prev[HOST]["offline"] = True
|
||||||
|
live = {
|
||||||
|
"host": HOST,
|
||||||
|
"name": "Kobra S1",
|
||||||
|
"state": "printing",
|
||||||
|
"ok": True,
|
||||||
|
"filename": "andere.gcode",
|
||||||
|
"weight": 0,
|
||||||
|
}
|
||||||
|
old = {"id": 42, "filename": FILE}
|
||||||
|
new = {"id": 99, "filename": "andere.gcode"}
|
||||||
|
with mock.patch.object(self.watcher, "_emit") as emit, \
|
||||||
|
mock.patch.object(self.watcher, "_track_print", return_value=new), \
|
||||||
|
mock.patch.object(self.watcher, "_ensure_start_segment"), \
|
||||||
|
mock.patch.object(self.watcher, "_check_spool", return_value=False), \
|
||||||
|
mock.patch("grotti.watcher.store.find_live_run", side_effect=[old, old, None]), \
|
||||||
|
mock.patch("grotti.watcher.store.abandon_live_runs") as abandon, \
|
||||||
|
mock.patch("grotti.watcher.jobs.sync_history") as sync:
|
||||||
|
self.watcher._one(mock.Mock(), live)
|
||||||
|
sync.assert_called_once()
|
||||||
|
self.assertGreaterEqual(abandon.call_count, 1)
|
||||||
|
kinds = [call.args[3] for call in emit.call_args_list]
|
||||||
|
self.assertEqual(kinds, ["abbruch", "offline", "start"])
|
||||||
|
|
||||||
|
def test_other_file_without_offline_releases_the_old_run(self):
|
||||||
|
live = {
|
||||||
|
"host": HOST,
|
||||||
|
"name": "Kobra S1",
|
||||||
|
"state": "printing",
|
||||||
|
"ok": True,
|
||||||
|
"filename": "andere.gcode",
|
||||||
|
"weight": 0,
|
||||||
|
}
|
||||||
|
old = {"id": 42, "filename": FILE}
|
||||||
|
new = {"id": 99, "filename": "andere.gcode"}
|
||||||
|
seen = {"n": 0}
|
||||||
|
|
||||||
|
def live_run(_conn, _host, filename=""):
|
||||||
|
seen["n"] += 1
|
||||||
|
if seen["n"] <= 3:
|
||||||
|
return old
|
||||||
|
return None
|
||||||
|
|
||||||
|
with mock.patch.object(self.watcher, "_emit") as emit, \
|
||||||
|
mock.patch.object(self.watcher, "_track_print", return_value=new), \
|
||||||
|
mock.patch.object(self.watcher, "_ensure_start_segment"), \
|
||||||
|
mock.patch.object(self.watcher, "_check_spool", return_value=False), \
|
||||||
|
mock.patch("grotti.watcher.store.find_live_run", side_effect=live_run), \
|
||||||
|
mock.patch("grotti.watcher.store.abandon_live_runs") as abandon, \
|
||||||
|
mock.patch("grotti.watcher.jobs.sync_history") as sync:
|
||||||
|
self.watcher._one(mock.Mock(), live)
|
||||||
|
sync.assert_called_once()
|
||||||
|
abandon.assert_called()
|
||||||
|
self.assertIn("abbruch", [call.args[3] for call in emit.call_args_list])
|
||||||
|
self.assertIn("start", [call.args[3] for call in emit.call_args_list])
|
||||||
|
|
||||||
|
def test_cancel_event_survives_deleted_live_run(self):
|
||||||
|
self.watcher._cancel_hits[HOST] = 2
|
||||||
|
live = {
|
||||||
|
"host": HOST,
|
||||||
|
"name": "Kobra S1",
|
||||||
|
"state": "cancelled",
|
||||||
|
"ok": True,
|
||||||
|
"filename": FILE,
|
||||||
|
}
|
||||||
|
dangling = {"id": 42, "filename": FILE, "state": "in_progress"}
|
||||||
|
|
||||||
|
def row(_conn, run_id):
|
||||||
|
if run_id == 77:
|
||||||
|
return {"id": 77, "filename": FILE}
|
||||||
|
return None
|
||||||
|
|
||||||
|
with mock.patch("grotti.watcher.store.find_live_run", side_effect=[dangling, None]), \
|
||||||
|
mock.patch("grotti.watcher.store.find_open_history_run", return_value=None), \
|
||||||
|
mock.patch("grotti.watcher.store.get_run_row", side_effect=row), \
|
||||||
|
mock.patch("grotti.watcher.store.latest_run_id", return_value=77), \
|
||||||
|
mock.patch("grotti.watcher.store.abandon_live_runs"), \
|
||||||
|
mock.patch("grotti.watcher.store.set_bed_needs_clear"), \
|
||||||
|
mock.patch("grotti.watcher.store.set_printer_active_run"), \
|
||||||
|
mock.patch("grotti.watcher.jobs.sync_history") as sync, \
|
||||||
|
mock.patch("grotti.watcher.notify.record") as record:
|
||||||
|
self.watcher._one(mock.Mock(), live)
|
||||||
|
sync.assert_called_once()
|
||||||
|
record.assert_called()
|
||||||
|
self.assertEqual(record.call_args.kwargs["run_id"], 77)
|
||||||
|
self.assertEqual(record.call_args.args[4], "abbruch")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+256
-22
@@ -19,7 +19,7 @@ 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 db as grotti_db
|
||||||
from grotti import eject, jobs, library, moonraker, notify, store
|
from grotti import eject, jobs, library, logutil, moonraker, notify, store
|
||||||
from grotti.catalog import color_catalog
|
from grotti.catalog import color_catalog
|
||||||
from grotti.cost import calculate, offer_from_file, order_actuals
|
from grotti.cost import calculate, offer_from_file, order_actuals
|
||||||
from grotti import orders as grotti_orders
|
from grotti import orders as grotti_orders
|
||||||
@@ -32,6 +32,8 @@ from grotti.version import __version__
|
|||||||
from grotti import verdict as grotti_verdict
|
from grotti import verdict as grotti_verdict
|
||||||
from grotti.watcher import StateWatcher
|
from grotti.watcher import StateWatcher
|
||||||
|
|
||||||
|
log = logutil.get("grotti.web")
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent
|
ROOT = Path(__file__).resolve().parent
|
||||||
TOOL_ROOT = ROOT.parent
|
TOOL_ROOT = ROOT.parent
|
||||||
DOCS_DIR = TOOL_ROOT / "docs"
|
DOCS_DIR = TOOL_ROOT / "docs"
|
||||||
@@ -46,6 +48,8 @@ TOKEN_QUERY_PATHS = (
|
|||||||
"/api/v1/kamera",
|
"/api/v1/kamera",
|
||||||
"/api/v1/kamera/stream",
|
"/api/v1/kamera/stream",
|
||||||
"/api/v1/vorschau",
|
"/api/v1/vorschau",
|
||||||
|
"/api/v1/dateien/",
|
||||||
|
"/api/v1/projekte/",
|
||||||
)
|
)
|
||||||
STEER_ACTIONS = {
|
STEER_ACTIONS = {
|
||||||
"nozzle", "bed", "speed", "flow", "fan", "emergency_stop", "clear_error",
|
"nozzle", "bed", "speed", "flow", "fan", "emergency_stop", "clear_error",
|
||||||
@@ -97,6 +101,7 @@ def create_app(cfg):
|
|||||||
"/setup",
|
"/setup",
|
||||||
"/api/v1/auth/status",
|
"/api/v1/auth/status",
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
|
"/api/v1/auth/token",
|
||||||
"/api/v1/auth/setup",
|
"/api/v1/auth/setup",
|
||||||
"/api/v1/version",
|
"/api/v1/version",
|
||||||
}
|
}
|
||||||
@@ -316,7 +321,7 @@ def create_app(cfg):
|
|||||||
message = "Diesen Eintrag gibt es schon."
|
message = "Diesen Eintrag gibt es schon."
|
||||||
elif isinstance(err, psycopg.errors.InvalidTextRepresentation):
|
elif isinstance(err, psycopg.errors.InvalidTextRepresentation):
|
||||||
message = "Ungültiger Wert in der Anfrage."
|
message = "Ungültiger Wert in der Anfrage."
|
||||||
print("DB: %s" % err, flush=True)
|
log.warning("DB: %s", err)
|
||||||
return jsonify({"error": message, "code": "db"}), 500
|
return jsonify({"error": message, "code": "db"}), 500
|
||||||
|
|
||||||
def body():
|
def body():
|
||||||
@@ -452,6 +457,39 @@ def create_app(cfg):
|
|||||||
store.add_event(conn(), "", "", "auth", "Login %s" % row["username"])
|
store.add_event(conn(), "", "", "auth", "Login %s" % row["username"])
|
||||||
return jsonify({"user": grotti_auth.public_user(row), "csrf": session["csrf"]})
|
return jsonify({"user": grotti_auth.public_user(row), "csrf": session["csrf"]})
|
||||||
|
|
||||||
|
@app.post("/api/v1/auth/token")
|
||||||
|
def auth_token():
|
||||||
|
"""Passwort → API-Token (für Android/App). Keine Session, Rate-Limit wie Login."""
|
||||||
|
if store.user_count(conn()) == 0:
|
||||||
|
raise GrottiError("Zuerst Admin anlegen.", "setup", 503)
|
||||||
|
key = request.remote_addr or "unknown"
|
||||||
|
if not grotti_auth.login_rate_ok(key):
|
||||||
|
raise GrottiError("Zu viele Versuche. Bitte warten.", "rate", 429)
|
||||||
|
data = body()
|
||||||
|
username = (data.get("username") or "").strip()
|
||||||
|
password = data.get("password") or ""
|
||||||
|
device = (data.get("device_name") or data.get("name") or "App").strip()[:80] or "App"
|
||||||
|
row = store.get_user_by_name(conn(), username)
|
||||||
|
if not row or not row.get("active") or not grotti_auth.verify_password(row["password_hash"], password):
|
||||||
|
grotti_auth.login_rate_hit(key)
|
||||||
|
raise GrottiError("Benutzer oder Passwort falsch.", "auth", 401)
|
||||||
|
store.touch_user_login(conn(), row["id"])
|
||||||
|
raw = grotti_auth.new_api_token()
|
||||||
|
role = grotti_auth.clamp_token_role(row["role"], data.get("role"))
|
||||||
|
token_row = store.create_api_token(
|
||||||
|
conn(),
|
||||||
|
row["id"],
|
||||||
|
device,
|
||||||
|
grotti_auth.hash_token(raw),
|
||||||
|
grotti_auth.token_prefix(raw),
|
||||||
|
role,
|
||||||
|
)
|
||||||
|
store.add_event(conn(), "", "", "auth", "Token „%s“ für %s" % (device, row["username"]))
|
||||||
|
item = grotti_auth.public_token(token_row)
|
||||||
|
item["token"] = raw
|
||||||
|
item["user"] = grotti_auth.public_user(row)
|
||||||
|
return jsonify(item), 201
|
||||||
|
|
||||||
@app.post("/api/v1/auth/logout")
|
@app.post("/api/v1/auth/logout")
|
||||||
def auth_logout():
|
def auth_logout():
|
||||||
session.clear()
|
session.clear()
|
||||||
@@ -582,6 +620,7 @@ def create_app(cfg):
|
|||||||
|
|
||||||
ANLEITUNG_ORDER = (
|
ANLEITUNG_ORDER = (
|
||||||
"Anleitung-Einrichtung",
|
"Anleitung-Einrichtung",
|
||||||
|
"Anleitung-Installation-Pi",
|
||||||
"Anleitung-Oberflaeche",
|
"Anleitung-Oberflaeche",
|
||||||
"Anleitung-Wand",
|
"Anleitung-Wand",
|
||||||
"Anleitung-Dateien",
|
"Anleitung-Dateien",
|
||||||
@@ -605,6 +644,7 @@ def create_app(cfg):
|
|||||||
def anleitung_title(name):
|
def anleitung_title(name):
|
||||||
labels = {
|
labels = {
|
||||||
"Anleitung-Einrichtung": "Einrichtung",
|
"Anleitung-Einrichtung": "Einrichtung",
|
||||||
|
"Anleitung-Installation-Pi": "Raspberry Pi (.deb)",
|
||||||
"Anleitung-Oberflaeche": "Oberfläche",
|
"Anleitung-Oberflaeche": "Oberfläche",
|
||||||
"Anleitung-Wand": "Druckerwand",
|
"Anleitung-Wand": "Druckerwand",
|
||||||
"Anleitung-Dateien": "Dateien senden",
|
"Anleitung-Dateien": "Dateien senden",
|
||||||
@@ -807,7 +847,22 @@ def create_app(cfg):
|
|||||||
|
|
||||||
@app.get("/api/v1/version")
|
@app.get("/api/v1/version")
|
||||||
def version_info():
|
def version_info():
|
||||||
return jsonify({"version": __version__})
|
return jsonify({
|
||||||
|
"name": "Grotti",
|
||||||
|
"version": __version__,
|
||||||
|
"api": 1,
|
||||||
|
"features": [
|
||||||
|
"auth_token",
|
||||||
|
"sse",
|
||||||
|
"queue",
|
||||||
|
"projects",
|
||||||
|
"orders",
|
||||||
|
"spools",
|
||||||
|
"camera",
|
||||||
|
"confirm",
|
||||||
|
"ntfy",
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
@app.get("/api/v1/einstellungen")
|
@app.get("/api/v1/einstellungen")
|
||||||
def settings():
|
def settings():
|
||||||
@@ -965,6 +1020,8 @@ def create_app(cfg):
|
|||||||
@app.post("/api/v1/drucker/<host>/<action>")
|
@app.post("/api/v1/drucker/<host>/<action>")
|
||||||
def printer_action(host, action):
|
def printer_action(host, action):
|
||||||
require_host(host)
|
require_host(host)
|
||||||
|
aliases = {"resume": "fortsetzen", "cancel": "abbrechen"}
|
||||||
|
action = aliases.get(action, action)
|
||||||
if action not in ("pause", "fortsetzen", "abbrechen"):
|
if action not in ("pause", "fortsetzen", "abbrechen"):
|
||||||
raise GrottiError("Unbekannter Befehl.", "befehl")
|
raise GrottiError("Unbekannter Befehl.", "befehl")
|
||||||
return jsonify(jobs.control(conn(), host, action))
|
return jsonify(jobs.control(conn(), host, action))
|
||||||
@@ -985,7 +1042,7 @@ def create_app(cfg):
|
|||||||
host = require_host(request.args.get("host", ""))
|
host = require_host(request.args.get("host", ""))
|
||||||
image = moonraker.camera_image(host)
|
image = moonraker.camera_image(host)
|
||||||
if not image:
|
if not image:
|
||||||
abort(404)
|
raise GrottiError("Kein Kamerabild.", "kamera", 404)
|
||||||
return image[1], 200, {"Content-Type": image[0], "Cache-Control": "no-store"}
|
return image[1], 200, {"Content-Type": image[0], "Cache-Control": "no-store"}
|
||||||
|
|
||||||
@app.get("/api/v1/kamera/stream")
|
@app.get("/api/v1/kamera/stream")
|
||||||
@@ -1044,7 +1101,8 @@ def create_app(cfg):
|
|||||||
|
|
||||||
@app.get("/api/v1/projekte")
|
@app.get("/api/v1/projekte")
|
||||||
def project_list():
|
def project_list():
|
||||||
return jsonify(store.list_projects(conn()))
|
status = (request.args.get("status") or "").strip() or None
|
||||||
|
return jsonify(store.list_projects(conn(), status=status))
|
||||||
|
|
||||||
@app.post("/api/v1/projekte")
|
@app.post("/api/v1/projekte")
|
||||||
def project_create():
|
def project_create():
|
||||||
@@ -1079,11 +1137,83 @@ def create_app(cfg):
|
|||||||
|
|
||||||
@app.get("/api/v1/projekte/<int:project_id>")
|
@app.get("/api/v1/projekte/<int:project_id>")
|
||||||
def project_get(project_id):
|
def project_get(project_id):
|
||||||
row = store.get_project(conn(), project_id)
|
row = store.get_project(conn(), project_id, watch_dir=cfg.get("watch_dir"))
|
||||||
if not row:
|
if not row:
|
||||||
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
|
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
|
||||||
|
# Status „druckt“, wenn ein Lauf aktiv ist (nur Anzeige — kein Auto-Write ohne Wunsch)
|
||||||
return jsonify(row)
|
return jsonify(row)
|
||||||
|
|
||||||
|
@app.get("/api/v1/projekte/<int:project_id>/laeufe")
|
||||||
|
def project_runs(project_id):
|
||||||
|
if not store.get_project(conn(), project_id):
|
||||||
|
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
|
||||||
|
try:
|
||||||
|
limit = max(1, min(200, int(request.args.get("limit") or 40)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = 40
|
||||||
|
try:
|
||||||
|
offset = max(0, int(request.args.get("offset") or 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
offset = 0
|
||||||
|
data = store.list_project_runs(conn(), project_id, limit=limit, offset=offset)
|
||||||
|
resp = jsonify(data["items"])
|
||||||
|
resp.headers["X-Total-Count"] = str(data["total"])
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@app.post("/api/v1/projekte/<int:project_id>/einreihen")
|
||||||
|
def project_einreihen(project_id):
|
||||||
|
data = body()
|
||||||
|
if not bool(data.get("confirm")):
|
||||||
|
raise GrottiError("Bestätigung fehlt.", "bestaetigung")
|
||||||
|
project = store.get_project(conn(), project_id, watch_dir=cfg.get("watch_dir"))
|
||||||
|
if not project:
|
||||||
|
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
|
||||||
|
wanted = data.get("file_ids")
|
||||||
|
if isinstance(wanted, list) and wanted:
|
||||||
|
try:
|
||||||
|
wanted_ids = {int(x) for x in wanted}
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise GrottiError("Ungültige Datei-IDs.", "datei") from exc
|
||||||
|
files = [f for f in (project.get("files") or []) if f["id"] in wanted_ids]
|
||||||
|
else:
|
||||||
|
files = list(project.get("files") or [])
|
||||||
|
if not files:
|
||||||
|
raise GrottiError("Keine Druckdateien zum Einreihen.", "datei")
|
||||||
|
try:
|
||||||
|
menge = max(1, min(50, int(data.get("menge") or 1)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
menge = 1
|
||||||
|
try:
|
||||||
|
priority = int(data.get("priority", 100))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
priority = 100
|
||||||
|
host = optional_host(data.get("host") or "")
|
||||||
|
if host:
|
||||||
|
require_host(host)
|
||||||
|
bed = bool(data.get("bed_leveling"))
|
||||||
|
override = bool(data.get("override_machine"))
|
||||||
|
created = []
|
||||||
|
for file_row in files:
|
||||||
|
family = (data.get("family") or file_row.get("printer_family") or "").strip()
|
||||||
|
for _ in range(menge):
|
||||||
|
created.append(
|
||||||
|
store.enqueue_print(
|
||||||
|
conn(),
|
||||||
|
file_row["id"],
|
||||||
|
host,
|
||||||
|
family if not host else "",
|
||||||
|
priority,
|
||||||
|
optional_id(data.get("spool_id")),
|
||||||
|
data.get("ace_map") if isinstance(data.get("ace_map"), list) else [],
|
||||||
|
bed,
|
||||||
|
override,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Status auf „druckt“ heben, wenn noch Idee/bereit
|
||||||
|
if project.get("status") in ("idee", "bereit"):
|
||||||
|
store.update_project(conn(), project_id, {"status": "druckt"})
|
||||||
|
return jsonify({"jobs": created, "count": len(created)}), 201
|
||||||
|
|
||||||
@app.patch("/api/v1/projekte/<int:project_id>")
|
@app.patch("/api/v1/projekte/<int:project_id>")
|
||||||
def project_patch(project_id):
|
def project_patch(project_id):
|
||||||
data = body()
|
data = body()
|
||||||
@@ -1178,7 +1308,39 @@ 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(), watch_dir=cfg.get("watch_dir")))
|
family = (request.args.get("family") or "").strip()
|
||||||
|
material = (request.args.get("material") or "").strip()
|
||||||
|
q = (request.args.get("q") or "").strip()
|
||||||
|
host = optional_host(request.args.get("host") or "")
|
||||||
|
project_id = optional_id(request.args.get("project_id") or request.args.get("project"))
|
||||||
|
limit_raw = request.args.get("limit")
|
||||||
|
offset_raw = request.args.get("offset")
|
||||||
|
limit = None
|
||||||
|
offset = 0
|
||||||
|
if limit_raw is not None and str(limit_raw).strip() != "":
|
||||||
|
try:
|
||||||
|
limit = max(1, min(500, int(limit_raw)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = 40
|
||||||
|
if offset_raw is not None:
|
||||||
|
try:
|
||||||
|
offset = max(0, int(offset_raw))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
offset = 0
|
||||||
|
data = store.list_files(
|
||||||
|
conn(),
|
||||||
|
watch_dir=cfg.get("watch_dir"),
|
||||||
|
family=family,
|
||||||
|
material=material,
|
||||||
|
project_id=project_id,
|
||||||
|
q=q,
|
||||||
|
host=host,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
resp = jsonify(data["items"])
|
||||||
|
resp.headers["X-Total-Count"] = str(data["total"])
|
||||||
|
return resp
|
||||||
|
|
||||||
@app.post("/api/v1/dateien/<int:file_id>/meta")
|
@app.post("/api/v1/dateien/<int:file_id>/meta")
|
||||||
def file_meta_refresh(file_id):
|
def file_meta_refresh(file_id):
|
||||||
@@ -1263,10 +1425,10 @@ def create_app(cfg):
|
|||||||
def file_preview(file_id):
|
def file_preview(file_id):
|
||||||
row = store.get_file_row(conn(), file_id)
|
row = store.get_file_row(conn(), file_id)
|
||||||
if not row:
|
if not row:
|
||||||
abort(404)
|
raise GrottiError("Vorschau fehlt.", "vorschau", 404)
|
||||||
png = library.preview_png(row, conn=conn(), data_dir=cfg["data_dir"])
|
png = library.preview_png(row, conn=conn(), data_dir=cfg["data_dir"])
|
||||||
if not png:
|
if not png:
|
||||||
abort(404)
|
raise GrottiError("Vorschau fehlt.", "vorschau", 404)
|
||||||
return png, 200, {"Content-Type": "image/png", "Cache-Control": "private, max-age=300"}
|
return png, 200, {"Content-Type": "image/png", "Cache-Control": "private, max-age=300"}
|
||||||
|
|
||||||
@app.get("/api/v1/dateien/<int:file_id>/pfad")
|
@app.get("/api/v1/dateien/<int:file_id>/pfad")
|
||||||
@@ -1407,9 +1569,12 @@ def create_app(cfg):
|
|||||||
file_id = int(request.args.get("file_id") or 0)
|
file_id = int(request.args.get("file_id") or 0)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
file_id = 0
|
file_id = 0
|
||||||
if not host or not file_id:
|
if not host:
|
||||||
raise GrottiError("Host und Datei nötig.", "senden")
|
raise GrottiError("Host nötig.", "senden")
|
||||||
row = store.latest_send_for_file(conn(), host, file_id)
|
if file_id:
|
||||||
|
row = store.latest_send_for_file(conn(), host, file_id)
|
||||||
|
else:
|
||||||
|
row = store.latest_send_for_host(conn(), host)
|
||||||
if not row:
|
if not row:
|
||||||
return jsonify(None)
|
return jsonify(None)
|
||||||
return jsonify(row)
|
return jsonify(row)
|
||||||
@@ -1504,7 +1669,18 @@ def create_app(cfg):
|
|||||||
limit = max(1, min(200, int(request.args.get("limit") or 40)))
|
limit = max(1, min(200, int(request.args.get("limit") or 40)))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
limit = 40
|
limit = 40
|
||||||
return jsonify(store.list_print_queue(conn(), limit=limit))
|
try:
|
||||||
|
offset = max(0, int(request.args.get("offset") or 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
offset = 0
|
||||||
|
host = optional_host(request.args.get("host") or "")
|
||||||
|
status = (request.args.get("status") or "").strip()
|
||||||
|
data = store.list_print_queue(
|
||||||
|
conn(), limit=limit, offset=offset, host=host, status=status
|
||||||
|
)
|
||||||
|
resp = jsonify(data["items"])
|
||||||
|
resp.headers["X-Total-Count"] = str(data["total"])
|
||||||
|
return resp
|
||||||
|
|
||||||
@app.get("/api/v1/laeuft")
|
@app.get("/api/v1/laeuft")
|
||||||
def running_now():
|
def running_now():
|
||||||
@@ -1586,7 +1762,10 @@ def create_app(cfg):
|
|||||||
for item in running:
|
for item in running:
|
||||||
rem = float(item.get("remain") or 0)
|
rem = float(item.get("remain") or 0)
|
||||||
item["free_at"] = jetzt_mod._iso_remain(rem) if rem > 0 else None
|
item["free_at"] = jetzt_mod._iso_remain(rem) if rem > 0 else None
|
||||||
waiting = [j for j in store.list_print_queue(conn(), limit=80) if j.get("status") == "waiting"]
|
waiting = [
|
||||||
|
j for j in store.list_print_queue(conn(), limit=80)["items"]
|
||||||
|
if j.get("status") == "waiting"
|
||||||
|
]
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"running": running,
|
"running": running,
|
||||||
"waiting_count": len(waiting),
|
"waiting_count": len(waiting),
|
||||||
@@ -1690,15 +1869,14 @@ def create_app(cfg):
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise GrottiError(str(exc), "urteil") from exc
|
raise GrottiError(str(exc), "urteil") from exc
|
||||||
run_json = store.set_verdict(c, run_row["id"], verdict, reason, ok_n, bad_n)
|
run_json = store.set_verdict(c, run_row["id"], verdict, reason, ok_n, bad_n)
|
||||||
# Live-Lauf schließen: Moonraker-History bleibt oft ohne Endzeit hängen,
|
# Live-Lauf schließen ohne Abzug — History bucht; Fallback nach Frist.
|
||||||
# sonst bleibt der Druck im Protokoll ewig „in_progress“ trotz Urteil.
|
|
||||||
if run_row and (run_row.get("state") or "") in ("in_progress", "printing", "paused"):
|
if run_row and (run_row.get("state") or "") in ("in_progress", "printing", "paused"):
|
||||||
closed = store.complete_open_run(c, run_row["id"], state="completed")
|
closed = store.complete_open_run(c, run_row["id"], state="completed")
|
||||||
if closed:
|
if closed:
|
||||||
try:
|
try:
|
||||||
jobs.apply_deduction(c, closed, cfg["energy"])
|
jobs.sync_history(c, cfg, host=host)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Abräumen Abzug %s: %s" % (host, exc), flush=True)
|
log.warning("Abräumen Abgleich %s: %s", host, exc)
|
||||||
row = store.get_run_row(c, closed["id"]) or closed
|
row = store.get_run_row(c, closed["id"]) or closed
|
||||||
run_json = store.run_out(row)
|
run_json = store.run_out(row)
|
||||||
reprint = None
|
reprint = None
|
||||||
@@ -2192,7 +2370,28 @@ def create_app(cfg):
|
|||||||
|
|
||||||
@app.get("/api/v1/spulen")
|
@app.get("/api/v1/spulen")
|
||||||
def spool_list():
|
def spool_list():
|
||||||
return jsonify(store.list_spools(conn()))
|
aktiv_raw = (request.args.get("aktiv") or "").strip().lower()
|
||||||
|
aktiv = None
|
||||||
|
if aktiv_raw in ("1", "true", "yes"):
|
||||||
|
aktiv = True
|
||||||
|
elif aktiv_raw in ("0", "false", "no"):
|
||||||
|
aktiv = False
|
||||||
|
limit_raw = request.args.get("limit")
|
||||||
|
limit = None
|
||||||
|
offset = 0
|
||||||
|
if limit_raw is not None and str(limit_raw).strip() != "":
|
||||||
|
try:
|
||||||
|
limit = max(1, min(500, int(limit_raw)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = 100
|
||||||
|
try:
|
||||||
|
offset = max(0, int(request.args.get("offset") or 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
offset = 0
|
||||||
|
data = store.list_spools(conn(), aktiv=aktiv, limit=limit, offset=offset)
|
||||||
|
resp = jsonify(data["items"])
|
||||||
|
resp.headers["X-Total-Count"] = str(data["total"])
|
||||||
|
return resp
|
||||||
|
|
||||||
@app.get("/api/v1/spulen/by-code/<path:code>")
|
@app.get("/api/v1/spulen/by-code/<path:code>")
|
||||||
def spool_by_code(code):
|
def spool_by_code(code):
|
||||||
@@ -2286,6 +2485,15 @@ def create_app(cfg):
|
|||||||
for row in old:
|
for row in old:
|
||||||
store.open_segment(conn(), live_run["id"], row["id"], slot, 0)
|
store.open_segment(conn(), live_run["id"], row["id"], slot, 0)
|
||||||
store.close_open_segments(conn(), live_run["id"], slot, mm_now)
|
store.close_open_segments(conn(), live_run["id"], slot, mm_now)
|
||||||
|
elif not closed and mm_now > 0:
|
||||||
|
# ACE ohne Startabschnitt oder alte Rolle schon im Regal —
|
||||||
|
# slot-genau: Platz / ACE-Map / letzter Abschnitt / Lauf-Hinweis.
|
||||||
|
prev_id = store.prev_spool_id_for_slot(
|
||||||
|
conn(), host, slot, live_run, spool_id, old_rows=old,
|
||||||
|
)
|
||||||
|
if prev_id:
|
||||||
|
store.open_segment(conn(), live_run["id"], prev_id, slot, 0)
|
||||||
|
store.close_open_segments(conn(), live_run["id"], slot, mm_now)
|
||||||
for row in old:
|
for row in old:
|
||||||
store.update_spool(conn(), row["id"], {"printer_host": "", "ace_slot": None})
|
store.update_spool(conn(), row["id"], {"printer_host": "", "ace_slot": None})
|
||||||
store.add_event(
|
store.add_event(
|
||||||
@@ -2451,7 +2659,11 @@ def create_app(cfg):
|
|||||||
|
|
||||||
@app.get("/api/v1/laeufe")
|
@app.get("/api/v1/laeufe")
|
||||||
def run_list():
|
def run_list():
|
||||||
return jsonify(store.list_runs(conn()))
|
try:
|
||||||
|
limit = min(1000, max(1, int(request.args.get("limit") or 500)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = 500
|
||||||
|
return jsonify(store.list_runs(conn(), limit=limit))
|
||||||
|
|
||||||
@app.get("/api/v1/laeufe/aktuell")
|
@app.get("/api/v1/laeufe/aktuell")
|
||||||
def run_aktuell():
|
def run_aktuell():
|
||||||
@@ -2487,8 +2699,18 @@ def create_app(cfg):
|
|||||||
run = store.run_out(row)
|
run = store.run_out(row)
|
||||||
if run.get("chargen_nr") and run.get("objects"):
|
if run.get("chargen_nr") and run.get("objects"):
|
||||||
store.ensure_run_labels(conn(), run_id, run.get("objects") or [], qty=1)
|
store.ensure_run_labels(conn(), run_id, run.get("objects") or [], qty=1)
|
||||||
|
if run.get("order_item_id") and not run.get("order_id"):
|
||||||
|
oi = conn().execute(
|
||||||
|
"SELECT order_id FROM order_items WHERE id = %s",
|
||||||
|
(run["order_item_id"],),
|
||||||
|
).fetchone()
|
||||||
|
if oi:
|
||||||
|
run["order_id"] = oi["order_id"]
|
||||||
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"], watch_dir=cfg.get("watch_dir"))
|
||||||
|
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
|
from grotti.cost import energy_estimate
|
||||||
|
|
||||||
@@ -2504,7 +2726,7 @@ def create_app(cfg):
|
|||||||
"samples": samples,
|
"samples": samples,
|
||||||
"labels": store.list_run_labels(conn(), run_id),
|
"labels": store.list_run_labels(conn(), run_id),
|
||||||
"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, watch_dir=cfg.get("watch_dir"), conn=conn()) if file_row else None,
|
||||||
"project": project,
|
"project": project,
|
||||||
"energy": energy,
|
"energy": energy,
|
||||||
})
|
})
|
||||||
@@ -2514,6 +2736,18 @@ def create_app(cfg):
|
|||||||
library.sync_watch(conn(), cfg, force=False)
|
library.sync_watch(conn(), cfg, force=False)
|
||||||
return jsonify(jobs.sync_history(conn(), cfg))
|
return jsonify(jobs.sync_history(conn(), cfg))
|
||||||
|
|
||||||
|
@app.post("/api/v1/laeufe/<int:run_id>/verbinden")
|
||||||
|
def run_merge(run_id):
|
||||||
|
"""Zwei Protokoll-Zeilen desselben physischen Drucks verbinden (admin)."""
|
||||||
|
require_role("admin")
|
||||||
|
data = body()
|
||||||
|
other = int(data.get("other_id") or data.get("mit") or 0)
|
||||||
|
if not other:
|
||||||
|
raise GrottiError("other_id fehlt.", "lauf")
|
||||||
|
return jsonify(jobs.manual_merge_runs(
|
||||||
|
conn(), run_id, other, confirm=bool(data.get("confirm"))
|
||||||
|
))
|
||||||
|
|
||||||
@app.patch("/api/v1/laeufe/<int:run_id>")
|
@app.patch("/api/v1/laeufe/<int:run_id>")
|
||||||
def run_patch(run_id):
|
def run_patch(run_id):
|
||||||
data = body()
|
data = body()
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ const CSRF = document.querySelector('meta[name="csrf"]')?.content || "";
|
|||||||
const STATE_LABELS = {
|
const STATE_LABELS = {
|
||||||
printing: "druckt", paused: "pausiert", complete: "fertig", standby: "bereit",
|
printing: "druckt", paused: "pausiert", complete: "fertig", standby: "bereit",
|
||||||
heating: "heizt", error: "Fehler", cancelled: "abgebrochen", offline: "offline",
|
heating: "heizt", error: "Fehler", cancelled: "abgebrochen", offline: "offline",
|
||||||
waiting: "wartet", running: "läuft",
|
waiting: "wartet", running: "läuft", starting: "startet",
|
||||||
};
|
};
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
|
|||||||
+253
-26
@@ -64,8 +64,8 @@
|
|||||||
--st-error-soft: rgba(248, 113, 113, 0.14);
|
--st-error-soft: rgba(248, 113, 113, 0.14);
|
||||||
--st-offline: #71717a;
|
--st-offline: #71717a;
|
||||||
--st-offline-soft: rgba(255, 255, 255, 0.04);
|
--st-offline-soft: rgba(255, 255, 255, 0.04);
|
||||||
--st-waiting: #f97316;
|
--st-waiting: #f59e0b;
|
||||||
--st-waiting-soft: rgba(249, 115, 22, 0.14);
|
--st-waiting-soft: rgba(245, 158, 11, 0.16);
|
||||||
--st-idle: #a1a1aa;
|
--st-idle: #a1a1aa;
|
||||||
--st-idle-soft: rgba(255, 255, 255, 0.05);
|
--st-idle-soft: rgba(255, 255, 255, 0.05);
|
||||||
--ease: cubic-bezier(0.2, 0.7, 0.2, 1);
|
--ease: cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||||
@@ -105,8 +105,8 @@ html[data-theme="light"] {
|
|||||||
--st-error-soft: rgba(220, 38, 38, 0.1);
|
--st-error-soft: rgba(220, 38, 38, 0.1);
|
||||||
--st-offline: #73737c;
|
--st-offline: #73737c;
|
||||||
--st-offline-soft: rgba(0, 0, 0, 0.04);
|
--st-offline-soft: rgba(0, 0, 0, 0.04);
|
||||||
--st-waiting: #ea580c;
|
--st-waiting: #b45309;
|
||||||
--st-waiting-soft: rgba(234, 88, 12, 0.1);
|
--st-waiting-soft: rgba(180, 83, 9, 0.12);
|
||||||
--st-idle: #5c5c66;
|
--st-idle: #5c5c66;
|
||||||
--st-idle-soft: rgba(0, 0, 0, 0.04);
|
--st-idle-soft: rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
@@ -124,14 +124,14 @@ body {
|
|||||||
font: 14.5px/1.45 var(--font);
|
font: 14.5px/1.45 var(--font);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background:
|
background:
|
||||||
radial-gradient(900px 420px at 0% 0%, rgba(249, 115, 22, 0.07), transparent 55%),
|
radial-gradient(1100px 520px at 0% 0%, rgba(249, 115, 22, 0.14), transparent 58%),
|
||||||
var(--bg);
|
var(--bg);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
font-feature-settings: "cv11", "ss01";
|
font-feature-settings: "cv11", "ss01";
|
||||||
}
|
}
|
||||||
html[data-theme="light"] body {
|
html[data-theme="light"] body {
|
||||||
background:
|
background:
|
||||||
radial-gradient(900px 420px at 0% 0%, rgba(234, 88, 12, 0.06), transparent 55%),
|
radial-gradient(1100px 520px at 0% 0%, rgba(234, 88, 12, 0.11), transparent 58%),
|
||||||
var(--bg);
|
var(--bg);
|
||||||
}
|
}
|
||||||
.tabular, .num, .hero-main, .hero-side, .stat b, .chip, .count, .run-line-text, time {
|
.tabular, .num, .hero-main, .hero-side, .stat b, .chip, .count, .run-line-text, time {
|
||||||
@@ -298,6 +298,8 @@ html.shelf .hero-main,
|
|||||||
html.shelf [data-role="hero"],
|
html.shelf [data-role="hero"],
|
||||||
body.shelf .hero-main,
|
body.shelf .hero-main,
|
||||||
body.shelf [data-role="hero"] { font-size: 1.35rem; }
|
body.shelf [data-role="hero"] { font-size: 1.35rem; }
|
||||||
|
html.shelf article.machine.printing .hero-main,
|
||||||
|
body.shelf article.machine.printing .hero-main { font-size: 2.4rem; }
|
||||||
.mobile-nav .nav-shelf { display: none; }
|
.mobile-nav .nav-shelf { display: none; }
|
||||||
|
|
||||||
@media (max-height: 900px) {
|
@media (max-height: 900px) {
|
||||||
@@ -396,6 +398,9 @@ body.shelf [data-role="hero"] { font-size: 1.35rem; }
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.live-page {
|
.live-page {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
|
||||||
max-width: 36rem;
|
max-width: 36rem;
|
||||||
}
|
}
|
||||||
.jetzt-eta {
|
.jetzt-eta {
|
||||||
@@ -548,7 +553,16 @@ button.warn:hover { background: var(--st-error-soft); filter: none; }
|
|||||||
background: rgba(255, 255, 255, 0.025);
|
background: rgba(255, 255, 255, 0.025);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.queue-box.is-empty { display: none; }
|
.live-pop .queue-box.is-empty { display: none; }
|
||||||
|
.live-page .queue-box.is-empty { display: block; }
|
||||||
|
.queue-empty {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.85rem 0.7rem 1rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
.queue-box[open] { border-color: var(--line); }
|
.queue-box[open] { border-color: var(--line); }
|
||||||
.run-stack {
|
.run-stack {
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -613,6 +627,8 @@ h2.queue-title { font-size: var(--t-xs); }
|
|||||||
}
|
}
|
||||||
.icon-btn:hover { color: var(--ink); background: rgba(255, 255, 255, 0.06); filter: none; }
|
.icon-btn:hover { color: var(--ink); background: rgba(255, 255, 255, 0.06); filter: none; }
|
||||||
.icon-btn.danger:hover { color: var(--st-error); background: var(--st-error-soft); }
|
.icon-btn.danger:hover { color: var(--st-error); background: var(--st-error-soft); }
|
||||||
|
.ghost.tiny-btn.danger { color: var(--st-error); }
|
||||||
|
.ghost.tiny-btn.danger:hover { background: var(--st-error-soft); }
|
||||||
.queue-list { list-style: none; margin: 0; padding: 0 0.4rem 0.45rem; display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.35rem; }
|
.queue-list { list-style: none; margin: 0; padding: 0 0.4rem 0.45rem; display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.35rem; }
|
||||||
.queue-list > li { min-width: 0; }
|
.queue-list > li { min-width: 0; }
|
||||||
.queue-empty {
|
.queue-empty {
|
||||||
@@ -895,6 +911,22 @@ h2 { font-size: 0.95rem; margin: 0 0 0.65rem; letter-spacing: -0.02em; font-weig
|
|||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
.stat-more {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.35rem 0.65rem 0.55rem;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
.stat-more > summary {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
font-weight: 650;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
.stat-more > summary::-webkit-details-marker { display: none; }
|
||||||
|
.stat-more[open] > summary { margin-bottom: 0.45rem; }
|
||||||
.stat-filters {
|
.stat-filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -985,7 +1017,7 @@ article.machine {
|
|||||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 40%), var(--card);
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 40%), var(--card);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), var(--shadow);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -993,6 +1025,9 @@ article.machine {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.3s var(--ease), box-shadow 0.3s var(--ease), transform 0.2s var(--ease);
|
transition: border-color 0.3s var(--ease), box-shadow 0.3s var(--ease), transform 0.2s var(--ease);
|
||||||
}
|
}
|
||||||
|
html[data-theme="light"] article.machine {
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), var(--shadow);
|
||||||
|
}
|
||||||
article.machine::before {
|
article.machine::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -1002,12 +1037,31 @@ article.machine::before {
|
|||||||
}
|
}
|
||||||
article.machine:hover {
|
article.machine:hover {
|
||||||
border-color: var(--line-strong);
|
border-color: var(--line-strong);
|
||||||
box-shadow: var(--shadow), 0 0 0 1px rgba(255, 255, 255, 0.04);
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), var(--shadow), 0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
article.machine.printing { border-color: rgba(61, 206, 124, 0.35); box-shadow: var(--shadow), 0 0 0 1px rgba(61, 206, 124, 0.06), 0 18px 40px -24px rgba(61, 206, 124, 0.45); }
|
html[data-theme="light"] article.machine:hover {
|
||||||
article.machine.printing::before { background: linear-gradient(90deg, transparent, var(--st-printing), transparent); opacity: 0.7; }
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.95), var(--shadow), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||||
article.machine.printing:hover { border-color: rgba(61, 206, 124, 0.55); box-shadow: var(--shadow), 0 0 0 1px rgba(61, 206, 124, 0.12), 0 18px 40px -20px rgba(61, 206, 124, 0.55); }
|
}
|
||||||
|
article.machine.printing {
|
||||||
|
border-color: rgba(61, 206, 124, 0.35);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), var(--shadow), 0 0 0 1px rgba(61, 206, 124, 0.06), 0 18px 40px -24px rgba(61, 206, 124, 0.45);
|
||||||
|
}
|
||||||
|
article.machine.printing::before {
|
||||||
|
background: linear-gradient(90deg, transparent, var(--st-printing), transparent);
|
||||||
|
opacity: 0.7;
|
||||||
|
animation: print-strip-breathe 2.8s var(--ease) infinite;
|
||||||
|
}
|
||||||
|
article.machine.printing:hover {
|
||||||
|
border-color: rgba(61, 206, 124, 0.55);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), var(--shadow), 0 0 0 1px rgba(61, 206, 124, 0.12), 0 18px 40px -20px rgba(61, 206, 124, 0.55);
|
||||||
|
}
|
||||||
|
article.machine.printing .hero-main {
|
||||||
|
font-size: 2.4rem;
|
||||||
|
letter-spacing: -0.05em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
article.machine.paused { border-color: rgba(251, 191, 36, 0.35); }
|
article.machine.paused { border-color: rgba(251, 191, 36, 0.35); }
|
||||||
article.machine.paused:hover { border-color: rgba(251, 191, 36, 0.55); }
|
article.machine.paused:hover { border-color: rgba(251, 191, 36, 0.55); }
|
||||||
article.machine.heating { border-color: rgba(251, 191, 36, 0.28); }
|
article.machine.heating { border-color: rgba(251, 191, 36, 0.28); }
|
||||||
@@ -1017,6 +1071,10 @@ article.machine.error::before { background: var(--st-error); opacity: 0.7; }
|
|||||||
article.machine.error:hover { border-color: rgba(248, 113, 113, 0.6); }
|
article.machine.error:hover { border-color: rgba(248, 113, 113, 0.6); }
|
||||||
article.machine.offline { opacity: 0.62; }
|
article.machine.offline { opacity: 0.62; }
|
||||||
article.machine.offline:hover { opacity: 1; }
|
article.machine.offline:hover { opacity: 1; }
|
||||||
|
@keyframes print-strip-breathe {
|
||||||
|
0%, 100% { opacity: 0.45; }
|
||||||
|
50% { opacity: 0.9; }
|
||||||
|
}
|
||||||
|
|
||||||
.machine-head {
|
.machine-head {
|
||||||
padding: 0.85rem 0.95rem 0.3rem;
|
padding: 0.85rem 0.95rem 0.3rem;
|
||||||
@@ -1328,6 +1386,15 @@ article.printer {
|
|||||||
}
|
}
|
||||||
main > .card { margin-bottom: 0.85rem; }
|
main > .card { margin-bottom: 0.85rem; }
|
||||||
.split { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); gap: 1rem; align-items: start; }
|
.split { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); gap: 1rem; align-items: start; }
|
||||||
|
.edit-split { grid-template-columns: minmax(0, 1.15fr) minmax(18rem, 0.95fr); }
|
||||||
|
.edit-split.is-idle { grid-template-columns: 1fr; }
|
||||||
|
.edit-panel { position: sticky; top: calc(var(--topbar-h) + 0.75rem); }
|
||||||
|
.cell-path {
|
||||||
|
max-width: 14rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.55rem 0.75rem; }
|
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.55rem 0.75rem; }
|
||||||
.field { display: grid; gap: 0.28rem; min-width: 0; }
|
.field { display: grid; gap: 0.28rem; min-width: 0; }
|
||||||
.field > label, .field-label {
|
.field > label, .field-label {
|
||||||
@@ -1441,7 +1508,7 @@ td select { width: auto; min-width: 8.5rem; padding: 0.3rem 0.45rem; min-height:
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse at 20% 0%, rgba(61, 206, 124, 0.12), transparent 45%),
|
radial-gradient(ellipse at 20% 0%, rgba(249, 115, 22, 0.16), transparent 50%),
|
||||||
var(--bg);
|
var(--bg);
|
||||||
}
|
}
|
||||||
.auth-card {
|
.auth-card {
|
||||||
@@ -1450,6 +1517,10 @@ td select { width: auto; min-width: 8.5rem; padding: 0.3rem 0.45rem; min-height:
|
|||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 1.4rem 1.35rem 1.5rem;
|
padding: 1.4rem 1.35rem 1.5rem;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), var(--shadow);
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .auth-card {
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), var(--shadow);
|
||||||
}
|
}
|
||||||
.auth-card h1 { margin: 0 0 0.35rem; font-size: 1.45rem; }
|
.auth-card h1 { margin: 0 0 0.35rem; font-size: 1.45rem; }
|
||||||
.auth-brand {
|
.auth-brand {
|
||||||
@@ -2150,7 +2221,7 @@ body.desk-on main {
|
|||||||
.chip.complete { --c: var(--st-done); --cs: var(--st-done-soft); }
|
.chip.complete { --c: var(--st-done); --cs: var(--st-done-soft); }
|
||||||
.chip.error { --c: var(--st-error); --cs: var(--st-error-soft); }
|
.chip.error { --c: var(--st-error); --cs: var(--st-error-soft); }
|
||||||
.chip.offline { --c: var(--st-offline); --cs: var(--st-offline-soft); }
|
.chip.offline { --c: var(--st-offline); --cs: var(--st-offline-soft); }
|
||||||
.chip.waiting { --c: var(--st-waiting); --cs: transparent; box-shadow: inset 0 0 0 1px rgba(249, 115, 22, 0.45); }
|
.chip.waiting { --c: var(--st-waiting); --cs: transparent; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--st-waiting) 45%, transparent); }
|
||||||
.chip.cancelled { --c: var(--muted); --cs: var(--st-idle-soft); }
|
.chip.cancelled { --c: var(--muted); --cs: var(--st-idle-soft); }
|
||||||
.chip.sm { font-size: 0.6rem; padding: 0.12rem 0.45rem 0.12rem 0.38rem; gap: 0.3rem; }
|
.chip.sm { font-size: 0.6rem; padding: 0.12rem 0.45rem 0.12rem 0.38rem; gap: 0.3rem; }
|
||||||
.chip.sm::before { width: 0.36rem; height: 0.36rem; }
|
.chip.sm::before { width: 0.36rem; height: 0.36rem; }
|
||||||
@@ -2290,7 +2361,46 @@ body.desk-on main {
|
|||||||
.strip.stopped .strip-actions button:not(.ghost) { background: var(--accent); color: var(--accent-ink); }
|
.strip.stopped .strip-actions button:not(.ghost) { background: var(--accent); color: var(--accent-ink); }
|
||||||
.strip.stopped .strip-actions button.ghost { color: #fecaca; border-color: rgba(248, 113, 113, 0.4); }
|
.strip.stopped .strip-actions button.ghost { color: #fecaca; border-color: rgba(248, 113, 113, 0.4); }
|
||||||
.waiting-note { font-size: var(--t-xs); color: var(--muted); display: flex; align-items: center; gap: 0.35rem; }
|
.waiting-note { font-size: var(--t-xs); color: var(--muted); display: flex; align-items: center; gap: 0.35rem; }
|
||||||
.waiting-note b { color: var(--accent); }
|
.waiting-note b { color: var(--st-waiting); }
|
||||||
|
.desk-next {
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
padding: 0.55rem 0.6rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
.desk-next-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-weight: 650;
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
}
|
||||||
|
.desk-next-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.desk-next-row .grow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.05rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.desk-next-row .grow span,
|
||||||
|
.desk-next-row .grow b {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.desk-next-files {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
.segmented {
|
.segmented {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
@@ -2461,7 +2571,7 @@ article.machine.flash { animation: flash 2s var(--ease); }
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0.35rem 0.5rem;
|
padding: 0.35rem 0.5rem;
|
||||||
}
|
}
|
||||||
@media (min-width: 1100px) {
|
@media (min-width: 1280px) {
|
||||||
.lib-tools { flex-wrap: nowrap; }
|
.lib-tools { flex-wrap: nowrap; }
|
||||||
.lib-tools .search-wrap { max-width: 12rem; }
|
.lib-tools .search-wrap { max-width: 12rem; }
|
||||||
}
|
}
|
||||||
@@ -2510,21 +2620,22 @@ article.machine.flash { animation: flash 2s var(--ease); }
|
|||||||
.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; }
|
||||||
.lib-info-pop {
|
.lib-info-pop {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
right: 0;
|
z-index: 80;
|
||||||
top: calc(100% + 0.25rem);
|
|
||||||
z-index: 20;
|
|
||||||
min-width: 11rem;
|
min-width: 11rem;
|
||||||
|
max-width: min(18rem, calc(100vw - 1rem));
|
||||||
padding: 0.55rem 0.7rem;
|
padding: 0.55rem 0.7rem;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid var(--line-strong);
|
border: 1px solid var(--line-strong);
|
||||||
background: #121217;
|
background: var(--card-2);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
font-size: var(--t-xs);
|
font-size: var(--t-xs);
|
||||||
color: var(--muted);
|
color: var(--ink);
|
||||||
display: grid;
|
display: none;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
.lib-info[open] > .lib-info-pop { display: grid; }
|
||||||
.picked-details { opacity: 0.9; }
|
.picked-details { opacity: 0.9; }
|
||||||
|
|
||||||
.flow-step { padding: 0.9rem 0 0.95rem; border-top: 1px solid var(--line); }
|
.flow-step { padding: 0.9rem 0 0.95rem; border-top: 1px solid var(--line); }
|
||||||
@@ -2637,11 +2748,49 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); }
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
.proj-file { flex-direction: column; align-items: flex-start; gap: 0.15rem; }
|
.proj-file { flex-direction: column; align-items: flex-start; gap: 0.15rem; flex: 1 1 auto; min-width: 0; }
|
||||||
.proj-file:hover, .proj-asset:hover { border-color: var(--line-strong); }
|
.proj-file:hover, .proj-asset:hover { border-color: var(--line-strong); }
|
||||||
.proj-asset a { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); }
|
.proj-asset a { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); }
|
||||||
.proj-asset .tiny { flex: none; color: var(--muted); }
|
.proj-asset .tiny { flex: none; color: var(--muted); }
|
||||||
|
.proj-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(5.5rem, 1fr));
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin: 0.75rem 0 0.35rem;
|
||||||
|
}
|
||||||
|
.proj-stat {
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
.proj-stat strong { font-size: var(--t-sm); }
|
||||||
|
.proj-file-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
.proj-file-row .ghost { flex: none; }
|
||||||
|
.focus-flash {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
animation: focus-flash-fade 2.4s var(--ease) forwards;
|
||||||
|
}
|
||||||
|
@keyframes focus-flash-fade {
|
||||||
|
0% { outline-color: var(--accent); }
|
||||||
|
70% { outline-color: var(--accent); }
|
||||||
|
100% { outline-color: transparent; }
|
||||||
|
}
|
||||||
|
|
||||||
|
a.lib-proj {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a.lib-proj:hover { text-decoration: underline; }
|
||||||
.lib-card { position: relative; transition: box-shadow 0.2s var(--ease); }
|
.lib-card { position: relative; transition: box-shadow 0.2s var(--ease); }
|
||||||
.lib-card.drop { box-shadow: 0 0 0 2px var(--accent), var(--shadow); }
|
.lib-card.drop { box-shadow: 0 0 0 2px var(--accent), var(--shadow); }
|
||||||
.drop-hint { margin: 0.6rem 0 0; font-size: var(--t-xs); color: var(--faint); text-align: center; }
|
.drop-hint { margin: 0.6rem 0 0; font-size: var(--t-xs); color: var(--faint); text-align: center; }
|
||||||
@@ -2789,11 +2938,17 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); }
|
|||||||
@media (max-width: 1080px) {
|
@media (max-width: 1080px) {
|
||||||
.send-layout { grid-template-columns: 1fr; }
|
.send-layout { grid-template-columns: 1fr; }
|
||||||
.send-panel { position: static; }
|
.send-panel { position: static; }
|
||||||
|
.split, .edit-split, .mat-split { grid-template-columns: 1fr; }
|
||||||
|
.edit-panel, .mat-form { position: static; }
|
||||||
.doc-layout { grid-template-columns: 1fr; }
|
.doc-layout { grid-template-columns: 1fr; }
|
||||||
.doc-toc { position: static; display: flex; flex-wrap: wrap; gap: 0.3rem; padding-bottom: 0.9rem; border-bottom: 1px solid var(--line); }
|
.doc-toc { position: static; display: flex; flex-wrap: wrap; gap: 0.3rem; padding-bottom: 0.9rem; border-bottom: 1px solid var(--line); }
|
||||||
.doc-toc p { display: none; }
|
.doc-toc p { display: none; }
|
||||||
.doc-toc a { border: 1px solid var(--line-strong); border-radius: 99px; padding: 0.22rem 0.65rem; font-size: var(--t-xs); }
|
.doc-toc a { border: 1px solid var(--line-strong); border-radius: 99px; padding: 0.22rem 0.65rem; font-size: var(--t-xs); }
|
||||||
.doc-toc a.on { border-radius: 99px; border-color: rgba(249, 115, 22, 0.55); }
|
.doc-toc a.on { border-radius: 99px; border-color: rgba(249, 115, 22, 0.55); }
|
||||||
|
.proto-table th:nth-child(4),
|
||||||
|
.proto-table th:nth-child(5),
|
||||||
|
.proto-table td:nth-child(4),
|
||||||
|
.proto-table td:nth-child(5) { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
@@ -2831,7 +2986,11 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); }
|
|||||||
.send-panel, .doc-toc { top: 1rem; }
|
.send-panel, .doc-toc { top: 1rem; }
|
||||||
.sidebar { border-right: 0; border-bottom: 1px solid var(--line); }
|
.sidebar { border-right: 0; border-bottom: 1px solid var(--line); }
|
||||||
.live-bar { display: none; }
|
.live-bar { display: none; }
|
||||||
.split, .grid-2 { grid-template-columns: 1fr; }
|
.grid-2 { grid-template-columns: 1fr; }
|
||||||
|
.proto-table th:nth-child(6),
|
||||||
|
.proto-table th:nth-child(8),
|
||||||
|
.proto-table td:nth-child(6),
|
||||||
|
.proto-table td.proto-spool { display: none; }
|
||||||
.fleet { grid-template-columns: 1fr; }
|
.fleet { grid-template-columns: 1fr; }
|
||||||
main { padding: 1rem 0.85rem 2rem; }
|
main { padding: 1rem 0.85rem 2rem; }
|
||||||
.hero-main { font-size: 1.4rem; }
|
.hero-main { font-size: 1.4rem; }
|
||||||
@@ -3048,6 +3207,16 @@ ul.plain li:last-child { border-bottom: 0; }
|
|||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
.mobile-more-sheet .nav-label {
|
||||||
|
margin: 0.45rem 0 0.1rem;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
font-size: var(--t-xs);
|
||||||
|
color: var(--faint);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.mobile-more-sheet .nav-label:first-child { margin-top: 0; }
|
||||||
.mobile-more-sheet a {
|
.mobile-more-sheet a {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 0.65rem 0.75rem;
|
padding: 0.65rem 0.75rem;
|
||||||
@@ -3479,6 +3648,36 @@ td .cell-file {
|
|||||||
margin-top: 0.15rem;
|
margin-top: 0.15rem;
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
.proto-table td.proto-file .warn-text {
|
||||||
|
color: var(--warn, #b45309);
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
.proto-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.proto-help {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
max-width: 36rem;
|
||||||
|
}
|
||||||
|
.proto-help summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.proto-help p { margin: 0.35rem 0 0; color: var(--muted); }
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
.proto-table td.proto-spool {
|
.proto-table td.proto-spool {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -3492,6 +3691,13 @@ td.actions-cell {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
td.actions-cell .icon-btn { vertical-align: middle; }
|
td.actions-cell .icon-btn { vertical-align: middle; }
|
||||||
|
td.actions-cell .act-label { margin-left: 0.15rem; font-size: var(--t-xs); }
|
||||||
|
td.actions-cell .ghost.tiny-btn.with-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
button.is-busy { position: relative; color: transparent !important; pointer-events: none; }
|
button.is-busy { position: relative; color: transparent !important; pointer-events: none; }
|
||||||
button.is-busy::after {
|
button.is-busy::after {
|
||||||
@@ -3644,8 +3850,29 @@ dialog::backdrop { background: rgba(0, 0, 0, 0.55); backdrop-filter: blur(4px);
|
|||||||
margin-right: 0.35rem;
|
margin-right: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pager {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
}
|
||||||
|
.pager[hidden] { display: none !important; }
|
||||||
|
.pager-info {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
min-width: 7rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.pager button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
.sidebar, .mobile-nav, .conn-banner, .toast-host, .nav-toggle { display: none !important; }
|
.sidebar, .mobile-nav, .conn-banner, .toast-host, .nav-toggle, .pager { display: none !important; }
|
||||||
.app-shell { display: block; }
|
.app-shell { display: block; }
|
||||||
.app-main { margin: 0; }
|
.app-main { margin: 0; }
|
||||||
body { background: #fff; color: #111; }
|
body { background: #fff; color: #111; }
|
||||||
|
|||||||
@@ -333,3 +333,97 @@ window.setConnectionBanner = setConnectionBanner;
|
|||||||
window.createPoller = createPoller;
|
window.createPoller = createPoller;
|
||||||
window.promptAbraeumen = promptAbraeumen;
|
window.promptAbraeumen = promptAbraeumen;
|
||||||
window.VERDICT_REASON_LABELS = VERDICT_REASON_LABELS;
|
window.VERDICT_REASON_LABELS = VERDICT_REASON_LABELS;
|
||||||
|
|
||||||
|
/** Listen-Pagination (clientseitig auf gefiltertem Array). */
|
||||||
|
const DEFAULT_PAGE_SIZE = 40;
|
||||||
|
|
||||||
|
function pageSlice(items, page, pageSize) {
|
||||||
|
const list = Array.isArray(items) ? items : [];
|
||||||
|
const size = Math.max(1, Number(pageSize) || DEFAULT_PAGE_SIZE);
|
||||||
|
const total = list.length;
|
||||||
|
const pages = Math.max(1, Math.ceil(total / size) || 1);
|
||||||
|
let p = Math.max(1, parseInt(page, 10) || 1);
|
||||||
|
if (p > pages) p = pages;
|
||||||
|
const start = (p - 1) * size;
|
||||||
|
return {
|
||||||
|
items: list.slice(start, start + size),
|
||||||
|
page: p,
|
||||||
|
pages,
|
||||||
|
total,
|
||||||
|
pageSize: size,
|
||||||
|
from: total ? start + 1 : 0,
|
||||||
|
to: Math.min(start + size, total),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPager(el, state, onPage) {
|
||||||
|
if (!el) return;
|
||||||
|
const s = state || pageSlice([], 1);
|
||||||
|
if (s.total <= s.pageSize) {
|
||||||
|
el.hidden = true;
|
||||||
|
el.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.hidden = false;
|
||||||
|
el.className = "pager";
|
||||||
|
el.innerHTML =
|
||||||
|
'<button type="button" class="ghost pager-prev" ' + (s.page <= 1 ? "disabled" : "") + ">Zurück</button>" +
|
||||||
|
'<span class="pager-info">' + s.from + "–" + s.to + " von " + s.total + "</span>" +
|
||||||
|
'<button type="button" class="ghost pager-next" ' + (s.page >= s.pages ? "disabled" : "") + ">Weiter</button>";
|
||||||
|
el.querySelector(".pager-prev").onclick = () => onPage(s.page - 1);
|
||||||
|
el.querySelector(".pager-next").onclick = () => onPage(s.page + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einheitliche Querverlinkung zwischen Seiten.
|
||||||
|
* @param {"datei"|"projekt"|"spule"|"auftrag"|"lauf"|"drucker"} kind
|
||||||
|
* @param {string|number} id
|
||||||
|
* @param {string} [label]
|
||||||
|
* @returns {string} HTML-Anker oder escaped Text
|
||||||
|
*/
|
||||||
|
function linkTo(kind, id, label) {
|
||||||
|
const text = escapeHtml(label == null || label === "" ? String(id ?? "") : String(label));
|
||||||
|
if (id == null || id === "") return text;
|
||||||
|
const enc = encodeURIComponent(String(id));
|
||||||
|
let href = "";
|
||||||
|
switch (kind) {
|
||||||
|
case "datei":
|
||||||
|
href = "/dateien?file=" + enc;
|
||||||
|
break;
|
||||||
|
case "projekt":
|
||||||
|
href = "/projekte#" + enc;
|
||||||
|
break;
|
||||||
|
case "spule":
|
||||||
|
href = "/material?spule=" + enc;
|
||||||
|
break;
|
||||||
|
case "auftrag":
|
||||||
|
href = "/auftraege#" + enc;
|
||||||
|
break;
|
||||||
|
case "lauf":
|
||||||
|
href = "/protokoll/" + enc;
|
||||||
|
break;
|
||||||
|
case "drucker":
|
||||||
|
href = "/#" + enc;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return '<a href="' + href + '">' + text + "</a>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hash/Query-Fokus: Element hervorheben und scrollen. */
|
||||||
|
function focusById(id, opts) {
|
||||||
|
if (!id) return null;
|
||||||
|
const el = document.getElementById(id) || document.querySelector('[data-id="' + CSS.escape(String(id)) + '"]');
|
||||||
|
if (!el) return null;
|
||||||
|
el.classList.add("focus-flash");
|
||||||
|
el.scrollIntoView({ behavior: (opts && opts.behavior) || "smooth", block: "center" });
|
||||||
|
setTimeout(() => el.classList.remove("focus-flash"), 2400);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.DEFAULT_PAGE_SIZE = DEFAULT_PAGE_SIZE;
|
||||||
|
window.pageSlice = pageSlice;
|
||||||
|
window.renderPager = renderPager;
|
||||||
|
window.linkTo = linkTo;
|
||||||
|
window.focusById = focusById;
|
||||||
@@ -14,5 +14,5 @@
|
|||||||
<section class="queue-box" data-box="alerts" id="alert-box" hidden>
|
<section class="queue-box" data-box="alerts" id="alert-box" hidden>
|
||||||
<h2 class="queue-title"><svg class="ico title-ico"><use href="#i-bell"/></svg><span class="grow">Meldungen</span><span class="count bad" id="alert-count">0</span></h2>
|
<h2 class="queue-title"><svg class="ico title-ico"><use href="#i-bell"/></svg><span class="grow">Meldungen</span><span class="count bad" id="alert-count">0</span></h2>
|
||||||
<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">Meldungen gelesen</button> <a href="/aktivitaet">alle →</a></div>
|
||||||
</section>
|
</section>
|
||||||
@@ -19,16 +19,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar" id="f-kind" role="group" aria-label="Art"></div>
|
<div class="toolbar" id="f-kind" role="group" aria-label="Art"></div>
|
||||||
<div class="toolbar" id="f-host" role="group" aria-label="Drucker"></div>
|
<div class="toolbar" aria-label="Drucker">
|
||||||
|
<label class="field inline" for="f-host">
|
||||||
|
<span class="sr-only">Drucker</span>
|
||||||
|
<select id="f-host" title="Drucker">
|
||||||
|
<option value="">Alle Drucker</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p id="tl-note" class="tiny"></p>
|
<p id="tl-note" class="tiny"></p>
|
||||||
<div class="timeline" id="timeline">
|
<div class="timeline" id="timeline">
|
||||||
<div class="skeleton" style="height:4rem"></div>
|
<div class="skeleton" style="height:4rem"></div>
|
||||||
<div class="skeleton" style="height:4rem"></div>
|
<div class="skeleton" style="height:4rem"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pager" id="tl-pager" hidden></div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block script %}
|
{% block script %}
|
||||||
<script>
|
<script>
|
||||||
|
let tlPage = 1;
|
||||||
|
let tlItems = [];
|
||||||
const KINDS = [
|
const KINDS = [
|
||||||
["queue", "Warteschlange"],
|
["queue", "Warteschlange"],
|
||||||
["ablauf", "Ablauf"],
|
["ablauf", "Ablauf"],
|
||||||
@@ -64,11 +74,13 @@ function renderFilters() {
|
|||||||
KINDS.map(([k, label]) =>
|
KINDS.map(([k, label]) =>
|
||||||
`<button type="button" class="fchip ${filter.kinds.has(k) ? "on" : ""}" data-kind="${k}"><span class="tl-dot ${k}"></span>${label}</button>`
|
`<button type="button" class="fchip ${filter.kinds.has(k) ? "on" : ""}" data-kind="${k}"><span class="tl-dot ${k}"></span>${label}</button>`
|
||||||
).join("");
|
).join("");
|
||||||
document.getElementById("f-host").innerHTML =
|
const hostSel = document.getElementById("f-host");
|
||||||
`<button type="button" class="fchip ${filter.host ? "" : "on"}" data-host="">Alle Drucker</button>` +
|
const cur = filter.host;
|
||||||
|
hostSel.innerHTML = '<option value="">Alle Drucker</option>' +
|
||||||
printers.map((p) =>
|
printers.map((p) =>
|
||||||
`<button type="button" class="fchip ${filter.host === p.host ? "on" : ""}" data-host="${escapeHtml(p.host)}">${escapeHtml(p.name)}</button>`
|
`<option value="${escapeHtml(p.host)}">${escapeHtml(p.name)}</option>`
|
||||||
).join("");
|
).join("");
|
||||||
|
if ([...hostSel.options].some((o) => o.value === cur)) hostSel.value = cur;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sinceIso() {
|
function sinceIso() {
|
||||||
@@ -111,14 +123,19 @@ function itemHtml(ev) {
|
|||||||
function render(items) {
|
function render(items) {
|
||||||
const box = document.getElementById("timeline");
|
const box = document.getElementById("timeline");
|
||||||
const note = document.getElementById("tl-note");
|
const note = document.getElementById("tl-note");
|
||||||
if (!items.length) {
|
const pager = document.getElementById("tl-pager");
|
||||||
|
tlItems = items || [];
|
||||||
|
if (!tlItems.length) {
|
||||||
box.innerHTML = `<div class="card empty-state">${icon("activity")}<p>Keine Einträge${filter.range === "day" ? " heute" : ""}.</p>${
|
box.innerHTML = `<div class="card empty-state">${icon("activity")}<p>Keine Einträge${filter.range === "day" ? " heute" : ""}.</p>${
|
||||||
filter.range !== "all" ? `<button type="button" class="ghost" data-range-jump="all">Alle anzeigen</button>` : ""}</div>`;
|
filter.range !== "all" ? `<button type="button" class="ghost" data-range-jump="all">Alle anzeigen</button>` : ""}</div>`;
|
||||||
note.textContent = "";
|
note.textContent = "";
|
||||||
|
renderPager(pager, null, () => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const paged = pageSlice(tlItems, tlPage, DEFAULT_PAGE_SIZE);
|
||||||
|
tlPage = paged.page;
|
||||||
const groups = [];
|
const groups = [];
|
||||||
for (const ev of items) {
|
for (const ev of paged.items) {
|
||||||
const label = dayLabel(ev.created_at);
|
const label = dayLabel(ev.created_at);
|
||||||
if (!groups.length || groups[groups.length - 1].label !== label) groups.push({ label, items: [] });
|
if (!groups.length || groups[groups.length - 1].label !== label) groups.push({ label, items: [] });
|
||||||
groups[groups.length - 1].items.push(ev);
|
groups[groups.length - 1].items.push(ev);
|
||||||
@@ -126,7 +143,11 @@ function render(items) {
|
|||||||
box.innerHTML = groups.map((g) =>
|
box.innerHTML = groups.map((g) =>
|
||||||
`<section class="tl-day"><h3>${escapeHtml(g.label)} · ${g.items.length}</h3><ol class="tl-list">${g.items.map(itemHtml).join("")}</ol></section>`
|
`<section class="tl-day"><h3>${escapeHtml(g.label)} · ${g.items.length}</h3><ol class="tl-list">${g.items.map(itemHtml).join("")}</ol></section>`
|
||||||
).join("");
|
).join("");
|
||||||
note.textContent = items.length >= 300 ? "Die letzten 300 Einträge. Zeitraum oder Filter enger stellen für ältere." : "";
|
note.textContent = tlItems.length >= 300 ? "Die letzten 300 Einträge. Zeitraum oder Filter enger stellen für ältere." : "";
|
||||||
|
renderPager(pager, paged, (p) => {
|
||||||
|
tlPage = p;
|
||||||
|
render(tlItems);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -137,6 +158,7 @@ async function load() {
|
|||||||
if (filter.host) q.set("host", filter.host);
|
if (filter.host) q.set("host", filter.host);
|
||||||
try {
|
try {
|
||||||
const data = await api("/ereignisse?" + q.toString());
|
const data = await api("/ereignisse?" + q.toString());
|
||||||
|
tlPage = 1;
|
||||||
render(data.items || []);
|
render(data.items || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
document.getElementById("timeline").innerHTML = `<div class="strip bad">${icon("alert")}<span>${escapeHtml(err.message)}</span></div>`;
|
document.getElementById("timeline").innerHTML = `<div class="strip bad">${icon("alert")}<span>${escapeHtml(err.message)}</span></div>`;
|
||||||
@@ -152,8 +174,6 @@ document.addEventListener("click", (e) => {
|
|||||||
if (!k) filter.kinds.clear();
|
if (!k) filter.kinds.clear();
|
||||||
else if (filter.kinds.has(k)) filter.kinds.delete(k);
|
else if (filter.kinds.has(k)) filter.kinds.delete(k);
|
||||||
else filter.kinds.add(k);
|
else filter.kinds.add(k);
|
||||||
} else if (e.target.closest("#f-host [data-host]")) {
|
|
||||||
filter.host = e.target.closest("[data-host]").dataset.host;
|
|
||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -162,6 +182,13 @@ document.addEventListener("click", (e) => {
|
|||||||
load();
|
load();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById("f-host").addEventListener("change", () => {
|
||||||
|
filter.host = document.getElementById("f-host").value || "";
|
||||||
|
syncUrl();
|
||||||
|
renderFilters();
|
||||||
|
load();
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById("tl-refresh").addEventListener("click", load);
|
document.getElementById("tl-refresh").addEventListener("click", load);
|
||||||
document.getElementById("tl-read").addEventListener("click", async () => {
|
document.getElementById("tl-read").addEventListener("click", async () => {
|
||||||
await api("/ereignisse/gelesen", { method: "POST", json: {} });
|
await api("/ereignisse/gelesen", { method: "POST", json: {} });
|
||||||
|
|||||||
+237
-84
@@ -7,9 +7,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a class="ghost" id="lieferschein-link" href="#" hidden target="_blank" rel="noopener">Lieferschein</a>
|
<a class="ghost" id="lieferschein-link" href="#" hidden target="_blank" rel="noopener">Lieferschein</a>
|
||||||
|
<button type="button" class="btn-primary" id="order-new-head"><svg class="ico"><use href="#i-plus"/></svg>Neuer Auftrag</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="split">
|
<div class="split edit-split is-idle" id="order-layout">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="field inline">
|
<div class="field inline">
|
||||||
<label for="filter-status">Status</label>
|
<label for="filter-status">Status</label>
|
||||||
@@ -29,11 +30,17 @@
|
|||||||
<thead><tr><th>Titel</th><th>Status</th><th>Fortschritt</th></tr></thead>
|
<thead><tr><th>Titel</th><th>Status</th><th>Fortschritt</th></tr></thead>
|
||||||
<tbody id="order-rows"></tbody>
|
<tbody id="order-rows"></tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<div class="pager" id="order-pager" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="card">
|
<section class="card edit-panel" id="order-panel" hidden>
|
||||||
<h2 id="order-title">Neuer Auftrag</h2>
|
<div class="mat-form-head">
|
||||||
<form id="order-form">
|
<h2 id="order-title">Neuer Auftrag</h2>
|
||||||
|
<button type="button" class="ghost tiny-btn" id="order-edit-btn" hidden>Bearbeiten</button>
|
||||||
|
<button type="button" class="icon-btn" id="order-close" title="Schließen" aria-label="Schließen"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="order-form" hidden>
|
||||||
<input id="order-id" type="hidden">
|
<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="field"><label for="order-title-in">Titel</label><input id="order-title-in" required></div>
|
||||||
<div class="grid-2">
|
<div class="grid-2">
|
||||||
@@ -56,43 +63,61 @@
|
|||||||
<div class="field"><label for="order-notes">Notiz</label><textarea id="order-notes"></textarea></div>
|
<div class="field"><label for="order-notes">Notiz</label><textarea id="order-notes"></textarea></div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit" class="btn-primary">Speichern</button>
|
<button type="submit" class="btn-primary">Speichern</button>
|
||||||
<button type="button" class="ghost" id="order-new">Neu</button>
|
<button type="button" class="ghost" id="order-cancel-edit" hidden>Abbrechen</button>
|
||||||
<button type="button" class="btn-danger" id="order-delete" hidden>Löschen</button>
|
<button type="button" class="btn-danger" id="order-delete" hidden>Löschen</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<p id="order-note" class="tiny"></p>
|
<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 id="order-detail" hidden>
|
||||||
<div class="table-wrap">
|
<p class="tiny" id="order-progress"></p>
|
||||||
<table>
|
<p class="tiny" id="order-actuals"></p>
|
||||||
<thead><tr><th>Datei</th><th class="num">Stk</th><th class="num">fertig</th><th></th></tr></thead>
|
<p class="tiny" id="order-notes-view"></p>
|
||||||
<tbody id="item-rows"></tbody>
|
|
||||||
</table>
|
<h2 class="section-gap">Positionen</h2>
|
||||||
</div>
|
<div class="table-wrap">
|
||||||
<form id="item-form" class="section-gap">
|
<table>
|
||||||
<div class="grid-2">
|
<thead><tr><th>Datei</th><th class="num">Stk</th><th class="num">fertig</th><th></th></tr></thead>
|
||||||
<div class="field"><label for="item-file">Datei</label><select id="item-file"><option value="">—</option></select></div>
|
<tbody id="item-rows"></tbody>
|
||||||
<div class="field"><label for="item-qty">Stück</label><input id="item-qty" type="number" min="1" step="1" value="1"></div>
|
</table>
|
||||||
<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>
|
||||||
<div class="actions"><button type="submit">Position hinzufügen</button></div>
|
|
||||||
</form>
|
<form id="item-edit-form" class="section-gap card" hidden style="padding:0.75rem;margin-top:0.75rem">
|
||||||
<details class="section-gap">
|
<input type="hidden" id="item-edit-id">
|
||||||
<summary>Kunde anlegen / bearbeiten</summary>
|
<h3 style="margin:0 0 0.5rem;font-size:0.95rem">Position ändern</h3>
|
||||||
<form id="customer-form">
|
<div class="grid-2">
|
||||||
<input type="hidden" id="cust-id">
|
<div class="field"><label for="item-edit-qty">Stück</label><input id="item-edit-qty" type="number" min="1" step="1" value="1"></div>
|
||||||
<div class="field"><label for="cust-name">Name</label><input id="cust-name" required></div>
|
<div class="field"><label for="item-edit-price">Preis/Stk €</label><input id="item-edit-price" type="number" min="0" step="0.01" value="0"></div>
|
||||||
<div class="field"><label for="cust-email">E-Mail</label><input id="cust-email" type="email"></div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit" class="btn-primary">Kunde speichern</button>
|
<button type="submit" class="btn-primary">Speichern</button>
|
||||||
<button type="button" class="ghost" id="cust-edit-current">Aktuellen laden</button>
|
<button type="button" class="ghost" id="item-edit-cancel">Abbrechen</button>
|
||||||
<button type="button" class="btn-danger" id="cust-delete" hidden>Kunde löschen</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
|
||||||
|
<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 / bearbeiten</summary>
|
||||||
|
<form id="customer-form">
|
||||||
|
<input type="hidden" id="cust-id">
|
||||||
|
<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>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="btn-primary">Kunde speichern</button>
|
||||||
|
<button type="button" class="ghost" id="cust-edit-current">Aktuellen laden</button>
|
||||||
|
<button type="button" class="btn-danger" id="cust-delete" hidden>Kunde löschen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -106,6 +131,43 @@ let orders = [];
|
|||||||
let customers = [];
|
let customers = [];
|
||||||
let files = [];
|
let files = [];
|
||||||
let current = null;
|
let current = null;
|
||||||
|
/** idle | create | view | edit */
|
||||||
|
let panelMode = "idle";
|
||||||
|
let orderPage = 1;
|
||||||
|
|
||||||
|
document.getElementById("order-close").innerHTML = icon("x");
|
||||||
|
|
||||||
|
function openPanel() {
|
||||||
|
document.getElementById("order-panel").hidden = false;
|
||||||
|
document.getElementById("order-layout").classList.remove("is-idle");
|
||||||
|
document.getElementById("order-panel").scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePanel() {
|
||||||
|
panelMode = "idle";
|
||||||
|
current = null;
|
||||||
|
document.getElementById("order-panel").hidden = true;
|
||||||
|
document.getElementById("order-layout").classList.add("is-idle");
|
||||||
|
document.getElementById("order-form").hidden = true;
|
||||||
|
document.getElementById("order-detail").hidden = true;
|
||||||
|
document.getElementById("order-edit-btn").hidden = true;
|
||||||
|
document.getElementById("order-cancel-edit").hidden = true;
|
||||||
|
document.getElementById("order-delete").hidden = true;
|
||||||
|
document.getElementById("lieferschein-link").hidden = true;
|
||||||
|
document.getElementById("item-edit-form").hidden = true;
|
||||||
|
document.getElementById("order-note").textContent = "";
|
||||||
|
history.replaceState(null, "", location.pathname);
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFormValues(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 || "") : "";
|
||||||
|
}
|
||||||
|
|
||||||
function fillCustomers() {
|
function fillCustomers() {
|
||||||
const sel = document.getElementById("order-customer");
|
const sel = document.getElementById("order-customer");
|
||||||
@@ -145,27 +207,64 @@ function paintActuals(order) {
|
|||||||
el.textContent = bits.join(" · ");
|
el.textContent = bits.join(" · ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillOrder(order) {
|
function showCreate() {
|
||||||
|
panelMode = "create";
|
||||||
|
current = null;
|
||||||
|
setFormValues(null);
|
||||||
|
document.getElementById("order-title").textContent = "Neuer Auftrag";
|
||||||
|
document.getElementById("order-form").hidden = false;
|
||||||
|
document.getElementById("order-detail").hidden = true;
|
||||||
|
document.getElementById("order-edit-btn").hidden = true;
|
||||||
|
document.getElementById("order-cancel-edit").hidden = true;
|
||||||
|
document.getElementById("order-delete").hidden = true;
|
||||||
|
document.getElementById("lieferschein-link").hidden = true;
|
||||||
|
document.getElementById("order-note").textContent = "";
|
||||||
|
document.getElementById("item-edit-form").hidden = true;
|
||||||
|
history.replaceState(null, "", location.pathname);
|
||||||
|
openPanel();
|
||||||
|
renderList();
|
||||||
|
document.getElementById("order-title-in").focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showView(order) {
|
||||||
|
panelMode = "view";
|
||||||
current = order;
|
current = order;
|
||||||
document.getElementById("order-id").value = order ? order.id : "";
|
setFormValues(order);
|
||||||
document.getElementById("order-title-in").value = order ? order.title : "";
|
document.getElementById("order-title").textContent = order.title || "Auftrag";
|
||||||
document.getElementById("order-status").value = order ? order.status : "idee";
|
document.getElementById("order-form").hidden = true;
|
||||||
document.getElementById("order-due").value = order && order.due_at ? order.due_at.slice(0, 10) : "";
|
document.getElementById("order-detail").hidden = false;
|
||||||
document.getElementById("order-customer").value = order && order.customer_id ? order.customer_id : "";
|
document.getElementById("order-edit-btn").hidden = false;
|
||||||
document.getElementById("order-notes").value = order ? order.notes : "";
|
document.getElementById("order-cancel-edit").hidden = true;
|
||||||
document.getElementById("order-title").textContent = order ? order.title : "Neuer Auftrag";
|
document.getElementById("order-delete").hidden = true;
|
||||||
document.getElementById("order-delete").hidden = !order;
|
document.getElementById("item-edit-form").hidden = true;
|
||||||
const link = document.getElementById("lieferschein-link");
|
const link = document.getElementById("lieferschein-link");
|
||||||
if (order) {
|
link.href = "/auftraege/" + order.id + "/lieferschein";
|
||||||
link.href = "/auftraege/" + order.id + "/lieferschein";
|
link.hidden = false;
|
||||||
link.hidden = false;
|
history.replaceState(null, "", "#" + order.id);
|
||||||
} else {
|
const prog = order.progress;
|
||||||
link.hidden = true;
|
|
||||||
}
|
|
||||||
const prog = order && order.progress;
|
|
||||||
document.getElementById("order-progress").textContent = prog ? "Fortschritt: " + prog.label : "";
|
document.getElementById("order-progress").textContent = prog ? "Fortschritt: " + prog.label : "";
|
||||||
paintActuals(order || {});
|
paintActuals(order);
|
||||||
renderItems(order && order.items ? order.items : []);
|
const notes = (order.notes || "").trim();
|
||||||
|
document.getElementById("order-notes-view").textContent = notes ? "Notiz: " + notes : "";
|
||||||
|
renderItems(order.items || []);
|
||||||
|
openPanel();
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEdit(order) {
|
||||||
|
panelMode = "edit";
|
||||||
|
current = order;
|
||||||
|
setFormValues(order);
|
||||||
|
document.getElementById("order-title").textContent = "Bearbeiten";
|
||||||
|
document.getElementById("order-form").hidden = false;
|
||||||
|
document.getElementById("order-detail").hidden = true;
|
||||||
|
document.getElementById("order-edit-btn").hidden = true;
|
||||||
|
document.getElementById("order-cancel-edit").hidden = false;
|
||||||
|
document.getElementById("order-delete").hidden = false;
|
||||||
|
document.getElementById("item-edit-form").hidden = true;
|
||||||
|
openPanel();
|
||||||
|
renderList();
|
||||||
|
document.getElementById("order-title-in").focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderItems(items) {
|
function renderItems(items) {
|
||||||
@@ -178,7 +277,11 @@ function renderItems(items) {
|
|||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
tr.innerHTML = "<td></td><td class=\"num\"></td><td class=\"num\"></td><td class=\"actions\"></td>";
|
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 || "—"));
|
if (item.file_id) {
|
||||||
|
tr.children[0].innerHTML = linkTo("datei", item.file_id, item.file_name || ("#" + item.file_id));
|
||||||
|
} else {
|
||||||
|
tr.children[0].textContent = item.file_name || "—";
|
||||||
|
}
|
||||||
tr.children[1].textContent = item.qty;
|
tr.children[1].textContent = item.qty;
|
||||||
tr.children[2].textContent = item.done_ok + (item.done_bad ? " (−" + item.done_bad + ")" : "");
|
tr.children[2].textContent = item.done_ok + (item.done_bad ? " (−" + item.done_bad + ")" : "");
|
||||||
const tools = document.createElement("div");
|
const tools = document.createElement("div");
|
||||||
@@ -193,7 +296,7 @@ function renderItems(items) {
|
|||||||
edit.type = "button";
|
edit.type = "button";
|
||||||
edit.className = "ghost tiny-btn";
|
edit.className = "ghost tiny-btn";
|
||||||
edit.textContent = "Ändern";
|
edit.textContent = "Ändern";
|
||||||
edit.addEventListener("click", () => editItem(item));
|
edit.addEventListener("click", () => beginEditItem(item));
|
||||||
const del = document.createElement("button");
|
const del = document.createElement("button");
|
||||||
del.type = "button";
|
del.type = "button";
|
||||||
del.className = "btn-danger tiny-btn";
|
del.className = "btn-danger tiny-btn";
|
||||||
@@ -203,7 +306,7 @@ function renderItems(items) {
|
|||||||
await api("/auftraege/positionen/" + item.id, { method: "DELETE" });
|
await api("/auftraege/positionen/" + item.id, { method: "DELETE" });
|
||||||
toast("Position gelöscht.");
|
toast("Position gelöscht.");
|
||||||
const full = await api("/auftraege/" + current.id);
|
const full = await api("/auftraege/" + current.id);
|
||||||
fillOrder(full);
|
showView(full);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
tools.append(btn, edit, del);
|
tools.append(btn, edit, del);
|
||||||
@@ -212,22 +315,12 @@ function renderItems(items) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function editItem(item) {
|
function beginEditItem(item) {
|
||||||
const qty = prompt("Stückzahl", String(item.qty || 1));
|
document.getElementById("item-edit-id").value = item.id;
|
||||||
if (qty == null) return;
|
document.getElementById("item-edit-qty").value = item.qty || 1;
|
||||||
const price = prompt("Preis/Stk €", String(item.unit_price || 0));
|
document.getElementById("item-edit-price").value = item.unit_price || 0;
|
||||||
if (price == null) return;
|
document.getElementById("item-edit-form").hidden = false;
|
||||||
try {
|
document.getElementById("item-edit-qty").focus();
|
||||||
await api("/auftraege/positionen/" + item.id, {
|
|
||||||
method: "PATCH",
|
|
||||||
json: { qty: Number(qty) || 1, unit_price: Number(price) || 0 },
|
|
||||||
});
|
|
||||||
const full = await api("/auftraege/" + current.id);
|
|
||||||
fillOrder(full);
|
|
||||||
toast("Position gespeichert.");
|
|
||||||
} catch (error) {
|
|
||||||
showNote("order-note", error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function einreihen(itemId) {
|
async function einreihen(itemId) {
|
||||||
@@ -239,7 +332,7 @@ async function einreihen(itemId) {
|
|||||||
});
|
});
|
||||||
toast("In Warteschlange.");
|
toast("In Warteschlange.");
|
||||||
const full = await api("/auftraege/" + current.id);
|
const full = await api("/auftraege/" + current.id);
|
||||||
fillOrder(full);
|
showView(full);
|
||||||
await loadList();
|
await loadList();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showNote("order-note", error);
|
showNote("order-note", error);
|
||||||
@@ -249,38 +342,47 @@ async function einreihen(itemId) {
|
|||||||
function renderList() {
|
function renderList() {
|
||||||
const body = document.getElementById("order-rows");
|
const body = document.getElementById("order-rows");
|
||||||
body.innerHTML = "";
|
body.innerHTML = "";
|
||||||
|
const pager = document.getElementById("order-pager");
|
||||||
if (!orders.length) {
|
if (!orders.length) {
|
||||||
body.innerHTML = '<tr><td colspan="3" class="empty"><div class="empty-state"><p>Noch kein Auftrag.</p><p><button type="button" class="ghost" id="order-empty-new">Ersten Auftrag anlegen →</button></p></div></td></tr>';
|
body.innerHTML = '<tr><td colspan="3" class="empty"><div class="empty-state"><p>Noch kein Auftrag.</p><p><button type="button" class="btn-primary" id="order-empty-new">Ersten Auftrag anlegen</button></p></div></td></tr>';
|
||||||
document.getElementById("order-empty-new")?.addEventListener("click", () => fillOrder(null));
|
document.getElementById("order-empty-new")?.addEventListener("click", showCreate);
|
||||||
|
renderPager(pager, null, () => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const selected = document.getElementById("order-id").value;
|
const selected = current ? String(current.id) : "";
|
||||||
orders.forEach((order) => {
|
const paged = pageSlice(orders, orderPage, DEFAULT_PAGE_SIZE);
|
||||||
|
orderPage = paged.page;
|
||||||
|
paged.items.forEach((order) => {
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
tr.className = "pick" + (selected && String(order.id) === selected ? " on" : "");
|
tr.className = "pick" + (selected && String(order.id) === selected ? " on" : "");
|
||||||
tr.innerHTML = "<td></td><td></td><td></td>";
|
tr.innerHTML = "<td></td><td></td><td class=\"cell-path\"></td>";
|
||||||
tr.children[0].textContent = order.title;
|
tr.children[0].textContent = order.title;
|
||||||
const st = order.status || "";
|
const st = order.status || "";
|
||||||
const chipState = st === "druckt" ? "printing" : (st === "fertig" || st === "geliefert" || st === "bezahlt") ? "done" : st === "bestaetigt" ? "waiting" : "idle";
|
const chipState = st === "druckt" ? "printing" : (st === "fertig" || st === "geliefert" || st === "bezahlt") ? "done" : st === "bestaetigt" ? "waiting" : "idle";
|
||||||
tr.children[1].innerHTML = chipHtml(chipState, STATUS_LABEL[st] || st);
|
tr.children[1].innerHTML = chipHtml(chipState, STATUS_LABEL[st] || st);
|
||||||
tr.children[2].textContent = (order.progress && order.progress.label) || "";
|
tr.children[2].textContent = (order.progress && order.progress.label) || "";
|
||||||
|
tr.children[2].title = (order.progress && order.progress.label) || "";
|
||||||
tr.addEventListener("click", async () => {
|
tr.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
const full = await api("/auftraege/" + order.id);
|
const full = await api("/auftraege/" + order.id);
|
||||||
fillOrder(full);
|
showView(full);
|
||||||
renderList();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showNote("order-note", error);
|
showNote("order-note", error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
body.appendChild(tr);
|
body.appendChild(tr);
|
||||||
});
|
});
|
||||||
|
renderPager(pager, paged, (p) => {
|
||||||
|
orderPage = p;
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadList() {
|
async function loadList() {
|
||||||
const st = document.getElementById("filter-status").value;
|
const st = document.getElementById("filter-status").value;
|
||||||
const q = st ? "?status=" + encodeURIComponent(st) : "";
|
const q = st ? "?status=" + encodeURIComponent(st) : "";
|
||||||
orders = await api("/auftraege" + q);
|
orders = await api("/auftraege" + q);
|
||||||
|
orderPage = 1;
|
||||||
renderList();
|
renderList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,11 +394,28 @@ async function loadBase() {
|
|||||||
fillCustomers();
|
fillCustomers();
|
||||||
fillFiles();
|
fillFiles();
|
||||||
await loadList();
|
await loadList();
|
||||||
fillOrder(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("filter-status").addEventListener("change", () => loadList().catch((e) => showNote("order-note", e)));
|
document.getElementById("filter-status").addEventListener("change", () => loadList().catch((e) => showNote("order-note", e)));
|
||||||
|
|
||||||
|
document.getElementById("order-new-head").addEventListener("click", showCreate);
|
||||||
|
document.getElementById("order-close").addEventListener("click", closePanel);
|
||||||
|
document.getElementById("order-edit-btn").addEventListener("click", () => {
|
||||||
|
if (current) showEdit(current);
|
||||||
|
});
|
||||||
|
document.getElementById("order-cancel-edit").addEventListener("click", async () => {
|
||||||
|
if (!current) {
|
||||||
|
closePanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const full = await api("/auftraege/" + current.id);
|
||||||
|
showView(full);
|
||||||
|
} catch (error) {
|
||||||
|
showNote("order-note", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById("order-form").addEventListener("submit", async (event) => {
|
document.getElementById("order-form").addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const id = document.getElementById("order-id").value;
|
const id = document.getElementById("order-id").value;
|
||||||
@@ -312,22 +431,22 @@ document.getElementById("order-form").addEventListener("submit", async (event) =
|
|||||||
? await api("/auftraege/" + id, { method: "PATCH", json: payload })
|
? await api("/auftraege/" + id, { method: "PATCH", json: payload })
|
||||||
: await api("/auftraege", { method: "POST", json: payload });
|
: await api("/auftraege", { method: "POST", json: payload });
|
||||||
showNote("order-note", null);
|
showNote("order-note", null);
|
||||||
document.getElementById("order-note").textContent = "Gespeichert.";
|
toast("Gespeichert.", "good");
|
||||||
await loadList();
|
await loadList();
|
||||||
fillOrder(saved);
|
const full = await api("/auftraege/" + saved.id);
|
||||||
|
showView(full);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showNote("order-note", error);
|
showNote("order-note", error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("order-new").addEventListener("click", () => fillOrder(null));
|
|
||||||
document.getElementById("order-delete").addEventListener("click", (event) => {
|
document.getElementById("order-delete").addEventListener("click", (event) => {
|
||||||
const id = document.getElementById("order-id").value;
|
const id = document.getElementById("order-id").value;
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
armButton(event.currentTarget, "Auftrag wirklich löschen?", async () => {
|
armButton(event.currentTarget, "Auftrag wirklich löschen?", async () => {
|
||||||
await api("/auftraege/" + id, { method: "DELETE" });
|
await api("/auftraege/" + id, { method: "DELETE" });
|
||||||
toast("Gelöscht.");
|
toast("Gelöscht.");
|
||||||
fillOrder(null);
|
closePanel();
|
||||||
loadList();
|
loadList();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -347,12 +466,36 @@ document.getElementById("item-form").addEventListener("submit", async (event) =>
|
|||||||
try {
|
try {
|
||||||
await api("/auftraege/" + current.id + "/positionen", { method: "POST", json: payload });
|
await api("/auftraege/" + current.id + "/positionen", { method: "POST", json: payload });
|
||||||
const full = await api("/auftraege/" + current.id);
|
const full = await api("/auftraege/" + current.id);
|
||||||
fillOrder(full);
|
showView(full);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showNote("order-note", error);
|
showNote("order-note", error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById("item-edit-form").addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!current) return;
|
||||||
|
const itemId = document.getElementById("item-edit-id").value;
|
||||||
|
try {
|
||||||
|
await api("/auftraege/positionen/" + itemId, {
|
||||||
|
method: "PATCH",
|
||||||
|
json: {
|
||||||
|
qty: Number(document.getElementById("item-edit-qty").value) || 1,
|
||||||
|
unit_price: Number(document.getElementById("item-edit-price").value) || 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
document.getElementById("item-edit-form").hidden = true;
|
||||||
|
const full = await api("/auftraege/" + current.id);
|
||||||
|
showView(full);
|
||||||
|
toast("Position gespeichert.");
|
||||||
|
} catch (error) {
|
||||||
|
showNote("order-note", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById("item-edit-cancel").addEventListener("click", () => {
|
||||||
|
document.getElementById("item-edit-form").hidden = true;
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById("customer-form").addEventListener("submit", async (event) => {
|
document.getElementById("customer-form").addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const id = document.getElementById("cust-id").value;
|
const id = document.getElementById("cust-id").value;
|
||||||
@@ -409,6 +552,16 @@ document.getElementById("cust-delete").addEventListener("click", (event) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
loadBase().catch((error) => showNote("order-note", error));
|
loadBase().then(async () => {
|
||||||
|
const hash = (location.hash || "").replace(/^#/, "");
|
||||||
|
if (hash && /^\d+$/.test(hash)) {
|
||||||
|
try {
|
||||||
|
const full = await api("/auftraege/" + hash);
|
||||||
|
showView(full);
|
||||||
|
} catch (error) {
|
||||||
|
showNote("order-note", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch((error) => showNote("order-note", error));
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -110,9 +110,14 @@ function costBreakdownHtml(energy, filamentCost) {
|
|||||||
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";
|
||||||
document.getElementById("bericht-sub").textContent =
|
const subBits = [
|
||||||
(run.printer_name || run.printer_host) + " · " + name
|
run.printer_host
|
||||||
+ (run.chargen_nr ? " · " + run.chargen_nr : "");
|
? linkTo("drucker", run.printer_host, run.printer_name || run.printer_host)
|
||||||
|
: escapeHtml(run.printer_name || ""),
|
||||||
|
run.file_id ? linkTo("datei", run.file_id, name) : escapeHtml(name),
|
||||||
|
run.chargen_nr ? escapeHtml(run.chargen_nr) : "",
|
||||||
|
].filter(Boolean);
|
||||||
|
document.getElementById("bericht-sub").innerHTML = subBits.join(" · ");
|
||||||
|
|
||||||
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;
|
||||||
@@ -128,7 +133,7 @@ function render(data) {
|
|||||||
|
|
||||||
const spoolRows = (data.spools || []).map((s) => `
|
const spoolRows = (data.spools || []).map((s) => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${escapeHtml(s.name)}${s.ace_slot ? " · ACE " + s.ace_slot : ""}</td>
|
<td>${s.spool_id ? linkTo("spule", s.spool_id, s.name) : escapeHtml(s.name)}${s.ace_slot ? " · ACE " + s.ace_slot : ""}</td>
|
||||||
<td>${escapeHtml(s.material_type || "")}</td>
|
<td>${escapeHtml(s.material_type || "")}</td>
|
||||||
<td class="num">${grams(s.weight_planned_g)}</td>
|
<td class="num">${grams(s.weight_planned_g)}</td>
|
||||||
<td class="num">${grams(s.weight_g)}${s.is_estimate ? ' <span class="tag">Schätzwert</span>' : ""}</td>
|
<td class="num">${grams(s.weight_g)}${s.is_estimate ? ' <span class="tag">Schätzwert</span>' : ""}</td>
|
||||||
@@ -161,7 +166,8 @@ function render(data) {
|
|||||||
<p>Kosten <b>${euro(cost)}</b> (Filament ${euro(run.filament_cost)} · Strom ${euro(energyEuro)}${energyHint ? " · " + energyHint : ""})${run.cost_per_part != null ? " · <b>" + euro(run.cost_per_part) + "</b>/Teil" : ""}</p>
|
<p>Kosten <b>${euro(cost)}</b> (Filament ${euro(run.filament_cost)} · Strom ${euro(energyEuro)}${energyHint ? " · " + energyHint : ""})${run.cost_per_part != null ? " · <b>" + euro(run.cost_per_part) + "</b>/Teil" : ""}</p>
|
||||||
${run.pieces_ok || run.pieces_bad ? `<p>Stück <b>${run.pieces_ok || 0}</b> gut${run.pieces_bad ? " · " + run.pieces_bad + " schlecht" : ""}</p>` : ""}
|
${run.pieces_ok || run.pieces_bad ? `<p>Stück <b>${run.pieces_ok || 0}</b> gut${run.pieces_bad ? " · " + run.pieces_bad + " schlecht" : ""}</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: " + linkTo("projekt", data.project.id, data.project.name) + "</p>" : ""}
|
||||||
|
${run.order_id ? "<p class=\"tiny\">Auftrag: " + linkTo("auftrag", run.order_id, "#" + run.order_id) + "</p>" : ""}
|
||||||
</div>
|
</div>
|
||||||
${costBreakdownHtml(energy, run.filament_cost)}
|
${costBreakdownHtml(energy, run.filament_cost)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
<div class="skeleton" style="height:4rem"></div>
|
<div class="skeleton" style="height:4rem"></div>
|
||||||
<div class="skeleton" style="height:4rem"></div>
|
<div class="skeleton" style="height:4rem"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pager" id="lib-pager" hidden></div>
|
||||||
<p class="drop-hint">Dateien hierher ziehen zum Hochladen.</p>
|
<p class="drop-hint">Dateien hierher ziehen zum Hochladen.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -603,6 +604,20 @@ function folderLabel(folder) {
|
|||||||
return "Wurzel";
|
return "Wurzel";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function folderMatches(fileFolder, selected) {
|
||||||
|
if (!selected) return true;
|
||||||
|
if (selected === "root") return !fileFolder;
|
||||||
|
if (!fileFolder) return false;
|
||||||
|
return fileFolder === selected || fileFolder.startsWith(selected + "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseRootForProject() {
|
||||||
|
const project = document.getElementById("file-filter-project").value;
|
||||||
|
const folder = document.getElementById("file-filter-folder");
|
||||||
|
if (!folder || !project || project === "none" || folder.value !== "root") return;
|
||||||
|
folder.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
function saveLibMemory() {
|
function saveLibMemory() {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(LIB_KEY, JSON.stringify({
|
localStorage.setItem(LIB_KEY, JSON.stringify({
|
||||||
@@ -649,8 +664,7 @@ function filteredFiles() {
|
|||||||
const types = ((file.filaments) || []).map((f) => f.type).filter(Boolean);
|
const types = ((file.filaments) || []).map((f) => f.type).filter(Boolean);
|
||||||
if (!types.includes(material)) return false;
|
if (!types.includes(material)) return false;
|
||||||
}
|
}
|
||||||
if (folder === "root" && folderKey(file)) return false;
|
if (!folderMatches(folderKey(file), folder)) return false;
|
||||||
if (folder && folder !== "root" && folderKey(file) !== folder) return false;
|
|
||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
const hay = [
|
const hay = [
|
||||||
file.name || "",
|
file.name || "",
|
||||||
@@ -808,10 +822,13 @@ function alwaysMeta(file, project) {
|
|||||||
file.weight_g ? grams(file.weight_g) : "",
|
file.weight_g ? grams(file.weight_g) : "",
|
||||||
layerHeightText(file),
|
layerHeightText(file),
|
||||||
families[file.printer_family] || "",
|
families[file.printer_family] || "",
|
||||||
project ? project.name : "",
|
|
||||||
];
|
];
|
||||||
if (!file.weight_g && !file.estimated_s) bits.push(sizeText(file.size_bytes));
|
if (!file.weight_g && !file.estimated_s) bits.push(sizeText(file.size_bytes));
|
||||||
return bits.filter(Boolean).map((t) => `<span>${escapeHtml(t)}</span>`).join("") + swatchesHtml(file);
|
const spans = bits.filter(Boolean).map((t) => `<span>${escapeHtml(t)}</span>`);
|
||||||
|
if (project) {
|
||||||
|
spans.push(`<a class="lib-proj" href="/projekte#${encodeURIComponent(project.id)}" onclick="event.stopPropagation()">${escapeHtml(project.name)}</a>`);
|
||||||
|
}
|
||||||
|
return spans.join("") + swatchesHtml(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
function detailMeta(file) {
|
function detailMeta(file) {
|
||||||
@@ -839,6 +856,39 @@ function infoPopupHtml(file) {
|
|||||||
</details>`;
|
</details>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function placeLibInfoPop(details) {
|
||||||
|
const pop = details.querySelector(".lib-info-pop");
|
||||||
|
const anchor = details.querySelector("summary");
|
||||||
|
if (!pop || !anchor) return;
|
||||||
|
const margin = 6;
|
||||||
|
const r = anchor.getBoundingClientRect();
|
||||||
|
pop.style.left = "0px";
|
||||||
|
pop.style.top = "0px";
|
||||||
|
const pw = pop.offsetWidth;
|
||||||
|
const ph = pop.offsetHeight;
|
||||||
|
let left = r.right - pw;
|
||||||
|
left = Math.max(margin, Math.min(left, window.innerWidth - pw - margin));
|
||||||
|
let top = r.bottom + margin;
|
||||||
|
if (top + ph > window.innerHeight - margin && r.top - margin - ph >= margin) {
|
||||||
|
top = r.top - margin - ph;
|
||||||
|
} else {
|
||||||
|
top = Math.min(top, Math.max(margin, window.innerHeight - ph - margin));
|
||||||
|
}
|
||||||
|
pop.style.left = left + "px";
|
||||||
|
pop.style.top = top + "px";
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeOpenLibInfo(except) {
|
||||||
|
document.querySelectorAll("details.lib-info[open]").forEach((node) => {
|
||||||
|
if (node !== except) node.removeAttribute("open");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function repositionOpenLibInfo() {
|
||||||
|
const open = document.querySelector("details.lib-info[open]");
|
||||||
|
if (open) placeLibInfoPop(open);
|
||||||
|
}
|
||||||
|
|
||||||
function thumbHtml(file) {
|
function thumbHtml(file) {
|
||||||
return file.preview
|
return file.preview
|
||||||
? `<img alt="" loading="lazy" src="${escapeHtml(withToken(file.preview))}" onerror="this.style.visibility='hidden'">`
|
? `<img alt="" loading="lazy" src="${escapeHtml(withToken(file.preview))}" onerror="this.style.visibility='hidden'">`
|
||||||
@@ -856,6 +906,8 @@ function libItemHtml(file) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let libPage = 1;
|
||||||
|
|
||||||
function renderLibrary() {
|
function renderLibrary() {
|
||||||
const list = document.getElementById("lib-list");
|
const list = document.getElementById("lib-list");
|
||||||
const shown = filteredFiles();
|
const shown = filteredFiles();
|
||||||
@@ -863,15 +915,19 @@ function renderLibrary() {
|
|||||||
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>';
|
||||||
|
renderPager(document.getElementById("lib-pager"), null, () => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!shown.length) {
|
if (!shown.length) {
|
||||||
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>';
|
||||||
|
renderPager(document.getElementById("lib-pager"), null, () => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const paged = pageSlice(shown, libPage, DEFAULT_PAGE_SIZE);
|
||||||
|
libPage = paged.page;
|
||||||
const parts = [];
|
const parts = [];
|
||||||
let lastGroup = null;
|
let lastGroup = null;
|
||||||
shown.forEach((file) => {
|
paged.items.forEach((file) => {
|
||||||
let group = null;
|
let group = null;
|
||||||
if (sort === "material") group = materialKey(file) || "Ohne Material";
|
if (sort === "material") group = materialKey(file) || "Ohne Material";
|
||||||
else if (sort === "folder") group = folderLabel(folderKey(file));
|
else if (sort === "folder") group = folderLabel(folderKey(file));
|
||||||
@@ -882,6 +938,10 @@ function renderLibrary() {
|
|||||||
parts.push(libItemHtml(file));
|
parts.push(libItemHtml(file));
|
||||||
});
|
});
|
||||||
list.innerHTML = parts.join("");
|
list.innerHTML = parts.join("");
|
||||||
|
renderPager(document.getElementById("lib-pager"), paged, (p) => {
|
||||||
|
libPage = p;
|
||||||
|
renderLibrary();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Flow rechts ---------- */
|
/* ---------- Flow rechts ---------- */
|
||||||
@@ -1110,6 +1170,12 @@ async function load() {
|
|||||||
fillProjectFilter();
|
fillProjectFilter();
|
||||||
fillMaterialFilter();
|
fillMaterialFilter();
|
||||||
fillFolderFilter();
|
fillFolderFilter();
|
||||||
|
const deepProject = new URLSearchParams(location.search).get("project");
|
||||||
|
if (deepProject) {
|
||||||
|
const pf = document.getElementById("file-filter-project");
|
||||||
|
if (pf && [...pf.options].some((o) => o.value === deepProject)) pf.value = deepProject;
|
||||||
|
}
|
||||||
|
releaseRootForProject();
|
||||||
renderLibrary();
|
renderLibrary();
|
||||||
renderTabs();
|
renderTabs();
|
||||||
refreshFlow();
|
refreshFlow();
|
||||||
@@ -1546,6 +1612,23 @@ async function doAblauf() {
|
|||||||
|
|
||||||
/* ---------- Ereignisse ---------- */
|
/* ---------- Ereignisse ---------- */
|
||||||
|
|
||||||
|
document.getElementById("lib-list").addEventListener("toggle", (event) => {
|
||||||
|
const details = event.target;
|
||||||
|
if (!(details instanceof HTMLDetailsElement) || !details.classList.contains("lib-info")) return;
|
||||||
|
if (details.open) {
|
||||||
|
closeOpenLibInfo(details);
|
||||||
|
placeLibInfoPop(details);
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
document.getElementById("lib-list").addEventListener("scroll", repositionOpenLibInfo, { passive: true });
|
||||||
|
window.addEventListener("resize", repositionOpenLibInfo);
|
||||||
|
|
||||||
|
document.addEventListener("pointerdown", (event) => {
|
||||||
|
if (event.target.closest("details.lib-info")) return;
|
||||||
|
closeOpenLibInfo();
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById("lib-list").addEventListener("click", (event) => {
|
document.getElementById("lib-list").addEventListener("click", (event) => {
|
||||||
if (event.target.closest(".lib-move")) {
|
if (event.target.closest(".lib-move")) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -1628,6 +1711,8 @@ document.getElementById("lib-list").addEventListener("keydown", (event) => {
|
|||||||
const node = document.getElementById(id);
|
const node = document.getElementById(id);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
const onChange = () => {
|
const onChange = () => {
|
||||||
|
if (id === "file-filter-project") releaseRootForProject();
|
||||||
|
libPage = 1;
|
||||||
saveLibMemory();
|
saveLibMemory();
|
||||||
renderLibrary();
|
renderLibrary();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
<dt>Auftrag</dt><dd>Ein Eintrag in der Warteschlange: eine Datei für einen Drucker oder für den nächsten freien.</dd>
|
<dt>Auftrag</dt><dd>Ein Eintrag in der Warteschlange: eine Datei für einen Drucker oder für den nächsten freien.</dd>
|
||||||
<dt>Ablauf</dt><dd>Auftrag mit mehreren Schritten und Wiederholungen. Nur Kobra 3.</dd>
|
<dt>Ablauf</dt><dd>Auftrag mit mehreren Schritten und Wiederholungen. Nur Kobra 3.</dd>
|
||||||
<dt>Dieser Drucker</dt><dd>Frei und Platte klar → sofort; sonst Warteschlange für genau diesen Drucker.</dd>
|
<dt>Dieser Drucker</dt><dd>Frei und Platte klar → sofort; sonst Warteschlange für genau diesen Drucker.</dd>
|
||||||
<dt>Nächster passender</dt><dd>Grotti claimt den nächsten freien Drucker mit passender Katalog-Sorte und Restfilament.</dd>
|
<dt>Nächster passender</dt><dd>Grotti claimt den nächsten freien Drucker mit passender Katalog-Sorte. Zu wenig Rest warnt nur und hält den Start nicht auf.</dd>
|
||||||
<dt>Platte belegt</dt><dd>Nach jedem Druck gesetzt. Blockiert den nächsten Auftrag auf diesem Drucker.</dd>
|
<dt>Platte belegt</dt><dd>Nach jedem Druck gesetzt. Blockiert den nächsten Auftrag auf diesem Drucker.</dd>
|
||||||
<dt>Alert</dt><dd>Wichtiges Ereignis (Fertig, Fehler, Abbruch, offline). Steht in der Seitenleiste und geht auf Wunsch an Telegram.</dd>
|
<dt>Alert</dt><dd>Wichtiges Ereignis (Fertig, Fehler, Abbruch, offline). Steht in der Seitenleiste und geht auf Wunsch an Telegram.</dd>
|
||||||
<dt>Ereignis</dt><dd>Jeder Eintrag unter Aktivität. Ablauf-Schritte und Starts sind Ereignisse, aber keine Alerts.</dd>
|
<dt>Ereignis</dt><dd>Jeder Eintrag unter Aktivität. Ablauf-Schritte und Starts sind Ereignisse, aber keine Alerts.</dd>
|
||||||
|
|||||||
@@ -257,6 +257,10 @@
|
|||||||
</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>
|
||||||
|
<div id="token-qr-wrap" hidden style="margin-top:0.75rem">
|
||||||
|
<p class="tiny">App-Kopplung — QR mit Server-URL und Token (nur jetzt):</p>
|
||||||
|
<canvas id="token-qr" width="180" height="180" aria-label="QR-Code"></canvas>
|
||||||
|
</div>
|
||||||
<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>
|
<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>
|
||||||
|
|
||||||
@@ -293,6 +297,7 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block script %}
|
{% block script %}
|
||||||
|
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
|
||||||
<script>
|
<script>
|
||||||
const families = { kobra_3: "Kobra 3", kobra_s1: "Kobra S1", "": "—" };
|
const families = { kobra_3: "Kobra 3", kobra_s1: "Kobra S1", "": "—" };
|
||||||
const isAdmin = document.querySelector('meta[name="user-role"]')?.content === "admin";
|
const isAdmin = document.querySelector('meta[name="user-role"]')?.content === "admin";
|
||||||
@@ -543,7 +548,9 @@ document.getElementById("user-add")?.addEventListener("submit", async (event) =>
|
|||||||
document.getElementById("token-add").addEventListener("submit", async (event) => {
|
document.getElementById("token-add").addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const once = document.getElementById("token-once");
|
const once = document.getElementById("token-once");
|
||||||
|
const qrWrap = document.getElementById("token-qr-wrap");
|
||||||
once.hidden = true;
|
once.hidden = true;
|
||||||
|
if (qrWrap) qrWrap.hidden = true;
|
||||||
try {
|
try {
|
||||||
const out = await api("/tokens", {
|
const out = await api("/tokens", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -555,8 +562,20 @@ document.getElementById("token-add").addEventListener("submit", async (event) =>
|
|||||||
document.getElementById("token-add").reset();
|
document.getElementById("token-add").reset();
|
||||||
once.hidden = false;
|
once.hidden = false;
|
||||||
once.textContent = "Token (nur jetzt sichtbar): " + out.token;
|
once.textContent = "Token (nur jetzt sichtbar): " + out.token;
|
||||||
showNote("token-note", null, "Token erzeugt — jetzt kopieren.");
|
showNote("token-note", null, "Token erzeugt — jetzt kopieren oder QR scannen.");
|
||||||
loadTokens();
|
loadTokens();
|
||||||
|
if (out.token && window.QRCode && qrWrap) {
|
||||||
|
const canvas = document.getElementById("token-qr");
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
url: location.origin,
|
||||||
|
token: out.token,
|
||||||
|
name: "Grotti",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
QRCode.toCanvas(canvas, payload, { width: 180, margin: 1, errorCorrectionLevel: "M" });
|
||||||
|
qrWrap.hidden = false;
|
||||||
|
} catch (error) { /* QR optional */ }
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showNote("token-note", error);
|
showNote("token-note", error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
{% extends "layout.html" %}
|
|
||||||
{% block body %}
|
|
||||||
<div class="page-head">
|
|
||||||
<div>
|
|
||||||
<h1>Kameras</h1>
|
|
||||||
<p class="tiny">Livebilder aller Drucker.</p>
|
|
||||||
</div>
|
|
||||||
<div class="page-head-side">
|
|
||||||
<label class="field cam-mode-field">
|
|
||||||
<span class="tiny">Bild</span>
|
|
||||||
<select id="cam-mode" title="Kamera-Modus">
|
|
||||||
<option value="snapshot">Snapshot</option>
|
|
||||||
<option value="stream">Stream</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<span id="when"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="camera-grid" id="grid"></div>
|
|
||||||
{% endblock %}
|
|
||||||
{% block script %}
|
|
||||||
<script>
|
|
||||||
const tiles = new Map();
|
|
||||||
|
|
||||||
(function initCamMode() {
|
|
||||||
const sel = document.getElementById("cam-mode");
|
|
||||||
if (!sel || typeof cameraMode !== "function") return;
|
|
||||||
sel.value = cameraMode();
|
|
||||||
sel.addEventListener("change", () => {
|
|
||||||
setCameraMode(sel.value);
|
|
||||||
for (const tile of tiles.values()) {
|
|
||||||
const img = tile.querySelector('[data-role="cam"]');
|
|
||||||
if (img) img.dataset.srcKey = "";
|
|
||||||
}
|
|
||||||
refresh().catch(() => {});
|
|
||||||
});
|
|
||||||
window.addEventListener("grotti-camera-mode", () => {
|
|
||||||
sel.value = cameraMode();
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
function metaLine(printer) {
|
|
||||||
const state = shownState(printer);
|
|
||||||
const busy = state === "printing" || state === "paused";
|
|
||||||
const bits = [chipHtml(state, STATE_LABELS[state] || state)];
|
|
||||||
if (busy) {
|
|
||||||
const pct = Math.round((printer.progress || 0) * 100);
|
|
||||||
bits.push(pct + " %");
|
|
||||||
const eta = finishClock(printer.remain);
|
|
||||||
if (eta) bits.push("fertig " + eta);
|
|
||||||
else if (hours(printer.remain)) bits.push("Rest " + hours(printer.remain));
|
|
||||||
}
|
|
||||||
const job = (printer.filename || "").replace(/\.gcode$/i, "").replace(/^(\d+-)+/, "");
|
|
||||||
if (busy && job) bits.push(escapeHtml(job));
|
|
||||||
return bits.join(" · ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureTile(printer) {
|
|
||||||
let tile = tiles.get(printer.host);
|
|
||||||
if (tile) return tile;
|
|
||||||
tile = document.createElement("figure");
|
|
||||||
tile.className = "camera-tile";
|
|
||||||
tile.dataset.host = printer.host;
|
|
||||||
tile.innerHTML = `
|
|
||||||
<figcaption>
|
|
||||||
<span class="cam-name"></span>
|
|
||||||
<span class="host"></span>
|
|
||||||
</figcaption>
|
|
||||||
<p class="cam-meta tiny"></p>
|
|
||||||
<div class="cam-stage">
|
|
||||||
<img alt="" data-role="cam" hidden>
|
|
||||||
<p class="cam-empty" data-role="cam-empty">${icon("camera")}<span>Keine Kamera</span></p>
|
|
||||||
</div>
|
|
||||||
<a class="ghost cam-open" href="/#${encodeURIComponent(printer.host)}">Cockpit</a>`;
|
|
||||||
tiles.set(printer.host, tile);
|
|
||||||
document.getElementById("grid").appendChild(tile);
|
|
||||||
return tile;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
const printers = await api("/drucker?live=1");
|
|
||||||
document.getElementById("when").textContent = new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
|
||||||
const grid = document.getElementById("grid");
|
|
||||||
if (!printers.length) {
|
|
||||||
tiles.clear();
|
|
||||||
grid.innerHTML = `<div class="empty-state">${icon("camera", "lg")}<p>Noch keine Drucker.</p><a href="/einstellungen">Zu den Einstellungen</a></div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (grid.querySelector(".empty-state")) grid.innerHTML = "";
|
|
||||||
const streaming = cameraMode() === "stream";
|
|
||||||
const seen = new Set();
|
|
||||||
for (const printer of printers) {
|
|
||||||
seen.add(printer.host);
|
|
||||||
const tile = ensureTile(printer);
|
|
||||||
const state = shownState(printer);
|
|
||||||
tile.className = "camera-tile " + state + (streaming ? " stream" : "");
|
|
||||||
tile.querySelector(".cam-name").textContent = printer.name;
|
|
||||||
tile.querySelector(".host").textContent = printer.host;
|
|
||||||
tile.querySelector(".cam-meta").innerHTML = metaLine(printer);
|
|
||||||
tile.querySelector(".cam-open").href = "/#" + encodeURIComponent(printer.host);
|
|
||||||
const img = tile.querySelector('[data-role="cam"]');
|
|
||||||
const empty = tile.querySelector('[data-role="cam-empty"]');
|
|
||||||
if (!printer.camera || printer.has_camera === false) {
|
|
||||||
img.removeAttribute("src");
|
|
||||||
img.hidden = true;
|
|
||||||
empty.hidden = false;
|
|
||||||
empty.innerHTML = icon("camera") + "<span>Keine Kamera</span>";
|
|
||||||
img.dataset.srcKey = "";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
img.alt = "Kamera " + printer.name;
|
|
||||||
const next = cameraUrl(printer, streaming ? 0 : 2000);
|
|
||||||
if (img.dataset.srcKey === next) continue;
|
|
||||||
img.dataset.srcKey = next;
|
|
||||||
img.onerror = () => {
|
|
||||||
img.hidden = true;
|
|
||||||
empty.hidden = false;
|
|
||||||
empty.innerHTML = icon("camera") + "<span>Keine Kamera</span>";
|
|
||||||
};
|
|
||||||
img.onload = () => {
|
|
||||||
img.hidden = false;
|
|
||||||
empty.hidden = true;
|
|
||||||
};
|
|
||||||
img.src = next;
|
|
||||||
}
|
|
||||||
for (const [host, tile] of [...tiles.entries()]) {
|
|
||||||
if (!seen.has(host)) {
|
|
||||||
tile.remove();
|
|
||||||
tiles.delete(host);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
refresh().catch((error) => console.error(error));
|
|
||||||
setInterval(() => refresh().catch(() => {}), 5000);
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
+24
-10
@@ -9,7 +9,7 @@
|
|||||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
|
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
|
||||||
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}">
|
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}">
|
||||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}">
|
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=68">
|
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=75">
|
||||||
<link rel="manifest" href="{{ url_for('static', filename='manifest.webmanifest') }}">
|
<link rel="manifest" href="{{ url_for('static', filename='manifest.webmanifest') }}">
|
||||||
<meta name="theme-color" content="#0a0a0c" id="theme-color-meta">
|
<meta name="theme-color" content="#0a0a0c" id="theme-color-meta">
|
||||||
<script>
|
<script>
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
<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-file"/></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="/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="/auftraege" class="{{ 'on' if page == 'auftraege' else '' }} nav-desk" title="Aufträge"><svg class="ico"><use href="#i-clipboard"/></svg><span>Aufträge</span></a>
|
||||||
<span class="nav-label nav-desk">Rückschau</span>
|
<span class="nav-label nav-desk">Rückschau</span>
|
||||||
<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-chart"/></svg><span>Statistik</span></a>
|
<a href="/statistik" class="{{ 'on' if page == 'statistik' else '' }} nav-desk" title="Statistik"><svg class="ico"><use href="#i-chart"/></svg><span>Statistik</span></a>
|
||||||
@@ -138,11 +138,14 @@
|
|||||||
</nav>
|
</nav>
|
||||||
<dialog id="mobile-more-sheet" class="mobile-more-sheet">
|
<dialog id="mobile-more-sheet" class="mobile-more-sheet">
|
||||||
<nav aria-label="Weitere Seiten">
|
<nav aria-label="Weitere Seiten">
|
||||||
|
<p class="nav-label">Live</p>
|
||||||
<a href="/auftraege">Aufträge</a>
|
<a href="/auftraege">Aufträge</a>
|
||||||
<a href="/projekte">Projekte</a>
|
<p class="nav-label">Rückschau</p>
|
||||||
<a href="/protokoll">Protokoll</a>
|
<a href="/protokoll">Protokoll</a>
|
||||||
<a href="/statistik">Statistik</a>
|
<a href="/statistik">Statistik</a>
|
||||||
<a href="/aktivitaet">Aktivität</a>
|
<a href="/aktivitaet">Aktivität</a>
|
||||||
|
<p class="nav-label">Verwalten</p>
|
||||||
|
<a href="/projekte">Projekte</a>
|
||||||
<a href="/material">Material</a>
|
<a href="/material">Material</a>
|
||||||
<a href="/wartung">Wartung</a>
|
<a href="/wartung">Wartung</a>
|
||||||
<a href="/einstellungen">Einstellungen</a>
|
<a href="/einstellungen">Einstellungen</a>
|
||||||
@@ -160,8 +163,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="dialog"><button class="ghost" value="cancel">Schließen</button></form>
|
<form method="dialog"><button class="ghost" value="cancel">Schließen</button></form>
|
||||||
</dialog>
|
</dialog>
|
||||||
<script src="{{ url_for('static', filename='api.js') }}?v=32"></script>
|
<script src="{{ url_for('static', filename='api.js') }}?v=33"></script>
|
||||||
<script src="{{ url_for('static', filename='ui.js') }}?v=5"></script>
|
<script src="{{ url_for('static', filename='ui.js') }}?v=6"></script>
|
||||||
<script>
|
<script>
|
||||||
async function doLogout() {
|
async function doLogout() {
|
||||||
try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {}
|
try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {}
|
||||||
@@ -315,9 +318,9 @@ function runLineHtml({ name, progress, remain, filename, tipExtra, cancelId, hos
|
|||||||
const pct = Math.max(0, Math.min(100, Math.round((Number(progress) || 0) * 100)));
|
const pct = Math.max(0, Math.min(100, Math.round((Number(progress) || 0) * 100)));
|
||||||
const finish = remain > 0 ? finishClock(remain) : "";
|
const finish = remain > 0 ? finishClock(remain) : "";
|
||||||
const bits = [name];
|
const bits = [name];
|
||||||
if (pct > 0 || remain > 0) bits.push(pct + " %");
|
|
||||||
if (finish) bits.push(finish);
|
|
||||||
if (tipExtra) bits.push(tipExtra);
|
if (tipExtra) bits.push(tipExtra);
|
||||||
|
else if (pct > 0 || remain > 0) bits.push(pct + " %");
|
||||||
|
if (finish) bits.push(finish);
|
||||||
const tip = [
|
const tip = [
|
||||||
filename ? jobLabel(filename) : "",
|
filename ? jobLabel(filename) : "",
|
||||||
remain > 0 ? "noch " + hours(remain) : "",
|
remain > 0 ? "noch " + hours(remain) : "",
|
||||||
@@ -375,9 +378,10 @@ function renderLiveChips({ actionItems, running, activeSends, alertCount, etaTex
|
|||||||
const pct = Math.max(0, Math.min(100, Math.round((Number(p.progress) || 0) * 100)));
|
const pct = Math.max(0, Math.min(100, Math.round((Number(p.progress) || 0) * 100)));
|
||||||
const finish = p.remain > 0 ? finishClock(p.remain) : "";
|
const finish = p.remain > 0 ? finishClock(p.remain) : "";
|
||||||
const pausedFil = p.state === "paused" && (p.filament_empty || p.pause_reason);
|
const pausedFil = p.state === "paused" && (p.filament_empty || p.pause_reason);
|
||||||
|
const starting = p.starting || p.state === "starting";
|
||||||
const label = [
|
const label = [
|
||||||
shortPrinter(p.name || p.host),
|
shortPrinter(p.name || p.host),
|
||||||
pausedFil ? "Material!" : (pct ? pct + "%" : ""),
|
pausedFil ? "Material!" : (starting ? "startet" : (pct ? pct + "%" : "")),
|
||||||
finish,
|
finish,
|
||||||
].filter(Boolean).join(" · ");
|
].filter(Boolean).join(" · ");
|
||||||
const tip = [p.name || "", p.pause_reason || ""].filter(Boolean).join(" — ");
|
const tip = [p.name || "", p.pause_reason || ""].filter(Boolean).join(" — ");
|
||||||
@@ -465,7 +469,12 @@ async function refreshJetzt() {
|
|||||||
}
|
}
|
||||||
if (jetztBox) {
|
if (jetztBox) {
|
||||||
jetztBox.classList.toggle("is-empty", !actionItems.length);
|
jetztBox.classList.toggle("is-empty", !actionItems.length);
|
||||||
setListHtml(document.getElementById("jetzt-list"), actionItems.map(actionItemHtml).join(""));
|
const jetztList = document.getElementById("jetzt-list");
|
||||||
|
if (!actionItems.length) {
|
||||||
|
setListHtml(jetztList, '<li class="queue-empty">Alles erledigt — nichts zu tun.</li>');
|
||||||
|
} else {
|
||||||
|
setListHtml(jetztList, actionItems.map(actionItemHtml).join(""));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const runBox = document.getElementById("run-box");
|
const runBox = document.getElementById("run-box");
|
||||||
@@ -479,6 +488,7 @@ async function refreshJetzt() {
|
|||||||
progress: p.progress,
|
progress: p.progress,
|
||||||
remain: p.remain,
|
remain: p.remain,
|
||||||
filename: p.filename,
|
filename: p.filename,
|
||||||
|
tipExtra: (p.starting || p.state === "starting") ? "wird gestartet" : "",
|
||||||
cancelId: p.queue_id,
|
cancelId: p.queue_id,
|
||||||
})).join(""),
|
})).join(""),
|
||||||
);
|
);
|
||||||
@@ -605,13 +615,17 @@ refreshJetzt();
|
|||||||
(function liveUpdates() {
|
(function liveUpdates() {
|
||||||
let streamLive = false;
|
let streamLive = false;
|
||||||
const sseKinds = {
|
const sseKinds = {
|
||||||
queue: () => refreshJetzt(),
|
queue: () => {
|
||||||
|
refreshJetzt();
|
||||||
|
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
|
||||||
|
},
|
||||||
senden: () => refreshJetzt(),
|
senden: () => refreshJetzt(),
|
||||||
ereignis: () => refreshJetzt(),
|
ereignis: () => refreshJetzt(),
|
||||||
aufgaben: () => refreshJetzt(),
|
aufgaben: () => refreshJetzt(),
|
||||||
drucker: () => {
|
drucker: () => {
|
||||||
refreshJetzt();
|
refreshJetzt();
|
||||||
if (typeof window.onDruckerLive === "function") window.onDruckerLive();
|
if (typeof window.onDruckerLive === "function") window.onDruckerLive();
|
||||||
|
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<meta name="csrf" content="{{ csrf }}">
|
<meta name="csrf" content="{{ csrf }}">
|
||||||
<title>Anmelden · Grotti</title>
|
<title>Anmelden · Grotti</title>
|
||||||
<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="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=62">
|
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=75">
|
||||||
</head>
|
</head>
|
||||||
<body class="auth-body">
|
<body class="auth-body">
|
||||||
<main class="auth-card">
|
<main class="auth-card">
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
<thead><tr><th>Spule</th><th>Charge</th><th>Sorte</th><th>Farbe</th><th>Ort</th><th class="num">Rest</th></tr></thead>
|
<thead><tr><th>Spule</th><th>Charge</th><th>Sorte</th><th>Farbe</th><th>Ort</th><th class="num">Rest</th></tr></thead>
|
||||||
<tbody id="spool-rows"></tbody>
|
<tbody id="spool-rows"></tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<div class="pager" id="spool-pager" hidden></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card mat-form" id="spool-panel" hidden>
|
<section class="card mat-form" id="spool-panel" hidden>
|
||||||
@@ -523,8 +524,13 @@ async function loadAkte(spoolId) {
|
|||||||
const charge = run.chargen_nr
|
const charge = run.chargen_nr
|
||||||
? '<a class="tag akte-charge" href="' + href + '">' + escapeHtml(run.chargen_nr) + "</a>"
|
? '<a class="tag akte-charge" href="' + href + '">' + escapeHtml(run.chargen_nr) + "</a>"
|
||||||
: '<span class="akte-charge tiny">—</span>';
|
: '<span class="akte-charge tiny">—</span>';
|
||||||
const file = '<a class="akte-file" href="' + href + '" title="' + escapeHtml((run.filename || "").split("/").pop() || "") + '">'
|
const fileName = akteFile(run.filename);
|
||||||
+ escapeHtml(akteFile(run.filename)) + "</a>";
|
const file = run.file_id
|
||||||
|
? '<a class="akte-file" href="/dateien?file=' + encodeURIComponent(run.file_id) + '" title="' + escapeHtml((run.filename || "").split("/").pop() || "") + '">'
|
||||||
|
+ escapeHtml(fileName) + "</a>"
|
||||||
|
+ ' <a class="tiny" href="' + href + '">Lauf</a>'
|
||||||
|
: '<a class="akte-file" href="' + href + '" title="' + escapeHtml((run.filename || "").split("/").pop() || "") + '">'
|
||||||
|
+ escapeHtml(fileName) + "</a>";
|
||||||
const gramsTxt = grams(run.spool_weight_g) + (run.spool_is_estimate ? " ≈" : "");
|
const gramsTxt = grams(run.spool_weight_g) + (run.spool_is_estimate ? " ≈" : "");
|
||||||
return '<div class="akte-run">'
|
return '<div class="akte-run">'
|
||||||
+ '<span class="akte-when tiny">' + escapeHtml(akteWhen(run.started_at)) + "</span>"
|
+ '<span class="akte-when tiny">' + escapeHtml(akteWhen(run.started_at)) + "</span>"
|
||||||
@@ -683,6 +689,8 @@ function fillSelects() {
|
|||||||
materialSelect.value = currentMaterial;
|
materialSelect.value = currentMaterial;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let spoolPage = 1;
|
||||||
|
|
||||||
function renderSpools() {
|
function renderSpools() {
|
||||||
document.querySelectorAll("#spool-filter .fchip").forEach((btn) => {
|
document.querySelectorAll("#spool-filter .fchip").forEach((btn) => {
|
||||||
btn.classList.toggle("on", (btn.dataset.filter || "all") === spoolFilter);
|
btn.classList.toggle("on", (btn.dataset.filter || "all") === spoolFilter);
|
||||||
@@ -698,14 +706,17 @@ function renderSpools() {
|
|||||||
spoolFilter === "archive" ? shown.length : activeCount
|
spoolFilter === "archive" ? shown.length : activeCount
|
||||||
);
|
);
|
||||||
const note = document.getElementById("spool-list-note");
|
const note = document.getElementById("spool-list-note");
|
||||||
|
const pager = document.getElementById("spool-pager");
|
||||||
if (!spools.length) {
|
if (!spools.length) {
|
||||||
note.textContent = "";
|
note.textContent = "";
|
||||||
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("spool") + '<p>Noch keine Spule.</p><p class="tiny">„Neue Spule“ anlegen oder Code scannen.</p></div></td></tr>';
|
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("spool") + '<p>Noch keine Spule.</p><p class="tiny">„Neue Spule“ anlegen oder Code scannen.</p></div></td></tr>';
|
||||||
|
renderPager(pager, null, () => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!shown.length) {
|
if (!shown.length) {
|
||||||
note.textContent = "";
|
note.textContent = "";
|
||||||
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("search") + '<p>Keine Spule passt.</p></div></td></tr>';
|
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("search") + '<p>Keine Spule passt.</p></div></td></tr>';
|
||||||
|
renderPager(pager, null, () => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
note.textContent = spoolFilter === "archive"
|
note.textContent = spoolFilter === "archive"
|
||||||
@@ -719,8 +730,10 @@ function renderSpools() {
|
|||||||
const bh = b.printer_host || "zzz";
|
const bh = b.printer_host || "zzz";
|
||||||
return ah.localeCompare(bh, "de") || (Number(a.ace_slot) || 0) - (Number(b.ace_slot) || 0) || a.name.localeCompare(b.name, "de");
|
return ah.localeCompare(bh, "de") || (Number(a.ace_slot) || 0) - (Number(b.ace_slot) || 0) || a.name.localeCompare(b.name, "de");
|
||||||
});
|
});
|
||||||
|
const paged = pageSlice(ordered, spoolPage, DEFAULT_PAGE_SIZE);
|
||||||
|
spoolPage = paged.page;
|
||||||
let lastPlace = null;
|
let lastPlace = null;
|
||||||
ordered.forEach((spool) => {
|
paged.items.forEach((spool) => {
|
||||||
const group = spool.printer_host ? printerName(spool.printer_host) : "Regal";
|
const group = spool.printer_host ? printerName(spool.printer_host) : "Regal";
|
||||||
if (group !== lastPlace) {
|
if (group !== lastPlace) {
|
||||||
lastPlace = group;
|
lastPlace = group;
|
||||||
@@ -753,6 +766,10 @@ function renderSpools() {
|
|||||||
tr.addEventListener("click", () => fill(spool));
|
tr.addEventListener("click", () => fill(spool));
|
||||||
body.appendChild(tr);
|
body.appendChild(tr);
|
||||||
});
|
});
|
||||||
|
renderPager(pager, paged, (p) => {
|
||||||
|
spoolPage = p;
|
||||||
|
renderSpools();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSorten() {
|
function renderSorten() {
|
||||||
@@ -867,6 +884,7 @@ document.querySelectorAll(".mat-tabs [data-view]").forEach((btn) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("spool-filter").addEventListener("click", (event) => {
|
document.getElementById("spool-filter").addEventListener("click", (event) => {
|
||||||
|
spoolPage = 1;
|
||||||
const btn = event.target.closest("[data-filter]");
|
const btn = event.target.closest("[data-filter]");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
spoolFilter = btn.dataset.filter || "all";
|
spoolFilter = btn.dataset.filter || "all";
|
||||||
@@ -874,6 +892,7 @@ document.getElementById("spool-filter").addEventListener("click", (event) => {
|
|||||||
});
|
});
|
||||||
document.getElementById("spool-search").addEventListener("input", (event) => {
|
document.getElementById("spool-search").addEventListener("input", (event) => {
|
||||||
spoolSearch = event.target.value || "";
|
spoolSearch = event.target.value || "";
|
||||||
|
spoolPage = 1;
|
||||||
renderSpools();
|
renderSpools();
|
||||||
});
|
});
|
||||||
document.getElementById("sorten-search").addEventListener("input", (event) => {
|
document.getElementById("sorten-search").addEventListener("input", (event) => {
|
||||||
@@ -996,10 +1015,12 @@ document.getElementById("spool-delete").addEventListener("click", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
document.getElementById("spool-filter-type")?.addEventListener("change", (event) => {
|
document.getElementById("spool-filter-type")?.addEventListener("change", (event) => {
|
||||||
|
spoolPage = 1;
|
||||||
spoolTypeFilter = event.target.value || "";
|
spoolTypeFilter = event.target.value || "";
|
||||||
renderSpools();
|
renderSpools();
|
||||||
});
|
});
|
||||||
document.getElementById("spool-filter-color")?.addEventListener("change", (event) => {
|
document.getElementById("spool-filter-color")?.addEventListener("change", (event) => {
|
||||||
|
spoolPage = 1;
|
||||||
spoolColorFilter = event.target.value || "";
|
spoolColorFilter = event.target.value || "";
|
||||||
renderSpools();
|
renderSpools();
|
||||||
});
|
});
|
||||||
@@ -1099,11 +1120,24 @@ load().then(() => {
|
|||||||
closeSpoolPanel();
|
closeSpoolPanel();
|
||||||
closeSortenPanel();
|
closeSortenPanel();
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const spoolId = params.get("spool");
|
const spoolId = params.get("spule") || params.get("spool");
|
||||||
|
const hostHint = params.get("host") || "";
|
||||||
|
const slotHint = params.get("slot") || "";
|
||||||
if (spoolId) {
|
if (spoolId) {
|
||||||
const spool = spools.find((row) => String(row.id) === spoolId);
|
const spool = spools.find((row) => String(row.id) === spoolId);
|
||||||
if (spool) fill(spool);
|
if (spool) {
|
||||||
else openByCode(spoolId);
|
fill(spool);
|
||||||
|
focusById("spool-id") || document.getElementById("spool-name")?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
} else openByCode(spoolId);
|
||||||
|
} else if (hostHint) {
|
||||||
|
document.getElementById("spool-panel")?.removeAttribute("hidden");
|
||||||
|
document.getElementById("spool-new")?.click?.();
|
||||||
|
const hostField = document.getElementById("spool-printer");
|
||||||
|
if (hostField) hostField.value = hostHint;
|
||||||
|
const slotField = document.getElementById("spool-slot");
|
||||||
|
if (slotField && slotHint) slotField.value = String(slotHint);
|
||||||
|
document.getElementById("spool-name")?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
toast("Drucker und Platz vorausgefüllt — Rolle wählen und speichern.");
|
||||||
}
|
}
|
||||||
}).catch((error) => showNote("spool-note", error));
|
}).catch((error) => showNote("spool-note", error));
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+356
-56
@@ -6,11 +6,16 @@
|
|||||||
<p class="tiny">Arbeitsmappen mit Ordner unter dem Slice-Pfad — G-code und Konstruktionsdaten.</p>
|
<p class="tiny">Arbeitsmappen mit Ordner unter dem Slice-Pfad — G-code und Konstruktionsdaten.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
<div class="seg" id="proj-filter" role="group" aria-label="Filter">
|
||||||
|
<button type="button" class="on" data-filter="aktiv">Aktiv</button>
|
||||||
|
<button type="button" data-filter="fertig">Fertig</button>
|
||||||
|
<button type="button" data-filter="">Alle</button>
|
||||||
|
</div>
|
||||||
<button type="button" class="btn-primary" id="proj-new"><svg class="ico"><use href="#i-plus"/></svg>Neues Projekt</button>
|
<button type="button" class="btn-primary" id="proj-new"><svg class="ico"><use href="#i-plus"/></svg>Neues Projekt</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="split">
|
<div class="split edit-split is-idle" id="proj-layout">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card-head lib-head">
|
<div class="card-head lib-head">
|
||||||
<svg class="ico head-ico"><use href="#i-folder"/></svg>
|
<svg class="ico head-ico"><use href="#i-folder"/></svg>
|
||||||
@@ -19,15 +24,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Name</th><th>Status</th><th>Ordner</th><th class="num">Dateien</th></tr></thead>
|
<thead><tr><th>Name</th><th>Status</th><th>Ordner</th><th class="num">Dateien</th><th class="num">Kosten</th></tr></thead>
|
||||||
<tbody id="proj-rows"></tbody>
|
<tbody id="proj-rows"></tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<div class="pager" id="proj-pager" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card" id="proj-panel">
|
<section class="card edit-panel" id="proj-panel" hidden>
|
||||||
<h2 id="proj-title">Neues Projekt</h2>
|
<div class="mat-form-head">
|
||||||
<form id="proj-form" novalidate>
|
<h2 id="proj-title">Neues Projekt</h2>
|
||||||
|
<button type="button" class="ghost tiny-btn" id="proj-edit-btn" hidden>Bearbeiten</button>
|
||||||
|
<button type="button" class="icon-btn" id="proj-close" title="Schließen" aria-label="Schließen"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="proj-form" novalidate hidden>
|
||||||
<input id="proj-id" type="hidden">
|
<input id="proj-id" type="hidden">
|
||||||
<div class="field"><label for="proj-name">Name</label><input id="proj-name" required autocomplete="off"></div>
|
<div class="field"><label for="proj-name">Name</label><input id="proj-name" required autocomplete="off"></div>
|
||||||
<div class="grid-2">
|
<div class="grid-2">
|
||||||
@@ -35,9 +46,9 @@
|
|||||||
<label for="proj-status">Status</label>
|
<label for="proj-status">Status</label>
|
||||||
<select id="proj-status">
|
<select id="proj-status">
|
||||||
<option value="idee">Idee</option>
|
<option value="idee">Idee</option>
|
||||||
<option value="bereit">bereit</option>
|
<option value="bereit">Bereit</option>
|
||||||
<option value="druckt">druckt</option>
|
<option value="druckt">Druckt</option>
|
||||||
<option value="fertig">fertig</option>
|
<option value="fertig">Fertig</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -49,23 +60,46 @@
|
|||||||
<div class="field"><label for="proj-notes">Notiz</label><textarea id="proj-notes" rows="3"></textarea></div>
|
<div class="field"><label for="proj-notes">Notiz</label><textarea id="proj-notes" rows="3"></textarea></div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit" class="btn-primary" id="proj-save">Speichern</button>
|
<button type="submit" class="btn-primary" id="proj-save">Speichern</button>
|
||||||
<a class="ghost" id="proj-files-link" href="/dateien" hidden>Dateien</a>
|
<button type="button" class="ghost" id="proj-cancel-edit" hidden>Abbrechen</button>
|
||||||
<button type="button" class="warn" id="proj-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
|
<button type="button" class="warn" id="proj-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<p id="proj-note" class="tiny"></p>
|
<p id="proj-note" class="tiny"></p>
|
||||||
|
|
||||||
<div id="proj-detail" hidden>
|
<div id="proj-detail" hidden>
|
||||||
<h3 class="section-gap">Druckdateien</h3>
|
|
||||||
<div id="proj-gcodes" class="proj-file-list"></div>
|
|
||||||
|
|
||||||
<h3 class="section-gap">Weitere Dateien</h3>
|
|
||||||
<p class="tiny">STEP, STL, PDF, SolidWorks usw. im Projektordner — nicht in der Druck-Bibliothek.</p>
|
|
||||||
<div class="actions" style="margin-bottom:0.5rem">
|
<div class="actions" style="margin-bottom:0.5rem">
|
||||||
<label class="button ghost" for="proj-asset-file"><svg class="ico"><use href="#i-upload"/></svg>Hochladen</label>
|
<a class="ghost" id="proj-files-link" href="/dateien" hidden>Dateien</a>
|
||||||
<input id="proj-asset-file" type="file" multiple hidden>
|
</div>
|
||||||
|
<div id="proj-stats" class="proj-stats"></div>
|
||||||
|
<p id="proj-suggest" class="tiny" hidden></p>
|
||||||
|
<p id="proj-meta" class="tiny"></p>
|
||||||
|
|
||||||
|
<div class="seg" id="proj-tabs" role="tablist" style="margin:0.75rem 0">
|
||||||
|
<button type="button" class="on" data-tab="files">Druckdateien</button>
|
||||||
|
<button type="button" data-tab="runs">Läufe</button>
|
||||||
|
<button type="button" data-tab="assets">Weitere Dateien</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="proj-tab-files">
|
||||||
|
<div class="actions" style="margin-bottom:0.5rem">
|
||||||
|
<button type="button" class="btn-primary" id="proj-enqueue-all" hidden>Alle einreihen</button>
|
||||||
|
</div>
|
||||||
|
<div id="proj-gcodes" class="proj-file-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="proj-tab-runs" hidden>
|
||||||
|
<div id="proj-runs" class="proj-file-list"></div>
|
||||||
|
<div class="pager" id="proj-runs-pager" hidden></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="proj-tab-assets" hidden>
|
||||||
|
<p class="tiny">STEP, STL, PDF, SolidWorks usw. im Projektordner — nicht in der Druck-Bibliothek.</p>
|
||||||
|
<div class="actions" style="margin-bottom:0.5rem">
|
||||||
|
<label class="button ghost" for="proj-asset-file"><svg class="ico"><use href="#i-upload"/></svg>Hochladen</label>
|
||||||
|
<input id="proj-asset-file" type="file" multiple hidden>
|
||||||
|
</div>
|
||||||
|
<div id="proj-assets" class="proj-file-list"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="proj-assets" class="proj-file-list"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,79 +107,261 @@
|
|||||||
|
|
||||||
{% block script %}
|
{% block script %}
|
||||||
<script>
|
<script>
|
||||||
const STATUS_LABEL = { idee: "Idee", bereit: "bereit", druckt: "druckt", fertig: "fertig" };
|
const STATUS_LABEL = { idee: "Idee", bereit: "Bereit", druckt: "Druckt", fertig: "Fertig" };
|
||||||
let projects = [];
|
let projects = [];
|
||||||
let selectedId = null;
|
let selectedId = null;
|
||||||
|
let listFilter = "aktiv";
|
||||||
|
let detailTab = "files";
|
||||||
|
let runsPage = 0;
|
||||||
|
let runsTotal = 0;
|
||||||
|
const RUNS_PAGE = 20;
|
||||||
|
/** idle | create | view | edit */
|
||||||
|
let panelMode = "idle";
|
||||||
|
let folderTouched = false;
|
||||||
|
let projPage = 1;
|
||||||
|
|
||||||
function fillBlank() {
|
document.getElementById("proj-close").innerHTML = icon("x");
|
||||||
|
|
||||||
|
function openPanel() {
|
||||||
|
document.getElementById("proj-panel").hidden = false;
|
||||||
|
document.getElementById("proj-layout").classList.remove("is-idle");
|
||||||
|
document.getElementById("proj-panel").scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePanel() {
|
||||||
|
panelMode = "idle";
|
||||||
selectedId = null;
|
selectedId = null;
|
||||||
document.getElementById("proj-id").value = "";
|
document.getElementById("proj-panel").hidden = true;
|
||||||
document.getElementById("proj-name").value = "";
|
document.getElementById("proj-layout").classList.add("is-idle");
|
||||||
document.getElementById("proj-status").value = "idee";
|
document.getElementById("proj-form").hidden = true;
|
||||||
document.getElementById("proj-folder").value = "";
|
document.getElementById("proj-detail").hidden = true;
|
||||||
document.getElementById("proj-notes").value = "";
|
document.getElementById("proj-edit-btn").hidden = true;
|
||||||
document.getElementById("proj-title").textContent = "Neues Projekt";
|
document.getElementById("proj-cancel-edit").hidden = true;
|
||||||
document.getElementById("proj-delete").hidden = true;
|
document.getElementById("proj-delete").hidden = true;
|
||||||
document.getElementById("proj-files-link").hidden = true;
|
document.getElementById("proj-files-link").hidden = true;
|
||||||
document.getElementById("proj-detail").hidden = true;
|
|
||||||
document.getElementById("proj-note").textContent = "";
|
document.getElementById("proj-note").textContent = "";
|
||||||
|
document.getElementById("proj-enqueue-all").hidden = true;
|
||||||
|
history.replaceState(null, "", location.pathname);
|
||||||
renderList();
|
renderList();
|
||||||
}
|
}
|
||||||
|
|
||||||
function fill(project) {
|
function setFormValues(project) {
|
||||||
|
document.getElementById("proj-id").value = project ? project.id : "";
|
||||||
|
document.getElementById("proj-name").value = project ? (project.name || "") : "";
|
||||||
|
document.getElementById("proj-status").value = project ? (project.status || "idee") : "idee";
|
||||||
|
document.getElementById("proj-folder").value = project ? (project.folder || "") : "";
|
||||||
|
document.getElementById("proj-notes").value = project ? (project.notes || "") : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreate() {
|
||||||
|
panelMode = "create";
|
||||||
|
selectedId = null;
|
||||||
|
folderTouched = false;
|
||||||
|
setFormValues(null);
|
||||||
|
document.getElementById("proj-title").textContent = "Neues Projekt";
|
||||||
|
document.getElementById("proj-form").hidden = false;
|
||||||
|
document.getElementById("proj-detail").hidden = true;
|
||||||
|
document.getElementById("proj-edit-btn").hidden = true;
|
||||||
|
document.getElementById("proj-cancel-edit").hidden = true;
|
||||||
|
document.getElementById("proj-delete").hidden = true;
|
||||||
|
document.getElementById("proj-files-link").hidden = true;
|
||||||
|
document.getElementById("proj-note").textContent = "";
|
||||||
|
document.getElementById("proj-enqueue-all").hidden = true;
|
||||||
|
openPanel();
|
||||||
|
renderList();
|
||||||
|
document.getElementById("proj-name").focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showView(project) {
|
||||||
|
panelMode = "view";
|
||||||
selectedId = project.id;
|
selectedId = project.id;
|
||||||
document.getElementById("proj-id").value = project.id;
|
setFormValues(project);
|
||||||
document.getElementById("proj-name").value = project.name || "";
|
|
||||||
document.getElementById("proj-status").value = project.status || "idee";
|
|
||||||
document.getElementById("proj-folder").value = project.folder || "";
|
|
||||||
document.getElementById("proj-notes").value = project.notes || "";
|
|
||||||
document.getElementById("proj-title").textContent = project.name || "Projekt";
|
document.getElementById("proj-title").textContent = project.name || "Projekt";
|
||||||
document.getElementById("proj-delete").hidden = false;
|
document.getElementById("proj-form").hidden = true;
|
||||||
|
document.getElementById("proj-detail").hidden = false;
|
||||||
|
document.getElementById("proj-edit-btn").hidden = false;
|
||||||
|
document.getElementById("proj-cancel-edit").hidden = true;
|
||||||
|
document.getElementById("proj-delete").hidden = true;
|
||||||
const link = document.getElementById("proj-files-link");
|
const link = document.getElementById("proj-files-link");
|
||||||
link.hidden = false;
|
link.hidden = false;
|
||||||
link.href = "/dateien";
|
link.href = "/dateien?project=" + encodeURIComponent(project.id);
|
||||||
document.getElementById("proj-detail").hidden = false;
|
|
||||||
document.getElementById("proj-note").textContent = "";
|
document.getElementById("proj-note").textContent = "";
|
||||||
|
openPanel();
|
||||||
renderList();
|
renderList();
|
||||||
renderDetail(project);
|
renderDetail(project);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showEdit(project) {
|
||||||
|
panelMode = "edit";
|
||||||
|
selectedId = project.id;
|
||||||
|
folderTouched = true;
|
||||||
|
setFormValues(project);
|
||||||
|
document.getElementById("proj-title").textContent = "Bearbeiten";
|
||||||
|
document.getElementById("proj-form").hidden = false;
|
||||||
|
document.getElementById("proj-detail").hidden = true;
|
||||||
|
document.getElementById("proj-edit-btn").hidden = true;
|
||||||
|
document.getElementById("proj-cancel-edit").hidden = false;
|
||||||
|
document.getElementById("proj-delete").hidden = false;
|
||||||
|
document.getElementById("proj-files-link").hidden = true;
|
||||||
|
openPanel();
|
||||||
|
renderList();
|
||||||
|
document.getElementById("proj-name").focus();
|
||||||
|
}
|
||||||
|
|
||||||
function renderList() {
|
function renderList() {
|
||||||
const body = document.getElementById("proj-rows");
|
const body = document.getElementById("proj-rows");
|
||||||
|
const pager = document.getElementById("proj-pager");
|
||||||
document.getElementById("proj-count").textContent = String(projects.length);
|
document.getElementById("proj-count").textContent = String(projects.length);
|
||||||
if (!projects.length) {
|
if (!projects.length) {
|
||||||
body.innerHTML = '<tr><td class="empty" colspan="4"><div class="empty-state">' + icon("folder") + "<p>Noch kein Projekt.</p></div></td></tr>";
|
body.innerHTML = '<tr><td class="empty" colspan="5"><div class="empty-state">' + icon("folder")
|
||||||
|
+ "<p>Noch kein Projekt.</p>"
|
||||||
|
+ '<p><button type="button" class="btn-primary" id="proj-empty-new">Erstes Projekt anlegen</button></p>'
|
||||||
|
+ "</div></td></tr>";
|
||||||
|
document.getElementById("proj-empty-new")?.addEventListener("click", showCreate);
|
||||||
|
renderPager(pager, null, () => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
body.innerHTML = "";
|
body.innerHTML = "";
|
||||||
projects.forEach((project) => {
|
const paged = pageSlice(projects, projPage, DEFAULT_PAGE_SIZE);
|
||||||
|
projPage = paged.page;
|
||||||
|
paged.items.forEach((project) => {
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
tr.className = "pick" + (String(project.id) === String(selectedId) ? " on" : "");
|
tr.className = "pick" + (String(project.id) === String(selectedId) ? " on" : "");
|
||||||
tr.innerHTML = "<td></td><td></td><td></td><td class=\"num\"></td>";
|
tr.innerHTML = "<td></td><td></td><td class=\"cell-path\"></td><td class=\"num\"></td><td class=\"num\"></td>";
|
||||||
tr.children[0].textContent = project.name;
|
tr.children[0].textContent = project.name;
|
||||||
tr.children[1].textContent = STATUS_LABEL[project.status] || project.status || "";
|
let status = STATUS_LABEL[project.status] || project.status || "";
|
||||||
|
if ((project.stats || {}).active_count > 0) status = STATUS_LABEL.druckt;
|
||||||
|
tr.children[1].textContent = status;
|
||||||
tr.children[2].textContent = project.folder || "—";
|
tr.children[2].textContent = project.folder || "—";
|
||||||
tr.children[2].title = project.folder || "";
|
tr.children[2].title = project.folder || "";
|
||||||
tr.children[3].textContent = String(project.file_count ?? (project.files || []).length);
|
tr.children[3].textContent = String(project.file_count ?? (project.files || []).length);
|
||||||
|
const cost = (project.stats || {}).cost;
|
||||||
|
tr.children[4].textContent = cost ? euro(cost) : "—";
|
||||||
tr.addEventListener("click", () => openProject(project.id));
|
tr.addEventListener("click", () => openProject(project.id));
|
||||||
body.appendChild(tr);
|
body.appendChild(tr);
|
||||||
});
|
});
|
||||||
|
renderPager(pager, paged, (p) => {
|
||||||
|
projPage = p;
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTab(tab) {
|
||||||
|
detailTab = tab;
|
||||||
|
document.querySelectorAll("#proj-tabs [data-tab]").forEach((btn) => {
|
||||||
|
btn.classList.toggle("on", btn.dataset.tab === tab);
|
||||||
|
});
|
||||||
|
document.getElementById("proj-tab-files").hidden = tab !== "files";
|
||||||
|
document.getElementById("proj-tab-runs").hidden = tab !== "runs";
|
||||||
|
document.getElementById("proj-tab-assets").hidden = tab !== "assets";
|
||||||
|
if (tab === "runs" && selectedId) loadRuns(selectedId, 0);
|
||||||
|
if (tab === "assets" && selectedId) loadAssets(selectedId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDetail(project) {
|
function renderDetail(project) {
|
||||||
const gcodes = document.getElementById("proj-gcodes");
|
const stats = project.stats || {};
|
||||||
|
const statsEl = document.getElementById("proj-stats");
|
||||||
|
const chips = [
|
||||||
|
[stats.run_count || 0, "Läufe"],
|
||||||
|
[hours(stats.duration_s) || "0 min", "Zeit"],
|
||||||
|
[grams(stats.weight_g || 0), "Filament"],
|
||||||
|
[euro(stats.cost || 0), "Kosten"],
|
||||||
|
];
|
||||||
|
if (stats.files_total) {
|
||||||
|
chips.push([stats.files_ok + "/" + stats.files_total, "Dateien ok"]);
|
||||||
|
}
|
||||||
|
statsEl.innerHTML = chips.map(([v, l]) =>
|
||||||
|
`<div class="proj-stat"><strong>${escapeHtml(String(v))}</strong><span class="tiny">${escapeHtml(l)}</span></div>`
|
||||||
|
).join("");
|
||||||
|
|
||||||
|
const meta = [];
|
||||||
|
if (project.folder) meta.push(project.folder);
|
||||||
|
if (project.created_at) meta.push("Angelegt " + formatDateTime(project.created_at));
|
||||||
|
if (stats.last_run) {
|
||||||
|
meta.push("Zuletzt " + formatDateTime(stats.last_run.started_at) + " · " + (stats.last_run.filename || ""));
|
||||||
|
}
|
||||||
|
document.getElementById("proj-meta").textContent = meta.join(" · ");
|
||||||
|
|
||||||
|
const suggest = document.getElementById("proj-suggest");
|
||||||
|
if (stats.suggest_fertig) {
|
||||||
|
suggest.hidden = false;
|
||||||
|
suggest.innerHTML = 'Alle Dateien mindestens einmal ok gedruckt. <button type="button" class="ghost" id="proj-mark-fertig">Als fertig markieren?</button>';
|
||||||
|
document.getElementById("proj-mark-fertig")?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await api("/projekte/" + project.id, { method: "PATCH", json: { status: "fertig" } });
|
||||||
|
toast("Projekt als fertig markiert.", "good");
|
||||||
|
await load();
|
||||||
|
await openProject(project.id);
|
||||||
|
} catch (error) {
|
||||||
|
showNote("proj-note", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
suggest.hidden = true;
|
||||||
|
suggest.textContent = "";
|
||||||
|
}
|
||||||
|
|
||||||
const files = project.files || [];
|
const files = project.files || [];
|
||||||
|
document.getElementById("proj-enqueue-all").hidden = !files.length;
|
||||||
|
const gcodes = document.getElementById("proj-gcodes");
|
||||||
if (!files.length) {
|
if (!files.length) {
|
||||||
gcodes.innerHTML = '<p class="tiny">Keine G-code/3MF-Dateien verknüpft. Dateien in den Projektordner legen oder unter Dateien verschieben.</p>';
|
gcodes.innerHTML = '<p class="tiny">Keine G-code/3MF-Dateien verknüpft. Dateien in den Projektordner legen oder unter Dateien verschieben.</p>';
|
||||||
} else {
|
} else {
|
||||||
gcodes.innerHTML = files.map((file) => {
|
gcodes.innerHTML = files.map((file) => {
|
||||||
const mats = (file.filaments || []).map((f) => f.type || f.material_type || f.name || "").filter(Boolean);
|
const mats = (file.filaments || []).map((f) => f.type || f.material_type || f.name || "").filter(Boolean);
|
||||||
const mat = [...new Set(mats)].join(" / ");
|
const mat = [...new Set(mats)].join(" / ");
|
||||||
const meta = [mat, file.weight_g ? Math.round(file.weight_g) + " g" : ""].filter(Boolean).join(" · ");
|
const metaBits = [
|
||||||
return `<a class="proj-file" href="/dateien">${escapeHtml(file.name)}<span class="tiny">${escapeHtml(meta)}</span></a>`;
|
mat,
|
||||||
|
file.weight_g ? Math.round(file.weight_g) + " g" : "",
|
||||||
|
file.printed ? file.printed + "× gedruckt" : "noch nie",
|
||||||
|
file.queued ? file.queued + " in Queue" : "",
|
||||||
|
file.ok_once ? "ok" : "",
|
||||||
|
].filter(Boolean).join(" · ");
|
||||||
|
const href = "/dateien?file=" + encodeURIComponent(file.id) + "&project=" + encodeURIComponent(project.id);
|
||||||
|
return `<div class="proj-file-row">
|
||||||
|
<a class="proj-file" href="${escapeHtml(href)}">${escapeHtml(file.name)}<span class="tiny">${escapeHtml(metaBits)}</span></a>
|
||||||
|
<button type="button" class="ghost" data-enqueue="${file.id}">Einreihen</button>
|
||||||
|
</div>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
}
|
}
|
||||||
loadAssets(project.id);
|
setTab(detailTab);
|
||||||
|
if (detailTab === "assets") loadAssets(project.id);
|
||||||
|
if (detailTab === "runs") loadRuns(project.id, runsPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRuns(projectId, offset) {
|
||||||
|
const box = document.getElementById("proj-runs");
|
||||||
|
const pager = document.getElementById("proj-runs-pager");
|
||||||
|
runsPage = Math.max(0, offset || 0);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(withToken("/api/v1/projekte/" + projectId + "/laeufe?limit=" + RUNS_PAGE + "&offset=" + runsPage), {
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error("Läufe laden fehlgeschlagen.");
|
||||||
|
const rows = await resp.json();
|
||||||
|
runsTotal = Number(resp.headers.get("X-Total-Count") || rows.length);
|
||||||
|
if (!rows.length) {
|
||||||
|
box.innerHTML = '<p class="tiny">Noch keine Läufe.</p>';
|
||||||
|
pager.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
box.innerHTML = rows.map((run) => {
|
||||||
|
const state = (window.RUN_STATES && RUN_STATES[run.state]) || run.state || "";
|
||||||
|
const cost = Number(run.filament_cost || 0) + Number(run.energy_cost || 0);
|
||||||
|
const meta = [state, hours(run.duration_s), cost ? euro(cost) : "", formatDateTime(run.started_at)].filter(Boolean).join(" · ");
|
||||||
|
return `<a class="proj-file" href="/protokoll/${run.id}">${escapeHtml(run.filename || "Lauf #" + run.id)}<span class="tiny">${escapeHtml(meta)}</span></a>`;
|
||||||
|
}).join("");
|
||||||
|
const pages = Math.max(1, Math.ceil(runsTotal / RUNS_PAGE));
|
||||||
|
const page = Math.floor(runsPage / RUNS_PAGE) + 1;
|
||||||
|
renderPager(pager, {
|
||||||
|
items: rows, page, pages, total: runsTotal, pageSize: RUNS_PAGE,
|
||||||
|
from: runsTotal ? runsPage + 1 : 0,
|
||||||
|
to: Math.min(runsPage + rows.length, runsTotal),
|
||||||
|
}, (p) => loadRuns(projectId, (p - 1) * RUNS_PAGE));
|
||||||
|
} catch (error) {
|
||||||
|
box.innerHTML = '<p class="tiny">' + escapeHtml(error.message || String(error)) + "</p>";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAssets(projectId) {
|
async function loadAssets(projectId) {
|
||||||
@@ -176,32 +392,80 @@ function sizeText(bytes) {
|
|||||||
return (n / (1024 * 1024)).toFixed(1).replace(".", ",") + " MB";
|
return (n / (1024 * 1024)).toFixed(1).replace(".", ",") + " MB";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function enqueueFiles(projectId, fileIds) {
|
||||||
|
const payload = { confirm: true };
|
||||||
|
if (fileIds && fileIds.length) payload.file_ids = fileIds;
|
||||||
|
const out = await api("/projekte/" + projectId + "/einreihen", { method: "POST", json: payload });
|
||||||
|
toast((out.count || 0) + " in die Warteschlange.", "good");
|
||||||
|
await openProject(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
async function openProject(id) {
|
async function openProject(id) {
|
||||||
try {
|
try {
|
||||||
const row = await api("/projekte/" + id);
|
const row = await api("/projekte/" + id);
|
||||||
fill(row);
|
showView(row);
|
||||||
|
history.replaceState(null, "", "#" + id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showNote("proj-note", error);
|
showNote("proj-note", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
projects = await api("/projekte");
|
const q = listFilter ? ("?status=" + encodeURIComponent(listFilter)) : "";
|
||||||
|
projects = await api("/projekte" + q);
|
||||||
renderList();
|
renderList();
|
||||||
if (selectedId) {
|
if (selectedId && (panelMode === "view" || panelMode === "edit")) {
|
||||||
const still = projects.find((p) => String(p.id) === String(selectedId));
|
const still = projects.find((p) => String(p.id) === String(selectedId));
|
||||||
if (still) await openProject(selectedId);
|
if (still) {
|
||||||
else fillBlank();
|
if (panelMode === "edit") {
|
||||||
|
const row = await api("/projekte/" + selectedId);
|
||||||
|
showEdit(row);
|
||||||
|
} else {
|
||||||
|
await openProject(selectedId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
closePanel();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let folderTouched = false;
|
document.getElementById("proj-filter").addEventListener("click", (event) => {
|
||||||
|
const btn = event.target.closest("[data-filter]");
|
||||||
|
if (!btn) return;
|
||||||
|
listFilter = btn.dataset.filter || "";
|
||||||
|
document.querySelectorAll("#proj-filter [data-filter]").forEach((b) => b.classList.toggle("on", b === btn));
|
||||||
|
projPage = 1;
|
||||||
|
load().catch((error) => showNote("proj-note", error));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("proj-tabs").addEventListener("click", (event) => {
|
||||||
|
const btn = event.target.closest("[data-tab]");
|
||||||
|
if (!btn) return;
|
||||||
|
setTab(btn.dataset.tab);
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById("proj-new").addEventListener("click", () => {
|
document.getElementById("proj-new").addEventListener("click", () => {
|
||||||
folderTouched = false;
|
history.replaceState(null, "", location.pathname);
|
||||||
fillBlank();
|
showCreate();
|
||||||
document.getElementById("proj-name").focus();
|
|
||||||
});
|
});
|
||||||
|
document.getElementById("proj-close").addEventListener("click", closePanel);
|
||||||
|
document.getElementById("proj-edit-btn").addEventListener("click", async () => {
|
||||||
|
if (!selectedId) return;
|
||||||
|
try {
|
||||||
|
const row = await api("/projekte/" + selectedId);
|
||||||
|
showEdit(row);
|
||||||
|
} catch (error) {
|
||||||
|
showNote("proj-note", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById("proj-cancel-edit").addEventListener("click", async () => {
|
||||||
|
if (!selectedId) {
|
||||||
|
closePanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await openProject(selectedId);
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById("proj-folder").addEventListener("input", () => {
|
document.getElementById("proj-folder").addEventListener("input", () => {
|
||||||
folderTouched = true;
|
folderTouched = true;
|
||||||
});
|
});
|
||||||
@@ -267,7 +531,7 @@ document.getElementById("proj-delete").addEventListener("click", () => {
|
|||||||
try {
|
try {
|
||||||
await api("/projekte/" + id + "?force=1", { method: "DELETE" });
|
await api("/projekte/" + id + "?force=1", { method: "DELETE" });
|
||||||
toast("Projekt gelöscht.");
|
toast("Projekt gelöscht.");
|
||||||
fillBlank();
|
closePanel();
|
||||||
await load();
|
await load();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showNote("proj-note", error);
|
showNote("proj-note", error);
|
||||||
@@ -275,8 +539,36 @@ document.getElementById("proj-delete").addEventListener("click", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById("proj-enqueue-all").addEventListener("click", () => {
|
||||||
|
const id = selectedId;
|
||||||
|
if (!id) return;
|
||||||
|
armButton(document.getElementById("proj-enqueue-all"), "Alle einreihen?", async () => {
|
||||||
|
try {
|
||||||
|
await enqueueFiles(id, null);
|
||||||
|
} catch (error) {
|
||||||
|
showNote("proj-note", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("proj-gcodes").addEventListener("click", (event) => {
|
||||||
|
const btn = event.target.closest("[data-enqueue]");
|
||||||
|
if (!btn) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const id = selectedId;
|
||||||
|
const fileId = Number(btn.dataset.enqueue);
|
||||||
|
if (!id || !fileId) return;
|
||||||
|
armButton(btn, "einreihen?", async () => {
|
||||||
|
try {
|
||||||
|
await enqueueFiles(id, [fileId]);
|
||||||
|
} catch (error) {
|
||||||
|
showNote("proj-note", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById("proj-asset-file").addEventListener("change", async (event) => {
|
document.getElementById("proj-asset-file").addEventListener("change", async (event) => {
|
||||||
const id = document.getElementById("proj-id").value;
|
const id = selectedId;
|
||||||
const files = [...(event.target.files || [])];
|
const files = [...(event.target.files || [])];
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
if (!id || !files.length) return;
|
if (!id || !files.length) return;
|
||||||
@@ -296,7 +588,7 @@ document.getElementById("proj-asset-file").addEventListener("change", async (eve
|
|||||||
document.getElementById("proj-assets").addEventListener("click", (event) => {
|
document.getElementById("proj-assets").addEventListener("click", (event) => {
|
||||||
const btn = event.target.closest("[data-asset-del]");
|
const btn = event.target.closest("[data-asset-del]");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const id = document.getElementById("proj-id").value;
|
const id = selectedId;
|
||||||
const rel = btn.dataset.assetDel;
|
const rel = btn.dataset.assetDel;
|
||||||
if (!id || !rel) return;
|
if (!id || !rel) return;
|
||||||
armButton(btn, "löschen?", async () => {
|
armButton(btn, "löschen?", async () => {
|
||||||
@@ -306,6 +598,14 @@ document.getElementById("proj-assets").addEventListener("click", (event) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
load().catch((error) => showNote("proj-note", error));
|
(async () => {
|
||||||
|
try {
|
||||||
|
await load();
|
||||||
|
const hash = (location.hash || "").replace(/^#/, "");
|
||||||
|
if (hash && /^\d+$/.test(hash)) await openProject(Number(hash));
|
||||||
|
} catch (error) {
|
||||||
|
showNote("proj-note", error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
+103
-11
@@ -4,6 +4,10 @@
|
|||||||
<div>
|
<div>
|
||||||
<h1>Protokoll</h1>
|
<h1>Protokoll</h1>
|
||||||
<p class="tiny">Abgeschlossene und laufende Drucke mit Dauer, Material und Kosten.</p>
|
<p class="tiny">Abgeschlossene und laufende Drucke mit Dauer, Material und Kosten.</p>
|
||||||
|
<details class="tiny proto-help">
|
||||||
|
<summary>Filament-Pause / doppelte Zeilen</summary>
|
||||||
|
<p>Nach Runout kann der Drucker kurz zwei History-Zeilen erzeugen. „Läufe holen“ legt sie zusammen; sonst „Verbinden“.</p>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-head-side">
|
<div class="page-head-side">
|
||||||
<button type="button" id="sync"><svg class="ico"><use href="#i-refresh"/></svg>Läufe holen</button>
|
<button type="button" id="sync"><svg class="ico"><use href="#i-refresh"/></svg>Läufe holen</button>
|
||||||
@@ -27,6 +31,7 @@
|
|||||||
<thead><tr><th>Drucker</th><th>Datei</th><th>Zustand</th><th>Dauer</th><th>Ist</th><th>Kosten</th><th>Urteil</th><th>Spule</th><th></th></tr></thead>
|
<thead><tr><th>Drucker</th><th>Datei</th><th>Zustand</th><th>Dauer</th><th>Ist</th><th>Kosten</th><th>Urteil</th><th>Spule</th><th></th></tr></thead>
|
||||||
<tbody id="run-rows"></tbody>
|
<tbody id="run-rows"></tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<div class="pager" id="run-pager" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block script %}
|
{% block script %}
|
||||||
@@ -37,6 +42,7 @@ const states = {
|
|||||||
};
|
};
|
||||||
let runs = [];
|
let runs = [];
|
||||||
let spools = [];
|
let spools = [];
|
||||||
|
let runPage = 1;
|
||||||
|
|
||||||
function fillPrinterFilter() {
|
function fillPrinterFilter() {
|
||||||
const select = document.getElementById("run-filter-printer");
|
const select = document.getElementById("run-filter-printer");
|
||||||
@@ -82,15 +88,18 @@ function render() {
|
|||||||
const shown = filteredRuns();
|
const shown = filteredRuns();
|
||||||
if (!runs.length) {
|
if (!runs.length) {
|
||||||
body.innerHTML = '<tr><td class="empty" colspan="9"><div class="empty-state">' + icon("list") + '<p>Noch kein Lauf.</p><p class="tiny">„Läufe holen“ liest die Drucker aus.</p></div></td></tr>';
|
body.innerHTML = '<tr><td class="empty" colspan="9"><div class="empty-state">' + icon("list") + '<p>Noch kein Lauf.</p><p class="tiny">„Läufe holen“ liest die Drucker aus.</p></div></td></tr>';
|
||||||
|
renderPager(document.getElementById("run-pager"), null, () => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!shown.length) {
|
if (!shown.length) {
|
||||||
body.innerHTML = '<tr><td class="empty" colspan="9"><div class="empty-state">' + icon("filter") + '<p>Kein Lauf passt zum Filter.</p></div></td></tr>';
|
body.innerHTML = '<tr><td class="empty" colspan="9"><div class="empty-state">' + icon("filter") + '<p>Kein Lauf passt zum Filter.</p></div></td></tr>';
|
||||||
|
renderPager(document.getElementById("run-pager"), null, () => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
shown.forEach((run) => {
|
const paged = pageSlice(shown, runPage, DEFAULT_PAGE_SIZE);
|
||||||
|
runPage = paged.page;
|
||||||
|
paged.items.forEach((run) => {
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
tr.className = "pick";
|
|
||||||
tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>";
|
tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>";
|
||||||
tr.children[0].textContent = run.printer_name;
|
tr.children[0].textContent = run.printer_name;
|
||||||
tr.children[0].className = "proto-printer";
|
tr.children[0].className = "proto-printer";
|
||||||
@@ -103,19 +112,54 @@ function render() {
|
|||||||
link.title = fullName;
|
link.title = fullName;
|
||||||
link.textContent = shortRunName(run.filename) || fullName;
|
link.textContent = shortRunName(run.filename) || fullName;
|
||||||
fileCell.appendChild(link);
|
fileCell.appendChild(link);
|
||||||
|
if (run.file_id) {
|
||||||
|
const fileLink = document.createElement("a");
|
||||||
|
fileLink.href = "/dateien?file=" + encodeURIComponent(run.file_id);
|
||||||
|
fileLink.className = "tiny";
|
||||||
|
fileLink.textContent = "Datei";
|
||||||
|
fileLink.style.marginLeft = "0.35rem";
|
||||||
|
fileCell.appendChild(fileLink);
|
||||||
|
}
|
||||||
|
if (run.project_id) {
|
||||||
|
const projLink = document.createElement("a");
|
||||||
|
projLink.href = "/projekte#" + encodeURIComponent(run.project_id);
|
||||||
|
projLink.className = "tiny";
|
||||||
|
projLink.textContent = "Projekt";
|
||||||
|
projLink.style.marginLeft = "0.35rem";
|
||||||
|
fileCell.appendChild(projLink);
|
||||||
|
}
|
||||||
if (run.chargen_nr) {
|
if (run.chargen_nr) {
|
||||||
const code = document.createElement("div");
|
const code = document.createElement("div");
|
||||||
code.className = "mono-badge";
|
code.className = "mono-badge";
|
||||||
code.textContent = run.chargen_nr;
|
code.textContent = run.chargen_nr;
|
||||||
fileCell.appendChild(code);
|
fileCell.appendChild(code);
|
||||||
}
|
}
|
||||||
|
if ((run.spool_names || []).length > 1 || (run.segment_count || 0) > 1) {
|
||||||
|
const swap = document.createElement("div");
|
||||||
|
swap.className = "tiny warn-text";
|
||||||
|
swap.textContent = "Rollenwechsel: " + (run.spool_names || []).join(" → ");
|
||||||
|
fileCell.appendChild(swap);
|
||||||
|
}
|
||||||
|
if (run.merge_with_id) {
|
||||||
|
const hint = document.createElement("div");
|
||||||
|
hint.className = "tiny warn-text";
|
||||||
|
hint.textContent = "Vermutlich Fortsetzung von/zu #" + run.merge_with_id;
|
||||||
|
fileCell.appendChild(hint);
|
||||||
|
}
|
||||||
const stateKey = run.state === "in_progress" || run.state === "printing" ? "printing"
|
const stateKey = run.state === "in_progress" || run.state === "printing" ? "printing"
|
||||||
|
: run.state === "paused" ? "paused"
|
||||||
: run.state === "completed" ? "complete"
|
: run.state === "completed" ? "complete"
|
||||||
: run.state === "error" ? "error"
|
: run.state === "error" ? "error"
|
||||||
: run.state === "cancelled" || run.state === "interrupted" ? "cancelled"
|
: run.state === "cancelled" || run.state === "interrupted" ? "cancelled"
|
||||||
: "idle";
|
: "idle";
|
||||||
tr.children[2].innerHTML = chipHtml(stateKey, states[run.state] || run.state);
|
let stateLabel = states[run.state] || run.state;
|
||||||
tr.children[3].textContent = hours(run.duration_s);
|
if (run.state === "cancelled" && run.merge_with_id) stateLabel = "Teilabbruch (Runout?)";
|
||||||
|
tr.children[2].innerHTML = chipHtml(stateKey, stateLabel);
|
||||||
|
let durationText = hours(run.duration_s);
|
||||||
|
if (run.pause_s && run.pause_s > 60) {
|
||||||
|
durationText += " (Pause " + hours(run.pause_s) + ")";
|
||||||
|
}
|
||||||
|
tr.children[3].textContent = durationText;
|
||||||
tr.children[3].className = "num";
|
tr.children[3].className = "num";
|
||||||
const used = Number(run.weight_used_g) || Number(run.weight_g) || 0;
|
const used = Number(run.weight_used_g) || Number(run.weight_g) || 0;
|
||||||
const planned = Number(run.weight_planned_g) || Number(run.weight_g) || 0;
|
const planned = Number(run.weight_planned_g) || Number(run.weight_g) || 0;
|
||||||
@@ -128,7 +172,11 @@ function render() {
|
|||||||
}
|
}
|
||||||
const cost = Number(run.filament_cost || 0) + Number(run.energy_cost || 0);
|
const cost = Number(run.filament_cost || 0) + Number(run.energy_cost || 0);
|
||||||
tr.children[5].textContent = cost ? euro(cost) : "";
|
tr.children[5].textContent = cost ? euro(cost) : "";
|
||||||
|
const verdictLabel = document.createElement("label");
|
||||||
|
verdictLabel.className = "sr-only";
|
||||||
|
verdictLabel.textContent = "Urteil";
|
||||||
const verdict = document.createElement("select");
|
const verdict = document.createElement("select");
|
||||||
|
verdict.setAttribute("aria-label", "Urteil");
|
||||||
[["", "offen"], ["gut", "gut"], ["ausschuss", "Ausschuss"]].forEach(([value, label]) => {
|
[["", "offen"], ["gut", "gut"], ["ausschuss", "Ausschuss"]].forEach(([value, label]) => {
|
||||||
const option = document.createElement("option");
|
const option = document.createElement("option");
|
||||||
option.value = value;
|
option.value = value;
|
||||||
@@ -139,10 +187,12 @@ function render() {
|
|||||||
verdict.addEventListener("change", async () => {
|
verdict.addEventListener("change", async () => {
|
||||||
try {
|
try {
|
||||||
await api("/laeufe/" + run.id, { method: "PATCH", json: { verdict: verdict.value } });
|
await api("/laeufe/" + run.id, { method: "PATCH", json: { verdict: verdict.value } });
|
||||||
|
toast("Urteil gespeichert.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showNote("run-note", error);
|
showNote("run-note", error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
tr.children[6].appendChild(verdictLabel);
|
||||||
tr.children[6].appendChild(verdict);
|
tr.children[6].appendChild(verdict);
|
||||||
if (run.needs_spool || !run.deducted) {
|
if (run.needs_spool || !run.deducted) {
|
||||||
const select = document.createElement("select");
|
const select = document.createElement("select");
|
||||||
@@ -177,14 +227,49 @@ function render() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
tr.children[7].appendChild(select);
|
tr.children[7].appendChild(select);
|
||||||
|
} else if ((run.spool_names || []).length > 1) {
|
||||||
|
tr.children[7].className = "proto-spool";
|
||||||
|
tr.children[7].textContent = run.spool_names.join(" → ");
|
||||||
|
tr.children[7].title = run.spool_names.join(" → ");
|
||||||
} else {
|
} else {
|
||||||
const spool = spools.find((item) => item.id === run.spool_id);
|
const spool = spools.find((item) => item.id === run.spool_id);
|
||||||
tr.children[7].textContent = spool ? spool.name : "abgezogen";
|
|
||||||
tr.children[7].title = spool ? spool.name : "";
|
|
||||||
tr.children[7].className = "proto-spool";
|
tr.children[7].className = "proto-spool";
|
||||||
|
if (spool) {
|
||||||
|
tr.children[7].innerHTML = linkTo("spule", spool.id, spool.name);
|
||||||
|
tr.children[7].title = spool.name;
|
||||||
|
} else if ((run.spool_names || []).length) {
|
||||||
|
tr.children[7].textContent = run.spool_names[0];
|
||||||
|
} else {
|
||||||
|
tr.children[7].textContent = "abgezogen";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!tr.children[7].className) tr.children[7].className = "proto-spool";
|
if (!tr.children[7].className) tr.children[7].className = "proto-spool";
|
||||||
if (run.file_id) {
|
const actions = tr.children[8];
|
||||||
|
actions.className = "proto-actions";
|
||||||
|
if (run.merge_with_id) {
|
||||||
|
const mergeBtn = document.createElement("button");
|
||||||
|
mergeBtn.type = "button";
|
||||||
|
mergeBtn.className = "ghost tiny-btn";
|
||||||
|
mergeBtn.textContent = "Verbinden";
|
||||||
|
mergeBtn.title = "Mit Lauf #" + run.merge_with_id + " verbinden";
|
||||||
|
mergeBtn.addEventListener("click", () => {
|
||||||
|
armButton(mergeBtn, "Mit #" + run.merge_with_id + " verbinden?", async () => {
|
||||||
|
try {
|
||||||
|
await api("/laeufe/" + run.id + "/verbinden", {
|
||||||
|
method: "POST",
|
||||||
|
json: { other_id: run.merge_with_id, confirm: true },
|
||||||
|
});
|
||||||
|
toast("Läufe verbunden.");
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message, "bad");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
actions.appendChild(mergeBtn);
|
||||||
|
}
|
||||||
|
const running = ["in_progress", "printing", "paused"].includes(run.state);
|
||||||
|
if (run.file_id && !running) {
|
||||||
const again = document.createElement("button");
|
const again = document.createElement("button");
|
||||||
again.type = "button";
|
again.type = "button";
|
||||||
again.className = "ghost tiny-btn";
|
again.className = "ghost tiny-btn";
|
||||||
@@ -229,20 +314,27 @@ function render() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
tr.children[8].appendChild(again);
|
actions.appendChild(again);
|
||||||
}
|
}
|
||||||
body.appendChild(tr);
|
body.appendChild(tr);
|
||||||
});
|
});
|
||||||
|
renderPager(document.getElementById("run-pager"), paged, (p) => {
|
||||||
|
runPage = p;
|
||||||
|
render();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
[runs, spools] = await Promise.all([api("/laeufe"), api("/spulen")]);
|
[runs, spools] = await Promise.all([api("/laeufe?limit=500"), api("/spulen")]);
|
||||||
|
runPage = 1;
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
window.onDruckerLive = () => { load().catch(() => {}); };
|
||||||
|
window.onQueueChanged = () => { load().catch(() => {}); };
|
||||||
|
|
||||||
["run-filter-printer", "run-filter-verdict", "run-filter-spool"].forEach((id) => {
|
["run-filter-printer", "run-filter-verdict", "run-filter-spool"].forEach((id) => {
|
||||||
document.getElementById(id).addEventListener("change", render);
|
document.getElementById(id).addEventListener("change", () => { runPage = 1; render(); });
|
||||||
document.getElementById(id).addEventListener("input", render);
|
document.getElementById(id).addEventListener("input", () => { runPage = 1; render(); });
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("sync").addEventListener("click", async () => {
|
document.getElementById("sync").addEventListener("click", async () => {
|
||||||
|
|||||||
@@ -19,14 +19,18 @@
|
|||||||
<button type="button" data-period="last_week" class="fchip">Letzte Woche</button>
|
<button type="button" data-period="last_week" class="fchip">Letzte Woche</button>
|
||||||
<button type="button" data-period="last_month" class="fchip">Letzter Monat</button>
|
<button type="button" data-period="last_month" class="fchip">Letzter Monat</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-filters">
|
<details class="stat-more">
|
||||||
<input type="date" id="since" title="Von" class="date-field">
|
<summary>Weitere Filter</summary>
|
||||||
<input type="date" id="until" title="Bis" class="date-field">
|
<div class="stat-filters">
|
||||||
<select id="host"><option value="">Alle Drucker</option></select>
|
<input type="date" id="since" title="Von" class="date-field">
|
||||||
<input type="text" id="material" placeholder="Sorte" size="8" autocomplete="off">
|
<input type="date" id="until" title="Bis" class="date-field">
|
||||||
<input type="text" id="brand" placeholder="Marke" size="8" autocomplete="off">
|
<select id="host"><option value="">Alle Drucker</option></select>
|
||||||
<input type="text" id="color" placeholder="Farbe" size="8" autocomplete="off">
|
<select id="project"><option value="">Alle Projekte</option></select>
|
||||||
</div>
|
<input type="text" id="material" placeholder="Sorte" size="8" autocomplete="off">
|
||||||
|
<input type="text" id="brand" placeholder="Marke" size="8" autocomplete="off">
|
||||||
|
<input type="text" id="color" placeholder="Farbe" size="8" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<p id="stat-note"></p>
|
<p id="stat-note"></p>
|
||||||
<div id="stat" class="statistik"></div>
|
<div id="stat" class="statistik"></div>
|
||||||
@@ -231,17 +235,37 @@ function render(data) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fillProjects() {
|
||||||
|
const select = document.getElementById("project");
|
||||||
|
if (!select || select.dataset.filled) return;
|
||||||
|
try {
|
||||||
|
const rows = await api("/projekte?status=");
|
||||||
|
const current = select.value;
|
||||||
|
rows.forEach((p) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = p.id;
|
||||||
|
opt.textContent = p.name;
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
select.dataset.filled = "1";
|
||||||
|
if ([...select.options].some((o) => o.value === current)) select.value = current;
|
||||||
|
} catch (error) { /* optional */ }
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
await fillProjects();
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
const since = document.getElementById("since").value;
|
const since = document.getElementById("since").value;
|
||||||
const until = document.getElementById("until").value;
|
const until = document.getElementById("until").value;
|
||||||
const host = document.getElementById("host").value;
|
const host = document.getElementById("host").value;
|
||||||
|
const project = document.getElementById("project").value;
|
||||||
const material = document.getElementById("material").value.trim();
|
const material = document.getElementById("material").value.trim();
|
||||||
const brand = document.getElementById("brand").value.trim();
|
const brand = document.getElementById("brand").value.trim();
|
||||||
const color = document.getElementById("color").value.trim();
|
const color = document.getElementById("color").value.trim();
|
||||||
if (since) params.set("since", since);
|
if (since) params.set("since", since);
|
||||||
if (until) params.set("until", until);
|
if (until) params.set("until", until);
|
||||||
if (host) params.set("host", host);
|
if (host) params.set("host", host);
|
||||||
|
if (project) params.set("project_id", project);
|
||||||
if (material) params.set("material", material);
|
if (material) params.set("material", material);
|
||||||
if (brand) params.set("brand", brand);
|
if (brand) params.set("brand", brand);
|
||||||
if (color) params.set("color", color);
|
if (color) params.set("color", color);
|
||||||
@@ -278,6 +302,7 @@ document.getElementById("period").addEventListener("click", (event) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
document.getElementById("host").addEventListener("change", scheduleLoad);
|
document.getElementById("host").addEventListener("change", scheduleLoad);
|
||||||
|
document.getElementById("project").addEventListener("change", scheduleLoad);
|
||||||
["material", "brand", "color"].forEach((id) => {
|
["material", "brand", "color"].forEach((id) => {
|
||||||
const node = document.getElementById(id);
|
const node = document.getElementById(id);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
|||||||
+235
-16
@@ -49,6 +49,10 @@
|
|||||||
<div class="desk-facts" data-role="facts"></div>
|
<div class="desk-facts" data-role="facts"></div>
|
||||||
<div data-role="flow"></div>
|
<div data-role="flow"></div>
|
||||||
<div data-role="strips"></div>
|
<div data-role="strips"></div>
|
||||||
|
<div class="desk-next" data-role="next" hidden>
|
||||||
|
<div class="desk-next-head"><svg class="ico"><use href="#i-play"/></svg><span class="grow">Als Nächstes</span></div>
|
||||||
|
<div data-role="next-body"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="desk-foot">
|
<div class="desk-foot">
|
||||||
<div class="card-actions" data-role="controls"></div>
|
<div class="card-actions" data-role="controls"></div>
|
||||||
@@ -322,13 +326,22 @@ function actionsHtml(state, printer) {
|
|||||||
function flowHtml(printer) {
|
function flowHtml(printer) {
|
||||||
const job = printer.queue_running;
|
const job = printer.queue_running;
|
||||||
const bits = [];
|
const bits = [];
|
||||||
if (job) {
|
const pending = startingJob(printer);
|
||||||
|
if (pending) {
|
||||||
|
const name = jobName(pending.filename || "") || "Datei";
|
||||||
|
bits.push(`<div class="strip flow">
|
||||||
|
<div class="flow-head">${icon("play")}<span class="grow">Startet ${escapeHtml(name)}</span></div>
|
||||||
|
<div class="flow-msg">Die Datei ist auf dem Drucker. Bis der Druck läuft, kann es etwa eine Minute dauern — der Auftrag bleibt hier.</div>
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
// Nur Ablauf braucht den Strip (Schritte). Einfacher Queue-Druck: Karte zeigt schon DRUCKT.
|
||||||
|
if (job && job.is_ablauf) {
|
||||||
const rep = repeatText(job);
|
const rep = repeatText(job);
|
||||||
const title = job.is_ablauf ? "Ablauf" + (rep ? " " + rep : "") : "Aus der Warteschlange";
|
const title = "Ablauf" + (rep ? " " + rep : "");
|
||||||
bits.push(`<div class="strip flow">
|
bits.push(`<div class="strip flow">
|
||||||
<div class="flow-head">${icon("queue")}<span class="grow">${escapeHtml(title)}</span>
|
<div class="flow-head">${icon("queue")}<span class="grow">${escapeHtml(title)}</span>
|
||||||
<button type="button" class="icon-btn danger" data-cancel-run="${job.id}" title="Ablauf abbrechen">${icon("x")}</button></div>
|
<button type="button" class="icon-btn danger" data-cancel-run="${job.id}" title="Ablauf abbrechen">${icon("x")}</button></div>
|
||||||
${job.is_ablauf ? stepsHtml(job.step_labels, job.display_step_index != null ? job.display_step_index : job.step_index) : ""}
|
${stepsHtml(job.step_labels, job.display_step_index != null ? job.display_step_index : job.step_index)}
|
||||||
${job.message ? `<div class="flow-msg">${escapeHtml(phaseText(job))}</div>` : ""}
|
${job.message ? `<div class="flow-msg">${escapeHtml(phaseText(job))}</div>` : ""}
|
||||||
</div>`);
|
</div>`);
|
||||||
}
|
}
|
||||||
@@ -358,10 +371,15 @@ function stripsHtml(printer, state) {
|
|||||||
}
|
}
|
||||||
const filamentHint = state === "paused" ? filamentPauseHint(printer) : "";
|
const filamentHint = state === "paused" ? filamentPauseHint(printer) : "";
|
||||||
if (filamentHint) {
|
if (filamentHint) {
|
||||||
|
const slot = (printer.slots || []).find((s) => s && (s.empty || s.runout)) || (printer.slots || [])[0];
|
||||||
|
const slotN = slot && (slot.index != null ? Number(slot.index) + 1 : slot.slot);
|
||||||
|
const matHref = "/material?host=" + encodeURIComponent(printer.host || "")
|
||||||
|
+ (slotN ? "&slot=" + encodeURIComponent(slotN) : "");
|
||||||
|
const slotTxt = slotN ? "ACE Platz " + slotN + ": " : "";
|
||||||
bits.push(`<div class="strip warn filament-swap">
|
bits.push(`<div class="strip warn filament-swap">
|
||||||
<div class="strip-head">${icon("alert")}<span class="grow"><b>Neue Rolle in Material dem Drucker zuweisen</b></span></div>
|
<div class="strip-head">${icon("alert")}<span class="grow"><b>${slotTxt}Neue Rolle in Material zuweisen</b></span></div>
|
||||||
<span class="strip-msg">${escapeHtml(filamentHint)} — sonst zählt der Verbrauch weiter zur alten Spule. Ort = dieser Drucker, speichern, dann Weiter.</span>
|
<span class="strip-msg">${escapeHtml(filamentHint)} — 1) Rolle dem Drucker zuweisen, 2) speichern, 3) Weiter. Verbrauch bleibt dieselbe Charge.</span>
|
||||||
<div class="strip-actions"><a class="ghost" href="/material">Zu Material</a></div>
|
<div class="strip-actions"><a class="ghost" href="${matHref}">Zu Material</a></div>
|
||||||
</div>`);
|
</div>`);
|
||||||
}
|
}
|
||||||
const failed = !queueBusy && printer.queue_failed;
|
const failed = !queueBusy && printer.queue_failed;
|
||||||
@@ -510,6 +528,16 @@ function steerPanelHtml(printer) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startingJob(printer) {
|
||||||
|
const job = printer && printer.queue_running;
|
||||||
|
if (!job || job.is_ablauf) return null;
|
||||||
|
const phase = String(job.phase || "").toLowerCase();
|
||||||
|
if (phase !== "starting" && phase !== "await_print") return null;
|
||||||
|
const state = shownState(printer);
|
||||||
|
if (state === "printing" || state === "paused" || state === "heating") return null;
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
function heroHtml(printer, state) {
|
function heroHtml(printer, state) {
|
||||||
const remain = hours(printer.remain);
|
const remain = hours(printer.remain);
|
||||||
const eta = finishClock(printer.remain);
|
const eta = finishClock(printer.remain);
|
||||||
@@ -531,6 +559,11 @@ function heroHtml(printer, state) {
|
|||||||
if (state === "offline") {
|
if (state === "offline") {
|
||||||
return `<div class="hero idle"><span class="hero-main">nicht erreichbar</span></div>`;
|
return `<div class="hero idle"><span class="hero-main">nicht erreichbar</span></div>`;
|
||||||
}
|
}
|
||||||
|
const pending = startingJob(printer);
|
||||||
|
if (pending) {
|
||||||
|
const name = jobName(pending.filename || "") || "Datei";
|
||||||
|
return `<div class="hero"><span class="hero-main"><small>startet</small>${escapeHtml(name)}</span><span class="hero-side">kann eine Minute dauern</span></div>`;
|
||||||
|
}
|
||||||
if (state === "complete") {
|
if (state === "complete") {
|
||||||
const took = hours(printer.elapsed);
|
const took = hours(printer.elapsed);
|
||||||
const last = jobLabel(printer.filename || "");
|
const last = jobLabel(printer.filename || "");
|
||||||
@@ -540,7 +573,7 @@ function heroHtml(printer, state) {
|
|||||||
if (state === "cancelled") {
|
if (state === "cancelled") {
|
||||||
return `<div class="hero idle"><span class="hero-main">abgebrochen</span></div>`;
|
return `<div class="hero idle"><span class="hero-main">abgebrochen</span></div>`;
|
||||||
}
|
}
|
||||||
const busyQueue = printer.queue_running ? "Ablauf läuft" : "";
|
const busyQueue = printer.queue_running && printer.queue_running.is_ablauf ? "Ablauf läuft" : "";
|
||||||
return `<div class="hero idle"><span class="hero-main">${needsClear(printer) ? "Platte belegt" : "bereit"}</span>${busyQueue ? `<span class="hero-side">${busyQueue}</span>` : ""}</div>`;
|
return `<div class="hero idle"><span class="hero-main">${needsClear(printer) ? "Platte belegt" : "bereit"}</span>${busyQueue ? `<span class="hero-side">${busyQueue}</span>` : ""}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -808,13 +841,15 @@ function patchCard(article, printer) {
|
|||||||
else runningJobs.delete(printer.host);
|
else runningJobs.delete(printer.host);
|
||||||
|
|
||||||
const role = (name) => article.querySelector(`[data-role=${name}]`);
|
const role = (name) => article.querySelector(`[data-role=${name}]`);
|
||||||
setHtml(role("chip"), chipHtml(state, labels[state]));
|
const pendingStart = startingJob(printer);
|
||||||
|
setHtml(role("chip"), chipHtml(pendingStart ? "heating" : state, pendingStart ? "startet" : labels[state]));
|
||||||
setHtml(role("hero"), heroHtml(printer, state));
|
setHtml(role("hero"), heroHtml(printer, state));
|
||||||
patchBar(role("bar"), printer, state);
|
patchBar(role("bar"), printer, state);
|
||||||
setHtml(role("stats"), statsHtml(printer, state));
|
setHtml(role("stats"), statsHtml(printer, state));
|
||||||
setHtml(role("temps"), tempbarsHtml(printer, state));
|
setHtml(role("temps"), tempbarsHtml(printer, state));
|
||||||
const jobline = role("jobline");
|
const jobline = role("jobline");
|
||||||
const jobVisible = jobLineVisible(printer, state, shownFile);
|
const startName = pendingStart ? (pendingStart.filename || "") : "";
|
||||||
|
const jobVisible = jobLineVisible(printer, state, shownFile || startName) || !!pendingStart;
|
||||||
const hasThumb = !!(jobVisible && printer.preview);
|
const hasThumb = !!(jobVisible && printer.preview);
|
||||||
if (jobline) {
|
if (jobline) {
|
||||||
jobline.hidden = !jobVisible;
|
jobline.hidden = !jobVisible;
|
||||||
@@ -846,8 +881,8 @@ function patchCard(article, printer) {
|
|||||||
}
|
}
|
||||||
const job = role("job");
|
const job = role("job");
|
||||||
if (job) {
|
if (job) {
|
||||||
setText(job, jobName(shownFile) || "");
|
setText(job, jobName(shownFile || startName) || "");
|
||||||
job.title = shownFile || "";
|
job.title = shownFile || startName || "";
|
||||||
}
|
}
|
||||||
setHtml(role("swatches"), swatchesHtml(printer));
|
setHtml(role("swatches"), swatchesHtml(printer));
|
||||||
setHtml(role("flow"), flowHtml(printer));
|
setHtml(role("flow"), flowHtml(printer));
|
||||||
@@ -975,7 +1010,8 @@ function patchDesk(printer) {
|
|||||||
const nameEl = role("name");
|
const nameEl = role("name");
|
||||||
if (nameEl) nameEl.textContent = printer.name || printer.host;
|
if (nameEl) nameEl.textContent = printer.name || printer.host;
|
||||||
setHtml(role("badge"), printer.family_label ? `<span class="badge">${escapeHtml(printer.family_label)}</span>` : "");
|
setHtml(role("badge"), printer.family_label ? `<span class="badge">${escapeHtml(printer.family_label)}</span>` : "");
|
||||||
setHtml(role("chip"), chipHtml(state, labels[state]));
|
const pendingStart = startingJob(printer);
|
||||||
|
setHtml(role("chip"), chipHtml(pendingStart ? "heating" : state, pendingStart ? "startet" : labels[state]));
|
||||||
setHtml(role("pills"), pillsHtml(printer.host));
|
setHtml(role("pills"), pillsHtml(printer.host));
|
||||||
const mainsail = role("mainsail");
|
const mainsail = role("mainsail");
|
||||||
if (mainsail) {
|
if (mainsail) {
|
||||||
@@ -988,12 +1024,13 @@ function patchDesk(printer) {
|
|||||||
setHtml(role("temps"), tempbarsHtml(printer, state));
|
setHtml(role("temps"), tempbarsHtml(printer, state));
|
||||||
|
|
||||||
const jobline = role("jobline");
|
const jobline = role("jobline");
|
||||||
const jobVisible = jobLineVisible(printer, state, shownFile);
|
const startName = pendingStart ? (pendingStart.filename || "") : "";
|
||||||
|
const jobVisible = jobLineVisible(printer, state, shownFile || startName) || !!pendingStart;
|
||||||
if (jobline) jobline.hidden = !jobVisible && !printer.preview;
|
if (jobline) jobline.hidden = !jobVisible && !printer.preview;
|
||||||
const job = role("job");
|
const job = role("job");
|
||||||
if (job) {
|
if (job) {
|
||||||
setText(job, jobName(shownFile) || (jobVisible ? "" : "Kein Auftrag"));
|
setText(job, jobName(shownFile || startName) || (jobVisible ? "" : "Kein Auftrag"));
|
||||||
job.title = shownFile || "";
|
job.title = shownFile || startName || "";
|
||||||
}
|
}
|
||||||
const preview = role("preview");
|
const preview = role("preview");
|
||||||
if (preview) {
|
if (preview) {
|
||||||
@@ -1037,6 +1074,156 @@ 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);
|
refreshDeskVerlauf(false);
|
||||||
|
refreshDeskNext(printer, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
let deskNextAt = 0;
|
||||||
|
let deskNextHost = "";
|
||||||
|
let deskNextKey = "";
|
||||||
|
|
||||||
|
function deskCanOfferStart(printer, state) {
|
||||||
|
if (!printer) return false;
|
||||||
|
if (["printing", "paused", "heating", "offline", "error"].includes(state)) return false;
|
||||||
|
if (printer.queue_running || startingJob(printer)) return false;
|
||||||
|
if (needsClear(printer)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deskMaterialTypes(printer) {
|
||||||
|
const types = new Set();
|
||||||
|
(printer.spools || []).forEach((s) => {
|
||||||
|
if (s && s.material_type) types.add(String(s.material_type).toLowerCase());
|
||||||
|
});
|
||||||
|
(printer.ace_slots || printer.slots || []).forEach((s) => {
|
||||||
|
const t = (s && (s.type || s.material_type)) || "";
|
||||||
|
if (t) types.add(String(t).toLowerCase());
|
||||||
|
});
|
||||||
|
return types;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileMatchesPrinter(file, printer) {
|
||||||
|
if (!file) return false;
|
||||||
|
const family = printer.family || "";
|
||||||
|
if (file.printer_family && family && file.printer_family !== family) return false;
|
||||||
|
const types = deskMaterialTypes(printer);
|
||||||
|
if (!types.size) return true;
|
||||||
|
const mats = (file.filaments || []).map((f) => String(f.type || f.material_type || "").toLowerCase()).filter(Boolean);
|
||||||
|
if (!mats.length) return true;
|
||||||
|
return mats.some((m) => [...types].some((t) => t.includes(m) || m.includes(t)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshDeskNext(printer, state, force) {
|
||||||
|
const box = deskEl.querySelector('[data-role="next"]');
|
||||||
|
const body = deskEl.querySelector('[data-role="next-body"]');
|
||||||
|
if (!box || !body) return;
|
||||||
|
if (!deskCanOfferStart(printer, state)) {
|
||||||
|
box.hidden = true;
|
||||||
|
body.innerHTML = "";
|
||||||
|
deskNextKey = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const host = printer.host;
|
||||||
|
const key = host + "|" + (printer.queue_waiting || 0) + "|" + (printer.filename || "");
|
||||||
|
const now = Date.now();
|
||||||
|
if (!force && deskNextHost === host && deskNextKey === key && now - deskNextAt < 25000) {
|
||||||
|
box.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deskNextAt = now;
|
||||||
|
deskNextHost = host;
|
||||||
|
deskNextKey = key;
|
||||||
|
box.hidden = false;
|
||||||
|
body.innerHTML = '<p class="tiny">Lade Vorschläge …</p>';
|
||||||
|
try {
|
||||||
|
const [last, queue, files] = await Promise.all([
|
||||||
|
api("/senden-zuletzt?host=" + encodeURIComponent(host)).catch(() => null),
|
||||||
|
api("/druck-queue?host=" + encodeURIComponent(host) + "&status=waiting&limit=5").catch(() => []),
|
||||||
|
api("/dateien?family=" + encodeURIComponent(printer.family || "") + "&limit=40").catch(() => []),
|
||||||
|
]);
|
||||||
|
if (deskHost !== host) return;
|
||||||
|
const bits = [];
|
||||||
|
if (last && last.file_id) {
|
||||||
|
const name = jobLabel(last.filename) || ("#" + last.file_id);
|
||||||
|
bits.push(`<div class="desk-next-row">
|
||||||
|
<div class="grow"><b>Nochmal</b><span class="tiny">${escapeHtml(name)}</span></div>
|
||||||
|
<button type="button" class="btn-primary tiny-btn" data-next-again="${last.file_id}">Start</button>
|
||||||
|
<a class="ghost tiny-btn" href="/dateien?host=${encodeURIComponent(host)}&file=${encodeURIComponent(last.file_id)}">Anpassen</a>
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
const waiting = (Array.isArray(queue) ? queue : []).filter((j) => j.status === "waiting");
|
||||||
|
if (waiting.length) {
|
||||||
|
const job = waiting[0];
|
||||||
|
bits.push(`<div class="desk-next-row">
|
||||||
|
<div class="grow"><b>Warteschlange</b><span class="tiny">${escapeHtml(jobLabel(job.filename) || "Job")}</span></div>
|
||||||
|
<a class="ghost tiny-btn" href="/warteschlange">Zur Queue</a>
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
const matched = (Array.isArray(files) ? files : [])
|
||||||
|
.filter((f) => fileMatchesPrinter(f, printer))
|
||||||
|
.slice(0, 5);
|
||||||
|
if (matched.length) {
|
||||||
|
bits.push('<div class="desk-next-files"><span class="tiny">Passende Dateien</span>' + matched.map((f) => {
|
||||||
|
const mats = [...new Set((f.filaments || []).map((x) => x.type || x.material_type).filter(Boolean))].join(" / ");
|
||||||
|
return `<div class="desk-next-row">
|
||||||
|
<div class="grow"><span>${escapeHtml(jobLabel(f.name) || f.name)}</span><span class="tiny">${escapeHtml(mats || "")}</span></div>
|
||||||
|
<button type="button" class="ghost tiny-btn" data-next-start="${f.id}">Start</button>
|
||||||
|
<a class="ghost tiny-btn" href="/dateien?host=${encodeURIComponent(host)}&file=${encodeURIComponent(f.id)}">Anpassen</a>
|
||||||
|
</div>`;
|
||||||
|
}).join("") + "</div>");
|
||||||
|
}
|
||||||
|
if (!bits.length) {
|
||||||
|
body.innerHTML = `<p class="tiny">Keine Vorschläge. <a href="/dateien?host=${encodeURIComponent(host)}">Datei wählen →</a></p>`;
|
||||||
|
} else {
|
||||||
|
body.innerHTML = bits.join("");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (deskHost !== host) return;
|
||||||
|
body.innerHTML = '<p class="tiny msg">' + escapeHtml(error.message || "Vorschläge fehlen") + "</p>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deskStartFile(fileId, fromLast) {
|
||||||
|
if (!deskHost || !fileId) return;
|
||||||
|
const host = deskHost;
|
||||||
|
let aceMap = [];
|
||||||
|
let bedLevel = false;
|
||||||
|
let spoolId = null;
|
||||||
|
try {
|
||||||
|
const last = await api(
|
||||||
|
"/senden-zuletzt?host=" + encodeURIComponent(host)
|
||||||
|
+ "&file_id=" + encodeURIComponent(fileId)
|
||||||
|
);
|
||||||
|
if (last) {
|
||||||
|
aceMap = last.ace_map || [];
|
||||||
|
bedLevel = !!last.bed_leveling;
|
||||||
|
spoolId = last.spool_id || null;
|
||||||
|
}
|
||||||
|
} catch (error) { /* Defaults */ }
|
||||||
|
if (fromLast && !aceMap.length) {
|
||||||
|
try {
|
||||||
|
const lastHost = await api("/senden-zuletzt?host=" + encodeURIComponent(host));
|
||||||
|
if (lastHost && String(lastHost.file_id) === String(fileId)) {
|
||||||
|
aceMap = lastHost.ace_map || [];
|
||||||
|
bedLevel = !!lastHost.bed_leveling;
|
||||||
|
spoolId = lastHost.spool_id || null;
|
||||||
|
}
|
||||||
|
} catch (error) { /* ignore */ }
|
||||||
|
}
|
||||||
|
const printer = printerCache.get(host);
|
||||||
|
const canNow = printer && deskCanOfferStart(printer, shownState(printer));
|
||||||
|
const payload = {
|
||||||
|
file_id: Number(fileId),
|
||||||
|
hosts: [host],
|
||||||
|
confirm: true,
|
||||||
|
when_free: !canNow,
|
||||||
|
bed_leveling: bedLevel,
|
||||||
|
};
|
||||||
|
if (spoolId) payload.spool_id = spoolId;
|
||||||
|
if (aceMap.length) payload.ace_maps = { [host]: aceMap };
|
||||||
|
await api("/senden", { method: "POST", json: payload });
|
||||||
|
toast(canNow ? "Druck startet…" : "In die Warteschlange.", "good");
|
||||||
|
deskNextAt = 0;
|
||||||
|
if (printer) refreshDeskNext(printer, shownState(printer), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshDeskVerlauf(force) {
|
async function refreshDeskVerlauf(force) {
|
||||||
@@ -1074,8 +1261,12 @@ async function refreshDeskVerlauf(force) {
|
|||||||
kwh,
|
kwh,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
if (caption) caption.textContent = bits.join(" · ");
|
if (caption) caption.textContent = bits.join(" · ");
|
||||||
|
const fileBit = run.file_id
|
||||||
|
? `<a class="ghost tiny-btn" href="/dateien?file=${encodeURIComponent(run.file_id)}">Datei</a>`
|
||||||
|
: "";
|
||||||
const head = `<div class="desk-verlauf-head">
|
const head = `<div class="desk-verlauf-head">
|
||||||
<span class="tiny grow">${escapeHtml(jobLabel(run.filename) || "Druck")}${run.chargen_nr ? " · " + escapeHtml(run.chargen_nr) : ""}</span>
|
<span class="tiny grow">${escapeHtml(jobLabel(run.filename) || "Druck")}${run.chargen_nr ? " · " + escapeHtml(run.chargen_nr) : ""}</span>
|
||||||
|
${fileBit}
|
||||||
<a class="ghost tiny-btn" href="/protokoll/${run.id}">Bericht →</a>
|
<a class="ghost tiny-btn" href="/protokoll/${run.id}">Bericht →</a>
|
||||||
</div>`;
|
</div>`;
|
||||||
const html = head + tempChartHtml(data.samples || []);
|
const html = head + tempChartHtml(data.samples || []);
|
||||||
@@ -1389,7 +1580,7 @@ function sortPrinters(printers) {
|
|||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
if (!libraryFiles.length) {
|
if (!libraryFiles.length) {
|
||||||
try { libraryFiles = await api("/dateien"); } catch (error) { libraryFiles = []; }
|
api("/dateien").then((rows) => { libraryFiles = rows || []; }).catch(() => {});
|
||||||
}
|
}
|
||||||
if (!wallSpools.length || deskHost) {
|
if (!wallSpools.length || deskHost) {
|
||||||
try { wallSpools = await api("/spulen"); } catch (error) { /* behalten */ }
|
try { wallSpools = await api("/spulen"); } catch (error) { /* behalten */ }
|
||||||
@@ -1487,6 +1678,34 @@ window.addEventListener("keydown", (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function handleWallClick(event) {
|
async function handleWallClick(event) {
|
||||||
|
const nextAgain = event.target.closest("[data-next-again]");
|
||||||
|
if (nextAgain) {
|
||||||
|
armButton(nextAgain, "Nochmal starten?", async () => {
|
||||||
|
nextAgain.disabled = true;
|
||||||
|
try {
|
||||||
|
await deskStartFile(nextAgain.dataset.nextAgain, true);
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message || String(error), "bad");
|
||||||
|
} finally {
|
||||||
|
nextAgain.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextStart = event.target.closest("[data-next-start]");
|
||||||
|
if (nextStart) {
|
||||||
|
armButton(nextStart, "Starten?", async () => {
|
||||||
|
nextStart.disabled = true;
|
||||||
|
try {
|
||||||
|
await deskStartFile(nextStart.dataset.nextStart, false);
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message || String(error), "bad");
|
||||||
|
} finally {
|
||||||
|
nextStart.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const pill = event.target.closest("[data-desk-host]");
|
const pill = event.target.closest("[data-desk-host]");
|
||||||
if (pill) {
|
if (pill) {
|
||||||
openDesk(pill.dataset.deskHost);
|
openDesk(pill.dataset.deskHost);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody id="q-rows"></tbody>
|
<tbody id="q-rows"></tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<div class="pager" id="q-pager" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block script %}
|
{% block script %}
|
||||||
@@ -45,6 +46,7 @@ const STATUS_LABEL = {
|
|||||||
let jobs = [];
|
let jobs = [];
|
||||||
let liveDirect = [];
|
let liveDirect = [];
|
||||||
let filter = "";
|
let filter = "";
|
||||||
|
let qPage = 1;
|
||||||
|
|
||||||
function targetOf(job) {
|
function targetOf(job) {
|
||||||
return job.printer_name || job.host || (job.family_label ? "nächster " + job.family_label : "nächster freier");
|
return job.printer_name || job.host || (job.family_label ? "nächster " + job.family_label : "nächster freier");
|
||||||
@@ -86,6 +88,7 @@ function render() {
|
|||||||
});
|
});
|
||||||
const body = document.getElementById("q-rows");
|
const body = document.getElementById("q-rows");
|
||||||
const shown = shownJobs();
|
const shown = shownJobs();
|
||||||
|
const pager = document.getElementById("q-pager");
|
||||||
if (!shown.length) {
|
if (!shown.length) {
|
||||||
const hint = jobs.length
|
const hint = jobs.length
|
||||||
? "Nichts in dieser Ansicht."
|
? "Nichts in dieser Ansicht."
|
||||||
@@ -97,15 +100,18 @@ function render() {
|
|||||||
: "";
|
: "";
|
||||||
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("queue") + '<p>' + hint + '</p>' + cta + '</div></td></tr>';
|
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("queue") + '<p>' + hint + '</p>' + cta + '</div></td></tr>';
|
||||||
document.getElementById("q-note").textContent = "";
|
document.getElementById("q-note").textContent = "";
|
||||||
|
renderPager(pager, null, () => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const waiting = jobs.filter((j) => j.status === "waiting");
|
const waiting = jobs.filter((j) => j.status === "waiting");
|
||||||
body.innerHTML = shown.map((job) => {
|
const paged = pageSlice(shown, qPage, DEFAULT_PAGE_SIZE);
|
||||||
|
qPage = paged.page;
|
||||||
|
body.innerHTML = paged.items.map((job) => {
|
||||||
const st = job.status;
|
const st = job.status;
|
||||||
if (job.direct) {
|
if (job.direct) {
|
||||||
return `<tr data-direct="${escapeHtml(job.host)}">
|
return `<tr data-direct="${escapeHtml(job.host)}">
|
||||||
<td>${chipHtml("printing", "läuft")}</td>
|
<td>${chipHtml("printing", "läuft")}</td>
|
||||||
<td><span class="cell-file" title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</span></td>
|
<td>${job.file_id ? `<a class="cell-file" href="/dateien?file=${encodeURIComponent(job.file_id)}" title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</a>` : `<span class="cell-file" title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</span>`}</td>
|
||||||
<td><a href="/#${encodeURIComponent(job.host)}">${escapeHtml(targetOf(job))}</a></td>
|
<td><a href="/#${encodeURIComponent(job.host)}">${escapeHtml(targetOf(job))}</a></td>
|
||||||
<td>—</td>
|
<td>—</td>
|
||||||
<td class="tiny">${escapeHtml(job.message || "")}</td>
|
<td class="tiny">${escapeHtml(job.message || "")}</td>
|
||||||
@@ -119,21 +125,32 @@ function render() {
|
|||||||
const rep = repeatText(job);
|
const rep = repeatText(job);
|
||||||
const tools = [];
|
const tools = [];
|
||||||
if (st === "waiting" && waitIdx > 0) {
|
if (st === "waiting" && waitIdx > 0) {
|
||||||
tools.push(`<button type="button" class="icon-btn" data-prio="${job.id}" data-dir="-1" title="Höher">${icon("up")}</button>`);
|
tools.push(`<button type="button" class="icon-btn" data-prio="${job.id}" data-dir="-1" title="Höher" aria-label="Höher">${icon("up")}</button>`);
|
||||||
}
|
}
|
||||||
if (st === "waiting" && waitIdx >= 0 && waitIdx < waiting.length - 1) {
|
if (st === "waiting" && waitIdx >= 0 && waitIdx < waiting.length - 1) {
|
||||||
tools.push(`<button type="button" class="icon-btn" data-prio="${job.id}" data-dir="1" title="Tiefer">${icon("down")}</button>`);
|
tools.push(`<button type="button" class="icon-btn" data-prio="${job.id}" data-dir="1" title="Tiefer" aria-label="Tiefer">${icon("down")}</button>`);
|
||||||
}
|
}
|
||||||
if (st === "error" && job.is_ablauf) {
|
if (st === "error" && job.is_ablauf) {
|
||||||
tools.push(`<button type="button" class="icon-btn" data-resume="${job.id}" title="Fortsetzen">${icon("play")}</button>`);
|
tools.push(`<button type="button" class="ghost tiny-btn with-label" data-resume="${job.id}" title="Fortsetzen" aria-label="Fortsetzen">${icon("play")}<span class="act-label">Fortsetzen</span></button>`);
|
||||||
tools.push(`<button type="button" class="icon-btn danger" data-dismiss="${job.id}" title="Verwerfen">${icon("x")}</button>`);
|
tools.push(`<button type="button" class="ghost tiny-btn with-label danger" data-dismiss="${job.id}" title="Verwerfen" aria-label="Verwerfen">${icon("x")}<span class="act-label">Verwerfen</span></button>`);
|
||||||
|
} else if (st === "error") {
|
||||||
|
if (job.host) {
|
||||||
|
tools.push(`<a class="ghost tiny-btn with-label" href="/#${encodeURIComponent(job.host)}" title="Zur Wand" aria-label="Zur Wand">${icon("printer")}<span class="act-label">Wand</span></a>`);
|
||||||
|
}
|
||||||
|
if (job.file_id) {
|
||||||
|
tools.push(`<button type="button" class="ghost tiny-btn with-label" data-requeue="${job.id}" title="Erneut einreihen" aria-label="Erneut einreihen">${icon("refresh")}<span class="act-label">Nochmal</span></button>`);
|
||||||
|
}
|
||||||
|
tools.push(`<button type="button" class="ghost tiny-btn with-label danger" data-dismiss="${job.id}" title="Verwerfen" aria-label="Verwerfen">${icon("x")}<span class="act-label">Verwerfen</span></button>`);
|
||||||
} else if (st === "waiting" || st === "running") {
|
} else if (st === "waiting" || st === "running") {
|
||||||
tools.push(`<button type="button" class="icon-btn danger" data-cancel="${job.id}" title="Abbrechen">${icon("x")}</button>`);
|
tools.push(`<button type="button" class="ghost tiny-btn with-label danger" data-cancel="${job.id}" title="Abbrechen" aria-label="Abbrechen">${icon("x")}<span class="act-label">Abbrechen</span></button>`);
|
||||||
}
|
}
|
||||||
const chipState = st === "running" ? "printing" : st === "waiting" ? "waiting" : st === "error" ? "error" : st === "done" ? "done" : st === "cancelled" || st === "dismissed" ? "dismissed" : "complete";
|
const phase = String(job.phase || "").toLowerCase();
|
||||||
|
const starting = st === "running" && (phase === "starting" || phase === "await_print");
|
||||||
|
const chipState = starting ? "heating" : st === "running" ? "printing" : st === "waiting" ? "waiting" : st === "error" ? "error" : st === "done" ? "done" : st === "cancelled" || st === "dismissed" ? "dismissed" : "complete";
|
||||||
|
const chipLabel = starting ? "startet" : (STATUS_LABEL[st] || st);
|
||||||
return `<tr data-id="${job.id}">
|
return `<tr data-id="${job.id}">
|
||||||
<td>${chipHtml(chipState, STATUS_LABEL[st] || st)}</td>
|
<td>${chipHtml(chipState, chipLabel)}</td>
|
||||||
<td><span class="cell-file" title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</span>${rep ? " · " + escapeHtml(rep) : ""}</td>
|
<td>${job.file_id ? `<a class="cell-file" href="/dateien?file=${encodeURIComponent(job.file_id)}" title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</a>` : `<span class="cell-file" title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</span>`}${rep ? " · " + escapeHtml(rep) : ""}</td>
|
||||||
<td>${job.host ? `<a href="/#${encodeURIComponent(job.host)}">${escapeHtml(targetOf(job))}</a>` : escapeHtml(targetOf(job))}</td>
|
<td>${job.host ? `<a href="/#${encodeURIComponent(job.host)}">${escapeHtml(targetOf(job))}</a>` : escapeHtml(targetOf(job))}</td>
|
||||||
<td>${steps || "—"}</td>
|
<td>${steps || "—"}</td>
|
||||||
<td class="tiny">${escapeHtml(job.message || "")}</td>
|
<td class="tiny">${escapeHtml(job.message || "")}</td>
|
||||||
@@ -141,17 +158,22 @@ function render() {
|
|||||||
</tr>`;
|
</tr>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
document.getElementById("q-note").textContent = shown.length === 1 ? "1 Eintrag" : shown.length + " Einträge";
|
document.getElementById("q-note").textContent = shown.length === 1 ? "1 Eintrag" : shown.length + " Einträge";
|
||||||
|
renderPager(pager, paged, (p) => {
|
||||||
|
qPage = p;
|
||||||
|
render();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const [queue, active] = await Promise.all([
|
const [queue, active] = await Promise.all([
|
||||||
api("/druck-queue?limit=120"),
|
api("/druck-queue?limit=200"),
|
||||||
api("/laeuft").catch(() => []),
|
api("/laeuft").catch(() => []),
|
||||||
]);
|
]);
|
||||||
jobs = queue || [];
|
jobs = queue || [];
|
||||||
const queuedHosts = new Set(jobs.filter((j) => j.status === "running").map((j) => j.host));
|
const queuedHosts = new Set(jobs.filter((j) => j.status === "running").map((j) => j.host));
|
||||||
liveDirect = (active || []).filter((p) => p.host && !queuedHosts.has(p.host));
|
liveDirect = (active || []).filter((p) => p.host && !queuedHosts.has(p.host));
|
||||||
|
qPage = 1;
|
||||||
render();
|
render();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
document.getElementById("q-rows").innerHTML =
|
document.getElementById("q-rows").innerHTML =
|
||||||
@@ -163,6 +185,7 @@ document.getElementById("q-filter").addEventListener("click", (event) => {
|
|||||||
const btn = event.target.closest("[data-status]");
|
const btn = event.target.closest("[data-status]");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
filter = btn.dataset.status || "";
|
filter = btn.dataset.status || "";
|
||||||
|
qPage = 1;
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
document.getElementById("q-refresh").addEventListener("click", load);
|
document.getElementById("q-refresh").addEventListener("click", load);
|
||||||
@@ -202,6 +225,38 @@ document.getElementById("q-rows").addEventListener("click", async (event) => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const requeue = event.target.closest("[data-requeue]");
|
||||||
|
if (requeue) {
|
||||||
|
const job = jobs.find((j) => j.id === Number(requeue.dataset.requeue));
|
||||||
|
if (!job || !job.file_id) return;
|
||||||
|
armButton(requeue, "Erneut einreihen?", async () => {
|
||||||
|
try {
|
||||||
|
await api("/druck-queue/" + job.id, {
|
||||||
|
method: "PATCH", json: { status: "waiting", undo: true },
|
||||||
|
});
|
||||||
|
toast("Wieder in der Warteschlange.");
|
||||||
|
load();
|
||||||
|
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
await api("/senden", {
|
||||||
|
method: "POST",
|
||||||
|
json: {
|
||||||
|
file_id: job.file_id,
|
||||||
|
hosts: job.host ? [job.host] : [],
|
||||||
|
confirm: true,
|
||||||
|
when_free: true,
|
||||||
|
ace_maps: job.host && (job.ace_map || []).length ? { [job.host]: job.ace_map } : {},
|
||||||
|
bed_leveling: !!job.bed_leveling,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast("Erneut eingereiht.");
|
||||||
|
load();
|
||||||
|
} catch (err2) { toast(err2.message, "bad"); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const prio = event.target.closest("[data-prio]");
|
const prio = event.target.closest("[data-prio]");
|
||||||
if (prio) {
|
if (prio) {
|
||||||
const id = Number(prio.dataset.prio);
|
const id = Number(prio.dataset.prio);
|
||||||
|
|||||||
Reference in new issue
Block a user