Grotti 3.5.0: Pagination, Logging, Deb-Paket, Makro-Doku, PLA-Match-Fix.

This commit is contained in:
Dominic Gundel committed 2026-10-19 11:14:24 +02:00
1 parent 4ffe1dcfc6
commit 61e80f69a3
38 files changed
+1057 -70

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
+17 -1
View File
@@ -4,9 +4,24 @@ 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 ### Geändert
- **README:** Banner, Badges, Feature-Übersicht und Community-Screenshots oben; Setup/API darunter. - **Config:** `GROTTI_CONFIG` bzw. `/etc/grotti/config.json` für Paket-Install; sonst Repo-`config.json`.
- **README:** Banner, Badges, Feature-Übersicht und Community-Screenshots oben; Setup/API darunter; Pi-`.deb`-Hinweis.
### Behoben
- **Queue „braucht Eryone PLA Lite“ trotz PLA-Spule:** Basis-Profil (ohne Meta/Matte/Silk) matcht nach Typ; Slice-Hex-Farbe (`#26A69A`) blockiert Lagerfarbe „Grau“ nicht mehr.
- **Dateien Info-Popup:** Details-Popup wird nicht mehr vom Bibliothek-Scroll abgeschnitten; Position fixed mit Flip nach oben, besserer Kontrast.
## [3.4.0] – 2026-10-18 ## [3.4.0] – 2026-10-18
@@ -16,6 +31,7 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
### Behoben ### Behoben
- **Queue „druckt schon“ bei Folgejobs:** Einfach-Druck bleibt `running` bis Moonraker fertig — nicht mehr sofort `done` nach Start (sonst startete der nächste Job zu früh).
- **Bedarf „443 g fehlen“ trotz voller Spulen:** Slice-Farbe `#26A69A` matchte nicht Lagerfarbe „Grau“ → Rest 0. Bei Hex-Farbe zählt jetzt der Rest gleicher Sorte; archivierte Spulen nicht. - **Bedarf „443 g fehlen“ trotz voller Spulen:** Slice-Farbe `#26A69A` matchte nicht Lagerfarbe „Grau“ → Rest 0. Bei Hex-Farbe zählt jetzt der Rest gleicher Sorte; archivierte Spulen nicht.
- **Falsches „Material nachlegen“ bei manueller Pause:** Anycubic meldet `active_filament.empty=true` auch ohne Runout (`tool`/`gate` = −1). Heuristik nur noch bei Meldung/`reason_for_pause`/10107, aktivem Tool + differenziertem `gate_status`, oder G-code-Store. - **Falsches „Material nachlegen“ bei manueller Pause:** Anycubic meldet `active_filament.empty=true` auch ohne Runout (`tool`/`gate` = −1). Heuristik nur noch bei Meldung/`reason_for_pause`/10107, aktivem Tool + differenziertem `gate_status`, oder G-code-Store.
- **gklib 10111 nach Einfarbig-Upload:** Orca-Zeile `; filament_colour_type` wird beim Senden entfernt (nur Einfarbig; Multicolor unangetastet). - **gklib 10111 nach Einfarbig-Upload:** Orca-Zeile `; filament_colour_type` wird beim Senden entfernt (nur Einfarbig; Multicolor unangetastet).
+13 -1
View File
@@ -9,7 +9,7 @@
</p> </p>
<p align="center"> <p align="center">
<a href="https://git.gundel.dev/dg/grotti"><img src="https://img.shields.io/badge/Version-3.4.0-e85d04?style=flat-square" alt="Version 3.4.0"></a> <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/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/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/Moonraker-Klipper-2ecc71?style=flat-square" alt="Moonraker">
@@ -80,6 +80,18 @@ copy config.example.json config.json
Dann <http://127.0.0.1:8770/>. Bedienung unter **Doku** in der App (`docs/Anleitung-*.md`). Dann <http://127.0.0.1:8770/>. Bedienung unter **Doku** in der App (`docs/Anleitung-*.md`).
### 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. `config.json` und `data/` bleiben lokal (nicht im Git). Drucker zuerst in `drucker.json`, danach unter Einstellungen.
| Schlüssel | Bedeutung | | Schlüssel | Bedeutung |
+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": {
+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).
+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 |
+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)
+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 import urllib.request
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from grotti import logutil
from grotti.parse import embedded_png from grotti.parse import embedded_png
log = logutil.get("grotti.moonraker")
META_CACHE = {} META_CACHE = {}
CAMERA_CACHE = {} CAMERA_CACHE = {}
EMBEDDED = {} EMBEDDED = {}
@@ -1241,7 +1244,7 @@ def snapshot(printers):
card.update(query(printer["host"])) card.update(query(printer["host"]))
except Exception as error: except Exception as error:
# Auch RemoteDisconnected / IncompleteRead / ValueError — sonst fällt der ganze Tick aus. # Auch RemoteDisconnected / IncompleteRead / ValueError — sonst fällt der ganze Tick aus.
print("snapshot %s: %s" % (printer.get("host"), error), flush=True) log.warning("snapshot %s: %s", printer.get("host"), error)
card.update({ card.update({
"ok": False, "ok": False,
"state": "offline", "state": "offline",
+67 -9
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 not _has_ablauf(running):
if ( if (
not _has_ablauf(running) (running.get("phase") or "").lower() == "starting"
and (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)
+21 -11
View File
@@ -2813,6 +2813,20 @@ def _file_needs(conn, file_id):
return types, float(row.get("weight_g") or 0) return types, float(row.get("weight_g") or 0)
def _prefer_color(spools, need_color):
"""Farbe soft: Slice-Hex ignorieren; benannte Farbe bevorzugen, nie blockieren."""
from grotti.bedarf import _is_slicer_hex
need_c = (need_color or "").strip().lower()
if not need_c or _is_slicer_hex(need_c) or not spools:
return list(spools)
preferred = [
h for h in spools
if not (h.get("color") or "").strip() or (h.get("color") or "").strip().lower() == need_c
]
return preferred or list(spools)
def _match_spools_for_need(need, have, used_ids=None): def _match_spools_for_need(need, have, used_ids=None):
used = used_ids or set() used = used_ids or set()
candidates = [h for h in have if h.get("spool_id") not in used] candidates = [h for h in have if h.get("spool_id") not in used]
@@ -2822,29 +2836,25 @@ def _match_spools_for_need(need, have, used_ids=None):
kind = (kind or "").strip().lower() kind = (kind or "").strip().lower()
return [ return [
h for h in candidates h for h in candidates
if (not kind or h["material_type"] == kind) if not kind or h["material_type"] == kind
and (not need.get("color") or h["color"] == need["color"] or not h["color"])
] ]
if profile: if profile:
by_profile = [h for h in candidates if _profile_matches(profile, h.get("material_name"))] by_profile = [h for h in candidates if _profile_matches(profile, h.get("material_name"))]
if by_profile: if by_profile:
colored = [ return _prefer_color(by_profile, need.get("color"))
h for h in by_profile # Basis-Profil ohne Meta/Matte/Silk: Marke egal — Typ reicht
if not need.get("color") or h["color"] == need["color"] or not h["color"] # (Sunlu PLA ↔ Eryone PLA Lite; Slice-Hex #26A69A blockiert nicht „Grau“).
]
return colored or by_profile
# Basis-Profil ohne Meta/Matte/Silk: Marke egal — Typ reicht (Sunlu PLA ↔ Eryone PLA).
# Spulen mit Variante im Namen bleiben draußen (kein Meta für „nur PLA“).
if not _profile_has_variant(profile): if not _profile_has_variant(profile):
kind = _profile_base_type(profile) or (need.get("material_type") or "") kind = _profile_base_type(profile) or (need.get("material_type") or "")
if kind: if kind:
return [ typed = [
h for h in _by_type(kind) h for h in _by_type(kind)
if not _profile_has_variant(h.get("material_name") or "") if not _profile_has_variant(h.get("material_name") or "")
] ]
return _prefer_color(typed, need.get("color"))
return [] return []
return _by_type(need.get("material_type") or "") return _prefer_color(_by_type(need.get("material_type") or ""), need.get("color"))
def material_fit_score(conn, host, file_id): def material_fit_score(conn, host, file_id):
+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.4.0" __version__ = "3.5.0"
+10 -8
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):
@@ -118,7 +120,7 @@ class StateWatcher:
jobs.apply_deduction(conn, closed, self.cfg["energy"]) jobs.apply_deduction(conn, closed, self.cfg["energy"])
except Exception as exc: except Exception as exc:
print("Watcher Abzug offline %s: %s" % (host, exc), flush=True) log.warning("Watcher Abzug offline %s: %s", host, exc)
self._prev[host] = {"state": "offline", "offline": True, "filename": filename} self._prev[host] = {"state": "offline", "offline": True, "filename": filename}
return return
@@ -193,7 +195,7 @@ class StateWatcher:
jobs.sync_history(conn, self.cfg) jobs.sync_history(conn, self.cfg)
except Exception as exc: except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True) log.warning("Watcher Abgleich %s: %s", host, exc)
# History oft ohne Endzeit (interrupted) — Live trotzdem schließen. # History oft ohne Endzeit (interrupted) — Live trotzdem schließen.
dangling = store.find_live_run(conn, host) dangling = store.find_live_run(conn, host)
if dangling: if dangling:
@@ -204,7 +206,7 @@ class StateWatcher:
jobs.apply_deduction(conn, closed, self.cfg["energy"]) jobs.apply_deduction(conn, closed, self.cfg["energy"])
except Exception as exc: except Exception as exc:
print("Watcher Abzug %s: %s" % (host, exc), flush=True) log.warning("Watcher Abzug %s: %s", host, exc)
elif state in ("cancelled", "standby"): elif state in ("cancelled", "standby"):
# Live-Lauf schließen: normaler Abbruch, oder nach Offline/Neustart # Live-Lauf schließen: normaler Abbruch, oder nach Offline/Neustart
# (prev wird „offline“, der Übergang paused→standby geht sonst verloren). # (prev wird „offline“, der Übergang paused→standby geht sonst verloren).
@@ -231,7 +233,7 @@ class StateWatcher:
jobs.sync_history(conn, self.cfg) jobs.sync_history(conn, self.cfg)
except Exception as exc: except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True) log.warning("Watcher Abgleich %s: %s", host, exc)
elif state == "error" and prev_state != "error": elif state == "error" and prev_state != "error":
if prev_state == "printing" or prev_state == "paused": if prev_state == "printing" or prev_state == "paused":
store.set_bed_needs_clear(conn, host, True, filename or "Fehler") store.set_bed_needs_clear(conn, host, True, filename or "Fehler")
@@ -254,7 +256,7 @@ class StateWatcher:
jobs.sync_history(conn, self.cfg) jobs.sync_history(conn, self.cfg)
except Exception as exc: except Exception as exc:
print("Watcher Abgleich %s: %s" % (host, exc), flush=True) log.warning("Watcher Abgleich %s: %s", host, exc)
elif state == "error": elif state == "error":
if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0: if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0:
self._cool_heaters(host) self._cool_heaters(host)
@@ -436,7 +438,7 @@ class StateWatcher:
try: try:
moonraker.heaters_off(host) moonraker.heaters_off(host)
except Exception as exc: except Exception as exc:
print("Watcher Heizung aus %s: %s" % (host, exc), flush=True) log.warning("Watcher Heizung aus %s: %s", host, exc)
def _check_spool(self, conn, host, name, filename, live): def _check_spool(self, conn, host, name, filename, live):
need = float(live.get("weight") or 0) need = float(live.get("weight") or 0)
+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)
+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} ids = {m["spool_id"] for m in matched}
self.assertEqual(ids, {3}) # Eryone PLA; Meta/Matte nicht self.assertEqual(ids, {3}) # Eryone PLA; Meta/Matte nicht
def test_pla_lite_hex_matches_plain_pla(self):
# Orca: Profil „Eryone PLA Lite“, Farbe #26A69A — Lager: Eryone/Sunlu PLA „Grau“
need = {
"profile": "Eryone PLA Lite",
"material_type": "pla",
"color": "#26A69A",
"paint_index": 0,
}
have = [
{
"spool_id": 15,
"material_type": "pla",
"color": "grau",
"remaining_g": 873,
"material_name": "sunlu pla",
"ace_slot": None,
},
{
"spool_id": 1,
"material_type": "pla",
"color": "grau",
"remaining_g": 800,
"material_name": "sunlu pla meta",
"ace_slot": 1,
},
]
matched = store._match_spools_for_need(need, have)
self.assertEqual({m["spool_id"] for m in matched}, {15})
def test_picks_meta_not_matte(self): def test_picks_meta_not_matte(self):
need = {"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "", "paint_index": 0} need = {"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "", "paint_index": 0}
matched = store._match_spools_for_need(need, self.have) matched = store._match_spools_for_need(need, self.have)
+12 -4
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",
@@ -1698,7 +1702,7 @@ def create_app(cfg):
try: try:
jobs.apply_deduction(c, closed, cfg["energy"]) jobs.apply_deduction(c, closed, cfg["energy"])
except Exception as exc: except Exception as exc:
print("Abräumen Abzug %s: %s" % (host, exc), flush=True) log.warning("Abräumen Abzug %s: %s", host, exc)
row = store.get_run_row(c, closed["id"]) or closed row = store.get_run_row(c, closed["id"]) or closed
run_json = store.run_out(row) run_json = store.run_out(row)
reprint = None reprint = None
@@ -2451,7 +2455,11 @@ def create_app(cfg):
@app.get("/api/v1/laeufe") @app.get("/api/v1/laeufe")
def run_list(): def run_list():
return jsonify(store.list_runs(conn())) try:
limit = min(1000, max(1, int(request.args.get("limit") or 500)))
except (TypeError, ValueError):
limit = 500
return jsonify(store.list_runs(conn(), limit=limit))
@app.get("/api/v1/laeufe/aktuell") @app.get("/api/v1/laeufe/aktuell")
def run_aktuell(): def run_aktuell():
+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();
} }
+63 -1
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>
@@ -839,6 +840,39 @@ function infoPopupHtml(file) {
</details>`; </details>`;
} }
function placeLibInfoPop(details) {
const pop = details.querySelector(".lib-info-pop");
const anchor = details.querySelector("summary");
if (!pop || !anchor) return;
const margin = 6;
const r = anchor.getBoundingClientRect();
pop.style.left = "0px";
pop.style.top = "0px";
const pw = pop.offsetWidth;
const ph = pop.offsetHeight;
let left = r.right - pw;
left = Math.max(margin, Math.min(left, window.innerWidth - pw - margin));
let top = r.bottom + margin;
if (top + ph > window.innerHeight - margin && r.top - margin - ph >= margin) {
top = r.top - margin - ph;
} else {
top = Math.min(top, Math.max(margin, window.innerHeight - ph - margin));
}
pop.style.left = left + "px";
pop.style.top = top + "px";
}
function closeOpenLibInfo(except) {
document.querySelectorAll("details.lib-info[open]").forEach((node) => {
if (node !== except) node.removeAttribute("open");
});
}
function repositionOpenLibInfo() {
const open = document.querySelector("details.lib-info[open]");
if (open) placeLibInfoPop(open);
}
function thumbHtml(file) { function thumbHtml(file) {
return file.preview return file.preview
? `<img alt="" loading="lazy" src="${escapeHtml(withToken(file.preview))}" onerror="this.style.visibility='hidden'">` ? `<img alt="" loading="lazy" src="${escapeHtml(withToken(file.preview))}" onerror="this.style.visibility='hidden'">`
@@ -856,6 +890,8 @@ function libItemHtml(file) {
</div>`; </div>`;
} }
let libPage = 1;
function renderLibrary() { function renderLibrary() {
const list = document.getElementById("lib-list"); const list = document.getElementById("lib-list");
const shown = filteredFiles(); const shown = filteredFiles();
@@ -863,15 +899,19 @@ function renderLibrary() {
document.getElementById("lib-count").textContent = shown.length === files.length ? String(files.length) : shown.length + "/" + files.length; document.getElementById("lib-count").textContent = shown.length === files.length ? String(files.length) : shown.length + "/" + files.length;
if (!files.length) { if (!files.length) {
list.innerHTML = '<div class="picked-empty">' + icon("file") + '<span>Noch keine Datei.</span><span class="tiny">Hochladen oder Slice-Ordner in den Einstellungen setzen.</span></div>'; list.innerHTML = '<div class="picked-empty">' + icon("file") + '<span>Noch keine Datei.</span><span class="tiny">Hochladen oder Slice-Ordner in den Einstellungen setzen.</span></div>';
renderPager(document.getElementById("lib-pager"), null, () => {});
return; return;
} }
if (!shown.length) { if (!shown.length) {
list.innerHTML = '<div class="picked-empty">' + icon("search") + '<span>Keine Datei passt.</span></div>'; list.innerHTML = '<div class="picked-empty">' + icon("search") + '<span>Keine Datei passt.</span></div>';
renderPager(document.getElementById("lib-pager"), null, () => {});
return; return;
} }
const paged = pageSlice(shown, libPage, DEFAULT_PAGE_SIZE);
libPage = paged.page;
const parts = []; const parts = [];
let lastGroup = null; let lastGroup = null;
shown.forEach((file) => { paged.items.forEach((file) => {
let group = null; let group = null;
if (sort === "material") group = materialKey(file) || "Ohne Material"; if (sort === "material") group = materialKey(file) || "Ohne Material";
else if (sort === "folder") group = folderLabel(folderKey(file)); else if (sort === "folder") group = folderLabel(folderKey(file));
@@ -882,6 +922,10 @@ function renderLibrary() {
parts.push(libItemHtml(file)); parts.push(libItemHtml(file));
}); });
list.innerHTML = parts.join(""); list.innerHTML = parts.join("");
renderPager(document.getElementById("lib-pager"), paged, (p) => {
libPage = p;
renderLibrary();
});
} }
/* ---------- Flow rechts ---------- */ /* ---------- Flow rechts ---------- */
@@ -1546,6 +1590,23 @@ async function doAblauf() {
/* ---------- Ereignisse ---------- */ /* ---------- Ereignisse ---------- */
document.getElementById("lib-list").addEventListener("toggle", (event) => {
const details = event.target;
if (!(details instanceof HTMLDetailsElement) || !details.classList.contains("lib-info")) return;
if (details.open) {
closeOpenLibInfo(details);
placeLibInfoPop(details);
}
}, true);
document.getElementById("lib-list").addEventListener("scroll", repositionOpenLibInfo, { passive: true });
window.addEventListener("resize", repositionOpenLibInfo);
document.addEventListener("pointerdown", (event) => {
if (event.target.closest("details.lib-info")) return;
closeOpenLibInfo();
});
document.getElementById("lib-list").addEventListener("click", (event) => { document.getElementById("lib-list").addEventListener("click", (event) => {
if (event.target.closest(".lib-move")) { if (event.target.closest(".lib-move")) {
event.stopPropagation(); event.stopPropagation();
@@ -1628,6 +1689,7 @@ document.getElementById("lib-list").addEventListener("keydown", (event) => {
const node = document.getElementById(id); const node = document.getElementById(id);
if (!node) return; if (!node) return;
const onChange = () => { const onChange = () => {
libPage = 1;
saveLibMemory(); saveLibMemory();
renderLibrary(); renderLibrary();
}; };
+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);