Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1eb87ba25 | ||
|
|
5166ed6530 | ||
|
|
f034c5e626 | ||
|
|
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,46 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [3.5.2] – 2026-10-21
|
||||
|
||||
### Geändert
|
||||
|
||||
- **Wenig Rest:** ACE oder Spule unter dem Dateigewicht warnt unter Dateien, bricht Start und Warteschlange nicht mehr ab. Falsche Sorte bleibt ein Wartegrund.
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Queue „Nochmal — Start abgebrochen (Neustart)“ während der Drucker anläuft:** Nach dem Upload bleibt der Auftrag in `await_print`, bis Moonraker druckt. Die 45-Sekunden-Regel gilt nur noch, wenn der Upload-Thread weg ist. Wand und Seitenleiste zeigen „startet“ und dass es etwa eine Minute dauern kann.
|
||||
|
||||
## [3.5.1] – 2026-10-20
|
||||
|
||||
### Geändert
|
||||
|
||||
- **MCP:** Server-Anweisung für Clients (Ablauf, confirm, Filament-Pause, Platten-Quittung, kein G-code), damit der Agent die Tools in der richtigen Reihenfolge nutzt.
|
||||
- **Wand:** „Aus der Warteschlange“-Strip nur noch bei Ablauf (mit Schritten); einfacher Queue-Druck zeigt nur DRUCKT.
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Queue zu früh „Fertig.“:** Nach Start bleibt Phase `starting`, bis Moonraker `printing` meldet — alter `complete`/`standby` schließt den Job nicht mehr.
|
||||
|
||||
## [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 +52,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.2-e85d04?style=flat-square" alt="Version 3.5.2"></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": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ACE und Spulen
|
||||
|
||||
Stand **18.10.2026**. Mehrfarb-Drucke brauchen vor dem Start eine Zuordnung Datei-Farbe → ACE-Platz und eine passende Spule am Drucker.
|
||||
Stand **21.10.2026**. Mehrfarb-Drucke brauchen vor dem Start eine Zuordnung Datei-Farbe → ACE-Platz und eine passende Spule am Drucker.
|
||||
|
||||

|
||||
|
||||
@@ -44,7 +44,7 @@ Nach einem Grotti-Update mit ACE-Farben: unter Dateien **neu lesen**, damit alte
|
||||
| „T… fehlt in der Map“ | Datei neu lesen, Zuordnung erneut setzen (Slicer-T ≠ ACE-Platz) |
|
||||
| **invalid filament id** (10011700) | Alte Grotti-Version ohne T-Verdichtung, oder Map nicht neu gesetzt. Grotti neu starten, Datei neu lesen, nochmal senden. |
|
||||
| Material-Warnung | Eingelegte Sorte ≠ Datei. Der Start geht trotzdem. PA12-CF steht in Grotti so, an den ACE geht PA6-CF. |
|
||||
| Rest zu gering | Andere Spule oder leichteres Teil |
|
||||
| Rest zu gering | Warnung unter Dateien („nur … g, Datei braucht … g“). Der Start geht trotzdem; leer pausiert der Drucker. |
|
||||
| ACE blinkt nicht / kein Wechsel | Spule und Platz prüfen, ggf. Druck neu starten |
|
||||
| Drucker hängt kurz nach Multi-Color-Start | Oft falsche Map (T10 gemappt als Platz 0). Neu lesen + Zuordnung. |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# MCP-Server
|
||||
|
||||
Stand **15.10.2026**. Agenten steuern Grotti nur über die HTTP-API mit einem API-Token.
|
||||
Stand **19.10.2026**. Agenten steuern Grotti nur über die HTTP-API mit einem API-Token.
|
||||
|
||||
Beim Connect bekommt der Client die Server-Anweisung aus `grotti/mcp_server.py` (`INSTRUCTIONS`): Deutsch antworten, Host und Datei-id nachschlagen, erst lesen dann schreiben, `confirm=true` nur auf klaren Wunsch, Warteschlange vor Sofortstart, Filament-Pause nicht blind fortsetzen, `clear_bed` ist nur eine Quittung, kein G-code und kein Firmware-Restart.
|
||||
|
||||
## Start
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+5
-1
@@ -255,13 +255,17 @@ def build(conn, watcher_latest, printers=None, send_jobs=None):
|
||||
live_by_host[host]["display_step_index"] = job.get("display_step_index")
|
||||
live_by_host[host]["step_index"] = job.get("step_index")
|
||||
else:
|
||||
phase = (job.get("phase") or "").lower()
|
||||
starting = phase in ("starting", "await_print")
|
||||
running.append({
|
||||
"host": host,
|
||||
"name": job.get("printer_name") or host,
|
||||
"state": "running",
|
||||
"state": "starting" if starting else "running",
|
||||
"starting": starting,
|
||||
"progress": job.get("progress") or 0,
|
||||
"remain": job.get("remain") or 0,
|
||||
"filename": job.get("filename") or "",
|
||||
"message": job.get("message") or "",
|
||||
"queue_id": job.get("id"),
|
||||
"is_ablauf": bool(job.get("is_ablauf")),
|
||||
})
|
||||
|
||||
+2
-38
@@ -415,44 +415,8 @@ def sync_history(conn, cfg):
|
||||
|
||||
|
||||
def assert_filament_enough(conn, file_row, spool_id=None, ace_map=None, host=""):
|
||||
"""Dateien-Gewicht gegen Spule oder ACE-Plätze prüfen."""
|
||||
need = float((file_row or {}).get("weight_g") or 0)
|
||||
if need <= 0:
|
||||
return
|
||||
mapping = acm.normalize_mapping(ace_map)
|
||||
if mapping and host:
|
||||
leftovers = []
|
||||
for item in mapping:
|
||||
slot = int(item["ams_index"]) + 1
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT remaining_g, name FROM spools
|
||||
WHERE printer_host = %s AND ace_slot = %s
|
||||
ORDER BY id LIMIT 1
|
||||
""",
|
||||
(host, slot),
|
||||
).fetchone()
|
||||
if row:
|
||||
leftovers.append(float(row["remaining_g"] or 0))
|
||||
if leftovers and sum(leftovers) + 0.5 < need:
|
||||
raise GrottiError(
|
||||
"ACE-Plätze haben nur %.0f g, Datei braucht %.0f g."
|
||||
% (sum(leftovers), need),
|
||||
"spule",
|
||||
)
|
||||
return
|
||||
if not spool_id:
|
||||
return
|
||||
spool = store.get_spool_row(conn, spool_id)
|
||||
if not spool:
|
||||
return
|
||||
left = float(spool["remaining_g"] or 0)
|
||||
if left + 0.5 < need:
|
||||
raise GrottiError(
|
||||
"Spule „%s“ hat nur %.0f g, Datei braucht %.0f g."
|
||||
% (spool["name"], left, need),
|
||||
"spule",
|
||||
)
|
||||
"""Zu wenig Rest bricht den Start nicht ab. Die Oberfläche warnt vorher."""
|
||||
return
|
||||
|
||||
|
||||
def prepare_send(conn, file_id, host, confirm, override, spool_id, ace_map=None):
|
||||
|
||||
@@ -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)
|
||||
+52
-13
@@ -57,6 +57,51 @@ def _write_guard():
|
||||
raise RuntimeError("MCP läuft im Read-only-Modus (GROTTI_MCP_READ_ONLY).")
|
||||
|
||||
|
||||
# Wird beim Connect an den Client gegeben (Claude, Cursor, …).
|
||||
INSTRUCTIONS = """\
|
||||
Grotti steuert eine 3D-Drucker-Farm (Anycubic Kobra, Klipper/Moonraker).
|
||||
Antworte dem Nutzer auf Deutsch. Handle nur über diese Tools.
|
||||
|
||||
Identität
|
||||
- Drucker über das Feld host (IP). Den Namen nur zur Anzeige nutzen. Hosts nie raten: erst list_printers oder get_printer.
|
||||
- Dateien über die numerische id aus list_files (Feld id), nicht über den Dateinamen allein.
|
||||
- Vor jedem Schreibzugriff den aktuellen Stand lesen.
|
||||
|
||||
Lesen
|
||||
- Farm: list_printers (live=true). Ein Drucker: get_printer(host).
|
||||
- Läuft: list_running. Warteschlange: list_queue. Ereignisse: list_events.
|
||||
- Bibliothek: list_files. Spulen: list_spools. Filament-Fehlmenge: bedarf_lesen.
|
||||
- Kundenaufträge: auftraege_lesen. Verschleißteile: wartung_lesen.
|
||||
|
||||
Zustand (Feld state)
|
||||
- printing: druckt. paused: Pause. standby: frei. complete: fertig, Platte oft noch belegt.
|
||||
- error: Fehler, nicht einfach neu starten. offline: nicht erreichbar.
|
||||
- progress ist 0…1. filament_empty oder pause_reason bei Pause heißt Filament leer.
|
||||
Dann dem Nutzer sagen, die neue Spule in Grotti dem Drucker zuzuweisen, und erst danach resume_printer.
|
||||
Eine Filament-Pause nicht blind fortsetzen.
|
||||
|
||||
Schreiben
|
||||
confirm=true nur setzen, wenn der Nutzer diese Aktion in diesem Zug klar will. Nicht vorauseilend bestätigen.
|
||||
- enqueue_when_free: Datei in die Warteschlange. host leer = Grotti wählt den nächsten Drucker mit passender Sorte. host gesetzt = dieser Drucker, sobald er frei ist und die Platte klar ist. Das ist der normale Weg.
|
||||
- enqueue_print: sofort senden und starten. Nur wenn der Nutzer einen Sofortstart auf einem genannten freien Drucker will (state standby, Platte frei). Große Dateien brauchen oft etwa eine Minute bis zum Start.
|
||||
- Den Material-Match macht Grotti (PLA Meta ist nicht Matte und nicht Silk). Eine falsche Maschine nicht erzwingen. Eine Ablehnung wörtlich weitergeben.
|
||||
- bed_leveling=true nur auf ausdrücklichen Wunsch (Bettnetz / Auto-Level).
|
||||
- pause_printer und resume_printer während des Drucks, ohne confirm.
|
||||
- cancel_printer bricht den Druck ab und braucht confirm=true. Bei Unklarheit erst nachfragen.
|
||||
- clear_bed ist nur die Quittung „Platte ist leer“ und schiebt nichts physisch. Erst aufrufen, wenn der Nutzer sagt, das Teil ist herunter. Ohne diese Quittung startet der nächste „wenn frei“-Job nicht.
|
||||
- steer_temp (Düse und Bett in °C), steer_speed (Prozent), steer_fan (0–100): nur diese Werte. Kein freier G-code.
|
||||
|
||||
Grenzen
|
||||
- Keine G-code-Konsole, kein Firmware-Restart, kein E-Stop, keine Benutzer- oder Token-Verwaltung. Diese Lücken nicht umgehen.
|
||||
- Im Read-only-Modus (GROTTI_MCP_READ_ONLY) schlagen Schreib-Tools fehl. Das sagen und nicht wiederholen.
|
||||
- Gesperrte Drucker und ausgeschaltete Automatik starten keine Warteschlangen-Jobs. Den Fehler nennen.
|
||||
|
||||
Antworten
|
||||
- Kurz und konkret: Druckername, host, Datei, Zustand, was du getan hast.
|
||||
- Den Fehlertext von Grotti zitieren. Keinen Workaround außerhalb der Tools erfinden.
|
||||
"""
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
@@ -67,23 +112,17 @@ def main():
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
server = FastMCP(
|
||||
"grotti",
|
||||
instructions=(
|
||||
"Grotti steuert die 3D-Drucker-Farm. Destruktive Aktionen brauchen "
|
||||
"confirm=true. Kein freier G-code, kein Firmware-Restart."
|
||||
),
|
||||
)
|
||||
server = FastMCP("grotti", instructions=INSTRUCTIONS)
|
||||
|
||||
@server.tool()
|
||||
def list_printers(live: bool = True) -> str:
|
||||
"""Druckerliste mit optionalem Live-Zustand."""
|
||||
"""Alle Drucker. live=true liefert Zustand, Temps, Fortschritt, Dateiname. Host ist die IP."""
|
||||
q = "?live=1" if live else "?live=0"
|
||||
return json.dumps(_api("GET", "/drucker" + q), ensure_ascii=False, indent=2)
|
||||
|
||||
@server.tool()
|
||||
def get_printer(host: str) -> str:
|
||||
"""Einen Drucker anhand Host/IP aus der Live-Liste."""
|
||||
"""Einen Drucker anhand host (IP) aus der Live-Liste. Host vorher über list_printers holen."""
|
||||
rows = _api("GET", "/drucker?live=1") or []
|
||||
for row in rows:
|
||||
if row.get("host") == host:
|
||||
@@ -115,7 +154,7 @@ def main():
|
||||
|
||||
@server.tool()
|
||||
def list_files(limit: int = 50) -> str:
|
||||
"""Dateien in der Bibliothek."""
|
||||
"""Dateien in der Bibliothek. Die numerische id ist die file_id fürs Einreihen und Starten."""
|
||||
rows = _api("GET", "/dateien") or []
|
||||
return json.dumps(rows[: max(1, min(limit, 200))], ensure_ascii=False, indent=2)
|
||||
|
||||
@@ -181,7 +220,7 @@ def main():
|
||||
|
||||
@server.tool()
|
||||
def clear_bed(host: str, confirm: bool = False) -> str:
|
||||
"""Platte als geräumt quittieren (kein physisches Schieben)."""
|
||||
"""Quittung, dass der Nutzer die Platte geleert hat. Schiebt nichts. confirm muss true sein. Danach kann die Warteschlange den nächsten Job starten."""
|
||||
_write_guard()
|
||||
if not confirm:
|
||||
raise RuntimeError("Quittierung braucht confirm=true.")
|
||||
@@ -201,7 +240,7 @@ def main():
|
||||
confirm: bool = False,
|
||||
bed_leveling: bool = False,
|
||||
) -> str:
|
||||
"""Datei in die Warteschlange („wenn frei“). confirm muss true sein."""
|
||||
"""Datei in die Warteschlange. host leer: Grotti wählt den nächsten Drucker mit passender Sorte. host gesetzt: dieser Drucker, sobald frei und Platte klar. confirm muss true sein. Normaler Weg zum Drucken."""
|
||||
_write_guard()
|
||||
if not confirm:
|
||||
raise RuntimeError("Einreihen braucht confirm=true.")
|
||||
@@ -220,7 +259,7 @@ def main():
|
||||
confirm: bool = False,
|
||||
bed_leveling: bool = False,
|
||||
) -> str:
|
||||
"""Sofort senden und starten. confirm muss true sein."""
|
||||
"""Sofort an diesen host senden und starten. Nur bei freiem Drucker (standby, Platte frei) und klarem Sofortstart-Wunsch. confirm muss true sein."""
|
||||
_write_guard()
|
||||
if not confirm:
|
||||
raise RuntimeError("Start braucht confirm=true.")
|
||||
|
||||
+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",
|
||||
|
||||
+92
-15
@@ -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", {})
|
||||
@@ -28,7 +30,9 @@ BUSY = {"printing", "paused", "heating", "error"}
|
||||
KEEPALIVE_S = 120
|
||||
# Nach Start ohne print_active: Upload/MQTT/LeviQ brauchen Zeit, aber nicht ewig
|
||||
PRINT_START_TIMEOUT_S = 600
|
||||
# Einfacher Queue-Start ohne Worker (typisch Grotti-Neustart)
|
||||
# Upload-Worker weg (typisch Grotti-Neustart mitten im Hochladen).
|
||||
# Nach erfolgreichem Senden ist die Phase await_print — dort gilt das nicht,
|
||||
# sonst wird ein noch anlaufender Druck als „Neustart“ neu eingereiht.
|
||||
SIMPLE_START_STUCK_S = 45
|
||||
|
||||
|
||||
@@ -111,7 +115,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 +211,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 +282,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 +423,19 @@ class PrintQueueDispatcher:
|
||||
from grotti import orders
|
||||
|
||||
orders.attach_order_item_to_live_run(wconn, host, name, oid)
|
||||
# await_print: Datei ist raus, Drucker meldet printing oft erst
|
||||
# nach einer Minute. Nicht bei starting lassen — sonst gilt nach
|
||||
# 45 s der Neustart-Retry und der zweite Start trifft „druckt schon“.
|
||||
store.update_print_queue(
|
||||
wconn,
|
||||
item_id,
|
||||
{
|
||||
"status": "done",
|
||||
"message": "Gestartet auf %s: %s" % (printer_copy["name"], name),
|
||||
"status": "running",
|
||||
"message": "Datei ist auf %s. Druckstart kann etwa eine Minute dauern."
|
||||
% printer_copy["name"],
|
||||
"host": host,
|
||||
"phase": "",
|
||||
"phase_started_at": None,
|
||||
"phase": "await_print",
|
||||
"phase_started_at": "now",
|
||||
},
|
||||
)
|
||||
self._event(
|
||||
@@ -477,6 +487,73 @@ 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 in ("starting", "await_print"):
|
||||
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 == "await_print" and self._print_start_timed_out(item):
|
||||
store.update_print_queue(
|
||||
conn,
|
||||
item["id"],
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Druckstart-Timeout: Drucker hat nach %d min nicht angefangen."
|
||||
% (PRINT_START_TIMEOUT_S // 60),
|
||||
"phase": "",
|
||||
"phase_started_at": None,
|
||||
},
|
||||
)
|
||||
return
|
||||
# complete/standby vom vorherigen Auftrag: noch warten, nicht „Fertig.“
|
||||
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)
|
||||
|
||||
+25
-22
@@ -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,33 +2836,32 @@ 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):
|
||||
"""Höher = besser. -1 = warten (falsche Sorte mit Profil, oder zu wenig Rest)."""
|
||||
"""Höher = besser. -1 = warten (falsche Sorte mit Profil).
|
||||
|
||||
Zu wenig Rest blockiert nicht; genug Rest gibt Zusatzpunkte.
|
||||
"""
|
||||
need, weight = _file_needs(conn, file_id)
|
||||
have = _spool_material_set(conn, host)
|
||||
if not need:
|
||||
@@ -2873,11 +2886,6 @@ def material_fit_score(conn, host, file_id):
|
||||
score += 5
|
||||
if weight and best["remaining_g"] + 1e-6 >= weight:
|
||||
score += 5
|
||||
elif weight and all(h["remaining_g"] + 1e-6 < weight for h in matched):
|
||||
return -1, "braucht %s (%s g fehlen)" % (
|
||||
_need_label(n),
|
||||
int(max(0, weight - max(h["remaining_g"] for h in matched))),
|
||||
)
|
||||
return score, ""
|
||||
|
||||
have_types = {h["material_type"] for h in have if h["material_type"]}
|
||||
@@ -2890,11 +2898,6 @@ def material_fit_score(conn, host, file_id):
|
||||
score += 5
|
||||
if weight and any(h["remaining_g"] + 1e-6 >= weight for h in matched):
|
||||
score += 5
|
||||
elif weight and all(h["remaining_g"] + 1e-6 < weight for h in matched):
|
||||
return -1, "braucht %s (%s g fehlen)" % (
|
||||
_need_label(n),
|
||||
int(max(0, weight - max(h["remaining_g"] for h in matched))),
|
||||
)
|
||||
return score, ""
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
|
||||
|
||||
__version__ = "3.4.0"
|
||||
__version__ = "3.5.2"
|
||||
+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():
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ const CSRF = document.querySelector('meta[name="csrf"]')?.content || "";
|
||||
const STATE_LABELS = {
|
||||
printing: "druckt", paused: "pausiert", complete: "fertig", standby: "bereit",
|
||||
heating: "heizt", error: "Fehler", cancelled: "abgebrochen", offline: "offline",
|
||||
waiting: "wartet", running: "läuft",
|
||||
waiting: "wartet", running: "läuft", starting: "startet",
|
||||
};
|
||||
|
||||
function escapeHtml(value) {
|
||||
|
||||
+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();
|
||||
};
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<dt>Auftrag</dt><dd>Ein Eintrag in der Warteschlange: eine Datei für einen Drucker oder für den nächsten freien.</dd>
|
||||
<dt>Ablauf</dt><dd>Auftrag mit mehreren Schritten und Wiederholungen. Nur Kobra 3.</dd>
|
||||
<dt>Dieser Drucker</dt><dd>Frei und Platte klar → sofort; sonst Warteschlange für genau diesen Drucker.</dd>
|
||||
<dt>Nächster passender</dt><dd>Grotti claimt den nächsten freien Drucker mit passender Katalog-Sorte und Restfilament.</dd>
|
||||
<dt>Nächster passender</dt><dd>Grotti claimt den nächsten freien Drucker mit passender Katalog-Sorte. Zu wenig Rest warnt nur und hält den Start nicht auf.</dd>
|
||||
<dt>Platte belegt</dt><dd>Nach jedem Druck gesetzt. Blockiert den nächsten Auftrag auf diesem Drucker.</dd>
|
||||
<dt>Alert</dt><dd>Wichtiges Ereignis (Fertig, Fehler, Abbruch, offline). Steht in der Seitenleiste und geht auf Wunsch an Telegram.</dd>
|
||||
<dt>Ereignis</dt><dd>Jeder Eintrag unter Aktivität. Ablauf-Schritte und Starts sind Ereignisse, aber keine Alerts.</dd>
|
||||
|
||||
@@ -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>
|
||||
@@ -160,8 +160,8 @@
|
||||
{% endif %}
|
||||
<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='api.js') }}?v=33"></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) {}
|
||||
@@ -315,9 +315,9 @@ function runLineHtml({ name, progress, remain, filename, tipExtra, cancelId, hos
|
||||
const pct = Math.max(0, Math.min(100, Math.round((Number(progress) || 0) * 100)));
|
||||
const finish = remain > 0 ? finishClock(remain) : "";
|
||||
const bits = [name];
|
||||
if (pct > 0 || remain > 0) bits.push(pct + " %");
|
||||
if (finish) bits.push(finish);
|
||||
if (tipExtra) bits.push(tipExtra);
|
||||
else if (pct > 0 || remain > 0) bits.push(pct + " %");
|
||||
if (finish) bits.push(finish);
|
||||
const tip = [
|
||||
filename ? jobLabel(filename) : "",
|
||||
remain > 0 ? "noch " + hours(remain) : "",
|
||||
@@ -375,9 +375,10 @@ function renderLiveChips({ actionItems, running, activeSends, alertCount, etaTex
|
||||
const pct = Math.max(0, Math.min(100, Math.round((Number(p.progress) || 0) * 100)));
|
||||
const finish = p.remain > 0 ? finishClock(p.remain) : "";
|
||||
const pausedFil = p.state === "paused" && (p.filament_empty || p.pause_reason);
|
||||
const starting = p.starting || p.state === "starting";
|
||||
const label = [
|
||||
shortPrinter(p.name || p.host),
|
||||
pausedFil ? "Material!" : (pct ? pct + "%" : ""),
|
||||
pausedFil ? "Material!" : (starting ? "startet" : (pct ? pct + "%" : "")),
|
||||
finish,
|
||||
].filter(Boolean).join(" · ");
|
||||
const tip = [p.name || "", p.pause_reason || ""].filter(Boolean).join(" — ");
|
||||
@@ -479,6 +480,7 @@ async function refreshJetzt() {
|
||||
progress: p.progress,
|
||||
remain: p.remain,
|
||||
filename: p.filename,
|
||||
tipExtra: (p.starting || p.state === "starting") ? "wird gestartet" : "",
|
||||
cancelId: p.queue_id,
|
||||
})).join(""),
|
||||
);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
+40
-12
@@ -322,13 +322,22 @@ function actionsHtml(state, printer) {
|
||||
function flowHtml(printer) {
|
||||
const job = printer.queue_running;
|
||||
const bits = [];
|
||||
if (job) {
|
||||
const pending = startingJob(printer);
|
||||
if (pending) {
|
||||
const name = jobName(pending.filename || "") || "Datei";
|
||||
bits.push(`<div class="strip flow">
|
||||
<div class="flow-head">${icon("play")}<span class="grow">Startet ${escapeHtml(name)}</span></div>
|
||||
<div class="flow-msg">Die Datei ist auf dem Drucker. Bis der Druck läuft, kann es etwa eine Minute dauern — der Auftrag bleibt hier.</div>
|
||||
</div>`);
|
||||
}
|
||||
// Nur Ablauf braucht den Strip (Schritte). Einfacher Queue-Druck: Karte zeigt schon DRUCKT.
|
||||
if (job && job.is_ablauf) {
|
||||
const rep = repeatText(job);
|
||||
const title = job.is_ablauf ? "Ablauf" + (rep ? " " + rep : "") : "Aus der Warteschlange";
|
||||
const title = "Ablauf" + (rep ? " " + rep : "");
|
||||
bits.push(`<div class="strip flow">
|
||||
<div class="flow-head">${icon("queue")}<span class="grow">${escapeHtml(title)}</span>
|
||||
<button type="button" class="icon-btn danger" data-cancel-run="${job.id}" title="Ablauf abbrechen">${icon("x")}</button></div>
|
||||
${job.is_ablauf ? stepsHtml(job.step_labels, job.display_step_index != null ? job.display_step_index : job.step_index) : ""}
|
||||
${stepsHtml(job.step_labels, job.display_step_index != null ? job.display_step_index : job.step_index)}
|
||||
${job.message ? `<div class="flow-msg">${escapeHtml(phaseText(job))}</div>` : ""}
|
||||
</div>`);
|
||||
}
|
||||
@@ -510,6 +519,16 @@ function steerPanelHtml(printer) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function startingJob(printer) {
|
||||
const job = printer && printer.queue_running;
|
||||
if (!job || job.is_ablauf) return null;
|
||||
const phase = String(job.phase || "").toLowerCase();
|
||||
if (phase !== "starting" && phase !== "await_print") return null;
|
||||
const state = shownState(printer);
|
||||
if (state === "printing" || state === "paused" || state === "heating") return null;
|
||||
return job;
|
||||
}
|
||||
|
||||
function heroHtml(printer, state) {
|
||||
const remain = hours(printer.remain);
|
||||
const eta = finishClock(printer.remain);
|
||||
@@ -531,6 +550,11 @@ function heroHtml(printer, state) {
|
||||
if (state === "offline") {
|
||||
return `<div class="hero idle"><span class="hero-main">nicht erreichbar</span></div>`;
|
||||
}
|
||||
const pending = startingJob(printer);
|
||||
if (pending) {
|
||||
const name = jobName(pending.filename || "") || "Datei";
|
||||
return `<div class="hero"><span class="hero-main"><small>startet</small>${escapeHtml(name)}</span><span class="hero-side">kann eine Minute dauern</span></div>`;
|
||||
}
|
||||
if (state === "complete") {
|
||||
const took = hours(printer.elapsed);
|
||||
const last = jobLabel(printer.filename || "");
|
||||
@@ -540,7 +564,7 @@ function heroHtml(printer, state) {
|
||||
if (state === "cancelled") {
|
||||
return `<div class="hero idle"><span class="hero-main">abgebrochen</span></div>`;
|
||||
}
|
||||
const busyQueue = printer.queue_running ? "Ablauf läuft" : "";
|
||||
const busyQueue = printer.queue_running && printer.queue_running.is_ablauf ? "Ablauf läuft" : "";
|
||||
return `<div class="hero idle"><span class="hero-main">${needsClear(printer) ? "Platte belegt" : "bereit"}</span>${busyQueue ? `<span class="hero-side">${busyQueue}</span>` : ""}</div>`;
|
||||
}
|
||||
|
||||
@@ -808,13 +832,15 @@ function patchCard(article, printer) {
|
||||
else runningJobs.delete(printer.host);
|
||||
|
||||
const role = (name) => article.querySelector(`[data-role=${name}]`);
|
||||
setHtml(role("chip"), chipHtml(state, labels[state]));
|
||||
const pendingStart = startingJob(printer);
|
||||
setHtml(role("chip"), chipHtml(pendingStart ? "heating" : state, pendingStart ? "startet" : labels[state]));
|
||||
setHtml(role("hero"), heroHtml(printer, state));
|
||||
patchBar(role("bar"), printer, state);
|
||||
setHtml(role("stats"), statsHtml(printer, state));
|
||||
setHtml(role("temps"), tempbarsHtml(printer, state));
|
||||
const jobline = role("jobline");
|
||||
const jobVisible = jobLineVisible(printer, state, shownFile);
|
||||
const startName = pendingStart ? (pendingStart.filename || "") : "";
|
||||
const jobVisible = jobLineVisible(printer, state, shownFile || startName) || !!pendingStart;
|
||||
const hasThumb = !!(jobVisible && printer.preview);
|
||||
if (jobline) {
|
||||
jobline.hidden = !jobVisible;
|
||||
@@ -846,8 +872,8 @@ function patchCard(article, printer) {
|
||||
}
|
||||
const job = role("job");
|
||||
if (job) {
|
||||
setText(job, jobName(shownFile) || "");
|
||||
job.title = shownFile || "";
|
||||
setText(job, jobName(shownFile || startName) || "");
|
||||
job.title = shownFile || startName || "";
|
||||
}
|
||||
setHtml(role("swatches"), swatchesHtml(printer));
|
||||
setHtml(role("flow"), flowHtml(printer));
|
||||
@@ -975,7 +1001,8 @@ function patchDesk(printer) {
|
||||
const nameEl = role("name");
|
||||
if (nameEl) nameEl.textContent = printer.name || printer.host;
|
||||
setHtml(role("badge"), printer.family_label ? `<span class="badge">${escapeHtml(printer.family_label)}</span>` : "");
|
||||
setHtml(role("chip"), chipHtml(state, labels[state]));
|
||||
const pendingStart = startingJob(printer);
|
||||
setHtml(role("chip"), chipHtml(pendingStart ? "heating" : state, pendingStart ? "startet" : labels[state]));
|
||||
setHtml(role("pills"), pillsHtml(printer.host));
|
||||
const mainsail = role("mainsail");
|
||||
if (mainsail) {
|
||||
@@ -988,12 +1015,13 @@ function patchDesk(printer) {
|
||||
setHtml(role("temps"), tempbarsHtml(printer, state));
|
||||
|
||||
const jobline = role("jobline");
|
||||
const jobVisible = jobLineVisible(printer, state, shownFile);
|
||||
const startName = pendingStart ? (pendingStart.filename || "") : "";
|
||||
const jobVisible = jobLineVisible(printer, state, shownFile || startName) || !!pendingStart;
|
||||
if (jobline) jobline.hidden = !jobVisible && !printer.preview;
|
||||
const job = role("job");
|
||||
if (job) {
|
||||
setText(job, jobName(shownFile) || (jobVisible ? "" : "Kein Auftrag"));
|
||||
job.title = shownFile || "";
|
||||
setText(job, jobName(shownFile || startName) || (jobVisible ? "" : "Kein Auftrag"));
|
||||
job.title = shownFile || startName || "";
|
||||
}
|
||||
const preview = role("preview");
|
||||
if (preview) {
|
||||
|
||||
@@ -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)}">
|
||||
@@ -130,9 +136,12 @@ function render() {
|
||||
} else if (st === "waiting" || st === "running") {
|
||||
tools.push(`<button type="button" class="icon-btn danger" data-cancel="${job.id}" title="Abbrechen">${icon("x")}</button>`);
|
||||
}
|
||||
const chipState = st === "running" ? "printing" : st === "waiting" ? "waiting" : st === "error" ? "error" : st === "done" ? "done" : st === "cancelled" || st === "dismissed" ? "dismissed" : "complete";
|
||||
const phase = String(job.phase || "").toLowerCase();
|
||||
const starting = st === "running" && (phase === "starting" || phase === "await_print");
|
||||
const chipState = starting ? "heating" : st === "running" ? "printing" : st === "waiting" ? "waiting" : st === "error" ? "error" : st === "done" ? "done" : st === "cancelled" || st === "dismissed" ? "dismissed" : "complete";
|
||||
const chipLabel = starting ? "startet" : (STATUS_LABEL[st] || st);
|
||||
return `<tr data-id="${job.id}">
|
||||
<td>${chipHtml(chipState, STATUS_LABEL[st] || st)}</td>
|
||||
<td>${chipHtml(chipState, chipLabel)}</td>
|
||||
<td><span class="cell-file" title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</span>${rep ? " · " + escapeHtml(rep) : ""}</td>
|
||||
<td>${job.host ? `<a href="/#${encodeURIComponent(job.host)}">${escapeHtml(targetOf(job))}</a>` : escapeHtml(targetOf(job))}</td>
|
||||
<td>${steps || "—"}</td>
|
||||
@@ -141,17 +150,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 +177,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