6 Commits
56 changed files with 1733 additions and 270 deletions

No files matched your search

+5
View File
@@ -10,3 +10,8 @@ slice/*
__pycache__/
*.pyc
_tmp_*
%s
# gebaute .deb (Skript legt sie hier ab)
packaging/dist/*
!packaging/dist/.gitkeep
+55
View File
@@ -4,6 +4,61 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
## [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
### Neu
- **Dateien:** Umbenennen in der Bibliothek (Stift-Icon; Endung bleibt erhalten).
### 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.
- **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).
- **Protokoll „druckt“ nach Offline:** Live-Läufe werden bei anhaltendem Offline als `interrupted` geschlossen (z. B. gklib-Shutdown).
- **Sorte „Sunlu PLA“ vs. Eryone PLA:** Basis-Profil ohne Meta/Matte/Silk matcht nach Material-Typ (Marke egal); Varianten bleiben streng.
## [3.3.0] – 2026-10-18
### Neu
+112 -77
View File
@@ -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
- PostgreSQL (eine leere Datenbank `grotti` reicht, das Schema legt Grotti selbst an)
- Drucker im selben Netz mit Moonraker auf Port 80
## Features
## 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">
&nbsp;
<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">
&nbsp;
<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">
&nbsp;
<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
cd D:\Projekte\Grotti
python -m venv .venv
.\.venv\Scripts\pip install -r requirements.txt
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
```
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
| Pfad | Inhalt |
| --- | --- |
| `server.py` | Start, Datenbank vorbereiten, Hintergrund-Threads |
| `grotti/` | Kern: Moonraker, Warteschlange (`print_queue.py`), Senden (`send_queue.py`), Abräumen (`eject.py`), Zustandswächter (`watcher.py`), Ereignisse und Telegram (`notify.py`), Datenbank (`store.py`, `schema.sql`) |
| `web/` | Flask-App (`app.py`), Seiten (`templates/`), CSS und JS (`static/`) |
| `docs/` | Bedienungsanleitung (`Anleitung-*.md`) unter `/doku`; Technik unter `docs/internal/` (nicht in der App) |
| `web/static/doku/` | Screenshots für die Anleitung |
| `CHANGELOG.md` | Änderungen je Version |
| `server.py` | Start, DB, Hintergrund-Threads |
| `grotti/` | Moonraker, Queue, Senden, Abräumen, Watcher, Store |
| `web/` | Flask, Templates, CSS/JS, Branding |
| `docs/` | Anleitungen (`/doku`), Community-Screens, Technik unter `internal/` |
| `CHANGELOG.md` | Versionen |
## 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 |
| --- | --- |
@@ -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 /version` | Version |
| `GET`/`PATCH /einstellungen`, `POST /telegram/test` | Config und Telegram-Probe |
| `GET /drucker?live=1` | Drucker mit Live-Zustand, laufendem und wartendem Auftrag |
| `POST /drucker` · `PATCH`/`DELETE /drucker/<id>` | Drucker anlegen/ändern (Admin) |
| `GET /drucker?live=1` | Drucker mit Live-Zustand |
| `POST /drucker/<host>/<aktion>` | Pause, Fortsetzen, Abbrechen, Fehler löschen |
| `POST /drucker/<host>/steuern` | Temperatur, Speed, Flow, Lüfter, ACE |
| `POST /drucker/<host>/abraeumen` | Platte geräumt (`confirm`) |
| `GET /kamera/<host>`, `/vorschau/<host>` | Snapshot / Job-Thumbnail |
| `GET`/`POST /dateien`, `PATCH`/`DELETE /dateien/<id>` | Bibliothek, Upload, Metadaten |
| `POST /dateien/neu-lesen`, `/dateien/ordner`, `/dateien/<id>/verschieben` | Watch-Sync, Ordner, Verschieben |
| `POST /senden`, `GET /sende-auftraege` | Sofort senden (`confirm`), Fortschritt |
| `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 |
| `GET`/`POST /dateien`, `PATCH`/`DELETE /dateien/<id>` | Bibliothek |
| `POST /senden`, `GET`/`POST /druck-queue` | Senden und Warteschlange (`confirm`) |
| `GET /spulen`, `POST /spulen/<id>/einlegen` | Material |
| `GET /laeufe`, `/statistik`, `/ereignisse` | Protokoll und Aktivität |
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
- Login Pflicht (Setup legt den ersten Admin an). Rollen: Admin, Operator.
- Druckstarts brauchen `confirm: true`. Die Oberfläche fragt per Zwei-Klick.
- Keine freie G-code-Konsole; Firmware-Neustart über Grotti ist gesperrt.
- Für Internet: Reverse Proxy mit TLS; nicht den Entwickler-Server öffentlich freigeben.
- Login Pflicht. Rollen: Admin, Operator.
- Druckstarts brauchen `confirm: true` (Zwei-Klick in der UI).
- Keine freie G-code-Konsole; kein `FIRMWARE_RESTART` über Grotti.
- 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.
- Jede Änderung kommt in `CHANGELOG.md` unter `[Unreleased]` (Neu, Geändert, Behoben).
- Beim Release: Version in `grotti/version.py` setzen, `[Unreleased]` in `[x.y.z] – Datum` umbenennen, Git-Tag `vX.Y.Z`.
- Ändert sich `web/static/grotti.css` oder `api.js`, in `layout.html` den `?v=` hochzählen.
- SemVer in `grotti/version.py`, Einträge in [CHANGELOG.md](CHANGELOG.md).
- Release: `[Unreleased]` → `[x.y.z] – Datum`, Tag `vX.Y.Z`.
- CSS/JS-Cache: `?v=` in `layout.html` mitziehen.
## 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>
+1
View File
@@ -7,6 +7,7 @@
"token": "",
"secret_key": "",
"fqdn": "",
"log_level": "INFO",
"auto_queue": true,
"auto_retry": true,
"oauth": {
+2 -2
View File
@@ -1,6 +1,6 @@
# 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.
![ACE-Zuordnung beim Senden](../web/static/doku/ace-zuordnung.png)
@@ -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) |
| **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. |
| 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 |
| Drucker hängt kurz nach Multi-Color-Start | Oft falsche Map (T10 gemappt als Platz 0). Neu lesen + Zuordnung. |
+1
View File
@@ -14,6 +14,7 @@ Stand **18.10.2026**. Seite `/dateien` — Bibliothek links, Senden rechts.
- Info-Popup mit Slice-Metadaten (Kopf und Footer)
- **Hochladen** per Knopf oder Drag & Drop
- Mit gesetztem Slice-Ordner ([Einstellungen](Anleitung-Einstellungen.md)): **Ordner anlegen**; Watch- **und** Upload-Dateien per Auswahl **verschieben** (Upload wird zur Watch-Datei im Slice-Ordner)
- **Umbenennen** (Stift-Icon) — Datei auf der Platte und in der Bibliothek; Endung `.gcode`/`.3mf` bleibt, wenn du sie weglässt
- **Löschen** (Papierkorb, Zwei-Klick) für jede Bibliotheksdatei — entfernt Eintrag und Datei auf der Platte (nur unter Slice-Ordner oder `eingang`)
- **Ordner neu lesen**, wenn unter Einstellungen ein Slice-Ordner gesetzt ist
+92
View File
@@ -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).
+4
View File
@@ -49,6 +49,10 @@ Oft noch Verarbeitung auf dem Drucker. In der Sidebar unter **Senden** den Forts
Oft Dateinamen mit Nicht-ASCII-Zeichen (z. B. Chinesisch). Grotti normalisiert den Namen beim Senden auf ASCII. Nochmal senden; in der Bibliothek darf der Originalname bleiben.
## Drucker „heruntergefahren“ / 10111 / 506 nach Upload
Oft **Einfarbig**-G-code mit `; filament_colour_type` (Orca) — gklib stürzt ab. Grotti entfernt die Zeile beim Senden nur bei Einfarbig. Multicolor bleibt unverändert. Betroffene Drucker: Display-Reboot oder Strom aus/an, dann in Grotti ggf. **Fehler löschen**.
## Wo nachschauen
- Sidebar: Alerts, Läuft, Wartet, Senden
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

+3 -1
View File
@@ -1,6 +1,8 @@
# 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).
---
+5
View File
@@ -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.
- 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.
## Siehe auch
- [Makros-Kobra](Makros-Kobra.md) — Nicht-tun, Vanilla-Alltag, ACE, LeviQ/Wipe je Druckertyp
+3 -1
View File
@@ -1,6 +1,8 @@
# 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
+195
View File
@@ -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
+5
View File
@@ -3,3 +3,8 @@
Diese Dateien sind für Entwicklung und Agenten — **nicht** unter `/doku` in der App.
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 |
+27 -2
View File
@@ -2,6 +2,10 @@
from __future__ import annotations
import re
_HEX_COLOR_RE = re.compile(r"^#?[0-9a-f]{6}([0-9a-f]{2})?$", re.I)
def _group_key(material_type, color):
return (
@@ -10,6 +14,27 @@ def _group_key(material_type, color):
)
def _is_slicer_hex(color):
"""Orca/Anycubic speichert oft #26A69A — Lagerfarben heißen „Grau“/„Weiß“."""
return bool(_HEX_COLOR_RE.match((color or "").strip()))
def _stock_for_need(stock_map, mat, color):
"""Exakte Farbe zuerst; bei Slice-Hex oder leerer Farbe: Summe gleicher Sorte."""
key = _group_key(mat, color)
exact = stock_map.get(key, 0.0)
if exact > 0.5:
return exact
if not color or _is_slicer_hex(color):
mat_key = (mat or "").strip().lower()
return sum(
grams
for (m, _c), grams in stock_map.items()
if m == mat_key and grams > 0
)
return exact
def compute(conn):
"""Wartende Jobs × Dateigewicht vs. Spulenrest, gruppiert nach Sorte/Farbe."""
queue_rows = conn.execute(
@@ -52,6 +77,7 @@ def compute(conn):
"""
SELECT material_type, color, COALESCE(SUM(remaining_g), 0) AS remaining_g
FROM spools
WHERE archived_at IS NULL
GROUP BY material_type, color
"""
).fetchall()
@@ -67,8 +93,7 @@ def compute(conn):
)
shortfall = []
for item in needed:
key = _group_key(item["material_type"], item["color"])
have = stock_map.get(key, 0.0)
have = _stock_for_need(stock_map, item["material_type"], item["color"])
gap = item["weight_g"] - have
if gap > 0.5:
shortfall.append({
+24 -4
View File
@@ -1,6 +1,7 @@
"""Host, Port, Datenordner und Datenbank kommen aus config.json."""
import json
import os
import secrets
import threading
from pathlib import Path
@@ -9,11 +10,24 @@ from grotti.errors import GrottiError
ROOT = Path(__file__).resolve().parent.parent
LOOPBACK = {"127.0.0.1", "::1", "localhost"}
CONFIG_PATH = ROOT / "config.json"
DEFAULT_WATCH_REL = "slice"
_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):
tariff = raw.get("tariff") if isinstance(raw, dict) else None
if not isinstance(tariff, dict) or not tariff:
@@ -121,9 +135,11 @@ def _ensure_secret_key(path, raw):
def load():
path = CONFIG_PATH
path = config_path()
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"))
host = (raw.get("host") or "127.0.0.1").strip()
token = raw.get("token") or ""
@@ -139,6 +155,9 @@ def load():
if not data_dir.is_absolute():
data_dir = ROOT / data_dir
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 {
"host": host,
"port": int(raw.get("port") or 8770),
@@ -149,7 +168,8 @@ def load():
"secret_key": secret_key,
"oauth": _oauth(raw),
"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),
"telegram": _telegram(raw),
"ntfy": _ntfy(raw),
+5 -1
View File
@@ -13,6 +13,10 @@ from psycopg import sql
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from grotti import logutil
log = logutil.get("grotti.db")
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
SCHEMA_LEGACY = Path(__file__).parent / "schema.sql"
@@ -123,7 +127,7 @@ def prepare(url):
if not exists:
conn.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(name)))
except psycopg.Error as exc:
print("Datenbank anlegen: %s" % exc, flush=True)
log.warning("Datenbank anlegen: %s", exc)
try:
with psycopg.connect(url, row_factory=dict_row, connect_timeout=5) as conn:
apply_migrations(conn)
+5 -1
View File
@@ -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]["step_index"] = job.get("step_index")
else:
phase = (job.get("phase") or "").lower()
starting = phase in ("starting", "await_print")
running.append({
"host": host,
"name": job.get("printer_name") or host,
"state": "running",
"state": "starting" if starting else "running",
"starting": starting,
"progress": job.get("progress") or 0,
"remain": job.get("remain") or 0,
"filename": job.get("filename") or "",
"message": job.get("message") or "",
"queue_id": job.get("id"),
"is_ablauf": bool(job.get("is_ablauf")),
})
+1 -37
View File
@@ -415,44 +415,8 @@ def sync_history(conn, cfg):
def assert_filament_enough(conn, file_row, spool_id=None, ace_map=None, host=""):
"""Dateien-Gewicht gegen Spule oder ACE-Plätze prüfen."""
need = float((file_row or {}).get("weight_g") or 0)
if need <= 0:
"""Zu wenig Rest bricht den Start nicht ab. Die Oberfläche warnt vorher."""
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):
+51
View File
@@ -334,6 +334,57 @@ def delete_library_file(conn, cfg, file_id):
return row
def _ensure_print_suffix(new_name, old_name):
"""Endung .gcode / .3mf / .gcode.3mf behalten, wenn der Nutzer sie weglässt."""
name = safe_filename(new_name)
low = name.lower()
old_low = (old_name or "").lower()
if low.endswith(".gcode.3mf") or low.endswith(".3mf") or low.endswith(".gcode"):
return name
if old_low.endswith(".gcode.3mf"):
return name + ".gcode.3mf"
if old_low.endswith(".3mf"):
return name + ".3mf"
if old_low.endswith(".gcode"):
return name + ".gcode"
return name
def rename_library_file(conn, cfg, file_id, new_name):
"""Datei im selben Ordner umbenennen — Platte + DB."""
row = store.get_file_row(conn, file_id)
if not row:
raise GrottiError("Die Datei fehlt.", "datei", 404)
src = Path(row.get("stored_path") or "")
if not src.is_file():
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
if not path_allowed_for_delete(str(src), cfg):
raise GrottiError("Umbenennen außerhalb von Slice-Ordner oder Eingang ist nicht erlaubt.", "datei")
final = _ensure_print_suffix(new_name, row.get("name") or src.name)
if not final or final in (".", ".."):
raise GrottiError("Ungültiger Dateiname.", "datei")
dest = src.with_name(final)
if dest.resolve() == src.resolve():
return store.file_out(row, watch_dir=cfg.get("watch_dir"))
if dest.exists():
raise GrottiError("Unter diesem Namen liegt schon eine Datei.", "datei")
try:
src.rename(dest)
except OSError as exc:
raise GrottiError("Umbenennen fehlgeschlagen: %s" % exc, "datei") from exc
stat = dest.stat()
store.update_file_location(
conn,
file_id,
str(dest.resolve()),
name=dest.name,
size_bytes=stat.st_size,
mtime=stat.st_mtime,
)
row = store.get_file_row(conn, file_id)
return store.file_out(row, watch_dir=cfg.get("watch_dir"))
_PRINT_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
+52
View File
@@ -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
View File
@@ -57,6 +57,51 @@ def _write_guard():
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():
try:
from mcp.server.fastmcp import FastMCP
@@ -67,23 +112,17 @@ def main():
)
sys.exit(1)
server = FastMCP(
"grotti",
instructions=(
"Grotti steuert die 3D-Drucker-Farm. Destruktive Aktionen brauchen "
"confirm=true. Kein freier G-code, kein Firmware-Restart."
),
)
server = FastMCP("grotti", instructions=INSTRUCTIONS)
@server.tool()
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"
return json.dumps(_api("GET", "/drucker" + q), ensure_ascii=False, indent=2)
@server.tool()
def get_printer(host: str) -> str:
"""Einen Drucker anhand Host/IP aus der Live-Liste."""
"""Einen Drucker anhand host (IP) aus der Live-Liste. Host vorher über list_printers holen."""
rows = _api("GET", "/drucker?live=1") or []
for row in rows:
if row.get("host") == host:
@@ -115,7 +154,7 @@ def main():
@server.tool()
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 []
return json.dumps(rows[: max(1, min(limit, 200))], ensure_ascii=False, indent=2)
@@ -181,7 +220,7 @@ def main():
@server.tool()
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()
if not confirm:
raise RuntimeError("Quittierung braucht confirm=true.")
@@ -201,7 +240,7 @@ def main():
confirm: bool = False,
bed_leveling: bool = False,
) -> 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()
if not confirm:
raise RuntimeError("Einreihen braucht confirm=true.")
@@ -220,7 +259,7 @@ def main():
confirm: bool = False,
bed_leveling: bool = False,
) -> 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()
if not confirm:
raise RuntimeError("Start braucht confirm=true.")
+45 -24
View File
@@ -8,8 +8,11 @@ import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from grotti import logutil
from grotti.parse import embedded_png
log = logutil.get("grotti.moonraker")
META_CACHE = {}
CAMERA_CACHE = {}
EMBEDDED = {}
@@ -917,11 +920,27 @@ def last_filament_gcode_hint(host, limit=40):
return ""
def _mmu_tool_index(mmu):
"""Aktives ACE/MMU-Tool, sonst None. −1 / fehlend = kein Tool (Anycubic-Normalfall)."""
if not isinstance(mmu, dict):
return None
tool = mmu.get("tool")
if not isinstance(tool, (int, float)) or isinstance(tool, bool):
tool = mmu.get("gate")
if not isinstance(tool, (int, float)) or isinstance(tool, bool):
return None
tool_i = int(tool)
return tool_i if tool_i >= 0 else None
def filament_out_hint(mmu, message="", host=None):
"""Vermutetes Material-Ende bei Pause: (code, Text) oder (None, None).
Quellen: print_stats.message, ACE/mmu (active_filament.empty, gate_status),
optional G-code-Store (nur wenn host gesetzt).
Quellen: print_stats.message, mmu.reason_for_pause, gate_status bei aktivem Tool,
active_filament.empty nur mit aktivem Tool, optional G-code-Store (host).
Nicht: active_filament.empty allein — Anycubic ACE meldet empty=true auch
beim normalen Druck und bei manueller Pause (tool/gate = −1).
"""
text = (message or "").strip()
if text and _FILAMENT_HINT_RE.search(text):
@@ -930,32 +949,34 @@ def filament_out_hint(mmu, message="", host=None):
return "message", text[:160]
if isinstance(mmu, dict) and mmu:
active = mmu.get("active_filament")
if isinstance(active, dict) and (
active.get("empty") is True
or str(active.get("empty") or "").lower() in ("1", "true")
):
return "active_empty", "Aktives Filament leer (ACE)"
rfp = (mmu.get("reason_for_pause") or "").strip()
if rfp and _FILAMENT_HINT_RE.search(rfp):
if "10107" in rfp.lower():
return "reason", "Filament gerissen oder leer (10107)"
return "reason", rfp[:160]
tool = mmu.get("tool")
if not isinstance(tool, (int, float)) or isinstance(tool, bool):
tool = mmu.get("gate")
if isinstance(tool, (int, float)) and not isinstance(tool, bool) and int(tool) >= 0:
tool_i = int(tool)
tool_i = _mmu_tool_index(mmu)
states = mmu.get("gate_status")
if isinstance(states, list) and tool_i < len(states) and states[tool_i] == 0:
# gate_status==0 nur, wenn Status differenziert (nicht alle 0 = Anycubic-Unknown)
if (
tool_i is not None
and isinstance(states, list)
and tool_i < len(states)
and int(states[tool_i] or 0) == 0
and any(int(s or 0) != 0 for s in states)
):
return "gate_empty", "ACE-Platz %d leer" % (tool_i + 1)
# ACE pausiert und kein aktives Filament → oft Runout ohne leeren gate_status
if mmu.get("is_paused") and isinstance(active, dict):
kind = (
active.get("filament_type")
or active.get("type")
or active.get("material")
or ""
active = mmu.get("active_filament")
if (
tool_i is not None
and isinstance(active, dict)
and (
active.get("empty") is True
or str(active.get("empty") or "").lower() in ("1", "true")
)
if not str(kind).strip():
return "mmu_paused", "ACE pausiert — kein Filament aktiv"
):
return "active_empty", "Aktives Filament leer (ACE)"
if host:
hint = last_filament_gcode_hint(host)
@@ -1223,7 +1244,7 @@ def snapshot(printers):
card.update(query(printer["host"]))
except Exception as error:
# Auch RemoteDisconnected / IncompleteRead / ValueError — sonst fällt der ganze Tick aus.
print("snapshot %s: %s" % (printer.get("host"), error), flush=True)
log.warning("snapshot %s: %s", printer.get("host"), error)
card.update({
"ok": False,
"state": "offline",
+33
View File
@@ -17,6 +17,8 @@ TRAILER_RE = re.compile(rb";\s*referenced_tools\s*=", re.I)
RINKHALS_FINGERPRINT = b"; processed by MmuAcePatcher"
_SLICER_RE = re.compile(rb"^;.*generated by ([A-Za-z]*) .*$|^; (BambuStudio) .*$", re.I)
_TOOL_RE = re.compile(rb"^(?:MMU_CHANGE_TOOL(?:_STANDALONE)? .*?TOOL=|T)(\d{1,2})", re.I)
# Orca `; filament_colour_type` — gklib parsed wie `filament_colour` und panickt (10111).
_COLOUR_TYPE_LINE_RE = re.compile(rb"(?m)^;\s*filament_colour_type\b[^\n]*\n?")
_AUTHORIZED_SLICERS = {
"prusaslicer",
"superslicer",
@@ -244,6 +246,36 @@ def _paint_tool_indexes(content):
return found
def gcode_is_multicolor(content, tools=None):
"""Mehr als ein Slicer-/ACE-Tool → Multicolor (Header nicht antasten)."""
if not content or content[:2] == b"PK":
return False
indexes = set()
for value in tools or []:
try:
indexes.add(int(value))
except (TypeError, ValueError):
continue
for line in _gcode_lines(content):
match = _TOOL_RE.match(line)
if match:
indexes.add(int(match.group(1)))
indexes |= _paint_tool_indexes(content)
return len(indexes) > 1
def strip_filament_colour_type(content, tools=None):
"""`; filament_colour_type` nur bei Einfarbig entfernen (gklib 10111).
Multicolor unverändert — ACE/paint_index und `; filament_colour` bleiben.
"""
if not content or content[:2] == b"PK":
return content
if gcode_is_multicolor(content, tools=tools):
return content
return _COLOUR_TYPE_LINE_RE.sub(b"", content)
def prepare_rinkhals_gcode(content, tools=None):
"""Kopfzeile für Rinkhals plus `; referenced_tools`, wenn sie fehlt.
@@ -253,6 +285,7 @@ def prepare_rinkhals_gcode(content, tools=None):
"""
if not content or content[:2] == b"PK":
return content
content = strip_filament_colour_type(content, tools=tools)
first_end = content.find(b"\n")
first = content if first_end < 0 else content[:first_end]
if first.endswith(b"\r"):
+87 -10
View File
@@ -10,7 +10,7 @@ import threading
import time
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.errors import GrottiError
from grotti import time_window
@@ -18,6 +18,8 @@ from grotti import time_window
WINDOW_WAIT_MSG = "Wartet auf Zeitfenster…"
from grotti.events_bus import publish as publish_live
log = logutil.get("grotti.queue")
def _notify_queue():
publish_live("queue", {})
@@ -28,7 +30,9 @@ BUSY = {"printing", "paused", "heating", "error"}
KEEPALIVE_S = 120
# Nach Start ohne print_active: Upload/MQTT/LeviQ brauchen Zeit, aber nicht ewig
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
@@ -111,7 +115,7 @@ class PrintQueueDispatcher:
try:
self.tick()
except Exception as exc:
print("Druck-Queue: %s" % exc, flush=True)
log.warning("Druck-Queue: %s", exc)
self._stop.wait(self.interval)
def _live_map(self, printers):
@@ -207,7 +211,7 @@ class PrintQueueDispatcher:
try:
target(*args)
except Exception as exc:
print("Queue-Worker %s: %s" % (item_id, exc), flush=True)
log.warning("Queue-Worker %s: %s", item_id, exc)
finally:
_notify_queue()
with self._worker_lock:
@@ -278,13 +282,15 @@ class PrintQueueDispatcher:
continue
running = store.running_print_on_host(conn, host)
if running:
if not _has_ablauf(running):
if (
not _has_ablauf(running)
and (running.get("phase") or "").lower() == "starting"
(running.get("phase") or "").lower() == "starting"
and not self._worker_busy(running["id"])
and self._item_phase_age_s(running) > SIMPLE_START_STUCK_S
):
self._fail_stuck_simple_start(conn, running, printer)
else:
self._advance_simple(conn, running, printer, live)
conn.commit()
continue
if store.bed_needs_clear(conn, host):
@@ -417,15 +423,19 @@ class PrintQueueDispatcher:
from grotti import orders
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(
wconn,
item_id,
{
"status": "done",
"message": "Gestartet auf %s: %s" % (printer_copy["name"], name),
"status": "running",
"message": "Datei ist auf %s. Druckstart kann etwa eine Minute dauern."
% printer_copy["name"],
"host": host,
"phase": "",
"phase_started_at": None,
"phase": "await_print",
"phase_started_at": "now",
},
)
self._event(
@@ -477,6 +487,73 @@ class PrintQueueDispatcher:
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):
try:
eject.require_kobra_3(printer)
+60 -24
View File
@@ -2690,6 +2690,26 @@ def update_print_queue(conn, item_id, fields):
return print_queue_out(fresh, fresh.get("filename")) if fresh else None
_PROFILE_VARIANTS = frozenset({"meta", "matte", "matt", "silk", "plus", "+", "hf", "cf"})
_PROFILE_TYPES = frozenset({"pla", "petg", "abs", "asa", "tpu", "pa", "pc", "hips", "pva"})
def _profile_tokens(profile):
return (profile or "").strip().lower().replace("_", " ").replace("-", " ").split()
def _profile_has_variant(profile):
return bool(_PROFILE_VARIANTS.intersection(_profile_tokens(profile)))
def _profile_base_type(profile):
"""Letzter Material-Typ im Profilnamen (Sunlu PLA → pla)."""
for token in reversed(_profile_tokens(profile)):
if token in _PROFILE_TYPES:
return token
return ""
def _profile_matches(need_profile, spool_name):
"""Sorte: exakter Katalogname (Meta ≠ Matte ≠ PLA). Zusatz nur hinter dem Namen."""
a = (need_profile or "").strip().lower()
@@ -2705,7 +2725,7 @@ def _profile_matches(need_profile, spool_name):
rest = b[len(a):].lstrip(" ·-–_|/")
# Nächstes Wort darf keine bekannte PLA-/PETG-Variante sein
first = (rest.split() or [""])[0]
if first in {"meta", "matte", "matt", "silk", "plus", "+", "hf", "cf"}:
if first in _PROFILE_VARIANTS:
return False
return True
return False
@@ -2793,29 +2813,55 @@ def _file_needs(conn, file_id):
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):
used = used_ids or set()
candidates = [h for h in have if h.get("spool_id") not in used]
profile = (need.get("profile") or "").strip()
if profile:
by_profile = [h for h in candidates if _profile_matches(profile, h.get("material_name"))]
if not by_profile:
return []
colored = [
h for h in by_profile
if not need.get("color") or h["color"] == need["color"] or not h["color"]
]
return colored or by_profile
kind = need.get("material_type") or ""
def _by_type(kind):
kind = (kind or "").strip().lower()
return [
h for h in candidates
if (not kind or h["material_type"] == kind)
and (not need.get("color") or h["color"] == need["color"] or not h["color"])
if not kind or h["material_type"] == kind
]
if profile:
by_profile = [h for h in candidates if _profile_matches(profile, h.get("material_name"))]
if by_profile:
return _prefer_color(by_profile, need.get("color"))
# Basis-Profil ohne Meta/Matte/Silk: Marke egal — Typ reicht
# (Sunlu PLA ↔ Eryone PLA Lite; Slice-Hex #26A69A blockiert nicht „Grau“).
if not _profile_has_variant(profile):
kind = _profile_base_type(profile) or (need.get("material_type") or "")
if kind:
typed = [
h for h in _by_type(kind)
if not _profile_has_variant(h.get("material_name") or "")
]
return _prefer_color(typed, need.get("color"))
return []
return _prefer_color(_by_type(need.get("material_type") or ""), need.get("color"))
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)
have = _spool_material_set(conn, host)
if not need:
@@ -2840,11 +2886,6 @@ def material_fit_score(conn, host, file_id):
score += 5
if weight and best["remaining_g"] + 1e-6 >= weight:
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, ""
have_types = {h["material_type"] for h in have if h["material_type"]}
@@ -2857,11 +2898,6 @@ def material_fit_score(conn, host, file_id):
score += 5
if weight and any(h["remaining_g"] + 1e-6 >= weight for h in matched):
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, ""
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
__version__ = "3.3.0"
__version__ = "3.5.2"
+33 -7
View File
@@ -6,10 +6,12 @@ import threading
import time
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.db import connect
log = logutil.get("grotti.watcher")
SAMPLE_EVERY_S = 30
SAMPLE_HEAT_S = 10
@@ -59,7 +61,7 @@ class StateWatcher:
try:
self.tick()
except Exception as exc:
print("Watcher: %s" % exc, flush=True)
log.warning("Watcher: %s", exc)
self._stop.wait(self.interval)
def tick(self):
@@ -95,6 +97,30 @@ class StateWatcher:
self._offline_hits[host] = hits
if hits >= 2 and not prev_offline:
self._emit(conn, host, name, "offline", "%s ist offline." % name, filename)
# Nach Crash/Shutdown (503/gklib) bleibt sonst „druckt“ im Protokoll.
if hits >= 2:
dangling = store.find_live_run(conn, host)
if dangling:
closed = store.complete_open_run(
conn, dangling["id"], state="interrupted"
)
if closed:
label = dangling.get("filename") or filename or "offline"
self._emit(
conn,
host,
name,
"abbruch",
"%s: Druck unterbrochen (Drucker offline)." % name,
label,
closed["id"],
)
try:
from grotti import jobs
jobs.apply_deduction(conn, closed, self.cfg["energy"])
except Exception as exc:
log.warning("Watcher Abzug offline %s: %s", host, exc)
self._prev[host] = {"state": "offline", "offline": True, "filename": filename}
return
@@ -169,7 +195,7 @@ class StateWatcher:
jobs.sync_history(conn, self.cfg)
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.
dangling = store.find_live_run(conn, host)
if dangling:
@@ -180,7 +206,7 @@ class StateWatcher:
jobs.apply_deduction(conn, closed, self.cfg["energy"])
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"):
# Live-Lauf schließen: normaler Abbruch, oder nach Offline/Neustart
# (prev wird „offline“, der Übergang paused→standby geht sonst verloren).
@@ -207,7 +233,7 @@ class StateWatcher:
jobs.sync_history(conn, self.cfg)
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":
if prev_state == "printing" or prev_state == "paused":
store.set_bed_needs_clear(conn, host, True, filename or "Fehler")
@@ -230,7 +256,7 @@ class StateWatcher:
jobs.sync_history(conn, self.cfg)
except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
log.warning("Watcher Abgleich %s: %s", host, exc)
elif state == "error":
if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0:
self._cool_heaters(host)
@@ -412,7 +438,7 @@ class StateWatcher:
try:
moonraker.heaters_off(host)
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):
need = float(live.get("weight") or 0)
+84
View File
@@ -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"
+56
View File
@@ -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
}
}
+1
View File
@@ -0,0 +1 @@
/etc/grotti/config.json
+45
View File
@@ -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
+19
View File
@@ -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
+13
View File
@@ -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
View File
Whitespace-only changes.
+24
View File
@@ -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
+9 -6
View File
@@ -7,10 +7,12 @@ import time
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 web.app import create_app
log = logutil.get("grotti.server")
def background(cfg):
def work():
@@ -20,10 +22,10 @@ def background(cfg):
jobs.sync_history(conn, cfg)
deleted = db.prune_run_samples(conn, keep_days=90)
if deleted:
print("Proben gelöscht: %s" % deleted, flush=True)
log.info("Proben gelöscht: %s", deleted)
conn.commit()
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()
@@ -39,14 +41,15 @@ def _retention_loop(cfg, stop_event):
n = db.prune_run_samples(conn, keep_days=90)
conn.commit()
if n:
print("Retention: %s Proben gelöscht." % n, flush=True)
log.info("Retention: %s Proben gelöscht.", n)
except Exception as exc:
print("Retention: %s" % exc, flush=True)
log.warning("Retention: %s", exc)
stop_event.wait(3600)
def main():
cfg = load()
logutil.setup(cfg.get("log_level") or "INFO")
cfg["data_dir"].mkdir(parents=True, exist_ok=True)
(cfg["data_dir"] / "eingang").mkdir(exist_ok=True)
db.prepare(cfg["database_url"])
@@ -107,7 +110,7 @@ def main():
except (ValueError, OSError):
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)
+24
View File
@@ -63,6 +63,30 @@ class BedarfTests(unittest.TestCase):
out = compute(conn)
self.assertEqual(out["shortfall"], [])
def test_slicer_hex_matches_named_stock(self):
"""Slice #26A69A vs. Spule „Grau“ — 1 kg reicht, kein Fehlalarm."""
conn = MagicMock()
conn.execute.side_effect = self._rows(
[
{
"id": 1,
"spool_id": None,
"repeat_total": 1,
"repeat_done": 0,
"weight_g": 49.23,
"filaments": [{"type": "PLA", "color": "#26A69A"}],
"spool_material": None,
"spool_color": None,
},
],
[
{"material_type": "PLA", "color": "Grau", "remaining_g": 1000.0},
],
)
out = compute(conn)
self.assertEqual(out["shortfall"], [])
self.assertAlmostEqual(out["needed"][0]["weight_g"], 49.23)
if __name__ == "__main__":
unittest.main()
+44
View File
@@ -0,0 +1,44 @@
"""Einfarbig: filament_colour_type strippen; Multicolor unangetastet."""
import unittest
from grotti.parse import gcode_is_multicolor, prepare_rinkhals_gcode, strip_filament_colour_type
class ColourTypeStripTest(unittest.TestCase):
def test_strip_single_color(self):
raw = (
b"; generated by OrcaSlicer 2.3.0\n"
b"; filament_colour = #26A69A\n"
b"; filament_colour_type = 1\n"
b"G28\n"
b"T0\n"
b"G1 X10\n"
)
out = strip_filament_colour_type(raw)
self.assertNotIn(b"filament_colour_type", out)
self.assertIn(b"filament_colour = #26A69A", out)
self.assertFalse(gcode_is_multicolor(raw))
def test_keep_multicolor(self):
raw = (
b"; generated by OrcaSlicer 2.3.0\n"
b"; filament_colour = #111111;#222222\n"
b"; filament_colour_type = 0;1\n"
b"T0\n"
b"G1 X1\n"
b"T1\n"
b"G1 X2\n"
)
self.assertTrue(gcode_is_multicolor(raw))
out = strip_filament_colour_type(raw)
self.assertIn(b"filament_colour_type = 0;1", out)
def test_prepare_strips_single(self):
raw = b"; filament_colour_type = 1\nG28\nT0\n"
out = prepare_rinkhals_gcode(raw)
self.assertNotIn(b"filament_colour_type", out)
if __name__ == "__main__":
unittest.main()
+41 -7
View File
@@ -11,11 +11,43 @@ class FilamentOutHintTests(unittest.TestCase):
self.assertEqual(code, "message")
self.assertIn("10107", text)
def test_active_filament_empty(self):
code, text = filament_out_hint({"active_filament": {"empty": True}})
def test_reason_for_pause(self):
code, text = filament_out_hint({"reason_for_pause": "Filament runout"}, "")
self.assertEqual(code, "reason")
self.assertIn("runout", text.lower())
def test_active_filament_empty_needs_active_tool(self):
code, text = filament_out_hint({
"tool": 1,
"gate_status": [1, 1, 1, 1],
"active_filament": {"empty": True},
})
self.assertEqual(code, "active_empty")
self.assertIn("leer", text.lower())
def test_anycubic_empty_without_tool_is_not_runout(self):
# Live A2/Kobra 3: empty=true, tool=-1, alle gate_status 0 — auch beim Druck
code, text = filament_out_hint({
"tool": -1,
"gate": -1,
"is_paused": False,
"gate_status": [0, 0, 0, 0],
"active_filament": {"empty": True, "material": ""},
"reason_for_pause": "",
})
self.assertIsNone(code)
self.assertIsNone(text)
def test_manual_pause_anycubic_not_material(self):
code, text = filament_out_hint({
"tool": -1,
"is_paused": True,
"gate_status": [0, 0, 0, 0],
"active_filament": {"empty": True},
}, "paused")
self.assertIsNone(code)
self.assertIsNone(text)
def test_gate_status_zero_for_tool(self):
code, text = filament_out_hint({
"tool": 2,
@@ -25,13 +57,15 @@ class FilamentOutHintTests(unittest.TestCase):
self.assertEqual(code, "gate_empty")
self.assertIn("3", text)
def test_mmu_paused_without_active_type(self):
def test_all_gates_zero_not_empty_signal(self):
# Anycubic: gate_status oft durchgängig 0 — kein Runout-Beweis
code, text = filament_out_hint({
"is_paused": True,
"active_filament": {},
"tool": 0,
"gate_status": [0, 0, 0, 0],
"active_filament": {"type": "PLA", "empty": False},
})
self.assertEqual(code, "mmu_paused")
self.assertTrue(text)
self.assertIsNone(code)
self.assertIsNone(text)
def test_no_hint_for_plain_pause(self):
code, text = filament_out_hint({"tool": 0, "gate_status": [1, 1]}, "paused")
+22
View File
@@ -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()
+49
View File
@@ -17,6 +17,12 @@ class ProfileMatchTests(unittest.TestCase):
def test_empty_profile_always_ok(self):
self.assertTrue(store._profile_matches("", "Sunlu PLA Meta"))
def test_base_profile_helpers(self):
self.assertFalse(store._profile_has_variant("Sunlu PLA"))
self.assertTrue(store._profile_has_variant("Sunlu PLA Meta"))
self.assertEqual(store._profile_base_type("Sunlu PLA"), "pla")
self.assertEqual(store._profile_base_type("Generic PETG"), "petg")
class MatchSpoolsTests(unittest.TestCase):
def setUp(self):
@@ -37,8 +43,51 @@ class MatchSpoolsTests(unittest.TestCase):
"material_name": "sunlu pla matte",
"ace_slot": 2,
},
{
"spool_id": 3,
"material_type": "pla",
"color": "",
"remaining_g": 900,
"material_name": "eryone pla",
"ace_slot": None,
},
]
def test_base_sunlu_pla_matches_plain_pla_not_meta(self):
need = {"profile": "Sunlu PLA", "material_type": "pla", "color": "", "paint_index": 0}
matched = store._match_spools_for_need(need, self.have)
ids = {m["spool_id"] for m in matched}
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):
need = {"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "", "paint_index": 0}
matched = store._match_spools_for_need(need, self.have)
+20 -4
View File
@@ -19,7 +19,7 @@ from werkzeug.middleware.proxy_fix import ProxyFix
from grotti import auth as grotti_auth
from grotti import config as grotti_config
from grotti import db as grotti_db
from grotti import eject, jobs, library, moonraker, notify, store
from grotti import eject, jobs, library, logutil, moonraker, notify, store
from grotti.catalog import color_catalog
from grotti.cost import calculate, offer_from_file, order_actuals
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.watcher import StateWatcher
log = logutil.get("grotti.web")
ROOT = Path(__file__).resolve().parent
TOOL_ROOT = ROOT.parent
DOCS_DIR = TOOL_ROOT / "docs"
@@ -316,7 +318,7 @@ def create_app(cfg):
message = "Diesen Eintrag gibt es schon."
elif isinstance(err, psycopg.errors.InvalidTextRepresentation):
message = "Ungültiger Wert in der Anfrage."
print("DB: %s" % err, flush=True)
log.warning("DB: %s", err)
return jsonify({"error": message, "code": "db"}), 500
def body():
@@ -582,6 +584,7 @@ def create_app(cfg):
ANLEITUNG_ORDER = (
"Anleitung-Einrichtung",
"Anleitung-Installation-Pi",
"Anleitung-Oberflaeche",
"Anleitung-Wand",
"Anleitung-Dateien",
@@ -605,6 +608,7 @@ def create_app(cfg):
def anleitung_title(name):
labels = {
"Anleitung-Einrichtung": "Einrichtung",
"Anleitung-Installation-Pi": "Raspberry Pi (.deb)",
"Anleitung-Oberflaeche": "Oberfläche",
"Anleitung-Wand": "Druckerwand",
"Anleitung-Dateien": "Dateien senden",
@@ -1233,6 +1237,14 @@ def create_app(cfg):
moved = library.move_file(conn(), cfg, file_id, folder)
return jsonify(moved)
@app.post("/api/v1/dateien/<int:file_id>/umbenennen")
def file_rename(file_id):
data = body()
name = (data.get("name") or "").strip()
if not name:
raise GrottiError("Neuer Dateiname fehlt.", "datei")
return jsonify(library.rename_library_file(conn(), cfg, file_id, name))
@app.post("/api/v1/dateien")
def file_upload():
upload = request.files.get("file")
@@ -1690,7 +1702,7 @@ def create_app(cfg):
try:
jobs.apply_deduction(c, closed, cfg["energy"])
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
run_json = store.run_out(row)
reprint = None
@@ -2443,7 +2455,11 @@ def create_app(cfg):
@app.get("/api/v1/laeufe")
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")
def run_aktuell():
+1 -1
View File
@@ -4,7 +4,7 @@ const CSRF = document.querySelector('meta[name="csrf"]')?.content || "";
const STATE_LABELS = {
printing: "druckt", paused: "pausiert", complete: "fertig", standby: "bereit",
heating: "heizt", error: "Fehler", cancelled: "abgebrochen", offline: "offline",
waiting: "wartet", running: "läuft",
waiting: "wartet", running: "läuft", starting: "startet",
};
function escapeHtml(value) {
+30 -8
View File
@@ -2510,21 +2510,22 @@ article.machine.flash { animation: flash 2s var(--ease); }
.lib-info > summary { list-style: none; }
.lib-info > summary::-webkit-details-marker { display: none; }
.lib-info-pop {
position: absolute;
right: 0;
top: calc(100% + 0.25rem);
z-index: 20;
position: fixed;
z-index: 80;
min-width: 11rem;
max-width: min(18rem, calc(100vw - 1rem));
padding: 0.55rem 0.7rem;
border-radius: 10px;
border: 1px solid var(--line-strong);
background: #121217;
background: var(--card-2);
box-shadow: var(--shadow);
font-size: var(--t-xs);
color: var(--muted);
display: grid;
color: var(--ink);
display: none;
gap: 0.2rem;
line-height: 1.35;
}
.lib-info[open] > .lib-info-pop { display: grid; }
.picked-details { opacity: 0.9; }
.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;
}
.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 {
.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-main { margin: 0; }
body { background: #fff; color: #111; }
+44
View File
@@ -333,3 +333,47 @@ window.setConnectionBanner = setConnectionBanner;
window.createPoller = createPoller;
window.promptAbraeumen = promptAbraeumen;
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;
+16 -3
View File
@@ -26,9 +26,12 @@
<div class="skeleton" style="height:4rem"></div>
<div class="skeleton" style="height:4rem"></div>
</div>
<div class="pager" id="tl-pager" hidden></div>
{% endblock %}
{% block script %}
<script>
let tlPage = 1;
let tlItems = [];
const KINDS = [
["queue", "Warteschlange"],
["ablauf", "Ablauf"],
@@ -111,14 +114,19 @@ function itemHtml(ev) {
function render(items) {
const box = document.getElementById("timeline");
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>${
filter.range !== "all" ? `<button type="button" class="ghost" data-range-jump="all">Alle anzeigen</button>` : ""}</div>`;
note.textContent = "";
renderPager(pager, null, () => {});
return;
}
const paged = pageSlice(tlItems, tlPage, DEFAULT_PAGE_SIZE);
tlPage = paged.page;
const groups = [];
for (const ev of items) {
for (const ev of paged.items) {
const label = dayLabel(ev.created_at);
if (!groups.length || groups[groups.length - 1].label !== label) groups.push({ label, items: [] });
groups[groups.length - 1].items.push(ev);
@@ -126,7 +134,11 @@ function render(items) {
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>`
).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() {
@@ -137,6 +149,7 @@ async function load() {
if (filter.host) q.set("host", filter.host);
try {
const data = await api("/ereignisse?" + q.toString());
tlPage = 1;
render(data.items || []);
} catch (err) {
document.getElementById("timeline").innerHTML = `<div class="strip bad">${icon("alert")}<span>${escapeHtml(err.message)}</span></div>`;
+13 -1
View File
@@ -29,6 +29,7 @@
<thead><tr><th>Titel</th><th>Status</th><th>Fortschritt</th></tr></thead>
<tbody id="order-rows"></tbody>
</table>
<div class="pager" id="order-pager" hidden></div>
</div>
</section>
<section class="card">
@@ -246,16 +247,22 @@ async function einreihen(itemId) {
}
}
let orderPage = 1;
function renderList() {
const body = document.getElementById("order-rows");
body.innerHTML = "";
const pager = document.getElementById("order-pager");
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>';
document.getElementById("order-empty-new")?.addEventListener("click", () => fillOrder(null));
renderPager(pager, null, () => {});
return;
}
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");
tr.className = "pick" + (selected && String(order.id) === selected ? " on" : "");
tr.innerHTML = "<td></td><td></td><td></td>";
@@ -275,12 +282,17 @@ function renderList() {
});
body.appendChild(tr);
});
renderPager(pager, paged, (p) => {
orderPage = p;
renderList();
});
}
async function loadList() {
const st = document.getElementById("filter-status").value;
const q = st ? "?status=" + encodeURIComponent(st) : "";
orders = await api("/auftraege" + q);
orderPage = 1;
renderList();
}
+107 -6
View File
@@ -57,6 +57,7 @@
<div class="skeleton" style="height:4rem"></div>
<div class="skeleton" style="height:4rem"></div>
</div>
<div class="pager" id="lib-pager" hidden></div>
<p class="drop-hint">Dateien hierher ziehen zum Hochladen.</p>
</section>
@@ -360,17 +361,37 @@ function spoolWarnText() {
const hosts = selectedHosts();
const fileTypes = ((file.filaments || []).map((f) => (f.type || "").toLowerCase()).filter(Boolean));
const fileProfiles = ((file.filaments || []).map((f) => (f.profile || "").toLowerCase()).filter(Boolean));
const PROFILE_VARIANTS = ["meta", "matte", "matt", "silk", "plus", "hf", "cf"];
function profileHasVariant(want) {
return PROFILE_VARIANTS.some((v) => new RegExp("\\b" + v + "\\b", "i").test(want || ""));
}
function profileBaseType(want) {
const m = String(want || "").toLowerCase().match(/\b(pla|petg|abs|asa|tpu|pa|pc|hips|pva)\b/);
return m ? m[1] : "";
}
function profileOk(spoolRows) {
if (!fileProfiles.length) return true;
const names = spoolRows.map((s) => (s.name || "").toLowerCase()).filter(Boolean);
if (!names.length) return true;
return fileProfiles.every((want) => names.some((n) => {
const types = spoolRows.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean);
if (!names.length && !types.length) return true;
return fileProfiles.every((want) => {
if (names.some((n) => {
if (n === want) return true;
if (!n.startsWith(want) || /[a-z0-9]/i.test(n.charAt(want.length) || "")) return false;
const rest = n.slice(want.length).replace(/^[\s·\-_|/]+/, "");
const first = (rest.split(/\s+/)[0] || "").toLowerCase();
return !["meta", "matte", "matt", "silk", "plus", "hf", "cf"].includes(first);
}));
return !PROFILE_VARIANTS.includes(first);
})) return true;
// Basis-Profil (nur „… PLA“): Typ reicht, Marke egal — keine Meta/Matte-Spule
if (profileHasVariant(want)) return false;
const kind = profileBaseType(want);
if (!kind) return true;
return spoolRows.some((s) => {
const t = (s.material_type || "").toLowerCase();
const n = (s.name || "").toLowerCase();
return t === kind && !profileHasVariant(n);
});
});
}
for (const host of hosts) {
const name = (printers.find((p) => p.host === host) || {}).name || host;
@@ -819,6 +840,39 @@ function infoPopupHtml(file) {
</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) {
return file.preview
? `<img alt="" loading="lazy" src="${escapeHtml(withToken(file.preview))}" onerror="this.style.visibility='hidden'">`
@@ -827,14 +881,17 @@ function thumbHtml(file) {
function libItemHtml(file) {
const project = projects.find((p) => String(p.id) === String(file.project_id));
const rename = `<button type="button" class="icon-btn" data-rename="${file.id}" title="Umbenennen">${icon("edit")}</button>`;
const del = `<button type="button" class="icon-btn" data-delete="${file.id}" title="Datei löschen">${icon("trash")}</button>`;
return `<div class="lib-item ${String(file.id) === String(sel.fileId) ? "on" : ""}" role="button" tabindex="0" data-file="${file.id}">
${thumbHtml(file)}
<span style="min-width:0"><span class="t" title="${escapeHtml(file.name)}">${escapeHtml(file.name)}</span><span class="m">${alwaysMeta(file, project)}</span></span>
<span class="side">${moveSelectHtml(file)}${infoPopupHtml(file)}${del}</span>
<span class="side">${moveSelectHtml(file)}${infoPopupHtml(file)}${rename}${del}</span>
</div>`;
}
let libPage = 1;
function renderLibrary() {
const list = document.getElementById("lib-list");
const shown = filteredFiles();
@@ -842,15 +899,19 @@ function renderLibrary() {
document.getElementById("lib-count").textContent = shown.length === files.length ? String(files.length) : shown.length + "/" + 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>';
renderPager(document.getElementById("lib-pager"), null, () => {});
return;
}
if (!shown.length) {
list.innerHTML = '<div class="picked-empty">' + icon("search") + '<span>Keine Datei passt.</span></div>';
renderPager(document.getElementById("lib-pager"), null, () => {});
return;
}
const paged = pageSlice(shown, libPage, DEFAULT_PAGE_SIZE);
libPage = paged.page;
const parts = [];
let lastGroup = null;
shown.forEach((file) => {
paged.items.forEach((file) => {
let group = null;
if (sort === "material") group = materialKey(file) || "Ohne Material";
else if (sort === "folder") group = folderLabel(folderKey(file));
@@ -861,6 +922,10 @@ function renderLibrary() {
parts.push(libItemHtml(file));
});
list.innerHTML = parts.join("");
renderPager(document.getElementById("lib-pager"), paged, (p) => {
libPage = p;
renderLibrary();
});
}
/* ---------- Flow rechts ---------- */
@@ -1525,11 +1590,46 @@ async function doAblauf() {
/* ---------- 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) => {
if (event.target.closest(".lib-move")) {
event.stopPropagation();
return;
}
const ren = event.target.closest("[data-rename]");
if (ren) {
event.stopPropagation();
const file = files.find((f) => String(f.id) === String(ren.dataset.rename));
if (!file) return;
const next = window.prompt("Neuer Dateiname:", file.name);
if (next == null) return;
const trimmed = String(next).trim();
if (!trimmed || trimmed === file.name) return;
api("/dateien/" + file.id + "/umbenennen", { method: "POST", json: { name: trimmed } })
.then(async (saved) => {
toast("Umbenannt in „" + (saved.name || trimmed) + "“.", "good");
await load();
if (saved && saved.id) selectFile(saved.id);
})
.catch((error) => toast(error.message || String(error), "bad"));
return;
}
const del = event.target.closest("[data-delete]");
if (del) {
event.stopPropagation();
@@ -1589,6 +1689,7 @@ document.getElementById("lib-list").addEventListener("keydown", (event) => {
const node = document.getElementById(id);
if (!node) return;
const onChange = () => {
libPage = 1;
saveLibMemory();
renderLibrary();
};
+1 -1
View File
@@ -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>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>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>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>
+8 -6
View File
@@ -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="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=68">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=70">
<link rel="manifest" href="{{ url_for('static', filename='manifest.webmanifest') }}">
<meta name="theme-color" content="#0a0a0c" id="theme-color-meta">
<script>
@@ -160,8 +160,8 @@
{% endif %}
<form method="dialog"><button class="ghost" value="cancel">Schließen</button></form>
</dialog>
<script src="{{ url_for('static', filename='api.js') }}?v=32"></script>
<script src="{{ url_for('static', filename='ui.js') }}?v=5"></script>
<script src="{{ url_for('static', filename='api.js') }}?v=33"></script>
<script src="{{ url_for('static', filename='ui.js') }}?v=6"></script>
<script>
async function doLogout() {
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 finish = remain > 0 ? finishClock(remain) : "";
const bits = [name];
if (pct > 0 || remain > 0) bits.push(pct + " %");
if (finish) bits.push(finish);
if (tipExtra) bits.push(tipExtra);
else if (pct > 0 || remain > 0) bits.push(pct + " %");
if (finish) bits.push(finish);
const tip = [
filename ? jobLabel(filename) : "",
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 finish = p.remain > 0 ? finishClock(p.remain) : "";
const pausedFil = p.state === "paused" && (p.filament_empty || p.pause_reason);
const starting = p.starting || p.state === "starting";
const label = [
shortPrinter(p.name || p.host),
pausedFil ? "Material!" : (pct ? pct + "%" : ""),
pausedFil ? "Material!" : (starting ? "startet" : (pct ? pct + "%" : "")),
finish,
].filter(Boolean).join(" · ");
const tip = [p.name || "", p.pause_reason || ""].filter(Boolean).join(" — ");
@@ -479,6 +480,7 @@ async function refreshJetzt() {
progress: p.progress,
remain: p.remain,
filename: p.filename,
tipExtra: (p.starting || p.state === "starting") ? "wird gestartet" : "",
cancelId: p.queue_id,
})).join(""),
);
+17 -1
View File
@@ -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>
<tbody id="spool-rows"></tbody>
</table>
<div class="pager" id="spool-pager" hidden></div>
</section>
<section class="card mat-form" id="spool-panel" hidden>
@@ -683,6 +684,8 @@ function fillSelects() {
materialSelect.value = currentMaterial;
}
let spoolPage = 1;
function renderSpools() {
document.querySelectorAll("#spool-filter .fchip").forEach((btn) => {
btn.classList.toggle("on", (btn.dataset.filter || "all") === spoolFilter);
@@ -698,14 +701,17 @@ function renderSpools() {
spoolFilter === "archive" ? shown.length : activeCount
);
const note = document.getElementById("spool-list-note");
const pager = document.getElementById("spool-pager");
if (!spools.length) {
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>';
renderPager(pager, null, () => {});
return;
}
if (!shown.length) {
note.textContent = "";
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;
}
note.textContent = spoolFilter === "archive"
@@ -719,8 +725,10 @@ function renderSpools() {
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");
});
const paged = pageSlice(ordered, spoolPage, DEFAULT_PAGE_SIZE);
spoolPage = paged.page;
let lastPlace = null;
ordered.forEach((spool) => {
paged.items.forEach((spool) => {
const group = spool.printer_host ? printerName(spool.printer_host) : "Regal";
if (group !== lastPlace) {
lastPlace = group;
@@ -753,6 +761,10 @@ function renderSpools() {
tr.addEventListener("click", () => fill(spool));
body.appendChild(tr);
});
renderPager(pager, paged, (p) => {
spoolPage = p;
renderSpools();
});
}
function renderSorten() {
@@ -867,6 +879,7 @@ document.querySelectorAll(".mat-tabs [data-view]").forEach((btn) => {
});
document.getElementById("spool-filter").addEventListener("click", (event) => {
spoolPage = 1;
const btn = event.target.closest("[data-filter]");
if (!btn) return;
spoolFilter = btn.dataset.filter || "all";
@@ -874,6 +887,7 @@ document.getElementById("spool-filter").addEventListener("click", (event) => {
});
document.getElementById("spool-search").addEventListener("input", (event) => {
spoolSearch = event.target.value || "";
spoolPage = 1;
renderSpools();
});
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) => {
spoolPage = 1;
spoolTypeFilter = event.target.value || "";
renderSpools();
});
document.getElementById("spool-filter-color")?.addEventListener("change", (event) => {
spoolPage = 1;
spoolColorFilter = event.target.value || "";
renderSpools();
});
+12 -1
View File
@@ -22,6 +22,7 @@
<thead><tr><th>Name</th><th>Status</th><th>Ordner</th><th class="num">Dateien</th></tr></thead>
<tbody id="proj-rows"></tbody>
</table>
<div class="pager" id="proj-pager" hidden></div>
</div>
</section>
@@ -110,15 +111,21 @@ function fill(project) {
renderDetail(project);
}
let projPage = 1;
function renderList() {
const body = document.getElementById("proj-rows");
const pager = document.getElementById("proj-pager");
document.getElementById("proj-count").textContent = String(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>";
renderPager(pager, null, () => {});
return;
}
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");
tr.className = "pick" + (String(project.id) === String(selectedId) ? " on" : "");
tr.innerHTML = "<td></td><td></td><td></td><td class=\"num\"></td>";
@@ -130,6 +137,10 @@ function renderList() {
tr.addEventListener("click", () => openProject(project.id));
body.appendChild(tr);
});
renderPager(pager, paged, (p) => {
projPage = p;
renderList();
});
}
function renderDetail(project) {
+15 -4
View File
@@ -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>
<tbody id="run-rows"></tbody>
</table>
<div class="pager" id="run-pager" hidden></div>
</div>
{% endblock %}
{% block script %}
@@ -37,6 +38,7 @@ const states = {
};
let runs = [];
let spools = [];
let runPage = 1;
function fillPrinterFilter() {
const select = document.getElementById("run-filter-printer");
@@ -82,13 +84,17 @@ function render() {
const shown = filteredRuns();
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>';
renderPager(document.getElementById("run-pager"), null, () => {});
return;
}
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>';
renderPager(document.getElementById("run-pager"), null, () => {});
return;
}
shown.forEach((run) => {
const paged = pageSlice(shown, runPage, DEFAULT_PAGE_SIZE);
runPage = paged.page;
paged.items.forEach((run) => {
const tr = document.createElement("tr");
tr.className = "pick";
tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>";
@@ -233,16 +239,21 @@ function render() {
}
body.appendChild(tr);
});
renderPager(document.getElementById("run-pager"), paged, (p) => {
runPage = p;
render();
});
}
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();
}
["run-filter-printer", "run-filter-verdict", "run-filter-spool"].forEach((id) => {
document.getElementById(id).addEventListener("change", render);
document.getElementById(id).addEventListener("input", render);
document.getElementById(id).addEventListener("change", () => { runPage = 1; render(); });
document.getElementById(id).addEventListener("input", () => { runPage = 1; render(); });
});
document.getElementById("sync").addEventListener("click", async () => {
+40 -12
View File
@@ -322,13 +322,22 @@ function actionsHtml(state, printer) {
function flowHtml(printer) {
const job = printer.queue_running;
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 title = job.is_ablauf ? "Ablauf" + (rep ? " " + rep : "") : "Aus der Warteschlange";
const title = "Ablauf" + (rep ? " " + rep : "");
bits.push(`<div class="strip flow">
<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>
${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>` : ""}
</div>`);
}
@@ -510,6 +519,16 @@ function steerPanelHtml(printer) {
</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) {
const remain = hours(printer.remain);
const eta = finishClock(printer.remain);
@@ -531,6 +550,11 @@ function heroHtml(printer, state) {
if (state === "offline") {
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") {
const took = hours(printer.elapsed);
const last = jobLabel(printer.filename || "");
@@ -540,7 +564,7 @@ function heroHtml(printer, state) {
if (state === "cancelled") {
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>`;
}
@@ -808,13 +832,15 @@ function patchCard(article, printer) {
else runningJobs.delete(printer.host);
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));
patchBar(role("bar"), printer, state);
setHtml(role("stats"), statsHtml(printer, state));
setHtml(role("temps"), tempbarsHtml(printer, state));
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);
if (jobline) {
jobline.hidden = !jobVisible;
@@ -846,8 +872,8 @@ function patchCard(article, printer) {
}
const job = role("job");
if (job) {
setText(job, jobName(shownFile) || "");
job.title = shownFile || "";
setText(job, jobName(shownFile || startName) || "");
job.title = shownFile || startName || "";
}
setHtml(role("swatches"), swatchesHtml(printer));
setHtml(role("flow"), flowHtml(printer));
@@ -975,7 +1001,8 @@ function patchDesk(printer) {
const nameEl = role("name");
if (nameEl) nameEl.textContent = printer.name || printer.host;
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));
const mainsail = role("mainsail");
if (mainsail) {
@@ -988,12 +1015,13 @@ function patchDesk(printer) {
setHtml(role("temps"), tempbarsHtml(printer, state));
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;
const job = role("job");
if (job) {
setText(job, jobName(shownFile) || (jobVisible ? "" : "Kein Auftrag"));
job.title = shownFile || "";
setText(job, jobName(shownFile || startName) || (jobVisible ? "" : "Kein Auftrag"));
job.title = shownFile || startName || "";
}
const preview = role("preview");
if (preview) {
+19 -4
View File
@@ -34,6 +34,7 @@
</thead>
<tbody id="q-rows"></tbody>
</table>
<div class="pager" id="q-pager" hidden></div>
</div>
{% endblock %}
{% block script %}
@@ -45,6 +46,7 @@ const STATUS_LABEL = {
let jobs = [];
let liveDirect = [];
let filter = "";
let qPage = 1;
function targetOf(job) {
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 shown = shownJobs();
const pager = document.getElementById("q-pager");
if (!shown.length) {
const hint = jobs.length
? "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>';
document.getElementById("q-note").textContent = "";
renderPager(pager, null, () => {});
return;
}
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;
if (job.direct) {
return `<tr data-direct="${escapeHtml(job.host)}">
@@ -130,9 +136,12 @@ function render() {
} else if (st === "waiting" || st === "running") {
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}">
<td>${chipHtml(chipState, STATUS_LABEL[st] || st)}</td>
<td>${chipHtml(chipState, chipLabel)}</td>
<td><span class="cell-file" title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</span>${rep ? " · " + escapeHtml(rep) : ""}</td>
<td>${job.host ? `<a href="/#${encodeURIComponent(job.host)}">${escapeHtml(targetOf(job))}</a>` : escapeHtml(targetOf(job))}</td>
<td>${steps || "—"}</td>
@@ -141,17 +150,22 @@ function render() {
</tr>`;
}).join("");
document.getElementById("q-note").textContent = shown.length === 1 ? "1 Eintrag" : shown.length + " Einträge";
renderPager(pager, paged, (p) => {
qPage = p;
render();
});
}
async function load() {
try {
const [queue, active] = await Promise.all([
api("/druck-queue?limit=120"),
api("/druck-queue?limit=200"),
api("/laeuft").catch(() => []),
]);
jobs = queue || [];
const queuedHosts = new Set(jobs.filter((j) => j.status === "running").map((j) => j.host));
liveDirect = (active || []).filter((p) => p.host && !queuedHosts.has(p.host));
qPage = 1;
render();
} catch (error) {
document.getElementById("q-rows").innerHTML =
@@ -163,6 +177,7 @@ document.getElementById("q-filter").addEventListener("click", (event) => {
const btn = event.target.closest("[data-status]");
if (!btn) return;
filter = btn.dataset.status || "";
qPage = 1;
render();
});
document.getElementById("q-refresh").addEventListener("click", load);