2 Commits
40 changed files with 1162 additions and 146 deletions

No files matched your search

+5
View File
@@ -10,3 +10,8 @@ slice/*
__pycache__/
*.pyc
_tmp_*
%s
# gebaute .deb (Skript legt sie hier ab)
packaging/dist/*
!packaging/dist/.gitkeep
+20
View File
@@ -4,6 +4,25 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
## [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
@@ -12,6 +31,7 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
### 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).
+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
- PostgreSQL (eine leere Datenbank `grotti` reicht, das Schema legt Grotti selbst an)
- Drucker im selben Netz mit Moonraker auf Port 80
## Features
## Einrichten
| | |
| --- | --- |
| **Druckerwand & Cockpit** | Live-Status, Temps, Pause/Cancel, Farm-ETA |
| **Warteschlange** | Priorität, „nächster freier“, Material-Match, ACE-Map |
| **Dateien & Projekte** | Slice-Watch, Senden, Umbenennen, Projektordner |
| **Kobra-3-Ablauf** | Druck → Abkühlen → Schieben → Tasten |
| **Material** | Spulen, Restgewicht, Bedarf, Chargen |
| **Aufträge & Statistik** | Kunden, Lieferschein, Kosten (€ Material+Strom) |
| **Betrieb** | Auth (Session/`grt_…`), Telegram/ntfy, PWA |
Stack: **Python · Flask · PostgreSQL · Moonraker** (Rinkhals auf Kobra 3 / S1).
## Screenshots
<p align="center">
<img src="docs/community/06-jetzt.png" alt="Jetzt-Liste" width="440">
&nbsp;
<img src="docs/community/02-warteschlange.png" alt="Warteschlange" width="440">
</p>
<p align="center">
<img src="docs/community/09-dateien.png" alt="Dateien" width="440">
&nbsp;
<img src="docs/community/03-material.png" alt="Material" width="440">
</p>
<p align="center">
<img src="docs/community/05-statistik.png" alt="Statistik" width="440">
&nbsp;
<img src="docs/community/07-cockpit.png" alt="Cockpit" width="440">
</p>
Weitere Bilder und Post-Text: [`docs/community/`](docs/community/README.md).
## Schnellstart
**Voraussetzungen:** Python 3.11, PostgreSQL (leere DB `grotti`), Drucker mit Moonraker im LAN.
```powershell
cd D:\Projekte\Grotti
python -m venv .venv
.\.venv\Scripts\pip install -r requirements.txt
copy config.example.json config.json
```
`config.json` anpassen:
| Schlüssel | Bedeutung |
| --- | --- |
| `host`, `port` | Wo die Oberfläche lauscht. Standard `127.0.0.1:8770`. |
| `database_url` | PostgreSQL-Verbindung. |
| `data_dir` | Uploads, Vorschaubilder, Cache. Standard `data`. |
| `watch_dir` | Slice-Ordner (unter Einstellungen setzen). Leer = nur Upload. |
| `token` | Wenn gesetzt, braucht die API `Authorization: Bearer <token>`. |
| `fqdn` | Öffentliche Basis-URL für Spulen-Etiketten-QR. Leer = Request-Host. |
| `energy` | Watt und Strompreis für die Kosten im Protokoll. |
| `telegram`, `alerts` | Bot und welche Ereignisse als Nachricht kommen. |
| `auto_queue` | Wenn `false`, startet die Warteschlange nichts Neues. |
Die Drucker stehen beim ersten Start in `drucker.json` und landen dann in der Datenbank. Danach werden sie unter Einstellungen gepflegt.
Schema-Änderungen laufen beim Start über `grotti/migrations/` (Tabelle `schema_version`). Vor Major-Updates die Datenbank sichern (`pg_dump`).
```json
[{ "name": "Kobra 3 da26", "host": "10.10.1.60" }]
```
Die Familie (Kobra 3 oder S1) liest Grotti aus dem Namen.
## Starten
```powershell
.\.venv\Scripts\python.exe server.py
```
Dann <http://127.0.0.1:8770/> öffnen. Bedienungsanleitung mit Screenshots in der Oberfläche unter **Doku** (Kapitel `Anleitung-*`), Technik-Notizen darunter.
Dann <http://127.0.0.1:8770/>. Bedienung unter **Doku** in der App (`docs/Anleitung-*.md`).
`config.json` und `data/` sind lokal und nicht im Git.
### Raspberry Pi (`.deb`)
Auf dem Pi (arm64 / Bookworm) bauen und installieren — PostgreSQL und systemd-Dienst werden mit eingerichtet:
```bash
bash packaging/build-deb.sh
sudo apt install ./packaging/dist/grotti_*_arm64.deb
journalctl -u grotti -f
```
Details: [docs/Anleitung-Installation-Pi.md](docs/Anleitung-Installation-Pi.md). Öffentliches apt-Repo folgt später.
`config.json` und `data/` bleiben lokal (nicht im Git). Drucker zuerst in `drucker.json`, danach unter Einstellungen.
| Schlüssel | Bedeutung |
| --- | --- |
| `host`, `port` | Oberfläche (Standard `127.0.0.1:8770`) |
| `database_url` | PostgreSQL |
| `watch_dir` | Slice-Ordner (oder leer = nur Upload) |
| `energy` / `telegram` / `alerts` | Kosten und Benachrichtigungen |
Schema-Migrationen laufen beim Start (`grotti/migrations/`). Vor Major-Updates: `pg_dump`.
## Ordner
| Pfad | Inhalt |
| --- | --- |
| `server.py` | Start, Datenbank vorbereiten, Hintergrund-Threads |
| `grotti/` | Kern: Moonraker, Warteschlange (`print_queue.py`), Senden (`send_queue.py`), Abräumen (`eject.py`), Zustandswächter (`watcher.py`), Ereignisse und Telegram (`notify.py`), Datenbank (`store.py`, `schema.sql`) |
| `web/` | Flask-App (`app.py`), Seiten (`templates/`), CSS und JS (`static/`) |
| `docs/` | Bedienungsanleitung (`Anleitung-*.md`) unter `/doku`; Technik unter `docs/internal/` (nicht in der App) |
| `web/static/doku/` | Screenshots für die Anleitung |
| `CHANGELOG.md` | Änderungen je Version |
| `server.py` | Start, DB, Hintergrund-Threads |
| `grotti/` | Moonraker, Queue, Senden, Abräumen, Watcher, Store |
| `web/` | Flask, Templates, CSS/JS, Branding |
| `docs/` | Anleitungen (`/doku`), Community-Screens, Technik unter `internal/` |
| `CHANGELOG.md` | Versionen |
## API
Alles unter `/api/v1`, JSON. Auth: Session-Cookie (UI) oder `Authorization: Bearer grt_…` (API-Token unter Einstellungen). Optional noch Shared-`token` in der Config (Migrationsbrücke).
Alles unter `/api/v1`, JSON. Auth: Session oder `Authorization: Bearer grt_…`.
| Methode und Pfad | Zweck |
| --- | --- |
@@ -78,43 +123,33 @@ Alles unter `/api/v1`, JSON. Auth: Session-Cookie (UI) oder `Authorization: Bear
| `GET /me`, `/users`, `/tokens` | Konto, Benutzer (Admin), API-Tokens |
| `GET /version` | Version |
| `GET`/`PATCH /einstellungen`, `POST /telegram/test` | Config und Telegram-Probe |
| `GET /drucker?live=1` | Drucker mit Live-Zustand, laufendem und wartendem Auftrag |
| `POST /drucker` · `PATCH`/`DELETE /drucker/<id>` | Drucker anlegen/ändern (Admin) |
| `GET /drucker?live=1` | Drucker mit Live-Zustand |
| `POST /drucker/<host>/<aktion>` | Pause, Fortsetzen, Abbrechen, Fehler löschen |
| `POST /drucker/<host>/steuern` | Temperatur, Speed, Flow, Lüfter, ACE |
| `POST /drucker/<host>/abraeumen` | Platte geräumt (`confirm`) |
| `GET /kamera/<host>`, `/vorschau/<host>` | Snapshot / Job-Thumbnail |
| `GET`/`POST /dateien`, `PATCH`/`DELETE /dateien/<id>` | Bibliothek, Upload, Metadaten |
| `POST /dateien/neu-lesen`, `/dateien/ordner`, `/dateien/<id>/verschieben` | Watch-Sync, Ordner, Verschieben |
| `POST /senden`, `GET /sende-auftraege` | Sofort senden (`confirm`), Fortschritt |
| `GET`/`POST /druck-queue`, `PATCH /druck-queue/<id>` | Warteschlange (`confirm` beim Einreihen) |
| `GET /laeuft`, `/aufgaben` | Direktstarts und Platte-belegt |
| `POST /ablauf`, `/schieben-testen` | Ablauf Kobra 3 / Schieben-Test |
| `GET /spulen`, `POST /spulen`, `POST /spulen/<id>/einlegen` | Spulen und Einlegen |
| `GET /spulen/<id>/akte`, `/spulen/by-code` | Spulenakte, Scan `S-…` |
| `GET`/`POST /materialien`, `/farben` | Sorten und Farbpalette |
| `GET /laeufe`, `/laeufe/aktuell`, `/laeufe/<id>` | Protokoll und Druckbericht |
| `GET /statistik`, `/statistik.csv` | Auslastung und CSV |
| `GET`/`POST /projekte`, `/ideen` | Projekte und Ideen-Backlog |
| `GET /ereignisse`, `POST /ereignisse/gelesen` | Aktivität und Alerts |
| `POST /kalkulation` | Vorkalkulation Material+Strom |
| `GET`/`POST /dateien`, `PATCH`/`DELETE /dateien/<id>` | Bibliothek |
| `POST /senden`, `GET`/`POST /druck-queue` | Senden und Warteschlange (`confirm`) |
| `GET /spulen`, `POST /spulen/<id>/einlegen` | Material |
| `GET /laeufe`, `/statistik`, `/ereignisse` | Protokoll und Aktivität |
MCP: `python -m grotti.mcp_server` mit `GROTTI_URL` und `GROTTI_TOKEN` (siehe `docs/internal/MCP.md`).
MCP: `python -m grotti.mcp_server` mit `GROTTI_URL` / `GROTTI_TOKEN` → `docs/internal/MCP.md`.
## Sicherheit
- Login Pflicht (Setup legt den ersten Admin an). Rollen: Admin, Operator.
- Druckstarts brauchen `confirm: true`. Die Oberfläche fragt per Zwei-Klick.
- Keine freie G-code-Konsole; Firmware-Neustart über Grotti ist gesperrt.
- Für Internet: Reverse Proxy mit TLS; nicht den Entwickler-Server öffentlich freigeben.
- Login Pflicht. Rollen: Admin, Operator.
- Druckstarts brauchen `confirm: true` (Zwei-Klick in der UI).
- Keine freie G-code-Konsole; kein `FIRMWARE_RESTART` über Grotti.
- Nach außen: Reverse Proxy mit TLS.
## Versionen und Changelog
## Versionen
- Versionierung nach [SemVer](https://semver.org/lang/de/): Patch für Fehlerbehebung, Minor für neue Funktion, Major für Bruch an API oder Datenbankschema.
- Jede Änderung kommt in `CHANGELOG.md` unter `[Unreleased]` (Neu, Geändert, Behoben).
- Beim Release: Version in `grotti/version.py` setzen, `[Unreleased]` in `[x.y.z] – Datum` umbenennen, Git-Tag `vX.Y.Z`.
- Ändert sich `web/static/grotti.css` oder `api.js`, in `layout.html` den `?v=` hochzählen.
- SemVer in `grotti/version.py`, Einträge in [CHANGELOG.md](CHANGELOG.md).
- Release: `[Unreleased]` → `[x.y.z] – Datum`, Tag `vX.Y.Z`.
- CSS/JS-Cache: `?v=` in `layout.html` mitziehen.
## Repo
---
Eigenes Repository: <https://git.gundel.dev/dg/grotti>. Doku zum Tool gehört nach `docs/`.
<p align="center">
<img src="web/static/brand/logo.png" alt="" width="72"><br>
<sub>Repo: <a href="https://git.gundel.dev/dg/grotti">git.gundel.dev/dg/grotti</a></sub>
</p>
+1
View File
@@ -7,6 +7,7 @@
"token": "",
"secret_key": "",
"fqdn": "",
"log_level": "INFO",
"auto_queue": true,
"auto_retry": true,
"oauth": {
+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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

+3 -1
View File
@@ -1,6 +1,8 @@
# Grotti — Community-Vorstellung
Screenshots aus dem laufenden Betrieb (v3.2.0, Okt. 2026). Zielgruppe: mittlere bis große Printfarmen.
Screenshots aus dem laufenden Betrieb (v3.4.0, Okt. 2026). Zielgruppe: mittlere bis große Printfarmen.
Logo und Repo-Banner: [`docs/brand/`](../brand/). Haupt-README: [`../../README.md`](../../README.md).
---
+5
View File
@@ -139,3 +139,8 @@ Ohne Map: Rinkhals füllt oft Identity T0→Platz0 … — deshalb Grotti bei ei
- Quellcode von **gkapi** (Task-State-Machine, exakte 10101-Bedingungen) ist nicht öffentlich.
- Deadlock in **FIRMWARE_RESTART** ist beobachtet und in Rinkhals abgefangen, Root Cause im Binary nicht freigelegt.
- Kobra X / K4-Stack ist ein anderes System (C++-Klipper), nicht GoKlipper — für unsere Drucker irrelevant.
## Siehe auch
- [Makros-Kobra](Makros-Kobra.md) — Nicht-tun, Vanilla-Alltag, ACE, LeviQ/Wipe je Druckertyp
+195
View File
@@ -0,0 +1,195 @@
# Makros und G-code: Kobra S1 und Kobra 3
Stand **19.10.2026**. Interne Referenz — nicht unter `/doku`. Messwerte und Bettnetz-Story: `D:\Projekte\3DP\FDM\Wissen\Drucker\` (`Kobra S1.md`, `Kobra 3.md`). Stack: [Anycubic-Stack](Anycubic-Stack.md).
Quellen: `printer/gcode/help` (11.10. auf den Maschinen), GoKlipper/`extras_ace.go`, Rinkhals-RE `GOKLIPPER_ACE_GCODE_COMMANDS.md`, Grotti-Policy.
---
## Nicht tun
| Nicht | Warum | Stattdessen |
| --- | --- | --- |
| `FIRMWARE_RESTART` / `RESTART` | gklib Deadlock bis Power-Cycle (Rinkhals #32) | Display-Reboot oder Strom; hängenden Job: **Fehler löschen** (`SDCARD_RESET_FILE`) |
| `LEVIQ` / `LEVIQ2` aus Mainsail | Heizung + volle Nivellierfolge, fährt unerwartet | Am Display Auto-Level; Einzelschritte nur bewusst |
| `SAVE_CONFIG` aus Mainsail | schreibt u. a. Z-Offset mit | Dauerhaft nur gezielt / nach Display-Nivellieren |
| Nur Moonraker-Cancel | Anycubic-Task bleibt → Start-Code **10101** | Cancel + MQTT-stop (Grotti `abbrechen`) |
| Community-`ACE_SET_SLOT` / manuelles `G9111` als Grotti-Pfad | Grotti mappt über `.acm` + MQTT | Map in der App; Slice-`G9111` im Start-G-code belassen |
| `minimum_cruise_ratio` auf **0** (S1) | Moonraker hängt auf dieser Firmware | bei **0,05** belassen |
| `G28 Y` mit Teilen auf dem Bett (K3) | Sensorless-Home nach **vorn** über die Teile | Bett leeren; Abräumen ohne `G28 Z` |
| Blind `FEED` / `FEED_FILAMENT` ohne Unwind | Blockade bis Neustart (RE-Doku) | Unload/Unwind-Sequenz einhalten |
| `SET_KINEMATIC_POSITION` mit erfundener Z nach Stepper-aus | Portal sackt → Null daneben, Probe „No trigger“ oder Crash | echte letzte XYZ; oder Bett leer + `G28` |
| Freie G-code-Konsole in Grotti für Experimente | Policy | nur bekannte, bestätigte Scripts |
| Abräumen-Makro in `printer.cfg` pflegen | Rinkhals schreibt Config neu | Grotti schickt Scripts per Moonraker |
---
## Vanilla-Klipper-Alltag (beide Typen)
Befehl → was er macht. Anycubic-Hinweis nur wo nötig.
### Homes und Bewegung
| Befehl | Funktion |
| --- | --- |
| `G28` | Homed die genannten Achsen (ohne Argument: alle). **K3:** Y-Home fährt nach vorn — Bett muss leer sein. |
| `G0` / `G1` | Lineare Bewegung / Extrusion. Absolute/relative Modi wie üblich (`G90`/`G91`, `M82`/`M83`). |
| `M400` | Wartet, bis alle Moves fertig sind. |
| `SET_VELOCITY_LIMIT` | Limits für Speed/Accel zur Laufzeit. |
| `SET_KINEMATIC_POSITION` | Setzt die interne Position ohne zu fahren. Nach `M84`/Leerlauf riskant — siehe „Nicht tun“. |
| `SET_IDLE_TIMEOUT` | Idle-Zeit bis Stepper aus. Abräumen hält Idle bewusst länger. |
| `M84` / `M18` | Stepper aus → Achsen unhomed. Slice-Ende K3 macht das oft; Abräumen entfernt es beim Upload. |
### Heizen
| Befehl | Funktion |
| --- | --- |
| `M104` / `M109` | Düse Soll / warten bis erreicht. |
| `M140` / `M190` | Bett Soll / warten. |
| `TURN_OFF_HEATERS` | Düse und Bett aus. |
| `PID_CALIBRATE` | PID für Extruder oder Bett kalibrieren. Danach oft `SAVE_CONFIG` — auf diesen Maschinen vorsichtig. |
| `M106` / `M107` | Teilkühlung an / aus. |
### Drucklauf
| Befehl | Funktion |
| --- | --- |
| `PAUSE` / `RESUME` / `CANCEL_PRINT` | Rinkhals-Makros um Pause/Weiter/Abbruch. Moonraker-Cancel allein räumt den Anycubic-Task nicht. |
| `SDCARD_RESET_FILE` | Löscht hängenden virtual-sdcard-/Druckstatus („Fehler löschen“ in Grotti). |
| `PRINT_STATS` | Status des laufenden/letzten Jobs (Objekt auch über Moonraker). |
### Bettnetz und Probe
| Befehl | Funktion |
| --- | --- |
| `BED_MESH_CALIBRATE` | Tastet das Netz und schaltet es aktiv. |
| `BED_MESH_OUTPUT` | Gibt die Punkte aus, ohne zu fahren. |
| `BED_MESH_MAP` | Schreibt das interpolierte Netz in die Konsole. |
| `BED_MESH_CLEAR` | Schaltet die Korrektur aus. |
| `BED_MESH_OFFSET` | Verschiebt das Netz in X/Y. |
| `BED_MESH_PROFILE` | `LOAD` / `SAVE` / `REMOVE` für Profile. Dauerhaft erst mit `SAVE_CONFIG`. |
| `PROBE` | Ein Tastpunkt an der aktuellen Position. |
| `PROBE_ACCURACY` | Mehrfach tasten, Streuung ausgeben. |
| `PROBE_CALIBRATE` | Sensor kalibrieren (nicht der Z-Regler am Display). |
| `Z_OFFSET_APPLY_PROBE` | Laufenden G-code-Offset in `probe.z_offset` schreiben. |
| `SET_GCODE_OFFSET` | Offset nur für den aktuellen Auftrag. |
| `SAVE_CONFIG` | Schreibt Config dauerhaft — **nicht** blind aus Mainsail. |
### Diagnose
| Befehl | Funktion |
| --- | --- |
| `QUERY_ENDSTOPS` / `M119` | Endstop-Zustände. |
| `GET_POSITION` / `M114` | Toolhead-/Stepper-Position. |
| `QUERY_ADC` | ADC-Werte (z. B. Thermistoren). |
| `DUMP_TMC` | TMC-Register (Stepper nennen). |
### Slice-Start Anycubic
| Befehl | Funktion |
| --- | --- |
| `G9111` | Vorheizen aus dem Slicer: typisch `bedTemp=…` und `extruderTemp=…`. Steht im Maschinen-Start-G-code. Grotti ersetzt das nicht und startet auch nicht darüber. |
---
## ACE (gemeinsam)
Zwei Namenswelten:
1. **Open-Source** GoKlipper `extras_ace.go` (BunnyACE-Stil): `ACE_CHANGE_TOOL`, `ACE_FEED`, `ACE_RETRACT`, `ACE_SET_SLOT`, `ACE_QUERY_SLOTS`, `ACE_START_DRYING` / `ACE_STOP_DRYING`, Feed-Assist, Endless Spool, …
2. **Ausgeliefertes Binary** (Rinkhals-RE): u. a. `FEED_FILAMENT`, `UNWIND_FILAMENT`, `UNWIND_ALL_FILAMENT`, `SET_CURRENT_FILAMENT`, `ACE_INFO`, `ACE_STATUS`, Cutter-/Pipe-Befehle.
Grotti nutzt **keine** Community-Makros `ACE_SET_SLOT` / manuelles Tool-Mapping per G-code. Zuordnung: Sidecar `.acm` + MQTT `ams_settings` ([Senden-Queue](Senden-Queue.md), [ACE-Live](ACE-Live.md)). Slots anzeigen: Moonraker-Objekt `mmu` (Rinkhals). Trocknen: Moonraker `POST /server/filament_hub/start_drying` (nicht zwingend der G-code).
| Befehl (Auswahl) | Funktion |
| --- | --- |
| `ACE_CHANGE_TOOL` / Tool-Macros `T0`… | Werkzeugwechsel über ACE. |
| `ACE_FEED` / `FEED_FILAMENT` | Filament vom Gate zur Düse laden. **Vorher unloaden.** |
| `ACE_RETRACT` / `UNWIND_FILAMENT` | Zurückziehen / parkieren. |
| `ACE_SET_SLOT` | Inventar Slot setzen — Grotti meidet das. |
| `ACE_START_DRYING` / `ACE_STOP_DRYING` | Trockner; alternativ `filament_hub`-API. |
| `ACE_ENABLE_ENDLESS_SPOOL` / `…_DISABLE` / `…_STATUS` | Endless Spool. |
Details und Parameter: Rinkhals `docs/GOKLIPPER_ACE_GCODE_COMMANDS.md` (lokal unter `D:\Projekte\_ref\Rinkhals\`).
---
## Kobra S1 — Anycubic-Makros
Config-Sektion heißt `[leviQ3]`, die Befehle auf dem Gerät heißen **`LEVIQ2`**. Stecken in GoKlipper. Hilfe gelesen 11.10. über `printer/gcode/help`.
### LeviQ2
| Befehl | Funktion |
| --- | --- |
| `LEVIQ2` | Ganze Nivellierfolge: heizen, wischen, antasten. **Nicht** aus Mainsail. `SYNC=enable` startet danach neu. |
| `LEVIQ2_PREHEATING` | Vorheizen: Bett 55 °C, Düse 170 °C, dann `HOME_XY`. `S` = Düsentemp, `E` = Temp nach dem Wischen. |
| `LEVIQ2_WIPING` | Wischen: Netz löschen, Düse auf 140 °C, intern `G9112`, dann `G28 Z`. |
| `LEVIQ2_PROBE` | Antasten: `BED_MESH_CALIBRATE`, Lüfter/Bett aus, Z auf 20 mm, `HOME_XY`, Düse aus. |
| `LEVIQ_SET_ZERO` | Z-Basis und Home-Position auf 0. |
| `LEVIQ2_AUTO_ZOFFSET` | Löscht laut Hilfe den Koeffizienten K. |
| `LEVIQ2_AUTO_ZOFFSET_ON_OFF` | Schaltet automatischen Z-Offset. |
| `LEVIQ2_TEMP_OFFSET` | Temperaturausgleich (`expansion_factor` / `zoffset_base` in der Config). Fehlt im veröffentlichten Quelltext. |
| `HOME_XY` | Nur X/Y homen (von LeviQ-Schritten genutzt). |
Netz: 5×5, X/Y 5–245 mm. Details: Wissen `Kobra S1.md`.
### Wipe (Pad hinten)
| Befehl | Funktion |
| --- | --- |
| `WIPE_ENTER` | Anfahrt Pad (Y 250 → X 81, Y 273). |
| `WIPE_NOZZLE` | Wischbahn; wiederholt die Strecke X 96↔81. |
| `WIPE_STOP` | Kurze Stopp-Bahnen, dann X 75. |
| `WIPE_EXIT` | Weg vom Pad (X 83, Y 250). |
| `TO_THROW_POSITION` | Position zum Abwerfen (~X 47, Y 276). |
| `MOVE_TO_SWEEP_POSITION` | Wisch-/Kehrposition (gleiche Gegend). |
| `MOVE_TO_IMPACT_POSITION` | Weg vom Pad nach vorn rechts (Y 25, X 261). |
| `PRINT_LINE` | Purge-Linie bei Y 251, Z 0,28 bis X 125. |
---
## Kobra 3 — Anycubic-Makros
Befehle gelesen 11.10. auf `da26` (`92ee`/`6e41` gleiche `printer.generated.cfg`). Im veröffentlichten Quelltext heißen Teile noch `LEVIQ3_*` / `LEVIQ2_PROBE` — auf den drei Druckern gelten die Namen unten.
### LeviQ
| Befehl | Funktion |
| --- | --- |
| `LEVIQ` | Ganze Folge inkl. Z-Offset-Rechnung. **Nicht** aus Mainsail. `SYNC=enable` startet danach neu. |
| `LEVIQ_PREHEATING` | Z-Basis 0, Bett 60 °C, Düse 170 °C, `HOME_XY`, auf Düse warten. `S` / `E` wie bei S1. |
| `LEVIQ_WIPING` | Netz löschen, Düse 140 °C, `G9112`, `G28 Z`. |
| `LEVIQ_PROBE` | `BED_MESH_CALIBRATE`, Lüfter/Bett aus, Z auf 30 mm, `HOME_XY`, Düse aus. |
| `LEVIQ_SET_ZERO` | Z-Basis und Home auf 0. |
| `LEVIQ_AUTO_ZOFFSET` | Löscht Koeffizient K (Hilfe). |
| `LEVIQ_AUTO_ZOFFSET_ON_OFF` | Automatischen Z-Offset schalten (oft an). |
| `LEVIQ_TEMP_OFFSET` | Temperaturausgleich (Hilfezeile „LEVIQ3 TEMP OFFSET“). Fehlt im Open-Source-Code. |
| `HOME_XY` | Nur X/Y homen. |
Netz: 5×5, X 15–240, Y 15–235 mm. Details: Wissen `Kobra 3.md`.
### Wipe
| Befehl | Funktion |
| --- | --- |
| `WIPE_NOZZLE` | Dreimal `SINGLE_WIPE_NOZZLE` (Y 260/259, X 46–90, Z −0,8↔1, Lüfter an). |
| `SINGLE_WIPE_NOZZLE` | Eine einzelne Wischbahn. |
| `MOVE_HEAT_POS` | Z 15, dann X 278 (Heiz-/Parknahe Position). |
| `UNDERLINE` | Purge-Linie Z 0,28 bis X 180, ~40 mm Extrusion. |
| `MOVE_END_POS` | Ende/Park X 5, Y 242. |
### Wägezelle
| Befehl | Funktion |
| --- | --- |
| `CS1237_DUMP` | Debug-Ausgabe zum CS1237 (Z-Sensor / Kraft). Moonraker liefert keine laufende Gramm-Anzeige; `probe.last_z_result` fehlt. |
---
## Siehe auch
- [Anycubic-Stack](Anycubic-Stack.md) — MQTT-Start/Stop, 10101, kein Firmware-Restart
- [Senden-Queue](Senden-Queue.md) — `.acm`, kein Community-ACE-Makro
- [Abraeumen](Abraeumen.md) — K3 Farm-Scripts statt Druckermakro
- [ACE-Live](ACE-Live.md) — Live-Mapping und Trailer
+5
View File
@@ -3,3 +3,8 @@
Diese Dateien sind für Entwicklung und Agenten — **nicht** unter `/doku` in der App.
Nutzer-Handbuch: `docs/Anleitung-*.md`.
| Datei | Inhalt |
| --- | --- |
| [Makros-Kobra.md](Makros-Kobra.md) | G-code/Makros Kobra S1 und 3: Nicht-tun, Vanilla-Alltag, ACE, LeviQ/Wipe |
| [Anycubic-Stack.md](Anycubic-Stack.md) | GoKlipper, MQTT-Start/Stop, ACE-Kanäle |
+24 -4
View File
@@ -1,6 +1,7 @@
"""Host, Port, Datenordner und Datenbank kommen aus config.json."""
import json
import os
import secrets
import threading
from pathlib import Path
@@ -9,11 +10,24 @@ from grotti.errors import GrottiError
ROOT = Path(__file__).resolve().parent.parent
LOOPBACK = {"127.0.0.1", "::1", "localhost"}
CONFIG_PATH = ROOT / "config.json"
DEFAULT_WATCH_REL = "slice"
_SETTINGS_LOCK = threading.Lock()
def config_path():
"""GROTTI_CONFIG, sonst /etc/grotti/config.json (Paket), sonst Repo-config.json."""
env = (os.environ.get("GROTTI_CONFIG") or "").strip()
if env:
return Path(env)
etc = Path("/etc/grotti/config.json")
if etc.is_file():
return etc
return ROOT / "config.json"
CONFIG_PATH = config_path()
def _tariff(raw):
tariff = raw.get("tariff") if isinstance(raw, dict) else None
if not isinstance(tariff, dict) or not tariff:
@@ -121,9 +135,11 @@ def _ensure_secret_key(path, raw):
def load():
path = CONFIG_PATH
path = config_path()
if not path.exists():
path.write_text((ROOT / "config.example.json").read_text(encoding="utf-8"), encoding="utf-8")
example = ROOT / "config.example.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(example.read_text(encoding="utf-8"), encoding="utf-8")
raw = json.loads(path.read_text(encoding="utf-8"))
host = (raw.get("host") or "127.0.0.1").strip()
token = raw.get("token") or ""
@@ -139,6 +155,9 @@ def load():
if not data_dir.is_absolute():
data_dir = ROOT / data_dir
watch_dir, _stored = parse_watch_dir(raw.get("watch_dir") or "", create=False, missing_ok=True)
printers_file = Path(raw.get("printers_file") or (ROOT / "drucker.json"))
if not printers_file.is_absolute():
printers_file = ROOT / printers_file
return {
"host": host,
"port": int(raw.get("port") or 8770),
@@ -149,7 +168,8 @@ def load():
"secret_key": secret_key,
"oauth": _oauth(raw),
"fqdn": normalize_fqdn(raw.get("fqdn") or ""),
"printers_file": ROOT / "drucker.json",
"printers_file": printers_file,
"log_level": str(raw.get("log_level") or "INFO").strip() or "INFO",
"energy": _energy(raw),
"telegram": _telegram(raw),
"ntfy": _ntfy(raw),
+5 -1
View File
@@ -13,6 +13,10 @@ from psycopg import sql
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from grotti import logutil
log = logutil.get("grotti.db")
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
SCHEMA_LEGACY = Path(__file__).parent / "schema.sql"
@@ -123,7 +127,7 @@ def prepare(url):
if not exists:
conn.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(name)))
except psycopg.Error as exc:
print("Datenbank anlegen: %s" % exc, flush=True)
log.warning("Datenbank anlegen: %s", exc)
try:
with psycopg.connect(url, row_factory=dict_row, connect_timeout=5) as conn:
apply_migrations(conn)
+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)
+4 -1
View File
@@ -8,8 +8,11 @@ import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from grotti import logutil
from grotti.parse import embedded_png
log = logutil.get("grotti.moonraker")
META_CACHE = {}
CAMERA_CACHE = {}
EMBEDDED = {}
@@ -1241,7 +1244,7 @@ def snapshot(printers):
card.update(query(printer["host"]))
except Exception as error:
# Auch RemoteDisconnected / IncompleteRead / ValueError — sonst fällt der ganze Tick aus.
print("snapshot %s: %s" % (printer.get("host"), error), flush=True)
log.warning("snapshot %s: %s", printer.get("host"), error)
card.update({
"ok": False,
"state": "offline",
+67 -9
View File
@@ -10,7 +10,7 @@ import threading
import time
from datetime import datetime, timezone
from grotti import acm, eject, jobs, moonraker, notify, store
from grotti import acm, eject, jobs, logutil, moonraker, notify, store
from grotti.db import connect
from grotti.errors import GrottiError
from grotti import time_window
@@ -18,6 +18,8 @@ from grotti import time_window
WINDOW_WAIT_MSG = "Wartet auf Zeitfenster…"
from grotti.events_bus import publish as publish_live
log = logutil.get("grotti.queue")
def _notify_queue():
publish_live("queue", {})
@@ -111,7 +113,7 @@ class PrintQueueDispatcher:
try:
self.tick()
except Exception as exc:
print("Druck-Queue: %s" % exc, flush=True)
log.warning("Druck-Queue: %s", exc)
self._stop.wait(self.interval)
def _live_map(self, printers):
@@ -207,7 +209,7 @@ class PrintQueueDispatcher:
try:
target(*args)
except Exception as exc:
print("Queue-Worker %s: %s" % (item_id, exc), flush=True)
log.warning("Queue-Worker %s: %s", item_id, exc)
finally:
_notify_queue()
with self._worker_lock:
@@ -278,13 +280,15 @@ class PrintQueueDispatcher:
continue
running = store.running_print_on_host(conn, host)
if running:
if not _has_ablauf(running):
if (
not _has_ablauf(running)
and (running.get("phase") or "").lower() == "starting"
(running.get("phase") or "").lower() == "starting"
and not self._worker_busy(running["id"])
and self._item_phase_age_s(running) > SIMPLE_START_STUCK_S
):
self._fail_stuck_simple_start(conn, running, printer)
else:
self._advance_simple(conn, running, printer, live)
conn.commit()
continue
if store.bed_needs_clear(conn, host):
@@ -417,15 +421,17 @@ class PrintQueueDispatcher:
from grotti import orders
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(
wconn,
item_id,
{
"status": "done",
"message": "Gestartet auf %s: %s" % (printer_copy["name"], name),
"status": "running",
"message": "Druckt auf %s: %s" % (printer_copy["name"], name),
"host": host,
"phase": "",
"phase_started_at": None,
"phase": "print_active",
"phase_started_at": "now",
},
)
self._event(
@@ -477,6 +483,58 @@ class PrintQueueDispatcher:
self._spawn_worker(item_id, work)
def _advance_simple(self, conn, item, printer, live):
"""Einfach-Druck: running bleiben bis Moonraker fertig/abgebrochen."""
phase = (item.get("phase") or "").lower()
state = (live.get("state") or "").lower()
if phase == "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):
try:
eject.require_kobra_3(printer)
+21 -11
View File
@@ -2813,6 +2813,20 @@ def _file_needs(conn, file_id):
return types, float(row.get("weight_g") or 0)
def _prefer_color(spools, need_color):
"""Farbe soft: Slice-Hex ignorieren; benannte Farbe bevorzugen, nie blockieren."""
from grotti.bedarf import _is_slicer_hex
need_c = (need_color or "").strip().lower()
if not need_c or _is_slicer_hex(need_c) or not spools:
return list(spools)
preferred = [
h for h in spools
if not (h.get("color") or "").strip() or (h.get("color") or "").strip().lower() == need_c
]
return preferred or list(spools)
def _match_spools_for_need(need, have, used_ids=None):
used = used_ids or set()
candidates = [h for h in have if h.get("spool_id") not in used]
@@ -2822,29 +2836,25 @@ def _match_spools_for_need(need, have, used_ids=None):
kind = (kind or "").strip().lower()
return [
h for h in candidates
if (not kind or h["material_type"] == kind)
and (not need.get("color") or h["color"] == need["color"] or not h["color"])
if not kind or h["material_type"] == kind
]
if profile:
by_profile = [h for h in candidates if _profile_matches(profile, h.get("material_name"))]
if by_profile:
colored = [
h for h in by_profile
if not need.get("color") or h["color"] == need["color"] or not h["color"]
]
return colored or by_profile
# Basis-Profil ohne Meta/Matte/Silk: Marke egal — Typ reicht (Sunlu PLA ↔ Eryone PLA).
# Spulen mit Variante im Namen bleiben draußen (kein Meta für „nur PLA“).
return _prefer_color(by_profile, need.get("color"))
# Basis-Profil ohne Meta/Matte/Silk: Marke egal — Typ reicht
# (Sunlu PLA ↔ Eryone PLA Lite; Slice-Hex #26A69A blockiert nicht „Grau“).
if not _profile_has_variant(profile):
kind = _profile_base_type(profile) or (need.get("material_type") or "")
if kind:
return [
typed = [
h for h in _by_type(kind)
if not _profile_has_variant(h.get("material_name") or "")
]
return _prefer_color(typed, need.get("color"))
return []
return _by_type(need.get("material_type") or "")
return _prefer_color(_by_type(need.get("material_type") or ""), need.get("color"))
def material_fit_score(conn, host, file_id):
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
__version__ = "3.4.0"
__version__ = "3.5.0"
+10 -8
View File
@@ -6,10 +6,12 @@ import threading
import time
from datetime import datetime, timezone
from grotti import jobs, moonraker, notify, store, usage
from grotti import jobs, logutil, moonraker, notify, store, usage
from grotti.events_bus import publish as publish_live
from grotti.db import connect
log = logutil.get("grotti.watcher")
SAMPLE_EVERY_S = 30
SAMPLE_HEAT_S = 10
@@ -59,7 +61,7 @@ class StateWatcher:
try:
self.tick()
except Exception as exc:
print("Watcher: %s" % exc, flush=True)
log.warning("Watcher: %s", exc)
self._stop.wait(self.interval)
def tick(self):
@@ -118,7 +120,7 @@ class StateWatcher:
jobs.apply_deduction(conn, closed, self.cfg["energy"])
except Exception as exc:
print("Watcher Abzug offline %s: %s" % (host, exc), flush=True)
log.warning("Watcher Abzug offline %s: %s", host, exc)
self._prev[host] = {"state": "offline", "offline": True, "filename": filename}
return
@@ -193,7 +195,7 @@ class StateWatcher:
jobs.sync_history(conn, self.cfg)
except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
log.warning("Watcher Abgleich %s: %s", host, exc)
# History oft ohne Endzeit (interrupted) — Live trotzdem schließen.
dangling = store.find_live_run(conn, host)
if dangling:
@@ -204,7 +206,7 @@ class StateWatcher:
jobs.apply_deduction(conn, closed, self.cfg["energy"])
except Exception as exc:
print("Watcher Abzug %s: %s" % (host, exc), flush=True)
log.warning("Watcher Abzug %s: %s", host, exc)
elif state in ("cancelled", "standby"):
# Live-Lauf schließen: normaler Abbruch, oder nach Offline/Neustart
# (prev wird „offline“, der Übergang paused→standby geht sonst verloren).
@@ -231,7 +233,7 @@ class StateWatcher:
jobs.sync_history(conn, self.cfg)
except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
log.warning("Watcher Abgleich %s: %s", host, exc)
elif state == "error" and prev_state != "error":
if prev_state == "printing" or prev_state == "paused":
store.set_bed_needs_clear(conn, host, True, filename or "Fehler")
@@ -254,7 +256,7 @@ class StateWatcher:
jobs.sync_history(conn, self.cfg)
except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
log.warning("Watcher Abgleich %s: %s", host, exc)
elif state == "error":
if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0:
self._cool_heaters(host)
@@ -436,7 +438,7 @@ class StateWatcher:
try:
moonraker.heaters_off(host)
except Exception as exc:
print("Watcher Heizung aus %s: %s" % (host, exc), flush=True)
log.warning("Watcher Heizung aus %s: %s", host, exc)
def _check_spool(self, conn, host, name, filename, live):
need = float(live.get("weight") or 0)
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# Baut grotti_<version>_<arch>.deb (auf dem Zielsystem oder arm64-Container).
# Aufruf aus dem Repo-Root: bash packaging/build-deb.sh
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
if ! command -v dpkg-deb >/dev/null 2>&1; then
echo "dpkg-deb fehlt — bitte auf Debian/Raspberry Pi OS bauen." >&2
exit 1
fi
VERSION="$(python3 -c "from grotti.version import __version__; print(__version__)")"
ARCH="$(dpkg --print-architecture)"
PKG="grotti_${VERSION}_${ARCH}"
DIST="$ROOT/packaging/dist"
STAGE="$DIST/$PKG"
rm -rf "$STAGE"
mkdir -p "$STAGE/DEBIAN" \
"$STAGE/usr/lib/grotti" \
"$STAGE/usr/bin" \
"$STAGE/etc/grotti" \
"$STAGE/lib/systemd/system" \
"$STAGE/usr/share/grotti" \
"$STAGE/usr/share/doc/grotti" \
"$STAGE/var/lib/grotti/data/eingang" \
"$STAGE/var/lib/grotti/slice"
rsync -a --delete \
--exclude '__pycache__' --exclude '*.pyc' --exclude '.pytest_cache' \
"$ROOT/grotti/" "$STAGE/usr/lib/grotti/grotti/"
rsync -a --delete \
--exclude '__pycache__' --exclude '*.pyc' \
"$ROOT/web/" "$STAGE/usr/lib/grotti/web/"
cp "$ROOT/server.py" "$STAGE/usr/lib/grotti/server.py"
cp "$ROOT/requirements.txt" "$STAGE/usr/lib/grotti/requirements.txt"
cp "$ROOT/config.example.json" "$STAGE/usr/lib/grotti/config.example.json"
echo '[]' > "$STAGE/usr/lib/grotti/drucker.json"
cp "$ROOT/packaging/config.pi.json" "$STAGE/usr/share/grotti/config.pi.json"
cp "$ROOT/packaging/config.pi.json" "$STAGE/etc/grotti/config.json"
cp "$ROOT/packaging/grotti.service" "$STAGE/lib/systemd/system/grotti.service"
cp "$ROOT/docs/Anleitung-Installation-Pi.md" "$STAGE/usr/share/doc/grotti/"
cp "$ROOT/CHANGELOG.md" "$STAGE/usr/share/doc/grotti/changelog"
gzip -9 -f "$STAGE/usr/share/doc/grotti/changelog"
cat > "$STAGE/usr/bin/grotti" <<'EOF'
#!/bin/sh
export GROTTI_CONFIG="${GROTTI_CONFIG:-/etc/grotti/config.json}"
exec /usr/lib/grotti/venv/bin/python /usr/lib/grotti/server.py "$@"
EOF
chmod 755 "$STAGE/usr/bin/grotti"
python3 -m venv "$STAGE/usr/lib/grotti/venv"
"$STAGE/usr/lib/grotti/venv/bin/pip" install --upgrade pip wheel
"$STAGE/usr/lib/grotti/venv/bin/pip" install -r "$ROOT/requirements.txt"
SIZE_KB="$(du -sk "$STAGE" | awk '{print $1}')"
cat > "$STAGE/DEBIAN/control" <<EOF
Package: grotti
Version: ${VERSION}
Section: web
Priority: optional
Architecture: ${ARCH}
Maintainer: Dominic Gundel <[email protected]>
Depends: python3 (>= 3.11), python3-venv, postgresql, adduser
Installed-Size: ${SIZE_KB}
Homepage: https://git.gundel.dev/dg/grotti
Description: Farm-Manager für Moonraker-Drucker (Kobra 3 / S1)
Grotti steuert eine 3D-Drucker-Farm über Moonraker: Wand, Warteschlange,
Material, Abläufe und Protokoll. PostgreSQL wird lokal mit eingerichtet.
EOF
cp "$ROOT/packaging/debian/conffiles" "$STAGE/DEBIAN/conffiles"
for script in postinst prerm postrm; do
cp "$ROOT/packaging/debian/$script" "$STAGE/DEBIAN/$script"
chmod 755 "$STAGE/DEBIAN/$script"
done
dpkg-deb --root-owner-group --build "$STAGE" "$DIST/${PKG}.deb"
echo "Gebaut: $DIST/${PKG}.deb"
echo "Install: sudo apt install ./packaging/dist/${PKG}.deb"
echo "Logs: journalctl -u grotti -f"
+56
View File
@@ -0,0 +1,56 @@
{
"host": "0.0.0.0",
"port": 8770,
"data_dir": "/var/lib/grotti/data",
"watch_dir": "/var/lib/grotti/slice",
"database_url": "postgresql:///grotti?host=/var/run/postgresql",
"token": "",
"secret_key": "",
"fqdn": "",
"log_level": "INFO",
"auto_queue": true,
"auto_retry": true,
"oauth": {
"enabled": false,
"issuer": "",
"client_id": "",
"client_secret": "",
"redirect_uri": ""
},
"energy": {
"watts": 40,
"nozzle_watts": 60,
"bed_watts": 220,
"euro_per_kwh": 0.35,
"dryer_watts": 0,
"dry_hours": 0,
"waste_percent": 0,
"utilization": 50,
"tariff": {
"ht_euro": 0.35,
"nt_euro": 0.22,
"nt_from": "22:00",
"nt_to": "06:00"
}
},
"telegram": {
"enabled": false,
"bot_token": "",
"chat_id": ""
},
"ntfy": {
"enabled": false,
"server": "https://ntfy.sh",
"topic": "",
"token": ""
},
"alerts": {
"fertig": true,
"fehler": true,
"offline": true,
"queue": true,
"spule": true,
"ablauf": false,
"start": false
}
}
+1
View File
@@ -0,0 +1 @@
/etc/grotti/config.json
+45
View File
@@ -0,0 +1,45 @@
#!/bin/sh
set -e
_pg() {
if command -v runuser >/dev/null 2>&1; then
runuser -u postgres -- "$@"
else
su -s /bin/sh postgres -c "$*"
fi
}
case "$1" in
configure)
if ! getent passwd grotti >/dev/null; then
adduser --system --group --home /var/lib/grotti --no-create-home \
--gecos "Grotti farm manager" grotti
fi
mkdir -p /var/lib/grotti/data/eingang /var/lib/grotti/slice /etc/grotti
chown -R grotti:grotti /var/lib/grotti
chmod 750 /var/lib/grotti
if [ ! -f /etc/grotti/config.json ]; then
cp /usr/share/grotti/config.pi.json /etc/grotti/config.json
fi
chmod 640 /etc/grotti/config.json
chown root:grotti /etc/grotti/config.json
if getent passwd postgres >/dev/null 2>&1; then
_pg psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='grotti'" | grep -q 1 \
|| _pg createuser -D -R -S grotti || true
_pg psql -tAc "SELECT 1 FROM pg_database WHERE datname='grotti'" | grep -q 1 \
|| _pg createdb -O grotti grotti || true
_pg psql -c "GRANT ALL PRIVILEGES ON DATABASE grotti TO grotti;" >/dev/null 2>&1 || true
fi
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload || true
systemctl enable grotti.service || true
systemctl restart grotti.service || true
fi
;;
esac
exit 0
+19
View File
@@ -0,0 +1,19 @@
#!/bin/sh
set -e
case "$1" in
purge)
rm -f /etc/grotti/config.json
rmdir /etc/grotti 2>/dev/null || true
# Daten und DB bleiben absichtlich (Backup). Manuell:
# sudo rm -rf /var/lib/grotti
# sudo -u postgres dropdb grotti; sudo -u postgres dropuser grotti
;;
remove)
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload || true
fi
;;
esac
exit 0
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
set -e
case "$1" in
remove|upgrade|deconfigure)
if command -v systemctl >/dev/null 2>&1; then
systemctl stop grotti.service 2>/dev/null || true
systemctl disable grotti.service 2>/dev/null || true
fi
;;
esac
exit 0
View File
Whitespace-only changes.
+24
View File
@@ -0,0 +1,24 @@
[Unit]
Description=Grotti Farm-Manager
Documentation=file:///usr/share/doc/grotti/Anleitung-Installation-Pi.md
After=network-online.target postgresql.service
Wants=network-online.target
Requires=postgresql.service
[Service]
Type=simple
User=grotti
Group=grotti
WorkingDirectory=/usr/lib/grotti
Environment=GROTTI_CONFIG=/etc/grotti/config.json
Environment=PYTHONUNBUFFERED=1
ExecStart=/usr/lib/grotti/venv/bin/python /usr/lib/grotti/server.py
Restart=on-failure
RestartSec=5
# Logs → journald (journalctl -u grotti -f)
StandardOutput=journal
StandardError=journal
SyslogIdentifier=grotti
[Install]
WantedBy=multi-user.target
+9 -6
View File
@@ -7,10 +7,12 @@ import time
from waitress import serve
from grotti import db, jobs, library, store
from grotti import db, jobs, library, logutil, store
from grotti.config import load
from web.app import create_app
log = logutil.get("grotti.server")
def background(cfg):
def work():
@@ -20,10 +22,10 @@ def background(cfg):
jobs.sync_history(conn, cfg)
deleted = db.prune_run_samples(conn, keep_days=90)
if deleted:
print("Proben gelöscht: %s" % deleted, flush=True)
log.info("Proben gelöscht: %s", deleted)
conn.commit()
except Exception as exc:
print("Abgleich: %s" % exc, flush=True)
log.warning("Abgleich: %s", exc)
threading.Thread(target=work, name="grotti-abgleich", daemon=True).start()
@@ -39,14 +41,15 @@ def _retention_loop(cfg, stop_event):
n = db.prune_run_samples(conn, keep_days=90)
conn.commit()
if n:
print("Retention: %s Proben gelöscht." % n, flush=True)
log.info("Retention: %s Proben gelöscht.", n)
except Exception as exc:
print("Retention: %s" % exc, flush=True)
log.warning("Retention: %s", exc)
stop_event.wait(3600)
def main():
cfg = load()
logutil.setup(cfg.get("log_level") or "INFO")
cfg["data_dir"].mkdir(parents=True, exist_ok=True)
(cfg["data_dir"] / "eingang").mkdir(exist_ok=True)
db.prepare(cfg["database_url"])
@@ -107,7 +110,7 @@ def main():
except (ValueError, OSError):
pass
print("Grotti: http://%s:%s/" % (cfg["host"], cfg["port"]), flush=True)
log.info("Grotti: http://%s:%s/", cfg["host"], cfg["port"])
serve(app, host=cfg["host"], port=cfg["port"], threads=16, channel_timeout=120)
+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()
+29
View File
@@ -59,6 +59,35 @@ class MatchSpoolsTests(unittest.TestCase):
ids = {m["spool_id"] for m in matched}
self.assertEqual(ids, {3}) # Eryone PLA; Meta/Matte nicht
def test_pla_lite_hex_matches_plain_pla(self):
# Orca: Profil „Eryone PLA Lite“, Farbe #26A69A — Lager: Eryone/Sunlu PLA „Grau“
need = {
"profile": "Eryone PLA Lite",
"material_type": "pla",
"color": "#26A69A",
"paint_index": 0,
}
have = [
{
"spool_id": 15,
"material_type": "pla",
"color": "grau",
"remaining_g": 873,
"material_name": "sunlu pla",
"ace_slot": None,
},
{
"spool_id": 1,
"material_type": "pla",
"color": "grau",
"remaining_g": 800,
"material_name": "sunlu pla meta",
"ace_slot": 1,
},
]
matched = store._match_spools_for_need(need, have)
self.assertEqual({m["spool_id"] for m in matched}, {15})
def test_picks_meta_not_matte(self):
need = {"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "", "paint_index": 0}
matched = store._match_spools_for_need(need, self.have)
+12 -4
View File
@@ -19,7 +19,7 @@ from werkzeug.middleware.proxy_fix import ProxyFix
from grotti import auth as grotti_auth
from grotti import config as grotti_config
from grotti import db as grotti_db
from grotti import eject, jobs, library, moonraker, notify, store
from grotti import eject, jobs, library, logutil, moonraker, notify, store
from grotti.catalog import color_catalog
from grotti.cost import calculate, offer_from_file, order_actuals
from grotti import orders as grotti_orders
@@ -32,6 +32,8 @@ from grotti.version import __version__
from grotti import verdict as grotti_verdict
from grotti.watcher import StateWatcher
log = logutil.get("grotti.web")
ROOT = Path(__file__).resolve().parent
TOOL_ROOT = ROOT.parent
DOCS_DIR = TOOL_ROOT / "docs"
@@ -316,7 +318,7 @@ def create_app(cfg):
message = "Diesen Eintrag gibt es schon."
elif isinstance(err, psycopg.errors.InvalidTextRepresentation):
message = "Ungültiger Wert in der Anfrage."
print("DB: %s" % err, flush=True)
log.warning("DB: %s", err)
return jsonify({"error": message, "code": "db"}), 500
def body():
@@ -582,6 +584,7 @@ def create_app(cfg):
ANLEITUNG_ORDER = (
"Anleitung-Einrichtung",
"Anleitung-Installation-Pi",
"Anleitung-Oberflaeche",
"Anleitung-Wand",
"Anleitung-Dateien",
@@ -605,6 +608,7 @@ def create_app(cfg):
def anleitung_title(name):
labels = {
"Anleitung-Einrichtung": "Einrichtung",
"Anleitung-Installation-Pi": "Raspberry Pi (.deb)",
"Anleitung-Oberflaeche": "Oberfläche",
"Anleitung-Wand": "Druckerwand",
"Anleitung-Dateien": "Dateien senden",
@@ -1698,7 +1702,7 @@ def create_app(cfg):
try:
jobs.apply_deduction(c, closed, cfg["energy"])
except Exception as exc:
print("Abräumen Abzug %s: %s" % (host, exc), flush=True)
log.warning("Abräumen Abzug %s: %s", host, exc)
row = store.get_run_row(c, closed["id"]) or closed
run_json = store.run_out(row)
reprint = None
@@ -2451,7 +2455,11 @@ def create_app(cfg):
@app.get("/api/v1/laeufe")
def run_list():
return jsonify(store.list_runs(conn()))
try:
limit = min(1000, max(1, int(request.args.get("limit") or 500)))
except (TypeError, ValueError):
limit = 500
return jsonify(store.list_runs(conn(), limit=limit))
@app.get("/api/v1/laeufe/aktuell")
def run_aktuell():
+30 -8
View File
@@ -2510,21 +2510,22 @@ article.machine.flash { animation: flash 2s var(--ease); }
.lib-info > summary { list-style: none; }
.lib-info > summary::-webkit-details-marker { display: none; }
.lib-info-pop {
position: absolute;
right: 0;
top: calc(100% + 0.25rem);
z-index: 20;
position: fixed;
z-index: 80;
min-width: 11rem;
max-width: min(18rem, calc(100vw - 1rem));
padding: 0.55rem 0.7rem;
border-radius: 10px;
border: 1px solid var(--line-strong);
background: #121217;
background: var(--card-2);
box-shadow: var(--shadow);
font-size: var(--t-xs);
color: var(--muted);
display: grid;
color: var(--ink);
display: none;
gap: 0.2rem;
line-height: 1.35;
}
.lib-info[open] > .lib-info-pop { display: grid; }
.picked-details { opacity: 0.9; }
.flow-step { padding: 0.9rem 0 0.95rem; border-top: 1px solid var(--line); }
@@ -3644,8 +3645,29 @@ dialog::backdrop { background: rgba(0, 0, 0, 0.55); backdrop-filter: blur(4px);
margin-right: 0.35rem;
}
.pager {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin: 0.75rem 0 0;
padding: 0.35rem 0;
}
.pager[hidden] { display: none !important; }
.pager-info {
font-size: 0.85rem;
color: var(--muted);
min-width: 7rem;
text-align: center;
}
.pager button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
@media print {
.sidebar, .mobile-nav, .conn-banner, .toast-host, .nav-toggle { display: none !important; }
.sidebar, .mobile-nav, .conn-banner, .toast-host, .nav-toggle, .pager { display: none !important; }
.app-shell { display: block; }
.app-main { margin: 0; }
body { background: #fff; color: #111; }
+44
View File
@@ -333,3 +333,47 @@ window.setConnectionBanner = setConnectionBanner;
window.createPoller = createPoller;
window.promptAbraeumen = promptAbraeumen;
window.VERDICT_REASON_LABELS = VERDICT_REASON_LABELS;
/** Listen-Pagination (clientseitig auf gefiltertem Array). */
const DEFAULT_PAGE_SIZE = 40;
function pageSlice(items, page, pageSize) {
const list = Array.isArray(items) ? items : [];
const size = Math.max(1, Number(pageSize) || DEFAULT_PAGE_SIZE);
const total = list.length;
const pages = Math.max(1, Math.ceil(total / size) || 1);
let p = Math.max(1, parseInt(page, 10) || 1);
if (p > pages) p = pages;
const start = (p - 1) * size;
return {
items: list.slice(start, start + size),
page: p,
pages,
total,
pageSize: size,
from: total ? start + 1 : 0,
to: Math.min(start + size, total),
};
}
function renderPager(el, state, onPage) {
if (!el) return;
const s = state || pageSlice([], 1);
if (s.total <= s.pageSize) {
el.hidden = true;
el.innerHTML = "";
return;
}
el.hidden = false;
el.className = "pager";
el.innerHTML =
'<button type="button" class="ghost pager-prev" ' + (s.page <= 1 ? "disabled" : "") + ">Zurück</button>" +
'<span class="pager-info">' + s.from + "–" + s.to + " von " + s.total + "</span>" +
'<button type="button" class="ghost pager-next" ' + (s.page >= s.pages ? "disabled" : "") + ">Weiter</button>";
el.querySelector(".pager-prev").onclick = () => onPage(s.page - 1);
el.querySelector(".pager-next").onclick = () => onPage(s.page + 1);
}
window.DEFAULT_PAGE_SIZE = DEFAULT_PAGE_SIZE;
window.pageSlice = pageSlice;
window.renderPager = renderPager;
+16 -3
View File
@@ -26,9 +26,12 @@
<div class="skeleton" style="height:4rem"></div>
<div class="skeleton" style="height:4rem"></div>
</div>
<div class="pager" id="tl-pager" hidden></div>
{% endblock %}
{% block script %}
<script>
let tlPage = 1;
let tlItems = [];
const KINDS = [
["queue", "Warteschlange"],
["ablauf", "Ablauf"],
@@ -111,14 +114,19 @@ function itemHtml(ev) {
function render(items) {
const box = document.getElementById("timeline");
const note = document.getElementById("tl-note");
if (!items.length) {
const pager = document.getElementById("tl-pager");
tlItems = items || [];
if (!tlItems.length) {
box.innerHTML = `<div class="card empty-state">${icon("activity")}<p>Keine Einträge${filter.range === "day" ? " heute" : ""}.</p>${
filter.range !== "all" ? `<button type="button" class="ghost" data-range-jump="all">Alle anzeigen</button>` : ""}</div>`;
note.textContent = "";
renderPager(pager, null, () => {});
return;
}
const paged = pageSlice(tlItems, tlPage, DEFAULT_PAGE_SIZE);
tlPage = paged.page;
const groups = [];
for (const ev of items) {
for (const ev of paged.items) {
const label = dayLabel(ev.created_at);
if (!groups.length || groups[groups.length - 1].label !== label) groups.push({ label, items: [] });
groups[groups.length - 1].items.push(ev);
@@ -126,7 +134,11 @@ function render(items) {
box.innerHTML = groups.map((g) =>
`<section class="tl-day"><h3>${escapeHtml(g.label)} · ${g.items.length}</h3><ol class="tl-list">${g.items.map(itemHtml).join("")}</ol></section>`
).join("");
note.textContent = items.length >= 300 ? "Die letzten 300 Einträge. Zeitraum oder Filter enger stellen für ältere." : "";
note.textContent = tlItems.length >= 300 ? "Die letzten 300 Einträge. Zeitraum oder Filter enger stellen für ältere." : "";
renderPager(pager, paged, (p) => {
tlPage = p;
render(tlItems);
});
}
async function load() {
@@ -137,6 +149,7 @@ async function load() {
if (filter.host) q.set("host", filter.host);
try {
const data = await api("/ereignisse?" + q.toString());
tlPage = 1;
render(data.items || []);
} catch (err) {
document.getElementById("timeline").innerHTML = `<div class="strip bad">${icon("alert")}<span>${escapeHtml(err.message)}</span></div>`;
+13 -1
View File
@@ -29,6 +29,7 @@
<thead><tr><th>Titel</th><th>Status</th><th>Fortschritt</th></tr></thead>
<tbody id="order-rows"></tbody>
</table>
<div class="pager" id="order-pager" hidden></div>
</div>
</section>
<section class="card">
@@ -246,16 +247,22 @@ async function einreihen(itemId) {
}
}
let orderPage = 1;
function renderList() {
const body = document.getElementById("order-rows");
body.innerHTML = "";
const pager = document.getElementById("order-pager");
if (!orders.length) {
body.innerHTML = '<tr><td colspan="3" class="empty"><div class="empty-state"><p>Noch kein Auftrag.</p><p><button type="button" class="ghost" id="order-empty-new">Ersten Auftrag anlegen →</button></p></div></td></tr>';
document.getElementById("order-empty-new")?.addEventListener("click", () => fillOrder(null));
renderPager(pager, null, () => {});
return;
}
const selected = document.getElementById("order-id").value;
orders.forEach((order) => {
const paged = pageSlice(orders, orderPage, DEFAULT_PAGE_SIZE);
orderPage = paged.page;
paged.items.forEach((order) => {
const tr = document.createElement("tr");
tr.className = "pick" + (selected && String(order.id) === selected ? " on" : "");
tr.innerHTML = "<td></td><td></td><td></td>";
@@ -275,12 +282,17 @@ function renderList() {
});
body.appendChild(tr);
});
renderPager(pager, paged, (p) => {
orderPage = p;
renderList();
});
}
async function loadList() {
const st = document.getElementById("filter-status").value;
const q = st ? "?status=" + encodeURIComponent(st) : "";
orders = await api("/auftraege" + q);
orderPage = 1;
renderList();
}
+63 -1
View File
@@ -57,6 +57,7 @@
<div class="skeleton" style="height:4rem"></div>
<div class="skeleton" style="height:4rem"></div>
</div>
<div class="pager" id="lib-pager" hidden></div>
<p class="drop-hint">Dateien hierher ziehen zum Hochladen.</p>
</section>
@@ -839,6 +840,39 @@ function infoPopupHtml(file) {
</details>`;
}
function placeLibInfoPop(details) {
const pop = details.querySelector(".lib-info-pop");
const anchor = details.querySelector("summary");
if (!pop || !anchor) return;
const margin = 6;
const r = anchor.getBoundingClientRect();
pop.style.left = "0px";
pop.style.top = "0px";
const pw = pop.offsetWidth;
const ph = pop.offsetHeight;
let left = r.right - pw;
left = Math.max(margin, Math.min(left, window.innerWidth - pw - margin));
let top = r.bottom + margin;
if (top + ph > window.innerHeight - margin && r.top - margin - ph >= margin) {
top = r.top - margin - ph;
} else {
top = Math.min(top, Math.max(margin, window.innerHeight - ph - margin));
}
pop.style.left = left + "px";
pop.style.top = top + "px";
}
function closeOpenLibInfo(except) {
document.querySelectorAll("details.lib-info[open]").forEach((node) => {
if (node !== except) node.removeAttribute("open");
});
}
function repositionOpenLibInfo() {
const open = document.querySelector("details.lib-info[open]");
if (open) placeLibInfoPop(open);
}
function thumbHtml(file) {
return file.preview
? `<img alt="" loading="lazy" src="${escapeHtml(withToken(file.preview))}" onerror="this.style.visibility='hidden'">`
@@ -856,6 +890,8 @@ function libItemHtml(file) {
</div>`;
}
let libPage = 1;
function renderLibrary() {
const list = document.getElementById("lib-list");
const shown = filteredFiles();
@@ -863,15 +899,19 @@ function renderLibrary() {
document.getElementById("lib-count").textContent = shown.length === files.length ? String(files.length) : shown.length + "/" + files.length;
if (!files.length) {
list.innerHTML = '<div class="picked-empty">' + icon("file") + '<span>Noch keine Datei.</span><span class="tiny">Hochladen oder Slice-Ordner in den Einstellungen setzen.</span></div>';
renderPager(document.getElementById("lib-pager"), null, () => {});
return;
}
if (!shown.length) {
list.innerHTML = '<div class="picked-empty">' + icon("search") + '<span>Keine Datei passt.</span></div>';
renderPager(document.getElementById("lib-pager"), null, () => {});
return;
}
const paged = pageSlice(shown, libPage, DEFAULT_PAGE_SIZE);
libPage = paged.page;
const parts = [];
let lastGroup = null;
shown.forEach((file) => {
paged.items.forEach((file) => {
let group = null;
if (sort === "material") group = materialKey(file) || "Ohne Material";
else if (sort === "folder") group = folderLabel(folderKey(file));
@@ -882,6 +922,10 @@ function renderLibrary() {
parts.push(libItemHtml(file));
});
list.innerHTML = parts.join("");
renderPager(document.getElementById("lib-pager"), paged, (p) => {
libPage = p;
renderLibrary();
});
}
/* ---------- Flow rechts ---------- */
@@ -1546,6 +1590,23 @@ async function doAblauf() {
/* ---------- Ereignisse ---------- */
document.getElementById("lib-list").addEventListener("toggle", (event) => {
const details = event.target;
if (!(details instanceof HTMLDetailsElement) || !details.classList.contains("lib-info")) return;
if (details.open) {
closeOpenLibInfo(details);
placeLibInfoPop(details);
}
}, true);
document.getElementById("lib-list").addEventListener("scroll", repositionOpenLibInfo, { passive: true });
window.addEventListener("resize", repositionOpenLibInfo);
document.addEventListener("pointerdown", (event) => {
if (event.target.closest("details.lib-info")) return;
closeOpenLibInfo();
});
document.getElementById("lib-list").addEventListener("click", (event) => {
if (event.target.closest(".lib-move")) {
event.stopPropagation();
@@ -1628,6 +1689,7 @@ document.getElementById("lib-list").addEventListener("keydown", (event) => {
const node = document.getElementById(id);
if (!node) return;
const onChange = () => {
libPage = 1;
saveLibMemory();
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="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=68">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=70">
<link rel="manifest" href="{{ url_for('static', filename='manifest.webmanifest') }}">
<meta name="theme-color" content="#0a0a0c" id="theme-color-meta">
<script>
@@ -161,7 +161,7 @@
<form method="dialog"><button class="ghost" value="cancel">Schließen</button></form>
</dialog>
<script src="{{ url_for('static', filename='api.js') }}?v=32"></script>
<script src="{{ url_for('static', filename='ui.js') }}?v=5"></script>
<script src="{{ url_for('static', filename='ui.js') }}?v=6"></script>
<script>
async function doLogout() {
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>
<tbody id="spool-rows"></tbody>
</table>
<div class="pager" id="spool-pager" hidden></div>
</section>
<section class="card mat-form" id="spool-panel" hidden>
@@ -683,6 +684,8 @@ function fillSelects() {
materialSelect.value = currentMaterial;
}
let spoolPage = 1;
function renderSpools() {
document.querySelectorAll("#spool-filter .fchip").forEach((btn) => {
btn.classList.toggle("on", (btn.dataset.filter || "all") === spoolFilter);
@@ -698,14 +701,17 @@ function renderSpools() {
spoolFilter === "archive" ? shown.length : activeCount
);
const note = document.getElementById("spool-list-note");
const pager = document.getElementById("spool-pager");
if (!spools.length) {
note.textContent = "";
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("spool") + '<p>Noch keine Spule.</p><p class="tiny">„Neue Spule“ anlegen oder Code scannen.</p></div></td></tr>';
renderPager(pager, null, () => {});
return;
}
if (!shown.length) {
note.textContent = "";
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("search") + '<p>Keine Spule passt.</p></div></td></tr>';
renderPager(pager, null, () => {});
return;
}
note.textContent = spoolFilter === "archive"
@@ -719,8 +725,10 @@ function renderSpools() {
const bh = b.printer_host || "zzz";
return ah.localeCompare(bh, "de") || (Number(a.ace_slot) || 0) - (Number(b.ace_slot) || 0) || a.name.localeCompare(b.name, "de");
});
const paged = pageSlice(ordered, spoolPage, DEFAULT_PAGE_SIZE);
spoolPage = paged.page;
let lastPlace = null;
ordered.forEach((spool) => {
paged.items.forEach((spool) => {
const group = spool.printer_host ? printerName(spool.printer_host) : "Regal";
if (group !== lastPlace) {
lastPlace = group;
@@ -753,6 +761,10 @@ function renderSpools() {
tr.addEventListener("click", () => fill(spool));
body.appendChild(tr);
});
renderPager(pager, paged, (p) => {
spoolPage = p;
renderSpools();
});
}
function renderSorten() {
@@ -867,6 +879,7 @@ document.querySelectorAll(".mat-tabs [data-view]").forEach((btn) => {
});
document.getElementById("spool-filter").addEventListener("click", (event) => {
spoolPage = 1;
const btn = event.target.closest("[data-filter]");
if (!btn) return;
spoolFilter = btn.dataset.filter || "all";
@@ -874,6 +887,7 @@ document.getElementById("spool-filter").addEventListener("click", (event) => {
});
document.getElementById("spool-search").addEventListener("input", (event) => {
spoolSearch = event.target.value || "";
spoolPage = 1;
renderSpools();
});
document.getElementById("sorten-search").addEventListener("input", (event) => {
@@ -996,10 +1010,12 @@ document.getElementById("spool-delete").addEventListener("click", () => {
});
});
document.getElementById("spool-filter-type")?.addEventListener("change", (event) => {
spoolPage = 1;
spoolTypeFilter = event.target.value || "";
renderSpools();
});
document.getElementById("spool-filter-color")?.addEventListener("change", (event) => {
spoolPage = 1;
spoolColorFilter = event.target.value || "";
renderSpools();
});
+12 -1
View File
@@ -22,6 +22,7 @@
<thead><tr><th>Name</th><th>Status</th><th>Ordner</th><th class="num">Dateien</th></tr></thead>
<tbody id="proj-rows"></tbody>
</table>
<div class="pager" id="proj-pager" hidden></div>
</div>
</section>
@@ -110,15 +111,21 @@ function fill(project) {
renderDetail(project);
}
let projPage = 1;
function renderList() {
const body = document.getElementById("proj-rows");
const pager = document.getElementById("proj-pager");
document.getElementById("proj-count").textContent = String(projects.length);
if (!projects.length) {
body.innerHTML = '<tr><td class="empty" colspan="4"><div class="empty-state">' + icon("folder") + "<p>Noch kein Projekt.</p></div></td></tr>";
renderPager(pager, null, () => {});
return;
}
body.innerHTML = "";
projects.forEach((project) => {
const paged = pageSlice(projects, projPage, DEFAULT_PAGE_SIZE);
projPage = paged.page;
paged.items.forEach((project) => {
const tr = document.createElement("tr");
tr.className = "pick" + (String(project.id) === String(selectedId) ? " on" : "");
tr.innerHTML = "<td></td><td></td><td></td><td class=\"num\"></td>";
@@ -130,6 +137,10 @@ function renderList() {
tr.addEventListener("click", () => openProject(project.id));
body.appendChild(tr);
});
renderPager(pager, paged, (p) => {
projPage = p;
renderList();
});
}
function renderDetail(project) {
+15 -4
View File
@@ -27,6 +27,7 @@
<thead><tr><th>Drucker</th><th>Datei</th><th>Zustand</th><th>Dauer</th><th>Ist</th><th>Kosten</th><th>Urteil</th><th>Spule</th><th></th></tr></thead>
<tbody id="run-rows"></tbody>
</table>
<div class="pager" id="run-pager" hidden></div>
</div>
{% endblock %}
{% block script %}
@@ -37,6 +38,7 @@ const states = {
};
let runs = [];
let spools = [];
let runPage = 1;
function fillPrinterFilter() {
const select = document.getElementById("run-filter-printer");
@@ -82,13 +84,17 @@ function render() {
const shown = filteredRuns();
if (!runs.length) {
body.innerHTML = '<tr><td class="empty" colspan="9"><div class="empty-state">' + icon("list") + '<p>Noch kein Lauf.</p><p class="tiny">„Läufe holen“ liest die Drucker aus.</p></div></td></tr>';
renderPager(document.getElementById("run-pager"), null, () => {});
return;
}
if (!shown.length) {
body.innerHTML = '<tr><td class="empty" colspan="9"><div class="empty-state">' + icon("filter") + '<p>Kein Lauf passt zum Filter.</p></div></td></tr>';
renderPager(document.getElementById("run-pager"), null, () => {});
return;
}
shown.forEach((run) => {
const paged = pageSlice(shown, runPage, DEFAULT_PAGE_SIZE);
runPage = paged.page;
paged.items.forEach((run) => {
const tr = document.createElement("tr");
tr.className = "pick";
tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>";
@@ -233,16 +239,21 @@ function render() {
}
body.appendChild(tr);
});
renderPager(document.getElementById("run-pager"), paged, (p) => {
runPage = p;
render();
});
}
async function load() {
[runs, spools] = await Promise.all([api("/laeufe"), api("/spulen")]);
[runs, spools] = await Promise.all([api("/laeufe?limit=500"), api("/spulen")]);
runPage = 1;
render();
}
["run-filter-printer", "run-filter-verdict", "run-filter-spool"].forEach((id) => {
document.getElementById(id).addEventListener("change", render);
document.getElementById(id).addEventListener("input", render);
document.getElementById(id).addEventListener("change", () => { runPage = 1; render(); });
document.getElementById(id).addEventListener("input", () => { runPage = 1; render(); });
});
document.getElementById("sync").addEventListener("click", async () => {
+14 -2
View File
@@ -34,6 +34,7 @@
</thead>
<tbody id="q-rows"></tbody>
</table>
<div class="pager" id="q-pager" hidden></div>
</div>
{% endblock %}
{% block script %}
@@ -45,6 +46,7 @@ const STATUS_LABEL = {
let jobs = [];
let liveDirect = [];
let filter = "";
let qPage = 1;
function targetOf(job) {
return job.printer_name || job.host || (job.family_label ? "nächster " + job.family_label : "nächster freier");
@@ -86,6 +88,7 @@ function render() {
});
const body = document.getElementById("q-rows");
const shown = shownJobs();
const pager = document.getElementById("q-pager");
if (!shown.length) {
const hint = jobs.length
? "Nichts in dieser Ansicht."
@@ -97,10 +100,13 @@ function render() {
: "";
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("queue") + '<p>' + hint + '</p>' + cta + '</div></td></tr>';
document.getElementById("q-note").textContent = "";
renderPager(pager, null, () => {});
return;
}
const waiting = jobs.filter((j) => j.status === "waiting");
body.innerHTML = shown.map((job) => {
const paged = pageSlice(shown, qPage, DEFAULT_PAGE_SIZE);
qPage = paged.page;
body.innerHTML = paged.items.map((job) => {
const st = job.status;
if (job.direct) {
return `<tr data-direct="${escapeHtml(job.host)}">
@@ -141,17 +147,22 @@ function render() {
</tr>`;
}).join("");
document.getElementById("q-note").textContent = shown.length === 1 ? "1 Eintrag" : shown.length + " Einträge";
renderPager(pager, paged, (p) => {
qPage = p;
render();
});
}
async function load() {
try {
const [queue, active] = await Promise.all([
api("/druck-queue?limit=120"),
api("/druck-queue?limit=200"),
api("/laeuft").catch(() => []),
]);
jobs = queue || [];
const queuedHosts = new Set(jobs.filter((j) => j.status === "running").map((j) => j.host));
liveDirect = (active || []).filter((p) => p.host && !queuedHosts.has(p.host));
qPage = 1;
render();
} catch (error) {
document.getElementById("q-rows").innerHTML =
@@ -163,6 +174,7 @@ document.getElementById("q-filter").addEventListener("click", (event) => {
const btn = event.target.closest("[data-status]");
if (!btn) return;
filter = btn.dataset.status || "";
qPage = 1;
render();
});
document.getElementById("q-refresh").addEventListener("click", load);