Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1f79c2ce2 | ||
|
|
4ffe1dcfc6 |
No files matched your search
@@ -10,3 +10,8 @@ slice/*
|
||||
__pycache__/
|
||||
*.pyc
|
||||
_tmp_*
|
||||
%s
|
||||
|
||||
# gebaute .deb (Skript legt sie hier ab)
|
||||
packaging/dist/*
|
||||
!packaging/dist/.gitkeep
|
||||
@@ -4,6 +4,25 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [3.5.0] – 2026-10-19
|
||||
|
||||
### Neu
|
||||
|
||||
- **Doku:** interne Makro-Referenz `docs/internal/Makros-Kobra.md` (Nicht-tun, Vanilla-Klipper-Alltag, ACE, LeviQ/Wipe für S1 und K3).
|
||||
- **Pagination:** Dateien, Protokoll, Material, Aufträge, Warteschlange, Aktivität, Projekte — max. 40 Zeilen pro Seite (Zurück/Weiter).
|
||||
- **Logging:** `log_level` in der Config; Kernpfade schreiben nach stdout (unter systemd → `journalctl -u grotti`).
|
||||
- **Debian-Paket:** `packaging/build-deb.sh` baut `.deb` mit venv, systemd-Unit und PostgreSQL-`postinst` (Peer-Auth). Anleitung: `docs/Anleitung-Installation-Pi.md`.
|
||||
|
||||
### Geändert
|
||||
|
||||
- **Config:** `GROTTI_CONFIG` bzw. `/etc/grotti/config.json` für Paket-Install; sonst Repo-`config.json`.
|
||||
- **README:** Banner, Badges, Feature-Übersicht und Community-Screenshots oben; Setup/API darunter; Pi-`.deb`-Hinweis.
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Queue „braucht Eryone PLA Lite“ trotz PLA-Spule:** Basis-Profil (ohne Meta/Matte/Silk) matcht nach Typ; Slice-Hex-Farbe (`#26A69A`) blockiert Lagerfarbe „Grau“ nicht mehr.
|
||||
- **Dateien Info-Popup:** Details-Popup wird nicht mehr vom Bibliothek-Scroll abgeschnitten; Position fixed mit Flip nach oben, besserer Kontrast.
|
||||
|
||||
## [3.4.0] – 2026-10-18
|
||||
|
||||
### Neu
|
||||
@@ -12,6 +31,7 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Queue „druckt schon“ bei Folgejobs:** Einfach-Druck bleibt `running` bis Moonraker fertig — nicht mehr sofort `done` nach Start (sonst startete der nächste Job zu früh).
|
||||
- **Bedarf „443 g fehlen“ trotz voller Spulen:** Slice-Farbe `#26A69A` matchte nicht Lagerfarbe „Grau“ → Rest 0. Bei Hex-Farbe zählt jetzt der Rest gleicher Sorte; archivierte Spulen nicht.
|
||||
- **Falsches „Material nachlegen“ bei manueller Pause:** Anycubic meldet `active_filament.empty=true` auch ohne Runout (`tool`/`gate` = −1). Heuristik nur noch bei Meldung/`reason_for_pause`/10107, aktivem Tool + differenziertem `gate_status`, oder G-code-Store.
|
||||
- **gklib 10111 nach Einfarbig-Upload:** Orca-Zeile `; filament_colour_type` wird beim Senden entfernt (nur Einfarbig; Multicolor unangetastet).
|
||||
|
||||
@@ -1,76 +1,121 @@
|
||||
# Grotti
|
||||
<p align="center">
|
||||
<img src="docs/brand/readme-banner.png" alt="Grotti" width="720">
|
||||
</p>
|
||||
|
||||
Steuert die 3D-Drucker im Haus von einer Weboberfläche aus: Live-Zustand, Dateien senden, Warteschlange, Abläufe für den Kobra 3 (drucken, abkühlen, abschieben, prüfen), Material, Projekte und ein Protokoll, was wann passiert ist.
|
||||
<p align="center">
|
||||
<strong>Farm-Manager für Moonraker-Drucker</strong><br>
|
||||
Eine Oberfläche für die ganze Farm: Wand, Warteschlange, Material, Abläufe und Protokoll.<br>
|
||||
<em>Benannt nach der Mühle Grotti — sie mahlt ohne Pause, was man ihr aufträgt.</em>
|
||||
</p>
|
||||
|
||||
Der Name kommt von der Mühle Grotti: sie mahlt ohne Pause, was man ihr aufträgt.
|
||||
<p align="center">
|
||||
<a href="https://git.gundel.dev/dg/grotti"><img src="https://img.shields.io/badge/Version-3.5.0-e85d04?style=flat-square" alt="Version 3.5.0"></a>
|
||||
<img src="https://img.shields.io/badge/Python-3.11+-3776AB?style=flat-square&logo=python&logoColor=white" alt="Python 3.11+">
|
||||
<img src="https://img.shields.io/badge/PostgreSQL-14+-4169E1?style=flat-square&logo=postgresql&logoColor=white" alt="PostgreSQL">
|
||||
<img src="https://img.shields.io/badge/Moonraker-Klipper-2ecc71?style=flat-square" alt="Moonraker">
|
||||
<img src="https://img.shields.io/badge/UI-Deutsch-111827?style=flat-square" alt="UI Deutsch">
|
||||
<img src="https://img.shields.io/badge/Self--hosted-lokal-6b7280?style=flat-square" alt="Self-hosted">
|
||||
</p>
|
||||
|
||||
Die Drucker laufen mit Rinkhals (Kobra 3, Kobra S1). Grotti spricht sie über Moonraker an.
|
||||
<p align="center">
|
||||
<a href="#features">Features</a> ·
|
||||
<a href="#screenshots">Screenshots</a> ·
|
||||
<a href="#schnellstart">Schnellstart</a> ·
|
||||
<a href="#api">API</a> ·
|
||||
<a href="CHANGELOG.md">Changelog</a> ·
|
||||
<a href="docs/community/README.md">Community</a>
|
||||
</p>
|
||||
|
||||
Aktuelle Version: siehe `grotti/version.py`, Änderungen in [CHANGELOG.md](CHANGELOG.md).
|
||||
---
|
||||
|
||||
## Voraussetzungen
|
||||
<p align="center">
|
||||
<img src="docs/community/01-druckerwand.png" alt="Druckerwand" width="900">
|
||||
</p>
|
||||
|
||||
- Python 3.11
|
||||
- PostgreSQL (eine leere Datenbank `grotti` reicht, das Schema legt Grotti selbst an)
|
||||
- Drucker im selben Netz mit Moonraker auf Port 80
|
||||
## Features
|
||||
|
||||
## Einrichten
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **Druckerwand & Cockpit** | Live-Status, Temps, Pause/Cancel, Farm-ETA |
|
||||
| **Warteschlange** | Priorität, „nächster freier“, Material-Match, ACE-Map |
|
||||
| **Dateien & Projekte** | Slice-Watch, Senden, Umbenennen, Projektordner |
|
||||
| **Kobra-3-Ablauf** | Druck → Abkühlen → Schieben → Tasten |
|
||||
| **Material** | Spulen, Restgewicht, Bedarf, Chargen |
|
||||
| **Aufträge & Statistik** | Kunden, Lieferschein, Kosten (€ Material+Strom) |
|
||||
| **Betrieb** | Auth (Session/`grt_…`), Telegram/ntfy, PWA |
|
||||
|
||||
Stack: **Python · Flask · PostgreSQL · Moonraker** (Rinkhals auf Kobra 3 / S1).
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/community/06-jetzt.png" alt="Jetzt-Liste" width="440">
|
||||
|
||||
<img src="docs/community/02-warteschlange.png" alt="Warteschlange" width="440">
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/community/09-dateien.png" alt="Dateien" width="440">
|
||||
|
||||
<img src="docs/community/03-material.png" alt="Material" width="440">
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/community/05-statistik.png" alt="Statistik" width="440">
|
||||
|
||||
<img src="docs/community/07-cockpit.png" alt="Cockpit" width="440">
|
||||
</p>
|
||||
|
||||
Weitere Bilder und Post-Text: [`docs/community/`](docs/community/README.md).
|
||||
|
||||
## Schnellstart
|
||||
|
||||
**Voraussetzungen:** Python 3.11, PostgreSQL (leere DB `grotti`), Drucker mit Moonraker im LAN.
|
||||
|
||||
```powershell
|
||||
cd D:\Projekte\Grotti
|
||||
python -m venv .venv
|
||||
.\.venv\Scripts\pip install -r requirements.txt
|
||||
copy config.example.json config.json
|
||||
```
|
||||
|
||||
`config.json` anpassen:
|
||||
|
||||
| Schlüssel | Bedeutung |
|
||||
| --- | --- |
|
||||
| `host`, `port` | Wo die Oberfläche lauscht. Standard `127.0.0.1:8770`. |
|
||||
| `database_url` | PostgreSQL-Verbindung. |
|
||||
| `data_dir` | Uploads, Vorschaubilder, Cache. Standard `data`. |
|
||||
| `watch_dir` | Slice-Ordner (unter Einstellungen setzen). Leer = nur Upload. |
|
||||
| `token` | Wenn gesetzt, braucht die API `Authorization: Bearer <token>`. |
|
||||
| `fqdn` | Öffentliche Basis-URL für Spulen-Etiketten-QR. Leer = Request-Host. |
|
||||
| `energy` | Watt und Strompreis für die Kosten im Protokoll. |
|
||||
| `telegram`, `alerts` | Bot und welche Ereignisse als Nachricht kommen. |
|
||||
| `auto_queue` | Wenn `false`, startet die Warteschlange nichts Neues. |
|
||||
|
||||
Die Drucker stehen beim ersten Start in `drucker.json` und landen dann in der Datenbank. Danach werden sie unter Einstellungen gepflegt.
|
||||
|
||||
Schema-Änderungen laufen beim Start über `grotti/migrations/` (Tabelle `schema_version`). Vor Major-Updates die Datenbank sichern (`pg_dump`).
|
||||
|
||||
```json
|
||||
[{ "name": "Kobra 3 da26", "host": "10.10.1.60" }]
|
||||
```
|
||||
|
||||
Die Familie (Kobra 3 oder S1) liest Grotti aus dem Namen.
|
||||
|
||||
## Starten
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe server.py
|
||||
```
|
||||
|
||||
Dann <http://127.0.0.1:8770/> öffnen. Bedienungsanleitung mit Screenshots in der Oberfläche unter **Doku** (Kapitel `Anleitung-*`), Technik-Notizen darunter.
|
||||
Dann <http://127.0.0.1:8770/>. Bedienung unter **Doku** in der App (`docs/Anleitung-*.md`).
|
||||
|
||||
`config.json` und `data/` sind lokal und nicht im Git.
|
||||
### Raspberry Pi (`.deb`)
|
||||
|
||||
Auf dem Pi (arm64 / Bookworm) bauen und installieren — PostgreSQL und systemd-Dienst werden mit eingerichtet:
|
||||
|
||||
```bash
|
||||
bash packaging/build-deb.sh
|
||||
sudo apt install ./packaging/dist/grotti_*_arm64.deb
|
||||
journalctl -u grotti -f
|
||||
```
|
||||
|
||||
Details: [docs/Anleitung-Installation-Pi.md](docs/Anleitung-Installation-Pi.md). Öffentliches apt-Repo folgt später.
|
||||
|
||||
`config.json` und `data/` bleiben lokal (nicht im Git). Drucker zuerst in `drucker.json`, danach unter Einstellungen.
|
||||
|
||||
| Schlüssel | Bedeutung |
|
||||
| --- | --- |
|
||||
| `host`, `port` | Oberfläche (Standard `127.0.0.1:8770`) |
|
||||
| `database_url` | PostgreSQL |
|
||||
| `watch_dir` | Slice-Ordner (oder leer = nur Upload) |
|
||||
| `energy` / `telegram` / `alerts` | Kosten und Benachrichtigungen |
|
||||
|
||||
Schema-Migrationen laufen beim Start (`grotti/migrations/`). Vor Major-Updates: `pg_dump`.
|
||||
|
||||
## Ordner
|
||||
|
||||
| Pfad | Inhalt |
|
||||
| --- | --- |
|
||||
| `server.py` | Start, Datenbank vorbereiten, Hintergrund-Threads |
|
||||
| `grotti/` | Kern: Moonraker, Warteschlange (`print_queue.py`), Senden (`send_queue.py`), Abräumen (`eject.py`), Zustandswächter (`watcher.py`), Ereignisse und Telegram (`notify.py`), Datenbank (`store.py`, `schema.sql`) |
|
||||
| `web/` | Flask-App (`app.py`), Seiten (`templates/`), CSS und JS (`static/`) |
|
||||
| `docs/` | Bedienungsanleitung (`Anleitung-*.md`) unter `/doku`; Technik unter `docs/internal/` (nicht in der App) |
|
||||
| `web/static/doku/` | Screenshots für die Anleitung |
|
||||
| `CHANGELOG.md` | Änderungen je Version |
|
||||
| `server.py` | Start, DB, Hintergrund-Threads |
|
||||
| `grotti/` | Moonraker, Queue, Senden, Abräumen, Watcher, Store |
|
||||
| `web/` | Flask, Templates, CSS/JS, Branding |
|
||||
| `docs/` | Anleitungen (`/doku`), Community-Screens, Technik unter `internal/` |
|
||||
| `CHANGELOG.md` | Versionen |
|
||||
|
||||
## API
|
||||
|
||||
Alles unter `/api/v1`, JSON. Auth: Session-Cookie (UI) oder `Authorization: Bearer grt_…` (API-Token unter Einstellungen). Optional noch Shared-`token` in der Config (Migrationsbrücke).
|
||||
Alles unter `/api/v1`, JSON. Auth: Session oder `Authorization: Bearer grt_…`.
|
||||
|
||||
| Methode und Pfad | Zweck |
|
||||
| --- | --- |
|
||||
@@ -78,43 +123,33 @@ Alles unter `/api/v1`, JSON. Auth: Session-Cookie (UI) oder `Authorization: Bear
|
||||
| `GET /me`, `/users`, `/tokens` | Konto, Benutzer (Admin), API-Tokens |
|
||||
| `GET /version` | Version |
|
||||
| `GET`/`PATCH /einstellungen`, `POST /telegram/test` | Config und Telegram-Probe |
|
||||
| `GET /drucker?live=1` | Drucker mit Live-Zustand, laufendem und wartendem Auftrag |
|
||||
| `POST /drucker` · `PATCH`/`DELETE /drucker/<id>` | Drucker anlegen/ändern (Admin) |
|
||||
| `GET /drucker?live=1` | Drucker mit Live-Zustand |
|
||||
| `POST /drucker/<host>/<aktion>` | Pause, Fortsetzen, Abbrechen, Fehler löschen |
|
||||
| `POST /drucker/<host>/steuern` | Temperatur, Speed, Flow, Lüfter, ACE |
|
||||
| `POST /drucker/<host>/abraeumen` | Platte geräumt (`confirm`) |
|
||||
| `GET /kamera/<host>`, `/vorschau/<host>` | Snapshot / Job-Thumbnail |
|
||||
| `GET`/`POST /dateien`, `PATCH`/`DELETE /dateien/<id>` | Bibliothek, Upload, Metadaten |
|
||||
| `POST /dateien/neu-lesen`, `/dateien/ordner`, `/dateien/<id>/verschieben` | Watch-Sync, Ordner, Verschieben |
|
||||
| `POST /senden`, `GET /sende-auftraege` | Sofort senden (`confirm`), Fortschritt |
|
||||
| `GET`/`POST /druck-queue`, `PATCH /druck-queue/<id>` | Warteschlange (`confirm` beim Einreihen) |
|
||||
| `GET /laeuft`, `/aufgaben` | Direktstarts und Platte-belegt |
|
||||
| `POST /ablauf`, `/schieben-testen` | Ablauf Kobra 3 / Schieben-Test |
|
||||
| `GET /spulen`, `POST /spulen`, `POST /spulen/<id>/einlegen` | Spulen und Einlegen |
|
||||
| `GET /spulen/<id>/akte`, `/spulen/by-code` | Spulenakte, Scan `S-…` |
|
||||
| `GET`/`POST /materialien`, `/farben` | Sorten und Farbpalette |
|
||||
| `GET /laeufe`, `/laeufe/aktuell`, `/laeufe/<id>` | Protokoll und Druckbericht |
|
||||
| `GET /statistik`, `/statistik.csv` | Auslastung und CSV |
|
||||
| `GET`/`POST /projekte`, `/ideen` | Projekte und Ideen-Backlog |
|
||||
| `GET /ereignisse`, `POST /ereignisse/gelesen` | Aktivität und Alerts |
|
||||
| `POST /kalkulation` | Vorkalkulation Material+Strom |
|
||||
| `GET`/`POST /dateien`, `PATCH`/`DELETE /dateien/<id>` | Bibliothek |
|
||||
| `POST /senden`, `GET`/`POST /druck-queue` | Senden und Warteschlange (`confirm`) |
|
||||
| `GET /spulen`, `POST /spulen/<id>/einlegen` | Material |
|
||||
| `GET /laeufe`, `/statistik`, `/ereignisse` | Protokoll und Aktivität |
|
||||
|
||||
MCP: `python -m grotti.mcp_server` mit `GROTTI_URL` und `GROTTI_TOKEN` (siehe `docs/internal/MCP.md`).
|
||||
MCP: `python -m grotti.mcp_server` mit `GROTTI_URL` / `GROTTI_TOKEN` → `docs/internal/MCP.md`.
|
||||
|
||||
## Sicherheit
|
||||
|
||||
- Login Pflicht (Setup legt den ersten Admin an). Rollen: Admin, Operator.
|
||||
- Druckstarts brauchen `confirm: true`. Die Oberfläche fragt per Zwei-Klick.
|
||||
- Keine freie G-code-Konsole; Firmware-Neustart über Grotti ist gesperrt.
|
||||
- Für Internet: Reverse Proxy mit TLS; nicht den Entwickler-Server öffentlich freigeben.
|
||||
- Login Pflicht. Rollen: Admin, Operator.
|
||||
- Druckstarts brauchen `confirm: true` (Zwei-Klick in der UI).
|
||||
- Keine freie G-code-Konsole; kein `FIRMWARE_RESTART` über Grotti.
|
||||
- Nach außen: Reverse Proxy mit TLS.
|
||||
|
||||
## Versionen und Changelog
|
||||
## Versionen
|
||||
|
||||
- Versionierung nach [SemVer](https://semver.org/lang/de/): Patch für Fehlerbehebung, Minor für neue Funktion, Major für Bruch an API oder Datenbankschema.
|
||||
- Jede Änderung kommt in `CHANGELOG.md` unter `[Unreleased]` (Neu, Geändert, Behoben).
|
||||
- Beim Release: Version in `grotti/version.py` setzen, `[Unreleased]` in `[x.y.z] – Datum` umbenennen, Git-Tag `vX.Y.Z`.
|
||||
- Ändert sich `web/static/grotti.css` oder `api.js`, in `layout.html` den `?v=` hochzählen.
|
||||
- SemVer in `grotti/version.py`, Einträge in [CHANGELOG.md](CHANGELOG.md).
|
||||
- Release: `[Unreleased]` → `[x.y.z] – Datum`, Tag `vX.Y.Z`.
|
||||
- CSS/JS-Cache: `?v=` in `layout.html` mitziehen.
|
||||
|
||||
## Repo
|
||||
---
|
||||
|
||||
Eigenes Repository: <https://git.gundel.dev/dg/grotti>. Doku zum Tool gehört nach `docs/`.
|
||||
<p align="center">
|
||||
<img src="web/static/brand/logo.png" alt="" width="72"><br>
|
||||
<sub>Repo: <a href="https://git.gundel.dev/dg/grotti">git.gundel.dev/dg/grotti</a></sub>
|
||||
</p>
|
||||
@@ -7,6 +7,7 @@
|
||||
"token": "",
|
||||
"secret_key": "",
|
||||
"fqdn": "",
|
||||
"log_level": "INFO",
|
||||
"auto_queue": true,
|
||||
"auto_retry": true,
|
||||
"oauth": {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Installation auf dem Raspberry Pi
|
||||
|
||||
Stand **18.10.2026**. Phase 1: Installation per `.deb`-Datei. Ein öffentliches apt-Repository kommt später.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- Raspberry Pi OS **Bookworm** (64-Bit / arm64), Pi 4 oder 5 empfohlen
|
||||
- Netz zu den Druckern (Moonraker/Rinkhals)
|
||||
- ca. 1 GB freier Speicher für Paket + PostgreSQL
|
||||
|
||||
## Paket bauen (auf dem Pi oder arm64-Host)
|
||||
|
||||
Im geklonten Repo:
|
||||
|
||||
```bash
|
||||
sudo apt install -y python3-venv rsync dpkg-dev
|
||||
bash packaging/build-deb.sh
|
||||
```
|
||||
|
||||
Ergebnis: `packaging/dist/grotti_<version>_arm64.deb`
|
||||
|
||||
## Installieren
|
||||
|
||||
```bash
|
||||
sudo apt install ./packaging/dist/grotti_*_arm64.deb
|
||||
```
|
||||
|
||||
Das zieht **PostgreSQL** mit, legt Benutzer `grotti`, Datenbank `grotti` und den Dienst an.
|
||||
|
||||
- Oberfläche: `http://<pi-ip>:8770/`
|
||||
- Config: `/etc/grotti/config.json` (wird bei Updates nicht überschrieben)
|
||||
- Daten: `/var/lib/grotti/`
|
||||
- Slice-Ordner: `/var/lib/grotti/slice` (unter Einstellungen änderbar)
|
||||
|
||||
Beim ersten Aufruf Admin-Konto anlegen (wie gewohnt).
|
||||
|
||||
## Logging
|
||||
|
||||
Logs gehen nach **journald**:
|
||||
|
||||
```bash
|
||||
journalctl -u grotti -f
|
||||
journalctl -u grotti --since "1h ago"
|
||||
journalctl -u grotti -n 200
|
||||
```
|
||||
|
||||
Log-Level in `/etc/grotti/config.json`:
|
||||
|
||||
```json
|
||||
"log_level": "INFO"
|
||||
```
|
||||
|
||||
Werte: `DEBUG`, `INFO`, `WARNING`, `ERROR`. Danach:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart grotti
|
||||
```
|
||||
|
||||
## Dienst
|
||||
|
||||
```bash
|
||||
sudo systemctl status grotti
|
||||
sudo systemctl restart grotti
|
||||
sudo systemctl stop grotti
|
||||
```
|
||||
|
||||
## Update
|
||||
|
||||
Neue `.deb` bauen bzw. holen, dann erneut:
|
||||
|
||||
```bash
|
||||
sudo apt install ./packaging/dist/grotti_*_arm64.deb
|
||||
```
|
||||
|
||||
Config und `/var/lib/grotti` bleiben erhalten.
|
||||
|
||||
## Deinstallieren
|
||||
|
||||
```bash
|
||||
sudo apt remove grotti # Dienst weg, Daten bleiben
|
||||
sudo apt purge grotti # zusätzlich /etc/grotti
|
||||
# optional Datenbank:
|
||||
# sudo -u postgres dropdb grotti
|
||||
# sudo -u postgres dropuser grotti
|
||||
# sudo rm -rf /var/lib/grotti
|
||||
```
|
||||
|
||||
## Hinweise
|
||||
|
||||
- Standard lauscht auf `0.0.0.0:8770` (LAN). Für Internet: Reverse Proxy mit TLS.
|
||||
- Drucker unter **Einstellungen** anlegen (kein vorgefülltes `drucker.json` nötig).
|
||||
- Öffentliches `apt install grotti` ohne Datei: kommt mit dem Repo-Server (Phase 2).
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
@@ -1,6 +1,8 @@
|
||||
# Grotti — Community-Vorstellung
|
||||
|
||||
Screenshots aus dem laufenden Betrieb (v3.2.0, Okt. 2026). Zielgruppe: mittlere bis große Printfarmen.
|
||||
Screenshots aus dem laufenden Betrieb (v3.4.0, Okt. 2026). Zielgruppe: mittlere bis große Printfarmen.
|
||||
|
||||
Logo und Repo-Banner: [`docs/brand/`](../brand/). Haupt-README: [`../../README.md`](../../README.md).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -3,3 +3,8 @@
|
||||
Diese Dateien sind für Entwicklung und Agenten — **nicht** unter `/doku` in der App.
|
||||
|
||||
Nutzer-Handbuch: `docs/Anleitung-*.md`.
|
||||
|
||||
| Datei | Inhalt |
|
||||
| --- | --- |
|
||||
| [Makros-Kobra.md](Makros-Kobra.md) | G-code/Makros Kobra S1 und 3: Nicht-tun, Vanilla-Alltag, ACE, LeviQ/Wipe |
|
||||
| [Anycubic-Stack.md](Anycubic-Stack.md) | GoKlipper, MQTT-Start/Stop, ACE-Kanäle |
|
||||
+24
-4
@@ -1,6 +1,7 @@
|
||||
"""Host, Port, Datenordner und Datenbank kommen aus config.json."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
from pathlib import Path
|
||||
@@ -9,11 +10,24 @@ from grotti.errors import GrottiError
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
LOOPBACK = {"127.0.0.1", "::1", "localhost"}
|
||||
CONFIG_PATH = ROOT / "config.json"
|
||||
DEFAULT_WATCH_REL = "slice"
|
||||
_SETTINGS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def config_path():
|
||||
"""GROTTI_CONFIG, sonst /etc/grotti/config.json (Paket), sonst Repo-config.json."""
|
||||
env = (os.environ.get("GROTTI_CONFIG") or "").strip()
|
||||
if env:
|
||||
return Path(env)
|
||||
etc = Path("/etc/grotti/config.json")
|
||||
if etc.is_file():
|
||||
return etc
|
||||
return ROOT / "config.json"
|
||||
|
||||
|
||||
CONFIG_PATH = config_path()
|
||||
|
||||
|
||||
def _tariff(raw):
|
||||
tariff = raw.get("tariff") if isinstance(raw, dict) else None
|
||||
if not isinstance(tariff, dict) or not tariff:
|
||||
@@ -121,9 +135,11 @@ def _ensure_secret_key(path, raw):
|
||||
|
||||
|
||||
def load():
|
||||
path = CONFIG_PATH
|
||||
path = config_path()
|
||||
if not path.exists():
|
||||
path.write_text((ROOT / "config.example.json").read_text(encoding="utf-8"), encoding="utf-8")
|
||||
example = ROOT / "config.example.json"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(example.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
host = (raw.get("host") or "127.0.0.1").strip()
|
||||
token = raw.get("token") or ""
|
||||
@@ -139,6 +155,9 @@ def load():
|
||||
if not data_dir.is_absolute():
|
||||
data_dir = ROOT / data_dir
|
||||
watch_dir, _stored = parse_watch_dir(raw.get("watch_dir") or "", create=False, missing_ok=True)
|
||||
printers_file = Path(raw.get("printers_file") or (ROOT / "drucker.json"))
|
||||
if not printers_file.is_absolute():
|
||||
printers_file = ROOT / printers_file
|
||||
return {
|
||||
"host": host,
|
||||
"port": int(raw.get("port") or 8770),
|
||||
@@ -149,7 +168,8 @@ def load():
|
||||
"secret_key": secret_key,
|
||||
"oauth": _oauth(raw),
|
||||
"fqdn": normalize_fqdn(raw.get("fqdn") or ""),
|
||||
"printers_file": ROOT / "drucker.json",
|
||||
"printers_file": printers_file,
|
||||
"log_level": str(raw.get("log_level") or "INFO").strip() or "INFO",
|
||||
"energy": _energy(raw),
|
||||
"telegram": _telegram(raw),
|
||||
"ntfy": _ntfy(raw),
|
||||
|
||||
+5
-1
@@ -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)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Zentraler Logger für Dienst und journald (stdout)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
_CONFIGURED = False
|
||||
|
||||
LEVELS = {
|
||||
"critical": logging.CRITICAL,
|
||||
"error": logging.ERROR,
|
||||
"warning": logging.WARNING,
|
||||
"warn": logging.WARNING,
|
||||
"info": logging.INFO,
|
||||
"debug": logging.DEBUG,
|
||||
}
|
||||
|
||||
|
||||
def parse_level(value, default=logging.INFO):
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
text = str(value or "").strip().lower()
|
||||
return LEVELS.get(text, default)
|
||||
|
||||
|
||||
def setup(level="INFO"):
|
||||
"""Root-Logger einmalig auf stdout (systemd → journald)."""
|
||||
global _CONFIGURED
|
||||
resolved = parse_level(level)
|
||||
root = logging.getLogger()
|
||||
if _CONFIGURED:
|
||||
root.setLevel(resolved)
|
||||
return root
|
||||
root.handlers.clear()
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(
|
||||
logging.Formatter(
|
||||
"%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
)
|
||||
root.addHandler(handler)
|
||||
root.setLevel(resolved)
|
||||
logging.getLogger("waitress").setLevel(logging.WARNING)
|
||||
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
||||
_CONFIGURED = True
|
||||
return root
|
||||
|
||||
|
||||
def get(name="grotti"):
|
||||
return logging.getLogger(name)
|
||||
+4
-1
@@ -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",
|
||||
|
||||
+72
-14
@@ -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)
|
||||
|
||||
+21
-11
@@ -2813,6 +2813,20 @@ def _file_needs(conn, file_id):
|
||||
return types, float(row.get("weight_g") or 0)
|
||||
|
||||
|
||||
def _prefer_color(spools, need_color):
|
||||
"""Farbe soft: Slice-Hex ignorieren; benannte Farbe bevorzugen, nie blockieren."""
|
||||
from grotti.bedarf import _is_slicer_hex
|
||||
|
||||
need_c = (need_color or "").strip().lower()
|
||||
if not need_c or _is_slicer_hex(need_c) or not spools:
|
||||
return list(spools)
|
||||
preferred = [
|
||||
h for h in spools
|
||||
if not (h.get("color") or "").strip() or (h.get("color") or "").strip().lower() == need_c
|
||||
]
|
||||
return preferred or list(spools)
|
||||
|
||||
|
||||
def _match_spools_for_need(need, have, used_ids=None):
|
||||
used = used_ids or set()
|
||||
candidates = [h for h in have if h.get("spool_id") not in used]
|
||||
@@ -2822,29 +2836,25 @@ def _match_spools_for_need(need, have, used_ids=None):
|
||||
kind = (kind or "").strip().lower()
|
||||
return [
|
||||
h for h in candidates
|
||||
if (not kind or h["material_type"] == kind)
|
||||
and (not need.get("color") or h["color"] == need["color"] or not h["color"])
|
||||
if not kind or h["material_type"] == kind
|
||||
]
|
||||
|
||||
if profile:
|
||||
by_profile = [h for h in candidates if _profile_matches(profile, h.get("material_name"))]
|
||||
if by_profile:
|
||||
colored = [
|
||||
h for h in by_profile
|
||||
if not need.get("color") or h["color"] == need["color"] or not h["color"]
|
||||
]
|
||||
return colored or by_profile
|
||||
# Basis-Profil ohne Meta/Matte/Silk: Marke egal — Typ reicht (Sunlu PLA ↔ Eryone PLA).
|
||||
# Spulen mit Variante im Namen bleiben draußen (kein Meta für „nur PLA“).
|
||||
return _prefer_color(by_profile, need.get("color"))
|
||||
# Basis-Profil ohne Meta/Matte/Silk: Marke egal — Typ reicht
|
||||
# (Sunlu PLA ↔ Eryone PLA Lite; Slice-Hex #26A69A blockiert nicht „Grau“).
|
||||
if not _profile_has_variant(profile):
|
||||
kind = _profile_base_type(profile) or (need.get("material_type") or "")
|
||||
if kind:
|
||||
return [
|
||||
typed = [
|
||||
h for h in _by_type(kind)
|
||||
if not _profile_has_variant(h.get("material_name") or "")
|
||||
]
|
||||
return _prefer_color(typed, need.get("color"))
|
||||
return []
|
||||
return _by_type(need.get("material_type") or "")
|
||||
return _prefer_color(_by_type(need.get("material_type") or ""), need.get("color"))
|
||||
|
||||
|
||||
def material_fit_score(conn, host, file_id):
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
|
||||
|
||||
__version__ = "3.4.0"
|
||||
__version__ = "3.5.0"
|
||||
+10
-8
@@ -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)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
# Baut grotti_<version>_<arch>.deb (auf dem Zielsystem oder arm64-Container).
|
||||
# Aufruf aus dem Repo-Root: bash packaging/build-deb.sh
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
if ! command -v dpkg-deb >/dev/null 2>&1; then
|
||||
echo "dpkg-deb fehlt — bitte auf Debian/Raspberry Pi OS bauen." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="$(python3 -c "from grotti.version import __version__; print(__version__)")"
|
||||
ARCH="$(dpkg --print-architecture)"
|
||||
PKG="grotti_${VERSION}_${ARCH}"
|
||||
DIST="$ROOT/packaging/dist"
|
||||
STAGE="$DIST/$PKG"
|
||||
|
||||
rm -rf "$STAGE"
|
||||
mkdir -p "$STAGE/DEBIAN" \
|
||||
"$STAGE/usr/lib/grotti" \
|
||||
"$STAGE/usr/bin" \
|
||||
"$STAGE/etc/grotti" \
|
||||
"$STAGE/lib/systemd/system" \
|
||||
"$STAGE/usr/share/grotti" \
|
||||
"$STAGE/usr/share/doc/grotti" \
|
||||
"$STAGE/var/lib/grotti/data/eingang" \
|
||||
"$STAGE/var/lib/grotti/slice"
|
||||
|
||||
rsync -a --delete \
|
||||
--exclude '__pycache__' --exclude '*.pyc' --exclude '.pytest_cache' \
|
||||
"$ROOT/grotti/" "$STAGE/usr/lib/grotti/grotti/"
|
||||
rsync -a --delete \
|
||||
--exclude '__pycache__' --exclude '*.pyc' \
|
||||
"$ROOT/web/" "$STAGE/usr/lib/grotti/web/"
|
||||
cp "$ROOT/server.py" "$STAGE/usr/lib/grotti/server.py"
|
||||
cp "$ROOT/requirements.txt" "$STAGE/usr/lib/grotti/requirements.txt"
|
||||
cp "$ROOT/config.example.json" "$STAGE/usr/lib/grotti/config.example.json"
|
||||
echo '[]' > "$STAGE/usr/lib/grotti/drucker.json"
|
||||
cp "$ROOT/packaging/config.pi.json" "$STAGE/usr/share/grotti/config.pi.json"
|
||||
cp "$ROOT/packaging/config.pi.json" "$STAGE/etc/grotti/config.json"
|
||||
cp "$ROOT/packaging/grotti.service" "$STAGE/lib/systemd/system/grotti.service"
|
||||
cp "$ROOT/docs/Anleitung-Installation-Pi.md" "$STAGE/usr/share/doc/grotti/"
|
||||
cp "$ROOT/CHANGELOG.md" "$STAGE/usr/share/doc/grotti/changelog"
|
||||
gzip -9 -f "$STAGE/usr/share/doc/grotti/changelog"
|
||||
|
||||
cat > "$STAGE/usr/bin/grotti" <<'EOF'
|
||||
#!/bin/sh
|
||||
export GROTTI_CONFIG="${GROTTI_CONFIG:-/etc/grotti/config.json}"
|
||||
exec /usr/lib/grotti/venv/bin/python /usr/lib/grotti/server.py "$@"
|
||||
EOF
|
||||
chmod 755 "$STAGE/usr/bin/grotti"
|
||||
|
||||
python3 -m venv "$STAGE/usr/lib/grotti/venv"
|
||||
"$STAGE/usr/lib/grotti/venv/bin/pip" install --upgrade pip wheel
|
||||
"$STAGE/usr/lib/grotti/venv/bin/pip" install -r "$ROOT/requirements.txt"
|
||||
|
||||
SIZE_KB="$(du -sk "$STAGE" | awk '{print $1}')"
|
||||
cat > "$STAGE/DEBIAN/control" <<EOF
|
||||
Package: grotti
|
||||
Version: ${VERSION}
|
||||
Section: web
|
||||
Priority: optional
|
||||
Architecture: ${ARCH}
|
||||
Maintainer: Dominic Gundel <[email protected]>
|
||||
Depends: python3 (>= 3.11), python3-venv, postgresql, adduser
|
||||
Installed-Size: ${SIZE_KB}
|
||||
Homepage: https://git.gundel.dev/dg/grotti
|
||||
Description: Farm-Manager für Moonraker-Drucker (Kobra 3 / S1)
|
||||
Grotti steuert eine 3D-Drucker-Farm über Moonraker: Wand, Warteschlange,
|
||||
Material, Abläufe und Protokoll. PostgreSQL wird lokal mit eingerichtet.
|
||||
EOF
|
||||
|
||||
cp "$ROOT/packaging/debian/conffiles" "$STAGE/DEBIAN/conffiles"
|
||||
for script in postinst prerm postrm; do
|
||||
cp "$ROOT/packaging/debian/$script" "$STAGE/DEBIAN/$script"
|
||||
chmod 755 "$STAGE/DEBIAN/$script"
|
||||
done
|
||||
|
||||
dpkg-deb --root-owner-group --build "$STAGE" "$DIST/${PKG}.deb"
|
||||
echo "Gebaut: $DIST/${PKG}.deb"
|
||||
echo "Install: sudo apt install ./packaging/dist/${PKG}.deb"
|
||||
echo "Logs: journalctl -u grotti -f"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/etc/grotti/config.json
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Vendored
Whitespace-only changes.
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -59,6 +59,35 @@ class MatchSpoolsTests(unittest.TestCase):
|
||||
ids = {m["spool_id"] for m in matched}
|
||||
self.assertEqual(ids, {3}) # Eryone PLA; Meta/Matte nicht
|
||||
|
||||
def test_pla_lite_hex_matches_plain_pla(self):
|
||||
# Orca: Profil „Eryone PLA Lite“, Farbe #26A69A — Lager: Eryone/Sunlu PLA „Grau“
|
||||
need = {
|
||||
"profile": "Eryone PLA Lite",
|
||||
"material_type": "pla",
|
||||
"color": "#26A69A",
|
||||
"paint_index": 0,
|
||||
}
|
||||
have = [
|
||||
{
|
||||
"spool_id": 15,
|
||||
"material_type": "pla",
|
||||
"color": "grau",
|
||||
"remaining_g": 873,
|
||||
"material_name": "sunlu pla",
|
||||
"ace_slot": None,
|
||||
},
|
||||
{
|
||||
"spool_id": 1,
|
||||
"material_type": "pla",
|
||||
"color": "grau",
|
||||
"remaining_g": 800,
|
||||
"material_name": "sunlu pla meta",
|
||||
"ace_slot": 1,
|
||||
},
|
||||
]
|
||||
matched = store._match_spools_for_need(need, have)
|
||||
self.assertEqual({m["spool_id"] for m in matched}, {15})
|
||||
|
||||
def test_picks_meta_not_matte(self):
|
||||
need = {"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "", "paint_index": 0}
|
||||
matched = store._match_spools_for_need(need, self.have)
|
||||
|
||||
+12
-4
@@ -19,7 +19,7 @@ from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from grotti import auth as grotti_auth
|
||||
from grotti import config as grotti_config
|
||||
from grotti import db as grotti_db
|
||||
from grotti import eject, jobs, library, moonraker, notify, store
|
||||
from grotti import eject, jobs, library, logutil, moonraker, notify, store
|
||||
from grotti.catalog import color_catalog
|
||||
from grotti.cost import calculate, offer_from_file, order_actuals
|
||||
from grotti import orders as grotti_orders
|
||||
@@ -32,6 +32,8 @@ from grotti.version import __version__
|
||||
from grotti import verdict as grotti_verdict
|
||||
from grotti.watcher import StateWatcher
|
||||
|
||||
log = logutil.get("grotti.web")
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
TOOL_ROOT = ROOT.parent
|
||||
DOCS_DIR = TOOL_ROOT / "docs"
|
||||
@@ -316,7 +318,7 @@ def create_app(cfg):
|
||||
message = "Diesen Eintrag gibt es schon."
|
||||
elif isinstance(err, psycopg.errors.InvalidTextRepresentation):
|
||||
message = "Ungültiger Wert in der Anfrage."
|
||||
print("DB: %s" % err, flush=True)
|
||||
log.warning("DB: %s", err)
|
||||
return jsonify({"error": message, "code": "db"}), 500
|
||||
|
||||
def body():
|
||||
@@ -582,6 +584,7 @@ def create_app(cfg):
|
||||
|
||||
ANLEITUNG_ORDER = (
|
||||
"Anleitung-Einrichtung",
|
||||
"Anleitung-Installation-Pi",
|
||||
"Anleitung-Oberflaeche",
|
||||
"Anleitung-Wand",
|
||||
"Anleitung-Dateien",
|
||||
@@ -605,6 +608,7 @@ def create_app(cfg):
|
||||
def anleitung_title(name):
|
||||
labels = {
|
||||
"Anleitung-Einrichtung": "Einrichtung",
|
||||
"Anleitung-Installation-Pi": "Raspberry Pi (.deb)",
|
||||
"Anleitung-Oberflaeche": "Oberfläche",
|
||||
"Anleitung-Wand": "Druckerwand",
|
||||
"Anleitung-Dateien": "Dateien senden",
|
||||
@@ -1698,7 +1702,7 @@ def create_app(cfg):
|
||||
try:
|
||||
jobs.apply_deduction(c, closed, cfg["energy"])
|
||||
except Exception as exc:
|
||||
print("Abräumen Abzug %s: %s" % (host, exc), flush=True)
|
||||
log.warning("Abräumen Abzug %s: %s", host, exc)
|
||||
row = store.get_run_row(c, closed["id"]) or closed
|
||||
run_json = store.run_out(row)
|
||||
reprint = None
|
||||
@@ -2451,7 +2455,11 @@ def create_app(cfg):
|
||||
|
||||
@app.get("/api/v1/laeufe")
|
||||
def run_list():
|
||||
return jsonify(store.list_runs(conn()))
|
||||
try:
|
||||
limit = min(1000, max(1, int(request.args.get("limit") or 500)))
|
||||
except (TypeError, ValueError):
|
||||
limit = 500
|
||||
return jsonify(store.list_runs(conn(), limit=limit))
|
||||
|
||||
@app.get("/api/v1/laeufe/aktuell")
|
||||
def run_aktuell():
|
||||
|
||||
+30
-8
@@ -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; }
|
||||
|
||||
@@ -333,3 +333,47 @@ window.setConnectionBanner = setConnectionBanner;
|
||||
window.createPoller = createPoller;
|
||||
window.promptAbraeumen = promptAbraeumen;
|
||||
window.VERDICT_REASON_LABELS = VERDICT_REASON_LABELS;
|
||||
|
||||
/** Listen-Pagination (clientseitig auf gefiltertem Array). */
|
||||
const DEFAULT_PAGE_SIZE = 40;
|
||||
|
||||
function pageSlice(items, page, pageSize) {
|
||||
const list = Array.isArray(items) ? items : [];
|
||||
const size = Math.max(1, Number(pageSize) || DEFAULT_PAGE_SIZE);
|
||||
const total = list.length;
|
||||
const pages = Math.max(1, Math.ceil(total / size) || 1);
|
||||
let p = Math.max(1, parseInt(page, 10) || 1);
|
||||
if (p > pages) p = pages;
|
||||
const start = (p - 1) * size;
|
||||
return {
|
||||
items: list.slice(start, start + size),
|
||||
page: p,
|
||||
pages,
|
||||
total,
|
||||
pageSize: size,
|
||||
from: total ? start + 1 : 0,
|
||||
to: Math.min(start + size, total),
|
||||
};
|
||||
}
|
||||
|
||||
function renderPager(el, state, onPage) {
|
||||
if (!el) return;
|
||||
const s = state || pageSlice([], 1);
|
||||
if (s.total <= s.pageSize) {
|
||||
el.hidden = true;
|
||||
el.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
el.hidden = false;
|
||||
el.className = "pager";
|
||||
el.innerHTML =
|
||||
'<button type="button" class="ghost pager-prev" ' + (s.page <= 1 ? "disabled" : "") + ">Zurück</button>" +
|
||||
'<span class="pager-info">' + s.from + "–" + s.to + " von " + s.total + "</span>" +
|
||||
'<button type="button" class="ghost pager-next" ' + (s.page >= s.pages ? "disabled" : "") + ">Weiter</button>";
|
||||
el.querySelector(".pager-prev").onclick = () => onPage(s.page - 1);
|
||||
el.querySelector(".pager-next").onclick = () => onPage(s.page + 1);
|
||||
}
|
||||
|
||||
window.DEFAULT_PAGE_SIZE = DEFAULT_PAGE_SIZE;
|
||||
window.pageSlice = pageSlice;
|
||||
window.renderPager = renderPager;
|
||||
@@ -26,9 +26,12 @@
|
||||
<div class="skeleton" style="height:4rem"></div>
|
||||
<div class="skeleton" style="height:4rem"></div>
|
||||
</div>
|
||||
<div class="pager" id="tl-pager" hidden></div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
let tlPage = 1;
|
||||
let tlItems = [];
|
||||
const KINDS = [
|
||||
["queue", "Warteschlange"],
|
||||
["ablauf", "Ablauf"],
|
||||
@@ -111,14 +114,19 @@ function itemHtml(ev) {
|
||||
function render(items) {
|
||||
const box = document.getElementById("timeline");
|
||||
const note = document.getElementById("tl-note");
|
||||
if (!items.length) {
|
||||
const pager = document.getElementById("tl-pager");
|
||||
tlItems = items || [];
|
||||
if (!tlItems.length) {
|
||||
box.innerHTML = `<div class="card empty-state">${icon("activity")}<p>Keine Einträge${filter.range === "day" ? " heute" : ""}.</p>${
|
||||
filter.range !== "all" ? `<button type="button" class="ghost" data-range-jump="all">Alle anzeigen</button>` : ""}</div>`;
|
||||
note.textContent = "";
|
||||
renderPager(pager, null, () => {});
|
||||
return;
|
||||
}
|
||||
const paged = pageSlice(tlItems, tlPage, DEFAULT_PAGE_SIZE);
|
||||
tlPage = paged.page;
|
||||
const groups = [];
|
||||
for (const ev of items) {
|
||||
for (const ev of paged.items) {
|
||||
const label = dayLabel(ev.created_at);
|
||||
if (!groups.length || groups[groups.length - 1].label !== label) groups.push({ label, items: [] });
|
||||
groups[groups.length - 1].items.push(ev);
|
||||
@@ -126,7 +134,11 @@ function render(items) {
|
||||
box.innerHTML = groups.map((g) =>
|
||||
`<section class="tl-day"><h3>${escapeHtml(g.label)} · ${g.items.length}</h3><ol class="tl-list">${g.items.map(itemHtml).join("")}</ol></section>`
|
||||
).join("");
|
||||
note.textContent = items.length >= 300 ? "Die letzten 300 Einträge. Zeitraum oder Filter enger stellen für ältere." : "";
|
||||
note.textContent = tlItems.length >= 300 ? "Die letzten 300 Einträge. Zeitraum oder Filter enger stellen für ältere." : "";
|
||||
renderPager(pager, paged, (p) => {
|
||||
tlPage = p;
|
||||
render(tlItems);
|
||||
});
|
||||
}
|
||||
|
||||
async function load() {
|
||||
@@ -137,6 +149,7 @@ async function load() {
|
||||
if (filter.host) q.set("host", filter.host);
|
||||
try {
|
||||
const data = await api("/ereignisse?" + q.toString());
|
||||
tlPage = 1;
|
||||
render(data.items || []);
|
||||
} catch (err) {
|
||||
document.getElementById("timeline").innerHTML = `<div class="strip bad">${icon("alert")}<span>${escapeHtml(err.message)}</span></div>`;
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<thead><tr><th>Titel</th><th>Status</th><th>Fortschritt</th></tr></thead>
|
||||
<tbody id="order-rows"></tbody>
|
||||
</table>
|
||||
<div class="pager" id="order-pager" hidden></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card">
|
||||
@@ -246,16 +247,22 @@ async function einreihen(itemId) {
|
||||
}
|
||||
}
|
||||
|
||||
let orderPage = 1;
|
||||
|
||||
function renderList() {
|
||||
const body = document.getElementById("order-rows");
|
||||
body.innerHTML = "";
|
||||
const pager = document.getElementById("order-pager");
|
||||
if (!orders.length) {
|
||||
body.innerHTML = '<tr><td colspan="3" class="empty"><div class="empty-state"><p>Noch kein Auftrag.</p><p><button type="button" class="ghost" id="order-empty-new">Ersten Auftrag anlegen →</button></p></div></td></tr>';
|
||||
document.getElementById("order-empty-new")?.addEventListener("click", () => fillOrder(null));
|
||||
renderPager(pager, null, () => {});
|
||||
return;
|
||||
}
|
||||
const selected = document.getElementById("order-id").value;
|
||||
orders.forEach((order) => {
|
||||
const paged = pageSlice(orders, orderPage, DEFAULT_PAGE_SIZE);
|
||||
orderPage = paged.page;
|
||||
paged.items.forEach((order) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "pick" + (selected && String(order.id) === selected ? " on" : "");
|
||||
tr.innerHTML = "<td></td><td></td><td></td>";
|
||||
@@ -275,12 +282,17 @@ function renderList() {
|
||||
});
|
||||
body.appendChild(tr);
|
||||
});
|
||||
renderPager(pager, paged, (p) => {
|
||||
orderPage = p;
|
||||
renderList();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
const st = document.getElementById("filter-status").value;
|
||||
const q = st ? "?status=" + encodeURIComponent(st) : "";
|
||||
orders = await api("/auftraege" + q);
|
||||
orderPage = 1;
|
||||
renderList();
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
<div class="skeleton" style="height:4rem"></div>
|
||||
<div class="skeleton" style="height:4rem"></div>
|
||||
</div>
|
||||
<div class="pager" id="lib-pager" hidden></div>
|
||||
<p class="drop-hint">Dateien hierher ziehen zum Hochladen.</p>
|
||||
</section>
|
||||
|
||||
@@ -839,6 +840,39 @@ function infoPopupHtml(file) {
|
||||
</details>`;
|
||||
}
|
||||
|
||||
function placeLibInfoPop(details) {
|
||||
const pop = details.querySelector(".lib-info-pop");
|
||||
const anchor = details.querySelector("summary");
|
||||
if (!pop || !anchor) return;
|
||||
const margin = 6;
|
||||
const r = anchor.getBoundingClientRect();
|
||||
pop.style.left = "0px";
|
||||
pop.style.top = "0px";
|
||||
const pw = pop.offsetWidth;
|
||||
const ph = pop.offsetHeight;
|
||||
let left = r.right - pw;
|
||||
left = Math.max(margin, Math.min(left, window.innerWidth - pw - margin));
|
||||
let top = r.bottom + margin;
|
||||
if (top + ph > window.innerHeight - margin && r.top - margin - ph >= margin) {
|
||||
top = r.top - margin - ph;
|
||||
} else {
|
||||
top = Math.min(top, Math.max(margin, window.innerHeight - ph - margin));
|
||||
}
|
||||
pop.style.left = left + "px";
|
||||
pop.style.top = top + "px";
|
||||
}
|
||||
|
||||
function closeOpenLibInfo(except) {
|
||||
document.querySelectorAll("details.lib-info[open]").forEach((node) => {
|
||||
if (node !== except) node.removeAttribute("open");
|
||||
});
|
||||
}
|
||||
|
||||
function repositionOpenLibInfo() {
|
||||
const open = document.querySelector("details.lib-info[open]");
|
||||
if (open) placeLibInfoPop(open);
|
||||
}
|
||||
|
||||
function thumbHtml(file) {
|
||||
return file.preview
|
||||
? `<img alt="" loading="lazy" src="${escapeHtml(withToken(file.preview))}" onerror="this.style.visibility='hidden'">`
|
||||
@@ -856,6 +890,8 @@ function libItemHtml(file) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
let libPage = 1;
|
||||
|
||||
function renderLibrary() {
|
||||
const list = document.getElementById("lib-list");
|
||||
const shown = filteredFiles();
|
||||
@@ -863,15 +899,19 @@ function renderLibrary() {
|
||||
document.getElementById("lib-count").textContent = shown.length === files.length ? String(files.length) : shown.length + "/" + files.length;
|
||||
if (!files.length) {
|
||||
list.innerHTML = '<div class="picked-empty">' + icon("file") + '<span>Noch keine Datei.</span><span class="tiny">Hochladen oder Slice-Ordner in den Einstellungen setzen.</span></div>';
|
||||
renderPager(document.getElementById("lib-pager"), null, () => {});
|
||||
return;
|
||||
}
|
||||
if (!shown.length) {
|
||||
list.innerHTML = '<div class="picked-empty">' + icon("search") + '<span>Keine Datei passt.</span></div>';
|
||||
renderPager(document.getElementById("lib-pager"), null, () => {});
|
||||
return;
|
||||
}
|
||||
const paged = pageSlice(shown, libPage, DEFAULT_PAGE_SIZE);
|
||||
libPage = paged.page;
|
||||
const parts = [];
|
||||
let lastGroup = null;
|
||||
shown.forEach((file) => {
|
||||
paged.items.forEach((file) => {
|
||||
let group = null;
|
||||
if (sort === "material") group = materialKey(file) || "Ohne Material";
|
||||
else if (sort === "folder") group = folderLabel(folderKey(file));
|
||||
@@ -882,6 +922,10 @@ function renderLibrary() {
|
||||
parts.push(libItemHtml(file));
|
||||
});
|
||||
list.innerHTML = parts.join("");
|
||||
renderPager(document.getElementById("lib-pager"), paged, (p) => {
|
||||
libPage = p;
|
||||
renderLibrary();
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- Flow rechts ---------- */
|
||||
@@ -1546,6 +1590,23 @@ async function doAblauf() {
|
||||
|
||||
/* ---------- Ereignisse ---------- */
|
||||
|
||||
document.getElementById("lib-list").addEventListener("toggle", (event) => {
|
||||
const details = event.target;
|
||||
if (!(details instanceof HTMLDetailsElement) || !details.classList.contains("lib-info")) return;
|
||||
if (details.open) {
|
||||
closeOpenLibInfo(details);
|
||||
placeLibInfoPop(details);
|
||||
}
|
||||
}, true);
|
||||
|
||||
document.getElementById("lib-list").addEventListener("scroll", repositionOpenLibInfo, { passive: true });
|
||||
window.addEventListener("resize", repositionOpenLibInfo);
|
||||
|
||||
document.addEventListener("pointerdown", (event) => {
|
||||
if (event.target.closest("details.lib-info")) return;
|
||||
closeOpenLibInfo();
|
||||
});
|
||||
|
||||
document.getElementById("lib-list").addEventListener("click", (event) => {
|
||||
if (event.target.closest(".lib-move")) {
|
||||
event.stopPropagation();
|
||||
@@ -1628,6 +1689,7 @@ document.getElementById("lib-list").addEventListener("keydown", (event) => {
|
||||
const node = document.getElementById(id);
|
||||
if (!node) return;
|
||||
const onChange = () => {
|
||||
libPage = 1;
|
||||
saveLibMemory();
|
||||
renderLibrary();
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}">
|
||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=68">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=70">
|
||||
<link rel="manifest" href="{{ url_for('static', filename='manifest.webmanifest') }}">
|
||||
<meta name="theme-color" content="#0a0a0c" id="theme-color-meta">
|
||||
<script>
|
||||
@@ -161,7 +161,7 @@
|
||||
<form method="dialog"><button class="ghost" value="cancel">Schließen</button></form>
|
||||
</dialog>
|
||||
<script src="{{ url_for('static', filename='api.js') }}?v=32"></script>
|
||||
<script src="{{ url_for('static', filename='ui.js') }}?v=5"></script>
|
||||
<script src="{{ url_for('static', filename='ui.js') }}?v=6"></script>
|
||||
<script>
|
||||
async function doLogout() {
|
||||
try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {}
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
<thead><tr><th>Spule</th><th>Charge</th><th>Sorte</th><th>Farbe</th><th>Ort</th><th class="num">Rest</th></tr></thead>
|
||||
<tbody id="spool-rows"></tbody>
|
||||
</table>
|
||||
<div class="pager" id="spool-pager" hidden></div>
|
||||
</section>
|
||||
|
||||
<section class="card mat-form" id="spool-panel" hidden>
|
||||
@@ -683,6 +684,8 @@ function fillSelects() {
|
||||
materialSelect.value = currentMaterial;
|
||||
}
|
||||
|
||||
let spoolPage = 1;
|
||||
|
||||
function renderSpools() {
|
||||
document.querySelectorAll("#spool-filter .fchip").forEach((btn) => {
|
||||
btn.classList.toggle("on", (btn.dataset.filter || "all") === spoolFilter);
|
||||
@@ -698,14 +701,17 @@ function renderSpools() {
|
||||
spoolFilter === "archive" ? shown.length : activeCount
|
||||
);
|
||||
const note = document.getElementById("spool-list-note");
|
||||
const pager = document.getElementById("spool-pager");
|
||||
if (!spools.length) {
|
||||
note.textContent = "";
|
||||
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("spool") + '<p>Noch keine Spule.</p><p class="tiny">„Neue Spule“ anlegen oder Code scannen.</p></div></td></tr>';
|
||||
renderPager(pager, null, () => {});
|
||||
return;
|
||||
}
|
||||
if (!shown.length) {
|
||||
note.textContent = "";
|
||||
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("search") + '<p>Keine Spule passt.</p></div></td></tr>';
|
||||
renderPager(pager, null, () => {});
|
||||
return;
|
||||
}
|
||||
note.textContent = spoolFilter === "archive"
|
||||
@@ -719,8 +725,10 @@ function renderSpools() {
|
||||
const bh = b.printer_host || "zzz";
|
||||
return ah.localeCompare(bh, "de") || (Number(a.ace_slot) || 0) - (Number(b.ace_slot) || 0) || a.name.localeCompare(b.name, "de");
|
||||
});
|
||||
const paged = pageSlice(ordered, spoolPage, DEFAULT_PAGE_SIZE);
|
||||
spoolPage = paged.page;
|
||||
let lastPlace = null;
|
||||
ordered.forEach((spool) => {
|
||||
paged.items.forEach((spool) => {
|
||||
const group = spool.printer_host ? printerName(spool.printer_host) : "Regal";
|
||||
if (group !== lastPlace) {
|
||||
lastPlace = group;
|
||||
@@ -753,6 +761,10 @@ function renderSpools() {
|
||||
tr.addEventListener("click", () => fill(spool));
|
||||
body.appendChild(tr);
|
||||
});
|
||||
renderPager(pager, paged, (p) => {
|
||||
spoolPage = p;
|
||||
renderSpools();
|
||||
});
|
||||
}
|
||||
|
||||
function renderSorten() {
|
||||
@@ -867,6 +879,7 @@ document.querySelectorAll(".mat-tabs [data-view]").forEach((btn) => {
|
||||
});
|
||||
|
||||
document.getElementById("spool-filter").addEventListener("click", (event) => {
|
||||
spoolPage = 1;
|
||||
const btn = event.target.closest("[data-filter]");
|
||||
if (!btn) return;
|
||||
spoolFilter = btn.dataset.filter || "all";
|
||||
@@ -874,6 +887,7 @@ document.getElementById("spool-filter").addEventListener("click", (event) => {
|
||||
});
|
||||
document.getElementById("spool-search").addEventListener("input", (event) => {
|
||||
spoolSearch = event.target.value || "";
|
||||
spoolPage = 1;
|
||||
renderSpools();
|
||||
});
|
||||
document.getElementById("sorten-search").addEventListener("input", (event) => {
|
||||
@@ -996,10 +1010,12 @@ document.getElementById("spool-delete").addEventListener("click", () => {
|
||||
});
|
||||
});
|
||||
document.getElementById("spool-filter-type")?.addEventListener("change", (event) => {
|
||||
spoolPage = 1;
|
||||
spoolTypeFilter = event.target.value || "";
|
||||
renderSpools();
|
||||
});
|
||||
document.getElementById("spool-filter-color")?.addEventListener("change", (event) => {
|
||||
spoolPage = 1;
|
||||
spoolColorFilter = event.target.value || "";
|
||||
renderSpools();
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<thead><tr><th>Name</th><th>Status</th><th>Ordner</th><th class="num">Dateien</th></tr></thead>
|
||||
<tbody id="proj-rows"></tbody>
|
||||
</table>
|
||||
<div class="pager" id="proj-pager" hidden></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -110,15 +111,21 @@ function fill(project) {
|
||||
renderDetail(project);
|
||||
}
|
||||
|
||||
let projPage = 1;
|
||||
|
||||
function renderList() {
|
||||
const body = document.getElementById("proj-rows");
|
||||
const pager = document.getElementById("proj-pager");
|
||||
document.getElementById("proj-count").textContent = String(projects.length);
|
||||
if (!projects.length) {
|
||||
body.innerHTML = '<tr><td class="empty" colspan="4"><div class="empty-state">' + icon("folder") + "<p>Noch kein Projekt.</p></div></td></tr>";
|
||||
renderPager(pager, null, () => {});
|
||||
return;
|
||||
}
|
||||
body.innerHTML = "";
|
||||
projects.forEach((project) => {
|
||||
const paged = pageSlice(projects, projPage, DEFAULT_PAGE_SIZE);
|
||||
projPage = paged.page;
|
||||
paged.items.forEach((project) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "pick" + (String(project.id) === String(selectedId) ? " on" : "");
|
||||
tr.innerHTML = "<td></td><td></td><td></td><td class=\"num\"></td>";
|
||||
@@ -130,6 +137,10 @@ function renderList() {
|
||||
tr.addEventListener("click", () => openProject(project.id));
|
||||
body.appendChild(tr);
|
||||
});
|
||||
renderPager(pager, paged, (p) => {
|
||||
projPage = p;
|
||||
renderList();
|
||||
});
|
||||
}
|
||||
|
||||
function renderDetail(project) {
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<thead><tr><th>Drucker</th><th>Datei</th><th>Zustand</th><th>Dauer</th><th>Ist</th><th>Kosten</th><th>Urteil</th><th>Spule</th><th></th></tr></thead>
|
||||
<tbody id="run-rows"></tbody>
|
||||
</table>
|
||||
<div class="pager" id="run-pager" hidden></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
@@ -37,6 +38,7 @@ const states = {
|
||||
};
|
||||
let runs = [];
|
||||
let spools = [];
|
||||
let runPage = 1;
|
||||
|
||||
function fillPrinterFilter() {
|
||||
const select = document.getElementById("run-filter-printer");
|
||||
@@ -82,13 +84,17 @@ function render() {
|
||||
const shown = filteredRuns();
|
||||
if (!runs.length) {
|
||||
body.innerHTML = '<tr><td class="empty" colspan="9"><div class="empty-state">' + icon("list") + '<p>Noch kein Lauf.</p><p class="tiny">„Läufe holen“ liest die Drucker aus.</p></div></td></tr>';
|
||||
renderPager(document.getElementById("run-pager"), null, () => {});
|
||||
return;
|
||||
}
|
||||
if (!shown.length) {
|
||||
body.innerHTML = '<tr><td class="empty" colspan="9"><div class="empty-state">' + icon("filter") + '<p>Kein Lauf passt zum Filter.</p></div></td></tr>';
|
||||
renderPager(document.getElementById("run-pager"), null, () => {});
|
||||
return;
|
||||
}
|
||||
shown.forEach((run) => {
|
||||
const paged = pageSlice(shown, runPage, DEFAULT_PAGE_SIZE);
|
||||
runPage = paged.page;
|
||||
paged.items.forEach((run) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "pick";
|
||||
tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>";
|
||||
@@ -233,16 +239,21 @@ function render() {
|
||||
}
|
||||
body.appendChild(tr);
|
||||
});
|
||||
renderPager(document.getElementById("run-pager"), paged, (p) => {
|
||||
runPage = p;
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
async function load() {
|
||||
[runs, spools] = await Promise.all([api("/laeufe"), api("/spulen")]);
|
||||
[runs, spools] = await Promise.all([api("/laeufe?limit=500"), api("/spulen")]);
|
||||
runPage = 1;
|
||||
render();
|
||||
}
|
||||
|
||||
["run-filter-printer", "run-filter-verdict", "run-filter-spool"].forEach((id) => {
|
||||
document.getElementById(id).addEventListener("change", render);
|
||||
document.getElementById(id).addEventListener("input", render);
|
||||
document.getElementById(id).addEventListener("change", () => { runPage = 1; render(); });
|
||||
document.getElementById(id).addEventListener("input", () => { runPage = 1; render(); });
|
||||
});
|
||||
|
||||
document.getElementById("sync").addEventListener("click", async () => {
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
</thead>
|
||||
<tbody id="q-rows"></tbody>
|
||||
</table>
|
||||
<div class="pager" id="q-pager" hidden></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
@@ -45,6 +46,7 @@ const STATUS_LABEL = {
|
||||
let jobs = [];
|
||||
let liveDirect = [];
|
||||
let filter = "";
|
||||
let qPage = 1;
|
||||
|
||||
function targetOf(job) {
|
||||
return job.printer_name || job.host || (job.family_label ? "nächster " + job.family_label : "nächster freier");
|
||||
@@ -86,6 +88,7 @@ function render() {
|
||||
});
|
||||
const body = document.getElementById("q-rows");
|
||||
const shown = shownJobs();
|
||||
const pager = document.getElementById("q-pager");
|
||||
if (!shown.length) {
|
||||
const hint = jobs.length
|
||||
? "Nichts in dieser Ansicht."
|
||||
@@ -97,10 +100,13 @@ function render() {
|
||||
: "";
|
||||
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("queue") + '<p>' + hint + '</p>' + cta + '</div></td></tr>';
|
||||
document.getElementById("q-note").textContent = "";
|
||||
renderPager(pager, null, () => {});
|
||||
return;
|
||||
}
|
||||
const waiting = jobs.filter((j) => j.status === "waiting");
|
||||
body.innerHTML = shown.map((job) => {
|
||||
const paged = pageSlice(shown, qPage, DEFAULT_PAGE_SIZE);
|
||||
qPage = paged.page;
|
||||
body.innerHTML = paged.items.map((job) => {
|
||||
const st = job.status;
|
||||
if (job.direct) {
|
||||
return `<tr data-direct="${escapeHtml(job.host)}">
|
||||
@@ -141,17 +147,22 @@ function render() {
|
||||
</tr>`;
|
||||
}).join("");
|
||||
document.getElementById("q-note").textContent = shown.length === 1 ? "1 Eintrag" : shown.length + " Einträge";
|
||||
renderPager(pager, paged, (p) => {
|
||||
qPage = p;
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [queue, active] = await Promise.all([
|
||||
api("/druck-queue?limit=120"),
|
||||
api("/druck-queue?limit=200"),
|
||||
api("/laeuft").catch(() => []),
|
||||
]);
|
||||
jobs = queue || [];
|
||||
const queuedHosts = new Set(jobs.filter((j) => j.status === "running").map((j) => j.host));
|
||||
liveDirect = (active || []).filter((p) => p.host && !queuedHosts.has(p.host));
|
||||
qPage = 1;
|
||||
render();
|
||||
} catch (error) {
|
||||
document.getElementById("q-rows").innerHTML =
|
||||
@@ -163,6 +174,7 @@ document.getElementById("q-filter").addEventListener("click", (event) => {
|
||||
const btn = event.target.closest("[data-status]");
|
||||
if (!btn) return;
|
||||
filter = btn.dataset.status || "";
|
||||
qPage = 1;
|
||||
render();
|
||||
});
|
||||
document.getElementById("q-refresh").addEventListener("click", load);
|
||||
|
||||
Reference in new issue
Block a user