From f1f79c2ce2e1098b601fec39dfcbfc6c3542c335 Mon Sep 17 00:00:00 2001
From: Dominic Gundel
Date: Mon, 19 Oct 2026 11:14:24 +0200
Subject: [PATCH] Grotti 3.5.0: Pagination, Logging, Deb-Paket, Makro-Doku,
PLA-Match-Fix.
---
.gitignore | 5 +
CHANGELOG.md | 18 ++-
README.md | 14 ++-
config.example.json | 1 +
docs/Anleitung-Installation-Pi.md | 92 ++++++++++++++
docs/internal/Anycubic-Stack.md | 5 +
docs/internal/Makros-Kobra.md | 195 ++++++++++++++++++++++++++++++
docs/internal/README.md | 5 +
grotti/config.py | 28 ++++-
grotti/db.py | 6 +-
grotti/logutil.py | 52 ++++++++
grotti/moonraker.py | 5 +-
grotti/print_queue.py | 86 ++++++++++---
grotti/store.py | 32 +++--
grotti/version.py | 2 +-
grotti/watcher.py | 18 +--
packaging/build-deb.sh | 84 +++++++++++++
packaging/config.pi.json | 56 +++++++++
packaging/debian/conffiles | 1 +
packaging/debian/postinst | 45 +++++++
packaging/debian/postrm | 19 +++
packaging/debian/prerm | 13 ++
packaging/dist/.gitkeep | 0
packaging/grotti.service | 24 ++++
server.py | 15 ++-
tests/test_logutil.py | 22 ++++
tests/test_material_routing.py | 29 +++++
web/app.py | 16 ++-
web/static/grotti.css | 38 ++++--
web/static/ui.js | 44 +++++++
web/templates/aktivitaet.html | 19 ++-
web/templates/auftraege.html | 14 ++-
web/templates/dateien.html | 64 +++++++++-
web/templates/layout.html | 4 +-
web/templates/material.html | 18 ++-
web/templates/projekte.html | 13 +-
web/templates/protokoll.html | 19 ++-
web/templates/warteschlange.html | 16 ++-
38 files changed, 1062 insertions(+), 75 deletions(-)
create mode 100644 docs/Anleitung-Installation-Pi.md
create mode 100644 docs/internal/Makros-Kobra.md
create mode 100644 grotti/logutil.py
create mode 100644 packaging/build-deb.sh
create mode 100644 packaging/config.pi.json
create mode 100644 packaging/debian/conffiles
create mode 100644 packaging/debian/postinst
create mode 100644 packaging/debian/postrm
create mode 100644 packaging/debian/prerm
create mode 100644 packaging/dist/.gitkeep
create mode 100644 packaging/grotti.service
create mode 100644 tests/test_logutil.py
diff --git a/.gitignore b/.gitignore
index b6b20c4..1bc7539 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,8 @@ slice/*
__pycache__/
*.pyc
_tmp_*
+%s
+
+# gebaute .deb (Skript legt sie hier ab)
+packaging/dist/*
+!packaging/dist/.gitkeep
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 50c9a0f..b1b68a2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,9 +4,24 @@ 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
-- **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
@@ -16,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).
diff --git a/README.md b/README.md
index 9b13050..cd46f3c 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
-
+
@@ -80,6 +80,18 @@ copy config.example.json config.json
Dann . 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.
| Schlüssel | Bedeutung |
diff --git a/config.example.json b/config.example.json
index 516ad90..7e20e88 100644
--- a/config.example.json
+++ b/config.example.json
@@ -7,6 +7,7 @@
"token": "",
"secret_key": "",
"fqdn": "",
+ "log_level": "INFO",
"auto_queue": true,
"auto_retry": true,
"oauth": {
diff --git a/docs/Anleitung-Installation-Pi.md b/docs/Anleitung-Installation-Pi.md
new file mode 100644
index 0000000..7ed42e3
--- /dev/null
+++ b/docs/Anleitung-Installation-Pi.md
@@ -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__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://: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).
diff --git a/docs/internal/Anycubic-Stack.md b/docs/internal/Anycubic-Stack.md
index 2d7c584..800d0c1 100644
--- a/docs/internal/Anycubic-Stack.md
+++ b/docs/internal/Anycubic-Stack.md
@@ -139,3 +139,8 @@ Ohne Map: Rinkhals füllt oft Identity T0→Platz0 … — deshalb Grotti bei ei
- Quellcode von **gkapi** (Task-State-Machine, exakte 10101-Bedingungen) ist nicht öffentlich.
- 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
+
diff --git a/docs/internal/Makros-Kobra.md b/docs/internal/Makros-Kobra.md
new file mode 100644
index 0000000..74f1e96
--- /dev/null
+++ b/docs/internal/Makros-Kobra.md
@@ -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
diff --git a/docs/internal/README.md b/docs/internal/README.md
index b28b413..857c767 100644
--- a/docs/internal/README.md
+++ b/docs/internal/README.md
@@ -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 |
diff --git a/grotti/config.py b/grotti/config.py
index da88ccf..94c93a0 100644
--- a/grotti/config.py
+++ b/grotti/config.py
@@ -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),
diff --git a/grotti/db.py b/grotti/db.py
index 640b9c3..a25dd34 100644
--- a/grotti/db.py
+++ b/grotti/db.py
@@ -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)
diff --git a/grotti/logutil.py b/grotti/logutil.py
new file mode 100644
index 0000000..ca397fb
--- /dev/null
+++ b/grotti/logutil.py
@@ -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)
diff --git a/grotti/moonraker.py b/grotti/moonraker.py
index a339d42..7959a3b 100644
--- a/grotti/moonraker.py
+++ b/grotti/moonraker.py
@@ -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",
diff --git a/grotti/print_queue.py b/grotti/print_queue.py
index 77c0216..f188dfe 100644
--- a/grotti/print_queue.py
+++ b/grotti/print_queue.py
@@ -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)
- and (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)
+ if not _has_ablauf(running):
+ if (
+ (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)
diff --git a/grotti/store.py b/grotti/store.py
index 46d442c..41c3152 100644
--- a/grotti/store.py
+++ b/grotti/store.py
@@ -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):
diff --git a/grotti/version.py b/grotti/version.py
index c5e9d4c..0f49bb1 100644
--- a/grotti/version.py
+++ b/grotti/version.py
@@ -1,3 +1,3 @@
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
-__version__ = "3.4.0"
+__version__ = "3.5.0"
diff --git a/grotti/watcher.py b/grotti/watcher.py
index c35250e..299feae 100644
--- a/grotti/watcher.py
+++ b/grotti/watcher.py
@@ -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)
diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh
new file mode 100644
index 0000000..89b2ae4
--- /dev/null
+++ b/packaging/build-deb.sh
@@ -0,0 +1,84 @@
+#!/usr/bin/env bash
+# Baut grotti__.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" <
+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"
diff --git a/packaging/config.pi.json b/packaging/config.pi.json
new file mode 100644
index 0000000..84aaeb5
--- /dev/null
+++ b/packaging/config.pi.json
@@ -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
+ }
+}
diff --git a/packaging/debian/conffiles b/packaging/debian/conffiles
new file mode 100644
index 0000000..d255d71
--- /dev/null
+++ b/packaging/debian/conffiles
@@ -0,0 +1 @@
+/etc/grotti/config.json
diff --git a/packaging/debian/postinst b/packaging/debian/postinst
new file mode 100644
index 0000000..dd318f0
--- /dev/null
+++ b/packaging/debian/postinst
@@ -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
diff --git a/packaging/debian/postrm b/packaging/debian/postrm
new file mode 100644
index 0000000..3951a46
--- /dev/null
+++ b/packaging/debian/postrm
@@ -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
diff --git a/packaging/debian/prerm b/packaging/debian/prerm
new file mode 100644
index 0000000..7c09baa
--- /dev/null
+++ b/packaging/debian/prerm
@@ -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
diff --git a/packaging/dist/.gitkeep b/packaging/dist/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/packaging/grotti.service b/packaging/grotti.service
new file mode 100644
index 0000000..b83b0ed
--- /dev/null
+++ b/packaging/grotti.service
@@ -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
diff --git a/server.py b/server.py
index 1868d29..971b404 100644
--- a/server.py
+++ b/server.py
@@ -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)
diff --git a/tests/test_logutil.py b/tests/test_logutil.py
new file mode 100644
index 0000000..15efe6b
--- /dev/null
+++ b/tests/test_logutil.py
@@ -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()
diff --git a/tests/test_material_routing.py b/tests/test_material_routing.py
index 2a190cd..a9e6bfa 100644
--- a/tests/test_material_routing.py
+++ b/tests/test_material_routing.py
@@ -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)
diff --git a/web/app.py b/web/app.py
index 1c5fd3e..8d55c48 100644
--- a/web/app.py
+++ b/web/app.py
@@ -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():
diff --git a/web/static/grotti.css b/web/static/grotti.css
index b79b733..98cfd7e 100644
--- a/web/static/grotti.css
+++ b/web/static/grotti.css
@@ -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; }
diff --git a/web/static/ui.js b/web/static/ui.js
index 6cddf90..58840bd 100644
--- a/web/static/ui.js
+++ b/web/static/ui.js
@@ -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 =
+ '" +
+ '" +
+ '";
+ 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;
diff --git a/web/templates/aktivitaet.html b/web/templates/aktivitaet.html
index 3315ddd..e14276c 100644
--- a/web/templates/aktivitaet.html
+++ b/web/templates/aktivitaet.html
@@ -26,9 +26,12 @@
+
{% endblock %}
{% block script %}
-
+