3 Commits
48 changed files with 1584 additions and 196 deletions

No files matched your search

+5
View File
@@ -10,3 +10,8 @@ slice/*
__pycache__/ __pycache__/
*.pyc *.pyc
_tmp_* _tmp_*
%s
# gebaute .deb (Skript legt sie hier ab)
packaging/dist/*
!packaging/dist/.gitkeep
+34
View File
@@ -4,6 +4,40 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
## [Unreleased] ## [Unreleased]
## [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 ## [3.3.0] – 2026-10-18
### Neu ### 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.0-e85d04?style=flat-square" alt="Version 3.5.0"></a>
<img src="https://img.shields.io/badge/Python-3.11+-3776AB?style=flat-square&logo=python&logoColor=white" alt="Python 3.11+">
<img src="https://img.shields.io/badge/PostgreSQL-14+-4169E1?style=flat-square&logo=postgresql&logoColor=white" alt="PostgreSQL">
<img src="https://img.shields.io/badge/Moonraker-Klipper-2ecc71?style=flat-square" alt="Moonraker">
<img src="https://img.shields.io/badge/UI-Deutsch-111827?style=flat-square" alt="UI Deutsch">
<img src="https://img.shields.io/badge/Self--hosted-lokal-6b7280?style=flat-square" alt="Self-hosted">
</p>
Die Drucker laufen mit Rinkhals (Kobra 3, Kobra S1). Grotti spricht sie über Moonraker an. <p align="center">
<a href="#features">Features</a> ·
<a href="#screenshots">Screenshots</a> ·
<a href="#schnellstart">Schnellstart</a> ·
<a href="#api">API</a> ·
<a href="CHANGELOG.md">Changelog</a> ·
<a href="docs/community/README.md">Community</a>
</p>
Aktuelle Version: siehe `grotti/version.py`, Änderungen in [CHANGELOG.md](CHANGELOG.md). ---
## Voraussetzungen <p align="center">
<img src="docs/community/01-druckerwand.png" alt="Druckerwand" width="900">
</p>
- Python 3.11 ## Features
- PostgreSQL (eine leere Datenbank `grotti` reicht, das Schema legt Grotti selbst an)
- Drucker im selben Netz mit Moonraker auf Port 80
## Einrichten | | |
| --- | --- |
| **Druckerwand & Cockpit** | Live-Status, Temps, Pause/Cancel, Farm-ETA |
| **Warteschlange** | Priorität, „nächster freier“, Material-Match, ACE-Map |
| **Dateien & Projekte** | Slice-Watch, Senden, Umbenennen, Projektordner |
| **Kobra-3-Ablauf** | Druck → Abkühlen → Schieben → Tasten |
| **Material** | Spulen, Restgewicht, Bedarf, Chargen |
| **Aufträge & Statistik** | Kunden, Lieferschein, Kosten (€ Material+Strom) |
| **Betrieb** | Auth (Session/`grt_…`), Telegram/ntfy, PWA |
Stack: **Python · Flask · PostgreSQL · Moonraker** (Rinkhals auf Kobra 3 / S1).
## Screenshots
<p align="center">
<img src="docs/community/06-jetzt.png" alt="Jetzt-Liste" width="440">
&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 ```powershell
cd D:\Projekte\Grotti cd D:\Projekte\Grotti
python -m venv .venv python -m venv .venv
.\.venv\Scripts\pip install -r requirements.txt .\.venv\Scripts\pip install -r requirements.txt
copy config.example.json config.json copy config.example.json config.json
```
`config.json` anpassen:
| Schlüssel | Bedeutung |
| --- | --- |
| `host`, `port` | Wo die Oberfläche lauscht. Standard `127.0.0.1:8770`. |
| `database_url` | PostgreSQL-Verbindung. |
| `data_dir` | Uploads, Vorschaubilder, Cache. Standard `data`. |
| `watch_dir` | Slice-Ordner (unter Einstellungen setzen). Leer = nur Upload. |
| `token` | Wenn gesetzt, braucht die API `Authorization: Bearer <token>`. |
| `fqdn` | Öffentliche Basis-URL für Spulen-Etiketten-QR. Leer = Request-Host. |
| `energy` | Watt und Strompreis für die Kosten im Protokoll. |
| `telegram`, `alerts` | Bot und welche Ereignisse als Nachricht kommen. |
| `auto_queue` | Wenn `false`, startet die Warteschlange nichts Neues. |
Die Drucker stehen beim ersten Start in `drucker.json` und landen dann in der Datenbank. Danach werden sie unter Einstellungen gepflegt.
Schema-Änderungen laufen beim Start über `grotti/migrations/` (Tabelle `schema_version`). Vor Major-Updates die Datenbank sichern (`pg_dump`).
```json
[{ "name": "Kobra 3 da26", "host": "10.10.1.60" }]
```
Die Familie (Kobra 3 oder S1) liest Grotti aus dem Namen.
## Starten
```powershell
.\.venv\Scripts\python.exe server.py .\.venv\Scripts\python.exe server.py
``` ```
Dann <http://127.0.0.1:8770/> öffnen. Bedienungsanleitung mit Screenshots in der Oberfläche unter **Doku** (Kapitel `Anleitung-*`), Technik-Notizen darunter. Dann <http://127.0.0.1:8770/>. Bedienung unter **Doku** in der App (`docs/Anleitung-*.md`).
`config.json` und `data/` sind lokal und nicht im Git. ### Raspberry Pi (`.deb`)
Auf dem Pi (arm64 / Bookworm) bauen und installieren — PostgreSQL und systemd-Dienst werden mit eingerichtet:
```bash
bash packaging/build-deb.sh
sudo apt install ./packaging/dist/grotti_*_arm64.deb
journalctl -u grotti -f
```
Details: [docs/Anleitung-Installation-Pi.md](docs/Anleitung-Installation-Pi.md). Öffentliches apt-Repo folgt später.
`config.json` und `data/` bleiben lokal (nicht im Git). Drucker zuerst in `drucker.json`, danach unter Einstellungen.
| Schlüssel | Bedeutung |
| --- | --- |
| `host`, `port` | Oberfläche (Standard `127.0.0.1:8770`) |
| `database_url` | PostgreSQL |
| `watch_dir` | Slice-Ordner (oder leer = nur Upload) |
| `energy` / `telegram` / `alerts` | Kosten und Benachrichtigungen |
Schema-Migrationen laufen beim Start (`grotti/migrations/`). Vor Major-Updates: `pg_dump`.
## Ordner ## Ordner
| Pfad | Inhalt | | Pfad | Inhalt |
| --- | --- | | --- | --- |
| `server.py` | Start, Datenbank vorbereiten, Hintergrund-Threads | | `server.py` | Start, DB, Hintergrund-Threads |
| `grotti/` | Kern: Moonraker, Warteschlange (`print_queue.py`), Senden (`send_queue.py`), Abräumen (`eject.py`), Zustandswächter (`watcher.py`), Ereignisse und Telegram (`notify.py`), Datenbank (`store.py`, `schema.sql`) | | `grotti/` | Moonraker, Queue, Senden, Abräumen, Watcher, Store |
| `web/` | Flask-App (`app.py`), Seiten (`templates/`), CSS und JS (`static/`) | | `web/` | Flask, Templates, CSS/JS, Branding |
| `docs/` | Bedienungsanleitung (`Anleitung-*.md`) unter `/doku`; Technik unter `docs/internal/` (nicht in der App) | | `docs/` | Anleitungen (`/doku`), Community-Screens, Technik unter `internal/` |
| `web/static/doku/` | Screenshots für die Anleitung | | `CHANGELOG.md` | Versionen |
| `CHANGELOG.md` | Änderungen je Version |
## API ## API
Alles unter `/api/v1`, JSON. Auth: Session-Cookie (UI) oder `Authorization: Bearer grt_…` (API-Token unter Einstellungen). Optional noch Shared-`token` in der Config (Migrationsbrücke). Alles unter `/api/v1`, JSON. Auth: Session oder `Authorization: Bearer grt_…`.
| Methode und Pfad | Zweck | | Methode und Pfad | Zweck |
| --- | --- | | --- | --- |
@@ -78,43 +123,33 @@ Alles unter `/api/v1`, JSON. Auth: Session-Cookie (UI) oder `Authorization: Bear
| `GET /me`, `/users`, `/tokens` | Konto, Benutzer (Admin), API-Tokens | | `GET /me`, `/users`, `/tokens` | Konto, Benutzer (Admin), API-Tokens |
| `GET /version` | Version | | `GET /version` | Version |
| `GET`/`PATCH /einstellungen`, `POST /telegram/test` | Config und Telegram-Probe | | `GET`/`PATCH /einstellungen`, `POST /telegram/test` | Config und Telegram-Probe |
| `GET /drucker?live=1` | Drucker mit Live-Zustand, laufendem und wartendem Auftrag | | `GET /drucker?live=1` | Drucker mit Live-Zustand |
| `POST /drucker` · `PATCH`/`DELETE /drucker/<id>` | Drucker anlegen/ändern (Admin) |
| `POST /drucker/<host>/<aktion>` | Pause, Fortsetzen, Abbrechen, Fehler löschen | | `POST /drucker/<host>/<aktion>` | Pause, Fortsetzen, Abbrechen, Fehler löschen |
| `POST /drucker/<host>/steuern` | Temperatur, Speed, Flow, Lüfter, ACE | | `POST /drucker/<host>/steuern` | Temperatur, Speed, Flow, Lüfter, ACE |
| `POST /drucker/<host>/abraeumen` | Platte geräumt (`confirm`) | | `POST /drucker/<host>/abraeumen` | Platte geräumt (`confirm`) |
| `GET /kamera/<host>`, `/vorschau/<host>` | Snapshot / Job-Thumbnail | | `GET`/`POST /dateien`, `PATCH`/`DELETE /dateien/<id>` | Bibliothek |
| `GET`/`POST /dateien`, `PATCH`/`DELETE /dateien/<id>` | Bibliothek, Upload, Metadaten | | `POST /senden`, `GET`/`POST /druck-queue` | Senden und Warteschlange (`confirm`) |
| `POST /dateien/neu-lesen`, `/dateien/ordner`, `/dateien/<id>/verschieben` | Watch-Sync, Ordner, Verschieben | | `GET /spulen`, `POST /spulen/<id>/einlegen` | Material |
| `POST /senden`, `GET /sende-auftraege` | Sofort senden (`confirm`), Fortschritt | | `GET /laeufe`, `/statistik`, `/ereignisse` | Protokoll und Aktivität |
| `GET`/`POST /druck-queue`, `PATCH /druck-queue/<id>` | Warteschlange (`confirm` beim Einreihen) |
| `GET /laeuft`, `/aufgaben` | Direktstarts und Platte-belegt |
| `POST /ablauf`, `/schieben-testen` | Ablauf Kobra 3 / Schieben-Test |
| `GET /spulen`, `POST /spulen`, `POST /spulen/<id>/einlegen` | Spulen und Einlegen |
| `GET /spulen/<id>/akte`, `/spulen/by-code` | Spulenakte, Scan `S-…` |
| `GET`/`POST /materialien`, `/farben` | Sorten und Farbpalette |
| `GET /laeufe`, `/laeufe/aktuell`, `/laeufe/<id>` | Protokoll und Druckbericht |
| `GET /statistik`, `/statistik.csv` | Auslastung und CSV |
| `GET`/`POST /projekte`, `/ideen` | Projekte und Ideen-Backlog |
| `GET /ereignisse`, `POST /ereignisse/gelesen` | Aktivität und Alerts |
| `POST /kalkulation` | Vorkalkulation Material+Strom |
MCP: `python -m grotti.mcp_server` mit `GROTTI_URL` und `GROTTI_TOKEN` (siehe `docs/internal/MCP.md`). MCP: `python -m grotti.mcp_server` mit `GROTTI_URL` / `GROTTI_TOKEN` → `docs/internal/MCP.md`.
## Sicherheit ## Sicherheit
- Login Pflicht (Setup legt den ersten Admin an). Rollen: Admin, Operator. - Login Pflicht. Rollen: Admin, Operator.
- Druckstarts brauchen `confirm: true`. Die Oberfläche fragt per Zwei-Klick. - Druckstarts brauchen `confirm: true` (Zwei-Klick in der UI).
- Keine freie G-code-Konsole; Firmware-Neustart über Grotti ist gesperrt. - Keine freie G-code-Konsole; kein `FIRMWARE_RESTART` über Grotti.
- Für Internet: Reverse Proxy mit TLS; nicht den Entwickler-Server öffentlich freigeben. - Nach außen: Reverse Proxy mit TLS.
## Versionen und Changelog ## Versionen
- Versionierung nach [SemVer](https://semver.org/lang/de/): Patch für Fehlerbehebung, Minor für neue Funktion, Major für Bruch an API oder Datenbankschema. - SemVer in `grotti/version.py`, Einträge in [CHANGELOG.md](CHANGELOG.md).
- Jede Änderung kommt in `CHANGELOG.md` unter `[Unreleased]` (Neu, Geändert, Behoben). - Release: `[Unreleased]` → `[x.y.z] – Datum`, Tag `vX.Y.Z`.
- Beim Release: Version in `grotti/version.py` setzen, `[Unreleased]` in `[x.y.z] – Datum` umbenennen, Git-Tag `vX.Y.Z`. - CSS/JS-Cache: `?v=` in `layout.html` mitziehen.
- Ändert sich `web/static/grotti.css` oder `api.js`, in `layout.html` den `?v=` hochzählen.
## Repo ---
Eigenes Repository: <https://git.gundel.dev/dg/grotti>. Doku zum Tool gehört nach `docs/`. <p align="center">
<img src="web/static/brand/logo.png" alt="" width="72"><br>
<sub>Repo: <a href="https://git.gundel.dev/dg/grotti">git.gundel.dev/dg/grotti</a></sub>
</p>
+1
View File
@@ -7,6 +7,7 @@
"token": "", "token": "",
"secret_key": "", "secret_key": "",
"fqdn": "", "fqdn": "",
"log_level": "INFO",
"auto_queue": true, "auto_queue": true,
"auto_retry": true, "auto_retry": true,
"oauth": { "oauth": {
+1
View File
@@ -14,6 +14,7 @@ Stand **18.10.2026**. Seite `/dateien` — Bibliothek links, Senden rechts.
- Info-Popup mit Slice-Metadaten (Kopf und Footer) - Info-Popup mit Slice-Metadaten (Kopf und Footer)
- **Hochladen** per Knopf oder Drag & Drop - **Hochladen** per Knopf oder Drag & Drop
- 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) - 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`) - **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 - **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. 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 ## Wo nachschauen
- Sidebar: Alerts, Läuft, Wartet, Senden - 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 # 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. - Quellcode von **gkapi** (Task-State-Machine, exakte 10101-Bedingungen) ist nicht öffentlich.
- Deadlock in **FIRMWARE_RESTART** ist beobachtet und in Rinkhals abgefangen, Root Cause im Binary nicht freigelegt. - Deadlock in **FIRMWARE_RESTART** ist beobachtet und in Rinkhals abgefangen, Root Cause im Binary nicht freigelegt.
- Kobra X / K4-Stack ist ein anderes System (C++-Klipper), nicht GoKlipper — für unsere Drucker irrelevant. - Kobra X / K4-Stack ist ein anderes System (C++-Klipper), nicht GoKlipper — für unsere Drucker irrelevant.
## Siehe auch
- [Makros-Kobra](Makros-Kobra.md) — Nicht-tun, Vanilla-Alltag, ACE, LeviQ/Wipe je Druckertyp
+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. Diese Dateien sind für Entwicklung und Agenten — **nicht** unter `/doku` in der App.
Nutzer-Handbuch: `docs/Anleitung-*.md`. Nutzer-Handbuch: `docs/Anleitung-*.md`.
| Datei | Inhalt |
| --- | --- |
| [Makros-Kobra.md](Makros-Kobra.md) | G-code/Makros Kobra S1 und 3: Nicht-tun, Vanilla-Alltag, ACE, LeviQ/Wipe |
| [Anycubic-Stack.md](Anycubic-Stack.md) | GoKlipper, MQTT-Start/Stop, ACE-Kanäle |
+27 -2
View File
@@ -2,6 +2,10 @@
from __future__ import annotations 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): def _group_key(material_type, color):
return ( 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): def compute(conn):
"""Wartende Jobs × Dateigewicht vs. Spulenrest, gruppiert nach Sorte/Farbe.""" """Wartende Jobs × Dateigewicht vs. Spulenrest, gruppiert nach Sorte/Farbe."""
queue_rows = conn.execute( queue_rows = conn.execute(
@@ -52,6 +77,7 @@ def compute(conn):
""" """
SELECT material_type, color, COALESCE(SUM(remaining_g), 0) AS remaining_g SELECT material_type, color, COALESCE(SUM(remaining_g), 0) AS remaining_g
FROM spools FROM spools
WHERE archived_at IS NULL
GROUP BY material_type, color GROUP BY material_type, color
""" """
).fetchall() ).fetchall()
@@ -67,8 +93,7 @@ def compute(conn):
) )
shortfall = [] shortfall = []
for item in needed: for item in needed:
key = _group_key(item["material_type"], item["color"]) have = _stock_for_need(stock_map, item["material_type"], item["color"])
have = stock_map.get(key, 0.0)
gap = item["weight_g"] - have gap = item["weight_g"] - have
if gap > 0.5: if gap > 0.5:
shortfall.append({ shortfall.append({
+24 -4
View File
@@ -1,6 +1,7 @@
"""Host, Port, Datenordner und Datenbank kommen aus config.json.""" """Host, Port, Datenordner und Datenbank kommen aus config.json."""
import json import json
import os
import secrets import secrets
import threading import threading
from pathlib import Path from pathlib import Path
@@ -9,11 +10,24 @@ from grotti.errors import GrottiError
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
LOOPBACK = {"127.0.0.1", "::1", "localhost"} LOOPBACK = {"127.0.0.1", "::1", "localhost"}
CONFIG_PATH = ROOT / "config.json"
DEFAULT_WATCH_REL = "slice" DEFAULT_WATCH_REL = "slice"
_SETTINGS_LOCK = threading.Lock() _SETTINGS_LOCK = threading.Lock()
def config_path():
"""GROTTI_CONFIG, sonst /etc/grotti/config.json (Paket), sonst Repo-config.json."""
env = (os.environ.get("GROTTI_CONFIG") or "").strip()
if env:
return Path(env)
etc = Path("/etc/grotti/config.json")
if etc.is_file():
return etc
return ROOT / "config.json"
CONFIG_PATH = config_path()
def _tariff(raw): def _tariff(raw):
tariff = raw.get("tariff") if isinstance(raw, dict) else None tariff = raw.get("tariff") if isinstance(raw, dict) else None
if not isinstance(tariff, dict) or not tariff: if not isinstance(tariff, dict) or not tariff:
@@ -121,9 +135,11 @@ def _ensure_secret_key(path, raw):
def load(): def load():
path = CONFIG_PATH path = config_path()
if not path.exists(): if not path.exists():
path.write_text((ROOT / "config.example.json").read_text(encoding="utf-8"), encoding="utf-8") example = ROOT / "config.example.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(example.read_text(encoding="utf-8"), encoding="utf-8")
raw = json.loads(path.read_text(encoding="utf-8")) raw = json.loads(path.read_text(encoding="utf-8"))
host = (raw.get("host") or "127.0.0.1").strip() host = (raw.get("host") or "127.0.0.1").strip()
token = raw.get("token") or "" token = raw.get("token") or ""
@@ -139,6 +155,9 @@ def load():
if not data_dir.is_absolute(): if not data_dir.is_absolute():
data_dir = ROOT / data_dir data_dir = ROOT / data_dir
watch_dir, _stored = parse_watch_dir(raw.get("watch_dir") or "", create=False, missing_ok=True) watch_dir, _stored = parse_watch_dir(raw.get("watch_dir") or "", create=False, missing_ok=True)
printers_file = Path(raw.get("printers_file") or (ROOT / "drucker.json"))
if not printers_file.is_absolute():
printers_file = ROOT / printers_file
return { return {
"host": host, "host": host,
"port": int(raw.get("port") or 8770), "port": int(raw.get("port") or 8770),
@@ -149,7 +168,8 @@ def load():
"secret_key": secret_key, "secret_key": secret_key,
"oauth": _oauth(raw), "oauth": _oauth(raw),
"fqdn": normalize_fqdn(raw.get("fqdn") or ""), "fqdn": normalize_fqdn(raw.get("fqdn") or ""),
"printers_file": ROOT / "drucker.json", "printers_file": printers_file,
"log_level": str(raw.get("log_level") or "INFO").strip() or "INFO",
"energy": _energy(raw), "energy": _energy(raw),
"telegram": _telegram(raw), "telegram": _telegram(raw),
"ntfy": _ntfy(raw), "ntfy": _ntfy(raw),
+5 -1
View File
@@ -13,6 +13,10 @@ from psycopg import sql
from psycopg.rows import dict_row from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool from psycopg_pool import ConnectionPool
from grotti import logutil
log = logutil.get("grotti.db")
MIGRATIONS_DIR = Path(__file__).parent / "migrations" MIGRATIONS_DIR = Path(__file__).parent / "migrations"
SCHEMA_LEGACY = Path(__file__).parent / "schema.sql" SCHEMA_LEGACY = Path(__file__).parent / "schema.sql"
@@ -123,7 +127,7 @@ def prepare(url):
if not exists: if not exists:
conn.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(name))) conn.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(name)))
except psycopg.Error as exc: except psycopg.Error as exc:
print("Datenbank anlegen: %s" % exc, flush=True) log.warning("Datenbank anlegen: %s", exc)
try: try:
with psycopg.connect(url, row_factory=dict_row, connect_timeout=5) as conn: with psycopg.connect(url, row_factory=dict_row, connect_timeout=5) as conn:
apply_migrations(conn) apply_migrations(conn)
+51
View File
@@ -334,6 +334,57 @@ def delete_library_file(conn, cfg, file_id):
return row 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") _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)
+47 -26
View File
@@ -8,8 +8,11 @@ import urllib.parse
import urllib.request import urllib.request
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from grotti import logutil
from grotti.parse import embedded_png from grotti.parse import embedded_png
log = logutil.get("grotti.moonraker")
META_CACHE = {} META_CACHE = {}
CAMERA_CACHE = {} CAMERA_CACHE = {}
EMBEDDED = {} EMBEDDED = {}
@@ -917,11 +920,27 @@ def last_filament_gcode_hint(host, limit=40):
return "" 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): def filament_out_hint(mmu, message="", host=None):
"""Vermutetes Material-Ende bei Pause: (code, Text) oder (None, None). """Vermutetes Material-Ende bei Pause: (code, Text) oder (None, None).
Quellen: print_stats.message, ACE/mmu (active_filament.empty, gate_status), Quellen: print_stats.message, mmu.reason_for_pause, gate_status bei aktivem Tool,
optional G-code-Store (nur wenn host gesetzt). 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() text = (message or "").strip()
if text and _FILAMENT_HINT_RE.search(text): if text and _FILAMENT_HINT_RE.search(text):
@@ -930,33 +949,35 @@ def filament_out_hint(mmu, message="", host=None):
return "message", text[:160] return "message", text[:160]
if isinstance(mmu, dict) and mmu: if isinstance(mmu, dict) and mmu:
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_i = _mmu_tool_index(mmu)
states = mmu.get("gate_status")
# 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)
active = mmu.get("active_filament") active = mmu.get("active_filament")
if isinstance(active, dict) and ( if (
active.get("empty") is True tool_i is not None
or str(active.get("empty") or "").lower() in ("1", "true") and 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)" return "active_empty", "Aktives Filament leer (ACE)"
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)
states = mmu.get("gate_status")
if isinstance(states, list) and tool_i < len(states) and states[tool_i] == 0:
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 ""
)
if not str(kind).strip():
return "mmu_paused", "ACE pausiert — kein Filament aktiv"
if host: if host:
hint = last_filament_gcode_hint(host) hint = last_filament_gcode_hint(host)
if hint: if hint:
@@ -1223,7 +1244,7 @@ def snapshot(printers):
card.update(query(printer["host"])) card.update(query(printer["host"]))
except Exception as error: except Exception as error:
# Auch RemoteDisconnected / IncompleteRead / ValueError — sonst fällt der ganze Tick aus. # Auch RemoteDisconnected / IncompleteRead / ValueError — sonst fällt der ganze Tick aus.
print("snapshot %s: %s" % (printer.get("host"), error), flush=True) log.warning("snapshot %s: %s", printer.get("host"), error)
card.update({ card.update({
"ok": False, "ok": False,
"state": "offline", "state": "offline",
+33
View File
@@ -17,6 +17,8 @@ TRAILER_RE = re.compile(rb";\s*referenced_tools\s*=", re.I)
RINKHALS_FINGERPRINT = b"; processed by MmuAcePatcher" RINKHALS_FINGERPRINT = b"; processed by MmuAcePatcher"
_SLICER_RE = re.compile(rb"^;.*generated by ([A-Za-z]*) .*$|^; (BambuStudio) .*$", re.I) _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) _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 = { _AUTHORIZED_SLICERS = {
"prusaslicer", "prusaslicer",
"superslicer", "superslicer",
@@ -244,6 +246,36 @@ def _paint_tool_indexes(content):
return found 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): def prepare_rinkhals_gcode(content, tools=None):
"""Kopfzeile für Rinkhals plus `; referenced_tools`, wenn sie fehlt. """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": if not content or content[:2] == b"PK":
return content return content
content = strip_filament_colour_type(content, tools=tools)
first_end = content.find(b"\n") first_end = content.find(b"\n")
first = content if first_end < 0 else content[:first_end] first = content if first_end < 0 else content[:first_end]
if first.endswith(b"\r"): if first.endswith(b"\r"):
+72 -14
View File
@@ -10,7 +10,7 @@ import threading
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from grotti import acm, eject, jobs, moonraker, notify, store from grotti import acm, eject, jobs, logutil, moonraker, notify, store
from grotti.db import connect from grotti.db import connect
from grotti.errors import GrottiError from grotti.errors import GrottiError
from grotti import time_window from grotti import time_window
@@ -18,6 +18,8 @@ from grotti import time_window
WINDOW_WAIT_MSG = "Wartet auf Zeitfenster…" WINDOW_WAIT_MSG = "Wartet auf Zeitfenster…"
from grotti.events_bus import publish as publish_live from grotti.events_bus import publish as publish_live
log = logutil.get("grotti.queue")
def _notify_queue(): def _notify_queue():
publish_live("queue", {}) publish_live("queue", {})
@@ -111,7 +113,7 @@ class PrintQueueDispatcher:
try: try:
self.tick() self.tick()
except Exception as exc: except Exception as exc:
print("Druck-Queue: %s" % exc, flush=True) log.warning("Druck-Queue: %s", exc)
self._stop.wait(self.interval) self._stop.wait(self.interval)
def _live_map(self, printers): def _live_map(self, printers):
@@ -207,7 +209,7 @@ class PrintQueueDispatcher:
try: try:
target(*args) target(*args)
except Exception as exc: except Exception as exc:
print("Queue-Worker %s: %s" % (item_id, exc), flush=True) log.warning("Queue-Worker %s: %s", item_id, exc)
finally: finally:
_notify_queue() _notify_queue()
with self._worker_lock: with self._worker_lock:
@@ -278,13 +280,15 @@ class PrintQueueDispatcher:
continue continue
running = store.running_print_on_host(conn, host) running = store.running_print_on_host(conn, host)
if running: if running:
if ( if not _has_ablauf(running):
not _has_ablauf(running) if (
and (running.get("phase") or "").lower() == "starting" (running.get("phase") or "").lower() == "starting"
and not self._worker_busy(running["id"]) and not self._worker_busy(running["id"])
and self._item_phase_age_s(running) > SIMPLE_START_STUCK_S and self._item_phase_age_s(running) > SIMPLE_START_STUCK_S
): ):
self._fail_stuck_simple_start(conn, running, printer) self._fail_stuck_simple_start(conn, running, printer)
else:
self._advance_simple(conn, running, printer, live)
conn.commit() conn.commit()
continue continue
if store.bed_needs_clear(conn, host): if store.bed_needs_clear(conn, host):
@@ -417,15 +421,17 @@ class PrintQueueDispatcher:
from grotti import orders from grotti import orders
orders.attach_order_item_to_live_run(wconn, host, name, oid) orders.attach_order_item_to_live_run(wconn, host, name, oid)
# Nicht sofort „done“: sonst greift der nächste Wartende, während
# der Drucker noch druckt (Live-Cache kann kurz FREE bleiben).
store.update_print_queue( store.update_print_queue(
wconn, wconn,
item_id, item_id,
{ {
"status": "done", "status": "running",
"message": "Gestartet auf %s: %s" % (printer_copy["name"], name), "message": "Druckt auf %s: %s" % (printer_copy["name"], name),
"host": host, "host": host,
"phase": "", "phase": "print_active",
"phase_started_at": None, "phase_started_at": "now",
}, },
) )
self._event( self._event(
@@ -477,6 +483,58 @@ class PrintQueueDispatcher:
self._spawn_worker(item_id, work) self._spawn_worker(item_id, work)
def _advance_simple(self, conn, item, printer, live):
"""Einfach-Druck: running bleiben bis Moonraker fertig/abgebrochen."""
phase = (item.get("phase") or "").lower()
state = (live.get("state") or "").lower()
if phase == "starting":
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 != "print_active":
return
if state in ("printing", "paused", "heating"):
return
if state == "error":
store.update_print_queue(
conn,
item["id"],
{
"status": "error",
"message": (live.get("message") or "Druckerfehler").strip()[:200],
"phase": "",
"phase_started_at": None,
},
)
return
if state in FREE or state in ("complete", "cancelled"):
store.update_print_queue(
conn,
item["id"],
{
"status": "done",
"message": "Fertig.",
"phase": "",
"phase_started_at": None,
},
)
self._event(
conn,
printer["host"],
printer["name"],
"fertig",
"%s fertig: %s" % (printer["name"], item.get("filename") or "Druck"),
item.get("filename") or "",
)
def _begin_ablauf(self, conn, item, printer, live): def _begin_ablauf(self, conn, item, printer, live):
try: try:
eject.require_kobra_3(printer) eject.require_kobra_3(printer)
+57 -14
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 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): def _profile_matches(need_profile, spool_name):
"""Sorte: exakter Katalogname (Meta ≠ Matte ≠ PLA). Zusatz nur hinter dem Namen.""" """Sorte: exakter Katalogname (Meta ≠ Matte ≠ PLA). Zusatz nur hinter dem Namen."""
a = (need_profile or "").strip().lower() a = (need_profile or "").strip().lower()
@@ -2705,7 +2725,7 @@ def _profile_matches(need_profile, spool_name):
rest = b[len(a):].lstrip(" ·-–_|/") rest = b[len(a):].lstrip(" ·-–_|/")
# Nächstes Wort darf keine bekannte PLA-/PETG-Variante sein # Nächstes Wort darf keine bekannte PLA-/PETG-Variante sein
first = (rest.split() or [""])[0] first = (rest.split() or [""])[0]
if first in {"meta", "matte", "matt", "silk", "plus", "+", "hf", "cf"}: if first in _PROFILE_VARIANTS:
return False return False
return True return True
return False return False
@@ -2793,25 +2813,48 @@ def _file_needs(conn, file_id):
return types, float(row.get("weight_g") or 0) return types, float(row.get("weight_g") or 0)
def _prefer_color(spools, need_color):
"""Farbe soft: Slice-Hex ignorieren; benannte Farbe bevorzugen, nie blockieren."""
from grotti.bedarf import _is_slicer_hex
need_c = (need_color or "").strip().lower()
if not need_c or _is_slicer_hex(need_c) or not spools:
return list(spools)
preferred = [
h for h in spools
if not (h.get("color") or "").strip() or (h.get("color") or "").strip().lower() == need_c
]
return preferred or list(spools)
def _match_spools_for_need(need, have, used_ids=None): def _match_spools_for_need(need, have, used_ids=None):
used = used_ids or set() used = used_ids or set()
candidates = [h for h in have if h.get("spool_id") not in used] candidates = [h for h in have if h.get("spool_id") not in used]
profile = (need.get("profile") or "").strip() profile = (need.get("profile") or "").strip()
def _by_type(kind):
kind = (kind or "").strip().lower()
return [
h for h in candidates
if not kind or h["material_type"] == kind
]
if profile: if profile:
by_profile = [h for h in candidates if _profile_matches(profile, h.get("material_name"))] by_profile = [h for h in candidates if _profile_matches(profile, h.get("material_name"))]
if not by_profile: if by_profile:
return [] return _prefer_color(by_profile, need.get("color"))
colored = [ # Basis-Profil ohne Meta/Matte/Silk: Marke egal — Typ reicht
h for h in by_profile # (Sunlu PLA ↔ Eryone PLA Lite; Slice-Hex #26A69A blockiert nicht „Grau“).
if not need.get("color") or h["color"] == need["color"] or not h["color"] if not _profile_has_variant(profile):
] kind = _profile_base_type(profile) or (need.get("material_type") or "")
return colored or by_profile if kind:
kind = need.get("material_type") or "" typed = [
return [ h for h in _by_type(kind)
h for h in candidates if not _profile_has_variant(h.get("material_name") or "")
if (not kind or h["material_type"] == kind) ]
and (not need.get("color") or h["color"] == need["color"] or not h["color"]) 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): def material_fit_score(conn, host, file_id):
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen.""" """Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
__version__ = "3.3.0" __version__ = "3.5.0"
+33 -7
View File
@@ -6,10 +6,12 @@ import threading
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from grotti import jobs, moonraker, notify, store, usage from grotti import jobs, logutil, moonraker, notify, store, usage
from grotti.events_bus import publish as publish_live from grotti.events_bus import publish as publish_live
from grotti.db import connect from grotti.db import connect
log = logutil.get("grotti.watcher")
SAMPLE_EVERY_S = 30 SAMPLE_EVERY_S = 30
SAMPLE_HEAT_S = 10 SAMPLE_HEAT_S = 10
@@ -59,7 +61,7 @@ class StateWatcher:
try: try:
self.tick() self.tick()
except Exception as exc: except Exception as exc:
print("Watcher: %s" % exc, flush=True) log.warning("Watcher: %s", exc)
self._stop.wait(self.interval) self._stop.wait(self.interval)
def tick(self): def tick(self):
@@ -95,6 +97,30 @@ class StateWatcher:
self._offline_hits[host] = hits self._offline_hits[host] = hits
if hits >= 2 and not prev_offline: if hits >= 2 and not prev_offline:
self._emit(conn, host, name, "offline", "%s ist offline." % name, filename) self._emit(conn, host, name, "offline", "%s ist offline." % name, filename)
# Nach Crash/Shutdown (503/gklib) bleibt sonst „druckt“ im Protokoll.
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} self._prev[host] = {"state": "offline", "offline": True, "filename": filename}
return return
@@ -169,7 +195,7 @@ class StateWatcher:
jobs.sync_history(conn, self.cfg) jobs.sync_history(conn, self.cfg)
except Exception as exc: except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True) log.warning("Watcher Abgleich %s: %s", host, exc)
# History oft ohne Endzeit (interrupted) — Live trotzdem schließen. # History oft ohne Endzeit (interrupted) — Live trotzdem schließen.
dangling = store.find_live_run(conn, host) dangling = store.find_live_run(conn, host)
if dangling: if dangling:
@@ -180,7 +206,7 @@ class StateWatcher:
jobs.apply_deduction(conn, closed, self.cfg["energy"]) jobs.apply_deduction(conn, closed, self.cfg["energy"])
except Exception as exc: except Exception as exc:
print("Watcher Abzug %s: %s" % (host, exc), flush=True) log.warning("Watcher Abzug %s: %s", host, exc)
elif state in ("cancelled", "standby"): elif state in ("cancelled", "standby"):
# Live-Lauf schließen: normaler Abbruch, oder nach Offline/Neustart # Live-Lauf schließen: normaler Abbruch, oder nach Offline/Neustart
# (prev wird „offline“, der Übergang paused→standby geht sonst verloren). # (prev wird „offline“, der Übergang paused→standby geht sonst verloren).
@@ -207,7 +233,7 @@ class StateWatcher:
jobs.sync_history(conn, self.cfg) jobs.sync_history(conn, self.cfg)
except Exception as exc: except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True) log.warning("Watcher Abgleich %s: %s", host, exc)
elif state == "error" and prev_state != "error": elif state == "error" and prev_state != "error":
if prev_state == "printing" or prev_state == "paused": if prev_state == "printing" or prev_state == "paused":
store.set_bed_needs_clear(conn, host, True, filename or "Fehler") store.set_bed_needs_clear(conn, host, True, filename or "Fehler")
@@ -230,7 +256,7 @@ class StateWatcher:
jobs.sync_history(conn, self.cfg) jobs.sync_history(conn, self.cfg)
except Exception as exc: except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True) log.warning("Watcher Abgleich %s: %s", host, exc)
elif state == "error": elif state == "error":
if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0: if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0:
self._cool_heaters(host) self._cool_heaters(host)
@@ -412,7 +438,7 @@ class StateWatcher:
try: try:
moonraker.heaters_off(host) moonraker.heaters_off(host)
except Exception as exc: except Exception as exc:
print("Watcher Heizung aus %s: %s" % (host, exc), flush=True) log.warning("Watcher Heizung aus %s: %s", host, exc)
def _check_spool(self, conn, host, name, filename, live): def _check_spool(self, conn, host, name, filename, live):
need = float(live.get("weight") or 0) need = float(live.get("weight") or 0)
+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 waitress import serve
from grotti import db, jobs, library, store from grotti import db, jobs, library, logutil, store
from grotti.config import load from grotti.config import load
from web.app import create_app from web.app import create_app
log = logutil.get("grotti.server")
def background(cfg): def background(cfg):
def work(): def work():
@@ -20,10 +22,10 @@ def background(cfg):
jobs.sync_history(conn, cfg) jobs.sync_history(conn, cfg)
deleted = db.prune_run_samples(conn, keep_days=90) deleted = db.prune_run_samples(conn, keep_days=90)
if deleted: if deleted:
print("Proben gelöscht: %s" % deleted, flush=True) log.info("Proben gelöscht: %s", deleted)
conn.commit() conn.commit()
except Exception as exc: except Exception as exc:
print("Abgleich: %s" % exc, flush=True) log.warning("Abgleich: %s", exc)
threading.Thread(target=work, name="grotti-abgleich", daemon=True).start() threading.Thread(target=work, name="grotti-abgleich", daemon=True).start()
@@ -39,14 +41,15 @@ def _retention_loop(cfg, stop_event):
n = db.prune_run_samples(conn, keep_days=90) n = db.prune_run_samples(conn, keep_days=90)
conn.commit() conn.commit()
if n: if n:
print("Retention: %s Proben gelöscht." % n, flush=True) log.info("Retention: %s Proben gelöscht.", n)
except Exception as exc: except Exception as exc:
print("Retention: %s" % exc, flush=True) log.warning("Retention: %s", exc)
stop_event.wait(3600) stop_event.wait(3600)
def main(): def main():
cfg = load() cfg = load()
logutil.setup(cfg.get("log_level") or "INFO")
cfg["data_dir"].mkdir(parents=True, exist_ok=True) cfg["data_dir"].mkdir(parents=True, exist_ok=True)
(cfg["data_dir"] / "eingang").mkdir(exist_ok=True) (cfg["data_dir"] / "eingang").mkdir(exist_ok=True)
db.prepare(cfg["database_url"]) db.prepare(cfg["database_url"])
@@ -107,7 +110,7 @@ def main():
except (ValueError, OSError): except (ValueError, OSError):
pass pass
print("Grotti: http://%s:%s/" % (cfg["host"], cfg["port"]), flush=True) log.info("Grotti: http://%s:%s/", cfg["host"], cfg["port"])
serve(app, host=cfg["host"], port=cfg["port"], threads=16, channel_timeout=120) serve(app, host=cfg["host"], port=cfg["port"], threads=16, channel_timeout=120)
+24
View File
@@ -63,6 +63,30 @@ class BedarfTests(unittest.TestCase):
out = compute(conn) out = compute(conn)
self.assertEqual(out["shortfall"], []) 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__": if __name__ == "__main__":
unittest.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.assertEqual(code, "message")
self.assertIn("10107", text) self.assertIn("10107", text)
def test_active_filament_empty(self): def test_reason_for_pause(self):
code, text = filament_out_hint({"active_filament": {"empty": True}}) 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.assertEqual(code, "active_empty")
self.assertIn("leer", text.lower()) 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): def test_gate_status_zero_for_tool(self):
code, text = filament_out_hint({ code, text = filament_out_hint({
"tool": 2, "tool": 2,
@@ -25,13 +57,15 @@ class FilamentOutHintTests(unittest.TestCase):
self.assertEqual(code, "gate_empty") self.assertEqual(code, "gate_empty")
self.assertIn("3", text) 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({ code, text = filament_out_hint({
"is_paused": True, "tool": 0,
"active_filament": {}, "gate_status": [0, 0, 0, 0],
"active_filament": {"type": "PLA", "empty": False},
}) })
self.assertEqual(code, "mmu_paused") self.assertIsNone(code)
self.assertTrue(text) self.assertIsNone(text)
def test_no_hint_for_plain_pause(self): def test_no_hint_for_plain_pause(self):
code, text = filament_out_hint({"tool": 0, "gate_status": [1, 1]}, "paused") 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): def test_empty_profile_always_ok(self):
self.assertTrue(store._profile_matches("", "Sunlu PLA Meta")) 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): class MatchSpoolsTests(unittest.TestCase):
def setUp(self): def setUp(self):
@@ -37,8 +43,51 @@ class MatchSpoolsTests(unittest.TestCase):
"material_name": "sunlu pla matte", "material_name": "sunlu pla matte",
"ace_slot": 2, "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): def test_picks_meta_not_matte(self):
need = {"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "", "paint_index": 0} need = {"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "", "paint_index": 0}
matched = store._match_spools_for_need(need, self.have) matched = store._match_spools_for_need(need, self.have)
+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 auth as grotti_auth
from grotti import config as grotti_config from grotti import config as grotti_config
from grotti import db as grotti_db from grotti import db as grotti_db
from grotti import eject, jobs, library, moonraker, notify, store from grotti import eject, jobs, library, logutil, moonraker, notify, store
from grotti.catalog import color_catalog from grotti.catalog import color_catalog
from grotti.cost import calculate, offer_from_file, order_actuals from grotti.cost import calculate, offer_from_file, order_actuals
from grotti import orders as grotti_orders from grotti import orders as grotti_orders
@@ -32,6 +32,8 @@ from grotti.version import __version__
from grotti import verdict as grotti_verdict from grotti import verdict as grotti_verdict
from grotti.watcher import StateWatcher from grotti.watcher import StateWatcher
log = logutil.get("grotti.web")
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
TOOL_ROOT = ROOT.parent TOOL_ROOT = ROOT.parent
DOCS_DIR = TOOL_ROOT / "docs" DOCS_DIR = TOOL_ROOT / "docs"
@@ -316,7 +318,7 @@ def create_app(cfg):
message = "Diesen Eintrag gibt es schon." message = "Diesen Eintrag gibt es schon."
elif isinstance(err, psycopg.errors.InvalidTextRepresentation): elif isinstance(err, psycopg.errors.InvalidTextRepresentation):
message = "Ungültiger Wert in der Anfrage." message = "Ungültiger Wert in der Anfrage."
print("DB: %s" % err, flush=True) log.warning("DB: %s", err)
return jsonify({"error": message, "code": "db"}), 500 return jsonify({"error": message, "code": "db"}), 500
def body(): def body():
@@ -582,6 +584,7 @@ def create_app(cfg):
ANLEITUNG_ORDER = ( ANLEITUNG_ORDER = (
"Anleitung-Einrichtung", "Anleitung-Einrichtung",
"Anleitung-Installation-Pi",
"Anleitung-Oberflaeche", "Anleitung-Oberflaeche",
"Anleitung-Wand", "Anleitung-Wand",
"Anleitung-Dateien", "Anleitung-Dateien",
@@ -605,6 +608,7 @@ def create_app(cfg):
def anleitung_title(name): def anleitung_title(name):
labels = { labels = {
"Anleitung-Einrichtung": "Einrichtung", "Anleitung-Einrichtung": "Einrichtung",
"Anleitung-Installation-Pi": "Raspberry Pi (.deb)",
"Anleitung-Oberflaeche": "Oberfläche", "Anleitung-Oberflaeche": "Oberfläche",
"Anleitung-Wand": "Druckerwand", "Anleitung-Wand": "Druckerwand",
"Anleitung-Dateien": "Dateien senden", "Anleitung-Dateien": "Dateien senden",
@@ -1233,6 +1237,14 @@ def create_app(cfg):
moved = library.move_file(conn(), cfg, file_id, folder) moved = library.move_file(conn(), cfg, file_id, folder)
return jsonify(moved) 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") @app.post("/api/v1/dateien")
def file_upload(): def file_upload():
upload = request.files.get("file") upload = request.files.get("file")
@@ -1690,7 +1702,7 @@ def create_app(cfg):
try: try:
jobs.apply_deduction(c, closed, cfg["energy"]) jobs.apply_deduction(c, closed, cfg["energy"])
except Exception as exc: except Exception as exc:
print("Abräumen Abzug %s: %s" % (host, exc), flush=True) log.warning("Abräumen Abzug %s: %s", host, exc)
row = store.get_run_row(c, closed["id"]) or closed row = store.get_run_row(c, closed["id"]) or closed
run_json = store.run_out(row) run_json = store.run_out(row)
reprint = None reprint = None
@@ -2443,7 +2455,11 @@ def create_app(cfg):
@app.get("/api/v1/laeufe") @app.get("/api/v1/laeufe")
def run_list(): def run_list():
return jsonify(store.list_runs(conn())) try:
limit = min(1000, max(1, int(request.args.get("limit") or 500)))
except (TypeError, ValueError):
limit = 500
return jsonify(store.list_runs(conn(), limit=limit))
@app.get("/api/v1/laeufe/aktuell") @app.get("/api/v1/laeufe/aktuell")
def run_aktuell(): def run_aktuell():
+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 { list-style: none; }
.lib-info > summary::-webkit-details-marker { display: none; } .lib-info > summary::-webkit-details-marker { display: none; }
.lib-info-pop { .lib-info-pop {
position: absolute; position: fixed;
right: 0; z-index: 80;
top: calc(100% + 0.25rem);
z-index: 20;
min-width: 11rem; min-width: 11rem;
max-width: min(18rem, calc(100vw - 1rem));
padding: 0.55rem 0.7rem; padding: 0.55rem 0.7rem;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--line-strong); border: 1px solid var(--line-strong);
background: #121217; background: var(--card-2);
box-shadow: var(--shadow); box-shadow: var(--shadow);
font-size: var(--t-xs); font-size: var(--t-xs);
color: var(--muted); color: var(--ink);
display: grid; display: none;
gap: 0.2rem; gap: 0.2rem;
line-height: 1.35;
} }
.lib-info[open] > .lib-info-pop { display: grid; }
.picked-details { opacity: 0.9; } .picked-details { opacity: 0.9; }
.flow-step { padding: 0.9rem 0 0.95rem; border-top: 1px solid var(--line); } .flow-step { padding: 0.9rem 0 0.95rem; border-top: 1px solid var(--line); }
@@ -3644,8 +3645,29 @@ dialog::backdrop { background: rgba(0, 0, 0, 0.55); backdrop-filter: blur(4px);
margin-right: 0.35rem; margin-right: 0.35rem;
} }
.pager {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin: 0.75rem 0 0;
padding: 0.35rem 0;
}
.pager[hidden] { display: none !important; }
.pager-info {
font-size: 0.85rem;
color: var(--muted);
min-width: 7rem;
text-align: center;
}
.pager button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
@media print { @media print {
.sidebar, .mobile-nav, .conn-banner, .toast-host, .nav-toggle { display: none !important; } .sidebar, .mobile-nav, .conn-banner, .toast-host, .nav-toggle, .pager { display: none !important; }
.app-shell { display: block; } .app-shell { display: block; }
.app-main { margin: 0; } .app-main { margin: 0; }
body { background: #fff; color: #111; } body { background: #fff; color: #111; }
+44
View File
@@ -333,3 +333,47 @@ window.setConnectionBanner = setConnectionBanner;
window.createPoller = createPoller; window.createPoller = createPoller;
window.promptAbraeumen = promptAbraeumen; window.promptAbraeumen = promptAbraeumen;
window.VERDICT_REASON_LABELS = VERDICT_REASON_LABELS; window.VERDICT_REASON_LABELS = VERDICT_REASON_LABELS;
/** Listen-Pagination (clientseitig auf gefiltertem Array). */
const DEFAULT_PAGE_SIZE = 40;
function pageSlice(items, page, pageSize) {
const list = Array.isArray(items) ? items : [];
const size = Math.max(1, Number(pageSize) || DEFAULT_PAGE_SIZE);
const total = list.length;
const pages = Math.max(1, Math.ceil(total / size) || 1);
let p = Math.max(1, parseInt(page, 10) || 1);
if (p > pages) p = pages;
const start = (p - 1) * size;
return {
items: list.slice(start, start + size),
page: p,
pages,
total,
pageSize: size,
from: total ? start + 1 : 0,
to: Math.min(start + size, total),
};
}
function renderPager(el, state, onPage) {
if (!el) return;
const s = state || pageSlice([], 1);
if (s.total <= s.pageSize) {
el.hidden = true;
el.innerHTML = "";
return;
}
el.hidden = false;
el.className = "pager";
el.innerHTML =
'<button type="button" class="ghost pager-prev" ' + (s.page <= 1 ? "disabled" : "") + ">Zurück</button>" +
'<span class="pager-info">' + s.from + "–" + s.to + " von " + s.total + "</span>" +
'<button type="button" class="ghost pager-next" ' + (s.page >= s.pages ? "disabled" : "") + ">Weiter</button>";
el.querySelector(".pager-prev").onclick = () => onPage(s.page - 1);
el.querySelector(".pager-next").onclick = () => onPage(s.page + 1);
}
window.DEFAULT_PAGE_SIZE = DEFAULT_PAGE_SIZE;
window.pageSlice = pageSlice;
window.renderPager = renderPager;
+16 -3
View File
@@ -26,9 +26,12 @@
<div class="skeleton" style="height:4rem"></div> <div class="skeleton" style="height:4rem"></div>
<div class="skeleton" style="height:4rem"></div> <div class="skeleton" style="height:4rem"></div>
</div> </div>
<div class="pager" id="tl-pager" hidden></div>
{% endblock %} {% endblock %}
{% block script %} {% block script %}
<script> <script>
let tlPage = 1;
let tlItems = [];
const KINDS = [ const KINDS = [
["queue", "Warteschlange"], ["queue", "Warteschlange"],
["ablauf", "Ablauf"], ["ablauf", "Ablauf"],
@@ -111,14 +114,19 @@ function itemHtml(ev) {
function render(items) { function render(items) {
const box = document.getElementById("timeline"); const box = document.getElementById("timeline");
const note = document.getElementById("tl-note"); const note = document.getElementById("tl-note");
if (!items.length) { const pager = document.getElementById("tl-pager");
tlItems = items || [];
if (!tlItems.length) {
box.innerHTML = `<div class="card empty-state">${icon("activity")}<p>Keine Einträge${filter.range === "day" ? " heute" : ""}.</p>${ box.innerHTML = `<div class="card empty-state">${icon("activity")}<p>Keine Einträge${filter.range === "day" ? " heute" : ""}.</p>${
filter.range !== "all" ? `<button type="button" class="ghost" data-range-jump="all">Alle anzeigen</button>` : ""}</div>`; filter.range !== "all" ? `<button type="button" class="ghost" data-range-jump="all">Alle anzeigen</button>` : ""}</div>`;
note.textContent = ""; note.textContent = "";
renderPager(pager, null, () => {});
return; return;
} }
const paged = pageSlice(tlItems, tlPage, DEFAULT_PAGE_SIZE);
tlPage = paged.page;
const groups = []; const groups = [];
for (const ev of items) { for (const ev of paged.items) {
const label = dayLabel(ev.created_at); const label = dayLabel(ev.created_at);
if (!groups.length || groups[groups.length - 1].label !== label) groups.push({ label, items: [] }); if (!groups.length || groups[groups.length - 1].label !== label) groups.push({ label, items: [] });
groups[groups.length - 1].items.push(ev); groups[groups.length - 1].items.push(ev);
@@ -126,7 +134,11 @@ function render(items) {
box.innerHTML = groups.map((g) => box.innerHTML = groups.map((g) =>
`<section class="tl-day"><h3>${escapeHtml(g.label)} · ${g.items.length}</h3><ol class="tl-list">${g.items.map(itemHtml).join("")}</ol></section>` `<section class="tl-day"><h3>${escapeHtml(g.label)} · ${g.items.length}</h3><ol class="tl-list">${g.items.map(itemHtml).join("")}</ol></section>`
).join(""); ).join("");
note.textContent = items.length >= 300 ? "Die letzten 300 Einträge. Zeitraum oder Filter enger stellen für ältere." : ""; note.textContent = tlItems.length >= 300 ? "Die letzten 300 Einträge. Zeitraum oder Filter enger stellen für ältere." : "";
renderPager(pager, paged, (p) => {
tlPage = p;
render(tlItems);
});
} }
async function load() { async function load() {
@@ -137,6 +149,7 @@ async function load() {
if (filter.host) q.set("host", filter.host); if (filter.host) q.set("host", filter.host);
try { try {
const data = await api("/ereignisse?" + q.toString()); const data = await api("/ereignisse?" + q.toString());
tlPage = 1;
render(data.items || []); render(data.items || []);
} catch (err) { } catch (err) {
document.getElementById("timeline").innerHTML = `<div class="strip bad">${icon("alert")}<span>${escapeHtml(err.message)}</span></div>`; document.getElementById("timeline").innerHTML = `<div class="strip bad">${icon("alert")}<span>${escapeHtml(err.message)}</span></div>`;
+13 -1
View File
@@ -29,6 +29,7 @@
<thead><tr><th>Titel</th><th>Status</th><th>Fortschritt</th></tr></thead> <thead><tr><th>Titel</th><th>Status</th><th>Fortschritt</th></tr></thead>
<tbody id="order-rows"></tbody> <tbody id="order-rows"></tbody>
</table> </table>
<div class="pager" id="order-pager" hidden></div>
</div> </div>
</section> </section>
<section class="card"> <section class="card">
@@ -246,16 +247,22 @@ async function einreihen(itemId) {
} }
} }
let orderPage = 1;
function renderList() { function renderList() {
const body = document.getElementById("order-rows"); const body = document.getElementById("order-rows");
body.innerHTML = ""; body.innerHTML = "";
const pager = document.getElementById("order-pager");
if (!orders.length) { if (!orders.length) {
body.innerHTML = '<tr><td colspan="3" class="empty"><div class="empty-state"><p>Noch kein Auftrag.</p><p><button type="button" class="ghost" id="order-empty-new">Ersten Auftrag anlegen →</button></p></div></td></tr>'; body.innerHTML = '<tr><td colspan="3" class="empty"><div class="empty-state"><p>Noch kein Auftrag.</p><p><button type="button" class="ghost" id="order-empty-new">Ersten Auftrag anlegen →</button></p></div></td></tr>';
document.getElementById("order-empty-new")?.addEventListener("click", () => fillOrder(null)); document.getElementById("order-empty-new")?.addEventListener("click", () => fillOrder(null));
renderPager(pager, null, () => {});
return; return;
} }
const selected = document.getElementById("order-id").value; const selected = document.getElementById("order-id").value;
orders.forEach((order) => { const paged = pageSlice(orders, orderPage, DEFAULT_PAGE_SIZE);
orderPage = paged.page;
paged.items.forEach((order) => {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.className = "pick" + (selected && String(order.id) === selected ? " on" : ""); tr.className = "pick" + (selected && String(order.id) === selected ? " on" : "");
tr.innerHTML = "<td></td><td></td><td></td>"; tr.innerHTML = "<td></td><td></td><td></td>";
@@ -275,12 +282,17 @@ function renderList() {
}); });
body.appendChild(tr); body.appendChild(tr);
}); });
renderPager(pager, paged, (p) => {
orderPage = p;
renderList();
});
} }
async function loadList() { async function loadList() {
const st = document.getElementById("filter-status").value; const st = document.getElementById("filter-status").value;
const q = st ? "?status=" + encodeURIComponent(st) : ""; const q = st ? "?status=" + encodeURIComponent(st) : "";
orders = await api("/auftraege" + q); orders = await api("/auftraege" + q);
orderPage = 1;
renderList(); renderList();
} }
+111 -10
View File
@@ -57,6 +57,7 @@
<div class="skeleton" style="height:4rem"></div> <div class="skeleton" style="height:4rem"></div>
<div class="skeleton" style="height:4rem"></div> <div class="skeleton" style="height:4rem"></div>
</div> </div>
<div class="pager" id="lib-pager" hidden></div>
<p class="drop-hint">Dateien hierher ziehen zum Hochladen.</p> <p class="drop-hint">Dateien hierher ziehen zum Hochladen.</p>
</section> </section>
@@ -360,17 +361,37 @@ function spoolWarnText() {
const hosts = selectedHosts(); const hosts = selectedHosts();
const fileTypes = ((file.filaments || []).map((f) => (f.type || "").toLowerCase()).filter(Boolean)); const fileTypes = ((file.filaments || []).map((f) => (f.type || "").toLowerCase()).filter(Boolean));
const fileProfiles = ((file.filaments || []).map((f) => (f.profile || "").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) { function profileOk(spoolRows) {
if (!fileProfiles.length) return true; if (!fileProfiles.length) return true;
const names = spoolRows.map((s) => (s.name || "").toLowerCase()).filter(Boolean); const names = spoolRows.map((s) => (s.name || "").toLowerCase()).filter(Boolean);
if (!names.length) return true; const types = spoolRows.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean);
return fileProfiles.every((want) => names.some((n) => { if (!names.length && !types.length) return true;
if (n === want) return true; return fileProfiles.every((want) => {
if (!n.startsWith(want) || /[a-z0-9]/i.test(n.charAt(want.length) || "")) return false; if (names.some((n) => {
const rest = n.slice(want.length).replace(/^[\s·\-_|/]+/, ""); if (n === want) return true;
const first = (rest.split(/\s+/)[0] || "").toLowerCase(); if (!n.startsWith(want) || /[a-z0-9]/i.test(n.charAt(want.length) || "")) return false;
return !["meta", "matte", "matt", "silk", "plus", "hf", "cf"].includes(first); const rest = n.slice(want.length).replace(/^[\s·\-_|/]+/, "");
})); const first = (rest.split(/\s+/)[0] || "").toLowerCase();
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) { for (const host of hosts) {
const name = (printers.find((p) => p.host === host) || {}).name || host; const name = (printers.find((p) => p.host === host) || {}).name || host;
@@ -819,6 +840,39 @@ function infoPopupHtml(file) {
</details>`; </details>`;
} }
function placeLibInfoPop(details) {
const pop = details.querySelector(".lib-info-pop");
const anchor = details.querySelector("summary");
if (!pop || !anchor) return;
const margin = 6;
const r = anchor.getBoundingClientRect();
pop.style.left = "0px";
pop.style.top = "0px";
const pw = pop.offsetWidth;
const ph = pop.offsetHeight;
let left = r.right - pw;
left = Math.max(margin, Math.min(left, window.innerWidth - pw - margin));
let top = r.bottom + margin;
if (top + ph > window.innerHeight - margin && r.top - margin - ph >= margin) {
top = r.top - margin - ph;
} else {
top = Math.min(top, Math.max(margin, window.innerHeight - ph - margin));
}
pop.style.left = left + "px";
pop.style.top = top + "px";
}
function closeOpenLibInfo(except) {
document.querySelectorAll("details.lib-info[open]").forEach((node) => {
if (node !== except) node.removeAttribute("open");
});
}
function repositionOpenLibInfo() {
const open = document.querySelector("details.lib-info[open]");
if (open) placeLibInfoPop(open);
}
function thumbHtml(file) { function thumbHtml(file) {
return file.preview return file.preview
? `<img alt="" loading="lazy" src="${escapeHtml(withToken(file.preview))}" onerror="this.style.visibility='hidden'">` ? `<img alt="" loading="lazy" src="${escapeHtml(withToken(file.preview))}" onerror="this.style.visibility='hidden'">`
@@ -827,14 +881,17 @@ function thumbHtml(file) {
function libItemHtml(file) { function libItemHtml(file) {
const project = projects.find((p) => String(p.id) === String(file.project_id)); 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>`; 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}"> return `<div class="lib-item ${String(file.id) === String(sel.fileId) ? "on" : ""}" role="button" tabindex="0" data-file="${file.id}">
${thumbHtml(file)} ${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 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>`; </div>`;
} }
let libPage = 1;
function renderLibrary() { function renderLibrary() {
const list = document.getElementById("lib-list"); const list = document.getElementById("lib-list");
const shown = filteredFiles(); const shown = filteredFiles();
@@ -842,15 +899,19 @@ function renderLibrary() {
document.getElementById("lib-count").textContent = shown.length === files.length ? String(files.length) : shown.length + "/" + files.length; document.getElementById("lib-count").textContent = shown.length === files.length ? String(files.length) : shown.length + "/" + files.length;
if (!files.length) { if (!files.length) {
list.innerHTML = '<div class="picked-empty">' + icon("file") + '<span>Noch keine Datei.</span><span class="tiny">Hochladen oder Slice-Ordner in den Einstellungen setzen.</span></div>'; list.innerHTML = '<div class="picked-empty">' + icon("file") + '<span>Noch keine Datei.</span><span class="tiny">Hochladen oder Slice-Ordner in den Einstellungen setzen.</span></div>';
renderPager(document.getElementById("lib-pager"), null, () => {});
return; return;
} }
if (!shown.length) { if (!shown.length) {
list.innerHTML = '<div class="picked-empty">' + icon("search") + '<span>Keine Datei passt.</span></div>'; list.innerHTML = '<div class="picked-empty">' + icon("search") + '<span>Keine Datei passt.</span></div>';
renderPager(document.getElementById("lib-pager"), null, () => {});
return; return;
} }
const paged = pageSlice(shown, libPage, DEFAULT_PAGE_SIZE);
libPage = paged.page;
const parts = []; const parts = [];
let lastGroup = null; let lastGroup = null;
shown.forEach((file) => { paged.items.forEach((file) => {
let group = null; let group = null;
if (sort === "material") group = materialKey(file) || "Ohne Material"; if (sort === "material") group = materialKey(file) || "Ohne Material";
else if (sort === "folder") group = folderLabel(folderKey(file)); else if (sort === "folder") group = folderLabel(folderKey(file));
@@ -861,6 +922,10 @@ function renderLibrary() {
parts.push(libItemHtml(file)); parts.push(libItemHtml(file));
}); });
list.innerHTML = parts.join(""); list.innerHTML = parts.join("");
renderPager(document.getElementById("lib-pager"), paged, (p) => {
libPage = p;
renderLibrary();
});
} }
/* ---------- Flow rechts ---------- */ /* ---------- Flow rechts ---------- */
@@ -1525,11 +1590,46 @@ async function doAblauf() {
/* ---------- Ereignisse ---------- */ /* ---------- Ereignisse ---------- */
document.getElementById("lib-list").addEventListener("toggle", (event) => {
const details = event.target;
if (!(details instanceof HTMLDetailsElement) || !details.classList.contains("lib-info")) return;
if (details.open) {
closeOpenLibInfo(details);
placeLibInfoPop(details);
}
}, true);
document.getElementById("lib-list").addEventListener("scroll", repositionOpenLibInfo, { passive: true });
window.addEventListener("resize", repositionOpenLibInfo);
document.addEventListener("pointerdown", (event) => {
if (event.target.closest("details.lib-info")) return;
closeOpenLibInfo();
});
document.getElementById("lib-list").addEventListener("click", (event) => { document.getElementById("lib-list").addEventListener("click", (event) => {
if (event.target.closest(".lib-move")) { if (event.target.closest(".lib-move")) {
event.stopPropagation(); event.stopPropagation();
return; 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]"); const del = event.target.closest("[data-delete]");
if (del) { if (del) {
event.stopPropagation(); event.stopPropagation();
@@ -1589,6 +1689,7 @@ document.getElementById("lib-list").addEventListener("keydown", (event) => {
const node = document.getElementById(id); const node = document.getElementById(id);
if (!node) return; if (!node) return;
const onChange = () => { const onChange = () => {
libPage = 1;
saveLibMemory(); saveLibMemory();
renderLibrary(); renderLibrary();
}; };
+2 -2
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="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}"> <link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}"> <link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=68"> <link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=70">
<link rel="manifest" href="{{ url_for('static', filename='manifest.webmanifest') }}"> <link rel="manifest" href="{{ url_for('static', filename='manifest.webmanifest') }}">
<meta name="theme-color" content="#0a0a0c" id="theme-color-meta"> <meta name="theme-color" content="#0a0a0c" id="theme-color-meta">
<script> <script>
@@ -161,7 +161,7 @@
<form method="dialog"><button class="ghost" value="cancel">Schließen</button></form> <form method="dialog"><button class="ghost" value="cancel">Schließen</button></form>
</dialog> </dialog>
<script src="{{ url_for('static', filename='api.js') }}?v=32"></script> <script src="{{ url_for('static', filename='api.js') }}?v=32"></script>
<script src="{{ url_for('static', filename='ui.js') }}?v=5"></script> <script src="{{ url_for('static', filename='ui.js') }}?v=6"></script>
<script> <script>
async function doLogout() { async function doLogout() {
try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {} try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {}
+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> <thead><tr><th>Spule</th><th>Charge</th><th>Sorte</th><th>Farbe</th><th>Ort</th><th class="num">Rest</th></tr></thead>
<tbody id="spool-rows"></tbody> <tbody id="spool-rows"></tbody>
</table> </table>
<div class="pager" id="spool-pager" hidden></div>
</section> </section>
<section class="card mat-form" id="spool-panel" hidden> <section class="card mat-form" id="spool-panel" hidden>
@@ -683,6 +684,8 @@ function fillSelects() {
materialSelect.value = currentMaterial; materialSelect.value = currentMaterial;
} }
let spoolPage = 1;
function renderSpools() { function renderSpools() {
document.querySelectorAll("#spool-filter .fchip").forEach((btn) => { document.querySelectorAll("#spool-filter .fchip").forEach((btn) => {
btn.classList.toggle("on", (btn.dataset.filter || "all") === spoolFilter); btn.classList.toggle("on", (btn.dataset.filter || "all") === spoolFilter);
@@ -698,14 +701,17 @@ function renderSpools() {
spoolFilter === "archive" ? shown.length : activeCount spoolFilter === "archive" ? shown.length : activeCount
); );
const note = document.getElementById("spool-list-note"); const note = document.getElementById("spool-list-note");
const pager = document.getElementById("spool-pager");
if (!spools.length) { if (!spools.length) {
note.textContent = ""; note.textContent = "";
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("spool") + '<p>Noch keine Spule.</p><p class="tiny">„Neue Spule“ anlegen oder Code scannen.</p></div></td></tr>'; body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("spool") + '<p>Noch keine Spule.</p><p class="tiny">„Neue Spule“ anlegen oder Code scannen.</p></div></td></tr>';
renderPager(pager, null, () => {});
return; return;
} }
if (!shown.length) { if (!shown.length) {
note.textContent = ""; note.textContent = "";
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("search") + '<p>Keine Spule passt.</p></div></td></tr>'; body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("search") + '<p>Keine Spule passt.</p></div></td></tr>';
renderPager(pager, null, () => {});
return; return;
} }
note.textContent = spoolFilter === "archive" note.textContent = spoolFilter === "archive"
@@ -719,8 +725,10 @@ function renderSpools() {
const bh = b.printer_host || "zzz"; const bh = b.printer_host || "zzz";
return ah.localeCompare(bh, "de") || (Number(a.ace_slot) || 0) - (Number(b.ace_slot) || 0) || a.name.localeCompare(b.name, "de"); return ah.localeCompare(bh, "de") || (Number(a.ace_slot) || 0) - (Number(b.ace_slot) || 0) || a.name.localeCompare(b.name, "de");
}); });
const paged = pageSlice(ordered, spoolPage, DEFAULT_PAGE_SIZE);
spoolPage = paged.page;
let lastPlace = null; let lastPlace = null;
ordered.forEach((spool) => { paged.items.forEach((spool) => {
const group = spool.printer_host ? printerName(spool.printer_host) : "Regal"; const group = spool.printer_host ? printerName(spool.printer_host) : "Regal";
if (group !== lastPlace) { if (group !== lastPlace) {
lastPlace = group; lastPlace = group;
@@ -753,6 +761,10 @@ function renderSpools() {
tr.addEventListener("click", () => fill(spool)); tr.addEventListener("click", () => fill(spool));
body.appendChild(tr); body.appendChild(tr);
}); });
renderPager(pager, paged, (p) => {
spoolPage = p;
renderSpools();
});
} }
function renderSorten() { function renderSorten() {
@@ -867,6 +879,7 @@ document.querySelectorAll(".mat-tabs [data-view]").forEach((btn) => {
}); });
document.getElementById("spool-filter").addEventListener("click", (event) => { document.getElementById("spool-filter").addEventListener("click", (event) => {
spoolPage = 1;
const btn = event.target.closest("[data-filter]"); const btn = event.target.closest("[data-filter]");
if (!btn) return; if (!btn) return;
spoolFilter = btn.dataset.filter || "all"; spoolFilter = btn.dataset.filter || "all";
@@ -874,6 +887,7 @@ document.getElementById("spool-filter").addEventListener("click", (event) => {
}); });
document.getElementById("spool-search").addEventListener("input", (event) => { document.getElementById("spool-search").addEventListener("input", (event) => {
spoolSearch = event.target.value || ""; spoolSearch = event.target.value || "";
spoolPage = 1;
renderSpools(); renderSpools();
}); });
document.getElementById("sorten-search").addEventListener("input", (event) => { document.getElementById("sorten-search").addEventListener("input", (event) => {
@@ -996,10 +1010,12 @@ document.getElementById("spool-delete").addEventListener("click", () => {
}); });
}); });
document.getElementById("spool-filter-type")?.addEventListener("change", (event) => { document.getElementById("spool-filter-type")?.addEventListener("change", (event) => {
spoolPage = 1;
spoolTypeFilter = event.target.value || ""; spoolTypeFilter = event.target.value || "";
renderSpools(); renderSpools();
}); });
document.getElementById("spool-filter-color")?.addEventListener("change", (event) => { document.getElementById("spool-filter-color")?.addEventListener("change", (event) => {
spoolPage = 1;
spoolColorFilter = event.target.value || ""; spoolColorFilter = event.target.value || "";
renderSpools(); renderSpools();
}); });
+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> <thead><tr><th>Name</th><th>Status</th><th>Ordner</th><th class="num">Dateien</th></tr></thead>
<tbody id="proj-rows"></tbody> <tbody id="proj-rows"></tbody>
</table> </table>
<div class="pager" id="proj-pager" hidden></div>
</div> </div>
</section> </section>
@@ -110,15 +111,21 @@ function fill(project) {
renderDetail(project); renderDetail(project);
} }
let projPage = 1;
function renderList() { function renderList() {
const body = document.getElementById("proj-rows"); const body = document.getElementById("proj-rows");
const pager = document.getElementById("proj-pager");
document.getElementById("proj-count").textContent = String(projects.length); document.getElementById("proj-count").textContent = String(projects.length);
if (!projects.length) { if (!projects.length) {
body.innerHTML = '<tr><td class="empty" colspan="4"><div class="empty-state">' + icon("folder") + "<p>Noch kein Projekt.</p></div></td></tr>"; body.innerHTML = '<tr><td class="empty" colspan="4"><div class="empty-state">' + icon("folder") + "<p>Noch kein Projekt.</p></div></td></tr>";
renderPager(pager, null, () => {});
return; return;
} }
body.innerHTML = ""; body.innerHTML = "";
projects.forEach((project) => { const paged = pageSlice(projects, projPage, DEFAULT_PAGE_SIZE);
projPage = paged.page;
paged.items.forEach((project) => {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.className = "pick" + (String(project.id) === String(selectedId) ? " on" : ""); tr.className = "pick" + (String(project.id) === String(selectedId) ? " on" : "");
tr.innerHTML = "<td></td><td></td><td></td><td class=\"num\"></td>"; tr.innerHTML = "<td></td><td></td><td></td><td class=\"num\"></td>";
@@ -130,6 +137,10 @@ function renderList() {
tr.addEventListener("click", () => openProject(project.id)); tr.addEventListener("click", () => openProject(project.id));
body.appendChild(tr); body.appendChild(tr);
}); });
renderPager(pager, paged, (p) => {
projPage = p;
renderList();
});
} }
function renderDetail(project) { function renderDetail(project) {
+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> <thead><tr><th>Drucker</th><th>Datei</th><th>Zustand</th><th>Dauer</th><th>Ist</th><th>Kosten</th><th>Urteil</th><th>Spule</th><th></th></tr></thead>
<tbody id="run-rows"></tbody> <tbody id="run-rows"></tbody>
</table> </table>
<div class="pager" id="run-pager" hidden></div>
</div> </div>
{% endblock %} {% endblock %}
{% block script %} {% block script %}
@@ -37,6 +38,7 @@ const states = {
}; };
let runs = []; let runs = [];
let spools = []; let spools = [];
let runPage = 1;
function fillPrinterFilter() { function fillPrinterFilter() {
const select = document.getElementById("run-filter-printer"); const select = document.getElementById("run-filter-printer");
@@ -82,13 +84,17 @@ function render() {
const shown = filteredRuns(); const shown = filteredRuns();
if (!runs.length) { if (!runs.length) {
body.innerHTML = '<tr><td class="empty" colspan="9"><div class="empty-state">' + icon("list") + '<p>Noch kein Lauf.</p><p class="tiny">„Läufe holen“ liest die Drucker aus.</p></div></td></tr>'; body.innerHTML = '<tr><td class="empty" colspan="9"><div class="empty-state">' + icon("list") + '<p>Noch kein Lauf.</p><p class="tiny">„Läufe holen“ liest die Drucker aus.</p></div></td></tr>';
renderPager(document.getElementById("run-pager"), null, () => {});
return; return;
} }
if (!shown.length) { if (!shown.length) {
body.innerHTML = '<tr><td class="empty" colspan="9"><div class="empty-state">' + icon("filter") + '<p>Kein Lauf passt zum Filter.</p></div></td></tr>'; body.innerHTML = '<tr><td class="empty" colspan="9"><div class="empty-state">' + icon("filter") + '<p>Kein Lauf passt zum Filter.</p></div></td></tr>';
renderPager(document.getElementById("run-pager"), null, () => {});
return; return;
} }
shown.forEach((run) => { const paged = pageSlice(shown, runPage, DEFAULT_PAGE_SIZE);
runPage = paged.page;
paged.items.forEach((run) => {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
tr.className = "pick"; tr.className = "pick";
tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>"; tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>";
@@ -233,16 +239,21 @@ function render() {
} }
body.appendChild(tr); body.appendChild(tr);
}); });
renderPager(document.getElementById("run-pager"), paged, (p) => {
runPage = p;
render();
});
} }
async function load() { async function load() {
[runs, spools] = await Promise.all([api("/laeufe"), api("/spulen")]); [runs, spools] = await Promise.all([api("/laeufe?limit=500"), api("/spulen")]);
runPage = 1;
render(); render();
} }
["run-filter-printer", "run-filter-verdict", "run-filter-spool"].forEach((id) => { ["run-filter-printer", "run-filter-verdict", "run-filter-spool"].forEach((id) => {
document.getElementById(id).addEventListener("change", render); document.getElementById(id).addEventListener("change", () => { runPage = 1; render(); });
document.getElementById(id).addEventListener("input", render); document.getElementById(id).addEventListener("input", () => { runPage = 1; render(); });
}); });
document.getElementById("sync").addEventListener("click", async () => { document.getElementById("sync").addEventListener("click", async () => {
+14 -2
View File
@@ -34,6 +34,7 @@
</thead> </thead>
<tbody id="q-rows"></tbody> <tbody id="q-rows"></tbody>
</table> </table>
<div class="pager" id="q-pager" hidden></div>
</div> </div>
{% endblock %} {% endblock %}
{% block script %} {% block script %}
@@ -45,6 +46,7 @@ const STATUS_LABEL = {
let jobs = []; let jobs = [];
let liveDirect = []; let liveDirect = [];
let filter = ""; let filter = "";
let qPage = 1;
function targetOf(job) { function targetOf(job) {
return job.printer_name || job.host || (job.family_label ? "nächster " + job.family_label : "nächster freier"); return job.printer_name || job.host || (job.family_label ? "nächster " + job.family_label : "nächster freier");
@@ -86,6 +88,7 @@ function render() {
}); });
const body = document.getElementById("q-rows"); const body = document.getElementById("q-rows");
const shown = shownJobs(); const shown = shownJobs();
const pager = document.getElementById("q-pager");
if (!shown.length) { if (!shown.length) {
const hint = jobs.length const hint = jobs.length
? "Nichts in dieser Ansicht." ? "Nichts in dieser Ansicht."
@@ -97,10 +100,13 @@ function render() {
: ""; : "";
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("queue") + '<p>' + hint + '</p>' + cta + '</div></td></tr>'; body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("queue") + '<p>' + hint + '</p>' + cta + '</div></td></tr>';
document.getElementById("q-note").textContent = ""; document.getElementById("q-note").textContent = "";
renderPager(pager, null, () => {});
return; return;
} }
const waiting = jobs.filter((j) => j.status === "waiting"); const waiting = jobs.filter((j) => j.status === "waiting");
body.innerHTML = shown.map((job) => { const paged = pageSlice(shown, qPage, DEFAULT_PAGE_SIZE);
qPage = paged.page;
body.innerHTML = paged.items.map((job) => {
const st = job.status; const st = job.status;
if (job.direct) { if (job.direct) {
return `<tr data-direct="${escapeHtml(job.host)}"> return `<tr data-direct="${escapeHtml(job.host)}">
@@ -141,17 +147,22 @@ function render() {
</tr>`; </tr>`;
}).join(""); }).join("");
document.getElementById("q-note").textContent = shown.length === 1 ? "1 Eintrag" : shown.length + " Einträge"; document.getElementById("q-note").textContent = shown.length === 1 ? "1 Eintrag" : shown.length + " Einträge";
renderPager(pager, paged, (p) => {
qPage = p;
render();
});
} }
async function load() { async function load() {
try { try {
const [queue, active] = await Promise.all([ const [queue, active] = await Promise.all([
api("/druck-queue?limit=120"), api("/druck-queue?limit=200"),
api("/laeuft").catch(() => []), api("/laeuft").catch(() => []),
]); ]);
jobs = queue || []; jobs = queue || [];
const queuedHosts = new Set(jobs.filter((j) => j.status === "running").map((j) => j.host)); const queuedHosts = new Set(jobs.filter((j) => j.status === "running").map((j) => j.host));
liveDirect = (active || []).filter((p) => p.host && !queuedHosts.has(p.host)); liveDirect = (active || []).filter((p) => p.host && !queuedHosts.has(p.host));
qPage = 1;
render(); render();
} catch (error) { } catch (error) {
document.getElementById("q-rows").innerHTML = document.getElementById("q-rows").innerHTML =
@@ -163,6 +174,7 @@ document.getElementById("q-filter").addEventListener("click", (event) => {
const btn = event.target.closest("[data-status]"); const btn = event.target.closest("[data-status]");
if (!btn) return; if (!btn) return;
filter = btn.dataset.status || ""; filter = btn.dataset.status || "";
qPage = 1;
render(); render();
}); });
document.getElementById("q-refresh").addEventListener("click", load); document.getElementById("q-refresh").addEventListener("click", load);