Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,46 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [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 +52,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,76 +1,121 @@
|
|||||||
# 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.5.2-e85d04?style=flat-square" alt="Version 3.5.2"></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_…`.
|
||||||
|
|
||||||
| Methode und Pfad | Zweck |
|
| Methode und Pfad | Zweck |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -78,43 +123,33 @@ Alles unter `/api/v1`, JSON. Auth: Session-Cookie (UI) oder `Authorization: Bear
|
|||||||
| `GET /me`, `/users`, `/tokens` | Konto, Benutzer (Admin), API-Tokens |
|
| `GET /me`, `/users`, `/tokens` | Konto, Benutzer (Admin), API-Tokens |
|
||||||
| `GET /version` | Version |
|
| `GET /version` | Version |
|
||||||
| `GET`/`PATCH /einstellungen`, `POST /telegram/test` | Config und Telegram-Probe |
|
| `GET`/`PATCH /einstellungen`, `POST /telegram/test` | Config und Telegram-Probe |
|
||||||
| `GET /drucker?live=1` | Drucker mit Live-Zustand, laufendem und wartendem Auftrag |
|
| `GET /drucker?live=1` | Drucker mit Live-Zustand |
|
||||||
| `POST /drucker` · `PATCH`/`DELETE /drucker/<id>` | Drucker anlegen/ändern (Admin) |
|
|
||||||
| `POST /drucker/<host>/<aktion>` | Pause, Fortsetzen, Abbrechen, Fehler löschen |
|
| `POST /drucker/<host>/<aktion>` | Pause, Fortsetzen, Abbrechen, Fehler löschen |
|
||||||
| `POST /drucker/<host>/steuern` | Temperatur, Speed, Flow, Lüfter, ACE |
|
| `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 |
|
||||||
| `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 /spulen`, `POST /spulen/<id>/einlegen` | Material |
|
||||||
| `POST /senden`, `GET /sende-auftraege` | Sofort senden (`confirm`), Fortschritt |
|
| `GET /laeufe`, `/statistik`, `/ereignisse` | Protokoll und Aktivität |
|
||||||
| `GET`/`POST /druck-queue`, `PATCH /druck-queue/<id>` | Warteschlange (`confirm` beim Einreihen) |
|
|
||||||
| `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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -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. |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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).
|
||||||
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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -139,3 +139,8 @@ Ohne Map: Rinkhals füllt oft Identity T0→Platz0 … — deshalb Grotti bei ei
|
|||||||
- 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
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -3,3 +3,8 @@
|
|||||||
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 |
|
||||||
|
| --- | --- |
|
||||||
|
| [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 |
|
||||||
+24
-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:
|
||||||
@@ -121,9 +135,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 +155,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,7 +168,8 @@ 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),
|
||||||
|
|||||||
+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)
|
||||||
|
|||||||
+5
-1
@@ -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")),
|
||||||
})
|
})
|
||||||
|
|||||||
+2
-38
@@ -415,44 +415,8 @@ def sync_history(conn, cfg):
|
|||||||
|
|
||||||
|
|
||||||
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):
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
+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",
|
||||||
|
|||||||
+92
-15
@@ -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,7 +30,9 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -111,7 +115,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):
|
||||||
@@ -207,7 +211,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:
|
||||||
@@ -278,13 +282,15 @@ class PrintQueueDispatcher:
|
|||||||
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):
|
||||||
@@ -417,15 +423,19 @@ class PrintQueueDispatcher:
|
|||||||
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)
|
||||||
|
# await_print: Datei ist raus, Drucker meldet printing oft erst
|
||||||
|
# nach einer Minute. Nicht bei starting lassen — sonst gilt nach
|
||||||
|
# 45 s der Neustart-Retry und der zweite Start trifft „druckt schon“.
|
||||||
store.update_print_queue(
|
store.update_print_queue(
|
||||||
wconn,
|
wconn,
|
||||||
item_id,
|
item_id,
|
||||||
{
|
{
|
||||||
"status": "done",
|
"status": "running",
|
||||||
"message": "Gestartet auf %s: %s" % (printer_copy["name"], name),
|
"message": "Datei ist auf %s. Druckstart kann etwa eine Minute dauern."
|
||||||
|
% printer_copy["name"],
|
||||||
"host": host,
|
"host": host,
|
||||||
"phase": "",
|
"phase": "await_print",
|
||||||
"phase_started_at": None,
|
"phase_started_at": "now",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self._event(
|
self._event(
|
||||||
@@ -477,6 +487,73 @@ 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"):
|
||||||
|
store.update_print_queue(
|
||||||
|
conn,
|
||||||
|
item["id"],
|
||||||
|
{
|
||||||
|
"phase": "print_active",
|
||||||
|
"phase_started_at": "now",
|
||||||
|
"message": "Druckt…",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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"):
|
||||||
|
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
|
||||||
|
if state in FREE or state in ("complete", "cancelled"):
|
||||||
|
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 "",
|
||||||
|
)
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
+25
-22
@@ -2813,6 +2813,20 @@ def _file_needs(conn, file_id):
|
|||||||
return types, float(row.get("weight_g") or 0)
|
return types, float(row.get("weight_g") or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _prefer_color(spools, need_color):
|
||||||
|
"""Farbe soft: Slice-Hex ignorieren; benannte Farbe bevorzugen, nie blockieren."""
|
||||||
|
from grotti.bedarf import _is_slicer_hex
|
||||||
|
|
||||||
|
need_c = (need_color or "").strip().lower()
|
||||||
|
if not need_c or _is_slicer_hex(need_c) or not spools:
|
||||||
|
return list(spools)
|
||||||
|
preferred = [
|
||||||
|
h for h in spools
|
||||||
|
if not (h.get("color") or "").strip() or (h.get("color") or "").strip().lower() == need_c
|
||||||
|
]
|
||||||
|
return preferred or list(spools)
|
||||||
|
|
||||||
|
|
||||||
def _match_spools_for_need(need, have, used_ids=None):
|
def _match_spools_for_need(need, have, used_ids=None):
|
||||||
used = used_ids or set()
|
used = used_ids or set()
|
||||||
candidates = [h for h in have if h.get("spool_id") not in used]
|
candidates = [h for h in have if h.get("spool_id") not in used]
|
||||||
@@ -2822,33 +2836,32 @@ def _match_spools_for_need(need, have, used_ids=None):
|
|||||||
kind = (kind or "").strip().lower()
|
kind = (kind or "").strip().lower()
|
||||||
return [
|
return [
|
||||||
h for h in candidates
|
h for h in candidates
|
||||||
if (not kind or h["material_type"] == kind)
|
if not kind or h["material_type"] == kind
|
||||||
and (not need.get("color") or h["color"] == need["color"] or not h["color"])
|
|
||||||
]
|
]
|
||||||
|
|
||||||
if profile:
|
if profile:
|
||||||
by_profile = [h for h in candidates if _profile_matches(profile, h.get("material_name"))]
|
by_profile = [h for h in candidates if _profile_matches(profile, h.get("material_name"))]
|
||||||
if by_profile:
|
if by_profile:
|
||||||
colored = [
|
return _prefer_color(by_profile, need.get("color"))
|
||||||
h for h in by_profile
|
# Basis-Profil ohne Meta/Matte/Silk: Marke egal — Typ reicht
|
||||||
if not need.get("color") or h["color"] == need["color"] or not h["color"]
|
# (Sunlu PLA ↔ Eryone PLA Lite; Slice-Hex #26A69A blockiert nicht „Grau“).
|
||||||
]
|
|
||||||
return colored or by_profile
|
|
||||||
# Basis-Profil ohne Meta/Matte/Silk: Marke egal — Typ reicht (Sunlu PLA ↔ Eryone PLA).
|
|
||||||
# Spulen mit Variante im Namen bleiben draußen (kein Meta für „nur PLA“).
|
|
||||||
if not _profile_has_variant(profile):
|
if not _profile_has_variant(profile):
|
||||||
kind = _profile_base_type(profile) or (need.get("material_type") or "")
|
kind = _profile_base_type(profile) or (need.get("material_type") or "")
|
||||||
if kind:
|
if kind:
|
||||||
return [
|
typed = [
|
||||||
h for h in _by_type(kind)
|
h for h in _by_type(kind)
|
||||||
if not _profile_has_variant(h.get("material_name") or "")
|
if not _profile_has_variant(h.get("material_name") or "")
|
||||||
]
|
]
|
||||||
|
return _prefer_color(typed, need.get("color"))
|
||||||
return []
|
return []
|
||||||
return _by_type(need.get("material_type") or "")
|
return _prefer_color(_by_type(need.get("material_type") or ""), need.get("color"))
|
||||||
|
|
||||||
|
|
||||||
def material_fit_score(conn, host, file_id):
|
def material_fit_score(conn, host, file_id):
|
||||||
"""Höher = besser. -1 = warten (falsche Sorte mit Profil, oder zu wenig Rest)."""
|
"""Höher = besser. -1 = warten (falsche Sorte mit Profil).
|
||||||
|
|
||||||
|
Zu wenig Rest blockiert nicht; genug Rest gibt Zusatzpunkte.
|
||||||
|
"""
|
||||||
need, weight = _file_needs(conn, file_id)
|
need, weight = _file_needs(conn, file_id)
|
||||||
have = _spool_material_set(conn, host)
|
have = _spool_material_set(conn, host)
|
||||||
if not need:
|
if not need:
|
||||||
@@ -2873,11 +2886,6 @@ def material_fit_score(conn, host, file_id):
|
|||||||
score += 5
|
score += 5
|
||||||
if weight and best["remaining_g"] + 1e-6 >= weight:
|
if weight and best["remaining_g"] + 1e-6 >= weight:
|
||||||
score += 5
|
score += 5
|
||||||
elif weight and all(h["remaining_g"] + 1e-6 < weight for h in matched):
|
|
||||||
return -1, "braucht %s (%s g fehlen)" % (
|
|
||||||
_need_label(n),
|
|
||||||
int(max(0, weight - max(h["remaining_g"] for h in matched))),
|
|
||||||
)
|
|
||||||
return score, ""
|
return score, ""
|
||||||
|
|
||||||
have_types = {h["material_type"] for h in have if h["material_type"]}
|
have_types = {h["material_type"] for h in have if h["material_type"]}
|
||||||
@@ -2890,11 +2898,6 @@ def material_fit_score(conn, host, file_id):
|
|||||||
score += 5
|
score += 5
|
||||||
if weight and any(h["remaining_g"] + 1e-6 >= weight for h in matched):
|
if weight and any(h["remaining_g"] + 1e-6 >= weight for h in matched):
|
||||||
score += 5
|
score += 5
|
||||||
elif weight and all(h["remaining_g"] + 1e-6 < weight for h in matched):
|
|
||||||
return -1, "braucht %s (%s g fehlen)" % (
|
|
||||||
_need_label(n),
|
|
||||||
int(max(0, weight - max(h["remaining_g"] for h in matched))),
|
|
||||||
)
|
|
||||||
return score, ""
|
return score, ""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+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.5.2"
|
||||||
+10
-8
@@ -6,10 +6,12 @@ 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
|
||||||
|
|
||||||
@@ -59,7 +61,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):
|
||||||
@@ -118,7 +120,7 @@ class StateWatcher:
|
|||||||
|
|
||||||
jobs.apply_deduction(conn, closed, self.cfg["energy"])
|
jobs.apply_deduction(conn, closed, self.cfg["energy"])
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Watcher Abzug offline %s: %s" % (host, exc), flush=True)
|
log.warning("Watcher Abzug offline %s: %s", host, exc)
|
||||||
self._prev[host] = {"state": "offline", "offline": True, "filename": filename}
|
self._prev[host] = {"state": "offline", "offline": True, "filename": filename}
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -193,7 +195,7 @@ class StateWatcher:
|
|||||||
|
|
||||||
jobs.sync_history(conn, self.cfg)
|
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.
|
# History oft ohne Endzeit (interrupted) — Live trotzdem schließen.
|
||||||
dangling = store.find_live_run(conn, host)
|
dangling = store.find_live_run(conn, host)
|
||||||
if dangling:
|
if dangling:
|
||||||
@@ -204,7 +206,7 @@ class StateWatcher:
|
|||||||
|
|
||||||
jobs.apply_deduction(conn, closed, self.cfg["energy"])
|
jobs.apply_deduction(conn, closed, self.cfg["energy"])
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Watcher Abzug %s: %s" % (host, exc), flush=True)
|
log.warning("Watcher Abzug %s: %s", host, exc)
|
||||||
elif state in ("cancelled", "standby"):
|
elif state in ("cancelled", "standby"):
|
||||||
# Live-Lauf schließen: normaler Abbruch, oder nach Offline/Neustart
|
# Live-Lauf schließen: normaler Abbruch, oder nach Offline/Neustart
|
||||||
# (prev wird „offline“, der Übergang paused→standby geht sonst verloren).
|
# (prev wird „offline“, der Übergang paused→standby geht sonst verloren).
|
||||||
@@ -231,7 +233,7 @@ class StateWatcher:
|
|||||||
|
|
||||||
jobs.sync_history(conn, self.cfg)
|
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)
|
||||||
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")
|
||||||
@@ -254,7 +256,7 @@ class StateWatcher:
|
|||||||
|
|
||||||
jobs.sync_history(conn, self.cfg)
|
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)
|
||||||
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)
|
||||||
@@ -436,7 +438,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)
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
#!/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"
|
||||||
|
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,56 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"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,24 @@
|
|||||||
|
[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
|
||||||
|
# Logs → journald (journalctl -u grotti -f)
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=grotti
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -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,7 +110,7 @@ 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=16, channel_timeout=120)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -59,6 +59,35 @@ class MatchSpoolsTests(unittest.TestCase):
|
|||||||
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, {3}) # Eryone PLA; Meta/Matte nicht
|
||||||
|
|
||||||
|
def test_pla_lite_hex_matches_plain_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})
|
||||||
|
|
||||||
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}
|
||||||
matched = store._match_spools_for_need(need, self.have)
|
matched = store._match_spools_for_need(need, self.have)
|
||||||
|
|||||||
+12
-4
@@ -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"
|
||||||
@@ -316,7 +318,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():
|
||||||
@@ -582,6 +584,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 +608,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",
|
||||||
@@ -1698,7 +1702,7 @@ def create_app(cfg):
|
|||||||
try:
|
try:
|
||||||
jobs.apply_deduction(c, closed, cfg["energy"])
|
jobs.apply_deduction(c, closed, cfg["energy"])
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print("Abräumen Abzug %s: %s" % (host, exc), flush=True)
|
log.warning("Abräumen Abzug %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
|
||||||
@@ -2451,7 +2455,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():
|
||||||
|
|||||||
+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) {
|
||||||
|
|||||||
+30
-8
@@ -2510,21 +2510,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); }
|
||||||
@@ -3644,8 +3645,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,47 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.DEFAULT_PAGE_SIZE = DEFAULT_PAGE_SIZE;
|
||||||
|
window.pageSlice = pageSlice;
|
||||||
|
window.renderPager = renderPager;
|
||||||
@@ -26,9 +26,12 @@
|
|||||||
<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"],
|
||||||
@@ -111,14 +114,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 +134,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 +149,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>`;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
<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">
|
||||||
@@ -246,16 +247,22 @@ async function einreihen(itemId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let orderPage = 1;
|
||||||
|
|
||||||
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="ghost" 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", () => fillOrder(null));
|
||||||
|
renderPager(pager, null, () => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const selected = document.getElementById("order-id").value;
|
const selected = document.getElementById("order-id").value;
|
||||||
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></td>";
|
||||||
@@ -275,12 +282,17 @@ function renderList() {
|
|||||||
});
|
});
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -839,6 +840,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 +890,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 +899,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 +922,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 ---------- */
|
||||||
@@ -1546,6 +1590,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 +1689,7 @@ 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 = () => {
|
||||||
|
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>
|
||||||
|
|||||||
@@ -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=70">
|
||||||
<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>
|
||||||
@@ -160,8 +160,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 +315,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 +375,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(" — ");
|
||||||
@@ -479,6 +480,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(""),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -683,6 +684,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 +701,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 +725,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 +761,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 +879,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 +887,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 +1010,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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
<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></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>
|
||||||
|
|
||||||
@@ -110,15 +111,21 @@ function fill(project) {
|
|||||||
renderDetail(project);
|
renderDetail(project);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let projPage = 1;
|
||||||
|
|
||||||
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="4"><div class="empty-state">' + icon("folder") + "<p>Noch kein Projekt.</p></div></td></tr>";
|
||||||
|
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></td><td class=\"num\"></td>";
|
||||||
@@ -130,6 +137,10 @@ function renderList() {
|
|||||||
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 renderDetail(project) {
|
function renderDetail(project) {
|
||||||
|
|||||||
@@ -27,6 +27,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 +38,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,13 +84,17 @@ 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.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>";
|
||||||
@@ -233,16 +239,21 @@ function render() {
|
|||||||
}
|
}
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
["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 () => {
|
||||||
|
|||||||
+40
-12
@@ -322,13 +322,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>`);
|
||||||
}
|
}
|
||||||
@@ -510,6 +519,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 +550,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 +564,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 +832,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 +872,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 +1001,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 +1015,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) {
|
||||||
|
|||||||
@@ -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,10 +100,13 @@ 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)}">
|
||||||
@@ -130,9 +136,12 @@ function render() {
|
|||||||
} 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="icon-btn danger" data-cancel="${job.id}" title="Abbrechen">${icon("x")}</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><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>
|
||||||
@@ -141,17 +150,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 +177,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);
|
||||||
|
|||||||
Reference in new issue
Block a user