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 @@

- Version 3.4.0 + Version 3.5.0 Python 3.11+ PostgreSQL Moonraker @@ -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 = + '" + + '' + s.from + "–" + s.to + " von " + s.total + "" + + '"; + 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 %} - +